diff --git a/backend/package.json b/backend/package.json index 50e0a9ba..77749354 100644 --- a/backend/package.json +++ b/backend/package.json @@ -41,7 +41,7 @@ "prepack": "npm run build", "typecheck": "tsc --noEmit", "test": "jest", - "test:core": "jest --runInBand --forceExit src/agent/communication/__tests__/agentMessageBus.test.ts src/agent/core/executors/__tests__/strategyExecutor.test.ts src/agent/core/executors/__tests__/hypothesisExecutor.test.ts src/agent/context/__tests__/enhancedSessionContext.test.ts src/tests/adbTools.test.ts src/services/__tests__/sessionLogger.test.ts src/services/__tests__/traceAnalysisSkillConfig.test.ts src/agent/agents/domain/__tests__/registry.test.ts src/agentv3/__tests__/sqlIncludeInjector.test.ts src/agentv3/__tests__/claudeRuntimeRuntimeSnapshots.test.ts src/middleware/__tests__/auth.test.ts src/services/__tests__/rbac.test.ts src/routes/__tests__/agentRoutesRbac.test.ts src/routes/__tests__/ownerGuardRoutes.test.ts src/routes/__tests__/requestContextRouteCoverage.test.ts src/middleware/__tests__/legacyApiCompatibility.test.ts src/services/__tests__/enterpriseDb.test.ts src/services/__tests__/enterpriseSchema.test.ts src/services/__tests__/enterpriseRepository.test.ts src/services/__tests__/runtimeSnapshotStore.test.ts src/routes/__tests__/enterpriseTraceMetadataRoutes.test.ts src/routes/__tests__/enterpriseReportRoutes.test.ts", + "test:core": "jest --runInBand --forceExit src/agent/communication/__tests__/agentMessageBus.test.ts src/agent/core/executors/__tests__/strategyExecutor.test.ts src/agent/core/executors/__tests__/hypothesisExecutor.test.ts src/agent/context/__tests__/enhancedSessionContext.test.ts src/tests/adbTools.test.ts src/services/__tests__/sessionLogger.test.ts src/services/__tests__/traceAnalysisSkillConfig.test.ts src/agent/agents/domain/__tests__/registry.test.ts src/agentv3/__tests__/sqlIncludeInjector.test.ts src/agentv3/__tests__/claudeRuntimeRuntimeSnapshots.test.ts src/middleware/__tests__/auth.test.ts src/services/__tests__/rbac.test.ts src/routes/__tests__/agentRoutesRbac.test.ts src/routes/__tests__/ownerGuardRoutes.test.ts src/routes/__tests__/requestContextRouteCoverage.test.ts src/middleware/__tests__/legacyApiCompatibility.test.ts src/services/__tests__/enterpriseDb.test.ts src/services/__tests__/enterpriseSchema.test.ts src/services/__tests__/enterpriseRepository.test.ts src/services/__tests__/runtimeSnapshotStore.test.ts src/services/providerManager/__tests__/enterpriseProviderStore.test.ts src/routes/__tests__/enterpriseTraceMetadataRoutes.test.ts src/routes/__tests__/enterpriseReportRoutes.test.ts", "test:watch": "jest --watch", "test:coverage": "jest --coverage", "test:unit": "jest --testPathPatterns=src/tests", diff --git a/backend/src/agentOpenAI/openAiConfig.ts b/backend/src/agentOpenAI/openAiConfig.ts index cc19461b..d9cfe1f0 100644 --- a/backend/src/agentOpenAI/openAiConfig.ts +++ b/backend/src/agentOpenAI/openAiConfig.ts @@ -3,7 +3,7 @@ // This file is part of SmartPerfetto. See LICENSE for details. import { DEFAULT_OUTPUT_LANGUAGE, outputLanguageDisplayName, parseOutputLanguage, type OutputLanguage } from '../agentv3/outputLanguage'; -import { getProviderService, type OpenAIProtocol } from '../services/providerManager'; +import { getProviderService, type OpenAIProtocol, type ProviderScope } from '../services/providerManager'; import { mergeIsolatedProviderEnv } from '../services/providerManager/envIsolation'; export interface OpenAIAgentConfig { @@ -38,13 +38,16 @@ function parseProtocol(value: string | undefined): OpenAIProtocol { return value === 'chat_completions' ? 'chat_completions' : 'responses'; } -export function createOpenAIEnv(providerId?: string | null): Record { +export function createOpenAIEnv( + providerId?: string | null, + providerScope?: ProviderScope, +): Record { const svc = getProviderService(); const provider = typeof providerId === 'string' - ? svc.getRawProvider(providerId) + ? svc.getRawProvider(providerId, providerScope) : providerId === null ? undefined - : svc.getRawEffectiveProvider(); + : svc.getRawEffectiveProvider(providerScope); if (typeof providerId === 'string' && !provider) { throw new Error(`Provider not found: ${providerId}`); @@ -56,13 +59,13 @@ export function createOpenAIEnv(providerId?: string | null): Record | undefined { } } +function providerScopeFromOptions(options: AnalysisOptions): ProviderScope | undefined { + if (!options.tenantId || !options.workspaceId) return undefined; + return { + tenantId: options.tenantId, + workspaceId: options.workspaceId, + userId: options.userId, + }; +} + function summarizeToolOutput(value: unknown): string { const text = typeof value === 'string' ? value : JSON.stringify(value); if (!text) return ''; @@ -206,7 +216,7 @@ export class OpenAIRuntime extends EventEmitter implements IOrchestrator { const startTime = Date.now(); let accumulatedAnswer = ''; let rounds = 0; - const config = loadOpenAIConfig(options.providerId); + const config = loadOpenAIConfig(options.providerId, providerScopeFromOptions(options)); const sceneType = classifyScene(query); const quickMode = options.analysisMode === 'fast'; diff --git a/backend/src/agentRuntime/runtimeSelection.ts b/backend/src/agentRuntime/runtimeSelection.ts index 806c6172..214bf92a 100644 --- a/backend/src/agentRuntime/runtimeSelection.ts +++ b/backend/src/agentRuntime/runtimeSelection.ts @@ -5,7 +5,7 @@ import type { TraceProcessorService } from '../services/traceProcessorService'; import type { IOrchestrator } from '../agent/core/orchestratorTypes'; import { createClaudeRuntime } from '../agentv3'; -import { getProviderService, type AgentRuntimeKind } from '../services/providerManager'; +import { getProviderService, type AgentRuntimeKind, type ProviderScope } from '../services/providerManager'; export type BackendAgentRuntimeKind = AgentRuntimeKind; @@ -26,6 +26,7 @@ export interface CreateAgentOrchestratorInput { */ providerId?: string | null; runtimeOverride?: BackendAgentRuntimeKind; + providerScope?: ProviderScope; } function parseRuntimeEnv(value: string | undefined): BackendAgentRuntimeKind | undefined { @@ -41,13 +42,14 @@ function parseRuntimeEnv(value: string | undefined): BackendAgentRuntimeKind | u export function resolveAgentRuntimeSelection( providerId?: string | null, runtimeOverride?: BackendAgentRuntimeKind, + providerScope?: ProviderScope, ): RuntimeSelection { const providerSvc = getProviderService(); const provider = typeof providerId === 'string' - ? providerSvc.getRawProvider(providerId) + ? providerSvc.getRawProvider(providerId, providerScope) : providerId === null || runtimeOverride ? undefined - : providerSvc.getRawEffectiveProvider(); + : providerSvc.getRawEffectiveProvider(providerScope); if (typeof providerId === 'string' && !provider) { throw new Error(`Provider not found: ${providerId}`); @@ -82,7 +84,7 @@ export function resolveAgentRuntimeSelection( } export function createAgentOrchestrator(input: CreateAgentOrchestratorInput): IOrchestrator { - const selection = resolveAgentRuntimeSelection(input.providerId, input.runtimeOverride); + const selection = resolveAgentRuntimeSelection(input.providerId, input.runtimeOverride, input.providerScope); switch (selection.kind) { case 'openai-agents-sdk': { // Lazy import keeps the OpenAI runtime isolated from Claude-only startup diff --git a/backend/src/agentv3/claudeConfig.ts b/backend/src/agentv3/claudeConfig.ts index b1f180d4..418647e3 100644 --- a/backend/src/agentv3/claudeConfig.ts +++ b/backend/src/agentv3/claudeConfig.ts @@ -6,6 +6,7 @@ import type { SceneType } from './sceneClassifier'; import { getRegisteredScenes } from './strategyLoader'; import { DEFAULT_OUTPUT_LANGUAGE, outputLanguageDisplayName, parseOutputLanguage, type OutputLanguage } from './outputLanguage'; import { mergeIsolatedProviderEnv } from '../services/providerManager/envIsolation'; +import type { ProviderScope } from '../services/providerManager'; export type EffortLevel = 'low' | 'medium' | 'high' | 'max'; @@ -275,16 +276,19 @@ export function getSdkBinaryOption(env: Record = pro * configured for the Claude Agent SDK runtime. * Falls back to raw process.env when no provider is configured. */ -export function createSdkEnv(sessionOverrideProviderId?: string | null): Record { +export function createSdkEnv( + sessionOverrideProviderId?: string | null, + providerScope?: ProviderScope, +): Record { // Lazy import to avoid circular dependency at module load time const { getProviderService } = require('../services/providerManager'); const svc = getProviderService(); const provider = typeof sessionOverrideProviderId === 'string' - ? svc.getRawProvider(sessionOverrideProviderId) + ? svc.getRawProvider(sessionOverrideProviderId, providerScope) : sessionOverrideProviderId === null ? undefined - : svc.getRawEffectiveProvider(); + : svc.getRawEffectiveProvider(providerScope); if (typeof sessionOverrideProviderId === 'string' && !provider) { throw new Error(`Provider not found: ${sessionOverrideProviderId}`); @@ -296,7 +300,7 @@ export function createSdkEnv(sessionOverrideProviderId?: string | null): Record< } const providerEnv = providerRuntime === 'claude-agent-sdk' && provider - ? svc.getEnvForProvider(provider.id) + ? svc.getEnvForProvider(provider.id, providerScope) : null; const env = mergeIsolatedProviderEnv(process.env, providerEnv); @@ -312,16 +316,20 @@ export function createSdkEnv(sessionOverrideProviderId?: string | null): Record< * this reads providerManager env vars at call time when the provider matches * the Claude Agent SDK runtime. */ -export function resolveRuntimeConfig(baseConfig: ClaudeAgentConfig, providerId?: string | null): ClaudeAgentConfig { +export function resolveRuntimeConfig( + baseConfig: ClaudeAgentConfig, + providerId?: string | null, + providerScope?: ProviderScope, +): ClaudeAgentConfig { // Lazy import to avoid circular dependency const { getProviderService } = require('../services/providerManager'); const svc = getProviderService(); const provider = typeof providerId === 'string' - ? svc.getRawProvider(providerId) + ? svc.getRawProvider(providerId, providerScope) : providerId === null ? undefined - : svc.getRawEffectiveProvider(); + : svc.getRawEffectiveProvider(providerScope); if (typeof providerId === 'string' && !provider) { throw new Error(`Provider not found: ${providerId}`); @@ -333,7 +341,7 @@ export function resolveRuntimeConfig(baseConfig: ClaudeAgentConfig, providerId?: } const providerEnv = providerRuntime === 'claude-agent-sdk' && provider - ? svc.getEnvForProvider(provider.id) + ? svc.getEnvForProvider(provider.id, providerScope) : null; if (!providerEnv) return baseConfig; diff --git a/backend/src/agentv3/claudeRuntime.ts b/backend/src/agentv3/claudeRuntime.ts index 6443a361..1103e7f7 100644 --- a/backend/src/agentv3/claudeRuntime.ts +++ b/backend/src/agentv3/claudeRuntime.ts @@ -90,6 +90,7 @@ import { saveClaudeSessionMapToRuntimeSnapshots, type ClaudeSessionMapRuntimeEntry, } from '../services/runtimeSnapshotStore'; +import type { ProviderScope } from '../services/providerManager'; const SESSION_MAP_FILE = path.resolve(__dirname, '../../logs/claude_session_map.json'); /** Max age for session map entries before pruning (24 hours). */ @@ -146,6 +147,15 @@ function loadSessionMapForCurrentMode(): Map { return legacyMap; } +function providerScopeFromOptions(options: AnalysisOptions): ProviderScope | undefined { + if (!options.tenantId || !options.workspaceId) return undefined; + return { + tenantId: options.tenantId, + workspaceId: options.workspaceId, + userId: options.userId, + }; +} + /** * Debounce timer for session map persistence — avoids blocking event loop on every SDK message. * P2-1: Use a Map keyed by the Map reference to support multiple ClaudeRuntime instances. @@ -494,7 +504,8 @@ export class ClaudeRuntime extends EventEmitter implements IOrchestrator { return { apps: [], primaryApp: undefined, method: 'none' as const }; }); - const runtimeConfig = resolveRuntimeConfig(this.config, options.providerId); + const providerScope = providerScopeFromOptions(options); + const runtimeConfig = resolveRuntimeConfig(this.config, options.providerId, providerScope); let queryComplexity: QueryComplexity; let classifierSource: 'user_explicit' | 'hard_rule' | 'ai'; @@ -597,7 +608,7 @@ export class ClaudeRuntime extends EventEmitter implements IOrchestrator { effectivePrompt = `${traceSection}\n\n${effectivePrompt}`; } - const sdkEnv = createSdkEnv(options.providerId); + const sdkEnv = createSdkEnv(options.providerId, providerScope); const { stream, close: closeSdk } = sdkQueryWithRetry({ prompt: effectivePrompt, @@ -1661,7 +1672,8 @@ export class ClaudeRuntime extends EventEmitter implements IOrchestrator { outputLanguage: this.config.outputLanguage, }); - const quickConfig = createQuickConfig(resolveRuntimeConfig(this.config, options.providerId)); + const providerScope = providerScopeFromOptions(options); + const quickConfig = createQuickConfig(resolveRuntimeConfig(this.config, options.providerId, providerScope)); const { handleMessage: bridge, getAccumulatedAnswer } = createSseBridge((update: StreamingUpdate) => { this.emitUpdate(update); @@ -1691,7 +1703,7 @@ export class ClaudeRuntime extends EventEmitter implements IOrchestrator { if (sessionMapEntry && existingSdkSessionId && enterpriseSessionMapStoreEnabled()) { this.persistSessionMapEntry(sessionId, traceId, sessionMapKey, sessionMapEntry, options); } - const sdkEnv = createSdkEnv(options.providerId); + const sdkEnv = createSdkEnv(options.providerId, providerScope); // Prepend pre-queried trace data so the AI skips basic SQL turns in fast mode let quickPrompt = query; diff --git a/backend/src/assistant/application/agentAnalyzeSessionService.ts b/backend/src/assistant/application/agentAnalyzeSessionService.ts index 6f8a9e44..7df3b0fd 100644 --- a/backend/src/assistant/application/agentAnalyzeSessionService.ts +++ b/backend/src/assistant/application/agentAnalyzeSessionService.ts @@ -11,7 +11,7 @@ import { import { createAgentOrchestrator } from '../../agentRuntime'; import { getProviderService } from '../../services/providerManager'; import { resolveProviderRuntimeSnapshot } from '../../services/providerManager/providerSnapshot'; -import type { AgentRuntimeKind } from '../../services/providerManager'; +import type { AgentRuntimeKind, ProviderScope } from '../../services/providerManager'; import { getTraceProcessorService } from '../../services/traceProcessorService'; import { type EnhancedSessionContext, @@ -112,6 +112,7 @@ interface PrepareAnalyzeSessionInput { query: string; requestedSessionId?: string; providerId?: string | null; + providerScope?: ProviderScope; options?: any; } @@ -155,12 +156,13 @@ export class AgentAnalyzeSessionService prepareSession(input: PrepareAnalyzeSessionInput): PrepareAnalyzeSessionResult { const { traceId, query, requestedSessionId, options = {} } = input; + const providerScope = input.providerScope; const explicitProviderId = input.providerId !== undefined ? input.providerId : options.providerId as string | null | undefined; const providerSvc = getProviderService(); - if (typeof explicitProviderId === 'string' && !providerSvc.getRawProvider(explicitProviderId)) { + if (typeof explicitProviderId === 'string' && !providerSvc.getRawProvider(explicitProviderId, providerScope)) { throw new AnalyzeSessionPreparationError(`Provider not found: ${explicitProviderId}`, { code: 'PROVIDER_NOT_FOUND', httpStatus: 404, @@ -169,14 +171,14 @@ export class AgentAnalyzeSessionService const activeProviderId = explicitProviderId !== undefined ? undefined - : providerSvc.getRawEffectiveProvider()?.id; + : providerSvc.getRawEffectiveProvider(providerScope)?.id; const sessionProviderId = explicitProviderId !== undefined ? explicitProviderId : activeProviderId ?? null; const resolveProviderSnapshotHash = ( providerId: string | null, runtimeOverride?: AgentRuntimeKind, - ) => resolveProviderRuntimeSnapshot(providerSvc, providerId, runtimeOverride).snapshotHash; + ) => resolveProviderRuntimeSnapshot(providerSvc, providerId, runtimeOverride, providerScope).snapshotHash; const sessionProviderSnapshotHash = resolveProviderSnapshotHash(sessionProviderId); if (requestedSessionId) { @@ -222,6 +224,7 @@ export class AgentAnalyzeSessionService existingSession.orchestrator = createAgentOrchestrator({ traceProcessorService: getTraceProcessorService(), providerId: liveSessionProviderId, + providerScope, }); existingSession.providerId = liveSessionProviderId; existingSession.providerSnapshotHash = liveSessionProviderSnapshotHash; @@ -297,7 +300,7 @@ export class AgentAnalyzeSessionService stateSnapshot && snapshotProviderId !== explicitProviderId, ); - if (!snapshotProviderMismatch && typeof snapshotProviderId === 'string' && !providerSvc.getRawProvider(snapshotProviderId)) { + if (!snapshotProviderMismatch && typeof snapshotProviderId === 'string' && !providerSvc.getRawProvider(snapshotProviderId, providerScope)) { throw new AnalyzeSessionPreparationError(`Provider not found: ${snapshotProviderId}`, { code: 'PROVIDER_NOT_FOUND', httpStatus: 404, @@ -326,6 +329,7 @@ export class AgentAnalyzeSessionService traceProcessorService: getTraceProcessorService(), providerId: restoredProviderId, runtimeOverride: restoredProviderId ? undefined : stateSnapshot?.agentRuntimeKind, + providerScope, }); const focusSnapshot = this.sessionPersistenceService.loadFocusStore(requestedSessionId); @@ -497,6 +501,7 @@ export class AgentAnalyzeSessionService const orchestrator: IOrchestrator = createAgentOrchestrator({ traceProcessorService: getTraceProcessorService(), providerId: sessionProviderId, + providerScope, }); const logger = this.createSessionLogger(sessionId); diff --git a/backend/src/routes/agentResumeRoutes.ts b/backend/src/routes/agentResumeRoutes.ts index ba891ed4..79e7f5c1 100644 --- a/backend/src/routes/agentResumeRoutes.ts +++ b/backend/src/routes/agentResumeRoutes.ts @@ -118,12 +118,17 @@ export function registerAgentResumeRoutes( sessionContextManager.set(sessionId, effectiveTraceId, restoredContext); const providerSvc = getProviderService(); + const providerScope = { + tenantId: requestContext.tenantId, + workspaceId: requestContext.workspaceId, + userId: requestContext.userId, + }; const snapshot = persistenceService.loadSessionStateSnapshot(sessionId); const snapshotProviderId = snapshot?.agentRuntimeProviderId; const restoredProviderId = snapshot ? snapshotProviderId ?? null - : providerSvc.getRawEffectiveProvider()?.id ?? null; - if (typeof snapshotProviderId === 'string' && !providerSvc.getRawProvider(snapshotProviderId)) { + : providerSvc.getRawEffectiveProvider(providerScope)?.id ?? null; + if (typeof snapshotProviderId === 'string' && !providerSvc.getRawProvider(snapshotProviderId, providerScope)) { return res.status(404).json({ success: false, error: `Provider not found: ${snapshotProviderId}`, @@ -135,6 +140,7 @@ export function registerAgentResumeRoutes( providerSvc, restoredProviderId, restoredProviderId ? undefined : snapshot?.agentRuntimeKind, + providerScope, ).snapshotHash; const providerSnapshotChanged = Boolean( snapshot?.agentRuntimeProviderSnapshotHash && @@ -144,6 +150,7 @@ export function registerAgentResumeRoutes( traceProcessorService: getTraceProcessorService(), providerId: restoredProviderId, runtimeOverride: restoredProviderId ? undefined : snapshot?.agentRuntimeKind, + providerScope, }) as any; const focusSnapshot = persistenceService.loadFocusStore(sessionId); diff --git a/backend/src/routes/agentRoutes.ts b/backend/src/routes/agentRoutes.ts index 7226eb37..fbf3fbe2 100644 --- a/backend/src/routes/agentRoutes.ts +++ b/backend/src/routes/agentRoutes.ts @@ -886,6 +886,11 @@ async function handleAnalyzeRequest( query, requestedSessionId, providerId, + providerScope: { + tenantId: requestContext.tenantId, + workspaceId: requestContext.workspaceId, + userId: requestContext.userId, + }, options, }); sessionId = prepared.sessionId; diff --git a/backend/src/routes/providerRoutes.ts b/backend/src/routes/providerRoutes.ts index 50e78fdd..793412aa 100644 --- a/backend/src/routes/providerRoutes.ts +++ b/backend/src/routes/providerRoutes.ts @@ -3,28 +3,38 @@ import express from 'express'; import { getProviderService, officialTemplates } from '../services/providerManager'; -import type { AgentRuntimeKind, ProviderCreateInput, ProviderUpdateInput } from '../services/providerManager'; +import type { AgentRuntimeKind, ProviderCreateInput, ProviderScope, ProviderUpdateInput } from '../services/providerManager'; import { testProviderConnection } from '../services/providerManager/connectionTester'; -import { authenticate } from '../middleware/auth'; +import { authenticate, requireRequestContext } from '../middleware/auth'; const router = express.Router(); router.use(authenticate); +function providerScopeForRequest(req: express.Request): ProviderScope { + const context = requireRequestContext(req); + return { + tenantId: context.tenantId, + workspaceId: context.workspaceId, + userId: context.userId, + }; +} + router.get('/', (req, res) => { const svc = getProviderService(); - res.json({ success: true, providers: svc.list() }); + res.json({ success: true, providers: svc.list(providerScopeForRequest(req)) }); }); router.get('/templates', (_req, res) => { res.json({ success: true, templates: officialTemplates }); }); -router.get('/effective', (_req, res) => { +router.get('/effective', (req, res) => { const svc = getProviderService(); - const env = svc.getEffectiveEnv(); + const scope = providerScopeForRequest(req); + const env = svc.getEffectiveEnv(scope); if (env) { - const active = svc.list().find(p => p.isActive); + const active = svc.list(scope).find(p => p.isActive); res.json({ success: true, source: 'provider-manager', provider: active, env: maskEnvKeys(env) }); } else { res.json({ success: true, source: 'env-fallback', provider: null }); @@ -33,7 +43,7 @@ router.get('/effective', (_req, res) => { router.get('/:id', (req, res) => { const svc = getProviderService(); - const provider = svc.get(req.params.id); + const provider = svc.get(req.params.id, providerScopeForRequest(req)); if (!provider) return res.status(404).json({ success: false, error: 'Provider not found' }); res.json({ success: true, provider }); }); @@ -42,8 +52,9 @@ router.post('/', (req, res) => { try { const svc = getProviderService(); const input: ProviderCreateInput = req.body; - const provider = svc.create(input); - res.status(201).json({ success: true, provider: svc.get(provider.id) }); + const scope = providerScopeForRequest(req); + const provider = svc.create(input, scope); + res.status(201).json({ success: true, provider: svc.get(provider.id, scope) }); } catch (err: any) { res.status(400).json({ success: false, error: err.message }); } @@ -53,8 +64,9 @@ router.patch('/:id', (req, res) => { try { const svc = getProviderService(); const input: ProviderUpdateInput = req.body; - svc.update(req.params.id, input); - res.json({ success: true, provider: svc.get(req.params.id) }); + const scope = providerScopeForRequest(req); + svc.update(req.params.id, input, scope); + res.json({ success: true, provider: svc.get(req.params.id, scope) }); } catch (err: any) { const status = err.message.includes('not found') ? 404 : 400; res.status(status).json({ success: false, error: err.message }); @@ -64,7 +76,7 @@ router.patch('/:id', (req, res) => { router.delete('/:id', (req, res) => { try { const svc = getProviderService(); - svc.delete(req.params.id); + svc.delete(req.params.id, providerScopeForRequest(req)); res.json({ success: true }); } catch (err: any) { const status = err.message.includes('not found') ? 404 : 400; @@ -72,16 +84,16 @@ router.delete('/:id', (req, res) => { } }); -router.post('/deactivate', (_req, res) => { +router.post('/deactivate', (req, res) => { const svc = getProviderService(); - svc.deactivateAll(); + svc.deactivateAll(providerScopeForRequest(req)); res.json({ success: true }); }); router.post('/:id/activate', (req, res) => { try { const svc = getProviderService(); - svc.activate(req.params.id); + svc.activate(req.params.id, providerScopeForRequest(req)); res.json({ success: true }); } catch (err: any) { const status = err.message.includes('not found') ? 404 : 400; @@ -96,8 +108,9 @@ 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' }); } - svc.switchAgentRuntime(req.params.id, runtime); - res.json({ success: true, provider: svc.get(req.params.id) }); + const scope = providerScopeForRequest(req); + svc.switchAgentRuntime(req.params.id, runtime, scope); + res.json({ success: true, provider: svc.get(req.params.id, scope) }); } catch (err: any) { const status = err.message.includes('not found') ? 404 : 400; res.status(status).json({ success: false, error: err.message }); @@ -106,7 +119,7 @@ router.post('/:id/runtime', (req, res) => { router.post('/:id/test', async (req, res) => { const svc = getProviderService(); - const provider = svc.getRaw(req.params.id); + 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); diff --git a/backend/src/services/providerManager/__tests__/enterpriseProviderStore.test.ts b/backend/src/services/providerManager/__tests__/enterpriseProviderStore.test.ts new file mode 100644 index 00000000..84043b7e --- /dev/null +++ b/backend/src/services/providerManager/__tests__/enterpriseProviderStore.test.ts @@ -0,0 +1,143 @@ +// 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 fs from 'fs/promises'; +import os from 'os'; +import path from 'path'; +import { ENTERPRISE_FEATURE_FLAG_ENV } from '../../../config'; +import { ENTERPRISE_DB_PATH_ENV, openEnterpriseDb } from '../../enterpriseDb'; +import { SECRET_STORE_DIR_ENV } from '../localSecretStore'; +import { ProviderService } from '../providerService'; +import type { ProviderCreateInput, ProviderScope } from '../types'; + +const originalEnv = { + enterprise: process.env[ENTERPRISE_FEATURE_FLAG_ENV], + enterpriseDbPath: process.env[ENTERPRISE_DB_PATH_ENV], + secretStoreDir: process.env[SECRET_STORE_DIR_ENV], +}; + +interface ProviderCredentialRow { + id: string; + tenant_id: string; + workspace_id: string | null; + owner_user_id: string | null; + scope: string; + models_json: string; + secret_ref: string; + policy_json: string; +} + +let tmpDir: string | undefined; +let dbPath: string; +let secretDir: string; +let svc: ProviderService; + +const input: ProviderCreateInput = { + name: 'Enterprise OpenAI', + category: 'official', + type: 'openai', + models: { primary: 'gpt-5.5', light: 'gpt-5.4-mini' }, + connection: { + openaiApiKey: 'sk-enterprise-secret-a', + openaiBaseUrl: 'https://api.openai.com/v1', + }, +}; + +function restoreEnvValue(key: string, value: string | undefined): void { + if (value === undefined) { + delete process.env[key]; + } else { + process.env[key] = value; + } +} + +function scope(userId: string): ProviderScope { + return { + tenantId: 'tenant-a', + workspaceId: 'workspace-a', + userId, + }; +} + +function readProviderRows(): ProviderCredentialRow[] { + const db = openEnterpriseDb(dbPath); + try { + return db.prepare(` + SELECT * + FROM provider_credentials + ORDER BY owner_user_id + `).all(); + } finally { + db.close(); + } +} + +beforeEach(async () => { + tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'smartperfetto-enterprise-provider-store-')); + dbPath = path.join(tmpDir, 'enterprise.sqlite'); + secretDir = path.join(tmpDir, 'secrets'); + process.env[ENTERPRISE_FEATURE_FLAG_ENV] = 'true'; + process.env[ENTERPRISE_DB_PATH_ENV] = dbPath; + process.env[SECRET_STORE_DIR_ENV] = secretDir; + svc = new ProviderService(path.join(tmpDir, 'providers.json')); +}); + +afterEach(async () => { + restoreEnvValue(ENTERPRISE_FEATURE_FLAG_ENV, originalEnv.enterprise); + restoreEnvValue(ENTERPRISE_DB_PATH_ENV, originalEnv.enterpriseDbPath); + restoreEnvValue(SECRET_STORE_DIR_ENV, originalEnv.secretStoreDir); + if (tmpDir) { + await fs.rm(tmpDir, { recursive: true, force: true }); + tmpDir = undefined; + } +}); + +describe('enterprise provider store', () => { + it('stores provider metadata in DB and encrypted secrets outside provider_credentials', async () => { + const provider = svc.create(input, scope('user-a')); + svc.activate(provider.id, scope('user-a')); + + const row = readProviderRows()[0]; + expect(row).toEqual(expect.objectContaining({ + id: provider.id, + tenant_id: 'tenant-a', + workspace_id: 'workspace-a', + owner_user_id: 'user-a', + scope: 'personal', + })); + expect(row.models_json).toContain('gpt-5.5'); + expect(row.policy_json).toContain('openaiBaseUrl'); + expect(row.policy_json).not.toContain('sk-enterprise-secret-a'); + expect(row.secret_ref).toMatch(/^secret:provider:/); + + const providerJsonPath = path.join(tmpDir!, 'providers.json'); + await expect(fs.access(providerJsonPath)).rejects.toBeTruthy(); + + const secretFile = await fs.readFile(path.join(secretDir, 'provider-secrets.enc.json'), 'utf-8'); + expect(secretFile).not.toContain('sk-enterprise-secret-a'); + expect(svc.getEnvForProvider(provider.id, scope('user-a'))!.OPENAI_API_KEY) + .toBe('sk-enterprise-secret-a'); + }); + + it('keeps personal provider activation isolated by user scope', () => { + const providerA = svc.create({ + ...input, + name: 'Provider A', + connection: { openaiApiKey: 'sk-user-a' }, + }, scope('user-a')); + svc.activate(providerA.id, scope('user-a')); + + const providerB = svc.create({ + ...input, + name: 'Provider B', + connection: { openaiApiKey: 'sk-user-b' }, + }, scope('user-b')); + svc.activate(providerB.id, scope('user-b')); + + expect(svc.list(scope('user-a')).map(provider => provider.id)).toEqual([providerA.id]); + expect(svc.list(scope('user-b')).map(provider => provider.id)).toEqual([providerB.id]); + expect(svc.getEffectiveEnv(scope('user-a'))!.OPENAI_API_KEY).toBe('sk-user-a'); + expect(svc.getEffectiveEnv(scope('user-b'))!.OPENAI_API_KEY).toBe('sk-user-b'); + }); +}); diff --git a/backend/src/services/providerManager/index.ts b/backend/src/services/providerManager/index.ts index 42e741a4..65c4f18c 100644 --- a/backend/src/services/providerManager/index.ts +++ b/backend/src/services/providerManager/index.ts @@ -9,6 +9,7 @@ export type { AgentRuntimeKind, OpenAIProtocol, ProviderConfig, + ProviderScope, ProviderCreateInput, ProviderUpdateInput, OfficialProviderTemplate, diff --git a/backend/src/services/providerManager/localSecretStore.ts b/backend/src/services/providerManager/localSecretStore.ts new file mode 100644 index 00000000..9be9846d --- /dev/null +++ b/backend/src/services/providerManager/localSecretStore.ts @@ -0,0 +1,157 @@ +// 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'; +import path from 'path'; + +export const SECRET_STORE_DIR_ENV = 'SMARTPERFETTO_SECRET_STORE_DIR'; +export const SECRET_STORE_MASTER_KEY_ENV = 'SMARTPERFETTO_SECRET_STORE_MASTER_KEY'; + +interface EncryptedSecretEntry { + version: number; + algorithm: 'aes-256-gcm'; + iv: string; + tag: string; + ciphertext: string; + updatedAt: number; +} + +interface EncryptedSecretFile { + version: 1; + entries: Record; +} + +function resolveSecretStoreDir(): string { + const configured = process.env[SECRET_STORE_DIR_ENV]; + return path.resolve(configured && configured.trim().length > 0 + ? configured + : path.join(process.cwd(), 'data', 'secrets')); +} + +function decodeMasterKey(raw: string): Buffer { + const trimmed = raw.trim(); + if (/^[a-fA-F0-9]{64}$/.test(trimmed)) { + return Buffer.from(trimmed, 'hex'); + } + try { + const decoded = Buffer.from(trimmed, 'base64'); + if (decoded.length === 32) return decoded; + } catch { + // Fall through to passphrase hashing. + } + return crypto.createHash('sha256').update(trimmed, 'utf8').digest(); +} + +function readOrCreateLocalMasterKey(dir: string): Buffer { + const keyPath = path.join(dir, '.master-key'); + if (fs.existsSync(keyPath)) { + return decodeMasterKey(fs.readFileSync(keyPath, 'utf-8')); + } + const key = crypto.randomBytes(32); + fs.mkdirSync(dir, { recursive: true }); + fs.writeFileSync(keyPath, key.toString('base64'), { mode: 0o600 }); + try { fs.chmodSync(keyPath, 0o600); } catch { /* Windows */ } + return key; +} + +function resolveMasterKey(dir: string): Buffer { + const configured = process.env[SECRET_STORE_MASTER_KEY_ENV]; + if (configured && configured.trim().length > 0) { + return decodeMasterKey(configured); + } + return readOrCreateLocalMasterKey(dir); +} + +function emptySecretFile(): EncryptedSecretFile { + return { version: 1, entries: {} }; +} + +export class LocalEncryptedSecretStore { + private readonly dir: string; + private readonly filePath: string; + private readonly key: Buffer; + + constructor(dir: string = resolveSecretStoreDir()) { + this.dir = dir; + this.filePath = path.join(dir, 'provider-secrets.enc.json'); + this.key = resolveMasterKey(dir); + } + + get(ref: string): Record { + const file = this.readFile(); + const entry = file.entries[ref]; + if (!entry) return {}; + try { + const decipher = crypto.createDecipheriv( + entry.algorithm, + this.key, + Buffer.from(entry.iv, 'base64'), + ); + decipher.setAuthTag(Buffer.from(entry.tag, 'base64')); + const plaintext = Buffer.concat([ + decipher.update(Buffer.from(entry.ciphertext, 'base64')), + decipher.final(), + ]).toString('utf-8'); + const parsed = JSON.parse(plaintext); + return parsed && typeof parsed === 'object' && !Array.isArray(parsed) + ? parsed as Record + : {}; + } catch (err) { + console.warn('[LocalSecretStore] Failed to decrypt secret:', (err as Error).message); + return {}; + } + } + + put(ref: string, value: Record): number { + const file = this.readFile(); + const previous = file.entries[ref]; + const iv = crypto.randomBytes(12); + const cipher = crypto.createCipheriv('aes-256-gcm', this.key, iv); + const ciphertext = Buffer.concat([ + cipher.update(JSON.stringify(value), 'utf-8'), + cipher.final(), + ]); + const version = (previous?.version ?? 0) + 1; + file.entries[ref] = { + version, + algorithm: 'aes-256-gcm', + iv: iv.toString('base64'), + tag: cipher.getAuthTag().toString('base64'), + ciphertext: ciphertext.toString('base64'), + updatedAt: Date.now(), + }; + this.writeFile(file); + return version; + } + + delete(ref: string): boolean { + const file = this.readFile(); + const existed = Object.prototype.hasOwnProperty.call(file.entries, ref); + if (!existed) return false; + delete file.entries[ref]; + this.writeFile(file); + return true; + } + + private readFile(): EncryptedSecretFile { + if (!fs.existsSync(this.filePath)) return emptySecretFile(); + try { + const parsed = JSON.parse(fs.readFileSync(this.filePath, 'utf-8')); + return parsed && parsed.version === 1 && parsed.entries && typeof parsed.entries === 'object' + ? parsed as EncryptedSecretFile + : emptySecretFile(); + } catch { + return emptySecretFile(); + } + } + + private writeFile(file: EncryptedSecretFile): void { + fs.mkdirSync(this.dir, { recursive: true }); + const tmp = `${this.filePath}.tmp`; + fs.writeFileSync(tmp, JSON.stringify(file, null, 2), { mode: 0o600 }); + fs.renameSync(tmp, this.filePath); + try { fs.chmodSync(this.filePath, 0o600); } catch { /* Windows */ } + } +} diff --git a/backend/src/services/providerManager/providerService.ts b/backend/src/services/providerManager/providerService.ts index cbfb3943..d597a3a5 100644 --- a/backend/src/services/providerManager/providerService.ts +++ b/backend/src/services/providerManager/providerService.ts @@ -9,6 +9,7 @@ import type { OpenAIProtocol, ProviderConfig, ProviderCreateInput, + ProviderScope, ProviderUpdateInput, ProviderType, } from './types'; @@ -98,17 +99,17 @@ export class ProviderService { this.store.load(); } - list(): ProviderConfig[] { - return this.store.getAll().map(maskProvider); + list(scope?: ProviderScope): ProviderConfig[] { + return this.store.getAll(scope).map(maskProvider); } - get(id: string): ProviderConfig | undefined { - const p = this.store.get(id); + get(id: string, scope?: ProviderScope): ProviderConfig | undefined { + const p = this.store.get(id, scope); return p ? maskProvider(p) : undefined; } - getRaw(id: string): ProviderConfig | undefined { - return this.store.get(id); + getRaw(id: string, scope?: ProviderScope): ProviderConfig | undefined { + return this.store.get(id, scope); } private static VALID_TYPES: ProviderType[] = [ @@ -121,7 +122,7 @@ export class ProviderService { 'custom', ]; - create(input: ProviderCreateInput): ProviderConfig { + create(input: ProviderCreateInput, scope?: ProviderScope): ProviderConfig { if (!input.name?.trim()) throw new Error('Provider name is required'); if (!input.type) throw new Error('Provider type is required'); if (!ProviderService.VALID_TYPES.includes(input.type as ProviderType)) { @@ -147,12 +148,12 @@ export class ProviderService { ...(input.custom ? { custom: input.custom } : {}), }; - this.store.set(provider); + this.store.set(provider, scope); return provider; } - update(id: string, input: ProviderUpdateInput): ProviderConfig { - const existing = this.store.get(id); + update(id: string, input: ProviderUpdateInput, scope?: ProviderScope): ProviderConfig { + const existing = this.store.get(id, scope); if (!existing) throw new Error(`Provider not found: ${id}`); const updated: ProviderConfig = { @@ -177,61 +178,61 @@ export class ProviderService { if (input.tuning !== undefined) updated.tuning = input.tuning ?? undefined; if (input.custom !== undefined) updated.custom = input.custom ?? undefined; - this.store.set(updated); + this.store.set(updated, scope); return updated; } - delete(id: string): void { - const existing = this.store.get(id); + delete(id: string, scope?: ProviderScope): void { + const existing = this.store.get(id, scope); if (!existing) throw new Error(`Provider not found: ${id}`); if (existing.isActive) throw new Error('Cannot delete the active provider. Deactivate or switch first.'); - this.store.delete(id); + this.store.delete(id, scope); } - activate(id: string): void { - const target = this.store.get(id); + activate(id: string, scope?: ProviderScope): void { + const target = this.store.get(id, scope); if (!target) throw new Error(`Provider not found: ${id}`); - const current = this.store.getActive(); + const current = this.store.getActive(scope); if (current && current.id !== id) { - this.store.set({ ...current, isActive: false, updatedAt: new Date().toISOString() }); + this.store.set({ ...current, isActive: false, updatedAt: new Date().toISOString() }, scope); } - this.store.set({ ...target, isActive: true, updatedAt: new Date().toISOString() }); + this.store.set({ ...target, isActive: true, updatedAt: new Date().toISOString() }, scope); } - deactivateAll(): void { - const current = this.store.getActive(); + deactivateAll(scope?: ProviderScope): void { + const current = this.store.getActive(scope); if (current) { - this.store.set({ ...current, isActive: false, updatedAt: new Date().toISOString() }); + this.store.set({ ...current, isActive: false, updatedAt: new Date().toISOString() }, scope); } } - switchAgentRuntime(id: string, runtime: AgentRuntimeKind): ProviderConfig { + switchAgentRuntime(id: string, runtime: AgentRuntimeKind, scope?: ProviderScope): ProviderConfig { if (runtime !== 'claude-agent-sdk' && runtime !== 'openai-agents-sdk') { throw new Error(`Invalid agent runtime: ${runtime}`); } - return this.update(id, { connection: { agentRuntime: runtime } }); + return this.update(id, { connection: { agentRuntime: runtime } }, scope); } - getEffectiveEnv(): Record | null { - const active = this.store.getActive(); + getEffectiveEnv(scope?: ProviderScope): Record | null { + const active = this.store.getActive(scope); if (!active) return null; return this.toEnvVars(active); } - getEnvForProvider(id: string): Record | null { - const provider = this.store.get(id); + getEnvForProvider(id: string, scope?: ProviderScope): Record | null { + const provider = this.store.get(id, scope); if (!provider) return null; return this.toEnvVars(provider); } - getRawEffectiveProvider(): ProviderConfig | undefined { - return this.store.getActive(); + getRawEffectiveProvider(scope?: ProviderScope): ProviderConfig | undefined { + return this.store.getActive(scope); } - getRawProvider(id: string): ProviderConfig | undefined { - return this.store.get(id); + getRawProvider(id: string, scope?: ProviderScope): ProviderConfig | undefined { + return this.store.get(id, scope); } resolveAgentRuntime(provider?: ProviderConfig | null): AgentRuntimeKind { diff --git a/backend/src/services/providerManager/providerSnapshot.ts b/backend/src/services/providerManager/providerSnapshot.ts index ec06be02..9c6361b8 100644 --- a/backend/src/services/providerManager/providerSnapshot.ts +++ b/backend/src/services/providerManager/providerSnapshot.ts @@ -4,7 +4,7 @@ import crypto from 'crypto'; import type { ProviderService } from './providerService'; -import type { AgentRuntimeKind, ProviderConfig, ProviderTuning } from './types'; +import type { AgentRuntimeKind, ProviderConfig, ProviderScope, ProviderTuning } from './types'; type JsonPrimitive = string | number | boolean | null; type JsonValue = JsonPrimitive | JsonValue[] | { [key: string]: JsonValue }; @@ -223,9 +223,10 @@ function providerSecretVersion(provider: ProviderConfig): string { function providerRuntimeSnapshot( providerService: ProviderService, provider: ProviderConfig, + providerScope?: ProviderScope, ): ProviderRuntimeSnapshot { const runtimeKind = providerService.resolveAgentRuntime(provider); - const env = providerService.getEnvForProvider(provider.id) ?? {}; + const env = providerService.getEnvForProvider(provider.id, providerScope) ?? {}; const nonSecretEnv = pickEnv(env, PROVIDER_RUNTIME_ENV_KEYS); return { version: 1, @@ -255,12 +256,13 @@ export function resolveProviderRuntimeSnapshot( providerService: ProviderService, providerId: string | null, runtimeOverride?: AgentRuntimeKind, + providerScope?: ProviderScope, ): ProviderRuntimeSnapshotResolution { const snapshot = typeof providerId === 'string' ? (() => { - const provider = providerService.getRawProvider(providerId); + const provider = providerService.getRawProvider(providerId, providerScope); if (!provider) throw new Error(`Provider not found: ${providerId}`); - return providerRuntimeSnapshot(providerService, provider); + return providerRuntimeSnapshot(providerService, provider, providerScope); })() : envRuntimeSnapshot(runtimeOverride); return { diff --git a/backend/src/services/providerManager/providerStore.ts b/backend/src/services/providerManager/providerStore.ts index 6d460a3e..a5cd185d 100644 --- a/backend/src/services/providerManager/providerStore.ts +++ b/backend/src/services/providerManager/providerStore.ts @@ -3,17 +3,183 @@ import * as fs from 'fs'; import * as path from 'path'; -import type { ProviderConfig } from './types'; +import { resolveFeatureConfig } from '../../config'; +import { openEnterpriseDb } from '../enterpriseDb'; +import type { ProviderConfig, ProviderConnection, ProviderScope } from './types'; +import { LocalEncryptedSecretStore } from './localSecretStore'; + +type ProviderCredentialScope = 'personal' | 'workspace' | 'org'; + +interface ProviderCredentialRow { + id: string; + tenant_id: string; + workspace_id: string | null; + owner_user_id: string | null; + scope: ProviderCredentialScope; + name: string; + type: ProviderConfig['type']; + models_json: string; + secret_ref: string; + policy_json: string | null; + created_at: number; + updated_at: number; +} + +interface ProviderPolicyJson { + category?: ProviderConfig['category']; + isActive?: boolean; + connection?: ProviderConnection; + tuning?: ProviderConfig['tuning']; + custom?: ProviderConfig['custom']; + secretVersion?: number; +} + +const DEFAULT_PROVIDER_SCOPE: Required = { + tenantId: 'default-dev-tenant', + workspaceId: 'default-workspace', + userId: 'dev-user-123', +}; + +const SAFE_PROVIDER_SCOPE_RE = /^[a-zA-Z0-9._:-]+$/; +const SENSITIVE_CONNECTION_FIELDS: Array = [ + 'apiKey', + 'claudeApiKey', + 'claudeAuthToken', + 'openaiApiKey', + 'awsBearerToken', + 'awsAccessKeyId', + 'awsSecretAccessKey', + 'awsSessionToken', +]; + +function enterpriseProviderStoreEnabled(): boolean { + return resolveFeatureConfig(process.env).enterprise; +} + +function assertSafeScopeSegment(value: string, label: string): string { + if (!SAFE_PROVIDER_SCOPE_RE.test(value) || value === '.' || value === '..') { + throw new Error(`Unsafe provider ${label}: ${value}`); + } + return value; +} + +function resolveProviderScope(scope?: ProviderScope): Required { + return { + tenantId: assertSafeScopeSegment(scope?.tenantId || DEFAULT_PROVIDER_SCOPE.tenantId, 'tenant id'), + workspaceId: assertSafeScopeSegment(scope?.workspaceId || DEFAULT_PROVIDER_SCOPE.workspaceId, 'workspace id'), + userId: assertSafeScopeSegment(scope?.userId || DEFAULT_PROVIDER_SCOPE.userId, 'user id'), + }; +} + +function parseJsonObject(value: string | null): Record { + if (!value) return {}; + try { + const parsed = JSON.parse(value); + return parsed && typeof parsed === 'object' && !Array.isArray(parsed) + ? parsed as Record + : {}; + } catch { + return {}; + } +} + +function toEpochMs(value: string): number { + const parsed = Date.parse(value); + return Number.isFinite(parsed) ? parsed : Date.now(); +} + +function toIsoString(value: number): string { + return new Date(value).toISOString(); +} + +function splitConnectionSecrets(connection: ProviderConnection): { + publicConnection: ProviderConnection; + secretConnection: Record; +} { + const publicConnection: ProviderConnection = {}; + const secretConnection: Record = {}; + for (const [key, value] of Object.entries(connection)) { + if (value === undefined) continue; + if (SENSITIVE_CONNECTION_FIELDS.includes(key as keyof ProviderConnection)) { + if (typeof value === 'string' && value.length > 0) { + secretConnection[key] = value; + } + } else { + (publicConnection as Record)[key] = value; + } + } + return { publicConnection, secretConnection }; +} + +function mergeConnectionSecrets( + publicConnection: ProviderConnection | undefined, + secretConnection: Record, +): ProviderConnection { + return { + ...(publicConnection ?? {}), + ...secretConnection, + }; +} + +function providerSecretRef(scope: Required, providerId: string): string { + return `secret:provider:${scope.tenantId}:${scope.workspaceId}:${scope.userId}:${providerId}`; +} + +function ensureEnterpriseProviderGraph(scope: Required): void { + const now = Date.now(); + const db = openEnterpriseDb(); + try { + db.prepare(` + INSERT OR IGNORE INTO organizations (id, name, status, plan, created_at, updated_at) + VALUES (?, ?, 'active', 'enterprise', ?, ?) + `).run(scope.tenantId, scope.tenantId, now, now); + db.prepare(` + INSERT OR IGNORE INTO workspaces (id, tenant_id, name, created_at, updated_at) + VALUES (?, ?, ?, ?, ?) + `).run(scope.workspaceId, scope.tenantId, scope.workspaceId, now, now); + db.prepare(` + INSERT INTO users (id, tenant_id, email, display_name, idp_subject, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(id) DO UPDATE SET + email = excluded.email, + display_name = excluded.display_name, + updated_at = excluded.updated_at + `).run( + scope.userId, + scope.tenantId, + `${scope.userId}@provider.local`, + scope.userId, + `provider:${scope.userId}`, + now, + now, + ); + } finally { + db.close(); + } +} + +function accessibleProviderWhere(): string { + return ` + tenant_id = @tenantId + AND ( + (scope = 'personal' AND workspace_id = @workspaceId AND owner_user_id = @userId) + OR (scope = 'workspace' AND workspace_id = @workspaceId AND owner_user_id IS NULL) + OR (scope = 'org' AND workspace_id IS NULL AND owner_user_id IS NULL) + ) + `; +} export class ProviderStore { private providers = new Map(); private filePath: string; + private secretStore?: LocalEncryptedSecretStore; constructor(filePath: string) { this.filePath = filePath; } load(): void { + if (enterpriseProviderStoreEnabled()) return; this.providers.clear(); if (!fs.existsSync(this.filePath)) return; try { @@ -25,33 +191,210 @@ export class ProviderStore { } } - getAll(): ProviderConfig[] { + getAll(scope?: ProviderScope): ProviderConfig[] { + if (enterpriseProviderStoreEnabled()) { + return this.getAllEnterprise(scope); + } return Array.from(this.providers.values()); } - get(id: string): ProviderConfig | undefined { + get(id: string, scope?: ProviderScope): ProviderConfig | undefined { + if (enterpriseProviderStoreEnabled()) { + return this.getEnterprise(id, scope); + } return this.providers.get(id); } - getActive(): ProviderConfig | undefined { - for (const p of this.providers.values()) { + getActive(scope?: ProviderScope): ProviderConfig | undefined { + for (const p of this.getAll(scope)) { if (p.isActive) return p; } return undefined; } - set(provider: ProviderConfig): void { + set(provider: ProviderConfig, scope?: ProviderScope): void { + if (enterpriseProviderStoreEnabled()) { + this.setEnterprise(provider, scope); + return; + } this.providers.set(provider.id, provider); this.persist(); } - delete(id: string): boolean { + delete(id: string, scope?: ProviderScope): boolean { + if (enterpriseProviderStoreEnabled()) { + return this.deleteEnterprise(id, scope); + } const deleted = this.providers.delete(id); if (deleted) this.persist(); return deleted; } + private getSecretStore(): LocalEncryptedSecretStore { + if (!this.secretStore) { + this.secretStore = new LocalEncryptedSecretStore(); + } + return this.secretStore; + } + + private getAllEnterprise(scope?: ProviderScope): ProviderConfig[] { + const resolved = resolveProviderScope(scope); + const db = openEnterpriseDb(); + try { + const rows = db.prepare(` + SELECT * + FROM provider_credentials + WHERE ${accessibleProviderWhere()} + ORDER BY + CASE scope + WHEN 'personal' THEN 0 + WHEN 'workspace' THEN 1 + ELSE 2 + END, + updated_at DESC + `).all(resolved); + return rows + .map(row => this.providerFromEnterpriseRow(row)) + .filter((provider): provider is ProviderConfig => Boolean(provider)); + } finally { + db.close(); + } + } + + private getEnterprise(id: string, scope?: ProviderScope): ProviderConfig | undefined { + const resolved = resolveProviderScope(scope); + const db = openEnterpriseDb(); + try { + const row = db.prepare(` + SELECT * + FROM provider_credentials + WHERE id = @id AND ${accessibleProviderWhere()} + LIMIT 1 + `).get({ ...resolved, id }); + return row ? this.providerFromEnterpriseRow(row) ?? undefined : undefined; + } finally { + db.close(); + } + } + + private setEnterprise(provider: ProviderConfig, scope?: ProviderScope): void { + const resolved = resolveProviderScope(scope); + ensureEnterpriseProviderGraph(resolved); + const existing = this.getEnterpriseRowById(provider.id, resolved); + const effectiveScope = existing?.scope ?? (resolved.userId ? 'personal' : 'workspace'); + const workspaceId = effectiveScope === 'org' ? null : resolved.workspaceId; + const ownerUserId = effectiveScope === 'personal' ? resolved.userId : null; + const secretRef = existing?.secret_ref ?? providerSecretRef(resolved, provider.id); + const { publicConnection, secretConnection } = splitConnectionSecrets(provider.connection); + const secretVersion = this.getSecretStore().put(secretRef, secretConnection); + const policy: ProviderPolicyJson = { + category: provider.category, + isActive: provider.isActive, + connection: publicConnection, + ...(provider.tuning ? { tuning: provider.tuning } : {}), + ...(provider.custom ? { custom: provider.custom } : {}), + secretVersion, + }; + + const db = openEnterpriseDb(); + try { + 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 + (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(id) DO UPDATE SET + tenant_id = excluded.tenant_id, + workspace_id = excluded.workspace_id, + owner_user_id = excluded.owner_user_id, + scope = excluded.scope, + name = excluded.name, + type = excluded.type, + models_json = excluded.models_json, + secret_ref = excluded.secret_ref, + policy_json = excluded.policy_json, + updated_at = excluded.updated_at + `).run( + provider.id, + resolved.tenantId, + workspaceId, + ownerUserId, + effectiveScope, + provider.name, + provider.type, + JSON.stringify(provider.models), + secretRef, + JSON.stringify(policy), + existing?.created_at ?? toEpochMs(provider.createdAt), + toEpochMs(provider.updatedAt), + ); + } finally { + db.close(); + } + } + + private deleteEnterprise(id: string, scope?: ProviderScope): boolean { + const resolved = resolveProviderScope(scope); + const row = this.getEnterpriseRowById(id, resolved); + if (!row) return false; + const db = openEnterpriseDb(); + try { + const result = db.prepare(` + DELETE FROM provider_credentials + WHERE id = @id AND ${accessibleProviderWhere()} + `).run({ ...resolved, id }); + if (result.changes > 0) { + this.getSecretStore().delete(row.secret_ref); + } + return result.changes > 0; + } finally { + db.close(); + } + } + + private getEnterpriseRowById(id: string, scope: Required): ProviderCredentialRow | undefined { + const db = openEnterpriseDb(); + try { + return db.prepare(` + SELECT * + FROM provider_credentials + WHERE id = @id AND ${accessibleProviderWhere()} + LIMIT 1 + `).get({ ...scope, id }); + } finally { + db.close(); + } + } + + private providerFromEnterpriseRow(row: ProviderCredentialRow): ProviderConfig | null { + const policy = parseJsonObject(row.policy_json) as ProviderPolicyJson; + const models = parseJsonObject(row.models_json); + if (typeof models.primary !== 'string' || typeof models.light !== 'string') { + return null; + } + const secretConnection = this.getSecretStore().get(row.secret_ref); + const connection = mergeConnectionSecrets(policy.connection, secretConnection); + return { + id: row.id, + name: row.name, + category: policy.category ?? 'custom', + type: row.type, + isActive: policy.isActive === true, + createdAt: toIsoString(row.created_at), + updatedAt: toIsoString(row.updated_at), + models: { + primary: models.primary, + light: models.light, + ...(typeof models.subAgent === 'string' ? { subAgent: models.subAgent } : {}), + }, + connection, + ...(policy.tuning ? { tuning: policy.tuning } : {}), + ...(policy.custom ? { custom: policy.custom } : {}), + }; + } + private persist(): void { + if (enterpriseProviderStoreEnabled()) return; const dir = path.dirname(this.filePath); if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true }); const tmp = `${this.filePath}.tmp`; diff --git a/backend/src/services/providerManager/types.ts b/backend/src/services/providerManager/types.ts index 3aa9f537..4d9aba1a 100644 --- a/backend/src/services/providerManager/types.ts +++ b/backend/src/services/providerManager/types.ts @@ -108,6 +108,12 @@ export interface ProviderConfig { custom?: ProviderCustom; } +export interface ProviderScope { + tenantId: string; + workspaceId: string; + userId?: string; +} + export interface ModelOption { id: string; name: string; diff --git a/docs/features/enterprise-multi-tenant/README.md b/docs/features/enterprise-multi-tenant/README.md index 1583518e..1926830f 100644 --- a/docs/features/enterprise-multi-tenant/README.md +++ b/docs/features/enterprise-multi-tenant/README.md @@ -44,7 +44,7 @@ - [x] 3.3 trace metadata 入 DB;trace 文件迁到 `data/{tenantId}/{workspaceId}/traces/` - [x] 3.4 report metadata 入 DB;report 内容迁到 `data/{tenantId}/{workspaceId}/reports/`(§14.2) - [x] 3.5 `logs/claude_session_map.json` 迁到 `runtime_snapshots` -- [ ] 3.6 provider 从 `data/providers.json` 迁到 DB metadata + encrypted SecretStore +- [x] 3.6 provider 从 `data/providers.json` 迁到 DB metadata + encrypted SecretStore - [ ] 3.7 Memory / RAG / Case / Baseline 表加 scope(§14.1,先 filter 后语义召回) - [ ] 3.8 双写 → 切读 → 退役 三阶段(§17),每阶段都能回滚;准备 filesystem + DB snapshot - [ ] 3.9 SecretStore:libsodium 加密 + OS keyring 解 master key + secret rotation + 读取审计