Skip to content

Commit

Permalink
feat: Provide Prisma client extension
Browse files Browse the repository at this point in the history
Prisma client 4.16.0 deprecates middlewares
and the `$use` method, and favours client extensions instead.

There's a little bit of plumbing needed to use
the same logic across both interfaces, and
the extension could probably be refined to
only handle models with encrypted fields,
but this first draft seems to work.

Closes #63.
  • Loading branch information
franky47 committed Jul 23, 2023
1 parent c5c07c4 commit 9b9b34a
Show file tree
Hide file tree
Showing 5 changed files with 131 additions and 82 deletions.
15 changes: 13 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
name: Continuous Integration

env:
FORCE_COLOR: 3

on:
push:
branches:
Expand Down Expand Up @@ -30,8 +33,16 @@ jobs:
${{ runner.os }}-yarn-
- run: yarn install
name: Install dependencies
- run: yarn ci
name: Run integration tests
- name: Run integration tests
run: |
yarn build
yarn test:types
yarn test:unit
USE_MIDDLEWARE=1 yarn test:integration
USE_EXTENSIONS=1 yarn test:integration
yarn test:coverage:merge
yarn test:coverage:report
- uses: coverallsapp/github-action@c7885c00cb7ec0b8f9f5ff3f53cddb980f7a4412
name: Report code coverage
continue-on-error: true
Expand Down
63 changes: 63 additions & 0 deletions src/extension.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import { debug } from './debugger'
import { analyseDMMF } from './dmmf'
import { configureKeys, decryptOnRead, encryptOnWrite } from './encryption'
import { Configuration, MiddlewareParams } from './types'

type AllOperationsArgs = {
model?: string
operation: string
args: any
query: (args: any) => Promise<any>
}

type CustomQuery = {
$allOperations(args: AllOperationsArgs): Promise<any>
}

export function fieldEncryptionExtension<
Models extends string = any,
Actions extends string = any
>(config: Configuration = {}) {
const keys = configureKeys(config)
debug.setup('Keys: %O', keys)
const models = analyseDMMF(
config.dmmf ?? require('@prisma/client').Prisma.dmmf
)
debug.setup('Models: %O', models)

return {
name: 'prisma-field-encryption',
query: {
$allModels: {
async $allOperations({
model,
operation,
args,
query
}: AllOperationsArgs) {
if (!model) {
// Unsupported operation
debug.runtime('Unsupported operation (missing model): %O', args)
return await query(args)
}
const params: MiddlewareParams<Models, Actions> = {
args,
model: model as Models,
action: operation as Actions,
dataPath: [],
runInTransaction: false
}
const encryptedParams = encryptOnWrite(
params,
keys,
models,
operation
)
let result = await query(encryptedParams.args)
decryptOnRead(encryptedParams, result, keys, models, operation)
return result
}
}
}
}
}
38 changes: 2 additions & 36 deletions src/index.ts
Original file line number Diff line number Diff line change
@@ -1,36 +1,2 @@
import { debug } from './debugger'
import { analyseDMMF } from './dmmf'
import { configureKeys, decryptOnRead, encryptOnWrite } from './encryption'
import type { Configuration, Middleware, MiddlewareParams } from './types'

export function fieldEncryptionMiddleware<
Models extends string = any,
Actions extends string = any
>(config: Configuration = {}): Middleware<Models, Actions> {
// This will throw if the encryption key is missing
// or if anything is invalid.
const keys = configureKeys(config)
debug.setup('Keys: %O', keys)
const models = analyseDMMF(
config.dmmf ?? require('@prisma/client').Prisma.dmmf
)
debug.setup('Models: %O', models)

return async function fieldEncryptionMiddleware(
params: MiddlewareParams<Models, Actions>,
next: (params: MiddlewareParams<Models, Actions>) => Promise<any>
) {
if (!params.model) {
// Unsupported operation
debug.runtime('Unsupported operation (missing model): %O', params)
return await next(params)
}
const operation = `${params.model}.${params.action}`
// Params are mutated in-place for modifications to occur.
// See https://github.com/prisma/prisma/issues/9522
const encryptedParams = encryptOnWrite(params, keys, models, operation)
let result = await next(encryptedParams)
decryptOnRead(encryptedParams, result, keys, models, operation)
return result
}
}
export { fieldEncryptionExtension } from './extension' // Prisma >= 4.16.0
export { fieldEncryptionMiddleware } from './middleware' // Prisma >= 3.8
36 changes: 36 additions & 0 deletions src/middleware.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import { debug } from './debugger'
import { analyseDMMF } from './dmmf'
import { configureKeys, decryptOnRead, encryptOnWrite } from './encryption'
import type { Configuration, Middleware, MiddlewareParams } from './types'

export function fieldEncryptionMiddleware<
Models extends string = any,
Actions extends string = any
>(config: Configuration = {}): Middleware<Models, Actions> {
// This will throw if the encryption key is missing
// or if anything is invalid.
const keys = configureKeys(config)
debug.setup('Keys: %O', keys)
const models = analyseDMMF(
config.dmmf ?? require('@prisma/client').Prisma.dmmf
)
debug.setup('Models: %O', models)

return async function fieldEncryptionMiddleware(
params: MiddlewareParams<Models, Actions>,
next: (params: MiddlewareParams<Models, Actions>) => Promise<any>
) {
if (!params.model) {
// Unsupported operation
debug.runtime('Unsupported operation (missing model): %O', params)
return await next(params)
}
const operation = `${params.model}.${params.action}`
// Params are mutated in-place for modifications to occur.
// See https://github.com/prisma/prisma/issues/9522
const encryptedParams = encryptOnWrite(params, keys, models, operation)
let result = await next(encryptedParams)
decryptOnRead(encryptedParams, result, keys, models, operation)
return result
}
}
61 changes: 17 additions & 44 deletions src/tests/prismaClient.ts
Original file line number Diff line number Diff line change
@@ -1,53 +1,26 @@
import { fieldEncryptionMiddleware } from '../index'
import { fieldEncryptionExtension, fieldEncryptionMiddleware } from '../index'
import { Configuration } from '../types'
import { Prisma, PrismaClient } from './.generated/client'

const TEST_ENCRYPTION_KEY =
'k1.aesgcm256.__________________________________________8='

export const logger =
process.env.PRISMA_FIELD_ENCRYPTION_LOG === 'true'
? console
: {
log: (_args: any) => {},
info: (_args: any) => {},
dir: (_args: any) => {},
error: console.error, // Still log errors
warn: console.warn // and warnings
}
const config: Configuration = {
encryptionKey: TEST_ENCRYPTION_KEY,
dmmf: Prisma.dmmf
}

export const client = new PrismaClient()
const useMiddleware = Boolean(process.env.USE_MIDDLEWARE)
const useExtensions = Boolean(process.env.USE_EXTENSIONS)

client.$use(async (params, next) => {
const operation = `${params.model}.${params.action}`
logger.dir(
{ '👀': `${operation}: before encryption`, params },
{ depth: null }
)
const result = await next(params)
logger.dir(
{ '👀': `${operation}: after decryption`, result },
{ depth: null }
)
return result
})
const globalClient = new PrismaClient()

client.$use(
fieldEncryptionMiddleware({
encryptionKey: TEST_ENCRYPTION_KEY,
dmmf: Prisma.dmmf
})
)
if (useMiddleware) {
globalClient.$use(fieldEncryptionMiddleware(config))
}

client.$use(async (params, next) => {
const operation = `${params.model}.${params.action}`
logger.dir(
{ '👀': `${operation}: sent to database`, params },
{ depth: null }
)
const result = await next(params)
logger.dir(
{ '👀': `${operation}: received from database`, result },
{ depth: null }
)
return result
})
const extendedClient = globalClient.$extends(
fieldEncryptionExtension(config)
) as PrismaClient // <- Type annotation needed for internals only

export const client = useExtensions ? extendedClient : globalClient

0 comments on commit 9b9b34a

Please sign in to comment.