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.

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.