diff --git a/src/firebase_functions/identity_fn.py b/src/firebase_functions/identity_fn.py index 0e7131f..aad9bf2 100644 --- a/src/firebase_functions/identity_fn.py +++ b/src/firebase_functions/identity_fn.py @@ -31,7 +31,10 @@ 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 +from firebase_functions.options import ( + IS_NOT_TENANT, # noqa: F401 + AuthOptions, +) @_dataclasses.dataclass(frozen=True) @@ -506,6 +509,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 +538,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 +558,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 +587,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 df8011a..628d256 100644 --- a/src/firebase_functions/options.py +++ b/src/firebase_functions/options.py @@ -37,6 +37,14 @@ """Special configuration value to reset configuration to platform default.""" +class Sentinel(_util.Sentinel): + """Sentinel class for special option configuration values.""" + + +IS_NOT_TENANT = 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 +1070,12 @@ class AuthOptions(EventHandlerOptions): Internal use only. """ - tenant_id: str | Expression[str] | None = None + tenant_id: str | Expression[str] | 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,9 +1084,21 @@ 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 isinstance(self.tenant_id, _util.Sentinel): + if self.tenant_id != IS_NOT_TENANT: + raise ValueError( + f"Invalid sentinel for tenant_id: {self.tenant_id.description!r}. " + "Only IS_NOT_TENANT is supported." + ) + elif self.tenant_id is not None: if self.tenant_id == "": - raise ValueError("AuthOptions: tenant_id cannot be an empty string.") + raise ValueError( + "AuthOptions: tenant_id cannot be an empty string; " + "use IS_NOT_TENANT to match users without a tenant." + ) 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 e074332..6054c93 100644 --- a/src/firebase_functions/private/_identity_fn.py +++ b/src/firebase_functions/private/_identity_fn.py @@ -35,6 +35,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 @@ -509,10 +510,20 @@ 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). """ + # 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 + # before unpacking the CloudEvent payload. + if ( + options is not None + and options.tenant_id == _options.IS_NOT_TENANT + and raw._get_attributes().get("tenantid") is not None + ): + return event = _auth_event_from_ce(raw) _with_init(func)(event) diff --git a/tests/test_identity_fn.py b/tests/test_identity_fn.py index 5826ba4..09ea834 100644 --- a/tests/test_identity_fn.py +++ b/tests/test_identity_fn.py @@ -11,7 +11,7 @@ from flask import Flask, Request from werkzeug.test import EnvironBuilder -from firebase_functions import core, identity_fn, params +from firebase_functions import core, identity_fn, options, params from firebase_functions.private import _identity_fn, serving token_verifier_mock = MagicMock() @@ -356,3 +356,101 @@ def example(event: identity_fn.AuthEvent) -> None: self.assertIsNone(captured_event.data.password_salt) self.assertIsNone(captured_event.data.tokens_valid_after_time) self.assertIsNone(captured_event.data.multi_factor) + + 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 + # before unpacking the payload (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=None, + ) + example(tenant_attr_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"], {}) + + non_tenant_deleted_ce = CloudEvent( + attributes={ + "specversion": "1.0", + "id": "event-del-no-tenant", + "source": "//identitytoolkit.googleapis.com/projects/my-project", + "type": "google.firebase.auth.user.v2.deleted", + "time": "2023-01-01T00:00:00Z", + }, + data={"value": None, "oldValue": {"uid": "deleted-no-tenant"}}, + ) + tenant_deleted_ce = CloudEvent( + attributes={ + "specversion": "1.0", + "id": "event-del-tenant", + "source": "//identitytoolkit.googleapis.com/projects/my-project", + "type": "google.firebase.auth.user.v2.deleted", + "time": "2023-01-01T00:00:00Z", + "tenantid": "some-tenant", + }, + data={"value": None, "oldValue": {"uid": "deleted-with-tenant"}}, + ) + example_deleted(non_tenant_deleted_ce) # type: ignore[arg-type] + example_deleted(tenant_deleted_ce) # type: ignore[arg-type] + self.assertEqual(deleted_uids, ["deleted-no-tenant"]) + + # Rejects other Sentinel instances such as RESET_VALUE + with self.assertRaises(ValueError): + + @identity_fn.on_user_created(tenant_id=options.RESET_VALUE) # type: ignore[arg-type] + def _invalid_sentinel(_event: identity_fn.AuthEvent) -> None: + pass