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

nordisk/gunnar

crates/gunnar-core/tests/gate.rs

//! **The account gate: a user who is locked out is locked out of everything.**
//!
//! Measure, never assert, in the same shape `acl.rs` uses it: every assertion here has its
//! opposite beside it, because a gate that refuses everybody passes "a gated
//! user is refused" and a gate that refuses nobody passes "an ungated user is
//! served". Both directions or neither proves anything.
//!
//! # Why the gate is not a read/write distinction
//!
//! The obvious design is "gated means read-only": let them clone, refuse the
//! push. It is wrong, and the reason is in this crate's own storage: **a read
//! creates things.** A fetch opens the store, which lazily explodes objects
//! into the read cache and builds the oid index the next fetch will use; a
//! `Refs.Log` walks and caches. Bytes appear on the disk of an account that is
//! being emptied, *after* the emptying walked past that repository. A purge
//! racing a reader does not converge.
//!
//! So the gate is total: [`AuthTable::may`], [`AuthTable::may_namespace`] and
//! [`AuthTable::may_server`] all answer `false`, at every level, for a gated
//! principal — and for anybody at all reaching into a gated account, which is
//! the half that makes a purge finish.

mod common;

use common::Fixture;
use gunnar_core::model::PrincipalKind;
use gunnar_core::{AccountGate, Grant, Level, Principal, Visibility};

fn principal(fx: &Fixture, id: &str) {
    fx.auth
        .add_principal(Principal::new(id, id, PrincipalKind::User).expect("legal id"))
        .expect("add principal");
}

/// The headline: an account owner may do everything in their own namespace
/// (rule 2, intrinsic), and the moment they are gated they may do **nothing** —
/// including the read that rule would otherwise hand them for free.
#[test]
fn a_gated_principal_loses_their_own_account_at_every_level() {
    let fx = Fixture::new();
    principal(&fx, "alice");
    let repo = fx.repo("alice", "widget");

    // Before: intrinsic account admin, all three levels.
    for level in [Level::Read, Level::Write, Level::Admin] {
        assert!(
            fx.auth.may("alice", &repo, level).expect("acl"),
            "an account owner must hold {level:?} on their own repository before any gate"
        );
    }

    fx.auth
        .gate(AccountGate::administrative(
            "alice",
            "operator",
            "non-payment",
        ))
        .expect("gate");

    for level in [Level::Read, Level::Write, Level::Admin] {
        assert!(
            !fx.auth.may("alice", &repo, level).expect("acl"),
            "a gated principal must be refused {level:?} on their own repository; READ is not \
             safe — a fetch explodes the read cache and writes an index, so a reader racing a \
             purge puts bytes back behind it"
        );
    }

    // And it lifts. A gate that could not be lifted is a bricked account.
    fx.auth.lift_gate("alice").expect("lift");
    for level in [Level::Read, Level::Write, Level::Admin] {
        assert!(
            fx.auth.may("alice", &repo, level).expect("acl"),
            "lifting the gate must give {level:?} back"
        );
    }
}

/// A gate on the ACTOR, not on the repository: a gated principal loses the
/// grants they hold on *other people's* repositories too.
#[test]
fn a_gated_principal_loses_the_grants_they_hold_elsewhere() {
    let fx = Fixture::new();
    principal(&fx, "alice");
    principal(&fx, "bob");
    let repo = fx.repo("bob", "widget");
    fx.auth
        .grant(Grant::repo("alice", repo.path(), Level::Write))
        .expect("grant");

    assert!(fx.auth.may("alice", &repo, Level::Write).expect("acl"));
    fx.auth
        .gate(AccountGate::administrative("alice", "operator", "purge"))
        .expect("gate");
    assert!(
        !fx.auth.may("alice", &repo, Level::Write).expect("acl"),
        "the gate is on the principal; a grant they hold in someone else's namespace goes with \
         them"
    );
    // …and bob, who was never gated, is untouched. The gate is a row, not a
    // switch on the server.
    assert!(fx.auth.may("bob", &repo, Level::Admin).expect("acl"));
}

/// The half a purge needs: a gated ACCOUNT is closed to everybody, including a
/// stranger reading a public repository in it and a collaborator who holds a
/// grant there.
#[test]
fn a_gated_account_is_closed_to_everybody_not_only_to_its_owner() {
    let fx = Fixture::new();
    principal(&fx, "alice");
    principal(&fx, "carol");
    let public = fx.repo_with(
        "alice",
        "open",
        gunnar_core::HashKind::Sha256,
        Visibility::Public,
    );
    let private = fx.repo("alice", "closed");
    fx.auth
        .grant(Grant::repo("carol", private.path(), Level::Write))
        .expect("grant");

    assert!(fx.auth.may("carol", &public, Level::Read).expect("acl"));
    assert!(fx.auth.may("carol", &private, Level::Write).expect("acl"));

    fx.auth
        .gate(AccountGate::administrative("alice", "operator", "purge"))
        .expect("gate");

    assert!(
        !fx.auth.may("carol", &public, Level::Read).expect("acl"),
        "a public repository in a gated account must stop being readable: a purge that let \
         strangers keep cloning would be reclaiming bytes a fetch is re-exploding behind it"
    );
    assert!(
        !fx.auth.may("carol", &private, Level::Write).expect("acl"),
        "a collaborator's grant does not survive the account being gated"
    );
    // carol's own account is untouched — the gate is on alice's, not on carol.
    let carols = fx.repo("carol", "hers");
    assert!(fx.auth.may("carol", &carols, Level::Admin).expect("acl"));
}

/// The namespace and server questions answer the same way. `may_namespace` is
/// what `repo create` and `repo delete` ask, so a gate that only covered
/// `may` would let a locked-out user create repositories in the account being
/// emptied.
#[test]
fn a_gated_principal_may_not_create_namespaces_or_administer_the_server() {
    let fx = Fixture::new();
    principal(&fx, "alice");
    fx.auth
        .grant(Grant::server("alice", Level::Admin))
        .expect("grant");

    assert!(fx
        .auth
        .may_namespace("alice", "alice", Level::Admin)
        .expect("acl"));
    assert!(fx.auth.may_server("alice", Level::Admin).expect("acl"));

    fx.auth
        .gate(AccountGate::administrative("alice", "operator", "purge"))
        .expect("gate");

    assert!(
        !fx.auth
            .may_namespace("alice", "alice", Level::Admin)
            .expect("acl"),
        "`repo create` asks may_namespace; a gated user must not be able to make a new \
         repository in the account being emptied"
    );
    assert!(
        !fx.auth.may_server("alice", Level::Admin).expect("acl"),
        "a gated principal holding a Server/Admin grant must not be able to lift their own gate \
         through the front door"
    );
}

/// **The brick, and the lease that prevents it.** A purge holds the gate while
/// it runs, and a purge can die: a panic, a SIGKILL, a power cut. A gate with
/// no deadline held by a process that is gone is an account nobody can use and
/// nobody remembers to unlock.
#[test]
fn a_purge_lease_expires_but_an_administrative_gate_does_not() {
    let fx = Fixture::new();
    principal(&fx, "alice");
    let repo = fx.repo("alice", "widget");

    // A lease that expired one second ago: what a crashed purge leaves behind.
    let stale = AccountGate::purge_lease("alice", "purge-1", gunnar_core::now_ms() - 1_000);
    fx.auth.gate(stale).expect("gate");
    assert!(
        fx.auth.may("alice", &repo, Level::Read).expect("acl"),
        "an EXPIRED purge lease must not lock an account: the process that took it is gone and \
         nothing else is going to lift it"
    );
    assert!(
        fx.auth.gate_of("alice").expect("read").is_none(),
        "an expired lease must read as no gate at all, so an operator is not told the account \
         is locked when it is not"
    );

    // A live lease does lock.
    let live = AccountGate::purge_lease("alice", "purge-2", gunnar_core::now_ms() + 60_000);
    fx.auth.gate(live).expect("gate");
    assert!(!fx.auth.may("alice", &repo, Level::Read).expect("acl"));

    // An administrative gate has no deadline and never expires on its own —
    // "suspended for non-payment" must not quietly time out.
    fx.auth
        .gate(AccountGate::administrative(
            "alice",
            "operator",
            "non-payment",
        ))
        .expect("gate");
    let gate = fx.auth.gate_of("alice").expect("read").expect("gated");
    assert!(
        gate.expires.is_none(),
        "an administrative gate must carry no deadline"
    );
    assert!(!fx.auth.may("alice", &repo, Level::Read).expect("acl"));
}

/// A purge must not lift a gate it did not take. Two writers of the same row:
/// a purge job holding a lease, and an operator who suspends the account while
/// it runs. When the purge finishes, the operator's gate must still be there.
#[test]
fn lifting_a_lease_leaves_an_administrative_gate_that_arrived_meanwhile() {
    let fx = Fixture::new();
    principal(&fx, "alice");
    let repo = fx.repo("alice", "widget");

    fx.auth
        .gate(AccountGate::purge_lease(
            "alice",
            "purge-7",
            gunnar_core::now_ms() + 60_000,
        ))
        .expect("gate");
    // The operator suspends the account mid-purge; this overwrites the row.
    fx.auth
        .gate(AccountGate::administrative(
            "alice",
            "operator",
            "non-payment",
        ))
        .expect("gate");

    let lifted = fx.auth.lift_gate_held_by("alice", "purge-7").expect("lift");
    assert!(
        lifted.is_none(),
        "the purge held lease `purge-7`; the row now says `operator`, so there is nothing of \
         the purge's to lift"
    );
    assert!(
        !fx.auth.may("alice", &repo, Level::Read).expect("acl"),
        "the operator's suspension must survive the purge finishing; a purge that cleared it \
         would silently un-suspend a non-paying account"
    );

    // The same call by the holder that IS on the row does lift it. An
    // administrative gate's holder is the sentinel, not the actor's name:
    // `operator` above is who ASKED, and two operators suspending the same
    // account must not each hold a separate claim on one row.
    let lifted = fx
        .auth
        .lift_gate_held_by("alice", gunnar_core::ADMINISTRATIVE_HOLDER)
        .expect("lift");
    assert!(lifted.is_some());
    assert!(fx.auth.may("alice", &repo, Level::Read).expect("acl"));
}

/// Removing a principal takes its gate with it. Otherwise a stale row keyed by
/// a name nobody holds locks out the **next** principal created with that name
/// — an account bricked before it existed.
#[test]
fn removing_a_principal_takes_its_gate_with_it() {
    let fx = Fixture::new();
    principal(&fx, "alice");
    fx.auth
        .gate(AccountGate::administrative("alice", "operator", "purge"))
        .expect("gate");
    fx.auth.remove_principal("alice").expect("remove");
    assert!(
        fx.auth.gate_of("alice").expect("read").is_none(),
        "a removed principal's gate must go with it; a row left behind would lock out whoever \
         is created under that name next"
    );

    principal(&fx, "alice");
    let repo = fx.repo("alice", "widget");
    assert!(
        fx.auth.may("alice", &repo, Level::Admin).expect("acl"),
        "a freshly created principal must not inherit a dead gate"
    );
}

/// The listing an operator and a console both read. Empty is a real answer;
/// expired leases are not in it.
#[test]
fn the_gate_listing_shows_live_gates_only() {
    let fx = Fixture::new();
    principal(&fx, "alice");
    principal(&fx, "bob");
    principal(&fx, "carol");

    assert!(fx.auth.gates().expect("list").is_empty());

    fx.auth
        .gate(AccountGate::administrative("alice", "operator", "one"))
        .expect("gate");
    fx.auth
        .gate(AccountGate::purge_lease(
            "bob",
            "purge-3",
            gunnar_core::now_ms() + 60_000,
        ))
        .expect("gate");
    fx.auth
        .gate(AccountGate::purge_lease(
            "carol",
            "purge-4",
            gunnar_core::now_ms() - 1,
        ))
        .expect("gate");

    let listed: Vec<String> = fx
        .auth
        .gates()
        .expect("list")
        .into_iter()
        .map(|gate| gate.account)
        .collect();
    assert_eq!(
        listed,
        vec!["alice".to_owned(), "bob".to_owned()],
        "carol's lease has expired and must not be listed as a lockout that is not in force"
    );
}

/// `require` says *gated*, not *denied*. The two are different things to the
/// person on the other end of an SSH session: one means "ask for a grant", the
/// other means "your account is locked and a grant will not help".
#[test]
fn require_names_the_gate_rather_than_reporting_a_plain_denial() {
    let fx = Fixture::new();
    principal(&fx, "alice");
    let repo = fx.repo("alice", "widget");
    fx.auth
        .gate(AccountGate::administrative(
            "alice",
            "operator",
            "non-payment",
        ))
        .expect("gate");

    let err = fx
        .auth
        .require("alice", &repo, Level::Read)
        .expect_err("a gated principal must be refused");
    assert!(
        err.is_gated(),
        "a gate must be reported as a gate and not as a missing grant: {err}"
    );
    assert!(
        !err.is_denied(),
        "is_denied and is_gated must not both be true; the SSH layer routes on them: {err}"
    );
    let text = format!("{err}");
    assert!(text.contains("alice"), "{text}");
    assert!(
        text.contains("non-payment"),
        "the refusal must carry the reason the operator typed, or the user has nothing to act \
         on: {text}"
    );
}

/// **Namespaces nest, and a gate on an account must cover the subtree.**
///
/// `alice/team` is a *different* namespace from `alice` — `Scope::Namespace`
/// matches exactly and `owns_account("alice", "alice/team")` is false — but on
/// the disk it is `repos/alice/team/…`, inside the account's own directory, and
/// a purge of `alice` reclaims it. A gate keyed by exact name would therefore
/// leave the one part of the account a purge is walking wide open to whoever
/// holds a grant there.
#[test]
fn a_gate_on_an_account_closes_the_namespaces_nested_under_it() {
    let fx = Fixture::new();
    principal(&fx, "alice");
    principal(&fx, "bob");
    let nested = fx.repo("alice/team", "widget");
    fx.auth
        .grant(Grant::repo("bob", nested.path(), Level::Write))
        .expect("grant");
    assert!(fx.auth.may("bob", &nested, Level::Write).expect("acl"));

    fx.auth
        .gate(AccountGate::administrative("alice", "operator", "purge"))
        .expect("gate");

    assert!(
        !fx.auth.may("bob", &nested, Level::Write).expect("acl"),
        "`alice/team` lives inside alice's directory subtree and a purge of alice reclaims it, \
         so gating alice must close it too"
    );
    assert!(
        !fx.auth
            .may_namespace("bob", "alice/team", Level::Admin)
            .expect("acl"),
        "and no new repository may be created there while the account is shut"
    );

    // It is a PREFIX on path components, not on characters. `alicehacks` is a
    // different account and must not be caught by a gate on `alice`.
    principal(&fx, "alicehacks");
    let neighbour = fx.repo("alicehacks", "widget");
    assert!(
        fx.auth
            .may("alicehacks", &neighbour, Level::Admin)
            .expect("acl"),
        "`alicehacks` merely starts with `alice`; a gate on one account must not close another"
    );
}