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

+12 -1
View File
@@ -34,6 +34,7 @@ const (
CategorySystem Category = "system"
CategorySites Category = "sites"
CategoryAuthors Category = "authors"
CategoryRoles Category = "roles"
)
type Outcome string
@@ -80,6 +81,10 @@ const (
ActionAuthorCreate = "author.create"
ActionAuthorEdit = "author.edit"
ActionAuthorDelete = "author.delete"
ActionRoleCreate = "role.create"
ActionRoleEdit = "role.edit"
ActionRoleDelete = "role.delete"
ActionRoleReassign = "role.reassign"
ActionMediaUpload = "media.upload"
ActionMediaRename = "media.rename"
ActionMediaDelete = "media.delete"
@@ -118,6 +123,7 @@ const (
TargetRelease = "release"
TargetSite = "site"
TargetAuthor = "author"
TargetRole = "role"
)
const (
@@ -147,6 +153,10 @@ const (
ParamFormat = "format"
ParamQuality = "quality"
ParamTag = "tag"
ParamAdded = "added"
ParamChanged = "changed"
ParamMembers = "members"
ParamMoved = "moved"
FieldTitle = "title"
FieldOnline = "online"
FieldBody = "body"
@@ -158,6 +168,7 @@ const (
FieldFormat = "format"
FieldQuality = "quality"
FieldName = "name"
FieldDescription = "description"
FieldBaseURL = "base_url"
FieldDefaultRobots = "default_robots"
FieldLocale = "locale"
@@ -193,7 +204,7 @@ func ActorTypes() []ActorType {
}
func Categories() []Category {
return []Category{CategoryArticles, CategoryMedia, CategoryUsers, CategoryAPIKeys, CategoryAuth, CategorySystem, CategorySites, CategoryAuthors}
return []Category{CategoryArticles, CategoryMedia, CategoryUsers, CategoryAPIKeys, CategoryAuth, CategorySystem, CategorySites, CategoryAuthors, CategoryRoles}
}
func Outcomes() []Outcome {
+22 -7
View File
@@ -3,6 +3,7 @@ package role
import (
"errors"
"strings"
"time"
)
var (
@@ -14,6 +15,18 @@ var (
ErrDuplicateGrant = errors.New("permission granted more than once")
ErrMissingPermission = errors.New("missing permission")
ErrRoleHasMembers = errors.New("role still has members")
ErrRoleNameTaken = errors.New("role name already used")
ErrRoleLocked = errors.New("role is locked")
ErrBuiltInRole = errors.New("role is built in")
ErrBuiltInName = errors.New("built-in role cannot be renamed")
ErrOwnRole = errors.New("cannot change the role you hold")
ErrInvalidRoleName = errors.New("invalid role name")
ErrUnknownContentType = errors.New("unknown content type")
ErrNotBuiltIn = errors.New("role is not built in")
ErrInvalidReassign = errors.New("invalid reassign target")
ErrEscalation = errors.New("grants more than the caller holds")
)
type Scope string
@@ -30,13 +43,15 @@ type Grant struct {
}
type Role struct {
ID uint32 `json:"id"`
Name string `json:"name"`
Description string `json:"description"`
BuiltIn bool `json:"built_in"`
Locked bool `json:"locked"`
Version uint32 `json:"version"`
Grants []Grant `json:"grants"`
ID uint32 `json:"id"`
Name string `json:"name"`
Description string `json:"description"`
BuiltIn bool `json:"built_in"`
Locked bool `json:"locked"`
Version uint32 `json:"version"`
Grants []Grant `json:"grants"`
UpdatedAt time.Time `json:"-"`
UpdatedBy *uint32 `json:"-"`
}
func resolveGrantKey(key string) (Permission, error) {
+45
View File
@@ -0,0 +1,45 @@
package role
type GrantChange struct {
Key string `json:"key"`
From Scope `json:"from"`
To Scope `json:"to"`
}
type GrantDiff struct {
Added []Grant `json:"added"`
Removed []Grant `json:"removed"`
Changed []GrantChange `json:"changed"`
}
func (d GrantDiff) Empty() bool {
return len(d.Added) == 0 && len(d.Removed) == 0 && len(d.Changed) == 0
}
func DiffGrants(before []Grant, after []Grant) GrantDiff {
diff := GrantDiff{Added: []Grant{}, Removed: []Grant{}, Changed: []GrantChange{}}
previous := scopesByKey(before)
current := scopesByKey(after)
for _, grant := range after {
held, present := previous[grant.Key]
if !present {
diff.Added = append(diff.Added, grant)
} else if held != grant.Scope {
diff.Changed = append(diff.Changed, GrantChange{Key: grant.Key, From: held, To: grant.Scope})
}
}
for _, grant := range before {
if _, present := current[grant.Key]; !present {
diff.Removed = append(diff.Removed, grant)
}
}
return diff
}
func scopesByKey(grants []Grant) map[string]Scope {
scopes := make(map[string]Scope, len(grants))
for _, grant := range grants {
scopes[grant.Key] = grant.Scope
}
return scopes
}
+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()
}
@@ -4,7 +4,9 @@ type Repository interface {
List() ([]Role, error)
Get(id uint32) (Role, error)
GetByName(name string) (Role, error)
ListGrants(roleID uint32) ([]Grant, error)
Create(created Role) (Role, error)
Save(saved Role) (Role, error)
Delete(id uint32) error
ReassignMembers(from uint32, to uint32) (int, error)
RemoveTypedGrants(typeKey string) (int, error)
}
+44
View File
@@ -0,0 +1,44 @@
package role
import (
"slices"
"strings"
)
func NormalizeName(name string) (string, error) {
trimmed := strings.TrimSpace(name)
if trimmed == "" {
return "", ErrInvalidRoleName
}
return trimmed, nil
}
func SameName(left string, right string) bool {
return strings.EqualFold(strings.TrimSpace(left), strings.TrimSpace(right))
}
func ValidateGrantTypes(grants []Grant, allowedTypeKeys []string) error {
for _, grant := range grants {
if !isTypedKey(grant.Key) {
continue
}
_, typeKey, err := ParseTypedKey(grant.Key)
if err != nil {
return err
}
if !slices.Contains(allowedTypeKeys, typeKey) {
return ErrUnknownContentType
}
}
return nil
}
func TypedKeys(typeKey string) []string {
keys := []string{}
for _, permission := range catalogue {
if permission.PerType() {
keys = append(keys, permission.Key+typeSeparator+typeKey)
}
}
return keys
}