Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/gunnar

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
    );
}