Skip to content

Commit 9906fd7

Browse files
committed
fix: liniting checks for hard coded package manager
1 parent cc001e0 commit 9906fd7

12 files changed

Lines changed: 863 additions & 9 deletions

File tree

examples/sequelize-integration.ts

Lines changed: 332 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,332 @@
1+
/**
2+
* Sequelize v6 + @cipherstash/stack — minimal integration sketch.
3+
*
4+
* NOT a packaged integration. A starting point you can hand to a customer
5+
* to drop into their app and adapt.
6+
*
7+
* What's here:
8+
* 1. registerEqlTypeParser(sequelize) — pg type parser for `eql_v2_encrypted`
9+
* so SELECT returns a JS object instead of the raw composite-literal
10+
* string `("{...}")`.
11+
* 2. defineEncryptedModel(...) — wires beforeCreate / beforeUpdate /
12+
* beforeBulkCreate (encrypt) and afterFind (decrypt) hooks via
13+
* bulkEncryptModels / bulkDecryptModels (one ZeroKMS call per batch),
14+
* and patches the JSONB DataType for encrypted columns so the bound
15+
* value carries a `::jsonb` cast (explained below).
16+
* 3. encryptedFinders(...) — eq / ilike / gte / lte / between helpers that
17+
* pre-encrypt the search term and emit `eql_v2.<op>(col, $::eql_v2_encrypted)`
18+
* SQL fragments for use with Sequelize's `where: literal(...)`.
19+
*
20+
* Why we use plain DataTypes.JSONB and not a custom DataType class
21+
* ----------------------------------------------------------------
22+
* Subclassing DataTypes.JSONB and passing the subclass into a model attribute
23+
* looks right, but Sequelize v6 normalises attribute types during `define()`
24+
* and instantiates a fresh DataTypes.JSONB — your subclass is dropped.
25+
* Properly registering a custom type requires monkey-patching the dialect's
26+
* data-types module, which is fragile across versions. So instead:
27+
* - Declare encrypted columns as `DataTypes.JSONB` in Sequelize.
28+
* - Take the encryption schema (encryptedTable(...)) explicitly so we know
29+
* which columns to encrypt.
30+
* - After define(), patch each encrypted column's DataType instance so its
31+
* `_bindParam` / `_stringify` add a `::jsonb` cast. This forces the
32+
* implicit ASSIGNMENT cast `jsonb -> eql_v2_encrypted` (defined by EQL
33+
* via `eql_v2.to_encrypted(jsonb)` which does `ROW(data)::eql_v2_encrypted`)
34+
* to fire — avoiding the "malformed record literal" error you get when
35+
* Postgres tries to parse the JSON string as a composite literal directly.
36+
*
37+
* Database column type can be either:
38+
* - `eql_v2_encrypted` — full EQL operator overrides on `=`, `<`, etc.
39+
* Requires the type parser registration so reads come back as JS objects.
40+
* - `JSONB` — simpler; reads come back as JS objects natively. Our query
41+
* helpers use the explicit `eql_v2.<fn>(...)` calls so don't depend on
42+
* the operator overloads. Pick this if you want to avoid the type parser.
43+
*/
44+
45+
import { DataTypes, literal } from 'sequelize'
46+
import type { Model, ModelAttributes, ModelStatic, Sequelize } from 'sequelize'
47+
import pg from 'pg'
48+
49+
import { encryptedTable } from '@cipherstash/stack'
50+
import type { EncryptionClient } from '@cipherstash/stack/encryption'
51+
52+
// ---------------------------------------------------------------------------
53+
// 1. pg type parser for eql_v2_encrypted (only needed if the PG column type
54+
// is eql_v2_encrypted; skip if you store as JSONB).
55+
// ---------------------------------------------------------------------------
56+
57+
export async function registerEqlTypeParser(sequelize: Sequelize): Promise<void> {
58+
const [rows] = await sequelize.query(
59+
`SELECT oid::int AS oid FROM pg_type WHERE typname = 'eql_v2_encrypted' LIMIT 1`,
60+
)
61+
const row = (rows as Array<{ oid: number }>)[0]
62+
if (!row) {
63+
throw new Error('eql_v2_encrypted type not found — install EQL first')
64+
}
65+
pg.types.setTypeParser(row.oid, (raw: string) => {
66+
if (raw == null) return null
67+
if (!raw.startsWith('(') || !raw.endsWith(')')) return JSON.parse(raw)
68+
let inner = raw.slice(1, -1)
69+
if (inner.startsWith('"') && inner.endsWith('"')) {
70+
inner = inner.slice(1, -1).replace(/""/g, '"').replace(/\\\\/g, '\\')
71+
}
72+
return JSON.parse(inner)
73+
})
74+
}
75+
76+
// Convenience helper — encrypted columns are just JSONB to Sequelize.
77+
export const encryptedAttribute = (opts: { allowNull?: boolean } = {}) => ({
78+
type: DataTypes.JSONB,
79+
allowNull: opts.allowNull ?? true,
80+
})
81+
82+
// ---------------------------------------------------------------------------
83+
// 2. defineEncryptedModel — wires hooks
84+
// ---------------------------------------------------------------------------
85+
86+
export interface EncryptedModelHandle<M extends Model> {
87+
model: ModelStatic<M>
88+
schema: ReturnType<typeof encryptedTable>
89+
encryptedColumns: string[]
90+
}
91+
92+
export interface DefineEncryptedOpts {
93+
client: EncryptionClient
94+
// biome-ignore lint/suspicious/noExplicitAny: schema is opaque
95+
schema: any
96+
tableName?: string
97+
timestamps?: boolean
98+
}
99+
100+
export function defineEncryptedModel<M extends Model = Model>(
101+
sequelize: Sequelize,
102+
modelName: string,
103+
attributes: ModelAttributes,
104+
{ client, schema, tableName, timestamps = false }: DefineEncryptedOpts,
105+
): EncryptedModelHandle<M> {
106+
const encryptedColumns = Object.keys(schema.columnBuilders ?? {})
107+
108+
const Mdl = sequelize.define(modelName, attributes, {
109+
tableName,
110+
timestamps,
111+
}) as ModelStatic<M>
112+
113+
// Patch JSONB DataType instances on encrypted columns so the bound value
114+
// carries a ::jsonb cast — see top-of-file comment for why.
115+
for (const c of encryptedColumns) {
116+
// biome-ignore lint/suspicious/noExplicitAny: rawAttributes typing
117+
const attr = (Mdl.rawAttributes as any)[c]
118+
if (!attr?.type) continue
119+
const t = attr.type
120+
t.escape = false
121+
t._stringify = (value: unknown) => {
122+
if (value == null) return 'NULL'
123+
const json = JSON.stringify(value).replace(/'/g, "''")
124+
return `'${json}'::jsonb`
125+
}
126+
t._bindParam = (value: unknown, opts: { bindParam: (v: unknown) => string }) => {
127+
if (value == null) return 'NULL'
128+
return `${opts.bindParam(JSON.stringify(value))}::jsonb`
129+
}
130+
}
131+
132+
// Snapshot plaintext per-call so we can restore it after the write completes,
133+
// otherwise the user's instance ends up holding the EQL payload.
134+
const snapshots = new WeakMap<object, Map<unknown, Record<string, unknown>>>()
135+
const snap = (opts: object): Map<unknown, Record<string, unknown>> => {
136+
let m = snapshots.get(opts)
137+
if (!m) { m = new Map(); snapshots.set(opts, m) }
138+
return m
139+
}
140+
141+
// biome-ignore lint/suspicious/noExplicitAny: instance is dynamic
142+
const getVal = (inst: any, c: string) =>
143+
typeof inst.getDataValue === 'function' ? inst.getDataValue(c) : inst[c]
144+
// biome-ignore lint/suspicious/noExplicitAny: instance is dynamic
145+
const setVal = (inst: any, c: string, v: unknown) => {
146+
if (typeof inst.setDataValue === 'function') inst.setDataValue(c, v)
147+
else inst[c] = v
148+
}
149+
150+
// Distinguish plaintext from already-encrypted EQL JSON (idempotent in case
151+
// both beforeSave and beforeCreate fire on the same write).
152+
const isEqlPayload = (v: unknown): boolean =>
153+
typeof v === 'object' && v !== null && 'c' in (v as object) && 'i' in (v as object)
154+
155+
// biome-ignore lint/suspicious/noExplicitAny: instance is dynamic
156+
const collectPlaintexts = (inst: any) => {
157+
const out: Record<string, unknown> = {}
158+
for (const c of encryptedColumns) {
159+
const v = getVal(inst, c)
160+
if (v != null && !isEqlPayload(v)) out[c] = v
161+
}
162+
return out
163+
}
164+
165+
// biome-ignore lint/suspicious/noExplicitAny: instance is dynamic
166+
const restore = (inst: any, plain: Record<string, unknown>) => {
167+
for (const [k, v] of Object.entries(plain)) setVal(inst, k, v)
168+
}
169+
170+
// ---- single-row writes ----
171+
// biome-ignore lint/suspicious/noExplicitAny: hook signature
172+
const beforeWrite = async (inst: any, opts: any) => {
173+
const plain = collectPlaintexts(inst)
174+
if (Object.keys(plain).length === 0) return
175+
const r = await client.encryptModel(plain, schema)
176+
if (r.failure) throw new Error(`encrypt failed: ${r.failure.message}`)
177+
snap(opts).set(inst, plain)
178+
for (const [k, v] of Object.entries(r.data)) setVal(inst, k, v)
179+
}
180+
181+
// biome-ignore lint/suspicious/noExplicitAny: hook signature
182+
const afterWrite = (inst: any, opts: any) => {
183+
const m = snap(opts)
184+
const plain = m.get(inst)
185+
if (plain) { restore(inst, plain); m.delete(inst) }
186+
}
187+
188+
Mdl.addHook('beforeCreate', beforeWrite)
189+
Mdl.addHook('beforeUpdate', beforeWrite)
190+
Mdl.addHook('afterCreate', afterWrite)
191+
Mdl.addHook('afterUpdate', afterWrite)
192+
193+
// ---- bulk create — one bulkEncryptModels call regardless of batch size ----
194+
// biome-ignore lint/suspicious/noExplicitAny: hook signature
195+
Mdl.addHook('beforeBulkCreate', async (instances: any[], opts: any) => {
196+
if (instances.length === 0) return
197+
const plains = instances.map(collectPlaintexts)
198+
const r = await client.bulkEncryptModels(plains, schema)
199+
if (r.failure) throw new Error(`bulk encrypt failed: ${r.failure.message}`)
200+
const m = snap(opts)
201+
instances.forEach((inst, i) => {
202+
m.set(inst, plains[i])
203+
for (const [k, v] of Object.entries(r.data[i] as Record<string, unknown>)) setVal(inst, k, v)
204+
})
205+
})
206+
// biome-ignore lint/suspicious/noExplicitAny: hook signature
207+
Mdl.addHook('afterBulkCreate', (instances: any[], opts: any) => {
208+
const m = snap(opts)
209+
for (const inst of instances) {
210+
const plain = m.get(inst)
211+
if (plain) { restore(inst, plain); m.delete(inst) }
212+
}
213+
})
214+
215+
// ---- decrypt after find ----
216+
Mdl.addHook('afterFind', async (result: unknown) => {
217+
if (!result) return
218+
// biome-ignore lint/suspicious/noExplicitAny: result shape varies
219+
const list: any[] = Array.isArray(result) ? result : [result]
220+
if (list.length === 0) return
221+
222+
const payloads: Array<{ id: string; data: unknown }> = []
223+
list.forEach((inst, i) => {
224+
for (const c of encryptedColumns) {
225+
const v = getVal(inst, c)
226+
if (v != null) payloads.push({ id: `${i}:${c}`, data: v })
227+
}
228+
})
229+
if (payloads.length === 0) return
230+
231+
// biome-ignore lint/suspicious/noExplicitAny: payload cast
232+
const r = await client.bulkDecrypt(payloads as any)
233+
if (r.failure) throw new Error(`decrypt failed: ${r.failure.message}`)
234+
235+
for (const item of r.data) {
236+
if (!('data' in item)) continue
237+
const sep = item.id.indexOf(':')
238+
const idx = Number(item.id.slice(0, sep))
239+
const col = item.id.slice(sep + 1)
240+
setVal(list[idx], col, item.data)
241+
}
242+
})
243+
244+
return { model: Mdl, schema, encryptedColumns }
245+
}
246+
247+
// ---------------------------------------------------------------------------
248+
// 3. encryptedFinders — query-side helpers
249+
// ---------------------------------------------------------------------------
250+
// Sequelize's `Op.eq`/`Op.like`/etc. won't auto-encrypt the right-hand side,
251+
// so we encrypt the search term and emit a literal SQL fragment of the form:
252+
// eql_v2.<op>("<col>", '<eql json>'::jsonb::eql_v2_encrypted)
253+
// Combine with `where: literal((q.and(...) as any).val)` or just `where: lit`.
254+
255+
type Lit = ReturnType<typeof literal>
256+
257+
const sqlIdent = (s: string) => `"${s.replace(/"/g, '""')}"`
258+
const sqlString = (s: string) => `'${s.replace(/'/g, "''")}'`
259+
260+
function eqlFragment(op: string, columnName: string, payload: unknown): Lit {
261+
return literal(
262+
`eql_v2.${op}(${sqlIdent(columnName)}, ${sqlString(JSON.stringify(payload))}::jsonb::eql_v2_encrypted)`,
263+
)
264+
}
265+
266+
export interface EncryptedFinders {
267+
eq: (col: string, value: unknown) => Promise<Lit>
268+
gt: (col: string, value: unknown) => Promise<Lit>
269+
gte: (col: string, value: unknown) => Promise<Lit>
270+
lt: (col: string, value: unknown) => Promise<Lit>
271+
lte: (col: string, value: unknown) => Promise<Lit>
272+
like: (col: string, pattern: string) => Promise<Lit>
273+
ilike: (col: string, pattern: string) => Promise<Lit>
274+
between: (col: string, min: unknown, max: unknown) => Promise<Lit>
275+
and: (...frags: Lit[]) => Lit
276+
or: (...frags: Lit[]) => Lit
277+
}
278+
279+
export function encryptedFinders<M extends Model>(
280+
client: EncryptionClient,
281+
{ schema }: EncryptedModelHandle<M>,
282+
): EncryptedFinders {
283+
// biome-ignore lint/suspicious/noExplicitAny: schema is opaque
284+
const lookup = (col: string): { column: any; table: any } => {
285+
// biome-ignore lint/suspicious/noExplicitAny: schema is opaque
286+
const table = schema as any
287+
const column = table.columnBuilders?.[col] ?? table[col]
288+
if (!column) throw new Error(`Column '${col}' is not encrypted`)
289+
return { column, table }
290+
}
291+
292+
const make = (op: string, queryType: 'equality' | 'orderAndRange' | 'freeTextSearch') =>
293+
async (col: string, value: unknown): Promise<Lit> => {
294+
const { column, table } = lookup(col)
295+
// biome-ignore lint/suspicious/noExplicitAny: encryptQuery generic
296+
const r = await client.encryptQuery(value as any, { column, table, queryType })
297+
if (r.failure) throw new Error(r.failure.message)
298+
return eqlFragment(op, col, r.data)
299+
}
300+
301+
const combineSql = (sep: ' AND ' | ' OR ', frags: Lit[]): Lit => {
302+
if (frags.length === 0) return literal(sep === ' AND ' ? 'TRUE' : 'FALSE')
303+
// biome-ignore lint/suspicious/noExplicitAny: Literal's val is internal
304+
const parts = frags.map(f => `(${(f as any).val})`)
305+
return literal(parts.join(sep))
306+
}
307+
308+
return {
309+
eq: make('eq', 'equality'),
310+
gt: make('gt', 'orderAndRange'),
311+
gte: make('gte', 'orderAndRange'),
312+
lt: make('lt', 'orderAndRange'),
313+
lte: make('lte', 'orderAndRange'),
314+
like: make('like', 'freeTextSearch'),
315+
ilike: make('ilike', 'freeTextSearch'),
316+
between: async (col, min, max) => {
317+
const { column, table } = lookup(col)
318+
// biome-ignore lint/suspicious/noExplicitAny: encryptQuery generic
319+
const minR = await client.encryptQuery(min as any, { column, table, queryType: 'orderAndRange' })
320+
// biome-ignore lint/suspicious/noExplicitAny: encryptQuery generic
321+
const maxR = await client.encryptQuery(max as any, { column, table, queryType: 'orderAndRange' })
322+
if (minR.failure) throw new Error(minR.failure.message)
323+
if (maxR.failure) throw new Error(maxR.failure.message)
324+
return literal(
325+
`(eql_v2.gte(${sqlIdent(col)}, ${sqlString(JSON.stringify(minR.data))}::jsonb::eql_v2_encrypted) ` +
326+
`AND eql_v2.lte(${sqlIdent(col)}, ${sqlString(JSON.stringify(maxR.data))}::jsonb::eql_v2_encrypted))`,
327+
)
328+
},
329+
and: (...frags) => combineSql(' AND ', frags),
330+
or: (...frags) => combineSql(' OR ', frags),
331+
}
332+
}

0 commit comments

Comments
 (0)