diff --git a/backend/src/__tests__/audit-log.test.ts b/backend/src/__tests__/audit-log.test.ts index 7c7fbd9d..e3baa3ca 100644 --- a/backend/src/__tests__/audit-log.test.ts +++ b/backend/src/__tests__/audit-log.test.ts @@ -3,7 +3,8 @@ * - getAuditSummary() pure function (wildcard, prefix, fallback) * - DatabaseService audit log CRUD (insert, query, filter, paginate, cleanup) * - API endpoints (GET /api/audit-log, GET /api/audit-log/export) - * - Permission gating (Admiral + system:audit required) + * - Tier gating: the list endpoint is Community (recent-activity window) + + * system:audit; export and stats stay Admiral (paid) * - Audit middleware integration (logs mutating requests, skips GETs) */ import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest'; @@ -412,14 +413,15 @@ describe('DatabaseService audit methods', () => { // ---- API endpoint tests ---- describe('GET /api/audit-log', () => { - it('returns 403 without a paid license', async () => { + it('returns 200 for a Community admin (recent-activity window, no tier gate)', async () => { const { LicenseService } = await import('../services/LicenseService'); vi.spyOn(LicenseService.getInstance(), 'getTier').mockReturnValueOnce('community'); const res = await request(app) .get('/api/audit-log') .set('Authorization', `Bearer ${adminToken()}`); - expect(res.status).toBe(403); + expect(res.status).toBe(200); + expect(Array.isArray(res.body.entries)).toBe(true); }); it('returns 403 for viewer role (no system:audit permission)', async () => { @@ -563,6 +565,87 @@ describe('GET /api/audit-log', () => { }); }); +describe('GET /api/audit-log (Community recent-activity window)', () => { + const windowUser = 'communitywindowuser'; + + it('clamps Community results to the last 14 days', async () => { + const db = DatabaseService.getInstance(); + const now = Date.now(); + db.insertAuditLog({ + timestamp: now - 20 * 24 * 60 * 60 * 1000, + username: windowUser, method: 'POST', path: '/api/stacks/old', + status_code: 200, node_id: null, ip_address: '127.0.0.1', summary: 'old windowed entry', + }); + db.insertAuditLog({ + timestamp: now - 1 * 24 * 60 * 60 * 1000, + username: windowUser, method: 'POST', path: '/api/stacks/recent', + status_code: 200, node_id: null, ip_address: '127.0.0.1', summary: 'recent windowed entry', + }); + + const { LicenseService } = await import('../services/LicenseService'); + vi.spyOn(LicenseService.getInstance(), 'getTier').mockReturnValueOnce('community'); + const res = await request(app) + .get(`/api/audit-log?search=${windowUser}&limit=100`) + .set('Authorization', `Bearer ${adminToken()}`); + + expect(res.status).toBe(200); + const summaries = res.body.entries.map((e: { summary: string }) => e.summary); + expect(summaries).toContain('recent windowed entry'); + expect(summaries).not.toContain('old windowed entry'); + }); + + it('clamps even when a Community caller passes an explicit from older than the window', async () => { + const { LicenseService } = await import('../services/LicenseService'); + vi.spyOn(LicenseService.getInstance(), 'getTier').mockReturnValueOnce('community'); + const explicitOldFrom = Date.now() - 30 * 24 * 60 * 60 * 1000; + const res = await request(app) + .get(`/api/audit-log?search=${windowUser}&from=${explicitOldFrom}&limit=100`) + .set('Authorization', `Bearer ${adminToken()}`); + + expect(res.status).toBe(200); + const summaries = res.body.entries.map((e: { summary: string }) => e.summary); + expect(summaries).toContain('recent windowed entry'); + expect(summaries).not.toContain('old windowed entry'); + }); + + it('does not let a non-numeric from lift the Community window clamp', async () => { + const { LicenseService } = await import('../services/LicenseService'); + vi.spyOn(LicenseService.getInstance(), 'getTier').mockReturnValueOnce('community'); + const res = await request(app) + .get(`/api/audit-log?search=${windowUser}&from=abc&limit=100`) + .set('Authorization', `Bearer ${adminToken()}`); + + expect(res.status).toBe(200); + const summaries = res.body.entries.map((e: { summary: string }) => e.summary); + expect(summaries).toContain('recent windowed entry'); + expect(summaries).not.toContain('old windowed entry'); + }); + + it('paid tier still sees entries older than the Community window', async () => { + // The suite default mock is the paid tier (no clamp). + const res = await request(app) + .get(`/api/audit-log?search=${windowUser}&limit=100`) + .set('Authorization', `Bearer ${adminToken()}`); + + expect(res.status).toBe(200); + const summaries = res.body.entries.map((e: { summary: string }) => e.summary); + expect(summaries).toContain('old windowed entry'); + }); + + it('does not annotate anomalies for Community even when with_anomalies=1', async () => { + const { LicenseService } = await import('../services/LicenseService'); + vi.spyOn(LicenseService.getInstance(), 'getTier').mockReturnValueOnce('community'); + const res = await request(app) + .get('/api/audit-log?with_anomalies=1&limit=5') + .set('Authorization', `Bearer ${adminToken()}`); + + expect(res.status).toBe(200); + for (const entry of res.body.entries) { + expect(entry.flags).toBeUndefined(); + } + }); +}); + describe('GET /api/audit-log/stats', () => { it('returns 403 without a paid license', async () => { const { LicenseService } = await import('../services/LicenseService'); diff --git a/backend/src/__tests__/distributed-license.test.ts b/backend/src/__tests__/distributed-license.test.ts index 958c4456..478f3a68 100644 --- a/backend/src/__tests__/distributed-license.test.ts +++ b/backend/src/__tests__/distributed-license.test.ts @@ -24,10 +24,12 @@ const signToken = (payload: Record, expiresIn: string | number jwt.sign(payload, TEST_JWT_SECRET, { expiresIn: expiresIn as jwt.SignOptions['expiresIn'] }); // We need a Paid-gated route that doesn't depend on Docker or remote nodes. -// /api/webhooks/... triggers are public, but the management routes are -// admin-gated, so we use a Paid-gated route that just reads from the DB. -// /api/audit-log is paid-gated and reads from the DB. -const PAID_ROUTE = '/api/audit-log'; +// The bare /api/audit-log list is Community (recent-activity window), but +// /api/audit-log/stats keeps requirePaid and reads from the DB, so it is a +// stable stand-in for exercising the distributed-license trust chain. The +// node_proxy and admin session tokens used below both satisfy its +// system:audit permission gate. +const PAID_ROUTE = '/api/audit-log/stats'; // ─── authMiddleware: proxyTier propagation ────────────────────────────────── diff --git a/backend/src/routes/auditLog.ts b/backend/src/routes/auditLog.ts index 37a0c329..d4780b10 100644 --- a/backend/src/routes/auditLog.ts +++ b/backend/src/routes/auditLog.ts @@ -1,27 +1,41 @@ import { Router, type Request, type Response } from 'express'; import { DatabaseService, AUDIT_ANOMALY_HISTORY_CAP } from '../services/DatabaseService'; import { annotateEntries, computeAuditStats, HISTORY_WINDOW_MS } from '../services/AuditAnomalyService'; -import { requirePaid } from '../middleware/tierGates'; +import { requirePaid, effectiveTier } from '../middleware/tierGates'; import { requirePermission } from '../middleware/permissions'; import { isDebugEnabled } from '../utils/debug'; import { escapeCsvField } from '../utils/csv'; import { sanitizeForLog } from '../utils/safeLog'; +// Community sees a rolling recent-activity window; full retention, export, and +// anomaly annotation are paid. The list endpoint clamps `from` to this window +// for unpaid tiers so older entries are not reachable through the API. +const COMMUNITY_AUDIT_WINDOW_MS = 14 * 24 * 60 * 60 * 1000; + export const auditLogRouter = Router(); auditLogRouter.get('/', async (req: Request, res: Response): Promise => { - if (!requirePaid(req, res)) return; if (!requirePermission(req, res, 'system:audit')) return; try { + const isPaid = effectiveTier(req) === 'paid'; const page = Math.max(1, parseInt(req.query.page as string) || 1); const limit = Math.min(Math.max(1, 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; + // Normalize a non-numeric `from` (e.g. ?from=abc) to undefined. A raw NaN + // would survive Math.max() below and then be dropped as falsy at the SQL + // boundary, which would silently lift the Community window clamp. + const parsedFrom = req.query.from ? parseInt(req.query.from as string, 10) : undefined; + let from = Number.isFinite(parsedFrom) ? parsedFrom : undefined; const to = req.query.to ? parseInt(req.query.to as string) : undefined; - const withAnomalies = req.query.with_anomalies === '1'; + const withAnomalies = isPaid && req.query.with_anomalies === '1'; + + if (!isPaid) { + const windowStart = Date.now() - COMMUNITY_AUDIT_WINDOW_MS; + from = from === undefined ? windowStart : Math.max(from, windowStart); + } if (isDebugEnabled()) { console.log(`[Audit:diag] Query: page=${page} limit=${limit} username=${sanitizeForLog(username || '-')} method=${sanitizeForLog(method || '-')} search=${sanitizeForLog(search || '-')}`); diff --git a/frontend/src/components/AuditLogView.tsx b/frontend/src/components/AuditLogView.tsx index 148add76..d41d0257 100644 --- a/frontend/src/components/AuditLogView.tsx +++ b/frontend/src/components/AuditLogView.tsx @@ -11,6 +11,7 @@ import { Sparkline } from '@/components/ui/sparkline'; import { ChevronLeft, ChevronRight, Search, ScrollText, RefreshCw, Download, ChevronDown, Activity, Table2 } from 'lucide-react'; import { apiFetch } from '@/lib/api'; import { toast } from '@/components/ui/toast-store'; +import { useLicense } from '@/context/LicenseContext'; type AnomalyFlag = 'unusual_hour' | 'new_ip' | 'first_seen_actor'; @@ -109,6 +110,11 @@ function isExpiredSession(err: unknown): boolean { } export function AuditLogView() { + // Community gets the recent-activity stream; CSV/JSON export, the stat + // tiles, and per-row anomaly annotation are paid. The backend clamps the + // list to a 14-day window for unpaid tiers, so this flag only governs the + // export, stats, and anomaly-annotation affordances, not the list itself. + const { isPaid } = useLicense(); const [entries, setEntries] = useState([]); const [total, setTotal] = useState(0); const [page, setPage] = useState(1); @@ -151,7 +157,7 @@ export function AuditLogView() { const params = buildFilterParams(); params.set('page', String(page)); params.set('limit', String(limit)); - params.set('with_anomalies', '1'); + if (isPaid) params.set('with_anomalies', '1'); const res = await apiFetch(`/audit-log?${params}`, { localOnly: true }); if (res.ok) { @@ -170,7 +176,7 @@ export function AuditLogView() { } finally { setLoading(false); } - }, [page, buildFilterParams]); + }, [page, buildFilterParams, isPaid]); const fetchStats = useCallback(async () => { try { @@ -194,8 +200,8 @@ export function AuditLogView() { }, [fetchLogs]); useEffect(() => { - if (view === 'stream') fetchStats(); - }, [view, fetchStats]); + if (view === 'stream' && isPaid) fetchStats(); + }, [view, fetchStats, isPaid]); const totalPages = Math.max(1, Math.ceil(total / limit)); @@ -284,20 +290,22 @@ export function AuditLogView() { Table - - - - - - handleExport('csv')}>Export as CSV - handleExport('json')}>Export as JSON - - - + + + handleExport('csv')}>Export as CSV + handleExport('json')}>Export as JSON + + + )} + @@ -311,6 +319,7 @@ export function AuditLogView() { {view === 'stream' ? ( void; } -function StreamView({ stats, loading, groups, now, page, totalPages, onPage }: StreamViewProps) { +function StreamView({ stats, showStats, loading, groups, now, page, totalPages, onPage }: StreamViewProps) { const tiles: AuditStatTile[] = stats ? [stats.events_24h, stats.actors_24h, stats.failure_rate, stats.unusual_hour] : []; @@ -477,6 +487,7 @@ function StreamView({ stats, loading, groups, now, page, totalPages, onPage }: S return (
+ {showStats && (
{tiles.length === 0 ? ( Array.from({ length: 4 }).map((_, i) => ( @@ -522,6 +533,7 @@ function StreamView({ stats, loading, groups, now, page, totalPages, onPage }: S )) )}
+ )} {loading ? (
Loading audit stream...