diff --git a/.gitignore b/.gitignore index c09110a02a..a15d363030 100644 --- a/.gitignore +++ b/.gitignore @@ -17,6 +17,8 @@ *.db *.sqlite *.sqlite3 +active_subs.json +charges.json # OS files .DS_Store diff --git a/docs/API.md b/docs/API.md index 7811919cb8..d870c93b9a 100644 --- a/docs/API.md +++ b/docs/API.md @@ -198,6 +198,48 @@ Returns a new raw token (shown once) and updates stored hashes: "requiresRestart": false, "message": "New API token generated and active immediately! Save this token - it won't be shown again." } + +--- + +## 🧾 Audit Log (Enterprise) + +These endpoints require admin access and the `settings:read` scope. In OSS builds, the list endpoint returns an empty set and `persistentLogging: false`. + +### List Audit Events +`GET /api/audit?limit=100&event=login&user=admin&success=true&startTime=2024-01-01T00:00:00Z&endTime=2024-01-31T23:59:59Z` + +Response: +```json +{ + "events": [ + { + "id": "6b3c9c3c-9a2f-4b3c-9a3b-3d0e8c5c5d45", + "timestamp": "2024-01-12T10:15:30Z", + "event": "login", + "user": "admin", + "ip": "10.0.0.10", + "path": "/api/login", + "success": true, + "details": "Successful login", + "signature": "..." + } + ], + "total": 1, + "persistentLogging": true +} +``` + +### Verify Audit Event Signature +`GET /api/audit//verify` + +Response: +```json +{ + "available": true, + "verified": true, + "message": "Event signature verified" +} +``` ``` ### Validate API Token (Admin) diff --git a/docs/PULSE_PRO.md b/docs/PULSE_PRO.md index 8d174e9025..3a6fb86e6d 100644 --- a/docs/PULSE_PRO.md +++ b/docs/PULSE_PRO.md @@ -4,6 +4,14 @@ Pulse Pro unlocks advanced AI automation features on top of the free Pulse platf ## What You Get +### Enterprise Audit Log +- Persistent audit trail with SQLite storage and HMAC signing. +- Queryable via `/api/audit` and verified per event in the Security → Audit Log UI. +- Supports filtering, verification badges, and signature checks for tamper detection. +- Configure with `PULSE_AUDIT_SIGNING_KEY`, `PULSE_AUDIT_RETENTION_DAYS`, and `PULSE_AUDIT_CLEANUP_INTERVAL_HOURS`. +- API reference: `docs/API.md`. +- If no signing key is set, events are stored without signatures and verification will fail. + ### AI Patrol (LLM-Backed) Scheduled background analysis that correlates live state + metrics history to produce actionable findings. diff --git a/docs/TROUBLESHOOTING.md b/docs/TROUBLESHOOTING.md index bed76acd78..c80d5f059d 100644 --- a/docs/TROUBLESHOOTING.md +++ b/docs/TROUBLESHOOTING.md @@ -43,6 +43,21 @@ sudo pulse bootstrap-token - Check if your IP is locked out (wait 15 mins). - If another admin can log in, use `POST /api/security/reset-lockout` to clear the lockout for your username or IP. +**Audit Log verification shows unsigned events** +- **Symptom**: Audit Log entries show “Unsigned” or verification fails in the UI. +- **Root cause**: `PULSE_AUDIT_SIGNING_KEY` is not set, so events are stored without signatures. +- **Fix**: Set `PULSE_AUDIT_SIGNING_KEY` and restart Pulse Pro. Newly created events will be signed; existing unsigned events remain unsigned. + +**Audit Log is empty** +- **Symptom**: Audit Log shows zero events or “Console Logging Only.” +- **Root cause**: OSS build uses console logging only, or enterprise audit logging is not enabled. +- **Fix**: Use Pulse Pro with enterprise audit logging enabled, then generate new audit events (logins, token creation, password changes). + +**Audit Log verification fails for older events** +- **Symptom**: Older events fail verification while newer events pass. +- **Root cause**: The signing key changed or was rotated, so signatures no longer match. +- **Fix**: Keep `PULSE_AUDIT_SIGNING_KEY` stable. If rotated intentionally, expect older events to fail verification. + ### Monitoring Data **VMs show "-" for disk usage** diff --git a/frontend-modern/package-lock.json b/frontend-modern/package-lock.json index ddc80c190c..e4e4d31dc6 100644 --- a/frontend-modern/package-lock.json +++ b/frontend-modern/package-lock.json @@ -116,6 +116,7 @@ "integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.5", @@ -489,6 +490,7 @@ } ], "license": "MIT", + "peer": true, "engines": { "node": ">=18" }, @@ -512,6 +514,7 @@ } ], "license": "MIT", + "peer": true, "engines": { "node": ">=18" } @@ -1586,6 +1589,7 @@ "resolved": "https://registry.npmjs.org/@solidjs/router/-/router-0.10.10.tgz", "integrity": "sha512-nGl7gMgsojuaupI5MAK2cFtkndmWWSAPhill/8La3IjujY3vMBamcQFymBsA2ejzxEYJjkOlEQHYgp2jNFkwuQ==", "license": "MIT", + "peer": true, "peerDependencies": { "solid-js": "^1.8.6" } @@ -1755,6 +1759,7 @@ "integrity": "sha512-0l6cjgF0XnihUpndDhk+nyD3exio3iKaYROSgvh/qSevPXax3L8p5DBRFjbvalnwatGgHEQn2R88y2fA3g4irg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "undici-types": "~6.21.0" } @@ -1801,6 +1806,7 @@ "integrity": "sha512-N9lBGA9o9aqb1hVMc9hzySbhKibHmB+N3IpoShyV6HyQYRGIhlrO5rQgttypi+yEeKsKI4idxC8Jw6gXKD4THA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.49.0", "@typescript-eslint/types": "8.49.0", @@ -2161,6 +2167,7 @@ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -2472,6 +2479,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -3048,6 +3056,7 @@ "deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.6.1", @@ -4035,6 +4044,7 @@ "integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==", "dev": true, "license": "MIT", + "peer": true, "bin": { "jiti": "bin/jiti.js" } @@ -4065,6 +4075,7 @@ "integrity": "sha512-MyL55p3Ut3cXbeBEG7Hcv0mVM8pp8PBNWxRqchZnSfAiES1v1mRnMeFfaHWIPULpwsYfvO+ZmMZz5tGCnjzDUQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "cssstyle": "^4.0.1", "data-urls": "^5.0.0", @@ -4743,6 +4754,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", @@ -5226,6 +5238,7 @@ "resolved": "https://registry.npmjs.org/seroval/-/seroval-1.3.2.tgz", "integrity": "sha512-RbcPH1n5cfwKrru7v7+zrZvjLurgHhGyso3HTyGtRivGWgYjbOmGuivCQaORNELjNONoK35nj28EoWul9sb1zQ==", "license": "MIT", + "peer": true, "engines": { "node": ">=10" } @@ -5290,6 +5303,7 @@ "resolved": "https://registry.npmjs.org/solid-js/-/solid-js-1.9.10.tgz", "integrity": "sha512-Coz956cos/EPDlhs6+jsdTxKuJDPT7B5SVIWgABwROyxjY7Xbr8wkzD68Et+NxnV7DLJ3nJdAC2r9InuV/4Jew==", "license": "MIT", + "peer": true, "dependencies": { "csstype": "^3.1.0", "seroval": "~1.3.0", @@ -5550,6 +5564,7 @@ "integrity": "sha512-3ofp+LL8E+pK/JuPLPggVAIaEuhvIz4qNcf3nA1Xn2o/7fb7s/TYpHhwGDv1ZU3PkBluUVaF8PyCHcm48cKLWQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@alloc/quick-lru": "^5.2.0", "arg": "^5.0.2", @@ -5717,6 +5732,7 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -5848,6 +5864,7 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -5938,6 +5955,7 @@ "integrity": "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.4.4", @@ -6079,6 +6097,7 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -6112,6 +6131,7 @@ "integrity": "sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@types/chai": "^5.2.2", "@vitest/expect": "3.2.4", diff --git a/frontend-modern/src/components/Settings/AuditLogPanel.tsx b/frontend-modern/src/components/Settings/AuditLogPanel.tsx index d76ca43fe5..b5bfada393 100644 --- a/frontend-modern/src/components/Settings/AuditLogPanel.tsx +++ b/frontend-modern/src/components/Settings/AuditLogPanel.tsx @@ -1,5 +1,14 @@ -import { createSignal, Show, For, onMount } from 'solid-js'; -import { Shield, CheckCircle, XCircle, AlertTriangle, RefreshCw, Filter } from 'lucide-solid'; +import { createSignal, Show, For, onMount, createMemo, onCleanup, createEffect } from 'solid-js'; +import { Shield, CheckCircle, XCircle, AlertTriangle, RefreshCw, Filter, Info, Play, X } from 'lucide-solid'; +import { showTooltip, hideTooltip } from '@/components/shared/Tooltip'; +import Toggle from '@/components/shared/Toggle'; +import { + createLocalStorageBooleanSignal, + createLocalStorageNumberSignal, + createLocalStorageStringSignal, + STORAGE_KEYS, +} from '@/utils/localStorage'; +import { showSuccess, showWarning, showToast } from '@/utils/toast'; interface AuditEvent { id: string; @@ -19,21 +28,82 @@ interface AuditResponse { persistentLogging: boolean; } +interface VerifyResponse { + available: boolean; + verified?: boolean; + message?: string; +} + +type VerificationState = { + status: 'verified' | 'failed' | 'unavailable' | 'error'; + message: string; +}; + export default function AuditLogPanel() { const [events, setEvents] = createSignal([]); + const [totalEvents, setTotalEvents] = createSignal(0); const [loading, setLoading] = createSignal(true); const [error, setError] = createSignal(null); const [isPersistent, setIsPersistent] = createSignal(false); - const [eventFilter, setEventFilter] = createSignal(''); - const [userFilter, setUserFilter] = createSignal(''); - const [successFilter, setSuccessFilter] = createSignal<'all' | 'success' | 'failed'>('all'); + const [eventFilter, setEventFilter] = createLocalStorageStringSignal( + STORAGE_KEYS.AUDIT_EVENT_FILTER, + '', + ); + const [userFilter, setUserFilter] = createLocalStorageStringSignal( + STORAGE_KEYS.AUDIT_USER_FILTER, + '', + ); + const [successFilter, setSuccessFilter] = createLocalStorageStringSignal( + STORAGE_KEYS.AUDIT_SUCCESS_FILTER, + 'all', + ); + const [verificationFilter, setVerificationFilter] = createLocalStorageStringSignal( + STORAGE_KEYS.AUDIT_VERIFICATION_FILTER, + 'all', + ); + const allowedVerificationFilters = new Set(['all', 'needs', 'verified', 'failed']); + const allowedSuccessFilters = new Set(['all', 'success', 'failed']); + const [pageSize, setPageSize] = createLocalStorageNumberSignal( + STORAGE_KEYS.AUDIT_PAGE_SIZE, + 100, + ); + const [pageOffset, setPageOffset] = createLocalStorageNumberSignal( + STORAGE_KEYS.AUDIT_PAGE_OFFSET, + 0, + ); + const [verification, setVerification] = createSignal>({}); + const [verifying, setVerifying] = createSignal>({}); + const [verifyingAll, setVerifyingAll] = createSignal(false); + const [verifyAllTotal, setVerifyAllTotal] = createSignal(0); + const [verifyAllDone, setVerifyAllDone] = createSignal(0); + const [autoVerifyEnabled, setAutoVerifyEnabled] = createLocalStorageBooleanSignal( + STORAGE_KEYS.AUDIT_AUTO_VERIFY, + true, + ); + const [autoVerifyLimit, setAutoVerifyLimit] = createLocalStorageNumberSignal( + STORAGE_KEYS.AUDIT_AUTO_VERIFY_LIMIT, + 50, + ); + const [pageInput, setPageInput] = createSignal(''); + const [isMounted, setIsMounted] = createSignal(false); + const [cancelVerifyAll, setCancelVerifyAll] = createSignal(false); + const [verifyCanceled, setVerifyCanceled] = createSignal(false); + const [verifyControllers, setVerifyControllers] = createSignal>({}); + + const fetchAuditEvents = async (options?: { limit?: number; offset?: number }) => { + const limit = options?.limit ?? pageSize(); + const offset = options?.offset ?? pageOffset(); - const fetchAuditEvents = async () => { setLoading(true); setError(null); + setVerification({}); + setVerifying({}); + setVerifyAllTotal(0); + setVerifyAllDone(0); try { const params = new URLSearchParams(); - params.set('limit', '100'); + params.set('limit', String(limit)); + params.set('offset', String(Math.max(0, offset))); if (eventFilter()) params.set('event', eventFilter()); if (userFilter()) params.set('user', userFilter()); if (successFilter() === 'success') params.set('success', 'true'); @@ -46,6 +116,23 @@ export default function AuditLogPanel() { const data: AuditResponse = await response.json(); setEvents(data.events || []); setIsPersistent(data.persistentLogging); + setTotalEvents(data.total ?? 0); + if (data.total && offset >= data.total) { + const maxOffset = Math.max(0, Math.floor((data.total - 1) / limit) * limit); + if (maxOffset !== offset) { + setPageOffset(maxOffset); + void fetchAuditEvents({ limit, offset: maxOffset }); + return; + } + } + if (data.persistentLogging && autoVerifyEnabled()) { + const limit = autoVerifyLimit(); + if (limit <= 0) return; + setTimeout(() => { + if (!isMounted()) return; + void verifyAllEvents({ limit, showToast: false }); + }, 0); + } } catch (err) { setError(err instanceof Error ? err.message : 'Unknown error'); } finally { @@ -53,10 +140,160 @@ export default function AuditLogPanel() { } }; + const verifyEvent = async (event: AuditEvent) => { + if (!event.signature) return; + setVerifying((prev) => ({ ...prev, [event.id]: true })); + try { + const controller = new AbortController(); + setVerifyControllers((prev) => ({ ...prev, [event.id]: controller })); + if (!isMounted()) { + controller.abort(); + } + const response = await fetch(`/api/audit/${event.id}/verify`, { signal: controller.signal }); + if (!response.ok) { + throw new Error(`Failed to verify signature: ${response.statusText}`); + } + const data: VerifyResponse = await response.json(); + let status: VerificationState['status'] = 'unavailable'; + if (!data.available) { + status = 'unavailable'; + } else if (data.verified) { + status = 'verified'; + } else { + status = 'failed'; + } + setVerification((prev) => ({ + ...prev, + [event.id]: { status, message: data.message || '' }, + })); + } catch (err) { + if ((err as { name?: string })?.name === 'AbortError') { + return; + } + setVerification((prev) => ({ + ...prev, + [event.id]: { + status: 'error', + message: err instanceof Error ? err.message : 'Unknown error', + }, + })); + } finally { + setVerifying((prev) => ({ ...prev, [event.id]: false })); + setVerifyControllers((prev) => { + const next = { ...prev }; + delete next[event.id]; + return next; + }); + } + }; + + const verifyAllEvents = async (options?: { limit?: number; showToast?: boolean; resume?: boolean }) => { + const limit = options?.limit; + let signedEvents = events().filter((event) => event.signature); + if (options?.resume) { + signedEvents = signedEvents.filter((event) => { + const state = verification()[event.id]; + return !state || state.status === 'failed' || state.status === 'error'; + }); + } + if (limit !== undefined) { + signedEvents = signedEvents.slice(0, Math.max(0, limit)); + } + if (signedEvents.length === 0) return; + setVerifyingAll(true); + setVerifyCanceled(false); + setCancelVerifyAll(false); + setVerifyAllTotal(signedEvents.length); + setVerifyAllDone(0); + for (const event of signedEvents) { + if (cancelVerifyAll()) { + setVerifyingAll(false); + setVerifyCanceled(true); + if (options?.showToast) { + showToast('info', 'Signature verification canceled'); + } + const controllers = verifyControllers(); + for (const controller of Object.values(controllers)) { + controller.abort(); + } + return; + } + await verifyEvent(event); + setVerifyAllDone((prev) => prev + 1); + } + setVerifyingAll(false); + setVerifyCanceled(false); + + if (options?.showToast) { + let verified = 0; + let failed = 0; + let errors = 0; + let unavailable = 0; + let unchecked = 0; + + for (const event of signedEvents) { + const state = verification()[event.id]; + if (!state) { + unchecked += 1; + continue; + } + switch (state.status) { + case 'verified': + verified += 1; + break; + case 'failed': + failed += 1; + break; + case 'error': + errors += 1; + break; + case 'unavailable': + unavailable += 1; + break; + default: + unchecked += 1; + } + } + + if (failed > 0 || errors > 0) { + showWarning( + 'Signature verification completed', + `Verified ${verified}, failed ${failed}, errors ${errors}, unavailable ${unavailable}.`, + ); + } else { + showSuccess('Signature verification completed', `Verified ${verified} events.`); + } + } + }; + onMount(() => { + setIsMounted(true); fetchAuditEvents(); }); + createEffect(() => { + const current = verificationFilter(); + if (!allowedVerificationFilters.has(current)) { + setVerificationFilter('all'); + } + }); + + createEffect(() => { + const current = successFilter(); + if (!allowedSuccessFilters.has(current)) { + setSuccessFilter('all'); + } + }); + + onCleanup(() => { + setIsMounted(false); + setCancelVerifyAll(true); + const controllers = verifyControllers(); + for (const controller of Object.values(controllers)) { + controller.abort(); + } + }); + const formatTimestamp = (ts: string) => { const date = new Date(ts); return date.toLocaleString(); @@ -78,6 +315,123 @@ export default function AuditLogPanel() { return colors[event] || 'bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-200'; }; + const hasSignedEvents = () => events().some((event) => event.signature); + const hasResumeEvents = () => + events().some((event) => { + if (!event.signature) return false; + const state = verification()[event.id]; + return !state || state.status === 'failed' || state.status === 'error'; + }); + const resumeCount = () => + events().filter((event) => { + if (!event.signature) return false; + const state = verification()[event.id]; + return !state || state.status === 'failed' || state.status === 'error'; + }).length; + const verifyAllLabel = () => { + if (!verifyingAll()) return 'Verify All'; + if (verifyAllTotal() === 0) return 'Verifying…'; + return `Verifying ${verifyAllDone()} of ${verifyAllTotal()}`; + }; + const hasNextPage = () => events().length === pageSize(); + const pageNumber = () => Math.floor(pageOffset() / pageSize()) + 1; + const totalPages = () => Math.max(1, Math.ceil(totalEvents() / pageSize())); + const pageRangeText = () => { + if (totalEvents() === 0) return 'Showing 0 of 0'; + const start = pageOffset() + 1; + const end = Math.min(totalEvents(), pageOffset() + events().length); + return `Showing ${start}-${end} of ${totalEvents()}`; + }; + + const verificationSummary = createMemo(() => { + const summary = { + total: events().length, + signed: 0, + verified: 0, + failed: 0, + error: 0, + unavailable: 0, + unchecked: 0, + }; + + for (const event of events()) { + if (!event.signature) continue; + summary.signed += 1; + const state = verification()[event.id]; + if (!state) { + summary.unchecked += 1; + continue; + } + switch (state.status) { + case 'verified': + summary.verified += 1; + break; + case 'failed': + summary.failed += 1; + break; + case 'error': + summary.error += 1; + break; + case 'unavailable': + summary.unavailable += 1; + break; + default: + summary.unchecked += 1; + } + } + + return summary; + }); + + const activeFilterCount = () => { + let count = 0; + if (eventFilter()) count += 1; + if (userFilter()) count += 1; + if (successFilter() !== 'all') count += 1; + if (verificationFilter() !== 'all') count += 1; + return count; + }; + + const activeFilterChips = () => { + const chips: { label: string; key: 'event' | 'user' | 'success' | 'verification' }[] = []; + if (eventFilter()) chips.push({ label: `Event: ${eventFilter()}`, key: 'event' }); + if (userFilter()) chips.push({ label: `User: ${userFilter()}`, key: 'user' }); + if (successFilter() !== 'all') chips.push({ label: `Success: ${successFilter()}`, key: 'success' }); + if (verificationFilter() !== 'all') chips.push({ label: `Verification: ${verificationFilter()}`, key: 'verification' }); + return chips; + }; + + const filteredEvents = createMemo(() => { + const filter = verificationFilter(); + if (filter === 'all') return events(); + return events().filter((event) => { + if (!event.signature) return false; + const state = verification()[event.id]; + if (!state) { + return filter === 'needs'; + } + if (filter === 'verified') return state.status === 'verified'; + if (filter === 'failed') return state.status === 'failed' || state.status === 'error'; + return false; + }); + }); + + const getVerificationBadge = (state?: VerificationState) => { + if (!state) { + return { label: 'Not checked', class: 'bg-gray-100 text-gray-700 dark:bg-gray-800 dark:text-gray-300' }; + } + switch (state.status) { + case 'verified': + return { label: 'Verified', class: 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200' }; + case 'failed': + return { label: 'Failed', class: 'bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200' }; + case 'error': + return { label: 'Error', class: 'bg-orange-100 text-orange-800 dark:bg-orange-900 dark:text-orange-200' }; + default: + return { label: 'Unavailable', class: 'bg-gray-100 text-gray-700 dark:bg-gray-800 dark:text-gray-300' }; + } + }; + return (
{/* Header */} @@ -91,14 +445,55 @@ export default function AuditLogPanel() {
- +
+ + + + + + + Verification canceled + + +
{/* OSS Notice */} @@ -142,19 +537,164 @@ export default function AuditLogPanel() { /> + + + + + 0}> +
+ + {(chip) => ( + + )} + +
+
+ + + {/* Verification Preferences */} + +
+ setAutoVerifyEnabled(e.currentTarget.checked)} + /> +
+ + 0 disables, max 500 + { + const rect = e.currentTarget.getBoundingClientRect(); + showTooltip( + 'Large verification batches can add load. Use a smaller limit on busy systems.', + rect.left + rect.width / 2, + rect.top, + { align: 'center', direction: 'up', maxWidth: 260 }, + ); + }} + onMouseLeave={() => hideTooltip()} + > + + + +
@@ -173,7 +713,41 @@ export default function AuditLogPanel() { {/* Events Table */} - 0}> + 0}> +
+ Total: {totalEvents()} + Signed: {verificationSummary().signed} + Verified: {verificationSummary().verified} + Failed: {verificationSummary().failed} + Errors: {verificationSummary().error} + Unavailable: {verificationSummary().unavailable} + Unchecked: {verificationSummary().unchecked} + { + const rect = e.currentTarget.getBoundingClientRect(); + const content = [ + 'Unsigned: no signature stored for this event', + 'Unavailable: verification not supported by the logger', + 'Not checked: signature exists but has not been verified', + 'Failed: signature mismatch or tampering detected', + 'Error: verification request failed', + ].join('\n'); + showTooltip(content, rect.left + rect.width / 2, rect.top, { + align: 'center', + direction: 'up', + maxWidth: 320, + }); + }} + onMouseLeave={() => hideTooltip()} + > + + Legend + + + {pageRangeText()} • Page {pageNumber()} of {totalPages()} + +
@@ -184,11 +758,11 @@ export default function AuditLogPanel() { - + - + {(event) => ( @@ -225,10 +825,98 @@ export default function AuditLogPanel() { {/* Empty State */} - +

No audit events found

+ +

+ Try clearing the verification filter. +

+
+
+
+ + {/* Pagination */} + +
+
+ + +
+ + + +
diff --git a/frontend-modern/src/utils/localStorage.ts b/frontend-modern/src/utils/localStorage.ts index 54e3fb9b57..03ea2cfd81 100644 --- a/frontend-modern/src/utils/localStorage.ts +++ b/frontend-modern/src/utils/localStorage.ts @@ -50,6 +50,43 @@ export function createLocalStorageBooleanSignal( ); } +/** + * Creates a number signal that syncs with localStorage + * @param key - The localStorage key + * @param defaultValue - Default value if nothing in localStorage + */ +export function createLocalStorageNumberSignal( + key: string, + defaultValue: number = 0, +): Signal { + return createLocalStorageSignal( + key, + defaultValue, + (val) => { + const parsed = Number(val); + return Number.isFinite(parsed) ? parsed : defaultValue; + }, + (val) => String(val), + ); +} + +/** + * Creates a string signal that syncs with localStorage + * @param key - The localStorage key + * @param defaultValue - Default value if nothing in localStorage + */ +export function createLocalStorageStringSignal( + key: string, + defaultValue: string = '', +): Signal { + return createLocalStorageSignal( + key, + defaultValue, + (val) => String(val), + (val) => String(val), + ); +} + /** * Creates a number signal that syncs with localStorage * @param key - The localStorage key @@ -126,4 +163,14 @@ export const STORAGE_KEYS = { // Feature discovery DISMISSED_FEATURE_TIPS: 'pulse-dismissed-feature-tips', + + // Audit log + AUDIT_AUTO_VERIFY: 'pulse-audit-auto-verify', + AUDIT_AUTO_VERIFY_LIMIT: 'pulse-audit-auto-verify-limit', + AUDIT_PAGE_SIZE: 'pulse-audit-page-size', + AUDIT_PAGE_OFFSET: 'pulse-audit-page-offset', + AUDIT_VERIFICATION_FILTER: 'pulse-audit-verification-filter', + AUDIT_EVENT_FILTER: 'pulse-audit-event-filter', + AUDIT_USER_FILTER: 'pulse-audit-user-filter', + AUDIT_SUCCESS_FILTER: 'pulse-audit-success-filter', } as const; diff --git a/internal/api/audit_handlers.go b/internal/api/audit_handlers.go index c187e94729..f7607ecb4c 100644 --- a/internal/api/audit_handlers.go +++ b/internal/api/audit_handlers.go @@ -69,18 +69,30 @@ func (h *AuditHandlers) HandleListAuditEvents(w http.ResponseWriter, r *http.Req filter.Success = &success } + logger := audit.GetLogger() + // Query events from the current logger - events, err := audit.GetLogger().Query(filter) + events, err := logger.Query(filter) if err != nil { writeErrorResponse(w, http.StatusInternalServerError, "query_failed", "Failed to query audit events", nil) return } + countFilter := filter + countFilter.Limit = 0 + countFilter.Offset = 0 + + totalCount, err := logger.Count(countFilter) + if err != nil { + writeErrorResponse(w, http.StatusInternalServerError, "query_failed", "Failed to count audit events", nil) + return + } + // For OSS (ConsoleLogger), events will be empty // Return a response indicating the feature status response := map[string]interface{}{ "events": events, - "total": len(events), + "total": totalCount, "persistentLogging": len(events) > 0 || isPersistentLogger(), } diff --git a/internal/api/audit_handlers_test.go b/internal/api/audit_handlers_test.go new file mode 100644 index 0000000000..a49dc17a3e --- /dev/null +++ b/internal/api/audit_handlers_test.go @@ -0,0 +1,209 @@ +package api + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/rcourtman/pulse-go-rewrite/pkg/audit" +) + +type verifyResponse struct { + Available bool `json:"available"` + Verified bool `json:"verified"` + Message string `json:"message"` +} + +type testAuditLogger struct { + events []audit.Event + verifyResult bool + queryErr error +} + +func (l *testAuditLogger) Log(event audit.Event) error { + l.events = append(l.events, event) + return nil +} + +func (l *testAuditLogger) Query(filter audit.QueryFilter) ([]audit.Event, error) { + if l.queryErr != nil { + return nil, l.queryErr + } + if filter.ID != "" { + for _, event := range l.events { + if event.ID == filter.ID { + return []audit.Event{event}, nil + } + } + return []audit.Event{}, nil + } + return l.events, nil +} + +func (l *testAuditLogger) Count(filter audit.QueryFilter) (int, error) { + events, err := l.Query(filter) + if err != nil { + return 0, err + } + return len(events), nil +} + +func (l *testAuditLogger) Close() error { + return nil +} + +func (l *testAuditLogger) VerifySignature(event audit.Event) bool { + return l.verifyResult +} + +type testAuditLoggerNoVerify struct { + events []audit.Event +} + +func (l *testAuditLoggerNoVerify) Log(event audit.Event) error { + l.events = append(l.events, event) + return nil +} + +func (l *testAuditLoggerNoVerify) Query(filter audit.QueryFilter) ([]audit.Event, error) { + return l.events, nil +} + +func (l *testAuditLoggerNoVerify) Count(filter audit.QueryFilter) (int, error) { + return len(l.events), nil +} + +func (l *testAuditLoggerNoVerify) Close() error { + return nil +} + +func setAuditLogger(t *testing.T, logger audit.Logger) { + prev := audit.GetLogger() + audit.SetLogger(logger) + t.Cleanup(func() { + audit.SetLogger(prev) + }) +} + +func TestHandleVerifyAuditEvent_InvalidPath(t *testing.T) { + handler := NewAuditHandlers() + + req := httptest.NewRequest(http.MethodGet, "/api/audit/verify", nil) + rec := httptest.NewRecorder() + + handler.HandleVerifyAuditEvent(rec, req) + + if rec.Code != http.StatusNotFound { + t.Fatalf("expected status %d, got %d", http.StatusNotFound, rec.Code) + } +} + +func TestHandleVerifyAuditEvent_NotPersistent(t *testing.T) { + setAuditLogger(t, audit.NewConsoleLogger()) + handler := NewAuditHandlers() + + req := httptest.NewRequest(http.MethodGet, "/api/audit/abc/verify", nil) + rec := httptest.NewRecorder() + + handler.HandleVerifyAuditEvent(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("expected status %d, got %d", http.StatusOK, rec.Code) + } + + var resp verifyResponse + if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil { + t.Fatalf("failed to unmarshal response: %v", err) + } + if resp.Available { + t.Fatalf("expected available to be false") + } + if resp.Message == "" { + t.Fatalf("expected message to be set") + } +} + +func TestHandleVerifyAuditEvent_NoVerifier(t *testing.T) { + setAuditLogger(t, &testAuditLoggerNoVerify{}) + handler := NewAuditHandlers() + + req := httptest.NewRequest(http.MethodGet, "/api/audit/abc/verify", nil) + rec := httptest.NewRecorder() + + handler.HandleVerifyAuditEvent(rec, req) + + if rec.Code != http.StatusNotImplemented { + t.Fatalf("expected status %d, got %d", http.StatusNotImplemented, rec.Code) + } +} + +func TestHandleVerifyAuditEvent_NotFound(t *testing.T) { + setAuditLogger(t, &testAuditLogger{}) + handler := NewAuditHandlers() + + req := httptest.NewRequest(http.MethodGet, "/api/audit/abc/verify", nil) + rec := httptest.NewRecorder() + + handler.HandleVerifyAuditEvent(rec, req) + + if rec.Code != http.StatusNotFound { + t.Fatalf("expected status %d, got %d", http.StatusNotFound, rec.Code) + } +} + +func TestHandleVerifyAuditEvent_Verified(t *testing.T) { + setAuditLogger(t, &testAuditLogger{ + events: []audit.Event{{ID: "abc"}}, + verifyResult: true, + }) + handler := NewAuditHandlers() + + req := httptest.NewRequest(http.MethodGet, "/api/audit/abc/verify", nil) + rec := httptest.NewRecorder() + + handler.HandleVerifyAuditEvent(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("expected status %d, got %d", http.StatusOK, rec.Code) + } + + var resp verifyResponse + if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil { + t.Fatalf("failed to unmarshal response: %v", err) + } + if !resp.Available { + t.Fatalf("expected available to be true") + } + if !resp.Verified { + t.Fatalf("expected verified to be true") + } +} + +func TestHandleVerifyAuditEvent_Failed(t *testing.T) { + setAuditLogger(t, &testAuditLogger{ + events: []audit.Event{{ID: "abc"}}, + verifyResult: false, + }) + handler := NewAuditHandlers() + + req := httptest.NewRequest(http.MethodGet, "/api/audit/abc/verify", nil) + rec := httptest.NewRecorder() + + handler.HandleVerifyAuditEvent(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("expected status %d, got %d", http.StatusOK, rec.Code) + } + + var resp verifyResponse + if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil { + t.Fatalf("failed to unmarshal response: %v", err) + } + if !resp.Available { + t.Fatalf("expected available to be true") + } + if resp.Verified { + t.Fatalf("expected verified to be false") + } +} diff --git a/pkg/audit/audit.go b/pkg/audit/audit.go index 799c78ab03..6f39f6abd9 100644 --- a/pkg/audit/audit.go +++ b/pkg/audit/audit.go @@ -51,6 +51,9 @@ type Logger interface { // Query retrieves audit events matching the filter (optional, may return empty for console logger) Query(filter QueryFilter) ([]Event, error) + // Count returns the number of audit events matching the filter + Count(filter QueryFilter) (int, error) + // Close releases any resources held by the logger Close() error } @@ -150,6 +153,11 @@ func (c *ConsoleLogger) Query(filter QueryFilter) ([]Event, error) { return []Event{}, nil } +// Count returns zero for the console logger. +func (c *ConsoleLogger) Count(filter QueryFilter) (int, error) { + return 0, nil +} + // Close is a no-op for the console logger. func (c *ConsoleLogger) Close() error { return nil diff --git a/pkg/audit/audit_test.go b/pkg/audit/audit_test.go index 329ad339d5..a90bc04d89 100644 --- a/pkg/audit/audit_test.go +++ b/pkg/audit/audit_test.go @@ -58,6 +58,19 @@ func TestConsoleLogger_Query(t *testing.T) { } } +func TestConsoleLogger_Count(t *testing.T) { + logger := NewConsoleLogger() + + count, err := logger.Count(QueryFilter{}) + if err != nil { + t.Errorf("ConsoleLogger.Count() returned error: %v", err) + } + + if count != 0 { + t.Errorf("ConsoleLogger.Count() should return 0, got %d", count) + } +} + func TestConsoleLogger_Close(t *testing.T) { logger := NewConsoleLogger()
User IP DetailsSignedVerification
@@ -212,8 +786,34 @@ export default function AuditLogPanel() { {event.details || '-'} - -}> - + Unsigned} + > + {(() => { + const badge = getVerificationBadge(verification()[event.id]); + return ( +
+ + {badge.label} + + } + > + Verifying… + + +
+ ); + })()}