diff --git a/src/firebase_functions/identity_fn.py b/src/firebase_functions/identity_fn.py index 0246054..632da9d 100644 --- a/src/firebase_functions/identity_fn.py +++ b/src/firebase_functions/identity_fn.py @@ -31,7 +31,7 @@ import firebase_functions.core as _core import firebase_functions.options as _options import firebase_functions.private.util as _util -from firebase_functions.options import AuthOptions # noqa: F401 +from firebase_functions.options import IS_NOT_TENANT, AuthOptions # noqa: F401 @_dataclasses.dataclass(frozen=True) @@ -506,6 +506,8 @@ def on_user_created( ) -> _typing.Callable[[AuthEventCallable], AuthEventCallable]: """ Handles an event that is triggered when a user is created in Firebase Authentication. + Pass ``tenant_id=identity_fn.IS_NOT_TENANT`` to only trigger on users in the + default project (not scoped to any Identity Platform tenant). Example: @@ -533,7 +535,7 @@ def on_user_created_decorator(func: AuthEventCallable): @_functools.wraps(func) def on_user_created_wrapped(raw: _ce.CloudEvent): - return user_event_handler(func, raw) + return user_event_handler(func, raw, options) _util.set_func_endpoint_attr( on_user_created_wrapped, @@ -553,6 +555,8 @@ def on_user_deleted( ) -> _typing.Callable[[AuthEventCallable], AuthEventCallable]: """ Handles an event that is triggered when a user is deleted in Firebase Authentication. + Pass ``tenant_id=identity_fn.IS_NOT_TENANT`` to only trigger on users in the + default project (not scoped to any Identity Platform tenant). Example: @@ -580,7 +584,7 @@ def on_user_deleted_decorator(func: AuthEventCallable): @_functools.wraps(func) def on_user_deleted_wrapped(raw: _ce.CloudEvent): - return user_event_handler(func, raw) + return user_event_handler(func, raw, options) _util.set_func_endpoint_attr( on_user_deleted_wrapped, diff --git a/src/firebase_functions/options.py b/src/firebase_functions/options.py index 2e2786b..ae7824e 100644 --- a/src/firebase_functions/options.py +++ b/src/firebase_functions/options.py @@ -36,6 +36,11 @@ ) """Special configuration value to reset configuration to platform default.""" +IS_NOT_TENANT = _util.Sentinel( + "Constant to represent the absence of a tenant ID in Auth event triggers." +) +"""Constant to represent the absence of a tenant ID in Auth event triggers.""" + class VpcEgressSetting(str, _enum.Enum): """Valid settings for VPC egress.""" @@ -1062,9 +1067,12 @@ class AuthOptions(EventHandlerOptions): Internal use only. """ - tenant_id: str | Expression[str] | None = None + tenant_id: str | Expression[str] | _util.Sentinel | None = None """ An optional tenant ID to scope the trigger to. + If not set, the function triggers on users across all tenants. + Set to ``IS_NOT_TENANT`` to only trigger on users in the default + project (no tenant). """ def _endpoint( @@ -1073,7 +1081,10 @@ def _endpoint( ) -> _manifest.ManifestEndpoint: assert kwargs["event_type"] is not None event_filters: dict[str, str | Expression[str]] = {} - if self.tenant_id is not None: + # Eventarc does not support empty-string filters to match "no tenant". When + # tenant_id is IS_NOT_TENANT (a Sentinel), we omit the Eventarc filter so all + # events are delivered and filter out tenant-scoped events at runtime instead. + if self.tenant_id is not None and not isinstance(self.tenant_id, _util.Sentinel): event_filters["tenantid"] = self.tenant_id endpoint = super()._endpoint( diff --git a/src/firebase_functions/private/_identity_fn.py b/src/firebase_functions/private/_identity_fn.py index 1699748..d6b5c6c 100644 --- a/src/firebase_functions/private/_identity_fn.py +++ b/src/firebase_functions/private/_identity_fn.py @@ -15,6 +15,7 @@ # pylint: disable=protected-access import datetime as _dt +import email.utils as _email_utils import json as _json import re as _re import time as _time @@ -35,6 +36,7 @@ ) from functions_framework import logging as _logging +import firebase_functions.options as _options import firebase_functions.private.token_verifier as _token_verifier import firebase_functions.private.util as _util from firebase_functions.core import _with_init @@ -390,7 +392,8 @@ def _ensure_utc(value: _dt.datetime) -> _dt.datetime: def _parse_auth_timestamp(value: _typing.Any) -> _dt.datetime | None: """ Normalizes Auth event timestamps to timezone-aware UTC datetimes. - Supports epoch milliseconds (int, float, or numeric str) and ISO-8601 strings. + Supports epoch milliseconds (int, float, or numeric str), ISO-8601 strings + (production Eventarc), and RFC-2822 strings (emitted by the Firebase Auth Emulator). """ if isinstance(value, _dt.datetime): return _ensure_utc(value) @@ -403,10 +406,14 @@ def _parse_auth_timestamp(value: _typing.Any) -> _dt.datetime | None: except (OverflowError, OSError, ValueError): return None if isinstance(value, str) and value: - for parser in (_util.timestamp_conversion, _dt.datetime.fromisoformat): + for parser in ( + _util.timestamp_conversion, + _dt.datetime.fromisoformat, + _email_utils.parsedate_to_datetime, + ): try: return _ensure_utc(parser(value)) - except ValueError: + except (ValueError, TypeError): continue return None @@ -611,10 +618,19 @@ def _auth_event_from_ce(raw: _ce.CloudEvent): def user_event_handler( func: _typing.Callable, raw: _ce.CloudEvent, + options: _options.AuthOptions | None = None, ) -> None: """ Runtime handler invoked by functions-framework for v2 Auth triggers (on_user_created and on_user_deleted). """ event = _auth_event_from_ce(raw) + # Eventarc cannot filter for the absence of a tenant attribute, so when + # tenant_id=IS_NOT_TENANT we drop events belonging to any tenant at runtime. + if ( + options is not None + and options.tenant_id == _options.IS_NOT_TENANT + and event.tenant_id is not None + ): + return _with_init(func)(event) diff --git a/tests/test_identity_fn.py b/tests/test_identity_fn.py index 4ba4f0f..294ccd9 100644 --- a/tests/test_identity_fn.py +++ b/tests/test_identity_fn.py @@ -326,3 +326,124 @@ def example(event: identity_fn.AuthEvent) -> None: dt.datetime(2023, 1, 1, 0, 0, 0, tzinfo=dt.timezone.utc), ) self.assertEqual(factors[1].factor_id, "totp") + + def test_on_user_created_is_not_tenant(self) -> None: + called_uids: list[str] = [] + init_calls = 0 + + @core.init + def init_fn() -> None: + nonlocal init_calls + init_calls += 1 + + @identity_fn.on_user_created(tenant_id=identity_fn.IS_NOT_TENANT) + def example(event: identity_fn.AuthEvent) -> None: + called_uids.append(event.data.uid) + + endpoint = getattr(example, "__firebase_endpoint__", None) + assert endpoint is not None + self.assertEqual(endpoint.eventTrigger["eventFilters"], {}) + + rendered_yaml = yaml.safe_load(serving.functions_as_yaml({"example": example})) + self.assertEqual( + rendered_yaml["endpoints"]["example"]["eventTrigger"]["eventFilters"], + {}, + ) + + # Should ignore events with top-level tenantid CloudEvent attribute (and not run @core.init) + tenant_attr_ce = CloudEvent( + attributes={ + "specversion": "1.0", + "id": "event-tenant-attr", + "source": "//identitytoolkit.googleapis.com/projects/my-project", + "type": "google.firebase.auth.user.v2.created", + "time": "2023-01-01T00:00:00Z", + "tenantid": "some-tenant", + }, + data={"value": {"uid": "user-with-tenant-attr"}}, + ) + example(tenant_attr_ce) # type: ignore[arg-type] + self.assertEqual(called_uids, []) + self.assertEqual(init_calls, 0) + + # Should ignore events with tenantId inside the user payload + tenant_data_ce = CloudEvent( + attributes={ + "specversion": "1.0", + "id": "event-tenant-data", + "source": "//identitytoolkit.googleapis.com/projects/my-project", + "type": "google.firebase.auth.user.v2.created", + "time": "2023-01-01T00:00:00Z", + }, + data={"value": {"uid": "user-with-tenant-data", "tenantId": "some-tenant"}}, + ) + example(tenant_data_ce) # type: ignore[arg-type] + self.assertEqual(called_uids, []) + self.assertEqual(init_calls, 0) + + # Should execute for non-tenant events (and run @core.init) + non_tenant_ce = CloudEvent( + attributes={ + "specversion": "1.0", + "id": "event-no-tenant", + "source": "//identitytoolkit.googleapis.com/projects/my-project", + "type": "google.firebase.auth.user.v2.created", + "time": "2023-01-01T00:00:00Z", + }, + data={"value": {"uid": "user-no-tenant"}}, + ) + example(non_tenant_ce) # type: ignore[arg-type] + self.assertEqual(called_uids, ["user-no-tenant"]) + self.assertEqual(init_calls, 1) + + # Also verify on_user_deleted with IS_NOT_TENANT + deleted_uids: list[str] = [] + + @identity_fn.on_user_deleted(tenant_id=identity_fn.IS_NOT_TENANT) + def example_deleted(event: identity_fn.AuthEvent) -> None: + deleted_uids.append(event.data.uid) + + delete_endpoint = getattr(example_deleted, "__firebase_endpoint__", None) + assert delete_endpoint is not None + self.assertEqual(delete_endpoint.eventTrigger["eventFilters"], {}) + + example_deleted(non_tenant_ce) # type: ignore[arg-type] + example_deleted(tenant_attr_ce) # type: ignore[arg-type] + self.assertEqual(deleted_uids, ["user-no-tenant"]) + + def test_auth_emulator_rfc2822_timestamps(self) -> None: + captured_event: identity_fn.AuthEvent | None = None + + @identity_fn.on_user_created() + def example(event: identity_fn.AuthEvent) -> None: + nonlocal captured_event + captured_event = event + + emulator_ce = CloudEvent( + attributes={ + "specversion": "1.0", + "id": "emu-event-1", + "source": "//identitytoolkit.googleapis.com/projects/my-project", + "type": "google.firebase.auth.user.v2.created", + "time": "2023-01-01T00:00:00.000Z", + }, + data={ + "value": { + "uid": "emu-user-1", + "metadata": { + "creationTime": "Sun, 01 Jan 2023 00:00:00 GMT", + "lastSignInTime": "1672662600000", + }, + } + }, + ) + example(emulator_ce) # type: ignore[arg-type] + assert captured_event is not None + self.assertEqual( + captured_event.data.metadata.creation_time, + dt.datetime(2023, 1, 1, 0, 0, 0, tzinfo=dt.timezone.utc), + ) + self.assertEqual( + captured_event.data.metadata.last_sign_in_time, + dt.datetime(2023, 1, 2, 12, 30, 0, tzinfo=dt.timezone.utc), + )