feat(admin): журнал аудита с ротацией и апгрейд таблицы пользователей
Добавлен 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:
1 parent
e430abe98b
commit
26fc8253c3
30 files changed
+2959
-59
No files matched your search
@@ -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, {
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
})
|
||||
}
|
||||
@@ -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 }
|
||||
})
|
||||
}
|
||||
@@ -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 }
|
||||
})
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user