Skip to content

Commit b687134

Browse files
calvinbrewerclaude
authored andcommitted
feat(prisma-next): v3 bulk-encrypt middleware (plain-JSONB payloads)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent fc2a25f commit b687134

5 files changed

Lines changed: 684 additions & 126 deletions

File tree

packages/prisma-next/src/middleware/bulk-encrypt.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -194,7 +194,14 @@ function collectTargets(
194194
return targets
195195
}
196196

197-
function stampRoutingKeysFromAst(ast: AnyQueryAst | undefined): void {
197+
/**
198+
* Walk a lowered `InsertAst` / `UpdateAst` and stamp `(table, column)`
199+
* routing context onto every cipherstash envelope embedded in a
200+
* `ParamRef`. Version-neutral: it touches only the shared envelope base
201+
* and the AST — no wire/codec knowledge — so the v3 middleware
202+
* (`../v3/bulk-encrypt-v3.ts`) reuses it rather than forking the walk.
203+
*/
204+
export function stampRoutingKeysFromAst(ast: AnyQueryAst | undefined): void {
198205
if (!ast) return
199206
switch (ast.kind) {
200207
case 'insert':
Lines changed: 155 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,155 @@
1+
/**
2+
* v3 bulk-encrypt middleware — the plain-JSONB twin of the v2
3+
* `../middleware/bulk-encrypt.ts`. Same `beforeExecute` lifecycle and
4+
* batching contract, with two deliberate differences:
5+
*
6+
* - **Jurisdiction**: params are matched against the closed v3
7+
* codec-id set ({@link CIPHERSTASH_V3_CODEC_ID_SET}), never the v2
8+
* set — the two middlewares partition the param space by codec id,
9+
* so a plan whose params carry v2 codec ids is invisible here.
10+
* - **Wire**: the param slot is replaced with the plain JSONB text of
11+
* the EQL payload ({@link v3ToDriver} — v3 columns are PG domains
12+
* over `jsonb`), never the v2 `eql_v2_encrypted` composite literal.
13+
* This module MUST NOT import `encodeEqlV2EncryptedWire`.
14+
*
15+
* The `(table, column)` routing-key stamping is NOT forked: the AST walk
16+
* is version-neutral (it touches only the shared envelope base) and is
17+
* reused from the v2 module via the exported
18+
* {@link stampRoutingKeysFromAst}.
19+
*
20+
* Everything else — one `sdk.bulkEncrypt` per `(table, column)` group,
21+
* `setHandleCiphertext` stamping with plaintext retention, `ctx.signal`
22+
* forwarding by identity plus the pre-flight abort check and the abort
23+
* race, and the ciphertext-count diagnostic — mirrors the v2 middleware;
24+
* see its module doc for the full lifecycle rationale.
25+
*/
26+
27+
import type {
28+
ParamRefHandle,
29+
SqlParamRefMutator,
30+
} from '@prisma-next/sql-relational-core/middleware'
31+
import type { SqlMiddleware } from '@prisma-next/sql-runtime'
32+
import { ifDefined } from '@prisma-next/utils/defined'
33+
import {
34+
checkCipherstashAborted,
35+
raceCipherstashAbort,
36+
} from '../execution/abort'
37+
import {
38+
EncryptedEnvelopeBase,
39+
setHandleCiphertext,
40+
} from '../execution/envelope-base'
41+
import { markBulkEncryptMiddlewareRegistered } from '../execution/middleware-registry'
42+
import { type BulkEncryptTarget, groupByRoutingKey } from '../execution/routing'
43+
import type { CipherstashSdk } from '../execution/sdk'
44+
import { CIPHERSTASH_V3_CODEC_ID_SET } from '../extension-metadata/constants-v3'
45+
// Version-neutral AST routing-key stamping — reused, not the v2 encode path.
46+
import { stampRoutingKeysFromAst } from '../middleware/bulk-encrypt'
47+
import { v3ToDriver } from './wire-v3'
48+
49+
/**
50+
* Construct the v3 bulk-encrypt middleware. The returned middleware is
51+
* stateless aside from the captured `sdk` reference; one instance per
52+
* (v3) runtime extension is the expected pattern.
53+
*/
54+
export function bulkEncryptMiddlewareV3(sdk: CipherstashSdk): SqlMiddleware {
55+
// Mark this sdk as wired up so the codec's `encode` can distinguish
56+
// "happy path: middleware will run later and fill in the ciphertext"
57+
// from "misconfig: this sdk has no middleware registered". See
58+
// `../execution/middleware-registry.ts`.
59+
markBulkEncryptMiddlewareRegistered(sdk)
60+
return {
61+
name: 'cipherstash.bulk-encrypt-v3',
62+
familyId: 'sql',
63+
async beforeExecute(plan, ctx, params) {
64+
if (!params) {
65+
return
66+
}
67+
68+
stampRoutingKeysFromAst(plan.ast)
69+
70+
const targets = collectV3Targets(params)
71+
if (targets.length === 0) {
72+
return
73+
}
74+
75+
const groups = groupByRoutingKey(targets)
76+
for (const [groupKey, group] of groups) {
77+
const first = group[0]
78+
if (!first) continue
79+
const routingKey = first.routingKey
80+
81+
checkCipherstashAborted(ctx.signal, 'bulk-encrypt')
82+
const ciphertexts = await raceCipherstashAbort(
83+
sdk.bulkEncrypt({
84+
routingKey,
85+
values: group.map((t) => t.plaintext),
86+
...ifDefined('signal', ctx.signal),
87+
}),
88+
ctx.signal,
89+
'bulk-encrypt',
90+
)
91+
92+
if (ciphertexts.length !== group.length) {
93+
throw new Error(
94+
`cipherstash v3 bulk-encrypt: SDK returned ${ciphertexts.length} ciphertexts ` +
95+
`for routing key ${groupKey} but ${group.length} were requested.`,
96+
)
97+
}
98+
99+
// Replace each ParamRef's value with the plain JSONB text the
100+
// pg driver can serialise directly (the envelope instance
101+
// itself would fail at the driver boundary). The envelope's
102+
// ciphertext slot is also stamped via `setHandleCiphertext`
103+
// and its plaintext slot retained, so follow-on reuse of the
104+
// same envelope skips the re-encrypt round-trip.
105+
params.replaceValues(
106+
group.map((t, i) => {
107+
const ciphertext = ciphertexts[i]
108+
setHandleCiphertext(t.envelope, ciphertext)
109+
return {
110+
ref: t.ref,
111+
newValue: v3ToDriver(ciphertext),
112+
}
113+
}),
114+
)
115+
}
116+
},
117+
}
118+
}
119+
120+
function collectV3Targets(
121+
params: SqlParamRefMutator,
122+
): BulkEncryptTarget<ParamRefHandle<string | undefined>>[] {
123+
const targets: BulkEncryptTarget<ParamRefHandle<string | undefined>>[] = []
124+
for (const entry of params.entries()) {
125+
if (
126+
entry.codecId === undefined ||
127+
!CIPHERSTASH_V3_CODEC_ID_SET.has(entry.codecId)
128+
)
129+
continue
130+
const value = entry.value
131+
if (!(value instanceof EncryptedEnvelopeBase)) continue
132+
const handle = value.expose()
133+
if (handle.plaintext === undefined) {
134+
throw new Error(
135+
'cipherstash v3 bulk-encrypt: encountered an envelope with no plaintext on the write path. ' +
136+
'Use the relevant `Encrypted*.from(plaintext)` factory to construct write-side envelopes.',
137+
)
138+
}
139+
if (handle.table === undefined || handle.column === undefined) {
140+
throw new Error(
141+
'cipherstash v3 bulk-encrypt: envelope reached the bulk-encrypt phase without a (table, column) ' +
142+
"routing context. The middleware's AST walk only handles `InsertAst` and `UpdateAst`; " +
143+
'cipherstash envelopes embedded in other plan shapes (e.g. raw SQL) must stamp routing ' +
144+
'context explicitly via `setHandleRoutingKey` before execute.',
145+
)
146+
}
147+
targets.push({
148+
ref: entry.ref,
149+
plaintext: handle.plaintext,
150+
envelope: value,
151+
routingKey: { table: handle.table, column: handle.column },
152+
})
153+
}
154+
return targets
155+
}
Lines changed: 144 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,144 @@
1+
/**
2+
* Shared harness for the bulk-encrypt middleware suites (v2
3+
* `bulk-encrypt-middleware.test.ts` and v3 `v3/bulk-encrypt-v3.test.ts`).
4+
* Version-neutral by construction: plan builders take the codec id to
5+
* stamp on each `ParamRef` (defaulting to the v2 string codec id so
6+
* existing v2 call sites read unchanged), and the mock SDK echoes
7+
* whatever ciphertext shape the caller's `encryptImpl` produces.
8+
*/
9+
10+
import type { Contract, PlanMeta } from '@prisma-next/contract/types'
11+
import type { SqlStorage } from '@prisma-next/sql-contract/types'
12+
import {
13+
type ColumnRef,
14+
InsertAst,
15+
ParamRef,
16+
TableSource,
17+
UpdateAst,
18+
} from '@prisma-next/sql-relational-core/ast'
19+
import type { SqlExecutionPlan } from '@prisma-next/sql-relational-core/plan'
20+
import type { SqlMiddlewareContext } from '@prisma-next/sql-runtime'
21+
import { vi } from 'vitest'
22+
import type {
23+
CipherstashBulkDecryptArgs,
24+
CipherstashBulkEncryptArgs,
25+
CipherstashSdk,
26+
CipherstashSingleDecryptArgs,
27+
} from '../src/execution/sdk'
28+
import { CIPHERSTASH_STRING_CODEC_ID } from '../src/extension-metadata/constants'
29+
30+
export { createSqlParamRefMutator } from '@prisma-next/sql-relational-core/middleware'
31+
32+
export const baseMeta: PlanMeta = {
33+
target: 'postgres',
34+
storageHash: 'sha256:test',
35+
lane: 'dsl',
36+
}
37+
38+
export function createCtx(
39+
overrides?: Partial<SqlMiddlewareContext>,
40+
): SqlMiddlewareContext {
41+
return {
42+
contract: {} as Contract<SqlStorage>,
43+
mode: 'strict' as const,
44+
scope: 'runtime' as const,
45+
now: () => Date.now(),
46+
log: {
47+
info: vi.fn(),
48+
warn: vi.fn(),
49+
error: vi.fn(),
50+
},
51+
contentHash: async () => 'mock-hash',
52+
planExecutionId: 'test-plan-execution',
53+
...overrides,
54+
}
55+
}
56+
57+
export interface CounterSdk extends CipherstashSdk {
58+
readonly bulkEncryptCalls: CipherstashBulkEncryptArgs[]
59+
readonly bulkDecryptCalls: CipherstashBulkDecryptArgs[]
60+
readonly singleDecryptCalls: CipherstashSingleDecryptArgs[]
61+
}
62+
63+
export function makeCounterSdk(options?: {
64+
encryptImpl?: (args: CipherstashBulkEncryptArgs) => ReadonlyArray<unknown>
65+
}): CounterSdk {
66+
const bulkEncryptCalls: CipherstashBulkEncryptArgs[] = []
67+
const bulkDecryptCalls: CipherstashBulkDecryptArgs[] = []
68+
const singleDecryptCalls: CipherstashSingleDecryptArgs[] = []
69+
const encryptImpl =
70+
options?.encryptImpl ??
71+
((args: CipherstashBulkEncryptArgs) =>
72+
args.values.map(
73+
(plaintext) =>
74+
`cipher:${args.routingKey.table}.${args.routingKey.column}:${plaintext}`,
75+
))
76+
return {
77+
bulkEncryptCalls,
78+
bulkDecryptCalls,
79+
singleDecryptCalls,
80+
decrypt(args) {
81+
singleDecryptCalls.push(args)
82+
return Promise.resolve(`single:${String(args.ciphertext)}`)
83+
},
84+
bulkEncrypt(args) {
85+
bulkEncryptCalls.push(args)
86+
return Promise.resolve(encryptImpl(args))
87+
},
88+
bulkDecrypt(args) {
89+
bulkDecryptCalls.push(args)
90+
return Promise.resolve(
91+
args.ciphertexts.map((c) => `bulk-decrypt:${String(c)}`),
92+
)
93+
},
94+
}
95+
}
96+
97+
export function buildInsertPlan(
98+
table: string,
99+
rows: ReadonlyArray<Record<string, unknown>>,
100+
codecId: string = CIPHERSTASH_STRING_CODEC_ID,
101+
): SqlExecutionPlan {
102+
const params: unknown[] = []
103+
const astRows = rows.map((row) => {
104+
const out: Record<string, ParamRef> = {}
105+
for (const [column, value] of Object.entries(row)) {
106+
const ref = ParamRef.of(value, {
107+
codec: { codecId },
108+
})
109+
out[column] = ref
110+
params.push(value)
111+
}
112+
return out
113+
})
114+
const ast = new InsertAst(TableSource.named(table), astRows)
115+
return {
116+
sql: `INSERT INTO "${table}" (...) VALUES (...)`,
117+
params,
118+
meta: { ...baseMeta },
119+
ast,
120+
} as SqlExecutionPlan
121+
}
122+
123+
export function buildUpdatePlan(
124+
table: string,
125+
set: Record<string, unknown>,
126+
codecId: string = CIPHERSTASH_STRING_CODEC_ID,
127+
): SqlExecutionPlan {
128+
const params: unknown[] = []
129+
const astSet: Record<string, ParamRef | ColumnRef> = {}
130+
for (const [column, value] of Object.entries(set)) {
131+
const ref = ParamRef.of(value, {
132+
codec: { codecId },
133+
})
134+
astSet[column] = ref
135+
params.push(value)
136+
}
137+
const ast = new UpdateAst(TableSource.named(table), astSet)
138+
return {
139+
sql: `UPDATE "${table}" SET ...`,
140+
params,
141+
meta: { ...baseMeta },
142+
ast,
143+
} as SqlExecutionPlan
144+
}

0 commit comments

Comments
 (0)