vole
Clone
Read only, no credentials:
https://rodent.software/vole.git
Write over SSH with a registered key:
git clone ssh://git@rodent.software:2222/vole.git
A free and open-source forge that lives in your Git repo.
Add instance user administration
395554f453ca31d3bd325920a7011c91230e961c · Keenan Nemetz · 15d ago · parent 8e93a8f0
Add instance user administration Instance admins get an Admin -> Users surface: create an account from a one-time enrollment link, grant or revoke instance admin, retire an account, refresh its invite, inspect keys and sessions, remove a key, and revoke every session. Account creation uses CreateWithInvite, so the browser never asks an operator to paste somebody else's SSH key. Enrollment links are rendered once with no-store and warn when browser login is disabled. Retiring and admin changes keep the store's last-admin guard and audit trail.
server/admin_users_test.go +162
| @@ -0,0 +1,162 @@ | |||
| 1 | package server_test | ||
| 2 | |||
| 3 | import ( | ||
| 4 | "net/url" | ||
| 5 | "strings" | ||
| 6 | "testing" | ||
| 7 | ) | ||
| 8 | |||
| 9 | func TestAdminUsersLifecycle(t *testing.T) { | ||
| 10 | t.Parallel() | ||
| 11 | f := browserFixture(t) | ||
| 12 | if _, err := f.srv.Identity.SetAdmin(f.ownerID, true, "test"); err != nil { | ||
| 13 | t.Fatal(err) | ||
| 14 | } | ||
| 15 | sess, cookie := f.browserSession(t, f.ownerID) | ||
| 16 | |||
| 17 | resp, body := f.browserGet(t, "/admin/users", cookie) | ||
| 18 | if resp.StatusCode != 200 || !strings.Contains(body, "Create an account") || !strings.Contains(body, "keenan") { | ||
| 19 | t.Fatalf("users page = %d:\n%s", resp.StatusCode, body) | ||
| 20 | } | ||
| 21 | resp, _ = f.browserGet(t, "/admin", cookie) | ||
| 22 | if resp.StatusCode != 200 { | ||
| 23 | t.Fatalf("admin page = %d", resp.StatusCode) | ||
| 24 | } | ||
| 25 | if _, body := f.browserGet(t, "/admin", cookie); !strings.Contains(body, "/admin/users") { | ||
| 26 | t.Fatal("admin page does not link to users") | ||
| 27 | } | ||
| 28 | |||
| 29 | create := url.Values{ | ||
| 30 | "csrf": {sess.CSRF}, "name": {"newbie"}, "display": {"New Bie"}, | ||
| 31 | } | ||
| 32 | resp, body = f.browserPost(t, "/admin/users", cookie, create) | ||
| 33 | if resp.StatusCode != 200 || !strings.Contains(body, "Enrollment link") || !strings.Contains(body, "newbie") { | ||
| 34 | t.Fatalf("create user = %d:\n%s", resp.StatusCode, body) | ||
| 35 | } | ||
| 36 | if resp.Header.Get("Cache-Control") != "no-store" { | ||
| 37 | t.Fatalf("enrollment page cache header = %q", resp.Header.Get("Cache-Control")) | ||
| 38 | } | ||
| 39 | idx := strings.Index(body, "/invite/") | ||
| 40 | if idx < 0 { | ||
| 41 | t.Fatalf("enrollment page has no link:\n%s", body) | ||
| 42 | } | ||
| 43 | rest := body[idx+len("/invite/"):] | ||
| 44 | token := rest[:strings.IndexAny(rest, "<\" ")] | ||
| 45 | if resp, page := f.browserGet(t, "/invite/"+token, ""); resp.StatusCode != 200 || !strings.Contains(page, "newbie") { | ||
| 46 | t.Fatalf("enrollment link = %d:\n%s", resp.StatusCode, page) | ||
| 47 | } | ||
| 48 | |||
| 49 | acct, err := f.srv.Identity.Find("newbie") | ||
| 50 | if err != nil { | ||
| 51 | t.Fatal(err) | ||
| 52 | } | ||
| 53 | if len(acct.Keys) != 0 || !acct.Active() || acct.Admin { | ||
| 54 | t.Fatalf("created account = %+v", acct) | ||
| 55 | } | ||
| 56 | |||
| 57 | form := url.Values{ | ||
| 58 | "csrf": {sess.CSRF}, "name": {"newbie"}, "display": {""}, | ||
| 59 | } | ||
| 60 | if resp, page := f.browserPost(t, "/admin/users", cookie, form); resp.StatusCode != 400 || !strings.Contains(page, "belongs") { | ||
| 61 | t.Fatalf("duplicate name = %d:\n%s", resp.StatusCode, page) | ||
| 62 | } | ||
| 63 | |||
| 64 | resp, body = f.browserGet(t, "/admin/users/"+acct.ID, cookie) | ||
| 65 | if resp.StatusCode != 200 || !strings.Contains(body, "No SSH keys") || !strings.Contains(body, "No browser sessions") { | ||
| 66 | t.Fatalf("account page = %d:\n%s", resp.StatusCode, body) | ||
| 67 | } | ||
| 68 | |||
| 69 | resp, _ = f.browserPost(t, "/admin/users/"+acct.ID+"/admin", cookie, url.Values{ | ||
| 70 | "csrf": {sess.CSRF}, "admin": {"true"}, | ||
| 71 | }) | ||
| 72 | if resp.StatusCode != 303 || !f.srv.Identity.IsAdmin(acct.ID) { | ||
| 73 | t.Fatalf("grant admin = %d, admin=%t", resp.StatusCode, f.srv.Identity.IsAdmin(acct.ID)) | ||
| 74 | } | ||
| 75 | resp, _ = f.browserPost(t, "/admin/users/"+acct.ID+"/admin", cookie, url.Values{ | ||
| 76 | "csrf": {sess.CSRF}, "admin": {"false"}, | ||
| 77 | }) | ||
| 78 | if resp.StatusCode != 303 || f.srv.Identity.IsAdmin(acct.ID) { | ||
| 79 | t.Fatalf("revoke admin = %d, admin=%t", resp.StatusCode, f.srv.Identity.IsAdmin(acct.ID)) | ||
| 80 | } | ||
| 81 | |||
| 82 | resp, body = f.browserPost(t, "/admin/users/"+acct.ID+"/invite", cookie, url.Values{"csrf": {sess.CSRF}}) | ||
| 83 | if resp.StatusCode != 200 || !strings.Contains(body, "Enrollment link") { | ||
| 84 | t.Fatalf("refresh invite = %d:\n%s", resp.StatusCode, body) | ||
| 85 | } | ||
| 86 | |||
| 87 | // Keys and sessions belong to a second account so the operations have | ||
| 88 | // something to remove. | ||
| 89 | f.addWriter(t) | ||
| 90 | writer, err := f.srv.Identity.Find(f.writerID) | ||
| 91 | if err != nil { | ||
| 92 | t.Fatal(err) | ||
| 93 | } | ||
| 94 | if len(writer.Keys) != 1 { | ||
| 95 | t.Fatalf("writer keys = %d", len(writer.Keys)) | ||
| 96 | } | ||
| 97 | if _, err := f.srv.Sessions.Create(writer.ID, nil, "test-browser"); err != nil { | ||
| 98 | t.Fatal(err) | ||
| 99 | } | ||
| 100 | resp, body = f.browserGet(t, "/admin/users/"+writer.ID, cookie) | ||
| 101 | if resp.StatusCode != 200 || !strings.Contains(body, writer.Keys[0].Fingerprint) || !strings.Contains(body, "test-browser") { | ||
| 102 | t.Fatalf("account detail with key and session = %d:\n%s", resp.StatusCode, body) | ||
| 103 | } | ||
| 104 | resp, _ = f.browserPost(t, "/admin/users/"+writer.ID+"/sessions/revoke", cookie, url.Values{"csrf": {sess.CSRF}}) | ||
| 105 | if resp.StatusCode != 303 { | ||
| 106 | t.Fatalf("revoke sessions = %d", resp.StatusCode) | ||
| 107 | } | ||
| 108 | if list, err := f.srv.Sessions.List(writer.ID); err != nil || len(list) != 0 { | ||
| 109 | t.Fatalf("sessions after revoke = %d, %v", len(list), err) | ||
| 110 | } | ||
| 111 | resp, _ = f.browserPost(t, "/admin/users/"+writer.ID+"/keys/remove", cookie, url.Values{ | ||
| 112 | "csrf": {sess.CSRF}, "fingerprint": {writer.Keys[0].Fingerprint}, | ||
| 113 | }) | ||
| 114 | if resp.StatusCode != 303 { | ||
| 115 | t.Fatalf("remove key = %d", resp.StatusCode) | ||
| 116 | } | ||
| 117 | writer, err = f.srv.Identity.Find(writer.ID) | ||
| 118 | if err != nil { | ||
| 119 | t.Fatal(err) | ||
| 120 | } | ||
| 121 | if len(writer.Keys) != 0 { | ||
| 122 | t.Fatalf("writer still has %d key(s)", len(writer.Keys)) | ||
| 123 | } | ||
| 124 | |||
| 125 | resp, body = f.browserPost(t, "/admin/users/"+writer.ID+"/retire", cookie, url.Values{ | ||
| 126 | "csrf": {sess.CSRF}, "confirm": {"wrong"}, | ||
| 127 | }) | ||
| 128 | if resp.StatusCode != 400 || !strings.Contains(body, "Type the account name") { | ||
| 129 | t.Fatalf("retire without confirmation = %d:\n%s", resp.StatusCode, body) | ||
| 130 | } | ||
| 131 | resp, _ = f.browserPost(t, "/admin/users/"+writer.ID+"/retire", cookie, url.Values{ | ||
| 132 | "csrf": {sess.CSRF}, "confirm": {"wilbur"}, | ||
| 133 | }) | ||
| 134 | if resp.StatusCode != 303 || resp.Header.Get("Location") != "/admin/users" { | ||
| 135 | t.Fatalf("retire = %d %q", resp.StatusCode, resp.Header.Get("Location")) | ||
| 136 | } | ||
| 137 | writer, err = f.srv.Identity.Find(writer.ID) | ||
| 138 | if err != nil { | ||
| 139 | t.Fatal(err) | ||
| 140 | } | ||
| 141 | if writer.Active() { | ||
| 142 | t.Fatal("retired account is still active") | ||
| 143 | } | ||
| 144 | |||
| 145 | // CSRF is checked before any mutation. | ||
| 146 | if resp, _ := f.browserPost(t, "/admin/users/"+acct.ID+"/admin", cookie, url.Values{"admin": {"true"}}); resp.StatusCode != 403 { | ||
| 147 | t.Fatalf("missing CSRF = %d, want 403", resp.StatusCode) | ||
| 148 | } | ||
| 149 | } | ||
| 150 | |||
| 151 | func TestAdminUsersRequiresInstanceAdmin(t *testing.T) { | ||
| 152 | t.Parallel() | ||
| 153 | f := browserFixture(t) | ||
| 154 | if resp, _ := f.browserGet(t, "/admin/users", ""); resp.StatusCode != 303 { | ||
| 155 | t.Fatalf("anonymous users page = %d, want login redirect", resp.StatusCode) | ||
| 156 | } | ||
| 157 | f.addWriter(t) | ||
| 158 | _, writerCookie := f.browserSession(t, f.writerID) | ||
| 159 | if resp, _ := f.browserGet(t, "/admin/users", writerCookie); resp.StatusCode != 404 { | ||
| 160 | t.Fatalf("non-admin users page = %d, want 404", resp.StatusCode) | ||
| 161 | } | ||
| 162 | } | ||