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