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/vole.git

Write over SSH with a registered key:

git clone ssh://git@rodent.software: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 · 18d 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/requests.go +21−14

@@ -25,6 +25,7 @@ import (
25 25 "time"
26 26
27 27 "github.com/rodent-software/vole/internal/gitx"
28 "github.com/rodent-software/vole/internal/id"
28 29 "github.com/rodent-software/vole/internal/issues"
29 30 )
30 31
@@ -43,15 +44,15 @@ const (
43 44 var (
44 45 ulidRE = regexp.MustCompile(`^[0-9A-HJKMNP-TV-Z]{26}$`)
45 46 shaRE = regexp.MustCompile(`^[0-9a-f]{40}$`)
46 pathRE = regexp.MustCompile(`^requests/([0-9A-HJKMNP-TV-Z]{26})(?:/(meta\.json|description\.md)|/approvals/([a-z0-9_-]{2,32})\.json)$`)
47 userNameRE = regexp.MustCompile(`^[a-z0-9][a-z0-9_-]{1,31}$`)
47 pathRE = regexp.MustCompile(`^requests/([0-9A-HJKMNP-TV-Z]{26})(?:/(meta\.json|description\.md)|/approvals/([0-9A-HJKMNP-TV-Z]{26})\.json)$`)
48 48 branchNameRE = regexp.MustCompile(`^[a-z0-9][a-z0-9._-]{0,62}$`)
49 49 )
50 50
51 51 type Request struct {
52 52 ID string
53 53 Title string
54 Author string
54 Author string // display name at the time of writing
55 AuthorID string // account id: what the approval gate compares against
55 56 Base string // branch name, e.g. "main"
56 57 Head string // commit sha under review
57 58 State string // open | merged | closed
@@ -84,13 +85,14 @@ func (r *Request) ApprovedBy(principal string) bool {
84 85 }
85 86
86 87 type meta struct {
87 Title string `json:"title"`
88 Author string `json:"author"`
89 Base string `json:"base"`
90 Head string `json:"head"`
91 State string `json:"state"`
92 Created string `json:"created"`
93 Updated string `json:"updated,omitempty"`
88 Title string `json:"title"`
89 Author string `json:"author"`
90 AuthorID string `json:"author_id"`
91 Base string `json:"base"`
92 Head string `json:"head"`
93 State string `json:"state"`
94 Created string `json:"created"`
95 Updated string `json:"updated,omitempty"`
94 96 }
95 97
96 98 // Load reads and validates every request on the metadata branch.
@@ -170,9 +172,9 @@ func Parse(contents map[string]string) ([]*Request, error) {
170 172 // Self-approval is not an approval. Catching it here rather than at the
171 173 // gate means the author learns immediately instead of after a rejection.
172 174 for _, a := range req.Approvals {
173 if a.Principal == req.Author {
175 if a.Principal == req.AuthorID {
174 176 problems = append(problems, fmt.Sprintf(
175 "%s/%s/%s/%s.json: %s cannot approve their own request", Dir, req.ID, approvalDir, a.Principal, a.Principal))
177 "%s/%s/%s/%s.json: the author cannot approve their own request", Dir, req.ID, approvalDir, a.Principal))
176 178 }
177 179 }
178 180 sort.Slice(req.Approvals, func(i, j int) bool { return req.Approvals[i].Principal < req.Approvals[j].Principal })
@@ -202,6 +204,11 @@ func applyMeta(req *Request, raw, path string, problems *[]string) {
202 204 if req.Author == "" {
203 205 *problems = append(*problems, fmt.Sprintf("%s: author is required", path))
204 206 }
207 req.AuthorID = strings.TrimSpace(m.AuthorID)
208 if !id.Valid(req.AuthorID) {
209 *problems = append(*problems, fmt.Sprintf(
210 "%s: author_id must be the author's account id (a 26-character ULID), not their name; it is what blocks self-approval", path))
211 }
205 212 req.Base = strings.TrimSpace(m.Base)
206 213 if !branchNameRE.MatchString(req.Base) {
207 214 *problems = append(*problems, fmt.Sprintf("%s: base must be a branch name, got %q", path, req.Base))
@@ -235,8 +242,8 @@ func applyMeta(req *Request, raw, path string, problems *[]string) {
235 242
236 243 func parseApproval(principal, raw, path string) (Approval, string) {
237 244 a := Approval{Principal: principal}
238 if !userNameRE.MatchString(principal) {
239 return a, fmt.Sprintf("%s: %q is not a valid principal name", path, principal)
245 if !id.Valid(principal) {
246 return a, fmt.Sprintf("%s: the file name must be the approver's account id (a 26-character ULID)", path)
240 247 }
241 248 var body struct {
242 249 Base string `json:"base"`