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