Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
32 commits
Select commit Hold shift + click to select a range
735c958
feat(orm): add encrypted column decorator with deterministic and blin…
RomainLanz Feb 7, 2026
8fcfc7b
feat(orm): add encrypted column decorators with driver support and bl…
RomainLanz Feb 7, 2026
a1e0adc
fix(orm): encrypt ModelQueryBuilder update payloads and sync blind in…
RomainLanz Feb 7, 2026
3de2e12
fix(orm): avoid encrypting joined table columns with matching names
RomainLanz Feb 7, 2026
1959601
fix(orm): harden encrypted column query/write paths and blind-index h…
RomainLanz Feb 7, 2026
832f316
test(db): include users_encrypted in table snapshots and shared test …
RomainLanz Feb 7, 2026
4d10090
test(db): remove table name
RomainLanz Feb 7, 2026
5c97073
fix(orm): support in/not in operators for encrypted where clauses
RomainLanz Feb 7, 2026
9a1a77f
fix(orm): validate blind encrypted metadata in model write path
RomainLanz Feb 7, 2026
122f944
test(orm): cover unsupported encrypted whereBetween and whereJson ope…
RomainLanz Feb 7, 2026
aea389b
fix(orm): harden encrypted where rewriting (in/not in, object, and/or)
RomainLanz Feb 7, 2026
881dfc5
refactor(orm): optimize encrypted update payload preparation in Model…
RomainLanz Feb 7, 2026
b80919b
fix(encrypted): exclude driver from column options passed to $addColumn
RomainLanz Feb 7, 2026
10a37e0
refactor(query-builder): deduplicate encrypted where and whereIn logic
RomainLanz Feb 7, 2026
d68f680
fix(query-builder): rewrite object where variants for blind multi-ind…
RomainLanz Feb 7, 2026
52f202e
fix(query-builder): guard whereColumn comparisons on encrypted columns
RomainLanz Feb 7, 2026
79cc5a0
refactor(base-model): compute blind index writes in deferred single-p…
RomainLanz Feb 7, 2026
58712d7
refactor(query-builder): validate qualified source before encrypted c…
RomainLanz Feb 7, 2026
9ad4551
fix(encrypted-column): pass driver to decrypt during consume
RomainLanz Feb 7, 2026
90fe535
types(encrypted-column): narrow allowed column options
RomainLanz Feb 7, 2026
8aa33b0
test(query-builder): cover encrypted key/value update with returning
RomainLanz Feb 7, 2026
f0dc615
fix(query-builder): reject increment/decrement on encrypted columns
RomainLanz Feb 7, 2026
131629a
test(schema): index blind column on users_encrypted table
RomainLanz Feb 7, 2026
54ea6ce
refactor(query-builder): use named options for encrypted method guards
RomainLanz Feb 7, 2026
79a5a37
fix(query-builder): block object-form increment/decrement on encrypte…
RomainLanz Feb 7, 2026
19a7cb6
fix(base-model): keep encryption provider dynamically inherited after…
RomainLanz Feb 7, 2026
51c3bd0
fix(query-builder): use explicit error for standard encrypted where q…
RomainLanz Feb 7, 2026
3e2df46
fix(encrypted-column): keep driver value as provided and trim only bl…
RomainLanz Feb 7, 2026
2e3d540
chore(errors): reword unsupported encrypted query message for non-whe…
RomainLanz Feb 7, 2026
5d5eca4
refactor(query-builder): simplify encrypted method guard signature
RomainLanz Feb 7, 2026
44fa650
refactor(query-builder): extract encrypted query support into dedicat…
RomainLanz Feb 7, 2026
ec11ddb
refactor(encryption): replace useEncryption API with config defaults …
RomainLanz Feb 7, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions src/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
123 changes: 116 additions & 7 deletions src/orm/base_model/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,9 @@ import {
type ModelKeysContract,
type ModelAssignOptions,
type ModelAdapterOptions,
type EncryptedColumnMode,
type ModelEncryptionConfig,
type ModelEncryptionContract,
type ModelRelationOptions,
type ModelQueryBuilderContract,
type ModelPaginatorContract,
Expand Down Expand Up @@ -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
Expand All @@ -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
*/
Expand Down Expand Up @@ -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
}

/**
Expand Down
143 changes: 143 additions & 0 deletions src/orm/decorators/encrypted.ts
Original file line number Diff line number Diff line change
@@ -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)
},
})
}
}
4 changes: 4 additions & 0 deletions src/orm/decorators/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
type LucidModel,
type HooksDecorator,
type ColumnDecorator,
type EncryptedColumnDecorator,
type ComputedDecorator,
type DateColumnDecorator,
type DateTimeColumnDecorator,
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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
Expand Down
1 change: 1 addition & 0 deletions src/orm/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down
Loading
Loading