UNPKG

mongodb-data-service

Version:
288 lines • 15 kB
"use strict"; var __importDefault = (this && this.__importDefault) || function (mod) { return (mod && mod.__esModule) ? mod : { "default": mod }; }; Object.defineProperty(exports, "__esModule", { value: true }); exports.CSFLECollectionTrackerImpl = void 0; const mongodb_ns_1 = __importDefault(require("mongodb-ns")); const lodash_1 = __importDefault(require("lodash")); const logger_1 = require("./logger"); const bson_1 = require("bson"); class CSFLEEncryptedFieldsSetImpl { constructor() { this._encryptedFields = []; } get encryptedFields() { return this._encryptedFields.map(({ path }) => path); } get equalityQueryableEncryptedFields() { return this._encryptedFields .filter(({ equalityQueryable }) => equalityQueryable) .map(({ path }) => path); } addField(path, equalityQueryable) { const existingField = this._encryptedFields.find((field) => lodash_1.default.isEqual(field.path, path)); if (existingField) { // Deduplicate field entries. If there already is one for this field, // only make sure that the `equalityQueryable` attributes match. // If they don't, assume that the field is not equality-queryable. existingField.equalityQueryable &&= equalityQueryable; } else { this._encryptedFields.push({ path: [...path], equalityQueryable }); } return this; } withPathPrefix(prefix) { const ret = new CSFLEEncryptedFieldsSetImpl(); ret._encryptedFields = this._encryptedFields.map(({ path, equalityQueryable }) => ({ path: [prefix, ...path], equalityQueryable, })); return ret; } static isEncryptedField(set, path) { return set.encryptedFields.some((encryptedField) => lodash_1.default.isEqual(path, encryptedField)); } static isEqualityQueryableEncryptedField(set, path) { return set.equalityQueryableEncryptedFields.some((encryptedField) => lodash_1.default.isEqual(path, encryptedField)); } static merge(...sets) { const ret = new CSFLEEncryptedFieldsSetImpl(); for (const set of sets) { if (!set) continue; for (const field of set.encryptedFields) { ret.addField(field, this.isEqualityQueryableEncryptedField(set, field)); } } return ret; } } // Fetch a list of encrypted fields from a JSON schema document. function extractEncryptedFieldsFromSchema(schema) { let ret = new CSFLEEncryptedFieldsSetImpl(); if (schema?.encrypt) { const algorithm = schema?.encrypt?.algorithm; // Deterministic encryption in CSFLE = Equality-searchable return ret.addField([], algorithm === 'AEAD_AES_256_CBC_HMAC_SHA_512-Deterministic'); } for (const [key, subschema] of Object.entries(schema?.properties ?? {})) { const subfields = extractEncryptedFieldsFromSchema(subschema); ret = CSFLEEncryptedFieldsSetImpl.merge(ret, subfields.withPathPrefix(key)); } return ret; } // Fetch a list of encrypted fields from a QE EncryptedFieldConfig document. function extractEncryptedFieldsFromEncryptedFieldsConfig(encryptedFields) { const fields = encryptedFields?.fields ?? []; const ret = new CSFLEEncryptedFieldsSetImpl(); for (const field of fields) { const queries = Array.isArray(field.queries) ? field.queries : [field.queries ?? {}]; const equalityQueryable = queries.some(({ queryType }) => ['equality', 'range', 'rangePreview'].includes(queryType)); ret.addField(field.path.split('.'), equalityQueryable); } return ret; } // Fetch a list of encrypted fields based on client-side driver options. function extractEncrytedFieldFromAutoEncryptionOptions(ns, autoEncryption) { return CSFLEEncryptedFieldsSetImpl.merge(extractEncryptedFieldsFromSchema(autoEncryption.schemaMap?.[ns]), extractEncryptedFieldsFromEncryptedFieldsConfig(autoEncryption?.encryptedFieldsMap?.[ns])); } // Fetch a list of encrypted fields based on the server-side collection info. function extractEncryptedFieldsFromListCollectionsResult(options) { const schema = options?.validator?.$jsonSchema; const encryptedFieldsConfig = options?.encryptedFields; return CSFLEEncryptedFieldsSetImpl.merge(extractEncryptedFieldsFromSchema(schema), extractEncryptedFieldsFromEncryptedFieldsConfig(encryptedFieldsConfig)); } // Fetch a list of encrypted fields based on a document received from the server. // eslint-disable-next-line @typescript-eslint/no-explicit-any function extractEncryptedFieldsFromDocument(doc) { const decryptedFields = (doc?.[Symbol.for('@@mdb.decryptedKeys')] ?? []).map((field) => [field]); for (const [key, value] of Object.entries(doc)) { if (!value || typeof value !== 'object') { continue; } const nestedFields = extractEncryptedFieldsFromDocument(value); decryptedFields.push(...nestedFields.map((path) => [key, ...path])); } return decryptedFields; } function isOlderThan1Minute(oldDate) { return Date.now() - oldDate.getTime() >= 60_000; } class CSFLECollectionTrackerImpl { constructor(_dataService, _crudClient, _logger) { this._dataService = _dataService; this._crudClient = _crudClient; this._logger = _logger; this._nsToInfo = new Map(); this._processClientSchemaDefinitions(); const { autoEncrypter } = this._crudClient; if (autoEncrypter) { this._logger?.info('COMPASS-DATA-SERVICE', (0, logger_1.mongoLogId)(1_001_000_118), 'CSFLECollectionTracker', 'Hooking AutoEncrypter metaDataClient property'); // eslint-disable-next-line @typescript-eslint/no-explicit-any autoEncrypter._metaDataClient = this._createHookedMetadataClient(autoEncrypter._metaDataClient); } } async isUpdateAllowed(ns, originalDocument) { const originalDocEncryptedFields = extractEncryptedFieldsFromDocument(originalDocument); if (originalDocEncryptedFields.length === 0) { // Shortcut: If no fields were encrypted when we got them // from the server, then we also do not need to worry // about writing them back unencrypted. return true; } const { encryptedFields } = await this.knownSchemaForCollection(ns); // Updates are allowed if there is a guarantee that all fields that // were decrypted in the original document will also be written back // as encrypted fields. To that end, the server or client configuration // must contain entries that are picked up by libmongocrypt as indicators // for encrypted fields. for (const originalDocPath of originalDocEncryptedFields) { if (!CSFLEEncryptedFieldsSetImpl.isEncryptedField(encryptedFields, originalDocPath)) { return false; } } return true; } async knownSchemaForCollection(ns) { const info = await this._fetchCSFLECollectionInfo(ns); const encryptedFields = CSFLEEncryptedFieldsSetImpl.merge(info.clientEnforcedEncryptedFields, info.serverEnforcedEncryptedFields); const hasSchema = !!(info.hasServerSchema || encryptedFields.encryptedFields.length > 0); return { hasSchema, encryptedFields }; } _processClientSchemaDefinitions() { // Process client-side options available at instantiation time. const { autoEncryption } = this._crudClient.options; for (const ns of [ ...Object.keys(autoEncryption?.schemaMap ?? {}), ...Object.keys(autoEncryption?.encryptedFieldsMap ?? {}), ]) { this._logger?.info('COMPASS-DATA-SERVICE', (0, logger_1.mongoLogId)(1_001_000_119), 'CSFLECollectionTracker', 'Processing client-side schema information', { ns }); const info = this._getCSFLECollectionInfo(ns); info.clientEnforcedEncryptedFields = extractEncrytedFieldFromAutoEncryptionOptions(ns, autoEncryption); } } async _fetchCSFLECollectionInfo(ns) { const parsedNs = (0, mongodb_ns_1.default)(ns); const info = this._getCSFLECollectionInfo(ns); if (!info.serverEnforcedEncryptedFields || !info.lastUpdated || isOlderThan1Minute(info.lastUpdated)) { this._logger?.info('COMPASS-DATA-SERVICE', (0, logger_1.mongoLogId)(1_001_000_120), 'CSFLECollectionTracker', 'Refreshing listCollections cache', { ns }); // Let the data service fetch new collection infos. // We installed a listener earlier which picks up the results, // and additionally also fetches the results from unrelated // listCollections calls so that explicitly fetching them // becomes necessary less often. await this._dataService.listCollections(parsedNs.database, { name: parsedNs.collection, }); } return info; } _getCSFLECollectionInfo(ns) { // Look up the internally stored CSFLE collection info for a specific namespace. const existing = this._nsToInfo.get(ns); if (existing) return existing; const info = {}; this._nsToInfo.set(ns, info); return info; } *_getCSFLECollectionNames() { for (const ns of this._nsToInfo.keys()) { yield (0, mongodb_ns_1.default)(ns); } } updateCollectionInfo(ns, result) { this._logger?.info('COMPASS-DATA-SERVICE', (0, logger_1.mongoLogId)(1_001_000_121), 'CSFLECollectionTracker', 'Processing listCollections update', { ns }); const info = this._getCSFLECollectionInfo(ns); // Store the updated list of encrypted fields. // This list can be empty if no server-side validation existed or was removed. info.serverEnforcedEncryptedFields = extractEncryptedFieldsFromListCollectionsResult(result.options); info.hasServerSchema = !!result.options?.validator?.$jsonSchema; info.lastUpdated = new Date(); } _createHookedMetadataClient(wrappedClient) { // The AutoEncrypter instance used by the MongoClient will // use listCollections to look up metadata for a given collection. // We hook into this process to verify that this listCollections // call does not return looser restrictions than those that // Compass knows about and relies on. // This listCollections call will only be made in a specific way, // with specific arguments. If this ever changes at some point, // we may need to work out e.g. a good semi-official hook with the // driver team, similar to what we have for the @@mdb.decryptedFields // functionality, but currently no such changes are planned or expected. return { db: (dbName) => { return { listCollections: (filter, opts) => { // eslint-disable-next-line @typescript-eslint/no-this-alias const self = this; // there are no async generator arrow functions return { [Symbol.asyncIterator]: async function* () { const collectionInfos = await wrappedClient .db(dbName) .listCollections(filter, opts) .toArray(); const err = self._checkListCollectionsForLibmongocryptResult(dbName, filter, (collectionInfos ?? [])); if (err) { self._logger?.error('COMPASS-DATA-SERVICE', (0, logger_1.mongoLogId)(1_001_000_122), 'CSFLECollectionTracker', 'Rejecting listCollections in hooked metaDataClient', { error: err.message }); throw err; } yield* collectionInfos; }, }; }, }; }, }; } _checkListCollectionsForLibmongocryptResult(dbName, filter, collectionInfos) { // filter is either { name: string } or { name: { $in: string[] } } if (typeof filter?.name !== 'string' && (!filter?.name || typeof filter.name !== 'object' || !Array.isArray(filter.name.$in) || !filter.name.$in.every((name) => typeof name === 'string'))) { // This is an assertion more than an actual error condition. // It ensures that we're only getting listCollections requests // in the format that we expect them to come in. return new Error(`[Compass] Unexpected listCollections request on '${dbName}' with filter: ${bson_1.EJSON.stringify(filter)}`); } const filterNames = typeof filter.name === 'string' ? [filter.name] : filter.name.$in; // First check: All collections for which we had existing encrypted fields // in the server schema also have a collection schema in the new listCollections response for (const existingNs of this._getCSFLECollectionNames()) { if (existingNs.database === dbName && filterNames.includes(existingNs.collection)) { const existingInfo = this._getCSFLECollectionInfo(existingNs.ns); if (existingInfo.serverEnforcedEncryptedFields?.encryptedFields?.length && !collectionInfos.some((info) => info.name === existingNs.collection)) { return new Error(`[Compass] Missing encrypted field information for collection '${existingNs.ns}'`); } } } // Second check: All fields that were previously known to be encrypted // are still encrypted in the new listCollections response for (const info of collectionInfos) { const ns = `${dbName}.${info.name}`; const existingInfo = this._getCSFLECollectionInfo(ns); const newInfo = extractEncryptedFieldsFromListCollectionsResult(info.options); for (const expectedEncryptedField of existingInfo .serverEnforcedEncryptedFields?.encryptedFields ?? []) { if (!newInfo.encryptedFields.some((field) => lodash_1.default.isEqual(field, expectedEncryptedField))) { return new Error(`[Compass] Missing encrypted field '${expectedEncryptedField.join('.')}' of collection '${ns}' in listCollections result`); } } } } } exports.CSFLECollectionTrackerImpl = CSFLECollectionTrackerImpl; //# sourceMappingURL=csfle-collection-tracker.js.map