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