From 69b499c9ec221de5a68da41ebefa5665a61fa3cf Mon Sep 17 00:00:00 2001 From: binarybeach Date: Tue, 5 May 2026 00:56:35 -1000 Subject: [PATCH] =?UTF-8?q?binarybeachio:=20trusted=20view=20=E2=80=94=20k?= =?UTF-8?q?ey=20User=20on=20bb=5Fmailbox=20(four-layer=20identity=20model)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- BINARYBEACHIO.md | 6 +-- .../plane/authentication/views/app/trusted.py | 39 +++++++++++++++++-- 2 files changed, 39 insertions(+), 6 deletions(-) diff --git a/BINARYBEACHIO.md b/BINARYBEACHIO.md index fcbb4ba58..53a9440ab 100644 --- a/BINARYBEACHIO.md +++ b/BINARYBEACHIO.md @@ -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 6000–6099 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..binarybeach.io/auth/sign-in-trusted/?token=&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:` 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:` 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) diff --git a/apps/api/plane/authentication/views/app/trusted.py b/apps/api/plane/authentication/views/app/trusted.py index a6e6a8b8f..1cdf4b444 100644 --- a/apps/api/plane/authentication/views/app/trusted.py +++ b/apps/api/plane/authentication/views/app/trusted.py @@ -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` = `@`, + # 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, },