Skip to content

Commit 3fbefcc

Browse files
add support for encrypted schemas
1 parent 4b8380e commit 3fbefcc

21 files changed

+809
-7
lines changed

.eslintrc.js

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

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,3 +70,4 @@ list.out
7070

7171
data
7272
*.pid
73+
mo-expansion*

docs/field-level-encryption.md

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -112,3 +112,41 @@ With the above connection, if you create a model named 'Test' that uses the 'tes
112112
const Model = mongoose.model('Test', mongoose.Schema({ name: String }));
113113
await Model.create({ name: 'super secret' });
114114
```
115+
116+
## Automatic FLE in Mongoose
117+
118+
Mongoose supports the declaration of encrypted schemas - schemas that, when connected to a model, utilize MongoDB's Client Side
119+
Field Level Encryption or Queryable Encryption under the hood. Mongoose automatically generates either an `encryptedFieldsMap` or a
120+
`schemaMap` when instantiating a MongoClient and encrypts fields on write and decrypts fields on reads.
121+
122+
### Encryption types
123+
124+
MongoDB has two different automatic encryption implementations: client side field level encryption (CSFLE) and queryable encryption (QE).
125+
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).
126+
127+
### Declaring Encrypted Schemas
128+
129+
The following schema declares two properties, `name` and `ssn`. `ssn` is encrypted using queryable encryption, and
130+
is configured for equality queries:
131+
132+
```javascript
133+
const encryptedUserSchema = new Schema({
134+
name: String,
135+
ssn: {
136+
type: String,
137+
// 1
138+
encrypt: {
139+
keyId: '<uuid string of key id>',
140+
queries: 'equality'
141+
}
142+
}
143+
// 2
144+
}, { encryptionType: 'queryableEncryption' });
145+
```
146+
147+
To declare a field as encrypted, you must:
148+
149+
1. Annotate the field with encryption metadata in the schema definition
150+
2. Choose an encryption type for the schema and configure the schema for the encryption type
151+
152+
Not all schematypes are supported for CSFLE and QE. For an overview of valid schema types, refer to MongoDB's documentation.

lib/schema.js

Lines changed: 100 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,7 @@ const numberRE = /^\d+$/;
8686
* - [pluginTags](https://mongoosejs.com/docs/guide.html#pluginTags): array of strings - defaults to `undefined`. If set and plugin called with `tags` option, will only apply that plugin to schemas with a matching tag.
8787
* - [virtuals](https://mongoosejs.com/docs/tutorials/virtuals.html#virtuals-via-schema-options): object - virtuals to define, alias for [`.virtual`](https://mongoosejs.com/docs/api/schema.html#Schema.prototype.virtual())
8888
* - [collectionOptions]: object with options passed to [`createCollection()`](https://www.mongodb.com/docs/manual/reference/method/db.createCollection/) when calling `Model.createCollection()` or `autoCreate` set to true.
89+
* - [encryptionType]: the encryption type for the schema. Valid options are `csfle` or `queryableEncryption`. See https://mongoosejs.com/docs/field-level-encryption.
8990
*
9091
* #### Options for Nested Schemas:
9192
*
@@ -128,6 +129,7 @@ function Schema(obj, options) {
128129
// For internal debugging. Do not use this to try to save a schema in MDB.
129130
this.$id = ++id;
130131
this.mapPaths = [];
132+
this.encryptedFields = {};
131133

132134
this.s = {
133135
hooks: new Kareem()
@@ -463,6 +465,8 @@ Schema.prototype._clone = function _clone(Constructor) {
463465

464466
s.aliases = Object.assign({}, this.aliases);
465467

468+
s.encryptedFields = clone(this.encryptedFields);
469+
466470
return s;
467471
};
468472

@@ -495,7 +499,17 @@ Schema.prototype.pick = function(paths, options) {
495499
}
496500

497501
for (const path of paths) {
498-
if (this.nested[path]) {
502+
if (path in this.encryptedFields) {
503+
const encrypt = this.encryptedFields[path];
504+
const schemaType = this.path(path);
505+
newSchema.add({
506+
[path]: {
507+
encrypt,
508+
[this.options.typeKey]: schemaType
509+
}
510+
});
511+
}
512+
else if (this.nested[path]) {
499513
newSchema.add({ [path]: get(this.tree, path) });
500514
} else {
501515
const schematype = this.path(path);
@@ -506,6 +520,10 @@ Schema.prototype.pick = function(paths, options) {
506520
}
507521
}
508522

523+
if (!this._hasEncryptedFields()) {
524+
newSchema.options.encryptionType = null;
525+
}
526+
509527
return newSchema;
510528
};
511529

@@ -667,6 +685,20 @@ Schema.prototype._defaultToObjectOptions = function(json) {
667685
return defaultOptions;
668686
};
669687

688+
/**
689+
* Sets the encryption type of the schema, if a value is provided, otherwise
690+
* returns the encryption type.
691+
*
692+
* @param {'csfle' | 'queryableEncryption' | undefined} encryptionType plain object with paths to add, or another schema
693+
*/
694+
Schema.prototype.encryptionType = function encryptionType(encryptionType) {
695+
if (typeof encryptionType === 'string' || encryptionType === null) {
696+
this.options.encryptionType = encryptionType;
697+
} else {
698+
return this.options.encryptionType;
699+
}
700+
};
701+
670702
/**
671703
* Adds key path / schema type pairs to this schema.
672704
*
@@ -818,6 +850,32 @@ Schema.prototype.add = function add(obj, prefix) {
818850
}
819851
}
820852
}
853+
854+
if (val.instanceOfSchema && val.encryptionType() != null) {
855+
// schema.add({ field: <instance of encrypted schema> })
856+
if (this.encryptionType() != val.encryptionType()) {
857+
throw new Error('encryptionType of a nested schema must match the encryption type of the parent schema.');
858+
}
859+
860+
for (const [encryptedField, encryptedFieldConfig] of Object.entries(val.encryptedFields)) {
861+
const path = fullPath + '.' + encryptedField;
862+
this._addEncryptedField(path, encryptedFieldConfig);
863+
}
864+
}
865+
else if (typeof val === 'object' && 'encrypt' in val) {
866+
// schema.add({ field: { type: <schema type>, encrypt: { ... }}})
867+
const { encrypt } = val;
868+
869+
if (this.encryptionType() == null) {
870+
throw new Error('encryptionType must be provided');
871+
}
872+
873+
this._addEncryptedField(fullPath, encrypt);
874+
} else {
875+
// if the field was already encrypted and we re-configure it to be unencrypted, remove
876+
// the encrypted field configuration
877+
this._removeEncryptedField(fullPath);
878+
}
821879
}
822880

823881
const aliasObj = Object.fromEntries(
@@ -827,6 +885,35 @@ Schema.prototype.add = function add(obj, prefix) {
827885
return this;
828886
};
829887

888+
/**
889+
* @param {string} path
890+
* @param {object} fieldConfig
891+
*
892+
* @api private
893+
*/
894+
Schema.prototype._addEncryptedField = function _addEncryptedField(path, fieldConfig) {
895+
const type = this.path(path).autoEncryptionType();
896+
if (type == null) {
897+
throw new Error(`Invalid BSON type for FLE field: '${path}'`);
898+
}
899+
900+
this.encryptedFields[path] = clone(fieldConfig);
901+
};
902+
903+
/**
904+
* @api private
905+
*/
906+
Schema.prototype._removeEncryptedField = function _removeEncryptedField(path) {
907+
delete this.encryptedFields[path];
908+
};
909+
910+
/**
911+
* @api private
912+
*/
913+
Schema.prototype._hasEncryptedFields = function _hasEncryptedFields() {
914+
return Object.keys(this.encryptedFields).length > 0;
915+
};
916+
830917
/**
831918
* Add an alias for `path`. This means getting or setting the `alias`
832919
* is equivalent to getting or setting the `path`.
@@ -1378,6 +1465,16 @@ Schema.prototype.interpretAsType = function(path, obj, options) {
13781465
let type = obj[options.typeKey] && (obj[options.typeKey] instanceof Function || options.typeKey !== 'type' || !obj.type.type)
13791466
? obj[options.typeKey]
13801467
: {};
1468+
1469+
if (type instanceof SchemaType) {
1470+
if (type.path === path) {
1471+
return type;
1472+
}
1473+
const clone = type.clone();
1474+
clone.path = path;
1475+
return clone;
1476+
}
1477+
13811478
let name;
13821479

13831480
if (utils.isPOJO(type) || type === 'mixed') {
@@ -2523,6 +2620,8 @@ Schema.prototype.remove = function(path) {
25232620

25242621
delete this.paths[name];
25252622
_deletePath(this, name);
2623+
2624+
this._removeEncryptedField(name);
25262625
}, this);
25272626
}
25282627
return this;

lib/schema/array.js

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -718,6 +718,10 @@ SchemaArray.prototype.toJSONSchema = function toJSONSchema(options) {
718718
};
719719
};
720720

721+
SchemaArray.prototype.autoEncryptionType = function autoEncryptionType() {
722+
return 'array';
723+
};
724+
721725
/*!
722726
* Module exports.
723727
*/

lib/schema/bigint.js

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -254,6 +254,10 @@ SchemaBigInt.prototype.toJSONSchema = function toJSONSchema(options) {
254254
return createJSONSchemaTypeDefinition('string', 'long', options?.useBsonType, isRequired);
255255
};
256256

257+
SchemaBigInt.prototype.autoEncryptionType = function autoEncryptionType() {
258+
return 'int64';
259+
};
260+
257261
/*!
258262
* Module exports.
259263
*/

lib/schema/boolean.js

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -304,6 +304,10 @@ SchemaBoolean.prototype.toJSONSchema = function toJSONSchema(options) {
304304
return createJSONSchemaTypeDefinition('boolean', 'bool', options?.useBsonType, isRequired);
305305
};
306306

307+
SchemaBoolean.prototype.autoEncryptionType = function autoEncryptionType() {
308+
return 'boolean';
309+
};
310+
307311
/*!
308312
* Module exports.
309313
*/

lib/schema/buffer.js

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -314,6 +314,10 @@ SchemaBuffer.prototype.toJSONSchema = function toJSONSchema(options) {
314314
return createJSONSchemaTypeDefinition('string', 'binData', options?.useBsonType, isRequired);
315315
};
316316

317+
SchemaBuffer.prototype.autoEncryptionType = function autoEncryptionType() {
318+
return 'binary';
319+
};
320+
317321
/*!
318322
* Module exports.
319323
*/

lib/schema/date.js

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -440,6 +440,10 @@ SchemaDate.prototype.toJSONSchema = function toJSONSchema(options) {
440440
return createJSONSchemaTypeDefinition('string', 'date', options?.useBsonType, isRequired);
441441
};
442442

443+
SchemaDate.prototype.autoEncryptionType = function autoEncryptionType() {
444+
return 'date';
445+
};
446+
443447
/*!
444448
* Module exports.
445449
*/

lib/schema/decimal128.js

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -235,6 +235,10 @@ SchemaDecimal128.prototype.toJSONSchema = function toJSONSchema(options) {
235235
return createJSONSchemaTypeDefinition('string', 'decimal', options?.useBsonType, isRequired);
236236
};
237237

238+
SchemaDecimal128.prototype.autoEncryptionType = function autoEncryptionType() {
239+
return 'decimal128';
240+
};
241+
238242
/*!
239243
* Module exports.
240244
*/

0 commit comments

Comments
 (0)