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

nordisk/edda

crates/nornir-identity-sso/src/rules.rs

//! **Who a verified sign-in is** (moved from Gunnar's `gunnar-core::auth::sso_table`
//! and `sso.rs`, A34 M-4): the rules alone, over plain data, so every product's
//! store applies the same ones inside its own transaction.
//!
//! 1. A subject already bound in the account's directory is that employee.
//! 2. Else a directory user of the IdP's user name is **claimed** — only on the
//!    admin's binding (its `externalId` is one of the IdP's stable ids for the
//!    person) or on an e-mail the IdP asserts is verified, that is the row's
//!    address, in one of the account's **proven** domains (A34-2, A34-5). A user
//!    name alone never claims. A row bound to another identity, or created by
//!    another sign-in, is claimed only while its admin opened a **rebind**
//!    (A34-11: an IdP migration), and then on the same two bases.
//! 3. Else a NEW employee is made just in time, as the provider's JIT policy
//!    allows (A34-25).

use crate::refusal::Refusal;

/// **The key a subject is bound under**: SHA-256 of `issuer ␟ subject`, hex.
/// Hashed so a listing of the directory carries no IdP identifiers.
pub fn subject_key(issuer: &str, subject: &str) -> String {
    let mut text = issuer.trim().trim_end_matches('/').to_owned();
    text.push('\u{1f}');
    text.push_str(subject);
    crate::sha256_hex(text.as_bytes())
}

/// Where a directory user came from.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Source {
    /// Made just in time by its first sign-in.
    Jit,
    /// Provisioned by the company's directory (SCIM).
    Scim,
    /// Provisioned by the admin's CSV import.
    Csv,
}

/// A directory user, as the rules need it.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Row<'a> {
    /// The directory's own id for the person (SCIM `externalId`); the admin's binding.
    pub external_id: &'a str,
    /// The person's address, as the directory says it.
    pub email: &'a str,
    /// The directory user name.
    pub user_name: &'a str,
    pub source: Source,
    /// Bound to an IdP subject already.
    pub bound: bool,
    /// The admin opened a rebind for it (A34-11): its next sign-in may bind it
    /// afresh, on the claim bases.
    pub rebind: bool,
}

/// What the IdP verified, as the rules need it.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Login<'a> {
    /// The e-mail the IdP named.
    pub email: &'a str,
    /// The IdP ASSERTED it is verified (OIDC `email_verified`; SAML: never).
    pub email_verified: bool,
    /// The IdP's stable ids for the person (OIDC `sub`/`oid`, the SAML NameID).
    pub idp_ids: &'a [String],
}

/// How a provisioned (or rebound) user was claimed, for the audit line.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ClaimBasis {
    /// The row's `externalId` is one of the IdP's ids for the person.
    ExternalId,
    /// A verified e-mail, the row's own, in a proven domain.
    VerifiedEmail,
}

impl ClaimBasis {
    /// The word the audit line carries.
    pub fn as_str(self) -> &'static str {
        match self {
            ClaimBasis::ExternalId => "external_id",
            ClaimBasis::VerifiedEmail => "verified_email",
        }
    }
}

fn domain_of(email: &str) -> &str {
    email.rsplit_once('@').map(|(_, d)| d).unwrap_or_default()
}

fn in_domains(domain: &str, domains: &[String]) -> bool {
    !domain.is_empty()
        && domains
            .iter()
            .any(|d| d.trim().eq_ignore_ascii_case(domain))
}

/// **May this sign-in claim the directory user `row`?** Its basis, or the
/// refusal by code. `proven_domains` are the account's DNS-proven e-mail domains
/// (A34-5), never the ones an admin merely typed.
pub fn may_claim(
    row: &Row<'_>,
    login: &Login<'_>,
    proven_domains: &[String],
) -> Result<ClaimBasis, Refusal> {
    if row.bound && !row.rebind {
        return Err(Refusal::ClaimBound);
    }
    if row.source == Source::Jit && !row.rebind {
        return Err(Refusal::ClaimJit);
    }
    let external_id = row.external_id.trim();
    if !external_id.is_empty() && login.idp_ids.iter().any(|s| s.trim() == external_id) {
        return Ok(ClaimBasis::ExternalId);
    }
    let email = login.email.trim().to_ascii_lowercase();
    if !login.email_verified || email.is_empty() {
        return Err(Refusal::ClaimUnverified);
    }
    let own = [
        row.email.trim().to_ascii_lowercase(),
        row.user_name.trim().to_ascii_lowercase(),
    ];
    if !own.contains(&email) {
        return Err(Refusal::ClaimNotAddress);
    }
    if !in_domains(domain_of(&email), proven_domains) {
        return Err(Refusal::ClaimDomain);
    }
    Ok(ClaimBasis::VerifiedEmail)
}

/// **Who may become a NEW employee just in time** (A34-25), per provider.
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub enum JitPolicy {
    /// Anyone the IdP signs in (the default, as before).
    #[default]
    Open,
    /// Only a person whose e-mail the IdP verified, in one of the account's
    /// proven domains.
    Domains,
    /// Nobody: the directory (SCIM, CSV) provisions every employee.
    Off,
}

impl JitPolicy {
    /// The stored word.
    pub fn as_str(self) -> &'static str {
        match self {
            JitPolicy::Open => "open",
            JitPolicy::Domains => "domains",
            JitPolicy::Off => "off",
        }
    }

    /// The policy a stored word names; anything unknown is [`JitPolicy::Off`]
    /// (a word this build does not know never opens the door wider).
    pub fn parse(word: &str) -> JitPolicy {
        match word.trim() {
            "" | "open" => JitPolicy::Open,
            "domains" => JitPolicy::Domains,
            _ => JitPolicy::Off,
        }
    }
}

/// **May this sign-in make a new employee?** (A34-25.)
pub fn jit_allowed(
    policy: JitPolicy,
    login: &Login<'_>,
    proven_domains: &[String],
) -> Result<(), Refusal> {
    match policy {
        JitPolicy::Open => Ok(()),
        JitPolicy::Off => Err(Refusal::JitOff),
        JitPolicy::Domains => {
            let email = login.email.trim().to_ascii_lowercase();
            if login.email_verified && in_domains(domain_of(&email), proven_domains) {
                Ok(())
            } else {
                Err(Refusal::JitDomain)
            }
        }
    }
}

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

    fn row(source: Source) -> Row<'static> {
        Row {
            external_id: "",
            email: "bob@acme.example",
            user_name: "bob",
            source,
            bound: false,
            rebind: false,
        }
    }

    fn proven() -> Vec<String> {
        vec!["acme.example".to_owned()]
    }

    #[test]
    fn subject_keys_bind_the_issuer() {
        assert_ne!(
            subject_key("https://a.example", "1"),
            subject_key("https://b.example", "1")
        );
        assert_eq!(
            subject_key("https://a.example/", "1"),
            subject_key("https://a.example", "1")
        );
        assert_eq!(subject_key("i", "s").len(), 64);
    }

    /// ★ **A34-2, moved: a user name alone never claims**; the admin's
    /// externalId or a verified e-mail of the row in a proven domain does.
    #[test]
    fn a_provisioned_user_is_claimed_only_on_the_two_bases() {
        let none: [String; 0] = [];
        let ids = ["sub-1".to_owned()];
        let unverified = Login {
            email: "bob@acme.example",
            email_verified: false,
            idp_ids: &ids,
        };
        assert_eq!(
            may_claim(&row(Source::Scim), &unverified, &proven()),
            Err(Refusal::ClaimUnverified)
        );
        let bound = Row {
            external_id: "sub-1",
            ..row(Source::Scim)
        };
        assert_eq!(
            may_claim(&bound, &unverified, &proven()),
            Ok(ClaimBasis::ExternalId)
        );
        let verified = Login {
            email_verified: true,
            ..unverified.clone()
        };
        assert_eq!(
            may_claim(&row(Source::Csv), &verified, &proven()),
            Ok(ClaimBasis::VerifiedEmail)
        );
        let other = Login {
            email: "eve@acme.example",
            ..verified.clone()
        };
        assert_eq!(
            may_claim(&row(Source::Csv), &other, &proven()),
            Err(Refusal::ClaimNotAddress)
        );
        assert_eq!(
            may_claim(&row(Source::Csv), &verified, &none),
            Err(Refusal::ClaimDomain),
            "an unproven domain claims nothing (A34-5)"
        );
        let no_ids = Login {
            idp_ids: &[],
            ..verified.clone()
        };
        assert_eq!(
            may_claim(
                &Row {
                    external_id: "sub-1",
                    ..row(Source::Csv)
                },
                &no_ids,
                &none
            ),
            Err(Refusal::ClaimDomain)
        );
    }

    /// ★ **An IdP migration does not lock the directory out** (A34-11): a bound
    /// or JIT-made user is claimed afresh only while its admin opened a rebind,
    /// and then on the same two bases.
    #[test]
    fn a_rebind_lets_the_new_idp_claim_a_bound_user_on_the_same_bases() {
        let ids = ["new-sub".to_owned()];
        let verified = Login {
            email: "bob@acme.example",
            email_verified: true,
            idp_ids: &ids,
        };
        let bound = Row {
            bound: true,
            ..row(Source::Scim)
        };
        assert_eq!(
            may_claim(&bound, &verified, &proven()),
            Err(Refusal::ClaimBound),
            "never re-pointed without a rebind"
        );
        let jit = Row {
            bound: true,
            ..row(Source::Jit)
        };
        assert_eq!(
            may_claim(&jit, &verified, &proven()),
            Err(Refusal::ClaimBound)
        );
        let open = Row {
            rebind: true,
            ..bound.clone()
        };
        assert_eq!(
            may_claim(&open, &verified, &proven()),
            Ok(ClaimBasis::VerifiedEmail)
        );
        let open_jit = Row {
            rebind: true,
            ..jit.clone()
        };
        assert_eq!(
            may_claim(&open_jit, &verified, &proven()),
            Ok(ClaimBasis::VerifiedEmail)
        );
        let unverified = Login {
            email_verified: false,
            ..verified.clone()
        };
        assert_eq!(
            may_claim(&open, &unverified, &proven()),
            Err(Refusal::ClaimUnverified),
            "a rebind is no free pass"
        );
        let by_admin = Row {
            external_id: "new-sub",
            ..open.clone()
        };
        assert_eq!(
            may_claim(&by_admin, &unverified, &proven()),
            Ok(ClaimBasis::ExternalId),
            "the admin names the new id"
        );
        assert_eq!(
            may_claim(
                &Row {
                    rebind: false,
                    bound: false,
                    ..row(Source::Jit)
                },
                &verified,
                &proven()
            ),
            Err(Refusal::ClaimJit)
        );
    }

    /// ★ **JIT is the provider's to limit** (A34-25).
    #[test]
    fn jit_follows_the_providers_policy() {
        let ids: [String; 0] = [];
        let verified = Login {
            email: "Ann@ACME.example",
            email_verified: true,
            idp_ids: &ids,
        };
        let unverified = Login {
            email_verified: false,
            ..verified.clone()
        };
        let stranger = Login {
            email: "ann@gmail.com",
            ..verified.clone()
        };
        assert_eq!(jit_allowed(JitPolicy::Open, &unverified, &[]), Ok(()));
        assert_eq!(
            jit_allowed(JitPolicy::Off, &verified, &proven()),
            Err(Refusal::JitOff)
        );
        assert_eq!(
            jit_allowed(JitPolicy::Domains, &verified, &proven()),
            Ok(())
        );
        assert_eq!(
            jit_allowed(JitPolicy::Domains, &unverified, &proven()),
            Err(Refusal::JitDomain)
        );
        assert_eq!(
            jit_allowed(JitPolicy::Domains, &stranger, &proven()),
            Err(Refusal::JitDomain)
        );
        assert_eq!(JitPolicy::parse(""), JitPolicy::Open);
        assert_eq!(JitPolicy::parse("domains"), JitPolicy::Domains);
        assert_eq!(
            JitPolicy::parse("someday"),
            JitPolicy::Off,
            "an unknown word never opens the door"
        );
        for p in [JitPolicy::Open, JitPolicy::Domains, JitPolicy::Off] {
            assert_eq!(JitPolicy::parse(p.as_str()), p);
        }
    }
}