gunnar-grpc 3.0.0

The tonic service implementations and the event bus behind gunnar's gRPC control plane.
Documentation
//! **SAML 2.0**, Gunnar's half: the account's `SamlSettings` mapped onto edda's
//! SP (`nornir_identity_sso::saml`, A34 M-4) — an AuthnRequest out
//! (HTTP-Redirect), the signed Response back (HTTP-POST to the console's ACS,
//! relayed here), verified by `nornir_auth_saml::sp` in pure Rust. Only
//! SP-initiated sign-ins; each assertion is consumed once.
// Without `sso` the protocol halves are compiled out; their helpers stay.
#![cfg_attr(not(feature = "sso"), allow(unused_imports, dead_code))]

use std::sync::Arc;

use base64::Engine as _;
use gunnar_proto::{SamlSettings, SsoProvider};

use super::{Egress, Flow, SsoState};

#[cfg(feature = "sso")]
pub use nornir_identity_sso::saml::Verified;

/// The settings of a SAML provider, or why it has none.
pub fn settings(provider: &SsoProvider) -> Result<&SamlSettings, String> {
    provider
        .saml
        .as_ref()
        .filter(|s| {
            !s.idp_entity_id.trim().is_empty()
                && !s.idp_sso_url.trim().is_empty()
                && !s.idp_certificates.is_empty()
        })
        .ok_or_else(|| {
            format!(
                "{}'s SAML provider has no IdP entity id, sign-on URL or certificate",
                provider.account
            )
        })
}

/// The stored certificates (base64 DER) as DER.
pub fn certificates(settings: &SamlSettings) -> Result<Vec<Vec<u8>>, String> {
    settings
        .idp_certificates
        .iter()
        .map(|c| {
            base64::engine::general_purpose::STANDARD
                .decode(c.trim())
                .map_err(|e| format!("a stored IdP certificate does not decode: {e}"))
        })
        .collect()
}

/// `SHA256:<base64>` of a certificate's DER, as a listing shows it.
pub fn fingerprint(der: &[u8]) -> String {
    nornir_identity_sso::fingerprint(der)
}

#[cfg(feature = "sso")]
fn shared(provider: &SsoProvider) -> Result<nornir_identity_sso::saml::SamlProvider, String> {
    let s = settings(provider)?;
    Ok(nornir_identity_sso::saml::SamlProvider {
        account: provider.account.clone(),
        console_origin: provider.console_origin.clone(),
        idp_entity_id: s.idp_entity_id.clone(),
        idp_sso_url: s.idp_sso_url.clone(),
        certificates_der: certificates(s)?,
        username_attribute: s.username_attribute.clone(),
        display_name_attribute: s.display_name_attribute.clone(),
    })
}

/// **An egress whose every call trusts `ca`** (one account's IdP CA, DER)
/// beside the public roots (A34-41): what the shared SAML input asks of it —
/// the metadata URL — goes through [`Egress::get_trusting`], as an OIDC
/// provider's calls do. An empty `ca` is the egress as it is.
pub fn trusting(inner: Arc<dyn Egress>, ca: Vec<Vec<u8>>) -> Arc<dyn Egress> {
    if ca.is_empty() {
        return inner;
    }
    Arc::new(Trusting { inner, ca })
}

struct Trusting {
    inner: Arc<dyn Egress>,
    ca: Vec<Vec<u8>>,
}

impl Egress for Trusting {
    fn get(&self, url: &str) -> Result<(u16, String), String> {
        self.inner.get_trusting(url, &self.ca)
    }
    fn post_form(
        &self,
        url: &str,
        authorization: &str,
        body: &str,
    ) -> Result<(u16, String), String> {
        self.inner
            .post_form_trusting(url, authorization, body, &self.ca)
    }
    fn get_trusting(&self, url: &str, ca: &[Vec<u8>]) -> Result<(u16, String), String> {
        self.inner.get_trusting(url, ca)
    }
    fn post_form_trusting(
        &self,
        url: &str,
        authorization: &str,
        body: &str,
        ca: &[Vec<u8>],
    ) -> Result<(u16, String), String> {
        self.inner.post_form_trusting(url, authorization, body, ca)
    }
}

#[cfg(feature = "sso")]
/// **Fill an admin's SAML input** (`nornir_identity_sso::saml::resolve_input`,
/// on the blocking pool when a metadata URL is fetched). Returns the settings
/// to store, certificates as base64 DER.
pub async fn resolve_input(
    state: &Arc<SsoState>,
    egress: Option<Arc<dyn Egress>>,
    input: &SamlSettings,
) -> Result<SamlSettings, String> {
    let ask = nornir_identity_sso::saml::SamlInput {
        metadata_xml: input.idp_metadata_xml.clone(),
        metadata_url: input.idp_metadata_url.clone(),
        idp_entity_id: input.idp_entity_id.clone(),
        idp_sso_url: input.idp_sso_url.clone(),
        certificates: input.idp_certificates.clone(),
        username_attribute: input.username_attribute.clone(),
        display_name_attribute: input.display_name_attribute.clone(),
    };
    let state = Arc::clone(state);
    let resolved = tokio::task::spawn_blocking(move || {
        nornir_identity_sso::saml::resolve_input(&state, egress.as_deref(), &ask)
    })
    .await
    .map_err(|e| format!("the metadata fetch did not finish: {e}"))??;
    Ok(SamlSettings {
        idp_metadata_xml: String::new(),
        idp_metadata_url: resolved.metadata_url,
        idp_entity_id: resolved.idp_entity_id,
        idp_sso_url: resolved.idp_sso_url,
        idp_certificates: resolved
            .certificates_der
            .iter()
            .map(|der| base64::engine::general_purpose::STANDARD.encode(der))
            .collect(),
        username_attribute: resolved.username_attribute,
        display_name_attribute: resolved.display_name_attribute,
    })
}

#[cfg(feature = "sso")]
/// **The redirect**: (URL, state, flow) — `nornir_identity_sso::saml::begin`.
pub fn begin(provider: &SsoProvider, now: i64) -> Result<(String, String, Flow), String> {
    nornir_identity_sso::saml::begin(&shared(provider)?, now)
}

#[cfg(feature = "sso")]
/// **The ACS**: the response verified, its assertion consumed once, the person
/// read out of it — `nornir_identity_sso::saml::finish`.
pub fn finish(
    state: &SsoState,
    provider: &SsoProvider,
    flow: &Flow,
    saml_response: &str,
    now: i64,
) -> Result<Verified, String> {
    nornir_identity_sso::saml::finish(state, &shared(provider)?, flow, saml_response, now)
}

#[cfg(feature = "sso")]
/// The SP metadata the admin uploads to the IdP.
pub fn sp_metadata(provider: &SsoProvider) -> String {
    nornir_identity_sso::saml::sp_metadata(&provider.console_origin, &provider.account)
}

/// Without the `sso` feature: SAML refuses, by name.
#[cfg(not(feature = "sso"))]
mod off {
    use super::*;
    const NOT_BUILT: &str =
        "company SSO (OIDC/SAML) is not compiled into this build (gunnar-grpc feature `sso`)";
    /// What a verified assertion says (never built here).
    #[derive(Debug, Clone, PartialEq, Eq)]
    pub struct Verified {
        pub issuer: String,
        pub subject: String,
        pub user_name: String,
        pub display_name: String,
        pub email: String,
        pub assertion_id: String,
        pub not_on_or_after_unix: i64,
    }
    #[allow(clippy::unused_async)]
    pub async fn resolve_input(
        _: &Arc<SsoState>,
        _: Option<Arc<dyn Egress>>,
        _: &SamlSettings,
    ) -> Result<SamlSettings, String> {
        Err(NOT_BUILT.to_owned())
    }
    pub fn begin(_: &SsoProvider, _: i64) -> Result<(String, String, Flow), String> {
        Err(NOT_BUILT.to_owned())
    }
    pub fn finish(
        _: &SsoState,
        _: &SsoProvider,
        _: &Flow,
        _: &str,
        _: i64,
    ) -> Result<Verified, String> {
        Err(NOT_BUILT.to_owned())
    }
    pub fn sp_metadata(_: &SsoProvider) -> String {
        String::new()
    }
}
#[cfg(not(feature = "sso"))]
pub use off::{begin, finish, resolve_input, sp_metadata, Verified};