From 628acdcf5a7d1af2ea2d724461d348cc6d62b88a Mon Sep 17 00:00:00 2001 From: CJ Brewer Date: Wed, 28 May 2025 09:08:23 -0600 Subject: [PATCH] feat: add createSearchTerms --- .changeset/fruity-shoes-talk.md | 5 + docs/reference/supabase-sdk.md | 125 +++++++++++------- .../protect/__tests__/search-terms.test.ts | 90 +++++++++++++ packages/protect/src/ffi/index.ts | 15 +++ .../src/ffi/operations/search-terms.ts | 55 ++++++++ packages/protect/src/types.ts | 18 +++ 6 files changed, 257 insertions(+), 51 deletions(-) create mode 100644 .changeset/fruity-shoes-talk.md create mode 100644 packages/protect/__tests__/search-terms.test.ts create mode 100644 packages/protect/src/ffi/operations/search-terms.ts diff --git a/.changeset/fruity-shoes-talk.md b/.changeset/fruity-shoes-talk.md new file mode 100644 index 000000000..cb06f31f5 --- /dev/null +++ b/.changeset/fruity-shoes-talk.md @@ -0,0 +1,5 @@ +--- +"@cipherstash/protect": minor +--- + +Implemented createSearchTerms for a streamlined way of working with encrypted search terms. diff --git a/docs/reference/supabase-sdk.md b/docs/reference/supabase-sdk.md index ace09180c..1c50376eb 100644 --- a/docs/reference/supabase-sdk.md +++ b/docs/reference/supabase-sdk.md @@ -14,18 +14,31 @@ Upvote this [issue](https://github.com/cipherstash/protectjs/issues/135) and fol When searching encrypted data, you need to convert the encrypted payload into a format that PostgreSQL and the Supabase SDK can understand. The encrypted payload needs to be converted to a raw composite type format by double stringifying the JSON: ```typescript -const searchResult = await protectClient.encrypt('billy@example.com', { - column: users.email, - table: users, -}) - -const searchTerm = `(${JSON.stringify(JSON.stringify(searchResult.data))})` +const searchTerms = await protectClient.createSearchTerms([ + { + value: 'billy@example.com', + column: users.email, + table: users, + returnType: 'composite-literal' + } +]) + +const searchTerm = searchTerms.data[0] ``` -For certain queries, when including the encrypted search term with an operator that uses the string logic syntax, your need to triple stringify the payload. +For certain queries, when including the encrypted search term with an operator that uses the string logic syntax, you need to use the 'escaped-composite-literal' return type: ```typescript -const searchTerm = `${JSON.stringify(`(${JSON.stringify(JSON.stringify(searchResult.data))})`)}` +const searchTerms = await protectClient.createSearchTerms([ + { + value: 'billy@example.com', + column: users.email, + table: users, + returnType: 'escaped-composite-literal' + } +]) + +const searchTerm = searchTerms.data[0] ``` ## Query Examples @@ -35,33 +48,37 @@ Here are examples of different ways to search encrypted data using the Supabase ### Equality Search ```typescript -const searchResult = await protectClient.encrypt('billy@example.com', { - column: users.email, - table: users, -}) - -const searchTerm = `(${JSON.stringify(JSON.stringify(searchResult.data))})` +const searchTerms = await protectClient.createSearchTerms([ + { + value: 'billy@example.com', + column: users.email, + table: users, + returnType: 'composite-literal' + } +]) const { data, error } = await supabase .from('users') .select('id, email::jsonb, name::jsonb') - .eq('email', searchTerm) + .eq('email', searchTerms.data[0]) ``` ### Pattern Matching Search ```typescript -const searchResult = await protectClient.encrypt('example.com', { - column: users.email, - table: users, -}) - -const searchTerm = `(${JSON.stringify(JSON.stringify(searchResult.data))})` +const searchTerms = await protectClient.createSearchTerms([ + { + value: 'example.com', + column: users.email, + table: users, + returnType: 'composite-literal' + } +]) const { data, error } = await supabase .from('users') .select('id, email::jsonb, name::jsonb') - .like('email', searchTerm) + .like('email', searchTerms.data[0]) ``` ### IN Operator Search @@ -70,25 +87,26 @@ When you need to search for multiple encrypted values, you can use the IN operat ```typescript // Encrypt multiple search terms -const searchResult1 = await protectClient.encrypt('value1', { - column: users.name, - table: users, -}) - -const searchResult2 = await protectClient.encrypt('value2', { - column: users.name, - table: users, -}) - -// Format each search term -const searchTerm = `${JSON.stringify(`(${JSON.stringify(JSON.stringify(searchResult.data))})`)}` -const searchTerm2 = `${JSON.stringify(`(${JSON.stringify(JSON.stringify(searchResult2.data))})`)}` +const searchTerms = await protectClient.createSearchTerms([ + { + value: 'value1', + column: users.name, + table: users, + returnType: 'escaped-composite-literal' + }, + { + value: 'value2', + column: users.name, + table: users, + returnType: 'escaped-composite-literal' + } +]) // Combine terms for IN operator const { data, error } = await supabase .from('users') .select('id, email::jsonb, name::jsonb') - .filter('name', 'in', `(${searchTerm1},${searchTerm2})`) + .filter('name', 'in', `(${searchTerms.data[0]},${searchTerms.data[1]})`) ``` ### OR Condition Search @@ -97,27 +115,32 @@ You can combine multiple encrypted search conditions using the `.or()` syntax. T ```typescript // Encrypt search terms for different columns -const emailSearch = await protectClient.encrypt('user@example.com', { - column: users.email, - table: users, -}) - -const nameSearch = await protectClient.encrypt('John', { - column: users.name, - table: users, -}) - -// Format each search term -const emailTerm = `${JSON.stringify(`(${JSON.stringify(JSON.stringify(emailSearch.data))})`)}` -const nameTerm = `${JSON.stringify(`(${JSON.stringify(JSON.stringify(nameSearch.data))})`)}` +const searchTerms = await protectClient.createSearchTerms([ + { + value: 'user@example.com', + column: users.email, + table: users, + returnType: 'escaped-composite-literal' + }, + { + value: 'John', + column: users.name, + table: users, + returnType: 'escaped-composite-literal' + } +]) // Combine conditions with OR const { data, error } = await supabase .from('users') .select('id, email::jsonb, name::jsonb') - .or(`email.ilike.${emailTerm}, name.ilike.${nameTerm}`) + .or(`email.ilike.${searchTerms.data[0]}, name.ilike.${searchTerms.data[1]}`) ``` ## Conclusion -The key is in the string formatting of the encrypted payload: `(${JSON.stringify(JSON.stringify(searchTerm))})`. This ensures the encrypted data is properly formatted for comparison in the database using the EQL custom type. You can use this pattern with any of Supabase's query methods like `.eq()`, `.like()`, `.ilike()`, etc. \ No newline at end of file +The key is in using the appropriate return type for your search terms: +- Use `composite-literal` for simple equality and pattern matching queries +- Use `escaped-composite-literal` when you need to include the search term in string-based operators like IN or OR conditions + +You can use these patterns with any of Supabase's query methods like `.eq()`, `.like()`, `.ilike()`, etc. \ No newline at end of file diff --git a/packages/protect/__tests__/search-terms.test.ts b/packages/protect/__tests__/search-terms.test.ts new file mode 100644 index 000000000..735db68ef --- /dev/null +++ b/packages/protect/__tests__/search-terms.test.ts @@ -0,0 +1,90 @@ +import 'dotenv/config' +import { describe, expect, it } from 'vitest' + +import { protect, csTable, csColumn, type SearchTerm } from '../src' + +const users = csTable('users', { + email: csColumn('email').freeTextSearch().equality().orderAndRange(), + address: csColumn('address').freeTextSearch(), +}) + +describe('create search terms', () => { + it('should create search terms with default return type', async () => { + const protectClient = await protect(users) + + const searchTerms = [ + { + value: 'hello', + column: users.email, + table: users, + }, + { + value: 'world', + column: users.address, + table: users, + }, + ] as SearchTerm[] + + const searchTermsResult = await protectClient.createSearchTerms(searchTerms) + + if (searchTermsResult.failure) { + throw new Error(`[protect]: ${searchTermsResult.failure.message}`) + } + + expect(searchTermsResult.data).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + c: expect.any(String), + }), + ]), + ) + }, 30000) + + it('should create search terms with composite-literal return type', async () => { + const protectClient = await protect(users) + + const searchTerms = [ + { + value: 'hello', + column: users.email, + table: users, + returnType: 'composite-literal', + }, + ] as SearchTerm[] + + const searchTermsResult = await protectClient.createSearchTerms(searchTerms) + + if (searchTermsResult.failure) { + throw new Error(`[protect]: ${searchTermsResult.failure.message}`) + } + + const result = searchTermsResult.data[0] as string + expect(result).toMatch(/^\(.*\)$/) + expect(() => JSON.parse(result.slice(1, -1))).not.toThrow() + }, 30000) + + it('should create search terms with escaped-composite-literal return type', async () => { + const protectClient = await protect(users) + + const searchTerms = [ + { + value: 'hello', + column: users.email, + table: users, + returnType: 'escaped-composite-literal', + }, + ] as SearchTerm[] + + const searchTermsResult = await protectClient.createSearchTerms(searchTerms) + + if (searchTermsResult.failure) { + throw new Error(`[protect]: ${searchTermsResult.failure.message}`) + } + + const result = searchTermsResult.data[0] as string + expect(result).toMatch(/^".*"$/) + const unescaped = JSON.parse(result) + expect(unescaped).toMatch(/^\(.*\)$/) + expect(() => JSON.parse(unescaped.slice(1, -1))).not.toThrow() + }, 30000) +}) diff --git a/packages/protect/src/ffi/index.ts b/packages/protect/src/ffi/index.ts index 736d31c9a..46fa858b8 100644 --- a/packages/protect/src/ffi/index.ts +++ b/packages/protect/src/ffi/index.ts @@ -7,8 +7,10 @@ import type { Client, Decrypted, EncryptedPayload, + EncryptedSearchTerm, EncryptOptions, EncryptPayload, + SearchTerm, } from '../types' import { EncryptModelOperation } from './operations/encrypt-model' import { DecryptModelOperation } from './operations/decrypt-model' @@ -16,6 +18,7 @@ import { BulkEncryptModelsOperation } from './operations/bulk-encrypt-models' import { BulkDecryptModelsOperation } from './operations/bulk-decrypt-models' import { EncryptOperation } from './operations/encrypt' import { DecryptOperation } from './operations/decrypt' +import { SearchTermsOperation } from './operations/search-terms' import { type EncryptConfig, encryptConfigSchema, @@ -136,6 +139,18 @@ export class ProtectClient { return new BulkDecryptModelsOperation(this.client, input) } + /** + * Create search terms to use in a query searching encrypted data + * Usage: + * await eqlClient.createSearchTerms(searchTerms) + * await eqlClient.createSearchTerms(searchTerms).withLockContext(lockContext) + */ + createSearchTerms( + terms: SearchTerm[], + ): Promise> { + return new SearchTermsOperation(this.client, terms).execute() + } + /** e.g., debugging or environment info */ clientInfo() { return { diff --git a/packages/protect/src/ffi/operations/search-terms.ts b/packages/protect/src/ffi/operations/search-terms.ts new file mode 100644 index 000000000..3f0fe7446 --- /dev/null +++ b/packages/protect/src/ffi/operations/search-terms.ts @@ -0,0 +1,55 @@ +import { encryptBulk } from '@cipherstash/protect-ffi' +import { withResult, type Result } from '@byteslice/result' +import { noClientError } from '../index' +import { type ProtectError, ProtectErrorTypes } from '../..' +import { logger } from '../../../../utils/logger' +import type { Client, EncryptedSearchTerm, SearchTerm } from '../../types' + +export class SearchTermsOperation { + private client: Client + private terms: SearchTerm[] + + constructor(client: Client, terms: SearchTerm[]) { + this.client = client + this.terms = terms + } + + public async execute(): Promise> { + logger.debug('Creating search terms', { + terms: this.terms, + }) + + return await withResult( + async () => { + if (!this.client) { + throw noClientError() + } + + const encryptedSearchTerms = await encryptBulk( + this.client, + this.terms.map((term) => ({ + plaintext: term.value, + column: term.column.getName(), + table: term.table.tableName, + })), + ) + + return this.terms.map((term, index) => { + if (term.returnType === 'composite-literal') { + return `(${JSON.stringify(JSON.stringify(encryptedSearchTerms[index]))})` + } + + if (term.returnType === 'escaped-composite-literal') { + return `${JSON.stringify(`(${JSON.stringify(JSON.stringify(encryptedSearchTerms[index]))})`)}` + } + + return encryptedSearchTerms[index] + }) + }, + (error) => ({ + type: ProtectErrorTypes.EncryptionError, + message: error.message, + }), + ) + } +} diff --git a/packages/protect/src/types.ts b/packages/protect/src/types.ts index 41435bdfe..2c43c3dc5 100644 --- a/packages/protect/src/types.ts +++ b/packages/protect/src/types.ts @@ -13,6 +13,24 @@ export type Client = Awaited> | undefined */ export type EncryptedPayload = Encrypted | null +/** + * Represents a value that will be encrypted and used in a search + */ +export type SearchTerm = { + value: string + column: ProtectColumn + table: ProtectTable + returnType?: 'eql' | 'composite-literal' | 'escaped-composite-literal' +} + +/** + * The return type of the search term based on the return type specified in the `SearchTerm` type + * If the return type is `eql`, the return type is `EncryptedPayload` + * If the return type is `composite-literal`, the return type is `string` where the value is a composite literal + * If the return type is `escaped-composite-literal`, the return type is `string` where the value is an escaped composite literal + */ +export type EncryptedSearchTerm = EncryptedPayload | string + /** * Represents a payload to be encrypted using the `encrypt` function * We currently only support the encryption of strings