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:
Florian Sylvain committed 2026-10-27 00:45:24 +01:00
1 parent a043f7f41a
commit 094df63a7f
51 files changed
+4822 -115

No files matched your search

+73 -6
View File
@@ -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()
}