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/auth_test.go +17−101
| @@ -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 | } | |
| @@ -228,7 +228,7 @@ func TestWebApprovalNamesTheSessionAccount(t *testing.T) { | |||
| 228 | 228 | f.addWriter(t) | |
| 229 | 229 | m := mustRepo(t, f) | |
| 230 | 230 | head := strings.Repeat("a", 40) | |
| 231 | req, err := metadata.NewRequest("A change", "please review", "wilbur", f.writerID, "main", head, time.Now().UTC()) | ||
| 231 | req, err := metadata.NewRequest("A change", "please review", "wilbur", "main", head, time.Now().UTC()) | ||
| 232 | 232 | if err != nil { | |
| 233 | 233 | t.Fatal(err) | |
| 234 | 234 | } | |
| @@ -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 | } | |
| @@ -297,7 +297,7 @@ func TestContributorNamespaceAndRequestUseTheAccountName(t *testing.T) { | |||
| 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 | } | |
| @@ -306,7 +306,7 @@ func TestContributorNamespaceAndRequestUseTheAccountName(t *testing.T) { | |||
| 306 | 306 | if err != nil { | |
| 307 | 307 | t.Fatal(err) | |
| 308 | 308 | } | |
| 309 | if err := a.SetEntry(contrib.ID, "contributor"); err != nil { | ||
| 309 | if err := a.SetEntry(contrib.Name, "contributor"); err != nil { | ||
| 310 | 310 | t.Fatal(err) | |
| 311 | 311 | } | |
| 312 | 312 | if err := m.SetACL(a, "test"); err != nil { | |
| @@ -330,13 +330,7 @@ func TestContributorNamespaceAndRequestUseTheAccountName(t *testing.T) { | |||
| 330 | 330 | t.Fatal("contributor pushed another account's namespace") | |
| 331 | 331 | } | |
| 332 | 332 | ||
| 333 | sess, cookie := f.browserSession(t, contrib.ID) | ||
| 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 { | ||
| 337 | t.Fatal(err) | ||
| 338 | } | ||
| 339 | |||
| 333 | sess, cookie := f.browserSession(t, contrib.Name) | ||
| 340 | 334 | // Contributors can discuss without access to shared branches. | |
| 341 | 335 | resp, body := f.browserPost(t, "/r/"+f.repo+"/issues", cookie, url.Values{ | |
| 342 | 336 | "csrf": {sess.CSRF}, "title": {"Contributor issue"}, "body": {"from outside"}, | |
| @@ -361,7 +355,7 @@ func TestContributorNamespaceAndRequestUseTheAccountName(t *testing.T) { | |||
| 361 | 355 | if err != nil { | |
| 362 | 356 | t.Fatal(err) | |
| 363 | 357 | } | |
| 364 | if len(list) != 1 || list[0].AuthorID != contrib.ID || list[0].Author != "contrib" || list[0].Base != "main" { | ||
| 358 | if len(list) != 1 || list[0].Author != "contrib" || list[0].Base != "main" { | ||
| 365 | 359 | t.Fatalf("request = %+v", list) | |
| 366 | 360 | } | |
| 367 | 361 | want, _ := m.Git().RevParse("refs/heads/" + headRef) | |
| @@ -384,7 +378,7 @@ func TestContributorCIIsHeldUntilWriterAllows(t *testing.T) { | |||
| 384 | 378 | f := newFixtureWith(t, func(c *config.Config) { c.LeaseSeconds = 2 }) | |
| 385 | 379 | ||
| 386 | 380 | pub, priv := keypair(t, filepath.Join(f.cfg.DataDir, "id_ci_contrib")) | |
| 387 | contrib, err := f.srv.Identity.Create("ci-contrib", "", pub, "test") | ||
| 381 | contrib, err := f.srv.Identity.Create("ci-contrib", pub, "test") | ||
| 388 | 382 | if err != nil { | |
| 389 | 383 | t.Fatal(err) | |
| 390 | 384 | } | |
| @@ -393,7 +387,7 @@ func TestContributorCIIsHeldUntilWriterAllows(t *testing.T) { | |||
| 393 | 387 | if err != nil { | |
| 394 | 388 | t.Fatal(err) | |
| 395 | 389 | } | |
| 396 | if err := a.SetEntry(contrib.ID, "contributor"); err != nil { | ||
| 390 | if err := a.SetEntry(contrib.Name, "contributor"); err != nil { | ||
| 397 | 391 | t.Fatal(err) | |
| 398 | 392 | } | |
| 399 | 393 | if err := m.SetACL(a, "test"); err != nil { | |
| @@ -436,7 +430,7 @@ jobs: | |||
| 436 | 430 | time.Sleep(100 * time.Millisecond) | |
| 437 | 431 | } | |
| 438 | 432 | ||
| 439 | contribSess, contribCookie := f.browserSession(t, contrib.ID) | ||
| 433 | contribSess, contribCookie := f.browserSession(t, contrib.Name) | ||
| 440 | 434 | resp, body := f.browserPost(t, "/r/"+f.repo+"/requests", contribCookie, url.Values{ | |
| 441 | 435 | "csrf": {contribSess.CSRF}, "title": {"CI gate"}, "body": {"please review"}, | |
| 442 | 436 | "base": {"main"}, "head": {ref}, | |
| @@ -534,16 +528,6 @@ func TestOpenRegistrationPagesAndBegin(t *testing.T) { | |||
| 534 | 528 | if resp.StatusCode != http.StatusBadRequest { | |
| 535 | 529 | t.Fatalf("invalid name status = %d, want 400", resp.StatusCode) | |
| 536 | 530 | } | |
| 537 | // The display name is optional and validated with the same rules the roster | ||
| 538 | // applies, so a form cannot hand the store something it would reject. | ||
| 539 | resp, _ = f.browserJSON(t, "/auth/register/begin", "", "", map[string]string{"username": "newbie", "display": strings.Repeat("x", 100)}) | ||
| 540 | if resp.StatusCode != http.StatusBadRequest { | ||
| 541 | t.Fatalf("overlong display status = %d, want 400", resp.StatusCode) | ||
| 542 | } | ||
| 543 | resp, out = f.browserJSON(t, "/auth/register/begin", "", "", map[string]string{"username": "newbie", "display": "New Bie"}) | ||
| 544 | if resp.StatusCode != http.StatusOK || out["publicKey"] == nil { | ||
| 545 | t.Fatalf("register begin with a display name: status=%d body=%v", resp.StatusCode, out) | ||
| 546 | } | ||
| 547 | 531 | ||
| 548 | 532 | // An account is not created until the authenticator completes the ceremony. | |
| 549 | 533 | if _, err := f.srv.Identity.Find("newbie"); err == nil { | |
| @@ -613,82 +597,14 @@ func TestAccountAddsAndRemovesItsOwnSSHKey(t *testing.T) { | |||
| 613 | 597 | } | |
| 614 | 598 | } | |
| 615 | 599 | ||
| 616 | // TestAccountDisplayNameIsALabelNotAnIdentity: the display name is what pages | ||
| 617 | // show, and it may change; the account name is what refs and metadata files were | ||
| 618 | // written with, and it may not. | ||
| 619 | func TestAccountDisplayNameIsALabelNotAnIdentity(t *testing.T) { | ||
| 600 | // TestDisplayNameRouteIsGone: an account is only its registered name, so there | ||
| 601 | // is no second label to change. | ||
| 602 | func TestDisplayNameRouteIsGone(t *testing.T) { | ||
| 620 | 603 | t.Parallel() | |
| 621 | 604 | f := browserFixture(t) | |
| 622 | sess, cookie := f.browserSession(t, f.ownerID) | ||
| 623 | |||
| 624 | // The signed-in person sets their own label; no administrator involved. | ||
| 625 | resp, body := f.browserPost(t, "/account/display", cookie, url.Values{ | ||
| 626 | "csrf": {sess.CSRF}, "display": {"Keenan O'Brien"}, | ||
| 627 | }) | ||
| 628 | if resp.StatusCode != http.StatusSeeOther { | ||
| 629 | t.Fatalf("set display: status=%d body=%s", resp.StatusCode, body) | ||
| 630 | } | ||
| 631 | a, err := f.srv.Identity.Find(f.ownerID) | ||
| 632 | if err != nil { | ||
| 633 | t.Fatal(err) | ||
| 634 | } | ||
| 635 | if a.Name != "keenan" || a.Display != "Keenan O'Brien" { | ||
| 636 | t.Fatalf("account = %+v, want name keenan and the display label", a) | ||
| 637 | } | ||
| 638 | |||
| 639 | // Pages show the label; metadata still records the name. | ||
| 640 | if _, body := f.browserGet(t, "/account", cookie); !strings.Contains(body, "Keenan O'Brien") { | ||
| 641 | t.Fatalf("account page does not show the display name: %s", body) | ||
| 642 | } | ||
| 643 | resp, body = f.browserPost(t, "/r/"+f.repo+"/issues", cookie, url.Values{ | ||
| 644 | "csrf": {sess.CSRF}, "title": {"Label check"}, "body": {"who wrote this?"}, | ||
| 645 | }) | ||
| 646 | if resp.StatusCode != http.StatusSeeOther { | ||
| 647 | t.Fatalf("create issue: status=%d body=%s", resp.StatusCode, body) | ||
| 648 | } | ||
| 649 | loc := resp.Header.Get("Location") | ||
| 650 | if _, body := f.browserGet(t, loc, cookie); !strings.Contains(body, "Keenan O'Brien") { | ||
| 651 | t.Fatalf("issue page does not show the display name: %s", body) | ||
| 652 | } | ||
| 653 | iss, err := index.LoadIssue(mustRepo(t, f), strings.TrimPrefix(loc, "/r/"+f.repo+"/issues/")) | ||
| 654 | if err != nil { | ||
| 655 | t.Fatal(err) | ||
| 656 | } | ||
| 657 | if iss.Author != "keenan" { | ||
| 658 | t.Fatalf("issue author = %q; metadata must keep the account name", iss.Author) | ||
| 659 | } | ||
| 660 | |||
| 661 | // A display name is not an identity: it cannot be used to resolve an account. | ||
| 662 | if _, err := f.srv.Identity.Find("Keenan O'Brien"); err == nil { | ||
| 663 | t.Fatal("a display name resolved as an account") | ||
| 664 | } | ||
| 665 | if _, ok := f.srv.Identity.ResolveID("Keenan O'Brien"); ok { | ||
| 666 | t.Fatal("a display name resolved to an account id") | ||
| 667 | } | ||
| 668 | |||
| 669 | // Invalid labels are refused before anything is written, and a write without | ||
| 670 | // the token never reaches the roster. | ||
| 671 | resp, _ = f.browserPost(t, "/account/display", cookie, url.Values{ | ||
| 672 | "csrf": {sess.CSRF}, "display": {strings.Repeat("x", 100)}, | ||
| 673 | }) | ||
| 674 | if resp.StatusCode != http.StatusBadRequest { | ||
| 675 | t.Fatalf("overlong display status = %d, want 400", resp.StatusCode) | ||
| 676 | } | ||
| 677 | resp, _ = f.browserPost(t, "/account/display", cookie, url.Values{ | ||
| 678 | "display": {"forged"}, | ||
| 679 | }) | ||
| 680 | if resp.StatusCode != http.StatusForbidden { | ||
| 681 | t.Fatalf("display without CSRF status = %d, want 403", resp.StatusCode) | ||
| 682 | } | ||
| 683 | |||
| 684 | // Clearing the label falls back to the name. | ||
| 685 | resp, _ = f.browserPost(t, "/account/display", cookie, url.Values{ | ||
| 686 | "csrf": {sess.CSRF}, "display": {""}, | ||
| 687 | }) | ||
| 688 | if resp.StatusCode != http.StatusSeeOther { | ||
| 689 | t.Fatalf("clear display: status=%d", resp.StatusCode) | ||
| 690 | } | ||
| 691 | if a, err := f.srv.Identity.Find(f.ownerID); err != nil || a.Display != "" || a.Label() != "keenan" { | ||
| 692 | t.Fatalf("cleared display = %+v, %v", a, err) | ||
| 605 | _, cookie := f.browserSession(t, f.ownerID) | ||
| 606 | resp, _ := f.browserPost(t, "/account/display", cookie, url.Values{"display": {"Keenan O'Brien"}}) | ||
| 607 | if resp.StatusCode != http.StatusNotFound { | ||
| 608 | t.Fatalf("display route status = %d, want 404", resp.StatusCode) | ||
| 693 | 609 | } | |
| 694 | 610 | } | |