Skip to content

Commit

Permalink
Add support for encrypted models
Browse files Browse the repository at this point in the history
  • Loading branch information
baileympearson committed Jan 23, 2025
1 parent a6a51bd commit be4563e
Show file tree
Hide file tree
Showing 14 changed files with 1,473 additions and 211 deletions.
10 changes: 10 additions & 0 deletions docs/field-level-encryption.md
Original file line number Diff line number Diff line change
Expand Up @@ -150,3 +150,13 @@ To declare a field as encrypted, you must:
2. Choose an encryption type for the schema and configure the schema for the encryption type

Not all schematypes are supported for CSFLE and QE. For an overview of valid schema types, refer to MongoDB's documentation.

### Registering Models

Encrypted schemas must be registered on a connection, not the Mongoose global:

```javascript

const connection = mongoose.createConnection();
const UserModel = connection.model('User', encryptedUserSchema);
```
2 changes: 1 addition & 1 deletion lib/collection.js
Original file line number Diff line number Diff line change
Expand Up @@ -81,7 +81,7 @@ Collection.prototype.onOpen = function() {
* @api private
*/

Collection.prototype.onClose = function() {};
Collection.prototype.onClose = function() { };

/**
* Queues a method for later execution when its
Expand Down
20 changes: 10 additions & 10 deletions lib/connection.js
Original file line number Diff line number Diff line change
Expand Up @@ -607,7 +607,7 @@ Connection.prototype.bulkWrite = async function bulkWrite(ops, options) {

Connection.prototype.createCollections = async function createCollections(options = {}) {
const result = {};
const errorsMap = { };
const errorsMap = {};

const { continueOnError } = options;
delete options.continueOnError;
Expand Down Expand Up @@ -734,7 +734,7 @@ Connection.prototype.transaction = function transaction(fn, options) {
throw err;
}).
finally(() => {
session.endSession().catch(() => {});
session.endSession().catch(() => { });
});
});
};
Expand Down Expand Up @@ -1025,7 +1025,7 @@ Connection.prototype.openUri = async function openUri(uri, options) {

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

// `createConnection()` calls this `openUri()` function without
Expand Down Expand Up @@ -1061,7 +1061,7 @@ Connection.prototype.openUri = async function openUri(uri, options) {
// to avoid uncaught exceptions when using `on('error')`. See gh-14377.
Connection.prototype.on = function on(event, callback) {
if (event === 'error' && this.$initialConnection) {
this.$initialConnection.catch(() => {});
this.$initialConnection.catch(() => { });
}
return EventEmitter.prototype.on.call(this, event, callback);
};
Expand All @@ -1083,7 +1083,7 @@ Connection.prototype.on = function on(event, callback) {
// to avoid uncaught exceptions when using `on('error')`. See gh-14377.
Connection.prototype.once = function on(event, callback) {
if (event === 'error' && this.$initialConnection) {
this.$initialConnection.catch(() => {});
this.$initialConnection.catch(() => { });
}
return EventEmitter.prototype.once.call(this, event, callback);
};
Expand Down Expand Up @@ -1412,7 +1412,7 @@ Connection.prototype.model = function model(name, schema, collection, options) {
}

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

return model;
}
Expand All @@ -1439,7 +1439,7 @@ Connection.prototype.model = function model(name, schema, collection, options) {
}

if (this === model.prototype.db
&& (!collection || collection === model.collection.name)) {
&& (!collection || collection === model.collection.name)) {
// model already uses this connection.

// only the first model with this name is cached to allow
Expand Down Expand Up @@ -1626,8 +1626,8 @@ Connection.prototype.authMechanismDoesNotRequirePassword = function authMechanis
*/
Connection.prototype.optionsProvideAuthenticationData = function optionsProvideAuthenticationData(options) {
return (options) &&
(options.user) &&
((options.pass) || this.authMechanismDoesNotRequirePassword());
(options.user) &&
((options.pass) || this.authMechanismDoesNotRequirePassword());
};

/**
Expand Down Expand Up @@ -1689,7 +1689,7 @@ Connection.prototype.createClient = function createClient() {
*/
Connection.prototype.syncIndexes = async function syncIndexes(options = {}) {
const result = {};
const errorsMap = { };
const errorsMap = {};

const { continueOnError } = options;
delete options.continueOnError;
Expand Down
52 changes: 48 additions & 4 deletions lib/drivers/node-mongodb-native/connection.js
Original file line number Diff line number Diff line change
Expand Up @@ -315,6 +315,16 @@ NativeConnection.prototype.createClient = async function createClient(uri, optio
};
}

const { schemaMap, encryptedFieldsMap } = this._buildEncryptionSchemas();

if (Object.keys(schemaMap).length > 0) {
options.autoEncryption.schemaMap = schemaMap;
}

if (Object.keys(encryptedFieldsMap).length > 0) {
options.autoEncryption.encryptedFieldsMap = encryptedFieldsMap;
}

this.readyState = STATES.connecting;
this._connectionString = uri;

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

/**
* Given a connection, which may or may not have encrypted models, build
* a schemaMap and/or an encryptedFieldsMap for the connection, combining all models
* into a single schemaMap and encryptedFields map.
*
* @returns a copy of the options object with a schemaMap and/or an encryptedFieldsMap added to the options' autoEncryption
* options.
*/
NativeConnection.prototype._buildEncryptionSchemas = function() {
const schemaMap = Object.values(this.models).filter(model => model.schema.encryptionType() === 'csfle').reduce(
(schemaMap, model) => {
const { schema, collection: { collectionName } } = model;
const namespace = `${this.$dbName}.${collectionName}`;
schemaMap[namespace] = schema._buildSchemaMap();
return schemaMap;
},
{}
);

const encryptedFieldsMap = Object.values(this.models).filter(model => model.schema.encryptionType() === 'qe').reduce(
(encryptedFieldsMap, model) => {
const { schema, collection: { collectionName } } = model;
const namespace = `${this.$dbName}.${collectionName}`;
encryptedFieldsMap[namespace] = schema._buildEncryptedFields();
return encryptedFieldsMap;
},
{}
);

return {
schemaMap, encryptedFieldsMap
};
};

/*!
* ignore
*/
Expand All @@ -358,7 +402,7 @@ NativeConnection.prototype.setClient = function setClient(client) {

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

return this;
Expand Down Expand Up @@ -401,9 +445,9 @@ function _setClient(conn, client, options, dbName) {
};

const type = client &&
client.topology &&
client.topology.description &&
client.topology.description.type || '';
client.topology &&
client.topology.description &&
client.topology.description.type || '';

if (type === 'Single') {
client.on('serverDescriptionChanged', ev => {
Expand Down
72 changes: 72 additions & 0 deletions lib/encryption_utils.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
'use strict';

const { Array } = require('./schema/index.js');
const SchemaBigInt = require('./schema/bigint');
const SchemaBoolean = require('./schema/boolean');
const SchemaBuffer = require('./schema/buffer');
const SchemaDate = require('./schema/date');
const SchemaDecimal128 = require('./schema/decimal128');
const SchemaDouble = require('./schema/double');
const SchemaInt32 = require('./schema/int32');
const SchemaObjectId = require('./schema/objectId');
const SchemaString = require('./schema/string');

/**
* Given a schema and a path to a field in the schema, this returns the
* BSON type of the field, if it can be determined. This method specifically
* **only** handles BSON types that are used for CSFLE and QE - any other
* BSON types will return `null`. (example: MinKey and MaxKey).
*
* @param {import('.').Schema} schema
* @param {string} path
* @returns
*/
function inferBSONType(schema, path) {
const type = schema.path(path);

if (type instanceof SchemaString) {
return 'string';
}

if (type instanceof SchemaInt32) {
return 'int';
}

if (type instanceof SchemaBigInt) {
return 'long';
}

if (type instanceof SchemaBoolean) {
return 'bool';
}

if (type instanceof SchemaDate) {
return 'date';
}

if (type instanceof SchemaBuffer) {
return 'binData';
}

if (type instanceof SchemaObjectId) {
return 'objectId';
}

if (type instanceof SchemaDecimal128) {
return 'decimal';
}

if (type instanceof SchemaDouble) {
return 'double';
}

if (type instanceof Array) {
return 'array';
}

return null;
}

module.exports = {
inferBSONType
};
57 changes: 57 additions & 0 deletions lib/schema.js
Original file line number Diff line number Diff line change
Expand Up @@ -916,6 +916,63 @@ Schema.prototype._hasEncryptedFields = function _hasEncryptedFields() {
return Object.keys(this.encryptedFields).length > 0;
};

Schema.prototype._buildEncryptedFields = function() {
const fields = Object.entries(this.encryptedFields).map(
([path, config]) => {
const bsonType = inferBSONType(this, path);
// { path, bsonType, keyId, queries? }
return { path, bsonType, ...config };
});

return { fields };
};

Schema.prototype._buildSchemaMap = function() {
/**
* `schemaMap`s are JSON schemas, which use the following structure to represent objects:
* { field: { bsonType: 'object', properties: { ... } } }
*
* for example, a schema that looks like this `{ a: { b: int32 } }` would be encoded as
* `{ a: { bsonType: 'object', properties: { b: < encryption configuration > } } }`
*
* This function takes an array of path segments, an output object (that gets mutated) and
* a value to associated with the full path, and constructs a valid CSFLE JSON schema path for
* the object. This works for deeply nested properties as well.
*
* @param {string[]} path array of path components
* @param {object} object the object in which to build a JSON schema of `path`'s properties
* @param {object} value the value to associate with the path in object
*/
function buildNestedPath(path, object, value) {
let i = 0, component = path[i];
for (; i < path.length - 1; ++i, component = path[i]) {
object[component] = object[component] == null ? {
bsonType: 'object',
properties: {}
} : object[component];
object = object[component].properties;
}
object[component] = value;
}

const schemaMapPropertyReducer = (accum, [path, propertyConfig]) => {
const bsonType = inferBSONType(this, path);
const pathComponents = path.split('.');
const configuration = { encrypt: { ...propertyConfig, bsonType } };
buildNestedPath(pathComponents, accum, configuration);
return accum;
};

const properties = Object.entries(this.encryptedFields).reduce(
schemaMapPropertyReducer,
{});

return {
bsonType: 'object',
properties
};
};

/**
* Add an alias for `path`. This means getting or setting the `alias`
* is equivalent to getting or setting the `path`.
Expand Down
32 changes: 21 additions & 11 deletions lib/utils.js
Original file line number Diff line number Diff line change
Expand Up @@ -89,19 +89,19 @@ exports.deepEqual = function deepEqual(a, b) {
}

if ((isBsonType(a, 'ObjectId') && isBsonType(b, 'ObjectId')) ||
(isBsonType(a, 'Decimal128') && isBsonType(b, 'Decimal128'))) {
(isBsonType(a, 'Decimal128') && isBsonType(b, 'Decimal128'))) {
return a.toString() === b.toString();
}

if (a instanceof RegExp && b instanceof RegExp) {
return a.source === b.source &&
a.ignoreCase === b.ignoreCase &&
a.multiline === b.multiline &&
a.global === b.global &&
a.dotAll === b.dotAll &&
a.unicode === b.unicode &&
a.sticky === b.sticky &&
a.hasIndices === b.hasIndices;
a.ignoreCase === b.ignoreCase &&
a.multiline === b.multiline &&
a.global === b.global &&
a.dotAll === b.dotAll &&
a.unicode === b.unicode &&
a.sticky === b.sticky &&
a.hasIndices === b.hasIndices;
}

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

exports.noop = function() {};
exports.noop = function() { };

exports.errorToPOJO = function errorToPOJO(error) {
const isError = error instanceof Error;
Expand Down Expand Up @@ -1025,3 +1025,13 @@ exports.injectTimestampsOption = function injectTimestampsOption(writeOperation,
}
writeOperation.timestamps = timestampsOption;
};

exports.print = function(...args) {
const { inspect } = require('util');
console.error(
inspect(
...args,
{ depth: Infinity }
)
);
};
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -146,4 +146,4 @@
"target": "ES2017"
}
}
}
}
2 changes: 1 addition & 1 deletion scripts/configure-cluster-with-encryption.sh
Original file line number Diff line number Diff line change
Expand Up @@ -51,4 +51,4 @@ if [ ! -d "data" ]; then
echo 'Cluster Configuration Finished!'

cd ..
fi
fi
Loading

0 comments on commit be4563e

Please sign in to comment.