diff --git a/apps/api/src/app.ts b/apps/api/src/app.ts index 012687d..4926974 100644 --- a/apps/api/src/app.ts +++ b/apps/api/src/app.ts @@ -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' }, ) diff --git a/apps/api/src/config.ts b/apps/api/src/config.ts index 6bed92d..a8f3569 100644 --- a/apps/api/src/config.ts +++ b/apps/api/src/config.ts @@ -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'}` diff --git a/apps/api/src/plugins/auth.ts b/apps/api/src/plugins/auth.ts index 611b730..bf21a00 100644 --- a/apps/api/src/plugins/auth.ts +++ b/apps/api/src/plugins/auth.ts @@ -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), diff --git a/apps/api/src/routes/audit.ts b/apps/api/src/routes/audit.ts new file mode 100644 index 0000000..ee7290f --- /dev/null +++ b/apps/api/src/routes/audit.ts @@ -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, + }) + }) +} diff --git a/apps/api/src/routes/control.ts b/apps/api/src/routes/control.ts index 3fd26cd..ca695ec 100644 --- a/apps/api/src/routes/control.ts +++ b/apps/api/src/routes/control.ts @@ -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>, @@ -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 } }) diff --git a/apps/api/src/services/audit.test.ts b/apps/api/src/services/audit.test.ts new file mode 100644 index 0000000..da10ec3 --- /dev/null +++ b/apps/api/src/services/audit.test.ts @@ -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).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() + }) +}) diff --git a/apps/api/src/services/audit.ts b/apps/api/src/services/audit.ts new file mode 100644 index 0000000..e3fc013 --- /dev/null +++ b/apps/api/src/services/audit.ts @@ -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 | 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 { + 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 & Partial>, +): void { + recordAudit(app, config, { + ...actorFromRequest(request), + ip: input.ip ?? clientIp(request), + ...input, + }) +} diff --git a/docs/integrate-auth-portal.md b/docs/integrate-auth-portal.md index 3f36c18..c90bff7 100644 --- a/docs/integrate-auth-portal.md +++ b/docs/integrate-auth-portal.md @@ -23,6 +23,7 @@ Browser → EvoFirewall UI (нет token) | `fw:policies:read` / `write` | `/rules`, overrides | | `fw:stats:read` | `/stats` | | `fw:settings:admin` | `/settings`, install-context | +| `fw:audit:read` | `GET /api/v1/audit` (локальный журнал) | ## Env @@ -31,10 +32,13 @@ AUTH_REQUIRED=true AUTH_JWT_SECRET=<тот же JWT_SECRET портала> AUTH_ISSUER=https://auth.shnt.top AUTH_PORTAL_URL=https://auth.shnt.top +AUTH_AUDIT_INGEST_SECRET= PUBLIC_BASE_URL=https://fw.example.com EVOFW_ENROLL_SEED= ``` +Dual-write: мутации agents/lists/rules/policy пишут в локальный `audit_log` и асинхронно шлют batch в `POST {AUTH_PORTAL_URL}/api/v1/ingest/audit` с `source_app: fw`. + ```env # apps/web/.env.local VITE_AUTH_ENABLED=true diff --git a/docs/openapi.yaml b/docs/openapi.yaml index 2567bfb..40652d1 100644 --- a/docs/openapi.yaml +++ b/docs/openapi.yaml @@ -50,6 +50,23 @@ paths: responses: '200': description: Created + /api/v1/audit: + get: + summary: Local audit log (dual-write source fw) + security: [{ bearerAuth: [] }] + parameters: + - name: action + in: query + schema: { type: string } + - name: severity + in: query + schema: { type: string, enum: [info, warning, critical] } + - name: limit + in: query + schema: { type: integer, default: 200 } + responses: + '200': + description: Audit entries /v1/agent/enroll: post: summary: Enroll agent (public + seed) diff --git a/packages/db/migrations/006_audit_log.sql b/packages/db/migrations/006_audit_log.sql new file mode 100644 index 0000000..865dd87 --- /dev/null +++ b/packages/db/migrations/006_audit_log.sql @@ -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); diff --git a/packages/db/src/audit-log.ts b/packages/db/src/audit-log.ts new file mode 100644 index 0000000..bef4f6c --- /dev/null +++ b/packages/db/src/audit-log.ts @@ -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 | null + ip?: string | null + createdAt?: string | null +} + +function mapRow(row: typeof auditLog.$inferSelect): AuditLogEntry { + let details: Record | null = null + if (row.detailsJson) { + try { + details = JSON.parse(row.detailsJson) as Record + } 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) +} diff --git a/packages/db/src/index.ts b/packages/db/src/index.ts index df9fc47..8d4fb85 100644 --- a/packages/db/src/index.ts +++ b/packages/db/src/index.ts @@ -1,3 +1,4 @@ export * from './schema.js' export * from './client.js' export * from './repositories/index.js' +export * from './audit-log.js' diff --git a/packages/db/src/schema.ts b/packages/db/src/schema.ts index c39fc74..3b34acd 100644 --- a/packages/db/src/schema.ts +++ b/packages/db/src/schema.ts @@ -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, } diff --git a/packages/shared/src/contracts/audit.ts b/packages/shared/src/contracts/audit.ts new file mode 100644 index 0000000..83236a6 --- /dev/null +++ b/packages/shared/src/contracts/audit.ts @@ -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 + +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 + +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 diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 6c45e19..77dbab1 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -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' diff --git a/packages/shared/src/permissions.ts b/packages/shared/src/permissions.ts index 604781c..39ab2c8 100644 --- a/packages/shared/src/permissions.ts +++ b/packages/shared/src/permissions.ts @@ -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 }