server/lib/src/auth.rs
//! Authentication and authorization for holger-server.
//!
//! The general auth POLICY / composition layer this module used to own — the
//! RBAC role model ([`Role`], [`AuthConfig`] + its `role_for` / `role_for_repo` /
//! `rbac_enabled` / `authorize_read` / the `warn_*` boot checks), the declarative
//! [`AuthMethodConfig`] (OIDC / OAuth2 / mTLS / static-token), [`AuthIdentity`] /
//! [`AuthError`], and [`validate_request`] (which builds the enabled, feature-gated
//! `nornir_service::AuthProvider` set from the config and dispatches a bearer +
//! client-cert CN through it) — was promoted verbatim into the shared
//! **`nornir-auth-policy`** edda leaf (suite auth-unify) so korp / dwarves / nornir
//! adopt the SAME login + authorization model. holger is now the reference
//! CONSUMER, not the owner.
//!
//! This module re-exports that public API UNCHANGED, so every holger call site
//! (`grpc.rs`, `exposed/http.rs`) keeps using `crate::auth::{…}` exactly as before
//! — the promotion is zero-behavior-change. The provider feature set is still
//! pinned per tier by holger's own `auth-{bearer,oidc,oauth2,mtls}` features, which
//! forward 1:1 into the leaf.
//!
//! What holger still owns, and what lives BELOW this line, are the boot-time
//! posture checks that are holger's *own* policy rather than the shared model's:
//! the leaf ships `warn_unenforced_mtls_ca`, `warn_unpinned_providers` and
//! `warn_unauthenticatable_read_gate` (all of them WARNINGS, because a shared leaf
//! cannot decide how strict its consumers are); holger adds the ones it is willing
//! to REFUSE on. They are called from the same place — [`crate::Holger::start`].
pub use nornir_auth_policy::{AuthConfig, AuthError, AuthIdentity, AuthMethodConfig, Duty, Role};
/// **Validate a request's credential — and, for a passkey session, ask the
/// store whether it is still worth anything.**
///
/// The shared leaf's [`nornir_auth_policy::validate_request`], unchanged, then
/// one holger step on top (the identity core's session, `identity_host`): a `passkey` identity is
/// re-decided against the passkey store — refused when that person logged out,
/// was revoked or holds no enabled credential any more, and given the groups
/// they hold NOW. Every door calls this one function (`crate::auth::validate_
/// request`), so a revocation needs no work at any call site.
///
/// A revoked session is `Err(Unauthorized)`, the answer a forged one gets: it is
/// a credential that does not authenticate, and saying more would tell its
/// holder which.
pub async fn validate_request(
config: &AuthConfig,
bearer_token: Option<&str>,
client_cert_cn: Option<&str>,
) -> Result<Option<AuthIdentity>, AuthError> {
// ★ A repository token is for the repository door only ([`validate_request_for`], which
// knows the repository and whether the request writes). Every other door — admin, the
// passkey door, the door that mints tokens — refuses it as it refuses a forged credential.
#[cfg(feature = "auth-passkey")]
if bearer_token.is_some_and(crate::repo_token::is_token) {
return Err(AuthError::Unauthorized);
}
// ★ A session of the identity core (IDENTITY-HOST): the person it is NOW, with the groups
// they hold, or refused like a forged credential when it ended, expired or was revoked.
#[cfg(feature = "auth-passkey")]
if let Some(token) = bearer_token.filter(|b| crate::identity_host::is_session_token(b)) {
return crate::identity_host::session_identity(config, token)
.map(Some)
.ok_or(AuthError::Unauthorized);
}
let identity =
nornir_auth_policy::validate_request(config, bearer_token, client_cert_cn).await?;
// A passkey identity is only ever the core's session (above): a token of the old MAC'd
// shape is no credential here any more.
#[cfg(feature = "auth-passkey")]
if identity.as_ref().is_some_and(|id| id.method == "passkey") {
return Err(AuthError::Unauthorized);
}
Ok(identity)
}
/// Why the repository door refused a credential.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum DoorRefusal {
/// The plain 401 a forged credential gets.
Unauthorized,
/// A repository token, refused by its own code (`holger-token-<n>`).
#[cfg(feature = "auth-passkey")]
Token(crate::repo_token::Refused),
}
/// **[`validate_request`] for the repository door**, which knows the repository `repo` and
/// whether the request `write`s — the one door that also takes a repository token
/// (`crate::repo_token`). A token resolves to its OWNER as they stand now, so the door's own
/// gates then decide exactly as they would for the owner; the token only narrows that to its
/// repositories and its scope. Any other credential is [`validate_request`], unchanged.
pub async fn validate_request_for(
config: &AuthConfig,
bearer_token: Option<&str>,
client_cert_cn: Option<&str>,
repo: &str,
write: bool,
) -> Result<Option<AuthIdentity>, DoorRefusal> {
#[cfg(feature = "auth-passkey")]
if let Some(secret) = bearer_token.filter(|b| crate::repo_token::is_token(b)) {
return match crate::repo_token::resolve(config, secret, repo, write) {
Ok(Some(owner)) => Ok(Some(owner)),
Ok(None) => Err(DoorRefusal::Unauthorized),
Err(why) => Err(DoorRefusal::Token(why)),
};
}
let _ = (repo, write);
validate_request(config, bearer_token, client_cert_cn)
.await
.map_err(|_| DoorRefusal::Unauthorized)
}
// ─── The estate's duty table (lane W7) ───────────────────────────────────────
/// The group an Importer invite puts a person in (`group_duties`: `[Importer]`).
pub const ESTATE_IMPORTERS: &str = "holger-importers";
/// The groups a box with passkey sign-in gets when its RON names no Admin group (lane
/// HOL-FIELD-TEST P3), the estate declaration's own (holger-recipe `estate_ron::group_duties`).
pub const PASSKEY_GROUPS: [(&str, Role); 3] = [
("holger-admins", Role::Admin),
("holger-writers", Role::Writer),
("holger-readers", Role::Reader),
];
/// **The people's groups on a box with passkey sign-in** (lane HOL-FIELD-TEST P3, Gunnar's
/// first-login path): a `Passkey` method and no group with the role Admin left the box with no
/// way to ever have an admin (`holger-server-9`). The box then takes [`PASSKEY_GROUPS`], so its
/// first sign-in link enrols the admin and an admin's invites have their groups; where the RON
/// grants duties, the admins' group holds RepositoryAdmin, SecurityApprover and Auditor, never
/// Importer. A RON that names an Admin group is the operator's word and is left as it is.
/// Answers the line to say, or `None` when nothing was changed.
pub fn passkey_groups(auth: &mut AuthConfig) -> Option<String> {
let passkey = auth
.methods
.iter()
.any(|m| matches!(m, AuthMethodConfig::Passkey { .. }));
if !passkey || auth.group_roles.values().any(|r| *r == Role::Admin) {
return None;
}
let duties = auth.duties_enabled();
for (group, role) in PASSKEY_GROUPS {
auth.group_roles.entry(group.to_string()).or_insert(role);
if duties {
let d: Vec<Duty> = if role == Role::Admin {
vec![Duty::RepositoryAdmin, Duty::SecurityApprover, Duty::Auditor]
} else {
role.implied_duties().into_iter().collect()
};
auth.group_duties.entry(group.to_string()).or_insert(d);
}
}
Some(format!(
"passkey sign-in: the RON names no Admin group, so the people's groups are {}",
PASSKEY_GROUPS
.iter()
.map(|(g, r)| format!("{g} ({r:?})"))
.collect::<Vec<_>>()
.join(", ")
))
}
/// The group a Security Approver invite puts a person in (`group_duties`: `[SecurityApprover]`).
pub const ESTATE_APPROVERS: &str = "holger-approvers";
/// The group an Auditor invite puts a person in (`group_duties`: `[Auditor]`).
pub const ESTATE_AUDITORS: &str = "holger-auditors";
/// The duties of an estate's administrators: the console's bearer and every admin passkey.
/// They configure and they judge; they never import, so an upload always comes from a
/// principal they invited and they may approve it.
pub const ADMIN_DUTIES: [Duty; 2] = [Duty::RepositoryAdmin, Duty::SecurityApprover];
/// Pure: **a duty by the word a console or an invite says** — `importer`, `security_approver`
/// (or `approver`), `repository_admin`, `auditor`, `developer`; any case, `-`, `_` or a space
/// between the words.
pub fn duty_from_word(word: &str) -> Option<Duty> {
let w: String = word
.chars()
.filter(|c| !matches!(c, '-' | '_' | ' '))
.collect::<String>()
.to_ascii_lowercase();
Some(match w.as_str() {
"developer" => Duty::Developer,
"importer" => Duty::Importer,
"securityapprover" | "approver" => Duty::SecurityApprover,
"repositoryadmin" => Duty::RepositoryAdmin,
"auditor" => Duty::Auditor,
_ => return None,
})
}
/// **The estate's separation of duties, when its RON grants none** (lane W7).
///
/// A box ships with one console login (the `console` bearer, Admin) and its passkey plane.
/// With no duty in the config, every Admin holds all five duties, and the intake chain's
/// rule that an importer never approves its own upload then leaves one console with nothing
/// it can ever approve. This gives the estate the table that works:
///
/// | who | duties |
/// |---|---|
/// | an `Admin` subject (the console's bearer) and an `Admin` group (the first admin's passkey) | [`ADMIN_DUTIES`]: RepositoryAdmin, SecurityApprover — never Importer |
/// | any other subject or group in `roles` / `group_roles` | its role's implied duties (a Writer imports, a Reader is a Developer) |
/// | [`ESTATE_IMPORTERS`], [`ESTATE_APPROVERS`], [`ESTATE_AUDITORS`] | Importer, SecurityApprover, Auditor |
///
/// An admin then invites the importers and the further approvers (an invite with
/// `"duty": "importer"` or `"duty": "security_approver"`, `crate::passkey::group_for_duty`).
/// A config that grants any duty itself is the operator's word and is left as it is.
/// Answers the lines to say; empty when nothing was changed.
pub fn estate_duties(auth: &mut AuthConfig) -> Vec<String> {
if auth.methods.is_empty() || auth.duties_enabled() {
return Vec::new();
}
let of = |role: Role| -> Vec<Duty> {
if role == Role::Admin {
ADMIN_DUTIES.to_vec()
} else {
role.implied_duties().into_iter().collect()
}
};
let mut said = Vec::new();
let mut subjects: Vec<(String, Role)> =
auth.roles.iter().map(|(s, r)| (s.clone(), *r)).collect();
subjects.sort();
for (subject, role) in subjects {
let d = of(role);
if !d.is_empty() {
said.push(format!(
"{subject} ({role:?}): {}",
d.iter().map(|d| d.name()).collect::<Vec<_>>().join(", ")
));
auth.duties.insert(subject, d);
}
}
let mut groups: Vec<(String, Role)> = auth
.group_roles
.iter()
.map(|(g, r)| (g.clone(), *r))
.collect();
groups.sort();
for (group, role) in groups {
let d = of(role);
if !d.is_empty() {
said.push(format!(
"group {group} ({role:?}): {}",
d.iter().map(|d| d.name()).collect::<Vec<_>>().join(", ")
));
auth.group_duties.insert(group, d);
}
}
for (group, duty) in [
(ESTATE_IMPORTERS, Duty::Importer),
(ESTATE_APPROVERS, Duty::SecurityApprover),
(ESTATE_AUDITORS, Duty::Auditor),
] {
if !auth.group_roles.contains_key(group) {
auth.group_duties
.entry(group.to_string())
.or_insert_with(|| vec![duty]);
said.push(format!("group {group}: {}", duty.name()));
}
}
said
}
// ─── Boot-time posture checks holger OWNS ────────────────────────────────────
/// **A declared `StaticToken` whose secret resolves EMPTY is REFUSED BY NAME.**
///
/// MEASURED 2026-09-21, lane H2, against the estate's own emitted config. The
/// shared ladder is `token` > `token_env` > `token_file` > `""`, and the leaf's
/// contract says plainly: *"A secret that resolves EMPTY disables the method
/// (fail-closed)."* Fail-closed is the right verdict and it was reached in
/// **SILENCE** — nothing in `holger start` said a word:
///
/// ```text
/// $ holger-server start --config deploy/generated/server/holger.ron
/// Starting Holger "…/holger.ron"
/// gRPC server listening on 0.0.0.0:50051 [h2c (cleartext)] (gRPC-web enabled)
/// Holger is running. Press Ctrl+C to stop.
/// ```
///
/// That config declares `StaticToken(subject: "console", token_file:
/// Some("/var/lib/holger/admin-token"))` and **nothing in the estate ever
/// creates that file** — `private-holger-ops` emits the PATH and leaves the
/// bytes to a pair of printed plan steps (`front.rs` steps 8 and 16). So a
/// freshly installed box comes up looking healthy, answers `/health` with
/// `status=ok`, and authenticates NOBODY: the console cannot log in, and the
/// only way to discover it is to try.
///
/// This is the green-looking dead appliance in its auth coat, and the same
/// answer applies: **a declaration that cannot be enforced is a refusal, not a
/// default.** An operator who does not want the method deletes it from
/// `methods` — that is one line, it is honest, and it is visible in the config
/// rather than in the absence of a file.
///
/// The refusal names the subject, the source that was declared, and WHY that
/// source came out empty, because "you declared no source at all", "your env
/// var is unset" and "your file is missing or blank" need three different
/// fixes.
///
/// **Why it is `cfg`-gated on `auth-bearer`**, exactly as [`refuse_unbound_oidc`]
/// is gated on `auth-oidc`: a tier that did not pin the provider dispatches the
/// method to nothing whatever its secret says, and `warn_unpinned_providers`
/// already names that. Refusing twice over one gap is noise.
pub fn refuse_disabled_static_token(cfg: &AuthConfig) -> anyhow::Result<()> {
if !cfg!(feature = "auth-bearer") {
return Ok(());
}
for method in &cfg.methods {
let AuthMethodConfig::StaticToken {
subject,
token,
token_env,
token_file,
} = method
else {
continue;
};
// The SHARED ladder, asked exactly as the bearer provider asks it at
// request time (`nornir-auth-bearer`'s `resolved`) — so what this refuses
// on is what that would have dispatched on, not a second reading of the
// same rule.
let file = token_file.as_ref().map(std::path::Path::new);
let (secret, _whence) = nornir_service::resolve_token_whence(
token.as_deref(),
token_env.as_deref().unwrap_or(""),
file,
);
if !secret.is_empty() {
continue;
}
anyhow::bail!(
"{}",
disabled_static_token_message(subject, token, token_env, token_file)
);
}
Ok(())
}
/// The refusal text for [`refuse_disabled_static_token`], separated so a test can
/// read it without standing a config up.
fn disabled_static_token_message(
subject: &str,
token: &Option<String>,
token_env: &Option<String>,
token_file: &Option<String>,
) -> String {
// Name the gap precisely. Three different absences, three different fixes.
let why = match (token, token_env, token_file) {
(None, None, None) => "the method declares NO source at all — no `token`, no `token_env`, \
no `token_file`"
.to_string(),
_ => {
let mut parts: Vec<String> = Vec::new();
if token.is_some() {
parts.push("the inline `token` is empty".to_string());
}
if let Some(var) = token_env {
parts.push(format!(
"`token_env: \"{var}\"` is unset or empty in this process's environment"
));
}
if let Some(path) = token_file {
let p = std::path::Path::new(path);
let state = if !p.exists() {
"does not exist"
} else if p.is_dir() {
"is a directory"
} else {
"is empty or whitespace only"
};
parts.push(format!("`token_file: \"{path}\"` {state}"));
}
parts.join("; ")
}
};
format!(
"auth method StaticToken(subject: \"{subject}\") is DECLARED but its secret resolves \
EMPTY, which disables the method fail-closed — it would authenticate NOBODY while this \
server reported itself healthy. {why}. The shared ladder is `token` > `token_env` > \
`token_file`; place the secret (32 random bytes, 0600, owned by the server's user) at \
the source the config names, or DELETE this method from `methods` if it is not wanted. \
REFUSING TO START."
)
}
/// Whether this build actually pinned the provider a configured auth method needs
/// — the same table [`AuthConfig::warn_unpinned_providers`] consults to decide
/// whether to shout, exposed here as a value so a *validator* can use it too.
///
/// The leaf's `warn_*` helper only logs; `validate_profile` has to be able to ASK,
/// because "an OIDC method is configured" and "this binary can dispatch an OIDC
/// method" are different facts and the Critical profile only cares about the second
/// (see the static-bearer check in [`crate::Holger::validate_profile`]).
///
/// The `cfg!` arms are deliberately a verbatim copy of the leaf's match rather than
/// a call into it: holger's `auth-{bearer,oidc,oauth2,mtls}` features forward 1:1
/// into `nornir-auth-policy` (see `server/lib/Cargo.toml`), so the two tables are
/// the same truth evaluated in two crates. If the leaf ever grows a method arm, the
/// non-exhaustive match here stops compiling and this has to be updated with it.
pub fn provider_pinned(method: &AuthMethodConfig) -> bool {
match method {
AuthMethodConfig::Oidc { .. } => cfg!(feature = "auth-oidc"),
AuthMethodConfig::OAuth2 { .. } => cfg!(feature = "auth-oauth2"),
AuthMethodConfig::StaticToken { .. } => cfg!(feature = "auth-bearer"),
AuthMethodConfig::Mtls { .. } => cfg!(feature = "auth-mtls"),
AuthMethodConfig::Passkey { .. } => cfg!(feature = "auth-passkey"),
}
}
/// The `Critical` profile's "a shared secret must not be the only way in" rule.
///
/// **What changed and why.** The rule used to read "not every method is a
/// `StaticToken`", which counts what the *file* says. Provider linkage is per tier
/// though — the shipped `holger-server` is the `mini` build (bearer + mTLS, no OIDC
/// linked at all), and `validate_request` drops a method whose provider this binary
/// did not pin. So a config reading
///
/// ```text
/// methods: [ StaticToken(subject: "ci", token_env: "HOLGER_TOKEN"), Oidc(...) ]
/// ```
///
/// passed the Critical gate on a binary where the `Oidc` arm authenticates nobody:
/// the node was certified as multi-mechanism while being exactly one long-lived
/// shared secret away from total compromise. A profile whose entire job is to
/// refuse what it cannot enforce must not accept a mechanism it cannot reach.
///
/// `pinned` is injected rather than read from `cfg!` inside so the rule can be
/// tested for both answers from one build — the dev/default feature set pins ALL
/// four providers, so there is otherwise no way to exercise the refusing branch.
/// Production passes [`provider_pinned`].
pub fn require_dispatchable_alternative_to_static_bearer(
methods: &[AuthMethodConfig],
pinned: impl Fn(&AuthMethodConfig) -> bool,
) -> Result<(), String> {
fn kind(m: &AuthMethodConfig) -> &'static str {
match m {
AuthMethodConfig::Oidc { .. } => "oidc",
AuthMethodConfig::OAuth2 { .. } => "oauth2",
AuthMethodConfig::Mtls { .. } => "mtls",
AuthMethodConfig::StaticToken { .. } => "bearer",
AuthMethodConfig::Passkey { .. } => "passkey",
}
}
let alternatives: Vec<&AuthMethodConfig> = methods
.iter()
.filter(|m| !matches!(m, AuthMethodConfig::StaticToken { .. }))
.collect();
if alternatives.iter().any(|m| pinned(m)) {
return Ok(());
}
// Name the gap precisely: "you configured nothing else" and "you configured
// something this binary cannot link" need different fixes.
let unpinned: Vec<&str> = alternatives
.iter()
.filter(|m| !pinned(m))
.map(|m| kind(m))
.collect();
let tail = if unpinned.is_empty() {
"add OIDC or mTLS".to_string()
} else {
format!(
"the config declares {declared}, but this build did not pin the matching provider, \
so that method authenticates NOBODY (fail-closed) and the shared secret is the only \
way in — build a tier that links {features}, or add mTLS, which every tier pins",
declared = unpinned.join(" + "),
features = unpinned
.iter()
.map(|k| format!("`auth-{k}`"))
.collect::<Vec<_>>()
.join(" / "),
)
};
Err(format!(
"critical profile: static bearer must not be the only auth mechanism this build can \
dispatch ({tail})"
))
}
/// Does this method have PRE-PROVISIONED key material (inline `jwks`, or a
/// `jwks_path` readable now), so it verifies with no network at all?
///
/// Pure (one `is_file` stat, no network). Since nornir-auth-oidc 0.2 (A34-1) a
/// JWT is verified with the relying party's checks or refused by name, so key
/// material decides only WHETHER tokens can be verified, never whether a bad
/// one slips through to `/userinfo` (there is no such path any more).
pub fn oidc_key_material_resolvable(jwks: Option<&str>, jwks_path: Option<&str>) -> bool {
matches!(
oidc_key_source(jwks, jwks_path, None, false),
OidcKeySource::Bound
)
}
/// Where this method's verification keys come from, and so whether it can
/// verify a token at all.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum OidcKeySource {
/// Inline `jwks` or a readable `jwks_path`: verifies with no network.
Bound,
/// Only an https `jwks_uri`, in a build that has an HTTP client. Verifies
/// against the fetched key set; when the fetch yields nothing, the token is
/// REFUSED (nornir-auth-oidc 0.2, A34-1), never sent to `/userinfo`.
UriOnly,
/// A `jwks_uri` that is not https: the provider refuses to fetch keys over
/// cleartext (A34-28), so the method authenticates nobody.
UriNotHttps,
/// Nothing usable: no inline `jwks`, no readable `jwks_path`, and no
/// `jwks_uri` this build can fetch. The method authenticates nobody.
Unbound,
}
/// Classify one `Oidc` method's key material. `online` is a parameter rather than
/// a `cfg!` so both tiers are testable from one build; in an airgap tier the fetch
/// branch is compiled out entirely, so a `jwks_uri` there is decoration and this
/// reports [`OidcKeySource::Unbound`].
pub fn oidc_key_source(
jwks: Option<&str>,
jwks_path: Option<&str>,
jwks_uri: Option<&str>,
online: bool,
) -> OidcKeySource {
fn present(o: Option<&str>) -> Option<&str> {
o.map(str::trim).filter(|s| !s.is_empty())
}
if present(jwks).is_some() {
return OidcKeySource::Bound;
}
if let Some(p) = present(jwks_path) {
if std::path::Path::new(p).is_file() {
return OidcKeySource::Bound;
}
}
if online {
if let Some(uri) = present(jwks_uri) {
return if uri.to_ascii_lowercase().starts_with("https://") {
OidcKeySource::UriOnly
} else {
OidcKeySource::UriNotHttps
};
}
}
OidcKeySource::Unbound
}
/// Every configured `Oidc` method that can verify NO token, with the reason.
/// Pure; `online` is passed in rather than read from `cfg!` so the policy is
/// testable in both shapes from one build. [`OidcKeySource::UriOnly`] is not
/// listed: an https key endpoint verifies, and fails closed.
pub fn unbound_oidc_issuers(
methods: &[AuthMethodConfig],
online: bool,
) -> Vec<(String, OidcKeySource)> {
methods
.iter()
.filter_map(|m| match m {
AuthMethodConfig::Oidc {
issuer_url,
jwks,
jwks_path,
jwks_uri,
..
} => match oidc_key_source(
jwks.as_deref(),
jwks_path.as_deref(),
jwks_uri.as_deref(),
online,
) {
OidcKeySource::Bound | OidcKeySource::UriOnly => None,
other => Some((issuer_url.clone(), other)),
},
_ => None,
})
.collect()
}
/// Every configured `Oidc` method with no `audience`: the provider refuses
/// every JWT for it (a token not bound to holger may be any app's), A34-4.
pub fn oidc_issuers_without_audience(methods: &[AuthMethodConfig]) -> Vec<String> {
methods
.iter()
.filter_map(|m| match m {
AuthMethodConfig::Oidc {
issuer_url,
audience,
..
} if audience
.as_deref()
.map(str::trim)
.unwrap_or_default()
.is_empty() =>
{
Some(issuer_url.clone())
}
_ => None,
})
.collect()
}
/// The text for one issuer whose method can verify no token. Long on purpose:
/// this is the only place an operator learns why a config is refused, and the
/// answer must be a one-line edit they can make from the message alone.
fn unbound_oidc_message(
issuer: &str,
source: OidcKeySource,
online: bool,
verdict: &str,
) -> String {
if source == OidcKeySource::UriNotHttps {
return format!(
"auth: the Oidc method for issuer '{issuer}' names a `jwks_uri` that is not https. \
The provider refuses to fetch keys (or send anything) over cleartext, so this \
method would verify NO token and authenticate nobody. {verdict} To fix it, give \
the issuer's https key endpoint, or pre-provision the key set as inline `jwks` or \
a readable `jwks_path`."
);
}
let fix = if online {
"set `jwks_uri` to the issuer's real https key endpoint — Entra ID: \
`<issuer>/discovery/v2.0/keys`, Keycloak: `<issuer>/protocol/openid-connect/certs`, \
Auth0: `<issuer>/.well-known/jwks.json`, Okta: `<issuer>/v1/keys` — or pre-provision \
the key set with inline `jwks` / a readable `jwks_path`"
} else {
"this build has no outbound HTTP (`online` is off), so the key set must be \
pre-provisioned: inline `jwks`, or a readable `jwks_path`. A `jwks_uri` alone \
cannot be fetched here"
};
format!(
"auth: the Oidc method for issuer '{issuer}' configures NO usable JWKS — no inline \
`jwks`, no readable `jwks_path`, no `jwks_uri`. A JWT is verified against the \
issuer's keys (signature, `iss`, `aud`, `exp`) or refused; it is never sent to \
`/userinfo` (nornir-auth-oidc 0.2, A34-1), and holger takes keys only from a source \
its config names. So this method would verify NO token and authenticate nobody. \
{verdict} To fix it, {fix}. (`auth_allow_unbound_oidc` \
no longer changes this: the `/userinfo` path it allowed is gone.)"
)
}
/// Refuse to serve an `Oidc` auth method that can verify no token, or that
/// binds no token to holger (no `audience`). The boot gate over
/// [`unbound_oidc_issuers`] and [`oidc_issuers_without_audience`]; called from
/// [`crate::Holger::start`] beside the leaf's `warn_*` checks.
///
/// **A refusal, never a warning** (A34-1, 2026-10-26). Before nornir-auth-oidc
/// 0.2 a method without verifiable keys fell through to `/userinfo`, which
/// checks neither issuer nor audience, and the `auth_allow_unbound_oidc` hatch
/// let an operator keep that. The provider now refuses every JWT it cannot
/// verify, so such a method authenticates nobody: booting it would be a node
/// that looks configured and lets no one in. The hatch is still parsed (old
/// configs keep loading) but changes nothing, and says so.
///
/// An https `jwks_uri` alone is fine: it verifies against the fetched set and
/// fails closed when the fetch yields nothing — holger's marketplace shape.
///
/// **Why it is `cfg`-gated on `auth-oidc`.** A tier that did not pin the provider
/// (the shipped `holger-server` is `mini` — bearer + mTLS only) dispatches an
/// `Oidc` method to nothing at all: it authenticates no one, fail-closed, and
/// `warn_unpinned_providers` already says so.
pub fn refuse_unbound_oidc(cfg: &AuthConfig, allow_unbound: bool) -> anyhow::Result<()> {
if !cfg!(feature = "auth-oidc") {
return Ok(());
}
let online = cfg!(feature = "online");
if allow_unbound {
log::warn!(
"SECURITY: `auth_allow_unbound_oidc: true` has no effect any more: nornir-auth-oidc \
0.2 never sends a JWT to `/userinfo`, so there is nothing left for it to allow. \
Remove it from the config."
);
}
if let Some(issuer) = oidc_issuers_without_audience(&cfg.methods)
.into_iter()
.next()
{
anyhow::bail!(
"auth: the Oidc method for issuer '{issuer}' has no `audience`. A token that is not \
bound to holger may have been minted for any application of that directory, so \
the provider refuses every JWT for this method and it would authenticate nobody. \
REFUSING TO START. To fix it, set `audience: Some(\"<holger's client id or API \
audience at the IdP>\")`."
);
}
if let Some((issuer, source)) = unbound_oidc_issuers(&cfg.methods, online)
.into_iter()
.next()
{
anyhow::bail!(
"{}",
unbound_oidc_message(&issuer, source, online, "REFUSING TO START.")
);
}
Ok(())
}
/// **The READ gate, group-aware.**
///
/// The shared leaf ships the right ROLE RESOLVER — [`AuthConfig::role_for_identity_in_repo`],
/// which honours a per-repository ACL, then an explicit subject, then the highest
/// matched directory group, then `default_role`. What it does not ship is a READ
/// GATE that uses it: [`AuthConfig::authorize_read`] resolves through
/// `role_for_repo(repo, subject)` — a subject STRING — so it cannot see a
/// credential's groups.
///
/// That asymmetry is the whole reason this function exists, and it is why holger's
/// gates were only half fixed at first. Writes and admin were migrated to the
/// identity resolver while reads kept the subject-only one, which produces the
/// worst possible shape: a group granted `Reader` is refused every read, while the
/// same group's `Writer` grant works for uploads. An operator's policy half-applied,
/// with nothing anywhere saying so.
///
/// Same contract as the leaf's, deliberately: `Ok(())` when the read gate is off,
/// `401` when it is on and nobody is authenticated, `403` when the resolved role
/// cannot read. The status codes and messages are identical so the doors' responses
/// and audit records keep their shape. When the leaf grows a group-aware read gate,
/// delete this and call it.
pub fn authorize_read_identity(
cfg: &AuthConfig,
identity: Option<&AuthIdentity>,
repo: &str,
) -> std::result::Result<(), (u16, &'static str)> {
let gate = ReadGate::GLOBAL_ONLY;
authorize_read_identity_scoped(cfg, &gate, identity, repo)
}
/// **Which repositories demand a credential**, beyond the server-wide read gate.
///
/// One value carried beside the [`AuthConfig`] wherever a read is authorized
/// (`HolgerGrpc`, `GatewaySettings`), built at boot from the repositories that
/// declared [`ron_require_auth`](crate::repository::Repository::ron_require_auth).
///
/// It is a separate type rather than a field on `AuthConfig` for a boundary
/// reason, not a style one: `AuthConfig` is the SHARED `nornir-auth-policy` leaf
/// that korp / dwarves / nornir also consume, and it knows nothing about
/// repositories being configured objects with their own RON block. The scope
/// lives where the repositories do — in holger.
#[derive(Debug)]
pub struct ReadGate {
/// The repositories that require an authenticated read regardless of the
/// global gate. Sorted, so a diagnostic naming them is stable.
repos: std::collections::BTreeSet<String>,
/// The repositories that declared a ROLE floor (`ron_require_role`): the
/// least role that may read or write them.
floors: std::collections::BTreeMap<String, Role>,
/// **Gated after the boot** (lane P4): a private repository made at run time
/// (`holger.account.v1.CreateRepository`, a private sold one a twin takes on).
/// The gate is shared (`Arc<ReadGate>`) by every door, so one name added here
/// gates it on all of them. Only ever grows: a start learns the same names from
/// the config (`ron_require_auth`).
late: std::sync::RwLock<std::collections::BTreeSet<String>>,
/// **The repositories a paid order made, and whose they are** (lane P1): shared by every
/// door of this server and filled at run time, so a repository made after the boot is
/// private from the moment it serves. `None`: this gate keeps no such table.
sold: Option<SoldOwners>,
/// **The public repositories** (HCON-5, `visibility: Public`): read with no sign-in even
/// when reads are gated.
public: std::collections::BTreeSet<String>,
/// **The front page and the sign-up doors stay readable when reads are gated** (HCON-5,
/// `access.front: Open`, the default).
front_open: bool,
}
/// **A sold repository's owner**, as [`ReadGate`] keeps it: a subject (`passkey:…`), a group
/// as `@<group>` (the estate's owners, [`ESTATE_OWNERS`]), or empty (nobody bound yet: the
/// estate's admins alone).
///
/// One table behind one lock, cloned as a handle: the gRPC door, the HTTP door and the live
/// route table hold the SAME table, so a repository gated on one is gated on all.
#[derive(Clone, Debug, Default)]
pub struct SoldOwners(
std::sync::Arc<std::sync::RwLock<std::collections::BTreeMap<String, SoldEntry>>>,
);
/// One owner-only repository as the gate keeps it: its owner (a matcher list,
/// [`owner_names`]) and the people its owner granted, by subject (`true`: read and write;
/// `false`: read).
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct SoldEntry {
pub owner: String,
pub grants: std::collections::BTreeMap<String, bool>,
}
impl PartialEq for SoldOwners {
fn eq(&self, other: &Self) -> bool {
std::sync::Arc::ptr_eq(&self.0, &other.0) || self.snapshot() == other.snapshot()
}
}
impl Eq for SoldOwners {}
impl SoldOwners {
/// A table holding `rows` (repository → owner, no grants).
pub fn of(rows: impl IntoIterator<Item = (String, String)>) -> Self {
Self::of_entries(rows.into_iter().map(|(n, owner)| {
(
n,
SoldEntry {
owner,
grants: Default::default(),
},
)
}))
}
/// A table holding `rows` (repository → owner and grants).
pub fn of_entries(rows: impl IntoIterator<Item = (String, SoldEntry)>) -> Self {
SoldOwners(std::sync::Arc::new(std::sync::RwLock::new(
rows.into_iter().collect(),
)))
}
/// Every row, by name.
pub fn snapshot(&self) -> std::collections::BTreeMap<String, SoldEntry> {
crate::lock::RwLockOrRecover::read_or_recover(&*self.0).clone()
}
/// `repo`'s owner, when it is owner-only.
pub fn owner_of(&self, repo: &str) -> Option<String> {
crate::lock::RwLockOrRecover::read_or_recover(&*self.0)
.get(repo)
.map(|e| e.owner.clone())
}
/// `repo`'s row, when it is owner-only.
pub fn entry(&self, repo: &str) -> Option<SoldEntry> {
crate::lock::RwLockOrRecover::read_or_recover(&*self.0)
.get(repo)
.cloned()
}
/// `repo` is owner-only, owned by `owner` (its grants are kept).
pub fn insert(&self, repo: &str, owner: &str) {
crate::lock::RwLockOrRecover::write_or_recover(&*self.0)
.entry(repo.to_string())
.or_default()
.owner = owner.to_string();
}
/// `subject` may read (`Some(false)`), read and write (`Some(true)`) or no longer use
/// (`None`) the owner-only `repo`. `false`: `repo` is not owner-only here.
pub fn grant(&self, repo: &str, subject: &str, write: Option<bool>) -> bool {
let mut t = crate::lock::RwLockOrRecover::write_or_recover(&*self.0);
let Some(e) = t.get_mut(repo) else {
return false;
};
match write {
Some(w) => {
e.grants.insert(subject.to_string(), w);
}
None => {
e.grants.remove(subject);
}
}
true
}
}
/// **The group the shop's buyer is put in** by the first-login invite (lane P1): the estate's
/// owners. A repository a paid order made on that estate is theirs (`@holger-owners`).
pub const ESTATE_OWNERS: &str = "holger-owners";
/// Under the data root: the record that the shop's first-login invite was taken in, once
/// (`passkey::PasskeyDoor::seed_first_admin`). Its presence says the estate was sold to a
/// buyer, whose repositories are [`ESTATE_OWNERS`]'.
pub const FIRST_ADMIN_MARKER_REL: &str = "holger-passkeys/first-admin.json";
/// Pure: **does `owner` name `id`?** A `,`-separated list of subjects and `@<group>`s (one
/// of the identity's groups). An empty owner names nobody.
pub fn owner_names(owner: &str, id: &AuthIdentity) -> bool {
owner
.split(',')
.map(str::trim)
.filter(|o| !o.is_empty())
.any(|o| match o.strip_prefix('@') {
Some(group) => id.groups.iter().any(|g| g == group),
None => o == id.subject,
})
}
impl Default for ReadGate {
fn default() -> Self {
ReadGate::GLOBAL_ONLY
}
}
impl Clone for ReadGate {
fn clone(&self) -> Self {
Self {
repos: self.repos.clone(),
floors: self.floors.clone(),
late: std::sync::RwLock::new(self.late_names()),
sold: self.sold.clone(),
public: self.public.clone(),
front_open: self.front_open,
}
}
}
impl PartialEq for ReadGate {
fn eq(&self, other: &Self) -> bool {
self.repos == other.repos
&& self.floors == other.floors
&& self.late_names() == other.late_names()
&& self.sold == other.sold
&& self.public == other.public
&& self.front_open == other.front_open
}
}
impl Eq for ReadGate {}
impl ReadGate {
/// No per-repository scope at all: the global gate and nothing else. What
/// every deployment written before `ron_require_auth` existed has, and the
/// default for every constructor that does not opt in.
#[allow(clippy::declare_interior_mutable_const)] // a fresh, empty gate at every use
pub const GLOBAL_ONLY: ReadGate = ReadGate {
repos: std::collections::BTreeSet::new(),
floors: std::collections::BTreeMap::new(),
late: std::sync::RwLock::new(std::collections::BTreeSet::new()),
sold: None,
public: std::collections::BTreeSet::new(),
front_open: true,
};
/// Build from the repository names that declared `ron_require_auth`.
pub fn from_repos(names: impl IntoIterator<Item = String>) -> Self {
Self {
repos: names.into_iter().collect(),
..Self::default()
}
}
/// **Gate `repo` from now on** (lane P4): a private repository made after the
/// boot. Every door holding this gate demands a credential for it at once.
pub fn gate_from_now(&self, repo: &str) {
crate::lock::RwLockOrRecover::write_or_recover(&self.late).insert(repo.to_owned());
}
fn late_names(&self) -> std::collections::BTreeSet<String> {
crate::lock::RwLockOrRecover::read_or_recover(&self.late).clone()
}
/// The same gate with this box's `access` posture (HCON-5): the `public` repositories are
/// read with no sign-in even when reads are gated; `front_open` keeps the front page and the
/// sign-up doors readable.
pub fn with_access(
mut self,
public: impl IntoIterator<Item = String>,
front_open: bool,
) -> Self {
self.public = public.into_iter().collect();
self.front_open = front_open;
self
}
/// Is `repo` public (`visibility: Public`)?
pub fn is_public(&self, repo: &str) -> bool {
self.public.contains(repo)
}
/// The public repositories, in name order.
pub fn public_names(&self) -> Vec<String> {
self.public.iter().cloned().collect()
}
/// Does this box declare any public repository?
pub fn has_public(&self) -> bool {
!self.public.is_empty()
}
/// Are the front page and the sign-up doors readable when reads are gated?
pub fn front_open(&self) -> bool {
self.front_open
}
/// **Does the cross-repository catalog resolve its caller without requiring one?** With
/// per-repository gates, or public repositories on a gated box, the catalog lists each
/// repository to whoever may read it, an anonymous caller included.
pub fn catalog_resolves(&self) -> bool {
!self.is_global_only() || self.has_public()
}
/// The same gate keeping `sold` (lane P1): every repository in it is private, read and
/// written by its owner and the estate's admins only.
pub fn with_sold(mut self, sold: SoldOwners) -> Self {
self.sold = Some(sold);
self
}
/// **`repo` is owner-only from now on** (lane P1): owned by `owner` (a matcher list,
/// [`owner_names`]), gated on every door that holds this gate.
pub fn own(&self, repo: &str, owner: &str) {
if let Some(sold) = &self.sold {
sold.insert(repo, owner);
}
}
/// **`repo` is owner-only from now on, as `entry` says** (its owner and its grants).
pub fn own_entry(&self, repo: &str, entry: SoldEntry) {
if let Some(sold) = &self.sold {
crate::lock::RwLockOrRecover::write_or_recover(&*sold.0)
.insert(repo.to_string(), entry);
}
}
/// **The owner's grant on `repo`** (lane P1, [`SoldOwners::grant`]): `false` when this gate
/// keeps no such table or `repo` is not owner-only.
pub fn grant(&self, repo: &str, subject: &str, write: Option<bool>) -> bool {
self.sold
.as_ref()
.is_some_and(|s| s.grant(repo, subject, write))
}
/// The sold-repository table this gate keeps, if any.
pub fn sold(&self) -> Option<&SoldOwners> {
self.sold.as_ref()
}
/// `repo`'s owner, when a paid order made it (empty: nobody bound yet).
pub fn sold_owner(&self, repo: &str) -> Option<String> {
self.sold.as_ref()?.owner_of(repo)
}
/// **May `id`, at `role`, READ the owner-only repository `repo`?** Not an owner-only one:
/// yes. An owner-only one: its owner ([`owner_names`]), the people its owner granted, and
/// the estate's admins; nobody else.
pub fn owner_admits(&self, repo: &str, id: &AuthIdentity, role: Role) -> bool {
match self.sold.as_ref().and_then(|s| s.entry(repo)) {
None => true,
Some(e) => {
role.can_admin() || owner_names(&e.owner, id) || e.grants.contains_key(&id.subject)
}
}
}
/// **Does `id` WRITE the owner-only `repo` by its owner's word?** Its owner and the people
/// its owner granted read and write, whatever their role on the rest of the server
/// (lane P1). `false` for a repository that is not owner-only.
pub fn owner_writes(&self, repo: &str, id: &AuthIdentity) -> bool {
self.sold
.as_ref()
.and_then(|s| s.entry(repo))
.is_some_and(|e| owner_names(&e.owner, id) || e.grants.get(&id.subject) == Some(&true))
}
/// **May `id`, at `role`, WRITE `repo`?** Not an owner-only one: its role decides. An
/// owner-only one: the estate's admins, its owner and the people granted read and write;
/// an estate Writer with no grant is refused.
pub fn write_admits(&self, repo: &str, id: &AuthIdentity, role: Role) -> bool {
match self.sold.as_ref().and_then(|s| s.entry(repo)) {
None => role.can_write(),
Some(_) => role.can_admin() || self.owner_writes(repo, id),
}
}
/// The same gate with role floors (`ron_require_role`) — each floored
/// repository demands a credential too.
pub fn with_floors(mut self, floors: impl IntoIterator<Item = (String, Role)>) -> Self {
for (repo, role) in floors {
self.repos.insert(repo.clone());
self.floors.insert(repo, role);
}
self
}
/// The least role `repo` may be read or written with, if it declared one.
pub fn floor(&self, repo: &str) -> Option<Role> {
self.floors.get(repo).copied()
}
/// **A system repository**: one floored at [`Role::Admin`] (`ron_require_role:
/// Some(Admin)`), the estate's own — the sealed `monetize-backup` the front keeps the
/// installer material in. It is never a billable tenant: Gunnar's report leaves its
/// Machine-owned namespaces out of `tenants` the same way.
pub fn is_system(&self, repo: &str) -> bool {
self.floor(repo) == Some(Role::Admin)
}
/// Does `role` clear `repo`'s floor? (No floor: always.)
pub fn clears_floor(&self, repo: &str, role: Role) -> bool {
self.floor(repo).is_none_or(|f| role >= f)
}
/// Does `repo` demand a credential on its own account? A repository a paid order made
/// always does (lane P1): it never copies its template's open read.
pub fn repo_requires_auth(&self, repo: &str) -> bool {
self.repos.contains(repo)
|| crate::lock::RwLockOrRecover::read_or_recover(&self.late).contains(repo)
|| self.sold_owner(repo).is_some()
}
/// Every repository gated at the boot, in name order. For boot diagnostics.
pub fn gated_repos(&self) -> impl Iterator<Item = &str> {
self.repos.iter().map(|s| s.as_str())
}
/// `true` when no repository declared a per-repository requirement.
pub fn is_global_only(&self) -> bool {
self.repos.is_empty() && self.late_names().is_empty()
}
/// **Must a read of `repo` authenticate?** Either the server said so for
/// everything, or this repository said so for itself.
///
/// A repository made at run time (a paid order's, an admin's private one) is private on
/// EVERY server (lane P1, decision c1): one with no auth method serves it to nobody, never
/// openly. A security gateway has no silent opens.
pub fn applies(&self, cfg: &AuthConfig, repo: &str) -> bool {
// HCON-5: a public repository is read openly under the global gate; the catalog (`""`)
// of a box with public repositories resolves its caller and lists per repository.
(cfg.require_auth_for_reads
&& !self.public.contains(repo)
&& !(repo.is_empty() && self.has_public()))
|| self.repos.contains(repo)
|| crate::lock::RwLockOrRecover::read_or_recover(&self.late).contains(repo)
|| self.sold_owner(repo).is_some()
}
}
/// [`authorize_read_identity`] with the per-repository scope in hand.
///
/// The ONE predicate every read door consults once the scope exists, and the
/// reason it is one function: a door that inlines its own copy is a door that
/// can forget the scope, which is exactly how `StreamArtifact` once served
/// quarantined bytes.
///
/// * neither gate applies ⇒ `Ok(())`, the open-read posture, untouched.
/// * a gate applies and there is no identity ⇒ `401`.
/// * a gate applies and the repo-scoped role cannot read ⇒ `403`.
///
/// Note what the middle case means for a mirror: a repository the caller may not
/// read is REFUSED, loudly, per repository. It is not quietly omitted from a
/// listing and it is not skipped — a mirror that silently passed over a
/// repository it could not authenticate for would report a clean cycle and hold
/// nothing.
pub fn authorize_read_identity_scoped(
cfg: &AuthConfig,
gate: &ReadGate,
identity: Option<&AuthIdentity>,
repo: &str,
) -> std::result::Result<(), (u16, &'static str)> {
if !gate.applies(cfg, repo) {
return Ok(());
}
match identity {
None => Err((401, "[read-gated] authenticated identity required for read access (not a public repository)")),
Some(id) => {
let role = cfg.role_for_identity_in_repo(repo, id);
if !role.can_read() {
Err((403, "identity is denied read access to this repository"))
} else if !gate.clears_floor(repo, role) {
Err((403, "this repository is read only by the role its `ron_require_role` names"))
} else if !gate.owner_admits(repo, id, role) {
Err((403, "this repository is private: only its owner and the estate's admins read it"))
} else {
Ok(())
}
}
}
}
/// **Refuse a boot that declares a gated repository nothing can authenticate
/// for**, naming the repositories.
///
/// The global read gate only WARNS in this situation
/// ([`AuthConfig::warn_unauthenticatable_read_gate`]), and that is the shared
/// leaf's call to make — it cannot know how strict its consumers are. holger is
/// stricter here, and the asymmetry is deliberate: the global gate refusing
/// every read is at least uniform and immediately obvious, while ONE repository
/// that can never be reached on a server that serves everything else happily is
/// the kind of fault that is found months later by a customer whose mirror has
/// been empty the whole time.
pub fn require_authenticatable_gated_repos(
cfg: &AuthConfig,
gate: &ReadGate,
) -> std::result::Result<(), String> {
if gate.is_global_only() || !cfg.methods.is_empty() {
return Ok(());
}
let names: Vec<&str> = gate.gated_repos().collect();
Err(format!(
"repositories {} declare `ron_require_auth: true`, and this config has no `auth.methods` \
at all — nothing can authenticate, so every read of them would be refused 401 forever \
while the rest of the server serves open. REFUSING to start: declare an auth method \
(Mtls is the one every tier pins), or drop the key.",
names
.iter()
.map(|n| format!("'{n}'"))
.collect::<Vec<_>>()
.join(", ")
))
}
/// Warn when `client_auth_optional` was set on an endpoint with no `client_ca`.
///
/// Harmless — there is no client verification to make optional — but it reads as
/// a security decision and is not one, and an operator who wrote it probably
/// meant to configure mTLS and stopped one line short.
pub fn warn_pointless_client_auth_optional(endpoint: &str) {
log::warn!(
"auth: exposed endpoint '{endpoint}' sets `client_auth_optional: true` with no \
`client_ca` — there is no client-certificate verification to make optional, so the \
key does nothing. A repository with `ron_require_auth` on this endpoint can then only \
be satisfied by a bearer credential, never by a certificate."
);
}
/// Warn about `group_roles` entries set to `Denied`.
///
/// Such an entry reads as "this group may not access holger" and does nothing of
/// the kind: group roles are combined by MAXIMUM, so the entry is overridden by
/// any other matching group and, alone, only bites when `default_role` is higher.
/// The operator wrote a denial and did not get one. This is a warning rather than
/// a boot refusal because the entry is harmless in itself — the danger is the
/// belief that it is protecting something.
pub fn warn_group_roles_that_cannot_deny(cfg: &AuthConfig) {
let denied: Vec<&str> = cfg
.group_roles
.iter()
.filter(|(_, role)| **role == Role::Denied)
.map(|(g, _)| g.as_str())
.collect();
if denied.is_empty() {
return;
}
let mut names: Vec<&str> = denied;
names.sort();
log::warn!(
"SECURITY: group_roles sets {} to Denied, which DOES NOT DENY ANYTHING. \
Group roles combine by taking the highest, so this entry is overridden by \
any other group the identity is in, and on its own it only has an effect \
when default_role is higher than Denied. To actually refuse an identity, \
name it in `roles` (everywhere) or in `repo_roles` (one repository).",
names
.iter()
.map(|g| format!("'{g}'"))
.collect::<Vec<_>>()
.join(", ")
);
}
#[cfg(test)]
mod boot_posture_tests {
// ── a repository may demand a credential on its own account ──────────────
#[allow(clippy::borrow_interior_mutable_const)] // ReadGate::GLOBAL_ONLY read as the callers do
mod per_repository_read_gate_tests {
use super::super::*;
use std::collections::HashMap;
/// ★ **Lane P1: a repository a paid order made is private, its owner's.** It demands a
/// credential though the server reads open; its owner (a subject, or the estate's
/// owners' group) and the estate's admins read it; anyone else is refused 403 even
/// with a role that reads everything else; a repository nobody bought is untouched. The
/// table is shared: a row added after the gate was handed out gates at once.
#[test]
fn a_sold_repository_is_read_by_its_owner_and_the_admins_only() {
let cfg = AuthConfig {
methods: vec![AuthMethodConfig::StaticToken {
subject: "console".into(),
token: Some("c".repeat(40)),
token_env: None,
token_file: None,
}],
roles: HashMap::from([
("console".to_string(), Role::Admin),
("ivan".to_string(), Role::Writer),
]),
..Default::default()
};
let sold = SoldOwners::default();
let gate = ReadGate::from_repos(Vec::new()).with_sold(sold.clone());
let who = |s: &str, g: &[&str]| AuthIdentity {
subject: s.into(),
method: "passkey".into(),
groups: g.iter().map(|x| x.to_string()).collect(),
display: None,
};
assert!(
authorize_read_identity_scoped(&cfg, &gate, None, "acme-artifacts").is_ok(),
"not sold yet: the open read"
);
sold.insert("acme-artifacts", &format!("@{ESTATE_OWNERS}"));
sold.insert("bolt-libs", "passkey:bolt");
assert_eq!(
authorize_read_identity_scoped(&cfg, &gate, None, "acme-artifacts")
.unwrap_err()
.0,
401,
"private from the moment it is sold"
);
assert!(
authorize_read_identity_scoped(
&cfg,
&gate,
Some(&who("passkey:buyer", &[ESTATE_OWNERS])),
"acme-artifacts"
)
.is_ok(),
"the owners' group"
);
assert!(
authorize_read_identity_scoped(
&cfg,
&gate,
Some(&who("console", &[])),
"acme-artifacts"
)
.is_ok(),
"an admin"
);
assert_eq!(
authorize_read_identity_scoped(
&cfg,
&gate,
Some(&who("ivan", &[])),
"acme-artifacts"
)
.unwrap_err()
.0,
403,
"a writer of the estate is no owner"
);
assert!(
authorize_read_identity_scoped(
&cfg,
&gate,
Some(&who("passkey:bolt", &[])),
"bolt-libs"
)
.is_ok(),
"an owner by subject"
);
assert_eq!(
authorize_read_identity_scoped(
&cfg,
&gate,
Some(&who("passkey:bolt", &[])),
"acme-artifacts"
)
.unwrap_err()
.0,
403,
"another buyer's"
);
assert!(
authorize_read_identity_scoped(&cfg, &gate, None, "crates").is_ok(),
"the estate's own stay as declared"
);
assert!(
gate.repo_requires_auth("acme-artifacts") && !gate.repo_requires_auth("crates")
);
assert!(
gate.owner_admits("acme-artifacts", &who("console", &[]), Role::Admin)
&& !gate.owner_admits("acme-artifacts", &who("ivan", &[]), Role::Writer)
);
sold.insert("unbound", "");
assert!(
!gate.owner_admits("unbound", &who("ivan", &[ESTATE_OWNERS]), Role::Writer),
"an owner not bound yet is nobody's but the admins'"
);
assert!(owner_names("@g", &who("x", &["g"])) && !owner_names("", &who("", &[])));
let open = AuthConfig::default();
assert_eq!(
authorize_read_identity_scoped(&open, &gate, None, "acme-artifacts")
.unwrap_err()
.0,
401,
"decision c1: never served anonymously, on any server"
);
}
/// ★ **A role floor makes a repository an admin's alone** — the ledger
/// backup once every person holds a passkey. A reader that reads every
/// other repository is refused here; an admin is not; an unauthenticated
/// caller meets the gate the floor implies.
#[test]
fn a_role_floor_admits_only_the_role_it_names() {
let mut roles = HashMap::new();
roles.insert("console".to_string(), Role::Admin);
roles.insert("vera".to_string(), Role::Reader);
roles.insert("wendy".to_string(), Role::Writer);
let cfg = AuthConfig {
methods: vec![AuthMethodConfig::Mtls {
ca_cert: "/ca".into(),
}],
roles,
..Default::default()
};
let gate = ReadGate::from_repos(Vec::<String>::new())
.with_floors([("monetize-backup".to_string(), Role::Admin)]);
assert!(
gate.applies(&cfg, "monetize-backup"),
"a floor implies the gate"
);
assert!(!gate.applies(&cfg, "bundles"));
let id = |s: &str| AuthIdentity {
subject: s.to_string(),
..Default::default()
};
assert!(authorize_read_identity_scoped(
&cfg,
&gate,
Some(&id("console")),
"monetize-backup"
)
.is_ok());
assert_eq!(
authorize_read_identity_scoped(&cfg, &gate, Some(&id("vera")), "monetize-backup")
.unwrap_err()
.0,
403
);
assert_eq!(
authorize_read_identity_scoped(&cfg, &gate, Some(&id("wendy")), "monetize-backup")
.unwrap_err()
.0,
403
);
assert_eq!(
authorize_read_identity_scoped(&cfg, &gate, None, "monetize-backup")
.unwrap_err()
.0,
401
);
assert!(
authorize_read_identity_scoped(&cfg, &gate, Some(&id("vera")), "bundles").is_ok(),
"elsewhere untouched"
);
assert!(
gate.clears_floor("monetize-backup", Role::Admin)
&& !gate.clears_floor("monetize-backup", Role::Writer)
);
assert!(
gate.clears_floor("bundles", Role::Reader),
"no floor: always"
);
}
/// ★ An Admin-floored repository is a system repository (never a tenant); a
/// merely gated or a Writer-floored one is still a tenant.
#[test]
fn an_admin_floor_marks_a_system_repository() {
let gate = ReadGate::from_repos(["private".to_string()]).with_floors([
("monetize-backup".to_string(), Role::Admin),
("team".to_string(), Role::Writer),
]);
assert!(gate.is_system("monetize-backup"));
assert!(
!gate.is_system("team") && !gate.is_system("private") && !gate.is_system("crates")
);
}
fn identity(subject: &str) -> AuthIdentity {
AuthIdentity {
subject: subject.to_string(),
..Default::default()
}
}
fn mtls_cfg() -> AuthConfig {
AuthConfig {
methods: vec![AuthMethodConfig::Mtls {
ca_cert: "/etc/holger/ca.pem".into(),
}],
default_role: Some(Role::Reader),
..Default::default()
}
}
/// **The posture the owner asked for**: one repository demands a
/// certificate, the rest of the same server stays open.
///
/// This is what the global `require_auth_for_reads` could never say —
/// it is one bool for everything — and what `repo_roles` could never say
/// either, because a per-repo role only refines an ALREADY-authenticated
/// identity and never runs with the gate off.
#[test]
fn one_repository_demands_a_credential_while_the_rest_stays_open() {
let cfg = mtls_cfg(); // require_auth_for_reads stays FALSE
let gate = ReadGate::from_repos(["crates".to_string()]);
let who = identity("mirror-node");
// The gated repository: anonymous is refused, the certificate CN is served.
assert_eq!(
authorize_read_identity_scoped(&cfg, &gate, None, "crates"),
Err((401, "[read-gated] authenticated identity required for read access (not a public repository)")),
"a gated repository must refuse an anonymous read even with the global gate off"
);
assert!(authorize_read_identity_scoped(&cfg, &gate, Some(&who), "crates").is_ok());
// The open repository on the SAME server: served either way.
assert!(
authorize_read_identity_scoped(&cfg, &gate, None, "bundles").is_ok(),
"an ungated repository must stay open — that is the whole point of per-repository"
);
assert!(authorize_read_identity_scoped(&cfg, &gate, Some(&who), "bundles").is_ok());
}
/// The global gate still means EVERY repository, and the per-repository
/// scope is additive on top of it — never a way to carve an exemption.
#[test]
fn the_global_gate_still_covers_repositories_that_declared_nothing() {
let cfg = AuthConfig {
require_auth_for_reads: true,
..mtls_cfg()
};
let gate = ReadGate::from_repos(["crates".to_string()]);
assert!(
authorize_read_identity_scoped(&cfg, &gate, None, "bundles").is_err(),
"a per-repository scope must not exempt anything from the global gate"
);
assert!(authorize_read_identity_scoped(&cfg, &gate, None, "crates").is_err());
}
/// An empty scope is EXACTLY the prior behaviour, which is what every
/// deployment that has not opted in has.
#[test]
fn an_empty_scope_is_byte_for_byte_the_old_gate() {
let open = mtls_cfg();
let gated = AuthConfig {
require_auth_for_reads: true,
..mtls_cfg()
};
for repo in ["crates", "bundles", ""] {
assert_eq!(
authorize_read_identity_scoped(&open, &ReadGate::GLOBAL_ONLY, None, repo),
authorize_read_identity(&open, None, repo),
);
assert_eq!(
authorize_read_identity_scoped(&gated, &ReadGate::GLOBAL_ONLY, None, repo),
authorize_read_identity(&gated, None, repo),
);
}
assert!(ReadGate::GLOBAL_ONLY.is_global_only());
}
/// Authorization still layers on top: a valid credential is not a right.
/// `repo_roles` naming the subject `Denied` refuses it on that repository
/// and nowhere else.
#[test]
fn a_valid_credential_is_still_refused_by_a_per_repo_denied() {
let mut cfg = mtls_cfg();
cfg.repo_roles = HashMap::from([(
"crates".to_string(),
HashMap::from([("mirror-node".to_string(), Role::Denied)]),
)]);
let gate = ReadGate::from_repos(["crates".to_string()]);
let who = identity("mirror-node");
assert_eq!(
authorize_read_identity_scoped(&cfg, &gate, Some(&who), "crates"),
Err((403, "identity is denied read access to this repository")),
);
assert!(
authorize_read_identity_scoped(&cfg, &gate, Some(&who), "bundles").is_ok(),
"a per-repo Denied must not travel to another repository"
);
}
/// A gated repository on a server nothing can authenticate for is a
/// repository nobody can ever read, while the rest serves open. holger
/// REFUSES that boot, naming the repositories — where the shared leaf
/// only warns for the global gate.
#[test]
fn a_gated_repository_with_no_auth_method_refuses_the_boot_by_name() {
let cfg = AuthConfig::default(); // methods: []
let gate = ReadGate::from_repos(["crates".to_string(), "secret".to_string()]);
let e = require_authenticatable_gated_repos(&cfg, &gate)
.expect_err("nothing can authenticate — this must not start");
assert!(
e.contains("'crates'") && e.contains("'secret'"),
"names both: {e}"
);
assert!(e.contains("REFUSING"), "{e}");
// With a method configured, it is a legitimate posture.
require_authenticatable_gated_repos(&mtls_cfg(), &gate)
.expect("an mTLS method can authenticate a gated repository");
// And with no gated repository at all, the check never fires.
require_authenticatable_gated_repos(&cfg, &ReadGate::GLOBAL_ONLY)
.expect("no gated repositories ⇒ nothing to refuse");
}
}
// ── group_roles must actually reach a gate ───────────────────────────────
mod group_role_tests {
use super::*;
use std::collections::HashMap;
fn identity(subject: &str, groups: &[&str]) -> AuthIdentity {
AuthIdentity {
subject: subject.to_string(),
groups: groups.iter().map(|g| g.to_string()).collect(),
..Default::default()
}
}
fn config_with_groups() -> AuthConfig {
let mut group_roles = HashMap::new();
group_roles.insert("platform-team".to_string(), Role::Writer);
group_roles.insert("all-staff".to_string(), Role::Reader);
AuthConfig {
group_roles,
default_role: Some(Role::Reader),
..Default::default()
}
}
/// The finding, as a test. `group_roles` was parsed, counted towards
/// `rbac_enabled()` — switching RBAC on for the whole server — and then
/// consulted by no gate, because every gate resolved through
/// `role_for_repo(repo, subject)`, which takes a string and cannot see a
/// credential's groups. The operator's grant was silently dropped on a
/// server their config had just made strict.
#[test]
fn a_group_grant_reaches_the_repository_gate() {
let cfg = config_with_groups();
let dev = identity("alice", &["platform-team"]);
assert_eq!(
cfg.role_for_identity_in_repo("rust-prod", &dev),
Role::Writer
);
assert!(
cfg.role_for_identity_in_repo("rust-prod", &dev).can_write(),
"a directory group granted Writer must be able to write"
);
}
/// An identity in several groups gets the HIGHEST, never the first match —
/// `HashMap` has no order, so a first-match rule lets the hash seed pick a
/// privilege level.
#[test]
fn many_groups_resolve_to_the_highest_never_the_first() {
let cfg = config_with_groups();
let both = identity("bob", &["all-staff", "platform-team"]);
for _ in 0..64 {
assert_eq!(cfg.role_for_identity_in_repo("r", &both), Role::Writer);
}
}
/// A per-repository rule is more specific than any group, and it wins in
/// BOTH directions — a carve-out that denies, and one that promotes.
#[test]
fn a_per_repository_rule_outranks_a_group_in_both_directions() {
let mut cfg = config_with_groups();
let mut classified = HashMap::new();
classified.insert("alice".to_string(), Role::Denied);
cfg.repo_roles.insert("classified".to_string(), classified);
let mut sandbox = HashMap::new();
sandbox.insert("carol".to_string(), Role::Admin);
cfg.repo_roles.insert("sandbox".to_string(), sandbox);
let alice = identity("alice", &["platform-team"]);
assert_eq!(
cfg.role_for_identity_in_repo("classified", &alice),
Role::Denied,
"containing a named account must not require editing the directory"
);
assert_eq!(
cfg.role_for_identity_in_repo("rust-prod", &alice),
Role::Writer,
"…and the carve-out is scoped to that one repository"
);
let carol = identity("carol", &["all-staff"]);
assert_eq!(
cfg.role_for_identity_in_repo("sandbox", &carol),
Role::Admin
);
assert_eq!(
cfg.role_for_identity_in_repo("elsewhere", &carol),
Role::Reader
);
}
/// A global `roles[subject]` entry outranks a group too, so a `Denied`
/// holds on an account that is still in a privileged directory group.
#[test]
fn a_named_denial_holds_against_a_privileged_group() {
let mut cfg = config_with_groups();
cfg.default_role = Some(Role::Reader);
cfg.roles.insert("mallory".to_string(), Role::Denied);
let mallory = identity("mallory", &["platform-team"]);
assert_eq!(
cfg.role_for_identity_in_repo("anything", &mallory),
Role::Denied
);
assert!(!cfg
.role_for_identity_in_repo("anything", &mallory)
.can_read());
}
/// A deployment written before groups existed must be unchanged: with an
/// empty `group_roles` this resolver IS `role_for_repo`, asserted rather
/// than argued.
#[test]
fn with_no_groups_configured_this_is_exactly_the_old_resolver() {
let mut cfg = AuthConfig {
default_role: Some(Role::Reader),
..Default::default()
};
cfg.roles.insert("dave".to_string(), Role::Admin);
let mut prod = HashMap::new();
prod.insert("erin".to_string(), Role::Writer);
cfg.repo_roles.insert("prod".to_string(), prod);
for subject in ["dave", "erin", "nobody"] {
for repo in ["prod", "other"] {
let id = identity(subject, &["some-group-nobody-configured"]);
assert_eq!(
cfg.role_for_identity_in_repo(repo, &id),
cfg.role_for_repo(repo, subject),
"{subject} in {repo} must resolve identically with no group_roles"
);
}
}
}
/// Reads must resolve a role the same way writes do.
///
/// This is the half that was nearly missed. Migrating only the write and
/// admin gates would have produced the worst possible shape: a group
/// granted Reader is refused every read while the same group's Writer
/// grant works for uploads — an operator's policy half-applied, with no
/// error anywhere saying so.
#[test]
fn a_group_grant_reaches_the_read_gate_too_not_only_the_write_gate() {
let mut cfg = config_with_groups();
cfg.require_auth_for_reads = true;
// Denied by default, so the GROUP grant is the only thing that can let
// this identity read. With a permissive default the test would pass
// either way and prove nothing.
cfg.default_role = Some(Role::Denied);
let staff = identity("alice", &["all-staff"]); // group grants Reader
assert!(
authorize_read_identity(&cfg, Some(&staff), "rust-prod").is_ok(),
"a directory group granted Reader must be able to READ, not only write"
);
// The leaf's subject-only gate is what this replaces; assert the two
// genuinely disagree, so the test cannot pass by accident if the call
// is ever switched back.
assert!(
cfg.authorize_read(Some(&staff), "rust-prod").is_err(),
"…and the group-blind resolver refuses the same identity, which is the bug"
);
}
/// The refusals keep their exact contract: off means open, unauthenticated
/// means 401, and a Denied identity means 403. The doors and the audit log
/// depend on these codes, so the group-aware gate must not change them.
#[test]
fn the_read_gate_keeps_the_leafs_status_codes_exactly() {
let mut cfg = config_with_groups();
// Gate off: everything passes, authenticated or not.
assert!(authorize_read_identity(&cfg, None, "r").is_ok());
cfg.require_auth_for_reads = true;
assert_eq!(
authorize_read_identity(&cfg, None, "r").map_err(|(c, _)| c),
Err(401),
"gate on, nobody authenticated"
);
cfg.roles.insert("mallory".to_string(), Role::Denied);
assert_eq!(
authorize_read_identity(&cfg, Some(&identity("mallory", &["platform-team"])), "r")
.map_err(|(c, _)| c),
Err(403),
"a named Denied holds against a privileged group, on reads too"
);
}
/// A group set to `Denied` cannot deny — group roles combine by maximum —
/// so the boot warning is the only thing standing between an operator and
/// a rule they believe is protecting something.
#[test]
fn a_denied_group_does_not_deny_which_is_why_it_is_warned_about() {
let mut cfg = config_with_groups();
cfg.group_roles
.insert("contractors".to_string(), Role::Denied);
let contractor = identity("frank", &["contractors", "all-staff"]);
assert_eq!(
cfg.role_for_identity_in_repo("r", &contractor),
Role::Reader,
"the Denied group is overridden by any other matching group"
);
// The warning is what makes this discoverable; assert it identifies
// exactly the offending groups.
let offenders: Vec<&str> = cfg
.group_roles
.iter()
.filter(|(_, r)| **r == Role::Denied)
.map(|(g, _)| g.as_str())
.collect();
assert_eq!(offenders, vec!["contractors"]);
warn_group_roles_that_cannot_deny(&cfg); // must not panic
}
}
use super::*;
fn oidc(
jwks: Option<&str>,
jwks_path: Option<&str>,
jwks_uri: Option<&str>,
) -> AuthMethodConfig {
AuthMethodConfig::Oidc {
issuer_url: "https://idp.example/realms/holger".into(),
audience: Some("holger".into()),
jwks: jwks.map(str::to_string),
jwks_path: jwks_path.map(str::to_string),
jwks_uri: jwks_uri.map(str::to_string),
}
}
/// The shape EVERY shipped example uses: issuer + audience, no key material.
/// This is the finding — it must read as unbound.
#[test]
fn issuer_and_audience_alone_is_unbound() {
let m = vec![oidc(None, None, None)];
assert_eq!(
unbound_oidc_issuers(&m, true),
vec![(
"https://idp.example/realms/holger".to_string(),
OidcKeySource::Unbound
)],
"issuer+audience with no JWKS must be reported as unbound"
);
// …and offline too: with no HTTP client there is nothing to fetch either.
assert_eq!(unbound_oidc_issuers(&m, false).len(), 1);
}
#[test]
fn inline_jwks_binds() {
let m = vec![oidc(Some(r#"{"keys":[]}"#), None, None)];
assert!(unbound_oidc_issuers(&m, true).is_empty());
assert!(
unbound_oidc_issuers(&m, false).is_empty(),
"an inline key set is the airgap path — it must bind with no HTTP client"
);
}
/// **An https `jwks_uri` alone verifies** (A34-1): nornir-auth-oidc 0.2 counts
/// it as a key source and REFUSES a token when the fetch yields nothing, so
/// it is not reported. Offline the fetch branch is compiled out, so there it
/// is unbound; an http one is refused (A34-28).
#[test]
fn an_https_jwks_uri_alone_verifies_and_an_http_one_is_refused() {
let m = vec![oidc(None, None, Some("https://idp.example/keys"))];
assert!(
unbound_oidc_issuers(&m, true).is_empty(),
"an https key endpoint fails closed"
);
assert_eq!(
unbound_oidc_issuers(&m, false),
vec![(
"https://idp.example/realms/holger".to_string(),
OidcKeySource::Unbound
)]
);
let http = vec![oidc(None, None, Some("http://idp.example/keys"))];
assert_eq!(
unbound_oidc_issuers(&http, true),
vec![(
"https://idp.example/realms/holger".to_string(),
OidcKeySource::UriNotHttps
)]
);
}
/// A method with no audience is listed: it binds no token to holger.
#[test]
fn an_oidc_method_without_an_audience_is_listed() {
let mut m = oidc(Some(r#"{"keys":[]}"#), None, None);
if let AuthMethodConfig::Oidc { audience, .. } = &mut m {
*audience = None;
}
assert_eq!(
oidc_issuers_without_audience(&[m]),
vec!["https://idp.example/realms/holger".to_string()]
);
assert!(oidc_issuers_without_audience(&[oidc(None, None, None)]).is_empty());
}
/// A `jwks_uri` BESIDE pre-provisioned key material is fine, and is the shape
/// operators should end up in: the file makes verification authoritative, the
/// uri is how the key set is refreshed. The refusal message says exactly this,
/// so it must be true.
#[test]
fn a_jwks_uri_beside_a_readable_path_binds() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("jwks.json");
std::fs::write(&path, r#"{"keys":[]}"#).unwrap();
std::fs::set_permissions(
&path,
<std::fs::Permissions as std::os::unix::fs::PermissionsExt>::from_mode(0o600),
)
.unwrap();
let m = vec![oidc(
None,
Some(path.to_str().unwrap()),
Some("https://idp.example/keys"),
)];
assert!(unbound_oidc_issuers(&m, true).is_empty());
assert!(unbound_oidc_issuers(&m, false).is_empty());
}
/// The two messages must not be the same text: an operator who set an http
/// `jwks_uri` is not helped by being told to set `jwks_uri`. Neither offers
/// the old `/userinfo` hatch as a way out.
#[test]
fn the_http_uri_refusal_does_not_repeat_the_unbound_advice() {
let unbound =
unbound_oidc_message("https://idp.example", OidcKeySource::Unbound, true, "V.");
let http = unbound_oidc_message(
"https://idp.example",
OidcKeySource::UriNotHttps,
true,
"V.",
);
assert_ne!(unbound, http);
assert!(
unbound.contains("set `jwks_uri`"),
"the unbound message should offer jwks_uri: {unbound}"
);
assert!(http.contains("https"), "{http}");
for m in [&unbound, &http] {
assert!(m.contains("V."), "both must carry the verdict");
assert!(
!m.contains("auth_allow_unbound_oidc: true"),
"no hatch is offered: {m}"
);
}
}
/// `jwks_path` counts only if the file is READABLE NOW — the provider treats
/// a missing path as "no keys" and guesses the URL anyway.
#[test]
fn jwks_path_binds_only_when_the_file_exists() {
let tmp = tempfile::tempdir().unwrap();
let missing = tmp.path().join("jwks.json");
let m = vec![oidc(None, Some(&missing.to_string_lossy()), None)];
assert_eq!(
unbound_oidc_issuers(&m, true).len(),
1,
"a jwks_path that does not exist yet is not key material"
);
std::fs::write(&missing, r#"{"keys":[]}"#).unwrap();
assert!(unbound_oidc_issuers(&m, true).is_empty());
}
/// Blank strings are not key material (the provider trims and ignores them).
#[test]
fn whitespace_is_not_key_material() {
assert!(!oidc_key_material_resolvable(Some(" "), Some("")));
assert_eq!(
oidc_key_source(Some(" "), Some(""), Some(" \n"), true),
OidcKeySource::Unbound,
"a blank jwks_uri is not a uri either"
);
}
/// Non-OIDC methods are none of this check's business.
#[test]
fn other_methods_are_never_unbound() {
let m = vec![
AuthMethodConfig::Mtls {
ca_cert: "ca.pem".into(),
},
AuthMethodConfig::StaticToken {
subject: "ci".into(),
token: Some("s".into()),
token_env: None,
token_file: None,
},
];
assert!(unbound_oidc_issuers(&m, true).is_empty());
}
/// The refusal has to carry the fix, not just the verdict.
#[test]
fn the_refusal_names_the_issuer_the_hazard_and_the_fix() {
let cfg = AuthConfig {
methods: vec![oidc(None, None, None)],
..Default::default()
};
let err = refuse_unbound_oidc(&cfg, false).expect_err("an unbound Oidc method must refuse");
let msg = format!("{err:#}");
assert!(
msg.contains("https://idp.example/realms/holger"),
"got: {msg}"
);
assert!(msg.contains("userinfo"), "the hazard must be named: {msg}");
assert!(msg.contains("aud"), "the lost check must be named: {msg}");
assert!(
msg.contains("auth_allow_unbound_oidc"),
"the escape hatch must be named: {msg}"
);
assert!(
msg.contains("REFUSING TO START"),
"the verdict must be unambiguous: {msg}"
);
}
/// ★ A34-1: the hatch no longer downgrades the refusal: the `/userinfo`
/// path it allowed is gone, so an unbound method authenticates nobody.
#[test]
fn the_escape_hatch_no_longer_permits_boot() {
let cfg = AuthConfig {
methods: vec![oidc(None, None, None)],
..Default::default()
};
let err = refuse_unbound_oidc(&cfg, true)
.expect_err("`auth_allow_unbound_oidc` must not let an unbound method boot");
assert!(format!("{err:#}").contains("REFUSING TO START"), "{err:#}");
}
/// A correctly-pinned OIDC config boots untouched — and "correctly pinned" now
/// means PRE-PROVISIONED key material, not merely a `jwks_uri`. Both tiers,
/// because a pre-provisioned key set is exactly what makes the airgap tier work
/// as well.
#[test]
fn a_bound_config_boots() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("jwks.json");
std::fs::write(&path, r#"{"keys":[]}"#).unwrap();
std::fs::set_permissions(
&path,
<std::fs::Permissions as std::os::unix::fs::PermissionsExt>::from_mode(0o600),
)
.unwrap();
let cfg = AuthConfig {
methods: vec![oidc(
None,
Some(path.to_str().unwrap()),
Some("https://idp.example/v1/keys"),
)],
..Default::default()
};
refuse_unbound_oidc(&cfg, false)
.expect("a readable jwks_path binds the token, on every tier");
}
// ─── The Critical profile's static-bearer rule ───────────────────────────
fn bearer() -> AuthMethodConfig {
AuthMethodConfig::StaticToken {
subject: "ci".into(),
token: Some("secret".into()),
token_env: None,
token_file: None,
}
}
/// THE finding: a bearer plus a method whose provider this build did not pin
/// is a ONE-mechanism node, and `Critical` must say so. RED before the fix —
/// the old rule only asked whether every method was a `StaticToken`, which
/// this config answers "no" to while still having exactly one way in.
#[test]
fn an_unpinned_alternative_does_not_count_as_a_second_mechanism() {
let methods = vec![bearer(), oidc(None, None, None)];
let err = require_dispatchable_alternative_to_static_bearer(&methods, |_| false)
.expect_err("a method no provider serves is not an alternative");
assert!(
err.contains("static bearer must not be the only"),
"got: {err}"
);
assert!(
err.contains("oidc") && err.contains("`auth-oidc`"),
"the refusal must name the method AND the feature that would make it real: {err}"
);
}
/// The same config on a build that DID pin the provider is genuinely
/// two-mechanism and passes — the fix must not just refuse everything.
#[test]
fn a_pinned_alternative_counts() {
let methods = vec![bearer(), oidc(None, None, None)];
require_dispatchable_alternative_to_static_bearer(&methods, |_| true)
.expect("a dispatchable OIDC method IS a second mechanism");
}
/// Bearer alone still fails, with the message an operator who configured
/// nothing else needs (not the unpinned-provider one).
#[test]
fn bearer_alone_still_fails_with_the_plain_message() {
let err = require_dispatchable_alternative_to_static_bearer(&[bearer()], |_| true)
.expect_err("one static bearer is one mechanism");
assert!(err.contains("add OIDC or mTLS"), "got: {err}");
}
/// Mixed: one unpinned and one pinned alternative is still two mechanisms.
#[test]
fn one_dispatchable_alternative_is_enough() {
let methods = vec![
bearer(),
oidc(None, None, None),
AuthMethodConfig::Mtls {
ca_cert: "ca.pem".into(),
},
];
require_dispatchable_alternative_to_static_bearer(&methods, |m| {
matches!(m, AuthMethodConfig::Mtls { .. })
})
.expect("the pinned mTLS method carries the requirement on its own");
}
/// `provider_pinned` must agree with this build's feature set — the fact
/// `validate_profile` leans on for the Critical static-bearer check.
#[test]
fn provider_pinned_reads_this_builds_features() {
assert_eq!(
provider_pinned(&AuthMethodConfig::Mtls {
ca_cert: "ca.pem".into()
}),
cfg!(feature = "auth-mtls")
);
assert_eq!(
provider_pinned(&oidc(None, None, None)),
cfg!(feature = "auth-oidc")
);
}
}
// ─── The passkey relying party must be able to work in a browser ─────────────
/// Refuse to serve a `Passkey` auth method whose relying party a browser will
/// reject. The boot gate over [`AuthMethodConfig::Passkey`], called from
/// [`crate::Holger::validate_auth_posture`] beside [`refuse_unbound_oidc`].
///
/// **Why this is a boot refusal and not a runtime 400.** A WebAuthn credential is
/// scoped by the authenticator to the relying-party id, and browsers reject an IP
/// literal as an RP ID outright. An appliance configured with one therefore serves
/// a login page that renders, a ceremony that starts, and a registration that
/// fails in the browser with a message naming neither holger nor the cause — a
/// door that is open and leads nowhere. The same goes for an origin that does not
/// belong to the `rp_id`: `rp_id` is what the credential is scoped to and the
/// origin is what the assertion is checked against, so if the two may disagree,
/// the check that binds a credential to *this* server is checking something else.
/// Neither is a condition that improves on retry, so neither should be discovered
/// by an operator at a login prompt.
///
/// **Why it is NOT `cfg`-gated on `auth-passkey`, unlike [`refuse_unbound_oidc`].**
/// That gate is about a method whose provider this build cannot dispatch, which is
/// already fail-closed and already warned about — refusing to boot over it would
/// be noise. This one is about a method that CANNOT WORK IN ANY BUILD, on any
/// tier, ever. An operator who writes an IP-literal `rp_id` has made a mistake
/// worth naming whether or not this particular binary pinned the provider, and
/// the check is pure string work (it links no crypto — the validators live in the
/// `nornir_service` seam precisely so a backend-less build can still ask).
///
/// **What it deliberately does NOT check: whether the name resolves.** It cannot,
/// and it should not try. In an airgapped deployment the name resolves inside the
/// bubble — a DNS record on the appliance's own resolver, or an `/etc/hosts` entry
/// on each client — and a build host or a boot-time lookup has no view of that. A
/// resolution check here would refuse correct airgap configurations and pass
/// incorrect routable ones.
pub fn refuse_unusable_passkey_rp_id(cfg: &AuthConfig) -> anyhow::Result<()> {
for method in &cfg.methods {
let AuthMethodConfig::Passkey {
rp_id, rp_origin, ..
} = method
else {
continue;
};
if let Err(why) = nornir_service::passkey_rp::validate_rp_id(rp_id) {
anyhow::bail!(
"auth: the Passkey method's relying party cannot work in a browser — {why} \
Passkeys are ONE auth method among several: if this appliance genuinely has \
no name, keep using mTLS or a static token, which are what keep a renamed or \
relocated box reachable in any case."
);
}
if let Err(why) = nornir_service::passkey_rp::validate_origin_for_rp_id(rp_origin, rp_id) {
anyhow::bail!("auth: the Passkey method's relying party is inconsistent — {why}");
}
}
Ok(())
}
/// **The refusal that a fresh install needs** — a declared `StaticToken` whose
/// secret resolves empty.
///
/// Every test here is RED before `refuse_disabled_static_token` exists: today's
/// behaviour is `Ok(())` in silence, which is the defect.
#[cfg(all(test, feature = "auth-bearer"))]
mod disabled_static_token_tests {
use super::*;
fn bearer(
subject: &str,
token: Option<&str>,
env: Option<&str>,
file: Option<&str>,
) -> AuthMethodConfig {
AuthMethodConfig::StaticToken {
subject: subject.to_string(),
token: token.map(str::to_string),
token_env: env.map(str::to_string),
token_file: file.map(str::to_string),
}
}
fn cfg(methods: Vec<AuthMethodConfig>) -> AuthConfig {
AuthConfig {
methods,
..Default::default()
}
}
/// **The estate's own shape, measured 2026-09-21.** `deploy/generated/*/
/// holger.ron` declares `StaticToken(subject: "console", token_file:
/// Some("/var/lib/holger/admin-token"))` and nothing in the estate ever
/// writes that file. Before this check the server started, said nothing,
/// answered `/health` with `status=ok` and authenticated nobody.
#[test]
fn a_token_file_that_does_not_exist_refuses_by_name() {
let dir = tempfile::tempdir().unwrap();
let missing = dir.path().join("admin-token");
let err = refuse_disabled_static_token(&cfg(vec![bearer(
"console",
None,
None,
Some(missing.to_str().unwrap()),
)]))
.expect_err("a declared method that authenticates nobody must refuse");
let msg = format!("{err:#}");
assert!(msg.contains("console"), "the subject must be named: {msg}");
assert!(
msg.contains("does not exist"),
"the reason must be named: {msg}"
);
assert!(
msg.contains(missing.to_str().unwrap()),
"the path must be named: {msg}"
);
assert!(
msg.contains("REFUSING TO START"),
"the verdict must be unambiguous: {msg}"
);
}
/// An EXISTING but blank file is the same gap wearing a different coat — the
/// ladder trims and falls through — so it must read the same way.
#[test]
fn an_empty_token_file_reads_as_no_secret_at_all() {
let dir = tempfile::tempdir().unwrap();
let blank = dir.path().join("admin-token");
std::fs::write(&blank, " \n\t\n").unwrap();
let err = refuse_disabled_static_token(&cfg(vec![bearer(
"console",
None,
None,
Some(blank.to_str().unwrap()),
)]))
.expect_err("a blank token file authenticates nobody too");
let msg = format!("{err:#}");
assert!(msg.contains("is empty or whitespace only"), "got: {msg}");
}
/// The three absences need three different fixes, so they must not render
/// identically. A method declaring NO source is its own sentence.
#[test]
fn a_method_declaring_no_source_at_all_says_so_rather_than_naming_a_file() {
let err = refuse_disabled_static_token(&cfg(vec![bearer("console", None, None, None)]))
.expect_err("no source at all is still a method that authenticates nobody");
let msg = format!("{err:#}");
assert!(msg.contains("NO source at all"), "got: {msg}");
assert!(
!msg.contains("token_file: \""),
"no path may be invented: {msg}"
);
}
/// An unset `token_env` is named as an ENVIRONMENT gap — not as a missing
/// file, which is what an operator would go looking for.
#[test]
fn an_unset_token_env_names_the_variable() {
let err = refuse_disabled_static_token(&cfg(vec![bearer(
"ci",
None,
Some("HOLGER_H2_DEFINITELY_UNSET"),
None,
)]))
.expect_err("an unset env var resolves empty");
let msg = format!("{err:#}");
assert!(msg.contains("HOLGER_H2_DEFINITELY_UNSET"), "got: {msg}");
assert!(msg.contains("unset or empty"), "got: {msg}");
}
/// **Not vacuous.** A method whose secret RESOLVES is untouched, through
/// every rung of the ladder — otherwise this check would be a boot refusal
/// for every server that works.
#[test]
fn a_secret_that_resolves_boots_untouched_through_every_rung() {
// inline
refuse_disabled_static_token(&cfg(vec![bearer("ci", Some("s3cret"), None, None)]))
.expect("an inline token resolves");
// file
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("admin-token");
std::fs::write(&path, "s3cret\n").unwrap();
std::fs::set_permissions(
&path,
<std::fs::Permissions as std::os::unix::fs::PermissionsExt>::from_mode(0o600),
)
.unwrap();
refuse_disabled_static_token(&cfg(vec![bearer(
"console",
None,
None,
Some(path.to_str().unwrap()),
)]))
.expect("a populated token file resolves");
// env — set and removed inside the one test that reads it
// SAFETY: single-threaded within this test; the variable is this lane's own.
unsafe { std::env::set_var("HOLGER_H2_TEST_TOKEN", "s3cret") };
let verdict = refuse_disabled_static_token(&cfg(vec![bearer(
"ci",
None,
Some("HOLGER_H2_TEST_TOKEN"),
None,
)]));
unsafe { std::env::remove_var("HOLGER_H2_TEST_TOKEN") };
verdict.expect("a populated env var resolves");
}
/// A config with NO static token at all is not this check's business.
#[test]
fn a_config_without_a_static_token_is_untouched() {
refuse_disabled_static_token(&cfg(vec![AuthMethodConfig::Mtls {
ca_cert: "/etc/holger/ca.pem".into(),
}]))
.expect("mtls alone says nothing about a bearer");
refuse_disabled_static_token(&cfg(vec![])).expect("no methods, no opinion");
}
}
#[cfg(test)]
mod estate_duty_tests {
use super::*;
use std::collections::HashMap;
fn kit() -> AuthConfig {
AuthConfig {
methods: vec![AuthMethodConfig::StaticToken {
subject: "console".into(),
token: Some("t".into()),
token_env: None,
token_file: None,
}],
roles: HashMap::from([
("console".to_string(), Role::Admin),
("ci".to_string(), Role::Writer),
]),
group_roles: HashMap::from([
("holger-admins".to_string(), Role::Admin),
("holger-writers".to_string(), Role::Writer),
("holger-readers".to_string(), Role::Reader),
]),
..Default::default()
}
}
fn who(s: &str, g: &[&str]) -> AuthIdentity {
AuthIdentity {
subject: s.into(),
groups: g.iter().map(|g| g.to_string()).collect(),
..Default::default()
}
}
/// ★ **The console's admins approve and never import** (lane W7): the console's bearer and
/// an admin passkey hold RepositoryAdmin and SecurityApprover, an invited Importer imports,
/// an invited approver approves, and the coarse roles stay what they were.
#[test]
fn an_estate_with_no_duties_gets_the_table_where_the_admin_approves_and_never_imports() {
let mut a = kit();
let said = estate_duties(&mut a);
assert!(!said.is_empty());
let set = |d: &[Duty]| d.iter().copied().collect::<std::collections::BTreeSet<_>>();
assert_eq!(
a.duties_for_identity(&who("console", &[])),
set(&ADMIN_DUTIES)
);
assert_eq!(
a.duties_for_identity(&who("passkey:anna", &["holger-admins"])),
set(&ADMIN_DUTIES)
);
assert!(
!a.duties_for_identity(&who("console", &[]))
.contains(&Duty::Importer),
"the admin never imports"
);
assert_eq!(
a.duties_for_identity(&who("passkey:ivan", &[ESTATE_IMPORTERS])),
set(&[Duty::Importer])
);
assert_eq!(
a.duties_for_identity(&who("passkey:sara", &[ESTATE_APPROVERS])),
set(&[Duty::SecurityApprover])
);
assert_eq!(
a.duties_for_identity(&who("ci", &[])),
set(&[Duty::Developer, Duty::Importer]),
"a writer token imports as before"
);
assert_eq!(
a.duties_for_identity(&who("passkey:r", &["holger-readers"])),
set(&[Duty::Developer])
);
// The coarse ladder is unchanged, and the invited duties stay below admin.
assert_eq!(a.role_for_identity(&who("console", &[])), Role::Admin);
assert_eq!(
a.role_for_identity(&who("passkey:ivan", &[ESTATE_IMPORTERS])),
Role::Writer
);
assert_eq!(
a.role_for_identity(&who("passkey:sara", &[ESTATE_APPROVERS])),
Role::Reader
);
}
/// The operator's word wins: a config that grants any duty is left as it is, and an open
/// config (no methods) gets nothing.
#[test]
fn a_config_with_duties_or_without_auth_is_left_as_it_is() {
let mut a = kit();
a.duties.insert("console".into(), vec![Duty::Auditor]);
assert!(estate_duties(&mut a).is_empty());
assert_eq!(a.duties.len(), 1);
assert!(a.group_duties.is_empty());
let mut open = AuthConfig::default();
assert!(estate_duties(&mut open).is_empty() && !open.duties_enabled());
}
#[test]
fn duty_words_are_read_as_a_console_says_them() {
assert_eq!(
duty_from_word("Security Approver"),
Some(Duty::SecurityApprover)
);
assert_eq!(
duty_from_word("security_approver"),
Some(Duty::SecurityApprover)
);
assert_eq!(duty_from_word("approver"), Some(Duty::SecurityApprover));
assert_eq!(duty_from_word("IMPORTER"), Some(Duty::Importer));
assert_eq!(
duty_from_word("repository-admin"),
Some(Duty::RepositoryAdmin)
);
assert_eq!(duty_from_word("root"), None);
}
}
/// Pure (WWW-CANON, owner 2026-10-30: the site is `www.holger.rs`): **the origins a ceremony
/// of the relying party `rp_id` may come from**, the site's own first. A site named by its bare
/// name (`origin` is `https://<rp_id>`, `rp_id` a DNS name that is not itself `www.`) has
/// `https://www.<rp_id>` first and keeps `https://<rp_id>` (the passkeys made there keep their
/// RP ID, and the bare origin stays accepted during the change); any other origin (a port, a
/// development `http://localhost`) is its only one. A kept config needs no re-pair.
pub fn origins_of(rp_id: &str, origin: &str) -> Vec<String> {
let rp_id = rp_id.trim().trim_end_matches('.').to_ascii_lowercase();
let origin = origin.trim().trim_end_matches('/').to_owned();
let bare = format!("https://{rp_id}");
let named = rp_id.contains('.')
&& !rp_id.starts_with("www.")
&& rp_id.parse::<std::net::IpAddr>().is_err();
if named && origin.eq_ignore_ascii_case(&bare) {
vec![format!("https://www.{rp_id}"), bare]
} else {
vec![origin]
}
}
/// The site's origin for links (WWW-CANON): the first of [`origins_of`].
pub fn site_origin(rp_id: &str, origin: &str) -> String {
origins_of(rp_id, origin).swap_remove(0)
}
/// WWW-CANON: the site's origins are the auth config's own (no passkey plane needed): the account
/// and room links of a build without `auth-passkey` (holger-mvp `mvp-server,appliance,paired`,
/// dev_narrate_symbols) name them too.
#[cfg(test)]
mod site_origin_tests {
#[test]
fn the_sites_origin_is_www_in_every_build() {
assert_eq!(
super::site_origin("holger.rs", "https://holger.rs/"),
"https://www.holger.rs"
);
assert_eq!(
super::origins_of("holger.rs", "https://holger.rs"),
vec![
"https://www.holger.rs".to_string(),
"https://holger.rs".to_string()
]
);
assert_eq!(
super::origins_of("localhost", "http://localhost:8090"),
vec!["http://localhost:8090".to_string()]
);
}
}