diff --git a/src/db/crypto.ts b/src/db/crypto.ts index bce641b1..741f67a4 100644 --- a/src/db/crypto.ts +++ b/src/db/crypto.ts @@ -47,6 +47,22 @@ export function encryptCredential(plaintext: string, aad: string): string { return `${PREFIX}${iv.toString('hex')}:${authTag.toString('hex')}:${encrypted.toString('hex')}`; } +/** + * Re-encrypt a credential value with a different AAD (e.g., when migrating from + * org-scoped to project-scoped credentials). + * - If encryption is disabled (no master key), returns the value unchanged. + * - If the value is plaintext, returns it unchanged (nothing to re-encrypt). + * - If the value is encrypted with `oldAad`, decrypts then re-encrypts with `newAad`. + * @param stored - The stored credential value (may be plaintext or encrypted). + * @param oldAad - The AAD used during original encryption (e.g., orgId). + * @param newAad - The new AAD to use for re-encryption (e.g., projectId). + */ +export function reEncryptCredential(stored: string, oldAad: string, newAad: string): string { + if (!isEncryptedValue(stored)) return stored; + const plaintext = decryptCredential(stored, oldAad); + return encryptCredential(plaintext, newAad); +} + /** * Decrypt a credential value. * If the value is not encrypted (no `enc:` prefix), returns it as-is. diff --git a/src/db/migrations/0040_project_scoped_credentials.sql b/src/db/migrations/0040_project_scoped_credentials.sql new file mode 100644 index 00000000..201c2663 --- /dev/null +++ b/src/db/migrations/0040_project_scoped_credentials.sql @@ -0,0 +1,89 @@ +-- 0040_project_scoped_credentials.sql +-- Create project_credentials table and backfill from org-scoped + integration credentials. +-- +-- NOTE ON ENCRYPTION: +-- Values copied here retain their original encryption AAD (orgId). When +-- CREDENTIAL_MASTER_KEY is set, run the re-encryption tool after this migration: +-- npx tsx tools/migrate-project-credentials-reencrypt.ts +-- This will decrypt each value with its org's orgId and re-encrypt with the projectId. + +BEGIN; + +-- Step 1: Create the project_credentials table +CREATE TABLE IF NOT EXISTS project_credentials ( + id SERIAL PRIMARY KEY, + project_id TEXT NOT NULL REFERENCES projects(id) ON DELETE CASCADE, + env_var_key TEXT NOT NULL, + value TEXT NOT NULL, + name TEXT, + created_at TIMESTAMP DEFAULT NOW(), + updated_at TIMESTAMP DEFAULT NOW() +); + +-- Step 2: Unique constraint on (project_id, env_var_key) +CREATE UNIQUE INDEX IF NOT EXISTS uq_project_credentials_project_env_var_key + ON project_credentials(project_id, env_var_key); + +-- Step 3: Backfill org-default credentials into every project in the org. +-- Only the is_default=true credentials are treated as org defaults. +-- ON CONFLICT DO NOTHING means integration credentials added in Step 4 won't +-- be overwritten here; we rely on Step 4's ON CONFLICT DO UPDATE to apply +-- integration overrides after the defaults have been inserted. +INSERT INTO project_credentials (project_id, env_var_key, value, name, created_at, updated_at) +SELECT + p.id AS project_id, + c.env_var_key, + c.value, + c.name, + NOW() AS created_at, + NOW() AS updated_at +FROM credentials c +JOIN projects p ON p.org_id = c.org_id +WHERE c.is_default = true +ON CONFLICT (project_id, env_var_key) DO NOTHING; + +-- Step 4: Backfill integration credentials, overriding org defaults when both +-- exist for the same (project_id, env_var_key). +-- The role→env_var_key mapping mirrors PROVIDER_CREDENTIAL_ROLES in +-- src/config/integrationRoles.ts: +-- trello: api_key → TRELLO_API_KEY +-- api_secret → TRELLO_API_SECRET +-- token → TRELLO_TOKEN +-- jira: email → JIRA_EMAIL +-- api_token → JIRA_API_TOKEN +-- github: implementer_token → GITHUB_TOKEN_IMPLEMENTER +-- reviewer_token → GITHUB_TOKEN_REVIEWER +-- webhook_secret → GITHUB_WEBHOOK_SECRET +INSERT INTO project_credentials (project_id, env_var_key, value, name, created_at, updated_at) +SELECT + pi.project_id, + CASE ic.role + WHEN 'api_key' THEN 'TRELLO_API_KEY' + WHEN 'api_secret' THEN 'TRELLO_API_SECRET' + WHEN 'token' THEN 'TRELLO_TOKEN' + WHEN 'email' THEN 'JIRA_EMAIL' + WHEN 'api_token' THEN 'JIRA_API_TOKEN' + WHEN 'implementer_token' THEN 'GITHUB_TOKEN_IMPLEMENTER' + WHEN 'reviewer_token' THEN 'GITHUB_TOKEN_REVIEWER' + WHEN 'webhook_secret' THEN 'GITHUB_WEBHOOK_SECRET' + ELSE ic.role + END AS env_var_key, + c.value, + c.name, + NOW() AS created_at, + NOW() AS updated_at +FROM integration_credentials ic +JOIN project_integrations pi ON pi.id = ic.integration_id +JOIN credentials c ON c.id = ic.credential_id +-- Only process roles that have a known env_var_key mapping +WHERE ic.role IN ( + 'api_key', 'api_secret', 'token', + 'email', 'api_token', + 'implementer_token', 'reviewer_token', 'webhook_secret' +) +ON CONFLICT (project_id, env_var_key) DO UPDATE + SET value = EXCLUDED.value, + name = EXCLUDED.name, + updated_at = NOW(); + +COMMIT; diff --git a/src/db/migrations/meta/_journal.json b/src/db/migrations/meta/_journal.json index 40156d71..69a36d62 100644 --- a/src/db/migrations/meta/_journal.json +++ b/src/db/migrations/meta/_journal.json @@ -281,6 +281,13 @@ "when": 1774000000000, "tag": "0039_webhook_credential_roles", "breakpoints": false + }, + { + "idx": 40, + "version": "7", + "when": 1775000000000, + "tag": "0040_project_scoped_credentials", + "breakpoints": false } ] } diff --git a/src/db/schema/index.ts b/src/db/schema/index.ts index 894443dc..3708ed2d 100644 --- a/src/db/schema/index.ts +++ b/src/db/schema/index.ts @@ -1,4 +1,5 @@ export { credentials } from './credentials.js'; +export { projectCredentials } from './projectCredentials.js'; export { organizations } from './organizations.js'; export { agentConfigs } from './agentConfigs.js'; export { agentDefinitions } from './agentDefinitions.js'; diff --git a/src/db/schema/projectCredentials.ts b/src/db/schema/projectCredentials.ts new file mode 100644 index 00000000..26a45861 --- /dev/null +++ b/src/db/schema/projectCredentials.ts @@ -0,0 +1,23 @@ +import { serial, text, timestamp, uniqueIndex } from 'drizzle-orm/pg-core'; +import { pgTable } from 'drizzle-orm/pg-core'; +import { projects } from './projects.js'; + +export const projectCredentials = pgTable( + 'project_credentials', + { + id: serial('id').primaryKey(), + projectId: text('project_id') + .notNull() + .references(() => projects.id, { onDelete: 'cascade' }), + envVarKey: text('env_var_key').notNull(), + value: text('value').notNull(), + name: text('name'), + createdAt: timestamp('created_at').defaultNow(), + updatedAt: timestamp('updated_at') + .defaultNow() + .$onUpdate(() => new Date()), + }, + (table) => [ + uniqueIndex('uq_project_credentials_project_env_var_key').on(table.projectId, table.envVarKey), + ], +); diff --git a/tests/unit/db/crypto.test.ts b/tests/unit/db/crypto.test.ts index 054322d9..4b243985 100644 --- a/tests/unit/db/crypto.test.ts +++ b/tests/unit/db/crypto.test.ts @@ -5,6 +5,7 @@ import { encryptCredential, isEncryptedValue, isEncryptionEnabled, + reEncryptCredential, } from '../../../src/db/crypto.js'; // Generate a valid 32-byte hex key for tests @@ -132,6 +133,39 @@ describe('crypto', () => { }); }); + describe('reEncryptCredential', () => { + it('decrypts with oldAad and re-encrypts with newAad', () => { + const plaintext = 'ghp_abc123def456'; + const oldAad = 'org-1'; + const newAad = 'project-xyz'; + + const originalEncrypted = encryptCredential(plaintext, oldAad); + const reEncrypted = reEncryptCredential(originalEncrypted, oldAad, newAad); + + // Should still be encrypted + expect(isEncryptedValue(reEncrypted)).toBe(true); + // Should not equal the original (different AAD / random IV) + expect(reEncrypted).not.toBe(originalEncrypted); + // Should decrypt correctly with newAad + expect(decryptCredential(reEncrypted, newAad)).toBe(plaintext); + // Should NOT decrypt with oldAad + expect(() => decryptCredential(reEncrypted, oldAad)).toThrow(); + }); + + it('returns plaintext value unchanged when not encrypted', () => { + const plaintext = 'ghp_plaintext'; + const result = reEncryptCredential(plaintext, 'org-1', 'project-xyz'); + expect(result).toBe(plaintext); + }); + + it('returns plaintext value unchanged when encryption is disabled', () => { + vi.stubEnv('CREDENTIAL_MASTER_KEY', ''); + const plaintext = 'ghp_plaintext'; + const result = reEncryptCredential(plaintext, 'org-1', 'project-xyz'); + expect(result).toBe(plaintext); + }); + }); + describe('error cases', () => { it('throws when trying to decrypt encrypted value without key', () => { const encrypted = encryptCredential('secret', 'org-1');