feat(audit): локальный журнал и push в auth-portal
Таблица audit_log, recordAudit на мутациях, GET /api/v1/audit и dual-write source_app=fw. Co-authored-by: Cursor <[email protected]>
This commit is contained in:
@@ -0,0 +1,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,
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user