Compare commits

..

No commits in common. "main" and "upstream" have entirely different histories.

23 changed files with 93 additions and 1143 deletions

View file

@ -1,17 +0,0 @@
# bb-plane-fork local-test env — copy to `.env.bb-local` and fill in.
# Gitignored. Used by docker-compose.bb-local.yml.
# Bucket-4 trusted-JWT endpoint (apps/api/plane/authentication/views/app/trusted.py).
# Activated when this URL is set; unset → endpoint returns 404 (regression-safe
# default; vanilla upstream behavior preserved out of the box).
#
# Production points at the in-cluster bridge service:
# http://auth-bridge-<uuid>:3000/.well-known/bb-bridge.pub.pem
# Local dev typically points at a manually-served PEM (e.g. via `python3 -m http.server`)
# or at the production bridge for read-only key fetch testing:
# https://bridge.binarybeach.io/.well-known/bb-bridge.pub.pem
BB_BRIDGE_PUBLIC_KEY_URL=
# When BB_BRIDGE_PUBLIC_KEY_URL is unset, the trusted endpoint is disabled and
# Plane behaves like upstream-vanilla (email+password sign-in, the four
# stock OAuth providers). That's the right default for purely-local hacking.

6
.gitignore vendored
View file

@ -41,9 +41,6 @@ pnpm-debug.log*
.env.test.local
.env.production.local
# binarybeachio fork-local test env (Zitadel OIDC client creds)
.env.bb-local
# Vercel
.vercel
@ -113,6 +110,3 @@ build/
.react-router/
temp/
scripts/
# binarybeachio: Cloudflare Wrangler local dev cache (when used for *.binarybeach.io DNS work)
.wrangler/

View file

@ -1,232 +0,0 @@
# 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
# Backend — Dockerfile.api COPY paths are relative to apps/api/, so the
# build context must be apps/api/, NOT the repo root. Building from the
# repo root (`-f apps/api/Dockerfile.api .`) fails: "/plane: not found".
cd C:\Users\maxwe\GitHubRepos\bb-plane-fork\apps\api
docker build -t git.binarybeach.io/binarybeach/plane-backend:v1.3.0-mine.<n> \
-t git.binarybeach.io/binarybeach/plane-backend:latest \
-f Dockerfile.api .
docker push git.binarybeach.io/binarybeach/plane-backend:v1.3.0-mine.<n>
docker push git.binarybeach.io/binarybeach/plane-backend:latest
# Frontend — OPPOSITE convention: Dockerfile.web does `COPY . .` then
# `turbo prune --scope=web --docker`, so the context must be the monorepo
# ROOT (turbo needs to see all workspaces to prune correctly).
cd C:\Users\maxwe\GitHubRepos\bb-plane-fork
docker build -t git.binarybeach.io/binarybeach/plane-frontend:v1.3.0-mine.<n> \
-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.<n>
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-frontend:v1.3.0-mine.4` | v1.3.0 | 2026-05-05 | SPA 401 interceptor hard-navs to `/sign-in/?_bb_reauth=<ts>` (with cache-bust ts) instead of `/?next_path=...`. Vanilla path was a hard nav too, but the browser HTTP cache returned the cached SPA bundle for `/`, looping on every XHR 401 without ever hitting Traefik. Routing through `/sign-in/` matches the priority-200 plane-signin-redirect Traefik router → bridge handoff. Bundled with the mine.7 backend edge-identity work. |
| `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. mine.3 was Patch 2 frontend bits (presigned-PUT plumbing). mine.4 (2026-05-05) is the 401-interceptor hard-nav fix bundled with the edge-identity rollout.
## 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`.

View file

@ -71,17 +71,6 @@ AUTHENTICATION_ERROR_CODES = {
"RATE_LIMIT_EXCEEDED": 5900,
# Unknown
"AUTHENTICATION_FAILED": 5999,
# binarybeachio fork addition (Bucket-4 trusted-JWT entry-point) — see
# views/app/trusted.py and BINARYBEACHIO.md. Codes 6000-6099 are reserved
# for fork additions to keep them outside the upstream-allocated 5000-5999
# range and reduce upstream-merge collision risk.
"TRUSTED_JWT_ENDPOINT_DISABLED": 6000,
"TRUSTED_JWT_TOKEN_MISSING": 6001,
"TRUSTED_JWT_TOKEN_INVALID": 6002,
"TRUSTED_JWT_TOKEN_EXPIRED": 6003,
"TRUSTED_JWT_TOKEN_REPLAYED": 6004,
"TRUSTED_JWT_REPLAY_STORE_DOWN": 6005,
"TRUSTED_JWT_KEY_FETCH_FAILED": 6006,
}

View file

@ -44,8 +44,6 @@ from .views import (
GiteaOauthInitiateEndpoint,
GiteaCallbackSpaceEndpoint,
GiteaOauthInitiateSpaceEndpoint,
# binarybeachio fork addition — see views/app/trusted.py.
TrustedSignInEndpoint,
)
urlpatterns = [
@ -152,7 +150,4 @@ urlpatterns = [
GiteaCallbackSpaceEndpoint.as_view(),
name="space-gitea-callback",
),
# binarybeachio fork addition — Bucket-4 trusted-JWT entry-point.
# See views/app/trusted.py and BINARYBEACHIO.md.
path("sign-in-trusted/", TrustedSignInEndpoint.as_view(), name="sign-in-trusted"),
]

View file

@ -41,7 +41,3 @@ from .space.password_management import (
ResetPasswordSpaceEndpoint,
)
from .app.password_management import ForgotPasswordEndpoint, ResetPasswordEndpoint
# binarybeachio fork addition (Bucket-4 trusted-JWT entry-point) — see
# views/app/trusted.py and BINARYBEACHIO.md.
from .app.trusted import TrustedSignInEndpoint

View file

@ -2,9 +2,6 @@
# 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
@ -16,21 +13,6 @@ 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
@ -41,15 +23,6 @@ class SignOutAuthEndpoint(View):
user.save()
# Log the user out
logout(request)
return HttpResponseRedirect(base_host(request=request, is_app=True))
except Exception:
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
return HttpResponseRedirect(base_host(request=request, is_app=True))

View file

@ -1,345 +0,0 @@
# 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.
#
# Bucket-4 trusted-JWT entry-point. Validates a short-lived RS256 JWT signed
# by the binarybeachio auth-bridge (private key BRIDGE_SIGNING_KEY), enforces
# single-use replay protection via shared-redis SETNX (per the contract in
# `binarybeachio/docs/architecture/bridge-jwt-replay-protection.md`), then
# finds-or-creates the corresponding User and starts a Django session via
# the existing user_login() helper.
#
# Endpoint behavior when not configured:
# - If BB_BRIDGE_PUBLIC_KEY_URL env is unset → 404 (endpoint disabled).
# Vanilla upstream behavior is preserved out-of-the-box; the trusted-JWT
# entry-point only exists in deployments that explicitly opt in.
#
# Public-key transport:
# - Fetched at request time from BB_BRIDGE_PUBLIC_KEY_URL (typically
# `http://auth-bridge-<uuid>:3000/.well-known/bb-bridge.pub.pem`).
# - Cached in-process for 5 minutes; auto-refreshed on signature failure
# to handle bridge key rotation transparently.
# - This sidesteps the env-PEM corruption issue: putting RSA PEMs through
# Coolify's .env writer escapes backslashes (`\n` → `\\n`), which
# corrupts the multi-line PEM. HTTP fetch never traverses that path.
# See bb-activepieces-fork/.../trusted-jwt-verifier.ts module-doc for
# the original write-up.
#
# Replay protection:
# - Bridge mints with a UUIDv4 `jti` claim.
# - This view atomically SETNX `bb_bridge_jti:<jti>` in shared-redis with
# TTL = (exp - now) + 30s clock-skew tolerance.
# - Fail closed: if Redis is unavailable, REJECT. Auth correctness >
# auth availability; break-glass admin (email+password) covers operator
# access during a Redis outage.
import logging
import os
import time
import uuid
from typing import Optional, Tuple
from urllib.parse import urlparse
import jwt as pyjwt
import redis
import requests
from django.db import transaction
from django.http import HttpResponseRedirect, HttpResponseNotFound
from django.views import View
from plane.authentication.adapter.error import (
AUTHENTICATION_ERROR_CODES,
AuthenticationException,
)
from plane.authentication.utils.host import base_host
from plane.authentication.utils.login import user_login
from plane.authentication.utils.redirection_path import get_redirection_path
from plane.authentication.utils.user_auth_workflow import post_user_auth_workflow
from plane.db.models import Profile, User
from plane.settings.redis import redis_instance
from plane.utils.path_validator import get_safe_redirect_url
log = logging.getLogger("plane.authentication.trusted")
# Audience the bridge sets in JWTs minted for Plane (signBridgeJwt(..., audience: 'plane')).
_EXPECTED_AUDIENCE = "plane"
# Issuer the bridge sets (every adapter shares this).
_EXPECTED_ISSUER = "bb-bridge"
# Replay-store key prefix per bridge-jwt-replay-protection.md.
_JTI_KEY_PREFIX = "bb_bridge_jti:"
# Clock-skew tolerance applied to exp/iat checks.
_CLOCK_SKEW_SECONDS = 30
# Public-key cache (in-process). Keyed on URL so test/dev with multiple
# bridges per process is safe. _key_cache: {url: (pem, fetched_at_epoch)}.
_KEY_CACHE_TTL_SECONDS = 5 * 60
_key_cache: dict[str, Tuple[str, float]] = {}
def _bridge_public_key_url() -> Optional[str]:
"""Returns the configured bridge public-key URL, or None if disabled.
The endpoint is implicitly disabled (returns 404) when this env is unset
the regression-safe default for builds shipped without the bridge wired up.
"""
return os.environ.get("BB_BRIDGE_PUBLIC_KEY_URL") or None
def _fetch_bridge_public_key(url: str, *, force_refresh: bool = False) -> str:
"""Fetch (and cache) the bridge's public key PEM. Refetches on signature
failure or after the cache TTL elapses. Falls back to stale cache if a
refresh fails temporarily-unreachable bridge shouldn't brick logins."""
now = time.time()
cached = _key_cache.get(url)
if not force_refresh and cached and (now - cached[1]) < _KEY_CACHE_TTL_SECONDS:
return cached[0]
try:
resp = requests.get(url, timeout=3.0, headers={"accept": "application/x-pem-file"})
resp.raise_for_status()
pem = resp.text
if "-----BEGIN PUBLIC KEY-----" not in pem:
raise ValueError(f"non-PEM body from {url} (first 80: {pem[:80]!r})")
_key_cache[url] = (pem, now)
return pem
except Exception as exc:
if cached:
log.warning("bridge public-key fetch failed; using stale cache", extra={"url": url, "err": str(exc)})
return cached[0]
raise
def _consume_jti(jti: str, exp_epoch: int) -> Tuple[bool, Optional[str]]:
"""Atomically mark a `jti` consumed in shared-redis. Returns (first_use, error_code).
- (True, None) not previously consumed; admit the request.
- (False, code) either already consumed (TRUSTED_JWT_TOKEN_REPLAYED) or
the replay store is unavailable (TRUSTED_JWT_REPLAY_STORE_DOWN). Either
way, REJECT the request (fail closed).
TTL = (exp - now) + 30s clock-skew tolerance, with a 30s minimum floor for
edge cases where exp is already past at consumption time (signature still
valid under clock-skew tolerance).
"""
if not jti or not exp_epoch:
return False, "TRUSTED_JWT_TOKEN_INVALID"
try:
client = redis_instance()
except Exception as exc:
log.error("replay store init failed", extra={"err": str(exc)})
return False, "TRUSTED_JWT_REPLAY_STORE_DOWN"
try:
ttl = max(int(exp_epoch - time.time()) + _CLOCK_SKEW_SECONDS, 30)
# SET key value NX EX ttl -- returns True on first-set, None if already set.
ok = client.set(_JTI_KEY_PREFIX + jti, "1", nx=True, ex=ttl)
if ok is None:
return False, "TRUSTED_JWT_TOKEN_REPLAYED"
return True, None
except redis.RedisError as exc:
log.error("replay store SETNX failed", extra={"err": str(exc), "jti": jti})
return False, "TRUSTED_JWT_REPLAY_STORE_DOWN"
def _redirect_with_error(request, error_code: str, error_message: str, next_path: str) -> HttpResponseRedirect:
"""Surface the failure as a Plane-style redirect to the host with error params."""
exc = AuthenticationException(
error_code=AUTHENTICATION_ERROR_CODES[error_code],
error_message=error_message,
)
return HttpResponseRedirect(
get_safe_redirect_url(
base_url=base_host(request=request, is_app=True),
next_path=next_path,
params=exc.get_error_dict(),
)
)
def _verify_with_retry(token: str, public_key_url: str) -> dict:
"""Verify the JWT, refetching the bridge key once on signature failure to
transparently handle bridge key rotation. Other verify failures (expired,
wrong issuer/audience, malformed) do NOT trigger a refetch those are
tampering or clock issues, not key drift.
`bb_mailbox` is intentionally NOT in the required-claims list. The bridge
only emits it when Zitadel's `bb-claims` Action has propagated and the
tenant's `mail_domain` org metadata is set; absent the claim, the view
falls back to `email` for User keying so a partial deployment (bridge
upgraded but Action not yet live) still works. See the four-layer model
in `binarybeachio/docs/architecture/multi-tenant-identity.md` §4."""
pem = _fetch_bridge_public_key(public_key_url)
try:
return pyjwt.decode(
token,
pem,
algorithms=["RS256"],
audience=_EXPECTED_AUDIENCE,
issuer=_EXPECTED_ISSUER,
leeway=_CLOCK_SKEW_SECONDS,
options={"require": ["exp", "iat", "sub", "email", "jti"]},
)
except pyjwt.InvalidSignatureError:
log.warning("trusted-jwt signature failed; refetching bridge key", extra={"url": public_key_url})
pem = _fetch_bridge_public_key(public_key_url, force_refresh=True)
return pyjwt.decode(
token,
pem,
algorithms=["RS256"],
audience=_EXPECTED_AUDIENCE,
issuer=_EXPECTED_ISSUER,
leeway=_CLOCK_SKEW_SECONDS,
options={"require": ["exp", "iat", "sub", "email", "jti"]},
)
class TrustedSignInEndpoint(View):
"""GET /auth/sign-in-trusted/?token=<jwt>&next_path=<rel-path>
The bridge 302s the browser here after a successful oauth2-proxy session
is established. We verify the JWT, claim its `jti` to prevent replay,
find-or-create the User, and call user_login() to set the Django session
cookie. Then 302 the user to next_path on the same host.
"""
def get(self, request):
public_key_url = _bridge_public_key_url()
if not public_key_url:
# Endpoint disabled — bridge not wired up in this deployment.
return HttpResponseNotFound()
# Validate next_path on every exit — even error redirects honor it so
# the user lands somewhere sensible. get_safe_redirect_url further
# constrains to the trusted base host.
next_path = request.GET.get("next_path") or "/"
token = request.GET.get("token")
if not token:
return _redirect_with_error(request, "TRUSTED_JWT_TOKEN_MISSING", "TRUSTED_JWT_TOKEN_MISSING", next_path)
try:
claims = _verify_with_retry(token, public_key_url)
except pyjwt.ExpiredSignatureError:
return _redirect_with_error(request, "TRUSTED_JWT_TOKEN_EXPIRED", "TRUSTED_JWT_TOKEN_EXPIRED", next_path)
except pyjwt.InvalidTokenError as e:
log.warning("trusted-jwt invalid", extra={"err_class": e.__class__.__name__})
return _redirect_with_error(request, "TRUSTED_JWT_TOKEN_INVALID", f"TRUSTED_JWT_TOKEN_INVALID: {e.__class__.__name__}", next_path)
except Exception as e:
log.error("trusted-jwt key fetch failed", extra={"err": str(e)})
return _redirect_with_error(request, "TRUSTED_JWT_KEY_FETCH_FAILED", "TRUSTED_JWT_KEY_FETCH_FAILED", next_path)
# Replay enforcement — atomic SETNX in shared-redis. Fail closed.
first_use, replay_err = _consume_jti(claims.get("jti", ""), int(claims.get("exp", 0)))
if not first_use:
log.warning(
"trusted-jwt rejected by replay-store",
extra={"jti": claims.get("jti"), "sub": claims.get("sub"), "code": replay_err},
)
return _redirect_with_error(request, replay_err or "TRUSTED_JWT_TOKEN_REPLAYED", replay_err or "TRUSTED_JWT_TOKEN_REPLAYED", next_path)
email = (claims.get("email") or "").strip().lower()
if not email:
return _redirect_with_error(request, "TRUSTED_JWT_TOKEN_INVALID", "TRUSTED_JWT_TOKEN_NO_EMAIL", next_path)
# Identity-model rollout (multi-tenant-identity.md §4): prefer
# `bb_mailbox` over `email` for User keying. `email` carries the
# federation address (e.g., the user's Google login) — it's useful
# for auditing but it is NOT the canonical per-tenant identity. The
# canonical identity is `bb_mailbox` = `<bb_username>@<bb_mail_domain>`,
# the address Stalwart actually hosts and the address the operator
# invites users at.
#
# If `bb_mailbox` is absent, fall back to `email` so a transitional
# deployment (bridge already upgraded but Zitadel `bb-claims` Action
# not yet propagated) keeps working. The WARN log is the operator's
# signal that the chain is still incomplete.
bb_mailbox = (claims.get("bb_mailbox") or "").strip().lower()
if bb_mailbox:
lookup_email = bb_mailbox
else:
log.warning(
"trusted-jwt missing bb_mailbox claim; falling back to federation email — "
"verify Zitadel `bb-claims` Action is published and the tenant's "
"`mail_domain` org metadata is set",
extra={"sub": claims.get("sub"), "email": email, "tenant": claims.get("tenant")},
)
lookup_email = email
# Find-or-create. We mirror the User-creation shape that
# OauthAdapter.complete_login_or_signup() produces (apps/api/plane/
# authentication/adapter/base.py:289-342) — same field set, same
# required side-effect of creating a Profile row. Skipping the Profile
# would cause Plane's SPA /api/users/me/profile/ to 404 and bounce
# the user back to /login in an onboarding loop.
user = User.objects.filter(email=lookup_email).first()
created = user is None
if created:
with transaction.atomic():
user = User(
email=lookup_email,
username=uuid.uuid4().hex,
first_name=claims.get("first_name") or claims.get("given_name") or "",
last_name=claims.get("last_name") or claims.get("family_name") or "",
is_password_autoset=True,
is_email_verified=True,
)
# Random password — user signs in via SSO; the password exists
# only so Django's auth machinery has a non-empty hash and the
# break-glass admin pattern can be applied later by ALTER-ing
# this user out of band if needed.
user.set_password(uuid.uuid4().hex)
user.save()
# Profile row is mandatory: every Plane API endpoint that
# touches user state (workspace listings, onboarding) reads
# Profile, and the SPA's /api/users/me/profile/ 404s without it.
Profile.objects.create(user=user)
# Plane's existing post-auth workflow (default workspace, invitations, etc.)
post_user_auth_workflow(user=user, is_signup=created, request=request)
# Set Django session cookie via the existing helper.
user_login(request=request, user=user, is_app=True)
# NOTE: do NOT name extra keys after LogRecord built-in attributes
# (`name`, `created`, `levelname`, `module`, `message`, etc.) —
# Logger.makeRecord raises KeyError("Attempt to overwrite %r in LogRecord")
# on collision. Use is_signup instead of created.
log.info(
"trusted-jwt sign-in",
extra={
"jti": claims.get("jti"),
"sub": claims.get("sub"),
"email": email,
"lookup_email": lookup_email,
"bb_mailbox_present": bool(bb_mailbox),
"tenant": claims.get("tenant"),
"is_signup": created,
},
)
target = next_path or get_redirection_path(user=user)
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

View file

@ -1,112 +0,0 @@
# 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

View file

@ -68,12 +68,6 @@ 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",

View file

@ -63,58 +63,40 @@ class S3Storage(S3Boto3Storage):
)
def generate_presigned_post(self, object_name, file_type, file_size, expiration=None):
"""Generate a presigned URL for browser-direct upload.
BB-PATCH (binarybeachio fork): method name preserved for caller
compat, but this now mints a presigned PUT URL not POST.
Why: Cloudflare R2 and Backblaze B2 the two most common self-host
S3-compatible backends do NOT implement S3 PostObject. Both return
HTTP 501 NotImplemented for the bucket-form POST verb that vanilla
Plane uses. Confirmed empirically against both backends 2026-04-30.
Rolling our own backend support isn't tractable; PUT is universally
supported (R2, B2, AWS S3, MinIO, Wasabi, etc).
Tradeoff: we lose signed enforcement of `content-length-range`. Size
is still validated server-side at presign time via the `file_size`
param (see callers: 413 raised before we get here), so a determined
client could only over-upload by misreporting the size they'd be
capped by the bucket's max-file-size at worst.
See docs/features/storage-upload-flow.md in the binarybeachio repo
for the full decision record + rollback procedure (`git revert` this
commit and rebuild the images).
"""
"""Generate a presigned URL to upload an S3 object"""
if expiration is None:
expiration = self.signed_url_expiration
# Default to application/octet-stream when caller passes empty/None.
# The file-type library Plane's frontend uses returns "" for unsniffable
# formats (plain text, .json, etc.), which would sign a presigned URL
# with `Content-Type=""`. Browsers can't reliably send an empty
# Content-Type header, so the SigV4 signature would never match and PUT
# would 403. We resolve this by signing a definite default; the
# frontend then sends the signed value verbatim (see helper.ts).
signed_content_type = file_type or "application/octet-stream"
fields = {"Content-Type": file_type}
conditions = [
{"bucket": self.aws_storage_bucket_name},
["content-length-range", 1, file_size],
{"Content-Type": file_type},
]
# Add condition for the object name (key)
if object_name.startswith("${filename}"):
conditions.append(["starts-with", "$key", object_name[: -len("${filename}")]])
else:
fields["key"] = object_name
conditions.append({"key": object_name})
# Generate the presigned POST URL
try:
url = self.s3_client.generate_presigned_url(
"put_object",
Params={
"Bucket": self.aws_storage_bucket_name,
"Key": object_name,
"ContentType": signed_content_type,
},
# Generate a presigned URL for the S3 object
response = self.s3_client.generate_presigned_post(
Bucket=self.aws_storage_bucket_name,
Key=object_name,
Fields=fields,
Conditions=conditions,
ExpiresIn=expiration,
HttpMethod="PUT",
)
# Handle errors
except ClientError as e:
print(f"Error generating presigned PUT URL: {e}")
print(f"Error generating presigned POST URL: {e}")
return None
return {
"url": url,
"method": "PUT",
"fields": {"Content-Type": signed_content_type, "key": object_name},
}
return response
def _get_content_disposition(self, disposition, filename=None):
"""Helper method to generate Content-Disposition header value"""

View file

@ -63,15 +63,13 @@ class TestS3StorageSignedURLExpiration:
)
@patch("plane.settings.storage.boto3")
def test_generate_presigned_post_uses_default_expiration(self, mock_boto3):
"""Test that generate_presigned_post uses the configured default expiration
BB-PATCH: generate_presigned_post now mints a presigned PUT URL under
the hood (R2/B2 don't implement PostObject). Test asserts the
underlying generate_presigned_url call rather than generate_presigned_post.
"""
"""Test that generate_presigned_post uses the configured default expiration"""
# Mock the boto3 client and its response
mock_s3_client = Mock()
mock_s3_client.generate_presigned_url.return_value = "https://test-url.com"
mock_s3_client.generate_presigned_post.return_value = {
"url": "https://test-url.com",
"fields": {},
}
mock_boto3.client.return_value = mock_s3_client
# Create S3Storage instance
@ -81,10 +79,9 @@ class TestS3StorageSignedURLExpiration:
storage.generate_presigned_post("test-object", "image/png", 1024)
# Assert that the boto3 method was called with the default expiration (3600)
mock_s3_client.generate_presigned_url.assert_called_once()
call_kwargs = mock_s3_client.generate_presigned_url.call_args[1]
mock_s3_client.generate_presigned_post.assert_called_once()
call_kwargs = mock_s3_client.generate_presigned_post.call_args[1]
assert call_kwargs["ExpiresIn"] == 3600
assert call_kwargs["HttpMethod"] == "PUT"
@patch.dict(
os.environ,
@ -99,14 +96,13 @@ class TestS3StorageSignedURLExpiration:
)
@patch("plane.settings.storage.boto3")
def test_generate_presigned_post_uses_custom_expiration(self, mock_boto3):
"""Test that generate_presigned_post uses custom expiration from env variable
BB-PATCH: see test_generate_presigned_post_uses_default_expiration for
why this asserts generate_presigned_url instead of generate_presigned_post.
"""
"""Test that generate_presigned_post uses custom expiration from env variable"""
# Mock the boto3 client and its response
mock_s3_client = Mock()
mock_s3_client.generate_presigned_url.return_value = "https://test-url.com"
mock_s3_client.generate_presigned_post.return_value = {
"url": "https://test-url.com",
"fields": {},
}
mock_boto3.client.return_value = mock_s3_client
# Create S3Storage instance with SIGNED_URL_EXPIRATION=60
@ -116,10 +112,9 @@ class TestS3StorageSignedURLExpiration:
storage.generate_presigned_post("test-object", "image/png", 1024)
# Assert that the boto3 method was called with custom expiration (60)
mock_s3_client.generate_presigned_url.assert_called_once()
call_kwargs = mock_s3_client.generate_presigned_url.call_args[1]
mock_s3_client.generate_presigned_post.assert_called_once()
call_kwargs = mock_s3_client.generate_presigned_post.call_args[1]
assert call_kwargs["ExpiresIn"] == 60
assert call_kwargs["HttpMethod"] == "PUT"
@patch.dict(
os.environ,

View file

@ -415,11 +415,12 @@ GENERIC_ASSET_UPLOAD_SUCCESS_RESPONSE = OpenApiResponse(
name="Generic Asset Upload Response",
value={
"upload_data": {
"url": "https://s3.amazonaws.com/bucket-name/workspace-id/uuid-filename.pdf?X-Amz-Signature=...",
"method": "PUT",
"url": "https://s3.amazonaws.com/bucket-name",
"fields": {
"Content-Type": "application/pdf",
"key": "workspace-id/uuid-filename.pdf",
"AWSAccessKeyId": "AKIA...",
"policy": "eyJ...",
"signature": "abc123...",
},
},
"asset_id": "550e8400-e29b-41d4-a716-446655440000",

Binary file not shown.

Before

Width:  |  Height:  |  Size: 788 KiB

View file

@ -27,21 +27,8 @@ export abstract class APIService {
(response) => response,
(error) => {
if (error.response && error.response.status === 401) {
// binarybeachio fork — re-auth must miss the browser HTTP cache.
// Vanilla Plane navigated to `/?next_path=<currentPath>` which is the
// SPA root route; the browser served the cached SPA bundle, the SPA
// re-fetched the same /api endpoint, the call 401'd again, and the
// page looped without ever hitting the network at the document
// level. Plane's Traefik plane-signin-redirect router (priority 200,
// matching `^/(sign-in|...)`) catches `/sign-in/` regardless of
// cookie state and 302s to the bridge handoff — but only if the
// browser actually fetches it. Append a ts param so the URL is
// never in cache, and target /sign-in/ so the priority-200 router
// wins. Vanilla Plane handles /sign-in/ in its SPA too (the SPA
// bounces it to /), so the patch is also benign in non-platform
// deployments. See binarybeachio/docs/conventions/per-app-edge-
// identity-validation.md and feedback_plane_spa_cached_loop.
window.location.replace(`/sign-in/?_bb_reauth=${Date.now()}`);
const currentPath = window.location.pathname;
window.location.replace(`/${currentPath ? `?next_path=${currentPath}` : ``}`);
}
return Promise.reject(error);
}

View file

@ -6,8 +6,6 @@
import type { AxiosRequestConfig } from "axios";
import axios from "axios";
// plane services
import type { TFileUploadRequest } from "@plane/services";
// services
import { APIService } from "@/services/api.service";
@ -18,18 +16,16 @@ export class FileUploadService extends APIService {
super("");
}
// BB-PATCH: dispatches on payload.method (PUT for fork default, POST kept
// for upstream-Plane parity). See packages/services/src/file/helper.ts.
async uploadFile(
payload: TFileUploadRequest,
url: string,
data: FormData,
uploadProgressHandler?: AxiosRequestConfig["onUploadProgress"]
): Promise<void> {
this.cancelSource = axios.CancelToken.source();
return this.request({
method: payload.method,
url: payload.url,
data: payload.body,
headers: payload.headers,
return this.post(url, data, {
headers: {
"Content-Type": "multipart/form-data",
},
cancelToken: this.cancelSource.token,
withCredentials: false,
onUploadProgress: uploadProgressHandler,

View file

@ -83,7 +83,11 @@ export class FileService extends APIService {
.then(async (response) => {
const signedURLResponse: TFileSignedURLResponse = response?.data;
const fileUploadPayload = generateFileUploadPayload(signedURLResponse, file);
await this.fileUploadService.uploadFile(fileUploadPayload, uploadProgressHandler);
await this.fileUploadService.uploadFile(
signedURLResponse.upload_data.url,
fileUploadPayload,
uploadProgressHandler
);
await this.updateWorkspaceAssetUploadStatus(workspaceSlug.toString(), signedURLResponse.asset_id);
return signedURLResponse;
})
@ -156,7 +160,11 @@ export class FileService extends APIService {
.then(async (response) => {
const signedURLResponse: TFileSignedURLResponse = response?.data;
const fileUploadPayload = generateFileUploadPayload(signedURLResponse, file);
await this.fileUploadService.uploadFile(fileUploadPayload, uploadProgressHandler);
await this.fileUploadService.uploadFile(
signedURLResponse.upload_data.url,
fileUploadPayload,
uploadProgressHandler
);
await this.updateProjectAssetUploadStatus(workspaceSlug, projectId, signedURLResponse.asset_id);
return signedURLResponse;
})
@ -182,7 +190,7 @@ export class FileService extends APIService {
.then(async (response) => {
const signedURLResponse: TFileSignedURLResponse = response?.data;
const fileUploadPayload = generateFileUploadPayload(signedURLResponse, file);
await this.fileUploadService.uploadFile(fileUploadPayload);
await this.fileUploadService.uploadFile(signedURLResponse.upload_data.url, fileUploadPayload);
await this.updateUserAssetUploadStatus(signedURLResponse.asset_id);
return signedURLResponse;
})

View file

@ -55,7 +55,11 @@ export class IssueAttachmentService extends APIService {
.then(async (response) => {
const signedURLResponse: TIssueAttachmentUploadResponse = response?.data;
const fileUploadPayload = generateFileUploadPayload(signedURLResponse, file);
await this.fileUploadService.uploadFile(fileUploadPayload, uploadProgressHandler);
await this.fileUploadService.uploadFile(
signedURLResponse.upload_data.url,
fileUploadPayload,
uploadProgressHandler
);
await this.updateIssueAttachmentUploadStatus(workspaceSlug, projectId, issueId, signedURLResponse.asset_id);
return signedURLResponse.attachment;
})

View file

@ -1,216 +0,0 @@
# bb-plane-fork local-test compose — binarybeachio
# ---------------------------------------------------------------------------
# Spins up a Plane stack on the laptop using:
# - OUR PATCHED images (plane-backend, plane-frontend) built from this fork
# - Upstream-vanilla images for the other 4 services (per architecture
# doc §7.4 — only build what we touched)
# - Ephemeral local Postgres + Redis + RabbitMQ + MinIO (NOT shared-postgres;
# this is a destructible dev stack — `docker compose down -v` wipes everything)
# - Hosted Zitadel (auth.binarybeach.io) for the OIDC flow
#
# Build first, then run:
#
# # Build patched images locally
# docker build -t plane-backend:bb-local -f apps/api/Dockerfile.api apps/api/
# docker build -t plane-frontend:bb-local -f apps/web/Dockerfile.web .
#
# # Bring up
# docker compose -f docker-compose.bb-local.yml --env-file .env.bb-local up -d
#
# # Watch logs
# docker compose -f docker-compose.bb-local.yml logs -f api worker
#
# # Visit http://localhost:8888 — log in with email+password (break-glass-style)
# # or, with BB_BRIDGE_PUBLIC_KEY_URL set, exercise the trusted-JWT endpoint
# # by hand: GET http://localhost:8888/auth/sign-in-trusted/?token=<jwt>
#
# Required env (.env.bb-local — gitignored):
# BB_BRIDGE_PUBLIC_KEY_URL= # leave unset for vanilla email+password testing
# ---------------------------------------------------------------------------
x-db-env: &db-env
PGHOST: plane-db
PGDATABASE: plane
POSTGRES_USER: plane
POSTGRES_PASSWORD: plane
POSTGRES_DB: plane
POSTGRES_PORT: 5432
PGDATA: /var/lib/postgresql/data
x-redis-env: &redis-env
REDIS_HOST: plane-redis
REDIS_PORT: 6379
REDIS_URL: redis://plane-redis:6379/
x-mq-env: &mq-env
RABBITMQ_HOST: plane-mq
RABBITMQ_PORT: 5672
RABBITMQ_DEFAULT_USER: plane
RABBITMQ_DEFAULT_PASS: plane
RABBITMQ_DEFAULT_VHOST: plane
RABBITMQ_USER: plane
RABBITMQ_PASSWORD: plane
RABBITMQ_VHOST: plane
x-minio-env: &minio-env
MINIO_ROOT_USER: access-key
MINIO_ROOT_PASSWORD: secret-key
x-aws-s3-env: &aws-s3-env
AWS_REGION: ""
AWS_ACCESS_KEY_ID: access-key
AWS_SECRET_ACCESS_KEY: secret-key
AWS_S3_ENDPOINT_URL: http://plane-minio:9000
AWS_S3_BUCKET_NAME: uploads
x-proxy-env: &proxy-env
APP_DOMAIN: localhost:8888
FILE_SIZE_LIMIT: 5242880
CERT_EMAIL: ""
# Plane proxy's Caddy parser requires a syntactically valid CA URL even
# when not actually using ACME (we serve plain HTTP locally).
CERT_ACME_CA: https://acme-v02.api.letsencrypt.org/directory
CERT_ACME_DNS: ""
LISTEN_HTTP_PORT: 80
LISTEN_HTTPS_PORT: 443
BUCKET_NAME: uploads
SITE_ADDRESS: ":80"
x-live-env: &live-env
API_BASE_URL: http://api:8000
LIVE_SERVER_SECRET_KEY: bb-local-test-live-secret-do-not-reuse
x-app-env: &app-env
WEB_URL: http://localhost:8888
CORS_ALLOWED_ORIGINS: http://localhost:8888
DEBUG: 1
GUNICORN_WORKERS: 1
USE_MINIO: 1
DATABASE_URL: postgresql://plane:plane@plane-db/plane
SECRET_KEY: bb-local-test-django-secret-do-not-reuse-anywhere-real
AMQP_URL: amqp://plane:plane@plane-mq:5672/plane
API_KEY_RATE_LIMIT: 60/minute
MINIO_ENDPOINT_SSL: 0
LIVE_SERVER_SECRET_KEY: bb-local-test-live-secret-do-not-reuse
# === binarybeachio fork: Bucket-4 trusted-JWT entry-point ===
# When BB_BRIDGE_PUBLIC_KEY_URL is set, /auth/sign-in-trusted/ is enabled
# and verifies bridge-issued JWTs against the URL-served PEM. When unset,
# the endpoint returns 404 and Plane behaves like upstream-vanilla.
# See apps/api/plane/authentication/views/app/trusted.py.
BB_BRIDGE_PUBLIC_KEY_URL: ${BB_BRIDGE_PUBLIC_KEY_URL:-}
# === binarybeachio session-lifecycle convention (15 min idle, slide-on-activity) ===
# Canonical: binarybeachio/infrastructure/_shared/.env.session-convention
SESSION_COOKIE_AGE: ${SESSION_COOKIE_AGE:-900}
ADMIN_SESSION_COOKIE_AGE: ${ADMIN_SESSION_COOKIE_AGE:-900}
SESSION_SAVE_EVERY_REQUEST: ${SESSION_SAVE_EVERY_REQUEST:-1}
services:
api:
image: plane-backend:bb-local
command: ./bin/docker-entrypoint-api.sh
environment:
<<: [*app-env, *db-env, *redis-env, *minio-env, *aws-s3-env, *proxy-env]
depends_on:
- plane-db
- plane-redis
- plane-mq
worker:
image: plane-backend:bb-local
command: ./bin/docker-entrypoint-worker.sh
environment:
<<: [*app-env, *db-env, *redis-env, *minio-env, *aws-s3-env, *proxy-env]
depends_on:
- api
beat-worker:
image: plane-backend:bb-local
command: ./bin/docker-entrypoint-beat.sh
environment:
<<: [*app-env, *db-env, *redis-env, *minio-env, *aws-s3-env, *proxy-env]
depends_on:
- api
migrator:
image: plane-backend:bb-local
command: ./bin/docker-entrypoint-migrator.sh
restart: "no"
environment:
<<: [*app-env, *db-env, *redis-env, *minio-env, *aws-s3-env, *proxy-env]
depends_on:
- plane-db
- plane-redis
web:
image: plane-frontend:bb-local
depends_on:
- api
- worker
space:
image: makeplane/plane-space:v1.3.0
depends_on:
- api
- worker
- web
admin:
image: makeplane/plane-admin:v1.3.0
depends_on:
- api
- web
live:
image: makeplane/plane-live:v1.3.0
environment:
<<: [*live-env, *redis-env]
depends_on:
- api
- web
plane-db:
image: postgres:15.7-alpine
command: postgres -c 'max_connections=1000'
environment:
<<: *db-env
volumes:
- bb-local-pgdata:/var/lib/postgresql/data
plane-redis:
image: valkey/valkey:7.2.11-alpine
volumes:
- bb-local-redisdata:/data
plane-mq:
image: rabbitmq:3.13.6-management-alpine
environment:
<<: *mq-env
volumes:
- bb-local-rmqdata:/var/lib/rabbitmq
plane-minio:
image: minio/minio:latest
command: server /export --console-address ":9090"
environment:
<<: *minio-env
volumes:
- bb-local-minio:/export
proxy:
image: makeplane/plane-proxy:v1.3.0
environment:
<<: *proxy-env
ports:
- "8888:80"
depends_on:
- web
- api
- space
- admin
- live
volumes:
bb-local-pgdata:
bb-local-redisdata:
bb-local-rmqdata:
bb-local-minio:

View file

@ -7,8 +7,6 @@
import axios from "axios";
// api service
import { APIService } from "../api.service";
// helpers
import type { TFileUploadRequest } from "./helper";
/**
* Service class for handling file upload operations
@ -23,17 +21,18 @@ export class FileUploadService extends APIService {
}
/**
* Uploads a file to the presigned URL using the request descriptor produced
* by `generateFileUploadPayload`. BB-PATCH: dispatches on `payload.method`
* (PUT for the fork default, POST kept for upstream-Plane parity).
* Uploads a file to the specified signed URL
* @param {string} url - The URL to upload the file to
* @param {FormData} data - The form data to upload
* @returns {Promise<void>} Promise resolving to void
* @throws {Error} If the request fails
*/
async uploadFile(payload: TFileUploadRequest): Promise<void> {
async uploadFile(url: string, data: FormData): Promise<void> {
this.cancelSource = axios.CancelToken.source();
return this.request({
method: payload.method,
url: payload.url,
data: payload.body,
headers: payload.headers,
return this.post(url, data, {
headers: {
"Content-Type": "multipart/form-data",
},
cancelToken: this.cancelSource.token,
withCredentials: false,
})

View file

@ -49,58 +49,17 @@ const validateFilename = (filename: string): string | null => {
return null;
};
// BB-PATCH (binarybeachio fork): upload payload is now a request descriptor
// (url+method+body+headers), not raw FormData. The fork mints presigned PUT
// URLs because R2/B2 don't implement PostObject — see backend storage.py
// docstring + docs/features/storage-upload-flow.md.
export type TFileUploadRequest = {
url: string;
method: "PUT" | "POST";
body: File | FormData;
headers: Record<string, string>;
};
/**
* @description Build a request descriptor for uploading the file using the
* presigned URL returned by the API. Dispatches on `upload_data.method`:
* - "PUT" (fork default): raw file body + Content-Type header
* - "POST" (vanilla AWS S3 path, kept for upstream parity): multipart form
* @description from the provided signed URL response, generate a payload to be used to upload the file
* @param {TFileSignedURLResponse} signedURLResponse
* @param {File} file
* @returns {TFileUploadRequest}
* @returns {FormData} file upload request payload
*/
export const generateFileUploadPayload = (
signedURLResponse: TFileSignedURLResponse,
file: File
): TFileUploadRequest => {
const data = signedURLResponse.upload_data;
if (data.method === "POST") {
const formData = new FormData();
Object.entries(data.fields).forEach(
([key, value]) => value != null && formData.append(key, value as string)
);
formData.append("file", file);
return {
url: data.url,
method: "POST",
body: formData,
headers: { "Content-Type": "multipart/form-data" },
};
}
// Content-Type MUST exactly match what the backend signed in the presigned
// PUT URL — the AWS SigV4 signature includes Content-Type as a signed header.
// If we send the browser's `file.type` (which guesses from extension) but
// the backend signed `fileMetaData.type` (from the file-type library, which
// sniffs file magic bytes), they often disagree and R2 returns 403
// SignatureDoesNotMatch. Always prefer the signed value.
const signedContentType =
data.fields["Content-Type"] || file.type || "application/octet-stream";
return {
url: data.url,
method: "PUT",
body: file,
headers: { "Content-Type": signedContentType },
};
export const generateFileUploadPayload = (signedURLResponse: TFileSignedURLResponse, file: File): FormData => {
const formData = new FormData();
Object.entries(signedURLResponse.upload_data.fields).forEach(([key, value]) => formData.append(key, value));
formData.append("file", file);
return formData;
};
/**

View file

@ -88,7 +88,7 @@ export class SitesFileService extends FileService {
.then(async (response) => {
const signedURLResponse: TFileSignedURLResponse = response?.data;
const fileUploadPayload = generateFileUploadPayload(signedURLResponse, file);
await this.fileUploadService.uploadFile(fileUploadPayload);
await this.fileUploadService.uploadFile(signedURLResponse.upload_data.url, fileUploadPayload);
await this.updateAssetUploadStatus(anchor, signedURLResponse.asset_id);
return signedURLResponse;
})

View file

@ -20,19 +20,19 @@ export type TFileEntityInfo = {
export type TFileMetaData = TFileMetaDataLite & TFileEntityInfo;
// BB-PATCH (binarybeachio fork): upload now uses presigned PUT (not POST).
// `method` and the trimmed `fields` shape reflect that. See backend
// plane/settings/storage.py docstring + docs/features/storage-upload-flow.md
// in binarybeachio for the full decision record.
export type TFileSignedURLResponse = {
asset_id: string;
asset_url: string;
upload_data: {
url: string;
method?: "PUT" | "POST";
fields: {
"Content-Type": string;
key: string;
"x-amz-algorithm": string;
"x-amz-credential": string;
"x-amz-date": string;
policy: string;
"x-amz-signature": string;
};
};
};