From 7a570f1841b629abb1d1da8c010b5f549ccb3a86 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B6rn=20Strausmann?= Date: Tue, 23 Jun 2026 11:46:04 +0000 Subject: [PATCH] =?UTF-8?q?feat(auth):=20Pangolin-SSO=20+=20Bypass=20f?= =?UTF-8?q?=C3=BCr=20alle=20Scopes=20trusted=20(ADR=200014)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit In Phase 7c hatten wir SSO und Bypass auf "read scope only" beschraenkt, um Leak-Defense fuer den claude-automation Bypass zu haben. Konsequenz: das HTML-Admin-UI ist via SSO nicht nutzbar, weil Admin-Routen "admin" Scope verlangen und SSO nur "read" liefert. Diese Aenderung trusted SSO und Bypass fuer den jeweils verlangten Scope. Die Defense-in-Depth ist: - SSO: Pangolin Resource Policy gated, wer ueberhaupt auf labels.* kommt. - Bypass: Secret liegt in Vault, rotiert via Vault bei Verdacht auf Leak. Production-Integrationen (Hangar, Snipe-IT, Grocy) sollen API-Keys mit spezifischem Scope nutzen, nicht den Bypass. Multi-Scope-System auf X-Label-Hub-Key API-Keys bleibt unveraendert: jeder Key hat weiterhin read/print/admin Scope-Liste und per-printer ACL. Tests: - test_pangolin_sso_blocked_on_print_scope ersetzt durch test_pangolin_sso_allows_print_and_admin_scopes (parametrisiert) - 75/75 auth + admin tests gruen, inkl. Race-Detector ADR 0014 dokumentiert die Entscheidung inkl. der zwei verworfenen Optionen (SSO-Admin-User-Liste in ENV; Scope-System komplett entfernen). Refs: Issue #78 (Phase 7c original), PR #130/#132 (Header-Forwarding), ADR 0014 --- backend/app/auth/dependencies.py | 51 ++++++----- backend/tests/unit/auth/test_dependencies.py | 20 +++-- ...n-sso-and-bypass-trusted-for-all-scopes.md | 87 +++++++++++++++++++ 3 files changed, 127 insertions(+), 31 deletions(-) create mode 100644 docs/decisions/0014-pangolin-sso-and-bypass-trusted-for-all-scopes.md diff --git a/backend/app/auth/dependencies.py b/backend/app/auth/dependencies.py index 7d16666..4a99f30 100644 --- a/backend/app/auth/dependencies.py +++ b/backend/app/auth/dependencies.py @@ -293,14 +293,23 @@ def require_scope( settings: Override settings (for testing). Defaults to get_settings(). The dependency resolves through four paths (in priority order): - 1. X-Label-Hub-Key API key header - 2. Pangolin-SSO (Remote-User + X-Pangolin-Token Trust-Token) — read scope only - 2b. Pangolin-SSO Legacy (X-Pangolin-User) — read scope only (Rückwärtskompatibilität) - 3. Pangolin-bypass (claude-automation Basic Auth) — read scope only + 1. X-Label-Hub-Key API key header — scope from the key's stored scope + 2. Pangolin-SSO (Remote-User + X-Pangolin-Token Trust-Token) — trusted for + all scopes (the Pangolin Resource Policy already gates who reaches us) + 2b. Pangolin-SSO Legacy (X-Pangolin-User) — trusted for all scopes + 3. Pangolin-bypass (claude-automation Basic Auth) — trusted for all scopes + (the bypass-secret is treated as an admin-equivalent credential; rotate + it via Vault if it leaks) Returns a callable that FastAPI injects as ``Depends(require_scope("read"))``. + + See ADR 0014 for the rationale behind treating SSO/Bypass as fully-trusted + (Single-Owner-Operator HomeLab + small-team workflow; multi-tier scoping is + retained on API keys for per-integration restrictions but not for the + Pangolin-fronted UI sessions). """ effective_settings = settings or get_settings() + _ = effective_settings.pangolin_bypass_scope_downgrade # kept for backward-compat env async def _check( request: Request, @@ -313,35 +322,25 @@ async def _check( if key_header: return await _validate_api_key(session, key_header, required, client_ip) - # Path 2: Pangolin-SSO (browser session) — Standard-Headers + Legacy - if _has_pangolin_sso_session(request, effective_settings) and required == "read": + # Path 2: Pangolin-SSO (browser session) — Standard-Headers + Legacy. + # Trusted for all scopes (Pangolin Resource Policy gates access). + if _has_pangolin_sso_session(request, effective_settings): return AuthContext( source="pangolin-sso", - scope="read", + scope=required, api_key_id=None, ip=client_ip, ) - # Path 3: Pangolin-bypass (claude-automation) — read-only + # Path 3: Pangolin-bypass (claude-automation Basic Auth) — trusted for + # all scopes. The bypass-secret lives in Vault and is treated as an + # admin-equivalent credential. Rotate via Vault on suspected leak. if _is_pangolin_bypass(request): - # After Phase 7c, bypass is downgraded to read-only. - # The feature flag controls when the downgrade is enforced. - if required == "read" or not effective_settings.pangolin_bypass_scope_downgrade: - return AuthContext( - source="pangolin-bypass", - scope="read", - api_key_id=None, - ip=client_ip, - ) - # Downgrade enforced: bypass cannot satisfy print/admin - raise HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, - detail={ - "error_code": "bypass_scope_downgraded", - "error_message": ( - "Pangolin bypass is read-only. Use X-Label-Hub-Key for write operations." - ), - }, + return AuthContext( + source="pangolin-bypass", + scope=required, + api_key_id=None, + ip=client_ip, ) raise HTTPException( diff --git a/backend/tests/unit/auth/test_dependencies.py b/backend/tests/unit/auth/test_dependencies.py index beef7ff..2fb198a 100644 --- a/backend/tests/unit/auth/test_dependencies.py +++ b/backend/tests/unit/auth/test_dependencies.py @@ -316,8 +316,15 @@ async def _session(): @pytest.mark.asyncio -async def test_pangolin_sso_blocked_on_print_scope(): - """Pangolin-SSO on print scope endpoint → 401 (SSO only grants read).""" +@pytest.mark.parametrize("required_scope", ["print", "admin"]) +async def test_pangolin_sso_allows_print_and_admin_scopes(required_scope: str): + """Pangolin-SSO grants the required scope on print/admin endpoints (ADR 0014). + + Pangolin Resource Policy already gates which users can reach the upstream; + an SSO-authenticated request is therefore trusted for any scope. The + multi-scope system remains intact on X-Label-Hub-Key API keys for fine- + grained per-integration restrictions. + """ import app.models from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine @@ -337,8 +344,8 @@ async def test_pangolin_sso_blocked_on_print_scope(): app = FastAPI() @app.post("/test") - async def ep(ctx=Depends(require_scope("print", settings=settings))): - return {} + async def ep(ctx=Depends(require_scope(required_scope, settings=settings))): + return {"source": ctx.source, "scope": ctx.scope} async def _session(): async with factory() as s: @@ -349,7 +356,10 @@ async def _session(): async with AsyncClient(transport=ASGITransport(app=app), base_url="http://t") as client: resp = await client.post("/test", headers={"X-Pangolin-User": "testuser@example.com"}) - assert resp.status_code == 401 + assert resp.status_code == 200 + body = resp.json() + assert body["source"] == "pangolin-sso" + assert body["scope"] == required_scope await eng.dispose() diff --git a/docs/decisions/0014-pangolin-sso-and-bypass-trusted-for-all-scopes.md b/docs/decisions/0014-pangolin-sso-and-bypass-trusted-for-all-scopes.md new file mode 100644 index 0000000..b03c675 --- /dev/null +++ b/docs/decisions/0014-pangolin-sso-and-bypass-trusted-for-all-scopes.md @@ -0,0 +1,87 @@ +# 0014 — Pangolin-SSO and Bypass are trusted for all scopes + +- **Status:** Accepted +- **Date:** 2026-06-23 +- **Deciders:** Project Owner + +## Context + +In Phase 7c (Issue #78, 2026-05-17) we introduced per-app authentication on top of the Pangolin-edge gating. The motivation was leak-defence: if the `claude-automation` Basic-Auth bypass secret leaks, callers should not automatically have full CRUD on every endpoint. + +The implementation introduced three auth scopes (`read`, `print`, `admin`) and four auth paths: + +1. `X-Label-Hub-Key` — API key (each key carries its own scope set + per-printer ACL) +2. Pangolin-SSO (`Remote-User` + `X-Pangolin-Token` trust-token) +3. Pangolin-SSO Legacy (`X-Pangolin-User`) +4. Pangolin Bypass (Basic-Auth `claude-automation:...`) + +Paths 2/2b/3 were hard-coded to grant **read scope only**. This was intentional defensive design. + +Two practical problems surfaced in production: + +- The HTML UI in the Frontend has admin routes (`/admin/printers`, `/admin/api-keys`) that need `admin` scope. Browser users authenticated via Pangolin SSO got a 503 from the Frontend (Backend returned 401 because the SSO path could not satisfy `admin`). The Admin UI was effectively unusable through SSO — which is its primary access path. +- The Bypass path was also `read`-only. Tooling that needs to script printer changes from a curl had to provision an API key first, despite the bypass-secret already being a strongly-guarded high-trust credential (single Vault item, rotated separately). + +A wider context point: this project is deployed in HomeLab-sized installations today, and is designed to scale into multi-team OSS setups. Per-integration scoping on API keys is genuinely useful (e.g. Snipe-IT should be able to print but not delete printers). Per-browser-user scoping is not — Pangolin already controls who can reach the upstream at all via its Resource Policy. + +## Decision + +**Pangolin-SSO and Pangolin-Bypass are trusted for any scope the caller requests.** API keys continue to enforce their stored scope. + +`require_scope(required)` resolves like this: + +1. `X-Label-Hub-Key` — the key's scope must satisfy `required` (unchanged) +2. Pangolin-SSO (Standard headers or Legacy) — grants `required` directly +3. Pangolin Bypass — grants `required` directly + +The defense-in-depth for SSO is now provided exclusively by Pangolin's Resource Policy: it decides who can reach `labels.strausmann.cloud` at all. Anyone who passes that gate is treated as a fully authenticated operator. + +The defense-in-depth for Bypass is now operational hygiene: the bypass-secret lives in Vault, is rotated on suspected leak, and is meant for ad-hoc curl and emergency operations — production integrations are expected to use scoped API keys. + +## Options considered + +### Option A — what we picked: SSO and Bypass trusted for all scopes + +- Pros: + - Browser-UI works out of the box for SSO-authenticated users + - Bypass is a real fallback again (not just a read fallback) + - Tiny code change (≈5 LOC), tiny conceptual change + - Aligns with the existing trust model: Pangolin owns the edge, the app trusts what comes through +- Cons: + - Bypass-secret leak now grants full access. Mitigation: secret lives in Vault, rotation is a single ENV change + stack restart + - No app-side per-user role distinction. Acceptable because that is what Pangolin's Resource Policy / roles are for + +### Option B — Keep scope-tiering, add an SSO admin-user allow-list + +- Pros: Preserves "leak → only read" defense +- Cons: + - Requires per-deployment ENV var (`PRINTER_HUB_SSO_ADMIN_USERS=…`) + - Two places define who is admin (Pangolin's policy + this ENV) → drift risk + - Adds friction every time an OSS user wants to use the Admin UI: "why is my UI giving 503?" + +### Option C — Remove the scope system entirely + +- Pros: Smallest mental model +- Cons: + - Removes per-integration scoping on API keys, which is the part that genuinely earns its keep (Hangar with `print`-only, Snipe-IT with `read`-only, Grocy-webhook unaffected). Rejected by the project owner during the decision discussion. + +## Consequences + +- `backend/app/auth/dependencies.py::require_scope`: SSO and Bypass paths return `scope=required` instead of a hard-coded `"read"`. The `pangolin_bypass_scope_downgrade` feature flag is left in `Settings` for one release to avoid forcing an env-config change on existing deployments, but the runtime no longer reads it for decision-making. +- `backend/tests/unit/auth/test_dependencies.py`: the previous "SSO blocked on print" test is replaced by a parametrised test that asserts SSO grants `print` and `admin`. +- API key behaviour is unchanged. The Admin UI for managing API keys is unchanged. +- Frontend (`frontend/internal/api/client.go`) is unchanged. +- OpenAPI schema is unchanged. +- DB migrations are unchanged. + +### Follow-ups + +- After one release cycle, drop the `pangolin_bypass_scope_downgrade` Settings field (dead flag). +- If a future deployment ever does want SSO-tiered access (multi-team setup with viewer/operator roles), the natural place is a Pangolin-role-based check on `Remote-Role`, configurable via env — not in scope here. + +## References + +- Issue #78 — Phase 7c original auth introduction +- PR #130 — `X-Pangolin-Token` forwarding in Frontend +- PR #132 — `Remote-User` forwarding in Frontend +- ADR 0011 — OpenAPI-as-contract (auth headers must remain part of contract)