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
@@ -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 {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
Reference in new issue
Block a user