Add access control rework

This commit is contained in:
2026-04-02 03:18:37 +02:00
parent 314a26325f
commit 37cac95b38
111 changed files with 5355 additions and 1892 deletions

View File

@@ -1,8 +1,11 @@
import { DataSource } from 'typeorm';
import {
ServerChannelPermissionEntity,
ServerChannelEntity,
ServerEntity,
ServerRoleEntity,
ServerTagEntity,
ServerUserRoleEntity,
JoinRequestEntity,
ServerMembershipEntity,
ServerInviteEntity,
@@ -16,6 +19,9 @@ export async function handleDeleteServer(command: DeleteServerCommand, dataSourc
await dataSource.transaction(async (manager) => {
await manager.getRepository(ServerTagEntity).delete({ serverId });
await manager.getRepository(ServerChannelEntity).delete({ serverId });
await manager.getRepository(ServerRoleEntity).delete({ serverId });
await manager.getRepository(ServerUserRoleEntity).delete({ serverId });
await manager.getRepository(ServerChannelPermissionEntity).delete({ serverId });
await manager.getRepository(JoinRequestEntity).delete({ serverId });
await manager.getRepository(ServerMembershipEntity).delete({ serverId });
await manager.getRepository(ServerInviteEntity).delete({ serverId });

View File

@@ -5,6 +5,7 @@ import { UpsertServerCommand } from '../../types';
export async function handleUpsertServer(command: UpsertServerCommand, dataSource: DataSource): Promise<void> {
const { server } = command.payload;
await dataSource.transaction(async (manager) => {
const repo = manager.getRepository(ServerEntity);
const entity = repo.create({
@@ -17,6 +18,7 @@ export async function handleUpsertServer(command: UpsertServerCommand, dataSourc
isPrivate: server.isPrivate ? 1 : 0,
maxUsers: server.maxUsers,
currentUsers: server.currentUsers,
slowModeInterval: server.slowModeInterval ?? 0,
createdAt: server.createdAt,
lastSeen: server.lastSeen
});
@@ -24,7 +26,10 @@ export async function handleUpsertServer(command: UpsertServerCommand, dataSourc
await repo.save(entity);
await replaceServerRelations(manager, server.id, {
tags: server.tags,
channels: server.channels ?? []
channels: server.channels ?? [],
roles: server.roles ?? [],
roleAssignments: server.roleAssignments ?? [],
channelPermissions: server.channelPermissions ?? []
});
});
}

View File

@@ -6,6 +6,7 @@ import {
ServerPayload,
JoinRequestPayload
} from './types';
import { relationRecordToServerPayload } from './relations';
export function rowToAuthUser(row: AuthUserEntity): AuthUserPayload {
return {
@@ -19,8 +20,22 @@ export function rowToAuthUser(row: AuthUserEntity): AuthUserPayload {
export function rowToServer(
row: ServerEntity,
relations: Pick<ServerPayload, 'tags' | 'channels'> = { tags: [], channels: [] }
relations: Pick<ServerPayload, 'tags' | 'channels' | 'roles' | 'roleAssignments' | 'channelPermissions'> = {
tags: [],
channels: [],
roles: [],
roleAssignments: [],
channelPermissions: []
}
): ServerPayload {
const relationPayload = relationRecordToServerPayload({ slowModeInterval: row.slowModeInterval }, {
tags: relations.tags ?? [],
channels: relations.channels ?? [],
roles: relations.roles ?? [],
roleAssignments: relations.roleAssignments ?? [],
channelPermissions: relations.channelPermissions ?? []
});
return {
id: row.id,
name: row.name,
@@ -32,8 +47,12 @@ export function rowToServer(
isPrivate: !!row.isPrivate,
maxUsers: row.maxUsers,
currentUsers: row.currentUsers,
tags: relations.tags ?? [],
channels: relations.channels ?? [],
slowModeInterval: relationPayload.slowModeInterval,
tags: relationPayload.tags,
channels: relationPayload.channels,
roles: relationPayload.roles,
roleAssignments: relationPayload.roleAssignments,
channelPermissions: relationPayload.channelPermissions,
createdAt: row.createdAt,
lastSeen: row.lastSeen
};

View File

@@ -5,13 +5,46 @@ import {
} from 'typeorm';
import {
ServerChannelEntity,
ServerTagEntity
ServerTagEntity,
ServerRoleEntity,
ServerUserRoleEntity,
ServerChannelPermissionEntity
} from '../entities';
import { ServerChannelPayload } from './types';
import {
AccessRolePayload,
ChannelPermissionPayload,
RoleAssignmentPayload,
ServerChannelPayload,
ServerPayload,
ServerPermissionKeyPayload,
PermissionStatePayload
} from './types';
const SERVER_PERMISSION_KEYS: ServerPermissionKeyPayload[] = [
'manageServer',
'manageRoles',
'manageChannels',
'manageIcon',
'kickMembers',
'banMembers',
'manageBans',
'deleteMessages',
'joinVoice',
'shareScreen',
'uploadFiles'
];
const SYSTEM_ROLE_IDS = {
everyone: 'system-everyone',
moderator: 'system-moderator',
admin: 'system-admin'
} as const;
interface ServerRelationRecord {
tags: string[];
channels: ServerChannelPayload[];
roles: AccessRolePayload[];
roleAssignments: RoleAssignmentPayload[];
channelPermissions: ChannelPermissionPayload[];
}
function normalizeChannelName(name: string): string {
@@ -22,16 +55,125 @@ function channelNameKey(type: ServerChannelPayload['type'], name: string): strin
return `${type}:${normalizeChannelName(name).toLocaleLowerCase()}`;
}
function compareText(firstValue: string, secondValue: string): number {
return firstValue.localeCompare(secondValue, undefined, { sensitivity: 'base' });
}
function isFiniteNumber(value: unknown): value is number {
return typeof value === 'number' && Number.isFinite(value);
}
function uniqueStrings(values: readonly string[] | undefined): string[] {
return Array.from(new Set((values ?? [])
.filter((value): value is string => typeof value === 'string' && value.trim().length > 0)
.map((value) => value.trim())));
}
function normalizePermissionState(value: unknown): PermissionStatePayload {
return value === 'allow' || value === 'deny' || value === 'inherit'
? value
: 'inherit';
}
function normalizePermissionMatrix(rawMatrix: unknown): Partial<Record<ServerPermissionKeyPayload, PermissionStatePayload>> {
const matrix = rawMatrix && typeof rawMatrix === 'object'
? rawMatrix as Record<string, unknown>
: {};
const normalized: Partial<Record<ServerPermissionKeyPayload, PermissionStatePayload>> = {};
for (const key of SERVER_PERMISSION_KEYS) {
const value = normalizePermissionState(matrix[key]);
if (value !== 'inherit') {
normalized[key] = value;
}
}
return normalized;
}
function buildDefaultServerRoles(): AccessRolePayload[] {
return [
{
id: SYSTEM_ROLE_IDS.everyone,
name: '@everyone',
color: '#6b7280',
position: 0,
isSystem: true,
permissions: {
joinVoice: 'allow',
shareScreen: 'allow',
uploadFiles: 'allow'
}
},
{
id: SYSTEM_ROLE_IDS.moderator,
name: 'Moderator',
color: '#10b981',
position: 200,
isSystem: true,
permissions: {
kickMembers: 'allow',
deleteMessages: 'allow'
}
},
{
id: SYSTEM_ROLE_IDS.admin,
name: 'Admin',
color: '#60a5fa',
position: 300,
isSystem: true,
permissions: {
kickMembers: 'allow',
banMembers: 'allow',
manageBans: 'allow',
deleteMessages: 'allow',
manageChannels: 'allow',
manageIcon: 'allow'
}
}
];
}
function normalizeServerRole(rawRole: Partial<AccessRolePayload>, fallbackRole?: AccessRolePayload): AccessRolePayload | null {
const id = typeof rawRole.id === 'string' ? rawRole.id.trim() : fallbackRole?.id ?? '';
const name = typeof rawRole.name === 'string' ? rawRole.name.trim().replace(/\s+/g, ' ') : fallbackRole?.name ?? '';
if (!id || !name) {
return null;
}
return {
id,
name,
color: typeof rawRole.color === 'string' && rawRole.color.trim() ? rawRole.color.trim() : fallbackRole?.color,
position: isFiniteNumber(rawRole.position) ? rawRole.position : fallbackRole?.position ?? 0,
isSystem: typeof rawRole.isSystem === 'boolean' ? rawRole.isSystem : fallbackRole?.isSystem,
permissions: normalizePermissionMatrix(rawRole.permissions ?? fallbackRole?.permissions)
};
}
function compareRoles(firstRole: AccessRolePayload, secondRole: AccessRolePayload): number {
if (firstRole.position !== secondRole.position) {
return firstRole.position - secondRole.position;
}
return compareText(firstRole.name, secondRole.name);
}
function compareAssignments(firstAssignment: RoleAssignmentPayload, secondAssignment: RoleAssignmentPayload): number {
return compareText(firstAssignment.oderId || firstAssignment.userId, secondAssignment.oderId || secondAssignment.userId);
}
export function normalizeServerTags(rawTags: unknown): string[] {
if (!Array.isArray(rawTags)) {
return [];
}
return rawTags.filter((tag): tag is string => typeof tag === 'string');
return rawTags
.filter((tag): tag is string => typeof tag === 'string')
.map((tag) => tag.trim())
.filter(Boolean);
}
export function normalizeServerChannels(rawChannels: unknown): ServerChannelPayload[] {
@@ -72,19 +214,169 @@ export function normalizeServerChannels(rawChannels: unknown): ServerChannelPayl
return channels;
}
export function normalizeServerRoles(rawRoles: unknown): AccessRolePayload[] {
const rolesById = new Map<string, AccessRolePayload>();
if (Array.isArray(rawRoles)) {
for (const rawRole of rawRoles) {
if (!rawRole || typeof rawRole !== 'object') {
continue;
}
const normalizedRole = normalizeServerRole(rawRole as Record<string, unknown>);
if (normalizedRole) {
rolesById.set(normalizedRole.id, normalizedRole);
}
}
}
for (const defaultRole of buildDefaultServerRoles()) {
const mergedRole = normalizeServerRole(rolesById.get(defaultRole.id) ?? defaultRole, defaultRole) ?? defaultRole;
rolesById.set(defaultRole.id, mergedRole);
}
return Array.from(rolesById.values()).sort(compareRoles);
}
export function normalizeServerRoleAssignments(rawAssignments: unknown, roles: readonly AccessRolePayload[]): RoleAssignmentPayload[] {
const validRoleIds = new Set(roles.map((role) => role.id).filter((roleId) => roleId !== SYSTEM_ROLE_IDS.everyone));
const assignmentsByKey = new Map<string, RoleAssignmentPayload>();
if (!Array.isArray(rawAssignments)) {
return [];
}
for (const rawAssignment of rawAssignments) {
if (!rawAssignment || typeof rawAssignment !== 'object') {
continue;
}
const assignment = rawAssignment as Record<string, unknown>;
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(Array.isArray(assignment['roleIds']) ? assignment['roleIds'] as string[] : undefined)
.filter((roleId) => validRoleIds.has(roleId));
if (roleIds.length === 0) {
continue;
}
assignmentsByKey.set(key, {
userId: userId || key,
oderId,
roleIds
});
}
return Array.from(assignmentsByKey.values()).sort(compareAssignments);
}
export function normalizeServerChannelPermissions(
rawChannelPermissions: unknown,
roles: readonly AccessRolePayload[]
): ChannelPermissionPayload[] {
if (!Array.isArray(rawChannelPermissions)) {
return [];
}
const validRoleIds = new Set(roles.map((role) => role.id));
const overridesByKey = new Map<string, ChannelPermissionPayload>();
for (const rawOverride of rawChannelPermissions) {
if (!rawOverride || typeof rawOverride !== 'object') {
continue;
}
const override = rawOverride as Record<string, unknown>;
const channelId = typeof override['channelId'] === 'string' ? override['channelId'].trim() : '';
const targetType = override['targetType'] === 'role' || override['targetType'] === 'user' ? override['targetType'] : null;
const targetId = typeof override['targetId'] === 'string' ? override['targetId'].trim() : '';
const permission = SERVER_PERMISSION_KEYS.find((key) => key === override['permission']);
const value = normalizePermissionState(override['value']);
if (!channelId || !targetType || !targetId || !permission || value === 'inherit') {
continue;
}
if (targetType === 'role' && !validRoleIds.has(targetId)) {
continue;
}
const key = `${channelId}:${targetType}:${targetId}:${permission}`;
overridesByKey.set(key, {
channelId,
targetType,
targetId,
permission,
value
});
}
return Array.from(overridesByKey.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 async function replaceServerRelations(
manager: EntityManager,
serverId: string,
options: { tags: unknown; channels: unknown }
options: {
tags: unknown;
channels: unknown;
roles?: unknown;
roleAssignments?: unknown;
channelPermissions?: unknown;
}
): Promise<void> {
const tagRepo = manager.getRepository(ServerTagEntity);
const channelRepo = manager.getRepository(ServerChannelEntity);
const roleRepo = manager.getRepository(ServerRoleEntity);
const userRoleRepo = manager.getRepository(ServerUserRoleEntity);
const channelPermissionRepo = manager.getRepository(ServerChannelPermissionEntity);
const tags = normalizeServerTags(options.tags);
const channels = normalizeServerChannels(options.channels);
const roles = options.roles !== undefined ? normalizeServerRoles(options.roles) : [];
await tagRepo.delete({ serverId });
await channelRepo.delete({ serverId });
if (options.roles !== undefined) {
await roleRepo.delete({ serverId });
}
if (options.roleAssignments !== undefined) {
await userRoleRepo.delete({ serverId });
}
if (options.channelPermissions !== undefined) {
await channelPermissionRepo.delete({ serverId });
}
if (tags.length > 0) {
await tagRepo.insert(
tags.map((tag, position) => ({
@@ -106,6 +398,66 @@ export async function replaceServerRelations(
}))
);
}
if (options.roles !== undefined && roles.length > 0) {
await roleRepo.insert(
roles.map((role) => ({
serverId,
roleId: role.id,
name: role.name,
color: role.color ?? null,
position: role.position,
isSystem: role.isSystem ? 1 : 0,
manageServer: normalizePermissionState(role.permissions?.manageServer),
manageRoles: normalizePermissionState(role.permissions?.manageRoles),
manageChannels: normalizePermissionState(role.permissions?.manageChannels),
manageIcon: normalizePermissionState(role.permissions?.manageIcon),
kickMembers: normalizePermissionState(role.permissions?.kickMembers),
banMembers: normalizePermissionState(role.permissions?.banMembers),
manageBans: normalizePermissionState(role.permissions?.manageBans),
deleteMessages: normalizePermissionState(role.permissions?.deleteMessages),
joinVoice: normalizePermissionState(role.permissions?.joinVoice),
shareScreen: normalizePermissionState(role.permissions?.shareScreen),
uploadFiles: normalizePermissionState(role.permissions?.uploadFiles)
}))
);
}
if (options.roleAssignments !== undefined) {
const roleAssignments = normalizeServerRoleAssignments(options.roleAssignments, roles.length > 0 ? roles : normalizeServerRoles([]));
const rows = roleAssignments.flatMap((assignment) =>
assignment.roleIds.map((roleId) => ({
serverId,
userId: assignment.userId,
roleId,
oderId: assignment.oderId ?? null
}))
);
if (rows.length > 0) {
await userRoleRepo.insert(rows);
}
}
if (options.channelPermissions !== undefined) {
const channelPermissions = normalizeServerChannelPermissions(
options.channelPermissions,
roles.length > 0 ? roles : normalizeServerRoles([])
);
if (channelPermissions.length > 0) {
await channelPermissionRepo.insert(
channelPermissions.map((channelPermission) => ({
serverId,
channelId: channelPermission.channelId,
targetType: channelPermission.targetType,
targetId: channelPermission.targetId,
permission: channelPermission.permission,
value: channelPermission.value
}))
);
}
}
}
export async function loadServerRelationsMap(
@@ -118,43 +470,134 @@ export async function loadServerRelationsMap(
return groupedRelations;
}
const [tagRows, channelRows] = await Promise.all([
const [
tagRows,
channelRows,
roleRows,
userRoleRows,
channelPermissionRows
] = await Promise.all([
dataSource.getRepository(ServerTagEntity).find({
where: { serverId: In([...serverIds]) }
}),
dataSource.getRepository(ServerChannelEntity).find({
where: { serverId: In([...serverIds]) }
}),
dataSource.getRepository(ServerRoleEntity).find({
where: { serverId: In([...serverIds]) }
}),
dataSource.getRepository(ServerUserRoleEntity).find({
where: { serverId: In([...serverIds]) }
}),
dataSource.getRepository(ServerChannelPermissionEntity).find({
where: { serverId: In([...serverIds]) }
})
]);
for (const row of tagRows) {
const relation = groupedRelations.get(row.serverId) ?? { tags: [], channels: [] };
for (const serverId of serverIds) {
groupedRelations.set(serverId, {
tags: [],
channels: [],
roles: [],
roleAssignments: [],
channelPermissions: []
});
}
relation.tags.push(row.value);
groupedRelations.set(row.serverId, relation);
for (const row of tagRows) {
groupedRelations.get(row.serverId)?.tags.push(row.value);
}
for (const row of channelRows) {
const relation = groupedRelations.get(row.serverId) ?? { tags: [], channels: [] };
relation.channels.push({
groupedRelations.get(row.serverId)?.channels.push({
id: row.channelId,
name: row.name,
type: row.type,
position: row.position
});
groupedRelations.set(row.serverId, relation);
}
for (const row of roleRows) {
groupedRelations.get(row.serverId)?.roles.push({
id: row.roleId,
name: row.name,
color: row.color ?? undefined,
position: row.position,
isSystem: !!row.isSystem,
permissions: normalizePermissionMatrix({
manageServer: row.manageServer,
manageRoles: row.manageRoles,
manageChannels: row.manageChannels,
manageIcon: row.manageIcon,
kickMembers: row.kickMembers,
banMembers: row.banMembers,
manageBans: row.manageBans,
deleteMessages: row.deleteMessages,
joinVoice: row.joinVoice,
shareScreen: row.shareScreen,
uploadFiles: row.uploadFiles
})
});
}
for (const row of userRoleRows) {
const relation = groupedRelations.get(row.serverId);
if (!relation) {
continue;
}
const existing = relation.roleAssignments.find((assignment) => assignment.userId === row.userId || assignment.oderId === row.oderId);
if (existing) {
existing.roleIds = uniqueStrings([...existing.roleIds, row.roleId]);
continue;
}
relation.roleAssignments.push({
userId: row.userId,
oderId: row.oderId ?? undefined,
roleIds: [row.roleId]
});
}
for (const row of channelPermissionRows) {
groupedRelations.get(row.serverId)?.channelPermissions.push({
channelId: row.channelId,
targetType: row.targetType,
targetId: row.targetId,
permission: row.permission as ServerPermissionKeyPayload,
value: normalizePermissionState(row.value)
});
}
for (const [serverId, relation] of groupedRelations) {
const orderedTags = tagRows
relation.tags = tagRows
.filter((row) => row.serverId === serverId)
.sort((first, second) => first.position - second.position)
.sort((firstTag, secondTag) => firstTag.position - secondTag.position)
.map((row) => row.value);
relation.tags = orderedTags;
relation.channels.sort((first, second) => first.position - second.position || first.name.localeCompare(second.name));
relation.channels.sort(
(firstChannel, secondChannel) => firstChannel.position - secondChannel.position || compareText(firstChannel.name, secondChannel.name)
);
relation.roles.sort(compareRoles);
relation.roleAssignments.sort(compareAssignments);
}
return groupedRelations;
}
}
export function relationRecordToServerPayload(
row: Pick<ServerPayload, 'slowModeInterval'>,
relations: ServerRelationRecord
): Pick<ServerPayload, 'tags' | 'channels' | 'roles' | 'roleAssignments' | 'channelPermissions' | 'slowModeInterval'> {
return {
tags: relations.tags,
channels: relations.channels,
roles: relations.roles,
roleAssignments: relations.roleAssignments,
channelPermissions: relations.channelPermissions,
slowModeInterval: row.slowModeInterval ?? 0
};
}

View File

@@ -37,6 +37,44 @@ export interface ServerChannelPayload {
position: number;
}
export type PermissionStatePayload = 'allow' | 'deny' | 'inherit';
export type ServerPermissionKeyPayload =
| 'manageServer'
| 'manageRoles'
| 'manageChannels'
| 'manageIcon'
| 'kickMembers'
| 'banMembers'
| 'manageBans'
| 'deleteMessages'
| 'joinVoice'
| 'shareScreen'
| 'uploadFiles';
export interface AccessRolePayload {
id: string;
name: string;
color?: string;
position: number;
isSystem?: boolean;
permissions?: Partial<Record<ServerPermissionKeyPayload, PermissionStatePayload>>;
}
export interface RoleAssignmentPayload {
userId: string;
oderId?: string;
roleIds: string[];
}
export interface ChannelPermissionPayload {
channelId: string;
targetType: 'role' | 'user';
targetId: string;
permission: ServerPermissionKeyPayload;
value: PermissionStatePayload;
}
export interface ServerPayload {
id: string;
name: string;
@@ -48,8 +86,12 @@ export interface ServerPayload {
isPrivate: boolean;
maxUsers: number;
currentUsers: number;
slowModeInterval?: number;
tags: string[];
channels: ServerChannelPayload[];
roles?: AccessRolePayload[];
roleAssignments?: RoleAssignmentPayload[];
channelPermissions?: ChannelPermissionPayload[];
createdAt: number;
lastSeen: number;
}