refactor: stricter domain: access-control
This commit is contained in:
@@ -0,0 +1,47 @@
|
||||
import { BanEntry, User } from '../../../../shared-kernel';
|
||||
|
||||
type BanAwareUser = Pick<User, 'id' | 'oderId'> | null | undefined;
|
||||
|
||||
/** Build the set of user identifiers that may appear in room ban entries. */
|
||||
export function getRoomBanCandidateIds(user: BanAwareUser, persistedUserId?: string | null): string[] {
|
||||
const candidates = [
|
||||
user?.id,
|
||||
user?.oderId,
|
||||
persistedUserId
|
||||
].filter((value): value is string => typeof value === 'string' && value.trim().length > 0);
|
||||
|
||||
return Array.from(new Set(candidates));
|
||||
}
|
||||
|
||||
/** Resolve the user identifier stored by a ban entry, with legacy fallback support. */
|
||||
export function getRoomBanTargetId(ban: Pick<BanEntry, 'userId' | 'oderId'>): string {
|
||||
if (typeof ban.userId === 'string' && ban.userId.trim().length > 0) {
|
||||
return ban.userId;
|
||||
}
|
||||
|
||||
return ban.oderId;
|
||||
}
|
||||
|
||||
/** Return true when the given ban targets the provided user. */
|
||||
export function isRoomBanMatch(
|
||||
ban: Pick<BanEntry, 'userId' | 'oderId'>,
|
||||
user: BanAwareUser,
|
||||
persistedUserId?: string | null
|
||||
): boolean {
|
||||
const candidateIds = getRoomBanCandidateIds(user, persistedUserId);
|
||||
|
||||
if (candidateIds.length === 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return candidateIds.includes(getRoomBanTargetId(ban));
|
||||
}
|
||||
|
||||
/** Return true when any active ban entry targets the provided user. */
|
||||
export function hasRoomBanForUser(
|
||||
bans: Pick<BanEntry, 'userId' | 'oderId'>[],
|
||||
user: BanAwareUser,
|
||||
persistedUserId?: string | null
|
||||
): boolean {
|
||||
return bans.some((ban) => isRoomBanMatch(ban, user, persistedUserId));
|
||||
}
|
||||
@@ -0,0 +1,248 @@
|
||||
import {
|
||||
ChannelPermissionOverride,
|
||||
PermissionState,
|
||||
Room,
|
||||
RoomPermissionKey,
|
||||
RoomRole
|
||||
} from '../../../../shared-kernel';
|
||||
import { SYSTEM_ROLE_IDS } from '../constants/access-control.constants';
|
||||
import type { MemberIdentity } from '../models/access-control.model';
|
||||
import {
|
||||
buildRoleLookup,
|
||||
getRolePermissionState,
|
||||
matchesIdentity,
|
||||
normalizePermissionState,
|
||||
roleSortAscending,
|
||||
compareText
|
||||
} from '../util/access-control.util';
|
||||
import { getAssignedRoleIds, getHighestAssignedRole } from './role-assignment.rules';
|
||||
import { getRoomRoleById, normalizeRoomRoles } from './role.rules';
|
||||
|
||||
function resolveRolePermissionState(roles: readonly RoomRole[], assignedRoleIds: readonly string[], permission: RoomPermissionKey): PermissionState {
|
||||
const roleLookup = buildRoleLookup(roles);
|
||||
const assignedRoles = assignedRoleIds.map((roleId) => roleLookup.get(roleId)).filter((role): role is RoomRole => !!role);
|
||||
const effectiveRoles = [roleLookup.get(SYSTEM_ROLE_IDS.everyone), ...assignedRoles]
|
||||
.filter((role): role is RoomRole => !!role)
|
||||
.sort(roleSortAscending);
|
||||
|
||||
let state: PermissionState = 'inherit';
|
||||
|
||||
for (const role of effectiveRoles) {
|
||||
const nextState = getRolePermissionState(role, permission);
|
||||
|
||||
if (nextState !== 'inherit') {
|
||||
state = nextState;
|
||||
}
|
||||
}
|
||||
|
||||
return state;
|
||||
}
|
||||
|
||||
function resolveChannelOverrideState(
|
||||
overrides: readonly ChannelPermissionOverride[],
|
||||
roles: readonly RoomRole[],
|
||||
assignedRoleIds: readonly string[],
|
||||
identity: MemberIdentity,
|
||||
channelId: string,
|
||||
permission: RoomPermissionKey,
|
||||
baseState: PermissionState
|
||||
): PermissionState {
|
||||
const roleLookup = buildRoleLookup(roles);
|
||||
|
||||
let state = baseState;
|
||||
|
||||
const everyoneOverride = overrides.find(
|
||||
(override) =>
|
||||
override.channelId === channelId &&
|
||||
override.targetType === 'role' &&
|
||||
override.targetId === SYSTEM_ROLE_IDS.everyone &&
|
||||
override.permission === permission
|
||||
);
|
||||
|
||||
if (everyoneOverride?.value && everyoneOverride.value !== 'inherit') {
|
||||
state = everyoneOverride.value;
|
||||
}
|
||||
|
||||
const orderedAssignedRoles = assignedRoleIds
|
||||
.map((roleId) => roleLookup.get(roleId))
|
||||
.filter((role): role is RoomRole => !!role)
|
||||
.sort(roleSortAscending);
|
||||
|
||||
for (const role of orderedAssignedRoles) {
|
||||
const override = overrides.find(
|
||||
(candidateOverride) =>
|
||||
candidateOverride.channelId === channelId &&
|
||||
candidateOverride.targetType === 'role' &&
|
||||
candidateOverride.targetId === role.id &&
|
||||
candidateOverride.permission === permission
|
||||
);
|
||||
|
||||
if (override?.value && override.value !== 'inherit') {
|
||||
state = override.value;
|
||||
}
|
||||
}
|
||||
|
||||
const userOverride = overrides.find(
|
||||
(override) =>
|
||||
override.channelId === channelId &&
|
||||
override.targetType === 'user' &&
|
||||
override.permission === permission &&
|
||||
(override.targetId === identity.id || override.targetId === identity.oderId)
|
||||
);
|
||||
|
||||
if (userOverride?.value && userOverride.value !== 'inherit') {
|
||||
state = userOverride.value;
|
||||
}
|
||||
|
||||
return state;
|
||||
}
|
||||
|
||||
export function normalizeChannelPermissionOverrides(
|
||||
overrides: readonly ChannelPermissionOverride[] | undefined,
|
||||
roles: readonly RoomRole[]
|
||||
): ChannelPermissionOverride[] {
|
||||
const validRoleIds = new Set(roles.map((role) => role.id));
|
||||
const normalizedByKey = new Map<string, ChannelPermissionOverride>();
|
||||
|
||||
for (const override of overrides ?? []) {
|
||||
if (!override || typeof override !== 'object') {
|
||||
continue;
|
||||
}
|
||||
|
||||
const channelId = typeof override.channelId === 'string' ? override.channelId.trim() : '';
|
||||
const targetId = typeof override.targetId === 'string' ? override.targetId.trim() : '';
|
||||
const targetType = override.targetType === 'role' || override.targetType === 'user' ? override.targetType : null;
|
||||
const permission = override.permission;
|
||||
const value = normalizePermissionState(override.value);
|
||||
|
||||
if (!channelId || !targetId || !targetType || !permission || value === 'inherit') {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (targetType === 'role' && !validRoleIds.has(targetId)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
normalizedByKey.set(`${channelId}:${targetType}:${targetId}:${permission}`, {
|
||||
channelId,
|
||||
targetType,
|
||||
targetId,
|
||||
permission,
|
||||
value
|
||||
});
|
||||
}
|
||||
|
||||
return Array.from(normalizedByKey.values()).sort((firstOverride, secondOverride) => {
|
||||
const channelCompare = compareText(firstOverride.channelId, secondOverride.channelId);
|
||||
|
||||
if (channelCompare !== 0) {
|
||||
return channelCompare;
|
||||
}
|
||||
|
||||
if (firstOverride.targetType !== secondOverride.targetType) {
|
||||
return compareText(firstOverride.targetType, secondOverride.targetType);
|
||||
}
|
||||
|
||||
const targetCompare = compareText(firstOverride.targetId, secondOverride.targetId);
|
||||
|
||||
if (targetCompare !== 0) {
|
||||
return targetCompare;
|
||||
}
|
||||
|
||||
return compareText(firstOverride.permission, secondOverride.permission);
|
||||
});
|
||||
}
|
||||
|
||||
export function resolveRoomPermission(
|
||||
room: Room,
|
||||
identity: MemberIdentity | null | undefined,
|
||||
permission: RoomPermissionKey,
|
||||
channelId?: string
|
||||
): boolean {
|
||||
if (!identity) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (room.hostId === identity.id || room.hostId === identity.oderId) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const roles = normalizeRoomRoles(room.roles, room.permissions);
|
||||
const assignedRoleIds = getAssignedRoleIds(room.roleAssignments, identity);
|
||||
const roleState = resolveRolePermissionState(roles, assignedRoleIds, permission);
|
||||
const channelState = channelId
|
||||
? resolveChannelOverrideState(
|
||||
normalizeChannelPermissionOverrides(room.channelPermissions, roles),
|
||||
roles,
|
||||
assignedRoleIds,
|
||||
identity,
|
||||
channelId,
|
||||
permission,
|
||||
roleState
|
||||
)
|
||||
: roleState;
|
||||
|
||||
return channelState === 'allow';
|
||||
}
|
||||
|
||||
export function canManageMember(
|
||||
room: Room,
|
||||
actor: MemberIdentity | null | undefined,
|
||||
target: MemberIdentity | null | undefined,
|
||||
permission: 'kickMembers' | 'banMembers' | 'manageRoles'
|
||||
): boolean {
|
||||
if (!actor || !target) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const isActorOwner = room.hostId === actor.id || room.hostId === actor.oderId;
|
||||
const isTargetOwner = room.hostId === target.id || room.hostId === target.oderId;
|
||||
const isSameIdentity = matchesIdentity(actor, {
|
||||
userId: target.id || target.oderId || '',
|
||||
oderId: target.oderId
|
||||
});
|
||||
|
||||
if (isSameIdentity) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (isTargetOwner && !isActorOwner) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (isActorOwner) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!resolveRoomPermission(room, actor, permission)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const actorRole = getHighestAssignedRole(room, actor);
|
||||
const targetRole = getHighestAssignedRole(room, target);
|
||||
|
||||
return (actorRole?.position ?? 0) > (targetRole?.position ?? 0);
|
||||
}
|
||||
|
||||
export function canManageRole(room: Room, actor: MemberIdentity | null | undefined, roleId: string): boolean {
|
||||
if (!actor || !roleId) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (room.hostId === actor.id || room.hostId === actor.oderId) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!resolveRoomPermission(room, actor, 'manageRoles')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const targetRole = getRoomRoleById(normalizeRoomRoles(room.roles, room.permissions), roleId);
|
||||
const actorRole = getHighestAssignedRole(room, actor);
|
||||
|
||||
if (!targetRole) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return (actorRole?.position ?? 0) > targetRole.position;
|
||||
}
|
||||
@@ -0,0 +1,201 @@
|
||||
import {
|
||||
Room,
|
||||
RoomMember,
|
||||
RoomRole,
|
||||
RoomRoleAssignment
|
||||
} from '../../../../shared-kernel';
|
||||
import { SYSTEM_ROLE_IDS } from '../constants/access-control.constants';
|
||||
import type { MemberIdentity } from '../models/access-control.model';
|
||||
import {
|
||||
buildRoleLookup,
|
||||
compareText,
|
||||
memberIdentityKey,
|
||||
matchesIdentity,
|
||||
roleSortDescending,
|
||||
uniqueStrings
|
||||
} from '../util/access-control.util';
|
||||
import { getRoomRoleById, normalizeRoomRoles } from './role.rules';
|
||||
|
||||
function sortAssignments(assignments: readonly RoomRoleAssignment[]): RoomRoleAssignment[] {
|
||||
return [...assignments].sort((firstAssignment, secondAssignment) =>
|
||||
compareText(firstAssignment.oderId || firstAssignment.userId, secondAssignment.oderId || secondAssignment.userId)
|
||||
);
|
||||
}
|
||||
|
||||
function resolveLegacyMemberRoleIds(member: RoomMember, validRoleIds: Set<string>): string[] {
|
||||
if (Array.isArray(member.roleIds) && member.roleIds.length > 0) {
|
||||
return uniqueStrings(member.roleIds).filter((roleId) => validRoleIds.has(roleId));
|
||||
}
|
||||
|
||||
if (member.role === 'admin') {
|
||||
return [SYSTEM_ROLE_IDS.admin];
|
||||
}
|
||||
|
||||
if (member.role === 'moderator') {
|
||||
return [SYSTEM_ROLE_IDS.moderator];
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
export function normalizeRoomRoleAssignments(
|
||||
assignments: readonly RoomRoleAssignment[] | undefined,
|
||||
members: readonly RoomMember[] | undefined,
|
||||
roles: readonly RoomRole[]
|
||||
): RoomRoleAssignment[] {
|
||||
const validRoleIds = new Set(roles.map((role) => role.id).filter((roleId) => roleId !== SYSTEM_ROLE_IDS.everyone));
|
||||
const normalizedByUserKey = new Map<string, RoomRoleAssignment>();
|
||||
|
||||
for (const assignment of assignments ?? []) {
|
||||
if (!assignment || typeof assignment !== 'object') {
|
||||
continue;
|
||||
}
|
||||
|
||||
const userId = typeof assignment.userId === 'string' ? assignment.userId.trim() : '';
|
||||
const oderId = typeof assignment.oderId === 'string' ? assignment.oderId.trim() : undefined;
|
||||
const key = oderId || userId;
|
||||
|
||||
if (!key) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const roleIds = uniqueStrings(assignment.roleIds ?? []).filter((roleId) => validRoleIds.has(roleId));
|
||||
|
||||
if (roleIds.length === 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
normalizedByUserKey.set(key, {
|
||||
userId: userId || key,
|
||||
oderId,
|
||||
roleIds
|
||||
});
|
||||
}
|
||||
|
||||
if (normalizedByUserKey.size > 0) {
|
||||
return sortAssignments(Array.from(normalizedByUserKey.values()));
|
||||
}
|
||||
|
||||
for (const member of members ?? []) {
|
||||
const key = memberIdentityKey(member);
|
||||
|
||||
if (!key) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const roleIds = resolveLegacyMemberRoleIds(member, validRoleIds);
|
||||
|
||||
if (roleIds.length === 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
normalizedByUserKey.set(key, {
|
||||
userId: member.id || key,
|
||||
oderId: member.oderId || undefined,
|
||||
roleIds
|
||||
});
|
||||
}
|
||||
|
||||
return sortAssignments(Array.from(normalizedByUserKey.values()));
|
||||
}
|
||||
|
||||
export function getAssignedRoleIds(assignments: readonly RoomRoleAssignment[] | undefined, identity: MemberIdentity | null | undefined): string[] {
|
||||
const assignment = (assignments ?? []).find((candidateAssignment) => matchesIdentity(identity, candidateAssignment));
|
||||
|
||||
return uniqueStrings(assignment?.roleIds ?? []);
|
||||
}
|
||||
|
||||
export function getDisplayRoleName(room: Room, member: MemberIdentity | null | undefined): string {
|
||||
if (!member) {
|
||||
return 'Member';
|
||||
}
|
||||
|
||||
if (room.hostId === member.id || room.hostId === member.oderId) {
|
||||
return 'Owner';
|
||||
}
|
||||
|
||||
const roles = normalizeRoomRoles(room.roles, room.permissions);
|
||||
const roleLookup = buildRoleLookup(roles);
|
||||
const assignedRoles = getAssignedRoleIds(room.roleAssignments, member)
|
||||
.map((roleId) => roleLookup.get(roleId))
|
||||
.filter((role): role is RoomRole => !!role)
|
||||
.sort(roleSortDescending);
|
||||
|
||||
return assignedRoles[0]?.name || '@everyone';
|
||||
}
|
||||
|
||||
export function getAssignedRoles(room: Room, identity: MemberIdentity | null | undefined): RoomRole[] {
|
||||
const roles = normalizeRoomRoles(room.roles, room.permissions);
|
||||
const roleLookup = buildRoleLookup(roles);
|
||||
|
||||
return getAssignedRoleIds(room.roleAssignments, identity)
|
||||
.map((roleId) => roleLookup.get(roleId))
|
||||
.filter((role): role is RoomRole => !!role)
|
||||
.sort(roleSortDescending);
|
||||
}
|
||||
|
||||
export function getHighestAssignedRole(room: Room, identity: MemberIdentity | null | undefined): RoomRole | null {
|
||||
if (!identity) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (room.hostId === identity.id || room.hostId === identity.oderId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return getAssignedRoles(room, identity)[0] ?? getRoomRoleById(normalizeRoomRoles(room.roles, room.permissions), SYSTEM_ROLE_IDS.everyone) ?? null;
|
||||
}
|
||||
|
||||
export function setRoleAssignmentsForMember(
|
||||
assignments: readonly RoomRoleAssignment[] | undefined,
|
||||
member: MemberIdentity,
|
||||
roleIds: readonly string[]
|
||||
): RoomRoleAssignment[] {
|
||||
const nextAssignments = new Map<string, RoomRoleAssignment>();
|
||||
const memberKey = memberIdentityKey(member);
|
||||
|
||||
for (const assignment of assignments ?? []) {
|
||||
const key = memberIdentityKey({
|
||||
id: assignment.userId,
|
||||
oderId: assignment.oderId
|
||||
});
|
||||
|
||||
if (!key || key === memberKey) {
|
||||
continue;
|
||||
}
|
||||
|
||||
nextAssignments.set(key, {
|
||||
userId: assignment.userId,
|
||||
oderId: assignment.oderId,
|
||||
roleIds: uniqueStrings(assignment.roleIds)
|
||||
});
|
||||
}
|
||||
|
||||
const normalizedRoleIds = uniqueStrings(roleIds);
|
||||
|
||||
if (memberKey && normalizedRoleIds.length > 0) {
|
||||
nextAssignments.set(memberKey, {
|
||||
userId: member.id || member.oderId || memberKey,
|
||||
oderId: member.oderId || undefined,
|
||||
roleIds: normalizedRoleIds
|
||||
});
|
||||
}
|
||||
|
||||
return sortAssignments(Array.from(nextAssignments.values()));
|
||||
}
|
||||
|
||||
export function removeRoleFromAssignments(assignments: readonly RoomRoleAssignment[] | undefined, roleId: string): RoomRoleAssignment[] {
|
||||
return (assignments ?? [])
|
||||
.map((assignment) => ({
|
||||
...assignment,
|
||||
roleIds: assignment.roleIds.filter((candidateRoleId) => candidateRoleId !== roleId)
|
||||
}))
|
||||
.filter((assignment) => assignment.roleIds.length > 0);
|
||||
}
|
||||
|
||||
export function getRoleIdsForMember(room: Room, member: MemberIdentity | null | undefined): string[] {
|
||||
return getAssignedRoleIds(
|
||||
normalizeRoomRoleAssignments(room.roleAssignments, room.members, normalizeRoomRoles(room.roles, room.permissions)),
|
||||
member
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
import {
|
||||
RoomPermissionMatrix,
|
||||
RoomPermissions,
|
||||
RoomRole
|
||||
} from '../../../../shared-kernel';
|
||||
import { SYSTEM_ROLE_IDS } from '../constants/access-control.constants';
|
||||
import {
|
||||
buildRoleLookup,
|
||||
buildSystemRole,
|
||||
nextRolePosition,
|
||||
normalizeName,
|
||||
normalizePermissionMatrix,
|
||||
roleSortAscending,
|
||||
roleSortDescending
|
||||
} from '../util/access-control.util';
|
||||
|
||||
const ROLE_COLORS = {
|
||||
everyone: '#6b7280',
|
||||
moderator: '#10b981',
|
||||
admin: '#60a5fa'
|
||||
} as const;
|
||||
|
||||
function resolveNormalizedRolePosition(
|
||||
position: unknown,
|
||||
fallbackPosition: number | undefined,
|
||||
existingRoles: readonly RoomRole[],
|
||||
defaultRoles: readonly RoomRole[]
|
||||
): number {
|
||||
if (typeof position === 'number' && Number.isFinite(position)) {
|
||||
return position;
|
||||
}
|
||||
|
||||
if (typeof fallbackPosition === 'number') {
|
||||
return fallbackPosition;
|
||||
}
|
||||
|
||||
return nextRolePosition(existingRoles.length > 0 ? existingRoles : defaultRoles);
|
||||
}
|
||||
|
||||
function normalizeRoomRoleEntry(
|
||||
role: RoomRole | null | undefined,
|
||||
defaultsById: Map<string, RoomRole>,
|
||||
existingRoles: readonly RoomRole[],
|
||||
defaultRoles: readonly RoomRole[]
|
||||
): RoomRole | null {
|
||||
if (!role) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const id = typeof role.id === 'string' ? role.id.trim() : '';
|
||||
const fallbackRole = defaultsById.get(id);
|
||||
const name = normalizeName(typeof role.name === 'string' ? role.name : (fallbackRole?.name ?? 'Role'));
|
||||
|
||||
if (!id || !name) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
id,
|
||||
name,
|
||||
position: resolveNormalizedRolePosition(role.position, fallbackRole?.position, existingRoles, defaultRoles),
|
||||
color: typeof role.color === 'string' && role.color.trim() ? role.color.trim() : fallbackRole?.color,
|
||||
isSystem: typeof role.isSystem === 'boolean' ? role.isSystem : fallbackRole?.isSystem,
|
||||
permissions: normalizePermissionMatrix(role.permissions ?? fallbackRole?.permissions)
|
||||
};
|
||||
}
|
||||
|
||||
export function buildDefaultRoomRoles(legacyPermissions?: RoomPermissions): RoomRole[] {
|
||||
const everyonePermissions: RoomPermissionMatrix = {
|
||||
joinVoice: legacyPermissions?.allowVoice === false ? 'deny' : 'allow',
|
||||
shareScreen: legacyPermissions?.allowScreenShare === false ? 'deny' : 'allow',
|
||||
uploadFiles: legacyPermissions?.allowFileUploads === false ? 'deny' : 'allow'
|
||||
};
|
||||
const moderatorPermissions: RoomPermissionMatrix = {
|
||||
kickMembers: 'allow',
|
||||
deleteMessages: 'allow',
|
||||
manageChannels: legacyPermissions?.moderatorsManageRooms ? 'allow' : 'inherit',
|
||||
manageIcon: legacyPermissions?.moderatorsManageIcon ? 'allow' : 'inherit'
|
||||
};
|
||||
const adminPermissions: RoomPermissionMatrix = {
|
||||
kickMembers: 'allow',
|
||||
banMembers: 'allow',
|
||||
manageBans: 'allow',
|
||||
deleteMessages: 'allow',
|
||||
manageChannels: legacyPermissions?.adminsManageRooms ? 'allow' : 'inherit',
|
||||
manageIcon: legacyPermissions?.adminsManageIcon ? 'allow' : 'inherit'
|
||||
};
|
||||
|
||||
return [
|
||||
buildSystemRole(SYSTEM_ROLE_IDS.everyone, '@everyone', 0, everyonePermissions, ROLE_COLORS.everyone),
|
||||
buildSystemRole(SYSTEM_ROLE_IDS.moderator, 'Moderator', 200, moderatorPermissions, ROLE_COLORS.moderator),
|
||||
buildSystemRole(SYSTEM_ROLE_IDS.admin, 'Admin', 300, adminPermissions, ROLE_COLORS.admin)
|
||||
];
|
||||
}
|
||||
|
||||
export function sortRolesForDisplay(roles: readonly RoomRole[]): RoomRole[] {
|
||||
return [...roles].sort(roleSortDescending);
|
||||
}
|
||||
|
||||
export function normalizeRoomRoles(roles: readonly RoomRole[] | undefined, legacyPermissions?: RoomPermissions): RoomRole[] {
|
||||
const defaultRoles = buildDefaultRoomRoles(legacyPermissions);
|
||||
const defaultsById = buildRoleLookup(defaultRoles);
|
||||
const normalizedById = new Map<string, RoomRole>();
|
||||
|
||||
for (const role of roles ?? []) {
|
||||
const normalizedRole = normalizeRoomRoleEntry(role, defaultsById, Array.from(normalizedById.values()), defaultRoles);
|
||||
|
||||
if (normalizedRole) {
|
||||
normalizedById.set(normalizedRole.id, normalizedRole);
|
||||
}
|
||||
}
|
||||
|
||||
for (const [roleId, role] of defaultsById) {
|
||||
if (!normalizedById.has(roleId)) {
|
||||
normalizedById.set(roleId, role);
|
||||
}
|
||||
}
|
||||
|
||||
return Array.from(normalizedById.values()).sort(roleSortAscending);
|
||||
}
|
||||
|
||||
export function getRoomRoleById(roles: readonly RoomRole[] | undefined, roleId: string): RoomRole | undefined {
|
||||
return (roles ?? []).find((role) => role.id === roleId);
|
||||
}
|
||||
|
||||
export function createCustomRoomRole(name: string, roles: readonly RoomRole[]): RoomRole {
|
||||
const normalizedName = normalizeName(name) || 'New Role';
|
||||
|
||||
return {
|
||||
id: `role-${crypto.randomUUID()}`,
|
||||
name: normalizedName,
|
||||
position: nextRolePosition(roles),
|
||||
permissions: {}
|
||||
};
|
||||
}
|
||||
|
||||
export function reorderRoles(roles: readonly RoomRole[], orderedRoleIds: readonly string[]): RoomRole[] {
|
||||
const roleLookup = buildRoleLookup(roles);
|
||||
const systemRoles = roles.filter((role) => role.isSystem);
|
||||
const customRoles = orderedRoleIds.map((roleId) => roleLookup.get(roleId)).filter((role): role is RoomRole => !!role && !role.isSystem);
|
||||
const remainingCustomRoles = roles.filter((role) => !role.isSystem && !orderedRoleIds.includes(role.id));
|
||||
const orderedRoles = sortRolesForDisplay(systemRoles).concat(customRoles)
|
||||
.concat(sortRolesForDisplay(remainingCustomRoles));
|
||||
|
||||
return orderedRoles
|
||||
.map((role, index) => ({
|
||||
...role,
|
||||
position: (orderedRoles.length - index - 1) * 100
|
||||
}))
|
||||
.sort(roleSortAscending);
|
||||
}
|
||||
|
||||
export function withUpdatedRole(roles: readonly RoomRole[], roleId: string, updates: Partial<RoomRole>): RoomRole[] {
|
||||
return normalizeRoomRoles(
|
||||
roles.map((role) => {
|
||||
if (role.id !== roleId) {
|
||||
return role;
|
||||
}
|
||||
|
||||
return {
|
||||
...role,
|
||||
...updates,
|
||||
permissions: normalizePermissionMatrix(updates.permissions ?? role.permissions)
|
||||
};
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
export function findAssignableRoles(roles: readonly RoomRole[]): RoomRole[] {
|
||||
return sortRolesForDisplay(roles).filter((role) => role.id !== SYSTEM_ROLE_IDS.everyone);
|
||||
}
|
||||
@@ -0,0 +1,209 @@
|
||||
import {
|
||||
PermissionState,
|
||||
Room,
|
||||
RoomMember,
|
||||
RoomPermissionKey,
|
||||
RoomPermissions,
|
||||
RoomRole,
|
||||
RoomRoleAssignment,
|
||||
UserRole
|
||||
} from '../../../../shared-kernel';
|
||||
import { SYSTEM_ROLE_IDS } from '../constants/access-control.constants';
|
||||
import {
|
||||
getRolePermissionState,
|
||||
permissionStateToBoolean,
|
||||
resolveLegacyAllowState
|
||||
} from '../util/access-control.util';
|
||||
import type { MemberIdentity } from '../models/access-control.model';
|
||||
import {
|
||||
getAssignedRoleIds,
|
||||
normalizeRoomRoleAssignments,
|
||||
removeRoleFromAssignments
|
||||
} from './role-assignment.rules';
|
||||
import {
|
||||
getRoomRoleById,
|
||||
normalizeRoomRoles,
|
||||
withUpdatedRole
|
||||
} from './role.rules';
|
||||
import { normalizeChannelPermissionOverrides, resolveRoomPermission } from './permission.rules';
|
||||
|
||||
function applyRolePermissionChanges(
|
||||
roles: readonly RoomRole[],
|
||||
role: RoomRole | null | undefined,
|
||||
changes: Partial<Record<RoomPermissionKey, PermissionState | undefined>>
|
||||
): RoomRole[] {
|
||||
if (!role) {
|
||||
return [...roles];
|
||||
}
|
||||
|
||||
return withUpdatedRole(roles, role.id, {
|
||||
permissions: {
|
||||
...role.permissions,
|
||||
...changes
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function getEveryoneLegacyPermissionChanges(
|
||||
role: RoomRole,
|
||||
permissions: Partial<RoomPermissions>
|
||||
): Partial<Record<RoomPermissionKey, PermissionState | undefined>> {
|
||||
return {
|
||||
joinVoice: resolveLegacyAllowState(permissions.allowVoice, role.permissions?.joinVoice, 'deny'),
|
||||
shareScreen: resolveLegacyAllowState(permissions.allowScreenShare, role.permissions?.shareScreen, 'deny'),
|
||||
uploadFiles: resolveLegacyAllowState(permissions.allowFileUploads, role.permissions?.uploadFiles, 'deny')
|
||||
};
|
||||
}
|
||||
|
||||
function getModeratorLegacyPermissionChanges(
|
||||
role: RoomRole,
|
||||
permissions: Partial<RoomPermissions>
|
||||
): Partial<Record<RoomPermissionKey, PermissionState | undefined>> {
|
||||
return {
|
||||
manageChannels: resolveLegacyAllowState(permissions.moderatorsManageRooms, role.permissions?.manageChannels, 'inherit'),
|
||||
manageIcon: resolveLegacyAllowState(permissions.moderatorsManageIcon, role.permissions?.manageIcon, 'inherit')
|
||||
};
|
||||
}
|
||||
|
||||
function getAdminLegacyPermissionChanges(
|
||||
role: RoomRole,
|
||||
permissions: Partial<RoomPermissions>
|
||||
): Partial<Record<RoomPermissionKey, PermissionState | undefined>> {
|
||||
return {
|
||||
manageChannels: resolveLegacyAllowState(permissions.adminsManageRooms, role.permissions?.manageChannels, 'inherit'),
|
||||
manageIcon: resolveLegacyAllowState(permissions.adminsManageIcon, role.permissions?.manageIcon, 'inherit')
|
||||
};
|
||||
}
|
||||
|
||||
export function resolveLegacyRole(room: Room, identity: MemberIdentity | null | undefined): UserRole {
|
||||
if (!identity) {
|
||||
return 'member';
|
||||
}
|
||||
|
||||
if (room.hostId === identity.id || room.hostId === identity.oderId) {
|
||||
return 'host';
|
||||
}
|
||||
|
||||
const assignedRoleIds = getAssignedRoleIds(room.roleAssignments, identity);
|
||||
|
||||
if (assignedRoleIds.includes(SYSTEM_ROLE_IDS.admin)) {
|
||||
return 'admin';
|
||||
}
|
||||
|
||||
if (assignedRoleIds.includes(SYSTEM_ROLE_IDS.moderator)) {
|
||||
return 'moderator';
|
||||
}
|
||||
|
||||
if (
|
||||
resolveRoomPermission(room, identity, 'manageRoles') ||
|
||||
resolveRoomPermission(room, identity, 'banMembers') ||
|
||||
resolveRoomPermission(room, identity, 'manageBans') ||
|
||||
resolveRoomPermission(room, identity, 'manageServer')
|
||||
) {
|
||||
return 'admin';
|
||||
}
|
||||
|
||||
if (
|
||||
resolveRoomPermission(room, identity, 'kickMembers') ||
|
||||
resolveRoomPermission(room, identity, 'deleteMessages') ||
|
||||
resolveRoomPermission(room, identity, 'manageChannels') ||
|
||||
resolveRoomPermission(room, identity, 'manageIcon')
|
||||
) {
|
||||
return 'moderator';
|
||||
}
|
||||
|
||||
return 'member';
|
||||
}
|
||||
|
||||
export function deriveLegacyRoomPermissions(room: Pick<Room, 'roles' | 'permissions' | 'slowModeInterval'>): RoomPermissions {
|
||||
const roles = normalizeRoomRoles(room.roles, room.permissions);
|
||||
const everyoneRole = getRoomRoleById(roles, SYSTEM_ROLE_IDS.everyone);
|
||||
const moderatorRole = getRoomRoleById(roles, SYSTEM_ROLE_IDS.moderator);
|
||||
const adminRole = getRoomRoleById(roles, SYSTEM_ROLE_IDS.admin);
|
||||
|
||||
return {
|
||||
allowVoice: permissionStateToBoolean(getRolePermissionState(everyoneRole, 'joinVoice'), true),
|
||||
allowScreenShare: permissionStateToBoolean(getRolePermissionState(everyoneRole, 'shareScreen'), true),
|
||||
allowFileUploads: permissionStateToBoolean(getRolePermissionState(everyoneRole, 'uploadFiles'), true),
|
||||
adminsManageRooms: getRolePermissionState(adminRole, 'manageChannels') === 'allow',
|
||||
moderatorsManageRooms: getRolePermissionState(moderatorRole, 'manageChannels') === 'allow',
|
||||
adminsManageIcon: getRolePermissionState(adminRole, 'manageIcon') === 'allow',
|
||||
moderatorsManageIcon: getRolePermissionState(moderatorRole, 'manageIcon') === 'allow',
|
||||
slowModeInterval: room.slowModeInterval ?? room.permissions?.slowModeInterval ?? 0
|
||||
};
|
||||
}
|
||||
|
||||
export function withLegacyRoomPermissions(room: Room, permissions: Partial<RoomPermissions>): Room {
|
||||
const roles = normalizeRoomRoles(room.roles, room.permissions);
|
||||
const everyoneRole = getRoomRoleById(roles, SYSTEM_ROLE_IDS.everyone);
|
||||
const moderatorRole = getRoomRoleById(roles, SYSTEM_ROLE_IDS.moderator);
|
||||
const adminRole = getRoomRoleById(roles, SYSTEM_ROLE_IDS.admin);
|
||||
|
||||
let nextRoles = applyRolePermissionChanges(roles, everyoneRole, everyoneRole ? getEveryoneLegacyPermissionChanges(everyoneRole, permissions) : {});
|
||||
|
||||
nextRoles = applyRolePermissionChanges(
|
||||
nextRoles,
|
||||
moderatorRole,
|
||||
moderatorRole ? getModeratorLegacyPermissionChanges(moderatorRole, permissions) : {}
|
||||
);
|
||||
|
||||
nextRoles = applyRolePermissionChanges(nextRoles, adminRole, adminRole ? getAdminLegacyPermissionChanges(adminRole, permissions) : {});
|
||||
|
||||
return normalizeRoomAccessControl({
|
||||
...room,
|
||||
roles: nextRoles,
|
||||
slowModeInterval: permissions.slowModeInterval ?? room.slowModeInterval ?? room.permissions?.slowModeInterval ?? 0
|
||||
});
|
||||
}
|
||||
|
||||
export function hydrateRoomMembers(room: Room): RoomMember[] {
|
||||
const roles = normalizeRoomRoles(room.roles, room.permissions);
|
||||
const roleAssignments = normalizeRoomRoleAssignments(room.roleAssignments, room.members, roles);
|
||||
|
||||
return (room.members ?? []).map((member) => {
|
||||
const roleIds = getAssignedRoleIds(roleAssignments, member);
|
||||
const hydratedRoom: Room = {
|
||||
...room,
|
||||
roles,
|
||||
roleAssignments
|
||||
};
|
||||
|
||||
return {
|
||||
...member,
|
||||
roleIds,
|
||||
role: resolveLegacyRole(hydratedRoom, member)
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export function normalizeRoomAccessControl(room: Room): Room {
|
||||
const roles = normalizeRoomRoles(room.roles, room.permissions);
|
||||
const roleAssignments = normalizeRoomRoleAssignments(room.roleAssignments, room.members, roles);
|
||||
const channelPermissions = normalizeChannelPermissionOverrides(room.channelPermissions, roles);
|
||||
const slowModeInterval = room.slowModeInterval ?? room.permissions?.slowModeInterval ?? 0;
|
||||
const nextRoom: Room = {
|
||||
...room,
|
||||
roles,
|
||||
roleAssignments,
|
||||
channelPermissions,
|
||||
slowModeInterval
|
||||
};
|
||||
|
||||
nextRoom.permissions = deriveLegacyRoomPermissions(nextRoom);
|
||||
nextRoom.members = hydrateRoomMembers(nextRoom);
|
||||
|
||||
return nextRoom;
|
||||
}
|
||||
|
||||
export function removeRole(
|
||||
roles: readonly RoomRole[],
|
||||
assignments: readonly RoomRoleAssignment[] | undefined,
|
||||
roleId: string
|
||||
): { roles: RoomRole[]; roleAssignments: RoomRoleAssignment[] } {
|
||||
const nextRoles = roles.filter((role) => role.id !== roleId || role.isSystem);
|
||||
|
||||
return {
|
||||
roles: normalizeRoomRoles(nextRoles),
|
||||
roleAssignments: removeRoleFromAssignments(assignments, roleId)
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user