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,20 @@
|
||||
CREATE TABLE IF NOT EXISTS audit_log (
|
||||
id TEXT PRIMARY KEY,
|
||||
event_id TEXT,
|
||||
source_app TEXT NOT NULL DEFAULT 'fw',
|
||||
action TEXT NOT NULL,
|
||||
severity TEXT NOT NULL DEFAULT 'info',
|
||||
actor_user_id TEXT,
|
||||
actor_email TEXT,
|
||||
actor_name TEXT,
|
||||
target_type TEXT,
|
||||
target_id TEXT,
|
||||
summary TEXT NOT NULL,
|
||||
details_json TEXT,
|
||||
ip TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_audit_log_event_id ON audit_log(event_id) WHERE event_id IS NOT NULL;
|
||||
CREATE INDEX IF NOT EXISTS idx_audit_log_created_at ON audit_log(created_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_audit_log_action ON audit_log(action);
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
import { z } from 'zod'
|
||||
|
||||
export const AUDIT_SEVERITIES = ['info', 'warning', 'critical'] as const
|
||||
export type AuditSeverity = (typeof AUDIT_SEVERITIES)[number]
|
||||
export const auditSeveritySchema = z.enum(AUDIT_SEVERITIES)
|
||||
|
||||
export const AUDIT_SOURCE_APPS = ['portal', 'vps', 'cfdm', 'bgp', 'fw'] as const
|
||||
export type AuditSourceApp = (typeof AUDIT_SOURCE_APPS)[number]
|
||||
export const auditSourceAppSchema = z.enum(AUDIT_SOURCE_APPS)
|
||||
|
||||
export const AUDIT_TARGET_TYPES = [
|
||||
'user',
|
||||
'settings',
|
||||
'session',
|
||||
'system',
|
||||
'app_resource',
|
||||
] as const
|
||||
export type AuditTargetType = (typeof AUDIT_TARGET_TYPES)[number]
|
||||
export const auditTargetTypeSchema = z.enum(AUDIT_TARGET_TYPES)
|
||||
|
||||
/** EvoFirewall action keys pushed to auth-portal ingest. */
|
||||
export const FW_AUDIT_ACTIONS = [
|
||||
'agent.create',
|
||||
'agent.update',
|
||||
'agent.approve',
|
||||
'agent.revoke',
|
||||
'agent.delete',
|
||||
'agent.clone_rules',
|
||||
'agent.policy_sets.update',
|
||||
'override.create',
|
||||
'override.delete',
|
||||
'list.create',
|
||||
'list.entries.add',
|
||||
'list.entries.delete',
|
||||
'list.refresh',
|
||||
'list.delete',
|
||||
'policy_set.create',
|
||||
'policy_set.update',
|
||||
'policy_set.delete',
|
||||
'rule.create',
|
||||
'rule.update',
|
||||
'rule.reorder',
|
||||
'rule.delete',
|
||||
] as const
|
||||
export type FwAuditAction = (typeof FW_AUDIT_ACTIONS)[number]
|
||||
|
||||
export const auditLogEntrySchema = z.object({
|
||||
id: z.string(),
|
||||
event_id: z.string().nullable(),
|
||||
source_app: auditSourceAppSchema,
|
||||
action: z.string(),
|
||||
severity: auditSeveritySchema,
|
||||
actor_user_id: z.string().nullable(),
|
||||
actor_email: z.string().nullable(),
|
||||
actor_name: z.string().nullable(),
|
||||
target_type: auditTargetTypeSchema.nullable(),
|
||||
target_id: z.string().nullable(),
|
||||
summary: z.string(),
|
||||
details: z.record(z.string(), z.unknown()).nullable(),
|
||||
ip: z.string().nullable(),
|
||||
created_at: z.string(),
|
||||
})
|
||||
export type AuditLogEntry = z.infer<typeof auditLogEntrySchema>
|
||||
|
||||
export const auditListQuerySchema = z.object({
|
||||
action: z.string().optional(),
|
||||
severity: auditSeveritySchema.optional(),
|
||||
limit: z.coerce.number().int().min(1).max(500).default(200),
|
||||
})
|
||||
export type AuditListQuery = z.infer<typeof auditListQuerySchema>
|
||||
|
||||
export const ingestAuditEventSchema = z.object({
|
||||
event_id: z.string().min(1).max(128),
|
||||
source_app: z.literal('fw'),
|
||||
action: z.string().min(1).max(200),
|
||||
severity: auditSeveritySchema.optional(),
|
||||
actor_user_id: z.string().nullable().optional(),
|
||||
actor_email: z.string().email().nullable().optional(),
|
||||
actor_name: z.string().nullable().optional(),
|
||||
target_type: auditTargetTypeSchema.nullable().optional(),
|
||||
target_id: z.string().nullable().optional(),
|
||||
summary: z.string().min(1).max(500),
|
||||
details: z.record(z.string(), z.unknown()).nullable().optional(),
|
||||
ip: z.string().nullable().optional(),
|
||||
created_at: z.string().optional(),
|
||||
})
|
||||
export type IngestAuditEvent = z.infer<typeof ingestAuditEventSchema>
|
||||
@@ -1,4 +1,5 @@
|
||||
export * from './contracts.js'
|
||||
export * from './contracts/audit.js'
|
||||
export * from './list-entries.js'
|
||||
export * from './permissions.js'
|
||||
export * from './app-switcher.js'
|
||||
|
||||
@@ -35,7 +35,11 @@ export function permissionForRequest(
|
||||
if (path.startsWith('/api/v1/lists')) {
|
||||
return write ? 'fw:lists:write' : 'fw:lists:read'
|
||||
}
|
||||
if (path.startsWith('/api/v1/rules') || path.startsWith('/api/v1/policies')) {
|
||||
if (
|
||||
path.startsWith('/api/v1/rules') ||
|
||||
path.startsWith('/api/v1/policies') ||
|
||||
path.startsWith('/api/v1/policy-sets')
|
||||
) {
|
||||
return write ? 'fw:policies:write' : 'fw:policies:read'
|
||||
}
|
||||
if (path.startsWith('/api/v1/stats') || path.startsWith('/api/v1/dashboard')) {
|
||||
@@ -44,6 +48,9 @@ export function permissionForRequest(
|
||||
if (path.startsWith('/api/v1/settings') || path.startsWith('/api/v1/install-context')) {
|
||||
return write ? 'fw:settings:admin' : 'fw:settings:read'
|
||||
}
|
||||
if (path.startsWith('/api/v1/audit')) {
|
||||
return 'fw:audit:read'
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user