crates/gunnar-cli/tests/groups_cli.rs
#![cfg(all(feature = "server", feature = "client", feature = "control"))]
//! **Groups in the ACL, proven at the SSH door**.
//!
//! A group is a principal that holds no key; a grant to a group is inherited by
//! its members. Every green here is the *applied* effect — a real `ls-remote`
//! over real SSH reaches (or is refused) a repository — never a control-plane
//! round-trip, because the control plane and the SSH listener are two readers
//! of one table and only the second is the door the world comes through.
//!
//! # The reds this file saw
//!
//! Each was tripped against the real binary during development:
//!
//! * **Inheritance** — before bob is a member, the group's read grant is
//! inert for him and the door is shut (asserted). It opens only when he is
//! added. With the core resolver's group walk unwired (`holder_grants`
//! returning only the principal's own grants), the "member reaches it" green
//! below goes red at the door — a grant to a group that no member can use.
//! * **Removal revokes** — removing the member shuts the door again, the same
//! call. A `remove_group_member` that dropped the row without the access
//! would leave this door open, which no listing can see.
//! * **Not a backdoor to admin** — a non-admin cannot add themselves to a
//! powerful group: the RPC is Server-Admin-gated exactly like `grant`, so
//! bob's self-add is refused and the admin group's repository stays out of
//! his reach. A gate mutated to allow it opens that door and trips the
//! assertion.
use std::path::Path;
#[path = "common/served_binary.rs"]
mod served_binary;
use served_binary::{gunnar, mint, ssh_reaches, Ran, Serving};
/// Run a `gunnar control …` verb signed with `ident` against `grpc`.
fn control(home: &Path, grpc: &str, ident: &Path, args: &[&str]) -> Ran {
let mut full = vec!["control"];
full.extend_from_slice(args);
full.extend_from_slice(&["-i", ident.to_str().unwrap(), "--at", grpc]);
gunnar(home, &full)
}
/// **The headline: grant a team, not one person at a time.** A group is granted
/// read on a repository; a member inherits it over real SSH; removing the
/// member revokes it. Every step is checked at the door.
#[test]
fn a_member_inherits_a_group_grant_over_ssh_and_removal_revokes_it() {
let dir = tempfile::tempdir().expect("a temp dir");
let (data_root, home) = (dir.path().join("data"), dir.path().join("home"));
let keys = home.join("keys");
std::fs::create_dir_all(&keys).expect("mkdir");
let (alice_key, alice_pub) = mint(&keys, "alice");
let (bob_key, bob_pub) = mint(&keys, "bob");
let serving = Serving::start(&data_root, &home, &dir.path().join("server.log"));
// Bootstrap the admin, a private repo, and bob as an ordinary user.
let boot = gunnar(
&home,
&[
"control",
"bootstrap",
"alice",
"--key",
&format!("@{}", alice_pub.display()),
"--at",
&serving.grpc,
],
);
assert!(boot.ok, "bootstrap: {}", boot.out);
for args in [
vec!["repo", "create", "alice/proj"],
vec![
"user",
"add",
"bob",
"--key",
&format!("@{}", bob_pub.display()),
],
// The group, and its grant. `group add` creates a keyless principal;
// `grant` is the ORDINARY grant verb, pointed at the group (one
// implementation — no new grant type).
vec!["group", "add", "backend"],
vec!["grant", "backend", "read", "--repo", "alice/proj"],
] {
let ran = control(&home, &serving.grpc, &alice_key, &args);
assert!(ran.ok, "{args:?}: {}", ran.out);
}
// ── RED: the grant is to the GROUP, and bob is not in it yet ────────
let before = ssh_reaches(&home, &serving.ssh, &bob_key, "alice/proj");
assert!(
!before.ok,
"bob reached a repo granted only to a group he is NOT a member of — the group is \
acting as a public door, so the inheritance green below could never fail: {}",
before.out
);
// ── add the member, and the door opens (the applied inheritance) ───────
let add = control(
&home,
&serving.grpc,
&alice_key,
&["group", "member", "add", "backend", "bob"],
);
assert!(add.ok, "group member add: {}", add.out);
let reaches = ssh_reaches(&home, &serving.ssh, &bob_key, "alice/proj");
assert!(
reaches.ok,
"bob is a member of a group granted read, and his key does NOT reach the repository \
over real SSH — the group's grant is not being inherited: {}",
reaches.out
);
// ── remove the member, and the door shuts again, the same call ─────────
let remove = control(
&home,
&serving.grpc,
&alice_key,
&["group", "member", "remove", "backend", "bob"],
);
assert!(remove.ok, "group member remove: {}", remove.out);
let after = ssh_reaches(&home, &serving.ssh, &bob_key, "alice/proj");
assert!(
!after.ok,
"bob was removed from the group and his key STILL reaches the repository over SSH — \
the removal dropped a membership row without removing the access: {}",
after.out
);
// The listing verbs answer the audit question.
let members = control(
&home,
&serving.grpc,
&alice_key,
&["group", "members", "backend"],
);
assert!(members.ok, "group members: {}", members.out);
assert!(
!members.stdout.lines().any(|l| l.starts_with("bob")),
"bob was removed and still appears in `group members`:\n{}",
members.stdout
);
let list = control(&home, &serving.grpc, &alice_key, &["group", "list"]);
assert!(
list.stdout.lines().any(|l| l.starts_with("backend")),
"`group list` must show the group:\n{}",
list.stdout
);
}
/// **A group is not a backdoor to admin.** A non-admin cannot add themselves to
/// a group — the membership RPC is Server-Admin-gated exactly like `grant` — so
/// an admin group's repository stays out of a non-member's reach.
#[test]
fn a_non_admin_cannot_add_themselves_to_a_group() {
let dir = tempfile::tempdir().expect("a temp dir");
let (data_root, home) = (dir.path().join("data"), dir.path().join("home"));
let keys = home.join("keys");
std::fs::create_dir_all(&keys).expect("mkdir");
let (alice_key, alice_pub) = mint(&keys, "alice");
let (mallory_key, mallory_pub) = mint(&keys, "mallory");
let serving = Serving::start(&data_root, &home, &dir.path().join("server.log"));
let boot = gunnar(
&home,
&[
"control",
"bootstrap",
"alice",
"--key",
&format!("@{}", alice_pub.display()),
"--at",
&serving.grpc,
],
);
assert!(boot.ok, "bootstrap: {}", boot.out);
for args in [
vec!["repo", "create", "alice/secret"],
vec![
"user",
"add",
"mallory",
"--key",
&format!("@{}", mallory_pub.display()),
],
vec!["group", "add", "readers"],
vec!["grant", "readers", "read", "--repo", "alice/secret"],
] {
let ran = control(&home, &serving.grpc, &alice_key, &args);
assert!(ran.ok, "{args:?}: {}", ran.out);
}
// mallory, signing with her OWN key, tries to add herself to the group
// that can read the secret. Server-Admin-gated: refused.
let selfadd = control(
&home,
&serving.grpc,
&mallory_key,
&["group", "member", "add", "readers", "mallory"],
);
assert!(
!selfadd.ok,
"a non-admin added themselves to a group — the membership RPC is not admin-gated, so a \
group is a self-service backdoor to whatever it can reach: {}",
selfadd.out
);
// The applied proof: the door never opened. mallory cannot reach the secret.
let reaches = ssh_reaches(&home, &serving.ssh, &mallory_key, "alice/secret");
assert!(
!reaches.ok,
"mallory reached the admin group's repository despite being refused membership: {}",
reaches.out
);
// And the group genuinely confers access when an ADMIN adds a member — so
// the refusal above is the gate, not the feature being broken.
let add = control(
&home,
&serving.grpc,
&alice_key,
&["group", "member", "add", "readers", "mallory"],
);
assert!(add.ok, "an admin must be able to add a member: {}", add.out);
let now = ssh_reaches(&home, &serving.ssh, &mallory_key, "alice/secret");
assert!(
now.ok,
"once an admin adds her, mallory must reach the repository — proving the earlier refusal \
was the gate and not a broken group: {}",
now.out
);
}
/// **Membership is durable.** It survives a full server stop and restart on the
/// same data root, because it lives in the stored identity document.
#[test]
fn membership_survives_a_server_restart() {
let dir = tempfile::tempdir().expect("a temp dir");
let (data_root, home) = (dir.path().join("data"), dir.path().join("home"));
let keys = home.join("keys");
std::fs::create_dir_all(&keys).expect("mkdir");
let (alice_key, alice_pub) = mint(&keys, "alice");
let (bob_key, bob_pub) = mint(&keys, "bob");
let log = dir.path().join("server.log");
{
let serving = Serving::start(&data_root, &home, &log);
let boot = gunnar(
&home,
&[
"control",
"bootstrap",
"alice",
"--key",
&format!("@{}", alice_pub.display()),
"--at",
&serving.grpc,
],
);
assert!(boot.ok, "bootstrap: {}", boot.out);
for args in [
vec!["repo", "create", "alice/proj"],
vec![
"user",
"add",
"bob",
"--key",
&format!("@{}", bob_pub.display()),
],
vec!["group", "add", "backend"],
vec!["grant", "backend", "read", "--repo", "alice/proj"],
vec!["group", "member", "add", "backend", "bob"],
] {
let ran = control(&home, &serving.grpc, &alice_key, &args);
assert!(ran.ok, "{args:?}: {}", ran.out);
}
// Dropped here: the server stops.
}
// A fresh server on the SAME data root. Membership, the grant and the
// inheritance must all still be there.
let serving = Serving::start(&data_root, &home, &log);
let reaches = ssh_reaches(&home, &serving.ssh, &bob_key, "alice/proj");
assert!(
reaches.ok,
"after a restart, bob's inherited access is gone — membership did not survive: {}",
reaches.out
);
let members = control(
&home,
&serving.grpc,
&alice_key,
&["group", "members", "backend"],
);
assert!(
members.stdout.lines().any(|l| l.starts_with("bob")),
"after a restart, `group members` lost bob:\n{}",
members.stdout
);
}