crates/gunnar-core/tests/sso_directory.rs
//! **Company SSO, the stored rules** (owner, 2026-10-05). What the identity
//! table itself promises, under every protocol and every door:
//!
//! * a first login creates a NEW employee of THAT account — an existing
//! principal is never attached, whatever its name;
//! * a subject is bound per account: the same IdP subject in another account
//! is another person there, never the first account's employee;
//! * a provisioned user (SCIM/CSV) is claimed by its directory name once, and
//! never re-pointed to another subject;
//! * deactivation gates the employee and ends its web sessions; reactivation
//! lifts only the directory's own gate; data is kept;
//! * the account admin is never an employee (the break-glass stays theirs).
mod common;
use common::{Fixture, KAT_ED25519, OTHER_ED25519};
use gunnar_core::sso::{self, SsoProtocol, SsoProvider, SsoSource};
use gunnar_core::{
Level, Principal, PrincipalKind, RoomAsk, Scope, SsoImportOp, SsoImportRow, SsoLogin,
SsoProvision, SsoUserPatch,
};
const NOW: i64 = 1_800_000_000_000;
fn fixture_with_account(account: &str) -> Fixture {
let f = Fixture::new();
f.catalog.create_namespace(account).expect("namespace");
f.auth
.add_principal(Principal::new(account, account, PrincipalKind::User).unwrap())
.expect("account principal");
f.auth
.set_sso_provider(provider(account, &[]))
.expect("provider");
f
}
fn provider(account: &str, domains: &[&str]) -> SsoProvider {
SsoProvider {
account: account.to_owned(),
protocol: SsoProtocol::Oidc,
display_name: format!("{account} corp"),
domains: domains.iter().map(|d| d.to_string()).collect(),
oidc: Some(sso::OidcSettings {
issuer: "https://idp.example.test".into(),
client_id: "c".into(),
..Default::default()
}),
saml: None,
require_sso: false,
require_passkey: false,
console_origin: "https://gunnar.example".into(),
updated: NOW,
updated_by: account.to_owned(),
verified_domains: Vec::new(),
jit: String::new(),
}
}
fn candidates(account: &str, user: &str) -> Vec<Principal> {
sso::principal_id_candidates(account, user)
.into_iter()
.map(|id| Principal::new(&id, &id, PrincipalKind::User).unwrap())
.collect()
}
fn login(account: &str, subject: &str, user: &str, session: Option<&str>) -> SsoLogin {
SsoLogin {
account: account.to_owned(),
subject: sso::subject_key("https://idp.example.test", subject),
user_name: user.to_owned(),
display_name: String::new(),
email: user.to_owned(),
email_verified: false,
idp_subjects: vec![subject.to_owned()],
candidates: candidates(account, user),
room: RoomAsk {
room_bytes: 10 << 30,
..RoomAsk::default()
},
session: session.map(|k| (k.to_owned(), NOW + 3_600_000)),
now: NOW,
}
}
/// ★ First login: a NEW employee of the account, with the account's Read
/// grant and no namespace of its own; the second login is the same person.
#[test]
fn a_first_login_creates_a_new_employee_and_the_second_finds_it() {
let f = fixture_with_account("acme");
let first = f
.auth
.sso_login(&login(
"acme",
"00u1",
"bob@acme.example",
Some(KAT_ED25519),
))
.expect("first login");
assert!(first.created && !first.claimed);
assert_eq!(first.principal.id, "acme.bob");
assert_eq!(
f.auth.employer_of("acme.bob").unwrap().as_deref(),
Some("acme")
);
let grants = f.auth.grants(Some("acme.bob")).unwrap();
assert_eq!(grants.len(), 1);
assert_eq!(grants[0].scope, Scope::Namespace("acme".into()));
assert_eq!(grants[0].level, Level::Read);
let session = first.session.expect("a session key");
assert_eq!(session.principal_id, "acme.bob");
assert_eq!(session.expires, Some(NOW + 3_600_000));
assert!(f
.auth
.keys_of("acme.bob")
.unwrap()
.iter()
.any(|k| k.fingerprint_sha256 == session.fingerprint_sha256));
let again = f
.auth
.sso_login(&login(
"acme",
"00u1",
"bob@acme.example",
Some(OTHER_ED25519),
))
.expect("second login");
assert!(!again.created && !again.claimed);
assert_eq!(again.principal.id, "acme.bob");
assert_eq!(again.user.sessions.len(), 2);
}
/// ★ **An existing principal is never attached.** A user `acme.bob` that
/// exists on the server (not an employee, somebody else entirely) is passed
/// over: the login creates a NEW employee under the next id.
#[test]
fn an_existing_principal_is_never_attached() {
let f = fixture_with_account("acme");
f.auth
.add_principal(Principal::new("acme.bob", "acme.bob", PrincipalKind::User).unwrap())
.expect("a stranger named acme.bob");
let done = f
.auth
.sso_login(&login("acme", "00u1", "bob@acme.example", None))
.expect("login");
assert!(done.created);
assert_eq!(done.principal.id, "acme.bob-2");
assert_eq!(
f.auth.employer_of("acme.bob").unwrap(),
None,
"the stranger became an employee"
);
assert!(f.auth.sso_user("acme.bob").unwrap().is_none());
}
/// ★ **Never across accounts.** The same IdP subject logging into another
/// account is a NEW employee THERE; nothing of the first account's employee
/// is reachable from the second account's IdP.
#[test]
fn a_subject_of_one_account_is_never_another_accounts_employee() {
let f = fixture_with_account("acme");
f.catalog.create_namespace("globex").unwrap();
f.auth
.add_principal(Principal::new("globex", "globex", PrincipalKind::User).unwrap())
.unwrap();
f.auth.set_sso_provider(provider("globex", &[])).unwrap();
let a = f
.auth
.sso_login(&login("acme", "same-sub", "bob@x.example", None))
.unwrap();
let g = f
.auth
.sso_login(&login("globex", "same-sub", "bob@x.example", None))
.unwrap();
assert_ne!(a.principal.id, g.principal.id);
assert_eq!(
f.auth.employer_of(&g.principal.id).unwrap().as_deref(),
Some("globex")
);
assert_eq!(
f.auth.employer_of(&a.principal.id).unwrap().as_deref(),
Some("acme")
);
// An account with no identity provider has no SSO login at all.
f.catalog.create_namespace("initech").unwrap();
f.auth
.add_principal(Principal::new("initech", "initech", PrincipalKind::User).unwrap())
.unwrap();
let err = f
.auth
.sso_login(&login("initech", "same-sub", "bob@x.example", None))
.unwrap_err();
assert!(err.to_string().contains("no identity provider"), "{err}");
}
/// A provisioned user is claimed by its directory name ONCE; another subject
/// presenting the same name later is refused, never re-pointed.
#[test]
fn a_provisioned_user_is_claimed_once_and_never_repointed() {
let f = fixture_with_account("acme");
let user = f
.auth
.sso_provision(&SsoProvision {
account: "acme".into(),
user_name: "carol@acme.example".into(),
display_name: "Carol".into(),
email: "carol@acme.example".into(),
external_id: "ext-1".into(),
active: true,
source: SsoSource::Scim,
candidates: candidates("acme", "carol@acme.example"),
room: RoomAsk {
room_bytes: 10 << 30,
..RoomAsk::default()
},
actor: "acme".into(),
now: NOW,
})
.expect("provisioned");
assert_eq!(user.principal_id, "acme.carol");
assert!(user.subject.is_none());
// The admin's binding: the row's externalId is carol's id at the IdP
// (A34-2: a user name alone never claims).
let claimed = f
.auth
.sso_login(&login("acme", "ext-1", "carol@acme.example", None))
.expect("claim");
assert!(claimed.claimed && !claimed.created);
assert_eq!(claimed.principal.id, "acme.carol");
let err = f
.auth
.sso_login(&login("acme", "sub-mallory", "carol@acme.example", None))
.unwrap_err();
assert!(err.to_string().contains("never re-pointed"), "{err}");
// A second provisioning of the same name is SCIM's `uniqueness`.
let dup = f.auth.sso_provision(&SsoProvision {
account: "acme".into(),
user_name: "carol@acme.example".into(),
display_name: String::new(),
email: String::new(),
external_id: String::new(),
active: true,
source: SsoSource::Csv,
candidates: candidates("acme", "carol@acme.example"),
room: RoomAsk::default(),
actor: "acme".into(),
now: NOW,
});
assert!(
matches!(dup, Err(gunnar_core::Error::NameExists { .. })),
"{dup:?}"
);
}
/// ★ Deactivation: gated (push refused — `may` says no anywhere), every web
/// session key revoked, data kept; the login is refused. Reactivation lifts
/// the directory's own gate and nobody else's.
#[test]
fn deactivation_gates_revokes_sessions_and_reactivation_lifts_only_its_own_gate() {
let f = fixture_with_account("acme");
let repo = f.repo("acme", "app");
let done = f
.auth
.sso_login(&login(
"acme",
"00u1",
"bob@acme.example",
Some(KAT_ED25519),
))
.unwrap();
let bob = done.principal.id.clone();
f.auth
.grant(gunnar_core::Grant {
principal: bob.clone(),
scope: Scope::Repo(repo.path()),
level: Level::Write,
})
.unwrap();
assert!(f.auth.may(&bob, &repo, Level::Write).unwrap());
let (user, changed) = f
.auth
.sso_user_update(
"acme",
&bob,
&SsoUserPatch {
active: Some(false),
..Default::default()
},
"scim",
NOW,
)
.expect("deactivate");
assert!(changed && !user.active && user.sessions.is_empty());
assert!(
!f.auth.may(&bob, &repo, Level::Write).unwrap(),
"a deactivated employee can still push"
);
assert!(!f.auth.may(&bob, &repo, Level::Read).unwrap());
assert!(
f.auth.keys_of(&bob).unwrap().is_empty(),
"a session key survived the deactivation"
);
assert!(
f.auth.principal(&bob).unwrap().is_some(),
"the employee itself is kept"
);
let err = f
.auth
.sso_login(&login(
"acme",
"00u1",
"bob@acme.example",
Some(OTHER_ED25519),
))
.unwrap_err();
assert!(err.to_string().contains("deactivated"), "{err}");
let (user, changed) = f
.auth
.sso_user_update(
"acme",
&bob,
&SsoUserPatch {
active: Some(true),
..Default::default()
},
"scim",
NOW,
)
.unwrap();
assert!(changed && user.active);
assert!(f.auth.may(&bob, &repo, Level::Write).unwrap());
// An operator's suspension is not the directory's to lift.
f.auth
.gate(gunnar_core::AccountGate::administrative(
&bob,
"operator",
"under investigation",
))
.unwrap();
f.auth
.sso_user_update(
"acme",
&bob,
&SsoUserPatch {
active: Some(false),
..Default::default()
},
"scim",
NOW,
)
.unwrap();
f.auth
.sso_user_update(
"acme",
&bob,
&SsoUserPatch {
active: Some(true),
..Default::default()
},
"scim",
NOW,
)
.unwrap();
let gate = f
.auth
.gate_of(&bob)
.unwrap()
.expect("the operator's gate stays");
assert_eq!(gate.holder, gunnar_core::ADMINISTRATIVE_HOLDER);
}
/// SCIM DELETE: deactivated and unlisted, the employee kept; the name is free
/// for a NEW person, never for the removed one.
#[test]
fn a_deleted_user_is_kept_gated_and_its_name_goes_to_a_new_employee() {
let f = fixture_with_account("acme");
let done = f
.auth
.sso_login(&login("acme", "00u1", "bob@acme.example", None))
.unwrap();
let removed = f
.auth
.sso_user_delete("acme", &done.principal.id, "scim", NOW)
.unwrap();
assert!(removed.deleted && !removed.active);
assert!(f.auth.gate_of(&done.principal.id).unwrap().is_some());
let err = f
.auth
.sso_login(&login("acme", "00u1", "bob@acme.example", None))
.unwrap_err();
assert!(err.to_string().contains("deactivated"), "{err}");
let fresh = f
.auth
.sso_login(&login("acme", "00u2", "bob@acme.example", None))
.unwrap();
assert!(fresh.created);
assert_ne!(fresh.principal.id, done.principal.id);
}
/// The account admin is not an employee; an employee cannot configure an
/// identity provider; a domain leads to one account only — the one that PROVED
/// it (A34-5): a typed domain leads nowhere and blocks nobody.
#[test]
fn providers_are_accounts_and_domains_are_unique() {
let f = fixture_with_account("acme");
f.auth
.set_sso_provider(provider("acme", &["acme.example"]))
.unwrap();
assert_eq!(
f.auth.sso_account_for_domain("acme.example").unwrap(),
None,
"a typed domain routed"
);
let proven = f
.auth
.verify_sso_domain("acme", "acme.example")
.expect("proven");
assert_eq!(proven.verified_domains, vec!["acme.example".to_owned()]);
assert_eq!(
f.auth
.sso_account_for_domain("acme.example")
.unwrap()
.as_deref(),
Some("acme")
);
let err = f
.auth
.verify_sso_domain("acme", "other.example")
.unwrap_err();
assert!(err.to_string().contains("[sso-domain-unknown]"), "{err}");
let done = f
.auth
.sso_login(&login("acme", "00u1", "bob@acme.example", None))
.unwrap();
let err = f
.auth
.set_sso_provider(provider(&done.principal.id, &[]))
.unwrap_err();
assert!(err.to_string().contains("employee"), "{err}");
f.catalog.create_namespace("globex").unwrap();
f.auth
.add_principal(Principal::new("globex", "globex", PrincipalKind::User).unwrap())
.unwrap();
f.auth
.set_sso_provider(provider("globex", &["acme.example"]))
.expect("naming a domain squats nothing");
let err = f
.auth
.verify_sso_domain("globex", "acme.example")
.unwrap_err();
assert!(err.to_string().contains("[sso-domain-taken]"), "{err}");
f.auth.remove_sso_provider("globex").unwrap();
assert_eq!(
f.auth
.sso_account_for_domain("acme.example")
.unwrap()
.as_deref(),
Some("acme"),
"globex's removal took acme's domain"
);
// Re-saving keeps a proof of a domain still named, and drops the others.
f.auth
.set_sso_provider(provider("acme", &["acme.example", "acme.test"]))
.unwrap();
assert_eq!(
f.auth
.sso_account_for_domain("acme.example")
.unwrap()
.as_deref(),
Some("acme")
);
f.auth
.set_sso_provider(provider("acme", &["acme.test"]))
.unwrap();
assert_eq!(
f.auth.sso_account_for_domain("acme.example").unwrap(),
None,
"the old domain still leads to acme"
);
assert!(
f.auth.employer_of("acme").unwrap().is_none(),
"the account admin became an employee"
);
}
/// A JIT employee takes an open seat first, and is refused when the account
/// is out of room — nothing is written.
#[test]
fn a_new_employee_takes_a_seat_or_room_and_a_short_account_creates_nobody() {
let f = fixture_with_account("acme");
// 10 GiB of room in all: one user of 10 GiB fits, the second does not.
let mut l = login("acme", "a", "ann@acme.example", None);
l.room = RoomAsk {
room_bytes: 10 << 30,
cap: Some(10 << 30),
stored: 0,
..RoomAsk::default()
};
f.auth.sso_login(&l).expect("one fits");
let mut l2 = login("acme", "b", "ben@acme.example", None);
l2.room = l.room.clone();
let err = f.auth.sso_login(&l2).unwrap_err();
assert!(
matches!(err, gunnar_core::Error::RoomShort { .. }),
"{err:?}"
);
assert!(f.auth.sso_user("acme.ben").unwrap().is_none());
assert!(f.auth.principal("acme.ben").unwrap().is_none());
}
/// Removing an employee takes its directory row and its subject with it, so
/// the next principal of that id is never logged in by the old subject.
#[test]
fn removing_the_employee_unbinds_its_subject() {
let f = fixture_with_account("acme");
let done = f
.auth
.sso_login(&login("acme", "00u1", "bob@acme.example", None))
.unwrap();
f.auth.remove_principal(&done.principal.id).unwrap();
assert!(f.auth.sso_user(&done.principal.id).unwrap().is_none());
let subject = sso::subject_key("https://idp.example.test", "00u1");
assert!(f
.auth
.sso_principal_for_subject("acme", &subject)
.unwrap()
.is_none());
}
/// ★ A34-2: **a user name alone never claims a provisioned user.** A login
/// that offers no stable IdP id equal to the row's `externalId` and no
/// verified e-mail is refused by name, and the row stays unbound; before,
/// the first subject whose user name matched took the employee over.
#[test]
fn a34_2_a_matching_user_name_alone_never_claims_a_provisioned_user() {
let f = fixture_with_account("acme");
f.auth
.sso_provision(&SsoProvision {
account: "acme".into(),
user_name: "dora@acme.example".into(),
display_name: "Dora".into(),
email: "dora@acme.example".into(),
external_id: "ext-dora".into(),
active: true,
source: SsoSource::Scim,
candidates: candidates("acme", "dora@acme.example"),
room: RoomAsk {
room_bytes: 10 << 30,
..RoomAsk::default()
},
actor: "acme".into(),
now: NOW,
})
.expect("provisioned");
let err = f
.auth
.sso_login(&login("acme", "sub-mallory", "dora@acme.example", None))
.unwrap_err();
assert!(err.to_string().contains("claim"), "refused by name: {err}");
assert!(
f.auth
.sso_user("acme.dora")
.unwrap()
.unwrap()
.subject
.is_none(),
"the row was bound"
);
}
/// Provision `user_name` (its address too) into acme by SCIM, `external_id`
/// as given; the row.
fn provision_into_acme(f: &Fixture, user_name: &str, external_id: &str) -> sso::SsoUser {
f.auth
.sso_provision(&SsoProvision {
account: "acme".into(),
user_name: user_name.into(),
display_name: String::new(),
email: user_name.into(),
external_id: external_id.into(),
active: true,
source: SsoSource::Scim,
candidates: candidates("acme", user_name),
room: RoomAsk {
room_bytes: 10 << 30,
..RoomAsk::default()
},
actor: "acme".into(),
now: NOW,
})
.expect("provisioned")
}
/// A login whose IdP ASSERTS the e-mail is verified (`email_verified=true`).
fn verified_login(account: &str, subject: &str, user: &str) -> SsoLogin {
SsoLogin {
email_verified: true,
..login(account, subject, user, None)
}
}
/// ★ A34-2: **an unverified e-mail never claims**, even when it is the
/// row's address and in the account's domain.
#[test]
fn a34_2_an_unverified_email_never_claims() {
let f = fixture_with_account("acme");
f.auth
.set_sso_provider(provider("acme", &["acme.example"]))
.unwrap();
let row = provision_into_acme(&f, "erin@acme.example", "hr-1");
let err = f
.auth
.sso_login(&login("acme", "sub-erin", "erin@acme.example", None))
.unwrap_err();
assert!(
err.to_string().contains("verified"),
"refused by name: {err}"
);
assert!(f
.auth
.sso_user(&row.principal_id)
.unwrap()
.unwrap()
.subject
.is_none());
}
/// ★ A34-2: **a verified e-mail in one of the account's domains claims**,
/// and the binding says how it was made.
#[test]
fn a34_2_a_verified_email_in_a_claimed_domain_claims() {
let f = fixture_with_account("acme");
f.auth
.set_sso_provider(provider("acme", &["acme.example"]))
.unwrap();
f.auth
.verify_sso_domain("acme", "acme.example")
.expect("proven");
let row = provision_into_acme(&f, "erin@acme.example", "hr-1");
let done = f
.auth
.sso_login(&verified_login("acme", "sub-erin", "erin@acme.example"))
.expect("a verified e-mail in acme.example claims");
assert!(done.claimed && !done.created);
assert_eq!(done.claimed_by, Some("verified_email"));
assert_eq!(done.principal.id, row.principal_id);
}
/// ★ A34-2: **a verified e-mail outside the account's domains never claims.**
#[test]
fn a34_2_a_verified_email_outside_the_accounts_domains_never_claims() {
let f = fixture_with_account("acme");
f.auth
.set_sso_provider(provider("acme", &["acme.example"]))
.unwrap();
let row = provision_into_acme(&f, "erin@contractor.mock.invalid", "hr-1");
let err = f
.auth
.sso_login(&verified_login(
"acme",
"sub-erin",
"erin@contractor.mock.invalid",
))
.unwrap_err();
assert!(err.to_string().contains("domain"), "refused by name: {err}");
assert!(f
.auth
.sso_user(&row.principal_id)
.unwrap()
.unwrap()
.subject
.is_none());
}
/// ★ A34-2: **the admin's binding claims**: the row's `externalId` is the
/// IdP's subject, with no e-mail rule at all.
#[test]
fn a34_2_the_admins_external_id_binding_claims() {
let f = fixture_with_account("acme");
let row = provision_into_acme(&f, "erin@acme.example", "sub-erin");
let done = f
.auth
.sso_login(&login("acme", "sub-erin", "erin@acme.example", None))
.expect("externalId == the IdP subject claims");
assert_eq!(done.claimed_by, Some("external_id"));
assert_eq!(done.principal.id, row.principal_id);
}
/// ★ A34-2: **a second identity cannot steal a bound user**, not even with
/// a verified e-mail in the account's domain.
#[test]
fn a34_2_a_second_identity_cannot_steal_a_bound_user() {
let f = fixture_with_account("acme");
f.auth
.set_sso_provider(provider("acme", &["acme.example"]))
.unwrap();
f.auth
.verify_sso_domain("acme", "acme.example")
.expect("proven");
let row = provision_into_acme(&f, "erin@acme.example", "hr-1");
f.auth
.sso_login(&verified_login("acme", "sub-erin", "erin@acme.example"))
.expect("claimed");
let err = f
.auth
.sso_login(&verified_login("acme", "sub-thief", "erin@acme.example"))
.unwrap_err();
assert!(err.to_string().contains("never re-pointed"), "{err}");
assert_eq!(
f.auth.sso_user(&row.principal_id).unwrap().unwrap().subject,
Some(sso::subject_key("https://idp.example.test", "sub-erin"))
);
}
fn provision_of(account: &str, user: &str) -> SsoProvision {
SsoProvision {
account: account.into(),
user_name: user.into(),
display_name: String::new(),
email: String::new(),
external_id: String::new(),
active: true,
source: SsoSource::Csv,
candidates: candidates(account, user),
room: RoomAsk {
room_bytes: 10 << 30,
..RoomAsk::default()
},
actor: "csv:acme".into(),
now: NOW,
}
}
fn create(line: usize, user: &str) -> SsoImportRow {
SsoImportRow {
label: format!("line {line}: {user}"),
op: SsoImportOp::Create(provision_of("acme", user)),
}
}
/// ★ **A34-17: a whole import is ONE write, all or nothing.** A row the
/// table refuses (here: an update of a person the directory does not hold)
/// leaves every earlier row unapplied, and the refusal names that row. A dry
/// run runs the same rows on a copy and writes nothing.
#[test]
fn an_import_is_one_write_all_or_nothing() {
let f = fixture_with_account("acme");
let bad = vec![
create(2, "ann@acme.example"),
create(3, "ben@acme.example"),
SsoImportRow {
label: "line 4: gone@acme.example".into(),
op: SsoImportOp::Update {
principal_id: "acme.gone".into(),
patch: SsoUserPatch {
active: Some(false),
..SsoUserPatch::default()
},
},
},
];
let (label, _) = f
.auth
.sso_import("acme", &bad, "csv:acme", NOW, true)
.unwrap_err();
assert_eq!(label, "line 4: gone@acme.example");
assert!(
f.auth.sso_users_of("acme").unwrap().is_empty(),
"a refused import applied rows"
);
let good = vec![create(2, "ann@acme.example"), create(3, "ben@acme.example")];
f.auth
.sso_import("acme", &good, "csv:acme", NOW, false)
.expect("a dry run");
assert!(
f.auth.sso_users_of("acme").unwrap().is_empty(),
"a dry run wrote"
);
let (label, _) = f
.auth
.sso_import("acme", &bad, "csv:acme", NOW, false)
.unwrap_err();
assert_eq!(
label, "line 4: gone@acme.example",
"the dry run sees the same refusal"
);
f.auth
.sso_import("acme", &good, "csv:acme", NOW, true)
.expect("the import");
let mut ids: Vec<String> = f
.auth
.sso_users_of("acme")
.unwrap()
.into_iter()
.map(|u| u.principal_id)
.collect();
ids.sort();
assert_eq!(ids, vec!["acme.ann".to_owned(), "acme.ben".to_owned()]);
}
/// ★ **A34-42: the 10th person with the same local part gets an id.** The
/// numbered ids stop at `-9`; the next take a suffix derived from the whole
/// user name.
#[test]
fn ten_people_with_the_same_local_part_each_get_an_id() {
let f = fixture_with_account("acme");
let mut ids = Vec::new();
for n in 1..=11 {
let user = format!("bob@site{n}.example");
ids.push(
f.auth
.sso_provision(&provision_of("acme", &user))
.expect("provisioned")
.principal_id,
);
}
assert_eq!(ids[0], "acme.bob");
assert_eq!(ids[8], "acme.bob-9");
assert!(
ids[9].starts_with("acme.bob-") && ids[9].len() == "acme.bob-".len() + 6,
"{ids:?}"
);
let unique: std::collections::BTreeSet<_> = ids.iter().collect();
assert_eq!(unique.len(), 11, "{ids:?}");
}
/// ★ A34-11: **a rebind unbinds the old subject and lets the next sign-in bind
/// the same employee**, on the claim rules; an externalId names its new id.
#[test]
fn a34_11_a_rebind_binds_the_same_employee_afresh() {
let f = fixture_with_account("acme");
f.auth
.set_sso_provider(provider("acme", &["acme.example"]))
.unwrap();
f.auth
.verify_sso_domain("acme", "acme.example")
.expect("proven");
let first = f
.auth
.sso_login(&login("acme", "old-sub", "erin@acme.example", None))
.expect("JIT");
let id = first.principal.id.clone();
let err = f
.auth
.sso_login(&verified_login("acme", "new-sub", "erin@acme.example"))
.unwrap_err();
assert!(err.to_string().contains("[sso-claim-bound]"), "{err}");
let opened = f
.auth
.sso_user_rebind("acme", std::slice::from_ref(&id), None)
.expect("rebind");
assert!(opened[0].rebind && opened[0].subject.is_none());
let again = f
.auth
.sso_login(&verified_login("acme", "new-sub", "erin@acme.example"))
.expect("rebound");
assert_eq!(again.principal.id, id);
assert!(again.claimed && !again.created);
let row = f.auth.sso_user(&id).unwrap().unwrap();
assert!(!row.rebind);
assert_eq!(
row.subject,
Some(sso::subject_key("https://idp.example.test", "new-sub"))
);
// Everyone at once (an issuer migration), and an externalId only for one.
assert!(f
.auth
.sso_user_rebind("acme", &[id.clone(), "x".into()], Some("z"))
.is_err());
let all = f
.auth
.sso_user_rebind("acme", &[], None)
.expect("all bound");
assert_eq!(all.len(), 1);
}
/// ★ A34-25: **JIT is the provider's**: `off` makes nobody, `domains` only a
/// verified e-mail in a proven domain.
#[test]
fn a34_25_jit_follows_the_providers_policy() {
let f = fixture_with_account("acme");
let mut p = provider("acme", &["acme.example"]);
p.jit = "off".into();
f.auth.set_sso_provider(p.clone()).unwrap();
f.auth
.verify_sso_domain("acme", "acme.example")
.expect("proven");
let err = f
.auth
.sso_login(&verified_login("acme", "s1", "ann@acme.example"))
.unwrap_err();
assert!(err.to_string().contains("[sso-jit-off]"), "{err}");
p.jit = "domains".into();
f.auth.set_sso_provider(p).unwrap();
let err = f
.auth
.sso_login(&login("acme", "s1", "ann@acme.example", None))
.unwrap_err();
assert!(err.to_string().contains("[sso-jit-domain]"), "{err}");
assert!(
f.auth
.sso_login(&verified_login("acme", "s1", "ann@acme.example"))
.expect("verified, proven")
.created
);
}