Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

0012: Give repositories immutable ids, and authorize users with config rules

  • Status: accepted
  • Date: 2026-10-25
  • Scope: hub, git, auth, index, storage, server, docs

Context

M1 serves the CAS alone. Its credentials are CAS credentials: a static token or an EdDSA JWT naming a scope, a mode and optionally a tenant (ADR 0007, rulings M1-R38 and M1-R43). M2 adds repositories, the Hub API and Git hosting (design §6, §10.9), and with them two questions the M1 model can't answer.

What is a repository's scope? Design §5.4 makes the scope the unit of access and persists it: its SHA-256 keys every registration record in the object-store bucket (ruling M1-R37). ADR 0010, as first written, set a repository's scope to {namespace}/{name} and asked the M2 plan for an immutable scope id, so that a rename never orphans data. If the scope were the name itself, renaming foo/bar to foo/baz would leave every registration record and every bucket key under the old name, and a later repository named foo/bar would inherit them.

Who may read, write or create a repository? Design §5.4 names an authenticator, which turns a credential into a principal (subject plus claims), and an authorizer, which decides per repository or namespace. OIDC, the webhook authorizer and openxet-issued access tokens are M3 work (design §12). M2 still needs stock clients to work end to end: hf auth login sends one token, and that token must open every repository its holder may use, not one scope.

Decision

Repository identity (ruling M2-R2).

  1. A repository has an immutable id: a UUIDv7, rendered as 32 lowercase hexadecimal characters. It is assigned at creation and never reused.
  2. Its CAS scope is repo:{id}, and its tenant is its namespace (design §5.4). CAS tokens the Hub and LFS token routes mint carry that scope and tenant. The scope prefix repo: is reserved for repositories: a configured static token whose scope starts with repo: is a configuration problem, and serve and config check refuse it. A CAS token a platform signs with such a scope is accepted, because the platform holds the signing key and is trusted with any scope.
  3. Bucket keys for its data carry the id (repos/{id}/…). Namespace and name live in the repository's metadata record, so a rename rewrites one record and orphans nothing.
  4. Namespace and name each match ^[A-Za-z0-9][A-Za-z0-9._-]{0,95}$ and contain no .., and the name doesn't end in .git. {namespace}/{name} is unique case-insensitively and stored as written. Names are unique across labels (ADR 0010 item 5). The namespaces models, datasets, spaces, kernels, buckets, api, v1, v2, openapi.json, healthz and readyz are reserved, because they are route prefixes; the Hub refuses to create a repository in one.

User credentials and the authorizer (ruling M2-R3). 5. A credential is a CAS credential (M1: scope, tenant, mode), a user credential (a subject plus claims, which are string lists under free names such as orgs and groups), or both.

  • A static token without scope is a user credential.
  • A static token with scope is a CAS credential, as in M1.
  • A static token with a scope and user fields (name, email or claims) is both; the dev token is one. CAS routes take the CAS credential; Hub, Git and LFS routes take the user credential and ask the authorizer.
  1. The Authorizer decides read, write and create for a user credential: read and write per repository, create per namespace.
  2. M2 ships the config-rules authorizer. Each [[auth.rules]] entry has:
    • subjects, a list of subjects, and/or claims, a table of claim names to lists of values. A rule matches a principal whose subject is listed, or who carries one of the listed values under the named claim. Subjects match exactly; * is not a wildcard there.
    • repos, a list of globs over {namespace}/{name}. * matches within one path segment and never crosses the /, except that * alone matches every repository: org/* matches org/x, and o*/x matches org/x. Globs match ignoring ASCII case, as names are unique that way (item 4).
    • actions, a subset of read, write and create. write includes read. create is granted only by a pattern that covers the whole namespace: org/* allows creating repositories in namespace org, and * in every namespace; org/x or o*/x grants no create.
  3. Rules only grant. A request is allowed if any rule allows it, and refused if none does. Rule order doesn't matter: there is no first-match-wins and no deny rule.
  4. The pseudo subject anonymous stands for everyone: a rule naming it matches every request, signed in or not, so an operator can make repositories public with an explicit rule. No rule names it by default.
  5. Dev mode installs one rule: subjects = ["dev"], repos = ["*"], actions = ["read", "write", "create"].
  6. GET /api/whoami-v2 answers name from the subject, or from the static token's name, and orgs from the orgs claim.

Consequences

  • A rename touches one metadata record. Registrations, packs, ref logs and labels stay keyed by the id, so reindex rebuilds a renamed repository from the object-store bucket alone (non-negotiable 2).
  • Scope strings stay opaque to the CAS: repo:{id} is one more scope string, so M1's file scoping, dedup and per-tenant isolation apply unchanged.
  • Because repo: is reserved, an operator's storage-only static token can't land in a repository's scope by a naming accident. A platform that signs CAS tokens can still name a repository's scope on purpose, as it can any scope; signing keys are trusted with every scope (ADR 0007). The config check is enforced with the static-token changes of M2 (the plan's Task 3).
  • Per-tenant dedup groups an organisation's repositories: a fine-tune dedups against its base model in another repository of the same namespace (design §5.4).
  • Static tokens become M2's user credential. M3's OIDC subjects and claims, openxet-issued access tokens and the webhook authorizer plug into the same seam: an authenticator produces a UserPrincipal, an Authorizer answers allows.
  • Public repositories are an explicit anonymous grant, never a default, so a server with auth on still has no unauthenticated reads (design §5.9 requirement 1) until its operator writes one.
  • With grant-only rules, a reviewer audits a server's access by reading the rules that name a repository; no rule elsewhere in the list can take access away.
  • Tests: crates/auth/tests/rules.rs (no_rule_denies, anonymous_only_with_explicit_rule, glob_never_crosses_the_slash and the matcher cases), names_validated and name_taken_case_insensitively_409 in crates/hub/tests/repos.rs, and token_scope_is_repo_id_and_tenant_namespace in crates/hub/tests/tokens.rs.

Alternatives considered

  • Scope = {namespace}/{name}, as ADR 0010 first wrote it. Rejected: a rename would orphan every registration and bucket key, and a new repository under the old name would inherit them.
  • Deny rules, or ordered rules with first-match-wins. Rejected until an operator needs them: with denies on top of grants, whether a request is allowed depends on every rule and their order, which is harder to audit. A deny can be added later without changing what existing rules mean.
  • Only CAS credentials, one static token per repository. Rejected: hf auth login stores one token, and a user with ten repositories would need ten logins.

Read this page as Markdown · Report a docs issue