Add AuditLog table and Prisma audit extension
This commit is contained in:
@@ -0,0 +1,30 @@
|
||||
-- CreateTable
|
||||
CREATE TABLE "AuditLog" (
|
||||
"id" TEXT NOT NULL,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"userId" TEXT,
|
||||
"email" TEXT,
|
||||
"roleId" INTEGER,
|
||||
"roleName" TEXT,
|
||||
"page" TEXT NOT NULL,
|
||||
"action" TEXT NOT NULL,
|
||||
"entity" TEXT,
|
||||
"entityId" TEXT,
|
||||
"before" JSONB,
|
||||
"after" JSONB,
|
||||
"ip" TEXT,
|
||||
|
||||
CONSTRAINT "AuditLog_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "AuditLog_createdAt_idx" ON "AuditLog"("createdAt");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "AuditLog_roleId_idx" ON "AuditLog"("roleId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "AuditLog_action_idx" ON "AuditLog"("action");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "AuditLog_page_idx" ON "AuditLog"("page");
|
||||
@@ -229,3 +229,27 @@ model ColorPalette {
|
||||
|
||||
@@map("ColorPalette")
|
||||
}
|
||||
|
||||
// Append-only audit trail of database mutations. Written centrally by the
|
||||
// Prisma extension in src/lib/db.ts; read by the admin Audit Log page.
|
||||
model AuditLog {
|
||||
id String @id @default(uuid())
|
||||
createdAt DateTime @default(now())
|
||||
userId String?
|
||||
email String?
|
||||
roleId Int?
|
||||
roleName String?
|
||||
page String
|
||||
action String
|
||||
entity String?
|
||||
entityId String?
|
||||
before Json?
|
||||
after Json?
|
||||
ip String?
|
||||
|
||||
@@index([createdAt])
|
||||
@@index([roleId])
|
||||
@@index([action])
|
||||
@@index([page])
|
||||
@@map("AuditLog")
|
||||
}
|
||||
|
||||
+79
-58
@@ -3,71 +3,92 @@ import { svelteKitHandler } from 'better-auth/svelte-kit';
|
||||
import { building } from '$app/environment';
|
||||
import { env } from '$env/dynamic/private';
|
||||
import { validateApiKey } from '$lib/server/api-key';
|
||||
import { runWithAuditContext, type AuditContext } from '$lib/server/audit-context';
|
||||
import { clientIp } from '$lib/server/v2-helpers';
|
||||
|
||||
function contextFor(
|
||||
event: { url: URL; request: Request },
|
||||
session?: App.Locals['user']
|
||||
): AuditContext {
|
||||
return {
|
||||
userId: session?.id ?? null,
|
||||
email: session?.email ?? null,
|
||||
roleId: session?.roleId ?? null,
|
||||
page: event.url.pathname,
|
||||
ip: clientIp(event.request)
|
||||
};
|
||||
}
|
||||
|
||||
export async function handle({ event, resolve }) {
|
||||
// v2 auth is ALWAYS enforced — no API_KEYS_ACTIVE bypass. The only
|
||||
// exceptions are the signed onboarding routes (HMAC-protected).
|
||||
// TODO: re-enable when v2 API key auth is ready.
|
||||
// const isV2 = event.url.pathname.startsWith('/api/v2/');
|
||||
// const isSignedOnboarding = event.url.pathname.startsWith('/api/v2/automations/');
|
||||
//
|
||||
// if (isV2 && !isSignedOnboarding) {
|
||||
// const authHeader = event.request.headers.get('authorization');
|
||||
// if (!authHeader?.startsWith('Bearer ')) {
|
||||
// return new Response(JSON.stringify({ error: 'API key required' }), {
|
||||
// status: 401,
|
||||
// headers: { 'content-type': 'application/json' }
|
||||
// });
|
||||
// }
|
||||
//
|
||||
// const key = authHeader.slice(7);
|
||||
// const record = await validateApiKey(key);
|
||||
// if (!record) {
|
||||
// return new Response(JSON.stringify({ error: 'Invalid or expired API key' }), {
|
||||
// status: 401,
|
||||
// headers: { 'content-type': 'application/json' }
|
||||
// });
|
||||
// }
|
||||
//
|
||||
// event.locals.apiKey = record;
|
||||
//
|
||||
// return resolve(event);
|
||||
// }
|
||||
return runWithAuditContext(contextFor(event), async () => {
|
||||
// v2 auth is ALWAYS enforced — no API_KEYS_ACTIVE bypass. The only
|
||||
// exceptions are the signed onboarding routes (HMAC-protected).
|
||||
// TODO: re-enable when v2 API key auth is ready.
|
||||
// const isV2 = event.url.pathname.startsWith('/api/v2/');
|
||||
// const isSignedOnboarding = event.url.pathname.startsWith('/api/v2/automations/');
|
||||
//
|
||||
// if (isV2 && !isSignedOnboarding) {
|
||||
// const authHeader = event.request.headers.get('authorization');
|
||||
// if (!authHeader?.startsWith('Bearer ')) {
|
||||
// return new Response(JSON.stringify({ error: 'API key required' }), {
|
||||
// status: 401,
|
||||
// headers: { 'content-type': 'application/json' }
|
||||
// });
|
||||
// }
|
||||
//
|
||||
// const key = authHeader.slice(7);
|
||||
// const record = await validateApiKey(key);
|
||||
// if (!record) {
|
||||
// return new Response(JSON.stringify({ error: 'Invalid or expired API key' }), {
|
||||
// status: 401,
|
||||
// headers: { 'content-type': 'application/json' }
|
||||
// });
|
||||
// }
|
||||
//
|
||||
// event.locals.apiKey = record;
|
||||
//
|
||||
// return resolve(event);
|
||||
// }
|
||||
|
||||
if (
|
||||
event.url.pathname.startsWith('/api/v1/') &&
|
||||
!event.url.pathname.startsWith('/api/v1/automations/')
|
||||
) {
|
||||
if (env.API_KEYS_ACTIVE !== 'false') {
|
||||
const authHeader = event.request.headers.get('authorization');
|
||||
if (!authHeader?.startsWith('Bearer ')) {
|
||||
return new Response(JSON.stringify({ error: 'API key required' }), {
|
||||
status: 401,
|
||||
headers: { 'content-type': 'application/json' }
|
||||
});
|
||||
if (
|
||||
event.url.pathname.startsWith('/api/v1/') &&
|
||||
!event.url.pathname.startsWith('/api/v1/automations/')
|
||||
) {
|
||||
if (env.API_KEYS_ACTIVE !== 'false') {
|
||||
const authHeader = event.request.headers.get('authorization');
|
||||
if (!authHeader?.startsWith('Bearer ')) {
|
||||
return new Response(JSON.stringify({ error: 'API key required' }), {
|
||||
status: 401,
|
||||
headers: { 'content-type': 'application/json' }
|
||||
});
|
||||
}
|
||||
|
||||
const key = authHeader.slice(7);
|
||||
const record = await validateApiKey(key);
|
||||
if (!record) {
|
||||
return new Response(JSON.stringify({ error: 'Invalid or expired API key' }), {
|
||||
status: 401,
|
||||
headers: { 'content-type': 'application/json' }
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const key = authHeader.slice(7);
|
||||
const record = await validateApiKey(key);
|
||||
if (!record) {
|
||||
return new Response(JSON.stringify({ error: 'Invalid or expired API key' }), {
|
||||
status: 401,
|
||||
headers: { 'content-type': 'application/json' }
|
||||
});
|
||||
}
|
||||
return resolve(event);
|
||||
}
|
||||
|
||||
return resolve(event);
|
||||
}
|
||||
const session = await auth.api.getSession({
|
||||
headers: event.request.headers
|
||||
});
|
||||
|
||||
const session = await auth.api.getSession({
|
||||
headers: event.request.headers
|
||||
if (session) {
|
||||
event.locals.session = session.session;
|
||||
event.locals.user = session.user;
|
||||
|
||||
return runWithAuditContext(contextFor(event, session.user), () =>
|
||||
svelteKitHandler({ event, resolve, auth, building })
|
||||
);
|
||||
}
|
||||
|
||||
return svelteKitHandler({ event, resolve, auth, building });
|
||||
});
|
||||
|
||||
if (session) {
|
||||
event.locals.session = session.session;
|
||||
event.locals.user = session.user;
|
||||
}
|
||||
|
||||
return svelteKitHandler({ event, resolve, auth, building });
|
||||
}
|
||||
|
||||
+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;
|
||||
}
|
||||
|
||||
|
||||
@@ -92,3 +92,8 @@ export type ApiKey = Prisma.ApiKeyModel
|
||||
*
|
||||
*/
|
||||
export type ColorPalette = Prisma.ColorPaletteModel
|
||||
/**
|
||||
* Model AuditLog
|
||||
*
|
||||
*/
|
||||
export type AuditLog = Prisma.AuditLogModel
|
||||
|
||||
@@ -116,3 +116,8 @@ export type ApiKey = Prisma.ApiKeyModel
|
||||
*
|
||||
*/
|
||||
export type ColorPalette = Prisma.ColorPaletteModel
|
||||
/**
|
||||
* Model AuditLog
|
||||
*
|
||||
*/
|
||||
export type AuditLog = Prisma.AuditLogModel
|
||||
|
||||
@@ -253,6 +253,57 @@ export type JsonWithAggregatesFilterBase<$PrismaModel = never> = {
|
||||
_max?: Prisma.NestedJsonFilter<$PrismaModel>
|
||||
}
|
||||
|
||||
export type JsonNullableFilter<$PrismaModel = never> =
|
||||
| Prisma.PatchUndefined<
|
||||
Prisma.Either<Required<JsonNullableFilterBase<$PrismaModel>>, Exclude<keyof Required<JsonNullableFilterBase<$PrismaModel>>, 'path'>>,
|
||||
Required<JsonNullableFilterBase<$PrismaModel>>
|
||||
>
|
||||
| Prisma.OptionalFlat<Omit<Required<JsonNullableFilterBase<$PrismaModel>>, 'path'>>
|
||||
|
||||
export type JsonNullableFilterBase<$PrismaModel = never> = {
|
||||
equals?: runtime.InputJsonValue | Prisma.JsonFieldRefInput<$PrismaModel> | Prisma.JsonNullValueFilter
|
||||
path?: string[]
|
||||
mode?: Prisma.QueryMode | Prisma.EnumQueryModeFieldRefInput<$PrismaModel>
|
||||
string_contains?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||
string_starts_with?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||
string_ends_with?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||
array_starts_with?: runtime.InputJsonValue | Prisma.JsonFieldRefInput<$PrismaModel> | null
|
||||
array_ends_with?: runtime.InputJsonValue | Prisma.JsonFieldRefInput<$PrismaModel> | null
|
||||
array_contains?: runtime.InputJsonValue | Prisma.JsonFieldRefInput<$PrismaModel> | null
|
||||
lt?: runtime.InputJsonValue | Prisma.JsonFieldRefInput<$PrismaModel>
|
||||
lte?: runtime.InputJsonValue | Prisma.JsonFieldRefInput<$PrismaModel>
|
||||
gt?: runtime.InputJsonValue | Prisma.JsonFieldRefInput<$PrismaModel>
|
||||
gte?: runtime.InputJsonValue | Prisma.JsonFieldRefInput<$PrismaModel>
|
||||
not?: runtime.InputJsonValue | Prisma.JsonFieldRefInput<$PrismaModel> | Prisma.JsonNullValueFilter
|
||||
}
|
||||
|
||||
export type JsonNullableWithAggregatesFilter<$PrismaModel = never> =
|
||||
| Prisma.PatchUndefined<
|
||||
Prisma.Either<Required<JsonNullableWithAggregatesFilterBase<$PrismaModel>>, Exclude<keyof Required<JsonNullableWithAggregatesFilterBase<$PrismaModel>>, 'path'>>,
|
||||
Required<JsonNullableWithAggregatesFilterBase<$PrismaModel>>
|
||||
>
|
||||
| Prisma.OptionalFlat<Omit<Required<JsonNullableWithAggregatesFilterBase<$PrismaModel>>, 'path'>>
|
||||
|
||||
export type JsonNullableWithAggregatesFilterBase<$PrismaModel = never> = {
|
||||
equals?: runtime.InputJsonValue | Prisma.JsonFieldRefInput<$PrismaModel> | Prisma.JsonNullValueFilter
|
||||
path?: string[]
|
||||
mode?: Prisma.QueryMode | Prisma.EnumQueryModeFieldRefInput<$PrismaModel>
|
||||
string_contains?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||
string_starts_with?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||
string_ends_with?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||
array_starts_with?: runtime.InputJsonValue | Prisma.JsonFieldRefInput<$PrismaModel> | null
|
||||
array_ends_with?: runtime.InputJsonValue | Prisma.JsonFieldRefInput<$PrismaModel> | null
|
||||
array_contains?: runtime.InputJsonValue | Prisma.JsonFieldRefInput<$PrismaModel> | null
|
||||
lt?: runtime.InputJsonValue | Prisma.JsonFieldRefInput<$PrismaModel>
|
||||
lte?: runtime.InputJsonValue | Prisma.JsonFieldRefInput<$PrismaModel>
|
||||
gt?: runtime.InputJsonValue | Prisma.JsonFieldRefInput<$PrismaModel>
|
||||
gte?: runtime.InputJsonValue | Prisma.JsonFieldRefInput<$PrismaModel>
|
||||
not?: runtime.InputJsonValue | Prisma.JsonFieldRefInput<$PrismaModel> | Prisma.JsonNullValueFilter
|
||||
_count?: Prisma.NestedIntNullableFilter<$PrismaModel>
|
||||
_min?: Prisma.NestedJsonNullableFilter<$PrismaModel>
|
||||
_max?: Prisma.NestedJsonNullableFilter<$PrismaModel>
|
||||
}
|
||||
|
||||
export type NestedStringFilter<$PrismaModel = never> = {
|
||||
equals?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||
in?: string[] | Prisma.ListStringFieldRefInput<$PrismaModel>
|
||||
@@ -478,4 +529,28 @@ export type NestedJsonFilterBase<$PrismaModel = never> = {
|
||||
not?: runtime.InputJsonValue | Prisma.JsonFieldRefInput<$PrismaModel> | Prisma.JsonNullValueFilter
|
||||
}
|
||||
|
||||
export type NestedJsonNullableFilter<$PrismaModel = never> =
|
||||
| Prisma.PatchUndefined<
|
||||
Prisma.Either<Required<NestedJsonNullableFilterBase<$PrismaModel>>, Exclude<keyof Required<NestedJsonNullableFilterBase<$PrismaModel>>, 'path'>>,
|
||||
Required<NestedJsonNullableFilterBase<$PrismaModel>>
|
||||
>
|
||||
| Prisma.OptionalFlat<Omit<Required<NestedJsonNullableFilterBase<$PrismaModel>>, 'path'>>
|
||||
|
||||
export type NestedJsonNullableFilterBase<$PrismaModel = never> = {
|
||||
equals?: runtime.InputJsonValue | Prisma.JsonFieldRefInput<$PrismaModel> | Prisma.JsonNullValueFilter
|
||||
path?: string[]
|
||||
mode?: Prisma.QueryMode | Prisma.EnumQueryModeFieldRefInput<$PrismaModel>
|
||||
string_contains?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||
string_starts_with?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||
string_ends_with?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||
array_starts_with?: runtime.InputJsonValue | Prisma.JsonFieldRefInput<$PrismaModel> | null
|
||||
array_ends_with?: runtime.InputJsonValue | Prisma.JsonFieldRefInput<$PrismaModel> | null
|
||||
array_contains?: runtime.InputJsonValue | Prisma.JsonFieldRefInput<$PrismaModel> | null
|
||||
lt?: runtime.InputJsonValue | Prisma.JsonFieldRefInput<$PrismaModel>
|
||||
lte?: runtime.InputJsonValue | Prisma.JsonFieldRefInput<$PrismaModel>
|
||||
gt?: runtime.InputJsonValue | Prisma.JsonFieldRefInput<$PrismaModel>
|
||||
gte?: runtime.InputJsonValue | Prisma.JsonFieldRefInput<$PrismaModel>
|
||||
not?: runtime.InputJsonValue | Prisma.JsonFieldRefInput<$PrismaModel> | Prisma.JsonNullValueFilter
|
||||
}
|
||||
|
||||
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -398,7 +398,8 @@ export const ModelName = {
|
||||
ProclamationCategory: 'ProclamationCategory',
|
||||
LicenseKey: 'LicenseKey',
|
||||
ApiKey: 'ApiKey',
|
||||
ColorPalette: 'ColorPalette'
|
||||
ColorPalette: 'ColorPalette',
|
||||
AuditLog: 'AuditLog'
|
||||
} as const
|
||||
|
||||
export type ModelName = (typeof ModelName)[keyof typeof ModelName]
|
||||
@@ -414,7 +415,7 @@ export type TypeMap<ExtArgs extends runtime.Types.Extensions.InternalArgs = runt
|
||||
omit: GlobalOmitOptions
|
||||
}
|
||||
meta: {
|
||||
modelProps: "user" | "profile" | "address" | "session" | "account" | "userRole" | "verification" | "proclamation" | "proclamationGroup" | "userProclamations" | "solfeggioFrequency" | "proclamationCategory" | "licenseKey" | "apiKey" | "colorPalette"
|
||||
modelProps: "user" | "profile" | "address" | "session" | "account" | "userRole" | "verification" | "proclamation" | "proclamationGroup" | "userProclamations" | "solfeggioFrequency" | "proclamationCategory" | "licenseKey" | "apiKey" | "colorPalette" | "auditLog"
|
||||
txIsolationLevel: TransactionIsolationLevel
|
||||
}
|
||||
model: {
|
||||
@@ -1528,6 +1529,80 @@ export type TypeMap<ExtArgs extends runtime.Types.Extensions.InternalArgs = runt
|
||||
}
|
||||
}
|
||||
}
|
||||
AuditLog: {
|
||||
payload: Prisma.$AuditLogPayload<ExtArgs>
|
||||
fields: Prisma.AuditLogFieldRefs
|
||||
operations: {
|
||||
findUnique: {
|
||||
args: Prisma.AuditLogFindUniqueArgs<ExtArgs>
|
||||
result: runtime.Types.Utils.PayloadToResult<Prisma.$AuditLogPayload> | null
|
||||
}
|
||||
findUniqueOrThrow: {
|
||||
args: Prisma.AuditLogFindUniqueOrThrowArgs<ExtArgs>
|
||||
result: runtime.Types.Utils.PayloadToResult<Prisma.$AuditLogPayload>
|
||||
}
|
||||
findFirst: {
|
||||
args: Prisma.AuditLogFindFirstArgs<ExtArgs>
|
||||
result: runtime.Types.Utils.PayloadToResult<Prisma.$AuditLogPayload> | null
|
||||
}
|
||||
findFirstOrThrow: {
|
||||
args: Prisma.AuditLogFindFirstOrThrowArgs<ExtArgs>
|
||||
result: runtime.Types.Utils.PayloadToResult<Prisma.$AuditLogPayload>
|
||||
}
|
||||
findMany: {
|
||||
args: Prisma.AuditLogFindManyArgs<ExtArgs>
|
||||
result: runtime.Types.Utils.PayloadToResult<Prisma.$AuditLogPayload>[]
|
||||
}
|
||||
create: {
|
||||
args: Prisma.AuditLogCreateArgs<ExtArgs>
|
||||
result: runtime.Types.Utils.PayloadToResult<Prisma.$AuditLogPayload>
|
||||
}
|
||||
createMany: {
|
||||
args: Prisma.AuditLogCreateManyArgs<ExtArgs>
|
||||
result: BatchPayload
|
||||
}
|
||||
createManyAndReturn: {
|
||||
args: Prisma.AuditLogCreateManyAndReturnArgs<ExtArgs>
|
||||
result: runtime.Types.Utils.PayloadToResult<Prisma.$AuditLogPayload>[]
|
||||
}
|
||||
delete: {
|
||||
args: Prisma.AuditLogDeleteArgs<ExtArgs>
|
||||
result: runtime.Types.Utils.PayloadToResult<Prisma.$AuditLogPayload>
|
||||
}
|
||||
update: {
|
||||
args: Prisma.AuditLogUpdateArgs<ExtArgs>
|
||||
result: runtime.Types.Utils.PayloadToResult<Prisma.$AuditLogPayload>
|
||||
}
|
||||
deleteMany: {
|
||||
args: Prisma.AuditLogDeleteManyArgs<ExtArgs>
|
||||
result: BatchPayload
|
||||
}
|
||||
updateMany: {
|
||||
args: Prisma.AuditLogUpdateManyArgs<ExtArgs>
|
||||
result: BatchPayload
|
||||
}
|
||||
updateManyAndReturn: {
|
||||
args: Prisma.AuditLogUpdateManyAndReturnArgs<ExtArgs>
|
||||
result: runtime.Types.Utils.PayloadToResult<Prisma.$AuditLogPayload>[]
|
||||
}
|
||||
upsert: {
|
||||
args: Prisma.AuditLogUpsertArgs<ExtArgs>
|
||||
result: runtime.Types.Utils.PayloadToResult<Prisma.$AuditLogPayload>
|
||||
}
|
||||
aggregate: {
|
||||
args: Prisma.AuditLogAggregateArgs<ExtArgs>
|
||||
result: runtime.Types.Utils.Optional<Prisma.AggregateAuditLog>
|
||||
}
|
||||
groupBy: {
|
||||
args: Prisma.AuditLogGroupByArgs<ExtArgs>
|
||||
result: runtime.Types.Utils.Optional<Prisma.AuditLogGroupByOutputType>[]
|
||||
}
|
||||
count: {
|
||||
args: Prisma.AuditLogCountArgs<ExtArgs>
|
||||
result: runtime.Types.Utils.Optional<Prisma.AuditLogCountAggregateOutputType> | number
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} & {
|
||||
other: {
|
||||
@@ -1746,6 +1821,25 @@ export const ColorPaletteScalarFieldEnum = {
|
||||
export type ColorPaletteScalarFieldEnum = (typeof ColorPaletteScalarFieldEnum)[keyof typeof ColorPaletteScalarFieldEnum]
|
||||
|
||||
|
||||
export const AuditLogScalarFieldEnum = {
|
||||
id: 'id',
|
||||
createdAt: 'createdAt',
|
||||
userId: 'userId',
|
||||
email: 'email',
|
||||
roleId: 'roleId',
|
||||
roleName: 'roleName',
|
||||
page: 'page',
|
||||
action: 'action',
|
||||
entity: 'entity',
|
||||
entityId: 'entityId',
|
||||
before: 'before',
|
||||
after: 'after',
|
||||
ip: 'ip'
|
||||
} as const
|
||||
|
||||
export type AuditLogScalarFieldEnum = (typeof AuditLogScalarFieldEnum)[keyof typeof AuditLogScalarFieldEnum]
|
||||
|
||||
|
||||
export const SortOrder = {
|
||||
asc: 'asc',
|
||||
desc: 'desc'
|
||||
@@ -1761,6 +1855,14 @@ export const JsonNullValueInput = {
|
||||
export type JsonNullValueInput = (typeof JsonNullValueInput)[keyof typeof JsonNullValueInput]
|
||||
|
||||
|
||||
export const NullableJsonNullValueInput = {
|
||||
DbNull: DbNull,
|
||||
JsonNull: JsonNull
|
||||
} as const
|
||||
|
||||
export type NullableJsonNullValueInput = (typeof NullableJsonNullValueInput)[keyof typeof NullableJsonNullValueInput]
|
||||
|
||||
|
||||
export const QueryMode = {
|
||||
default: 'default',
|
||||
insensitive: 'insensitive'
|
||||
@@ -1993,6 +2095,7 @@ export type GlobalOmitConfig = {
|
||||
licenseKey?: Prisma.LicenseKeyOmit
|
||||
apiKey?: Prisma.ApiKeyOmit
|
||||
colorPalette?: Prisma.ColorPaletteOmit
|
||||
auditLog?: Prisma.AuditLogOmit
|
||||
}
|
||||
|
||||
/* Types for Logging */
|
||||
|
||||
@@ -65,7 +65,8 @@ export const ModelName = {
|
||||
ProclamationCategory: 'ProclamationCategory',
|
||||
LicenseKey: 'LicenseKey',
|
||||
ApiKey: 'ApiKey',
|
||||
ColorPalette: 'ColorPalette'
|
||||
ColorPalette: 'ColorPalette',
|
||||
AuditLog: 'AuditLog'
|
||||
} as const
|
||||
|
||||
export type ModelName = (typeof ModelName)[keyof typeof ModelName]
|
||||
@@ -263,6 +264,25 @@ export const ColorPaletteScalarFieldEnum = {
|
||||
export type ColorPaletteScalarFieldEnum = (typeof ColorPaletteScalarFieldEnum)[keyof typeof ColorPaletteScalarFieldEnum]
|
||||
|
||||
|
||||
export const AuditLogScalarFieldEnum = {
|
||||
id: 'id',
|
||||
createdAt: 'createdAt',
|
||||
userId: 'userId',
|
||||
email: 'email',
|
||||
roleId: 'roleId',
|
||||
roleName: 'roleName',
|
||||
page: 'page',
|
||||
action: 'action',
|
||||
entity: 'entity',
|
||||
entityId: 'entityId',
|
||||
before: 'before',
|
||||
after: 'after',
|
||||
ip: 'ip'
|
||||
} as const
|
||||
|
||||
export type AuditLogScalarFieldEnum = (typeof AuditLogScalarFieldEnum)[keyof typeof AuditLogScalarFieldEnum]
|
||||
|
||||
|
||||
export const SortOrder = {
|
||||
asc: 'asc',
|
||||
desc: 'desc'
|
||||
@@ -278,6 +298,14 @@ export const JsonNullValueInput = {
|
||||
export type JsonNullValueInput = (typeof JsonNullValueInput)[keyof typeof JsonNullValueInput]
|
||||
|
||||
|
||||
export const NullableJsonNullValueInput = {
|
||||
DbNull: DbNull,
|
||||
JsonNull: JsonNull
|
||||
} as const
|
||||
|
||||
export type NullableJsonNullValueInput = (typeof NullableJsonNullValueInput)[keyof typeof NullableJsonNullValueInput]
|
||||
|
||||
|
||||
export const QueryMode = {
|
||||
default: 'default',
|
||||
insensitive: 'insensitive'
|
||||
|
||||
@@ -23,4 +23,5 @@ export type * from './models/ProclamationCategory.ts'
|
||||
export type * from './models/LicenseKey.ts'
|
||||
export type * from './models/ApiKey.ts'
|
||||
export type * from './models/ColorPalette.ts'
|
||||
export type * from './models/AuditLog.ts'
|
||||
export type * from './commonInputTypes.ts'
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,23 @@
|
||||
import { AsyncLocalStorage } from 'node:async_hooks';
|
||||
|
||||
/**
|
||||
* The actor for the current request, used by the Prisma audit extension to
|
||||
* attribute database mutations. Populated in `hooks.server.ts`.
|
||||
*/
|
||||
export type AuditContext = {
|
||||
userId: string | null;
|
||||
email: string | null;
|
||||
roleId: number | null;
|
||||
page: string;
|
||||
ip: string | null;
|
||||
};
|
||||
|
||||
const storage = new AsyncLocalStorage<AuditContext>();
|
||||
|
||||
export function runWithAuditContext<T>(context: AuditContext, fn: () => T): T {
|
||||
return storage.run(context, fn);
|
||||
}
|
||||
|
||||
export function getAuditContext(): AuditContext | undefined {
|
||||
return storage.getStore();
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { describeAction, entityIdFor, sanitizeAuditValue, shouldAudit } from './audit';
|
||||
|
||||
describe('shouldAudit', () => {
|
||||
it('audits mutations', () => {
|
||||
expect(shouldAudit('User', 'create')).toBe(true);
|
||||
expect(shouldAudit('Profile', 'update')).toBe(true);
|
||||
expect(shouldAudit('Proclamation', 'delete')).toBe(true);
|
||||
expect(shouldAudit('LicenseKey', 'updateMany')).toBe(true);
|
||||
});
|
||||
|
||||
it('ignores reads', () => {
|
||||
expect(shouldAudit('User', 'findMany')).toBe(false);
|
||||
expect(shouldAudit('User', 'findUnique')).toBe(false);
|
||||
expect(shouldAudit('User', 'count')).toBe(false);
|
||||
});
|
||||
|
||||
it('ignores the audit log and auth internals', () => {
|
||||
for (const model of ['AuditLog', 'Session', 'Account', 'Verification']) {
|
||||
expect(shouldAudit(model, 'create')).toBe(false);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('sanitizeAuditValue', () => {
|
||||
it('redacts sensitive keys', () => {
|
||||
const out = sanitizeAuditValue({
|
||||
password: 'hunter2',
|
||||
token: 'abc',
|
||||
keyHash: 'deadbeef',
|
||||
email: 'jane@example.com'
|
||||
}) as Record<string, unknown>;
|
||||
|
||||
expect(out.password).toBe('[redacted]');
|
||||
expect(out.token).toBe('[redacted]');
|
||||
expect(out.keyHash).toBe('[redacted]');
|
||||
expect(out.email).toBe('jane@example.com');
|
||||
});
|
||||
|
||||
it('converts dates to ISO strings', () => {
|
||||
const date = new Date('2026-01-01T00:00:00.000Z');
|
||||
expect(sanitizeAuditValue({ at: date })).toEqual({ at: '2026-01-01T00:00:00.000Z' });
|
||||
});
|
||||
|
||||
it('handles arrays and nullish values', () => {
|
||||
expect(sanitizeAuditValue([1, null, 'a'])).toEqual([1, null, 'a']);
|
||||
expect(sanitizeAuditValue(null)).toBeNull();
|
||||
expect(sanitizeAuditValue(undefined)).toBeNull();
|
||||
});
|
||||
|
||||
it('truncates very long strings', () => {
|
||||
const long = 'a'.repeat(21000);
|
||||
const out = sanitizeAuditValue(long) as string;
|
||||
expect(out.length).toBe(20001);
|
||||
expect(out.endsWith('…')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('entityIdFor', () => {
|
||||
it('prefers the result id', () => {
|
||||
expect(entityIdFor({ where: { id: 'w' } }, { id: 'r' }, { id: 'b' })).toBe('r');
|
||||
});
|
||||
|
||||
it('falls back to where, data, then before', () => {
|
||||
expect(entityIdFor({ where: { id: 5 } }, null, null)).toBe('5');
|
||||
expect(entityIdFor({ data: { id: 'd' } }, null, null)).toBe('d');
|
||||
expect(entityIdFor({}, null, { id: 'b' })).toBe('b');
|
||||
expect(entityIdFor({}, null, null)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('describeAction', () => {
|
||||
it('labels bulk operations', () => {
|
||||
expect(describeAction('updateMany')).toBe('bulk update');
|
||||
expect(describeAction('deleteMany')).toBe('bulk delete');
|
||||
expect(describeAction('create')).toBe('create');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,87 @@
|
||||
/**
|
||||
* Pure helpers for the audit extension. Kept free of database imports so the
|
||||
* extension in `$lib/db.ts` can use them without a cycle.
|
||||
*/
|
||||
|
||||
/** Prisma operations that change data. Reads are never audited. */
|
||||
const MUTATION_OPERATIONS = new Set([
|
||||
'create',
|
||||
'createMany',
|
||||
'createManyAndReturn',
|
||||
'update',
|
||||
'updateMany',
|
||||
'updateManyAndReturn',
|
||||
'upsert',
|
||||
'delete',
|
||||
'deleteMany'
|
||||
]);
|
||||
|
||||
/**
|
||||
* Models we never audit:
|
||||
* - `AuditLog` itself (would recurse and is not user data)
|
||||
* - `Session`/`Account`/`Verification` are high-churn auth internals that hold
|
||||
* tokens, password hashes and other secrets. Business mutations are audited.
|
||||
*/
|
||||
const EXCLUDED_MODELS = new Set(['AuditLog', 'Session', 'Account', 'Verification']);
|
||||
|
||||
export function shouldAudit(model: string, operation: string): boolean {
|
||||
return !EXCLUDED_MODELS.has(model) && MUTATION_OPERATIONS.has(operation);
|
||||
}
|
||||
|
||||
const SENSITIVE_KEY = /pass(word)?|secret|token|hash/i;
|
||||
|
||||
/**
|
||||
* Make a value safe to persist as JSON: dates become ISO strings, sensitive
|
||||
* keys are redacted, very long strings are truncated, and cycles/depth are
|
||||
* bounded.
|
||||
*/
|
||||
export function sanitizeAuditValue(value: unknown, depth = 0): unknown {
|
||||
if (value === undefined || value === null) return null;
|
||||
if (depth > 8) return '[truncated]';
|
||||
if (value instanceof Date) return value.toISOString();
|
||||
if (Array.isArray(value)) return value.map((item) => sanitizeAuditValue(item, depth + 1));
|
||||
if (typeof value === 'object') {
|
||||
const out: Record<string, unknown> = {};
|
||||
for (const [key, nested] of Object.entries(value as Record<string, unknown>)) {
|
||||
out[key] = SENSITIVE_KEY.test(key) ? '[redacted]' : sanitizeAuditValue(nested, depth + 1);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
if (typeof value === 'string' && value.length > 20000) return `${value.slice(0, 20000)}…`;
|
||||
return value;
|
||||
}
|
||||
|
||||
/** Best-effort row id for the changed record. */
|
||||
export function entityIdFor(args: unknown, result: unknown, before: unknown): string | null {
|
||||
const where = (args as { where?: { id?: unknown } } | null)?.where;
|
||||
const data = (args as { data?: { id?: unknown } } | null)?.data;
|
||||
const resultId = (result as { id?: unknown } | null)?.id;
|
||||
const first = Array.isArray(before) ? before[0] : before;
|
||||
const beforeId = (first as { id?: unknown } | undefined)?.id;
|
||||
const id = resultId ?? where?.id ?? data?.id ?? beforeId;
|
||||
return id === undefined || id === null ? null : String(id);
|
||||
}
|
||||
|
||||
/** Human label for an operation, e.g. `updateMany` → `bulk update`. */
|
||||
export function describeAction(operation: string): string {
|
||||
switch (operation) {
|
||||
case 'create':
|
||||
return 'create';
|
||||
case 'update':
|
||||
return 'update';
|
||||
case 'delete':
|
||||
return 'delete';
|
||||
case 'upsert':
|
||||
return 'upsert';
|
||||
case 'createMany':
|
||||
case 'createManyAndReturn':
|
||||
return 'bulk create';
|
||||
case 'updateMany':
|
||||
case 'updateManyAndReturn':
|
||||
return 'bulk update';
|
||||
case 'deleteMany':
|
||||
return 'bulk delete';
|
||||
default:
|
||||
return operation;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user