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>
224 lines
18 KiB
Markdown
224 lines
18 KiB
Markdown
# 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 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:
|
||
|
||
- 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`.
|