Security¶
This page is the developer-level threat model: who can do what, how each path is authenticated, and which controls a change must not weaken. Deployment hardening (TLS, secrets in the platform, database roles, backups) is an operator task, see Operators. The rules that tests pin are listed in Invariants.
Assets and actors¶
- What needs protecting
The approval history (
decision_event) and the content snapshots it refers to; the catalog content itself (internal decisions); GitLab tokens and CI push tokens.- Who acts
Readers and reviewers (browser, OIDC token), operators (OIDC token with
operate), CI pipelines (push token), and anyone on the network who can reach the frontend.- Trust boundaries
The browser, CI and the sources are untrusted input. The OIDC provider is trusted to authenticate people. PostgreSQL is trusted storage, but not tamper-evident (see Audit integrity).
Authentication paths¶
flowchart TD
req(["request"])
pub{"/healthz, /openapi.json<br/>or /api/docs?"}
intake{"/api/v1/candidates?"}
push["push-token check<br/>app.auth.push"]
alg{"token alg header"}
oidc["OIDC validator<br/>RS256, JWKS"]
sec{"dev shared<br/>secret set?"}
dev["dev validator<br/>HS256"]
deny["401"]
principal["Principal<br/>issuer, subject, email, roles"]
perm["require_permission<br/>read or operate"]
req --> pub
pub -- "yes" --> pubok["public, no record data"]
pub -- "no" --> intake
intake -- "yes" --> push
intake -- "no" --> alg
alg -- "RS256" --> oidc
alg -- "HS256" --> sec
alg -- "anything else" --> deny
sec -- "yes" --> dev
sec -- "no" --> deny
oidc -- "valid" --> principal
dev -- "valid" --> principal
principal --> perm
- Bearer tokens (people)
app.auth.tokens.verify_tokenpicks the validator from the token’s unverifiedalgheader and never tries validators in turn, so an RS256 token can never be checked against the dev secret.app.auth.oidcvalidates signature,exp,nbf,audandisswith PyJWT;exp,iss,audandsubare required. No custom crypto. An unacceptable token is 401; an unreachable IdP or no configured mechanism is 503. Token values are never logged or echoed.- Claims to principal
app.auth.deps.principal_from_claimsmapsissandsubto the identity,nameorpreferred_usernameto the display name,email(dropped whenemail_verifiedis false) and the roles atADRIANO_OIDC_ROLES_CLAIM. No user is stored.- Push tokens (CI)
Per-registration shared secrets, resolved at startup from the env var names in
push_tokens_env.app.auth.pushhashes both sides with SHA-256 and compares withhmac.compare_digest, checking every configured token without an early exit, so neither content nor length leaks through timing. A push token authorizes writing candidates to its own registration and nothing else; intake refuses JWTs, and read routes refuse push tokens. Intake authenticates before it parses the body, so an unauthenticated caller learns nothing about registrations or the expected shape.- Frontend tokens
The SPA is a public OIDC client (Authorization Code + PKCE via
oidc-client-ts). Tokens live in memory only, never inlocalStorage; only the short-lived PKCE state usessessionStorage. A page reload therefore signs the user out.
Untrusted content¶
ADR files come from sources and CI and are untrusted.
- Parsing
YAML is loaded with
yaml.safe_load. Parsing never raises for content, so a hostile file cannot stop a poll or affect other records.- Rendering
Markdown is rendered with markdown-it-py and sanitized with nh3 at ingest (
app.services.ingest.render_markdown): scripts, event handlers and unsafe URLs are removed and links getrel="noopener noreferrer". Only the stored, sanitized HTML is rendered withv-html(RecordDecisionCard.vue,ErratumTextBlocks.vue). A change to rendering must keep sanitization at ingest.- Size limits
Push intake refuses bodies over 8 MiB and document content over 1 MiB (413). Reviewer comments are limited to 2,000 characters.
Browser hardening¶
The backend adds these headers to every response
(SecurityHeadersMiddleware in backend/app/web.py):
Content-Security-Policydefault-src 'self',script-src 'self',style-src 'self' 'unsafe-inline'(Vuetify sets theme variables in inlinestyleattributes),connect-srcandframe-srclimited to'self'plus the identity provider’s origin,frame-ancestors 'none',base-uri 'none',form-action 'self'.X-Content-Type-Optionsnosniff.Referrer-Policystrict-origin-when-cross-origin.
The settings validation rejects an ADRIANO_OIDC_AUTHORITY that is
not a plain http(s)://host[:port][/path] before its origin is
written into the CSP and into /config.js, so the setting cannot
inject CSP directives.
The development bypass¶
Dev login lets a developer mint a token for any identity, so it is guarded at three independent points (0011: A gated development login):
- Backend validator
HS256 tokens are accepted only while
ADRIANO_DEV_SHARED_SECRETis set; the secret has no default.- Backend router
/auth/dev/tokenand/auth/dev/rolesare imported and mounted only whenADRIANO_DEV_AUTH_ENABLEDis true and the secret is set, with a loud warning in the log. Otherwise they are a plain 404.- Frontend build
The dev-login UI is reachable only when the build-time literal
import.meta.env.DEV && VITE_DEV_AUTH === "true"is true, which is never the case in a production build, and is never part of runtime configuration.frontend/scripts/check-no-dev-auth.shruns after everynpm run buildand fails if dev-login markers (/auth/dev/token,/auth/dev/roles,DevLoginDialog) appear indist/.
With a real issuer configured, a dev token is issued under that issuer
and can impersonate any issuer|subject, including a reviewer.
That is why the secret must never be set in production.
Secrets¶
catalog.yamlholds env var names (token_env,push_tokens_env), never values. Values are resolved once at startup byapp.compositionand held aspydantic.SecretStr.Secrets never appear in log lines, error messages, stored
configJSON orintake_runrows; secret-looking keys inconfigare redacted defensively.The GitLab token is sent only in the
PRIVATE-TOKENheader, over HTTPS with certificate verification, without following redirects; pagination links outside the API root are refused, so the token is never sent to another origin.
Audit integrity¶
decision_event is append-only through database triggers
(see Database schema).
They stop application bugs and ordinary mistakes,
not a table owner or superuser, who can disable or drop them.
Running the application as a database role that does not own the tables
and has no TRUNCATE or ALTER grant is the stronger mitigation.
Neither is cryptographic; a signed audit export is the deferred answer
(Content hashing).
Known gaps¶
No rate limiting on push intake; every request, including refused ones, writes an
intake_runrow.A repeated
delivery_idreturns the earlier result without comparing the body; it is retry protection, not a content check.The CSP allows exactly one identity-provider origin, that of
OIDC_AUTHORITY; a provider serving endpoints from a second origin is blocked.OIDC_AUTHORITYwith a bracketed IPv6 host is refused at container start.RP-initiated logout is not implemented: signing out clears the in-memory token but leaves the IdP session.
The browser’s Authorization Code + PKCE round trip has not been exercised against a real provider; backend RS256 validation is tested against a fake IdP’s JWKS.
Self-approval cannot be detected for polled records, which have no author.