feat(admin): ingest аудита из apps и users 1:1 с Sheet журнала
Build and Push Auth Portal Docker Image / build-and-push (push) Successful in 1m46s
Build and Push Auth Portal Docker Image / create-release (push) Skipped

Добавлен POST /api/v1/ingest/audit, фильтры source_app/user_id, last_login_at; таблица пользователей по solution-users-1 с журналом в Sheet.

Co-authored-by: Cursor <[email protected]>
This commit is contained in:
Denozordec
2026-07-21 13:24:28 +07:00
co-authored by Cursor
parent 26fc8253c3
commit 57e34ff5e9
27 changed files with 1983 additions and 639 deletions
+2
View File
@@ -28,6 +28,7 @@ import type { AppConfig } from './config.js'
import { authRoutes } from './routes/auth.js'
import { adminRoutes } from './routes/admin.js'
import { auditAdminRoutes } from './routes/audit.js'
import { auditIngestRoutes } from './routes/ingest-audit.js'
import { startAuditRetentionJob } from './services/audit-retention.js'
declare module 'fastify' {
@@ -119,6 +120,7 @@ export async function buildApp(opts: {
await app.register(authRoutes)
await app.register(adminRoutes)
await app.register(auditAdminRoutes)
await app.register(auditIngestRoutes)
if (process.env.NODE_ENV !== 'test') {
const stopRetention = startAuditRetentionJob(app)
+8 -1
View File
@@ -19,6 +19,7 @@ export const configSchema = z.object({
staticDir: z.string().optional(),
logLevel: z.string().default('info'),
isProd: z.boolean(),
auditIngestSecret: z.string().min(8).optional(),
})
export type AppConfig = z.infer<typeof configSchema>
@@ -26,6 +27,9 @@ export type AppConfig = z.infer<typeof configSchema>
export function loadConfig(env: NodeJS.ProcessEnv = process.env): AppConfig {
const isProd = env.NODE_ENV === 'production'
const jwtSecret = env.JWT_SECRET ?? (isProd ? '' : 'dev-secret-change-me')
const auditIngestSecret =
env.AUDIT_INGEST_SECRET ??
(isProd ? undefined : 'dev-audit-ingest-secret')
return configSchema.parse({
databaseUrl: env.DATABASE_URL ?? 'sqlite:data/app.db',
@@ -41,6 +45,9 @@ export function loadConfig(env: NodeJS.ProcessEnv = process.env): AppConfig {
serverPort: env.SERVER_PORT ?? 8080,
staticDir: env.STATIC_DIR || undefined,
logLevel: env.LOG_LEVEL ?? 'info',
isProd: boolFromEnv(env.NODE_ENV === 'production' ? 'true' : undefined, false) || isProd,
isProd:
boolFromEnv(env.NODE_ENV === 'production' ? 'true' : undefined, false) ||
isProd,
auditIngestSecret,
})
}
+1
View File
@@ -45,6 +45,7 @@ function mapUser(
disabled: user.disabled,
apps,
permissions,
last_login_at: user.lastLoginAt ?? null,
created_at: user.createdAt,
updated_at: user.updatedAt,
}
+8 -1
View File
@@ -25,7 +25,14 @@ export async function auditAdminRoutes(app: FastifyInstance): Promise<void> {
error: { code: 'VALIDATION_ERROR', message: 'Некорректные параметры' },
})
}
return listAudit(app.db, parsed.data)
const q = parsed.data
return listAudit(app.db, {
action: q.action,
severity: q.severity,
userId: q.user_id,
sourceApp: q.source_app,
limit: q.limit,
})
})
app.get('/api/v1/admin/audit/settings', async () => ({
+2
View File
@@ -14,6 +14,7 @@ import {
getUserById,
listUsers,
revokeRefreshSession,
touchLastLogin,
} from '@authportal/db'
import { requireAuth } from '../plugins/auth-guards.js'
import { issueAccessToken } from '../lib/issue-access-token.js'
@@ -82,6 +83,7 @@ export async function authRoutes(app: FastifyInstance): Promise<void> {
Date.now() + app.config.refreshTtlDays * 24 * 60 * 60 * 1000,
)
createRefreshSession(app.db, user.id, refreshRaw, refreshExpires)
touchLastLogin(app.db, user.id)
reply.header(
'Set-Cookie',
+77
View File
@@ -0,0 +1,77 @@
import type { FastifyInstance } from 'fastify'
import { appendAudit } from '@authportal/db'
import {
ingestAuditRequestSchema,
type AuditSourceApp,
} from '@authportal/shared'
import { timingSafeEqual } from 'node:crypto'
function secretsEqual(a: string, b: string): boolean {
const ba = Buffer.from(a)
const bb = Buffer.from(b)
if (ba.length !== bb.length) return false
return timingSafeEqual(ba, bb)
}
export async function auditIngestRoutes(app: FastifyInstance): Promise<void> {
app.post(
'/api/v1/ingest/audit',
{
config: { rateLimit: { max: 120, timeWindow: '1 minute' } },
},
async (request, reply) => {
const secret = app.config.auditIngestSecret
if (!secret) {
return reply.status(503).send({
error: {
code: 'UNAVAILABLE',
message: 'Audit ingest не настроен',
},
})
}
const auth = request.headers.authorization ?? ''
const token = auth.startsWith('Bearer ') ? auth.slice(7).trim() : ''
if (!token || !secretsEqual(token, secret)) {
return reply.status(401).send({
error: { code: 'UNAUTHORIZED', message: 'Неверный ingest secret' },
})
}
const parsed = ingestAuditRequestSchema.safeParse(request.body)
if (!parsed.success) {
return reply.status(400).send({
error: { code: 'VALIDATION_ERROR', message: 'Некорректные данные' },
})
}
let accepted = 0
let duplicates = 0
for (const event of parsed.data.events) {
try {
const inserted = appendAudit(app.db, {
eventId: event.event_id,
sourceApp: event.source_app as AuditSourceApp,
action: event.action,
severity: event.severity,
actorUserId: event.actor_user_id,
actorEmail: event.actor_email,
actorName: event.actor_name,
targetType: event.target_type,
targetId: event.target_id,
summary: event.summary,
details: event.details,
ip: event.ip,
createdAt: event.created_at,
})
if (inserted) accepted += 1
else duplicates += 1
} catch (err) {
app.log.warn({ err, event_id: event.event_id }, 'ingest append failed')
}
}
return { accepted, duplicates }
},
)
}
+60
View File
@@ -11,6 +11,7 @@ async function buildTestApp() {
ADMIN_PASSWORD: 'adminpass',
DATABASE_URL: 'sqlite::memory:',
NODE_ENV: 'test',
AUDIT_INGEST_SECRET: 'dev-audit-ingest-secret',
})
return buildApp({ config, databaseUrl: 'sqlite::memory:' })
}
@@ -26,6 +27,65 @@ async function adminToken(app: Awaited<ReturnType<typeof buildTestApp>>) {
}
describe('audit log API', () => {
it('ingests external events with secret and dedupes by event_id', async () => {
const app = await buildTestApp()
const denied = await app.inject({
method: 'POST',
url: '/api/v1/ingest/audit',
payload: { events: [] },
})
expect(denied.statusCode).toBe(401)
const eventId = 'evt-test-1'
const ok = await app.inject({
method: 'POST',
url: '/api/v1/ingest/audit',
headers: { authorization: 'Bearer dev-audit-ingest-secret' },
payload: {
events: [
{
event_id: eventId,
source_app: 'vps',
action: 'vps.vps.create',
summary: 'Создан VPS',
actor_email: '[email protected]',
},
],
},
})
expect(ok.statusCode).toBe(200)
expect(ok.json()).toMatchObject({ accepted: 1, duplicates: 0 })
const dup = await app.inject({
method: 'POST',
url: '/api/v1/ingest/audit',
headers: { authorization: 'Bearer dev-audit-ingest-secret' },
payload: {
events: [
{
event_id: eventId,
source_app: 'vps',
action: 'vps.vps.create',
summary: 'Создан VPS',
},
],
},
})
expect(dup.json()).toMatchObject({ accepted: 0, duplicates: 1 })
const token = await adminToken(app)
const list = await app.inject({
method: 'GET',
url: '/api/v1/admin/audit?source_app=vps',
headers: { authorization: `Bearer ${token}` },
})
expect(list.statusCode).toBe(200)
const entries = list.json() as { source_app: string; action: string }[]
expect(entries.some((e) => e.action === 'vps.vps.create')).toBe(true)
await app.close()
})
it('records login and lists for admin', async () => {
const app = await buildTestApp()
const token = await adminToken(app)