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/git/vole.git

Write over SSH with a registered key:

git clone ssh://git@0.0.0.0:2222/vole.git

A free and open-source forge that lives in your Git repo.

Code

392 commit(s) on main

Merge change request 01M70WES5ZZDJ6RP226WB11BBZ: Let people rename and remove their own passkeys keenan · 16m ago 4b8c9e1d
history blame raw 30.0 KiB
1package ssh
2
3// Remote administration over SSH. The command grammar is fixed; nothing here
4// goes through a shell. Authorization is the account name from the SSH handshake,
5// and every successful or rejected command is recorded with that identity.
6
7import (
8 "encoding/json"
9 "errors"
10 "fmt"
11 "io"
12 "strconv"
13 "strings"
14 "time"
15
16 xssh "golang.org/x/crypto/ssh"
17
18 "rodent.software/vole/forge/access"
19 "rodent.software/vole/forge/identity"
20 "rodent.software/vole/forge/repo"
21 "rodent.software/vole/internal/audit"
22)
23
24const maxAdminKeyBytes = 1 << 20
25
26type adminResponse struct {
27 OK bool `json:"ok"`
28 Data any `json:"data,omitempty"`
29 Error *adminError `json:"error,omitempty"`
30}
31
32type adminError struct {
33 Code string `json:"code"`
34 Message string `json:"message"`
35}
36
37type adminUsageError struct{ msg string }
38
39func (e *adminUsageError) Error() string { return e.msg }
40
41type adminRender func(stdout, stderr io.Writer)
42
43// serveAdmin runs one `vole-admin` command and returns the process exit code.
44func (s *Server) serveAdmin(ch xssh.Channel, argv []string, user, remote string) int {
45 jsonOut, args, err := splitAdminJSON(argv)
46 if err != nil {
47 return s.adminError(ch, jsonOut, "usage", err, user, remote)
48 }
49 if !s.cfg.Identity.IsAdmin(user) {
50 return s.adminError(ch, jsonOut, "permission_denied",
51 fmt.Errorf("instance admin required for %s", user), user, remote)
52 }
53 if s.cfg.Instance == nil {
54 return s.adminError(ch, jsonOut, "unavailable",
55 errors.New("admin API is not wired into this server"), user, remote)
56 }
57 if len(args) < 2 {
58 return s.adminError(ch, jsonOut, "usage", &adminUsageError{msg: "usage: vole-admin <resource> <action> [args]"},
59 user, remote)
60 }
61
62 // The command is recorded before it runs. An administrative change that cannot
63 // be recorded is not made: the log is the only history of the roster and the
64 // access rules, so a silent success would be a change nobody can explain.
65 if err := s.auditAdmin(user, remote, strings.Join(args, " ")); err != nil {
66 return s.adminUnavailable(ch, jsonOut, err)
67 }
68 data, render, err := s.runAdmin(args, user, ch)
69 if err != nil {
70 return s.adminError(ch, jsonOut, "failed", err, user, remote)
71 }
72 if jsonOut {
73 _ = json.NewEncoder(ch).Encode(adminResponse{OK: true, Data: data})
74 } else if render != nil {
75 render(ch, ch.Stderr())
76 }
77 s.auditAdminOutcome(user, remote, strings.Join(args, " ")+" ok")
78 return 0
79}
80
81// adminUnavailable answers a command that was not run because it could not be
82// recorded. It does not try to record the refusal: that is the thing that failed.
83func (s *Server) adminUnavailable(ch xssh.Channel, jsonOut bool, err error) int {
84 if jsonOut {
85 _ = json.NewEncoder(ch).Encode(adminResponse{OK: false, Error: &adminError{
86 Code: "audit_unavailable", Message: "the audit log could not be written: " + err.Error()}})
87 } else {
88 fmt.Fprintf(ch.Stderr(), "vole: the audit log could not be written, so nothing was changed: %v\n", err)
89 }
90 s.cfg.Log.Error("admin command refused: audit log unwritable", "error", err)
91 return 1
92}
93
94func (s *Server) adminError(ch xssh.Channel, jsonOut bool, code string, err error, user, remote string) int {
95 exitCode := 1
96 if _, ok := err.(*adminUsageError); ok {
97 exitCode = 129
98 }
99 if jsonOut {
100 _ = json.NewEncoder(ch).Encode(adminResponse{OK: false, Error: &adminError{Code: code, Message: err.Error()}})
101 } else {
102 fmt.Fprintf(ch.Stderr(), "vole: %v\n", err)
103 }
104 s.auditAdminOutcome(user, remote, "failed: "+err.Error())
105 return exitCode
106}
107
108func splitAdminJSON(argv []string) (jsonOut bool, rest []string, err error) {
109 terminated := false
110 for _, a := range argv {
111 if terminated {
112 rest = append(rest, a)
113 continue
114 }
115 switch a {
116 case "--":
117 terminated = true
118 case "--json":
119 jsonOut = true
120 default:
121 rest = append(rest, a)
122 }
123 }
124 return
125}
126
127func (s *Server) runAdmin(args []string, actor string, in io.Reader) (any, adminRender, error) {
128 resource, action, rest := args[0], args[1], args[2:]
129 switch resource {
130 case "account", "user":
131 // "user" is the historical spelling of this resource.
132 return s.adminAccount(action, rest, actor, in)
133 case "repo":
134 return s.adminRepo(action, rest, actor)
135 case "acl":
136 return s.adminACL(action, rest, actor)
137 }
138 return nil, nil, &adminUsageError{msg: fmt.Sprintf("unknown admin resource %q", resource)}
139}
140
141func (s *Server) adminAccount(action string, args []string, actor string, in io.Reader) (any, adminRender, error) {
142 switch action {
143 case "list":
144 all := s.cfg.Identity.All()
145 data := make([]map[string]any, 0, len(all))
146 for _, a := range all {
147 data = append(data, accountData(a))
148 }
149 return data, func(out, _ io.Writer) {
150 if len(all) == 0 {
151 fmt.Fprintln(out, "no accounts")
152 return
153 }
154 for _, a := range all {
155 fmt.Fprintf(out, "%-32s %-8s %-12s keys=%d\n", a.Name, a.Status, a.Role, len(a.Keys))
156 for _, k := range a.Keys {
157 fmt.Fprintf(out, " %s %s %s\n", k.Fingerprint, k.Type, k.Comment)
158 }
159 }
160 }, nil
161
162 case "show":
163 if len(args) != 1 {
164 return nil, nil, &adminUsageError{msg: "usage: vole-admin account show <name>"}
165 }
166 a, err := s.cfg.Identity.Find(args[0])
167 if err != nil {
168 return nil, nil, err
169 }
170 return accountData(a), func(out, _ io.Writer) {
171 fmt.Fprintf(out, "name: %s\nstatus: %s\nrole: %s\nadmin: %t\ncreated: %s\n",
172 a.Name, a.Status, a.Role, a.Admin, a.Created.Format(time.RFC3339))
173 for _, k := range a.Keys {
174 fmt.Fprintf(out, "key: %s %s %s\n", k.Fingerprint, k.Type, k.Comment)
175 }
176 }, nil
177
178 case "create":
179 f, err := parseAdminFlags(args,
180 adminFlagSpec{long: "no-key", boolean: true},
181 adminFlagSpec{long: "bot", boolean: true},
182 adminFlagSpec{long: "role"})
183 if err != nil {
184 return nil, nil, err
185 }
186 if len(f.rest) != 1 {
187 return nil, nil, &adminUsageError{msg: "usage: vole-admin account create [--no-key] [--bot] [--role <role>] <name> (public key on stdin unless --no-key)"}
188 }
189 role, err := access.ParseRole(f.one("role"))
190 if err != nil {
191 return nil, nil, err
192 }
193 // A bot has no browser sign-in, so an invitation would be a secret it could
194 // never use. The local CLI refuses this too; refuse it here rather than
195 // create an account nobody can act as.
196 if f.has("bot") && f.has("no-key") {
197 return nil, nil, &adminUsageError{msg: "a bot account has no browser sign-in, so it needs a key: send a public key on stdin instead of passing --no-key"}
198 }
199 create := s.cfg.Identity.CreateWithRole
200 if f.has("bot") {
201 create = s.cfg.Identity.CreateBot
202 }
203 if f.has("no-key") {
204 a, inv, err := s.cfg.Identity.CreateWithInviteRole(f.rest[0], role, actor, 0)
205 if err != nil {
206 return nil, nil, err
207 }
208 data := map[string]any{"account": accountData(a), "invite": s.inviteData(inv)}
209 return data, func(out, errOut io.Writer) {
210 fmt.Fprintln(out, a.Name)
211 s.renderInvite(inv, out)
212 }, nil
213 }
214 key, err := readAdminKey(in)
215 if err != nil {
216 return nil, nil, err
217 }
218 a, err := create(f.rest[0], role, key, actor)
219 if err != nil {
220 return nil, nil, err
221 }
222 return accountData(a), func(out, errOut io.Writer) {
223 fmt.Fprintln(out, a.Name)
224 if len(a.Keys) > 0 {
225 fmt.Fprintf(errOut, "key %s\n", a.Keys[0].Fingerprint)
226 }
227 }, nil
228
229 case "role":
230 if len(args) != 2 {
231 return nil, nil, &adminUsageError{msg: "usage: vole-admin account role <name> <none|read|contribute|maintain|admin>"}
232 }
233 role, err := access.ParseRole(args[1])
234 if err != nil {
235 return nil, nil, err
236 }
237 a, err := s.cfg.Identity.SetInstanceRole(args[0], role, actor)
238 if err != nil {
239 return nil, nil, err
240 }
241 return accountData(a), func(out, _ io.Writer) {
242 fmt.Fprintf(out, "%s: %s\n", a.Name, a.Role)
243 }, nil
244
245 case "invite":
246 if len(args) != 1 {
247 return nil, nil, &adminUsageError{msg: "usage: vole-admin account invite <name>"}
248 }
249 inv, err := s.cfg.Identity.Invite(args[0], actor, 0)
250 if err != nil {
251 return nil, nil, err
252 }
253 return s.inviteData(inv), func(out, _ io.Writer) {
254 s.renderInvite(inv, out)
255 }, nil
256
257 case "credentials":
258 if len(args) != 1 {
259 return nil, nil, &adminUsageError{msg: "usage: vole-admin account credentials <name>"}
260 }
261 creds, err := s.cfg.Identity.Credentials(args[0])
262 if err != nil {
263 return nil, nil, err
264 }
265 data := make([]map[string]any, 0, len(creds))
266 for _, c := range creds {
267 data = append(data, map[string]any{
268 "id": identity.CredentialIDString(c.ID), "label": c.Label,
269 "created": c.Created, "last_used": c.LastUsed,
270 })
271 }
272 return data, func(out, _ io.Writer) {
273 if len(creds) == 0 {
274 fmt.Fprintln(out, "no passkeys")
275 return
276 }
277 for _, c := range creds {
278 label := c.Label
279 if label == "" {
280 label = "passkey"
281 }
282 fmt.Fprintf(out, "%s %-16s created %s\n", identity.CredentialIDString(c.ID), label, c.Created.Format(time.RFC3339))
283 }
284 }, nil
285
286 case "rm-credential":
287 if len(args) != 2 {
288 return nil, nil, &adminUsageError{msg: "usage: vole-admin account rm-credential <name> <credential-id>"}
289 }
290 id, err := identity.ParseCredentialID(args[1])
291 if err != nil {
292 return nil, nil, err
293 }
294 if err := s.cfg.Identity.RemoveCredential(args[0], id, actor); err != nil {
295 return nil, nil, err
296 }
297 return map[string]any{"credential": args[1]}, func(out, _ io.Writer) {
298 fmt.Fprintf(out, "revoked passkey %s\n", args[1])
299 }, nil
300
301 case "sessions":
302 if len(args) != 1 {
303 return nil, nil, &adminUsageError{msg: "usage: vole-admin account sessions <name>"}
304 }
305 a, err := s.cfg.Identity.Find(args[0])
306 if err != nil {
307 return nil, nil, err
308 }
309 if s.cfg.Instance == nil || s.cfg.Instance.Sessions == nil {
310 return nil, nil, errors.New("session store is not wired into this server")
311 }
312 list, err := s.cfg.Instance.Sessions.List(a.Name)
313 if err != nil {
314 return nil, nil, err
315 }
316 return list, func(out, _ io.Writer) {
317 if len(list) == 0 {
318 fmt.Fprintf(out, "no sessions for %s\n", a.Name)
319 return
320 }
321 for _, sess := range list {
322 fmt.Fprintf(out, "created %s last %s expires %s %s\n",
323 sess.Created.Format(time.RFC3339), sess.LastSeen.Format(time.RFC3339),
324 sess.Expires.Format(time.RFC3339), sess.UserAgent)
325 }
326 }, nil
327
328 case "revoke-sessions":
329 f, err := parseAdminFlags(args, adminFlagSpec{long: "all", boolean: true})
330 if err != nil {
331 return nil, nil, err
332 }
333 if s.cfg.Instance == nil || s.cfg.Instance.Sessions == nil {
334 return nil, nil, errors.New("session store is not wired into this server")
335 }
336 if f.has("all") {
337 if len(f.rest) != 0 {
338 return nil, nil, &adminUsageError{msg: "usage: vole-admin account revoke-sessions [--all | <name>]"}
339 }
340 n, err := s.cfg.Instance.Sessions.RevokeAll()
341 if err != nil {
342 return nil, nil, err
343 }
344 return map[string]any{"revoked": n}, func(out, _ io.Writer) {
345 fmt.Fprintf(out, "revoked %d session(s)\n", n)
346 }, nil
347 }
348 if len(f.rest) != 1 {
349 return nil, nil, &adminUsageError{msg: "usage: vole-admin account revoke-sessions [--all | <name>]"}
350 }
351 a, err := s.cfg.Identity.Find(f.rest[0])
352 if err != nil {
353 return nil, nil, err
354 }
355 n, err := s.cfg.Instance.Sessions.RevokeAccount(a.Name)
356 if err != nil {
357 return nil, nil, err
358 }
359 return map[string]any{"revoked": n, "account": accountData(a)}, func(out, _ io.Writer) {
360 fmt.Fprintf(out, "revoked %d session(s) for %s\n", n, a.Name)
361 }, nil
362
363 case "add-key":
364 if len(args) != 1 {
365 return nil, nil, &adminUsageError{msg: "usage: vole-admin account add-key <name>"}
366 }
367 key, err := readAdminKey(in)
368 if err != nil {
369 return nil, nil, err
370 }
371 fp, err := s.cfg.Identity.AddKey(args[0], key, actor)
372 if err != nil {
373 return nil, nil, err
374 }
375 a, _ := s.cfg.Identity.Find(args[0])
376 data := map[string]any{"fingerprint": fp, "account": accountData(a)}
377 return data, func(out, _ io.Writer) {
378 fmt.Fprintf(out, "added key %s for %s\n", fp, a.Name)
379 }, nil
380
381 case "rm-key":
382 if len(args) != 2 {
383 return nil, nil, &adminUsageError{msg: "usage: vole-admin account rm-key <name> <fingerprint>"}
384 }
385 if err := s.cfg.Identity.RemoveKey(args[0], args[1], actor); err != nil {
386 return nil, nil, err
387 }
388 return map[string]any{"fingerprint": args[1]}, func(out, _ io.Writer) {
389 fmt.Fprintf(out, "revoked %s\n", args[1])
390 }, nil
391
392 case "rename", "display":
393 return nil, nil, &adminUsageError{msg: "account names are permanent and are the only identifier an account has"}
394
395 case "retire", "rm", "delete":
396 if len(args) != 1 {
397 return nil, nil, &adminUsageError{msg: "usage: vole-admin account retire <name>"}
398 }
399 a, err := s.cfg.Identity.Retire(args[0], actor)
400 if err != nil {
401 return nil, nil, err
402 }
403 return accountData(a), func(out, errOut io.Writer) {
404 fmt.Fprintln(out, "retired "+a.Name)
405 fmt.Fprintln(errOut, "keys stop working within 5s; the name is never reissued")
406 }, nil
407
408 case "backup":
409 return nil, nil, &adminUsageError{msg: "account backup writes to the server's filesystem; run it locally"}
410
411 case "grant-admin", "revoke-admin":
412 if len(args) != 1 {
413 return nil, nil, &adminUsageError{msg: "usage: vole-admin account " + action + " <name>"}
414 }
415 a, err := s.cfg.Identity.SetAdmin(args[0], action == "grant-admin", actor)
416 if err != nil {
417 return nil, nil, err
418 }
419 return accountData(a), func(out, _ io.Writer) {
420 if action == "grant-admin" {
421 fmt.Fprintln(out, "granted instance admin to "+a.Name)
422 } else {
423 fmt.Fprintln(out, "revoked instance admin from "+a.Name)
424 }
425 }, nil
426 }
427 return nil, nil, &adminUsageError{msg: fmt.Sprintf("unknown account command %q", action)}
428}
429
430func (s *Server) adminRepo(action string, args []string, actor string) (any, adminRender, error) {
431 switch action {
432 case "list":
433 repos, err := s.cfg.Instance.Repos.List()
434 if err != nil {
435 return nil, nil, err
436 }
437 data := make([]map[string]any, 0, len(repos))
438 views := make([]*repo.Meta, 0, len(repos))
439 for _, m := range repos {
440 d, _, err := s.repoData(m)
441 if err != nil {
442 return nil, nil, err
443 }
444 data = append(data, d)
445 views = append(views, m)
446 }
447 return data, func(out, _ io.Writer) {
448 for _, d := range data {
449 vis := "private"
450 if d["public"].(bool) {
451 vis = "public"
452 }
453 fmt.Fprintf(out, "%-32s id=%s principals=%d %s\n", d["name"], d["id"], d["principals"], vis)
454 }
455 if len(views) == 0 {
456 fmt.Fprintln(out, "no repositories")
457 }
458 }, nil
459
460 case "show":
461 if len(args) != 1 {
462 return nil, nil, &adminUsageError{msg: "usage: vole-admin repo show <name>"}
463 }
464 m, err := s.cfg.Instance.Repos.Open(args[0])
465 if err != nil {
466 return nil, nil, fmt.Errorf("repository %q not found", args[0])
467 }
468 data, a, err := s.repoData(m)
469 if err != nil {
470 return nil, nil, err
471 }
472 return data, func(out, _ io.Writer) {
473 vis := "private"
474 if a.Can("", access.Read) {
475 vis = "public"
476 }
477 fmt.Fprintf(out, "name: %s\nid: %s\nvisibility: %s\ndefault branch: %s\ndescription: %s\n",
478 m.Name, m.ID, vis, m.DefaultBranch, m.Description)
479 }, nil
480
481 case "create":
482 f, err := parseAdminFlags(args,
483 adminFlagSpec{long: "owner", short: "o"},
484 adminFlagSpec{long: "description", short: "d"})
485 if err != nil {
486 return nil, nil, err
487 }
488 if len(f.rest) != 1 || f.one("owner") == "" {
489 return nil, nil, &adminUsageError{msg: "usage: vole-admin repo create --owner <account> <name> [--description <text>]"}
490 }
491 owner, err := s.cfg.Identity.Find(f.one("owner"))
492 if err != nil {
493 return nil, nil, err
494 }
495 if !owner.Active() {
496 return nil, nil, fmt.Errorf("owner %q is retired", owner.Name)
497 }
498 m, err := s.cfg.Instance.Repos.Create(f.rest[0], owner.Name, f.one("description"))
499 if err != nil {
500 return nil, nil, err
501 }
502 if err := s.cfg.Instance.Index.Upsert(m); err != nil {
503 return nil, nil, err
504 }
505 data, _, err := s.repoData(m)
506 if err != nil {
507 return nil, nil, err
508 }
509 return data, func(out, errOut io.Writer) {
510 fmt.Fprintf(out, "created %s (id %s)\n", m.Name, m.ID)
511 fmt.Fprintf(errOut, "new repositories are private; publish with: vole admin acl set-default-role %s read\n", m.Name)
512 }, nil
513
514 case "rename":
515 if len(args) != 2 {
516 return nil, nil, &adminUsageError{msg: "usage: vole-admin repo rename <old> <new>"}
517 }
518 if err := s.cfg.Instance.RenameRepo(args[0], args[1]); err != nil {
519 return nil, nil, err
520 }
521 return map[string]any{"old": args[0], "name": args[1]}, func(out, _ io.Writer) {
522 fmt.Fprintf(out, "renamed %s -> %s\n", args[0], args[1])
523 }, nil
524
525 case "delete":
526 if len(args) != 1 {
527 return nil, nil, &adminUsageError{msg: "usage: vole-admin repo delete <name>"}
528 }
529 if err := s.cfg.Instance.DeleteRepo(args[0], actor); err != nil {
530 return nil, nil, err
531 }
532 return map[string]any{"name": args[0]}, func(out, _ io.Writer) {
533 fmt.Fprintf(out, "deleted %s\n", args[0])
534 }, nil
535 }
536 return nil, nil, &adminUsageError{msg: fmt.Sprintf("unknown repo command %q", action)}
537}
538
539func (s *Server) adminACL(action string, args []string, actor string) (any, adminRender, error) {
540 var (
541 repoName string
542 mode string
543 accountName string
544 roleName string
545 approvals int
546 values []string
547 )
548 switch action {
549 case "show":
550 if len(args) != 1 {
551 return nil, nil, &adminUsageError{msg: "usage: vole-admin acl show <repo>"}
552 }
553 repoName = args[0]
554
555 case "set-role":
556 if len(args) != 3 {
557 return nil, nil, &adminUsageError{msg: "usage: vole-admin acl set-role <repo> <account> <none|read|contribute|maintain|admin>"}
558 }
559 repoName, accountName, roleName = args[0], args[1], args[2]
560
561 case "set-default-role":
562 if len(args) != 2 {
563 return nil, nil, &adminUsageError{msg: "usage: vole-admin acl set-default-role <repo> <none|read|contribute|maintain|admin>"}
564 }
565 repoName, roleName = args[0], args[1]
566
567 case "set-approvals":
568 if len(args) != 2 {
569 return nil, nil, &adminUsageError{msg: "usage: vole-admin acl set-approvals <repo> <n>"}
570 }
571 repoName = args[0]
572 n, err := strconv.Atoi(args[1])
573 if err != nil || n < 0 {
574 return nil, nil, &adminUsageError{msg: fmt.Sprintf("%q is not a number of approvals", args[1])}
575 }
576 approvals = n
577
578 case "checks":
579 if len(args) != 2 {
580 return nil, nil, &adminUsageError{msg: "usage: vole-admin acl checks enable|disable <repo>"}
581 }
582 mode, repoName = args[0], args[1]
583 if mode != "enable" && mode != "disable" {
584 return nil, nil, &adminUsageError{msg: fmt.Sprintf("checks takes enable or disable, not %q", mode)}
585 }
586
587 case "protect", "runners":
588 if len(args) < 2 {
589 return nil, nil, &adminUsageError{msg: fmt.Sprintf("usage: vole-admin acl %s list|add|remove|set|clear <repo> ...", action)}
590 }
591 mode, repoName, values = args[0], args[1], args[2:]
592 switch mode {
593 case "list", "clear":
594 if len(values) > 0 {
595 return nil, nil, &adminUsageError{msg: fmt.Sprintf("acl %s %s takes only a repository", action, mode)}
596 }
597 case "add", "remove", "set":
598 if len(values) == 0 {
599 if action == "protect" {
600 return nil, nil, &adminUsageError{msg: fmt.Sprintf("acl protect %s needs at least one ref pattern", mode)}
601 }
602 return nil, nil, &adminUsageError{msg: fmt.Sprintf("acl runners %s needs at least one account", mode)}
603 }
604 default:
605 return nil, nil, &adminUsageError{msg: fmt.Sprintf("unknown acl %s command %q", action, mode)}
606 }
607
608 default:
609 return nil, nil, &adminUsageError{msg: "usage: vole-admin acl <show|set-role|set-default-role|set-approvals|checks|protect|runners> <repo> ..."}
610 }
611
612 m, err := s.cfg.Instance.Repos.Open(repoName)
613 if err != nil {
614 return nil, nil, fmt.Errorf("repository %q not found", repoName)
615 }
616 a, err := m.ACL()
617 if err != nil {
618 return nil, nil, err
619 }
620 finish := func() (any, adminRender, error) {
621 if err := m.SetACL(a, actor); err != nil {
622 return nil, nil, err
623 }
624 if err := s.cfg.Instance.Index.Upsert(m); err != nil {
625 return nil, nil, err
626 }
627 return s.aclResult(m, a)
628 }
629
630 switch action {
631 case "show":
632 return s.aclResult(m, a)
633
634 case "set-role":
635 role, err := access.ParseRole(roleName)
636 if err != nil {
637 return nil, nil, err
638 }
639 account, err := s.cfg.Identity.Find(accountName)
640 if err != nil {
641 return nil, nil, err
642 }
643 if err := a.SetEntry(account.Name, role.String()); err != nil {
644 return nil, nil, err
645 }
646 return finish()
647
648 case "set-default-role":
649 role, err := access.ParseRole(roleName)
650 if err != nil {
651 return nil, nil, err
652 }
653 a.Default = role.String()
654 return finish()
655
656 case "set-approvals":
657 a.ApprovalsRequired = &approvals
658 return finish()
659
660 case "checks":
661 v := mode == "enable"
662 a.ChecksRequired = &v
663 return finish()
664
665 case "protect":
666 if mode == "list" {
667 return s.aclProtectedResult(m, a)
668 }
669 // A pattern IsProtected cannot match protects nothing, so it is refused
670 // here rather than stored and echoed back by `acl show` as if it worked.
671 if mode == "add" || mode == "set" {
672 if err := access.ValidateProtectedPatterns(values); err != nil {
673 return nil, nil, err
674 }
675 }
676 switch mode {
677 case "clear":
678 a.Protected = nil
679 case "set":
680 a.Protected = append([]string(nil), values...)
681 case "add":
682 for _, pattern := range values {
683 found := false
684 for _, existing := range a.Protected {
685 if existing == pattern {
686 found = true
687 break
688 }
689 }
690 if !found {
691 a.Protected = append(a.Protected, pattern)
692 }
693 }
694 case "remove":
695 next := append([]string(nil), a.Protected...)
696 for _, pattern := range values {
697 kept := make([]string, 0, len(next))
698 found := false
699 for _, existing := range next {
700 if existing == pattern {
701 found = true
702 continue
703 }
704 kept = append(kept, existing)
705 }
706 if !found {
707 return nil, nil, fmt.Errorf("%q is not protected for %s", pattern, m.Name)
708 }
709 next = kept
710 }
711 a.Protected = next
712 }
713 return finish()
714
715 case "runners":
716 if mode == "list" {
717 return s.aclRunnersResult(m, a)
718 }
719 resolve := func(name string) (string, error) {
720 account, err := s.cfg.Identity.Find(name)
721 if err != nil {
722 return "", err
723 }
724 return account.Name, nil
725 }
726 var next []string
727 switch mode {
728 case "clear":
729 // next stays empty
730 case "set":
731 next = make([]string, 0, len(values))
732 for _, name := range values {
733 id, err := resolve(name)
734 if err != nil {
735 return nil, nil, err
736 }
737 next = append(next, id)
738 }
739 case "add":
740 next = append(next, a.Runners...)
741 for _, name := range values {
742 id, err := resolve(name)
743 if err != nil {
744 return nil, nil, err
745 }
746 next = append(next, id)
747 }
748 case "remove":
749 next = append(next, a.Runners...)
750 for _, name := range values {
751 id, err := resolve(name)
752 if err != nil {
753 return nil, nil, err
754 }
755 kept, found := next[:0], false
756 for _, r := range next {
757 if r == id {
758 found = true
759 continue
760 }
761 kept = append(kept, r)
762 }
763 if !found {
764 return nil, nil, fmt.Errorf("%s is not a runner for %s", name, m.Name)
765 }
766 next = kept
767 }
768 }
769 a.SetRunners(next)
770 return finish()
771 }
772 return nil, nil, &adminUsageError{msg: fmt.Sprintf("unknown acl command %q", action)}
773}
774
775func (s *Server) aclProtectedResult(m *repo.Meta, a *access.ACL) (any, adminRender, error) {
776 protected := append([]string(nil), a.Protected...)
777 data := map[string]any{"repo": m.Name, "protected": protected}
778 return data, func(out, _ io.Writer) {
779 if len(a.Protected) == 0 {
780 fmt.Fprintf(out, "protected: none for %s\n", m.Name)
781 return
782 }
783 fmt.Fprintf(out, "protected: %s\n", strings.Join(a.Protected, ", "))
784 }, nil
785}
786
787func (s *Server) aclRunnersResult(m *repo.Meta, a *access.ACL) (any, adminRender, error) {
788 runners := append([]string(nil), a.Runners...)
789 data := map[string]any{"repo": m.Name, "runners": runners}
790 return data, func(out, _ io.Writer) {
791 for _, r := range a.Runners {
792 fmt.Fprintf(out, "runner %s\n", r)
793 }
794 if len(a.Runners) > 0 {
795 fmt.Fprintln(out, "(admins may also report CI results)")
796 return
797 }
798 fmt.Fprintf(out, "runners: none named; only admins may report CI results for %s\n", m.Name)
799 for _, p := range a.Principals() {
800 if a.CanReport(p) {
801 fmt.Fprintf(out, " admin %s\n", p)
802 }
803 }
804 }, nil
805}
806
807func (s *Server) repoData(m *repo.Meta) (map[string]any, *access.ACL, error) {
808 a, err := m.ACL()
809 if err != nil {
810 return nil, nil, err
811 }
812 return map[string]any{
813 "id": m.ID,
814 "name": m.Name,
815 "description": m.Description,
816 "default_branch": m.DefaultBranch,
817 "public": a.Can("", access.Read),
818 "principals": len(a.Entries),
819 "created": m.Created,
820 }, a, nil
821}
822
823func (s *Server) aclResult(m *repo.Meta, a *access.ACL) (any, adminRender, error) {
824 entries := make([]map[string]string, 0, len(a.Entries))
825 for _, principal := range a.Principals() {
826 entries = append(entries, map[string]string{
827 "name": principal,
828 "role": a.Entries[principal],
829 })
830 }
831 runners := make([]string, 0, len(a.Runners))
832 runners = append(runners, a.Runners...)
833 data := map[string]any{
834 "repo": m.Name,
835 "public": a.Can("", access.Read),
836 "default": a.Default,
837 "entries": entries,
838 "protected": a.Protected,
839 "approvals_required": a.RequiredApprovals(),
840 "checks_required": a.RequiresChecks(),
841 "runners": runners,
842 }
843 return data, func(out, _ io.Writer) {
844 vis := "private"
845 if a.Can("", access.Read) {
846 vis = "public"
847 }
848 fmt.Fprintf(out, "repo: %s (%s)\n", m.Name, vis)
849 fmt.Fprintf(out, "default: %s\n", a.Default)
850 for _, e := range entries {
851 fmt.Fprintf(out, " %-32s %s\n", e["name"], e["role"])
852 }
853 if len(a.Protected) > 0 {
854 fmt.Fprintf(out, "protected: %s\n", strings.Join(a.Protected, ", "))
855 }
856 fmt.Fprintf(out, "approvals: %d required on protected branches\n", a.RequiredApprovals())
857 if a.RequiresChecks() {
858 fmt.Fprintln(out, "checks: required on protected branches")
859 } else {
860 fmt.Fprintln(out, "checks: not required")
861 }
862 if len(a.Runners) == 0 {
863 fmt.Fprintln(out, "runners: none named (admins may report CI results)")
864 return
865 }
866 for _, r := range runners {
867 fmt.Fprintf(out, "runner %s\n", r)
868 }
869 fmt.Fprintln(out, "(admins may also report CI results)")
870 }, nil
871}
872
873func readAdminKey(r io.Reader) (string, error) {
874 data, err := io.ReadAll(io.LimitReader(r, maxAdminKeyBytes+1))
875 if err != nil {
876 return "", err
877 }
878 if len(data) > maxAdminKeyBytes {
879 return "", fmt.Errorf("public key is larger than %d bytes", maxAdminKeyBytes)
880 }
881 if strings.TrimSpace(string(data)) == "" {
882 return "", errors.New("no public key on stdin")
883 }
884 return string(data), nil
885}
886
887func (s *Server) inviteData(inv *identity.Invite) map[string]any {
888 url := ""
889 if base := strings.TrimRight(s.cfg.PublicURL, "/"); base != "" {
890 url = base + "/-/invite#" + inv.Token
891 }
892 return map[string]any{"token": inv.Token, "url": url, "expires": inv.Expires}
893}
894
895func (s *Server) renderInvite(inv *identity.Invite, out io.Writer) {
896 if base := strings.TrimRight(s.cfg.PublicURL, "/"); base != "" {
897 fmt.Fprintf(out, "enrollment: %s/invite#%s\n", base, inv.Token)
898 } else {
899 fmt.Fprintf(out, "enrollment token: %s (set public_url to print a link)\n", inv.Token)
900 }
901 fmt.Fprintf(out, "expires: %s\n", inv.Expires.Format(time.RFC3339))
902}
903
904func accountData(a *identity.Account) map[string]any {
905 if a == nil {
906 return nil
907 }
908 return map[string]any{
909 "name": a.Name,
910 "status": a.Status,
911 "admin": a.Admin,
912 "role": a.Role.String(),
913 "keys": a.Keys,
914 "created": a.Created,
915 }
916}
917
918// auditAdmin records one admin command and reports whether it was written. The
919// caller refuses the command when it was not: field escaping is handled by
920// internal/audit, so a command argument cannot forge a column or a record.
921func (s *Server) auditAdmin(user, remote, detail string) error {
922 return audit.Append(s.cfg.AuditPath, audit.Record{
923 Action: "admin",
924 Actor: user,
925 Subject: remote,
926 Detail: detail,
927 })
928}
929
930// auditAdminOutcome records how a command ended. The decision it describes has
931// already been made and applied, so this can only report its own failure.
932func (s *Server) auditAdminOutcome(user, remote, detail string) {
933 if err := s.auditAdmin(user, remote, detail); err != nil {
934 s.cfg.Log.Error("audit log write failed", "account", user, "detail", detail, "error", err)
935 }
936}
937
938// adminFlagSpec and parseAdminFlags are a small local flag parser for the
939// server side. The client CLI already parsed its own flags; this parses only the
940// flags that cross the SSH boundary.
941type adminFlagSpec struct {
942 long string
943 short string
944 boolean bool
945}
946
947type adminFlags struct {
948 vals map[string][]string
949 bools map[string]bool
950 rest []string
951}
952
953func parseAdminFlags(args []string, specs ...adminFlagSpec) (*adminFlags, error) {
954 f := &adminFlags{vals: map[string][]string{}, bools: map[string]bool{}}
955 byLong := map[string]adminFlagSpec{}
956 byShort := map[string]adminFlagSpec{}
957 for _, s := range specs {
958 byLong[s.long] = s
959 if s.short != "" {
960 byShort[s.short] = s
961 }
962 }
963 terminated := false
964 for i := 0; i < len(args); i++ {
965 a := args[i]
966 if terminated {
967 f.rest = append(f.rest, a)
968 continue
969 }
970 if a == "--" {
971 terminated = true
972 continue
973 }
974 switch {
975 case strings.HasPrefix(a, "--"):
976 name, value, hasValue := strings.Cut(strings.TrimPrefix(a, "--"), "=")
977 spec, ok := byLong[name]
978 if !ok {
979 return nil, &adminUsageError{msg: fmt.Sprintf("unknown option --%s", name)}
980 }
981 if spec.boolean {
982 if hasValue {
983 return nil, &adminUsageError{msg: fmt.Sprintf("option --%s takes no value", name)}
984 }
985 f.bools[spec.long] = true
986 continue
987 }
988 if !hasValue {
989 i++
990 if i >= len(args) {
991 return nil, &adminUsageError{msg: fmt.Sprintf("option --%s requires a value", name)}
992 }
993 value = args[i]
994 }
995 f.vals[spec.long] = append(f.vals[spec.long], value)
996 case strings.HasPrefix(a, "-") && a != "-":
997 short := a[1:2]
998 spec, ok := byShort[short]
999 if !ok {
1000 return nil, &adminUsageError{msg: fmt.Sprintf("unknown option -%s", short)}
1001 }
1002 if spec.boolean {
1003 if len(a) > 2 {
1004 return nil, &adminUsageError{msg: fmt.Sprintf("option -%s takes no value", short)}
1005 }
1006 f.bools[spec.long] = true
1007 continue
1008 }
1009 value := a[2:]
1010 if value == "" {
1011 i++
1012 if i >= len(args) {
1013 return nil, &adminUsageError{msg: fmt.Sprintf("option -%s requires a value", short)}
1014 }
1015 value = args[i]
1016 }
1017 f.vals[spec.long] = append(f.vals[spec.long], value)
1018 default:
1019 f.rest = append(f.rest, a)
1020 }
1021 }
1022 return f, nil
1023}
1024
1025func (f *adminFlags) one(name string) string {
1026 v := f.vals[name]
1027 if len(v) == 0 {
1028 return ""
1029 }
1030 return v[len(v)-1]
1031}
1032
1033func (f *adminFlags) has(name string) bool { return f.bools[name] }
1034