Skip to content

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

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

Merged
merged 16 commits into from
Apr 15, 2025
Merged
Show file tree
Hide file tree
Changes from 12 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
65 changes: 65 additions & 0 deletions docs/field-level-encryption.md
Original file line number Diff line number Diff line change
Expand Up @@ -151,3 +151,68 @@ 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);
```

### Connecting and configuring encryption options

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.

Queryable encryption and CSFLE requires all the same configuration as outlined in <>, except for the schemaMap or encryptedFieldsMap options.

```javascript
const keyVaultNamespace = 'client.encryption';
const kmsProviders = { local: { key } };
await connection.openUri(`mongodb://localhost:27017`, {
// Configure auto encryption
autoEncryption: {
keyVaultNamespace: 'datakeys.datakeys',
kmsProviders
}
});
```

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.

### Discriminators

Discriminators are supported for encrypted models as well:

```javascript
const connection = createConnection();

const schema = new Schema({
name: {
type: String, encrypt: { keyId }
}
}, {
encryptionType: 'queryableEncryption'
});

const Model = connection.model('BaseUserModel', schema);
const ModelWithAge = model.discriminator('ModelWithAge', new Schema({
age: {
type: Int32, encrypt: { keyId: keyId2 }
}
}, {
encryptionType: 'queryableEncryption'
}));

const ModelWithBirthday = model.discriminator('ModelWithBirthday', new Schema({
dob: {
type: Int32, encrypt: { keyId: keyId3 }
}
}, {
encryptionType: 'queryableEncryption'
}));
```

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.
65 changes: 65 additions & 0 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('../../schema');

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

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

if ((Object.keys(schemaMap).length > 0 || Object.keys(encryptedFieldsMap).length) && !options.autoEncryption) {
throw new Error('Must provide `autoEncryption` when connecting with encrypted schemas.');
}

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 @@ -343,6 +358,56 @@ 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 the generated schemaMap and encryptedFieldsMap
*/
NativeConnection.prototype._buildEncryptionSchemas = function() {
const qeMappings = {};
const csfleMappings = {};

const encryptedModels = Object.values(this.models).filter(model => model.schema._hasEncryptedFields());

// 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 and then generate a schemaMap/encryptedFieldsMap for the combined schema.
for (const model of encryptedModels) {
const { schema, collection: { collectionName } } = model;
const namespace = `${this.$dbName}.${collectionName}`;
const mappings = schema.encryptionType() === 'csfle' ? csfleMappings : qeMappings;

mappings[namespace] ??= new Schema({}, { encryptionType: schema.encryptionType() });

const isNonRootDiscriminator = schema.discriminatorMapping && !schema.discriminatorMapping.isRoot;
if (isNonRootDiscriminator) {
const rootSchema = schema._baseSchema;
schema.eachPath((pathname) => {
Copy link
Collaborator

Choose a reason for hiding this comment

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

Potential edge case here: discriminator base schema defines nested path, discriminator child schema defines subdocument with same path but different options.

const schema = new Schema({
  name: {
    first: { type: String, encrypt: { keyId: [keyId], algorithm } }
  }
});

const discriminatorSchema = new Schema({
  name: new Schema({ first: Number }) // Different type, no encryption, stored as same field in MDB
});

schema.eachpath() doesn't account for subdocuments because subdocuments have a distinct schema (it does account for nested paths though because nested paths do not have their own schema)

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Good catch - I wrote a test for this scenario and you were right.

I've been trying out some different approaches and I can't find a better solution than something like this:

  function* allPaths(schema, prefix) {
    for (const path of Object.keys(schema.paths)) {
      const fullPath = prefix != null ? `${prefix}.${path}` : path;
      if (schema.path(path).instance === 'Embedded') {
        yield* allPaths(schema.path(path).schema, fullPath);
      } else {
        yield fullPath;
      }
    }
  }

  const paths = new Set(allPaths(schema));

  for (const path of allPaths(model.schema)) {
    if (paths.has(path) && (model.schema._hasEncryptedField(path) || schema._hasEncryptedField(path))) {
      throw new Error(`cannot declare an encrypted field on child schema overriding base schema. key=${path}`);
    }
  }

This generates all possible paths in the schema (the above doesn't handle arrays, but encryption on fields in arrays isn't supported so that's out of scope here). Not my first choice, because this feels brittle. I'll keep looking at it but

  1. Can you think of a better solution here than recursively iterating over all paths + nested schemas?
  2. If not, does a utility that I could use like allPaths exist somewhere?

Copy link
Collaborator

Choose a reason for hiding this comment

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

I don't think recursively checking all paths is necessary here because you just want to find conflicts in the top-level paths. So if discriminator schema has a path pathname with an encrypted field, and root schema has a nested path with rootSchema.nested[pathname.split('.')[0]], you can already call that a conflict and throw an error. Similarly, if discriminator schema has a nested path pathname but in root schema you have rootSchema.paths[pathname] then you can also throw an error.

Copy link
Contributor Author

@baileympearson baileympearson Apr 3, 2025

Choose a reason for hiding this comment

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

Not necessarily, right? That would throw an error in scenarios where the child schema provides a subdocument with the same root path but doesn't modify the encrypted path. ex:

const schema = new Schema({
  name: {
    first: { type: String, encrypt: { keyId: [keyId], algorithm } }
  }
});

const discriminatorSchema = new Schema({
  name: new Schema({ age: Number }) // Different path, no encryption
});

I'd expect this to be fine, because there isn't a conflicting path for name.first.

Copy link
Collaborator

Choose a reason for hiding this comment

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

In the case you described, name: new Schema({ age: Number }) would actually override name: { first: { type: String } }, so the discriminator schema would not have a name.first property at all. Discriminators merge nested paths from root schema, but subdocuments override because subdocuments can have middleware.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I tried the approach of determining conflicting base paths, as you suggested but I ran into two complications:

  1. It isn't enough to simply detect base path collisions, you need to check that something in the schema also has an encrypted field that uses the base path as well. This is feasible but requires some logic to allow for asking "is there an encrypted field at some subpath of this path?"
  2. It felt like there were more corner cases than not - schemas with only one level of nesting, one level of subdocuments, sub documents on parent but not child and vice versa, multiple layers of nesting of subdocuments, etc.

I decided that the two above points complicated the approach. The changes in this PR were much simpler for me to work with.. This approach works by:

  1. finding the intersection of all keypaths for all fields (including subdocuments) between the parent and child
  2. for each keypath, validates that this keypath isn't encrypted on the parent and on the child

Let me know what you think - I can rework it if you would prefer a different approach.

Copy link
Collaborator

Choose a reason for hiding this comment

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

I agree, this is a reasonable approach. This approach is more restrictive, but simpler implementation. If it proves to be too restrictive, we can come up with a more flexible approach.

if (rootSchema.path(pathname)) return;
if (!mappings[namespace]._hasEncryptedField(pathname)) return;

throw new Error(`Cannot have duplicate keys in discriminators with encryption. key=${pathname}`);
});
}

mappings[namespace].add(schema);
}

const schemaMap = Object.fromEntries(Object.entries(csfleMappings).map(
([namespace, schema]) => ([namespace, schema._buildSchemaMap()])
));

const encryptedFieldsMap = Object.fromEntries(Object.entries(qeMappings).map(
([namespace, schema]) => ([namespace, schema._buildEncryptedFields()])
));

return {
schemaMap, encryptedFieldsMap
};
};

/*!
* ignore
*/
Expand Down
7 changes: 7 additions & 0 deletions lib/helpers/model/discriminator.js
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,13 @@ module.exports = function discriminator(model, name, schema, tiedValue, applyPlu
const baseSchemaPaths = Object.keys(baseSchema.paths);
const conflictingPaths = [];


baseSchema.eachPath((pathname) => {
if (schema._hasEncryptedField(pathname)) {
throw new Error(`cannot declare an encrypted field on child schema overriding base schema. key=${pathname}`);
}
});

for (const path of baseSchemaPaths) {
if (schema.nested[path]) {
conflictingPaths.push(path);
Expand Down
72 changes: 71 additions & 1 deletion lib/schema.js
Original file line number Diff line number Diff line change
Expand Up @@ -721,7 +721,6 @@ Schema.prototype.encryptionType = function encryptionType(encryptionType) {
Schema.prototype.add = function add(obj, prefix) {
if (obj instanceof Schema || (obj != null && obj.instanceOfSchema)) {
merge(this, obj);

return this;
}

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

/**
* @api private
*/
Schema.prototype._hasEncryptedField = function _hasEncryptedField(path) {
return path in this.encryptedFields;
};


/**
* Builds an encryptedFieldsMap for the schema.
*/
Schema.prototype._buildEncryptedFields = function() {
const fields = Object.entries(this.encryptedFields).map(
([path, config]) => {
const bsonType = this.path(path).autoEncryptionType();
// { path, bsonType, keyId, queries? }
return { path, bsonType, ...config };
});

return { fields };
};

/**
* Builds a schemaMap for the schema, if the schema is configured for client-side field level encryption.
*/
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 = this.path(path).autoEncryptionType();
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
2 changes: 1 addition & 1 deletion lib/schema/bigint.js
Original file line number Diff line number Diff line change
Expand Up @@ -255,7 +255,7 @@ SchemaBigInt.prototype.toJSONSchema = function toJSONSchema(options) {
};

SchemaBigInt.prototype.autoEncryptionType = function autoEncryptionType() {
return 'int64';
return 'long';
};

/*!
Expand Down
2 changes: 1 addition & 1 deletion lib/schema/boolean.js
Original file line number Diff line number Diff line change
Expand Up @@ -305,7 +305,7 @@ SchemaBoolean.prototype.toJSONSchema = function toJSONSchema(options) {
};

SchemaBoolean.prototype.autoEncryptionType = function autoEncryptionType() {
return 'boolean';
return 'bool';
};

/*!
Expand Down
2 changes: 1 addition & 1 deletion lib/schema/buffer.js
Original file line number Diff line number Diff line change
Expand Up @@ -315,7 +315,7 @@ SchemaBuffer.prototype.toJSONSchema = function toJSONSchema(options) {
};

SchemaBuffer.prototype.autoEncryptionType = function autoEncryptionType() {
return 'binary';
return 'binData';
};

/*!
Expand Down
2 changes: 1 addition & 1 deletion lib/schema/decimal128.js
Original file line number Diff line number Diff line change
Expand Up @@ -236,7 +236,7 @@ SchemaDecimal128.prototype.toJSONSchema = function toJSONSchema(options) {
};

SchemaDecimal128.prototype.autoEncryptionType = function autoEncryptionType() {
return 'decimal128';
return 'decimal';
};

/*!
Expand Down
2 changes: 1 addition & 1 deletion lib/schema/int32.js
Original file line number Diff line number Diff line change
Expand Up @@ -261,7 +261,7 @@ SchemaInt32.prototype.toJSONSchema = function toJSONSchema(options) {
};

SchemaInt32.prototype.autoEncryptionType = function autoEncryptionType() {
return 'int32';
return 'int';
};


Expand Down
4 changes: 4 additions & 0 deletions lib/schema/map.js
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,10 @@ class SchemaMap extends SchemaType {

return result;
}

autoEncryptionType() {
return 'object';
}
}

/**
Expand Down
2 changes: 1 addition & 1 deletion lib/schema/objectId.js
Original file line number Diff line number Diff line change
Expand Up @@ -305,7 +305,7 @@ SchemaObjectId.prototype.toJSONSchema = function toJSONSchema(options) {
};

SchemaObjectId.prototype.autoEncryptionType = function autoEncryptionType() {
return 'objectid';
return 'objectId';
};

/*!
Expand Down
16 changes: 7 additions & 9 deletions scripts/configure-cluster-with-encryption.sh
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,11 @@

# this script downloads all tools required to use FLE with mongodb, then starts a cluster of the provided configuration (sharded on 8.0 server)

export CWD=$(pwd);
export DRIVERS_TOOLS_PINNED_COMMIT=35d0592c76f4f3d25a5607895eb21b491dd52543;
export CWD=$(pwd)
export DRIVERS_TOOLS_PINNED_COMMIT=4e18803c074231ec9fc3ace8f966e2c49d9874bb

# install extra dependency
npm install mongodb-client-encryption
npm install --no-save mongodb-client-encryption

# set up mongodb cluster and encryption configuration if the data/ folder does not exist
if [ ! -d "data" ]; then
Expand All @@ -33,11 +33,9 @@ if [ ! -d "data" ]; then

# configure cluster settings
export DRIVERS_TOOLS=$CWD/data/drivers-evergreen-tools
export MONGODB_VERSION=8.0
export AUTH=true
export AUTH=auth
export MONGODB_BINARIES=$DRIVERS_TOOLS/mongodb/bin
export MONGO_ORCHESTRATION_HOME=$DRIVERS_TOOLS/mo
export PROJECT_ORCHESTRATION_HOME=$DRIVERS_TOOLS/.evergreen/orchestration
export MONGO_ORCHESTRATION_HOME=$DRIVERS_TOOLS/.evergreen/orchestration
export TOPOLOGY=sharded_cluster
export SSL=nossl

Expand All @@ -46,12 +44,12 @@ if [ ! -d "data" ]; then
mkdir mo
cd -

rm expansions.sh 2> /dev/null
rm expansions.sh 2>/dev/null

echo 'Configuring Cluster...'

# start cluster
(bash $DRIVERS_TOOLS/.evergreen/run-orchestration.sh) 1> /dev/null 2> /dev/null
(bash $DRIVERS_TOOLS/.evergreen/run-orchestration.sh) 1>/dev/null 2>/dev/null

echo 'Cluster Configuration Finished!'

Expand Down
Loading
Loading