feat: /v1/role list, get, create, update and delete. Reassign members on delete. Write-time validation (ValidateGrants, duplicates, typed keys against real content types). Name uniqueness (role_name_taken), role_locked, role_has_members, role_conflict on version mismatch. No-escalation: nobody grants a permission they don't hold, and roles.edit_owner_only guards the Owner role. Activity events for role changes. Typed-key exposure in /auth/me. Cleanup of typed rows when a content type is deleted. | Backend only. Will carry the deferred ListGrants decision.
This commit is contained in:
1 parent
a043f7f41a
commit
094df63a7f
51 files changed
+4822
-115
No files matched your search
@@ -1,5 +1,10 @@
|
||||
package role
|
||||
|
||||
import (
|
||||
"slices"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type MissingPermissionError struct {
|
||||
Key string
|
||||
}
|
||||
@@ -33,14 +38,13 @@ func (r Role) GrantSet() GrantSet {
|
||||
}
|
||||
|
||||
func effectiveScope(kind Kind, scope Scope) Scope {
|
||||
switch {
|
||||
case scope == ScopeAll:
|
||||
if scope == ScopeAll {
|
||||
return ScopeAll
|
||||
case scope == ScopeOwn && kind == KindScoped:
|
||||
return ScopeOwn
|
||||
default:
|
||||
return ScopeNone
|
||||
}
|
||||
if scope == ScopeOwn && kind == KindScoped {
|
||||
return ScopeOwn
|
||||
}
|
||||
return ScopeNone
|
||||
}
|
||||
|
||||
func scopeRank(scope Scope) int {
|
||||
@@ -112,3 +116,66 @@ func (g GrantSet) Holds(key string) bool {
|
||||
func (g GrantSet) HoldsAll(key string) bool {
|
||||
return g.ScopeFor(key, "") == ScopeAll
|
||||
}
|
||||
|
||||
type EscalationError struct {
|
||||
Key string
|
||||
}
|
||||
|
||||
func (e EscalationError) Error() string {
|
||||
return ErrEscalation.Error() + ": " + e.Key
|
||||
}
|
||||
|
||||
func (e EscalationError) Is(target error) bool {
|
||||
return target == ErrEscalation
|
||||
}
|
||||
|
||||
func (g GrantSet) Covers(other GrantSet, typeKeys []string) error {
|
||||
sortedTypeKeys := slices.Sorted(slices.Values(typeKeys))
|
||||
for _, permission := range catalogue {
|
||||
if scopeRank(other.ScopeFor(permission.Key, "")) > scopeRank(g.ScopeFor(permission.Key, "")) {
|
||||
return EscalationError{Key: permission.Key}
|
||||
}
|
||||
if !permission.PerType() {
|
||||
continue
|
||||
}
|
||||
for _, typeKey := range sortedTypeKeys {
|
||||
if scopeRank(other.ScopeFor(permission.Key, typeKey)) > scopeRank(g.ScopeFor(permission.Key, typeKey)) {
|
||||
return EscalationError{Key: permission.Key + typeSeparator + typeKey}
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (g GrantSet) Effective() []Grant {
|
||||
effective := []Grant{}
|
||||
for _, permission := range catalogue {
|
||||
if scope := g.wildcard(permission.Key); scope != ScopeNone {
|
||||
effective = append(effective, Grant{Key: permission.Key, Scope: scope})
|
||||
}
|
||||
if permission.PerType() {
|
||||
effective = append(effective, g.typedGrants(permission.Key)...)
|
||||
}
|
||||
}
|
||||
return effective
|
||||
}
|
||||
|
||||
func (g GrantSet) typedGrants(key string) []Grant {
|
||||
prefix := key + typeSeparator
|
||||
composites := []string{}
|
||||
for composite := range g.scopes {
|
||||
if strings.HasPrefix(composite, prefix) {
|
||||
composites = append(composites, composite)
|
||||
}
|
||||
}
|
||||
slices.Sort(composites)
|
||||
typed := make([]Grant, 0, len(composites))
|
||||
for _, composite := range composites {
|
||||
typed = append(typed, Grant{Key: composite, Scope: g.scopes[composite]})
|
||||
}
|
||||
return typed
|
||||
}
|
||||
|
||||
func (r Role) EffectiveGrants() []Grant {
|
||||
return r.GrantSet().Effective()
|
||||
}
|
||||
Reference in new issue
Block a user