Skip to content

Commit 24d6fba

Browse files
committed
feat(db): add manual Trigger.dev task for the account token sealing backfill
1 parent 5b85be3 commit 24d6fba

3 files changed

Lines changed: 162 additions & 0 deletions

File tree

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
/**
2+
* @vitest-environment node
3+
*/
4+
5+
import { sealingKeyId } from '@sim/security/encryption'
6+
import { databaseMockFns } from '@sim/testing'
7+
import { afterEach, 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 { runSealAccountTokens } from '@/background/seal-account-tokens'
16+
17+
const ENCRYPTION_KEY = 'ab'.repeat(32)
18+
const WORKER_KEY_ID = sealingKeyId(Buffer.from(ENCRYPTION_KEY, 'hex'))
19+
20+
/** A postgres.js client over an empty `account` table. */
21+
function emptyAccountTable() {
22+
return Object.assign(async () => [], { end: vi.fn(async () => {}) })
23+
}
24+
25+
describe('runSealAccountTokens', () => {
26+
beforeEach(() => {
27+
vi.stubEnv('ENCRYPTION_KEY', ENCRYPTION_KEY)
28+
vi.stubEnv('ENCRYPT_ACCOUNT_TOKENS', 'true')
29+
databaseMockFns.mockResolveDbUrl.mockReturnValue('postgresql://fixture/sim')
30+
mockPostgres.mockReset()
31+
mockPostgres.mockImplementation(emptyAccountTable)
32+
})
33+
34+
afterEach(() => {
35+
vi.unstubAllEnvs()
36+
})
37+
38+
it('refuses a real seal under a key id other than the worker’s, before connecting', async () => {
39+
await expect(
40+
runSealAccountTokens({ mode: 'seal', dryRun: false, expectedKeyId: 'deadbeef' })
41+
).rejects.toThrow(`ENCRYPTION_KEY has key id ${WORKER_KEY_ID}, not the expected deadbeef`)
42+
expect(mockPostgres).not.toHaveBeenCalled()
43+
})
44+
45+
it('refuses to seal while this worker writes plaintext tokens, before connecting', async () => {
46+
vi.stubEnv('ENCRYPT_ACCOUNT_TOKENS', 'false')
47+
48+
await expect(
49+
runSealAccountTokens({ mode: 'seal', dryRun: false, expectedKeyId: WORKER_KEY_ID })
50+
).rejects.toThrow('ENCRYPT_ACCOUNT_TOKENS is not enabled')
51+
expect(mockPostgres).not.toHaveBeenCalled()
52+
})
53+
54+
it('still allows unsealing while writers store plaintext, which is the rollback', async () => {
55+
vi.stubEnv('ENCRYPT_ACCOUNT_TOKENS', 'false')
56+
57+
await expect(
58+
runSealAccountTokens({ mode: 'unseal', dryRun: false, expectedKeyId: WORKER_KEY_ID })
59+
).resolves.toEqual({ keyId: WORKER_KEY_ID, scanned: 0, pending: 0, updated: 0, foreignKey: 0 })
60+
})
61+
})
Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
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+
assertIntegerInRange('batchSize', batchSize, 1, MAX_BATCH_SIZE)
45+
assertIntegerInRange('pauseMs', pauseMs, 0, MAX_PAUSE_MS)
46+
47+
const keyId = sealingKeyId(readEncryptionKey())
48+
if (expectedKeyId !== keyId) {
49+
throw new Error(`ENCRYPTION_KEY has key id ${keyId}, not the expected ${expectedKeyId}`)
50+
}
51+
if (mode === 'seal' && !isAccountTokenWriteSealingEnabled()) {
52+
throw new Error(
53+
'Refusing to seal: ENCRYPT_ACCOUNT_TOKENS is not enabled on this worker, so writers still store plaintext'
54+
)
55+
}
56+
57+
const role = process.env.SIM_DB_ROLE?.trim() || 'web'
58+
const url = resolveDbUrl('DATABASE_URL', role)
59+
if (!url) throw new Error('Missing DATABASE_URL environment variable')
60+
61+
logger.info('Account token sealing started', { mode, dryRun, keyId, batchSize, pauseMs })
62+
const sql = postgres(url, {
63+
max: 1,
64+
prepare: false,
65+
connect_timeout: 30,
66+
idle_timeout: 20,
67+
onnotice: () => {},
68+
connection: { application_name: 'sim-account-token-sealing' },
69+
})
70+
try {
71+
const result = await sealAccountTokens(sql, {
72+
mode,
73+
expectedKeyId,
74+
dryRun,
75+
batchSize,
76+
pauseMs,
77+
})
78+
logger.info('Account token sealing finished', { mode, dryRun, ...result })
79+
return result
80+
} finally {
81+
await sql.end({ timeout: 5 })
82+
}
83+
}
84+
85+
/** Manual-only: never scheduled or dispatched by app code; trigger it from the dashboard or API. */
86+
export const sealAccountTokensTask = task({
87+
id: 'seal-account-tokens',
88+
machine: 'small-1x',
89+
maxDuration: 10_800,
90+
retry: { maxAttempts: 1 },
91+
queue: {
92+
name: 'seal-account-tokens',
93+
concurrencyLimit: 1,
94+
},
95+
run: (payload: SealAccountTokensPayload) => runSealAccountTokens(payload),
96+
})

‎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)