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