gunnar_core/auth.rs
1//! The identity table and the ACL (the ACL model).
2//!
3//! **gunnar owns its own table.** No OS users, no `~/.ssh/authorized_keys`, no
4//! `/etc/passwd`, no home directory. The server has to run as PID 1 in a
5//! distroless image where none of those exist (the no-OS server rule). Identity comes from
6//! the SSH key and nothing else; the SSH *username* is decorative, so
7//! `git@host:ns/repo.git` works and `git` means nothing.
8//!
9//! The auth flow the SSH listener runs is three calls:
10//!
11//! ```text
12//! russh auth_publickey → Fingerprint::of_key(offered)
13//! → AuthTable::principal_by_fingerprint(&fp) // who
14//! → RepoCatalog::open(&sandboxed_path) // what
15//! → AuthTable::may(&principal.id, &repo, level) // may they
16//! ```
17//!
18//! `upload-pack` needs [`Level::Read`], `receive-pack` needs [`Level::Write`].
19//!
20//! # The one caller that never authenticates
21//!
22//! A **public** repository is readable with no key at all. Such a caller is
23//! served as [`Principal::anonymous`], which is not in this table, cannot be
24//! put in it, and holds neither key nor grant. The question *may it?* still
25//! goes to [`AuthTable::may`], exactly as it does for everybody else. Rule 0
26//! there is the whole of what anonymous can ever be allowed: `Read`, on a
27//! `Public` repository, and nothing anywhere else. See
28//! [`ANONYMOUS_PRINCIPAL_ID`](crate::model::ANONYMOUS_PRINCIPAL_ID).
29
30use std::sync::Arc;
31
32use crate::control::ControlStore;
33use crate::doc::{impl_doc, DocRef, ShardedMap};
34use crate::error::{Error, Result};
35use crate::key_attestation::AttestingKey;
36use crate::model::{
37 now_ms, AccessToken, Grant, Level, Principal, PrincipalKind, PublicKey, Repo, Scope, UnixMillis,
38};
39
40mod sso_table;
41pub use sso_table::{
42 SsoImportOp, SsoImportRow, SsoLogin, SsoLoginDone, SsoProvision, SsoUserPatch,
43};
44
45/// **The name the identity table is stored under** in the control store.
46///
47/// It still reads `refs/gunnar/auth`. Until 2026-08-09 that WAS a git reference
48/// (the certificate-identity plan); it is now a key in redb, and the string was kept because it is
49/// what every error message, log line and runbook calls this document.
50pub const AUTH_REF: &str = "refs/gunnar/auth";
51
52/// Id of the principal [`AuthTable::bootstrap_admin`] installs when no name is
53/// given (`--admin-key` on the command line).
54pub const BOOTSTRAP_ADMIN_ID: &str = "admin";
55
56/// **Clans** (owner, 2026-10-05): ordinary accounts grouped, each giving the
57/// clan part of its own room; the clan's admin administers what is made in it.
58mod clan_table;
59pub use clan_table::{
60 AccountRoom, Clan, ClanInvite, ClanMember, ClanRefusal, ClanRepoOffer, ClanRole,
61};
62
63/// Every fingerprint gunnar computes or accepts carries this prefix.
64pub const SHA256_PREFIX: &str = "SHA256:";
65
66/// The `algo` `Repos.RecipientKeys` names for a person's **quantum-safe encryption
67/// key** (crypt-pq): an `age1pq1…` recipient (age's hybrid ML-KEM-768 + X25519),
68/// kept in the encryption-key store since CRYPT-YUBI. The SSH key table takes no
69/// such line any more (CRYPT-PQ-LEGACY); a row with this `algo` from before that
70/// is never a login and never attests.
71pub const PQ_RECIPIENT_ALGO: &str = "mlkem768x25519";
72
73/// **The one fingerprint: SHA-256 over the SSH wire-format public key
74/// blob, rendered `SHA256:<base64>` exactly as `ssh-keygen -lf` prints it.**
75///
76/// `ssh_wire_blob` is the `string` an SSH public key is encoded as on the wire:
77/// `ssh-ed25519` followed by its length-prefixed key material. Same bytes as
78/// the base64 payload in the middle of an `authorized_keys` line. Both
79/// `ssh-key` versions in gunnar's graph produce it with `PublicKey::to_bytes()`.
80///
81/// # Why bytes and not a type
82///
83/// `gunnar-ssh` links `ssh-key 0.7.0-rc.10`, because `russh 0.61.2` pins that
84/// version exactly and `Handler::auth_publickey` hands over *its*
85/// `PublicKey`. This crate links the workspace's `ssh-key 0.6.7`. They are
86/// semver-incompatible and both are in the graph, so a key that arrives through
87/// russh can never be passed to a 0.6 API. The workspace is deliberately
88/// **not** bumped to a release candidate to paper over that.
89///
90/// A blob crosses the line. Define the fingerprint over the blob and one
91/// fingerprint serves both crates: `gunnar-ssh` computes the same string the
92/// [`AuthTable`] stores, and `tests/fingerprint.rs` pins it against literal
93/// `ssh-keygen -lf` output rather than against gunnar's own arithmetic.
94///
95/// Any bytes at all are accepted: this is a hash, not a parser, and refusing to
96/// hash a malformed blob would only turn "this key is not registered" into an
97/// error path with a second answer in it.
98pub fn fingerprint_of_blob(ssh_wire_blob: &[u8]) -> Fingerprint {
99 use base64ct::Encoding as _;
100 use sha2::Digest as _;
101
102 let digest = sha2::Sha256::digest(ssh_wire_blob);
103 Fingerprint(format!(
104 "{SHA256_PREFIX}{}",
105 base64ct::Base64Unpadded::encode_string(&digest)
106 ))
107}
108
109/// The stored form of an access token secret: SHA-256, lowercase hex.
110///
111/// Hex rather than the `SHA256:<base64>` spelling [`Fingerprint`] uses, and the
112/// difference is on purpose: a fingerprint is meant to be compared by eye
113/// against what `ssh-keygen -lf` prints on somebody's laptop, and a token hash
114/// is never compared against anything outside gunnar. Sharing the spelling
115/// would invite the two to be confused in a log, where one identifies a key and
116/// the other is all that is left of a password.
117///
118/// **Length is not a secret and neither is this value.** It is what
119/// `token list` prints. What must never be printed is the input.
120///
121/// Public because the HTTP front end needs the same value to record a use and
122/// to name the token in a log line, and computing it there would be a second
123/// definition of the stored form of a credential (one implementation).
124pub fn token_hash(secret: &str) -> String {
125 use sha2::Digest as _;
126
127 hex::encode(sha2::Sha256::digest(secret.as_bytes()))
128}
129
130/// How fresh a token's recorded `last_used` may be before another
131/// authentication skips rewriting it — see [`AuthTable::touch_token`], where
132/// the measurement that sets this is written down. One minute: coarse enough
133/// that the shared-document CAS leaves the request path, fine enough that
134/// `token list` still answers "is this credential alive" honestly.
135pub const LAST_USED_GRANULARITY: UnixMillis = 60_000;
136
137/// One user of a bulk import — see [`AuthTable::bulk_add_users`].
138#[derive(Clone, Debug)]
139pub struct BulkUser {
140 /// The principal to create.
141 pub principal: Principal,
142 /// [`token_hash`] of an access-token secret, when the user gets one. The
143 /// secret itself stays with the caller, as it does for
144 /// [`AuthTable::register_token`].
145 pub token_hash: Option<String>,
146 /// The label stored beside the token; ignored without `token_hash`.
147 pub token_label: String,
148 /// An OpenSSH public key line to register for the user, when they get one.
149 /// Parsed and fingerprinted here exactly as [`AuthTable::register_key`]
150 /// does, so a bulk-imported key and a singly-registered one are the same
151 /// record.
152 pub openssh_public_key: Option<String>,
153 /// Grants to install, replacing nothing — the principal is new.
154 pub grants: Vec<Grant>,
155}
156
157/// Mint a token secret: 256 bits of OS entropy, `gunnar_pat_<base64url>`.
158///
159/// Separate from [`AuthTable::register_token`], which is handed a secret and
160/// stores only its hash — the same split as [`mint_ed25519`](crate::mint_ed25519)
161/// and [`AuthTable::register_key`]. Minting and storing are different acts, and
162/// keeping them apart is what makes it possible to state that the table never
163/// holds a usable credential.
164///
165/// **The prefix is not decoration.** It is what makes a leaked token findable:
166/// a bare base64 blob in a log or a commit is indistinguishable from any other
167/// blob, and every forge that has been through this ended up prefixing for the
168/// same reason. It costs eleven bytes.
169pub fn mint_token_secret() -> String {
170 use base64ct::Encoding as _;
171 use rand::Rng as _;
172
173 let mut bytes = [0u8; 32];
174 // The OS generator panics rather than returning short: a server that
175 // could not get entropy must not mint a guessable credential.
176 crate::os_rng().fill_bytes(&mut bytes);
177 format!(
178 "gunnar_pat_{}",
179 base64ct::Base64UrlUnpadded::encode_string(&bytes)
180 )
181}
182
183/// An OpenSSH `SHA256:<base64>` public key fingerprint.
184///
185/// This is exactly what `ssh-keygen -lf` prints and what OpenSSH shows on an
186/// unknown-host prompt: unpadded base64 of the sha256 of the wire encoding.
187/// gunnar never computes "something like" a fingerprint. An operator has to be
188/// able to compare what gunnar lists against what `ssh-keygen` says on their
189/// laptop, so `tests/fingerprint.rs` pins it to known answers taken from the
190/// real OpenSSH binary.
191///
192/// Every constructor routes through [`fingerprint_of_blob`], so there is one
193/// definition and it is a definition over bytes.
194#[derive(Clone, PartialEq, Eq, PartialOrd, Ord, Hash, serde::Serialize, serde::Deserialize)]
195pub struct Fingerprint(String);
196
197/// A certificate's DER, from bytes that may be PEM-armoured or already DER.
198///
199/// Lives beside [`fingerprint_of_certificate`] because the two have to agree
200/// about what "the certificate bytes" are. If the caller hashed the armoured
201/// form and this unwrapped it, the same certificate would register under two
202/// fingerprints depending only on which shape somebody happened to hand over.
203pub fn certificate_der(raw: &[u8]) -> Result<Vec<u8>> {
204 use base64ct::Encoding as _;
205
206 let text = String::from_utf8_lossy(raw);
207 const BEGIN: &str = "-----BEGIN CERTIFICATE-----";
208 const END: &str = "-----END CERTIFICATE-----";
209 let Some(start) = text.find(BEGIN) else {
210 // No armour: it is already DER.
211 return Ok(raw.to_vec());
212 };
213 let body = &text[start + BEGIN.len()..];
214 let end = body
215 .find(END)
216 .ok_or_else(|| Error::Key("the PEM has a BEGIN line and no END line".to_owned()))?;
217 let b64: String = body[..end].chars().filter(|c| !c.is_whitespace()).collect();
218 base64ct::Base64::decode_vec(&b64)
219 .map_err(|e| Error::Key(format!("the PEM body is not valid base64: {e}")))
220}
221
222/// The same SHA256 fingerprint, taken over an X.509 certificate's DER.
223///
224/// # Why mTLS reuses this type rather than growing its own
225///
226/// A `Fingerprint` is the whole of what gunnar knows about a credential: the
227/// `AuthTable` maps one to a principal, and every `Grant` check downstream works
228/// on the principal. So a second way to *arrive* at a fingerprint is the entire
229/// mTLS change — no new identity, no second grant path, nothing downstream aware
230/// there are now two kinds of credential (the mTLS plan).
231///
232/// # Over the DER, not the public key
233///
234/// The whole certificate, so that re-issuing the same subject with the same key
235/// under a new validity window produces a **different** fingerprint. That is the
236/// conservative direction: a re-issued certificate has to be registered again,
237/// rather than silently inheriting the grants of the one it replaced. A
238/// deployment that wants key continuity across renewal wants a different scheme
239/// than a fingerprint, and should not get it by accident here.
240///
241/// Any bytes are accepted, for the same reason [`fingerprint_of_blob`] accepts
242/// any: this is a hash, not a parser, and refusing to hash a malformed
243/// certificate would only turn "not registered" into a second answer.
244pub fn fingerprint_of_certificate(der: &[u8]) -> Fingerprint {
245 use base64ct::Encoding as _;
246 use sha2::Digest as _;
247
248 let digest = sha2::Sha256::digest(der);
249 Fingerprint(format!(
250 "{SHA256_PREFIX}{}",
251 base64ct::Base64Unpadded::encode_string(&digest)
252 ))
253}
254
255impl Fingerprint {
256 /// Fingerprint a parsed key.
257 ///
258 /// Convenience over [`fingerprint_of_blob`] for the crates that link *this*
259 /// `ssh-key`. `gunnar-ssh` cannot use it (see [`fingerprint_of_blob`]) and
260 /// calls that function with `russh`'s key blob instead.
261 pub fn of_key(key: &ssh_key::PublicKey) -> Self {
262 // Encoding a key that was already parsed cannot fail: `to_bytes` only
263 // writes the decoded `KeyData` into a fresh `Vec`.
264 let blob = key
265 .to_bytes()
266 .expect("a parsed public key always re-encodes to its own wire form");
267 fingerprint_of_blob(&blob)
268 }
269
270 /// Parse an `authorized_keys`-style line and fingerprint it.
271 ///
272 /// Accepts what OpenSSH writes: `<algo> <base64> [comment]`.
273 pub fn of_openssh(line: &str) -> Result<Self> {
274 Ok(Self::of_key(&parse_public_key(line)?))
275 }
276
277 /// Accept a fingerprint that arrived as text: config file, gRPC request,
278 /// an operator's terminal.
279 ///
280 /// Validated rather than trusted: `SHA256:` plus 43 base64 characters. A
281 /// malformed fingerprint that gets stored anyway is a key that can never be
282 /// matched and never be revoked.
283 pub fn parse(text: &str) -> Result<Self> {
284 let body = text.strip_prefix(SHA256_PREFIX).ok_or_else(|| {
285 Error::BadFingerprint(format!("{text:?}: missing the {SHA256_PREFIX} prefix"))
286 })?;
287 if body.len() != 43 {
288 return Err(Error::BadFingerprint(format!(
289 "{text:?}: expected 43 base64 characters after the prefix, got {}",
290 body.len()
291 )));
292 }
293 if !body
294 .bytes()
295 .all(|b| b.is_ascii_alphanumeric() || b == b'+' || b == b'/')
296 {
297 return Err(Error::BadFingerprint(format!(
298 "{text:?}: not unpadded base64"
299 )));
300 }
301 Ok(Fingerprint(text.to_owned()))
302 }
303
304 /// The `SHA256:...` text.
305 pub fn as_str(&self) -> &str {
306 &self.0
307 }
308}
309
310impl std::fmt::Display for Fingerprint {
311 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
312 f.write_str(&self.0)
313 }
314}
315
316impl std::fmt::Debug for Fingerprint {
317 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
318 write!(f, "Fingerprint({})", self.0)
319 }
320}
321
322impl std::str::FromStr for Fingerprint {
323 type Err = Error;
324 fn from_str(s: &str) -> Result<Self> {
325 Fingerprint::parse(s)
326 }
327}
328
329/// Parse an `authorized_keys` line into a key.
330///
331/// Pure Rust, through `ssh-key`. There is no `ssh-keygen` subprocess anywhere
332/// in gunnar (the no-OS server rule).
333pub fn parse_public_key(line: &str) -> Result<ssh_key::PublicKey> {
334 let line = line.trim();
335 if line.is_empty() {
336 return Err(Error::Key("empty".to_owned()));
337 }
338 if line.starts_with("age1") {
339 return Err(Error::Key(AGE_LINE_IS_NO_SSH_KEY.to_owned()));
340 }
341 ssh_key::PublicKey::from_openssh(line).map_err(Error::from)
342}
343
344/// The refusal for an `age1…` line given as an SSH key (CRYPT-PQ-LEGACY): it is an
345/// encryption key, and it has its own way in.
346pub const AGE_LINE_IS_NO_SSH_KEY: &str = "an age1… line is an encryption key, not an SSH key: \
347 let it open your encryption key with `gunnar crypt key add-pq`";
348
349/// The identity table as it is stored.
350///
351/// All three maps are sharded (`doc.rs`): at forge scale this document holds
352/// every principal, every public key and every grant on the server, and the
353/// pre-2026-08-03 layout put all of them inline in one blob that was parsed
354/// four times per SSH connection.
355///
356/// **`grants` is keyed by principal**, not a `Vec`. It was a flat list, so
357/// [`AuthTable::may`] — three calls deep on every connection, through
358/// `Control::access_for` — scanned every grant on the server to answer one
359/// question. Keyed, the answer is one shard probe and a walk of that
360/// principal's own handful of grants.
361#[derive(Default, Clone, serde::Serialize, serde::Deserialize)]
362#[serde(deny_unknown_fields)]
363struct AuthDoc {
364 #[serde(default)]
365 principals: ShardedMap<Principal>,
366 /// Keyed by fingerprint text, which is what an SSH handshake produces.
367 #[serde(default)]
368 keys: ShardedMap<PublicKey>,
369 /// Keyed by [`Principal::id`]: one principal's grants, sorted.
370 #[serde(default)]
371 grants: ShardedMap<Vec<Grant>>,
372 /// Keyed by [`AccessToken::hash`], so an HTTP request costs one probe and
373 /// never a scan.
374 #[serde(default)]
375 tokens: ShardedMap<AccessToken>,
376 /// **Group membership, keyed by member** (punkt 9): one principal's id maps
377 /// to the sorted ids of the groups it belongs to.
378 ///
379 /// Keyed by member and not by group **for the same reason `grants` is keyed
380 /// by principal**: [`AuthTable::may`] runs on every connection and asks one
381 /// question — *what may this caller reach* — so the walk it needs is "which
382 /// groups is this caller in", which is one shard probe here. The inverse
383 /// question, *who is in this group*, is a listing
384 /// ([`AuthTable::members_of`]) and walks, exactly as `grants(None)` walks:
385 /// a listing is allowed to be O(rows), a connection is not.
386 ///
387 /// A group is itself a principal, so a group id can appear as a *member*
388 /// here too — that is nesting, and [`AuthTable::grant_principals`] resolves
389 /// it transitively and cycle-safely.
390 #[serde(default)]
391 memberships: ShardedMap<Vec<String>>,
392 /// **Whether bootstrap has ever happened, as its own record.**
393 ///
394 /// Not a `ShardedMap`, not derived from any of the four above, and never
395 /// touched by [`AuthTable::remove_principal`] or
396 /// [`AuthTable::revoke_key`]: it is a field beside the identity table, not
397 /// a row in it. That separation is the whole security property. "Bootstrap
398 /// has happened" spelled as `!keys.is_empty()` would mean *deleting every
399 /// key reopens the unauthenticated enrolment window*, which is the first
400 /// thing an attacker who can reach `Access.RemovePrincipal` would try.
401 ///
402 /// It rides in the same document, and therefore in the same
403 /// compare-and-swap on `refs/gunnar/auth`, as the identity it installs.
404 /// That is deliberate and is what makes "exactly one of two concurrent
405 /// enrolments wins" true by construction rather than by a lock: the loser
406 /// re-reads inside [`DocRef::update`] and finds the seal already set. A
407 /// separate reference would need two writes and could be interrupted
408 /// between them.
409 #[serde(default, skip_serializing_if = "Option::is_none")]
410 bootstrap: Option<BootstrapRecord>,
411 /// **Accounts that are locked out**, keyed by principal id (see
412 /// [`crate::gate`]).
413 ///
414 /// In this document and not beside it, for the reason `bootstrap` is: the
415 /// gate is read on the same code path as the grants it overrides, and a
416 /// second document would mean a second read, a second cache and a second
417 /// version — three places for the lockout and the ACL to disagree, with the
418 /// permissive answer winning silently (the one-sandbox rule). Here they are one blob and
419 /// one compare-and-swap: a gate and the grants it overrides are never seen
420 /// from different versions of the table.
421 ///
422 /// Sharded like the other maps, because [`AuthTable::may`] probes it on
423 /// **every** connection and a whole-table parse per SSH handshake is the
424 /// cost the sharding removed.
425 #[serde(default)]
426 gates: ShardedMap<crate::gate::AccountGate>,
427 /// **Display name → principal id**, so a name is unique by construction
428 /// and a lookup by name is one probe rather than a walk.
429 ///
430 /// Written in the same compare-and-swap as the `principals` row it points
431 /// at — by [`AuthTable::add_principal`], [`AuthTable::rename_principal`]
432 /// and [`AuthTable::remove_principal`] — so the index and the row are never
433 /// seen from two versions of the table.
434 ///
435 /// **Rows from before this index existed have no entry**, and need none:
436 /// every one of them was created with `name == id`, so a probe of
437 /// `principals` by the name finds it. [`AuthTable::principal_by_name`] and
438 /// the uniqueness check both take that second step, which is what makes
439 /// the index additive — no migration, and a table written by an older
440 /// server reads exactly as it did.
441 #[serde(default)]
442 names: ShardedMap<String>,
443 /// **Employee → the account that enrolled it** (owner, 2026-10-03: "a user
444 /// starts an account. he can choose to enroll new users=employees then it
445 /// becomes an enterprise account").
446 ///
447 /// Written ONLY by [`AuthTable::enroll_employee`], in the same
448 /// compare-and-swap as the principal row it creates, so a principal is an
449 /// employee from the moment it exists or never: there is no writer that
450 /// binds an EXISTING principal to an account ("they might have repos
451 /// already, that would be a risk for the company"). Removed with the
452 /// principal ([`AuthTable::remove_principal`]). An account with at least
453 /// one row here IS an enterprise account; there is no flag to set.
454 #[serde(default)]
455 employers: ShardedMap<String>,
456 /// **Employee → the room it holds out of its account's room** (owner,
457 /// 2026-10-04, "Add users"). Written in the same compare-and-swap as the
458 /// employee it belongs to, and removed with it (the room goes back to the
459 /// account). An employee from before rooms existed has no row and holds
460 /// nothing.
461 #[serde(default)]
462 rooms: ShardedMap<EmployeeRoom>,
463 /// **Account → its SEATS**: places for employees not yet named, each
464 /// holding its room, filled one by one by a seat claim (an invite, later
465 /// an import). Lots, not a counter, because two orders may differ in the
466 /// room per seat.
467 #[serde(default)]
468 seats: ShardedMap<Vec<SeatLot>>,
469 /// **Order → what it granted**, so a seller that retries a grant after a
470 /// timeout is answered with the first one instead of granting twice.
471 #[serde(default)]
472 room_orders: ShardedMap<RoomGrant>,
473 /// **Clans** (owner, 2026-10-05), keyed by the clan's name — which is its
474 /// namespace. Here, beside the grants, for the reason `gates` is: rule 2½
475 /// of [`AuthTable::may`] reads it on the same path, from the same version.
476 /// Not written at all until the first clan exists, so a table with none
477 /// is byte-for-byte what it was before clans.
478 #[serde(default, skip_serializing_if = "ShardedMap::is_empty")]
479 clans: ShardedMap<clan_table::Clan>,
480 /// **Account → the clans it is in or invited to**, by name: what it gives
481 /// its clans is one probe on the push path (its own room is its cap less
482 /// that), and "my clans" is a probe, not a walk.
483 #[serde(default, skip_serializing_if = "ShardedMap::is_empty")]
484 clan_index: ShardedMap<Vec<String>>,
485 /// **Web credentials — the people's passkeys** (Stage L, 2026-10-04),
486 /// keyed by the credential id (base64url), which is what a login names.
487 /// In this document for the reason `keys` are: a passkey and the principal
488 /// it logs in as are one compare-and-swap, it goes with the principal
489 /// ([`AuthTable::remove_principal`]), and it reaches the twin with the
490 /// rest of the identity table — a reinstalled front keeps every login.
491 #[serde(default)]
492 credentials: ShardedMap<crate::model::WebCredential>,
493 /// **Invites — pending passkey enrolments** (Stage L), keyed by the
494 /// invite id. Here for the reason `credentials` are: redeeming one and
495 /// storing the passkey it enrols are one compare-and-swap, and an invite
496 /// goes with its principal.
497 #[serde(default)]
498 invites: ShardedMap<crate::model::FirstInviteRow>,
499 /// **Redeemed invites** (2026-10-19), keyed by the secret's hash: so
500 /// `Access.CheckInvite` can say USED rather than UNKNOWN for an invite a
501 /// passkey was registered with, and a console refuses it before a second
502 /// passkey replaces the first on the person's device. Written in the
503 /// redemption's own compare-and-swap, kept [`REDEEMED_INVITE_KEEP_MS`],
504 /// removed with the principal. Written only once it holds a row, as the
505 /// SSO maps are: an older gunnar still reads a table that never had one.
506 #[serde(default, skip_serializing_if = "ShardedMap::is_empty")]
507 redeemed_invites: ShardedMap<RedeemedInvite>,
508 /// **The operator's looks into accounts** (2026-10-05), keyed by
509 /// `account␟operator`: the latest request per pair. Here, in the table
510 /// [`AuthTable::may`] reads, for the reason `gates` is: an approved look
511 /// widens grants and must be read from the same version as they are.
512 ///
513 /// Written only once it holds a row, as `clans` and SSO's maps are: a
514 /// table no operator ever asked into stays readable by an older gunnar.
515 #[serde(default, skip_serializing_if = "ShardedMap::is_empty")]
516 operator_access: ShardedMap<operator_access::OperatorAccess>,
517 /// **Company SSO** (owner, 2026-10-05): each account's identity provider,
518 /// keyed by the account. No secret is in it (see [`crate::sso`]).
519 ///
520 /// This map and the five SSO maps below are written only once they hold
521 /// a row (as `clans` are), and dropped again with their last one: a table
522 /// that never used SSO is byte-for-byte what it was before SSO, so an
523 /// older gunnar — whose `AuthDoc` is `deny_unknown_fields` — still reads
524 /// it on a downgrade or a twin not yet upgraded.
525 #[serde(default, skip_serializing_if = "ShardedMap::is_empty")]
526 sso_providers: ShardedMap<crate::sso::SsoProvider>,
527 /// Email domain → the account whose IdP it leads to. One domain, one
528 /// account; written with the provider row.
529 #[serde(default, skip_serializing_if = "ShardedMap::is_empty")]
530 sso_domains: ShardedMap<String>,
531 /// **The companies' directories**: principal id → its directory row. An
532 /// employee from SSO, SCIM or a CSV import has one; written in the same
533 /// compare-and-swap as the employee, removed with it.
534 #[serde(default, skip_serializing_if = "ShardedMap::is_empty")]
535 sso_users: ShardedMap<crate::sso::SsoUser>,
536 /// `s␟<account>␟<subject key>` and `u␟<account>␟<user name>` → principal
537 /// id: a login resolves its subject in one probe, never across accounts.
538 #[serde(default, skip_serializing_if = "ShardedMap::is_empty")]
539 sso_index: ShardedMap<String>,
540 /// SCIM bearers by the SHA-256 of their secret.
541 #[serde(default, skip_serializing_if = "ShardedMap::is_empty")]
542 scim_tokens: ShardedMap<crate::sso::ScimToken>,
543 /// SCIM groups by id.
544 #[serde(default, skip_serializing_if = "ShardedMap::is_empty")]
545 sso_groups: ShardedMap<crate::sso::SsoGroup>,
546}
547
548impl_doc!(AuthDoc {
549 principals,
550 keys,
551 grants,
552 tokens,
553 memberships,
554 gates,
555 names,
556 clans,
557 clan_index,
558 employers,
559 rooms,
560 seats,
561 room_orders,
562 credentials,
563 invites,
564 redeemed_invites,
565 operator_access,
566 sso_providers,
567 sso_domains,
568 sso_users,
569 sso_index,
570 scim_tokens,
571 sso_groups
572});
573
574/// How long a redeemed invite is remembered as USED: an invite lives at most
575/// thirty days, so a link older than that reads expired anyway.
576pub const REDEEMED_INVITE_KEEP_MS: UnixMillis = 30 * 24 * 60 * 60 * 1000;
577
578/// **An invite that was redeemed** (2026-10-19): whose passkey it enrolled, and when.
579#[derive(Clone, PartialEq, Eq, Debug, serde::Serialize, serde::Deserialize)]
580pub struct RedeemedInvite {
581 /// The principal whose passkey it enrolled.
582 pub principal_id: String,
583 /// When it was redeemed.
584 pub redeemed: UnixMillis,
585}
586
587/// **The room one employee holds** out of its account's room.
588#[derive(Clone, PartialEq, Eq, Debug, Default, serde::Serialize, serde::Deserialize)]
589#[serde(deny_unknown_fields)]
590pub struct EmployeeRoom {
591 /// Bytes.
592 pub room_bytes: u64,
593 /// The order it came with (the seat's, for a filled seat); empty: none.
594 #[serde(default, skip_serializing_if = "String::is_empty")]
595 pub order: String,
596}
597
598/// **A block of SEATS** an account holds: `total` places for employees not
599/// yet named, each holding `room_bytes` of the account's room.
600#[derive(Clone, PartialEq, Eq, Debug, Default, serde::Serialize, serde::Deserialize)]
601#[serde(deny_unknown_fields)]
602pub struct SeatLot {
603 /// Bytes per seat.
604 pub room_bytes: u64,
605 /// How many seats the lot was granted with.
606 pub total: u32,
607 /// The employees that filled a seat, in the order they came. A removed
608 /// employee leaves this list, and its seat is open again.
609 #[serde(default, skip_serializing_if = "Vec::is_empty")]
610 pub filled: Vec<String>,
611 /// The order that granted it; empty: none.
612 #[serde(default, skip_serializing_if = "String::is_empty")]
613 pub order: String,
614 /// When.
615 pub at: UnixMillis,
616}
617
618impl SeatLot {
619 /// Seats not yet filled.
620 pub fn open(&self) -> u32 {
621 self.total
622 .saturating_sub(u32::try_from(self.filled.len()).unwrap_or(u32::MAX))
623 }
624}
625
626/// **What an order granted**, kept for its retry.
627#[derive(Clone, PartialEq, Eq, Debug, Default, serde::Serialize, serde::Deserialize)]
628#[serde(deny_unknown_fields)]
629pub struct RoomGrant {
630 /// The account.
631 pub account: String,
632 /// The named employee, or empty for seats.
633 #[serde(default, skip_serializing_if = "String::is_empty")]
634 pub principal: String,
635 /// Seats granted (0 for a named employee).
636 #[serde(default)]
637 pub seats: u32,
638 /// Bytes per employee or seat.
639 pub room_bytes: u64,
640 /// What it asked of the account, bytes.
641 #[serde(default)]
642 pub need_bytes: u64,
643}
644
645/// **What an enrolment asks of the account's room** ("Add users").
646#[derive(Clone, PartialEq, Eq, Debug, Default)]
647pub struct RoomAsk {
648 /// Bytes per employee or seat. Ignored for a seat claim: the seat's.
649 pub room_bytes: u64,
650 /// Seats to grant, unnamed. 0: one named employee.
651 pub seats: u32,
652 /// Fill an open seat with the named employee instead of asking for room.
653 pub from_seat: bool,
654 /// The account's room: its cap. `None`: nothing caps this account on
655 /// this server.
656 pub cap: Option<u64>,
657 /// What the account stores now, bytes (counted against `cap`).
658 pub stored: u64,
659 /// The order this belongs to (idempotency); empty: none.
660 pub order: String,
661 /// Ask, write nothing.
662 pub dry_run: bool,
663}
664
665/// **The answer** to a [`RoomAsk`].
666#[derive(Clone, PartialEq, Eq, Debug, Default)]
667pub struct RoomAnswer {
668 /// The named employee (unset for seats and for a dry run).
669 pub principal: Option<Principal>,
670 /// Bytes each new employee or seat holds.
671 pub room_bytes: u64,
672 /// The account's seats after this: every seat granted…
673 pub seats: u32,
674 /// …and the ones not yet filled.
675 pub seats_open: u32,
676 /// What was asked of the account's room, bytes.
677 pub need_bytes: u64,
678 /// The account's room neither stored nor promised, before this; `None`
679 /// when nothing caps it.
680 pub unused_bytes: Option<u64>,
681 /// How much is missing (a dry run's answer; the real call refuses).
682 pub shortfall_bytes: u64,
683 /// Nothing was written: a dry run, or the retry of a granted order.
684 pub unchanged: bool,
685}
686
687/// **The record that the unauthenticated enrolment window has been used.**
688///
689/// Written once, never rewritten, never removed. Every field is here so an
690/// operator can answer *who opened this server, with which key, and when* from
691/// the server itself rather than from a log that may have rotated.
692#[derive(Clone, PartialEq, Eq, Debug, serde::Serialize, serde::Deserialize)]
693#[serde(deny_unknown_fields)]
694pub struct BootstrapRecord {
695 /// When the window closed.
696 pub at: UnixMillis,
697 /// The principal that was created. It may since have been removed; the
698 /// record still stands, which is the point.
699 pub principal_id: String,
700 /// The `SHA256:…` fingerprint of the key it was created with.
701 pub fingerprint: String,
702 /// The account (namespace) that principal is intrinsically admin of. Equal
703 /// to `principal_id`; carried explicitly so a later change to that rule is
704 /// a visible schema change and not a silent re-derivation.
705 pub account: String,
706}
707
708/// What [`AuthTable::bootstrap_admin`] did.
709#[derive(Clone, PartialEq, Eq, Debug)]
710pub enum Bootstrap {
711 /// The table was empty and this admin was installed.
712 Installed(Principal),
713 /// The table already held keys; nothing was changed.
714 AlreadyProvisioned,
715}
716
717/// **What state the identity table is in at start-up, and therefore whether the
718/// unauthenticated enrolment door is open.**
719///
720/// Until 2026-08-05 there were two states and one of them was *refuse to
721/// start*: a server whose identity table was empty and which had been given no
722/// `--admin-key` bailed out of `Server::start`. That is why there was no first
723/// boot at all — the box could not be reached to be given its first key, and
724/// the only way in was `gunnar key add` **with the server down**, which needs a
725/// shell on the machine and there is no shell on the appliance.
726///
727/// The refusal is replaced by [`Readiness::AwaitingBootstrap`]: the server
728/// starts, and `Bootstrap.Enroll` is servable **without a credential** for
729/// exactly as long as [`AuthTable::bootstrap_record`] is `None`.
730///
731/// Three states, and each predicate below answers `false` for at least one of
732/// them: a `may_serve()` that had become true for every state would be an
733/// identity value, and a test sitting on it could not tell a working gate from
734/// a dead one (measured, not asserted).
735#[derive(Clone, PartialEq, Eq, Debug)]
736pub enum Readiness {
737 /// There is at least one registered key, and bootstrap has happened.
738 /// Ordinary operation.
739 Ready,
740 /// **Nothing has ever been enrolled.** The identity table is empty *and*
741 /// there is no [`BootstrapRecord`]. `Bootstrap.Enroll` is open, to anyone
742 /// who can reach the control port. This must be said out loud at every
743 /// start and reported in status; see `gunnar_grpc::service::bootstrap`.
744 AwaitingBootstrap,
745 /// **The enrolment window is shut and there is no key to authenticate
746 /// with.** Nobody can reach this server over either listener until a key is
747 /// put back with `gunnar key add`, offline — and the window stays shut
748 /// regardless, which is the attack this design exists to refuse.
749 SealedAndEmpty {
750 /// Who bootstrapped it, and when, so the operator can tell an attack
751 /// from an accident.
752 ///
753 /// `None` for a table that holds principals but no keys and carries no
754 /// bootstrap record: a half-provisioned data path written before the
755 /// record existed. That is a real state and must be described as
756 /// itself rather than with an invented record.
757 record: Option<Box<BootstrapRecord>>,
758 },
759}
760
761impl Readiness {
762 /// Is the unauthenticated `Bootstrap.Enroll` window open?
763 ///
764 /// True for exactly one of the three states.
765 pub fn bootstrap_window_open(&self) -> bool {
766 matches!(self, Readiness::AwaitingBootstrap)
767 }
768
769 /// Can anybody actually authenticate against this server right now?
770 ///
771 /// False for both non-`Ready` states, for two different reasons, which is
772 /// why this is not the negation of [`Readiness::bootstrap_window_open`].
773 pub fn has_an_identity(&self) -> bool {
774 matches!(self, Readiness::Ready)
775 }
776}
777
778impl std::fmt::Display for Readiness {
779 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
780 match self {
781 Readiness::Ready => f.write_str("ready"),
782 Readiness::AwaitingBootstrap => f.write_str(
783 "AWAITING BOOTSTRAP: no identity has ever been enrolled, so Bootstrap.Enroll is \
784 open to any caller that can reach the control port",
785 ),
786 Readiness::SealedAndEmpty {
787 record: Some(record),
788 } => write!(
789 f,
790 "bootstrapped at {} by {} but every identity has since been removed; the \
791 enrolment window stays SHUT and nobody can reach this server",
792 record.at, record.principal_id
793 ),
794 Readiness::SealedAndEmpty { record: None } => f.write_str(
795 "this identity table holds principals but no keys and carries no bootstrap \
796 record; the enrolment window stays SHUT and nobody can reach this server",
797 ),
798 }
799 }
800}
801
802/// Principals, their keys, and the grants between them and repositories.
803///
804/// Cheap to clone; every clone reads and writes the same document.
805#[derive(Clone, Debug)]
806pub struct AuthTable {
807 doc: DocRef<AuthDoc>,
808}
809
810impl AuthTable {
811 /// Open the identity table held in `store` (the certificate-identity plan).
812 pub fn new(store: Arc<dyn ControlStore>) -> Result<Self> {
813 Ok(AuthTable {
814 doc: DocRef::new(store, AUTH_REF)?,
815 })
816 }
817
818 /// The name the table is stored under.
819 pub fn ref_name(&self) -> &str {
820 self.doc.ref_name()
821 }
822
823 /// **Every version this table has ever had**, oldest first (the certificate-identity plan).
824 ///
825 /// A ref update per change used to be a free audit trail; on a key/value
826 /// store it is an explicit append-only table, and this is the door to it.
827 /// Each entry's `blob` is still readable through the control store, so
828 /// "what did the ACL look like before Tuesday" is answerable from the
829 /// server rather than from a log that may have rotated.
830 pub fn history(&self) -> Result<Vec<crate::control::DocVersion>> {
831 self.doc.history()
832 }
833
834 // ── principals ─────────────────────────────────────────────────────────
835
836 /// Add a principal.
837 ///
838 /// Refuses [`ANONYMOUS_PRINCIPAL_ID`](crate::model::ANONYMOUS_PRINCIPAL_ID)
839 /// [`Principal::new`] refuses it too, but this is the door a
840 /// [`Principal::anonymous`] *value* would arrive at. A principal sitting in
841 /// the table under that name would be handed the public-read rule of
842 /// [`AuthTable::may`] and could then be given keys and grants on top of it.
843 ///
844 /// **The table stamps `created`** with its own clock, whatever the caller
845 /// put there: one writer for the field, so a row's creation time is the
846 /// server's word and not the client's.
847 ///
848 /// The name is refused if another principal carries it — as its name or as
849 /// its id ([`Error::NameExists`]). The id is what everything keys on; the
850 /// name is the label; the label is still unique, in the same
851 /// compare-and-swap as the row, so a listing can never show two rows a
852 /// human reads as one.
853 pub fn add_principal(&self, principal: Principal) -> Result<Principal> {
854 crate::sandbox::check_component(&principal.id, &principal.id)?;
855 if principal.id == crate::model::ANONYMOUS_PRINCIPAL_ID {
856 return Err(Error::ReservedPrincipalId(principal.id));
857 }
858 let principal = Principal {
859 created: now_ms(),
860 ..principal
861 };
862 self.doc.update(|doc| {
863 if doc.principals.contains_key(&principal.id)? {
864 return Err(Error::PrincipalExists(principal.id.clone()));
865 }
866 Self::refuse_taken_name(doc, &principal.name, &principal.id)?;
867 doc.principals
868 .insert(principal.id.clone(), principal.clone())?;
869 Self::index_name(doc, &principal)?;
870 Ok(principal.clone())
871 })
872 }
873
874 /// **Change a principal's display name and nothing else.**
875 ///
876 /// The id — and with it the account namespace, every key, grant,
877 /// membership and gate — stays exactly where it is; only the label moves.
878 /// This is what makes a rename an edit rather than a re-identification: a
879 /// console that pinned the id sees the same principal afterwards, and a
880 /// URL, a policy file or a grant that names the id is untouched.
881 ///
882 /// Refuses a name another principal already carries, as its name or as its
883 /// id ([`Error::NameExists`]). Renaming to the name it already has is a
884 /// no-op that succeeds.
885 pub fn rename_principal(&self, id: &str, name: &str) -> Result<Principal> {
886 self.doc.update(|doc| {
887 let before = doc
888 .principals
889 .get(id)?
890 .ok_or_else(|| Error::NoSuchPrincipal(id.to_owned()))?;
891 if before.name == name {
892 return Ok(before);
893 }
894 Self::refuse_taken_name(doc, name, id)?;
895 let after = Principal {
896 name: name.to_owned(),
897 ..before.clone()
898 };
899 // The old index entry goes only if it was this principal's. A row
900 // from before the index has no entry, and `remove` of an absent key
901 // is the same no-op.
902 if doc.names.get(&before.name)?.as_deref() == Some(id) {
903 doc.names.remove(&before.name)?;
904 }
905 doc.principals.insert(id.to_owned(), after.clone())?;
906 Self::index_name(doc, &after)?;
907 Ok(after)
908 })
909 }
910
911 /// **One principal, by its display name.** One index probe, then one row
912 /// probe for a principal from before the index (whose name is its id).
913 ///
914 /// This is the lookup a console makes at the one moment a name is anybody's
915 /// to give — registration — before it pins the id and stops asking by name.
916 pub fn principal_by_name(&self, name: &str) -> Result<Option<Principal>> {
917 let doc = self.doc.load()?;
918 Self::resolve_name(&doc, name)
919 }
920
921 /// The principal `name` resolves to, through the index first and the
922 /// pre-index fallback second. One reader for both
923 /// [`AuthTable::principal_by_name`] and [`AuthTable::refuse_taken_name`], so
924 /// "what does this name mean" and "is this name free" cannot disagree.
925 fn resolve_name(doc: &AuthDoc, name: &str) -> Result<Option<Principal>> {
926 if let Some(id) = doc.names.get(name)? {
927 return doc.principals.get(&id);
928 }
929 // Before the index every row was `name == id`, so the name is the key.
930 // The name check matters: a row whose name has since been changed is
931 // still at this key, and it is not the principal called `name` now.
932 Ok(doc
933 .principals
934 .get(name)?
935 .filter(|principal| principal.name == name))
936 }
937
938 /// Refuse `name` unless it is free, or is `own_id`'s own.
939 ///
940 /// Taken means: another principal's name (through [`AuthTable::resolve_name`])
941 /// or another principal's **id** — the second rule is what stops a display
942 /// name from ever spelling somebody else's identity on the wire.
943 fn refuse_taken_name(doc: &AuthDoc, name: &str, own_id: &str) -> Result<()> {
944 let holder = match Self::resolve_name(doc, name)? {
945 Some(principal) => Some(principal.id),
946 None if name != own_id && doc.principals.contains_key(name)? => Some(name.to_owned()),
947 None => None,
948 };
949 match holder {
950 Some(id) if id != own_id => Err(Error::NameExists {
951 name: name.to_owned(),
952 principal_id: id,
953 }),
954 _ => Ok(()),
955 }
956 }
957
958 /// Write the index entry for `principal`'s name. The one writer, used by
959 /// every path that puts a row in `principals`.
960 fn index_name(doc: &mut AuthDoc, principal: &Principal) -> Result<()> {
961 // A clan's name is its namespace, and a principal is admin of the
962 // namespace named after its id (`owns_account`): no principal is ever
963 // made under a clan's name, on any path that makes one.
964 if !doc.clans.is_empty() && doc.clans.contains_key(&principal.id)? {
965 return Err(Error::PrincipalExists(principal.id.clone()));
966 }
967 doc.names
968 .insert(principal.name.clone(), principal.id.clone())?;
969 Ok(())
970 }
971
972 /// Remove a principal, its keys and its grants.
973 ///
974 /// All three together, by construction: a principal whose keys outlived it
975 /// is an account that still authenticates.
976 ///
977 /// **O(keys on the server)**. Keys are stored by fingerprint, because that
978 /// is what the connection path looks them up by, so finding one principal's
979 /// keys is a walk. Deleting an account happens once a day, not once a
980 /// connection; making it cheap would mean a second index to keep in step
981 /// with the first, which is the twin that one implementation refuses.
982 pub fn remove_principal(&self, id: &str) -> Result<Principal> {
983 self.doc.update(|doc| {
984 let principal = doc
985 .principals
986 .remove(id)?
987 .ok_or_else(|| Error::NoSuchPrincipal(id.to_owned()))?;
988 // The name goes with the row, in the same update, so the name is
989 // claimable again exactly when the id is. Only if the entry is this
990 // principal's: a pre-index row has none, and `remove` of an absent
991 // key is the same no-op.
992 if doc.names.get(&principal.name)?.as_deref() == Some(id) {
993 doc.names.remove(&principal.name)?;
994 }
995 doc.keys.retain(|_, key| key.principal_id != id)?;
996 // **Tokens go with the principal, for the reason the gate does**
997 // (GUN-026).
998 //
999 // Leaving them was nearly harmless and not quite:
1000 // `principal_by_token_at` ends in `doc.principals.get(&token.\
1001 // principal_id)`, so an orphaned token authenticates as nobody and
1002 // the credential is dead the moment its holder is. But a principal
1003 // id is CLAIMABLE AGAIN the moment the principal is gone —
1004 // `add_principal` refuses only a name that is currently taken — and
1005 // a token row is keyed by its hash and names its principal by that
1006 // id. So recreating `alice` handed the new alice every access token
1007 // the old one had, and neither the operator who removed the
1008 // principal nor the one who created it saw a line about it. That is
1009 // the gate bug below in its other direction: there, a leftover row
1010 // locked the next principal out; here, it lets somebody else in.
1011 doc.tokens.retain(|_, token| token.principal_id != id)?;
1012 doc.grants.remove(id)?;
1013 // Membership, both directions. A principal whose membership edges
1014 // outlived it would keep inheriting grants (its own list) or keep
1015 // conferring them (if it was a group other principals were in), so
1016 // both are cut in the same update, by construction — the same rule
1017 // as keys and grants above.
1018 doc.memberships.remove(id)?;
1019 let strip: Vec<(String, Vec<String>)> = doc
1020 .memberships
1021 .entries()?
1022 .into_iter()
1023 .filter(|(_, groups)| groups.iter().any(|g| g == id))
1024 .map(|(member, mut groups)| {
1025 groups.retain(|g| g != id);
1026 (member, groups)
1027 })
1028 .collect();
1029 for (member, groups) in strip {
1030 if groups.is_empty() {
1031 doc.memberships.remove(&member)?;
1032 } else {
1033 doc.memberships.insert(member, groups)?;
1034 }
1035 }
1036 // **The gate goes with the principal, in the same update.**
1037 //
1038 // A gate is keyed by principal id, and a principal id is claimable
1039 // again the moment the principal is gone (`add_principal` only
1040 // refuses a name that is currently taken). A row left behind here
1041 // would therefore lock out *the next* principal created under this
1042 // name — an account bricked before it existed, by a decision taken
1043 // about somebody else. `tests/gate.rs` drives exactly that
1044 // sequence.
1045 doc.gates.remove(id)?;
1046 // An employee's binding to its account goes with it, so the id —
1047 // claimable again from here — never comes back as somebody's
1048 // employee.
1049 doc.employers.remove(id)?;
1050 // Its room goes back to the account, and the seat it filled (if
1051 // any) is open again; an account that goes takes its seats and its
1052 // orders with it.
1053 doc.rooms.remove(id)?;
1054 let reopen: Vec<(String, Vec<SeatLot>)> = doc
1055 .seats
1056 .entries()?
1057 .into_iter()
1058 .filter(|(_, lots)| lots.iter().any(|lot| lot.filled.iter().any(|f| f == id)))
1059 .map(|(account, mut lots)| {
1060 for lot in &mut lots {
1061 lot.filled.retain(|f| f != id);
1062 }
1063 (account, lots)
1064 })
1065 .collect();
1066 for (account, lots) in reopen {
1067 doc.seats.insert(account, lots)?;
1068 }
1069 doc.seats.remove(id)?;
1070 doc.room_orders
1071 .retain(|_, grant| grant.account != id && grant.principal != id)?;
1072 // Its clans (2026-10-05): out of every one, its contribution with
1073 // it; a clan it was the last admin of goes to its longest member.
1074 Self::forget_clan_member(doc, id)?;
1075 // Its passkeys go too (Stage L): a credential that outlived its
1076 // principal would log the NEXT principal of that id in.
1077 doc.credentials
1078 .retain(|_, credential| credential.principal_id != id)?;
1079 // …and its invites: a pending enrolment on a principal that is
1080 // gone would enrol the next principal of that id.
1081 doc.invites.retain(|_, invite| invite.principal_id != id)?;
1082 doc.redeemed_invites
1083 .retain(|_, row| row.principal_id != id)?;
1084 // …and the operator's looks into its account, or its own looks
1085 // into others': a grant keyed by a name that is claimable again.
1086 doc.operator_access
1087 .retain(|_, look| look.account != id && look.operator != id)?;
1088 // …and its directory row and the index entries that lead to it
1089 // (SSO): a subject bound to a principal that is gone would log
1090 // the NEXT principal of that id in. An account's own SSO rows go
1091 // with the account.
1092 doc.sso_users.remove(id)?;
1093 doc.sso_index.retain(|_, held| held != id)?;
1094 doc.sso_users.retain(|_, user| user.account != id)?;
1095 doc.sso_index
1096 .retain(|key, _| !Self::sso_index_of_account(key, id))?;
1097 if let Some(provider) = doc.sso_providers.remove(id)? {
1098 for domain in provider.domains {
1099 doc.sso_domains.remove(&domain)?;
1100 }
1101 }
1102 doc.scim_tokens.retain(|_, token| token.account != id)?;
1103 doc.sso_groups.retain(|_, group| group.account != id)?;
1104 for (gid, mut group) in doc.sso_groups.entries()? {
1105 if group.members.iter().any(|m| m == id) {
1106 group.members.retain(|m| m != id);
1107 doc.sso_groups.insert(gid, group)?;
1108 }
1109 }
1110 Ok(principal)
1111 })
1112 }
1113
1114 /// **Enroll an employee: a NEW user, created by and bound to `account`.**
1115 ///
1116 /// The only way a principal becomes an employee, and it can only make a
1117 /// new one: the id must not exist (an existing principal "might have repos
1118 /// already, that would be a risk for the company") and the name is checked
1119 /// as for every principal. In ONE compare-and-swap it writes the principal
1120 /// row, its name, the employee binding and a `Read` grant on the account's
1121 /// namespace — a member sees the account's repositories and events; what
1122 /// it may write is granted per repository by the account admin.
1123 ///
1124 /// An employee gets NO account of its own (no namespace named after it;
1125 /// `CoreControlPlane::enroll_employee` refuses an id that names one), so
1126 /// the intrinsic "your own account" rule never makes it an admin of
1127 /// anything, and it may not make a repository public anywhere: that is the
1128 /// account admin's ([`AuthTable::may_namespace`] at `Admin`).
1129 ///
1130 /// Who may enroll (the account's admin) is the caller's question; the
1131 /// account must exist as a namespace, which the control plane checks. Only
1132 /// a [`PrincipalKind::User`](crate::PrincipalKind::User) can be enrolled.
1133 pub fn enroll_employee(&self, account: &str, principal: Principal) -> Result<Principal> {
1134 let answer = self.enroll_with_room(account, Some(principal), &RoomAsk::default())?;
1135 answer.principal.ok_or_else(|| Error::InvalidInput {
1136 what: "employee",
1137 reason: "the enrolment named nobody".to_owned(),
1138 })
1139 }
1140
1141 /// **Enroll with room** (owner, 2026-10-04, "Add users"): ONE named
1142 /// employee holding `room_bytes`, or `seats` unnamed seats each holding
1143 /// it, or a seat claim (`from_seat`: the named employee takes an open
1144 /// seat and its room). The room comes out of what the account holds and
1145 /// has neither stored nor promised — `budget` minus the room its
1146 /// employees and open seats hold — and in the SAME compare-and-swap as
1147 /// the rows it writes, so two enrolments can never both spend the last of
1148 /// it. Short: [`Error::RoomShort`] naming what is missing, nothing
1149 /// written. A dry run answers with the shortfall instead and writes
1150 /// nothing; a retried `order` is answered with its first grant.
1151 ///
1152 /// The rules [`AuthTable::enroll_employee`] keeps for a named employee
1153 /// hold here unchanged: a NEW principal only, a person, not the account.
1154 pub fn enroll_with_room(
1155 &self,
1156 account: &str,
1157 principal: Option<Principal>,
1158 ask: &RoomAsk,
1159 ) -> Result<RoomAnswer> {
1160 let principal = match principal {
1161 Some(principal) => {
1162 crate::sandbox::check_component(&principal.id, &principal.id)?;
1163 if principal.id == crate::model::ANONYMOUS_PRINCIPAL_ID {
1164 return Err(Error::ReservedPrincipalId(principal.id));
1165 }
1166 if principal.kind != crate::model::PrincipalKind::User {
1167 return Err(Error::InvalidInput {
1168 what: "employee",
1169 reason: format!(
1170 "an employee is a person; {} is a {:?}, enroll a user",
1171 principal.id, principal.kind
1172 ),
1173 });
1174 }
1175 if principal.id == account {
1176 return Err(Error::InvalidInput {
1177 what: "employee",
1178 reason: format!("{account} is the account itself, not an employee of it"),
1179 });
1180 }
1181 Some(Principal {
1182 created: now_ms(),
1183 ..principal
1184 })
1185 }
1186 None => None,
1187 };
1188 if principal.is_none() && (ask.seats == 0 || ask.from_seat) {
1189 return Err(Error::InvalidInput {
1190 what: "employee",
1191 reason: "one employee, or a seat filled, needs a name".to_owned(),
1192 });
1193 }
1194 if principal.is_some() && ask.seats > 0 {
1195 return Err(Error::InvalidInput {
1196 what: "employee",
1197 reason: "seats name nobody: a seat is named when it is filled".to_owned(),
1198 });
1199 }
1200 if ask.dry_run {
1201 let doc = self.doc.load()?;
1202 return Self::plan_room(&doc, account, principal.as_ref(), ask).map(|plan| plan.answer);
1203 }
1204 self.doc.update(|doc| {
1205 let plan = Self::plan_room(doc, account, principal.as_ref(), ask)?;
1206 if plan.answer.unchanged {
1207 return Ok(plan.answer);
1208 }
1209 if plan.answer.shortfall_bytes > 0 {
1210 return Err(Error::RoomShort {
1211 account: account.to_owned(),
1212 need: plan.answer.need_bytes,
1213 unused: plan.answer.unused_bytes.unwrap_or(0),
1214 short: plan.answer.shortfall_bytes,
1215 });
1216 }
1217 let mut answer = plan.answer.clone();
1218 let mut seats = doc.seats.get(account)?.unwrap_or_default();
1219 match &principal {
1220 Some(principal) => {
1221 Self::write_employee(
1222 doc,
1223 account,
1224 principal,
1225 plan.lot,
1226 &ask.order,
1227 answer.room_bytes,
1228 &mut seats,
1229 )?;
1230 answer.principal = Some(principal.clone());
1231 }
1232 None => seats.push(SeatLot {
1233 room_bytes: answer.room_bytes,
1234 total: ask.seats,
1235 filled: Vec::new(),
1236 order: ask.order.clone(),
1237 at: now_ms(),
1238 }),
1239 }
1240 answer.seats = seats.iter().map(|lot| lot.total).sum();
1241 answer.seats_open = seats.iter().map(SeatLot::open).sum();
1242 if !seats.is_empty() {
1243 doc.seats.insert(account.to_owned(), seats)?;
1244 }
1245 if !ask.order.is_empty() {
1246 doc.room_orders.insert(
1247 ask.order.clone(),
1248 RoomGrant {
1249 account: account.to_owned(),
1250 principal: principal.as_ref().map(|p| p.id.clone()).unwrap_or_default(),
1251 seats: ask.seats,
1252 room_bytes: answer.room_bytes,
1253 need_bytes: answer.need_bytes,
1254 },
1255 )?;
1256 }
1257 Ok(answer)
1258 })
1259 }
1260
1261 /// **The rows one NEW named employee is**, inside the caller's
1262 /// compare-and-swap: the principal and its name, the employee binding, a
1263 /// `Read` grant on the account's namespace, the seat it fills (when
1264 /// `lot`), and the room it holds. The one writer of an employee —
1265 /// [`AuthTable::enroll_with_room`] and an SSO first login both call it.
1266 fn write_employee(
1267 doc: &mut AuthDoc,
1268 account: &str,
1269 principal: &Principal,
1270 lot: Option<usize>,
1271 order: &str,
1272 room_bytes: u64,
1273 seats: &mut [SeatLot],
1274 ) -> Result<()> {
1275 doc.principals
1276 .insert(principal.id.clone(), principal.clone())?;
1277 Self::index_name(doc, principal)?;
1278 doc.employers
1279 .insert(principal.id.clone(), account.to_owned())?;
1280 doc.grants.insert(
1281 principal.id.clone(),
1282 vec![Grant {
1283 principal: principal.id.clone(),
1284 scope: Scope::Namespace(account.to_owned()),
1285 level: Level::Read,
1286 }],
1287 )?;
1288 let order = match lot {
1289 Some(lot) => {
1290 seats[lot].filled.push(principal.id.clone());
1291 seats[lot].order.clone()
1292 }
1293 None => order.to_owned(),
1294 };
1295 if room_bytes > 0 {
1296 doc.rooms
1297 .insert(principal.id.clone(), EmployeeRoom { room_bytes, order })?;
1298 }
1299 Ok(())
1300 }
1301
1302 /// The decision, read-only: what the ask needs, what is unused, which seat
1303 /// a claim takes, and whether `order` was already granted.
1304 fn plan_room(
1305 doc: &AuthDoc,
1306 account: &str,
1307 principal: Option<&Principal>,
1308 ask: &RoomAsk,
1309 ) -> Result<RoomPlan> {
1310 let lots = doc.seats.get(account)?.unwrap_or_default();
1311 let seats = lots.iter().map(|lot| lot.total).sum();
1312 let seats_open = lots.iter().map(SeatLot::open).sum();
1313 if !ask.order.is_empty() {
1314 if let Some(grant) = doc.room_orders.get(&ask.order)? {
1315 if grant.account != account {
1316 return Err(Error::InvalidInput {
1317 what: "order",
1318 reason: format!(
1319 "order {} was granted to {}, not to {account}",
1320 ask.order, grant.account
1321 ),
1322 });
1323 }
1324 let principal = match grant.principal.as_str() {
1325 "" => None,
1326 id => doc.principals.get(id)?,
1327 };
1328 return Ok(RoomPlan {
1329 answer: RoomAnswer {
1330 principal,
1331 room_bytes: grant.room_bytes,
1332 seats,
1333 seats_open,
1334 need_bytes: grant.need_bytes,
1335 unused_bytes: None,
1336 shortfall_bytes: 0,
1337 unchanged: true,
1338 },
1339 lot: None,
1340 });
1341 }
1342 }
1343 if let Some(principal) = principal {
1344 if doc.principals.contains_key(&principal.id)? {
1345 return Err(Error::PrincipalExists(principal.id.clone()));
1346 }
1347 Self::refuse_taken_name(doc, &principal.name, &principal.id)?;
1348 }
1349 let lot = if ask.from_seat {
1350 Some(
1351 lots.iter()
1352 .position(|lot| lot.open() > 0)
1353 .ok_or_else(|| Error::NoOpenSeat(account.to_owned()))?,
1354 )
1355 } else {
1356 None
1357 };
1358 let room_bytes = match lot {
1359 Some(lot) => lots[lot].room_bytes,
1360 None => ask.room_bytes,
1361 };
1362 let need_bytes = match (lot, ask.seats) {
1363 (Some(_), _) => 0,
1364 (None, 0) => room_bytes,
1365 (None, seats) => room_bytes.saturating_mul(u64::from(seats)),
1366 };
1367 // What the account already holds that is NOT free: what it stores and
1368 // what its employees and open seats hold. Counted in full — an
1369 // account storing past its cap is short by that much too, so the
1370 // shortfall is everything a sale must add for the ask to fit.
1371 // What it gives its clans is promised too (2026-10-05): a byte given
1372 // to a clan is not a byte an employee may hold.
1373 let committed = match ask.cap {
1374 None => None,
1375 Some(_) => Some(
1376 Self::promised_by(doc, account)?
1377 .saturating_add(Self::contributed_in(doc, account, None)?)
1378 .saturating_add(ask.stored),
1379 ),
1380 };
1381 let unused_bytes = ask
1382 .cap
1383 .zip(committed)
1384 .map(|(cap, committed)| cap.saturating_sub(committed));
1385 let shortfall_bytes = ask.cap.zip(committed).map_or(0, |(cap, committed)| {
1386 need_bytes.saturating_add(committed).saturating_sub(cap)
1387 });
1388 Ok(RoomPlan {
1389 answer: RoomAnswer {
1390 principal: None,
1391 room_bytes,
1392 seats,
1393 seats_open,
1394 need_bytes,
1395 unused_bytes,
1396 shortfall_bytes,
1397 unchanged: ask.dry_run,
1398 },
1399 lot,
1400 })
1401 }
1402
1403 /// **What an account has promised its people**: the room its employees
1404 /// hold and its open seats hold, bytes. The one count `plan_room` and a
1405 /// clan contribution both take.
1406 fn promised_by(doc: &AuthDoc, account: &str) -> Result<u64> {
1407 let lots = doc.seats.get(account)?.unwrap_or_default();
1408 let mut promised: u64 = lots
1409 .iter()
1410 .map(|lot| lot.room_bytes.saturating_mul(u64::from(lot.open())))
1411 .fold(0, u64::saturating_add);
1412 for (id, employer) in doc.employers.entries()? {
1413 if employer == account {
1414 if let Some(room) = doc.rooms.get(&id)? {
1415 promised = promised.saturating_add(room.room_bytes);
1416 }
1417 }
1418 }
1419 Ok(promised)
1420 }
1421
1422 /// **The account's seats**: every seat granted, and the open ones.
1423 pub fn seats_of(&self, account: &str) -> Result<(u32, u32)> {
1424 let lots = self.doc.load()?.seats.get(account)?.unwrap_or_default();
1425 Ok((
1426 lots.iter().map(|lot| lot.total).sum(),
1427 lots.iter().map(SeatLot::open).sum(),
1428 ))
1429 }
1430
1431 /// The room an employee holds, if any.
1432 pub fn room_of(&self, principal_id: &str) -> Result<Option<EmployeeRoom>> {
1433 self.doc.load()?.rooms.get(principal_id)
1434 }
1435
1436 /// The account that enrolled this principal, if it is an employee.
1437 pub fn employer_of(&self, principal_id: &str) -> Result<Option<String>> {
1438 self.doc.load()?.employers.get(principal_id)
1439 }
1440
1441 /// Every employee an account has enrolled, by id. A listing: it walks the
1442 /// bindings, as `members_of` walks memberships.
1443 pub fn employees_of(&self, account: &str) -> Result<Vec<Principal>> {
1444 let doc = self.doc.load()?;
1445 let mut out = Vec::new();
1446 for (id, employer) in doc.employers.entries()? {
1447 if employer == account {
1448 if let Some(p) = doc.principals.get(&id)? {
1449 out.push(p);
1450 }
1451 }
1452 }
1453 out.sort_by(|a, b| a.id.cmp(&b.id));
1454 Ok(out)
1455 }
1456
1457 /// **Is this an enterprise account?** — derived, never set: an account
1458 /// whose admin has enrolled at least one employee.
1459 pub fn is_enterprise(&self, account: &str) -> Result<bool> {
1460 // Stops at the first binding (asked on every `Access.WhoAmI`), cloning nothing.
1461 self.doc
1462 .load()?
1463 .employers
1464 .any(|_, employer| employer == account)
1465 }
1466
1467 /// One principal, if it exists.
1468 pub fn principal(&self, id: &str) -> Result<Option<Principal>> {
1469 self.doc.load()?.principals.get(id)
1470 }
1471
1472 /// Every principal. The listing path, **O(principals)**.
1473 pub fn principals(&self) -> Result<Vec<Principal>> {
1474 self.doc.load()?.principals.values()
1475 }
1476
1477 // ── keys ───────────────────────────────────────────────────────────────
1478
1479 /// Register an `authorized_keys`-style public key line to a principal.
1480 ///
1481 /// Refuses an unknown principal, and refuses a key that is already
1482 /// registered. [`Error::KeyExists`] says why re-pointing it would be worse
1483 /// than failing.
1484 /// Register an **X.509 certificate** to a principal.
1485 ///
1486 /// The mTLS twin of [`register_key`](Self::register_key), and deliberately
1487 /// the same record: a `Fingerprint` is all gunnar knows about a credential,
1488 /// so a certificate and an SSH key differ in how they are *presented*, not
1489 /// in what is stored or in what any grant check afterwards does (the mTLS plan).
1490 ///
1491 /// `der` is the certificate itself. `algo` is recorded as `x509` so an
1492 /// operator listing keys can tell the two apart — which matters precisely
1493 /// because [`fingerprint_of_certificate`] and [`fingerprint_of_blob`] share
1494 /// one hash space, so the fingerprint alone cannot say which kind it is.
1495 ///
1496 /// **Not parsed.** gunnar does not validate the certificate here and must
1497 /// not: validity, chain and revocation are the TLS handshake's job, at
1498 /// connection time, against the configured CA. Checking a subset of that
1499 /// here would produce a second, weaker opinion about whether a credential is
1500 /// good — and the weaker opinion is the one an attacker picks.
1501 pub fn register_certificate(
1502 &self,
1503 principal_id: &str,
1504 der: &[u8],
1505 expires: Option<UnixMillis>,
1506 ) -> Result<PublicKey> {
1507 let fingerprint = fingerprint_of_certificate(der);
1508 let record = PublicKey {
1509 fingerprint_sha256: fingerprint.as_str().to_owned(),
1510 algo: "x509".to_owned(),
1511 principal_id: principal_id.to_owned(),
1512 added: now_ms(),
1513 last_used: None,
1514 expires,
1515 // The credential's own bytes, base64 as a PEM body carries them, so
1516 // an operator can see WHICH certificate was registered rather than
1517 // only its digest.
1518 openssh: {
1519 use base64ct::Encoding as _;
1520 base64ct::Base64::encode_string(der)
1521 },
1522 };
1523 self.doc.update(|doc| {
1524 if !doc.principals.contains_key(principal_id)? {
1525 return Err(Error::NoSuchPrincipal(principal_id.to_owned()));
1526 }
1527 // A fingerprint already registered is REFUSED rather than
1528 // reassigned, exactly as for an SSH key. Two principals sharing one
1529 // credential is not a convenience, it is an audit trail that cannot
1530 // say who acted.
1531 if let Some(existing) = doc.keys.get(fingerprint.as_str())? {
1532 return Err(Error::KeyExists {
1533 fingerprint: fingerprint.as_str().to_owned(),
1534 principal_id: existing.principal_id.clone(),
1535 });
1536 }
1537 doc.keys
1538 .insert(fingerprint.as_str().to_owned(), record.clone())?;
1539 Ok(record.clone())
1540 })
1541 }
1542
1543 /// Register an OpenSSH public key line for `principal_id`, deriving the
1544 /// fingerprint here so no caller can store one that disagrees with the key.
1545 pub fn register_key(
1546 &self,
1547 principal_id: &str,
1548 openssh: &str,
1549 expires: Option<UnixMillis>,
1550 ) -> Result<PublicKey> {
1551 let parsed = parse_public_key(openssh)?;
1552 let (fingerprint, algo, line) = (
1553 Fingerprint::of_key(&parsed),
1554 parsed.algorithm().as_str().to_owned(),
1555 parsed.to_openssh().map_err(Error::from)?.trim().to_owned(),
1556 );
1557 let record = PublicKey {
1558 fingerprint_sha256: fingerprint.as_str().to_owned(),
1559 algo,
1560 principal_id: principal_id.to_owned(),
1561 added: now_ms(),
1562 last_used: None,
1563 expires,
1564 openssh: line,
1565 };
1566 self.doc.update(|doc| {
1567 if !doc.principals.contains_key(principal_id)? {
1568 return Err(Error::NoSuchPrincipal(principal_id.to_owned()));
1569 }
1570 if let Some(existing) = doc.keys.get(fingerprint.as_str())? {
1571 return Err(Error::KeyExists {
1572 fingerprint: fingerprint.as_str().to_owned(),
1573 principal_id: existing.principal_id.clone(),
1574 });
1575 }
1576 doc.keys
1577 .insert(fingerprint.as_str().to_owned(), record.clone())?;
1578 Ok(record.clone())
1579 })
1580 }
1581
1582 /// **Register a web credential (a passkey) on a person** (Stage L).
1583 ///
1584 /// Refuses an unknown principal, a principal that is not a USER (no human
1585 /// credential on a machine principal) and a credential id already
1586 /// registered — to anybody. The key's shape is the caller's to have
1587 /// checked; this stores what it is given.
1588 pub fn add_web_credential(
1589 &self,
1590 credential: crate::model::WebCredential,
1591 ) -> Result<crate::model::WebCredential> {
1592 self.doc.update(|doc| {
1593 let principal = doc
1594 .principals
1595 .get(&credential.principal_id)?
1596 .ok_or_else(|| Error::NoSuchPrincipal(credential.principal_id.clone()))?;
1597 if principal.kind != crate::model::PrincipalKind::User {
1598 return Err(Error::NotAPerson {
1599 principal_id: principal.id.clone(),
1600 kind: format!("{:?}", principal.kind).to_lowercase(),
1601 });
1602 }
1603 if let Some(existing) = doc.credentials.get(&credential.id)? {
1604 return Err(Error::CredentialExists {
1605 id: credential.id.clone(),
1606 principal_id: existing.principal_id,
1607 });
1608 }
1609 doc.credentials
1610 .insert(credential.id.clone(), credential.clone())?;
1611 Ok(credential.clone())
1612 })
1613 }
1614
1615 /// **A passkey was used** (the identity core's login and step-up,
1616 /// IDENTITY-HOST 2026-10-27): its counter (when it counts) and last use, in
1617 /// one update; a counter that went back is refused, as a web login's is.
1618 pub fn touch_web_credential(
1619 &self,
1620 id: &str,
1621 presented: u32,
1622 now: UnixMillis,
1623 ) -> Result<crate::model::WebCredential> {
1624 self.doc.update(|doc| {
1625 let mut credential = doc
1626 .credentials
1627 .get(id)?
1628 .ok_or_else(|| Error::NoSuchCredential(id.to_owned()))?;
1629 if credential.sign_count != 0 && presented != 0 && presented <= credential.sign_count {
1630 return Err(Error::CounterRollback {
1631 id: id.to_owned(),
1632 stored: credential.sign_count,
1633 presented,
1634 });
1635 }
1636 if presented != 0 {
1637 credential.sign_count = presented;
1638 }
1639 credential.last_used = Some(now);
1640 doc.credentials.insert(id.to_owned(), credential.clone())?;
1641 Ok(credential)
1642 })
1643 }
1644
1645 /// One web credential by its id (base64url), or `None`.
1646 pub fn web_credential(&self, id: &str) -> Result<Option<crate::model::WebCredential>> {
1647 self.doc.load()?.credentials.get(id)
1648 }
1649
1650 /// One principal's web credentials, or every one. A listing path.
1651 pub fn web_credentials(
1652 &self,
1653 principal_id: Option<&str>,
1654 ) -> Result<Vec<crate::model::WebCredential>> {
1655 let mut rows: Vec<crate::model::WebCredential> = self
1656 .doc
1657 .load()?
1658 .credentials
1659 .values()?
1660 .into_iter()
1661 .filter(|c| principal_id.is_none_or(|want| c.principal_id == want))
1662 .collect();
1663 rows.sort_by(|a, b| {
1664 (a.principal_id.as_str(), a.created, a.id.as_str()).cmp(&(
1665 b.principal_id.as_str(),
1666 b.created,
1667 b.id.as_str(),
1668 ))
1669 });
1670 Ok(rows)
1671 }
1672
1673 /// Revoke a web credential **and the session keys its logins enrolled**,
1674 /// in one update — the person's console sessions on that passkey end with
1675 /// it. Returns the row removed, if there was one.
1676 pub fn revoke_web_credential(&self, id: &str) -> Result<Option<crate::model::WebCredential>> {
1677 let removed = self.doc.update(|doc| {
1678 let removed = doc.credentials.remove(id)?;
1679 if let Some(credential) = &removed {
1680 for fingerprint in &credential.sessions {
1681 if doc
1682 .keys
1683 .get(fingerprint)?
1684 .is_some_and(|key| key.principal_id == credential.principal_id)
1685 {
1686 doc.keys.remove(fingerprint)?;
1687 }
1688 }
1689 }
1690 Ok(removed)
1691 })?;
1692 if let Some(removed) = &removed {
1693 tracing::info!(credential = %id, principal = %removed.principal_id, "web credential revoked");
1694 }
1695 Ok(removed)
1696 }
1697
1698 /// **Commit an approved machine enrolment** (2026-10-05), in ONE update:
1699 /// the approver's step-up passkey's counter (re-checked: it
1700 /// must increase when both it and the stored one are non-zero) and last
1701 /// use; the MACHINE principal `machine` made when absent (a principal of
1702 /// that id that is not a machine is [`Error::NotAMachine`]); its
1703 /// server-wide grant set to `level`; and `credential` stored on it (an id
1704 /// already registered is [`Error::CredentialExists`]). The ONE way a web
1705 /// credential lands on a machine — [`AuthTable::add_web_credential`] and
1706 /// [`AuthTable::add_web_credential`] still refuse one. Returns the
1707 /// machine, the stored credential, and whether this update made the
1708 /// machine.
1709 pub fn approve_machine_credential(
1710 &self,
1711 machine: &str,
1712 level: Level,
1713 mut credential: crate::model::WebCredential,
1714 step_up: Option<(&str, u32)>,
1715 now: UnixMillis,
1716 ) -> Result<(Principal, crate::model::WebCredential, bool)> {
1717 crate::sandbox::check_component(machine, machine)?;
1718 if machine == crate::model::ANONYMOUS_PRINCIPAL_ID {
1719 return Err(Error::ReservedPrincipalId(machine.to_owned()));
1720 }
1721 self.doc.update(|doc| {
1722 if let Some(existing) = doc.credentials.get(&credential.id)? {
1723 return Err(Error::CredentialExists {
1724 id: credential.id.clone(),
1725 principal_id: existing.principal_id,
1726 });
1727 }
1728 let approver = match step_up {
1729 Some((id, presented)) => {
1730 let mut held = doc
1731 .credentials
1732 .get(id)?
1733 .ok_or_else(|| Error::NoSuchCredential(id.to_owned()))?;
1734 if held.sign_count != 0 && presented != 0 && presented <= held.sign_count {
1735 return Err(Error::CounterRollback {
1736 id: id.to_owned(),
1737 stored: held.sign_count,
1738 presented,
1739 });
1740 }
1741 if presented != 0 {
1742 held.sign_count = presented;
1743 }
1744 held.last_used = Some(now);
1745 Some((id.to_owned(), held))
1746 }
1747 None => None,
1748 };
1749 let (principal, created) = match doc.principals.get(machine)? {
1750 Some(held) if held.kind != crate::model::PrincipalKind::Machine => {
1751 return Err(Error::NotAMachine {
1752 principal_id: held.id.clone(),
1753 kind: format!("{:?}", held.kind).to_lowercase(),
1754 })
1755 }
1756 Some(held) => (held, false),
1757 None => {
1758 Self::refuse_taken_name(doc, machine, machine)?;
1759 let made = Principal {
1760 id: machine.to_owned(),
1761 name: machine.to_owned(),
1762 kind: crate::model::PrincipalKind::Machine,
1763 created: now,
1764 };
1765 doc.principals.insert(made.id.clone(), made.clone())?;
1766 Self::index_name(doc, &made)?;
1767 (made, true)
1768 }
1769 };
1770 if let Some((id, held)) = approver {
1771 doc.credentials.insert(id, held)?;
1772 }
1773 let grant = Grant::server(machine.to_owned(), level);
1774 let mut grants = doc.grants.get(machine)?.unwrap_or_default();
1775 grants.retain(|old| old.scope != grant.scope);
1776 grants.push(grant);
1777 grants.sort();
1778 doc.grants.insert(machine.to_owned(), grants)?;
1779 credential.principal_id = machine.to_owned();
1780 doc.credentials
1781 .insert(credential.id.clone(), credential.clone())?;
1782 Ok((principal, credential.clone(), created))
1783 })
1784 }
1785
1786 /// Change a web credential's label. Returns the updated row, if there was
1787 /// one.
1788 pub fn rename_web_credential(
1789 &self,
1790 id: &str,
1791 label: &str,
1792 ) -> Result<Option<crate::model::WebCredential>> {
1793 self.doc.update(|doc| {
1794 let Some(mut credential) = doc.credentials.get(id)? else {
1795 return Ok(None);
1796 };
1797 credential.label = label.to_owned();
1798 doc.credentials.insert(id.to_owned(), credential.clone())?;
1799 Ok(Some(credential))
1800 })
1801 }
1802
1803 /// The person an invite is for, checked: exists, and is a USER.
1804 fn invitable(doc: &AuthDoc, principal_id: &str) -> Result<Principal> {
1805 let principal = doc
1806 .principals
1807 .get(principal_id)?
1808 .ok_or_else(|| Error::NoSuchPrincipal(principal_id.to_owned()))?;
1809 if principal.kind != crate::model::PrincipalKind::User {
1810 return Err(Error::NotAPerson {
1811 principal_id: principal.id.clone(),
1812 kind: format!("{:?}", principal.kind).to_lowercase(),
1813 });
1814 }
1815 Ok(principal)
1816 }
1817
1818 /// **Record the operator's first invite** (the installer's token by its
1819 /// hash; IDENTITY-HOST: the identity core reads it as a fed invite onto
1820 /// the operator). The person must be a USER; one per box, recorded again
1821 /// it is replaced.
1822 pub fn put_first_invite(&self, invite: crate::model::FirstInviteRow) -> Result<()> {
1823 self.doc.update(|doc| {
1824 Self::invitable(doc, &invite.principal_id)?;
1825 doc.invites.insert(invite.id.clone(), invite.clone())?;
1826 Ok(())
1827 })
1828 }
1829
1830 /// The operator's first invite, if one is recorded (`id`), expired or not.
1831 pub fn first_invite(&self, id: &str) -> Result<Option<crate::model::FirstInviteRow>> {
1832 self.doc.load()?.invites.get(id)
1833 }
1834
1835 /// Drop the operator's first invite (spent, withdrawn, or its person
1836 /// holds a passkey). Returns the row removed, if there was one.
1837 pub fn drop_first_invite(&self, id: &str) -> Result<Option<crate::model::FirstInviteRow>> {
1838 self.doc.update(|doc| doc.invites.remove(id))
1839 }
1840
1841 /// Revoke a key. Returns the record that was removed, if there was one.
1842 pub fn revoke_key(&self, fingerprint: &Fingerprint) -> Result<Option<PublicKey>> {
1843 let removed = self
1844 .doc
1845 .update(|doc| doc.keys.remove(fingerprint.as_str()))?;
1846 if removed.is_some() {
1847 tracing::info!(%fingerprint, "public key revoked");
1848 }
1849 Ok(removed)
1850 }
1851
1852 /// **Revoke every certificate registered to `principal_id` that certifies a
1853 /// key OTHER than `spki`** (a DER SubjectPublicKeyInfo), in one update.
1854 /// Returns the rows removed. SSH keys and certificates for `spki` itself (a
1855 /// renewal: the same key, a new validity window) are kept; a certificate row
1856 /// whose bytes do not parse certifies no key anyone can prove, and goes.
1857 ///
1858 /// What re-certifying a SINGLE-HOLDER principal means (the twin's
1859 /// `gunnar-twin`, twin reborn 2026-09-30): a new key is a new box, and the
1860 /// box it replaces — nuked, its disk possibly copied — must not keep a
1861 /// registered credential beside it.
1862 pub fn supersede_certificates(
1863 &self,
1864 principal_id: &str,
1865 spki: &[u8],
1866 ) -> Result<Vec<PublicKey>> {
1867 use base64ct::Encoding as _;
1868 self.doc.update(|doc| {
1869 let mut gone = Vec::new();
1870 for row in doc.keys.values()? {
1871 if row.principal_id != principal_id || row.algo != "x509" {
1872 continue;
1873 }
1874 let same_key = base64ct::Base64::decode_vec(&row.openssh)
1875 .ok()
1876 .and_then(|der| crate::issuer::public_key_info_of_der(&der).ok())
1877 .is_some_and(|key| key == spki);
1878 if !same_key {
1879 if let Some(removed) = doc.keys.remove(&row.fingerprint_sha256)? {
1880 gone.push(removed);
1881 }
1882 }
1883 }
1884 Ok(gone)
1885 })
1886 }
1887
1888 /// Every registered key, **O(keys)**. Also a listing path.
1889 ///
1890 /// **Includes expired keys**, deliberately: `gunnar key list` must show a key
1891 /// that has run out, or nobody can see why authentication stopped working.
1892 /// Anything making a *decision* wants [`AuthTable::signing_keys`] instead.
1893 pub fn keys(&self) -> Result<Vec<PublicKey>> {
1894 self.doc.load()?.keys.values()
1895 }
1896
1897 /// Every key that may still **satisfy a signature requirement**, as of now.
1898 ///
1899 /// # Why this exists as its own method
1900 ///
1901 /// Found 2026-08-05: `gunnar-cli`'s `key_directory` — the whole of the
1902 /// ssh-sig gate's key material (the pre-receive gate) — built itself from
1903 /// [`AuthTable::keys`] with **no expiry check at all**. So a key past its
1904 /// `expires` could no longer open an SSH session, and could still sign a
1905 /// commit that `require_signature` accepted. That is one credential with two
1906 /// lifetimes, and the longer one was the one nobody had written down.
1907 ///
1908 /// A *revoked* key is a removed row and so cannot reach either path, which is
1909 /// exactly why the bug hid: the revocation case, which is the one everybody
1910 /// tests, was already correct.
1911 ///
1912 /// The fix is a named method rather than a `filter` at the call site because
1913 /// the call site is what got it wrong. "Every key" and "every key that may
1914 /// decide something" are different questions, and until now the second one
1915 /// had no way to be asked. [`AuthTable::principal_by_fingerprint_at`] answers
1916 /// the same question for authentication, through the same
1917 /// [`PublicKey::is_valid_at`] — one predicate, two consumers, so the two
1918 /// lifetimes cannot drift apart again (one implementation).
1919 pub fn signing_keys(&self) -> Result<Vec<PublicKey>> {
1920 self.signing_keys_at(now_ms())
1921 }
1922
1923 /// [`AuthTable::signing_keys`] at an explicit time, so expiry can be tested
1924 /// without waiting for it.
1925 pub fn signing_keys_at(&self, now: UnixMillis) -> Result<Vec<PublicKey>> {
1926 Ok(self
1927 .doc
1928 .load()?
1929 .keys
1930 .values()?
1931 .into_iter()
1932 .filter(|key| key.is_valid_at(now))
1933 .collect())
1934 }
1935
1936 /// **The keys one principal could attest an addition with**: its keys that
1937 /// are valid now and are OpenSSH key material rather than a certificate,
1938 /// since only those can make an `sshsig`. What
1939 /// [`crate::key_attestation::Requirement::of`] is handed.
1940 ///
1941 /// **O(keys on the server)**, for the reason [`AuthTable::keys_of`] is: it
1942 /// sits on `Access.AddKey`, which happens when a person gets a machine, not
1943 /// on a connection.
1944 pub fn attesting_keys_of(&self, principal_id: &str) -> Result<Vec<AttestingKey>> {
1945 let now = now_ms();
1946 Ok(self
1947 .keys_of(principal_id)?
1948 .into_iter()
1949 .filter(|key| {
1950 key.is_valid_at(now) && key.algo != "x509" && key.algo != PQ_RECIPIENT_ALGO
1951 })
1952 .map(|key| AttestingKey {
1953 fingerprint: key.fingerprint_sha256,
1954 openssh: key.openssh,
1955 })
1956 .collect())
1957 }
1958
1959 /// Every key registered to one principal. `gunnar-policy` needs this to
1960 /// verify an ssh-sig signature (the pre-receive gate).
1961 ///
1962 /// **O(keys on the server)**, for the reason
1963 /// [`AuthTable::remove_principal`] gives. It sits on the
1964 /// signature-verification path of a push, not on a connection.
1965 pub fn keys_of(&self, principal_id: &str) -> Result<Vec<PublicKey>> {
1966 Ok(self
1967 .doc
1968 .load()?
1969 .keys
1970 .values()?
1971 .into_iter()
1972 .filter(|key| key.principal_id == principal_id)
1973 .collect())
1974 }
1975
1976 /// **The SSH auth answer**: who owns this key, as of now?
1977 ///
1978 /// `None` for an unregistered key, for a key whose principal has been
1979 /// removed, and for an expired key.
1980 pub fn principal_by_fingerprint(&self, fingerprint: &Fingerprint) -> Result<Option<Principal>> {
1981 self.principal_by_fingerprint_at(fingerprint, now_ms())
1982 }
1983
1984 /// [`AuthTable::principal_by_fingerprint`] at an explicit time, so expiry
1985 /// can be tested without waiting for it.
1986 pub fn principal_by_fingerprint_at(
1987 &self,
1988 fingerprint: &Fingerprint,
1989 now: UnixMillis,
1990 ) -> Result<Option<Principal>> {
1991 let doc = self.doc.load()?;
1992 let Some(key) = doc.keys.get(fingerprint.as_str())? else {
1993 return Ok(None);
1994 };
1995 if !key.is_valid_at(now) {
1996 tracing::warn!(%fingerprint, "expired key offered");
1997 return Ok(None);
1998 }
1999 doc.principals.get(&key.principal_id)
2000 }
2001
2002 /// Record that a key just authenticated.
2003 ///
2004 /// Separate from the lookup because it writes, and the lookup is on the
2005 /// connection path: a listener that cannot write must still be able to
2006 /// authenticate.
2007 ///
2008 /// # DELIBERATELY UNCALLED. Do not "fix" this by adding the call
2009 ///
2010 /// Nothing in `crates/*/src` calls this, so [`PublicKey::last_used`] is
2011 /// `None` for every key on the server and `Refs`/`Identity` hand a consumer
2012 /// a field that is never written. That is a real gap and it is filed. It is
2013 /// **not** closed by wiring this into `auth_publickey`.
2014 ///
2015 /// The cost is no longer the objection it was: sharding split the identity
2016 /// table, so this rewrites one leaf plus two trie nodes plus the document —
2017 /// single-digit KB below ~30 000 keys — and not the several hundred
2018 /// megabytes the finding was originally written against.
2019 ///
2020 /// What forbids it is the **shape**, not the size. [`DocRef::update`] is a
2021 /// compare-and-swap on **one** reference, `refs/gunnar/auth`, with
2022 /// `MAX_ATTEMPTS = 8`. Calling this per successful authentication puts every
2023 /// login on the server in contention for that single ref, and the failure
2024 /// mode is not slowness: it is an `Err` after eight lost races, **on the
2025 /// authentication path**. A key that authenticated correctly would be
2026 /// refused because a bookkeeping write lost. `last_used` reading `None` is
2027 /// strictly better than that.
2028 ///
2029 /// Either of two things closes it, and both are more than a call site: an
2030 /// append-only identity log, where "key K authenticated at T" is one
2031 /// appended row and needs no CAS; or a low-durability sidecar written
2032 /// best-effort off the connection path and allowed to lose updates. **No
2033 /// sidecar exists in the tree today**, which is why this is still here.
2034 pub fn touch_key(&self, fingerprint: &Fingerprint, now: UnixMillis) -> Result<()> {
2035 self.doc.update(|doc| {
2036 doc.keys
2037 .modify(fingerprint.as_str(), |key| key.last_used = Some(now))?;
2038 Ok(())
2039 })
2040 }
2041
2042 // ── access tokens ──────────────────────────────────────────────────────
2043
2044 /// Register an access token for `principal_id`, storing only its hash.
2045 ///
2046 /// **The secret is minted by the caller, not here**, exactly as
2047 /// [`AuthTable::register_key`] is handed an `authorized_keys` line rather
2048 /// than generating a key pair. That keeps a random number generator out of
2049 /// this crate, and it keeps the one moment the secret exists in a place the
2050 /// operator can see: whoever calls this is the only code that ever holds
2051 /// it, and after this returns nothing in gunnar can produce it again.
2052 ///
2053 /// Refuses an unknown principal, refuses
2054 /// [`ANONYMOUS_PRINCIPAL_ID`](crate::model::ANONYMOUS_PRINCIPAL_ID) (which
2055 /// cannot be in the table anyway, so this is the belt to
2056 /// [`AuthTable::add_principal`]'s braces), and refuses a secret that is
2057 /// already registered.
2058 pub fn register_token(
2059 &self,
2060 principal_id: &str,
2061 secret: &str,
2062 label: &str,
2063 expires: Option<UnixMillis>,
2064 ) -> Result<AccessToken> {
2065 if principal_id == crate::model::ANONYMOUS_PRINCIPAL_ID {
2066 return Err(Error::ReservedPrincipalId(principal_id.to_owned()));
2067 }
2068 let hash = token_hash(secret);
2069 let record = AccessToken {
2070 hash: hash.clone(),
2071 principal_id: principal_id.to_owned(),
2072 label: label.to_owned(),
2073 created: now_ms(),
2074 last_used: None,
2075 expires,
2076 };
2077 self.doc.update(|doc| {
2078 if !doc.principals.contains_key(principal_id)? {
2079 return Err(Error::NoSuchPrincipal(principal_id.to_owned()));
2080 }
2081 if let Some(existing) = doc.tokens.get(&hash)? {
2082 return Err(Error::TokenExists {
2083 hash: hash.clone(),
2084 principal_id: existing.principal_id.clone(),
2085 });
2086 }
2087 doc.tokens.insert(hash.clone(), record.clone())?;
2088 Ok(record.clone())
2089 })
2090 }
2091
2092 /// Revoke a token by its **hash**, which is what listing prints.
2093 pub fn revoke_token(&self, hash: &str) -> Result<Option<AccessToken>> {
2094 let removed = self.doc.update(|doc| doc.tokens.remove(hash))?;
2095 if removed.is_some() {
2096 tracing::info!(token = %hash, "access token revoked");
2097 }
2098 Ok(removed)
2099 }
2100
2101 /// Every registered token, **O(tokens)**. A listing path, never an auth
2102 /// path.
2103 pub fn tokens(&self) -> Result<Vec<AccessToken>> {
2104 self.doc.load()?.tokens.values()
2105 }
2106
2107 /// **The HTTP auth answer**: who holds this secret, as of now?
2108 ///
2109 /// `None` for an unregistered secret, for a token whose principal has been
2110 /// removed, and for an expired token — the same three cases, in the same
2111 /// order, as [`AuthTable::principal_by_fingerprint`], because they are the
2112 /// same question asked with a different credential.
2113 pub fn principal_by_token(&self, secret: &str) -> Result<Option<Principal>> {
2114 self.principal_by_token_at(secret, now_ms())
2115 }
2116
2117 /// [`AuthTable::principal_by_token`] at an explicit time, so expiry can be
2118 /// tested without waiting for it.
2119 pub fn principal_by_token_at(
2120 &self,
2121 secret: &str,
2122 now: UnixMillis,
2123 ) -> Result<Option<Principal>> {
2124 let doc = self.doc.load()?;
2125 let Some(token) = doc.tokens.get(&token_hash(secret))? else {
2126 return Ok(None);
2127 };
2128 if !token.is_valid_at(now) {
2129 // The hash, never the secret: this line goes to a log.
2130 tracing::warn!(token = %token.hash, "expired access token presented");
2131 return Ok(None);
2132 }
2133 doc.principals.get(&token.principal_id)
2134 }
2135
2136 /// Record that a token just authenticated — **at most once per
2137 /// [`LAST_USED_GRANULARITY`] per token**.
2138 ///
2139 /// Separate from the lookup for the same reason [`AuthTable::touch_key`]
2140 /// is: the lookup is on the request path and must work on a table that
2141 /// cannot be written.
2142 ///
2143 /// The granularity is the fix for the serving tier's measured ceiling.
2144 /// `last_used` lives in ONE control document shared by every principal and
2145 /// every token, so writing it per request made every authenticated HTTP
2146 /// request in the process contend on one compare-and-swap — MEASURED on
2147 /// 2026-08-11 at 573 → 1416 req/s (16-way ls-refs, 4-CPU cpuset) with
2148 /// this call the only difference, and again on 2026-08-12 by
2149 /// `gunnar.multiuser_scaling`: 102 users plateaued at 82 ops/s with the
2150 /// server stuck below 2 of 32 cores, 8 630 lost CAS attempts and 862
2151 /// silently dropped updates in one 12-second window. A credential's
2152 /// "when was this last seen" is a minute-grained fact; a fresh
2153 /// `last_used` answers the question and the skipped write is the
2154 /// throughput. The first touch of a quiet token still writes immediately.
2155 pub fn touch_token(&self, hash: &str, now: UnixMillis) -> Result<()> {
2156 // The read is the same cached document load every request's auth
2157 // lookup already does; only a token whose record is stale (or never
2158 // written) proceeds to the shared-document CAS.
2159 if let Some(token) = self.doc.load()?.tokens.get(hash)? {
2160 if let Some(last) = token.last_used {
2161 if now.saturating_sub(last) < LAST_USED_GRANULARITY {
2162 return Ok(());
2163 }
2164 }
2165 }
2166 self.doc.update(|doc| {
2167 doc.tokens.modify(hash, |token| {
2168 // Re-checked under the CAS: of the requests that race into
2169 // the same window boundary, one write wins and the rest
2170 // rewrite to the same bytes, which `DocRef::update` commits
2171 // as "unchanged" without moving the pointer.
2172 if token
2173 .last_used
2174 .is_none_or(|last| now.saturating_sub(last) >= LAST_USED_GRANULARITY)
2175 {
2176 token.last_used = Some(now);
2177 }
2178 })?;
2179 Ok(())
2180 })
2181 }
2182
2183 // ── bulk import ────────────────────────────────────────────────────────
2184
2185 /// Import many users — principal, optional access token, grants — in **one
2186 /// document transaction**.
2187 ///
2188 /// The per-entity verbs commit one CAS each, and each CAS clones the
2189 /// document and rewrites its skeleton: at an appliance-scale import that is
2190 /// O(users²) work aimed at one shared pointer. This is the batch door the
2191 /// 1→100 000-user story needs: every insert lands in the in-memory
2192 /// document once, dirty shards are flushed once, and the whole population
2193 /// becomes durable in a single compare-and-swap — so the import is also
2194 /// **atomic**: a refused user refuses the batch, and a batch that lost the
2195 /// CAS race retries as one unit.
2196 ///
2197 /// Token secrets never enter this crate: callers hand over
2198 /// [`token_hash`]es, exactly as [`AuthTable::register_token`] stores and
2199 /// [`AuthTable::principal_by_token`] looks up.
2200 pub fn bulk_add_users(&self, users: &[BulkUser], now: UnixMillis) -> Result<usize> {
2201 for user in users {
2202 crate::sandbox::check_component(&user.principal.id, &user.principal.id)?;
2203 if user.principal.id == crate::model::ANONYMOUS_PRINCIPAL_ID {
2204 return Err(Error::ReservedPrincipalId(user.principal.id.clone()));
2205 }
2206 }
2207 // Keys parsed OUTSIDE the transaction: parsing 100k OpenSSH lines is
2208 // real work, and a CAS retry must not repeat it.
2209 let mut keys: Vec<Option<PublicKey>> = Vec::with_capacity(users.len());
2210 for user in users {
2211 keys.push(match &user.openssh_public_key {
2212 None => None,
2213 Some(line) => {
2214 let parsed = parse_public_key(line)?;
2215 let fingerprint = Fingerprint::of_key(&parsed);
2216 Some(PublicKey {
2217 fingerprint_sha256: fingerprint.as_str().to_owned(),
2218 algo: parsed.algorithm().as_str().to_owned(),
2219 principal_id: user.principal.id.clone(),
2220 added: now,
2221 last_used: None,
2222 expires: None,
2223 openssh: parsed.to_openssh().map_err(Error::from)?.trim().to_owned(),
2224 })
2225 }
2226 });
2227 }
2228 self.doc.update(|doc| {
2229 for user in users {
2230 let id = &user.principal.id;
2231 if doc.principals.contains_key(id)? {
2232 return Err(Error::PrincipalExists(id.clone()));
2233 }
2234 Self::refuse_taken_name(doc, &user.principal.name, id)?;
2235 let principal = Principal {
2236 created: now,
2237 ..user.principal.clone()
2238 };
2239 doc.principals.insert(id.clone(), principal.clone())?;
2240 Self::index_name(doc, &principal)?;
2241 if let Some(hash) = &user.token_hash {
2242 if let Some(existing) = doc.tokens.get(hash)? {
2243 return Err(Error::TokenExists {
2244 hash: hash.clone(),
2245 principal_id: existing.principal_id.clone(),
2246 });
2247 }
2248 doc.tokens.insert(
2249 hash.clone(),
2250 AccessToken {
2251 hash: hash.clone(),
2252 principal_id: id.clone(),
2253 label: user.token_label.clone(),
2254 created: now,
2255 last_used: None,
2256 expires: None,
2257 },
2258 )?;
2259 }
2260 if !user.grants.is_empty() {
2261 // The principal is new in this same transaction, so there
2262 // is nothing to merge with; sorted for the same reason
2263 // `grant` sorts.
2264 let mut held = user.grants.clone();
2265 held.sort();
2266 doc.grants.insert(id.clone(), held)?;
2267 }
2268 }
2269 for record in keys.iter().flatten() {
2270 if let Some(existing) = doc.keys.get(&record.fingerprint_sha256)? {
2271 return Err(Error::KeyExists {
2272 fingerprint: record.fingerprint_sha256.clone(),
2273 principal_id: existing.principal_id.clone(),
2274 });
2275 }
2276 doc.keys
2277 .insert(record.fingerprint_sha256.clone(), record.clone())?;
2278 }
2279 Ok(users.len())
2280 })
2281 }
2282
2283 // ── grants ─────────────────────────────────────────────────────────────
2284
2285 /// Add a grant, replacing any existing grant this principal has on the same
2286 /// scope.
2287 ///
2288 /// Replacing rather than accumulating means a downgrade is a downgrade: if
2289 /// grants piled up, "set them to Read" would leave the old Write in place
2290 /// and the ACL would take the maximum.
2291 pub fn grant(&self, grant: Grant) -> Result<Grant> {
2292 self.doc.update(|doc| {
2293 if !doc.principals.contains_key(&grant.principal)? {
2294 return Err(Error::NoSuchPrincipal(grant.principal.clone()));
2295 }
2296 let mut held = doc.grants.get(&grant.principal)?.unwrap_or_default();
2297 held.retain(|old| old.scope != grant.scope);
2298 held.push(grant.clone());
2299 held.sort();
2300 doc.grants.insert(grant.principal.clone(), held)?;
2301 Ok(grant.clone())
2302 })
2303 }
2304
2305 /// Remove a principal's grant on one scope. `true` if there was one.
2306 pub fn revoke_grant(&self, principal_id: &str, scope: &Scope) -> Result<bool> {
2307 self.doc.update(|doc| {
2308 let Some(mut held) = doc.grants.get(principal_id)? else {
2309 return Ok(false);
2310 };
2311 let before = held.len();
2312 held.retain(|old| &old.scope != scope);
2313 if held.len() == before {
2314 return Ok(false);
2315 }
2316 if held.is_empty() {
2317 doc.grants.remove(principal_id)?;
2318 } else {
2319 doc.grants.insert(principal_id.to_owned(), held)?;
2320 }
2321 Ok(true)
2322 })
2323 }
2324
2325 /// Every grant, or every grant held by one principal.
2326 ///
2327 /// Naming a principal is a shard probe; asking for all of them walks the
2328 /// whole ACL, which is what a listing is.
2329 pub fn grants(&self, principal_id: Option<&str>) -> Result<Vec<Grant>> {
2330 let doc = self.doc.load()?;
2331 match principal_id {
2332 Some(id) => Ok(doc.grants.get(id)?.unwrap_or_default()),
2333 None => Ok(doc.grants.values()?.into_iter().flatten().collect()),
2334 }
2335 }
2336
2337 // ── groups (punkt 9) ───────────────────────────────────────────────────
2338
2339 /// **Make `member_id` a member of the group `group_id`.**
2340 ///
2341 /// The group must exist and be a
2342 /// [`PrincipalKind::Group`](crate::PrincipalKind::Group); the member must
2343 /// exist, and may be a group itself (nesting). Idempotent: adding a member
2344 /// already in the group is a no-op, so a config that lists a membership
2345 /// twice does not create a duplicate edge.
2346 ///
2347 /// This is admin-gated at the RPC exactly like [`AuthTable::grant`], and for
2348 /// the same reason: adding a member to a group that holds a grant *is*
2349 /// giving that member the grant. The gate is the caller's; here we only
2350 /// refuse memberships that do not typecheck.
2351 pub fn add_to_group(&self, group_id: &str, member_id: &str) -> Result<()> {
2352 self.doc.update(|doc| {
2353 let group = doc
2354 .principals
2355 .get(group_id)?
2356 .ok_or_else(|| Error::NoSuchPrincipal(group_id.to_owned()))?;
2357 if group.kind != PrincipalKind::Group {
2358 return Err(Error::NotAGroup(group_id.to_owned()));
2359 }
2360 if !doc.principals.contains_key(member_id)? {
2361 return Err(Error::NoSuchPrincipal(member_id.to_owned()));
2362 }
2363 let mut held = doc.memberships.get(member_id)?.unwrap_or_default();
2364 if !held.iter().any(|g| g == group_id) {
2365 held.push(group_id.to_owned());
2366 held.sort();
2367 doc.memberships.insert(member_id.to_owned(), held)?;
2368 }
2369 Ok(())
2370 })
2371 }
2372
2373 /// **Remove `member_id` from the group `group_id`.** `true` if it was a
2374 /// member.
2375 ///
2376 /// The access the group conferred is gone the moment this returns: the next
2377 /// [`AuthTable::may`] resolves the member's groups afresh from the document
2378 /// this write updated, so there is no cached membership and no window. The
2379 /// tests clone through SSH before and after to prove the applied effect, not
2380 /// the row.
2381 pub fn remove_from_group(&self, group_id: &str, member_id: &str) -> Result<bool> {
2382 self.doc.update(|doc| {
2383 let Some(mut held) = doc.memberships.get(member_id)? else {
2384 return Ok(false);
2385 };
2386 let before = held.len();
2387 held.retain(|g| g != group_id);
2388 if held.len() == before {
2389 return Ok(false);
2390 }
2391 if held.is_empty() {
2392 doc.memberships.remove(member_id)?;
2393 } else {
2394 doc.memberships.insert(member_id.to_owned(), held)?;
2395 }
2396 Ok(true)
2397 })
2398 }
2399
2400 /// The groups `member_id` belongs to **directly** (one shard probe).
2401 pub fn groups_of(&self, member_id: &str) -> Result<Vec<String>> {
2402 Ok(self
2403 .doc
2404 .load()?
2405 .memberships
2406 .get(member_id)?
2407 .unwrap_or_default())
2408 }
2409
2410 /// **Who is directly in this group.** A listing, **O(members on the
2411 /// server)**: membership is keyed by member for the connection path, so the
2412 /// inverse question is a walk. Sorted, for a stable listing.
2413 pub fn members_of(&self, group_id: &str) -> Result<Vec<String>> {
2414 let doc = self.doc.load()?;
2415 let mut members: Vec<String> = doc
2416 .memberships
2417 .entries()?
2418 .into_iter()
2419 .filter(|(_, groups)| groups.iter().any(|g| g == group_id))
2420 .map(|(member, _)| member)
2421 .collect();
2422 members.sort();
2423 Ok(members)
2424 }
2425
2426 /// **Every group on the server** — the principals whose kind is
2427 /// [`Group`](crate::PrincipalKind::Group). Walks the principals, like the
2428 /// principal listing does.
2429 pub fn groups(&self) -> Result<Vec<Principal>> {
2430 Ok(self
2431 .doc
2432 .load()?
2433 .principals
2434 .values()?
2435 .into_iter()
2436 .filter(|p| p.kind == PrincipalKind::Group)
2437 .collect())
2438 }
2439
2440 /// **Every principal whose grants `principal_id` may exercise**: itself,
2441 /// then the transitive closure of the groups it belongs to.
2442 ///
2443 /// This is the whole of group inheritance. A member is authorized by any
2444 /// grant its group holds; because a group is itself a principal that can be
2445 /// a member of another group, it is authorized by any grant a group's group
2446 /// holds, and so on — a breadth-first walk of the membership edges.
2447 ///
2448 /// # Bounded against a cycle, by construction
2449 ///
2450 /// Nothing at write time forbids a membership cycle (A ∈ B, B ∈ A), because
2451 /// forbidding it would need this very closure. `seen` is what makes the walk
2452 /// terminate anyway: a principal already expanded is never expanded again,
2453 /// so a cycle is visited once and stops. `group_cycle_terminates` in the
2454 /// tests drives A ∈ B ∈ A through here and asserts it returns rather than
2455 /// hangs — an unbounded resolver hangs that test red.
2456 ///
2457 /// The querying principal is always first and always present, so a
2458 /// principal in no group resolves to exactly itself and every `may*` below
2459 /// behaves precisely as it did before groups existed (one implementation).
2460 fn grant_principals(doc: &AuthDoc, principal_id: &str) -> Result<Vec<String>> {
2461 let mut seen = std::collections::BTreeSet::new();
2462 let mut queue = std::collections::VecDeque::new();
2463 let mut holders = Vec::new();
2464 queue.push_back(principal_id.to_owned());
2465 while let Some(id) = queue.pop_front() {
2466 if !seen.insert(id.clone()) {
2467 continue;
2468 }
2469 if let Some(groups) = doc.memberships.get(&id)? {
2470 for group in groups {
2471 if !seen.contains(&group) {
2472 queue.push_back(group);
2473 }
2474 }
2475 }
2476 holders.push(id);
2477 }
2478 Ok(holders)
2479 }
2480
2481 /// Every grant held by `principal_id` **or by any group it inherits from**.
2482 ///
2483 /// The one place group inheritance widens the grant set, so `may`,
2484 /// `may_namespace` and `may_server` share it and cannot drift: each applies
2485 /// its own scope filter to the same widened set (one implementation).
2486 fn holder_grants(doc: &AuthDoc, principal_id: &str) -> Result<Vec<Grant>> {
2487 let mut all = Vec::new();
2488 for holder in Self::grant_principals(doc, principal_id)? {
2489 if let Some(grants) = doc.grants.get(&holder)? {
2490 all.extend(grants);
2491 }
2492 }
2493 Ok(all)
2494 }
2495
2496 // ── the ACL ────────────────────────────────────────────────────────────
2497
2498 /// **May this principal do this to this repository?**
2499 ///
2500 /// The rules, in full:
2501 ///
2502 /// 0. The **anonymous** principal — the one an unauthenticated caller
2503 /// is served as — may `Read` a
2504 /// [`Visibility::Public`](crate::model::Visibility::Public) repository
2505 /// and may do nothing else, anywhere. It is not in the table, cannot be
2506 /// put in the table ([`Error::ReservedPrincipalId`]) and therefore can
2507 /// never hold a grant; this line is the *whole* of what "anonymous" can
2508 /// ever be allowed, and it is reached before the table is even read.
2509 /// 1. An unknown principal may do nothing. Not "read-only" — nothing.
2510 /// 2. **A principal is unconditionally admin of its own account**: the
2511 /// namespace whose name is its own id, which is the `<account>` in
2512 /// `<host>/<account>/<repo>`. See [`AuthTable::owns_account`].
2513 /// 3. A [`Visibility::Public`](crate::model::Visibility::Public)
2514 /// repository is readable by any registered principal too, with no
2515 /// grant.
2516 /// 4. Otherwise the highest grant covering the repository — server,
2517 /// namespace or repository scope — held by this principal **or by any
2518 /// group it is (transitively) a member of** must be at least `level`.
2519 /// [`Level`] is ordered, so `Admin` implies `Write` implies `Read`.
2520 ///
2521 /// **Groups (punkt 9).** A group is a principal
2522 /// ([`PrincipalKind::Group`](crate::PrincipalKind::Group)) that holds no
2523 /// keys; a member inherits the group's grants and nothing else. Inheritance
2524 /// is of grants only — never of a group's intrinsic account ownership (rule
2525 /// 2 reads the *querying* principal's own id, so being in a group named
2526 /// `team` does not make you admin of the `team` namespace). Nesting is
2527 /// resolved transitively and cycle-safely by
2528 /// [`AuthTable::grant_principals`]. The maximum over the inherited grants is
2529 /// taken, so a group granted `Read` can never let a member `Write`: a
2530 /// read-only group is read-only for everyone in it, by the same ordering
2531 /// that stops a read grant from becoming a write one for a lone user.
2532 ///
2533 /// A namespace grant matches its namespace **exactly**: `team` does not
2534 /// reach `team/sub`. See [`Scope::Namespace`].
2535 ///
2536 /// # Why anonymous is a rule here and not a branch at the listener
2537 ///
2538 /// One implementation. The SSH dispatch, the gRPC gate and anything added later ask this
2539 /// one function; a listener that decided "public, so serve it" for itself
2540 /// would be a second ACL, and the failure mode of two ACLs is that the
2541 /// permissive one wins silently. `may` is the only place a `Visibility` is
2542 /// ever read for an access decision.
2543 ///
2544 /// **A server-scope grant stops at a customer's account** (2026-10-05,
2545 /// the operator sees aggregates only): inside a namespace a PERSON owns,
2546 /// a PERSON's server grant counts for nothing unless the account's admin
2547 /// approved a look, and then up to `Read` ([`operator_access`]). A
2548 /// machine's keeps its reach: it is the estate's plumbing.
2549 pub fn may(&self, principal_id: &str, repo: &Repo, level: Level) -> Result<bool> {
2550 self.may_at(principal_id, repo, level, now_ms())
2551 }
2552
2553 /// [`AuthTable::may`] at a given instant (an operator's look ends on time).
2554 pub fn may_at(&self, principal_id: &str, repo: &Repo, level: Level, now: i64) -> Result<bool> {
2555 let doc = self.doc.load()?;
2556 Self::may_in(&doc, principal_id, repo, level, now)
2557 }
2558
2559 /// **[`AuthTable::may`] for every repository of a listing, under ONE read
2560 /// of the identity table** (round 5.3.1, test5 2026-10-08: a listing asked
2561 /// `may` once per row, and each ask read the table's pointer from the
2562 /// control store again — two store reads and a handful of shared-cache
2563 /// locks per row, so concurrent listings queued on each other). The same
2564 /// rule, row by row: [`AuthTable::may_at`] is this with one row.
2565 pub fn may_each(&self, principal_id: &str, repos: &[Repo], level: Level) -> Result<Vec<bool>> {
2566 let doc = self.doc.load()?;
2567 let now = now_ms();
2568 repos
2569 .iter()
2570 .map(|repo| Self::may_in(&doc, principal_id, repo, level, now))
2571 .collect()
2572 }
2573
2574 fn may_in(
2575 doc: &AuthDoc,
2576 principal_id: &str,
2577 repo: &Repo,
2578 level: Level,
2579 now: i64,
2580 ) -> Result<bool> {
2581 // **Rule −1: the gate, and it is read before anything else** (see
2582 // [`crate::gate`]). Both directions: a gated principal acts nowhere,
2583 // and nobody acts inside a gated account — including the anonymous
2584 // principal, which is why this sits ABOVE the anonymous rule rather
2585 // than after it. A stranger cloning a public repository out of an
2586 // account that is being emptied is exactly the reader that puts bytes
2587 // back behind a purge.
2588 if Self::gated(doc, principal_id)? || Self::gated(doc, &repo.namespace)? {
2589 return Ok(false);
2590 }
2591 if principal_id == crate::model::ANONYMOUS_PRINCIPAL_ID {
2592 return Ok(level == Level::Read && repo.visibility == crate::model::Visibility::Public);
2593 }
2594 if !doc.principals.contains_key(principal_id)? {
2595 return Ok(false);
2596 }
2597 // Rule 2½: a CLAN's own (2026-10-05) — its admin administers every
2598 // repository in the clan's namespace, its members read and write
2599 // them. Intrinsic like rule 2: no grant row a `Revoke` could take.
2600 if Self::clan_level(doc, principal_id, &repo.namespace)?.is_some_and(|have| have >= level) {
2601 return Ok(true);
2602 }
2603 // Rule 2: your own account. Intrinsic, so `level` is not consulted —
2604 // every level is allowed, including `Admin`. Never a MEMBER's: an
2605 // employee has no account of its own (2026-10-07).
2606 if Self::owns_account(principal_id, &repo.namespace) && !Self::is_member(doc, principal_id)?
2607 {
2608 return Ok(true);
2609 }
2610 if level == Level::Read && repo.visibility == crate::model::Visibility::Public {
2611 return Ok(true);
2612 }
2613 // Rule 4, now including inheritance: the highest grant covering the
2614 // repository held by this principal OR by any group it is (transitively)
2615 // a member of. `holder_grants` is `[principal] ∪ its group closure`, so
2616 // a principal in no group searches exactly its own grants as before.
2617 let covering = Self::holder_grants(doc, principal_id)?
2618 .into_iter()
2619 .filter(|grant| grant.scope.covers(repo));
2620 let best = Self::best_under(covering, || {
2621 Self::shield_in(doc, principal_id, &repo.namespace, now)
2622 })?;
2623 Ok(best.is_some_and(|have| have >= level))
2624 }
2625
2626 /// [`AuthTable::may`], as the error the SSH and gRPC layers want to
2627 /// return.
2628 pub fn require(&self, principal_id: &str, repo: &Repo, level: Level) -> Result<()> {
2629 if self.may(principal_id, repo, level)? {
2630 return Ok(());
2631 }
2632 // **A gate is reported AS a gate**, and never as a missing grant. The
2633 // two mean different things to whoever is on the other end of the SSH
2634 // session: "ask for a grant" and "your account is locked and a grant
2635 // will not help". Reported for the principal's own gate first and for
2636 // the account's second, so a locked-out user is told about *their*
2637 // lockout rather than about the namespace they were reaching into.
2638 for account in [principal_id, repo.namespace.as_str()] {
2639 if let Some(gate) = self.gate_covering(account)? {
2640 return Err(Error::Gated {
2641 account: gate.account,
2642 reason: gate.reason.as_str().to_owned(),
2643 note: gate.note,
2644 });
2645 }
2646 }
2647 Err(Error::Denied {
2648 principal_id: principal_id.to_owned(),
2649 level,
2650 repo: repo.path(),
2651 })
2652 }
2653
2654 /// May this principal create and delete repositories in this namespace?
2655 ///
2656 /// Never, for the anonymous principal. Refused **by construction**
2657 /// rather than by the absence of a grant: absence is already an answer
2658 /// here, but an answer that depends on a table staying empty is a weaker
2659 /// promise than one that does not look at the table at all.
2660 pub fn may_namespace(&self, principal_id: &str, namespace: &str, level: Level) -> Result<bool> {
2661 if principal_id == crate::model::ANONYMOUS_PRINCIPAL_ID {
2662 return Ok(false);
2663 }
2664 let doc = self.doc.load()?;
2665 // The gate, both directions, exactly as in [`AuthTable::may`]. This is
2666 // the question `repo create` and `repo delete` ask, so a gate that
2667 // covered only `may` would let a locked-out user go on making
2668 // repositories in the account a purge is walking.
2669 if Self::gated(&doc, principal_id)? || Self::gated(&doc, namespace)? {
2670 return Ok(false);
2671 }
2672 if !doc.principals.contains_key(principal_id)? {
2673 return Ok(false);
2674 }
2675 // A clan's own, as in [`AuthTable::may`]'s rule 2½: its admin at
2676 // every level, its members up to `Write` (they create repositories
2677 // there; administering them is the admin's).
2678 if Self::clan_level(&doc, principal_id, namespace)?.is_some_and(|have| have >= level) {
2679 return Ok(true);
2680 }
2681 // Your own account, at every level. Same rule as [`AuthTable::may`]'s
2682 // rule 2 and the same one writer, so a repository question and a
2683 // namespace question can never disagree about who owns an account.
2684 if Self::owns_account(principal_id, namespace) && !Self::is_member(&doc, principal_id)? {
2685 return Ok(true);
2686 }
2687 let covering = Self::holder_grants(&doc, principal_id)?
2688 .into_iter()
2689 .filter(|grant| grant.scope.covers_namespace(namespace));
2690 let best = Self::best_under(covering, || {
2691 Self::shield_in(&doc, principal_id, namespace, now_ms())
2692 })?;
2693 Ok(best.is_some_and(|have| have >= level))
2694 }
2695
2696 /// May this principal administer the server (create namespaces, register
2697 /// principals)?
2698 ///
2699 /// Never, for the anonymous principal, for the reason
2700 /// [`AuthTable::may_namespace`] gives.
2701 pub fn may_server(&self, principal_id: &str, level: Level) -> Result<bool> {
2702 if principal_id == crate::model::ANONYMOUS_PRINCIPAL_ID {
2703 return Ok(false);
2704 }
2705 let doc = self.doc.load()?;
2706 // Only the actor's own gate here: `Scope::Server` names no account, so
2707 // there is no second name to check. Without this line a gated principal
2708 // holding `Server`/`Admin` could lift their own gate through the front
2709 // door, which would make the whole table advisory.
2710 if Self::gated(&doc, principal_id)? {
2711 return Ok(false);
2712 }
2713 if !doc.principals.contains_key(principal_id)? {
2714 return Ok(false);
2715 }
2716 let best = Self::holder_grants(&doc, principal_id)?
2717 .into_iter()
2718 .filter(|grant| grant.scope == Scope::Server)
2719 .map(|grant| grant.level)
2720 .max();
2721 Ok(best.is_some_and(|have| have >= level))
2722 }
2723
2724 /// **Does this principal hold a SERVER-scope grant** — any level, its own or through a
2725 /// group (2026-10-08)? The operator's mark: a namespace whose owner holds one is the
2726 /// SELLER's own (`Namespace.owner_server_grant`), never a customer a selling shop must give
2727 /// a fact. Unlike [`AuthTable::may_server`] the gate is not asked: a gated operator is still
2728 /// the seller (a fact given in the meantime would stay after the gate lifts). Only an
2729 /// operator grants `Scope::Server`; nothing a customer does — signing up, an invite, its
2730 /// own account, an employee's enrolment, a namespace or repository grant — makes one.
2731 pub fn holds_server_grant(&self, principal_id: &str) -> Result<bool> {
2732 if principal_id == crate::model::ANONYMOUS_PRINCIPAL_ID {
2733 return Ok(false);
2734 }
2735 let doc = self.doc.load()?;
2736 if !doc.principals.contains_key(principal_id)? {
2737 return Ok(false);
2738 }
2739 Ok(Self::holder_grants(&doc, principal_id)?
2740 .iter()
2741 .any(|grant| grant.scope == Scope::Server))
2742 }
2743
2744 // ── accounts (`<host>/<account>/<repo>`) ───────────────────────────────
2745
2746 /// **Does this principal own this account?**
2747 ///
2748 /// An account is a namespace, and a principal owns the namespace whose name
2749 /// is its own id — `github.com/<konto>/<repo>`, with `<konto>` the user.
2750 ///
2751 /// # Why this is a rule and not a grant
2752 ///
2753 /// Account admin is **intrinsic**, not granted. A `Grant { scope:
2754 /// Namespace(id), level: Admin }` row would look identical from the outside
2755 /// and would be *revocable*: `Access.Revoke` would take a user's own
2756 /// account away from them, and nothing would put it back, because nothing
2757 /// would remember that it was supposed to be there. This function reads no
2758 /// table, so there is no row to remove.
2759 ///
2760 /// The collision it depends on is closed at the other end: a principal's
2761 /// account namespace is created **with** the principal
2762 /// (`CoreControlPlane::add_principal`), so a namespace that already exists
2763 /// makes the principal fail to be created and a principal that already
2764 /// exists makes the namespace fail to be created. Neither name can be
2765 /// claimed twice, so this rule can never hand one person's namespace to
2766 /// somebody else.
2767 ///
2768 /// Associated rather than free so there is one spelling of the rule and
2769 /// both [`AuthTable::may`] and [`AuthTable::may_namespace`] use it (one implementation).
2770 pub fn owns_account(principal_id: &str, namespace: &str) -> bool {
2771 // Never for the anonymous principal, which holds no account. Both
2772 // callers already refuse it above; this is the belt, because the whole
2773 // rule is "a name equals a name" and `""` equalling `""` would be a
2774 // catastrophic reading of it.
2775 !principal_id.is_empty()
2776 && principal_id != crate::model::ANONYMOUS_PRINCIPAL_ID
2777 && principal_id == namespace
2778 }
2779
2780 /// **Is `principal_id` an account's MEMBER** (an employee its admin
2781 /// enrolled)? A member has no account of its own (2026-10-07, the members
2782 /// model): rule 2 does not make it the admin of a namespace that happens
2783 /// to carry its id — the walkthrough's `ben` "owned" a namespace `ben`
2784 /// nobody had made, was offered it for New repository and Import, and
2785 /// bought room for it. Asked only when the names are equal, so the hot
2786 /// path pays one probe for the rare case.
2787 fn is_member(doc: &AuthDoc, principal_id: &str) -> Result<bool> {
2788 Ok(doc.employers.get(principal_id)?.is_some())
2789 }
2790
2791 // ── the account gate (see `crate::gate`) ───────────────────────────────
2792
2793 /// **Is `account` locked out right now?** The predicate every `may*` reads.
2794 ///
2795 /// Takes the already-loaded document rather than loading its own, because
2796 /// [`AuthTable::may`] asks it twice per call and a second `load()` would
2797 /// double the parse on the path the sharding exists to keep cheap — and, worse,
2798 /// could answer the two halves of one decision from two versions of the
2799 /// table.
2800 ///
2801 /// An **expired** lease is not a gate. That is the whole crash-safety
2802 /// story: see [`crate::gate`].
2803 /// **Namespaces nest, so this walks the ancestors.** `alice/team` is
2804 /// a different namespace from `alice` — [`Scope::Namespace`] matches
2805 /// exactly and [`AuthTable::owns_account`] is a string equality — but on
2806 /// the disk it is `repos/alice/team/…`, inside the account's own directory
2807 /// subtree, and `Accounts.Purge` reclaims it along with the rest. A gate
2808 /// keyed by exact name would leave open the one part of an account a purge
2809 /// is walking.
2810 ///
2811 /// On PATH COMPONENTS and never on characters: `alicehacks` is a different
2812 /// account and a gate on `alice` must not close it.
2813 ///
2814 /// The cost is one shard probe per component, on a path
2815 /// [`AuthTable::may`] runs twice per connection. Namespaces are one
2816 /// component deep in every deployment there has been, so this is one probe;
2817 /// the walk exists so that the rare nested case is correct rather than
2818 /// silently exempt.
2819 fn gated(doc: &AuthDoc, account: &str) -> Result<bool> {
2820 let now = crate::model::now_ms();
2821 let mut prefix = String::new();
2822 for component in account.split('/') {
2823 if !prefix.is_empty() {
2824 prefix.push('/');
2825 }
2826 prefix.push_str(component);
2827 if doc
2828 .gates
2829 .get(&prefix)?
2830 .is_some_and(|gate| gate.is_live_at(now))
2831 {
2832 return Ok(true);
2833 }
2834 }
2835 Ok(false)
2836 }
2837
2838 /// **Lock an account out.** Upserts: the newest writer owns the row.
2839 ///
2840 /// Upsert rather than refuse-if-present on purpose. The two writers are a
2841 /// purge taking a lease and a person suspending an account, and in every
2842 /// ordering the *later* decision is the one that should stand — a refusal
2843 /// would mean an operator could not suspend an account a purge is holding,
2844 /// which is precisely when they most want to. What is protected instead is
2845 /// the *lift*: see [`AuthTable::lift_gate_held_by`].
2846 ///
2847 /// Returns the row as stored. Idempotent in the sense that matters: gating
2848 /// an already-gated account leaves it gated.
2849 pub fn gate(&self, gate: crate::gate::AccountGate) -> Result<crate::gate::AccountGate> {
2850 // A gate is a statement about a name, and the name has to be one this
2851 // server could ever hold — the same check `add_principal` makes, so a
2852 // path-traversal string cannot be parked in the table as a gate.
2853 crate::sandbox::check_component(&gate.account, &gate.account)?;
2854 if gate.account == crate::model::ANONYMOUS_PRINCIPAL_ID {
2855 // Gating "anonymous" would look like it closed public reads across
2856 // the whole server and would in fact do nothing of the sort, since
2857 // `may` reads the *repository's* namespace for the second check.
2858 // A refusal by name beats a row that lies.
2859 return Err(Error::ReservedPrincipalId(gate.account));
2860 }
2861 self.doc.update(|doc| {
2862 doc.gates.insert(gate.account.clone(), gate.clone())?;
2863 Ok(gate.clone())
2864 })
2865 }
2866
2867 /// **The gate on `account`, if one is in force.**
2868 ///
2869 /// `None` covers both "never gated" and "the lease expired": to everything
2870 /// downstream those are the same fact, and reporting an expired lease as a
2871 /// lockout would tell an operator an account is shut when it is open.
2872 pub fn gate_of(&self, account: &str) -> Result<Option<crate::gate::AccountGate>> {
2873 Ok(self
2874 .doc
2875 .load()?
2876 .gates
2877 .get(account)?
2878 .filter(|gate| gate.is_live_at(crate::model::now_ms())))
2879 }
2880
2881 /// **The gate that closes `account`**, which may be on an ancestor
2882 /// namespace rather than on `account` itself — see [`AuthTable::gated`],
2883 /// whose walk this is the reporting half of.
2884 ///
2885 /// [`AuthTable::gate_of`] stays an exact lookup because `Accounts.Ungate`
2886 /// and the purge's own release name a row, and lifting "whatever covers
2887 /// this" would let an ungate of `alice/team` silently lift the gate on
2888 /// `alice`. This one answers the different question a refusal has to
2889 /// answer: *why can I not do this*.
2890 pub fn gate_covering(&self, account: &str) -> Result<Option<crate::gate::AccountGate>> {
2891 let doc = self.doc.load()?;
2892 let now = crate::model::now_ms();
2893 let mut prefix = String::new();
2894 for component in account.split('/') {
2895 if !prefix.is_empty() {
2896 prefix.push('/');
2897 }
2898 prefix.push_str(component);
2899 if let Some(gate) = doc.gates.get(&prefix)?.filter(|g| g.is_live_at(now)) {
2900 return Ok(Some(gate));
2901 }
2902 }
2903 Ok(None)
2904 }
2905
2906 /// Every gate in force, by account. The listing path, **O(gates)**.
2907 pub fn gates(&self) -> Result<Vec<crate::gate::AccountGate>> {
2908 let now = crate::model::now_ms();
2909 let mut live: Vec<crate::gate::AccountGate> = self
2910 .doc
2911 .load()?
2912 .gates
2913 .values()?
2914 .into_iter()
2915 .filter(|gate| gate.is_live_at(now))
2916 .collect();
2917 live.sort_by(|a, b| a.account.cmp(&b.account));
2918 Ok(live)
2919 }
2920
2921 /// **Lift the gate on `account`, whoever holds it.** Returns what was
2922 /// there, or `None` if nothing was.
2923 ///
2924 /// The operator's override, and the reason a bricked account is always
2925 /// recoverable: it does not consult the holder, so a lease left behind by
2926 /// a process that no longer exists can still be cleared by hand.
2927 pub fn lift_gate(&self, account: &str) -> Result<Option<crate::gate::AccountGate>> {
2928 self.doc.update(|doc| doc.gates.remove(account))
2929 }
2930
2931 /// **Lift the gate on `account` only if `holder` still holds it.**
2932 ///
2933 /// What a finishing purge calls. If an operator suspended the account
2934 /// while the purge was running, the row now names them, this returns
2935 /// `None`, and the suspension survives — a purge must not be able to
2936 /// un-suspend an account as a side effect of finishing.
2937 ///
2938 /// The read and the remove are inside one [`DocRef::update`], so there is
2939 /// no window between checking the holder and clearing the row.
2940 pub fn lift_gate_held_by(
2941 &self,
2942 account: &str,
2943 holder: &str,
2944 ) -> Result<Option<crate::gate::AccountGate>> {
2945 self.doc.update(|doc| {
2946 match doc.gates.get(account)? {
2947 Some(gate) if gate.holder == holder => doc.gates.remove(account),
2948 // Somebody else's row, or none at all. Both are "nothing of
2949 // mine to lift", and neither is an error: a purge that failed
2950 // its own lease renewal and had it expire finds no row here and
2951 // must still be able to finish reporting.
2952 _ => Ok(None),
2953 }
2954 })
2955 }
2956
2957 /// **Push a lease's deadline out**, if `holder` still holds it.
2958 ///
2959 /// The heartbeat a running purge makes after each repository it reclaims.
2960 /// Returns whether the row was renewed — `false` means the purge no longer
2961 /// holds the gate (an operator replaced it, or its lease expired and
2962 /// something else took the account), which the worker logs rather than
2963 /// treats as a failure: it is still emptying an account that is still shut.
2964 pub fn renew_gate(&self, account: &str, holder: &str, expires: UnixMillis) -> Result<bool> {
2965 self.doc.update(|doc| {
2966 let renew = doc
2967 .gates
2968 .get(account)?
2969 .is_some_and(|gate| gate.holder == holder);
2970 if renew {
2971 doc.gates
2972 .modify(account, |gate| gate.expires = Some(expires))?;
2973 }
2974 Ok(renew)
2975 })
2976 }
2977
2978 // ── bootstrap (the secret-store seam) ──────────────────────────────────
2979
2980 /// Are there no keys at all? A fresh server, in other words.
2981 ///
2982 /// **This is not "has bootstrap happened".** See [`AuthTable::bootstrap_record`].
2983 pub fn is_empty(&self) -> Result<bool> {
2984 Ok(self.doc.load()?.keys.is_empty())
2985 }
2986
2987 /// **Has the unauthenticated enrolment window ever been used?**
2988 ///
2989 /// `Some` means it is shut, permanently, whatever the identity table now
2990 /// holds. `Err` means the table could not be read, and a caller must treat
2991 /// that as *unknown*, never as *no*: reading "cannot read" as "not
2992 /// bootstrapped" is precisely how the door reopens for real. Every caller
2993 /// in the tree propagates it; `bootstrap_persistence.rs` corrupts the
2994 /// document and asserts the enrolment RPC answers `UNAVAILABLE` rather than
2995 /// enrolling.
2996 pub fn bootstrap_record(&self) -> Result<Option<BootstrapRecord>> {
2997 Ok(self.doc.load()?.bootstrap.clone())
2998 }
2999
3000 /// How many principals, keys and grants there are, for `Admin.Stats`.
3001 ///
3002 /// Principals and keys are counted, not walked. Grant *rows* are not grant
3003 /// *entries*; one entry holds a principal's whole list, so the third number
3004 /// costs a walk of the ACL. `Admin.Stats` is an operator asking a question,
3005 /// not a connection being served.
3006 pub fn counts(&self) -> Result<(usize, usize, usize)> {
3007 let doc = self.doc.load()?;
3008 let grants = doc.grants.values()?.iter().map(Vec::len).sum();
3009 Ok((doc.principals.len(), doc.keys.len(), grants))
3010 }
3011
3012 /// **THE ONE WRITER of the first identity, and the one place the
3013 /// unauthenticated enrolment window is closed.**
3014 ///
3015 /// `--admin-key` at start-up and the `Bootstrap.Enroll` RPC both land here
3016 /// (one implementation). There is exactly one code path that can produce a
3017 /// [`BootstrapRecord`], and it does five things in **one** compare-and-swap
3018 /// on `refs/gunnar/auth`:
3019 ///
3020 /// 1. refuse if a [`BootstrapRecord`] already exists;
3021 /// 2. refuse if the identity table already holds a principal or a key —
3022 /// belt to (1)'s braces, for a table written before this field existed;
3023 /// 3. create the principal, whose id **is** its account name;
3024 /// 4. register the key, and grant it `Server`/`Admin`, which is what
3025 /// "gunnar-admin" is;
3026 /// 5. write the [`BootstrapRecord`].
3027 ///
3028 /// # Why one CAS is the whole concurrency argument
3029 ///
3030 /// [`DocRef::update`] re-reads the document on every attempt and commits
3031 /// with a compare-and-swap on the reference. Two enrolments racing both run
3032 /// step 1 against a table with no record; one of them commits; the other
3033 /// loses the CAS, is retried from a **fresh read**, sees the winner's
3034 /// record, and returns [`Error::AlreadyBootstrapped`]. There is no window
3035 /// between "check" and "write" because they are the same write. A lock, a
3036 /// flag file or a second reference would each have one.
3037 ///
3038 /// Partial failure cannot leave the door half-shut for the same reason: the
3039 /// principal, the key, the grant and the record are one blob and one ref
3040 /// move. Either all five happened or none did.
3041 fn install_first_identity(&self, id: &str, name: &str, openssh: &str) -> Result<Enrolled> {
3042 crate::sandbox::check_component(id, id)?;
3043 if id == crate::model::ANONYMOUS_PRINCIPAL_ID {
3044 return Err(Error::ReservedPrincipalId(id.to_owned()));
3045 }
3046 let parsed = parse_public_key(openssh)?;
3047 let fingerprint = Fingerprint::of_key(&parsed);
3048 let algo = parsed.algorithm().as_str().to_owned();
3049 let normalised = parsed.to_openssh().map_err(Error::from)?.trim().to_owned();
3050
3051 let enrolled = self.doc.update(|doc| {
3052 if let Some(record) = &doc.bootstrap {
3053 return Err(Error::AlreadyBootstrapped {
3054 at: record.at,
3055 principal_id: record.principal_id.clone(),
3056 });
3057 }
3058 // A table with identities in it but no record predates this field.
3059 // Refuse anyway and say which of the two refusals fired, or the
3060 // upgrade path becomes a way to enrol a second admin on a live
3061 // server.
3062 if !doc.principals.is_empty() || !doc.keys.is_empty() {
3063 return Err(Error::AlreadyProvisioned {
3064 principals: doc.principals.len(),
3065 keys: doc.keys.len(),
3066 });
3067 }
3068 let principal = Principal {
3069 id: id.to_owned(),
3070 name: name.to_owned(),
3071 kind: PrincipalKind::User,
3072 created: now_ms(),
3073 };
3074 doc.principals.insert(id.to_owned(), principal.clone())?;
3075 Self::index_name(doc, &principal)?;
3076 let key = PublicKey {
3077 fingerprint_sha256: fingerprint.as_str().to_owned(),
3078 algo: algo.clone(),
3079 principal_id: id.to_owned(),
3080 added: now_ms(),
3081 last_used: None,
3082 expires: None,
3083 openssh: normalised.clone(),
3084 };
3085 doc.keys
3086 .insert(fingerprint.as_str().to_owned(), key.clone())?;
3087 // gunnar-admin. A `Scope::Server` Admin grant and nothing new:
3088 // it is the same row `Access.Grant --server … admin` writes, so
3089 // any gunnar-admin can hand it to anybody else through the ordinary
3090 // authenticated verb and there is no second path (one implementation).
3091 doc.grants.insert(
3092 id.to_owned(),
3093 vec![Grant::server(id.to_owned(), Level::Admin)],
3094 )?;
3095 let record = BootstrapRecord {
3096 at: now_ms(),
3097 principal_id: id.to_owned(),
3098 fingerprint: fingerprint.as_str().to_owned(),
3099 account: id.to_owned(),
3100 };
3101 doc.bootstrap = Some(record.clone());
3102 Ok(Enrolled {
3103 principal,
3104 key,
3105 record,
3106 })
3107 })?;
3108
3109 tracing::warn!(
3110 target: "gunnar::audit",
3111 event = "bootstrap.enrolled",
3112 principal = %enrolled.principal.id,
3113 account = %enrolled.record.account,
3114 %fingerprint,
3115 "THE UNAUTHENTICATED ENROLMENT WINDOW IS NOW CLOSED. It cannot be reopened by \
3116 removing this principal or this key: the record lives beside the identity table, \
3117 not in it."
3118 );
3119 Ok(enrolled)
3120 }
3121
3122 /// **Enrol the first identity over the wire.** The `Bootstrap.Enroll` RPC.
3123 ///
3124 /// One name, one public key. The principal that comes out is
3125 /// gunnar-admin (a `Server`/`Admin` grant) *and* intrinsically admin of the
3126 /// account `<name>`; everything after this is ordinary authenticated
3127 /// administration through `Access.*`.
3128 ///
3129 /// Refuses with [`Error::AlreadyBootstrapped`] once it has succeeded once,
3130 /// **including after every identity it created has been deleted**.
3131 pub fn enroll_first_user(&self, name: &str, openssh: &str) -> Result<Enrolled> {
3132 self.install_first_identity(name, name, openssh)
3133 }
3134
3135 /// Install the first admin from a public key, on an empty table.
3136 ///
3137 /// This is the answer to "how does the first key get in on a box with no
3138 /// shell" **when the operator already has the key at boot time**: it comes
3139 /// from `--admin-key` and is installed here. It closes the enrolment window
3140 /// exactly as [`AuthTable::enroll_first_user`] does — otherwise a server
3141 /// started with `--admin-key` would still be standing there with an open
3142 /// unauthenticated door.
3143 ///
3144 /// Returns [`Bootstrap::AlreadyProvisioned`] and changes nothing if
3145 /// bootstrap has already happened, so it is safe to call on every boot.
3146 pub fn bootstrap_admin(&self, openssh: &str) -> Result<Bootstrap> {
3147 match self.install_first_identity(BOOTSTRAP_ADMIN_ID, "bootstrap admin", openssh) {
3148 Ok(enrolled) => Ok(Bootstrap::Installed(enrolled.principal)),
3149 Err(Error::AlreadyBootstrapped { .. }) | Err(Error::AlreadyProvisioned { .. }) => {
3150 tracing::debug!("identity table already provisioned; bootstrap key ignored");
3151 Ok(Bootstrap::AlreadyProvisioned)
3152 }
3153 Err(err) => Err(err),
3154 }
3155 }
3156
3157 /// **The start-up gate.** Install the admin key if one was supplied and
3158 /// nothing has been enrolled, then say what state this server is in.
3159 ///
3160 /// | bootstrap record | keys | `admin_key` | result |
3161 /// |---|---|---|---|
3162 /// | present | any | any | [`Readiness::Ready`], key ignored |
3163 /// | absent | any | `Some` | admin installed, [`Readiness::Ready`] |
3164 /// | absent | none | `None` | [`Readiness::AwaitingBootstrap`] — **door open** |
3165 /// | present | none | any | [`Readiness::SealedAndEmpty`] — door stays shut |
3166 ///
3167 /// The third row used to be a refusal to start, which is why gunnar had no
3168 /// first boot; see [`Readiness`].
3169 ///
3170 /// A read fault propagates as `Err`. It is never reported as "nothing is
3171 /// enrolled": that would open the door on a corrupt or unreadable document,
3172 /// which is the single worst failure this design can have.
3173 pub fn prepare_to_serve(&self, admin_key: Option<&str>) -> Result<Readiness> {
3174 if let Some(key) = admin_key {
3175 // Idempotent, and it seals. A no-op when bootstrap has happened.
3176 self.bootstrap_admin(key)?;
3177 }
3178 let doc = self.doc.load()?;
3179 if !doc.keys.is_empty() {
3180 return Ok(Readiness::Ready);
3181 }
3182 match &doc.bootstrap {
3183 Some(record) => Ok(Readiness::SealedAndEmpty {
3184 record: Some(Box::new(record.clone())),
3185 }),
3186 // A table with principals but no keys and no record is not a fresh
3187 // server either: `install_first_identity` refuses it, so reporting
3188 // the window as open here would be a status that disagrees with the
3189 // gate. `bootstrap_window_open` is the one predicate both use.
3190 None if !doc.principals.is_empty() => Ok(Readiness::SealedAndEmpty { record: None }),
3191 None => Ok(Readiness::AwaitingBootstrap),
3192 }
3193 }
3194
3195 /// **Is `Bootstrap.Enroll` servable without a credential right now?**
3196 ///
3197 /// The single predicate the gate and the status report both read, so a
3198 /// server can never say "closed" and then enrol, or say "open" and then
3199 /// refuse. Its three clauses are the three refusals
3200 /// [`AuthTable::install_first_identity`] performs, in the same order.
3201 pub fn bootstrap_window_open(&self) -> Result<bool> {
3202 let doc = self.doc.load()?;
3203 Ok(doc.bootstrap.is_none() && doc.principals.is_empty() && doc.keys.is_empty())
3204 }
3205}
3206
3207/// What [`AuthTable::enroll_first_user`] created: the principal, its key, and
3208/// the record that shut the door behind it.
3209#[derive(Clone, PartialEq, Eq, Debug)]
3210pub struct Enrolled {
3211 /// The principal that was created. Its id is also its account name.
3212 pub principal: Principal,
3213 /// The key it was created with.
3214 pub key: PublicKey,
3215 /// The record that shut the enrolment window.
3216 pub record: BootstrapRecord,
3217}
3218
3219#[cfg(test)]
3220mod name_index_tests {
3221 use super::*;
3222
3223 /// A row written by a server from before the `names` index existed has no
3224 /// index entry — and it must still be found by name, and its name must
3225 /// still be refused to a newcomer. Driven by writing the row the old way,
3226 /// straight into `principals` with no index, which nothing public can do
3227 /// any more; that is why this test lives here.
3228 #[test]
3229 fn a_row_from_before_the_index_resolves_by_name_and_keeps_it() {
3230 let store = crate::memory_control_store().expect("store");
3231 let auth = AuthTable::new(store).expect("table");
3232 let legacy = Principal::new("alice", "alice", PrincipalKind::User).unwrap();
3233 auth.doc
3234 .update(|doc| {
3235 doc.principals.insert("alice".into(), legacy.clone())?;
3236 Ok(())
3237 })
3238 .expect("write the old way");
3239 assert!(
3240 auth.doc.load().unwrap().names.is_empty(),
3241 "the fixture must be a row with NO index entry, or the test proves nothing"
3242 );
3243
3244 assert_eq!(
3245 auth.principal_by_name("alice").expect("read").map(|p| p.id),
3246 Some("alice".to_owned()),
3247 "a pre-index row must be found by its name"
3248 );
3249 let err = auth
3250 .add_principal(Principal::new("alice2", "alice", PrincipalKind::User).unwrap())
3251 .expect_err("a pre-index row's name is still taken");
3252 assert!(matches!(err, Error::NameExists { .. }), "{err}");
3253
3254 // Renaming it brings it into the index, and frees nothing that is an id.
3255 auth.rename_principal("alice", "Alice").expect("rename");
3256 assert_eq!(
3257 auth.principal_by_name("Alice").expect("read").map(|p| p.id),
3258 Some("alice".to_owned())
3259 );
3260 assert!(auth.principal_by_name("alice").expect("read").is_none());
3261 }
3262}
3263
3264#[cfg(test)]
3265mod certificate_fingerprint_tests {
3266 use super::{fingerprint_of_blob, fingerprint_of_certificate};
3267
3268 /// **Different certificates must fingerprint differently, and the same one
3269 /// must fingerprint the same.**
3270 ///
3271 /// The first half is what makes the value an identity at all. The second is
3272 /// what makes it storable: a fingerprint that varied per call would put a
3273 /// principal in the `AuthTable` that nothing could ever match.
3274 #[test]
3275 fn a_certificate_fingerprint_is_stable_and_distinguishing() {
3276 let a = b"-----BEGIN CERTIFICATE-----AAAA-----END CERTIFICATE-----";
3277 let b = b"-----BEGIN CERTIFICATE-----AAAB-----END CERTIFICATE-----";
3278
3279 assert_eq!(
3280 fingerprint_of_certificate(a),
3281 fingerprint_of_certificate(a),
3282 "the same certificate must fingerprint the same, or nothing could store one"
3283 );
3284 assert_ne!(
3285 fingerprint_of_certificate(a),
3286 fingerprint_of_certificate(b),
3287 "two certificates differing by one byte must not share a fingerprint"
3288 );
3289 }
3290
3291 /// **A certificate and an SSH key must never collide**, even on identical
3292 /// bytes.
3293 ///
3294 /// They cannot today — both are SHA256 over the input, so identical input
3295 /// gives identical output — and that is worth *asserting* rather than
3296 /// assuming, because the consequence is not cosmetic: a collision would let
3297 /// a credential of one kind be presented as the other and match a principal
3298 /// it was never issued to.
3299 ///
3300 /// This test therefore documents a REAL property of the current scheme: the
3301 /// two spaces are not separated. Registering a fingerprint registers the
3302 /// bytes, not the credential type. If that is ever not wanted, the fix is a
3303 /// domain separator in the hash input, and this test is where it will be
3304 /// noticed.
3305 #[test]
3306 fn the_two_credential_kinds_share_one_hash_space_and_that_is_recorded() {
3307 let bytes = b"identical bytes, two meanings";
3308 assert_eq!(
3309 fingerprint_of_certificate(bytes),
3310 fingerprint_of_blob(bytes),
3311 "if these ever differ, the hash grew a domain separator and the AuthTable's \
3312 existing entries are all invalidated — which is a migration, not a tidy-up"
3313 );
3314 }
3315}
3316
3317/// [`AuthTable::plan_room`]'s answer, and which seat lot a claim takes.
3318struct RoomPlan {
3319 answer: RoomAnswer,
3320 lot: Option<usize>,
3321}
3322
3323/// **The operator's server grant stops at an account's door; the account
3324/// admin approves a look** (2026-10-05). A child module so the identity table
3325/// keeps one file per concern.
3326pub mod operator_access;
3327
3328/// **The seller's mark** (2026-10-08): only a SERVER-scope grant makes a principal's namespace
3329/// the seller's own (`Namespace.owner_server_grant`) — what a selling shop leaves out when it
3330/// gives every person's account a fact.
3331#[cfg(test)]
3332mod seller_mark_tests {
3333 use super::*;
3334 use crate::model::{Grant, Level};
3335
3336 fn table() -> AuthTable {
3337 AuthTable::new(crate::memory_control_store().expect("store")).expect("table")
3338 }
3339
3340 fn person(auth: &AuthTable, id: &str) {
3341 auth.add_principal(Principal::new(id, id, PrincipalKind::User).unwrap())
3342 .expect("a person");
3343 }
3344
3345 /// ★ **A normal person never holds the seller's mark**: their own account, a namespace
3346 /// grant (even Admin, even on another account), a repository grant, being an employee —
3347 /// none of it is a server grant. A server grant, direct or through a group, is.
3348 #[test]
3349 fn only_a_server_grant_marks_the_seller_and_a_normal_person_never_holds_it() {
3350 let auth = table();
3351 for id in ["cust", "boss", "emp", "op", "staff", "ops-group"] {
3352 if id == "ops-group" {
3353 auth.add_principal(Principal::new(id, id, PrincipalKind::Group).unwrap())
3354 .unwrap();
3355 } else {
3356 person(&auth, id);
3357 }
3358 }
3359 auth.grant(Grant::namespace("cust", "cust", Level::Admin))
3360 .unwrap();
3361 auth.grant(Grant::namespace("cust", "boss", Level::Admin))
3362 .unwrap();
3363 let _ = auth.enroll_employee(
3364 "boss",
3365 Principal::new("emp2", "emp2", PrincipalKind::User).unwrap(),
3366 );
3367 for normal in [
3368 "cust",
3369 "boss",
3370 "emp",
3371 "emp2",
3372 "nobody",
3373 crate::model::ANONYMOUS_PRINCIPAL_ID,
3374 ] {
3375 assert!(
3376 !auth.holds_server_grant(normal).unwrap(),
3377 "{normal} is a normal person (or nobody): never the seller"
3378 );
3379 }
3380 auth.grant(Grant::server("op", Level::Read)).unwrap();
3381 assert!(
3382 auth.holds_server_grant("op").unwrap(),
3383 "a server grant at any level is the seller's mark"
3384 );
3385 auth.grant(Grant::server("ops-group", Level::Write))
3386 .unwrap();
3387 auth.add_to_group("ops-group", "staff").unwrap();
3388 assert!(
3389 auth.holds_server_grant("staff").unwrap(),
3390 "through a group too"
3391 );
3392 assert!(
3393 !auth.holds_server_grant("cust").unwrap(),
3394 "and still never the customer"
3395 );
3396 }
3397}