feat(audit): dual-write журнала в auth-portal ingest
Docker / build (push) Failing after 23s

Добавлены event_id/actorUserId и async push source_app=vps без блокировки CRUD.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Denozordec
2026-07-21 13:24:30 +07:00
co-authored by Cursor
parent 8cee00e6e3
commit a02daba69b
9 changed files with 344 additions and 11 deletions
+2
View File
@@ -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=
+36
View File
@@ -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 }
}
+7 -4
View File
@@ -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<string, unknown>)
if (last?.id) {
auditCreate('vps', last.id, parsed.data as Record<string, unknown>, 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<string, unknown>)
auditUpdate('vps', req.params.id, parsed.data as Record<string, unknown>, 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<string, unknown>)
auditUpdate('vps', req.params.id, parsed.data as Record<string, unknown>, 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()
})
+138
View File
@@ -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()
})
})
+153 -6
View File
@@ -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<string, unknown>): 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<string, unknown>): 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<string, string> = {
vps: 'VPS',
payment: 'Платёж',
providerAccount: 'Аккаунт',
provider: 'Хостер',
settings: 'Настройки',
balanceLedger: 'Баланс',
serverProject: 'Проект',
}
const ACTION_LABELS: Record<AuditOp, string> = {
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<string, unknown> | 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<string, unknown>
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<string, unknown>,
ctx?: AuditActorContext,
): void {
recordAudit(entity, entityId, 'create', data, ctx)
}
export function auditUpdate(
entity: string,
entityId: string,
patch: Record<string, unknown>,
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)
}
+4 -1
View File
@@ -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<string, unknown>
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()
},
+2
View File
@@ -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`,
+1
View File
@@ -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')
+1
View File
@@ -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,