mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-29 11:47:01 +00:00
feat(audit-log): add configurable retention, export, Auditor role, and enhanced filtering (#258)
- Configurable retention: audit_retention_days setting (1-365 days, default 90) replaces hardcoded 90-day retention, exposed in Settings > Data Retention - Export: one-click CSV/JSON export of filtered audit data via new GET /api/audit-log/export endpoint (capped at 10,000 entries) - Auditor role: read-only role with system:audit permission for viewing and exporting audit logs without admin privileges (Admiral tier) - Enhanced filtering: full-text search across summaries/paths/usernames, date range picker, and expandable row details showing request path, IP address, node ID, and entry ID
This commit is contained in:
@@ -15,6 +15,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
|
|
||||||
### Added
|
### 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)
|
* **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
|
- 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
|
- Node Admin role: full stack and node management but no system settings, user management, or license access
|
||||||
|
|||||||
+58
-8
@@ -832,6 +832,9 @@ const ROLE_PERMISSIONS: Record<UserRole, PermissionAction[]> = {
|
|||||||
viewer: [
|
viewer: [
|
||||||
'stack:read', 'node:read',
|
'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' });
|
res.status(400).json({ error: 'Password must be at least 6 characters' });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const validRoles: UserRole[] = ['admin', 'viewer', 'deployer', 'node-admin'];
|
const validRoles: UserRole[] = ['admin', 'viewer', 'deployer', 'node-admin', 'auditor'];
|
||||||
if (!validRoles.includes(role)) {
|
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;
|
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 db = DatabaseService.getInstance();
|
||||||
const existing = db.getUserByUsername(username);
|
const existing = db.getUserByUsername(username);
|
||||||
@@ -1832,12 +1835,12 @@ app.put('/api/users/:id', authMiddleware, async (req: Request, res: Response): P
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (role !== undefined) {
|
if (role !== undefined) {
|
||||||
const validRoles: UserRole[] = ['admin', 'viewer', 'deployer', 'node-admin'];
|
const validRoles: UserRole[] = ['admin', 'viewer', 'deployer', 'node-admin', 'auditor'];
|
||||||
if (!validRoles.includes(role)) {
|
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;
|
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
|
// Prevent demoting yourself
|
||||||
if (user.username === req.user!.username && role !== user.role) {
|
if (user.username === req.user!.username && role !== user.role) {
|
||||||
res.status(400).json({ error: 'Cannot change your own role' });
|
res.status(400).json({ error: 'Cannot change your own role' });
|
||||||
@@ -3177,6 +3180,7 @@ const ALLOWED_SETTING_KEYS = new Set([
|
|||||||
'template_registry_url',
|
'template_registry_url',
|
||||||
'metrics_retention_hours',
|
'metrics_retention_hours',
|
||||||
'log_retention_days',
|
'log_retention_days',
|
||||||
|
'audit_retention_days',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// Zod schema for bulk PATCH - all keys optional, present keys fully validated
|
// 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' }),
|
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),
|
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),
|
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();
|
}).partial();
|
||||||
|
|
||||||
app.get('/api/settings', async (req: Request, res: Response) => {
|
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) ---
|
// --- Audit Log Routes (Admiral, local-only) ---
|
||||||
|
|
||||||
app.get('/api/audit-log', async (req: Request, res: Response): Promise<void> => {
|
app.get('/api/audit-log', async (req: Request, res: Response): Promise<void> => {
|
||||||
if (!requireAdmin(req, res)) return;
|
|
||||||
if (!requireAdmiral(req, res)) return;
|
if (!requireAdmiral(req, res)) return;
|
||||||
|
if (!requirePermission(req, res, 'system:audit')) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const page = parseInt(req.query.page as string) || 1;
|
const page = parseInt(req.query.page as string) || 1;
|
||||||
const limit = Math.min(parseInt(req.query.limit as string) || 50, 200);
|
const limit = Math.min(parseInt(req.query.limit as string) || 50, 200);
|
||||||
const username = req.query.username as string | undefined;
|
const username = req.query.username as string | undefined;
|
||||||
const method = req.query.method 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 from = req.query.from ? parseInt(req.query.from as string) : undefined;
|
||||||
const to = req.query.to ? parseInt(req.query.to 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);
|
res.json(result);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('[AuditLog] Failed to fetch audit log:', 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<void> =>
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
app.get('/api/audit-log/export', async (req: Request, res: Response): Promise<void> => {
|
||||||
|
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) ---
|
// --- API Token Routes (Admiral, admin-only, local-only) ---
|
||||||
|
|
||||||
app.post('/api/api-tokens', authMiddleware, async (req: Request, res: Response): Promise<void> => {
|
app.post('/api/api-tokens', authMiddleware, async (req: Request, res: Response): Promise<void> => {
|
||||||
|
|||||||
@@ -62,7 +62,7 @@ export interface WebhookExecution {
|
|||||||
|
|
||||||
export type AuthProvider = 'local' | 'ldap' | 'oidc_google' | 'oidc_github' | 'oidc_okta';
|
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 type ResourceType = 'stack' | 'node';
|
||||||
|
|
||||||
export interface User {
|
export interface User {
|
||||||
@@ -1109,6 +1109,7 @@ export class DatabaseService {
|
|||||||
method?: string;
|
method?: string;
|
||||||
from?: number;
|
from?: number;
|
||||||
to?: number;
|
to?: number;
|
||||||
|
search?: string;
|
||||||
} = {}): { entries: AuditLogEntry[]; total: number } {
|
} = {}): { entries: AuditLogEntry[]; total: number } {
|
||||||
const page = filters.page ?? 1;
|
const page = filters.page ?? 1;
|
||||||
const limit = filters.limit ?? 50;
|
const limit = filters.limit ?? 50;
|
||||||
@@ -1133,6 +1134,11 @@ export class DatabaseService {
|
|||||||
conditions.push('timestamp <= ?');
|
conditions.push('timestamp <= ?');
|
||||||
params.push(filters.to);
|
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 ')}` : '';
|
const where = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : '';
|
||||||
|
|
||||||
|
|||||||
@@ -304,7 +304,8 @@ export class MonitorService {
|
|||||||
db.cleanupOldMetrics(isNaN(retentionHours) ? 24 : retentionHours);
|
db.cleanupOldMetrics(isNaN(retentionHours) ? 24 : retentionHours);
|
||||||
const retentionDays = parseInt(settings['log_retention_days'] || '30', 10);
|
const retentionDays = parseInt(settings['log_retention_days'] || '30', 10);
|
||||||
db.cleanupOldNotifications(isNaN(retentionDays) ? 30 : retentionDays);
|
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) {
|
} catch (e) {
|
||||||
console.error('MonitorService: failed to cleanup old data', e);
|
console.error('MonitorService: failed to cleanup old data', e);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
---
|
---
|
||||||
title: Audit Log
|
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.
|
||||||
---
|
---
|
||||||
|
|
||||||
<Note>
|
<Note>
|
||||||
@@ -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") |
|
| **Action** | Human-readable summary (e.g., "Deployed stack: nginx-proxy") |
|
||||||
| **Status** | HTTP response status code |
|
| **Status** | HTTP response status code |
|
||||||
| **Node** | Which node the action targeted |
|
| **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
|
### Example actions tracked
|
||||||
|
|
||||||
@@ -33,25 +35,59 @@ Every `POST`, `PUT`, `DELETE`, and `PATCH` request to the Sencho API is automati
|
|||||||
- License activation/deactivation
|
- License activation/deactivation
|
||||||
- Webhook and notification agent configuration
|
- Webhook and notification agent configuration
|
||||||
- Fleet backup creation, restoration, and deletion
|
- Fleet backup creation, restoration, and deletion
|
||||||
|
- Scheduled task management
|
||||||
|
- Registry credential management
|
||||||
|
|
||||||
## Viewing the audit log
|
## 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.
|
||||||
|
|
||||||
<Frame>
|
<Frame>
|
||||||
<img src="/images/audit-log.png" alt="Audit Log view showing a timeline of actions" />
|
<img src="/images/audit-log/audit-log-overview.png" alt="Audit Log view showing a timeline of actions with search, filters, and export" />
|
||||||
</Frame>
|
</Frame>
|
||||||
|
|
||||||
### 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
|
## Filtering & search
|
||||||
- **Method filter** - Filter by HTTP method (POST, PUT, DELETE, PATCH)
|
|
||||||
|
|
||||||
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
|
## Security at rest
|
||||||
|
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 120 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 122 KiB |
@@ -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 { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
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 { apiFetch } from '@/lib/api';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
|
||||||
interface AuditEntry {
|
interface AuditEntry {
|
||||||
id: number;
|
id: number;
|
||||||
@@ -25,16 +27,32 @@ export function AuditLogView() {
|
|||||||
const [total, setTotal] = useState(0);
|
const [total, setTotal] = useState(0);
|
||||||
const [page, setPage] = useState(1);
|
const [page, setPage] = useState(1);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [usernameFilter, setUsernameFilter] = useState('');
|
const [searchFilter, setSearchFilter] = useState('');
|
||||||
const [methodFilter, setMethodFilter] = useState('all');
|
const [methodFilter, setMethodFilter] = useState('all');
|
||||||
|
const [fromDate, setFromDate] = useState('');
|
||||||
|
const [toDate, setToDate] = useState('');
|
||||||
|
const [expandedId, setExpandedId] = useState<number | null>(null);
|
||||||
const limit = 50;
|
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 () => {
|
const fetchLogs = useCallback(async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
const params = new URLSearchParams({ page: String(page), limit: String(limit) });
|
const params = buildFilterParams();
|
||||||
if (usernameFilter) params.set('username', usernameFilter);
|
params.set('page', String(page));
|
||||||
if (methodFilter !== 'all') params.set('method', methodFilter);
|
params.set('limit', String(limit));
|
||||||
|
|
||||||
const res = await apiFetch(`/audit-log?${params}`, { localOnly: true });
|
const res = await apiFetch(`/audit-log?${params}`, { localOnly: true });
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
@@ -47,7 +65,7 @@ export function AuditLogView() {
|
|||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
}, [page, usernameFilter, methodFilter]);
|
}, [page, buildFilterParams]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchLogs();
|
fetchLogs();
|
||||||
@@ -71,6 +89,30 @@ export function AuditLogView() {
|
|||||||
return 'text-muted-foreground';
|
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 (
|
return (
|
||||||
<div className="flex-1 flex flex-col gap-4 p-6 overflow-auto">
|
<div className="flex-1 flex flex-col gap-4 p-6 overflow-auto">
|
||||||
<Card>
|
<Card>
|
||||||
@@ -80,10 +122,25 @@ export function AuditLogView() {
|
|||||||
<ScrollText className="w-5 h-5" />
|
<ScrollText className="w-5 h-5" />
|
||||||
<CardTitle>Audit Log</CardTitle>
|
<CardTitle>Audit Log</CardTitle>
|
||||||
</div>
|
</div>
|
||||||
<Button variant="outline" size="sm" onClick={fetchLogs} disabled={loading}>
|
<div className="flex items-center gap-2">
|
||||||
<RefreshCw className={`w-4 h-4 mr-2 ${loading ? 'animate-spin' : ''}`} />
|
<DropdownMenu>
|
||||||
Refresh
|
<DropdownMenuTrigger asChild>
|
||||||
</Button>
|
<Button variant="outline" size="sm">
|
||||||
|
<Download className="w-4 h-4 mr-2" />
|
||||||
|
Export
|
||||||
|
<ChevronDown className="w-3 h-3 ml-1" />
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end">
|
||||||
|
<DropdownMenuItem onClick={() => handleExport('csv')}>Export as CSV</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem onClick={() => handleExport('json')}>Export as JSON</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
<Button variant="outline" size="sm" onClick={fetchLogs} disabled={loading}>
|
||||||
|
<RefreshCw className={`w-4 h-4 mr-2 ${loading ? 'animate-spin' : ''}`} />
|
||||||
|
Refresh
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-sm text-muted-foreground mt-1">
|
<p className="text-sm text-muted-foreground mt-1">
|
||||||
Track all mutating actions across your Sencho instance. {total > 0 && `${total} total entries.`}
|
Track all mutating actions across your Sencho instance. {total > 0 && `${total} total entries.`}
|
||||||
@@ -91,13 +148,13 @@ export function AuditLogView() {
|
|||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
{/* Filters */}
|
{/* Filters */}
|
||||||
<div className="flex items-center gap-3 mb-4">
|
<div className="flex items-center gap-3 mb-4 flex-wrap">
|
||||||
<div className="relative flex-1 max-w-xs">
|
<div className="relative flex-1 min-w-[200px] max-w-xs">
|
||||||
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
|
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
|
||||||
<Input
|
<Input
|
||||||
placeholder="Filter by username..."
|
placeholder="Search actions, paths, users..."
|
||||||
value={usernameFilter}
|
value={searchFilter}
|
||||||
onChange={(e) => { setUsernameFilter(e.target.value); setPage(1); }}
|
onChange={(e) => { setSearchFilter(e.target.value); setPage(1); }}
|
||||||
className="pl-8"
|
className="pl-8"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -113,6 +170,20 @@ export function AuditLogView() {
|
|||||||
<SelectItem value="PATCH">PATCH</SelectItem>
|
<SelectItem value="PATCH">PATCH</SelectItem>
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
|
<Input
|
||||||
|
type="date"
|
||||||
|
value={fromDate}
|
||||||
|
onChange={(e) => { setFromDate(e.target.value); setPage(1); }}
|
||||||
|
className="w-[150px]"
|
||||||
|
placeholder="From"
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
type="date"
|
||||||
|
value={toDate}
|
||||||
|
onChange={(e) => { setToDate(e.target.value); setPage(1); }}
|
||||||
|
className="w-[150px]"
|
||||||
|
placeholder="To"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Table */}
|
{/* Table */}
|
||||||
@@ -143,28 +214,57 @@ export function AuditLogView() {
|
|||||||
</TableRow>
|
</TableRow>
|
||||||
) : (
|
) : (
|
||||||
entries.map((entry) => (
|
entries.map((entry) => (
|
||||||
<TableRow key={entry.id}>
|
<Fragment key={entry.id}>
|
||||||
<TableCell className="text-xs text-muted-foreground font-mono">
|
<TableRow
|
||||||
{new Date(entry.timestamp).toLocaleString()}
|
className="cursor-pointer hover:bg-muted/50"
|
||||||
</TableCell>
|
onClick={() => setExpandedId(expandedId === entry.id ? null : entry.id)}
|
||||||
<TableCell className="font-medium text-sm">
|
>
|
||||||
{entry.username}
|
<TableCell className="text-xs text-muted-foreground font-mono">
|
||||||
</TableCell>
|
{new Date(entry.timestamp).toLocaleString()}
|
||||||
<TableCell>
|
</TableCell>
|
||||||
<Badge variant={methodBadgeVariant(entry.method)} className="text-xs font-mono">
|
<TableCell className="font-medium text-sm">
|
||||||
{entry.method}
|
{entry.username}
|
||||||
</Badge>
|
</TableCell>
|
||||||
</TableCell>
|
<TableCell>
|
||||||
<TableCell className="text-sm">
|
<Badge variant={methodBadgeVariant(entry.method)} className="text-xs font-mono">
|
||||||
{entry.summary}
|
{entry.method}
|
||||||
</TableCell>
|
</Badge>
|
||||||
<TableCell className={`text-sm font-mono ${statusColor(entry.status_code)}`}>
|
</TableCell>
|
||||||
{entry.status_code}
|
<TableCell className="text-sm">
|
||||||
</TableCell>
|
{entry.summary}
|
||||||
<TableCell className="text-sm text-muted-foreground">
|
</TableCell>
|
||||||
{entry.node_id ?? '-'}
|
<TableCell className={`text-sm font-mono ${statusColor(entry.status_code)}`}>
|
||||||
</TableCell>
|
{entry.status_code}
|
||||||
</TableRow>
|
</TableCell>
|
||||||
|
<TableCell className="text-sm text-muted-foreground">
|
||||||
|
{entry.node_id ?? '-'}
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
{expandedId === entry.id && (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell colSpan={6} className="bg-muted/30 px-6 py-3">
|
||||||
|
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 text-sm">
|
||||||
|
<div>
|
||||||
|
<span className="text-muted-foreground text-xs block">Request Path</span>
|
||||||
|
<span className="font-mono text-xs">{entry.path}</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="text-muted-foreground text-xs block">IP Address</span>
|
||||||
|
<span className="font-mono text-xs">{entry.ip_address || '-'}</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="text-muted-foreground text-xs block">Node ID</span>
|
||||||
|
<span className="font-mono text-xs">{entry.node_id ?? 'Local'}</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="text-muted-foreground text-xs block">Entry ID</span>
|
||||||
|
<span className="font-mono text-xs">#{entry.id}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
)}
|
||||||
|
</Fragment>
|
||||||
))
|
))
|
||||||
)}
|
)}
|
||||||
</TableBody>
|
</TableBody>
|
||||||
|
|||||||
@@ -167,14 +167,12 @@ export default function EditorLayout() {
|
|||||||
{ value: 'templates', label: 'App Store', icon: CloudDownload },
|
{ value: 'templates', label: 'App Store', icon: CloudDownload },
|
||||||
{ value: 'global-observability', label: 'Logs', icon: Activity },
|
{ value: 'global-observability', label: 'Logs', icon: Activity },
|
||||||
);
|
);
|
||||||
if (isPro && license?.variant === 'team' && isAdmin) {
|
if (isPro && license?.variant === 'team') {
|
||||||
items.push(
|
if (can('system:audit')) items.push({ value: 'audit-log', label: 'Audit', icon: ScrollText });
|
||||||
{ value: 'audit-log', label: 'Audit', icon: ScrollText },
|
if (isAdmin) items.push({ value: 'scheduled-ops', label: 'Schedules', icon: Clock });
|
||||||
{ value: 'scheduled-ops', label: 'Schedules', icon: Clock },
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
return items;
|
return items;
|
||||||
}, [isAdmin, isPro, license?.variant]);
|
}, [isAdmin, isPro, license?.variant, can]);
|
||||||
|
|
||||||
// Only highlight a tab if activeView matches a nav item
|
// Only highlight a tab if activeView matches a nav item
|
||||||
const navTabValue = navItems.some(i => i.value === activeView) ? activeView : undefined;
|
const navTabValue = navItems.some(i => i.value === activeView) ? activeView : undefined;
|
||||||
|
|||||||
@@ -49,6 +49,7 @@ interface PatchableSettings {
|
|||||||
template_registry_url?: string;
|
template_registry_url?: string;
|
||||||
metrics_retention_hours?: string;
|
metrics_retention_hours?: string;
|
||||||
log_retention_days?: 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';
|
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: '',
|
template_registry_url: '',
|
||||||
metrics_retention_hours: '24',
|
metrics_retention_hours: '24',
|
||||||
log_retention_days: '30',
|
log_retention_days: '30',
|
||||||
|
audit_retention_days: '90',
|
||||||
};
|
};
|
||||||
|
|
||||||
function WebhooksSection({ isPro }: { isPro: boolean }) {
|
function WebhooksSection({ isPro }: { isPro: boolean }) {
|
||||||
@@ -630,6 +632,7 @@ function UsersSection() {
|
|||||||
<>
|
<>
|
||||||
<SelectItem value="deployer">Deployer</SelectItem>
|
<SelectItem value="deployer">Deployer</SelectItem>
|
||||||
<SelectItem value="node-admin">Node Admin</SelectItem>
|
<SelectItem value="node-admin">Node Admin</SelectItem>
|
||||||
|
<SelectItem value="auditor">Auditor</SelectItem>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
@@ -873,7 +876,8 @@ export function SettingsModal({ isOpen, onClose }: SettingsModalProps) {
|
|||||||
settings.developer_mode !== serverSettingsRef.current.developer_mode ||
|
settings.developer_mode !== serverSettingsRef.current.developer_mode ||
|
||||||
settings.global_logs_refresh !== serverSettingsRef.current.global_logs_refresh ||
|
settings.global_logs_refresh !== serverSettingsRef.current.global_logs_refresh ||
|
||||||
settings.metrics_retention_hours !== serverSettingsRef.current.metrics_retention_hours ||
|
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(() => {
|
useEffect(() => {
|
||||||
if (isOpen) {
|
if (isOpen) {
|
||||||
@@ -925,6 +929,7 @@ export function SettingsModal({ isOpen, onClose }: SettingsModalProps) {
|
|||||||
developer_mode: (localData.developer_mode as '0' | '1') ?? DEFAULT_SETTINGS.developer_mode,
|
developer_mode: (localData.developer_mode as '0' | '1') ?? DEFAULT_SETTINGS.developer_mode,
|
||||||
metrics_retention_hours: localData.metrics_retention_hours ?? DEFAULT_SETTINGS.metrics_retention_hours,
|
metrics_retention_hours: localData.metrics_retention_hours ?? DEFAULT_SETTINGS.metrics_retention_hours,
|
||||||
log_retention_days: localData.log_retention_days ?? DEFAULT_SETTINGS.log_retention_days,
|
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);
|
setSettings(safe);
|
||||||
serverSettingsRef.current = { ...safe };
|
serverSettingsRef.current = { ...safe };
|
||||||
@@ -980,6 +985,7 @@ export function SettingsModal({ isOpen, onClose }: SettingsModalProps) {
|
|||||||
global_logs_refresh: settings.global_logs_refresh,
|
global_logs_refresh: settings.global_logs_refresh,
|
||||||
metrics_retention_hours: settings.metrics_retention_hours,
|
metrics_retention_hours: settings.metrics_retention_hours,
|
||||||
log_retention_days: settings.log_retention_days,
|
log_retention_days: settings.log_retention_days,
|
||||||
|
audit_retention_days: settings.audit_retention_days,
|
||||||
}, setIsSavingDeveloper, true);
|
}, setIsSavingDeveloper, true);
|
||||||
if (ok) toast.success('Developer settings saved.');
|
if (ok) toast.success('Developer settings saved.');
|
||||||
};
|
};
|
||||||
@@ -1751,6 +1757,26 @@ export function SettingsModal({ isOpen, onClose }: SettingsModalProps) {
|
|||||||
<span className="text-sm text-muted-foreground w-8">days</span>
|
<span className="text-sm text-muted-foreground w-8">days</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{isPro && license?.variant === 'team' && (
|
||||||
|
<div className="flex items-center justify-between gap-4 pt-4 border-t border-border">
|
||||||
|
<div className="space-y-0.5">
|
||||||
|
<Label className="text-base">Audit Log Retention</Label>
|
||||||
|
<p className="text-xs text-muted-foreground">How long to keep audit trail entries.</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 shrink-0">
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
max={365}
|
||||||
|
value={settings.audit_retention_days}
|
||||||
|
onChange={(e) => handleSettingChange('audit_retention_days', e.target.value)}
|
||||||
|
className="w-20"
|
||||||
|
/>
|
||||||
|
<span className="text-sm text-muted-foreground w-8">days</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { createContext, useContext, useState, useEffect, useCallback, type React
|
|||||||
|
|
||||||
type AppStatus = 'loading' | 'needsSetup' | 'notAuthenticated' | 'authenticated';
|
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 =
|
export type PermissionAction =
|
||||||
| 'stack:read' | 'stack:edit' | 'stack:deploy' | 'stack:create' | 'stack:delete'
|
| 'stack:read' | 'stack:edit' | 'stack:deploy' | 'stack:create' | 'stack:delete'
|
||||||
|
|||||||
Reference in New Issue
Block a user