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-mtls/src/lib.rs

//! # nornir-auth-mtls
//!
//! The **mTLS (client-certificate) auth provider** — the transport-level method
//! that maps a mutual-TLS leaf-certificate Common Name to an identity. It is the
//! sibling of the bearer providers ([`nornir-auth-oidc`], [`nornir-auth-bearer`])
//! in the shared edda auth family: every MODERN method is a pluggable sub-crate
//! implementing the ONE shared [`nornir_service::AuthProvider`] seam so a server
//! holds a single `Vec<Box<dyn AuthProvider>>` and dispatches through it.
//!
//! ## Trust model — read this (M12 / P18)
//!
//! This provider does **not** verify a certificate chain and does **not**
//! independently path-validate a CA. Cryptographic client-cert trust — that the
//! presented leaf chains to a trusted CA — is established ENTIRELY by the TLS
//! transport during the mutual-TLS handshake (rustls' `client_ca` /
//! `with_client_cert_verifier`). By the time this provider runs, the handshake
//! has already succeeded and the TLS layer has EXTRACTED the leaf's Common Name.
//! The provider's only job is the identity mapping `CN → Principal{ method:
//! "mtls" }`. The retained [`MtlsProvider::ca_cert`] is documentation of the
//! operator's intended CA, surfaced by [`MtlsProvider::ca_trust_warning`] so a
//! server can shout at boot that the transport `client_ca` must equal it — a
//! broader `client_ca` admits any cert valid under it. This is why the provider
//! links no crypto/TLS/HTTP stack: it is the leanest provider of all, and a tier
//! pins it whenever the server terminates mTLS.
//!
//! ```
//! use nornir_auth_mtls::MtlsProvider;
//! use nornir_service::provider::AuthProvider;
//! # async fn demo() {
//! let p = MtlsProvider::new(String::new());
//! // A bearer credential is never this provider's — it speaks client certs only.
//! assert!(p.verify_bearer("some.jwt.token").await.unwrap().is_none());
//! // The extracted leaf CN becomes the identity.
//! let id = p.verify_client_cert("CN=agent-07").await.unwrap().unwrap();
//! assert_eq!(id.subject, "CN=agent-07");
//! assert_eq!(id.method, "mtls");
//! # }
//! ```

use nornir_service::provider::{AuthProvider, Principal, ProviderError};
use tonic::async_trait;

/// A client-certificate (mTLS) auth provider. Holds the operator-declared trusted
/// CA PEM purely for the boot-time trust warning — it is NOT used to validate the
/// chain (the TLS transport does that during the handshake). On presentation of a
/// CN already extracted by the TLS layer, the provider yields a [`Principal`]
/// whose `subject` is that CN and `method` is `"mtls"`.
#[derive(Debug, Clone, Default)]
pub struct MtlsProvider {
    /// Operator-declared trusted CA PEM for client certs. Documentation only:
    /// client-cert trust is delegated to the transport `client_ca`. See
    /// [`MtlsProvider::ca_trust_warning`].
    ca_cert: String,
}

impl MtlsProvider {
    /// Build from the configured (documentation-only) CA PEM. Pass `String::new()`
    /// when the CA is declared solely at the transport.
    pub fn new(ca_cert: impl Into<String>) -> Self {
        Self { ca_cert: ca_cert.into() }
    }

    /// The operator-declared CA PEM (documentation only — never path-validated
    /// here; see the crate trust-model note).
    pub fn ca_cert(&self) -> &str {
        &self.ca_cert
    }

    /// The boot-time SECURITY warning to emit when a per-method `ca_cert` is
    /// configured (M12 / P18): the per-method CA is NOT independently enforced —
    /// the transport `client_ca` (TLS handshake) is authoritative for client-cert
    /// trust. `None` when no CA PEM is declared (nothing to warn about). A server
    /// logs this at startup so the operator knows `client_ca` must equal the
    /// intended CA.
    pub fn ca_trust_warning(&self) -> Option<&'static str> {
        if self.ca_cert.trim().is_empty() {
            None
        } else {
            Some(
                "SECURITY (mTLS): the per-method `ca_cert` is NOT independently \
                 enforced — the transport `client_ca` (TLS handshake) is authoritative \
                 for client-cert trust. Ensure `client_ca` equals this method's intended \
                 CA; a broader `client_ca` admits any cert valid under it.",
            )
        }
    }

    /// Map an already-extracted leaf-cert CN to a [`Principal`]. `None` CN ⇒
    /// `Ok(None)` ("no client-cert identity presented" — the caller falls through
    /// to unauthorized). This is the shared decision the bearer-dispatch path
    /// reaches for via [`AuthProvider::verify_client_cert`].
    pub fn principal_for_cn(&self, cn: Option<&str>) -> Option<Principal> {
        cn.map(|cn| Principal::new(cn.to_string(), "mtls"))
    }
}

#[async_trait]
impl AuthProvider for MtlsProvider {
    fn name(&self) -> &str {
        "mtls"
    }

    /// mTLS is a transport method, never a bearer method — a presented bearer is
    /// always "not mine" so the dispatcher tries the bearer providers.
    async fn verify_bearer(&self, _token: &str) -> Result<Option<Principal>, ProviderError> {
        Ok(None)
    }

    /// The extracted leaf CN becomes the identity (`method = "mtls"`). Trust in the
    /// cert itself was already established by the TLS handshake (see the crate
    /// trust-model note); here a non-empty CN always maps to a principal.
    async fn verify_client_cert(&self, cn: &str) -> Result<Option<Principal>, ProviderError> {
        Ok(Some(Principal::new(cn.to_string(), "mtls")))
    }
}

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

    /// The extracted CN becomes the identity with method `"mtls"`; a bearer is
    /// never this provider's. RED-when-broken: the mTLS identity mapping is the
    /// whole job of this crate.
    #[tokio::test]
    async fn cn_becomes_identity_bearer_is_not_mine() {
        let p = MtlsProvider::new(String::new());
        let id = p.verify_client_cert("CN=agent-07").await.unwrap().expect("cn identity");
        assert_eq!(id.subject, "CN=agent-07");
        assert_eq!(id.method, "mtls");

        // A bearer credential is always "not mine" — mTLS is transport-level.
        assert!(p.verify_bearer("any.jwt.here").await.unwrap().is_none());
    }

    /// `principal_for_cn` mirrors the trait method and handles the "no CN
    /// presented" case as `None` (the caller then falls through to unauthorized).
    #[test]
    fn principal_for_cn_handles_absent_cn() {
        let p = MtlsProvider::new(String::new());
        assert_eq!(p.principal_for_cn(Some("CN=x")).unwrap().subject, "CN=x");
        assert!(p.principal_for_cn(None).is_none(), "no CN ⇒ no identity");
    }

    /// The CA trust warning fires only when a per-method CA PEM is declared —
    /// documenting that the transport `client_ca` is authoritative (M12).
    #[test]
    fn ca_trust_warning_only_when_ca_declared() {
        assert!(MtlsProvider::new(String::new()).ca_trust_warning().is_none());
        assert!(MtlsProvider::new("   ").ca_trust_warning().is_none(), "blank CA ⇒ no warning");
        assert!(MtlsProvider::new("-----BEGIN CERTIFICATE-----\n...").ca_trust_warning().is_some());
    }
}