Skip to main content

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}