diff --git a/drizzle.config.ts b/drizzle.config.ts index 57c1c992..f8084ffc 100644 --- a/drizzle.config.ts +++ b/drizzle.config.ts @@ -2,6 +2,8 @@ import { defineConfig } from 'drizzle-kit'; export default defineConfig({ schema: [ + './src/db/schema/organizations.ts', + './src/db/schema/credentials.ts', './src/db/schema/defaults.ts', './src/db/schema/projects.ts', './src/db/schema/runs.ts', diff --git a/src/config/configCache.ts b/src/config/configCache.ts index 1aab8f9a..05bd253d 100644 --- a/src/config/configCache.ts +++ b/src/config/configCache.ts @@ -12,6 +12,7 @@ class ConfigCache { private projectByBoardId = new Map>(); private projectByRepo = new Map>(); private projectSecrets = new Map>>(); + private orgIdByProject = new Map>(); private ttlMs: number; constructor(ttlMs = DEFAULT_TTL_MS) { @@ -52,6 +53,15 @@ class ConfigCache { this.projectByRepo.set(repo, this.makeEntry(project)); } + getOrgIdForProject(projectId: string): string | null { + const entry = this.orgIdByProject.get(projectId); + return this.isValid(entry) ? entry.data : null; + } + + setOrgIdForProject(projectId: string, orgId: string): void { + this.orgIdByProject.set(projectId, this.makeEntry(orgId)); + } + getSecrets(projectId: string): Record | null { const entry = this.projectSecrets.get(projectId); return this.isValid(entry) ? entry.data : null; @@ -66,6 +76,7 @@ class ConfigCache { this.projectByBoardId.clear(); this.projectByRepo.clear(); this.projectSecrets.clear(); + this.orgIdByProject.clear(); } } diff --git a/src/config/provider.ts b/src/config/provider.ts index 81e17e54..9fa9d7c8 100644 --- a/src/config/provider.ts +++ b/src/config/provider.ts @@ -5,9 +5,9 @@ import { loadConfigFromDb, } from '../db/repositories/configRepository.js'; import { - getProjectSecret as getProjectSecretFromDb, - getProjectSecrets as getProjectSecretsFromDb, -} from '../db/repositories/secretsRepository.js'; + resolveAllCredentials, + resolveCredential, +} from '../db/repositories/credentialsRepository.js'; import type { CascadeConfig, ProjectConfig } from '../types/index.js'; import { configCache } from './configCache.js'; @@ -43,6 +43,19 @@ export async function findProjectById(id: string): Promise { + const cached = configCache.getOrgIdForProject(projectId); + if (cached) return cached; + + const project = await findProjectByIdFromDb(projectId); + const orgId = project?.orgId ?? 'default'; + configCache.setOrgIdForProject(projectId, orgId); + return orgId; +} + export async function getProjectSecret(projectId: string, key: string): Promise { // Check cached secrets first const cachedSecrets = configCache.getSecrets(projectId); @@ -50,8 +63,9 @@ export async function getProjectSecret(projectId: string, key: string): Promise< return cachedSecrets[key]; } - // DB is the sole source of truth for project secrets - const dbValue = await getProjectSecretFromDb(projectId, key); + // Resolve via credentials system (project override → org default) + const orgId = await getOrgIdForProject(projectId); + const dbValue = await resolveCredential(projectId, orgId, key); if (dbValue) return dbValue; throw new Error(`Secret '${key}' not found for project '${projectId}' in database`); @@ -72,7 +86,8 @@ export async function getProjectSecrets(projectId: string): Promise = { id: row.id, + orgId: row.orgId, name: row.name, repo: row.repo, baseBranch: row.baseBranch ?? 'main', @@ -136,23 +138,38 @@ async function loadAgentConfigs(): Promise { export async function loadConfigFromDb(): Promise { const db = getDb(); + // Load first defaults row (for the primary/default org) const [defaultsRow] = await db.select().from(cascadeDefaults).limit(1); const projectRows = await db.select().from(projects); const allAgentConfigs = await loadAgentConfigs(); - // Split agent configs into global (project_id IS NULL) and per-project - const globalAgentConfigs = allAgentConfigs.filter((ac) => ac.projectId === null); + // Split agent configs: global (project_id IS NULL, org_id IS NULL) and per-project + // Also collect org-level configs (org_id set, project_id IS NULL) as fallback globals + const globalAgentConfigs = allAgentConfigs.filter( + (ac) => ac.projectId === null && ac.orgId === null, + ); + const orgAgentConfigsMap = new Map(); const projectAgentConfigsMap = new Map(); for (const ac of allAgentConfigs) { if (ac.projectId !== null) { const existing = projectAgentConfigsMap.get(ac.projectId) ?? []; existing.push(ac); projectAgentConfigsMap.set(ac.projectId, existing); + } else if (ac.orgId !== null) { + const existing = orgAgentConfigsMap.get(ac.orgId) ?? []; + existing.push(ac); + orgAgentConfigsMap.set(ac.orgId, existing); } } + // Merge global + org-level agent configs for defaults + const mergedGlobalConfigs = [ + ...globalAgentConfigs, + ...(orgAgentConfigsMap.get(defaultsRow?.orgId ?? 'default') ?? []), + ]; + const rawConfig = { - defaults: mapDefaultsRow(defaultsRow, globalAgentConfigs), + defaults: mapDefaultsRow(defaultsRow, mergedGlobalConfigs), projects: projectRows.map((row) => mapProjectRow(row, projectAgentConfigsMap.get(row.id) ?? []), ), @@ -169,11 +186,21 @@ export async function findProjectByBoardIdFromDb( if (!row) return undefined; const projectAcs = await db.select().from(agentConfigs).where(eq(agentConfigs.projectId, row.id)); - const globalAcs = await db.select().from(agentConfigs).where(isNull(agentConfigs.projectId)); + const orgAcs = await db + .select() + .from(agentConfigs) + .where(and(eq(agentConfigs.orgId, row.orgId), isNull(agentConfigs.projectId))); + const globalAcs = await db + .select() + .from(agentConfigs) + .where(and(isNull(agentConfigs.projectId), isNull(agentConfigs.orgId))); - const [defaultsRow] = await db.select().from(cascadeDefaults).limit(1); + const [defaultsRow] = await db + .select() + .from(cascadeDefaults) + .where(eq(cascadeDefaults.orgId, row.orgId)); const rawConfig = { - defaults: mapDefaultsRow(defaultsRow, globalAcs), + defaults: mapDefaultsRow(defaultsRow, [...globalAcs, ...orgAcs]), projects: [mapProjectRow(row, projectAcs)], }; const validated = validateConfig(rawConfig); @@ -186,11 +213,21 @@ export async function findProjectByRepoFromDb(repo: string): Promise { + const db = getDb(); + + // 1. Check project override + const [override] = await db + .select({ value: credentials.value }) + .from(projectCredentialOverrides) + .innerJoin(credentials, eq(projectCredentialOverrides.credentialId, credentials.id)) + .where( + and( + eq(projectCredentialOverrides.projectId, projectId), + eq(projectCredentialOverrides.envVarKey, envVarKey), + ), + ); + if (override) return override.value; + + // 2. Check org default + const [orgDefault] = await db + .select({ value: credentials.value }) + .from(credentials) + .where( + and( + eq(credentials.orgId, orgId), + eq(credentials.envVarKey, envVarKey), + eq(credentials.isDefault, true), + ), + ); + if (orgDefault) return orgDefault.value; + + return null; +} + +/** + * Resolve all credentials for a project as a key-value map. + * Merges org defaults with project overrides (overrides win). + */ +export async function resolveAllCredentials( + projectId: string, + orgId: string, +): Promise> { + const db = getDb(); + const result: Record = {}; + + // Load org defaults + const orgDefaults = await db + .select({ envVarKey: credentials.envVarKey, value: credentials.value }) + .from(credentials) + .where(and(eq(credentials.orgId, orgId), eq(credentials.isDefault, true))); + + for (const row of orgDefaults) { + result[row.envVarKey] = row.value; + } + + // Load project overrides (overwrite org defaults) + const overrides = await db + .select({ + envVarKey: projectCredentialOverrides.envVarKey, + value: credentials.value, + }) + .from(projectCredentialOverrides) + .innerJoin(credentials, eq(projectCredentialOverrides.credentialId, credentials.id)) + .where(eq(projectCredentialOverrides.projectId, projectId)); + + for (const row of overrides) { + result[row.envVarKey] = row.value; + } + + return result; +} + +// --- CRUD for credentials --- + +export async function createCredential(params: { + orgId: string; + name: string; + envVarKey: string; + value: string; + description?: string; + isDefault?: boolean; +}): Promise<{ id: number }> { + const db = getDb(); + const [row] = await db + .insert(credentials) + .values({ + orgId: params.orgId, + name: params.name, + envVarKey: params.envVarKey, + value: params.value, + description: params.description, + isDefault: params.isDefault ?? false, + }) + .returning({ id: credentials.id }); + return row; +} + +export async function updateCredential( + id: number, + updates: { + name?: string; + value?: string; + description?: string; + isDefault?: boolean; + }, +): Promise { + const db = getDb(); + const setClause: Record = { updatedAt: new Date() }; + if (updates.name !== undefined) setClause.name = updates.name; + if (updates.value !== undefined) setClause.value = updates.value; + if (updates.description !== undefined) setClause.description = updates.description; + if (updates.isDefault !== undefined) setClause.isDefault = updates.isDefault; + + await db.update(credentials).set(setClause).where(eq(credentials.id, id)); +} + +export async function deleteCredential(id: number): Promise { + const db = getDb(); + await db.delete(credentials).where(eq(credentials.id, id)); +} + +export async function listOrgCredentials( + orgId: string, +): Promise<(typeof credentials.$inferSelect)[]> { + const db = getDb(); + return db.select().from(credentials).where(eq(credentials.orgId, orgId)); +} + +// --- Override management --- + +export async function setProjectCredentialOverride( + projectId: string, + envVarKey: string, + credentialId: number, +): Promise { + const db = getDb(); + await db + .insert(projectCredentialOverrides) + .values({ projectId, envVarKey, credentialId }) + .onConflictDoUpdate({ + target: [projectCredentialOverrides.projectId, projectCredentialOverrides.envVarKey], + set: { credentialId, updatedAt: new Date() }, + }); +} + +export async function removeProjectCredentialOverride( + projectId: string, + envVarKey: string, +): Promise { + const db = getDb(); + await db + .delete(projectCredentialOverrides) + .where( + and( + eq(projectCredentialOverrides.projectId, projectId), + eq(projectCredentialOverrides.envVarKey, envVarKey), + ), + ); +} + +export async function listProjectOverrides( + projectId: string, +): Promise<{ envVarKey: string; credentialId: number; credentialName: string }[]> { + const db = getDb(); + const rows = await db + .select({ + envVarKey: projectCredentialOverrides.envVarKey, + credentialId: projectCredentialOverrides.credentialId, + credentialName: credentials.name, + }) + .from(projectCredentialOverrides) + .innerJoin(credentials, eq(projectCredentialOverrides.credentialId, credentials.id)) + .where(eq(projectCredentialOverrides.projectId, projectId)); + return rows; +} diff --git a/src/db/schema/credentials.ts b/src/db/schema/credentials.ts new file mode 100644 index 00000000..59cd0881 --- /dev/null +++ b/src/db/schema/credentials.ts @@ -0,0 +1,61 @@ +import { + boolean, + index, + integer, + pgTable, + serial, + text, + timestamp, + uniqueIndex, +} from 'drizzle-orm/pg-core'; +import { organizations } from './organizations.js'; +import { projects } from './projects.js'; + +export const credentials = pgTable( + 'credentials', + { + id: serial('id').primaryKey(), + orgId: text('org_id') + .notNull() + .references(() => organizations.id, { onDelete: 'cascade' }), + name: text('name').notNull(), + envVarKey: text('env_var_key').notNull(), + value: text('value').notNull(), + description: text('description'), + isDefault: boolean('is_default').notNull().default(false), + createdAt: timestamp('created_at').defaultNow(), + updatedAt: timestamp('updated_at') + .defaultNow() + .$onUpdate(() => new Date()), + }, + (table) => [ + index('idx_credentials_org_env_var_key').on(table.orgId, table.envVarKey), + // Partial unique: only one default per (org_id, env_var_key) + // NOTE: Drizzle doesn't support partial unique indexes natively. + // This is enforced by the migration SQL directly. + ], +); + +export const projectCredentialOverrides = pgTable( + 'project_credential_overrides', + { + id: serial('id').primaryKey(), + projectId: text('project_id') + .notNull() + .references(() => projects.id, { onDelete: 'cascade' }), + envVarKey: text('env_var_key').notNull(), + credentialId: integer('credential_id') + .notNull() + .references(() => credentials.id, { onDelete: 'cascade' }), + createdAt: timestamp('created_at').defaultNow(), + updatedAt: timestamp('updated_at') + .defaultNow() + .$onUpdate(() => new Date()), + }, + (table) => [ + uniqueIndex('uq_project_credential_overrides_project_env_var_key').on( + table.projectId, + table.envVarKey, + ), + ], +); diff --git a/src/db/schema/defaults.ts b/src/db/schema/defaults.ts index 8eb533c2..86a504bc 100644 --- a/src/db/schema/defaults.ts +++ b/src/db/schema/defaults.ts @@ -1,7 +1,12 @@ import { integer, numeric, pgTable, text, timestamp } from 'drizzle-orm/pg-core'; +import { organizations } from './organizations.js'; export const cascadeDefaults = pgTable('cascade_defaults', { - id: text('id').primaryKey().default('singleton'), + id: text('id').primaryKey(), + orgId: text('org_id') + .notNull() + .unique() + .references(() => organizations.id, { onDelete: 'cascade' }), model: text('model'), maxIterations: integer('max_iterations'), freshMachineTimeoutMs: integer('fresh_machine_timeout_ms'), diff --git a/src/db/schema/index.ts b/src/db/schema/index.ts index f5700f54..565a4655 100644 --- a/src/db/schema/index.ts +++ b/src/db/schema/index.ts @@ -1,4 +1,6 @@ +export { credentials, projectCredentialOverrides } from './credentials.js'; export { cascadeDefaults } from './defaults.js'; +export { organizations } from './organizations.js'; export { agentConfigs, projects } from './projects.js'; export { agentRunLlmCalls, agentRunLogs, agentRuns, debugAnalyses } from './runs.js'; export { projectSecrets } from './secrets.js'; diff --git a/src/db/schema/organizations.ts b/src/db/schema/organizations.ts new file mode 100644 index 00000000..4cf7a46f --- /dev/null +++ b/src/db/schema/organizations.ts @@ -0,0 +1,10 @@ +import { pgTable, text, timestamp } from 'drizzle-orm/pg-core'; + +export const organizations = pgTable('organizations', { + id: text('id').primaryKey(), + name: text('name').notNull(), + createdAt: timestamp('created_at').defaultNow(), + updatedAt: timestamp('updated_at') + .defaultNow() + .$onUpdate(() => new Date()), +}); diff --git a/src/db/schema/projects.ts b/src/db/schema/projects.ts index 3866571d..ae616fc6 100644 --- a/src/db/schema/projects.ts +++ b/src/db/schema/projects.ts @@ -9,11 +9,15 @@ import { timestamp, uniqueIndex, } from 'drizzle-orm/pg-core'; +import { organizations } from './organizations.js'; export const projects = pgTable( 'projects', { id: text('id').primaryKey(), + orgId: text('org_id') + .notNull() + .references(() => organizations.id, { onDelete: 'cascade' }), name: text('name').notNull(), repo: text('repo').notNull().unique(), baseBranch: text('base_branch').default('main'), @@ -60,6 +64,7 @@ export const agentConfigs = pgTable( 'agent_configs', { id: serial('id').primaryKey(), + orgId: text('org_id').references(() => organizations.id, { onDelete: 'cascade' }), projectId: text('project_id').references(() => projects.id, { onDelete: 'cascade' }), agentType: text('agent_type').notNull(), model: text('model'), diff --git a/tests/unit/config/configCache.test.ts b/tests/unit/config/configCache.test.ts new file mode 100644 index 00000000..fb458455 --- /dev/null +++ b/tests/unit/config/configCache.test.ts @@ -0,0 +1,100 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { configCache } from '../../../src/config/configCache.js'; + +describe('configCache', () => { + beforeEach(() => { + configCache.invalidate(); + }); + + afterEach(() => { + configCache.invalidate(); + }); + + describe('orgIdByProject', () => { + it('returns null when no cached org ID', () => { + expect(configCache.getOrgIdForProject('project1')).toBeNull(); + }); + + it('caches and retrieves org ID', () => { + configCache.setOrgIdForProject('project1', 'acme-corp'); + expect(configCache.getOrgIdForProject('project1')).toBe('acme-corp'); + }); + + it('caches different org IDs per project', () => { + configCache.setOrgIdForProject('project1', 'acme-corp'); + configCache.setOrgIdForProject('project2', 'other-org'); + + expect(configCache.getOrgIdForProject('project1')).toBe('acme-corp'); + expect(configCache.getOrgIdForProject('project2')).toBe('other-org'); + }); + + it('is cleared by invalidate()', () => { + configCache.setOrgIdForProject('project1', 'acme-corp'); + configCache.invalidate(); + expect(configCache.getOrgIdForProject('project1')).toBeNull(); + }); + + it('expires after TTL', () => { + vi.useFakeTimers(); + try { + configCache.setOrgIdForProject('project1', 'acme-corp'); + expect(configCache.getOrgIdForProject('project1')).toBe('acme-corp'); + + // Advance past the default 60s TTL + vi.advanceTimersByTime(61_000); + expect(configCache.getOrgIdForProject('project1')).toBeNull(); + } finally { + vi.useRealTimers(); + } + }); + }); + + describe('secrets cache', () => { + it('returns null when no cached secrets', () => { + expect(configCache.getSecrets('project1')).toBeNull(); + }); + + it('caches and retrieves secrets', () => { + const secrets = { GITHUB_TOKEN: 'ghp_abc', TRELLO_API_KEY: 'trello123' }; + configCache.setSecrets('project1', secrets); + expect(configCache.getSecrets('project1')).toEqual(secrets); + }); + + it('is cleared by invalidate()', () => { + configCache.setSecrets('project1', { KEY: 'val' }); + configCache.invalidate(); + expect(configCache.getSecrets('project1')).toBeNull(); + }); + }); + + describe('config cache', () => { + it('returns null when no cached config', () => { + expect(configCache.getConfig()).toBeNull(); + }); + + it('is cleared by invalidate()', () => { + const config = { + defaults: {} as never, + projects: [] as never, + }; + configCache.setConfig(config); + expect(configCache.getConfig()).toBe(config); + configCache.invalidate(); + expect(configCache.getConfig()).toBeNull(); + }); + }); + + describe('project lookups', () => { + it('clears projectByBoardId on invalidate', () => { + configCache.setProjectByBoardId('board1', undefined); + configCache.invalidate(); + expect(configCache.getProjectByBoardId('board1')).toBeNull(); + }); + + it('clears projectByRepo on invalidate', () => { + configCache.setProjectByRepo('owner/repo', undefined); + configCache.invalidate(); + expect(configCache.getProjectByRepo('owner/repo')).toBeNull(); + }); + }); +}); diff --git a/tests/unit/config/projects.test.ts b/tests/unit/config/projects.test.ts index 6b6db3eb..7a3ad873 100644 --- a/tests/unit/config/projects.test.ts +++ b/tests/unit/config/projects.test.ts @@ -8,9 +8,9 @@ vi.mock('../../../src/db/repositories/configRepository.js', () => ({ findProjectByIdFromDb: vi.fn(), })); -vi.mock('../../../src/db/repositories/secretsRepository.js', () => ({ - getProjectSecret: vi.fn(), - getProjectSecrets: vi.fn(), +vi.mock('../../../src/db/repositories/credentialsRepository.js', () => ({ + resolveCredential: vi.fn(), + resolveAllCredentials: vi.fn(), })); import { getProjectGitHubToken } from '../../../src/config/projects.js'; @@ -19,6 +19,8 @@ import { findProjectById, findProjectByRepo, getProjectSecret, + getProjectSecretOrNull, + getProjectSecrets, invalidateConfigCache, loadConfig, } from '../../../src/config/provider.js'; @@ -28,11 +30,15 @@ import { findProjectByRepoFromDb, loadConfigFromDb, } from '../../../src/db/repositories/configRepository.js'; -import { getProjectSecret as getProjectSecretFromDb } from '../../../src/db/repositories/secretsRepository.js'; +import { + resolveAllCredentials, + resolveCredential, +} from '../../../src/db/repositories/credentialsRepository.js'; describe('config provider', () => { const mockProject1 = { id: 'project1', + orgId: 'default', name: 'Project 1', repo: 'owner/repo1', baseBranch: 'main', @@ -46,6 +52,7 @@ describe('config provider', () => { const mockProject2 = { id: 'project2', + orgId: 'default', name: 'Project 2', repo: 'owner/repo2', baseBranch: 'main', @@ -162,15 +169,17 @@ describe('config provider', () => { }); describe('getProjectSecret', () => { - it('returns DB secret when available', async () => { - vi.mocked(getProjectSecretFromDb).mockResolvedValue('db-secret-value'); + it('returns DB credential when available', async () => { + vi.mocked(findProjectByIdFromDb).mockResolvedValue(mockProject1); + vi.mocked(resolveCredential).mockResolvedValue('db-secret-value'); const result = await getProjectSecret('project1', 'TRELLO_API_KEY'); expect(result).toBe('db-secret-value'); }); it('throws when secret not found in DB', async () => { - vi.mocked(getProjectSecretFromDb).mockResolvedValue(null); + vi.mocked(findProjectByIdFromDb).mockResolvedValue(mockProject1); + vi.mocked(resolveCredential).mockResolvedValue(null); await expect(getProjectSecret('project1', 'MISSING_KEY')).rejects.toThrow( "Secret 'MISSING_KEY' not found for project 'project1' in database", @@ -178,16 +187,127 @@ describe('config provider', () => { }); }); + describe('getProjectSecret - cached secrets path', () => { + it('returns from cached secrets without hitting resolveCredential', async () => { + vi.mocked(findProjectByIdFromDb).mockResolvedValue(mockProject1); + vi.mocked(resolveAllCredentials).mockResolvedValue({ + TRELLO_API_KEY: 'cached-value', + GITHUB_TOKEN: 'cached-gh-token', + }); + + // Populate the secrets cache via getProjectSecrets + await getProjectSecrets('project1'); + + vi.clearAllMocks(); + + // Now getProjectSecret should use the cached secrets + const result = await getProjectSecret('project1', 'TRELLO_API_KEY'); + expect(result).toBe('cached-value'); + + // resolveCredential should NOT have been called (cache hit) + expect(resolveCredential).not.toHaveBeenCalled(); + }); + }); + + describe('getProjectSecretOrNull', () => { + it('returns credential value when found', async () => { + vi.mocked(findProjectByIdFromDb).mockResolvedValue(mockProject1); + vi.mocked(resolveCredential).mockResolvedValue('secret-value'); + + const result = await getProjectSecretOrNull('project1', 'TRELLO_API_KEY'); + expect(result).toBe('secret-value'); + }); + + it('returns null when no credential found', async () => { + vi.mocked(findProjectByIdFromDb).mockResolvedValue(mockProject1); + vi.mocked(resolveCredential).mockResolvedValue(null); + + const result = await getProjectSecretOrNull('project1', 'NO_SUCH_KEY'); + expect(result).toBeNull(); + }); + }); + + describe('getProjectSecrets', () => { + it('resolves all credentials via org ID', async () => { + vi.mocked(findProjectByIdFromDb).mockResolvedValue(mockProject1); + vi.mocked(resolveAllCredentials).mockResolvedValue({ + GITHUB_TOKEN: 'ghp_abc', + TRELLO_API_KEY: 'trello123', + }); + + const result = await getProjectSecrets('project1'); + expect(result).toEqual({ + GITHUB_TOKEN: 'ghp_abc', + TRELLO_API_KEY: 'trello123', + }); + expect(resolveAllCredentials).toHaveBeenCalledWith('project1', 'default'); + }); + + it('caches secrets after first call', async () => { + vi.mocked(findProjectByIdFromDb).mockResolvedValue(mockProject1); + vi.mocked(resolveAllCredentials).mockResolvedValue({ KEY: 'val' }); + + await getProjectSecrets('project1'); + await getProjectSecrets('project1'); + + // resolveAllCredentials called only once + expect(resolveAllCredentials).toHaveBeenCalledTimes(1); + }); + + it('returns empty object when no credentials exist', async () => { + vi.mocked(findProjectByIdFromDb).mockResolvedValue(mockProject2); + vi.mocked(resolveAllCredentials).mockResolvedValue({}); + + const result = await getProjectSecrets('project2'); + expect(result).toEqual({}); + }); + }); + + describe('orgId resolution', () => { + it('caches org ID after first lookup', async () => { + vi.mocked(findProjectByIdFromDb).mockResolvedValue(mockProject1); + vi.mocked(resolveCredential).mockResolvedValue('value1'); + + // Two calls for the same project + await getProjectSecret('project1', 'KEY1'); + await getProjectSecret('project1', 'KEY2'); + + // findProjectByIdFromDb called once (for orgId), not twice + expect(findProjectByIdFromDb).toHaveBeenCalledTimes(1); + }); + + it('defaults org ID to "default" when project not found', async () => { + vi.mocked(findProjectByIdFromDb).mockResolvedValue(undefined); + vi.mocked(resolveCredential).mockResolvedValue('value1'); + + await getProjectSecret('nonexistent', 'KEY'); + + expect(resolveCredential).toHaveBeenCalledWith('nonexistent', 'default', 'KEY'); + }); + + it('uses project-specific org ID', async () => { + const customOrgProject = { ...mockProject1, orgId: 'acme-corp' }; + vi.mocked(findProjectByIdFromDb).mockResolvedValue(customOrgProject); + vi.mocked(resolveCredential).mockResolvedValue('value1'); + + await getProjectSecret('project1', 'KEY'); + + expect(resolveCredential).toHaveBeenCalledWith('project1', 'acme-corp', 'KEY'); + }); + }); + describe('getProjectGitHubToken', () => { it('returns DB secret when available', async () => { - vi.mocked(getProjectSecretFromDb).mockResolvedValue('db-github-token'); + vi.mocked(findProjectByIdFromDb).mockResolvedValue(mockProject1); + vi.mocked(resolveCredential).mockResolvedValue('db-github-token'); const result = await getProjectGitHubToken(mockConfig.projects[0]); expect(result).toBe('db-github-token'); }); it('throws when no token in DB', async () => { - vi.mocked(getProjectSecretFromDb).mockResolvedValue(null); + vi.mocked(findProjectByIdFromDb).mockResolvedValue(mockProject1); + vi.mocked(resolveCredential).mockResolvedValue(null); await expect(getProjectGitHubToken(mockConfig.projects[0])).rejects.toThrow( "Missing GITHUB_TOKEN in database for project 'project1'", diff --git a/tests/unit/config/schema.test.ts b/tests/unit/config/schema.test.ts index 89478992..8ffa81c8 100644 --- a/tests/unit/config/schema.test.ts +++ b/tests/unit/config/schema.test.ts @@ -5,6 +5,7 @@ describe('ProjectConfigSchema', () => { it('validates a valid project config', () => { const config = { id: 'test-project', + orgId: 'default', name: 'Test Project', repo: 'owner/repo', trello: { @@ -29,6 +30,7 @@ describe('ProjectConfigSchema', () => { it('rejects invalid repo format', () => { const config = { id: 'test', + orgId: 'default', name: 'Test', repo: 'invalid-repo-format', trello: { @@ -44,6 +46,7 @@ describe('ProjectConfigSchema', () => { it('applies default values', () => { const config = { id: 'test', + orgId: 'default', name: 'Test', repo: 'owner/repo', trello: { @@ -61,6 +64,7 @@ describe('ProjectConfigSchema', () => { it('accepts agentBackend with default and overrides', () => { const config = { id: 'test', + orgId: 'default', name: 'Test', repo: 'owner/repo', trello: { boardId: 'b1', lists: {}, labels: {} }, @@ -78,6 +82,7 @@ describe('ProjectConfigSchema', () => { it('works without agentBackend (optional field)', () => { const config = { id: 'test', + orgId: 'default', name: 'Test', repo: 'owner/repo', trello: { boardId: 'b1', lists: {}, labels: {} }, @@ -90,6 +95,7 @@ describe('ProjectConfigSchema', () => { it('accepts subscriptionCostZero on agentBackend', () => { const config = { id: 'test', + orgId: 'default', name: 'Test', repo: 'owner/repo', trello: { boardId: 'b1', lists: {}, labels: {} }, @@ -106,6 +112,7 @@ describe('ProjectConfigSchema', () => { it('defaults subscriptionCostZero to false', () => { const config = { id: 'test', + orgId: 'default', name: 'Test', repo: 'owner/repo', trello: { boardId: 'b1', lists: {}, labels: {} }, @@ -121,6 +128,7 @@ describe('ProjectConfigSchema', () => { it('applies default "llmist" for agentBackend.default when object provided', () => { const config = { id: 'test', + orgId: 'default', name: 'Test', repo: 'owner/repo', trello: { boardId: 'b1', lists: {}, labels: {} }, @@ -139,6 +147,7 @@ describe('validateConfig', () => { projects: [ { id: 'test', + orgId: 'default', name: 'Test', repo: 'owner/repo', trello: { @@ -165,6 +174,7 @@ describe('validateConfig', () => { projects: [ { id: 'test', + orgId: 'default', name: 'Test', repo: 'owner/repo', trello: { boardId: 'b1', lists: {}, labels: {} }, @@ -184,6 +194,7 @@ describe('validateConfig', () => { projects: [ { id: 'test', + orgId: 'default', name: 'Test', repo: 'owner/repo', trello: { boardId: 'b1', lists: {}, labels: {} }, diff --git a/tests/unit/db/repositories/credentialsRepository.test.ts b/tests/unit/db/repositories/credentialsRepository.test.ts new file mode 100644 index 00000000..4557cafb --- /dev/null +++ b/tests/unit/db/repositories/credentialsRepository.test.ts @@ -0,0 +1,293 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +// Mock the DB client +vi.mock('../../../../src/db/client.js', () => ({ + getDb: vi.fn(), +})); + +import { getDb } from '../../../../src/db/client.js'; +import { + createCredential, + deleteCredential, + listOrgCredentials, + listProjectOverrides, + removeProjectCredentialOverride, + resolveAllCredentials, + resolveCredential, + setProjectCredentialOverride, + updateCredential, +} from '../../../../src/db/repositories/credentialsRepository.js'; + +/** + * Creates a mock Drizzle query chain that supports the common patterns: + * select().from().where(), select().from().innerJoin().where(), + * insert().values().returning(), insert().values().onConflictDoUpdate(), + * update().set().where(), delete().from().where() + */ +function createMockDb() { + const chain: Record> = {}; + + // Terminal methods that return results + chain.where = vi.fn().mockResolvedValue([]); + chain.returning = vi.fn().mockResolvedValue([]); + chain.onConflictDoUpdate = vi.fn().mockResolvedValue(undefined); + + // Chain methods + chain.innerJoin = vi.fn().mockReturnValue({ where: chain.where }); + chain.from = vi.fn().mockReturnValue({ + where: chain.where, + innerJoin: chain.innerJoin, + }); + chain.set = vi.fn().mockReturnValue({ where: chain.where }); + chain.values = vi.fn().mockReturnValue({ + returning: chain.returning, + onConflictDoUpdate: chain.onConflictDoUpdate, + }); + + const db = { + select: vi.fn().mockReturnValue({ from: chain.from }), + insert: vi.fn().mockReturnValue({ values: chain.values }), + update: vi.fn().mockReturnValue({ set: chain.set }), + delete: vi.fn().mockReturnValue({ where: chain.where }), + }; + + return { db, chain }; +} + +describe('credentialsRepository', () => { + let mockDb: ReturnType; + + beforeEach(() => { + mockDb = createMockDb(); + vi.mocked(getDb).mockReturnValue(mockDb.db as never); + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + describe('resolveCredential', () => { + it('returns project override value when found', async () => { + // First query (project override) returns a result + mockDb.chain.where.mockResolvedValueOnce([{ value: 'project-override-secret' }]); + + const result = await resolveCredential('proj1', 'org1', 'GITHUB_TOKEN'); + expect(result).toBe('project-override-secret'); + + // Should only call select once (found override, short-circuits) + expect(mockDb.db.select).toHaveBeenCalledTimes(1); + }); + + it('falls back to org default when no project override', async () => { + // First query (project override) returns empty + mockDb.chain.where.mockResolvedValueOnce([]); + // Second query (org default) returns a result + mockDb.chain.where.mockResolvedValueOnce([{ value: 'org-default-secret' }]); + + const result = await resolveCredential('proj1', 'org1', 'GITHUB_TOKEN'); + expect(result).toBe('org-default-secret'); + + // Two selects: override check + org default check + expect(mockDb.db.select).toHaveBeenCalledTimes(2); + }); + + it('returns null when neither override nor org default exists', async () => { + mockDb.chain.where.mockResolvedValueOnce([]); + mockDb.chain.where.mockResolvedValueOnce([]); + + const result = await resolveCredential('proj1', 'org1', 'GITHUB_TOKEN'); + expect(result).toBeNull(); + }); + }); + + describe('resolveAllCredentials', () => { + it('merges org defaults with project overrides', async () => { + // First query: org defaults + mockDb.chain.where.mockResolvedValueOnce([ + { envVarKey: 'GITHUB_TOKEN', value: 'org-gh-token' }, + { envVarKey: 'TRELLO_API_KEY', value: 'org-trello-key' }, + ]); + // Second query: project overrides + mockDb.chain.where.mockResolvedValueOnce([ + { envVarKey: 'GITHUB_TOKEN', value: 'project-gh-token' }, + ]); + + const result = await resolveAllCredentials('proj1', 'org1'); + expect(result).toEqual({ + GITHUB_TOKEN: 'project-gh-token', // override wins + TRELLO_API_KEY: 'org-trello-key', // org default kept + }); + }); + + it('returns only org defaults when no overrides', async () => { + mockDb.chain.where.mockResolvedValueOnce([{ envVarKey: 'KEY1', value: 'val1' }]); + mockDb.chain.where.mockResolvedValueOnce([]); // no overrides + + const result = await resolveAllCredentials('proj1', 'org1'); + expect(result).toEqual({ KEY1: 'val1' }); + }); + + it('returns empty when no credentials exist', async () => { + mockDb.chain.where.mockResolvedValueOnce([]); + mockDb.chain.where.mockResolvedValueOnce([]); + + const result = await resolveAllCredentials('proj1', 'org1'); + expect(result).toEqual({}); + }); + }); + + describe('createCredential', () => { + it('inserts credential and returns id', async () => { + mockDb.chain.returning.mockResolvedValueOnce([{ id: 42 }]); + + const result = await createCredential({ + orgId: 'org1', + name: 'GitHub Bot', + envVarKey: 'GITHUB_TOKEN', + value: 'ghp_abc123', + description: 'Bot token for CI', + isDefault: true, + }); + + expect(result).toEqual({ id: 42 }); + expect(mockDb.db.insert).toHaveBeenCalledTimes(1); + expect(mockDb.chain.values).toHaveBeenCalledWith({ + orgId: 'org1', + name: 'GitHub Bot', + envVarKey: 'GITHUB_TOKEN', + value: 'ghp_abc123', + description: 'Bot token for CI', + isDefault: true, + }); + }); + + it('defaults isDefault to false', async () => { + mockDb.chain.returning.mockResolvedValueOnce([{ id: 1 }]); + + await createCredential({ + orgId: 'org1', + name: 'Key', + envVarKey: 'KEY', + value: 'val', + }); + + expect(mockDb.chain.values).toHaveBeenCalledWith( + expect.objectContaining({ isDefault: false }), + ); + }); + }); + + describe('updateCredential', () => { + it('updates specified fields', async () => { + mockDb.chain.where.mockResolvedValueOnce(undefined); + + await updateCredential(42, { name: 'New Name', value: 'new-secret' }); + + expect(mockDb.db.update).toHaveBeenCalledTimes(1); + expect(mockDb.chain.set).toHaveBeenCalledWith( + expect.objectContaining({ + name: 'New Name', + value: 'new-secret', + }), + ); + }); + + it('includes updatedAt timestamp', async () => { + mockDb.chain.where.mockResolvedValueOnce(undefined); + + await updateCredential(1, { description: 'updated desc' }); + + const setArg = mockDb.chain.set.mock.calls[0][0]; + expect(setArg.updatedAt).toBeInstanceOf(Date); + expect(setArg.description).toBe('updated desc'); + }); + + it('only updates provided fields', async () => { + mockDb.chain.where.mockResolvedValueOnce(undefined); + + await updateCredential(1, { isDefault: true }); + + const setArg = mockDb.chain.set.mock.calls[0][0]; + expect(setArg.isDefault).toBe(true); + expect(setArg.name).toBeUndefined(); + expect(setArg.value).toBeUndefined(); + expect(setArg.description).toBeUndefined(); + }); + }); + + describe('deleteCredential', () => { + it('deletes by id', async () => { + mockDb.chain.where.mockResolvedValueOnce(undefined); + + await deleteCredential(42); + + expect(mockDb.db.delete).toHaveBeenCalledTimes(1); + }); + }); + + describe('listOrgCredentials', () => { + it('returns credentials for org', async () => { + const mockCreds = [ + { id: 1, orgId: 'org1', name: 'Key 1', envVarKey: 'KEY1', value: 'v1', isDefault: true }, + { id: 2, orgId: 'org1', name: 'Key 2', envVarKey: 'KEY2', value: 'v2', isDefault: false }, + ]; + mockDb.chain.where.mockResolvedValueOnce(mockCreds); + + const result = await listOrgCredentials('org1'); + expect(result).toHaveLength(2); + expect(result[0].name).toBe('Key 1'); + }); + + it('returns empty array when no credentials', async () => { + mockDb.chain.where.mockResolvedValueOnce([]); + + const result = await listOrgCredentials('empty-org'); + expect(result).toEqual([]); + }); + }); + + describe('setProjectCredentialOverride', () => { + it('upserts override', async () => { + mockDb.chain.onConflictDoUpdate.mockResolvedValueOnce(undefined); + + await setProjectCredentialOverride('proj1', 'GITHUB_TOKEN', 42); + + expect(mockDb.db.insert).toHaveBeenCalledTimes(1); + expect(mockDb.chain.values).toHaveBeenCalledWith({ + projectId: 'proj1', + envVarKey: 'GITHUB_TOKEN', + credentialId: 42, + }); + expect(mockDb.chain.onConflictDoUpdate).toHaveBeenCalled(); + }); + }); + + describe('removeProjectCredentialOverride', () => { + it('deletes override for project and key', async () => { + mockDb.chain.where.mockResolvedValueOnce(undefined); + + await removeProjectCredentialOverride('proj1', 'GITHUB_TOKEN'); + + expect(mockDb.db.delete).toHaveBeenCalledTimes(1); + }); + }); + + describe('listProjectOverrides', () => { + it('returns overrides with credential names', async () => { + const mockOverrides = [ + { envVarKey: 'GITHUB_TOKEN', credentialId: 42, credentialName: 'Bot Token' }, + ]; + mockDb.chain.where.mockResolvedValueOnce(mockOverrides); + + const result = await listProjectOverrides('proj1'); + expect(result).toEqual(mockOverrides); + }); + + it('returns empty array when no overrides', async () => { + mockDb.chain.where.mockResolvedValueOnce([]); + + const result = await listProjectOverrides('proj1'); + expect(result).toEqual([]); + }); + }); +}); diff --git a/tools/manage-secrets.ts b/tools/manage-secrets.ts index fe14bb18..037213fb 100644 --- a/tools/manage-secrets.ts +++ b/tools/manage-secrets.ts @@ -1,83 +1,188 @@ #!/usr/bin/env tsx /** - * Manage per-project secrets in the database. + * Manage org-scoped credentials and per-project overrides. * * Usage: - * npx tsx tools/manage-secrets.ts set - * npx tsx tools/manage-secrets.ts list - * npx tsx tools/manage-secrets.ts delete + * npx tsx tools/manage-secrets.ts create [--name "..."] [--description "..."] [--default] + * npx tsx tools/manage-secrets.ts list + * npx tsx tools/manage-secrets.ts delete + * npx tsx tools/manage-secrets.ts set-override + * npx tsx tools/manage-secrets.ts remove-override + * npx tsx tools/manage-secrets.ts resolve * * Requires DATABASE_URL to be set. */ import { closeDb } from '../src/db/client.js'; +import { findProjectByIdFromDb } from '../src/db/repositories/configRepository.js'; import { - deleteProjectSecret, - getProjectSecrets, - setProjectSecret, -} from '../src/db/repositories/secretsRepository.js'; - -const WELL_KNOWN_KEYS = [ - 'GITHUB_TOKEN', - 'GITHUB_REVIEWER_TOKEN', - 'TRELLO_API_KEY', - 'TRELLO_TOKEN', - 'OPENROUTER_API_KEY', - 'CLAUDE_CODE_OAUTH_TOKEN', -]; + createCredential, + deleteCredential, + listOrgCredentials, + listProjectOverrides, + removeProjectCredentialOverride, + resolveAllCredentials, + setProjectCredentialOverride, +} from '../src/db/repositories/credentialsRepository.js'; function printUsage(): void { console.log('Usage:'); - console.log(' npx tsx tools/manage-secrets.ts set '); - console.log(' npx tsx tools/manage-secrets.ts list '); - console.log(' npx tsx tools/manage-secrets.ts delete '); - console.log(''); - console.log('Well-known keys:', WELL_KNOWN_KEYS.join(', ')); + console.log( + ' npx tsx tools/manage-secrets.ts create [--name "..."] [--description "..."] [--default]', + ); + console.log(' npx tsx tools/manage-secrets.ts list '); + console.log(' npx tsx tools/manage-secrets.ts delete '); + console.log( + ' npx tsx tools/manage-secrets.ts set-override ', + ); + console.log(' npx tsx tools/manage-secrets.ts remove-override '); + console.log(' npx tsx tools/manage-secrets.ts resolve '); +} + +function parseFlag(args: string[], flag: string): string | undefined { + const idx = args.indexOf(flag); + if (idx === -1 || idx + 1 >= args.length) return undefined; + return args[idx + 1]; +} + +function hasFlag(args: string[], flag: string): boolean { + return args.includes(flag); +} + +function maskValue(value: string): string { + if (value.length <= 8) return '****'; + return `${value.slice(0, 4)}...${value.slice(-4)}`; } async function main() { - const [command, projectId, key, value] = process.argv.slice(2); + const args = process.argv.slice(2); + const command = args[0]; - if (!command || !projectId) { + if (!command) { printUsage(); process.exit(1); } switch (command) { - case 'set': { - if (!key || !value) { - console.error('Error: set requires and '); + case 'create': { + const [, orgId, envVarKey, value] = args; + if (!orgId || !envVarKey || !value) { + console.error('Error: create requires '); printUsage(); process.exit(1); } - await setProjectSecret(projectId, key, value); - console.log(`Set ${key} for project ${projectId}`); + const name = parseFlag(args, '--name') ?? envVarKey; + const description = parseFlag(args, '--description'); + const isDefault = hasFlag(args, '--default'); + + const { id } = await createCredential({ + orgId, + name, + envVarKey, + value, + description, + isDefault, + }); + console.log( + `Created credential #${id}: ${name} (${envVarKey}) for org ${orgId}${isDefault ? ' [DEFAULT]' : ''}`, + ); break; } case 'list': { - const secrets = await getProjectSecrets(projectId); - const keys = Object.keys(secrets); - if (keys.length === 0) { - console.log(`No secrets found for project ${projectId}`); + const orgId = args[1]; + if (!orgId) { + console.error('Error: list requires '); + printUsage(); + process.exit(1); + } + const creds = await listOrgCredentials(orgId); + if (creds.length === 0) { + console.log(`No credentials found for org ${orgId}`); } else { - console.log(`Secrets for project ${projectId}:`); - for (const k of keys) { - const masked = `${secrets[k].slice(0, 4)}...${secrets[k].slice(-4)}`; - console.log(` ${k}: ${masked}`); + console.log(`Credentials for org ${orgId}:`); + for (const c of creds) { + const defaultTag = c.isDefault ? ' [DEFAULT]' : ''; + const desc = c.description ? ` - ${c.description}` : ''; + console.log( + ` #${c.id}: ${c.name} (${c.envVarKey}) = ${maskValue(c.value)}${defaultTag}${desc}`, + ); } } break; } case 'delete': { - if (!key) { - console.error('Error: delete requires '); + const credIdStr = args[1]; + if (!credIdStr) { + console.error('Error: delete requires '); + printUsage(); + process.exit(1); + } + const credId = Number.parseInt(credIdStr, 10); + if (Number.isNaN(credId)) { + console.error('Error: credential-id must be a number'); + process.exit(1); + } + await deleteCredential(credId); + console.log(`Deleted credential #${credId}`); + break; + } + + case 'set-override': { + const [, projectId, envVarKey, credIdStr] = args; + if (!projectId || !envVarKey || !credIdStr) { + console.error('Error: set-override requires '); + printUsage(); + process.exit(1); + } + const credId = Number.parseInt(credIdStr, 10); + if (Number.isNaN(credId)) { + console.error('Error: credential-id must be a number'); + process.exit(1); + } + await setProjectCredentialOverride(projectId, envVarKey, credId); + console.log(`Set override: project ${projectId} → ${envVarKey} → credential #${credId}`); + break; + } + + case 'remove-override': { + const [, projectId, envVarKey] = args; + if (!projectId || !envVarKey) { + console.error('Error: remove-override requires '); printUsage(); process.exit(1); } - await deleteProjectSecret(projectId, key); - console.log(`Deleted ${key} for project ${projectId}`); + await removeProjectCredentialOverride(projectId, envVarKey); + console.log(`Removed override: project ${projectId} → ${envVarKey}`); + break; + } + + case 'resolve': { + const projectId = args[1]; + if (!projectId) { + console.error('Error: resolve requires '); + printUsage(); + process.exit(1); + } + const project = await findProjectByIdFromDb(projectId); + if (!project) { + console.error(`Project '${projectId}' not found`); + process.exit(1); + } + const resolved = await resolveAllCredentials(projectId, project.orgId); + const overrides = await listProjectOverrides(projectId); + const overrideKeys = new Set(overrides.map((o) => o.envVarKey)); + + if (Object.keys(resolved).length === 0) { + console.log(`No credentials resolved for project ${projectId}`); + } else { + console.log(`Resolved credentials for project ${projectId} (org: ${project.orgId}):`); + for (const [key, value] of Object.entries(resolved)) { + const source = overrideKeys.has(key) ? 'override' : 'org-default'; + console.log(` ${key}: ${maskValue(value)} [${source}]`); + } + } break; }