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.

Identify accounts by their registered name

62576fa8157d431dcf2d360f3ebe481de77bda03 · Keenan Nemetz · 14d ago · parent 642e5994

Identify accounts by their registered name

Drop the internal account id and display name. Users, keys, passkeys,
invites, sessions, ACL entries, approvals, locks and CI records now all
use the registered name as the single identity.

The roster migrates in place: old id-keyed tables are rebuilt around the
name, and access-rule/lock references are rewritten through the same
mapping. Display-name editing and whoami --id are removed.

server/hook/hook.go +23−97

@@ -16,7 +16,6 @@ import (
16 16 "github.com/rodent-software/vole/core/git"
17 17 "github.com/rodent-software/vole/core/lfs"
18 18 "github.com/rodent-software/vole/forge/access"
19 "github.com/rodent-software/vole/forge/identity"
20 19 "github.com/rodent-software/vole/forge/repo"
21 20 "github.com/rodent-software/vole/internal/format"
22 21 "github.com/rodent-software/vole/metadata"
@@ -26,11 +25,8 @@ type Config struct {
26 25 RepoDir string
27 26 Actor string
28 27 MaxBlobBytes int64
29 // ActorID is the account id resolved at the SSH handshake. ACL entries and
30 // approvals are keyed by it.
31 ActorID string
32 // UsersDB, when set, is the roster database, used only to render account ids
33 // as names in messages.
28 // UsersDB, when set, is the roster database, read here for access rules and
29 // file locks. Attribution itself is the Actor name resolved at the handshake.
34 30 UsersDB string
35 31 // SpoolDir receives one file per accepted ref update, for the CI queue.
36 32 SpoolDir string
@@ -104,24 +100,16 @@ func PreReceive(cfg Config) int {
104 100 return 1
105 101 }
106 102
103 // The account name is the authorization identity: it comes from the SSH key
104 // the server resolved, never from the client, and every ACL entry, approval
105 // and record is keyed by it.
107 106 actor := strings.TrimSpace(cfg.Actor)
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.
112 actorID := strings.TrimSpace(cfg.ActorID)
113 if actorID == "" {
114 actorID = actor // tolerate callers that only set a name (tests, manual runs)
115 }
116 107 if actor == "" {
117 108 reject(cfg.Out, "pushes must be authenticated; no user was supplied")
118 109 return 1
119 110 }
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) }
123 111
124 role := a.RoleFor(actorID)
112 role := a.RoleFor(actor)
125 113 if !role.AtLeast(access.Contributor) {
126 114 reject(cfg.Out, "%s does not have maintain access or contributor access to %s (role: %s)", actor, m.Name, role)
127 115 return 1
@@ -247,7 +235,7 @@ func PreReceive(cfg Config) int {
247 235 held, err := lockTable.Paths(m.ID)
248 236 if err != nil {
249 237 problems = append(problems, fmt.Sprintf("%s: cannot read the lock table: %v", c.ref, err))
250 } else if lockProblems, err := lockedPathFindings(g, c, held, actorID, who); err != nil {
238 } else if lockProblems, err := lockedPathFindings(g, c, held, actor); err != nil {
251 239 problems = append(problems, fmt.Sprintf("%s: cannot check locks: %v", c.ref, err))
252 240 } else if len(lockProblems) > 0 {
253 241 if role.AtLeast(access.Admin) {
@@ -312,14 +300,14 @@ func PreReceive(cfg Config) int {
312 300 if m == nil {
313 301 problems = append(problems, fmt.Sprintf(
314 302 "%s is protected: %s needs %d approval(s) on an open change request whose head is exactly %s. "+
315 "Add requests/<ulid>/request.md with base=%q and head=%s, then have an approver commit approvals/<their-account-id>.md naming that same head.",
303 "Add requests/<ulid>/request.md with base=%q and head=%s, then have an approver commit approvals/<their-name>.md naming that same head.",
316 304 c.ref, actor, a.RequiredApprovals(), format.SHA(c.new), branch, format.SHA(c.new)))
317 305 continue
318 306 }
319 307 // Approvals say a person read the change; checks say it works. Both are
320 308 // required unless the repository turns checks off.
321 309 if a.RequiresChecks() {
322 gaps, err := checkGaps(g, cfg.RunsDB, repoNameFromDir(cfg.RepoDir), m.Request.Head, branch, a, who)
310 gaps, err := checkGaps(g, cfg.RunsDB, repoNameFromDir(cfg.RepoDir), m.Request.Head, branch, a)
323 311 if err != nil {
324 312 problems = append(problems, fmt.Sprintf("%s: cannot verify required checks: %v", c.ref, err))
325 313 continue
@@ -340,8 +328,8 @@ func PreReceive(cfg Config) int {
340 328 // the runner that ran the job, and the gate's question about them is who was
341 329 // allowed to. See records.go.
342 330 if c.ref == metadata.Ref {
343 problems = append(problems, recordFindings(g, c, a, actor, actorID,
344 m.Name, cfg.UsersDB, cfg.RunsDB, who)...)
331 problems = append(problems, recordFindings(g, c, a, actor,
332 m.Name, cfg.UsersDB, cfg.RunsDB)...)
345 333 }
346 334 if c.ref == metadata.Ref && c.new != git.ZeroSHA {
347 335 for _, prob := range metadata.ValidateIssues(g, c.new) {
@@ -351,16 +339,16 @@ func PreReceive(cfg Config) int {
351 339 problems = append(problems, "requests: "+prob)
352 340 }
353 341 // An approval is a statement about the actor's own review, so the file
354 // name must be the actor's account id. Without this, anyone with write
342 // name must be the actor's account name. Without this, anyone with write
355 343 // access could commit approvals/<someone-else>.json and approve on their
356 344 // behalf.
357 for _, prob := range approvalOwnership(g, c, actor, actorID, role, cfg.AuditPath, who) {
345 for _, prob := range approvalOwnership(g, c, actor, role, cfg.AuditPath) {
358 346 problems = append(problems, prob)
359 347 }
360 348 // A comment is somebody's own words, so changing or deleting it is
361 349 // theirs too — checked against the author on the *old* blob, which is
362 350 // the one that was validated when it landed.
363 for _, prob := range commentOwnership(g, c, actor, actorID, role, cfg.AuditPath, cfg.UsersDB, cfg.Out) {
351 for _, prob := range commentOwnership(g, c, actor, role, cfg.AuditPath, cfg.Out) {
364 352 problems = append(problems, prob)
365 353 }
366 354 }
@@ -422,7 +410,7 @@ func readChanges(r io.Reader) ([]change, error) {
422 410 // Every write counts: added, modified, renamed, retyped. Checking only additions
423 411 // is not enough, because a file that already exists can simply be edited to name
424 412 // a different commit.
425 func approvalOwnership(g *git.Repo, c change, actor, actorID string, role access.Role, auditPath string, who func(string) string) []string {
413 func approvalOwnership(g *git.Repo, c change, actor string, role access.Role, auditPath string) []string {
426 414 // On the first push the branch has no old commit, so every approval in the
427 415 // tree is new and must be checked. Skipping that would let whoever creates the
428 416 // branch seed approvals in other people's names.
@@ -433,9 +421,9 @@ func approvalOwnership(g *git.Repo, c change, actor, actorID string, role access
433 421 }
434 422 var problems []string
435 423 for _, b := range blobs {
436 if owner := metadata.ApprovalPath(b.Name); owner != "" && owner != actorID {
424 if owner := metadata.ApprovalPath(b.Name); owner != "" && owner != actor {
437 425 problems = append(problems, fmt.Sprintf(
438 "requests: %s: %s may not create an approval for %s", b.Name, actor, who(owner)))
426 "requests: %s: %s may not create an approval for %s", b.Name, actor, owner))
439 427 }
440 428 }
441 429 return problems
@@ -448,7 +436,7 @@ func approvalOwnership(g *git.Repo, c change, actor, actorID string, role access
448 436 var problems []string
449 437 for path, st := range status {
450 438 owner := metadata.ApprovalPath(path)
451 if owner == "" || owner == actorID {
439 if owner == "" || owner == actor {
452 440 continue
453 441 }
454 442 if st == "D" {
@@ -459,7 +447,7 @@ func approvalOwnership(g *git.Repo, c change, actor, actorID string, role access
459 447 if !role.AtLeast(access.Admin) {
460 448 problems = append(problems, fmt.Sprintf(
461 449 "requests: %s: %s may not delete %s's approval; only %s can withdraw it",
462 path, actor, who(owner), who(owner)))
450 path, actor, owner, owner))
463 451 } else {
464 452 auditLine(auditPath, fmt.Sprintf("approval-removed %s by admin %s", path, actor))
465 453 }
@@ -467,7 +455,7 @@ func approvalOwnership(g *git.Repo, c change, actor, actorID string, role access
467 455 }
468 456 problems = append(problems, fmt.Sprintf(
469 457 "requests: %s: %s may not add or change %s's approval; only that account can approve",
470 path, actor, who(owner)))
458 path, actor, owner))
471 459 }
472 460 return problems
473 461 }
@@ -490,7 +478,7 @@ func approvalOwnership(g *git.Repo, c change, actor, actorID string, role access
490 478 // - A request's own file is not covered either, for the same reason: its state,
491 479 // head and base are shared, and updating them is the author's normal edit.
492 480 // - A first push has nothing to compare against, so it is skipped.
493 func commentOwnership(g *git.Repo, c change, actor, actorID string, role access.Role, auditPath, usersDB string, out io.Writer) []string {
481 func commentOwnership(g *git.Repo, c change, actor string, role access.Role, auditPath string, out io.Writer) []string {
494 482 if c.old == "" || c.old == git.ZeroSHA {
495 483 return nil
496 484 }
@@ -499,25 +487,6 @@ func commentOwnership(g *git.Repo, c change, actor, actorID string, role access.
499 487 return []string{fmt.Sprintf("issues: cannot verify comment authorship: %v", err)}
500 488 }
501 489 var problems []string
502 // Only open the roster when there is a comment in the push to check.
503 var roster *identity.Store
504 openRoster := func() *identity.Store {
505 if roster != nil || usersDB == "" {
506 return roster
507 }
508 store, err := identity.Open(usersDB)
509 if err != nil {
510 return nil
511 }
512 roster = store
513 return roster
514 }
515 defer func() {
516 if roster != nil {
517 roster.Close()
518 }
519 }()
520
521 490 for path, st := range status {
522 491 // An issue comment and a review comment on a change request are the same
523 492 // file shape, so one rule covers both.
@@ -541,7 +510,7 @@ func commentOwnership(g *git.Repo, c change, actor, actorID string, role access.
541 510 "issues: %s: cannot establish whose comment this is, refusing the push: %v", path, err))
542 511 continue
543 512 }
544 if ownsAuthor(openRoster(), author, actor, actorID) {
513 if author == actor {
545 514 continue
546 515 }
547 516 verb := "change"
@@ -563,27 +532,6 @@ func commentOwnership(g *git.Repo, c change, actor, actorID string, role access.
563 532 return problems
564 533 }
565 534
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.
569 //
570 // Without a roster the name the handshake supplied is all there is, and the
571 // comparison fails closed.
572 func ownsAuthor(roster *identity.Store, author, actor, actorID string) bool {
573 if roster != nil {
574 if id, ok := roster.ResolveID(author); ok {
575 return id == actorID
576 }
577 // ResolveID covers active names; Find also covers retired ones, so an
578 // account editing a comment it wrote before it retired still owns it.
579 if a, err := roster.Find(author); err == nil {
580 return a.ID == actorID
581 }
582 return false
583 }
584 return author == actor
585 }
586
587 535 // touchesWorkflows reports whether this push changes anything under the workflow
588 536 // directory, so repositories without CI pay one diff and no more.
589 537 func touchesWorkflows(g *git.Repo, c change) (bool, error) {
@@ -623,20 +571,6 @@ func repoNameFromDir(dir string) string {
623 571 return strings.TrimSuffix(filepath.Base(abs), ".git")
624 572 }
625 573
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 {
630 if usersDB == "" {
631 return func(accountID string) string { return accountID }
632 }
633 store, err := identity.Open(usersDB)
634 if err != nil {
635 return func(accountID string) string { return accountID }
636 }
637 return store.Label
638 }
639
640 574 // auditLine is best-effort: failing to write the log must not change policy.
641 575 func auditLine(path, line string) {
642 576 if path == "" {
@@ -673,15 +607,7 @@ func reject(w io.Writer, format string, args ...any) {
673 607 // turn it into an error the client reports as a rejected update.
674 608 func PostReceive(cfg Config) int {
675 609 repo := repoNameFromDir(cfg.RepoDir)
676 actor := strings.TrimSpace(cfg.Actor)
677 actorID := strings.TrimSpace(cfg.ActorID)
678 if actorID == "" {
679 actorID = actor // tolerate callers that only set a name (tests, manual runs)
680 }
681 who := actor
682 if actorID != "" && actorID != actor {
683 who = fmt.Sprintf("%s (%s)", actor, actorID)
684 }
610 who := strings.TrimSpace(cfg.Actor)
685 611 if who == "" {
686 612 who = "unknown"
687 613 }