Files

489 lines
18 KiB
Go
Raw Permalink Normal View History

2026-08-01 22:06:14 +02:00
package service
import (
"context"
"fmt"
"log/slog"
"regexp"
"strings"
"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
// 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.
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) {
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) {
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
}
// 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) {
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) {
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
}
// 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)
return nil, false
2026-08-01 22:06:14 +02:00
}
return ids, true
2026-08-01 22:06:14 +02:00
}