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.

Blame · forge/access/acl.go

Line attribution on main · 514 line(s)

c5902451
Keenan Nemetz
16d ago
1 // The role model below is used by both
653e0cfc
Keenan Nemetz
18d ago
2 // the pre-receive hook and the web handlers so the two can never disagree.
c5902451
Keenan Nemetz
16d ago
3 package access
653e0cfc
Keenan Nemetz
18d ago
4
5 import (
6 "encoding/json"
93c7cae8
Keenan Nemetz
19h ago
7 "errors"
653e0cfc
Keenan Nemetz
18d ago
8 "fmt"
9 "sort"
10 "strings"
93c7cae8
Keenan Nemetz
19h ago
11 "unicode"
727df386
Keenan Nemetz
8d ago
12
d37d301f
Keenan Nemetz
17h ago
13 "rodent.software/vole/core/role"
653e0cfc
Keenan Nemetz
18d ago
14 )
15
727df386
Keenan Nemetz
8d ago
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.
18 type Role = role.Role
653e0cfc
Keenan Nemetz
18d ago
19
20 const (
727df386
Keenan Nemetz
8d ago
21 None = role.None
22 Read = role.Read
23 Contribute = role.Contribute
24 Maintain = role.Maintain
25 Admin = role.Admin
653e0cfc
Keenan Nemetz
18d ago
26 )
27
727df386
Keenan Nemetz
8d ago
28 // ParseRole keeps the historical call shape.
29 func ParseRole(s string) (Role, error) { return role.Parse(s) }
653e0cfc
Keenan Nemetz
18d ago
30
6c84d387
Keenan Nemetz
15d ago
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.
34 const Write = Maintain
35
5b814e99
Keenan Nemetz
14d ago
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.
39 const Contributor = Contribute
40
ec5c519c
Keenan Nemetz
16d ago
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
62576fa8
Keenan Nemetz
14d ago
43 // instance owns, and they are keyed by account name, so a copy in a clone would mean
ec5c519c
Keenan Nemetz
16d ago
44 // nothing without the roster that gives those ids names.
653e0cfc
Keenan Nemetz
18d ago
45 type 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"`
1eeca4e6
Keenan Nemetz
17d ago
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"`
35eaeccf
Keenan Nemetz
17d ago
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"`
0789f22e
Keenan Nemetz
9d ago
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"`
852c067d
Keenan Nemetz
17d ago
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
6c84d387
Keenan Nemetz
15d ago
72 // here would be theatre. Everyone else — a maintainer who runs CI on their laptop
852c067d
Keenan Nemetz
17d ago
73 // included — gets their result recorded but not counted.
74 Runners []string `json:"runners,omitempty"`
5b814e99
Keenan Nemetz
14d ago
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
62b59a76
Keenan Nemetz
12d ago
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.
98 func (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.
110 func (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)
852c067d
Keenan Nemetz
17d ago
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
62b59a76
Keenan Nemetz
12d ago
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.
852c067d
Keenan Nemetz
17d ago
127 func (a *ACL) CanReport(accountID string) bool {
62b59a76
Keenan Nemetz
12d ago
128 if accountID == "" || !a.Active(accountID) {
852c067d
Keenan Nemetz
17d ago
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.
144 func (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
35eaeccf
Keenan Nemetz
17d ago
157 }
158
159 // RequiresChecks resolves the nil-means-true rule.
160 func (a *ACL) RequiresChecks() bool {
161 if a.ChecksRequired == nil {
162 return true
163 }
164 return *a.ChecksRequired
1eeca4e6
Keenan Nemetz
17d ago
165 }
166
0789f22e
Keenan Nemetz
9d ago
167 // RequiresLinkedIssue reports whether new change requests must name at least
168 // one issue they close.
169 func (a *ACL) RequiresLinkedIssue() bool {
170 return a != nil && a.RequireLinkedIssue
171 }
172
1eeca4e6
Keenan Nemetz
17d ago
173 // RequiredApprovals resolves the nil-means-default rule.
174 func (a *ACL) RequiredApprovals() int {
175 if a.ApprovalsRequired == nil {
176 return 1
177 }
178 return *a.ApprovalsRequired
653e0cfc
Keenan Nemetz
18d ago
179 }
180 func 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 }
187 func 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 }
6c84d387
Keenan Nemetz
15d ago
198 def, err := ParseRole(a.Default)
199 if err != nil {
653e0cfc
Keenan Nemetz
18d ago
200 return nil, err
201 }
6c84d387
Keenan Nemetz
15d ago
202 a.Default = def.String()
1eeca4e6
Keenan Nemetz
17d ago
203 if a.ApprovalsRequired != nil && *a.ApprovalsRequired < 0 {
204 return nil, fmt.Errorf("approvals_required cannot be negative")
205 }
653e0cfc
Keenan Nemetz
18d ago
206 for k, v := range a.Entries {
6c84d387
Keenan Nemetz
15d ago
207 role, err := ParseRole(v)
208 if err != nil {
653e0cfc
Keenan Nemetz
18d ago
209 return nil, fmt.Errorf("acl entry %q: %w", k, err)
210 }
6c84d387
Keenan Nemetz
15d ago
211 a.Entries[k] = role.String()
653e0cfc
Keenan Nemetz
18d ago
212 }
852c067d
Keenan Nemetz
17d ago
213 a.SetRunners(a.Runners)
653e0cfc
Keenan Nemetz
18d ago
214 return a, nil
215 }
216 func (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
5b814e99
Keenan Nemetz
14d ago
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.
653e0cfc
Keenan Nemetz
18d ago
232 func (a *ACL) RoleFor(user string) Role {
5b814e99
Keenan Nemetz
14d ago
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.
250 func (a *ACL) RepositoryRoleFor(user string) Role {
653e0cfc
Keenan Nemetz
18d ago
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 }
5b814e99
Keenan Nemetz
14d ago
261
262 // Public reports whether anonymous readers may read the repository. Instance
263 // roles only apply where this is true.
264 func (a *ACL) Public() bool {
265 def, _ := ParseRole(a.Default)
266 return def.AtLeast(Read)
267 }
268
653e0cfc
Keenan Nemetz
18d ago
269 func (a *ACL) Can(user string, need Role) bool { return a.RoleFor(user).AtLeast(need) }
270
5b814e99
Keenan Nemetz
14d ago
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.
381d6aac
Keenan Nemetz
14d ago
275 func (a *ACL) EffectiveRole(user string, instanceAdmin bool) Role {
5b814e99
Keenan Nemetz
14d ago
276 if user != "" && instanceAdmin {
277 return Admin
381d6aac
Keenan Nemetz
14d ago
278 }
5b814e99
Keenan Nemetz
14d ago
279 return a.RoleFor(user)
381d6aac
Keenan Nemetz
14d ago
280 }
281
6c84d387
Keenan Nemetz
15d ago
282 // ContributorBranchPrefix is the only branch namespace a contributor may push.
474a812d
Keenan Nemetz
15d ago
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.
287 func ContributorBranchPrefix(name string) string {
288 return "refs/heads/contrib/" + name + "/"
6c84d387
Keenan Nemetz
15d ago
289 }
290
474a812d
Keenan Nemetz
15d ago
291 // IsContributorRef reports whether ref is a non-empty branch inside name's
62576fa8
Keenan Nemetz
14d ago
292 // namespace. name is the account name.
474a812d
Keenan Nemetz
15d ago
293 func IsContributorRef(name, ref string) bool {
294 if name == "" {
6c84d387
Keenan Nemetz
15d ago
295 return false
296 }
474a812d
Keenan Nemetz
15d ago
297 prefix := ContributorBranchPrefix(name)
6c84d387
Keenan Nemetz
15d ago
298 return strings.HasPrefix(ref, prefix) && len(ref) > len(prefix)
299 }
300
474a812d
Keenan Nemetz
15d ago
301 // ContributorRefName returns the account name embedded in a contributor ref.
6c84d387
Keenan Nemetz
15d ago
302 // It is the reverse of ContributorBranchPrefix, used by the CI gate before it
474a812d
Keenan Nemetz
15d ago
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.
305 func ContributorRefName(ref string) (name string, ok bool) {
6c84d387
Keenan Nemetz
15d ago
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
e5478085
Keenan Nemetz
11d ago
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.
321 const 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.
327 func 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
5b814e99
Keenan Nemetz
14d ago
339 // CanDiscuss covers filing issues and commenting. The Contribute role may do
340 // this without being able to move a shared branch.
341 func (a *ACL) CanDiscuss(user string) bool { return a.RoleFor(user).AtLeast(Contribute) }
6c84d387
Keenan Nemetz
15d ago
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.
5b814e99
Keenan Nemetz
14d ago
345 func (a *ACL) CanOpenRequest(user string) bool { return a.RoleFor(user).AtLeast(Contribute) }
6c84d387
Keenan Nemetz
15d ago
346
5b814e99
Keenan Nemetz
14d ago
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
474a812d
Keenan Nemetz
15d ago
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.
5b814e99
Keenan Nemetz
14d ago
351 func (a *ACL) CanPush(user string) bool { return a.RoleFor(user).AtLeast(Contribute) }
6c84d387
Keenan Nemetz
15d ago
352
353 // CanTriage covers changing somebody else's issue: labels, assignees, state.
354 func (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.
358 func (a *ACL) CanApprove(user string) bool { return a.RoleFor(user).AtLeast(Maintain) }
359
93c7cae8
Keenan Nemetz
19h ago
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.
370 func 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.
420 func 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.
432 func 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.
450 func 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
653e0cfc
Keenan Nemetz
18d ago
476 // IsProtected reports whether ref matches a protected pattern. Patterns are an
93c7cae8
Keenan Nemetz
19h ago
477 // exact ref or a trailing "*" prefix such as "refs/heads/release/*". Every
478 // pattern stored through ValidateProtectedPattern is one of the two.
653e0cfc
Keenan Nemetz
18d ago
479 func (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.
495 func (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 }
507 func (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 }