bb-plane-fork/apps/api/plane/middleware/bb_edge_identity.py
binarybeach 64513797ee 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>
2026-05-05 13:31:02 -10:00

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