feat(audit): локальный журнал и push в auth-portal
Build and Push EvoFirewall Docker Image / build-and-push (push) Successful in 1m54s
Build and Push EvoFirewall Docker Image / create-release (push) Skipped

Таблица audit_log, recordAudit на мутациях, GET /api/v1/audit и dual-write source_app=fw.

Co-authored-by: Cursor <[email protected]>
This commit is contained in:
Denozordec
2026-07-21 13:24:34 +07:00
co-authored by Cursor
parent 919c1d0f95
commit 39e4856caa
16 changed files with 744 additions and 2 deletions
+20
View File
@@ -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);
+120
View File
@@ -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
View File
@@ -1,3 +1,4 @@
export * from './schema.js'
export * from './client.js'
export * from './repositories/index.js'
export * from './audit-log.js'
+28
View File
@@ -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,
}
+87
View File
@@ -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
View File
@@ -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'
+8 -1
View File
@@ -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
}