Files
ersatztv/docs/decisions/records/security/fail-closed-api-auth.md
T
timothy fba5233caf
PR Gates / CI image pin matches docker/ci (pull_request) Successful in 11s
PR Gates / Docs update reminder (pull_request) Successful in 16s
PR Gates / decisions lifecycle (pull_request) Failing after 23s
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (pull_request) Successful in 1m17s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Successful in 1m29s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 8m5s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 16m5s
Build ErsatzTV Image / Functional E2E (curl + UI contracts) (pull_request) Successful in 17m6s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
feat(610): split the decision corpus into one YAML-frontmatter file per record
168 records -> docs/decisions/records/<area>/<topic>.md (163 active, 23 dirs) and
docs/decisions/archive/<area>/<topic>.md (5 archived). The filename IS the key,
so one-active-record-per-key becomes a filesystem property rather than a
validator check, and supersession becomes a `git mv`.

WHY: the monolith was a concurrency problem before an aesthetic one. A
3,900-line append target made parallel sessions collide -- PR #605 and PR #614
both hit append-vs-append conflicts during routine rebases, and hand-resolving
those inside the corpus is exactly the operation the rationale-rewrite guard
exists to police.

HOW IT IS VERIFIED: a ~170-file diff cannot be meaningfully read, so correctness
does not rest on reading it. The parser was taught BOTH formats first, so the
body-diff guard parses the old form at the merge-base and the new form at head --
the migration validates itself, no bypass. The proof is a field-level equivalence
harness: 168 records before and after, zero lost, zero gained, zero field
mismatches, zero rationale bodies differing. Reviewers should scrutinise the
harness; it is the actual evidence.

What measuring caught that reading would not have:

- ~500 lines sit OUTSIDE any record -- decisions.md's lifecycle schema and each
  topic file's preamble, mostly the only copy. Source files are kept and
  stripped, never deleted. They also cannot be filed per-area: topic files hold
  several areas and 4 of 23 areas span several files.
- Archive discovery was a non-recursive glob; after the split it found ZERO
  archived records, surfacing as four bogus "supersedes points to unknown key"
  errors rather than an obvious failure.
- ~32 live docs point into the corpus BY DATE, which the split dangles. Each
  stripped file now ends with a generated "Records formerly in this file" index,
  which also rescues the identical breadcrumbs in old issue comments.
- decisions.md's "In this file:" list was 97 same-file anchor bullets that the
  split makes WRONG, not merely stale. Dropped; the generated index replaces
  them with links that resolve.

The equivalence harness now runs against a checked-in FIXTURE, not the live
corpus. The earlier version migrated the real tree, which made it a one-shot:
the moment the migration landed there was nothing left to move and the tests
failed for reasons unrelated to the code. A fixture keeps them testing the
SCRIPT rather than the repo's current state.

Keys preserved verbatim, warts included: `sched` (12) and `scheduling` (1) remain
two directories for one concept. Renaming a key is not a move -- it changes
identity, breaks the equivalence proof, and invalidates MemPalace's per-key
drawers. Taxonomy normalisation is separate work.

refs #610
2026-07-25 19:45:09 +02:00

4.4 KiB

key, title, status, since, supersedes, superseded-by, rule, signals, mechanics
key title status since supersedes superseded-by rule signals mechanics
security.fail-closed-api-auth 2026-07-12 — Fail-closed API auth + sensitive-read tier + CORS/ForwardedHeaders lockdown (#197 Bundle A, PR #292) active 2026-07-12 none none Every mutating `/api` request requires `X-Api-Key` (no open mode); reads are gated by `Api:RequireKeyForReads` (default true) OR `[RequiresApiKey]` on sensitive controllers; CORS is an exact-origin allowlist (`ApiCors`); `ForwardedHeaders` trust stays configurable but defaults to trust-all-with-warning. fail-closed auth, sensitive-read tier, CORS allowlist, single API key · paths: `IApiKeyProvider`, `ErsatzTV/Services/ApiKeyProvider.cs`, `FileSystemLayout.ApiKeyPath` · issues: #197, #280, #281, #282, #284, #285 `docs/api-conventions.md` §5; `ApiControllerSecurityTests`

Phase-1 of the #197 remediation — the auth posture that must land before any remote exposure. Owner decisions (confirmed this session): single API key (no read/write split), and Api:RequireKeyForReads defaults true (the whole /api surface requires the key). This does not affect Jellyfin/streaming: /iptv/* (playlist/guide/streams/logos) and /artwork/* are outside the filter's /api scope and keep their own optional access-token; only the management API the SPA talks to is gated.

  • Fail-closed writes (#280, S1). The empty-key "open" branch is deleted; there is no open mode. New IApiKeyProvider (ErsatzTV/Services/ApiKeyProvider.cs, singleton, resolved once at startup) yields a never-empty key: Api:WriteKey if set, else a key persisted at FileSystemLayout.ApiKeyPath (/config/api.key, 0600, path logged not value), else a generated 256-bit hex key. Every mutating /api request now requires X-Api-Key.
  • Sensitive-read tier (#282, S3/S5). Reads are gated by Api:RequireKeyForReads (default true) OR a new [RequiresApiKey] marker (mirror of [SkipApiKeyAuthorization]) applied to Troubleshoot/Logs/Settings/Maintenance, so that tier stays gated even if an operator opts reads open. OPTIONS preflight is exempt (CORS middleware owns it). ApiControllerSecurityTests asserts the tier reflectively.
  • Delete dead non-/api mutation surfaces (#281, S2). SortController (POST media/collections/{id}/items, dead Blazor SortableJS residue — the SPA uses PUT /api/collections/{id}/custom-order) and AccountController (POST account/logout, dead OIDC) bypassed the key because they sat outside /api. Removed rather than guarded.
  • CORS opt-in (#284, S6). AllowAnyOrigin/Method/Header is replaced by the ApiCors policy: an exact-origin allowlist from Api:CorsAllowedOrigins (semicolon list) that permits X-Api-Key/If-Match and exposes ETag; with no origins configured there is no cross-origin access (the SPA is same-origin).
  • ForwardedHeaders trust + scanner loopback (#285, S7/S10). GET /api/maintenance/gcPOST (crawler-triggerable GC; spec regenerated). ForwardedHeaders trust is configurable via ForwardedHeaders:KnownProxies/KnownNetworksunconfigured preserves the current trust-all behavior but logs a warning (flipping the default to loopback-only would break reverse-proxy scheme/host detection and thus M3U/XMLTV absolute URLs — the operator must name their proxy network). ScannerController gains [LocalhostOnly] (the scanner always calls back over http://localhost:{UiPort}/api/scan/...), which is only spoof-resistant once ForwardedHeaders trust is restricted — the two interlock. search/all-items DoS-paging is deferred (it feeds the SPA "add all" flow and needs coordinated pagination; the unauth exposure is already closed by read-gating).
  • SPA (web/). The client sends the stored key (ctv-api-key) on every method (not just mutations); a new keyless API Key screen (/app/api-key) lets the user paste the generated key, and a shell-level banner points there on any 401. See spa-conventions §5e. First-run/upgrade UX: with reads gated by default, the SPA shows no data until the key (from /config/api.key) is entered — an intended consequence of the strict default.

Phase-2 (contract freeze) — the declarative OpenAPI security scheme, global 401 docs, and /api/v1 versioning — remains #286/#287/#288. Phase-3 follow-ups: #265, #269, #172 remainder, search/all-items paging, per-key rate limiting.