# 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.