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

nordisk/edda

crates/nornir-auth-policy/src/lib.rs

//! Authentication and authorization POLICY layer for the nordisk servers.
//!
//! This is the shared COMPOSITION tier above the per-method provider leaves
//! (`nornir-auth-{bearer,oidc,oauth2,mtls}`): it owns the RBAC role model, the
//! declarative auth config, and the `validate_request` dispatcher. holger is the
//! reference consumer; korp / dwarves / nornir adopt the same model suite-wide.
//!
//! Two separable concerns. **AuthN** (who are you): `validate_request` walks the
//! configured `AuthMethodConfig`s and dispatches a presented bearer credential to
//! the enabled, feature-gated MODERN auth PROVIDERS — each living in its own
//! sub-crate implementing the shared [`nornir_service::AuthProvider`] seam:
//!
//! * **OIDC / OAuth2** (`nornir-auth-oidc`, feature `auth-oidc`) — verify a
//!   bearer JWT against a pre-provisioned JWKS (airgap) or one fetched from
//!   `jwks_uri`; a JWT that does not verify is refused (A34-1). Covers Entra ID / Okta / Auth0 /
//!   Google / Keycloak / Zitadel through the one protocol.
//! * **Static Bearer token / API-key** (`nornir-auth-bearer`, feature
//!   `auth-bearer`) — constant-time compare against a secret resolved through the
//!   shared `nornir_service` ladder (inline / env / file / the future dwarves
//!   vault). The tiny provider the embedded/mini tier ships alone.
//! * **mTLS** (leaf-cert CN) — transport-level (`nornir-auth-mtls`, feature
//!   `auth-mtls`). A SHARED edda provider like the others: it implements the same
//!   [`nornir_service::AuthProvider`] seam through `verify_client_cert`, mapping a
//!   mutual-TLS leaf CN (already extracted by the TLS layer) to an identity. It
//!   links no crypto/HTTP — the handshake + CA trust stay in the transport — so
//!   every tier that terminates mTLS pins it cheaply.
//!
//! A pinned build links ONLY the providers its tier selected (build-thing pins
//! the feature set per variant): the embedded/mini tier is `auth-bearer` +
//! `auth-mtls` (both tiny, no jsonwebtoken/HTTP closure) while `full` adds
//! `auth-oidc`. The provider trait's object-safety leaves the layout open to
//! future `nornir-auth-saml` / `nornir-auth-webauthn` sibling crates without
//! touching this dispatch.
//!
//! `validate_request` yields an `AuthIdentity { subject, method }`, `Ok(None)`
//! when no method is configured (open access), or `Err` on invalid creds.
//!
//! **AuthZ** (what may you do): declarative RBAC policy from the RON config
//! (`roles` map + `default_role` + per-repo `repo_roles`) resolved by
//! `role_for`/`role_for_repo` into a `Role` (Reader < Writer < Admin, `Ord`
//! follows declaration order). This is the seam a write call site hits before a
//! write.
//!
//! RBAC is off unless any policy is set (`rbac_enabled`), preserving the
//! pre-RBAC authN-only behaviour; when on, an unmapped identity fails closed to
//! least-privilege `Reader`.
//!
//! The one gotcha (M12): the mTLS method's `ca_cert` is NOT path-validated here
//! — client-cert trust is delegated entirely to the transport `client_ca`
//! (rustls handshake); we only receive the extracted CN. `warn_unenforced_mtls_ca`
//! shouts about this at boot.
use std::collections::HashMap;

use serde::{Deserialize, Serialize};

/// A coarse authorization role. Ordered least → most privileged (the derived
/// `Ord` follows declaration order: `Reader < Writer < Admin`).
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
pub enum Role {
    /// **No access at all** — not even reads. Declared FIRST so the derived `Ord`
    /// keeps it least-privileged (`Denied < Reader < Writer < Admin`). Purely
    /// additive: nothing produces it implicitly and no existing RON names it, so a
    /// config written before this variant existed deserializes and behaves exactly
    /// as before. It exists so an operator can express "this identity may NOT read
    /// this repo" once the read gate is on — a per-repo `Denied` carves a private
    /// repo out of an otherwise readable server.
    Denied,
    /// Download + list only.
    Reader,
    /// + upload / create repos (everything `Reader` can, plus writes).
    Writer,
    /// + promote / delete / manage config.
    Admin,
}

impl Role {
    /// May this role READ (download/list/search) artifacts? Every role except
    /// [`Role::Denied`] can — `Reader` is the floor of the ladder, which is exactly
    /// what the (opt-in) read gate checks.
    pub fn can_read(self) -> bool {
        !matches!(self, Role::Denied)
    }
    /// May this role write (upload/put) artifacts?
    pub fn can_write(self) -> bool {
        matches!(self, Role::Writer | Role::Admin)
    }
    /// May this role perform admin actions (promote/delete/manage)?
    pub fn can_admin(self) -> bool {
        matches!(self, Role::Admin)
    }
}

/// **A separation-of-duties grant.** Where [`Role`] is one rung on a ladder
/// (an identity has exactly one), duties are a SET: one principal may be both an
/// importer and a security approver, and a server that enforces "the importer
/// never approves" must be able to see both. Duties are granted in the same
/// config, to the same identity (subject or group), as roles; they are not a
/// second auth system.
///
/// Every duty carries a [`floor`](Duty::floor) on the coarse ladder, so the
/// existing read / write / admin gates keep working for a principal that is
/// granted only duties: an `Importer` writes, a `RepositoryAdmin` administers,
/// the other three read.
///
/// Holger's intake chain is the first consumer (Developer reads AVAILABLE
/// artifacts, Importer ingests, SecurityApprover approves / quarantines /
/// releases / blocks, RepositoryAdmin configures, Auditor reads the audit and
/// the evidence); the vocabulary is shared so korp and dwarves can adopt it.
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
pub enum Duty {
    /// Reads what is published (Holger: AVAILABLE artifacts only).
    Developer,
    /// Brings artifacts in (Holger: UNTRUSTED → INGESTED).
    Importer,
    /// Judges artifacts (Holger: approve, quarantine, release, block).
    SecurityApprover,
    /// Configures the server and its repositories. Never approves.
    RepositoryAdmin,
    /// Reads the audit log and the evidence. Changes nothing.
    Auditor,
}

impl Duty {
    /// Every duty, in declaration order.
    pub const ALL: [Duty; 5] =
        [Duty::Developer, Duty::Importer, Duty::SecurityApprover, Duty::RepositoryAdmin, Duty::Auditor];

    /// The coarse [`Role`] this duty implies for the existing gates.
    pub fn floor(self) -> Role {
        match self {
            Duty::Developer | Duty::SecurityApprover | Duty::Auditor => Role::Reader,
            Duty::Importer => Role::Writer,
            Duty::RepositoryAdmin => Role::Admin,
        }
    }

    /// The name an operator writes and an audit line prints.
    pub fn name(self) -> &'static str {
        match self {
            Duty::Developer => "Developer",
            Duty::Importer => "Importer",
            Duty::SecurityApprover => "SecurityApprover",
            Duty::RepositoryAdmin => "RepositoryAdmin",
            Duty::Auditor => "Auditor",
        }
    }
}

impl Role {
    /// The duties a coarse role implies when a config grants **no** duties at
    /// all — the pre-duties behaviour, where an `Admin` could do everything:
    /// `Admin` ⇒ every duty, `Writer` ⇒ Importer + Developer, `Reader` ⇒
    /// Developer, `Denied` ⇒ none.
    pub fn implied_duties(self) -> std::collections::BTreeSet<Duty> {
        match self {
            Role::Denied => Default::default(),
            Role::Reader => [Duty::Developer].into_iter().collect(),
            Role::Writer => [Duty::Developer, Duty::Importer].into_iter().collect(),
            Role::Admin => Duty::ALL.into_iter().collect(),
        }
    }
}

/// Auth configuration for a nordisk server.
///
/// `methods` is authentication (who are you); `roles`/`default_role` are
/// authorization (what may you do). Authorization is **declarative policy**:
/// it lives here in the RON config (git-tracked, reviewable, immutable in the
/// static/drift flavor), never in a mutable store.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[derive(Default)]
// STRICT (Holger G0, 2026-10-23): a misspelled control (`require_auth_for_read`) is a
// gate that reads as ON and runs as OFF, so an unknown key is refused by name.
#[serde(deny_unknown_fields)]
pub struct AuthConfig {
    /// Which auth methods to accept. Empty = no auth (open access).
    pub methods: Vec<AuthMethodConfig>,

    /// Identity → role map. Identity is the OIDC `sub` claim, the static-token
    /// principal `subject`, or the mTLS client cert CN (the string
    /// `AuthIdentity::subject` carries). Absent/empty (with no `default_role`)
    /// means **RBAC is off** — the server is authentication-only and any valid
    /// identity may write, preserving the pre-RBAC behaviour.
    #[serde(default)]
    pub roles: HashMap<String, Role>,

    /// Role granted to an authenticated identity that has no explicit mapping.
    /// `None` + an empty `roles` map = RBAC off. When RBAC is on, an unmapped
    /// identity falls back to this (or `Reader` — least privilege — if unset).
    #[serde(default)]
    pub default_role: Option<Role>,

    /// **Per-repository** role overrides (ACL). Outer key = repository name; inner
    /// map = identity → role FOR THAT REPO. A repo-scoped mapping WINS over the
    /// global `roles`/`default_role`. Absent/empty ⇒ the global policy applies to
    /// every repo. Configuring any per-repo ACL turns RBAC on.
    #[serde(default)]
    pub repo_roles: HashMap<String, HashMap<String, Role>>,

    /// **GROUP → role.** The map that makes OIDC usable by an organisation
    /// rather than by a list of GUIDs.
    ///
    /// `roles` above keys on `AuthIdentity::subject`, which for every modern IdP
    /// — Entra ID, Okta, Auth0, Keycloak — is an opaque per-user GUID. Granting
    /// twenty investigators `Writer` through it means pasting twenty GUIDs into
    /// a server config and editing that config every time someone joins or
    /// leaves. The directory already knows who is an investigator, and the token
    /// already carries it: the `groups` (or `roles`) claim. This map reads it.
    ///
    /// **Resolution, most specific first** (see
    /// [`role_for_identity`](Self::role_for_identity)): a per-repo ACL, then an
    /// explicit `roles` entry for the subject, then the **highest** role among
    /// the identity's matched groups, then `default_role`, then `Reader`.
    ///
    /// Two properties of that order are deliberate and both are tested:
    ///
    /// * **The subject mapping BEATS the group**, so an account can be contained
    ///   at the server (`roles: {alice: Denied}`) without waiting for the
    ///   directory. If the group won, containment would be impossible here.
    /// * **The group resolution is a MAXIMUM, not a first match.** An operator
    ///   is in many groups and a `HashMap` has no order, so "whichever matched
    ///   first" would let a hash seed decide a privilege level.
    ///
    /// Absent/empty leaves every existing deployment byte-identical — asserted
    /// by comparing the two resolvers over every identity, not by inspection.
    /// Configuring any group mapping turns RBAC on.
    #[serde(default)]
    pub group_roles: HashMap<String, Role>,

    /// **Gate READS behind the RBAC `Reader` role. OFF by default.**
    ///
    /// A mirror server's reads (download / list / archive / search) have always
    /// been open: only writes and admin actions authenticate. That is the right
    /// default for a mirror on the secure side of an airgap, where the network
    /// boundary *is* the perimeter and every client inside it may pull. It was
    /// never a *decision* so much as an unfinished one: the `reader` role has
    /// existed in the docs and in [`Role`] since the beginning with nothing
    /// enforcing it.
    ///
    /// Setting this to `true` closes it: every read door demands a valid identity
    /// (Bearer / OIDC / mTLS CN) whose resolved role [`Role::can_read`]. It is
    /// **opt-in** precisely so no deployment changes behaviour on upgrade —
    /// absent from a RON config, `#[serde(default)]` leaves it `false` and the read
    /// path is byte-for-byte what it was.
    ///
    /// Fail-closed when on: no identity ⇒ 401, a `Denied` role ⇒ 403. Turning it on
    /// with **no** `methods` configured leaves nothing able to authenticate, so
    /// every read is refused — deliberate (that is what fail-closed means), and
    /// [`AuthConfig::warn_unauthenticatable_read_gate`] shouts about it at boot.
    ///
    /// Independent of [`rbac_enabled`](Self::rbac_enabled): the read gate is about
    /// *whether reads authenticate at all*, while the role map decides what an
    /// authenticated identity may do. With the gate on and no role policy, any
    /// authenticated identity reads (role resolves to the least-privilege
    /// `Reader`), which is the useful "authenticated mirror" configuration.
    #[serde(default)]
    pub require_auth_for_reads: bool,

    /// **Subject → duties** (separation of duties, see [`Duty`]). Keyed on
    /// [`AuthIdentity::subject`] exactly like `roles`. A principal may hold
    /// several. Configuring any duty turns RBAC on and switches
    /// [`duties_for_identity`](Self::duties_for_identity) from "derived from the
    /// role" to "exactly what is granted".
    #[serde(default)]
    pub duties: HashMap<String, Vec<Duty>>,

    /// **Group → duties**, the `group_roles` of duties: an OIDC `groups` /
    /// `roles` claim grants these. Unioned with the subject's own duties.
    #[serde(default)]
    pub group_duties: HashMap<String, Vec<Duty>>,
}

impl AuthConfig {
    /// RBAC is active iff any role policy is configured (a global mapping, a
    /// default role, or any per-repo ACL). When inactive the write path is
    /// authN-only.
    pub fn rbac_enabled(&self) -> bool {
        !self.roles.is_empty()
            || self.default_role.is_some()
            || !self.repo_roles.is_empty()
            || !self.group_roles.is_empty()
            || self.duties_enabled()
    }

    /// Does this config grant any duty? Off ⇒ duties are derived from the
    /// coarse role ([`Role::implied_duties`]), which is how a config written
    /// before duties existed keeps its meaning.
    pub fn duties_enabled(&self) -> bool {
        !self.duties.is_empty() || !self.group_duties.is_empty()
    }

    /// The highest [`Duty::floor`] among the duties granted to `subject` alone.
    fn subject_duty_floor(&self, subject: &str) -> Option<Role> {
        self.duties.get(subject)?.iter().map(|d| d.floor()).max()
    }

    /// **The duties of an identity.** With duties configured: the union of the
    /// subject's own grant and every matched group's grant — exactly what is
    /// written, nothing derived. Without: [`Role::implied_duties`] of the
    /// resolved role. Either way an explicit `roles[subject] = Denied` contains
    /// the account: it holds no duty, whatever its groups say.
    pub fn duties_for_identity(&self, id: &AuthIdentity) -> std::collections::BTreeSet<Duty> {
        if self.roles.get(&id.subject) == Some(&Role::Denied) {
            return Default::default();
        }
        if !self.duties_enabled() {
            return self.role_for_identity(id).implied_duties();
        }
        let mut out: std::collections::BTreeSet<Duty> =
            self.duties.get(&id.subject).into_iter().flatten().copied().collect();
        for g in &id.groups {
            out.extend(self.group_duties.get(g).into_iter().flatten().copied());
        }
        out
    }

    /// Resolve the role for an authenticated `subject`. An explicit mapping wins;
    /// otherwise the configured `default_role`; otherwise `Reader` (least
    /// privilege — fail closed).
    pub fn role_for(&self, subject: &str) -> Role {
        self.roles
            .get(subject)
            .copied()
            .or_else(|| self.subject_duty_floor(subject))
            .or(self.default_role)
            .unwrap_or(Role::Reader)
    }

    /// Resolve the role for `subject` **scoped to `repo`**. A per-repo explicit
    /// mapping (`repo_roles[repo][subject]`) wins; otherwise falls back to the
    /// global [`role_for`](Self::role_for).
    pub fn role_for_repo(&self, repo: &str, subject: &str) -> Role {
        if let Some(role) = self.repo_roles.get(repo).and_then(|m| m.get(subject)) {
            return *role;
        }
        self.role_for(subject)
    }

    /// **Resolve the role for a whole IDENTITY, not just its subject string.**
    ///
    /// This is the resolver a handler should call, because it is the only one
    /// that can see the credential's [`groups`](AuthIdentity::groups). Order,
    /// most specific first:
    ///
    /// 1. an explicit `roles[subject]` entry — including a `Denied` that
    ///    CONTAINS an account that is still in a privileged directory group;
    /// 2. the **highest** role among the identity's matched groups;
    /// 3. `default_role`;
    /// 4. `Reader` — least privilege, fail closed.
    ///
    /// With an empty `group_roles` this is [`role_for`](Self::role_for) exactly,
    /// which is what keeps every deployment written before groups existed
    /// unchanged — and it is asserted over every identity rather than argued.
    pub fn role_for_identity(&self, id: &AuthIdentity) -> Role {
        if let Some(role) = self.roles.get(&id.subject) {
            return *role;
        }
        // The MAXIMUM over matched groups, never the first match: an operator is
        // in many groups and `HashMap` has no order, so a first-match rule would
        // let the hash seed pick a privilege level.
        // Duties count with their floor, in the same maximum: a principal granted
        // `Importer` writes even when no role names it.
        let duty_floors = self
            .subject_duty_floor(&id.subject)
            .into_iter()
            .chain(id.groups.iter().filter_map(|g| {
                self.group_duties.get(g)?.iter().map(|d| d.floor()).max()
            }));
        if let Some(highest) = id
            .groups
            .iter()
            .filter_map(|g| self.group_roles.get(g))
            .copied()
            .chain(duty_floors)
            .max()
        {
            return highest;
        }
        self.default_role.unwrap_or(Role::Reader)
    }

    /// [`role_for_identity`](Self::role_for_identity) **scoped to `repo`**. A
    /// per-repo ACL is the most specific statement in the policy and wins over
    /// a group grant for the same reason an explicit subject mapping does.
    pub fn role_for_identity_in_repo(&self, repo: &str, id: &AuthIdentity) -> Role {
        if let Some(role) = self.repo_roles.get(repo).and_then(|m| m.get(&id.subject)) {
            return *role;
        }
        self.role_for_identity(id)
    }

    /// Is the (opt-in) READ gate on? See [`require_auth_for_reads`](Self::require_auth_for_reads).
    pub fn read_gate_enabled(&self) -> bool {
        self.require_auth_for_reads
    }

    /// The AuthZ half of the read gate, scoped to `repo`. The ONE predicate every
    /// read door consults — gRPC unary fetch, the chunked stream, listings,
    /// archive/search, and the HTTP door — so "may this identity read" means the
    /// same thing everywhere (L5; a door that inlines its own copy is a door that
    /// can forget it, which is exactly how `StreamArtifact` once served quarantined
    /// bytes).
    ///
    /// * gate off ⇒ `Ok(())`, always — the pre-existing open-read behaviour, byte
    ///   for byte.
    /// * gate on, no identity ⇒ `Err((401, …))` (fail closed).
    /// * gate on, identity whose repo-scoped role cannot read ⇒ `Err((403, …))`.
    ///
    /// `identity` is what [`validate_request`] returned. `None` means either "no
    /// credentials presented" or "no auth methods configured"; with the gate ON
    /// both are refusals — an operator who asks for authenticated reads and gives
    /// nothing to authenticate with gets no reads, not open reads.
    pub fn authorize_read(
        &self,
        identity: Option<&AuthIdentity>,
        repo: &str,
    ) -> Result<(), (u16, &'static str)> {
        if !self.require_auth_for_reads {
            return Ok(());
        }
        match identity {
            None => Err((401, "authenticated identity required for read access")),
            Some(id) => {
                if self.role_for_repo(repo, &id.subject).can_read() {
                    Ok(())
                } else {
                    Err((403, "identity is denied read access to this repository"))
                }
            }
        }
    }

    /// Shout at boot when the read gate is on but nothing can authenticate — the
    /// one configuration where the gate silently refuses *every* read. Fail-closed
    /// is correct, but it must not be a mystery.
    pub fn warn_unauthenticatable_read_gate(&self) {
        if self.require_auth_for_reads && self.methods.is_empty() {
            log::warn!(
                "auth: require_auth_for_reads is on but no auth `methods` are configured — \
                 nothing can authenticate, so EVERY read will be refused with 401. Configure \
                 an auth method (Bearer / Oidc / Mtls) or turn the read gate off."
            );
        }
    }

    /// Make the mTLS trust decision EXPLICIT (M12 / P18): the transport's
    /// `client_ca` (rustls handshake) is authoritative for client-cert trust;
    /// the per-method `ca_cert` is NOT independently path-validated here. Warn at
    /// startup so the operator knows it must equal the transport `client_ca`. The
    /// warning text now lives WITH the provider ([`nornir_auth_mtls::MtlsProvider::
    /// ca_trust_warning`]) — this only iterates the config and emits it.
    pub fn warn_unenforced_mtls_ca(&self) {
        #[cfg(feature = "auth-mtls")]
        for method in &self.methods {
            if let AuthMethodConfig::Mtls { ca_cert } = method {
                if let Some(msg) =
                    nornir_auth_mtls::MtlsProvider::new(ca_cert.clone()).ca_trust_warning()
                {
                    log::warn!("{msg}");
                }
            }
        }
    }

    /// Build a behavior-PRESERVING config from a single optional static bearer —
    /// the drop-in for the old `nornir_service::auth_interceptor(Arc<String>)`.
    ///
    /// * `None` or an empty/whitespace-only token ⇒ [`AuthConfig::default`] —
    ///   `methods` empty, so the [`PolicyAuthLayer`] passes every request through
    ///   (OPEN), byte-for-byte the pre-policy "empty token ⇒ open" behaviour.
    /// * a non-empty token ⇒ exactly one [`AuthMethodConfig::StaticToken`] with the
    ///   inline secret and the principal `"static"`, and NO RBAC (empty `roles` /
    ///   no `default_role`), so the layer requires a matching `Bearer <token>` and
    ///   any valid identity may act — identical to the old bearer interceptor.
    pub fn from_static_bearer(token: Option<String>) -> AuthConfig {
        match token.filter(|t| !t.trim().is_empty()) {
            None => AuthConfig::default(),
            Some(t) => AuthConfig {
                methods: vec![AuthMethodConfig::StaticToken {
                    subject: "static".into(),
                    token: Some(t),
                    token_env: None,
                    token_file: None,
                }],
                ..Default::default()
            },
        }
    }

    /// The suite-wide auth switch: load a declarative [`AuthConfig`] from the file
    /// named by the `config_path_env` environment variable when it points to a
    /// readable RON/TOML config (turning ON the rich holger-style login —
    /// OIDC / OAuth2 / mTLS + RBAC), otherwise fall back to the behavior-preserving
    /// [`from_static_bearer`](Self::from_static_bearer) built from `token`.
    ///
    /// Format is picked by extension: `.toml` ⇒ TOML, everything else (`.ron` or no
    /// extension) ⇒ RON (the constellation convention).
    ///
    /// FAIL-CLOSED on a broken EXPLICIT config: if the env var is SET to a non-empty
    /// path but the file can't be read/parsed, this returns [`deny_all`](Self::deny_all)
    /// — NOT the static/open fallback. An operator who configured auth and typo'd it
    /// must not silently fall OPEN; the server and its health probe stay up (deny-all
    /// is not a panic) but every real request is denied until the config is fixed and
    /// the server restarted. Only an UNSET env var takes the behavior-preserving
    /// static-bearer default.
    pub fn load_or_static(config_path_env: &str, token: Option<String>) -> AuthConfig {
        if let Some(path) = std::env::var_os(config_path_env) {
            let path = std::path::PathBuf::from(path);
            if !path.as_os_str().is_empty() {
                match Self::load_file(&path) {
                    Ok(cfg) => {
                        log::info!(
                            "auth: loaded declarative AuthConfig from {} (via ${config_path_env}) — \
                             {} method(s), RBAC {}",
                            path.display(),
                            cfg.methods.len(),
                            if cfg.rbac_enabled() { "on" } else { "off" },
                        );
                        return cfg;
                    }
                    // Explicitly configured but broken ⇒ FAIL CLOSED, never fall open.
                    Err(e) => {
                        log::error!(
                            "auth: ${config_path_env} points to {} but it could not be loaded \
                             ({e}) — FAILING CLOSED (deny-all): every request is denied until the \
                             config is fixed and the server restarted. Health/liveness stay exempt.",
                            path.display(),
                        );
                        return Self::deny_all();
                    }
                }
            }
        }
        Self::from_static_bearer(token)
    }

    /// A fail-CLOSED sentinel policy: `methods` is non-empty (so the layer does NOT
    /// treat it as open), but its lone method carries NO secret — `build_providers`
    /// drops the disabled provider and `validate_request` then never yields an
    /// identity, so EVERY request is denied `Unauthenticated`. The layer's health
    /// exemption still lets liveness through. Used by [`load_or_static`] when an
    /// explicitly-configured auth file fails to load: a broken auth config must fail
    /// closed, not fall open.
    pub fn deny_all() -> AuthConfig {
        AuthConfig {
            methods: vec![AuthMethodConfig::StaticToken {
                subject: "__auth-config-error__".into(),
                token: None,
                token_env: None,
                token_file: None,
            }],
            ..Default::default()
        }
    }

    /// Load an [`AuthConfig`] from a RON or TOML file, chosen by extension
    /// (`.toml` ⇒ TOML; else RON). Returns a human-readable error string on an
    /// unreadable or unparseable file.
    pub fn load_file(path: &std::path::Path) -> Result<AuthConfig, String> {
        let text = std::fs::read_to_string(path)
            .map_err(|e| format!("read {}: {e}", path.display()))?;
        let is_toml = path
            .extension()
            .and_then(|e| e.to_str())
            .is_some_and(|e| e.eq_ignore_ascii_case("toml"));
        if is_toml {
            toml::from_str(&text).map_err(|e| format!("parse TOML {}: {e}", path.display()))
        } else {
            ron::from_str(&text).map_err(|e| format!("parse RON {}: {e}", path.display()))
        }
    }

    /// Warn about any configured auth method whose PROVIDER was not compiled into
    /// this build (the tier's feature set didn't pin it). Such a method silently
    /// authenticates nothing — fail-closed — but the operator should know the
    /// config asks for a provider this binary can't serve. Called once at boot.
    pub fn warn_unpinned_providers(&self) {
        for method in &self.methods {
            let (kind, pinned) = match method {
                AuthMethodConfig::Oidc { .. } => ("oidc", cfg!(feature = "auth-oidc")),
                AuthMethodConfig::OAuth2 { .. } => ("oauth2", cfg!(feature = "auth-oauth2")),
                AuthMethodConfig::StaticToken { .. } => ("bearer", cfg!(feature = "auth-bearer")),
                AuthMethodConfig::Mtls { .. } => ("mtls", cfg!(feature = "auth-mtls")),
                AuthMethodConfig::Passkey { .. } => ("passkey", cfg!(feature = "auth-passkey")),
            };
            if !pinned {
                log::warn!(
                    "AUTH: config declares a `{kind}` method but this build did not pin the \
                     `auth-{kind}` provider — it will authenticate NOTHING (fail-closed). Build a \
                     tier that includes the provider, or remove the method from the config."
                );
            }
        }
    }
}

/// `#[serde(default)]` for a flag whose safe value is ON.
fn default_true() -> bool {
    true
}

#[derive(Debug, Clone, Serialize, Deserialize)]
// STRICT, as `AuthConfig` is: a typo inside a method is refused by name.
#[serde(deny_unknown_fields)]
pub enum AuthMethodConfig {
    /// Validate OIDC Bearer JWTs via the `nornir-auth-oidc` provider (feature
    /// `auth-oidc`): against a pre-provisioned JWKS (airgap) or one fetched from
    /// `jwks_uri` (`online`). A JWT that does not verify is refused; there is
    /// no `/userinfo` fall-through (A34-1). Opaque tokens go to `OAuth2`.
    Oidc {
        /// OIDC issuer URL (e.g. `https://keycloak.example/realms/holger`). Also
        /// the expected `iss` claim on the offline JWT path.
        issuer_url: String,
        /// Expected `aud` (audience) claim. REQUIRED in effect: `None` refuses
        /// every JWT, by name (A34-4).
        #[serde(default)]
        audience: Option<String>,
        /// Inline JWKS — the JSON `{"keys":[…]}` embedded in the RON config
        /// itself. The fully-airgapped pre-provisioning path.
        #[serde(default)]
        jwks: Option<String>,
        /// Filesystem path to a pre-provisioned JWKS JSON file.
        #[serde(default)]
        jwks_path: Option<String>,
        /// Where to fetch the JWKS when ONLINE. Unset ⇒ `{issuer_url}/jwks`.
        #[serde(default)]
        jwks_uri: Option<String>,
    },
    /// Validate an OAuth 2.0 / 2.1 access token via the `nornir-auth-oauth2`
    /// provider (feature `auth-oauth2`): RFC 7662 token INTROSPECTION against the
    /// IdP (opaque access tokens, beyond oidc's offline JWT-vs-JWKS). Enables Entra
    /// ID / Okta / Auth0 / Google / AWS IAM Identity Center as OAuth providers.
    /// Requires `online` (the introspection call is outbound HTTP).
    OAuth2 {
        /// RFC 7662 token-introspection endpoint.
        introspection_url: String,
        /// This resource server's OAuth2 client id (authenticates the call).
        client_id: String,
        /// This client's secret (confidential client). Empty for a public client.
        #[serde(default)]
        client_secret: String,
    },
    /// Validate client certificates (mTLS). Requires TLS with client auth.
    /// Transport-level — handled directly in `validate_request` from the
    /// extracted leaf CN, not through a bearer provider.
    Mtls {
        /// Trusted CA PEM for client certs.
        ca_cert: String,
    },
    /// Static Bearer token / API-key — the MODERN machine-to-machine path for CI
    /// runners, agents, and headless clients, served by the `nornir-auth-bearer`
    /// provider (feature `auth-bearer`). The presented `Bearer <token>` is
    /// compared CONSTANT-TIME against a secret resolved through the SHARED nordisk
    /// ladder: inline `token` > `token_env` > `token_file` > `""`. A secret that
    /// resolves EMPTY disables the method (fail-closed). On a match the request
    /// authenticates as `subject`, which maps to an RBAC role like any identity.
    /// **Passkeys (WebAuthn / FIDO2)** — the passwordless human login served by
    /// the `nornir-auth-webauthn` provider's PURE-RUST backend (feature
    /// `auth-passkey`). The fifth method, beside the four above and never instead
    /// of one of them.
    ///
    /// **Why it exists, and it is one word: airgap.** Every other human-login
    /// method here points at an IdP, and an IdP is another service that must live
    /// *inside* the sealed environment — raised, sealed, upgraded and kept running,
    /// or nobody logs in anywhere. A passkey needs nothing outside the box: the
    /// authenticator is local (a platform authenticator, a security key) and the
    /// ceremony is between the browser and the server standing in front of it.
    ///
    /// **A deployment that HAS an IdP should keep using [`Oidc`](Self::Oidc).**
    /// This is a method, selected by configuration, exactly like the others;
    /// deleting it from `methods` leaves every role map, group map, per-repo ACL
    /// and the read gate byte-identical. Nothing here only works if passkeys are
    /// the only method.
    ///
    /// ## How a two-round-trip ceremony lives in a one-shot seam
    ///
    /// It does not, and it does not need to. [`validate_request`] sees one
    /// stateless credential per request, which is why this method was declared and
    /// left undispatched for a year. The ceremony runs on the SERVER'S OWN DOOR
    /// (`/-/auth/passkey/{register,login}/{start,finish}` in holger) and, on
    /// success, mints a MAC'd session token. What this method dispatches is that
    /// token, through the ordinary bearer path — so the seam needed no new shape.
    ///
    /// ## The subject is opaque, and deliberately so
    ///
    /// A passkey identity's [`AuthIdentity::subject`] is an id minted once at
    /// enrolment (`passkey:<uuid>`), never the friendly name the operator chose.
    /// It has to be: `subject` is what every `roles` map ever written keys on, and
    /// a name somebody can change is a privilege somebody can move. The human name
    /// rides in [`AuthIdentity::display`], audit-only, and the enrolment record's
    /// groups ride in [`AuthIdentity::groups`] — so a passkey identity resolves
    /// through [`AuthConfig::role_for_identity`] exactly as an OIDC one does.
    Passkey {
        /// The relying-party id: the DNS name the BROWSER reaches this server by.
        ///
        /// **Configuration only — never derived from a request, and never from the
        /// `Host` header.** A credential is scoped by the authenticator to this
        /// value and the browser refuses to release it to any other, which makes
        /// it the phishing boundary. Reading it from a header would let anyone who
        /// can set that header — including a merely misconfigured reverse proxy —
        /// choose the boundary that is supposed to contain them.
        ///
        /// It must be a NAME. WebAuthn forbids an IP literal, so an appliance
        /// reached at `https://10.0.0.7` can never enrol a key; the consumer's boot
        /// gate refuses that config rather than shipping a login door that opens
        /// onto nothing.
        ///
        /// A credential is bound to this value **forever**: an appliance renamed
        /// from `holger.lab.internal` to `holger.prod.internal` has invalidated
        /// every key registered against it. That is what binding a credential to a
        /// name means, and the mitigation is not code — it is that passkeys are one
        /// method among several, so mTLS and a static token keep a renamed box
        /// reachable for re-enrolment. A server offering passkeys as its ONLY
        /// method has built a brick waiting for a rename.
        rp_id: String,
        /// The exact browser origin, scheme and port included (e.g.
        /// `https://holger.lab.internal`). Its host must be `rp_id` or a subdomain
        /// of it; if the two may disagree, the check that binds a credential to
        /// THIS server is checking something else.
        rp_origin: String,
        /// The human-facing name the authenticator shows the operator at the
        /// touch prompt. Empty falls back to `rp_id`.
        #[serde(default)]
        rp_name: String,
        /// File holding the 32-byte session MAC key (typically 0600), persisted so
        /// sessions outlive a restart.
        ///
        /// **Rotating this file is the revocation hammer**: every outstanding
        /// session token is MAC'd with it, so replacing it voids all of them at
        /// once. That is deliberately the ONE mechanism — a second epoch counter
        /// beside it would be a second thing to get wrong for the same effect.
        ///
        /// Unset means the server manages the key itself under its data root,
        /// which is the ordinary appliance case.
        #[serde(default)]
        session_key_file: Option<String>,
        /// **Retire the bootstrap path once a real admin passkey exists.**
        ///
        /// Enrolment in an airgap has no mail, no IdP and no internet, so the FIRST
        /// credential is registered by an identity that is already `Admin` under
        /// some other method — the static bearer in the unit file, or an mTLS
        /// client cert. That is the right bootstrap and the wrong steady state: a
        /// long-lived shared secret that goes on being a parallel admin credential
        /// after a real one exists is the credential that leaks.
        ///
        /// With this on (the default), the moment an enabled admin passkey exists
        /// the other methods stop being able to enrol passkeys. Deleting the
        /// credential store brings the bootstrap back, which is the intended
        /// break-glass and requires filesystem access to the appliance.
        ///
        /// Ported from gunnar, where the same rule retires `GUNNAR_UI_ADMIN_TOKEN`.
        #[serde(default = "default_true")]
        bootstrap_until_first_admin: bool,
    },
    StaticToken {
        /// Principal yielded on a match — the identity the RBAC maps key on.
        subject: String,
        /// Inline token secret embedded in the RON config (highest precedence).
        #[serde(default)]
        token: Option<String>,
        /// Environment variable holding the secret when `token` is unset (e.g.
        /// `HOLGER_API_TOKEN`) — what a systemd unit or a vault-injected env
        /// carries. Resolved via the shared `resolve_token` ladder.
        #[serde(default)]
        token_env: Option<String>,
        /// File holding the secret (read + trimmed; typically 0600).
        #[serde(default)]
        token_file: Option<String>,
    },
}

/// Resolved identity from auth validation.
#[derive(Debug, Clone, Default)]
pub struct AuthIdentity {
    /// The stable machine identity the RBAC maps key on — the OIDC `sub`, the
    /// static-token `subject`, or the mTLS client-cert CN. **Never changed to a
    /// friendlier claim**: every `AuthConfig::roles` map ever written keys on
    /// this string, and swapping in `preferred_username` would silently
    /// re-grant every deployment's roles to different people.
    pub subject: String,
    pub method: String,
    /// **The credential's GROUP grants** — the `groups` and `roles` claims of an
    /// OIDC token, unioned. Empty for the static-bearer and mTLS paths, which
    /// carry no such claim.
    ///
    /// This field is the fix for a defect in this very file: the OIDC provider
    /// filled `Principal::scopes` and `validate_request` built an `AuthIdentity`
    /// with two fields, so the grants were dropped one type after they were
    /// parsed. See [`claims_to_roles_tests`].
    pub groups: Vec<String>,
    /// **The human name to WRITE IN AN AUDIT LINE** — `preferred_username`, else
    /// `email`, else `None`. Display only: it authorizes nothing and no map keys
    /// on it, because a mutable claim must never decide privilege.
    ///
    /// It exists because `subject` is an opaque GUID at every modern IdP, and a
    /// journal that records `8f3a1c22-0b7e-…` answers "who looked at this" with
    /// a string nobody at the organisation can resolve.
    pub display: Option<String>,
}

impl AuthIdentity {
    /// The identity to PRINT: the human name when the token carried one, with
    /// the stable subject beside it so an audit line stays resolvable after
    /// someone's username changes. Falls back to the subject alone.
    #[must_use]
    pub fn audit_name(&self) -> String {
        match self.display.as_deref().filter(|d| !d.trim().is_empty()) {
            Some(d) if d != self.subject => format!("{d} ({})", self.subject),
            _ => self.subject.clone(),
        }
    }
}

#[derive(Debug)]
pub enum AuthError {
    Unauthorized,
}

/// Build the ordered set of enabled auth PROVIDERS from the configured methods.
/// Each provider's construction is feature-gated: a method whose provider feature
/// is not pinned into this build is skipped (it then authenticates nothing —
/// fail-closed; `warn_unpinned_providers` shouts about it at boot).
///
/// The set mixes the bearer providers (OIDC / static-token) and the transport
/// mTLS provider (`nornir-auth-mtls`) — ONE `Vec<Box<dyn AuthProvider>>` that
/// `validate_request` dispatches through twice: a presented bearer via
/// `verify_any`, then the client-cert CN via `verify_any_cert`. The mTLS provider
/// ignores bearers (`Ok(None)`) and the bearer providers ignore client certs
/// (the defaulted `verify_client_cert` ⇒ `Ok(None)`), so the two passes never
/// cross-claim.
#[cfg(any(feature = "auth-oidc", feature = "auth-oauth2", feature = "auth-bearer", feature = "auth-mtls", feature = "auth-passkey"))]
fn build_providers(
    config: &AuthConfig,
) -> Vec<Box<dyn nornir_service::provider::AuthProvider>> {
    let mut providers: Vec<Box<dyn nornir_service::provider::AuthProvider>> = Vec::new();
    for method in &config.methods {
        match method {
            #[cfg(feature = "auth-oidc")]
            AuthMethodConfig::Oidc { issuer_url, audience, jwks, jwks_path, jwks_uri } => {
                // A JWT is verified or refused by name; opaque tokens are
                // not opted in here (the `OAuth2` method introspects them).
                providers.push(Box::new(nornir_auth_oidc::OidcProvider {
                    issuer_url: issuer_url.clone(),
                    audience: audience.clone(),
                    jwks: jwks.clone(),
                    jwks_path: jwks_path.clone(),
                    jwks_uri: jwks_uri.clone(),
                    ..Default::default()
                }));
            }
            // OAuth 2.0/2.1 (RFC 7662 introspection): the shared edda
            // `nornir-auth-oauth2` provider. A bearer credential presented to the
            // server is introspected against the IdP; `verify_any` dispatches it.
            #[cfg(feature = "auth-oauth2")]
            AuthMethodConfig::OAuth2 { introspection_url, client_id, client_secret } => {
                let provider = nornir_auth_oauth2::OAuth2Provider::new(
                    nornir_auth_oauth2::OAuth2Config {
                        introspection_url: introspection_url.clone(),
                        client_id: client_id.clone(),
                        client_secret: client_secret.clone(),
                        ..Default::default()
                    },
                );
                // A provider with no introspection endpoint authenticates nothing;
                // drop it so it can't shadow a later provider that would match.
                if provider.is_enabled() {
                    providers.push(Box::new(provider));
                }
            }
            #[cfg(feature = "auth-bearer")]
            AuthMethodConfig::StaticToken { subject, token, token_env, token_file } => {
                let file = token_file.as_deref().map(std::path::Path::new);
                let env_var = token_env.as_deref().unwrap_or("");
                let provider = nornir_auth_bearer::BearerProvider::resolved(
                    subject.clone(),
                    token.as_deref(),
                    env_var,
                    file,
                    Vec::new(),
                );
                // A disabled (empty-secret) provider authenticates nothing; drop
                // it so it can't shadow a later provider that would match.
                if provider.is_enabled() {
                    providers.push(Box::new(provider));
                }
            }
            // mTLS (transport-level): the shared edda `nornir-auth-mtls` provider,
            // dispatched via `verify_any_cert` from the extracted client-cert CN.
            #[cfg(feature = "auth-mtls")]
            AuthMethodConfig::Mtls { ca_cert } => {
                providers.push(Box::new(nornir_auth_mtls::MtlsProvider::new(ca_cert.clone())));
            }
            // Passkeys: a BEARER provider, because what it verifies is the session
            // token the ceremony minted — not the ceremony, which ran on the
            // server's own door two round trips ago. A credential that is not one
            // of ours is "not mine", so this can never shadow the OIDC or
            // static-token provider sitting beside it.
            #[cfg(feature = "auth-passkey")]
            AuthMethodConfig::Passkey { session_key_file, .. } => {
                // A key that cannot be resolved disables the method (fail-closed),
                // the same rule the static-bearer provider follows for an empty
                // secret: drop the provider so it cannot shadow a later one.
                if let Some(key) = resolve_session_key(session_key_file.as_deref()) {
                    providers.push(Box::new(
                        nornir_auth_webauthn::pure::PurePasskeyProvider::new(
                            key,
                            std::sync::Arc::new(std::sync::atomic::AtomicU64::new(0)),
                        ),
                    ));
                }
            }
            // Any method whose provider feature is off (fail-closed).
            #[allow(unreachable_patterns)]
            _ => {}
        }
    }
    providers
}

/// Read the 32-byte session MAC key a passkey method signs its tokens with.
///
/// `None` — an unset, unreadable or wrong-length file — DISABLES the method, which
/// is the fail-closed direction: a server that cannot verify a session token must
/// authenticate nobody with it, never everybody.
#[cfg(feature = "auth-passkey")]
fn resolve_session_key(path: Option<&str>) -> Option<nornir_auth_webauthn::pure::SessionKey> {
    let path = path.map(str::trim).filter(|p| !p.is_empty())?;
    let bytes = std::fs::read(path)
        .map_err(|e| log::warn!("auth: passkey session key {path} is unreadable ({e}) — the passkey method authenticates nobody"))
        .ok()?;
    let key: [u8; 32] = bytes.as_slice().try_into().ok().or_else(|| {
        log::warn!(
            "auth: passkey session key {path} is {} bytes, not 32 — the passkey method \
             authenticates nobody",
            bytes.len()
        );
        None
    })?;
    Some(nornir_auth_webauthn::pure::SessionKey::new(key))
}

/// Validate an incoming request's auth.
/// Returns `Some(identity)` if valid, `None` if no auth configured (open access).
/// Returns `Err` if auth is configured but credentials are invalid.
///
/// Dispatch order mirrors the pre-provider behaviour: a presented bearer is
/// offered to the enabled providers (OIDC then static-token, config order); if
/// none authenticate it, mTLS is tried from the extracted client-cert CN; if
/// neither yields an identity, the request is unauthorized.
pub async fn validate_request(
    config: &AuthConfig,
    bearer_token: Option<&str>,
    client_cert_cn: Option<&str>,
) -> Result<Option<AuthIdentity>, AuthError> {
    if config.methods.is_empty() {
        return Ok(None); // No auth configured — open access.
    }

    // Build the enabled provider set ONCE (feature-gated), then dispatch through
    // it: first a presented bearer (OIDC / static-token) via `verify_any`, then
    // the extracted client-cert CN (mTLS) via `verify_any_cert`. The mTLS provider
    // ignores bearers and the bearer providers ignore client certs, so a single
    // provider set serves both passes.
    #[cfg(any(feature = "auth-oidc", feature = "auth-oauth2", feature = "auth-bearer", feature = "auth-mtls", feature = "auth-passkey"))]
    {
        let providers = build_providers(config);

        // 1. Bearer providers (OIDC / static-token). First to authenticate wins.
        if let Some(token) = bearer_token {
            if let Ok(Some(principal)) =
                nornir_service::provider::verify_any(&providers, token).await
            {
                return Ok(Some(AuthIdentity {
                    subject: principal.subject,
                    method: principal.method,
                    // The grants the provider PARSED. They used to stop here:
                    // `AuthIdentity` had two fields and `principal.groups` was
                    // silently dropped one line after the JWT was verified, so
                    // `AuthConfig` could only ever key on an opaque GUID.
                    groups: principal.groups,
                    display: principal.display,
                }));
            }
            // No provider authenticated the bearer — fall through to mTLS, then
            // to the unauthorized tail.
        }

        // 2. mTLS (transport-level, `nornir-auth-mtls`). Client-cert trust is
        //    delegated to the TLS layer's `client_ca` (rustls handshake); the
        //    provider maps the already-extracted leaf CN (M12 — see
        //    `warn_unenforced_mtls_ca`) to the identity.
        if let Some(cn) = client_cert_cn {
            if let Ok(Some(principal)) =
                nornir_service::provider::verify_any_cert(&providers, cn).await
            {
                return Ok(Some(AuthIdentity {
                    subject: principal.subject,
                    method: principal.method,
                    // The grants the provider PARSED. They used to stop here:
                    // `AuthIdentity` had two fields and `principal.groups` was
                    // silently dropped one line after the JWT was verified, so
                    // `AuthConfig` could only ever key on an opaque GUID.
                    groups: principal.groups,
                    display: principal.display,
                }));
            }
        }
    }
    #[cfg(not(any(feature = "auth-oidc", feature = "auth-oauth2", feature = "auth-bearer", feature = "auth-mtls", feature = "auth-passkey")))]
    let _ = (bearer_token, client_cert_cn);

    Err(AuthError::Unauthorized)
}

// ───────────────────────────── embedded-host seam ──────────────────────────
//
// The auth POLICY's embedded-host seam, PEER to `nornir_service::SuiteHost`.
// Where `SuiteHost` hands an embedded suite app the host's single bearer secret,
// `PolicyHost` hands it the full declarative [`AuthConfig`] (methods + RBAC) — the
// one authority the [`PolicyAuthLayer`] consults on every RPC. A fat suite process
// implements this once and every embedded gRPC server layers the same policy in,
// so authorization means the same thing across nornir / korp / dwarves.

/// The embedded-host authority for the shared auth POLICY. A host (e.g. a
/// `dwarves-full` process embedding nornir + skade) implements it once and returns
/// the process-wide [`AuthConfig`]; each embedded server builds a
/// [`PolicyAuthLayer`] from it. In-process Rust call — never gRPC / no network hop.
pub trait PolicyHost: Send + Sync {
    /// The process-wide auth configuration the [`PolicyAuthLayer`] enforces.
    fn auth_config(&self) -> std::sync::Arc<AuthConfig>;
}

/// A client-certificate **Common Name** already extracted by an upstream
/// TLS/mTLS-terminating layer and stashed in the request extensions. The
/// [`PolicyAuthLayer`] reads it best-effort to feed the mTLS auth path.
///
/// This leaf deliberately links **no** TLS stack (tonic's transport/TLS features
/// are OFF here), so it cannot pull the CN off a live handshake itself — the
/// transport that terminates mTLS (e.g. holger's TLS `Server` tail) must insert
/// this newtype. Absent it — plaintext, or a build with no client-cert peer — the
/// CN is simply `None`, which is fine: the bearer methods (Static / OIDC / OAuth2)
/// authenticate with no client cert at all.
#[derive(Debug, Clone)]
pub struct ClientCertCn(pub String);

// ─────────────────── async tonic auth Layer (PolicyAuthLayer) ───────────────
//
// `validate_request` is ASYNC (OIDC/OAuth2 providers may await), so it cannot run
// inside tonic's sync `with_interceptor` (which returns a plain `Result`). Instead
// we authenticate in a tower `Layer`/`Service` applied to the whole router via
// `tonic::transport::Server::builder().layer(PolicyAuthLayer::new(cfg))`, whose
// `call` returns a boxed future free to `.await` the dispatch. The layer sees the
// raw `http::Request`, so it reads the HTTP `:path` (for the health exemption) and
// the `authorization` header directly, and — on success — inserts the resolved
// [`AuthIdentity`] into the request extensions, which tonic surfaces to the gRPC
// handler via `tonic::Request::extensions()` for per-resource RBAC
// (`role_for_repo` / `authorize_read`).

use std::future::Future;
use std::pin::Pin;
use std::sync::Arc;
use std::task::{Context, Poll};

use tower_layer::Layer;
use tower_service::Service;

/// A tower [`Layer`] that authenticates every gRPC request against an
/// [`AuthConfig`] before it reaches the handler. Apply it to the whole server:
///
/// ```ignore
/// use nornir_auth_policy::PolicyAuthLayer;
/// tonic::transport::Server::builder()
///     .layer(PolicyAuthLayer::from_host(&host)) // host: impl PolicyHost
///     .add_routes(routes)
///     .serve(addr)
///     .await?;
/// ```
///
/// Per-request behaviour (see [`PolicyAuth::call`]):
/// * `config.methods` empty ⇒ **pass through** (open / dev — today's empty⇒open).
/// * the gRPC health path `/grpc.health.v1.Health/*` is **exempt** (liveness
///   probes never authenticate).
/// * otherwise the bearer (`authorization: Bearer <t>`) and any best-effort
///   client-cert CN ([`ClientCertCn`] in the extensions) are validated via
///   [`validate_request`]; a valid identity is inserted into the request
///   extensions and the request proceeds; anything else ⇒ gRPC `Unauthenticated`.
#[derive(Clone, Debug)]
pub struct PolicyAuthLayer {
    config: Arc<AuthConfig>,
}

impl PolicyAuthLayer {
    /// Build the layer from a shared [`AuthConfig`].
    pub fn new(config: Arc<AuthConfig>) -> Self {
        Self { config }
    }

    /// Build the layer from an embedded [`PolicyHost`] — the app asks the host for
    /// the process-wide policy rather than owning one.
    pub fn from_host<H: PolicyHost + ?Sized>(host: &H) -> Self {
        Self { config: host.auth_config() }
    }
}

impl<S> Layer<S> for PolicyAuthLayer {
    type Service = PolicyAuth<S>;

    fn layer(&self, inner: S) -> PolicyAuth<S> {
        PolicyAuth { inner, config: self.config.clone() }
    }
}

/// The [`Service`] produced by [`PolicyAuthLayer`]. Wraps the inner router service
/// and gates each request on the shared [`AuthConfig`].
#[derive(Clone)]
pub struct PolicyAuth<S> {
    inner: S,
    config: Arc<AuthConfig>,
}

impl<S, ReqBody, ResBody> Service<http::Request<ReqBody>> for PolicyAuth<S>
where
    S: Service<http::Request<ReqBody>, Response = http::Response<ResBody>> + Clone + Send + 'static,
    S::Future: Send + 'static,
    ReqBody: Send + 'static,
    ResBody: Default + Send + 'static,
{
    type Response = http::Response<ResBody>;
    type Error = S::Error;
    type Future = Pin<Box<dyn Future<Output = Result<Self::Response, Self::Error>> + Send>>;

    fn poll_ready(&mut self, cx: &mut Context<'_>) -> Poll<Result<(), Self::Error>> {
        self.inner.poll_ready(cx)
    }

    fn call(&mut self, mut req: http::Request<ReqBody>) -> Self::Future {
        let config = self.config.clone();

        // Readiness/clone dance: `poll_ready` was driven on `self.inner` (the ready
        // one), so we must call THAT clone, leaving a fresh clone behind.
        let clone = self.inner.clone();
        let mut inner = std::mem::replace(&mut self.inner, clone);

        // Open/dev: no methods configured ⇒ pass through untouched (empty ⇒ open).
        if config.methods.is_empty() {
            return Box::pin(async move { inner.call(req).await });
        }

        // Liveness/readiness probes never authenticate — a tower layer sees the raw
        // HTTP `:path` `/<pkg>.<Service>/<Method>`. Suite servers do NOT all use the
        // standard `grpc.health.v1.Health` service: nornir exposes `/nornir.v1.Health/*`,
        // and korp/dwarves expose a `Health` METHOD on their main service. Exempting
        // ONLY the standard path silently gated every suite health probe once auth was
        // on (a regression). So exempt the standard path AND any RPC whose method
        // segment is a well-known liveness verb — the only RPCs that must stay open.
        {
            let path = req.uri().path();
            let exempt = path.starts_with("/grpc.health.v1.Health/")
                || matches!(
                    path.rsplit('/').next(),
                    Some("Health" | "Ping" | "Check" | "Watch")
                );
            if exempt {
                return Box::pin(async move { inner.call(req).await });
            }
        }

        // Extract credentials as OWNED values so no borrow of `req` is held across
        // the `.await` — we mutate `req.extensions_mut()` on the success path.
        let bearer = req
            .headers()
            .get(http::header::AUTHORIZATION)
            .and_then(|v| v.to_str().ok())
            .and_then(|s| s.strip_prefix("Bearer "))
            .map(str::to_owned);
        // Client-cert CN, best-effort: only present if an upstream TLS layer stashed
        // a `ClientCertCn` (this leaf links no TLS — see the type's docs). Absent it
        // the CN is `None` and the bearer methods still work.
        let cn = req.extensions().get::<ClientCertCn>().map(|c| c.0.clone());

        Box::pin(async move {
            match validate_request(&config, bearer.as_deref(), cn.as_deref()).await {
                // Authenticated: hand the identity to the handler via extensions.
                Ok(Some(identity)) => {
                    req.extensions_mut().insert(identity);
                    inner.call(req).await
                }
                // `Err(Unauthorized)`, or `Ok(None)` reaching here despite non-empty
                // methods, are both refusals — fail closed with gRPC Unauthenticated.
                _ => Ok(unauthenticated::<ResBody>()),
            }
        })
    }
}

/// Build a gRPC `Unauthenticated` response as a raw `http::Response`.
/// `tonic::Status::into_http` sets the `grpc-status`/`content-type` headers and
/// stashes the `Status` in the response extensions — the same shape the transport
/// would emit for a handler-returned `Status`.
fn unauthenticated<B: Default>() -> http::Response<B> {
    tonic::Status::unauthenticated("missing or invalid credentials").into_http()
}

#[cfg(test)]
mod tests {
    use super::*;

    // ── W2: role policy (authorization) ──────────────────────────────────────

    #[test]
    fn role_privilege_ordering_and_capabilities() {
        assert!(Role::Reader < Role::Writer && Role::Writer < Role::Admin);
        assert!(!Role::Reader.can_write());
        assert!(Role::Writer.can_write());
        assert!(Role::Admin.can_write());
        assert!(!Role::Writer.can_admin());
        assert!(Role::Admin.can_admin());
    }

    #[test]
    fn rbac_is_off_until_policy_is_configured() {
        let open = AuthConfig::default();
        assert!(!open.rbac_enabled());

        let mut roles = HashMap::new();
        roles.insert("alice".to_string(), Role::Writer);
        let cfg = AuthConfig { methods: vec![], roles, default_role: None, ..Default::default() };
        assert!(cfg.rbac_enabled());

        let cfg2 = AuthConfig { methods: vec![], roles: HashMap::new(), default_role: Some(Role::Reader), ..Default::default() };
        assert!(cfg2.rbac_enabled());

        let mut repo_roles = HashMap::new();
        repo_roles.insert(
            "team-a-dev".to_string(),
            HashMap::from([("alice".to_string(), Role::Writer)]),
        );
        let cfg3 = AuthConfig { repo_roles, ..Default::default() };
        assert!(cfg3.rbac_enabled());
    }

    #[test]
    fn role_resolution_explicit_then_default_then_least_privilege() {
        let mut roles = HashMap::new();
        roles.insert("alice".to_string(), Role::Admin);
        roles.insert("bot".to_string(), Role::Writer);
        let cfg = AuthConfig { methods: vec![], roles, default_role: Some(Role::Reader), ..Default::default() };

        assert_eq!(cfg.role_for("alice"), Role::Admin);
        assert_eq!(cfg.role_for("bot"), Role::Writer);
        assert_eq!(cfg.role_for("stranger"), Role::Reader);

        let cfg_nodef = AuthConfig {
            methods: vec![],
            roles: HashMap::from([("bot".to_string(), Role::Writer)]),
            default_role: None,
            ..Default::default()
        };
        assert_eq!(cfg_nodef.role_for("stranger"), Role::Reader);
        assert!(!cfg_nodef.role_for("stranger").can_write(), "unmapped identity cannot write");
    }

    #[test]
    fn per_repo_acl_overrides_global_role() {
        let roles = HashMap::from([
            ("alice".to_string(), Role::Reader),
            ("bob".to_string(), Role::Writer),
        ]);
        let repo_roles = HashMap::from([(
            "team-a-dev".to_string(),
            HashMap::from([
                ("alice".to_string(), Role::Writer),
                ("bob".to_string(), Role::Reader),
            ]),
        )]);
        let cfg = AuthConfig {
            methods: vec![],
            roles,
            default_role: Some(Role::Reader),
            repo_roles,
            ..Default::default()
        };

        assert_eq!(cfg.role_for_repo("team-a-dev", "alice"), Role::Writer);
        assert!(cfg.role_for_repo("team-a-dev", "alice").can_write());
        assert_eq!(cfg.role_for_repo("team-a-dev", "bob"), Role::Reader);
        assert!(!cfg.role_for_repo("team-a-dev", "bob").can_write(), "per-repo demotion holds");

        assert_eq!(cfg.role_for_repo("other-repo", "alice"), Role::Reader);
        assert_eq!(cfg.role_for_repo("other-repo", "bob"), Role::Writer);

        assert_eq!(cfg.role_for_repo("team-a-dev", "stranger"), Role::Reader);
    }

    // ── Step 1: the behavior-preserving static-bearer builder + file switch ──

    /// `from_static_bearer(None)` and an empty/whitespace token ⇒ the DEFAULT
    /// (open) config: no methods, RBAC off. This is the old `auth_interceptor`
    /// "empty ⇒ open" behaviour, preserved.
    #[test]
    fn from_static_bearer_empty_is_open() {
        for t in [None, Some(String::new()), Some("   ".to_string())] {
            let cfg = AuthConfig::from_static_bearer(t);
            assert!(cfg.methods.is_empty(), "empty token ⇒ open (no methods)");
            assert!(!cfg.rbac_enabled(), "no RBAC on the static-bearer fallback");
        }
    }

    /// A non-empty token ⇒ exactly one `StaticToken` method (principal `static`,
    /// inline secret) and NO RBAC — the exact shape the old bearer interceptor had.
    #[test]
    fn from_static_bearer_token_is_one_static_method_no_rbac() {
        let cfg = AuthConfig::from_static_bearer(Some("a-secret-of-sufficient-length".into()));
        assert_eq!(cfg.methods.len(), 1);
        match &cfg.methods[0] {
            AuthMethodConfig::StaticToken { subject, token, token_env, token_file } => {
                assert_eq!(subject, "static");
                assert_eq!(token.as_deref(), Some("a-secret-of-sufficient-length"));
                assert!(token_env.is_none() && token_file.is_none());
            }
            other => panic!("expected StaticToken, got {other:?}"),
        }
        assert!(!cfg.rbac_enabled(), "static-bearer carries no RBAC");
    }

    /// `load_or_static`: an unset env var falls back to the static-bearer policy
    /// (here: a non-empty token ⇒ one StaticToken method), preserving behaviour.
    #[test]
    fn load_or_static_falls_back_when_env_unset() {
        let env = "NORNIR_AUTH_POLICY_TEST_UNSET_VAR_XYZ";
        std::env::remove_var(env);
        let cfg = AuthConfig::load_or_static(env, Some("fallback-token-of-sufficient-length".into()));
        assert_eq!(cfg.methods.len(), 1, "unset env ⇒ static-bearer fallback");
    }

    /// `load_or_static`: when the env var points at a readable RON `AuthConfig`,
    /// it is loaded (turning ON the rich policy) instead of the static fallback.
    /// RON round-trips through the crate's own `Serialize` so the test is
    /// format-faithful. A `#[serial]`-free unique env-var keeps it isolated.
    #[test]
    fn load_or_static_loads_ron_file_when_env_points_to_it() {
        let dir = std::env::temp_dir().join(format!("nornir-auth-policy-{}", std::process::id()));
        std::fs::create_dir_all(&dir).unwrap();
        let path = dir.join("auth.ron");
        let rich = AuthConfig {
            methods: vec![AuthMethodConfig::Oidc {
                issuer_url: "https://idp.example/realm".into(),
                audience: Some("nordisk".into()),
                jwks: None,
                jwks_path: None,
                jwks_uri: None,
            }],
            roles: HashMap::from([("alice".to_string(), Role::Admin)]),
            default_role: Some(Role::Reader),
            ..Default::default()
        };
        std::fs::write(&path, ron::to_string(&rich).unwrap()).unwrap();

        let env = "NORNIR_AUTH_POLICY_TEST_RON_PATH_XYZ";
        std::env::set_var(env, &path);
        let cfg = AuthConfig::load_or_static(env, Some("ignored-when-file-loads".into()));
        std::env::remove_var(env);
        let _ = std::fs::remove_file(&path);

        assert_eq!(cfg.methods.len(), 1, "the RON file's method set won, not the fallback");
        assert!(matches!(cfg.methods[0], AuthMethodConfig::Oidc { .. }), "OIDC method loaded");
        assert!(cfg.rbac_enabled(), "the loaded config's RBAC is active");
        assert_eq!(cfg.role_for("alice"), Role::Admin);
    }

    /// `load_file`: a TOML `AuthConfig` (chosen by the `.toml` extension) loads the
    /// same shape as RON — the format is picked by extension, both are accepted.
    #[test]
    fn load_file_reads_toml_by_extension() {
        let dir = std::env::temp_dir().join(format!("nornir-auth-policy-toml-{}", std::process::id()));
        std::fs::create_dir_all(&dir).unwrap();
        let path = dir.join("auth.toml");
        let rich = AuthConfig {
            methods: vec![AuthMethodConfig::StaticToken {
                subject: "ci-runner".into(),
                token: Some("ci-token-of-sufficient-length".into()),
                token_env: None,
                token_file: None,
            }],
            roles: HashMap::from([("ci-runner".to_string(), Role::Writer)]),
            ..Default::default()
        };
        std::fs::write(&path, toml::to_string(&rich).unwrap()).unwrap();
        let cfg = AuthConfig::load_file(&path).expect("TOML AuthConfig loads");
        let _ = std::fs::remove_file(&path);
        assert_eq!(cfg.methods.len(), 1);
        assert!(cfg.rbac_enabled());
        assert_eq!(cfg.role_for("ci-runner"), Role::Writer);
    }

    /// With no auth methods configured the server is OPEN — `validate_request`
    /// yields `Ok(None)` regardless of any presented credential.
    #[tokio::test]
    async fn no_methods_is_open_access() {
        let cfg = AuthConfig::default();
        assert!(validate_request(&cfg, None, None).await.unwrap().is_none());
        assert!(validate_request(&cfg, Some("whatever"), None).await.unwrap().is_none());
    }

    /// mTLS: with an `Mtls` method configured and a client-cert CN extracted by
    /// the TLS layer, the CN becomes the identity (via the shared
    /// `nornir-auth-mtls` provider through `verify_any_cert`); no CN ⇒
    /// unauthorized. Gated on `auth-mtls` (the provider must be pinned).
    #[cfg(feature = "auth-mtls")]
    #[tokio::test]
    async fn mtls_cn_authenticates_missing_cn_refused() {
        let cfg = AuthConfig {
            methods: vec![AuthMethodConfig::Mtls { ca_cert: String::new() }],
            ..Default::default()
        };
        let id = validate_request(&cfg, None, Some("CN=agent-07")).await.unwrap().expect("cn identity");
        assert_eq!(id.subject, "CN=agent-07");
        assert_eq!(id.method, "mtls");
        assert!(validate_request(&cfg, None, None).await.is_err(), "no CN ⇒ unauthorized");
    }

    /// OAuth2 (RFC 7662 introspection): the `nornir-auth-oauth2` provider is wired
    /// into the dispatch. An OAuth2 method with an EMPTY introspection endpoint is
    /// disabled (dropped from the set — fail-closed), so no token authenticates
    /// through it; and it must not break a coexisting static-token method (the
    /// oauth2 provider yields "not mine" for a bearer it can't introspect). Gated on
    /// `auth-oauth2` (the provider must be pinned). RED-when-broken: a miswired
    /// oauth2 arm would either panic on the empty config or shadow the bearer.
    #[cfg(all(feature = "auth-oauth2", feature = "auth-bearer"))]
    #[tokio::test]
    async fn oauth2_disabled_is_fail_closed_and_coexists_with_bearer() {
        let secret = "ci-runner-api-key-of-sufficient-length";
        let cfg = AuthConfig {
            methods: vec![
                // Disabled oauth2 (no introspection endpoint) — authenticates nothing.
                AuthMethodConfig::OAuth2 {
                    introspection_url: String::new(),
                    client_id: "rs".into(),
                    client_secret: String::new(),
                },
                AuthMethodConfig::StaticToken {
                    subject: "ci-runner".into(),
                    token: Some(secret.to_string()),
                    token_env: None,
                    token_file: None,
                },
            ],
            ..Default::default()
        };
        // The static token still authenticates past the (disabled) oauth2 provider.
        let id = validate_request(&cfg, Some(secret), None).await.unwrap().expect("valid token");
        assert_eq!(id.subject, "ci-runner");
        assert_eq!(id.method, "bearer");
        // An unknown token: the disabled oauth2 provider can't rescue it ⇒ refused.
        assert!(validate_request(&cfg, Some("unknown-token"), None).await.is_err());
    }

    // ── W3: static Bearer token / API-key (modern M2M auth), dispatched through
    //    the pluggable `nornir-auth-bearer` provider. Gated on `auth-bearer`
    //    (the provider must be pinned for the static-token path to authenticate).
    #[cfg(feature = "auth-bearer")]
    mod static_token {
        use super::*;

        fn static_method(subject: &str, secret: &str) -> AuthMethodConfig {
            AuthMethodConfig::StaticToken {
                subject: subject.to_string(),
                token: Some(secret.to_string()),
                token_env: None,
                token_file: None,
            }
        }

        /// The matching Bearer authenticates as the method's `subject` (method
        /// `token`); a wrong or missing Bearer is REFUSED. RED-when-broken.
        #[tokio::test]
        async fn matching_bearer_yields_subject_wrong_refused() {
            let secret = "ci-runner-api-key-of-sufficient-length";
            let cfg = AuthConfig {
                methods: vec![static_method("ci-runner", secret)],
                ..Default::default()
            };
            let id = validate_request(&cfg, Some(secret), None).await.unwrap().expect("valid token");
            assert_eq!(id.subject, "ci-runner");
            assert_eq!(id.method, "bearer");

            assert!(validate_request(&cfg, Some("not-the-key"), None).await.is_err(), "wrong token refused");
            assert!(validate_request(&cfg, None, None).await.is_err(), "missing token refused");
        }

        /// A static-token method whose secret resolves EMPTY is disabled — no
        /// bearer authenticates (fail-closed).
        #[tokio::test]
        async fn empty_secret_method_is_disabled() {
            let cfg = AuthConfig {
                methods: vec![AuthMethodConfig::StaticToken {
                    subject: "ghost".into(),
                    token: None,
                    token_env: None,
                    token_file: None,
                }],
                ..Default::default()
            };
            assert!(validate_request(&cfg, Some("anything"), None).await.is_err());
        }

        /// End-to-end MODERN flow at the AuthN+RBAC seam: a static-token WRITER
        /// may write, an insufficient-scope READER may not, anonymous is refused.
        #[tokio::test]
        async fn writer_allowed_reader_denied_rbac() {
            let writer_key = "writer-token-of-sufficient-length-1";
            let reader_key = "reader-token-of-sufficient-length-2";
            let cfg = AuthConfig {
                methods: vec![
                    static_method("release-bot", writer_key),
                    static_method("audit-bot", reader_key),
                ],
                roles: HashMap::from([
                    ("release-bot".to_string(), Role::Writer),
                    ("audit-bot".to_string(), Role::Reader),
                ]),
                default_role: Some(Role::Reader),
                ..Default::default()
            };
            assert!(cfg.rbac_enabled());

            let w = validate_request(&cfg, Some(writer_key), None).await.unwrap().unwrap();
            assert_eq!(w.subject, "release-bot");
            assert!(cfg.role_for("release-bot").can_write(), "writer scope allows write");

            let r = validate_request(&cfg, Some(reader_key), None).await.unwrap().unwrap();
            assert_eq!(r.subject, "audit-bot");
            assert!(!cfg.role_for("audit-bot").can_write(), "reader scope denied write (RBAC)");

            assert!(validate_request(&cfg, None, None).await.is_err(), "anonymous write refused");
        }
    }

    // ── W1: OIDC dispatch through the pluggable `nornir-auth-oidc` provider.
    //    Gated on `auth-oidc`; uses the oidc crate's test-support to mint a
    //    signed JWT verified offline against an inline test JWKS.
    #[cfg(feature = "auth-oidc")]
    mod oidc {
        use super::*;
        use nornir_auth_oidc::test_support;

        fn oidc_method(issuer: &str) -> AuthMethodConfig {
            AuthMethodConfig::Oidc {
                issuer_url: issuer.to_string(),
                audience: Some("holger".into()),
                jwks: Some(test_support::jwks_json()),
                jwks_path: None,
                jwks_uri: None,
            }
        }

        /// A valid offline-verified OIDC JWT authenticates as its `sub`; an
        /// expired token is refused. RED-when-broken.
        #[tokio::test]
        async fn valid_oidc_jwt_authenticates_expired_refused() {
            let issuer = "https://idp.example/holger-auth";
            let cfg = AuthConfig { methods: vec![oidc_method(issuer)], ..Default::default() };

            let good = test_support::token_for(issuer, "holger", "alice@corp", 3600);
            let id = validate_request(&cfg, Some(&good), None).await.unwrap().expect("valid jwt");
            assert_eq!(id.subject, "alice@corp");
            assert_eq!(id.method, "oidc");

            let expired = test_support::token_for(issuer, "holger", "alice@corp", -3600);
            assert!(validate_request(&cfg, Some(&expired), None).await.is_err(), "expired jwt refused");
        }

        /// OIDC + RBAC: an admin JWT resolves to Admin (can promote); a writer
        /// JWT can write but not admin; anonymous is refused.
        #[tokio::test]
        async fn oidc_identity_maps_to_rbac_role() {
            let issuer = "https://idp.example/holger-rbac";
            let cfg = AuthConfig {
                methods: vec![oidc_method(issuer)],
                roles: HashMap::from([
                    ("admin@corp".to_string(), Role::Admin),
                    ("bot@corp".to_string(), Role::Writer),
                ]),
                default_role: Some(Role::Reader),
                ..Default::default()
            };

            let admin = test_support::token_for(issuer, "holger", "admin@corp", 3600);
            let a = validate_request(&cfg, Some(&admin), None).await.unwrap().unwrap();
            assert!(cfg.role_for(&a.subject).can_admin(), "admin JWT ⇒ Admin");

            let writer = test_support::token_for(issuer, "holger", "bot@corp", 3600);
            let w = validate_request(&cfg, Some(&writer), None).await.unwrap().unwrap();
            assert!(cfg.role_for(&w.subject).can_write() && !cfg.role_for(&w.subject).can_admin(), "writer JWT ⇒ Writer");

            assert!(validate_request(&cfg, None, None).await.is_err(), "anonymous refused");
        }
    }
}

#[cfg(test)]
mod layer_tests {
    //! Unit tests for the async tonic auth [`PolicyAuthLayer`]/[`PolicyAuth`]
    //! `Service`. These drive the `Service` DIRECTLY (in-process, no TCP socket or
    //! generated proto service): the layer is exactly the seam a live
    //! `Server::builder().layer(..)` would exercise per request, so calling it with
    //! a hand-built `http::Request` proves the same behaviour with none of the
    //! transport weight.
    use super::*;
    use std::sync::Mutex;

    /// A stand-in inner router service. Records, per call, whether it was reached
    /// and the `AuthIdentity` subject it saw in the request extensions (if any), so
    /// a test can assert both "did the request pass the gate" and "was the identity
    /// handed through". Returns an empty `http::Response<()>`.
    #[derive(Clone)]
    struct MockInner {
        /// `None` = never called; `Some(None)` = called without an identity;
        /// `Some(Some(subject))` = called with that identity in extensions.
        seen: Arc<Mutex<Option<Option<String>>>>,
    }

    impl MockInner {
        fn new() -> Self {
            Self { seen: Arc::new(Mutex::new(None)) }
        }
        fn seen(&self) -> Option<Option<String>> {
            self.seen.lock().unwrap().clone()
        }
    }

    impl Service<http::Request<()>> for MockInner {
        type Response = http::Response<()>;
        type Error = std::convert::Infallible;
        type Future = std::future::Ready<Result<Self::Response, Self::Error>>;

        fn poll_ready(&mut self, _: &mut Context<'_>) -> Poll<Result<(), Self::Error>> {
            Poll::Ready(Ok(()))
        }

        fn call(&mut self, req: http::Request<()>) -> Self::Future {
            let subject = req.extensions().get::<AuthIdentity>().map(|i| i.subject.clone());
            *self.seen.lock().unwrap() = Some(subject);
            std::future::ready(Ok(http::Response::new(())))
        }
    }

    fn request(path: &str, bearer: Option<&str>) -> http::Request<()> {
        let mut b = http::Request::builder().uri(format!("http://host{path}"));
        if let Some(t) = bearer {
            b = b.header(http::header::AUTHORIZATION, format!("Bearer {t}"));
        }
        b.body(()).unwrap()
    }

    /// The response the gate emitted is a gRPC refusal (`Unauthenticated`).
    fn is_unauthenticated(resp: &http::Response<()>) -> bool {
        resp.extensions()
            .get::<tonic::Status>()
            .is_some_and(|s| s.code() == tonic::Code::Unauthenticated)
    }

    fn service(cfg: AuthConfig) -> (PolicyAuth<MockInner>, MockInner) {
        let inner = MockInner::new();
        let layer = PolicyAuthLayer::new(Arc::new(cfg));
        (layer.layer(inner.clone()), inner)
    }

    /// Empty `methods` ⇒ open/dev: the request passes through UNCHANGED — the inner
    /// service is reached and no identity is injected — regardless of any bearer.
    #[tokio::test]
    async fn empty_methods_passes_through() {
        let (mut svc, inner) = service(AuthConfig::default());
        let resp = svc.call(request("/pkg.Svc/Method", Some("whatever"))).await.unwrap();
        assert!(!is_unauthenticated(&resp), "open mode never rejects");
        assert_eq!(inner.seen(), Some(None), "inner reached, no identity injected");
    }

    /// The gRPC health path is EXEMPT even when methods are non-empty and no bearer
    /// is presented — liveness probes must not need auth. (No provider feature
    /// needed: the exemption short-circuits before `validate_request`.)
    #[tokio::test]
    async fn health_path_is_exempt() {
        let cfg = AuthConfig {
            methods: vec![AuthMethodConfig::StaticToken {
                subject: "svc".into(),
                token: Some("a-secret-token-of-sufficient-length".into()),
                token_env: None,
                token_file: None,
            }],
            ..Default::default()
        };
        let (mut svc, inner) = service(cfg);
        let resp = svc.call(request("/grpc.health.v1.Health/Check", None)).await.unwrap();
        assert!(!is_unauthenticated(&resp), "health probe is not gated");
        assert_eq!(inner.seen(), Some(None), "health reached inner without auth");
    }

    /// REGRESSION (health-path fix): the suite servers do NOT use the standard
    /// `grpc.health.v1.Health` service — nornir is `/nornir.v1.Health/*`, and
    /// korp/dwarves expose a `Health` METHOD on their main service. All must stay
    /// exempt, else turning auth on silently gates every liveness probe.
    #[tokio::test]
    async fn suite_health_verbs_are_exempt() {
        let cfg = AuthConfig {
            methods: vec![AuthMethodConfig::StaticToken {
                subject: "svc".into(),
                token: Some("a-secret-token-of-sufficient-length".into()),
                token_env: None,
                token_file: None,
            }],
            ..Default::default()
        };
        for path in [
            "/nornir.v1.Health/Ping",
            "/korp.KorpControl/Health",
            "/dwarf.v1.Dwarf/Health",
            "/grpc.health.v1.Health/Watch",
        ] {
            let (mut svc, inner) = service(cfg.clone());
            let resp = svc.call(request(path, None)).await.unwrap();
            assert!(!is_unauthenticated(&resp), "liveness path {path} must be exempt");
            assert_eq!(inner.seen(), Some(None), "{path} reached inner without auth");
        }
    }

    /// FAIL-CLOSED: `AuthConfig::deny_all()` (what a broken explicit config resolves
    /// to) denies every real request even WITH a bearer — never falls open — while
    /// the health path stays exempt. Needs `auth-bearer` so the no-secret StaticToken
    /// resolves to a dropped provider ⇒ nothing can authenticate.
    #[cfg(feature = "auth-bearer")]
    #[tokio::test]
    async fn deny_all_denies_every_real_request_but_not_health() {
        let (mut svc, inner) = service(AuthConfig::deny_all());
        let resp = svc.call(request("/pkg.Svc/Method", Some("any-bearer"))).await.unwrap();
        assert!(is_unauthenticated(&resp), "deny_all must reject a real request");
        assert_eq!(inner.seen(), None, "inner never reached under deny_all");
        let (mut svc2, inner2) = service(AuthConfig::deny_all());
        let hresp = svc2.call(request("/korp.KorpControl/Health", None)).await.unwrap();
        assert!(!is_unauthenticated(&hresp), "health exempt even under deny_all");
        assert_eq!(inner2.seen(), Some(None));
    }

    /// A gated method with a `StaticToken` method configured: the correct bearer
    /// authenticates and the `AuthIdentity` arrives in the handler's extensions; a
    /// wrong or missing bearer is refused with `Unauthenticated` and the inner
    /// service is never reached. Requires the `auth-bearer` provider to be pinned.
    #[cfg(feature = "auth-bearer")]
    #[tokio::test]
    async fn static_token_gate_accepts_right_bearer_rejects_others() {
        let secret = "ci-runner-api-key-of-sufficient-length";
        let cfg = AuthConfig {
            methods: vec![AuthMethodConfig::StaticToken {
                subject: "ci-runner".into(),
                token: Some(secret.into()),
                token_env: None,
                token_file: None,
            }],
            ..Default::default()
        };

        // Correct bearer ⇒ identity present in extensions, inner reached.
        let (mut svc, inner) = service(cfg.clone());
        let resp = svc.call(request("/pkg.Svc/Method", Some(secret))).await.unwrap();
        assert!(!is_unauthenticated(&resp));
        assert_eq!(inner.seen(), Some(Some("ci-runner".to_string())), "identity handed through");

        // Wrong bearer ⇒ Unauthenticated, inner NOT reached.
        let (mut svc, inner) = service(cfg.clone());
        let resp = svc.call(request("/pkg.Svc/Method", Some("not-the-key"))).await.unwrap();
        assert!(is_unauthenticated(&resp), "wrong bearer refused");
        assert_eq!(inner.seen(), None, "handler never reached on bad auth");

        // Missing bearer ⇒ Unauthenticated, inner NOT reached.
        let (mut svc, inner) = service(cfg);
        let resp = svc.call(request("/pkg.Svc/Method", None)).await.unwrap();
        assert!(is_unauthenticated(&resp), "missing bearer refused");
        assert_eq!(inner.seen(), None, "handler never reached without credentials");
    }
}

/// **Claims → roles: the half of OIDC that decides what an operator may DO.**
///
/// # The defect these guards close
///
/// `nornir-auth-oidc` verified a JWT correctly — signature by header `kid`
/// against a JWKS, `iss`, `aud`, `exp` — and returned a
/// `Principal { subject, scopes, method }` with the `scope`/`scp` grants filled
/// in. `validate_request` then built an `AuthIdentity { subject, method }`, and
/// **`scopes` was not one of the two fields**, so the grants were dropped at the
/// type boundary in this file. `Principal::has_scope` had ZERO callers anywhere
/// in the constellation; every occurrence was a test or a `with_scopes` write.
///
/// What was left was `AuthConfig::roles`, keyed on the raw OIDC `sub` — an
/// opaque GUID at Entra ID and Okta. Granting an operator `Reader` meant pasting
/// their GUID into the server's config, one operator at a time, and a `groups`
/// claim could grant nothing at all because `OidcClaims` did not parse one.
/// That is not an RBAC policy; it is a hand-maintained allow-list wearing one.
///
/// The measurement that made it concrete (korp, 2026-09-06): korp's own client
/// already read `preferred_username` → `email` → `sub` to show who was signed
/// in, while the server it dialled recorded and authorized the GUID. The human
/// name existed, on the same token, and only the client could see it.
#[cfg(test)]
mod claims_to_roles_tests {
    use super::*;

    fn oidc(subject: &str, groups: &[&str]) -> AuthIdentity {
        AuthIdentity {
            subject: subject.to_string(),
            method: "oidc".to_string(),
            groups: groups.iter().map(|g| (*g).to_string()).collect(),
            display: None,
        }
    }

    /// **THE RED.** A `groups` claim must be able to grant a role, because that
    /// is how every real IdP expresses "this person is an investigator".
    #[test]
    fn a_group_claim_grants_a_role_without_the_operators_guid_in_the_config() {
        let mut group_roles = HashMap::new();
        group_roles.insert("korp-investigators".to_string(), Role::Writer);
        let cfg = AuthConfig { group_roles, ..Default::default() };
        assert!(cfg.rbac_enabled(), "a group map alone must turn RBAC on");

        // The operator's `sub` appears NOWHERE in the config — that is the point.
        let alice = oidc("8f3a1c22-0b7e-4c19-9d5e-6a2f4b8c1d03", &["korp-investigators"]);
        assert_eq!(
            cfg.role_for_identity(&alice),
            Role::Writer,
            "a mapped group must grant its role to a subject the config never names"
        );

        // And someone whose groups match nothing gets least privilege, not the
        // group's role — otherwise the map would be granting to everyone.
        let bob = oidc("11111111-2222-3333-4444-555555555555", &["all-staff"]);
        assert_eq!(
            cfg.role_for_identity(&bob),
            Role::Reader,
            "an unmapped group must not inherit another group's grant"
        );
    }

    /// **The highest matched group wins, and it is genuinely a maximum.** An
    /// operator is in many groups; picking the first match would make the grant
    /// depend on `HashMap` iteration order, which is a privilege decision made
    /// by a hash seed.
    #[test]
    fn the_highest_matched_group_wins_and_not_whichever_hashes_first() {
        let mut group_roles = HashMap::new();
        group_roles.insert("all-staff".to_string(), Role::Reader);
        group_roles.insert("korp-investigators".to_string(), Role::Writer);
        group_roles.insert("korp-admins".to_string(), Role::Admin);
        let cfg = AuthConfig { group_roles, ..Default::default() };

        // Every ordering of the same three groups must give the same answer.
        for order in [
            ["all-staff", "korp-investigators", "korp-admins"],
            ["korp-admins", "all-staff", "korp-investigators"],
            ["korp-investigators", "korp-admins", "all-staff"],
        ] {
            assert_eq!(
                cfg.role_for_identity(&oidc("sub-1", &order)),
                Role::Admin,
                "order {order:?} changed the grant — the resolution is not a maximum"
            );
        }
        // Two groups, neither of them Admin: the answer is the higher of the two,
        // not the highest in the MAP. A max over the wrong set would say Admin.
        assert_eq!(
            cfg.role_for_identity(&oidc("sub-2", &["all-staff", "korp-investigators"])),
            Role::Writer
        );
    }

    /// **An explicit subject mapping still wins.** Every deployment that
    /// configured `roles` before groups existed must keep behaving identically,
    /// including one that DEMOTES a person who is in a privileged group — which
    /// is the case that matters, because it is how an account gets contained.
    #[test]
    fn an_explicit_subject_mapping_beats_the_group_and_can_demote() {
        let mut roles = HashMap::new();
        roles.insert("suspended-admin".to_string(), Role::Denied);
        let mut group_roles = HashMap::new();
        group_roles.insert("korp-admins".to_string(), Role::Admin);
        let cfg = AuthConfig { roles, group_roles, ..Default::default() };

        assert_eq!(
            cfg.role_for_identity(&oidc("suspended-admin", &["korp-admins"])),
            Role::Denied,
            "an explicit subject mapping must be able to DEMOTE a privileged group \
             member; if the group won, an account could not be contained at the server"
        );
        assert_eq!(
            cfg.role_for_identity(&oidc("someone-else", &["korp-admins"])),
            Role::Admin
        );
    }

    /// **Nothing changes for a deployment with no group map.** The whole point
    /// of an additive change is that the old shape is untouched, and the proof
    /// is that the two resolvers agree on every identity when `group_roles` is
    /// empty — not that the code "looks additive".
    #[test]
    fn with_no_group_map_the_new_resolver_is_the_old_one() {
        let mut roles = HashMap::new();
        roles.insert("alice".to_string(), Role::Admin);
        for default_role in [None, Some(Role::Reader), Some(Role::Writer)] {
            let cfg = AuthConfig { roles: roles.clone(), default_role, ..Default::default() };
            for subject in ["alice", "bob", ""] {
                for groups in [&[][..], &["korp-admins"][..], &["all-staff"][..]] {
                    let id = oidc(subject, groups);
                    assert_eq!(
                        cfg.role_for_identity(&id),
                        cfg.role_for(subject),
                        "group_roles is empty, so `{subject}` with {groups:?} must resolve \
                         exactly as `role_for` always did (default_role={default_role:?})"
                    );
                }
            }
        }
    }

    /// A per-repo ACL is the most specific statement there is and keeps winning
    /// over a group — the same reason an explicit subject mapping does.
    #[test]
    fn a_per_repo_acl_still_beats_a_group() {
        let mut repo_roles = HashMap::new();
        let mut per = HashMap::new();
        per.insert("alice".to_string(), Role::Reader);
        repo_roles.insert("secret-case".to_string(), per);
        let mut group_roles = HashMap::new();
        group_roles.insert("korp-admins".to_string(), Role::Admin);
        let cfg = AuthConfig { repo_roles, group_roles, ..Default::default() };

        let alice = oidc("alice", &["korp-admins"]);
        assert_eq!(
            cfg.role_for_identity_in_repo("secret-case", &alice),
            Role::Reader,
            "a per-repo ACL is more specific than a group and must win"
        );
        assert_eq!(
            cfg.role_for_identity_in_repo("some-other-case", &alice),
            Role::Admin,
            "…and only for the repo it names"
        );
    }

    /// **A group grant needs an identity that CARRIES groups**, and the static
    /// bearer / mTLS paths carry none. They must not accidentally match a group
    /// mapping through an empty-string key or a defaulted field.
    #[test]
    fn an_identity_with_no_groups_matches_no_group_mapping() {
        let mut group_roles = HashMap::new();
        group_roles.insert(String::new(), Role::Admin); // the pathological key
        group_roles.insert("korp-admins".to_string(), Role::Admin);
        let cfg = AuthConfig { group_roles, ..Default::default() };

        let machine = AuthIdentity {
            subject: "ci-runner".to_string(),
            method: "bearer".to_string(),
            groups: Vec::new(),
            display: None,
        };
        assert_eq!(
            cfg.role_for_identity(&machine),
            Role::Reader,
            "a groupless identity must not pick up a grant from an empty-string key"
        );
    }
}

#[cfg(test)]
mod duty_tests {
    //! Separation of duties: a SET of grants beside the one-rung [`Role`].
    use super::*;

    fn who(subject: &str, groups: &[&str]) -> AuthIdentity {
        AuthIdentity {
            subject: subject.to_string(),
            method: "oidc".to_string(),
            groups: groups.iter().map(|g| (*g).to_string()).collect(),
            display: None,
        }
    }

    #[test]
    fn one_principal_may_hold_two_duties_and_both_are_seen() {
        let mut duties = HashMap::new();
        duties.insert("ida".to_string(), vec![Duty::Importer, Duty::SecurityApprover]);
        let cfg = AuthConfig { duties, ..Default::default() };
        assert!(cfg.rbac_enabled() && cfg.duties_enabled());
        let got = cfg.duties_for_identity(&who("ida", &[]));
        assert!(got.contains(&Duty::Importer) && got.contains(&Duty::SecurityApprover), "{got:?}");
        assert!(cfg.duties_for_identity(&who("nobody", &[])).is_empty(), "nothing derived once duties are on");
    }

    #[test]
    fn a_duty_lifts_the_coarse_role_to_its_floor() {
        let mut duties = HashMap::new();
        duties.insert("imp".to_string(), vec![Duty::Importer]);
        duties.insert("adm".to_string(), vec![Duty::RepositoryAdmin]);
        duties.insert("aud".to_string(), vec![Duty::Auditor]);
        let mut group_duties = HashMap::new();
        group_duties.insert("importers".to_string(), vec![Duty::Importer]);
        let cfg = AuthConfig { duties, group_duties, ..Default::default() };
        assert_eq!(cfg.role_for("imp"), Role::Writer);
        assert_eq!(cfg.role_for_identity(&who("imp", &[])), Role::Writer);
        assert_eq!(cfg.role_for_identity(&who("adm", &[])), Role::Admin);
        assert_eq!(cfg.role_for_identity(&who("aud", &[])), Role::Reader, "an auditor writes nothing");
        assert_eq!(cfg.role_for_identity(&who("g", &["importers"])), Role::Writer);
        assert_eq!(
            cfg.duties_for_identity(&who("g", &["importers"])).into_iter().collect::<Vec<_>>(),
            vec![Duty::Importer]
        );
    }

    #[test]
    fn without_duties_they_are_derived_from_the_role() {
        let mut roles = HashMap::new();
        roles.insert("root".to_string(), Role::Admin);
        roles.insert("w".to_string(), Role::Writer);
        let cfg = AuthConfig { roles, ..Default::default() };
        assert!(!cfg.duties_enabled());
        assert_eq!(cfg.duties_for_identity(&who("root", &[])).len(), Duty::ALL.len());
        let w = cfg.duties_for_identity(&who("w", &[]));
        assert!(w.contains(&Duty::Importer) && !w.contains(&Duty::SecurityApprover));
        assert_eq!(cfg.duties_for_identity(&who("r", &[])).into_iter().collect::<Vec<_>>(), vec![Duty::Developer]);
    }

    #[test]
    fn an_explicit_denied_contains_a_principal_whatever_its_duties() {
        let mut roles = HashMap::new();
        roles.insert("mallory".to_string(), Role::Denied);
        let mut duties = HashMap::new();
        duties.insert("mallory".to_string(), vec![Duty::SecurityApprover]);
        let mut group_duties = HashMap::new();
        group_duties.insert("sec".to_string(), vec![Duty::SecurityApprover]);
        let cfg = AuthConfig { roles, duties, group_duties, ..Default::default() };
        assert!(cfg.duties_for_identity(&who("mallory", &["sec"])).is_empty());
        assert_eq!(cfg.role_for_identity(&who("mallory", &["sec"])), Role::Denied);
    }

    #[test]
    fn duties_read_from_ron() {
        let cfg: AuthConfig = ron::from_str(
            r#"(methods: [], duties: {"ida": [Importer, SecurityApprover]}, group_duties: {"audit": [Auditor]})"#,
        )
        .expect("duties parse");
        assert_eq!(cfg.duties["ida"], vec![Duty::Importer, Duty::SecurityApprover]);
        assert_eq!(cfg.group_duties["audit"], vec![Duty::Auditor]);
    }
}