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.

Accounts with stable ids; the roster moves into the system repository

31df8baea9c9e8acf74b71bdda26d27907b30f2a · Keenan Nemetz · 17d ago · parent 36293228

Accounts with stable ids; the roster moves into the system repository

Names were the primary key everywhere — the keys file name, the ACL entry, the
approval file — so renaming someone broke their access, orphaned their approvals,
and left a retired name able to hand a newcomer the old holder's rights. This
splits identity into an account (stable id, anchored by its SSH keys) and a name
(mutable, never reissued).

  data/system.git  refs/heads/main
    users/<ulid>/meta.json   name, status, keys, former_names

Everything that grants authority now holds the id: ACL entries, approval files,
and the identity resolved at the SSH handshake (VOLE_ACTOR_ID). A rename is a
one-file change that disturbs nothing else, and both the old and new names stay
reserved forever.

The roster moved from a trusted-keys directory back into git, which reverses an
earlier simplification — deliberately, and in a smaller shape (one file per
account, one writer, no CAS plumbing for unused fields). Git buys what a directory
could not: `git log` answers who renamed whom and when a key appeared, and the
roster is now readable by anyone who can clone, since it is served read-only at
ssh://host/system.git. `vole request new` and `approve` use exactly that to turn a
name typed on the command line into the account id they store.

Two latent bugs surfaced, both caught by tests added here:

- gitx inherited git's repository-scoped environment. pre-receive runs with
  GIT_OBJECT_DIRECTORY and GIT_QUARANTINE_PATH pointing at the pushed repository's
  quarantine, so every git call a hook made for a *different* repository silently
  read the pushed one: the hook saw an empty roster. gitx now scrubs those
  variables and the hook restores them for the pushed repository only.
- The hook built the repository name with filepath.Base on receive-pack's path,
  which carries a trailing separator, so refusals read "does not have write access
  to .". Cleaned first.

Verified end to end on a live instance: two accounts file, approve and land a
change; `vole user rename wilbur robert` keeps the same key's write access and the
approval still reads as current, now credited to robert; the roster history shows
"user: rename wilbur to robert"; retiring an account rejects its key within five
seconds and refuses to reissue the name.

internal/requests/write.go +17−11

@@ -6,12 +6,13 @@ import (
6 6 "time"
7 7
8 8 "github.com/rodent-software/vole/internal/gitx"
9 voleid "github.com/rodent-software/vole/internal/id"
9 10 "github.com/rodent-software/vole/internal/issues"
10 11 )
11 12
12 13 // New builds a request with a fresh ULID. Nothing is assigned server-side, so
13 14 // concurrent authors never contend.
14 func New(title, body, author, base, head string, now time.Time) (*Request, error) {
15 func New(title, body, author, authorID, base, head string, now time.Time) (*Request, error) {
15 16 id, err := issues.NewULID(now)
16 17 if err != nil {
17 18 return nil, err
@@ -20,6 +21,7 @@ func New(title, body, author, base, head string, now time.Time) (*Request, error
20 21 ID: id,
21 22 Title: title,
22 23 Author: author,
24 AuthorID: authorID,
23 25 Base: base,
24 26 Head: head,
25 27 State: "open",
@@ -42,6 +44,9 @@ func validateForWrite(r *Request) error {
42 44 if r.Author == "" {
43 45 return fmt.Errorf("requests: author is required")
44 46 }
47 if !voleid.Valid(r.AuthorID) {
48 return fmt.Errorf("requests: author_id must be the author's account id")
49 }
45 50 if !branchNameRE.MatchString(r.Base) {
46 51 return fmt.Errorf("requests: base %q is not a branch name", r.Base)
47 52 }
@@ -53,12 +58,13 @@ func validateForWrite(r *Request) error {
53 58
54 59 func marshalMeta(r *Request) ([]byte, error) {
55 60 m := meta{
56 Title: r.Title,
57 Author: r.Author,
58 Base: r.Base,
59 Head: r.Head,
60 State: r.State,
61 Created: r.Created.UTC().Format(time.RFC3339),
61 Title: r.Title,
62 Author: r.Author,
63 AuthorID: r.AuthorID,
64 Base: r.Base,
65 Head: r.Head,
66 State: r.State,
67 Created: r.Created.UTC().Format(time.RFC3339),
62 68 }
63 69 if !r.Updated.IsZero() {
64 70 m.Updated = r.Updated.UTC().Format(time.RFC3339)
@@ -133,8 +139,8 @@ func SetHead(g *gitx.Repo, id, head string, now time.Time, actor string) (*Reque
133 139
134 140 // Approve records principal's approval of the request's current head and base.
135 141 func Approve(g *gitx.Repo, id, principal string, now time.Time, actor string) (*Request, error) {
136 if !userNameRE.MatchString(principal) {
137 return nil, fmt.Errorf("requests: %q is not a valid Vole user name", principal)
142 if !voleid.Valid(principal) {
143 return nil, fmt.Errorf("requests: %q is not an account id", principal)
138 144 }
139 145 var out *Request
140 146 base := fmt.Sprintf("%s/%s/", Dir, id)
@@ -143,8 +149,8 @@ func Approve(g *gitx.Repo, id, principal string, now time.Time, actor string) (*
143 149 if err != nil {
144 150 return err
145 151 }
146 if r.Author == principal {
147 return fmt.Errorf("requests: %s cannot approve their own request", principal)
152 if r.AuthorID == principal {
153 return fmt.Errorf("requests: the author cannot approve their own request")
148 154 }
149 155 if r.Head == "" {
150 156 return fmt.Errorf("requests: %s has no head commit to approve", id)