gunnar-core 2.0.0

gunnar's repository model, identity table, ACL and path sandbox. gunnar-ssh, gunnar-grpc and gunnar-cli all sit on it.
Documentation
//! **The account gate: one row that says a user may do nothing.**
//!
//! # The problem it solves
//!
//! Gunnar could delete a *repository* — `Repos.Delete --purge`, `Repos.Purge`,
//! `Repos.ReclaimOrphan` — and could not empty an *account*. The per-user hard
//! purge (`Accounts.Purge`) is that verb, and it cannot be written safely
//! without this module first, because a purge is not atomic: it walks an
//! account's repositories and reclaims them one at a time, over minutes, while
//! the server is still serving. Anything the user does during that walk lands
//! behind it.
//!
//! # Why a lockout, and why it is TOTAL
//!
//! The tempting design is read-only: refuse the push, allow the fetch, "reads
//! are harmless". **They are not.** In gunnar a read creates things:
//!
//! * a fetch opens the store, which lazily explodes objects into the read cache
//!   — `gunnar-server`'s `storage.rs` calls that cache exactly what a `repo gc`
//!   reclaims, so a fetch *re-creates* what a purge just unlinked;
//! * the first read of a repository whose pack has no index builds one, and
//!   writes it;
//! * `Refs.Log` and the search path walk and cache.
//!
//! So a purge that permitted reads would be racing a reader that keeps putting
//! bytes back, in a directory the purge has already walked past. The gate is
//! therefore total: every level, every verb, both doors (the SSH listener and
//! the control plane ask the same [`crate::AuthTable`]), and — see below — for
//! everybody, not only for the account's owner.
//!
//! # Two directions, because a purge needs both
//!
//! [`crate::AuthTable::may`] consults the gate twice:
//!
//! * **the actor** — a gated principal does nothing, anywhere, including in
//!   namespaces they merely hold a grant on. This is the half that means
//!   "suspended".
//! * **the account** — nobody reaches into a gated account, including a
//!   stranger cloning a public repository in it and a collaborator with a
//!   grant. This is the half that means a purge terminates.
//!
//! The second is the surprising one and it is deliberate. Gating `alice` takes
//! `alice`'s public repositories off the air for everyone. For a purge that is
//! the entire point; for a billing suspension it is the honest reading of
//! "suspended" too. There is no knob to separate them, because the moment
//! there is one, somebody sets it to the permissive value on the purge path.
//!
//! # The lease, and the account that would otherwise be bricked
//!
//! A gate is persisted. A purge that takes one and then dies — a panic, a
//! SIGKILL, a power cut between two `unlink`s — leaves a locked account with no
//! process left to unlock it, and nobody remembers a row they never saw. That
//! is a worse outcome than an unfinished purge.
//!
//! So a gate taken *by a purge* is a **lease**: it carries [`AccountGate::expires`]
//! and stops being in force when that passes. The purge worker re-stamps it as
//! it goes, so a long purge does not unlock underneath itself, and a dead purge
//! unlocks on its own within [`PURGE_LEASE`]. A gate taken by a *person*
//! ([`AccountGate::administrative`]) carries no deadline at all: "suspended for
//! non-payment" must not quietly time out.
//!
//! # Why the holder is on the row
//!
//! Two writers can hold an opinion about the same account at once: the purge
//! job, and an operator who suspends the account while it runs. The row
//! records [`AccountGate::holder`], and
//! [`crate::AuthTable::lift_gate_held_by`] lifts it only if the holder still
//! matches — so a purge finishing cannot silently un-suspend an account
//! somebody suspended for a different reason.

use crate::model::{now_ms, UnixMillis};

/// How long a purge's lease on an account is valid without being re-stamped.
///
/// Ten minutes. The trade is one-sided in both directions and this is where it
/// balances: too short and a purge that stalls on one enormous repository
/// unlocks the account it is halfway through emptying; too long and a crashed
/// purge holds a customer's account shut for that long with nothing running.
/// The worker re-stamps after every repository it reclaims, so the only way to
/// spend the whole lease is a single repository taking longer than ten minutes
/// — and the only cost of that is that the lease has to be renewed once more.
pub const PURGE_LEASE_MS: i64 = 10 * 60 * 1000;

/// The holder recorded on a gate an operator or a service set by hand.
///
/// Not an empty string: an empty holder would make
/// [`crate::AuthTable::lift_gate_held_by`] with an empty argument lift an
/// administrative gate, which is exactly the accident that call exists to
/// prevent.
pub const ADMINISTRATIVE_HOLDER: &str = "administrative";

/// Why an account is gated.
///
/// The distinction is not cosmetic: it is what decides whether the row has a
/// deadline, and therefore whether a crash brings the account back on its own.
#[derive(Clone, Copy, PartialEq, Eq, Debug, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum GateReason {
    /// **Somebody decided.** An operator suspending an account, monetize
    /// locking a non-paying customer out. No deadline; it stays until it is
    /// lifted.
    Administrative,
    /// **A purge is holding it.** Carries [`AccountGate::expires`] and is
    /// re-stamped by the worker; see the module docs.
    Purge,
}

impl GateReason {
    /// The word that goes in a refusal an end user reads.
    pub fn as_str(self) -> &'static str {
        match self {
            GateReason::Administrative => "administrative",
            GateReason::Purge => "purge",
        }
    }
}

/// **One account, locked.**
///
/// Keyed in the identity table by [`AccountGate::account`], which is a
/// principal id — and therefore also the namespace that principal
/// intrinsically owns ([`crate::AuthTable::owns_account`]). One row covers both
/// senses of "this user is locked out" because in gunnar they are the same
/// name.
#[derive(Clone, PartialEq, Eq, Debug, serde::Serialize, serde::Deserialize)]
#[serde(deny_unknown_fields)]
pub struct AccountGate {
    /// The principal id, which is also the account namespace.
    pub account: String,
    /// Why it is gated: somebody decided ([`GateReason::Administrative`], no
    /// deadline) or a purge holds it ([`GateReason::Purge`], with `expires`).
    pub reason: GateReason,
    /// Who set it: a principal id for an operator, a job id for a purge. Read
    /// back by [`crate::AuthTable::lift_gate_held_by`], which is what stops a
    /// finishing purge from lifting somebody else's suspension.
    pub holder: String,
    /// The principal that asked, for the audit trail. A purge records the
    /// principal that called `Accounts.Purge`, not the worker thread.
    pub actor: String,
    /// Free text, shown to the locked-out user. **Not optional in practice**:
    /// a refusal with no reason is a support ticket.
    pub note: String,
    /// When it was set.
    pub at: UnixMillis,
    /// `None` for an administrative gate — it never expires on its own.
    /// `Some` for a purge lease; see [`PURGE_LEASE_MS`].
    pub expires: Option<UnixMillis>,
}

impl AccountGate {
    /// A gate a person or a service set, with no deadline.
    pub fn administrative(
        account: impl Into<String>,
        actor: impl Into<String>,
        note: impl Into<String>,
    ) -> Self {
        AccountGate {
            account: account.into(),
            reason: GateReason::Administrative,
            holder: ADMINISTRATIVE_HOLDER.to_owned(),
            actor: actor.into(),
            note: note.into(),
            at: now_ms(),
            expires: None,
        }
    }

    /// A purge's lease, expiring at `expires`.
    ///
    /// Takes the deadline rather than computing `now + PURGE_LEASE_MS` so a
    /// test can hand it a time in the past and see the expiry rule fire, which
    /// is the only way to observe the property that keeps an account from being
    /// bricked by a crash.
    pub fn purge_lease(
        account: impl Into<String>,
        job_id: impl Into<String>,
        expires: UnixMillis,
    ) -> Self {
        let job_id = job_id.into();
        AccountGate {
            account: account.into(),
            reason: GateReason::Purge,
            holder: job_id.clone(),
            actor: job_id,
            note: "a hard purge of this account is running".to_owned(),
            at: now_ms(),
            expires: Some(expires),
        }
    }

    /// Record the principal that asked for the purge, for the audit trail.
    pub fn by(mut self, actor: impl Into<String>) -> Self {
        self.actor = actor.into();
        self
    }

    /// Replace the note shown to the locked-out user.
    pub fn noted(mut self, note: impl Into<String>) -> Self {
        self.note = note.into();
        self
    }

    /// **Is this gate in force at `now`?**
    ///
    /// The one place the deadline is read, so an expired lease cannot be in
    /// force for the ACL and absent from the listing, or the other way round
    /// (one implementation).
    pub fn is_live_at(&self, now: UnixMillis) -> bool {
        match self.expires {
            None => true,
            Some(deadline) => now < deadline,
        }
    }

    /// [`AccountGate::is_live_at`] against the wall clock.
    pub fn is_live(&self) -> bool {
        self.is_live_at(now_ms())
    }
}