feat(audit): локальный журнал и push в auth-portal
Таблица audit_log, recordAudit на мутациях, GET /api/v1/audit и dual-write source_app=fw. Co-authored-by: Cursor <[email protected]>
This commit is contained in:
@@ -0,0 +1,120 @@
|
||||
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)
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
export * from './schema.js'
|
||||
export * from './client.js'
|
||||
export * from './repositories/index.js'
|
||||
export * from './audit-log.js'
|
||||
|
||||
@@ -218,6 +218,33 @@ export const agentInstallLinks = sqliteTable(
|
||||
}),
|
||||
)
|
||||
|
||||
export const auditLog = sqliteTable(
|
||||
'audit_log',
|
||||
{
|
||||
id: text('id').primaryKey(),
|
||||
eventId: text('event_id'),
|
||||
sourceApp: text('source_app').notNull().default('fw'),
|
||||
action: text('action').notNull(),
|
||||
severity: text('severity').notNull().default('info'),
|
||||
actorUserId: text('actor_user_id'),
|
||||
actorEmail: text('actor_email'),
|
||||
actorName: text('actor_name'),
|
||||
targetType: text('target_type'),
|
||||
targetId: text('target_id'),
|
||||
summary: text('summary').notNull(),
|
||||
detailsJson: text('details_json'),
|
||||
ip: text('ip'),
|
||||
createdAt: text('created_at')
|
||||
.notNull()
|
||||
.default(sql`(strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))`),
|
||||
},
|
||||
(t) => ({
|
||||
eventIdIdx: uniqueIndex('idx_audit_log_event_id').on(t.eventId),
|
||||
createdAtIdx: index('idx_audit_log_created_at').on(t.createdAt),
|
||||
actionIdx: index('idx_audit_log_action').on(t.action),
|
||||
}),
|
||||
)
|
||||
|
||||
export const SHARED_POLICY_SET_ID = 'set-shared-default'
|
||||
|
||||
export const schema = {
|
||||
@@ -232,4 +259,5 @@ export const schema = {
|
||||
ipOverrides,
|
||||
agentStatsSamples,
|
||||
agentInstallLinks,
|
||||
auditLog,
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user