diff --git a/app/backend/prisma.config.ts b/app/backend/prisma.config.ts index b2ad2efe..127a4e64 100644 --- a/app/backend/prisma.config.ts +++ b/app/backend/prisma.config.ts @@ -6,6 +6,6 @@ export default defineConfig({ path: 'prisma/migrations', }, datasource: { - url: process.env.DATABASE_URL || 'file:./dev.db', + url: process.env.DATABASE_URL || 'postgresql://localhost:5432/chainforge_dev', }, }); diff --git a/app/backend/prisma/migrations/20260717000000_backfill_api_key_hashes/migration.sql b/app/backend/prisma/migrations/20260717000000_backfill_api_key_hashes/migration.sql new file mode 100644 index 00000000..f5dd89d8 --- /dev/null +++ b/app/backend/prisma/migrations/20260717000000_backfill_api_key_hashes/migration.sql @@ -0,0 +1,42 @@ +-- Migration: backfill_api_key_hashes +-- Purpose : Null-out legacy plaintext `key` values on rows that have +-- already been hashed by the TypeScript backfill script +-- (scripts/api-key-backfill.ts). +-- +-- Run order: +-- 1. Run `ts-node scripts/api-key-backfill.ts` first — it computes +-- SHA-256 hashes in application code and writes `keyHash` / +-- `keyPreview` for every row where `key IS NOT NULL`. +-- 2. Then run `prisma migrate deploy` (or `prisma migrate dev`) — +-- this migration will clear `key` for every row whose hash was +-- successfully backfilled. +-- +-- Idempotency: +-- The predicate `key IS NOT NULL AND keyHash IS NOT NULL` means: +-- • If the TS script has not run yet — zero rows matched → no-op. +-- • If this migration has already run — zero rows matched → no-op. +-- • A double-run is therefore always safe. +-- +-- Concurrency: +-- SQLite serialises writes at the connection level. The transaction +-- below is still included for atomicity on engines that support it +-- (e.g. PostgreSQL, if the DB is ever migrated). +-- On PostgreSQL, replace the comment with: +-- SELECT pg_advisory_xact_lock(hashtext('backfill_api_key_hashes')); + +BEGIN; + +-- Step 1: Acquire advisory lock to prevent concurrent backfill runs. +-- hashtext() is a built-in PostgreSQL function that converts a text value +-- to a stable 32-bit integer, suitable for use as an advisory lock key. +SELECT pg_advisory_xact_lock(hashtext('backfill_api_key_hashes')); + +-- Step 2: Clear the plaintext key for every row that the TS script +-- has already hashed. Rows where keyHash IS NULL are left untouched +-- so a partial backfill can be resumed safely. +UPDATE "ApiKey" +SET "key" = NULL +WHERE "key" IS NOT NULL + AND "keyHash" IS NOT NULL; + +COMMIT; diff --git a/app/backend/prisma/seed.ts b/app/backend/prisma/seed.ts index 945c9b41..716c457d 100644 --- a/app/backend/prisma/seed.ts +++ b/app/backend/prisma/seed.ts @@ -1,6 +1,18 @@ import { PrismaClient, AppRole, Campaign } from '@prisma/client'; +import { createHash } from 'node:crypto'; + const prisma = new PrismaClient(); +/** SHA-256 hex digest — mirrors api-keys.service.ts */ +function sha256Hex(value: string): string { + return createHash('sha256').update(value).digest('hex'); +} + +/** Preview mask: first 6 chars + "..." + last 4 chars */ +function maskPreview(rawKey: string): string { + return `${rawKey.slice(0, 6)}...${rawKey.slice(-4)}`; +} + async function main() { const roles = ['admin', 'ngo', 'user']; @@ -40,10 +52,25 @@ async function main() { ]; for (const data of devApiKeys) { + // Compute hash + preview so dev seeds exercise the same column shape as + // production keys created via ApiKeysService. + const keyHash = sha256Hex(data.key); + const keyPreview = maskPreview(data.key); + await prisma.apiKey.upsert({ where: { key: data.key }, - update: { role: data.role, description: data.description }, - create: data, + update: { + role: data.role, + description: data.description, + // Backfill hash/preview when re-seeding a pre-existing dev database. + keyHash, + keyPreview, + }, + create: { + ...data, + keyHash, + keyPreview, + }, }); } diff --git a/app/backend/scripts/api-key-backfill.spec.ts b/app/backend/scripts/api-key-backfill.spec.ts new file mode 100644 index 00000000..7a018e04 --- /dev/null +++ b/app/backend/scripts/api-key-backfill.spec.ts @@ -0,0 +1,256 @@ +/** + * scripts/api-key-backfill.spec.ts + * + * Unit tests for the API-key backfill helpers and orchestration logic. + * All Prisma calls are mocked — no real database is required. + */ + +import { createHash } from 'node:crypto'; +import { + sha256Hex, + maskPreview, + processBatch, + runBackfill, + BackfillRow, +} from './api-key-backfill'; + +// --------------------------------------------------------------------------- +// Test helpers +// --------------------------------------------------------------------------- + +/** Build a minimal PrismaClient mock for runBackfill tests. */ +function makePrismaMock(rows: Array<{ id: string; key: string | null }>) { + return { + apiKey: { + findMany: jest.fn().mockResolvedValue(rows), + update: jest.fn().mockResolvedValue({}), + }, + $disconnect: jest.fn().mockResolvedValue(undefined), + } as any; +} + +// --------------------------------------------------------------------------- +// sha256Hex +// --------------------------------------------------------------------------- + +describe('sha256Hex', () => { + it('returns a 64-character hex string', () => { + expect(sha256Hex('hello')).toHaveLength(64); + expect(sha256Hex('hello')).toMatch(/^[0-9a-f]+$/); + }); + + it('matches the node:crypto reference output', () => { + const expected = createHash('sha256').update('test-key-value').digest('hex'); + expect(sha256Hex('test-key-value')).toBe(expected); + }); + + it('produces different hashes for different inputs', () => { + expect(sha256Hex('key-a')).not.toBe(sha256Hex('key-b')); + }); + + it('is deterministic — same input produces same output', () => { + expect(sha256Hex('stable')).toBe(sha256Hex('stable')); + }); +}); + +// --------------------------------------------------------------------------- +// maskPreview +// --------------------------------------------------------------------------- + +describe('maskPreview', () => { + it('returns first-6 + "..." + last-4 format', () => { + expect(maskPreview('s2s_abcdefghij1234')).toBe('s2s_ab...1234'); + }); + + it('handles a key exactly 10 characters long (no overlap)', () => { + expect(maskPreview('ABCDEF1234')).toBe('ABCDEF...1234'); + }); + + it('never includes the middle portion of the key', () => { + const raw = 's2s_XXXXXXXXXXX_XXXX'; + const preview = maskPreview(raw); + // Preview must be prefix + separator + suffix only + expect(preview).toBe(`${raw.slice(0, 6)}...${raw.slice(-4)}`); + }); + + it('matches the same logic as api-keys.service.ts', () => { + const rawKey = 's2s_randomtoken123456'; + const expected = `${rawKey.slice(0, 6)}...${rawKey.slice(-4)}`; + expect(maskPreview(rawKey)).toBe(expected); + }); +}); + +// --------------------------------------------------------------------------- +// processBatch +// --------------------------------------------------------------------------- + +describe('processBatch', () => { + const rows: BackfillRow[] = [ + { id: 'r1', key: 'dev-admin-key-000' }, + { id: 'r2', key: 'dev-operator-key-001' }, + ]; + + it('calls updater for each valid row in normal mode', async () => { + const updater = jest.fn().mockResolvedValue(undefined); + + const result = await processBatch(rows, updater, false); + + expect(updater).toHaveBeenCalledTimes(2); + expect(result.processed).toBe(2); + expect(result.skipped).toBe(0); + }); + + it('passes the correct hash and preview to updater', async () => { + const updater = jest.fn().mockResolvedValue(undefined); + + await processBatch([rows[0]], updater, false); + + const [id, keyHash, keyPreview] = updater.mock.calls[0] as [ + string, + string, + string, + ]; + + expect(id).toBe('r1'); + expect(keyHash).toBe(sha256Hex('dev-admin-key-000')); + expect(keyPreview).toBe(maskPreview('dev-admin-key-000')); + }); + + it('never calls updater in dry-run mode', async () => { + const updater = jest.fn().mockResolvedValue(undefined); + + const result = await processBatch(rows, updater, true /* dryRun */); + + expect(updater).not.toHaveBeenCalled(); + expect(result.processed).toBe(2); + }); + + it('skips rows with an empty key', async () => { + const badRows = [ + { id: 'r3', key: '' }, + { id: 'r4', key: 'good-key-abc' }, + ] as BackfillRow[]; + const updater = jest.fn().mockResolvedValue(undefined); + + const result = await processBatch(badRows, updater, false); + + expect(updater).toHaveBeenCalledTimes(1); + expect(result.skipped).toBe(1); + expect(result.processed).toBe(1); + }); + + it('does not log or pass raw key values to updater', async () => { + const capturedHashes: string[] = []; + const updater = jest.fn().mockImplementation( + (_id: string, keyHash: string) => { + capturedHashes.push(keyHash); + return Promise.resolve(); + }, + ); + + await processBatch([{ id: 'x', key: 'super-secret' }], updater, false); + + // The value passed to updater must be the hash, not the plaintext. + expect(capturedHashes[0]).toBe(sha256Hex('super-secret')); + expect(capturedHashes[0]).not.toBe('super-secret'); + }); +}); + +// --------------------------------------------------------------------------- +// runBackfill +// --------------------------------------------------------------------------- + +describe('runBackfill', () => { + it('returns { total: 0, skipped: 0 } when no legacy rows exist', async () => { + const prisma = makePrismaMock([]); + + const result = await runBackfill(prisma, { dryRun: false, batchSize: 100 }); + + expect(result).toEqual({ total: 0, skipped: 0 }); + expect(prisma.apiKey.update).not.toHaveBeenCalled(); + }); + + it('processes all legacy rows and writes the expected fields', async () => { + const prisma = makePrismaMock([ + { id: 'k1', key: 'dev-admin-key-000' }, + { id: 'k2', key: 'dev-operator-key-001' }, + ]); + + const result = await runBackfill(prisma, { dryRun: false, batchSize: 100 }); + + expect(result.total).toBe(2); + expect(result.skipped).toBe(0); + expect(prisma.apiKey.update).toHaveBeenCalledTimes(2); + + // Verify the payload for the first row. + expect(prisma.apiKey.update).toHaveBeenCalledWith({ + where: { id: 'k1' }, + data: { + keyHash: sha256Hex('dev-admin-key-000'), + keyPreview: maskPreview('dev-admin-key-000'), + key: null, + }, + }); + }); + + it('is idempotent — a second run on an empty result set does nothing', async () => { + // Simulate: first run processed everything → findMany returns [] + const prisma = makePrismaMock([]); + + const r1 = await runBackfill(prisma, { dryRun: false, batchSize: 100 }); + const r2 = await runBackfill(prisma, { dryRun: false, batchSize: 100 }); + + expect(r1.total).toBe(0); + expect(r2.total).toBe(0); + expect(prisma.apiKey.update).not.toHaveBeenCalled(); + }); + + it('does not call update in dry-run mode', async () => { + const prisma = makePrismaMock([ + { id: 'k1', key: 'dev-admin-key-000' }, + ]); + + const result = await runBackfill(prisma, { dryRun: true, batchSize: 100 }); + + expect(result.total).toBe(1); + expect(prisma.apiKey.update).not.toHaveBeenCalled(); + }); + + it('respects batchSize — splits rows across multiple batches', async () => { + const rows = Array.from({ length: 5 }, (_, i) => ({ + id: `k${i}`, + key: `key-value-${i}`, + })); + const prisma = makePrismaMock(rows); + + // Batch size of 2 → 3 batches (2 + 2 + 1) + const result = await runBackfill(prisma, { dryRun: false, batchSize: 2 }); + + expect(result.total).toBe(5); + expect(prisma.apiKey.update).toHaveBeenCalledTimes(5); + }); + + it('clears the key column (sets it to null) after hashing', async () => { + const prisma = makePrismaMock([{ id: 'k1', key: 'plaintext' }]); + + await runBackfill(prisma, { dryRun: false, batchSize: 100 }); + + const callArg = (prisma.apiKey.update as jest.Mock).mock.calls[0][0]; + expect(callArg.data.key).toBeNull(); + }); + + it('queries only rows where key is non-null and keyHash is null', async () => { + const prisma = makePrismaMock([]); + + await runBackfill(prisma, { dryRun: false, batchSize: 100 }); + + expect(prisma.apiKey.findMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: { + key: { not: null }, + keyHash: null, + }, + }), + ); + }); +}); diff --git a/app/backend/scripts/api-key-backfill.ts b/app/backend/scripts/api-key-backfill.ts new file mode 100644 index 00000000..856b8814 --- /dev/null +++ b/app/backend/scripts/api-key-backfill.ts @@ -0,0 +1,245 @@ +/** + * scripts/api-key-backfill.ts + * + * One-time backfill job: hash every legacy plaintext API key. + * + * What it does + * ------------ + * For every ApiKey row where `key IS NOT NULL` (legacy plaintext) and + * `keyHash IS NULL` (not yet hashed), the script: + * 1. Computes SHA-256 of the raw key value. + * 2. Builds a preview: first 6 chars + "..." + last 4 chars. + * 3. Writes `keyHash` and `keyPreview`, then clears `key`. + * + * Idempotency + * ----------- + * Rows are only processed when `key IS NOT NULL AND keyHash IS NULL`. + * A second run finds zero matching rows and exits immediately — it is + * a true no-op. + * + * Dry-run mode + * ------------ + * Pass --dry-run (or set DRY_RUN=true) to print counts and sample + * previews without writing anything to the database. + * + * Batch processing + * ---------------- + * The script processes rows in batches of BATCH_SIZE (default 100) to + * avoid long-running transactions on large tables. + * + * Usage + * ----- + * # Normal run + * ts-node --transpile-only -r tsconfig-paths/register scripts/api-key-backfill.ts + * + * # Dry-run (read-only, prints counts + previews) + * ts-node --transpile-only -r tsconfig-paths/register scripts/api-key-backfill.ts --dry-run + * + * # Custom batch size + * BATCH_SIZE=50 ts-node --transpile-only -r tsconfig-paths/register scripts/api-key-backfill.ts + */ + +import { createHash } from 'node:crypto'; +import { PrismaClient } from '@prisma/client'; + +// --------------------------------------------------------------------------- +// Configuration +// --------------------------------------------------------------------------- + +const BATCH_SIZE = parseInt(process.env['BATCH_SIZE'] ?? '100', 10); + +const DRY_RUN = + process.argv.includes('--dry-run') || + process.env['DRY_RUN']?.toLowerCase() === 'true'; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +/** + * Compute the SHA-256 hex digest of a string. + * Mirrors the sha256Hex() helper in api-keys.service.ts. + */ +export function sha256Hex(value: string): string { + return createHash('sha256').update(value).digest('hex'); +} + +/** + * Build a masked preview of a raw key. + * Format: first 6 chars + "..." + last 4 chars. + * Mirrors maskPreview() in api-keys.service.ts. + */ +export function maskPreview(rawKey: string): string { + const prefix = rawKey.slice(0, 6); + const suffix = rawKey.slice(-4); + return `${prefix}...${suffix}`; +} + +// --------------------------------------------------------------------------- +// Core backfill logic — exported so tests can import it without spawning +// a real Prisma client. +// --------------------------------------------------------------------------- + +export interface BackfillRow { + id: string; + key: string; // non-null, validated before this function is called +} + +export interface BackfillResult { + processed: number; + skipped: number; // rows whose key was somehow empty/null at read time +} + +/** + * Process a single batch of rows. + * + * Each row is updated inside its own short transaction so that a failure + * in one row does not roll back the whole batch. + */ +export async function processBatch( + rows: BackfillRow[], + updater: ( + id: string, + keyHash: string, + keyPreview: string, + ) => Promise, + dryRun: boolean, +): Promise { + let processed = 0; + let skipped = 0; + + for (const row of rows) { + // Guard: the query should only return rows with non-null key, but be + // defensive to avoid ever hashing an empty string. + if (!row.key) { + skipped++; + continue; + } + + const keyHash = sha256Hex(row.key); + const keyPreview = maskPreview(row.key); + + if (dryRun) { + // Never log the raw key — only the preview and hash prefix. + console.log( + `[DRY-RUN] id=${row.id} preview="${keyPreview}" hash=${keyHash.slice(0, 8)}...`, + ); + processed++; + continue; + } + + await updater(row.id, keyHash, keyPreview); + processed++; + } + + return { processed, skipped }; +} + +// --------------------------------------------------------------------------- +// Main entry point +// --------------------------------------------------------------------------- + +export async function runBackfill( + prisma: PrismaClient, + opts: { dryRun: boolean; batchSize: number } = { + dryRun: DRY_RUN, + batchSize: BATCH_SIZE, + }, +): Promise<{ total: number; skipped: number }> { + const { dryRun, batchSize } = opts; + + if (dryRun) { + console.log('[api-key-backfill] DRY-RUN mode — no changes will be written.'); + } + + // Fetch all legacy rows up-front. For very large tables this can be + // replaced with cursor-based pagination; for a one-off backfill the + // in-memory approach is simpler and safe. + const legacyRows = await prisma.apiKey.findMany({ + where: { + key: { not: null }, + keyHash: null, + }, + select: { id: true, key: true }, + orderBy: { createdAt: 'asc' }, + }); + + const totalLegacy = legacyRows.length; + + if (totalLegacy === 0) { + console.log( + '[api-key-backfill] No legacy rows found. Nothing to do (idempotent no-op).', + ); + return { total: 0, skipped: 0 }; + } + + console.log( + `[api-key-backfill] Found ${totalLegacy} legacy row(s) to process in batches of ${batchSize}.`, + ); + + // Updater function: writes keyHash + keyPreview, clears key. + const updater = async ( + id: string, + keyHash: string, + keyPreview: string, + ): Promise => { + await prisma.apiKey.update({ + where: { id }, + data: { + keyHash, + keyPreview, + key: null, + }, + }); + }; + + let totalProcessed = 0; + let totalSkipped = 0; + + // Split into batches. + for (let offset = 0; offset < totalLegacy; offset += batchSize) { + const batch = legacyRows + .slice(offset, offset + batchSize) + .filter((r): r is BackfillRow => r.key !== null); + + const { processed, skipped } = await processBatch(batch, updater, dryRun); + + totalProcessed += processed; + totalSkipped += skipped; + + console.log( + `[api-key-backfill] Batch ${Math.floor(offset / batchSize) + 1}: processed=${processed}` + + (skipped > 0 ? ` skipped=${skipped}` : ''), + ); + } + + console.log( + `[api-key-backfill] Done. total_processed=${totalProcessed}` + + (totalSkipped > 0 ? ` total_skipped=${totalSkipped}` : '') + + (dryRun ? ' (DRY-RUN — no rows written)' : ''), + ); + + return { total: totalProcessed, skipped: totalSkipped }; +} + +// --------------------------------------------------------------------------- +// CLI entry — only runs when executed directly, not when imported by tests. +// --------------------------------------------------------------------------- + +/* istanbul ignore next */ +if (require.main === module) { + const prisma = new PrismaClient(); + + runBackfill(prisma, { dryRun: DRY_RUN, batchSize: BATCH_SIZE }) + .then(() => { + process.exit(0); + }) + .catch(err => { + // Log the error message but never log raw key values. + console.error('[api-key-backfill] Fatal error:', (err as Error).message); + process.exit(1); + }) + .finally(() => { + void prisma.$disconnect(); + }); +} diff --git a/package.json b/package.json index df23dfa0..b5a1524d 100644 --- a/package.json +++ b/package.json @@ -5,8 +5,8 @@ "scripts": { "build": "pnpm -r build", "lint": "pnpm -r lint", - "test": "pnpm -r test", - }, + "test": "pnpm -r test" + }, "pnpm": { "overrides": { "jest": "^30.4.2",