crates/gunnar-core/tests/groups.rs
//! Groups in the ACL (punkt 9), asserted in **both** directions.
//!
//! A group is a principal whose grants its members inherit. Measure, never assert: every
//! "a member inherits" here has its "a non-member is refused" next to it, and
//! the resolution rule is proven by its applied answer — `may()` — not by the
//! membership row existing. The real-SSH-clone proof of the same effects lives
//! in `gunnar-cli/tests/groups_cli.rs`; this file pins the resolution rule
//! itself, including the two shapes an SSH test cannot cheaply reach: a
//! membership cycle, and the boundary between an inherited grant and a group's
//! intrinsic account.
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");
}
/// The headline: grant a GROUP read on a repo, add a member, and the member may
/// read it — while a principal who is *not* a member is refused. Both halves
/// against `may()`, the applied ACL answer.
#[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");
// RED, before membership: alice is granted nothing directly, so the group's
// grant is inert for her.
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");
// GREEN: alice now inherits Read.
assert!(
fx.auth.may("alice", &repo, Level::Read).expect("acl"),
"a member must inherit the group's grant"
);
// bob was never added: the grant is still inert for him. The refusal is the
// group not being a public door.
assert!(
!fx.auth.may("bob", &repo, Level::Read).expect("acl"),
"only members inherit; a bystander is still refused"
);
}
/// Removing a member revokes the inherited access **the same call**: the next
/// `may()` reads the document the removal wrote, so there is no window.
#[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");
// RED after removal: access is gone, not merely the row.
assert!(
!fx.auth.may("alice", &repo, Level::Read).expect("acl"),
"a removed member must lose the inherited access immediately"
);
// Removing again is a no-op, not an error, and does not resurrect access.
assert!(!fx
.auth
.remove_from_group("backend", "alice")
.expect("idempotent remove"));
}
/// A group granted `Read` is not a backdoor to `Write` or `Admin`: the ACL
/// takes the maximum of the inherited grants, and Read is below both.
#[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"
);
}
/// Inheritance is of grants only — never of a group's intrinsic account
/// ownership. A group named `team` owns the `team` namespace intrinsically
/// (rule 2), but a member of that group is not admin of `team`: rule 2 reads
/// the *querying* principal's own id, and alice is not `team`.
#[test]
fn membership_does_not_confer_the_groups_account() {
let fx = Fixture::new();
// Creating the group does not itself make the account namespace here (that
// is the server control plane's job), so grant nothing and assert the
// intrinsic rule directly: alice != "team".
group(&fx, "team");
user(&fx, "alice");
fx.auth.add_to_group("team", "alice").expect("add");
// The group would be admin of its own account namespace; the member is not.
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"
);
}
/// A non-admin cannot be a member of an admin group unless an admin puts them
/// there — the write path here is `add_to_group`, which the RPC gates on Admin
/// exactly like `grant`. At the core level the gate is the caller's; what this
/// pins is that the *only* way to gain the access is the membership edge, and a
/// principal with no edge has nothing. (The RPC-level "a non-admin is refused
/// AddGroupMember" red lives in `gunnar-cli/tests/groups_cli.rs`.)
#[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");
// mallory exists and the powerful group exists, but she is not in it.
assert!(!fx.auth.may_server("mallory", Level::Admin).expect("acl"));
assert!(!fx.auth.may("mallory", &repo, Level::Write).expect("acl"));
}
/// Nesting: a group may be a member of a group, and grants inherit through the
/// chain. `eng` is a member of `all`; `all` is granted Read; a member of `eng`
/// inherits it transitively.
#[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");
// RED first: before the outer edge, alice reaches nothing.
// (Assert by removing then re-adding is overkill; instead assert a peer who
// is only in `eng` with no path to `all`'s grant is fine because `eng` has
// the path — so prove the negative with a bystander.)
user(&fx, "bob");
assert!(
!fx.auth.may("bob", &repo, Level::Read).expect("acl"),
"a non-member reaches nothing through the chain"
);
// GREEN: alice → eng → all → Read.
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"
);
}
/// A membership cycle terminates. A ∈ B and B ∈ A; alice ∈ A; `all`'s grant is
/// on B. Resolution must return rather than loop. **This test hangs against an
/// unbounded resolver** — the whole point of the `seen` set — so its completing
/// is the red-and-green: an infinite walk never reaches the assertion.
#[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");
// The cycle: a ∈ b, b ∈ a.
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");
// Terminates, and resolves: alice → a → b → Read.
assert!(
fx.auth.may("alice", &repo, Level::Read).expect("acl"),
"a cycle must be walked once and still resolve the reachable grant"
);
// A principal outside the cycle is unaffected and also terminates.
user(&fx, "bob");
assert!(!fx.auth.may("bob", &repo, Level::Read).expect("acl"));
}
/// Membership is durable: a fresh `AuthTable` over the same control store sees
/// the edges and the inheritance, because both live in the stored document.
#[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");
// Reopen the identity table over the very same store.
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"
);
}
/// A member cannot be added to a non-group, and neither principal may be
/// unknown. The membership edge only ever attaches to a real group.
#[test]
fn add_to_group_typechecks_its_arguments() {
let fx = Fixture::new();
user(&fx, "alice");
user(&fx, "notagroup");
group(&fx, "team");
// A user is not a group.
let err = fx
.auth
.add_to_group("notagroup", "alice")
.expect_err("a user cannot have members");
assert!(format!("{err}").contains("not a group"), "{err}");
// Unknown group, unknown member.
assert!(fx.auth.add_to_group("ghost", "alice").is_err());
assert!(fx.auth.add_to_group("team", "ghost").is_err());
// Removing a member also cleans the group listing.
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; // keep the import honest across edits
}