Expand description
Per-repository authorization (D29).
The node’s basic-auth check answers who is this and returns a username. This module answers the other question — may this actor do this to this repository — which until D29 nothing asked. Without it any valid credential reaches every repository on the node: safe while one operator holds the only token, and the first thing that matters when a second one is issued.
The grammar is three whitespace-separated columns, # comments, blank
lines ignored — the same shape as --auth-file, so the operator
learns one file format rather than two — and an optional fourth column
carrying a deadline (D66):
# <user|@anon> <repo|*|@node> <level> [until=<unix seconds>]
alice owner/project own
alice owner/notes read
bob owner/project write until=1788000000
carol @node auditor
@anon owner/project readThe last line is the one that publishes something. ANON is the
reader who presented no credential, and naming a repository beside it
is how that repository stops being behind the wall. It may hold only
read, only on repositories written out one per line, and never
@node; those three are refused by Acl::parse, so a file that
would publish more than the operator typed does not load at all.
A grant with a deadline stops mattering when the deadline passes,
and nothing sweeps: the table is dated on every request, so a lapse
takes effect on the next one. It lapses downward, not to nothing —
bob above keeps whatever weaker grant another line gives him, which
is what makes a time-locked write over a permanent read a usable
way to lend a privilege rather than an account.
This is the mechanism D24’s T1 response names (“time-locks + bonds
only”) and did not have. It is deliberately not an answer to T1’s
measurement problem: a grant lives in this file and in the D36
store, never in the op log, so a replayer still cannot see one. That
is D29’s design and choir_view::View::validate_submit says so —
the ACL grant is the single element replay cannot rederive.
Four levels, read < propose < write < own. propose arrived
with D60 and is the one that lets a repository take contributions
from someone who is not trusted with its branches: it admits a push
to refs/for/<branch>/<user>/<topic>, where the pusher’s own name is
what keeps two of them apart, and refuses every other ref. own
arrived with D42, which
is the first repository-scoped administrative action to exist: on a
protected ref an owner’s assent authorizes the landing, and write
alone does not. Before that there was deliberately no admin, because
a level with no operation behind it only invites a meaningless grant.
own is granted in this file only. Self-service (D36) renders its
issued grants in the same grammar, but the landing gate reads the
operator’s file directly rather than the merged table, so ownership
cannot be self-issued.
The file is not the only source of grants. Credential self-service
(D36) renders what it issued in this same grammar, and the node
enforces the two as one table (Acl::merged) — so an issued grant
and a hand-written one are the same kind of fact, checked in the same
place. What self-service may not issue is Scope::Node: node-wide
authority stays in the file the operator writes.
* covers every repository and never covers Scope::Node: the op
log and the ref-state attestation describe the whole node, not a
repository, and are gated rather than filtered because filtering a
hash chain or a complete-ref-state snapshot destroys the property each
exists to provide.
The operator’s guide to every authorization question this module and its neighbours answer:
§Authorization
Five separate questions:
| Question | Answered by |
|---|---|
| Who are you? | --auth-file, or an account issued under --accounts-file |
| Which repositories may you reach, and how far? | --acl-file (D29) |
| May this particular landing happen? | --protected-refs + --require-review, narrowed by own (D42) |
| Why was it allowed? | the Submit op’s authorization basis (D43) |
| Whose key signed it, and when? | key bindings and revocations (D44) |
[!IMPORTANT] Without
--acl-file, every credential reaches every repository.
§Per-repository authorization (D29)
Three whitespace-separated columns, an optional fourth, # comments:
# <user> <repo|*|@node> <level> [until=<unix seconds>]
alice owner/demo write
bob owner/demo read
bob owner/notes write
carol * read
dave @node auditor
erin owner/demo propose
frank owner/demo write until=1788000000Each level adds to the one above:
| Level | Adds |
|---|---|
read | cloning and fetching |
propose | opening a review |
write | pushing any other ref, workspace provisioning, submitting ops that touch that repository |
own | authorizing a landing on a protected ref (D42) |
propose admits one thing (D60): a push to
refs/for/<branch>/<user>/<topic>, which opens a review (D53). Every
other ref is refused. The pusher’s own name is a required segment. The
grant is checked at the smart-HTTP boundary and again when the
pre-receive hook reports the refs.
The operator’s credential usually wants:
myself * write
myself @node write* never covers @node. @node auditor reads /api/log and
/api/ref-agreement; @node write is needed for ops naming no repository,
such as key bindings. Vouching (D65) needs only @node auditor.
§A grant that ends (D66)
The fourth column is a deadline in unix seconds. The table is dated on every request; no restart.
A deadline lapses downward. Pair a permanent read with a write that
ends:
frank owner/demo read
frank owner/demo write until=1788000000A single expiring grant leaves the holder with a 404.
- A deadline in the past parses and never matches; startup says
acl enabled (7 grants, 1 expired). until=is absolute:date -v+90d +%son macOS,date -d '+90 days' +%son GNU.- The node’s clock decides.
own may carry a deadline; when it lapses the landing gate returns to the
approval-weight rule (D42).
Fail closed: anything not granted is refused. An unreadable repository
answers 404, never 403. The flag requires --auth-file. A malformed file
refuses to start; a malformed edit keeps the previous table and complains.
/api/view, /api/reviews and the browser page are narrowed to readable
repositories. Node-wide sections (ref-state attestation, key bindings, vouch
graph, telemetry) need @node auditor; the log head and build stamp reach
everyone. A review you were assigned to reaches you on any repository.
§Repository ownership (D42)
With --protected-refs and --require-review, a protected ref needs
approval weight 2 from two distinct operators. own changes the question:
myself owner/demo ownOn a protected ref of an owned repository, one owner’s assent is necessary
and sufficient: the owner lands it, or the owner approved a review naming
that exact (ref, commit).
- An owner’s key is equivalent to the repositories they own.
- An owner submitting directly must have their key bound in
--keys-file(<channel> <64-hex>). Approving a review does not need this. ownis granted in the file you write; self-service cannot issue it.
The file is re-read per landing. An unreadable or malformed file refuses the landing.
§Landing a review with the reason it was allowed (D43)
Submit is a ref move with the gate’s answer attached:
{"Submit": {"review": "r1", "name": "demo.git:refs/heads/main",
"commit": {...}, "prev": {...},
"authorization": {"format_version": 1,
"basis": {"OwnerApproved": {"owner": "myself"}},
"approvers": [{...}]}}}Basis is OwnerLanded, OwnerApproved, or ApprovalWeight {required, met}.
The authorization is never a client’s to assert. Post a Submit; on
mismatch the rejection’s expected field is the gate’s record. Sign that
verbatim and post again:
curl -u "$USER" -X POST https://<HOST>/api/submit -d "$FIRST_ATTEMPT" \
| jq -r .expected # the authorization the gate producedthis landing cannot name its approvers: an approving channel has no key binding (or two). Bind the reviewer’s key and merge again.is not gated on this node: unprotected ref, or no--require-review. UseSetRef.
Archiving a review discards its verdicts; the entry bytes remain the answer to who approved a landing.
§Rotating a key without breaking anything (D44)
An approval is credited against the key live when the verdict was cast.
choir revoke <api> <node-key-file> <old-pubkey-hex> "laptop lost"
choir bind <api> <node-key-file> <operator> <new-pubkey-hex> [channel]Both take the public key hex choir key prints and are node-signed.
- An approval cast by an already-revoked key cannot be credited; the reviewer needs a fresh key and verdict.
choir log --verifychecks signatures as of their own position. It fetches revocation positions from/api/view, so it needs API access.
[!WARNING] Keep revoked keys in the trusted-keys file. The log stores a key id, never the public key, so deleting the line makes every entry that key signed permanently unverifiable.
That applies to ed25519 keys only. A passkey-signed entry carries its own
credential key (D45); choir log --verify counts those as intact but
unanchored.
Review retention is opt-in: --review-retention N archives completed
reviews when more than N remain live. --review-lapse-after-secs is
invalid without it.
§Issuing a credential without editing a file (D36)
--accounts-file <path> turns on invite-only self-service; issued
credentials are added to what the auth and ACL files say.
Passkeys are --passkeys. Without it, POST /api/accounts/passkey and
/account answer 503.
choir-node ./repos 8417 --auth-file ~/.choir/auth --acl-file ~/.choir/acl \
--keys-file ~/.choir/keys --accounts-file ./repos/.choir/accounts.json \
--ssh-handoff ./repos/.choir/ssh-handoff \
--ssh-authorized-keys ./repos/.choir/authorized_keys§The console (D72)
/people, for @node write: the queue of people asking for access, a
button to let one in, and a form that mints an invite link.
A stranger can ask for access on the front page and keeps the link it gives them; granting turns that link into their invite. Each request costs a proof of work; the queue caps at 64.
A POST whose Origin names another site is refused.
§The same three by hand
Mint an invite, as @node write:
curl -u "$OPERATOR" -X POST https://<HOST>/api/accounts/invite \
-d '{"user":"bob","grants":["owner/demo read","owner/notes write"]}'The invite names nobody (D75); the username is theirs to pick. Send
{"user":"buildbot"} when the name must be exact.
The response carries invite, an id:secret pair, once. It is presented as
basic auth to one endpoint:
curl -u "<INVITE>" -X POST https://<HOST>/api/accounts/redeem \
-d "{\"ssh_key\":\"$(cat ~/.ssh/id_ed25519.pub)\"}"That answers with the clone token and registers the key for SSH. Invites
are single use and expire in a day (expires_in_secs).
In a browser the link enrols a passkey and opens a session. A token for
git is minted from /account (POST /account/token), one per account.
GET /api/accounts lists accounts, live invites and pending requests.
POST /api/accounts/request/grant with {"request_id":"ask-...","grants":[...]}
answers one; POST /api/accounts/request/decline drops one;
POST /api/accounts/revoke with {"user":"bob"} deletes an account, its
grants and its authorized_keys line. Revocation is deletion.
[!CAUTION]
@nodecan never be issued by self-service. The flag requires both--auth-fileand--acl-file.
- An issued grant may carry a deadline:
{"grants":["owner/demo write until=1788000000"]}. - The generated
authorized_keysis generated. Pointsshdat it (AuthorizedKeysFile /path/to/repos/.choir/authorized_keys, see Git over SSH) and never edit it.
§Publishing a repository to everybody (D78)
One line in the ACL opens one repository to readers with no account:
@anon owner/project.git read@anon is the reader who presented no credential. An unauthenticated browse
or fetch is evaluated under that principal; every check after the gate is
unchanged. No flag.
It cannot be authenticated as. Account names are ASCII letters, digits,
-, _ and ., so @ is unspellable. A user called anon is an ordinary
account.
Three grants it will not take, refused at parse time:
| Written | Refused because |
|---|---|
@anon @node auditor | the op log and the audit surface |
@anon * read | name each public repository explicitly |
@anon o/r write | a write path with no credential |
What opens: the browse surface for that repository and the read half of
git smart-HTTP, so git clone works with no credentials. What does not:
/api/view, /api/log, /reviews, and git-receive-pack. An unpublished
repository answers exactly as one that does not exist.
Pair with --site-repo owner/project to make that repository the front page.
Structs§
- Acl
- A parsed ACL file: which users hold which grants, and until when.
- Denial
- A refused request: the status to answer with, and the reason.
- Effective
- The grants that hold right now: an
Aclwith every lapsed deadline already dropped (D66).
Enums§
- Disclosure
- How one top-level section of a read response may be disclosed.
- Level
- Grant strength.
Level::Readis implied byLevel::Write, which is implied byLevel::Own. - Scope
- What a grant applies to.
Constants§
- ANON
- The reader who presented no credential.
- SECTIONS
- Every top-level section
/api/viewand/api/reviewsserve.
Functions§
- api_
denial - The ACL decision for one platform-API request, or
Nonewhen it is allowed. - disclosure
- The disclosure rule for
section, orNonewhen it has no row and must therefore be withheld. - filter_
response - A read response narrowed to what
usermay see (D29 phase B). - git_
requirement - Repository and level a git smart-HTTP request needs.
- normalize_
repo - Canonical ACL spelling of a repository name: one trailing
.gitremoved, soowner/repoandowner/repo.gitare the same grant. - op_
level - The grant strength an op needs over the scopes
op_scopesnames. - op_
scopes - Scopes an op must be authorized against, never empty.