From 57e34ff5e950eb9796dff8b9b053b6a1a90ba50f Mon Sep 17 00:00:00 2001 From: Denozordec Date: Tue, 21 Jul 2026 13:24:28 +0700 Subject: [PATCH] =?UTF-8?q?feat(admin):=20ingest=20=D0=B0=D1=83=D0=B4?= =?UTF-8?q?=D0=B8=D1=82=D0=B0=20=D0=B8=D0=B7=20apps=20=D0=B8=20users=201:1?= =?UTF-8?q?=20=D1=81=20Sheet=20=D0=B6=D1=83=D1=80=D0=BD=D0=B0=D0=BB=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Добавлен POST /api/v1/ingest/audit, фильтры source_app/user_id, last_login_at; таблица пользователей по solution-users-1 с журналом в Sheet. Co-authored-by: Cursor --- apps/api/src/app.ts | 2 + apps/api/src/config.ts | 9 +- apps/api/src/routes/admin.ts | 1 + apps/api/src/routes/audit.ts | 9 +- apps/api/src/routes/auth.ts | 2 + apps/api/src/routes/ingest-audit.ts | 77 ++ apps/api/test/audit.test.ts | 60 + .../components/reui-kit/admin-users-grid.tsx | 1014 +++++++++++++++++ .../components/reui-kit/audit-log-helpers.ts | 22 +- .../reui-kit/audit-log-timeline.tsx | 163 ++- .../components/reui-kit/create-user-sheet.tsx | 172 +++ apps/web/src/components/reui-kit/index.ts | 3 + .../components/reui-kit/user-audit-sheet.tsx | 109 ++ apps/web/src/queries/audit.ts | 41 +- apps/web/src/routes/_auth.admin.audit.tsx | 2 +- apps/web/src/routes/_auth.admin.index.tsx | 715 +++--------- docs/integrate-audit-ingest.md | 63 + docs/integrate-cfdm.md | 4 + docs/integrate-evobgp.md | 4 + docs/integrate-evofirewall.md | 4 + docs/integrate-vps-tracker.md | 4 + packages/db/src/audit-log.ts | 48 +- packages/db/src/index.ts | 37 +- packages/db/src/schema/index.ts | 3 + packages/db/src/users.ts | 8 + packages/shared/src/contracts/audit.ts | 45 +- packages/shared/src/contracts/auth.ts | 1 + 27 files changed, 1983 insertions(+), 639 deletions(-) create mode 100644 apps/api/src/routes/ingest-audit.ts create mode 100644 apps/web/src/components/reui-kit/admin-users-grid.tsx create mode 100644 apps/web/src/components/reui-kit/create-user-sheet.tsx create mode 100644 apps/web/src/components/reui-kit/user-audit-sheet.tsx create mode 100644 docs/integrate-audit-ingest.md diff --git a/apps/api/src/app.ts b/apps/api/src/app.ts index fc32204..c0abd89 100644 --- a/apps/api/src/app.ts +++ b/apps/api/src/app.ts @@ -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) diff --git a/apps/api/src/config.ts b/apps/api/src/config.ts index 19017e9..691db21 100644 --- a/apps/api/src/config.ts +++ b/apps/api/src/config.ts @@ -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 @@ -26,6 +27,9 @@ export type AppConfig = z.infer 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, }) } diff --git a/apps/api/src/routes/admin.ts b/apps/api/src/routes/admin.ts index ce68572..8b39b24 100644 --- a/apps/api/src/routes/admin.ts +++ b/apps/api/src/routes/admin.ts @@ -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, } diff --git a/apps/api/src/routes/audit.ts b/apps/api/src/routes/audit.ts index 6fa104e..fbfd728 100644 --- a/apps/api/src/routes/audit.ts +++ b/apps/api/src/routes/audit.ts @@ -25,7 +25,14 @@ export async function auditAdminRoutes(app: FastifyInstance): Promise { 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 () => ({ diff --git a/apps/api/src/routes/auth.ts b/apps/api/src/routes/auth.ts index db96854..c7c0848 100644 --- a/apps/api/src/routes/auth.ts +++ b/apps/api/src/routes/auth.ts @@ -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 { 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', diff --git a/apps/api/src/routes/ingest-audit.ts b/apps/api/src/routes/ingest-audit.ts new file mode 100644 index 0000000..b8bf2fc --- /dev/null +++ b/apps/api/src/routes/ingest-audit.ts @@ -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 { + 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 } + }, + ) +} diff --git a/apps/api/test/audit.test.ts b/apps/api/test/audit.test.ts index 2c48b46..a8bb235 100644 --- a/apps/api/test/audit.test.ts +++ b/apps/api/test/audit.test.ts @@ -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>) { } 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: 'ops@test.local', + }, + ], + }, + }) + 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) diff --git a/apps/web/src/components/reui-kit/admin-users-grid.tsx b/apps/web/src/components/reui-kit/admin-users-grid.tsx new file mode 100644 index 0000000..9a0e5c2 --- /dev/null +++ b/apps/web/src/components/reui-kit/admin-users-grid.tsx @@ -0,0 +1,1014 @@ +/** + * Admin users grid — DNA solution-users-1 (members-grid, columns, bulk-action-bar). + * Preview: https://reui.io/preview/base/solution-users-1 + * Docs: https://reui.io/docs/components/base/filters · https://reui.io/docs/components/base/data-grid + */ +import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react' +import type { AdminUser, AppId } from '@authportal/shared' +import { APPS } from '@authportal/shared' +import { + CircleDotIcon, + FilterIcon, + FunnelXIcon, + LockIcon, + MailIcon, + MoreHorizontalIcon, + PauseCircleIcon, + PencilIcon, + ScrollTextIcon, + Settings2Icon, + ShieldCheckIcon, + Trash2Icon, + TriangleAlertIcon, + UserIcon, + UserPlusIcon, +} from 'lucide-react' +import { + getCoreRowModel, + getFilteredRowModel, + getPaginationRowModel, + getSortedRowModel, + type ColumnDef, + type PaginationState, + type Row, + type RowSelectionState, + type SortingState, + type VisibilityState, + useReactTable, +} from '@tanstack/react-table' +import { Badge } from '@/components/reui/badge' +import { DataGrid } from '@/components/reui/data-grid/data-grid' +import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header' +import { DataGridColumnVisibility } from '@/components/reui/data-grid/data-grid-column-visibility' +import { DataGridPagination } from '@/components/reui/data-grid/data-grid-pagination' +import { DataGridScrollArea } from '@/components/reui/data-grid/data-grid-scroll-area' +import { + DataGridTable, + DataGridTableRowSelect, + DataGridTableRowSelectAll, +} from '@/components/reui/data-grid/data-grid-table' +import { + createFilter, + Filters, + type Filter, + type FilterFieldConfig, +} from '@/components/reui/filters' +import { + Frame, + FrameDescription, + FrameFooter, + FrameHeader, + FramePanel, + FrameTitle, +} from '@/components/reui/frame' +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from '@authportal/ui/components/alert-dialog' +import { + Avatar, + AvatarFallback, +} from '@authportal/ui/components/avatar' +import { Button } from '@authportal/ui/components/button' +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuGroup, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from '@authportal/ui/components/dropdown-menu' +import { + Select, + SelectContent, + SelectGroup, + SelectItem, + SelectTrigger, + SelectValue, +} from '@authportal/ui/components/select' +import { Separator } from '@authportal/ui/components/separator' +import { Skeleton } from '@authportal/ui/components/skeleton' +import { TooltipProvider } from '@authportal/ui/components/tooltip' + +const APP_TITLE: Record = Object.fromEntries( + APPS.map((app) => [app.id, app.title]), +) as Record + +const BULK_ROLE_OPTIONS = [ + { value: 'admin', label: 'Админ' }, + { value: 'user', label: 'Пользователь' }, +] as const + +type BulkRole = (typeof BULK_ROLE_OPTIONS)[number]['value'] + +export interface AdminUsersGridProps { + users: AdminUser[] + isLoading?: boolean + onCreateClick: () => void + onOpenAudit: (user: AdminUser) => void + onOpenAccess: (user: AdminUser) => void + onDeactivate: (user: AdminUser) => void + onDelete: (user: AdminUser) => void + onBulkSetRole: (userIds: string[], isAdmin: boolean) => void + onBulkDeactivate: (userIds: string[]) => void +} + +function getActiveFilters(filters: Filter[]) { + return filters.filter((filter) => { + const { values } = filter + if (!values || values.length === 0) return false + if ( + values.every((value) => typeof value === 'string' && value.trim() === '') + ) + return false + if (values.every((value) => value === null || value === undefined)) + return false + if (values.every((value) => Array.isArray(value) && value.length === 0)) + return false + return true + }) +} + +function serializeActiveFiltersKey(active: Filter[]) { + return JSON.stringify( + active.map((f) => ({ + field: f.field, + operator: f.operator, + values: f.values, + })), + ) +} + +function filterFieldValue(item: AdminUser, field: string): unknown { + if (field === 'name') return item.name + if (field === 'email') return item.email + if (field === 'role') return item.is_admin ? 'admin' : 'user' + if (field === 'status') return item.disabled ? 'disabled' : 'active' + if (field === 'apps') return item.apps.join(' ') + return undefined +} + +function applyFiltersToUsers(data: AdminUser[], filters: Filter[]): AdminUser[] { + const active = getActiveFilters(filters) + let result = [...data] + for (const filter of active) { + const { field, operator, values } = filter + result = result.filter((item) => { + if (field === 'apps') { + const selected = values.map(String) + switch (operator) { + case 'is': + return ( + selected.length > 0 && item.apps.includes(selected[0] as AppId) + ) + case 'is_any_of': + return selected.some((v) => item.apps.includes(v as AppId)) + case 'is_not_any_of': + return !selected.some((v) => item.apps.includes(v as AppId)) + case 'contains': { + const tokens = values.map((v) => String(v).trim()).filter(Boolean) + if (tokens.length === 0) return true + return tokens.some((token) => + item.apps.some((appId) => + APP_TITLE[appId].toLowerCase().includes(token.toLowerCase()), + ), + ) + } + default: + return true + } + } + + const raw = filterFieldValue(item, field) + const fieldValue = raw != null ? raw : '' + + switch (operator) { + case 'is': + return values.includes(fieldValue) + case 'is_not': + return !values.includes(fieldValue) + case 'is_any_of': + return values.some((v) => fieldValue === v) + case 'is_not_any_of': + return !values.some((v) => fieldValue === v) + case 'contains': { + const tokens = values.map((v) => String(v).trim()).filter(Boolean) + if (tokens.length === 0) return true + return tokens.some((token) => + String(fieldValue).toLowerCase().includes(token.toLowerCase()), + ) + } + case 'not_contains': + return !values.some((v) => + String(fieldValue).toLowerCase().includes(String(v).toLowerCase()), + ) + case 'starts_with': + return values.some((v) => + String(fieldValue) + .toLowerCase() + .startsWith(String(v).toLowerCase()), + ) + case 'ends_with': + return values.some((v) => + String(fieldValue).toLowerCase().endsWith(String(v).toLowerCase()), + ) + case 'empty': + return fieldValue === '' || fieldValue == null + case 'not_empty': + return fieldValue !== '' && fieldValue != null + default: + return true + } + }) + } + return result +} + +function createDefaultFilters(): Filter[] { + return [createFilter('name', 'contains', [''])] +} + +function userInitials(name: string, email: string) { + const parts = name.trim().split(/\s+/).filter(Boolean) + if (parts.length >= 2) { + return `${parts[0]![0] ?? ''}${parts[1]![0] ?? ''}`.toUpperCase() + } + return (name || email).slice(0, 2).toUpperCase() +} + +function formatLastActive(iso: string | null) { + if (!iso) return '—' + const date = new Date(iso) + if (Number.isNaN(date.getTime())) return '—' + return new Intl.DateTimeFormat('ru-RU', { + day: 'numeric', + month: 'short', + year: 'numeric', + hour: '2-digit', + minute: '2-digit', + }).format(date) +} + +function renderSelectedCount(values: unknown[]) { + if (values.length === 0) return 'Выберите…' + if (values.length > 1) return `${values.length} выбрано` + return null +} + +function DotSeparator() { + return ( +