Skip to content

Commit 05b6496

Browse files
authored
fix: allow updating unindexed encrypted fields COMPASS-5809 (#3262)
* chore(data-service): track equality-searchable encrypted fields In addition to tracking all encrypted fields for a given collection’s schema sources, also track equality-searchable encrypted fields. This is helpful for knowing whether a field can be included in the query part of a Compass CRUD update operation that includes original values of fields in the query portion of that update. As part of this, the list of encrypted fields reported by `knownSchemaForCollection()` and used internally in the encrypted field tracker is changed from a simple array of strings to a pair of field path lists, one for all encrypted and one for equality-searchable encrypted fields in the schema. Using field paths instead of `.`-joined strings has no direct effect here, but aligns this part of Compass with the recent changes to nested field editing/dots and dollars support. * fix(compass-crud): exclude unindexed encrypted fields in update query COMPASS-5809 When updating unindexed encrypted fields, exclude them from the find part of the update operation, since they are by definition not queryable. This modifies the HadronDocument query building methods to take options arguments instead of key lists, and updates these options to be passed as field path lists rather than a plain array of strings (where `.` was ambiguous between literal field names and nested fields, which was not a problem previously because the field paths were always coming from the context of shard keys, where `.` is unambiguous but now is with FLE1 nested fields).
1 parent 0896aaf commit 05b6496

File tree

8 files changed

+524
-187
lines changed

8 files changed

+524
-187
lines changed

packages/compass-crud/src/stores/crud-store.js

Lines changed: 61 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -456,7 +456,12 @@ const configureStore = (options = {}) => {
456456
// required for updated documents in sharded collections.
457457
const { query, updateDoc } =
458458
doc.generateUpdateUnlessChangedInBackgroundQuery(
459-
Object.keys(this.state.shardKeys)
459+
// '.' in shard keys means nested doc
460+
{
461+
alwaysIncludeKeys: Object.keys(this.state.shardKeys || {}).map(
462+
(key) => key.split('.')
463+
),
464+
}
460465
);
461466
debug('Performing findOneAndUpdate', { query, updateDoc });
462467

@@ -513,38 +518,58 @@ const configureStore = (options = {}) => {
513518
track('Document Updated', { mode: this.modeForTelemetry() });
514519
try {
515520
doc.emit('update-start');
516-
const object = doc.generateObject();
517-
const query = doc.getQueryForOriginalKeysAndValuesForSpecifiedKeys([
518-
'_id',
519-
...Object.keys(this.state.shardKeys || {}),
520-
]);
521-
debug('Performing findOneAndReplace', { query, object });
522521

523522
if (!(await this._verifyUpdateAllowed(this.state.ns, doc))) {
524523
// _verifyUpdateAllowed emitted update-error
525524
return;
526525
}
527526

527+
const object = doc.generateObject();
528+
const queryKeyInclusionOptions = {
529+
alwaysIncludeKeys: [
530+
['_id'],
531+
// '.' in shard keys means nested doc
532+
...Object.keys(this.state.shardKeys || {}).map((key) =>
533+
key.split('.')
534+
),
535+
],
536+
};
537+
528538
if (
529539
this.dataService.getCSFLEMode &&
530-
this.dataService.getCSFLEMode() === 'enabled' &&
531-
object.__safeContent__ &&
532-
isEqual(
533-
object.__safeContent__,
534-
doc.generateOriginalObject().__safeContent__
535-
) &&
536-
(
537-
await this.dataService
538-
.getCSFLECollectionTracker()
539-
.knownSchemaForCollection(this.state.ns)
540-
).hasSchema
540+
this.dataService.getCSFLEMode() === 'enabled'
541541
) {
542-
// SERVER-66662 blocks writes of __safeContent__ for queryable-encryption-enabled
543-
// collections. We remove it unless it was edited, in which case we assume that the
544-
// user really knows what they are doing.
545-
delete object.__safeContent__;
542+
const knownSchemaForCollection = await this.dataService
543+
.getCSFLECollectionTracker()
544+
.knownSchemaForCollection(this.state.ns);
545+
546+
// The find/query portion will typically exclude encrypted fields,
547+
// because those cannot be queried to make sure that the original
548+
// value matches the current one; however, if we know that the
549+
// field is equality-searchable, we can (and should) still include it.
550+
queryKeyInclusionOptions.includableEncryptedKeys =
551+
knownSchemaForCollection.encryptedFields.equalityQueryableEncryptedFields;
552+
553+
if (
554+
object.__safeContent__ &&
555+
isEqual(
556+
object.__safeContent__,
557+
doc.generateOriginalObject().__safeContent__
558+
) &&
559+
knownSchemaForCollection.hasSchema
560+
) {
561+
// SERVER-66662 blocks writes of __safeContent__ for queryable-encryption-enabled
562+
// collections. We remove it unless it was edited, in which case we assume that the
563+
// user really knows what they are doing.
564+
delete object.__safeContent__;
565+
}
546566
}
547567

568+
const query = doc.getQueryForOriginalKeysAndValuesForSpecifiedKeys(
569+
queryKeyInclusionOptions
570+
);
571+
debug('Performing findOneAndReplace', { query, object });
572+
548573
// eslint-disable-next-line no-shadow
549574
const [error, d] = await findAndModifyWithFLEFallback(
550575
this.dataService,
@@ -734,10 +759,20 @@ const configureStore = (options = {}) => {
734759
// does not have a schema.
735760
const csfleCollectionTracker =
736761
this.dataService.getCSFLECollectionTracker();
737-
const { hasSchema, encryptedFields } =
738-
await csfleCollectionTracker.knownSchemaForCollection(this.state.ns);
739-
if (encryptedFields) {
740-
csfleState.encryptedFields = encryptedFields;
762+
const {
763+
hasSchema,
764+
encryptedFields: { encryptedFields },
765+
} = await csfleCollectionTracker.knownSchemaForCollection(
766+
this.state.ns
767+
);
768+
if (encryptedFields.length > 0) {
769+
// This is for displaying encrypted fields to the user. We do not really
770+
// need to worry about the distinction between '.' as a nested-field
771+
// indicator and '.' as a literal part of a field name here, esp. since
772+
// automatic Queryable Encryption does not support '.' in field names at all.
773+
csfleState.encryptedFields = encryptedFields.map((field) =>
774+
field.join('.')
775+
);
741776
}
742777
if (!hasSchema) {
743778
csfleState.state = 'no-known-schema';

packages/compass-crud/src/stores/crud-store.spec.js

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1367,7 +1367,7 @@ describe('store', function () {
13671367
});
13681368

13691369
getCSFLEMode.returns('enabled');
1370-
knownSchemaForCollection.resolves({ hasSchema: false });
1370+
knownSchemaForCollection.resolves({ hasSchema: false, encryptedFields: { encryptedFields: [] } });
13711371

13721372
store.openInsertDocumentDialog(doc, false);
13731373

@@ -1391,7 +1391,7 @@ describe('store', function () {
13911391
getCSFLEMode.returns('enabled');
13921392
knownSchemaForCollection.resolves({
13931393
hasSchema: true,
1394-
encryptedFields: ['x'],
1394+
encryptedFields: { encryptedFields: [['x']] },
13951395
});
13961396
isUpdateAllowed.resolves(false);
13971397

@@ -1417,7 +1417,7 @@ describe('store', function () {
14171417
getCSFLEMode.returns('enabled');
14181418
knownSchemaForCollection.resolves({
14191419
hasSchema: true,
1420-
encryptedFields: ['x'],
1420+
encryptedFields: { encryptedFields: [['x']] },
14211421
});
14221422
isUpdateAllowed.resolves(true);
14231423

@@ -1442,7 +1442,7 @@ describe('store', function () {
14421442
getCSFLEMode.returns('disabled');
14431443
knownSchemaForCollection.resolves({
14441444
hasSchema: true,
1445-
encryptedFields: ['x'],
1445+
encryptedFields: { encryptedFields: [['x']] },
14461446
});
14471447
isUpdateAllowed.resolves(true);
14481448

packages/compass-e2e-tests/tests/in-use-encryption.test.ts

Lines changed: 62 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -209,6 +209,7 @@ describe('FLE2', function () {
209209
describe('when fleEncryptedFieldsMap is specified while connecting', function () {
210210
const databaseName = 'fle-test';
211211
const collectionName = 'my-another-collection';
212+
const collectionNameUnindexed = 'my-another-collection2';
212213
let compass: Compass;
213214
let browser: CompassBrowser;
214215
let plainMongo: MongoClient;
@@ -233,6 +234,15 @@ describe('FLE2', function () {
233234
queries: { queryType: 'equality' }
234235
}
235236
]
237+
},
238+
'${databaseName}.${collectionNameUnindexed}': {
239+
fields: [
240+
{
241+
path: 'phoneNumber',
242+
keyId: UUID("28bbc608-524e-4717-9246-33633361788e"),
243+
bsonType: 'string'
244+
}
245+
]
236246
}
237247
}`,
238248
});
@@ -382,50 +392,66 @@ describe('FLE2', function () {
382392
expect(isDocumentPhoneNumberDecryptedIconExisting).to.be.equal(true);
383393
});
384394

385-
it('can edit and query the encrypted field in the CRUD view', async function () {
386-
await browser.shellEval(`db.createCollection('${collectionName}')`);
387-
await browser.shellEval(
388-
`db[${JSON.stringify(
389-
collectionName
390-
)}].insertOne({ "phoneNumber": "30303030", "name": "Person X" })`
391-
);
395+
for (const [mode, coll] of [
396+
['indexed', collectionName],
397+
['unindexed', collectionNameUnindexed],
398+
]) {
399+
it(`can edit and query the ${mode} encrypted field in the CRUD view`, async function () {
400+
await browser.shellEval(`db.createCollection('${coll}')`);
401+
await browser.shellEval(
402+
`db[${JSON.stringify(
403+
coll
404+
)}].insertOne({ "phoneNumber": "30303030", "name": "Person X" })`
405+
);
392406

393-
await browser.navigateToCollectionTab(
394-
databaseName,
395-
collectionName,
396-
'Documents'
397-
);
407+
await browser.navigateToCollectionTab(
408+
databaseName,
409+
coll,
410+
'Documents'
411+
);
398412

399-
const result = await getFirstListDocument(browser);
400-
expect(result.phoneNumber).to.be.equal('"30303030"');
413+
const result = await getFirstListDocument(browser);
414+
expect(result.phoneNumber).to.be.equal('"30303030"');
401415

402-
const document = await browser.$(Selectors.DocumentListEntry);
403-
const value = await document.$(
404-
`${Selectors.HadronDocumentElement}[data-field="phoneNumber"] ${Selectors.HadronDocumentClickableValue}`
405-
);
406-
await value.doubleClick();
416+
const document = await browser.$(Selectors.DocumentListEntry);
417+
const value = await document.$(
418+
`${Selectors.HadronDocumentElement}[data-field="phoneNumber"] ${Selectors.HadronDocumentClickableValue}`
419+
);
420+
await value.doubleClick();
407421

408-
const input = await document.$(
409-
`${Selectors.HadronDocumentElement}[data-field="phoneNumber"] ${Selectors.HadronDocumentValueEditor}`
410-
);
411-
await input.setValue('10101010');
422+
const input = await document.$(
423+
`${Selectors.HadronDocumentElement}[data-field="phoneNumber"] ${Selectors.HadronDocumentValueEditor}`
424+
);
425+
await input.setValue('10101010');
412426

413-
const footer = await document.$(Selectors.DocumentFooterMessage);
414-
expect(await footer.getText()).to.equal('Document modified.');
427+
const footer = await document.$(Selectors.DocumentFooterMessage);
428+
expect(await footer.getText()).to.equal('Document modified.');
415429

416-
const button = await document.$(Selectors.UpdateDocumentButton);
417-
await button.click();
418-
await footer.waitForDisplayed({ reverse: true });
430+
const button = await document.$(Selectors.UpdateDocumentButton);
431+
await button.click();
432+
try {
433+
await footer.waitForDisplayed({ reverse: true, timeout: 1000 });
434+
} catch (err) {
435+
if (
436+
mode === 'unindexed' &&
437+
(await footer.getText()) ===
438+
'Found indexed encrypted fields but could not find __safeContent__'
439+
) {
440+
return; // This version is affected by SERVER-68065 where updates on unindexed fields in QE are buggy.
441+
}
442+
}
443+
await footer.waitForDisplayed({ reverse: true });
419444

420-
await browser.runFindOperation(
421-
'Documents',
422-
"{ phoneNumber: '10101010' }"
423-
);
445+
await browser.runFindOperation(
446+
'Documents',
447+
"{ phoneNumber: '10101010' }"
448+
);
424449

425-
const modifiedResult = await getFirstListDocument(browser);
426-
expect(modifiedResult.phoneNumber).to.be.equal('"10101010"');
427-
expect(modifiedResult._id).to.be.equal(result._id);
428-
});
450+
const modifiedResult = await getFirstListDocument(browser);
451+
expect(modifiedResult.phoneNumber).to.be.equal('"10101010"');
452+
expect(modifiedResult._id).to.be.equal(result._id);
453+
});
454+
}
429455

430456
it('can edit and query the encrypted field in the JSON view', async function () {
431457
await browser.shellEval(`db.createCollection('${collectionName}')`);

0 commit comments

Comments
 (0)