Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
51 changes: 25 additions & 26 deletions backend/app/auth/dependencies.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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,
)
Comment on lines +327 to 333

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Da Pangolin-SSO-Sitzungen für alle Scopes voll vertrauenswürdig sind (äquivalent zu admin), führt die dynamische Rückgabe von scope=required dazu, dass das Berechtigungsniveau im zurückgegebenen AuthContext künstlich auf das für den aktuellen Endpunkt erforderliche Minimum herabgestuft wird. Wenn ein Handler oder eine nachgelagerte Abhängigkeit auth_context.scope prüft (z. B. um Admin-Aktionen innerhalb eines mit read geschützten Endpunkts bedingt zu erlauben), wird ein voll vertrauenswürdiger SSO-Benutzer fälschlicherweise eingeschränkt. Das Hardcoden von scope="admin" für diese vertrauenswürdigen Pfade stellt sicher, dass sie immer als voll berechtigt erkannt werden.

Suggested change
if _has_pangolin_sso_session(request, effective_settings):
return AuthContext(
source="pangolin-sso",
scope="read",
scope=required,
api_key_id=None,
ip=client_ip,
)
if _has_pangolin_sso_session(request, effective_settings):
return AuthContext(
source="pangolin-sso",
scope="admin",
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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Analog zum SSO-Pfad sollte auch der Bypass-Pfad (der als Admin-äquivalente Anmeldeinformation behandelt wird) immer den Scope "admin" zurückgeben, anstatt ihn dynamisch auf required herabzustufen. Dies verhindert unerwartete Einschränkungen in nachgelagerten Handlern, die den Scope im AuthContext überprüfen.

Suggested change
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,
)
if _is_pangolin_bypass(request):
return AuthContext(
source="pangolin-bypass",
scope="admin",
api_key_id=None,
ip=client_ip,
)


raise HTTPException(
Expand Down
20 changes: 15 additions & 5 deletions backend/tests/unit/auth/test_dependencies.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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:
Expand All @@ -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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Da voll vertrauenswürdige SSO- und Bypass-Sitzungen nun immer den Scope "admin" zurückgeben, sollte die Test-Assertion angepasst werden, um zu überprüfen, ob der zurückgegebene Scope tatsächlich "admin" ist, unabhängig vom angeforderten Scope.

Suggested change
assert resp.status_code == 200
body = resp.json()
assert body["source"] == "pangolin-sso"
assert body["scope"] == required_scope
assert resp.status_code == 200
body = resp.json()
assert body["source"] == "pangolin-sso"
assert body["scope"] == "admin"

await eng.dispose()


Expand Down
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)
Loading