Skip to content

Commit 7609cb6

Browse files
authored
Merge pull request #15390 from Automattic/csfle
CSFLE support
2 parents e5dcf43 + a7e7ce5 commit 7609cb6

33 files changed

+3312
-101
lines changed

.eslintrc.js

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,8 @@ module.exports = {
1414
'!.*',
1515
'node_modules',
1616
'.git',
17-
'data'
17+
'data',
18+
'.config'
1819
],
1920
overrides: [
2021
{

.github/workflows/encryption-tests.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ on:
44
push:
55
branches: ['master']
66
pull_request:
7-
branches: [ 'master' ]
7+
branches: [ 'master', 'csfle' ]
88
workflow_dispatch: {}
99

1010
permissions:

.gitignore

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -72,4 +72,4 @@ notes.md
7272
list.out
7373

7474
data
75-
*.pid
75+
fle-cluster-config.json

docs/field-level-encryption.md

Lines changed: 134 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -16,11 +16,141 @@ The resulting document will look similar to the following to a client that doesn
1616

1717
You can read more about CSFLE on the [MongoDB CSFLE documentation](https://www.mongodb.com/docs/manual/core/csfle/) and [this blog post about CSFLE in Node.js](https://www.mongodb.com/developer/languages/javascript/client-side-field-level-encryption-csfle-mongodb-node/).
1818

19-
Note that Mongoose does **not** currently have any Mongoose-specific APIs for CSFLE.
20-
Mongoose defers all CSFLE-related work to the MongoDB Node.js driver, so the [`autoEncryption` option](https://mongodb.github.io/node-mongodb-native/5.6/interfaces/AutoEncryptionOptions.html) for `mongoose.connect()` and `mongoose.createConnection()` is where you put all CSFLE-related configuration.
21-
Mongoose schemas currently don't support CSFLE configuration.
19+
## Automatic FLE in Mongoose
2220

23-
## Setting Up Field Level Encryption with Mongoose
21+
Mongoose supports the declaration of encrypted schemas - schemas that, when connected to a model, utilize MongoDB's Client Side
22+
Field Level Encryption or Queryable Encryption under the hood. Mongoose automatically generates either an `encryptedFieldsMap` or a
23+
`schemaMap` when instantiating a MongoClient and encrypts fields on write and decrypts fields on reads.
24+
25+
### Encryption types
26+
27+
MongoDB has two different automatic encryption implementations: client side field level encryption (CSFLE) and queryable encryption (QE).
28+
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).
29+
30+
### Declaring Encrypted Schemas
31+
32+
The following schema declares two properties, `name` and `ssn`. `ssn` is encrypted using queryable encryption, and
33+
is configured for equality queries:
34+
35+
```javascript
36+
const encryptedUserSchema = new Schema({
37+
name: String,
38+
ssn: {
39+
type: String,
40+
// 1
41+
encrypt: {
42+
keyId: '<uuid string of key id>',
43+
queries: 'equality'
44+
}
45+
}
46+
// 2
47+
}, { encryptionType: 'queryableEncryption' });
48+
```
49+
50+
To declare a field as encrypted, you must:
51+
52+
1. Annotate the field with encryption metadata in the schema definition
53+
2. Choose an encryption type for the schema and configure the schema for the encryption type
54+
55+
Not all schematypes are supported for CSFLE and QE. For an overview of supported BSON types, refer to MongoDB's documentation.
56+
57+
### Registering Models
58+
59+
Encrypted schemas can be registered on the global mongoose object or on a specific connection, so long as models are registered before the connection
60+
is established:
61+
62+
```javascript
63+
// specific connection
64+
const GlobalUserModel = mongoose.model('User', encryptedUserSchema);
65+
66+
// specific connection
67+
const connection = mongoose.createConnection();
68+
const UserModel = connection.model('User', encryptedUserSchema);
69+
```
70+
71+
### Connecting and configuring encryption options
72+
73+
Field level encryption in Mongoose works by generating the encryption schema that the MongoDB driver expects for each encrypted model on the connection. This happens automatically when the model's connection is established.
74+
75+
Queryable encryption and CSFLE require all the same configuration as outlined in the [MongoDB encryption in-use documentation](https://www.mongodb.com/docs/manual/core/security-in-use-encryption/), except for the schemaMap or encryptedFieldsMap options.
76+
77+
```javascript
78+
const keyVaultNamespace = 'client.encryption';
79+
const kmsProviders = { local: { key } };
80+
await connection.openUri(`mongodb://localhost:27017`, {
81+
// Configure auto encryption
82+
autoEncryption: {
83+
keyVaultNamespace: 'datakeys.datakeys',
84+
kmsProviders
85+
}
86+
});
87+
```
88+
89+
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.
90+
91+
### Discriminators
92+
93+
Discriminators are supported for encrypted models as well:
94+
95+
```javascript
96+
const connection = createConnection();
97+
98+
const schema = new Schema({
99+
name: {
100+
type: String, encrypt: { keyId }
101+
}
102+
}, {
103+
encryptionType: 'queryableEncryption'
104+
});
105+
106+
const Model = connection.model('BaseUserModel', schema);
107+
const ModelWithAge = model.discriminator('ModelWithAge', new Schema({
108+
age: {
109+
type: Int32, encrypt: { keyId: keyId2 }
110+
}
111+
}, {
112+
encryptionType: 'queryableEncryption'
113+
}));
114+
115+
const ModelWithBirthday = model.discriminator('ModelWithBirthday', new Schema({
116+
dob: {
117+
type: Int32, encrypt: { keyId: keyId3 }
118+
}
119+
}, {
120+
encryptionType: 'queryableEncryption'
121+
}));
122+
```
123+
124+
When generating encryption schemas, Mongoose merges all discriminators together for all of the discriminators declared on the same namespace. As a result, discriminators that declare the same key with different types are not supported. Furthermore, all discriminators for the same namespace must share the same encryption type - it is not possible to configure discriminators on the same model for both CSFLE and Queryable Encryption.
125+
126+
## Managing Data Keys
127+
128+
Mongoose provides a convenient API to obtain a [ClientEncryption](https://mongodb.github.io/node-mongodb-native/Next/classes/ClientEncryption.html)
129+
object configured to manage data keys in the key vault. A client encryption can be obtained with the `Model.clientEncryption()` helper:
130+
131+
```javascript
132+
const connection = createConnection();
133+
134+
const schema = new Schema({
135+
name: {
136+
type: String, encrypt: { keyId }
137+
}
138+
}, {
139+
encryptionType: 'queryableEncryption'
140+
});
141+
142+
const Model = connection.model('BaseUserModel', schema);
143+
await connection.openUri(`mongodb://localhost:27017`, {
144+
autoEncryption: {
145+
keyVaultNamespace: 'datakeys.datakeys',
146+
kmsProviders: { local: '....' }
147+
}
148+
});
149+
150+
const clientEncryption = Model.clientEncryption();
151+
```
152+
153+
## Manual FLE in Mongoose
24154

25155
First, you need to install the [mongodb-client-encryption npm package](https://www.npmjs.com/package/mongodb-client-encryption).
26156
This is MongoDB's official package for setting up encryption keys.

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/drivers/node-mongodb-native/index.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,3 +7,4 @@
77
exports.BulkWriteResult = require('./bulkWriteResult');
88
exports.Collection = require('./collection');
99
exports.Connection = require('./connection');
10+
exports.ClientEncryption = require('mongodb').ClientEncryption;

lib/helpers/model/discriminator.js

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,51 @@ 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+
return [...Object.keys(schema.paths), ...Object.keys(schema.singleNestedPaths)];
49+
}
50+
51+
/**
52+
* @param {Iterable<string>} i1
53+
* @param {Iterable<string>} i2
54+
*/
55+
function* setIntersection(i1, i2) {
56+
const s1 = new Set(i1);
57+
for (const item of i2) {
58+
if (s1.has(item)) {
59+
yield item;
60+
}
61+
}
62+
}
63+
}
64+
2065
/*!
2166
* ignore
2267
*/
@@ -80,6 +125,8 @@ module.exports = function discriminator(model, name, schema, tiedValue, applyPlu
80125
value = tiedValue;
81126
}
82127

128+
validateDiscriminatorSchemasForEncryption(model.schema, schema);
129+
83130
function merge(schema, baseSchema) {
84131
// Retain original schema before merging base schema
85132
schema._baseSchema = baseSchema;

lib/model.js

Lines changed: 33 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -69,7 +69,6 @@ const minimize = require('./helpers/minimize');
6969
const MongooseBulkSaveIncompleteError = require('./error/bulkSaveIncompleteError');
7070
const ObjectExpectedError = require('./error/objectExpected');
7171
const decorateBulkWriteResult = require('./helpers/model/decorateBulkWriteResult');
72-
7372
const modelCollectionSymbol = Symbol('mongoose#Model#collection');
7473
const modelDbSymbol = Symbol('mongoose#Model#db');
7574
const modelSymbol = require('./helpers/symbols').modelSymbol;
@@ -4893,6 +4892,39 @@ Model.compile = function compile(name, schema, collectionName, connection, base)
48934892
return model;
48944893
};
48954894

4895+
/**
4896+
* If auto encryption is enabled, returns a ClientEncryption instance that is configured with the same settings that
4897+
* Mongoose's underlying MongoClient is using. If the client has not yet been configured, returns null.
4898+
*
4899+
* @returns {ClientEncryption | null}
4900+
*/
4901+
Model.clientEncryption = function clientEncryption() {
4902+
const ClientEncryption = this.base.driver.get().ClientEncryption;
4903+
if (!ClientEncryption) {
4904+
throw new Error('The mongodb driver must be used to obtain a ClientEncryption object.');
4905+
}
4906+
4907+
const client = this.collection?.conn?.client;
4908+
4909+
if (!client) return null;
4910+
4911+
const autoEncryptionOptions = client.options.autoEncryption;
4912+
4913+
if (!autoEncryptionOptions) return null;
4914+
4915+
const {
4916+
keyVaultNamespace,
4917+
keyVaultClient,
4918+
kmsProviders,
4919+
credentialProviders,
4920+
proxyOptions,
4921+
tlsOptions
4922+
} = autoEncryptionOptions;
4923+
return new ClientEncryption(keyVaultClient ?? client,
4924+
{ keyVaultNamespace, kmsProviders, credentialProviders, proxyOptions, tlsOptions }
4925+
);
4926+
};
4927+
48964928
/**
48974929
* Update this model to use the new connection, including updating all internal
48984930
* references and creating a new `Collection` instance using the new connection.

0 commit comments

Comments
 (0)