Skip to content
Closed
Show file tree
Hide file tree
Changes from all 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
3 changes: 2 additions & 1 deletion .eslintrc.js
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,8 @@ module.exports = {
'!.*',
'node_modules',
'.git',
'data'
'data',
'.config'
],
overrides: [
{
Expand Down
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -70,3 +70,4 @@ list.out

data
*.pid
mo-expansion*
38 changes: 38 additions & 0 deletions docs/field-level-encryption.md
Original file line number Diff line number Diff line change
Expand Up @@ -113,3 +113,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 two 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: 'queryableEncryption' });
```

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.
101 changes: 100 additions & 1 deletion lib/schema.js
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,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 `queryableEncryption`. See https://mongoosejs.com/docs/field-level-encryption.
*
* #### Options for Nested Schemas:
*
Expand Down Expand Up @@ -128,6 +129,7 @@ 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.s = {
hooks: new Kareem()
Expand Down Expand Up @@ -463,6 +465,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 +499,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 +520,10 @@ Schema.prototype.pick = function(paths, options) {
}
}

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

return newSchema;
};

Expand Down Expand Up @@ -667,6 +685,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' | 'queryableEncryption' | undefined} encryptionType plain object with paths to add, or another schema
*/
Schema.prototype.encryptionType = function encryptionType(encryptionType) {
if (typeof encryptionType === 'string' || encryptionType === null) {
this.options.encryptionType = encryptionType;
} else {
return this.options.encryptionType;
}
};

/**
* Adds key path / schema type pairs to this schema.
*
Expand Down Expand Up @@ -818,6 +850,32 @@ 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(
Expand All @@ -827,6 +885,35 @@ Schema.prototype.add = function add(obj, prefix) {
return this;
};

/**
* @param {string} path
* @param {object} fieldConfig
*
* @api private
*/
Schema.prototype._addEncryptedField = function _addEncryptedField(path, fieldConfig) {
const type = this.path(path).autoEncryptionType();
if (type == null) {
throw new Error(`Invalid BSON type for FLE 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 @@ -1378,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 @@ -2523,6 +2620,8 @@ Schema.prototype.remove = function(path) {

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

this._removeEncryptedField(name);
}, this);
}
return this;
Expand Down
4 changes: 4 additions & 0 deletions lib/schema/array.js
Original file line number Diff line number Diff line change
Expand Up @@ -718,6 +718,10 @@ SchemaArray.prototype.toJSONSchema = function toJSONSchema(options) {
};
};

SchemaArray.prototype.autoEncryptionType = function autoEncryptionType() {
return 'array';
};

/*!
* Module exports.
*/
Expand Down
4 changes: 4 additions & 0 deletions lib/schema/bigint.js
Original file line number Diff line number Diff line change
Expand Up @@ -254,6 +254,10 @@ SchemaBigInt.prototype.toJSONSchema = function toJSONSchema(options) {
return createJSONSchemaTypeDefinition('string', 'long', options?.useBsonType, isRequired);
};

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

/*!
* Module exports.
*/
Expand Down
4 changes: 4 additions & 0 deletions lib/schema/boolean.js
Original file line number Diff line number Diff line change
Expand Up @@ -304,6 +304,10 @@ SchemaBoolean.prototype.toJSONSchema = function toJSONSchema(options) {
return createJSONSchemaTypeDefinition('boolean', 'bool', options?.useBsonType, isRequired);
};

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

/*!
* Module exports.
*/
Expand Down
4 changes: 4 additions & 0 deletions lib/schema/buffer.js
Original file line number Diff line number Diff line change
Expand Up @@ -314,6 +314,10 @@ SchemaBuffer.prototype.toJSONSchema = function toJSONSchema(options) {
return createJSONSchemaTypeDefinition('string', 'binData', options?.useBsonType, isRequired);
};

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

/*!
* Module exports.
*/
Expand Down
4 changes: 4 additions & 0 deletions lib/schema/date.js
Original file line number Diff line number Diff line change
Expand Up @@ -440,6 +440,10 @@ SchemaDate.prototype.toJSONSchema = function toJSONSchema(options) {
return createJSONSchemaTypeDefinition('string', 'date', options?.useBsonType, isRequired);
};

SchemaDate.prototype.autoEncryptionType = function autoEncryptionType() {
return 'date';
};

/*!
* Module exports.
*/
Expand Down
4 changes: 4 additions & 0 deletions lib/schema/decimal128.js
Original file line number Diff line number Diff line change
Expand Up @@ -235,6 +235,10 @@ SchemaDecimal128.prototype.toJSONSchema = function toJSONSchema(options) {
return createJSONSchemaTypeDefinition('string', 'decimal', options?.useBsonType, isRequired);
};

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

/*!
* Module exports.
*/
Expand Down
4 changes: 4 additions & 0 deletions lib/schema/double.js
Original file line number Diff line number Diff line change
Expand Up @@ -218,6 +218,10 @@ SchemaDouble.prototype.toJSONSchema = function toJSONSchema(options) {
return createJSONSchemaTypeDefinition('number', 'double', options?.useBsonType, isRequired);
};

SchemaDouble.prototype.autoEncryptionType = function autoEncryptionType() {
return 'double';
};

/*!
* Module exports.
*/
Expand Down
4 changes: 4 additions & 0 deletions lib/schema/int32.js
Original file line number Diff line number Diff line change
Expand Up @@ -260,6 +260,10 @@ SchemaInt32.prototype.toJSONSchema = function toJSONSchema(options) {
return createJSONSchemaTypeDefinition('number', 'int', options?.useBsonType, isRequired);
};

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


/*!
* Module exports.
Expand Down
4 changes: 4 additions & 0 deletions lib/schema/objectId.js
Original file line number Diff line number Diff line change
Expand Up @@ -304,6 +304,10 @@ SchemaObjectId.prototype.toJSONSchema = function toJSONSchema(options) {
return createJSONSchemaTypeDefinition('string', 'objectId', options?.useBsonType, isRequired);
};

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

/*!
* Module exports.
*/
Expand Down
4 changes: 4 additions & 0 deletions lib/schema/string.js
Original file line number Diff line number Diff line change
Expand Up @@ -712,6 +712,10 @@ SchemaString.prototype.toJSONSchema = function toJSONSchema(options) {
return createJSONSchemaTypeDefinition('string', 'string', options?.useBsonType, isRequired);
};

SchemaString.prototype.autoEncryptionType = function autoEncryptionType() {
return 'string';
};

/*!
* Module exports.
*/
Expand Down
8 changes: 8 additions & 0 deletions lib/schemaType.js
Original file line number Diff line number Diff line change
Expand Up @@ -1783,6 +1783,14 @@ SchemaType.prototype.toJSONSchema = function toJSONSchema() {
throw new Error('Converting unsupported SchemaType to JSON Schema: ' + this.instance);
};

/**
* Returns the BSON type that the schema corresponds to, for automatic encryption.
* @api private
*/
SchemaType.prototype.autoEncryptionType = function autoEncryptionType() {
return null;
};

/*!
* Module exports.
*/
Expand Down
Loading
Loading