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 browser auth, contributor access, and gated CI

6c84d387e5f608360e30484a1a5b1cff41076850 · Keenan Nemetz · 16d ago · parent c36d5073

Add browser auth, contributor access, and gated CI

Browser sign-in now uses passkeys. Enrollment links are one-time, sessions are hashed in users.db, and issue/comment/approval writes use the session account. HTTPS public_url is required for WebAuthn.

Add the contributor role between read and maintain. Contributors can discuss and open change requests, and may push only refs/heads/contrib/<account-id>/. Contributor CI is held per commit until a maintainer allows the exact tip from the repository page.

Rename the write role to maintain. write remains accepted as the historical spelling, and access.Write remains as a Go alias so existing callers and ACL rows keep working.

Update the web, CLI, queue schema, and guides for the new flows, and cover the role, namespace, auth, and CI-gating paths in tests.

server/auth_test.go +459

@@ -0,0 +1,459 @@
1 package server_test
2
3 import (
4 "crypto/rand"
5 "encoding/json"
6 "io"
7 "net/http"
8 "net/url"
9 "path/filepath"
10 "strings"
11 "testing"
12 "time"
13
14 "github.com/rodent-software/vole/ci/runs"
15 "github.com/rodent-software/vole/forge/config"
16 "github.com/rodent-software/vole/forge/identity"
17 "github.com/rodent-software/vole/forge/index"
18 "github.com/rodent-software/vole/forge/session"
19 "github.com/rodent-software/vole/metadata"
20 )
21
22 var noRedirectAuth = &http.Client{CheckRedirect: func(*http.Request, []*http.Request) error {
23 return http.ErrUseLastResponse
24 }}
25
26 func browserFixture(t *testing.T) *fixture {
27 t.Helper()
28 return newFixtureWith(t, func(c *config.Config) {
29 c.PublicURL = "https://forge.test"
30 })
31 }
32
33 func (f *fixture) browserSession(t *testing.T, accountID string) (*session.Session, string) {
34 t.Helper()
35 sess, err := f.srv.Sessions.Create(accountID, nil, "test-browser")
36 if err != nil {
37 t.Fatal(err)
38 }
39 return sess, "__Host-vole_session=" + sess.Token
40 }
41
42 func (f *fixture) browserGet(t *testing.T, path, cookie string) (*http.Response, string) {
43 t.Helper()
44 req, err := http.NewRequest("GET", f.http.URL+path, nil)
45 if err != nil {
46 t.Fatal(err)
47 }
48 if cookie != "" {
49 req.Header.Set("Cookie", cookie)
50 }
51 resp, err := noRedirectAuth.Do(req)
52 if err != nil {
53 t.Fatal(err)
54 }
55 defer resp.Body.Close()
56 body, _ := io.ReadAll(resp.Body)
57 return resp, string(body)
58 }
59
60 func (f *fixture) browserPost(t *testing.T, path, cookie string, form url.Values) (*http.Response, string) {
61 t.Helper()
62 req, err := http.NewRequest("POST", f.http.URL+path, strings.NewReader(form.Encode()))
63 if err != nil {
64 t.Fatal(err)
65 }
66 req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
67 if cookie != "" {
68 req.Header.Set("Cookie", cookie)
69 }
70 resp, err := noRedirectAuth.Do(req)
71 if err != nil {
72 t.Fatal(err)
73 }
74 defer resp.Body.Close()
75 body, _ := io.ReadAll(resp.Body)
76 return resp, string(body)
77 }
78
79 func (f *fixture) browserJSON(t *testing.T, path, cookie, csrf string, body any) (*http.Response, map[string]any) {
80 t.Helper()
81 raw, err := json.Marshal(body)
82 if err != nil {
83 t.Fatal(err)
84 }
85 req, err := http.NewRequest("POST", f.http.URL+path, strings.NewReader(string(raw)))
86 if err != nil {
87 t.Fatal(err)
88 }
89 req.Header.Set("Content-Type", "application/json")
90 if cookie != "" {
91 req.Header.Set("Cookie", cookie)
92 }
93 if csrf != "" {
94 req.Header.Set("X-Vole-CSRF", csrf)
95 }
96 resp, err := noRedirectAuth.Do(req)
97 if err != nil {
98 t.Fatal(err)
99 }
100 defer resp.Body.Close()
101 var out map[string]any
102 _ = json.NewDecoder(resp.Body).Decode(&out)
103 return resp, out
104 }
105
106 func TestBrowserLoginAndInvitePages(t *testing.T) {
107 t.Parallel()
108 f := browserFixture(t)
109
110 resp, body := f.browserGet(t, "/login?next=/r/demo", "")
111 if resp.StatusCode != http.StatusOK || !strings.Contains(body, "Use a passkey") {
112 t.Fatalf("login page: status=%d body=%s", resp.StatusCode, body)
113 }
114 if !strings.Contains(body, `data-next="/r/demo"`) {
115 t.Fatalf("login page lost the next path: %s", body)
116 }
117
118 acct, inv, err := f.srv.Identity.CreateWithInvite("newbie", "test", time.Hour)
119 if err != nil {
120 t.Fatal(err)
121 }
122 resp, body = f.browserGet(t, "/invite/"+inv.Token, "")
123 if resp.StatusCode != http.StatusOK || !strings.Contains(body, acct.Name) || !strings.Contains(body, "Create a passkey") {
124 t.Fatalf("invite page: status=%d body=%s", resp.StatusCode, body)
125 }
126 resp, _ = f.browserGet(t, "/invite/not-a-token", "")
127 if resp.StatusCode != http.StatusNotFound {
128 t.Fatalf("invalid invite status = %d, want 404", resp.StatusCode)
129 }
130 }
131
132 func TestLoginIsDisabledWithoutHTTPS(t *testing.T) {
133 t.Parallel()
134 f := newFixture(t)
135 resp, body := f.browserGet(t, "/login", "")
136 if resp.StatusCode != http.StatusOK || !strings.Contains(body, "public_url") {
137 t.Fatalf("disabled login page: status=%d body=%s", resp.StatusCode, body)
138 }
139 resp, _ = f.browserGet(t, "/invite/anything", "")
140 if resp.StatusCode != http.StatusNotFound {
141 t.Fatalf("disabled invite status = %d, want 404", resp.StatusCode)
142 }
143 }
144
145 func TestSessionVisibilityAndLogoutCSRF(t *testing.T) {
146 t.Parallel()
147 f := browserFixture(t)
148 sess, cookie := f.browserSession(t, f.ownerID)
149
150 // Anonymous still gets a private repository's 404.
151 resp, _ := f.browserGet(t, "/r/"+f.repo, "")
152 if resp.StatusCode != http.StatusNotFound {
153 t.Fatalf("anonymous private repo status = %d, want 404", resp.StatusCode)
154 }
155 resp, body := f.browserGet(t, "/r/"+f.repo, cookie)
156 if resp.StatusCode != http.StatusOK || !strings.Contains(body, "keenan") || !strings.Contains(body, "account") {
157 t.Fatalf("logged-in repo page: status=%d body=%s", resp.StatusCode, body)
158 }
159
160 // Logout is a state-changing POST and needs the session's CSRF token.
161 resp, _ = f.browserPost(t, "/logout", cookie, url.Values{})
162 if resp.StatusCode != http.StatusForbidden {
163 t.Fatalf("logout without CSRF status = %d, want 403", resp.StatusCode)
164 }
165 resp, _ = f.browserPost(t, "/logout", cookie, url.Values{"csrf": {sess.CSRF}})
166 if resp.StatusCode != http.StatusSeeOther {
167 t.Fatalf("logout status = %d, want 303", resp.StatusCode)
168 }
169 if _, err := f.srv.Sessions.Lookup(sess.Token); err == nil {
170 t.Fatal("logout did not revoke the session")
171 }
172 }
173
174 func TestWebIssueAndCommentWritesUseSessionIdentity(t *testing.T) {
175 t.Parallel()
176 f := browserFixture(t)
177 sess, cookie := f.browserSession(t, f.ownerID)
178
179 resp, body := f.browserPost(t, "/r/"+f.repo+"/issues", cookie, url.Values{
180 "csrf": {sess.CSRF}, "title": {"Browser filed issue"}, "body": {"Written from the web."},
181 })
182 if resp.StatusCode != http.StatusSeeOther {
183 t.Fatalf("create issue: status=%d body=%s", resp.StatusCode, body)
184 }
185 loc := resp.Header.Get("Location")
186 if !strings.HasPrefix(loc, "/r/"+f.repo+"/issues/") {
187 t.Fatalf("create issue redirect = %q", loc)
188 }
189 id := strings.TrimPrefix(loc, "/r/"+f.repo+"/issues/")
190
191 resp, body = f.browserGet(t, loc, cookie)
192 if resp.StatusCode != http.StatusOK || !strings.Contains(body, "Browser filed issue") || !strings.Contains(body, "Written from the web.") {
193 t.Fatalf("issue page: status=%d body=%s", resp.StatusCode, body)
194 }
195
196 resp, body = f.browserPost(t, loc+"/comments", cookie, url.Values{
197 "csrf": {sess.CSRF}, "body": {"A browser comment."},
198 })
199 if resp.StatusCode != http.StatusSeeOther {
200 t.Fatalf("comment: status=%d body=%s", resp.StatusCode, body)
201 }
202 _, body = f.browserGet(t, loc, cookie)
203 if !strings.Contains(body, "A browser comment.") {
204 t.Fatalf("comment missing from issue page: %s", body)
205 }
206
207 iss, err := index.LoadIssue(mustRepo(t, f), id)
208 if err != nil {
209 t.Fatal(err)
210 }
211 if iss.Author != "keenan" || len(iss.Comments) != 1 || iss.Comments[0].Author != "keenan" {
212 t.Fatalf("authors came from the wrong place: %+v", iss)
213 }
214
215 // A write without the token is refused before anything is committed.
216 resp, _ = f.browserPost(t, "/r/"+f.repo+"/issues", cookie, url.Values{
217 "title": {"forged"},
218 })
219 if resp.StatusCode != http.StatusForbidden {
220 t.Fatalf("issue without CSRF status = %d, want 403", resp.StatusCode)
221 }
222 }
223
224 func TestWebApprovalNamesTheSessionAccount(t *testing.T) {
225 t.Parallel()
226 f := browserFixture(t)
227 f.addWriter(t)
228 m := mustRepo(t, f)
229 head := strings.Repeat("a", 40)
230 req, err := metadata.NewRequest("A change", "please review", "wilbur", f.writerID, "main", head, time.Now().UTC())
231 if err != nil {
232 t.Fatal(err)
233 }
234 if err := metadata.CreateRequest(m.Git(), req, "wilbur"); err != nil {
235 t.Fatal(err)
236 }
237
238 sess, cookie := f.browserSession(t, f.ownerID)
239 resp, body := f.browserPost(t, "/r/"+f.repo+"/requests/"+req.ID+"/approve", cookie,
240 url.Values{"csrf": {sess.CSRF}})
241 if resp.StatusCode != http.StatusSeeOther {
242 t.Fatalf("approve: status=%d body=%s", resp.StatusCode, body)
243 }
244 list, err := metadata.LoadRequests(m.Git(), metadata.Ref)
245 if err != nil {
246 t.Fatal(err)
247 }
248 if len(list) != 1 || len(list[0].Approvals) != 1 || list[0].Approvals[0].Principal != f.ownerID {
249 t.Fatalf("approval was not written for the session account: %+v", list)
250 }
251 }
252
253 func TestWebAuthnBeginEndpoints(t *testing.T) {
254 t.Parallel()
255 f := browserFixture(t)
256
257 // A credential is enough for BeginLogin; no assertion is completed here.
258 id := make([]byte, 16)
259 pub := make([]byte, 32)
260 for _, b := range [][]byte{id, pub} {
261 if _, err := rand.Read(b); err != nil {
262 t.Fatal(err)
263 }
264 }
265 if err := f.srv.Identity.AddCredential(f.ownerID, identity.Credential{ID: id, PublicKey: pub, Label: "test"}, "test"); err != nil {
266 t.Fatal(err)
267 }
268 resp, out := f.browserJSON(t, "/auth/login/begin", "", "", map[string]string{"username": "keenan"})
269 if resp.StatusCode != http.StatusOK || out["publicKey"] == nil {
270 t.Fatalf("login begin: status=%d body=%v", resp.StatusCode, out)
271 }
272
273 _, inv, err := f.srv.Identity.CreateWithInvite("newbie", "test", time.Hour)
274 if err != nil {
275 t.Fatal(err)
276 }
277 resp, out = f.browserJSON(t, "/auth/invite/begin", "", "", map[string]string{"token": inv.Token})
278 if resp.StatusCode != http.StatusOK || out["publicKey"] == nil {
279 t.Fatalf("invite begin: status=%d body=%v", resp.StatusCode, out)
280 }
281
282 // Account registration is state-changing and must demand the CSRF token.
283 sess, cookie := f.browserSession(t, f.ownerID)
284 resp, _ = f.browserJSON(t, "/account/passkeys/begin", cookie, "", map[string]string{"label": "phone"})
285 if resp.StatusCode != http.StatusForbidden {
286 t.Fatalf("passkey begin without CSRF status = %d, want 403", resp.StatusCode)
287 }
288 resp, out = f.browserJSON(t, "/account/passkeys/begin", cookie, sess.CSRF, map[string]string{"label": "phone"})
289 if resp.StatusCode != http.StatusOK || out["publicKey"] == nil {
290 t.Fatalf("passkey begin: status=%d body=%v", resp.StatusCode, out)
291 }
292 }
293
294 func TestContributorNamespaceAndRequestSurviveRename(t *testing.T) {
295 t.Parallel()
296 f := browserFixture(t)
297
298 pub, priv := keypair(t, filepath.Join(f.cfg.DataDir, "id_contrib"))
299 contrib, err := f.srv.Identity.Create("contrib", pub, "test")
300 if err != nil {
301 t.Fatal(err)
302 }
303 m := mustRepo(t, f)
304 a, err := m.ACL()
305 if err != nil {
306 t.Fatal(err)
307 }
308 if err := a.SetEntry(contrib.ID, "contributor"); err != nil {
309 t.Fatal(err)
310 }
311 if err := m.SetACL(a, "test"); err != nil {
312 t.Fatal(err)
313 }
314
315 base := workRepo(t, map[string]string{"readme.md": "base\n"})
316 if _, errs, err := f.git(base, "push", f.sshURL(f.repo), "main:refs/heads/main"); err != nil {
317 t.Fatalf("owner push: %v\n%s", err, errs)
318 }
319 work := workRepo(t, map[string]string{"readme.md": "contribution\n"})
320 headRef := "contrib/" + contrib.ID + "/fix"
321 if _, errs, err := f.gitWithKey(priv, work, "push", f.sshURL(f.repo), "main:refs/heads/"+headRef); err != nil {
322 t.Fatalf("contributor push: %v\n%s", err, errs)
323 }
324
325 // A different namespace is refused by the pre-receive gate.
326 other := workRepo(t, map[string]string{"x": "x\n"})
327 if _, _, err := f.gitWithKey(priv, other, "push", f.sshURL(f.repo), "main:refs/heads/contrib/someone-else/x"); err == nil {
328 t.Fatal("contributor pushed another account's namespace")
329 }
330
331 sess, cookie := f.browserSession(t, contrib.ID)
332 // A rename keeps the account id, which is the branch namespace and the ACL
333 // key. The session remains valid, and the request author uses the new name.
334 if _, err := f.srv.Identity.Rename(contrib.ID, "renamed", "test"); err != nil {
335 t.Fatal(err)
336 }
337
338 // Contributors can discuss without access to shared branches.
339 resp, body := f.browserPost(t, "/r/"+f.repo+"/issues", cookie, url.Values{
340 "csrf": {sess.CSRF}, "title": {"Contributor issue"}, "body": {"from outside"},
341 })
342 if resp.StatusCode != http.StatusSeeOther {
343 t.Fatalf("contributor issue: status=%d body=%s", resp.StatusCode, body)
344 }
345
346 resp, body = f.browserGet(t, "/r/"+f.repo+"/requests", cookie)
347 if resp.StatusCode != http.StatusOK || !strings.Contains(body, headRef) || !strings.Contains(body, "Open change request") {
348 t.Fatalf("request form: status=%d body=%s", resp.StatusCode, body)
349 }
350
351 resp, body = f.browserPost(t, "/r/"+f.repo+"/requests", cookie, url.Values{
352 "csrf": {sess.CSRF}, "title": {"Contributor patch"}, "body": {"please review"},
353 "base": {"main"}, "head": {headRef},
354 })
355 if resp.StatusCode != http.StatusSeeOther {
356 t.Fatalf("contributor request: status=%d body=%s", resp.StatusCode, body)
357 }
358 list, err := metadata.LoadRequests(m.Git(), metadata.Ref)
359 if err != nil {
360 t.Fatal(err)
361 }
362 if len(list) != 1 || list[0].AuthorID != contrib.ID || list[0].Author != "renamed" || list[0].Base != "main" {
363 t.Fatalf("request = %+v", list)
364 }
365 want, _ := m.Git().RevParse("refs/heads/" + headRef)
366 if list[0].Head != want {
367 t.Fatalf("request head = %s, want %s", list[0].Head, want)
368 }
369
370 // The handler refuses a head outside the contributor namespace even if the
371 // form is edited by hand.
372 resp, _ = f.browserPost(t, "/r/"+f.repo+"/requests", cookie, url.Values{
373 "csrf": {sess.CSRF}, "title": {"Wrong branch"}, "base": {"main"}, "head": {"main"},
374 })
375 if resp.StatusCode != http.StatusForbidden {
376 t.Fatalf("contributor opened a request from main: status=%d", resp.StatusCode)
377 }
378 }
379
380 func TestContributorCIIsHeldUntilWriterAllows(t *testing.T) {
381 t.Parallel()
382 f := newFixtureWith(t, func(c *config.Config) { c.LeaseSeconds = 2 })
383
384 pub, priv := keypair(t, filepath.Join(f.cfg.DataDir, "id_ci_contrib"))
385 contrib, err := f.srv.Identity.Create("ci-contrib", pub, "test")
386 if err != nil {
387 t.Fatal(err)
388 }
389 m := mustRepo(t, f)
390 a, err := m.ACL()
391 if err != nil {
392 t.Fatal(err)
393 }
394 if err := a.SetEntry(contrib.ID, "contributor"); err != nil {
395 t.Fatal(err)
396 }
397 if err := m.SetACL(a, "test"); err != nil {
398 t.Fatal(err)
399 }
400
401 work := workRepo(t, workflowFile(`version: 1
402 jobs:
403 test:
404 steps:
405 - name: say hello
406 run: echo "held until allowed"
407 `))
408 head, _, err := runGit(t, work, "rev-parse", "HEAD")
409 if err != nil {
410 t.Fatal(err)
411 }
412 head = strings.TrimSpace(head)
413 ref := "contrib/" + contrib.ID + "/ci"
414 if _, errs, err := f.gitWithKey(priv, work, "push", f.sshURL(f.repo), "HEAD:refs/heads/"+ref); err != nil {
415 t.Fatalf("contributor push: %v\n%s", err, errs)
416 }
417
418 // The spool ticker is one second; two seconds is enough to see that ingestion
419 // deliberately skipped the tip rather than merely not having reached it.
420 deadline := time.Now().Add(2 * time.Second)
421 for time.Now().Before(deadline) {
422 if list, err := f.srv.Runs.Recent(f.repo, 20); err == nil {
423 for _, r := range list {
424 if r.SHA == head {
425 t.Fatalf("CI ran for an unapproved contributor tip: %+v", r)
426 }
427 }
428 }
429 time.Sleep(100 * time.Millisecond)
430 }
431
432 contribSess, contribCookie := f.browserSession(t, contrib.ID)
433 resp, body := f.browserGet(t, "/r/"+f.repo, contribCookie)
434 if resp.StatusCode != http.StatusOK || !strings.Contains(body, "waiting for a maintainer") {
435 t.Fatalf("contributor branch status: status=%d body=%s", resp.StatusCode, body)
436 }
437 resp, _ = f.browserPost(t, "/r/"+f.repo+"/ci/allow", contribCookie, url.Values{
438 "csrf": {contribSess.CSRF}, "ref": {"refs/heads/" + ref},
439 })
440 if resp.StatusCode != http.StatusForbidden {
441 t.Fatalf("contributor allowed their own CI: status=%d", resp.StatusCode)
442 }
443
444 ownerSess, ownerCookie := f.browserSession(t, f.ownerID)
445 resp, body = f.browserGet(t, "/r/"+f.repo, ownerCookie)
446 if resp.StatusCode != http.StatusOK || !strings.Contains(body, "Allow CI for this tip") {
447 t.Fatalf("maintainer CI control missing: status=%d body=%s", resp.StatusCode, body)
448 }
449 resp, body = f.browserPost(t, "/r/"+f.repo+"/ci/allow", ownerCookie, url.Values{
450 "csrf": {ownerSess.CSRF}, "ref": {"refs/heads/" + ref},
451 })
452 if resp.StatusCode != http.StatusSeeOther {
453 t.Fatalf("maintainer allow: status=%d body=%s", resp.StatusCode, body)
454 }
455 run := f.waitForRun(t, 5*time.Second, func(r *runs.Run) bool { return r.SHA == head })
456 if run.Ref != "refs/heads/"+ref || run.Status != runs.StatusQueued {
457 t.Fatalf("queued run = %+v", run)
458 }
459 }