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.

Blame · server/web/admin_access.go

Line attribution on main · 312 line(s)

c717d4eb
Keenan Nemetz
13d ago
1 package web
2
3 // The instance-admin half of a repository's Settings page: the access rules, the
e189a23e
Keenan Nemetz
22h ago
4 // review policy, and which accounts' CI results count as checks. It is the Access
5 // tab.
c717d4eb
Keenan Nemetz
13d ago
6 //
7 // It is a separate file from settings.go because it is a separate rule and a
8 // separate audience. A repository admin owns the description, the default branch
9 // and whether the repository is public; an instance admin owns who may do what in
e189a23e
Keenan Nemetz
22h ago
10 // it. Both halves are tabs of one page because that is where an operator looks; the
11 // Access tab is only in the row for an instance admin, and a page whose only tab is
12 // this one says that the repository's details stay with its admins.
c717d4eb
Keenan Nemetz
13d ago
13
14 import (
15 "net/http"
16 "sort"
17 "strconv"
18 "strings"
19
d37d301f
Keenan Nemetz
15h ago
20 "rodent.software/vole/forge/access"
c717d4eb
Keenan Nemetz
13d ago
21 )
22
23 type aclRow struct {
24 Name string
25 Status string
26 Role string // repository-specific entry, or none when inherited
27 InstanceRole string
28 EffectiveRole string
29 Runner bool
30 }
31
32 // accessView is the access section. It is built only for an instance admin: the
33 // page renders it with {{with .Access}}, so a nil view means no section.
34 type accessView struct {
35 Default string
36 Approvals int
37 Checks bool
0789f22e
Keenan Nemetz
9d ago
38 LinkedIssue bool
c717d4eb
Keenan Nemetz
13d ago
39 Protected string
40 Rows []aclRow
41 AddAccounts []string
42 Error string
43 Saved bool
44 Added bool
45 }
46
47 func (w *Web) accessView(r *http.Request, ctx *settingsContext) *accessView {
48 if !ctx.instanceAdmin {
49 return nil
50 }
51 v := &accessView{
0789f22e
Keenan Nemetz
9d ago
52 Default: ctx.acl.Default,
53 Approvals: ctx.acl.RequiredApprovals(),
54 Checks: ctx.acl.RequiresChecks(),
55 LinkedIssue: ctx.acl.RequiresLinkedIssue(),
56 Protected: strings.Join(ctx.acl.Protected, "\n"),
57 Rows: w.aclRows(ctx.acl),
c717d4eb
Keenan Nemetz
13d ago
58 }
59 v.AddAccounts = w.addableAccounts(v.Rows)
60 switch r.URL.Query().Get("saved") {
61 case "access":
62 v.Saved = true
63 case "added":
64 v.Added = true
65 }
66 return v
67 }
68
69 // SaveRepositoryAccess applies the instance-admin half of Settings: who may do
70 // what here, and what a protected branch requires before it can land.
71 func (w *Web) SaveRepositoryAccess(rw http.ResponseWriter, r *http.Request) {
72 ctx, ok := w.settingsContext(rw, r)
73 if !ok {
74 return
75 }
76 if !ctx.instanceAdmin {
77 w.notFound(rw, r)
78 return
79 }
80 m, acl, pr := ctx.m, ctx.acl, ctx.pr
81 if !w.readForm(rw, r, maxSmallForm) {
82 return
83 }
84
85 defaultRole, err := access.ParseRole(r.PostFormValue("default"))
86 if err != nil {
87 w.accessFailure(rw, r, ctx, err.Error())
88 return
89 }
90 approvals, err := strconv.Atoi(strings.TrimSpace(r.PostFormValue("approvals")))
91 if err != nil || approvals < 0 {
92 w.accessFailure(rw, r, ctx, "approvals must be a non-negative number")
93 return
94 }
95 checks := r.PostFormValue("checks") != ""
0789f22e
Keenan Nemetz
9d ago
96 linkedIssue := r.PostFormValue("require_linked_issue") != ""
62b59a76
Keenan Nemetz
12d ago
97 protected := splitLines(r.PostFormValue("protected"))
93c7cae8
Keenan Nemetz
17h ago
98 // The same rule the CLI applies: a pattern that can never match would show
99 // the branch as protected in Settings and leave it open in the hook.
100 if err := access.ValidateProtectedPatterns(protected); err != nil {
101 w.accessFailure(rw, r, ctx, err.Error())
102 return
103 }
c717d4eb
Keenan Nemetz
13d ago
104
62b59a76
Keenan Nemetz
12d ago
105 // The rows this page rendered, and what the form said about each one. Anything
106 // the form did not submit is not this submission's to change: an entry or a
107 // runner another admin added while this page was open must survive the save.
108 rendered := map[string]bool{}
109 type roleChange struct {
110 name string
111 role access.Role
112 }
113 var roles []roleChange
c717d4eb
Keenan Nemetz
13d ago
114 var runners []string
115 for _, row := range w.aclRows(acl) {
62b59a76
Keenan Nemetz
12d ago
116 rendered[row.Name] = true
c717d4eb
Keenan Nemetz
13d ago
117 if raw, present := r.PostForm["role:"+row.Name]; present {
118 role, err := access.ParseRole(raw[0])
119 if err != nil {
120 w.accessFailure(rw, r, ctx, "account "+row.Name+": "+err.Error())
121 return
122 }
62b59a76
Keenan Nemetz
12d ago
123 roles = append(roles, roleChange{name: row.Name, role: role})
c717d4eb
Keenan Nemetz
13d ago
124 }
125 if r.PostFormValue("runner:"+row.Name) != "" {
126 runners = append(runners, row.Name)
127 }
128 }
62b59a76
Keenan Nemetz
12d ago
129
130 if _, err := m.UpdateACL(pr.account.Name, func(cur *access.ACL) error {
131 cur.Default = defaultRole.String()
132 cur.ApprovalsRequired = &approvals
133 cur.ChecksRequired = &checks
0789f22e
Keenan Nemetz
9d ago
134 cur.RequireLinkedIssue = linkedIssue
62b59a76
Keenan Nemetz
12d ago
135 cur.Protected = protected
136 for _, rc := range roles {
137 if rc.role == access.None {
138 delete(cur.Entries, rc.name)
139 continue
140 }
141 cur.Entries[rc.name] = rc.role.String()
142 }
143 // Runners for accounts this page did not render stay as they are; the
144 // rendered ones are exactly what the form said.
145 var kept []string
146 for _, existing := range cur.Runners {
147 if !rendered[existing] {
148 kept = append(kept, existing)
149 }
150 }
151 cur.Runners = nil
152 cur.SetRunners(append(kept, runners...))
153 return nil
154 }); err != nil {
c717d4eb
Keenan Nemetz
13d ago
155 w.serverError(rw, r, "cannot save access rules: "+err.Error())
156 return
157 }
158 if w.Index != nil {
159 if err := w.Index.Upsert(m); err != nil {
160 w.serverError(rw, r, "cannot update the repository index: "+err.Error())
161 return
162 }
163 }
b1499c72
Keenan Nemetz
12d ago
164 w.audit("web.admin.acl", pr.account.Name, m.Name, "")
ed03b3d1
Keenan Nemetz
2h ago
165 http.Redirect(rw, r, "/"+m.Name+"/settings?tab=access&saved=access#access", http.StatusSeeOther)
c717d4eb
Keenan Nemetz
13d ago
166 }
167
168 // AddRepositoryAccess grants one account a repository role and saves
169 // immediately. It is a separate form and endpoint from SaveRepositoryAccess so
170 // the button under the accounts table cannot be mistaken for the add control.
171 func (w *Web) AddRepositoryAccess(rw http.ResponseWriter, r *http.Request) {
172 ctx, ok := w.settingsContext(rw, r)
173 if !ok {
174 return
175 }
176 if !ctx.instanceAdmin {
177 w.notFound(rw, r)
178 return
179 }
62b59a76
Keenan Nemetz
12d ago
180 m, pr := ctx.m, ctx.pr
c717d4eb
Keenan Nemetz
13d ago
181 if !w.readForm(rw, r, maxSmallForm) {
182 return
183 }
184 name := strings.TrimSpace(r.PostFormValue("new_account"))
185 if name == "" {
186 w.accessFailure(rw, r, ctx, "Choose an account to add.")
187 return
188 }
189 acct, err := w.Ident.Find(name)
190 if err != nil || !acct.Active() {
191 w.accessFailure(rw, r, ctx, "Choose an active account to add.")
192 return
193 }
194 role, err := access.ParseRole(r.PostFormValue("new_role"))
195 if err != nil {
196 w.accessFailure(rw, r, ctx, "New account "+acct.Name+": "+err.Error())
197 return
198 }
199 if role == access.None {
200 w.accessFailure(rw, r, ctx, "Choose a role to grant.")
201 return
202 }
62b59a76
Keenan Nemetz
12d ago
203 // One entry, applied to the rules as they are now: a page loaded before another
204 // admin's change must not write that change away.
205 if _, err := m.UpdateACL(pr.account.Name, func(cur *access.ACL) error {
206 cur.Entries[acct.Name] = role.String()
207 return nil
208 }); err != nil {
c717d4eb
Keenan Nemetz
13d ago
209 w.serverError(rw, r, "cannot save access rules: "+err.Error())
210 return
211 }
212 if w.Index != nil {
213 if err := w.Index.Upsert(m); err != nil {
214 w.serverError(rw, r, "cannot update the repository index: "+err.Error())
215 return
216 }
217 }
b1499c72
Keenan Nemetz
12d ago
218 w.audit("web.admin.acl.add", pr.account.Name, m.Name, acct.Name+"="+role.String())
ed03b3d1
Keenan Nemetz
2h ago
219 http.Redirect(rw, r, "/"+m.Name+"/settings?tab=access&saved=added#access", http.StatusSeeOther)
c717d4eb
Keenan Nemetz
13d ago
220 }
221
222 // accessFailure re-renders the access form with the page's current values and a
223 // message, so a rejected role does not throw away the rest of the editor.
224 func (w *Web) accessFailure(rw http.ResponseWriter, r *http.Request, ctx *settingsContext, msg string) {
225 v := w.settingsView(r, ctx)
e189a23e
Keenan Nemetz
22h ago
226 v.focusTab("access")
c717d4eb
Keenan Nemetz
13d ago
227 if v.Access == nil {
228 v.Access = &accessView{}
229 }
230 v.Access.Error = msg
231 w.render(rw, "settings", v, http.StatusBadRequest)
232 }
233
234 func splitLines(raw string) []string {
235 var out []string
236 for _, line := range strings.Split(raw, "\n") {
237 if line = strings.TrimSpace(line); line != "" {
238 out = append(out, line)
239 }
240 }
241 return out
242 }
243
244 // aclRows lists only the accounts this repository actually mentions: an
245 // explicit role entry or a runner allowlist entry. The roster is not enumerated
246 // here, because a repository with three entries should not render every account
247 // on the instance.
248 func (w *Web) aclRows(a *access.ACL) []aclRow {
249 isRunner := func(id string) bool {
250 for _, runner := range a.Runners {
251 if runner == id {
252 return true
253 }
254 }
255 return false
256 }
257 role := func(id string) string {
258 if r, ok := a.Entries[id]; ok {
259 return r
260 }
261 return access.None.String()
262 }
263
264 seen := map[string]bool{}
265 var rows []aclRow
266 add := func(name string) {
267 if name == "" || seen[name] {
268 return
269 }
270 seen[name] = true
271 instance := access.None
272 instanceAdmin := false
273 if w.Ident != nil {
274 instance = w.Ident.InstanceRole(name)
275 instanceAdmin = w.Ident.IsAdmin(name)
276 }
277 rows = append(rows, aclRow{
278 Name: name, Status: w.accountStatus(name),
279 Role: role(name), Runner: isRunner(name),
280 InstanceRole: instance.String(),
281 EffectiveRole: a.EffectiveRole(name, instanceAdmin).String(),
282 })
283 }
284 for id := range a.Entries {
285 add(id)
286 }
287 for _, id := range a.Runners {
288 add(id)
289 }
290 sort.Slice(rows, func(i, j int) bool { return rows[i].Name < rows[j].Name })
291 return rows
292 }
293
294 // addableAccounts are active accounts with no explicit role or runner entry in
295 // this repository. They are the choices for the "Add account" control.
296 func (w *Web) addableAccounts(rows []aclRow) []string {
297 if w.Ident == nil {
298 return nil
299 }
300 seen := map[string]bool{}
301 for _, row := range rows {
302 seen[row.Name] = true
303 }
304 var out []string
305 for _, acct := range w.Ident.All() {
306 if acct.Active() && !seen[acct.Name] {
307 out = append(out, acct.Name)
308 }
309 }
310 sort.Strings(out)
311 return out
312 }