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