gunnar_ssh/control.rs
1//! The control plane this listener answers from: `gunnar-core`, directly
2//! (the ACL model).
3//!
4//! # What this replaced
5//!
6//! Two local traits, `seam::AuthDirectory` and `seam::RepoResolver`, written
7//! while `gunnar-core` was being built in parallel. They are gone, and this is
8//! not a trait: there is one identity table and one catalog, they are
9//! `gunnar-core`'s, and putting an interface in front of them would only be
10//! somewhere for a second answer to live (one implementation).
11//!
12//! # The bug the seam had, which is why this is a rewrite and not a rename
13//!
14//! Both seam traits returned `Option`. `gunnar-core` returns `Result`, and
15//! deliberately: the table lives in storage (the Arrow-IPC ref log), a read can fail, and
16//! the ACL model spells out that `may(...)` must not be a bare `bool` because the
17//! safe-looking invention for the failure case ("no access") turns a storage
18//! fault into a silent permission denial. Any adapter from `Result` to `Option`
19//! has to make exactly that invention.
20//!
21//! So the `Result` is carried all the way through
22//! [`Dispatch::authorise`](crate::server::Dispatch::authorise) and lands as its
23//! own [`DispatchError::Store`](crate::server::DispatchError::Store) variant:
24//! separate from `Denied`, logged as a fault, and never reported to the client
25//! as "you may not have this repository".
26
27use std::sync::atomic::{AtomicBool, Ordering};
28use std::sync::Arc;
29
30use gunnar_core::{
31 AuthTable, ControlStore, Fingerprint, HashKind, Level, NewRepo, Principal, PrincipalKind, Repo,
32 RepoCatalog, RepoRef,
33};
34
35/// The identity table and the repository catalog, together.
36///
37/// Cheap to clone: both halves are handles onto the same stored documents.
38#[derive(Clone)]
39pub struct Control {
40 auth: AuthTable,
41 catalog: RepoCatalog,
42 /// **Is this box a frozen STANDBY?** `true` refuses a client `git push`.
43 ///
44 /// Shared by clone with the server's replication slot, which flips it on a
45 /// role change — so this front door and the control plane read ONE value
46 /// (one implementation) and never disagree about whether a write should be refused. The
47 /// default is an unshared `false`: a bare `Control` (a test, a build with no
48 /// replication) is a writable primary, exactly as before this existed.
49 writes_frozen: Arc<AtomicBool>,
50 /// **Is this box a browse replica**? Shared with
51 /// the replication slot the same way `writes_frozen` is; read for the
52 /// refusal WORDING (a browse box's refusal names itself) and to override
53 /// [`AnonymousPolicy::Refuse`] — the browse replica exists to serve
54 /// anonymous reads, so it serves them whatever the flag says.
55 browse: Arc<AtomicBool>,
56 /// **Is this box a TWIN right now?** `true` refuses every git service —
57 /// read and write, keyed and anonymous — at
58 /// [`Dispatch::authorise_service`](crate::server::Dispatch::authorise_service).
59 ///
60 /// Shared by clone with `ServerStores`' own flag, the way `writes_frozen`
61 /// and `browse` are, so the front door and the control plane read ONE value
62 /// and can never disagree. It has to be live, not a snapshot: a box is made
63 /// a twin over the wire by `Bootstrap.BecomeTwin` while it is already
64 /// running and serving, and under the owner's one-medium ruling that is the
65 /// ONLY way a twin is ever made. The default is an unshared `false` — a
66 /// bare `Control` is a primary, exactly as before this existed.
67 twin: Arc<AtomicBool>,
68 /// **Does this box serve anonymous reads at all?** The primary of a
69 /// browse-replica deployment says `Refuse`, and names where to go.
70 anonymous: AnonymousPolicy,
71 /// **The gate chain**: the server's answers to "may this namespace push
72 /// right now", asked IN ORDER by `Dispatch::authorise_service`; the first
73 /// `Refuse` wins. The server wires the disk-headroom gate first and, on a
74 /// `metered` build with a monetize key, the entitlement gate second. Empty
75 /// — a bare `Control`, a test — is every namespace Free with infinite disk,
76 /// which is what a test that is not about the gates wants.
77 plan: Vec<Arc<dyn crate::plan::PlanGate>>,
78 /// **The owner's enterprise list at the push door** (2026-10-05): the
79 /// name filter a first push's repository name is put to, and the
80 /// accounts' audit logs its creation is written into. `None` — a bare
81 /// `Control`, a test not about them — filters nothing and logs nothing.
82 enterprise: Option<(
83 gunnar_core::name_filter::NameFilterStore,
84 Arc<gunnar_core::account_audit::AccountAuditLog>,
85 )>,
86 /// People's encryption keys (CRYPT-YUBI): the `gunnar-user-key` door and the
87 /// recipient answer read and write them. `None`: a door without them.
88 encryption_keys: Option<gunnar_core::encryption_key::EncryptionKeys>,
89}
90
91/// **The `--anonymous` knob** (the browse replica). Anonymous reads of
92/// public repositories are either served here (`Allow`, the default and every
93/// pre-2026-09 server) or refused with a line that names the browse replica
94/// they are served by (`Refuse`). Strangers cost CPU and bandwidth on the box
95/// that also takes the paying writes; the browse replica is where that cost
96/// belongs, and the primary sends them there rather than quietly 404-ing.
97#[derive(Debug, Clone, PartialEq, Eq, Default)]
98pub enum AnonymousPolicy {
99 /// Serve anonymous reads of public repositories on this box.
100 #[default]
101 Allow,
102 /// Refuse every anonymous request; `browse_host`, when named, goes into
103 /// the refusal so `git clone` prints where to clone from instead.
104 Refuse {
105 /// `host[:port]` of the browse replica, for the refusal line.
106 browse_host: Option<String>,
107 },
108}
109
110impl Control {
111 /// Wrap an already-open table and catalog. Writable (a primary) until
112 /// [`Control::with_write_gate`] shares a standby's freeze flag in.
113 pub fn new(auth: AuthTable, catalog: RepoCatalog) -> Self {
114 Control {
115 auth,
116 catalog,
117 writes_frozen: Arc::new(AtomicBool::new(false)),
118 browse: Arc::new(AtomicBool::new(false)),
119 twin: Arc::new(AtomicBool::new(false)),
120 anonymous: AnonymousPolicy::Allow,
121 plan: Vec::new(),
122 enterprise: None,
123 encryption_keys: None,
124 }
125 }
126
127 /// The store of people's encryption keys this door reads and writes.
128 #[must_use]
129 pub fn with_encryption_keys(
130 mut self,
131 keys: gunnar_core::encryption_key::EncryptionKeys,
132 ) -> Self {
133 self.encryption_keys = Some(keys);
134 self
135 }
136
137 /// People's encryption keys, when this door holds them.
138 pub fn encryption_keys(&self) -> Option<&gunnar_core::encryption_key::EncryptionKeys> {
139 self.encryption_keys.as_ref()
140 }
141
142 /// **The name filter and the accounts' audit logs** (2026-10-05): a
143 /// repository a first push creates is named here too, and its creation is
144 /// in its account's log.
145 #[must_use]
146 pub fn with_enterprise(
147 mut self,
148 names: gunnar_core::name_filter::NameFilterStore,
149 audit: Arc<gunnar_core::account_audit::AccountAuditLog>,
150 ) -> Self {
151 self.enterprise = Some((names, audit));
152 self
153 }
154
155 /// Write one event into the account `namespace` belongs to, if a person
156 /// owns it. Best effort: one WARN when the log cannot be written.
157 fn account_event(
158 &self,
159 principal: &Principal,
160 namespace: &str,
161 kind: &str,
162 target: &str,
163 detail: &str,
164 refused: bool,
165 ) {
166 let Some((_, log)) = &self.enterprise else {
167 return;
168 };
169 let Ok(Some(account)) = self.auth.account_of_namespace(namespace) else {
170 return;
171 };
172 let role = self
173 .auth
174 .role_in_account(&principal.id, &account)
175 .unwrap_or("unknown");
176 let written = log.append(
177 &account,
178 gunnar_core::account_audit::AccountEvent {
179 kind,
180 actor: &principal.id,
181 actor_role: role,
182 target,
183 detail,
184 outcome: if refused {
185 gunnar_core::account_audit::Outcome::Refused
186 } else {
187 gunnar_core::account_audit::Outcome::Ok
188 },
189 },
190 gunnar_core::now_ms(),
191 );
192 if let Err(err) = written {
193 tracing::warn!(target: "gunnar::audit", event = "account_audit.not_written", account = %account, kind, error = %err, "an event could not be written to its account's audit log");
194 }
195 }
196
197 /// **An event about a person's own encryption key** (RA4-3): written into
198 /// the account the person belongs to, the same kinds the gRPC door writes
199 /// (`encryption-key.created`, `.method-added`, `.method-removed`).
200 pub fn encryption_key_event(&self, principal: &Principal, kind: &str, detail: &str) {
201 let Some((_, log)) = &self.enterprise else {
202 return;
203 };
204 let Ok(Some(account)) = self.auth.account_of_principal(&principal.id) else {
205 return;
206 };
207 let role = self
208 .auth
209 .role_in_account(&principal.id, &account)
210 .unwrap_or("unknown");
211 let written = log.append(
212 &account,
213 gunnar_core::account_audit::AccountEvent {
214 kind,
215 actor: &principal.id,
216 actor_role: role,
217 target: &principal.id,
218 detail,
219 outcome: gunnar_core::account_audit::Outcome::Ok,
220 },
221 gunnar_core::now_ms(),
222 );
223 if let Err(err) = written {
224 tracing::warn!(target: "gunnar::audit", event = "account_audit.not_written", account = %account, kind, error = %err, "an event could not be written to its account's audit log");
225 }
226 }
227
228 /// **The name filter at the push door**: the sentence a first push to
229 /// `path` is refused with, or `None`. A blocked name is noted for the
230 /// account admin; a borderline one is held for them (and refused until
231 /// they allow it). Reserved names are a person's and an account's, never
232 /// a repository's.
233 pub fn push_create_name_refusal(
234 &self,
235 principal: &Principal,
236 path: &RepoRef,
237 ) -> Option<String> {
238 use gunnar_core::name_filter::{NameKind, Verdict};
239 let (names, _) = self.enterprise.as_ref()?;
240 let account = self
241 .auth
242 .account_of_namespace(path.namespace())
243 .ok()
244 .flatten();
245 let verdict = names
246 .check(
247 NameKind::Repo,
248 path.name(),
249 account.as_deref().unwrap_or(""),
250 )
251 .ok()?;
252 if verdict == Verdict::Clean || matches!(verdict, Verdict::Reserved { .. }) {
253 return None;
254 }
255 let hold = matches!(verdict, Verdict::Borderline { .. }) && account.is_some();
256 if let Some(account) = &account {
257 let now = gunnar_core::now_ms();
258 let queued = if hold {
259 names.hold(
260 NameKind::Repo,
261 path.name(),
262 account,
263 &principal.id,
264 &verdict,
265 now,
266 )
267 } else {
268 names.note(
269 NameKind::Repo,
270 path.name(),
271 account,
272 &principal.id,
273 &verdict,
274 now,
275 )
276 };
277 if let Err(err) = queued {
278 tracing::warn!(target: "gunnar::audit", event = "name_filter.not_queued", account = %account, error = %err, "a name the filter stopped could not be put in its account's queue");
279 }
280 }
281 self.account_event(
282 principal,
283 path.namespace(),
284 if hold { "name.held" } else { "name.refused" },
285 path.name(),
286 &format!(
287 "a repo name, {} by the name filter, at a first push",
288 verdict.as_str()
289 ),
290 true,
291 );
292 Some(verdict.sentence(NameKind::Repo, path.name()))
293 }
294
295 /// **Share the standby write-gate in.** The `Arc` the server's replication
296 /// slot writes on a role change; reading it is how the front door learns,
297 /// per push, that this box has become a read-only standby.
298 #[must_use]
299 pub fn with_write_gate(mut self, writes_frozen: Arc<AtomicBool>) -> Self {
300 self.writes_frozen = writes_frozen;
301 self
302 }
303
304 /// Share the browse flag in — `ServerStores::browse()`, the same `Arc`
305 /// the replication slot flips.
306 #[must_use]
307 pub fn with_browse_flag(mut self, browse: Arc<AtomicBool>) -> Self {
308 self.browse = browse;
309 self
310 }
311
312 /// Set the anonymous policy. `Refuse` is overridden by the browse flag:
313 /// see [`Control::refuses_anonymous`].
314 #[must_use]
315 pub fn with_anonymous_policy(mut self, policy: AnonymousPolicy) -> Self {
316 self.anonymous = policy;
317 self
318 }
319
320 /// Share the twin flag in — `ServerStores::twin_flag()`, the same `Arc`
321 /// `Bootstrap.BecomeTwin` flips while the server runs.
322 #[must_use]
323 pub fn with_twin_flag(mut self, twin: Arc<AtomicBool>) -> Self {
324 self.twin = twin;
325 self
326 }
327
328 /// Is this box a browse replica right now?
329 pub fn is_browse(&self) -> bool {
330 self.browse.load(Ordering::SeqCst)
331 }
332
333 /// **Is this box a twin right now?** A live read: the answer changes under
334 /// a running server the moment `Bootstrap.BecomeTwin` returns.
335 pub fn is_twin(&self) -> bool {
336 self.twin.load(Ordering::SeqCst)
337 }
338
339 /// **Is an anonymous request refused here?** `Refuse` on a box that is
340 /// not a browse replica. A browse replica serves anonymous reads whatever
341 /// the knob says — it is the box the knob points AT, and a deployment that
342 /// flipped a primary into a browse box by `SetRole` must not carry the
343 /// primary's refusal along.
344 pub fn refuses_anonymous(&self) -> bool {
345 matches!(self.anonymous, AnonymousPolicy::Refuse { .. }) && !self.is_browse()
346 }
347
348 /// The browse host the refusal names, when the operator gave one.
349 pub fn browse_host(&self) -> Option<&str> {
350 match &self.anonymous {
351 AnonymousPolicy::Refuse { browse_host } => browse_host.as_deref(),
352 AnonymousPolicy::Allow => None,
353 }
354 }
355
356 /// **Append a gate to the chain.** Order is the call order: the server
357 /// hands the disk-headroom gate in first, then (`metered`) the entitlement
358 /// gate, so a full box is told it is full before it is told about its
359 /// plan. From here on every push asks each link in
360 /// `Dispatch::authorise_service`, and the first `Refuse` is the answer.
361 #[must_use]
362 pub fn with_plan_gate(mut self, gate: Arc<dyn crate::plan::PlanGate>) -> Self {
363 self.plan.push(gate);
364 self
365 }
366
367 /// The chain, in the order it is asked. Empty when nothing was wired.
368 pub fn plan_gates(&self) -> &[Arc<dyn crate::plan::PlanGate>] {
369 &self.plan
370 }
371
372 /// Open both over the server's control store (the certificate-identity plan).
373 pub fn open(store: Arc<dyn ControlStore>) -> Result<Self, gunnar_core::Error> {
374 Ok(Control {
375 auth: AuthTable::new(Arc::clone(&store))?,
376 encryption_keys: Some(
377 gunnar_core::encryption_key::EncryptionKeys::new(Arc::clone(&store)).map_err(
378 |e| match e {
379 gunnar_core::encryption_key::KeyRefusal::Store(e) => e,
380 other => gunnar_core::Error::ControlStore(other.to_string()),
381 },
382 )?,
383 ),
384 catalog: RepoCatalog::new(store)?,
385 writes_frozen: Arc::new(AtomicBool::new(false)),
386 browse: Arc::new(AtomicBool::new(false)),
387 twin: Arc::new(AtomicBool::new(false)),
388 anonymous: AnonymousPolicy::Allow,
389 plan: Vec::new(),
390 enterprise: None,
391 })
392 }
393
394 /// **Would a client write be refused right now?** `true` on a standby.
395 ///
396 /// The replication push from a primary is a [`PrincipalKind::Machine`]
397 /// identity and is NOT a client write — a standby exists to receive it —
398 /// so the freeze is scoped to non-`Machine` principals by
399 /// [`Control::write_frozen_for`], which is what the dispatch actually calls.
400 pub fn writes_frozen(&self) -> bool {
401 self.writes_frozen.load(Ordering::SeqCst)
402 }
403
404 /// **Must this principal's write be refused because this box is a standby?**
405 ///
406 /// `true` only when the box is frozen AND the writer is not the replication
407 /// source. A `Machine` identity is how a primary authenticates its
408 /// replication push, and a standby is a box that exists precisely to accept
409 /// that — freezing it would stop the very copy the standby is for. A human
410 /// (`User`) or a `Deploy` key pushing directly to a standby is the write
411 /// that must be refused: it would land here and never reach the primary.
412 pub fn write_frozen_for(&self, principal: &Principal) -> bool {
413 self.writes_frozen() && principal.kind != PrincipalKind::Machine
414 }
415
416 /// The identity table.
417 pub fn auth(&self) -> &AuthTable {
418 &self.auth
419 }
420
421 /// The repository catalog.
422 pub fn catalog(&self) -> &RepoCatalog {
423 &self.catalog
424 }
425
426 /// **Who is this?** The principal that owns `fingerprint`, or `None` to
427 /// refuse the authentication.
428 ///
429 /// `Err` is a storage fault and is *not* the same as `Ok(None)`. The caller
430 /// must refuse the key either way, but it must say which happened, because
431 /// "nobody holds this key" and "the identity table could not be read" call
432 /// for entirely different operator action.
433 pub fn principal_for_key(
434 &self,
435 fingerprint: &Fingerprint,
436 ) -> Result<Option<Principal>, gunnar_core::Error> {
437 self.auth.principal_by_fingerprint(fingerprint)
438 }
439
440 /// **Does it exist?** The repository at `path`, or `None`.
441 ///
442 /// `None` here is only ever "no such repository". It must not be conflated
443 /// with "no permission" at this layer: the server merges the two into one
444 /// client-visible message on purpose, so a client cannot use the difference
445 /// to enumerate private repositories, but they arrive separately so the
446 /// server log can tell them apart.
447 pub fn resolve(&self, path: &RepoRef) -> Result<Option<Repo>, gunnar_core::Error> {
448 self.catalog.get(path)
449 }
450
451 /// **May they?** The highest level `principal` holds on `repo`, or `None`
452 /// for no access at all.
453 ///
454 /// Derived by asking [`AuthTable::may`] from the top down, so this is the
455 /// ACL's answer and not a second reading of the grant table. A namespace
456 /// grant matches its namespace **exactly**: `team` does not reach
457 /// `team/sub`. That is what closes the escalation the permissive path
458 /// sandbox would otherwise carry, and it is pinned by
459 /// `gunnar-core`'s `tests/acl.rs`.
460 pub fn access_for(
461 &self,
462 principal: &Principal,
463 repo: &Repo,
464 ) -> Result<Option<Level>, gunnar_core::Error> {
465 for level in [Level::Admin, Level::Write, Level::Read] {
466 if self.auth.may(&principal.id, repo, level)? {
467 return Ok(Some(level));
468 }
469 }
470 Ok(None)
471 }
472
473 /// **The first-repository door, the checks**: may `principal` create
474 /// `path` by pushing to it? `Some(spec)` is what would be created —
475 /// nothing has been written; `None` the dispatch MUST render as its
476 /// ordinary not-found, so the door is invisible to everyone it does not
477 /// admit.
478 ///
479 /// # Why this exists (the appliance brick, 2026-08-25)
480 ///
481 /// On an installed appliance the bootstrap admin from `--admin-key`
482 /// authenticates and can then do nothing: the exec surface is three git
483 /// verbs, all of which want a repository; the catalog is empty; gRPC —
484 /// the only surface with `Repos.Create` — is off by design;
485 /// and there is no shell by construction. Nobody could ever create the
486 /// first namespace. `a8ba0665` fixed "authorizes nobody"; this fixes
487 /// "can do nothing".
488 ///
489 /// # It asks gRPC's questions, verbatim (one implementation)
490 ///
491 /// This is deliberately NOT a new permission vocabulary. The two
492 /// predicates are exactly the ones the gRPC control plane gates creation
493 /// on, so SSH and gRPC can never disagree about who may create:
494 ///
495 /// * a namespace that does not exist needs what `Repos.CreateNamespace`
496 /// needs: [`AuthTable::may_server`] at [`Level::Admin`] — creating a
497 /// tenant is a server-wide act;
498 /// * the repository needs what `Repos.Create` needs:
499 /// [`AuthTable::may_namespace`] at [`Level::Admin`] — namespace-scoped,
500 /// because the repository does not exist yet, so there is nothing
501 /// narrower to hold a grant on.
502 ///
503 /// So the door admits exactly who the RPC would admit: the gunnar-admin
504 /// anywhere, an account owner inside their own (existing) account
505 /// namespace, a namespace-admin inside that namespace exactly (no
506 /// inheritance into `team/sub`). The anonymous principal is refused by
507 /// construction inside both predicates.
508 ///
509 /// # Why the catalog's "never conjure a namespace" rule bends here
510 ///
511 /// [`RepoCatalog::create`] refuses a repository whose namespace does not
512 /// exist, "because then a typo in a push URL would silently become a
513 /// tenant". That rule is about *unprivileged* writers. Here the namespace
514 /// is only ever conjured by a principal holding `Server`/`Admin` — whose
515 /// grant already covers every namespace on the box, so no tenant boundary
516 /// is crossed; the blast radius of the admin's typo is a private, empty
517 /// repository on the admin's own server.
518 ///
519 /// # What it would create
520 ///
521 /// [`NewRepo::new`] and nothing else: sha256, private, default
522 /// branch. The one writer of defaults — a door that chose its own
523 /// defaults would be the second answer one implementation forbids.
524 ///
525 /// # Why the write is NOT here (the ghost repository, 2026-08-30)
526 ///
527 /// This used to create the catalog row at exec time, before the client
528 /// had seen the advertisement. A sha1 working tree pushing to the sha256
529 /// default reads `object-format=sha256` in that advertisement, prints
530 /// *"the receiving end does not support this repository's hash
531 /// algorithm"* and hangs up **having sent no command** — and the empty
532 /// repository it never pushed to was there afterwards: `repo get`
533 /// succeeded, `ls-remote` answered (the site test, two FAIL rows).
534 /// The server cannot know the client's hash before advertising, so the
535 /// only order that leaves nothing behind on a refusal is: advertise the
536 /// *prospect*, read the commands, check the hash, **then** write. The
537 /// write is [`Self::admit_push_create`], called by the transport once
538 /// there is a push to admit; the catalog is untouched until then.
539 pub fn push_create_spec(
540 &self,
541 principal: &Principal,
542 path: &RepoRef,
543 ) -> Result<Option<NewRepo>, gunnar_core::Error> {
544 let namespace = path.namespace();
545 let namespace_exists = self.catalog.namespace(namespace)?.is_some();
546 if !namespace_exists && !self.auth.may_server(&principal.id, Level::Admin)? {
547 return Ok(None);
548 }
549 // The account admin's door — and a CLAN's member's (clans 2026-10-05,
550 // the rule `Repos.Create` keeps): the member creates PRIVATE
551 // repositories in the clan with the Write its membership gives, and
552 // the prospect below is private. An invited account that has not
553 // accepted is no member.
554 let admin = self
555 .auth
556 .may_namespace(&principal.id, namespace, Level::Admin)?;
557 let member = !admin
558 && namespace_exists
559 && self.auth.clan_role(&principal.id, namespace)?
560 == Some(gunnar_core::ClanRole::Member);
561 if !(admin || member) {
562 return Ok(None);
563 }
564 // **SHA-1, and only here.** `NewRepo::new` defaults to sha256, which is
565 // the right default for a repository somebody ASKS for: `repo create`
566 // takes a hash kind, and a caller that wants sha256 gets it. A
567 // first-push prospect is not asked for — it is inferred, before the
568 // client has said one word — and git's v1 receive-pack advertises the
569 // repository's object format BEFORE the client sends anything, so there
570 // is nothing to infer it from. The client then reads the capability and
571 // decides on its own.
572 //
573 // MEASURED on gunnar.rs, 2026-09-20, seeding a freshly re-imaged forge:
574 //
575 // xtask: REFUSED [seed-push] nordisk/gunnar: protocol error:
576 // local repository is Sha1 but the remote is Sha256;
577 // git cannot push between object formats
578 //
579 // The push never reached the hash check this door's whole ordering was
580 // built around ("advertise the prospect, read the commands, check the
581 // hash, THEN write") — the client refused the advertisement. So the
582 // first-push door, as shipped, admitted only sha256 clients, and this
583 // estate has none: all four declared repositories are sha1, as is every
584 // clone of them and every repository git creates without being told
585 // otherwise.
586 //
587 // sha1 is what an un-negotiated client assumes, so it is the only
588 // prospect that can be right for a client that has not spoken yet. The
589 // hash check after the commands is unchanged and still has the final
590 // word; a sha256 pusher is still served by `repo create`, where the
591 // hash is a decision somebody made rather than one nobody could.
592 Ok(Some(NewRepo::new(path.clone()).hash_kind(HashKind::Sha1)))
593 }
594
595 /// **The first-repository door, the write**: the namespace, conjured if
596 /// it does not exist, and the catalog row — for a push that has sent its
597 /// commands and passed the hash check. Re-asks
598 /// [`Self::push_create_spec`]'s questions, so a grant revoked between the
599 /// advertisement and the pack is still a refusal; `Ok(None)` is that
600 /// refusal.
601 ///
602 /// The on-disk stores are NOT created here (this crate has no
603 /// filesystem): the transport creates them through `ServerStores`, and it
604 /// is the transport that makes the pair transactional — a store that
605 /// cannot be made rolls this row back, see `ServerStores::create_repo`.
606 ///
607 /// # Races
608 ///
609 /// Two admins pushing the same new name race on the catalog's
610 /// compare-and-swap. The loser of either CAS finds the thing it was
611 /// creating already there and proceeds — the repository exists, which is
612 /// what it wanted. `Err` is only ever a storage fault, and the caller
613 /// must report it as one ([`crate::server::DispatchError::Store`]), never
614 /// as not-found.
615 ///
616 /// Returns the repository and whether **this call** conjured the
617 /// namespace, so a caller that fails afterwards can take the namespace
618 /// back with the row and leave no trace at all.
619 pub fn admit_push_create(
620 &self,
621 principal: &Principal,
622 path: &RepoRef,
623 ) -> Result<Option<(Repo, bool)>, gunnar_core::Error> {
624 let Some(spec) = self.push_create_spec(principal, path)? else {
625 return Ok(None);
626 };
627 let namespace = path.namespace();
628 let mut conjured = false;
629 if self.catalog.namespace(namespace)?.is_none() {
630 match self.catalog.create_namespace(namespace) {
631 Ok(_) => conjured = true,
632 // Lost a race; it exists now, which is all that was wanted.
633 Err(gunnar_core::Error::NamespaceExists(_)) => {}
634 Err(err) => return Err(err),
635 }
636 }
637 let repo = match self.catalog.create(spec) {
638 Ok(repo) => repo,
639 // The other pusher won; serve the repository it created.
640 Err(gunnar_core::Error::RepoExists(_)) => self.catalog.open(path)?,
641 Err(err) => return Err(err),
642 };
643 self.account_event(
644 principal,
645 path.namespace(),
646 "repo.created",
647 &path.to_string(),
648 if conjured {
649 "by its first push (the namespace with it)"
650 } else {
651 "by its first push"
652 },
653 false,
654 );
655 tracing::warn!(
656 target: "gunnar::audit",
657 event = "repo.created_on_first_push",
658 principal = %principal.id,
659 repo = %path,
660 namespace_conjured = conjured,
661 "repository created by its first push; the creator's grant, not a new rule, \
662 is what authorised it"
663 );
664 Ok(Some((repo, conjured)))
665 }
666
667 /// Undo [`Self::admit_push_create`] after the store could not be made:
668 /// the row goes, and the namespace too if that call conjured it. Nothing
669 /// is left for `repo get` to find.
670 pub fn retract_push_create(
671 &self,
672 path: &RepoRef,
673 namespace_conjured: bool,
674 ) -> Result<(), gunnar_core::Error> {
675 match self.catalog.delete(path) {
676 Ok(_) | Err(gunnar_core::Error::NoSuchRepo(_)) => {}
677 Err(err) => return Err(err),
678 }
679 if namespace_conjured {
680 match self.catalog.delete_namespace(path.namespace(), false) {
681 Ok(_) | Err(gunnar_core::Error::NoSuchNamespace(_)) => {}
682 Err(err) => return Err(err),
683 }
684 }
685 Ok(())
686 }
687}
688
689impl std::fmt::Debug for Control {
690 /// By hand rather than derived, because the gates are `dyn` trait objects
691 /// with no `Debug` of their own — and what an operator wants to see of them
692 /// is which are wired and in what order, not their insides.
693 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
694 let gates: Vec<&'static str> = self.plan.iter().map(|g| g.name()).collect();
695 f.debug_struct("Control")
696 .field("auth", &self.auth)
697 .field("catalog", &self.catalog)
698 .field("writes_frozen", &self.writes_frozen())
699 .field("browse", &self.is_browse())
700 .field("anonymous", &self.anonymous)
701 .field("gates", &gates)
702 .finish()
703 }
704}