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

nordisk/holger

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()]
        );
    }
}