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