Skip to content

Commit

Permalink
Add support for encrypted schemas
Browse files Browse the repository at this point in the history
  • Loading branch information
baileympearson committed Jan 13, 2025
1 parent 682160f commit ed4b23c
Show file tree
Hide file tree
Showing 4 changed files with 177 additions and 35 deletions.
4 changes: 3 additions & 1 deletion .eslintrc.js
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,9 @@ module.exports = {
'**/docs/js/native.js',
'!.*',
'node_modules',
'.git'
'.git',
'data',
'.config'
],
overrides: [
{
Expand Down
38 changes: 38 additions & 0 deletions docs/field-level-encryption.md
Original file line number Diff line number Diff line change
Expand Up @@ -112,3 +112,41 @@ 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 to 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: 'queryable encryption' });
```

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.
2 changes: 1 addition & 1 deletion lib/model.js
Original file line number Diff line number Diff line change
Expand Up @@ -3688,7 +3688,7 @@ Model.castObject = function castObject(obj, options) {
}

if (schemaType.$isMongooseDocumentArray) {
const castNonArraysOption = schemaType.options?.castNonArrays ??schemaType.constructor.options.castNonArrays;
const castNonArraysOption = schemaType.options?.castNonArrays ?? schemaType.constructor.options.castNonArrays;
if (!Array.isArray(val)) {
if (!castNonArraysOption) {
if (!options.ignoreCastErrors) {
Expand Down
168 changes: 135 additions & 33 deletions lib/schema.js
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ const setPopulatedVirtualValue = require('./helpers/populate/setPopulatedVirtual
const setupTimestamps = require('./helpers/timestamps/setupTimestamps');
const utils = require('./utils');
const validateRef = require('./helpers/populate/validateRef');
const { inferBSONType } = require('./encryption_utils');

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

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

this.s = {
hooks: new Kareem()
Expand Down Expand Up @@ -166,7 +170,7 @@ function Schema(obj, options) {

// ensure the documents get an auto _id unless disabled
const auto_id = !this.paths['_id'] &&
(this.options._id) && !_idSubDoc;
(this.options._id) && !_idSubDoc;

if (auto_id) {
addAutoId(this);
Expand Down Expand Up @@ -463,6 +467,8 @@ Schema.prototype._clone = function _clone(Constructor) {

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

s.encryptedFields = clone(this.encryptedFields);

return s;
};

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

for (const path of paths) {
if (this.nested[path]) {
if (path in this.encryptedFields) {
const encrypt = this.encryptedFields[path];
const schemaType = this.path(path);
newSchema.add({
[path]: {
encrypt,
[this.options.typeKey]: schemaType
}
});
}
else if (this.nested[path]) {
newSchema.add({ [path]: get(this.tree, path) });
} else {
const schematype = this.path(path);
Expand All @@ -506,6 +522,10 @@ Schema.prototype.pick = function(paths, options) {
}
}

if (!this._hasEncryptedFields()) {
newSchema._encryptionType = null;
}

return newSchema;
};

Expand Down Expand Up @@ -534,9 +554,9 @@ Schema.prototype.omit = function(paths, options) {
if (!Array.isArray(paths)) {
throw new MongooseError(
'Schema#omit() only accepts an array argument, ' +
'got "' +
typeof paths +
'"'
'got "' +
typeof paths +
'"'
);
}

Expand Down Expand Up @@ -667,6 +687,20 @@ Schema.prototype._defaultToObjectOptions = function(json) {
return defaultOptions;
};

/**
* Sets the encryption type of the schema, if a value is provided, otherwise
* returns the encryption type.
*
* @param {'csfle' | 'queryable encryption' | undefined} encryptionType plain object with paths to add, or another schema
*/
Schema.prototype.encryptionType = function encryptionType(encryptionType) {
if (typeof encryptionType === 'string' || encryptionType === null) {
this._encryptionType = encryptionType;
} else {
return this._encryptionType;
}
};

/**
* Adds key path / schema type pairs to this schema.
*
Expand Down Expand Up @@ -735,7 +769,7 @@ Schema.prototype.add = function add(obj, prefix) {
if (
key !== '_id' &&
((typeof val !== 'object' && typeof val !== 'function' && !isMongooseTypeString) ||
val == null)
val == null)
) {
throw new TypeError(`Invalid schema configuration: \`${val}\` is not ` +
`a valid type at path \`${key}\`. See ` +
Expand Down Expand Up @@ -818,15 +852,71 @@ Schema.prototype.add = function add(obj, prefix) {
}
}
}

if (val.instanceOfSchema && val.encryptionType() != null) {
// schema.add({ field: <instance of encrypted schema> })
if (this.encryptionType() != val.encryptionType()) {
throw new Error('encryptionType of a nested schema must match the encryption type of the parent schema.');
}

for (const [encryptedField, encryptedFieldConfig] of Object.entries(val.encryptedFields)) {
const path = fullPath + '.' + encryptedField;
this._addEncryptedField(path, encryptedFieldConfig);
}
}
else if (typeof val === 'object' && 'encrypt' in val) {
// schema.add({ field: { type: <schema type>, encrypt: { ... }}})
const { encrypt } = val;

if (this.encryptionType() == null) {
throw new Error('encryptionType must be provided');
}

this._addEncryptedField(fullPath, encrypt);
} else {
// if the field was already encrypted and we re-configure it to be unencrypted, remove
// the encrypted field configuration
this._removeEncryptedField(fullPath);
}
}

const aliasObj = Object.fromEntries(
Object.entries(obj).map(([key]) => ([prefix + key, null]))
);
aliasFields(this, aliasObj);

return this;
};

/**
* @param {string} path
* @param {object} fieldConfig
*
* @api private
*/
Schema.prototype._addEncryptedField = function _addEncryptedField(path, fieldConfig) {
const type = inferBSONType(this, path);
if (type == null) {
throw new Error('unable to determine bson type for field `' + path + '`');
}

this.encryptedFields[path] = clone(fieldConfig);
};

/**
* @api private
*/
Schema.prototype._removeEncryptedField = function _removeEncryptedField(path) {
delete this.encryptedFields[path];
};

/**
* @api private
*/
Schema.prototype._hasEncryptedFields = function _hasEncryptedFields() {
return Object.keys(this.encryptedFields).length > 0;
};

/**
* Add an alias for `path`. This means getting or setting the `alias`
* is equivalent to getting or setting the `path`.
Expand Down Expand Up @@ -1008,23 +1098,23 @@ Schema.prototype.reserved = Schema.reserved;
const reserved = Schema.reserved;
// Core object
reserved['prototype'] =
// EventEmitter
reserved.emit =
reserved.listeners =
reserved.removeListener =

// document properties and functions
reserved.collection =
reserved.errors =
reserved.get =
reserved.init =
reserved.isModified =
reserved.isNew =
reserved.populated =
reserved.remove =
reserved.save =
reserved.toObject =
reserved.validate = 1;
// EventEmitter
reserved.emit =
reserved.listeners =
reserved.removeListener =

// document properties and functions
reserved.collection =
reserved.errors =
reserved.get =
reserved.init =
reserved.isModified =
reserved.isNew =
reserved.populated =
reserved.remove =
reserved.save =
reserved.toObject =
reserved.validate = 1;
reserved.collection = 1;

/**
Expand Down Expand Up @@ -1104,10 +1194,10 @@ Schema.prototype.path = function(path, obj) {
}
if (typeof branch[sub] !== 'object') {
const msg = 'Cannot set nested path `' + path + '`. '
+ 'Parent path `'
+ fullPath
+ '` already set to type ' + branch[sub].name
+ '.';
+ 'Parent path `'
+ fullPath
+ '` already set to type ' + branch[sub].name
+ '.';
throw new Error(msg);
}
branch = branch[sub];
Expand Down Expand Up @@ -1375,6 +1465,16 @@ Schema.prototype.interpretAsType = function(path, obj, options) {
let type = obj[options.typeKey] && (obj[options.typeKey] instanceof Function || options.typeKey !== 'type' || !obj.type.type)
? obj[options.typeKey]
: {};

if (type instanceof SchemaType) {
if (type.path === path) {
return type;
}
const clone = type.clone();
clone.path = path;
return clone;
}

let name;

if (utils.isPOJO(type) || type === 'mixed') {
Expand Down Expand Up @@ -1404,8 +1504,8 @@ Schema.prototype.interpretAsType = function(path, obj, options) {
return new MongooseTypes.DocumentArray(path, cast, obj);
}
if (cast &&
cast[options.typeKey] &&
cast[options.typeKey].instanceOfSchema) {
cast[options.typeKey] &&
cast[options.typeKey].instanceOfSchema) {
if (!(cast[options.typeKey] instanceof Schema)) {
if (this.options._isMerging) {
cast[options.typeKey] = new Schema(cast[options.typeKey]);
Expand Down Expand Up @@ -1739,7 +1839,7 @@ Schema.prototype.hasMixedParent = function(path) {
for (let i = 0; i < subpaths.length; ++i) {
path = i > 0 ? path + '.' + subpaths[i] : subpaths[i];
if (this.paths.hasOwnProperty(path) &&
this.paths[path] instanceof MongooseTypes.Mixed) {
this.paths[path] instanceof MongooseTypes.Mixed) {
return this.paths[path];
}
}
Expand Down Expand Up @@ -2516,6 +2616,8 @@ Schema.prototype.remove = function(path) {

delete this.paths[name];
_deletePath(this, name);

this._removeEncryptedField(name);
}, this);
}
return this;
Expand Down Expand Up @@ -2611,9 +2713,9 @@ Schema.prototype.removeVirtual = function(path) {
Schema.prototype.loadClass = function(model, virtualsOnly) {
// Stop copying when hit certain base classes
if (model === Object.prototype ||
model === Function.prototype ||
model.prototype.hasOwnProperty('$isMongooseModelPrototype') ||
model.prototype.hasOwnProperty('$isMongooseDocumentPrototype')) {
model === Function.prototype ||
model.prototype.hasOwnProperty('$isMongooseModelPrototype') ||
model.prototype.hasOwnProperty('$isMongooseDocumentPrototype')) {
return this;
}

Expand Down

0 comments on commit ed4b23c

Please sign in to comment.