diff --git a/.env.example b/.env.example index 7b39f4e..1c886b6 100644 --- a/.env.example +++ b/.env.example @@ -5,6 +5,8 @@ AUTH_REQUIRED=false AUTH_JWT_SECRET=dev-secret-change-me AUTH_ISSUER=https://auth.shnt.top AUTH_PORTAL_URL=http://localhost:5175 +# Shared with auth-portal AUDIT_INGEST_SECRET (Bearer for POST /api/v1/ingest/audit) +AUTH_AUDIT_INGEST_SECRET=dev-audit-ingest-secret # Owner of space-main after migration (portal user id) # VPS_MAIN_SPACE_OWNER_USER_ID= diff --git a/apps/api/src/lib/audit-actor.ts b/apps/api/src/lib/audit-actor.ts new file mode 100644 index 0000000..3783985 --- /dev/null +++ b/apps/api/src/lib/audit-actor.ts @@ -0,0 +1,36 @@ +import type { FastifyRequest } from 'fastify' +import type { AuthUser } from './permissions.js' + +export type AuditActorContext = { + actorUserId: string | null + actorEmail: string | null + actorName: string | null + ip: string | null +} + +export function actorFromAuthUser(user?: AuthUser | null): AuditActorContext { + if (!user) { + return { + actorUserId: null, + actorEmail: null, + actorName: null, + ip: null, + } + } + return { + actorUserId: user.id, + actorEmail: user.email || null, + actorName: user.name || null, + ip: null, + } +} + +export function actorFromRequest(request: FastifyRequest): AuditActorContext { + const base = actorFromAuthUser(request.authUser) + const forwarded = request.headers['x-forwarded-for'] + let ip: string | null = request.ip ?? null + if (typeof forwarded === 'string' && forwarded.trim()) { + ip = forwarded.split(',')[0]?.trim() ?? ip + } + return { ...base, ip } +} diff --git a/apps/api/src/routes/vps.ts b/apps/api/src/routes/vps.ts index f5ef072..fd61d29 100644 --- a/apps/api/src/routes/vps.ts +++ b/apps/api/src/routes/vps.ts @@ -5,6 +5,7 @@ import { vpsGrantsRepository } from '@cfdm/db/repositories/spaces' import { getCurrentSpaceId } from '@cfdm/db' import { vpsSchema } from '@cfdm/shared/contracts/vps' import { auditCreate, auditDelete, auditUpdate } from '../services/audit.js' +import { actorFromRequest } from '../lib/audit-actor.js' import { canWriteInSpace, requireSpaceRole } from '../plugins/space.js' export const vpsRoutes: FastifyPluginAsync = async (app) => { @@ -43,7 +44,9 @@ export const vpsRoutes: FastifyPluginAsync = async (app) => { const created = vpsRepository.create(parsed.data) const list = Array.isArray(created) ? created : [created] const last = list[list.length - 1] as { id?: string } | undefined - if (last?.id) auditCreate('vps', last.id, parsed.data as Record) + if (last?.id) { + auditCreate('vps', last.id, parsed.data as Record, actorFromRequest(req)) + } return reply.code(201).send(created) }) @@ -61,7 +64,7 @@ export const vpsRoutes: FastifyPluginAsync = async (app) => { return reply.code(404).send({ error: { code: 'NOT_FOUND', message: 'Not found' } }) } vpsDomainsRepository.rematchAll() - auditUpdate('vps', req.params.id, parsed.data as Record) + auditUpdate('vps', req.params.id, parsed.data as Record, actorFromRequest(req)) return updated } @@ -73,7 +76,7 @@ export const vpsRoutes: FastifyPluginAsync = async (app) => { if (!updated) { return reply.code(404).send({ error: { code: 'NOT_FOUND', message: 'Not found' } }) } - auditUpdate('vps', req.params.id, parsed.data as Record) + auditUpdate('vps', req.params.id, parsed.data as Record, actorFromRequest(req)) return { ...updated, access: 'shared', grantPermission: 'write' } } @@ -96,7 +99,7 @@ export const vpsRoutes: FastifyPluginAsync = async (app) => { } return reply.code(404).send({ error: { code: 'NOT_FOUND', message: 'Not found' } }) } - auditDelete('vps', req.params.id) + auditDelete('vps', req.params.id, actorFromRequest(req)) return reply.code(204).send() }) diff --git a/apps/api/src/services/audit.test.ts b/apps/api/src/services/audit.test.ts new file mode 100644 index 0000000..ac6131f --- /dev/null +++ b/apps/api/src/services/audit.test.ts @@ -0,0 +1,138 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { closeDb } from '@cfdm/db' +import { auditLogRepository } from '@cfdm/db/repositories/audit-log' +import { resetTestDb } from '@cfdm/db/test-setup' +import { + auditCreate, + loadAuditIngestConfig, + pushAuditToPortal, +} from './audit.js' + +describe('audit dual-write', () => { + beforeEach(() => { + resetTestDb() + process.env.AUTH_PORTAL_URL = 'http://portal.test' + process.env.AUTH_AUDIT_INGEST_SECRET = 'test-ingest-secret' + process.env.NODE_ENV = 'test' + }) + + afterEach(() => { + vi.unstubAllGlobals() + closeDb() + }) + + it('writes eventId and actorUserId locally', () => { + auditCreate( + 'vps', + 'vps-1', + { ip: '1.2.3.4' }, + { + actorUserId: 'user-42', + actorEmail: 'u@test.local', + actorName: 'User', + ip: '10.0.0.1', + }, + ) + + const rows = auditLogRepository.list(10) + expect(rows).toHaveLength(1) + expect(rows[0]?.entity).toBe('vps') + expect(rows[0]?.entityId).toBe('vps-1') + expect(rows[0]?.action).toBe('create') + expect(rows[0]?.actorUserId).toBe('user-42') + expect(rows[0]?.eventId).toMatch( + /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i, + ) + }) + + it('fire-and-forgets portal ingest with vps action key', async () => { + const fetchMock = vi.fn(async () => new Response(JSON.stringify({ accepted: 1, duplicates: 0 }))) + vi.stubGlobal('fetch', fetchMock) + + auditCreate( + 'vps', + 'vps-2', + { status: 'active' }, + { + actorUserId: 'user-7', + actorEmail: 'actor@test.local', + actorName: 'Actor', + ip: null, + }, + ) + + await vi.waitFor(() => 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.method).toBe('POST') + expect(init.headers).toMatchObject({ + Authorization: 'Bearer test-ingest-secret', + 'Content-Type': 'application/json', + }) + + const body = JSON.parse(String(init.body)) as { + events: Array<{ + event_id: string + source_app: string + action: string + actor_user_id: string + target_type: string + target_id: string + summary: string + }> + } + expect(body.events).toHaveLength(1) + expect(body.events[0]?.source_app).toBe('vps') + expect(body.events[0]?.action).toBe('vps.vps.create') + expect(body.events[0]?.actor_user_id).toBe('user-7') + expect(body.events[0]?.target_type).toBe('app_resource') + expect(body.events[0]?.target_id).toBe('vps-2') + expect(body.events[0]?.summary).toContain('VPS') + }) + + it('does not throw when portal ingest fails', () => { + vi.stubGlobal( + 'fetch', + vi.fn(async () => { + throw new Error('network down') + }), + ) + + expect(() => + pushAuditToPortal({ + eventId: 'evt-1', + entity: 'vps', + entityId: 'vps-3', + op: 'delete', + ctx: { + actorUserId: null, + actorEmail: null, + actorName: null, + ip: null, + }, + createdAt: new Date().toISOString(), + }), + ).not.toThrow() + }) + + it('skips portal push when ingest secret is unset in production', () => { + process.env.NODE_ENV = 'production' + delete process.env.AUTH_AUDIT_INGEST_SECRET + + const fetchMock = vi.fn() + vi.stubGlobal('fetch', fetchMock) + + pushAuditToPortal({ + eventId: 'evt-2', + entity: 'vps', + entityId: 'vps-4', + op: 'update', + diff: { ip: '9.9.9.9' }, + createdAt: new Date().toISOString(), + }) + + expect(fetchMock).not.toHaveBeenCalled() + expect(loadAuditIngestConfig().ingestSecret).toBeUndefined() + }) +}) diff --git a/apps/api/src/services/audit.ts b/apps/api/src/services/audit.ts index d233435..3eeaeeb 100644 --- a/apps/api/src/services/audit.ts +++ b/apps/api/src/services/audit.ts @@ -1,13 +1,160 @@ +import { randomUUID } from 'node:crypto' import { auditLogRepository } from '@cfdm/db/repositories/audit-log' +import type { AuditActorContext } from '../lib/audit-actor.js' -export function auditCreate(entity: string, entityId: string, data?: Record): void { - auditLogRepository.append({ entity, entityId, action: 'create', diff: data }) +type AuditOp = 'create' | 'update' | 'delete' + +export type AuditIngestConfig = { + portalUrl: string + ingestSecret: string | undefined } -export function auditUpdate(entity: string, entityId: string, patch: Record): void { - auditLogRepository.append({ entity, entityId, action: 'update', diff: patch }) +export function loadAuditIngestConfig( + env: NodeJS.ProcessEnv = process.env, +): AuditIngestConfig { + const isProd = env.NODE_ENV === 'production' + const portalUrl = ( + env.AUTH_PORTAL_URL ?? + env.VITE_AUTH_PORTAL_URL ?? + 'http://localhost:5175' + ).replace(/\/$/, '') + const ingestSecret = + env.AUTH_AUDIT_INGEST_SECRET ?? + (isProd ? undefined : 'dev-audit-ingest-secret') + return { portalUrl, ingestSecret } } -export function auditDelete(entity: string, entityId: string): void { - auditLogRepository.append({ entity, entityId, action: 'delete' }) +const ENTITY_LABELS: Record = { + vps: 'VPS', + payment: 'Платёж', + providerAccount: 'Аккаунт', + provider: 'Хостер', + settings: 'Настройки', + balanceLedger: 'Баланс', + serverProject: 'Проект', +} + +const ACTION_LABELS: Record = { + create: 'создание', + update: 'изменение', + delete: 'удаление', +} + +function auditSummary(entity: string, op: AuditOp, entityId: string): string { + const label = ENTITY_LABELS[entity] ?? entity + const verb = ACTION_LABELS[op] + return `${label}: ${verb} (${entityId})` +} + +function portalAction(entity: string, op: AuditOp): string { + return `vps.${entity}.${op}` +} + +function recordAudit( + entity: string, + entityId: string, + op: AuditOp, + diff: Record | undefined, + ctx?: AuditActorContext, +): void { + const eventId = randomUUID() + const createdAt = new Date().toISOString() + const actorUserId = ctx?.actorUserId ?? null + + try { + auditLogRepository.append({ + eventId, + entity, + entityId, + action: op, + diff, + actorUserId, + createdAt, + }) + } catch { + // Local audit must not break CRUD; swallow repository errors too. + return + } + + pushAuditToPortal({ + eventId, + entity, + entityId, + op, + diff, + ctx, + createdAt, + }) +} + +type PortalPushInput = { + eventId: string + entity: string + entityId: string + op: AuditOp + diff?: Record + ctx?: AuditActorContext + createdAt: string +} + +export function pushAuditToPortal(input: PortalPushInput): void { + const config = loadAuditIngestConfig() + if (!config.ingestSecret) return + + const body = { + events: [ + { + event_id: input.eventId, + source_app: 'vps' as const, + action: portalAction(input.entity, input.op), + severity: 'info' as const, + actor_user_id: input.ctx?.actorUserId ?? null, + actor_email: input.ctx?.actorEmail ?? null, + actor_name: input.ctx?.actorName ?? null, + target_type: 'app_resource' as const, + target_id: input.entityId, + summary: auditSummary(input.entity, input.op, input.entityId), + details: input.diff ?? null, + ip: input.ctx?.ip ?? null, + created_at: input.createdAt, + }, + ], + } + + void fetch(`${config.portalUrl}/api/v1/ingest/audit`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${config.ingestSecret}`, + }, + body: JSON.stringify(body), + }).catch(() => { + // Fire-and-forget: portal ingest failures must not affect CRUD. + }) +} + +export function auditCreate( + entity: string, + entityId: string, + data?: Record, + ctx?: AuditActorContext, +): void { + recordAudit(entity, entityId, 'create', data, ctx) +} + +export function auditUpdate( + entity: string, + entityId: string, + patch: Record, + ctx?: AuditActorContext, +): void { + recordAudit(entity, entityId, 'update', patch, ctx) +} + +export function auditDelete( + entity: string, + entityId: string, + ctx?: AuditActorContext, +): void { + recordAudit(entity, entityId, 'delete', undefined, ctx) } diff --git a/packages/db/src/repositories/audit-log.ts b/packages/db/src/repositories/audit-log.ts index aa402af..bcea1e0 100644 --- a/packages/db/src/repositories/audit-log.ts +++ b/packages/db/src/repositories/audit-log.ts @@ -4,11 +4,13 @@ import { getDb, schema } from '../index.js' import { getCurrentSpaceId } from '../space-context.js' export interface AuditEntryInput { + eventId: string entity: string entityId: string action: 'create' | 'update' | 'delete' diff?: Record actorUserId?: string | null + createdAt?: string } function parseDiff(row: { diff: string | null }) { @@ -26,13 +28,14 @@ export const auditLogRepository = { .insert(schema.auditLog) .values({ id: `audit-${randomUUID()}`, + eventId: input.eventId, spaceId: getCurrentSpaceId(), entity: input.entity, entityId: input.entityId, action: input.action, diff: input.diff ? JSON.stringify(input.diff) : null, actorUserId: input.actorUserId ?? null, - createdAt: new Date().toISOString(), + createdAt: input.createdAt ?? new Date().toISOString(), }) .run() }, diff --git a/packages/db/src/runtime-migrate.ts b/packages/db/src/runtime-migrate.ts index a9febe4..c711ee1 100644 --- a/packages/db/src/runtime-migrate.ts +++ b/packages/db/src/runtime-migrate.ts @@ -220,6 +220,7 @@ const CORE_TABLE_MIGRATIONS: string[] = [ )`, `CREATE TABLE IF NOT EXISTS audit_log ( id TEXT PRIMARY KEY, + eventId TEXT, spaceId TEXT NOT NULL DEFAULT 'space-main', entity TEXT NOT NULL, entityId TEXT NOT NULL, @@ -325,6 +326,7 @@ const COLUMN_MIGRATIONS: string[] = [ `ALTER TABLE vps_health_checks ADD COLUMN spaceId TEXT`, `ALTER TABLE audit_log ADD COLUMN spaceId TEXT`, `ALTER TABLE audit_log ADD COLUMN actorUserId TEXT`, + `ALTER TABLE audit_log ADD COLUMN eventId TEXT`, `ALTER TABLE spaces ADD COLUMN deletedAt TEXT`, `ALTER TABLE sync_log ADD COLUMN spaceId TEXT`, `ALTER TABLE sync_log ADD COLUMN summary TEXT`, diff --git a/packages/db/src/schema/index.ts b/packages/db/src/schema/index.ts index 67526aa..cd63468 100644 --- a/packages/db/src/schema/index.ts +++ b/packages/db/src/schema/index.ts @@ -275,6 +275,7 @@ export const vpsHealthChecks = sqliteTable('vps_health_checks', { export const auditLog = sqliteTable('audit_log', { id: text('id').primaryKey(), + eventId: text('eventId'), spaceId: text('spaceId') .notNull() .default('space-main') diff --git a/packages/db/src/test-setup.ts b/packages/db/src/test-setup.ts index 6b992c8..fc1373f 100644 --- a/packages/db/src/test-setup.ts +++ b/packages/db/src/test-setup.ts @@ -264,6 +264,7 @@ CREATE TABLE IF NOT EXISTS server_projects ( CREATE TABLE IF NOT EXISTS audit_log ( id TEXT PRIMARY KEY, + eventId TEXT, spaceId TEXT NOT NULL DEFAULT 'space-main', entity TEXT NOT NULL, entityId TEXT NOT NULL,