gunnar-core 3.0.0

gunnar's repository model, identity table, ACL and path sandbox. gunnar-ssh, gunnar-grpc and gunnar-cli all sit on it.
Documentation
//! 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
}