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 :doc:`/operator/index`.
The rules that tests pin are listed in :doc:`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
--------------------
.. mermaid::
flowchart TD
req(["request"])
pub{"/healthz, /openapi.json
or /api/docs?"}
intake{"/api/v1/candidates?"}
push["push-token check
app.auth.push"]
alg{"token alg header"}
oidc["OIDC validator
RS256, JWKS"]
sec{"dev shared
secret set?"}
dev["dev validator
HS256"]
deny["401"]
principal["Principal
issuer, subject, email, roles"]
perm["require_permission
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)
:py:obj:`app.auth.tokens.verify_token` picks the validator from the
token's unverified ``alg`` header and never tries validators in
turn, so an RS256 token can never be checked against the dev
secret.
:mod:`app.auth.oidc` validates signature, ``exp``, ``nbf``, ``aud`` and
``iss`` with PyJWT; ``exp``, ``iss``, ``aud`` and ``sub`` are
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
:py:obj:`app.auth.deps.principal_from_claims` maps ``iss`` and ``sub`` to
the identity, ``name`` or ``preferred_username`` to the display name,
``email`` (dropped when ``email_verified`` is false) and the roles
at ``ADRIANO_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``.
:mod:`app.auth.push` hashes both sides with SHA-256 and compares with
``hmac.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 in ``localStorage``;
only the short-lived PKCE state uses ``sessionStorage``.
A page reload therefore signs the user out.
Authorization
-------------
Permissions
``read`` and ``operate`` come from the ``rbac`` section of
``catalog.yaml``, mapping IdP roles to permissions.
A missing or invalid section grants nothing (deny by default).
Catalog routes need ``read``; poll triggers need ``operate``;
``/api/v1/me`` needs only a valid token.
Reviewing
Not a permission.
Eligibility comes only from the approval policy
(``issuer|subject`` or role); ``read`` never implies it.
See :doc:`domain/approval` for the decision checks, including
self-approval and its limits.
Scope of reads
The diff endpoint answers only for digests related to the record,
so it cannot be used to read arbitrary snapshots.
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 (:py:obj:`app.services.ingest.render_markdown`):
scripts, event handlers and unsafe URLs are removed and links get
``rel="noopener noreferrer"``.
Only the stored, sanitized HTML is rendered with ``v-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-Policy``
``default-src 'self'``, ``script-src 'self'``,
``style-src 'self' 'unsafe-inline'`` (Vuetify sets theme variables
in inline ``style`` attributes),
``connect-src`` and ``frame-src`` limited to ``'self'`` plus the
identity provider's origin,
``frame-ancestors 'none'``, ``base-uri 'none'``,
``form-action 'self'``.
``X-Content-Type-Options``
``nosniff``.
``Referrer-Policy``
``strict-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
(:doc:`decisions/0011-dev-auth-bypass`):
Backend validator
HS256 tokens are accepted only while ``ADRIANO_DEV_SHARED_SECRET``
is set; the secret has no default.
Backend router
``/auth/dev/token`` and ``/auth/dev/roles`` are imported and mounted
only when ``ADRIANO_DEV_AUTH_ENABLED`` is 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.sh`` runs after every
``npm run build`` and fails if dev-login markers
(``/auth/dev/token``, ``/auth/dev/roles``, ``DevLoginDialog``)
appear in ``dist/``.
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.yaml`` holds env var **names** (``token_env``,
``push_tokens_env``), never values.
Values are resolved once at startup by :mod:`app.composition` and held
as ``pydantic.SecretStr``.
* Secrets never appear in log lines, error messages, stored
``config`` JSON or ``intake_run`` rows;
secret-looking keys in ``config`` are redacted defensively.
* The GitLab token is sent only in the ``PRIVATE-TOKEN`` header,
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 :doc:`domain/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
(:doc:`domain/hashing`).
Known gaps
----------
* No rate limiting on push intake; every request, including refused
ones, writes an ``intake_run`` row.
* A repeated ``delivery_id`` returns 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_AUTHORITY`` with 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.