Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions src/db/crypto.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
89 changes: 89 additions & 0 deletions src/db/migrations/0040_project_scoped_credentials.sql
Original file line number Diff line number Diff line change
@@ -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;
7 changes: 7 additions & 0 deletions src/db/migrations/meta/_journal.json
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
]
}
1 change: 1 addition & 0 deletions src/db/schema/index.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down
23 changes: 23 additions & 0 deletions src/db/schema/projectCredentials.ts
Original file line number Diff line number Diff line change
@@ -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),
],
);
34 changes: 34 additions & 0 deletions tests/unit/db/crypto.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
encryptCredential,
isEncryptedValue,
isEncryptionEnabled,
reEncryptCredential,
} from '../../../src/db/crypto.js';

// Generate a valid 32-byte hex key for tests
Expand Down Expand Up @@ -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');
Expand Down
Loading