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/gitx/gitx.go +41−1
| @@ -25,6 +25,11 @@ type Entry struct { | |||
| 25 | 25 | ||
| 26 | 26 | type Repo struct { | |
| 27 | 27 | Dir string | |
| 28 | // ExtraEnv is appended to every git invocation. It exists for the one case | ||
| 29 | // where a repository-scoped variable must survive the scrub below: a hook | ||
| 30 | // inspecting the very repository being pushed, whose new objects are only | ||
| 31 | // visible through GIT_QUARANTINE_PATH. | ||
| 32 | ExtraEnv []string | ||
| 28 | 33 | } | |
| 29 | 34 | ||
| 30 | 35 | func Open(dir string) *Repo { return &Repo{Dir: dir} } | |
| @@ -38,12 +43,13 @@ func (r *Repo) run(stdin []byte, args ...string) ([]byte, string, error) { | |||
| 38 | 43 | // for a duplicated key, which is what makes the override reliable. | |
| 39 | 44 | func (r *Repo) runEnv(extra []string, stdin []byte, args ...string) ([]byte, string, error) { | |
| 40 | 45 | cmd := exec.Command("git", args...) | |
| 41 | cmd.Env = append(os.Environ(), | ||
| 46 | cmd.Env = append(scrubbedEnv(), | ||
| 42 | 47 | "GIT_DIR="+r.Dir, | |
| 43 | 48 | "GIT_TERMINAL_PROMPT=0", | |
| 44 | 49 | "GIT_AUTHOR_NAME=vole", "GIT_AUTHOR_EMAIL=vole@localhost", | |
| 45 | 50 | "GIT_COMMITTER_NAME=vole", "GIT_COMMITTER_EMAIL=vole@localhost", | |
| 46 | 51 | ) | |
| 52 | cmd.Env = append(cmd.Env, r.ExtraEnv...) | ||
| 47 | 53 | cmd.Env = append(cmd.Env, extra...) | |
| 48 | 54 | if stdin != nil { | |
| 49 | 55 | cmd.Stdin = bytes.NewReader(stdin) | |
| @@ -328,6 +334,40 @@ type Commit struct { | |||
| 328 | 334 | Subject string | |
| 329 | 335 | } | |
| 330 | 336 | ||
| 337 | // repositoryEnv lists the variables git sets to point at a specific repository. | ||
| 338 | // They are inherited by hooks: pre-receive runs with GIT_QUARANTINE_PATH and | ||
| 339 | // friends set for the repository being pushed, so a hook that shells out to git | ||
| 340 | // for a *different* repository (the roster, for instance) would silently read the | ||
| 341 | // pushed one instead. Every command we run names its repository explicitly, so | ||
| 342 | // these are always dropped. | ||
| 343 | var repositoryEnv = map[string]bool{ | ||
| 344 | "GIT_DIR": true, | ||
| 345 | "GIT_WORK_TREE": true, | ||
| 346 | "GIT_COMMON_DIR": true, | ||
| 347 | "GIT_INDEX_FILE": true, | ||
| 348 | "GIT_OBJECT_DIRECTORY": true, | ||
| 349 | "GIT_ALTERNATE_OBJECT_DIRECTORIES": true, | ||
| 350 | "GIT_QUARANTINE_PATH": true, | ||
| 351 | "GIT_NAMESPACE": true, | ||
| 352 | "GIT_PREFIX": true, | ||
| 353 | } | ||
| 354 | |||
| 355 | func scrubbedEnv() []string { | ||
| 356 | env := os.Environ() | ||
| 357 | out := make([]string, 0, len(env)) | ||
| 358 | for _, kv := range env { | ||
| 359 | name := kv | ||
| 360 | if i := strings.IndexByte(kv, '='); i >= 0 { | ||
| 361 | name = kv[:i] | ||
| 362 | } | ||
| 363 | if repositoryEnv[name] { | ||
| 364 | continue | ||
| 365 | } | ||
| 366 | out = append(out, kv) | ||
| 367 | } | ||
| 368 | return out | ||
| 369 | } | ||
| 370 | |||
| 331 | 371 | // Log returns up to n commits from rev. | |
| 332 | 372 | func (r *Repo) Log(rev string, n int) ([]Commit, error) { | |
| 333 | 373 | out, errs, err := r.run(nil, "log", "--format=%H%x1f%an%x1f%ae%x1f%at%x1f%s", | |