bb-plane-fork/BINARYBEACHIO.md
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

224 lines
18 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# bb-plane-fork — binarybeachio customizations of Plane
This file is the canonical contract between this fork and the binarybeachio platform repo. It exists so anyone (or any agent) on a fresh session can answer "what's customized, why, and how do I refresh from upstream" without reading code.
**Fork repo convention** (template — same shape for every Path B fork in binarybeachio):
```
upstream remote → original project on github.com (read-only, merge-source)
origin remote → git.binarybeach.io/binarybeach/bb-<name>-fork (where we push)
github mirror → github.com/binarybeachllc/bb-<name>-fork (push-mirror, off-site backup)
upstream branch — clean mirror of upstream's default branch, never modified
main branch — our customizations on top of latest upstream tag we've integrated
update/<v> — short-lived integration branch when pulling a new upstream version
```
`git log main..upstream` = "upstream changes I haven't pulled in"
`git log upstream..main` = "binarybeachio's customizations"
---
## Upstream
| Field | Value |
|---|---|
| Project | Plane (open-source project management) |
| Upstream repo | https://github.com/makeplane/plane |
| Upstream default branch | `preview` |
| Currently integrated upstream version | **v1.3.0** (release commit `cf696d2`) |
| License | AGPL-3.0-only (we MUST publish source of any deployed customizations — public Forgejo + push-mirror to GitHub satisfies this) |
## Why we forked (post-2026-05-04 platform-architecture pivot)
Plane's first-party OIDC support is gated behind the **Pro/Business commercial edition** (Pro tier minimum 25 users = $338+/mo). The community edition's `/god-mode/authentication/oidc` page is a frontend stub — the backend handler returns 404. Plane CE has GitHub/GitLab/Gitea/Google OAuth providers but no native OIDC, no SAML, and no trusted-proxy-header auth.
We integrate Plane into the binarybeachio platform via the **architecture's Bucket 4 pattern**: a single additive trusted-JWT endpoint that the platform's auth-bridge calls after oauth2-proxy validates a Zitadel session at the edge. See:
- `binarybeachio/docs/architecture/01-platform-architecture.md` for the bucket taxonomy and bridge contract
- `binarybeachio/docs/architecture/bridge-jwt-replay-protection.md` for the JWT replay-protection contract
- `binarybeachio/docs/services/plane/migration-plan.md` for the full per-service migration write-up
The previous shape of this fork (in-place patching of `provider/oauth/github.py` to repurpose GitHub OAuth as Zitadel OIDC) was reverted on 2026-05-04 in favor of the Bucket-4 additive endpoint, which has a smaller fork surface, fewer hot files to track on upstream merges, and centralizes OIDC handling in the auth-bridge instead of duplicating it per-app. The pre-revert source state is preserved on the `pre-migration-2026-05-04` branch for reference.
## What's customized (the inventory — keep current)
Three logical patch groups across the repo. Touch surface is intentionally minimal.
### Patch 1: Bucket-4 trusted-JWT entry-point (additive — 1 new file + 2 line additions)
| 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. **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 60006099 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:
- 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.<tenant>.binarybeach.io/auth/sign-in-trusted/?token=<jwt>&next_path=<rd>`.
- Plane's view: fetches public key from `BB_BRIDGE_PUBLIC_KEY_URL` (cached 5 min), verifies signature + claims, atomically `SETNX bb_bridge_jti:<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)
| File | Change | Risk on upgrade |
|---|---|---|
| `apps/api/plane/settings/storage.py` | `S3Storage.generate_presigned_post(...)` rewritten to mint a presigned PUT URL via `generate_presigned_url(HttpMethod="PUT")`. Method name preserved for caller compat. | **Medium.** If Plane's upload flow changes upstream, conflict surface grows. Candidate for upstream PR. |
| `apps/api/plane/utils/openapi/responses.py` | OpenAPI example response updated to PUT shape. | **Low.** |
| `apps/api/plane/tests/unit/settings/test_storage.py` | 2 tests retargeted to assert `generate_presigned_url` boto3 call. | **Low.** |
| `packages/types/src/file.ts` | `TFileSignedURLResponse.upload_data` adds `method?: "PUT" \| "POST"`, drops AWS POST-form-data fields. | **Low.** |
| `packages/services/src/file/helper.ts` | `generateFileUploadPayload(...)` returns a `TFileUploadRequest` descriptor; dispatches PUT/POST. | **Medium.** |
| `packages/services/src/file/file-upload.service.ts` + `apps/web/core/services/file-upload.service.ts` | `uploadFile(...)` signature changed to `(payload, progress?)`. Uses `axios.request({method, url, data, headers})`. | **Medium.** |
| `apps/web/core/services/file.service.ts`, `apps/web/core/services/issue/issue_attachment.service.ts`, `packages/services/src/file/sites-file.service.ts` | 5 caller sites updated to pass `TFileUploadRequest` to `uploadFile`. | **Low.** |
Decision record at `binarybeachio/docs/features/storage-upload-flow.md`. Patch 2 is independent of Patch 1 — `git revert <storage-PUT sha>` undoes it cleanly.
### Patch 3: Brand asset (kept as dormant; entry-point UX is Traefik-driven)
| File | Change | Risk on upgrade |
|---|---|---|
| `apps/web/app/assets/logos/binarybeach-logo.png` | New asset. Currently unreferenced; preserved for future AGPL §13 footer-link addition or other branding work. | **None.** |
The previous fork's GitHub-button rebrand patch (`apps/web/core/hooks/oauth/core.tsx`) was reverted on 2026-05-04. Sign-in entry-point UX is now driven by a Traefik `redirectregex` middleware applied to the per-tenant Plane router that 302s `/sign-in*`, `/sign-up*`, `/accounts/sign-in*` to `https://bridge.binarybeach.io/handoff?app=plane&tenant=<slug>&...`. Pure infrastructure config; no source modification needed for the redirect.
Files **not** changed (deliberately):
- `apps/api/plane/authentication/provider/oauth/github.py` — upstream-clean. Vanilla GitHub OAuth still works if configured via god-mode UI.
- `apps/api/plane/authentication/views/app/github.py` and the gitlab/gitea/google equivalents — all upstream-clean.
- `apps/admin/...` — god-mode UI unchanged.
- `apps/space/...` — public-share OAuth unchanged. Authenticated public boards continue to use email+password sign-in (vanilla upstream). When a tenant needs SSO for shared boards, add a sibling `views/space/trusted.py` (estimated ~80 LOC, mirrors the app/ view).
## Required runtime config
Set on the patched `plane-backend` container (binarybeachio sets these in `infrastructure/plane/.env`):
```bash
# 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`):
```bash
ADAPTER_PLANE_BINARYBEACH_BASE_URL=https://pm.binarybeach.binarybeach.io
# BRIDGE_SIGNING_KEY is loaded centrally by bridge-key.ts; the matching
# public key is served at /.well-known/bb-bridge.pub.pem and consumed by
# Plane via BB_BRIDGE_PUBLIC_KEY_URL.
```
Plane god-mode admin UI (`/god-mode/authentication/...`):
- All four upstream OAuth providers (GitHub/GitLab/Gitea/Google) can be left disabled. The trusted-JWT entry-point is the SSO front door.
- Email+password sign-in remains available as the break-glass admin path. Per-tenant `bb-admin` user is seeded with a permanent password from `_shared/.env.bb-admin`.
## Cross-fork conventions adopted
This fork pulls in binarybeachio's [session lifecycle convention](https://git.binarybeach.io/binarybeach/binarybeachio-platform/src/branch/main/docs/features/session-lifecycle.md) — 15-min idle timeout, slide-on-activity. Applied automatically by `bootstrap.py` at deploy. To override for this fork specifically, set `SESSION_COOKIE_AGE` / `ADMIN_SESSION_COOKIE_AGE` / `SESSION_SAVE_EVERY_REQUEST` in `infrastructure/plane/.env` (per-app .env beats convention).
## Refresh from upstream — the procedure
When a new Plane release lands and we want to integrate:
```bash
git fetch upstream
# Sync the upstream mirror branch (never touched by us)
git switch upstream
git reset --hard upstream/preview # or @v1.4.0 if we track tags
git push origin upstream
# Integration branch
git switch main
git switch -c update/v1.4.0
git merge upstream # likely conflict-free since fork is additive
# Hand-test:
# 1. Local stack via docker-compose.bb-local.yml — confirm sign-in works.
# 2. Trusted endpoint with a hand-minted JWT (helper script TBD; for now,
# mint via a node REPL using bridge-key.ts:signBridgeJwt).
# 3. Vanilla email+password regression test.
# Once happy:
git switch main
git merge --ff-only update/v1.4.0
git branch -d update/v1.4.0
git push origin main
# Then on laptop: rebuild + tag + push images (see "Build" below)
# Then in binarybeachio repo: bump tag in infrastructure/plane/docker-compose.yml
# Then: py infrastructure/_shared/bootstrap.py to trigger the Coolify deploy
```
## Build — which images to rebuild and how
Per binarybeachio architecture doc §7.4 ("only rebuild what we touched"), this fork only requires rebuilding **two of the six** Plane images:
| Image | Customized? | Source | Build target |
|---|---|---|---|
| `plane-backend` | YES (Patch 1 + Patch 2) | `apps/api/Dockerfile.api` | `git.binarybeach.io/binarybeach/plane-backend:v1.3.0-mine.<n>` |
| `plane-frontend` (aka web) | YES (Patch 2 frontend bits only) | `apps/web/Dockerfile.web` | `git.binarybeach.io/binarybeach/plane-frontend:v1.3.0-mine.<n>` |
| `plane-space` | no | upstream `makeplane/plane-space:v1.3.0` | (no rebuild) |
| `plane-admin` | no | upstream `makeplane/plane-admin:v1.3.0` | (no rebuild) |
| `plane-live` | no | upstream `makeplane/plane-live:v1.3.0` | (no rebuild) |
| `plane-proxy` | no | upstream `makeplane/plane-proxy:v1.3.0` | (no rebuild) |
Tag scheme per architecture §6 #7: `<upstream-version>-mine.<n>`. Push immutable tag + `:latest`:
```bash
# from C:\Users\maxwe\GitHubRepos\bb-plane-fork
docker build -t git.binarybeach.io/binarybeach/plane-backend:v1.3.0-mine.2 \
-t git.binarybeach.io/binarybeach/plane-backend:latest \
-f apps/api/Dockerfile.api .
docker push git.binarybeach.io/binarybeach/plane-backend:v1.3.0-mine.2
docker push git.binarybeach.io/binarybeach/plane-backend:latest
docker build -t git.binarybeach.io/binarybeach/plane-frontend:v1.3.0-mine.2 \
-t git.binarybeach.io/binarybeach/plane-frontend:latest \
-f apps/web/Dockerfile.web .
docker push git.binarybeach.io/binarybeach/plane-frontend:v1.3.0-mine.2
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:
1. **Forgejo source**`git.binarybeach.io/binarybeach/bb-plane-fork` is publicly readable.
2. **GitHub mirror** — push-mirror to `github.com/binarybeachllc/bb-plane-fork`.
3. **In-product source link** — TODO. AGPL §13 requires "prominent" notice to network users; a footer suffices. Tracked separately.
## Test plan (manual, until we have CI)
1. **Local build smoke**: both images build cleanly.
2. **Local stack**: `docker compose -f docker-compose.bb-local.yml --env-file .env.bb-local up -d` (with `BB_BRIDGE_PUBLIC_KEY_URL` unset) → vanilla email+password sign-in works (regression check).
3. **Trusted-JWT happy path**: with `BB_BRIDGE_PUBLIC_KEY_URL` pointing at production bridge, hand-mint a JWT (claims: `iss=bb-bridge`, `aud=plane`, valid `exp`, fresh `jti`, valid email), `GET /auth/sign-in-trusted/?token=<jwt>&next_path=/`, expect 302 to `/` with sessionid cookie set.
4. **Trusted-JWT replay rejection**: hit the same URL with the same token twice. First → 302 + sessionid. Second → 302 to error redirect with `TRUSTED_JWT_TOKEN_REPLAYED`.
5. **Trusted-JWT disabled regression**: unset `BB_BRIDGE_PUBLIC_KEY_URL`, hit `/auth/sign-in-trusted/`, expect 404.
6. **Production deploy**: bump tag in `binarybeachio/infrastructure/plane/docker-compose.yml``py infrastructure/_shared/bootstrap.py` → verify on `pm.binarybeach.binarybeach.io`.