-
Notifications
You must be signed in to change notification settings - Fork 0
feat(auth): SSO + Bypass trusted für alle Scopes (ADR 0014) #133
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -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, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| ) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
Comment on lines
338
to
344
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Analog zum SSO-Pfad sollte auch der Bypass-Pfad (der als Admin-äquivalente Anmeldeinformation behandelt wird) immer den Scope
Suggested change
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| raise HTTPException( | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| Original file line number | Diff line number | Diff line change | ||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -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 | ||||||||||||||||||
|
Comment on lines
+359
to
+362
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Da voll vertrauenswürdige SSO- und Bypass-Sitzungen nun immer den Scope
Suggested change
|
||||||||||||||||||
| await eng.dispose() | ||||||||||||||||||
|
|
||||||||||||||||||
|
|
||||||||||||||||||
|
|
||||||||||||||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Da Pangolin-SSO-Sitzungen für alle Scopes voll vertrauenswürdig sind (äquivalent zu
admin), führt die dynamische Rückgabe vonscope=requireddazu, dass das Berechtigungsniveau im zurückgegebenenAuthContextkünstlich auf das für den aktuellen Endpunkt erforderliche Minimum herabgestuft wird. Wenn ein Handler oder eine nachgelagerte Abhängigkeitauth_context.scopeprüft (z. B. um Admin-Aktionen innerhalb eines mitreadgeschützten Endpunkts bedingt zu erlauben), wird ein voll vertrauenswürdiger SSO-Benutzer fälschlicherweise eingeschränkt. Das Hardcoden vonscope="admin"für diese vertrauenswürdigen Pfade stellt sicher, dass sie immer als voll berechtigt erkannt werden.