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/// **An invite: a pending passkey enrolment on an existing person** (Stage L,
245/// 2026-10-04). Its secret is shown once, to whoever created it; this row keeps
246/// only the hash. Redeemed once (it is removed in the same update that stores
247/// the passkey), and never after `expires`.
248#[derive(Clone, PartialEq, Eq, Debug, serde::Serialize, serde::Deserialize)]
249#[serde(deny_unknown_fields)]
250pub struct Invite {
251 /// The invite's public id — what a listing and a revocation name.
252 pub id: String,
253 /// The person whose passkey it enrols.
254 pub principal_id: String,
255 /// Lowercase hex SHA-256 of the secret. Never the secret.
256 pub secret_hash: String,
257 /// Who created it.
258 pub created_by: String,
259 /// When.
260 pub created: UnixMillis,
261 /// When it stops being redeemable.
262 pub expires: UnixMillis,
263 /// Free text from the inviter.
264 #[serde(default)]
265 pub note: String,
266 /// The server-wide grant a redemption applies (the console's operator or
267 /// admin), if any. Only a server admin's invite carries one.
268 #[serde(default)]
269 pub server_level: Option<Level>,
270}
271
272/// A registered **access token**: the credential smart HTTP authenticates with
273///
274///
275/// # Why this is not a second identity model
276///
277/// It is a second *authentication* method for the identity model that already
278/// exists. A token names a [`Principal`] and nothing else. It carries no grant,
279/// no level and no scope, so every authorisation question it can produce is
280/// still answered by [`AuthTable::may`](crate::AuthTable::may) against that
281/// principal's grants — the same call, on the same table, that an SSH key
282/// produces. Revoking the token removes a way in; it changes nobody's access.
283///
284/// # The secret is not here, and cannot be
285///
286/// [`Self::hash`] is the SHA-256 of the token secret, hex, and it is also the
287/// key this record is stored under. That is deliberate on both counts:
288///
289/// * the stored table never holds a usable credential, so a leaked auth
290/// document (it is a git blob, and every version of it is kept — see
291/// [`doc`](crate::doc)) does not hand anybody a login;
292/// * a lookup is *hash the presented secret, probe once*, which is O(1) and
293/// does not walk the token list. A table keyed by anything else would either
294/// scan on every HTTP request or need a second index to avoid it.
295///
296/// The hash is therefore also the token's **public id**: it is what
297/// `gunnar admin token list` prints and what revocation names. Printing a hash
298/// is safe; printing the secret again is not possible, which is the point.
299#[derive(Clone, PartialEq, Eq, Debug, serde::Serialize, serde::Deserialize)]
300pub struct AccessToken {
301 /// SHA-256 of the secret, lowercase hex. The primary key, and the id an
302 /// operator sees.
303 pub hash: String,
304 /// Which principal this token authenticates.
305 pub principal_id: String,
306 /// An operator-supplied label: *which laptop, which CI job*. Never part of
307 /// the credential.
308 pub label: String,
309 /// When it was created.
310 pub created: UnixMillis,
311 /// When it last authenticated, if ever.
312 pub last_used: Option<UnixMillis>,
313 /// When it stops being accepted, if ever.
314 pub expires: Option<UnixMillis>,
315}
316
317impl AccessToken {
318 /// Is this token still valid at `now`?
319 ///
320 /// Same shape as [`PublicKey::is_valid_at`] and deliberately so: expiry is
321 /// one rule, and two credentials that expired by different arithmetic would
322 /// be two rules (one implementation).
323 pub fn is_valid_at(&self, now: UnixMillis) -> bool {
324 match self.expires {
325 None => true,
326 Some(expires) => now < expires,
327 }
328 }
329}
330
331/// A tenant. First-class from day one (multi-tenancy), because adding it later would
332/// mean migrating every stored path and every grant.
333#[derive(Clone, PartialEq, Eq, Debug, serde::Serialize, serde::Deserialize)]
334pub struct Namespace {
335 /// The name, possibly nested (`team/sub`).
336 pub name: String,
337 /// **The exploded read cache this account's repositories may hold**, in
338 /// bytes — the quota, and the one number a plan is allowed to move.
339 ///
340 /// `None` is *"take the server's default"*, which is
341 /// `min(10 % of /data, 8 GiB)` per repository. `Some(0)` is unlimited and
342 /// is a deliberate answer, not an absent one — which is why this is an
343 /// `Option` and not a `u64` with a magic value.
344 ///
345 /// What it buys, and what it does not: a bigger cache means more objects
346 /// are already inflated, so reads are FAST; a smaller one means more reads
347 /// re-inflate their own delta chain out of the pack (~0.5–2 ms an object),
348 /// which is SLOW. It is never a refusal and never an error — an account at
349 /// its budget serves every object it has, correctly, more slowly. Nothing
350 /// in this field can lose data: every byte it bounds is re-derivable from
351 /// the packs, which are not bounded by it.
352 ///
353 /// `serde(default)` so a control document written before the quota field loads
354 /// unchanged and every existing namespace reads as "the default".
355 #[serde(default)]
356 pub explode_budget_bytes: Option<u64>,
357 /// **The namespace is public** — an explicit setting of the account admin's
358 /// (owner, 2026-10-03: "default for all accounts is that emitted events and
359 /// namespaces are not public, such things has to be set").
360 ///
361 /// Private (`false`, the default) is what every namespace is until its
362 /// account admin says otherwise: somebody outside the account learns of it
363 /// only through a public repository in it, and a private namespace holding
364 /// no public repository answers exactly as one that does not exist. Public:
365 /// the namespace itself is shown to everyone, even while it holds no public
366 /// repository. Either way a PRIVATE repository in it is never shown to an
367 /// outsider — that is the repository's own visibility.
368 ///
369 /// Skipped when `false`, so a default namespace's stored row is byte for
370 /// byte what it was before the field existed, and every older row reads as
371 /// private.
372 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
373 pub public: bool,
374 /// **The account's emitted events are public** — the account admin's
375 /// explicit setting, `false` by default: until it is set, the events of an
376 /// account are shown to nobody outside it. Skipped when `false`, as
377 /// [`Namespace::public`].
378 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
379 pub events_public: bool,
380}
381
382/// Which hash a repository's objects are named by.
383///
384/// gunnar's own enum rather than [`gix_hash::Kind`] so the wire and storage
385/// representations do not depend on a gix version's `serde` feature. sha256 is
386/// the default for new repositories; sha1 stays fully
387/// supported.
388#[derive(
389 Clone,
390 Copy,
391 PartialEq,
392 Eq,
393 PartialOrd,
394 Ord,
395 Hash,
396 Debug,
397 Default,
398 serde::Serialize,
399 serde::Deserialize,
400)]
401pub enum HashKind {
402 /// Git's original hash.
403 Sha1,
404 /// The default for new repositories.
405 #[default]
406 Sha256,
407}
408
409impl From<HashKind> for gix_hash::Kind {
410 fn from(kind: HashKind) -> Self {
411 match kind {
412 HashKind::Sha1 => gix_hash::Kind::Sha1,
413 HashKind::Sha256 => gix_hash::Kind::Sha256,
414 }
415 }
416}
417
418/// `gix_hash::Kind` is `#[non_exhaustive]`, so the conversion back is fallible
419/// by construction: a gix release that adds a third hash must not silently be
420/// stored as one of gunnar's two.
421impl TryFrom<gix_hash::Kind> for HashKind {
422 type Error = Error;
423 fn try_from(kind: gix_hash::Kind) -> Result<Self> {
424 match kind {
425 gix_hash::Kind::Sha1 => Ok(HashKind::Sha1),
426 gix_hash::Kind::Sha256 => Ok(HashKind::Sha256),
427 other => Err(Error::Other(anyhow::anyhow!(
428 "gunnar does not know the hash kind {other}"
429 ))),
430 }
431 }
432}
433
434impl std::fmt::Display for HashKind {
435 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
436 f.write_str(match self {
437 HashKind::Sha1 => "sha1",
438 HashKind::Sha256 => "sha256",
439 })
440 }
441}
442
443/// Who may read a repository without an explicit grant.
444///
445/// > **⚠ The meaning of [`Visibility::Public`] changed on 2026-08-03.**
446/// > It used to mean "any *authenticated* principal may read", because gunnar
447/// > had no unauthenticated path at all and the field therefore honoured
448/// > nothing a private repository did not already get from a grant. It now
449/// > means what the word says: **readable by anyone, with no key**. An operator
450/// > who marked a repository public under the old reading has published it
451/// > under the new one. [`Visibility::Private`] is still the default and is
452/// > unchanged, and an appliance that must never serve a stranger turns the
453/// > anonymous path off at the listener
454/// > (`SshConfig::without_anonymous_access`).
455#[derive(
456 Clone,
457 Copy,
458 PartialEq,
459 Eq,
460 PartialOrd,
461 Ord,
462 Hash,
463 Debug,
464 Default,
465 serde::Serialize,
466 serde::Deserialize,
467)]
468pub enum Visibility {
469 /// Only principals with a grant. The default, and the only safe one.
470 ///
471 /// A private repository is **indistinguishable from one that does not
472 /// exist** to a caller who may not read it. The SSH dispatch answers both
473 /// with the same string, so a stranger cannot enumerate private
474 /// repositories one guess at a time.
475 #[default]
476 Private,
477 /// Readable by **anyone**, including a caller who presented no key at all
478 /// That is what [`PrincipalKind::Anonymous`] is for.
479 ///
480 /// Read only, and only `git-upload-pack`. Writing still needs a grant, so
481 /// [`Level::Write`] and [`Level::Admin`] are unaffected by this field.
482 Public,
483}
484
485/// A repository, as the catalog holds it.
486#[derive(Clone, PartialEq, Eq, Debug, serde::Serialize, serde::Deserialize)]
487pub struct Repo {
488 /// The namespace it lives in, possibly nested.
489 pub namespace: String,
490 /// Its name, without any `.git` suffix.
491 pub name: String,
492 /// sha1 or sha256. Fixed at creation; a repository never changes hash.
493 pub hash_kind: HashKind,
494 /// Full ref name, e.g. `refs/heads/main`.
495 pub default_branch: String,
496 /// Who may read it without a grant.
497 pub visibility: Visibility,
498 /// When it was created.
499 pub created: UnixMillis,
500 /// Free-text description, for UIs.
501 pub description: String,
502 /// **Encrypted repository** (2026-10-05): the server refuses any push
503 /// that would put plaintext on it — every new blob must be a
504 /// gunnar-crypt envelope (the `encrypted` push gate). The server holds no
505 /// key either way. Only the ACCOUNT admin sets it. Skipped when `false`, so
506 /// every older row reads as not encrypted and is stored byte for byte as
507 /// before.
508 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
509 pub encrypted: bool,
510}
511
512impl Repo {
513 /// Its path, as the sandbox produces one.
514 pub fn path(&self) -> RepoRef {
515 RepoRef::new(&self.namespace, &self.name)
516 .expect("a catalogued repo was built from a validated RepoRef")
517 }
518
519 /// Its catalog key, `<namespace>/<repo>`.
520 pub fn key(&self) -> String {
521 format!("{}/{}", self.namespace, self.name)
522 }
523}
524
525/// The same string [`Repo::key`] returns, written straight into the formatter.
526///
527/// `key()` allocates; a `repo = %repo` span or log field does not. Both spell
528/// `<namespace>/<repo>` and there is one implementation of that shape here, so
529/// a log line and a catalog lookup can never disagree about what a repository
530/// is called (one implementation). `RepoRef` already had exactly this pair.
531impl fmt::Display for Repo {
532 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
533 write!(f, "{}/{}", self.namespace, self.name)
534 }
535}
536
537/// How much access a grant confers. Ordered: `Read < Write < Admin`, and the
538/// ACL compares with `>=`, so an admin can push and a writer can fetch.
539#[derive(
540 Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Debug, serde::Serialize, serde::Deserialize,
541)]
542pub enum Level {
543 /// `git-upload-pack`: clone and fetch.
544 Read,
545 /// `git-receive-pack`: push. Implies [`Level::Read`].
546 Write,
547 /// Administer the repository, namespace or server. Implies
548 /// [`Level::Write`].
549 Admin,
550}
551
552impl Level {
553 /// Does holding `self` satisfy a requirement of `needed`?
554 ///
555 /// The ordering is the whole point, and it is why the SSH dispatch compares
556 /// levels instead of matching on which verb it got: adding a third verb
557 /// (LFS over SSH) then cannot forget a case. `gunnar-ssh` had its own copy of
558 /// this enum and this method; it now uses this one.
559 pub fn permits(self, needed: Level) -> bool {
560 self >= needed
561 }
562}
563
564impl std::fmt::Display for Level {
565 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
566 f.write_str(match self {
567 Level::Read => "read",
568 Level::Write => "write",
569 Level::Admin => "admin",
570 })
571 }
572}
573
574/// What a grant applies to.
575///
576/// # Deviation from the brief, recorded
577///
578/// The brief writes `scope: Namespace|Repo`. [`Scope::Server`] is added,
579/// because without it the bootstrap admin of the secret-store seam has nothing it is allowed to
580/// do: it cannot create the first namespace, so a freshly bootstrapped server
581/// would be reachable and useless. A strict superset: no namespace-scoped or
582/// repo-scoped behaviour changes.
583#[derive(
584 Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Debug, serde::Serialize, serde::Deserialize,
585)]
586pub enum Scope {
587 /// The whole server. Only the bootstrap admin and whoever it appoints.
588 Server,
589 /// One namespace, matched **exactly**: a grant on `team` does *not* reach
590 /// `team/sub`. Nested namespaces are separate tenants, and inheritance
591 /// would be a privilege escalation that nobody wrote down.
592 Namespace(String),
593 /// One repository.
594 Repo(RepoRef),
595}
596
597impl Scope {
598 /// Does this scope cover `repo`?
599 pub fn covers(&self, repo: &Repo) -> bool {
600 match self {
601 Scope::Server => true,
602 Scope::Namespace(name) => name == &repo.namespace,
603 Scope::Repo(path) => path.namespace() == repo.namespace && path.name() == repo.name,
604 }
605 }
606
607 /// Does this scope cover the namespace `name`?
608 pub fn covers_namespace(&self, name: &str) -> bool {
609 match self {
610 Scope::Server => true,
611 Scope::Namespace(scoped) => scoped == name,
612 Scope::Repo(_) => false,
613 }
614 }
615}
616
617impl std::fmt::Display for Scope {
618 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
619 match self {
620 Scope::Server => f.write_str("server"),
621 Scope::Namespace(name) => write!(f, "namespace {name}"),
622 Scope::Repo(path) => write!(f, "repo {path}"),
623 }
624 }
625}
626
627/// One row of the ACL: this principal has this much access to this scope.
628#[derive(
629 Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Debug, serde::Serialize, serde::Deserialize,
630)]
631pub struct Grant {
632 /// Which principal, by [`Principal::id`].
633 pub principal: String,
634 /// What it applies to.
635 pub scope: Scope,
636 /// How much.
637 pub level: Level,
638}
639
640impl Grant {
641 /// A grant on one repository.
642 pub fn repo(principal: impl Into<String>, path: RepoRef, level: Level) -> Self {
643 Grant {
644 principal: principal.into(),
645 scope: Scope::Repo(path),
646 level,
647 }
648 }
649
650 /// A grant on a whole namespace.
651 pub fn namespace(
652 principal: impl Into<String>,
653 namespace: impl Into<String>,
654 level: Level,
655 ) -> Self {
656 Grant {
657 principal: principal.into(),
658 scope: Scope::Namespace(namespace.into()),
659 level,
660 }
661 }
662
663 /// A grant on the whole server.
664 pub fn server(principal: impl Into<String>, level: Level) -> Self {
665 Grant {
666 principal: principal.into(),
667 scope: Scope::Server,
668 level,
669 }
670 }
671}
672
673/// Check that `name` is a legal full ref name under `refs/heads/`.
674///
675/// Reuses `gunnar_store`'s validator rather than growing a second opinion about
676/// what a ref name is (one implementation). That one already rejects `..`, control
677/// characters and empty components.
678///
679/// It used to be `FullName::new`, which validated *and* produced a value type.
680/// `FullName` went with `RefStore`; the rules did not — they are a property of
681/// git, not of a backend, and one of them (`refs/heads/../../etc/passwd`) is a
682/// path-traversal boundary. They live in `gunnar_store::refname` now, as a
683/// free function over `&str`, which is the shape `GitOps` speaks.
684pub(crate) fn check_branch(name: &str) -> Result<String> {
685 if !name.starts_with("refs/heads/") {
686 return Err(Error::BadBranchName(format!(
687 "{name:?}: a default branch must be a full name under refs/heads/"
688 )));
689 }
690 gunnar_store::refname::validate(name)
691 .map_err(|err| Error::BadBranchName(format!("{name:?}: {err}")))?;
692 Ok(name.to_owned())
693}