mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-11 05:02:24 +09:00
397 lines
13 KiB
TypeScript
397 lines
13 KiB
TypeScript
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
|
|
import {ADMIN_AUDIT_READ_ACTIONS, getAdminAuditAccess} from '@app/api/admin/AdminAuditActions';
|
|
import type {AdminAuditLog, IAdminRepository} from '@app/api/admin/IAdminRepository';
|
|
import type {UserID} from '@app/api/BrandedTypes';
|
|
import {createChannelID, createGuildID, createUserID} from '@app/api/BrandedTypes';
|
|
import type {IChannelRepository} from '@app/api/channel/IChannelRepository';
|
|
import type {IGuildRepositoryAggregate} from '@app/api/guild/repositories/IGuildRepositoryAggregate';
|
|
import type {ISnowflakeService} from '@app/api/infrastructure/ISnowflakeService';
|
|
import {Logger} from '@app/api/Logger';
|
|
import type {Channel} from '@app/api/models/Channel';
|
|
import type {Guild} from '@app/api/models/Guild';
|
|
import type {User} from '@app/api/models/User';
|
|
import {getAuditLogSearchService} from '@app/api/SearchFactory';
|
|
import type {IUserRepository} from '@app/api/user/IUserRepository';
|
|
import type {AuditLogSearchFilters} from '@fluxer/schema/src/contracts/search/SearchDocumentTypes';
|
|
import type {
|
|
AdminAuditAccess,
|
|
AdminAuditLogChannelSummary,
|
|
AdminAuditLogGuildSummary,
|
|
AdminAuditLogResponse,
|
|
AdminAuditLogUserSummary,
|
|
AuditLogsListResponse,
|
|
} from '@fluxer/schema/src/domains/admin/AdminSchemas';
|
|
|
|
interface CreateAdminAuditLogParams {
|
|
adminUserId: UserID;
|
|
targetType: string;
|
|
targetId: bigint;
|
|
action: string;
|
|
auditLogReason: string | null;
|
|
metadata?: Map<string, string>;
|
|
}
|
|
|
|
export class AdminAuditService {
|
|
constructor(
|
|
private readonly adminRepository: IAdminRepository,
|
|
private readonly snowflakeService: ISnowflakeService,
|
|
private readonly enrichmentDeps: AuditLogEnrichmentDeps = {},
|
|
) {}
|
|
|
|
async createAuditLog({
|
|
adminUserId,
|
|
targetType,
|
|
targetId,
|
|
action,
|
|
auditLogReason,
|
|
metadata = new Map(),
|
|
}: CreateAdminAuditLogParams): Promise<void> {
|
|
const log = await this.adminRepository.createAuditLog({
|
|
log_id: await this.snowflakeService.generate(),
|
|
admin_user_id: adminUserId,
|
|
target_type: targetType,
|
|
target_id: targetId,
|
|
action,
|
|
audit_log_reason: auditLogReason ?? null,
|
|
metadata,
|
|
created_at: new Date(),
|
|
});
|
|
const auditLogSearchService = getAuditLogSearchService();
|
|
if (auditLogSearchService) {
|
|
auditLogSearchService.indexAuditLog(log).catch((error) => {
|
|
Logger.error({error, logId: log.logId}, 'Failed to index audit log to search');
|
|
});
|
|
}
|
|
}
|
|
|
|
async getAuditLog(logId: bigint): Promise<AdminAuditLogResponse | null> {
|
|
const log = await this.adminRepository.getAuditLog(logId);
|
|
if (!log) {
|
|
return null;
|
|
}
|
|
const [response] = await this.toResponses([log]);
|
|
return response;
|
|
}
|
|
|
|
async listAuditLogs(data: {
|
|
admin_user_id?: bigint;
|
|
target_type?: string;
|
|
target_id?: string;
|
|
access?: AdminAuditAccess;
|
|
limit?: number;
|
|
offset?: number;
|
|
}): Promise<AuditLogsListResponse> {
|
|
const auditLogSearchService = getAuditLogSearchService();
|
|
const targetIdBigInt = data.target_id ? BigInt(data.target_id) : undefined;
|
|
if (!auditLogSearchService?.isAvailable()) {
|
|
return this.listAuditLogsFromDatabase({
|
|
adminUserId: data.admin_user_id,
|
|
targetType: data.target_type,
|
|
targetId: targetIdBigInt,
|
|
access: data.access,
|
|
limit: data.limit,
|
|
offset: data.offset,
|
|
});
|
|
}
|
|
const limit = data.limit || 50;
|
|
const filters: AuditLogSearchFilters = {...accessFilters(data.access)};
|
|
if (data.admin_user_id) {
|
|
filters.adminUserId = data.admin_user_id.toString();
|
|
}
|
|
if (data.target_type) {
|
|
filters.targetType = data.target_type;
|
|
}
|
|
if (data.target_id) {
|
|
filters.targetId = data.target_id;
|
|
}
|
|
const {hits, total} = await auditLogSearchService.searchAuditLogs('', filters, {
|
|
limit,
|
|
offset: data.offset || 0,
|
|
});
|
|
const orderedLogs = await this.loadLogsInSearchOrder(hits.map((hit) => BigInt(hit.logId)));
|
|
return {
|
|
logs: await this.toResponses(orderedLogs),
|
|
total,
|
|
};
|
|
}
|
|
|
|
async searchAuditLogs(data: {
|
|
query?: string;
|
|
admin_user_id?: bigint;
|
|
target_type?: string;
|
|
target_id?: string;
|
|
access?: AdminAuditAccess;
|
|
sort_by?: 'createdAt' | 'relevance';
|
|
sort_order?: 'asc' | 'desc';
|
|
limit?: number;
|
|
offset?: number;
|
|
}): Promise<AuditLogsListResponse> {
|
|
const auditLogSearchService = getAuditLogSearchService();
|
|
const targetIdBigInt = data.target_id ? BigInt(data.target_id) : undefined;
|
|
if (!auditLogSearchService?.isAvailable()) {
|
|
return this.listAuditLogsFromDatabase({
|
|
adminUserId: data.admin_user_id,
|
|
targetType: data.target_type,
|
|
targetId: targetIdBigInt,
|
|
access: data.access,
|
|
limit: data.limit,
|
|
offset: data.offset,
|
|
});
|
|
}
|
|
const filters: AuditLogSearchFilters = {...accessFilters(data.access)};
|
|
if (data.admin_user_id) {
|
|
filters.adminUserId = data.admin_user_id.toString();
|
|
}
|
|
if (data.target_id) {
|
|
filters.targetId = data.target_id;
|
|
}
|
|
if (data.target_type) {
|
|
filters.targetType = data.target_type;
|
|
}
|
|
if (data.sort_by) {
|
|
filters.sortBy = data.sort_by;
|
|
}
|
|
if (data.sort_order) {
|
|
filters.sortOrder = data.sort_order;
|
|
}
|
|
const {hits, total} = await auditLogSearchService.searchAuditLogs(data.query || '', filters, {
|
|
limit: data.limit || 50,
|
|
offset: data.offset || 0,
|
|
});
|
|
const orderedLogs = await this.loadLogsInSearchOrder(hits.map((hit) => BigInt(hit.logId)));
|
|
return {
|
|
logs: await this.toResponses(orderedLogs),
|
|
total,
|
|
};
|
|
}
|
|
|
|
private async listAuditLogsFromDatabase(data: {
|
|
adminUserId?: bigint;
|
|
targetType?: string;
|
|
targetId?: bigint;
|
|
access?: AdminAuditAccess;
|
|
limit?: number;
|
|
offset?: number;
|
|
}): Promise<AuditLogsListResponse> {
|
|
const limit = data.limit || 50;
|
|
const allLogs = await this.adminRepository.listAllAuditLogsPaginated(limit + (data.offset || 0));
|
|
let filteredLogs = allLogs;
|
|
if (data.adminUserId) {
|
|
filteredLogs = filteredLogs.filter((log) => log.adminUserId === data.adminUserId);
|
|
}
|
|
if (data.targetType) {
|
|
filteredLogs = filteredLogs.filter((log) => log.targetType === data.targetType);
|
|
}
|
|
if (data.targetId) {
|
|
filteredLogs = filteredLogs.filter((log) => log.targetId === data.targetId);
|
|
}
|
|
if (data.access) {
|
|
filteredLogs = filteredLogs.filter((log) => getAdminAuditAccess(log.action) === data.access);
|
|
}
|
|
const offset = data.offset || 0;
|
|
const paginatedLogs = filteredLogs.slice(offset, offset + limit);
|
|
return {
|
|
logs: await this.toResponses(paginatedLogs),
|
|
total: filteredLogs.length,
|
|
};
|
|
}
|
|
|
|
private async loadLogsInSearchOrder(logIds: Array<bigint>): Promise<Array<AdminAuditLog>> {
|
|
const logs = await this.adminRepository.listAuditLogsByIds(logIds);
|
|
const logMap = new Map(logs.map((log) => [log.logId.toString(), log]));
|
|
const result: Array<AdminAuditLog> = [];
|
|
for (const logId of logIds) {
|
|
const log = logMap.get(logId.toString());
|
|
if (log) {
|
|
result.push(log);
|
|
}
|
|
}
|
|
return result;
|
|
}
|
|
|
|
private async toResponses(logs: Array<AdminAuditLog>): Promise<Array<AdminAuditLogResponse>> {
|
|
const enrichment = await this.loadEnrichment(logs);
|
|
return logs.map((log) => this.toResponse(log, enrichment));
|
|
}
|
|
|
|
private toResponse(log: AdminAuditLog, enrichment: AuditLogEnrichment): AdminAuditLogResponse {
|
|
const adminUserId = log.adminUserId.toString();
|
|
const targetId = log.targetId.toString();
|
|
const targetUser = this.targetUserForLog(log, enrichment);
|
|
const targetGuild = log.targetType === 'guild' ? enrichment.guilds.get(targetId) : null;
|
|
const targetChannel = this.targetChannelForLog(log, enrichment);
|
|
return {
|
|
log_id: log.logId.toString(),
|
|
admin_user_id: adminUserId,
|
|
admin_user: mapUserSummary(enrichment.users.get(adminUserId) ?? null),
|
|
target_type: log.targetType,
|
|
target_id: targetId,
|
|
target_user: mapUserSummary(targetUser),
|
|
target_guild: mapGuildSummary(targetGuild ?? null),
|
|
target_channel: mapChannelSummary(targetChannel ?? null),
|
|
related_users: Object.fromEntries(
|
|
[...enrichment.users.entries()].map(([id, user]) => [id, mapUserSummary(user)!]),
|
|
),
|
|
related_guilds: Object.fromEntries(
|
|
[...enrichment.guilds.entries()].map(([id, guild]) => [id, mapGuildSummary(guild)!]),
|
|
),
|
|
related_channels: Object.fromEntries(
|
|
[...enrichment.channels.entries()].map(([id, channel]) => [id, mapChannelSummary(channel)!]),
|
|
),
|
|
action: log.action,
|
|
access: getAdminAuditAccess(log.action),
|
|
audit_log_reason: log.auditLogReason,
|
|
metadata: Object.fromEntries(log.metadata),
|
|
created_at: log.createdAt.toISOString(),
|
|
};
|
|
}
|
|
|
|
private targetUserForLog(log: AdminAuditLog, enrichment: AuditLogEnrichment): User | null {
|
|
const targetId = log.targetId.toString();
|
|
if (USER_TARGET_TYPES.has(log.targetType)) {
|
|
return enrichment.users.get(targetId) ?? null;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
private targetChannelForLog(log: AdminAuditLog, enrichment: AuditLogEnrichment): Channel | null {
|
|
const targetId = log.targetId.toString();
|
|
if (log.targetType === 'channel') {
|
|
return enrichment.channels.get(targetId) ?? null;
|
|
}
|
|
const metadataChannelId = log.metadata.get('channel_id');
|
|
return metadataChannelId ? (enrichment.channels.get(metadataChannelId) ?? null) : null;
|
|
}
|
|
|
|
private async loadEnrichment(logs: Array<AdminAuditLog>): Promise<AuditLogEnrichment> {
|
|
const userIds = new Set<string>();
|
|
const guildIds = new Set<string>();
|
|
const channelIds = new Set<string>();
|
|
|
|
for (const log of logs) {
|
|
userIds.add(log.adminUserId.toString());
|
|
const targetId = log.targetId.toString();
|
|
if (USER_TARGET_TYPES.has(log.targetType)) userIds.add(targetId);
|
|
if (log.targetType === 'guild') guildIds.add(targetId);
|
|
if (log.targetType === 'channel') channelIds.add(targetId);
|
|
|
|
for (const [key, value] of log.metadata) {
|
|
if (isSnowflakeString(value)) {
|
|
if (isUserIdKey(key)) userIds.add(value);
|
|
if (isGuildIdKey(key)) guildIds.add(value);
|
|
if (isChannelIdKey(key)) channelIds.add(value);
|
|
}
|
|
}
|
|
}
|
|
|
|
const [users, guilds, channels] = await Promise.all([
|
|
this.loadUsers(userIds),
|
|
this.loadGuilds(guildIds),
|
|
this.loadChannels(channelIds),
|
|
]);
|
|
return {users, guilds, channels};
|
|
}
|
|
|
|
private async loadUsers(ids: Set<string>): Promise<Map<string, User>> {
|
|
const {userRepository} = this.enrichmentDeps;
|
|
if (!userRepository) return new Map();
|
|
const entries = await Promise.all(
|
|
[...ids].map(async (id) => {
|
|
const user = await userRepository.findUnique(createUserID(BigInt(id)));
|
|
return [id, user] as const;
|
|
}),
|
|
);
|
|
return new Map(entries.filter((entry): entry is readonly [string, User] => entry[1] !== null));
|
|
}
|
|
|
|
private async loadGuilds(ids: Set<string>): Promise<Map<string, Guild>> {
|
|
const {guildRepository} = this.enrichmentDeps;
|
|
if (!guildRepository) return new Map();
|
|
const entries = await Promise.all(
|
|
[...ids].map(async (id) => {
|
|
const guild = await guildRepository.findUnique(createGuildID(BigInt(id)));
|
|
return [id, guild] as const;
|
|
}),
|
|
);
|
|
return new Map(entries.filter((entry): entry is readonly [string, Guild] => entry[1] !== null));
|
|
}
|
|
|
|
private async loadChannels(ids: Set<string>): Promise<Map<string, Channel>> {
|
|
const {channelRepository} = this.enrichmentDeps;
|
|
if (!channelRepository) return new Map();
|
|
const entries = await Promise.all(
|
|
[...ids].map(async (id) => {
|
|
const channel = await channelRepository.findUnique(createChannelID(BigInt(id)));
|
|
return [id, channel] as const;
|
|
}),
|
|
);
|
|
return new Map(entries.filter((entry): entry is readonly [string, Channel] => entry[1] !== null));
|
|
}
|
|
}
|
|
|
|
function accessFilters(access: AdminAuditAccess | undefined): AuditLogSearchFilters {
|
|
if (access === 'read') return {actions: [...ADMIN_AUDIT_READ_ACTIONS]};
|
|
if (access === 'write') return {excludeActions: [...ADMIN_AUDIT_READ_ACTIONS]};
|
|
return {};
|
|
}
|
|
|
|
interface AuditLogEnrichmentDeps {
|
|
userRepository?: Pick<IUserRepository, 'findUnique'>;
|
|
guildRepository?: Pick<IGuildRepositoryAggregate, 'findUnique'>;
|
|
channelRepository?: Pick<IChannelRepository, 'findUnique'>;
|
|
}
|
|
|
|
interface AuditLogEnrichment {
|
|
users: Map<string, User>;
|
|
guilds: Map<string, Guild>;
|
|
channels: Map<string, Channel>;
|
|
}
|
|
|
|
const USER_TARGET_TYPES = new Set(['user', 'guild_member', 'message_deletion', 'message_shred']);
|
|
const SNOWFLAKE_RE = /^(0|[1-9][0-9]*)$/;
|
|
|
|
function isSnowflakeString(value: string): boolean {
|
|
return SNOWFLAKE_RE.test(value);
|
|
}
|
|
|
|
function isUserIdKey(key: string): boolean {
|
|
return key === 'user_id' || key === 'target_user_id' || key === 'admin_user_id' || key.endsWith('_user_id');
|
|
}
|
|
|
|
function isGuildIdKey(key: string): boolean {
|
|
return key === 'guild_id' || key.endsWith('_guild_id');
|
|
}
|
|
|
|
function isChannelIdKey(key: string): boolean {
|
|
return key === 'channel_id' || key.endsWith('_channel_id');
|
|
}
|
|
|
|
function mapUserSummary(user: User | null): AdminAuditLogUserSummary | null {
|
|
if (!user) return null;
|
|
return {
|
|
id: user.id.toString(),
|
|
username: user.username,
|
|
discriminator: String(user.discriminator).padStart(4, '0'),
|
|
global_name: user.globalName,
|
|
};
|
|
}
|
|
|
|
function mapGuildSummary(guild: Guild | null): AdminAuditLogGuildSummary | null {
|
|
if (!guild) return null;
|
|
return {
|
|
id: guild.id.toString(),
|
|
name: guild.name,
|
|
};
|
|
}
|
|
|
|
function mapChannelSummary(channel: Channel | null): AdminAuditLogChannelSummary | null {
|
|
if (!channel) return null;
|
|
return {
|
|
id: channel.id.toString(),
|
|
name: channel.name,
|
|
type: channel.type,
|
|
guild_id: channel.guildId?.toString() ?? null,
|
|
};
|
|
}
|