From 64513797eeae06d5eabaf37c35859c04e0325ec7 Mon Sep 17 00:00:00 2001 From: binarybeach Date: Tue, 5 May 2026 13:31:02 -1000 Subject: [PATCH] binarybeachio: per-app edge-identity validation + bundled bridge logout MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Marker-cookie pattern per docs/conventions/per-app-edge-identity-validation.md: - New BbEdgeIdentityMiddleware compares `_bb_edge_sub` cookie to `X-Auth-Request-User` header on every authenticated request. On mismatch, flushes the Django session and replaces request.user with AnonymousUser so DRF returns 401 / browser navigations land at the bridge handoff redirect. Lazy-populates the cookie on legacy sessions; passes through for anonymous requests and bearer-token-only callers. - Trusted-JWT view sets `_bb_edge_sub` on the redirect response when X-Auth-Request-User is present (single session-mint choke-point — the Bucket-4 entry-point is the only path that creates Plane sessions in this deployment). - SignOutAuthEndpoint reads optional BB_LOGOUT_REDIRECT_URL env. When set, the SPA's /auth/sign-out/ form-POST is 302'd to the platform bridge's synced-logout endpoint (clears edge `_bb_oauth2` + back-channels Zitadel end_session). Without this, the user's Zitadel session at the edge outlives the Plane logout and silently re-logs them in via bridge handoff → trusted sign-in. Vanilla regression-safe: env unset → upstream behavior. Net surface vs upstream-clean: 1 new middleware file, 1 line in MIDDLEWARE, ~20 lines added to trusted.py and signout.py. No new dependencies. Co-Authored-By: Claude Opus 4.7 (1M context) --- BINARYBEACHIO.md | 27 ++++- .../plane/authentication/views/app/signout.py | 31 ++++- .../plane/authentication/views/app/trusted.py | 21 +++- apps/api/plane/middleware/bb_edge_identity.py | 112 ++++++++++++++++++ apps/api/plane/settings/common.py | 6 + 5 files changed, 193 insertions(+), 4 deletions(-) create mode 100644 apps/api/plane/middleware/bb_edge_identity.py diff --git a/BINARYBEACHIO.md b/BINARYBEACHIO.md index 53a9440ab..3d606ccbe 100644 --- a/BINARYBEACHIO.md +++ b/BINARYBEACHIO.md @@ -49,10 +49,13 @@ 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 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/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. **Sets `_bb_edge_sub` cookie on the redirect response (mine.7) — the per-app edge-identity marker compared by the middleware on every authenticated request.** 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.** | +| `apps/api/plane/middleware/bb_edge_identity.py` | **New file (mine.7).** Django middleware that compares the `_bb_edge_sub` cookie to `X-Auth-Request-User` on every authenticated request: lazy-populates on legacy sessions, and on mismatch flushes the Django session + replaces `request.user` with `AnonymousUser` so DRF returns 401 / browser navigations land at the bridge handoff redirect. See `binarybeachio/docs/conventions/per-app-edge-identity-validation.md`. | **Low.** Pure addition; uses only `django.contrib.auth.models.AnonymousUser` and `request.session.flush()`. Skip-paths handled by the `request.user.is_anonymous` short-circuit (covers login, trusted-sign-in, healthcheck, public webhooks). | +| `apps/api/plane/settings/common.py` | 1-line MIDDLEWARE addition (mine.7) — `plane.middleware.bb_edge_identity.BbEdgeIdentityMiddleware` inserted after `django.contrib.auth.middleware.AuthenticationMiddleware`. | **Low.** Pure list-append at a stable position. | +| `apps/api/plane/authentication/views/app/signout.py` | (mine.7) Reads optional `BB_LOGOUT_REDIRECT_URL` env; when set, the SPA's sign-out form-POST gets 302'd to the platform bridge `/logout` instead of back to the app root. Bridge clears `_bb_oauth2` and back-channels Zitadel `end_session`. Also explicitly deletes `_bb_edge_sub` on the response. Falls back to vanilla `base_host()` when env is unset. | **Low.** Single-file behavioral change gated by env; vanilla regression-safe. | The full bridge ↔ Plane contract: @@ -97,6 +100,12 @@ Set on the patched `plane-backend` container (binarybeachio sets these in `infra # Activates the trusted-JWT endpoint. URL points at the in-cluster bridge # service's public-key endpoint. Unset → endpoint returns 404 (regression-safe). BB_BRIDGE_PUBLIC_KEY_URL=http://auth-bridge-:3000/.well-known/bb-bridge.pub.pem + +# When set, the SPA's /auth/sign-out/ form-POST is 302'd to the platform +# bridge's synced-logout endpoint, which clears the edge `_bb_oauth2` cookie +# and back-channels Zitadel's end_session. Unset → falls back to vanilla +# behavior (redirect to app root). +BB_LOGOUT_REDIRECT_URL=https://bridge.binarybeach.io/logout?rd=https://pm.binarybeach.io/ ``` Bridge-side configuration (in `binarybeachio/infrastructure/auth-bridge/.env`): @@ -181,6 +190,22 @@ docker push git.binarybeach.io/binarybeach/plane-frontend:latest `mine.` resets to `mine.1` on every upstream version bump; increments per local rebuild within the same upstream version. +## Tag history + +Plane runs as two patched images (`plane-backend`, `plane-frontend`); they bump on independent cadences. Backend tag is the one that lives in `infrastructure/plane/.env::PLANE_BACKEND_IMAGE`. + +| Tag | Upstream | Date | What changed | +|---|---|---|---| +| `plane-backend:v1.3.0-mine.7` | v1.3.0 | 2026-05-05 | Per-app edge-identity validation (`_bb_edge_sub` cookie + `BbEdgeIdentityMiddleware`); `BB_LOGOUT_REDIRECT_URL` env re-points sign-out to platform bridge `/logout`. Per `binarybeachio/docs/conventions/per-app-edge-identity-validation.md`. | +| `plane-backend:v1.3.0-mine.6` | v1.3.0 | 2026-05-05 | Trusted view keys User on `bb_mailbox` (four-layer identity model T2.4); WARN-log fallback to federation email when claim absent. | +| `plane-backend:v1.3.0-mine.5` | v1.3.0 | 2026-05-04 | Trusted view mirrors OauthAdapter user-create shape (Profile, username uuid hex, is_email_verified, is_password_autoset, transaction). | +| `plane-backend:v1.3.0-mine.4` | v1.3.0 | 2026-05-04 | Trusted view: rename log extra key `created` → `is_signup` (LogRecord built-in collision). | +| `plane-backend:v1.3.0-mine.3` | v1.3.0 | 2026-05-04 | Bucket-4 trusted-JWT auth (`/auth/sign-in-trusted/`). Replaces in-place github.py-as-OIDC patch. | +| `plane-backend:v1.3.0-mine.2` | v1.3.0 | 2026-05-01 | Presigned-PUT signature mismatch fix on empty Content-Type. | +| `plane-backend:v1.3.0-mine.1` | v1.3.0 | 2026-04-30 | Initial fork — presigned PUT for uploads (R2/B2 don't implement PostObject), GitHub-as-OIDC, brand asset. | + +`plane-frontend` follows independently; it currently sits at `v1.3.0-mine.3` (Patch 2 frontend bits — presigned-PUT plumbing). The mine.7 backend bump above does NOT require a frontend rebuild — the SPA's existing `signOut()` form-POST goes to the same `/auth/sign-out/` URL; only the backend's response 302 target changed. + ## License compliance Plane is AGPL-3.0-only. The license requires us to provide the source of any modified version we deploy or offer over a network. Our compliance: diff --git a/apps/api/plane/authentication/views/app/signout.py b/apps/api/plane/authentication/views/app/signout.py index 9941da3c9..a3ed6d776 100644 --- a/apps/api/plane/authentication/views/app/signout.py +++ b/apps/api/plane/authentication/views/app/signout.py @@ -2,6 +2,9 @@ # SPDX-License-Identifier: AGPL-3.0-only # See the LICENSE file for details. +# Python imports +import os + # Django imports from django.views import View from django.contrib.auth import logout @@ -13,6 +16,21 @@ from plane.authentication.utils.host import user_ip, base_host from plane.db.models import User +# binarybeachio fork addition. When set, the SPA's /auth/sign-out/ form-POST +# (apps/web/core/services/auth.service.ts) gets 302'd here instead of back to +# the app root. The platform bridge's /logout endpoint clears the +# oauth2-proxy `_bb_oauth2` cookie AND back-channels Zitadel's end_session, +# so signing out from inside Plane now propagates to the edge — without it, +# the user lands back on / with the Zitadel session still alive at the edge, +# auto-redirects through plane-signin-redirect → bridge handoff → trusted +# sign-in, and is silently re-logged-in as the same identity. +# +# Read at request time so dashboard env-var changes don't require a rebuild. +# See binarybeachio/docs/services/auth-bridge/session-debrief-2026-05-04-edge-validation-and-logout.md +# §B "Bundle with each rollout". +_BB_LOGOUT_REDIRECT_URL_ENV = "BB_LOGOUT_REDIRECT_URL" + + class SignOutAuthEndpoint(View): def post(self, request): # Get user @@ -23,6 +41,15 @@ class SignOutAuthEndpoint(View): user.save() # Log the user out logout(request) - return HttpResponseRedirect(base_host(request=request, is_app=True)) except Exception: - return HttpResponseRedirect(base_host(request=request, is_app=True)) + pass + + bb_logout_url = os.environ.get(_BB_LOGOUT_REDIRECT_URL_ENV) or "" + target = bb_logout_url or base_host(request=request, is_app=True) + response = HttpResponseRedirect(target) + # Clear the edge-identity marker cookie alongside the session. The + # SessionMiddleware will delete the session-id cookie on its own once + # request.session.is_empty() (Django logout() flushes it), but the + # marker has no equivalent owner — clear it explicitly here. + response.delete_cookie("_bb_edge_sub", path="/") + return response diff --git a/apps/api/plane/authentication/views/app/trusted.py b/apps/api/plane/authentication/views/app/trusted.py index 1cdf4b444..91e4c6cae 100644 --- a/apps/api/plane/authentication/views/app/trusted.py +++ b/apps/api/plane/authentication/views/app/trusted.py @@ -317,10 +317,29 @@ class TrustedSignInEndpoint(View): ) target = next_path or get_redirection_path(user=user) - return HttpResponseRedirect( + response = HttpResponseRedirect( get_safe_redirect_url( base_url=base_host(request=request, is_app=True), next_path=target, params={}, ) ) + + # Per-app edge-identity validation — set the marker cookie at the same + # choke-point that mints the Django session. The middleware in + # plane/middleware/bb_edge_identity.py compares this cookie to + # `X-Auth-Request-User` on every authenticated request and flushes the + # session on mismatch. See + # binarybeachio/docs/conventions/per-app-edge-identity-validation.md. + edge_sub = request.META.get("HTTP_X_AUTH_REQUEST_USER", "") + if edge_sub: + response.set_cookie( + "_bb_edge_sub", + edge_sub, + httponly=True, + secure=True, + samesite="Lax", + path="/", + ) + + return response diff --git a/apps/api/plane/middleware/bb_edge_identity.py b/apps/api/plane/middleware/bb_edge_identity.py new file mode 100644 index 000000000..66c33603f --- /dev/null +++ b/apps/api/plane/middleware/bb_edge_identity.py @@ -0,0 +1,112 @@ +# Copyright (c) 2023-present Plane Software, Inc. and contributors +# SPDX-License-Identifier: AGPL-3.0-only +# See the LICENSE file for details. +# +# binarybeachio fork addition — see BINARYBEACHIO.md at repo root. +# +# Per-app edge-identity validation. Detects when the platform edge identity +# (oauth2-proxy's `_bb_oauth2` cookie, surfaced via `X-Auth-Request-User`) +# has been swapped to a different Zitadel `sub` since this Plane session was +# minted, and forces a re-OIDC by flushing the Django session. +# +# Without this, signing in as identity A then switching the edge to B (via +# `bridge.binarybeach.io/logout` + signing in as B at Zitadel) leaves the +# Plane `session-id` cookie pointing at A's User row. The SPA's same-origin +# XHRs continue to authenticate as A even though the edge now carries B — +# the staleness window is bounded only by Plane's `SESSION_COOKIE_AGE` +# (default 7d, see settings/common.py). +# +# Per docs/conventions/per-app-edge-identity-validation.md (binarybeachio repo). + +import logging + +from django.contrib.auth.models import AnonymousUser + +log = logging.getLogger("plane.bb_edge_identity") + +_COOKIE_NAME = "_bb_edge_sub" +# Django ALL_CAPS-ifies and prefixes headers with HTTP_; X-Auth-Request-User +# arrives as request.META["HTTP_X_AUTH_REQUEST_USER"]. +_HEADER_KEY = "HTTP_X_AUTH_REQUEST_USER" + + +class BbEdgeIdentityMiddleware: + """Insert AFTER `plane.authentication.middleware.session.SessionMiddleware` + AND `django.contrib.auth.middleware.AuthenticationMiddleware` in MIDDLEWARE + so `request.session` and `request.user` are populated when this runs. + + Decision tree per request: + + - `request.user` anonymous (no Plane session yet) -> pass through. Login, + `/auth/sign-in-trusted/`, healthchecks (`/`), webhooks, and static assets + all fall in this bucket; no explicit skip-path list needed. + - No `X-Auth-Request-User` header (request didn't traverse oauth2-proxy: + mobile bearer-token clients, internal Docker probes, dev) -> pass through. + - Cookie missing -> lazy-populate via `Set-Cookie` on the response. Covers + legacy sessions minted before this patch; one extra `Set-Cookie` then the + next request is gated. + - Cookie != header -> edge identity changed. Flush the Django session, + replace `request.user` with `AnonymousUser` so downstream views/permissions + see the request as unauthenticated, and clear `_bb_edge_sub` on the + response. The request itself proceeds with anonymous identity (DRF's + `IsAuthenticated` returns 401; browser navigations hit the front-door + Traefik redirect to `bridge.binarybeach.io/handoff`). + - Cookie == header -> pass through. + """ + + def __init__(self, get_response): + self.get_response = get_response + + def __call__(self, request): + edge_sub = request.META.get(_HEADER_KEY, "") + cookie_sub = request.COOKIES.get(_COOKIE_NAME, "") + + write_cookie = "" + clear_cookie = False + + # Only validate authenticated requests. AuthenticationMiddleware sets + # request.user; before that runs (or if the chain ordering ever + # regresses) the attribute may be absent. + user = getattr(request, "user", None) + if edge_sub and user is not None and not user.is_anonymous: + if not cookie_sub: + write_cookie = edge_sub + elif cookie_sub != edge_sub: + log.warning( + "bb_edge_identity: edge identity changed; flushing session", + extra={ + "cookie_sub": cookie_sub, + "edge_sub": edge_sub, + "path": request.path, + }, + ) + try: + request.session.flush() + except Exception as exc: + log.error( + "bb_edge_identity: session flush failed", + extra={"err": str(exc)}, + ) + request.user = AnonymousUser() + clear_cookie = True + + response = self.get_response(request) + + # If the inner view explicitly set `_bb_edge_sub` (the trusted-JWT + # entry-point does this on session mint), don't override. Django's + # SimpleCookie holds whatever was set last; checking presence avoids + # stomping a fresh real value with our delete or stale lazy-populate. + if _COOKIE_NAME not in response.cookies: + if clear_cookie: + response.delete_cookie(_COOKIE_NAME, path="/") + elif write_cookie: + response.set_cookie( + _COOKIE_NAME, + write_cookie, + httponly=True, + secure=True, + samesite="Lax", + path="/", + ) + + return response diff --git a/apps/api/plane/settings/common.py b/apps/api/plane/settings/common.py index 9d651bd1b..c7b6a9650 100644 --- a/apps/api/plane/settings/common.py +++ b/apps/api/plane/settings/common.py @@ -68,6 +68,12 @@ MIDDLEWARE = [ "django.middleware.common.CommonMiddleware", "django.middleware.csrf.CsrfViewMiddleware", "django.contrib.auth.middleware.AuthenticationMiddleware", + # binarybeachio fork addition — per-app edge-identity validation. + # Must run after AuthenticationMiddleware (needs request.user) and after + # plane.authentication.middleware.session.SessionMiddleware (needs + # request.session). See plane/middleware/bb_edge_identity.py and + # binarybeachio/docs/conventions/per-app-edge-identity-validation.md. + "plane.middleware.bb_edge_identity.BbEdgeIdentityMiddleware", "django.middleware.clickjacking.XFrameOptionsMiddleware", "crum.CurrentRequestUserMiddleware", "django.middleware.gzip.GZipMiddleware",