Add access control rework
This commit is contained in:
@@ -0,0 +1,65 @@
|
||||
import type { RoomPermissionDefinition } from './access-control.models';
|
||||
|
||||
export const SYSTEM_ROLE_IDS = {
|
||||
everyone: 'system-everyone',
|
||||
moderator: 'system-moderator',
|
||||
admin: 'system-admin'
|
||||
} as const;
|
||||
|
||||
export const ROOM_PERMISSION_DEFINITIONS: RoomPermissionDefinition[] = [
|
||||
{
|
||||
key: 'manageServer',
|
||||
label: 'Manage Server',
|
||||
description: 'Edit server settings such as name, privacy, and limits.'
|
||||
},
|
||||
{
|
||||
key: 'manageRoles',
|
||||
label: 'Manage Roles',
|
||||
description: 'Create, edit, reorder, and assign roles.'
|
||||
},
|
||||
{
|
||||
key: 'manageChannels',
|
||||
label: 'Manage Channels',
|
||||
description: 'Create, rename, delete, and reorder channels.'
|
||||
},
|
||||
{
|
||||
key: 'manageIcon',
|
||||
label: 'Manage Icon',
|
||||
description: 'Change the server icon for all members.'
|
||||
},
|
||||
{
|
||||
key: 'kickMembers',
|
||||
label: 'Kick Members',
|
||||
description: 'Remove members from the server without banning them.'
|
||||
},
|
||||
{
|
||||
key: 'banMembers',
|
||||
label: 'Ban Members',
|
||||
description: 'Ban members from the server.'
|
||||
},
|
||||
{
|
||||
key: 'manageBans',
|
||||
label: 'Manage Bans',
|
||||
description: 'Review and revoke existing bans.'
|
||||
},
|
||||
{
|
||||
key: 'deleteMessages',
|
||||
label: 'Delete Messages',
|
||||
description: 'Delete messages sent by other members.'
|
||||
},
|
||||
{
|
||||
key: 'joinVoice',
|
||||
label: 'Join Voice',
|
||||
description: 'Join voice channels.'
|
||||
},
|
||||
{
|
||||
key: 'shareScreen',
|
||||
label: 'Share Screen',
|
||||
description: 'Start screen sharing in voice channels.'
|
||||
},
|
||||
{
|
||||
key: 'uploadFiles',
|
||||
label: 'Upload Files',
|
||||
description: 'Upload attachments in chat.'
|
||||
}
|
||||
];
|
||||
@@ -0,0 +1,117 @@
|
||||
import {
|
||||
PermissionState,
|
||||
RoomPermissionKey,
|
||||
RoomPermissionMatrix,
|
||||
RoomRole,
|
||||
RoomRoleAssignment,
|
||||
ROOM_PERMISSION_KEYS
|
||||
} from '../../../shared-kernel';
|
||||
import type { MemberIdentity } from './access-control.models';
|
||||
|
||||
export function normalizeName(name: string): string {
|
||||
return name.trim().replace(/\s+/g, ' ');
|
||||
}
|
||||
|
||||
export function compareText(firstValue: string, secondValue: string): number {
|
||||
return firstValue.localeCompare(secondValue, undefined, { sensitivity: 'base' });
|
||||
}
|
||||
|
||||
export function uniqueStrings(values: readonly string[]): string[] {
|
||||
return Array.from(new Set(values.filter((value) => typeof value === 'string' && value.trim().length > 0).map((value) => value.trim())));
|
||||
}
|
||||
|
||||
export function normalizePermissionState(value: unknown): PermissionState {
|
||||
return value === 'allow' || value === 'deny' || value === 'inherit' ? value : 'inherit';
|
||||
}
|
||||
|
||||
export function normalizePermissionMatrix(matrix: RoomPermissionMatrix | undefined): RoomPermissionMatrix {
|
||||
const normalized: RoomPermissionMatrix = {};
|
||||
|
||||
for (const key of ROOM_PERMISSION_KEYS) {
|
||||
const value = normalizePermissionState(matrix?.[key]);
|
||||
|
||||
if (value !== 'inherit') {
|
||||
normalized[key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
return normalized;
|
||||
}
|
||||
|
||||
export function memberIdentityKey(identity: MemberIdentity | null | undefined): string {
|
||||
return identity?.oderId?.trim() || identity?.id?.trim() || '';
|
||||
}
|
||||
|
||||
export function matchesIdentity(identity: MemberIdentity | null | undefined, candidate: Pick<RoomRoleAssignment, 'userId' | 'oderId'>): boolean {
|
||||
if (!identity) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return !!(
|
||||
(identity.id && (candidate.userId === identity.id || candidate.oderId === identity.id)) ||
|
||||
(identity.oderId && (candidate.userId === identity.oderId || candidate.oderId === identity.oderId))
|
||||
);
|
||||
}
|
||||
|
||||
export function roleSortAscending(firstRole: RoomRole, secondRole: RoomRole): number {
|
||||
if (firstRole.position !== secondRole.position) {
|
||||
return firstRole.position - secondRole.position;
|
||||
}
|
||||
|
||||
return compareText(firstRole.name, secondRole.name);
|
||||
}
|
||||
|
||||
export function roleSortDescending(firstRole: RoomRole, secondRole: RoomRole): number {
|
||||
return roleSortAscending(secondRole, firstRole);
|
||||
}
|
||||
|
||||
export function permissionStateToBoolean(value: PermissionState | undefined, fallbackValue: boolean): boolean {
|
||||
if (value === 'allow') {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (value === 'deny') {
|
||||
return false;
|
||||
}
|
||||
|
||||
return fallbackValue;
|
||||
}
|
||||
|
||||
export function getRolePermissionState(role: RoomRole | undefined, permission: RoomPermissionKey): PermissionState {
|
||||
return normalizePermissionState(role?.permissions?.[permission]);
|
||||
}
|
||||
|
||||
export function buildSystemRole(id: string, name: string, position: number, permissions: RoomPermissionMatrix, color: string): RoomRole {
|
||||
return {
|
||||
id,
|
||||
name,
|
||||
position,
|
||||
color,
|
||||
isSystem: true,
|
||||
permissions: normalizePermissionMatrix(permissions)
|
||||
};
|
||||
}
|
||||
|
||||
export function buildRoleLookup(roles: readonly RoomRole[]): Map<string, RoomRole> {
|
||||
return new Map(roles.map((role) => [role.id, role]));
|
||||
}
|
||||
|
||||
export function nextRolePosition(roles: readonly RoomRole[]): number {
|
||||
if (roles.length === 0) {
|
||||
return 100;
|
||||
}
|
||||
|
||||
return Math.max(...roles.map((role) => role.position)) + 100;
|
||||
}
|
||||
|
||||
export function resolveLegacyAllowState(
|
||||
value: boolean | undefined,
|
||||
currentState: PermissionState | undefined,
|
||||
disabledState: Exclude<PermissionState, 'allow'>
|
||||
): PermissionState | undefined {
|
||||
if (value === undefined) {
|
||||
return currentState;
|
||||
}
|
||||
|
||||
return value ? 'allow' : disabledState;
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export * from './access-control.models';
|
||||
export * from './access-control.constants';
|
||||
export * from './role.rules';
|
||||
export * from './role-assignment.rules';
|
||||
export * from './permission.rules';
|
||||
export * from './room.rules';
|
||||
@@ -0,0 +1,13 @@
|
||||
import type {
|
||||
RoomMember,
|
||||
RoomPermissionKey,
|
||||
User
|
||||
} from '../../../shared-kernel';
|
||||
|
||||
export interface RoomPermissionDefinition {
|
||||
key: RoomPermissionKey;
|
||||
label: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
export type MemberIdentity = Pick<RoomMember, 'id' | 'oderId'> | Pick<User, 'id' | 'oderId'> | { id?: string; oderId?: string };
|
||||
@@ -0,0 +1,248 @@
|
||||
import {
|
||||
ChannelPermissionOverride,
|
||||
PermissionState,
|
||||
Room,
|
||||
RoomPermissionKey,
|
||||
RoomRole
|
||||
} from '../../../shared-kernel';
|
||||
import { SYSTEM_ROLE_IDS } from './access-control.constants';
|
||||
import type { MemberIdentity } from './access-control.models';
|
||||
import {
|
||||
buildRoleLookup,
|
||||
getRolePermissionState,
|
||||
matchesIdentity,
|
||||
normalizePermissionState,
|
||||
roleSortAscending,
|
||||
compareText
|
||||
} from './access-control.internal';
|
||||
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 './access-control.constants';
|
||||
import type { MemberIdentity } from './access-control.models';
|
||||
import {
|
||||
buildRoleLookup,
|
||||
compareText,
|
||||
memberIdentityKey,
|
||||
matchesIdentity,
|
||||
roleSortDescending,
|
||||
uniqueStrings
|
||||
} from './access-control.internal';
|
||||
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
|
||||
);
|
||||
}
|
||||
171
toju-app/src/app/domains/access-control/domain/role.rules.ts
Normal file
171
toju-app/src/app/domains/access-control/domain/role.rules.ts
Normal file
@@ -0,0 +1,171 @@
|
||||
import {
|
||||
RoomPermissionMatrix,
|
||||
RoomPermissions,
|
||||
RoomRole
|
||||
} from '../../../shared-kernel';
|
||||
import { SYSTEM_ROLE_IDS } from './access-control.constants';
|
||||
import {
|
||||
buildRoleLookup,
|
||||
buildSystemRole,
|
||||
nextRolePosition,
|
||||
normalizeName,
|
||||
normalizePermissionMatrix,
|
||||
roleSortAscending,
|
||||
roleSortDescending
|
||||
} from './access-control.internal';
|
||||
|
||||
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);
|
||||
}
|
||||
209
toju-app/src/app/domains/access-control/domain/room.rules.ts
Normal file
209
toju-app/src/app/domains/access-control/domain/room.rules.ts
Normal file
@@ -0,0 +1,209 @@
|
||||
import {
|
||||
PermissionState,
|
||||
Room,
|
||||
RoomMember,
|
||||
RoomPermissionKey,
|
||||
RoomPermissions,
|
||||
RoomRole,
|
||||
RoomRoleAssignment,
|
||||
UserRole
|
||||
} from '../../../shared-kernel';
|
||||
import { SYSTEM_ROLE_IDS } from './access-control.constants';
|
||||
import {
|
||||
getRolePermissionState,
|
||||
permissionStateToBoolean,
|
||||
resolveLegacyAllowState
|
||||
} from './access-control.internal';
|
||||
import type { MemberIdentity } from './access-control.models';
|
||||
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