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.

cmd/vole/main.go +77−26

@@ -150,7 +150,7 @@ func cmdServe(args []string) error {
150 150 defer srv.Close()
151 151
152 152 log.Info("instance key", "fingerprint", srv.Key.Fingerprint())
153 if n := len(srv.Identity.Snapshot().Users); n == 0 {
153 if n := srv.Identity.Snapshot().Count; n == 0 {
154 154 log.Warn("no users yet; add one with: vole user create <name> && vole user add-key <name>")
155 155 }
156 156
@@ -199,7 +199,7 @@ func cmdInit(args []string) error {
199 199 if err := os.MkdirAll(cfg.ReposDir(), 0o755); err != nil {
200 200 return err
201 201 }
202 if err := identity.Init(cfg.TrustedKeysDir()); err != nil {
202 if err := identity.Init(cfg.SystemRepo()); err != nil {
203 203 return err
204 204 }
205 205
@@ -221,7 +221,7 @@ func openStores(cfgPath string) (*stores, error) {
221 221 if err != nil {
222 222 return nil, err
223 223 }
224 ident, err := identity.Open(cfg.TrustedKeysDir())
224 ident, err := identity.Open(cfg.SystemRepo())
225 225 if err != nil {
226 226 return nil, fmt.Errorf("%w\nrun `vole init` first", err)
227 227 }
@@ -250,7 +250,7 @@ func (s *stores) Close() {
250 250
251 251 func cmdUser(args []string) error {
252 252 if len(args) == 0 {
253 return errors.New("usage: vole user create|add-key|rm-key|list")
253 return errors.New("usage: vole user create|add-key|rm-key|rename|retire|list")
254 254 }
255 255 sub := args[0]
256 256 f, err := parseFlags(args[1:], configFlag(), flagSpec{long: "key", short: "k"})
@@ -262,21 +262,24 @@ func cmdUser(args []string) error {
262 262 return err
263 263 }
264 264 defer st.Close()
265 actor := os.Getenv("USER")
265 266
266 267 switch sub {
267 268 case "create":
268 269 if len(f.rest) != 1 {
269 270 return errors.New("usage: vole user create <name>")
270 271 }
271 if err := st.ident.Create(f.rest[0]); err != nil {
272 a, err := st.ident.Create(f.rest[0], actor)
273 if err != nil {
272 274 return err
273 275 }
274 fmt.Printf("created %s (no keys yet: they cannot authenticate until you add one)\n", f.rest[0])
276 fmt.Printf("%s\t%s\n", a.Name, a.ID)
277 fmt.Fprintln(os.Stderr, "no keys yet: this account cannot authenticate until you add one")
275 278 return nil
276 279
277 280 case "add-key":
278 281 if len(f.rest) != 1 {
279 return errors.New("usage: vole user add-key <name> [-key file|-]")
282 return errors.New("usage: vole user add-key --key <file|-> <name|id>")
280 283 }
281 284 src := f.one("key")
282 285 var line []byte
@@ -288,35 +291,62 @@ func cmdUser(args []string) error {
288 291 if err != nil {
289 292 return err
290 293 }
291 actor := os.Getenv("USER")
292 294 fp, err := st.ident.AddKey(f.rest[0], string(line), actor)
293 295 if err != nil {
294 296 return err
295 297 }
296 fmt.Printf("added key %s for %s\n", fp, f.rest[0])
298 a, _ := st.ident.Find(f.rest[0])
299 fmt.Printf("added key %s for %s\n", fp, a.Name)
297 300 return nil
298 301
299 302 case "rm-key":
300 303 if len(f.rest) != 2 {
301 return errors.New("usage: vole user rm-key <name> <fingerprint>")
304 return errors.New("usage: vole user rm-key <name|id> <fingerprint>")
305 }
306 if err := st.ident.RemoveKey(f.rest[0], f.rest[1], actor); err != nil {
307 return err
308 }
309 fmt.Printf("revoked %s\n", f.rest[1])
310 return nil
311
312 case "rename":
313 if len(f.rest) != 2 {
314 return errors.New("usage: vole user rename <name|id> <new-name>")
315 }
316 a, err := st.ident.Rename(f.rest[0], f.rest[1], actor)
317 if err != nil {
318 return err
319 }
320 // Access and approvals follow the id, so nothing else needs rewriting.
321 fmt.Printf("%s\t%s\n", a.Name, a.ID)
322 fmt.Fprintln(os.Stderr, "ACL entries and approvals refer to this id and are unaffected")
323 return nil
324
325 case "retire", "rm", "delete":
326 if len(f.rest) != 1 {
327 return errors.New("usage: vole user retire <name|id>")
302 328 }
303 if err := st.ident.RemoveKey(f.rest[0], f.rest[1], os.Getenv("USER")); err != nil {
329 a, err := st.ident.Retire(f.rest[0], actor)
330 if err != nil {
304 331 return err
305 332 }
306 fmt.Printf("revoked %s from %s\n", f.rest[1], f.rest[0])
333 fmt.Printf("retired %s\t%s\n", a.Name, a.ID)
334 fmt.Fprintln(os.Stderr, "keys stop working within 5s; the name is never reissued")
307 335 return nil
308 336
309 337 case "list":
310 snap := st.ident.Snapshot()
311 names := snap.Names()
312 if len(names) == 0 {
338 all := st.ident.All()
339 if len(all) == 0 {
313 340 fmt.Println("no users")
314 341 return nil
315 342 }
316 for _, n := range names {
317 u := snap.Users[n]
318 fmt.Printf("%-16s keys=%d\n", n, len(u.Keys))
319 for _, k := range u.Keys {
343 for _, a := range all {
344 status := a.Status
345 if len(a.Former) > 0 {
346 status += ", formerly " + strings.Join(a.Former, ", ")
347 }
348 fmt.Printf("%-16s %s %-28s keys=%d\n", a.Name, a.ID, status, len(a.Keys))
349 for _, k := range a.Keys {
320 350 fmt.Printf(" %s %s %s\n", k.Fingerprint, k.Type, k.Comment)
321 351 }
322 352 }
@@ -351,12 +381,18 @@ func cmdRepo(args []string) error {
351 381 }
352 382 owner := f.one("owner")
353 383 if owner == "" {
354 return errors.New("-owner is required: it becomes the repository's first admin")
384 return errors.New("--owner is required: it becomes the repository's first admin")
385 }
386 // ACLs are keyed by account id, so the owner is resolved once here and the
387 // id is what gets stored; renaming the owner later changes nothing.
388 ownerAccount, err := st.ident.Find(owner)
389 if err != nil {
390 return err
355 391 }
356 if _, ok := st.ident.Snapshot().Users[owner]; !ok {
357 return fmt.Errorf("owner %q: %w", owner, errors.New("no such user"))
392 if !ownerAccount.Active() {
393 return fmt.Errorf("owner %q is retired", ownerAccount.Name)
358 394 }
359 m, err := st.repos.Create(f.rest[0], owner, f.one("description"))
395 m, err := st.repos.Create(f.rest[0], ownerAccount.ID, f.one("description"))
360 396 if err != nil {
361 397 return err
362 398 }
@@ -455,7 +491,13 @@ func cmdACL(args []string) error {
455 491 fmt.Printf("repo: %s (%s)\n", m.Name, vis)
456 492 fmt.Printf("default: %s\n", a.Default)
457 493 for _, p := range a.Principals() {
458 fmt.Printf(" %-24s %s\n", p, a.Entries[p])
494 // Entries are account ids; render the current name beside them so a
495 // rename is visible without decoding ULIDs by hand.
496 name := p
497 if a2, err := st.ident.Find(p); err == nil {
498 name = a2.Name
499 }
500 fmt.Printf(" %-16s %-28s %s\n", name, p, a.Entries[p])
459 501 }
460 502 if len(a.Protected) > 0 {
461 503 fmt.Printf("protected: %s\n", strings.Join(a.Protected, ", "))
@@ -466,14 +508,21 @@ func cmdACL(args []string) error {
466 508 if len(f.rest) != 3 {
467 509 return errors.New("usage: vole acl set <repo> <user> <none|read|write|admin>")
468 510 }
469 if err := a.SetEntry(f.rest[1], f.rest[2]); err != nil {
511 if _, err := acl.ParseRole(f.rest[2]); err != nil {
512 return err
513 }
514 account, err := st.ident.Find(f.rest[1])
515 if err != nil {
516 return err
517 }
518 if err := a.SetEntry(account.ID, strings.ToLower(f.rest[2])); err != nil {
470 519 return err
471 520 }
472 521 if err := m.SetACL(a, os.Getenv("USER")); err != nil {
473 522 return err
474 523 }
475 524 _ = st.index.Upsert(m)
476 fmt.Printf("%s: %s\n", f.rest[1], strings.ToLower(f.rest[2]))
525 fmt.Printf("%s: %s\n", account.Name, strings.ToLower(f.rest[2]))
477 526 return nil
478 527
479 528 case "default":
@@ -574,8 +623,10 @@ func cmdHook(args []string) error {
574 623 code := hook.PreReceive(hook.Config{
575 624 RepoDir: dir,
576 625 Actor: os.Getenv("VOLE_ACTOR"),
626 ActorID: os.Getenv("VOLE_ACTOR_ID"),
577 627 MaxBlobBytes: maxBlob,
578 628 AuditPath: os.Getenv("VOLE_AUDIT"),
629 SystemRepo: os.Getenv("VOLE_SYSTEM_REPO"),
579 630 In: os.Stdin,
580 631 Out: os.Stderr,
581 632 })