Skip to content

Commit 8486f64

Browse files
authored
feat: improvements to "encryption" enhancement (#1927)
1 parent dcef942 commit 8486f64

File tree

5 files changed

+186
-21
lines changed

5 files changed

+186
-21
lines changed

packages/runtime/src/enhancements/node/create-enhancement.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ import type { PolicyDef } from './types';
2121
/**
2222
* All enhancement kinds
2323
*/
24-
const ALL_ENHANCEMENTS: EnhancementKind[] = ['password', 'omit', 'policy', 'validation', 'delegate', 'encrypted'];
24+
const ALL_ENHANCEMENTS: EnhancementKind[] = ['password', 'omit', 'policy', 'validation', 'delegate', 'encryption'];
2525

2626
/**
2727
* Options for {@link createEnhancement}
@@ -129,7 +129,7 @@ export function createEnhancement<DbClient extends object>(
129129
result = withPassword(result, options);
130130
}
131131

132-
if (hasEncrypted && kinds.includes('encrypted')) {
132+
if (hasEncrypted && kinds.includes('encryption')) {
133133
if (!options.encryption) {
134134
throw new Error('Encryption options are required for @encrypted enhancement');
135135
}

packages/runtime/src/enhancements/node/encrypted.ts

Lines changed: 13 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -9,8 +9,9 @@ import {
99
resolveField,
1010
type PrismaWriteActionType,
1111
} from '../../cross';
12-
import { DbClientContract, CustomEncryption, SimpleEncryption } from '../../types';
12+
import { CustomEncryption, DbClientContract, SimpleEncryption } from '../../types';
1313
import { InternalEnhancementOptions } from './create-enhancement';
14+
import { Logger } from './logger';
1415
import { DefaultPrismaProxyHandler, PrismaProxyActions, makeProxy } from './proxy';
1516
import { QueryUtils } from './query-utils';
1617

@@ -27,28 +28,32 @@ export function withEncrypted<DbClient extends object = any>(
2728
prisma,
2829
options.modelMeta,
2930
(_prisma, model) => new EncryptedHandler(_prisma as DbClientContract, model, options),
30-
'encrypted'
31+
'encryption'
3132
);
3233
}
3334

3435
class EncryptedHandler extends DefaultPrismaProxyHandler {
3536
private queryUtils: QueryUtils;
3637
private encoder = new TextEncoder();
3738
private decoder = new TextDecoder();
39+
private logger: Logger;
3840

3941
constructor(prisma: DbClientContract, model: string, options: InternalEnhancementOptions) {
4042
super(prisma, model, options);
4143

4244
this.queryUtils = new QueryUtils(prisma, options);
45+
this.logger = new Logger(prisma);
4346

44-
if (!options.encryption) throw new Error('Encryption options must be provided');
47+
if (!options.encryption) throw this.queryUtils.unknownError('Encryption options must be provided');
4548

4649
if (this.isCustomEncryption(options.encryption!)) {
4750
if (!options.encryption.encrypt || !options.encryption.decrypt)
48-
throw new Error('Custom encryption must provide encrypt and decrypt functions');
51+
throw this.queryUtils.unknownError('Custom encryption must provide encrypt and decrypt functions');
4952
} else {
50-
if (!options.encryption.encryptionKey) throw new Error('Encryption key must be provided');
51-
if (options.encryption.encryptionKey.length !== 32) throw new Error('Encryption key must be 32 bytes');
53+
if (!options.encryption.encryptionKey)
54+
throw this.queryUtils.unknownError('Encryption key must be provided');
55+
if (options.encryption.encryptionKey.length !== 32)
56+
throw this.queryUtils.unknownError('Encryption key must be 32 bytes');
5257
}
5358
}
5459

@@ -147,7 +152,7 @@ class EncryptedHandler extends DefaultPrismaProxyHandler {
147152
try {
148153
entityData[field] = await this.decrypt(fieldInfo, entityData[field]);
149154
} catch (error) {
150-
console.warn('Decryption failed, keeping original value:', error);
155+
this.logger.warn(`Decryption failed, keeping original value: ${error}`);
151156
}
152157
}
153158
}
@@ -164,7 +169,7 @@ class EncryptedHandler extends DefaultPrismaProxyHandler {
164169
try {
165170
context.parent[field.name] = await this.encrypt(field, data);
166171
} catch (error) {
167-
throw new Error(`Encryption failed for field ${field.name}: ${error}`);
172+
this.queryUtils.unknownError(`Encryption failed for field ${field.name}: ${error}`);
168173
}
169174
}
170175
},

packages/runtime/src/types.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -151,7 +151,7 @@ export type EnhancementContext<User extends AuthUser = AuthUser> = {
151151
/**
152152
* Kinds of enhancements to `PrismaClient`
153153
*/
154-
export type EnhancementKind = 'password' | 'omit' | 'policy' | 'validation' | 'delegate' | 'encrypted';
154+
export type EnhancementKind = 'password' | 'omit' | 'policy' | 'validation' | 'delegate' | 'encryption';
155155

156156
/**
157157
* Function for transforming errors.

packages/schema/src/language-server/validator/attribute-application-validator.ts

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ import {
2525
isRelationshipField,
2626
resolved,
2727
} from '@zenstackhq/sdk';
28-
import { ValidationAcceptor, streamAst } from 'langium';
28+
import { ValidationAcceptor, streamAllContents, streamAst } from 'langium';
2929
import pluralize from 'pluralize';
3030
import { AstValidator } from '../types';
3131
import { getStringLiteral, mapBuiltinTypeToExpressionType, typeAssignable } from './utils';
@@ -138,6 +138,9 @@ export default class AttributeApplicationValidator implements AstValidator<Attri
138138
return;
139139
}
140140
this.validatePolicyKinds(kind, ['create', 'read', 'update', 'delete', 'all'], attr, accept);
141+
142+
// @encrypted fields cannot be used in policy rules
143+
this.rejectEncryptedFields(attr, accept);
141144
}
142145

143146
@check('@allow')
@@ -166,6 +169,9 @@ export default class AttributeApplicationValidator implements AstValidator<Attri
166169
);
167170
}
168171
}
172+
173+
// @encrypted fields cannot be used in policy rules
174+
this.rejectEncryptedFields(attr, accept);
169175
}
170176

171177
@check('@@validate')
@@ -206,6 +212,14 @@ export default class AttributeApplicationValidator implements AstValidator<Attri
206212
}
207213
}
208214

215+
private rejectEncryptedFields(attr: AttributeApplication, accept: ValidationAcceptor) {
216+
streamAllContents(attr).forEach((node) => {
217+
if (isDataModelFieldReference(node) && hasAttribute(node.target.ref as DataModelField, '@encrypted')) {
218+
accept('error', `Encrypted fields cannot be used in policy rules`, { node });
219+
}
220+
});
221+
}
222+
209223
private validatePolicyKinds(
210224
kind: string,
211225
candidates: string[],

tests/integration/tests/enhancements/with-encrypted/with-encrypted.test.ts

Lines changed: 155 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,10 @@
11
import { FieldInfo } from '@zenstackhq/runtime';
2-
import { loadSchema } from '@zenstackhq/testtools';
2+
import { loadSchema, loadModelWithError } from '@zenstackhq/testtools';
33
import path from 'path';
44

55
describe('Encrypted test', () => {
66
let origDir: string;
7+
const encryptionKey = new Uint8Array(Buffer.from('AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8=', 'base64'));
78

89
beforeAll(async () => {
910
origDir = path.resolve('.');
@@ -14,21 +15,25 @@ describe('Encrypted test', () => {
1415
});
1516

1617
it('Simple encryption test', async () => {
17-
const { enhance } = await loadSchema(`
18+
const { enhance, prisma } = await loadSchema(
19+
`
1820
model User {
1921
id String @id @default(cuid())
2022
encrypted_value String @encrypted()
2123
2224
@@allow('all', true)
23-
}`);
25+
}`,
26+
{
27+
enhancements: ['encryption'],
28+
enhanceOptions: {
29+
encryption: { encryptionKey },
30+
},
31+
}
32+
);
2433

2534
const sudoDb = enhance(undefined, { kinds: [] });
26-
const encryptionKey = new Uint8Array(Buffer.from('AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8=', 'base64'));
2735

28-
const db = enhance(undefined, {
29-
kinds: ['encrypted'],
30-
encryption: { encryptionKey },
31-
});
36+
const db = enhance();
3237

3338
const create = await db.user.create({
3439
data: {
@@ -49,9 +54,50 @@ describe('Encrypted test', () => {
4954
},
5055
});
5156

57+
const rawRead = await prisma.user.findUnique({ where: { id: '1' } });
58+
5259
expect(create.encrypted_value).toBe('abc123');
5360
expect(read.encrypted_value).toBe('abc123');
5461
expect(sudoRead.encrypted_value).not.toBe('abc123');
62+
expect(rawRead.encrypted_value).not.toBe('abc123');
63+
});
64+
65+
it('Multi-field encryption test', async () => {
66+
const { enhance } = await loadSchema(
67+
`
68+
model User {
69+
id String @id @default(cuid())
70+
x1 String @encrypted()
71+
x2 String @encrypted()
72+
73+
@@allow('all', true)
74+
}`,
75+
{
76+
enhancements: ['encryption'],
77+
enhanceOptions: {
78+
encryption: { encryptionKey },
79+
},
80+
}
81+
);
82+
83+
const db = enhance();
84+
85+
const create = await db.user.create({
86+
data: {
87+
id: '1',
88+
x1: 'abc123',
89+
x2: '123abc',
90+
},
91+
});
92+
93+
const read = await db.user.findUnique({
94+
where: {
95+
id: '1',
96+
},
97+
});
98+
99+
expect(create).toMatchObject({ x1: 'abc123', x2: '123abc' });
100+
expect(read).toMatchObject({ x1: 'abc123', x2: '123abc' });
55101
});
56102

57103
it('Custom encryption test', async () => {
@@ -65,7 +111,7 @@ describe('Encrypted test', () => {
65111

66112
const sudoDb = enhance(undefined, { kinds: [] });
67113
const db = enhance(undefined, {
68-
kinds: ['encrypted'],
114+
kinds: ['encryption'],
69115
encryption: {
70116
encrypt: async (model: string, field: FieldInfo, data: string) => {
71117
// Add _enc to the end of the input
@@ -105,4 +151,104 @@ describe('Encrypted test', () => {
105151
expect(read.encrypted_value).toBe('abc123');
106152
expect(sudoRead.encrypted_value).toBe('abc123_enc');
107153
});
154+
155+
it('Only supports string fields', async () => {
156+
await expect(
157+
loadModelWithError(
158+
`
159+
model User {
160+
id String @id @default(cuid())
161+
encrypted_value Bytes @encrypted()
162+
}`
163+
)
164+
).resolves.toContain(`attribute \"@encrypted\" cannot be used on this type of field`);
165+
});
166+
167+
it('Returns cipher text when decryption fails', async () => {
168+
const { enhance, enhanceRaw, prisma } = await loadSchema(
169+
`
170+
model User {
171+
id String @id @default(cuid())
172+
encrypted_value String @encrypted()
173+
174+
@@allow('all', true)
175+
}`,
176+
{ enhancements: ['encryption'] }
177+
);
178+
179+
const db = enhance(undefined, {
180+
kinds: ['encryption'],
181+
encryption: { encryptionKey },
182+
});
183+
184+
const create = await db.user.create({
185+
data: {
186+
id: '1',
187+
encrypted_value: 'abc123',
188+
},
189+
});
190+
expect(create.encrypted_value).toBe('abc123');
191+
192+
const db1 = enhanceRaw(prisma, undefined, {
193+
encryption: { encryptionKey: crypto.getRandomValues(new Uint8Array(32)) },
194+
});
195+
const read = await db1.user.findUnique({ where: { id: '1' } });
196+
expect(read.encrypted_value).toBeTruthy();
197+
expect(read.encrypted_value).not.toBe('abc123');
198+
});
199+
200+
it('Works with length validation', async () => {
201+
const { enhance } = await loadSchema(
202+
`
203+
model User {
204+
id String @id @default(cuid())
205+
encrypted_value String @encrypted() @length(0, 6)
206+
207+
@@allow('all', true)
208+
}`,
209+
{
210+
enhanceOptions: { encryption: { encryptionKey } },
211+
}
212+
);
213+
214+
const db = enhance();
215+
216+
const create = await db.user.create({
217+
data: {
218+
id: '1',
219+
encrypted_value: 'abc123',
220+
},
221+
});
222+
expect(create.encrypted_value).toBe('abc123');
223+
224+
await expect(
225+
db.user.create({
226+
data: { id: '2', encrypted_value: 'abc1234' },
227+
})
228+
).toBeRejectedByPolicy();
229+
});
230+
231+
it('Complains when encrypted fields are used in model-level policy rules', async () => {
232+
await expect(
233+
loadModelWithError(`
234+
model User {
235+
id String @id @default(cuid())
236+
encrypted_value String @encrypted()
237+
@@allow('all', encrypted_value != 'abc123')
238+
}
239+
`)
240+
).resolves.toContain(`Encrypted fields cannot be used in policy rules`);
241+
});
242+
243+
it('Complains when encrypted fields are used in field-level policy rules', async () => {
244+
await expect(
245+
loadModelWithError(`
246+
model User {
247+
id String @id @default(cuid())
248+
encrypted_value String @encrypted()
249+
value Int @allow('all', encrypted_value != 'abc123')
250+
}
251+
`)
252+
).resolves.toContain(`Encrypted fields cannot be used in policy rules`);
253+
});
108254
});

0 commit comments

Comments
 (0)