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.

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 == "" {