mirror of
https://github.com/J3vb/OwnCord.git
synced 2026-09-03 03:50:00 +03:00
* feat(invariants): add the invariant-rule harness and the syncutil-locks rule * fix(ws,service): route the last five raw mutexes through syncutil The -tags deadlock CI pass only observes locks declared via syncutil, whose Mutex/RWMutex are build-tag aliases. These five were declared as raw sync types and were invisible to it, including the hub voice key-holder lock and the permission and role caches. TestServerInvariants now gates the tree against regressions. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(invariants): walk the tree through os.Root to close a symlink TOCTOU gosec G122: reading a filepath.WalkDir-supplied path is race-prone, since a symlink swapped between the walk and the read escapes the intended tree. os.Root confines every read to the root and cannot be traversed out of. Walking the root's fs.FS also yields slash-separated paths already relative to it, so the filepath.Rel and ToSlash conversion is no longer needed. * fix(invariants): close syncutil-locks evasions, isolate per-rule tests, harden the gate - I1: TestServerInvariants now asserts every registered Rule.Scope directory exists and holds at least one non-test .go file, so the gate cannot pass by scanning nothing. - I2: split CheckSource into a thin wrapper over an unexported checkSourceWith(rules, ...), so TestSyncutilLocks tests the syncutil-locks rule in isolation instead of the whole registry. - I3: broaden checkSyncutilLocks to a single SelectorExpr match (any sync.Mutex/sync.RWMutex reference bound via f.Imports, aliases included) instead of only *ast.Field/*ast.ValueSpec. Catches := composite literals, untyped var specs, type aliases, and []sync.Mutex/map[K]sync.Mutex, none of which the old rule saw. A dot-import of "sync" is now its own violation, since it would otherwise let a bare Mutex evade the selector match entirely. - M2: suppression now keys off the violation's own Rule id (allowed[v.Line][v.Rule]) rather than the running rule's ID, so a rule that ever emits a sub-id isn't silently unsuppressible. - M3: Run sorts with sort.SliceStable, since an unreasoned allow comment and the violation it fails to suppress can share a file:line. - M4/M5/M1-partial: add a build-tag-gated fixture test, document that allow comments must be same-line, and correct the skipDirs comment to describe both the generated-code and gitignored-runtime-dir cases it actually covers. All ten original TestSyncutilLocks subtests pass unchanged; six new subtests cover the evasions above. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * docs(server): point at the syncutil-locks invariant gate Server/CLAUDE.md told developers not to hand-roll around syncutil but never said it's enforced. Note that Server/invariants/ checks it at go test time and that exceptions are greppable via grep -rn "invariant:allow" Server/. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
489 lines
18 KiB
Go
489 lines
18 KiB
Go
package service
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"log/slog"
|
|
"regexp"
|
|
"strings"
|
|
|
|
"github.com/owncord/server/db"
|
|
"github.com/owncord/server/permissions"
|
|
"github.com/owncord/server/syncutil"
|
|
)
|
|
|
|
// 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
|
|
}
|
|
|
|
// 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()
|
|
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()
|
|
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)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
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()
|
|
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()
|
|
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) {
|
|
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
|
|
}
|
|
return ids, true
|
|
}
|