diff --git a/.eslintrc.js b/.eslintrc.js index 002d1e7b8b9..91b38166932 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -15,7 +15,8 @@ module.exports = { '!.*', 'node_modules', '.git', - 'data' + 'data', + '.config' ], overrides: [ { diff --git a/.gitignore b/.gitignore index 9a52110981e..ac921cdc28c 100644 --- a/.gitignore +++ b/.gitignore @@ -70,3 +70,4 @@ list.out data *.pid +mo-expansion* diff --git a/docs/field-level-encryption.md b/docs/field-level-encryption.md index 3531fca0218..13daef15dfe 100644 --- a/docs/field-level-encryption.md +++ b/docs/field-level-encryption.md @@ -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 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: '', + 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. diff --git a/lib/schema.js b/lib/schema.js index f7528f6b4b4..5679e421109 100644 --- a/lib/schema.js +++ b/lib/schema.js @@ -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: * @@ -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() @@ -463,6 +465,8 @@ Schema.prototype._clone = function _clone(Constructor) { s.aliases = Object.assign({}, this.aliases); + s.encryptedFields = clone(this.encryptedFields); + return s; }; @@ -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); @@ -506,6 +520,10 @@ Schema.prototype.pick = function(paths, options) { } } + if (!this._hasEncryptedFields()) { + newSchema.options.encryptionType = null; + } + return newSchema; }; @@ -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. * @@ -818,6 +850,32 @@ Schema.prototype.add = function add(obj, prefix) { } } } + + if (val.instanceOfSchema && val.encryptionType() != null) { + // schema.add({ field: }) + 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: , 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( @@ -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`. @@ -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') { @@ -2523,6 +2620,8 @@ Schema.prototype.remove = function(path) { delete this.paths[name]; _deletePath(this, name); + + this._removeEncryptedField(name); }, this); } return this; diff --git a/lib/schema/array.js b/lib/schema/array.js index 06b1e988cb8..9e689ec5201 100644 --- a/lib/schema/array.js +++ b/lib/schema/array.js @@ -718,6 +718,10 @@ SchemaArray.prototype.toJSONSchema = function toJSONSchema(options) { }; }; +SchemaArray.prototype.autoEncryptionType = function autoEncryptionType() { + return 'array'; +}; + /*! * Module exports. */ diff --git a/lib/schema/bigint.js b/lib/schema/bigint.js index 474d77461fd..be937eafbf5 100644 --- a/lib/schema/bigint.js +++ b/lib/schema/bigint.js @@ -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. */ diff --git a/lib/schema/boolean.js b/lib/schema/boolean.js index b11162621f0..ed478b95bf8 100644 --- a/lib/schema/boolean.js +++ b/lib/schema/boolean.js @@ -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. */ diff --git a/lib/schema/buffer.js b/lib/schema/buffer.js index 8111956fb95..f9d3027367d 100644 --- a/lib/schema/buffer.js +++ b/lib/schema/buffer.js @@ -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. */ diff --git a/lib/schema/date.js b/lib/schema/date.js index 6d671f51e50..8aa20811716 100644 --- a/lib/schema/date.js +++ b/lib/schema/date.js @@ -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. */ diff --git a/lib/schema/decimal128.js b/lib/schema/decimal128.js index 3c7f3e28ca3..b3d80d54a6c 100644 --- a/lib/schema/decimal128.js +++ b/lib/schema/decimal128.js @@ -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. */ diff --git a/lib/schema/double.js b/lib/schema/double.js index 23b1f33b38d..fbbf484aba2 100644 --- a/lib/schema/double.js +++ b/lib/schema/double.js @@ -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. */ diff --git a/lib/schema/int32.js b/lib/schema/int32.js index 7cf2c364dc5..65bfb66e174 100644 --- a/lib/schema/int32.js +++ b/lib/schema/int32.js @@ -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. diff --git a/lib/schema/objectId.js b/lib/schema/objectId.js index 6eb0fbed08f..fd379e014d1 100644 --- a/lib/schema/objectId.js +++ b/lib/schema/objectId.js @@ -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. */ diff --git a/lib/schema/string.js b/lib/schema/string.js index 1e84cac6271..b2c05f374a7 100644 --- a/lib/schema/string.js +++ b/lib/schema/string.js @@ -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. */ diff --git a/lib/schemaType.js b/lib/schemaType.js index 22c9edbd473..5b2951e222a 100644 --- a/lib/schemaType.js +++ b/lib/schemaType.js @@ -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. */ diff --git a/test/encryptedSchema.test.js b/test/encryptedSchema.test.js new file mode 100644 index 00000000000..5134d39864e --- /dev/null +++ b/test/encryptedSchema.test.js @@ -0,0 +1,538 @@ + +'use strict'; + +const assert = require('assert'); +const start = require('./common'); +const { ObjectId, Decimal128 } = require('../lib/types'); +const { Double, Int32, UUID } = require('bson'); + +const mongoose = start.mongoose; +const Schema = mongoose.Schema; + +/** + * + * @param {import('../lib').Schema} object + * @param {Array | string} path + * @returns + */ +function schemaHasEncryptedProperty(schema, path) { + path = [path].flat(); + path = path.join('.'); + + return path in schema.encryptedFields; +} + +const KEY_ID = new UUID(); +const algorithm = 'AEAD_AES_256_CBC_HMAC_SHA_512-Deterministic'; + +describe('encrypted schema declaration', function() { + describe('Tests that fields of valid schema types can be declared as encrypted schemas', function() { + const basicSchemaTypes = [ + { type: String, name: 'string' }, + { type: Schema.Types.Boolean, name: 'boolean' }, + { type: Schema.Types.Buffer, name: 'buffer' }, + { type: Date, name: 'date' }, + { type: ObjectId, name: 'objectid' }, + { type: BigInt, name: 'bigint' }, + { type: Decimal128, name: 'Decimal128' }, + { type: Int32, name: 'int32' }, + { type: Double, name: 'double' } + ]; + + for (const { type, name } of basicSchemaTypes) { + describe(`When a schema is instantiated with an encrypted field of type ${name}`, function() { + let schema; + beforeEach(function() { + schema = new Schema({ + field: { + type, encrypt: { keyId: KEY_ID, algorithm } + } + }, { + encryptionType: 'csfle' + }); + }); + + it(`Then the schema has an encrypted property of type ${name}`, function() { + assert.ok(schemaHasEncryptedProperty(schema, 'field')); + }); + }); + } + + describe('when a schema is instantiated with a nested encrypted schema', function() { + let schema; + beforeEach(function() { + const encryptedSchema = new Schema({ + encrypted: { + type: String, encrypt: { keyId: KEY_ID, algorithm } + } + }, { encryptionType: 'csfle' }); + schema = new Schema({ + field: encryptedSchema + }, { encryptionType: 'csfle' }); + }); + + + it('then the schema has a nested property that is encrypted', function() { + assert.ok(schemaHasEncryptedProperty(schema, ['field', 'encrypted'])); + }); + }); + + describe('when a schema is instantiated with a nested schema object', function() { + let schema; + beforeEach(function() { + schema = new Schema({ + field: { + encrypted: { + type: String, encrypt: { keyId: KEY_ID, algorithm } + } + } + }, { encryptionType: 'csfle' }); + }); + + it('then the schema has a nested property that is encrypted', function() { + assert.ok(schemaHasEncryptedProperty(schema, ['field', 'encrypted'])); + }); + }); + + describe('when a schema is instantiated as an Array', function() { + let schema; + beforeEach(function() { + schema = new Schema({ + encrypted: { + type: [Number], + encrypt: { keyId: KEY_ID, algorithm } + } + }, { encryptionType: 'csfle' }); + }); + + it('then the schema has a nested property that is encrypted', function() { + assert.ok(schemaHasEncryptedProperty(schema, 'encrypted')); + }); + }); + + }); + + describe('invalid schema types for encrypted schemas', function() { + describe('When a schema is instantiated with an encrypted field of type Number', function() { + it('Then an error is thrown', function() { + assert.throws(() => { + new Schema({ + field: { + type: Number, encrypt: { keyId: KEY_ID, algorithm } + } + }, { encryptionType: 'csfle' }); + }, /Invalid BSON type/); + }); + }); + + describe('When a schema is instantiated with an encrypted field of type Mixed', function() { + it('Then an error is thrown', function() { + assert.throws(() => { + new Schema({ + field: { + type: Schema.Types.Mixed, encrypt: { keyId: KEY_ID, algorithm } + } + }, { encryptionType: 'csfle' }); + }, /Invalid BSON type/); + }); + }); + + describe('When a schema is instantiated with a custom schema type plugin', function() { + class Int8 extends mongoose.SchemaType { + constructor(key, options) { + super(key, options, 'Int8'); + } + } + + beforeEach(function() { + // Don't forget to add `Int8` to the type registry + mongoose.Schema.Types.Int8 = Int8; + }); + afterEach(function() { + delete mongoose.Schema.Types.Int8; + }); + + it('Then an error is thrown', function() { + assert.throws(() => { + new Schema({ + field: { + type: Int8, encrypt: { keyId: KEY_ID, algorithm } + } + }, { encryptionType: 'csfle' }); + }, /Invalid BSON type/); + }); + }); + + }); + + describe('options.encryptionType', function() { + describe('when an encrypted schema is instantiated and an encryptionType is not provided', function() { + it('an error is thrown', function() { + assert.throws( + () => { + new Schema({ + field: { + type: String, + encrypt: { keyId: KEY_ID, algorithm } + } + }); + }, /encryptionType must be provided/ + ); + + + }); + }); + + describe('when a nested encrypted schema is provided to schema constructor and the encryption types are different', function() { + it('then an error is thrown', function() { + const innerSchema = new Schema({ + field1: { + type: String, encrypt: { + keyId: KEY_ID, + queries: { type: 'equality' } + } + } + }, { encryptionType: 'csfle' }); + assert.throws(() => { + new Schema({ + field1: innerSchema + }, { encryptionType: 'queryableEncryption' }); + }, /encryptionType of a nested schema must match the encryption type of the parent schema/); + }); + }); + }); + + describe('tests for schema mutation methods', function() { + describe('Schema.prototype.add()', function() { + describe('Given a schema with no encrypted fields', function() { + describe('When an encrypted field is added', function() { + it('Then the encrypted field is added to the encrypted fields for the schema', function() { + const schema = new Schema({ + field1: Number + }); + schema.encryptionType('csfle'); + schema.add( + { name: { type: String, encrypt: { keyId: KEY_ID, algorithm } } } + ); + assert.ok(schemaHasEncryptedProperty(schema, ['name'])); + }); + }); + }); + + describe('Given a schema with an encrypted field', function() { + describe('when an encrypted field is added', function() { + describe('and the encryption type matches the existing encryption type', function() { + it('Then the encrypted field is added to the encrypted fields for the schema', function() { + const schema = new Schema({ + field1: { type: String, encrypt: { keyId: KEY_ID, algorithm } } + }, { encryptionType: 'csfle' }); + schema.add( + { name: { type: String, encrypt: { keyId: KEY_ID, algorithm } } } + ); + assert.ok(schemaHasEncryptedProperty(schema, ['name'])); + }); + }); + }); + }); + + describe('Given a schema with an encrypted field', function() { + describe('when an encrypted field is added with different encryption settings for the same field', function() { + it('The encryption settings for the field are overridden', function() { + const schema = new Schema({ + field1: { type: String, encrypt: { keyId: KEY_ID, algorithm } } + }, { encryptionType: 'csfle' }); + schema.add( + { name: { type: String, encrypt: { keyId: new UUID(), algorithm } } } + ); + assert.notEqual(schema.encryptedFields['name'].keyId, KEY_ID); + }); + + }); + + describe('When an unencrypted field is added for the same field', function() { + it('The field on the schema is overridden', function() { + const schema = new Schema({ + field1: { type: String, encrypt: { keyId: KEY_ID, algorithm } } + }, { encryptionType: 'csfle' }); + schema.add( + { field1: String } + ); + assert.equal(schemaHasEncryptedProperty(schema, ['field1']), false); + }); + + }); + }); + + describe('Given a schema', function() { + describe('When multiple encrypted fields are added to the schema in one call to add()', function() { + it('Then all the encrypted fields are added to the schema', function() { + const schema = new Schema({ + field1: { type: String, encrypt: { keyId: KEY_ID, algorithm } } + }, { encryptionType: 'csfle' }); + schema.add( + { + name: { type: String, encrypt: { keyId: KEY_ID, algorithm } }, + age: { type: String, encrypt: { keyId: KEY_ID, algorithm } } + } + ); + + assert.ok(schemaHasEncryptedProperty(schema, ['name'])); + assert.ok(schemaHasEncryptedProperty(schema, ['age'])); + }); + }); + }); + }); + + describe('Schema.prototype.remove()', function() { + describe('Given a schema with one encrypted field', function() { + describe('When the encrypted field is removed', function() { + it('Then the encrypted fields on the schema does not contain the removed field', function() { + const schema = new Schema({ + field1: { type: String, encrypt: { keyId: KEY_ID, algorithm } } + }, { encryptionType: 'csfle' }); + schema.remove('field1'); + + assert.equal(schemaHasEncryptedProperty(schema, ['field1']), false); + }); + }); + }); + + describe('Given a schema with multiple encrypted fields', function() { + describe('When one encrypted field is removed', function() { + it('The encrypted fields on the schema does not contain the removed field', function() { + const schema = new Schema({ + field1: { type: String, encrypt: { keyId: KEY_ID, algorithm } }, + name: { type: String, encrypt: { keyId: KEY_ID, algorithm } }, + age: { type: String, encrypt: { keyId: KEY_ID, algorithm } } + }, { encryptionType: 'csfle' }); + schema.remove(['field1']); + + assert.equal(schemaHasEncryptedProperty(schema, ['field1']), false); + assert.equal(schemaHasEncryptedProperty(schema, ['name']), true); + assert.equal(schemaHasEncryptedProperty(schema, ['age']), true); + }); + }); + + describe('When all encrypted fields are removed', function() { + it('The encrypted fields on the schema does not contain the removed field', function() { + const schema = new Schema({ + field1: { type: String, encrypt: { keyId: KEY_ID, algorithm } }, + name: { type: String, encrypt: { keyId: KEY_ID, algorithm } }, + age: { type: String, encrypt: { keyId: KEY_ID, algorithm } } + }, { encryptionType: 'csfle' }); + schema.remove(['field1', 'name', 'age']); + + assert.equal(schemaHasEncryptedProperty(schema, ['field1']), false); + assert.equal(schemaHasEncryptedProperty(schema, ['name']), false); + assert.equal(schemaHasEncryptedProperty(schema, ['age']), false); + }); + }); + }); + + describe('when a nested encrypted property is removed', function() { + it('the encrypted field is removed from the schema', function() { + const schema = new Schema({ + field1: { name: { type: String, encrypt: { keyId: KEY_ID, algorithm } } } + }, { encryptionType: 'csfle' }); + + assert.equal(schemaHasEncryptedProperty(schema, ['field1.name']), true); + + schema.remove(['field1.name']); + + assert.equal(schemaHasEncryptedProperty(schema, ['field1.name']), false); + }); + }); + }); + }); + + describe('tests for schema copying methods', function() { + describe('Schema.prototype.clone()', function() { + describe('Given a schema with encrypted fields', function() { + describe('When the schema is cloned', function() { + it('The resultant schema contains all the same encrypted fields as the original schema', function() { + const schema1 = new Schema({ name: { type: String, encrypt: { keyId: KEY_ID, algorithm } } }, { encryptionType: 'csfle' }); + const schema2 = schema1.clone(); + + assert.equal(schemaHasEncryptedProperty(schema2, ['name']), true); + }); + it('The encryption type of the cloned schema is the same as the original', function() { + const schema1 = new Schema({ name: { type: String, encrypt: { keyId: KEY_ID, algorithm } } }, { encryptionType: 'csfle' }); + const schema2 = schema1.clone(); + + assert.equal(schema2.encryptionType(), 'csfle'); + }); + describe('When the cloned schema is modified', function() { + it('The original is not modified', function() { + const schema1 = new Schema({ name: { type: String, encrypt: { keyId: KEY_ID, algorithm } } }, { encryptionType: 'csfle' }); + const schema2 = schema1.clone(); + schema2.remove('name'); + assert.equal(schemaHasEncryptedProperty(schema2, ['name']), false); + assert.equal(schemaHasEncryptedProperty(schema1, ['name']), true); + }); + }); + }); + }); + }); + + describe('Schema.prototype.pick()', function() { + describe('When pick() is used with only unencrypted fields', function() { + it('Then the resultant schema has none of the original schema’s encrypted fields', function() { + const originalSchema = new Schema({ + name: { type: String, encrypt: { keyId: KEY_ID, algorithm } }, + age: { type: Int32, encrypt: { keyId: KEY_ID, algorithm } }, + name1: String, + age1: Int32 + }, { encryptionType: 'csfle' }); + + const schema2 = originalSchema.pick(['name1', 'age1']); + + assert.equal(schemaHasEncryptedProperty(schema2, ['name']), false); + assert.equal(schemaHasEncryptedProperty(schema2, ['age']), false); + }); + it('Then the encryption type is set to the cloned schemas encryptionType', function() { + const originalSchema = new Schema({ + name: { type: String, encrypt: { keyId: KEY_ID, algorithm } }, + age: { type: Int32, encrypt: { keyId: KEY_ID, algorithm } }, + name1: String, + age1: Int32 + }, { encryptionType: 'csfle' }); + + const schema2 = originalSchema.pick(['name1', 'age1']); + + assert.equal(schema2.encryptionType(), 'csfle'); + }); + }); + + describe('When pick() is used with some unencrypted fields', function() { + it('Then the resultant schema has the encrypted fields of the original schema that were specified to pick().', function() { + const originalSchema = new Schema({ + name: { type: String, encrypt: { keyId: KEY_ID, algorithm } }, + age: { type: Int32, encrypt: { keyId: KEY_ID, algorithm } }, + name1: String, + age1: Int32 + }, { encryptionType: 'csfle' }); + + const schema2 = originalSchema.pick(['name', 'age1']); + + assert.equal(schemaHasEncryptedProperty(schema2, ['name']), true); + assert.equal(schemaHasEncryptedProperty(schema2, ['age']), false); + }); + it('Then the encryption type is the same as the original schema’s encryption type', function() { + const originalSchema = new Schema({ + name: { type: String, encrypt: { keyId: KEY_ID, algorithm } }, + age: { type: Int32, encrypt: { keyId: KEY_ID, algorithm } }, + name1: String, + age1: Int32 + }, { encryptionType: 'csfle' }); + + const schema2 = originalSchema.pick(['name', 'age1']); + + assert.equal(schema2.encryptionType(), 'csfle'); + }); + }); + + describe('When pick() is used with nested paths', function() { + it('Then the resultant schema has the encrypted fields of the original schema that were specified to pick().', function() { + const originalSchema = new Schema({ + name: { + name: { type: String, encrypt: { keyId: KEY_ID, algorithm } } + }, + age: { type: Int32, encrypt: { keyId: KEY_ID, algorithm } }, + name1: String, + age1: Int32 + }, { encryptionType: 'csfle' }); + + const schema2 = originalSchema.pick(['name.name', 'age1']); + + assert.equal(schemaHasEncryptedProperty(schema2, ['name', 'name']), true); + assert.equal(schemaHasEncryptedProperty(schema2, ['age']), false); + }); + it('Then the encryption type is the same as the original schema’s encryption type', function() { + const originalSchema = new Schema({ + name: { type: String, encrypt: { keyId: KEY_ID, algorithm } }, + age: { type: Int32, encrypt: { keyId: KEY_ID, algorithm } }, + name1: String, + age1: Int32 + }, { encryptionType: 'csfle' }); + + const schema2 = originalSchema.pick(['name', 'age1']); + + assert.equal(schema2.encryptionType(), 'csfle'); + }); + }); + }); + + describe('Schema.prototype.omit()', function() { + describe('When omit() is used with only unencrypted fields', function() { + it('Then the resultant schema has all the original schema’s encrypted fields', function() { + const originalSchema = new Schema({ + name: { type: String, encrypt: { keyId: KEY_ID, algorithm } }, + age: { type: Int32, encrypt: { keyId: KEY_ID, algorithm } }, + name1: String, + age1: Int32 + }, { encryptionType: 'csfle' }); + + const schema2 = originalSchema.omit(['name1', 'age1']); + + assert.equal(schemaHasEncryptedProperty(schema2, ['name']), true); + assert.equal(schemaHasEncryptedProperty(schema2, ['age']), true); + }); + it('Then the encryption type is the same as the original schema’s encryption type', function() { + const originalSchema = new Schema({ + name: { type: String, encrypt: { keyId: KEY_ID, algorithm } }, + age: { type: Int32, encrypt: { keyId: KEY_ID, algorithm } }, + name1: String, + age1: Int32 + }, { encryptionType: 'csfle' }); + + const schema2 = originalSchema.omit(['name1', 'age1']); + + assert.equal(schema2.encryptionType(), 'csfle'); + }); + }); + + describe('When omit() is used with some unencrypted fields', function() { + it('Then the resultant schema has the encrypted fields of the original schema that were specified to omit()', function() { + const originalSchema = new Schema({ + name: { type: String, encrypt: { keyId: KEY_ID, algorithm } }, + age: { type: Int32, encrypt: { keyId: KEY_ID, algorithm } }, + name1: String, + age1: Int32 + }, { encryptionType: 'csfle' }); + + const schema2 = originalSchema.omit(['name', 'age1']); + + assert.equal(schemaHasEncryptedProperty(schema2, ['name']), false); + assert.equal(schemaHasEncryptedProperty(schema2, ['age']), true); + }); + it('Then the encryption type is the same as the original schema’s encryption type', function() { + const originalSchema = new Schema({ + name: { type: String, encrypt: { keyId: KEY_ID, algorithm } }, + age: { type: Int32, encrypt: { keyId: KEY_ID, algorithm } }, + name1: String, + age1: Int32 + }, { encryptionType: 'csfle' }); + + const schema2 = originalSchema.omit(['name', 'age1']); + + assert.equal(schema2.encryptionType(), 'csfle'); + }); + }); + + describe('When omit() is used with all the encrypted fields', function() { + it('Then the encryption type is the same as the original schema’s encryption type', function() { + const originalSchema = new Schema({ + name: { type: String, encrypt: { keyId: KEY_ID, algorithm } }, + age: { type: Int32, encrypt: { keyId: KEY_ID, algorithm } }, + name1: String, + age1: Int32 + }, { encryptionType: 'csfle' }); + + const schema2 = originalSchema.omit(['name', 'age']); + + assert.equal(schema2.encryptionType(), 'csfle'); + }); + }); + }); + }); +}); diff --git a/test/schema.test.js b/test/schema.test.js index 52a1b265781..a012376d28a 100644 --- a/test/schema.test.js +++ b/test/schema.test.js @@ -2173,7 +2173,7 @@ describe('schema', function() { const keys = Object.keys(SchemaStringOptions.prototype). filter(key => key !== 'constructor' && key !== 'populate'); const functions = Object.keys(Schema.Types.String.prototype). - filter(key => ['constructor', 'cast', 'castForQuery', 'checkRequired', 'toJSONSchema'].indexOf(key) === -1); + filter(key => ['constructor', 'cast', 'castForQuery', 'checkRequired', 'toJSONSchema', 'autoEncryptionType'].indexOf(key) === -1); assert.deepEqual(keys.sort(), functions.sort()); }); diff --git a/test/types/schema.test.ts b/test/types/schema.test.ts index 9d1cb073f51..cc575fcb19c 100644 --- a/test/types/schema.test.ts +++ b/test/types/schema.test.ts @@ -21,11 +21,9 @@ import { Types, Query, model, - ValidateOpts, - BufferToBinary + ValidateOpts } from 'mongoose'; -import { Binary } from 'mongodb'; -import { IsPathRequired } from '../../types/inferschematype'; +import { Binary, BSON } from 'mongodb'; import { expectType, expectError, expectAssignable } from 'tsd'; import { ObtainDocumentPathType, ResolvePathType } from '../../types/inferschematype'; @@ -591,6 +589,16 @@ const batchSchema2 = new Schema({ name: String }, { discriminatorKey: 'kind', st } } }); batchSchema2.discriminator('event', eventSchema2); + +function encryptionType() { + const keyId = new BSON.UUID(); + expectError(new Schema({ name: { type: String, encrypt: { keyId } } }, { encryptionType: 'newFakeEncryptionType' })); + expectError(new Schema({ name: { type: String, encrypt: { keyId } } }, { encryptionType: 1 })); + + expectType(new Schema({ name: { type: String, encrypt: { keyId } } }, { encryptionType: 'queryableEncryption' })); + expectType(new Schema({ name: { type: String, encrypt: { keyId } } }, { encryptionType: 'csfle' })); +} + function gh11828() { interface IUser { name: string; diff --git a/test/types/schemaTypeOptions.test.ts b/test/types/schemaTypeOptions.test.ts index 3514b01d7e9..9e501322fb5 100644 --- a/test/types/schemaTypeOptions.test.ts +++ b/test/types/schemaTypeOptions.test.ts @@ -1,3 +1,4 @@ +import { BSON } from 'mongodb'; import { AnyArray, Schema, @@ -74,3 +75,37 @@ function defaultOptions() { expectType>(new Schema.Types.Subdocument('none').defaultOptions); expectType>(new Schema.Types.UUID('none').defaultOptions); } + +function encrypt() { + const uuid = new BSON.UUID(); + const binary = new BSON.Binary(); + + new SchemaTypeOptions()['encrypt'] = { keyId: uuid, algorithm: 'AEAD_AES_256_CBC_HMAC_SHA_512-Deterministic' }; + new SchemaTypeOptions()['encrypt'] = { keyId: uuid, algorithm: 'AEAD_AES_256_CBC_HMAC_SHA_512-Random' }; + new SchemaTypeOptions()['encrypt'] = { keyId: uuid, algorithm: undefined }; + new SchemaTypeOptions()['encrypt'] = { keyId: [uuid], algorithm: 'AEAD_AES_256_CBC_HMAC_SHA_512-Random' }; + + // qe + valid queries + new SchemaTypeOptions()['encrypt'] = { keyId: uuid, queries: 'equality' }; + new SchemaTypeOptions()['encrypt'] = { keyId: uuid, queries: 'range' }; + new SchemaTypeOptions()['encrypt'] = { keyId: uuid, queries: undefined }; + + // empty object + expectError['encrypt']>({}); + + // invalid keyId + expectError['encrypt']>({ keyId: 'fakeId' }); + + // missing keyId + expectError['encrypt']>({ queries: 'equality' }); + expectError['encrypt']>({ algorithm: 'AEAD_AES_256_CBC_HMAC_SHA_512-Deterministic' }); + + // invalid algorithm + expectError['encrypt']>({ keyId: uuid, algorithm: 'SHA_FAKE_ALG' }); + + // invalid queries + expectError['encrypt']>({ keyId: uuid, queries: 'fakeQueryOption' }); + + // invalid input option + expectError['encrypt']>({ keyId: uuid, invalidKey: 'fakeKeyOption' }); +} diff --git a/types/schemaoptions.d.ts b/types/schemaoptions.d.ts index 4df87a806ea..f661e1643de 100644 --- a/types/schemaoptions.d.ts +++ b/types/schemaoptions.d.ts @@ -258,6 +258,11 @@ declare module 'mongoose' { * @default false */ overwriteModels?: boolean; + + /** + * Required when the schema is encrypted. + */ + encryptionType?: 'csfle' | 'queryableEncryption'; } interface DefaultSchemaOptions { diff --git a/types/schematypes.d.ts b/types/schematypes.d.ts index 519f3a4c1a8..f24aa4f8595 100644 --- a/types/schematypes.d.ts +++ b/types/schematypes.d.ts @@ -1,3 +1,5 @@ +import * as BSON from 'bson'; + declare module 'mongoose' { /** The Mongoose Date [SchemaType](/docs/schematypes.html). */ @@ -207,6 +209,11 @@ declare module 'mongoose' { maxlength?: number | [number, string] | readonly [number, string]; [other: string]: any; + + /** + * If set, configures the field for automatic encryption. + */ + encrypt?: EncryptSchemaTypeOptions; } interface Validator { @@ -218,6 +225,28 @@ declare module 'mongoose' { type ValidatorFunction = (this: DocType, value: any, validatorProperties?: Validator) => any; + interface QueryEncryptionEncryptOptions { + /** The id of the dataKey to use for encryption. Must be a BSON binary subtype 4 (UUID). */ + keyId: BSON.Binary; + + /** + * Specifies the type of queries that the field can be queried on the encrypted field. + */ + queries?: 'equality' | 'range'; + } + + interface ClientSideEncryptionEncryptOptions { + /** The id of the dataKey to use for encryption. Must be a BSON binary subtype 4 (UUID). */ + keyId: [BSON.Binary]; + + /** + * The algorithm to use for encryption. + */ + algorithm: 'AEAD_AES_256_CBC_HMAC_SHA_512-Deterministic' | 'AEAD_AES_256_CBC_HMAC_SHA_512-Random'; + } + + export type EncryptSchemaTypeOptions = QueryEncryptionEncryptOptions | ClientSideEncryptionEncryptOptions; + class SchemaType { /** SchemaType constructor */ constructor(path: string, options?: AnyObject, instance?: string);