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.

forge/access/acl.go +66−24

@@ -14,7 +14,7 @@ type Role int
14 14 const (
15 15 None Role = iota
16 16 Read
17 Contributor
17 Contribute
18 18 Maintain
19 19 Admin
20 20 )
@@ -25,8 +25,8 @@ func ParseRole(s string) (Role, error) {
25 25 return None, nil
26 26 case "read":
27 27 return Read, nil
28 case "contributor", "contribute", "contrib":
29 return Contributor, nil
28 case "contribute", "contributor", "contrib":
29 return Contribute, nil
30 30 case "maintain", "maintainer", "write":
31 31 // "write" is the historical spelling. Existing ACL rows and scripts keep
32 32 // working; new entries are written as "maintain".
@@ -34,14 +34,14 @@ func ParseRole(s string) (Role, error) {
34 34 case "admin", "owner":
35 35 return Admin, nil
36 36 }
37 return None, fmt.Errorf("unknown role %q (want none, read, contributor, maintain or admin)", s)
37 return None, fmt.Errorf("unknown role %q (want none, read, contribute, maintain or admin)", s)
38 38 }
39 39 func (r Role) String() string {
40 40 switch r {
41 41 case Read:
42 42 return "read"
43 case Contributor:
44 return "contributor"
43 case Contribute:
44 return "contribute"
45 45 case Maintain:
46 46 return "maintain"
47 47 case Admin:
@@ -56,6 +56,11 @@ func (r Role) AtLeast(need Role) bool { return r >= need }
56 56 // parsing. New code should say Maintain.
57 57 const Write = Maintain
58 58
59 // Contributor is the historical spelling of Contribute. It stays so external
60 // code and ACL rows written before the rename keep compiling and parsing. New
61 // code should say Contribute.
62 const Contributor = Contribute
63
59 64 // ACL is one repository's rules. They live in the instance database beside the roster
60 65 // (see the access package), not inside the repository: they are configuration the
61 66 // instance owns, and they are keyed by account name, so a copy in a clone would mean
@@ -85,6 +90,13 @@ type ACL struct {
85 90 // here would be theatre. Everyone else — a maintainer who runs CI on their laptop
86 91 // included — gets their result recorded but not counted.
87 92 Runners []string `json:"runners,omitempty"`
93
94 // instanceRole is not part of the repository document. The access store
95 // attaches the roster's instance-wide role when it loads the ACL, so every
96 // capability check below sees a global grant without every caller having to
97 // pass it in. It is deliberately unexported: instance roles belong to the
98 // account, and writing one into repo_acl would freeze it at that moment.
99 instanceRole func(string) Role
88 100 }
89 101
90 102 // CanReport says whether a CI result reported by this account counts as a check
@@ -181,10 +193,33 @@ func (a *ACL) Bytes() ([]byte, error) {
181 193 return append(b, '\n'), nil
182 194 }
183 195
184 // RoleFor resolves a principal's effective role. Privilege only ever comes from
185 // an explicit entry: the default applies to everyone else, and it may never
186 // exceed read unless an operator explicitly says so.
196 // RoleFor resolves a principal's effective role.
197 //
198 // A repository grant or the repository default is the base. An instance-wide
199 // role raises that base, but only on a public repository: a private repository
200 // is not made readable by an instance grant, because "contribute on everything"
201 // should not mean "reader of every draft". The exception is the instance Admin
202 // role: an instance administrator may govern every repository, private ones
203 // included, so they are Admin everywhere.
187 204 func (a *ACL) RoleFor(user string) Role {
205 role := a.RepositoryRoleFor(user)
206 if user == "" || a.instanceRole == nil {
207 return role
208 }
209 instance := a.instanceRole(user)
210 if instance.AtLeast(Admin) {
211 return Admin
212 }
213 if a.Public() && instance > role {
214 return instance
215 }
216 return role
217 }
218
219 // RepositoryRoleFor is RoleFor without the instance-wide overlay. Repository
220 // settings use it so an inherited instance role is never mistaken for a
221 // repository-specific entry.
222 func (a *ACL) RepositoryRoleFor(user string) Role {
188 223 if user != "" {
189 224 if r, ok := a.Entries[user]; ok {
190 225 if role, err := ParseRole(r); err == nil {
@@ -195,18 +230,25 @@ func (a *ACL) RoleFor(user string) Role {
195 230 def, _ := ParseRole(a.Default)
196 231 return def
197 232 }
233
234 // Public reports whether anonymous readers may read the repository. Instance
235 // roles only apply where this is true.
236 func (a *ACL) Public() bool {
237 def, _ := ParseRole(a.Default)
238 return def.AtLeast(Read)
239 }
240
198 241 func (a *ACL) Can(user string, need Role) bool { return a.RoleFor(user).AtLeast(need) }
199 242
200 // EffectiveRole is RoleFor with one policy overlay: an instance administrator may
201 // read every repository. They can already rewrite any repository's rules, so
202 // hiding the content would hide the thing they govern without protecting it. An
203 // explicit role above read is kept; this only raises none and read to read.
243 // EffectiveRole keeps the old call shape. The bool is retained for callers that
244 // already know the account is an instance administrator; the role itself is
245 // already part of RoleFor when the access store is wired to the roster. The
246 // explicit check makes the rule true even for a bare ACL in a test.
204 247 func (a *ACL) EffectiveRole(user string, instanceAdmin bool) Role {
205 role := a.RoleFor(user)
206 if user != "" && instanceAdmin && role < Read {
207 return Read
248 if user != "" && instanceAdmin {
249 return Admin
208 250 }
209 return role
251 return a.RoleFor(user)
210 252 }
211 253
212 254 // ContributorBranchPrefix is the only branch namespace a contributor may push.
@@ -245,19 +287,19 @@ func ContributorRefName(ref string) (name string, ok bool) {
245 287 return rest[:i], true
246 288 }
247 289
248 // CanDiscuss covers filing issues and commenting. A contributor may do this
249 // without being able to move a shared branch.
250 func (a *ACL) CanDiscuss(user string) bool { return a.RoleFor(user).AtLeast(Contributor) }
290 // CanDiscuss covers filing issues and commenting. The Contribute role may do
291 // this without being able to move a shared branch.
292 func (a *ACL) CanDiscuss(user string) bool { return a.RoleFor(user).AtLeast(Contribute) }
251 293
252 294 // CanOpenRequest covers opening a change request. The head must still be a
253 295 // branch the caller may push: the web checks the contributor namespace.
254 func (a *ACL) CanOpenRequest(user string) bool { return a.RoleFor(user).AtLeast(Contributor) }
296 func (a *ACL) CanOpenRequest(user string) bool { return a.RoleFor(user).AtLeast(Contribute) }
255 297
256 // CanPush says the account may push somewhere. Which refs a contributor may
257 // write is a namespace question and needs the account *name*, not the id the ACL
298 // CanPush says the account may push somewhere. Which refs the Contribute role
299 // may write is a namespace question and needs the account *name*, not the id the ACL
258 300 // is keyed by, so that half of the rule lives where the push is gate-kept: the
259 301 // pre-receive hook, which has the name the SSH key resolved to.
260 func (a *ACL) CanPush(user string) bool { return a.RoleFor(user).AtLeast(Contributor) }
302 func (a *ACL) CanPush(user string) bool { return a.RoleFor(user).AtLeast(Contribute) }
261 303
262 304 // CanTriage covers changing somebody else's issue: labels, assignees, state.
263 305 func (a *ACL) CanTriage(user string) bool { return a.RoleFor(user).AtLeast(Maintain) }