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 · 19m ago 4b8c9e1d
history blame raw 9.0 KiB
1package web
2
3// The instance-admin half of a repository's Settings page: the access rules, the
4// review policy, and which accounts' CI results count as checks. It is the Access
5// tab.
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
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.
13
14import (
15 "net/http"
16 "sort"
17 "strconv"
18 "strings"
19
20 "rodent.software/vole/forge/access"
21)
22
23type 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.
34type accessView struct {
35 Default string
36 Approvals int
37 Checks bool
38 LinkedIssue bool
39 Protected string
40 Rows []aclRow
41 AddAccounts []string
42 Error string
43 Saved bool
44 Added bool
45}
46
47func (w *Web) accessView(r *http.Request, ctx *settingsContext) *accessView {
48 if !ctx.instanceAdmin {
49 return nil
50 }
51 v := &accessView{
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),
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.
71func (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") != ""
96 linkedIssue := r.PostFormValue("require_linked_issue") != ""
97 protected := splitLines(r.PostFormValue("protected"))
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 }
104
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
114 var runners []string
115 for _, row := range w.aclRows(acl) {
116 rendered[row.Name] = true
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 }
123 roles = append(roles, roleChange{name: row.Name, role: role})
124 }
125 if r.PostFormValue("runner:"+row.Name) != "" {
126 runners = append(runners, row.Name)
127 }
128 }
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
134 cur.RequireLinkedIssue = linkedIssue
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 {
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 }
164 w.audit("web.admin.acl", pr.account.Name, m.Name, "")
165 http.Redirect(rw, r, "/"+m.Name+"/settings?tab=access&saved=access#access", http.StatusSeeOther)
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.
171func (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 }
180 m, pr := ctx.m, ctx.pr
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 }
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 {
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 }
218 w.audit("web.admin.acl.add", pr.account.Name, m.Name, acct.Name+"="+role.String())
219 http.Redirect(rw, r, "/"+m.Name+"/settings?tab=access&saved=added#access", http.StatusSeeOther)
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.
224func (w *Web) accessFailure(rw http.ResponseWriter, r *http.Request, ctx *settingsContext, msg string) {
225 v := w.settingsView(r, ctx)
226 v.focusTab("access")
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
234func 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.
248func (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.
296func (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}
313