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.
refactor
c5902451905acdaa478f8476844ccd4a510d9ca9 · Keenan Nemetz · 17d ago · parent ec2aba4b
cmd/vole/acl.go +284
| @@ -0,0 +1,284 @@ | |||
| 1 | package main | ||
| 2 | |||
| 3 | import ( | ||
| 4 | "errors" | ||
| 5 | "fmt" | ||
| 6 | "os" | ||
| 7 | "strconv" | ||
| 8 | "strings" | ||
| 9 | |||
| 10 | "github.com/rodent-software/vole/forge" | ||
| 11 | "github.com/rodent-software/vole/forge/access" | ||
| 12 | ) | ||
| 13 | |||
| 14 | // ---------- acl ---------- | ||
| 15 | |||
| 16 | func cmdACL(args []string) error { | ||
| 17 | if len(args) == 0 { | ||
| 18 | return errors.New("usage: vole acl show|set|default|protect|checks|approvals|runners") | ||
| 19 | } | ||
| 20 | sub := args[0] | ||
| 21 | f, err := parseFlags(args[1:], configFlag(), | ||
| 22 | flagSpec{long: "add", boolean: true}, flagSpec{long: "remove", boolean: true}, | ||
| 23 | flagSpec{long: "clear", boolean: true}) | ||
| 24 | if err != nil { | ||
| 25 | return err | ||
| 26 | } | ||
| 27 | st, err := forge.OpenPath(f.cfgPath) | ||
| 28 | if err != nil { | ||
| 29 | return err | ||
| 30 | } | ||
| 31 | defer st.Close() | ||
| 32 | |||
| 33 | if sub != "runners" && (f.has("add") || f.has("remove") || f.has("clear")) { | ||
| 34 | return usagef("--add, --remove and --clear are for `acl runners`") | ||
| 35 | } | ||
| 36 | if len(f.rest) == 0 { | ||
| 37 | return usagef("repository name required") | ||
| 38 | } | ||
| 39 | m, err := st.Repos.Open(f.rest[0]) | ||
| 40 | if err != nil { | ||
| 41 | return err | ||
| 42 | } | ||
| 43 | a, err := m.ACL() | ||
| 44 | if err != nil { | ||
| 45 | return err | ||
| 46 | } | ||
| 47 | |||
| 48 | switch sub { | ||
| 49 | case "show": | ||
| 50 | vis := "private" | ||
| 51 | if a.Can("", access.Read) { | ||
| 52 | vis = "public" | ||
| 53 | } | ||
| 54 | fmt.Printf("repo: %s (%s)\n", m.Name, vis) | ||
| 55 | fmt.Printf("default: %s\n", a.Default) | ||
| 56 | for _, p := range a.Principals() { | ||
| 57 | // Entries are account ids; render the current name beside them so a | ||
| 58 | // rename is visible without decoding ULIDs by hand. | ||
| 59 | name := p | ||
| 60 | if a2, err := st.Identity.Find(p); err == nil { | ||
| 61 | name = a2.Name | ||
| 62 | } | ||
| 63 | fmt.Printf(" %-16s %-28s %s\n", name, p, a.Entries[p]) | ||
| 64 | } | ||
| 65 | if len(a.Protected) > 0 { | ||
| 66 | fmt.Printf("protected: %s\n", strings.Join(a.Protected, ", ")) | ||
| 67 | } | ||
| 68 | // Admins are implicit runners, so an empty list is not "nobody": it is | ||
| 69 | // "nobody beyond the people who can edit this file". | ||
| 70 | if len(a.Runners) == 0 { | ||
| 71 | fmt.Println("runners: none named (admins may report CI results)") | ||
| 72 | } else { | ||
| 73 | names := make([]string, 0, len(a.Runners)) | ||
| 74 | for _, r := range a.Runners { | ||
| 75 | names = append(names, st.Identity.Name(r)+" ("+r+")") | ||
| 76 | } | ||
| 77 | fmt.Printf("runners: %s (admins too)\n", strings.Join(names, ", ")) | ||
| 78 | } | ||
| 79 | return nil | ||
| 80 | |||
| 81 | case "set": | ||
| 82 | if len(f.rest) != 3 { | ||
| 83 | return errors.New("usage: vole acl set <repo> <user> <none|read|write|admin>") | ||
| 84 | } | ||
| 85 | if _, err := access.ParseRole(f.rest[2]); err != nil { | ||
| 86 | return err | ||
| 87 | } | ||
| 88 | account, err := st.Identity.Find(f.rest[1]) | ||
| 89 | if err != nil { | ||
| 90 | return err | ||
| 91 | } | ||
| 92 | if err := a.SetEntry(account.ID, strings.ToLower(f.rest[2])); err != nil { | ||
| 93 | return err | ||
| 94 | } | ||
| 95 | if err := m.SetACL(a, os.Getenv("USER")); err != nil { | ||
| 96 | return err | ||
| 97 | } | ||
| 98 | _ = st.Index.Upsert(m) | ||
| 99 | fmt.Printf("%s: %s\n", account.Name, strings.ToLower(f.rest[2])) | ||
| 100 | return nil | ||
| 101 | |||
| 102 | case "default": | ||
| 103 | if len(f.rest) != 2 { | ||
| 104 | return errors.New("usage: vole acl default <repo> <none|read|write|admin>") | ||
| 105 | } | ||
| 106 | if _, err := access.ParseRole(f.rest[1]); err != nil { | ||
| 107 | return err | ||
| 108 | } | ||
| 109 | a.Default = strings.ToLower(f.rest[1]) | ||
| 110 | if err := m.SetACL(a, os.Getenv("USER")); err != nil { | ||
| 111 | return err | ||
| 112 | } | ||
| 113 | _ = st.Index.Upsert(m) | ||
| 114 | if a.Can("", access.Read) { | ||
| 115 | fmt.Printf("default: %s (repo is now public: web + anonymous HTTP clone)\n", a.Default) | ||
| 116 | } else { | ||
| 117 | fmt.Printf("default: %s (repo is private: 404 on the web and over HTTP)\n", a.Default) | ||
| 118 | } | ||
| 119 | return nil | ||
| 120 | |||
| 121 | case "protect": | ||
| 122 | if len(f.rest) < 2 { | ||
| 123 | return errors.New("usage: vole acl protect <repo> <ref-pattern>...") | ||
| 124 | } | ||
| 125 | a.Protected = f.rest[1:] | ||
| 126 | if err := m.SetACL(a, os.Getenv("USER")); err != nil { | ||
| 127 | return err | ||
| 128 | } | ||
| 129 | _ = st.Index.Upsert(m) | ||
| 130 | fmt.Printf("protected: %s\n", strings.Join(a.Protected, ", ")) | ||
| 131 | return nil | ||
| 132 | |||
| 133 | case "checks": | ||
| 134 | if len(f.rest) != 2 { | ||
| 135 | return errors.New("usage: vole acl checks <repo> <on|off>") | ||
| 136 | } | ||
| 137 | switch strings.ToLower(f.rest[1]) { | ||
| 138 | case "on", "true", "yes", "1": | ||
| 139 | v := true | ||
| 140 | a.ChecksRequired = &v | ||
| 141 | case "off", "false", "no", "0": | ||
| 142 | v := false | ||
| 143 | a.ChecksRequired = &v | ||
| 144 | default: | ||
| 145 | return fmt.Errorf("%q is not on or off", f.rest[1]) | ||
| 146 | } | ||
| 147 | if err := m.SetACL(a, os.Getenv("USER")); err != nil { | ||
| 148 | return err | ||
| 149 | } | ||
| 150 | _ = st.Index.Upsert(m) | ||
| 151 | if a.RequiresChecks() { | ||
| 152 | fmt.Println("checks: required on protected branches (a commit lands only after its CI jobs pass)") | ||
| 153 | } else { | ||
| 154 | fmt.Println("checks: not required; approvals alone allow a merge") | ||
| 155 | } | ||
| 156 | return nil | ||
| 157 | |||
| 158 | case "runners": | ||
| 159 | // `runners <repo>` lists the allowlist and `<name>...` replaces it, the same | ||
| 160 | // shape as `acl protect`. `--add` and `--remove` change it one name at a time | ||
| 161 | // and `--clear` empties it, which is how git edits a list (`config --add`, | ||
| 162 | // `config --unset-all`, `remote set-url --add`) rather than with signs. | ||
| 163 | edits := 0 | ||
| 164 | for _, b := range []string{"add", "remove", "clear"} { | ||
| 165 | if f.has(b) { | ||
| 166 | edits++ | ||
| 167 | } | ||
| 168 | } | ||
| 169 | if edits > 1 { | ||
| 170 | return usagef("use one of --add, --remove or --clear") | ||
| 171 | } | ||
| 172 | names := f.rest[1:] | ||
| 173 | if len(names) == 0 && !f.has("clear") { | ||
| 174 | printRunners(st, a, m.Name) | ||
| 175 | return nil | ||
| 176 | } | ||
| 177 | var next []string | ||
| 178 | switch { | ||
| 179 | case f.has("clear"): | ||
| 180 | if len(names) > 0 { | ||
| 181 | return usagef("--clear takes no names: it empties the list") | ||
| 182 | } | ||
| 183 | // next stays empty | ||
| 184 | case f.has("add"), f.has("remove"): | ||
| 185 | if len(names) == 0 { | ||
| 186 | return usagef("--add and --remove need at least one account") | ||
| 187 | } | ||
| 188 | next = append(next, a.Runners...) | ||
| 189 | for _, name := range names { | ||
| 190 | id, err := runnerAccount(st, name) | ||
| 191 | if err != nil { | ||
| 192 | return err | ||
| 193 | } | ||
| 194 | if !f.has("remove") { | ||
| 195 | next = append(next, id) | ||
| 196 | continue | ||
| 197 | } | ||
| 198 | // `git config --unset` fails when there is nothing to unset, and so | ||
| 199 | // does this: a revoked trust that was never granted is a mistake | ||
| 200 | // worth hearing about. | ||
| 201 | kept, found := next[:0], false | ||
| 202 | for _, r := range next { | ||
| 203 | if r == id { | ||
| 204 | found = true | ||
| 205 | continue | ||
| 206 | } | ||
| 207 | kept = append(kept, r) | ||
| 208 | } | ||
| 209 | if !found { | ||
| 210 | return fmt.Errorf("%s is not a runner for %s", name, m.Name) | ||
| 211 | } | ||
| 212 | next = kept | ||
| 213 | } | ||
| 214 | default: | ||
| 215 | for _, name := range names { | ||
| 216 | id, err := runnerAccount(st, name) | ||
| 217 | if err != nil { | ||
| 218 | return err | ||
| 219 | } | ||
| 220 | next = append(next, id) | ||
| 221 | } | ||
| 222 | } | ||
| 223 | a.SetRunners(next) | ||
| 224 | if err := m.SetACL(a, os.Getenv("USER")); err != nil { | ||
| 225 | return err | ||
| 226 | } | ||
| 227 | _ = st.Index.Upsert(m) | ||
| 228 | printRunners(st, a, m.Name) | ||
| 229 | return nil | ||
| 230 | |||
| 231 | case "approvals": | ||
| 232 | if len(f.rest) != 2 { | ||
| 233 | return errors.New("usage: vole acl approvals <repo> <n>") | ||
| 234 | } | ||
| 235 | n, err := strconv.Atoi(f.rest[1]) | ||
| 236 | if err != nil || n < 0 { | ||
| 237 | return fmt.Errorf("%q is not a number of approvals", f.rest[1]) | ||
| 238 | } | ||
| 239 | a.ApprovalsRequired = &n | ||
| 240 | if err := m.SetACL(a, os.Getenv("USER")); err != nil { | ||
| 241 | return err | ||
| 242 | } | ||
| 243 | _ = st.Index.Upsert(m) | ||
| 244 | if n == 0 { | ||
| 245 | fmt.Println("approvals: 0 (protected branches are admin-only)") | ||
| 246 | } else { | ||
| 247 | fmt.Printf("approvals: %d required on protected branches\n", n) | ||
| 248 | } | ||
| 249 | return nil | ||
| 250 | } | ||
| 251 | return fmt.Errorf("unknown acl subcommand %q", sub) | ||
| 252 | } | ||
| 253 | |||
| 254 | // runnerAccount resolves a name or id to an account id, refusing an unknown one | ||
| 255 | // rather than writing a typo that silently permits nobody. | ||
| 256 | func runnerAccount(st *forge.Instance, name string) (string, error) { | ||
| 257 | if name == "" { | ||
| 258 | return "", usagef("a runner needs an account name or id") | ||
| 259 | } | ||
| 260 | account, err := st.Identity.Find(name) | ||
| 261 | if err != nil { | ||
| 262 | return "", err | ||
| 263 | } | ||
| 264 | return account.ID, nil | ||
| 265 | } | ||
| 266 | |||
| 267 | // printRunners renders the allowlist as names with ids, and says what an empty list | ||
| 268 | // means, since "no runners" reads like "CI cannot count" rather than "admins still | ||
| 269 | // count". | ||
| 270 | func printRunners(st *forge.Instance, a *access.ACL, repo string) { | ||
| 271 | for _, r := range a.Runners { | ||
| 272 | fmt.Printf("runner %-28s %s\n", r, st.Identity.Name(r)) | ||
| 273 | } | ||
| 274 | if len(a.Runners) > 0 { | ||
| 275 | fmt.Println("(admins may also report CI results)") | ||
| 276 | return | ||
| 277 | } | ||
| 278 | fmt.Printf("runners: none named; only admins may report CI results for %s\n", repo) | ||
| 279 | for _, p := range a.Principals() { | ||
| 280 | if a.CanReport(p) { | ||
| 281 | fmt.Printf(" admin %-28s %s\n", p, st.Identity.Name(p)) | ||
| 282 | } | ||
| 283 | } | ||
| 284 | } | ||