Skip to content

Commit 2b63ee1

Browse files
committed
feat(protect-dynamodb): support protect nested schemas
1 parent 3e2b70e commit 2b63ee1

File tree

4 files changed

+182
-41
lines changed

4 files changed

+182
-41
lines changed

.changeset/eighty-items-smile.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@cipherstash/protect-dynamodb": minor
3+
---
4+
5+
Support nested protect schema in dynamodb helper functions.

packages/protect-dynamodb/__tests__/dynamodb.test.ts

Lines changed: 49 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,19 @@
11
import 'dotenv/config'
22
import { describe, expect, it, beforeAll } from 'vitest'
33
import { protectDynamoDB } from '../src'
4-
import { protect, csColumn, csTable } from '@cipherstash/protect'
4+
import { protect, csColumn, csTable, csValue } from '@cipherstash/protect'
55

66
const schema = csTable('dynamo_cipherstash_test', {
77
email: csColumn('email').equality(),
88
firstName: csColumn('firstName').equality(),
99
lastName: csColumn('lastName').equality(),
1010
phoneNumber: csColumn('phoneNumber'),
11+
example: {
12+
protected: csValue('example.protected'),
13+
deep: {
14+
protected: csValue('example.deep.protected'),
15+
},
16+
},
1117
})
1218

1319
describe('protect dynamodb helpers', () => {
@@ -36,6 +42,14 @@ describe('protect dynamodb helpers', () => {
3642
companyName: 'Acme Corp',
3743
batteryBrands: ['Brand1', 'Brand2'],
3844
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+
},
3953
}
4054

4155
const result = await protectDynamo.encryptModel(testData, schema)
@@ -53,13 +67,18 @@ describe('protect dynamodb helpers', () => {
5367
expect(encryptedData).toHaveProperty('lastName__source')
5468
expect(encryptedData).toHaveProperty('lastName__hmac')
5569
expect(encryptedData).toHaveProperty('phoneNumber__source')
70+
expect(encryptedData).not.toHaveProperty('phoneNumber__hmac')
71+
expect(encryptedData.example).toHaveProperty('protected__source')
72+
expect(encryptedData.example.deep).toHaveProperty('protected__source')
5673

5774
// Verify other fields remain unchanged
5875
expect(encryptedData.id).toBe('01ABCDEFGHIJKLMNOPQRSTUVWX')
5976
expect(encryptedData.address).toBe('123 Main Street')
6077
expect(encryptedData.createdAt).toBe('2024-08-15T22:14:49.948Z')
6178
expect(encryptedData.companyName).toBe('Acme Corp')
6279
expect(encryptedData.batteryBrands).toEqual(['Brand1', 'Brand2'])
80+
expect(encryptedData.example.notProtected).toBe('I am not protected')
81+
expect(encryptedData.example.deep.notProtected).toBe('deep not protected')
6382
expect(encryptedData.metadata).toEqual({ role: 'admin' })
6483
})
6584

@@ -71,6 +90,14 @@ describe('protect dynamodb helpers', () => {
7190
lastName: 'Smith',
7291
phoneNumber: null,
7392
metadata: { role: null },
93+
example: {
94+
protected: null,
95+
notProtected: 'I am not protected',
96+
deep: {
97+
protected: undefined,
98+
notProtected: 'deep not protected',
99+
},
100+
},
74101
}
75102

76103
const result = await protectDynamo.encryptModel(testData, schema)
@@ -90,6 +117,9 @@ describe('protect dynamodb helpers', () => {
90117
expect(encryptedData.email).toBeNull()
91118
expect(encryptedData.firstName).toBeUndefined()
92119
expect(encryptedData.metadata).toEqual({ role: null })
120+
expect(encryptedData.example.protected).toBeNull()
121+
expect(encryptedData.example.deep.protected).toBeUndefined()
122+
expect(encryptedData.example.deep.notProtected).toBe('deep not protected')
93123
})
94124

95125
it('should handle empty strings and special characters', async () => {
@@ -117,6 +147,7 @@ describe('protect dynamodb helpers', () => {
117147
expect(encryptedData).toHaveProperty('lastName__source')
118148
expect(encryptedData).toHaveProperty('lastName__hmac')
119149
expect(encryptedData).toHaveProperty('phoneNumber__source')
150+
expect(encryptedData).not.toHaveProperty('phoneNumber__hmac')
120151

121152
// Verify other fields remain unchanged
122153
expect(encryptedData.id).toBe('01ABCDEFGHIJKLMNOPQRSTUVWX')
@@ -177,10 +208,19 @@ describe('protect dynamodb helpers', () => {
177208
firstName: 'John',
178209
lastName: 'Smith',
179210
phoneNumber: '555-555-5555',
211+
example: {
212+
protected: 'hello world',
213+
notProtected: 'I am not protected',
214+
deep: {
215+
protected: 'deep protected',
216+
notProtected: 'deep not protected',
217+
},
218+
},
180219
}
181220

182221
// First encrypt
183222
const encryptResult = await protectDynamo.encryptModel(originalData, schema)
223+
184224
if (encryptResult.failure) {
185225
throw new Error(`Encryption failed: ${encryptResult.failure.message}`)
186226
}
@@ -215,6 +255,14 @@ describe('protect dynamodb helpers', () => {
215255
firstName: 'Jane',
216256
lastName: 'Doe',
217257
phoneNumber: '555-555-5556',
258+
example: {
259+
protected: 'hello world',
260+
notProtected: 'I am not protected',
261+
deep: {
262+
protected: 'deep protected',
263+
notProtected: 'deep not protected',
264+
},
265+
},
218266
},
219267
]
220268

packages/protect-dynamodb/src/index.ts

Lines changed: 124 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -21,28 +21,78 @@ class ProtectDynamoDBErrorImpl extends Error implements ProtectDynamoDBError {
2121
}
2222
}
2323

24+
function deepClone<T>(obj: T): T {
25+
if (obj === null || typeof obj !== 'object') {
26+
return obj
27+
}
28+
29+
if (Array.isArray(obj)) {
30+
return obj.map((item) => deepClone(item)) as unknown as T
31+
}
32+
33+
return Object.entries(obj as Record<string, unknown>).reduce(
34+
(acc, [key, value]) => ({
35+
// biome-ignore lint/performance/noAccumulatingSpread: TODO later
36+
...acc,
37+
[key]: deepClone(value),
38+
}),
39+
{} as T,
40+
)
41+
}
42+
2443
function toEncryptedDynamoItem(
2544
encrypted: Record<string, unknown>,
2645
encryptedAttrs: string[],
2746
): Record<string, unknown> {
28-
return Object.entries(encrypted).reduce(
29-
(putItem, [attrName, attrValue]) => {
30-
if (encryptedAttrs.includes(attrName)) {
31-
if (attrValue === null || attrValue === undefined) {
32-
putItem[attrName] = attrValue
33-
} else {
34-
const encryptPayload = attrValue as EncryptedPayload
35-
if (encryptPayload?.c) {
36-
if (encryptPayload.hm) {
37-
putItem[`${attrName}${searchTermAttrSuffix}`] = encryptPayload.hm
38-
}
39-
putItem[`${attrName}${ciphertextAttrSuffix}`] = encryptPayload.c
40-
}
47+
function processValue(
48+
attrName: string,
49+
attrValue: unknown,
50+
isNested: boolean,
51+
): Record<string, unknown> {
52+
if (attrValue === null || attrValue === undefined) {
53+
return { [attrName]: attrValue }
54+
}
55+
56+
// Handle encrypted payload
57+
if (
58+
encryptedAttrs.includes(attrName) ||
59+
(isNested &&
60+
typeof attrValue === 'object' &&
61+
'c' in (attrValue as object))
62+
) {
63+
const encryptPayload = attrValue as EncryptedPayload
64+
if (encryptPayload?.c) {
65+
const result: Record<string, unknown> = {}
66+
if (encryptPayload.hm) {
67+
result[`${attrName}${searchTermAttrSuffix}`] = encryptPayload.hm
4168
}
42-
} else {
43-
putItem[attrName] = attrValue
69+
result[`${attrName}${ciphertextAttrSuffix}`] = encryptPayload.c
70+
return result
4471
}
45-
return putItem
72+
}
73+
74+
// Handle nested objects recursively
75+
if (typeof attrValue === 'object' && !Array.isArray(attrValue)) {
76+
const nestedResult = Object.entries(
77+
attrValue as Record<string, unknown>,
78+
).reduce(
79+
(acc, [key, val]) => {
80+
const processed = processValue(key, val, true)
81+
return Object.assign({}, acc, processed)
82+
},
83+
{} as Record<string, unknown>,
84+
)
85+
return { [attrName]: nestedResult }
86+
}
87+
88+
// Handle non-encrypted values
89+
return { [attrName]: attrValue }
90+
}
91+
92+
return Object.entries(encrypted).reduce(
93+
(putItem, [attrName, attrValue]) => {
94+
const processed = processValue(attrName, attrValue, false)
95+
return Object.assign({}, putItem, processed)
4696
},
4797
{} as Record<string, unknown>,
4898
)
@@ -52,27 +102,64 @@ function toItemWithEqlPayloads(
52102
decrypted: Record<string, EncryptedPayload | unknown>,
53103
encryptedAttrs: string[],
54104
): Record<string, unknown> {
55-
return Object.entries(decrypted).reduce(
56-
(formattedItem, [attrName, attrValue]) => {
57-
if (
58-
attrName.endsWith(ciphertextAttrSuffix) &&
59-
encryptedAttrs.includes(attrName.slice(0, -ciphertextAttrSuffix.length))
60-
) {
61-
formattedItem[attrName.slice(0, -ciphertextAttrSuffix.length)] = {
105+
function processValue(
106+
attrName: string,
107+
attrValue: unknown,
108+
isNested: boolean,
109+
): Record<string, unknown> {
110+
if (attrValue === null || attrValue === undefined) {
111+
return { [attrName]: attrValue }
112+
}
113+
114+
// Skip HMAC fields
115+
if (attrName.endsWith(searchTermAttrSuffix)) {
116+
return {}
117+
}
118+
119+
// Handle encrypted payload
120+
if (
121+
attrName.endsWith(ciphertextAttrSuffix) &&
122+
(encryptedAttrs.includes(
123+
attrName.slice(0, -ciphertextAttrSuffix.length),
124+
) ||
125+
isNested)
126+
) {
127+
const baseName = attrName.slice(0, -ciphertextAttrSuffix.length)
128+
return {
129+
[baseName]: {
62130
c: attrValue,
63131
bf: null,
64132
hm: null,
65133
i: { c: 'notUsed', t: 'notUsed' },
66134
k: 'notUsed',
67135
ob: null,
68136
v: 2,
69-
}
70-
} else if (attrName.endsWith(searchTermAttrSuffix)) {
71-
// skip HMAC attrs since we don't need those for decryption
72-
} else {
73-
formattedItem[attrName] = attrValue
137+
},
74138
}
75-
return formattedItem
139+
}
140+
141+
// Handle nested objects recursively
142+
if (typeof attrValue === 'object' && !Array.isArray(attrValue)) {
143+
const nestedResult = Object.entries(
144+
attrValue as Record<string, unknown>,
145+
).reduce(
146+
(acc, [key, val]) => {
147+
const processed = processValue(key, val, true)
148+
return Object.assign({}, acc, processed)
149+
},
150+
{} as Record<string, unknown>,
151+
)
152+
return { [attrName]: nestedResult }
153+
}
154+
155+
// Handle non-encrypted values
156+
return { [attrName]: attrValue }
157+
}
158+
159+
return Object.entries(decrypted).reduce(
160+
(formattedItem, [attrName, attrValue]) => {
161+
const processed = processValue(attrName, attrValue, false)
162+
return Object.assign({}, formattedItem, processed)
76163
},
77164
{} as Record<string, unknown>,
78165
)
@@ -110,7 +197,7 @@ export function protectDynamoDB(
110197
return await withResult(
111198
async () => {
112199
const encryptResult = await protectClient.encryptModel(
113-
item,
200+
deepClone(item),
114201
protectTable,
115202
)
116203

@@ -120,10 +207,10 @@ export function protectDynamoDB(
120207
)
121208
}
122209

123-
const data = encryptResult.data
210+
const data = deepClone(encryptResult.data)
124211
const encryptedAttrs = Object.keys(protectTable.build().columns)
125212

126-
return toEncryptedDynamoItem(data, encryptedAttrs)
213+
return toEncryptedDynamoItem(data, encryptedAttrs) as T
127214
},
128215
(error) => handleError(error, 'encryptModel'),
129216
)
@@ -136,7 +223,7 @@ export function protectDynamoDB(
136223
return await withResult(
137224
async () => {
138225
const encryptResult = await protectClient.bulkEncryptModels(
139-
items,
226+
items.map((item) => deepClone(item)),
140227
protectTable,
141228
)
142229

@@ -146,11 +233,12 @@ export function protectDynamoDB(
146233
)
147234
}
148235

149-
const data = encryptResult.data
236+
const data = encryptResult.data.map((item) => deepClone(item))
150237
const encryptedAttrs = Object.keys(protectTable.build().columns)
151238

152-
return data.map((encrypted) =>
153-
toEncryptedDynamoItem(encrypted, encryptedAttrs),
239+
return data.map(
240+
(encrypted) =>
241+
toEncryptedDynamoItem(encrypted, encryptedAttrs) as T,
154242
)
155243
},
156244
(error) => handleError(error, 'bulkEncryptModels'),

packages/protect-dynamodb/src/types.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -27,20 +27,20 @@ export interface ProtectDynamoDBInstance {
2727
encryptModel<T extends Record<string, unknown>>(
2828
item: T,
2929
protectTable: ProtectTable<ProtectTableColumn>,
30-
): Promise<Result<Record<string, unknown>, ProtectDynamoDBError>>
30+
): Promise<Result<T, ProtectDynamoDBError>>
3131

3232
bulkEncryptModels<T extends Record<string, unknown>>(
3333
items: T[],
3434
protectTable: ProtectTable<ProtectTableColumn>,
35-
): Promise<Result<Record<string, unknown>[], ProtectDynamoDBError>>
35+
): Promise<Result<T[], ProtectDynamoDBError>>
3636

3737
decryptModel<T extends Record<string, unknown>>(
38-
item: Record<string, EncryptedPayload | unknown>,
38+
item: T,
3939
protectTable: ProtectTable<ProtectTableColumn>,
4040
): Promise<Result<Decrypted<T>, ProtectDynamoDBError>>
4141

4242
bulkDecryptModels<T extends Record<string, unknown>>(
43-
items: Record<string, EncryptedPayload | unknown>[],
43+
items: T[],
4444
protectTable: ProtectTable<ProtectTableColumn>,
4545
): Promise<Result<Decrypted<T>[], ProtectDynamoDBError>>
4646

0 commit comments

Comments
 (0)