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 | } | ||