feat(audit): локальный журнал и push в auth-portal
Таблица audit_log, recordAudit на мутациях, GET /api/v1/audit и dual-write source_app=fw. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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' },
|
||||
)
|
||||
|
||||
@@ -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'}`
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -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 }
|
||||
})
|
||||
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
@@ -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=<AUDIT_INGEST_SECRET портала>
|
||||
PUBLIC_BASE_URL=https://fw.example.com
|
||||
EVOFW_ENROLL_SEED=<hex/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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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