From 6b21cb66e6d48798f55e12b5e8ff99066b62cb5a Mon Sep 17 00:00:00 2001 From: Chris Date: Sat, 9 May 2026 02:53:37 +0800 Subject: [PATCH] feat(enterprise): add tenant export bundle --- .../enterpriseTenantExportRoutes.test.ts | 244 +++++++ backend/src/routes/exportRoutes.ts | 59 +- .../services/enterpriseTenantExportService.ts | 601 ++++++++++++++++++ .../enterprise-multi-tenant/README.md | 9 +- 4 files changed, 911 insertions(+), 2 deletions(-) create mode 100644 backend/src/routes/__tests__/enterpriseTenantExportRoutes.test.ts create mode 100644 backend/src/services/enterpriseTenantExportService.ts diff --git a/backend/src/routes/__tests__/enterpriseTenantExportRoutes.test.ts b/backend/src/routes/__tests__/enterpriseTenantExportRoutes.test.ts new file mode 100644 index 000000000..9f60f8a39 --- /dev/null +++ b/backend/src/routes/__tests__/enterpriseTenantExportRoutes.test.ts @@ -0,0 +1,244 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later +// Copyright (C) 2024-2026 Gracker (Chris) +// This file is part of SmartPerfetto. See LICENSE for details. + +import crypto from 'crypto'; +import express from 'express'; +import fs from 'fs/promises'; +import os from 'os'; +import path from 'path'; +import request from 'supertest'; + +import { ENTERPRISE_FEATURE_FLAG_ENV } from '../../config'; +import { ENTERPRISE_DB_PATH_ENV, openEnterpriseDb } from '../../services/enterpriseDb'; +import { stableStringify } from '../../services/enterpriseTenantExportService'; +import exportRoutes from '../exportRoutes'; + +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 dbPath: string; + +function makeApp(): express.Express { + const app = express(); + app.use(express.json()); + app.use('/api/export', exportRoutes); + return app; +} + +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, + input: { role?: string; scopes?: string } = {}, +): request.Test { + return req + .set('X-SmartPerfetto-SSO-User-Id', 'user-a') + .set('X-SmartPerfetto-SSO-Email', 'user-a@example.test') + .set('X-SmartPerfetto-SSO-Tenant-Id', 'tenant-a') + .set('X-SmartPerfetto-SSO-Workspace-Id', 'workspace-a') + .set('X-SmartPerfetto-SSO-Roles', input.role ?? 'org_admin') + .set('X-SmartPerfetto-SSO-Scopes', input.scopes ?? 'report:read'); +} + +async function seedTenantExportFixture(): Promise { + const reportDir = path.join(tmpDir, 'data', 'tenant-a', 'workspace-a', 'reports', 'report-a'); + await fs.mkdir(reportDir, { recursive: true }); + await fs.writeFile(path.join(reportDir, 'report.html'), 'tenant report'); + await fs.writeFile(path.join(reportDir, 'report.json'), JSON.stringify({ title: 'Tenant report' })); + + const now = 1_800_000_000_000; + const db = openEnterpriseDb(dbPath); + try { + db.prepare(` + INSERT INTO organizations (id, name, status, plan, created_at, updated_at) + VALUES + ('tenant-a', 'Tenant A', 'active', 'enterprise', ?, ?), + ('tenant-b', 'Tenant B', 'active', 'enterprise', ?, ?) + `).run(now, now, now, now); + db.prepare(` + INSERT INTO workspaces (id, tenant_id, name, retention_policy, quota_policy, created_at, updated_at) + VALUES + ('workspace-a', 'tenant-a', 'Workspace A', '{"traceRetentionDays":7}', '{"monthlyRunLimit":10}', ?, ?), + ('workspace-b', 'tenant-a', 'Workspace B', NULL, NULL, ?, ?), + ('workspace-x', 'tenant-b', 'Workspace X', NULL, NULL, ?, ?) + `).run(now, now, now, now, now, now); + db.prepare(` + INSERT INTO users (id, tenant_id, email, display_name, idp_subject, created_at, updated_at) + VALUES + ('user-a', 'tenant-a', 'user-a@example.test', 'User A', 'sso:user-a', ?, ?), + ('user-b', 'tenant-b', 'user-b@example.test', 'User B', 'sso:user-b', ?, ?) + `).run(now, now, now, now); + db.prepare(` + INSERT INTO memberships (tenant_id, workspace_id, user_id, role, created_at) + VALUES ('tenant-a', 'workspace-a', 'user-a', 'org_admin', ?) + `).run(now); + db.prepare(` + INSERT INTO trace_assets + (id, tenant_id, workspace_id, owner_user_id, local_path, sha256, size_bytes, status, metadata_json, created_at, expires_at) + VALUES + ('trace-a', 'tenant-a', 'workspace-a', 'user-a', '/tmp/tenant-a-trace.pftrace', 'sha-a', 123, 'ready', '{"device":"pixel"}', ?, NULL), + ('trace-b', 'tenant-b', 'workspace-x', 'user-b', '/tmp/tenant-b-trace.pftrace', 'sha-b', 456, 'ready', NULL, ?, NULL) + `).run(now, now); + db.prepare(` + INSERT INTO provider_snapshots + (id, tenant_id, provider_id, snapshot_hash, runtime_kind, resolved_config_json, secret_version, created_at) + VALUES + ('snapshot-a', 'tenant-a', 'provider-a', 'hash-a', 'openai-agents-sdk', '{"connection":{"apiKey":"sk-secret","baseUrl":"https://example.test"}}', 'secret-v1', ?) + `).run(now); + db.prepare(` + INSERT INTO analysis_sessions + (id, tenant_id, workspace_id, trace_id, created_by, provider_snapshot_id, title, visibility, status, created_at, updated_at) + VALUES + ('session-a', 'tenant-a', 'workspace-a', 'trace-a', 'user-a', 'snapshot-a', 'Session A', 'private', 'completed', ?, ?) + `).run(now, now); + db.prepare(` + INSERT INTO analysis_runs + (id, tenant_id, workspace_id, session_id, mode, status, question, started_at, completed_at, error_json, heartbeat_at, updated_at) + VALUES + ('run-a', 'tenant-a', 'workspace-a', 'session-a', 'quick', 'completed', 'Why jank?', ?, ?, NULL, ?, ?) + `).run(now, now + 100, now + 50, now + 100); + db.prepare(` + INSERT INTO conversation_turns + (id, tenant_id, workspace_id, session_id, run_id, role, content_json, created_at) + VALUES + ('turn-a', 'tenant-a', 'workspace-a', 'session-a', 'run-a', 'assistant', '{"text":"answer"}', ?) + `).run(now + 10); + db.prepare(` + INSERT INTO report_artifacts + (id, tenant_id, workspace_id, session_id, run_id, local_path, content_hash, visibility, created_by, created_at, expires_at) + VALUES + ('report-a', 'tenant-a', 'workspace-a', 'session-a', 'run-a', ?, 'hash-report-a', 'private', 'user-a', ?, NULL) + `).run(path.join(reportDir, 'report.html'), now); + db.prepare(` + INSERT INTO memory_entries + (id, tenant_id, workspace_id, scope, source_run_id, content_json, embedding_ref, created_at, updated_at) + VALUES + ('memory-a', 'tenant-a', 'workspace-a', 'baseline', 'run-a', '{"kind":"baseline","externalId":"baseline-a","record":{"value":1}}', NULL, ?, ?) + `).run(now, now); + db.prepare(` + INSERT INTO provider_credentials + (id, tenant_id, workspace_id, owner_user_id, scope, name, type, models_json, secret_ref, policy_json, created_at, updated_at) + VALUES + ('provider-a', 'tenant-a', 'workspace-a', 'user-a', 'personal', 'Provider A', 'openai', '{"primary":"gpt-5.2","light":"gpt-5.2-mini"}', 'secret:provider:tenant-a:workspace-a:user-a:provider-a', '{"connection":{"apiKey":"sk-secret","baseUrl":"https://example.test"},"secretVersion":1}', ?, ?) + `).run(now, now); + db.prepare(` + INSERT INTO audit_events + (id, tenant_id, workspace_id, actor_user_id, action, resource_type, resource_id, metadata_json, created_at) + VALUES + ('audit-a', 'tenant-a', 'workspace-a', 'user-a', 'report.read', 'report', 'report-a', '{"ok":true}', ?), + ('audit-b', 'tenant-b', 'workspace-x', 'user-b', 'report.read', 'report', 'report-b', NULL, ?) + `).run(now, now); + } finally { + db.close(); + } +} + +beforeEach(async () => { + tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'smartperfetto-tenant-export-')); + 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; +}); + +afterEach(async () => { + 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); + await fs.rm(tmpDir, { recursive: true, force: true }); +}); + +describe('enterprise tenant export route', () => { + it('exports a tenant bundle with reports, manifests, identity proof, and no secrets', async () => { + await seedTenantExportFixture(); + const app = makeApp(); + + const res = await ssoHeaders(request(app).get('/api/export/tenant')); + + expect(res.status).toBe(200); + expect(res.headers['content-disposition']).toContain('smartperfetto-tenant-tenant-a'); + expect(res.body.success).toBe(true); + expect(res.body.bundleSha256).toBe( + `sha256:${crypto.createHash('sha256').update(stableStringify(res.body.bundle)).digest('hex')}`, + ); + expect(res.body.bundle.tenantIdentityProof).toEqual(expect.objectContaining({ + tenantId: 'tenant-a', + generatedBy: 'user-a', + workspaceIds: ['workspace-a', 'workspace-b'], + })); + expect(res.body.bundle.manifest).toEqual(expect.objectContaining({ + traceFilesIncluded: false, + traceCount: 1, + reportCount: 1, + sessionCount: 1, + runCount: 1, + turnCount: 1, + memoryRecordCount: 1, + auditEventCount: 1, + providerCredentialCount: 1, + providerSnapshotCount: 1, + })); + expect(res.body.bundle.traces[0]).toEqual(expect.objectContaining({ + id: 'trace-a', + fileIncluded: false, + sha256: 'sha-a', + })); + expect(res.body.bundle.reports[0]).toEqual(expect.objectContaining({ + id: 'report-a', + html: 'tenant report', + json: { title: 'Tenant report' }, + })); + expect(res.body.bundle.sessions[0].id).toBe('session-a'); + expect(res.body.bundle.runs[0].id).toBe('run-a'); + expect(res.body.bundle.turns[0].id).toBe('turn-a'); + expect(res.body.bundle.knowledge.memoryEntries[0].id).toBe('memory-a'); + + const serialized = JSON.stringify(res.body.bundle); + expect(serialized).not.toContain('tenant-b'); + expect(serialized).not.toContain('/tmp/tenant-a-trace.pftrace'); + expect(serialized).not.toContain('secret:provider'); + expect(serialized).not.toContain('sk-secret'); + expect(res.body.bundle.providers.credentials[0].policy.connection.apiKey).toBe('[redacted]'); + expect(res.body.bundle.providers.snapshots[0].resolvedConfig.connection.apiKey).toBe('[redacted]'); + + const db = openEnterpriseDb(dbPath); + try { + const audit = db.prepare(` + SELECT action, metadata_json + FROM audit_events + WHERE tenant_id = 'tenant-a' AND action = 'tenant.exported' + `).get(); + expect(audit?.action).toBe('tenant.exported'); + expect(audit?.metadata_json).toContain(res.body.bundleSha256); + } finally { + db.close(); + } + }); + + it('requires tenant export privileges', async () => { + await seedTenantExportFixture(); + const app = makeApp(); + + const res = await ssoHeaders( + request(app).get('/api/export/tenant'), + { role: 'analyst', scopes: 'report:read' }, + ); + + expect(res.status).toBe(403); + expect(res.body.details).toBe('Tenant export requires org_admin or tenant:export scope'); + }); +}); diff --git a/backend/src/routes/exportRoutes.ts b/backend/src/routes/exportRoutes.ts index 5117ab4d1..3d7e49c14 100644 --- a/backend/src/routes/exportRoutes.ts +++ b/backend/src/routes/exportRoutes.ts @@ -8,10 +8,21 @@ */ import { Router } from 'express'; +import { authenticate, requireRequestContext, type RequestContext } from '../middleware/auth'; +import { recordEnterpriseAuditEvent } from '../services/enterpriseAuditService'; +import { openEnterpriseDb } from '../services/enterpriseDb'; +import { buildTenantExportBundle } from '../services/enterpriseTenantExportService'; import { ResultExportService, AnalysisSessionExport } from '../services/resultExportService'; +import { sendForbidden } from '../services/rbac'; const router = Router(); +function canExportTenant(context: RequestContext): boolean { + return context.scopes.includes('*') + || context.scopes.includes('tenant:export') + || context.roles.includes('org_admin'); +} + /** * POST /api/export/result * Export a single SQL query result @@ -128,6 +139,52 @@ router.post('/analysis', async (req, res) => { } }); +/** + * GET /api/export/tenant + * Export a tenant-scoped compliance bundle without trace file bodies or secrets. + */ +router.get('/tenant', authenticate, async (req, res) => { + const context = requireRequestContext(req); + if (!canExportTenant(context)) { + return sendForbidden(res, 'Tenant export requires org_admin or tenant:export scope'); + } + + const db = openEnterpriseDb(); + try { + const exportResult = await buildTenantExportBundle(db, context); + recordEnterpriseAuditEvent(db, { + tenantId: context.tenantId, + actorUserId: context.userId, + action: 'tenant.exported', + resourceType: 'tenant', + resourceId: context.tenantId, + metadata: { + bundleSha256: exportResult.bundleSha256, + traceCount: exportResult.bundle.manifest.traceCount, + reportCount: exportResult.bundle.manifest.reportCount, + sessionCount: exportResult.bundle.manifest.sessionCount, + runCount: exportResult.bundle.manifest.runCount, + memoryRecordCount: exportResult.bundle.manifest.memoryRecordCount, + requestId: context.requestId, + }, + }); + res.setHeader('Content-Type', 'application/json; charset=utf-8'); + res.setHeader('Content-Disposition', `attachment; filename="${exportResult.filename}"`); + res.json({ + success: true, + bundleSha256: exportResult.bundleSha256, + bundle: exportResult.bundle, + }); + } catch (error: any) { + res.status(500).json({ + success: false, + error: error.message || 'Failed to export tenant bundle', + }); + } finally { + db.close(); + } +}); + /** * GET /api/export/formats * Get available export formats @@ -152,4 +209,4 @@ router.get('/formats', (req, res) => { }); }); -export default router; \ No newline at end of file +export default router; diff --git a/backend/src/services/enterpriseTenantExportService.ts b/backend/src/services/enterpriseTenantExportService.ts new file mode 100644 index 000000000..4113fb244 --- /dev/null +++ b/backend/src/services/enterpriseTenantExportService.ts @@ -0,0 +1,601 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later +// Copyright (C) 2024-2026 Gracker (Chris) +// This file is part of SmartPerfetto. See LICENSE for details. + +import crypto from 'crypto'; +import fs from 'fs/promises'; +import path from 'path'; +import type Database from 'better-sqlite3'; + +import type { RequestContext } from '../middleware/auth'; + +type JsonValue = string | number | boolean | null | JsonValue[] | { [key: string]: JsonValue }; + +interface TenantExportWorkspaceRow { + id: string; + tenant_id: string; + name: string; + retention_policy: string | null; + quota_policy: string | null; + created_at: number; + updated_at: number; +} + +interface TenantExportOrganizationRow { + id: string; + name: string; + status: string; + plan: string | null; + created_at: number; + updated_at: number; +} + +interface TenantExportUserRow { + id: string; + tenant_id: string; + email: string; + display_name: string | null; + idp_subject: string | null; + created_at: number; + updated_at: number; +} + +interface TenantExportMembershipRow { + tenant_id: string; + workspace_id: string; + user_id: string; + role: string; + created_at: number; +} + +interface TenantExportTraceRow { + id: string; + tenant_id: string; + workspace_id: string; + owner_user_id: string | null; + sha256: string | null; + size_bytes: number | null; + status: string; + metadata_json: string | null; + created_at: number; + expires_at: number | null; +} + +interface TenantExportReportRow { + id: string; + tenant_id: string; + workspace_id: string; + session_id: string; + run_id: string; + local_path: string; + content_hash: string | null; + visibility: string; + created_by: string | null; + created_at: number; + expires_at: number | null; +} + +interface TenantExportAnalysisSessionRow { + id: string; + tenant_id: string; + workspace_id: string; + trace_id: string; + created_by: string | null; + provider_snapshot_id: string | null; + title: string | null; + visibility: string; + status: string; + created_at: number; + updated_at: number; +} + +interface TenantExportAnalysisRunRow { + id: string; + tenant_id: string; + workspace_id: string; + session_id: string; + mode: string; + status: string; + question: string; + started_at: number; + completed_at: number | null; + error_json: string | null; + heartbeat_at: number | null; + updated_at: number | null; +} + +interface TenantExportConversationTurnRow { + id: string; + tenant_id: string; + workspace_id: string; + session_id: string; + run_id: string; + role: string; + content_json: string; + created_at: number; +} + +interface TenantExportMemoryRow { + id: string; + tenant_id: string; + workspace_id: string; + scope: string; + source_run_id: string | null; + content_json: string; + embedding_ref: string | null; + created_at: number; + updated_at: number; +} + +interface TenantExportProviderCredentialRow { + id: string; + tenant_id: string; + workspace_id: string | null; + owner_user_id: string | null; + scope: string; + name: string; + type: string; + models_json: string; + policy_json: string | null; + created_at: number; + updated_at: number; +} + +interface TenantExportProviderSnapshotRow { + id: string; + tenant_id: string; + provider_id: string; + snapshot_hash: string; + runtime_kind: string; + resolved_config_json: string; + secret_version: string | null; + created_at: number; +} + +interface TenantExportAuditRow { + id: string; + tenant_id: string; + workspace_id: string | null; + actor_user_id: string | null; + action: string; + resource_type: string; + resource_id: string | null; + metadata_json: string | null; + created_at: number; +} + +export interface TenantExportBundle { + schemaVersion: 1; + generatedAt: string; + tenantIdentityProof: { + tenantId: string; + organizationHash: string; + workspaceIds: string[]; + generatedBy: string; + requestId: string; + proofHash: string; + }; + manifest: { + traceFilesIncluded: false; + traceCount: number; + reportCount: number; + sessionCount: number; + runCount: number; + turnCount: number; + memoryRecordCount: number; + auditEventCount: number; + providerCredentialCount: number; + providerSnapshotCount: number; + }; + tenant: { + organization: Record | null; + workspaces: Array>; + users: Array>; + memberships: Array>; + }; + traces: Array>; + reports: Array>; + sessions: Array>; + runs: Array>; + turns: Array>; + knowledge: { + memoryEntries: Array>; + }; + auditEvents: Array>; + providers: { + credentials: Array>; + snapshots: Array>; + }; +} + +export interface TenantExportResult { + bundle: TenantExportBundle; + bundleSha256: string; + canonicalPayload: string; + filename: string; +} + +const SENSITIVE_KEY_RE = /(api[_-]?key|token|secret|password|credential|bearer|authorization)/i; + +function parseJson(value: string | null): JsonValue | null { + if (!value) return null; + try { + return sanitizeJson(JSON.parse(value)); + } catch { + return null; + } +} + +function sanitizeJson(value: unknown): JsonValue { + if (value === null) return null; + if (Array.isArray(value)) return value.map(item => sanitizeJson(item)); + if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') { + return value; + } + if (typeof value !== 'object') return null; + const out: Record = {}; + for (const [key, child] of Object.entries(value as Record)) { + out[key] = SENSITIVE_KEY_RE.test(key) ? '[redacted]' : sanitizeJson(child); + } + return out; +} + +function canonicalize(value: unknown): unknown { + if (Array.isArray(value)) return value.map(canonicalize); + if (!value || typeof value !== 'object') return value; + const input = value as Record; + const out: Record = {}; + for (const key of Object.keys(input).sort()) { + const child = input[key]; + if (child !== undefined) out[key] = canonicalize(child); + } + return out; +} + +export function stableStringify(value: unknown): string { + return JSON.stringify(canonicalize(value)); +} + +function sha256(value: string): string { + return `sha256:${crypto.createHash('sha256').update(value).digest('hex')}`; +} + +function toIso(value: number | null | undefined): string | null { + return typeof value === 'number' ? new Date(value).toISOString() : null; +} + +async function tryReadText(filePath: string): Promise { + try { + return await fs.readFile(filePath, 'utf8'); + } catch { + return null; + } +} + +function organizationExport(row: TenantExportOrganizationRow | null): Record | null { + if (!row) return null; + return { + id: row.id, + name: row.name, + status: row.status, + plan: row.plan, + createdAt: toIso(row.created_at), + updatedAt: toIso(row.updated_at), + }; +} + +function workspaceExport(row: TenantExportWorkspaceRow): Record { + return { + id: row.id, + tenantId: row.tenant_id, + name: row.name, + retentionPolicy: parseJson(row.retention_policy), + quotaPolicy: parseJson(row.quota_policy), + createdAt: toIso(row.created_at), + updatedAt: toIso(row.updated_at), + }; +} + +function userExport(row: TenantExportUserRow): Record { + return { + id: row.id, + tenantId: row.tenant_id, + email: row.email, + displayName: row.display_name, + idpSubject: row.idp_subject, + createdAt: toIso(row.created_at), + updatedAt: toIso(row.updated_at), + }; +} + +function membershipExport(row: TenantExportMembershipRow): Record { + return { + tenantId: row.tenant_id, + workspaceId: row.workspace_id, + userId: row.user_id, + role: row.role, + createdAt: toIso(row.created_at), + }; +} + +function traceManifestExport(row: TenantExportTraceRow): Record { + return { + id: row.id, + tenantId: row.tenant_id, + workspaceId: row.workspace_id, + ownerUserId: row.owner_user_id, + sha256: row.sha256, + sizeBytes: row.size_bytes, + status: row.status, + metadata: parseJson(row.metadata_json), + createdAt: toIso(row.created_at), + expiresAt: toIso(row.expires_at), + fileIncluded: false, + }; +} + +async function reportExport(row: TenantExportReportRow): Promise> { + const html = await tryReadText(row.local_path); + const jsonPath = path.join(path.dirname(row.local_path), 'report.json'); + const reportJson = parseJson(await tryReadText(jsonPath)); + return { + id: row.id, + tenantId: row.tenant_id, + workspaceId: row.workspace_id, + sessionId: row.session_id, + runId: row.run_id, + contentHash: row.content_hash, + visibility: row.visibility, + createdBy: row.created_by, + createdAt: toIso(row.created_at), + expiresAt: toIso(row.expires_at), + html, + json: reportJson, + }; +} + +function sessionExport(row: TenantExportAnalysisSessionRow): Record { + return { + id: row.id, + tenantId: row.tenant_id, + workspaceId: row.workspace_id, + traceId: row.trace_id, + createdBy: row.created_by, + providerSnapshotId: row.provider_snapshot_id, + title: row.title, + visibility: row.visibility, + status: row.status, + createdAt: toIso(row.created_at), + updatedAt: toIso(row.updated_at), + }; +} + +function runExport(row: TenantExportAnalysisRunRow): Record { + return { + id: row.id, + tenantId: row.tenant_id, + workspaceId: row.workspace_id, + sessionId: row.session_id, + mode: row.mode, + status: row.status, + question: row.question, + startedAt: toIso(row.started_at), + completedAt: toIso(row.completed_at), + heartbeatAt: toIso(row.heartbeat_at), + updatedAt: toIso(row.updated_at), + error: parseJson(row.error_json), + }; +} + +function turnExport(row: TenantExportConversationTurnRow): Record { + return { + id: row.id, + tenantId: row.tenant_id, + workspaceId: row.workspace_id, + sessionId: row.session_id, + runId: row.run_id, + role: row.role, + content: parseJson(row.content_json), + createdAt: toIso(row.created_at), + }; +} + +function memoryExport(row: TenantExportMemoryRow): Record { + return { + id: row.id, + tenantId: row.tenant_id, + workspaceId: row.workspace_id, + scope: row.scope, + sourceRunId: row.source_run_id, + content: parseJson(row.content_json), + embeddingRef: row.embedding_ref, + createdAt: toIso(row.created_at), + updatedAt: toIso(row.updated_at), + }; +} + +function providerCredentialExport(row: TenantExportProviderCredentialRow): Record { + const policy = parseJson(row.policy_json) as Record | null; + return { + id: row.id, + tenantId: row.tenant_id, + workspaceId: row.workspace_id, + ownerUserId: row.owner_user_id, + scope: row.scope, + name: row.name, + type: row.type, + models: parseJson(row.models_json), + policy, + secretConfigured: true, + createdAt: toIso(row.created_at), + updatedAt: toIso(row.updated_at), + }; +} + +function providerSnapshotExport(row: TenantExportProviderSnapshotRow): Record { + return { + id: row.id, + tenantId: row.tenant_id, + providerId: row.provider_id, + snapshotHash: row.snapshot_hash, + runtimeKind: row.runtime_kind, + resolvedConfig: parseJson(row.resolved_config_json), + secretVersion: row.secret_version, + createdAt: toIso(row.created_at), + }; +} + +function auditExport(row: TenantExportAuditRow): Record { + return { + id: row.id, + tenantId: row.tenant_id, + workspaceId: row.workspace_id, + actorUserId: row.actor_user_id, + action: row.action, + resourceType: row.resource_type, + resourceId: row.resource_id, + metadata: parseJson(row.metadata_json), + createdAt: toIso(row.created_at), + }; +} + +function tenantRows(db: Database.Database, table: string, tenantId: string, orderBy: string): T[] { + return db.prepare(` + SELECT * + FROM ${table} + WHERE tenant_id = ? + ORDER BY ${orderBy} + `).all(tenantId); +} + +export async function buildTenantExportBundle( + db: Database.Database, + context: RequestContext, +): Promise { + const generatedAt = new Date().toISOString(); + const organization = db.prepare(` + SELECT * + FROM organizations + WHERE id = ? + LIMIT 1 + `).get(context.tenantId) ?? null; + const workspaces = tenantRows(db, 'workspaces', context.tenantId, 'id ASC'); + const workspaceIds = workspaces.map(workspace => workspace.id); + + const reports = await Promise.all( + tenantRows(db, 'report_artifacts', context.tenantId, 'workspace_id ASC, id ASC') + .map(reportExport), + ); + const traces = tenantRows(db, 'trace_assets', context.tenantId, 'workspace_id ASC, id ASC') + .map(traceManifestExport); + const sessions = tenantRows( + db, + 'analysis_sessions', + context.tenantId, + 'workspace_id ASC, created_at ASC, id ASC', + ).map(sessionExport); + const runs = tenantRows( + db, + 'analysis_runs', + context.tenantId, + 'workspace_id ASC, started_at ASC, id ASC', + ).map(runExport); + const turns = tenantRows( + db, + 'conversation_turns', + context.tenantId, + 'workspace_id ASC, created_at ASC, id ASC', + ).map(turnExport); + const memoryEntries = tenantRows( + db, + 'memory_entries', + context.tenantId, + 'workspace_id ASC, updated_at ASC, id ASC', + ).map(memoryExport); + const auditEvents = tenantRows( + db, + 'audit_events', + context.tenantId, + 'created_at ASC, id ASC', + ).map(auditExport); + const providerCredentials = tenantRows( + db, + 'provider_credentials', + context.tenantId, + 'COALESCE(workspace_id, \'\') ASC, id ASC', + ).map(providerCredentialExport); + const providerSnapshots = tenantRows( + db, + 'provider_snapshots', + context.tenantId, + 'created_at ASC, id ASC', + ).map(providerSnapshotExport); + + const tenant = { + organization: organizationExport(organization), + workspaces: workspaces.map(workspaceExport), + users: tenantRows(db, 'users', context.tenantId, 'id ASC').map(userExport), + memberships: tenantRows( + db, + 'memberships', + context.tenantId, + 'workspace_id ASC, user_id ASC', + ).map(membershipExport), + }; + const organizationHash = sha256(stableStringify(tenant.organization)); + const proofPayload = { + tenantId: context.tenantId, + organizationHash, + workspaceIds, + generatedAt, + generatedBy: context.userId, + requestId: context.requestId, + }; + const bundle: TenantExportBundle = { + schemaVersion: 1, + generatedAt, + tenantIdentityProof: { + tenantId: context.tenantId, + organizationHash, + workspaceIds, + generatedBy: context.userId, + requestId: context.requestId, + proofHash: sha256(stableStringify(proofPayload)), + }, + manifest: { + traceFilesIncluded: false, + traceCount: traces.length, + reportCount: reports.length, + sessionCount: sessions.length, + runCount: runs.length, + turnCount: turns.length, + memoryRecordCount: memoryEntries.length, + auditEventCount: auditEvents.length, + providerCredentialCount: providerCredentials.length, + providerSnapshotCount: providerSnapshots.length, + }, + tenant, + traces, + reports, + sessions, + runs, + turns, + knowledge: { + memoryEntries, + }, + auditEvents, + providers: { + credentials: providerCredentials, + snapshots: providerSnapshots, + }, + }; + const canonicalPayload = stableStringify(bundle); + const bundleSha256 = sha256(canonicalPayload); + const generatedForFilename = generatedAt.replace(/[:.]/g, '-'); + return { + bundle, + bundleSha256, + canonicalPayload, + filename: `smartperfetto-tenant-${context.tenantId}-${generatedForFilename}.json`, + }; +} diff --git a/docs/features/enterprise-multi-tenant/README.md b/docs/features/enterprise-multi-tenant/README.md index 9b5cb9800..3ce9492ba 100644 --- a/docs/features/enterprise-multi-tenant/README.md +++ b/docs/features/enterprise-multi-tenant/README.md @@ -78,7 +78,7 @@ - [ ] 5.1 tenant / workspace / member / provider / quota 管理 UI 与后端 API - [x] 5.2 `audit_events` 表 + 关键操作埋点(trace / report / provider / memory / cleanup / delete / promote) - [x] 5.3 配额 / 预算 / retention policy(§16.1,含 quota_exceeded 终态) -- [ ] 5.4 Tenant export bundle(§16.2,含 SHA256 + tenant identity proof) +- [x] 5.4 Tenant export bundle(§16.2,含 SHA256 + tenant identity proof) - [ ] 5.5 Tenant tombstone + 7 天硬删窗口 + async purge + audit proof(§16.3) - [ ] 5.6 Custom skill v1 处置(§14.3):禁用 write endpoint 或修 loader 闭环 - [ ] 5.7 Legacy AI route 处置(§14.4 表) @@ -981,6 +981,13 @@ v1 要求: - provider metadata,去除 secrets。 - bundle SHA256 checksum 和 tenant identity proof。 +当前实现: + +- `GET /api/export/tenant` 生成 tenant-scoped JSON bundle,仅允许 `org_admin` 或 `tenant:export` scope。 +- bundle 包含 tenant identity proof、trace manifest、report HTML/JSON、sessions/runs/turns、`memory_entries` 私有知识数据、audit 子集和 provider metadata。 +- trace 文件本体默认不导出,provider `secret_ref` 与 secret-like JSON key 会被移除/脱敏。 +- 响应返回 `bundleSha256`,按 stable JSON 对 bundle payload 计算;导出操作写入 `tenant.exported` audit event。 + ### 16.3 Tenant 删除 删除流程: