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 <cursoragent@cursor.com>
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
+2
View File
@@ -14,6 +14,7 @@ import dbPlugin from './plugins/db.js'
import errorHandlerPlugin from './plugins/error-handler.js'
import { healthRoutes } from './routes/health.js'
import { controlRoutes } from './routes/control.js'
import { auditRoutes } from './routes/audit.js'
import { agentRoutes } from './routes/agent.js'
import { refreshAllLists } from './services/lists/refresh.js'
import { repos } from '@evofw/db'
@@ -62,6 +63,7 @@ export async function buildApp(opts: BuildAppOptions = {}) {
async (protectedApi) => {
protectedApi.addHook('onRequest', app.requireAuth)
await protectedApi.register(controlRoutes, { config })
await protectedApi.register(auditRoutes)
},
{ prefix: '/api/v1' },
)
+4
View File
@@ -10,6 +10,7 @@ export interface AppConfig {
authRequired: boolean
authIssuer: string
authPortalUrl: string
authAuditIngestSecret: string | null
publicBaseUrl: string
enrollSeed: string
}
@@ -43,6 +44,9 @@ export function loadConfig(): AppConfig {
process.env.VITE_AUTH_PORTAL_URL ??
'http://localhost:5175'
).replace(/\/$/, ''),
authAuditIngestSecret:
process.env.AUTH_AUDIT_INGEST_SECRET?.trim() ||
(!isProd ? 'dev-audit-ingest-secret' : null),
publicBaseUrl: (
process.env.PUBLIC_BASE_URL ??
`http://localhost:${process.env.SERVER_PORT ?? '8080'}`
+2
View File
@@ -111,6 +111,7 @@ async function authPlugin(
'fw:policies:write',
'fw:stats:read',
'fw:settings:admin',
'fw:audit:read',
],
isAdmin: true,
}
@@ -155,6 +156,7 @@ async function authPlugin(
'fw:policies:write',
'fw:stats:read',
'fw:settings:admin',
'fw:audit:read',
]
: permissions,
isAdmin: Boolean(payload.is_admin),
+19
View File
@@ -0,0 +1,19 @@
import type { FastifyPluginAsync } from 'fastify'
import { listAudit } from '@evofw/db'
import { auditListQuerySchema } from '@evofw/shared'
import { AppError } from '../plugins/error-handler.js'
export const auditRoutes: FastifyPluginAsync = async (app) => {
app.get('/audit', async (req) => {
const parsed = auditListQuerySchema.safeParse(req.query)
if (!parsed.success) {
throw new AppError('VALIDATION_ERROR', 'Некорректные параметры запроса', 400)
}
const q = parsed.data
return listAudit(app.db, {
action: q.action,
severity: q.severity,
limit: q.limit,
})
})
}
+201 -1
View File
@@ -35,6 +35,7 @@ import {
} from '../services/install-links.js'
import { hashToken } from '../plugins/auth.js'
import type { AppConfig } from '../config.js'
import { auditMutation } from '../services/audit.js'
function mapAgent(
a: NonNullable<ReturnType<typeof repos.getAgent>>,
@@ -202,6 +203,13 @@ export const controlRoutes: FastifyPluginAsync<{ config: AppConfig }> = async (
})()
const row = repos.getInstallLink(app.db, linkId)!
auditMutation(app, config, req, {
action: 'agent.create',
targetType: 'app_resource',
targetId: agentId,
summary: `Создан агент (invite): ${name}`,
details: { agent_id: agentId, platform, install_link_id: linkId },
})
return reply.code(201).send(mapInstallLink(row, config.publicBaseUrl))
})
@@ -261,7 +269,7 @@ export const controlRoutes: FastifyPluginAsync<{ config: AppConfig }> = async (
const body = patchAgentBodySchema.parse(req.body)
const a = repos.getAgent(app.db, req.params.id)
if (!a) throw new AppError('NOT_FOUND', 'Agent not found', 404)
const updated = repos.updateAgent(app.db, a.id, {
const updated = repos.updateAgent(app.db, a.id, {
name: body.name,
policyMode: body.policy_mode,
settingsJson: body.settings
@@ -272,6 +280,17 @@ export const controlRoutes: FastifyPluginAsync<{ config: AppConfig }> = async (
? a.policyGeneration + 1
: a.policyGeneration,
})
auditMutation(app, config, req, {
action: 'agent.update',
targetType: 'app_resource',
targetId: a.id,
summary: `Обновлён агент: ${updated!.name}`,
details: {
agent_id: a.id,
policy_mode: body.policy_mode,
name: body.name,
},
})
return mapAgent(updated!)
})
@@ -283,6 +302,13 @@ export const controlRoutes: FastifyPluginAsync<{ config: AppConfig }> = async (
approvedAt: new Date().toISOString(),
})
repos.ensureSharedSetAssigned(app.db, a.id)
auditMutation(app, config, req, {
action: 'agent.approve',
targetType: 'app_resource',
targetId: a.id,
summary: `Агент одобрен: ${updated!.name}`,
details: { agent_id: a.id },
})
return mapAgent(updated!)
})
@@ -293,11 +319,30 @@ export const controlRoutes: FastifyPluginAsync<{ config: AppConfig }> = async (
status: 'revoked',
revokedAt: new Date().toISOString(),
})
auditMutation(app, config, req, {
action: 'agent.revoke',
severity: 'warning',
targetType: 'app_resource',
targetId: a.id,
summary: `Агент отозван: ${updated!.name}`,
details: { agent_id: a.id },
})
return mapAgent(updated!)
})
app.delete<{ Params: { id: string } }>('/agents/:id', async (req) => {
const a = repos.getAgent(app.db, req.params.id)
repos.deleteAgent(app.db, req.params.id)
if (a) {
auditMutation(app, config, req, {
action: 'agent.delete',
severity: 'warning',
targetType: 'app_resource',
targetId: a.id,
summary: `Агент удалён: ${a.name}`,
details: { agent_id: a.id },
})
}
return { ok: true }
})
@@ -312,6 +357,17 @@ export const controlRoutes: FastifyPluginAsync<{ config: AppConfig }> = async (
body.include_overrides ?? false,
)
if (!updated) throw new AppError('NOT_FOUND', 'Agent not found', 404)
auditMutation(app, config, req, {
action: 'agent.clone_rules',
targetType: 'app_resource',
targetId: updated.id,
summary: `Правила скопированы с ${req.params.sourceId} на ${updated.name}`,
details: {
agent_id: updated.id,
source_agent_id: req.params.sourceId,
include_overrides: body.include_overrides ?? false,
},
})
return mapAgent(updated)
},
)
@@ -347,6 +403,18 @@ export const controlRoutes: FastifyPluginAsync<{ config: AppConfig }> = async (
createdAt: new Date().toISOString(),
})
repos.bumpAgentGeneration(app.db, a.id)
auditMutation(app, config, req, {
action: 'override.create',
targetType: 'app_resource',
targetId: row!.id,
summary: `Override ${body.action} ${body.cidr} для ${a.name}`,
details: {
override_id: row!.id,
agent_id: a.id,
cidr: body.cidr,
action: body.action,
},
})
return {
id: row!.id,
agent_id: row!.agentId,
@@ -363,6 +431,17 @@ export const controlRoutes: FastifyPluginAsync<{ config: AppConfig }> = async (
async (req) => {
repos.deleteOverride(app.db, req.params.overrideId)
repos.bumpAgentGeneration(app.db, req.params.id)
auditMutation(app, config, req, {
action: 'override.delete',
severity: 'warning',
targetType: 'app_resource',
targetId: req.params.overrideId,
summary: `Override удалён у агента ${req.params.id}`,
details: {
override_id: req.params.overrideId,
agent_id: req.params.id,
},
})
return { ok: true }
},
)
@@ -411,6 +490,13 @@ export const controlRoutes: FastifyPluginAsync<{ config: AppConfig }> = async (
} else if (!isManualListType(type)) {
await refreshIpList(app.db, id)
}
auditMutation(app, config, req, {
action: 'list.create',
targetType: 'app_resource',
targetId: list!.id,
summary: `Создан список: ${list!.name}`,
details: { list_id: list!.id, type: list!.type },
})
return {
id: list!.id,
name: list!.name,
@@ -438,6 +524,16 @@ export const controlRoutes: FastifyPluginAsync<{ config: AppConfig }> = async (
values: body.values,
items: body.items,
})
auditMutation(app, config, req, {
action: 'list.entries.add',
targetType: 'app_resource',
targetId: l.id,
summary: `Добавлены записи в список: ${l.name}`,
details: {
list_id: l.id,
entry_count: result.entries.length,
},
})
return mapListDetail(app.db, l.id) ?? result
} catch (err) {
throw new AppError(
@@ -457,6 +553,14 @@ export const controlRoutes: FastifyPluginAsync<{ config: AppConfig }> = async (
const body = deleteListEntryBodySchema.parse(req.body)
try {
await deleteListEntry(app.db, l.id, body.value)
auditMutation(app, config, req, {
action: 'list.entries.delete',
severity: 'warning',
targetType: 'app_resource',
targetId: l.id,
summary: `Удалена запись из списка: ${l.name}`,
details: { list_id: l.id, value: body.value },
})
return mapListDetail(app.db, l.id)
} catch (err) {
throw new AppError(
@@ -469,14 +573,33 @@ export const controlRoutes: FastifyPluginAsync<{ config: AppConfig }> = async (
)
app.post<{ Params: { id: string } }>('/lists/:id/refresh', async (req) => {
const l = repos.getIpList(app.db, req.params.id)
await refreshIpList(app.db, req.params.id)
const detail = mapListDetail(app.db, req.params.id)
if (!detail) throw new AppError('NOT_FOUND', 'List not found', 404)
auditMutation(app, config, req, {
action: 'list.refresh',
targetType: 'app_resource',
targetId: req.params.id,
summary: `Обновлён список: ${l?.name ?? req.params.id}`,
details: { list_id: req.params.id },
})
return detail
})
app.delete<{ Params: { id: string } }>('/lists/:id', async (req) => {
const l = repos.getIpList(app.db, req.params.id)
repos.deleteIpList(app.db, req.params.id)
if (l) {
auditMutation(app, config, req, {
action: 'list.delete',
severity: 'warning',
targetType: 'app_resource',
targetId: l.id,
summary: `Список удалён: ${l.name}`,
details: { list_id: l.id },
})
}
return { ok: true }
})
@@ -505,6 +628,13 @@ export const controlRoutes: FastifyPluginAsync<{ config: AppConfig }> = async (
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
})
auditMutation(app, config, req, {
action: 'policy_set.create',
targetType: 'app_resource',
targetId: row!.id,
summary: `Создан набор политик: ${row!.name}`,
details: { set_id: row!.id, policy_mode: row!.policyMode },
})
return mapPolicySet(row!, app.db)
})
@@ -547,14 +677,37 @@ export const controlRoutes: FastifyPluginAsync<{ config: AppConfig }> = async (
}
}
}
auditMutation(app, config, req, {
action: 'policy_set.update',
targetType: 'app_resource',
targetId: s.id,
summary: `Обновлён набор политик: ${updated!.name}`,
details: {
set_id: s.id,
enabled: body.enabled,
policy_mode: body.policy_mode,
name: body.name,
},
})
return mapPolicySet(updated!, app.db)
})
app.delete<{ Params: { id: string } }>('/policy-sets/:id', async (req) => {
const s = repos.getPolicySet(app.db, req.params.id)
try {
const agentIds = repos.listAgentIdsForSet(app.db, req.params.id)
repos.deletePolicySet(app.db, req.params.id)
for (const id of agentIds) repos.bumpAgentGeneration(app.db, id)
if (s) {
auditMutation(app, config, req, {
action: 'policy_set.delete',
severity: 'warning',
targetType: 'app_resource',
targetId: s.id,
summary: `Набор политик удалён: ${s.name}`,
details: { set_id: s.id, agents_affected: agentIds.length },
})
}
} catch (err) {
throw new AppError(
'VALIDATION_ERROR',
@@ -598,6 +751,13 @@ export const controlRoutes: FastifyPluginAsync<{ config: AppConfig }> = async (
400,
)
}
auditMutation(app, config, req, {
action: 'agent.policy_sets.update',
targetType: 'app_resource',
targetId: a.id,
summary: `Наборы политик агента ${a.name} обновлены`,
details: { agent_id: a.id, set_ids: body.set_ids },
})
return {
items: repos.listSetsForAgent(app.db, a.id).map((s) => ({
set_id: s.setId,
@@ -707,6 +867,18 @@ export const controlRoutes: FastifyPluginAsync<{ config: AppConfig }> = async (
}
repos.bumpAgentsForSet(app.db, body.set_id)
auditMutation(app, config, req, {
action: 'rule.create',
targetType: 'app_resource',
targetId: row!.id,
summary: `Создано правило ${body.action} в наборе ${set.name}`,
details: {
rule_id: row!.id,
set_id: body.set_id,
action: body.action,
priority,
},
})
return mapPolicyRule(row!, app.db)
})
@@ -721,6 +893,19 @@ export const controlRoutes: FastifyPluginAsync<{ config: AppConfig }> = async (
priority: body.priority,
})
repos.bumpAgentsForSet(app.db, rule.setId)
auditMutation(app, config, req, {
action: 'rule.update',
targetType: 'app_resource',
targetId: rule.id,
summary: `Обновлено правило ${rule.id}`,
details: {
rule_id: rule.id,
set_id: rule.setId,
enabled: body.enabled,
action: body.action,
priority: body.priority,
},
})
return mapPolicyRule(updated!, app.db)
})
@@ -740,6 +925,13 @@ export const controlRoutes: FastifyPluginAsync<{ config: AppConfig }> = async (
)
}
repos.bumpAgentsForSet(app.db, s.id)
auditMutation(app, config, req, {
action: 'rule.reorder',
targetType: 'app_resource',
targetId: s.id,
summary: `Порядок правил изменён в наборе ${s.name}`,
details: { set_id: s.id, ordered_ids: body.ordered_ids },
})
return {
items: repos
.listPolicyRules(app.db, s.id)
@@ -753,6 +945,14 @@ export const controlRoutes: FastifyPluginAsync<{ config: AppConfig }> = async (
if (!rule) throw new AppError('NOT_FOUND', 'Rule not found', 404)
repos.deletePolicyRule(app.db, req.params.id)
repos.bumpAgentsForSet(app.db, rule.setId)
auditMutation(app, config, req, {
action: 'rule.delete',
severity: 'warning',
targetType: 'app_resource',
targetId: rule.id,
summary: `Правило удалено из набора ${rule.setId}`,
details: { rule_id: rule.id, set_id: rule.setId },
})
return { ok: true }
})
+80
View File
@@ -0,0 +1,80 @@
import { describe, expect, it, vi, afterEach } from 'vitest'
import { appendAudit, listAudit } from '@evofw/db'
import { buildApp } from '../app.js'
import { loadConfig } from '../config.js'
function testConfig() {
return loadConfig()
}
describe('audit API', () => {
afterEach(() => {
vi.unstubAllGlobals()
})
it('GET /api/v1/audit returns local entries', async () => {
const app = await buildApp({ config: testConfig(), memory: true })
appendAudit(app.db, {
eventId: 'evt-1',
sourceApp: 'fw',
action: 'agent.approve',
summary: 'Тест одобрения',
actorUserId: 'dev',
})
const res = await app.inject({
method: 'GET',
url: '/api/v1/audit?action=agent.approve',
})
expect(res.statusCode).toBe(200)
const body = res.json() as { action: string; summary: string }[]
expect(body.length).toBe(1)
expect(body[0]?.action).toBe('agent.approve')
expect(body[0]?.summary).toBe('Тест одобрения')
await app.close()
})
it('recordAudit pushes to portal when configured', async () => {
const fetchMock = vi.fn().mockResolvedValue({
ok: true,
text: async () => '',
})
vi.stubGlobal('fetch', fetchMock)
const config = {
...testConfig(),
authPortalUrl: 'http://portal.test',
authAuditIngestSecret: 'test-ingest-secret',
}
const app = await buildApp({ config, memory: true })
const create = await app.inject({
method: 'POST',
url: '/api/v1/lists',
payload: {
name: 'audit-test-list',
type: 'static',
entries: ['1.2.3.4/32'],
},
})
expect(create.statusCode).toBe(200)
expect(fetchMock).toHaveBeenCalledTimes(1)
const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit]
expect(url).toBe('http://portal.test/api/v1/ingest/audit')
expect((init.headers as Record<string, string>).Authorization).toBe(
'Bearer test-ingest-secret',
)
const payload = JSON.parse(String(init.body)) as {
events: { source_app: string; action: string }[]
}
expect(payload.events[0]?.source_app).toBe('fw')
expect(payload.events[0]?.action).toBe('list.create')
const entries = listAudit(app.db, { action: 'list.create' })
expect(entries.some((e) => e.summary.includes('audit-test-list'))).toBe(true)
await app.close()
})
})
+150
View File
@@ -0,0 +1,150 @@
import { randomUUID } from 'node:crypto'
import type { FastifyBaseLogger, FastifyInstance, FastifyRequest } from 'fastify'
import { appendAudit, type AppendAuditInput } from '@evofw/db'
import type { AuditSeverity, AuditTargetType, IngestAuditEvent } from '@evofw/shared'
import type { AppConfig } from '../config.js'
export type RecordAuditInput = {
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
}
export function clientIp(request: FastifyRequest): string | null {
const forwarded = request.headers['x-forwarded-for']
if (typeof forwarded === 'string' && forwarded.trim()) {
return forwarded.split(',')[0]?.trim() ?? null
}
return request.ip ?? null
}
export function actorFromRequest(
request: FastifyRequest,
): Pick<
RecordAuditInput,
'actorUserId' | 'actorEmail' | 'actorName'
> {
const u = request.authUser
if (!u) {
return {
actorUserId: null,
actorEmail: null,
actorName: null,
}
}
return {
actorUserId: u.id,
actorEmail: u.email,
actorName: u.name,
}
}
async function pushAuditToPortal(
config: AppConfig,
log: FastifyBaseLogger,
event: IngestAuditEvent,
): Promise<void> {
const secret = config.authAuditIngestSecret
const portalUrl = config.authPortalUrl
if (!secret || !portalUrl) return
const url = `${portalUrl}/api/v1/ingest/audit`
const controller = new AbortController()
const timeout = setTimeout(() => controller.abort(), 8_000)
try {
const res = await fetch(url, {
method: 'POST',
headers: {
Authorization: `Bearer ${secret}`,
'Content-Type': 'application/json',
Accept: 'application/json',
},
body: JSON.stringify({ events: [event] }),
signal: controller.signal,
})
if (!res.ok) {
const body = await res.text().catch(() => '')
log.warn(
{ status: res.status, body: body.slice(0, 200), event_id: event.event_id },
'audit portal push failed',
)
}
} catch (err) {
log.warn({ err, event_id: event.event_id }, 'audit portal push error')
} finally {
clearTimeout(timeout)
}
}
/**
* Dual-write audit: local SQLite + auth-portal ingest (source_app fw).
* Portal push is fire-and-forget; local write is synchronous best-effort.
*/
export function recordAudit(
app: FastifyInstance,
config: AppConfig,
input: RecordAuditInput,
): void {
const eventId = randomUUID()
const createdAt = new Date().toISOString()
const localInput: AppendAuditInput = {
eventId,
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,
details: input.details ?? null,
ip: input.ip ?? null,
createdAt,
}
try {
appendAudit(app.db, localInput)
} catch (err) {
app.log.warn({ err, action: input.action }, 'audit_log local append failed')
}
const portalEvent: IngestAuditEvent = {
event_id: eventId,
source_app: 'fw',
action: input.action,
severity: input.severity ?? 'info',
actor_user_id: input.actorUserId ?? null,
actor_email: input.actorEmail?.trim() ? input.actorEmail : null,
actor_name: input.actorName ?? null,
target_type: input.targetType ?? null,
target_id: input.targetId ?? null,
summary: input.summary,
details: input.details ?? null,
ip: input.ip ?? null,
created_at: createdAt,
}
void pushAuditToPortal(config, app.log, portalEvent)
}
export function auditMutation(
app: FastifyInstance,
config: AppConfig,
request: FastifyRequest,
input: Omit<RecordAuditInput, 'ip'> & Partial<Pick<RecordAuditInput, 'ip'>>,
): void {
recordAudit(app, config, {
...actorFromRequest(request),
ip: input.ip ?? clientIp(request),
...input,
})
}