Gunnar

nordisk/holger

.nornir/auth-design.md

# Auth, RBAC & audit — design

> **Class:** `design` · **Function:** `auth` (gates `serve`'s write path). The whole-system
> view is [`design.md`](design.md); the model rationale + proposals are in
> `auth-rbac-design.md` (reasoning). This file states the **wired** posture.

![push: upload → auth → route → store → index](assets/flow_push.svg)

## The write gate (reads are not gated)

A write goes: agent → **authenticate** → **authorize (RBAC)** → **route** → **cap** →
`backend.put()`. `authorize_write` was added **only** to the write path, so a read never
runs auth.

## Authenticate — `server/lib/src/auth.rs`

`validate_request()` accepts either:

- **mTLS CN** — the client-cert chain is verified **once, at the TLS handshake** by rustls
  `WebPkiClientVerifier` (`server/lib/src/exposed/tls.rs`, `ring` provider pinned
  process-wide). Per request only the leaf CN is parsed (`leaf_common_name`, x509-parser),
  and only on writes. ALPN negotiates HTTP/2, so many requests multiplex over one
  long-lived connection (one handshake, N requests). This is the **fast path**.
- **OIDC bearer token** — validated against the issuer's `/userinfo` through **one shared
  pooled `reqwest::Client`** (`OnceLock`) + a **TTL token cache** keyed by
  `(issuer, blake3(token))` (default 120 s, `NORNIR_OIDC_USERINFO_CACHE_TTL_SECS`; the
  JWKS-refetch TTL is the separate `NORNIR_OIDC_JWKS_CACHE_TTL_SECS`, default 3600 s).
  **This TTL is the revocation window** — until it expires a token the IdP has already
  revoked is still accepted, so it is the knob to shorten when hardening. The reader
  lives in `edda/crates/nornir-auth-oidc`, NOT in holger: `server/lib/src/auth.rs` is a
  21-line re-export since the policy layer was promoted, and the variable was RENAMED in
  that move. These docs named the pre-rename `HOLGER_OIDC_CACHE_TTL_SECS` for months
  while no code read it, so an operator who set it got no error, no warning and the full
  120 s window — a security control failing silently OPEN. The legacy name is now
  honoured with a loud deprecation warning rather than ignored. The raw token is
  never stored — only its blake3 digest. A repeat write with the same token returns from
  cache with zero network calls.

An empty method list = open access; a configured-but-invalid credential = 401.

## Authorize — RBAC (`Role = Reader | Writer | Admin`)

Both write paths run the role check: gRPC `put_artifact` → `authorize_write` (`grpc.rs`)
and the HTTP/OCI gateway → `authorize_http_write` (`exposed/http.rs`).

- **Role model** — ordered least→most. `role_for`: explicit `roles` map → `default_role` →
  `Reader`. When RBAC is on, DELETE requires `admin`; every other write requires
  `writer`-or-`admin`.
- **Fail-closed** — policy configured but no authenticated identity ⇒ **denied**.
- **Opt-in** — `rbac_enabled()` is false until a `roles` or `default_role` policy is set,
  so an existing config keeps authN-only behaviour (any valid identity may write). With no
  `auth.methods` at all, writes proceed as `anonymous`.
- **Policy lives in RON**, alongside repos/endpoints/storage — git-tracked, diffable, and
  immutable in the sealed static flavor. Not a mutable KV store.

```ron
auth: Some((
    methods: [ Oidc(( issuer_url: "https://idp/realm" )), Mtls(( ca_cert: "..." )) ],
    roles: { "alice@corp": Admin, "CN=build-bot": Writer },
    default_role: Some(Reader),
))
```

## Caps, audit & hardening

- **Ingest / body cap** — `ron_max_body_bytes` on the `ExposedEndpoint` (default **1 GiB**,
  one knob) caps **both** the HTTP-gateway body and the gRPC `put` ingest; oversized
  payloads are rejected (413 / `ResourceExhausted`) before the backend sees them.
- **Audit** — **on by default**: an append-only **Arrow-IPC** segment log records identity
  (`sub`/CN) + repo + artifact + action + source IP + timestamp on every write (and read).
  Replay tolerates a corrupt/truncated segment (logged and skipped), so a crashed write
  can't poison offline review of the rest of the trail.
- **SSRF guards (agent side)** — the Nexus connector refuses to attach credentials to a
  `downloadUrl` that isn't same-origin with the configured base URL; the world/pip
  connector restricts downloads to an **https host allow-list**.
- **Injection-safe responses** — attacker-influenced JSON (docker `tags/list`, nuget
  versions) is built with `serde_json`; the PEP 503 HTML is escaped; `file-oci`
  `tags/list` 404s an unknown repo instead of masking it as 200 + empty.

## Known gaps (flagged in code)

Per-method mTLS `ca_cert` verification is **flagged open** — trust is currently delegated to
the TLS `client_ca`; the per-method chain check needs cert plumbing. Reads are **not yet
gated**. Offline OIDC (JWT-signature verify against an offline JWKS, no `/userinfo`) is the
airgap auth path and is now **WIRED** (`auth.rs::validate_oidc_bearer` → `verify_jwt_offline`
against a cached / pre-provisioned JWKS; see `auth-rbac-design.md §W1-airgap`).
Production air-gapped CA issuance/rotation does not exist (only the mannequin test PKI).