@friggframework/core 2.0.0--canary.499.897122d.0 → 2.0.0--canary.499.5d51f03.0

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.
@@ -10,18 +10,39 @@ const {
10
10
  const {
11
11
  CredentialRepositoryInterface,
12
12
  } = require('./credential-repository-interface');
13
+ const { DocumentDBEncryptionService } = require('../../database/documentdb-encryption-service');
13
14
 
15
+ /**
16
+ * Credential repository for DocumentDB.
17
+ * Uses DocumentDBEncryptionService for field-level encryption.
18
+ *
19
+ * Encrypted fields:
20
+ * - Credential.data.access_token
21
+ * - Credential.data.refresh_token
22
+ * - Credential.data.id_token
23
+ * - Credential.data.domain
24
+ *
25
+ * SECURITY CRITICAL: All OAuth credentials must be encrypted at rest.
26
+ *
27
+ * @see DocumentDBEncryptionService
28
+ * @see encryption-schema-registry.js
29
+ */
14
30
  class CredentialRepositoryDocumentDB extends CredentialRepositoryInterface {
15
31
  constructor() {
16
32
  super();
17
33
  this.prisma = prisma;
34
+ this.encryptionService = new DocumentDBEncryptionService();
18
35
  }
19
36
 
20
37
  async findCredentialById(id) {
21
38
  const objectId = toObjectId(id);
22
39
  if (!objectId) return null;
23
40
  const doc = await findOne(this.prisma, 'Credential', { _id: objectId });
24
- return doc ? this._mapCredentialById(doc) : null;
41
+ if (!doc) return null;
42
+
43
+ // Decrypt sensitive fields using service
44
+ const decryptedCredential = await this.encryptionService.decryptFields('Credential', doc);
45
+ return this._mapCredentialById(decryptedCredential);
25
46
  }
26
47
 
27
48
  async updateAuthenticationStatus(credentialId, authIsValid) {
@@ -73,28 +94,48 @@ class CredentialRepositoryDocumentDB extends CredentialRepositoryInterface {
73
94
  const now = new Date();
74
95
 
75
96
  if (existing) {
76
- const mergedData = { ...(existing.data || {}), ...oauthData };
97
+ // Decrypt existing credential data first
98
+ const decryptedExisting = await this.encryptionService.decryptFields('Credential', existing);
99
+ const mergedData = { ...(decryptedExisting.data || {}), ...oauthData };
100
+
101
+ // Build update document
102
+ const updateDocument = {
103
+ userId: toObjectId(userId || user) || existing.userId || null,
104
+ externalId: externalId !== undefined ? externalId : existing.externalId,
105
+ authIsValid: authIsValid !== undefined ? authIsValid : existing.authIsValid,
106
+ data: mergedData,
107
+ updatedAt: now,
108
+ };
109
+
110
+ // Encrypt before storing
111
+ const encryptedUpdate = await this.encryptionService.encryptFields(
112
+ 'Credential',
113
+ { data: updateDocument.data }
114
+ );
115
+
77
116
  await updateOne(
78
117
  this.prisma,
79
118
  'Credential',
80
119
  { _id: existing._id },
81
120
  {
82
121
  $set: {
83
- userId: toObjectId(userId || user) || existing.userId || null,
84
- externalId:
85
- externalId !== undefined ? externalId : existing.externalId,
86
- authIsValid:
87
- authIsValid !== undefined ? authIsValid : existing.authIsValid,
88
- data: mergedData,
89
- updatedAt: now,
122
+ userId: updateDocument.userId,
123
+ externalId: updateDocument.externalId,
124
+ authIsValid: updateDocument.authIsValid,
125
+ data: encryptedUpdate.data,
126
+ updatedAt: updateDocument.updatedAt,
90
127
  },
91
128
  }
92
129
  );
130
+
131
+ // Read back and decrypt
93
132
  const updated = await findOne(this.prisma, 'Credential', { _id: existing._id });
94
- return this._mapCredential(updated);
133
+ const decryptedCredential = await this.encryptionService.decryptFields('Credential', updated);
134
+ return this._mapCredential(decryptedCredential);
95
135
  }
96
136
 
97
- const document = {
137
+ // Build plain text document
138
+ const plainDocument = {
98
139
  userId: toObjectId(userId || user || identifiers.user),
99
140
  externalId: externalId !== undefined ? externalId : identifiers.externalId,
100
141
  authIsValid: authIsValid ?? null,
@@ -103,15 +144,28 @@ class CredentialRepositoryDocumentDB extends CredentialRepositoryInterface {
103
144
  updatedAt: now,
104
145
  };
105
146
 
106
- const insertedId = await insertOne(this.prisma, 'Credential', document);
147
+ // Encrypt before storing
148
+ const encryptedDocument = await this.encryptionService.encryptFields(
149
+ 'Credential',
150
+ plainDocument
151
+ );
152
+
153
+ const insertedId = await insertOne(this.prisma, 'Credential', encryptedDocument);
154
+
155
+ // Read back and decrypt
107
156
  const created = await findOne(this.prisma, 'Credential', { _id: insertedId });
108
- return this._mapCredential(created);
157
+ const decryptedCredential = await this.encryptionService.decryptFields('Credential', created);
158
+ return this._mapCredential(decryptedCredential);
109
159
  }
110
160
 
111
161
  async findCredential(filter) {
112
162
  const query = this._buildFilter(filter);
113
163
  const credential = await findOne(this.prisma, 'Credential', query);
114
- return credential ? this._mapCredential(credential) : null;
164
+ if (!credential) return null;
165
+
166
+ // Decrypt sensitive fields using service
167
+ const decryptedCredential = await this.encryptionService.decryptFields('Credential', credential);
168
+ return this._mapCredential(decryptedCredential);
115
169
  }
116
170
 
117
171
  async updateCredential(credentialId, updates) {
@@ -128,26 +182,44 @@ class CredentialRepositoryDocumentDB extends CredentialRepositoryInterface {
128
182
  ...oauthData
129
183
  } = updates || {};
130
184
 
131
- const mergedData = { ...(existing.data || {}), ...oauthData };
185
+ // Decrypt existing credential data first
186
+ const decryptedExisting = await this.encryptionService.decryptFields('Credential', existing);
187
+ const mergedData = { ...(decryptedExisting.data || {}), ...oauthData };
188
+
189
+ // Build update document
190
+ const updateDocument = {
191
+ userId: toObjectId(userId || user) || existing.userId || null,
192
+ externalId: externalId !== undefined ? externalId : existing.externalId,
193
+ authIsValid: authIsValid !== undefined ? authIsValid : existing.authIsValid,
194
+ data: mergedData,
195
+ updatedAt: new Date(),
196
+ };
197
+
198
+ // Encrypt before storing
199
+ const encryptedUpdate = await this.encryptionService.encryptFields(
200
+ 'Credential',
201
+ { data: updateDocument.data }
202
+ );
203
+
132
204
  await updateOne(
133
205
  this.prisma,
134
206
  'Credential',
135
207
  { _id: objectId },
136
208
  {
137
209
  $set: {
138
- userId: toObjectId(userId || user) || existing.userId || null,
139
- externalId:
140
- externalId !== undefined ? externalId : existing.externalId,
141
- authIsValid:
142
- authIsValid !== undefined ? authIsValid : existing.authIsValid,
143
- data: mergedData,
144
- updatedAt: new Date(),
210
+ userId: updateDocument.userId,
211
+ externalId: updateDocument.externalId,
212
+ authIsValid: updateDocument.authIsValid,
213
+ data: encryptedUpdate.data,
214
+ updatedAt: updateDocument.updatedAt,
145
215
  },
146
216
  }
147
217
  );
148
218
 
219
+ // Read back and decrypt
149
220
  const updated = await findOne(this.prisma, 'Credential', { _id: objectId });
150
- return updated ? this._mapCredential(updated) : null;
221
+ const decryptedCredential = await this.encryptionService.decryptFields('Credential', updated);
222
+ return this._mapCredential(decryptedCredential);
151
223
  }
152
224
 
153
225
  _buildIdentifierFilter(identifiers) {
@@ -0,0 +1,326 @@
1
+ const { Cryptor } = require('../encrypt/Cryptor');
2
+ const { getEncryptedFields } = 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
+ // Match logic from packages/core/database/prisma.js
50
+ const stage = process.env.STAGE || process.env.NODE_ENV || 'development';
51
+ const bypassEncryption = ['dev', 'test', 'local'].includes(stage.toLowerCase());
52
+
53
+ if (bypassEncryption) {
54
+ this.cryptor = null;
55
+ this.enabled = false;
56
+ return;
57
+ }
58
+
59
+ // Determine encryption method (ensure boolean values)
60
+ const hasKMS = !!(process.env.KMS_KEY_ARN && process.env.KMS_KEY_ARN.trim() !== '');
61
+ const hasAES = !!(process.env.AES_KEY_ID && process.env.AES_KEY_ID.trim() !== '');
62
+
63
+ if (!hasKMS && !hasAES) {
64
+ console.warn('[DocumentDBEncryptionService] No encryption keys configured. Encryption disabled.');
65
+ this.cryptor = null;
66
+ this.enabled = false;
67
+ return;
68
+ }
69
+
70
+ // KMS takes precedence over AES
71
+ const shouldUseAws = hasKMS;
72
+ this.cryptor = new Cryptor({ shouldUseAws });
73
+ this.enabled = true;
74
+ }
75
+
76
+ /**
77
+ * Encrypt sensitive fields in a document before storing to DocumentDB.
78
+ *
79
+ * Reads field paths from encryption-schema-registry.js and encrypts
80
+ * only the fields defined for the given model.
81
+ *
82
+ * @param {string} modelName - Model name from schema registry (e.g., 'User', 'Credential')
83
+ * @param {Object} document - Document to encrypt
84
+ * @returns {Promise<Object>} - New document with encrypted fields (original unchanged)
85
+ *
86
+ * @example
87
+ * const plainDoc = {
88
+ * userId: '123',
89
+ * data: { access_token: 'plain_secret' }
90
+ * };
91
+ * const encrypted = await service.encryptFields('Credential', plainDoc);
92
+ * // encrypted.data.access_token = "keyId:iv:cipher:encKey"
93
+ */
94
+ async encryptFields(modelName, document) {
95
+ // Bypass if encryption disabled
96
+ if (!this.enabled || !this.cryptor) {
97
+ return document;
98
+ }
99
+
100
+ // Validate input
101
+ if (!document || typeof document !== 'object') {
102
+ return document;
103
+ }
104
+
105
+ // Get encrypted fields from registry
106
+ const encryptedFieldsConfig = getEncryptedFields(modelName);
107
+ if (!encryptedFieldsConfig || !encryptedFieldsConfig.fields || encryptedFieldsConfig.fields.length === 0) {
108
+ return document;
109
+ }
110
+
111
+ // Deep clone to prevent mutation (preserves Date, RegExp, Buffer)
112
+ const result = structuredClone(document);
113
+
114
+ // Encrypt each field path
115
+ for (const fieldPath of encryptedFieldsConfig.fields) {
116
+ await this._encryptFieldPath(result, fieldPath, modelName);
117
+ }
118
+
119
+ return result;
120
+ }
121
+
122
+ /**
123
+ * Decrypt sensitive fields in a document after reading from DocumentDB.
124
+ *
125
+ * Reads field paths from encryption-schema-registry.js and decrypts
126
+ * only the fields defined for the given model.
127
+ *
128
+ * @param {string} modelName - Model name from schema registry
129
+ * @param {Object} document - Document to decrypt
130
+ * @returns {Promise<Object>} - New document with decrypted fields (original unchanged)
131
+ *
132
+ * @example
133
+ * const encryptedDoc = {
134
+ * userId: '123',
135
+ * data: { access_token: 'keyId:iv:cipher:encKey' }
136
+ * };
137
+ * const decrypted = await service.decryptFields('Credential', encryptedDoc);
138
+ * // decrypted.data.access_token = "plain_secret"
139
+ */
140
+ async decryptFields(modelName, document) {
141
+ // Bypass if encryption disabled
142
+ if (!this.enabled || !this.cryptor) {
143
+ return document;
144
+ }
145
+
146
+ // Validate input
147
+ if (!document || typeof document !== 'object') {
148
+ return document;
149
+ }
150
+
151
+ // Get encrypted fields from registry
152
+ const encryptedFieldsConfig = getEncryptedFields(modelName);
153
+ if (!encryptedFieldsConfig || !encryptedFieldsConfig.fields || encryptedFieldsConfig.fields.length === 0) {
154
+ return document;
155
+ }
156
+
157
+ // Deep clone to prevent mutation (preserves Date, RegExp, Buffer)
158
+ const result = structuredClone(document);
159
+
160
+ // Decrypt each field path
161
+ for (const fieldPath of encryptedFieldsConfig.fields) {
162
+ await this._decryptFieldPath(result, fieldPath, modelName);
163
+ }
164
+
165
+ return result;
166
+ }
167
+
168
+ /**
169
+ * Encrypt a specific field path in a document (handles nested fields).
170
+ *
171
+ * @private
172
+ * @param {Object} document - Document to modify (mutated in place)
173
+ * @param {string} fieldPath - Field path from schema registry (e.g., 'data.access_token')
174
+ * @param {string} modelName - For error logging context
175
+ */
176
+ async _encryptFieldPath(document, fieldPath, modelName) {
177
+ // Parse field path
178
+ const parts = fieldPath.split('.');
179
+
180
+ // Navigate to parent object
181
+ let current = document;
182
+ for (let i = 0; i < parts.length - 1; i++) {
183
+ if (!current[parts[i]]) {
184
+ // Path doesn't exist, nothing to encrypt
185
+ return;
186
+ }
187
+ current = current[parts[i]];
188
+ }
189
+
190
+ // Get field name and value
191
+ const fieldName = parts[parts.length - 1];
192
+ const value = current[fieldName];
193
+
194
+ // Skip if already encrypted or empty
195
+ if (!value || this._isEncryptedValue(value)) {
196
+ return;
197
+ }
198
+
199
+ try {
200
+ // Convert to string if needed
201
+ const stringValue = typeof value === 'string'
202
+ ? value
203
+ : JSON.stringify(value);
204
+
205
+ // Encrypt using Cryptor
206
+ current[fieldName] = await this.cryptor.encrypt(stringValue);
207
+ } catch (error) {
208
+ console.error(`[DocumentDBEncryptionService] Failed to encrypt ${modelName}.${fieldPath}:`, error.message);
209
+ throw error;
210
+ }
211
+ }
212
+
213
+ /**
214
+ * Decrypt a specific field path in a document (handles nested fields).
215
+ *
216
+ * @private
217
+ * @param {Object} document - Document to modify (mutated in place)
218
+ * @param {string} fieldPath - Field path from schema registry
219
+ * @param {string} modelName - For error logging context
220
+ */
221
+ async _decryptFieldPath(document, fieldPath, modelName) {
222
+ // Parse field path
223
+ const parts = fieldPath.split('.');
224
+
225
+ // Navigate to parent object
226
+ let current = document;
227
+ for (let i = 0; i < parts.length - 1; i++) {
228
+ if (!current[parts[i]]) {
229
+ // Path doesn't exist, nothing to decrypt
230
+ return;
231
+ }
232
+ current = current[parts[i]];
233
+ }
234
+
235
+ // Get field name and encrypted value
236
+ const fieldName = parts[parts.length - 1];
237
+ const encryptedValue = current[fieldName];
238
+
239
+ // Skip if not encrypted format
240
+ if (!encryptedValue || !this._isEncryptedValue(encryptedValue)) {
241
+ return;
242
+ }
243
+
244
+ try {
245
+ // Decrypt using Cryptor
246
+ const decryptedString = await this.cryptor.decrypt(encryptedValue);
247
+
248
+ // Try to parse as JSON (for objects/arrays)
249
+ try {
250
+ current[fieldName] = JSON.parse(decryptedString);
251
+ } catch {
252
+ // Not JSON, return as string
253
+ current[fieldName] = decryptedString;
254
+ }
255
+ } catch (error) {
256
+ const errorContext = {
257
+ modelName,
258
+ fieldPath,
259
+ encryptedValuePrefix: encryptedValue.substring(0, 20),
260
+ errorMessage: error.message
261
+ };
262
+
263
+ console.error(
264
+ `[DocumentDBEncryptionService] Failed to decrypt ${modelName}.${fieldPath}:`,
265
+ JSON.stringify(errorContext)
266
+ );
267
+
268
+ // Throw error to fail fast - don't silently corrupt data
269
+ throw new Error(`Decryption failed for ${modelName}.${fieldPath}: ${error.message}`);
270
+ }
271
+ }
272
+
273
+ /**
274
+ * Check if a value is in encrypted format.
275
+ *
276
+ * Encrypted format: "keyId:iv:cipher:encKey" (envelope encryption)
277
+ * All parts are base64-encoded strings.
278
+ *
279
+ * @private
280
+ * @param {any} value - Value to check
281
+ * @returns {boolean} - True if value is encrypted
282
+ *
283
+ * @example
284
+ * _isEncryptedValue("plain_text") // false
285
+ * _isEncryptedValue("YWVzLWtleS0x:TXlJVkhlcmU=:QWN0dWFsQ2lwaGVy:RW5jcnlwdGVk") // true
286
+ * _isEncryptedValue(null) // false
287
+ * _isEncryptedValue({}) // false
288
+ */
289
+ _isEncryptedValue(value) {
290
+ // Must be string
291
+ if (typeof value !== 'string') {
292
+ return false;
293
+ }
294
+
295
+ // Must have exactly 4 colon-separated parts
296
+ const parts = value.split(':');
297
+ if (parts.length !== 4) {
298
+ return false;
299
+ }
300
+
301
+ // Enhanced validation: check for base64 pattern
302
+ // This prevents false positives on URLs, connection strings, etc.
303
+ const base64Pattern = /^[A-Za-z0-9+/=]+$/;
304
+
305
+ // All parts should be base64-encoded
306
+ if (!parts.every(part => base64Pattern.test(part))) {
307
+ return false;
308
+ }
309
+
310
+ // Encrypted values should be sufficiently long to be valid
311
+ // Real encrypted values from Cryptor are always >50 chars due to envelope encryption format:
312
+ // - keyId (base64): ~12 chars minimum
313
+ // - iv (base64): ~24 chars for 16-byte IV
314
+ // - ciphertext (base64): varies, minimum ~16 chars for small values
315
+ // - encryptedKey (base64): ~44 chars for 32-byte data key
316
+ // Total minimum: ~96 chars, so 50 is a safe lower bound
317
+ // This prevents false positives on non-encrypted strings that happen to have 4 colons
318
+ if (value.length < 50) {
319
+ return false;
320
+ }
321
+
322
+ return true;
323
+ }
324
+ }
325
+
326
+ module.exports = { DocumentDBEncryptionService };
@@ -267,6 +267,14 @@ const CORE_ENCRYPTION_SCHEMA = {
267
267
  - Integration-specific sensitive data (use custom schema instead)
268
268
  - Temporary/experimental encryption (use custom schema instead)
269
269
 
270
+ #### After Adding Encrypted Fields
271
+
272
+ After adding fields to `encryption-schema-registry.js`:
273
+
274
+ 1. **For MongoDB/PostgreSQL**: No code changes needed (automatic via Prisma Extension)
275
+ 2. **For DocumentDB**: Encryption is automatic via DocumentDBEncryptionService
276
+ (service reads from same registry)
277
+
270
278
  ## How It Works
271
279
 
272
280
  ### Write Operation (Create/Update)
@@ -403,6 +411,48 @@ return entities.map((e) => ({
403
411
 
404
412
  See `modules/repositories/module-repository-postgres.js` and `module-repository-mongo.js` for complete implementation examples using `_fetchCredential()` and `_fetchCredentialsBulk()` helper methods.
405
413
 
414
+ ## DocumentDB Encryption
415
+
416
+ ### Why DocumentDB Needs Manual Encryption
417
+
418
+ DocumentDB repositories use `$runCommandRaw()` for MongoDB protocol compatibility, which bypasses Prisma Client Extensions. This means the automatic encryption extension does not apply.
419
+
420
+ ### DocumentDBEncryptionService
421
+
422
+ For DocumentDB repositories, use `DocumentDBEncryptionService` to manually encrypt/decrypt documents before/after database operations.
423
+
424
+ #### Usage Example
425
+
426
+ ```javascript
427
+ const { DocumentDBEncryptionService } = require('../documentdb-encryption-service');
428
+ const { insertOne, findOne } = require('../documentdb-utils');
429
+
430
+ class MyRepositoryDocumentDB {
431
+ constructor() {
432
+ this.encryptionService = new DocumentDBEncryptionService();
433
+ }
434
+
435
+ async create(data) {
436
+ // Encrypt before write
437
+ const encrypted = await this.encryptionService.encryptFields('ModelName', data);
438
+ const id = await insertOne(this.prisma, 'CollectionName', encrypted);
439
+
440
+ // Decrypt after read
441
+ const doc = await findOne(this.prisma, 'CollectionName', { _id: id });
442
+ const decrypted = await this.encryptionService.decryptFields('ModelName', doc);
443
+
444
+ return decrypted;
445
+ }
446
+ }
447
+ ```
448
+
449
+ #### Configuration
450
+
451
+ Uses the same environment variables and Cryptor as the Prisma Extension:
452
+ - `STAGE`: Bypasses encryption for dev/test/local
453
+ - `KMS_KEY_ARN`: AWS KMS encryption (production)
454
+ - `AES_KEY_ID` + `AES_KEY`: AES encryption (fallback)
455
+
406
456
  ## Usage Examples
407
457
 
408
458
  ### Repository Code (No Changes Needed!)
@@ -52,9 +52,7 @@ class HealthCheckRepositoryMongoDB extends HealthCheckRepositoryInterface {
52
52
  }),
53
53
  timeoutPromise
54
54
  ]);
55
-
56
- return Date.now() - pingStart;
57
- }
55
+
58
56
  return Date.now() - pingStart;
59
57
  }
60
58
 
@@ -132,3 +132,4 @@ class IntegrationMappingRepositoryDocumentDB extends IntegrationMappingRepositor
132
132
 
133
133
  module.exports = { IntegrationMappingRepositoryDocumentDB };
134
134
 
135
+
@@ -186,3 +186,4 @@ class IntegrationRepositoryDocumentDB extends IntegrationRepositoryInterface {
186
186
 
187
187
  module.exports = { IntegrationRepositoryDocumentDB };
188
188
 
189
+
@@ -138,3 +138,4 @@ class ProcessRepositoryDocumentDB extends ProcessRepositoryInterface {
138
138
 
139
139
  module.exports = { ProcessRepositoryDocumentDB };
140
140
 
141
+
@@ -9,111 +9,25 @@ const {
9
9
  deleteOne,
10
10
  } = require('../../database/documentdb-utils');
11
11
  const { ModuleRepositoryInterface } = require('./module-repository-interface');
12
- const { Cryptor } = require('../../encrypt/Cryptor');
13
- const { getEncryptedFields } = require('../../database/encryption/encryption-schema-registry');
14
-
12
+ const { DocumentDBEncryptionService } = require('../../database/documentdb-encryption-service');
13
+
14
+ /**
15
+ * Module/Entity repository for DocumentDB.
16
+ * Uses DocumentDBEncryptionService for credential decryption.
17
+ *
18
+ * Encrypted fields: Credential.data.*
19
+ *
20
+ * Note: This repository only reads credentials. CredentialRepository
21
+ * handles credential creation/updates with encryption.
22
+ *
23
+ * @see DocumentDBEncryptionService
24
+ * @see CredentialRepositoryDocumentDB
25
+ */
15
26
  class ModuleRepositoryDocumentDB extends ModuleRepositoryInterface {
16
27
  constructor() {
17
28
  super();
18
29
  this.prisma = prisma;
19
- this._initializeCryptor();
20
- }
21
-
22
- _initializeCryptor() {
23
- // Match logic from @friggframework/core/database/prisma.js
24
- const stage = process.env.STAGE || process.env.NODE_ENV || 'development';
25
- const bypassEncryption = ['dev', 'test', 'local'].includes(stage.toLowerCase());
26
-
27
- if (bypassEncryption) {
28
- this.cryptor = null;
29
- return;
30
- }
31
-
32
- // Determine encryption method
33
- const hasKMS = process.env.KMS_KEY_ARN && process.env.KMS_KEY_ARN.trim() !== '';
34
- const hasAES = process.env.AES_KEY_ID && process.env.AES_KEY_ID.trim() !== '';
35
-
36
- if (!hasKMS && !hasAES) {
37
- console.warn('No encryption keys configured. Encryption disabled.');
38
- this.cryptor = null;
39
- return;
40
- }
41
-
42
- const shouldUseAws = hasKMS;
43
- this.cryptor = new Cryptor({ shouldUseAws });
44
- }
45
-
46
- _isEncryptedValue(value) {
47
- // Envelope encryption format: "keyId:encryptedPart1:encryptedPart2:encryptedKey"
48
- // Must be string with at least 4 colon-separated parts
49
- if (typeof value !== 'string') {
50
- return false;
51
- }
52
-
53
- const parts = value.split(':');
54
- return parts.length >= 4;
55
- }
56
-
57
- async _decryptField(encryptedValue, context = '') {
58
- // If encryption is disabled, return as-is
59
- if (!this.cryptor) {
60
- return encryptedValue;
61
- }
62
-
63
- // If not encrypted format, return as-is
64
- if (!this._isEncryptedValue(encryptedValue)) {
65
- return encryptedValue;
66
- }
67
-
68
- try {
69
- // Decrypt using Cryptor
70
- const decryptedString = await this.cryptor.decrypt(encryptedValue);
71
-
72
- // Try to parse as JSON (for objects/arrays)
73
- try {
74
- return JSON.parse(decryptedString);
75
- } catch {
76
- // Not JSON, return as string
77
- return decryptedString;
78
- }
79
- } catch (error) {
80
- console.error(`Failed to decrypt field${context ? ` (${context})` : ''}:`, error.message);
81
- // Return null on decryption failure to avoid exposing encrypted data
82
- return null;
83
- }
84
- }
85
-
86
- async _decryptCredentialData(rawData) {
87
- if (!rawData || typeof rawData !== 'object') {
88
- return rawData;
89
- }
90
-
91
- // Get encrypted fields from registry
92
- const encryptedFieldsConfig = getEncryptedFields('Credential');
93
- if (!encryptedFieldsConfig || !encryptedFieldsConfig.fields) {
94
- return rawData;
95
- }
96
-
97
- const decrypted = {};
98
-
99
- for (const [key, value] of Object.entries(rawData)) {
100
- // Check if this field is in the encrypted fields list
101
- const isEncrypted = encryptedFieldsConfig.fields.some(field => {
102
- // Support both top-level and nested fields (e.g., 'data.access_token')
103
- const fieldPath = field.split('.');
104
- return fieldPath[fieldPath.length - 1] === key;
105
- });
106
-
107
- if (isEncrypted) {
108
- // Decrypt encrypted fields
109
- decrypted[key] = await this._decryptField(value, `Credential.data.${key}`);
110
- } else {
111
- // Pass through non-encrypted fields
112
- decrypted[key] = value;
113
- }
114
- }
115
-
116
- return decrypted;
30
+ this.encryptionService = new DocumentDBEncryptionService();
117
31
  }
118
32
 
119
33
  async findEntityById(entityId) {
@@ -254,25 +168,28 @@ class ModuleRepositoryDocumentDB extends ModuleRepositoryInterface {
254
168
 
255
169
  if (!rawCredential) return null;
256
170
 
257
- // Manually decrypt data field
258
- const decryptedData = await this._decryptCredentialData(
259
- rawCredential.data || {}
260
- );
171
+ // Decrypt sensitive fields using service
172
+ const decryptedCredential = await this.encryptionService.decryptFields('Credential', rawCredential);
261
173
 
262
174
  // Return in same format
263
175
  const credential = {
264
- id: fromObjectId(rawCredential._id),
265
- userId: fromObjectId(rawCredential.userId),
266
- externalId: rawCredential.externalId ?? null,
267
- authIsValid: rawCredential.authIsValid ?? null,
268
- createdAt: rawCredential.createdAt,
269
- updatedAt: rawCredential.updatedAt,
270
- data: decryptedData
176
+ id: fromObjectId(decryptedCredential._id),
177
+ userId: fromObjectId(decryptedCredential.userId),
178
+ externalId: decryptedCredential.externalId ?? null,
179
+ authIsValid: decryptedCredential.authIsValid ?? null,
180
+ createdAt: decryptedCredential.createdAt,
181
+ updatedAt: decryptedCredential.updatedAt,
182
+ data: decryptedCredential.data
271
183
  };
272
184
 
273
185
  return this._convertCredentialIds(credential);
274
186
  } catch (error) {
275
187
  console.error(`Failed to fetch/decrypt credential ${id}:`, error.message);
188
+ // Return null instead of throwing to allow graceful degradation
189
+ // This repository is read-only (doesn't create/update credentials)
190
+ // Entities can still be loaded even if their credential is corrupted/unreadable
191
+ // The entity will have null credential, which calling code must handle
192
+ // This is intentional behavior: prefer partial data over complete failure
276
193
  return null;
277
194
  }
278
195
  }
@@ -296,20 +213,18 @@ class ModuleRepositoryDocumentDB extends ModuleRepositoryInterface {
296
213
  // Decrypt all credentials in parallel
297
214
  const decryptionPromises = rawCredentials.map(async (rawCredential) => {
298
215
  try {
299
- // Manually decrypt the data field
300
- const decryptedData = await this._decryptCredentialData(
301
- rawCredential.data || {}
302
- );
216
+ // Decrypt sensitive fields using service
217
+ const decryptedCredential = await this.encryptionService.decryptFields('Credential', rawCredential);
303
218
 
304
219
  // Build credential object in same format as Prisma would return
305
220
  const credential = {
306
- id: fromObjectId(rawCredential._id),
307
- userId: fromObjectId(rawCredential.userId),
308
- externalId: rawCredential.externalId ?? null,
309
- authIsValid: rawCredential.authIsValid ?? null,
310
- createdAt: rawCredential.createdAt,
311
- updatedAt: rawCredential.updatedAt,
312
- data: decryptedData
221
+ id: fromObjectId(decryptedCredential._id),
222
+ userId: fromObjectId(decryptedCredential.userId),
223
+ externalId: decryptedCredential.externalId ?? null,
224
+ authIsValid: decryptedCredential.authIsValid ?? null,
225
+ createdAt: decryptedCredential.createdAt,
226
+ updatedAt: decryptedCredential.updatedAt,
227
+ data: decryptedCredential.data
313
228
  };
314
229
 
315
230
  return this._convertCredentialIds(credential);
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@friggframework/core",
3
3
  "prettier": "@friggframework/prettier-config",
4
- "version": "2.0.0--canary.499.897122d.0",
4
+ "version": "2.0.0--canary.499.5d51f03.0",
5
5
  "dependencies": {
6
6
  "@aws-sdk/client-apigatewaymanagementapi": "^3.588.0",
7
7
  "@aws-sdk/client-kms": "^3.588.0",
@@ -38,9 +38,9 @@
38
38
  }
39
39
  },
40
40
  "devDependencies": {
41
- "@friggframework/eslint-config": "2.0.0--canary.499.897122d.0",
42
- "@friggframework/prettier-config": "2.0.0--canary.499.897122d.0",
43
- "@friggframework/test": "2.0.0--canary.499.897122d.0",
41
+ "@friggframework/eslint-config": "2.0.0--canary.499.5d51f03.0",
42
+ "@friggframework/prettier-config": "2.0.0--canary.499.5d51f03.0",
43
+ "@friggframework/test": "2.0.0--canary.499.5d51f03.0",
44
44
  "@prisma/client": "^6.17.0",
45
45
  "@types/lodash": "4.17.15",
46
46
  "@typescript-eslint/eslint-plugin": "^8.0.0",
@@ -80,5 +80,5 @@
80
80
  "publishConfig": {
81
81
  "access": "public"
82
82
  },
83
- "gitHead": "897122d48b86791284a25d3f3bcc3b087d8bb077"
83
+ "gitHead": "5d51f03b5b8de0c3d48bf6055f492ad5c761e5fa"
84
84
  }
@@ -237,3 +237,4 @@ class SyncRepositoryDocumentDB extends SyncRepositoryInterface {
237
237
 
238
238
  module.exports = { SyncRepositoryDocumentDB };
239
239
 
240
+
@@ -122,3 +122,4 @@ class TokenRepositoryDocumentDB extends TokenRepositoryInterface {
122
122
 
123
123
  module.exports = { TokenRepositoryDocumentDB };
124
124
 
125
+
@@ -10,141 +10,23 @@ const {
10
10
  } = require('../../database/documentdb-utils');
11
11
  const { createTokenRepository } = require('../../token/repositories/token-repository-factory');
12
12
  const { UserRepositoryInterface } = require('./user-repository-interface');
13
- const { Cryptor } = require('../../encrypt/Cryptor');
14
- const { getEncryptedFields } = require('../../database/encryption/encryption-schema-registry');
15
-
13
+ const { DocumentDBEncryptionService } = require('../../database/documentdb-encryption-service');
14
+
15
+ /**
16
+ * User repository for DocumentDB.
17
+ * Uses DocumentDBEncryptionService for field-level encryption.
18
+ *
19
+ * Encrypted fields: User.hashword
20
+ *
21
+ * @see DocumentDBEncryptionService
22
+ * @see encryption-schema-registry.js
23
+ */
16
24
  class UserRepositoryDocumentDB extends UserRepositoryInterface {
17
25
  constructor() {
18
26
  super();
19
27
  this.prisma = prisma;
20
28
  this.tokenRepository = createTokenRepository();
21
- this._initializeCryptor();
22
- }
23
-
24
- _initializeCryptor() {
25
- // Match logic from @friggframework/core/database/prisma.js
26
- const stage = process.env.STAGE || process.env.NODE_ENV || 'development';
27
- const bypassEncryption = ['dev', 'test', 'local'].includes(stage.toLowerCase());
28
-
29
- if (bypassEncryption) {
30
- this.cryptor = null;
31
- return;
32
- }
33
-
34
- // Determine encryption method
35
- const hasKMS = process.env.KMS_KEY_ARN && process.env.KMS_KEY_ARN.trim() !== '';
36
- const hasAES = process.env.AES_KEY_ID && process.env.AES_KEY_ID.trim() !== '';
37
-
38
- if (!hasKMS && !hasAES) {
39
- console.warn('No encryption keys configured. Encryption disabled.');
40
- this.cryptor = null;
41
- return;
42
- }
43
-
44
- const shouldUseAws = hasKMS;
45
- this.cryptor = new Cryptor({ shouldUseAws });
46
- }
47
-
48
- _isEncryptedValue(value) {
49
- // Envelope encryption format: "keyId:encryptedPart1:encryptedPart2:encryptedKey"
50
- // Must be string with at least 4 colon-separated parts
51
- if (typeof value !== 'string') {
52
- return false;
53
- }
54
-
55
- const parts = value.split(':');
56
- return parts.length >= 4;
57
- }
58
-
59
- async _decryptField(encryptedValue, context = '') {
60
- // If encryption is disabled, return as-is
61
- if (!this.cryptor) {
62
- return encryptedValue;
63
- }
64
-
65
- // If not encrypted format, return as-is
66
- if (!this._isEncryptedValue(encryptedValue)) {
67
- return encryptedValue;
68
- }
69
-
70
- try {
71
- // Decrypt using Cryptor
72
- const decryptedString = await this.cryptor.decrypt(encryptedValue);
73
-
74
- // Try to parse as JSON (for objects/arrays)
75
- try {
76
- return JSON.parse(decryptedString);
77
- } catch {
78
- // Not JSON, return as string
79
- return decryptedString;
80
- }
81
- } catch (error) {
82
- console.error(`Failed to decrypt field${context ? ` (${context})` : ''}:`, error.message);
83
- // Return null on decryption failure to avoid exposing encrypted data
84
- return null;
85
- }
86
- }
87
-
88
- async _encryptField(plainValue, context = '') {
89
- // If encryption is disabled, return as-is
90
- if (!this.cryptor) {
91
- return plainValue;
92
- }
93
-
94
- // Don't encrypt null/undefined
95
- if (plainValue === null || plainValue === undefined) {
96
- return plainValue;
97
- }
98
-
99
- try {
100
- // Convert objects/arrays to JSON string
101
- const stringValue = typeof plainValue === 'string'
102
- ? plainValue
103
- : JSON.stringify(plainValue);
104
-
105
- // Encrypt using Cryptor
106
- return await this.cryptor.encrypt(stringValue);
107
- } catch (error) {
108
- console.error(`Failed to encrypt field${context ? ` (${context})` : ''}:`, error.message);
109
- throw error;
110
- }
111
- }
112
-
113
- async _decryptHashword(rawUser) {
114
- if (!rawUser || !rawUser.hashword) {
115
- return rawUser;
116
- }
117
-
118
- // Get encrypted fields from registry
119
- const encryptedFieldsConfig = getEncryptedFields('User');
120
- const shouldDecrypt = encryptedFieldsConfig?.fields?.includes('hashword');
121
-
122
- if (!shouldDecrypt) {
123
- return rawUser;
124
- }
125
-
126
- const decryptedHashword = await this._decryptField(rawUser.hashword, 'User.hashword');
127
-
128
- return {
129
- ...rawUser,
130
- hashword: decryptedHashword
131
- };
132
- }
133
-
134
- async _encryptHashword(hashword) {
135
- if (!hashword) {
136
- return hashword;
137
- }
138
-
139
- // Get encrypted fields from registry
140
- const encryptedFieldsConfig = getEncryptedFields('User');
141
- const shouldEncrypt = encryptedFieldsConfig?.fields?.includes('hashword');
142
-
143
- if (!shouldEncrypt) {
144
- return hashword;
145
- }
146
-
147
- return await this._encryptField(hashword, 'User.hashword');
29
+ this.encryptionService = new DocumentDBEncryptionService();
148
30
  }
149
31
 
150
32
  async getSessionToken(token) {
@@ -158,7 +40,7 @@ class UserRepositoryDocumentDB extends UserRepositoryInterface {
158
40
  _id: toObjectId(userId),
159
41
  type: 'ORGANIZATION',
160
42
  });
161
- const decrypted = await this._decryptHashword(doc);
43
+ const decrypted = await this.encryptionService.decryptFields('User', doc);
162
44
  return this._mapUser(decrypted);
163
45
  }
164
46
 
@@ -167,7 +49,7 @@ class UserRepositoryDocumentDB extends UserRepositoryInterface {
167
49
  _id: toObjectId(userId),
168
50
  type: 'INDIVIDUAL',
169
51
  });
170
- const decrypted = await this._decryptHashword(doc);
52
+ const decrypted = await this.encryptionService.decryptFields('User', doc);
171
53
  return this._mapUser(decrypted);
172
54
  }
173
55
 
@@ -212,13 +94,12 @@ class UserRepositoryDocumentDB extends UserRepositoryInterface {
212
94
  }
213
95
 
214
96
  // Bcrypt hash the password
215
- const hashedPassword = await bcrypt.hash(params.hashword, 10);
216
-
217
- // Encrypt the bcrypt hash if encryption is enabled
218
- document.hashword = await this._encryptHashword(hashedPassword);
97
+ document.hashword = await bcrypt.hash(params.hashword, 10);
219
98
  }
220
99
 
221
- const insertedId = await insertOne(this.prisma, 'User', document);
100
+ // Encrypt sensitive fields before insert
101
+ const encryptedDocument = await this.encryptionService.encryptFields('User', document);
102
+ const insertedId = await insertOne(this.prisma, 'User', encryptedDocument);
222
103
  const created = await findOne(this.prisma, 'User', { _id: insertedId });
223
104
 
224
105
  // Defensive check: verify document was found after insert
@@ -237,8 +118,8 @@ class UserRepositoryDocumentDB extends UserRepositoryInterface {
237
118
  );
238
119
  }
239
120
 
240
- // Decrypt hashword if present
241
- const decrypted = await this._decryptHashword(created);
121
+ // Decrypt sensitive fields after read
122
+ const decrypted = await this.encryptionService.decryptFields('User', created);
242
123
 
243
124
  return this._mapUser(decrypted);
244
125
  }
@@ -263,7 +144,7 @@ class UserRepositoryDocumentDB extends UserRepositoryInterface {
263
144
  type: 'INDIVIDUAL',
264
145
  username,
265
146
  });
266
- const decrypted = await this._decryptHashword(doc);
147
+ const decrypted = await this.encryptionService.decryptFields('User', doc);
267
148
  return this._mapUser(decrypted);
268
149
  }
269
150
 
@@ -272,7 +153,7 @@ class UserRepositoryDocumentDB extends UserRepositoryInterface {
272
153
  type: 'INDIVIDUAL',
273
154
  appUserId,
274
155
  });
275
- const decrypted = await this._decryptHashword(doc);
156
+ const decrypted = await this.encryptionService.decryptFields('User', doc);
276
157
  return this._mapUser(decrypted);
277
158
  }
278
159
 
@@ -281,13 +162,13 @@ class UserRepositoryDocumentDB extends UserRepositoryInterface {
281
162
  type: 'ORGANIZATION',
282
163
  appOrgId,
283
164
  });
284
- const decrypted = await this._decryptHashword(doc);
165
+ const decrypted = await this.encryptionService.decryptFields('User', doc);
285
166
  return this._mapUser(decrypted);
286
167
  }
287
168
 
288
169
  async findUserById(userId) {
289
170
  const doc = await findOne(this.prisma, 'User', { _id: toObjectId(userId) });
290
- const decrypted = await this._decryptHashword(doc);
171
+ const decrypted = await this.encryptionService.decryptFields('User', doc);
291
172
  return this._mapUser(decrypted);
292
173
  }
293
174
 
@@ -296,7 +177,7 @@ class UserRepositoryDocumentDB extends UserRepositoryInterface {
296
177
  type: 'INDIVIDUAL',
297
178
  email,
298
179
  });
299
- const decrypted = await this._decryptHashword(doc);
180
+ const decrypted = await this.encryptionService.decryptFields('User', doc);
300
181
  return this._mapUser(decrypted);
301
182
  }
302
183
 
@@ -307,20 +188,18 @@ class UserRepositoryDocumentDB extends UserRepositoryInterface {
307
188
  const payload = await this._prepareUpdatePayload(updates);
308
189
  payload.updatedAt = new Date();
309
190
 
310
- // Encrypt hashword if present in payload
311
- if (payload.hashword) {
312
- payload.hashword = await this._encryptHashword(payload.hashword);
313
- }
191
+ // Encrypt sensitive fields before update
192
+ const encryptedPayload = await this.encryptionService.encryptFields('User', payload);
314
193
 
315
194
  await updateOne(
316
195
  this.prisma,
317
196
  'User',
318
197
  { _id: objectId, type: 'INDIVIDUAL' },
319
- { $set: payload }
198
+ { $set: encryptedPayload }
320
199
  );
321
200
 
322
201
  const updated = await findOne(this.prisma, 'User', { _id: objectId });
323
- const decrypted = await this._decryptHashword(updated);
202
+ const decrypted = await this.encryptionService.decryptFields('User', updated);
324
203
  return this._mapUser(decrypted);
325
204
  }
326
205
 
@@ -338,7 +217,7 @@ class UserRepositoryDocumentDB extends UserRepositoryInterface {
338
217
  );
339
218
 
340
219
  const updated = await findOne(this.prisma, 'User', { _id: objectId });
341
- const decrypted = await this._decryptHashword(updated);
220
+ const decrypted = await this.encryptionService.decryptFields('User', updated);
342
221
  return this._mapUser(decrypted);
343
222
  }
344
223
 
@@ -116,3 +116,4 @@ class WebsocketConnectionRepositoryDocumentDB extends WebsocketConnectionReposit
116
116
 
117
117
  module.exports = { WebsocketConnectionRepositoryDocumentDB };
118
118
 
119
+