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

@@ -0,0 +1,191 @@
import type {
AccessRolePayload,
PermissionStatePayload,
RoleAssignmentPayload,
ServerPayload,
ServerPermissionKeyPayload
} from '../cqrs/types';
import { normalizeServerRoleAssignments, normalizeServerRoles } from '../cqrs/relations';
const SYSTEM_ROLE_IDS = {
everyone: 'system-everyone'
} as const;
interface ServerIdentity {
userId: string;
oderId?: string;
}
function getServerRoles(server: Pick<ServerPayload, 'roles'>): AccessRolePayload[] {
return normalizeServerRoles(server.roles);
}
function getServerAssignments(server: Pick<ServerPayload, 'roleAssignments' | 'roles'>): RoleAssignmentPayload[] {
return normalizeServerRoleAssignments(server.roleAssignments, getServerRoles(server));
}
function matchesIdentity(identity: ServerIdentity, assignment: RoleAssignmentPayload): boolean {
return assignment.userId === identity.userId
|| assignment.oderId === identity.userId
|| (!!identity.oderId && (assignment.userId === identity.oderId || assignment.oderId === identity.oderId));
}
function resolveAssignedRoleIds(server: Pick<ServerPayload, 'roleAssignments' | 'roles'>, identity: ServerIdentity): string[] {
const assignment = getServerAssignments(server).find((candidateAssignment) => matchesIdentity(identity, candidateAssignment));
return assignment?.roleIds ?? [];
}
function compareRolePosition(firstRole: AccessRolePayload, secondRole: AccessRolePayload): number {
if (firstRole.position !== secondRole.position) {
return firstRole.position - secondRole.position;
}
return firstRole.name.localeCompare(secondRole.name, undefined, { sensitivity: 'base' });
}
function resolveRolePermissionState(
roles: readonly AccessRolePayload[],
assignedRoleIds: readonly string[],
permission: ServerPermissionKeyPayload
): PermissionStatePayload {
const roleLookup = new Map(roles.map((role) => [role.id, role]));
const effectiveRoles = [roleLookup.get(SYSTEM_ROLE_IDS.everyone), ...assignedRoleIds.map((roleId) => roleLookup.get(roleId))]
.filter((role): role is AccessRolePayload => !!role)
.sort(compareRolePosition);
let state: PermissionStatePayload = 'inherit';
for (const role of effectiveRoles) {
const nextState = role.permissions?.[permission] ?? 'inherit';
if (nextState !== 'inherit') {
state = nextState;
}
}
return state;
}
function resolveHighestRole(
server: Pick<ServerPayload, 'roleAssignments' | 'roles'>,
identity: ServerIdentity
): AccessRolePayload | null {
const roles = getServerRoles(server);
const assignedRoleIds = resolveAssignedRoleIds(server, identity);
const roleLookup = new Map(roles.map((role) => [role.id, role]));
const assignedRoles = assignedRoleIds
.map((roleId) => roleLookup.get(roleId))
.filter((role): role is AccessRolePayload => !!role)
.sort((firstRole, secondRole) => compareRolePosition(secondRole, firstRole));
return assignedRoles[0] ?? roleLookup.get(SYSTEM_ROLE_IDS.everyone) ?? null;
}
export function isServerOwner(server: Pick<ServerPayload, 'ownerId'>, actorUserId: string): boolean {
return server.ownerId === actorUserId;
}
export function resolveServerPermission(
server: Pick<ServerPayload, 'ownerId' | 'roleAssignments' | 'roles'>,
actorUserId: string,
permission: ServerPermissionKeyPayload,
actorOderId?: string
): boolean {
if (isServerOwner(server, actorUserId)) {
return true;
}
const roles = getServerRoles(server);
const assignedRoleIds = resolveAssignedRoleIds(server, {
userId: actorUserId,
oderId: actorOderId
});
return resolveRolePermissionState(roles, assignedRoleIds, permission) === 'allow';
}
export function canManageServerUpdate(
server: Pick<ServerPayload, 'ownerId' | 'roleAssignments' | 'roles'>,
actorUserId: string,
updates: Record<string, unknown>,
actorOderId?: string
): boolean {
if (isServerOwner(server, actorUserId)) {
return true;
}
if (typeof updates['ownerId'] === 'string' || typeof updates['ownerPublicKey'] === 'string') {
return false;
}
const requiredPermissions = new Set<ServerPermissionKeyPayload>();
if (
Array.isArray(updates['roles'])
|| Array.isArray(updates['roleAssignments'])
|| Array.isArray(updates['channelPermissions'])
) {
requiredPermissions.add('manageRoles');
}
if (Array.isArray(updates['channels'])) {
requiredPermissions.add('manageChannels');
}
if (typeof updates['icon'] === 'string') {
requiredPermissions.add('manageIcon');
}
if (
typeof updates['name'] === 'string'
|| typeof updates['description'] === 'string'
|| typeof updates['isPrivate'] === 'boolean'
|| typeof updates['maxUsers'] === 'number'
|| typeof updates['password'] === 'string'
|| typeof updates['passwordHash'] === 'string'
|| typeof updates['slowModeInterval'] === 'number'
) {
requiredPermissions.add('manageServer');
}
return Array.from(requiredPermissions).every((permission) =>
resolveServerPermission(server, actorUserId, permission, actorOderId)
);
}
export function canModerateServerMember(
server: Pick<ServerPayload, 'ownerId' | 'roleAssignments' | 'roles'>,
actorUserId: string,
targetUserId: string,
permission: 'kickMembers' | 'banMembers' | 'manageBans',
actorOderId?: string,
targetOderId?: string
): boolean {
if (!actorUserId || !targetUserId || actorUserId === targetUserId) {
return false;
}
if (isServerOwner(server, targetUserId) && !isServerOwner(server, actorUserId)) {
return false;
}
if (isServerOwner(server, actorUserId)) {
return true;
}
if (!resolveServerPermission(server, actorUserId, permission, actorOderId)) {
return false;
}
const actorRole = resolveHighestRole(server, {
userId: actorUserId,
oderId: actorOderId
});
const targetRole = resolveHighestRole(server, {
userId: targetUserId,
oderId: targetOderId
});
return (actorRole?.position ?? 0) > (targetRole?.position ?? 0);
}