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/auth_test.go +102−11
| @@ -116,7 +116,7 @@ func TestBrowserLoginAndInvitePages(t *testing.T) { | |||
| 116 | 116 | t.Fatalf("login page lost the next path: %s", body) | |
| 117 | 117 | } | |
| 118 | 118 | ||
| 119 | acct, inv, err := f.srv.Identity.CreateWithInvite("newbie", "test", time.Hour) | ||
| 119 | acct, inv, err := f.srv.Identity.CreateWithInvite("newbie", "", "test", time.Hour) | ||
| 120 | 120 | if err != nil { | |
| 121 | 121 | t.Fatal(err) | |
| 122 | 122 | } | |
| @@ -271,7 +271,7 @@ func TestWebAuthnBeginEndpoints(t *testing.T) { | |||
| 271 | 271 | t.Fatalf("login begin: status=%d body=%v", resp.StatusCode, out) | |
| 272 | 272 | } | |
| 273 | 273 | ||
| 274 | _, inv, err := f.srv.Identity.CreateWithInvite("newbie", "test", time.Hour) | ||
| 274 | _, inv, err := f.srv.Identity.CreateWithInvite("newbie", "", "test", time.Hour) | ||
| 275 | 275 | if err != nil { | |
| 276 | 276 | t.Fatal(err) | |
| 277 | 277 | } | |
| @@ -292,12 +292,12 @@ func TestWebAuthnBeginEndpoints(t *testing.T) { | |||
| 292 | 292 | } | |
| 293 | 293 | } | |
| 294 | 294 | ||
| 295 | func TestContributorNamespaceAndRequestSurviveRename(t *testing.T) { | ||
| 295 | func TestContributorNamespaceAndRequestUseTheAccountName(t *testing.T) { | ||
| 296 | 296 | t.Parallel() | |
| 297 | 297 | f := browserFixture(t) | |
| 298 | 298 | ||
| 299 | 299 | pub, priv := keypair(t, filepath.Join(f.cfg.DataDir, "id_contrib")) | |
| 300 | contrib, err := f.srv.Identity.Create("contrib", pub, "test") | ||
| 300 | contrib, err := f.srv.Identity.Create("contrib", "", pub, "test") | ||
| 301 | 301 | if err != nil { | |
| 302 | 302 | t.Fatal(err) | |
| 303 | 303 | } | |
| @@ -318,7 +318,8 @@ func TestContributorNamespaceAndRequestSurviveRename(t *testing.T) { | |||
| 318 | 318 | t.Fatalf("owner push: %v\n%s", err, errs) | |
| 319 | 319 | } | |
| 320 | 320 | work := workRepo(t, map[string]string{"readme.md": "contribution\n"}) | |
| 321 | headRef := "contrib/" + contrib.ID + "/fix" | ||
| 321 | // The namespace is the account name, chosen at creation and fixed forever. | ||
| 322 | headRef := "contrib/" + contrib.Name + "/fix" | ||
| 322 | 323 | if _, errs, err := f.gitWithKey(priv, work, "push", f.sshURL(f.repo), "main:refs/heads/"+headRef); err != nil { | |
| 323 | 324 | t.Fatalf("contributor push: %v\n%s", err, errs) | |
| 324 | 325 | } | |
| @@ -330,9 +331,9 @@ func TestContributorNamespaceAndRequestSurviveRename(t *testing.T) { | |||
| 330 | 331 | } | |
| 331 | 332 | ||
| 332 | 333 | sess, cookie := f.browserSession(t, contrib.ID) | |
| 333 | // A rename keeps the account id, which is the branch namespace and the ACL | ||
| 334 | // key. The session remains valid, and the request author uses the new name. | ||
| 335 | if _, err := f.srv.Identity.Rename(contrib.ID, "renamed", "test"); err != nil { | ||
| 334 | // The display name is a label: it may change, and neither the branch | ||
| 335 | // namespace, the session, nor an existing request depends on it. | ||
| 336 | if _, err := f.srv.Identity.SetDisplay(contrib.ID, "Contributor Pat", "test"); err != nil { | ||
| 336 | 337 | t.Fatal(err) | |
| 337 | 338 | } | |
| 338 | 339 | ||
| @@ -360,7 +361,7 @@ func TestContributorNamespaceAndRequestSurviveRename(t *testing.T) { | |||
| 360 | 361 | if err != nil { | |
| 361 | 362 | t.Fatal(err) | |
| 362 | 363 | } | |
| 363 | if len(list) != 1 || list[0].AuthorID != contrib.ID || list[0].Author != "renamed" || list[0].Base != "main" { | ||
| 364 | if len(list) != 1 || list[0].AuthorID != contrib.ID || list[0].Author != "contrib" || list[0].Base != "main" { | ||
| 364 | 365 | t.Fatalf("request = %+v", list) | |
| 365 | 366 | } | |
| 366 | 367 | want, _ := m.Git().RevParse("refs/heads/" + headRef) | |
| @@ -383,7 +384,7 @@ func TestContributorCIIsHeldUntilWriterAllows(t *testing.T) { | |||
| 383 | 384 | f := newFixtureWith(t, func(c *config.Config) { c.LeaseSeconds = 2 }) | |
| 384 | 385 | ||
| 385 | 386 | pub, priv := keypair(t, filepath.Join(f.cfg.DataDir, "id_ci_contrib")) | |
| 386 | contrib, err := f.srv.Identity.Create("ci-contrib", pub, "test") | ||
| 387 | contrib, err := f.srv.Identity.Create("ci-contrib", "", pub, "test") | ||
| 387 | 388 | if err != nil { | |
| 388 | 389 | t.Fatal(err) | |
| 389 | 390 | } | |
| @@ -411,7 +412,7 @@ jobs: | |||
| 411 | 412 | t.Fatal(err) | |
| 412 | 413 | } | |
| 413 | 414 | head = strings.TrimSpace(head) | |
| 414 | ref := "contrib/" + contrib.ID + "/ci" | ||
| 415 | ref := "contrib/" + contrib.Name + "/ci" | ||
| 415 | 416 | if _, errs, err := f.gitWithKey(priv, work, "push", f.sshURL(f.repo), "HEAD:refs/heads/"+ref); err != nil { | |
| 416 | 417 | t.Fatalf("contributor push: %v\n%s", err, errs) | |
| 417 | 418 | } | |
| @@ -486,6 +487,16 @@ func TestOpenRegistrationPagesAndBegin(t *testing.T) { | |||
| 486 | 487 | if resp.StatusCode != http.StatusBadRequest { | |
| 487 | 488 | t.Fatalf("invalid name status = %d, want 400", resp.StatusCode) | |
| 488 | 489 | } | |
| 490 | // The display name is optional and validated with the same rules the roster | ||
| 491 | // applies, so a form cannot hand the store something it would reject. | ||
| 492 | resp, _ = f.browserJSON(t, "/auth/register/begin", "", "", map[string]string{"username": "newbie", "display": strings.Repeat("x", 100)}) | ||
| 493 | if resp.StatusCode != http.StatusBadRequest { | ||
| 494 | t.Fatalf("overlong display status = %d, want 400", resp.StatusCode) | ||
| 495 | } | ||
| 496 | resp, out = f.browserJSON(t, "/auth/register/begin", "", "", map[string]string{"username": "newbie", "display": "New Bie"}) | ||
| 497 | if resp.StatusCode != http.StatusOK || out["publicKey"] == nil { | ||
| 498 | t.Fatalf("register begin with a display name: status=%d body=%v", resp.StatusCode, out) | ||
| 499 | } | ||
| 489 | 500 | ||
| 490 | 501 | // An account is not created until the authenticator completes the ceremony. | |
| 491 | 502 | if _, err := f.srv.Identity.Find("newbie"); err == nil { | |
| @@ -554,3 +565,83 @@ func TestAccountAddsAndRemovesItsOwnSSHKey(t *testing.T) { | |||
| 554 | 565 | } | |
| 555 | 566 | } | |
| 556 | 567 | } | |
| 568 | |||
| 569 | // TestAccountDisplayNameIsALabelNotAnIdentity: the display name is what pages | ||
| 570 | // show, and it may change; the account name is what refs and metadata files were | ||
| 571 | // written with, and it may not. | ||
| 572 | func TestAccountDisplayNameIsALabelNotAnIdentity(t *testing.T) { | ||
| 573 | t.Parallel() | ||
| 574 | f := browserFixture(t) | ||
| 575 | sess, cookie := f.browserSession(t, f.ownerID) | ||
| 576 | |||
| 577 | // The signed-in person sets their own label; no administrator involved. | ||
| 578 | resp, body := f.browserPost(t, "/account/display", cookie, url.Values{ | ||
| 579 | "csrf": {sess.CSRF}, "display": {"Keenan O'Brien"}, | ||
| 580 | }) | ||
| 581 | if resp.StatusCode != http.StatusSeeOther { | ||
| 582 | t.Fatalf("set display: status=%d body=%s", resp.StatusCode, body) | ||
| 583 | } | ||
| 584 | a, err := f.srv.Identity.Find(f.ownerID) | ||
| 585 | if err != nil { | ||
| 586 | t.Fatal(err) | ||
| 587 | } | ||
| 588 | if a.Name != "keenan" || a.Display != "Keenan O'Brien" { | ||
| 589 | t.Fatalf("account = %+v, want name keenan and the display label", a) | ||
| 590 | } | ||
| 591 | |||
| 592 | // Pages show the label; metadata still records the name. | ||
| 593 | if _, body := f.browserGet(t, "/account", cookie); !strings.Contains(body, "Keenan O'Brien") { | ||
| 594 | t.Fatalf("account page does not show the display name: %s", body) | ||
| 595 | } | ||
| 596 | resp, body = f.browserPost(t, "/r/"+f.repo+"/issues", cookie, url.Values{ | ||
| 597 | "csrf": {sess.CSRF}, "title": {"Label check"}, "body": {"who wrote this?"}, | ||
| 598 | }) | ||
| 599 | if resp.StatusCode != http.StatusSeeOther { | ||
| 600 | t.Fatalf("create issue: status=%d body=%s", resp.StatusCode, body) | ||
| 601 | } | ||
| 602 | loc := resp.Header.Get("Location") | ||
| 603 | if _, body := f.browserGet(t, loc, cookie); !strings.Contains(body, "Keenan O'Brien") { | ||
| 604 | t.Fatalf("issue page does not show the display name: %s", body) | ||
| 605 | } | ||
| 606 | iss, err := index.LoadIssue(mustRepo(t, f), strings.TrimPrefix(loc, "/r/"+f.repo+"/issues/")) | ||
| 607 | if err != nil { | ||
| 608 | t.Fatal(err) | ||
| 609 | } | ||
| 610 | if iss.Author != "keenan" { | ||
| 611 | t.Fatalf("issue author = %q; metadata must keep the account name", iss.Author) | ||
| 612 | } | ||
| 613 | |||
| 614 | // A display name is not an identity: it cannot be used to resolve an account. | ||
| 615 | if _, err := f.srv.Identity.Find("Keenan O'Brien"); err == nil { | ||
| 616 | t.Fatal("a display name resolved as an account") | ||
| 617 | } | ||
| 618 | if _, ok := f.srv.Identity.ResolveID("Keenan O'Brien"); ok { | ||
| 619 | t.Fatal("a display name resolved to an account id") | ||
| 620 | } | ||
| 621 | |||
| 622 | // Invalid labels are refused before anything is written, and a write without | ||
| 623 | // the token never reaches the roster. | ||
| 624 | resp, _ = f.browserPost(t, "/account/display", cookie, url.Values{ | ||
| 625 | "csrf": {sess.CSRF}, "display": {strings.Repeat("x", 100)}, | ||
| 626 | }) | ||
| 627 | if resp.StatusCode != http.StatusBadRequest { | ||
| 628 | t.Fatalf("overlong display status = %d, want 400", resp.StatusCode) | ||
| 629 | } | ||
| 630 | resp, _ = f.browserPost(t, "/account/display", cookie, url.Values{ | ||
| 631 | "display": {"forged"}, | ||
| 632 | }) | ||
| 633 | if resp.StatusCode != http.StatusForbidden { | ||
| 634 | t.Fatalf("display without CSRF status = %d, want 403", resp.StatusCode) | ||
| 635 | } | ||
| 636 | |||
| 637 | // Clearing the label falls back to the name. | ||
| 638 | resp, _ = f.browserPost(t, "/account/display", cookie, url.Values{ | ||
| 639 | "csrf": {sess.CSRF}, "display": {""}, | ||
| 640 | }) | ||
| 641 | if resp.StatusCode != http.StatusSeeOther { | ||
| 642 | t.Fatalf("clear display: status=%d", resp.StatusCode) | ||
| 643 | } | ||
| 644 | if a, err := f.srv.Identity.Find(f.ownerID); err != nil || a.Display != "" || a.Label() != "keenan" { | ||
| 645 | t.Fatalf("cleared display = %+v, %v", a, err) | ||
| 646 | } | ||
| 647 | } | ||