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/server.go +31−3

@@ -18,6 +18,7 @@ import (
18 18 "github.com/rodent-software/vole/forge/identity"
19 19 "github.com/rodent-software/vole/forge/index"
20 20 "github.com/rodent-software/vole/forge/repo"
21 "github.com/rodent-software/vole/forge/session"
21 22 "github.com/rodent-software/vole/server/githttp"
22 23 "github.com/rodent-software/vole/server/pages"
23 24 "github.com/rodent-software/vole/server/ssh"
@@ -30,6 +31,7 @@ type Server struct {
30 31 Cfg *config.Config
31 32 Repos *repo.Store
32 33 Identity *identity.Store
34 Sessions *session.Store
33 35 Runs *runs.Store
34 36 Index *index.Store
35 37 LFS *lfs.Store
@@ -93,12 +95,18 @@ func New(cfg *config.Config, binPath string, log *slog.Logger) (*Server, error)
93 95 }
94 96 wp.LFS = inst.LFS
95 97 wp.Locks = inst.Locks
98 wp.Sessions = inst.Sessions
96 99 wp.Logger = func(format string, args ...any) { log.Warn(fmt.Sprintf(format, args...)) }
100 if wp.Auth != nil && !wp.Auth.Enabled() {
101 log.Info("browser login disabled", "reason", wp.Auth.Disabled)
102 }
97 103
98 104 s := &Server{
99 Cfg: cfg, Repos: inst.Repos, Identity: inst.Identity, Runs: inst.Runs, Index: inst.Index,
105 Cfg: cfg, Repos: inst.Repos, Identity: inst.Identity, Sessions: inst.Sessions,
106 Runs: inst.Runs, Index: inst.Index,
100 107 LFS: inst.LFS, Locks: inst.Locks, Proxy: proxy, SSH: ssh, Web: wp, Log: log,
101 108 }
109 wp.AllowCI = s.allowContributorCI
102 110 // Sites are opt-in: without a pages domain there is no host to serve them on,
103 111 // and an instance that never wanted them pays no processes for the idea.
104 112 if cfg.PagesDomain != "" {
@@ -130,6 +138,17 @@ func (s *Server) routes() *http.ServeMux {
130 138 w := s.Web
131 139
132 140 mux.HandleFunc("GET /{$}", w.IndexPage)
141 mux.HandleFunc("GET /login", w.LoginPage)
142 mux.HandleFunc("POST /auth/login/begin", w.LoginBegin)
143 mux.HandleFunc("POST /auth/login/finish", w.LoginFinish)
144 mux.HandleFunc("POST /logout", w.Logout)
145 mux.HandleFunc("GET /invite/{token}", w.InvitePage)
146 mux.HandleFunc("POST /auth/invite/begin", w.InviteBegin)
147 mux.HandleFunc("POST /auth/invite/finish", w.InviteFinish)
148 mux.HandleFunc("GET /account", w.AccountPage)
149 mux.HandleFunc("POST /account/passkeys/begin", w.AddPasskeyBegin)
150 mux.HandleFunc("POST /account/passkeys/finish", w.AddPasskeyFinish)
151 mux.HandleFunc("GET /auth.js", w.AuthJS)
133 152 mux.HandleFunc("GET /runners", w.RunnersPage)
134 153 mux.HandleFunc("GET /r/{name}", w.Repo)
135 154 mux.HandleFunc("GET /r/{name}/tree/{ref}/{path...}", w.Tree)
@@ -138,9 +157,14 @@ func (s *Server) routes() *http.ServeMux {
138 157 mux.HandleFunc("GET /r/{name}/commits/{ref}", w.Commits)
139 158 mux.HandleFunc("GET /r/{name}/commit/{sha}", w.CommitView)
140 159 mux.HandleFunc("GET /r/{name}/issues", w.Issues)
160 mux.HandleFunc("POST /r/{name}/issues", w.CreateIssue)
141 161 mux.HandleFunc("GET /r/{name}/issues/{id}", w.IssueView)
162 mux.HandleFunc("POST /r/{name}/issues/{id}/comments", w.AddIssueComment)
142 163 mux.HandleFunc("GET /r/{name}/requests", w.Requests)
164 mux.HandleFunc("POST /r/{name}/requests", w.CreateChangeRequest)
143 165 mux.HandleFunc("GET /r/{name}/requests/{id}", w.RequestView)
166 mux.HandleFunc("POST /r/{name}/requests/{id}/approve", w.ApproveRequest)
167 mux.HandleFunc("POST /r/{name}/ci/allow", w.AllowContributorCI)
144 168 // A tag may contain "/", so the detail route takes the rest of the path; the
145 169 // two archive routes are more specific and win for their suffixes.
146 170 mux.HandleFunc("GET /r/{name}/locks", w.LocksPage)
@@ -171,15 +195,16 @@ func (s *Server) routes() *http.ServeMux {
171 195 // Handler returns the HTTP surface. Site hosts are dispatched first: a request
172 196 // for <repo>.<pages_domain> is a page, and everything else is the forge UI.
173 197 func (s *Server) Handler() http.Handler {
198 forge := s.Web.Middleware(s.mux)
174 199 if s.Pages == nil {
175 return s.mux
200 return forge
176 201 }
177 202 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
178 203 if _, ok := s.Pages.Host(r.Host); ok {
179 204 s.Pages.ServeHTTP(w, r)
180 205 return
181 206 }
182 s.mux.ServeHTTP(w, r)
207 forge.ServeHTTP(w, r)
183 208 })
184 209 }
185 210
@@ -267,6 +292,9 @@ func (s *Server) Close() error {
267 292 return err
268 293 }
269 294 }
295 if s.Sessions != nil {
296 _ = s.Sessions.Close()
297 }
270 298 if s.Identity != nil {
271 299 return s.Identity.Close()
272 300 }