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

nordisk/holger

.nornir/auth-rbac-design.md

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:

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:

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:

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:

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

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
    },
)),

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