gunnar_core/model.rs
1//! The ACL model: principals, keys, namespaces, repositories, grants.
2//!
3//! Everything here is plain data with `serde` on it, because it is stored as a
4//! JSON document in the object store (see [`crate::doc`]) and handed to gRPC
5//! and to the SSH layer unchanged. No behaviour lives on these types beyond
6//! validation and the level ordering that the ACL is built on.
7
8use std::fmt;
9
10use crate::error::{Error, Result};
11use crate::sandbox::{check_component, RepoRef};
12
13/// Milliseconds since the unix epoch. Signed, to match what gRPC carries.
14pub type UnixMillis = i64;
15
16/// The default branch a repository gets when none is named.
17///
18/// Re-exported, not declared: `gunnar_store` writes a fresh repository's `HEAD`
19/// out of the same constant, and two crates each holding their own copy of this
20/// name is what `point_head_at` exists to stop repeating.
21pub use gunnar_store::DEFAULT_BRANCH;
22
23/// Wall-clock now, in milliseconds since the unix epoch.
24///
25/// Clamped at zero rather than panicking on a clock before 1970. An appliance
26/// with no RTC boots at the epoch, and refusing to serve because the clock is
27/// wrong would be the wrong failure.
28pub fn now_ms() -> UnixMillis {
29 std::time::SystemTime::now()
30 .duration_since(std::time::UNIX_EPOCH)
31 .map(|d| d.as_millis() as UnixMillis)
32 .unwrap_or(0)
33}
34
35/// What kind of thing an identity is.
36///
37/// The distinction is not decorative: a `Deploy` key is expected to be
38/// read-only and short-lived, and a UI that cannot tell a human from a CI
39/// runner cannot show a useful key list.
40#[derive(
41 Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Debug, serde::Serialize, serde::Deserialize,
42)]
43pub enum PrincipalKind {
44 /// A person.
45 User,
46 /// A machine account: CI, a bot, another service.
47 Machine,
48 /// A deploy key: one repository, usually read-only.
49 Deploy,
50 /// **A group of principals** (punkt 9).
51 ///
52 /// A group is a principal like any other — it has an id, it can be the
53 /// grantee of a [`Grant`] at any scope and level, and `grant`/`revoke`/`may`
54 /// treat it no differently from a user (one implementation, no new grant type). What is
55 /// special is only that it *holds no keys* and is *never authenticated as*:
56 /// a group is a name that grants attach to, and the members of the group
57 /// inherit those grants. Membership is an edge, tracked beside the identity
58 /// table (`AuthTable::add_to_group`), not a field on the principal, so the
59 /// same principal record serves a group and a user.
60 Group,
61 /// **Nobody.** The caller presented no key at all.
62 ///
63 /// There is exactly one principal of this kind, [`Principal::anonymous`].
64 /// It is never stored in the identity table, it can never hold a key and it
65 /// can never hold a grant. It exists so that "a stranger clones a public
66 /// repository" is answered by [`AuthTable::may`](crate::AuthTable::may)
67 /// like every other access question, rather than by a second code path
68 /// that could disagree with the first (one implementation).
69 Anonymous,
70}
71
72/// Someone, or something, that can authenticate.
73///
74/// **There are no OS users.** The server has no `/etc/passwd`, no home
75/// directories and no `authorized_keys`; this table is the entire identity
76/// system (the ACL model).
77#[derive(Clone, PartialEq, Eq, Debug, serde::Serialize, serde::Deserialize)]
78pub struct Principal {
79 /// Stable identifier, used as the key everywhere else. Same character
80 /// rules as a namespace, so it can never be mistaken for a path.
81 ///
82 /// **It is also the account**: the namespace this principal intrinsically
83 /// owns ([`AuthTable::owns_account`](crate::AuthTable::owns_account)), the
84 /// key a gate is filed under, and what a policy file's
85 /// `allowed_principals` names. Chosen once, at creation, and never moved.
86 /// Everything that may change about a principal changes in `name`.
87 pub id: String,
88 /// Display name, for humans and for UIs.
89 ///
90 /// **Not the identity.** It defaults to the id and it may be changed
91 /// ([`AuthTable::rename_principal`](crate::AuthTable::rename_principal))
92 /// without touching a key, a grant, a membership, a gate or the namespace.
93 /// It is unique on the table by construction — see the `names` index in
94 /// [`AuthTable`](crate::AuthTable) — and may not equal another principal's
95 /// id either, so a listing never shows two rows a human reads as one.
96 pub name: String,
97 /// What kind of identity this is.
98 pub kind: PrincipalKind,
99 /// When the row was created, by the table's clock, in unix milliseconds.
100 ///
101 /// Stamped by [`AuthTable::add_principal`](crate::AuthTable::add_principal)
102 /// — one writer — and `0` for a row from before the field existed, which is
103 /// what the wire reported for every principal until then. A caller that
104 /// pins a principal by `(id, created)` can tell a delete-and-recreate under
105 /// the same id from the row it first saw; the id alone cannot say, because
106 /// an account slug is claimable again once its account is purged.
107 #[serde(default)]
108 pub created: UnixMillis,
109}
110
111/// The id of the one [`PrincipalKind::Anonymous`] principal.
112///
113/// **Reserved.** [`Principal::new`] and
114/// [`AuthTable::add_principal`](crate::AuthTable::add_principal) both refuse
115/// it, so no registered principal can ever be spelled this way and no public
116/// key can ever be registered to it. That is what makes the rule in
117/// [`AuthTable::may`](crate::AuthTable::may) a statement about a name that
118/// cannot be claimed: *this id means "nobody", and nobody may read a public
119/// repository and nothing else*.
120pub const ANONYMOUS_PRINCIPAL_ID: &str = "anonymous";
121
122impl Principal {
123 /// Validate an id and build a principal.
124 ///
125 /// Refuses [`ANONYMOUS_PRINCIPAL_ID`]: the anonymous identity is not
126 /// something an operator registers, and a registered principal wearing that
127 /// name would be handed the public-read rule of
128 /// [`AuthTable::may`](crate::AuthTable::may) for free.
129 pub fn new(
130 id: impl Into<String>,
131 name: impl Into<String>,
132 kind: PrincipalKind,
133 ) -> Result<Self> {
134 let id = id.into();
135 check_component(&id, &id)?;
136 if id == ANONYMOUS_PRINCIPAL_ID {
137 return Err(Error::ReservedPrincipalId(id));
138 }
139 Ok(Principal {
140 id,
141 name: name.into(),
142 kind,
143 // The table stamps it on insert; a value chosen here would be the
144 // caller's clock, and two clocks for one field is the twin.
145 created: 0,
146 })
147 }
148
149 /// **Nobody.** The principal an unauthenticated caller is served as.
150 ///
151 /// Built directly rather than through [`Principal::new`], which refuses the
152 /// reserved id. There is no other constructor for it and no way to store
153 /// one, so every value of this shape in the process came from here.
154 pub fn anonymous() -> Self {
155 Principal {
156 id: ANONYMOUS_PRINCIPAL_ID.to_owned(),
157 name: "anonymous".to_owned(),
158 kind: PrincipalKind::Anonymous,
159 created: 0,
160 }
161 }
162
163 /// Is this the anonymous principal?
164 ///
165 /// **By id, not by `kind`.** The id is what
166 /// [`AuthTable::may`](crate::AuthTable::may) is keyed on and what a grant
167 /// would name; a `kind` that disagreed with the id would be the twinning
168 /// this whole design avoids.
169 pub fn is_anonymous(&self) -> bool {
170 self.id == ANONYMOUS_PRINCIPAL_ID
171 }
172}
173
174/// A registered public key.
175///
176/// The fingerprint is the primary key, because that is what an SSH handshake
177/// gives the server: russh hands over the offered key, gunnar fingerprints it
178/// and looks it up. The SSH *username* is decorative (the ACL model).
179#[derive(Clone, PartialEq, Eq, Debug, serde::Serialize, serde::Deserialize)]
180pub struct PublicKey {
181 /// OpenSSH's `SHA256:<base64>` fingerprint.
182 pub fingerprint_sha256: String,
183 /// The algorithm name as OpenSSH writes it: `ssh-ed25519`, `ssh-rsa`, …
184 pub algo: String,
185 /// Which principal this key authenticates.
186 pub principal_id: String,
187 /// When it was registered.
188 pub added: UnixMillis,
189 /// When it last authenticated, if ever.
190 pub last_used: Option<UnixMillis>,
191 /// When it stops being accepted, if ever.
192 pub expires: Option<UnixMillis>,
193 /// The `authorized_keys`-style line it was registered from.
194 ///
195 /// Kept because `gunnar-policy` needs the key material itself to verify
196 /// `gpg.format=ssh` commit signatures (the pre-receive gate), and a fingerprint cannot
197 /// verify a signature.
198 pub openssh: String,
199}
200
201impl PublicKey {
202 /// Is this key still valid at `now`?
203 pub fn is_valid_at(&self, now: UnixMillis) -> bool {
204 match self.expires {
205 None => true,
206 Some(expires) => now < expires,
207 }
208 }
209}
210
211/// **A web credential — a person's WebAuthn passkey** (Stage L, 2026-10-04).
212///
213/// A row in the identity table beside the person's SSH keys, so it lives and
214/// dies with the principal, rides the same compare-and-swap, and reaches the
215/// twin with the rest of `refs/gunnar/auth`. gunnar verifies a login's
216/// assertion against `public_key_hex` itself: the console relays the browser's
217/// answer and holds no credential of its own.
218#[derive(Clone, PartialEq, Eq, Debug, serde::Serialize, serde::Deserialize)]
219#[serde(deny_unknown_fields)]
220pub struct WebCredential {
221 /// The authenticator's raw credential id, base64url without padding — the
222 /// map key, and what a login names.
223 pub id: String,
224 /// The principal it logs in as. Only a USER may hold one.
225 pub principal_id: String,
226 /// What the person called it.
227 pub label: String,
228 /// The P-256 public key, SEC1 uncompressed (`04 || X || Y`), lowercase hex.
229 pub public_key_hex: String,
230 /// The authenticator's signature counter as of the last accepted login.
231 pub sign_count: u32,
232 /// When it was registered.
233 pub created: UnixMillis,
234 /// When it last logged in, if ever.
235 #[serde(default)]
236 pub last_used: Option<UnixMillis>,
237 /// **The session keys its logins enrolled** (fingerprints), so revoking
238 /// the passkey ends the sessions it opened. Pruned at each login to the
239 /// keys still held and unexpired.
240 #[serde(default)]
241 pub sessions: Vec<String>,
242}
243
244/// **The operator's first invite, as the identity table keeps it** (Stage L,
245/// 2026-10-04; IDENTITY-HOST 2026-10-27): the installer's break-glass token by
246/// its hash only, for the operator's first passkey. Invites are the identity
247/// core's (`nornir-identity`); this row is the one the core is FED (it reads it
248/// as `sha256:<hex>` onto the operator and spends it there). The shape is the
249/// table's from before the move, so a table written then still reads.
250#[derive(Clone, PartialEq, Eq, Debug, serde::Serialize, serde::Deserialize)]
251#[serde(deny_unknown_fields)]
252pub struct FirstInviteRow {
253 /// The invite's public id — what a listing and a revocation name.
254 pub id: String,
255 /// The person whose passkey it enrols.
256 pub principal_id: String,
257 /// Lowercase hex SHA-256 of the secret. Never the secret.
258 pub secret_hash: String,
259 /// Who created it.
260 pub created_by: String,
261 /// When.
262 pub created: UnixMillis,
263 /// When it stops being redeemable.
264 pub expires: UnixMillis,
265 /// Free text from the inviter.
266 #[serde(default)]
267 pub note: String,
268 /// The server-wide grant a redemption applies (the console's operator or
269 /// admin), if any. Only a server admin's invite carries one.
270 #[serde(default)]
271 pub server_level: Option<Level>,
272}
273
274/// A registered **access token**: the credential smart HTTP authenticates with
275///
276///
277/// # Why this is not a second identity model
278///
279/// It is a second *authentication* method for the identity model that already
280/// exists. A token names a [`Principal`] and nothing else. It carries no grant,
281/// no level and no scope, so every authorisation question it can produce is
282/// still answered by [`AuthTable::may`](crate::AuthTable::may) against that
283/// principal's grants — the same call, on the same table, that an SSH key
284/// produces. Revoking the token removes a way in; it changes nobody's access.
285///
286/// # The secret is not here, and cannot be
287///
288/// [`Self::hash`] is the SHA-256 of the token secret, hex, and it is also the
289/// key this record is stored under. That is deliberate on both counts:
290///
291/// * the stored table never holds a usable credential, so a leaked auth
292/// document (it is a git blob, and every version of it is kept — see
293/// [`doc`](crate::doc)) does not hand anybody a login;
294/// * a lookup is *hash the presented secret, probe once*, which is O(1) and
295/// does not walk the token list. A table keyed by anything else would either
296/// scan on every HTTP request or need a second index to avoid it.
297///
298/// The hash is therefore also the token's **public id**: it is what
299/// `gunnar admin token list` prints and what revocation names. Printing a hash
300/// is safe; printing the secret again is not possible, which is the point.
301#[derive(Clone, PartialEq, Eq, Debug, serde::Serialize, serde::Deserialize)]
302pub struct AccessToken {
303 /// SHA-256 of the secret, lowercase hex. The primary key, and the id an
304 /// operator sees.
305 pub hash: String,
306 /// Which principal this token authenticates.
307 pub principal_id: String,
308 /// An operator-supplied label: *which laptop, which CI job*. Never part of
309 /// the credential.
310 pub label: String,
311 /// When it was created.
312 pub created: UnixMillis,
313 /// When it last authenticated, if ever.
314 pub last_used: Option<UnixMillis>,
315 /// When it stops being accepted, if ever.
316 pub expires: Option<UnixMillis>,
317}
318
319impl AccessToken {
320 /// Is this token still valid at `now`?
321 ///
322 /// Same shape as [`PublicKey::is_valid_at`] and deliberately so: expiry is
323 /// one rule, and two credentials that expired by different arithmetic would
324 /// be two rules (one implementation).
325 pub fn is_valid_at(&self, now: UnixMillis) -> bool {
326 match self.expires {
327 None => true,
328 Some(expires) => now < expires,
329 }
330 }
331}
332
333/// A tenant. First-class from day one (multi-tenancy), because adding it later would
334/// mean migrating every stored path and every grant.
335#[derive(Clone, PartialEq, Eq, Debug, serde::Serialize, serde::Deserialize)]
336pub struct Namespace {
337 /// The name, possibly nested (`team/sub`).
338 pub name: String,
339 /// **The exploded read cache this account's repositories may hold**, in
340 /// bytes — the quota, and the one number a plan is allowed to move.
341 ///
342 /// `None` is *"take the server's default"*, which is
343 /// `min(10 % of /data, 8 GiB)` per repository. `Some(0)` is unlimited and
344 /// is a deliberate answer, not an absent one — which is why this is an
345 /// `Option` and not a `u64` with a magic value.
346 ///
347 /// What it buys, and what it does not: a bigger cache means more objects
348 /// are already inflated, so reads are FAST; a smaller one means more reads
349 /// re-inflate their own delta chain out of the pack (~0.5–2 ms an object),
350 /// which is SLOW. It is never a refusal and never an error — an account at
351 /// its budget serves every object it has, correctly, more slowly. Nothing
352 /// in this field can lose data: every byte it bounds is re-derivable from
353 /// the packs, which are not bounded by it.
354 ///
355 /// `serde(default)` so a control document written before the quota field loads
356 /// unchanged and every existing namespace reads as "the default".
357 #[serde(default)]
358 pub explode_budget_bytes: Option<u64>,
359 /// **The namespace is public** — an explicit setting of the account admin's
360 /// (owner, 2026-10-03: "default for all accounts is that emitted events and
361 /// namespaces are not public, such things has to be set").
362 ///
363 /// Private (`false`, the default) is what every namespace is until its
364 /// account admin says otherwise: somebody outside the account learns of it
365 /// only through a public repository in it, and a private namespace holding
366 /// no public repository answers exactly as one that does not exist. Public:
367 /// the namespace itself is shown to everyone, even while it holds no public
368 /// repository. Either way a PRIVATE repository in it is never shown to an
369 /// outsider — that is the repository's own visibility.
370 ///
371 /// Skipped when `false`, so a default namespace's stored row is byte for
372 /// byte what it was before the field existed, and every older row reads as
373 /// private.
374 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
375 pub public: bool,
376 /// **The account's emitted events are public** — the account admin's
377 /// explicit setting, `false` by default: until it is set, the events of an
378 /// account are shown to nobody outside it. Skipped when `false`, as
379 /// [`Namespace::public`].
380 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
381 pub events_public: bool,
382}
383
384/// Which hash a repository's objects are named by.
385///
386/// gunnar's own enum rather than [`gix_hash::Kind`] so the wire and storage
387/// representations do not depend on a gix version's `serde` feature. sha256 is
388/// the default for new repositories; sha1 stays fully
389/// supported.
390#[derive(
391 Clone,
392 Copy,
393 PartialEq,
394 Eq,
395 PartialOrd,
396 Ord,
397 Hash,
398 Debug,
399 Default,
400 serde::Serialize,
401 serde::Deserialize,
402)]
403pub enum HashKind {
404 /// Git's original hash.
405 Sha1,
406 /// The default for new repositories.
407 #[default]
408 Sha256,
409}
410
411impl From<HashKind> for gix_hash::Kind {
412 fn from(kind: HashKind) -> Self {
413 match kind {
414 HashKind::Sha1 => gix_hash::Kind::Sha1,
415 HashKind::Sha256 => gix_hash::Kind::Sha256,
416 }
417 }
418}
419
420/// `gix_hash::Kind` is `#[non_exhaustive]`, so the conversion back is fallible
421/// by construction: a gix release that adds a third hash must not silently be
422/// stored as one of gunnar's two.
423impl TryFrom<gix_hash::Kind> for HashKind {
424 type Error = Error;
425 fn try_from(kind: gix_hash::Kind) -> Result<Self> {
426 match kind {
427 gix_hash::Kind::Sha1 => Ok(HashKind::Sha1),
428 gix_hash::Kind::Sha256 => Ok(HashKind::Sha256),
429 other => Err(Error::Other(anyhow::anyhow!(
430 "gunnar does not know the hash kind {other}"
431 ))),
432 }
433 }
434}
435
436impl std::fmt::Display for HashKind {
437 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
438 f.write_str(match self {
439 HashKind::Sha1 => "sha1",
440 HashKind::Sha256 => "sha256",
441 })
442 }
443}
444
445/// Who may read a repository without an explicit grant.
446///
447/// > **⚠ The meaning of [`Visibility::Public`] changed on 2026-08-03.**
448/// > It used to mean "any *authenticated* principal may read", because gunnar
449/// > had no unauthenticated path at all and the field therefore honoured
450/// > nothing a private repository did not already get from a grant. It now
451/// > means what the word says: **readable by anyone, with no key**. An operator
452/// > who marked a repository public under the old reading has published it
453/// > under the new one. [`Visibility::Private`] is still the default and is
454/// > unchanged, and an appliance that must never serve a stranger turns the
455/// > anonymous path off at the listener
456/// > (`SshConfig::without_anonymous_access`).
457#[derive(
458 Clone,
459 Copy,
460 PartialEq,
461 Eq,
462 PartialOrd,
463 Ord,
464 Hash,
465 Debug,
466 Default,
467 serde::Serialize,
468 serde::Deserialize,
469)]
470pub enum Visibility {
471 /// Only principals with a grant. The default, and the only safe one.
472 ///
473 /// A private repository is **indistinguishable from one that does not
474 /// exist** to a caller who may not read it. The SSH dispatch answers both
475 /// with the same string, so a stranger cannot enumerate private
476 /// repositories one guess at a time.
477 #[default]
478 Private,
479 /// Readable by **anyone**, including a caller who presented no key at all
480 /// That is what [`PrincipalKind::Anonymous`] is for.
481 ///
482 /// Read only, and only `git-upload-pack`. Writing still needs a grant, so
483 /// [`Level::Write`] and [`Level::Admin`] are unaffected by this field.
484 Public,
485}
486
487/// A repository, as the catalog holds it.
488#[derive(Clone, PartialEq, Eq, Debug, serde::Serialize, serde::Deserialize)]
489pub struct Repo {
490 /// The namespace it lives in, possibly nested.
491 pub namespace: String,
492 /// Its name, without any `.git` suffix.
493 pub name: String,
494 /// sha1 or sha256. Fixed at creation; a repository never changes hash.
495 pub hash_kind: HashKind,
496 /// Full ref name, e.g. `refs/heads/main`.
497 pub default_branch: String,
498 /// Who may read it without a grant.
499 pub visibility: Visibility,
500 /// When it was created.
501 pub created: UnixMillis,
502 /// Free-text description, for UIs.
503 pub description: String,
504 /// **Encrypted repository** (2026-10-05): the server refuses any push
505 /// that would put plaintext on it — every new blob must be a
506 /// gunnar-crypt envelope (the `encrypted` push gate). The server holds no
507 /// key either way. Only the ACCOUNT admin sets it. Skipped when `false`, so
508 /// every older row reads as not encrypted and is stored byte for byte as
509 /// before.
510 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
511 pub encrypted: bool,
512}
513
514impl Repo {
515 /// Its path, as the sandbox produces one.
516 pub fn path(&self) -> RepoRef {
517 RepoRef::new(&self.namespace, &self.name)
518 .expect("a catalogued repo was built from a validated RepoRef")
519 }
520
521 /// Its catalog key, `<namespace>/<repo>`.
522 pub fn key(&self) -> String {
523 format!("{}/{}", self.namespace, self.name)
524 }
525}
526
527/// The same string [`Repo::key`] returns, written straight into the formatter.
528///
529/// `key()` allocates; a `repo = %repo` span or log field does not. Both spell
530/// `<namespace>/<repo>` and there is one implementation of that shape here, so
531/// a log line and a catalog lookup can never disagree about what a repository
532/// is called (one implementation). `RepoRef` already had exactly this pair.
533impl fmt::Display for Repo {
534 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
535 write!(f, "{}/{}", self.namespace, self.name)
536 }
537}
538
539/// How much access a grant confers. Ordered: `Read < Write < Admin`, and the
540/// ACL compares with `>=`, so an admin can push and a writer can fetch.
541#[derive(
542 Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Debug, serde::Serialize, serde::Deserialize,
543)]
544pub enum Level {
545 /// `git-upload-pack`: clone and fetch.
546 Read,
547 /// `git-receive-pack`: push. Implies [`Level::Read`].
548 Write,
549 /// Administer the repository, namespace or server. Implies
550 /// [`Level::Write`].
551 Admin,
552}
553
554impl Level {
555 /// Does holding `self` satisfy a requirement of `needed`?
556 ///
557 /// The ordering is the whole point, and it is why the SSH dispatch compares
558 /// levels instead of matching on which verb it got: adding a third verb
559 /// (LFS over SSH) then cannot forget a case. `gunnar-ssh` had its own copy of
560 /// this enum and this method; it now uses this one.
561 pub fn permits(self, needed: Level) -> bool {
562 self >= needed
563 }
564}
565
566impl std::fmt::Display for Level {
567 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
568 f.write_str(match self {
569 Level::Read => "read",
570 Level::Write => "write",
571 Level::Admin => "admin",
572 })
573 }
574}
575
576/// What a grant applies to.
577///
578/// # Deviation from the brief, recorded
579///
580/// The brief writes `scope: Namespace|Repo`. [`Scope::Server`] is added,
581/// because without it the bootstrap admin of the secret-store seam has nothing it is allowed to
582/// do: it cannot create the first namespace, so a freshly bootstrapped server
583/// would be reachable and useless. A strict superset: no namespace-scoped or
584/// repo-scoped behaviour changes.
585#[derive(
586 Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Debug, serde::Serialize, serde::Deserialize,
587)]
588pub enum Scope {
589 /// The whole server. Only the bootstrap admin and whoever it appoints.
590 Server,
591 /// One namespace, matched **exactly**: a grant on `team` does *not* reach
592 /// `team/sub`. Nested namespaces are separate tenants, and inheritance
593 /// would be a privilege escalation that nobody wrote down.
594 Namespace(String),
595 /// One repository.
596 Repo(RepoRef),
597}
598
599impl Scope {
600 /// Does this scope cover `repo`?
601 pub fn covers(&self, repo: &Repo) -> bool {
602 match self {
603 Scope::Server => true,
604 Scope::Namespace(name) => name == &repo.namespace,
605 Scope::Repo(path) => path.namespace() == repo.namespace && path.name() == repo.name,
606 }
607 }
608
609 /// Does this scope cover the namespace `name`?
610 pub fn covers_namespace(&self, name: &str) -> bool {
611 match self {
612 Scope::Server => true,
613 Scope::Namespace(scoped) => scoped == name,
614 Scope::Repo(_) => false,
615 }
616 }
617}
618
619impl std::fmt::Display for Scope {
620 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
621 match self {
622 Scope::Server => f.write_str("server"),
623 Scope::Namespace(name) => write!(f, "namespace {name}"),
624 Scope::Repo(path) => write!(f, "repo {path}"),
625 }
626 }
627}
628
629/// One row of the ACL: this principal has this much access to this scope.
630#[derive(
631 Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Debug, serde::Serialize, serde::Deserialize,
632)]
633pub struct Grant {
634 /// Which principal, by [`Principal::id`].
635 pub principal: String,
636 /// What it applies to.
637 pub scope: Scope,
638 /// How much.
639 pub level: Level,
640}
641
642impl Grant {
643 /// A grant on one repository.
644 pub fn repo(principal: impl Into<String>, path: RepoRef, level: Level) -> Self {
645 Grant {
646 principal: principal.into(),
647 scope: Scope::Repo(path),
648 level,
649 }
650 }
651
652 /// A grant on a whole namespace.
653 pub fn namespace(
654 principal: impl Into<String>,
655 namespace: impl Into<String>,
656 level: Level,
657 ) -> Self {
658 Grant {
659 principal: principal.into(),
660 scope: Scope::Namespace(namespace.into()),
661 level,
662 }
663 }
664
665 /// A grant on the whole server.
666 pub fn server(principal: impl Into<String>, level: Level) -> Self {
667 Grant {
668 principal: principal.into(),
669 scope: Scope::Server,
670 level,
671 }
672 }
673}
674
675/// Check that `name` is a legal full ref name under `refs/heads/`.
676///
677/// Reuses `gunnar_store`'s validator rather than growing a second opinion about
678/// what a ref name is (one implementation). That one already rejects `..`, control
679/// characters and empty components.
680///
681/// It used to be `FullName::new`, which validated *and* produced a value type.
682/// `FullName` went with `RefStore`; the rules did not — they are a property of
683/// git, not of a backend, and one of them (`refs/heads/../../etc/passwd`) is a
684/// path-traversal boundary. They live in `gunnar_store::refname` now, as a
685/// free function over `&str`, which is the shape `GitOps` speaks.
686pub(crate) fn check_branch(name: &str) -> Result<String> {
687 if !name.starts_with("refs/heads/") {
688 return Err(Error::BadBranchName(format!(
689 "{name:?}: a default branch must be a full name under refs/heads/"
690 )));
691 }
692 gunnar_store::refname::validate(name)
693 .map_err(|err| Error::BadBranchName(format!("{name:?}: {err}")))?;
694 Ok(name.to_owned())
695}