2026-08-01 22:06:14 +02:00
|
|
|
package service
|
|
|
|
|
|
|
|
|
|
import (
|
|
|
|
|
"context"
|
|
|
|
|
"fmt"
|
|
|
|
|
"log/slog"
|
|
|
|
|
"regexp"
|
|
|
|
|
"strings"
|
|
|
|
|
|
2026-08-28 06:54:32 +02:00
|
|
|
"github.com/J3vb/OwnCord/Server/db"
|
|
|
|
|
"github.com/J3vb/OwnCord/Server/permissions"
|
|
|
|
|
"github.com/J3vb/OwnCord/Server/syncutil"
|
2026-08-01 22:06:14 +02:00
|
|
|
)
|
|
|
|
|
|
|
|
|
|
// RoleService owns create/edit/delete/reorder of roles. It is the only writer
|
|
|
|
|
// of the roles table outside migrations.
|
|
|
|
|
//
|
|
|
|
|
// Every mutation is checked against the ACTOR's role position rather than
|
|
|
|
|
// against a bit alone: MANAGE_ROLES says "may manage roles", the hierarchy says
|
|
|
|
|
// "which ones". Without the position rule any holder of the bit could edit the
|
|
|
|
|
// role above them — or grant themselves ADMINISTRATOR through a role they
|
|
|
|
|
// create — which is the same privilege-escalation hole ChangeUserRole closed
|
|
|
|
|
// for role *assignment*.
|
|
|
|
|
type RoleService struct {
|
|
|
|
|
st Store
|
|
|
|
|
perms *PermissionService
|
2026-08-07 21:20:48 +02:00
|
|
|
// mu serializes the read-check-write mutations: position uniqueness and
|
|
|
|
|
// the role cap are enforced against a ListRoles snapshot, not by a DB
|
|
|
|
|
// constraint, so two interleaved mutations can both see the same free
|
|
|
|
|
// slot. Single-process server — one lock covers every writer.
|
2026-08-16 17:05:06 +02:00
|
|
|
mu syncutil.Mutex
|
2026-08-01 22:06:14 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// NewRoleService creates a RoleService.
|
|
|
|
|
func NewRoleService(st Store, perms *PermissionService) *RoleService {
|
|
|
|
|
return &RoleService{st: st, perms: perms}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const (
|
|
|
|
|
// maxRoleNameLen bounds the name so a role label cannot be used to blow up
|
|
|
|
|
// every member list and permission modal that renders it.
|
|
|
|
|
maxRoleNameLen = 32
|
|
|
|
|
// maxRoles bounds how many roles a server can hold. Reorder normalizes
|
|
|
|
|
// positions into 1..N strictly below the actor, so N must stay well under
|
|
|
|
|
// the owner position (100) for the hierarchy to remain expressible.
|
|
|
|
|
maxRoles = 64
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
// hexColorRe matches the two CSS hex forms the client renders (#rgb, #rrggbb).
|
|
|
|
|
// Anything else — named colors, rgb(), a bare hex without the hash — is
|
|
|
|
|
// rejected rather than normalized: the value is written straight into a style
|
|
|
|
|
// attribute by the desktop client and the admin panel.
|
|
|
|
|
var hexColorRe = regexp.MustCompile(`^#([0-9a-fA-F]{3}|[0-9a-fA-F]{6})$`)
|
|
|
|
|
|
|
|
|
|
// RoleInput carries the mutable fields of a role. Pointer fields are "leave
|
|
|
|
|
// unchanged" on update; on create a nil Position means "just below the actor".
|
|
|
|
|
type RoleInput struct {
|
|
|
|
|
Name *string
|
|
|
|
|
Color *string // points at "" to clear the color
|
|
|
|
|
Permissions *int64
|
|
|
|
|
Position *int
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// RoleWithMembers is a role plus how many users currently hold it. The count
|
|
|
|
|
// is what makes the delete confirmation honest ("12 members move to Member").
|
|
|
|
|
type RoleWithMembers struct {
|
|
|
|
|
db.Role
|
|
|
|
|
MemberCount int `json:"member_count"`
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// actorRole loads the actor's role through the permission cache and verifies
|
|
|
|
|
// MANAGE_ROLES (ADMINISTRATOR bypasses). Every failure is Forbidden: an
|
|
|
|
|
// unresolvable role must never authorize a role mutation.
|
|
|
|
|
func (s *RoleService) actorRole(ctx context.Context, actorID int64) (*db.Role, error) {
|
|
|
|
|
if s.perms == nil {
|
|
|
|
|
return nil, fmt.Errorf("%w: permission service unavailable", ErrForbidden)
|
|
|
|
|
}
|
|
|
|
|
role, err := s.perms.GetRoleForUser(ctx, actorID)
|
|
|
|
|
if err != nil || role == nil {
|
|
|
|
|
return nil, fmt.Errorf("%w: failed to load actor role", ErrForbidden)
|
|
|
|
|
}
|
|
|
|
|
if !permissions.HasServerPerm(role.Permissions, permissions.ManageRoles) {
|
|
|
|
|
return nil, fmt.Errorf("%w: missing %s permission", ErrForbidden, permissions.Name(permissions.ManageRoles))
|
|
|
|
|
}
|
|
|
|
|
return role, nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// requireBelowActor enforces the hierarchy rule shared by edit, delete and
|
|
|
|
|
// reorder: the target role must sit strictly below the actor's own position.
|
|
|
|
|
// Equality is refused too, so a peer cannot rewrite the role they both hold.
|
|
|
|
|
func requireBelowActor(actor *db.Role, target *db.Role) error {
|
|
|
|
|
if target.Position >= actor.Position {
|
|
|
|
|
return fmt.Errorf("%w: cannot manage a role at or above your own rank", ErrForbidden)
|
|
|
|
|
}
|
|
|
|
|
return nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// requireGrantable enforces "you cannot hand out what you do not hold": every
|
|
|
|
|
// bit being ADDED must be present in the actor's own mask. Removing a bit the
|
|
|
|
|
// actor lacks is allowed — that is a de-escalation, and refusing it would make
|
|
|
|
|
// an over-permissioned role impossible to wind down by anyone but an admin.
|
|
|
|
|
// ADMINISTRATOR bypasses, which is what lets the owner grant anything.
|
|
|
|
|
func requireGrantable(actor *db.Role, oldPerms, newPerms int64) error {
|
|
|
|
|
if permissions.HasAdmin(actor.Permissions) {
|
|
|
|
|
return nil
|
|
|
|
|
}
|
|
|
|
|
added := newPerms &^ oldPerms
|
|
|
|
|
if missing := added &^ actor.Permissions; missing != 0 {
|
|
|
|
|
return fmt.Errorf("%w: cannot grant a permission your own role lacks (%s)",
|
|
|
|
|
ErrForbidden, permissions.Name(missing&-missing))
|
|
|
|
|
}
|
|
|
|
|
return nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// validateName trims, bounds and uniqueness-checks a role name. excludeID is
|
|
|
|
|
// the role being renamed (0 on create) so a no-op rename is not a collision.
|
|
|
|
|
func (s *RoleService) validateName(ctx context.Context, raw string, excludeID int64) (string, error) {
|
|
|
|
|
name := strings.TrimSpace(raw)
|
|
|
|
|
if name == "" {
|
|
|
|
|
return "", fmt.Errorf("%w: name is required", ErrBadRequest)
|
|
|
|
|
}
|
|
|
|
|
if len([]rune(name)) > maxRoleNameLen {
|
|
|
|
|
return "", fmt.Errorf("%w: name must be at most %d characters", ErrBadRequest, maxRoleNameLen)
|
|
|
|
|
}
|
|
|
|
|
existing, err := s.st.GetRoleByName(ctx, name)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return "", fmt.Errorf("%w: failed to check role name: %v", ErrInternal, err)
|
|
|
|
|
}
|
|
|
|
|
if existing != nil && existing.ID != excludeID {
|
|
|
|
|
return "", fmt.Errorf("%w: a role named %q already exists", ErrBadRequest, existing.Name)
|
|
|
|
|
}
|
|
|
|
|
return name, nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// validateColor normalizes an optional color. A nil pointer means "unset"; an
|
|
|
|
|
// empty string clears it.
|
|
|
|
|
func validateColor(raw *string) (*string, error) {
|
|
|
|
|
if raw == nil {
|
|
|
|
|
return nil, nil
|
|
|
|
|
}
|
|
|
|
|
c := strings.TrimSpace(*raw)
|
|
|
|
|
if c == "" {
|
|
|
|
|
return nil, nil
|
|
|
|
|
}
|
|
|
|
|
if !hexColorRe.MatchString(c) {
|
|
|
|
|
return nil, fmt.Errorf("%w: color must be a hex value like #5865F2", ErrBadRequest)
|
|
|
|
|
}
|
|
|
|
|
c = strings.ToUpper(c)
|
|
|
|
|
return &c, nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// validatePosition bounds a requested position: non-negative and strictly below
|
|
|
|
|
// the actor's own rank.
|
|
|
|
|
func validatePosition(actor *db.Role, pos int) error {
|
|
|
|
|
if pos < 0 {
|
|
|
|
|
return fmt.Errorf("%w: position must not be negative", ErrBadRequest)
|
|
|
|
|
}
|
|
|
|
|
if pos >= actor.Position {
|
|
|
|
|
return fmt.Errorf("%w: cannot place a role at or above your own rank", ErrForbidden)
|
|
|
|
|
}
|
|
|
|
|
return nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ListRoles returns every role, highest position first, with member counts.
|
|
|
|
|
// Gated on MANAGE_ROLES like the mutations — the panel section that shows it is.
|
|
|
|
|
func (s *RoleService) ListRoles(ctx context.Context, actorID int64) ([]RoleWithMembers, error) {
|
|
|
|
|
if _, err := s.actorRole(ctx, actorID); err != nil {
|
|
|
|
|
return nil, err
|
|
|
|
|
}
|
|
|
|
|
roles, err := s.st.ListRoles(ctx)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, fmt.Errorf("%w: failed to list roles: %v", ErrInternal, err)
|
|
|
|
|
}
|
|
|
|
|
counts, err := s.st.CountRoleMembers(ctx)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, fmt.Errorf("%w: failed to count role members: %v", ErrInternal, err)
|
|
|
|
|
}
|
|
|
|
|
out := make([]RoleWithMembers, 0, len(roles))
|
|
|
|
|
for _, r := range roles {
|
|
|
|
|
out = append(out, RoleWithMembers{Role: *r, MemberCount: counts[r.ID]})
|
|
|
|
|
}
|
|
|
|
|
return out, nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// CreateRole creates a role strictly below the actor's own rank.
|
|
|
|
|
func (s *RoleService) CreateRole(ctx context.Context, actorID int64, in RoleInput) (*db.Role, error) {
|
2026-08-07 21:20:48 +02:00
|
|
|
s.mu.Lock()
|
|
|
|
|
defer s.mu.Unlock()
|
2026-08-01 22:06:14 +02:00
|
|
|
actor, err := s.actorRole(ctx, actorID)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, err
|
|
|
|
|
}
|
|
|
|
|
if in.Name == nil {
|
|
|
|
|
return nil, fmt.Errorf("%w: name is required", ErrBadRequest)
|
|
|
|
|
}
|
|
|
|
|
name, err := s.validateName(ctx, *in.Name, 0)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, err
|
|
|
|
|
}
|
|
|
|
|
color, err := validateColor(in.Color)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, err
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Unknown bits are dropped rather than rejected, matching the channel
|
|
|
|
|
// override handlers — a client sending a wider mask than this build knows
|
|
|
|
|
// about must not be able to persist bits nothing enforces.
|
|
|
|
|
perms := int64(0)
|
|
|
|
|
if in.Permissions != nil {
|
|
|
|
|
perms = *in.Permissions & permissions.AllPerms
|
|
|
|
|
}
|
|
|
|
|
// Every bit is "added" on create, so the grantable check runs against 0.
|
|
|
|
|
if err := requireGrantable(actor, 0, perms); err != nil {
|
|
|
|
|
return nil, err
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
existing, err := s.st.ListRoles(ctx)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, fmt.Errorf("%w: failed to list roles: %v", ErrInternal, err)
|
|
|
|
|
}
|
|
|
|
|
if len(existing) >= maxRoles {
|
|
|
|
|
return nil, fmt.Errorf("%w: server already has the maximum of %d roles", ErrBadRequest, maxRoles)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Positions must stay unique: every hierarchy comparison uses >=/<=, so two
|
|
|
|
|
// roles sharing a position read as equal rank and can never manage each
|
|
|
|
|
// other's members. Track the slots already taken.
|
|
|
|
|
taken := make(map[int]bool, len(existing))
|
|
|
|
|
for _, rl := range existing {
|
|
|
|
|
taken[rl.Position] = true
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Default placement is the highest free slot below the actor — directly
|
|
|
|
|
// below when that is free, which is what a manager creating a deputy role
|
|
|
|
|
// expects, but stepping past any occupied position so a second create does
|
|
|
|
|
// not collide with the first.
|
|
|
|
|
position := actor.Position - 1
|
|
|
|
|
if in.Position != nil {
|
|
|
|
|
position = *in.Position
|
|
|
|
|
// Rank first: an at/above-rank position is a hierarchy violation
|
|
|
|
|
// (ErrForbidden) regardless of whether it also happens to be occupied.
|
|
|
|
|
if err := validatePosition(actor, position); err != nil {
|
|
|
|
|
return nil, err
|
|
|
|
|
}
|
|
|
|
|
if taken[position] {
|
|
|
|
|
return nil, fmt.Errorf("%w: position %d is already used by another role", ErrBadRequest, position)
|
|
|
|
|
}
|
|
|
|
|
} else {
|
|
|
|
|
for position > 0 && taken[position] {
|
|
|
|
|
position--
|
|
|
|
|
}
|
|
|
|
|
if position <= 0 {
|
|
|
|
|
return nil, fmt.Errorf("%w: no free position below your rank — reorder existing roles first", ErrBadRequest)
|
|
|
|
|
}
|
|
|
|
|
if err := validatePosition(actor, position); err != nil {
|
|
|
|
|
return nil, err
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
role, err := s.st.CreateRole(ctx, name, color, perms, position)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, fmt.Errorf("%w: failed to create role: %v", ErrInternal, err)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
db.WriteAudit(context.WithoutCancel(ctx), s.st, actorID, "role_create", "role", role.ID,
|
|
|
|
|
fmt.Sprintf("created role %s (permissions=%#x position=%d)", role.Name, role.Permissions, role.Position))
|
|
|
|
|
slog.Info("role created", "actor_id", actorID, "role_id", role.ID, "name", role.Name)
|
|
|
|
|
return role, nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// UpdateRole applies a partial change to a role below the actor's rank.
|
|
|
|
|
// Returns the updated role and whether the permission mask actually moved —
|
|
|
|
|
// the caller uses that to decide between a cheap roles_update broadcast and a
|
|
|
|
|
// full visibility re-sync.
|
|
|
|
|
func (s *RoleService) UpdateRole(ctx context.Context, actorID, roleID int64, in RoleInput) (updated *db.Role, permsChanged bool, err error) {
|
2026-08-07 21:20:48 +02:00
|
|
|
s.mu.Lock()
|
|
|
|
|
defer s.mu.Unlock()
|
2026-08-01 22:06:14 +02:00
|
|
|
actor, err := s.actorRole(ctx, actorID)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, false, err
|
|
|
|
|
}
|
|
|
|
|
role, err := s.st.GetRoleByID(ctx, roleID)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, false, fmt.Errorf("%w: failed to fetch role: %v", ErrInternal, err)
|
|
|
|
|
}
|
|
|
|
|
if role == nil {
|
|
|
|
|
return nil, false, fmt.Errorf("%w: role not found", ErrNotFound)
|
|
|
|
|
}
|
|
|
|
|
if err := requireBelowActor(actor, role); err != nil {
|
|
|
|
|
return nil, false, err
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
name := role.Name
|
|
|
|
|
if in.Name != nil {
|
|
|
|
|
if name, err = s.validateName(ctx, *in.Name, role.ID); err != nil {
|
|
|
|
|
return nil, false, err
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
color := role.Color
|
|
|
|
|
if in.Color != nil {
|
|
|
|
|
if color, err = validateColor(in.Color); err != nil {
|
|
|
|
|
return nil, false, err
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
perms := role.Permissions
|
|
|
|
|
if in.Permissions != nil {
|
|
|
|
|
perms = *in.Permissions & permissions.AllPerms
|
|
|
|
|
if err := requireGrantable(actor, role.Permissions, perms); err != nil {
|
|
|
|
|
return nil, false, err
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
position := role.Position
|
|
|
|
|
if in.Position != nil {
|
|
|
|
|
position = *in.Position
|
|
|
|
|
if err := validatePosition(actor, position); err != nil {
|
|
|
|
|
return nil, false, err
|
|
|
|
|
}
|
2026-08-07 21:20:48 +02:00
|
|
|
// Positions must stay unique (see CreateRole): tied positions read
|
|
|
|
|
// as equal rank in every >=/<= hierarchy comparison. Moving onto a
|
|
|
|
|
// slot another role holds is refused; re-stating our own is fine.
|
|
|
|
|
if position != role.Position {
|
|
|
|
|
existing, err := s.st.ListRoles(ctx)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, false, fmt.Errorf("%w: failed to list roles: %v", ErrInternal, err)
|
|
|
|
|
}
|
|
|
|
|
for _, rl := range existing {
|
|
|
|
|
if rl.ID != role.ID && rl.Position == position {
|
|
|
|
|
return nil, false, fmt.Errorf("%w: position %d is already used by another role", ErrBadRequest, position)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-08-01 22:06:14 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if err := s.st.UpdateRole(ctx, role.ID, name, color, perms, position); err != nil {
|
|
|
|
|
return nil, false, fmt.Errorf("%w: failed to update role: %v", ErrInternal, err)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
db.WriteAudit(context.WithoutCancel(ctx), s.st, actorID, "role_update", "role", role.ID,
|
|
|
|
|
fmt.Sprintf("updated role %s (permissions=%#x position=%d)", name, perms, position))
|
|
|
|
|
slog.Info("role updated", "actor_id", actorID, "role_id", role.ID, "name", name)
|
|
|
|
|
|
|
|
|
|
return &db.Role{
|
|
|
|
|
ID: role.ID,
|
|
|
|
|
Name: name,
|
|
|
|
|
Color: color,
|
|
|
|
|
Permissions: perms,
|
|
|
|
|
Position: position,
|
|
|
|
|
IsDefault: role.IsDefault,
|
|
|
|
|
}, perms != role.Permissions, nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// DeleteRole removes a role below the actor's rank, moving its members onto the
|
|
|
|
|
// default role. Returns the deleted role, the fallback its members landed on,
|
|
|
|
|
// and the ids of those members so the caller can invalidate and re-sync them.
|
|
|
|
|
func (s *RoleService) DeleteRole(ctx context.Context, actorID, roleID int64) (deleted, fallback *db.Role, movedUserIDs []int64, err error) {
|
2026-08-07 21:20:48 +02:00
|
|
|
s.mu.Lock()
|
|
|
|
|
defer s.mu.Unlock()
|
2026-08-01 22:06:14 +02:00
|
|
|
actor, err := s.actorRole(ctx, actorID)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, nil, nil, err
|
|
|
|
|
}
|
|
|
|
|
role, err := s.st.GetRoleByID(ctx, roleID)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, nil, nil, fmt.Errorf("%w: failed to fetch role: %v", ErrInternal, err)
|
|
|
|
|
}
|
|
|
|
|
if role == nil {
|
|
|
|
|
return nil, nil, nil, fmt.Errorf("%w: role not found", ErrNotFound)
|
|
|
|
|
}
|
|
|
|
|
if err := requireBelowActor(actor, role); err != nil {
|
|
|
|
|
return nil, nil, nil, err
|
|
|
|
|
}
|
|
|
|
|
// Belt and braces: the position rule already puts the owner role out of
|
|
|
|
|
// reach (nothing is above position 100), but the seeded owner is named
|
|
|
|
|
// explicitly so a database whose positions were edited by hand cannot make
|
|
|
|
|
// the server ownerless.
|
|
|
|
|
if role.ID == permissions.OwnerRoleID || role.Position >= permissions.OwnerRolePosition {
|
|
|
|
|
return nil, nil, nil, fmt.Errorf("%w: the Owner role cannot be deleted", ErrBadRequest)
|
|
|
|
|
}
|
|
|
|
|
if role.IsDefault {
|
|
|
|
|
return nil, nil, nil, fmt.Errorf("%w: the default role cannot be deleted — every member falls back to it", ErrBadRequest)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fallback, err = s.st.GetDefaultRole(ctx)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, nil, nil, fmt.Errorf("%w: failed to resolve the default role: %v", ErrInternal, err)
|
|
|
|
|
}
|
|
|
|
|
if fallback == nil {
|
|
|
|
|
// Fail closed: without a fallback the members would be orphaned on a
|
|
|
|
|
// role id that no longer exists.
|
|
|
|
|
return nil, nil, nil, fmt.Errorf("%w: no default role is configured", ErrInternal)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
movedUserIDs, err = s.st.DeleteRoleReassigning(ctx, role.ID, fallback.ID)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, nil, nil, fmt.Errorf("%w: failed to delete role: %v", ErrInternal, err)
|
|
|
|
|
}
|
|
|
|
|
// The members' cached masks are the deleted role's until this drops them.
|
|
|
|
|
if s.perms != nil {
|
|
|
|
|
for _, uid := range movedUserIDs {
|
|
|
|
|
s.perms.InvalidateUser(uid)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
db.WriteAudit(context.WithoutCancel(ctx), s.st, actorID, "role_delete", "role", role.ID,
|
|
|
|
|
fmt.Sprintf("deleted role %s (%d members reassigned to %s)", role.Name, len(movedUserIDs), fallback.Name))
|
|
|
|
|
slog.Warn("role deleted", "actor_id", actorID, "role_id", role.ID, "name", role.Name,
|
|
|
|
|
"members_reassigned", len(movedUserIDs))
|
|
|
|
|
return role, fallback, movedUserIDs, nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ReorderRoles rewrites the positions of every role the actor may manage.
|
|
|
|
|
// orderedIDs is highest-rank-first and must name exactly the set of roles
|
|
|
|
|
// strictly below the actor — a partial list is refused rather than silently
|
|
|
|
|
// leaving the omitted roles wherever they were, which is how positions
|
|
|
|
|
// collided in the first place.
|
|
|
|
|
//
|
|
|
|
|
// Positions are normalized to N..1, so they stay unique, stay strictly below
|
|
|
|
|
// the actor (N < actor.Position, enforced by maxRoles), and never collide with
|
|
|
|
|
// the untouched roles above the actor.
|
|
|
|
|
func (s *RoleService) ReorderRoles(ctx context.Context, actorID int64, orderedIDs []int64) ([]*db.Role, error) {
|
2026-08-07 21:20:48 +02:00
|
|
|
s.mu.Lock()
|
|
|
|
|
defer s.mu.Unlock()
|
2026-08-01 22:06:14 +02:00
|
|
|
actor, err := s.actorRole(ctx, actorID)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, err
|
|
|
|
|
}
|
|
|
|
|
roles, err := s.st.ListRoles(ctx)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, fmt.Errorf("%w: failed to list roles: %v", ErrInternal, err)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
manageable := make(map[int64]*db.Role, len(roles))
|
|
|
|
|
for _, r := range roles {
|
|
|
|
|
if r.Position < actor.Position {
|
|
|
|
|
manageable[r.ID] = r
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
if len(orderedIDs) != len(manageable) {
|
|
|
|
|
return nil, fmt.Errorf("%w: the order must list all %d roles below your own rank", ErrBadRequest, len(manageable))
|
|
|
|
|
}
|
|
|
|
|
seen := make(map[int64]bool, len(orderedIDs))
|
|
|
|
|
for _, id := range orderedIDs {
|
|
|
|
|
if seen[id] {
|
|
|
|
|
return nil, fmt.Errorf("%w: role %d listed twice", ErrBadRequest, id)
|
|
|
|
|
}
|
|
|
|
|
seen[id] = true
|
|
|
|
|
if _, ok := manageable[id]; !ok {
|
|
|
|
|
// Covers both "unknown role" and "role at or above your rank"; the
|
|
|
|
|
// two are deliberately indistinguishable to the caller.
|
|
|
|
|
return nil, fmt.Errorf("%w: role %d is not yours to reorder", ErrForbidden, id)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
if len(orderedIDs) >= actor.Position {
|
|
|
|
|
return nil, fmt.Errorf("%w: too many roles to place below your own rank", ErrBadRequest)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
positions := make(map[int64]int, len(orderedIDs))
|
|
|
|
|
for i, id := range orderedIDs {
|
|
|
|
|
positions[id] = len(orderedIDs) - i
|
|
|
|
|
}
|
|
|
|
|
if err := s.st.SetRolePositions(ctx, positions); err != nil {
|
|
|
|
|
return nil, fmt.Errorf("%w: failed to reorder roles: %v", ErrInternal, err)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
db.WriteAudit(context.WithoutCancel(ctx), s.st, actorID, "role_reorder", "role", 0,
|
|
|
|
|
fmt.Sprintf("reordered %d roles", len(orderedIDs)))
|
|
|
|
|
slog.Info("roles reordered", "actor_id", actorID, "count", len(orderedIDs))
|
|
|
|
|
|
|
|
|
|
updated, err := s.st.ListRoles(ctx)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, fmt.Errorf("%w: failed to list roles: %v", ErrInternal, err)
|
|
|
|
|
}
|
|
|
|
|
return updated, nil
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-07 21:20:48 +02:00
|
|
|
// AffectedUserIDs returns the ids of the users holding roleID, and whether the
|
|
|
|
|
// lookup succeeded. Handlers use it to invalidate exactly the permission-cache
|
|
|
|
|
// entries a role edit touches; on ok=false the caller must fall back to a
|
|
|
|
|
// blanket invalidation — a nil list treated as "nobody" silently leaves stale
|
|
|
|
|
// masks in place.
|
|
|
|
|
func (s *RoleService) AffectedUserIDs(ctx context.Context, roleID int64) ([]int64, bool) {
|
2026-08-01 22:06:14 +02:00
|
|
|
ids, err := s.st.ListUserIDsByRole(ctx, roleID)
|
|
|
|
|
if err != nil {
|
|
|
|
|
slog.Warn("role service: failed to list role members", "role_id", roleID, "err", err)
|
2026-08-07 21:20:48 +02:00
|
|
|
return nil, false
|
2026-08-01 22:06:14 +02:00
|
|
|
}
|
2026-08-07 21:20:48 +02:00
|
|
|
return ids, true
|
2026-08-01 22:06:14 +02:00
|
|
|
}
|