Add AuditLog table and Prisma audit extension
This commit is contained in:
+161
-7
@@ -2,18 +2,172 @@ import pg from 'pg';
|
||||
import { PrismaPg } from '@prisma/adapter-pg';
|
||||
import { PrismaClient } from '$lib/generated/prisma/client';
|
||||
import { env } from '$env/dynamic/private';
|
||||
import { getAuditContext } from '$lib/server/audit-context';
|
||||
import { describeAction, entityIdFor, sanitizeAuditValue, shouldAudit } from '$lib/server/audit';
|
||||
|
||||
let _prisma: PrismaClient | undefined;
|
||||
let _base: PrismaClient | undefined;
|
||||
let _pool: pg.Pool | undefined;
|
||||
|
||||
function getBase(): { base: PrismaClient; pool: pg.Pool } {
|
||||
if (!_base || !_pool) {
|
||||
_pool = new pg.Pool({ connectionString: env.DATABASE_URL });
|
||||
const adapter = new PrismaPg(_pool);
|
||||
_base = new PrismaClient({ adapter });
|
||||
}
|
||||
return { base: _base, pool: _pool };
|
||||
}
|
||||
|
||||
const roleNameCache = new Map<number, string>();
|
||||
|
||||
async function resolveRoleName(base: PrismaClient, roleId: number | null): Promise<string | null> {
|
||||
if (roleId === null) return null;
|
||||
const cached = roleNameCache.get(roleId);
|
||||
if (cached) return cached;
|
||||
try {
|
||||
const role = await base.userRole.findUnique({
|
||||
where: { id: roleId },
|
||||
select: { name: true }
|
||||
});
|
||||
if (role?.name) {
|
||||
roleNameCache.set(roleId, role.name);
|
||||
return role.name;
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to resolve role name for audit log:', e);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
type Delegate = {
|
||||
findFirst?: (args: unknown) => Promise<unknown>;
|
||||
findMany?: (args: unknown) => Promise<unknown>;
|
||||
};
|
||||
|
||||
function delegateFor(base: PrismaClient, model: string): Delegate | undefined {
|
||||
const key = model.charAt(0).toLowerCase() + model.slice(1);
|
||||
return (base as unknown as Record<string, unknown>)[key] as Delegate | undefined;
|
||||
}
|
||||
|
||||
async function readBefore(
|
||||
base: PrismaClient,
|
||||
model: string,
|
||||
operation: string,
|
||||
args: unknown
|
||||
): Promise<unknown> {
|
||||
if (operation.startsWith('create')) return null;
|
||||
const delegate = delegateFor(base, model);
|
||||
if (!delegate) return null;
|
||||
const where = (args as { where?: unknown } | null)?.where;
|
||||
try {
|
||||
if (operation === 'updateMany' || operation === 'deleteMany') {
|
||||
return delegate.findMany ? await delegate.findMany({ where, take: 50 }) : null;
|
||||
}
|
||||
return delegate.findFirst ? await delegate.findFirst({ where }) : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function writeAuditRow(
|
||||
pool: pg.Pool,
|
||||
base: PrismaClient,
|
||||
entry: {
|
||||
userId: string | null;
|
||||
email: string | null;
|
||||
roleId: number | null;
|
||||
page: string;
|
||||
action: string;
|
||||
entity: string;
|
||||
entityId: string | null;
|
||||
before: unknown;
|
||||
after: unknown;
|
||||
ip: string | null;
|
||||
}
|
||||
): Promise<void> {
|
||||
const roleName = await resolveRoleName(base, entry.roleId);
|
||||
await pool.query(
|
||||
`INSERT INTO "AuditLog"
|
||||
("id", "createdAt", "userId", "email", "roleId", "roleName", "page", "action", "entity", "entityId", "before", "after", "ip")
|
||||
VALUES ($1, now(), $2, $3, $4, $5, $6, $7, $8, $9, $10::jsonb, $11::jsonb, $12)`,
|
||||
[
|
||||
crypto.randomUUID(),
|
||||
entry.userId,
|
||||
entry.email,
|
||||
entry.roleId,
|
||||
roleName,
|
||||
entry.page,
|
||||
entry.action,
|
||||
entry.entity,
|
||||
entry.entityId,
|
||||
entry.before === null ? null : JSON.stringify(entry.before),
|
||||
entry.after === null ? null : JSON.stringify(entry.after),
|
||||
entry.ip
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
function getPrisma(): PrismaClient {
|
||||
if (!_prisma) {
|
||||
const pool = new pg.Pool({
|
||||
connectionString: env.DATABASE_URL
|
||||
});
|
||||
if (_prisma) return _prisma;
|
||||
|
||||
const adapter = new PrismaPg(pool);
|
||||
_prisma = new PrismaClient({ adapter });
|
||||
}
|
||||
const { base, pool } = getBase();
|
||||
|
||||
const extended = base.$extends({
|
||||
query: {
|
||||
$allModels: {
|
||||
async $allOperations({ model, operation, args, query }) {
|
||||
if (!shouldAudit(model, operation)) {
|
||||
return query(args);
|
||||
}
|
||||
|
||||
const before = await readBefore(base, model, operation, args);
|
||||
const result = await query(args);
|
||||
|
||||
try {
|
||||
const ctx = getAuditContext();
|
||||
const res = result as { id?: unknown; email?: string; roleId?: number | null } | null;
|
||||
|
||||
let userId = ctx?.userId ?? null;
|
||||
let email = ctx?.email ?? null;
|
||||
let roleId = ctx?.roleId ?? null;
|
||||
|
||||
// Self-signup: attribute the new user to itself since no session exists yet.
|
||||
if (!userId && model === 'User' && operation === 'create' && res) {
|
||||
userId = (res.id as string | undefined) ?? null;
|
||||
email = res.email ?? null;
|
||||
roleId = res.roleId ?? null;
|
||||
}
|
||||
|
||||
const after =
|
||||
operation === 'updateMany' || operation === 'deleteMany'
|
||||
? { count: (result as { count?: number } | null)?.count ?? null }
|
||||
: operation.startsWith('delete')
|
||||
? null
|
||||
: result;
|
||||
|
||||
await writeAuditRow(pool, base, {
|
||||
userId,
|
||||
email,
|
||||
roleId,
|
||||
page: ctx?.page ?? '/system',
|
||||
action: describeAction(operation),
|
||||
entity: model,
|
||||
entityId: entityIdFor(args, result, before),
|
||||
before: sanitizeAuditValue(before),
|
||||
after: sanitizeAuditValue(after),
|
||||
ip: ctx?.ip ?? null
|
||||
});
|
||||
} catch (e) {
|
||||
console.error('Failed to write audit log:', e);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
_prisma = extended as unknown as PrismaClient;
|
||||
return _prisma;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user