mongodb-data-service
Version:
MongoDB Data Service
288 lines • 15 kB
JavaScript
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
;