diff --git a/CHANGELOG.md b/CHANGELOG.md index a7e081ca..97d92305 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +* **audit-log:** configurable retention period (1–365 days) via Settings → Developer → Data Retention, replacing the hardcoded 90-day limit +* **audit-log:** one-click CSV and JSON export of the currently filtered audit log dataset (capped at 10,000 entries) +* **rbac:** new Auditor role — read-only access to the audit log with no administrative privileges, ideal for compliance officers and security reviewers +* **audit-log:** full-text search across action summaries, API paths, and usernames +* **audit-log:** date range filter with From/To date pickers in the UI +* **audit-log:** expandable row details showing full request path, IP address, node ID, and entry ID + * **rbac:** introduce Deployer and Node Admin intermediate roles with scoped permissions (Team Pro) - Deployer role: can deploy, restart, stop, and start stacks but cannot edit compose files, delete stacks, or access system settings - Node Admin role: full stack and node management but no system settings, user management, or license access diff --git a/backend/src/index.ts b/backend/src/index.ts index fedf85a6..545072f3 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -832,6 +832,9 @@ const ROLE_PERMISSIONS: Record = { viewer: [ 'stack:read', 'node:read', ], + auditor: [ + 'stack:read', 'node:read', 'system:audit', + ], }; /** @@ -1765,12 +1768,12 @@ app.post('/api/users', authMiddleware, async (req: Request, res: Response): Prom res.status(400).json({ error: 'Password must be at least 6 characters' }); return; } - const validRoles: UserRole[] = ['admin', 'viewer', 'deployer', 'node-admin']; + const validRoles: UserRole[] = ['admin', 'viewer', 'deployer', 'node-admin', 'auditor']; if (!validRoles.includes(role)) { - res.status(400).json({ error: 'Role must be "admin", "viewer", "deployer", or "node-admin"' }); + res.status(400).json({ error: 'Role must be "admin", "viewer", "deployer", "node-admin", or "auditor"' }); return; } - if ((role === 'deployer' || role === 'node-admin') && !requireAdmiral(req, res)) return; + if ((role === 'deployer' || role === 'node-admin' || role === 'auditor') && !requireAdmiral(req, res)) return; const db = DatabaseService.getInstance(); const existing = db.getUserByUsername(username); @@ -1832,12 +1835,12 @@ app.put('/api/users/:id', authMiddleware, async (req: Request, res: Response): P } if (role !== undefined) { - const validRoles: UserRole[] = ['admin', 'viewer', 'deployer', 'node-admin']; + const validRoles: UserRole[] = ['admin', 'viewer', 'deployer', 'node-admin', 'auditor']; if (!validRoles.includes(role)) { - res.status(400).json({ error: 'Role must be "admin", "viewer", "deployer", or "node-admin"' }); + res.status(400).json({ error: 'Role must be "admin", "viewer", "deployer", "node-admin", or "auditor"' }); return; } - if ((role === 'deployer' || role === 'node-admin') && !requireAdmiral(req, res)) return; + if ((role === 'deployer' || role === 'node-admin' || role === 'auditor') && !requireAdmiral(req, res)) return; // Prevent demoting yourself if (user.username === req.user!.username && role !== user.role) { res.status(400).json({ error: 'Cannot change your own role' }); @@ -3177,6 +3180,7 @@ const ALLOWED_SETTING_KEYS = new Set([ 'template_registry_url', 'metrics_retention_hours', 'log_retention_days', + 'audit_retention_days', ]); // Zod schema for bulk PATCH - all keys optional, present keys fully validated @@ -3192,6 +3196,7 @@ const SettingsPatchSchema = z.object({ template_registry_url: z.string().max(2048).refine(v => v === '' || /^https?:\/\/.+/.test(v), { message: 'Must be a valid URL or empty' }), metrics_retention_hours: z.coerce.number().int().min(1).max(8760).transform(String), log_retention_days: z.coerce.number().int().min(1).max(365).transform(String), + audit_retention_days: z.coerce.number().int().min(1).max(365).transform(String), }).partial(); app.get('/api/settings', async (req: Request, res: Response) => { @@ -3485,18 +3490,19 @@ app.post('/api/sso/config/:provider/test', async (req: Request, res: Response): // --- Audit Log Routes (Admiral, local-only) --- app.get('/api/audit-log', async (req: Request, res: Response): Promise => { - if (!requireAdmin(req, res)) return; if (!requireAdmiral(req, res)) return; + if (!requirePermission(req, res, 'system:audit')) return; try { const page = parseInt(req.query.page as string) || 1; const limit = Math.min(parseInt(req.query.limit as string) || 50, 200); const username = req.query.username as string | undefined; const method = req.query.method as string | undefined; + const search = req.query.search as string | undefined; const from = req.query.from ? parseInt(req.query.from as string) : undefined; const to = req.query.to ? parseInt(req.query.to as string) : undefined; - const result = DatabaseService.getInstance().getAuditLogs({ page, limit, username, method, from, to }); + const result = DatabaseService.getInstance().getAuditLogs({ page, limit, username, method, from, to, search }); res.json(result); } catch (error) { console.error('[AuditLog] Failed to fetch audit log:', error); @@ -3504,6 +3510,50 @@ app.get('/api/audit-log', async (req: Request, res: Response): Promise => } }); +app.get('/api/audit-log/export', async (req: Request, res: Response): Promise => { + if (!requireAdmiral(req, res)) return; + if (!requirePermission(req, res, 'system:audit')) return; + + try { + const format = (req.query.format as string) === 'csv' ? 'csv' : 'json'; + const username = req.query.username as string | undefined; + const method = req.query.method as string | undefined; + const search = req.query.search as string | undefined; + const from = req.query.from ? parseInt(req.query.from as string) : undefined; + const to = req.query.to ? parseInt(req.query.to as string) : undefined; + + const result = DatabaseService.getInstance().getAuditLogs({ page: 1, limit: 10000, username, method, from, to, search }); + const timestamp = new Date().toISOString().slice(0, 10); + + if (format === 'json') { + res.setHeader('Content-Type', 'application/json'); + res.setHeader('Content-Disposition', `attachment; filename="audit-log-${timestamp}.json"`); + res.json(result.entries); + } else { + res.setHeader('Content-Type', 'text/csv'); + res.setHeader('Content-Disposition', `attachment; filename="audit-log-${timestamp}.csv"`); + + const csvEscape = (val: string | number | null): string => { + if (val === null || val === undefined) return ''; + const str = String(val); + if (str.includes(',') || str.includes('"') || str.includes('\n')) { + return `"${str.replace(/"/g, '""')}"`; + } + return str; + }; + + const headers = ['id', 'timestamp', 'username', 'method', 'path', 'status_code', 'node_id', 'ip_address', 'summary']; + const rows = result.entries.map(e => + headers.map(h => csvEscape(e[h as keyof typeof e])).join(',') + ); + res.send([headers.join(','), ...rows].join('\n')); + } + } catch (error) { + console.error('[AuditLog] Export failed:', error); + res.status(500).json({ error: 'Failed to export audit log' }); + } +}); + // --- API Token Routes (Admiral, admin-only, local-only) --- app.post('/api/api-tokens', authMiddleware, async (req: Request, res: Response): Promise => { diff --git a/backend/src/services/DatabaseService.ts b/backend/src/services/DatabaseService.ts index 91806ce8..4860ceda 100644 --- a/backend/src/services/DatabaseService.ts +++ b/backend/src/services/DatabaseService.ts @@ -62,7 +62,7 @@ export interface WebhookExecution { export type AuthProvider = 'local' | 'ldap' | 'oidc_google' | 'oidc_github' | 'oidc_okta'; -export type UserRole = 'admin' | 'viewer' | 'deployer' | 'node-admin'; +export type UserRole = 'admin' | 'viewer' | 'deployer' | 'node-admin' | 'auditor'; export type ResourceType = 'stack' | 'node'; export interface User { @@ -1109,6 +1109,7 @@ export class DatabaseService { method?: string; from?: number; to?: number; + search?: string; } = {}): { entries: AuditLogEntry[]; total: number } { const page = filters.page ?? 1; const limit = filters.limit ?? 50; @@ -1133,6 +1134,11 @@ export class DatabaseService { conditions.push('timestamp <= ?'); params.push(filters.to); } + if (filters.search) { + conditions.push('(summary LIKE ? OR path LIKE ? OR username LIKE ?)'); + const term = `%${filters.search}%`; + params.push(term, term, term); + } const where = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : ''; diff --git a/backend/src/services/MonitorService.ts b/backend/src/services/MonitorService.ts index 41559f0a..5612676f 100644 --- a/backend/src/services/MonitorService.ts +++ b/backend/src/services/MonitorService.ts @@ -304,7 +304,8 @@ export class MonitorService { db.cleanupOldMetrics(isNaN(retentionHours) ? 24 : retentionHours); const retentionDays = parseInt(settings['log_retention_days'] || '30', 10); db.cleanupOldNotifications(isNaN(retentionDays) ? 30 : retentionDays); - db.cleanupOldAuditLogs(90); + const auditRetentionDays = parseInt(settings['audit_retention_days'] || '90', 10); + db.cleanupOldAuditLogs(isNaN(auditRetentionDays) ? 90 : auditRetentionDays); } catch (e) { console.error('MonitorService: failed to cleanup old data', e); } diff --git a/docs/features/audit-log.mdx b/docs/features/audit-log.mdx index 44bebbe7..d85a969f 100644 --- a/docs/features/audit-log.mdx +++ b/docs/features/audit-log.mdx @@ -1,6 +1,6 @@ --- title: Audit Log -description: Track all mutating actions across your Sencho instance with a searchable audit trail for team accountability. +description: Track all mutating actions across your Sencho instance with a searchable, exportable audit trail for team accountability. --- @@ -21,6 +21,8 @@ Every `POST`, `PUT`, `DELETE`, and `PATCH` request to the Sencho API is automati | **Action** | Human-readable summary (e.g., "Deployed stack: nginx-proxy") | | **Status** | HTTP response status code | | **Node** | Which node the action targeted | +| **Request Path** | Full API path (visible in expanded detail view) | +| **IP Address** | Client IP address (visible in expanded detail view) | ### Example actions tracked @@ -33,25 +35,59 @@ Every `POST`, `PUT`, `DELETE`, and `PATCH` request to the Sencho API is automati - License activation/deactivation - Webhook and notification agent configuration - Fleet backup creation, restoration, and deletion +- Scheduled task management +- Registry credential management ## Viewing the audit log -Navigate to the **Audit** tab in the sidebar (visible to Admiral admins only). +Navigate to the **Audit** tab in the sidebar. This tab is visible to users with the **Admin** or **Auditor** role on an Admiral license. - Audit Log view showing a timeline of actions + Audit Log view showing a timeline of actions with search, filters, and export -### Filtering +Click any row to expand it and see full request details including the API path, IP address, node ID, and entry ID. -- **Username filter** - Search for actions by a specific user -- **Method filter** - Filter by HTTP method (POST, PUT, DELETE, PATCH) +## Filtering & search -Pagination is built in for navigating large audit histories. +The audit log provides several ways to find specific entries: -## Data retention +- **Full-text search** — Search across action summaries, API paths, and usernames +- **Method filter** — Filter by HTTP method (POST, PUT, DELETE, PATCH) +- **Date range** — Set a start and/or end date to narrow results to a specific time window -Audit log entries are automatically cleaned up after **90 days**. This runs as part of Sencho's periodic maintenance cycle alongside metrics and notification cleanup. +All filters work together and are applied server-side with pagination. + +## Export + +Export the currently filtered audit log dataset as **CSV** or **JSON** using the **Export** dropdown in the header. The export respects all active filters — so you can narrow down to a date range or specific user before exporting. + +Exports are capped at 10,000 entries per download. + +## Auditor role + +The **Auditor** role provides read-only access to the audit log without granting any administrative privileges. Auditors can: + +- View the full audit log +- Search and filter entries +- Export audit data as CSV or JSON +- View stacks and nodes (read-only) + +Auditors **cannot** modify settings, manage users, deploy stacks, or perform any other administrative actions. This role is ideal for compliance officers, security reviewers, or team leads who need visibility into system activity without operational access. + +To create an Auditor user, go to **Settings → Users** and select the **Auditor** role when creating a new user. + +## Configurable data retention + +Audit log entries are automatically cleaned up based on your configured retention period. The default is **90 days**. + +To change the retention period: + +1. Go to **Settings → Developer → Data Retention** +2. Set the **Audit Log Retention** value (1–365 days) +3. Click **Save Developer Settings** + +Cleanup runs automatically as part of Sencho's periodic maintenance cycle. ## Security at rest diff --git a/docs/images/audit-log/audit-log-expanded.png b/docs/images/audit-log/audit-log-expanded.png new file mode 100644 index 00000000..c7dd66d7 Binary files /dev/null and b/docs/images/audit-log/audit-log-expanded.png differ diff --git a/docs/images/audit-log/audit-log-overview.png b/docs/images/audit-log/audit-log-overview.png new file mode 100644 index 00000000..4fb26472 Binary files /dev/null and b/docs/images/audit-log/audit-log-overview.png differ diff --git a/frontend/src/components/AuditLogView.tsx b/frontend/src/components/AuditLogView.tsx index 62eda9ab..08aafec4 100644 --- a/frontend/src/components/AuditLogView.tsx +++ b/frontend/src/components/AuditLogView.tsx @@ -1,12 +1,14 @@ -import { useState, useEffect, useCallback } from 'react'; +import { useState, useEffect, useCallback, Fragment } from 'react'; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; import { Badge } from '@/components/ui/badge'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; -import { ChevronLeft, ChevronRight, Search, ScrollText, RefreshCw } from 'lucide-react'; +import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '@/components/ui/dropdown-menu'; +import { ChevronLeft, ChevronRight, Search, ScrollText, RefreshCw, Download, ChevronDown } from 'lucide-react'; import { apiFetch } from '@/lib/api'; +import { toast } from 'sonner'; interface AuditEntry { id: number; @@ -25,16 +27,32 @@ export function AuditLogView() { const [total, setTotal] = useState(0); const [page, setPage] = useState(1); const [loading, setLoading] = useState(true); - const [usernameFilter, setUsernameFilter] = useState(''); + const [searchFilter, setSearchFilter] = useState(''); const [methodFilter, setMethodFilter] = useState('all'); + const [fromDate, setFromDate] = useState(''); + const [toDate, setToDate] = useState(''); + const [expandedId, setExpandedId] = useState(null); const limit = 50; + const buildFilterParams = useCallback(() => { + const params = new URLSearchParams(); + if (searchFilter) params.set('search', searchFilter); + if (methodFilter !== 'all') params.set('method', methodFilter); + if (fromDate) params.set('from', String(new Date(fromDate).getTime())); + if (toDate) { + const end = new Date(toDate); + end.setHours(23, 59, 59, 999); + params.set('to', String(end.getTime())); + } + return params; + }, [searchFilter, methodFilter, fromDate, toDate]); + const fetchLogs = useCallback(async () => { setLoading(true); try { - const params = new URLSearchParams({ page: String(page), limit: String(limit) }); - if (usernameFilter) params.set('username', usernameFilter); - if (methodFilter !== 'all') params.set('method', methodFilter); + const params = buildFilterParams(); + params.set('page', String(page)); + params.set('limit', String(limit)); const res = await apiFetch(`/audit-log?${params}`, { localOnly: true }); if (res.ok) { @@ -47,7 +65,7 @@ export function AuditLogView() { } finally { setLoading(false); } - }, [page, usernameFilter, methodFilter]); + }, [page, buildFilterParams]); useEffect(() => { fetchLogs(); @@ -71,6 +89,30 @@ export function AuditLogView() { return 'text-muted-foreground'; }; + const handleExport = async (format: 'csv' | 'json') => { + try { + const params = buildFilterParams(); + params.set('format', format); + const res = await apiFetch(`/audit-log/export?${params}`, { localOnly: true }); + if (!res.ok) { + const err = await res.json().catch(() => ({})); + toast.error(err?.error || err?.message || 'Export failed.'); + return; + } + const blob = await res.blob(); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = `audit-log-${new Date().toISOString().slice(0, 10)}.${format === 'csv' ? 'csv' : 'json'}`; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + URL.revokeObjectURL(url); + } catch { + toast.error('Export failed.'); + } + }; + return (
@@ -80,10 +122,25 @@ export function AuditLogView() { Audit Log
- +
+ + + + + + handleExport('csv')}>Export as CSV + handleExport('json')}>Export as JSON + + + +

Track all mutating actions across your Sencho instance. {total > 0 && `${total} total entries.`} @@ -91,13 +148,13 @@ export function AuditLogView() { {/* Filters */} -

-
+
+
{ setUsernameFilter(e.target.value); setPage(1); }} + placeholder="Search actions, paths, users..." + value={searchFilter} + onChange={(e) => { setSearchFilter(e.target.value); setPage(1); }} className="pl-8" />
@@ -113,6 +170,20 @@ export function AuditLogView() { PATCH + { setFromDate(e.target.value); setPage(1); }} + className="w-[150px]" + placeholder="From" + /> + { setToDate(e.target.value); setPage(1); }} + className="w-[150px]" + placeholder="To" + />
{/* Table */} @@ -143,28 +214,57 @@ export function AuditLogView() { ) : ( entries.map((entry) => ( - - - {new Date(entry.timestamp).toLocaleString()} - - - {entry.username} - - - - {entry.method} - - - - {entry.summary} - - - {entry.status_code} - - - {entry.node_id ?? '-'} - - + + setExpandedId(expandedId === entry.id ? null : entry.id)} + > + + {new Date(entry.timestamp).toLocaleString()} + + + {entry.username} + + + + {entry.method} + + + + {entry.summary} + + + {entry.status_code} + + + {entry.node_id ?? '-'} + + + {expandedId === entry.id && ( + + +
+
+ Request Path + {entry.path} +
+
+ IP Address + {entry.ip_address || '-'} +
+
+ Node ID + {entry.node_id ?? 'Local'} +
+
+ Entry ID + #{entry.id} +
+
+
+
+ )} +
)) )} diff --git a/frontend/src/components/EditorLayout.tsx b/frontend/src/components/EditorLayout.tsx index e69c35a9..70af9dc8 100644 --- a/frontend/src/components/EditorLayout.tsx +++ b/frontend/src/components/EditorLayout.tsx @@ -167,14 +167,12 @@ export default function EditorLayout() { { value: 'templates', label: 'App Store', icon: CloudDownload }, { value: 'global-observability', label: 'Logs', icon: Activity }, ); - if (isPro && license?.variant === 'team' && isAdmin) { - items.push( - { value: 'audit-log', label: 'Audit', icon: ScrollText }, - { value: 'scheduled-ops', label: 'Schedules', icon: Clock }, - ); + if (isPro && license?.variant === 'team') { + if (can('system:audit')) items.push({ value: 'audit-log', label: 'Audit', icon: ScrollText }); + if (isAdmin) items.push({ value: 'scheduled-ops', label: 'Schedules', icon: Clock }); } return items; - }, [isAdmin, isPro, license?.variant]); + }, [isAdmin, isPro, license?.variant, can]); // Only highlight a tab if activeView matches a nav item const navTabValue = navItems.some(i => i.value === activeView) ? activeView : undefined; diff --git a/frontend/src/components/SettingsModal.tsx b/frontend/src/components/SettingsModal.tsx index 7afdb2a6..03be48d7 100644 --- a/frontend/src/components/SettingsModal.tsx +++ b/frontend/src/components/SettingsModal.tsx @@ -49,6 +49,7 @@ interface PatchableSettings { template_registry_url?: string; metrics_retention_hours?: string; log_retention_days?: string; + audit_retention_days?: string; } type SectionId = 'account' | 'license' | 'users' | 'sso' | 'api-tokens' | 'registries' | 'system' | 'notifications' | 'webhooks' | 'developer' | 'nodes' | 'appstore' | 'support' | 'about'; @@ -91,6 +92,7 @@ const DEFAULT_SETTINGS: PatchableSettings = { template_registry_url: '', metrics_retention_hours: '24', log_retention_days: '30', + audit_retention_days: '90', }; function WebhooksSection({ isPro }: { isPro: boolean }) { @@ -630,6 +632,7 @@ function UsersSection() { <> Deployer Node Admin + Auditor )} @@ -873,7 +876,8 @@ export function SettingsModal({ isOpen, onClose }: SettingsModalProps) { settings.developer_mode !== serverSettingsRef.current.developer_mode || settings.global_logs_refresh !== serverSettingsRef.current.global_logs_refresh || settings.metrics_retention_hours !== serverSettingsRef.current.metrics_retention_hours || - settings.log_retention_days !== serverSettingsRef.current.log_retention_days; + settings.log_retention_days !== serverSettingsRef.current.log_retention_days || + settings.audit_retention_days !== serverSettingsRef.current.audit_retention_days; useEffect(() => { if (isOpen) { @@ -925,6 +929,7 @@ export function SettingsModal({ isOpen, onClose }: SettingsModalProps) { developer_mode: (localData.developer_mode as '0' | '1') ?? DEFAULT_SETTINGS.developer_mode, metrics_retention_hours: localData.metrics_retention_hours ?? DEFAULT_SETTINGS.metrics_retention_hours, log_retention_days: localData.log_retention_days ?? DEFAULT_SETTINGS.log_retention_days, + audit_retention_days: localData.audit_retention_days ?? DEFAULT_SETTINGS.audit_retention_days, }; setSettings(safe); serverSettingsRef.current = { ...safe }; @@ -980,6 +985,7 @@ export function SettingsModal({ isOpen, onClose }: SettingsModalProps) { global_logs_refresh: settings.global_logs_refresh, metrics_retention_hours: settings.metrics_retention_hours, log_retention_days: settings.log_retention_days, + audit_retention_days: settings.audit_retention_days, }, setIsSavingDeveloper, true); if (ok) toast.success('Developer settings saved.'); }; @@ -1751,6 +1757,26 @@ export function SettingsModal({ isOpen, onClose }: SettingsModalProps) { days
+ + {isPro && license?.variant === 'team' && ( +
+
+ +

How long to keep audit trail entries.

+
+
+ handleSettingChange('audit_retention_days', e.target.value)} + className="w-20" + /> + days +
+
+ )} diff --git a/frontend/src/context/AuthContext.tsx b/frontend/src/context/AuthContext.tsx index 1841e8fa..2f87ebde 100644 --- a/frontend/src/context/AuthContext.tsx +++ b/frontend/src/context/AuthContext.tsx @@ -2,7 +2,7 @@ import { createContext, useContext, useState, useEffect, useCallback, type React type AppStatus = 'loading' | 'needsSetup' | 'notAuthenticated' | 'authenticated'; -export type UserRole = 'admin' | 'viewer' | 'deployer' | 'node-admin'; +export type UserRole = 'admin' | 'viewer' | 'deployer' | 'node-admin' | 'auditor'; export type PermissionAction = | 'stack:read' | 'stack:edit' | 'stack:deploy' | 'stack:create' | 'stack:delete'