Skip to content

feat(identity): support IS_NOT_TENANT filtering and emulator timestamps - #319

Open
shettyvarun268 wants to merge 1 commit into
shettyvarun268/auth-v2-triggersfrom
shettyvarun268/auth-v2-is-not-tenant
Open

shettyvarun268 wants to merge 1 commit into
shettyvarun268/auth-v2-triggersfrom
shettyvarun268/auth-v2-is-not-tenant

Conversation

@shettyvarun268

@shettyvarun268 shettyvarun268 commented Oct 13, 2026 •

Copy link
Copy Markdown
Contributor

Summary

Note: This PR is stacked on #318.

  1. Adds IS_NOT_TENANT support to AuthOptions.tenant_id so developers can scope on_user_created and on_user_deleted triggers exclusively to default-project (non-tenant) users, matching the Node.js SDK (firebase-functions/v2/identity).
  2. Adds RFC-2822 timestamp parsing fallback (email.utils.parsedate_to_datetime) in _parse_auth_timestamp to support user metadata timestamps emitted by the local Firebase Auth Emulator.

Usage

from firebase_functions import identity_fn

# Only triggers for users in the default project (ignores users created in any tenant)
@identity_fn.on_user_created(tenant_id=identity_fn.IS_NOT_TENANT)
def handle_default_project_user_created(event: identity_fn.AuthEvent) -> None:
    assert event.tenant_id is None
    print(f"Default-project user created: {event.data.uid}")

Architectural & Implementation Details

1. Why IS_NOT_TENANT Uses Hybrid Deploy-Time + Runtime Filtering (options.py, identity_fn.py, private/_identity_fn.py)

In Identity Platform multi-tenancy, there are three ways a developer may configure tenant_id on an Auth trigger:

  1. tenant_id=None (default): Trigger for all users across the project and all tenants (eventFilters: {}).
  2. tenant_id="tenant-a": Trigger only for users in "tenant-a" (eventFilters: {"tenantid": "tenant-a"}).
  3. tenant_id=IS_NOT_TENANT: Trigger only for default-project users who do not belong to any tenant.

Platform Constraint: Google Cloud Eventarc does not support negative filters or empty-string filters (eventFilters: {"tenantid": ""} is rejected by the Eventarc API). To support IS_NOT_TENANT (matching the Node SDK's implementation):

  • Sentinel Definition: Exported a dedicated IS_NOT_TENANT = _util.Sentinel(...) instance in firebase_functions.options and re-exported it in firebase_functions.identity_fn.
  • Deploy-Time (AuthOptions._endpoint): When self.tenant_id is a _util.Sentinel (IS_NOT_TENANT), we omit "tenantid" from event_filters in functions.yaml so Eventarc delivers all Auth events to the function.
  • Runtime (user_event_handler): After unpacking the AuthEvent, if options.tenant_id == _options.IS_NOT_TENANT and event.tenant_id is not None, the handler returns early and drops the tenant-scoped event before invoking @core.init or the user's function.

2. Firebase Auth Emulator RFC-2822 Timestamp Support (private/_identity_fn.py)

  • Why this is needed: While production Eventarc formats metadata.createTime and metadata.lastSignInTime as ISO-8601 strings ("2023-01-01T00:00:00Z"), the local Firebase Auth Emulator serializes metadata.creationTime and metadata.lastSignInTime using JavaScript's Date.prototype.toUTCString(), which produces RFC-2822 strings (e.g., "Sun, 01 Jan 2023 00:00:00 GMT").
  • Fix: Added Python's standard-library email.utils.parsedate_to_datetime as a fallback parser in _parse_auth_timestamp (normalized to UTC via _ensure_utc). This ensures event.data.metadata.creation_time and last_sign_in_time are properly parsed into timezone-aware UTC datetime objects when testing locally with the Firebase Emulator Suite.

Verification

Scenario on_user_created(tenant_id=IS_NOT_TENANT) on_user_created(tenant_id="tenant-a") on_user_created() (tenant_id=None)
Default-project user (tenant_id=None) Fires Ignored Fires
Tenant A user (tenant_id="tenant-a") Ignored (filtered at runtime) Fires Fires
Tenant B user (tenant_id="tenant-b") Ignored (filtered at runtime) Ignored (filtered by Eventarc) Fires
  • Automated Checks:
    • pytest (all 147 tests passing, including test_on_user_created_is_not_tenant and test_auth_emulator_rfc2822_timestamps)
    • mypy, ruff check, ruff format --check, and ./docs/generate.sh
  • End-to-End Testing:
    • Verified locally against the Firebase Auth + Functions Emulators (firebase emulators:exec).
    • Verified in production on Identity Platform across all 6 combinations of on_user_created / on_user_deleted $\times$ (IS_NOT_TENANT, specific tenant_id, and tenant_id=None).

PR Stack:

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request introduces support for filtering Auth event triggers to only trigger on users in the default project (no tenant) by setting tenant_id to IS_NOT_TENANT. Since Eventarc does not support empty-string filters, tenant-scoped events are filtered out at runtime. Additionally, it adds support for parsing RFC-2822 timestamps emitted by the Firebase Auth Emulator. Feedback on the changes suggests ensuring that all parsed or provided datetime objects in _parse_auth_timestamp are normalized to timezone-aware UTC datetimes to prevent returning naive datetimes or datetimes with non-UTC offsets.

Comment on lines 384 to 406
def _parse_auth_timestamp(value: _typing.Any) -> _dt.datetime | None:
"""Normalizes Auth event timestamps (epoch ms numbers/strings or ISO-8601 strings) to UTC."""
"""
Normalizes Auth event timestamps to UTC datetimes.
Supports epoch milliseconds (int/float/numeric str), ISO-8601 strings (production
Eventarc), and RFC-2822 strings (emitted by the Firebase Auth Emulator).
"""
if isinstance(value, _dt.datetime):
return value
if isinstance(value, int | float) and not isinstance(value, bool):
return _dt.datetime.fromtimestamp(value / 1000.0, tz=_dt.timezone.utc)
if isinstance(value, str) and value:
if value.isdigit():
return _dt.datetime.fromtimestamp(int(value) / 1000.0, tz=_dt.timezone.utc)
for parser in (_util.timestamp_conversion, _dt.datetime.fromisoformat):
for parser in (
_util.timestamp_conversion,
_dt.datetime.fromisoformat,
_email_utils.parsedate_to_datetime,
):
try:
return parser(value)
except ValueError:
except (ValueError, TypeError):
continue
return None

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

To ensure consistency and correctness across all parsing paths, any parsed or provided datetime object should be normalized to a timezone-aware UTC datetime. Currently, _email_utils.parsedate_to_datetime or _dt.datetime.fromisoformat can return naive datetimes or datetimes with non-UTC offsets. Normalizing them to UTC guarantees that the returned value always matches the docstring's contract.

def _parse_auth_timestamp(value: _typing.Any) -> _dt.datetime | None:
    """
    Normalizes Auth event timestamps to UTC datetimes.
    Supports epoch milliseconds (int/float/numeric str), ISO-8601 strings (production
    Eventarc), and RFC-2822 strings (emitted by the Firebase Auth Emulator).
    """
    if isinstance(value, _dt.datetime):
        if value.tzinfo is None:
            return value.replace(tzinfo=_dt.timezone.utc)
        return value.astimezone(_dt.timezone.utc)
    if isinstance(value, int | float) and not isinstance(value, bool):
        return _dt.datetime.fromtimestamp(value / 1000.0, tz=_dt.timezone.utc)
    if isinstance(value, str) and value:
        if value.isdigit():
            return _dt.datetime.fromtimestamp(int(value) / 1000.0, tz=_dt.timezone.utc)
        for parser in (
            _util.timestamp_conversion,
            _dt.datetime.fromisoformat,
            _email_utils.parsedate_to_datetime,
        ):
            try:
                dt = parser(value)
                if dt.tzinfo is None:
                    return dt.replace(tzinfo=_dt.timezone.utc)
                return dt.astimezone(_dt.timezone.utc)
            except (ValueError, TypeError):
                continue
    return None

@shettyvarun268
shettyvarun268 force-pushed the shettyvarun268/auth-v2-is-not-tenant branch 2 times, most recently from 552a64a to caefc5b Compare October 13, 2026 20:42
@shettyvarun268
shettyvarun268 force-pushed the shettyvarun268/auth-v2-is-not-tenant branch 5 times, most recently from 425136d to 87cb6b6 Compare October 13, 2026 23:05
@shettyvarun268
shettyvarun268 marked this pull request as ready for review October 13, 2026 23:13
@shettyvarun268
shettyvarun268 added this pull request to stack #320 October 14, 2026 14:57
@shettyvarun268
shettyvarun268 force-pushed the shettyvarun268/auth-v2-is-not-tenant branch from 87cb6b6 to 44bbe64 Compare October 15, 2026 15:43
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants