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).
- A repository has an immutable id: a UUIDv7, rendered as 32 lowercase hexadecimal characters. It is assigned at creation and never reused.
- 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 prefixrepo:is reserved for repositories: a configured static token whosescopestarts withrepo:is a configuration problem, andserveandconfig checkrefuse 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. - 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. - 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 namespacesmodels,datasets,spaces,kernels,buckets,api,v1,v2,openapi.json,healthzandreadyzare 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
scopeis a user credential. - A static token with
scopeis a CAS credential, as in M1. - A static token with a
scopeand user fields (name,emailorclaims) 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.
- The
Authorizerdecidesread,writeandcreatefor a user credential:readandwriteper repository,createper namespace. - M2 ships the config-rules authorizer. Each
[[auth.rules]]entry has:subjects, a list of subjects, and/orclaims, 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/*matchesorg/x, ando*/xmatchesorg/x. Globs match ignoring ASCII case, as names are unique that way (item 4).actions, a subset ofread,writeandcreate.writeincludesread.createis granted only by a pattern that covers the whole namespace:org/*allows creating repositories in namespaceorg, and*in every namespace;org/xoro*/xgrants nocreate.
- 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.
- The pseudo subject
anonymousstands 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. - Dev mode installs one rule:
subjects = ["dev"], repos = ["*"], actions = ["read", "write", "create"]. GET /api/whoami-v2answersnamefrom the subject, or from the static token'sname, andorgsfrom theorgsclaim.
Consequences
- A rename touches one metadata record. Registrations, packs, ref logs
and labels stay keyed by the id, so
reindexrebuilds 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, anAuthorizeranswersallows. - Public repositories are an explicit
anonymousgrant, 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_slashand the matcher cases),names_validatedandname_taken_case_insensitively_409incrates/hub/tests/repos.rs, andtoken_scope_is_repo_id_and_tenant_namespaceincrates/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 loginstores one token, and a user with ten repositories would need ten logins.