Skip to main content

gunnar_core/
gate.rs

1//! **The account gate: one row that says a user may do nothing.**
2//!
3//! # The problem it solves
4//!
5//! Gunnar could delete a *repository* — `Repos.Delete --purge`, `Repos.Purge`,
6//! `Repos.ReclaimOrphan` — and could not empty an *account*. The per-user hard
7//! purge (`Accounts.Purge`) is that verb, and it cannot be written safely
8//! without this module first, because a purge is not atomic: it walks an
9//! account's repositories and reclaims them one at a time, over minutes, while
10//! the server is still serving. Anything the user does during that walk lands
11//! behind it.
12//!
13//! # Why a lockout, and why it is TOTAL
14//!
15//! The tempting design is read-only: refuse the push, allow the fetch, "reads
16//! are harmless". **They are not.** In gunnar a read creates things:
17//!
18//! * a fetch opens the store, which lazily explodes objects into the read cache
19//!   — `gunnar-server`'s `storage.rs` calls that cache exactly what a `repo gc`
20//!   reclaims, so a fetch *re-creates* what a purge just unlinked;
21//! * the first read of a repository whose pack has no index builds one, and
22//!   writes it;
23//! * `Refs.Log` and the search path walk and cache.
24//!
25//! So a purge that permitted reads would be racing a reader that keeps putting
26//! bytes back, in a directory the purge has already walked past. The gate is
27//! therefore total: every level, every verb, both doors (the SSH listener and
28//! the control plane ask the same [`crate::AuthTable`]), and — see below — for
29//! everybody, not only for the account's owner.
30//!
31//! # Two directions, because a purge needs both
32//!
33//! [`crate::AuthTable::may`] consults the gate twice:
34//!
35//! * **the actor** — a gated principal does nothing, anywhere, including in
36//!   namespaces they merely hold a grant on. This is the half that means
37//!   "suspended".
38//! * **the account** — nobody reaches into a gated account, including a
39//!   stranger cloning a public repository in it and a collaborator with a
40//!   grant. This is the half that means a purge terminates.
41//!
42//! The second is the surprising one and it is deliberate. Gating `alice` takes
43//! `alice`'s public repositories off the air for everyone. For a purge that is
44//! the entire point; for a billing suspension it is the honest reading of
45//! "suspended" too. There is no knob to separate them, because the moment
46//! there is one, somebody sets it to the permissive value on the purge path.
47//!
48//! # The lease, and the account that would otherwise be bricked
49//!
50//! A gate is persisted. A purge that takes one and then dies — a panic, a
51//! SIGKILL, a power cut between two `unlink`s — leaves a locked account with no
52//! process left to unlock it, and nobody remembers a row they never saw. That
53//! is a worse outcome than an unfinished purge.
54//!
55//! So a gate taken *by a purge* is a **lease**: it carries [`AccountGate::expires`]
56//! and stops being in force when that passes. The purge worker re-stamps it as
57//! it goes, so a long purge does not unlock underneath itself, and a dead purge
58//! unlocks on its own within [`PURGE_LEASE`]. A gate taken by a *person*
59//! ([`AccountGate::administrative`]) carries no deadline at all: "suspended for
60//! non-payment" must not quietly time out.
61//!
62//! # Why the holder is on the row
63//!
64//! Two writers can hold an opinion about the same account at once: the purge
65//! job, and an operator who suspends the account while it runs. The row
66//! records [`AccountGate::holder`], and
67//! [`crate::AuthTable::lift_gate_held_by`] lifts it only if the holder still
68//! matches — so a purge finishing cannot silently un-suspend an account
69//! somebody suspended for a different reason.
70
71use crate::model::{now_ms, UnixMillis};
72
73/// How long a purge's lease on an account is valid without being re-stamped.
74///
75/// Ten minutes. The trade is one-sided in both directions and this is where it
76/// balances: too short and a purge that stalls on one enormous repository
77/// unlocks the account it is halfway through emptying; too long and a crashed
78/// purge holds a customer's account shut for that long with nothing running.
79/// The worker re-stamps after every repository it reclaims, so the only way to
80/// spend the whole lease is a single repository taking longer than ten minutes
81/// — and the only cost of that is that the lease has to be renewed once more.
82pub const PURGE_LEASE_MS: i64 = 10 * 60 * 1000;
83
84/// The holder recorded on a gate an operator or a service set by hand.
85///
86/// Not an empty string: an empty holder would make
87/// [`crate::AuthTable::lift_gate_held_by`] with an empty argument lift an
88/// administrative gate, which is exactly the accident that call exists to
89/// prevent.
90pub const ADMINISTRATIVE_HOLDER: &str = "administrative";
91
92/// Why an account is gated.
93///
94/// The distinction is not cosmetic: it is what decides whether the row has a
95/// deadline, and therefore whether a crash brings the account back on its own.
96#[derive(Clone, Copy, PartialEq, Eq, Debug, serde::Serialize, serde::Deserialize)]
97#[serde(rename_all = "snake_case")]
98pub enum GateReason {
99    /// **Somebody decided.** An operator suspending an account, monetize
100    /// locking a non-paying customer out. No deadline; it stays until it is
101    /// lifted.
102    Administrative,
103    /// **A purge is holding it.** Carries [`AccountGate::expires`] and is
104    /// re-stamped by the worker; see the module docs.
105    Purge,
106}
107
108impl GateReason {
109    /// The word that goes in a refusal an end user reads.
110    pub fn as_str(self) -> &'static str {
111        match self {
112            GateReason::Administrative => "administrative",
113            GateReason::Purge => "purge",
114        }
115    }
116}
117
118/// **One account, locked.**
119///
120/// Keyed in the identity table by [`AccountGate::account`], which is a
121/// principal id — and therefore also the namespace that principal
122/// intrinsically owns ([`crate::AuthTable::owns_account`]). One row covers both
123/// senses of "this user is locked out" because in gunnar they are the same
124/// name.
125#[derive(Clone, PartialEq, Eq, Debug, serde::Serialize, serde::Deserialize)]
126#[serde(deny_unknown_fields)]
127pub struct AccountGate {
128    /// The principal id, which is also the account namespace.
129    pub account: String,
130    /// Why it is gated: somebody decided ([`GateReason::Administrative`], no
131    /// deadline) or a purge holds it ([`GateReason::Purge`], with `expires`).
132    pub reason: GateReason,
133    /// Who set it: a principal id for an operator, a job id for a purge. Read
134    /// back by [`crate::AuthTable::lift_gate_held_by`], which is what stops a
135    /// finishing purge from lifting somebody else's suspension.
136    pub holder: String,
137    /// The principal that asked, for the audit trail. A purge records the
138    /// principal that called `Accounts.Purge`, not the worker thread.
139    pub actor: String,
140    /// Free text, shown to the locked-out user. **Not optional in practice**:
141    /// a refusal with no reason is a support ticket.
142    pub note: String,
143    /// When it was set.
144    pub at: UnixMillis,
145    /// `None` for an administrative gate — it never expires on its own.
146    /// `Some` for a purge lease; see [`PURGE_LEASE_MS`].
147    pub expires: Option<UnixMillis>,
148}
149
150impl AccountGate {
151    /// A gate a person or a service set, with no deadline.
152    pub fn administrative(
153        account: impl Into<String>,
154        actor: impl Into<String>,
155        note: impl Into<String>,
156    ) -> Self {
157        AccountGate {
158            account: account.into(),
159            reason: GateReason::Administrative,
160            holder: ADMINISTRATIVE_HOLDER.to_owned(),
161            actor: actor.into(),
162            note: note.into(),
163            at: now_ms(),
164            expires: None,
165        }
166    }
167
168    /// A purge's lease, expiring at `expires`.
169    ///
170    /// Takes the deadline rather than computing `now + PURGE_LEASE_MS` so a
171    /// test can hand it a time in the past and see the expiry rule fire, which
172    /// is the only way to observe the property that keeps an account from being
173    /// bricked by a crash.
174    pub fn purge_lease(
175        account: impl Into<String>,
176        job_id: impl Into<String>,
177        expires: UnixMillis,
178    ) -> Self {
179        let job_id = job_id.into();
180        AccountGate {
181            account: account.into(),
182            reason: GateReason::Purge,
183            holder: job_id.clone(),
184            actor: job_id,
185            note: "a hard purge of this account is running".to_owned(),
186            at: now_ms(),
187            expires: Some(expires),
188        }
189    }
190
191    /// Record the principal that asked for the purge, for the audit trail.
192    pub fn by(mut self, actor: impl Into<String>) -> Self {
193        self.actor = actor.into();
194        self
195    }
196
197    /// Replace the note shown to the locked-out user.
198    pub fn noted(mut self, note: impl Into<String>) -> Self {
199        self.note = note.into();
200        self
201    }
202
203    /// **Is this gate in force at `now`?**
204    ///
205    /// The one place the deadline is read, so an expired lease cannot be in
206    /// force for the ACL and absent from the listing, or the other way round
207    /// (one implementation).
208    pub fn is_live_at(&self, now: UnixMillis) -> bool {
209        match self.expires {
210            None => true,
211            Some(deadline) => now < deadline,
212        }
213    }
214
215    /// [`AccountGate::is_live_at`] against the wall clock.
216    pub fn is_live(&self) -> bool {
217        self.is_live_at(now_ms())
218    }
219}