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/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&#39;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&#39;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 }