Skip to content

Commit be4563e

Browse files
Add support for encrypted models
1 parent a6a51bd commit be4563e

File tree

14 files changed

+1473
-211
lines changed

14 files changed

+1473
-211
lines changed

docs/field-level-encryption.md

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

152152
Not all schematypes are supported for CSFLE and QE. For an overview of valid schema types, refer to MongoDB's documentation.
153+
154+
### Registering Models
155+
156+
Encrypted schemas must be registered on a connection, not the Mongoose global:
157+
158+
```javascript
159+
160+
const connection = mongoose.createConnection();
161+
const UserModel = connection.model('User', encryptedUserSchema);
162+
```

lib/collection.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -81,7 +81,7 @@ Collection.prototype.onOpen = function() {
8181
* @api private
8282
*/
8383

84-
Collection.prototype.onClose = function() {};
84+
Collection.prototype.onClose = function() { };
8585

8686
/**
8787
* Queues a method for later execution when its

lib/connection.js

Lines changed: 10 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -607,7 +607,7 @@ Connection.prototype.bulkWrite = async function bulkWrite(ops, options) {
607607

608608
Connection.prototype.createCollections = async function createCollections(options = {}) {
609609
const result = {};
610-
const errorsMap = { };
610+
const errorsMap = {};
611611

612612
const { continueOnError } = options;
613613
delete options.continueOnError;
@@ -734,7 +734,7 @@ Connection.prototype.transaction = function transaction(fn, options) {
734734
throw err;
735735
}).
736736
finally(() => {
737-
session.endSession().catch(() => {});
737+
session.endSession().catch(() => { });
738738
});
739739
});
740740
};
@@ -1025,7 +1025,7 @@ Connection.prototype.openUri = async function openUri(uri, options) {
10251025

10261026
for (const model of Object.values(this.models)) {
10271027
// Errors handled internally, so safe to ignore error
1028-
model.init().catch(function $modelInitNoop() {});
1028+
model.init().catch(function $modelInitNoop() { });
10291029
}
10301030

10311031
// `createConnection()` calls this `openUri()` function without
@@ -1061,7 +1061,7 @@ Connection.prototype.openUri = async function openUri(uri, options) {
10611061
// to avoid uncaught exceptions when using `on('error')`. See gh-14377.
10621062
Connection.prototype.on = function on(event, callback) {
10631063
if (event === 'error' && this.$initialConnection) {
1064-
this.$initialConnection.catch(() => {});
1064+
this.$initialConnection.catch(() => { });
10651065
}
10661066
return EventEmitter.prototype.on.call(this, event, callback);
10671067
};
@@ -1083,7 +1083,7 @@ Connection.prototype.on = function on(event, callback) {
10831083
// to avoid uncaught exceptions when using `on('error')`. See gh-14377.
10841084
Connection.prototype.once = function on(event, callback) {
10851085
if (event === 'error' && this.$initialConnection) {
1086-
this.$initialConnection.catch(() => {});
1086+
this.$initialConnection.catch(() => { });
10871087
}
10881088
return EventEmitter.prototype.once.call(this, event, callback);
10891089
};
@@ -1412,7 +1412,7 @@ Connection.prototype.model = function model(name, schema, collection, options) {
14121412
}
14131413

14141414
// Errors handled internally, so safe to ignore error
1415-
model.init().catch(function $modelInitNoop() {});
1415+
model.init().catch(function $modelInitNoop() { });
14161416

14171417
return model;
14181418
}
@@ -1439,7 +1439,7 @@ Connection.prototype.model = function model(name, schema, collection, options) {
14391439
}
14401440

14411441
if (this === model.prototype.db
1442-
&& (!collection || collection === model.collection.name)) {
1442+
&& (!collection || collection === model.collection.name)) {
14431443
// model already uses this connection.
14441444

14451445
// only the first model with this name is cached to allow
@@ -1626,8 +1626,8 @@ Connection.prototype.authMechanismDoesNotRequirePassword = function authMechanis
16261626
*/
16271627
Connection.prototype.optionsProvideAuthenticationData = function optionsProvideAuthenticationData(options) {
16281628
return (options) &&
1629-
(options.user) &&
1630-
((options.pass) || this.authMechanismDoesNotRequirePassword());
1629+
(options.user) &&
1630+
((options.pass) || this.authMechanismDoesNotRequirePassword());
16311631
};
16321632

16331633
/**
@@ -1689,7 +1689,7 @@ Connection.prototype.createClient = function createClient() {
16891689
*/
16901690
Connection.prototype.syncIndexes = async function syncIndexes(options = {}) {
16911691
const result = {};
1692-
const errorsMap = { };
1692+
const errorsMap = {};
16931693

16941694
const { continueOnError } = options;
16951695
delete options.continueOnError;

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

Lines changed: 48 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -315,6 +315,16 @@ NativeConnection.prototype.createClient = async function createClient(uri, optio
315315
};
316316
}
317317

318+
const { schemaMap, encryptedFieldsMap } = this._buildEncryptionSchemas();
319+
320+
if (Object.keys(schemaMap).length > 0) {
321+
options.autoEncryption.schemaMap = schemaMap;
322+
}
323+
324+
if (Object.keys(encryptedFieldsMap).length > 0) {
325+
options.autoEncryption.encryptedFieldsMap = encryptedFieldsMap;
326+
}
327+
318328
this.readyState = STATES.connecting;
319329
this._connectionString = uri;
320330

@@ -338,6 +348,40 @@ NativeConnection.prototype.createClient = async function createClient(uri, optio
338348
return this;
339349
};
340350

351+
/**
352+
* Given a connection, which may or may not have encrypted models, build
353+
* a schemaMap and/or an encryptedFieldsMap for the connection, combining all models
354+
* into a single schemaMap and encryptedFields map.
355+
*
356+
* @returns a copy of the options object with a schemaMap and/or an encryptedFieldsMap added to the options' autoEncryption
357+
* options.
358+
*/
359+
NativeConnection.prototype._buildEncryptionSchemas = function() {
360+
const schemaMap = Object.values(this.models).filter(model => model.schema.encryptionType() === 'csfle').reduce(
361+
(schemaMap, model) => {
362+
const { schema, collection: { collectionName } } = model;
363+
const namespace = `${this.$dbName}.${collectionName}`;
364+
schemaMap[namespace] = schema._buildSchemaMap();
365+
return schemaMap;
366+
},
367+
{}
368+
);
369+
370+
const encryptedFieldsMap = Object.values(this.models).filter(model => model.schema.encryptionType() === 'qe').reduce(
371+
(encryptedFieldsMap, model) => {
372+
const { schema, collection: { collectionName } } = model;
373+
const namespace = `${this.$dbName}.${collectionName}`;
374+
encryptedFieldsMap[namespace] = schema._buildEncryptedFields();
375+
return encryptedFieldsMap;
376+
},
377+
{}
378+
);
379+
380+
return {
381+
schemaMap, encryptedFieldsMap
382+
};
383+
};
384+
341385
/*!
342386
* ignore
343387
*/
@@ -358,7 +402,7 @@ NativeConnection.prototype.setClient = function setClient(client) {
358402

359403
for (const model of Object.values(this.models)) {
360404
// Errors handled internally, so safe to ignore error
361-
model.init().catch(function $modelInitNoop() {});
405+
model.init().catch(function $modelInitNoop() { });
362406
}
363407

364408
return this;
@@ -401,9 +445,9 @@ function _setClient(conn, client, options, dbName) {
401445
};
402446

403447
const type = client &&
404-
client.topology &&
405-
client.topology.description &&
406-
client.topology.description.type || '';
448+
client.topology &&
449+
client.topology.description &&
450+
client.topology.description.type || '';
407451

408452
if (type === 'Single') {
409453
client.on('serverDescriptionChanged', ev => {

lib/encryption_utils.js

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
'use strict';
2+
3+
const { Array } = require('./schema/index.js');
4+
const SchemaBigInt = require('./schema/bigint');
5+
const SchemaBoolean = require('./schema/boolean');
6+
const SchemaBuffer = require('./schema/buffer');
7+
const SchemaDate = require('./schema/date');
8+
const SchemaDecimal128 = require('./schema/decimal128');
9+
const SchemaDouble = require('./schema/double');
10+
const SchemaInt32 = require('./schema/int32');
11+
const SchemaObjectId = require('./schema/objectId');
12+
const SchemaString = require('./schema/string');
13+
14+
/**
15+
* Given a schema and a path to a field in the schema, this returns the
16+
* BSON type of the field, if it can be determined. This method specifically
17+
* **only** handles BSON types that are used for CSFLE and QE - any other
18+
* BSON types will return `null`. (example: MinKey and MaxKey).
19+
*
20+
* @param {import('.').Schema} schema
21+
* @param {string} path
22+
* @returns
23+
*/
24+
function inferBSONType(schema, path) {
25+
const type = schema.path(path);
26+
27+
if (type instanceof SchemaString) {
28+
return 'string';
29+
}
30+
31+
if (type instanceof SchemaInt32) {
32+
return 'int';
33+
}
34+
35+
if (type instanceof SchemaBigInt) {
36+
return 'long';
37+
}
38+
39+
if (type instanceof SchemaBoolean) {
40+
return 'bool';
41+
}
42+
43+
if (type instanceof SchemaDate) {
44+
return 'date';
45+
}
46+
47+
if (type instanceof SchemaBuffer) {
48+
return 'binData';
49+
}
50+
51+
if (type instanceof SchemaObjectId) {
52+
return 'objectId';
53+
}
54+
55+
if (type instanceof SchemaDecimal128) {
56+
return 'decimal';
57+
}
58+
59+
if (type instanceof SchemaDouble) {
60+
return 'double';
61+
}
62+
63+
if (type instanceof Array) {
64+
return 'array';
65+
}
66+
67+
return null;
68+
}
69+
70+
module.exports = {
71+
inferBSONType
72+
};

lib/schema.js

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -916,6 +916,63 @@ Schema.prototype._hasEncryptedFields = function _hasEncryptedFields() {
916916
return Object.keys(this.encryptedFields).length > 0;
917917
};
918918

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

lib/utils.js

Lines changed: 21 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -89,19 +89,19 @@ exports.deepEqual = function deepEqual(a, b) {
8989
}
9090

9191
if ((isBsonType(a, 'ObjectId') && isBsonType(b, 'ObjectId')) ||
92-
(isBsonType(a, 'Decimal128') && isBsonType(b, 'Decimal128'))) {
92+
(isBsonType(a, 'Decimal128') && isBsonType(b, 'Decimal128'))) {
9393
return a.toString() === b.toString();
9494
}
9595

9696
if (a instanceof RegExp && b instanceof RegExp) {
9797
return a.source === b.source &&
98-
a.ignoreCase === b.ignoreCase &&
99-
a.multiline === b.multiline &&
100-
a.global === b.global &&
101-
a.dotAll === b.dotAll &&
102-
a.unicode === b.unicode &&
103-
a.sticky === b.sticky &&
104-
a.hasIndices === b.hasIndices;
98+
a.ignoreCase === b.ignoreCase &&
99+
a.multiline === b.multiline &&
100+
a.global === b.global &&
101+
a.dotAll === b.dotAll &&
102+
a.unicode === b.unicode &&
103+
a.sticky === b.sticky &&
104+
a.hasIndices === b.hasIndices;
105105
}
106106

107107
if (a == null || b == null) {
@@ -287,8 +287,8 @@ exports.merge = function merge(to, from, options, path) {
287287
// base schema has a given path as a single nested but discriminator schema
288288
// has the path as a document array, or vice versa (gh-9534)
289289
if (options.isDiscriminatorSchemaMerge &&
290-
(from[key].$isSingleNested && to[key].$isMongooseDocumentArray) ||
291-
(from[key].$isMongooseDocumentArray && to[key].$isSingleNested)) {
290+
(from[key].$isSingleNested && to[key].$isMongooseDocumentArray) ||
291+
(from[key].$isMongooseDocumentArray && to[key].$isSingleNested)) {
292292
continue;
293293
} else if (from[key].instanceOfSchema) {
294294
if (to[key].instanceOfSchema) {
@@ -995,7 +995,7 @@ exports.getOption = function(name) {
995995
* ignore
996996
*/
997997

998-
exports.noop = function() {};
998+
exports.noop = function() { };
999999

10001000
exports.errorToPOJO = function errorToPOJO(error) {
10011001
const isError = error instanceof Error;
@@ -1025,3 +1025,13 @@ exports.injectTimestampsOption = function injectTimestampsOption(writeOperation,
10251025
}
10261026
writeOperation.timestamps = timestampsOption;
10271027
};
1028+
1029+
exports.print = function(...args) {
1030+
const { inspect } = require('util');
1031+
console.error(
1032+
inspect(
1033+
...args,
1034+
{ depth: Infinity }
1035+
)
1036+
);
1037+
};

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -146,4 +146,4 @@
146146
"target": "ES2017"
147147
}
148148
}
149-
}
149+
}

scripts/configure-cluster-with-encryption.sh

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -51,4 +51,4 @@ if [ ! -d "data" ]; then
5151
echo 'Cluster Configuration Finished!'
5252

5353
cd ..
54-
fi
54+
fi

0 commit comments

Comments
 (0)