feat: give Community a 14-day in-app audit log (#1337)

* feat: give Community a 14-day in-app audit log

The audit-log list view is now reachable on the Community tier, scoped to a
rolling 14-day recent-activity window, with the existing filters (actor,
method, search, date range). Full retention, CSV/JSON export, and per-row
anomaly annotation remain on the paid tier.

Backend clamps the list window for unpaid tiers and forces anomaly annotation
off; a non-numeric from query value is normalized so it cannot lift the clamp.
The stats and export endpoints keep their paid gate. The frontend hides the
export control and the anomaly stat tiles for Community and omits the anomaly
request. Audit entries are still captured for every tier, so only read access
changes.

* test: repoint distributed-license stand-in to a still-paid audit route

The distributed-license trust-chain test borrowed GET /api/audit-log as a
paid-gated, DB-reading stand-in. That route is now Community-accessible, so its
six 403 PAID_REQUIRED assertions flipped to 200. Point the stand-in at
GET /api/audit-log/stats, which keeps requirePaid plus the system:audit
permission gate and is satisfied by both token types the test uses.
This commit is contained in:
Anso
2026-06-08 08:58:49 -04:00
committed by GitHub
parent 54119be0c2
commit ea1267b32f
4 changed files with 141 additions and 30 deletions
+86 -3
View File
@@ -3,7 +3,8 @@
* - getAuditSummary() pure function (wildcard, prefix, fallback) * - getAuditSummary() pure function (wildcard, prefix, fallback)
* - DatabaseService audit log CRUD (insert, query, filter, paginate, cleanup) * - DatabaseService audit log CRUD (insert, query, filter, paginate, cleanup)
* - API endpoints (GET /api/audit-log, GET /api/audit-log/export) * - 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) * - Audit middleware integration (logs mutating requests, skips GETs)
*/ */
import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest'; import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest';
@@ -412,14 +413,15 @@ describe('DatabaseService audit methods', () => {
// ---- API endpoint tests ---- // ---- API endpoint tests ----
describe('GET /api/audit-log', () => { 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'); const { LicenseService } = await import('../services/LicenseService');
vi.spyOn(LicenseService.getInstance(), 'getTier').mockReturnValueOnce('community'); vi.spyOn(LicenseService.getInstance(), 'getTier').mockReturnValueOnce('community');
const res = await request(app) const res = await request(app)
.get('/api/audit-log') .get('/api/audit-log')
.set('Authorization', `Bearer ${adminToken()}`); .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 () => { 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', () => { describe('GET /api/audit-log/stats', () => {
it('returns 403 without a paid license', async () => { it('returns 403 without a paid license', async () => {
const { LicenseService } = await import('../services/LicenseService'); const { LicenseService } = await import('../services/LicenseService');
@@ -24,10 +24,12 @@ const signToken = (payload: Record<string, unknown>, expiresIn: string | number
jwt.sign(payload, TEST_JWT_SECRET, { expiresIn: expiresIn as jwt.SignOptions['expiresIn'] }); 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. // 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 // The bare /api/audit-log list is Community (recent-activity window), but
// admin-gated, so we use a Paid-gated route that just reads from the DB. // /api/audit-log/stats keeps requirePaid and reads from the DB, so it is a
// /api/audit-log is paid-gated and reads from the DB. // stable stand-in for exercising the distributed-license trust chain. The
const PAID_ROUTE = '/api/audit-log'; // 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 ────────────────────────────────── // ─── authMiddleware: proxyTier propagation ──────────────────────────────────
+18 -4
View File
@@ -1,27 +1,41 @@
import { Router, type Request, type Response } from 'express'; import { Router, type Request, type Response } from 'express';
import { DatabaseService, AUDIT_ANOMALY_HISTORY_CAP } from '../services/DatabaseService'; import { DatabaseService, AUDIT_ANOMALY_HISTORY_CAP } from '../services/DatabaseService';
import { annotateEntries, computeAuditStats, HISTORY_WINDOW_MS } from '../services/AuditAnomalyService'; 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 { requirePermission } from '../middleware/permissions';
import { isDebugEnabled } from '../utils/debug'; import { isDebugEnabled } from '../utils/debug';
import { escapeCsvField } from '../utils/csv'; import { escapeCsvField } from '../utils/csv';
import { sanitizeForLog } from '../utils/safeLog'; 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(); export const auditLogRouter = Router();
auditLogRouter.get('/', async (req: Request, res: Response): Promise<void> => { auditLogRouter.get('/', async (req: Request, res: Response): Promise<void> => {
if (!requirePaid(req, res)) return;
if (!requirePermission(req, res, 'system:audit')) return; if (!requirePermission(req, res, 'system:audit')) return;
try { try {
const isPaid = effectiveTier(req) === 'paid';
const page = Math.max(1, parseInt(req.query.page as string) || 1); 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 limit = Math.min(Math.max(1, 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 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 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()) { if (isDebugEnabled()) {
console.log(`[Audit:diag] Query: page=${page} limit=${limit} username=${sanitizeForLog(username || '-')} method=${sanitizeForLog(method || '-')} search=${sanitizeForLog(search || '-')}`); console.log(`[Audit:diag] Query: page=${page} limit=${limit} username=${sanitizeForLog(username || '-')} method=${sanitizeForLog(method || '-')} search=${sanitizeForLog(search || '-')}`);
+31 -19
View File
@@ -11,6 +11,7 @@ import { Sparkline } from '@/components/ui/sparkline';
import { ChevronLeft, ChevronRight, Search, ScrollText, RefreshCw, Download, ChevronDown, Activity, Table2 } from 'lucide-react'; import { ChevronLeft, ChevronRight, Search, ScrollText, RefreshCw, Download, ChevronDown, Activity, Table2 } from 'lucide-react';
import { apiFetch } from '@/lib/api'; import { apiFetch } from '@/lib/api';
import { toast } from '@/components/ui/toast-store'; import { toast } from '@/components/ui/toast-store';
import { useLicense } from '@/context/LicenseContext';
type AnomalyFlag = 'unusual_hour' | 'new_ip' | 'first_seen_actor'; type AnomalyFlag = 'unusual_hour' | 'new_ip' | 'first_seen_actor';
@@ -109,6 +110,11 @@ function isExpiredSession(err: unknown): boolean {
} }
export function AuditLogView() { 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<AuditEntry[]>([]); const [entries, setEntries] = useState<AuditEntry[]>([]);
const [total, setTotal] = useState(0); const [total, setTotal] = useState(0);
const [page, setPage] = useState(1); const [page, setPage] = useState(1);
@@ -151,7 +157,7 @@ export function AuditLogView() {
const params = buildFilterParams(); const params = buildFilterParams();
params.set('page', String(page)); params.set('page', String(page));
params.set('limit', String(limit)); 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 }); const res = await apiFetch(`/audit-log?${params}`, { localOnly: true });
if (res.ok) { if (res.ok) {
@@ -170,7 +176,7 @@ export function AuditLogView() {
} finally { } finally {
setLoading(false); setLoading(false);
} }
}, [page, buildFilterParams]); }, [page, buildFilterParams, isPaid]);
const fetchStats = useCallback(async () => { const fetchStats = useCallback(async () => {
try { try {
@@ -194,8 +200,8 @@ export function AuditLogView() {
}, [fetchLogs]); }, [fetchLogs]);
useEffect(() => { useEffect(() => {
if (view === 'stream') fetchStats(); if (view === 'stream' && isPaid) fetchStats();
}, [view, fetchStats]); }, [view, fetchStats, isPaid]);
const totalPages = Math.max(1, Math.ceil(total / limit)); const totalPages = Math.max(1, Math.ceil(total / limit));
@@ -284,20 +290,22 @@ export function AuditLogView() {
Table Table
</Button> </Button>
</div> </div>
<DropdownMenu modal={false}> {isPaid && (
<DropdownMenuTrigger asChild> <DropdownMenu modal={false}>
<Button variant="outline" size="sm" className="border-border"> <DropdownMenuTrigger asChild>
<Download className="w-4 h-4 mr-2" strokeWidth={1.5} /> <Button variant="outline" size="sm" className="border-border">
Export <Download className="w-4 h-4 mr-2" strokeWidth={1.5} />
<ChevronDown className="w-3 h-3 ml-1" strokeWidth={1.5} /> Export
</Button> <ChevronDown className="w-3 h-3 ml-1" strokeWidth={1.5} />
</DropdownMenuTrigger> </Button>
<DropdownMenuContent align="end"> </DropdownMenuTrigger>
<DropdownMenuItem onClick={() => handleExport('csv')}>Export as CSV</DropdownMenuItem> <DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => handleExport('json')}>Export as JSON</DropdownMenuItem> <DropdownMenuItem onClick={() => handleExport('csv')}>Export as CSV</DropdownMenuItem>
</DropdownMenuContent> <DropdownMenuItem onClick={() => handleExport('json')}>Export as JSON</DropdownMenuItem>
</DropdownMenu> </DropdownMenuContent>
<Button variant="outline" size="sm" className="border-border" onClick={() => { fetchLogs(); if (view === 'stream') fetchStats(); }} disabled={loading}> </DropdownMenu>
)}
<Button variant="outline" size="sm" className="border-border" onClick={() => { fetchLogs(); if (view === 'stream' && isPaid) fetchStats(); }} disabled={loading}>
<RefreshCw className={`w-4 h-4 mr-2 ${loading ? 'animate-spin' : ''}`} strokeWidth={1.5} /> <RefreshCw className={`w-4 h-4 mr-2 ${loading ? 'animate-spin' : ''}`} strokeWidth={1.5} />
Refresh Refresh
</Button> </Button>
@@ -311,6 +319,7 @@ export function AuditLogView() {
{view === 'stream' ? ( {view === 'stream' ? (
<StreamView <StreamView
stats={stats} stats={stats}
showStats={isPaid}
loading={loading && entries.length === 0} loading={loading && entries.length === 0}
groups={groupedEntries} groups={groupedEntries}
now={now} now={now}
@@ -460,6 +469,7 @@ export function AuditLogView() {
interface StreamViewProps { interface StreamViewProps {
stats: AuditStats | null; stats: AuditStats | null;
showStats: boolean;
loading: boolean; loading: boolean;
groups: { key: string; day: number; entries: AuditEntry[] }[]; groups: { key: string; day: number; entries: AuditEntry[] }[];
now: number; now: number;
@@ -468,7 +478,7 @@ interface StreamViewProps {
onPage: (n: number) => void; onPage: (n: number) => 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 const tiles: AuditStatTile[] = stats
? [stats.events_24h, stats.actors_24h, stats.failure_rate, stats.unusual_hour] ? [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 ( return (
<div className="space-y-5"> <div className="space-y-5">
{showStats && (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-0 rounded-md border border-card-border bg-card/40 overflow-hidden"> <div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-0 rounded-md border border-card-border bg-card/40 overflow-hidden">
{tiles.length === 0 ? ( {tiles.length === 0 ? (
Array.from({ length: 4 }).map((_, i) => ( Array.from({ length: 4 }).map((_, i) => (
@@ -522,6 +533,7 @@ function StreamView({ stats, loading, groups, now, page, totalPages, onPage }: S
)) ))
)} )}
</div> </div>
)}
{loading ? ( {loading ? (
<div className="py-10 text-center text-muted-foreground text-sm">Loading audit stream...</div> <div className="py-10 text-center text-muted-foreground text-sm">Loading audit stream...</div>