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/hook/hook.go +58−13
| @@ -13,6 +13,7 @@ import ( | |||
| 13 | 13 | ||
| 14 | 14 | "github.com/rodent-software/vole/internal/acl" | |
| 15 | 15 | "github.com/rodent-software/vole/internal/gitx" | |
| 16 | "github.com/rodent-software/vole/internal/identity" | ||
| 16 | 17 | "github.com/rodent-software/vole/internal/issues" | |
| 17 | 18 | "github.com/rodent-software/vole/internal/repo" | |
| 18 | 19 | "github.com/rodent-software/vole/internal/requests" | |
| @@ -22,6 +23,12 @@ type Config struct { | |||
| 22 | 23 | RepoDir string | |
| 23 | 24 | Actor string | |
| 24 | 25 | MaxBlobBytes int64 | |
| 26 | // ActorID is the account id resolved at the SSH handshake. ACL entries and | ||
| 27 | // approvals are keyed by it. | ||
| 28 | ActorID string | ||
| 29 | // SystemRepo, when set, is the roster repository, used only to render account | ||
| 30 | // ids as names in messages. | ||
| 31 | SystemRepo string | ||
| 25 | 32 | // AuditPath receives lines about privileged metadata operations. Empty means | |
| 26 | 33 | // no audit sink, which is only acceptable for tests. | |
| 27 | 34 | AuditPath string | |
| @@ -41,7 +48,9 @@ func PreReceive(cfg Config) int { | |||
| 41 | 48 | if cfg.In == nil { | |
| 42 | 49 | cfg.In = strings.NewReader("") | |
| 43 | 50 | } | |
| 44 | m := &repo.Meta{Name: filepath.Base(cfg.RepoDir), Dir: cfg.RepoDir} | ||
| 51 | // Clean first: receive-pack passes a path with a trailing separator, and | ||
| 52 | // filepath.Base("/x/y.git/") is ".". | ||
| 53 | m := &repo.Meta{Name: filepath.Base(filepath.Clean(cfg.RepoDir)), Dir: cfg.RepoDir} | ||
| 45 | 54 | a, err := m.ACL() | |
| 46 | 55 | if err != nil { | |
| 47 | 56 | reject(cfg.Out, "cannot read repository access rules: %v", err) | |
| @@ -49,11 +58,22 @@ func PreReceive(cfg Config) int { | |||
| 49 | 58 | } | |
| 50 | 59 | ||
| 51 | 60 | actor := strings.TrimSpace(cfg.Actor) | |
| 61 | // The account id is the authorization identity; the name is only for | ||
| 62 | // messages. ACL entries and approval files are keyed by id, so a rename | ||
| 63 | // changes nothing about what a person may do. | ||
| 64 | actorID := strings.TrimSpace(cfg.ActorID) | ||
| 65 | if actorID == "" { | ||
| 66 | actorID = actor // tolerate callers that only set a name (tests, manual runs) | ||
| 67 | } | ||
| 52 | 68 | if actor == "" { | |
| 53 | 69 | reject(cfg.Out, "pushes must be authenticated; no user was supplied") | |
| 54 | 70 | return 1 | |
| 55 | 71 | } | |
| 56 | role := a.RoleFor(actor) | ||
| 72 | // Names are a courtesy for the person reading the rejection; ids decide. | ||
| 73 | names := openNames(cfg.SystemRepo) | ||
| 74 | who := func(accountID string) string { return names(accountID) } | ||
| 75 | |||
| 76 | role := a.RoleFor(actorID) | ||
| 57 | 77 | if !role.AtLeast(acl.Write) { | |
| 58 | 78 | reject(cfg.Out, "%s does not have write access to %s (role: %s)", actor, m.Name, role) | |
| 59 | 79 | return 1 | |
| @@ -69,6 +89,17 @@ func PreReceive(cfg Config) int { | |||
| 69 | 89 | } | |
| 70 | 90 | ||
| 71 | 91 | g := gitx.Open(cfg.RepoDir) | |
| 92 | // receive-pack reveals the pushed objects through a quarantine: it points | ||
| 93 | // GIT_OBJECT_DIRECTORY at the temporary object directory and lists the real | ||
| 94 | // one in the alternates. gitx scrubs those variables so commands against other | ||
| 95 | // repositories are not redirected, so they are restored here for this one. | ||
| 96 | if os.Getenv("GIT_QUARANTINE_PATH") != "" { | ||
| 97 | for _, k := range []string{"GIT_QUARANTINE_PATH", "GIT_OBJECT_DIRECTORY", "GIT_ALTERNATE_OBJECT_DIRECTORIES"} { | ||
| 98 | if v := os.Getenv(k); v != "" { | ||
| 99 | g.ExtraEnv = append(g.ExtraEnv, k+"="+v) | ||
| 100 | } | ||
| 101 | } | ||
| 102 | } | ||
| 72 | 103 | var problems []string | |
| 73 | 104 | for _, c := range changes { | |
| 74 | 105 | // Metadata refs belong to the server. Letting a client write refs/vole/* | |
| @@ -161,9 +192,10 @@ func PreReceive(cfg Config) int { | |||
| 161 | 192 | problems = append(problems, "requests: "+prob) | |
| 162 | 193 | } | |
| 163 | 194 | // An approval is a statement about the actor's own review, so the file | |
| 164 | // name must be the actor's name. Without this, anyone with write access | ||
| 165 | // could commit approvals/<someone-else>.json and approve on their behalf. | ||
| 166 | for _, prob := range approvalOwnership(g, c, actor, role, cfg.AuditPath) { | ||
| 195 | // name must be the actor's account id. Without this, anyone with write | ||
| 196 | // access could commit approvals/<someone-else>.json and approve on their | ||
| 197 | // behalf. | ||
| 198 | for _, prob := range approvalOwnership(g, c, actor, actorID, role, cfg.AuditPath, who) { | ||
| 167 | 199 | problems = append(problems, prob) | |
| 168 | 200 | } | |
| 169 | 201 | } | |
| @@ -233,7 +265,7 @@ func readChanges(r io.Reader) ([]change, error) { | |||
| 233 | 265 | // Every write counts: added, modified, renamed, retyped. Checking only additions | |
| 234 | 266 | // is not enough, because a file that already exists can simply be edited to name | |
| 235 | 267 | // a different commit. | |
| 236 | func approvalOwnership(g *gitx.Repo, c change, actor string, role acl.Role, auditPath string) []string { | ||
| 268 | func approvalOwnership(g *gitx.Repo, c change, actor, actorID string, role acl.Role, auditPath string, who func(string) string) []string { | ||
| 237 | 269 | // On the first push the branch has no old commit, so every approval in the | |
| 238 | 270 | // tree is new and must be checked. Skipping that would let whoever creates the | |
| 239 | 271 | // branch seed approvals in other people's names. | |
| @@ -244,10 +276,9 @@ func approvalOwnership(g *gitx.Repo, c change, actor string, role acl.Role, audi | |||
| 244 | 276 | } | |
| 245 | 277 | var problems []string | |
| 246 | 278 | for _, b := range blobs { | |
| 247 | if owner := requests.ApprovalPath(b.Name); owner != "" && owner != actor { | ||
| 279 | if owner := requests.ApprovalPath(b.Name); owner != "" && owner != actorID { | ||
| 248 | 280 | problems = append(problems, fmt.Sprintf( | |
| 249 | "requests: %s: %s may not create an approval in %s's name; only %s can approve as %s", | ||
| 250 | b.Name, actor, owner, owner, owner)) | ||
| 281 | "requests: %s: %s may not create an approval for %s", b.Name, actor, who(owner))) | ||
| 251 | 282 | } | |
| 252 | 283 | } | |
| 253 | 284 | return problems | |
| @@ -260,7 +291,7 @@ func approvalOwnership(g *gitx.Repo, c change, actor string, role acl.Role, audi | |||
| 260 | 291 | var problems []string | |
| 261 | 292 | for path, st := range status { | |
| 262 | 293 | owner := requests.ApprovalPath(path) | |
| 263 | if owner == "" || owner == actor { | ||
| 294 | if owner == "" || owner == actorID { | ||
| 264 | 295 | continue | |
| 265 | 296 | } | |
| 266 | 297 | if st == "D" { | |
| @@ -271,19 +302,33 @@ func approvalOwnership(g *gitx.Repo, c change, actor string, role acl.Role, audi | |||
| 271 | 302 | if !role.AtLeast(acl.Admin) { | |
| 272 | 303 | problems = append(problems, fmt.Sprintf( | |
| 273 | 304 | "requests: %s: %s may not delete %s's approval; only %s can withdraw it", | |
| 274 | path, actor, owner, owner)) | ||
| 305 | path, actor, who(owner), who(owner))) | ||
| 275 | 306 | } else { | |
| 276 | 307 | auditLine(auditPath, fmt.Sprintf("approval-removed %s by admin %s", path, actor)) | |
| 277 | 308 | } | |
| 278 | 309 | continue | |
| 279 | 310 | } | |
| 280 | 311 | problems = append(problems, fmt.Sprintf( | |
| 281 | "requests: %s: %s may not add or change %s's approval; only %s can approve as %s", | ||
| 282 | path, actor, owner, owner, owner)) | ||
| 312 | "requests: %s: %s may not add or change %s's approval; only that account can approve", | ||
| 313 | path, actor, who(owner))) | ||
| 283 | 314 | } | |
| 284 | 315 | return problems | |
| 285 | 316 | } | |
| 286 | 317 | ||
| 318 | // openNames returns a function rendering account ids as names, falling back to | ||
| 319 | // the id when the roster is unavailable. Display only: nothing is authorized by | ||
| 320 | // the result. | ||
| 321 | func openNames(systemRepo string) func(string) string { | ||
| 322 | if systemRepo == "" { | ||
| 323 | return func(accountID string) string { return accountID } | ||
| 324 | } | ||
| 325 | store, err := identity.Open(systemRepo) | ||
| 326 | if err != nil { | ||
| 327 | return func(accountID string) string { return accountID } | ||
| 328 | } | ||
| 329 | return store.Name | ||
| 330 | } | ||
| 331 | |||
| 287 | 332 | // auditLine is best-effort: failing to write the log must not change policy. | |
| 288 | 333 | func auditLine(path, line string) { | |
| 289 | 334 | if path == "" { | |