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