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.
Add remote administration and local workflow runs
3abbe749096f4d7ff76f4d468e4427829b8a6f95 · Keenan Nemetz · 16d ago · parent 520470e1
Add remote administration and local workflow runs Make `vole admin` the single namespace for user, repository, and access management, runnable locally or over SSH by instance admins. Instance-admin grants live in users.db, and the audit log names the actor separately from the account being changed. Add `vole workflow run` to execute workflow jobs in the working tree without a server or runner. Positional arguments select workflows, --job selects jobs, and the command reuses the push-time validator. Update the README and guides for the new command shapes.
server/ssh/admin.go +740
| @@ -0,0 +1,740 @@ | |||
| 1 | package ssh | ||
| 2 | |||
| 3 | // Remote administration over SSH. The command grammar is fixed; nothing here | ||
| 4 | // goes through a shell. Authorization is the account id from the SSH handshake, | ||
| 5 | // and every successful or rejected command is recorded with that identity. | ||
| 6 | |||
| 7 | import ( | ||
| 8 | "encoding/json" | ||
| 9 | "errors" | ||
| 10 | "fmt" | ||
| 11 | "io" | ||
| 12 | "os" | ||
| 13 | "strconv" | ||
| 14 | "strings" | ||
| 15 | "time" | ||
| 16 | |||
| 17 | xssh "golang.org/x/crypto/ssh" | ||
| 18 | |||
| 19 | "github.com/rodent-software/vole/forge/access" | ||
| 20 | "github.com/rodent-software/vole/forge/identity" | ||
| 21 | "github.com/rodent-software/vole/forge/repo" | ||
| 22 | ) | ||
| 23 | |||
| 24 | const maxAdminKeyBytes = 1 << 20 | ||
| 25 | |||
| 26 | type adminResponse struct { | ||
| 27 | OK bool `json:"ok"` | ||
| 28 | Data any `json:"data,omitempty"` | ||
| 29 | Error *adminError `json:"error,omitempty"` | ||
| 30 | } | ||
| 31 | |||
| 32 | type adminError struct { | ||
| 33 | Code string `json:"code"` | ||
| 34 | Message string `json:"message"` | ||
| 35 | } | ||
| 36 | |||
| 37 | type adminUsageError struct{ msg string } | ||
| 38 | |||
| 39 | func (e *adminUsageError) Error() string { return e.msg } | ||
| 40 | |||
| 41 | type adminRender func(stdout, stderr io.Writer) | ||
| 42 | |||
| 43 | // serveAdmin runs one `vole-admin` command and returns the process exit code. | ||
| 44 | func (s *Server) serveAdmin(ch xssh.Channel, argv []string, user, accountID, remote string) int { | ||
| 45 | jsonOut, args, err := splitAdminJSON(argv) | ||
| 46 | if err != nil { | ||
| 47 | return s.adminError(ch, jsonOut, "usage", err, user, accountID, remote) | ||
| 48 | } | ||
| 49 | if !s.cfg.Identity.IsAdmin(accountID) { | ||
| 50 | return s.adminError(ch, jsonOut, "permission_denied", | ||
| 51 | fmt.Errorf("instance admin required for %s", user), user, accountID, 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, accountID, remote) | ||
| 56 | } | ||
| 57 | if len(args) < 2 { | ||
| 58 | return s.adminError(ch, jsonOut, "usage", &adminUsageError{msg: "usage: vole-admin <resource> <action> [args]"}, | ||
| 59 | user, accountID, remote) | ||
| 60 | } | ||
| 61 | |||
| 62 | data, render, err := s.runAdmin(args, user, ch) | ||
| 63 | if err != nil { | ||
| 64 | return s.adminError(ch, jsonOut, "failed", err, user, accountID, remote) | ||
| 65 | } | ||
| 66 | if jsonOut { | ||
| 67 | _ = json.NewEncoder(ch).Encode(adminResponse{OK: true, Data: data}) | ||
| 68 | } else if render != nil { | ||
| 69 | render(ch, ch.Stderr()) | ||
| 70 | } | ||
| 71 | s.auditAdmin(user, accountID, remote, strings.Join(args, " ")+" ok") | ||
| 72 | return 0 | ||
| 73 | } | ||
| 74 | |||
| 75 | func (s *Server) adminError(ch xssh.Channel, jsonOut bool, code string, err error, user, accountID, remote string) int { | ||
| 76 | exitCode := 1 | ||
| 77 | if _, ok := err.(*adminUsageError); ok { | ||
| 78 | exitCode = 129 | ||
| 79 | } | ||
| 80 | if jsonOut { | ||
| 81 | _ = json.NewEncoder(ch).Encode(adminResponse{OK: false, Error: &adminError{Code: code, Message: err.Error()}}) | ||
| 82 | } else { | ||
| 83 | fmt.Fprintf(ch.Stderr(), "vole: %v\n", err) | ||
| 84 | } | ||
| 85 | s.auditAdmin(user, accountID, remote, "failed: "+err.Error()) | ||
| 86 | return exitCode | ||
| 87 | } | ||
| 88 | |||
| 89 | func splitAdminJSON(argv []string) (jsonOut bool, rest []string, err error) { | ||
| 90 | terminated := false | ||
| 91 | for _, a := range argv { | ||
| 92 | if terminated { | ||
| 93 | rest = append(rest, a) | ||
| 94 | continue | ||
| 95 | } | ||
| 96 | switch a { | ||
| 97 | case "--": | ||
| 98 | terminated = true | ||
| 99 | case "--json": | ||
| 100 | jsonOut = true | ||
| 101 | default: | ||
| 102 | rest = append(rest, a) | ||
| 103 | } | ||
| 104 | } | ||
| 105 | return | ||
| 106 | } | ||
| 107 | |||
| 108 | func (s *Server) runAdmin(args []string, actor string, in io.Reader) (any, adminRender, error) { | ||
| 109 | resource, action, rest := args[0], args[1], args[2:] | ||
| 110 | switch resource { | ||
| 111 | case "user": | ||
| 112 | return s.adminUser(action, rest, actor, in) | ||
| 113 | case "repo": | ||
| 114 | return s.adminRepo(action, rest, actor) | ||
| 115 | case "acl": | ||
| 116 | return s.adminACL(action, rest, actor) | ||
| 117 | } | ||
| 118 | return nil, nil, &adminUsageError{msg: fmt.Sprintf("unknown admin resource %q", resource)} | ||
| 119 | } | ||
| 120 | |||
| 121 | func (s *Server) adminUser(action string, args []string, actor string, in io.Reader) (any, adminRender, error) { | ||
| 122 | switch action { | ||
| 123 | case "list": | ||
| 124 | all := s.cfg.Identity.All() | ||
| 125 | data := make([]map[string]any, 0, len(all)) | ||
| 126 | for _, a := range all { | ||
| 127 | data = append(data, userData(a)) | ||
| 128 | } | ||
| 129 | return data, func(out, _ io.Writer) { | ||
| 130 | if len(all) == 0 { | ||
| 131 | fmt.Fprintln(out, "no users") | ||
| 132 | return | ||
| 133 | } | ||
| 134 | for _, a := range all { | ||
| 135 | role := "" | ||
| 136 | if a.Admin { | ||
| 137 | role = " admin" | ||
| 138 | } | ||
| 139 | fmt.Fprintf(out, "%-16s %s %-28s%s keys=%d\n", a.Name, a.ID, a.Status, role, len(a.Keys)) | ||
| 140 | for _, k := range a.Keys { | ||
| 141 | fmt.Fprintf(out, " %s %s %s\n", k.Fingerprint, k.Type, k.Comment) | ||
| 142 | } | ||
| 143 | } | ||
| 144 | }, nil | ||
| 145 | |||
| 146 | case "show": | ||
| 147 | if len(args) != 1 { | ||
| 148 | return nil, nil, &adminUsageError{msg: "usage: vole-admin user show <name|id>"} | ||
| 149 | } | ||
| 150 | a, err := s.cfg.Identity.Find(args[0]) | ||
| 151 | if err != nil { | ||
| 152 | return nil, nil, err | ||
| 153 | } | ||
| 154 | return userData(a), func(out, _ io.Writer) { | ||
| 155 | fmt.Fprintf(out, "name: %s\nid: %s\nstatus: %s\nadmin: %t\ncreated: %s\n", | ||
| 156 | a.Name, a.ID, a.Status, a.Admin, a.Created.Format(time.RFC3339)) | ||
| 157 | for _, k := range a.Keys { | ||
| 158 | fmt.Fprintf(out, "key: %s %s %s\n", k.Fingerprint, k.Type, k.Comment) | ||
| 159 | } | ||
| 160 | }, nil | ||
| 161 | |||
| 162 | case "create": | ||
| 163 | if len(args) != 1 { | ||
| 164 | return nil, nil, &adminUsageError{msg: "usage: vole-admin user create <name>"} | ||
| 165 | } | ||
| 166 | a, err := s.cfg.Identity.Create(args[0], actor) | ||
| 167 | if err != nil { | ||
| 168 | return nil, nil, err | ||
| 169 | } | ||
| 170 | return userData(a), func(out, errOut io.Writer) { | ||
| 171 | fmt.Fprintf(out, "%s\t%s\n", a.Name, a.ID) | ||
| 172 | fmt.Fprintln(errOut, "no keys yet: this account cannot authenticate until you add one") | ||
| 173 | }, nil | ||
| 174 | |||
| 175 | case "add-key": | ||
| 176 | if len(args) != 1 { | ||
| 177 | return nil, nil, &adminUsageError{msg: "usage: vole-admin user add-key <name|id>"} | ||
| 178 | } | ||
| 179 | key, err := readAdminKey(in) | ||
| 180 | if err != nil { | ||
| 181 | return nil, nil, err | ||
| 182 | } | ||
| 183 | fp, err := s.cfg.Identity.AddKey(args[0], key, actor) | ||
| 184 | if err != nil { | ||
| 185 | return nil, nil, err | ||
| 186 | } | ||
| 187 | a, _ := s.cfg.Identity.Find(args[0]) | ||
| 188 | data := map[string]any{"fingerprint": fp, "account": userData(a)} | ||
| 189 | return data, func(out, _ io.Writer) { | ||
| 190 | fmt.Fprintf(out, "added key %s for %s\n", fp, a.Name) | ||
| 191 | }, nil | ||
| 192 | |||
| 193 | case "rm-key": | ||
| 194 | if len(args) != 2 { | ||
| 195 | return nil, nil, &adminUsageError{msg: "usage: vole-admin user rm-key <name|id> <fingerprint>"} | ||
| 196 | } | ||
| 197 | if err := s.cfg.Identity.RemoveKey(args[0], args[1], actor); err != nil { | ||
| 198 | return nil, nil, err | ||
| 199 | } | ||
| 200 | return map[string]any{"fingerprint": args[1]}, func(out, _ io.Writer) { | ||
| 201 | fmt.Fprintf(out, "revoked %s\n", args[1]) | ||
| 202 | }, nil | ||
| 203 | |||
| 204 | case "rename": | ||
| 205 | if len(args) != 2 { | ||
| 206 | return nil, nil, &adminUsageError{msg: "usage: vole-admin user rename <name|id> <new-name>"} | ||
| 207 | } | ||
| 208 | a, err := s.cfg.Identity.Rename(args[0], args[1], actor) | ||
| 209 | if err != nil { | ||
| 210 | return nil, nil, err | ||
| 211 | } | ||
| 212 | return userData(a), func(out, errOut io.Writer) { | ||
| 213 | fmt.Fprintf(out, "%s\t%s\n", a.Name, a.ID) | ||
| 214 | fmt.Fprintln(errOut, "ACL entries and approvals refer to this id and are unaffected") | ||
| 215 | }, nil | ||
| 216 | |||
| 217 | case "retire", "rm", "delete": | ||
| 218 | if len(args) != 1 { | ||
| 219 | return nil, nil, &adminUsageError{msg: "usage: vole-admin user retire <name|id>"} | ||
| 220 | } | ||
| 221 | a, err := s.cfg.Identity.Retire(args[0], actor) | ||
| 222 | if err != nil { | ||
| 223 | return nil, nil, err | ||
| 224 | } | ||
| 225 | return userData(a), func(out, errOut io.Writer) { | ||
| 226 | fmt.Fprintf(out, "retired %s\t%s\n", a.Name, a.ID) | ||
| 227 | fmt.Fprintln(errOut, "keys stop working within 5s; the name is never reissued") | ||
| 228 | }, nil | ||
| 229 | |||
| 230 | case "backup": | ||
| 231 | return nil, nil, &adminUsageError{msg: "user backup writes to the server's filesystem; run it locally"} | ||
| 232 | |||
| 233 | case "grant-admin", "revoke-admin": | ||
| 234 | if len(args) != 1 { | ||
| 235 | return nil, nil, &adminUsageError{msg: "usage: vole-admin user " + action + " <name|id>"} | ||
| 236 | } | ||
| 237 | a, err := s.cfg.Identity.SetAdmin(args[0], action == "grant-admin", actor) | ||
| 238 | if err != nil { | ||
| 239 | return nil, nil, err | ||
| 240 | } | ||
| 241 | return userData(a), func(out, _ io.Writer) { | ||
| 242 | if action == "grant-admin" { | ||
| 243 | fmt.Fprintf(out, "granted instance admin to %s\t%s\n", a.Name, a.ID) | ||
| 244 | } else { | ||
| 245 | fmt.Fprintf(out, "revoked instance admin from %s\t%s\n", a.Name, a.ID) | ||
| 246 | } | ||
| 247 | }, nil | ||
| 248 | } | ||
| 249 | return nil, nil, &adminUsageError{msg: fmt.Sprintf("unknown user command %q", action)} | ||
| 250 | } | ||
| 251 | |||
| 252 | func (s *Server) adminRepo(action string, args []string, actor string) (any, adminRender, error) { | ||
| 253 | switch action { | ||
| 254 | case "list": | ||
| 255 | repos, err := s.cfg.Instance.Repos.List() | ||
| 256 | if err != nil { | ||
| 257 | return nil, nil, err | ||
| 258 | } | ||
| 259 | data := make([]map[string]any, 0, len(repos)) | ||
| 260 | views := make([]*repo.Meta, 0, len(repos)) | ||
| 261 | for _, m := range repos { | ||
| 262 | d, _, err := s.repoData(m) | ||
| 263 | if err != nil { | ||
| 264 | return nil, nil, err | ||
| 265 | } | ||
| 266 | data = append(data, d) | ||
| 267 | views = append(views, m) | ||
| 268 | } | ||
| 269 | return data, func(out, _ io.Writer) { | ||
| 270 | for _, d := range data { | ||
| 271 | vis := "private" | ||
| 272 | if d["public"].(bool) { | ||
| 273 | vis = "public" | ||
| 274 | } | ||
| 275 | fmt.Fprintf(out, "%-32s id=%s principals=%d %s\n", d["name"], d["id"], d["principals"], vis) | ||
| 276 | } | ||
| 277 | if len(views) == 0 { | ||
| 278 | fmt.Fprintln(out, "no repositories") | ||
| 279 | } | ||
| 280 | }, nil | ||
| 281 | |||
| 282 | case "show": | ||
| 283 | if len(args) != 1 { | ||
| 284 | return nil, nil, &adminUsageError{msg: "usage: vole-admin repo show <name>"} | ||
| 285 | } | ||
| 286 | m, err := s.cfg.Instance.Repos.Open(args[0]) | ||
| 287 | if err != nil { | ||
| 288 | return nil, nil, fmt.Errorf("repository %q not found", args[0]) | ||
| 289 | } | ||
| 290 | data, a, err := s.repoData(m) | ||
| 291 | if err != nil { | ||
| 292 | return nil, nil, err | ||
| 293 | } | ||
| 294 | return data, func(out, _ io.Writer) { | ||
| 295 | vis := "private" | ||
| 296 | if a.Can("", access.Read) { | ||
| 297 | vis = "public" | ||
| 298 | } | ||
| 299 | fmt.Fprintf(out, "name: %s\nid: %s\nvisibility: %s\ndefault branch: %s\ndescription: %s\n", | ||
| 300 | m.Name, m.ID, vis, m.DefaultBranch, m.Description) | ||
| 301 | }, nil | ||
| 302 | |||
| 303 | case "create": | ||
| 304 | f, err := parseAdminFlags(args, | ||
| 305 | adminFlagSpec{long: "owner", short: "o"}, | ||
| 306 | adminFlagSpec{long: "description", short: "d"}) | ||
| 307 | if err != nil { | ||
| 308 | return nil, nil, err | ||
| 309 | } | ||
| 310 | if len(f.rest) != 1 || f.one("owner") == "" { | ||
| 311 | return nil, nil, &adminUsageError{msg: "usage: vole-admin repo create --owner <user> <name> [--description <text>]"} | ||
| 312 | } | ||
| 313 | owner, err := s.cfg.Identity.Find(f.one("owner")) | ||
| 314 | if err != nil { | ||
| 315 | return nil, nil, err | ||
| 316 | } | ||
| 317 | if !owner.Active() { | ||
| 318 | return nil, nil, fmt.Errorf("owner %q is retired", owner.Name) | ||
| 319 | } | ||
| 320 | m, err := s.cfg.Instance.Repos.Create(f.rest[0], owner.ID, f.one("description")) | ||
| 321 | if err != nil { | ||
| 322 | return nil, nil, err | ||
| 323 | } | ||
| 324 | if err := s.cfg.Instance.Index.Upsert(m); err != nil { | ||
| 325 | return nil, nil, err | ||
| 326 | } | ||
| 327 | data, _, err := s.repoData(m) | ||
| 328 | if err != nil { | ||
| 329 | return nil, nil, err | ||
| 330 | } | ||
| 331 | return data, func(out, errOut io.Writer) { | ||
| 332 | fmt.Fprintf(out, "created %s (id %s)\n", m.Name, m.ID) | ||
| 333 | fmt.Fprintf(errOut, "new repositories are private; publish with: vole admin acl default %s read\n", m.Name) | ||
| 334 | }, nil | ||
| 335 | |||
| 336 | case "rename": | ||
| 337 | if len(args) != 2 { | ||
| 338 | return nil, nil, &adminUsageError{msg: "usage: vole-admin repo rename <old> <new>"} | ||
| 339 | } | ||
| 340 | if err := s.cfg.Instance.RenameRepo(args[0], args[1]); err != nil { | ||
| 341 | return nil, nil, err | ||
| 342 | } | ||
| 343 | return map[string]any{"old": args[0], "name": args[1]}, func(out, _ io.Writer) { | ||
| 344 | fmt.Fprintf(out, "renamed %s -> %s\n", args[0], args[1]) | ||
| 345 | }, nil | ||
| 346 | |||
| 347 | case "delete": | ||
| 348 | if len(args) != 1 { | ||
| 349 | return nil, nil, &adminUsageError{msg: "usage: vole-admin repo delete <name>"} | ||
| 350 | } | ||
| 351 | if err := s.cfg.Instance.DeleteRepo(args[0]); err != nil { | ||
| 352 | return nil, nil, err | ||
| 353 | } | ||
| 354 | return map[string]any{"name": args[0]}, func(out, _ io.Writer) { | ||
| 355 | fmt.Fprintf(out, "deleted %s\n", args[0]) | ||
| 356 | }, nil | ||
| 357 | } | ||
| 358 | return nil, nil, &adminUsageError{msg: fmt.Sprintf("unknown repo command %q", action)} | ||
| 359 | } | ||
| 360 | |||
| 361 | func (s *Server) adminACL(action string, args []string, actor string) (any, adminRender, error) { | ||
| 362 | if len(args) == 0 { | ||
| 363 | return nil, nil, &adminUsageError{msg: "usage: vole-admin acl <show|set|default|protect|approvals|checks|runners> <repo> ..."} | ||
| 364 | } | ||
| 365 | repoName := args[0] | ||
| 366 | m, err := s.cfg.Instance.Repos.Open(repoName) | ||
| 367 | if err != nil { | ||
| 368 | return nil, nil, fmt.Errorf("repository %q not found", repoName) | ||
| 369 | } | ||
| 370 | a, err := m.ACL() | ||
| 371 | if err != nil { | ||
| 372 | return nil, nil, err | ||
| 373 | } | ||
| 374 | finish := func() (any, adminRender, error) { | ||
| 375 | if err := m.SetACL(a, actor); err != nil { | ||
| 376 | return nil, nil, err | ||
| 377 | } | ||
| 378 | if err := s.cfg.Instance.Index.Upsert(m); err != nil { | ||
| 379 | return nil, nil, err | ||
| 380 | } | ||
| 381 | return s.aclResult(m, a) | ||
| 382 | } | ||
| 383 | |||
| 384 | switch action { | ||
| 385 | case "show": | ||
| 386 | if len(args) != 1 { | ||
| 387 | return nil, nil, &adminUsageError{msg: "usage: vole-admin acl show <repo>"} | ||
| 388 | } | ||
| 389 | return s.aclResult(m, a) | ||
| 390 | |||
| 391 | case "set": | ||
| 392 | if len(args) != 3 { | ||
| 393 | return nil, nil, &adminUsageError{msg: "usage: vole-admin acl set <repo> <user> <none|read|write|admin>"} | ||
| 394 | } | ||
| 395 | role, err := access.ParseRole(args[2]) | ||
| 396 | if err != nil { | ||
| 397 | return nil, nil, err | ||
| 398 | } | ||
| 399 | account, err := s.cfg.Identity.Find(args[1]) | ||
| 400 | if err != nil { | ||
| 401 | return nil, nil, err | ||
| 402 | } | ||
| 403 | if err := a.SetEntry(account.ID, role.String()); err != nil { | ||
| 404 | return nil, nil, err | ||
| 405 | } | ||
| 406 | return finish() | ||
| 407 | |||
| 408 | case "default": | ||
| 409 | if len(args) != 2 { | ||
| 410 | return nil, nil, &adminUsageError{msg: "usage: vole-admin acl default <repo> <none|read|write|admin>"} | ||
| 411 | } | ||
| 412 | role, err := access.ParseRole(args[1]) | ||
| 413 | if err != nil { | ||
| 414 | return nil, nil, err | ||
| 415 | } | ||
| 416 | a.Default = role.String() | ||
| 417 | return finish() | ||
| 418 | |||
| 419 | case "protect": | ||
| 420 | if len(args) < 2 { | ||
| 421 | return nil, nil, &adminUsageError{msg: "usage: vole-admin acl protect <repo> <ref-pattern>..."} | ||
| 422 | } | ||
| 423 | a.Protected = append([]string{}, args[1:]...) | ||
| 424 | return finish() | ||
| 425 | |||
| 426 | case "approvals": | ||
| 427 | if len(args) != 2 { | ||
| 428 | return nil, nil, &adminUsageError{msg: "usage: vole-admin acl approvals <repo> <n>"} | ||
| 429 | } | ||
| 430 | n, err := strconv.Atoi(args[1]) | ||
| 431 | if err != nil || n < 0 { | ||
| 432 | return nil, nil, &adminUsageError{msg: fmt.Sprintf("%q is not a number of approvals", args[1])} | ||
| 433 | } | ||
| 434 | a.ApprovalsRequired = &n | ||
| 435 | return finish() | ||
| 436 | |||
| 437 | case "checks": | ||
| 438 | if len(args) != 2 { | ||
| 439 | return nil, nil, &adminUsageError{msg: "usage: vole-admin acl checks <repo> <on|off>"} | ||
| 440 | } | ||
| 441 | switch strings.ToLower(args[1]) { | ||
| 442 | case "on", "true", "yes", "1": | ||
| 443 | v := true | ||
| 444 | a.ChecksRequired = &v | ||
| 445 | case "off", "false", "no", "0": | ||
| 446 | v := false | ||
| 447 | a.ChecksRequired = &v | ||
| 448 | default: | ||
| 449 | return nil, nil, &adminUsageError{msg: fmt.Sprintf("%q is not on or off", args[1])} | ||
| 450 | } | ||
| 451 | return finish() | ||
| 452 | |||
| 453 | case "runners": | ||
| 454 | f, err := parseAdminFlags(args[1:], | ||
| 455 | adminFlagSpec{long: "add", boolean: true}, | ||
| 456 | adminFlagSpec{long: "remove", boolean: true}, | ||
| 457 | adminFlagSpec{long: "clear", boolean: true}) | ||
| 458 | if err != nil { | ||
| 459 | return nil, nil, err | ||
| 460 | } | ||
| 461 | names := f.rest | ||
| 462 | edits := 0 | ||
| 463 | for _, b := range []string{"add", "remove", "clear"} { | ||
| 464 | if f.has(b) { | ||
| 465 | edits++ | ||
| 466 | } | ||
| 467 | } | ||
| 468 | if edits > 1 { | ||
| 469 | return nil, nil, &adminUsageError{msg: "use one of --add, --remove or --clear"} | ||
| 470 | } | ||
| 471 | if len(names) == 0 && !f.has("clear") && !f.has("add") && !f.has("remove") { | ||
| 472 | return s.aclResult(m, a) | ||
| 473 | } | ||
| 474 | var next []string | ||
| 475 | resolve := func(name string) (string, error) { | ||
| 476 | account, err := s.cfg.Identity.Find(name) | ||
| 477 | if err != nil { | ||
| 478 | return "", err | ||
| 479 | } | ||
| 480 | return account.ID, nil | ||
| 481 | } | ||
| 482 | switch { | ||
| 483 | case f.has("clear"): | ||
| 484 | if len(names) > 0 { | ||
| 485 | return nil, nil, &adminUsageError{msg: "--clear takes no names"} | ||
| 486 | } | ||
| 487 | case f.has("add"), f.has("remove"): | ||
| 488 | if len(names) == 0 { | ||
| 489 | return nil, nil, &adminUsageError{msg: "--add and --remove need at least one account"} | ||
| 490 | } | ||
| 491 | next = append(next, a.Runners...) | ||
| 492 | for _, name := range names { | ||
| 493 | id, err := resolve(name) | ||
| 494 | if err != nil { | ||
| 495 | return nil, nil, err | ||
| 496 | } | ||
| 497 | if !f.has("remove") { | ||
| 498 | next = append(next, id) | ||
| 499 | continue | ||
| 500 | } | ||
| 501 | kept, found := next[:0], false | ||
| 502 | for _, r := range next { | ||
| 503 | if r == id { | ||
| 504 | found = true | ||
| 505 | continue | ||
| 506 | } | ||
| 507 | kept = append(kept, r) | ||
| 508 | } | ||
| 509 | if !found { | ||
| 510 | return nil, nil, fmt.Errorf("%s is not a runner for %s", name, m.Name) | ||
| 511 | } | ||
| 512 | next = kept | ||
| 513 | } | ||
| 514 | default: | ||
| 515 | for _, name := range names { | ||
| 516 | id, err := resolve(name) | ||
| 517 | if err != nil { | ||
| 518 | return nil, nil, err | ||
| 519 | } | ||
| 520 | next = append(next, id) | ||
| 521 | } | ||
| 522 | } | ||
| 523 | a.SetRunners(next) | ||
| 524 | return finish() | ||
| 525 | } | ||
| 526 | return nil, nil, &adminUsageError{msg: fmt.Sprintf("unknown acl command %q", action)} | ||
| 527 | } | ||
| 528 | |||
| 529 | func (s *Server) repoData(m *repo.Meta) (map[string]any, *access.ACL, error) { | ||
| 530 | a, err := m.ACL() | ||
| 531 | if err != nil { | ||
| 532 | return nil, nil, err | ||
| 533 | } | ||
| 534 | return map[string]any{ | ||
| 535 | "id": m.ID, | ||
| 536 | "name": m.Name, | ||
| 537 | "description": m.Description, | ||
| 538 | "default_branch": m.DefaultBranch, | ||
| 539 | "public": a.Can("", access.Read), | ||
| 540 | "principals": len(a.Entries), | ||
| 541 | "created": m.Created, | ||
| 542 | }, a, nil | ||
| 543 | } | ||
| 544 | |||
| 545 | func (s *Server) aclResult(m *repo.Meta, a *access.ACL) (any, adminRender, error) { | ||
| 546 | entries := make([]map[string]string, 0, len(a.Entries)) | ||
| 547 | for _, principal := range a.Principals() { | ||
| 548 | entries = append(entries, map[string]string{ | ||
| 549 | "id": principal, | ||
| 550 | "name": s.cfg.Identity.Name(principal), | ||
| 551 | "role": a.Entries[principal], | ||
| 552 | }) | ||
| 553 | } | ||
| 554 | runners := make([]map[string]string, 0, len(a.Runners)) | ||
| 555 | for _, runner := range a.Runners { | ||
| 556 | runners = append(runners, map[string]string{ | ||
| 557 | "id": runner, | ||
| 558 | "name": s.cfg.Identity.Name(runner), | ||
| 559 | }) | ||
| 560 | } | ||
| 561 | data := map[string]any{ | ||
| 562 | "repo": m.Name, | ||
| 563 | "public": a.Can("", access.Read), | ||
| 564 | "default": a.Default, | ||
| 565 | "entries": entries, | ||
| 566 | "protected": a.Protected, | ||
| 567 | "approvals_required": a.RequiredApprovals(), | ||
| 568 | "checks_required": a.RequiresChecks(), | ||
| 569 | "runners": runners, | ||
| 570 | } | ||
| 571 | return data, func(out, _ io.Writer) { | ||
| 572 | vis := "private" | ||
| 573 | if a.Can("", access.Read) { | ||
| 574 | vis = "public" | ||
| 575 | } | ||
| 576 | fmt.Fprintf(out, "repo: %s (%s)\n", m.Name, vis) | ||
| 577 | fmt.Fprintf(out, "default: %s\n", a.Default) | ||
| 578 | for _, e := range entries { | ||
| 579 | fmt.Fprintf(out, " %-16s %-28s %s\n", e["name"], e["id"], e["role"]) | ||
| 580 | } | ||
| 581 | if len(a.Protected) > 0 { | ||
| 582 | fmt.Fprintf(out, "protected: %s\n", strings.Join(a.Protected, ", ")) | ||
| 583 | } | ||
| 584 | fmt.Fprintf(out, "approvals: %d required on protected branches\n", a.RequiredApprovals()) | ||
| 585 | if a.RequiresChecks() { | ||
| 586 | fmt.Fprintln(out, "checks: required on protected branches") | ||
| 587 | } else { | ||
| 588 | fmt.Fprintln(out, "checks: not required") | ||
| 589 | } | ||
| 590 | if len(a.Runners) == 0 { | ||
| 591 | fmt.Fprintln(out, "runners: none named (admins may report CI results)") | ||
| 592 | return | ||
| 593 | } | ||
| 594 | for _, r := range runners { | ||
| 595 | fmt.Fprintf(out, "runner %-28s %s\n", r["id"], r["name"]) | ||
| 596 | } | ||
| 597 | fmt.Fprintln(out, "(admins may also report CI results)") | ||
| 598 | }, nil | ||
| 599 | } | ||
| 600 | |||
| 601 | func readAdminKey(r io.Reader) (string, error) { | ||
| 602 | data, err := io.ReadAll(io.LimitReader(r, maxAdminKeyBytes+1)) | ||
| 603 | if err != nil { | ||
| 604 | return "", err | ||
| 605 | } | ||
| 606 | if len(data) > maxAdminKeyBytes { | ||
| 607 | return "", fmt.Errorf("public key is larger than %d bytes", maxAdminKeyBytes) | ||
| 608 | } | ||
| 609 | if strings.TrimSpace(string(data)) == "" { | ||
| 610 | return "", errors.New("no public key on stdin") | ||
| 611 | } | ||
| 612 | return string(data), nil | ||
| 613 | } | ||
| 614 | |||
| 615 | func userData(a *identity.Account) map[string]any { | ||
| 616 | if a == nil { | ||
| 617 | return nil | ||
| 618 | } | ||
| 619 | return map[string]any{ | ||
| 620 | "id": a.ID, | ||
| 621 | "name": a.Name, | ||
| 622 | "status": a.Status, | ||
| 623 | "admin": a.Admin, | ||
| 624 | "former": a.Former, | ||
| 625 | "keys": a.Keys, | ||
| 626 | "created": a.Created, | ||
| 627 | } | ||
| 628 | } | ||
| 629 | |||
| 630 | func (s *Server) auditAdmin(user, accountID, remote, detail string) { | ||
| 631 | if s.cfg.AuditPath == "" { | ||
| 632 | return | ||
| 633 | } | ||
| 634 | // A command argument may contain a newline or tab; keep it on one audit line. | ||
| 635 | detail = strings.NewReplacer("\n", " ", "\r", " ", "\t", " ").Replace(detail) | ||
| 636 | f, err := os.OpenFile(s.cfg.AuditPath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o644) | ||
| 637 | if err != nil { | ||
| 638 | return | ||
| 639 | } | ||
| 640 | defer f.Close() | ||
| 641 | fmt.Fprintf(f, "%s\tadmin\t%s\t%s\t%s\t%s\n", | ||
| 642 | time.Now().UTC().Format(time.RFC3339), user, accountID, remote, detail) | ||
| 643 | } | ||
| 644 | |||
| 645 | // adminFlagSpec and parseAdminFlags are a small local flag parser for the | ||
| 646 | // server side. The client CLI already parsed its own flags; this parses only the | ||
| 647 | // flags that cross the SSH boundary. | ||
| 648 | type adminFlagSpec struct { | ||
| 649 | long string | ||
| 650 | short string | ||
| 651 | boolean bool | ||
| 652 | } | ||
| 653 | |||
| 654 | type adminFlags struct { | ||
| 655 | vals map[string][]string | ||
| 656 | bools map[string]bool | ||
| 657 | rest []string | ||
| 658 | } | ||
| 659 | |||
| 660 | func parseAdminFlags(args []string, specs ...adminFlagSpec) (*adminFlags, error) { | ||
| 661 | f := &adminFlags{vals: map[string][]string{}, bools: map[string]bool{}} | ||
| 662 | byLong := map[string]adminFlagSpec{} | ||
| 663 | byShort := map[string]adminFlagSpec{} | ||
| 664 | for _, s := range specs { | ||
| 665 | byLong[s.long] = s | ||
| 666 | if s.short != "" { | ||
| 667 | byShort[s.short] = s | ||
| 668 | } | ||
| 669 | } | ||
| 670 | terminated := false | ||
| 671 | for i := 0; i < len(args); i++ { | ||
| 672 | a := args[i] | ||
| 673 | if terminated { | ||
| 674 | f.rest = append(f.rest, a) | ||
| 675 | continue | ||
| 676 | } | ||
| 677 | if a == "--" { | ||
| 678 | terminated = true | ||
| 679 | continue | ||
| 680 | } | ||
| 681 | switch { | ||
| 682 | case strings.HasPrefix(a, "--"): | ||
| 683 | name, value, hasValue := strings.Cut(strings.TrimPrefix(a, "--"), "=") | ||
| 684 | spec, ok := byLong[name] | ||
| 685 | if !ok { | ||
| 686 | return nil, &adminUsageError{msg: fmt.Sprintf("unknown option --%s", name)} | ||
| 687 | } | ||
| 688 | if spec.boolean { | ||
| 689 | if hasValue { | ||
| 690 | return nil, &adminUsageError{msg: fmt.Sprintf("option --%s takes no value", name)} | ||
| 691 | } | ||
| 692 | f.bools[spec.long] = true | ||
| 693 | continue | ||
| 694 | } | ||
| 695 | if !hasValue { | ||
| 696 | i++ | ||
| 697 | if i >= len(args) { | ||
| 698 | return nil, &adminUsageError{msg: fmt.Sprintf("option --%s requires a value", name)} | ||
| 699 | } | ||
| 700 | value = args[i] | ||
| 701 | } | ||
| 702 | f.vals[spec.long] = append(f.vals[spec.long], value) | ||
| 703 | case strings.HasPrefix(a, "-") && a != "-": | ||
| 704 | short := a[1:2] | ||
| 705 | spec, ok := byShort[short] | ||
| 706 | if !ok { | ||
| 707 | return nil, &adminUsageError{msg: fmt.Sprintf("unknown option -%s", short)} | ||
| 708 | } | ||
| 709 | if spec.boolean { | ||
| 710 | if len(a) > 2 { | ||
| 711 | return nil, &adminUsageError{msg: fmt.Sprintf("option -%s takes no value", short)} | ||
| 712 | } | ||
| 713 | f.bools[spec.long] = true | ||
| 714 | continue | ||
| 715 | } | ||
| 716 | value := a[2:] | ||
| 717 | if value == "" { | ||
| 718 | i++ | ||
| 719 | if i >= len(args) { | ||
| 720 | return nil, &adminUsageError{msg: fmt.Sprintf("option -%s requires a value", short)} | ||
| 721 | } | ||
| 722 | value = args[i] | ||
| 723 | } | ||
| 724 | f.vals[spec.long] = append(f.vals[spec.long], value) | ||
| 725 | default: | ||
| 726 | f.rest = append(f.rest, a) | ||
| 727 | } | ||
| 728 | } | ||
| 729 | return f, nil | ||
| 730 | } | ||
| 731 | |||
| 732 | func (f *adminFlags) one(name string) string { | ||
| 733 | v := f.vals[name] | ||
| 734 | if len(v) == 0 { | ||
| 735 | return "" | ||
| 736 | } | ||
| 737 | return v[len(v)-1] | ||
| 738 | } | ||
| 739 | |||
| 740 | func (f *adminFlags) has(name string) bool { return f.bools[name] } | ||