Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions .changeset/eql-v3-json-skills.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
---
'stash': patch
---

Document EQL v3 JSON columns in the bundled skills: `types.Json` in the
`stash-encryption` typed-schema catalog (capability suffix, family, and an
encrypted-JSONB query section), and `contains(col, subObject)` JSON containment
on the v3 Drizzle operators in `stash-drizzle`.
18 changes: 18 additions & 0 deletions .changeset/eql-v3-json.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
---
'@cipherstash/stack': minor
---

Add EQL v3 JSON columns. `types.Json('col')` declares a `public.eql_v3_json`
column that encrypts a JSON document to an ste_vec `SteVecDocument` and
round-trips it losslessly through `encrypt`/`decrypt` and the model path. A new
`searchableJson` query capability emits the ste_vec index; the index uses
`mode: 'compat'`, which eql-3.0.0's `eql_v3_json` requires (it orders ste_vec
entries by the CLLW-OPE `op` term, so v2's `'standard'`/CLLW-`oc` terms are
rejected).

The Drizzle integration's `contains(col, subObject)` now answers encrypted-JSONB
containment on a `types.Json` column, emitting the `@>` operator with a
`query_jsonb` needle (from `encryptQuery`). The ste_vec index indexes array
elements by identity but not position, so containment is a true subset test
(`{ roles: ['x'] }` matches any document whose `roles` array contains `x`,
regardless of index).
101 changes: 94 additions & 7 deletions packages/stack/__tests__/drizzle-v3/operators.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,13 +50,21 @@ function setup(
}),
) {
const encrypt = vi.fn((...args: unknown[]) => chainable(encryptImpl(...args)))
// `encryptQuery` backs JSON containment: it returns a narrowed `query_jsonb`
// term (no ciphertext). The double returns a recognisable ste_vec-shaped term
// so a rendered `@>` query can be asserted.
const encryptQuery = vi.fn(() =>
chainable(
Promise.resolve({ data: { sv: [{ s: 'sel', hm: 'h' }] } }) as never,
),
)
// The factory's `client` parameter is the structural `{ encrypt }` surface,
// so this hand-rolled double satisfies it with no cast (M1).
const client = { encrypt }
const client = { encrypt, encryptQuery }
const ops = createEncryptionOperatorsV3(client, { lockContext, audit })
const dialect = new PgDialect()
const render = (s: unknown) => dialect.sqlToQuery(s as SQL)
return { ops, encrypt, render }
return { ops, encrypt, encryptQuery, render }
}

type BulkPayload = Array<{ id?: string; plaintext: unknown }>
Expand Down Expand Up @@ -107,13 +115,23 @@ const orderDomains = matrixEntries.filter(
([, spec]) => spec.indexes.ore || spec.indexes.ope,
)
const matchDomains = matrixEntries.filter(([, spec]) => spec.indexes.match)
const storageDomains = matrixEntries.filter(
// Domains with NO scalar query index (unique/ore/ope/match), so `eq`/`ne`/
// ordering all throw. This is the truly storage-only scalar domains PLUS
// `eql_v3_json` — json is a QUERYABLE domain (containment), it just answers no
// scalar op, so it lands here for the eq/order rejection checks.
const nonScalarQueryDomains = matrixEntries.filter(
([, spec]) =>
!spec.indexes.unique &&
!spec.indexes.ore &&
!spec.indexes.ope &&
!spec.indexes.match,
)
// Of those, the ones that also reject `contains` — i.e. everything except json.
// json answers `contains` via `@>`, so it is excluded here and gets its own
// positive assertion in the JSON containment describe above.
const noContainmentDomains = nonScalarQueryDomains.filter(
([, spec]) => !spec.indexes.ste_vec,
)

const users = pgTable('users', {
id: integer().primaryKey(),
Expand Down Expand Up @@ -393,22 +411,91 @@ describe('createEncryptionOperatorsV3 - free-text match', () => {
})
})

describe('createEncryptionOperatorsV3 - storage-only domains', () => {
it.each(storageDomains)('%s eq throws', async (eqlType, spec) => {
describe('createEncryptionOperatorsV3 - JSON containment', () => {
const JSON_TYPE = 'public.eql_v3_json'

// json has no `eql_v3.contains` overload: containment is the `@>` operator,
// and the needle is a narrowed `query_jsonb` term from `encryptQuery` (no
// ciphertext), cast to `eql_v3.query_jsonb`.
it('contains emits the @> operator with a query_jsonb needle', async () => {
const { ops, encryptQuery, render } = setup()
const q = render(
await ops.contains(matrixColumn(JSON_TYPE), { roles: ['eng'] }),
)

expect(q.sql.toLowerCase()).toContain(
`"matrix_users"."${slug(JSON_TYPE)}" operator(public.@>) $1::eql_v3.query_jsonb`,
)
expect(q.sql).not.toContain('eql_v3.contains')
// The needle is the encryptQuery result, not a full storage envelope.
expect(q.params).toEqual([JSON.stringify({ sv: [{ s: 'sel', hm: 'h' }] })])
expect(encryptQuery).toHaveBeenCalledTimes(1)
expect(encryptQuery.mock.calls[0]?.[1]).toMatchObject({
queryType: 'searchableJson',
})
})

it('contains surfaces an encryptQuery failure as an EncryptionOperatorError', async () => {
const { ops, encryptQuery } = setup()
encryptQuery.mockReturnValueOnce(
chainable(
Promise.resolve({ failure: { message: 'boom' } }) as never,
) as never,
)
await expect(
ops.contains(matrixColumn(JSON_TYPE), { roles: ['eng'] }),
).rejects.toBeInstanceOf(EncryptionOperatorError)
})

it('contains rejects a null operand before calling encryptQuery', async () => {
const { ops, encryptQuery } = setup()
await expect(
ops.contains(matrixColumn(JSON_TYPE), null),
).rejects.toBeInstanceOf(EncryptionOperatorError)
expect(encryptQuery).not.toHaveBeenCalled()
})

// `doc @> '{}'` holds for every row (jsonb `{} ⊆ anything`); an empty-object
// needle would silently return the whole table, so it is refused before
// encrypting — the same whole-table guard the bloom path applies.
it('contains rejects an empty-object needle before calling encryptQuery', async () => {
const { ops, encryptQuery } = setup()
await expect(ops.contains(matrixColumn(JSON_TYPE), {})).rejects.toThrow(
/matches every row/,
)
expect(encryptQuery).not.toHaveBeenCalled()
})

// `encryptQuery` is OPTIONAL on the operand client (see OperandEncryptionClient):
// a `{ encrypt }`-only client is structurally valid but cannot build the
// `query_jsonb` needle JSON containment needs. Without this guard the call would
// be a raw `TypeError: encryptQuery is not a function`; the guard turns it into a
// typed EncryptionOperatorError. Exercises the branch the real double can't.
it('contains throws a typed error when the client lacks encryptQuery', async () => {
const encrypt = vi.fn(() => chainable(Promise.resolve({ data: TERM })))
const ops = createEncryptionOperatorsV3({ encrypt })
await expect(
ops.contains(matrixColumn(JSON_TYPE), { roles: ['eng'] }),
).rejects.toBeInstanceOf(EncryptionOperatorError)
})
})

describe('createEncryptionOperatorsV3 - domains with no scalar query', () => {
it.each(nonScalarQueryDomains)('%s eq throws', async (eqlType, spec) => {
const { ops } = setup()
await expect(
ops.eq(matrixColumn(eqlType), sampleFor(spec)),
).rejects.toBeInstanceOf(EncryptionOperatorError)
})

it.each(storageDomains)('%s contains throws', async (eqlType, spec) => {
it.each(noContainmentDomains)('%s contains throws', async (eqlType, spec) => {
const { ops } = setup()
await expect(
ops.contains(matrixColumn(eqlType), sampleFor(spec)),
).rejects.toBeInstanceOf(EncryptionOperatorError)
})

it.each(storageDomains)('%s asc throws synchronously', (eqlType) => {
it.each(nonScalarQueryDomains)('%s asc throws synchronously', (eqlType) => {
const { ops } = setup()
expect(() => ops.asc(matrixColumn(eqlType))).toThrow(
EncryptionOperatorError,
Expand Down
1 change: 1 addition & 0 deletions packages/stack/__tests__/eql-v3-domain-registry.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@ const EXPECTED_DOMAIN_KEYS = [
'eql_v3_double_eq',
'eql_v3_double_ord_ore',
'eql_v3_double_ord',
'eql_v3_json',
] as const

describe('DOMAIN_REGISTRY', () => {
Expand Down
9 changes: 6 additions & 3 deletions packages/stack/__tests__/supabase-v3-matrix.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -101,12 +101,15 @@ function firstSample(spec: DomainSpec): unknown {
describe('supabase v3 wire encoding, every domain', () => {
// Guards the tier arithmetic itself. A domain silently dropping out of a
// tier would otherwise just shrink an `it.each` with no test turning red.
it('tiers all 39 domains', () => {
expect(matrixEntries).toHaveLength(39)
it('tiers all 40 domains', () => {
expect(matrixEntries).toHaveLength(40)
expect(equalityDomains).toHaveLength(28)
expect(orderDomains).toHaveLength(19)
expect(matchDomains).toHaveLength(2)
expect(storageOnlyDomains).toHaveLength(10)
// +1: `eql_v3_json` carries only an `ste_vec` index, so from this scalar
// tiering it reads as storage-only — the Supabase adapter has no JSON
// containment path (PostgREST can't cast/call), so it rejects scalar ops.
expect(storageOnlyDomains).toHaveLength(11)
})

describe.each(matrixEntries)('%s', (eqlType, spec) => {
Expand Down
10 changes: 8 additions & 2 deletions packages/stack/__tests__/test-kit-families.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,21 +55,27 @@ describe('test-kit families partition the v3 catalog', () => {
expect([...covered, ...deferred].sort()).toEqual([...allBare].sort())
})

it('defers exactly the block-ORE domains, each with a reason', () => {
it('defers the block-ORE domains and json, each with a reason', () => {
const deferred = FAMILY_NAMES.flatMap((f) => deferredForFamily(f))

expect(deferred.map((d) => d.bare).sort()).toEqual([
'bigint_ord_ore',
'date_ord_ore',
'double_ord_ore',
'integer_ord_ore',
'json',
Comment thread
coderdan marked this conversation as resolved.
'numeric_ord_ore',
'real_ord_ore',
'smallint_ord_ore',
'text_ord_ore',
'timestamp_ord_ore',
])
for (const { reason } of deferred) expect(reason).toMatch(/superuser-only/)
// ORE domains defer for the superuser-only opclass; json defers because it
// is queried by containment, not the scalar op-matrix (covered by dedicated
// suites — see catalog reason).
for (const { bare, reason } of deferred) {
expect(reason).toMatch(bare === 'json' ? /containment/ : /superuser-only/)
}
})

it('never hands a family a domain from a neighbouring prefix', () => {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
/**
Comment thread
coderdan marked this conversation as resolved.
* Live JSON containment for the v3 `types.Json()` column through the Drizzle
* operators. Seeds encrypted JSONB documents and queries them with
* `ops.contains(col, subObject)` — the same operator text search uses, but on a
* `json` column it emits the `@>` operator over the encrypted JSONB document
* (json has no `eql_v3.contains` overload) with a `query_jsonb` needle from
* `encryptQuery` — asserting it returns exactly the rows whose document contains
* the sub-object (jsonb `@>` semantics), and excludes the rest.
*/
import { databaseUrl, unwrapResult } from '@cipherstash/test-kit'
import { and, asc, eq, type SQL } from 'drizzle-orm'
import { integer, pgTable, text } from 'drizzle-orm/pg-core'
import { drizzle } from 'drizzle-orm/postgres-js'
import postgres from 'postgres'
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
import { EncryptionV3 } from '@/encryption/v3'
import type { JsonDocument } from '@/eql/v3'
import {
createEncryptionOperatorsV3,
extractEncryptionSchemaV3,
types,
} from '@/eql/v3/drizzle'

const sqlClient = postgres(databaseUrl(), { prepare: false })

const TABLE_NAME = 'protect_ci_v3_json_contains'
const RUN = `run-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`

const docTable = pgTable(TABLE_NAME, {
id: integer('id').primaryKey().generatedAlwaysAsIdentity(),
rowKey: text('row_key').notNull(),
testRunId: text('test_run_id').notNull(),
doc: types.Json('doc'),
})

const schema = extractEncryptionSchemaV3(docTable)

// Distinct documents so each containment query has a definite expected set.
const DOCS: Record<string, JsonDocument> = {
ada: { user: 'ada@example.com', roles: ['admin', 'eng'], active: true },
grace: { user: 'grace@example.com', roles: ['eng'] },
zoe: { user: 'zoe@example.com', roles: ['ops'], active: false },
}

type SelectRow = { rowKey: string }
let client: Awaited<ReturnType<typeof EncryptionV3>>
let ops: ReturnType<typeof createEncryptionOperatorsV3>
let db: ReturnType<typeof drizzle>

async function matching(condition: SQL): Promise<string[]> {
const rows = (await db
.select({ rowKey: docTable.rowKey })
.from(docTable)
.where(and(eq(docTable.testRunId, RUN), condition))
.orderBy(asc(docTable.rowKey))) as SelectRow[]
return rows.map((row) => row.rowKey)
}

beforeAll(async () => {
// EQL v3 is installed once per run by `global-setup.ts`.
client = await EncryptionV3({ schemas: [schema] })
ops = createEncryptionOperatorsV3(client)
db = drizzle({ client: sqlClient })

await sqlClient.unsafe(`DROP TABLE IF EXISTS ${TABLE_NAME}`)
await sqlClient.unsafe(`
CREATE TABLE ${TABLE_NAME} (
id INTEGER PRIMARY KEY GENERATED ALWAYS AS IDENTITY,
row_key TEXT NOT NULL,
test_run_id TEXT NOT NULL,
doc public.eql_v3_json NOT NULL
)
`)

const rows = Object.entries(DOCS).map(([rowKey, doc]) => ({
rowKey,
testRunId: RUN,
doc,
}))
const encrypted = unwrapResult(
await client.bulkEncryptModels(rows, schema),
) as Array<Record<string, unknown>>
await db.insert(docTable).values(encrypted as never)
}, 120000)

afterAll(async () => {
await sqlClient`DELETE FROM ${sqlClient(TABLE_NAME)} WHERE test_run_id = ${RUN}`
await sqlClient.end()
}, 30000)

describe('v3 drizzle JSON containment (live pg)', () => {
it('matches every document containing a sub-object (array element containment)', async () => {
// `{ roles: ['eng'] }` ⊆ ada (admin,eng) and grace (eng), not zoe (ops).
const condition = await ops.contains(docTable.doc, { roles: ['eng'] })
expect(await matching(condition)).toEqual(['ada', 'grace'])
}, 30000)

it('matches on a scalar field', async () => {
const condition = await ops.contains(docTable.doc, {
user: 'zoe@example.com',
})
expect(await matching(condition)).toEqual(['zoe'])
}, 30000)

it('matches on a nested boolean', async () => {
const condition = await ops.contains(docTable.doc, { active: true })
expect(await matching(condition)).toEqual(['ada'])
}, 30000)

it('returns nothing for a sub-object no document contains', async () => {
Comment thread
coderdan marked this conversation as resolved.
const condition = await ops.contains(docTable.doc, { roles: ['nope'] })
expect(await matching(condition)).toEqual([])
}, 30000)

// `doc @> '{}'` holds for every row (jsonb `{} ⊆ anything`), so an empty-object
// needle would silently return the whole table — the same whole-table footgun
// the bloom path rejects. The operator must refuse it before querying, rather
// than leak every row.
it('rejects an empty-object needle before querying', async () => {
await expect(ops.contains(docTable.doc, {})).rejects.toThrow(
/matches every row/,
)
}, 30000)
})
Loading
Loading