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