Gunnar

nordisk/holger

.nornir/auth-guide.md

# Operate securely — guide

> **Class:** `guide` · **Function:** `auth`. Architecture: [`auth-design.md`](auth-design.md).
> Holger's gates are **wired but opt-in**: out of the box a server with no `auth` block
> accepts **anonymous writes over cleartext** — fine for a local dev mirror, *not* for
> anything exposed. Turn the gates on before you publish a write path.

## D.1 — Turn on authentication

```ron
auth: Some((
    methods: [
        Oidc(( issuer_url: "https://idp.example.com/realm" )),
        Mtls(( ca_cert: "/etc/holger/ca.pem" )),
    ],
))
```

With it set, a missing/invalid credential is rejected (401); an empty method list = open.
mTLS is the fast path (chain verified once at the handshake; per-request only a leaf-CN
parse). OIDC bearer tokens validate against `/userinfo` (pooled client + TTL cache;
`NORNIR_OIDC_USERINFO_CACHE_TTL_SECS` overrides the 120 s default — the old
`HOLGER_OIDC_CACHE_TTL_SECS` name still works but warns, so migrate it). **Caveat:** the per-method
`ca_cert` is not yet verified separately — trust is delegated to the TLS `client_ca`, so
scope that CA tightly.

## D.2 — Turn on RBAC (role policy → default-deny on writes)

RBAC is **off until you add a role policy**. Once `roles` (or `default_role`) is set,
`rbac_enabled()` flips on and writes are gated on both paths (gRPC `put` and the HTTP/OCI
gateway): **DELETE requires `admin`, all other writes require `writer`-or-`admin`**,
**fail-closed** (configured policy + no identity ⇒ denied).

```ron
auth: Some((
    methods: [ /* …as above… */ ],
    roles: { "alice@corp": Admin, "CN=build-bot": Writer },
    default_role: Some(Reader),   // Reader (or omit) keeps writes locked to named writers/admins
))
```

To get **default-deny** writes: configure `methods` *and* a policy whose default is
`Reader`. An identity not listed as `Writer`/`Admin` then cannot write. **Reads are not yet
gated** — RBAC governs writes only.

## D.3 — Body & ingest caps

One knob on the `ExposedEndpoint` caps **both** the HTTP-gateway body and the gRPC `put`
ingest; oversized payloads are rejected (413 / `ResourceExhausted`) before the backend sees
them.

```ron
ExposedEndpoint( ron_name: "default", /* … */ ron_max_body_bytes: Some(1073741824) )   // 1 GiB (default)
```

## D.4 — Audit log (location & format)

Auditing is **on by default** — an append-only **Arrow-IPC** log records identity + repo +
artifact + action + source IP + timestamp on every write (and read). Configure with
`audit.mode`:

```ron
audit: Some((
    dir: "/var/lib/holger/audit",   // default: "holger-audit" (rel. CWD)
    mode: Everything,               // Off | Aggregated | Everything   (default: Everything)
    flush_secs: 30,                 // Aggregated only: how often the counters are written
)),
```

| `mode` | what is recorded | what it gives up |
|---|---|---|
| `Everything` | one blake3-chained row per call, flushed before the response returns | nothing — this is the complete record, and the most expensive thing holger does on a download |
| `Aggregated` | `(ident, action, repo, artifact, ip, status) -> count, bytes, first_seen, last_seen`, written every `flush_secs` | the per-call **timeline**; the chain covers the folded rows, not each call, and anything unflushed is lost on a crash. Measured: 19.5× fewer rows, 19.0× fewer segment bytes on a 20 000-fetch mirror workload |
| `Off` | nothing | **all tamper-evidence.** `Critical` refuses to boot on it |

`audit sent-to` — the per-principal and per-IP tally — is **exact in both recording
modes**: the fold keeps every column it groups on. Only `scanned` shrinks, and a
`--since/--until` window cuts at fold boundaries instead of at individual calls.

With `mode: Off`, `audit query` and `audit sent-to` **refuse** (`holger-audit-8`) rather
than printing an empty result. An empty tally reads as "nobody fetched anything"; the
truth would be "nothing was recorded", and those are different answers.

`enabled: false` is the legacy spelling of `mode: Off` and still means it; it wins over
any `mode` key, so an upgrade cannot switch auditing back on for a deployment that turned
it off.

The UI is **not** audited separately — the server audits the call, whoever made it
(console, or a plain `cargo`/`pip`/`docker`).

Replay tolerates a corrupt/truncated segment (logged and skipped).

## D.5 — Connector allow-lists (agent side)

The agent connectors are SSRF-guarded: the **Nexus** connector refuses to attach
credentials to a `downloadUrl` that isn't same-origin with the configured base URL; the
**world/pip** connector only downloads from an **https host allow-list**. Keep the
connector base URL correct so legitimate downloads aren't rejected as cross-origin.

## D.6 — Inside an OT zone: keep the server read-only

For a server inside the secure (OT) zone, **do not open an internal write path** — keep
writes on the agent side and ingest immutable `.znippy` archives across the boundary. The
gates above exist but are opt-in/off by default; making them mandatory (default-deny) plus a
production air-gapped PKI are prerequisites before any client may write into the OT-side
server. See `auth-rbac-design.md`.

## D.7 — Authenticated READS (opt-in, `require_auth_for_reads`)

Holger's read path — download, list, archive browse, search, every HTTP `GET` — is
**open by default**. That is on purpose: holger is usually a mirror on the secure side
of an airgap, where the network is the perimeter and unmodified `pip`/`cargo`/`docker`
must be able to pull with no credential. Only writes and admin actions authenticate.

When the perimeter is NOT the network — an internal registry on a routed corporate LAN,
or a repo set whose *catalog* alone is sensitive — close the read path with one flag:

```ron
auth: Some((
    methods: [ Oidc(issuer_url: "https://idp.corp/realms/holger") ],
    require_auth_for_reads: true,           // ← reads now need a valid identity
    default_role: Some(Reader),             // any authenticated identity may pull
    roles: { "release-bot": Writer },
    repo_roles: {
        "customer-secrets": { "contractor@corp": Denied },   // …except this one
    },
)),
```

What changes:

| | gate OFF (default) | gate ON |
|---|---|---|
| anonymous read | served | **401** |
| authenticated read, any role ≥ `Reader` | served | served |
| identity whose repo role is `Denied` | served | **403** |
| audit line for a read | `anonymous` | the real subject (OIDC `sub` / token principal / mTLS CN) |
| `/healthz`, `/readyz`, `/v2/`, gRPC `Health` | open | **still open** (probes must not break) |
| cost when off | — | zero: no header or certificate is parsed on the read path |

`Denied` is a role like the others, and the least-privileged one
(`Denied < Reader < Writer < Admin`). Its only job is to say "not this identity, not
this repo" — put it in `repo_roles` to carve a private repository out of an otherwise
readable server.

**It is fail-closed.** Gate on + an identity that cannot be resolved ⇒ refused. Gate on
with **no** `methods` configured ⇒ nothing can authenticate ⇒ **every read is refused**;
holger logs a loud warning at boot saying exactly that. Configure an auth method, or
leave the gate off.

**Nothing changes for existing deployments.** The flag defaults to `false` and a RON
config that never mentions it behaves exactly as it did. Turning RBAC on for writes does
*not* imply gated reads — you have to ask.