binarybeachio: trusted view — key User on bb_mailbox (four-layer identity model)

Identity-model rollout T2.4. Trusted view now derives `lookup_email = bb_mailbox or email`
and uses it for both User.objects.filter() and the new-User row's email field. WARN-log
fallback to federation email when the claim is absent (transitional safety; should never
fire once Zitadel `bb-claims` Action + bridge-side userinfo enrichment are live).

Decode-time required-claims unchanged (`bb_mailbox` stays optional) so partial deploys
aren't bricked. Pre-migration SQL rename of operator's existing User row required —
see binarybeachio docs/services/plane/migration-plan.md §9.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
binarybeach 2026-05-05 00:56:35 -10:00
parent c0cfbb2bdc
commit 69b499c9ec
2 changed files with 39 additions and 6 deletions

View file

@ -159,7 +159,14 @@ def _verify_with_retry(token: str, public_key_url: str) -> dict:
"""Verify the JWT, refetching the bridge key once on signature failure to
transparently handle bridge key rotation. Other verify failures (expired,
wrong issuer/audience, malformed) do NOT trigger a refetch those are
tampering or clock issues, not key drift."""
tampering or clock issues, not key drift.
`bb_mailbox` is intentionally NOT in the required-claims list. The bridge
only emits it when Zitadel's `bb-claims` Action has propagated and the
tenant's `mail_domain` org metadata is set; absent the claim, the view
falls back to `email` for User keying so a partial deployment (bridge
upgraded but Action not yet live) still works. See the four-layer model
in `binarybeachio/docs/architecture/multi-tenant-identity.md` §4."""
pem = _fetch_bridge_public_key(public_key_url)
try:
return pyjwt.decode(
@ -233,18 +240,42 @@ class TrustedSignInEndpoint(View):
if not email:
return _redirect_with_error(request, "TRUSTED_JWT_TOKEN_INVALID", "TRUSTED_JWT_TOKEN_NO_EMAIL", next_path)
# Identity-model rollout (multi-tenant-identity.md §4): prefer
# `bb_mailbox` over `email` for User keying. `email` carries the
# federation address (e.g., the user's Google login) — it's useful
# for auditing but it is NOT the canonical per-tenant identity. The
# canonical identity is `bb_mailbox` = `<bb_username>@<bb_mail_domain>`,
# the address Stalwart actually hosts and the address the operator
# invites users at.
#
# If `bb_mailbox` is absent, fall back to `email` so a transitional
# deployment (bridge already upgraded but Zitadel `bb-claims` Action
# not yet propagated) keeps working. The WARN log is the operator's
# signal that the chain is still incomplete.
bb_mailbox = (claims.get("bb_mailbox") or "").strip().lower()
if bb_mailbox:
lookup_email = bb_mailbox
else:
log.warning(
"trusted-jwt missing bb_mailbox claim; falling back to federation email — "
"verify Zitadel `bb-claims` Action is published and the tenant's "
"`mail_domain` org metadata is set",
extra={"sub": claims.get("sub"), "email": email, "tenant": claims.get("tenant")},
)
lookup_email = email
# Find-or-create. We mirror the User-creation shape that
# OauthAdapter.complete_login_or_signup() produces (apps/api/plane/
# authentication/adapter/base.py:289-342) — same field set, same
# required side-effect of creating a Profile row. Skipping the Profile
# would cause Plane's SPA /api/users/me/profile/ to 404 and bounce
# the user back to /login in an onboarding loop.
user = User.objects.filter(email=email).first()
user = User.objects.filter(email=lookup_email).first()
created = user is None
if created:
with transaction.atomic():
user = User(
email=email,
email=lookup_email,
username=uuid.uuid4().hex,
first_name=claims.get("first_name") or claims.get("given_name") or "",
last_name=claims.get("last_name") or claims.get("family_name") or "",
@ -278,6 +309,8 @@ class TrustedSignInEndpoint(View):
"jti": claims.get("jti"),
"sub": claims.get("sub"),
"email": email,
"lookup_email": lookup_email,
"bb_mailbox_present": bool(bb_mailbox),
"tenant": claims.get("tenant"),
"is_signup": created,
},