Skip to content

Commit ed4b23c

Browse files
Add support for encrypted schemas
1 parent 682160f commit ed4b23c

File tree

4 files changed

+177
-35
lines changed

4 files changed

+177
-35
lines changed

.eslintrc.js

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,9 @@ module.exports = {
1414
'**/docs/js/native.js',
1515
'!.*',
1616
'node_modules',
17-
'.git'
17+
'.git',
18+
'data',
19+
'.config'
1820
],
1921
overrides: [
2022
{

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 to 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: 'queryable encryption' });
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/model.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3688,7 +3688,7 @@ Model.castObject = function castObject(obj, options) {
36883688
}
36893689

36903690
if (schemaType.$isMongooseDocumentArray) {
3691-
const castNonArraysOption = schemaType.options?.castNonArrays ??schemaType.constructor.options.castNonArrays;
3691+
const castNonArraysOption = schemaType.options?.castNonArrays ?? schemaType.constructor.options.castNonArrays;
36923692
if (!Array.isArray(val)) {
36933693
if (!castNonArraysOption) {
36943694
if (!options.ignoreCastErrors) {

lib/schema.js

Lines changed: 135 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ const setPopulatedVirtualValue = require('./helpers/populate/setPopulatedVirtual
2525
const setupTimestamps = require('./helpers/timestamps/setupTimestamps');
2626
const utils = require('./utils');
2727
const validateRef = require('./helpers/populate/validateRef');
28+
const { inferBSONType } = require('./encryption_utils');
2829

2930
const hasNumericSubpathRegex = /\.\d+(\.|$)/;
3031

@@ -86,6 +87,7 @@ const numberRE = /^\d+$/;
8687
* - [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.
8788
* - [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())
8889
* - [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.
90+
* - [encryptionType]: the encryption type for the schema. Valid options are `csfle` or `queryable encryption`. See https://mongoosejs.com/docs/field-level-encryption.
8991
*
9092
* #### Options for Nested Schemas:
9193
*
@@ -128,6 +130,8 @@ function Schema(obj, options) {
128130
// For internal debugging. Do not use this to try to save a schema in MDB.
129131
this.$id = ++id;
130132
this.mapPaths = [];
133+
this.encryptedFields = {};
134+
this._encryptionType = options?.encryptionType;
131135

132136
this.s = {
133137
hooks: new Kareem()
@@ -166,7 +170,7 @@ function Schema(obj, options) {
166170

167171
// ensure the documents get an auto _id unless disabled
168172
const auto_id = !this.paths['_id'] &&
169-
(this.options._id) && !_idSubDoc;
173+
(this.options._id) && !_idSubDoc;
170174

171175
if (auto_id) {
172176
addAutoId(this);
@@ -463,6 +467,8 @@ Schema.prototype._clone = function _clone(Constructor) {
463467

464468
s.aliases = Object.assign({}, this.aliases);
465469

470+
s.encryptedFields = clone(this.encryptedFields);
471+
466472
return s;
467473
};
468474

@@ -495,7 +501,17 @@ Schema.prototype.pick = function(paths, options) {
495501
}
496502

497503
for (const path of paths) {
498-
if (this.nested[path]) {
504+
if (path in this.encryptedFields) {
505+
const encrypt = this.encryptedFields[path];
506+
const schemaType = this.path(path);
507+
newSchema.add({
508+
[path]: {
509+
encrypt,
510+
[this.options.typeKey]: schemaType
511+
}
512+
});
513+
}
514+
else if (this.nested[path]) {
499515
newSchema.add({ [path]: get(this.tree, path) });
500516
} else {
501517
const schematype = this.path(path);
@@ -506,6 +522,10 @@ Schema.prototype.pick = function(paths, options) {
506522
}
507523
}
508524

525+
if (!this._hasEncryptedFields()) {
526+
newSchema._encryptionType = null;
527+
}
528+
509529
return newSchema;
510530
};
511531

@@ -534,9 +554,9 @@ Schema.prototype.omit = function(paths, options) {
534554
if (!Array.isArray(paths)) {
535555
throw new MongooseError(
536556
'Schema#omit() only accepts an array argument, ' +
537-
'got "' +
538-
typeof paths +
539-
'"'
557+
'got "' +
558+
typeof paths +
559+
'"'
540560
);
541561
}
542562

@@ -667,6 +687,20 @@ Schema.prototype._defaultToObjectOptions = function(json) {
667687
return defaultOptions;
668688
};
669689

690+
/**
691+
* Sets the encryption type of the schema, if a value is provided, otherwise
692+
* returns the encryption type.
693+
*
694+
* @param {'csfle' | 'queryable encryption' | undefined} encryptionType plain object with paths to add, or another schema
695+
*/
696+
Schema.prototype.encryptionType = function encryptionType(encryptionType) {
697+
if (typeof encryptionType === 'string' || encryptionType === null) {
698+
this._encryptionType = encryptionType;
699+
} else {
700+
return this._encryptionType;
701+
}
702+
};
703+
670704
/**
671705
* Adds key path / schema type pairs to this schema.
672706
*
@@ -735,7 +769,7 @@ Schema.prototype.add = function add(obj, prefix) {
735769
if (
736770
key !== '_id' &&
737771
((typeof val !== 'object' && typeof val !== 'function' && !isMongooseTypeString) ||
738-
val == null)
772+
val == null)
739773
) {
740774
throw new TypeError(`Invalid schema configuration: \`${val}\` is not ` +
741775
`a valid type at path \`${key}\`. See ` +
@@ -818,15 +852,71 @@ Schema.prototype.add = function add(obj, prefix) {
818852
}
819853
}
820854
}
855+
856+
if (val.instanceOfSchema && val.encryptionType() != null) {
857+
// schema.add({ field: <instance of encrypted schema> })
858+
if (this.encryptionType() != val.encryptionType()) {
859+
throw new Error('encryptionType of a nested schema must match the encryption type of the parent schema.');
860+
}
861+
862+
for (const [encryptedField, encryptedFieldConfig] of Object.entries(val.encryptedFields)) {
863+
const path = fullPath + '.' + encryptedField;
864+
this._addEncryptedField(path, encryptedFieldConfig);
865+
}
866+
}
867+
else if (typeof val === 'object' && 'encrypt' in val) {
868+
// schema.add({ field: { type: <schema type>, encrypt: { ... }}})
869+
const { encrypt } = val;
870+
871+
if (this.encryptionType() == null) {
872+
throw new Error('encryptionType must be provided');
873+
}
874+
875+
this._addEncryptedField(fullPath, encrypt);
876+
} else {
877+
// if the field was already encrypted and we re-configure it to be unencrypted, remove
878+
// the encrypted field configuration
879+
this._removeEncryptedField(fullPath);
880+
}
821881
}
822882

823883
const aliasObj = Object.fromEntries(
824884
Object.entries(obj).map(([key]) => ([prefix + key, null]))
825885
);
826886
aliasFields(this, aliasObj);
887+
827888
return this;
828889
};
829890

891+
/**
892+
* @param {string} path
893+
* @param {object} fieldConfig
894+
*
895+
* @api private
896+
*/
897+
Schema.prototype._addEncryptedField = function _addEncryptedField(path, fieldConfig) {
898+
const type = inferBSONType(this, path);
899+
if (type == null) {
900+
throw new Error('unable to determine bson type for field `' + path + '`');
901+
}
902+
903+
this.encryptedFields[path] = clone(fieldConfig);
904+
};
905+
906+
/**
907+
* @api private
908+
*/
909+
Schema.prototype._removeEncryptedField = function _removeEncryptedField(path) {
910+
delete this.encryptedFields[path];
911+
};
912+
913+
/**
914+
* @api private
915+
*/
916+
Schema.prototype._hasEncryptedFields = function _hasEncryptedFields() {
917+
return Object.keys(this.encryptedFields).length > 0;
918+
};
919+
830920
/**
831921
* Add an alias for `path`. This means getting or setting the `alias`
832922
* is equivalent to getting or setting the `path`.
@@ -1008,23 +1098,23 @@ Schema.prototype.reserved = Schema.reserved;
10081098
const reserved = Schema.reserved;
10091099
// Core object
10101100
reserved['prototype'] =
1011-
// EventEmitter
1012-
reserved.emit =
1013-
reserved.listeners =
1014-
reserved.removeListener =
1015-
1016-
// document properties and functions
1017-
reserved.collection =
1018-
reserved.errors =
1019-
reserved.get =
1020-
reserved.init =
1021-
reserved.isModified =
1022-
reserved.isNew =
1023-
reserved.populated =
1024-
reserved.remove =
1025-
reserved.save =
1026-
reserved.toObject =
1027-
reserved.validate = 1;
1101+
// EventEmitter
1102+
reserved.emit =
1103+
reserved.listeners =
1104+
reserved.removeListener =
1105+
1106+
// document properties and functions
1107+
reserved.collection =
1108+
reserved.errors =
1109+
reserved.get =
1110+
reserved.init =
1111+
reserved.isModified =
1112+
reserved.isNew =
1113+
reserved.populated =
1114+
reserved.remove =
1115+
reserved.save =
1116+
reserved.toObject =
1117+
reserved.validate = 1;
10281118
reserved.collection = 1;
10291119

10301120
/**
@@ -1104,10 +1194,10 @@ Schema.prototype.path = function(path, obj) {
11041194
}
11051195
if (typeof branch[sub] !== 'object') {
11061196
const msg = 'Cannot set nested path `' + path + '`. '
1107-
+ 'Parent path `'
1108-
+ fullPath
1109-
+ '` already set to type ' + branch[sub].name
1110-
+ '.';
1197+
+ 'Parent path `'
1198+
+ fullPath
1199+
+ '` already set to type ' + branch[sub].name
1200+
+ '.';
11111201
throw new Error(msg);
11121202
}
11131203
branch = branch[sub];
@@ -1375,6 +1465,16 @@ Schema.prototype.interpretAsType = function(path, obj, options) {
13751465
let type = obj[options.typeKey] && (obj[options.typeKey] instanceof Function || options.typeKey !== 'type' || !obj.type.type)
13761466
? obj[options.typeKey]
13771467
: {};
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+
13781478
let name;
13791479

13801480
if (utils.isPOJO(type) || type === 'mixed') {
@@ -1404,8 +1504,8 @@ Schema.prototype.interpretAsType = function(path, obj, options) {
14041504
return new MongooseTypes.DocumentArray(path, cast, obj);
14051505
}
14061506
if (cast &&
1407-
cast[options.typeKey] &&
1408-
cast[options.typeKey].instanceOfSchema) {
1507+
cast[options.typeKey] &&
1508+
cast[options.typeKey].instanceOfSchema) {
14091509
if (!(cast[options.typeKey] instanceof Schema)) {
14101510
if (this.options._isMerging) {
14111511
cast[options.typeKey] = new Schema(cast[options.typeKey]);
@@ -1739,7 +1839,7 @@ Schema.prototype.hasMixedParent = function(path) {
17391839
for (let i = 0; i < subpaths.length; ++i) {
17401840
path = i > 0 ? path + '.' + subpaths[i] : subpaths[i];
17411841
if (this.paths.hasOwnProperty(path) &&
1742-
this.paths[path] instanceof MongooseTypes.Mixed) {
1842+
this.paths[path] instanceof MongooseTypes.Mixed) {
17431843
return this.paths[path];
17441844
}
17451845
}
@@ -2516,6 +2616,8 @@ Schema.prototype.remove = function(path) {
25162616

25172617
delete this.paths[name];
25182618
_deletePath(this, name);
2619+
2620+
this._removeEncryptedField(name);
25192621
}, this);
25202622
}
25212623
return this;
@@ -2611,9 +2713,9 @@ Schema.prototype.removeVirtual = function(path) {
26112713
Schema.prototype.loadClass = function(model, virtualsOnly) {
26122714
// Stop copying when hit certain base classes
26132715
if (model === Object.prototype ||
2614-
model === Function.prototype ||
2615-
model.prototype.hasOwnProperty('$isMongooseModelPrototype') ||
2616-
model.prototype.hasOwnProperty('$isMongooseDocumentPrototype')) {
2716+
model === Function.prototype ||
2717+
model.prototype.hasOwnProperty('$isMongooseModelPrototype') ||
2718+
model.prototype.hasOwnProperty('$isMongooseDocumentPrototype')) {
26172719
return this;
26182720
}
26192721

0 commit comments

Comments
 (0)