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