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.
Merge change request 01M70WES5ZZDJ6RP226WB11BBZ: Let people rename and remove their own passkeys
keenan · 1h ago
4b8c9e1d
| 1 | // The role model below is used by both |
| 2 | // the pre-receive hook and the web handlers so the two can never disagree. |
| 3 | package access |
| 4 | |
| 5 | import ( |
| 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. |
| 18 | type Role = role.Role |
| 19 | |
| 20 | const ( |
| 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. |
| 29 | func 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. |
| 34 | const 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. |
| 39 | const 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. |
| 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"` |
| 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. |
| 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) |
| 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. |
| 127 | func (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. |
| 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 |
| 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 |
| 165 | } |
| 166 | |
| 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 | |
| 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 |
| 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 | } |
| 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 | } |
| 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 | |
| 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. |
| 232 | func (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. |
| 250 | func (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. |
| 264 | func (a *ACL) Public() bool { |
| 265 | def, _ := ParseRole(a.Default) |
| 266 | return def.AtLeast(Read) |
| 267 | } |
| 268 | |
| 269 | func (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. |
| 275 | func (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. |
| 287 | func 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. |
| 293 | func 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. |
| 305 | func 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. |
| 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 | |
| 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) } |
| 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. |
| 345 | func (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. |
| 351 | func (a *ACL) CanPush(user string) bool { return a.RoleFor(user).AtLeast(Contribute) } |
| 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 | |
| 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 | |
| 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. |
| 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 | } |
| 515 |