Добавлены event_id/actorUserId и async push source_app=vps без блокировки CRUD. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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=
|
||||
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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()
|
||||
})
|
||||
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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,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()
|
||||
},
|
||||
|
||||
@@ -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`,
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user