-
Notifications
You must be signed in to change notification settings - Fork 0
docs(api): Phase 7c API-Auth design — 3-scope keys + rate-limit + /admin/api-keys UI #85
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
Merged
Merged
Changes from 1 commit
Commits
Show all changes
3 commits
Select commit
Hold shift + click to select a range
e32a418
docs(api): Phase 7c API-Auth design — 3-scope keys, bcrypt, rate-limi…
strausmann 1f47774
docs(api): address PR #85 bot-review — privacy sanitise + objective f…
strausmann a21d2f7
docs(api): sync 7d spec with main (privacy-clean version from PR #79)
strausmann File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
347 changes: 347 additions & 0 deletions
347
docs/superpowers/specs/2026-05-17-phase-7c-api-auth-design.md
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,347 @@ | ||
| # Phase 7c Foundation Design — App-side API Authentication | ||
|
|
||
| **Date:** 2026-05-17 | ||
| **Status:** Draft | ||
| **Tracking:** strausmann/label-printer-hub#22 (master), #78 (Phase 7c) | ||
| **Dependencies:** | ||
| - Phase 7b foundation (merged) provides the `/readiness` deep-check + status cache used by middleware sanity probes | ||
| - Hard-blocks Phase 7d (#80) — every external API caller needs an authenticated key before Phase 7d can ship to production | ||
|
|
||
| ## 1. Executive Summary | ||
|
|
||
| Phase 7c introduces **app-side API-Key authentication** to close the security gap exposed by Phase 7b's first production deploy: | ||
|
|
||
| - Currently the API has NO own authentication. All access control hangs at the Pangolin edge (SSO for browsers, Basic-Auth-Bypass `claude-automation` for tooling). | ||
| - Single shared secret → if leaked, full unrestricted access to every endpoint. | ||
| - No per-caller audit trail, no rate limiting, no scoping. | ||
|
|
||
| Phase 7c delivers: | ||
|
|
||
| 1. **Multi-key management** through a new HTMX UI at `/admin/api-keys` | ||
| 2. **3-level scope model:** `read`, `print`, `admin` per key (no finer granularity needed for HomeLab scope) | ||
| 3. **bcrypt-hashed key storage** with prefix preserved for UI display (`lh_ab12cd34...`) | ||
| 4. **60 prints/min default rate-limit** per key, configurable per-key in the UI (in-memory token-bucket sufficient for single-instance HomeLab) | ||
| 5. **Audit trail** in the Jobs table — `api_key_id`, `source_ip` on every print | ||
| 6. **Pangolin-Basic-Auth-Bypass downgrade** — after Phase 7c lands, `claude-automation` is scoped to `read`-only as recovery path, all writes require app-key | ||
| 7. **Transition window** — both auth paths work during deployment, switch-over communicated via docs | ||
|
|
||
| The auth layer is implemented as a FastAPI dependency that runs before any other route handler, so endpoint definitions stay clean (`Depends(require_scope("print"))`). | ||
|
|
||
| ## 2. Database Schema | ||
|
|
||
| ### New `api_keys` table | ||
|
|
||
| ```python | ||
| class ApiKey(SQLModel, table=True): | ||
| __tablename__ = "api_keys" | ||
|
|
||
| id: UUID = Field(default_factory=uuid4, primary_key=True) | ||
| name: str = Field(index=True) # User-facing display name, e.g. "Plex Print" | ||
| key_hash: str # bcrypt hash of the full plaintext key | ||
| key_prefix: str = Field(index=True) # First 12 chars for display, e.g. "lh_ab12cd34" | ||
| scopes: list[str] = Field(sa_column=Column(JSON, nullable=False)) # ["read"] / ["read", "print"] / ["admin"] | ||
| allowed_printer_ids: list[UUID] = Field( # Empty list = all printers; non-empty = restricted | ||
| default_factory=list, | ||
| sa_column=Column(JSON, nullable=False), | ||
| ) | ||
| rate_limit_per_minute: int = Field(default=60, ge=1, le=10000) | ||
| enabled: bool = True | ||
| created_at: datetime = Field(default_factory=lambda: datetime.now(UTC)) | ||
|
strausmann marked this conversation as resolved.
Outdated
|
||
| last_used_at: datetime | None = None | ||
| last_used_ip: str | None = None | ||
| expires_at: datetime | None = None # NULL = no expiry; future date = auto-disable after | ||
| notes: str | None = None # User-facing free text | ||
| ``` | ||
|
|
||
| `scopes` is a JSON list rather than a single string so future scopes can be added without schema migration. Values constrained at the Pydantic layer to the canonical set `{"read", "print", "admin"}`. | ||
|
|
||
| ### Job table extensions | ||
|
|
||
| ```python | ||
| # Add to existing Job model: | ||
| api_key_id: UUID | None = None # ForeignKey hint, no constraint (allow key deletion without losing job history) | ||
|
strausmann marked this conversation as resolved.
|
||
| source_ip: str | None = None | ||
| ``` | ||
|
|
||
| Both nullable so historical jobs from before Phase 7c retain integrity. Backfill is unnecessary — old jobs predate the auth concept. | ||
|
|
||
| ### Alembic migration | ||
|
|
||
| Single migration `20260517_phase7c_api_keys` that: | ||
|
|
||
| 1. Creates `api_keys` table with all columns above | ||
| 2. Adds `api_key_id` + `source_ip` to `jobs` table (nullable, no default) | ||
| 3. Indices on `api_keys.key_prefix` (lookup hot path) and `jobs.api_key_id` (audit queries) | ||
| 4. Seeds ONE initial admin key on first migration (only if `api_keys` is empty): name `"bootstrap-admin"`, scope `["admin"]`, prefix shown in startup log so operator can copy it. After first deploy, operator rotates it. | ||
|
|
||
| The seed prevents a chicken-and-egg lockout — without a first key, no one can create more keys via the API. | ||
|
strausmann marked this conversation as resolved.
Outdated
|
||
|
|
||
| ## 3. Authentication Middleware | ||
|
|
||
| ### Dependency factory | ||
|
|
||
| ```python | ||
| # backend/app/auth/dependencies.py | ||
|
|
||
| from fastapi import Depends, HTTPException, Request, Security | ||
| from fastapi.security import APIKeyHeader | ||
|
|
||
| _api_key_header = APIKeyHeader(name="X-Label-Hub-Key", auto_error=False) | ||
|
|
||
|
|
||
| def require_scope(required: str): | ||
| """Returns a FastAPI dependency that validates X-Label-Hub-Key | ||
| contains at least the `required` scope OR falls back to Pangolin-bypass | ||
| (read-only) OR Pangolin-SSO (browser session).""" | ||
|
|
||
| async def _check( | ||
| request: Request, | ||
| key_header: str | None = Security(_api_key_header), | ||
| session: AsyncSession = Depends(get_session), | ||
| ) -> AuthContext: | ||
| # Path 1: API-Key header | ||
| if key_header: | ||
| context = await _validate_api_key(session, key_header, required, request.client.host) | ||
| return context | ||
|
|
||
| # Path 2: Pangolin-SSO browser session (read scope only) | ||
| if _has_pangolin_sso_session(request) and required == "read": | ||
| return AuthContext(source="pangolin-sso", scope="read", api_key_id=None, ip=request.client.host) | ||
|
|
||
| # Path 3: Pangolin-Bypass with claude-automation (read scope only after 7c) | ||
| if _is_pangolin_bypass(request) and required == "read": | ||
| return AuthContext(source="pangolin-bypass", scope="read", api_key_id=None, ip=request.client.host) | ||
|
strausmann marked this conversation as resolved.
Outdated
strausmann marked this conversation as resolved.
Outdated
|
||
|
|
||
| raise HTTPException(401, "Missing or insufficient credentials") | ||
|
strausmann marked this conversation as resolved.
Outdated
strausmann marked this conversation as resolved.
Outdated
|
||
|
|
||
| return _check | ||
| ``` | ||
|
|
||
| ### `AuthContext` propagation | ||
|
|
||
| The dependency returns an `AuthContext` Pydantic model: | ||
| ```python | ||
| class AuthContext(BaseModel): | ||
| source: Literal["api-key", "pangolin-sso", "pangolin-bypass"] | ||
| scope: Literal["read", "print", "admin"] | ||
| api_key_id: UUID | None | ||
| ip: str | ||
| ``` | ||
|
|
||
| Routes that create jobs receive this context and persist `api_key_id` + `ip` to the new Job columns. Other routes ignore it. | ||
|
|
||
| ### Endpoint scope mapping | ||
|
|
||
| | Endpoint | Required scope | | ||
| |---|---| | ||
| | `GET /api/printers`, `GET /api/templates`, `GET /api/jobs/{id}` | `read` | | ||
| | `GET /api/printers/{id}/status`, `GET /readiness`, `GET /healthz` | `read` (but `/healthz` is publicly readable for Docker — no auth) | | ||
| | `POST /api/render/preview` | `read` (preview is side-effect-free) | | ||
| | `POST /api/print`, `POST /print` (legacy), `POST /api/webhook/print` | `print` | | ||
| | `POST /api/printers/{id}/pause`, `/resume`, `/clear` | `print` | | ||
| | `DELETE /api/templates/{id}`, `POST /api/templates` | `admin` | | ||
| | All `/api/admin/api-keys/*` endpoints | `admin` | | ||
|
|
||
| The `admin` scope subsumes `print` and `read`. `print` subsumes `read`. `read` is the lowest. | ||
|
|
||
| ### Performance: bcrypt verify on every request | ||
|
|
||
| bcrypt verify is ~100ms per call (intentionally slow). To keep request latency low, the middleware caches the `(key_hash → AuthContext)` mapping in an in-memory LRU with 5-minute TTL. Cache invalidation on: | ||
|
strausmann marked this conversation as resolved.
Outdated
|
||
| - Key delete: explicit cache flush by key_id | ||
| - Key rotation (recreate): old hash naturally expires after TTL | ||
|
|
||
| For a HomeLab with a handful of keys, this keeps per-request auth latency under 1ms after warm-up. | ||
|
|
||
|
strausmann marked this conversation as resolved.
Outdated
|
||
| ## 4. Key Generation + Format | ||
|
|
||
| ### Generation | ||
|
|
||
| ```python | ||
| import secrets | ||
|
|
||
| def generate_api_key() -> tuple[str, str, str]: | ||
| """Returns (plaintext, prefix, bcrypt_hash). | ||
|
|
||
| The plaintext is shown to the user ONCE on creation, never persisted. | ||
| """ | ||
| body = secrets.token_urlsafe(32) # 256 bits of entropy | ||
| plaintext = f"lh_{body}" | ||
| prefix = plaintext[:12] # "lh_ab12cd34X" — enough to identify in UI | ||
| hashed = bcrypt.hashpw(plaintext.encode(), bcrypt.gensalt(rounds=12)).decode() | ||
| return plaintext, prefix, hashed | ||
| ``` | ||
|
|
||
| - `lh_` prefix to distinguish from other token formats (GitHub PAT, etc.) | ||
| - 256-bit entropy from `secrets.token_urlsafe` — URL-safe charset, no padding issues in headers | ||
| - bcrypt rounds=12 (industry default 2024-2026, ~100-200ms verify) | ||
|
|
||
| ### Display in UI | ||
|
|
||
| After creation, the plaintext is shown ONCE in a copy-to-clipboard modal. The DB only stores the hash. Subsequent UI views show only `key_prefix` plus metadata (name, scope, last_used_at, etc.). | ||
|
|
||
| ## 5. Rate Limiting | ||
|
|
||
| ### In-memory token bucket | ||
|
|
||
| Implemented as a global dict in `app.services.rate_limiter`: | ||
|
|
||
| ```python | ||
| class RateLimiter: | ||
| def __init__(self) -> None: | ||
| self._buckets: dict[UUID, _TokenBucket] = {} | ||
|
|
||
| async def check_and_consume(self, key_id: UUID, limit_per_minute: int) -> bool: | ||
| """Returns True if the call is allowed; False if rate-limit exceeded. | ||
|
|
||
| Uses one token = one request, refill at `limit_per_minute / 60` tokens/second, | ||
| capacity = limit_per_minute. | ||
| """ | ||
| bucket = self._buckets.setdefault(key_id, _TokenBucket(limit_per_minute)) | ||
|
strausmann marked this conversation as resolved.
Outdated
|
||
| bucket.refill_to_now(limit_per_minute / 60) | ||
|
strausmann marked this conversation as resolved.
Outdated
|
||
| if bucket.tokens >= 1: | ||
| bucket.tokens -= 1 | ||
| return True | ||
| return False | ||
|
strausmann marked this conversation as resolved.
Outdated
|
||
| ``` | ||
|
|
||
| ### Why in-memory + not Redis | ||
|
|
||
| - Single instance (single backend container) — no need for cross-process coordination | ||
| - HomeLab volume → ~tens of prints/day, rate-limit rarely hit | ||
| - Restarting the backend resets buckets → not a correctness issue, just gives an extra "free" minute after restart | ||
| - A Redis-backed limiter is a Phase 7c.1 follow-up if/when the HomeLab grows multi-instance | ||
|
|
||
| ### Response on rate-limit | ||
|
|
||
| ```json | ||
| HTTP 429 Too Many Requests | ||
| { | ||
| "error_code": "rate_limit_exceeded", | ||
| "error_message": "Key 'Plex Print' exceeded 60 prints/minute. Retry after 12 seconds.", | ||
| "retry_after_seconds": 12 | ||
| } | ||
| ``` | ||
|
|
||
| Standard `Retry-After` header included. | ||
|
|
||
| ## 6. Admin UI — `/admin/api-keys` | ||
|
|
||
| ### Route + Layout | ||
|
|
||
| New HTMX page rendered by the Frontend Go server, proxying to the backend for data. Path: `/admin/api-keys`. Requires Pangolin-SSO (browser context) + the backend dependency checks for `admin` scope OR an SSO-authenticated user (HomeLab uses single-user → SSO = admin). | ||
|
|
||
| Page sections: | ||
|
|
||
| ``` | ||
| +-- Top bar -----------------------------------------------------+ | ||
| | API Keys [+ Neuer Key] | | ||
| +-- Key list ----------------------------------------------------+ | ||
| | Name Prefix Scopes Last used ⚙ ❌ | | ||
| | Plex Print lh_ab12cd34X [print] 5 min ago | | ||
| | Snipe-IT Asset lh_xyz98qwer [print] 2 days ago | | ||
| | Bootstrap Admin lh_seed00deadb [admin] never | | ||
| +----------------------------------------------------------------+ | ||
| ``` | ||
|
|
||
| ### Endpoints | ||
|
|
||
| | Method + Path | Purpose | | ||
| |---|---| | ||
| | `GET /admin/api-keys` | List page (HTMX-rendered, all keys via backend `GET /api/admin/api-keys`) | | ||
| | `POST /admin/api-keys/new` | Create form: name, scopes, allowed-printers, rate-limit, expires-at, notes | | ||
| | `POST /api/admin/api-keys` | Backend: create key, return `{key_id, plaintext, prefix}` (plaintext ONLY on creation) | | ||
| | `DELETE /api/admin/api-keys/{id}` | Backend: revoke key, returns 204 | | ||
| | `PATCH /api/admin/api-keys/{id}` | Backend: update enabled/rate_limit_per_minute/notes (NOT the key value) | | ||
| | `GET /api/admin/api-keys/{id}` | Backend: return single key metadata (no plaintext) | | ||
|
|
||
| ### Audit log on key page | ||
|
|
||
| Each key's detail view shows last 100 jobs that used it: | ||
| ``` | ||
| Jobs created by this key: | ||
| - job 6b692989 2026-05-17 21:26 template grocy-12mm → completed | ||
| - job a8c4b234 2026-05-17 18:12 template snipeit-12mm → completed | ||
| ``` | ||
|
|
||
| Sourced from `jobs.api_key_id` index. | ||
|
|
||
| ## 7. Transition from Pangolin-Basic-Auth-Bypass | ||
|
|
||
| ### Current state (post-Phase-7b) | ||
|
|
||
| - `claude-automation` user with 64-hex-secret can hit ANY endpoint | ||
| - Standard for all automation (curl, Plex, SnipeIt, this assistant's tooling) | ||
|
|
||
| ### Target state (post-Phase-7c) | ||
|
|
||
| - `claude-automation` Pangolin-Bypass remains as a RECOVERY pathway, but the FastAPI middleware downgrades its effective scope to `read` only | ||
| - All writes (POST/DELETE) require an `X-Label-Hub-Key` header | ||
| - Plex, SnipeIt, Hangar, etc. each get their own `print`-scoped key in the new UI | ||
|
|
||
| ### Migration | ||
|
|
||
| 1. Phase 7c lands with seeded bootstrap-admin key | ||
| 2. Operator (user) creates dedicated keys via UI: | ||
| - "Plex Print" → scope `print`, all printers allowed | ||
| - "SnipeIt Print" → scope `print`, all printers allowed | ||
| - "Hangar Print" → scope `print`, all printers allowed (used by Hangar's Print-Page, see strausmann/hangar#63) | ||
| 3. Operator updates each consumer's env / config to send `X-Label-Hub-Key: lh_...` | ||
| 4. After all consumers migrated, the `claude-automation` scope downgrade is enforced | ||
| 5. Recovery pathway documented — if all keys lost, `claude-automation` can still GET /readiness for diagnostics | ||
|
|
||
| The downgrade is implemented as a feature flag `settings.pangolin_bypass_scope_downgrade: bool = False` initially, flipped to `True` once migration confirmed. This avoids surprise breakage on the day of deploy. | ||
|
|
||
| ## 8. Testing Strategy | ||
|
|
||
| | Layer | Test type | What it covers | | ||
| |---|---|---| | ||
| | Key creation | Unit | `generate_api_key()` produces `lh_` prefix + 256-bit entropy + valid bcrypt hash | | ||
| | bcrypt verify | Unit | Correct plaintext verifies; wrong plaintext rejects | | ||
| | LRU cache | Unit | After verify, subsequent calls return cached AuthContext within TTL; expires after TTL | | ||
| | Auth dependency | Integration | Valid key → AuthContext; invalid key → 401; missing key + no Pangolin → 401; missing key + Pangolin-bypass + read scope → AuthContext source=pangolin-bypass | | ||
| | Scope rejection | Integration | print-scope key on admin endpoint → 403 with scope-mismatch detail | | ||
| | Rate-limit | Integration | 61 requests/min → 61st returns 429 with retry-after | | ||
| | Printer ACL | Integration | Key with allowed_printer_ids=[A] used on printer B → 403 | | ||
| | Audit trail | Integration | POST /api/print with key X → Job row has api_key_id=X and source_ip set | | ||
| | UI CRUD | E2E Playwright | Create key (plaintext shown once) → revoke (key rejected on next use) | | ||
| | Transition (feature flag off) | Integration | claude-automation can still POST /api/print until flag flipped | | ||
| | Transition (feature flag on) | Integration | claude-automation POST /api/print → 401, GET /readiness still 200 | | ||
|
|
||
| Coverage target stays at `fail_under = 80`. The new auth module should reach >= 95% on the core middleware path. | ||
|
|
||
| ## 9. Out-of-Scope | ||
|
|
||
| These are explicit non-goals for Phase 7c (deferred to later phases): | ||
|
|
||
| - **OAuth flow** — single-user HomeLab, no need for delegated auth | ||
| - **Per-user keys** — only one human user, scoping is per-application not per-person | ||
| - **Redis-backed rate limiter** — single-instance design | ||
| - **Webhook signature verification** beyond the existing webhook-API-key (orthogonal feature) | ||
| - **Key auto-rotation policy** — manual rotation in UI is fine | ||
| - **Hardware-backed (TPM/HSM) key storage** — overkill for HomeLab, file-based DB is acceptable | ||
| - **OpenAPI security scheme advertising the X-Label-Hub-Key** — Phase 7c.1 polish (oapi-codegen client will auto-detect once added) | ||
|
|
||
| ## 10. Definition of Done | ||
|
|
||
| - [ ] Alembic migration creates `api_keys` + extends `jobs` table | ||
| - [ ] `ApiKey` model + repository implemented | ||
| - [ ] `generate_api_key()` + bcrypt verify + LRU cache implemented | ||
| - [ ] `require_scope(level)` FastAPI dependency implemented | ||
| - [ ] All existing routes annotated with `Depends(require_scope(...))` | ||
| - [ ] Rate limiter implemented with token-bucket per key | ||
| - [ ] `/admin/api-keys` HTMX UI (list + create + revoke + detail) | ||
| - [ ] Backend API for key CRUD under `/api/admin/api-keys/*` | ||
| - [ ] Pangolin-bypass scope-downgrade feature flag | ||
| - [ ] Bootstrap-admin seed key emitted in startup log on first migration | ||
| - [ ] All tests passing, coverage >= 80% | ||
| - [ ] `make oapi` regenerates the client with the new auth header + admin routes | ||
| - [ ] Doku in README + a new `docs/site/operations/api-keys.md` operator guide | ||
| - [ ] Refs #22 + Closes #78 in PR | ||
|
|
||
| ## 11. Self-Review | ||
|
|
||
| - **Privacy:** spec uses RFC 5737 (192.0.2.x) and example.com placeholders consistently | ||
| - **Internal consistency:** scopes named identically everywhere (`read`/`print`/`admin`) | ||
| - **Backward compat:** existing routes get auth gracefully, old jobs survive (nullable cols) | ||
| - **Recovery:** Pangolin-bypass-as-read-only ensures the system is never bricked if all keys lost | ||
| - **Observability:** audit fields `api_key_id` + `source_ip` on Job make post-incident forensics trivial | ||
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.