feat: roles system backend

This commit is contained in:
Florian Sylvain committed 2026-10-26 05:02:49 +01:00
1 parent 44cca46d5e
commit 8c6c6456da
50 files changed
+2578 -22

No files matched your search

+1
View File
@@ -118,6 +118,7 @@ type Article struct {
Body string `json:"body"`
IsOnline bool `json:"is_online"`
SiteID uint32 `json:"site_id"`
CreatedBy uint32 `json:"-"`
Authors []author.Byline `json:"authors"`
Slug string `json:"slug"`
Excerpt string `json:"excerpt"`
+48
View File
@@ -0,0 +1,48 @@
package role
const (
OwnerName = "Owner"
AdminName = "Admin"
WriterName = "Writer"
)
func allGrants(include func(Permission) bool) []Grant {
grants := []Grant{}
for _, permission := range catalogue {
if include(permission) {
grants = append(grants, Grant{Key: permission.Key, Scope: ScopeAll})
}
}
return grants
}
func writerGrants() []Grant {
return []Grant{
{Key: "content.read", Scope: ScopeAll},
{Key: "content.create", Scope: ScopeAll},
{Key: "content.update", Scope: ScopeAll},
{Key: "schema.view", Scope: ScopeAll},
{Key: "media.view", Scope: ScopeAll},
{Key: "media.upload", Scope: ScopeAll},
{Key: "authors.view", Scope: ScopeAll},
{Key: "authors.edit", Scope: ScopeOwn},
{Key: "activity.view", Scope: ScopeOwn},
}
}
func BuiltInRoles() []Role {
return []Role{
{
Name: OwnerName, BuiltIn: true, Locked: true, Version: 1,
Grants: allGrants(func(Permission) bool { return true }),
},
{
Name: AdminName, BuiltIn: true, Version: 1,
Grants: allGrants(func(p Permission) bool { return !p.OwnerOnly }),
},
{
Name: WriterName, BuiltIn: true, Version: 1,
Grants: writerGrants(),
},
}
}
+118
View File
@@ -0,0 +1,118 @@
package role
import "strings"
type Kind string
const (
KindScoped Kind = "scoped"
KindToggle Kind = "toggle"
)
const contentGroup = "content"
type Permission struct {
Key string
Group string
Kind Kind
OwnerOnly bool
}
func (p Permission) PerType() bool {
return strings.HasPrefix(p.Key, contentGroup+".")
}
func scoped(group, key string) Permission {
return Permission{Key: key, Group: group, Kind: KindScoped}
}
func toggle(group, key string) Permission {
return Permission{Key: key, Group: group, Kind: KindToggle}
}
func ownerOnly(group, key string) Permission {
return Permission{Key: key, Group: group, Kind: KindToggle, OwnerOnly: true}
}
var catalogue = []Permission{
scoped("content", "content.read"),
toggle("content", "content.create"),
scoped("content", "content.update"),
scoped("content", "content.delete"),
scoped("content", "content.publish"),
toggle("schema", "schema.view"),
toggle("schema", "schema.edit"),
toggle("schema", "schema.delete"),
toggle("media", "media.view"),
toggle("media", "media.upload"),
scoped("media", "media.delete"),
toggle("authors", "authors.view"),
toggle("authors", "authors.create"),
scoped("authors", "authors.edit"),
toggle("authors", "authors.delete"),
scoped("activity", "activity.view"),
toggle("activity", "activity.export"),
toggle("users", "users.view"),
toggle("users", "users.invite"),
toggle("users", "users.change_role"),
toggle("users", "users.remove"),
toggle("settings", "settings.view"),
toggle("settings", "settings.edit_general"),
toggle("settings", "settings.manage_sites"),
ownerOnly("settings", "settings.delete_site"),
scoped("settings", "apikeys.view"),
toggle("settings", "apikeys.create"),
scoped("settings", "apikeys.revoke"),
toggle("roles", "roles.view"),
toggle("roles", "roles.manage"),
ownerOnly("roles", "roles.edit_owner_only"),
ownerOnly("system", "system.apply_updates"),
ownerOnly("system", "system.edit_retention"),
ownerOnly("system", "system.transfer_ownership"),
}
func Catalogue() []Permission {
return append([]Permission(nil), catalogue...)
}
func Lookup(key string) (Permission, error) {
for _, permission := range catalogue {
if permission.Key == key {
return permission, nil
}
}
return Permission{}, ErrUnknownPermission
}
func Groups() []string {
groups := []string{}
for _, permission := range catalogue {
if len(groups) == 0 || groups[len(groups)-1] != permission.Group {
groups = append(groups, permission.Group)
}
}
return groups
}
const (
typeSeparator = ":"
maxKeyLength = 100
typeKeyAlphabet = "abcdefghijklmnopqrstuvwxyz0123456789_-"
)
func ParseTypedKey(composite string) (string, string, error) {
base, typeKey, found := strings.Cut(composite, typeSeparator)
if !found || len(composite) > maxKeyLength || typeKey == "" {
return "", "", ErrUnknownPermission
}
permission, err := Lookup(base)
if err != nil || !permission.PerType() {
return "", "", ErrUnknownPermission
}
for _, char := range typeKey {
if !strings.ContainsRune(typeKeyAlphabet, char) {
return "", "", ErrUnknownPermission
}
}
return base, typeKey, nil
}
+120
View File
@@ -0,0 +1,120 @@
package role
import (
"errors"
"strings"
)
var (
ErrRoleNotFound = errors.New("role not found")
ErrUnknownPermission = errors.New("unknown permission")
ErrRoleConflict = errors.New("role was modified concurrently")
ErrInvalidScope = errors.New("invalid scope for permission")
ErrOwnerOnlyGrant = errors.New("owner-only permission granted to a non-owner role")
)
type Scope string
const (
ScopeNone Scope = "none"
ScopeOwn Scope = "own"
ScopeAll Scope = "all"
)
type Grant struct {
Key string `json:"key"`
Scope Scope `json:"scope"`
}
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"`
}
func (r Role) scopeOf(key string) Scope {
for _, grant := range r.Grants {
if grant.Key == key {
return grant.Scope
}
}
return ScopeNone
}
func (r Role) Allows(key string, owned bool) bool {
permission, err := Lookup(key)
if err != nil {
return false
}
if permission.OwnerOnly && !r.Locked {
return false
}
return scopeAllows(permission.Kind, r.scopeOf(key), owned)
}
func (r Role) AllowsFor(key string, typeKey string, owned bool) bool {
if typeKey == "" {
return r.Allows(key, owned)
}
composite := key + typeSeparator + typeKey
base, _, err := ParseTypedKey(composite)
if err != nil {
return false
}
permission, _ := Lookup(base)
for _, grant := range r.Grants {
if grant.Key == composite {
return scopeAllows(permission.Kind, grant.Scope, owned)
}
}
return r.Allows(key, owned)
}
func scopeAllows(kind Kind, scope Scope, owned bool) bool {
switch scope {
case ScopeAll:
return true
case ScopeOwn:
return kind == KindScoped && owned
default:
return false
}
}
func resolveGrantKey(key string) (Permission, error) {
if strings.Contains(key, typeSeparator) {
base, _, err := ParseTypedKey(key)
if err != nil {
return Permission{}, err
}
return Lookup(base)
}
return Lookup(key)
}
func ValidateGrants(locked bool, grants []Grant) error {
for _, grant := range grants {
permission, err := resolveGrantKey(grant.Key)
if err != nil {
return err
}
if permission.OwnerOnly && !locked {
return ErrOwnerOnlyGrant
}
if !scopeValidFor(permission.Kind, grant.Scope) {
return ErrInvalidScope
}
}
return nil
}
func scopeValidFor(kind Kind, scope Scope) bool {
if kind == KindToggle {
return scope == ScopeAll
}
return scope == ScopeOwn || scope == ScopeAll
}
@@ -0,0 +1,10 @@
package role
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)
}
+1
View File
@@ -47,6 +47,7 @@ type User struct {
DisplayName string `json:"display_name"`
IsVerified bool `json:"is_verified"`
Role Role `json:"role"`
RoleID uint32 `json:"role_id"`
VerificationCode string `json:"verification_code"`
VerificationExpiration time.Time `json:"verification_expiration"`
PasswordResetExpiration time.Time `json:"password_reset_expiration"`