Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 10 additions & 3 deletions src/firebase_functions/identity_fn.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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:

Expand Down Expand Up @@ -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,
Expand All @@ -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:

Expand Down Expand Up @@ -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,
Expand Down
29 changes: 26 additions & 3 deletions src/firebase_functions/options.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""

Expand Down Expand Up @@ -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(
Expand All @@ -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(
Expand Down
11 changes: 11 additions & 0 deletions src/firebase_functions/private/_identity_fn.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
100 changes: 99 additions & 1 deletion tests/test_identity_fn.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -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
Loading