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/identity/identity_test.go +155−97

@@ -3,18 +3,23 @@ package identity
3 3 import (
4 4 "crypto/ed25519"
5 5 "crypto/rand"
6 "fmt"
6 7 "os"
8 "os/exec"
7 9 "path/filepath"
8 10 "strings"
9 11 "sync"
10 12 "testing"
13 "time"
11 14
15 "github.com/rodent-software/vole/internal/gitx"
16 "github.com/rodent-software/vole/internal/id"
12 17 "golang.org/x/crypto/ssh"
13 18 )
14 19
15 20 func newStore(t *testing.T) *Store {
16 21 t.Helper()
17 dir := filepath.Join(t.TempDir(), "trusted_keys")
22 dir := filepath.Join(t.TempDir(), "system.git")
18 23 if err := Init(dir); err != nil {
19 24 t.Fatal(err)
20 25 }
@@ -41,24 +46,25 @@ func pubLine(t *testing.T) (string, string) {
41 46
42 47 func TestEmptyRosterLoads(t *testing.T) {
43 48 s := newStore(t)
44 if got := len(s.Snapshot().Users); got != 0 {
45 t.Fatalf("users = %d, want 0", got)
49 if got := s.Snapshot().Count; got != 0 {
50 t.Fatalf("count = %d, want 0", got)
46 51 }
47 52 }
48 53
49 func TestMissingDirectoryIsAnError(t *testing.T) {
54 func TestMissingRepositoryIsAnError(t *testing.T) {
50 55 if _, err := Open(filepath.Join(t.TempDir(), "nope")); err == nil {
51 t.Fatal("Open succeeded on a missing directory")
56 t.Fatal("Open succeeded on a missing repository")
52 57 }
53 58 }
54 59
55 func TestCreateUserAndAddKey(t *testing.T) {
60 func TestCreateAccountAndAddKey(t *testing.T) {
56 61 s := newStore(t)
57 if err := s.Create("keenan"); err != nil {
62 a, err := s.Create("keenan", "test")
63 if err != nil {
58 64 t.Fatal(err)
59 65 }
60 if _, ok := s.Snapshot().Users["keenan"]; !ok {
61 t.Fatal("user missing after create")
66 if !id.Valid(a.ID) {
67 t.Fatalf("account id %q is not a ULID", a.ID)
62 68 }
63 69 line, wantFP := pubLine(t)
64 70 fp, err := s.AddKey("keenan", line, "test")
@@ -68,24 +74,18 @@ func TestCreateUserAndAddKey(t *testing.T) {
68 74 if fp != wantFP {
69 75 t.Fatalf("fingerprint = %s, want %s", fp, wantFP)
70 76 }
71 if name, ok := s.Lookup(fp); !ok || name != "keenan" {
72 t.Fatalf("Lookup = %q,%v", name, ok)
73 }
74 data, err := os.ReadFile(filepath.Join(s.Dir, "keenan.keys"))
75 if err != nil {
76 t.Fatal(err)
77 }
78 if !strings.Contains(string(data), line) {
79 t.Fatalf("key not written verbatim:\n%s", data)
77 accountID, name, ok := s.Lookup(fp)
78 if !ok || accountID != a.ID || name != "keenan" {
79 t.Fatalf("Lookup = %q,%q,%v; want %q,keenan,true", accountID, name, ok, a.ID)
80 80 }
81 if !strings.HasPrefix(string(data), "#") {
82 t.Fatal("file has no explanatory header")
81 if got, ok := s.ResolveID("keenan"); !ok || got != a.ID {
82 t.Fatalf("ResolveID = %q,%v", got, ok)
83 83 }
84 84 }
85 85
86 func TestMultipleKeysPerUser(t *testing.T) {
86 func TestMultipleKeysPerAccount(t *testing.T) {
87 87 s := newStore(t)
88 if err := s.Create("keenan"); err != nil {
88 if _, err := s.Create("keenan", "test"); err != nil {
89 89 t.Fatal(err)
90 90 }
91 91 var fps []string
@@ -96,27 +96,27 @@ func TestMultipleKeysPerUser(t *testing.T) {
96 96 }
97 97 fps = append(fps, fp)
98 98 }
99 if got := len(s.Snapshot().Users["keenan"].Keys); got != 3 {
100 t.Fatalf("keys = %d, want 3", got)
101 }
102 reopened, err := Open(s.Dir)
99 reopened, err := Open(s.Dir())
103 100 if err != nil {
104 101 t.Fatal(err)
105 102 }
106 if got := len(reopened.Snapshot().Users["keenan"].Keys); got != 3 {
103 if got := len(reopened.Snapshot().ByName["keenan"].Keys); got != 3 {
107 104 t.Fatalf("keys after reopen = %d, want 3", got)
108 105 }
106 if got := len(s.Snapshot().ByName["keenan"].Keys); got != 3 {
107 t.Fatalf("keys = %d, want 3", got)
108 }
109 109 for _, fp := range fps {
110 if _, ok := reopened.Lookup(fp); !ok {
111 t.Fatalf("key %s lost across reopen", fp)
110 if _, _, ok := s.Lookup(fp); !ok {
111 t.Fatalf("key %s lost", fp)
112 112 }
113 113 }
114 114 }
115 115
116 func TestDuplicateKeyAcrossUsersRejected(t *testing.T) {
116 func TestDuplicateKeyAcrossAccountsRejected(t *testing.T) {
117 117 s := newStore(t)
118 118 for _, n := range []string{"alice", "bob"} {
119 if err := s.Create(n); err != nil {
119 if _, err := s.Create(n, "test"); err != nil {
120 120 t.Fatal(err)
121 121 }
122 122 }
@@ -125,37 +125,45 @@ func TestDuplicateKeyAcrossUsersRejected(t *testing.T) {
125 125 t.Fatal(err)
126 126 }
127 127 if _, err := s.AddKey("bob", line, "test"); err == nil {
128 t.Fatal("same key accepted for a second user")
128 t.Fatal("same key accepted for a second account")
129 129 } else if !strings.Contains(err.Error(), "already registered") {
130 130 t.Fatalf("unclear error: %v", err)
131 131 }
132 132 }
133 133
134 // TestDuplicateKeyOnDiskFailsStartup covers a hand-edited directory: two files
135 // claiming the same key is ambiguous, so the roster must refuse to load.
136 func TestDuplicateKeyOnDiskFailsStartup(t *testing.T) {
134 // TestDuplicateKeyInGitFailsStartup covers a hand-written commit: two accounts
135 // claiming one key is ambiguous, so the roster must refuse to load.
136 func TestDuplicateKeyInGitFailsStartup(t *testing.T) {
137 137 s := newStore(t)
138 for _, n := range []string{"alice", "bob"} {
139 if err := s.Create(n); err != nil {
140 t.Fatal(err)
141 }
138 if _, err := s.Create("alice", "test"); err != nil {
139 t.Fatal(err)
142 140 }
143 141 line, _ := pubLine(t)
144 142 if _, err := s.AddKey("alice", line, "test"); err != nil {
145 143 t.Fatal(err)
146 144 }
147 // Hand-copy the same key into bob's file, bypassing AddKey.
148 if err := os.WriteFile(filepath.Join(s.Dir, "bob.keys"), []byte(line+"\n"), 0o644); err != nil {
145 // Commit a second account by hand, bypassing AddKey.
146 g := gitx.Open(s.Dir())
147 newID, err := id.New(time.Now())
148 if err != nil {
149 t.Fatal(err)
150 }
151 bad := fmt.Sprintf(`{"name":"bob","status":"active","created":"2026-10-22T00:00:00Z","keys":[{"fingerprint":"%s","type":"ssh-ed25519","line":%q}]}`,
152 ssh.FingerprintSHA256(mustParse(t, line)), line)
153 if _, err := gitx.CommitFiles(g, Ref, func(files map[string][]byte) error {
154 files[Dir+"/"+newID+metaName] = []byte(bad)
155 return nil
156 }, "hand-written", "test"); err != nil {
149 157 t.Fatal(err)
150 158 }
151 if _, err := Open(s.Dir); err == nil {
159 if _, err := Open(g.Dir); err == nil {
152 160 t.Fatal("ambiguous roster loaded; it must refuse to start")
153 161 }
154 162 }
155 163
156 164 func TestRemoveKey(t *testing.T) {
157 165 s := newStore(t)
158 if err := s.Create("keenan"); err != nil {
166 if _, err := s.Create("keenan", "test"); err != nil {
159 167 t.Fatal(err)
160 168 }
161 169 keepLine, keepFP := pubLine(t)
@@ -168,77 +176,105 @@ func TestRemoveKey(t *testing.T) {
168 176 if err := s.RemoveKey("keenan", dropFP, "test"); err != nil {
169 177 t.Fatal(err)
170 178 }
171 if _, ok := s.Lookup(dropFP); ok {
179 if _, _, ok := s.Lookup(dropFP); ok {
172 180 t.Fatal("revoked key still authenticates")
173 181 }
174 if _, ok := s.Lookup(keepFP); !ok {
182 if _, _, ok := s.Lookup(keepFP); !ok {
175 183 t.Fatal("removing one key took the other with it")
176 184 }
177 data, err := os.ReadFile(filepath.Join(s.Dir, "keenan.keys"))
178 if err != nil {
179 t.Fatal(err)
180 }
181 if strings.Contains(string(data), dropLine) {
182 t.Fatal("revoked key still on disk")
183 }
184 if !strings.Contains(string(data), keepLine) {
185 t.Fatal("kept key vanished from disk")
186 }
187 185 }
188 186
189 func TestUnparsableLineFailsStartup(t *testing.T) {
187 // TestRetireStopsAuthenticationAndReservesTheName is the property that stops a
188 // new person inheriting a departed one's access.
189 func TestRetireStopsAuthenticationAndReservesTheName(t *testing.T) {
190 190 s := newStore(t)
191 if err := s.Create("keenan"); err != nil {
191 a, err := s.Create("wilbur", "test")
192 if err != nil {
193 t.Fatal(err)
194 }
195 line, fp := pubLine(t)
196 if _, err := s.AddKey("wilbur", line, "test"); err != nil {
192 197 t.Fatal(err)
193 198 }
194 if err := os.WriteFile(filepath.Join(s.Dir, "keenan.keys"), []byte("this is not a key\n"), 0o644); err != nil {
199 if _, err := s.Retire("wilbur", "test"); err != nil {
195 200 t.Fatal(err)
196 201 }
197 if _, err := Open(s.Dir); err == nil {
198 t.Fatal("roster with an unparsable key loaded; it must refuse to start")
202 if _, _, ok := s.Lookup(fp); ok {
203 t.Fatal("a retired account's key still authenticates")
204 }
205 if _, ok := s.ResolveID("wilbur"); ok {
206 t.Fatal("a retired name still resolves")
207 }
208 if _, err := s.Create("wilbur", "test"); err == nil {
209 t.Fatalf("the retired name was reissued to a new account")
210 } else if !strings.Contains(err.Error(), "cannot be reused") {
211 t.Fatalf("unclear error: %v", err)
212 }
213 // The account itself still exists for display and history.
214 if got, err := s.Find(a.ID); err != nil || got.Status != "retired" {
215 t.Fatalf("Find after retire = %v, %v", got, err)
199 216 }
200 217 }
201 218
202 func TestNotesAndBlanksIgnored(t *testing.T) {
219 // TestRenameKeepsTheIdAndReservesBothNames: the id is what ACLs and approvals
220 // hold, so a rename must not disturb it, and neither name may be reused.
221 func TestRenameKeepsTheIdAndReservesBothNames(t *testing.T) {
203 222 s := newStore(t)
223 a, err := s.Create("wilbur", "test")
224 if err != nil {
225 t.Fatal(err)
226 }
204 227 line, fp := pubLine(t)
205 content := "# a note\n\n \n" + line + "\n# trailing note\n"
206 if err := os.WriteFile(filepath.Join(s.Dir, "keenan.keys"), []byte(content), 0o644); err != nil {
228 if _, err := s.AddKey("wilbur", line, "test"); err != nil {
207 229 t.Fatal(err)
208 230 }
209 re, err := Open(s.Dir)
231 renamed, err := s.Rename("wilbur", "robert", "test")
210 232 if err != nil {
211 233 t.Fatal(err)
212 234 }
213 if _, ok := re.Lookup(fp); !ok {
214 t.Fatal("key on a commented file was not loaded")
235 if renamed.ID != a.ID {
236 t.Fatalf("rename changed the id: %s -> %s", a.ID, renamed.ID)
237 }
238 if got, _, ok := s.Lookup(fp); !ok || got != a.ID {
239 t.Fatalf("Lookup after rename = %q,%v; want %s", got, ok, a.ID)
215 240 }
216 if got := len(re.Snapshot().Users["keenan"].Keys); got != 1 {
217 t.Fatalf("keys = %d, want 1", got)
241 if _, ok := s.ResolveID("wilbur"); ok {
242 t.Fatal("the old name still resolves to an active account")
243 }
244 if got, ok := s.ResolveID("robert"); !ok || got != a.ID {
245 t.Fatalf("new name does not resolve: %q,%v", got, ok)
246 }
247 // Both names stay reserved.
248 if _, err := s.Create("wilbur", "test"); err == nil {
249 t.Fatal("the former name was handed to a new account")
250 }
251 other, err := s.Create("dana", "test")
252 if err != nil {
253 t.Fatal(err)
254 }
255 if _, err := s.Rename(other.ID, "wilbur", "test"); err == nil {
256 t.Fatal("another account took the former name")
257 }
258 // The old name still identifies the same account for display.
259 if got, err := s.Find("wilbur"); err != nil || got.ID != a.ID {
260 t.Fatalf("Find(former name) = %v, %v", got, err)
218 261 }
219 262 }
220 263
221 264 func TestInvalidNamesRefused(t *testing.T) {
222 265 s := newStore(t)
223 266 for _, name := range []string{"Keenan", "has space", "a", "with/slash", "trailing."} {
224 if err := s.Create(name); err == nil {
225 t.Fatalf("%q accepted as a username", name)
267 if _, err := s.Create(name, "test"); err == nil {
268 t.Fatalf("%q accepted as a name", name)
226 269 }
227 270 }
228 // A file named with an invalid user must also fail to load.
229 if err := os.WriteFile(filepath.Join(s.Dir, "Bad Name.keys"), []byte("x\n"), 0o644); err != nil {
230 t.Fatal(err)
231 }
232 if _, err := Open(s.Dir); err == nil {
233 t.Fatal("invalid filename in the roster directory was accepted")
234 }
235 271 }
236 272
237 // TestConcurrentAddsAllLand relies on append semantics: no read-modify-write, so
238 // there is no lost-update window.
273 // TestConcurrentAddsAllLand exercises the CAS tree helper: the roster is a git
274 // ref, so concurrent writers must retry rather than lose each other's keys.
239 275 func TestConcurrentAddsAllLand(t *testing.T) {
240 276 s := newStore(t)
241 if err := s.Create("keenan"); err != nil {
277 if _, err := s.Create("keenan", "test"); err != nil {
242 278 t.Fatal(err)
243 279 }
244 280 const n = 8
@@ -261,25 +297,27 @@ func TestConcurrentAddsAllLand(t *testing.T) {
261 297 t.Fatalf("add %d: %v", i, err)
262 298 }
263 299 }
264 final, err := Open(s.Dir)
265 if err != nil {
300 if err := s.Reload(); err != nil {
266 301 t.Fatal(err)
267 302 }
268 if got := len(final.Snapshot().Users["keenan"].Keys); got != n {
303 if got := len(s.Snapshot().ByName["keenan"].Keys); got != n {
269 304 t.Fatalf("after concurrent adds: %d keys, want %d", got, n)
270 305 }
271 306 }
272 307
273 308 func TestAuditLogRecordsChanges(t *testing.T) {
274 309 s := newStore(t)
275 if err := s.Create("keenan"); err != nil {
310 if _, err := s.Create("keenan", "test"); err != nil {
276 311 t.Fatal(err)
277 312 }
278 313 line, fp := pubLine(t)
279 if _, err := s.AddKey("keenan", line, "operator"); err != nil {
314 if _, err := s.AddKey("keenan", line, "test"); err != nil {
280 315 t.Fatal(err)
281 316 }
282 if err := s.RemoveKey("keenan", fp, "operator"); err != nil {
317 if _, err := s.Rename("keenan", "robert", "test"); err != nil {
318 t.Fatal(err)
319 }
320 if err := s.RemoveKey("robert", fp, "test"); err != nil {
283 321 t.Fatal(err)
284 322 }
285 323 data, err := os.ReadFile(s.AuditPath)
@@ -287,24 +325,44 @@ func TestAuditLogRecordsChanges(t *testing.T) {
287 325 t.Fatal(err)
288 326 }
289 327 log := string(data)
290 for _, want := range []string{"identity.create", "keenan", "identity.add-key", "identity.remove-key", fp} {
328 for _, want := range []string{"identity.create", "identity.add-key", "identity.rename", "identity.remove-key", fp} {
291 329 if !strings.Contains(log, want) {
292 330 t.Fatalf("audit log missing %q:\n%s", want, log)
293 331 }
294 332 }
295 if got := strings.Count(log, "\n"); got != 3 {
296 t.Fatalf("audit lines = %d, want 3:\n%s", got, log)
297 }
298 333 }
299 334
300 func TestAuditFailureDoesNotBlockChanges(t *testing.T) {
335 func TestRosterHistoryIsInGit(t *testing.T) {
301 336 s := newStore(t)
302 s.AuditPath = filepath.Join(t.TempDir(), "missing-dir", "audit.log")
303 if err := s.Create("keenan"); err != nil {
304 t.Fatalf("create failed because the audit log was unwritable: %v", err)
337 a, err := s.Create("keenan", "test")
338 if err != nil {
339 t.Fatal(err)
305 340 }
306 line, _ := pubLine(t)
307 if _, err := s.AddKey("keenan", line, "test"); err != nil {
308 t.Fatalf("add-key failed because the audit log was unwritable: %v", err)
341 if _, err := s.Rename(a.ID, "robert", "test"); err != nil {
342 t.Fatal(err)
343 }
344 g := gitx.Open(s.Dir())
345 log, err := g.Log(Ref, 10)
346 if err != nil {
347 t.Fatal(err)
348 }
349 if len(log) < 2 {
350 t.Fatalf("commits = %d, want at least 2", len(log))
351 }
352 // HEAD must be main, or `git log` in the roster shows an unborn branch.
353 if _, err := exec.Command("git", "-C", s.Dir(), "rev-parse", "--abbrev-ref", "HEAD").Output(); err != nil {
354 t.Fatalf("roster HEAD is not resolvable: %v", err)
355 }
356 if !strings.Contains(log[0].Subject, "rename") {
357 t.Fatalf("newest commit does not describe the rename: %q", log[0].Subject)
358 }
359 }
360
361 func mustParse(t *testing.T, line string) ssh.PublicKey {
362 t.Helper()
363 pub, _, _, _, err := ssh.ParseAuthorizedKey([]byte(line))
364 if err != nil {
365 t.Fatal(err)
309 366 }
367 return pub
310 368 }