feat(admin): журнал аудита с ротацией и апгрейд таблицы пользователей
Build and Push Auth Portal Docker Image / build-and-push (push) Successful in 1m37s
Build and Push Auth Portal Docker Image / create-release (push) Skipped

Добавлен audit log (solution-users-6) с retention N дней и hourly purge; таблица пользователей приведена к DNA solution-users-1 (Filters, avatar, sorting).

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
DenozordecandCursor committed 2026-07-21 04:20:47 +07:00
1 parent e430abe98b
commit 26fc8253c3
30 files changed
+2959 -59

No files matched your search

+10
View File
@@ -27,6 +27,8 @@ import { randomUUID } from 'node:crypto'
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 { startAuditRetentionJob } from './services/audit-retention.js'
declare module 'fastify' {
interface FastifyInstance {
@@ -116,6 +118,14 @@ export async function buildApp(opts: {
await ensureBootstrapAdmin(app)
await app.register(authRoutes)
await app.register(adminRoutes)
await app.register(auditAdminRoutes)
if (process.env.NODE_ENV !== 'test') {
const stopRetention = startAuditRetentionJob(app)
app.addHook('onClose', async () => {
stopRetention()
})
}
if (config.staticDir && existsSync(config.staticDir)) {
await app.register(fastifyStatic, {
+43
View File
@@ -0,0 +1,43 @@
import type { FastifyInstance, FastifyRequest } from 'fastify'
import {
appendAudit,
type AppendAuditInput,
} from '@authportal/db'
export function clientIp(request: FastifyRequest): string | null {
const forwarded = request.headers['x-forwarded-for']
if (typeof forwarded === 'string' && forwarded.trim()) {
return forwarded.split(',')[0]?.trim() ?? null
}
return request.ip ?? null
}
export function safeAudit(
app: FastifyInstance,
input: AppendAuditInput,
): void {
try {
appendAudit(app.db, input)
} catch (err) {
app.log.warn({ err }, 'audit_log append failed')
}
}
export function actorFromRequest(request: FastifyRequest): Pick<
AppendAuditInput,
'actorUserId' | 'actorEmail' | 'actorName'
> {
const u = request.authUser
if (!u) {
return {
actorUserId: null,
actorEmail: null,
actorName: null,
}
}
return {
actorUserId: u.id,
actorEmail: u.email,
actorName: u.name,
}
}
+77 -4
View File
@@ -25,6 +25,7 @@ import {
type AppId,
} from '@authportal/shared'
import { requireAdmin } from '../plugins/auth-guards.js'
import { actorFromRequest, clientIp, safeAudit } from '../lib/audit.js'
const allowedPermissions = new Set(allPermissionKeys())
@@ -115,7 +116,21 @@ export async function adminRoutes(app: FastifyInstance): Promise<void> {
isAdmin: data.is_admin,
})
setUserAccess(app.db, user.id, data.apps, permissions)
return reply.status(201).send(mapUser(app.db, getUserById(app.db, user.id)!))
const mapped = mapUser(app.db, getUserById(app.db, user.id)!)
safeAudit(app, {
action: 'user.create',
severity: 'info',
...actorFromRequest(request),
targetType: 'user',
targetId: user.id,
summary: `Создан пользователь ${mapped.email}`,
details: {
is_admin: mapped.is_admin,
apps: mapped.apps,
},
ip: clientIp(request),
})
return reply.status(201).send(mapped)
})
app.patch<{ Params: { id: string } }>(
@@ -152,7 +167,21 @@ export async function adminRoutes(app: FastifyInstance): Promise<void> {
isAdmin: parsed.data.is_admin,
disabled: parsed.data.disabled,
})
return mapUser(app.db, updated!)
const mapped = mapUser(app.db, updated!)
safeAudit(app, {
action: 'user.update',
severity: parsed.data.disabled ? 'warning' : 'info',
...actorFromRequest(request),
targetType: 'user',
targetId: mapped.id,
summary: `Изменён пользователь ${mapped.email}`,
details: {
...parsed.data,
password: parsed.data.password ? '[changed]' : undefined,
},
ip: clientIp(request),
})
return mapped
},
)
@@ -167,12 +196,28 @@ export async function adminRoutes(app: FastifyInstance): Promise<void> {
},
})
}
const existing = getUserById(app.db, request.params.id)
if (!existing) {
return reply.status(404).send({
error: { code: 'NOT_FOUND', message: 'Пользователь не найден' },
})
}
const ok = deleteUser(app.db, request.params.id)
if (!ok) {
return reply.status(404).send({
error: { code: 'NOT_FOUND', message: 'Пользователь не найден' },
})
}
safeAudit(app, {
action: 'user.delete',
severity: 'critical',
...actorFromRequest(request),
targetType: 'user',
targetId: existing.id,
summary: `Удалён пользователь ${existing.email}`,
details: { email: existing.email, name: existing.name },
ip: clientIp(request),
})
return reply.status(204).send()
},
)
@@ -205,7 +250,21 @@ export async function adminRoutes(app: FastifyInstance): Promise<void> {
parsed.data.apps,
permissions,
)
return mapUser(app.db, getUserById(app.db, request.params.id)!)
const mapped = mapUser(app.db, getUserById(app.db, request.params.id)!)
safeAudit(app, {
action: 'user.access_update',
severity: 'info',
...actorFromRequest(request),
targetType: 'user',
targetId: mapped.id,
summary: `Обновлены права: ${mapped.email}`,
details: {
apps: mapped.apps,
permissions_count: mapped.permissions.length,
},
ip: clientIp(request),
})
return mapped
},
)
@@ -220,6 +279,20 @@ export async function adminRoutes(app: FastifyInstance): Promise<void> {
error: { code: 'VALIDATION_ERROR', message: 'Некорректные данные' },
})
}
return setAppSwitcherConfig(app.db, parsed.data)
const result = setAppSwitcherConfig(app.db, parsed.data)
safeAudit(app, {
action: 'app_switcher.update',
severity: 'info',
...actorFromRequest(request),
targetType: 'settings',
targetId: 'app_switcher',
summary: 'Обновлены ссылки App Switcher',
details: {
menuLabel: result.menuLabel,
apps: result.apps.map((a) => a.id),
},
ip: clientIp(request),
})
return result
})
}
+74
View File
@@ -0,0 +1,74 @@
import type { FastifyInstance } from 'fastify'
import {
getAuditRetentionDays,
listAudit,
purgeAuditOlderThan,
setAuditRetentionDays,
} from '@authportal/db'
import {
auditListQuerySchema,
putAuditSettingsSchema,
} from '@authportal/shared'
import { actorFromRequest, clientIp, safeAudit } from '../lib/audit.js'
import { requireAdmin } from '../plugins/auth-guards.js'
export async function auditAdminRoutes(app: FastifyInstance): Promise<void> {
app.addHook('onRequest', async (request, reply) => {
if (!request.url.startsWith('/api/v1/admin/audit')) return
await requireAdmin(request, reply)
})
app.get('/api/v1/admin/audit', async (request, reply) => {
const parsed = auditListQuerySchema.safeParse(request.query)
if (!parsed.success) {
return reply.status(400).send({
error: { code: 'VALIDATION_ERROR', message: 'Некорректные параметры' },
})
}
return listAudit(app.db, parsed.data)
})
app.get('/api/v1/admin/audit/settings', async () => ({
retention_days: getAuditRetentionDays(app.db),
}))
app.put('/api/v1/admin/audit/settings', async (request, reply) => {
const parsed = putAuditSettingsSchema.safeParse(request.body)
if (!parsed.success) {
return reply.status(400).send({
error: {
code: 'VALIDATION_ERROR',
message: 'retention_days: от 7 до 3650',
},
})
}
const prev = getAuditRetentionDays(app.db)
const days = setAuditRetentionDays(app.db, parsed.data.retention_days)
safeAudit(app, {
action: 'audit.settings_update',
severity: 'info',
...actorFromRequest(request),
targetType: 'settings',
targetId: 'audit_retention',
summary: `Срок хранения журнала: ${prev} → ${days} дн.`,
details: { previous: prev, retention_days: days },
ip: clientIp(request),
})
return { retention_days: days }
})
app.post('/api/v1/admin/audit/purge', async (request) => {
const days = getAuditRetentionDays(app.db)
const deleted = purgeAuditOlderThan(app.db, days)
safeAudit(app, {
action: 'audit.purge',
severity: 'warning',
...actorFromRequest(request),
targetType: 'system',
summary: `Ручная очистка: удалено ${deleted} записей старше ${days} дн.`,
details: { deleted, retention_days: days, source: 'manual' },
ip: clientIp(request),
})
return { deleted, retention_days: days }
})
}
+65 -1
View File
@@ -6,7 +6,6 @@ import {
appsMetaFromSwitcher,
loginRequestSchema,
publicAppSwitcherConfig,
type LoginResponse,
} from '@authportal/shared'
import {
createRefreshSession,
@@ -18,6 +17,7 @@ import {
} from '@authportal/db'
import { requireAuth } from '../plugins/auth-guards.js'
import { issueAccessToken } from '../lib/issue-access-token.js'
import { clientIp, safeAudit } from '../lib/audit.js'
const REFRESH_COOKIE = 'refresh_token'
@@ -39,8 +39,18 @@ export async function authRoutes(app: FastifyInstance): Promise<void> {
}
const { email, password } = parsed.data
const ip = clientIp(request)
const user = getUserByEmail(app.db, email)
if (!user || user.disabled) {
safeAudit(app, {
action: 'auth.login_failed',
severity: 'warning',
actorEmail: email.toLowerCase(),
targetType: 'session',
summary: `Неудачный вход: ${email}`,
details: { reason: !user ? 'unknown_user' : 'disabled' },
ip,
})
return reply.status(401).send({
error: { code: 'UNAUTHORIZED', message: 'Неверный email или пароль' },
})
@@ -48,6 +58,18 @@ export async function authRoutes(app: FastifyInstance): Promise<void> {
const ok = await verify(user.passwordHash, password)
if (!ok) {
safeAudit(app, {
action: 'auth.login_failed',
severity: 'warning',
actorUserId: user.id,
actorEmail: user.email,
actorName: user.name,
targetType: 'session',
targetId: user.id,
summary: `Неудачный вход: ${user.email}`,
details: { reason: 'bad_password' },
ip,
})
return reply.status(401).send({
error: { code: 'UNAUTHORIZED', message: 'Неверный email или пароль' },
})
@@ -66,6 +88,18 @@ export async function authRoutes(app: FastifyInstance): Promise<void> {
`${REFRESH_COOKIE}=${refreshRaw}; Path=/; HttpOnly; SameSite=Lax; Max-Age=${app.config.refreshTtlDays * 86400}${app.config.isProd ? '; Secure' : ''}`,
)
safeAudit(app, {
action: 'auth.login',
severity: 'info',
actorUserId: user.id,
actorEmail: user.email,
actorName: user.name,
targetType: 'session',
targetId: user.id,
summary: `Вход: ${user.email}`,
ip,
})
return body
},
})
@@ -102,6 +136,36 @@ export async function authRoutes(app: FastifyInstance): Promise<void> {
'Set-Cookie',
`${REFRESH_COOKIE}=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0`,
)
// Best-effort actor from JWT if present
let actorUserId: string | null = null
let actorEmail: string | null = null
let actorName: string | null = null
try {
await request.jwtVerify()
const sub = request.user.sub
const row = getUserById(app.db, sub)
if (row) {
actorUserId = row.id
actorEmail = row.email
actorName = row.name
}
} catch {
/* anonymous logout */
}
safeAudit(app, {
action: 'auth.logout',
severity: 'info',
actorUserId,
actorEmail,
actorName,
targetType: 'session',
targetId: actorUserId,
summary: actorEmail ? `Выход: ${actorEmail}` : 'Выход',
ip: clientIp(request),
})
return { ok: true }
})
+41
View File
@@ -0,0 +1,41 @@
import type { FastifyInstance } from 'fastify'
import {
getAuditRetentionDays,
purgeAuditOlderThan,
} from '@authportal/db'
import { safeAudit } from '../lib/audit.js'
const HOUR_MS = 60 * 60 * 1000
export function startAuditRetentionJob(app: FastifyInstance): () => void {
const run = () => {
try {
const days = getAuditRetentionDays(app.db)
const deleted = purgeAuditOlderThan(app.db, days)
if (deleted > 0) {
safeAudit(app, {
action: 'audit.purge',
severity: 'info',
targetType: 'system',
summary: `Автоочистка: удалено ${deleted} записей старше ${days} дн.`,
details: { deleted, retention_days: days, source: 'scheduler' },
})
app.log.info(
{ deleted, retention_days: days },
'audit_log retention purge',
)
}
} catch (err) {
app.log.warn({ err }, 'audit_log retention job failed')
}
}
// Defer first run slightly so bootstrap finishes
const initial = setTimeout(run, 15_000)
const interval = setInterval(run, HOUR_MS)
return () => {
clearTimeout(initial)
clearInterval(interval)
}
}
+6 -1
View File
@@ -16,7 +16,12 @@ describe('app-switcher API', () => {
expect(res.statusCode).toBe(200)
const body = res.json() as { menuLabel: string; apps: { id: string }[] }
expect(body.menuLabel).toBeTruthy()
expect(body.apps.map((a) => a.id).sort()).toEqual(['bgp', 'cfdm', 'vps'])
expect(body.apps.map((a) => a.id).sort()).toEqual([
'bgp',
'cfdm',
'fw',
'vps',
])
await app.close()
})
+126
View File
@@ -0,0 +1,126 @@
import { describe, expect, it } from 'vitest'
import { appendAudit, listAudit, purgeAuditOlderThan } from '@authportal/db'
import { buildApp } from '../src/app.js'
import { loadConfig } from '../src/config.js'
async function buildTestApp() {
const config = loadConfig({
...process.env,
JWT_SECRET: 'test-secret-at-least-8',
ADMIN_EMAIL: 'admin@test.local',
ADMIN_PASSWORD: 'adminpass',
DATABASE_URL: 'sqlite::memory:',
NODE_ENV: 'test',
})
return buildApp({ config, databaseUrl: 'sqlite::memory:' })
}
async function adminToken(app: Awaited<ReturnType<typeof buildTestApp>>) {
const login = await app.inject({
method: 'POST',
url: '/api/v1/auth/login',
payload: { email: 'admin@test.local', password: 'adminpass' },
})
expect(login.statusCode).toBe(200)
return (login.json() as { access_token: string }).access_token
}
describe('audit log API', () => {
it('records login and lists for admin', async () => {
const app = await buildTestApp()
const token = await adminToken(app)
const list = await app.inject({
method: 'GET',
url: '/api/v1/admin/audit',
headers: { authorization: `Bearer ${token}` },
})
expect(list.statusCode).toBe(200)
const entries = list.json() as { action: string }[]
expect(entries.some((e) => e.action === 'auth.login')).toBe(true)
const denied = await app.inject({
method: 'GET',
url: '/api/v1/admin/audit',
})
expect(denied.statusCode).toBe(401)
await app.close()
})
it('updates retention and purges old rows', async () => {
const app = await buildTestApp()
const token = await adminToken(app)
appendAudit(app.db, {
action: 'user.create',
summary: 'old event',
})
// Backdate the last inserted row
app.sqlite
.prepare(
`UPDATE audit_log SET created_at = ? WHERE summary = 'old event'`,
)
.run(new Date(Date.now() - 40 * 24 * 60 * 60 * 1000).toISOString())
const settings = await app.inject({
method: 'PUT',
url: '/api/v1/admin/audit/settings',
headers: { authorization: `Bearer ${token}` },
payload: { retention_days: 30 },
})
expect(settings.statusCode).toBe(200)
expect(settings.json()).toEqual({ retention_days: 30 })
const before = listAudit(app.db, { limit: 500 })
expect(before.some((e) => e.summary === 'old event')).toBe(true)
const deleted = purgeAuditOlderThan(app.db, 30)
expect(deleted).toBeGreaterThanOrEqual(1)
expect(listAudit(app.db, { limit: 500 }).some((e) => e.summary === 'old event')).toBe(
false,
)
const purge = await app.inject({
method: 'POST',
url: '/api/v1/admin/audit/purge',
headers: { authorization: `Bearer ${token}` },
})
expect(purge.statusCode).toBe(200)
expect((purge.json() as { retention_days: number }).retention_days).toBe(30)
await app.close()
})
it('records user.create from admin mutation', async () => {
const app = await buildTestApp()
const token = await adminToken(app)
const create = await app.inject({
method: 'POST',
url: '/api/v1/admin/users',
headers: { authorization: `Bearer ${token}` },
payload: {
email: 'new@test.local',
name: 'New User',
password: 'secret12',
is_admin: false,
apps: ['cfdm'],
permissions: [],
},
})
expect(create.statusCode).toBe(201)
const list = await app.inject({
method: 'GET',
url: '/api/v1/admin/audit?action=user.create',
headers: { authorization: `Bearer ${token}` },
})
expect(list.statusCode).toBe(200)
const entries = list.json() as { action: string; summary: string }[]
expect(entries.length).toBeGreaterThanOrEqual(1)
expect(entries[0]?.summary).toContain('new@test.local')
await app.close()
})
})