Skip to content

Commit 2f33ca0

Browse files
authored
feat(db): add manual Trigger.dev task for the account token sealing backfill (#9393)
* feat(db): add manual Trigger.dev task for the account token sealing backfill * fix(db): reject seal-account-tokens payloads without a boolean dryRun
1 parent 90f7f8c commit 2f33ca0

3 files changed

Lines changed: 170 additions & 0 deletions

File tree

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
/**
2+
* @vitest-environment node
3+
*/
4+
5+
import { sealingKeyId } from '@sim/security/encryption'
6+
import { databaseMockFns } from '@sim/testing/mocks/database.mock'
7+
import { beforeEach, describe, expect, it, vi } from 'vitest'
8+
9+
const { mockPostgres } = vi.hoisted(() => ({ mockPostgres: vi.fn() }))
10+
11+
vi.mock('postgres', () => ({ default: mockPostgres }))
12+
/** The pass derives each column's sealing context from the real column definitions. */
13+
vi.unmock('@sim/db/schema')
14+
15+
import {
16+
runSealAccountTokens,
17+
type SealAccountTokensPayload,
18+
} from '@/background/seal-account-tokens'
19+
20+
const ENCRYPTION_KEY = 'ab'.repeat(32)
21+
const WORKER_KEY_ID = sealingKeyId(Buffer.from(ENCRYPTION_KEY, 'hex'))
22+
23+
/** A postgres.js client over an empty `account` table. */
24+
function emptyAccountTable() {
25+
return Object.assign(async () => [], { end: vi.fn(async () => {}) })
26+
}
27+
28+
describe('runSealAccountTokens', () => {
29+
beforeEach(() => {
30+
vi.stubEnv('ENCRYPTION_KEY', ENCRYPTION_KEY)
31+
vi.stubEnv('ENCRYPT_ACCOUNT_TOKENS', 'true')
32+
databaseMockFns.mockResolveDbUrl.mockReturnValue('postgresql://fixture/sim')
33+
mockPostgres.mockReset()
34+
mockPostgres.mockImplementation(emptyAccountTable)
35+
})
36+
37+
it('refuses a real seal under a key id other than the worker’s, before connecting', async () => {
38+
await expect(
39+
runSealAccountTokens({ mode: 'seal', dryRun: false, expectedKeyId: 'deadbeef' })
40+
).rejects.toThrow(`ENCRYPTION_KEY has key id ${WORKER_KEY_ID}, not the expected deadbeef`)
41+
expect(mockPostgres).not.toHaveBeenCalled()
42+
})
43+
44+
it('refuses a payload without dryRun instead of treating it as a real run', async () => {
45+
const payload = { mode: 'seal', expectedKeyId: WORKER_KEY_ID } as SealAccountTokensPayload
46+
47+
await expect(runSealAccountTokens(payload)).rejects.toThrow('dryRun must be a boolean')
48+
expect(mockPostgres).not.toHaveBeenCalled()
49+
})
50+
51+
it('refuses to seal while this worker writes plaintext tokens, before connecting', async () => {
52+
vi.stubEnv('ENCRYPT_ACCOUNT_TOKENS', 'false')
53+
54+
await expect(
55+
runSealAccountTokens({ mode: 'seal', dryRun: false, expectedKeyId: WORKER_KEY_ID })
56+
).rejects.toThrow('ENCRYPT_ACCOUNT_TOKENS is not enabled')
57+
expect(mockPostgres).not.toHaveBeenCalled()
58+
})
59+
60+
it('still allows unsealing while writers store plaintext, which is the rollback', async () => {
61+
vi.stubEnv('ENCRYPT_ACCOUNT_TOKENS', 'false')
62+
63+
await expect(
64+
runSealAccountTokens({ mode: 'unseal', dryRun: false, expectedKeyId: WORKER_KEY_ID })
65+
).resolves.toEqual({ keyId: WORKER_KEY_ID, scanned: 0, pending: 0, updated: 0, foreignKey: 0 })
66+
})
67+
})
Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
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+
})

‎packages/db/sealed-text.ts‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,11 @@ function writeSealingKey(): Buffer | null {
4646
return key
4747
}
4848

49+
/** Whether this process seals account tokens it writes: the flag is on and the key is usable. */
50+
export function isAccountTokenWriteSealingEnabled(): boolean {
51+
return writeSealingKey() !== null
52+
}
53+
4954
/** Logs once per process whether writes seal account tokens, and the key id (never the key). */
5055
export function reportAccountTokenSealing(): void {
5156
if (reportedSealing) return

0 commit comments

Comments
 (0)