From 2e3e91ea4e6fea0550adf87ffb9c2b12d3f419f5 Mon Sep 17 00:00:00 2001 From: Mikael Araya Date: Mon, 8 Jun 2026 16:23:21 +0300 Subject: [PATCH 01/10] Add audit log support and admin-ui display --- .gitignore | 1 + admin-ui/client/package.json | 7 +- admin-ui/src/components/ui/BreadCrumbs.tsx | 2 + admin-ui/src/i18n/de.json | 28 + admin-ui/src/i18n/en.json | 28 + .../src/modules/apollo/utils/typepolicies.ts | 10 + .../audit/components/AuditEntryDetail.tsx | 143 ++++ .../audit/components/AuditLogFilters.tsx | 82 +++ .../audit/components/AuditLogTable.tsx | 119 ++++ .../audit/components/ChainStatusBanner.tsx | 87 +++ .../audit/components/FailedLoginsWidget.tsx | 61 ++ .../modules/audit/components/ocsf-labels.ts | 32 + .../audit/fragments/AuditLogEntryFragment.ts | 52 ++ .../audit/hooks/useAuditChainStatus.ts | 40 ++ .../src/modules/audit/hooks/useAuditLogs.ts | 74 ++ .../audit/hooks/useFailedLoginAttempts.ts | 41 ++ admin-ui/src/modules/audit/index.ts | 3 + .../src/modules/common/components/Layout.tsx | 15 + admin-ui/src/pages/settings/index.tsx | 14 + admin-ui/src/pages/settings/security.tsx | 97 +++ examples/kitchensink/.gitignore | 3 +- packages/api/src/api-index.ts | 5 +- packages/api/src/context.ts | 4 +- packages/api/src/events.ts | 1 + packages/api/src/express/index.ts | 15 +- .../src/middleware/createAuthMiddleware.ts | 2 + .../mutations/accounts/loginWithPassword.ts | 20 +- .../queries/audit/auditChainStatus.ts | 27 + .../src/resolvers/queries/audit/auditLogs.ts | 99 +++ .../resolvers/queries/audit/auditLogsCount.ts | 26 + .../queries/audit/failedLoginAttempts.ts | 22 + packages/api/src/resolvers/queries/index.ts | 8 + packages/api/src/roles/all.ts | 2 + packages/api/src/roles/index.ts | 2 + packages/api/src/schema/query.ts | 34 + packages/api/src/schema/types/audit.ts | 99 +++ packages/api/src/schema/types/index.ts | 2 + .../events/src/audit/audit-integration.ts | 634 +++++++++++++++++- packages/events/src/audit/request-context.ts | 18 + packages/events/src/events-index.ts | 7 + packages/platform/src/startPlatform.ts | 29 +- tests/audit-log.test.js | 179 +++++ 42 files changed, 2155 insertions(+), 19 deletions(-) create mode 100644 admin-ui/src/modules/audit/components/AuditEntryDetail.tsx create mode 100644 admin-ui/src/modules/audit/components/AuditLogFilters.tsx create mode 100644 admin-ui/src/modules/audit/components/AuditLogTable.tsx create mode 100644 admin-ui/src/modules/audit/components/ChainStatusBanner.tsx create mode 100644 admin-ui/src/modules/audit/components/FailedLoginsWidget.tsx create mode 100644 admin-ui/src/modules/audit/components/ocsf-labels.ts create mode 100644 admin-ui/src/modules/audit/fragments/AuditLogEntryFragment.ts create mode 100644 admin-ui/src/modules/audit/hooks/useAuditChainStatus.ts create mode 100644 admin-ui/src/modules/audit/hooks/useAuditLogs.ts create mode 100644 admin-ui/src/modules/audit/hooks/useFailedLoginAttempts.ts create mode 100644 admin-ui/src/modules/audit/index.ts create mode 100644 admin-ui/src/pages/settings/index.tsx create mode 100644 admin-ui/src/pages/settings/security.tsx create mode 100644 packages/api/src/resolvers/queries/audit/auditChainStatus.ts create mode 100644 packages/api/src/resolvers/queries/audit/auditLogs.ts create mode 100644 packages/api/src/resolvers/queries/audit/auditLogsCount.ts create mode 100644 packages/api/src/resolvers/queries/audit/failedLoginAttempts.ts create mode 100644 packages/api/src/schema/types/audit.ts create mode 100644 packages/events/src/audit/request-context.ts create mode 100644 tests/audit-log.test.js diff --git a/.gitignore b/.gitignore index 7f5c9df601..a184574681 100644 --- a/.gitignore +++ b/.gitignore @@ -18,3 +18,4 @@ fly.toml .db *.db dist +audit-log \ No newline at end of file diff --git a/admin-ui/client/package.json b/admin-ui/client/package.json index 80d3463a48..93e8b0a7c5 100644 --- a/admin-ui/client/package.json +++ b/admin-ui/client/package.json @@ -95,7 +95,12 @@ "./work": { "types": "./dist/modules/work/index.d.ts", "import": "./dist/modules/work/index.js", - "require": "./dist/modules/work/index.js" + "require": "./dist/modules/work/index.js" + }, + "./audit": { + "import": "./dist/modules/audit/index.js", + "require": "./dist/modules/audit/index.js", + "types": "./dist/modules/audit/index.d.ts" } }, "peerDependencies": { diff --git a/admin-ui/src/components/ui/BreadCrumbs.tsx b/admin-ui/src/components/ui/BreadCrumbs.tsx index 707bd7f468..6f66fd46f6 100644 --- a/admin-ui/src/components/ui/BreadCrumbs.tsx +++ b/admin-ui/src/components/ui/BreadCrumbs.tsx @@ -32,6 +32,8 @@ const BreadCrumbs = ({ 'warehousing-provider': 'warehousing_provider', system: 'system', account: 'account', + settings: 'settings', + security: 'security', new: 'new', edit: 'edit', }; diff --git a/admin-ui/src/i18n/de.json b/admin-ui/src/i18n/de.json index 25619923da..7eab67896d 100644 --- a/admin-ui/src/i18n/de.json +++ b/admin-ui/src/i18n/de.json @@ -7,6 +7,26 @@ "account_status": "Kontostatus", "actions_column_header": "Aktionen", "activate": "Aktivieren", + "audit_chain_entries": "{checked} von {total} Einträgen verifiziert", + "audit_chain_invalid": "Audit-Kette manipuliert", + "audit_chain_reverify": "Erneut prüfen", + "audit_chain_valid": "Integrität der Audit-Kette verifiziert", + "audit_class": "Klasse", + "audit_detail_actor": "Akteur", + "audit_detail_api": "API", + "audit_detail_chain": "Hash-Kette", + "audit_detail_event": "Ereignis", + "audit_detail_raw": "OCSF-Rohereignis", + "audit_detail_source": "Quell-Endpunkt", + "audit_entry_detail": "Audit-Eintrag Details", + "audit_filter_all_classes": "Alle Klassen", + "audit_filter_all_statuses": "Alle Status", + "audit_filter_user_id": "Nach Benutzer-ID filtern...", + "audit_log_browser": "Audit-Log ({count})", + "audit_message": "Nachricht", + "audit_status": "Status", + "audit_time": "Zeit", + "audit_user": "Benutzer", "activate_assortment_description": "Wird bei der Suche sichtbar sein", "activate_button": "Aktivieren", "activate_header_conformation": "Sind Sie sicher, dass Sie dieses Abonnement abschliessen möchten?", @@ -363,6 +383,7 @@ "extensions": "Erweiterungen", "fail_reorder": "Neuordnung fehlgeschlagen, versuchen Sie es erneut!", "fail_upload": "Medien-Upload fehlgeschlagen", + "failed_login_attempts": "Fehlgeschlagene Anmeldeversuche", "failed_tool_response": "Tool-Ergebnis konnte nicht geparst werden.", "female": "Weiblich", "filer_options": "Filter-Optionen", @@ -458,6 +479,8 @@ "language_page_title": "{count, plural, one {# Sprache} other {# Sprachen}}", "language_updated": "Sprache erfolgreich aktualisiert!", "languages": "Sprachen", + "last_24_hours": "Letzte 24 Stunden", + "last_7_days": "Letzte 7 Tage", "last_Name": "Nachname", "last_login": "Letzter Login:", "last_updated": "Zuletzt aktualisiert:", @@ -708,6 +731,10 @@ "scaffold_product": "Gerüst", "scheduled": "Geplant", "search": "Suchen", + "security": "Sicherheit", + "security_page_header": "Sicherheit & Audit-Log", + "security_page_title": "Sicherheit & Audit-Log", + "settings": "Einstellungen", "search_product": "Search product", "select_language": "Sprache auswählen", "select_option": "Option auswählen", @@ -979,6 +1006,7 @@ "events": "Veranstaltungen", "items": "Artikel", "no_active_types": "Keine aktiven Arbeitertypen gefunden", + "no_audit_entries": "Keine Audit-Log-Einträge gefunden", "worker.dateRange": "Datumsbereich", "worker.deleteCount": "Gelöscht", "worker.errorCount": "Fehler", diff --git a/admin-ui/src/i18n/en.json b/admin-ui/src/i18n/en.json index fc13ebf649..c121bc2cd1 100644 --- a/admin-ui/src/i18n/en.json +++ b/admin-ui/src/i18n/en.json @@ -7,6 +7,26 @@ "action_edit": "Edit", "actions_column_header": "Actions", "activate": "Activate", + "audit_chain_entries": "{checked} of {total} entries verified", + "audit_chain_invalid": "Audit chain integrity compromised", + "audit_chain_reverify": "Re-verify", + "audit_chain_valid": "Audit chain integrity verified", + "audit_class": "Class", + "audit_detail_actor": "Actor", + "audit_detail_api": "API", + "audit_detail_chain": "Hash Chain", + "audit_detail_event": "Event", + "audit_detail_raw": "Raw OCSF Event", + "audit_detail_source": "Source Endpoint", + "audit_entry_detail": "Audit Entry Detail", + "audit_filter_all_classes": "All classes", + "audit_filter_all_statuses": "All statuses", + "audit_filter_user_id": "Filter by user ID...", + "audit_log_browser": "Audit Log ({count})", + "audit_message": "Message", + "audit_status": "Status", + "audit_time": "Time", + "audit_user": "User", "activate_assortment_description": "Will be visible on search", "activate_button": "Activate", "activate_header_conformation": "Are you sure you want to this subscription?", @@ -413,6 +433,7 @@ "extensions": "Extensions", "fail_reorder": "Reordering failed, Try again!", "fail_upload": "Media upload failed", + "failed_login_attempts": "Failed login attempts", "failed_tool_response": "Failed to parse tool result.", "female": "Female", "file": "File", @@ -523,6 +544,8 @@ "language_page_title": "{count, plural, one {# Language} other {# Languages}}", "language_updated": "Language updated successfully!", "languages": "Languages", + "last_24_hours": "Last 24 hours", + "last_7_days": "Last 7 days", "last_Name": "Last name", "last_login": "Last log in", "last_order_date": "Last order", @@ -589,6 +612,7 @@ "next": "Next", "no_active_exports_found": "No active files found or all links have expired.", "no_active_types": "No active worker types found", + "no_audit_entries": "No audit log entries found", "no_assortments_found": "No assortments found matching your criteria", "no_customers": "No top customers found.", "no_data_message": "

No {message} available

", @@ -829,6 +853,10 @@ "scaffold_product": "Scaffold", "scheduled": "Scheduled", "search": "Search", + "security": "Security", + "security_page_header": "Security & Audit Log", + "security_page_title": "Security & Audit Log", + "settings": "Settings", "search_product": "Search product", "select_language": "Select Language", "select_options_toggle": "Change {type} status", diff --git a/admin-ui/src/modules/apollo/utils/typepolicies.ts b/admin-ui/src/modules/apollo/utils/typepolicies.ts index a7e224ee47..cd93f5a7e7 100644 --- a/admin-ui/src/modules/apollo/utils/typepolicies.ts +++ b/admin-ui/src/modules/apollo/utils/typepolicies.ts @@ -112,6 +112,16 @@ const keyMappings: any = { 'paymentProviderIds', ]), }, + auditLogs: { + ...offsetLimitPagination([ + 'classUids', + 'userId', + 'success', + 'from', + 'until', + 'limit', + ]), + }, }, }, }; diff --git a/admin-ui/src/modules/audit/components/AuditEntryDetail.tsx b/admin-ui/src/modules/audit/components/AuditEntryDetail.tsx new file mode 100644 index 0000000000..24837f04e7 --- /dev/null +++ b/admin-ui/src/modules/audit/components/AuditEntryDetail.tsx @@ -0,0 +1,143 @@ +import { useIntl } from 'react-intl'; +import { CLASS_LABELS, SEVERITY_LABELS, STATUS_LABELS } from './ocsf-labels'; +import { XMarkIcon } from '@heroicons/react/24/outline'; + +const AuditEntryDetail = ({ + entry, + onClose, +}: { + entry: any; + onClose: () => void; +}) => { + const { formatMessage, formatDate, formatTime } = useIntl(); + + if (!entry) return null; + + const sections = [ + { + label: formatMessage({ + id: 'audit_detail_event', + defaultMessage: 'Event', + }), + rows: [ + ['Class', CLASS_LABELS[entry.className] || entry.className], + ['Type UID', entry.typeUid], + ['Activity ID', entry.activityId], + ['Severity', SEVERITY_LABELS[entry.severityId] || entry.severityId], + ['Status', STATUS_LABELS[entry.statusId] || entry.statusId], + entry.statusDetail && ['Status Detail', entry.statusDetail], + ['Message', entry.message], + [ + 'Time', + `${formatDate(entry.time, { + year: 'numeric', + month: 'short', + day: 'numeric', + })} ${formatTime(entry.time, { + hour: '2-digit', + minute: '2-digit', + second: '2-digit', + })}`, + ], + ].filter(Boolean), + }, + entry.actor && { + label: formatMessage({ + id: 'audit_detail_actor', + defaultMessage: 'Actor', + }), + rows: [ + entry.actor.user?.uid && ['User ID', entry.actor.user.uid], + entry.actor.user?.name && ['Name', entry.actor.user.name], + entry.actor.user?.emailAddr && ['Email', entry.actor.user.emailAddr], + entry.actor.session?.uid && ['Session', entry.actor.session.uid], + ].filter(Boolean), + }, + entry.srcEndpoint && { + label: formatMessage({ + id: 'audit_detail_source', + defaultMessage: 'Source Endpoint', + }), + rows: [ + entry.srcEndpoint.ip && ['IP', entry.srcEndpoint.ip], + entry.srcEndpoint.port && ['Port', entry.srcEndpoint.port], + ].filter(Boolean), + }, + entry.api && { + label: formatMessage({ id: 'audit_detail_api', defaultMessage: 'API' }), + rows: [ + entry.api.operation && ['Operation', entry.api.operation], + entry.api.request?.uid && ['Request ID', entry.api.request.uid], + entry.api.response?.code && ['Response Code', entry.api.response.code], + ].filter(Boolean), + }, + { + label: formatMessage({ + id: 'audit_detail_chain', + defaultMessage: 'Hash Chain', + }), + rows: [ + ['Sequence #', entry.sequenceNumber], + entry.hash && ['Hash', entry.hash], + entry.prevHash && ['Previous Hash', entry.prevHash], + ].filter(Boolean), + }, + ].filter(Boolean); + + return ( +
+
+

+ {formatMessage({ + id: 'audit_entry_detail', + defaultMessage: 'Audit Entry Detail', + })} +

+ +
+
+ {sections.map((section: any, i) => ( +
+

+ {section.label} +

+
+ {section.rows.map(([key, value]: [string, any], j: number) => ( +
+
+ {key} +
+
+ {String(value ?? '—')} +
+
+ ))} +
+
+ ))} +
+

+ {formatMessage({ + id: 'audit_detail_raw', + defaultMessage: 'Raw OCSF Event', + })} +

+
+            {JSON.stringify(entry.raw, null, 2)}
+          
+
+
+
+ ); +}; + +export default AuditEntryDetail; diff --git a/admin-ui/src/modules/audit/components/AuditLogFilters.tsx b/admin-ui/src/modules/audit/components/AuditLogFilters.tsx new file mode 100644 index 0000000000..e54005b61e --- /dev/null +++ b/admin-ui/src/modules/audit/components/AuditLogFilters.tsx @@ -0,0 +1,82 @@ +import { useIntl } from 'react-intl'; +import { useRouter } from 'next/router'; + +const CLASS_OPTIONS = [ + { value: '3002', label: 'Authentication' }, + { value: '3001', label: 'Account Change' }, + { value: '6003', label: 'API Activity' }, +]; + +const STATUS_OPTIONS = [ + { value: 'true', label: 'Success' }, + { value: 'false', label: 'Failure' }, +]; + +const AuditLogFilters = () => { + const { formatMessage } = useIntl(); + const { query, push } = useRouter(); + + const updateFilter = (key: string, value: string | null) => { + const newQuery = { ...query }; + if (value) { + newQuery[key] = value; + } else { + delete newQuery[key]; + } + delete newQuery.offset; + push({ query: newQuery }, undefined, { shallow: true }); + }; + + return ( +
+ + + + + updateFilter('userId', e.target.value || null)} + /> +
+ ); +}; + +export default AuditLogFilters; diff --git a/admin-ui/src/modules/audit/components/AuditLogTable.tsx b/admin-ui/src/modules/audit/components/AuditLogTable.tsx new file mode 100644 index 0000000000..8b3f3136a2 --- /dev/null +++ b/admin-ui/src/modules/audit/components/AuditLogTable.tsx @@ -0,0 +1,119 @@ +import { useIntl } from 'react-intl'; +import { CLASS_LABELS, STATUS_LABELS, CLASS_COLORS } from './ocsf-labels'; + +const AuditLogTable = ({ + entries, + onSelectEntry, +}: { + entries: any[]; + onSelectEntry: (entry: any) => void; +}) => { + const { formatMessage, formatDate, formatTime } = useIntl(); + + if (!entries?.length) { + return ( +

+ {formatMessage({ + id: 'no_audit_entries', + defaultMessage: 'No audit log entries found', + })} +

+ ); + } + + return ( +
+ + + + + + + + + + + + {entries.map((entry) => { + const userName = + entry.actor?.user?.name || + entry.actor?.user?.emailAddr || + entry.actor?.user?.uid || + '—'; + const statusLabel = STATUS_LABELS[entry.statusId] || 'Unknown'; + const isFailure = entry.statusId === 2; + const classColor = + CLASS_COLORS[entry.className] || + 'bg-slate-100 text-slate-800 dark:bg-slate-700 dark:text-slate-200'; + + return ( + onSelectEntry(entry)} + > + + + + + + + ); + })} + +
+ {formatMessage({ id: 'audit_time', defaultMessage: 'Time' })} + + {formatMessage({ id: 'audit_class', defaultMessage: 'Class' })} + + {formatMessage({ + id: 'audit_message', + defaultMessage: 'Message', + })} + + {formatMessage({ id: 'audit_user', defaultMessage: 'User' })} + + {formatMessage({ + id: 'audit_status', + defaultMessage: 'Status', + })} +
+ {formatDate(entry.time, { + month: 'short', + day: 'numeric', + })}{' '} + {formatTime(entry.time, { + hour: '2-digit', + minute: '2-digit', + second: '2-digit', + })} + + + {CLASS_LABELS[entry.className] || entry.className} + + + {entry.message || '—'} + + {userName} + + + {statusLabel} + +
+
+ ); +}; + +export default AuditLogTable; diff --git a/admin-ui/src/modules/audit/components/ChainStatusBanner.tsx b/admin-ui/src/modules/audit/components/ChainStatusBanner.tsx new file mode 100644 index 0000000000..285bc37f2f --- /dev/null +++ b/admin-ui/src/modules/audit/components/ChainStatusBanner.tsx @@ -0,0 +1,87 @@ +import { useIntl } from 'react-intl'; +import useAuditChainStatus from '../hooks/useAuditChainStatus'; +import { + CheckCircleIcon, + ExclamationTriangleIcon, +} from '@heroicons/react/24/outline'; + +const ChainStatusBanner = () => { + const { formatMessage } = useIntl(); + const { chainStatus, loading, refetch } = useAuditChainStatus(); + + if (loading || !chainStatus) return null; + + const isValid = chainStatus.valid; + + return ( +
+
+
+ {isValid ? ( + + ) : ( + + )} +
+

+ {isValid + ? formatMessage({ + id: 'audit_chain_valid', + defaultMessage: 'Audit chain integrity verified', + }) + : formatMessage({ + id: 'audit_chain_invalid', + defaultMessage: 'Audit chain integrity compromised', + })} +

+

+ {formatMessage( + { + id: 'audit_chain_entries', + defaultMessage: '{checked} of {total} entries verified', + }, + { + checked: chainStatus.checkedEntries, + total: chainStatus.totalEntries, + }, + )} +

+
+
+ +
+ {chainStatus.errors?.length > 0 && ( +
+ {chainStatus.errors.map((err, i) => ( +

+ #{err.sequenceNumber}: {err.message} +

+ ))} +
+ )} +
+ ); +}; + +export default ChainStatusBanner; diff --git a/admin-ui/src/modules/audit/components/FailedLoginsWidget.tsx b/admin-ui/src/modules/audit/components/FailedLoginsWidget.tsx new file mode 100644 index 0000000000..525a17804c --- /dev/null +++ b/admin-ui/src/modules/audit/components/FailedLoginsWidget.tsx @@ -0,0 +1,61 @@ +import { useIntl } from 'react-intl'; +import useFailedLoginAttempts from '../hooks/useFailedLoginAttempts'; +import { ExclamationTriangleIcon } from '@heroicons/react/24/outline'; + +const FailedLoginsWidget = () => { + const { formatMessage } = useIntl(); + const now = Date.now(); + const oneDayAgo = now - 24 * 60 * 60 * 1000; + const oneWeekAgo = now - 7 * 24 * 60 * 60 * 1000; + + const { failedLoginAttempts: last24h, loading: loading24h } = + useFailedLoginAttempts({ since: oneDayAgo }); + const { failedLoginAttempts: last7d, loading: loading7d } = + useFailedLoginAttempts({ since: oneWeekAgo }); + + const loading = loading24h || loading7d; + + return ( +
+
+ +

+ {formatMessage({ + id: 'failed_login_attempts', + defaultMessage: 'Failed login attempts', + })} +

+
+ {loading ? ( +
+ ) : ( +
+
+

+ {last24h} +

+

+ {formatMessage({ + id: 'last_24_hours', + defaultMessage: 'Last 24 hours', + })} +

+
+
+

+ {last7d} +

+

+ {formatMessage({ + id: 'last_7_days', + defaultMessage: 'Last 7 days', + })} +

+
+
+ )} +
+ ); +}; + +export default FailedLoginsWidget; diff --git a/admin-ui/src/modules/audit/components/ocsf-labels.ts b/admin-ui/src/modules/audit/components/ocsf-labels.ts new file mode 100644 index 0000000000..236b2b4610 --- /dev/null +++ b/admin-ui/src/modules/audit/components/ocsf-labels.ts @@ -0,0 +1,32 @@ +export const CLASS_LABELS: Record = { + ACCOUNT_CHANGE: 'Account Change', + AUTHENTICATION: 'Authentication', + API_ACTIVITY: 'API Activity', +}; + +export const SEVERITY_LABELS: Record = { + 0: 'Unknown', + 1: 'Informational', + 2: 'Low', + 3: 'Medium', + 4: 'High', + 5: 'Critical', + 6: 'Fatal', + 99: 'Other', +}; + +export const STATUS_LABELS: Record = { + 0: 'Unknown', + 1: 'Success', + 2: 'Failure', + 99: 'Other', +}; + +export const CLASS_COLORS: Record = { + AUTHENTICATION: + 'bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200', + ACCOUNT_CHANGE: + 'bg-purple-100 text-purple-800 dark:bg-purple-900 dark:text-purple-200', + API_ACTIVITY: + 'bg-amber-100 text-amber-800 dark:bg-amber-900 dark:text-amber-200', +}; diff --git a/admin-ui/src/modules/audit/fragments/AuditLogEntryFragment.ts b/admin-ui/src/modules/audit/fragments/AuditLogEntryFragment.ts new file mode 100644 index 0000000000..04b43a5393 --- /dev/null +++ b/admin-ui/src/modules/audit/fragments/AuditLogEntryFragment.ts @@ -0,0 +1,52 @@ +import { gql } from '@apollo/client'; + +const AuditLogEntryFragment = gql` + fragment AuditLogEntryFragment on AuditLogEntry { + id + time + message + classUid + className + activityId + activityName + typeUid + categoryUid + severityId + statusId + statusDetail + actor { + user { + uid + name + emailAddr + } + session { + uid + } + } + srcEndpoint { + ip + port + } + dstEndpoint { + ip + port + } + api { + operation + request { + uid + } + response { + code + } + } + metadata + sequenceNumber + prevHash + hash + raw + } +`; + +export default AuditLogEntryFragment; diff --git a/admin-ui/src/modules/audit/hooks/useAuditChainStatus.ts b/admin-ui/src/modules/audit/hooks/useAuditChainStatus.ts new file mode 100644 index 0000000000..f448dd31b1 --- /dev/null +++ b/admin-ui/src/modules/audit/hooks/useAuditChainStatus.ts @@ -0,0 +1,40 @@ +import { gql } from '@apollo/client'; +import { useQuery } from '@apollo/client/react'; + +const AuditChainStatusQuery = gql` + query AuditChainStatus { + auditChainStatus { + valid + totalEntries + checkedEntries + firstEntry + lastEntry + errors { + sequenceNumber + message + } + } + } +`; + +const useAuditChainStatus = () => { + const { data, loading, error, refetch } = useQuery<{ + auditChainStatus: { + valid: boolean; + totalEntries: number; + checkedEntries: number; + firstEntry: number | null; + lastEntry: number | null; + errors: { sequenceNumber: number; message: string }[]; + }; + }>(AuditChainStatusQuery); + + return { + chainStatus: data?.auditChainStatus, + loading, + error, + refetch, + }; +}; + +export default useAuditChainStatus; diff --git a/admin-ui/src/modules/audit/hooks/useAuditLogs.ts b/admin-ui/src/modules/audit/hooks/useAuditLogs.ts new file mode 100644 index 0000000000..58de66ee80 --- /dev/null +++ b/admin-ui/src/modules/audit/hooks/useAuditLogs.ts @@ -0,0 +1,74 @@ +import { gql } from '@apollo/client'; +import { useQuery } from '@apollo/client/react'; +import AuditLogEntryFragment from '../fragments/AuditLogEntryFragment'; + +const AuditLogsQuery = gql` + query AuditLogs( + $limit: Int + $offset: Int + $classUids: [Int!] + $userId: String + $success: Boolean + $from: Timestamp + $until: Timestamp + ) { + auditLogs( + limit: $limit + offset: $offset + classUids: $classUids + userId: $userId + success: $success + from: $from + until: $until + ) { + ...AuditLogEntryFragment + } + auditLogsCount( + classUids: $classUids + userId: $userId + success: $success + from: $from + until: $until + ) + } + ${AuditLogEntryFragment} +`; + +const useAuditLogs = ({ + limit = 50, + offset = 0, + classUids = null, + userId = null, + success = null, + from = null, + until = null, +}: { + limit?: number; + offset?: number; + classUids?: number[] | null; + userId?: string | null; + success?: boolean | null; + from?: number | null; + until?: number | null; +} = {}) => { + const { data, loading, error, fetchMore, previousData } = useQuery<{ + auditLogs: any[]; + auditLogsCount: number; + }>(AuditLogsQuery, { + variables: { limit, offset, classUids, userId, success, from, until }, + }); + + const auditLogs = data?.auditLogs || previousData?.auditLogs || []; + const auditLogsCount = + data?.auditLogsCount ?? previousData?.auditLogsCount ?? 0; + const hasMore = auditLogs.length < auditLogsCount; + + const loadMore = () => { + if (loading) return; + fetchMore({ variables: { offset: auditLogs.length } }); + }; + + return { auditLogs, auditLogsCount, hasMore, loadMore, loading, error }; +}; + +export default useAuditLogs; diff --git a/admin-ui/src/modules/audit/hooks/useFailedLoginAttempts.ts b/admin-ui/src/modules/audit/hooks/useFailedLoginAttempts.ts new file mode 100644 index 0000000000..a3b954e0e9 --- /dev/null +++ b/admin-ui/src/modules/audit/hooks/useFailedLoginAttempts.ts @@ -0,0 +1,41 @@ +import { gql } from '@apollo/client'; +import { useQuery } from '@apollo/client/react'; + +const FailedLoginAttemptsQuery = gql` + query FailedLoginAttempts( + $userId: String + $remoteAddress: String + $since: Timestamp + ) { + failedLoginAttempts( + userId: $userId + remoteAddress: $remoteAddress + since: $since + ) + } +`; + +const useFailedLoginAttempts = ({ + userId = null, + remoteAddress = null, + since = null, +}: { + userId?: string | null; + remoteAddress?: string | null; + since?: number | null; +} = {}) => { + const { data, loading, error, refetch } = useQuery<{ + failedLoginAttempts: number; + }>(FailedLoginAttemptsQuery, { + variables: { userId, remoteAddress, since }, + }); + + return { + failedLoginAttempts: data?.failedLoginAttempts ?? 0, + loading, + error, + refetch, + }; +}; + +export default useFailedLoginAttempts; diff --git a/admin-ui/src/modules/audit/index.ts b/admin-ui/src/modules/audit/index.ts new file mode 100644 index 0000000000..9c8adab9b8 --- /dev/null +++ b/admin-ui/src/modules/audit/index.ts @@ -0,0 +1,3 @@ +export { default as useAuditLogs } from './hooks/useAuditLogs'; +export { default as useAuditChainStatus } from './hooks/useAuditChainStatus'; +export { default as useFailedLoginAttempts } from './hooks/useFailedLoginAttempts'; diff --git a/admin-ui/src/modules/common/components/Layout.tsx b/admin-ui/src/modules/common/components/Layout.tsx index 5b68d604b0..b93c00e5fe 100644 --- a/admin-ui/src/modules/common/components/Layout.tsx +++ b/admin-ui/src/modules/common/components/Layout.tsx @@ -15,6 +15,7 @@ import { CubeIcon, DocumentTextIcon, FolderArrowDownIcon, + ShieldCheckIcon, } from '@heroicons/react/24/outline'; import Link from 'next/link'; import React, { useState } from 'react'; @@ -298,6 +299,20 @@ const Layout = ({ }, ].filter(Boolean), }, + isSystemReady && { + name: formatMessage({ id: 'settings', defaultMessage: 'Settings' }), + icon: ShieldCheckIcon, + children: [ + { + name: formatMessage({ + id: 'security', + defaultMessage: 'Security', + }), + requiredRole: 'viewAuditLog', + href: '/settings/security', + }, + ], + }, { _sortOrder: 120, name: formatMessage({ id: 'activities', defaultMessage: 'Activities' }), diff --git a/admin-ui/src/pages/settings/index.tsx b/admin-ui/src/pages/settings/index.tsx new file mode 100644 index 0000000000..c87bf4a594 --- /dev/null +++ b/admin-ui/src/pages/settings/index.tsx @@ -0,0 +1,14 @@ +import { useEffect } from 'react'; +import { useRouter } from 'next/router'; + +const Settings = () => { + const router = useRouter(); + + useEffect(() => { + router.replace('/settings/security'); + }, []); + + return null; +}; + +export default Settings; diff --git a/admin-ui/src/pages/settings/security.tsx b/admin-ui/src/pages/settings/security.tsx new file mode 100644 index 0000000000..6cadad3452 --- /dev/null +++ b/admin-ui/src/pages/settings/security.tsx @@ -0,0 +1,97 @@ +import { useState } from 'react'; +import { useIntl } from 'react-intl'; +import { useRouter } from 'next/router'; +import BreadCrumbs from '@/components/ui/BreadCrumbs'; +import PageHeader from '@/components/ui/PageHeader'; +import Loading from '@/components/ui/Loading'; +import InfiniteScroll from '../../modules/common/components/InfiniteScroll'; +import ChainStatusBanner from '../../modules/audit/components/ChainStatusBanner'; +import FailedLoginsWidget from '../../modules/audit/components/FailedLoginsWidget'; +import AuditLogFilters from '../../modules/audit/components/AuditLogFilters'; +import AuditLogTable from '../../modules/audit/components/AuditLogTable'; +import AuditEntryDetail from '../../modules/audit/components/AuditEntryDetail'; +import useAuditLogs from '../../modules/audit/hooks/useAuditLogs'; + +const SecurityPage = () => { + const { formatMessage } = useIntl(); + const { query } = useRouter(); + const [selectedEntry, setSelectedEntry] = useState(null); + + const classUids = query.classUid + ? [parseInt(query.classUid as string, 10)] + : null; + const success = + query.success === 'true' ? true : query.success === 'false' ? false : null; + const userId = (query.userId as string) || null; + + const { auditLogs, auditLogsCount, loading, hasMore, loadMore } = + useAuditLogs({ + limit: 50, + classUids, + success, + userId, + }); + + return ( + <> + + + +
+
+ + +
+ +
+

+ {formatMessage( + { + id: 'audit_log_browser', + defaultMessage: 'Audit Log ({count})', + }, + { count: auditLogsCount ?? '...' }, + )} +

+ + + +
+ + {loading && auditLogs.length === 0 ? ( + + ) : ( + + )} + +
+
+
+ + {selectedEntry && ( + setSelectedEntry(null)} + /> + )} + + ); +}; + +export default SecurityPage; diff --git a/examples/kitchensink/.gitignore b/examples/kitchensink/.gitignore index 29d8bd23d9..3297086202 100644 --- a/examples/kitchensink/.gitignore +++ b/examples/kitchensink/.gitignore @@ -1,4 +1,5 @@ node_modules .env lib -.db \ No newline at end of file +.db +audit-logs \ No newline at end of file diff --git a/packages/api/src/api-index.ts b/packages/api/src/api-index.ts index 3a4b499711..1ee02eecab 100644 --- a/packages/api/src/api-index.ts +++ b/packages/api/src/api-index.ts @@ -1,4 +1,4 @@ -import { registerEvents } from '@unchainedshop/events'; +import { registerEvents, type AuditLog } from '@unchainedshop/events'; import createGraphQLServer, { type GraphQLServerOptions } from './createGraphQLServer.ts'; import { createContextResolver, @@ -27,6 +27,7 @@ import { actions } from './roles/index.ts'; export type UnchainedServerOptions = { roles?: RolesInterface; adminUiConfig?: AdminUiConfig; + auditLog?: AuditLog; unchainedAPI: UnchainedCore; context?: (defaultResolver: UnchainedContextResolver) => UnchainedContextResolver; } & Partial; @@ -39,12 +40,14 @@ export const startAPIServer = async (options: UnchainedServerOptions) => { context: customContext, roles, adminUiConfig = {}, + auditLog, ...serverOptions } = options as UnchainedServerOptions; const contextResolver = createContextResolver(unchainedAPI, { roles, adminUiConfig, + auditLog, }); setCurrentContextResolver( diff --git a/packages/api/src/context.ts b/packages/api/src/context.ts index 09a66e7af1..33a3b6b395 100644 --- a/packages/api/src/context.ts +++ b/packages/api/src/context.ts @@ -1,5 +1,6 @@ import type { UnchainedCore } from '@unchainedshop/core'; import type { RolesInterface } from '@unchainedshop/roles'; +import type { AuditLog } from '@unchainedshop/events'; import instantiateLoaders, { type UnchainedLoaders } from './loaders/index.ts'; import { getLocaleContext, type UnchainedLocaleContext } from './locale-context.ts'; import type { UnchainedServerOptions } from './api-index.ts'; @@ -51,6 +52,7 @@ export type Context = UnchainedCore & { version?: string; roles?: RolesInterface; adminUiConfig?: AdminUiConfig; + auditLog?: AuditLog; loaders: UnchainedLoaders; } & UnchainedUserContext & UnchainedLocaleContext & @@ -73,7 +75,7 @@ export type UnchainedContextResolver = ( export const createContextResolver = ( unchainedAPI: UnchainedCore, - unchainedConfig: Pick, + unchainedConfig: Pick, ): UnchainedContextResolver => async ({ getHeader, diff --git a/packages/api/src/events.ts b/packages/api/src/events.ts index cc29543806..1c7c186646 100644 --- a/packages/api/src/events.ts +++ b/packages/api/src/events.ts @@ -1,5 +1,6 @@ export const API_EVENTS = { API_LOGIN_TOKEN_CREATED: 'API_LOGIN_TOKEN_CREATED', + API_LOGIN_FAILED: 'API_LOGIN_FAILED', API_LOGOUT: 'API_LOGOUT', ACL_DENIED: 'ACL_DENIED', ACL_GRANTED_SENSITIVE: 'ACL_GRANTED_SENSITIVE', diff --git a/packages/api/src/express/index.ts b/packages/api/src/express/index.ts index 3302735367..7f7dc754e2 100644 --- a/packages/api/src/express/index.ts +++ b/packages/api/src/express/index.ts @@ -7,6 +7,7 @@ import { createHash } from 'node:crypto'; import { existsSync } from 'node:fs'; import { join } from 'node:path'; +import { runWithAuditContext } from '@unchainedshop/events'; import { getCurrentContextResolver } from '../context.ts'; import { createAuthContext, type AuthContextParams } from '../middleware/createAuthMiddleware.ts'; import type { AuthConfig } from '../auth.ts'; @@ -164,7 +165,7 @@ const createAddContextMiddleware = (authConfig?: AuthConfig, trustProxy = false) const context = getCurrentContextResolver(); // Build full context - (req as any).unchainedContext = await context( + const unchainedContext = await context( { setHeader, getHeader, @@ -180,7 +181,17 @@ const createAddContextMiddleware = (authConfig?: AuthConfig, trustProxy = false) req, res, ); - next(); + (req as any).unchainedContext = unchainedContext; + + runWithAuditContext( + { + userId: unchainedContext.userId, + userName: unchainedContext.user?.username || unchainedContext.user?.emails?.[0]?.address, + remoteAddress, + sessionId: authContext.accessToken, + }, + () => next(), + ); } catch (error) { next(error); } diff --git a/packages/api/src/middleware/createAuthMiddleware.ts b/packages/api/src/middleware/createAuthMiddleware.ts index b18999c3b3..87fe7a1bd2 100644 --- a/packages/api/src/middleware/createAuthMiddleware.ts +++ b/packages/api/src/middleware/createAuthMiddleware.ts @@ -204,7 +204,9 @@ export async function createAuthContext( const tokenObject = { _id: crypto.randomUUID(), // Session ID equivalent userId: user._id, + userName: user.username || user.emails?.[0]?.address, tokenExpires: expires, + remoteAddress: params.remoteAddress, }; await emit(API_EVENTS.API_LOGIN_TOKEN_CREATED, tokenObject); diff --git a/packages/api/src/resolvers/mutations/accounts/loginWithPassword.ts b/packages/api/src/resolvers/mutations/accounts/loginWithPassword.ts index 919bc13911..91b3b37326 100755 --- a/packages/api/src/resolvers/mutations/accounts/loginWithPassword.ts +++ b/packages/api/src/resolvers/mutations/accounts/loginWithPassword.ts @@ -1,5 +1,7 @@ import { log } from '@unchainedshop/logger'; +import { emit } from '@unchainedshop/events'; import { InvalidCredentialsError, UsernameOrEmailRequiredError } from '../../../errors.ts'; +import { API_EVENTS } from '../../../events.ts'; import type { Context } from '../../../context.ts'; import type { User } from '@unchainedshop/core-users'; @@ -21,13 +23,27 @@ export default async function loginWithPassword( ? await context.modules.users.findUserByUsername(username) : await context.modules.users.findUserByEmail(email!); - if (!user) throw new InvalidCredentialsError({ username, email }); + if (!user) { + await emit(API_EVENTS.API_LOGIN_FAILED, { + username, + email, + remoteAddress: context.remoteAddress, + }); + throw new InvalidCredentialsError({ username, email }); + } const verified = user.services?.password && (await context.modules.users.verifyPassword(user.services.password, password)); - if (!verified) throw new InvalidCredentialsError({ username, email }); + if (!verified) { + await emit(API_EVENTS.API_LOGIN_FAILED, { + userId: user._id, + username: user.username, + remoteAddress: context.remoteAddress, + }); + throw new InvalidCredentialsError({ username, email }); + } if (user.guest) { await context.modules.users.updateGuest(user, false); diff --git a/packages/api/src/resolvers/queries/audit/auditChainStatus.ts b/packages/api/src/resolvers/queries/audit/auditChainStatus.ts new file mode 100644 index 0000000000..7643a30705 --- /dev/null +++ b/packages/api/src/resolvers/queries/audit/auditChainStatus.ts @@ -0,0 +1,27 @@ +import { log } from '@unchainedshop/logger'; +import type { Context } from '../../../context.ts'; + +export default async function auditChainStatus(_root: never, _params: never, context: Context) { + log('query auditChainStatus', { userId: context.userId }); + if (!context.auditLog) { + return { + valid: true, + totalEntries: 0, + checkedEntries: 0, + firstEntry: null, + lastEntry: null, + errors: [], + }; + } + + const result = await context.auditLog.verify(); + + return { + valid: result.valid, + totalEntries: result.entries, + checkedEntries: result.verified, + firstEntry: null, + lastEntry: null, + errors: result.error ? [{ sequenceNumber: 0, message: result.error }] : [], + }; +} diff --git a/packages/api/src/resolvers/queries/audit/auditLogs.ts b/packages/api/src/resolvers/queries/audit/auditLogs.ts new file mode 100644 index 0000000000..337e6b58c7 --- /dev/null +++ b/packages/api/src/resolvers/queries/audit/auditLogs.ts @@ -0,0 +1,99 @@ +import { log } from '@unchainedshop/logger'; +import { OCSF_CLASS, type OCSFEvent, type OCSFUser, type OCSFActor } from '@unchainedshop/events'; +import type { Context } from '../../../context.ts'; + +const CLASS_NAMES: Record = { + [OCSF_CLASS.ACCOUNT_CHANGE]: 'ACCOUNT_CHANGE', + [OCSF_CLASS.AUTHENTICATION]: 'AUTHENTICATION', + [OCSF_CLASS.API_ACTIVITY]: 'API_ACTIVITY', +}; + +function mapUser(user?: OCSFUser) { + if (!user) return undefined; + return { + uid: user.uid, + name: user.name, + emailAddr: user.email_addr, + }; +} + +function mapActor( + event: OCSFEvent, +): { user?: ReturnType; session?: { uid?: string } } | undefined { + if ('actor' in event && (event as any).actor) { + const actor = (event as any).actor as OCSFActor; + return { + user: mapUser(actor.user), + session: actor.session ? { uid: actor.session.uid } : undefined, + }; + } + if ('user' in event && (event as any).user) { + return { + user: mapUser((event as any).user as OCSFUser), + session: + 'session' in event && (event as any).session ? { uid: (event as any).session.uid } : undefined, + }; + } + return undefined; +} + +function mapEndpoint(ep?: { ip?: string; port?: number }) { + if (!ep) return undefined; + return { ip: ep.ip, port: ep.port }; +} + +export function mapAuditEntry(event: OCSFEvent) { + return { + id: event.unmapped?.hash || `${event.time}-${event.unmapped?.seq}`, + time: event.time, + message: event.message, + classUid: event.class_uid, + className: CLASS_NAMES[event.class_uid] || 'UNKNOWN', + activityId: event.activity_id, + activityName: event.message, + typeUid: event.type_uid, + categoryUid: event.category_uid, + severityId: event.severity_id, + statusId: event.status_id ?? 0, + statusDetail: event.status_detail, + actor: mapActor(event), + srcEndpoint: mapEndpoint('src_endpoint' in event ? (event as any).src_endpoint : undefined), + dstEndpoint: mapEndpoint('dst_endpoint' in event ? (event as any).dst_endpoint : undefined), + api: 'api' in event ? (event as any).api : undefined, + metadata: event.metadata, + sequenceNumber: event.unmapped?.seq, + prevHash: event.unmapped?.prev_hash, + hash: event.unmapped?.hash, + raw: event, + }; +} + +export default async function auditLogs( + _root: never, + params: { + limit?: number; + offset?: number; + classUids?: number[]; + userId?: string; + success?: boolean; + from?: number; + until?: number; + }, + context: Context, +) { + log(`query auditLogs limit: ${params.limit} offset: ${params.offset}`, { userId: context.userId }); + + if (!context.auditLog) return []; + + const entries = await context.auditLog.find({ + limit: params.limit, + offset: params.offset, + classUids: params.classUids, + userId: params.userId, + success: params.success ?? undefined, + startTime: params.from ? new Date(params.from) : undefined, + endTime: params.until ? new Date(params.until) : undefined, + }); + + return entries.map(mapAuditEntry); +} diff --git a/packages/api/src/resolvers/queries/audit/auditLogsCount.ts b/packages/api/src/resolvers/queries/audit/auditLogsCount.ts new file mode 100644 index 0000000000..38dcf48487 --- /dev/null +++ b/packages/api/src/resolvers/queries/audit/auditLogsCount.ts @@ -0,0 +1,26 @@ +import { log } from '@unchainedshop/logger'; +import type { Context } from '../../../context.ts'; + +export default async function auditLogsCount( + _root: never, + params: { + classUids?: number[]; + userId?: string; + success?: boolean; + from?: number; + until?: number; + }, + context: Context, +) { + log('query auditLogsCount', { userId: context.userId }); + + if (!context.auditLog) return 0; + + return context.auditLog.count({ + classUids: params.classUids, + userId: params.userId, + success: params.success ?? undefined, + startTime: params.from ? new Date(params.from) : undefined, + endTime: params.until ? new Date(params.until) : undefined, + }); +} diff --git a/packages/api/src/resolvers/queries/audit/failedLoginAttempts.ts b/packages/api/src/resolvers/queries/audit/failedLoginAttempts.ts new file mode 100644 index 0000000000..75efcbaa6d --- /dev/null +++ b/packages/api/src/resolvers/queries/audit/failedLoginAttempts.ts @@ -0,0 +1,22 @@ +import { log } from '@unchainedshop/logger'; +import type { Context } from '../../../context.ts'; + +export default async function failedLoginAttempts( + _root: never, + params: { + userId?: string; + remoteAddress?: string; + since?: number; + }, + context: Context, +) { + log('query failedLoginAttempts', { userId: context.userId }); + + if (!context.auditLog) return 0; + + return context.auditLog.getFailedLogins({ + userId: params.userId, + remoteAddress: params.remoteAddress, + since: params.since ? new Date(params.since) : undefined, + }); +} diff --git a/packages/api/src/resolvers/queries/index.ts b/packages/api/src/resolvers/queries/index.ts index a76d01c9c0..e3455dadfc 100755 --- a/packages/api/src/resolvers/queries/index.ts +++ b/packages/api/src/resolvers/queries/index.ts @@ -74,6 +74,10 @@ import eventStatistics from './events/eventStatistics.ts'; import registeredEventTypes from './events/registeredEventTypes.ts'; import orderStatistics from './orders/orderStatistics.ts'; import impersonator from './users/impersonator.ts'; +import auditLogs from './audit/auditLogs.ts'; +import auditLogsCount from './audit/auditLogsCount.ts'; +import auditChainStatus from './audit/auditChainStatus.ts'; +import failedLoginAttempts from './audit/failedLoginAttempts.ts'; export default { me, @@ -149,4 +153,8 @@ export default { registeredEventTypes: acl(actions.viewEvents)(registeredEventTypes), eventStatistics: acl(actions.viewStatistics)(eventStatistics), orderStatistics: acl(actions.viewStatistics)(orderStatistics), + auditLogs: acl(actions.viewAuditLog)(auditLogs), + auditLogsCount: acl(actions.viewAuditLog)(auditLogsCount), + auditChainStatus: acl(actions.verifyAuditChain)(auditChainStatus), + failedLoginAttempts: acl(actions.viewAuditLog)(failedLoginAttempts), }; diff --git a/packages/api/src/roles/all.ts b/packages/api/src/roles/all.ts index ff630a8437..4c77e6bb5a 100644 --- a/packages/api/src/roles/all.ts +++ b/packages/api/src/roles/all.ts @@ -50,6 +50,8 @@ export const all = (role, actions) => { role.allow(actions.viewEvent, () => false); role.allow(actions.viewEvents, () => false); + role.allow(actions.viewAuditLog, () => false); + role.allow(actions.verifyAuditChain, () => false); role.allow(actions.viewUser, () => false); role.allow(actions.viewUsers, () => false); role.allow(actions.viewUserCount, isUsersCollectionEmpty); diff --git a/packages/api/src/roles/index.ts b/packages/api/src/roles/index.ts index 5ba7f216a1..afe6214dcb 100644 --- a/packages/api/src/roles/index.ts +++ b/packages/api/src/roles/index.ts @@ -117,6 +117,8 @@ const actions: Record = [ 'downloadFile', 'uploadUserAvatar', 'uploadTempFile', + 'viewAuditLog', + 'verifyAuditChain', ].reduce((oldValue, actionValue) => { const newValue = oldValue; newValue[actionValue] = actionValue; diff --git a/packages/api/src/schema/query.ts b/packages/api/src/schema/query.ts index 4699fd9ed8..37bd3b9688 100644 --- a/packages/api/src/schema/query.ts +++ b/packages/api/src/schema/query.ts @@ -506,6 +506,40 @@ export default [ """ registeredEventTypes: [String!]! + """ + Get audit log entries + """ + auditLogs( + limit: Int = 50 + offset: Int = 0 + classUids: [Int!] + userId: String + success: Boolean + from: Timestamp + until: Timestamp + ): [AuditLogEntry!]! + + """ + Get total count of audit log entries + """ + auditLogsCount( + classUids: [Int!] + userId: String + success: Boolean + from: Timestamp + until: Timestamp + ): Int! + + """ + Verify tamper-evident hash chain integrity of the audit log + """ + auditChainStatus: AuditChainStatus! + + """ + Get count of failed login attempts + """ + failedLoginAttempts(userId: String, remoteAddress: String, since: Timestamp): Int! + """ Returns aggregated report of all the events that occurred in the system """ diff --git a/packages/api/src/schema/types/audit.ts b/packages/api/src/schema/types/audit.ts new file mode 100644 index 0000000000..85436284f3 --- /dev/null +++ b/packages/api/src/schema/types/audit.ts @@ -0,0 +1,99 @@ +export default [ + /* GraphQL */ ` + enum AuditLogClass { + ACCOUNT_CHANGE + AUTHENTICATION + API_ACTIVITY + } + + enum AuditLogSeverity { + UNKNOWN + INFORMATIONAL + LOW + MEDIUM + HIGH + CRITICAL + FATAL + OTHER + } + + enum AuditLogStatus { + UNKNOWN + SUCCESS + FAILURE + OTHER + } + + type AuditLogUser { + uid: String + name: String + emailAddr: String + } + + type AuditLogActor { + user: AuditLogUser + session: AuditLogSession + } + + type AuditLogSession { + uid: String + } + + type AuditLogEndpoint { + ip: String + port: Int + } + + type AuditLogApi { + operation: String + request: AuditLogApiRequest + response: AuditLogApiResponse + } + + type AuditLogApiRequest { + uid: String + } + + type AuditLogApiResponse { + code: Int + } + + type AuditLogEntry { + id: ID! + time: Float! + message: String + classUid: Int! + className: String! + activityId: Int! + activityName: String + typeUid: Int! + categoryUid: Int! + severityId: Int! + statusId: Int! + statusDetail: String + actor: AuditLogActor + srcEndpoint: AuditLogEndpoint + dstEndpoint: AuditLogEndpoint + api: AuditLogApi + metadata: JSON + sequenceNumber: Int + prevHash: String + hash: String + raw: JSON + } + + type AuditChainStatus { + valid: Boolean! + totalEntries: Int! + checkedEntries: Int! + firstEntry: Float + lastEntry: Float + errors: [AuditChainError!]! + } + + type AuditChainError { + sequenceNumber: Int! + message: String! + } + `, +]; diff --git a/packages/api/src/schema/types/index.ts b/packages/api/src/schema/types/index.ts index ae637a1aea..0381be9a35 100644 --- a/packages/api/src/schema/types/index.ts +++ b/packages/api/src/schema/types/index.ts @@ -28,6 +28,7 @@ import worker from './worker.ts'; import enrollment from './enrollment.ts'; import events from './events.ts'; import files from './files.ts'; +import audit from './audit.ts'; export default [ ...address, @@ -60,4 +61,5 @@ export default [ ...enrollment, ...events, ...files, + ...audit, ]; diff --git a/packages/events/src/audit/audit-integration.ts b/packages/events/src/audit/audit-integration.ts index a31e1dd4f2..3e01424e0d 100644 --- a/packages/events/src/audit/audit-integration.ts +++ b/packages/events/src/audit/audit-integration.ts @@ -9,16 +9,39 @@ import { createLogger } from '@unchainedshop/logger'; import { EventDirector } from '../EventDirector.ts'; import type { AuditLog } from './index.ts'; import { OCSF_AUTH_ACTIVITY, OCSF_ACCOUNT_ACTIVITY, OCSF_API_ACTIVITY } from './ocsf-types.ts'; +import { getAuditContext } from './request-context.ts'; const logger = createLogger('unchained:audit-integration'); +function entityLabel(entity: Record | undefined, fallbackId?: string): string { + if (!entity) return fallbackId || 'unknown'; + const name = + entity.slugs?.[0] || entity.key || entity.isoCode || entity.title || entity.name || entity.type; + const id = entity._id || fallbackId; + if (name && id) return `${name} (${id})`; + return name || id || 'unknown'; +} + +function changedFields(payload: Record, excludeKeys: string[]): string { + const keys = Object.keys(payload).filter( + (k) => !excludeKeys.includes(k) && !k.startsWith('_') && k !== 'updated', + ); + if (!keys.length) return ''; + return ` [${keys.join(', ')}]`; +} + /** Extracts common audit context from any event payload */ function extractContext(payload: Record) { + const reqCtx = getAuditContext(); return { - userId: payload.userId || payload.user?._id || payload.user?.id, - userName: payload.userName || payload.user?.username || payload.user?.emails?.[0]?.address, - remoteAddress: payload.remoteAddress || payload.ip, - sessionId: payload.sessionId, + userId: payload.userId || payload.user?._id || payload.user?.id || reqCtx?.userId, + userName: + payload.userName || + payload.user?.username || + payload.user?.emails?.[0]?.address || + reqCtx?.userName, + remoteAddress: payload.remoteAddress || payload.ip || reqCtx?.remoteAddress, + sessionId: payload.sessionId || reqCtx?.sessionId, }; } @@ -61,6 +84,16 @@ export function configureAuditIntegration(auditLog: AuditLog): void { await auditLog.logAuthentication({ activity: OCSF_AUTH_ACTIVITY.LOGON, ...ctx, success: true }); }); + sub('API_LOGIN_FAILED', async (p) => { + const ctx = extractContext(p); + await auditLog.logAuthentication({ + activity: OCSF_AUTH_ACTIVITY.LOGON, + ...ctx, + success: false, + message: 'Failed login attempt', + }); + }); + sub('API_LOGOUT', async (p) => { const ctx = extractContext(p); await auditLog.logAuthentication({ activity: OCSF_AUTH_ACTIVITY.LOGOFF, ...ctx, success: true }); @@ -124,7 +157,7 @@ export function configureAuditIntegration(auditLog: AuditLog): void { userId: p.order?.userId || ctx.userId, success: true, operation: 'createOrder', - message: `Order created: ${p.order?._id || 'unknown'}`, + message: `Order created: ${entityLabel(p.order)}`, }); }); @@ -136,7 +169,7 @@ export function configureAuditIntegration(auditLog: AuditLog): void { userId: p.order?.userId || ctx.userId, success: true, operation: 'checkoutOrder', - message: `Order checkout: ${p.order?._id || 'unknown'}`, + message: `Order checkout: ${entityLabel(p.order)}`, }); }); @@ -148,7 +181,7 @@ export function configureAuditIntegration(auditLog: AuditLog): void { userId: p.order?.userId || ctx.userId, success: true, operation: 'confirmOrder', - message: `Order confirmed: ${p.order?._id || 'unknown'}`, + message: `Order confirmed: ${entityLabel(p.order)}`, }); }); @@ -160,7 +193,7 @@ export function configureAuditIntegration(auditLog: AuditLog): void { userId: p.order?.userId || ctx.userId, success: true, operation: 'fulfillOrder', - message: `Order fulfilled: ${p.order?._id || 'unknown'}`, + message: `Order fulfilled: ${entityLabel(p.order)}`, }); }); @@ -172,7 +205,7 @@ export function configureAuditIntegration(auditLog: AuditLog): void { userId: p.order?.userId || ctx.userId, success: false, operation: 'rejectOrder', - message: `Order rejected: ${p.order?._id || 'unknown'}`, + message: `Order rejected: ${entityLabel(p.order)}`, }); }); @@ -220,6 +253,521 @@ export function configureAuditIntegration(auditLog: AuditLog): void { message: 'Payment updated', }); }); + + // Order lifecycle (remaining) + sub('ORDER_DELIVER', async (p) => { + const ctx = extractContext(p); + await auditLog.logApiActivity({ + activity: OCSF_API_ACTIVITY.UPDATE, + ...ctx, + userId: p.order?.userId || ctx.userId, + success: true, + operation: 'deliverOrder', + message: `Order delivered: ${entityLabel(p.order)}`, + }); + }); + + sub('ORDER_REMOVE', async (p) => { + const ctx = extractContext(p); + await auditLog.logApiActivity({ + activity: OCSF_API_ACTIVITY.DELETE, + ...ctx, + success: true, + operation: 'removeOrder', + message: `Order removed: ${entityLabel(p.order)}`, + }); + }); + + sub('ORDER_EMPTY_CART', async (p) => { + const ctx = extractContext(p); + await auditLog.logApiActivity({ + activity: OCSF_API_ACTIVITY.DELETE, + ...ctx, + success: true, + operation: 'emptyCart', + message: 'Cart emptied', + }); + }); + + sub('ORDER_SET_PAYMENT_PROVIDER', async (p) => { + const ctx = extractContext(p); + await auditLog.logApiActivity({ + activity: OCSF_API_ACTIVITY.UPDATE, + ...ctx, + success: true, + operation: 'setPaymentProvider', + message: `Payment provider set: ${p.paymentProviderId || 'unknown'}`, + }); + }); + + sub('ORDER_SET_DELIVERY_PROVIDER', async (p) => { + const ctx = extractContext(p); + await auditLog.logApiActivity({ + activity: OCSF_API_ACTIVITY.UPDATE, + ...ctx, + success: true, + operation: 'setDeliveryProvider', + message: `Delivery provider set: ${p.deliveryProviderId || 'unknown'}`, + }); + }); + + // Access control + sub('ACL_DENIED', async (p) => { + const ctx = extractContext(p); + await auditLog.logApiActivity({ + activity: OCSF_API_ACTIVITY.ACCESS_DENIED, + ...ctx, + success: false, + operation: p.action || 'unknown', + message: `Access denied: ${p.action || 'unknown'}`, + }); + }); + + sub('ACL_GRANTED_SENSITIVE', async (p) => { + const ctx = extractContext(p); + await auditLog.logApiActivity({ + activity: OCSF_API_ACTIVITY.UPDATE, + ...ctx, + success: true, + operation: p.action || 'impersonate', + message: `Sensitive access granted: ${p.action || 'unknown'}`, + }); + }); + + // Products + sub('PRODUCT_CREATE', async (p) => { + const ctx = extractContext(p); + await auditLog.logApiActivity({ + activity: OCSF_API_ACTIVITY.CREATE, + ...ctx, + success: true, + operation: 'createProduct', + message: `Product created: ${entityLabel(p.product)}`, + }); + }); + + sub('PRODUCT_UPDATE', async (p) => { + const ctx = extractContext(p); + const fields = changedFields(p, [ + 'product', + 'productId', + 'userId', + 'userName', + 'remoteAddress', + 'sessionId', + ]); + await auditLog.logApiActivity({ + activity: OCSF_API_ACTIVITY.UPDATE, + ...ctx, + success: true, + operation: 'updateProduct', + message: `Product updated: ${entityLabel(p.product, p.productId)}${fields}`, + }); + }); + + sub('PRODUCT_REMOVE', async (p) => { + const ctx = extractContext(p); + await auditLog.logApiActivity({ + activity: OCSF_API_ACTIVITY.DELETE, + ...ctx, + success: true, + operation: 'removeProduct', + message: `Product removed: ${entityLabel(p.product, p.productId)}`, + }); + }); + + sub('PRODUCT_PUBLISH', async (p) => { + const ctx = extractContext(p); + await auditLog.logApiActivity({ + activity: OCSF_API_ACTIVITY.UPDATE, + ...ctx, + success: true, + operation: 'publishProduct', + message: `Product published: ${entityLabel(p.product, p.productId)}`, + }); + }); + + sub('PRODUCT_UNPUBLISH', async (p) => { + const ctx = extractContext(p); + await auditLog.logApiActivity({ + activity: OCSF_API_ACTIVITY.UPDATE, + ...ctx, + success: true, + operation: 'unpublishProduct', + message: `Product unpublished: ${entityLabel(p.product, p.productId)}`, + }); + }); + + // Provider configuration + sub('PAYMENT_PROVIDER_CREATE', async (p) => { + const ctx = extractContext(p); + await auditLog.logApiActivity({ + activity: OCSF_API_ACTIVITY.CREATE, + ...ctx, + success: true, + operation: 'createPaymentProvider', + message: `Payment provider created: ${entityLabel(p.paymentProvider)}`, + }); + }); + + sub('PAYMENT_PROVIDER_UPDATE', async (p) => { + const ctx = extractContext(p); + await auditLog.logApiActivity({ + activity: OCSF_API_ACTIVITY.UPDATE, + ...ctx, + success: true, + operation: 'updatePaymentProvider', + message: `Payment provider updated: ${entityLabel(p.paymentProvider, p.paymentProviderId)}`, + }); + }); + + sub('PAYMENT_PROVIDER_REMOVE', async (p) => { + const ctx = extractContext(p); + await auditLog.logApiActivity({ + activity: OCSF_API_ACTIVITY.DELETE, + ...ctx, + success: true, + operation: 'removePaymentProvider', + message: `Payment provider removed: ${entityLabel(p.paymentProvider, p.paymentProviderId)}`, + }); + }); + + sub('DELIVERY_PROVIDER_CREATE', async (p) => { + const ctx = extractContext(p); + await auditLog.logApiActivity({ + activity: OCSF_API_ACTIVITY.CREATE, + ...ctx, + success: true, + operation: 'createDeliveryProvider', + message: `Delivery provider created: ${entityLabel(p.deliveryProvider)}`, + }); + }); + + sub('DELIVERY_PROVIDER_UPDATE', async (p) => { + const ctx = extractContext(p); + await auditLog.logApiActivity({ + activity: OCSF_API_ACTIVITY.UPDATE, + ...ctx, + success: true, + operation: 'updateDeliveryProvider', + message: `Delivery provider updated: ${entityLabel(p.deliveryProvider, p.deliveryProviderId)}`, + }); + }); + + sub('DELIVERY_PROVIDER_REMOVE', async (p) => { + const ctx = extractContext(p); + await auditLog.logApiActivity({ + activity: OCSF_API_ACTIVITY.DELETE, + ...ctx, + success: true, + operation: 'removeDeliveryProvider', + message: `Delivery provider removed: ${entityLabel(p.deliveryProvider, p.deliveryProviderId)}`, + }); + }); + + sub('WAREHOUSING_PROVIDER_CREATE', async (p) => { + const ctx = extractContext(p); + await auditLog.logApiActivity({ + activity: OCSF_API_ACTIVITY.CREATE, + ...ctx, + success: true, + operation: 'createWarehousingProvider', + message: `Warehousing provider created: ${entityLabel(p.warehousingProvider)}`, + }); + }); + + sub('WAREHOUSING_PROVIDER_UPDATE', async (p) => { + const ctx = extractContext(p); + await auditLog.logApiActivity({ + activity: OCSF_API_ACTIVITY.UPDATE, + ...ctx, + success: true, + operation: 'updateWarehousingProvider', + message: `Warehousing provider updated: ${entityLabel(p.warehousingProvider, p.warehousingProviderId)}`, + }); + }); + + sub('WAREHOUSING_PROVIDER_REMOVE', async (p) => { + const ctx = extractContext(p); + await auditLog.logApiActivity({ + activity: OCSF_API_ACTIVITY.DELETE, + ...ctx, + success: true, + operation: 'removeWarehousingProvider', + message: `Warehousing provider removed: ${entityLabel(p.warehousingProvider, p.warehousingProviderId)}`, + }); + }); + + // Tokens + sub('TOKEN_OWNERSHIP_CHANGED', async (p) => { + const ctx = extractContext(p); + await auditLog.logApiActivity({ + activity: OCSF_API_ACTIVITY.UPDATE, + ...ctx, + success: true, + operation: 'transferToken', + message: `Token ownership changed: ${entityLabel(p.token, p.tokenId)}`, + }); + }); + + sub('TOKEN_INVALIDATED', async (p) => { + const ctx = extractContext(p); + await auditLog.logApiActivity({ + activity: OCSF_API_ACTIVITY.DELETE, + ...ctx, + success: true, + operation: 'invalidateToken', + message: `Token invalidated: ${entityLabel(p.token, p.tokenId)}`, + }); + }); + + // Enrollments / Subscriptions + sub('ENROLLMENT_CREATE', async (p) => { + const ctx = extractContext(p); + await auditLog.logApiActivity({ + activity: OCSF_API_ACTIVITY.CREATE, + ...ctx, + success: true, + operation: 'createEnrollment', + message: `Enrollment created: ${entityLabel(p.enrollment)}`, + }); + }); + + sub('ENROLLMENT_UPDATE', async (p) => { + const ctx = extractContext(p); + await auditLog.logApiActivity({ + activity: OCSF_API_ACTIVITY.UPDATE, + ...ctx, + success: true, + operation: 'updateEnrollment', + message: `Enrollment updated: ${entityLabel(p.enrollment, p.enrollmentId)}`, + }); + }); + + sub('ENROLLMENT_REMOVE', async (p) => { + const ctx = extractContext(p); + await auditLog.logApiActivity({ + activity: OCSF_API_ACTIVITY.DELETE, + ...ctx, + success: true, + operation: 'removeEnrollment', + message: `Enrollment removed: ${entityLabel(p.enrollment, p.enrollmentId)}`, + }); + }); + + // Quotations + sub('QUOTATION_REQUEST_CREATE', async (p) => { + const ctx = extractContext(p); + await auditLog.logApiActivity({ + activity: OCSF_API_ACTIVITY.CREATE, + ...ctx, + success: true, + operation: 'createQuotation', + message: `Quotation created: ${entityLabel(p.quotation)}`, + }); + }); + + sub('QUOTATION_UPDATE', async (p) => { + const ctx = extractContext(p); + await auditLog.logApiActivity({ + activity: OCSF_API_ACTIVITY.UPDATE, + ...ctx, + success: true, + operation: 'updateQuotation', + message: `Quotation updated: ${entityLabel(p.quotation, p.quotationId)}`, + }); + }); + + // Assortments / Merchandising + sub('ASSORTMENT_CREATE', async (p) => { + const ctx = extractContext(p); + await auditLog.logApiActivity({ + activity: OCSF_API_ACTIVITY.CREATE, + ...ctx, + success: true, + operation: 'createAssortment', + message: `Assortment created: ${entityLabel(p.assortment)}`, + }); + }); + + sub('ASSORTMENT_UPDATE', async (p) => { + const ctx = extractContext(p); + await auditLog.logApiActivity({ + activity: OCSF_API_ACTIVITY.UPDATE, + ...ctx, + success: true, + operation: 'updateAssortment', + message: `Assortment updated: ${entityLabel(p.assortment, p.assortmentId)}`, + }); + }); + + sub('ASSORTMENT_REMOVE', async (p) => { + const ctx = extractContext(p); + await auditLog.logApiActivity({ + activity: OCSF_API_ACTIVITY.DELETE, + ...ctx, + success: true, + operation: 'removeAssortment', + message: `Assortment removed: ${entityLabel(p.assortment, p.assortmentId)}`, + }); + }); + + // Filters + sub('FILTER_CREATE', async (p) => { + const ctx = extractContext(p); + await auditLog.logApiActivity({ + activity: OCSF_API_ACTIVITY.CREATE, + ...ctx, + success: true, + operation: 'createFilter', + message: `Filter created: ${entityLabel(p.filter)}`, + }); + }); + + sub('FILTER_UPDATE', async (p) => { + const ctx = extractContext(p); + await auditLog.logApiActivity({ + activity: OCSF_API_ACTIVITY.UPDATE, + ...ctx, + success: true, + operation: 'updateFilter', + message: `Filter updated: ${entityLabel(p.filter, p.filterId)}`, + }); + }); + + sub('FILTER_REMOVE', async (p) => { + const ctx = extractContext(p); + await auditLog.logApiActivity({ + activity: OCSF_API_ACTIVITY.DELETE, + ...ctx, + success: true, + operation: 'removeFilter', + message: `Filter removed: ${entityLabel(p.filter, p.filterId)}`, + }); + }); + + // Files + sub('FILE_CREATE', async (p) => { + const ctx = extractContext(p); + await auditLog.logApiActivity({ + activity: OCSF_API_ACTIVITY.CREATE, + ...ctx, + success: true, + operation: 'createFile', + message: `File created: ${entityLabel(p.file, p.fileId)}`, + }); + }); + + sub('FILE_REMOVE', async (p) => { + const ctx = extractContext(p); + await auditLog.logApiActivity({ + activity: OCSF_API_ACTIVITY.DELETE, + ...ctx, + success: true, + operation: 'removeFile', + message: `File removed: ${entityLabel(p.file, p.fileId)}`, + }); + }); + + // System configuration + sub('COUNTRY_CREATE', async (p) => { + const ctx = extractContext(p); + await auditLog.logApiActivity({ + activity: OCSF_API_ACTIVITY.CREATE, + ...ctx, + success: true, + operation: 'createCountry', + message: `Country created: ${entityLabel(p.country, p.countryId)}`, + }); + }); + + sub('COUNTRY_UPDATE', async (p) => { + const ctx = extractContext(p); + await auditLog.logApiActivity({ + activity: OCSF_API_ACTIVITY.UPDATE, + ...ctx, + success: true, + operation: 'updateCountry', + message: `Country updated: ${entityLabel(p.country, p.countryId)}`, + }); + }); + + sub('COUNTRY_REMOVE', async (p) => { + const ctx = extractContext(p); + await auditLog.logApiActivity({ + activity: OCSF_API_ACTIVITY.DELETE, + ...ctx, + success: true, + operation: 'removeCountry', + message: `Country removed: ${entityLabel(p.country, p.countryId)}`, + }); + }); + + sub('CURRENCY_CREATE', async (p) => { + const ctx = extractContext(p); + await auditLog.logApiActivity({ + activity: OCSF_API_ACTIVITY.CREATE, + ...ctx, + success: true, + operation: 'createCurrency', + message: `Currency created: ${entityLabel(p.currency, p.currencyId)}`, + }); + }); + + sub('CURRENCY_UPDATE', async (p) => { + const ctx = extractContext(p); + await auditLog.logApiActivity({ + activity: OCSF_API_ACTIVITY.UPDATE, + ...ctx, + success: true, + operation: 'updateCurrency', + message: `Currency updated: ${entityLabel(p.currency, p.currencyId)}`, + }); + }); + + sub('CURRENCY_REMOVE', async (p) => { + const ctx = extractContext(p); + await auditLog.logApiActivity({ + activity: OCSF_API_ACTIVITY.DELETE, + ...ctx, + success: true, + operation: 'removeCurrency', + message: `Currency removed: ${entityLabel(p.currency, p.currencyId)}`, + }); + }); + + sub('LANGUAGE_CREATE', async (p) => { + const ctx = extractContext(p); + await auditLog.logApiActivity({ + activity: OCSF_API_ACTIVITY.CREATE, + ...ctx, + success: true, + operation: 'createLanguage', + message: `Language created: ${entityLabel(p.language, p.languageId)}`, + }); + }); + + sub('LANGUAGE_UPDATE', async (p) => { + const ctx = extractContext(p); + await auditLog.logApiActivity({ + activity: OCSF_API_ACTIVITY.UPDATE, + ...ctx, + success: true, + operation: 'updateLanguage', + message: `Language updated: ${entityLabel(p.language, p.languageId)}`, + }); + }); + + sub('LANGUAGE_REMOVE', async (p) => { + const ctx = extractContext(p); + await auditLog.logApiActivity({ + activity: OCSF_API_ACTIVITY.DELETE, + ...ctx, + success: true, + operation: 'removeLanguage', + message: `Language removed: ${entityLabel(p.language, p.languageId)}`, + }); + }); } /** @@ -229,6 +777,7 @@ export function configureAuditIntegration(auditLog: AuditLog): void { export const AUDITED_EVENTS = [ // Authentication 'API_LOGIN_TOKEN_CREATED', + 'API_LOGIN_FAILED', 'API_LOGOUT', // User account @@ -251,4 +800,71 @@ export const AUDITED_EVENTS = [ // Payments 'ORDER_PAY', 'ORDER_UPDATE_PAYMENT', + + // Order lifecycle (extended) + 'ORDER_DELIVER', + 'ORDER_REMOVE', + 'ORDER_EMPTY_CART', + 'ORDER_SET_PAYMENT_PROVIDER', + 'ORDER_SET_DELIVERY_PROVIDER', + + // Access control + 'ACL_DENIED', + 'ACL_GRANTED_SENSITIVE', + + // Products + 'PRODUCT_CREATE', + 'PRODUCT_UPDATE', + 'PRODUCT_REMOVE', + 'PRODUCT_PUBLISH', + 'PRODUCT_UNPUBLISH', + + // Provider configuration + 'PAYMENT_PROVIDER_CREATE', + 'PAYMENT_PROVIDER_UPDATE', + 'PAYMENT_PROVIDER_REMOVE', + 'DELIVERY_PROVIDER_CREATE', + 'DELIVERY_PROVIDER_UPDATE', + 'DELIVERY_PROVIDER_REMOVE', + 'WAREHOUSING_PROVIDER_CREATE', + 'WAREHOUSING_PROVIDER_UPDATE', + 'WAREHOUSING_PROVIDER_REMOVE', + + // Tokens + 'TOKEN_OWNERSHIP_CHANGED', + 'TOKEN_INVALIDATED', + + // Enrollments + 'ENROLLMENT_CREATE', + 'ENROLLMENT_UPDATE', + 'ENROLLMENT_REMOVE', + + // Quotations + 'QUOTATION_REQUEST_CREATE', + 'QUOTATION_UPDATE', + + // Assortments + 'ASSORTMENT_CREATE', + 'ASSORTMENT_UPDATE', + 'ASSORTMENT_REMOVE', + + // Filters + 'FILTER_CREATE', + 'FILTER_UPDATE', + 'FILTER_REMOVE', + + // Files + 'FILE_CREATE', + 'FILE_REMOVE', + + // System configuration + 'COUNTRY_CREATE', + 'COUNTRY_UPDATE', + 'COUNTRY_REMOVE', + 'CURRENCY_CREATE', + 'CURRENCY_UPDATE', + 'CURRENCY_REMOVE', + 'LANGUAGE_CREATE', + 'LANGUAGE_UPDATE', + 'LANGUAGE_REMOVE', ] as const; diff --git a/packages/events/src/audit/request-context.ts b/packages/events/src/audit/request-context.ts new file mode 100644 index 0000000000..f54db75a47 --- /dev/null +++ b/packages/events/src/audit/request-context.ts @@ -0,0 +1,18 @@ +import { AsyncLocalStorage } from 'node:async_hooks'; + +export interface AuditRequestContext { + userId?: string; + userName?: string; + remoteAddress?: string; + sessionId?: string; +} + +const asyncLocalStorage = new AsyncLocalStorage(); + +export function runWithAuditContext(context: AuditRequestContext, fn: () => T): T { + return asyncLocalStorage.run(context, fn); +} + +export function getAuditContext(): AuditRequestContext | undefined { + return asyncLocalStorage.getStore(); +} diff --git a/packages/events/src/events-index.ts b/packages/events/src/events-index.ts index fb0f5fa3d9..1aea2549b8 100644 --- a/packages/events/src/events-index.ts +++ b/packages/events/src/events-index.ts @@ -63,3 +63,10 @@ export { // Audit log integration with event system export { configureAuditIntegration, AUDITED_EVENTS } from './audit/audit-integration.ts'; + +// Request context for threading user info through to audit events +export { + runWithAuditContext, + getAuditContext, + type AuditRequestContext, +} from './audit/request-context.ts'; diff --git a/packages/platform/src/startPlatform.ts b/packages/platform/src/startPlatform.ts index cca8ae2457..93ed8ffb65 100644 --- a/packages/platform/src/startPlatform.ts +++ b/packages/platform/src/startPlatform.ts @@ -4,6 +4,12 @@ import { initDb, mongodb, stopDb } from '@unchainedshop/mongodb'; import { defaultLogger } from '@unchainedshop/logger'; import { getEmitAdapter } from '@unchainedshop/events'; import type { UnchainedCore } from '@unchainedshop/core'; +import { + createAuditLog, + configureAuditIntegration, + type AuditLogConfig, + type AuditLog, +} from '@unchainedshop/events'; import { setupAccounts } from './setup/setupAccounts.ts'; import { setupUploadHandlers } from './setup/setupUploadHandlers.ts'; import { setupTemplates, MessageTypes } from './setup/setupTemplates.ts'; @@ -18,8 +24,9 @@ export { MessageTypes }; export type PlatformOptions = { rolesOptions?: IRoleOptionConfig; workQueueOptions?: SetupWorkqueueOptions; + auditLog?: AuditLogConfig | false; } & Omit & - Omit; + Omit; const REQUIRED_ENV_VARIABLES = [ 'EMAIL_WEBSITE_NAME', @@ -56,6 +63,7 @@ export const startPlatform = async ({ bulkImporter, bulkExporter, workQueueOptions, + auditLog: auditLogConfig, ...arbitraryAPIServerConfiguration }: PlatformOptions): Promise<{ unchainedAPI: UnchainedCore; @@ -108,6 +116,12 @@ export const startPlatform = async ({ // Initialize plugins (call onRegister hooks) await pluginRegistry.initialize(unchainedAPI); + // Create audit log instance (integration configured after events are registered) + let auditLog: AuditLog | undefined; + if (auditLogConfig !== false) { + auditLog = createAuditLog(auditLogConfig || undefined); + } + // Setup Accounts specific extensions and event handlers setupAccounts(unchainedAPI); @@ -117,13 +131,19 @@ export const startPlatform = async ({ // Setup File Upload Handlers setupUploadHandlers(unchainedAPI); - // Start GraphQL Server + // Start GraphQL Server (registers API events) const graphqlHandler = await startAPIServer({ unchainedAPI, roles: configuredRoles, + auditLog, ...arbitraryAPIServerConfiguration, }); + // Configure audit integration after all events are registered + if (auditLog) { + configureAuditIntegration(auditLog); + } + // Setup Work Queue await setupWorkqueue({ unchainedAPI, @@ -169,6 +189,11 @@ export const startPlatform = async ({ defaultLogger.debug('Stopping GraphQL server', { signal }); await graphqlHandler.dispose(); + if (auditLog) { + defaultLogger.debug('Closing audit log', { signal }); + await auditLog.close(); + } + defaultLogger.debug('Stopping DB Connection', { signal }); await stopDb(); diff --git a/tests/audit-log.test.js b/tests/audit-log.test.js new file mode 100644 index 0000000000..1b2d272b5c --- /dev/null +++ b/tests/audit-log.test.js @@ -0,0 +1,179 @@ +import { + setupDatabase, + createLoggedInGraphqlFetch, + createAnonymousGraphqlFetch, + disconnect, +} from './helpers.js'; +import { ADMIN_TOKEN, USER_TOKEN } from './seeds/users.js'; +import assert from 'node:assert'; +import test from 'node:test'; + +let graphqlFetchAsAdminUser; +let graphqlFetchAsAnonymousUser; +let graphqlFetchAsNormalUser; + +test.describe('Audit Log', () => { + test.before(async () => { + await setupDatabase(); + graphqlFetchAsAdminUser = createLoggedInGraphqlFetch(ADMIN_TOKEN); + graphqlFetchAsNormalUser = createLoggedInGraphqlFetch(USER_TOKEN); + graphqlFetchAsAnonymousUser = createAnonymousGraphqlFetch(); + }); + + test.after(async () => { + await disconnect(); + }); + + test.describe('Query.auditLogs', () => { + test('admin can query audit logs', async () => { + const { data, errors } = await graphqlFetchAsAdminUser({ + query: /* GraphQL */ ` + query { + auditLogs(limit: 10) { + id + time + message + classUid + className + activityId + typeUid + severityId + statusId + sequenceNumber + } + } + `, + }); + assert.ok(!errors); + assert.ok(Array.isArray(data.auditLogs)); + }); + + test('anonymous user cannot query audit logs', async () => { + const { errors } = await graphqlFetchAsAnonymousUser({ + query: /* GraphQL */ ` + query { + auditLogs(limit: 10) { + id + } + } + `, + }); + assert.ok(errors?.length > 0); + }); + + test('normal user cannot query audit logs', async () => { + const { errors } = await graphqlFetchAsNormalUser({ + query: /* GraphQL */ ` + query { + auditLogs(limit: 10) { + id + } + } + `, + }); + assert.ok(errors?.length > 0); + }); + }); + + test.describe('Query.auditLogsCount', () => { + test('admin can query audit logs count', async () => { + const { data, errors } = await graphqlFetchAsAdminUser({ + query: /* GraphQL */ ` + query { + auditLogsCount + } + `, + }); + assert.ok(!errors); + assert.strictEqual(typeof data.auditLogsCount, 'number'); + }); + }); + + test.describe('Query.auditChainStatus', () => { + test('admin can verify chain integrity', async () => { + const { data, errors } = await graphqlFetchAsAdminUser({ + query: /* GraphQL */ ` + query { + auditChainStatus { + valid + totalEntries + checkedEntries + errors { + sequenceNumber + message + } + } + } + `, + }); + assert.ok(!errors); + assert.strictEqual(data.auditChainStatus.valid, true); + assert.ok(Array.isArray(data.auditChainStatus.errors)); + }); + + test('anonymous user cannot verify chain', async () => { + const { errors } = await graphqlFetchAsAnonymousUser({ + query: /* GraphQL */ ` + query { + auditChainStatus { + valid + } + } + `, + }); + assert.ok(errors?.length > 0); + }); + }); + + test.describe('Query.failedLoginAttempts', () => { + test('admin can query failed login attempts', async () => { + const { data, errors } = await graphqlFetchAsAdminUser({ + query: /* GraphQL */ ` + query { + failedLoginAttempts + } + `, + }); + assert.ok(!errors); + assert.strictEqual(typeof data.failedLoginAttempts, 'number'); + }); + }); + + test.describe('Login emits audit entry', () => { + test('login produces an authentication audit log entry', async () => { + // Trigger a login + await graphqlFetchAsAnonymousUser({ + query: /* GraphQL */ ` + mutation { + loginWithPassword(username: "admin", plainPassword: "password") { + id + token + tokenExpires + } + } + `, + }); + + // Small delay to let the event propagate to the audit log + await new Promise((resolve) => setTimeout(resolve, 200)); + + const { data } = await graphqlFetchAsAdminUser({ + query: /* GraphQL */ ` + query { + auditLogs(classUids: [3002], limit: 5) { + id + classUid + className + activityId + message + } + } + `, + }); + + assert.ok(data.auditLogs.length > 0); + const authEntry = data.auditLogs.find((e) => e.className === 'AUTHENTICATION'); + assert.ok(authEntry, 'Should have an AUTHENTICATION audit entry after login'); + }); + }); +}); From 919388934af829d18ea6ef5c6ad7359a47bd112e Mon Sep 17 00:00:00 2001 From: Mikael Araya Date: Mon, 8 Jun 2026 19:06:55 +0300 Subject: [PATCH 02/10] Adjust admin ui audit log display and collected data in module --- .gitignore | 2 +- .../src/modules/apollo/utils/typepolicies.ts | 1 + .../audit/components/AuditEntryDetail.tsx | 37 +- .../audit/components/AuditLogFilters.tsx | 280 +++- .../audit/components/AuditLogTable.tsx | 29 +- .../audit/components/FailedLoginsWidget.tsx | 11 +- .../modules/audit/components/ocsf-labels.ts | 52 + .../src/modules/audit/hooks/useAuditLogs.ts | 43 +- .../common/components/InfiniteScroll.tsx | 37 +- .../src/modules/common/components/Layout.tsx | 15 +- admin-ui/src/pages/settings/security.tsx | 103 +- packages/api/src/express/index.ts | 27 +- packages/api/src/fastify/index.ts | 25 +- .../src/resolvers/queries/audit/auditLogs.ts | 74 +- .../resolvers/queries/audit/auditLogsCount.ts | 2 + packages/api/src/schema/query.ts | 2 + .../events/src/audit/audit-integration.ts | 1228 ++++++++--------- packages/events/src/audit/index.ts | 83 +- tests/audit-compliance.test.js | 78 +- tests/audit-log.test.js | 8 +- 20 files changed, 1293 insertions(+), 844 deletions(-) diff --git a/.gitignore b/.gitignore index a184574681..9e0d193d8e 100644 --- a/.gitignore +++ b/.gitignore @@ -18,4 +18,4 @@ fly.toml .db *.db dist -audit-log \ No newline at end of file +audit-logs \ No newline at end of file diff --git a/admin-ui/src/modules/apollo/utils/typepolicies.ts b/admin-ui/src/modules/apollo/utils/typepolicies.ts index cd93f5a7e7..ee56b4992e 100644 --- a/admin-ui/src/modules/apollo/utils/typepolicies.ts +++ b/admin-ui/src/modules/apollo/utils/typepolicies.ts @@ -119,6 +119,7 @@ const keyMappings: any = { 'success', 'from', 'until', + 'queryText', 'limit', ]), }, diff --git a/admin-ui/src/modules/audit/components/AuditEntryDetail.tsx b/admin-ui/src/modules/audit/components/AuditEntryDetail.tsx index 24837f04e7..0a1f3a5b37 100644 --- a/admin-ui/src/modules/audit/components/AuditEntryDetail.tsx +++ b/admin-ui/src/modules/audit/components/AuditEntryDetail.tsx @@ -1,5 +1,10 @@ import { useIntl } from 'react-intl'; -import { CLASS_LABELS, SEVERITY_LABELS, STATUS_LABELS } from './ocsf-labels'; +import { + CLASS_LABELS, + SEVERITY_LABELS, + STATUS_LABELS, + getActivityName, +} from './ocsf-labels'; import { XMarkIcon } from '@heroicons/react/24/outline'; const AuditEntryDetail = ({ @@ -13,6 +18,8 @@ const AuditEntryDetail = ({ if (!entry) return null; + const changedData = entry.raw?.unmapped?.data; + const sections = [ { label: formatMessage({ @@ -21,8 +28,8 @@ const AuditEntryDetail = ({ }), rows: [ ['Class', CLASS_LABELS[entry.className] || entry.className], + ['Activity', getActivityName(entry.classUid, entry.activityId)], ['Type UID', entry.typeUid], - ['Activity ID', entry.activityId], ['Severity', SEVERITY_LABELS[entry.severityId] || entry.severityId], ['Status', STATUS_LABELS[entry.statusId] || entry.statusId], entry.statusDetail && ['Status Detail', entry.statusDetail], @@ -124,6 +131,32 @@ const AuditEntryDetail = ({
))} + + {changedData && ( +
+

+ {formatMessage({ + id: 'audit_detail_changed_data', + defaultMessage: 'Changed Data', + })} +

+
+ {Object.entries(changedData).map(([field, value]) => ( +
+

+ {field} +

+
+                    {typeof value === 'object'
+                      ? JSON.stringify(value, null, 2)
+                      : String(value)}
+                  
+
+ ))} +
+
+ )} +

{formatMessage({ diff --git a/admin-ui/src/modules/audit/components/AuditLogFilters.tsx b/admin-ui/src/modules/audit/components/AuditLogFilters.tsx index e54005b61e..fb59744616 100644 --- a/admin-ui/src/modules/audit/components/AuditLogFilters.tsx +++ b/admin-ui/src/modules/audit/components/AuditLogFilters.tsx @@ -1,5 +1,10 @@ import { useIntl } from 'react-intl'; import { useRouter } from 'next/router'; +import StatusFilter from '../../common/components/StatusFilter'; +import DateInputField from '@/components/ui/DateInput'; +import useFormatDateTime from '../../common/utils/useFormatDateTime'; +import { normalizeQuery } from '../../common/utils/utils'; +import deBounce from '../../common/utils/deBounce'; const CLASS_OPTIONS = [ { value: '3002', label: 'Authentication' }, @@ -7,74 +12,241 @@ const CLASS_OPTIONS = [ { value: '6003', label: 'API Activity' }, ]; -const STATUS_OPTIONS = [ - { value: 'true', label: 'Success' }, - { value: 'false', label: 'Failure' }, -]; +const STATUS_OPTIONS = ['Success', 'Failure']; + +const debouncedPush = deBounce(300); const AuditLogFilters = () => { const { formatMessage } = useIntl(); + const { parseDate } = useFormatDateTime(); const { query, push } = useRouter(); - const updateFilter = (key: string, value: string | null) => { - const newQuery = { ...query }; - if (value) { - newQuery[key] = value; + const selectedStatuses = (() => { + if (query.success === 'true') return ['Success']; + if (query.success === 'false') return ['Failure']; + return []; + })(); + + const onStatusChange = (statuses: string[]) => { + const { success, ...rest } = query; + if (statuses.length === 0 || statuses.length === 2) { + push({ query: rest }, undefined, { shallow: true }); + } else if (statuses.includes('Success')) { + push({ query: { ...rest, success: 'true' } }, undefined, { + shallow: true, + }); + } else { + push({ query: { ...rest, success: 'false' } }, undefined, { + shallow: true, + }); + } + }; + + const appliedClassLabels = ((query.classUids as string) || '') + .split(',') + .filter(Boolean) + .map((uid) => CLASS_OPTIONS.find((o) => o.value === uid)?.label) + .filter(Boolean) as string[]; + + const onClassChange = (labels: string[]) => { + const { classUids, ...rest } = query; + if (labels.length === 0) { + push({ query: rest }, undefined, { shallow: true }); } else { - delete newQuery[key]; + const uids = labels + .map((l) => CLASS_OPTIONS.find((o) => o.label === l)?.value) + .filter(Boolean) + .join(','); + push({ query: { ...rest, classUids: uids } }, undefined, { + shallow: true, + }); } - delete newQuery.offset; - push({ query: newQuery }, undefined, { shallow: true }); }; return ( -
- +
+
+ + { + if (value) { + push({ + query: normalizeQuery( + query, + new Date(value).toISOString(), + 'from', + ), + }); + } else { + const { from, ...rest } = query; + push({ query: { ...rest } }); + } + }} + placeholder={formatMessage({ + id: 'start_date', + defaultMessage: 'Start date', + })} + value={query?.from ? parseDate(query?.from) : null} + containerClassName="w-full" + /> + + { + if (value) { + push({ + query: normalizeQuery( + query, + new Date(value).toISOString(), + 'until', + ), + }); + } else { + const { until, ...rest } = query; + push({ query: { ...rest } }); + } + }} + placeholder={formatMessage({ + id: 'end_date', + defaultMessage: 'End Date', + })} + value={ + query?.until ? parseDate(query?.until) : parseDate(new Date()) + } + containerClassName="w-full" + /> +
+

- +
+
+
+ {formatMessage({ id: 'class', defaultMessage: 'Class' })} +
+
+ o.label)} + /> +
+
+ +
+
+ {formatMessage({ id: 'status', defaultMessage: 'Status' })} +
+
+ +
+
+
+ +
+
+ +
+
+ +
+ { + const value = e.target.value; + debouncedPush(() => { + const { queryText, ...rest } = query; + if (value) { + push({ query: { ...rest, queryText: value } }, undefined, { + shallow: true, + }); + } else { + push({ query: rest }, undefined, { shallow: true }); + } + }); + }} + /> +
+
- updateFilter('userId', e.target.value || null)} - /> +
+ +
+ { + const { userId, ...rest } = query; + if (e.target.value) { + push( + { query: { ...rest, userId: e.target.value } }, + undefined, + { shallow: true }, + ); + } else { + push({ query: rest }, undefined, { shallow: true }); + } + }} + /> +
+
+
); }; diff --git a/admin-ui/src/modules/audit/components/AuditLogTable.tsx b/admin-ui/src/modules/audit/components/AuditLogTable.tsx index 8b3f3136a2..6dc6a9874d 100644 --- a/admin-ui/src/modules/audit/components/AuditLogTable.tsx +++ b/admin-ui/src/modules/audit/components/AuditLogTable.tsx @@ -1,5 +1,11 @@ +import Link from 'next/link'; import { useIntl } from 'react-intl'; -import { CLASS_LABELS, STATUS_LABELS, CLASS_COLORS } from './ocsf-labels'; +import { + CLASS_LABELS, + STATUS_LABELS, + CLASS_COLORS, + getActivityName, +} from './ocsf-labels'; const AuditLogTable = ({ entries, @@ -32,6 +38,12 @@ const AuditLogTable = ({ {formatMessage({ id: 'audit_class', defaultMessage: 'Class' })} + + {formatMessage({ + id: 'audit_activity', + defaultMessage: 'Activity', + })} + {formatMessage({ id: 'audit_message', @@ -90,11 +102,24 @@ const AuditLogTable = ({ {CLASS_LABELS[entry.className] || entry.className} + + {getActivityName(entry.classUid, entry.activityId)} + {entry.message || '—'} - {userName} + {entry.actor?.user?.uid ? ( + e.stopPropagation()} + > + {userName} + + ) : ( + userName + )} { const { formatMessage } = useIntl(); - const now = Date.now(); - const oneDayAgo = now - 24 * 60 * 60 * 1000; - const oneWeekAgo = now - 7 * 24 * 60 * 60 * 1000; + const { oneDayAgo, oneWeekAgo } = useMemo(() => { + const now = Date.now(); + return { + oneDayAgo: now - 24 * 60 * 60 * 1000, + oneWeekAgo: now - 7 * 24 * 60 * 60 * 1000, + }; + }, []); const { failedLoginAttempts: last24h, loading: loading24h } = useFailedLoginAttempts({ since: oneDayAgo }); diff --git a/admin-ui/src/modules/audit/components/ocsf-labels.ts b/admin-ui/src/modules/audit/components/ocsf-labels.ts index 236b2b4610..143ac18764 100644 --- a/admin-ui/src/modules/audit/components/ocsf-labels.ts +++ b/admin-ui/src/modules/audit/components/ocsf-labels.ts @@ -30,3 +30,55 @@ export const CLASS_COLORS: Record = { API_ACTIVITY: 'bg-amber-100 text-amber-800 dark:bg-amber-900 dark:text-amber-200', }; + +const AUTH_ACTIVITIES: Record = { + 0: 'Unknown', + 1: 'Logon', + 2: 'Logoff', + 3: 'Authentication Ticket', + 4: 'Service Ticket Request', + 5: 'Service Ticket Renew', + 6: 'Pre-Auth', + 99: 'Other', +}; + +const ACCOUNT_ACTIVITIES: Record = { + 0: 'Unknown', + 1: 'Create', + 2: 'Enable', + 3: 'Password Change', + 4: 'Password Reset', + 5: 'Disable', + 6: 'Delete', + 7: 'Attach Policy', + 8: 'Detach Policy', + 9: 'Lock', + 10: 'MFA Enable', + 11: 'MFA Disable', + 99: 'Other', +}; + +const API_ACTIVITIES: Record = { + 0: 'Unknown', + 1: 'Create', + 2: 'Read', + 3: 'Update', + 4: 'Delete', + 90: 'Checkout', + 91: 'Payment', + 92: 'Refund', + 93: 'Export', + 94: 'Import', + 95: 'Access Denied', + 99: 'Other', +}; + +const ACTIVITY_MAP: Record> = { + 3002: AUTH_ACTIVITIES, + 3001: ACCOUNT_ACTIVITIES, + 6003: API_ACTIVITIES, +}; + +export function getActivityName(classUid: number, activityId: number): string { + return ACTIVITY_MAP[classUid]?.[activityId] || `Activity ${activityId}`; +} diff --git a/admin-ui/src/modules/audit/hooks/useAuditLogs.ts b/admin-ui/src/modules/audit/hooks/useAuditLogs.ts index 58de66ee80..c8b04a23c5 100644 --- a/admin-ui/src/modules/audit/hooks/useAuditLogs.ts +++ b/admin-ui/src/modules/audit/hooks/useAuditLogs.ts @@ -11,6 +11,7 @@ const AuditLogsQuery = gql` $success: Boolean $from: Timestamp $until: Timestamp + $queryText: String ) { auditLogs( limit: $limit @@ -20,6 +21,7 @@ const AuditLogsQuery = gql` success: $success from: $from until: $until + queryText: $queryText ) { ...AuditLogEntryFragment } @@ -29,6 +31,7 @@ const AuditLogsQuery = gql` success: $success from: $from until: $until + queryText: $queryText ) } ${AuditLogEntryFragment} @@ -42,6 +45,7 @@ const useAuditLogs = ({ success = null, from = null, until = null, + queryText = null, }: { limit?: number; offset?: number; @@ -50,25 +54,46 @@ const useAuditLogs = ({ success?: boolean | null; from?: number | null; until?: number | null; + queryText?: string | null; } = {}) => { - const { data, loading, error, fetchMore, previousData } = useQuery<{ - auditLogs: any[]; - auditLogsCount: number; - }>(AuditLogsQuery, { - variables: { limit, offset, classUids, userId, success, from, until }, - }); + const { data, loading, error, fetchMore, previousData, networkStatus } = + useQuery<{ + auditLogs: any[]; + auditLogsCount: number; + }>(AuditLogsQuery, { + variables: { + limit, + offset, + classUids, + userId, + success, + from, + until, + queryText, + }, + notifyOnNetworkStatusChange: true, + }); + const isFetchingMore = networkStatus === 3; const auditLogs = data?.auditLogs || previousData?.auditLogs || []; const auditLogsCount = data?.auditLogsCount ?? previousData?.auditLogsCount ?? 0; - const hasMore = auditLogs.length < auditLogsCount; + const hasMore = auditLogs.length > 0 && auditLogs.length < auditLogsCount; const loadMore = () => { - if (loading) return; + if (loading || isFetchingMore) return; fetchMore({ variables: { offset: auditLogs.length } }); }; - return { auditLogs, auditLogsCount, hasMore, loadMore, loading, error }; + return { + auditLogs, + auditLogsCount, + hasMore, + loadMore, + loading: loading && !isFetchingMore, + loadingMore: isFetchingMore, + error, + }; }; export default useAuditLogs; diff --git a/admin-ui/src/modules/common/components/InfiniteScroll.tsx b/admin-ui/src/modules/common/components/InfiniteScroll.tsx index dac34f3071..9d0faa4636 100644 --- a/admin-ui/src/modules/common/components/InfiniteScroll.tsx +++ b/admin-ui/src/modules/common/components/InfiniteScroll.tsx @@ -1,4 +1,4 @@ -import { useEffect, useRef, useCallback } from 'react'; +import { useEffect, useRef } from 'react'; import Loading from '@/components/ui/Loading'; interface InfiniteScrollProps { @@ -17,36 +17,35 @@ const InfiniteScroll = ({ threshold = 200, }: InfiniteScrollProps) => { const sentinelRef = useRef(null); + const loadingRef = useRef(loading); + const hasMoreRef = useRef(hasMore); + const onLoadMoreRef = useRef(onLoadMore); - const handleIntersect = useCallback( - (entries: IntersectionObserverEntry[]) => { - const [entry] = entries; - if (entry.isIntersecting && hasMore && !loading) { - onLoadMore(); - } - }, - [hasMore, loading, onLoadMore], - ); + loadingRef.current = loading; + hasMoreRef.current = hasMore; + onLoadMoreRef.current = onLoadMore; useEffect(() => { const sentinel = sentinelRef.current; if (!sentinel) return; - const observer = new IntersectionObserver(handleIntersect, { - rootMargin: `${threshold}px`, - }); + const observer = new IntersectionObserver( + ([entry]) => { + if (entry.isIntersecting && hasMoreRef.current && !loadingRef.current) { + onLoadMoreRef.current(); + } + }, + { rootMargin: `${threshold}px` }, + ); observer.observe(sentinel); - - return () => { - observer.disconnect(); - }; - }, [handleIntersect, threshold]); + return () => observer.disconnect(); + }, [threshold]); return ( <> {children} -
+ {hasMore &&
} {loading && (
diff --git a/admin-ui/src/modules/common/components/Layout.tsx b/admin-ui/src/modules/common/components/Layout.tsx index b93c00e5fe..68ca80cbcd 100644 --- a/admin-ui/src/modules/common/components/Layout.tsx +++ b/admin-ui/src/modules/common/components/Layout.tsx @@ -15,7 +15,6 @@ import { CubeIcon, DocumentTextIcon, FolderArrowDownIcon, - ShieldCheckIcon, } from '@heroicons/react/24/outline'; import Link from 'next/link'; import React, { useState } from 'react'; @@ -297,21 +296,15 @@ const Layout = ({ requiredRole: 'viewWarehousingProviders', href: '/warehousing-provider', }, - ].filter(Boolean), - }, - isSystemReady && { - name: formatMessage({ id: 'settings', defaultMessage: 'Settings' }), - icon: ShieldCheckIcon, - children: [ - { + isSystemReady && { name: formatMessage({ - id: 'security', - defaultMessage: 'Security', + id: 'audit_log', + defaultMessage: 'Audit Log', }), requiredRole: 'viewAuditLog', href: '/settings/security', }, - ], + ].filter(Boolean), }, { _sortOrder: 120, diff --git a/admin-ui/src/pages/settings/security.tsx b/admin-ui/src/pages/settings/security.tsx index 6cadad3452..1428aa4126 100644 --- a/admin-ui/src/pages/settings/security.tsx +++ b/admin-ui/src/pages/settings/security.tsx @@ -1,9 +1,10 @@ -import { useState } from 'react'; +import { useState, useCallback } from 'react'; import { useIntl } from 'react-intl'; import { useRouter } from 'next/router'; import BreadCrumbs from '@/components/ui/BreadCrumbs'; import PageHeader from '@/components/ui/PageHeader'; import Loading from '@/components/ui/Loading'; +import Button from '@/components/ui/Button'; import InfiniteScroll from '../../modules/common/components/InfiniteScroll'; import ChainStatusBanner from '../../modules/audit/components/ChainStatusBanner'; import FailedLoginsWidget from '../../modules/audit/components/FailedLoginsWidget'; @@ -17,21 +18,86 @@ const SecurityPage = () => { const { query } = useRouter(); const [selectedEntry, setSelectedEntry] = useState(null); - const classUids = query.classUid - ? [parseInt(query.classUid as string, 10)] + const classUids = query.classUids + ? (query.classUids as string).split(',').map((s) => parseInt(s, 10)) : null; const success = query.success === 'true' ? true : query.success === 'false' ? false : null; const userId = (query.userId as string) || null; + const queryText = (query.queryText as string) || null; + const from = query.from ? new Date(query.from as string).getTime() : null; + const until = query.until + ? new Date(query.until as string).getTime() + 86400000 + : null; - const { auditLogs, auditLogsCount, loading, hasMore, loadMore } = + const { auditLogs, auditLogsCount, loading, loadingMore, hasMore, loadMore } = useAuditLogs({ limit: 50, classUids, success, userId, + queryText, + from, + until, }); + const exportAuditLogs = useCallback( + (format: 'json' | 'csv') => { + if (!auditLogs.length) return; + + let content: string; + let mimeType: string; + let filename: string; + + if (format === 'json') { + content = JSON.stringify( + auditLogs.map((e) => e.raw), + null, + 2, + ); + mimeType = 'application/json'; + filename = `audit-log-${new Date().toISOString().slice(0, 10)}.json`; + } else { + const headers = [ + 'time', + 'class', + 'activity', + 'message', + 'user', + 'userId', + 'ip', + 'operation', + 'status', + ]; + const rows = auditLogs.map((entry) => [ + new Date(entry.time).toISOString(), + entry.className, + entry.activityId, + `"${(entry.message || '').replace(/"/g, '""')}"`, + entry.actor?.user?.name || entry.actor?.user?.emailAddr || '', + entry.actor?.user?.uid || '', + entry.srcEndpoint?.ip || '', + entry.api?.operation || '', + entry.statusId === 1 ? 'Success' : 'Failure', + ]); + content = [headers.join(','), ...rows.map((r) => r.join(','))].join( + '\n', + ); + mimeType = 'text/csv'; + filename = `audit-log-${new Date().toISOString().slice(0, 10)}.csv`; + } + + const blob = new Blob([content], { type: mimeType }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = filename; + a.click(); + URL.revokeObjectURL(url); + }, + [auditLogs], + ); + return ( <> @@ -44,7 +110,26 @@ const SecurityPage = () => { id: 'security_page_header', defaultMessage: 'Security & Audit Log', })} - /> + > +
diff --git a/admin-ui/src/modules/audit/components/AuditLogTable.tsx b/admin-ui/src/modules/audit/components/AuditLogTable.tsx index 6dc6a9874d..71bacc2580 100644 --- a/admin-ui/src/modules/audit/components/AuditLogTable.tsx +++ b/admin-ui/src/modules/audit/components/AuditLogTable.tsx @@ -1,20 +1,17 @@ import Link from 'next/link'; import { useIntl } from 'react-intl'; -import { - CLASS_LABELS, - STATUS_LABELS, - CLASS_COLORS, - getActivityName, -} from './ocsf-labels'; +import type { IAuditLogEntryFragment } from '@/gql/types'; +import { CLASS_COLORS, useOcsfLabels } from './ocsf-labels'; const AuditLogTable = ({ entries, onSelectEntry, }: { - entries: any[]; - onSelectEntry: (entry: any) => void; + entries: IAuditLogEntryFragment[]; + onSelectEntry: (entry: IAuditLogEntryFragment) => void; }) => { const { formatMessage, formatDate, formatTime } = useIntl(); + const { CLASS_LABELS, STATUS_LABELS, getActivityName } = useOcsfLabels(); if (!entries?.length) { return ( diff --git a/admin-ui/src/modules/audit/components/ChainStatusBanner.tsx b/admin-ui/src/modules/audit/components/ChainStatusBanner.tsx index 285bc37f2f..4f1b01664f 100644 --- a/admin-ui/src/modules/audit/components/ChainStatusBanner.tsx +++ b/admin-ui/src/modules/audit/components/ChainStatusBanner.tsx @@ -7,7 +7,23 @@ import { const ChainStatusBanner = () => { const { formatMessage } = useIntl(); - const { chainStatus, loading, refetch } = useAuditChainStatus(); + const { chainStatus, loading, error, refetch } = useAuditChainStatus(); + + if (error) { + return ( +
+
+ +

+ {formatMessage({ + id: 'audit_chain_error', + defaultMessage: 'Failed to load audit chain status', + })} +

+
+
+ ); + } if (loading || !chainStatus) return null; diff --git a/admin-ui/src/modules/audit/components/FailedLoginsWidget.tsx b/admin-ui/src/modules/audit/components/FailedLoginsWidget.tsx index 62014ca8d4..8a1dfa0b09 100644 --- a/admin-ui/src/modules/audit/components/FailedLoginsWidget.tsx +++ b/admin-ui/src/modules/audit/components/FailedLoginsWidget.tsx @@ -13,12 +13,13 @@ const FailedLoginsWidget = () => { }; }, []); - const { failedLoginAttempts: last24h, loading: loading24h } = + const { failedLoginAttempts: last24h, loading: loading24h, error: error24h } = useFailedLoginAttempts({ since: oneDayAgo }); - const { failedLoginAttempts: last7d, loading: loading7d } = + const { failedLoginAttempts: last7d, loading: loading7d, error: error7d } = useFailedLoginAttempts({ since: oneWeekAgo }); const loading = loading24h || loading7d; + const error = error24h || error7d; return (
@@ -31,7 +32,14 @@ const FailedLoginsWidget = () => { })}
- {loading ? ( + {error ? ( +

+ {formatMessage({ + id: 'failed_login_error', + defaultMessage: 'Failed to load login data', + })} +

+ ) : loading ? (
) : (
diff --git a/admin-ui/src/modules/audit/components/ocsf-labels.ts b/admin-ui/src/modules/audit/components/ocsf-labels.ts index 143ac18764..d54f91709d 100644 --- a/admin-ui/src/modules/audit/components/ocsf-labels.ts +++ b/admin-ui/src/modules/audit/components/ocsf-labels.ts @@ -1,26 +1,4 @@ -export const CLASS_LABELS: Record = { - ACCOUNT_CHANGE: 'Account Change', - AUTHENTICATION: 'Authentication', - API_ACTIVITY: 'API Activity', -}; - -export const SEVERITY_LABELS: Record = { - 0: 'Unknown', - 1: 'Informational', - 2: 'Low', - 3: 'Medium', - 4: 'High', - 5: 'Critical', - 6: 'Fatal', - 99: 'Other', -}; - -export const STATUS_LABELS: Record = { - 0: 'Unknown', - 1: 'Success', - 2: 'Failure', - 99: 'Other', -}; +import { useIntl } from 'react-intl'; export const CLASS_COLORS: Record = { AUTHENTICATION: @@ -31,54 +9,84 @@ export const CLASS_COLORS: Record = { 'bg-amber-100 text-amber-800 dark:bg-amber-900 dark:text-amber-200', }; -const AUTH_ACTIVITIES: Record = { - 0: 'Unknown', - 1: 'Logon', - 2: 'Logoff', - 3: 'Authentication Ticket', - 4: 'Service Ticket Request', - 5: 'Service Ticket Renew', - 6: 'Pre-Auth', - 99: 'Other', -}; +export function useOcsfLabels() { + const { formatMessage } = useIntl(); -const ACCOUNT_ACTIVITIES: Record = { - 0: 'Unknown', - 1: 'Create', - 2: 'Enable', - 3: 'Password Change', - 4: 'Password Reset', - 5: 'Disable', - 6: 'Delete', - 7: 'Attach Policy', - 8: 'Detach Policy', - 9: 'Lock', - 10: 'MFA Enable', - 11: 'MFA Disable', - 99: 'Other', -}; + const CLASS_LABELS: Record = { + ACCOUNT_CHANGE: formatMessage({ id: 'audit_class_account_change', defaultMessage: 'Account Change' }), + AUTHENTICATION: formatMessage({ id: 'audit_class_authentication', defaultMessage: 'Authentication' }), + API_ACTIVITY: formatMessage({ id: 'audit_class_api_activity', defaultMessage: 'API Activity' }), + }; -const API_ACTIVITIES: Record = { - 0: 'Unknown', - 1: 'Create', - 2: 'Read', - 3: 'Update', - 4: 'Delete', - 90: 'Checkout', - 91: 'Payment', - 92: 'Refund', - 93: 'Export', - 94: 'Import', - 95: 'Access Denied', - 99: 'Other', -}; + const SEVERITY_LABELS: Record = { + 0: formatMessage({ id: 'audit_severity_unknown', defaultMessage: 'Unknown' }), + 1: formatMessage({ id: 'audit_severity_informational', defaultMessage: 'Informational' }), + 2: formatMessage({ id: 'audit_severity_low', defaultMessage: 'Low' }), + 3: formatMessage({ id: 'audit_severity_medium', defaultMessage: 'Medium' }), + 4: formatMessage({ id: 'audit_severity_high', defaultMessage: 'High' }), + 5: formatMessage({ id: 'audit_severity_critical', defaultMessage: 'Critical' }), + 6: formatMessage({ id: 'audit_severity_fatal', defaultMessage: 'Fatal' }), + 99: formatMessage({ id: 'audit_severity_other', defaultMessage: 'Other' }), + }; -const ACTIVITY_MAP: Record> = { - 3002: AUTH_ACTIVITIES, - 3001: ACCOUNT_ACTIVITIES, - 6003: API_ACTIVITIES, -}; + const STATUS_LABELS: Record = { + 0: formatMessage({ id: 'audit_severity_unknown', defaultMessage: 'Unknown' }), + 1: formatMessage({ id: 'audit_status_success', defaultMessage: 'Success' }), + 2: formatMessage({ id: 'audit_status_failure', defaultMessage: 'Failure' }), + 99: formatMessage({ id: 'audit_severity_other', defaultMessage: 'Other' }), + }; + + const AUTH_ACTIVITIES: Record = { + 0: formatMessage({ id: 'audit_severity_unknown', defaultMessage: 'Unknown' }), + 1: formatMessage({ id: 'audit_activity_logon', defaultMessage: 'Logon' }), + 2: formatMessage({ id: 'audit_activity_logoff', defaultMessage: 'Logoff' }), + 3: formatMessage({ id: 'audit_activity_auth_ticket', defaultMessage: 'Authentication Ticket' }), + 4: formatMessage({ id: 'audit_activity_service_ticket_request', defaultMessage: 'Service Ticket Request' }), + 5: formatMessage({ id: 'audit_activity_service_ticket_renew', defaultMessage: 'Service Ticket Renew' }), + 6: formatMessage({ id: 'audit_activity_pre_auth', defaultMessage: 'Pre-Auth' }), + 99: formatMessage({ id: 'audit_severity_other', defaultMessage: 'Other' }), + }; + + const ACCOUNT_ACTIVITIES: Record = { + 0: formatMessage({ id: 'audit_severity_unknown', defaultMessage: 'Unknown' }), + 1: formatMessage({ id: 'audit_activity_create', defaultMessage: 'Create' }), + 2: formatMessage({ id: 'audit_activity_enable', defaultMessage: 'Enable' }), + 3: formatMessage({ id: 'audit_activity_password_change', defaultMessage: 'Password Change' }), + 4: formatMessage({ id: 'audit_activity_password_reset', defaultMessage: 'Password Reset' }), + 5: formatMessage({ id: 'audit_activity_disable', defaultMessage: 'Disable' }), + 6: formatMessage({ id: 'audit_activity_delete', defaultMessage: 'Delete' }), + 7: formatMessage({ id: 'audit_activity_attach_policy', defaultMessage: 'Attach Policy' }), + 8: formatMessage({ id: 'audit_activity_detach_policy', defaultMessage: 'Detach Policy' }), + 9: formatMessage({ id: 'audit_activity_lock', defaultMessage: 'Lock' }), + 10: formatMessage({ id: 'audit_activity_mfa_enable', defaultMessage: 'MFA Enable' }), + 11: formatMessage({ id: 'audit_activity_mfa_disable', defaultMessage: 'MFA Disable' }), + 99: formatMessage({ id: 'audit_severity_other', defaultMessage: 'Other' }), + }; + + const API_ACTIVITIES: Record = { + 0: formatMessage({ id: 'audit_severity_unknown', defaultMessage: 'Unknown' }), + 1: formatMessage({ id: 'audit_activity_create', defaultMessage: 'Create' }), + 2: formatMessage({ id: 'audit_activity_read', defaultMessage: 'Read' }), + 3: formatMessage({ id: 'audit_activity_update', defaultMessage: 'Update' }), + 4: formatMessage({ id: 'audit_activity_delete', defaultMessage: 'Delete' }), + 90: formatMessage({ id: 'audit_activity_checkout', defaultMessage: 'Checkout' }), + 91: formatMessage({ id: 'audit_activity_payment', defaultMessage: 'Payment' }), + 92: formatMessage({ id: 'audit_activity_refund', defaultMessage: 'Refund' }), + 93: formatMessage({ id: 'audit_activity_export', defaultMessage: 'Export' }), + 94: formatMessage({ id: 'audit_activity_import', defaultMessage: 'Import' }), + 95: formatMessage({ id: 'audit_activity_access_denied', defaultMessage: 'Access Denied' }), + 99: formatMessage({ id: 'audit_severity_other', defaultMessage: 'Other' }), + }; + + const ACTIVITY_MAP: Record> = { + 3002: AUTH_ACTIVITIES, + 3001: ACCOUNT_ACTIVITIES, + 6003: API_ACTIVITIES, + }; + + const getActivityName = (classUid: number, activityId: number): string => { + return ACTIVITY_MAP[classUid]?.[activityId] || `Activity ${activityId}`; + }; -export function getActivityName(classUid: number, activityId: number): string { - return ACTIVITY_MAP[classUid]?.[activityId] || `Activity ${activityId}`; + return { CLASS_LABELS, SEVERITY_LABELS, STATUS_LABELS, getActivityName }; } diff --git a/admin-ui/src/modules/audit/hooks/useAuditChainStatus.ts b/admin-ui/src/modules/audit/hooks/useAuditChainStatus.ts index f448dd31b1..44f3487f22 100644 --- a/admin-ui/src/modules/audit/hooks/useAuditChainStatus.ts +++ b/admin-ui/src/modules/audit/hooks/useAuditChainStatus.ts @@ -1,3 +1,7 @@ +import { + IAuditChainStatusQuery, + IAuditChainStatusQueryVariables, +} from '@/gql/types'; import { gql } from '@apollo/client'; import { useQuery } from '@apollo/client/react'; @@ -18,16 +22,10 @@ const AuditChainStatusQuery = gql` `; const useAuditChainStatus = () => { - const { data, loading, error, refetch } = useQuery<{ - auditChainStatus: { - valid: boolean; - totalEntries: number; - checkedEntries: number; - firstEntry: number | null; - lastEntry: number | null; - errors: { sequenceNumber: number; message: string }[]; - }; - }>(AuditChainStatusQuery); + const { data, loading, error, refetch } = useQuery< + IAuditChainStatusQuery, + IAuditChainStatusQueryVariables + >(AuditChainStatusQuery); return { chainStatus: data?.auditChainStatus, diff --git a/admin-ui/src/modules/audit/hooks/useAuditLogs.ts b/admin-ui/src/modules/audit/hooks/useAuditLogs.ts index 472e841f55..1044159611 100644 --- a/admin-ui/src/modules/audit/hooks/useAuditLogs.ts +++ b/admin-ui/src/modules/audit/hooks/useAuditLogs.ts @@ -1,6 +1,7 @@ import { gql } from '@apollo/client'; import { useQuery } from '@apollo/client/react'; import AuditLogEntryFragment from '../fragments/AuditLogEntryFragment'; +import { IAuditLogsQuery, IAuditLogsQueryVariables } from '@/gql/types'; const AuditLogsQuery = gql` query AuditLogs( @@ -10,7 +11,7 @@ const AuditLogsQuery = gql` $userId: String $success: Boolean $from: Timestamp - $until: Timestamp + $to: Timestamp $queryText: String ) { auditLogs( @@ -20,7 +21,7 @@ const AuditLogsQuery = gql` userId: $userId success: $success from: $from - until: $until + to: $to queryText: $queryText ) { ...AuditLogEntryFragment @@ -30,7 +31,7 @@ const AuditLogsQuery = gql` userId: $userId success: $success from: $from - until: $until + to: $to queryText: $queryText ) } @@ -44,23 +45,11 @@ const useAuditLogs = ({ userId = null, success = null, from = null, - until = null, + to = null, queryText = null, -}: { - limit?: number; - offset?: number; - classUids?: number[] | null; - userId?: string | null; - success?: boolean | null; - from?: number | null; - until?: number | null; - queryText?: string | null; -} = {}) => { +}: IAuditLogsQueryVariables = {}) => { const { data, loading, error, fetchMore, previousData, networkStatus } = - useQuery<{ - auditLogs: any[]; - auditLogsCount: number; - }>(AuditLogsQuery, { + useQuery(AuditLogsQuery, { variables: { limit, offset, @@ -68,7 +57,7 @@ const useAuditLogs = ({ userId, success, from, - until, + to, queryText, }, notifyOnNetworkStatusChange: true, diff --git a/admin-ui/src/modules/audit/hooks/useFailedLoginAttempts.ts b/admin-ui/src/modules/audit/hooks/useFailedLoginAttempts.ts index a3b954e0e9..e635a839e2 100644 --- a/admin-ui/src/modules/audit/hooks/useFailedLoginAttempts.ts +++ b/admin-ui/src/modules/audit/hooks/useFailedLoginAttempts.ts @@ -1,3 +1,7 @@ +import { + IFailedLoginAttemptsQuery, + IFailedLoginAttemptsQueryVariables, +} from '@/gql/types'; import { gql } from '@apollo/client'; import { useQuery } from '@apollo/client/react'; @@ -19,14 +23,11 @@ const useFailedLoginAttempts = ({ userId = null, remoteAddress = null, since = null, -}: { - userId?: string | null; - remoteAddress?: string | null; - since?: number | null; -} = {}) => { - const { data, loading, error, refetch } = useQuery<{ - failedLoginAttempts: number; - }>(FailedLoginAttemptsQuery, { +}: IFailedLoginAttemptsQueryVariables = {}) => { + const { data, loading, error, refetch } = useQuery< + IFailedLoginAttemptsQuery, + IFailedLoginAttemptsQueryVariables + >(FailedLoginAttemptsQuery, { variables: { userId, remoteAddress, since }, }); diff --git a/admin-ui/src/pages/settings/security.tsx b/admin-ui/src/pages/settings/security.tsx index b8d5e35a2f..21f6ecf5cc 100644 --- a/admin-ui/src/pages/settings/security.tsx +++ b/admin-ui/src/pages/settings/security.tsx @@ -27,8 +27,8 @@ const SecurityPage = () => { const userId = (query.userId as string) || null; const queryText = (query.queryText as string) || null; const from = query.from ? new Date(query.from as string).getTime() : null; - const until = query.until - ? new Date(query.until as string).getTime() + 86400000 + const to = query.to + ? new Date(query.to as string).getTime() + 86400000 : null; const { auditLogs, auditLogsCount, loading, loadingMore, hasMore, loadMore } = @@ -39,7 +39,7 @@ const SecurityPage = () => { userId, queryText, from, - until, + to, }); const { exportCSV, isExporting } = useCSVExport(); @@ -54,11 +54,11 @@ const SecurityPage = () => { ...(userId ? { userId } : {}), ...(success !== null ? { success } : {}), ...(from ? { from } : {}), - ...(until ? { until } : {}), + ...(to ? { to } : {}), ...(queryText ? { queryText } : {}), }); }, - [exportCSV, classUids, userId, success, from, until, queryText], + [exportCSV, classUids, userId, success, from, to, queryText], ); return ( diff --git a/packages/api/src/resolvers/queries/audit/auditLogs.ts b/packages/api/src/resolvers/queries/audit/auditLogs.ts index 2fc0aced0c..ac7782a2aa 100644 --- a/packages/api/src/resolvers/queries/audit/auditLogs.ts +++ b/packages/api/src/resolvers/queries/audit/auditLogs.ts @@ -97,7 +97,7 @@ export default async function auditLogs( userId?: string; success?: boolean; from?: number; - until?: number; + to?: number; queryText?: string; }, context: Context, @@ -113,7 +113,7 @@ export default async function auditLogs( userId: params.userId, success: params.success ?? undefined, startTime: params.from ? new Date(params.from) : undefined, - endTime: params.until ? new Date(params.until) : undefined, + endTime: params.to ? new Date(params.to) : undefined, queryText: params.queryText || undefined, }); diff --git a/packages/api/src/resolvers/queries/audit/auditLogsCount.ts b/packages/api/src/resolvers/queries/audit/auditLogsCount.ts index b24497806e..80df1e0101 100644 --- a/packages/api/src/resolvers/queries/audit/auditLogsCount.ts +++ b/packages/api/src/resolvers/queries/audit/auditLogsCount.ts @@ -8,7 +8,7 @@ export default async function auditLogsCount( userId?: string; success?: boolean; from?: number; - until?: number; + to?: number; queryText?: string; }, context: Context, @@ -22,7 +22,7 @@ export default async function auditLogsCount( userId: params.userId, success: params.success ?? undefined, startTime: params.from ? new Date(params.from) : undefined, - endTime: params.until ? new Date(params.until) : undefined, + endTime: params.to ? new Date(params.to) : undefined, queryText: params.queryText || undefined, }); } diff --git a/packages/api/src/schema/query.ts b/packages/api/src/schema/query.ts index 3bd90cdb98..b739c6834d 100644 --- a/packages/api/src/schema/query.ts +++ b/packages/api/src/schema/query.ts @@ -516,7 +516,7 @@ export default [ userId: String success: Boolean from: Timestamp - until: Timestamp + to: Timestamp queryText: String ): [AuditLogEntry!]! @@ -528,7 +528,7 @@ export default [ userId: String success: Boolean from: Timestamp - until: Timestamp + to: Timestamp queryText: String ): Int! diff --git a/packages/core/src/bulk-exporter/handlers/exportAuditLogsHandler.ts b/packages/core/src/bulk-exporter/handlers/exportAuditLogsHandler.ts index 42a5552568..b59db79354 100644 --- a/packages/core/src/bulk-exporter/handlers/exportAuditLogsHandler.ts +++ b/packages/core/src/bulk-exporter/handlers/exportAuditLogsHandler.ts @@ -9,7 +9,7 @@ export const AuditLogExportPayloadSchema = z.object({ userId: z.string().optional(), success: z.boolean().optional(), from: z.number().optional(), - until: z.number().optional(), + to: z.number().optional(), queryText: z.string().optional(), exportCSV: z.boolean().optional(), exportJSONL: z.boolean().optional(), @@ -67,7 +67,7 @@ const exportAuditLogsHandler = async ( userId: payload.userId, success: payload.success, startTime: payload.from ? new Date(payload.from) : undefined, - endTime: payload.until ? new Date(payload.until) : undefined, + endTime: payload.to ? new Date(payload.to) : undefined, queryText: payload.queryText, }); diff --git a/packages/events/src/audit/audit-integration.ts b/packages/events/src/audit/audit-integration.ts index 5177544f77..3c7f23f209 100644 --- a/packages/events/src/audit/audit-integration.ts +++ b/packages/events/src/audit/audit-integration.ts @@ -578,6 +578,8 @@ const API_EVENT_MAP: Record = { * Call once during application startup after creating the audit log. */ export function configureAuditIntegration(auditLog: AuditLog): void { + const subscribedEvents = new Set(); + const sub = (event: string, handler: (payload: Record) => Promise) => { try { EventDirector.subscribe(event, async ({ payload }) => { @@ -587,6 +589,7 @@ export function configureAuditIntegration(auditLog: AuditLog): void { logger.error(`Audit log error for ${event}: ${error}`); } }); + subscribedEvents.add(event); } catch { // Event not registered — skip } @@ -702,6 +705,17 @@ export function configureAuditIntegration(auditLog: AuditLog): void { }); }); } + + // Verify all advertised events are actually subscribed + const missing = AUDITED_EVENTS.filter((e) => !subscribedEvents.has(e)); + if (missing.length > 0) { + logger.warn( + `Audit integration: ${missing.length} advertised events not registered: ${missing.join(', ')}`, + ); + } + logger.info( + `Audit integration configured: ${subscribedEvents.size}/${AUDITED_EVENTS.length} events subscribed`, + ); } /** diff --git a/packages/events/src/audit/index.ts b/packages/events/src/audit/index.ts index 54dceef643..f44d9f29b8 100644 --- a/packages/events/src/audit/index.ts +++ b/packages/events/src/audit/index.ts @@ -230,7 +230,7 @@ export class AuditLog { private cachedVerifySeq = 0; constructor(config: AuditLogConfig = {}) { - this.dir = config.directory || './audit-logs'; + this.dir = config.directory || process.env.UNCHAINED_AUDIT_LOG_DIR || './audit-logs'; this.collectorUrl = config.collectorUrl; this.collectorHeaders = config.collectorHeaders || {}; this.batchSize = config.batchSize || 10; @@ -347,7 +347,12 @@ export class AuditLog { // Write to file const line = JSON.stringify(eventWithChain); - await appendFile(this.getFilePath(), line + '\n', 'utf-8'); + try { + await appendFile(this.getFilePath(), line + '\n', 'utf-8'); + } catch (err) { + logger.error(`Failed to write audit event seq=${seq}: ${(err as Error).message}`); + throw err; + } // Update state this.lastEvent = eventWithChain; @@ -367,7 +372,10 @@ export class AuditLog { return eventWithChain.metadata.uid!; }); - this.writeLock = result; + // Recover the write lock so subsequent writes aren't blocked by a single failure + this.writeLock = result.catch((e) => { + console.error('Error writing audit event:', e); + }); return result; } diff --git a/tests/audit-log.test.js b/tests/audit-log.test.js index 8517de5b1a..7666185753 100644 --- a/tests/audit-log.test.js +++ b/tests/audit-log.test.js @@ -139,6 +139,153 @@ test.describe('Audit Log', () => { }); }); + test.describe('Query.auditLogs filters', () => { + test('admin can filter audit logs with to date filter', async () => { + const tomorrow = Date.now() + 86400000; + const { data, errors } = await graphqlFetchAsAdminUser({ + query: /* GraphQL */ ` + query auditLogsTo($to: Timestamp) { + auditLogs(limit: 10, to: $to) { + id + time + } + } + `, + variables: { to: tomorrow }, + }); + assert.ok(!errors); + assert.ok(Array.isArray(data.auditLogs)); + for (const entry of data.auditLogs) { + assert.ok(entry.time <= tomorrow, 'All entries should be before the to date'); + } + }); + + test('admin can filter audit logs with from date filter', async () => { + const pastDate = Date.now() - 7 * 86400000; + const { data, errors } = await graphqlFetchAsAdminUser({ + query: /* GraphQL */ ` + query auditLogsFrom($from: Timestamp) { + auditLogs(limit: 10, from: $from) { + id + time + } + } + `, + variables: { from: pastDate }, + }); + assert.ok(!errors); + assert.ok(Array.isArray(data.auditLogs)); + for (const entry of data.auditLogs) { + assert.ok(entry.time >= pastDate, 'All entries should be after the from date'); + } + }); + + test('admin can filter audit logs with from and to date range', async () => { + const from = Date.now() - 7 * 86400000; + const to = Date.now() + 86400000; + const { data, errors } = await graphqlFetchAsAdminUser({ + query: /* GraphQL */ ` + query auditLogsRange($from: Timestamp, $to: Timestamp) { + auditLogs(limit: 10, from: $from, to: $to) { + id + time + } + auditLogsCount(from: $from, to: $to) + } + `, + variables: { from, to }, + }); + assert.ok(!errors); + assert.ok(Array.isArray(data.auditLogs)); + assert.strictEqual(typeof data.auditLogsCount, 'number'); + for (const entry of data.auditLogs) { + assert.ok(entry.time >= from && entry.time <= to, 'Entry should be within date range'); + } + }); + + test('admin can filter by classUids', async () => { + const { data, errors } = await graphqlFetchAsAdminUser({ + query: /* GraphQL */ ` + query { + auditLogs(limit: 10, classUids: [3002]) { + id + classUid + } + } + `, + }); + assert.ok(!errors); + assert.ok(Array.isArray(data.auditLogs)); + for (const entry of data.auditLogs) { + assert.strictEqual(entry.classUid, 3002, 'All entries should be authentication events'); + } + }); + + test('admin can filter by success status', async () => { + const { data, errors } = await graphqlFetchAsAdminUser({ + query: /* GraphQL */ ` + query { + auditLogs(limit: 10, success: true) { + id + statusId + } + } + `, + }); + assert.ok(!errors); + assert.ok(Array.isArray(data.auditLogs)); + for (const entry of data.auditLogs) { + assert.strictEqual(entry.statusId, 1, 'All entries should have success status'); + } + }); + + test('admin can filter by userId', async () => { + const { data, errors } = await graphqlFetchAsAdminUser({ + query: /* GraphQL */ ` + query { + auditLogs(limit: 10, userId: "nonexistent-user-id") { + id + } + } + `, + }); + assert.ok(!errors); + assert.ok(Array.isArray(data.auditLogs)); + assert.strictEqual(data.auditLogs.length, 0, 'No entries for nonexistent user'); + }); + + test('admin can search by queryText', async () => { + const { data, errors } = await graphqlFetchAsAdminUser({ + query: /* GraphQL */ ` + query { + auditLogs(limit: 10, queryText: "Login") { + id + message + } + } + `, + }); + assert.ok(!errors); + assert.ok(Array.isArray(data.auditLogs)); + }); + }); + + test.describe('Query.auditLogsCount with filters', () => { + test('count respects to date filter', async () => { + const distantPast = 0; + const { data, errors } = await graphqlFetchAsAdminUser({ + query: /* GraphQL */ ` + query auditLogsCountTo($to: Timestamp) { + auditLogsCount(to: $to) + } + `, + variables: { to: distantPast }, + }); + assert.ok(!errors); + assert.strictEqual(data.auditLogsCount, 0, 'No entries before epoch'); + }); + }); + test.describe('Login emits audit entry', () => { test('login produces an authentication audit log entry', async () => { // Trigger a login @@ -176,4 +323,93 @@ test.describe('Audit Log', () => { assert.ok(authEntry, 'Should have an AUTHENTICATION audit entry after login'); }); }); + + test.describe('Audit log export via work queue', () => { + test('admin can trigger CSV audit log export', async () => { + const { data, errors } = await graphqlFetchAsAdminUser({ + query: /* GraphQL */ ` + mutation addWork($type: WorkType!, $input: JSON) { + addWork(type: $type, input: $input) { + _id + type + status + } + } + `, + variables: { + type: 'BULK_EXPORT', + input: { type: 'AUDIT_LOGS', exportCSV: true }, + }, + }); + assert.ok(!errors, `addWork should not error: ${JSON.stringify(errors)}`); + assert.ok(data.addWork._id, 'Work should be created'); + assert.strictEqual(data.addWork.type, 'BULK_EXPORT'); + }); + + test('admin can trigger JSONL audit log export', async () => { + const { data, errors } = await graphqlFetchAsAdminUser({ + query: /* GraphQL */ ` + mutation addWork($type: WorkType!, $input: JSON) { + addWork(type: $type, input: $input) { + _id + type + status + } + } + `, + variables: { + type: 'BULK_EXPORT', + input: { type: 'AUDIT_LOGS', exportJSONL: true }, + }, + }); + assert.ok(!errors, `addWork should not error: ${JSON.stringify(errors)}`); + assert.ok(data.addWork._id, 'Work should be created'); + }); + + test('admin can trigger audit log export with filters', async () => { + const from = Date.now() - 7 * 86400000; + const to = Date.now() + 86400000; + const { data, errors } = await graphqlFetchAsAdminUser({ + query: /* GraphQL */ ` + mutation addWork($type: WorkType!, $input: JSON) { + addWork(type: $type, input: $input) { + _id + type + status + } + } + `, + variables: { + type: 'BULK_EXPORT', + input: { + type: 'AUDIT_LOGS', + exportCSV: true, + from, + to, + classUids: [3002], + success: true, + }, + }, + }); + assert.ok(!errors, `addWork should not error: ${JSON.stringify(errors)}`); + assert.ok(data.addWork._id, 'Work should be created with filters'); + }); + + test('anonymous user cannot trigger audit log export', async () => { + const { errors } = await graphqlFetchAsAnonymousUser({ + query: /* GraphQL */ ` + mutation addWork($type: WorkType!, $input: JSON) { + addWork(type: $type, input: $input) { + _id + } + } + `, + variables: { + type: 'BULK_EXPORT', + input: { type: 'AUDIT_LOGS', exportCSV: true }, + }, + }); + assert.ok(errors?.length > 0, 'Anonymous users should not be able to export'); + }); + }); }); From f12f114b5bfc614829d4eed243967ef68ccfe457 Mon Sep 17 00:00:00 2001 From: Mikael Araya Date: Tue, 9 Jun 2026 13:41:20 +0300 Subject: [PATCH 05/10] Lint --- .../audit/components/AuditEntryDetail.tsx | 3 +- .../audit/components/FailedLoginsWidget.tsx | 14 +- .../modules/audit/components/ocsf-labels.ts | 135 ++++++++++++++---- 3 files changed, 120 insertions(+), 32 deletions(-) diff --git a/admin-ui/src/modules/audit/components/AuditEntryDetail.tsx b/admin-ui/src/modules/audit/components/AuditEntryDetail.tsx index 5a07de5ea7..d6e2ad7107 100644 --- a/admin-ui/src/modules/audit/components/AuditEntryDetail.tsx +++ b/admin-ui/src/modules/audit/components/AuditEntryDetail.tsx @@ -11,7 +11,8 @@ const AuditEntryDetail = ({ onClose: () => void; }) => { const { formatMessage, formatDate, formatTime } = useIntl(); - const { CLASS_LABELS, SEVERITY_LABELS, STATUS_LABELS, getActivityName } = useOcsfLabels(); + const { CLASS_LABELS, SEVERITY_LABELS, STATUS_LABELS, getActivityName } = + useOcsfLabels(); if (!entry) return null; diff --git a/admin-ui/src/modules/audit/components/FailedLoginsWidget.tsx b/admin-ui/src/modules/audit/components/FailedLoginsWidget.tsx index 8a1dfa0b09..ddcdb60e1e 100644 --- a/admin-ui/src/modules/audit/components/FailedLoginsWidget.tsx +++ b/admin-ui/src/modules/audit/components/FailedLoginsWidget.tsx @@ -13,10 +13,16 @@ const FailedLoginsWidget = () => { }; }, []); - const { failedLoginAttempts: last24h, loading: loading24h, error: error24h } = - useFailedLoginAttempts({ since: oneDayAgo }); - const { failedLoginAttempts: last7d, loading: loading7d, error: error7d } = - useFailedLoginAttempts({ since: oneWeekAgo }); + const { + failedLoginAttempts: last24h, + loading: loading24h, + error: error24h, + } = useFailedLoginAttempts({ since: oneDayAgo }); + const { + failedLoginAttempts: last7d, + loading: loading7d, + error: error7d, + } = useFailedLoginAttempts({ since: oneWeekAgo }); const loading = loading24h || loading7d; const error = error24h || error7d; diff --git a/admin-ui/src/modules/audit/components/ocsf-labels.ts b/admin-ui/src/modules/audit/components/ocsf-labels.ts index d54f91709d..7f98303a22 100644 --- a/admin-ui/src/modules/audit/components/ocsf-labels.ts +++ b/admin-ui/src/modules/audit/components/ocsf-labels.ts @@ -13,68 +13,149 @@ export function useOcsfLabels() { const { formatMessage } = useIntl(); const CLASS_LABELS: Record = { - ACCOUNT_CHANGE: formatMessage({ id: 'audit_class_account_change', defaultMessage: 'Account Change' }), - AUTHENTICATION: formatMessage({ id: 'audit_class_authentication', defaultMessage: 'Authentication' }), - API_ACTIVITY: formatMessage({ id: 'audit_class_api_activity', defaultMessage: 'API Activity' }), + ACCOUNT_CHANGE: formatMessage({ + id: 'audit_class_account_change', + defaultMessage: 'Account Change', + }), + AUTHENTICATION: formatMessage({ + id: 'audit_class_authentication', + defaultMessage: 'Authentication', + }), + API_ACTIVITY: formatMessage({ + id: 'audit_class_api_activity', + defaultMessage: 'API Activity', + }), }; const SEVERITY_LABELS: Record = { - 0: formatMessage({ id: 'audit_severity_unknown', defaultMessage: 'Unknown' }), - 1: formatMessage({ id: 'audit_severity_informational', defaultMessage: 'Informational' }), + 0: formatMessage({ + id: 'audit_severity_unknown', + defaultMessage: 'Unknown', + }), + 1: formatMessage({ + id: 'audit_severity_informational', + defaultMessage: 'Informational', + }), 2: formatMessage({ id: 'audit_severity_low', defaultMessage: 'Low' }), 3: formatMessage({ id: 'audit_severity_medium', defaultMessage: 'Medium' }), 4: formatMessage({ id: 'audit_severity_high', defaultMessage: 'High' }), - 5: formatMessage({ id: 'audit_severity_critical', defaultMessage: 'Critical' }), + 5: formatMessage({ + id: 'audit_severity_critical', + defaultMessage: 'Critical', + }), 6: formatMessage({ id: 'audit_severity_fatal', defaultMessage: 'Fatal' }), 99: formatMessage({ id: 'audit_severity_other', defaultMessage: 'Other' }), }; const STATUS_LABELS: Record = { - 0: formatMessage({ id: 'audit_severity_unknown', defaultMessage: 'Unknown' }), + 0: formatMessage({ + id: 'audit_severity_unknown', + defaultMessage: 'Unknown', + }), 1: formatMessage({ id: 'audit_status_success', defaultMessage: 'Success' }), 2: formatMessage({ id: 'audit_status_failure', defaultMessage: 'Failure' }), 99: formatMessage({ id: 'audit_severity_other', defaultMessage: 'Other' }), }; const AUTH_ACTIVITIES: Record = { - 0: formatMessage({ id: 'audit_severity_unknown', defaultMessage: 'Unknown' }), + 0: formatMessage({ + id: 'audit_severity_unknown', + defaultMessage: 'Unknown', + }), 1: formatMessage({ id: 'audit_activity_logon', defaultMessage: 'Logon' }), 2: formatMessage({ id: 'audit_activity_logoff', defaultMessage: 'Logoff' }), - 3: formatMessage({ id: 'audit_activity_auth_ticket', defaultMessage: 'Authentication Ticket' }), - 4: formatMessage({ id: 'audit_activity_service_ticket_request', defaultMessage: 'Service Ticket Request' }), - 5: formatMessage({ id: 'audit_activity_service_ticket_renew', defaultMessage: 'Service Ticket Renew' }), - 6: formatMessage({ id: 'audit_activity_pre_auth', defaultMessage: 'Pre-Auth' }), + 3: formatMessage({ + id: 'audit_activity_auth_ticket', + defaultMessage: 'Authentication Ticket', + }), + 4: formatMessage({ + id: 'audit_activity_service_ticket_request', + defaultMessage: 'Service Ticket Request', + }), + 5: formatMessage({ + id: 'audit_activity_service_ticket_renew', + defaultMessage: 'Service Ticket Renew', + }), + 6: formatMessage({ + id: 'audit_activity_pre_auth', + defaultMessage: 'Pre-Auth', + }), 99: formatMessage({ id: 'audit_severity_other', defaultMessage: 'Other' }), }; const ACCOUNT_ACTIVITIES: Record = { - 0: formatMessage({ id: 'audit_severity_unknown', defaultMessage: 'Unknown' }), + 0: formatMessage({ + id: 'audit_severity_unknown', + defaultMessage: 'Unknown', + }), 1: formatMessage({ id: 'audit_activity_create', defaultMessage: 'Create' }), 2: formatMessage({ id: 'audit_activity_enable', defaultMessage: 'Enable' }), - 3: formatMessage({ id: 'audit_activity_password_change', defaultMessage: 'Password Change' }), - 4: formatMessage({ id: 'audit_activity_password_reset', defaultMessage: 'Password Reset' }), - 5: formatMessage({ id: 'audit_activity_disable', defaultMessage: 'Disable' }), + 3: formatMessage({ + id: 'audit_activity_password_change', + defaultMessage: 'Password Change', + }), + 4: formatMessage({ + id: 'audit_activity_password_reset', + defaultMessage: 'Password Reset', + }), + 5: formatMessage({ + id: 'audit_activity_disable', + defaultMessage: 'Disable', + }), 6: formatMessage({ id: 'audit_activity_delete', defaultMessage: 'Delete' }), - 7: formatMessage({ id: 'audit_activity_attach_policy', defaultMessage: 'Attach Policy' }), - 8: formatMessage({ id: 'audit_activity_detach_policy', defaultMessage: 'Detach Policy' }), + 7: formatMessage({ + id: 'audit_activity_attach_policy', + defaultMessage: 'Attach Policy', + }), + 8: formatMessage({ + id: 'audit_activity_detach_policy', + defaultMessage: 'Detach Policy', + }), 9: formatMessage({ id: 'audit_activity_lock', defaultMessage: 'Lock' }), - 10: formatMessage({ id: 'audit_activity_mfa_enable', defaultMessage: 'MFA Enable' }), - 11: formatMessage({ id: 'audit_activity_mfa_disable', defaultMessage: 'MFA Disable' }), + 10: formatMessage({ + id: 'audit_activity_mfa_enable', + defaultMessage: 'MFA Enable', + }), + 11: formatMessage({ + id: 'audit_activity_mfa_disable', + defaultMessage: 'MFA Disable', + }), 99: formatMessage({ id: 'audit_severity_other', defaultMessage: 'Other' }), }; const API_ACTIVITIES: Record = { - 0: formatMessage({ id: 'audit_severity_unknown', defaultMessage: 'Unknown' }), + 0: formatMessage({ + id: 'audit_severity_unknown', + defaultMessage: 'Unknown', + }), 1: formatMessage({ id: 'audit_activity_create', defaultMessage: 'Create' }), 2: formatMessage({ id: 'audit_activity_read', defaultMessage: 'Read' }), 3: formatMessage({ id: 'audit_activity_update', defaultMessage: 'Update' }), 4: formatMessage({ id: 'audit_activity_delete', defaultMessage: 'Delete' }), - 90: formatMessage({ id: 'audit_activity_checkout', defaultMessage: 'Checkout' }), - 91: formatMessage({ id: 'audit_activity_payment', defaultMessage: 'Payment' }), - 92: formatMessage({ id: 'audit_activity_refund', defaultMessage: 'Refund' }), - 93: formatMessage({ id: 'audit_activity_export', defaultMessage: 'Export' }), - 94: formatMessage({ id: 'audit_activity_import', defaultMessage: 'Import' }), - 95: formatMessage({ id: 'audit_activity_access_denied', defaultMessage: 'Access Denied' }), + 90: formatMessage({ + id: 'audit_activity_checkout', + defaultMessage: 'Checkout', + }), + 91: formatMessage({ + id: 'audit_activity_payment', + defaultMessage: 'Payment', + }), + 92: formatMessage({ + id: 'audit_activity_refund', + defaultMessage: 'Refund', + }), + 93: formatMessage({ + id: 'audit_activity_export', + defaultMessage: 'Export', + }), + 94: formatMessage({ + id: 'audit_activity_import', + defaultMessage: 'Import', + }), + 95: formatMessage({ + id: 'audit_activity_access_denied', + defaultMessage: 'Access Denied', + }), 99: formatMessage({ id: 'audit_severity_other', defaultMessage: 'Other' }), }; From d69452e0704d748a5a8968a1b923fbe0210e1058 Mon Sep 17 00:00:00 2001 From: Mikael Araya Date: Tue, 9 Jun 2026 16:29:11 +0300 Subject: [PATCH 06/10] Use mongodb to store audit log instead of file system --- admin-ui/src/components/ui/BreadCrumbs.tsx | 3 +- admin-ui/src/i18n/de.json | 4 +- admin-ui/src/i18n/en.json | 4 +- .../audit/components/AuditLogFilters.tsx | 2 +- .../src/modules/common/components/Layout.tsx | 18 +- .../security.tsx => activities/audit-log.tsx} | 12 +- admin-ui/src/pages/settings/index.tsx | 14 - package-lock.json | 3 +- packages/events/package.json | 3 +- packages/events/src/audit/index.test.ts | 38 +- packages/events/src/audit/index.ts | 442 ++++++------------ packages/platform/src/startPlatform.ts | 2 +- .../src/worker/audit-log-prune/adapter.ts | 2 +- tests/audit-compliance.test.js | 126 ++--- 14 files changed, 232 insertions(+), 441 deletions(-) rename admin-ui/src/pages/{settings/security.tsx => activities/audit-log.tsx} (95%) delete mode 100644 admin-ui/src/pages/settings/index.tsx diff --git a/admin-ui/src/components/ui/BreadCrumbs.tsx b/admin-ui/src/components/ui/BreadCrumbs.tsx index 6f66fd46f6..aa00ceef14 100644 --- a/admin-ui/src/components/ui/BreadCrumbs.tsx +++ b/admin-ui/src/components/ui/BreadCrumbs.tsx @@ -32,8 +32,7 @@ const BreadCrumbs = ({ 'warehousing-provider': 'warehousing_provider', system: 'system', account: 'account', - settings: 'settings', - security: 'security', + 'audit-log': 'audit_log', new: 'new', edit: 'edit', }; diff --git a/admin-ui/src/i18n/de.json b/admin-ui/src/i18n/de.json index 3d8f195898..1b32a93e30 100644 --- a/admin-ui/src/i18n/de.json +++ b/admin-ui/src/i18n/de.json @@ -778,8 +778,8 @@ "scheduled": "Geplant", "search": "Suchen", "security": "Sicherheit", - "security_page_header": "Sicherheit & Audit-Log", - "security_page_title": "Sicherheit & Audit-Log", + "audit_log_page_header": "Audit-Log", + "audit_log_page_title": "Audit-Log", "settings": "Einstellungen", "search_messages_placeholder": "Nach Nachricht oder Operation suchen...", "search_product": "Search product", diff --git a/admin-ui/src/i18n/en.json b/admin-ui/src/i18n/en.json index eb1ee377be..29416b63b6 100644 --- a/admin-ui/src/i18n/en.json +++ b/admin-ui/src/i18n/en.json @@ -900,8 +900,8 @@ "scheduled": "Scheduled", "search": "Search", "security": "Security", - "security_page_header": "Security & Audit Log", - "security_page_title": "Security & Audit Log", + "audit_log_page_header": "Audit Log", + "audit_log_page_title": "Audit Log", "settings": "Settings", "search_messages_placeholder": "Search by message or operation...", "search_product": "Search product", diff --git a/admin-ui/src/modules/audit/components/AuditLogFilters.tsx b/admin-ui/src/modules/audit/components/AuditLogFilters.tsx index e80f28f0b1..8bcd5deca6 100644 --- a/admin-ui/src/modules/audit/components/AuditLogFilters.tsx +++ b/admin-ui/src/modules/audit/components/AuditLogFilters.tsx @@ -151,7 +151,7 @@ const AuditLogFilters = () => {
-
+
{formatMessage({ id: 'class', defaultMessage: 'Class' })} diff --git a/admin-ui/src/modules/common/components/Layout.tsx b/admin-ui/src/modules/common/components/Layout.tsx index 68ca80cbcd..cad2181654 100644 --- a/admin-ui/src/modules/common/components/Layout.tsx +++ b/admin-ui/src/modules/common/components/Layout.tsx @@ -296,14 +296,6 @@ const Layout = ({ requiredRole: 'viewWarehousingProviders', href: '/warehousing-provider', }, - isSystemReady && { - name: formatMessage({ - id: 'audit_log', - defaultMessage: 'Audit Log', - }), - requiredRole: 'viewAuditLog', - href: '/settings/security', - }, ].filter(Boolean), }, { @@ -324,7 +316,15 @@ const Layout = ({ href: '/events', requiredRole: 'viewEvents', }, - ], + isSystemReady && { + name: formatMessage({ + id: 'audit_log', + defaultMessage: 'Audit Log', + }), + requiredRole: 'viewAuditLog', + href: '/activities/audit-log', + }, + ].filter(Boolean), }, shopInfo?.adminUiConfig?.externalLinks?.length && { _sortOrder: 130, diff --git a/admin-ui/src/pages/settings/security.tsx b/admin-ui/src/pages/activities/audit-log.tsx similarity index 95% rename from admin-ui/src/pages/settings/security.tsx rename to admin-ui/src/pages/activities/audit-log.tsx index 21f6ecf5cc..9fdcb6f9dc 100644 --- a/admin-ui/src/pages/settings/security.tsx +++ b/admin-ui/src/pages/activities/audit-log.tsx @@ -14,7 +14,7 @@ import AuditEntryDetail from '../../modules/audit/components/AuditEntryDetail'; import useAuditLogs from '../../modules/audit/hooks/useAuditLogs'; import { useCSVExport } from '../../modules/common/hooks/useCSVExport'; -const SecurityPage = () => { +const AuditLogPage = () => { const { formatMessage } = useIntl(); const { query } = useRouter(); const [selectedEntry, setSelectedEntry] = useState(null); @@ -66,12 +66,12 @@ const SecurityPage = () => {