diff --git a/ordinis-admin-ui/src/api/client.ts b/ordinis-admin-ui/src/api/client.ts index 38d924c..0808a38 100644 --- a/ordinis-admin-ui/src/api/client.ts +++ b/ordinis-admin-ui/src/api/client.ts @@ -107,3 +107,67 @@ export type CreateRecordRequest = { validFrom?: string validTo?: string } + +export type AuditAction = 'CREATE' | 'UPDATE' | 'CLOSE' + +export type AuditEntry = { + id: number + eventTime: string + eventType: string + action: AuditAction | string + userId?: string | null + userScope?: DataScope | null + dictionaryName?: string | null + dictionaryId?: string | null + recordId?: string | null + businessKey?: string | null + payloadBefore?: Record | null + payloadAfter?: Record | null + traceId?: string | null + requestId?: string | null + ipAddress?: string | null + userAgent?: string | null +} + +export type AuditPage = { + content: AuditEntry[] + totalElements: number + totalPages: number + number: number + size: number + first: boolean + last: boolean +} + +export type AuditFilters = { + dictionaryName?: string + userId?: string + action?: AuditAction | '' + businessKey?: string + from?: string + to?: string + page?: number + size?: number +} + +export type OutboxStats = { pending: number; dlq: number } + +export type DlqEntry = { + id: number + eventType: string + dictionaryName?: string | null + aggregateId: string + kafkaTopic: string + attemptCount: number + lastError?: string | null + createdAt: string + dlqAt: string +} + +export type DlqPage = { + content: DlqEntry[] + totalElements: number + totalPages: number + number: number + size: number +} diff --git a/ordinis-admin-ui/src/api/queries.ts b/ordinis-admin-ui/src/api/queries.ts index 0db867c..c988236 100644 --- a/ordinis-admin-ui/src/api/queries.ts +++ b/ordinis-admin-ui/src/api/queries.ts @@ -1,9 +1,13 @@ import { useQuery, queryOptions } from '@tanstack/react-query' import { apiClient, + type AuditFilters, + type AuditPage, type DictionaryDefinition, type DictionaryDetail, + type DlqPage, type FlattenedRecord, + type OutboxStats, type RecordResponse, } from './client' @@ -67,6 +71,70 @@ export const recordRawQuery = (dictionaryName: string, businessKey: string) => }, }) +const auditParams = (f: AuditFilters): Record => { + const out: Record = { + page: f.page ?? 0, + size: f.size ?? 50, + } + if (f.dictionaryName) out.dictionaryName = f.dictionaryName + if (f.userId) out.userId = f.userId + if (f.action) out.action = f.action + if (f.businessKey) out.businessKey = f.businessKey + if (f.from) out.from = f.from + if (f.to) out.to = f.to + return out +} + +export const auditQuery = (f: AuditFilters) => + queryOptions({ + queryKey: ['audit', f] as const, + queryFn: async (): Promise => { + const { data } = await apiClient.get('/admin/audit', { + params: auditParams(f), + }) + return data + }, + }) + +export const useAudit = (f: AuditFilters) => useQuery(auditQuery(f)) + +export const buildAuditExportUrl = (f: AuditFilters): string => { + const base = (apiClient.defaults.baseURL ?? '/api/v1').replace(/\/$/, '') + const sp = new URLSearchParams() + if (f.dictionaryName) sp.set('dictionaryName', f.dictionaryName) + if (f.userId) sp.set('userId', f.userId) + if (f.action) sp.set('action', f.action) + if (f.businessKey) sp.set('businessKey', f.businessKey) + if (f.from) sp.set('from', f.from) + if (f.to) sp.set('to', f.to) + const qs = sp.toString() + return `${base}/admin/audit/export.csv${qs ? `?${qs}` : ''}` +} + +export const outboxStatsQuery = queryOptions({ + queryKey: ['outbox', 'stats'] as const, + queryFn: async (): Promise => { + const { data } = await apiClient.get('/admin/outbox/stats') + return data + }, + refetchInterval: 30_000, +}) + +export const useOutboxStats = () => useQuery(outboxStatsQuery) + +export const dlqQuery = (page: number, size: number) => + queryOptions({ + queryKey: ['outbox', 'dlq', page, size] as const, + queryFn: async (): Promise => { + const { data } = await apiClient.get('/admin/outbox/dlq', { + params: { page, size }, + }) + return data + }, + }) + +export const useDlq = (page: number, size: number) => useQuery(dlqQuery(page, size)) + export const useDictionaries = () => useQuery(dictionariesQuery) export const useDictionaryDetail = (name: string) => useQuery(dictionaryDetailQuery(name)) export const useRecords = (dictionaryName: string, scopeCsv: string) => diff --git a/ordinis-admin-ui/src/i18n.ts b/ordinis-admin-ui/src/i18n.ts index 3a0e776..580c4cc 100644 --- a/ordinis-admin-ui/src/i18n.ts +++ b/ordinis-admin-ui/src/i18n.ts @@ -14,6 +14,51 @@ i18n translation: { 'app.title': 'Ordinis НСИ ЦУОД', 'nav.dictionaries': 'Справочники', + 'nav.audit': 'Аудит', + 'nav.outbox': 'Outbox', + 'audit.title': 'Журнал аудита', + 'audit.subtitle': 'Юридически значимый лог изменений НСИ', + 'audit.filter.dictionary': 'Справочник', + 'audit.filter.user': 'Пользователь', + 'audit.filter.action': 'Действие', + 'audit.filter.businessKey': 'Бизнес-ключ', + 'audit.filter.from': 'С', + 'audit.filter.to': 'По', + 'audit.filter.reset': 'Сбросить', + 'audit.filter.apply': 'Применить', + 'audit.col.time': 'Время', + 'audit.col.action': 'Действие', + 'audit.col.dictionary': 'Справочник', + 'audit.col.businessKey': 'Бизнес-ключ', + 'audit.col.user': 'Пользователь', + 'audit.col.scope': 'Scope', + 'audit.col.trace': 'Trace', + 'audit.col.diff': 'Изменения', + 'audit.action.expand': 'Раскрыть', + 'audit.action.export': 'Экспорт CSV', + 'audit.empty': 'Нет записей по выбранным фильтрам', + 'audit.action.CREATE': 'Создание', + 'audit.action.UPDATE': 'Изменение', + 'audit.action.CLOSE': 'Закрытие', + 'audit.diff.before': 'Было', + 'audit.diff.after': 'Стало', + 'audit.page.of': 'Стр. {{cur}} из {{total}}', + 'audit.page.size': 'На странице', + 'audit.page.prev': 'Назад', + 'audit.page.next': 'Вперёд', + 'outbox.title': 'Outbox events', + 'outbox.subtitle': 'Очередь публикации в Kafka и DLQ', + 'outbox.stats.pending': 'В очереди', + 'outbox.stats.dlq': 'В DLQ', + 'outbox.dlq.col.id': 'ID', + 'outbox.dlq.col.eventType': 'Тип', + 'outbox.dlq.col.dictionary': 'Справочник', + 'outbox.dlq.col.aggregateId': 'Aggregate ID', + 'outbox.dlq.col.topic': 'Topic', + 'outbox.dlq.col.attempts': 'Попыток', + 'outbox.dlq.col.lastError': 'Последняя ошибка', + 'outbox.dlq.col.dlqAt': 'В DLQ с', + 'outbox.dlq.empty': 'DLQ пуста — все события публикуются успешно', 'header.scope': 'Доступный scope', 'dict.list.subtitle': 'Каталоги НСИ ЦУОД ОДХ', 'dict.empty': 'В этом справочнике пока нет записей', @@ -133,6 +178,51 @@ i18n translation: { 'app.title': 'Ordinis MDM', 'nav.dictionaries': 'Dictionaries', + 'nav.audit': 'Audit', + 'nav.outbox': 'Outbox', + 'audit.title': 'Audit log', + 'audit.subtitle': 'Legally significant log of NSI changes', + 'audit.filter.dictionary': 'Dictionary', + 'audit.filter.user': 'User', + 'audit.filter.action': 'Action', + 'audit.filter.businessKey': 'Business key', + 'audit.filter.from': 'From', + 'audit.filter.to': 'To', + 'audit.filter.reset': 'Reset', + 'audit.filter.apply': 'Apply', + 'audit.col.time': 'Time', + 'audit.col.action': 'Action', + 'audit.col.dictionary': 'Dictionary', + 'audit.col.businessKey': 'Business key', + 'audit.col.user': 'User', + 'audit.col.scope': 'Scope', + 'audit.col.trace': 'Trace', + 'audit.col.diff': 'Diff', + 'audit.action.expand': 'Expand', + 'audit.action.export': 'Export CSV', + 'audit.empty': 'No entries match the filters', + 'audit.action.CREATE': 'Create', + 'audit.action.UPDATE': 'Update', + 'audit.action.CLOSE': 'Close', + 'audit.diff.before': 'Before', + 'audit.diff.after': 'After', + 'audit.page.of': 'Page {{cur}} of {{total}}', + 'audit.page.size': 'Per page', + 'audit.page.prev': 'Prev', + 'audit.page.next': 'Next', + 'outbox.title': 'Outbox events', + 'outbox.subtitle': 'Publication queue and DLQ', + 'outbox.stats.pending': 'Pending', + 'outbox.stats.dlq': 'In DLQ', + 'outbox.dlq.col.id': 'ID', + 'outbox.dlq.col.eventType': 'Type', + 'outbox.dlq.col.dictionary': 'Dictionary', + 'outbox.dlq.col.aggregateId': 'Aggregate ID', + 'outbox.dlq.col.topic': 'Topic', + 'outbox.dlq.col.attempts': 'Attempts', + 'outbox.dlq.col.lastError': 'Last error', + 'outbox.dlq.col.dlqAt': 'DLQ since', + 'outbox.dlq.empty': 'DLQ empty — all events publish successfully', 'header.scope': 'Allowed scope', 'dict.list.subtitle': 'Reference data catalogues', 'dict.empty': 'No records in this dictionary yet', diff --git a/ordinis-admin-ui/src/routeTree.gen.ts b/ordinis-admin-ui/src/routeTree.gen.ts index d03b429..db2b665 100644 --- a/ordinis-admin-ui/src/routeTree.gen.ts +++ b/ordinis-admin-ui/src/routeTree.gen.ts @@ -9,16 +9,28 @@ // Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified. import { Route as rootRouteImport } from './routes/__root' +import { Route as OutboxRouteImport } from './routes/outbox' import { Route as DictionariesRouteImport } from './routes/dictionaries' +import { Route as AuditRouteImport } from './routes/audit' import { Route as IndexRouteImport } from './routes/index' import { Route as DictionariesIndexRouteImport } from './routes/dictionaries.index' import { Route as DictionariesNameRouteImport } from './routes/dictionaries.$name' +const OutboxRoute = OutboxRouteImport.update({ + id: '/outbox', + path: '/outbox', + getParentRoute: () => rootRouteImport, +} as any) const DictionariesRoute = DictionariesRouteImport.update({ id: '/dictionaries', path: '/dictionaries', getParentRoute: () => rootRouteImport, } as any) +const AuditRoute = AuditRouteImport.update({ + id: '/audit', + path: '/audit', + getParentRoute: () => rootRouteImport, +} as any) const IndexRoute = IndexRouteImport.update({ id: '/', path: '/', @@ -37,42 +49,65 @@ const DictionariesNameRoute = DictionariesNameRouteImport.update({ export interface FileRoutesByFullPath { '/': typeof IndexRoute + '/audit': typeof AuditRoute '/dictionaries': typeof DictionariesRouteWithChildren + '/outbox': typeof OutboxRoute '/dictionaries/$name': typeof DictionariesNameRoute '/dictionaries/': typeof DictionariesIndexRoute } export interface FileRoutesByTo { '/': typeof IndexRoute + '/audit': typeof AuditRoute + '/outbox': typeof OutboxRoute '/dictionaries/$name': typeof DictionariesNameRoute '/dictionaries': typeof DictionariesIndexRoute } export interface FileRoutesById { __root__: typeof rootRouteImport '/': typeof IndexRoute + '/audit': typeof AuditRoute '/dictionaries': typeof DictionariesRouteWithChildren + '/outbox': typeof OutboxRoute '/dictionaries/$name': typeof DictionariesNameRoute '/dictionaries/': typeof DictionariesIndexRoute } export interface FileRouteTypes { fileRoutesByFullPath: FileRoutesByFullPath - fullPaths: '/' | '/dictionaries' | '/dictionaries/$name' | '/dictionaries/' + fullPaths: + | '/' + | '/audit' + | '/dictionaries' + | '/outbox' + | '/dictionaries/$name' + | '/dictionaries/' fileRoutesByTo: FileRoutesByTo - to: '/' | '/dictionaries/$name' | '/dictionaries' + to: '/' | '/audit' | '/outbox' | '/dictionaries/$name' | '/dictionaries' id: | '__root__' | '/' + | '/audit' | '/dictionaries' + | '/outbox' | '/dictionaries/$name' | '/dictionaries/' fileRoutesById: FileRoutesById } export interface RootRouteChildren { IndexRoute: typeof IndexRoute + AuditRoute: typeof AuditRoute DictionariesRoute: typeof DictionariesRouteWithChildren + OutboxRoute: typeof OutboxRoute } declare module '@tanstack/react-router' { interface FileRoutesByPath { + '/outbox': { + id: '/outbox' + path: '/outbox' + fullPath: '/outbox' + preLoaderRoute: typeof OutboxRouteImport + parentRoute: typeof rootRouteImport + } '/dictionaries': { id: '/dictionaries' path: '/dictionaries' @@ -80,6 +115,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof DictionariesRouteImport parentRoute: typeof rootRouteImport } + '/audit': { + id: '/audit' + path: '/audit' + fullPath: '/audit' + preLoaderRoute: typeof AuditRouteImport + parentRoute: typeof rootRouteImport + } '/': { id: '/' path: '/' @@ -120,7 +162,9 @@ const DictionariesRouteWithChildren = DictionariesRoute._addFileChildren( const rootRouteChildren: RootRouteChildren = { IndexRoute: IndexRoute, + AuditRoute: AuditRoute, DictionariesRoute: DictionariesRouteWithChildren, + OutboxRoute: OutboxRoute, } export const routeTree = rootRouteImport ._addFileChildren(rootRouteChildren) diff --git a/ordinis-admin-ui/src/routes/__root.tsx b/ordinis-admin-ui/src/routes/__root.tsx index 41411d1..ebed37a 100644 --- a/ordinis-admin-ui/src/routes/__root.tsx +++ b/ordinis-admin-ui/src/routes/__root.tsx @@ -29,6 +29,20 @@ function RootLayout() { > {t('nav.dictionaries')} + + {t('nav.audit')} + + + {t('nav.outbox')} +
({ page: 0, size: 50 }) + const [draft, setDraft] = useState(filters) + + const { data, isLoading, error, refetch, isFetching } = useAudit(filters) + + const dictionaryOptions = useMemo(() => { + const opts: { id: string; label: string }[] = [{ id: '', label: '—' }] + for (const d of dictionariesQuery.data ?? []) { + opts.push({ id: d.name, label: d.displayName ?? d.name }) + } + return opts + }, [dictionariesQuery.data]) + + const actionOptions = useMemo( + () => [ + { id: '', label: '—' }, + ...ACTIONS.map((a) => ({ id: a, label: t(`audit.action.${a}`) })), + ], + [t], + ) + + const apply = () => setFilters({ ...draft, page: 0 }) + const reset = () => { + const empty: AuditFilters = { page: 0, size: filters.size ?? 50 } + setDraft(empty) + setFilters(empty) + } + + const totalPages = data?.totalPages ?? 0 + const currentPage = data?.number ?? 0 + + return ( +
+ + + + + +
+ } + /> + + + + {error ? ( + + {String(error)} + + ) : isLoading ? ( + + ) : !data || data.content.length === 0 ? ( + + ) : ( + <> + + + setFilters((f) => ({ ...f, page: Math.max(0, (f.page ?? 0) - 1) })) + } + onNext={() => + setFilters((f) => ({ ...f, page: (f.page ?? 0) + 1 })) + } + onSizeChange={(size) => + setFilters((f) => ({ ...f, page: 0, size })) + } + /> + + )} +
+ ) +} + +type FilterPanelProps = { + filters: AuditFilters + onChange: (f: AuditFilters) => void + dictionaryOptions: { id: string; label: string }[] + actionOptions: { id: string; label: string }[] + onApply: () => void + onReset: () => void +} + +function FilterPanel({ + filters, + onChange, + dictionaryOptions, + actionOptions, + onApply, + onReset, +}: FilterPanelProps) { + const { t } = useTranslation() + const set = (k: K, v: AuditFilters[K]) => + onChange({ ...filters, [k]: v }) + + const fromDate = parseFormDate(filters.from) + const toDate = parseFormDate(filters.to) + + return ( +
+
+ set('dictionaryName', id || undefined)} + /> + set('action', (id as AuditAction) || '')} + /> + set('userId', e.target.value || undefined)} + /> + set('businessKey', e.target.value || undefined)} + /> + + set( + 'from', + d + ? `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String( + d.getDate(), + ).padStart(2, '0')}T00:00:00${localTzOffset(d)}` + : undefined, + ) + } + /> + + set( + 'to', + d + ? `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String( + d.getDate(), + ).padStart(2, '0')}T23:59:59${localTzOffset(d)}` + : undefined, + ) + } + /> +
+
+ + +
+
+ ) +} + +function AuditTable({ rows }: { rows: AuditEntry[] }) { + const { t } = useTranslation() + return ( + + + + {t('audit.col.time')} + {t('audit.col.action')} + {t('audit.col.dictionary')} + {t('audit.col.businessKey')} + {t('audit.col.user')} + {t('audit.col.scope')} + {t('audit.col.trace')} + {t('audit.col.diff')} + + + + {rows.length === 0 ? ( + {t('audit.empty')} + ) : ( + rows.map((r) => ) + )} + +
+ ) +} + +function AuditRow({ row }: { row: AuditEntry }) { + const { t } = useTranslation() + const [open, setOpen] = useState(false) + const time = new Date(row.eventTime) + const timeLabel = time.toLocaleString(undefined, { + year: 'numeric', + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + second: '2-digit', + }) + const actionVariant = + row.action === 'CREATE' ? 'success' : row.action === 'CLOSE' ? 'error' : 'info' + + return ( + <> + + + {timeLabel} + + + + {t(`audit.action.${row.action}`, row.action)} + + + {row.dictionaryName ?? '—'} + + {row.businessKey ?? '—'} + + {row.userId ?? 'anonymous'} + + {row.userScope ? {row.userScope} : null} + + + + {row.traceId ? row.traceId.slice(0, 8) : '—'} + + + + : } + label={t('audit.action.expand')} + onClick={() => setOpen((v) => !v)} + /> + + + {open && ( + + +
+
+
+ {t('audit.diff.before')} +
+
+                  {row.payloadBefore
+                    ? JSON.stringify(row.payloadBefore, null, 2)
+                    : '—'}
+                
+
+
+
+ {t('audit.diff.after')} +
+
+                  {row.payloadAfter
+                    ? JSON.stringify(row.payloadAfter, null, 2)
+                    : '—'}
+                
+
+ {(row.ipAddress || row.userAgent || row.requestId) && ( +
+ {row.ipAddress && IP: {row.ipAddress}} + {row.requestId && req: {row.requestId}} + {row.userAgent && ( + UA: {row.userAgent} + )} +
+ )} +
+
+
+ )} + + ) +} + +type PaginationProps = { + page: number + totalPages: number + size: number + onPrev: () => void + onNext: () => void + onSizeChange: (size: number) => void +} + +function Pagination({ + page, + totalPages, + size, + onPrev, + onNext, + onSizeChange, +}: PaginationProps) { + const { t } = useTranslation() + return ( +
+
+ {t('audit.page.size')} + onSizeChange(Number(id))} + /> +
+
+ + + {t('audit.page.of', { cur: page + 1, total: Math.max(totalPages, 1) })} + + +
+
+ ) +} diff --git a/ordinis-admin-ui/src/routes/outbox.tsx b/ordinis-admin-ui/src/routes/outbox.tsx new file mode 100644 index 0000000..612d163 --- /dev/null +++ b/ordinis-admin-ui/src/routes/outbox.tsx @@ -0,0 +1,174 @@ +import { useState } from 'react' +import { createFileRoute } from '@tanstack/react-router' +import { useTranslation } from 'react-i18next' +import { + Alert, + Badge, + Button, + EmptyState, + LoadingBlock, + PageHeader, + Table, + TableBody, + TableCell, + TableHead, + TableHeaderCell, + TableRow, +} from '@nstart/ui' +import { ArrowsClockwiseIcon } from '@phosphor-icons/react' +import { useDlq, useOutboxStats } from '@/api/queries' + +export const Route = createFileRoute('/outbox')({ + component: OutboxPage, +}) + +function OutboxPage() { + const { t } = useTranslation() + const [page, setPage] = useState(0) + const size = 50 + + const stats = useOutboxStats() + const dlq = useDlq(page, size) + + return ( +
+ } + onClick={() => { + stats.refetch() + dlq.refetch() + }} + > + {t('audit.filter.apply')} + + } + /> + +
+ + 0 ? 'error' : 'success'} + /> +
+ + {dlq.error ? ( + + {String(dlq.error)} + + ) : dlq.isLoading ? ( + + ) : !dlq.data || dlq.data.content.length === 0 ? ( + + ) : ( + <> + + + + {t('outbox.dlq.col.id')} + {t('outbox.dlq.col.eventType')} + {t('outbox.dlq.col.dictionary')} + {t('outbox.dlq.col.aggregateId')} + {t('outbox.dlq.col.topic')} + {t('outbox.dlq.col.attempts')} + {t('outbox.dlq.col.lastError')} + {t('outbox.dlq.col.dlqAt')} + + + + {dlq.data.content.map((row) => ( + + + {row.id} + + + {row.eventType} + + {row.dictionaryName ?? '—'} + + {row.aggregateId} + + + {row.kafkaTopic} + + {row.attemptCount} + + + {row.lastError ?? '—'} + + + + + {new Date(row.dlqAt).toLocaleString()} + + + + ))} + +
+
+ + + {t('audit.page.of', { + cur: (dlq.data.number ?? 0) + 1, + total: Math.max(dlq.data.totalPages ?? 1, 1), + })} + + +
+ + )} +
+ ) +} + +type StatCardProps = { + label: string + value: number | string + tone: 'info' | 'success' | 'error' +} + +function StatCard({ label, value, tone }: StatCardProps) { + const colour = + tone === 'error' + ? 'text-mars' + : tone === 'success' + ? 'text-grass' + : 'text-ultramarain' + return ( +
+
+ {label} +
+
{value}
+
+ ) +} diff --git a/ordinis-domain/src/main/java/cloud/nstart/terravault/ordinis/domain/audit/AuditLog.java b/ordinis-domain/src/main/java/cloud/nstart/terravault/ordinis/domain/audit/AuditLog.java index 6550c84..63b1c75 100644 --- a/ordinis-domain/src/main/java/cloud/nstart/terravault/ordinis/domain/audit/AuditLog.java +++ b/ordinis-domain/src/main/java/cloud/nstart/terravault/ordinis/domain/audit/AuditLog.java @@ -77,7 +77,7 @@ public class AuditLog { @Column(name = "user_agent", columnDefinition = "TEXT") private String userAgent; - protected AuditLog() {} + public AuditLog() {} public Long getId() { return id; } public OffsetDateTime getEventTime() { return eventTime; } diff --git a/ordinis-domain/src/main/java/cloud/nstart/terravault/ordinis/domain/audit/AuditLogRepository.java b/ordinis-domain/src/main/java/cloud/nstart/terravault/ordinis/domain/audit/AuditLogRepository.java index 2e8323d..55280f7 100644 --- a/ordinis-domain/src/main/java/cloud/nstart/terravault/ordinis/domain/audit/AuditLogRepository.java +++ b/ordinis-domain/src/main/java/cloud/nstart/terravault/ordinis/domain/audit/AuditLogRepository.java @@ -1,6 +1,10 @@ package cloud.nstart.terravault.ordinis.domain.audit; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; import java.time.OffsetDateTime; import java.util.List; @@ -15,4 +19,26 @@ public interface AuditLogRepository extends JpaRepository { List findByUserIdOrderByEventTimeDesc(String userId); List findByEventTimeBetweenOrderByEventTimeDesc(OffsetDateTime from, OffsetDateTime to); + + /** + * Гибкий запрос для admin UI: фильтры nullable, NULL = «не фильтруем». + * Сортировка задаётся {@link Pageable}. + */ + @Query(""" + SELECT a FROM AuditLog a + WHERE (:dictionaryName IS NULL OR a.dictionaryName = :dictionaryName) + AND (:userId IS NULL OR a.userId = :userId) + AND (:action IS NULL OR a.action = :action) + AND (:businessKey IS NULL OR a.businessKey = :businessKey) + AND (:from IS NULL OR a.eventTime >= :from) + AND (:to IS NULL OR a.eventTime <= :to) + """) + Page search( + @Param("dictionaryName") String dictionaryName, + @Param("userId") String userId, + @Param("action") String action, + @Param("businessKey") String businessKey, + @Param("from") OffsetDateTime from, + @Param("to") OffsetDateTime to, + Pageable pageable); } diff --git a/ordinis-rest-api/src/main/java/cloud/nstart/terravault/ordinis/restapi/audit/AuditLogger.java b/ordinis-rest-api/src/main/java/cloud/nstart/terravault/ordinis/restapi/audit/AuditLogger.java new file mode 100644 index 0000000..8d7855e --- /dev/null +++ b/ordinis-rest-api/src/main/java/cloud/nstart/terravault/ordinis/restapi/audit/AuditLogger.java @@ -0,0 +1,135 @@ +package cloud.nstart.terravault.ordinis.restapi.audit; + +import cloud.nstart.terravault.ordinis.auth.ScopeContext; +import cloud.nstart.terravault.ordinis.domain.DataScope; +import cloud.nstart.terravault.ordinis.domain.audit.AuditLog; +import cloud.nstart.terravault.ordinis.domain.audit.AuditLogRepository; +import cloud.nstart.terravault.ordinis.domain.definition.DictionaryDefinition; +import cloud.nstart.terravault.ordinis.domain.record.DictionaryRecord; +import com.fasterxml.jackson.databind.JsonNode; +import jakarta.servlet.http.HttpServletRequest; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.slf4j.MDC; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationToken; +import org.springframework.stereotype.Service; +import org.springframework.web.context.request.RequestAttributes; +import org.springframework.web.context.request.RequestContextHolder; +import org.springframework.web.context.request.ServletRequestAttributes; + +import java.time.OffsetDateTime; +import java.util.Set; + +/** + * Запись в {@code audit_log} — юридически значимый лог. Источник истины для + * compliance, отдельно от технических логов в Loki. + * + *

Захватывает: user (sub claim из JWT либо "anonymous"), scope, IP, UA, + * trace_id из MDC (Micrometer Tracing). Вызывается из {@link + * cloud.nstart.terravault.ordinis.restapi.service.DictionaryRecordService} в + * той же транзакции что и change — at-least-once семантика. + */ +@Service +public class AuditLogger { + + private static final Logger log = LoggerFactory.getLogger(AuditLogger.class); + + private final AuditLogRepository repository; + private final ScopeContext scopeContext; + + public AuditLogger(AuditLogRepository repository, ScopeContext scopeContext) { + this.repository = repository; + this.scopeContext = scopeContext; + } + + public void recordCreate(DictionaryDefinition d, DictionaryRecord r) { + write("RECORD_CREATED", "CREATE", d, r, null, r.getData()); + } + + public void recordUpdate( + DictionaryDefinition d, DictionaryRecord r, JsonNode payloadBefore) { + write("RECORD_UPDATED", "UPDATE", d, r, payloadBefore, r.getData()); + } + + public void recordClose(DictionaryDefinition d, DictionaryRecord r, String reason) { + write("RECORD_CLOSED", "CLOSE", d, r, r.getData(), null); + } + + private void write( + String eventType, + String action, + DictionaryDefinition d, + DictionaryRecord r, + JsonNode before, + JsonNode after) { + try { + AuditLog entry = new AuditLog(); + entry.setEventTime(OffsetDateTime.now()); + entry.setEventType(eventType); + entry.setAction(action); + entry.setDictionaryId(d.getId()); + entry.setDictionaryName(d.getName()); + entry.setRecordId(r.getId()); + entry.setBusinessKey(r.getBusinessKey()); + entry.setPayloadBefore(before); + entry.setPayloadAfter(after); + + User user = currentUser(); + entry.setUserId(user.id); + entry.setUserScope(user.scope); + + RequestContext rc = currentRequest(); + entry.setIpAddress(rc.ip); + entry.setUserAgent(rc.userAgent); + entry.setRequestId(rc.requestId); + entry.setTraceId(MDC.get("traceId")); + + repository.save(entry); + } catch (Exception ex) { + // Аудит не должен ломать основной flow — но логируем громко чтобы + // алертилось. Compliance-кейс ловит на этой ошибке. + log.error( + "Failed to write audit_log entry: dictionary={} record_id={} action={}: {}", + d.getName(), r.getId(), action, ex.getMessage(), ex); + } + } + + private User currentUser() { + Authentication auth = SecurityContextHolder.getContext().getAuthentication(); + if (auth instanceof JwtAuthenticationToken jwt) { + String sub = jwt.getToken().getSubject(); + if (sub == null || sub.isBlank()) { + Object preferred = jwt.getToken().getClaim("preferred_username"); + sub = preferred == null ? jwt.getName() : String.valueOf(preferred); + } + Set scopes = scopeContext.currentScopes(); + DataScope effective = scopes.contains(DataScope.RESTRICTED) ? DataScope.RESTRICTED + : scopes.contains(DataScope.INTERNAL) ? DataScope.INTERNAL + : DataScope.PUBLIC; + return new User(sub, effective); + } + return new User("anonymous", DataScope.PUBLIC); + } + + private RequestContext currentRequest() { + RequestAttributes attrs = RequestContextHolder.getRequestAttributes(); + if (attrs instanceof ServletRequestAttributes sra) { + HttpServletRequest req = sra.getRequest(); + String ip = headerOrRemote(req, "X-Forwarded-For"); + if (ip != null && ip.contains(",")) ip = ip.split(",")[0].trim(); + String requestId = req.getHeader("X-Request-Id"); + return new RequestContext(ip, req.getHeader("User-Agent"), requestId); + } + return new RequestContext(null, null, null); + } + + private static String headerOrRemote(HttpServletRequest req, String header) { + String v = req.getHeader(header); + return (v == null || v.isBlank()) ? req.getRemoteAddr() : v; + } + + private record User(String id, DataScope scope) {} + private record RequestContext(String ip, String userAgent, String requestId) {} +} diff --git a/ordinis-rest-api/src/main/java/cloud/nstart/terravault/ordinis/restapi/service/DictionaryRecordService.java b/ordinis-rest-api/src/main/java/cloud/nstart/terravault/ordinis/restapi/service/DictionaryRecordService.java index 6a9139e..9d9b979 100644 --- a/ordinis-rest-api/src/main/java/cloud/nstart/terravault/ordinis/restapi/service/DictionaryRecordService.java +++ b/ordinis-rest-api/src/main/java/cloud/nstart/terravault/ordinis/restapi/service/DictionaryRecordService.java @@ -8,6 +8,7 @@ import cloud.nstart.terravault.ordinis.events.record.DictionaryRecordCreated; import cloud.nstart.terravault.ordinis.events.record.DictionaryRecordDeleted; import cloud.nstart.terravault.ordinis.events.record.DictionaryRecordUpdated; import cloud.nstart.terravault.ordinis.outbox.OutboxRecorder; +import cloud.nstart.terravault.ordinis.restapi.audit.AuditLogger; import cloud.nstart.terravault.ordinis.restapi.dto.CreateRecordRequest; import cloud.nstart.terravault.ordinis.restapi.error.OrdinisException; import cloud.nstart.terravault.ordinis.validation.RecordValidator; @@ -43,6 +44,7 @@ public class DictionaryRecordService { private final DictionaryDefinitionService definitionService; private final RecordValidator validator; private final OutboxRecorder outbox; + private final AuditLogger auditLogger; private static final OffsetDateTime FAR_FUTURE = OffsetDateTime.parse("9999-12-31T23:59:59Z"); @@ -50,11 +52,13 @@ public class DictionaryRecordService { DictionaryRecordRepository repository, DictionaryDefinitionService definitionService, RecordValidator validator, - OutboxRecorder outbox) { + OutboxRecorder outbox, + AuditLogger auditLogger) { this.repository = repository; this.definitionService = definitionService; this.validator = validator; this.outbox = outbox; + this.auditLogger = auditLogger; } @Transactional(readOnly = true) @@ -110,6 +114,7 @@ public class DictionaryRecordService { } throw ex; } + auditLogger.recordCreate(d, saved); publishCreated(d, saved); return saved; } @@ -163,6 +168,7 @@ public class DictionaryRecordService { throw ex; } + auditLogger.recordUpdate(d, saved, prevData); publishUpdated(d, saved, previousRecordId, prevData); return saved; } @@ -180,6 +186,7 @@ public class DictionaryRecordService { current.setValidTo(when); repository.save(current); + auditLogger.recordClose(d, current, reason); publishDeleted(d, current, when, reason); } diff --git a/ordinis-rest-api/src/main/java/cloud/nstart/terravault/ordinis/restapi/web/AuditAdminController.java b/ordinis-rest-api/src/main/java/cloud/nstart/terravault/ordinis/restapi/web/AuditAdminController.java new file mode 100644 index 0000000..e983ea9 --- /dev/null +++ b/ordinis-rest-api/src/main/java/cloud/nstart/terravault/ordinis/restapi/web/AuditAdminController.java @@ -0,0 +1,150 @@ +package cloud.nstart.terravault.ordinis.restapi.web; + +import cloud.nstart.terravault.ordinis.domain.audit.AuditLog; +import cloud.nstart.terravault.ordinis.domain.audit.AuditLogRepository; +import com.fasterxml.jackson.databind.JsonNode; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Sort; +import org.springframework.format.annotation.DateTimeFormat; +import org.springframework.http.MediaType; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import java.time.OffsetDateTime; +import java.util.UUID; + +/** + * Admin endpoints для audit log. Read-only, фильтры опциональны. + * + *

    + *
  • {@code GET /admin/audit} — пагинированный поиск.
  • + *
  • {@code GET /admin/audit/export.csv} — выгрузка по тем же фильтрам + * (без пагинации; cap 10 000 строк против OOM).
  • + *
+ */ +@RestController +@RequestMapping("/api/v1/admin/audit") +public class AuditAdminController { + + private static final int MAX_EXPORT_ROWS = 10_000; + + private final AuditLogRepository repository; + + public AuditAdminController(AuditLogRepository repository) { + this.repository = repository; + } + + @GetMapping + public Page search( + @RequestParam(required = false) String dictionaryName, + @RequestParam(required = false) String userId, + @RequestParam(required = false) String action, + @RequestParam(required = false) String businessKey, + @RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) OffsetDateTime from, + @RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) OffsetDateTime to, + @RequestParam(defaultValue = "0") int page, + @RequestParam(defaultValue = "50") int size) { + Page result = repository.search( + nullIfBlank(dictionaryName), + nullIfBlank(userId), + nullIfBlank(action), + nullIfBlank(businessKey), + from, + to, + PageRequest.of(page, Math.min(size, 200), Sort.by(Sort.Direction.DESC, "eventTime"))); + return result.map(AuditEntry::from); + } + + @GetMapping(value = "/export.csv", produces = "text/csv") + public org.springframework.http.ResponseEntity exportCsv( + @RequestParam(required = false) String dictionaryName, + @RequestParam(required = false) String userId, + @RequestParam(required = false) String action, + @RequestParam(required = false) String businessKey, + @RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) OffsetDateTime from, + @RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) OffsetDateTime to) { + Page result = repository.search( + nullIfBlank(dictionaryName), + nullIfBlank(userId), + nullIfBlank(action), + nullIfBlank(businessKey), + from, + to, + PageRequest.of(0, MAX_EXPORT_ROWS, Sort.by(Sort.Direction.DESC, "eventTime"))); + + StringBuilder csv = new StringBuilder( + "id,event_time,event_type,action,user_id,user_scope,dictionary_name,business_key,record_id,trace_id,ip_address\n"); + for (AuditLog a : result.getContent()) { + csv.append(a.getId()).append(',') + .append(a.getEventTime()).append(',') + .append(csvCell(a.getEventType())).append(',') + .append(csvCell(a.getAction())).append(',') + .append(csvCell(a.getUserId())).append(',') + .append(a.getUserScope() == null ? "" : a.getUserScope().name()).append(',') + .append(csvCell(a.getDictionaryName())).append(',') + .append(csvCell(a.getBusinessKey())).append(',') + .append(a.getRecordId() == null ? "" : a.getRecordId()).append(',') + .append(csvCell(a.getTraceId())).append(',') + .append(csvCell(a.getIpAddress())) + .append('\n'); + } + + String filename = "audit-log-" + OffsetDateTime.now().toLocalDate() + ".csv"; + return org.springframework.http.ResponseEntity.ok() + .header("Content-Disposition", "attachment; filename=\"" + filename + "\"") + .contentType(MediaType.parseMediaType("text/csv; charset=UTF-8")) + .body(csv.toString()); + } + + private static String nullIfBlank(String s) { + return (s == null || s.isBlank()) ? null : s; + } + + private static String csvCell(String s) { + if (s == null) return ""; + if (s.indexOf(',') < 0 && s.indexOf('"') < 0 && s.indexOf('\n') < 0) return s; + return "\"" + s.replace("\"", "\"\"") + "\""; + } + + public record AuditEntry( + Long id, + OffsetDateTime eventTime, + String eventType, + String action, + String userId, + String userScope, + String dictionaryName, + UUID dictionaryId, + UUID recordId, + String businessKey, + JsonNode payloadBefore, + JsonNode payloadAfter, + String traceId, + String requestId, + String ipAddress, + String userAgent) { + + static AuditEntry from(AuditLog a) { + return new AuditEntry( + a.getId(), + a.getEventTime(), + a.getEventType(), + a.getAction(), + a.getUserId(), + a.getUserScope() == null ? null : a.getUserScope().name(), + a.getDictionaryName(), + a.getDictionaryId(), + a.getRecordId(), + a.getBusinessKey(), + a.getPayloadBefore(), + a.getPayloadAfter(), + a.getTraceId(), + a.getRequestId(), + a.getIpAddress(), + a.getUserAgent()); + } + } +} diff --git a/ordinis-rest-api/src/test/java/cloud/nstart/terravault/ordinis/restapi/service/DictionaryRecordServiceTest.java b/ordinis-rest-api/src/test/java/cloud/nstart/terravault/ordinis/restapi/service/DictionaryRecordServiceTest.java index b9e0522..96db241 100644 --- a/ordinis-rest-api/src/test/java/cloud/nstart/terravault/ordinis/restapi/service/DictionaryRecordServiceTest.java +++ b/ordinis-rest-api/src/test/java/cloud/nstart/terravault/ordinis/restapi/service/DictionaryRecordServiceTest.java @@ -38,7 +38,7 @@ class DictionaryRecordServiceTest { repo = mock(DictionaryRecordRepository.class); // resolveBusinessKey/enforceUniqueFields не дёргают остальные деп — передаём null, // mocking concrete Spring services через Mockito-inline на JDK 25 ломается. - service = new DictionaryRecordService(repo, null, null, null); + service = new DictionaryRecordService(repo, null, null, null, null); } // ============= resolveBusinessKey =============