Skip to content

Commit cfec286

Browse files
feat(identity): support IS_NOT_TENANT filtering for v2 Auth events
1 parent 9ea1349 commit cfec286

4 files changed

Lines changed: 156 additions & 7 deletions

File tree

‎src/firebase_functions/identity_fn.py‎

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,10 @@
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 (
35+
IS_NOT_TENANT, # noqa: F401
36+
AuthOptions,
37+
)
3538

3639

3740
@_dataclasses.dataclass(frozen=True)
@@ -506,6 +509,8 @@ def on_user_created(
506509
) -> _typing.Callable[[AuthEventCallable], AuthEventCallable]:
507510
"""
508511
Handles an event that is triggered when a user is created in Firebase Authentication.
512+
Pass ``tenant_id=identity_fn.IS_NOT_TENANT`` to only trigger on users in the
513+
default project (not scoped to any Identity Platform tenant).
509514
510515
Example:
511516
@@ -533,7 +538,7 @@ def on_user_created_decorator(func: AuthEventCallable):
533538

534539
@_functools.wraps(func)
535540
def on_user_created_wrapped(raw: _ce.CloudEvent):
536-
return user_event_handler(func, raw)
541+
return user_event_handler(func, raw, options)
537542

538543
_util.set_func_endpoint_attr(
539544
on_user_created_wrapped,
@@ -553,6 +558,8 @@ def on_user_deleted(
553558
) -> _typing.Callable[[AuthEventCallable], AuthEventCallable]:
554559
"""
555560
Handles an event that is triggered when a user is deleted in Firebase Authentication.
561+
Pass ``tenant_id=identity_fn.IS_NOT_TENANT`` to only trigger on users in the
562+
default project (not scoped to any Identity Platform tenant).
556563
557564
Example:
558565
@@ -580,7 +587,7 @@ def on_user_deleted_decorator(func: AuthEventCallable):
580587

581588
@_functools.wraps(func)
582589
def on_user_deleted_wrapped(raw: _ce.CloudEvent):
583-
return user_event_handler(func, raw)
590+
return user_event_handler(func, raw, options)
584591

585592
_util.set_func_endpoint_attr(
586593
on_user_deleted_wrapped,

‎src/firebase_functions/options.py‎

Lines changed: 23 additions & 3 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,9 +1081,21 @@ 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 isinstance(self.tenant_id, _util.Sentinel):
1088+
if self.tenant_id != IS_NOT_TENANT:
1089+
raise ValueError(
1090+
f"Invalid sentinel for tenant_id: {self.tenant_id.description!r}. "
1091+
"Only IS_NOT_TENANT is supported."
1092+
)
1093+
elif self.tenant_id is not None:
10771094
if self.tenant_id == "":
1078-
raise ValueError("AuthOptions: tenant_id cannot be an empty string.")
1095+
raise ValueError(
1096+
"AuthOptions: tenant_id cannot be an empty string; "
1097+
"use IS_NOT_TENANT to match users without a tenant."
1098+
)
10791099
event_filters["tenantid"] = self.tenant_id
10801100

10811101
endpoint = super()._endpoint(

‎src/firebase_functions/private/_identity_fn.py‎

Lines changed: 10 additions & 0 deletions
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
@@ -565,10 +566,19 @@ def _auth_event_from_ce(raw: _ce.CloudEvent):
565566
def user_event_handler(
566567
func: _typing.Callable,
567568
raw: _ce.CloudEvent,
569+
options: _options.AuthOptions | None = None,
568570
) -> None:
569571
"""
570572
Runtime handler invoked by functions-framework for v2 Auth triggers
571573
(on_user_created and on_user_deleted).
572574
"""
573575
event = _auth_event_from_ce(raw)
576+
# Eventarc cannot filter for the absence of a tenant attribute, so when
577+
# tenant_id=IS_NOT_TENANT we drop events belonging to any tenant at runtime.
578+
if (
579+
options is not None
580+
and options.tenant_id == _options.IS_NOT_TENANT
581+
and event.tenant_id is not None
582+
):
583+
return
574584
_with_init(func)(event)

‎tests/test_identity_fn.py‎

Lines changed: 113 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111
from flask import Flask, Request
1212
from werkzeug.test import EnvironBuilder
1313

14-
from firebase_functions import core, identity_fn, params
14+
from firebase_functions import core, identity_fn, options, params
1515
from firebase_functions.private import _identity_fn, serving
1616

1717
token_verifier_mock = MagicMock()
@@ -366,3 +366,115 @@ def example(event: identity_fn.AuthEvent) -> None:
366366
self.assertIsNone(captured_event.data.password_salt)
367367
self.assertIsNone(captured_event.data.tokens_valid_after_time)
368368
self.assertIsNone(captured_event.data.multi_factor)
369+
370+
def test_on_user_created_is_not_tenant(self) -> None:
371+
called_uids: list[str] = []
372+
init_calls = 0
373+
374+
@core.init
375+
def init_fn() -> None:
376+
nonlocal init_calls
377+
init_calls += 1
378+
379+
@identity_fn.on_user_created(tenant_id=identity_fn.IS_NOT_TENANT)
380+
def example(event: identity_fn.AuthEvent) -> None:
381+
called_uids.append(event.data.uid)
382+
383+
endpoint = getattr(example, "__firebase_endpoint__", None)
384+
assert endpoint is not None
385+
self.assertEqual(endpoint.eventTrigger["eventFilters"], {})
386+
387+
rendered_yaml = yaml.safe_load(serving.functions_as_yaml({"example": example}))
388+
self.assertEqual(
389+
rendered_yaml["endpoints"]["example"]["eventTrigger"]["eventFilters"],
390+
{},
391+
)
392+
393+
# Should ignore events with top-level tenantid CloudEvent attribute (and not run @core.init)
394+
tenant_attr_ce = CloudEvent(
395+
attributes={
396+
"specversion": "1.0",
397+
"id": "event-tenant-attr",
398+
"source": "//identitytoolkit.googleapis.com/projects/my-project",
399+
"type": "google.firebase.auth.user.v2.created",
400+
"time": "2023-01-01T00:00:00Z",
401+
"tenantid": "some-tenant",
402+
},
403+
data={"value": {"uid": "user-with-tenant-attr"}},
404+
)
405+
example(tenant_attr_ce) # type: ignore[arg-type]
406+
self.assertEqual(called_uids, [])
407+
self.assertEqual(init_calls, 0)
408+
409+
# Should ignore events with tenantId inside the user payload
410+
tenant_data_ce = CloudEvent(
411+
attributes={
412+
"specversion": "1.0",
413+
"id": "event-tenant-data",
414+
"source": "//identitytoolkit.googleapis.com/projects/my-project",
415+
"type": "google.firebase.auth.user.v2.created",
416+
"time": "2023-01-01T00:00:00Z",
417+
},
418+
data={"value": {"uid": "user-with-tenant-data", "tenantId": "some-tenant"}},
419+
)
420+
example(tenant_data_ce) # type: ignore[arg-type]
421+
self.assertEqual(called_uids, [])
422+
self.assertEqual(init_calls, 0)
423+
424+
# Should execute for non-tenant events (and run @core.init)
425+
non_tenant_ce = CloudEvent(
426+
attributes={
427+
"specversion": "1.0",
428+
"id": "event-no-tenant",
429+
"source": "//identitytoolkit.googleapis.com/projects/my-project",
430+
"type": "google.firebase.auth.user.v2.created",
431+
"time": "2023-01-01T00:00:00Z",
432+
},
433+
data={"value": {"uid": "user-no-tenant"}},
434+
)
435+
example(non_tenant_ce) # type: ignore[arg-type]
436+
self.assertEqual(called_uids, ["user-no-tenant"])
437+
self.assertEqual(init_calls, 1)
438+
439+
# Also verify on_user_deleted with IS_NOT_TENANT
440+
deleted_uids: list[str] = []
441+
442+
@identity_fn.on_user_deleted(tenant_id=identity_fn.IS_NOT_TENANT)
443+
def example_deleted(event: identity_fn.AuthEvent) -> None:
444+
deleted_uids.append(event.data.uid)
445+
446+
delete_endpoint = getattr(example_deleted, "__firebase_endpoint__", None)
447+
assert delete_endpoint is not None
448+
self.assertEqual(delete_endpoint.eventTrigger["eventFilters"], {})
449+
450+
non_tenant_deleted_ce = CloudEvent(
451+
attributes={
452+
"specversion": "1.0",
453+
"id": "event-del-no-tenant",
454+
"source": "//identitytoolkit.googleapis.com/projects/my-project",
455+
"type": "google.firebase.auth.user.v2.deleted",
456+
"time": "2023-01-01T00:00:00Z",
457+
},
458+
data={"value": None, "oldValue": {"uid": "deleted-no-tenant"}},
459+
)
460+
tenant_deleted_ce = CloudEvent(
461+
attributes={
462+
"specversion": "1.0",
463+
"id": "event-del-tenant",
464+
"source": "//identitytoolkit.googleapis.com/projects/my-project",
465+
"type": "google.firebase.auth.user.v2.deleted",
466+
"time": "2023-01-01T00:00:00Z",
467+
"tenantid": "some-tenant",
468+
},
469+
data={"value": None, "oldValue": {"uid": "deleted-with-tenant"}},
470+
)
471+
example_deleted(non_tenant_deleted_ce) # type: ignore[arg-type]
472+
example_deleted(tenant_deleted_ce) # type: ignore[arg-type]
473+
self.assertEqual(deleted_uids, ["deleted-no-tenant"])
474+
475+
# Rejects other Sentinel instances such as RESET_VALUE
476+
with self.assertRaises(ValueError):
477+
478+
@identity_fn.on_user_created(tenant_id=options.RESET_VALUE)
479+
def _invalid_sentinel(_event: identity_fn.AuthEvent) -> None:
480+
pass

0 commit comments

Comments
 (0)