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

@ -49,16 +49,16 @@ Three logical patch groups across the repo. Touch surface is intentionally minim
| File | Change | Risk on upgrade |
|---|---|---|
| `apps/api/plane/authentication/views/app/trusted.py` | **New file**. Django `View` that validates a bridge-issued RS256 JWT, atomically claims its `jti` in shared-redis (replay protection), find-or-creates the User, and calls `user_login(request, user, is_app=True)` to set the Django session cookie. PEM is fetched at runtime from `BB_BRIDGE_PUBLIC_KEY_URL` (avoids the env-PEM corruption issue Coolify has with backslash-escaped keys). Endpoint is implicitly disabled (returns 404) when the env is unset. | **Low.** Depends only on `User` model, `user_login`, `post_user_auth_workflow`, and `get_safe_redirect_url` — all stable upstream APIs. PyJWT and `requests` are existing deps. |
| `apps/api/plane/authentication/views/app/trusted.py` | **New file**. Django `View` that validates a bridge-issued RS256 JWT, atomically claims its `jti` in shared-redis (replay protection), find-or-creates the User keyed on `bb_mailbox` (four-layer identity model — falls back to `email` when the claim is absent), and calls `user_login(request, user, is_app=True)` to set the Django session cookie. PEM is fetched at runtime from `BB_BRIDGE_PUBLIC_KEY_URL` (avoids the env-PEM corruption issue Coolify has with backslash-escaped keys). Endpoint is implicitly disabled (returns 404) when the env is unset. | **Low.** Depends only on `User` model, `user_login`, `post_user_auth_workflow`, and `get_safe_redirect_url` — all stable upstream APIs. PyJWT and `requests` are existing deps. |
| `apps/api/plane/authentication/urls.py` | 1-line addition appending `path("sign-in-trusted/", TrustedSignInEndpoint.as_view(), name="sign-in-trusted")` to the urlpatterns list. | **Low.** Pure append; no existing routes modified. |
| `apps/api/plane/authentication/views/__init__.py` | 1-line addition exporting `TrustedSignInEndpoint`. | **Low.** Pure append. |
| `apps/api/plane/authentication/adapter/error.py` | Adds 7 error codes in the 60006099 range (reserved for fork additions). Pure dict-additions; no existing entries renumbered. | **None.** |
The full bridge ↔ Plane contract:
- Bridge mints `RS256` JWT signed with `BRIDGE_SIGNING_KEY` (private). Claims: `iss=bb-bridge`, `aud=plane`, `iat`, `exp` (now+60s), `jti` (UUIDv4), `sub`, `email`, `first_name`, `last_name`, `tenant`.
- Bridge mints `RS256` JWT signed with `BRIDGE_SIGNING_KEY` (private). Claims: `iss=bb-bridge`, `aud=plane`, `iat`, `exp` (now+60s), `jti` (UUIDv4), `sub`, `email`, `first_name`, `last_name`, `tenant`, and `bb_mailbox` (when emitted by Zitadel's `bb-claims` Action — see `binarybeachio/docs/architecture/multi-tenant-identity.md` §4).
- Bridge 302s the user's browser to `https://pm.<tenant>.binarybeach.io/auth/sign-in-trusted/?token=<jwt>&next_path=<rd>`.
- Plane's view: fetches public key from `BB_BRIDGE_PUBLIC_KEY_URL` (cached 5 min), verifies signature + claims, atomically `SETNX bb_bridge_jti:<jti>` in shared-redis with TTL = `exp - now + 30s`, find-or-creates User by email, calls `user_login()`, 302s to `next_path`.
- Plane's view: fetches public key from `BB_BRIDGE_PUBLIC_KEY_URL` (cached 5 min), verifies signature + claims, atomically `SETNX bb_bridge_jti:<jti>` in shared-redis with TTL = `exp - now + 30s`, find-or-creates User keyed on `bb_mailbox` (preferred) or `email` (fallback), calls `user_login()`, 302s to `next_path`.
- Replay protection is **fail closed**: if shared-redis is unavailable, the request is rejected. Operator break-glass uses the email+password sign-in (vanilla upstream code) which doesn't depend on either Redis or the bridge.
### Patch 2: Presigned PUT for uploads (R2/B2 don't implement PostObject)

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,
},