Таблица audit_log, recordAudit на мутациях, GET /api/v1/audit и dual-write source_app=fw. Co-authored-by: Cursor <cursoragent@cursor.com>
121 lines
3.1 KiB
TypeScript
121 lines
3.1 KiB
TypeScript
import { and, desc, eq } from 'drizzle-orm'
|
|
import { randomUUID } from 'node:crypto'
|
|
import type {
|
|
AuditLogEntry,
|
|
AuditSeverity,
|
|
AuditSourceApp,
|
|
AuditTargetType,
|
|
} from '@evofw/shared'
|
|
import type { Db } from './client.js'
|
|
import { auditLog } from './schema.js'
|
|
|
|
export type AppendAuditInput = {
|
|
eventId?: string | null
|
|
sourceApp?: AuditSourceApp
|
|
action: string
|
|
severity?: AuditSeverity
|
|
actorUserId?: string | null
|
|
actorEmail?: string | null
|
|
actorName?: string | null
|
|
targetType?: AuditTargetType | null
|
|
targetId?: string | null
|
|
summary: string
|
|
details?: Record<string, unknown> | null
|
|
ip?: string | null
|
|
createdAt?: string | null
|
|
}
|
|
|
|
function mapRow(row: typeof auditLog.$inferSelect): AuditLogEntry {
|
|
let details: Record<string, unknown> | null = null
|
|
if (row.detailsJson) {
|
|
try {
|
|
details = JSON.parse(row.detailsJson) as Record<string, unknown>
|
|
} catch {
|
|
details = { raw: row.detailsJson }
|
|
}
|
|
}
|
|
return {
|
|
id: row.id,
|
|
event_id: row.eventId,
|
|
source_app: (row.sourceApp as AuditSourceApp) || 'fw',
|
|
action: row.action,
|
|
severity: row.severity as AuditSeverity,
|
|
actor_user_id: row.actorUserId,
|
|
actor_email: row.actorEmail,
|
|
actor_name: row.actorName,
|
|
target_type: (row.targetType as AuditTargetType | null) ?? null,
|
|
target_id: row.targetId,
|
|
summary: row.summary,
|
|
details,
|
|
ip: row.ip,
|
|
created_at: row.createdAt,
|
|
}
|
|
}
|
|
|
|
/** @returns true if inserted, false if duplicate event_id */
|
|
export function appendAudit(db: Db, input: AppendAuditInput): boolean {
|
|
const now = input.createdAt ?? new Date().toISOString()
|
|
const eventId = input.eventId ?? null
|
|
|
|
if (eventId) {
|
|
const existing = db
|
|
.select({ id: auditLog.id })
|
|
.from(auditLog)
|
|
.where(eq(auditLog.eventId, eventId))
|
|
.get()
|
|
if (existing) return false
|
|
}
|
|
|
|
db.insert(auditLog)
|
|
.values({
|
|
id: randomUUID(),
|
|
eventId,
|
|
sourceApp: input.sourceApp ?? 'fw',
|
|
action: input.action,
|
|
severity: input.severity ?? 'info',
|
|
actorUserId: input.actorUserId ?? null,
|
|
actorEmail: input.actorEmail ?? null,
|
|
actorName: input.actorName ?? null,
|
|
targetType: input.targetType ?? null,
|
|
targetId: input.targetId ?? null,
|
|
summary: input.summary,
|
|
detailsJson: input.details ? JSON.stringify(input.details) : null,
|
|
ip: input.ip ?? null,
|
|
createdAt: now,
|
|
})
|
|
.run()
|
|
return true
|
|
}
|
|
|
|
export function listAudit(
|
|
db: Db,
|
|
opts: {
|
|
action?: string
|
|
severity?: AuditSeverity
|
|
limit?: number
|
|
} = {},
|
|
): AuditLogEntry[] {
|
|
const limit = opts.limit ?? 200
|
|
const conditions = []
|
|
if (opts.action) conditions.push(eq(auditLog.action, opts.action))
|
|
if (opts.severity) conditions.push(eq(auditLog.severity, opts.severity))
|
|
|
|
const rows =
|
|
conditions.length > 0
|
|
? db
|
|
.select()
|
|
.from(auditLog)
|
|
.where(and(...conditions))
|
|
.orderBy(desc(auditLog.createdAt))
|
|
.limit(limit)
|
|
.all()
|
|
: db
|
|
.select()
|
|
.from(auditLog)
|
|
.orderBy(desc(auditLog.createdAt))
|
|
.limit(limit)
|
|
.all()
|
|
|
|
return rows.map(mapRow)
|
|
}
|