Skip to content

Commit 65303cd

Browse files
feat(identity): support IS_NOT_TENANT filtering for v2 Auth events (#319)
1 parent 4691ce3 commit 65303cd

4 files changed

Lines changed: 146 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: 26 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,14 @@
3737
"""Special configuration value to reset configuration to platform default."""
3838

3939

40+
class Sentinel(_util.Sentinel):
41+
"""Sentinel class for special option configuration values."""
42+
43+
44+
IS_NOT_TENANT = Sentinel("Constant to represent the absence of a tenant ID in Auth event triggers.")
45+
"""Constant to represent the absence of a tenant ID in Auth event triggers."""
46+
47+
4048
class VpcEgressSetting(str, _enum.Enum):
4149
"""Valid settings for VPC egress."""
4250

@@ -1062,9 +1070,12 @@ class AuthOptions(EventHandlerOptions):
10621070
Internal use only.
10631071
"""
10641072

1065-
tenant_id: str | Expression[str] | None = None
1073+
tenant_id: str | Expression[str] | Sentinel | None = None
10661074
"""
10671075
An optional tenant ID to scope the trigger to.
1076+
If not set, the function triggers on users across all tenants.
1077+
Set to ``IS_NOT_TENANT`` to only trigger on users in the default
1078+
project (no tenant).
10681079
"""
10691080

10701081
def _endpoint(
@@ -1073,9 +1084,21 @@ def _endpoint(
10731084
) -> _manifest.ManifestEndpoint:
10741085
assert kwargs["event_type"] is not None
10751086
event_filters: dict[str, str | Expression[str]] = {}
1076-
if self.tenant_id is not None:
1087+
# Eventarc does not support empty-string filters to match "no tenant". When
1088+
# tenant_id is IS_NOT_TENANT (a Sentinel), we omit the Eventarc filter so all
1089+
# events are delivered and filter out tenant-scoped events at runtime instead.
1090+
if isinstance(self.tenant_id, _util.Sentinel):
1091+
if self.tenant_id != IS_NOT_TENANT:
1092+
raise ValueError(
1093+
f"Invalid sentinel for tenant_id: {self.tenant_id.description!r}. "
1094+
"Only IS_NOT_TENANT is supported."
1095+
)
1096+
elif self.tenant_id is not None:
10771097
if self.tenant_id == "":
1078-
raise ValueError("AuthOptions: tenant_id cannot be an empty string.")
1098+
raise ValueError(
1099+
"AuthOptions: tenant_id cannot be an empty string; "
1100+
"use IS_NOT_TENANT to match users without a tenant."
1101+
)
10791102
event_filters["tenantid"] = self.tenant_id
10801103

10811104
endpoint = super()._endpoint(

‎src/firebase_functions/private/_identity_fn.py‎

Lines changed: 11 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
@@ -509,10 +510,20 @@ def _auth_event_from_ce(raw: _ce.CloudEvent):
509510
def user_event_handler(
510511
func: _typing.Callable,
511512
raw: _ce.CloudEvent,
513+
options: _options.AuthOptions | None = None,
512514
) -> None:
513515
"""
514516
Runtime handler invoked by functions-framework for v2 Auth triggers
515517
(on_user_created and on_user_deleted).
516518
"""
519+
# Eventarc cannot filter for the absence of a tenant attribute, so when
520+
# tenant_id=IS_NOT_TENANT we drop events belonging to any tenant at runtime
521+
# before unpacking the CloudEvent payload.
522+
if (
523+
options is not None
524+
and options.tenant_id == _options.IS_NOT_TENANT
525+
and raw._get_attributes().get("tenantid") is not None
526+
):
527+
return
517528
event = _auth_event_from_ce(raw)
518529
_with_init(func)(event)

‎tests/test_identity_fn.py‎

Lines changed: 99 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()
@@ -356,3 +356,101 @@ def example(event: identity_fn.AuthEvent) -> None:
356356
self.assertIsNone(captured_event.data.password_salt)
357357
self.assertIsNone(captured_event.data.tokens_valid_after_time)
358358
self.assertIsNone(captured_event.data.multi_factor)
359+
360+
def test_on_user_created_is_not_tenant(self) -> None:
361+
called_uids: list[str] = []
362+
init_calls = 0
363+
364+
@core.init
365+
def init_fn() -> None:
366+
nonlocal init_calls
367+
init_calls += 1
368+
369+
@identity_fn.on_user_created(tenant_id=identity_fn.IS_NOT_TENANT)
370+
def example(event: identity_fn.AuthEvent) -> None:
371+
called_uids.append(event.data.uid)
372+
373+
endpoint = getattr(example, "__firebase_endpoint__", None)
374+
assert endpoint is not None
375+
self.assertEqual(endpoint.eventTrigger["eventFilters"], {})
376+
377+
rendered_yaml = yaml.safe_load(serving.functions_as_yaml({"example": example}))
378+
self.assertEqual(
379+
rendered_yaml["endpoints"]["example"]["eventTrigger"]["eventFilters"],
380+
{},
381+
)
382+
383+
# Should ignore events with top-level tenantid CloudEvent attribute
384+
# before unpacking the payload (and not run @core.init)
385+
tenant_attr_ce = CloudEvent(
386+
attributes={
387+
"specversion": "1.0",
388+
"id": "event-tenant-attr",
389+
"source": "//identitytoolkit.googleapis.com/projects/my-project",
390+
"type": "google.firebase.auth.user.v2.created",
391+
"time": "2023-01-01T00:00:00Z",
392+
"tenantid": "some-tenant",
393+
},
394+
data=None,
395+
)
396+
example(tenant_attr_ce) # type: ignore[arg-type]
397+
self.assertEqual(called_uids, [])
398+
self.assertEqual(init_calls, 0)
399+
400+
# Should execute for non-tenant events (and run @core.init)
401+
non_tenant_ce = CloudEvent(
402+
attributes={
403+
"specversion": "1.0",
404+
"id": "event-no-tenant",
405+
"source": "//identitytoolkit.googleapis.com/projects/my-project",
406+
"type": "google.firebase.auth.user.v2.created",
407+
"time": "2023-01-01T00:00:00Z",
408+
},
409+
data={"value": {"uid": "user-no-tenant"}},
410+
)
411+
example(non_tenant_ce) # type: ignore[arg-type]
412+
self.assertEqual(called_uids, ["user-no-tenant"])
413+
self.assertEqual(init_calls, 1)
414+
415+
# Also verify on_user_deleted with IS_NOT_TENANT
416+
deleted_uids: list[str] = []
417+
418+
@identity_fn.on_user_deleted(tenant_id=identity_fn.IS_NOT_TENANT)
419+
def example_deleted(event: identity_fn.AuthEvent) -> None:
420+
deleted_uids.append(event.data.uid)
421+
422+
delete_endpoint = getattr(example_deleted, "__firebase_endpoint__", None)
423+
assert delete_endpoint is not None
424+
self.assertEqual(delete_endpoint.eventTrigger["eventFilters"], {})
425+
426+
non_tenant_deleted_ce = CloudEvent(
427+
attributes={
428+
"specversion": "1.0",
429+
"id": "event-del-no-tenant",
430+
"source": "//identitytoolkit.googleapis.com/projects/my-project",
431+
"type": "google.firebase.auth.user.v2.deleted",
432+
"time": "2023-01-01T00:00:00Z",
433+
},
434+
data={"value": None, "oldValue": {"uid": "deleted-no-tenant"}},
435+
)
436+
tenant_deleted_ce = CloudEvent(
437+
attributes={
438+
"specversion": "1.0",
439+
"id": "event-del-tenant",
440+
"source": "//identitytoolkit.googleapis.com/projects/my-project",
441+
"type": "google.firebase.auth.user.v2.deleted",
442+
"time": "2023-01-01T00:00:00Z",
443+
"tenantid": "some-tenant",
444+
},
445+
data={"value": None, "oldValue": {"uid": "deleted-with-tenant"}},
446+
)
447+
example_deleted(non_tenant_deleted_ce) # type: ignore[arg-type]
448+
example_deleted(tenant_deleted_ce) # type: ignore[arg-type]
449+
self.assertEqual(deleted_uids, ["deleted-no-tenant"])
450+
451+
# Rejects other Sentinel instances such as RESET_VALUE
452+
with self.assertRaises(ValueError):
453+
454+
@identity_fn.on_user_created(tenant_id=options.RESET_VALUE) # type: ignore[arg-type]
455+
def _invalid_sentinel(_event: identity_fn.AuthEvent) -> None:
456+
pass

0 commit comments

Comments
 (0)