diff --git a/src/errors.ts b/src/errors.ts index d88bed96..c0c8ff6c 100644 --- a/src/errors.ts +++ b/src/errors.ts @@ -46,6 +46,30 @@ export const E_MODEL_DELETED = createError( 500 ) +export const E_MISSING_MODEL_ENCRYPTION = createError<[string]>( + 'Cannot use encrypted column "%s" without a configured encryption provider. Call "BaseModel.useEncryption()" before querying or persisting encrypted columns', + 'E_MISSING_MODEL_ENCRYPTION', + 500 +) + +export const E_INVALID_ENCRYPTED_COLUMN_CONFIGURATION = createError<[string, string]>( + 'Invalid encrypted column configuration for "%s". %s', + 'E_INVALID_ENCRYPTED_COLUMN_CONFIGURATION', + 500 +) + +export const E_UNSUPPORTED_ENCRYPTED_COLUMN_QUERY = createError<[string, string]>( + 'Cannot use "%s" on encrypted column "%s". Encrypted columns can only be used with equality-based WHERE clauses', + 'E_UNSUPPORTED_ENCRYPTED_COLUMN_QUERY', + 500 +) + +export const E_UNSUPPORTED_STANDARD_ENCRYPTED_COLUMN_QUERY = createError<[string, string]>( + 'Cannot use "%s" on standard encrypted column "%s". Equality queries require deterministic or blind encryption', + 'E_UNSUPPORTED_STANDARD_ENCRYPTED_COLUMN_QUERY', + 500 +) + /** * The "E_ROW_NOT_FOUND" exception is raised when * no row is found in a database single query diff --git a/src/orm/base_model/index.ts b/src/orm/base_model/index.ts index 2461fdbc..14417cf9 100644 --- a/src/orm/base_model/index.ts +++ b/src/orm/base_model/index.ts @@ -35,6 +35,9 @@ import { type ModelKeysContract, type ModelAssignOptions, type ModelAdapterOptions, + type EncryptedColumnMode, + type ModelEncryptionConfig, + type ModelEncryptionContract, type ModelRelationOptions, type ModelQueryBuilderContract, type ModelPaginatorContract, @@ -112,6 +115,11 @@ class BaseModelImpl implements LucidRow { */ static $adapter: AdapterContract + /** + * Encryption provider used by encrypted columns. + */ + static $encryption?: ModelEncryptionConfig + /** * Define an adapter to use for interacting with * the database @@ -120,6 +128,53 @@ class BaseModelImpl implements LucidRow { this.$adapter = adapter } + /** + * Define encryption provider to use for encrypted columns. + */ + static useEncryption(config: ModelEncryptionConfig) { + this.$encryption = config + } + + /** + * Returns encryption provider and raises when missing. + */ + static $getEncryption(attributeName?: string): ModelEncryptionContract { + this.boot() + + if (this.$encryption?.provider) { + return this.$encryption.provider + } + + const dottedAttribute = attributeName ? `${this.name}.${attributeName}` : this.name + throw new errors.E_MISSING_MODEL_ENCRYPTION([dottedAttribute]) + } + + /** + * Returns encryption provider and driver for a given encrypted column mode. + */ + static $resolveEncryption( + attributeName: string | undefined, + mode: EncryptedColumnMode, + columnDriver?: string + ): { + provider: ModelEncryptionContract + driver?: string + } { + const provider = this.$getEncryption(attributeName) + const defaults = this.$encryption?.defaults + const defaultDriver = + mode === 'deterministic' + ? defaults?.deterministicDriver + : mode === 'blind' + ? defaults?.blindDriver + : defaults?.standardDriver + + return { + provider, + driver: columnDriver ?? defaultDriver, + } + } + /** * Naming strategy for model properties */ @@ -1354,18 +1409,72 @@ class BaseModelImpl implements LucidRow { */ protected prepareForAdapter(attributes: ModelObject) { const Model = this.constructor as typeof BaseModel - - return Object.keys(attributes).reduce((result: any, key) => { + const blindWrites: Array<{ + key: string + value: any + purpose: string + blindColumnName: string + driver?: string + }> = [] + const result = Object.keys(attributes).reduce((acc: any, key) => { const column = Model.$getColumn(key)! + const attributeValue = attributes[key] - const value = + acc[column.columnName] = typeof column.prepare === 'function' - ? column.prepare(attributes[key], key, this) - : attributes[key] + ? column.prepare(attributeValue, key, this) + : attributeValue - result[column.columnName] = value - return result + const encryption = column.meta?.encryption + if (encryption?.mode !== 'blind') { + return acc + } + + const dottedAttribute = `${Model.name}.${key}` + const blindColumnName = encryption.blindColumnName?.trim() + if (!blindColumnName) { + throw new errors.E_INVALID_ENCRYPTED_COLUMN_CONFIGURATION([ + dottedAttribute, + 'Missing "blind.columnName"', + ]) + } + + const purpose = encryption.purpose?.trim() + if (!purpose) { + throw new errors.E_INVALID_ENCRYPTED_COLUMN_CONFIGURATION([ + dottedAttribute, + 'Missing "blind.purpose"', + ]) + } + + blindWrites.push({ + key, + value: attributeValue, + purpose, + blindColumnName, + driver: encryption.driver, + }) + + return acc }, {}) + + /** + * Apply blind writes after preparing base columns so computed blind indexes + * always win over any manually assigned blind column value. + */ + blindWrites.forEach(({ key, value, purpose, blindColumnName, driver }) => { + const encryption = Model.$resolveEncryption(key, 'blind', driver) + + result[blindColumnName] = + value === null || value === undefined + ? value + : encryption.provider.blindIndex(value, { + purpose, + driver: encryption.driver, + }) + }) + + return result } /** diff --git a/src/orm/decorators/encrypted.ts b/src/orm/decorators/encrypted.ts new file mode 100644 index 00000000..98d70101 --- /dev/null +++ b/src/orm/decorators/encrypted.ts @@ -0,0 +1,143 @@ +/* + * @adonisjs/lucid + * + * (c) AdonisJS + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +import * as errors from '../../errors.js' +import { + type LucidRow, + type LucidModel, + type EncryptedColumnMeta, + type EncryptedColumnOptions, + type EncryptedColumnDecorator, +} from '../../types/model.js' + +function defineEncryptedMeta( + model: LucidModel, + property: string, + options?: EncryptedColumnOptions +): EncryptedColumnMeta { + const dottedAttribute = `${model.name}.${property}` + const driver = options?.driver + + if (options?.deterministic && options?.blind) { + throw new errors.E_INVALID_ENCRYPTED_COLUMN_CONFIGURATION([ + dottedAttribute, + 'The "deterministic" and "blind" options cannot be used together', + ]) + } + + if (options?.blind) { + const blindColumnName = options.blind.columnName?.trim() + const purpose = options.blind.purpose?.trim() + + if (!blindColumnName) { + throw new errors.E_INVALID_ENCRYPTED_COLUMN_CONFIGURATION([ + dottedAttribute, + 'Missing "blind.columnName"', + ]) + } + + if (!purpose) { + throw new errors.E_INVALID_ENCRYPTED_COLUMN_CONFIGURATION([ + dottedAttribute, + 'Missing "blind.purpose"', + ]) + } + + return { + mode: 'blind', + driver, + blindColumnName, + purpose, + } + } + + if (options?.deterministic) { + return { + mode: 'deterministic', + driver, + } + } + + return { + mode: 'standard', + driver, + } +} + +/** + * Decorator to define an encrypted column + */ +export const encryptedColumn: EncryptedColumnDecorator = (options?) => { + return function decorateAsEncryptedColumn(target, property) { + const Model = target.constructor as LucidModel + Model.boot() + + const { deterministic, blind, driver, ...columnOptions } = options || {} + + const encryptionMeta = defineEncryptedMeta(Model, property, { + deterministic, + blind, + driver, + }) + const meta = Object.assign({}, columnOptions.meta, { encryption: encryptionMeta }) + + Model.$addColumn(property, { + ...columnOptions, + meta, + prepare(value: any, attributeName: string, modelInstance: LucidRow) { + if (value === null || value === undefined) { + return value + } + + const model = modelInstance.constructor as LucidModel + if (encryptionMeta.mode === 'deterministic') { + const encryption = model.$resolveEncryption( + attributeName, + 'deterministic', + encryptionMeta.driver + ) + + return encryption.driver + ? encryption.provider.encrypt(value, { + deterministic: true, + driver: encryption.driver, + }) + : encryption.provider.encrypt(value, { + deterministic: true, + }) + } + + const encryption = model.$resolveEncryption( + attributeName, + encryptionMeta.mode, + encryptionMeta.driver + ) + + return encryption.driver + ? encryption.provider.encrypt(value, { driver: encryption.driver }) + : encryption.provider.encrypt(value) + }, + consume(value: any, attributeName: string, modelInstance: LucidRow) { + if (value === null || value === undefined) { + return value + } + + const encryption = (modelInstance.constructor as LucidModel).$resolveEncryption( + attributeName, + encryptionMeta.mode, + encryptionMeta.driver + ) + + return encryption.driver + ? encryption.provider.decrypt(value, { driver: encryption.driver }) + : encryption.provider.decrypt(value) + }, + }) + } +} diff --git a/src/orm/decorators/index.ts b/src/orm/decorators/index.ts index 8755a96a..2b09700d 100644 --- a/src/orm/decorators/index.ts +++ b/src/orm/decorators/index.ts @@ -11,6 +11,7 @@ import { type LucidModel, type HooksDecorator, type ColumnDecorator, + type EncryptedColumnDecorator, type ComputedDecorator, type DateColumnDecorator, type DateTimeColumnDecorator, @@ -26,6 +27,7 @@ import { import { dateColumn } from './date.js' import { dateTimeColumn } from './date_time.js' +import { encryptedColumn } from './encrypted.js' /** * Define property on a model as a column. The decorator needs a @@ -34,6 +36,7 @@ import { dateTimeColumn } from './date_time.js' export const column: ColumnDecorator & { date: DateColumnDecorator dateTime: DateTimeColumnDecorator + encrypted: EncryptedColumnDecorator } = (options?) => { return function decorateAsColumn(target, property) { const Model = target.constructor as LucidModel @@ -44,6 +47,7 @@ export const column: ColumnDecorator & { column.date = dateColumn column.dateTime = dateTimeColumn +column.encrypted = encryptedColumn /** * Define computed property on a model. The decorator needs a diff --git a/src/orm/main.ts b/src/orm/main.ts index f740ff86..4a5fcce3 100644 --- a/src/orm/main.ts +++ b/src/orm/main.ts @@ -10,6 +10,7 @@ export * from './decorators/index.js' export * from './decorators/date.js' export * from './decorators/date_time.js' +export * from './decorators/encrypted.js' export { BaseModel, scope } from './base_model/index.js' export { ModelQueryBuilder } from './query_builder/index.js' export { ModelPaginator } from './paginator/index.js' diff --git a/src/orm/query_builder/encrypted_query_support.ts b/src/orm/query_builder/encrypted_query_support.ts new file mode 100644 index 00000000..f28b0668 --- /dev/null +++ b/src/orm/query_builder/encrypted_query_support.ts @@ -0,0 +1,600 @@ +/* + * @adonisjs/lucid + * + * (c) Harminder Virk + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +import { + type LucidModel, + type EncryptedColumnMeta, + type ModelColumnOptions, +} from '../../types/model.js' +import { type Dictionary } from '../../types/querybuilder.js' +import { isObject } from '../../utils/index.js' +import { Chainable } from '../../database/query_builder/chainable.js' +import * as errors from '../../errors.js' + +type EncryptedQueryColumn = { + key: string + attributeName: string + columnName: string + encryption: EncryptedColumnMeta +} + +type EncryptedWhereArgs = [any] | [any, any] | [any, any, any] + +export type EncryptedWhereMethod = 'where' | 'orWhere' | 'whereNot' | 'orWhereNot' +export type EncryptedWhereInMethod = 'whereIn' | 'orWhereIn' | 'whereNotIn' | 'orWhereNotIn' + +export type EncryptedWhereRewrite = + | { + target: 'where' + method: EncryptedWhereMethod + args: EncryptedWhereArgs + } + | { + target: 'whereIn' + method: EncryptedWhereInMethod + columns: any + value: any + } + +export type EncryptedWhereInRewrite = { + method: EncryptedWhereInMethod + columns: any + value: any +} + +/** + * Encapsulates encrypted-column query rewriting and write payload preparation. + */ +export class EncryptedQuerySupport { + readonly #model: LucidModel + readonly #getTableAlias: () => string | undefined + + constructor(model: LucidModel, getTableAlias: () => string | undefined = () => undefined) { + this.#model = model + this.#getTableAlias = getTableAlias + } + + rewriteWhereTernary( + method: EncryptedWhereMethod, + key: string, + operator: any, + value: any + ): EncryptedWhereRewrite { + const column = this.#getEncryptedQueryColumn(key, true) + this.#ensureStandardEncryptedQuerySupport(column, method) + + const inOperatorRewrite = this.#rewriteInOperator(method, key, operator, value) + if (inOperatorRewrite) { + return { + target: 'whereIn', + ...inOperatorRewrite, + } + } + + this.#ensureEncryptedEqualityOperator(column, operator, method) + + if (column?.encryption.mode === 'blind') { + const encryptedValues = this.#getEncryptedQueryValues(column, value) + const encryptedKey = this.#getEncryptedQueryKey(column) + const whereInMethod = this.#getEncryptedWhereInMethod(method) + + if (encryptedValues.length > 1) { + return { + target: 'whereIn', + method: whereInMethod, + columns: encryptedKey, + value: encryptedValues, + } + } + + return this.#toWhereCall(method, encryptedKey, operator, encryptedValues[0]) + } + + return this.#toWhereCall( + method, + column ? this.#getEncryptedQueryKey(column) : key, + operator, + column ? this.#getEncryptedQueryValue(column, value) : value + ) + } + + rewriteWhereBinary(method: EncryptedWhereMethod, key: string, value: any): EncryptedWhereRewrite { + const column = this.#getEncryptedQueryColumn(key, true) + this.#ensureStandardEncryptedQuerySupport(column, method) + + if (column?.encryption.mode === 'blind') { + const encryptedValues = this.#getEncryptedQueryValues(column, value) + const encryptedKey = this.#getEncryptedQueryKey(column) + const whereInMethod = this.#getEncryptedWhereInMethod(method) + + if (encryptedValues.length > 1) { + return { + target: 'whereIn', + method: whereInMethod, + columns: encryptedKey, + value: encryptedValues, + } + } + + return this.#toWhereCall(method, encryptedKey, encryptedValues[0]) + } + + return this.#toWhereCall( + method, + column ? this.#getEncryptedQueryKey(column) : key, + column ? this.#getEncryptedQueryValue(column, value) : value + ) + } + + rewriteWhereIn( + method: EncryptedWhereInMethod, + columns: any, + value: any + ): EncryptedWhereInRewrite { + if (typeof columns !== 'string') { + return { + method, + columns, + value, + } + } + + const column = this.#getEncryptedQueryColumn(columns, true) + this.#ensureStandardEncryptedQuerySupport(column, method) + + if (!column) { + return { + method, + columns, + value, + } + } + + const transformedValue = Array.isArray(value) + ? value.flatMap((item) => this.#getEncryptedQueryValues(column, item)) + : this.#isQueryBuilderValue(value) + ? value + : this.#getEncryptedQueryValues(column, value) + + return { + method, + columns: this.#getEncryptedQueryKey(column), + value: transformedValue, + } + } + + ensureMethodSupport(key: any, method: string) { + const column = this.#getEncryptedQueryColumn(key, true) + if (column) { + this.#raiseUnsupportedEncryptedColumn(method, column) + } + } + + ensureColumnComparisonSupport(method: string, column: any, comparisonColumn: any) { + this.ensureMethodSupport(column, method) + this.ensureMethodSupport(comparisonColumn, method) + } + + ensureArithmeticSupport(key: any, method: string) { + const keys = typeof key === 'string' ? [key] : isObject(key) ? Object.keys(key) : [] + + for (const columnKey of keys) { + this.ensureMethodSupport(columnKey, method) + } + } + + prepareUpdateValues( + values: Dictionary, + resolveKey: (key: string) => string, + transformRaw: (value: any) => any + ): Dictionary { + const result: Dictionary = {} + const blindWrites: Array<{ value: any; column: EncryptedQueryColumn }> = [] + + for (const [key, value] of Object.entries(values)) { + const column = this.#getEncryptedQueryColumn(key, true) + if (!column) { + result[resolveKey(key)] = transformRaw(value) + continue + } + + result[resolveKey(key)] = transformRaw(this.#getEncryptedWriteValue(column, value)) + + if (column.encryption.mode === 'blind') { + blindWrites.push({ value, column }) + } + } + + /** + * Apply blind writes after processing the original payload so computed indexes + * always win over any manually provided blind column value, regardless of key order. + */ + for (const { value, column } of blindWrites) { + const blindResult = this.#getBlindWriteValue(column, value) + if (blindResult.shouldWrite) { + result[resolveKey(this.#getEncryptedQueryKey(column))] = transformRaw(blindResult.value) + } + } + + return result + } + + #toWhereCall( + method: EncryptedWhereMethod, + key: any, + operator?: any, + value?: any + ): EncryptedWhereRewrite { + if (value !== undefined) { + return { + target: 'where', + method, + args: [key, operator, value], + } + } + + if (operator !== undefined) { + return { + target: 'where', + method, + args: [key, operator], + } + } + + return { + target: 'where', + method, + args: [key], + } + } + + #rewriteInOperator( + method: EncryptedWhereMethod, + key: string, + operator: any, + value: any + ): EncryptedWhereInRewrite | null { + const column = this.#getEncryptedQueryColumn(key) + if (!column) { + return null + } + + const normalizedOperator = this.#normalizeOperator(operator) + if (!this.#isInOperator(normalizedOperator) && !this.#isNotInOperator(normalizedOperator)) { + return null + } + + const whereInMethod = this.#getEncryptedWhereInMethod(method) + const targetMethod = this.#isInOperator(normalizedOperator) + ? whereInMethod + : this.#getOppositeEncryptedWhereInMethod(whereInMethod) + + return this.rewriteWhereIn(targetMethod, key, value) + } + + #getEncryptedWhereInMethod(method: EncryptedWhereMethod): EncryptedWhereInMethod { + switch (method) { + case 'where': + return 'whereIn' + case 'orWhere': + return 'orWhereIn' + case 'whereNot': + return 'whereNotIn' + case 'orWhereNot': + return 'orWhereNotIn' + } + } + + #getOppositeEncryptedWhereInMethod(method: EncryptedWhereInMethod): EncryptedWhereInMethod { + switch (method) { + case 'whereIn': + return 'whereNotIn' + case 'orWhereIn': + return 'orWhereNotIn' + case 'whereNotIn': + return 'whereIn' + case 'orWhereNotIn': + return 'orWhereIn' + } + } + + #getEncryptedQueryColumn( + key: any, + includeStandard: boolean = false + ): EncryptedQueryColumn | null { + if (typeof key !== 'string') { + return null + } + + const lastDot = key.lastIndexOf('.') + const normalizedKey = lastDot >= 0 ? key.slice(lastDot + 1) : key + + if (lastDot >= 0) { + const source = key.slice(0, lastDot) + if (!this.#isModelColumnSource(source)) { + return null + } + } + + const attributeName = + this.#model.$keys.columnsToAttributes.get(normalizedKey) ?? + this.#model.$keys.columnsToAttributes.get(key) ?? + normalizedKey + + if (!this.#model.$hasColumn(attributeName)) { + return null + } + + const column = this.#model.$getColumn(attributeName) as ModelColumnOptions + if (!this.#isModelColumnReference(normalizedKey, attributeName, column.columnName)) { + return null + } + + const encryption = column.meta?.encryption as EncryptedColumnMeta | undefined + if (!encryption || (!includeStandard && encryption.mode === 'standard')) { + return null + } + + return { + key, + attributeName, + columnName: column.columnName, + encryption, + } + } + + #isModelColumnSource(source: string): boolean { + if (source === this.#model.table || source.endsWith(`.${this.#model.table}`)) { + return true + } + + const tableAlias = this.#getTableAlias() + if (tableAlias && (source === tableAlias || source.endsWith(`.${tableAlias}`))) { + return true + } + + return false + } + + #isModelColumnReference(column: string, attributeName: string, columnName: string): boolean { + return column === attributeName || column === columnName + } + + #isQueryBuilderValue(value: any): boolean { + if (value instanceof Chainable || typeof value === 'function') { + return true + } + + return !!value && typeof value === 'object' && ('knexQuery' in value || 'toKnex' in value) + } + + #getEncryptedQueryKey(column: EncryptedQueryColumn): string { + if (column.encryption.mode !== 'blind') { + return column.key + } + + const blindColumnName = column.encryption.blindColumnName + if (!blindColumnName) { + throw new errors.E_INVALID_ENCRYPTED_COLUMN_CONFIGURATION([ + `${this.#model.name}.${column.attributeName}`, + 'Missing "blind.columnName"', + ]) + } + + if (column.key === column.attributeName || column.key === column.columnName) { + return blindColumnName + } + + if ( + column.key.endsWith(`.${column.attributeName}`) || + column.key.endsWith(`.${column.columnName}`) + ) { + const lastDot = column.key.lastIndexOf('.') + return `${column.key.slice(0, lastDot + 1)}${blindColumnName}` + } + + return blindColumnName + } + + #normalizeBlindIndexValues(indexes: any): any[] { + if (!indexes) { + return [] + } + + if (Array.isArray(indexes)) { + return indexes.filter((item) => item !== null && item !== undefined) + } + + if (isObject(indexes)) { + return Object.values(indexes).filter((item) => item !== null && item !== undefined) + } + + return [indexes] + } + + #getEncryptedQueryValues(column: EncryptedQueryColumn, value: any): any[] { + if (value === null || value === undefined || this.#isQueryBuilderValue(value)) { + return [value] + } + + if (column.encryption.mode === 'deterministic') { + const encryption = this.#model.$resolveEncryption( + column.attributeName, + 'deterministic', + column.encryption.driver + ) + + return [ + encryption.driver + ? encryption.provider.encrypt(value, { + deterministic: true, + driver: encryption.driver, + }) + : encryption.provider.encrypt(value, { + deterministic: true, + }), + ] + } + + if (!column.encryption.purpose) { + throw new errors.E_INVALID_ENCRYPTED_COLUMN_CONFIGURATION([ + `${this.#model.name}.${column.attributeName}`, + 'Missing "blind.purpose"', + ]) + } + + const encryption = this.#model.$resolveEncryption( + column.attributeName, + 'blind', + column.encryption.driver + ) + + const blindIndexes = this.#normalizeBlindIndexValues( + encryption.provider.blindIndexes(value, { + purpose: column.encryption.purpose, + driver: encryption.driver, + }) + ) + + if (blindIndexes.length) { + return blindIndexes + } + + return [ + encryption.provider.blindIndex(value, { + purpose: column.encryption.purpose, + driver: encryption.driver, + }), + ] + } + + #getEncryptedQueryValue(column: EncryptedQueryColumn, value: any): any { + return this.#getEncryptedQueryValues(column, value)[0] + } + + #normalizeOperator(operator: any): string { + return typeof operator === 'string' ? operator.trim().replace(/\s+/g, ' ').toLowerCase() : '' + } + + #isInOperator(operator: string): boolean { + return operator === 'in' + } + + #isNotInOperator(operator: string): boolean { + return operator === 'not in' + } + + #ensureEncryptedEqualityOperator( + column: EncryptedQueryColumn | null, + operator: any, + method: string + ) { + if (!column) { + return + } + + const normalizedOperator = this.#normalizeOperator(operator) + if ( + normalizedOperator !== '=' && + !this.#isInOperator(normalizedOperator) && + !this.#isNotInOperator(normalizedOperator) + ) { + this.#raiseUnsupportedEncryptedColumn(method, column) + } + } + + #ensureStandardEncryptedQuerySupport(column: EncryptedQueryColumn | null, method: string) { + if (column?.encryption.mode === 'standard') { + throw new errors.E_UNSUPPORTED_STANDARD_ENCRYPTED_COLUMN_QUERY([ + method, + `${this.#model.name}.${column.attributeName}`, + ]) + } + } + + #raiseUnsupportedEncryptedColumn(method: string, column: EncryptedQueryColumn) { + throw new errors.E_UNSUPPORTED_ENCRYPTED_COLUMN_QUERY([ + method, + `${this.#model.name}.${column.attributeName}`, + ]) + } + + #getEncryptedWriteValue(column: EncryptedQueryColumn, value: any): any { + if (value === null || value === undefined || this.#isQueryBuilderValue(value)) { + return value + } + + if (column.encryption.mode === 'deterministic') { + const encryption = this.#model.$resolveEncryption( + column.attributeName, + 'deterministic', + column.encryption.driver + ) + + return encryption.driver + ? encryption.provider.encrypt(value, { + deterministic: true, + driver: encryption.driver, + }) + : encryption.provider.encrypt(value, { + deterministic: true, + }) + } + + const encryption = this.#model.$resolveEncryption( + column.attributeName, + column.encryption.mode, + column.encryption.driver + ) + + return encryption.driver + ? encryption.provider.encrypt(value, { + driver: encryption.driver, + }) + : encryption.provider.encrypt(value) + } + + #getBlindWriteValue( + column: EncryptedQueryColumn, + value: any + ): { + shouldWrite: boolean + value: any + } { + if (value === null || value === undefined) { + return { shouldWrite: true, value } + } + + if (this.#isQueryBuilderValue(value)) { + return { shouldWrite: false, value: null } + } + + const purpose = column.encryption.purpose + if (!purpose) { + throw new errors.E_INVALID_ENCRYPTED_COLUMN_CONFIGURATION([ + `${this.#model.name}.${column.attributeName}`, + 'Missing "blind.purpose"', + ]) + } + + const encryption = this.#model.$resolveEncryption( + column.attributeName, + 'blind', + column.encryption.driver + ) + + return { + shouldWrite: true, + value: encryption.provider.blindIndex(value, { + purpose, + driver: encryption.driver, + }), + } + } +} diff --git a/src/orm/query_builder/index.ts b/src/orm/query_builder/index.ts index da02a8c0..e636e1fe 100644 --- a/src/orm/query_builder/index.ts +++ b/src/orm/query_builder/index.ts @@ -39,6 +39,12 @@ import { QueryRunner } from '../../query_runner/index.js' import { Chainable } from '../../database/query_builder/chainable.js' import { SimplePaginator } from '../../database/paginator/simple_paginator.js' import * as errors from '../../errors.js' +import { + EncryptedQuerySupport, + type EncryptedWhereMethod, + type EncryptedWhereInMethod, + type EncryptedWhereRewrite, +} from './encrypted_query_support.js' /** * A wrapper to invoke scope methods on the query builder @@ -129,6 +135,11 @@ export class ModelQueryBuilder */ isChildQuery = false + /** + * Support class responsible for encrypted query rewrites and guards. + */ + private encryptedSupport: EncryptedQuerySupport + /** * Side-loaded attributes that will be passed to the model instances */ @@ -155,6 +166,7 @@ export class ModelQueryBuilder this.preloader = new Preloader(this.model) this.debugQueries = this.client.debug + this.encryptedSupport = new EncryptedQuerySupport(this.model, () => this.tableAlias) this.clientOptions = { client: this.client, connection: this.client.connectionName, @@ -168,6 +180,423 @@ export class ModelQueryBuilder } } + /** + * Calls the matching super where* method. + */ + private callSuperWhere( + method: EncryptedWhereMethod, + key: any, + operator?: any, + value?: any + ): this { + if (value !== undefined) { + switch (method) { + case 'where': + return super.where(key, operator, value) + case 'orWhere': + return super.orWhere(key, operator, value) + case 'whereNot': + return super.whereNot(key, operator, value) + case 'orWhereNot': + return super.orWhereNot(key, operator, value) + } + } + + if (operator !== undefined) { + switch (method) { + case 'where': + return super.where(key, operator) + case 'orWhere': + return super.orWhere(key, operator) + case 'whereNot': + return super.whereNot(key, operator) + case 'orWhereNot': + return super.orWhereNot(key, operator) + } + } + + switch (method) { + case 'where': + return super.where(key) + case 'orWhere': + return super.orWhere(key) + case 'whereNot': + return super.whereNot(key) + case 'orWhereNot': + return super.orWhereNot(key) + } + } + + /** + * Calls the matching super where*In method. + */ + private callSuperWhereIn(method: EncryptedWhereInMethod, columns: any, value: any): this { + switch (method) { + case 'whereIn': + return super.whereIn(columns, value) + case 'orWhereIn': + return super.orWhereIn(columns, value) + case 'whereNotIn': + return super.whereNotIn(columns, value) + case 'orWhereNotIn': + return super.orWhereNotIn(columns, value) + } + } + + /** + * Applies encrypted where rewrite instructions to the matching super method. + */ + private applyWhereRewrite(rewrite: EncryptedWhereRewrite): this { + if (rewrite.target === 'whereIn') { + return this.callSuperWhereIn(rewrite.method, rewrite.columns, rewrite.value) + } + + if (rewrite.args.length === 1) { + return this.callSuperWhere(rewrite.method, rewrite.args[0]) + } + + if (rewrite.args.length === 2) { + return this.callSuperWhere(rewrite.method, rewrite.args[0], rewrite.args[1]) + } + + return this.callSuperWhere(rewrite.method, rewrite.args[0], rewrite.args[1], rewrite.args[2]) + } + + /** + * Shared implementation for where/orWhere/whereNot/orWhereNot. + */ + private encryptedWhere( + method: EncryptedWhereMethod, + key: any, + operator?: any, + value?: any + ): this { + if (typeof key === 'string') { + if (value !== undefined) { + return this.applyWhereRewrite( + this.encryptedSupport.rewriteWhereTernary(method, key, operator, value) + ) + } + + if (operator !== undefined) { + return this.applyWhereRewrite( + this.encryptedSupport.rewriteWhereBinary(method, key, operator) + ) + } + } + + if (!isObject(key)) { + return this.callSuperWhere(method, key, operator, value) + } + + const clauses = Object.entries(key) + if (!clauses.length) { + return method === 'where' ? this.callSuperWhere(method, key) : this + } + + if (method === 'orWhere') { + return this.callSuperWhere(method, (query: ModelQueryBuilder) => { + clauses.forEach(([clauseKey, clauseValue]) => { + query.where(clauseKey, clauseValue) + }) + }) + } + + clauses.forEach(([clauseKey, clauseValue]) => { + this.encryptedWhere(method, clauseKey, clauseValue) + }) + + return this + } + + /** + * Shared implementation for whereIn/orWhereIn/whereNotIn/orWhereNotIn. + */ + private encryptedWhereIn(method: EncryptedWhereInMethod, columns: any, value: any): this { + const rewrite = this.encryptedSupport.rewriteWhereIn(method, columns, value) + return this.callSuperWhereIn(rewrite.method, rewrite.columns, rewrite.value) + } + + /** + * Raises for unsupported encrypted column operators. + */ + private ensureEncryptedMethodSupport(key: any, method: string) { + this.encryptedSupport.ensureMethodSupport(key, method) + } + + /** + * Extract string columns from orderBy inputs. + */ + private getOrderByColumns(column: any): string[] { + if (typeof column === 'string') { + return [column] + } + + if (!Array.isArray(column)) { + if (column && typeof column === 'object' && typeof column.column === 'string') { + return [column.column] + } + + return [] + } + + return column.flatMap((item) => { + if (typeof item === 'string') { + return [item] + } + + if (item && typeof item === 'object' && typeof item.column === 'string') { + return [item.column] + } + + return [] + }) + } + + /** + * Extract string columns from groupBy inputs. + */ + private getGroupByColumns(columns: any[]): string[] { + return columns.flatMap((item) => { + if (typeof item === 'string') { + return [item] + } + + if (Array.isArray(item)) { + return item.filter((entry) => typeof entry === 'string') + } + + return [] + }) + } + + /** + * Raises for unsupported arithmetic operations on encrypted columns. + */ + private ensureEncryptedArithmeticSupport(key: any, method: string) { + this.encryptedSupport.ensureArithmeticSupport(key, method) + } + + /** + * Raises for unsupported column-vs-column comparisons on encrypted columns. + */ + private ensureEncryptedColumnComparisonSupport( + method: string, + column: any, + comparisonColumn: any + ) { + this.encryptedSupport.ensureColumnComparisonSupport(method, column, comparisonColumn) + } + + /** + * Prepares update payload by applying encrypted column transforms. + */ + private prepareUpdateValues(values: Dictionary): Dictionary { + return this.encryptedSupport.prepareUpdateValues( + values, + (key) => this.resolveKey(key), + (rawValue) => this.transformRaw(rawValue) + ) + } + + where(key: any, operator?: any, value?: any): this { + return this.encryptedWhere('where', key, operator, value) + } + + orWhere(key: any, operator?: any, value?: any): this { + return this.encryptedWhere('orWhere', key, operator, value) + } + + whereNot(key: any, operator?: any, value?: any): this { + return this.encryptedWhere('whereNot', key, operator, value) + } + + orWhereNot(key: any, operator?: any, value?: any): this { + return this.encryptedWhere('orWhereNot', key, operator, value) + } + + whereIn(columns: any, value: any): this { + return this.encryptedWhereIn('whereIn', columns, value) + } + + orWhereIn(columns: any, value: any): this { + return this.encryptedWhereIn('orWhereIn', columns, value) + } + + whereNotIn(columns: any, value: any): this { + return this.encryptedWhereIn('whereNotIn', columns, value) + } + + orWhereNotIn(columns: any, value: any): this { + return this.encryptedWhereIn('orWhereNotIn', columns, value) + } + + whereLike(key: any, value: any): this { + this.ensureEncryptedMethodSupport(key, 'whereLike') + return super.whereLike(key, value) + } + + groupBy(...columns: any[]): this { + this.getGroupByColumns(columns).forEach((column) => { + this.ensureEncryptedMethodSupport(column, 'groupBy') + }) + + return super.groupBy(...columns) + } + + orderBy(column: any, direction?: any): this { + this.getOrderByColumns(column).forEach((item) => { + this.ensureEncryptedMethodSupport(item, 'orderBy') + }) + + return super.orderBy(column, direction) + } + + whereColumn(column: any, operator: any, comparisonColumn?: any): this { + if (comparisonColumn !== undefined) { + this.ensureEncryptedColumnComparisonSupport('whereColumn', column, comparisonColumn) + return super.whereColumn(column, operator, comparisonColumn) + } + + this.ensureEncryptedColumnComparisonSupport('whereColumn', column, operator) + return super.whereColumn(column, operator) + } + + orWhereColumn(column: any, operator: any, comparisonColumn?: any): this { + if (comparisonColumn !== undefined) { + this.ensureEncryptedColumnComparisonSupport('orWhereColumn', column, comparisonColumn) + return super.orWhereColumn(column, operator, comparisonColumn) + } + + this.ensureEncryptedColumnComparisonSupport('orWhereColumn', column, operator) + return super.orWhereColumn(column, operator) + } + + whereNotColumn(column: any, operator: any, comparisonColumn?: any): this { + if (comparisonColumn !== undefined) { + this.ensureEncryptedColumnComparisonSupport('whereNotColumn', column, comparisonColumn) + return super.whereNotColumn(column, operator, comparisonColumn) + } + + this.ensureEncryptedColumnComparisonSupport('whereNotColumn', column, operator) + return super.whereNotColumn(column, operator) + } + + orWhereNotColumn(column: any, operator: any, comparisonColumn?: any): this { + if (comparisonColumn !== undefined) { + this.ensureEncryptedColumnComparisonSupport('orWhereNotColumn', column, comparisonColumn) + return super.orWhereNotColumn(column, operator, comparisonColumn) + } + + this.ensureEncryptedColumnComparisonSupport('orWhereNotColumn', column, operator) + return super.orWhereNotColumn(column, operator) + } + + orWhereLike(key: any, value: any): this { + this.ensureEncryptedMethodSupport(key, 'orWhereLike') + return super.orWhereLike(key, value) + } + + whereILike(key: any, value: any): this { + this.ensureEncryptedMethodSupport(key, 'whereILike') + return super.whereILike(key, value) + } + + orWhereILike(key: any, value: any): this { + this.ensureEncryptedMethodSupport(key, 'orWhereILike') + return super.orWhereILike(key, value) + } + + whereBetween(key: any, value: [any, any]): this { + this.ensureEncryptedMethodSupport(key, 'whereBetween') + return super.whereBetween(key, value) + } + + orWhereBetween(key: any, value: [any, any]): this { + this.ensureEncryptedMethodSupport(key, 'orWhereBetween') + return super.orWhereBetween(key, value) + } + + whereNotBetween(key: any, value: [any, any]): this { + this.ensureEncryptedMethodSupport(key, 'whereNotBetween') + return super.whereNotBetween(key, value) + } + + orWhereNotBetween(key: any, value: [any, any]): this { + this.ensureEncryptedMethodSupport(key, 'orWhereNotBetween') + return super.orWhereNotBetween(key, value) + } + + whereJson(column: string, value: any) { + this.ensureEncryptedMethodSupport(column, 'whereJson') + return super.whereJson(column, value) + } + + orWhereJson(column: string, value: any) { + this.ensureEncryptedMethodSupport(column, 'orWhereJson') + return super.orWhereJson(column, value) + } + + whereNotJson(column: string, value: any) { + this.ensureEncryptedMethodSupport(column, 'whereNotJson') + return super.whereNotJson(column, value) + } + + orWhereNotJson(column: string, value: any) { + this.ensureEncryptedMethodSupport(column, 'orWhereNotJson') + return super.orWhereNotJson(column, value) + } + + whereJsonSuperset(column: string, value: any) { + this.ensureEncryptedMethodSupport(column, 'whereJsonSuperset') + return super.whereJsonSuperset(column, value) + } + + orWhereJsonSuperset(column: string, value: any) { + this.ensureEncryptedMethodSupport(column, 'orWhereJsonSuperset') + return super.orWhereJsonSuperset(column, value) + } + + whereNotJsonSuperset(column: string, value: any) { + this.ensureEncryptedMethodSupport(column, 'whereNotJsonSuperset') + return super.whereNotJsonSuperset(column, value) + } + + orWhereNotJsonSuperset(column: string, value: any) { + this.ensureEncryptedMethodSupport(column, 'orWhereNotJsonSuperset') + return super.orWhereNotJsonSuperset(column, value) + } + + whereJsonSubset(column: string, value: any) { + this.ensureEncryptedMethodSupport(column, 'whereJsonSubset') + return super.whereJsonSubset(column, value) + } + + orWhereJsonSubset(column: string, value: any) { + this.ensureEncryptedMethodSupport(column, 'orWhereJsonSubset') + return super.orWhereJsonSubset(column, value) + } + + whereNotJsonSubset(column: string, value: any) { + this.ensureEncryptedMethodSupport(column, 'whereNotJsonSubset') + return super.whereNotJsonSubset(column, value) + } + + orWhereNotJsonSubset(column: string, value: any) { + this.ensureEncryptedMethodSupport(column, 'orWhereNotJsonSubset') + return super.orWhereNotJsonSubset(column, value) + } + + whereJsonPath(column: string, jsonPath: string, operator: any, value?: any): this { + this.ensureEncryptedMethodSupport(column, 'whereJsonPath') + return super.whereJsonPath(column, jsonPath, operator, value) + } + + orWhereJsonPath(column: string, jsonPath: string, operator: any, value?: any): this { + this.ensureEncryptedMethodSupport(column, 'orWhereJsonPath') + return super.orWhereJsonPath(column, jsonPath, operator, value) + } + /** * Executes the current query */ @@ -672,6 +1101,7 @@ export class ModelQueryBuilder */ increment(column: any, counter?: any): any { this.ensureCanPerformWrites() + this.ensureEncryptedArithmeticSupport(column, 'increment') this.knexQuery.increment(this.resolveKey(column, true), counter) return this } @@ -682,6 +1112,7 @@ export class ModelQueryBuilder */ decrement(column: any, counter?: any): any { this.ensureCanPerformWrites() + this.ensureEncryptedArithmeticSupport(column, 'decrement') this.knexQuery.decrement(this.resolveKey(column, true), counter) return this } @@ -705,21 +1136,30 @@ export class ModelQueryBuilder ): ModelQueryBuilderContract { this.ensureCanPerformWrites() - if (value === undefined && returning === undefined) { - // Transform values in the object before passing to knex - if (column && typeof column === 'object') { - const columns = Object.keys(column).reduce((result: any, key) => { - result[this.resolveKey(key)] = this.transformRaw(column[key]) - return result - }, {}) + if (column && typeof column === 'object') { + const columns = this.prepareUpdateValues(column) + if (value === undefined) { this.knexQuery.update(columns) } else { - this.knexQuery.update(column) + this.knexQuery.update(columns, value) } - } else if (returning === undefined) { - this.knexQuery.update(this.resolveKey(column), this.transformRaw(value)) + + return this + } + + if (value === undefined) { + this.knexQuery.update(column) + return this + } + + const columns = this.prepareUpdateValues({ + [column]: value, + }) + + if (returning === undefined) { + this.knexQuery.update(columns) } else { - this.knexQuery.update(this.resolveKey(column), this.transformRaw(value), returning) + this.knexQuery.update(columns, returning) } return this diff --git a/src/types/model.ts b/src/types/model.ts index 5bbdd6d7..ef82586e 100644 --- a/src/types/model.ts +++ b/src/types/model.ts @@ -220,6 +220,70 @@ export type ColumnOptions = { consume?: (value: any, attribute: string, model: LucidRow) => any } +/** + * Encryption contract used by encrypted columns. + * The shape is intentionally aligned with the + * boringnode/encryption package. + */ +export type ModelEncryptionContract = { + encrypt(value: any, options?: { deterministic?: boolean; [key: string]: any }): any + decrypt(value: any, options?: { [key: string]: any }): any + blindIndexes(value: any, options?: { [key: string]: any }): any + blindIndex(value: any, options: { purpose: string; [key: string]: any }): any +} + +/** + * Default drivers used when encrypted columns do not define one. + */ +export type ModelEncryptionDefaults = { + standardDriver?: string + deterministicDriver?: string + blindDriver?: string +} + +/** + * Encryption configuration for Lucid models. + */ +export type ModelEncryptionConfig = { + provider?: ModelEncryptionContract + defaults?: ModelEncryptionDefaults +} + +/** + * Supported encryption strategies for columns. + */ +export type EncryptedColumnMode = 'standard' | 'deterministic' | 'blind' + +/** + * Configuration for blind indexes. + */ +export type BlindEncryptedColumnOptions = { + columnName: string + purpose: string +} + +/** + * Metadata persisted on encrypted columns. + */ +export type EncryptedColumnMeta = { + mode: EncryptedColumnMode + driver?: string + blindColumnName?: string + purpose?: string +} + +/** + * Options accepted by the encrypted column decorator. + */ +export type EncryptedColumnOptions = Omit< + Partial, + 'isPrimary' | 'prepare' | 'consume' +> & { + deterministic?: boolean + driver?: string + blind?: BlindEncryptedColumnOptions +} + /** * Shape of column options after they have set on the model */ @@ -249,6 +313,11 @@ export type ModelRelationOptions = */ export type ColumnDecorator = (options?: Partial) => DecoratorFn +/** + * Signature for encrypted column decorator function + */ +export type EncryptedColumnDecorator = (options?: EncryptedColumnOptions) => DecoratorFn + /** * Signature for computed decorator function */ @@ -821,12 +890,36 @@ export interface LucidModel { */ $adapter: AdapterContract + /** + * Encryption provider used by encrypted columns. + */ + $encryption?: ModelEncryptionConfig + /** * Define an adapter to use for interacting with * the database */ useAdapter(adapter: AdapterContract): void + /** + * Define encryption provider to use for encrypted columns. + */ + useEncryption(config: ModelEncryptionConfig): void + + /** + * Returns the encryption provider. + */ + $getEncryption(attributeName?: string): ModelEncryptionContract + + /** + * Returns encryption provider along with the resolved driver for a given mode. + */ + $resolveEncryption( + attributeName: string | undefined, + mode: EncryptedColumnMode, + columnDriver?: string + ): { provider: ModelEncryptionContract; driver?: string } + /** * Reference to hooks */ diff --git a/test-helpers/index.ts b/test-helpers/index.ts index 09f95a57..5aba9e36 100644 --- a/test-helpers/index.ts +++ b/test-helpers/index.ts @@ -186,6 +186,19 @@ export async function setup(destroyDb: boolean = true) { }) } + const hasEncryptedUsersTable = await db.schema.hasTable('users_encrypted') + if (!hasEncryptedUsersTable) { + await db.schema.createTable('users_encrypted', (table) => { + table.increments() + table.string('username').unique() + table.text('email').nullable() + table.string('email_blind').nullable() + table.index(['email_blind']) + table.timestamp('created_at').defaultTo(db.fn.now()) + table.timestamp('updated_at').nullable() + }) + } + const hasUuidUsers = await db.schema.hasTable('uuid_users') if (!hasUuidUsers) { await db.schema.createTable('uuid_users', (table) => { @@ -329,6 +342,7 @@ export async function cleanup(customTables?: string[]) { } await db.schema.dropTableIfExists('users') + await db.schema.dropTableIfExists('users_encrypted') await db.schema.dropTableIfExists('uuid_users') await db.schema.dropTableIfExists('follows') await db.schema.dropTableIfExists('friends') @@ -352,6 +366,7 @@ export async function cleanup(customTables?: string[]) { export async function resetTables() { const db = getKnex(Object.assign({}, getConfig(), { debug: false })) await db.table('users').truncate() + await db.table('users_encrypted').truncate() await db.table('uuid_users').truncate() await db.table('follows').truncate() await db.table('friends').truncate() diff --git a/test/database/query_client.spec.ts b/test/database/query_client.spec.ts index cd2effa0..3010e13a 100644 --- a/test/database/query_client.spec.ts +++ b/test/database/query_client.spec.ts @@ -569,6 +569,7 @@ test.group('Query client | get tables', (group) => { 'skill_user', 'skills', 'users', + 'users_encrypted', 'uuid_users', ]) } else { @@ -585,6 +586,7 @@ test.group('Query client | get tables', (group) => { 'skills', 'skill_user', 'users', + 'users_encrypted', 'uuid_users', ]) } diff --git a/test/orm/base_model.spec.ts b/test/orm/base_model.spec.ts index 12fbbbe0..998b9b79 100644 --- a/test/orm/base_model.spec.ts +++ b/test/orm/base_model.spec.ts @@ -1315,6 +1315,436 @@ test.group('Base Model | persist', (group) => { assert.deepEqual(user.$original, { username: 'virk', fullName: 'H virk' }) }) + test('encrypt value before passing encrypted columns to the adapter', async ({ fs, assert }) => { + const app = new AppFactory().create(fs.baseUrl, () => {}) + await app.init() + const adapter = new FakeAdapter() + const BaseModel = getBaseModel(adapter) + + BaseModel.useEncryption({ + provider: { + encrypt: (value) => `enc:${value}`, + decrypt: (value) => String(value).replace(/^enc:/, ''), + blindIndex: (value, { purpose }) => `blind:${purpose}:${value}`, + blindIndexes: () => ({}), + }, + }) + + class User extends BaseModel { + @column.encrypted() + declare secret: string + } + + const user = new User() + user.secret = 'super-secret' + await user.save() + + assert.deepEqual(adapter.operations[0].attributes, { secret: 'enc:super-secret' }) + }) + + test('use standard default driver when encrypted standard columns omit driver', async ({ + fs, + assert, + }) => { + const app = new AppFactory().create(fs.baseUrl, () => {}) + await app.init() + const adapter = new FakeAdapter() + const BaseModel = getBaseModel(adapter) + + BaseModel.useEncryption({ + provider: { + encrypt: (value, options) => `enc:${options?.driver || 'default'}:${value}`, + decrypt: (value) => String(value).replace(/^enc:/, ''), + blindIndex: (value, { purpose }) => `blind:${purpose}:${value}`, + blindIndexes: () => ({}), + }, + defaults: { + standardDriver: 'std-default', + }, + }) + + class User extends BaseModel { + @column.encrypted() + declare secret: string + } + + const user = new User() + user.secret = 'super-secret' + await user.save() + + assert.deepEqual(adapter.operations[0].attributes, { + secret: 'enc:std-default:super-secret', + }) + }) + + test('allow models booted before useEncryption to pick encryption provider dynamically', async ({ + fs, + assert, + }) => { + const app = new AppFactory().create(fs.baseUrl, () => {}) + await app.init() + const adapter = new FakeAdapter() + const BaseModel = getBaseModel(adapter) + + BaseModel.useEncryption({ provider: undefined as any }) + + class User extends BaseModel { + @column.encrypted() + declare secret: string + } + + BaseModel.useEncryption({ + provider: { + encrypt: (value) => `enc:${value}`, + decrypt: (value) => String(value).replace(/^enc:/, ''), + blindIndex: (value, { purpose }) => `blind:${purpose}:${value}`, + blindIndexes: () => ({}), + }, + }) + + const user = new User() + user.secret = 'super-secret' + await user.save() + + assert.deepEqual(adapter.operations[0].attributes, { secret: 'enc:super-secret' }) + }) + + test('encrypt deterministic value before passing encrypted columns to the adapter', async ({ + fs, + assert, + }) => { + const app = new AppFactory().create(fs.baseUrl, () => {}) + await app.init() + const adapter = new FakeAdapter() + const BaseModel = getBaseModel(adapter) + + BaseModel.useEncryption({ + provider: { + encrypt: (value, options) => + options?.deterministic + ? `det:${options?.driver || 'default'}:${value}` + : `enc:${options?.driver || 'default'}:${value}`, + decrypt: (value) => String(value).replace(/^(enc:|det:)/, ''), + blindIndex: (value, { purpose }) => `blind:${purpose}:${value}`, + blindIndexes: () => ({}), + }, + defaults: { + deterministicDriver: 'det-default', + }, + }) + + class User extends BaseModel { + @column.encrypted({ deterministic: true, driver: 'det-v1' }) + declare email: string + } + + const user = new User() + user.email = 'virk@adonisjs.com' + await user.save() + + assert.deepEqual(adapter.operations[0].attributes, { + email: 'det:det-v1:virk@adonisjs.com', + }) + }) + + test('use deterministic default driver when encrypted deterministic columns omit driver', async ({ + fs, + assert, + }) => { + const app = new AppFactory().create(fs.baseUrl, () => {}) + await app.init() + const adapter = new FakeAdapter() + const BaseModel = getBaseModel(adapter) + + BaseModel.useEncryption({ + provider: { + encrypt: (value, options) => + options?.deterministic + ? `det:${options?.driver || 'default'}:${value}` + : `enc:${options?.driver || 'default'}:${value}`, + decrypt: (value) => String(value).replace(/^(enc:|det:)/, ''), + blindIndex: (value, { purpose }) => `blind:${purpose}:${value}`, + blindIndexes: () => ({}), + }, + defaults: { + deterministicDriver: 'det-default', + }, + }) + + class User extends BaseModel { + @column.encrypted({ deterministic: true }) + declare email: string + } + + const user = new User() + user.email = 'virk@adonisjs.com' + await user.save() + + assert.deepEqual(adapter.operations[0].attributes, { + email: 'det:det-default:virk@adonisjs.com', + }) + }) + + test('persist blind index in a dedicated column when using blind encrypted columns', async ({ + fs, + assert, + }) => { + const app = new AppFactory().create(fs.baseUrl, () => {}) + await app.init() + const adapter = new FakeAdapter() + const BaseModel = getBaseModel(adapter) + + BaseModel.useEncryption({ + provider: { + encrypt: (value, options) => `enc:${options?.driver || 'default'}:${value}`, + decrypt: (value) => String(value).replace(/^enc:/, ''), + blindIndex: (value, { purpose, driver }) => + `blind:${driver || 'default'}:${purpose}:${value}`, + blindIndexes: () => ({}), + }, + defaults: { + blindDriver: 'blind-default', + }, + }) + + class User extends BaseModel { + @column.encrypted({ + driver: 'enc-v1', + blind: { columnName: 'email_blind', purpose: 'users:email' }, + }) + declare email: string + } + + const user = new User() + user.email = 'virk@adonisjs.com' + await user.save() + + assert.deepEqual(adapter.operations[0].attributes, { + email: 'enc:enc-v1:virk@adonisjs.com', + email_blind: 'blind:enc-v1:users:email:virk@adonisjs.com', + }) + }) + + test('use blind default driver when encrypted blind columns omit driver', async ({ + fs, + assert, + }) => { + const app = new AppFactory().create(fs.baseUrl, () => {}) + await app.init() + const adapter = new FakeAdapter() + const BaseModel = getBaseModel(adapter) + + BaseModel.useEncryption({ + provider: { + encrypt: (value, options) => `enc:${options?.driver || 'default'}:${value}`, + decrypt: (value) => String(value).replace(/^enc:/, ''), + blindIndex: (value, { purpose, driver }) => + `blind:${driver || 'default'}:${purpose}:${value}`, + blindIndexes: () => ({}), + }, + defaults: { + blindDriver: 'blind-default', + }, + }) + + class User extends BaseModel { + @column.encrypted({ + blind: { columnName: 'email_blind', purpose: 'users:email' }, + }) + declare email: string + } + + const user = new User() + user.email = 'virk@adonisjs.com' + await user.save() + + assert.deepEqual(adapter.operations[0].attributes, { + email: 'enc:blind-default:virk@adonisjs.com', + email_blind: 'blind:blind-default:users:email:virk@adonisjs.com', + }) + }) + + test('prefer computed blind index over manual blind column attributes regardless of assignment order', async ({ + fs, + assert, + }) => { + const app = new AppFactory().create(fs.baseUrl, () => {}) + await app.init() + const adapter = new FakeAdapter() + const BaseModel = getBaseModel(adapter) + + BaseModel.useEncryption({ + provider: { + encrypt: (value, options) => `enc:${options?.driver || 'default'}:${value}`, + decrypt: (value) => String(value).replace(/^enc:/, ''), + blindIndex: (value, { purpose, driver }) => + `blind:${driver || 'default'}:${purpose}:${value}`, + blindIndexes: () => ({}), + }, + }) + + class User extends BaseModel { + @column.encrypted({ + driver: 'enc-v1', + blind: { columnName: 'email_blind', purpose: 'users:email' }, + }) + declare email: string + + @column({ columnName: 'email_blind' }) + declare emailBlind: string + } + + const first = new User() + first.emailBlind = 'manual:first' + first.email = 'virk@adonisjs.com' + await first.save() + + const second = new User() + second.email = 'nikk@adonisjs.com' + second.emailBlind = 'manual:second' + await second.save() + + assert.deepEqual(adapter.operations[0].attributes, { + email: 'enc:enc-v1:virk@adonisjs.com', + email_blind: 'blind:enc-v1:users:email:virk@adonisjs.com', + }) + + assert.deepEqual(adapter.operations[1].attributes, { + email: 'enc:enc-v1:nikk@adonisjs.com', + email_blind: 'blind:enc-v1:users:email:nikk@adonisjs.com', + }) + }) + + test('raise exception when using blind encrypted columns without purpose', async ({ + fs, + assert, + }) => { + const app = new AppFactory().create(fs.baseUrl, () => {}) + await app.init() + const adapter = new FakeAdapter() + const BaseModel = getBaseModel(adapter) + + const fn = () => { + class User extends BaseModel { + @column.encrypted({ blind: { columnName: 'email_blind' } } as any) + declare email: string + } + + return User + } + + assert.throws( + fn, + 'Invalid encrypted column configuration for "User.email". Missing "blind.purpose"' + ) + }) + + test('raise exception when blind encrypted meta is missing blind.columnName at persistence time', async ({ + fs, + assert, + }) => { + const app = new AppFactory().create(fs.baseUrl, () => {}) + await app.init() + const adapter = new FakeAdapter() + const BaseModel = getBaseModel(adapter) + + BaseModel.useEncryption({ + provider: { + encrypt: (value) => `enc:${value}`, + decrypt: (value) => String(value).replace(/^enc:/, ''), + blindIndex: (value, { purpose }) => `blind:${purpose}:${value}`, + blindIndexes: () => ({}), + }, + }) + + class User extends BaseModel { + @column({ + meta: { + encryption: { + mode: 'blind', + }, + }, + } as any) + declare email: string + } + + const user = new User() + user.email = 'virk@adonisjs.com' + + await assert.rejects( + () => user.save(), + 'Invalid encrypted column configuration for "User.email". Missing "blind.columnName"' + ) + }) + + test('raise exception when blind encrypted meta is missing blind.purpose at persistence time', async ({ + fs, + assert, + }) => { + const app = new AppFactory().create(fs.baseUrl, () => {}) + await app.init() + const adapter = new FakeAdapter() + const BaseModel = getBaseModel(adapter) + + BaseModel.useEncryption({ + provider: { + encrypt: (value) => `enc:${value}`, + decrypt: (value) => String(value).replace(/^enc:/, ''), + blindIndex: (value, { purpose }) => `blind:${purpose}:${value}`, + blindIndexes: () => ({}), + }, + }) + + class User extends BaseModel { + @column({ + meta: { + encryption: { + mode: 'blind', + blindColumnName: 'email_blind', + }, + }, + } as any) + declare email: string + } + + const user = new User() + user.email = 'virk@adonisjs.com' + + await assert.rejects( + () => user.save(), + 'Invalid encrypted column configuration for "User.email". Missing "blind.purpose"' + ) + }) + + test('raise exception when encryption provider is missing for encrypted columns', async ({ + fs, + assert, + }) => { + const app = new AppFactory().create(fs.baseUrl, () => {}) + await app.init() + const adapter = new FakeAdapter() + const BaseModel = getBaseModel(adapter) + BaseModel.useEncryption({ provider: undefined as any }) + + class User extends BaseModel { + @column.encrypted() + declare secret: string + } + + const user = new User() + user.secret = 'super-secret' + + try { + await user.save() + assert.fail('Expected save to fail when encryption provider is missing') + } catch (error: any) { + assert.equal( + error.message, + 'Cannot use encrypted column "User.secret" without a configured encryption provider. Call "BaseModel.useEncryption()" before querying or persisting encrypted columns' + ) + } + }) + test('send values mutated by the hooks to the adapter', async ({ fs, assert }) => { const app = new AppFactory().create(fs.baseUrl, () => {}) await app.init() @@ -1815,6 +2245,70 @@ test.group('Base Model | create from adapter results', (group) => { assert.deepEqual(user!.$original, { fullName: 'VIRK' }) }) + test('decrypt value when consuming encrypted columns', async ({ fs, assert }) => { + const app = new AppFactory().create(fs.baseUrl, () => {}) + await app.init() + const adapter = new FakeAdapter() + + const BaseModel = getBaseModel(adapter) + BaseModel.useEncryption({ + provider: { + encrypt: (value) => `enc:${value}`, + decrypt: (value) => String(value).replace(/^enc:/, ''), + blindIndex: (value, { purpose }) => `blind:${purpose}:${value}`, + blindIndexes: () => ({}), + }, + }) + + class User extends BaseModel { + @column.encrypted() + declare secret: string + } + + const user = User.$createFromAdapterResult({ secret: 'enc:top-secret' }) + + assert.isTrue(user!.$isPersisted) + assert.isFalse(user!.$isDirty) + assert.isFalse(user!.$isLocal) + assert.deepEqual(user!.$attributes, { secret: 'top-secret' }) + assert.deepEqual(user!.$original, { secret: 'top-secret' }) + }) + + test('pass encryption driver when decrypting encrypted columns during consume', async ({ + fs, + assert, + }) => { + const app = new AppFactory().create(fs.baseUrl, () => {}) + await app.init() + const adapter = new FakeAdapter() + + const BaseModel = getBaseModel(adapter) + let decryptOptions: any + + BaseModel.useEncryption({ + provider: { + encrypt: (value, options) => `enc:${options?.driver || 'default'}:${value}`, + decrypt: (value, options) => { + decryptOptions = options + return String(value).replace(/^enc:[^:]+:/, '') + }, + blindIndex: (value, { purpose }) => `blind:${purpose}:${value}`, + blindIndexes: () => ({}), + }, + }) + + class User extends BaseModel { + @column.encrypted({ driver: 'enc-v1' }) + declare secret: string + } + + const user = User.$createFromAdapterResult({ secret: 'enc:enc-v1:top-secret' }) + + assert.deepEqual(user!.$attributes, { secret: 'top-secret' }) + assert.deepEqual(user!.$original, { secret: 'top-secret' }) + assert.deepEqual(decryptOptions, { driver: 'enc-v1' }) + }) + test('original and attributes should not be shared', async ({ fs, assert }) => { const app = new AppFactory().create(fs.baseUrl, () => {}) await app.init() diff --git a/test/orm/model_query_builder.spec.ts b/test/orm/model_query_builder.spec.ts index 380280d0..56cfa1f3 100644 --- a/test/orm/model_query_builder.spec.ts +++ b/test/orm/model_query_builder.spec.ts @@ -99,6 +99,1008 @@ test.group('Model query builder', (group) => { assert.deepEqual(users[0].$attributes, { id: 1, username: 'virk' }) }) + test('rewrite deterministic encrypted where clauses', async ({ fs, assert }) => { + const app = new AppFactory().create(fs.baseUrl, () => {}) + await app.init() + const db = getDb() + const adapter = ormAdapter(db) + const BaseModel = getBaseModel(adapter) + + BaseModel.useEncryption({ + provider: { + encrypt: (value, options) => + options?.deterministic + ? `det:${options?.driver || 'default'}:${value}` + : `enc:${options?.driver || 'default'}:${value}`, + decrypt: (value) => String(value).replace(/^(enc:|det:)/, ''), + blindIndex: (value, { purpose }) => `blind:${purpose}:${value}`, + blindIndexes: () => ({}), + }, + defaults: { + deterministicDriver: 'det-default', + }, + }) + + class User extends BaseModel { + @column({ isPrimary: true }) + declare id: number + + @column.encrypted({ deterministic: true, driver: 'det-v1' }) + declare email: string + } + + const { sql, bindings } = User.query().where('email', 'virk@adonisjs.com').toSQL() + + const { sql: knexSql, bindings: knexBindings } = db + .connection() + .getWriteClient() + .from('users') + .where('email', 'det:det-v1:virk@adonisjs.com') + .toSQL() + + assert.equal(sql, knexSql) + assert.deepEqual(bindings, knexBindings) + }) + + test('use deterministic default driver when rewriting deterministic where clauses', async ({ + fs, + assert, + }) => { + const app = new AppFactory().create(fs.baseUrl, () => {}) + await app.init() + const db = getDb() + const adapter = ormAdapter(db) + const BaseModel = getBaseModel(adapter) + + BaseModel.useEncryption({ + provider: { + encrypt: (value, options) => + options?.deterministic + ? `det:${options?.driver || 'default'}:${value}` + : `enc:${options?.driver || 'default'}:${value}`, + decrypt: (value) => String(value).replace(/^(enc:|det:)/, ''), + blindIndex: (value, { purpose }) => `blind:${purpose}:${value}`, + blindIndexes: () => ({}), + }, + defaults: { + deterministicDriver: 'det-default', + }, + }) + + class User extends BaseModel { + @column({ isPrimary: true }) + declare id: number + + @column.encrypted({ deterministic: true }) + declare email: string + } + + const { sql, bindings } = User.query().where('email', 'virk@adonisjs.com').toSQL() + + const { sql: knexSql, bindings: knexBindings } = db + .connection() + .getWriteClient() + .from('users') + .where('email', 'det:det-default:virk@adonisjs.com') + .toSQL() + + assert.equal(sql, knexSql) + assert.deepEqual(bindings, knexBindings) + }) + + test('rewrite encrypted andWhere/orWhere clauses for deterministic and blind columns', async ({ + fs, + assert, + }) => { + const app = new AppFactory().create(fs.baseUrl, () => {}) + await app.init() + const db = getDb() + const adapter = ormAdapter(db) + const BaseModel = getBaseModel(adapter) + + BaseModel.useEncryption({ + provider: { + encrypt: (value, options) => + options?.deterministic + ? `det:${options?.driver || 'default'}:${value}` + : `enc:${options?.driver || 'default'}:${value}`, + decrypt: (value) => String(value).replace(/^(enc:|det:)/, ''), + blindIndex: (value, { purpose, driver }) => + `blind:${driver || 'default'}:${purpose}:${value}:single`, + blindIndexes: (value, options) => [ + `blind:${options?.driver || 'default'}:${options?.purpose}:${value}:new`, + `blind:${options?.driver || 'default'}:${options?.purpose}:${value}:old`, + ], + }, + }) + + class User extends BaseModel { + @column({ isPrimary: true }) + declare id: number + + @column.encrypted({ deterministic: true, driver: 'det-v1' }) + declare email: string + + @column.encrypted({ + driver: 'enc-v1', + blind: { columnName: 'username_blind', purpose: 'users:username' }, + }) + declare username: string + } + + const { sql, bindings } = User.query() + .where('id', 1) + .andWhere('email', 'virk@adonisjs.com') + .andWhere('username', 'virk') + .orWhere('email', 'nikk@adonisjs.com') + .orWhere('username', 'nikk') + .toSQL() + + const { sql: knexSql, bindings: knexBindings } = db + .connection() + .getWriteClient() + .from('users') + .where('id', 1) + .andWhere('email', 'det:det-v1:virk@adonisjs.com') + .whereIn('username_blind', [ + 'blind:enc-v1:users:username:virk:new', + 'blind:enc-v1:users:username:virk:old', + ]) + .orWhere('email', 'det:det-v1:nikk@adonisjs.com') + .orWhereIn('username_blind', [ + 'blind:enc-v1:users:username:nikk:new', + 'blind:enc-v1:users:username:nikk:old', + ]) + .toSQL() + + assert.equal(sql, knexSql) + assert.deepEqual(bindings, knexBindings) + }) + + test('do not rewrite joined table columns that match encrypted model column names', async ({ + fs, + assert, + }) => { + const app = new AppFactory().create(fs.baseUrl, () => {}) + await app.init() + const db = getDb() + const adapter = ormAdapter(db) + const BaseModel = getBaseModel(adapter) + + BaseModel.useEncryption({ + provider: { + encrypt: (value, options) => + options?.deterministic + ? `det:${options?.driver || 'default'}:${value}` + : `enc:${options?.driver || 'default'}:${value}`, + decrypt: (value) => String(value).replace(/^(enc:|det:)/, ''), + blindIndex: (value, { purpose }) => `blind:${purpose}:${value}`, + blindIndexes: () => ({}), + }, + }) + + class User extends BaseModel { + @column({ isPrimary: true }) + declare id: number + + @column.encrypted({ deterministic: true, driver: 'det-v1' }) + declare email: string + } + + const { sql, bindings } = User.query() + .join('contacts', 'contacts.user_id', 'users.id') + .where('contacts.email', 'virk@adonisjs.com') + .where('users.email', 'virk@adonisjs.com') + .toSQL() + + const { sql: knexSql, bindings: knexBindings } = db + .connection() + .getWriteClient() + .from('users') + .join('contacts', 'contacts.user_id', 'users.id') + .where('contacts.email', 'virk@adonisjs.com') + .where('users.email', 'det:det-v1:virk@adonisjs.com') + .toSQL() + + assert.equal(sql, knexSql) + assert.deepEqual(bindings, knexBindings) + }) + + test('rewrite only model alias columns and ignore joined aliases with same column name', async ({ + fs, + assert, + }) => { + const app = new AppFactory().create(fs.baseUrl, () => {}) + await app.init() + const db = getDb() + const adapter = ormAdapter(db) + const BaseModel = getBaseModel(adapter) + + BaseModel.useEncryption({ + provider: { + encrypt: (value, options) => + options?.deterministic + ? `det:${options?.driver || 'default'}:${value}` + : `enc:${options?.driver || 'default'}:${value}`, + decrypt: (value) => String(value).replace(/^(enc:|det:)/, ''), + blindIndex: (value, { purpose }) => `blind:${purpose}:${value}`, + blindIndexes: () => ({}), + }, + }) + + class User extends BaseModel { + @column({ isPrimary: true }) + declare id: number + + @column.encrypted({ deterministic: true, driver: 'det-v1' }) + declare email: string + } + + const { sql, bindings } = User.query() + .from({ u: 'users' }) + .join('contacts as c', 'c.user_id', 'u.id') + .where('c.email', 'virk@adonisjs.com') + .where('u.email', 'virk@adonisjs.com') + .toSQL() + + const { sql: knexSql, bindings: knexBindings } = db + .connection() + .getWriteClient() + .from({ u: 'users' }) + .join('contacts as c', 'c.user_id', 'u.id') + .where('c.email', 'virk@adonisjs.com') + .where('u.email', 'det:det-v1:virk@adonisjs.com') + .toSQL() + + assert.equal(sql, knexSql) + assert.deepEqual(bindings, knexBindings) + }) + + test('rewrite blind encrypted where and whereIn clauses', async ({ fs, assert }) => { + const app = new AppFactory().create(fs.baseUrl, () => {}) + await app.init() + const db = getDb() + const adapter = ormAdapter(db) + const BaseModel = getBaseModel(adapter) + + BaseModel.useEncryption({ + provider: { + encrypt: (value, options) => `enc:${options?.driver || 'default'}:${value}`, + decrypt: (value) => String(value).replace(/^enc:/, ''), + blindIndex: (value, { purpose, driver }) => + `blind:${driver || 'default'}:${purpose}:${value}`, + blindIndexes: () => ({}), + }, + defaults: { + blindDriver: 'blind-default', + }, + }) + + class User extends BaseModel { + @column({ isPrimary: true }) + declare id: number + + @column.encrypted({ + driver: 'enc-v1', + blind: { columnName: 'email_blind', purpose: 'users:email' }, + }) + declare email: string + } + + const { sql, bindings } = User.query() + .where({ email: 'virk@adonisjs.com' }) + .whereIn('email', ['virk@adonisjs.com', 'nikk@adonisjs.com']) + .toSQL() + + const { sql: knexSql, bindings: knexBindings } = db + .connection() + .getWriteClient() + .from('users') + .where('email_blind', 'blind:enc-v1:users:email:virk@adonisjs.com') + .whereIn('email_blind', [ + 'blind:enc-v1:users:email:virk@adonisjs.com', + 'blind:enc-v1:users:email:nikk@adonisjs.com', + ]) + .toSQL() + + assert.equal(sql, knexSql) + assert.deepEqual(bindings, knexBindings) + }) + + test('use blind default driver when rewriting blind where clauses', async ({ fs, assert }) => { + const app = new AppFactory().create(fs.baseUrl, () => {}) + await app.init() + const db = getDb() + const adapter = ormAdapter(db) + const BaseModel = getBaseModel(adapter) + + BaseModel.useEncryption({ + provider: { + encrypt: (value, options) => `enc:${options?.driver || 'default'}:${value}`, + decrypt: (value) => String(value).replace(/^enc:/, ''), + blindIndex: (value, { purpose, driver }) => + `blind:${driver || 'default'}:${purpose}:${value}`, + blindIndexes: () => ({}), + }, + defaults: { + blindDriver: 'blind-default', + }, + }) + + class User extends BaseModel { + @column({ isPrimary: true }) + declare id: number + + @column.encrypted({ + blind: { columnName: 'email_blind', purpose: 'users:email' }, + }) + declare email: string + } + + const { sql, bindings } = User.query() + .where({ email: 'virk@adonisjs.com' }) + .whereIn('email', ['virk@adonisjs.com', 'nikk@adonisjs.com']) + .toSQL() + + const { sql: knexSql, bindings: knexBindings } = db + .connection() + .getWriteClient() + .from('users') + .where('email_blind', 'blind:blind-default:users:email:virk@adonisjs.com') + .whereIn('email_blind', [ + 'blind:blind-default:users:email:virk@adonisjs.com', + 'blind:blind-default:users:email:nikk@adonisjs.com', + ]) + .toSQL() + + assert.equal(sql, knexSql) + assert.deepEqual(bindings, knexBindings) + }) + + test('trim blind encrypted metadata values before rewriting where clauses', async ({ + fs, + assert, + }) => { + const app = new AppFactory().create(fs.baseUrl, () => {}) + await app.init() + const db = getDb() + const adapter = ormAdapter(db) + const BaseModel = getBaseModel(adapter) + + BaseModel.useEncryption({ + provider: { + encrypt: (value, options) => `enc:${options?.driver || 'default'}:${value}`, + decrypt: (value) => String(value).replace(/^enc:/, ''), + blindIndex: (value, { purpose, driver }) => + `blind:${driver || 'default'}:${purpose}:${value}`, + blindIndexes: () => ({}), + }, + }) + + class User extends BaseModel { + @column({ isPrimary: true }) + declare id: number + + @column.encrypted({ + driver: 'enc-v1', + blind: { columnName: ' email_blind ', purpose: ' users:email ' }, + }) + declare email: string + } + + const { sql, bindings } = User.query().where('email', 'virk@adonisjs.com').toSQL() + + const { sql: knexSql, bindings: knexBindings } = db + .connection() + .getWriteClient() + .from('users') + .where('email_blind', 'blind:enc-v1:users:email:virk@adonisjs.com') + .toSQL() + + assert.equal(sql, knexSql) + assert.deepEqual(bindings, knexBindings) + }) + + test('rewrite all encrypted keys when using object where clauses', async ({ fs, assert }) => { + const app = new AppFactory().create(fs.baseUrl, () => {}) + await app.init() + const db = getDb() + const adapter = ormAdapter(db) + const BaseModel = getBaseModel(adapter) + + BaseModel.useEncryption({ + provider: { + encrypt: (value, options) => + options?.deterministic + ? `det:${options?.driver || 'default'}:${value}` + : `enc:${options?.driver || 'default'}:${value}`, + decrypt: (value) => String(value).replace(/^(enc:|det:)/, ''), + blindIndex: (value, { purpose, driver }) => + `blind:${driver || 'default'}:${purpose}:${value}`, + blindIndexes: () => ({}), + }, + }) + + class User extends BaseModel { + @column({ isPrimary: true }) + declare id: number + + @column.encrypted({ deterministic: true, driver: 'det-v1' }) + declare email: string + + @column.encrypted({ + driver: 'enc-v1', + blind: { columnName: 'username_blind', purpose: 'users:username' }, + }) + declare username: string + } + + const { sql, bindings } = User.query() + .where({ + id: 1, + email: 'virk@adonisjs.com', + username: 'virk', + }) + .toSQL() + + const { sql: knexSql, bindings: knexBindings } = db + .connection() + .getWriteClient() + .from('users') + .where({ + id: 1, + email: 'det:det-v1:virk@adonisjs.com', + username_blind: 'blind:enc-v1:users:username:virk', + }) + .toSQL() + + assert.equal(sql, knexSql) + assert.deepEqual(bindings, knexBindings) + }) + + test('rewrite encrypted where clauses using IN and NOT IN operators', async ({ fs, assert }) => { + const app = new AppFactory().create(fs.baseUrl, () => {}) + await app.init() + const db = getDb() + const adapter = ormAdapter(db) + const BaseModel = getBaseModel(adapter) + + BaseModel.useEncryption({ + provider: { + encrypt: (value, options) => + options?.deterministic + ? `det:${options?.driver || 'default'}:${value}` + : `enc:${options?.driver || 'default'}:${value}`, + decrypt: (value) => String(value).replace(/^(enc:|det:)/, ''), + blindIndex: (value, { purpose, driver }) => + `blind:${driver || 'default'}:${purpose}:${value}`, + blindIndexes: () => ({}), + }, + }) + + class User extends BaseModel { + @column({ isPrimary: true }) + declare id: number + + @column.encrypted({ deterministic: true, driver: 'det-v1' }) + declare email: string + + @column.encrypted({ + driver: 'enc-v1', + blind: { columnName: 'username_blind', purpose: 'users:username' }, + }) + declare username: string + } + + const { sql, bindings } = User.query() + .where('email', 'IN', ['virk@adonisjs.com', 'nikk@adonisjs.com']) + .where('email', 'not in', ['tom@adonisjs.com']) + .where('username', 'iN', ['virk']) + .where('username', 'NoT In', ['nikk']) + .toSQL() + + const { sql: knexSql, bindings: knexBindings } = db + .connection() + .getWriteClient() + .from('users') + .whereIn('email', ['det:det-v1:virk@adonisjs.com', 'det:det-v1:nikk@adonisjs.com']) + .whereNotIn('email', ['det:det-v1:tom@adonisjs.com']) + .whereIn('username_blind', ['blind:enc-v1:users:username:virk']) + .whereNotIn('username_blind', ['blind:enc-v1:users:username:nikk']) + .toSQL() + + assert.equal(sql, knexSql) + assert.deepEqual(bindings, knexBindings) + }) + + test('rewrite encrypted andWhere/orWhere clauses using IN and NOT IN operators', async ({ + fs, + assert, + }) => { + const app = new AppFactory().create(fs.baseUrl, () => {}) + await app.init() + const db = getDb() + const adapter = ormAdapter(db) + const BaseModel = getBaseModel(adapter) + + BaseModel.useEncryption({ + provider: { + encrypt: (value, options) => + options?.deterministic + ? `det:${options?.driver || 'default'}:${value}` + : `enc:${options?.driver || 'default'}:${value}`, + decrypt: (value) => String(value).replace(/^(enc:|det:)/, ''), + blindIndex: (value, { purpose, driver }) => + `blind:${driver || 'default'}:${purpose}:${value}`, + blindIndexes: () => ({}), + }, + }) + + class User extends BaseModel { + @column({ isPrimary: true }) + declare id: number + + @column.encrypted({ deterministic: true, driver: 'det-v1' }) + declare email: string + + @column.encrypted({ + driver: 'enc-v1', + blind: { columnName: 'username_blind', purpose: 'users:username' }, + }) + declare username: string + } + + const { sql, bindings } = User.query() + .where('id', 1) + .andWhere('email', 'IN', ['virk@adonisjs.com', 'nikk@adonisjs.com']) + .orWhere('email', 'Not In', ['tom@adonisjs.com']) + .andWhere('username', 'in', ['virk']) + .orWhere('username', 'NOT IN', ['nikk']) + .toSQL() + + const { sql: knexSql, bindings: knexBindings } = db + .connection() + .getWriteClient() + .from('users') + .where('id', 1) + .whereIn('email', ['det:det-v1:virk@adonisjs.com', 'det:det-v1:nikk@adonisjs.com']) + .orWhereNotIn('email', ['det:det-v1:tom@adonisjs.com']) + .whereIn('username_blind', ['blind:enc-v1:users:username:virk']) + .orWhereNotIn('username_blind', ['blind:enc-v1:users:username:nikk']) + .toSQL() + + assert.equal(sql, knexSql) + assert.deepEqual(bindings, knexBindings) + }) + + test('rewrite blind encrypted where to whereIn when blindIndexes returns multiple values', async ({ + fs, + assert, + }) => { + const app = new AppFactory().create(fs.baseUrl, () => {}) + await app.init() + const db = getDb() + const adapter = ormAdapter(db) + const BaseModel = getBaseModel(adapter) + + BaseModel.useEncryption({ + provider: { + encrypt: (value, options) => `enc:${options?.driver || 'default'}:${value}`, + decrypt: (value) => String(value).replace(/^enc:/, ''), + blindIndex: (value, { purpose, driver }) => + `blind:${driver || 'default'}:${purpose}:${value}:single`, + blindIndexes: (value, options) => [ + `blind:${options?.driver || 'default'}:${options?.purpose}:${value}:new`, + `blind:${options?.driver || 'default'}:${options?.purpose}:${value}:old`, + ], + }, + }) + + class User extends BaseModel { + @column({ isPrimary: true }) + declare id: number + + @column.encrypted({ + driver: 'enc-v1', + blind: { columnName: 'email_blind', purpose: 'users:email' }, + }) + declare email: string + } + + const { sql, bindings } = User.query().where('email', 'virk@adonisjs.com').toSQL() + + const { sql: knexSql, bindings: knexBindings } = db + .connection() + .getWriteClient() + .from('users') + .whereIn('email_blind', [ + 'blind:enc-v1:users:email:virk@adonisjs.com:new', + 'blind:enc-v1:users:email:virk@adonisjs.com:old', + ]) + .toSQL() + + assert.equal(sql, knexSql) + assert.deepEqual(bindings, knexBindings) + }) + + test('rewrite object where variants for blind columns when blindIndexes returns multiple values', async ({ + fs, + assert, + }) => { + const app = new AppFactory().create(fs.baseUrl, () => {}) + await app.init() + const db = getDb() + const adapter = ormAdapter(db) + const BaseModel = getBaseModel(adapter) + + BaseModel.useEncryption({ + provider: { + encrypt: (value, options) => `enc:${options?.driver || 'default'}:${value}`, + decrypt: (value) => String(value).replace(/^enc:/, ''), + blindIndex: (value, { purpose, driver }) => + `blind:${driver || 'default'}:${purpose}:${value}:single`, + blindIndexes: (value, options) => [ + `blind:${options?.driver || 'default'}:${options?.purpose}:${value}:new`, + `blind:${options?.driver || 'default'}:${options?.purpose}:${value}:old`, + ], + }, + }) + + class User extends BaseModel { + @column({ isPrimary: true }) + declare id: number + + @column.encrypted({ + driver: 'enc-v1', + blind: { columnName: 'email_blind', purpose: 'users:email' }, + }) + declare email: string + } + + const { sql, bindings } = User.query() + .where('id', 1) + .where({ email: 'a@adonisjs.com' }) + .orWhere({ email: 'b@adonisjs.com' }) + .whereNot({ email: 'c@adonisjs.com' }) + .orWhereNot({ email: 'd@adonisjs.com' }) + .toSQL() + + const { sql: knexSql, bindings: knexBindings } = db + .connection() + .getWriteClient() + .from('users') + .where('id', 1) + .whereIn('email_blind', [ + 'blind:enc-v1:users:email:a@adonisjs.com:new', + 'blind:enc-v1:users:email:a@adonisjs.com:old', + ]) + .orWhere((query) => { + query.whereIn('email_blind', [ + 'blind:enc-v1:users:email:b@adonisjs.com:new', + 'blind:enc-v1:users:email:b@adonisjs.com:old', + ]) + }) + .whereNotIn('email_blind', [ + 'blind:enc-v1:users:email:c@adonisjs.com:new', + 'blind:enc-v1:users:email:c@adonisjs.com:old', + ]) + .orWhereNotIn('email_blind', [ + 'blind:enc-v1:users:email:d@adonisjs.com:new', + 'blind:enc-v1:users:email:d@adonisjs.com:old', + ]) + .toSQL() + + assert.equal(sql, knexSql) + assert.deepEqual(bindings, knexBindings) + }) + + test('raise when using unsupported operators on encrypted columns', async ({ fs, assert }) => { + const app = new AppFactory().create(fs.baseUrl, () => {}) + await app.init() + const db = getDb() + const adapter = ormAdapter(db) + const BaseModel = getBaseModel(adapter) + + BaseModel.useEncryption({ + provider: { + encrypt: (value, options) => (options?.deterministic ? `det:${value}` : `enc:${value}`), + decrypt: (value) => String(value).replace(/^(enc:|det:)/, ''), + blindIndex: (value, { purpose }) => `blind:${purpose}:${value}`, + blindIndexes: () => ({}), + }, + }) + + class User extends BaseModel { + @column({ isPrimary: true }) + declare id: number + + @column.encrypted() + declare token: string + + @column.encrypted({ deterministic: true }) + declare email: string + + @column.encrypted({ + blind: { + columnName: 'username_blind', + purpose: 'users:username', + }, + }) + declare username: string + } + + assert.throws( + () => User.query().where('token', 'super-secret'), + 'Cannot use "where" on standard encrypted column "User.token". Equality queries require deterministic or blind encryption' + ) + assert.throws( + () => User.query().whereIn('token', ['a', 'b']), + 'Cannot use "whereIn" on standard encrypted column "User.token". Equality queries require deterministic or blind encryption' + ) + + assert.throws( + () => User.query().whereLike('token', '%secret%'), + 'Cannot use "whereLike" on encrypted column "User.token". Encrypted columns can only be used with equality-based WHERE clauses' + ) + assert.throws( + () => User.query().whereColumn('token', 'id'), + 'Cannot use "whereColumn" on encrypted column "User.token". Encrypted columns can only be used with equality-based WHERE clauses' + ) + + assert.throws( + () => User.query().whereLike('email', '%virk%'), + 'Cannot use "whereLike" on encrypted column "User.email". Encrypted columns can only be used with equality-based WHERE clauses' + ) + assert.throws( + () => User.query().whereBetween('email', ['a', 'z']), + 'Cannot use "whereBetween" on encrypted column "User.email". Encrypted columns can only be used with equality-based WHERE clauses' + ) + assert.throws( + () => User.query().whereJson('email', { value: 'virk' }), + 'Cannot use "whereJson" on encrypted column "User.email". Encrypted columns can only be used with equality-based WHERE clauses' + ) + + assert.throws( + () => User.query().whereBetween('username', ['a', 'z']), + 'Cannot use "whereBetween" on encrypted column "User.username". Encrypted columns can only be used with equality-based WHERE clauses' + ) + assert.throws( + () => User.query().whereJson('username', { value: 'virk' }), + 'Cannot use "whereJson" on encrypted column "User.username". Encrypted columns can only be used with equality-based WHERE clauses' + ) + + assert.throws( + () => User.query().orderBy('token'), + 'Cannot use "orderBy" on encrypted column "User.token". Encrypted columns can only be used with equality-based WHERE clauses' + ) + assert.throws( + () => User.query().groupBy('email'), + 'Cannot use "groupBy" on encrypted column "User.email". Encrypted columns can only be used with equality-based WHERE clauses' + ) + assert.throws( + () => User.query().orderBy([{ column: 'username', order: 'asc' }]), + 'Cannot use "orderBy" on encrypted column "User.username". Encrypted columns can only be used with equality-based WHERE clauses' + ) + + assert.throws( + () => User.query().whereColumn('email', 'username'), + 'Cannot use "whereColumn" on encrypted column "User.email". Encrypted columns can only be used with equality-based WHERE clauses' + ) + assert.throws( + () => User.query().whereColumn('id', 'email'), + 'Cannot use "whereColumn" on encrypted column "User.email". Encrypted columns can only be used with equality-based WHERE clauses' + ) + assert.throws( + () => User.query().orWhereColumn('id', 'username'), + 'Cannot use "orWhereColumn" on encrypted column "User.username". Encrypted columns can only be used with equality-based WHERE clauses' + ) + assert.throws( + () => User.query().whereNotColumn('email', 'id'), + 'Cannot use "whereNotColumn" on encrypted column "User.email". Encrypted columns can only be used with equality-based WHERE clauses' + ) + assert.throws( + () => User.query().orWhereNotColumn('id', 'username'), + 'Cannot use "orWhereNotColumn" on encrypted column "User.username". Encrypted columns can only be used with equality-based WHERE clauses' + ) + + assert.throws( + () => User.query().andWhereLike('email', '%virk%'), + 'Cannot use "whereLike" on encrypted column "User.email". Encrypted columns can only be used with equality-based WHERE clauses' + ) + assert.throws( + () => User.query().andWhereBetween('username', ['a', 'z']), + 'Cannot use "whereBetween" on encrypted column "User.username". Encrypted columns can only be used with equality-based WHERE clauses' + ) + }) + + test('encrypt encrypted column values when performing updates', async ({ fs, assert }) => { + const app = new AppFactory().create(fs.baseUrl, () => {}) + await app.init() + const db = getDb() + const adapter = ormAdapter(db) + const BaseModel = getBaseModel(adapter) + + BaseModel.useEncryption({ + provider: { + encrypt: (value, options) => `enc:${options?.driver || 'default'}:${value}`, + decrypt: (value) => String(value).replace(/^enc:/, ''), + blindIndex: (value, { purpose, driver }) => + `blind:${driver || 'default'}:${purpose}:${value}`, + blindIndexes: () => ({}), + }, + }) + + class User extends BaseModel { + @column({ isPrimary: true }) + declare id: number + + @column() + declare username: string + + @column.encrypted({ driver: 'enc-v1' }) + declare email: string + } + + await db + .insertQuery() + .table('users') + .insert([{ username: 'virk' }]) + + await User.query().where('username', 'virk').update({ email: 'virk@adonisjs.com' }) + + const user = await db.from('users').where('username', 'virk').first() + assert.equal(user!.email, 'enc:enc-v1:virk@adonisjs.com') + }) + + test('encrypt key/value updates with returning for encrypted columns', async ({ fs, assert }) => { + const app = new AppFactory().create(fs.baseUrl, () => {}) + await app.init() + const db = getDb() + const adapter = ormAdapter(db) + const BaseModel = getBaseModel(adapter) + + BaseModel.useEncryption({ + provider: { + encrypt: (value, options) => `enc:${options?.driver || 'default'}:${value}`, + decrypt: (value) => String(value).replace(/^enc:/, ''), + blindIndex: (value, { purpose, driver }) => + `blind:${driver || 'default'}:${purpose}:${value}`, + blindIndexes: () => ({}), + }, + }) + + class User extends BaseModel { + @column({ isPrimary: true }) + declare id: number + + @column.encrypted({ driver: 'enc-v1' }) + declare email: string + } + + const { sql, bindings } = User.query() + .where('id', 1) + .update('email', 'virk@adonisjs.com', ['id']) + .toSQL() + + const { sql: knexSql, bindings: knexBindings } = db + .connection() + .getWriteClient() + .from('users') + .where('id', 1) + .update('email', 'enc:enc-v1:virk@adonisjs.com', ['id']) + .toSQL() + + assert.equal(sql, knexSql) + assert.deepEqual(bindings, knexBindings) + }) + + test('sync blind index when updating blind encrypted columns', async ({ fs, assert }) => { + const app = new AppFactory().create(fs.baseUrl, () => {}) + await app.init() + const db = getDb() + const adapter = ormAdapter(db) + const BaseModel = getBaseModel(adapter) + + BaseModel.useEncryption({ + provider: { + encrypt: (value, options) => `enc:${options?.driver || 'default'}:${value}`, + decrypt: (value) => String(value).replace(/^enc:/, ''), + blindIndex: (value, { purpose, driver }) => + `blind:${driver || 'default'}:${purpose}:${value}`, + blindIndexes: () => ({}), + }, + }) + + class User extends BaseModel { + @column({ isPrimary: true }) + declare id: number + + @column.encrypted({ + driver: 'enc-v1', + blind: { columnName: 'email_blind', purpose: 'users:email' }, + }) + declare email: string + } + + const { sql, bindings } = User.query() + .where('id', 1) + .update({ email: 'virk@adonisjs.com' }) + .toSQL() + + const { sql: knexSql, bindings: knexBindings } = db + .connection() + .getWriteClient() + .from('users') + .where('id', 1) + .update({ + email: 'enc:enc-v1:virk@adonisjs.com', + email_blind: 'blind:enc-v1:users:email:virk@adonisjs.com', + }) + .toSQL() + + assert.equal(sql, knexSql) + assert.deepEqual(bindings, knexBindings) + }) + + test('prefer computed blind index over manual blind column updates regardless of key order', async ({ + fs, + assert, + }) => { + const app = new AppFactory().create(fs.baseUrl, () => {}) + await app.init() + const db = getDb() + const adapter = ormAdapter(db) + const BaseModel = getBaseModel(adapter) + + BaseModel.useEncryption({ + provider: { + encrypt: (value, options) => `enc:${options?.driver || 'default'}:${value}`, + decrypt: (value) => String(value).replace(/^enc:/, ''), + blindIndex: (value, { purpose, driver }) => + `blind:${driver || 'default'}:${purpose}:${value}`, + blindIndexes: () => ({}), + }, + }) + + class User extends BaseModel { + @column({ isPrimary: true }) + declare id: number + + @column() + declare username: string + + @column.encrypted({ + driver: 'enc-v1', + blind: { columnName: 'email_blind', purpose: 'users_encrypted:email' }, + }) + declare email: string + } + User.table = 'users_encrypted' + + await db + .insertQuery() + .table('users_encrypted') + .insert([{ username: 'virk' }]) + + await User.query().where('username', 'virk').update({ + email_blind: 'manual:first', + email: 'virk@adonisjs.com', + }) + + let user = await db.from('users_encrypted').where('username', 'virk').first() + assert.equal(user!.email, 'enc:enc-v1:virk@adonisjs.com') + assert.equal(user!.email_blind, 'blind:enc-v1:users_encrypted:email:virk@adonisjs.com') + + await User.query().where('username', 'virk').update({ + email: 'nikk@adonisjs.com', + email_blind: 'manual:second', + }) + + user = await db.from('users_encrypted').where('username', 'virk').first() + assert.equal(user!.email, 'enc:enc-v1:nikk@adonisjs.com') + assert.equal(user!.email_blind, 'blind:enc-v1:users_encrypted:email:nikk@adonisjs.com') + }) + test('pass custom connection to the model instance', async ({ fs, assert }) => { const app = new AppFactory().create(fs.baseUrl, () => {}) await app.init() @@ -240,6 +1242,71 @@ test.group('Model query builder', (group) => { assert.equal(user!.points, 2) }) + test('raise when using increment/decrement on encrypted columns', async ({ fs, assert }) => { + const app = new AppFactory().create(fs.baseUrl, () => {}) + await app.init() + const db = getDb() + const adapter = ormAdapter(db) + const BaseModel = getBaseModel(adapter) + + BaseModel.useEncryption({ + provider: { + encrypt: (value, options) => + options?.deterministic + ? `det:${options?.driver || 'default'}:${value}` + : `enc:${options?.driver || 'default'}:${value}`, + decrypt: (value) => String(value).replace(/^(enc:|det:)/, ''), + blindIndex: (value, { purpose, driver }) => + `blind:${driver || 'default'}:${purpose}:${value}`, + blindIndexes: () => ({}), + }, + }) + + class User extends BaseModel { + @column({ isPrimary: true }) + declare id: number + + @column.encrypted() + declare points: number + + @column.encrypted({ deterministic: true }) + declare score: number + + @column.encrypted({ + blind: { + columnName: 'tokens_blind', + purpose: 'users:tokens', + }, + }) + declare tokens: string + } + + assert.throws( + () => User.query().increment('points', 1), + 'Cannot use "increment" on encrypted column "User.points". Encrypted columns can only be used with equality-based WHERE clauses' + ) + assert.throws( + () => User.query().decrement('score', 1), + 'Cannot use "decrement" on encrypted column "User.score". Encrypted columns can only be used with equality-based WHERE clauses' + ) + assert.throws( + () => User.query().increment('tokens', 1), + 'Cannot use "increment" on encrypted column "User.tokens". Encrypted columns can only be used with equality-based WHERE clauses' + ) + assert.throws( + () => User.query().increment({ points: 1 }), + 'Cannot use "increment" on encrypted column "User.points". Encrypted columns can only be used with equality-based WHERE clauses' + ) + assert.throws( + () => User.query().decrement({ score: 1 }), + 'Cannot use "decrement" on encrypted column "User.score". Encrypted columns can only be used with equality-based WHERE clauses' + ) + assert.throws( + () => User.query().increment({ tokens: 1 }), + 'Cannot use "increment" on encrypted column "User.tokens". Encrypted columns can only be used with equality-based WHERE clauses' + ) + }) + test('delete in bulk', async ({ fs, assert }) => { const app = new AppFactory().create(fs.baseUrl, () => {}) await app.init()