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 | }) | |