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.

Add the instance access editor and repository danger zone

2973f7274b3c569de6018239ac8c90df98c38a1b · Keenan Nemetz · 15d ago · parent 1948358f

Add the instance access editor and repository danger zone

Instance admins get /admin: a repository list and a per-repository ACL editor for
default role, per-account roles, protected branches, approvals, checks, and
runners. The editor reads repositories directly instead of through the read
rule, because an instance admin may not have a role in the repository they are
administering. Everything else still hides behind a 404.

Repository admins get rename and delete on the settings page behind typed
confirmation. Both go through the instance operations that also move the index,
queue, LFS namespace, and locks, carry CSRF, and are audited.

server/web/admin.go +267

@@ -0,0 +1,267 @@
1 package web
2
3 // Instance administration starts with the per-repository access rules. The
4 // routes are instance-admin only: a repository admin manages description,
5 // branch, and visibility, but not who gets which role or what CI may be trusted.
6
7 import (
8 "net/http"
9 "slices"
10 "sort"
11 "strconv"
12 "strings"
13
14 "github.com/rodent-software/vole/forge/access"
15 "github.com/rodent-software/vole/forge/repo"
16 )
17
18 var aclRoles = []string{"none", "read", "contributor", "maintain", "admin"}
19
20 type adminRepoView struct {
21 Name string
22 Description string
23 Public bool
24 Default string
25 Principals int
26 Runners int
27 }
28
29 type aclRow struct {
30 ID string
31 Label string
32 Name string
33 Status string
34 Role string
35 Runner bool
36 }
37
38 type adminAccessView struct {
39 Page
40 Name string
41 Description string
42 Public bool
43 Default string
44 Approvals int
45 Checks bool
46 Protected string
47 Roles []string
48 Rows []aclRow
49 Error string
50 Saved bool
51 }
52
53 // requireInstanceAdmin redirects an anonymous visitor to login and hides the
54 // admin surface from an authenticated non-admin.
55 func (w *Web) requireInstanceAdmin(rw http.ResponseWriter, r *http.Request) (*principal, bool) {
56 pr, ok := w.requireHTMLUser(rw, r)
57 if !ok {
58 return nil, false
59 }
60 if w.Ident == nil || !w.Ident.IsAdmin(pr.account.ID) {
61 w.notFound(rw, r)
62 return nil, false
63 }
64 return pr, true
65 }
66
67 func (w *Web) AdminPage(rw http.ResponseWriter, r *http.Request) {
68 if _, ok := w.requireInstanceAdmin(rw, r); !ok {
69 return
70 }
71 repos, err := w.Repos.List()
72 if err != nil {
73 w.serverError(rw, r, "cannot list repositories: "+err.Error())
74 return
75 }
76 views := make([]adminRepoView, 0, len(repos))
77 for _, m := range repos {
78 acl, err := m.ACL()
79 if err != nil {
80 w.serverError(rw, r, "cannot read access rules for "+m.Name+": "+err.Error())
81 return
82 }
83 views = append(views, adminRepoView{
84 Name: m.Name,
85 Description: m.Description,
86 Public: acl.Can("", access.Read),
87 Default: acl.Default,
88 Principals: len(acl.Entries),
89 Runners: len(acl.Runners),
90 })
91 }
92 w.render(rw, "admin", struct {
93 Page
94 Repos []adminRepoView
95 }{w.page(r, "Admin"), views}, 0)
96 }
97
98 func (w *Web) adminAccessView(r *http.Request, m *repo.Meta, acl *access.ACL, msg string) adminAccessView {
99 return adminAccessView{
100 Page: w.page(r, m.Name+" · access"),
101 Name: m.Name,
102 Description: m.Description,
103 Public: acl.Can("", access.Read),
104 Default: acl.Default,
105 Approvals: acl.RequiredApprovals(),
106 Checks: acl.RequiresChecks(),
107 Protected: strings.Join(acl.Protected, "\n"),
108 Roles: aclRoles,
109 Rows: w.aclRows(acl),
110 Error: msg,
111 Saved: r.URL.Query().Get("saved") == "1",
112 }
113 }
114
115 func (w *Web) aclRows(a *access.ACL) []aclRow {
116 isRunner := func(id string) bool {
117 for _, runner := range a.Runners {
118 if runner == id {
119 return true
120 }
121 }
122 return false
123 }
124 role := func(id string) string {
125 if r, ok := a.Entries[id]; ok {
126 return r
127 }
128 return access.None.String()
129 }
130
131 seen := map[string]bool{}
132 var rows []aclRow
133 add := func(id, label, name, status string) {
134 seen[id] = true
135 rows = append(rows, aclRow{
136 ID: id, Label: label, Name: name, Status: status,
137 Role: role(id), Runner: isRunner(id),
138 })
139 }
140 if w.Ident != nil {
141 for _, acct := range w.Ident.All() {
142 add(acct.ID, acct.Label(), acct.Name, acct.Status)
143 }
144 }
145 var extra []string
146 for id := range a.Entries {
147 if !seen[id] {
148 extra = append(extra, id)
149 }
150 }
151 for _, id := range a.Runners {
152 if !seen[id] && !slices.Contains(extra, id) {
153 extra = append(extra, id)
154 }
155 }
156 sort.Strings(extra)
157 for _, id := range extra {
158 add(id, id, "", "missing")
159 }
160 return rows
161 }
162
163 func (w *Web) AdminAccessPage(rw http.ResponseWriter, r *http.Request) {
164 if _, ok := w.requireInstanceAdmin(rw, r); !ok {
165 return
166 }
167 m, err := w.Repos.Open(r.PathValue("name"))
168 if err != nil {
169 w.notFound(rw, r)
170 return
171 }
172 acl, err := m.ACL()
173 if err != nil {
174 w.serverError(rw, r, "cannot read access rules: "+err.Error())
175 return
176 }
177 w.render(rw, "admin_access", w.adminAccessView(r, m, acl, ""), 0)
178 }
179
180 func (w *Web) SaveAdminAccess(rw http.ResponseWriter, r *http.Request) {
181 pr, ok := w.requireInstanceAdmin(rw, r)
182 if !ok {
183 return
184 }
185 m, err := w.Repos.Open(r.PathValue("name"))
186 if err != nil {
187 w.notFound(rw, r)
188 return
189 }
190 acl, err := m.ACL()
191 if err != nil {
192 w.serverError(rw, r, "cannot read access rules: "+err.Error())
193 return
194 }
195 r.Body = http.MaxBytesReader(rw, r.Body, 1<<20)
196 if !w.checkCSRF(r) {
197 http.Error(rw, "bad request", http.StatusForbidden)
198 return
199 }
200 if err := r.ParseForm(); err != nil {
201 http.Error(rw, "cannot read the form", http.StatusBadRequest)
202 return
203 }
204
205 updated := *acl
206 updated.Entries = map[string]string{}
207 defaultRole, err := access.ParseRole(r.PostFormValue("default"))
208 if err != nil {
209 http.Error(rw, err.Error(), http.StatusBadRequest)
210 return
211 }
212 updated.Default = defaultRole.String()
213 approvals, err := strconv.Atoi(strings.TrimSpace(r.PostFormValue("approvals")))
214 if err != nil || approvals < 0 {
215 http.Error(rw, "approvals must be a non-negative number", http.StatusBadRequest)
216 return
217 }
218 updated.ApprovalsRequired = &approvals
219 checks := r.PostFormValue("checks") != ""
220 updated.ChecksRequired = &checks
221 updated.Protected = splitLines(r.PostFormValue("protected"))
222
223 var runners []string
224 for _, row := range w.aclRows(acl) {
225 // A field the form did not submit keeps whatever the ACL already had,
226 // so a race with a newly added entry cannot silently delete it.
227 if raw, present := r.PostForm["role:"+row.ID]; present {
228 role, err := access.ParseRole(raw[0])
229 if err != nil {
230 http.Error(rw, "account "+row.Label+": "+err.Error(), http.StatusBadRequest)
231 return
232 }
233 if role != access.None {
234 updated.Entries[row.ID] = role.String()
235 }
236 } else if existing, ok := acl.Entries[row.ID]; ok {
237 updated.Entries[row.ID] = existing
238 }
239 if r.PostFormValue("runner:"+row.ID) != "" {
240 runners = append(runners, row.ID)
241 }
242 }
243 updated.Runners = nil
244 updated.SetRunners(runners)
245 if err := m.SetACL(&updated, pr.account.ID); err != nil {
246 w.serverError(rw, r, "cannot save access rules: "+err.Error())
247 return
248 }
249 if w.Index != nil {
250 if err := w.Index.Upsert(m); err != nil {
251 w.serverError(rw, r, "cannot update the repository index: "+err.Error())
252 return
253 }
254 }
255 w.audit("web.admin.acl", pr.account.ID, m.Name)
256 http.Redirect(rw, r, "/admin/repos/"+m.Name+"/access?saved=1", http.StatusSeeOther)
257 }
258
259 func splitLines(raw string) []string {
260 var out []string
261 for _, line := range strings.Split(raw, "\n") {
262 if line = strings.TrimSpace(line); line != "" {
263 out = append(out, line)
264 }
265 }
266 return out
267 }