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 instance-wide roles and refine access UI
5b814e9958090cfd1fa826efb2d375bf613547c1 · Keenan Nemetz · 14d ago · parent 9579c1f9
Add instance-wide roles and refine access UI Instance roles now live on accounts, apply to public repositories, and the Admin role is the unified instance-admin permission with access to every repository. Repository ACLs can raise an instance role but not lower it. Adds role assignment to the users UI and CLI, open-registration role config, a reusable role picker, and separates adding a repository account from saving access rules.
server/web/admin.go +104−26
| @@ -2,24 +2,69 @@ package web | |||
| 2 | 2 | ||
| 3 | 3 | // Instance administration is split across the surfaces it belongs to: accounts | |
| 4 | 4 | // live on their own Users page in the top navigation, and a repository's access | |
| 5 | // rules are part of that repository's Settings page. Both are instance-admin | ||
| 6 | // operations; repository admins own the rest of Settings. | ||
| 5 | // rules are part of that repository's Settings page. An instance admin has the | ||
| 6 | // Admin role on every repository, so they can administer Settings there too. | ||
| 7 | 7 | ||
| 8 | 8 | import ( | |
| 9 | 9 | "net/http" | |
| 10 | "slices" | ||
| 11 | 10 | "sort" | |
| 12 | 11 | ||
| 13 | 12 | "github.com/rodent-software/vole/forge/access" | |
| 14 | 13 | ) | |
| 15 | 14 | ||
| 16 | var aclRoles = []string{"none", "read", "contributor", "maintain", "admin"} | ||
| 15 | type roleOption struct { | ||
| 16 | Value string | ||
| 17 | Name string | ||
| 18 | Description string | ||
| 19 | } | ||
| 20 | |||
| 21 | var instanceRoleOptions = []roleOption{ | ||
| 22 | {"none", "none", "no instance-wide access"}, | ||
| 23 | {"read", "read", "read and clone public repositories"}, | ||
| 24 | {"contribute", "contribute", "read, discuss, and push your own contrib branches"}, | ||
| 25 | {"maintain", "maintain", "push shared branches, triage, and approve on public repositories"}, | ||
| 26 | {"admin", "admin", "full admin on every repository, including private ones"}, | ||
| 27 | } | ||
| 28 | |||
| 29 | var repoRoleOptions = []roleOption{ | ||
| 30 | {"none", "none", "no repository-specific entry"}, | ||
| 31 | {"read", "read", "clone, fetch, and browse"}, | ||
| 32 | {"contribute", "contribute", "discuss and push your own contrib branches"}, | ||
| 33 | {"maintain", "maintain", "push shared branches, triage, and approve"}, | ||
| 34 | {"admin", "admin", "manage access, protected branches, and settings"}, | ||
| 35 | } | ||
| 36 | |||
| 37 | type rolePickerData struct { | ||
| 38 | Name string | ||
| 39 | Selected string | ||
| 40 | Legend string | ||
| 41 | Help string | ||
| 42 | Compact bool | ||
| 43 | Options []roleOption | ||
| 44 | } | ||
| 45 | |||
| 46 | // newRolePicker is the one role-selection control. scope chooses the wording: | ||
| 47 | // instance roles apply to public repositories, while repository roles describe | ||
| 48 | // what the entry grants in one repository. compact is for table rows, where the | ||
| 49 | // descriptions live in the radio's accessible label rather than on screen. | ||
| 50 | func newRolePicker(name, selected, legend, help, scope string, compact bool) rolePickerData { | ||
| 51 | options := instanceRoleOptions | ||
| 52 | if scope == "repo" { | ||
| 53 | options = repoRoleOptions | ||
| 54 | } | ||
| 55 | return rolePickerData{ | ||
| 56 | Name: name, Selected: selected, Legend: legend, Help: help, | ||
| 57 | Compact: compact, Options: options, | ||
| 58 | } | ||
| 59 | } | ||
| 17 | 60 | ||
| 18 | 61 | type aclRow struct { | |
| 19 | Name string | ||
| 20 | Status string | ||
| 21 | Role string | ||
| 22 | Runner bool | ||
| 62 | Name string | ||
| 63 | Status string | ||
| 64 | Role string // repository-specific entry, or none when inherited | ||
| 65 | InstanceRole string | ||
| 66 | EffectiveRole string | ||
| 67 | Runner bool | ||
| 23 | 68 | } | |
| 24 | 69 | ||
| 25 | 70 | // requireInstanceAdmin redirects an anonymous visitor to login and hides the | |
| @@ -36,6 +81,21 @@ func (w *Web) requireInstanceAdmin(rw http.ResponseWriter, r *http.Request) (*pr | |||
| 36 | 81 | return pr, true | |
| 37 | 82 | } | |
| 38 | 83 | ||
| 84 | func (w *Web) accountStatus(name string) string { | ||
| 85 | if w.Ident == nil { | ||
| 86 | return "missing" | ||
| 87 | } | ||
| 88 | acct, err := w.Ident.Find(name) | ||
| 89 | if err != nil { | ||
| 90 | return "missing" | ||
| 91 | } | ||
| 92 | return acct.Status | ||
| 93 | } | ||
| 94 | |||
| 95 | // aclRows lists only the accounts this repository actually mentions: an | ||
| 96 | // explicit role entry or a runner allowlist entry. The roster is not enumerated | ||
| 97 | // here, because a repository with three entries should not render every account | ||
| 98 | // on the instance. | ||
| 39 | 99 | func (w *Web) aclRows(a *access.ACL) []aclRow { | |
| 40 | 100 | isRunner := func(id string) bool { | |
| 41 | 101 | for _, runner := range a.Runners { | |
| @@ -54,32 +114,50 @@ func (w *Web) aclRows(a *access.ACL) []aclRow { | |||
| 54 | 114 | ||
| 55 | 115 | seen := map[string]bool{} | |
| 56 | 116 | var rows []aclRow | |
| 57 | add := func(name, status string) { | ||
| 117 | add := func(name string) { | ||
| 118 | if name == "" || seen[name] { | ||
| 119 | return | ||
| 120 | } | ||
| 58 | 121 | seen[name] = true | |
| 122 | instance := access.None | ||
| 123 | instanceAdmin := false | ||
| 124 | if w.Ident != nil { | ||
| 125 | instance = w.Ident.InstanceRole(name) | ||
| 126 | instanceAdmin = w.Ident.IsAdmin(name) | ||
| 127 | } | ||
| 59 | 128 | rows = append(rows, aclRow{ | |
| 60 | Name: name, Status: status, | ||
| 129 | Name: name, Status: w.accountStatus(name), | ||
| 61 | 130 | Role: role(name), Runner: isRunner(name), | |
| 131 | InstanceRole: instance.String(), | ||
| 132 | EffectiveRole: a.EffectiveRole(name, instanceAdmin).String(), | ||
| 62 | 133 | }) | |
| 63 | 134 | } | |
| 64 | if w.Ident != nil { | ||
| 65 | for _, acct := range w.Ident.All() { | ||
| 66 | add(acct.Name, acct.Status) | ||
| 67 | } | ||
| 68 | } | ||
| 69 | var extra []string | ||
| 70 | 135 | for id := range a.Entries { | |
| 71 | if !seen[id] { | ||
| 72 | extra = append(extra, id) | ||
| 73 | } | ||
| 136 | add(id) | ||
| 74 | 137 | } | |
| 75 | 138 | for _, id := range a.Runners { | |
| 76 | if !seen[id] && !slices.Contains(extra, id) { | ||
| 77 | extra = append(extra, id) | ||
| 78 | } | ||
| 79 | } | ||
| 80 | sort.Strings(extra) | ||
| 81 | for _, name := range extra { | ||
| 82 | add(name, "missing") | ||
| 139 | add(id) | ||
| 83 | 140 | } | |
| 141 | sort.Slice(rows, func(i, j int) bool { return rows[i].Name < rows[j].Name }) | ||
| 84 | 142 | return rows | |
| 85 | 143 | } | |
| 144 | |||
| 145 | // addableAccounts are active accounts with no explicit role or runner entry in | ||
| 146 | // this repository. They are the choices for the "Add account" control. | ||
| 147 | func (w *Web) addableAccounts(rows []aclRow) []string { | ||
| 148 | if w.Ident == nil { | ||
| 149 | return nil | ||
| 150 | } | ||
| 151 | seen := map[string]bool{} | ||
| 152 | for _, row := range rows { | ||
| 153 | seen[row.Name] = true | ||
| 154 | } | ||
| 155 | var out []string | ||
| 156 | for _, acct := range w.Ident.All() { | ||
| 157 | if acct.Active() && !seen[acct.Name] { | ||
| 158 | out = append(out, acct.Name) | ||
| 159 | } | ||
| 160 | } | ||
| 161 | sort.Strings(out) | ||
| 162 | return out | ||
| 163 | } | ||