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/ssh/admin.go +155−5

@@ -160,14 +160,29 @@ func (s *Server) adminUser(action string, args []string, actor string, in io.Rea
160 160 }, nil
161 161
162 162 case "create":
163 if len(args) != 1 {
164 return nil, nil, &adminUsageError{msg: "usage: vole-admin user create <name> (public key on stdin)"}
163 f, err := parseAdminFlags(args, adminFlagSpec{long: "no-key", boolean: true})
164 if err != nil {
165 return nil, nil, err
166 }
167 if len(f.rest) != 1 {
168 return nil, nil, &adminUsageError{msg: "usage: vole-admin user create [--no-key] <name> (public key on stdin unless --no-key)"}
169 }
170 if f.has("no-key") {
171 a, inv, err := s.cfg.Identity.CreateWithInvite(f.rest[0], actor, 0)
172 if err != nil {
173 return nil, nil, err
174 }
175 data := map[string]any{"account": userData(a), "invite": s.inviteData(inv)}
176 return data, func(out, errOut io.Writer) {
177 fmt.Fprintf(out, "%s\t%s\n", a.Name, a.ID)
178 s.renderInvite(inv, out)
179 }, nil
165 180 }
166 181 key, err := readAdminKey(in)
167 182 if err != nil {
168 183 return nil, nil, err
169 184 }
170 a, err := s.cfg.Identity.Create(args[0], key, actor)
185 a, err := s.cfg.Identity.Create(f.rest[0], key, actor)
171 186 if err != nil {
172 187 return nil, nil, err
173 188 }
@@ -178,6 +193,124 @@ func (s *Server) adminUser(action string, args []string, actor string, in io.Rea
178 193 }
179 194 }, nil
180 195
196 case "invite":
197 if len(args) != 1 {
198 return nil, nil, &adminUsageError{msg: "usage: vole-admin user invite <name|id>"}
199 }
200 inv, err := s.cfg.Identity.Invite(args[0], actor, 0)
201 if err != nil {
202 return nil, nil, err
203 }
204 return s.inviteData(inv), func(out, _ io.Writer) {
205 s.renderInvite(inv, out)
206 }, nil
207
208 case "credentials":
209 if len(args) != 1 {
210 return nil, nil, &adminUsageError{msg: "usage: vole-admin user credentials <name|id>"}
211 }
212 creds, err := s.cfg.Identity.Credentials(args[0])
213 if err != nil {
214 return nil, nil, err
215 }
216 data := make([]map[string]any, 0, len(creds))
217 for _, c := range creds {
218 data = append(data, map[string]any{
219 "id": identity.CredentialIDString(c.ID), "label": c.Label,
220 "created": c.Created, "last_used": c.LastUsed,
221 })
222 }
223 return data, func(out, _ io.Writer) {
224 if len(creds) == 0 {
225 fmt.Fprintln(out, "no passkeys")
226 return
227 }
228 for _, c := range creds {
229 label := c.Label
230 if label == "" {
231 label = "passkey"
232 }
233 fmt.Fprintf(out, "%s %-16s created %s\n", identity.CredentialIDString(c.ID), label, c.Created.Format(time.RFC3339))
234 }
235 }, nil
236
237 case "rm-credential":
238 if len(args) != 2 {
239 return nil, nil, &adminUsageError{msg: "usage: vole-admin user rm-credential <name|id> <credential-id>"}
240 }
241 id, err := identity.ParseCredentialID(args[1])
242 if err != nil {
243 return nil, nil, err
244 }
245 if err := s.cfg.Identity.RemoveCredential(args[0], id, actor); err != nil {
246 return nil, nil, err
247 }
248 return map[string]any{"credential": args[1]}, func(out, _ io.Writer) {
249 fmt.Fprintf(out, "revoked passkey %s\n", args[1])
250 }, nil
251
252 case "sessions":
253 if len(args) != 1 {
254 return nil, nil, &adminUsageError{msg: "usage: vole-admin user sessions <name|id>"}
255 }
256 a, err := s.cfg.Identity.Find(args[0])
257 if err != nil {
258 return nil, nil, err
259 }
260 if s.cfg.Instance == nil || s.cfg.Instance.Sessions == nil {
261 return nil, nil, errors.New("session store is not wired into this server")
262 }
263 list, err := s.cfg.Instance.Sessions.List(a.ID)
264 if err != nil {
265 return nil, nil, err
266 }
267 return list, func(out, _ io.Writer) {
268 if len(list) == 0 {
269 fmt.Fprintf(out, "no sessions for %s\n", a.Name)
270 return
271 }
272 for _, sess := range list {
273 fmt.Fprintf(out, "created %s last %s expires %s %s\n",
274 sess.Created.Format(time.RFC3339), sess.LastSeen.Format(time.RFC3339),
275 sess.Expires.Format(time.RFC3339), sess.UserAgent)
276 }
277 }, nil
278
279 case "revoke-sessions":
280 f, err := parseAdminFlags(args, adminFlagSpec{long: "all", boolean: true})
281 if err != nil {
282 return nil, nil, err
283 }
284 if s.cfg.Instance == nil || s.cfg.Instance.Sessions == nil {
285 return nil, nil, errors.New("session store is not wired into this server")
286 }
287 if f.has("all") {
288 if len(f.rest) != 0 {
289 return nil, nil, &adminUsageError{msg: "usage: vole-admin user revoke-sessions [--all | <name|id>]"}
290 }
291 n, err := s.cfg.Instance.Sessions.RevokeAll()
292 if err != nil {
293 return nil, nil, err
294 }
295 return map[string]any{"revoked": n}, func(out, _ io.Writer) {
296 fmt.Fprintf(out, "revoked %d session(s)\n", n)
297 }, nil
298 }
299 if len(f.rest) != 1 {
300 return nil, nil, &adminUsageError{msg: "usage: vole-admin user revoke-sessions [--all | <name|id>]"}
301 }
302 a, err := s.cfg.Identity.Find(f.rest[0])
303 if err != nil {
304 return nil, nil, err
305 }
306 n, err := s.cfg.Instance.Sessions.RevokeAccount(a.ID)
307 if err != nil {
308 return nil, nil, err
309 }
310 return map[string]any{"revoked": n, "account": userData(a)}, func(out, _ io.Writer) {
311 fmt.Fprintf(out, "revoked %d session(s) for %s\n", n, a.Name)
312 }, nil
313
181 314 case "add-key":
182 315 if len(args) != 1 {
183 316 return nil, nil, &adminUsageError{msg: "usage: vole-admin user add-key <name|id>"}
@@ -396,7 +529,7 @@ func (s *Server) adminACL(action string, args []string, actor string) (any, admi
396 529
397 530 case "set":
398 531 if len(args) != 3 {
399 return nil, nil, &adminUsageError{msg: "usage: vole-admin acl set <repo> <user> <none|read|write|admin>"}
532 return nil, nil, &adminUsageError{msg: "usage: vole-admin acl set <repo> <user> <none|read|contributor|maintain|admin>"}
400 533 }
401 534 role, err := access.ParseRole(args[2])
402 535 if err != nil {
@@ -413,7 +546,7 @@ func (s *Server) adminACL(action string, args []string, actor string) (any, admi
413 546
414 547 case "default":
415 548 if len(args) != 2 {
416 return nil, nil, &adminUsageError{msg: "usage: vole-admin acl default <repo> <none|read|write|admin>"}
549 return nil, nil, &adminUsageError{msg: "usage: vole-admin acl default <repo> <none|read|contributor|maintain|admin>"}
417 550 }
418 551 role, err := access.ParseRole(args[1])
419 552 if err != nil {
@@ -618,6 +751,23 @@ func readAdminKey(r io.Reader) (string, error) {
618 751 return string(data), nil
619 752 }
620 753
754 func (s *Server) inviteData(inv *identity.Invite) map[string]any {
755 url := ""
756 if base := strings.TrimRight(s.cfg.PublicURL, "/"); base != "" {
757 url = base + "/invite/" + inv.Token
758 }
759 return map[string]any{"token": inv.Token, "url": url, "expires": inv.Expires}
760 }
761
762 func (s *Server) renderInvite(inv *identity.Invite, out io.Writer) {
763 if base := strings.TrimRight(s.cfg.PublicURL, "/"); base != "" {
764 fmt.Fprintf(out, "enrollment: %s/invite/%s\n", base, inv.Token)
765 } else {
766 fmt.Fprintf(out, "enrollment token: %s (set public_url to print a link)\n", inv.Token)
767 }
768 fmt.Fprintf(out, "expires: %s\n", inv.Expires.Format(time.RFC3339))
769 }
770
621 771 func userData(a *identity.Account) map[string]any {
622 772 if a == nil {
623 773 return nil