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:
- git-tracked, code-reviewed, diffable — a policy change shows up in a PR.
- immutable in the static/drift flavor — the sealed binary's policy can't be silently mutated; it's exactly the bytes you reviewed.
- no IdP-claim-format coupling — airgap-friendly.
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:
- One shared pooled client (
OnceLock<reqwest::Client>, idle-pool + 10s timeout) — connection + TLS-session reuse instead of a handshake per write. - TTL token cache keyed by
(issuer, blake3(token))→(subject, instant). A repeat write with the same token returns from cache with zero network calls. TTL = 120s default (NORNIR_OIDC_USERINFO_CACHE_TTL_SECSoverride; the legacyHOLGER_OIDC_CACHE_TTL_SECSis honoured with a deprecation warning) — short enough that a revoked token stops working quickly. The raw token is never stored (only its blake3 digest), and the digest key can't collide into an auth bypass. Expired entries are purged opportunistically on insert (bounded map).
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
},
)),
AuthConfig::require_auth_for_reads: bool,#[serde(default)]⇒false. A config that does not mention it behaves byte-for-byte as before. Nothing breaks on upgrade; that is the point of making it opt-in rather than deriving it fromrbac_enabled()(turning RBAC on for writes must not silently close reads — asserted byread_gate_is_off_by_default_and_reads_stay_open).Role::Denied— a new variant, declared FIRST so the derivedOrdkeepsDenied < Reader < Writer < Admin. Nothing produces it implicitly and no existing RON names it, so it is purely additive. It is what makes the gate expressive instead of a boolean: a per-repoDeniedcarves one private repo out of an otherwise readable server.Role::can_read()is!Denied.AuthConfig::authorize_read(identity, repo)— the ONE predicate every read door consults (L5). Gate off ⇒Ok(()). Gate on: no identity ⇒ 401, a role that cannot read ⇒ 403. Repo-scoped, sorepo_rolesgoverns reads exactly as it governs writes.- Fail-closed, loudly. Gate on with no
methodsconfigured = nothing can authenticate = every read refused. That is fail-closed working as designed (asserted, so it can never be "fixed" into falling open), andwarn_unauthenticatable_read_gate()shouts at boot so it is not a mystery.
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
- W8 — audit log in redb (the RBAC companion).