From 36a9fdf46690c83d27941ba04f0f64adbfbf939a Mon Sep 17 00:00:00 2001 From: RedStar Date: Thu, 16 Apr 2026 13:50:08 +0200 Subject: [PATCH] feat: add AuditLogManager for dashboard settings audit logging - Add AuditLog Prisma model with migration - Add AuditLogManager following PermissionNodeManager conventions - Add AuditLogChange type and PrismaJson augmentation - Integrate into SettingsContext with constructor/onPatch pattern - Wire audit logging into settings.patch.ts with structuredClone snapshot - Add GET /guilds/:guild/audit-logs endpoint with pagination - Add 18 unit tests for AuditLogManager --- .../migration.sql | 12 ++ prisma/schema.prisma | 19 +++ .../settings/context/SettingsContext.ts | 8 + src/lib/database/settings/functions.ts | 4 + src/lib/database/settings/index.ts | 1 + .../settings/structures/AuditLogManager.ts | 101 ++++++++++++ src/lib/database/settings/types.ts | 6 + src/lib/types/Augments.d.ts | 4 +- src/routes/guilds/[guild]/audit-logs.get.ts | 37 +++++ src/routes/guilds/[guild]/settings.patch.ts | 11 +- .../structures/AuditLogManager.test.ts | 148 ++++++++++++++++++ 11 files changed, 349 insertions(+), 2 deletions(-) create mode 100644 prisma/migrations/20260416120000_add_dashboard_audit_log/migration.sql create mode 100644 src/lib/database/settings/structures/AuditLogManager.ts create mode 100644 src/routes/guilds/[guild]/audit-logs.get.ts create mode 100644 tests/lib/database/settings/structures/AuditLogManager.test.ts diff --git a/prisma/migrations/20260416120000_add_dashboard_audit_log/migration.sql b/prisma/migrations/20260416120000_add_dashboard_audit_log/migration.sql new file mode 100644 index 000000000..2f6e7ffe9 --- /dev/null +++ b/prisma/migrations/20260416120000_add_dashboard_audit_log/migration.sql @@ -0,0 +1,12 @@ +CREATE TABLE "audit_log" ( + "id" SERIAL NOT NULL, + "guild_id" VARCHAR(19) NOT NULL, + "user_id" VARCHAR(19) NOT NULL, + "action" VARCHAR(64) NOT NULL, + "section" VARCHAR(64) NOT NULL, + "changes" JSON NOT NULL DEFAULT '[]', + "created_at" TIMESTAMP(6) NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT "audit_log_pkey" PRIMARY KEY ("id") +); +CREATE INDEX "IDX_audit_log_guild_created" ON "audit_log"("guild_id", "created_at" DESC); +ALTER TABLE "audit_log" ADD CONSTRAINT "audit_log_guild_id_fkey" FOREIGN KEY ("guild_id") REFERENCES "guilds"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 10961b085..096477cb1 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -174,9 +174,12 @@ model Guild { channelsIgnoreVoiceActivity String[] @default([]) @map("channels.ignore.voice-activity") @db.VarChar(19) eventsTimeout Boolean @default(false) @map("events.timeout") + auditLogs AuditLog[] + @@map("guilds") } + model Migration { id Int @id(map: "PK_Migrations") @default(autoincrement()) timestamp BigInt @@ -219,3 +222,19 @@ model User { @@map("user") } + +model AuditLog { + id Int @id @default(autoincrement()) + guild Guild @relation(fields: [guildId], references: [id], onDelete: Cascade) + guildId String @map("guild_id") @db.VarChar(19) + userId String @map("user_id") @db.VarChar(19) + action String @db.VarChar(64) + section String @db.VarChar(64) + /// [AuditLogChanges] + changes Json @default("[]") @db.Json + createdAt DateTime @default(now()) @map("created_at") @db.Timestamp(6) + + @@index([guildId, createdAt(sort: Desc)], map: "IDX_audit_log_guild_created") + @@map("audit_log") +} + diff --git a/src/lib/database/settings/context/SettingsContext.ts b/src/lib/database/settings/context/SettingsContext.ts index 87d7cec88..00b5a2b47 100644 --- a/src/lib/database/settings/context/SettingsContext.ts +++ b/src/lib/database/settings/context/SettingsContext.ts @@ -1,5 +1,6 @@ import { AdderManager } from '#lib/database/settings/structures/AdderManager'; import { PermissionNodeManager } from '#lib/database/settings/structures/PermissionNodeManager'; +import { AuditLogManager } from '#lib/database/settings/structures/AuditLogManager'; import type { ReadonlyGuildData } from '#lib/database/settings/types'; import { create } from '#utils/Security/RegexCreator'; import { RateLimitManager } from '@sapphire/ratelimits'; @@ -8,12 +9,14 @@ import { isNullish, isNullishOrEmpty } from '@sapphire/utilities'; export class SettingsContext { readonly #adders: AdderManager; readonly #permissionNodes: PermissionNodeManager; + #auditLog: AuditLogManager; #wordFilterRegExp: RegExp | null; #noMentionSpam: RateLimitManager; public constructor(settings: ReadonlyGuildData) { this.#adders = new AdderManager(settings); this.#permissionNodes = new PermissionNodeManager(settings); + this.#auditLog = new AuditLogManager(settings); this.#wordFilterRegExp = isNullishOrEmpty(settings.selfmodFilterRaw) ? null : new RegExp(create(settings.selfmodFilterRaw), 'gi'); this.#noMentionSpam = new RateLimitManager(settings.noMentionSpamTimePeriod * 1000, settings.noMentionSpamMentionsAllowed); } @@ -26,6 +29,10 @@ export class SettingsContext { return this.#permissionNodes; } + public get auditLog() { + return this.#auditLog; + } + public get wordFilterRegExp() { return this.#wordFilterRegExp; } @@ -36,6 +43,7 @@ export class SettingsContext { public update(settings: ReadonlyGuildData, data: Partial) { this.#adders.onPatch(settings); + this.#auditLog.onPatch(settings); if (!isNullish(data.permissionsRoles) || !isNullish(data.permissionsUsers)) { this.#permissionNodes.refresh(settings); diff --git a/src/lib/database/settings/functions.ts b/src/lib/database/settings/functions.ts index 3a5b20ce8..9c8c8f61a 100644 --- a/src/lib/database/settings/functions.ts +++ b/src/lib/database/settings/functions.ts @@ -61,6 +61,10 @@ export function readSettingsCached(guild: GuildResolvable): ReadonlyGuildData | return cache.get(resolveGuildId(guild)) ?? null; } +export function readSettingsAuditLog(settings: ReadonlyGuildData) { + return getSettingsContext(settings).auditLog; +} + export async function writeSettings( guild: GuildResolvable, data: Partial | ((settings: ReadonlyGuildData) => Awaitable>) diff --git a/src/lib/database/settings/index.ts b/src/lib/database/settings/index.ts index 37724cf20..70b1f6621 100644 --- a/src/lib/database/settings/index.ts +++ b/src/lib/database/settings/index.ts @@ -5,6 +5,7 @@ export * from '#lib/database/settings/schema/SchemaGroup'; export * from '#lib/database/settings/schema/SchemaKey'; export * from '#lib/database/settings/structures/AdderManager'; export * from '#lib/database/settings/structures/PermissionNodeManager'; +export * from '#lib/database/settings/structures/AuditLogManager'; export * from '#lib/database/settings/structures/Serializer'; export * from '#lib/database/settings/structures/SerializerStore'; export * from '#lib/database/settings/types'; diff --git a/src/lib/database/settings/structures/AuditLogManager.ts b/src/lib/database/settings/structures/AuditLogManager.ts new file mode 100644 index 000000000..5f2f80b2b --- /dev/null +++ b/src/lib/database/settings/structures/AuditLogManager.ts @@ -0,0 +1,101 @@ +import type { AuditLogChange, ReadonlyGuildData } from '#lib/database/settings/types'; +import { container } from '@sapphire/framework'; + +export class AuditLogManager { + #guildId: string; + #settings: ReadonlyGuildData; + + public constructor(settings: ReadonlyGuildData) { + this.#guildId = settings.id; + this.#settings = settings; + } + + public onPatch(settings: ReadonlyGuildData): void { + this.#guildId = settings.id; + this.#settings = settings; + } + + public update(userId: string, newData: Record): Promise { + const changedKeys = Object.keys(newData); + const changes = this.#buildChanges(newData); + + return this.write(userId, { + action: 'settings.update', + section: AuditLogManager.deriveSection(changedKeys), + changes + }); + } + + public add(userId: string, key: string, value: unknown): Promise { + return this.write(userId, { + action: 'settings.add', + section: AuditLogManager.deriveSection([key]), + changes: [{ key, newValue: AuditLogManager.serializeValue(value) }] + }); + } + + public remove(userId: string, key: string, value: unknown): Promise { + return this.write(userId, { + action: 'settings.remove', + section: AuditLogManager.deriveSection([key]), + changes: [{ key, oldValue: AuditLogManager.serializeValue(value) }] + }); + } + + public async write(userId: string, params: { action: string; section: string; changes: AuditLogChange[] }): Promise { + await container.prisma.auditLog.create({ + data: { + guildId: this.#guildId, + userId, + action: params.action, + section: params.section, + changes: JSON.parse(JSON.stringify(params.changes)) + } + }); + } + + #buildChanges(newData: Record): AuditLogChange[] { + return Object.keys(newData).map((key) => ({ + key, + oldValue: AuditLogManager.serializeValue((this.#settings as Record)[key]), + newValue: AuditLogManager.serializeValue(newData[key]) + })); + } + + private static deriveSection(keys: string[]): string { + const counts = new Map(); + for (const key of keys) { + const section = AuditLogManager.classifyKey(key); + counts.set(section, (counts.get(section) ?? 0) + 1); + } + + if (counts.size === 0) return 'general'; + if (counts.size === 1) return counts.keys().next().value!; + + let best = 'general'; + let max = 0; + for (const [section, count] of counts) { + if (count > max) { + max = count; + best = section; + } + } + return best; + } + + private static classifyKey(key: string): string { + if (key.startsWith('permissions')) return 'permissions'; + if (key.startsWith('selfmod') || key.startsWith('noMentionSpam')) return 'moderation'; + if (key.startsWith('channels')) return 'channels'; + if (key.startsWith('roles')) return 'roles'; + if (key.startsWith('events')) return 'events'; + if (key.startsWith('messages')) return 'messages'; + if (key.startsWith('disabled')) return 'commands'; + return 'general'; + } + + private static serializeValue(value: unknown): unknown { + if (typeof value === 'bigint') return Number(value); + return value; + } +} diff --git a/src/lib/database/settings/types.ts b/src/lib/database/settings/types.ts index 4e30fa811..957ef50fb 100644 --- a/src/lib/database/settings/types.ts +++ b/src/lib/database/settings/types.ts @@ -14,6 +14,12 @@ import type { Snowflake } from 'discord.js'; export type { Guild as GuildData, Moderation as ModerationData, User as UserData } from '#generated/prisma'; +export interface AuditLogChange { + key: string; + oldValue?: unknown; + newValue?: unknown; +} + export interface PermissionsNode { allow: readonly Snowflake[]; deny: readonly Snowflake[]; diff --git a/src/lib/types/Augments.d.ts b/src/lib/types/Augments.d.ts index 964376cd2..132206e1a 100644 --- a/src/lib/types/Augments.d.ts +++ b/src/lib/types/Augments.d.ts @@ -7,7 +7,8 @@ import type { ReactionRole, SerializerStore, StickyRole, - UniqueRoleSet + UniqueRoleSet, + AuditLogChange } from '#lib/database'; import type { GuildMemberFetchQueue } from '#lib/discord/GuildMemberFetchQueue'; import type { WorkerManager } from '#lib/moderation/workers/WorkerManager'; @@ -34,6 +35,7 @@ declare global { export type StickyRoleEntries = StickyRole[]; export type ReactionRoleEntries = ReactionRole[]; export type UniqueRoleSetEntries = UniqueRoleSet[]; + export type AuditLogChanges = AuditLogChange[]; } } diff --git a/src/routes/guilds/[guild]/audit-logs.get.ts b/src/routes/guilds/[guild]/audit-logs.get.ts new file mode 100644 index 000000000..babbcb787 --- /dev/null +++ b/src/routes/guilds/[guild]/audit-logs.get.ts @@ -0,0 +1,37 @@ +import { authenticated, canManage, ratelimit } from '#lib/api/utils'; +import { seconds } from '#utils/common'; +import { container } from '@sapphire/framework'; +import { HttpCodes, Route } from '@sapphire/plugin-api'; + +export class UserRoute extends Route { + @authenticated() + @ratelimit(seconds(10), 5, true) + public async run(request: Route.Request, response: Route.Response) { + const guildId = request.params.guild; + + const guild = container.client.guilds.cache.get(guildId); + if (!guild) return response.error(HttpCodes.BadRequest); + + const member = await guild.members.fetch(request.auth!.id).catch(() => null); + if (!member) return response.error(HttpCodes.BadRequest); + + if (!(await canManage(guild, member))) return response.error(HttpCodes.Forbidden); + + const take = Math.min(Number(request.query.take) || 50, 100); + const skip = Math.max(Number(request.query.skip) || 0, 0); + + const [results, total] = await Promise.all([ + container.prisma.auditLog.findMany({ + where: { guildId }, + orderBy: { createdAt: 'desc' }, + take, + skip + }), + container.prisma.auditLog.count({ + where: { guildId } + }) + ]); + + return response.status(HttpCodes.OK).json({ entries: results, total }); + } +} diff --git a/src/routes/guilds/[guild]/settings.patch.ts b/src/routes/guilds/[guild]/settings.patch.ts index f37bbe585..3b4c4e881 100644 --- a/src/routes/guilds/[guild]/settings.patch.ts +++ b/src/routes/guilds/[guild]/settings.patch.ts @@ -2,6 +2,7 @@ import { authenticated, canManage, ratelimit } from '#lib/api/utils'; import { getConfigurableKeys, isSchemaKey, + readSettingsAuditLog, serializeSettings, writeSettingsTransaction, type GuildDataValue, @@ -37,7 +38,15 @@ export class UserRoute extends Route { try { using trx = await writeSettingsTransaction(guild); const data = await this.validateAll(trx.settings, guild, entries); - await trx.write(Object.fromEntries(data)).submit(); + const settingsData = Object.fromEntries(data); + + // Capture current settings for audit log before mutation + const auditLog = readSettingsAuditLog(structuredClone(trx.settings)); + + await trx.write(settingsData).submit(); + + // Fire-and-forget audit log write + auditLog.update(request.auth!.id, settingsData).catch(() => null); return this.sendSettings(response, trx.settings); } catch (errors) { diff --git a/tests/lib/database/settings/structures/AuditLogManager.test.ts b/tests/lib/database/settings/structures/AuditLogManager.test.ts new file mode 100644 index 000000000..2dd408a42 --- /dev/null +++ b/tests/lib/database/settings/structures/AuditLogManager.test.ts @@ -0,0 +1,148 @@ +import { AuditLogManager, type AuditLogChange } from '#lib/database'; +import { getDefaultGuildSettings } from '#lib/database/settings/constants'; +import type { ReadonlyGuildData } from '#lib/database/settings/types'; +import { container } from '@sapphire/framework'; + +describe('AuditLogManager', () => { + let entity: ReadonlyGuildData; + let manager: AuditLogManager; + const createSpy = vi.fn().mockResolvedValue({}); + + beforeEach(() => { + entity = Object.assign(Object.create(null), getDefaultGuildSettings(), { id: '123456789' }) as ReadonlyGuildData; + manager = new AuditLogManager(entity); + createSpy.mockClear(); + Reflect.set(container, 'prisma', { auditLog: { create: createSpy } }); + }); + + describe('constructor', () => { + test('GIVEN settings THEN creates manager', () => { + expect(manager).toBeInstanceOf(AuditLogManager); + }); + }); + + describe('onPatch', () => { + test('GIVEN new settings THEN updates internal state', () => { + const newEntity = Object.assign(Object.create(null), getDefaultGuildSettings(), { id: '987654321' }) as ReadonlyGuildData; + manager.onPatch(newEntity); + + // Verify by calling write which uses the guildId + manager.write('user1', { action: 'test', section: 'general', changes: [] }); + expect(createSpy).toHaveBeenCalledWith({ + data: expect.objectContaining({ guildId: '987654321' }) + }); + }); + }); + + describe('update', () => { + test('GIVEN new data THEN writes changes with old and new values', async () => { + await manager.update('user1', { prefix: '!' }); + + expect(createSpy).toHaveBeenCalledOnce(); + const call = createSpy.mock.calls[0][0]; + expect(call.data.guildId).toBe('123456789'); + expect(call.data.userId).toBe('user1'); + expect(call.data.action).toBe('settings.update'); + expect(call.data.changes).toEqual([{ key: 'prefix', oldValue: entity.prefix, newValue: '!' }]); + }); + + test('GIVEN multiple keys THEN writes all changes', async () => { + await manager.update('user1', { prefix: '!', language: 'es-ES' }); + + const call = createSpy.mock.calls[0][0]; + expect(call.data.changes).toHaveLength(2); + }); + }); + + describe('add', () => { + test('GIVEN key and value THEN writes add action', async () => { + await manager.add('user1', 'channelsIgnoreAll', '111'); + + expect(createSpy).toHaveBeenCalledOnce(); + const call = createSpy.mock.calls[0][0]; + expect(call.data.action).toBe('settings.add'); + expect(call.data.section).toBe('channels'); + expect(call.data.changes).toEqual([{ key: 'channelsIgnoreAll', newValue: '111' }]); + }); + }); + + describe('remove', () => { + test('GIVEN key and value THEN writes remove action', async () => { + await manager.remove('user1', 'rolesAdmin', '222'); + + expect(createSpy).toHaveBeenCalledOnce(); + const call = createSpy.mock.calls[0][0]; + expect(call.data.action).toBe('settings.remove'); + expect(call.data.section).toBe('roles'); + expect(call.data.changes).toEqual([{ key: 'rolesAdmin', oldValue: '222' }]); + }); + }); + + describe('deriveSection', () => { + // Access via update which calls deriveSection internally + test('GIVEN permission keys THEN derives permissions section', async () => { + await manager.update('user1', { permissionsUsers: [] }); + expect(createSpy.mock.calls[0][0].data.section).toBe('permissions'); + }); + + test('GIVEN selfmod keys THEN derives moderation section', async () => { + await manager.update('user1', { selfmodCapitalsEnabled: true }); + expect(createSpy.mock.calls[0][0].data.section).toBe('moderation'); + }); + + test('GIVEN channel keys THEN derives channels section', async () => { + await manager.update('user1', { channelsIgnoreAll: [] }); + expect(createSpy.mock.calls[0][0].data.section).toBe('channels'); + }); + + test('GIVEN role keys THEN derives roles section', async () => { + await manager.update('user1', { rolesAdmin: null }); + expect(createSpy.mock.calls[0][0].data.section).toBe('roles'); + }); + + test('GIVEN event keys THEN derives events section', async () => { + await manager.update('user1', { eventsMessageEdit: false }); + expect(createSpy.mock.calls[0][0].data.section).toBe('events'); + }); + + test('GIVEN message keys THEN derives messages section', async () => { + await manager.update('user1', { messagesGreeting: '' }); + expect(createSpy.mock.calls[0][0].data.section).toBe('messages'); + }); + + test('GIVEN disabled keys THEN derives commands section', async () => { + await manager.update('user1', { disabledCommands: [] }); + expect(createSpy.mock.calls[0][0].data.section).toBe('commands'); + }); + + test('GIVEN unclassified keys THEN derives general section', async () => { + await manager.update('user1', { prefix: '!' }); + expect(createSpy.mock.calls[0][0].data.section).toBe('general'); + }); + + test('GIVEN mixed keys THEN derives most common section', async () => { + await manager.update('user1', { channelsIgnoreAll: [], channelsMediaOnly: [], prefix: '!' }); + expect(createSpy.mock.calls[0][0].data.section).toBe('channels'); + }); + }); + + describe('serializeValue', () => { + test('GIVEN bigint value THEN serializes to number', async () => { + await manager.update('user1', { prefix: BigInt(42) as unknown }); + const call = createSpy.mock.calls[0][0]; + expect(call.data.changes[0].newValue).toBe(42); + }); + + test('GIVEN string value THEN keeps as-is', async () => { + await manager.update('user1', { prefix: '!' }); + const call = createSpy.mock.calls[0][0]; + expect(call.data.changes[0].newValue).toBe('!'); + }); + + test('GIVEN null value THEN keeps as-is', async () => { + await manager.update('user1', { prefix: null }); + const call = createSpy.mock.calls[0][0]; + expect(call.data.changes[0].newValue).toBeNull(); + }); + }); +});