Skip to content

feat(db): add project_credentials table and migration 0040#844

Merged
aaight merged 2 commits intodevfrom
feature/project-credentials-table
Mar 15, 2026
Merged

feat(db): add project_credentials table and migration 0040#844
aaight merged 2 commits intodevfrom
feature/project-credentials-table

Conversation

@aaight
Copy link
Copy Markdown
Collaborator

@aaight aaight commented Mar 15, 2026

Summary

  • Creates project_credentials table with unique (project_id, env_var_key) constraint and AES-GCM encryption support
  • Writes migration 0040_project_scoped_credentials.sql that creates the new table and backfills existing org-scoped + integration credentials into per-project rows
  • Adds reEncryptCredential(value, oldAad, newAad) helper to src/db/crypto.ts for decrypt-then-re-encrypt with a different AAD
  • Adds Drizzle schema in src/db/schema/projectCredentials.ts and exports from src/db/schema/index.ts

Trello card: https://trello.com/c/Kf8oZsLx/365-as-a-developer-i-want-a-projectcredentials-table-and-migration-so-that-secrets-can-be-stored-per-project

Changes

New files

  • src/db/schema/projectCredentials.ts — Drizzle schema for the new table
  • src/db/migrations/0040_project_scoped_credentials.sql — SQL migration with CREATE TABLE, backfill from org defaults, and backfill from integration credentials (overriding defaults)

Modified files

  • src/db/crypto.ts — adds reEncryptCredential(stored, oldAad, newAad) helper
  • src/db/schema/index.ts — exports projectCredentials
  • src/db/migrations/meta/_journal.json — adds journal entry for migration 0040
  • tests/unit/db/crypto.test.ts — 3 new tests for reEncryptCredential

Migration behaviour

  1. Creates project_credentials(id, project_id FK→projects, env_var_key, value, name, created_at, updated_at) with UNIQUE(project_id, env_var_key)
  2. Backfills all is_default=true credentials from the credentials table into every project in the same org
  3. Backfills integration credentials (mapped via PROVIDER_CREDENTIAL_ROLES), overriding org defaults on conflict
  4. Values are copied as-is (encryption AAD stays as orgId); re-encryption with projectId as AAD can be done post-migration via the reEncryptCredential helper

No runtime changes

This PR only adds the table and backfills data. Existing credential resolution continues to use the old credentials and integration_credentials tables unchanged.

Test plan

  • reEncryptCredential unit tests pass (3 new test cases)
  • All 20 crypto.test.ts tests pass
  • Full unit test suite: 4912 passed (8 pre-existing unrelated failures unchanged)
  • TypeScript type check: zero errors
  • Lint: zero errors

🕵️ claude-code · claude-sonnet-4-6 · run details

Copy link
Copy Markdown
Collaborator

@nhopeatall nhopeatall left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Summary

Clean, well-structured PR. The migration logic is correct, the role-to-env_var_key mapping matches the PROVIDER_CREDENTIAL_ROLES source of truth, the reEncryptCredential helper is properly tested, and the Drizzle schema follows existing conventions. One schema/migration consistency issue to address.

Should Fix

Timestamp type mismatch — src/db/schema/projectCredentials.ts:15-18 vs src/db/migrations/0040_project_scoped_credentials.sql:19-20

The migration uses TIMESTAMPTZ for created_at and updated_at, but the Drizzle schema uses timestamp() without { withTimezone: true }. Drizzle's timestamp() maps to PostgreSQL timestamp without time zone, not timestamptz. This means the Drizzle schema doesn't accurately reflect the actual DB column types, which could cause issues if db:generate or db:push is ever run (it would detect a diff and try to alter the columns).

Two options to fix:

  1. Match existing credentials table pattern: Change the SQL migration from TIMESTAMPTZ to TIMESTAMP (lines 19-20 of the migration). This is consistent with how other credential-adjacent tables (credentials, integrations, organizations) are defined.
  2. Match prWorkItems pattern: Change Drizzle schema to timestamp('created_at', { withTimezone: true }).defaultNow() and same for updated_at. Then keep TIMESTAMPTZ in the migration.

Either approach works — the key is that the Drizzle schema and SQL migration should agree on the type.

Everything else looks good

  • ✅ The CASE role-to-env_var_key mapping in migration Step 4 exactly matches PROVIDER_CREDENTIAL_ROLES in src/config/integrationRoles.ts
  • ✅ The reEncryptCredential helper correctly handles all three cases (encrypted→re-encrypt, plaintext→passthrough, no key→passthrough) with good test coverage
  • ✅ The backfill logic correctly uses ON CONFLICT DO NOTHING for org defaults (Step 3) and ON CONFLICT DO UPDATE for integration overrides (Step 4), so integration-specific credentials take precedence
  • ✅ The Drizzle schema structure follows existing patterns (serial PK, text FK to projects.id, uniqueIndex naming convention)
  • ✅ Migration journal entry is correctly sequenced
  • ✅ All CI checks pass

🕵️ claude-code · claude-opus-4-6 · run details

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 <noreply@anthropic.com>
Copy link
Copy Markdown
Collaborator

@nhopeatall nhopeatall left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Summary

LGTM — Clean, well-structured migration that adds the project_credentials table with correct backfill logic.

Verification

  • Drizzle schema ↔ SQL migration: Table definition, column types, nullability, FK constraint, and unique index are consistent between projectCredentials.ts and 0040_project_scoped_credentials.sql.
  • CASE mapping: The role → env_var_key mapping in Step 4 exactly matches PROVIDER_CREDENTIAL_ROLES in src/config/integrationRoles.ts (all 8 roles across trello/jira/github). The ELSE ic.role fallback is unreachable due to the WHERE ic.role IN (...) filter — defensive but harmless.
  • Backfill ordering: Step 3 inserts org defaults with DO NOTHING, Step 4 inserts integration-specific with DO UPDATE — correct precedence (integration credentials override org defaults).
  • Encryption strategy: Values are copied as-is (AAD stays orgId), with re-encryption deferred to a post-migration tool. This is the right call — avoids needing the master key during migration and keeps the migration purely SQL.
  • reEncryptCredential helper: Correctly delegates to existing isEncryptedValuedecryptCredentialencryptCredential pipeline. All edge cases tested (encrypted, plaintext, encryption disabled).
  • Schema export: Correctly added to src/db/schema/index.ts.
  • Journal entry: idx: 40, when: 1775000000000 follows the established monotonic sequence.
  • CI: All checks passing.

🕵️ claude-code · claude-opus-4-6 · run details

@aaight aaight merged commit b47623a into dev Mar 15, 2026
6 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants