Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/holger

.nornir/auth-guide.md

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

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.