binarybeachio: per-app edge-identity validation + bundled bridge logout
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) <noreply@anthropic.com>
This commit is contained in:
parent
69b499c9ec
commit
64513797ee
5 changed files with 193 additions and 4 deletions
|
|
@ -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-<uuid>: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.<n>` 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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
112
apps/api/plane/middleware/bb_edge_identity.py
Normal file
112
apps/api/plane/middleware/bb_edge_identity.py
Normal file
|
|
@ -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
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue