feat(admin): ingest аудита из apps и users 1:1 с Sheet журнала
Добавлен 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:
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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 () => ({
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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 }
|
||||
},
|
||||
)
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user