Skip to content

Commit 2fc58da

Browse files
authored
Merge pull request #15320 from mongodb-js/schema-maps-auto-generate
feat(NODE-6507): generate encryption configuration on mongoose connect
2 parents 19c0132 + 59af7cf commit 2fc58da

File tree

14 files changed

+2151
-133
lines changed

14 files changed

+2151
-133
lines changed

docs/field-level-encryption.md

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -151,3 +151,68 @@ To declare a field as encrypted, you must:
151151
2. Choose an encryption type for the schema and configure the schema for the encryption type
152152

153153
Not all schematypes are supported for CSFLE and QE. For an overview of valid schema types, refer to MongoDB's documentation.
154+
155+
### Registering Models
156+
157+
Encrypted schemas must be registered on a connection, not the Mongoose global:
158+
159+
```javascript
160+
161+
const connection = mongoose.createConnection();
162+
const UserModel = connection.model('User', encryptedUserSchema);
163+
```
164+
165+
### Connecting and configuring encryption options
166+
167+
CSFLE/QE in Mongoose work by generating the encryption schema that the MongoDB driver expects for each encrypted model on the connection. This happens automatically the model's connection is established.
168+
169+
Queryable encryption and CSFLE requires all the same configuration as outlined in <>, except for the schemaMap or encryptedFieldsMap options.
170+
171+
```javascript
172+
const keyVaultNamespace = 'client.encryption';
173+
const kmsProviders = { local: { key } };
174+
await connection.openUri(`mongodb://localhost:27017`, {
175+
// Configure auto encryption
176+
autoEncryption: {
177+
keyVaultNamespace: 'datakeys.datakeys',
178+
kmsProviders
179+
}
180+
});
181+
```
182+
183+
Once the connection is established, Mongoose's operations will work as usual. Writes are encrypted automatically by the MongoDB driver prior to sending them to the server and reads are decrypted by the driver after fetching documents from the server.
184+
185+
### Discriminators
186+
187+
Discriminators are supported for encrypted models as well:
188+
189+
```javascript
190+
const connection = createConnection();
191+
192+
const schema = new Schema({
193+
name: {
194+
type: String, encrypt: { keyId }
195+
}
196+
}, {
197+
encryptionType: 'queryableEncryption'
198+
});
199+
200+
const Model = connection.model('BaseUserModel', schema);
201+
const ModelWithAge = model.discriminator('ModelWithAge', new Schema({
202+
age: {
203+
type: Int32, encrypt: { keyId: keyId2 }
204+
}
205+
}, {
206+
encryptionType: 'queryableEncryption'
207+
}));
208+
209+
const ModelWithBirthday = model.discriminator('ModelWithBirthday', new Schema({
210+
dob: {
211+
type: Int32, encrypt: { keyId: keyId3 }
212+
}
213+
}, {
214+
encryptionType: 'queryableEncryption'
215+
}));
216+
```
217+
218+
When generating encryption schemas, Mongoose merges all discriminators together for the all discriminators declared on the same namespace. As a result, discriminators that declare the same key with different types are not supported. Furthermore, all discriminators must share the same encryption type - it is not possible to configure discriminators on the same model for both CSFLE and QE.

lib/drivers/node-mongodb-native/connection.js

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ const pkg = require('../../../package.json');
1212
const processConnectionOptions = require('../../helpers/processConnectionOptions');
1313
const setTimeout = require('../../helpers/timers').setTimeout;
1414
const utils = require('../../utils');
15+
const Schema = require('../../schema');
1516

1617
/**
1718
* A [node-mongodb-native](https://github.com/mongodb/node-mongodb-native) connection implementation.
@@ -320,6 +321,20 @@ NativeConnection.prototype.createClient = async function createClient(uri, optio
320321
};
321322
}
322323

324+
const { schemaMap, encryptedFieldsMap } = this._buildEncryptionSchemas();
325+
326+
if ((Object.keys(schemaMap).length > 0 || Object.keys(encryptedFieldsMap).length) && !options.autoEncryption) {
327+
throw new Error('Must provide `autoEncryption` when connecting with encrypted schemas.');
328+
}
329+
330+
if (Object.keys(schemaMap).length > 0) {
331+
options.autoEncryption.schemaMap = schemaMap;
332+
}
333+
334+
if (Object.keys(encryptedFieldsMap).length > 0) {
335+
options.autoEncryption.encryptedFieldsMap = encryptedFieldsMap;
336+
}
337+
323338
this.readyState = STATES.connecting;
324339
this._connectionString = uri;
325340

@@ -343,6 +358,56 @@ NativeConnection.prototype.createClient = async function createClient(uri, optio
343358
return this;
344359
};
345360

361+
/**
362+
* Given a connection, which may or may not have encrypted models, build
363+
* a schemaMap and/or an encryptedFieldsMap for the connection, combining all models
364+
* into a single schemaMap and encryptedFields map.
365+
*
366+
* @returns the generated schemaMap and encryptedFieldsMap
367+
*/
368+
NativeConnection.prototype._buildEncryptionSchemas = function() {
369+
const qeMappings = {};
370+
const csfleMappings = {};
371+
372+
const encryptedModels = Object.values(this.models).filter(model => model.schema._hasEncryptedFields());
373+
374+
// If discriminators are configured for the collection, there might be multiple models
375+
// pointing to the same namespace. For this scenario, we merge all the schemas for each namespace
376+
// into a single schema and then generate a schemaMap/encryptedFieldsMap for the combined schema.
377+
for (const model of encryptedModels) {
378+
const { schema, collection: { collectionName } } = model;
379+
const namespace = `${this.$dbName}.${collectionName}`;
380+
const mappings = schema.encryptionType() === 'csfle' ? csfleMappings : qeMappings;
381+
382+
mappings[namespace] ??= new Schema({}, { encryptionType: schema.encryptionType() });
383+
384+
const isNonRootDiscriminator = schema.discriminatorMapping && !schema.discriminatorMapping.isRoot;
385+
if (isNonRootDiscriminator) {
386+
const rootSchema = schema._baseSchema;
387+
schema.eachPath((pathname) => {
388+
if (rootSchema.path(pathname)) return;
389+
if (!mappings[namespace]._hasEncryptedField(pathname)) return;
390+
391+
throw new Error(`Cannot have duplicate keys in discriminators with encryption. key=${pathname}`);
392+
});
393+
}
394+
395+
mappings[namespace].add(schema);
396+
}
397+
398+
const schemaMap = Object.fromEntries(Object.entries(csfleMappings).map(
399+
([namespace, schema]) => ([namespace, schema._buildSchemaMap()])
400+
));
401+
402+
const encryptedFieldsMap = Object.fromEntries(Object.entries(qeMappings).map(
403+
([namespace, schema]) => ([namespace, schema._buildEncryptedFields()])
404+
));
405+
406+
return {
407+
schemaMap, encryptedFieldsMap
408+
};
409+
};
410+
346411
/*!
347412
* ignore
348413
*/

lib/helpers/model/discriminator.js

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,53 @@ const CUSTOMIZABLE_DISCRIMINATOR_OPTIONS = {
1717
methods: true
1818
};
1919

20+
/**
21+
* Validate fields declared on the child schema when either schema is configured for encryption. Specifically, this function ensures that:
22+
*
23+
* - any encrypted fields are declared on exactly one of the schemas (not both)
24+
* - encrypted fields cannot be declared on either the parent or child schema, where the other schema declares the same field without encryption.
25+
*
26+
* @param {Schema} parentSchema
27+
* @param {Schema} childSchema
28+
*/
29+
function validateDiscriminatorSchemasForEncryption(parentSchema, childSchema) {
30+
if (parentSchema.encryptionType() == null && childSchema.encryptionType() == null) return;
31+
32+
const allSharedNestedPaths = setIntersection(
33+
allNestedPaths(parentSchema),
34+
allNestedPaths(childSchema)
35+
);
36+
37+
for (const path of allSharedNestedPaths) {
38+
if (parentSchema._hasEncryptedField(path) && childSchema._hasEncryptedField(path)) {
39+
throw new Error(`encrypted fields cannot be declared on both the base schema and the child schema in a discriminator. path=${path}`);
40+
}
41+
42+
if (parentSchema._hasEncryptedField(path) || childSchema._hasEncryptedField(path)) {
43+
throw new Error(`encrypted fields cannot have the same path as a non-encrypted field for discriminators. path=${path}`);
44+
}
45+
}
46+
47+
function* allNestedPaths(schema) {
48+
const { paths, singleNestedPaths } = schema;
49+
yield* Object.keys(paths);
50+
yield* Object.keys(singleNestedPaths);
51+
}
52+
53+
/**
54+
* @param {Iterable<string>} i1
55+
* @param {Iterable<string>} i2
56+
*/
57+
function* setIntersection(i1, i2) {
58+
const s1 = new Set(i1);
59+
for (const item of i2) {
60+
if (s1.has(item)) {
61+
yield item;
62+
}
63+
}
64+
}
65+
}
66+
2067
/*!
2168
* ignore
2269
*/
@@ -80,6 +127,8 @@ module.exports = function discriminator(model, name, schema, tiedValue, applyPlu
80127
value = tiedValue;
81128
}
82129

130+
validateDiscriminatorSchemasForEncryption(model.schema, schema);
131+
83132
function merge(schema, baseSchema) {
84133
// Retain original schema before merging base schema
85134
schema._baseSchema = baseSchema;

lib/schema.js

Lines changed: 71 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -721,7 +721,6 @@ Schema.prototype.encryptionType = function encryptionType(encryptionType) {
721721
Schema.prototype.add = function add(obj, prefix) {
722722
if (obj instanceof Schema || (obj != null && obj.instanceOfSchema)) {
723723
merge(this, obj);
724-
725724
return this;
726725
}
727726

@@ -914,6 +913,77 @@ Schema.prototype._hasEncryptedFields = function _hasEncryptedFields() {
914913
return Object.keys(this.encryptedFields).length > 0;
915914
};
916915

916+
/**
917+
* @api private
918+
*/
919+
Schema.prototype._hasEncryptedField = function _hasEncryptedField(path) {
920+
return path in this.encryptedFields;
921+
};
922+
923+
924+
/**
925+
* Builds an encryptedFieldsMap for the schema.
926+
*/
927+
Schema.prototype._buildEncryptedFields = function() {
928+
const fields = Object.entries(this.encryptedFields).map(
929+
([path, config]) => {
930+
const bsonType = this.path(path).autoEncryptionType();
931+
// { path, bsonType, keyId, queries? }
932+
return { path, bsonType, ...config };
933+
});
934+
935+
return { fields };
936+
};
937+
938+
/**
939+
* Builds a schemaMap for the schema, if the schema is configured for client-side field level encryption.
940+
*/
941+
Schema.prototype._buildSchemaMap = function() {
942+
/**
943+
* `schemaMap`s are JSON schemas, which use the following structure to represent objects:
944+
* { field: { bsonType: 'object', properties: { ... } } }
945+
*
946+
* for example, a schema that looks like this `{ a: { b: int32 } }` would be encoded as
947+
* `{ a: { bsonType: 'object', properties: { b: < encryption configuration > } } }`
948+
*
949+
* This function takes an array of path segments, an output object (that gets mutated) and
950+
* a value to associated with the full path, and constructs a valid CSFLE JSON schema path for
951+
* the object. This works for deeply nested properties as well.
952+
*
953+
* @param {string[]} path array of path components
954+
* @param {object} object the object in which to build a JSON schema of `path`'s properties
955+
* @param {object} value the value to associate with the path in object
956+
*/
957+
function buildNestedPath(path, object, value) {
958+
let i = 0, component = path[i];
959+
for (; i < path.length - 1; ++i, component = path[i]) {
960+
object[component] = object[component] == null ? {
961+
bsonType: 'object',
962+
properties: {}
963+
} : object[component];
964+
object = object[component].properties;
965+
}
966+
object[component] = value;
967+
}
968+
969+
const schemaMapPropertyReducer = (accum, [path, propertyConfig]) => {
970+
const bsonType = this.path(path).autoEncryptionType();
971+
const pathComponents = path.split('.');
972+
const configuration = { encrypt: { ...propertyConfig, bsonType } };
973+
buildNestedPath(pathComponents, accum, configuration);
974+
return accum;
975+
};
976+
977+
const properties = Object.entries(this.encryptedFields).reduce(
978+
schemaMapPropertyReducer,
979+
{});
980+
981+
return {
982+
bsonType: 'object',
983+
properties
984+
};
985+
};
986+
917987
/**
918988
* Add an alias for `path`. This means getting or setting the `alias`
919989
* is equivalent to getting or setting the `path`.

lib/schema/bigint.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -255,7 +255,7 @@ SchemaBigInt.prototype.toJSONSchema = function toJSONSchema(options) {
255255
};
256256

257257
SchemaBigInt.prototype.autoEncryptionType = function autoEncryptionType() {
258-
return 'int64';
258+
return 'long';
259259
};
260260

261261
/*!

lib/schema/boolean.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -305,7 +305,7 @@ SchemaBoolean.prototype.toJSONSchema = function toJSONSchema(options) {
305305
};
306306

307307
SchemaBoolean.prototype.autoEncryptionType = function autoEncryptionType() {
308-
return 'boolean';
308+
return 'bool';
309309
};
310310

311311
/*!

lib/schema/buffer.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -315,7 +315,7 @@ SchemaBuffer.prototype.toJSONSchema = function toJSONSchema(options) {
315315
};
316316

317317
SchemaBuffer.prototype.autoEncryptionType = function autoEncryptionType() {
318-
return 'binary';
318+
return 'binData';
319319
};
320320

321321
/*!

lib/schema/decimal128.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -236,7 +236,7 @@ SchemaDecimal128.prototype.toJSONSchema = function toJSONSchema(options) {
236236
};
237237

238238
SchemaDecimal128.prototype.autoEncryptionType = function autoEncryptionType() {
239-
return 'decimal128';
239+
return 'decimal';
240240
};
241241

242242
/*!

lib/schema/int32.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -261,7 +261,7 @@ SchemaInt32.prototype.toJSONSchema = function toJSONSchema(options) {
261261
};
262262

263263
SchemaInt32.prototype.autoEncryptionType = function autoEncryptionType() {
264-
return 'int32';
264+
return 'int';
265265
};
266266

267267

lib/schema/map.js

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,10 @@ class SchemaMap extends SchemaType {
9595

9696
return result;
9797
}
98+
99+
autoEncryptionType() {
100+
return 'object';
101+
}
98102
}
99103

100104
/**

0 commit comments

Comments
 (0)