gunnar-core 3.0.0

gunnar's repository model, identity table, ACL and path sandbox. gunnar-ssh, gunnar-grpc and gunnar-cli all sit on it.
Documentation
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
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
//! **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"
    );
}