Skip to content

feat(orm): add encrypted column decorator with deterministic and blind indexes#1143

Open
RomainLanz wants to merge 32 commits into22.xfrom
feat/encryption
Open

feat(orm): add encrypted column decorator with deterministic and blind indexes#1143
RomainLanz wants to merge 32 commits into22.xfrom
feat/encryption

Conversation

@RomainLanz
Copy link
Copy Markdown
Member

@RomainLanz RomainLanz commented Feb 7, 2026

Hey there! 👋🏻
This PR adds encrypted columns to Lucid via a new @column.encrypted(...) decorator with three modes: standard, deterministic, and blind index, plus per-column driver selection.

  • @column.encrypted({ deterministic: true, driver }) keeps encrypted persistence and enables equality queries by rewriting query values with deterministic encryption on the selected driver.
  • @column.encrypted({ blind: { columnName, purpose }, driver }) stores the main value with standard encryption and writes a blind index to another column, then rewrites equality queries to target that blind column.
  • blind.purpose is required.
  • blind.columnName and blind.purpose are normalized (trim) when stored in metadata.

Lucid relies on an injectable ModelEncryptionContract (encrypt, decrypt, blindIndex, blindIndexes) bound via BaseModel.useEncryption(...).

If encrypted columns are used without an encryption provider, Lucid throws a dedicated error.

The encryption provider is dynamically inherited, so models booted before BaseModel.useEncryption(...) still pick it up later.

Query rewriting is implemented for equality-style WHERE methods on deterministic/blind encrypted columns:

  • where, orWhere, whereNot, orWhereNot
  • whereIn, orWhereIn, whereNotIn, orWhereNotIn
  • object-style clauses like where({ email: 'x' })
  • where('col', 'in', [...]) / where('col', 'not in', [...])

Blind indexes support both blindIndex() and blindIndexes() (for key rotation). When multiple indexes are returned, clauses are automatically rewritten to whereIn/whereNotIn.

Standard encrypted columns are explicitly rejected in where* and whereIn* with a dedicated error, instead of silently generating wrong queries.

Unsupported operations now throw clear errors for encrypted columns, including non-equality and JSON operators (whereLike, whereBetween, whereJson, etc.), column-to-column comparisons (whereColumn, orWhereColumn, whereNotColumn, orWhereNotColumn), ordering/grouping (orderBy, groupBy), and arithmetic updates (increment, decrement) including object payload forms.

Write-path hardening was also added. User.query().where(...).update({...}) and key/value update paths now apply encryption consistently, and blind-index persistence on update is stable regardless of object key order (computed blind values always win over manually provided blind columns).

BaseModel.useEncryption(encryptionProvider)

class User extends BaseModel {
  @column.encrypted()
  declare secret: string

  @column.encrypted({ deterministic: true, driver: 'det-v1' })
  declare ssn: string

  @column.encrypted({
    driver: 'enc-v1',
    blind: { columnName: 'email_bidx', purpose: 'users:email' },
  })
  declare email: string
}
// Deterministic encrypted column
const byDeterministic = await User.query().where('ssn', '211-61-2524')

// Supports `in` and `not in` operator forms too
const manySsn = await User.query().where('ssn', 'in', ['211-61-2524', '111-22-3333'])

// Blind encrypted column (rewritten internally to email_bidx lookups)
const byBlind = await User.query().where('email', 'romain@adonisjs.com')

// Also works with object clauses
const byObjectClause = await User.query().where({ email: 'romain@adonisjs.com' })

// Works with IN queries and blind index rotation
const byManyBlind = await User.query().whereIn('email', [
  'romain@adonisjs.com',
  'virk@adonisjs.com',
])

@RomainLanz RomainLanz requested a review from Copilot February 7, 2026 09:43
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Adds first-class support for encrypted Lucid columns via a new @column.encrypted(...) decorator, including deterministic and blind-index modes, plus query rewriting/validation and a pluggable encryption provider on BaseModel.

Changes:

  • Introduces encrypted column metadata/types and BaseModel.useEncryption() / $getEncryption() provider plumbing
  • Adds @column.encrypted() decorator with standard/deterministic/blind modes and blind-index persistence
  • Rewrites equality-style query methods for encrypted-queryable columns and throws on unsupported operators/methods, with tests

Reviewed changes

Copilot reviewed 9 out of 9 changed files in this pull request and generated 5 comments.

Show a summary per file
File Description
test/orm/model_query_builder.spec.ts Adds tests for deterministic/blind query rewriting and unsupported operator errors
test/orm/base_model.spec.ts Adds tests for encrypt/decrypt, blind-index persistence, and missing-provider/config errors
src/types/model.ts Introduces encryption-related public types and model API surface
src/orm/query_builder/index.ts Implements encrypted-column query rewriting + unsupported-method enforcement
src/orm/main.ts Exports the new encrypted decorator entrypoint
src/orm/decorators/index.ts Wires column.encrypted into the decorators API
src/orm/decorators/encrypted.ts Implements the encrypted column decorator and metadata validation
src/orm/base_model/index.ts Adds encryption provider storage/inheritance and blind-index persistence
src/errors.ts Adds dedicated encryption-related error types/messages

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread src/orm/base_model/index.ts Outdated
Comment thread src/orm/decorators/encrypted.ts Outdated
Comment thread src/orm/query_builder/index.ts Outdated
Comment thread src/types/model.ts
Comment on lines +228 to +232
export type ModelEncryptionContract = {
encrypt(value: any, options?: { deterministic?: boolean; [key: string]: any }): any
decrypt(value: any): any
blindIndexes(value: any, options?: { [key: string]: any }): any
blindIndex(value: any, options: { purpose: string; [key: string]: any }): any
Copy link

Copilot AI Feb 7, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Since this is a public contract type, the pervasive any weakens type safety for consumers and makes it harder to use correctly. Consider swapping to unknown (inputs/outputs) plus Record<string, unknown> for option bags, and/or making the contract generic over ciphertext/plaintext types to provide safer signatures without constraining implementations.

Suggested change
export type ModelEncryptionContract = {
encrypt(value: any, options?: { deterministic?: boolean; [key: string]: any }): any
decrypt(value: any): any
blindIndexes(value: any, options?: { [key: string]: any }): any
blindIndex(value: any, options: { purpose: string; [key: string]: any }): any
export type ModelEncryptionContract<
Plaintext = unknown,
Ciphertext = unknown,
BlindIndex = unknown,
BlindIndexMap = Record<string, unknown>
> = {
encrypt(
value: Plaintext,
options?: { deterministic?: boolean } & Record<string, unknown>
): Ciphertext
decrypt(value: Ciphertext): Plaintext
blindIndexes(
value: Plaintext,
options?: Record<string, unknown>
): BlindIndexMap
blindIndex(
value: Plaintext,
options: { purpose: string } & Record<string, unknown>
): BlindIndex

Copilot uses AI. Check for mistakes.
Comment thread src/orm/query_builder/index.ts
@RomainLanz RomainLanz marked this pull request as draft February 7, 2026 09:48
@RomainLanz RomainLanz requested a review from Copilot February 7, 2026 12:18
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 11 out of 11 changed files in this pull request and generated 5 comments.


💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread src/orm/query_builder/index.ts Outdated
Comment thread src/orm/query_builder/index.ts Outdated
Comment thread src/orm/decorators/encrypted.ts Outdated
Comment thread src/orm/query_builder/index.ts Outdated
Comment thread src/orm/base_model/index.ts Outdated
@RomainLanz RomainLanz requested a review from Copilot February 7, 2026 14:44
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 11 out of 11 changed files in this pull request and generated 4 comments.


💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread src/orm/query_builder/index.ts Outdated
Comment thread src/orm/query_builder/index.ts Outdated
Comment thread src/orm/decorators/encrypted.ts Outdated
Comment thread src/errors.ts Outdated
@RomainLanz RomainLanz marked this pull request as ready for review February 7, 2026 15:06
@RomainLanz
Copy link
Copy Markdown
Member Author

We have some flacky tests that should be fixed in another PR.

I believe this one is ready for review. I hope all use cases have been handled!

@github-actions
Copy link
Copy Markdown

github-actions Bot commented Mar 1, 2026

This pull request has been marked as stale because it has been inactive for more than 21 days. Please reopen if you still intend to submit this pull request

@github-actions github-actions Bot added the Stale label Mar 1, 2026
@github-actions
Copy link
Copy Markdown

github-actions Bot commented Mar 7, 2026

This pull request has been automatically closed because it has been inactive for more than 4 weeks. Please reopen if you still intend to submit this pull request

@github-actions github-actions Bot closed this Mar 7, 2026
@RomainLanz RomainLanz reopened this Apr 23, 2026
@github-actions github-actions Bot removed the Stale label Apr 24, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants