|
| 1 | +/** |
| 2 | + * Live, identity-bound querying through the v3 Drizzle operator path. |
| 3 | + * |
| 4 | + * `matrix-identity-live.test.ts` proves lock context round-trips through the |
| 5 | + * typed CLIENT (`encryptModel`/`decryptModel`), and `operators.test.ts` proves |
| 6 | + * the Drizzle operators FORWARD `lockContext`/`audit` — but only against a |
| 7 | + * MOCKED FFI. Nothing exercises the one end-to-end shape that matters most: |
| 8 | + * seed rows bound to an identity, then query them with `ops.eq(col, value, |
| 9 | + * { lockContext })` against a real database and assert the encrypted term |
| 10 | + * actually matches the stored row and decrypts. |
| 11 | + * |
| 12 | + * Wiring mirrors `lock-context.test.ts` (the current, non-deprecated |
| 13 | + * strategy-based flow): the client authenticates as the end user via |
| 14 | + * `OidcFederationStrategy` and binds the key to the `sub` claim with a plain |
| 15 | + * `.withLockContext({ identityClaim })`. |
| 16 | + * |
| 17 | + * Gated twice: `describeLivePg` (needs `DATABASE_URL` + CS creds) and an inner |
| 18 | + * `USER_JWT` guard (soft-skip, matching the existing identity/lock-context |
| 19 | + * suites). Whether the searchable index terms are themselves identity-bound is |
| 20 | + * decided inside `@cipherstash/protect-ffi`, not this repo — so we assert the |
| 21 | + * SYMMETRIC behaviour (same lock context on seed + query matches and decrypts), |
| 22 | + * not a cross-identity non-match. |
| 23 | + */ |
| 24 | +import 'dotenv/config' |
| 25 | +import { OidcFederationStrategy } from '@cipherstash/auth' |
| 26 | +import { and, asc as drizzleAsc, eq as drizzleEq, type SQL } from 'drizzle-orm' |
| 27 | +import { integer, pgTable, text } from 'drizzle-orm/pg-core' |
| 28 | +import { drizzle } from 'drizzle-orm/postgres-js' |
| 29 | +import postgres from 'postgres' |
| 30 | +import { afterAll, beforeAll, expect, it } from 'vitest' |
| 31 | +import { EncryptionV3 } from '@/encryption/v3' |
| 32 | +import { |
| 33 | + createEncryptionOperatorsV3, |
| 34 | + extractEncryptionSchemaV3, |
| 35 | +} from '@/eql/v3/drizzle' |
| 36 | +import { makeEqlV3Column } from '@/eql/v3/drizzle/column' |
| 37 | +import { installEqlV3IfNeeded } from '../helpers/eql-v3' |
| 38 | +import { describeLivePg, LIVE_EQL_V3_PG_ENABLED } from '../helpers/live-gate' |
| 39 | +import { V3_MATRIX } from '../v3-matrix/catalog' |
| 40 | + |
| 41 | +const url = process.env.DATABASE_URL |
| 42 | +const sqlClient = LIVE_EQL_V3_PG_ENABLED |
| 43 | + ? postgres(url as string, { prepare: false }) |
| 44 | + : (undefined as unknown as postgres.Sql) |
| 45 | + |
| 46 | +const TABLE_NAME = 'protect_ci_v3_drizzle_lock_context' |
| 47 | +const RUN = `run-${Date.now()}-${Math.random().toString(36).slice(2, 8)}` |
| 48 | +const ROW_A = 'row-a' |
| 49 | +const ROW_B = 'row-b' |
| 50 | +const SECRET_A = 'ada@example.com' |
| 51 | +const SECRET_B = 'grace@example.com' |
| 52 | + |
| 53 | +// A fixed identity claim; the same value must be supplied on encrypt and query |
| 54 | +// for the terms/keys to reproduce. |
| 55 | +const IDENTITY_CLAIM = { identityClaim: ['sub'] } |
| 56 | + |
| 57 | +const secretTable = pgTable(TABLE_NAME, { |
| 58 | + id: integer('id').primaryKey().generatedAlwaysAsIdentity(), |
| 59 | + rowKey: text('row_key').notNull(), |
| 60 | + testRunId: text('test_run_id').notNull(), |
| 61 | + secret: makeEqlV3Column(V3_MATRIX['eql_v3.text_eq'].builder('secret')), |
| 62 | +} as never) |
| 63 | + |
| 64 | +const schema = extractEncryptionSchemaV3(secretTable) |
| 65 | + |
| 66 | +type SelectRow = { rowKey: string } |
| 67 | + |
| 68 | +let client: Awaited<ReturnType<typeof EncryptionV3>> |
| 69 | +let ops: ReturnType<typeof createEncryptionOperatorsV3> |
| 70 | +let db: ReturnType<typeof drizzle> |
| 71 | +let userJwt: string | undefined |
| 72 | + |
| 73 | +function unwrap<T>(result: { data?: T; failure?: { message: string } }): T { |
| 74 | + if (result.failure) throw new Error(result.failure.message) |
| 75 | + return result.data as T |
| 76 | +} |
| 77 | + |
| 78 | +/** Run-scoped SELECT of row keys under an already-encrypted SQL condition. */ |
| 79 | +async function selectRowKeys(condition: SQL): Promise<string[]> { |
| 80 | + const rows = (await db |
| 81 | + .select({ rowKey: secretTable.rowKey }) |
| 82 | + .from(secretTable) |
| 83 | + .where(and(drizzleEq(secretTable.testRunId, RUN), condition)) |
| 84 | + .orderBy(drizzleAsc(secretTable.rowKey))) as SelectRow[] |
| 85 | + return rows.map((row) => row.rowKey) |
| 86 | +} |
| 87 | + |
| 88 | +beforeAll(async () => { |
| 89 | + if (!LIVE_EQL_V3_PG_ENABLED) return |
| 90 | + userJwt = process.env.USER_JWT |
| 91 | + if (!userJwt) return |
| 92 | + |
| 93 | + const crn = process.env.CS_WORKSPACE_CRN |
| 94 | + if (!crn) |
| 95 | + throw new Error('CS_WORKSPACE_CRN must be set for lock-context tests') |
| 96 | + |
| 97 | + await installEqlV3IfNeeded(sqlClient) |
| 98 | + client = await EncryptionV3({ |
| 99 | + schemas: [schema], |
| 100 | + config: { |
| 101 | + strategy: OidcFederationStrategy.create(crn, () => |
| 102 | + Promise.resolve(userJwt as string), |
| 103 | + ), |
| 104 | + }, |
| 105 | + }) |
| 106 | + ops = createEncryptionOperatorsV3(client) |
| 107 | + db = drizzle({ client: sqlClient }) |
| 108 | + |
| 109 | + await sqlClient.unsafe(` |
| 110 | + CREATE TABLE IF NOT EXISTS ${TABLE_NAME} ( |
| 111 | + id INTEGER PRIMARY KEY GENERATED ALWAYS AS IDENTITY, |
| 112 | + row_key TEXT NOT NULL, |
| 113 | + test_run_id TEXT NOT NULL, |
| 114 | + secret eql_v3.text_eq NOT NULL |
| 115 | + ) |
| 116 | + `) |
| 117 | + |
| 118 | + // Seed BOTH rows bound to the same lock context. |
| 119 | + const encryptedRows = unwrap<Array<Record<string, unknown>>>( |
| 120 | + await client |
| 121 | + .bulkEncryptModels( |
| 122 | + [ |
| 123 | + { rowKey: ROW_A, testRunId: RUN, secret: SECRET_A }, |
| 124 | + { rowKey: ROW_B, testRunId: RUN, secret: SECRET_B }, |
| 125 | + ] as never, |
| 126 | + schema, |
| 127 | + ) |
| 128 | + .withLockContext(IDENTITY_CLAIM), |
| 129 | + ) |
| 130 | + await db.insert(secretTable).values(encryptedRows as never) |
| 131 | +}, 120000) |
| 132 | + |
| 133 | +afterAll(async () => { |
| 134 | + if (!LIVE_EQL_V3_PG_ENABLED) return |
| 135 | + if (userJwt) { |
| 136 | + await sqlClient`DELETE FROM ${sqlClient(TABLE_NAME)} WHERE test_run_id = ${RUN}` |
| 137 | + } |
| 138 | + await sqlClient.end() |
| 139 | +}, 30000) |
| 140 | + |
| 141 | +describeLivePg('v3 drizzle operators with lock context (live pg)', () => { |
| 142 | + const skipUnlessJwt = (): boolean => { |
| 143 | + if (!userJwt) { |
| 144 | + console.log('Skipping lock-context operator test - no USER_JWT provided') |
| 145 | + return true |
| 146 | + } |
| 147 | + return false |
| 148 | + } |
| 149 | + |
| 150 | + it('eq with a matching lock context selects the exact row', async () => { |
| 151 | + if (skipUnlessJwt()) return |
| 152 | + const condition = await ops.eq(secretTable.secret, SECRET_A, { |
| 153 | + // Runtime accepts a plain { identityClaim } (forwarded to |
| 154 | + // withLockContext); the operator opts type is narrowed to LockContext. |
| 155 | + lockContext: IDENTITY_CLAIM as never, |
| 156 | + }) |
| 157 | + expect(await selectRowKeys(condition)).toEqual([ROW_A]) |
| 158 | + }, 30000) |
| 159 | + |
| 160 | + it('a lock-context-bound row decrypts with the same lock context', async () => { |
| 161 | + if (skipUnlessJwt()) return |
| 162 | + const [row] = await sqlClient.unsafe<Array<{ value: unknown }>>( |
| 163 | + `SELECT secret::jsonb AS value FROM ${TABLE_NAME} |
| 164 | + WHERE test_run_id = $1 AND row_key = $2`, |
| 165 | + [RUN, ROW_A], |
| 166 | + ) |
| 167 | + const decrypted = unwrap( |
| 168 | + await client.decrypt(row.value as never).withLockContext(IDENTITY_CLAIM), |
| 169 | + ) |
| 170 | + expect(decrypted).toBe(SECRET_A) |
| 171 | + }, 30000) |
| 172 | + |
| 173 | + it('eq threads an audit config alongside the lock context', async () => { |
| 174 | + if (skipUnlessJwt()) return |
| 175 | + const condition = await ops.eq(secretTable.secret, SECRET_B, { |
| 176 | + lockContext: IDENTITY_CLAIM as never, |
| 177 | + audit: { metadata: { sub: 'toby@cipherstash.com', type: 'query' } }, |
| 178 | + }) |
| 179 | + expect(await selectRowKeys(condition)).toEqual([ROW_B]) |
| 180 | + }, 30000) |
| 181 | +}) |
0 commit comments