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")
|
@@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 { 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
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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'
|
||||||
|
|||||||
@@ -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
@@ -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