Add AuditLog table and Prisma audit extension

This commit is contained in:
NeilClack
2026-09-10 15:10:11 -05:00
parent 7596937f30
commit d16965dab6
15 changed files with 2121 additions and 72 deletions
@@ -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");
+24
View File
@@ -229,3 +229,27 @@ model ColorPalette {
@@map("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
View File
@@ -3,71 +3,92 @@ import { svelteKitHandler } from 'better-auth/svelte-kit';
import { building } from '$app/environment'; import { building } from '$app/environment';
import { env } from '$env/dynamic/private'; import { env } from '$env/dynamic/private';
import { validateApiKey } from '$lib/server/api-key'; 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 }) { export async function handle({ event, resolve }) {
// v2 auth is ALWAYS enforced — no API_KEYS_ACTIVE bypass. The only return runWithAuditContext(contextFor(event), async () => {
// exceptions are the signed onboarding routes (HMAC-protected). // v2 auth is ALWAYS enforced — no API_KEYS_ACTIVE bypass. The only
// TODO: re-enable when v2 API key auth is ready. // exceptions are the signed onboarding routes (HMAC-protected).
// const isV2 = event.url.pathname.startsWith('/api/v2/'); // TODO: re-enable when v2 API key auth is ready.
// const isSignedOnboarding = event.url.pathname.startsWith('/api/v2/automations/'); // 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 (isV2 && !isSignedOnboarding) {
// if (!authHeader?.startsWith('Bearer ')) { // const authHeader = event.request.headers.get('authorization');
// return new Response(JSON.stringify({ error: 'API key required' }), { // if (!authHeader?.startsWith('Bearer ')) {
// status: 401, // return new Response(JSON.stringify({ error: 'API key required' }), {
// headers: { 'content-type': 'application/json' } // status: 401,
// }); // headers: { 'content-type': 'application/json' }
// } // });
// // }
// const key = authHeader.slice(7); //
// const record = await validateApiKey(key); // const key = authHeader.slice(7);
// if (!record) { // const record = await validateApiKey(key);
// return new Response(JSON.stringify({ error: 'Invalid or expired API key' }), { // if (!record) {
// status: 401, // return new Response(JSON.stringify({ error: 'Invalid or expired API key' }), {
// headers: { 'content-type': 'application/json' } // status: 401,
// }); // headers: { 'content-type': 'application/json' }
// } // });
// // }
// event.locals.apiKey = record; //
// // event.locals.apiKey = record;
// return resolve(event); //
// } // return resolve(event);
// }
if ( if (
event.url.pathname.startsWith('/api/v1/') && event.url.pathname.startsWith('/api/v1/') &&
!event.url.pathname.startsWith('/api/v1/automations/') !event.url.pathname.startsWith('/api/v1/automations/')
) { ) {
if (env.API_KEYS_ACTIVE !== 'false') { if (env.API_KEYS_ACTIVE !== 'false') {
const authHeader = event.request.headers.get('authorization'); const authHeader = event.request.headers.get('authorization');
if (!authHeader?.startsWith('Bearer ')) { if (!authHeader?.startsWith('Bearer ')) {
return new Response(JSON.stringify({ error: 'API key required' }), { return new Response(JSON.stringify({ error: 'API key required' }), {
status: 401, status: 401,
headers: { 'content-type': 'application/json' } 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); return resolve(event);
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); const session = await auth.api.getSession({
} headers: event.request.headers
});
const session = await auth.api.getSession({ if (session) {
headers: event.request.headers 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
View File
@@ -2,18 +2,172 @@ import pg from 'pg';
import { PrismaPg } from '@prisma/adapter-pg'; import { PrismaPg } from '@prisma/adapter-pg';
import { PrismaClient } from '$lib/generated/prisma/client'; import { PrismaClient } from '$lib/generated/prisma/client';
import { env } from '$env/dynamic/private'; 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 _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 { function getPrisma(): PrismaClient {
if (!_prisma) { if (_prisma) return _prisma;
const pool = new pg.Pool({
connectionString: env.DATABASE_URL
});
const adapter = new PrismaPg(pool); const { base, pool } = getBase();
_prisma = new PrismaClient({ adapter });
} 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; return _prisma;
} }
+5
View File
@@ -92,3 +92,8 @@ export type ApiKey = Prisma.ApiKeyModel
* *
*/ */
export type ColorPalette = Prisma.ColorPaletteModel export type ColorPalette = Prisma.ColorPaletteModel
/**
* Model AuditLog
*
*/
export type AuditLog = Prisma.AuditLogModel
+5
View File
@@ -116,3 +116,8 @@ export type ApiKey = Prisma.ApiKeyModel
* *
*/ */
export type ColorPalette = Prisma.ColorPaletteModel 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> _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> = { export type NestedStringFilter<$PrismaModel = never> = {
equals?: string | Prisma.StringFieldRefInput<$PrismaModel> equals?: string | Prisma.StringFieldRefInput<$PrismaModel>
in?: string[] | Prisma.ListStringFieldRefInput<$PrismaModel> in?: string[] | Prisma.ListStringFieldRefInput<$PrismaModel>
@@ -478,4 +529,28 @@ export type NestedJsonFilterBase<$PrismaModel = never> = {
not?: runtime.InputJsonValue | Prisma.JsonFieldRefInput<$PrismaModel> | Prisma.JsonNullValueFilter 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', ProclamationCategory: 'ProclamationCategory',
LicenseKey: 'LicenseKey', LicenseKey: 'LicenseKey',
ApiKey: 'ApiKey', ApiKey: 'ApiKey',
ColorPalette: 'ColorPalette' ColorPalette: 'ColorPalette',
AuditLog: 'AuditLog'
} as const } as const
export type ModelName = (typeof ModelName)[keyof typeof ModelName] export type ModelName = (typeof ModelName)[keyof typeof ModelName]
@@ -414,7 +415,7 @@ export type TypeMap<ExtArgs extends runtime.Types.Extensions.InternalArgs = runt
omit: GlobalOmitOptions omit: GlobalOmitOptions
} }
meta: { 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 txIsolationLevel: TransactionIsolationLevel
} }
model: { 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: { other: {
@@ -1746,6 +1821,25 @@ export const ColorPaletteScalarFieldEnum = {
export type ColorPaletteScalarFieldEnum = (typeof ColorPaletteScalarFieldEnum)[keyof typeof 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 = { export const SortOrder = {
asc: 'asc', asc: 'asc',
desc: 'desc' desc: 'desc'
@@ -1761,6 +1855,14 @@ export const JsonNullValueInput = {
export type JsonNullValueInput = (typeof JsonNullValueInput)[keyof typeof 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 = { export const QueryMode = {
default: 'default', default: 'default',
insensitive: 'insensitive' insensitive: 'insensitive'
@@ -1993,6 +2095,7 @@ export type GlobalOmitConfig = {
licenseKey?: Prisma.LicenseKeyOmit licenseKey?: Prisma.LicenseKeyOmit
apiKey?: Prisma.ApiKeyOmit apiKey?: Prisma.ApiKeyOmit
colorPalette?: Prisma.ColorPaletteOmit colorPalette?: Prisma.ColorPaletteOmit
auditLog?: Prisma.AuditLogOmit
} }
/* Types for Logging */ /* Types for Logging */
@@ -65,7 +65,8 @@ export const ModelName = {
ProclamationCategory: 'ProclamationCategory', ProclamationCategory: 'ProclamationCategory',
LicenseKey: 'LicenseKey', LicenseKey: 'LicenseKey',
ApiKey: 'ApiKey', ApiKey: 'ApiKey',
ColorPalette: 'ColorPalette' ColorPalette: 'ColorPalette',
AuditLog: 'AuditLog'
} as const } as const
export type ModelName = (typeof ModelName)[keyof typeof ModelName] export type ModelName = (typeof ModelName)[keyof typeof ModelName]
@@ -263,6 +264,25 @@ export const ColorPaletteScalarFieldEnum = {
export type ColorPaletteScalarFieldEnum = (typeof ColorPaletteScalarFieldEnum)[keyof typeof 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 = { export const SortOrder = {
asc: 'asc', asc: 'asc',
desc: 'desc' desc: 'desc'
@@ -278,6 +298,14 @@ export const JsonNullValueInput = {
export type JsonNullValueInput = (typeof JsonNullValueInput)[keyof typeof 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 = { export const QueryMode = {
default: 'default', default: 'default',
insensitive: 'insensitive' insensitive: 'insensitive'
+1
View File
@@ -23,4 +23,5 @@ export type * from './models/ProclamationCategory.ts'
export type * from './models/LicenseKey.ts' export type * from './models/LicenseKey.ts'
export type * from './models/ApiKey.ts' export type * from './models/ApiKey.ts'
export type * from './models/ColorPalette.ts' export type * from './models/ColorPalette.ts'
export type * from './models/AuditLog.ts'
export type * from './commonInputTypes.ts' export type * from './commonInputTypes.ts'
File diff suppressed because it is too large Load Diff
+23
View File
@@ -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();
}
+78
View File
@@ -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');
});
});
+87
View File
@@ -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;
}
}