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 | |