mod common;
use common::Fixture;
use gunnar_core::model::PrincipalKind;
use gunnar_core::{AuthTable, Grant, Level, Principal, Scope};
fn user(fx: &Fixture, id: &str) {
fx.auth
.add_principal(Principal::new(id, id, PrincipalKind::User).expect("legal id"))
.expect("add user");
}
fn group(fx: &Fixture, id: &str) {
fx.auth
.add_principal(Principal::new(id, id, PrincipalKind::Group).expect("legal id"))
.expect("add group");
}
#[test]
fn a_member_inherits_the_groups_grant_and_a_non_member_is_refused() {
let fx = Fixture::new();
let repo = fx.repo("acme", "widget");
group(&fx, "backend");
user(&fx, "alice");
user(&fx, "bob");
fx.auth
.grant(Grant::repo("backend", repo.path(), Level::Read))
.expect("grant to the group");
assert!(
!fx.auth.may("alice", &repo, Level::Read).expect("acl"),
"a non-member must not inherit the group's grant"
);
fx.auth.add_to_group("backend", "alice").expect("add member");
assert!(
fx.auth.may("alice", &repo, Level::Read).expect("acl"),
"a member must inherit the group's grant"
);
assert!(
!fx.auth.may("bob", &repo, Level::Read).expect("acl"),
"only members inherit; a bystander is still refused"
);
}
#[test]
fn removing_a_member_revokes_the_inherited_access() {
let fx = Fixture::new();
let repo = fx.repo("acme", "widget");
group(&fx, "backend");
user(&fx, "alice");
fx.auth
.grant(Grant::repo("backend", repo.path(), Level::Read))
.expect("grant");
fx.auth.add_to_group("backend", "alice").expect("add");
assert!(fx.auth.may("alice", &repo, Level::Read).expect("acl"));
let removed = fx
.auth
.remove_from_group("backend", "alice")
.expect("remove");
assert!(removed, "she was a member");
assert!(
!fx.auth.may("alice", &repo, Level::Read).expect("acl"),
"a removed member must lose the inherited access immediately"
);
assert!(!fx
.auth
.remove_from_group("backend", "alice")
.expect("idempotent remove"));
}
#[test]
fn a_read_group_is_not_a_write_backdoor() {
let fx = Fixture::new();
let repo = fx.repo("acme", "widget");
group(&fx, "readers");
user(&fx, "alice");
fx.auth
.grant(Grant::repo("readers", repo.path(), Level::Read))
.expect("grant");
fx.auth.add_to_group("readers", "alice").expect("add");
assert!(fx.auth.may("alice", &repo, Level::Read).expect("acl"));
assert!(
!fx.auth.may("alice", &repo, Level::Write).expect("acl"),
"a read group must not confer write"
);
assert!(
!fx.auth.may("alice", &repo, Level::Admin).expect("acl"),
"a read group must not confer admin"
);
}
#[test]
fn membership_does_not_confer_the_groups_account() {
let fx = Fixture::new();
group(&fx, "team");
user(&fx, "alice");
fx.auth.add_to_group("team", "alice").expect("add");
assert!(
AuthTable::owns_account("team", "team"),
"the group owns its own account (rule 2)"
);
assert!(
!AuthTable::owns_account("alice", "team"),
"a member does not own the group's account"
);
assert!(
!fx.auth
.may_namespace("alice", "team", Level::Admin)
.expect("acl"),
"membership must not hand a member the group's namespace"
);
}
#[test]
fn without_the_membership_edge_there_is_no_access() {
let fx = Fixture::new();
let repo = fx.repo("secure", "vault");
group(&fx, "admins");
user(&fx, "mallory");
fx.auth
.grant(Grant::server("admins", Level::Admin))
.expect("grant the group server admin");
assert!(!fx.auth.may_server("mallory", Level::Admin).expect("acl"));
assert!(!fx.auth.may("mallory", &repo, Level::Write).expect("acl"));
}
#[test]
fn nested_group_grants_inherit_transitively() {
let fx = Fixture::new();
let repo = fx.repo("acme", "widget");
group(&fx, "all");
group(&fx, "eng");
user(&fx, "alice");
fx.auth
.grant(Grant::repo("all", repo.path(), Level::Read))
.expect("grant to the outer group");
fx.auth.add_to_group("all", "eng").expect("eng ∈ all");
fx.auth.add_to_group("eng", "alice").expect("alice ∈ eng");
user(&fx, "bob");
assert!(
!fx.auth.may("bob", &repo, Level::Read).expect("acl"),
"a non-member reaches nothing through the chain"
);
assert!(
fx.auth.may("alice", &repo, Level::Read).expect("acl"),
"a member of a nested group inherits transitively"
);
assert_eq!(
fx.auth.groups_of("alice").expect("groups"),
vec!["eng".to_owned()],
"alice is a DIRECT member of eng only; the reach to all is resolved, not stored"
);
}
#[test]
fn a_membership_cycle_terminates() {
let fx = Fixture::new();
let repo = fx.repo("acme", "widget");
group(&fx, "a");
group(&fx, "b");
user(&fx, "alice");
fx.auth.add_to_group("b", "a").expect("a ∈ b");
fx.auth.add_to_group("a", "b").expect("b ∈ a");
fx.auth.add_to_group("a", "alice").expect("alice ∈ a");
fx.auth
.grant(Grant::repo("b", repo.path(), Level::Read))
.expect("grant on b");
assert!(
fx.auth.may("alice", &repo, Level::Read).expect("acl"),
"a cycle must be walked once and still resolve the reachable grant"
);
user(&fx, "bob");
assert!(!fx.auth.may("bob", &repo, Level::Read).expect("acl"));
}
#[test]
fn membership_survives_reopen() {
let fx = Fixture::new();
let repo = fx.repo("acme", "widget");
group(&fx, "backend");
user(&fx, "alice");
fx.auth
.grant(Grant::repo("backend", repo.path(), Level::Read))
.expect("grant");
fx.auth.add_to_group("backend", "alice").expect("add");
let reopened = AuthTable::new(std::sync::Arc::clone(&fx.store)).expect("reopen");
assert_eq!(
reopened.members_of("backend").expect("members"),
vec!["alice".to_owned()]
);
assert!(
reopened.may("alice", &repo, Level::Read).expect("acl"),
"inheritance must survive a reopen"
);
}
#[test]
fn add_to_group_typechecks_its_arguments() {
let fx = Fixture::new();
user(&fx, "alice");
user(&fx, "notagroup");
group(&fx, "team");
let err = fx
.auth
.add_to_group("notagroup", "alice")
.expect_err("a user cannot have members");
assert!(format!("{err}").contains("not a group"), "{err}");
assert!(fx.auth.add_to_group("ghost", "alice").is_err());
assert!(fx.auth.add_to_group("team", "ghost").is_err());
fx.auth.add_to_group("team", "alice").expect("add");
assert_eq!(fx.auth.groups().expect("groups").len(), 1);
assert_eq!(
fx.auth.members_of("team").expect("members"),
vec!["alice".to_owned()]
);
let _ = Scope::Server; }