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.

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)