Sign in

Search issue titles and bodies, change requests and comments. Private repositories you cannot read are not included.

vole
Clone

Read only, no credentials:

https://rodent.software/git/vole.git

Write over SSH with a registered key:

git clone ssh://git@0.0.0.0:2222/vole.git

A free and open-source forge that lives in your Git repo.

Code

392 commit(s) on main

Merge change request 01M70WES5ZZDJ6RP226WB11BBZ: Let people rename and remove their own passkeys keenan · 2h ago 4b8c9e1d
source preview history blame raw 23.9 KiB
1# Accounts and access
2
3Vole has no password database. An account has two kinds of credential:
4
5- an **SSH key** for Git over SSH and remote administration;
6- a **passkey** for signing in to the browser.
7
8Both resolve to the same account, so access rules, approvals, and audit records
9do not care which one you used.
10
11## Accounts and keys
12
13An account is identified by its **name**, chosen when the account is created and
14fixed for good. It is what your contributor branches are named after
15(`refs/heads/contrib/<name>/`), and it is the key for access rules, approvals,
16file locks and CI records. There is no second internal identifier and no separate
17display label, so a rename would strand every ref, approval and author field
18already written. Keep the name.
19
20Names are never reused: because the account row outlives retirement, retiring
21someone keeps their name attached to them. An old reference can never silently
22point at a different person.
23
24## Bot accounts
25
26An automation can have an account of its own, so the work it does is attributable
27to it and it can be assigned an issue or named in a comment like anyone else. A bot
28is created by an instance admin with the repository role it should have:
29
30```sh
31vole admin account create coding-agent --bot --key agent.pub --role contribute
32```
33
34An instance admin can do the same from the **Create a bot account** form on the
35Accounts page: name the account, paste its public key, and choose a role. A bot
36cannot be given the instance-admin role in either place.
37
38The kind is fixed at creation, like the name. A bot differs from a person in three
39ways, and only three:
40
41- **No browser sign-in.** A bot cannot be given a passkey or an enrollment link,
42 because it has no browser, and a link it could never use is a secret in the roster
43 for no reason. `account invite` and adding a passkey are refused for a bot.
44- **No instance admin.** A bot is a repository automation, and the Admin role reaches
45 every repository and every instance surface, so it is refused when the account is
46 created and when its role is changed later.
47- **It is shown as a bot** in the user directory and on its profile, so a reader
48 knows a machine wrote what they are reading.
49
50Everything else is the same, and deliberately so: a bot is subject to the rules of
51whatever role it holds. A `contribute` bot may push only
52`refs/heads/contrib/<name>/`, and CI on that tip waits for a maintainer to allow it,
53exactly as for a person. A `maintain` bot may push shared branches, its pushes run
54CI immediately, it receives the secrets its workflows declare, and its approval
55counts when it is not the author. Granting a role is the trust decision, and the
56audit log and every approval name the bot.
57
58A workflow can act as a bot without holding a long-lived key. A job that names the
59account with `acts-as:` is given a credential when it is leased: a key generated for
60that run, written into the job, and gone when the run ends. The key may do what the
61account may do: discuss, file an issue, manage an item it wrote, open a change from a
62branch it may push, triage and approve when the role allows it, and push the branches
63the role allows. It cannot reach an admin surface, read repository secrets, or lease
64work, because those are not repository writes.
65
66The credential is not the account's key, and it does not widen the role: a
67`contribute` bot's job still opens changes in its own namespace, and a job cannot
68approve unless its account can. See
69[Workflow reference § acts-as](reference/workflow-reference.md#acts-as). A bot may also hold an
70ordinary key, provisioned the way a person's is, for an automation that is not a
71workflow run. A CI runner is that case: the runner process uses the bot's ordinary
72key to lease work, while the jobs it runs still get a credential for the run. See
73[Runners § The runner's account](runner.md#the-runners-account).
74
75Retiring a bot revokes it immediately: its key stops resolving, and a queued job it
76was a runner for is no longer owed. Its name stays attached to its history, like
77anyone else's.
78
79## Profiles and the user directory
80
81Account names are public on an instance. `/-/u` lists everyone who has an account,
82and `/-/u/<name>` is that person's profile: what they have opened, reviewed and
83commented on, and the repositories that work is in. Retiring an account keeps its
84page, marked as no longer active, because every closed issue and merged change
85still refers to the name.
86
87The directory is a table of every account and its visible counts. Search it by
88name, narrow it to active or retired accounts, and order it from a column header
89— by name, repositories, issues, changes, reviews or joining date. The search, the
90filter, the order and the page are all in the URL, so a view can be linked and
91paging cannot change the question.
92
93What a profile counts is not public. Every number and every event is limited to
94the repositories the reader may open, so two people can see different totals for
95the same account, and a private repository's work contributes nothing to a
96stranger's view. The page says what its numbers cover instead of pretending they
97are the whole history. The same rule covers the counts beside a name in the
98directory.
99
100A profile is derived, not stored. The numbers and the feed are read from the web
101index (`data/index.db`), which is rebuilt from Git and from the CI records; no
102profile is written to `data/users.db`. Keys, passkeys, sessions and a person's
103instance role are not shown, with one exception: an instance admin sees the role
104on the profile, because they can already see it on the admin pages. Git commit
105authors are shown as text and never counted, because a commit's author is a name
106someone typed, not the credential that proves an account.
107
108Writing `@name` in an issue, a comment, a change request, or a release note links
109to that account's profile. It is the same mention syntax a workflow's `mentioned`
110filter evaluates ([Workflows](workflows.md)), so a name that reads as an address
111on the page is the name a job sees. A name that does not belong to an account
112stays plain text, and a mention inside a code sample is not a mention.
113
114Both pages ask crawlers to leave them out of an index. The names are public; a
115scraped roster is not what public has to mean.
116
117Account data lives in `data/users.db`. It also holds passkeys, one-time
118enrollment links, browser sessions, repository access rules, and file locks.
119Credentials are public keys, and session or enrollment-link tokens are stored
120only as hashes. Vole reloads the roster every few seconds, so key changes and retirements
121take effect without restarting the server.
122
123```sh
124vole admin account create alice --key ~/.ssh/id_ed25519.pub
125vole admin account create bob --key bob.pub
126vole admin account add-key alice --key ~/.ssh/id_ed25519_work.pub
127vole admin account list
128vole admin account rm-key alice SHA256:...
129vole admin account retire alice
130vole admin account backup backup/users.db
131```
132
133`account create --key` writes the account and its first SSH key together. Without
134`--key` (and with no key on stdin), it creates a browser-only account and prints a
135one-time enrollment link. `account invite` issues a fresh link for an existing
136account. `account add-key` adds another SSH key; use it to rotate credentials
137without changing the account that access rules and approvals refer to. There is no
138command to change the account name.
139
140## Browser sign-in
141
142Browser sign-in uses a passkey (Touch ID, Windows Hello, a phone, or a security
143key). It requires `public_url` to be an `https://` URL; Vole refuses to start a
144WebAuthn ceremony without a canonical secure origin.
145
1461. An instance admin creates the account and gives the person its one-time
147 enrollment link, or the server has `open_registration = true` and the person
148 creates the account at `/-/register`.
1492. The person registers a passkey.
1503. The passkey creates a browser session. The session cookie is HttpOnly and
151 Secure; Vole stores only its hash.
1524. From **Account**, the person can register another passkey, rename or remove the
153 ones they have, add or remove SSH keys, or review their sessions. A label is
154 display text only: the credential is what signs in, so renaming one is not a
155 re-registration. Removing a passkey cannot be undone, and the last one is
156 refused while it is the account's only way in — an SSH key counts as a way in
157 for Git, so an account with one loses only its browser sign-in. Otherwise the
158 person would need an admin to invite them back.
159
160The account field opts into WebAuthn conditional UI (`autocomplete="username
161webauthn"`), so a supporting browser can offer a saved passkey while the field is
162focused. The username-first form remains for browsers without autofill and for a
163passkey created before discoverable credentials were required. New passkeys are
164registered as discoverable credentials (resident keys) and require user
165verification; that is what lets the browser find one without being told which
166account it belongs to.
167
168A link is single-use and expires. Issuing a new link invalidates the previous
169one. If the person loses every passkey, an instance admin issues a new link; there
170is no password reset to phish. Retiring an account deletes its sessions
171immediately, and removing a passkey deletes the sessions that passkey created,
172including the one the removal was made from.
173
174Both writes are audited: `identity.credential-rename` names the passkey and its new
175label, and `identity.credential-remove` names the passkey, with the account as the
176subject. The account page's own record of the write is `web.passkey.rename` and
177`web.passkey.remove`, the same way `web.key.add` and `web.key.remove` sit beside the
178identity actions for SSH keys.
179
180The link carries its token in the URL fragment — `https://forge.example.com/-/invite#<token>`
181— so the browser never sends it to the server, a proxy's access log, or the
182referrer of anything the page loads. Links issued in the older `/-/invite/<token>`
183shape still work: Vole redirects them into the fragment, and its own logs redact
184the old path.
185
186Sign-in, registration, enrollment and passkey-adding are rate-limited per client
187address (and per account name for sign-in), and the ceremony store is bounded and
188expires after five minutes. A client that exceeds a budget gets `429` with
189`Retry-After` rather than a slower answer. Behind a proxy, set
190`git_http_client_ip_header` so those budgets follow the real client instead of
191the proxy.
192
193An account name is not proof of anything: asking to sign in as an account that
194does not exist, is retired, or has no passkey gets the same assertion request as
195a real one. The difference only appears when the ceremony is finished, and the
196failure is generic.
197
198A passkey's signature counter must advance. If an assertion arrives with a
199counter that did not move, two copies of the key probably exist, so the sign-in
200is refused and the event is recorded in the audit log as
201`auth.clone-warning`. This can lock out an authenticator with a broken counter
202until an admin removes the passkey; that is the deliberate trade. Synced
203passkeys that always report zero are handled by WebAuthn's own rule and do not
204trigger the refusal.
205
206With `open_registration = true`, `/-/register` creates a new account and its first
207passkey without an instance admin. Set `open_registration_role` to the
208instance-wide role those accounts should have; `contribute` is the usual choice
209for an open-source instance:
210
211```toml
212open_registration = true
213open_registration_role = "contribute"
214```
215
216That role applies to every **public** repository, so a new contributor can read,
217discuss, and push to `refs/heads/contrib/<name>/` without being added to each
218repository. It does not open private repositories. Only `none`, `read` and
219`contribute` are accepted: a stranger who typed a name must not be able to move a
220shared branch, approve a review or administer the instance, so `maintain` and
221`admin` are refused and have to be granted explicitly. A registered account can
222add its own SSH key from **Account**, which is what lets it push. Open
223registration has no email verification and is rate-limited per address, so leave
224it off on a private instance unless you want that policy.
225
226Admins can inspect and revoke sessions:
227
228```sh
229vole admin account credentials alice
230vole admin account rm-credential alice <credential-id>
231vole admin account sessions alice
232vole admin account revoke-sessions alice
233vole admin account revoke-sessions --all
234```
235
236`revoke-sessions --all` is the recovery step after restoring an old `users.db`.
237
238To see which account the current remote's key belongs to:
239
240```sh
241vole whoami
242```
243
244To see the role that account holds on the repository named by the remote:
245
246```sh
247vole whoami --role
248```
249
250## Remote administration
251
252By default, server management commands run on the host. To use them from another
253machine, mark an account as an **instance admin** and pass `--remote`:
254
255```sh
256vole admin account grant-admin alice
257vole admin --remote ssh://git@forge.example.com:2222 account create bob --key bob.pub
258vole admin --remote origin repo create demo --owner bob
259vole admin --remote origin acl set-default-role demo read
260```
261
262An instance admin is the instance-wide `admin` role. They can create accounts and
263repositories, change access rules anywhere, and use `--remote`. They are also a
264repository admin on every repository, private ones included: they can push,
265change settings, and manage access without a repository-specific entry. SSH Git
266and SSH LFS use the key's account for this check; anonymous HTTP clone has no
267identity, so a private repository still answers `404` there.
268
269Instance admins also get an **Admin** page in the top navigation. Its tabs cover
270the instance's overview, storage, runners, and effective configuration; an
271Accounts tab that creates a person from an enrollment link or an automation
272account; and the audit log. Existing accounts are managed from the users
273directory at `/-/u`, which is searchable and paginated: an instance admin gets a
274**Manage** action on each row that opens the account page, where they set its
275instance-wide role (including `admin`), retire it, start a fresh enrollment link,
276and inspect or revoke SSH keys and browser sessions. Repository creation is a
277**New repository** action on the home page, and per-repository access rules live
278on that repository's **Settings** tab, visible there to instance admins.
279
280The first instance admin must be granted on the host. Instance admin is the
281instance-wide `admin` role, so `vole admin account role alice admin` and
282`vole admin account grant-admin alice` mean the same thing. Vole refuses to remove or
283retire the last active instance admin, so a remote mistake cannot lock everyone
284out. Add `--json` to any remote command for a machine-readable response:
285
286```sh
287vole admin --remote origin --json account list
288```
289
290Backups, garbage collection, and index rebuilds stay on the host; they are
291recovery operations, not remote administration.
292
293## Roles
294
295A role can be granted in two places:
296
297- **instance-wide**, on the account: it applies to every public repository;
298- **per repository**: an explicit entry for that repository.
299
300The effective role is the higher of the repository's default role, the
301repository entry for the account, and the account's instance-wide role. Instance
302roles only take effect on public repositories. The exception is `admin`: an
303instance admin is an admin on every repository, including private ones.
304
305Each repository also has a default role for everyone and optional roles for
306specific accounts.
307
308| Role | What it allows |
309|---|---|
310| `none` | No access |
311| `read` | Clone, fetch, and browse the repository |
312| `contribute` | Read, file issues and comments, push only `refs/heads/contrib/<name>/*`, and open change requests from those branches |
313| `maintain` | Everything the contribute role can do, plus push shared branches, triage issues, approve change requests, and manage the repository's secrets |
314| `admin` | Everything a maintainer can do, plus manage access, push protected branches, override locks, and repair metadata |
315
316Rules are edited with a compare-and-swap. A page or a command that read the rules
317before somebody else changed them is refused rather than allowed to write its copy
318back, so an instance admin revoking a role cannot be undone by a repository admin
319who still has the older page open. The operations that own one field — the
320visibility toggle, adding one account, one runners entry — re-read the row and
321apply only that field, so an unrelated concurrent change is kept.
322
323A new repository is private. Its default role is `none`, and its owner is an
324admin. Private repositories return `404` to anonymous visitors, so people cannot
325tell whether they exist. An instance-wide `read`, `contribute`, or `maintain`
326grant does not open a private repository; an instance admin does.
327
328To publish a repository, give the default role `read`:
329
330```sh
331vole admin acl set-default-role demo read
332```
333
334A repository admin can also change the description, default branch, and
335visibility, and rename or delete the repository, from its **Settings** tab in
336the browser. An instance admin has those rights on every repository too.
337Settings is split into tabs: **General** (the repository's own fields),
338**Access** (the instance-admin rules), **Secrets**, and **Danger** (rename and
339delete). Each tab is offered only to the accounts that may use it, and a viewer
340who can use one of them sees no row at all. Visibility
341toggles the default role between `none` and `read`; explicit role entries are
342left alone. The Accounts table shows only accounts with an explicit role or
343runner entry; use **Add account** below it to grant another account a
344repository-specific role. Access rules and review policy remain instance-admin
345operations: instance admins can edit them on the repository's **Access** tab,
346or with `vole admin acl`.
347
348A maintainer who is not an admin also opens the page, and its one tab is
349Secrets. Values a repository keeps out of its code — a deploy key, a
350registry token, a staging password — belong to the accounts that approve work and allow CI,
351so the page shows them the names, versions and read times, and the form that sets
352them. It never shows a value; `vole secret get` prints one when somebody needs it.
353
354## Changing access
355
356```sh
357vole admin account create carol --role contribute --key carol.pub
358vole admin account role alice maintain
359vole admin account role bob none
360
361vole admin acl show demo
362vole admin acl set-role demo alice maintain
363vole admin acl set-role demo bob contribute
364vole admin acl set-role demo carol read
365vole admin acl set-default-role demo read
366vole admin acl protect add demo refs/heads/main
367vole admin acl set-approvals demo 2
368vole admin acl checks enable demo
369vole admin acl require-issues enable demo
370vole admin acl runners add demo ci-runner-1
371```
372
373- `account create --role` and `account role` set the account's instance-wide role.
374 On a public repository that role applies without a per-repository entry; on a
375 private repository it does not, unless the role is `admin`.
376- `set-role` gives one account a role in one repository. The roles are `none`,
377 `read`, `contribute`, `maintain` and `admin`. `contributor` and `contrib` are
378 accepted as aliases for `contribute`; `write` is accepted as the historical
379 spelling of `maintain`. A repository entry can raise the instance-wide role but
380 cannot lower it. Use `none` to remove the entry.
381- `set-default-role` changes the role for everyone else. `read` or higher makes
382 the repository public.
383- `protect` manages the protected branch patterns. `set` replaces the list,
384 `add` and `remove` change it one entry at a time, `clear` empties it, and
385 `list` shows it. A pattern is an exact ref (`refs/heads/main`) or a prefix
386 ending in one `*` (`refs/heads/release/*`); `add` and `set` refuse anything
387 else, because a pattern the hook can never match would leave the branch open
388 while `acl show` listed it as protected. Admins can still push to protected
389 branches directly.
390- `set-approvals` sets how many approvals a protected branch needs. `0` means
391 admin-only.
392- `checks enable` requires CI checks to pass before a protected branch can be
393 updated; `checks disable` allows approvals alone to land it.
394- `require-issues enable` requires at least one linked issue when a new change
395 request is opened; `disable` makes it optional again. The rule is checked when
396 the request is opened, so requests that already exist keep their history and
397 can still be approved and landed.
398- `runners` manages the accounts whose CI results this repository trusts. It uses
399 the same `list`, `add`, `remove`, `set`, and `clear` verbs as `protect`.
400 Admins always count. A retired account stops counting even while its name is
401 still in the list: the ACL row outlives the account, so membership alone is not
402 a grant that lasts. See
403 [Runners](runner.md#who-can-run-jobs-and-report-results).
404
405## Contributors and their branches
406
407The `contribute` role is for open-source repositories that accept outside patches.
408A contributor can read, file issues and comments, manage the items it wrote, and
409open a change request from a branch it may push — through the CLI or the browser,
410which apply the same rules. Their Git writes are confined to a branch folder named
411after their account name:
412
413```text
414refs/heads/contrib/<name>/<branch>
415```
416
417For example, account `alice` may push `refs/heads/contrib/alice/fix-docs`. They
418cannot push `refs/heads/main`, another account's folder, or tags. The pre-receive
419hook checks the namespace, and rebasing or deleting a branch inside their own
420folder is allowed because it is not shared history.
421
422They may also write `refs/heads/vole`, the issues branch, because that is where a
423comment, an issue, or a change request lives. What the push may contain is the
424same list the browser offers: a comment, an issue, a change request from their own
425branch, or a change to an item they wrote. Pre-receive refuses anything else, so
426the two transports cannot disagree about what a contributor may do.
427
428The namespace is the account name, so a ref says whose work it is without a
429lookup. It is also why names are fixed at creation: renaming an account would
430otherwise orphan every branch under its old folder.
431
432The browser's **Open change request** form lists only the branches the account
433may use as a head. The head commit is resolved from that branch; a contributor
434cannot name a commit they did not push. A maintainer can approve the request, and
435landing it still goes through the same protected-branch gate.
436
437The CLI's `vole issue new`, `vole issue comment` and `vole change new` write the
438metadata branch directly. Pre-receive checks the same per-path rules as the
439browser, so a `contribute` account can use those commands too: it may file an
440issue, comment, and open a change from a branch in its own namespace.
441
442Contributor branches are ordinary branches except for one rule: CI does not run
443for them automatically. A maintainer or admin opens the change request for the
444branch and chooses **Allow CI** there. The approval names the exact
445commit, so every new push waits again. Treat runner isolation as part of the trust decision before granting
446`contribute` on a public repository.
447
448Allowing CI also grants that commit the repository's secrets, if its workflow
449declares them. It is the same decision as letting the code run on a runner: the
450values arrive on the machine that runs it, and any step in that job can read the
451ones the job declared. A maintainer who would not hand a value to an outside patch
452should not approve a tip whose workflow asks for it.
453
454## When rules are checked
455
456Every rule is checked before Vole accepts a push. If a push breaks a rule, none of
457its branches move. Browser issue, comment, and approval writes check the same ACL
458capability and use the same metadata writers and validation as the CLI.
459
460That includes protected branches, approvals, required checks, file size limits,
461locks, issue formatting, and the CI runner allowlist. Privileged actions — such as
462an admin force-push, a lock override, an account change, or a merge through review —
463are written to `data/audit.log` and can be read by an instance admin at
464**Admin → Audit log**.
465
466For the reasoning behind this design, see
467[Architecture](architecture.md#trust-a-credential-resolves-to-an-account). For file locks, see
468[LFS § Locking](lfs.md#locking-files).
469