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 <cursoragent@cursor.com>
This commit is contained in:
1 parent
26fc8253c3
commit
57e34ff5e9
27 files changed
+1983
-639
No files matched your search
@@ -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 }
|
||||
},
|
||||
)
|
||||
}
|
||||
Reference in new issue
Block a user