@friggframework/core 2.0.0--canary.499.897122d.0 → 2.0.0--canary.499.832ff75.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,329 @@
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
+ * Note: Custom encryption schema is loaded eagerly at module initialization
47
+ * (see encryption-schema-registry.js), so it's already available here.
48
+ *
49
+ * @private
50
+ */
51
+ _initializeCryptor() {
52
+ // Match logic from packages/core/database/prisma.js
53
+ const stage = process.env.STAGE || process.env.NODE_ENV || 'development';
54
+ const bypassEncryption = ['dev', 'test', 'local'].includes(stage.toLowerCase());
55
+
56
+ if (bypassEncryption) {
57
+ this.cryptor = null;
58
+ this.enabled = false;
59
+ return;
60
+ }
61
+
62
+ // Determine encryption method (ensure boolean values)
63
+ const hasKMS = !!(process.env.KMS_KEY_ARN && process.env.KMS_KEY_ARN.trim() !== '');
64
+ const hasAES = !!(process.env.AES_KEY_ID && process.env.AES_KEY_ID.trim() !== '');
65
+
66
+ if (!hasKMS && !hasAES) {
67
+ console.warn('[DocumentDBEncryptionService] No encryption keys configured. Encryption disabled.');
68
+ this.cryptor = null;
69
+ this.enabled = false;
70
+ return;
71
+ }
72
+
73
+ // KMS takes precedence over AES
74
+ const shouldUseAws = hasKMS;
75
+ this.cryptor = new Cryptor({ shouldUseAws });
76
+ this.enabled = true;
77
+ }
78
+
79
+ /**
80
+ * Encrypt sensitive fields in a document before storing to DocumentDB.
81
+ *
82
+ * Reads field paths from encryption-schema-registry.js and encrypts
83
+ * only the fields defined for the given model.
84
+ *
85
+ * @param {string} modelName - Model name from schema registry (e.g., 'User', 'Credential')
86
+ * @param {Object} document - Document to encrypt
87
+ * @returns {Promise<Object>} - New document with encrypted fields (original unchanged)
88
+ *
89
+ * @example
90
+ * const plainDoc = {
91
+ * userId: '123',
92
+ * data: { access_token: 'plain_secret' }
93
+ * };
94
+ * const encrypted = await service.encryptFields('Credential', plainDoc);
95
+ * // encrypted.data.access_token = "keyId:iv:cipher:encKey"
96
+ */
97
+ async encryptFields(modelName, document) {
98
+ // Bypass if encryption disabled
99
+ if (!this.enabled || !this.cryptor) {
100
+ return document;
101
+ }
102
+
103
+ // Validate input
104
+ if (!document || typeof document !== 'object') {
105
+ return document;
106
+ }
107
+
108
+ // Get encrypted fields from registry
109
+ const encryptedFieldsConfig = getEncryptedFields(modelName);
110
+ if (!encryptedFieldsConfig || encryptedFieldsConfig.length === 0) {
111
+ return document;
112
+ }
113
+
114
+ // Deep clone to prevent mutation (preserves Date, RegExp, Buffer)
115
+ const result = structuredClone(document);
116
+
117
+ // Encrypt each field path
118
+ for (const fieldPath of encryptedFieldsConfig) {
119
+ await this._encryptFieldPath(result, fieldPath, modelName);
120
+ }
121
+
122
+ return result;
123
+ }
124
+
125
+ /**
126
+ * Decrypt sensitive fields in a document after reading from DocumentDB.
127
+ *
128
+ * Reads field paths from encryption-schema-registry.js and decrypts
129
+ * only the fields defined for the given model.
130
+ *
131
+ * @param {string} modelName - Model name from schema registry
132
+ * @param {Object} document - Document to decrypt
133
+ * @returns {Promise<Object>} - New document with decrypted fields (original unchanged)
134
+ *
135
+ * @example
136
+ * const encryptedDoc = {
137
+ * userId: '123',
138
+ * data: { access_token: 'keyId:iv:cipher:encKey' }
139
+ * };
140
+ * const decrypted = await service.decryptFields('Credential', encryptedDoc);
141
+ * // decrypted.data.access_token = "plain_secret"
142
+ */
143
+ async decryptFields(modelName, document) {
144
+ // Bypass if encryption disabled
145
+ if (!this.enabled || !this.cryptor) {
146
+ return document;
147
+ }
148
+
149
+ // Validate input
150
+ if (!document || typeof document !== 'object') {
151
+ return document;
152
+ }
153
+
154
+ // Get encrypted fields from registry
155
+ const encryptedFieldsConfig = getEncryptedFields(modelName);
156
+ if (!encryptedFieldsConfig || encryptedFieldsConfig.length === 0) {
157
+ return document;
158
+ }
159
+
160
+ // Deep clone to prevent mutation (preserves Date, RegExp, Buffer)
161
+ const result = structuredClone(document);
162
+
163
+ // Decrypt each field path
164
+ for (const fieldPath of encryptedFieldsConfig) {
165
+ await this._decryptFieldPath(result, fieldPath, modelName);
166
+ }
167
+
168
+ return result;
169
+ }
170
+
171
+ /**
172
+ * Encrypt a specific field path in a document (handles nested fields).
173
+ *
174
+ * @private
175
+ * @param {Object} document - Document to modify (mutated in place)
176
+ * @param {string} fieldPath - Field path from schema registry (e.g., 'data.access_token')
177
+ * @param {string} modelName - For error logging context
178
+ */
179
+ async _encryptFieldPath(document, fieldPath, modelName) {
180
+ // Parse field path
181
+ const parts = fieldPath.split('.');
182
+
183
+ // Navigate to parent object
184
+ let current = document;
185
+ for (let i = 0; i < parts.length - 1; i++) {
186
+ if (!current[parts[i]]) {
187
+ // Path doesn't exist, nothing to encrypt
188
+ return;
189
+ }
190
+ current = current[parts[i]];
191
+ }
192
+
193
+ // Get field name and value
194
+ const fieldName = parts[parts.length - 1];
195
+ const value = current[fieldName];
196
+
197
+ // Skip if already encrypted or empty
198
+ if (!value || this._isEncryptedValue(value)) {
199
+ return;
200
+ }
201
+
202
+ try {
203
+ // Convert to string if needed
204
+ const stringValue = typeof value === 'string'
205
+ ? value
206
+ : JSON.stringify(value);
207
+
208
+ // Encrypt using Cryptor
209
+ current[fieldName] = await this.cryptor.encrypt(stringValue);
210
+ } catch (error) {
211
+ console.error(`[DocumentDBEncryptionService] Failed to encrypt ${modelName}.${fieldPath}:`, error.message);
212
+ throw error;
213
+ }
214
+ }
215
+
216
+ /**
217
+ * Decrypt a specific field path in a document (handles nested fields).
218
+ *
219
+ * @private
220
+ * @param {Object} document - Document to modify (mutated in place)
221
+ * @param {string} fieldPath - Field path from schema registry
222
+ * @param {string} modelName - For error logging context
223
+ */
224
+ async _decryptFieldPath(document, fieldPath, modelName) {
225
+ // Parse field path
226
+ const parts = fieldPath.split('.');
227
+
228
+ // Navigate to parent object
229
+ let current = document;
230
+ for (let i = 0; i < parts.length - 1; i++) {
231
+ if (!current[parts[i]]) {
232
+ // Path doesn't exist, nothing to decrypt
233
+ return;
234
+ }
235
+ current = current[parts[i]];
236
+ }
237
+
238
+ // Get field name and encrypted value
239
+ const fieldName = parts[parts.length - 1];
240
+ const encryptedValue = current[fieldName];
241
+
242
+ // Skip if not encrypted format
243
+ if (!encryptedValue || !this._isEncryptedValue(encryptedValue)) {
244
+ return;
245
+ }
246
+
247
+ try {
248
+ // Decrypt using Cryptor
249
+ const decryptedString = await this.cryptor.decrypt(encryptedValue);
250
+
251
+ // Try to parse as JSON (for objects/arrays)
252
+ try {
253
+ current[fieldName] = JSON.parse(decryptedString);
254
+ } catch {
255
+ // Not JSON, return as string
256
+ current[fieldName] = decryptedString;
257
+ }
258
+ } catch (error) {
259
+ const errorContext = {
260
+ modelName,
261
+ fieldPath,
262
+ encryptedValuePrefix: encryptedValue.substring(0, 20),
263
+ errorMessage: error.message
264
+ };
265
+
266
+ console.error(
267
+ `[DocumentDBEncryptionService] Failed to decrypt ${modelName}.${fieldPath}:`,
268
+ JSON.stringify(errorContext)
269
+ );
270
+
271
+ // Throw error to fail fast - don't silently corrupt data
272
+ throw new Error(`Decryption failed for ${modelName}.${fieldPath}: ${error.message}`);
273
+ }
274
+ }
275
+
276
+ /**
277
+ * Check if a value is in encrypted format.
278
+ *
279
+ * Encrypted format: "keyId:iv:cipher:encKey" (envelope encryption)
280
+ * All parts are base64-encoded strings.
281
+ *
282
+ * @private
283
+ * @param {any} value - Value to check
284
+ * @returns {boolean} - True if value is encrypted
285
+ *
286
+ * @example
287
+ * _isEncryptedValue("plain_text") // false
288
+ * _isEncryptedValue("YWVzLWtleS0x:TXlJVkhlcmU=:QWN0dWFsQ2lwaGVy:RW5jcnlwdGVk") // true
289
+ * _isEncryptedValue(null) // false
290
+ * _isEncryptedValue({}) // false
291
+ */
292
+ _isEncryptedValue(value) {
293
+ // Must be string
294
+ if (typeof value !== 'string') {
295
+ return false;
296
+ }
297
+
298
+ // Must have exactly 4 colon-separated parts
299
+ const parts = value.split(':');
300
+ if (parts.length !== 4) {
301
+ return false;
302
+ }
303
+
304
+ // Enhanced validation: check for base64 pattern
305
+ // This prevents false positives on URLs, connection strings, etc.
306
+ const base64Pattern = /^[A-Za-z0-9+/=]+$/;
307
+
308
+ // All parts should be base64-encoded
309
+ if (!parts.every(part => base64Pattern.test(part))) {
310
+ return false;
311
+ }
312
+
313
+ // Encrypted values should be sufficiently long to be valid
314
+ // Real encrypted values from Cryptor are always >50 chars due to envelope encryption format:
315
+ // - keyId (base64): ~12 chars minimum
316
+ // - iv (base64): ~24 chars for 16-byte IV
317
+ // - ciphertext (base64): varies, minimum ~16 chars for small values
318
+ // - encryptedKey (base64): ~44 chars for 32-byte data key
319
+ // Total minimum: ~96 chars, so 50 is a safe lower bound
320
+ // This prevents false positives on non-encrypted strings that happen to have 4 colons
321
+ if (value.length < 50) {
322
+ return false;
323
+ }
324
+
325
+ return true;
326
+ }
327
+ }
328
+
329
+ 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!)