Gunnar

nordisk/holger

.nornir/auth-rbac-design.md

<!-- nornir:class: reasoning -->

# Auth & RBAC — design note (for review)

> Status: **IMPLEMENTED (W1 + W2 v1), awaiting Rickard's evaluation.** Grounded in
> `server/lib/src/auth.rs` + `server/lib/src/grpc.rs`. Folds the §O.holger-fixes
> W1/W2 items. The *model* below is the agent's professional call (delegated:
> "do it pro, I eval later"); change it freely.

## Where authorization config lives — RON, not redb

**Authorization policy is declarative config, not runtime state.** Who is
`reader`/`writer`/`admin`, and (later) which repo allows what, lives in the
**RON config graph** alongside repos/endpoints/storage:

- git-tracked, code-reviewed, diffable — a policy change shows up in a PR.
- **immutable in the static/drift flavor** — the sealed binary's policy can't be
  silently mutated; it's exactly the bytes you reviewed.
- no IdP-claim-format coupling — airgap-friendly.

**redb is for mutable *operational* state, not policy.** Its natural homes here:

| State | Why redb | Item |
|---|---|---|
| **Audit log** (who wrote/promoted/deleted what, when) | append-only, queryable, survives restart — the companion to RBAC | W8 (new) |
| **Promotion lineage** (DEV→PROD signed release units) | mutable, content-addressed history | W4 |
| **Writable dev-mode index** | already mutable; redb could back the coord index | §O keystone |

Putting auth *policy* in a mutable KV store would defeat audit/review and the
airgap immutability model. So: **policy → RON; runtime/audit/lineage → redb.**

## W1 — OIDC validation is fast (DONE)

`auth.rs::validate_oidc_token` previously built a fresh `reqwest::Client` and hit
`/userinfo` on **every** write. Now:

- **One shared pooled client** (`OnceLock<reqwest::Client>`, idle-pool + 10s
  timeout) — connection + TLS-session reuse instead of a handshake per write.
- **TTL token cache** keyed by `(issuer, blake3(token))` → `(subject, instant)`.
  A repeat write with the same token returns from cache with **zero** network
  calls. TTL = 120s default (`NORNIR_OIDC_USERINFO_CACHE_TTL_SECS` override; the
  legacy `HOLGER_OIDC_CACHE_TTL_SECS` is honoured with a deprecation warning) — short
  enough that a revoked token stops working quickly. The raw token is never
  stored (only its blake3 digest), and the digest key can't collide into an auth
  bypass. Expired entries are purged opportunistically on insert (bounded map).

Tests (`auth.rs` mod tests, real local `/userinfo` mock counting hits):
`oidc_validation_caches_and_skips_repeat_userinfo_call` (2nd validate → 1 hit),
`oidc_distinct_token_is_not_a_cache_hit` (2 tokens → 2 hits).

mTLS stays the fast path (chain verified once at handshake; per-request only a
leaf-CN parse) — unchanged.

### W1-airgap — offline OIDC (net-new, for the static/airgap profile)

The `/userinfo` round-trip above is the **online** path → dynamic-only (it makes
an outbound call, so it cannot exist in an airgap binary). The **airgap** path
validates the OIDC token **offline, like a PKI signature**: verify the JWT's
signature + standard claims (`exp`/`iss`/`aud`) against a **JWKS provisioned
offline** — carried in the RON config or the sealed `.znippy`, never fetched.
Pure crypto, **no `reqwest`**. So OIDC works in airgap without egress; the
profile/`BackendProvider` selects online-vs-offline. See
`static-dynamic-profile-design.md` (auth-by-egress table).

**DONE.** `auth.rs::validate_oidc_bearer` is offline-first: a JWT is verified
OFFLINE by `verify_jwt_offline` (`jsonwebtoken`, key selected by header `kid`)
against a **JWKS cache** keyed by issuer; claims `exp`/`iss`/`aud` are checked.
The JWKS is resolved in order — fresh in-memory cache → inline `jwks` in the RON
config → a pre-provisioned `jwks_path` file → an online fetch from `jwks_uri`
(write-through-persisted to `jwks_path`). The first three touch **no network** —
the fully-airgapped path. An opaque (non-JWT) token still falls back to the online
`/userinfo`. The `sub` claim feeds the same RBAC `role_for` path. New `AuthMethodConfig::Oidc`
fields (`audience`, `jwks`, `jwks_path`, `jwks_uri`) are all `#[serde(default)]`,
so existing `issuer_url`-only configs parse unchanged. Tests (`auth.rs`
`offline_oidc_tests`): valid-verifies-offline, expired / bad-sig / wrong-iss /
wrong-aud rejected, fully-offline via a pre-provisioned file, and JWKS
fetched-once-then-cached.

## W2 — authorization / RBAC (v1 global gate + v2 per-repo ACL — both DONE)

**Model.** `Role = Reader | Writer | Admin` (ordered, least→most). `AuthConfig`
gains:

```ron
auth: Some((
    methods: [ Oidc(( issuer_url: "https://idp/realm" )), Mtls(( ca_cert: "..." )) ],
    // identity (OIDC `sub` or mTLS CN) → role
    roles: {
        "alice@corp":   Admin,
        "CN=build-bot": Writer,
    },
    // role for an authenticated-but-unmapped identity (omit ⇒ Reader, least-priv)
    default_role: Some(Reader),
))
```

**Backward-compatible.** `roles`/`default_role` are `#[serde(default)]`, and RBAC
is **off** until either is set (`rbac_enabled()`), so every existing config keeps
its current authN-only behaviour (any valid identity may write). Old RON parses
unchanged.

**v1 enforcement (global gate, implemented).** `grpc.rs::authorize_write`
resolves the writer's role (`role_for`: explicit map → `default_role` → `Reader`)
and, when RBAC is on, requires `role.can_write()` (Writer|Admin) — else
`permission_denied`. Fail-closed: policy configured but no identity ⇒ denied.
Reads stay open (R3 is a separate, deliberate decision — see holger-fixes).

Tests: `role_privilege_ordering_and_capabilities`,
`rbac_is_off_until_policy_is_configured`,
`role_resolution_explicit_then_default_then_least_privilege`.

**v2 (per-repo ACL — implemented).** `AuthConfig` carries
`repo_roles: HashMap<String, HashMap<String, Role>>` (`server/lib/src/auth.rs:AuthConfig`)
— outer key = repository name, inner map = identity → role FOR THAT REPO. A
repo-scoped mapping WINS over the global `roles`/`default_role`, so a build-bot
can be a `Writer` on `team-a-dev` yet only a `Reader` everywhere else — the
airgap "who may push to which shelf" control:

```ron
// on AuthConfig, alongside `roles` / `default_role`
repo_roles: {
    "team-a-dev": { "alice@corp": Writer, "bob@corp": Reader },
},
```

Configuring any per-repo ACL turns RBAC on by itself — `rbac_enabled()`
(`server/lib/src/auth.rs:AuthConfig::rbac_enabled`) is true when *any* role
policy is set, even with an empty global `roles` map. The write path resolves the
scoped role via `AuthConfig::role_for_repo(repo, subject)`
(`server/lib/src/auth.rs:AuthConfig::role_for_repo`): per-repo explicit → global
explicit → `default_role` → least-privilege `Reader`. The enforcing gate is
`HolgerGrpc::authorize_write` (`server/lib/src/grpc.rs:HolgerGrpc::authorize_write`),
which calls `role_for_repo` once the target repo name is known — threaded in from
`put_artifact` (`server/lib/src/grpc/services.rs`); `authorize_admin` mirrors it
requiring `Admin`. Fail-closed as before: policy configured but no identity ⇒
denied. The v1→v2 change is *how* the role is resolved (repo-scoped vs global);
the gate, RON compat (`#[serde(default)]`), and fail-closed posture are unchanged.

Tests: `per_repo_acl_overrides_global_role` (a repo-scoped map both promotes and
demotes on its shelf, global elsewhere) and `rbac_is_off_until_policy_is_configured`
(a per-repo ACL alone flips RBAC on) — both in `server/lib/src/auth.rs`.

## R3 — authenticated READS (SHIPPED 2026-07-22, opt-in)

### The question first: gap, or deliberate?

Holger's reads — download, list, archive stat/browse, search, the whole HTTP door
`GET` surface — were never authenticated. Only writes and admin actions were. Was
that a decision or an omission?

**Both, and the split matters.** Open reads are the RIGHT DEFAULT for what holger
mostly is: a mirror on the *secure* side of an airgap, where the network boundary
IS the perimeter and every client inside it may pull. `pip` / `cargo` / `docker`
pointed at holger with no credentials is the whole "it thinks it's talking to
PyPI" promise; the read path is deliberately as frictionless as a public mirror.
`integration_oci_tls.rs::writes_blocked_without_auth_reads_open` has asserted that
posture on purpose since the beginning.

What was a **gap** is that there was no way to turn it OFF. `Role::Reader` has
existed in the docs, in `CLAUDE.md`'s permission table and in the code since day
one with **nothing enforcing it** — a role that granted "download, list" while
download and list asked for nobody. A holger that fronts an internal registry on a
routed corporate network, or one whose repo set is itself sensitive (the catalog
alone leaks project names), had no switch. This item was already logged here as
R3; it is now closed.

### The design: one opt-in flag, the existing role ladder

```ron
auth: Some((
    methods: [ Oidc(issuer_url: "https://idp.corp/realms/holger") ],
    require_auth_for_reads: true,             // ← the whole switch
    default_role: Some(Reader),               // any authenticated identity may pull
    repo_roles: {
        "customer-secrets": { "contractor@corp": Denied },   // …except here
    },
)),
```

- **`AuthConfig::require_auth_for_reads: bool`**, `#[serde(default)]` ⇒ `false`.
  A config that does not mention it behaves **byte-for-byte** as before. Nothing
  breaks on upgrade; that is the point of making it opt-in rather than deriving it
  from `rbac_enabled()` (turning RBAC on for *writes* must not silently close
  reads — asserted by `read_gate_is_off_by_default_and_reads_stay_open`).
- **`Role::Denied`** — a new variant, declared FIRST so the derived `Ord` keeps
  `Denied < Reader < Writer < Admin`. Nothing produces it implicitly and no
  existing RON names it, so it is purely additive. It is what makes the gate
  *expressive* instead of a boolean: a per-repo `Denied` carves one private repo
  out of an otherwise readable server. `Role::can_read()` is `!Denied`.
- **`AuthConfig::authorize_read(identity, repo)`** — the ONE predicate every read
  door consults (L5). Gate off ⇒ `Ok(())`. Gate on: no identity ⇒ **401**, a role
  that cannot read ⇒ **403**. Repo-scoped, so `repo_roles` governs reads exactly
  as it governs writes.
- **Fail-closed, loudly.** Gate on with no `methods` configured = nothing can
  authenticate = every read refused. That is fail-closed working as designed
  (asserted, so it can never be "fixed" into falling open), and
  `warn_unauthenticatable_read_gate()` shouts at boot so it is not a mystery.

### Where it is enforced

| door | gate |
|---|---|
| gRPC `FetchArtifact` · `StreamArtifact` · `ListArtifacts` | `HolgerGrpc::authorize_read` |
| gRPC `ListArchiveFiles` · `ArchiveInfo` · `ListRepositories` · `Search` | same |
| gRPC `FetchSbom` · `GetProperties` | same |
| HTTP door, every `GET`/`HEAD` route through `handle` | `auth_config.authorize_read` |
| `Health` / `/healthz` / `/readyz` / `/v2/` probes | **deliberately NOT gated** |

Health stays open on purpose: gating it would break every load balancer and
readiness probe the moment an operator opts in (`read_gate_leaves_health_open`).

### The bonus that falls out

A gated read audits the **real subject**. Every read used to record `anonymous`
because there was no identity to record; with the gate on, `AuditEvent::ident`
carries the OIDC `sub` / token principal / mTLS CN. Knowing *who* took a build
artifact is most of why one authenticates a download at all.

### Cost when off

Zero. `authorize_read` returns on a `bool` before touching metadata, headers or
certificates — no credential parsing happens on the default read path.

### Tests

`auth.rs` + `exposed/http.rs` unit cover the predicate (default-off, 401/403,
`Denied` globally and per-repo, the no-methods refusal). `grpc.rs` drives the
**real** service impls: `read_gate_off_serves_anonymous_reads_even_with_rbac_on`
(the default is untouched even with auth + roles configured),
`read_gate_on_refuses_anonymous_serves_reader_denies_denied` (401 / 200 / 403, and
the audit line names `pull-bot`), `read_gate_covers_every_read_door_not_just_fetch`
(RED-when-broken for a door that forgets the gate — `StreamArtifact` bypassed the
quarantine predicate exactly that way once), `read_gate_leaves_health_open`.

## Open follow-ups
- W8 — **audit log** in redb (the RBAC companion).