Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions backend/src/routes/__tests__/enterpriseReportRoutes.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import os from 'os';
import path from 'path';
import request from 'supertest';
import { ENTERPRISE_FEATURE_FLAG_ENV } from '../../config';
import { listEnterpriseAuditEvents } from '../../services/enterpriseAuditService';
import { ENTERPRISE_DB_PATH_ENV, openEnterpriseDb } from '../../services/enterpriseDb';
import { ENTERPRISE_DATA_DIR_ENV } from '../../services/traceMetadataStore';
import reportRoutes, { persistReport, reportStore } from '../reportRoutes';
Expand Down Expand Up @@ -75,6 +76,15 @@ function readReportArtifact(reportId: string): ReportArtifactRow | null {
}
}

function readAuditActions(): string[] {
const db = openEnterpriseDb(dbPath);
try {
return listEnterpriseAuditEvents(db).map(event => event.action);
} finally {
db.close();
}
}

beforeEach(async () => {
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'smartperfetto-enterprise-report-routes-'));
dbPath = path.join(tmpDir, 'enterprise.sqlite');
Expand Down Expand Up @@ -145,6 +155,14 @@ describe('enterprise report routes', () => {
expect(getRes.status).toBe(200);
expect(getRes.text).toContain('enterprise report');

const exportRes = await ssoHeaders(request(app).get(`/api/reports/${reportId}/export`));
expect(exportRes.status).toBe(200);
expect(exportRes.text).toContain('enterprise report');
expect(readAuditActions()).toEqual(expect.arrayContaining([
'report.read',
'report.exported',
]));

const otherWorkspaceRes = await ssoHeaders(
request(app).get(`/api/reports/${reportId}`),
'workspace-b',
Expand Down Expand Up @@ -178,5 +196,6 @@ describe('enterprise report routes', () => {
expect(readReportArtifact(reportId)).toBeNull();
await expect(fs.access(row!.local_path)).rejects.toThrow();
await expect(fs.access(path.dirname(row!.local_path))).rejects.toThrow();
expect(readAuditActions()).toContain('report.deleted');
});
});
11 changes: 11 additions & 0 deletions backend/src/routes/__tests__/enterpriseTraceMetadataRoutes.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -287,6 +287,13 @@ describe('enterprise trace metadata routes', () => {
expect(listRes.status).toBe(200);
expect(listRes.body.traces.map((trace: any) => trace.id)).toEqual([traceId]);

const ownTraceRes = await ssoHeaders(request(app).get(`/api/traces/${traceId}`));
expect(ownTraceRes.status).toBe(200);
expect(readAuditActions()).toEqual(expect.arrayContaining([
'trace.uploaded',
'trace.read',
]));

const otherWorkspaceRes = await ssoHeaders(
request(app).get(`/api/traces/${traceId}`),
'workspace-b',
Expand Down Expand Up @@ -792,5 +799,9 @@ describe('enterprise trace metadata routes', () => {
expect(fakeTraceProcessorService.deleteTrace).toHaveBeenCalledWith(traceId);
await expect(fs.access(row!.local_path)).rejects.toThrow();
expect(readTraceAsset(traceId)).toBeNull();
expect(readAuditActions()).toEqual(expect.arrayContaining([
'trace.uploaded',
'trace.deleted',
]));
});
});
72 changes: 72 additions & 0 deletions backend/src/routes/__tests__/memoryRoutes.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,13 +10,26 @@ import {describe, it, expect, beforeEach, afterEach} from '@jest/globals';
import express from 'express';
import request from 'supertest';

import {ENTERPRISE_FEATURE_FLAG_ENV} from '../../config';
import {createMemoryRoutes} from '../memoryRoutes';
import {ProjectMemory} from '../../agentv3/projectMemory';
import {listEnterpriseAuditEvents} from '../../services/enterpriseAuditService';
import {
ENTERPRISE_DB_PATH_ENV,
openEnterpriseDb,
} from '../../services/enterpriseDb';
import {
type MemoryPromotionPolicy,
type ProjectMemoryEntry,
} from '../../types/sparkContracts';

const originalEnv = {
enterprise: process.env[ENTERPRISE_FEATURE_FLAG_ENV],
trustedHeaders: process.env.SMARTPERFETTO_SSO_TRUSTED_HEADERS,
enterpriseDbPath: process.env[ENTERPRISE_DB_PATH_ENV],
apiKey: process.env.SMARTPERFETTO_API_KEY,
};

let tmpDir: string;
let memory: ProjectMemory;
let app: express.Express;
Expand All @@ -30,11 +43,42 @@ beforeEach(() => {
});

afterEach(() => {
restoreEnvValue(ENTERPRISE_FEATURE_FLAG_ENV, originalEnv.enterprise);
restoreEnvValue('SMARTPERFETTO_SSO_TRUSTED_HEADERS', originalEnv.trustedHeaders);
restoreEnvValue(ENTERPRISE_DB_PATH_ENV, originalEnv.enterpriseDbPath);
restoreEnvValue('SMARTPERFETTO_API_KEY', originalEnv.apiKey);
if (fs.existsSync(tmpDir)) {
fs.rmSync(tmpDir, {recursive: true, force: true});
}
});

function restoreEnvValue(key: string, value: string | undefined): void {
if (value === undefined) {
delete process.env[key];
} else {
process.env[key] = value;
}
}

function ssoHeaders(req: request.Test): request.Test {
return req
.set('X-SmartPerfetto-SSO-User-Id', 'memory-admin')
.set('X-SmartPerfetto-SSO-Email', 'memory-admin@example.test')
.set('X-SmartPerfetto-SSO-Tenant-Id', 'tenant-a')
.set('X-SmartPerfetto-SSO-Workspace-Id', 'workspace-a')
.set('X-SmartPerfetto-SSO-Roles', 'workspace_admin')
.set('X-SmartPerfetto-SSO-Scopes', 'audit:read');
}

function readEnterpriseAuditActions(dbPath: string): string[] {
const db = openEnterpriseDb(dbPath);
try {
return listEnterpriseAuditEvents(db).map(event => event.action);
} finally {
db.close();
}
}

function makeEntry(
overrides: Partial<ProjectMemoryEntry> = {},
): ProjectMemoryEntry {
Expand Down Expand Up @@ -149,6 +193,34 @@ describe('POST /api/memory/promote', () => {
expect(res.body.entry.promotionPolicy.trigger).toBe('reviewer_approval');
});

it('records enterprise audit events for promotion and deletion', async () => {
const dbPath = path.join(tmpDir, 'enterprise.sqlite');
process.env[ENTERPRISE_FEATURE_FLAG_ENV] = 'true';
process.env.SMARTPERFETTO_SSO_TRUSTED_HEADERS = 'true';
process.env[ENTERPRISE_DB_PATH_ENV] = dbPath;
delete process.env.SMARTPERFETTO_API_KEY;

memory.saveProjectMemoryEntry(makeEntry({entryId: 'a', scope: 'project'}), {
tenantId: 'tenant-a',
workspaceId: 'workspace-a',
userId: 'memory-admin',
});
const promoteRes = await ssoHeaders(
request(app)
.post('/api/memory/promote')
.send({entryId: 'a', policy: REVIEWER_POLICY}),
);
expect(promoteRes.status).toBe(200);

const deleteRes = await ssoHeaders(request(app).delete('/api/memory/a'));
expect(deleteRes.status).toBe(200);

expect(readEnterpriseAuditActions(dbPath)).toEqual(expect.arrayContaining([
'memory.promoted',
'memory.deleted',
]));
});

it('400 on missing body fields', async () => {
const res = await request(app).post('/api/memory/promote').send({});
expect(res.status).toBe(400);
Expand Down
17 changes: 17 additions & 0 deletions backend/src/routes/memoryRoutes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ import {Router, type Router as ExpressRouter} from 'express';

import {authenticate, requireRequestContext} from '../middleware/auth';
import {ProjectMemory} from '../agentv3/projectMemory';
import {recordEnterpriseAuditEventForContext} from '../services/enterpriseAuditService';
import {knowledgeScopeFromRequestContext} from '../services/scopedKnowledgeStore';
import type {MemoryPromotionPolicy} from '../types/sparkContracts';

Expand Down Expand Up @@ -114,6 +115,17 @@ export function createMemoryRoutes(memory?: ProjectMemory): ExpressRouter {
try {
m.promoteEntry(entryId, policy, storageScope);
const entry = m.getProjectMemoryEntry(entryId, storageScope);
recordEnterpriseAuditEventForContext(requireRequestContext(req), {
action: 'memory.promoted',
resourceType: 'memory',
resourceId: entryId,
metadata: {
fromScope: policy.fromScope,
toScope: policy.toScope,
trigger: policy.trigger,
reviewer: policy.reviewer,
},
});
return res.status(200).json({success: true, entry});
} catch (err) {
return res.status(400).json({
Expand All @@ -133,6 +145,11 @@ export function createMemoryRoutes(memory?: ProjectMemory): ExpressRouter {
error: `Entry '${req.params.entryId}' not found`,
});
}
recordEnterpriseAuditEventForContext(requireRequestContext(req), {
action: 'memory.deleted',
resourceType: 'memory',
resourceId: req.params.entryId,
});
res.json({success: true});
});

Expand Down
86 changes: 80 additions & 6 deletions backend/src/routes/providerRoutes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,8 @@ import express from 'express';
import { getProviderService, officialTemplates } from '../services/providerManager';
import type { AgentRuntimeKind, ProviderCreateInput, ProviderScope, ProviderUpdateInput } from '../services/providerManager';
import { testProviderConnection } from '../services/providerManager/connectionTester';
import { authenticate, requireRequestContext } from '../middleware/auth';
import { authenticate, requireRequestContext, type RequestContext } from '../middleware/auth';
import { recordEnterpriseAuditEventForContext } from '../services/enterpriseAuditService';

const router = express.Router();

Expand All @@ -20,6 +21,29 @@ function providerScopeForRequest(req: express.Request): ProviderScope {
};
}

function recordProviderAudit(
context: RequestContext,
action:
| 'provider.read'
| 'provider.created'
| 'provider.updated'
| 'provider.deleted'
| 'provider.activated'
| 'provider.deactivated'
| 'provider.runtime_switched'
| 'provider.secret_rotated'
| 'provider.connection_tested',
providerId: string | undefined,
metadata: Record<string, unknown> = {},
): void {
recordEnterpriseAuditEventForContext(context, {
action,
resourceType: 'provider',
resourceId: providerId,
metadata,
});
}

router.get('/', (req, res) => {
const svc = getProviderService();
res.json({ success: true, providers: svc.list(providerScopeForRequest(req)) });
Expand All @@ -43,17 +67,28 @@ router.get('/effective', (req, res) => {

router.get('/:id', (req, res) => {
const svc = getProviderService();
const context = requireRequestContext(req);
const provider = svc.get(req.params.id, providerScopeForRequest(req));
if (!provider) return res.status(404).json({ success: false, error: 'Provider not found' });
recordProviderAudit(context, 'provider.read', provider.id, {
type: provider.type,
active: provider.isActive,
});
res.json({ success: true, provider });
});

router.post('/', (req, res) => {
try {
const svc = getProviderService();
const input: ProviderCreateInput = req.body;
const context = requireRequestContext(req);
const scope = providerScopeForRequest(req);
const provider = svc.create(input, scope);
recordProviderAudit(context, 'provider.created', provider.id, {
type: provider.type,
category: provider.category,
runtime: provider.connection.agentRuntime,
});
res.status(201).json({ success: true, provider: svc.get(provider.id, scope) });
} catch (err: any) {
res.status(400).json({ success: false, error: err.message });
Expand All @@ -64,8 +99,13 @@ router.patch('/:id', (req, res) => {
try {
const svc = getProviderService();
const input: ProviderUpdateInput = req.body;
const context = requireRequestContext(req);
const scope = providerScopeForRequest(req);
svc.update(req.params.id, input, scope);
const updated = svc.update(req.params.id, input, scope);
recordProviderAudit(context, 'provider.updated', updated.id, {
type: updated.type,
changedFields: Object.keys(input),
});
res.json({ success: true, provider: svc.get(req.params.id, scope) });
} catch (err: any) {
const status = err.message.includes('not found') ? 404 : 400;
Expand All @@ -76,7 +116,13 @@ router.patch('/:id', (req, res) => {
router.delete('/:id', (req, res) => {
try {
const svc = getProviderService();
svc.delete(req.params.id, providerScopeForRequest(req));
const context = requireRequestContext(req);
const scope = providerScopeForRequest(req);
const existing = svc.get(req.params.id, scope);
svc.delete(req.params.id, scope);
recordProviderAudit(context, 'provider.deleted', req.params.id, {
type: existing?.type,
});
res.json({ success: true });
} catch (err: any) {
const status = err.message.includes('not found') ? 404 : 400;
Expand All @@ -86,14 +132,26 @@ router.delete('/:id', (req, res) => {

router.post('/deactivate', (req, res) => {
const svc = getProviderService();
svc.deactivateAll(providerScopeForRequest(req));
const context = requireRequestContext(req);
const scope = providerScopeForRequest(req);
const active = svc.list(scope).find(provider => provider.isActive);
svc.deactivateAll(scope);
recordProviderAudit(context, 'provider.deactivated', active?.id, {
type: active?.type,
});
res.json({ success: true });
});

router.post('/:id/activate', (req, res) => {
try {
const svc = getProviderService();
svc.activate(req.params.id, providerScopeForRequest(req));
const context = requireRequestContext(req);
const scope = providerScopeForRequest(req);
svc.activate(req.params.id, scope);
const provider = svc.get(req.params.id, scope);
recordProviderAudit(context, 'provider.activated', req.params.id, {
type: provider?.type,
});
res.json({ success: true });
} catch (err: any) {
const status = err.message.includes('not found') ? 404 : 400;
Expand All @@ -108,8 +166,13 @@ router.post('/:id/runtime', (req, res) => {
if (runtime !== 'claude-agent-sdk' && runtime !== 'openai-agents-sdk') {
return res.status(400).json({ success: false, error: 'Invalid agentRuntime' });
}
const context = requireRequestContext(req);
const scope = providerScopeForRequest(req);
svc.switchAgentRuntime(req.params.id, runtime, scope);
const provider = svc.switchAgentRuntime(req.params.id, runtime, scope);
recordProviderAudit(context, 'provider.runtime_switched', req.params.id, {
type: provider.type,
runtime,
});
res.json({ success: true, provider: svc.get(req.params.id, scope) });
} catch (err: any) {
const status = err.message.includes('not found') ? 404 : 400;
Expand All @@ -120,8 +183,14 @@ router.post('/:id/runtime', (req, res) => {
router.post('/:id/rotate-secret', (req, res) => {
try {
const svc = getProviderService();
const context = requireRequestContext(req);
const scope = providerScopeForRequest(req);
const secretVersion = svc.rotateSecret(req.params.id, scope);
const provider = svc.get(req.params.id, scope);
recordProviderAudit(context, 'provider.secret_rotated', req.params.id, {
type: provider?.type,
secretVersion,
});
res.json({ success: true, secretVersion, provider: svc.get(req.params.id, scope) });
} catch (err: any) {
const status = err.message.includes('not found') ? 404 : 400;
Expand All @@ -131,10 +200,15 @@ router.post('/:id/rotate-secret', (req, res) => {

router.post('/:id/test', async (req, res) => {
const svc = getProviderService();
const context = requireRequestContext(req);
const provider = svc.getRaw(req.params.id, providerScopeForRequest(req));
if (!provider) return res.status(404).json({ success: false, error: 'Provider not found' });

const result = await testProviderConnection(provider);
recordProviderAudit(context, 'provider.connection_tested', req.params.id, {
type: provider.type,
success: result.success,
});
res.json({ success: true, result });
});

Expand Down
Loading