@friggframework/core 2.0.0-next.53 → 2.0.0-next.55
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CLAUDE.md +2 -1
- package/application/commands/credential-commands.js +1 -1
- package/application/commands/integration-commands.js +1 -1
- package/application/index.js +1 -1
- package/core/create-handler.js +12 -0
- package/credential/repositories/credential-repository-documentdb.js +304 -0
- package/credential/repositories/credential-repository-factory.js +8 -1
- package/credential/repositories/credential-repository-mongo.js +16 -54
- package/credential/repositories/credential-repository-postgres.js +14 -41
- package/credential/use-cases/get-credential-for-user.js +7 -3
- package/database/config.js +4 -4
- package/database/documentdb-encryption-service.js +330 -0
- package/database/documentdb-utils.js +136 -0
- package/database/encryption/README.md +50 -1
- package/database/encryption/documentdb-encryption-service.md +3270 -0
- package/database/encryption/encryption-schema-registry.js +46 -0
- package/database/prisma.js +7 -47
- package/database/repositories/health-check-repository-documentdb.js +134 -0
- package/database/repositories/health-check-repository-factory.js +6 -1
- package/database/repositories/health-check-repository-interface.js +29 -34
- package/database/repositories/health-check-repository-mongodb.js +1 -3
- package/database/use-cases/check-database-state-use-case.js +3 -3
- package/database/use-cases/run-database-migration-use-case.js +6 -4
- package/database/use-cases/trigger-database-migration-use-case.js +2 -2
- package/database/utils/mongodb-schema-init.js +5 -5
- package/database/utils/prisma-runner.js +15 -9
- package/errors/client-safe-error.js +26 -0
- package/errors/fetch-error.js +2 -1
- package/errors/index.js +2 -0
- package/generated/prisma-mongodb/edge.js +3 -3
- package/generated/prisma-mongodb/index.d.ts +10 -4
- package/generated/prisma-mongodb/index.js +3 -3
- package/generated/prisma-mongodb/package.json +1 -1
- package/generated/prisma-mongodb/schema.prisma +1 -3
- package/generated/prisma-mongodb/wasm.js +2 -2
- package/generated/prisma-postgresql/edge.js +3 -3
- package/generated/prisma-postgresql/index.d.ts +10 -4
- package/generated/prisma-postgresql/index.js +3 -3
- package/generated/prisma-postgresql/package.json +1 -1
- package/generated/prisma-postgresql/schema.prisma +1 -3
- package/generated/prisma-postgresql/wasm.js +2 -2
- package/handlers/routers/db-migration.js +2 -3
- package/handlers/routers/health.js +0 -3
- package/handlers/workers/db-migration.js +8 -8
- package/integrations/integration-router.js +6 -6
- package/integrations/repositories/integration-mapping-repository-documentdb.js +280 -0
- package/integrations/repositories/integration-mapping-repository-factory.js +8 -1
- package/integrations/repositories/integration-repository-documentdb.js +210 -0
- package/integrations/repositories/integration-repository-factory.js +8 -1
- package/integrations/repositories/process-repository-documentdb.js +243 -0
- package/integrations/repositories/process-repository-factory.js +8 -1
- package/modules/repositories/module-repository-documentdb.js +307 -0
- package/modules/repositories/module-repository-factory.js +8 -1
- package/package.json +5 -5
- package/prisma-mongodb/schema.prisma +1 -3
- package/prisma-postgresql/migrations/20251112195422_update_user_unique_constraints/migration.sql +69 -0
- package/prisma-postgresql/schema.prisma +1 -3
- package/syncs/repositories/sync-repository-documentdb.js +240 -0
- package/syncs/repositories/sync-repository-factory.js +6 -1
- package/token/repositories/token-repository-documentdb.js +137 -0
- package/token/repositories/token-repository-factory.js +8 -1
- package/token/repositories/token-repository-mongo.js +10 -3
- package/token/repositories/token-repository-postgres.js +10 -3
- package/user/repositories/user-repository-documentdb.js +432 -0
- package/user/repositories/user-repository-factory.js +6 -1
- package/user/repositories/user-repository-mongo.js +3 -2
- package/user/repositories/user-repository-postgres.js +3 -2
- package/user/use-cases/login-user.js +1 -1
- package/websocket/repositories/websocket-connection-repository-documentdb.js +119 -0
- package/websocket/repositories/websocket-connection-repository-factory.js +8 -1
|
@@ -36,7 +36,6 @@ class CredentialRepositoryPostgres extends CredentialRepositoryInterface {
|
|
|
36
36
|
|
|
37
37
|
/**
|
|
38
38
|
* Find credential by ID
|
|
39
|
-
* Replaces: Credential.findById(id)
|
|
40
39
|
*
|
|
41
40
|
* @param {string} id - Credential ID (string from application layer)
|
|
42
41
|
* @returns {Promise<Object|null>} Credential object with string IDs or null
|
|
@@ -51,14 +50,11 @@ class CredentialRepositoryPostgres extends CredentialRepositoryInterface {
|
|
|
51
50
|
return null;
|
|
52
51
|
}
|
|
53
52
|
|
|
54
|
-
// Extract data from JSON field
|
|
55
53
|
const data = credential.data || {};
|
|
56
54
|
|
|
57
55
|
return {
|
|
58
|
-
_id: credential.id.toString(),
|
|
59
56
|
id: credential.id.toString(),
|
|
60
|
-
|
|
61
|
-
userId: credential.userId?.toString(),
|
|
57
|
+
userId: credential.userId.toString(),
|
|
62
58
|
externalId: credential.externalId,
|
|
63
59
|
authIsValid: credential.authIsValid,
|
|
64
60
|
...data, // Spread OAuth tokens from JSON field
|
|
@@ -67,7 +63,6 @@ class CredentialRepositoryPostgres extends CredentialRepositoryInterface {
|
|
|
67
63
|
|
|
68
64
|
/**
|
|
69
65
|
* Update authentication status
|
|
70
|
-
* Replaces: Credential.updateOne({ _id: credentialId }, { $set: { authIsValid } })
|
|
71
66
|
*
|
|
72
67
|
* @param {string} credentialId - Credential ID (string from application layer)
|
|
73
68
|
* @param {boolean} authIsValid - Authentication validity status
|
|
@@ -85,7 +80,6 @@ class CredentialRepositoryPostgres extends CredentialRepositoryInterface {
|
|
|
85
80
|
|
|
86
81
|
/**
|
|
87
82
|
* Permanently remove a credential document
|
|
88
|
-
* Replaces: Credential.deleteOne({ _id: credentialId })
|
|
89
83
|
*
|
|
90
84
|
* @param {string} credentialId - Credential ID (string from application layer)
|
|
91
85
|
* @returns {Promise<Object>} Deletion result
|
|
@@ -108,7 +102,6 @@ class CredentialRepositoryPostgres extends CredentialRepositoryInterface {
|
|
|
108
102
|
|
|
109
103
|
/**
|
|
110
104
|
* Create or update credential matching identifiers
|
|
111
|
-
* Replaces: Credential.findOneAndUpdate(query, update, { upsert: true })
|
|
112
105
|
*
|
|
113
106
|
* @param {{identifiers: Object, details: Object}} credentialDetails
|
|
114
107
|
* @returns {Promise<Object>} The persisted credential with string IDs
|
|
@@ -118,22 +111,21 @@ class CredentialRepositoryPostgres extends CredentialRepositoryInterface {
|
|
|
118
111
|
if (!identifiers)
|
|
119
112
|
throw new Error('identifiers required to upsert credential');
|
|
120
113
|
|
|
121
|
-
if (!identifiers.
|
|
122
|
-
throw new Error('
|
|
114
|
+
if (!identifiers.userId) {
|
|
115
|
+
throw new Error('userId required in identifiers');
|
|
123
116
|
}
|
|
124
117
|
if (!identifiers.externalId) {
|
|
125
118
|
throw new Error(
|
|
126
119
|
'externalId required in identifiers to prevent credential collision. ' +
|
|
127
|
-
|
|
128
|
-
|
|
120
|
+
'When multiple credentials exist for the same user, both userId and externalId ' +
|
|
121
|
+
'are needed to uniquely identify which credential to update.'
|
|
129
122
|
);
|
|
130
123
|
}
|
|
131
124
|
|
|
132
125
|
const where = this._convertIdentifiersToWhere(identifiers);
|
|
133
126
|
|
|
134
|
-
const {
|
|
127
|
+
const { externalId } = identifiers;
|
|
135
128
|
|
|
136
|
-
// Separate schema fields from dynamic OAuth data
|
|
137
129
|
const { authIsValid, ...oauthData } = details;
|
|
138
130
|
|
|
139
131
|
const existing = await this.prisma.credential.findFirst({ where });
|
|
@@ -144,15 +136,9 @@ class CredentialRepositoryPostgres extends CredentialRepositoryInterface {
|
|
|
144
136
|
const updated = await this.prisma.credential.update({
|
|
145
137
|
where: { id: existing.id },
|
|
146
138
|
data: {
|
|
147
|
-
userId: this._convertId(
|
|
148
|
-
externalId:
|
|
149
|
-
|
|
150
|
-
? externalId
|
|
151
|
-
: existing.externalId,
|
|
152
|
-
authIsValid:
|
|
153
|
-
authIsValid !== undefined
|
|
154
|
-
? authIsValid
|
|
155
|
-
: existing.authIsValid,
|
|
139
|
+
userId: this._convertId(existing.userId),
|
|
140
|
+
externalId: existing.externalId,
|
|
141
|
+
authIsValid: authIsValid,
|
|
156
142
|
data: mergedData,
|
|
157
143
|
},
|
|
158
144
|
});
|
|
@@ -168,10 +154,9 @@ class CredentialRepositoryPostgres extends CredentialRepositoryInterface {
|
|
|
168
154
|
|
|
169
155
|
const created = await this.prisma.credential.create({
|
|
170
156
|
data: {
|
|
171
|
-
userId: this._convertId(
|
|
157
|
+
userId: this._convertId(identifiers.userId),
|
|
172
158
|
externalId,
|
|
173
159
|
authIsValid: authIsValid,
|
|
174
|
-
|
|
175
160
|
data: oauthData,
|
|
176
161
|
},
|
|
177
162
|
});
|
|
@@ -187,7 +172,6 @@ class CredentialRepositoryPostgres extends CredentialRepositoryInterface {
|
|
|
187
172
|
|
|
188
173
|
/**
|
|
189
174
|
* Find a credential by filter criteria
|
|
190
|
-
* Replaces: Credential.findOne(query)
|
|
191
175
|
*
|
|
192
176
|
* @param {Object} filter
|
|
193
177
|
* @param {string} [filter.userId] - User ID (string from application layer)
|
|
@@ -215,21 +199,18 @@ class CredentialRepositoryPostgres extends CredentialRepositoryInterface {
|
|
|
215
199
|
authIsValid: credential.authIsValid,
|
|
216
200
|
access_token: data.access_token,
|
|
217
201
|
refresh_token: data.refresh_token,
|
|
218
|
-
domain: data.domain,
|
|
219
202
|
...data,
|
|
220
203
|
};
|
|
221
204
|
}
|
|
222
205
|
|
|
223
206
|
/**
|
|
224
207
|
* Update a credential by ID
|
|
225
|
-
* Replaces: Credential.findByIdAndUpdate(credentialId, { $set: updates })
|
|
226
208
|
*
|
|
227
209
|
* @param {string} credentialId - Credential ID (string from application layer)
|
|
228
210
|
* @param {Object} updates - Fields to update
|
|
229
211
|
* @returns {Promise<Object|null>} Updated credential object with string IDs or null if not found
|
|
230
212
|
*/
|
|
231
213
|
async updateCredential(credentialId, updates) {
|
|
232
|
-
// Get existing credential to merge OAuth data
|
|
233
214
|
const intId = this._convertId(credentialId);
|
|
234
215
|
const existing = await this.prisma.credential.findUnique({
|
|
235
216
|
where: { id: intId },
|
|
@@ -239,21 +220,16 @@ class CredentialRepositoryPostgres extends CredentialRepositoryInterface {
|
|
|
239
220
|
return null;
|
|
240
221
|
}
|
|
241
222
|
|
|
242
|
-
|
|
243
|
-
const { user, authIsValid, ...oauthData } =
|
|
244
|
-
updates;
|
|
223
|
+
const { authIsValid, ...oauthData } = updates;
|
|
245
224
|
|
|
246
|
-
// Merge OAuth data with existing
|
|
247
225
|
const mergedData = { ...(existing.data || {}), ...oauthData };
|
|
248
226
|
|
|
249
227
|
const updated = await this.prisma.credential.update({
|
|
250
228
|
where: { id: intId },
|
|
251
229
|
data: {
|
|
252
|
-
userId: this._convertId(
|
|
253
|
-
externalId:
|
|
254
|
-
|
|
255
|
-
authIsValid:
|
|
256
|
-
authIsValid !== undefined ? authIsValid : existing.authIsValid,
|
|
230
|
+
userId: this._convertId(existing.userId),
|
|
231
|
+
externalId: existing.externalId,
|
|
232
|
+
authIsValid: authIsValid,
|
|
257
233
|
data: mergedData,
|
|
258
234
|
},
|
|
259
235
|
});
|
|
@@ -267,7 +243,6 @@ class CredentialRepositoryPostgres extends CredentialRepositoryInterface {
|
|
|
267
243
|
authIsValid: updated.authIsValid,
|
|
268
244
|
access_token: data.access_token,
|
|
269
245
|
refresh_token: data.refresh_token,
|
|
270
|
-
domain: data.domain,
|
|
271
246
|
...data,
|
|
272
247
|
};
|
|
273
248
|
}
|
|
@@ -282,7 +257,6 @@ class CredentialRepositoryPostgres extends CredentialRepositoryInterface {
|
|
|
282
257
|
const where = {};
|
|
283
258
|
|
|
284
259
|
if (identifiers.id) where.id = this._convertId(identifiers.id);
|
|
285
|
-
if (identifiers.user) where.userId = this._convertId(identifiers.user);
|
|
286
260
|
if (identifiers.userId)
|
|
287
261
|
where.userId = this._convertId(identifiers.userId);
|
|
288
262
|
if (identifiers.externalId) where.externalId = identifiers.externalId;
|
|
@@ -302,7 +276,6 @@ class CredentialRepositoryPostgres extends CredentialRepositoryInterface {
|
|
|
302
276
|
if (filter.credentialId)
|
|
303
277
|
where.id = this._convertId(filter.credentialId);
|
|
304
278
|
if (filter.id) where.id = this._convertId(filter.id);
|
|
305
|
-
if (filter.user) where.userId = this._convertId(filter.user);
|
|
306
279
|
if (filter.userId) where.userId = this._convertId(filter.userId);
|
|
307
280
|
if (filter.externalId) where.externalId = filter.externalId;
|
|
308
281
|
|
|
@@ -4,14 +4,18 @@ class GetCredentialForUser {
|
|
|
4
4
|
}
|
|
5
5
|
|
|
6
6
|
async execute(credentialId, userId) {
|
|
7
|
-
const credential = await this.credentialRepository.findCredentialById(
|
|
7
|
+
const credential = await this.credentialRepository.findCredentialById(
|
|
8
|
+
credentialId
|
|
9
|
+
);
|
|
8
10
|
|
|
9
11
|
if (!credential) {
|
|
10
12
|
throw new Error(`Credential with id ${credentialId} not found`);
|
|
11
13
|
}
|
|
12
14
|
|
|
13
|
-
if (credential.
|
|
14
|
-
throw new Error(
|
|
15
|
+
if (credential.userId.toString() !== userId.toString()) {
|
|
16
|
+
throw new Error(
|
|
17
|
+
`Credential ${credentialId} does not belong to user ${userId}`
|
|
18
|
+
);
|
|
15
19
|
}
|
|
16
20
|
|
|
17
21
|
return credential;
|
package/database/config.js
CHANGED
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
* 1. DB_TYPE environment variable (set for migration handlers)
|
|
11
11
|
* 2. App definition (backend/index.js Definition.database configuration)
|
|
12
12
|
*
|
|
13
|
-
* @returns {'mongodb'|'postgresql'} Database type
|
|
13
|
+
* @returns {'mongodb'|'postgresql'|'documentdb'} Database type
|
|
14
14
|
* @throws {Error} If database type cannot be determined or app definition missing
|
|
15
15
|
*/
|
|
16
16
|
function getDatabaseType() {
|
|
@@ -93,7 +93,7 @@ function getDatabaseType() {
|
|
|
93
93
|
return 'mongodb';
|
|
94
94
|
}
|
|
95
95
|
if (database.documentDB?.enable === true) {
|
|
96
|
-
return '
|
|
96
|
+
return 'documentdb';
|
|
97
97
|
}
|
|
98
98
|
|
|
99
99
|
throw new Error(
|
|
@@ -114,7 +114,7 @@ function getDatabaseType() {
|
|
|
114
114
|
|
|
115
115
|
/**
|
|
116
116
|
* Cached database type (lazy evaluation)
|
|
117
|
-
* @type {'mongodb'|'postgresql'|null}
|
|
117
|
+
* @type {'mongodb'|'postgresql'|'documentdb'|null}
|
|
118
118
|
*/
|
|
119
119
|
let cachedDbType = null;
|
|
120
120
|
|
|
@@ -140,7 +140,7 @@ module.exports = {
|
|
|
140
140
|
/**
|
|
141
141
|
* Lazy-evaluated database type determined from app definition
|
|
142
142
|
* Only evaluates when accessed, preventing module load failures in test environments
|
|
143
|
-
* @type {'mongodb'|'postgresql'}
|
|
143
|
+
* @type {'mongodb'|'postgresql'|'documentdb'}
|
|
144
144
|
*/
|
|
145
145
|
Object.defineProperty(module.exports, 'DB_TYPE', {
|
|
146
146
|
get() {
|
|
@@ -0,0 +1,330 @@
|
|
|
1
|
+
const { Cryptor } = require('../encrypt/Cryptor');
|
|
2
|
+
const { getEncryptedFields, loadCustomEncryptionSchema } = require('./encryption/encryption-schema-registry');
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Encryption service specifically for DocumentDB repositories
|
|
6
|
+
* that use $runCommandRaw and bypass Prisma Client Extensions.
|
|
7
|
+
*
|
|
8
|
+
* Provides document-level encryption/decryption, handling nested fields
|
|
9
|
+
* according to the encryption schema registry.
|
|
10
|
+
*
|
|
11
|
+
* @class DocumentDBEncryptionService
|
|
12
|
+
* @example
|
|
13
|
+
* const service = new DocumentDBEncryptionService();
|
|
14
|
+
*
|
|
15
|
+
* // Encrypt before write
|
|
16
|
+
* const encrypted = await service.encryptFields('Credential', document);
|
|
17
|
+
* await insertOne(prisma, 'Credential', encrypted);
|
|
18
|
+
*
|
|
19
|
+
* // Decrypt after read
|
|
20
|
+
* const doc = await findOne(prisma, 'Credential', filter);
|
|
21
|
+
* const decrypted = await service.decryptFields('Credential', doc);
|
|
22
|
+
*/
|
|
23
|
+
class DocumentDBEncryptionService {
|
|
24
|
+
/**
|
|
25
|
+
* @param {Object} options - Configuration options
|
|
26
|
+
* @param {Cryptor} [options.cryptor] - Optional Cryptor instance for dependency injection (useful for testing)
|
|
27
|
+
*/
|
|
28
|
+
constructor({ cryptor = null } = {}) {
|
|
29
|
+
if (cryptor) {
|
|
30
|
+
// Dependency injection - use provided Cryptor (for testing)
|
|
31
|
+
this.cryptor = cryptor;
|
|
32
|
+
this.enabled = true;
|
|
33
|
+
} else {
|
|
34
|
+
// Default behavior - create Cryptor from environment
|
|
35
|
+
this._initializeCryptor();
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Initialize Cryptor with environment-based configuration.
|
|
41
|
+
* Matches the logic from @friggframework/core/database/prisma.js
|
|
42
|
+
*
|
|
43
|
+
* Encryption is bypassed in dev/test/local stages.
|
|
44
|
+
* Production uses AWS KMS (if available) or AES encryption.
|
|
45
|
+
*
|
|
46
|
+
* @private
|
|
47
|
+
*/
|
|
48
|
+
_initializeCryptor() {
|
|
49
|
+
// Load custom encryption schema from app definition BEFORE checking configuration
|
|
50
|
+
// This ensures custom fields (like User.username) are registered before any encryption operations
|
|
51
|
+
loadCustomEncryptionSchema();
|
|
52
|
+
|
|
53
|
+
// Match logic from packages/core/database/prisma.js
|
|
54
|
+
const stage = process.env.STAGE || process.env.NODE_ENV || 'development';
|
|
55
|
+
const bypassEncryption = ['dev', 'test', 'local'].includes(stage.toLowerCase());
|
|
56
|
+
|
|
57
|
+
if (bypassEncryption) {
|
|
58
|
+
this.cryptor = null;
|
|
59
|
+
this.enabled = false;
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// Determine encryption method (ensure boolean values)
|
|
64
|
+
const hasKMS = !!(process.env.KMS_KEY_ARN && process.env.KMS_KEY_ARN.trim() !== '');
|
|
65
|
+
const hasAES = !!(process.env.AES_KEY_ID && process.env.AES_KEY_ID.trim() !== '');
|
|
66
|
+
|
|
67
|
+
if (!hasKMS && !hasAES) {
|
|
68
|
+
console.warn('[DocumentDBEncryptionService] No encryption keys configured. Encryption disabled.');
|
|
69
|
+
this.cryptor = null;
|
|
70
|
+
this.enabled = false;
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// KMS takes precedence over AES
|
|
75
|
+
const shouldUseAws = hasKMS;
|
|
76
|
+
this.cryptor = new Cryptor({ shouldUseAws });
|
|
77
|
+
this.enabled = true;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Encrypt sensitive fields in a document before storing to DocumentDB.
|
|
82
|
+
*
|
|
83
|
+
* Reads field paths from encryption-schema-registry.js and encrypts
|
|
84
|
+
* only the fields defined for the given model.
|
|
85
|
+
*
|
|
86
|
+
* @param {string} modelName - Model name from schema registry (e.g., 'User', 'Credential')
|
|
87
|
+
* @param {Object} document - Document to encrypt
|
|
88
|
+
* @returns {Promise<Object>} - New document with encrypted fields (original unchanged)
|
|
89
|
+
*
|
|
90
|
+
* @example
|
|
91
|
+
* const plainDoc = {
|
|
92
|
+
* userId: '123',
|
|
93
|
+
* data: { access_token: 'plain_secret' }
|
|
94
|
+
* };
|
|
95
|
+
* const encrypted = await service.encryptFields('Credential', plainDoc);
|
|
96
|
+
* // encrypted.data.access_token = "keyId:iv:cipher:encKey"
|
|
97
|
+
*/
|
|
98
|
+
async encryptFields(modelName, document) {
|
|
99
|
+
// Bypass if encryption disabled
|
|
100
|
+
if (!this.enabled || !this.cryptor) {
|
|
101
|
+
return document;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// Validate input
|
|
105
|
+
if (!document || typeof document !== 'object') {
|
|
106
|
+
return document;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// Get encrypted fields from registry
|
|
110
|
+
const encryptedFieldsConfig = getEncryptedFields(modelName);
|
|
111
|
+
if (!encryptedFieldsConfig || encryptedFieldsConfig.length === 0) {
|
|
112
|
+
return document;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// Deep clone to prevent mutation (preserves Date, RegExp, Buffer)
|
|
116
|
+
const result = structuredClone(document);
|
|
117
|
+
|
|
118
|
+
// Encrypt each field path
|
|
119
|
+
for (const fieldPath of encryptedFieldsConfig) {
|
|
120
|
+
await this._encryptFieldPath(result, fieldPath, modelName);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
return result;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Decrypt sensitive fields in a document after reading from DocumentDB.
|
|
128
|
+
*
|
|
129
|
+
* Reads field paths from encryption-schema-registry.js and decrypts
|
|
130
|
+
* only the fields defined for the given model.
|
|
131
|
+
*
|
|
132
|
+
* @param {string} modelName - Model name from schema registry
|
|
133
|
+
* @param {Object} document - Document to decrypt
|
|
134
|
+
* @returns {Promise<Object>} - New document with decrypted fields (original unchanged)
|
|
135
|
+
*
|
|
136
|
+
* @example
|
|
137
|
+
* const encryptedDoc = {
|
|
138
|
+
* userId: '123',
|
|
139
|
+
* data: { access_token: 'keyId:iv:cipher:encKey' }
|
|
140
|
+
* };
|
|
141
|
+
* const decrypted = await service.decryptFields('Credential', encryptedDoc);
|
|
142
|
+
* // decrypted.data.access_token = "plain_secret"
|
|
143
|
+
*/
|
|
144
|
+
async decryptFields(modelName, document) {
|
|
145
|
+
// Bypass if encryption disabled
|
|
146
|
+
if (!this.enabled || !this.cryptor) {
|
|
147
|
+
return document;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// Validate input
|
|
151
|
+
if (!document || typeof document !== 'object') {
|
|
152
|
+
return document;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// Get encrypted fields from registry
|
|
156
|
+
const encryptedFieldsConfig = getEncryptedFields(modelName);
|
|
157
|
+
if (!encryptedFieldsConfig || encryptedFieldsConfig.length === 0) {
|
|
158
|
+
return document;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// Deep clone to prevent mutation (preserves Date, RegExp, Buffer)
|
|
162
|
+
const result = structuredClone(document);
|
|
163
|
+
|
|
164
|
+
// Decrypt each field path
|
|
165
|
+
for (const fieldPath of encryptedFieldsConfig) {
|
|
166
|
+
await this._decryptFieldPath(result, fieldPath, modelName);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
return result;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Encrypt a specific field path in a document (handles nested fields).
|
|
174
|
+
*
|
|
175
|
+
* @private
|
|
176
|
+
* @param {Object} document - Document to modify (mutated in place)
|
|
177
|
+
* @param {string} fieldPath - Field path from schema registry (e.g., 'data.access_token')
|
|
178
|
+
* @param {string} modelName - For error logging context
|
|
179
|
+
*/
|
|
180
|
+
async _encryptFieldPath(document, fieldPath, modelName) {
|
|
181
|
+
// Parse field path
|
|
182
|
+
const parts = fieldPath.split('.');
|
|
183
|
+
|
|
184
|
+
// Navigate to parent object
|
|
185
|
+
let current = document;
|
|
186
|
+
for (let i = 0; i < parts.length - 1; i++) {
|
|
187
|
+
if (!current[parts[i]]) {
|
|
188
|
+
// Path doesn't exist, nothing to encrypt
|
|
189
|
+
return;
|
|
190
|
+
}
|
|
191
|
+
current = current[parts[i]];
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// Get field name and value
|
|
195
|
+
const fieldName = parts[parts.length - 1];
|
|
196
|
+
const value = current[fieldName];
|
|
197
|
+
|
|
198
|
+
// Skip if already encrypted or empty
|
|
199
|
+
if (!value || this._isEncryptedValue(value)) {
|
|
200
|
+
return;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
try {
|
|
204
|
+
// Convert to string if needed
|
|
205
|
+
const stringValue = typeof value === 'string'
|
|
206
|
+
? value
|
|
207
|
+
: JSON.stringify(value);
|
|
208
|
+
|
|
209
|
+
// Encrypt using Cryptor
|
|
210
|
+
current[fieldName] = await this.cryptor.encrypt(stringValue);
|
|
211
|
+
} catch (error) {
|
|
212
|
+
console.error(`[DocumentDBEncryptionService] Failed to encrypt ${modelName}.${fieldPath}:`, error.message);
|
|
213
|
+
throw error;
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* Decrypt a specific field path in a document (handles nested fields).
|
|
219
|
+
*
|
|
220
|
+
* @private
|
|
221
|
+
* @param {Object} document - Document to modify (mutated in place)
|
|
222
|
+
* @param {string} fieldPath - Field path from schema registry
|
|
223
|
+
* @param {string} modelName - For error logging context
|
|
224
|
+
*/
|
|
225
|
+
async _decryptFieldPath(document, fieldPath, modelName) {
|
|
226
|
+
// Parse field path
|
|
227
|
+
const parts = fieldPath.split('.');
|
|
228
|
+
|
|
229
|
+
// Navigate to parent object
|
|
230
|
+
let current = document;
|
|
231
|
+
for (let i = 0; i < parts.length - 1; i++) {
|
|
232
|
+
if (!current[parts[i]]) {
|
|
233
|
+
// Path doesn't exist, nothing to decrypt
|
|
234
|
+
return;
|
|
235
|
+
}
|
|
236
|
+
current = current[parts[i]];
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
// Get field name and encrypted value
|
|
240
|
+
const fieldName = parts[parts.length - 1];
|
|
241
|
+
const encryptedValue = current[fieldName];
|
|
242
|
+
|
|
243
|
+
// Skip if not encrypted format
|
|
244
|
+
if (!encryptedValue || !this._isEncryptedValue(encryptedValue)) {
|
|
245
|
+
return;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
try {
|
|
249
|
+
// Decrypt using Cryptor
|
|
250
|
+
const decryptedString = await this.cryptor.decrypt(encryptedValue);
|
|
251
|
+
|
|
252
|
+
// Try to parse as JSON (for objects/arrays)
|
|
253
|
+
try {
|
|
254
|
+
current[fieldName] = JSON.parse(decryptedString);
|
|
255
|
+
} catch {
|
|
256
|
+
// Not JSON, return as string
|
|
257
|
+
current[fieldName] = decryptedString;
|
|
258
|
+
}
|
|
259
|
+
} catch (error) {
|
|
260
|
+
const errorContext = {
|
|
261
|
+
modelName,
|
|
262
|
+
fieldPath,
|
|
263
|
+
encryptedValuePrefix: encryptedValue.substring(0, 20),
|
|
264
|
+
errorMessage: error.message
|
|
265
|
+
};
|
|
266
|
+
|
|
267
|
+
console.error(
|
|
268
|
+
`[DocumentDBEncryptionService] Failed to decrypt ${modelName}.${fieldPath}:`,
|
|
269
|
+
JSON.stringify(errorContext)
|
|
270
|
+
);
|
|
271
|
+
|
|
272
|
+
// Throw error to fail fast - don't silently corrupt data
|
|
273
|
+
throw new Error(`Decryption failed for ${modelName}.${fieldPath}: ${error.message}`);
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/**
|
|
278
|
+
* Check if a value is in encrypted format.
|
|
279
|
+
*
|
|
280
|
+
* Encrypted format: "keyId:iv:cipher:encKey" (envelope encryption)
|
|
281
|
+
* All parts are base64-encoded strings.
|
|
282
|
+
*
|
|
283
|
+
* @private
|
|
284
|
+
* @param {any} value - Value to check
|
|
285
|
+
* @returns {boolean} - True if value is encrypted
|
|
286
|
+
*
|
|
287
|
+
* @example
|
|
288
|
+
* _isEncryptedValue("plain_text") // false
|
|
289
|
+
* _isEncryptedValue("YWVzLWtleS0x:TXlJVkhlcmU=:QWN0dWFsQ2lwaGVy:RW5jcnlwdGVk") // true
|
|
290
|
+
* _isEncryptedValue(null) // false
|
|
291
|
+
* _isEncryptedValue({}) // false
|
|
292
|
+
*/
|
|
293
|
+
_isEncryptedValue(value) {
|
|
294
|
+
// Must be string
|
|
295
|
+
if (typeof value !== 'string') {
|
|
296
|
+
return false;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
// Must have exactly 4 colon-separated parts
|
|
300
|
+
const parts = value.split(':');
|
|
301
|
+
if (parts.length !== 4) {
|
|
302
|
+
return false;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
// Enhanced validation: check for base64 pattern
|
|
306
|
+
// This prevents false positives on URLs, connection strings, etc.
|
|
307
|
+
const base64Pattern = /^[A-Za-z0-9+/=]+$/;
|
|
308
|
+
|
|
309
|
+
// All parts should be base64-encoded
|
|
310
|
+
if (!parts.every(part => base64Pattern.test(part))) {
|
|
311
|
+
return false;
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
// Encrypted values should be sufficiently long to be valid
|
|
315
|
+
// Real encrypted values from Cryptor are always >50 chars due to envelope encryption format:
|
|
316
|
+
// - keyId (base64): ~12 chars minimum
|
|
317
|
+
// - iv (base64): ~24 chars for 16-byte IV
|
|
318
|
+
// - ciphertext (base64): varies, minimum ~16 chars for small values
|
|
319
|
+
// - encryptedKey (base64): ~44 chars for 32-byte data key
|
|
320
|
+
// Total minimum: ~96 chars, so 50 is a safe lower bound
|
|
321
|
+
// This prevents false positives on non-encrypted strings that happen to have 4 colons
|
|
322
|
+
if (value.length < 50) {
|
|
323
|
+
return false;
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
return true;
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
module.exports = { DocumentDBEncryptionService };
|