Operate securely — guide
Class:
guide· Function:auth. Architecture:auth-design.md. Holger's gates are wired but opt-in: out of the box a server with noauthblock 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
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).
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.
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:
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:
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.