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.
Name branches after the account, and labels after the display
474a812d199a109ba894124cd6f26be12c73285d · Keenan Nemetz · 15d ago · parent ade6107e
Name branches after the account, and labels after the display
A contributor's folder was `refs/heads/contrib/<account-id>/`, which told a
reader nothing without decoding a ULID. It is now the account name,
refs/heads/contrib/<name>/<branch>
and the name is therefore fixed at creation: renaming an account would strand
every ref under its old folder, so `vole admin user rename` is gone and the
users row itself — kept after retirement — is what stops a name being reissued.
What people see is now a separate, mutable display name, validated by the same
rules wherever it is set (CLI, remote admin, registration, `/account`) and shown
on pages, approvals, listings and refusals. Nothing is authorized by it and
nothing on disk is keyed by it: metadata authors stay account names because the
push boundary resolves them for ownership, and approvals stay account ids.
`user_names`, `Snapshot.Reserved` and `Account.Former` are dropped: with a fixed
name there are no former names to remember, and the UNIQUE column already does
the reserving. Opening an older roster adds the `display` column and drops the
alias table in place; contributor refs written under the old id namespace are
left where they are, readable, and landable by a maintainer.
server/hook/hook.go +28−27
| @@ -105,9 +105,10 @@ func PreReceive(cfg Config) int { | |||
| 105 | 105 | } | |
| 106 | 106 | ||
| 107 | 107 | actor := strings.TrimSpace(cfg.Actor) | |
| 108 | // The account id is the authorization identity; the name is only for | ||
| 109 | // messages. ACL entries and approval files are keyed by id, so a rename | ||
| 110 | // changes nothing about what a person may do. | ||
| 108 | // The account id is the authorization identity; the name is what contributor | ||
| 109 | // branches are named after and what messages show. Both come from the SSH key | ||
| 110 | // the server resolved, never from the client. ACL entries and approval files | ||
| 111 | // are keyed by id, so the two answer different questions about the same person. | ||
| 111 | 112 | actorID := strings.TrimSpace(cfg.ActorID) | |
| 112 | 113 | if actorID == "" { | |
| 113 | 114 | actorID = actor // tolerate callers that only set a name (tests, manual runs) | |
| @@ -116,9 +117,9 @@ func PreReceive(cfg Config) int { | |||
| 116 | 117 | reject(cfg.Out, "pushes must be authenticated; no user was supplied") | |
| 117 | 118 | return 1 | |
| 118 | 119 | } | |
| 119 | // Names are a courtesy for the person reading the rejection; ids decide. | ||
| 120 | names := openNames(cfg.UsersDB) | ||
| 121 | who := func(accountID string) string { return names(accountID) } | ||
| 120 | // Labels are a courtesy for the person reading the rejection; ids decide. | ||
| 121 | labels := openLabels(cfg.UsersDB) | ||
| 122 | who := func(accountID string) string { return labels(accountID) } | ||
| 122 | 123 | ||
| 123 | 124 | role := a.RoleFor(actorID) | |
| 124 | 125 | if !role.AtLeast(access.Contributor) { | |
| @@ -158,14 +159,16 @@ func PreReceive(cfg Config) int { | |||
| 158 | 159 | "%s: only refs/heads/* and refs/tags/* may be pushed", c.ref)) | |
| 159 | 160 | continue | |
| 160 | 161 | } | |
| 161 | // A contributor gets one branch namespace, named after the account id. | ||
| 162 | // Everything else — metadata, shared branches, tags — belongs to write or | ||
| 163 | // admin access. The check is here rather than at the role gate because the | ||
| 164 | // gate only knows that the account may push something. | ||
| 165 | if !role.AtLeast(access.Maintain) && !access.IsContributorRef(actorID, c.ref) { | ||
| 162 | // A contributor gets one branch namespace, named after the account name. | ||
| 163 | // The name is fixed at creation and never reissued, so it identifies the | ||
| 164 | // owner without a lookup and no rename can strand a branch. Everything | ||
| 165 | // else — metadata, shared branches, tags — belongs to write or admin | ||
| 166 | // access. The check is here rather than at the role gate because the gate | ||
| 167 | // only knows that the account may push something. | ||
| 168 | if !role.AtLeast(access.Maintain) && !access.IsContributorRef(actor, c.ref) { | ||
| 166 | 169 | problems = append(problems, fmt.Sprintf( | |
| 167 | 170 | "%s: contributors may only push branches under %s (role: %s)", | |
| 168 | c.ref, access.ContributorBranchPrefix(actorID), role)) | ||
| 171 | c.ref, access.ContributorBranchPrefix(actor), role)) | ||
| 169 | 172 | continue | |
| 170 | 173 | } | |
| 171 | 174 | // The metadata branch is append-only in shape: linear history, no merge | |
| @@ -191,7 +194,7 @@ func PreReceive(cfg Config) int { | |||
| 191 | 194 | case role.AtLeast(access.Admin): | |
| 192 | 195 | auditLine(cfg.AuditPath, fmt.Sprintf("force-push %s %s -> %s by %s in %s", | |
| 193 | 196 | c.ref, format.SHA(c.old), format.SHA(c.new), actor, m.Name)) | |
| 194 | case role == access.Contributor && access.IsContributorRef(actorID, c.ref): | ||
| 197 | case role == access.Contributor && access.IsContributorRef(actor, c.ref): | ||
| 195 | 198 | // The namespace is theirs: rebasing or deleting their own branch is | |
| 196 | 199 | // not a rewrite of shared history. | |
| 197 | 200 | default: | |
| @@ -316,7 +319,7 @@ func PreReceive(cfg Config) int { | |||
| 316 | 319 | // Approvals say a person read the change; checks say it works. Both are | |
| 317 | 320 | // required unless the repository turns checks off. | |
| 318 | 321 | if a.RequiresChecks() { | |
| 319 | gaps, err := checkGaps(g, cfg.RunsDB, repoNameFromDir(cfg.RepoDir), m.Request.Head, branch, a, names) | ||
| 322 | gaps, err := checkGaps(g, cfg.RunsDB, repoNameFromDir(cfg.RepoDir), m.Request.Head, branch, a, who) | ||
| 320 | 323 | if err != nil { | |
| 321 | 324 | problems = append(problems, fmt.Sprintf("%s: cannot verify required checks: %v", c.ref, err)) | |
| 322 | 325 | continue | |
| @@ -470,8 +473,7 @@ func approvalOwnership(g *git.Repo, c change, actor, actorID string, role access | |||
| 470 | 473 | } | |
| 471 | 474 | ||
| 472 | 475 | // commentOwnership enforces that a comment is changed or removed by the account | |
| 473 | // whose words it is. | ||
| 474 | // | ||
| 476 | // whose words it is.// | ||
| 475 | 477 | // The author of a comment is a name in a file, and a name is not authentication. | |
| 476 | 478 | // What makes this check sound is *which* file it reads: the old blob was | |
| 477 | 479 | // validated when it landed, whereas the new one is whatever the pusher wrote. | |
| @@ -561,20 +563,19 @@ func commentOwnership(g *git.Repo, c change, actor, actorID string, role access. | |||
| 561 | 563 | return problems | |
| 562 | 564 | } | |
| 563 | 565 | ||
| 564 | // ownsAuthor reports whether the name on a file is the pushing account. Names are | ||
| 565 | // never reissued, so a former name still resolves to the same account. | ||
| 566 | // ownsAuthor reports whether the name on a file is the pushing account. An | ||
| 567 | // account name is fixed at creation and never reissued, so the name written into | ||
| 568 | // a comment stays that account's for as long as the file exists. | ||
| 566 | 569 | // | |
| 567 | 570 | // Without a roster the name the handshake supplied is all there is, and the | |
| 568 | // comparison fails closed: after a rename that refuses the edit instead of | ||
| 569 | // opening it to anyone. | ||
| 571 | // comparison fails closed. | ||
| 570 | 572 | func ownsAuthor(roster *identity.Store, author, actor, actorID string) bool { | |
| 571 | 573 | if roster != nil { | |
| 572 | 574 | if id, ok := roster.ResolveID(author); ok { | |
| 573 | 575 | return id == actorID | |
| 574 | 576 | } | |
| 575 | 577 | // ResolveID covers active names; Find also covers retired ones, so an | |
| 576 | // account editing a comment it wrote before it renamed or retired still | ||
| 577 | // owns it. | ||
| 578 | // account editing a comment it wrote before it retired still owns it. | ||
| 578 | 579 | if a, err := roster.Find(author); err == nil { | |
| 579 | 580 | return a.ID == actorID | |
| 580 | 581 | } | |
| @@ -622,10 +623,10 @@ func repoNameFromDir(dir string) string { | |||
| 622 | 623 | return strings.TrimSuffix(filepath.Base(abs), ".git") | |
| 623 | 624 | } | |
| 624 | 625 | ||
| 625 | // openNames returns a function rendering account ids as names, falling back to | ||
| 626 | // the id when the roster is unavailable. Display only: nothing is authorized by | ||
| 627 | // the result. | ||
| 628 | func openNames(usersDB string) func(string) string { | ||
| 626 | // openLabels returns a function rendering account ids as display labels, falling | ||
| 627 | // back to the id when the roster is unavailable. Display only: nothing is | ||
| 628 | // authorized by the result. | ||
| 629 | func openLabels(usersDB string) func(string) string { | ||
| 629 | 630 | if usersDB == "" { | |
| 630 | 631 | return func(accountID string) string { return accountID } | |
| 631 | 632 | } | |
| @@ -633,7 +634,7 @@ func openNames(usersDB string) func(string) string { | |||
| 633 | 634 | if err != nil { | |
| 634 | 635 | return func(accountID string) string { return accountID } | |
| 635 | 636 | } | |
| 636 | return store.Name | ||
| 637 | return store.Label | ||
| 637 | 638 | } | |
| 638 | 639 | ||
| 639 | 640 | // auditLine is best-effort: failing to write the log must not change policy. | |