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.

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 }