Skip to content

Commit caefc5b

Browse files
feat(identity): support IS_NOT_TENANT filtering and emulator timestamps
1 parent 9a13f3c commit caefc5b

4 files changed

Lines changed: 160 additions & 8 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 # noqa: F401
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: 19 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515

1616
# pylint: disable=protected-access
1717
import datetime as _dt
18+
import email.utils as _email_utils
1819
import json as _json
1920
import re as _re
2021
import time as _time
@@ -35,6 +36,7 @@
3536
)
3637
from functions_framework import logging as _logging
3738

39+
import firebase_functions.options as _options
3840
import firebase_functions.private.token_verifier as _token_verifier
3941
import firebase_functions.private.util as _util
4042
from firebase_functions.core import _with_init
@@ -390,7 +392,8 @@ def _ensure_utc(value: _dt.datetime) -> _dt.datetime:
390392
def _parse_auth_timestamp(value: _typing.Any) -> _dt.datetime | None:
391393
"""
392394
Normalizes Auth event timestamps to timezone-aware UTC datetimes.
393-
Supports epoch milliseconds (int, float, or numeric str) and ISO-8601 strings.
395+
Supports epoch milliseconds (int, float, or numeric str), ISO-8601 strings
396+
(production Eventarc), and RFC-2822 strings (emitted by the Firebase Auth Emulator).
394397
"""
395398
if isinstance(value, _dt.datetime):
396399
return _ensure_utc(value)
@@ -400,10 +403,14 @@ def _parse_auth_timestamp(value: _typing.Any) -> _dt.datetime | None:
400403
if isinstance(value, str) and value:
401404
if value.isdigit():
402405
return _dt.datetime.fromtimestamp(int(value) / 1000.0, tz=_dt.timezone.utc)
403-
for parser in (_util.timestamp_conversion, _dt.datetime.fromisoformat):
406+
for parser in (
407+
_util.timestamp_conversion,
408+
_dt.datetime.fromisoformat,
409+
_email_utils.parsedate_to_datetime,
410+
):
404411
try:
405412
return _ensure_utc(parser(value))
406-
except ValueError:
413+
except (ValueError, TypeError):
407414
continue
408415
return None
409416

@@ -638,10 +645,19 @@ def _auth_event_from_ce(raw: _ce.CloudEvent):
638645
def user_event_handler(
639646
func: _typing.Callable,
640647
raw: _ce.CloudEvent,
648+
options: _options.AuthOptions | None = None,
641649
) -> None:
642650
"""
643651
Runtime handler invoked by functions-framework for v2 Auth triggers
644652
(on_user_created and on_user_deleted).
645653
"""
646654
event = _auth_event_from_ce(raw)
655+
# Eventarc cannot filter for the absence of a tenant attribute, so when
656+
# tenant_id=IS_NOT_TENANT we drop events belonging to any tenant at runtime.
657+
if (
658+
options is not None
659+
and options.tenant_id == _options.IS_NOT_TENANT
660+
and event.tenant_id is not None
661+
):
662+
return
647663
_with_init(func)(event)

‎tests/test_identity_fn.py‎

Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -330,3 +330,124 @@ def example(event: identity_fn.AuthEvent) -> None:
330330
dt.datetime(2023, 1, 1, 0, 0, 0, tzinfo=dt.timezone.utc),
331331
)
332332
self.assertEqual(factors[1].factor_id, "totp")
333+
334+
def test_on_user_created_is_not_tenant(self) -> None:
335+
called_uids: list[str] = []
336+
init_calls = 0
337+
338+
@core.init
339+
def init_fn() -> None:
340+
nonlocal init_calls
341+
init_calls += 1
342+
343+
@identity_fn.on_user_created(tenant_id=identity_fn.IS_NOT_TENANT)
344+
def example(event: identity_fn.AuthEvent) -> None:
345+
called_uids.append(event.data.uid)
346+
347+
endpoint = getattr(example, "__firebase_endpoint__", None)
348+
assert endpoint is not None
349+
self.assertEqual(endpoint.eventTrigger["eventFilters"], {})
350+
351+
rendered_yaml = yaml.safe_load(serving.functions_as_yaml({"example": example}))
352+
self.assertEqual(
353+
rendered_yaml["endpoints"]["example"]["eventTrigger"]["eventFilters"],
354+
{},
355+
)
356+
357+
# Should ignore events with top-level tenantid CloudEvent attribute (and not run @core.init)
358+
tenant_attr_ce = CloudEvent(
359+
attributes={
360+
"specversion": "1.0",
361+
"id": "event-tenant-attr",
362+
"source": "//identitytoolkit.googleapis.com/projects/my-project",
363+
"type": "google.firebase.auth.user.v2.created",
364+
"time": "2023-01-01T00:00:00Z",
365+
"tenantid": "some-tenant",
366+
},
367+
data={"value": {"uid": "user-with-tenant-attr"}},
368+
)
369+
example(tenant_attr_ce) # type: ignore[arg-type]
370+
self.assertEqual(called_uids, [])
371+
self.assertEqual(init_calls, 0)
372+
373+
# Should ignore events with tenantId inside the user payload
374+
tenant_data_ce = CloudEvent(
375+
attributes={
376+
"specversion": "1.0",
377+
"id": "event-tenant-data",
378+
"source": "//identitytoolkit.googleapis.com/projects/my-project",
379+
"type": "google.firebase.auth.user.v2.created",
380+
"time": "2023-01-01T00:00:00Z",
381+
},
382+
data={"value": {"uid": "user-with-tenant-data", "tenantId": "some-tenant"}},
383+
)
384+
example(tenant_data_ce) # type: ignore[arg-type]
385+
self.assertEqual(called_uids, [])
386+
self.assertEqual(init_calls, 0)
387+
388+
# Should execute for non-tenant events (and run @core.init)
389+
non_tenant_ce = CloudEvent(
390+
attributes={
391+
"specversion": "1.0",
392+
"id": "event-no-tenant",
393+
"source": "//identitytoolkit.googleapis.com/projects/my-project",
394+
"type": "google.firebase.auth.user.v2.created",
395+
"time": "2023-01-01T00:00:00Z",
396+
},
397+
data={"value": {"uid": "user-no-tenant"}},
398+
)
399+
example(non_tenant_ce) # type: ignore[arg-type]
400+
self.assertEqual(called_uids, ["user-no-tenant"])
401+
self.assertEqual(init_calls, 1)
402+
403+
# Also verify on_user_deleted with IS_NOT_TENANT
404+
deleted_uids: list[str] = []
405+
406+
@identity_fn.on_user_deleted(tenant_id=identity_fn.IS_NOT_TENANT)
407+
def example_deleted(event: identity_fn.AuthEvent) -> None:
408+
deleted_uids.append(event.data.uid)
409+
410+
delete_endpoint = getattr(example_deleted, "__firebase_endpoint__", None)
411+
assert delete_endpoint is not None
412+
self.assertEqual(delete_endpoint.eventTrigger["eventFilters"], {})
413+
414+
example_deleted(non_tenant_ce) # type: ignore[arg-type]
415+
example_deleted(tenant_attr_ce) # type: ignore[arg-type]
416+
self.assertEqual(deleted_uids, ["user-no-tenant"])
417+
418+
def test_auth_emulator_rfc2822_timestamps(self) -> None:
419+
captured_event: identity_fn.AuthEvent | None = None
420+
421+
@identity_fn.on_user_created()
422+
def example(event: identity_fn.AuthEvent) -> None:
423+
nonlocal captured_event
424+
captured_event = event
425+
426+
emulator_ce = CloudEvent(
427+
attributes={
428+
"specversion": "1.0",
429+
"id": "emu-event-1",
430+
"source": "//identitytoolkit.googleapis.com/projects/my-project",
431+
"type": "google.firebase.auth.user.v2.created",
432+
"time": "2023-01-01T00:00:00.000Z",
433+
},
434+
data={
435+
"value": {
436+
"uid": "emu-user-1",
437+
"metadata": {
438+
"creationTime": "Sun, 01 Jan 2023 00:00:00 GMT",
439+
"lastSignInTime": "1672662600000",
440+
},
441+
}
442+
},
443+
)
444+
example(emulator_ce) # type: ignore[arg-type]
445+
assert captured_event is not None
446+
self.assertEqual(
447+
captured_event.data.metadata.creation_time,
448+
dt.datetime(2023, 1, 1, 0, 0, 0, tzinfo=dt.timezone.utc),
449+
)
450+
self.assertEqual(
451+
captured_event.data.metadata.last_sign_in_time,
452+
dt.datetime(2023, 1, 2, 12, 30, 0, tzinfo=dt.timezone.utc),
453+
)

0 commit comments

Comments
 (0)