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>
112 lines
4.8 KiB
Python
112 lines
4.8 KiB
Python
# 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
|