From 5ce292bbfbd60264b90de92ea4a26f6dcc4ad46b Mon Sep 17 00:00:00 2001 From: Cascade Bot Date: Sun, 15 Mar 2026 08:15:35 +0000 Subject: [PATCH 1/2] feat(db): add project_credentials table and migration 0040 --- src/db/crypto.ts | 16 ++++ .../0040_project_scoped_credentials.sql | 89 +++++++++++++++++++ src/db/migrations/meta/_journal.json | 7 ++ src/db/schema/index.ts | 1 + src/db/schema/projectCredentials.ts | 23 +++++ tests/unit/db/crypto.test.ts | 34 +++++++ 6 files changed, 170 insertions(+) create mode 100644 src/db/migrations/0040_project_scoped_credentials.sql create mode 100644 src/db/schema/projectCredentials.ts 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..56988efd --- /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 TIMESTAMPTZ DEFAULT NOW(), + updated_at TIMESTAMPTZ 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'); From 8cebca95b86d24f17c33052aca0798438576108b Mon Sep 17 00:00:00 2001 From: Cascade Bot Date: Sun, 15 Mar 2026 08:28:10 +0000 Subject: [PATCH 2/2] fix(db): align migration timestamp type with Drizzle schema Change TIMESTAMPTZ to TIMESTAMP in 0040 migration so the SQL column type matches the Drizzle schema's timestamp() (without timezone), consistent with the credentials and integrations table patterns. Co-Authored-By: Claude Opus 4.6 --- src/db/migrations/0040_project_scoped_credentials.sql | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/db/migrations/0040_project_scoped_credentials.sql b/src/db/migrations/0040_project_scoped_credentials.sql index 56988efd..201c2663 100644 --- a/src/db/migrations/0040_project_scoped_credentials.sql +++ b/src/db/migrations/0040_project_scoped_credentials.sql @@ -16,8 +16,8 @@ CREATE TABLE IF NOT EXISTS project_credentials ( env_var_key TEXT NOT NULL, value TEXT NOT NULL, name TEXT, - created_at TIMESTAMPTZ DEFAULT NOW(), - updated_at TIMESTAMPTZ DEFAULT NOW() + created_at TIMESTAMP DEFAULT NOW(), + updated_at TIMESTAMP DEFAULT NOW() ); -- Step 2: Unique constraint on (project_id, env_var_key)