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/git/vole.git

Write over SSH with a registered key:

git clone ssh://git@0.0.0.0:2222/vole.git

A free and open-source forge that lives in your Git repo.

Code

392 commit(s) on main

Merge change request 01M70WES5ZZDJ6RP226WB11BBZ: Let people rename and remove their own passkeys keenan · 1h ago 4b8c9e1d
history blame raw 19.1 KiB
1// The role model below is used by both
2// the pre-receive hook and the web handlers so the two can never disagree.
3package access
4
5import (
6 "encoding/json"
7 "errors"
8 "fmt"
9 "sort"
10 "strings"
11 "unicode"
12
13 "rodent.software/vole/core/role"
14)
15
16// Role is the ordered access role. The vocabulary lives in core/role so a
17// workflow can validate an author role without importing the access store.
18type Role = role.Role
19
20const (
21 None = role.None
22 Read = role.Read
23 Contribute = role.Contribute
24 Maintain = role.Maintain
25 Admin = role.Admin
26)
27
28// ParseRole keeps the historical call shape.
29func ParseRole(s string) (Role, error) { return role.Parse(s) }
30
31// Write is the historical name for Maintain. It stays so external code using the
32// access package, and ACL rows written before the rename, keep compiling and
33// parsing. New code should say Maintain.
34const Write = Maintain
35
36// Contributor is the historical spelling of Contribute. It stays so external
37// code and ACL rows written before the rename keep compiling and parsing. New
38// code should say Contribute.
39const Contributor = Contribute
40
41// ACL is one repository's rules. They live in the instance database beside the roster
42// (see the access package), not inside the repository: they are configuration the
43// instance owns, and they are keyed by account name, so a copy in a clone would mean
44// nothing without the roster that gives those ids names.
45type ACL struct {
46 Version int `json:"version"`
47 Default string `json:"default"`
48 Entries map[string]string `json:"entries"`
49 Protected []string `json:"protected,omitempty"`
50 // ApprovalsRequired is how many approvals a change request needs before it
51 // may be pushed to a protected branch. Nil means the default of 1; an
52 // explicit 0 means only admins may push, which is the pre-reviews behaviour.
53 ApprovalsRequired *int `json:"approvals_required,omitempty"`
54 // ChecksRequired gates a protected branch on CI having passed for the commit
55 // being landed. Nil means true: a repository with tests is expected to run
56 // them before merging. A repository with no workflows is unaffected, because
57 // there is nothing to run.
58 ChecksRequired *bool `json:"checks_required,omitempty"`
59 // RequireLinkedIssue makes opening a new change request require at least one
60 // linked issue in its closes list. It is about how a team files work, not who
61 // may write: existing requests keep their history and can still be edited,
62 // approved, and landed after the setting is turned on.
63 RequireLinkedIssue bool `json:"require_linked_issue,omitempty"`
64 // Runners are the accounts whose CI results count as checks here. It is an
65 // allowlist rather than a role because it answers a different question from
66 // read/write: not "may this account touch the repository" but "may this
67 // account's build be believed about it". Naming somebody a runner also lets
68 // them lease jobs for this repository, which means executing its code.
69 //
70 // Admins are implicit: they can rewrite this list, and a protected branch
71 // already lets them land anything with no checks at all, so restricting them
72 // here would be theatre. Everyone else — a maintainer who runs CI on their laptop
73 // included — gets their result recorded but not counted.
74 Runners []string `json:"runners,omitempty"`
75
76 // instanceRole is not part of the repository document. The access store
77 // attaches the roster's instance-wide role when it loads the ACL, so every
78 // capability check below sees a global grant without every caller having to
79 // pass it in. It is deliberately unexported: instance roles belong to the
80 // account, and writing one into repo_acl would freeze it at that moment.
81 instanceRole func(string) Role
82 // active is the roster's answer to "is this account still active", attached by
83 // the store beside instanceRole. An explicit entry or runners-list membership
84 // outlives retirement otherwise: the row stays after the account is retired,
85 // and a retired account that still counts as an approver or a runner is a
86 // permission nobody revoked. Nil means the caller has no roster (a test).
87 active func(string) bool
88 // revision is the version of the row this rule set was read from, attached by
89 // the store. It is what makes a write compare-and-swap: a rule set read before
90 // somebody else's change carries the old revision and is refused. Zero means a
91 // rule set built from scratch for a repository that has no row yet.
92 revision int64
93}
94
95// Active reports whether the account is still active. A rule set with no roster
96// attached treats every name as active, which is only true in a test: every
97// instance wires the lookup.
98func (a *ACL) Active(name string) bool {
99 if a == nil || a.active == nil {
100 return true
101 }
102 return a.active(name)
103}
104
105// EligibleApprover reports whether this account's approval still counts. An
106// approval says "I read this change and I am answerable for it"; that statement is
107// worth nothing if the account has been retired or no longer maintains the
108// repository. The gate and the page count approvals through this rule, so they
109// cannot disagree about how many there are.
110func (a *ACL) EligibleApprover(name string) bool {
111 if a == nil || name == "" || !a.Active(name) {
112 return false
113 }
114 return a.RoleFor(name).AtLeast(Maintain)
115}
116
117// CanReport says whether a CI result reported by this account counts as a check
118// for this repository. An empty account never counts: an unattributed result is not
119// a result anybody is answerable for. Neither does a retired account: the runners
120// list is edited by hand and the row stays after retirement, so membership alone
121// would let a revoked account keep vouching for builds — and keep leasing jobs,
122// which means executing repository code.
123//
124// A repository role is deliberately not required of an explicit runner. The
125// runners list answers a different question from read/write ("may this account's
126// build be believed about it"), and a machine that only builds may hold no role.
127func (a *ACL) CanReport(accountID string) bool {
128 if accountID == "" || !a.Active(accountID) {
129 return false
130 }
131 if a.RoleFor(accountID).AtLeast(Admin) {
132 return true
133 }
134 for _, r := range a.Runners {
135 if r == accountID {
136 return true
137 }
138 }
139 return false
140}
141
142// SetRunners replaces the allowlist, dropping blanks and duplicates so the file
143// does not depend on how an operator typed it.
144func (a *ACL) SetRunners(ids []string) {
145 seen := map[string]bool{}
146 out := a.Runners[:0]
147 for _, id := range ids {
148 id = strings.TrimSpace(id)
149 if id == "" || seen[id] {
150 continue
151 }
152 seen[id] = true
153 out = append(out, id)
154 }
155 sort.Strings(out)
156 a.Runners = out
157}
158
159// RequiresChecks resolves the nil-means-true rule.
160func (a *ACL) RequiresChecks() bool {
161 if a.ChecksRequired == nil {
162 return true
163 }
164 return *a.ChecksRequired
165}
166
167// RequiresLinkedIssue reports whether new change requests must name at least
168// one issue they close.
169func (a *ACL) RequiresLinkedIssue() bool {
170 return a != nil && a.RequireLinkedIssue
171}
172
173// RequiredApprovals resolves the nil-means-default rule.
174func (a *ACL) RequiredApprovals() int {
175 if a.ApprovalsRequired == nil {
176 return 1
177 }
178 return *a.ApprovalsRequired
179}
180func Empty(owner string) *ACL {
181 a := &ACL{Version: 1, Default: "none", Entries: map[string]string{}}
182 if owner != "" {
183 a.Entries[owner] = "admin"
184 }
185 return a
186}
187func Parse(data []byte) (*ACL, error) {
188 a := &ACL{}
189 if err := json.Unmarshal(data, a); err != nil {
190 return nil, fmt.Errorf("acl: %w", err)
191 }
192 if a.Version == 0 {
193 a.Version = 1
194 }
195 if a.Entries == nil {
196 a.Entries = map[string]string{}
197 }
198 def, err := ParseRole(a.Default)
199 if err != nil {
200 return nil, err
201 }
202 a.Default = def.String()
203 if a.ApprovalsRequired != nil && *a.ApprovalsRequired < 0 {
204 return nil, fmt.Errorf("approvals_required cannot be negative")
205 }
206 for k, v := range a.Entries {
207 role, err := ParseRole(v)
208 if err != nil {
209 return nil, fmt.Errorf("acl entry %q: %w", k, err)
210 }
211 a.Entries[k] = role.String()
212 }
213 a.SetRunners(a.Runners)
214 return a, nil
215}
216func (a *ACL) Bytes() ([]byte, error) {
217 b, err := json.MarshalIndent(a, "", " ")
218 if err != nil {
219 return nil, err
220 }
221 return append(b, '\n'), nil
222}
223
224// RoleFor resolves a principal's effective role.
225//
226// A repository grant or the repository default is the base. An instance-wide
227// role raises that base, but only on a public repository: a private repository
228// is not made readable by an instance grant, because "contribute on everything"
229// should not mean "reader of every draft". The exception is the instance Admin
230// role: an instance administrator may govern every repository, private ones
231// included, so they are Admin everywhere.
232func (a *ACL) RoleFor(user string) Role {
233 role := a.RepositoryRoleFor(user)
234 if user == "" || a.instanceRole == nil {
235 return role
236 }
237 instance := a.instanceRole(user)
238 if instance.AtLeast(Admin) {
239 return Admin
240 }
241 if a.Public() && instance > role {
242 return instance
243 }
244 return role
245}
246
247// RepositoryRoleFor is RoleFor without the instance-wide overlay. Repository
248// settings use it so an inherited instance role is never mistaken for a
249// repository-specific entry.
250func (a *ACL) RepositoryRoleFor(user string) Role {
251 if user != "" {
252 if r, ok := a.Entries[user]; ok {
253 if role, err := ParseRole(r); err == nil {
254 return role
255 }
256 }
257 }
258 def, _ := ParseRole(a.Default)
259 return def
260}
261
262// Public reports whether anonymous readers may read the repository. Instance
263// roles only apply where this is true.
264func (a *ACL) Public() bool {
265 def, _ := ParseRole(a.Default)
266 return def.AtLeast(Read)
267}
268
269func (a *ACL) Can(user string, need Role) bool { return a.RoleFor(user).AtLeast(need) }
270
271// EffectiveRole keeps the old call shape. The bool is retained for callers that
272// already know the account is an instance administrator; the role itself is
273// already part of RoleFor when the access store is wired to the roster. The
274// explicit check makes the rule true even for a bare ACL in a test.
275func (a *ACL) EffectiveRole(user string, instanceAdmin bool) Role {
276 if user != "" && instanceAdmin {
277 return Admin
278 }
279 return a.RoleFor(user)
280}
281
282// ContributorBranchPrefix is the only branch namespace a contributor may push.
283// The account name is in the path: it is chosen by the person, fixed at creation
284// and never reissued, so one contributor cannot move another's branch and a
285// maintainer can see whose work a ref belongs to without a join. No rename can
286// strand a branch, because there is nothing to rename.
287func ContributorBranchPrefix(name string) string {
288 return "refs/heads/contrib/" + name + "/"
289}
290
291// IsContributorRef reports whether ref is a non-empty branch inside name's
292// namespace. name is the account name.
293func IsContributorRef(name, ref string) bool {
294 if name == "" {
295 return false
296 }
297 prefix := ContributorBranchPrefix(name)
298 return strings.HasPrefix(ref, prefix) && len(ref) > len(prefix)
299}
300
301// ContributorRefName returns the account name embedded in a contributor ref.
302// It is the reverse of ContributorBranchPrefix, used by the CI gate before it
303// has a principal in hand. The name is a path component, so it is returned as
304// written; resolving it to an account is the roster's job.
305func ContributorRefName(ref string) (name string, ok bool) {
306 const prefix = "refs/heads/contrib/"
307 if !strings.HasPrefix(ref, prefix) {
308 return "", false
309 }
310 rest := strings.TrimPrefix(ref, prefix)
311 i := strings.IndexByte(rest, '/')
312 if i <= 0 || i == len(rest)-1 {
313 return "", false
314 }
315 return rest[:i], true
316}
317
318// CandidateRoot is the server-only namespace a combined result is published under.
319// It lives here rather than in forge/landing so the runner can read the trust of a
320// ref without importing the landing rules.
321const CandidateRoot = "refs/vole/candidates/"
322
323// TrustNamespace names the cache namespace a run's ref belongs to. A contributor
324// branch and a combined result derived from one are both untrusted code, and a
325// cache is shared mutable state: they may not seed the cache a protected branch
326// builds into. Everything else is direct.
327func TrustNamespace(ref string) string {
328 if name, ok := ContributorRefName(ref); ok {
329 return "contrib-" + name
330 }
331 if rest, ok := strings.CutPrefix(ref, CandidateRoot+"contrib/"); ok {
332 if i := strings.IndexByte(rest, '/'); i > 0 {
333 return "contrib-" + rest[:i]
334 }
335 }
336 return "direct"
337}
338
339// CanDiscuss covers filing issues and commenting. The Contribute role may do
340// this without being able to move a shared branch.
341func (a *ACL) CanDiscuss(user string) bool { return a.RoleFor(user).AtLeast(Contribute) }
342
343// CanOpenRequest covers opening a change request. The head must still be a
344// branch the caller may push: the web checks the contributor namespace.
345func (a *ACL) CanOpenRequest(user string) bool { return a.RoleFor(user).AtLeast(Contribute) }
346
347// CanPush says the account may push somewhere. Which refs the Contribute role
348// may write is a namespace question and needs the account *name*, not the id the ACL
349// is keyed by, so that half of the rule lives where the push is gate-kept: the
350// pre-receive hook, which has the name the SSH key resolved to.
351func (a *ACL) CanPush(user string) bool { return a.RoleFor(user).AtLeast(Contribute) }
352
353// CanTriage covers changing somebody else's issue: labels, assignees, state.
354func (a *ACL) CanTriage(user string) bool { return a.RoleFor(user).AtLeast(Maintain) }
355
356// CanApprove covers review approvals. Self-approval is a separate check against
357// the request's author id.
358func (a *ACL) CanApprove(user string) bool { return a.RoleFor(user).AtLeast(Maintain) }
359
360// ValidateProtectedPattern checks a pattern where an operator writes it, because
361// a pattern IsProtected can never match is worse than none at all: the repository
362// reads as protected while the branch is open. Only the two shapes IsProtected
363// understands are accepted, an exact ref and a ref prefix ending in one "*", and
364// the ref part must be a name Git could actually store. A prefix for a namespace
365// with nothing in it yet is allowed: "refs/heads/release/*" is a legitimate rule
366// for the first release branch.
367//
368// The rule lives here, not in the CLI, so the browser form and any other writer
369// cannot store what the CLI refuses.
370func ValidateProtectedPattern(pattern string) error {
371 if pattern == "" {
372 return errors.New(`a protected pattern cannot be empty: name a ref, as in "refs/heads/main"`)
373 }
374 if pattern != strings.TrimSpace(pattern) || strings.ContainsFunc(pattern, unicode.IsControl) {
375 return fmt.Errorf("protected pattern %q cannot match any ref: it carries whitespace or control characters", pattern)
376 }
377 if !strings.HasPrefix(pattern, "refs/") {
378 if fixed, ok := likelyRef(pattern); ok {
379 return fmt.Errorf("protected pattern %q cannot match any ref: did you mean %q?", pattern, fixed)
380 }
381 return fmt.Errorf(`protected pattern %q cannot match any ref: it must start with "refs/", like "refs/heads/main"`, pattern)
382 }
383 if fixed, ok := likelyRef(pattern); ok {
384 return fmt.Errorf("protected pattern %q cannot match any ref: did you mean %q?", pattern, fixed)
385 }
386 prefix := strings.HasSuffix(pattern, "*")
387 if n := strings.Count(pattern, "*"); n > 1 || (n == 1 && !prefix) {
388 return fmt.Errorf(`protected pattern %q cannot match any ref: the only wildcard understood is one trailing "*", as in "refs/heads/release/*"`, pattern)
389 }
390 base := strings.TrimSuffix(pattern, "*")
391 // "refs/*" is the one base with no name left after the wildcard; it matches
392 // every ref by design and there is nothing further to check.
393 if prefix && base == "refs/" {
394 return nil
395 }
396 // A prefix names the namespace refs live in, so its trailing slash is the
397 // boundary the wildcard stands in for, and the name before it may end in a
398 // dot or ".lock" the way a real ref may not.
399 name := base
400 if prefix {
401 name = strings.TrimSuffix(base, "/")
402 }
403 if reason := refNameProblem(name, !prefix); reason != "" {
404 if !prefix && strings.HasSuffix(base, "/") {
405 return fmt.Errorf("protected pattern %q cannot match any ref: %s; use %q to protect everything beneath it", pattern, reason, base+"*")
406 }
407 return fmt.Errorf("protected pattern %q cannot match any ref: %s", pattern, reason)
408 }
409 // A ref is at least a category and a name (refs/heads/main), so a base that
410 // stops after one slash names a category the repository does not store.
411 if !prefix && strings.Count(base, "/") < 2 {
412 return fmt.Errorf("protected pattern %q names a ref category, not a ref: use %q to protect everything in it", pattern, base+"/*")
413 }
414 return nil
415}
416
417// ValidateProtectedPatterns is ValidateProtectedPattern for a whole list, so a
418// surface that accepts several patterns rejects the first bad one before it
419// writes any of them.
420func ValidateProtectedPatterns(patterns []string) error {
421 for _, p := range patterns {
422 if err := ValidateProtectedPattern(p); err != nil {
423 return err
424 }
425 }
426 return nil
427}
428
429// likelyRef turns the singular category names Git does not use into the ref the
430// operator probably meant. A one-letter slip is the whole difference between a
431// protected branch and a rule that never fires.
432func likelyRef(pattern string) (string, bool) {
433 for _, fix := range []struct{ wrong, right string }{
434 {"refs/head/", "refs/heads/"},
435 {"refs/tag/", "refs/tags/"},
436 {"heads/", "refs/heads/"},
437 {"tags/", "refs/tags/"},
438 } {
439 if rest, ok := strings.CutPrefix(pattern, fix.wrong); ok {
440 return fix.right + rest, true
441 }
442 }
443 return "", false
444}
445
446// refNameProblem reports why a string cannot be a ref name, using git's own
447// ref-format rules, or "" when it can. exact says the whole string is the ref;
448// without it the string is a prefix a wildcard stands after, so its last element
449// is incomplete and the rules about how a name ends do not apply.
450func refNameProblem(name string, exact bool) string {
451 switch {
452 case strings.Contains(name, "//"):
453 return "a ref never contains an empty path component"
454 case strings.Contains(name, ".."):
455 return `a ref never contains ".."`
456 case strings.Contains(name, "@{"):
457 return `a ref never contains "@{"`
458 case strings.ContainsAny(name, " ~^:?*[\\"):
459 return `a ref never contains a space or one of ~ ^ : ? * [ \`
460 case name == "refs":
461 return `a ref has a category and a name, not "refs" alone`
462 case strings.HasSuffix(name, "/"):
463 return "a ref never ends in a slash"
464 }
465 if exact {
466 switch {
467 case strings.HasSuffix(name, "."):
468 return "a ref never ends in a dot"
469 case strings.HasSuffix(name, ".lock"):
470 return `a ref never ends in ".lock"`
471 }
472 }
473 return ""
474}
475
476// IsProtected reports whether ref matches a protected pattern. Patterns are an
477// exact ref or a trailing "*" prefix such as "refs/heads/release/*". Every
478// pattern stored through ValidateProtectedPattern is one of the two.
479func (a *ACL) IsProtected(ref string) bool {
480 for _, p := range a.Protected {
481 if strings.HasSuffix(p, "*") {
482 if strings.HasPrefix(ref, strings.TrimSuffix(p, "*")) {
483 return true
484 }
485 continue
486 }
487 if p == ref {
488 return true
489 }
490 }
491 return false
492}
493
494// SetEntry updates a principal's role. An empty role deletes the entry.
495func (a *ACL) SetEntry(user, role string) error {
496 r, err := ParseRole(role)
497 if err != nil {
498 return err
499 }
500 if r == None {
501 delete(a.Entries, user)
502 return nil
503 }
504 a.Entries[user] = r.String()
505 return nil
506}
507func (a *ACL) Principals() []string {
508 out := make([]string, 0, len(a.Entries))
509 for k := range a.Entries {
510 out = append(out, k)
511 }
512 sort.Strings(out)
513 return out
514}
515