|
| 1 | +import { resolveDbUrl } from '@sim/db' |
| 2 | +import { sealAccountTokens } from '@sim/db/script-migrations/0037_seal_account_tokens' |
| 3 | +import { isAccountTokenWriteSealingEnabled, readEncryptionKey } from '@sim/db/sealed-text' |
| 4 | +import { createLogger } from '@sim/logger' |
| 5 | +import { sealingKeyId } from '@sim/security/encryption' |
| 6 | +import { task } from '@trigger.dev/sdk' |
| 7 | +import postgres from 'postgres' |
| 8 | + |
| 9 | +const logger = createLogger('SealAccountTokensTask') |
| 10 | + |
| 11 | +const MAX_BATCH_SIZE = 1000 |
| 12 | +const MAX_PAUSE_MS = 10_000 |
| 13 | + |
| 14 | +export interface SealAccountTokensPayload { |
| 15 | + mode: 'seal' | 'unseal' |
| 16 | + dryRun: boolean |
| 17 | + /** The key id the fleet logs at startup (`Account token sealing`); must match this worker's. */ |
| 18 | + expectedKeyId: string |
| 19 | + batchSize?: number |
| 20 | + pauseMs?: number |
| 21 | +} |
| 22 | + |
| 23 | +function assertIntegerInRange(name: string, value: number | undefined, min: number, max: number) { |
| 24 | + if (value === undefined) return |
| 25 | + if (!Number.isInteger(value) || value < min || value > max) { |
| 26 | + throw new Error(`${name} must be an integer between ${min} and ${max}`) |
| 27 | + } |
| 28 | +} |
| 29 | + |
| 30 | +/** |
| 31 | + * Runs the account-token sealing backfill (`0037_seal_account_tokens`) from a worker, which already |
| 32 | + * holds the database and the fleet's `ENCRYPTION_KEY`. Refuses before connecting when the payload |
| 33 | + * names a key id other than this worker's, so a pass can never seal rows under a key the fleet |
| 34 | + * cannot open, and refuses `seal` while this worker's writers still store plaintext. |
| 35 | + * |
| 36 | + * Opens its own single connection rather than reusing the `@sim/db` pool: the pass binds JS arrays |
| 37 | + * through the postgres.js tag, which needs the array type map the shared pool skips |
| 38 | + * (`fetch_types: false`). It resolves the same URL as the worker's `db` client and sets no startup |
| 39 | + * `search_path`, which PgBouncer rejects. |
| 40 | + */ |
| 41 | +export async function runSealAccountTokens(payload: SealAccountTokensPayload) { |
| 42 | + const { mode, dryRun, expectedKeyId, batchSize, pauseMs } = payload |
| 43 | + if (mode !== 'seal' && mode !== 'unseal') throw new Error(`Unknown mode ${String(mode)}`) |
| 44 | + if (typeof dryRun !== 'boolean') throw new Error('dryRun must be a boolean') |
| 45 | + if (typeof expectedKeyId !== 'string') throw new Error('expectedKeyId must be a string') |
| 46 | + assertIntegerInRange('batchSize', batchSize, 1, MAX_BATCH_SIZE) |
| 47 | + assertIntegerInRange('pauseMs', pauseMs, 0, MAX_PAUSE_MS) |
| 48 | + |
| 49 | + const keyId = sealingKeyId(readEncryptionKey()) |
| 50 | + if (expectedKeyId !== keyId) { |
| 51 | + throw new Error(`ENCRYPTION_KEY has key id ${keyId}, not the expected ${expectedKeyId}`) |
| 52 | + } |
| 53 | + if (mode === 'seal' && !isAccountTokenWriteSealingEnabled()) { |
| 54 | + throw new Error( |
| 55 | + 'Refusing to seal: ENCRYPT_ACCOUNT_TOKENS is not enabled on this worker, so writers still store plaintext' |
| 56 | + ) |
| 57 | + } |
| 58 | + |
| 59 | + const role = process.env.SIM_DB_ROLE?.trim() || 'web' |
| 60 | + const url = resolveDbUrl('DATABASE_URL', role) |
| 61 | + if (!url) throw new Error('Missing DATABASE_URL environment variable') |
| 62 | + |
| 63 | + logger.info('Account token sealing started', { mode, dryRun, keyId, batchSize, pauseMs }) |
| 64 | + const sql = postgres(url, { |
| 65 | + max: 1, |
| 66 | + prepare: false, |
| 67 | + connect_timeout: 30, |
| 68 | + idle_timeout: 20, |
| 69 | + onnotice: () => {}, |
| 70 | + connection: { application_name: 'sim-account-token-sealing' }, |
| 71 | + }) |
| 72 | + try { |
| 73 | + const result = await sealAccountTokens(sql, { |
| 74 | + mode, |
| 75 | + expectedKeyId, |
| 76 | + dryRun, |
| 77 | + batchSize, |
| 78 | + pauseMs, |
| 79 | + }) |
| 80 | + logger.info('Account token sealing finished', { mode, dryRun, ...result }) |
| 81 | + return result |
| 82 | + } finally { |
| 83 | + await sql.end({ timeout: 5 }) |
| 84 | + } |
| 85 | +} |
| 86 | + |
| 87 | +/** Manual-only: never scheduled or dispatched by app code; trigger it from the dashboard or API. */ |
| 88 | +export const sealAccountTokensTask = task({ |
| 89 | + id: 'seal-account-tokens', |
| 90 | + machine: 'small-1x', |
| 91 | + maxDuration: 10_800, |
| 92 | + retry: { maxAttempts: 1 }, |
| 93 | + queue: { |
| 94 | + name: 'seal-account-tokens', |
| 95 | + concurrencyLimit: 1, |
| 96 | + }, |
| 97 | + run: (payload: SealAccountTokensPayload) => runSealAccountTokens(payload), |
| 98 | +}) |
0 commit comments