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.
Let instance admins create bot accounts from the web UI
8c72543b9ef4ac728740a6f0b3a649f9c3102e7a · Keenan Nemetz · 5d ago · parent f422c24f
Let instance admins create bot accounts from the web UI The Accounts page could only invite a person with an enrollment link. A bot has no browser sign-in, so it needs an SSH key at creation instead. Add a bot form that reuses identity.CreateBot, keep admin out of its role picker, and hide the enrollment action on a bot's account page.
server/admin_users_test.go +91−1
| @@ -2,6 +2,7 @@ package server_test | |||
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | 4 | "net/url" | |
| 5 | "path/filepath" | ||
| 5 | 6 | "strings" | |
| 6 | 7 | "testing" | |
| 7 | 8 | ||
| @@ -17,7 +18,7 @@ func TestAdminUsersLifecycle(t *testing.T) { | |||
| 17 | 18 | sess, cookie := f.browserSession(t, f.ownerID) | |
| 18 | 19 | ||
| 19 | 20 | resp, body := f.browserGet(t, "/users", cookie) | |
| 20 | if resp.StatusCode != 200 || !strings.Contains(body, "Create an account") || !strings.Contains(body, "keenan") { | ||
| 21 | if resp.StatusCode != 200 || !strings.Contains(body, "Invite a person") || !strings.Contains(body, "keenan") { | ||
| 21 | 22 | t.Fatalf("users page = %d:\n%s", resp.StatusCode, body) | |
| 22 | 23 | } | |
| 23 | 24 | if !strings.Contains(body, `href="/users" aria-current="page"`) { | |
| @@ -161,6 +162,95 @@ func TestAdminUsersLifecycle(t *testing.T) { | |||
| 161 | 162 | } | |
| 162 | 163 | } | |
| 163 | 164 | ||
| 165 | // A bot has no browser sign-in, so its first credential is an SSH key. The | ||
| 166 | // Accounts page collects one and creates the same account the CLI would, with the | ||
| 167 | // same kind, role, and audit line. | ||
| 168 | func TestAdminCreatesBotAccount(t *testing.T) { | ||
| 169 | t.Parallel() | ||
| 170 | f := browserFixture(t) | ||
| 171 | if _, err := f.srv.Identity.SetAdmin(f.ownerID, true, "test"); err != nil { | ||
| 172 | t.Fatal(err) | ||
| 173 | } | ||
| 174 | sess, cookie := f.browserSession(t, f.ownerID) | ||
| 175 | |||
| 176 | resp, body := f.browserGet(t, "/users", cookie) | ||
| 177 | if resp.StatusCode != 200 || !strings.Contains(body, "Create a bot account") || !strings.Contains(body, `action="/users/bots"`) { | ||
| 178 | t.Fatalf("users page has no bot form (%d):\n%s", resp.StatusCode, body) | ||
| 179 | } | ||
| 180 | |||
| 181 | botKey, _ := keypair(t, filepath.Join(f.cfg.DataDir, "id_bot")) | ||
| 182 | form := url.Values{ | ||
| 183 | "csrf": {sess.CSRF}, "name": {"ci-runner"}, "role": {"contribute"}, "key": {botKey}, | ||
| 184 | } | ||
| 185 | resp, body = f.browserPost(t, "/users/bots", cookie, form) | ||
| 186 | if resp.StatusCode != 303 || resp.Header.Get("Location") != "/users/ci-runner" { | ||
| 187 | t.Fatalf("create bot = %d %q:\n%s", resp.StatusCode, resp.Header.Get("Location"), body) | ||
| 188 | } | ||
| 189 | acct, err := f.srv.Identity.Find("ci-runner") | ||
| 190 | if err != nil { | ||
| 191 | t.Fatal(err) | ||
| 192 | } | ||
| 193 | if !acct.Bot() || acct.Role != access.Contribute || len(acct.Keys) != 1 || acct.Keys[0].Line != botKey { | ||
| 194 | t.Fatalf("created bot = %+v", acct) | ||
| 195 | } | ||
| 196 | if _, list := f.browserGet(t, "/users", cookie); !strings.Contains(list, `<a href="/users/ci-runner">ci-runner</a> <span class="state">bot</span>`) { | ||
| 197 | t.Fatalf("the account list does not mark the bot:\n%s", list) | ||
| 198 | } | ||
| 199 | |||
| 200 | // The page that manages the bot says what it is and offers only actions a bot | ||
| 201 | // can use: no enrollment link, and no instance-admin role. | ||
| 202 | resp, body = f.browserGet(t, "/users/ci-runner", cookie) | ||
| 203 | if resp.StatusCode != 200 || !strings.Contains(body, "ssh-ed25519") { | ||
| 204 | t.Fatalf("bot page = %d:\n%s", resp.StatusCode, body) | ||
| 205 | } | ||
| 206 | if !strings.Contains(body, `<span class="state">bot</span>`) { | ||
| 207 | t.Fatalf("bot page does not mark the account as a bot:\n%s", body) | ||
| 208 | } | ||
| 209 | if strings.Contains(body, "New enrollment link") { | ||
| 210 | t.Fatalf("bot page offers an enrollment link:\n%s", body) | ||
| 211 | } | ||
| 212 | if strings.Contains(body, `value="admin"`) { | ||
| 213 | t.Fatalf("bot role picker offers instance admin:\n%s", body) | ||
| 214 | } | ||
| 215 | |||
| 216 | // A bot without a key has no way in, so the form refuses instead of creating | ||
| 217 | // an account nobody can act as. | ||
| 218 | form.Set("name", "keyless-bot") | ||
| 219 | form.Del("key") | ||
| 220 | resp, body = f.browserPost(t, "/users/bots", cookie, form) | ||
| 221 | if resp.StatusCode != 400 || !strings.Contains(body, "Paste a public key for the bot") { | ||
| 222 | t.Fatalf("keyless bot = %d:\n%s", resp.StatusCode, body) | ||
| 223 | } | ||
| 224 | if _, err := f.srv.Identity.Find("keyless-bot"); err == nil { | ||
| 225 | t.Fatal("the keyless bot was created anyway") | ||
| 226 | } | ||
| 227 | |||
| 228 | // The hidden admin option is not what refuses this; the identity rule is. | ||
| 229 | adminKey, _ := keypair(t, filepath.Join(f.cfg.DataDir, "id_admin_bot")) | ||
| 230 | form.Set("name", "admin-bot") | ||
| 231 | form.Set("role", "admin") | ||
| 232 | form.Set("key", adminKey) | ||
| 233 | resp, body = f.browserPost(t, "/users/bots", cookie, form) | ||
| 234 | if resp.StatusCode != 400 || !strings.Contains(body, "cannot hold that role") { | ||
| 235 | t.Fatalf("admin bot = %d:\n%s", resp.StatusCode, body) | ||
| 236 | } | ||
| 237 | if _, err := f.srv.Identity.Find("admin-bot"); err == nil { | ||
| 238 | t.Fatal("the admin bot was created anyway") | ||
| 239 | } | ||
| 240 | |||
| 241 | // Instance-admin endpoints hide themselves from everybody else. | ||
| 242 | f.addWriter(t) | ||
| 243 | writerSess, writerCookie := f.browserSession(t, f.writerID) | ||
| 244 | form.Set("name", "writer-bot") | ||
| 245 | form.Set("role", "none") | ||
| 246 | resp, _ = f.browserPost(t, "/users/bots", writerCookie, url.Values{ | ||
| 247 | "csrf": {writerSess.CSRF}, "name": {"writer-bot"}, "role": {"none"}, "key": {adminKey}, | ||
| 248 | }) | ||
| 249 | if resp.StatusCode != 404 { | ||
| 250 | t.Fatalf("non-admin create bot = %d, want 404", resp.StatusCode) | ||
| 251 | } | ||
| 252 | } | ||
| 253 | |||
| 164 | 254 | func TestAdminUsersRequiresInstanceAdmin(t *testing.T) { | |
| 165 | 255 | t.Parallel() | |
| 166 | 256 | f := browserFixture(t) | |