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 | } | |