Skip to content

feat(NODE-6507): generate encryption configuration on mongoose connect #6

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Closed
wants to merge 5 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion .eslintrc.js
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,8 @@ module.exports = {
'!.*',
'node_modules',
'.git',
'data'
'data',
'.config'
],
overrides: [
{
Expand Down
48 changes: 48 additions & 0 deletions docs/field-level-encryption.md
Original file line number Diff line number Diff line change
Expand Up @@ -112,3 +112,51 @@ With the above connection, if you create a model named 'Test' that uses the 'tes
const Model = mongoose.model('Test', mongoose.Schema({ name: String }));
await Model.create({ name: 'super secret' });
```

## Automatic FLE in Mongoose

Mongoose supports the declaration of encrypted schemas - schemas that, when connected to a model, utilize MongoDB's Client Side
Field Level Encryption or Queryable Encryption under the hood. Mongoose automatically generates either an `encryptedFieldsMap` or a
`schemaMap` when instantiating a MongoClient and encrypts fields on write and decrypts fields on reads.

### Encryption types

MongoDB has two different automatic encryption implementations: client side field level encryption (CSFLE) and queryable encryption (QE).
See [choosing an in-use encryption approach](https://www.mongodb.com/docs/v7.3/core/queryable-encryption/about-qe-csfle/#choosing-an-in-use-encryption-approach).

### Declaring Encrypted Schemas

The following schema declares two properties, `name` and `ssn`. `ssn` is encrypted using queryable encryption, and
is configured for equality queries:

```javascript
const encryptedUserSchema = new Schema({
name: String,
ssn: {
type: String,
// 1
encrypt: {
keyId: '<uuid string of key id>',
queries: 'equality'
}
}
// 2
}, { encryptionType: 'queryableEncryption' });
```

To declare a field as encrypted, you must:

1. Annotate the field with encryption metadata in the schema definition
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(() => { });
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please avoid making these sort of minor style changes, they make review difficult and may cause linter issues

}
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
68 changes: 64 additions & 4 deletions lib/drivers/node-mongodb-native/connection.js
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ const pkg = require('../../../package.json');
const processConnectionOptions = require('../../helpers/processConnectionOptions');
const setTimeout = require('../../helpers/timers').setTimeout;
const utils = require('../../utils');
const { Schema } = require('../../mongoose');

/**
* A [node-mongodb-native](https://github.com/mongodb/node-mongodb-native) connection implementation.
Expand Down Expand Up @@ -315,6 +316,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 +349,55 @@ 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 qeMappings = {};
const csfleMappings = {};

// If discriminators are configured for the collection, there might be multiple models
// pointing to the same namespace. For this scenario, we merge all the schemas for each namespace
// into a single schema.
// Notably, this doesn't allow for discriminators to declare multiple values on the same fields.
for (const model of Object.values(this.models)) {
const { schema, collection: { collectionName } } = model;
const namespace = `${this.$dbName}.${collectionName}`;
if (schema.encryptionType() === 'csfle') {
csfleMappings[namespace] ??= new Schema({}, { encryptionType: 'csfle' });
csfleMappings[namespace].add(schema);
} else if (schema.encryptionType() === 'queryableEncryption') {
qeMappings[namespace] ??= new Schema({}, { encryptionType: 'queryableEncryption' });
qeMappings[namespace].add(schema);
}
}

const schemaMap = Object.entries(csfleMappings).reduce(
(schemaMap, [namespace, schema]) => {
schemaMap[namespace] = schema._buildSchemaMap();
return schemaMap;
},
{}
);

const encryptedFieldsMap = Object.entries(qeMappings).reduce(
(encryptedFieldsMap, [namespace, schema]) => {
encryptedFieldsMap[namespace] = schema._buildEncryptedFields();
return encryptedFieldsMap;
},
{}
);

return {
schemaMap, encryptedFieldsMap
};
};

/*!
* ignore
*/
Expand All @@ -358,7 +418,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 +461,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/encryptionUtils.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
'use strict';

const schemaTypes = 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 {string}
*/
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 schemaTypes.Array) {
return 'array';
}

return null;
}

module.exports = {
inferBSONType
};
Loading
Loading