crates/gunnar-core/tests/principal_identity.rs
//! **A principal's id is not its name.**
//!
//! The id is the account: chosen once, keyed on everywhere, never moved. The
//! name is a label: unique, and free to change without a key, a grant, a
//! membership, a gate or the namespace noticing. This file proves both halves
//! on the applied table, and proves the refusals red before the acceptances.
//!
//! The `created` stamp rides along: it is the table's clock, not the caller's,
//! and it is what lets a reader tell a delete-and-recreate under one id from
//! the row it first saw.
mod common;
use common::{Fixture, KAT_ED25519, KAT_ED25519_FINGERPRINT};
use gunnar_core::model::PrincipalKind;
use gunnar_core::{AuthTable, Error, Grant, Level, Principal, Scope};
fn add(fx: &Fixture, id: &str, name: &str) -> Principal {
fx.auth
.add_principal(Principal::new(id, name, PrincipalKind::User).expect("legal id"))
.expect("add principal")
}
/// The headline: the same name may not be carried twice, whether the other
/// holder has it as a name or as an id. Both reds, then the honest green.
#[test]
fn a_name_is_unique_against_names_and_ids() {
let fx = Fixture::new();
add(&fx, "alice", "alice");
add(&fx, "bob", "Bob Builder");
// RED: another principal's name.
let err = fx
.auth
.add_principal(Principal::new("bob2", "Bob Builder", PrincipalKind::User).unwrap())
.expect_err("a taken name must be refused");
assert!(
matches!(&err, Error::NameExists { name, principal_id } if name == "Bob Builder" && principal_id == "bob"),
"{err}"
);
// RED: another principal's ID, spelled as a name. A display name that is
// somebody else's identity would be a row two readers disagree about.
let err = fx
.auth
.add_principal(Principal::new("carol", "alice", PrincipalKind::User).unwrap())
.expect_err("a name equal to another principal's id must be refused");
assert!(
matches!(&err, Error::NameExists { name, principal_id } if name == "alice" && principal_id == "alice"),
"{err}"
);
// GREEN: a free name on a free id.
add(&fx, "carol", "Carol");
assert_eq!(
fx.auth.principals().expect("list").len(),
3,
"the two refusals must have written nothing"
);
}
/// `principal_by_name` is one probe, and it answers the CURRENT name: after a
/// rename the old name resolves to nobody, even though the row is still stored
/// under an id that spells it.
#[test]
fn lookup_by_name_answers_the_current_name_only() {
let fx = Fixture::new();
add(&fx, "alice", "alice");
assert_eq!(
fx.auth
.principal_by_name("alice")
.expect("read")
.map(|p| p.id),
Some("alice".to_owned())
);
assert!(fx.auth.principal_by_name("alicia").expect("read").is_none());
fx.auth.rename_principal("alice", "alicia").expect("rename");
assert_eq!(
fx.auth
.principal_by_name("alicia")
.expect("read")
.map(|p| p.id),
Some("alice".to_owned()),
"the new name must resolve to the same id"
);
// The row is still at key "alice" — the id — but "alice" is not anybody's
// NAME any more. A lookup that answered the row here would be a lookup by
// id wearing a name's clothes.
assert!(
fx.auth.principal_by_name("alice").expect("read").is_none(),
"the old name must not resolve after a rename"
);
}
/// The whole point: a rename moves the label and nothing else. Every keyed
/// thing — key, grant, membership, account ownership — is asserted on the
/// applied ACL answer before and after.
#[test]
fn a_rename_changes_the_name_and_nothing_that_is_keyed_on_the_id() {
let fx = Fixture::new();
let repo = fx.repo("acme", "widget");
let own = fx.repo("alice", "diary");
add(&fx, "alice", "alice");
fx.auth
.add_principal(Principal::new("backend", "backend", PrincipalKind::Group).unwrap())
.expect("group");
fx.auth
.register_key("alice", KAT_ED25519, None)
.expect("register key");
fx.auth
.grant(Grant::repo("alice", repo.path(), Level::Write))
.expect("grant");
fx.auth
.add_to_group("backend", "alice")
.expect("membership");
let before = fx.auth.principal("alice").expect("read").expect("exists");
assert_eq!(before.name, "alice");
let renamed = fx
.auth
.rename_principal("alice", "Alice Liddell")
.expect("rename");
assert_eq!(renamed.id, "alice", "the id must not move");
assert_eq!(renamed.name, "Alice Liddell");
assert_eq!(
renamed.created, before.created,
"a rename is not a re-creation"
);
// Applied output, not a round-trip of the request:
let after = fx
.auth
.principal("alice")
.expect("read")
.expect("still there");
assert_eq!(after, renamed);
let fp = gunnar_core::Fingerprint::parse(KAT_ED25519_FINGERPRINT).unwrap();
assert_eq!(
fx.auth
.principal_by_fingerprint(&fp)
.expect("read")
.map(|p| p.name),
Some("Alice Liddell".to_owned()),
"the key must still authenticate as the renamed principal"
);
assert!(
fx.auth.may("alice", &repo, Level::Write).expect("acl"),
"the grant must survive the rename"
);
assert!(
fx.auth.may("alice", &own, Level::Admin).expect("acl"),
"the account is the id; a rename must not take it away"
);
assert_eq!(
fx.auth.members_of("backend").expect("members"),
vec!["alice".to_owned()],
"the membership must survive the rename"
);
// And the freed name is NOT free: it is still somebody's id.
let err = fx
.auth
.add_principal(Principal::new("alice2", "alice", PrincipalKind::User).unwrap())
.expect_err("a name that is a live id stays taken");
assert!(matches!(err, Error::NameExists { .. }), "{err}");
// Renaming to a taken name is refused, and the row is untouched by the
// refusal.
add(&fx, "bob", "bob");
let err = fx
.auth
.rename_principal("bob", "Alice Liddell")
.expect_err("a taken name must be refused on rename too");
assert!(matches!(err, Error::NameExists { .. }), "{err}");
assert_eq!(
fx.auth.principal("bob").expect("read").unwrap().name,
"bob",
"a refused rename must change nothing"
);
// Nobody to rename.
let err = fx
.auth
.rename_principal("nobody", "x")
.expect_err("an unknown id must be refused");
assert!(matches!(err, Error::NoSuchPrincipal(_)), "{err}");
}
/// A removed principal frees its name in the same update; a re-created id is
/// a NEW row, and `created` says so.
#[test]
fn removal_frees_the_name_and_a_recreation_is_a_new_row() {
let fx = Fixture::new();
let first = add(&fx, "alice", "Alice");
assert!(first.created > 0, "the table must stamp a creation time");
fx.auth.remove_principal("alice").expect("remove");
assert!(fx.auth.principal_by_name("Alice").expect("read").is_none());
// The name is free for a different id…
let other = add(&fx, "alice2", "Alice");
assert_eq!(other.id, "alice2");
fx.auth.remove_principal("alice2").expect("remove");
// …and the id is claimable again, as a NEW row: same id, later stamp. This
// is what a console pins alongside the id to notice the recreation.
std::thread::sleep(std::time::Duration::from_millis(2));
let second = add(&fx, "alice", "Alice");
assert_eq!(second.id, first.id);
assert!(
second.created > first.created,
"a recreation under the same id must carry a later creation time: {} vs {}",
second.created,
first.created
);
}
/// The stamp is the TABLE's: whatever a caller wrote in `created` is replaced,
/// so the field is the server's word and cannot be back-dated by a client.
#[test]
fn created_is_stamped_by_the_table_not_the_caller() {
let fx = Fixture::new();
let mut claimed = Principal::new("alice", "alice", PrincipalKind::User).unwrap();
claimed.created = 1;
let stored = fx.auth.add_principal(claimed).expect("add");
assert_ne!(stored.created, 1, "the caller's stamp must not be kept");
assert!(
stored.created > 1_600_000_000_000,
"not a real clock: {}",
stored.created
);
assert_eq!(
fx.auth.principal("alice").expect("read").unwrap().created,
stored.created,
"what was returned must be what was written"
);
}
/// The name index rides in the same document as the rows, so it survives a
/// reopen exactly as they do.
#[test]
fn the_rename_survives_reopen() {
let fx = Fixture::new();
add(&fx, "alice", "alice");
fx.auth.rename_principal("alice", "alicia").expect("rename");
let reopened = AuthTable::new(fx.store.clone()).expect("reopen");
assert_eq!(
reopened
.principal_by_name("alicia")
.expect("read")
.map(|p| p.id),
Some("alice".to_owned())
);
assert!(reopened.principal_by_name("alice").expect("read").is_none());
// A server-scoped grant keyed by the id still resolves after the reopen and
// the rename — the id is what the grant names.
reopened
.grant(Grant {
principal: "alice".into(),
scope: Scope::Server,
level: Level::Admin,
})
.expect("grant by id");
assert!(reopened.may_server("alice", Level::Admin).expect("acl"));
}