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/refusal.rs

//! **Every SSO refusal has a code** (A34-46): a console maps the code to its
//! own words and never matches a sentence. A refusal travels as
//! `[<code>] <sentence>` (the form `[stepup]` and Gunnar's claim refusals
//! already use); [`code_of`] reads the code back out of any message.
//!
//! No refusal names a person: no e-mail, user name, domain or id is ever in
//! its sentence (RA4-2), because it is answered to an unauthenticated caller
//! and written into append-only audit chains.

/// An SSO refusal, by its stable code.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
#[non_exhaustive]
pub enum Refusal {
    /// The operator has not turned SSO's network on (A34-16).
    EgressOff,
    /// The build or product wired no network.
    NoEgress,
    /// This client has too many sign-ins open or failing (A34-6).
    Busy,
    /// The sign-in expired or was already used.
    Expired,
    /// The sign-in came back to another console.
    OtherConsole,
    /// The account's provider changed while the sign-in was out.
    ProviderChanged,
    /// The identity provider refused or answered nothing usable.
    IdpRefused,
    /// The identity provider named nobody.
    NamedNobody,
    /// The person is deactivated in the account's directory.
    Deactivated,
    /// The person is locked out (a gate).
    Locked,
    /// This identity is bound to an employee outside the account.
    SubjectElsewhere,
    /// A provisioned employee may not be claimed without a verified e-mail or
    /// the admin's externalId (A34-2).
    ClaimUnverified,
    /// The verified e-mail is not the employee's address.
    ClaimNotAddress,
    /// The verified e-mail's domain is not one of the account's proven domains.
    ClaimDomain,
    /// The user named is not in the account's directory.
    ClaimNotMember,
    /// The directory user is bound to another identity (and no rebind is open).
    ClaimBound,
    /// The directory user was created by another sign-in (and no rebind is open).
    ClaimJit,
    /// The account takes no new employee from its IdP (JIT off, A34-25).
    JitOff,
    /// A new employee must have a verified e-mail in a proven domain (A34-25).
    JitDomain,
    /// A public mail domain (gmail.com, …) leads to no company (A34-5).
    DomainPublic,
    /// The domain's DNS proof is not there (A34-5).
    DomainUnproven,
    /// The DNS proof could not be read (A34-5).
    DomainResolver,
    /// Another account proved the domain first (A34-5).
    DomainTaken,
    /// The domain is not one of the provider's.
    DomainUnknown,
    /// The passkey after the IdP was refused.
    SecondFactor,
}

impl Refusal {
    /// Every refusal, for a console mapping its words.
    pub const ALL: [Refusal; 25] = [
        Refusal::EgressOff,
        Refusal::NoEgress,
        Refusal::Busy,
        Refusal::Expired,
        Refusal::OtherConsole,
        Refusal::ProviderChanged,
        Refusal::IdpRefused,
        Refusal::NamedNobody,
        Refusal::Deactivated,
        Refusal::Locked,
        Refusal::SubjectElsewhere,
        Refusal::ClaimUnverified,
        Refusal::ClaimNotAddress,
        Refusal::ClaimDomain,
        Refusal::ClaimNotMember,
        Refusal::ClaimBound,
        Refusal::ClaimJit,
        Refusal::JitOff,
        Refusal::JitDomain,
        Refusal::DomainPublic,
        Refusal::DomainUnproven,
        Refusal::DomainResolver,
        Refusal::DomainTaken,
        Refusal::DomainUnknown,
        Refusal::SecondFactor,
    ];

    /// The stable code.
    pub fn code(self) -> &'static str {
        match self {
            Refusal::EgressOff => "sso-egress-off",
            Refusal::NoEgress => "sso-no-egress",
            Refusal::Busy => "sso-busy",
            Refusal::Expired => "sso-expired",
            Refusal::OtherConsole => "sso-other-console",
            Refusal::ProviderChanged => "sso-provider-changed",
            Refusal::IdpRefused => "sso-idp-refused",
            Refusal::NamedNobody => "sso-named-nobody",
            Refusal::Deactivated => "sso-deactivated",
            Refusal::Locked => "sso-locked",
            Refusal::SubjectElsewhere => "sso-subject-elsewhere",
            Refusal::ClaimUnverified => "sso-claim-unverified",
            Refusal::ClaimNotAddress => "sso-claim-not-address",
            Refusal::ClaimDomain => "sso-claim-domain",
            Refusal::ClaimNotMember => "sso-claim-not-member",
            Refusal::ClaimBound => "sso-claim-bound",
            Refusal::ClaimJit => "sso-claim-jit",
            Refusal::JitOff => "sso-jit-off",
            Refusal::JitDomain => "sso-jit-domain",
            Refusal::DomainPublic => "sso-domain-public",
            Refusal::DomainUnproven => "sso-domain-unproven",
            Refusal::DomainResolver => "sso-domain-resolver",
            Refusal::DomainTaken => "sso-domain-taken",
            Refusal::DomainUnknown => "sso-domain-unknown",
            Refusal::SecondFactor => "sso-second-factor",
        }
    }

    /// The crate's plain sentence (a console shows its own words instead).
    pub fn sentence(self) -> &'static str {
        match self {
            Refusal::EgressOff => {
                "this server reaches no identity provider: its operator has not turned single sign-on's network on (airgap)"
            }
            Refusal::NoEgress => "this server reaches no identity provider (no egress is wired into this build)",
            Refusal::Busy => "too many sign-ins from this address; try again in a minute",
            Refusal::Expired => "this sign-in expired or was already used; start again",
            Refusal::OtherConsole => "the sign-in came back to another console",
            Refusal::ProviderChanged => "the account's identity provider changed while the sign-in was out",
            Refusal::IdpRefused => "the identity provider refused the sign-in",
            Refusal::NamedNobody => "the identity provider named nobody",
            Refusal::Deactivated => "this person is deactivated in the account's directory",
            Refusal::Locked => "this person is locked out",
            Refusal::SubjectElsewhere => {
                "this identity is bound to an employee outside this account's directory"
            }
            Refusal::ClaimUnverified => {
                "this sign-in cannot claim a provisioned employee: the identity provider did not assert a verified \
                 e-mail (email_verified), and the employee's externalId is not the person's id there"
            }
            Refusal::ClaimNotAddress => {
                "this sign-in cannot claim a provisioned employee: the verified e-mail is not the employee's address"
            }
            Refusal::ClaimDomain => {
                "this sign-in cannot claim a provisioned employee: the verified e-mail's domain is not one of the \
                 account's proven e-mail domains"
            }
            Refusal::ClaimNotMember => "this sign-in's user is not in the account's directory",
            Refusal::ClaimBound => {
                "this directory user is already bound to another identity at the IdP and is never re-pointed by a \
                 sign-in; the account admin opens a rebind for it first"
            }
            Refusal::ClaimJit => {
                "this directory user was created by another sign-in; the account admin opens a rebind for it first"
            }
            Refusal::JitOff => "this account takes no new employee from its identity provider; ask its administrator",
            Refusal::JitDomain => {
                "a new employee needs a verified e-mail in one of the account's proven domains"
            }
            Refusal::DomainPublic => "a public mail domain leads to no company",
            Refusal::DomainUnproven => "the domain's DNS proof (its TXT record) is not there yet",
            Refusal::DomainResolver => "the domain's DNS proof could not be read",
            Refusal::DomainTaken => "another account proved this domain first",
            Refusal::DomainUnknown => "the domain is not one of this provider's",
            Refusal::SecondFactor => "the passkey after the identity provider was refused",
        }
    }

    /// `[<code>] <sentence>`.
    pub fn message(self) -> String {
        format!("[{}] {}", self.code(), self.sentence())
    }

    /// `[<code>] <sentence>: <detail>` — a detail that names nobody.
    pub fn with(self, detail: &str) -> String {
        format!("{}: {detail}", self.message())
    }

    /// The refusal a code names.
    pub fn from_code(code: &str) -> Option<Refusal> {
        Refusal::ALL.iter().copied().find(|r| r.code() == code)
    }
}

impl std::fmt::Display for Refusal {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        f.write_str(&self.message())
    }
}

/// **The code at the head of a refusal's message** (`[sso-busy] …` → `sso-busy`),
/// wherever it sits after a prefix a transport added (`single sign-on refused:
/// [sso-claim-domain] …`). `None` when the message carries no code.
pub fn code_of(message: &str) -> Option<&str> {
    let mut rest = message;
    while let Some(open) = rest.find('[') {
        let after = &rest[open + 1..];
        let close = after.find(']')?;
        let code = &after[..close];
        if !code.is_empty()
            && code
                .bytes()
                .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-')
        {
            return Some(code);
        }
        rest = &after[close..];
    }
    None
}

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

    /// ★ **A refusal is read by its code, never by its sentence** (A34-46).
    #[test]
    fn every_refusal_has_one_code_and_reads_back() {
        let mut seen = std::collections::BTreeSet::new();
        for r in Refusal::ALL {
            assert!(r.code().starts_with("sso-"), "{r:?}");
            assert!(seen.insert(r.code()), "{} twice", r.code());
            assert_eq!(code_of(&r.message()), Some(r.code()));
            assert_eq!(Refusal::from_code(r.code()), Some(r));
            assert!(
                !r.sentence().contains('@'),
                "a sentence names a person: {r:?}"
            );
        }
        assert_eq!(
            code_of("single sign-on refused: [sso-claim-domain] the verified …"),
            Some("sso-claim-domain")
        );
        assert_eq!(code_of("[stepup] needs a touch"), Some("stepup"));
        assert_eq!(
            code_of("the identity provider said \"[x y]\" then [sso-busy] wait"),
            Some("sso-busy")
        );
        assert_eq!(code_of("no code here"), None);
        assert_eq!(code_of("[] [A] ["), None);
        assert_eq!(
            Refusal::Busy.with("try later"),
            "[sso-busy] too many sign-ins from this address; try again in a minute: try later"
        );
    }
}