Skip to content

Commit 7aa3ce4

Browse files
feat(identity): support IS_NOT_TENANT filtering for v2 Auth events
1 parent 0845bff commit 7aa3ce4

4 files changed

Lines changed: 115 additions & 6 deletions

File tree

‎src/firebase_functions/identity_fn.py‎

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@
3131
import firebase_functions.core as _core
3232
import firebase_functions.options as _options
3333
import firebase_functions.private.util as _util
34-
from firebase_functions.options import AuthOptions
34+
from firebase_functions.options import IS_NOT_TENANT, AuthOptions # noqa: F401
3535

3636

3737
@_dataclasses.dataclass(frozen=True)
@@ -506,6 +506,8 @@ def on_user_created(
506506
) -> _typing.Callable[[AuthEventCallable], AuthEventCallable]:
507507
"""
508508
Handles an event that is triggered when a user is created in Firebase Authentication.
509+
Pass ``tenant_id=identity_fn.IS_NOT_TENANT`` to only trigger on users in the
510+
default project (not scoped to any Identity Platform tenant).
509511
510512
Example:
511513
@@ -533,7 +535,7 @@ def on_user_created_decorator(func: AuthEventCallable):
533535

534536
@_functools.wraps(func)
535537
def on_user_created_wrapped(raw: _ce.CloudEvent):
536-
return user_event_handler(func, raw)
538+
return user_event_handler(func, raw, options)
537539

538540
_util.set_func_endpoint_attr(
539541
on_user_created_wrapped,
@@ -553,6 +555,8 @@ def on_user_deleted(
553555
) -> _typing.Callable[[AuthEventCallable], AuthEventCallable]:
554556
"""
555557
Handles an event that is triggered when a user is deleted in Firebase Authentication.
558+
Pass ``tenant_id=identity_fn.IS_NOT_TENANT`` to only trigger on users in the
559+
default project (not scoped to any Identity Platform tenant).
556560
557561
Example:
558562
@@ -580,7 +584,7 @@ def on_user_deleted_decorator(func: AuthEventCallable):
580584

581585
@_functools.wraps(func)
582586
def on_user_deleted_wrapped(raw: _ce.CloudEvent):
583-
return user_event_handler(func, raw)
587+
return user_event_handler(func, raw, options)
584588

585589
_util.set_func_endpoint_attr(
586590
on_user_deleted_wrapped,

‎src/firebase_functions/options.py‎

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,11 @@
3636
)
3737
"""Special configuration value to reset configuration to platform default."""
3838

39+
IS_NOT_TENANT = _util.Sentinel(
40+
"Constant to represent the absence of a tenant ID in Auth event triggers."
41+
)
42+
"""Constant to represent the absence of a tenant ID in Auth event triggers."""
43+
3944

4045
class VpcEgressSetting(str, _enum.Enum):
4146
"""Valid settings for VPC egress."""
@@ -1062,9 +1067,12 @@ class AuthOptions(EventHandlerOptions):
10621067
Internal use only.
10631068
"""
10641069

1065-
tenant_id: str | Expression[str] | None = None
1070+
tenant_id: str | Expression[str] | _util.Sentinel | None = None
10661071
"""
10671072
An optional tenant ID to scope the trigger to.
1073+
If not set, the function triggers on users across all tenants.
1074+
Set to ``IS_NOT_TENANT`` to only trigger on users in the default
1075+
project (no tenant).
10681076
"""
10691077

10701078
def _endpoint(
@@ -1073,7 +1081,10 @@ def _endpoint(
10731081
) -> _manifest.ManifestEndpoint:
10741082
assert kwargs["event_type"] is not None
10751083
event_filters: dict[str, str | Expression[str]] = {}
1076-
if self.tenant_id is not None:
1084+
# Eventarc does not support empty-string filters to match "no tenant". When
1085+
# tenant_id is IS_NOT_TENANT (a Sentinel), we omit the Eventarc filter so all
1086+
# events are delivered and filter out tenant-scoped events at runtime instead.
1087+
if self.tenant_id is not None and not isinstance(self.tenant_id, _util.Sentinel):
10771088
event_filters["tenantid"] = self.tenant_id
10781089

10791090
endpoint = super()._endpoint(

‎src/firebase_functions/private/_identity_fn.py‎

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@
3535
)
3636
from functions_framework import logging as _logging
3737

38+
import firebase_functions.options as _options
3839
import firebase_functions.private.token_verifier as _token_verifier
3940
import firebase_functions.private.util as _util
4041
from firebase_functions.core import _with_init
@@ -406,7 +407,7 @@ def _parse_auth_timestamp(value: _typing.Any) -> _dt.datetime | None:
406407
for parser in (_util.timestamp_conversion, _dt.datetime.fromisoformat):
407408
try:
408409
return _ensure_utc(parser(value))
409-
except ValueError:
410+
except (ValueError, TypeError):
410411
continue
411412
return None
412413

@@ -557,10 +558,19 @@ def _auth_event_from_ce(raw: _ce.CloudEvent):
557558
def user_event_handler(
558559
func: _typing.Callable,
559560
raw: _ce.CloudEvent,
561+
options: _options.AuthOptions | None = None,
560562
) -> None:
561563
"""
562564
Runtime handler invoked by functions-framework for v2 Auth triggers
563565
(on_user_created and on_user_deleted).
564566
"""
565567
event = _auth_event_from_ce(raw)
568+
# Eventarc cannot filter for the absence of a tenant attribute, so when
569+
# tenant_id=IS_NOT_TENANT we drop events belonging to any tenant at runtime.
570+
if (
571+
options is not None
572+
and options.tenant_id == _options.IS_NOT_TENANT
573+
and event.tenant_id is not None
574+
):
575+
return
566576
_with_init(func)(event)

‎tests/test_identity_fn.py‎

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -350,3 +350,87 @@ def example(event: identity_fn.AuthEvent) -> None:
350350
self.assertIsNone(captured_event.data.password_salt)
351351
self.assertIsNone(captured_event.data.tokens_valid_after_time)
352352
self.assertIsNone(captured_event.data.multi_factor)
353+
354+
def test_on_user_created_is_not_tenant(self) -> None:
355+
called_uids: list[str] = []
356+
init_calls = 0
357+
358+
@core.init
359+
def init_fn() -> None:
360+
nonlocal init_calls
361+
init_calls += 1
362+
363+
@identity_fn.on_user_created(tenant_id=identity_fn.IS_NOT_TENANT)
364+
def example(event: identity_fn.AuthEvent) -> None:
365+
called_uids.append(event.data.uid)
366+
367+
endpoint = getattr(example, "__firebase_endpoint__", None)
368+
assert endpoint is not None
369+
self.assertEqual(endpoint.eventTrigger["eventFilters"], {})
370+
371+
rendered_yaml = yaml.safe_load(serving.functions_as_yaml({"example": example}))
372+
self.assertEqual(
373+
rendered_yaml["endpoints"]["example"]["eventTrigger"]["eventFilters"],
374+
{},
375+
)
376+
377+
# Should ignore events with top-level tenantid CloudEvent attribute (and not run @core.init)
378+
tenant_attr_ce = CloudEvent(
379+
attributes={
380+
"specversion": "1.0",
381+
"id": "event-tenant-attr",
382+
"source": "//identitytoolkit.googleapis.com/projects/my-project",
383+
"type": "google.firebase.auth.user.v2.created",
384+
"time": "2023-01-01T00:00:00Z",
385+
"tenantid": "some-tenant",
386+
},
387+
data={"value": {"uid": "user-with-tenant-attr"}},
388+
)
389+
example(tenant_attr_ce) # type: ignore[arg-type]
390+
self.assertEqual(called_uids, [])
391+
self.assertEqual(init_calls, 0)
392+
393+
# Should ignore events with tenantId inside the user payload
394+
tenant_data_ce = CloudEvent(
395+
attributes={
396+
"specversion": "1.0",
397+
"id": "event-tenant-data",
398+
"source": "//identitytoolkit.googleapis.com/projects/my-project",
399+
"type": "google.firebase.auth.user.v2.created",
400+
"time": "2023-01-01T00:00:00Z",
401+
},
402+
data={"value": {"uid": "user-with-tenant-data", "tenantId": "some-tenant"}},
403+
)
404+
example(tenant_data_ce) # type: ignore[arg-type]
405+
self.assertEqual(called_uids, [])
406+
self.assertEqual(init_calls, 0)
407+
408+
# Should execute for non-tenant events (and run @core.init)
409+
non_tenant_ce = CloudEvent(
410+
attributes={
411+
"specversion": "1.0",
412+
"id": "event-no-tenant",
413+
"source": "//identitytoolkit.googleapis.com/projects/my-project",
414+
"type": "google.firebase.auth.user.v2.created",
415+
"time": "2023-01-01T00:00:00Z",
416+
},
417+
data={"value": {"uid": "user-no-tenant"}},
418+
)
419+
example(non_tenant_ce) # type: ignore[arg-type]
420+
self.assertEqual(called_uids, ["user-no-tenant"])
421+
self.assertEqual(init_calls, 1)
422+
423+
# Also verify on_user_deleted with IS_NOT_TENANT
424+
deleted_uids: list[str] = []
425+
426+
@identity_fn.on_user_deleted(tenant_id=identity_fn.IS_NOT_TENANT)
427+
def example_deleted(event: identity_fn.AuthEvent) -> None:
428+
deleted_uids.append(event.data.uid)
429+
430+
delete_endpoint = getattr(example_deleted, "__firebase_endpoint__", None)
431+
assert delete_endpoint is not None
432+
self.assertEqual(delete_endpoint.eventTrigger["eventFilters"], {})
433+
434+
example_deleted(non_tenant_ce) # type: ignore[arg-type]
435+
example_deleted(tenant_attr_ce) # type: ignore[arg-type]
436+
self.assertEqual(deleted_uids, ["user-no-tenant"])

0 commit comments

Comments
 (0)