1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
//! **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;
/// 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.
/// **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.