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/ci.go +62

@@ -9,6 +9,7 @@ import (
9 9 "github.com/rodent-software/vole/ci"
10 10 "github.com/rodent-software/vole/ci/runs"
11 11 "github.com/rodent-software/vole/core/id"
12 "github.com/rodent-software/vole/forge/access"
12 13 "github.com/rodent-software/vole/metadata"
13 14 )
14 15
@@ -65,6 +66,20 @@ func (s *Server) enqueue(ev runs.Event) {
65 66 if ev.New == "" || strings.HasPrefix(ev.New, "0000000000000000000000000000000000000000") {
66 67 return // a deletion
67 68 }
69 // A contributor branch is untrusted code. It does not reach a runner until a
70 // writer has allowed this exact tip; the approval is keyed by SHA, so a new
71 // push is a new decision. This check covers both the spool and reconciliation.
72 if _, contributorBranch := access.ContributorRefAccount(ev.Ref); contributorBranch {
73 allowed, err := s.Runs.CIAllowed(ev.Repo, ev.Ref, ev.New)
74 if err != nil {
75 s.Log.Warn("ci: cannot read contributor approval", "repo", ev.Repo, "ref", ev.Ref, "err", err)
76 return
77 }
78 if !allowed {
79 s.Log.Debug("ci: contributor branch is waiting for a maintainer to allow it", "repo", ev.Repo, "ref", ev.Ref)
80 return
81 }
82 }
68 83 m, err := s.Repos.Open(ev.Repo)
69 84 if err != nil {
70 85 s.Log.Warn("ci: cannot open the pushed repository", "repo", ev.Repo, "err", err)
@@ -226,6 +241,20 @@ func (s *Server) reconcileRefs() {
226 241 if held, ok := cursors[ref]; ok && held == tip {
227 242 continue
228 243 }
244 // A contributor tip without an approval is not owed. Leave the cursor
245 // untouched: the allow action queues the tip, and if that enqueue hits a
246 // transient error the next reconciliation can retry it.
247 if _, contributorBranch := access.ContributorRefAccount(ref); contributorBranch {
248 allowed, err := s.Runs.CIAllowed(m.Name, ref, tip)
249 if err != nil {
250 s.Log.Warn("ci: cannot read contributor approval", "repo", m.Name, "ref", ref, "err", err)
251 continue
252 }
253 if !allowed {
254 s.Log.Debug("ci: contributor branch is waiting for a maintainer to allow it", "repo", m.Name, "ref", ref)
255 continue
256 }
257 }
229 258 // enqueue re-checks the tip against the ref, so an event that raced with
230 259 // another push is dropped there rather than trusted here.
231 260 s.enqueue(runs.Event{
@@ -322,3 +351,36 @@ func (s *Server) watchLeases(ctx context.Context) {
322 351 }
323 352 }
324 353 }
354
355 // allowContributorCI is the queue-side half of the "Allow CI" button. The web has
356 // already checked that the approver has maintain access; this re-checks the ref is
357 // still the contributor branch tip that was approved, records the decision, and
358 // queues it. The approval is keyed by SHA, so the next push waits again.
359 func (s *Server) allowContributorCI(repo, ref, sha, approver string) error {
360 if _, ok := access.ContributorRefAccount(ref); !ok {
361 return fmt.Errorf("ci: %s is not a contributor branch", ref)
362 }
363 m, err := s.Repos.Open(repo)
364 if err != nil {
365 return err
366 }
367 tip, err := m.Git().RevParseRaw(ref)
368 if err != nil || tip == "" {
369 return fmt.Errorf("ci: contributor branch %s does not exist", ref)
370 }
371 if tip != sha {
372 return fmt.Errorf("ci: %s moved; approve the new tip", ref)
373 }
374 now := time.Now().UTC()
375 if err := s.Runs.AllowCI(repo, ref, sha, approver, now); err != nil {
376 return err
377 }
378 // Reconciliation may already have recorded this tip while it was unapproved.
379 // Dropping the cursor makes the next pass retry if enqueue hits a transient
380 // error; enqueue is idempotent, so a successful pass costs only a second no-op.
381 if err := s.Runs.DropCursor(repo, ref); err != nil {
382 s.Log.Warn("ci: cannot clear a contributor ref cursor", "repo", repo, "ref", ref, "err", err)
383 }
384 s.enqueue(runs.Event{Repo: repo, Ref: ref, New: sha, At: now.Format(time.RFC3339Nano)})
385 return nil
386 }