Skip to content

Commit e29d376

Browse files
authored
Merge pull request #170 from cipherstash/integrate-audit
feat(protect): audit ffi calls
2 parents a66fba7 + 2701363 commit e29d376

24 files changed

+1025
-164
lines changed

.changeset/two-times-marry.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
"@cipherstash/protect-dynamodb": minor
3+
"@cipherstash/protect": minor
4+
---
5+
6+
Fully implemented audit metadata functionality.
Lines changed: 316 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,316 @@
1+
import 'dotenv/config'
2+
import { describe, expect, it, beforeAll } from 'vitest'
3+
import { protectDynamoDB } from '../src'
4+
import { protect, csColumn, csTable, csValue } from '@cipherstash/protect'
5+
6+
const schema = csTable('dynamo_cipherstash_test', {
7+
email: csColumn('email').equality(),
8+
firstName: csColumn('firstName').equality(),
9+
lastName: csColumn('lastName').equality(),
10+
phoneNumber: csColumn('phoneNumber'),
11+
example: {
12+
protected: csValue('example.protected'),
13+
deep: {
14+
protected: csValue('example.deep.protected'),
15+
},
16+
},
17+
})
18+
19+
describe('protect dynamodb helpers', () => {
20+
let protectClient: Awaited<ReturnType<typeof protect>>
21+
let protectDynamo: ReturnType<typeof protectDynamoDB>
22+
23+
beforeAll(async () => {
24+
protectClient = await protect({
25+
schemas: [schema],
26+
})
27+
28+
protectDynamo = protectDynamoDB({
29+
protectClient,
30+
})
31+
})
32+
33+
it('should encrypt columns', async () => {
34+
const testData = {
35+
id: '01ABCDEFGHIJKLMNOPQRSTUVWX',
36+
email: 'test.user@example.com',
37+
address: '123 Main Street',
38+
createdAt: '2024-08-15T22:14:49.948Z',
39+
firstName: 'John',
40+
lastName: 'Smith',
41+
phoneNumber: '555-555-5555',
42+
companyName: 'Acme Corp',
43+
batteryBrands: ['Brand1', 'Brand2'],
44+
metadata: { role: 'admin' },
45+
example: {
46+
protected: 'hello world',
47+
notProtected: 'I am not protected',
48+
deep: {
49+
protected: 'deep protected',
50+
notProtected: 'deep not protected',
51+
},
52+
},
53+
}
54+
55+
const result = await protectDynamo.encryptModel(testData, schema).audit({
56+
metadata: { sub: 'cj@cjb.io', type: 'dynamo_encrypt_model' },
57+
})
58+
if (result.failure) {
59+
throw new Error(`Encryption failed: ${result.failure.message}`)
60+
}
61+
62+
const encryptedData = result.data
63+
64+
// Verify equality columns are encrypted
65+
expect(encryptedData).toHaveProperty('email__source')
66+
expect(encryptedData).toHaveProperty('email__hmac')
67+
expect(encryptedData).toHaveProperty('firstName__source')
68+
expect(encryptedData).toHaveProperty('firstName__hmac')
69+
expect(encryptedData).toHaveProperty('lastName__source')
70+
expect(encryptedData).toHaveProperty('lastName__hmac')
71+
expect(encryptedData).toHaveProperty('phoneNumber__source')
72+
expect(encryptedData).not.toHaveProperty('phoneNumber__hmac')
73+
expect(encryptedData.example).toHaveProperty('protected__source')
74+
expect(encryptedData.example.deep).toHaveProperty('protected__source')
75+
76+
// Verify other fields remain unchanged
77+
expect(encryptedData.id).toBe('01ABCDEFGHIJKLMNOPQRSTUVWX')
78+
expect(encryptedData.address).toBe('123 Main Street')
79+
expect(encryptedData.createdAt).toBe('2024-08-15T22:14:49.948Z')
80+
expect(encryptedData.companyName).toBe('Acme Corp')
81+
expect(encryptedData.batteryBrands).toEqual(['Brand1', 'Brand2'])
82+
expect(encryptedData.example.notProtected).toBe('I am not protected')
83+
expect(encryptedData.example.deep.notProtected).toBe('deep not protected')
84+
expect(encryptedData.metadata).toEqual({ role: 'admin' })
85+
})
86+
87+
it('should handle null and undefined values', async () => {
88+
const testData = {
89+
id: '01ABCDEFGHIJKLMNOPQRSTUVWX',
90+
email: null,
91+
firstName: undefined,
92+
lastName: 'Smith',
93+
phoneNumber: null,
94+
metadata: { role: null },
95+
example: {
96+
protected: null,
97+
notProtected: 'I am not protected',
98+
deep: {
99+
protected: undefined,
100+
notProtected: 'deep not protected',
101+
},
102+
},
103+
}
104+
105+
const result = await protectDynamo.encryptModel(testData, schema).audit({
106+
metadata: { sub: 'cj@cjb.io', type: 'dynamo_encrypt_model' },
107+
})
108+
if (result.failure) {
109+
throw new Error(`Encryption failed: ${result.failure.message}`)
110+
}
111+
112+
const encryptedData = result.data
113+
114+
// Verify null/undefined equality columns are handled
115+
expect(encryptedData).toHaveProperty('lastName__source')
116+
expect(encryptedData).toHaveProperty('lastName__hmac')
117+
118+
// Verify other fields remain unchanged
119+
expect(encryptedData.id).toBe('01ABCDEFGHIJKLMNOPQRSTUVWX')
120+
expect(encryptedData.phoneNumber).toBeNull()
121+
expect(encryptedData.email).toBeNull()
122+
expect(encryptedData.firstName).toBeUndefined()
123+
expect(encryptedData.metadata).toEqual({ role: null })
124+
expect(encryptedData.example.protected).toBeNull()
125+
expect(encryptedData.example.deep.protected).toBeUndefined()
126+
expect(encryptedData.example.deep.notProtected).toBe('deep not protected')
127+
})
128+
129+
it('should handle empty strings and special characters', async () => {
130+
const testData = {
131+
id: '01ABCDEFGHIJKLMNOPQRSTUVWX',
132+
email: '',
133+
firstName: 'John!@#$%^&*()',
134+
lastName: 'Smith ',
135+
phoneNumber: '',
136+
metadata: { role: 'admin!@#$%^&*()' },
137+
}
138+
139+
const result = await protectDynamo.encryptModel(testData, schema).audit({
140+
metadata: { sub: 'cj@cjb.io', type: 'dynamo_encrypt_model' },
141+
})
142+
if (result.failure) {
143+
throw new Error(`Encryption failed: ${result.failure.message}`)
144+
}
145+
146+
const encryptedData = result.data
147+
148+
// Verify equality columns are encrypted
149+
expect(encryptedData).toHaveProperty('email__source')
150+
expect(encryptedData).toHaveProperty('email__hmac')
151+
expect(encryptedData).toHaveProperty('firstName__source')
152+
expect(encryptedData).toHaveProperty('firstName__hmac')
153+
expect(encryptedData).toHaveProperty('lastName__source')
154+
expect(encryptedData).toHaveProperty('lastName__hmac')
155+
expect(encryptedData).toHaveProperty('phoneNumber__source')
156+
expect(encryptedData).not.toHaveProperty('phoneNumber__hmac')
157+
158+
// Verify other fields remain unchanged
159+
expect(encryptedData.id).toBe('01ABCDEFGHIJKLMNOPQRSTUVWX')
160+
expect(encryptedData.metadata).toEqual({ role: 'admin!@#$%^&*()' })
161+
})
162+
163+
it('should handle bulk encryption', async () => {
164+
const testData = [
165+
{
166+
id: '01ABCDEFGHIJKLMNOPQRSTUVWX',
167+
email: 'test1@example.com',
168+
firstName: 'John',
169+
lastName: 'Smith',
170+
phoneNumber: '555-555-5555',
171+
},
172+
{
173+
id: '02ABCDEFGHIJKLMNOPQRSTUVWX',
174+
email: 'test2@example.com',
175+
firstName: 'Jane',
176+
lastName: 'Doe',
177+
phoneNumber: '555-555-5556',
178+
},
179+
]
180+
181+
const result = await protectDynamo
182+
.bulkEncryptModels(testData, schema)
183+
.audit({
184+
metadata: {
185+
sub: 'cj@cjb.io',
186+
type: 'dynamo_bulk_encrypt_models',
187+
},
188+
})
189+
if (result.failure) {
190+
throw new Error(`Bulk encryption failed: ${result.failure.message}`)
191+
}
192+
193+
const encryptedData = result.data
194+
195+
// Verify both items are encrypted
196+
expect(encryptedData).toHaveLength(2)
197+
198+
// Verify first item
199+
expect(encryptedData[0]).toHaveProperty('email__source')
200+
expect(encryptedData[0]).toHaveProperty('email__hmac')
201+
expect(encryptedData[0]).toHaveProperty('firstName__source')
202+
expect(encryptedData[0]).toHaveProperty('firstName__hmac')
203+
expect(encryptedData[0]).toHaveProperty('lastName__source')
204+
expect(encryptedData[0]).toHaveProperty('lastName__hmac')
205+
expect(encryptedData[0]).toHaveProperty('phoneNumber__source')
206+
207+
// Verify second item
208+
expect(encryptedData[1]).toHaveProperty('email__source')
209+
expect(encryptedData[1]).toHaveProperty('email__hmac')
210+
expect(encryptedData[1]).toHaveProperty('firstName__source')
211+
expect(encryptedData[1]).toHaveProperty('firstName__hmac')
212+
expect(encryptedData[1]).toHaveProperty('lastName__source')
213+
expect(encryptedData[1]).toHaveProperty('lastName__hmac')
214+
expect(encryptedData[1]).toHaveProperty('phoneNumber__source')
215+
})
216+
217+
it('should handle decryption of encrypted data', async () => {
218+
const originalData = {
219+
id: '01ABCDEFGHIJKLMNOPQRSTUVWX',
220+
email: 'test.user@example.com',
221+
firstName: 'John',
222+
lastName: 'Smith',
223+
phoneNumber: '555-555-5555',
224+
example: {
225+
protected: 'hello world',
226+
notProtected: 'I am not protected',
227+
deep: {
228+
protected: 'deep protected',
229+
notProtected: 'deep not protected',
230+
},
231+
},
232+
}
233+
234+
// First encrypt
235+
const encryptResult = await protectDynamo
236+
.encryptModel(originalData, schema)
237+
.audit({
238+
metadata: { sub: 'cj@cjb.io', type: 'dynamo_encrypt_model' },
239+
})
240+
241+
if (encryptResult.failure) {
242+
throw new Error(`Encryption failed: ${encryptResult.failure.message}`)
243+
}
244+
245+
// Then decrypt
246+
const decryptResult = await protectDynamo
247+
.decryptModel(encryptResult.data, schema)
248+
.audit({
249+
metadata: { sub: 'cj@cjb.io', type: 'dynamo_decrypt_model' },
250+
})
251+
if (decryptResult.failure) {
252+
throw new Error(`Decryption failed: ${decryptResult.failure.message}`)
253+
}
254+
255+
const decryptedData = decryptResult.data
256+
257+
// Verify all fields match original data
258+
expect(decryptedData).toEqual(originalData)
259+
})
260+
261+
it('should handle decryption of bulk encrypted data', async () => {
262+
const originalData = [
263+
{
264+
id: '01ABCDEFGHIJKLMNOPQRSTUVWX',
265+
email: 'test1@example.com',
266+
firstName: 'John',
267+
lastName: 'Smith',
268+
phoneNumber: '555-555-5555',
269+
},
270+
{
271+
id: '02ABCDEFGHIJKLMNOPQRSTUVWX',
272+
email: 'test2@example.com',
273+
firstName: 'Jane',
274+
lastName: 'Doe',
275+
phoneNumber: '555-555-5556',
276+
example: {
277+
protected: 'hello world',
278+
notProtected: 'I am not protected',
279+
deep: {
280+
protected: 'deep protected',
281+
notProtected: 'deep not protected',
282+
},
283+
},
284+
},
285+
]
286+
287+
// First encrypt
288+
const encryptResult = await protectDynamo
289+
.bulkEncryptModels(originalData, schema)
290+
.audit({
291+
metadata: { sub: 'cj@cjb.io', type: 'dynamo_bulk_encrypt_models' },
292+
})
293+
if (encryptResult.failure) {
294+
throw new Error(
295+
`Bulk encryption failed: ${encryptResult.failure.message}`,
296+
)
297+
}
298+
299+
// Then decrypt
300+
const decryptResult = await protectDynamo
301+
.bulkDecryptModels(encryptResult.data, schema)
302+
.audit({
303+
metadata: { sub: 'cj@cjb.io', type: 'dynamo_bulk_decrypt_models' },
304+
})
305+
if (decryptResult.failure) {
306+
throw new Error(
307+
`Bulk decryption failed: ${decryptResult.failure.message}`,
308+
)
309+
}
310+
311+
const decryptedData = decryptResult.data
312+
313+
// Verify all items match original data
314+
expect(decryptedData).toEqual(originalData)
315+
})
316+
})

packages/protect-dynamodb/src/operations/base-operation.ts

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,10 @@ export type AuditConfig = {
55
metadata?: Record<string, unknown>
66
}
77

8+
export type AuditData = {
9+
metadata?: Record<string, unknown>
10+
}
11+
812
export type DynamoDBOperationOptions = {
913
logger?: {
1014
error: (message: string, error: Error) => void
@@ -33,8 +37,10 @@ export abstract class DynamoDBOperation<T> {
3337
/**
3438
* Get the audit metadata for this operation.
3539
*/
36-
protected getAuditMetadata(): Record<string, unknown> | undefined {
37-
return this.auditMetadata
40+
protected getAuditData(): AuditData {
41+
return {
42+
metadata: this.auditMetadata,
43+
}
3844
}
3945

4046
/**

packages/protect-dynamodb/src/operations/bulk-decrypt-models.ts

Lines changed: 3 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -42,17 +42,9 @@ export class BulkDecryptModelsOperation<
4242
toItemWithEqlPayloads(item, encryptedAttrs),
4343
)
4444

45-
const operation = this.protectClient.bulkDecryptModels<T>(
46-
itemsWithEqlPayloads as T[],
47-
)
48-
49-
// Apply audit metadata if it exists
50-
const auditMetadata = this.getAuditMetadata()
51-
if (auditMetadata) {
52-
operation.audit({ metadata: auditMetadata })
53-
}
54-
55-
const decryptResult = await operation
45+
const decryptResult = await this.protectClient
46+
.bulkDecryptModels<T>(itemsWithEqlPayloads as T[])
47+
.audit(this.getAuditData())
5648

5749
if (decryptResult.failure) {
5850
throw new Error(`[protect]: ${decryptResult.failure.message}`)

packages/protect-dynamodb/src/operations/bulk-encrypt-models.ts

Lines changed: 6 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -33,18 +33,12 @@ export class BulkEncryptModelsOperation<
3333
public async execute(): Promise<Result<T[], ProtectDynamoDBError>> {
3434
return await withResult(
3535
async () => {
36-
const operation = this.protectClient.bulkEncryptModels(
37-
this.items.map((item) => deepClone(item)),
38-
this.protectTable,
39-
)
40-
41-
// Apply audit metadata if it exists
42-
const auditMetadata = this.getAuditMetadata()
43-
if (auditMetadata) {
44-
operation.audit({ metadata: auditMetadata })
45-
}
46-
47-
const encryptResult = await operation
36+
const encryptResult = await this.protectClient
37+
.bulkEncryptModels(
38+
this.items.map((item) => deepClone(item)),
39+
this.protectTable,
40+
)
41+
.audit(this.getAuditData())
4842

4943
if (encryptResult.failure) {
5044
throw new Error(`encryption error: ${encryptResult.failure.message}`)

0 commit comments

Comments
 (0)