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
9.5 KiB
key, title, status, since, supersedes, superseded-by, rule, signals, mechanics
| key | title | status | since | supersedes | superseded-by | rule | signals | mechanics |
|---|---|---|---|---|---|---|---|---|
| security.session-auth-dual-credential | 2026-07-12 — Browser SPA session auth: `/api` accepts session OR machine key (#295 PR1, server-only) | active | 2026-07-12 | none | none | `ApiAuthorizationFilter` accepts a request when a valid `X-Api-Key` matches OR the principal is an authenticated session (cookie `ctv-session`, `HttpOnly`/`SameSite=Lax`); session-authenticated mutations require the presence-only `X-CSRF` header or are rejected 403. This narrows the OIDC-inert sub-claim of `security.blazor-removal-auth-posture` (#206) — the rest of that record's auth-surface enumeration still holds. | session auth, dual credential, CSRF, OIDC revival, local admin · paths: `ApiAuthorizationFilter`, `ErsatzTV/Startup.cs` · issues: #295, #206, #197 | `docs/spa-conventions.md` §5e (PR2); `RootWriterForceVersionTests`-adjacent auth tests |
Implements the ratified #295 design (Fable [PLAN-MODE] pass, issue comment 9548). Supersedes the #206
"OIDC wiring stays inert until #197" note: the retained OIDC service registration is now revived, and a
cookie session becomes a first-class /api credential alongside the machine X-Api-Key. PR1 is
server-only and backward compatible — the SPA keeps sending its stored key; the SPA login flow, the
ApiKeyScreen→machine-key repurpose, and spa-conventions §5e land in PR2.
One gate, evolved (not [Authorize]-per-controller). ApiKeyAuthorizationFilter → ApiAuthorizationFilter,
same fail-closed-by-omission logic (a forgotten [Authorize] fails open — the #280 failure mode — so the
global filter stays the gate). It now accepts a request when a valid X-Api-Key matches OR the principal
is an authenticated session; the "does this endpoint need auth?" decision is still the single shared
EndpointRequiresKey(...) predicate (also drives OpenAPI, so the spec can't drift). Attributes renamed to
match the widened meaning: [RequiresApiKey]→[RequiresAuthentication], [SkipApiKeyAuthorization]→
[SkipApiAuthorization]. IApiKeyProvider, the X-Api-Key header, and Api:WriteKey/Api:RequireKeyForReads
are unchanged — machine/key behavior is byte-identical (verified: no OpenAPI drift, existing filter tests
still green).
CSRF (session only). The machine key is CSRF-immune (a browser can't set a custom header cross-origin
without a credentialed CORS grant we never issue). A cookie session is not: a session-authenticated mutation
must carry the X-CSRF header (presence-only — a custom header forces a CORS preflight a cross-site page can't
satisfy) or is rejected 403. Reinforced by SameSite=Lax + CORS without AllowCredentials (cross-origin
cookie auth is impossible by design). No antiforgery-token machinery.
Cookie ctv-session. Always registered (local login works with no IdP); OIDC handler added only when
OIDC:* is configured. HttpOnly, SameSite=Lax, SecurePolicy=SameAsRequest (so a plain-HTTP LAN isn't
bricked), 14-day sliding. /api XHR gets 401/403, not a redirect (OnRedirectToLogin/AccessDenied).
The UseAuthentication/UseAuthorization middleware — deleted with Blazor in #91b — is revived in the
legacy MapWhen branch only (hosts /api + OIDC /callback + /docs; /iptv and /app untouched).
Local store = ConfigElement rows, single admin, NO migration (owner ruling F2):
AuthLocalAdminUsername, AuthLocalAdminPasswordHash (ASP.NET PasswordHasher, PBKDF2, via
Microsoft.Extensions.Identity.Core), AuthSecurityStamp. A password change rotates the stamp; the cookie
OnValidatePrincipal (CookieSecurityStampValidator) compares the claim to the stored stamp and rejects a
stale session (revocation). OIDC sessions carry an etv:auth_method=oidc claim and skip the stamp check
(governed by the IdP).
Fail-closed out of the box + recovery. An unconfigured instance keeps /api gated (the key still works);
first-run is a setup-claim (POST /api/auth/setup, first-claim-wins, only valid while unconfigured —
owner ruling F1). Recovery without the browser: Auth:LocalAdmin:Password env seed (LocalAdminSeedService,
overwrites + rotates the stamp on startup) or the machine key. Login hardening: per-IP rate limit
([EnableRateLimiting("auth")], 10 / 5 min) on login/setup/password, dummy-hash verify on unknown/unconfigured
user (no enumeration).
Authelia = app-owned OIDC session; never trust proxy identity headers (owner ruling F3): the container is
LAN-reachable bypassing the proxy, so Remote-User/Remote-Email header trust is spoofable. OIDC→Authelia
gives SSO without a double login. ForwardedHeaders behaviour is kept unchanged from #285 (trust any peer
with a warning; restrict via KnownProxies/:KnownNetworks). A stricter "ignore X-Forwarded-* unless a proxy
is configured" default was implemented and then reverted after review (cold fork M1): the forwarded
scheme/host feed /iptv M3U/XMLTV/HLS absolute-URL generation (Request.Scheme in GetChannelGuideHandler/
IptvController), so ignoring them would regress stream URLs to http/internal-host for a proxied deployment
that hasn't set KnownProxies. Deployment coordination: operators behind a proxy should set
ForwardedHeaders:KnownProxies/:KnownNetworks — it gives the login rate limiter an unspoofable client IP and
marks the session cookie Secure behind TLS. The residual (a direct LAN peer can spoof X-Forwarded-For to
evade the per-IP login limit when unrestricted) is accepted defense-in-depth loss, mitigated by PBKDF2 +
no-enumeration.
Review hardening (fork + independent Codex pass, folded into PR1). Codex caught concurrency defects the
fork missed — folded in: (a) atomic first-claim-wins — setup writes the three credential rows in one
transaction guarded by the unique ConfigElement.Key index (a lost race → DbUpdateException → 409), so a
concurrent claim can't produce a mixed-state credential; (b) consistent login snapshot — login reads the
hash + stamp in one query and no longer rehashes-on-verify, so a login racing a password change can't capture a
newer stamp than the hash it verified (a concurrent change either fails the old password or leaves the issued
cookie carrying the pre-change stamp → revoked next request); (c) env-seed waits on
SystemStartup.WaitForDatabase (the migrator is a BackgroundService, so registration order alone didn't
guarantee the schema existed) — moved to Services/RunOnce/. Also: logout + password require X-CSRF
(the [SkipApiAuthorization] auth surface isn't covered by the filter's CSRF check → forced-logout CSRF), and
input length caps on username/password. Logout rotates the security stamp when called from a local session
(E2E-caught: SignOutAsync alone only clears the client cookie, leaving the stateless encrypted ticket
replayable server-side) — so signing out actually ends the session server-side; for the single admin this
revokes all local sessions ("log out everywhere"). Gated on an authenticated session so an unauthenticated
caller can't force-revoke the admin. Deferred with a tracked gate: side-effecting [RequiresAuthentication]
GETs (troubleshoot playback/archive) aren't CSRF-covered — #301, gating PR2 (latent in PR1: the SPA still
uses the machine key). OIDC-session revocation lever (no local stamp) noted for PR3 operator docs.
A fix-commit re-review (Codex, #242 discipline) then confirmed the above resolved and caught a second round:
(a) HIGH — env-seed vs. setup race: an attacker could claim admin in the startup window before
LocalAdminSeedService runs, and the seed's insert would then be swallowed (attacker's credential persists,
defeating the env recovery path). Fixed structurally: the setup-claim endpoint is closed whenever
Auth:LocalAdmin:Password is configured — the env seed owns the credential, so there is no claim to race
(this also strengthens the setup-claim TOFU posture: an operator on an untrusted network sets the env password
and browser setup is disabled). (b) LOW: a concurrent setup race-loser now returns 409 (not 422), and
ClaimLocalAdmin's DbUpdateException catch re-checks existence and rethrows genuine/transient DB errors
rather than masking them as "already configured". (c) MEDIUM — accepted: two simultaneous authenticated
password changes are a non-serializable lost-update (last-write-wins; the loser's cookie may be immediately
revoked). Accepted for a single-admin system: it needs two concurrent authenticated sessions both submitting
the correct current password at the same instant, and the outcome is self-healing (re-login). Adding EF
optimistic concurrency to the credential rows is disproportionate here.
OpenAPI = ApiKey-only; /api/auth/* excluded (owner ruling F4): the spec's audience is machine/MCP
clients, and a browser-interactive cookie login isn't something a generated client drives, so the cookie path
is an additional accepted credential the doc needn't express. AuthController is [ApiExplorerSettings(IgnoreApi = true)]. Verified: no v1.json/v1.d.ts/endpoint-index drift from this PR.
Phasing. PR1 = this (server only, no migration). PR2 = SPA (drop the key header for browser calls + add
X-CSRF, AuthContext + boot gate, login/setup screens, ApiKeyScreen→machine-key management, E2E,
spa-conventions §5e). PR3 = key rotation + operator docs (Authelia client + env reference). Rollout: PR1→PR2
same release, then a manual Authelia round-trip checklist before the prod pin bump.