Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/gunnar

crates/gunnar-core/src/model.rs

//! The ACL model: principals, keys, namespaces, repositories, grants.
//!
//! Everything here is plain data with `serde` on it, because it is stored as a
//! JSON document in the object store (see [`crate::doc`]) and handed to gRPC
//! and to the SSH layer unchanged. No behaviour lives on these types beyond
//! validation and the level ordering that the ACL is built on.

use std::fmt;

use crate::error::{Error, Result};
use crate::sandbox::{check_component, RepoRef};

/// Milliseconds since the unix epoch. Signed, to match what gRPC carries.
pub type UnixMillis = i64;

/// The default branch a repository gets when none is named.
///
/// Re-exported, not declared: `gunnar_store` writes a fresh repository's `HEAD`
/// out of the same constant, and two crates each holding their own copy of this
/// name is what `point_head_at` exists to stop repeating.
pub use gunnar_store::DEFAULT_BRANCH;

/// Wall-clock now, in milliseconds since the unix epoch.
///
/// Clamped at zero rather than panicking on a clock before 1970. An appliance
/// with no RTC boots at the epoch, and refusing to serve because the clock is
/// wrong would be the wrong failure.
pub fn now_ms() -> UnixMillis {
    std::time::SystemTime::now()
        .duration_since(std::time::UNIX_EPOCH)
        .map(|d| d.as_millis() as UnixMillis)
        .unwrap_or(0)
}

/// What kind of thing an identity is.
///
/// The distinction is not decorative: a `Deploy` key is expected to be
/// read-only and short-lived, and a UI that cannot tell a human from a CI
/// runner cannot show a useful key list.
#[derive(
    Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Debug, serde::Serialize, serde::Deserialize,
)]
pub enum PrincipalKind {
    /// A person.
    User,
    /// A machine account: CI, a bot, another service.
    Machine,
    /// A deploy key: one repository, usually read-only.
    Deploy,
    /// **A group of principals** (punkt 9).
    ///
    /// A group is a principal like any other — it has an id, it can be the
    /// grantee of a [`Grant`] at any scope and level, and `grant`/`revoke`/`may`
    /// treat it no differently from a user (one implementation, no new grant type). What is
    /// special is only that it *holds no keys* and is *never authenticated as*:
    /// a group is a name that grants attach to, and the members of the group
    /// inherit those grants. Membership is an edge, tracked beside the identity
    /// table (`AuthTable::add_to_group`), not a field on the principal, so the
    /// same principal record serves a group and a user.
    Group,
    /// **Nobody.** The caller presented no key at all.
    ///
    /// There is exactly one principal of this kind, [`Principal::anonymous`].
    /// It is never stored in the identity table, it can never hold a key and it
    /// can never hold a grant. It exists so that "a stranger clones a public
    /// repository" is answered by [`AuthTable::may`](crate::AuthTable::may)
    /// like every other access question, rather than by a second code path
    /// that could disagree with the first (one implementation).
    Anonymous,
}

/// Someone, or something, that can authenticate.
///
/// **There are no OS users.** The server has no `/etc/passwd`, no home
/// directories and no `authorized_keys`; this table is the entire identity
/// system (the ACL model).
#[derive(Clone, PartialEq, Eq, Debug, serde::Serialize, serde::Deserialize)]
pub struct Principal {
    /// Stable identifier, used as the key everywhere else. Same character
    /// rules as a namespace, so it can never be mistaken for a path.
    ///
    /// **It is also the account**: the namespace this principal intrinsically
    /// owns ([`AuthTable::owns_account`](crate::AuthTable::owns_account)), the
    /// key a gate is filed under, and what a policy file's
    /// `allowed_principals` names. Chosen once, at creation, and never moved.
    /// Everything that may change about a principal changes in `name`.
    pub id: String,
    /// Display name, for humans and for UIs.
    ///
    /// **Not the identity.** It defaults to the id and it may be changed
    /// ([`AuthTable::rename_principal`](crate::AuthTable::rename_principal))
    /// without touching a key, a grant, a membership, a gate or the namespace.
    /// It is unique on the table by construction — see the `names` index in
    /// [`AuthTable`](crate::AuthTable) — and may not equal another principal's
    /// id either, so a listing never shows two rows a human reads as one.
    pub name: String,
    /// What kind of identity this is.
    pub kind: PrincipalKind,
    /// When the row was created, by the table's clock, in unix milliseconds.
    ///
    /// Stamped by [`AuthTable::add_principal`](crate::AuthTable::add_principal)
    /// — one writer — and `0` for a row from before the field existed, which is
    /// what the wire reported for every principal until then. A caller that
    /// pins a principal by `(id, created)` can tell a delete-and-recreate under
    /// the same id from the row it first saw; the id alone cannot say, because
    /// an account slug is claimable again once its account is purged.
    #[serde(default)]
    pub created: UnixMillis,
}

/// The id of the one [`PrincipalKind::Anonymous`] principal.
///
/// **Reserved.** [`Principal::new`] and
/// [`AuthTable::add_principal`](crate::AuthTable::add_principal) both refuse
/// it, so no registered principal can ever be spelled this way and no public
/// key can ever be registered to it. That is what makes the rule in
/// [`AuthTable::may`](crate::AuthTable::may) a statement about a name that
/// cannot be claimed: *this id means "nobody", and nobody may read a public
/// repository and nothing else*.
pub const ANONYMOUS_PRINCIPAL_ID: &str = "anonymous";

impl Principal {
    /// Validate an id and build a principal.
    ///
    /// Refuses [`ANONYMOUS_PRINCIPAL_ID`]: the anonymous identity is not
    /// something an operator registers, and a registered principal wearing that
    /// name would be handed the public-read rule of
    /// [`AuthTable::may`](crate::AuthTable::may) for free.
    pub fn new(
        id: impl Into<String>,
        name: impl Into<String>,
        kind: PrincipalKind,
    ) -> Result<Self> {
        let id = id.into();
        check_component(&id, &id)?;
        if id == ANONYMOUS_PRINCIPAL_ID {
            return Err(Error::ReservedPrincipalId(id));
        }
        Ok(Principal {
            id,
            name: name.into(),
            kind,
            // The table stamps it on insert; a value chosen here would be the
            // caller's clock, and two clocks for one field is the twin.
            created: 0,
        })
    }

    /// **Nobody.** The principal an unauthenticated caller is served as.
    ///
    /// Built directly rather than through [`Principal::new`], which refuses the
    /// reserved id. There is no other constructor for it and no way to store
    /// one, so every value of this shape in the process came from here.
    pub fn anonymous() -> Self {
        Principal {
            id: ANONYMOUS_PRINCIPAL_ID.to_owned(),
            name: "anonymous".to_owned(),
            kind: PrincipalKind::Anonymous,
            created: 0,
        }
    }

    /// Is this the anonymous principal?
    ///
    /// **By id, not by `kind`.** The id is what
    /// [`AuthTable::may`](crate::AuthTable::may) is keyed on and what a grant
    /// would name; a `kind` that disagreed with the id would be the twinning
    /// this whole design avoids.
    pub fn is_anonymous(&self) -> bool {
        self.id == ANONYMOUS_PRINCIPAL_ID
    }
}

/// A registered public key.
///
/// The fingerprint is the primary key, because that is what an SSH handshake
/// gives the server: russh hands over the offered key, gunnar fingerprints it
/// and looks it up. The SSH *username* is decorative (the ACL model).
#[derive(Clone, PartialEq, Eq, Debug, serde::Serialize, serde::Deserialize)]
pub struct PublicKey {
    /// OpenSSH's `SHA256:<base64>` fingerprint.
    pub fingerprint_sha256: String,
    /// The algorithm name as OpenSSH writes it: `ssh-ed25519`, `ssh-rsa`, …
    pub algo: String,
    /// Which principal this key authenticates.
    pub principal_id: String,
    /// When it was registered.
    pub added: UnixMillis,
    /// When it last authenticated, if ever.
    pub last_used: Option<UnixMillis>,
    /// When it stops being accepted, if ever.
    pub expires: Option<UnixMillis>,
    /// The `authorized_keys`-style line it was registered from.
    ///
    /// Kept because `gunnar-policy` needs the key material itself to verify
    /// `gpg.format=ssh` commit signatures (the pre-receive gate), and a fingerprint cannot
    /// verify a signature.
    pub openssh: String,
}

impl PublicKey {
    /// Is this key still valid at `now`?
    pub fn is_valid_at(&self, now: UnixMillis) -> bool {
        match self.expires {
            None => true,
            Some(expires) => now < expires,
        }
    }
}

/// **A web credential — a person's WebAuthn passkey** (Stage L, 2026-10-04).
///
/// A row in the identity table beside the person's SSH keys, so it lives and
/// dies with the principal, rides the same compare-and-swap, and reaches the
/// twin with the rest of `refs/gunnar/auth`. gunnar verifies a login's
/// assertion against `public_key_hex` itself: the console relays the browser's
/// answer and holds no credential of its own.
#[derive(Clone, PartialEq, Eq, Debug, serde::Serialize, serde::Deserialize)]
#[serde(deny_unknown_fields)]
pub struct WebCredential {
    /// The authenticator's raw credential id, base64url without padding — the
    /// map key, and what a login names.
    pub id: String,
    /// The principal it logs in as. Only a USER may hold one.
    pub principal_id: String,
    /// What the person called it.
    pub label: String,
    /// The P-256 public key, SEC1 uncompressed (`04 || X || Y`), lowercase hex.
    pub public_key_hex: String,
    /// The authenticator's signature counter as of the last accepted login.
    pub sign_count: u32,
    /// When it was registered.
    pub created: UnixMillis,
    /// When it last logged in, if ever.
    #[serde(default)]
    pub last_used: Option<UnixMillis>,
    /// **The session keys its logins enrolled** (fingerprints), so revoking
    /// the passkey ends the sessions it opened. Pruned at each login to the
    /// keys still held and unexpired.
    #[serde(default)]
    pub sessions: Vec<String>,
}

/// **The operator's first invite, as the identity table keeps it** (Stage L,
/// 2026-10-04; IDENTITY-HOST 2026-10-27): the installer's break-glass token by
/// its hash only, for the operator's first passkey. Invites are the identity
/// core's (`nornir-identity`); this row is the one the core is FED (it reads it
/// as `sha256:<hex>` onto the operator and spends it there). The shape is the
/// table's from before the move, so a table written then still reads.
#[derive(Clone, PartialEq, Eq, Debug, serde::Serialize, serde::Deserialize)]
#[serde(deny_unknown_fields)]
pub struct FirstInviteRow {
    /// The invite's public id — what a listing and a revocation name.
    pub id: String,
    /// The person whose passkey it enrols.
    pub principal_id: String,
    /// Lowercase hex SHA-256 of the secret. Never the secret.
    pub secret_hash: String,
    /// Who created it.
    pub created_by: String,
    /// When.
    pub created: UnixMillis,
    /// When it stops being redeemable.
    pub expires: UnixMillis,
    /// Free text from the inviter.
    #[serde(default)]
    pub note: String,
    /// The server-wide grant a redemption applies (the console's operator or
    /// admin), if any. Only a server admin's invite carries one.
    #[serde(default)]
    pub server_level: Option<Level>,
}

/// A registered **access token**: the credential smart HTTP authenticates with
///
///
/// # Why this is not a second identity model
///
/// It is a second *authentication* method for the identity model that already
/// exists. A token names a [`Principal`] and nothing else. It carries no grant,
/// no level and no scope, so every authorisation question it can produce is
/// still answered by [`AuthTable::may`](crate::AuthTable::may) against that
/// principal's grants — the same call, on the same table, that an SSH key
/// produces. Revoking the token removes a way in; it changes nobody's access.
///
/// # The secret is not here, and cannot be
///
/// [`Self::hash`] is the SHA-256 of the token secret, hex, and it is also the
/// key this record is stored under. That is deliberate on both counts:
///
/// * the stored table never holds a usable credential, so a leaked auth
///   document (it is a git blob, and every version of it is kept — see
///   [`doc`](crate::doc)) does not hand anybody a login;
/// * a lookup is *hash the presented secret, probe once*, which is O(1) and
///   does not walk the token list. A table keyed by anything else would either
///   scan on every HTTP request or need a second index to avoid it.
///
/// The hash is therefore also the token's **public id**: it is what
/// `gunnar admin token list` prints and what revocation names. Printing a hash
/// is safe; printing the secret again is not possible, which is the point.
#[derive(Clone, PartialEq, Eq, Debug, serde::Serialize, serde::Deserialize)]
pub struct AccessToken {
    /// SHA-256 of the secret, lowercase hex. The primary key, and the id an
    /// operator sees.
    pub hash: String,
    /// Which principal this token authenticates.
    pub principal_id: String,
    /// An operator-supplied label: *which laptop, which CI job*. Never part of
    /// the credential.
    pub label: String,
    /// When it was created.
    pub created: UnixMillis,
    /// When it last authenticated, if ever.
    pub last_used: Option<UnixMillis>,
    /// When it stops being accepted, if ever.
    pub expires: Option<UnixMillis>,
}

impl AccessToken {
    /// Is this token still valid at `now`?
    ///
    /// Same shape as [`PublicKey::is_valid_at`] and deliberately so: expiry is
    /// one rule, and two credentials that expired by different arithmetic would
    /// be two rules (one implementation).
    pub fn is_valid_at(&self, now: UnixMillis) -> bool {
        match self.expires {
            None => true,
            Some(expires) => now < expires,
        }
    }
}

/// A tenant. First-class from day one (multi-tenancy), because adding it later would
/// mean migrating every stored path and every grant.
#[derive(Clone, PartialEq, Eq, Debug, serde::Serialize, serde::Deserialize)]
pub struct Namespace {
    /// The name, possibly nested (`team/sub`).
    pub name: String,
    /// **The exploded read cache this account's repositories may hold**, in
    /// bytes — the quota, and the one number a plan is allowed to move.
    ///
    /// `None` is *"take the server's default"*, which is
    /// `min(10 % of /data, 8 GiB)` per repository. `Some(0)` is unlimited and
    /// is a deliberate answer, not an absent one — which is why this is an
    /// `Option` and not a `u64` with a magic value.
    ///
    /// What it buys, and what it does not: a bigger cache means more objects
    /// are already inflated, so reads are FAST; a smaller one means more reads
    /// re-inflate their own delta chain out of the pack (~0.5–2 ms an object),
    /// which is SLOW. It is never a refusal and never an error — an account at
    /// its budget serves every object it has, correctly, more slowly. Nothing
    /// in this field can lose data: every byte it bounds is re-derivable from
    /// the packs, which are not bounded by it.
    ///
    /// `serde(default)` so a control document written before the quota field loads
    /// unchanged and every existing namespace reads as "the default".
    #[serde(default)]
    pub explode_budget_bytes: Option<u64>,
    /// **The namespace is public** — an explicit setting of the account admin's
    /// (owner, 2026-10-03: "default for all accounts is that emitted events and
    /// namespaces are not public, such things has to be set").
    ///
    /// Private (`false`, the default) is what every namespace is until its
    /// account admin says otherwise: somebody outside the account learns of it
    /// only through a public repository in it, and a private namespace holding
    /// no public repository answers exactly as one that does not exist. Public:
    /// the namespace itself is shown to everyone, even while it holds no public
    /// repository. Either way a PRIVATE repository in it is never shown to an
    /// outsider — that is the repository's own visibility.
    ///
    /// Skipped when `false`, so a default namespace's stored row is byte for
    /// byte what it was before the field existed, and every older row reads as
    /// private.
    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
    pub public: bool,
    /// **The account's emitted events are public** — the account admin's
    /// explicit setting, `false` by default: until it is set, the events of an
    /// account are shown to nobody outside it. Skipped when `false`, as
    /// [`Namespace::public`].
    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
    pub events_public: bool,
}

/// Which hash a repository's objects are named by.
///
/// gunnar's own enum rather than [`gix_hash::Kind`] so the wire and storage
/// representations do not depend on a gix version's `serde` feature. sha256 is
/// the default for new repositories; sha1 stays fully
/// supported.
#[derive(
    Clone,
    Copy,
    PartialEq,
    Eq,
    PartialOrd,
    Ord,
    Hash,
    Debug,
    Default,
    serde::Serialize,
    serde::Deserialize,
)]
pub enum HashKind {
    /// Git's original hash.
    Sha1,
    /// The default for new repositories.
    #[default]
    Sha256,
}

impl From<HashKind> for gix_hash::Kind {
    fn from(kind: HashKind) -> Self {
        match kind {
            HashKind::Sha1 => gix_hash::Kind::Sha1,
            HashKind::Sha256 => gix_hash::Kind::Sha256,
        }
    }
}

/// `gix_hash::Kind` is `#[non_exhaustive]`, so the conversion back is fallible
/// by construction: a gix release that adds a third hash must not silently be
/// stored as one of gunnar's two.
impl TryFrom<gix_hash::Kind> for HashKind {
    type Error = Error;
    fn try_from(kind: gix_hash::Kind) -> Result<Self> {
        match kind {
            gix_hash::Kind::Sha1 => Ok(HashKind::Sha1),
            gix_hash::Kind::Sha256 => Ok(HashKind::Sha256),
            other => Err(Error::Other(anyhow::anyhow!(
                "gunnar does not know the hash kind {other}"
            ))),
        }
    }
}

impl std::fmt::Display for HashKind {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        f.write_str(match self {
            HashKind::Sha1 => "sha1",
            HashKind::Sha256 => "sha256",
        })
    }
}

/// Who may read a repository without an explicit grant.
///
/// > **⚠ The meaning of [`Visibility::Public`] changed on 2026-08-03.**
/// > It used to mean "any *authenticated* principal may read", because gunnar
/// > had no unauthenticated path at all and the field therefore honoured
/// > nothing a private repository did not already get from a grant. It now
/// > means what the word says: **readable by anyone, with no key**. An operator
/// > who marked a repository public under the old reading has published it
/// > under the new one. [`Visibility::Private`] is still the default and is
/// > unchanged, and an appliance that must never serve a stranger turns the
/// > anonymous path off at the listener
/// > (`SshConfig::without_anonymous_access`).
#[derive(
    Clone,
    Copy,
    PartialEq,
    Eq,
    PartialOrd,
    Ord,
    Hash,
    Debug,
    Default,
    serde::Serialize,
    serde::Deserialize,
)]
pub enum Visibility {
    /// Only principals with a grant. The default, and the only safe one.
    ///
    /// A private repository is **indistinguishable from one that does not
    /// exist** to a caller who may not read it. The SSH dispatch answers both
    /// with the same string, so a stranger cannot enumerate private
    /// repositories one guess at a time.
    #[default]
    Private,
    /// Readable by **anyone**, including a caller who presented no key at all
    /// That is what [`PrincipalKind::Anonymous`] is for.
    ///
    /// Read only, and only `git-upload-pack`. Writing still needs a grant, so
    /// [`Level::Write`] and [`Level::Admin`] are unaffected by this field.
    Public,
}

/// A repository, as the catalog holds it.
#[derive(Clone, PartialEq, Eq, Debug, serde::Serialize, serde::Deserialize)]
pub struct Repo {
    /// The namespace it lives in, possibly nested.
    pub namespace: String,
    /// Its name, without any `.git` suffix.
    pub name: String,
    /// sha1 or sha256. Fixed at creation; a repository never changes hash.
    pub hash_kind: HashKind,
    /// Full ref name, e.g. `refs/heads/main`.
    pub default_branch: String,
    /// Who may read it without a grant.
    pub visibility: Visibility,
    /// When it was created.
    pub created: UnixMillis,
    /// Free-text description, for UIs.
    pub description: String,
    /// **Encrypted repository** (2026-10-05): the server refuses any push
    /// that would put plaintext on it — every new blob must be a
    /// gunnar-crypt envelope (the `encrypted` push gate). The server holds no
    /// key either way. Only the ACCOUNT admin sets it. Skipped when `false`, so
    /// every older row reads as not encrypted and is stored byte for byte as
    /// before.
    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
    pub encrypted: bool,
}

impl Repo {
    /// Its path, as the sandbox produces one.
    pub fn path(&self) -> RepoRef {
        RepoRef::new(&self.namespace, &self.name)
            .expect("a catalogued repo was built from a validated RepoRef")
    }

    /// Its catalog key, `<namespace>/<repo>`.
    pub fn key(&self) -> String {
        format!("{}/{}", self.namespace, self.name)
    }
}

/// The same string [`Repo::key`] returns, written straight into the formatter.
///
/// `key()` allocates; a `repo = %repo` span or log field does not. Both spell
/// `<namespace>/<repo>` and there is one implementation of that shape here, so
/// a log line and a catalog lookup can never disagree about what a repository
/// is called (one implementation). `RepoRef` already had exactly this pair.
impl fmt::Display for Repo {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        write!(f, "{}/{}", self.namespace, self.name)
    }
}

/// How much access a grant confers. Ordered: `Read < Write < Admin`, and the
/// ACL compares with `>=`, so an admin can push and a writer can fetch.
#[derive(
    Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Debug, serde::Serialize, serde::Deserialize,
)]
pub enum Level {
    /// `git-upload-pack`: clone and fetch.
    Read,
    /// `git-receive-pack`: push. Implies [`Level::Read`].
    Write,
    /// Administer the repository, namespace or server. Implies
    /// [`Level::Write`].
    Admin,
}

impl Level {
    /// Does holding `self` satisfy a requirement of `needed`?
    ///
    /// The ordering is the whole point, and it is why the SSH dispatch compares
    /// levels instead of matching on which verb it got: adding a third verb
    /// (LFS over SSH) then cannot forget a case. `gunnar-ssh` had its own copy of
    /// this enum and this method; it now uses this one.
    pub fn permits(self, needed: Level) -> bool {
        self >= needed
    }
}

impl std::fmt::Display for Level {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        f.write_str(match self {
            Level::Read => "read",
            Level::Write => "write",
            Level::Admin => "admin",
        })
    }
}

/// What a grant applies to.
///
/// # Deviation from the brief, recorded
///
/// The brief writes `scope: Namespace|Repo`. [`Scope::Server`] is added,
/// because without it the bootstrap admin of the secret-store seam has nothing it is allowed to
/// do: it cannot create the first namespace, so a freshly bootstrapped server
/// would be reachable and useless. A strict superset: no namespace-scoped or
/// repo-scoped behaviour changes.
#[derive(
    Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Debug, serde::Serialize, serde::Deserialize,
)]
pub enum Scope {
    /// The whole server. Only the bootstrap admin and whoever it appoints.
    Server,
    /// One namespace, matched **exactly**: a grant on `team` does *not* reach
    /// `team/sub`. Nested namespaces are separate tenants, and inheritance
    /// would be a privilege escalation that nobody wrote down.
    Namespace(String),
    /// One repository.
    Repo(RepoRef),
}

impl Scope {
    /// Does this scope cover `repo`?
    pub fn covers(&self, repo: &Repo) -> bool {
        match self {
            Scope::Server => true,
            Scope::Namespace(name) => name == &repo.namespace,
            Scope::Repo(path) => path.namespace() == repo.namespace && path.name() == repo.name,
        }
    }

    /// Does this scope cover the namespace `name`?
    pub fn covers_namespace(&self, name: &str) -> bool {
        match self {
            Scope::Server => true,
            Scope::Namespace(scoped) => scoped == name,
            Scope::Repo(_) => false,
        }
    }
}

impl std::fmt::Display for Scope {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        match self {
            Scope::Server => f.write_str("server"),
            Scope::Namespace(name) => write!(f, "namespace {name}"),
            Scope::Repo(path) => write!(f, "repo {path}"),
        }
    }
}

/// One row of the ACL: this principal has this much access to this scope.
#[derive(
    Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Debug, serde::Serialize, serde::Deserialize,
)]
pub struct Grant {
    /// Which principal, by [`Principal::id`].
    pub principal: String,
    /// What it applies to.
    pub scope: Scope,
    /// How much.
    pub level: Level,
}

impl Grant {
    /// A grant on one repository.
    pub fn repo(principal: impl Into<String>, path: RepoRef, level: Level) -> Self {
        Grant {
            principal: principal.into(),
            scope: Scope::Repo(path),
            level,
        }
    }

    /// A grant on a whole namespace.
    pub fn namespace(
        principal: impl Into<String>,
        namespace: impl Into<String>,
        level: Level,
    ) -> Self {
        Grant {
            principal: principal.into(),
            scope: Scope::Namespace(namespace.into()),
            level,
        }
    }

    /// A grant on the whole server.
    pub fn server(principal: impl Into<String>, level: Level) -> Self {
        Grant {
            principal: principal.into(),
            scope: Scope::Server,
            level,
        }
    }
}

/// Check that `name` is a legal full ref name under `refs/heads/`.
///
/// Reuses `gunnar_store`'s validator rather than growing a second opinion about
/// what a ref name is (one implementation). That one already rejects `..`, control
/// characters and empty components.
///
/// It used to be `FullName::new`, which validated *and* produced a value type.
/// `FullName` went with `RefStore`; the rules did not — they are a property of
/// git, not of a backend, and one of them (`refs/heads/../../etc/passwd`) is a
/// path-traversal boundary. They live in `gunnar_store::refname` now, as a
/// free function over `&str`, which is the shape `GitOps` speaks.
pub(crate) fn check_branch(name: &str) -> Result<String> {
    if !name.starts_with("refs/heads/") {
        return Err(Error::BadBranchName(format!(
            "{name:?}: a default branch must be a full name under refs/heads/"
        )));
    }
    gunnar_store::refname::validate(name)
        .map_err(|err| Error::BadBranchName(format!("{name:?}: {err}")))?;
    Ok(name.to_owned())
}