From aa7066c4b1cfce7cf72cf115e4b4316bb8a59738 Mon Sep 17 00:00:00 2001 From: Basit <1305718+mabaasit@users.noreply.github.com> Date: Mon, 9 Sep 2024 18:33:32 +0200 Subject: [PATCH] feat(connection-form): support for multiple kms options from same provider COMPASS-8082 (#6166) * ux changes * fix tests * fix after merge * tests * ts issues * remove todo * store connection data * use icon button * fix types * fix test * another ts * fix connection storage test * show delete button on hover * show configured providers when creating collection * multiple local keys in tests * multiple local keys in tests * allow rename of kms * tests * don't clear state when modal is closed * check * pr feedback * cleaner path names * e2e-test * remove tls * fix id * use regex to get next name * reformat doesn't take care of ts-comments * cr fix * revert unnecessary changes --- .../src/stores/connections-store-redux.ts | 7 +- .../helpers/commands/connect-form.ts | 32 ++- .../helpers/connect-form-state.ts | 8 +- .../compass-e2e-tests/helpers/selectors.ts | 10 +- .../tests/connection.test.ts | 8 +- .../tests/in-use-encryption.test.ts | 187 ++++++++++++- .../csfle-tab/csfle-tab.spec.tsx | 200 ++++++++++++++ .../csfle-tab/csfle-tab.tsx | 141 ++++++---- .../csfle-tab/kms-local-key-generator.tsx | 24 +- .../csfle-tab/kms-provider-card.tsx | 244 +++++++++++++++++ .../csfle-tab/kms-provider-content.tsx | 98 +++++++ .../csfle-tab/kms-provider-fields.tsx | 31 ++- .../kms-provider-status-indicator.tsx | 31 ++- .../csfle-tab/kms-tls-options.tsx | 16 +- .../src/components/connection-form.spec.tsx | 1 - .../src/hooks/use-connect-form.ts | 29 ++ .../src/utils/csfle-handler.spec.ts | 257 +++++++++++++++++- .../src/utils/csfle-handler.ts | 243 +++++++++++++++-- .../src/utils/csfle-kms-fields.ts | 91 ++++--- .../connection-form/src/utils/validation.ts | 2 + .../src/connection-secrets.spec.ts | 105 ++++++- .../connection-info/src/connection-secrets.ts | 86 ++++-- .../compass-main-connection-storage.spec.ts | 31 ++- .../collection-fields/fle2-fields.jsx | 25 +- .../src/modules/create-namespace.ts | 13 +- 25 files changed, 1718 insertions(+), 202 deletions(-) create mode 100644 packages/connection-form/src/components/advanced-options-tabs/csfle-tab/kms-provider-card.tsx create mode 100644 packages/connection-form/src/components/advanced-options-tabs/csfle-tab/kms-provider-content.tsx diff --git a/packages/compass-connections/src/stores/connections-store-redux.ts b/packages/compass-connections/src/stores/connections-store-redux.ts index b20108b4faa..9e8e6850371 100644 --- a/packages/compass-connections/src/stores/connections-store-redux.ts +++ b/packages/compass-connections/src/stores/connections-store-redux.ts @@ -1549,12 +1549,15 @@ export const connect = ( dispatch(disconnect(connectionInfo.id)); }); + const { connectionOptions, ...restOfTheConnectionInfo } = + connectionInfo; + const adjustedConnectionInfoForConnection: ConnectionInfo = merge( - cloneDeep(connectionInfo), + cloneDeep(restOfTheConnectionInfo), { connectionOptions: adjustConnectionOptionsBeforeConnect({ connectionOptions: merge( - cloneDeep(connectionInfo.connectionOptions), + cloneDeep(connectionOptions), SecretsForConnection.get(connectionInfo.id) ?? {} ), defaultAppName: appName, diff --git a/packages/compass-e2e-tests/helpers/commands/connect-form.ts b/packages/compass-e2e-tests/helpers/commands/connect-form.ts index c2d8f38491c..97575d39fce 100644 --- a/packages/compass-e2e-tests/helpers/commands/connect-form.ts +++ b/packages/compass-e2e-tests/helpers/commands/connect-form.ts @@ -455,6 +455,18 @@ function colorValueToName(color: string): string { return color; } +async function setKMSProviderName( + browser: CompassBrowser, + index: number, + name: string +) { + await browser.clickVisible(Selectors.connectionFormEditFLEName(index)); + return await browser.setValueVisible( + Selectors.connectionFormInputFLELocalName(index), + name + ); +} + export async function setConnectFormState( browser: CompassBrowser, state: ConnectFormState @@ -654,7 +666,7 @@ export async function setConnectFormState( // FLE2 if ( state.fleKeyVaultNamespace || - state.fleKey || + state.kmsProviders || state.fleEncryptedFieldsMap ) { await browser.navigateToConnectTab('In-Use Encryption'); @@ -665,12 +677,20 @@ export async function setConnectFormState( state.fleKeyVaultNamespace ); } - if (state.fleKey) { + if ((state.kmsProviders?.local?.length ?? 0) > 0) { await browser.expandAccordion(Selectors.ConnectionFormInputFLELocalKMS); - await browser.setValueVisible( - Selectors.ConnectionFormInputFLELocalKey, - state.fleKey - ); + for (const [index, item] of (state.kmsProviders?.local ?? []).entries()) { + if (item.name) { + await setKMSProviderName(browser, index, item.name); + } + await browser.setValueVisible( + Selectors.connectionFormInputFLELocalKey(index), + item.key + ); + await browser.clickVisible( + Selectors.ConnectionFormAddNewKMSProviderButton + ); + } } if (state.fleEncryptedFieldsMap) { // set the text in the editor diff --git a/packages/compass-e2e-tests/helpers/connect-form-state.ts b/packages/compass-e2e-tests/helpers/connect-form-state.ts index 9f1c087da39..d68cfddcdf2 100644 --- a/packages/compass-e2e-tests/helpers/connect-form-state.ts +++ b/packages/compass-e2e-tests/helpers/connect-form-state.ts @@ -63,8 +63,14 @@ export interface ConnectFormState { // FLE2 fleKeyVaultNamespace?: string; fleStoreCredentials?: boolean; - fleKey?: string; fleEncryptedFieldsMap?: string; + kmsProviders?: { + // For now adding support for local only + local?: { + name?: string; + key: string; + }[]; + }; // - SSH with Password sshPasswordHost?: string; diff --git a/packages/compass-e2e-tests/helpers/selectors.ts b/packages/compass-e2e-tests/helpers/selectors.ts index ae171e63922..3c1893019d9 100644 --- a/packages/compass-e2e-tests/helpers/selectors.ts +++ b/packages/compass-e2e-tests/helpers/selectors.ts @@ -120,8 +120,14 @@ export const ConnectionFormInputFLEStoreCredentialsCheckbox = '[data-testid="csfle-store-credentials-input"]'; export const ConnectionFormInputFLELocalKMS = '[data-testid="csfle-kms-provider-local"]'; -export const ConnectionFormInputFLELocalKey = - '[data-testid="csfle-kms-local-key"]'; +export const connectionFormEditFLEName = (index = 0) => + `[data-card-index="${index}"] [data-testid="csfle-edit-kms-name"]`; +export const connectionFormInputFLELocalName = (index = 0) => + `[data-card-index="${index}"] [data-testid="csfle-kms-card-name"]`; +export const connectionFormInputFLELocalKey = (index = 0) => + `[data-card-index="${index}"] [data-testid="csfle-kms-local-key"]`; +export const ConnectionFormAddNewKMSProviderButton = + '[data-testid="csfle-add-new-kms-provider-button"]'; export const ConnectionFormInputFLEEncryptedFieldsMap = '[data-testid="connection-csfle-encrypted-fields-map"]'; export const ConnectionFormInputFLEEncryptedFieldsMapEditor = diff --git a/packages/compass-e2e-tests/tests/connection.test.ts b/packages/compass-e2e-tests/tests/connection.test.ts index 9b772deaaae..b5477f5151e 100644 --- a/packages/compass-e2e-tests/tests/connection.test.ts +++ b/packages/compass-e2e-tests/tests/connection.test.ts @@ -1202,7 +1202,13 @@ describe('FLE2', function () { await browser.connectWithConnectionForm({ hosts: ['127.0.0.1:27091'], fleKeyVaultNamespace: 'alena.keyvault', - fleKey: 'A'.repeat(128), + kmsProviders: { + local: [ + { + key: 'A'.repeat(128), + }, + ], + }, fleEncryptedFieldsMap: `{ 'alena.coll': { fields: [ diff --git a/packages/compass-e2e-tests/tests/in-use-encryption.test.ts b/packages/compass-e2e-tests/tests/in-use-encryption.test.ts index 5d5a096b8b1..bf5a07bc33a 100644 --- a/packages/compass-e2e-tests/tests/in-use-encryption.test.ts +++ b/packages/compass-e2e-tests/tests/in-use-encryption.test.ts @@ -97,7 +97,13 @@ describe('CSFLE / QE', function () { const options: ConnectFormState = { hosts: [CONNECTION_HOSTS], fleKeyVaultNamespace: `${databaseName}.keyvault`, - fleKey: 'A'.repeat(128), + kmsProviders: { + local: [ + { + key: 'A'.repeat(128), + }, + ], + }, fleEncryptedFieldsMap: `{ '${databaseName}.${collectionName}': { fields: [ @@ -226,7 +232,14 @@ describe('CSFLE / QE', function () { await browser.connectWithConnectionForm({ hosts: [CONNECTION_HOSTS], fleKeyVaultNamespace: `${databaseName}.keyvault`, - fleKey: 'A'.repeat(128), + kmsProviders: { + local: [ + { + name: 'local', + key: 'A'.repeat(128), + }, + ], + }, connectionName, }); @@ -324,7 +337,14 @@ describe('CSFLE / QE', function () { await browser.connectWithConnectionForm({ hosts: [CONNECTION_HOSTS], fleKeyVaultNamespace: `${databaseName}.keyvault`, - fleKey: 'A'.repeat(128), + kmsProviders: { + local: [ + { + name: 'local', + key: 'A'.repeat(128), + }, + ], + }, fleEncryptedFieldsMap: `{ '${databaseName}.${collectionName}': { fields: [ @@ -372,7 +392,7 @@ describe('CSFLE / QE', function () { '"creationDate": ISODate("2022-05-27T18:28:33.925Z"),' + '"updateDate": ISODate("2022-05-27T18:28:33.925Z"),' + '"status": 0,' + - '"masterKey": { "provider" : "local" }' + + '"masterKey": { "provider" : "local:local" }' + '})', // make sure there is a collection so we can navigate to the database `db.getMongo().getDB('${databaseName}').createCollection('default')`, @@ -947,6 +967,157 @@ describe('CSFLE / QE', function () { }); }); }); + + describe('multiple kms providers of the same type', function () { + const databaseName = 'fle-test'; + const collection1 = 'collection-1'; + const collection2 = 'collection-2'; + const phoneNumber1 = '1234567890'; + const phoneNumber2 = '0987654321'; + let compass: Compass; + let browser: CompassBrowser; + let plainMongo: MongoClient; + + before(async function () { + compass = await init(this.test?.fullTitle()); + browser = compass.browser; + }); + + beforeEach(async function () { + await browser.disconnectAll(); + await browser.connectWithConnectionForm({ + hosts: [CONNECTION_HOSTS], + fleKeyVaultNamespace: `${databaseName}.keyvault`, + fleEncryptedFieldsMap: `{ + '${databaseName}.${collection1}': { + fields: [ + { + path: 'phoneNumber', + keyId: UUID("28bbc608-524e-4717-9246-33633361788e"), + bsonType: 'string', + queries: { queryType: 'equality' } + } + ] + }, + '${databaseName}.${collection2}': { + fields: [ + { + path: 'phoneNumber', + keyId: UUID("9c932ef9-f43c-489a-98f3-31012a83bc46"), + bsonType: 'string', + queries: { queryType: 'equality' } + } + ] + }, + }`, + kmsProviders: { + local: [ + { + name: 'localA', + key: 'A'.repeat(128), + }, + { + name: 'localB', + key: 'B'.repeat(128), + }, + ], + }, + connectionName, + }); + await browser.shellEval(connectionName, [ + `use ${databaseName}`, + 'db.keyvault.insertOne({' + + '"_id": UUID("28bbc608-524e-4717-9246-33633361788e"),' + + '"keyMaterial": Binary.createFromBase64("fqZuVyi6ThsSNbgUWtn9MCFDxOQtL3dibMa2P456l+1xJUvAkqzZB2SZBr5Zd2xLDua45IgYAagWFeLhX+hpi0KkdVgdIZu2zlZ+mJSbtwZrFxcuyQ3oPCPnp7l0YH1fSfxeoEIQNVMFpnHzfbu2CgZ/nC8jp6IaB9t+tcszTDdJRLeHnzPuHIKzblFGP8CfuQHJ81B5OA0PrBJr+HbjJg==", 0),' + + '"creationDate": ISODate("2022-05-27T18:28:33.925Z"),' + + '"updateDate": ISODate("2022-05-27T18:28:33.925Z"),' + + '"status": 0,' + + '"masterKey": { "provider" : "local:localA" }' + + '})', + 'db.keyvault.insertOne({' + + '"_id": UUID("9c932ef9-f43c-489a-98f3-31012a83bc46"),' + + '"keyMaterial": Binary.createFromBase64("TymoH++xeTsaiIl498fviLaklY4xTM/baQydmVUABphJzvBsitjWfkoiKlGod/J45Vwoou1VfDRsFaiVHNth7aiFBvEsqvto5ETDFC9hSzP17c1ZrQI1nqrOfI0VGJm+WBALB7IMVFuyd9LV2i6KDIslxBfchOGR4q05Gm1Vgb/cTTUPJpvYLxmduyNSjxqH6lBAJ2ut9TgmUxCC+dMQRQ==", 0),' + + '"creationDate": ISODate("2022-05-27T18:28:34.925Z"),' + + '"updateDate": ISODate("2022-05-27T18:28:34.925Z"),' + + '"status": 0,' + + '"masterKey": { "provider" : "local:localB" }' + + '})', + // make sure there is a collection so we can navigate to the database + `db.getMongo().getDB('${databaseName}').createCollection('default')`, + ]); + await refresh(browser, connectionName); + + plainMongo = await MongoClient.connect(CONNECTION_STRING); + }); + + after(async function () { + if (compass) { + await cleanup(compass); + } + }); + + afterEach(async function () { + if (compass) { + await screenshotIfFailed(compass, this.currentTest); + } + await plainMongo.db(databaseName).dropDatabase(); + await plainMongo.close(); + }); + + it('allows setting multiple kms providers of the same type', async function () { + async function verifyCollectionHasValue( + collection: string, + value: string + ) { + await browser.navigateToCollectionTab( + connectionName, + databaseName, + collection, + 'Documents' + ); + const result = await getFirstListDocument(browser); + expect(result.phoneNumber).to.be.equal(JSON.stringify(value)); + } + + await browser.shellEval(connectionName, [ + `use ${databaseName}`, + `db.createCollection("${collection1}")`, + `db.createCollection("${collection2}")`, + `db["${collection1}"].insertOne({ "phoneNumber": "${phoneNumber1}", "name": "LocalA" })`, + `db["${collection2}"].insertOne({ "phoneNumber": "${phoneNumber2}", "name": "LocalB" })`, + ]); + await refresh(browser, connectionName); + + await verifyCollectionHasValue(collection1, phoneNumber1); + await verifyCollectionHasValue(collection2, phoneNumber2); + + // create a new encrypted collection using keyId for local:localB + await browser.navigateToDatabaseCollectionsTab( + connectionName, + databaseName + ); + const collection3 = 'collection-3'; + const phoneNumber3 = '1111111111'; + await browser.clickVisible(Selectors.DatabaseCreateCollectionButton); + await browser.addCollection(collection3, { + encryptedFields: `{ + fields: [{ + path: 'phoneNumber', + keyId: UUID("9c932ef9-f43c-489a-98f3-31012a83bc46"), + bsonType: 'string', + queries: { queryType: 'equality' } + }] + }`, + }); + + await browser.shellEval(connectionName, [ + `use ${databaseName}`, + `db["${collection3}"].insertOne({ "phoneNumber": "${phoneNumber3}", "name": "LocalB" })`, + ]); + + await verifyCollectionHasValue(collection3, phoneNumber3); + }); + }); }); describe('server version gte 6.0 and lt 7.0', function () { @@ -1070,7 +1241,13 @@ describe('CSFLE / QE', function () { await browser.connectWithConnectionForm({ hosts: [CONNECTION_HOSTS], fleKeyVaultNamespace: `${databaseName}.keyvault`, - fleKey: 'A'.repeat(128), + kmsProviders: { + local: [ + { + key: 'A'.repeat(128), + }, + ], + }, connectionName, }); diff --git a/packages/connection-form/src/components/advanced-options-tabs/csfle-tab/csfle-tab.spec.tsx b/packages/connection-form/src/components/advanced-options-tabs/csfle-tab/csfle-tab.spec.tsx index d9dba077a74..f2b24aa6a56 100644 --- a/packages/connection-form/src/components/advanced-options-tabs/csfle-tab/csfle-tab.spec.tsx +++ b/packages/connection-form/src/components/advanced-options-tabs/csfle-tab/csfle-tab.spec.tsx @@ -8,6 +8,7 @@ import { screen, waitFor, fireEvent, + within, } from '@testing-library/react'; import type { ConnectionOptions } from 'mongodb-data-service'; @@ -15,6 +16,8 @@ import { setCodemirrorEditorValue } from '@mongodb-js/compass-editor'; import { Binary } from 'bson'; import ConnectionForm from '../../../'; +import userEvent from '@testing-library/user-event'; +import { getNextKmsProviderName } from './kms-provider-content'; const openAdvancedTab = async ( tabId: 'general' | 'authentication' | 'tls' | 'proxy' | 'advanced' | 'csfle' @@ -359,4 +362,201 @@ describe('In-Use Encryption', function () { }, }); }); + + context('supports multiple kms providers from same type', function () { + function renameKMSProvider(name: string, value: string) { + const card = screen.getByTestId(`${name}-kms-card-item`); + + const editButton = within(card).queryByRole('button', { + name: /edit kms provider name/i, + }); + + if (editButton) { + userEvent.click( + within(card).getByRole('button', { + name: /edit kms provider name/i, + }) + ); + } + + const selector = within(card).getByTestId('csfle-kms-card-name'); + userEvent.clear(selector); + userEvent.type(selector, value); + userEvent.keyboard('{enter}'); + } + + it('allows to have multiple KMS providers from same type', async function () { + fireEvent.click(screen.getByText('Local KMS')); + fireEvent.click(screen.getByText('Add item')); + + const kmsProviders: Record = {}; + + for (const kmsProviderName of ['local', 'local:1'] as const) { + const kmsCard = screen.getByTestId(`${kmsProviderName}-kms-card-item`); + + expect( + within(kmsCard).getByTestId('csfle-kms-local-key').closest('input') + ?.value + ).to.equal(''); + + fireEvent.click( + within(kmsCard).getByTestId('generate-local-key-button') + ); + + const generatedLocalKey = within(kmsCard) + .getByTestId('csfle-kms-local-key') + .closest('input')?.value; + + if (!generatedLocalKey) { + throw new Error('expected generatedLocalKey'); + } + + expect(generatedLocalKey).to.match(/^[a-zA-Z0-9+/-_=]{128}$/); + + kmsProviders[kmsProviderName] = { + key: generatedLocalKey, + }; + } + + await expectToConnectWith({ + connectionString: 'mongodb://localhost:27017', + fleOptions: { + storeCredentials: false, + autoEncryption: { + keyVaultNamespace: 'db.coll', + kmsProviders, + }, + }, + }); + }); + + it('allows rename of KMS provider', async function () { + fireEvent.click(screen.getByText('Local KMS')); + fireEvent.click(screen.getByText('Add item')); + + renameKMSProvider('local', 'new_name_1'); + renameKMSProvider('local:1', 'new_name_2'); + + const kmsProviders: Record = {}; + + for (const kmsProviderName of [ + 'local:new_name_1', + 'local:new_name_2', + ] as const) { + const kmsCard = screen.getByTestId(`${kmsProviderName}-kms-card-item`); + + expect( + within(kmsCard).getByTestId('csfle-kms-local-key').closest('input') + ?.value + ).to.equal(''); + + fireEvent.click( + within(kmsCard).getByTestId('generate-local-key-button') + ); + + const generatedLocalKey = within(kmsCard) + .getByTestId('csfle-kms-local-key') + .closest('input')?.value; + + if (!generatedLocalKey) { + throw new Error('expected generatedLocalKey'); + } + + expect(generatedLocalKey).to.match(/^[a-zA-Z0-9+/-_=]{128}$/); + + kmsProviders[kmsProviderName] = { + key: generatedLocalKey, + }; + } + + await expectToConnectWith({ + connectionString: 'mongodb://localhost:27017', + fleOptions: { + storeCredentials: false, + autoEncryption: { + keyVaultNamespace: 'db.coll', + kmsProviders, + }, + }, + }); + }); + + it('shows name validation errors', function () { + fireEvent.click(screen.getByText('Local KMS')); + fireEvent.click(screen.getByText('Add item')); + + // By default the first name is local + + // Check validation errors for the second name + renameKMSProvider('local:1', ''); + expect(screen.getByText('Name cannot be empty')).to.exist; + + renameKMSProvider('local:1', 'local 1'); + expect( + screen.getByText( + 'Name must be alphanumeric and may contain underscores' + ) + ).to.exist; + + renameKMSProvider('local', 'name1'); + renameKMSProvider('local:1', 'name1'); + expect(screen.getByText('Name already exists')).to.exist; + }); + + it('allows user to remove a kms provider', function () { + fireEvent.click(screen.getByText('Local KMS')); + + const card1 = screen.getByTestId('local-kms-card-item'); + userEvent.hover(card1); + // When its only one card, we do not show the delete button + expect(() => + within(card1).getByRole('button', { + name: /Remove KMS provider/i, + }) + ).to.throw; + + fireEvent.click(screen.getByText('Add item')); + + expect(within(card1).findByTestId('kms-card-header')).to.exist; + expect( + within(screen.getByTestId('local:1-kms-card-item')).findByTestId( + 'kms-card-header' + ) + ).to.exist; + + // we show remove button on hover + userEvent.hover(card1); + fireEvent.click( + within(card1).getByRole('button', { + name: /Remove KMS provider/i, + }) + ); + + expect(() => card1).to.throw; + }); + }); + + it('getNextKmsProviderName', function () { + const usecases = [ + { + providerNames: [], + expected: 'local', + }, + { + providerNames: ['local'], + expected: 'local:1', + }, + { + providerNames: ['local:9'], + expected: 'local:10', + }, + { + providerNames: ['local:what', 'local:this'], + expected: 'local:1', + }, + ]; + for (const { providerNames, expected } of usecases) { + expect(getNextKmsProviderName('local', providerNames)).to.equal(expected); + } + }); }); diff --git a/packages/connection-form/src/components/advanced-options-tabs/csfle-tab/csfle-tab.tsx b/packages/connection-form/src/components/advanced-options-tabs/csfle-tab/csfle-tab.tsx index 71736127ad0..81372d65138 100644 --- a/packages/connection-form/src/components/advanced-options-tabs/csfle-tab/csfle-tab.tsx +++ b/packages/connection-form/src/components/advanced-options-tabs/csfle-tab/csfle-tab.tsx @@ -1,5 +1,5 @@ import type { ChangeEvent } from 'react'; -import React, { useCallback } from 'react'; +import React, { useCallback, useMemo } from 'react'; import type { ConnectionOptions } from 'mongodb-data-service'; import { Accordion, @@ -19,7 +19,9 @@ import type { Document, AutoEncryptionOptions } from 'mongodb'; import type { UpdateConnectionFormField } from '../../../hooks/use-connect-form'; import KMSProviderStatusIndicator from './kms-provider-status-indicator'; -import KMSProviderFieldsForm from './kms-provider-fields'; +import KMSProviderContent, { + getNextKmsProviderName, +} from './kms-provider-content'; import EncryptedFieldConfigInput from './encrypted-field-config-input'; import type { ConnectionFormError } from '../../../utils/validation'; import { @@ -28,8 +30,9 @@ import { fieldNameHasError, } from '../../../utils/validation'; import type { - KMSProviderName, + KMSProviderType, KMSField, + KMSProviderName, } from '../../../utils/csfle-kms-fields'; import { KMSProviderFields } from '../../../utils/csfle-kms-fields'; import { useConnectionFormPreference } from '../../../hooks/use-connect-form-preferences'; @@ -40,7 +43,7 @@ const kmsProviderComponentWrapperStyles = css({ }); interface KMSProviderMetadata { - kmsProvider: KMSProviderName; + kmsProviderType: KMSProviderType; title: string; noTLS?: boolean; clientCertIsOptional?: boolean; @@ -49,24 +52,24 @@ interface KMSProviderMetadata { const options: KMSProviderMetadata[] = [ { title: 'Local KMS', - kmsProvider: 'local', + kmsProviderType: 'local', noTLS: true, }, { title: 'AWS', - kmsProvider: 'aws', + kmsProviderType: 'aws', }, { title: 'GCP', - kmsProvider: 'gcp', + kmsProviderType: 'gcp', }, { title: 'Azure', - kmsProvider: 'azure', + kmsProviderType: 'azure', }, { title: 'KMIP', - kmsProvider: 'kmip', + kmsProviderType: 'kmip', clientCertIsOptional: false, }, ]; @@ -78,6 +81,7 @@ const accordionContainerStyles = css({ const titleStyles = css({ display: 'flex', alignItems: 'center', + gap: spacing[50], }); function CSFLETab({ @@ -105,7 +109,7 @@ function CSFLETab({ ) => { return updateConnectionFormField({ type: 'update-csfle-param', - key: key, + key, value, }); }, @@ -122,6 +126,39 @@ function CSFLETab({ [updateConnectionFormField] ); + const onOpenAccordion = useCallback( + (kmsProviderType: KMSProviderType, isOpen: boolean) => { + const hasExistingKmsType = Object.keys( + connectionOptions.fleOptions?.autoEncryption?.kmsProviders ?? {} + ).some((kmsProvider) => kmsProvider.startsWith(kmsProviderType)); + // When we are expanding an accordion the first time, we should add a new empty KMS provider + // in the connection form state if there is none. + if (isOpen && !hasExistingKmsType) { + return updateConnectionFormField({ + type: 'add-new-csfle-kms-provider', + name: getNextKmsProviderName(kmsProviderType), + }); + } + }, + [ + updateConnectionFormField, + connectionOptions.fleOptions?.autoEncryption?.kmsProviders, + ] + ); + + const kmsProviders = useMemo(() => { + return Object.keys( + connectionOptions.fleOptions?.autoEncryption?.kmsProviders ?? {} + ).reduce((acc, kmsProvider) => { + const type = kmsProvider.split(':')[0] as KMSProviderType; + if (!acc[type]) { + acc[type] = []; + } + acc[type]!.push(kmsProvider as KMSProviderName); + return acc; + }, {} as Partial[]>>); + }, [connectionOptions.fleOptions?.autoEncryption?.kmsProviders]); + return ( <> @@ -182,43 +219,53 @@ function CSFLETab({ /> - {options.map(({ title, kmsProvider, ...kmsFieldComponentOptions }) => { - const accordionTitle = ( - - {title} - [] - } - /> - - ); - return ( -
- -
- [] - } - {...kmsFieldComponentOptions} - /> -
-
-
- ); - })} + {options.map( + ({ title, kmsProviderType, ...kmsFieldComponentOptions }) => { + const accordionTitle = ( + + {title} + {(kmsProviders[kmsProviderType]?.length ?? 0) > 1 && ( + ({kmsProviders[kmsProviderType]?.length}) + )} + [] + } + /> + + ); + return ( +
+ onOpenAccordion(kmsProviderType, open)} + data-testid={`csfle-kms-provider-${kmsProviderType}`} + text={accordionTitle} + > +
+ [] + } + kmsProviderNames={kmsProviders[kmsProviderType] ?? []} + {...kmsFieldComponentOptions} + /> +
+
+
+ ); + } + )}
; handleFieldChanged: (key: 'key', value?: string) => void; connectionOptions: ConnectionOptions; }): React.ReactElement { - const autoEncryptionOptions = - connectionOptions.fleOptions?.autoEncryption ?? {}; + const kmsConfig = useMemo(() => { + const autoEncryptionOptions = + connectionOptions.fleOptions?.autoEncryption ?? {}; + return autoEncryptionOptions.kmsProviders?.[ + kmsProviderName as keyof typeof autoEncryptionOptions.kmsProviders + ] as LocalKMSProviderConfiguration | undefined; + }, [connectionOptions.fleOptions?.autoEncryption, kmsProviderName]); const [generatedKeyMaterial, setGeneratedKeyMaterial] = useState(''); @@ -39,15 +50,12 @@ function KMSLocalKeyGenerator({ - {generatedKeyMaterial === - autoEncryptionOptions.kmsProviders?.local?.key && ( + {generatedKeyMaterial === kmsConfig?.key && ( <>
diff --git a/packages/connection-form/src/components/advanced-options-tabs/csfle-tab/kms-provider-card.tsx b/packages/connection-form/src/components/advanced-options-tabs/csfle-tab/kms-provider-card.tsx new file mode 100644 index 00000000000..9df300c58cc --- /dev/null +++ b/packages/connection-form/src/components/advanced-options-tabs/csfle-tab/kms-provider-card.tsx @@ -0,0 +1,244 @@ +import React, { useCallback, useState, type ChangeEvent } from 'react'; +import { + Card, + css, + Icon, + IconButton, + spacing, + TextInput, + useHoverState, + Body, + Label, + cx, +} from '@mongodb-js/compass-components'; + +import type { UpdateConnectionFormField } from '../../../hooks/use-connect-form'; +import type { + KMSField, + KMSProviderType, + KMSProviderName, +} from '../../../utils/csfle-kms-fields'; +import type { ConnectionFormError } from '../../../utils/validation'; +import type { ConnectionOptions } from 'mongodb-data-service'; +import KMSProviderFieldsForm from './kms-provider-fields'; + +const cardStyles = css({ + marginTop: spacing[200], + marginBottom: spacing[200], +}); + +const flexContainerStyles = css({ + display: 'flex', + alignItems: 'center', + gap: spacing[100], +}); + +// With these margins, when clicking edit button, there is no flickering. +const editKmsContainerStyles = css({ + marginTop: spacing[100], + marginBottom: spacing[100], +}); + +const pushRightStyles = css({ + marginLeft: 'auto', +}); + +function KMSNameComponent({ + kmsProviderName, + kmsProviderType, + validateName, + onRename, +}: { + kmsProviderName: KMSProviderName; + kmsProviderType: T; + validateName: (name: string) => string | undefined; + onRename: (newName: KMSProviderName) => void; +}) { + const [isEditing, setIsEditing] = useState(false); + const [validationError, setValidationError] = useState(); + const [name, setName] = useState(() => { + return kmsProviderName.replace(new RegExp(`^${kmsProviderType}:?`), ''); + }); + + const onEdit = useCallback(() => { + setIsEditing(true); + }, []); + + const onChangeName = useCallback( + (newName: string) => { + setName(newName); + setValidationError(validateName(newName)); + }, + [setValidationError, validateName] + ); + + const onSave = useCallback(() => { + if (validationError) { + return; + } + setIsEditing(false); + onRename(name === '' ? kmsProviderType : `${kmsProviderType}:${name}`); + }, [kmsProviderType, name, onRename, validationError]); + + if (!isEditing) { + return ( +
+ +
+ {kmsProviderName} + + + +
+
+ ); + } + + return ( +
+ +
+ {kmsProviderType}: + ) => { + onChangeName(value); + }} + id={kmsProviderName} + onBlur={onSave} + data-testid="csfle-kms-card-name" + aria-label={'KMS Name'} + type={'text'} + state={validationError ? 'error' : 'none'} + errorMessage={validationError} + value={name} + onKeyPress={(e) => { + if (e.key === 'Enter') { + e.preventDefault(); + onSave(); + } + }} + /> +
+
+ ); +} + +type KMSProviderCardProps = { + updateConnectionFormField: UpdateConnectionFormField; + connectionOptions: ConnectionOptions; + errors: ConnectionFormError[]; + kmsProviderType: T; + kmsProviderNames: KMSProviderName[]; + fields: KMSField[]; + clientCertIsOptional?: boolean; + noTLS?: boolean; + kmsProviderName: KMSProviderName; + index: number; +}; + +function KMSProviderCard({ + kmsProviderName, + kmsProviderNames, + updateConnectionFormField, + connectionOptions, + errors, + kmsProviderType, + fields, + clientCertIsOptional, + noTLS, + index, +}: KMSProviderCardProps) { + const [hoverProps, isHovered] = useHoverState(); + const onRenameKmsProvider = useCallback( + (newName: KMSProviderName) => { + return updateConnectionFormField({ + type: 'rename-csfle-kms-provider', + name: kmsProviderName, + newName, + }); + }, + [kmsProviderName, updateConnectionFormField] + ); + const onRemoveKmsProvider = useCallback(() => { + return updateConnectionFormField({ + type: 'remove-csfle-kms-provider', + name: kmsProviderName, + }); + }, [updateConnectionFormField, kmsProviderName]); + + const onValidateName = useCallback( + (name: string) => { + // Exclude the current KMS provider name from the list. + const withoutCurrentKMSProviderNames = kmsProviderNames.filter( + (n) => n !== kmsProviderName + ); + const maybeProviderName = + name === '' ? kmsProviderType : `${kmsProviderType}:${name}`; + // `kmsProviderType` can only exist once, so empty name is allowed only for that case. + if ( + name === '' && + withoutCurrentKMSProviderNames.some((n) => n === maybeProviderName) + ) { + return 'Name cannot be empty'; + } + if ( + withoutCurrentKMSProviderNames.includes( + maybeProviderName as KMSProviderName + ) + ) { + return 'Name already exists'; + } + const regex = new RegExp(`^${kmsProviderType}(:[a-zA-Z0-9_]+)?$`); + if (!maybeProviderName.match(regex)) { + return 'Name must be alphanumeric and may contain underscores'; + } + return undefined; + }, + [kmsProviderNames, kmsProviderName, kmsProviderType] + ); + + return ( + +
+ + {kmsProviderNames.length > 1 && isHovered && ( + + + + )} +
+ +
+ ); +} + +export default KMSProviderCard; diff --git a/packages/connection-form/src/components/advanced-options-tabs/csfle-tab/kms-provider-content.tsx b/packages/connection-form/src/components/advanced-options-tabs/csfle-tab/kms-provider-content.tsx new file mode 100644 index 00000000000..16c90369e05 --- /dev/null +++ b/packages/connection-form/src/components/advanced-options-tabs/csfle-tab/kms-provider-content.tsx @@ -0,0 +1,98 @@ +import React, { useCallback } from 'react'; +import { Button, css, Icon } from '@mongodb-js/compass-components'; +import type { UpdateConnectionFormField } from '../../../hooks/use-connect-form'; +import type { + KMSField, + KMSProviderType, + KMSProviderName, +} from '../../../utils/csfle-kms-fields'; +import type { ConnectionFormError } from '../../../utils/validation'; +import type { ConnectionOptions } from 'mongodb-data-service'; +import KMSProviderCard from './kms-provider-card'; + +const flexContainerStyles = css({ + display: 'flex', + alignItems: 'center', +}); + +const pushRightStyles = css({ + marginLeft: 'auto', +}); + +export function getNextKmsProviderName( + kmsProviderType: T, + currentProviders: string[] = [] +): KMSProviderName { + if (currentProviders.length === 0) { + return kmsProviderType; + } + const currentNums = currentProviders // local:1 + .map((name) => name.replace(new RegExp(`^${kmsProviderType}:?`), '')) // '1' + .map((x) => parseInt(x, 10)) // 1 + .filter((x) => !isNaN(x)); + const nextNum = Math.max(0, ...currentNums) + 1; + return `${kmsProviderType}:${nextNum}`; // local:2 +} + +type KMSProviderContentProps = { + updateConnectionFormField: UpdateConnectionFormField; + connectionOptions: ConnectionOptions; + errors: ConnectionFormError[]; + kmsProviderType: T; + kmsProviderNames: KMSProviderName[]; + fields: KMSField[]; + clientCertIsOptional?: boolean; + noTLS?: boolean; +}; + +function KMSProviderContent({ + updateConnectionFormField, + connectionOptions, + kmsProviderType, + kmsProviderNames, + ...restOfTheProps +}: KMSProviderContentProps): React.ReactElement { + const onAddKmsProvider = useCallback( + (name: KMSProviderName) => { + return updateConnectionFormField({ + type: 'add-new-csfle-kms-provider', + name, + }); + }, + [updateConnectionFormField] + ); + + return ( + <> + {kmsProviderNames.map((kmsProviderName, index) => ( + + ))} +
+ +
+ + ); +} + +export default KMSProviderContent; diff --git a/packages/connection-form/src/components/advanced-options-tabs/csfle-tab/kms-provider-fields.tsx b/packages/connection-form/src/components/advanced-options-tabs/csfle-tab/kms-provider-fields.tsx index 542ce1de2fd..ff706e1ce21 100644 --- a/packages/connection-form/src/components/advanced-options-tabs/csfle-tab/kms-provider-fields.tsx +++ b/packages/connection-form/src/components/advanced-options-tabs/csfle-tab/kms-provider-fields.tsx @@ -10,18 +10,21 @@ import KMSTLSOptions from './kms-tls-options'; import KMSLocalKeyGenerator from './kms-local-key-generator'; import type { UpdateConnectionFormField } from '../../../hooks/use-connect-form'; import type { - KMSProviders, + KMSProviderType, + KMSProviderName, KMSOption, KMSField, + KMSTLSProviderName, } from '../../../utils/csfle-kms-fields'; import type { ConnectionFormError } from '../../../utils/validation'; import type { ConnectionOptions } from 'mongodb-data-service'; -function KMSProviderFieldsForm({ +function KMSProviderFieldsForm({ updateConnectionFormField, connectionOptions, errors, - kmsProvider, + kmsProviderType, + kmsProviderName, fields, clientCertIsOptional, noTLS, @@ -29,8 +32,9 @@ function KMSProviderFieldsForm({ updateConnectionFormField: UpdateConnectionFormField; connectionOptions: ConnectionOptions; errors: ConnectionFormError[]; - kmsProvider: KMSProvider; - fields: KMSField[]; + kmsProviderType: T; + kmsProviderName: KMSProviderName; + fields: KMSField[]; clientCertIsOptional?: boolean; noTLS?: boolean; }): React.ReactElement { @@ -38,15 +42,15 @@ function KMSProviderFieldsForm({ connectionOptions.fleOptions?.autoEncryption ?? {}; const handleFieldChanged = useCallback( - (key: KMSOption, value?: string) => { + (key: KMSOption, value?: string) => { return updateConnectionFormField({ type: 'update-csfle-kms-param', - kmsProvider, + kmsProviderName, key, value, }); }, - [updateConnectionFormField, kmsProvider] + [updateConnectionFormField, kmsProviderName] ); return ( @@ -74,12 +78,12 @@ function KMSProviderFieldsForm({ handleFieldChanged(name, value); }} name={name} - data-testid={`csfle-kms-${kmsProvider}-${name}`} + data-testid={`csfle-kms-${kmsProviderType}-${name}`} label={label} type={type === 'textarea' ? undefined : type} optional={type === 'textarea' ? undefined : optional} - value={value(autoEncryptionOptions)} - errorMessage={errorMessage?.(errors)} + value={value(autoEncryptionOptions, kmsProviderName)} + errorMessage={errorMessage?.(errors, kmsProviderName)} state={typeof state === 'string' ? state : state(errors)} spellCheck={false} description={description} @@ -90,14 +94,15 @@ function KMSProviderFieldsForm({ )} {!noTLS && ( } autoEncryptionOptions={autoEncryptionOptions} updateConnectionFormField={updateConnectionFormField} clientCertIsOptional={clientCertIsOptional} /> )} - {kmsProvider === 'local' && ( + {kmsProviderType === 'local' && ( } connectionOptions={connectionOptions} handleFieldChanged={ handleFieldChanged as ( diff --git a/packages/connection-form/src/components/advanced-options-tabs/csfle-tab/kms-provider-status-indicator.tsx b/packages/connection-form/src/components/advanced-options-tabs/csfle-tab/kms-provider-status-indicator.tsx index 43376f0d31e..8508be777dd 100644 --- a/packages/connection-form/src/components/advanced-options-tabs/csfle-tab/kms-provider-status-indicator.tsx +++ b/packages/connection-form/src/components/advanced-options-tabs/csfle-tab/kms-provider-status-indicator.tsx @@ -1,7 +1,11 @@ import React from 'react'; import type { AutoEncryptionOptions } from 'mongodb'; -import type { KMSProviders, KMSField } from '../../../utils/csfle-kms-fields'; +import type { + KMSField, + KMSProviderName, + KMSProviderType, +} from '../../../utils/csfle-kms-fields'; import type { ConnectionFormError } from '../../../utils/validation'; import { css, Icon, spacing, palette } from '@mongodb-js/compass-components'; @@ -10,24 +14,31 @@ const iconStyles = css({ display: 'block', }); -function KMSProviderStatusIndicator({ +function KMSProviderStatusIndicator({ autoEncryptionOptions, errors, + kmsProviders, fields, }: { autoEncryptionOptions: AutoEncryptionOptions; errors: ConnectionFormError[]; - fields: KMSField[]; + fields: KMSField[]; + kmsProviders: KMSProviderName[]; }): React.ReactElement { - const hasAnyFieldSet = fields.some(({ value }) => - value(autoEncryptionOptions) + const hasAnyFieldSet = kmsProviders.some((kmsProviderName) => + fields.some(({ value }) => value(autoEncryptionOptions, kmsProviderName)) ); - const isMissingRequiredField = fields.some( - ({ value, optional }) => !optional && !value(autoEncryptionOptions) + const isMissingRequiredField = kmsProviders.some((kmsProviderName) => + fields.some( + ({ value, optional }) => + !optional && !value(autoEncryptionOptions, kmsProviderName) + ) ); - const hasFieldWithError = fields.some( - ({ state }) => - (typeof state === 'string' ? state : state(errors)) === 'error' + const hasFieldWithError = kmsProviders.some(() => + fields.some( + ({ state }) => + (typeof state === 'string' ? state : state(errors)) === 'error' + ) ); if (hasFieldWithError) { diff --git a/packages/connection-form/src/components/advanced-options-tabs/csfle-tab/kms-tls-options.tsx b/packages/connection-form/src/components/advanced-options-tabs/csfle-tab/kms-tls-options.tsx index 858993ee284..5a289814cc4 100644 --- a/packages/connection-form/src/components/advanced-options-tabs/csfle-tab/kms-tls-options.tsx +++ b/packages/connection-form/src/components/advanced-options-tabs/csfle-tab/kms-tls-options.tsx @@ -6,32 +6,36 @@ import type { import TLSCertificateAuthority from '../tls-ssl-tab/tls-certificate-authority'; import TLSClientCertificate from '../tls-ssl-tab/tls-client-certificate'; +import type { + KMSTLSProviderName, + KMSTLSProviderType, +} from '../../../utils/csfle-kms-fields'; import type { UpdateConnectionFormField } from '../../../hooks/use-connect-form'; -function KMSTLSOptions({ +function KMSTLSOptions({ updateConnectionFormField, autoEncryptionOptions, - kmsProvider, + kmsProviderName, clientCertIsOptional, }: { updateConnectionFormField: UpdateConnectionFormField; autoEncryptionOptions: AutoEncryptionOptions; - kmsProvider: keyof NonNullable; + kmsProviderName: KMSTLSProviderName; clientCertIsOptional?: boolean; }): React.ReactElement { const currentOptions: ClientEncryptionTlsOptions = - autoEncryptionOptions.tlsOptions?.[kmsProvider] ?? {}; + autoEncryptionOptions.tlsOptions?.[kmsProviderName] ?? {}; const handleFieldChanged = useCallback( (key: keyof ClientEncryptionTlsOptions, value?: string) => { return updateConnectionFormField({ type: 'update-csfle-kms-tls-param', - kmsProvider, + kmsProviderName, key, value, }); }, - [updateConnectionFormField, kmsProvider] + [updateConnectionFormField, kmsProviderName] ); return ( diff --git a/packages/connection-form/src/components/connection-form.spec.tsx b/packages/connection-form/src/components/connection-form.spec.tsx index 24d0feb9bd4..02235e9ded1 100644 --- a/packages/connection-form/src/components/connection-form.spec.tsx +++ b/packages/connection-form/src/components/connection-form.spec.tsx @@ -406,7 +406,6 @@ describe('ConnectionForm Component', function () { }); it('should show a Cancel button', function () { - screen.debug(screen.getByTestId('cancel-button')); const button = screen.queryByRole('button', { name: 'Cancel' }); expect(button).to.be.visible; diff --git a/packages/connection-form/src/hooks/use-connect-form.ts b/packages/connection-form/src/hooks/use-connect-form.ts index 678b36b6f20..6df2cad09d5 100644 --- a/packages/connection-form/src/hooks/use-connect-form.ts +++ b/packages/connection-form/src/hooks/use-connect-form.ts @@ -46,12 +46,19 @@ import { handleUpdateCsfleKmsParam, handleUpdateCsfleKmsTlsParam, adjustCSFLEParams, + handleAddKmsProvider, + handleRemoveKmsProvider, + unsetFleOptionsIfEmptyAutoEncryption, + handleRenameKmsProvider, } from '../utils/csfle-handler'; import type { UpdateCsfleStoreCredentialsAction, UpdateCsfleAction, UpdateCsfleKmsAction, UpdateCsfleKmsTlsAction, + AddCsfleProviderAction, + RemoveCsfleProviderAction, + RenameCsfleProviderAction, } from '../utils/csfle-handler'; import { handleUpdateOIDCParam, @@ -190,6 +197,9 @@ type ConnectionFormFieldActions = | { type: 'remove-app-proxy'; } + | AddCsfleProviderAction + | RenameCsfleProviderAction + | RemoveCsfleProviderAction | UpdateCsfleStoreCredentialsAction | UpdateCsfleAction | UpdateCsfleKmsAction @@ -677,6 +687,24 @@ export function handleConnectionFormFieldUpdate( connectionOptions: currentConnectionOptions, }); } + case 'add-new-csfle-kms-provider': { + return handleAddKmsProvider({ + action, + connectionOptions: currentConnectionOptions, + }); + } + case 'rename-csfle-kms-provider': { + return handleRenameKmsProvider({ + action, + connectionOptions: currentConnectionOptions, + }); + } + case 'remove-csfle-kms-provider': { + return handleRemoveKmsProvider({ + action, + connectionOptions: currentConnectionOptions, + }); + } } } @@ -835,6 +863,7 @@ export function adjustConnectionOptionsBeforeConnect({ connectionOptions: Readonly ) => ConnectionOptions)[] = [ adjustCSFLEParams, + unsetFleOptionsIfEmptyAutoEncryption, setAppNameParamIfMissing(defaultAppName), adjustOIDCConnectionOptionsBeforeConnect({ browserCommandForOIDCAuth: preferences.browserCommandForOIDCAuth, diff --git a/packages/connection-form/src/utils/csfle-handler.spec.ts b/packages/connection-form/src/utils/csfle-handler.spec.ts index 79772cbdcb7..2085fdeb1ef 100644 --- a/packages/connection-form/src/utils/csfle-handler.spec.ts +++ b/packages/connection-form/src/utils/csfle-handler.spec.ts @@ -12,6 +12,10 @@ import { encryptedFieldConfigToText, adjustCSFLEParams, randomLocalKey, + unsetFleOptionsIfEmptyAutoEncryption, + handleAddKmsProvider, + handleRenameKmsProvider, + handleRemoveKmsProvider, } from './csfle-handler'; describe('csfle-handler', function () { @@ -85,7 +89,7 @@ describe('csfle-handler', function () { }).connectionOptions.fleOptions ).to.deep.equal({ storeCredentials: false, - autoEncryption: undefined, + autoEncryption: {}, }); }); }); @@ -95,7 +99,7 @@ describe('csfle-handler', function () { const withParameterSet = handleUpdateCsfleKmsParam({ action: { type: 'update-csfle-kms-param', - kmsProvider: 'aws', + kmsProviderName: 'aws', key: 'accessKeyId', value: '123456', }, @@ -117,14 +121,16 @@ describe('csfle-handler', function () { handleUpdateCsfleKmsParam({ action: { type: 'update-csfle-kms-param', - kmsProvider: 'aws', + kmsProviderName: 'aws', key: 'accessKeyId', }, connectionOptions: withParameterSet, }).connectionOptions.fleOptions ).to.deep.equal({ storeCredentials: false, - autoEncryption: undefined, + autoEncryption: { + kmsProviders: {}, + }, }); }); }); @@ -134,7 +140,7 @@ describe('csfle-handler', function () { const withParameterSet = handleUpdateCsfleKmsTlsParam({ action: { type: 'update-csfle-kms-tls-param', - kmsProvider: 'aws', + kmsProviderName: 'aws', key: 'tlsCertificateKeyFilePassword', value: '123456', }, @@ -156,14 +162,16 @@ describe('csfle-handler', function () { handleUpdateCsfleKmsTlsParam({ action: { type: 'update-csfle-kms-tls-param', - kmsProvider: 'aws', + kmsProviderName: 'aws', key: 'tlsCertificateKeyFilePassword', }, connectionOptions: withParameterSet, }).connectionOptions.fleOptions ).to.deep.equal({ storeCredentials: false, - autoEncryption: undefined, + autoEncryption: { + tlsOptions: {}, + }, }); }); }); @@ -206,6 +214,193 @@ describe('csfle-handler', function () { }); }); + describe('#handleAddKmsProvider', function () { + it('can add a kms provider', function () { + let withParameterSet = handleAddKmsProvider({ + action: { + type: 'add-new-csfle-kms-provider', + name: 'local', + }, + connectionOptions, + }).connectionOptions; + + expect(withParameterSet.fleOptions).to.deep.equal({ + storeCredentials: false, + autoEncryption: { + kmsProviders: { + local: {}, + }, + }, + }); + + withParameterSet = handleAddKmsProvider({ + action: { + type: 'add-new-csfle-kms-provider', + name: 'aws', + }, + connectionOptions: withParameterSet, + }).connectionOptions; + + expect(withParameterSet.fleOptions).to.deep.equal({ + storeCredentials: false, + autoEncryption: { + kmsProviders: { + local: {}, + aws: {}, + }, + }, + }); + }); + }); + + describe('#handleRenameCsfleParam', function () { + it('can rename a kms provider name', function () { + connectionOptions.fleOptions = { + storeCredentials: false, + autoEncryption: { + kmsProviders: { + local: { + key: 'asdf', + }, + }, + tlsOptions: { + local: { + tlsCertificateKeyFilePassword: 'asdf', + }, + }, + }, + }; + const withParameterSet = handleRenameKmsProvider({ + action: { + type: 'rename-csfle-kms-provider', + name: 'local', + newName: 'local:1', + }, + connectionOptions, + }).connectionOptions; + + expect(withParameterSet.fleOptions).to.deep.equal({ + storeCredentials: false, + autoEncryption: { + kmsProviders: { + 'local:1': { + key: 'asdf', + }, + }, + tlsOptions: { + 'local:1': { + tlsCertificateKeyFilePassword: 'asdf', + }, + }, + }, + }); + }); + + it('renames kms name and does not change the position of the key', function () { + connectionOptions.fleOptions = { + storeCredentials: false, + autoEncryption: { + kmsProviders: { + // @ts-expect-error multiple kms providers are supported in next driver release + 'local:2': { + key: 'asdf', + }, + local: { + key: 'asdf', + }, + 'aws:1': { + secretAccessKey: 'asdf', + accessKeyId: 'asdf', + }, + }, + tlsOptions: { + 'local:2': { + tlsCertificateKeyFilePassword: 'asdf', + }, + local: { + tlsCertificateKeyFilePassword: 'asdf', + }, + 'aws:1': { + tlsCertificateKeyFilePassword: 'asdf', + }, + }, + }, + }; + const withParameterSet = handleRenameKmsProvider({ + action: { + type: 'rename-csfle-kms-provider', + name: 'local', + newName: 'local:3', + }, + connectionOptions, + }).connectionOptions; + + expect(withParameterSet.fleOptions).to.deep.equal({ + storeCredentials: false, + autoEncryption: { + kmsProviders: { + 'local:2': { + key: 'asdf', + }, + 'local:3': { + key: 'asdf', + }, + 'aws:1': { + secretAccessKey: 'asdf', + accessKeyId: 'asdf', + }, + }, + tlsOptions: { + 'local:2': { + tlsCertificateKeyFilePassword: 'asdf', + }, + 'local:3': { + tlsCertificateKeyFilePassword: 'asdf', + }, + 'aws:1': { + tlsCertificateKeyFilePassword: 'asdf', + }, + }, + }, + }); + }); + }); + + describe('#handleRemoveKmsProvider', function () { + it('can remove a kms provider', function () { + connectionOptions.fleOptions = { + storeCredentials: false, + autoEncryption: { + kmsProviders: { + local: { + key: 'asdf', + }, + }, + tlsOptions: { + local: { + tlsCertificateKeyFilePassword: 'asdf', + }, + }, + }, + }; + const withParameterSet = handleRemoveKmsProvider({ + action: { + type: 'remove-csfle-kms-provider', + name: 'local', + }, + connectionOptions, + }).connectionOptions; + + expect(withParameterSet.fleOptions).to.deep.equal({ + storeCredentials: false, + autoEncryption: { + kmsProviders: {}, + tlsOptions: {}, + }, + }); + }); + }); + describe('#randomLocalKey', function () { it('returns random 96-byte base64-encoded strings', function () { expect(randomLocalKey()).to.match(/^[A-Za-z0-9+/]{128}$/); @@ -354,4 +549,52 @@ describe('csfle-handler', function () { }); }); }); + + describe('unsetFleOptionsIfEmptyAutoEncryption', function () { + it('unsets fleOptions if options are empty', function () { + (connectionOptions.fleOptions as any).autoEncryption = { + kmsProviders: { + aws: {}, + 'aws:1': {}, + }, + tlsOptions: { + local: {}, + }, + }; + expect( + unsetFleOptionsIfEmptyAutoEncryption(connectionOptions) + ).to.deep.equal({ + connectionString: 'mongodb://localhost/', + fleOptions: undefined, + }); + }); + it('does not unset fleOptions if options are not empty', function () { + (connectionOptions.fleOptions as any).autoEncryption = { + kmsProviders: { + aws: { + accessKeyId: 'asdf', + }, + 'aws:1': {}, + }, + tlsOptions: { + local: {}, + }, + }; + expect( + unsetFleOptionsIfEmptyAutoEncryption(connectionOptions) + ).to.deep.equal({ + connectionString: 'mongodb://localhost/', + fleOptions: { + autoEncryption: { + kmsProviders: { + aws: { + accessKeyId: 'asdf', + }, + }, + }, + storeCredentials: false, + }, + }); + }); + }); }); diff --git a/packages/connection-form/src/utils/csfle-handler.ts b/packages/connection-form/src/utils/csfle-handler.ts index 2bc33c769fa..ae4f5e37298 100644 --- a/packages/connection-form/src/utils/csfle-handler.ts +++ b/packages/connection-form/src/utils/csfle-handler.ts @@ -6,7 +6,12 @@ import type { ClientEncryptionTlsOptions, Document, } from 'mongodb'; -import type { KMSProviderName, KMSTLSProviderName } from './csfle-kms-fields'; +import type { + KMSProviderName, + KMSProviderType, + KMSTLSProviderName, + KMSTLSProviderType, +} from './csfle-kms-fields'; import { toJSString } from 'mongodb-query-parser'; import parseShellStringToEJSON, { ParseMode, @@ -29,18 +34,41 @@ export interface UpdateCsfleAction { key: keyof AutoEncryptionOptions; value?: AutoEncryptionOptions[keyof AutoEncryptionOptions]; } +export interface AddCsfleProviderAction< + T extends KMSProviderType = KMSProviderType +> { + type: 'add-new-csfle-kms-provider'; + name: KMSProviderName; +} +export interface RenameCsfleProviderAction< + T extends KMSProviderType = KMSProviderType +> { + type: 'rename-csfle-kms-provider'; + name: KMSProviderName; + newName: KMSProviderName; +} +export interface RemoveCsfleProviderAction< + T extends KMSProviderType = KMSProviderType +> { + type: 'remove-csfle-kms-provider'; + name: KMSProviderName; +} type KMSProviders = NonNullable; -export interface UpdateCsfleKmsAction { +export interface UpdateCsfleKmsAction< + T extends KMSProviderType = KMSProviderType +> { type: 'update-csfle-kms-param'; - kmsProvider: KMSProviderName; + kmsProviderName: KMSProviderName; key: KeysOfUnion; value?: string; } -export interface UpdateCsfleKmsTlsAction { +export interface UpdateCsfleKmsTlsAction< + T extends KMSTLSProviderType = KMSTLSProviderType +> { type: 'update-csfle-kms-tls-param'; - kmsProvider: KMSTLSProviderName; + kmsProviderName: KMSTLSProviderName; key: keyof ClientEncryptionTlsOptions; value?: string; } @@ -92,17 +120,17 @@ export function handleUpdateCsfleParam({ fleOptions: { ...DEFAULT_FLE_OPTIONS, ...connectionOptions.fleOptions, - autoEncryption: unsetAutoEncryptionIfEmpty(autoEncryption), + autoEncryption, }, }, }; } -export function handleUpdateCsfleKmsParam({ +export function handleUpdateCsfleKmsParam({ action, connectionOptions, }: { - action: UpdateCsfleKmsAction; + action: UpdateCsfleKmsAction; connectionOptions: ConnectionOptions; }): { connectionOptions: ConnectionOptions; @@ -111,7 +139,9 @@ export function handleUpdateCsfleKmsParam({ const autoEncryption = connectionOptions.fleOptions?.autoEncryption ?? {}; // eslint-disable-next-line @typescript-eslint/no-explicit-any const kms: any = { - ...(autoEncryption.kmsProviders?.[action.kmsProvider] ?? {}), + ...(autoEncryption.kmsProviders?.[ + action.kmsProviderName as keyof KMSProviders + ] ?? {}), }; if (!action.value) { delete kms[action.key]; @@ -120,9 +150,9 @@ export function handleUpdateCsfleKmsParam({ } const kmsProviders = autoEncryption.kmsProviders ?? {}; if (Object.keys(kms).length === 0) { - delete kmsProviders[action.kmsProvider]; + delete kmsProviders[action.kmsProviderName as keyof KMSProviders]; } else { - kmsProviders[action.kmsProvider] = kms; + kmsProviders[action.kmsProviderName as keyof KMSProviders] = kms; } return { connectionOptions: { @@ -130,20 +160,20 @@ export function handleUpdateCsfleKmsParam({ fleOptions: { ...DEFAULT_FLE_OPTIONS, ...connectionOptions.fleOptions, - autoEncryption: unsetAutoEncryptionIfEmpty({ + autoEncryption: { ...autoEncryption, kmsProviders, - }), + }, }, }, }; } -export function handleUpdateCsfleKmsTlsParam({ +export function handleUpdateCsfleKmsTlsParam({ action, connectionOptions, }: { - action: UpdateCsfleKmsTlsAction; + action: UpdateCsfleKmsTlsAction; connectionOptions: ConnectionOptions; }): { connectionOptions: ConnectionOptions; @@ -152,7 +182,7 @@ export function handleUpdateCsfleKmsTlsParam({ const autoEncryption = connectionOptions.fleOptions?.autoEncryption ?? {}; // eslint-disable-next-line @typescript-eslint/no-explicit-any const tls: any = { - ...(autoEncryption.tlsOptions?.[action.kmsProvider] ?? {}), + ...(autoEncryption.tlsOptions?.[action.kmsProviderName] ?? {}), }; if (!action.value) { delete tls[action.key]; @@ -161,9 +191,9 @@ export function handleUpdateCsfleKmsTlsParam({ } const tlsOptions = autoEncryption.tlsOptions ?? {}; if (Object.keys(tls).length === 0) { - delete tlsOptions[action.kmsProvider]; + delete tlsOptions[action.kmsProviderName]; } else { - tlsOptions[action.kmsProvider] = tls; + tlsOptions[action.kmsProviderName] = tls; } return { connectionOptions: { @@ -171,10 +201,10 @@ export function handleUpdateCsfleKmsTlsParam({ fleOptions: { ...DEFAULT_FLE_OPTIONS, ...connectionOptions.fleOptions, - autoEncryption: unsetAutoEncryptionIfEmpty({ + autoEncryption: { ...autoEncryption, tlsOptions, - }), + }, }, }, }; @@ -184,10 +214,57 @@ export function handleUpdateCsfleKmsTlsParam({ // as an option, regardless of whether it is filled. Consequently, we need // to set it to undefined explicitly if the user wants to disable automatic // CSFLE entirely (indicated by removing all CSFLE options). -export function unsetAutoEncryptionIfEmpty( - o?: AutoEncryptionOptions -): AutoEncryptionOptions | undefined { - return o && hasAnyCsfleOption(o) ? o : undefined; +export function unsetFleOptionsIfEmptyAutoEncryption( + connectionOptions: Readonly +): ConnectionOptions { + connectionOptions = cloneDeep(connectionOptions); + const autoEncryption = + connectionOptions.fleOptions?.autoEncryption && + hasAnyCsfleOption(connectionOptions.fleOptions?.autoEncryption) + ? connectionOptions.fleOptions?.autoEncryption + : undefined; + + if (!autoEncryption) { + return { + ...connectionOptions, + fleOptions: undefined, + }; + } + + function filterEmptyValues( + obj: T | undefined + ): Partial { + const values = Object.fromEntries( + Object.entries(obj ?? {}).filter( + ([, v]) => Object.keys(v ?? {}).length > 0 + ) + ); + return Object.keys(values).length > 0 ? (values as Partial) : undefined; + } + // Filter out the empty kmsProviders or the tlsOptions + const kmsProviders = filterEmptyValues(autoEncryption.kmsProviders); + const tlsOptions = filterEmptyValues(autoEncryption.tlsOptions); + + const { + /* eslint-disable @typescript-eslint/no-unused-vars */ + kmsProviders: _1, + tlsOptions: _2, + /* eslint-enable @typescript-eslint/no-unused-vars */ + ...restOfTheAutoEncryption + } = autoEncryption; + + return { + ...connectionOptions, + fleOptions: { + ...DEFAULT_FLE_OPTIONS, + ...connectionOptions.fleOptions, + autoEncryption: { + ...restOfTheAutoEncryption, + ...(kmsProviders ? { kmsProviders } : {}), + ...(tlsOptions ? { tlsOptions } : {}), + }, + }, + }; } export function hasAnyCsfleOption(o: Readonly): boolean { @@ -279,3 +356,121 @@ export function adjustCSFLEParams( export function randomLocalKey(): string { return randomBytes(96).toString('base64'); } + +export function handleAddKmsProvider({ + action, + connectionOptions, +}: { + action: AddCsfleProviderAction; + connectionOptions: ConnectionOptions; +}): { + connectionOptions: ConnectionOptions; +} { + connectionOptions = cloneDeep(connectionOptions); + + const autoEncryption = connectionOptions.fleOptions?.autoEncryption ?? {}; + const kmsProviders = autoEncryption.kmsProviders ?? {}; + kmsProviders[action.name as keyof KMSProviders] = {} as any; + + return { + connectionOptions: { + ...connectionOptions, + fleOptions: { + ...DEFAULT_FLE_OPTIONS, + ...connectionOptions.fleOptions, + autoEncryption: { + ...autoEncryption, + kmsProviders, + }, + }, + }, + }; +} + +// In order to ensure that the order of the keys is preserved, we need to +// delete the old key and insert the new key at the same position. +function renameDataKey( + data: T | undefined, + oldKey: keyof T, + newKey: string +): Document | undefined { + if (!data) { + return undefined; + } + return Object.fromEntries( + Object.entries(data).map(([key, value]) => [ + key === oldKey ? newKey : key, + value, + ]) + ); +} + +export function handleRenameKmsProvider({ + action, + connectionOptions, +}: { + action: RenameCsfleProviderAction; + connectionOptions: ConnectionOptions; +}): { + connectionOptions: ConnectionOptions; +} { + connectionOptions = cloneDeep(connectionOptions); + const autoEncryption = connectionOptions.fleOptions?.autoEncryption ?? {}; + + const kmsProviders = renameDataKey( + autoEncryption.kmsProviders, + // @ts-expect-error multiple kms providers are supported in next driver release + action.name, + action.newName + ); + const tlsOptions = renameDataKey( + autoEncryption.tlsOptions, + action.name, + action.newName + ); + return { + connectionOptions: { + ...connectionOptions, + fleOptions: { + ...DEFAULT_FLE_OPTIONS, + ...connectionOptions.fleOptions, + autoEncryption: { + ...autoEncryption, + ...(kmsProviders && { kmsProviders }), + ...(tlsOptions && { tlsOptions }), + }, + }, + }, + }; +} + +export function handleRemoveKmsProvider({ + action, + connectionOptions, +}: { + action: RemoveCsfleProviderAction; + connectionOptions: ConnectionOptions; +}): { + connectionOptions: ConnectionOptions; +} { + connectionOptions = cloneDeep(connectionOptions); + const autoEncryption = connectionOptions.fleOptions?.autoEncryption ?? {}; + const kmsProviders = autoEncryption.kmsProviders ?? {}; + delete kmsProviders[action.name as keyof KMSProviders]; + const tlsOptions = autoEncryption.tlsOptions ?? {}; + delete tlsOptions[action.name as keyof KMSProviders]; + return { + connectionOptions: { + ...connectionOptions, + fleOptions: { + ...DEFAULT_FLE_OPTIONS, + ...connectionOptions.fleOptions, + autoEncryption: { + ...autoEncryption, + kmsProviders, + tlsOptions, + }, + }, + }, + }; +} diff --git a/packages/connection-form/src/utils/csfle-kms-fields.ts b/packages/connection-form/src/utils/csfle-kms-fields.ts index 6b5a9665c3a..5a72b802427 100644 --- a/packages/connection-form/src/utils/csfle-kms-fields.ts +++ b/packages/connection-form/src/utils/csfle-kms-fields.ts @@ -1,22 +1,36 @@ -import type { AutoEncryptionOptions, CSFLEKMSTlsOptions } from 'mongodb'; +import type { KMSProviders } from 'mongodb'; import type { ConnectionFormError } from './validation'; import { errorMessageByFieldName, fieldNameHasError } from './validation'; - +export type { + ClientEncryptionTlsOptions, + KMSProviders, + LocalKMSProviderConfiguration, +} from 'mongodb'; type KeysOfUnion = T extends T ? keyof T : never; -export type KMSProviders = NonNullable; -export type KMSProviderName = keyof KMSProviders; -export type KMSTLSProviderName = keyof CSFLEKMSTlsOptions; -export type KMSOption = KeysOfUnion< +export type KMSOption = KeysOfUnion< NonNullable >; +export type KMSProviderType = Extract< + keyof KMSProviders, + 'aws' | 'gcp' | 'azure' | 'kmip' | 'local' +>; +export type KMSProviderName = T | `${T}:${string}`; +export type KMSTLSProviderType = KMSProviderType; +export type KMSTLSProviderName = KMSProviderName; -export interface KMSField { - name: KMSOption; +export interface KMSField { + name: KMSOption; label: string; type: 'password' | 'text' | 'textarea'; optional: boolean; - value: (autoEncryption: AutoEncryptionOptions) => string; - errorMessage?: (errors: ConnectionFormError[]) => string | undefined; + value: ( + autoEncryption: { kmsProviders?: KMSProviders }, + kmsProviderName: KMSProviderName + ) => string; + errorMessage?: ( + errors: ConnectionFormError[], + kmsProviderName: KMSProviderName + ) => string | undefined; state: | 'error' | 'none' @@ -42,8 +56,9 @@ const GCPFields: KMSField<'gcp'>[] = [ label: 'Service Account E-Mail', type: 'text', optional: false, - value: (autoEncryption) => - decayUnion(autoEncryption?.kmsProviders?.gcp ?? empty)?.email ?? '', + value: (autoEncryption, provider) => + decayUnion(autoEncryption?.kmsProviders?.[provider] ?? empty)?.email ?? + '', state: 'none', description: 'The service account email to authenticate.', }, @@ -52,9 +67,9 @@ const GCPFields: KMSField<'gcp'>[] = [ label: 'Private Key', type: 'textarea', optional: false, - value: (autoEncryption) => + value: (autoEncryption, provider) => decayUnion( - autoEncryption?.kmsProviders?.gcp ?? empty + autoEncryption?.kmsProviders?.[provider] ?? empty )?.privateKey?.toString('base64') ?? '', state: 'none', description: 'A base64-encoded PKCS#8 private key.', @@ -64,8 +79,9 @@ const GCPFields: KMSField<'gcp'>[] = [ label: 'Endpoint', type: 'text', optional: true, - value: (autoEncryption) => - decayUnion(autoEncryption?.kmsProviders?.gcp ?? empty)?.endpoint ?? '', + value: (autoEncryption, provider) => + decayUnion(autoEncryption?.kmsProviders?.[provider] ?? empty)?.endpoint ?? + '', state: 'none', description: 'A host with an optional port.', }, @@ -77,8 +93,8 @@ const AWSFields: KMSField<'aws'>[] = [ label: 'Access Key ID', type: 'text', optional: false, - value: (autoEncryption) => - autoEncryption?.kmsProviders?.aws?.accessKeyId ?? '', + value: (autoEncryption, provider) => + autoEncryption?.kmsProviders?.[provider]?.accessKeyId ?? '', state: 'none', description: 'The access key used for the AWS KMS provider.', }, @@ -87,8 +103,8 @@ const AWSFields: KMSField<'aws'>[] = [ label: 'Secret Access Key', type: 'password', optional: false, - value: (autoEncryption) => - autoEncryption?.kmsProviders?.aws?.secretAccessKey ?? '', + value: (autoEncryption, provider) => + autoEncryption?.kmsProviders?.[provider]?.secretAccessKey ?? '', state: 'none', description: 'The secret access key used for the AWS KMS provider.', }, @@ -97,8 +113,8 @@ const AWSFields: KMSField<'aws'>[] = [ label: 'Session Token', type: 'password', optional: true, - value: (autoEncryption) => - autoEncryption?.kmsProviders?.aws?.sessionToken ?? '', + value: (autoEncryption, provider) => + autoEncryption?.kmsProviders?.[provider]?.sessionToken ?? '', state: 'none', description: 'An optional AWS session token that will be used as the X-Amz-Security-Token header for AWS requests.', @@ -111,8 +127,9 @@ const AzureFields: KMSField<'azure'>[] = [ label: 'Tenant ID', type: 'text', optional: false, - value: (autoEncryption) => - decayUnion(autoEncryption?.kmsProviders?.azure ?? empty)?.tenantId ?? '', + value: (autoEncryption, provider) => + decayUnion(autoEncryption?.kmsProviders?.[provider] ?? empty)?.tenantId ?? + '', state: 'none', description: 'The tenant ID identifies the organization for the account.', }, @@ -121,8 +138,9 @@ const AzureFields: KMSField<'azure'>[] = [ label: 'Client ID', type: 'text', optional: false, - value: (autoEncryption) => - decayUnion(autoEncryption?.kmsProviders?.azure ?? empty)?.clientId ?? '', + value: (autoEncryption, provider) => + decayUnion(autoEncryption?.kmsProviders?.[provider] ?? empty)?.clientId ?? + '', state: 'none', description: 'The client ID to authenticate a registered application.', }, @@ -131,9 +149,9 @@ const AzureFields: KMSField<'azure'>[] = [ label: 'Client Secret', type: 'password', optional: false, - value: (autoEncryption) => - decayUnion(autoEncryption?.kmsProviders?.azure ?? empty)?.clientSecret ?? - '', + value: (autoEncryption, provider) => + decayUnion(autoEncryption?.kmsProviders?.[provider] ?? empty) + ?.clientSecret ?? '', state: 'none', description: 'The client secret to authenticate a registered application.', }, @@ -142,8 +160,8 @@ const AzureFields: KMSField<'azure'>[] = [ label: 'Identity Platform Endpoint', type: 'text', optional: true, - value: (autoEncryption) => - decayUnion(autoEncryption?.kmsProviders?.azure ?? empty) + value: (autoEncryption, provider) => + decayUnion(autoEncryption?.kmsProviders?.[provider] ?? empty) ?.identityPlatformEndpoint ?? '', state: 'none', description: 'A host with an optional port.', @@ -156,8 +174,8 @@ const KMIPFields: KMSField<'kmip'>[] = [ label: 'Endpoint', type: 'text', optional: false, - value: (autoEncryption) => - autoEncryption?.kmsProviders?.kmip?.endpoint ?? '', + value: (autoEncryption, provider) => + autoEncryption?.kmsProviders?.[provider]?.endpoint ?? '', errorMessage: (errors) => errorMessageByFieldName(errors, 'kmip.endpoint'), state: (errors) => fieldNameHasError(errors, 'kmip.endpoint') ? 'error' : 'none', @@ -172,9 +190,10 @@ const LocalFields: KMSField<'local'>[] = [ label: 'Key', type: 'text', optional: false, - value: (autoEncryption) => - autoEncryption?.kmsProviders?.local?.key?.toString('base64') ?? '', - errorMessage: (errors) => errorMessageByFieldName(errors, 'local.key'), + value: (autoEncryption, provider) => + autoEncryption?.kmsProviders?.[provider]?.key?.toString('base64') ?? '', + errorMessage: (errors, provider) => + errorMessageByFieldName(errors, `${provider}.key`), state: (errors) => fieldNameHasError(errors, 'local.key') ? 'error' : 'none', description: diff --git a/packages/connection-form/src/utils/validation.ts b/packages/connection-form/src/utils/validation.ts index 7e1eed23026..42d8beb09d1 100644 --- a/packages/connection-form/src/utils/validation.ts +++ b/packages/connection-form/src/utils/validation.ts @@ -13,7 +13,9 @@ export type FieldName = | 'kerberosPrincipal' | 'keyVaultNamespace' | 'kmip.endpoint' + | `kmip:${string}.endpoint` | 'local.key' + | `local:${string}.key` | 'password' | 'schema' | 'proxyHostname' diff --git a/packages/connection-info/src/connection-secrets.spec.ts b/packages/connection-info/src/connection-secrets.spec.ts index 706408f7c44..3f635eebf06 100644 --- a/packages/connection-info/src/connection-secrets.spec.ts +++ b/packages/connection-info/src/connection-secrets.spec.ts @@ -197,45 +197,88 @@ describe('connection secrets', function () { secretAccessKey: 'secretAccessKey', sessionToken: 'sessionToken', }, + // @ts-expect-error multiple kms providers are supported in next driver release + 'aws:1': { + accessKeyId: 'accessKeyId', + secretAccessKey: 'secretAccessKey', + sessionToken: 'sessionToken', + }, local: { key: 'key', }, + 'local:1': { + key: 'key', + }, azure: { tenantId: 'tenantId', clientId: 'clientId', clientSecret: 'clientSecret', identityPlatformEndpoint: 'identityPlatformEndpoint', }, + 'azure:1': { + tenantId: 'tenantId', + clientId: 'clientId', + clientSecret: 'clientSecret', + identityPlatformEndpoint: 'identityPlatformEndpoint', + }, gcp: { email: 'email', privateKey: 'privateKey', endpoint: 'endpoint', }, + 'gcp:1': { + email: 'email', + privateKey: 'privateKey', + endpoint: 'endpoint', + }, kmip: { endpoint: 'endpoint', }, + 'kmip:1': { + endpoint: 'endpoint', + }, }, tlsOptions: { aws: { tlsCertificateKeyFile: 'file', tlsCertificateKeyFilePassword: 'pwd', }, + 'aws:1': { + tlsCertificateKeyFile: 'file', + tlsCertificateKeyFilePassword: 'pwd', + }, local: { tlsCertificateKeyFile: 'file', tlsCertificateKeyFilePassword: 'pwd', }, + 'local:1': { + tlsCertificateKeyFile: 'file', + tlsCertificateKeyFilePassword: 'pwd', + }, azure: { tlsCertificateKeyFile: 'file', tlsCertificateKeyFilePassword: 'pwd', }, + 'azure:1': { + tlsCertificateKeyFile: 'file', + tlsCertificateKeyFilePassword: 'pwd', + }, gcp: { tlsCertificateKeyFile: 'file', tlsCertificateKeyFilePassword: 'pwd', }, + 'gcp:1': { + tlsCertificateKeyFile: 'file', + tlsCertificateKeyFilePassword: 'pwd', + }, kmip: { tlsCertificateKeyFile: 'file', tlsCertificateKeyFilePassword: 'pwd', }, + 'kmip:1': { + tlsCertificateKeyFile: 'file', + tlsCertificateKeyFilePassword: 'pwd', + }, }, }, }, @@ -268,40 +311,70 @@ describe('connection secrets', function () { aws: { accessKeyId: 'accessKeyId', }, + 'aws:1': { + accessKeyId: 'accessKeyId', + }, azure: { tenantId: 'tenantId', clientId: 'clientId', identityPlatformEndpoint: 'identityPlatformEndpoint', }, + 'azure:1': { + tenantId: 'tenantId', + clientId: 'clientId', + identityPlatformEndpoint: 'identityPlatformEndpoint', + }, gcp: { email: 'email', endpoint: 'endpoint', }, + 'gcp:1': { + email: 'email', + endpoint: 'endpoint', + }, kmip: { endpoint: 'endpoint', }, + 'kmip:1': { + endpoint: 'endpoint', + }, }, tlsOptions: { aws: { tlsCertificateKeyFile: 'file', }, + 'aws:1': { + tlsCertificateKeyFile: 'file', + }, local: { tlsCertificateKeyFile: 'file', }, + 'local:1': { + tlsCertificateKeyFile: 'file', + }, azure: { tlsCertificateKeyFile: 'file', }, + 'azure:1': { + tlsCertificateKeyFile: 'file', + }, gcp: { tlsCertificateKeyFile: 'file', }, + 'gcp:1': { + tlsCertificateKeyFile: 'file', + }, kmip: { tlsCertificateKeyFile: 'file', }, + 'kmip:1': { + tlsCertificateKeyFile: 'file', + }, }, }, }, }, - } as ConnectionInfo); + }); expect(secrets).to.be.deep.equal({ awsSessionToken: 'sessionToken', @@ -317,35 +390,63 @@ describe('connection secrets', function () { secretAccessKey: 'secretAccessKey', sessionToken: 'sessionToken', }, + 'aws:1': { + secretAccessKey: 'secretAccessKey', + sessionToken: 'sessionToken', + }, local: { key: 'key', }, + 'local:1': { + key: 'key', + }, azure: { clientSecret: 'clientSecret', }, + 'azure:1': { + clientSecret: 'clientSecret', + }, gcp: { privateKey: 'privateKey', }, + 'gcp:1': { + privateKey: 'privateKey', + }, }, tlsOptions: { aws: { tlsCertificateKeyFilePassword: 'pwd', }, + 'aws:1': { + tlsCertificateKeyFilePassword: 'pwd', + }, local: { tlsCertificateKeyFilePassword: 'pwd', }, + 'local:1': { + tlsCertificateKeyFilePassword: 'pwd', + }, azure: { tlsCertificateKeyFilePassword: 'pwd', }, + 'azure:1': { + tlsCertificateKeyFilePassword: 'pwd', + }, gcp: { tlsCertificateKeyFilePassword: 'pwd', }, + 'gcp:1': { + tlsCertificateKeyFilePassword: 'pwd', + }, kmip: { tlsCertificateKeyFilePassword: 'pwd', }, + 'kmip:1': { + tlsCertificateKeyFilePassword: 'pwd', + }, }, }, - } as ConnectionSecrets); + }); const { connectionInfo: newConnectionInfoNoFle, secrets: secretsNoFle } = extractSecrets( diff --git a/packages/connection-info/src/connection-secrets.ts b/packages/connection-info/src/connection-secrets.ts index 41e491fe18e..e953106d40e 100644 --- a/packages/connection-info/src/connection-secrets.ts +++ b/packages/connection-info/src/connection-secrets.ts @@ -4,6 +4,7 @@ import ConnectionString, { } from 'mongodb-connection-string-url'; import type { ConnectionInfo } from './connection-info'; import type { + Document, MongoClientOptions, AuthMechanismProperties, AutoEncryptionOptions, @@ -153,21 +154,11 @@ export function extractSecrets(connectionInfo: Readonly): { if (connectionOptions.fleOptions?.autoEncryption) { const { autoEncryption } = connectionOptions.fleOptions; - const kmsProviders = ['aws', 'local', 'azure', 'gcp', 'kmip'] as const; - const secretPaths = [ - 'kmsProviders.aws.secretAccessKey', - 'kmsProviders.aws.sessionToken', - 'kmsProviders.local.key', - 'kmsProviders.azure.clientSecret', - 'kmsProviders.gcp.privateKey', - ...kmsProviders.map( - (p) => `tlsOptions.${p}.tlsCertificateKeyFilePassword` - ), - ]; - connectionOptions.fleOptions.autoEncryption = _.omit( - autoEncryption, - secretPaths - ); + const { + data: autoEncryptionWithoutSecrets, + secrets: autoEncryptionSecrets, + } = extractAutoEncryptionSecrets(autoEncryption); + connectionOptions.fleOptions.autoEncryption = autoEncryptionWithoutSecrets; // Remove potentially empty KMS provider options objects, // since libmongocrypt assumes that, if a KMS provider options // object is present but empty, the caller will be able @@ -178,7 +169,7 @@ export function extractSecrets(connectionInfo: Readonly): { connectionOptions.fleOptions.autoEncryption.kmsProviders ); if (connectionOptions.fleOptions.storeCredentials) { - secrets.autoEncryption = _.pick(autoEncryption, secretPaths); + secrets.autoEncryption = autoEncryptionSecrets; } } @@ -190,10 +181,67 @@ export function extractSecrets(connectionInfo: Readonly): { return { connectionInfo: connectionInfoWithoutSecrets, secrets }; } -function omitPropertiesWhoseValuesAreEmptyObjects< - T extends Record> ->(obj: T): Partial { +function omitPropertiesWhoseValuesAreEmptyObjects(obj: T) { return Object.fromEntries( Object.entries(obj).filter(([, value]) => Object.keys(value).length > 0) ) as Partial; } + +const KMS_PROVIDER_SECRET_PATHS = { + local: ['key'], + aws: ['secretAccessKey', 'sessionToken'], + azure: ['clientSecret'], + gcp: ['privateKey'], + // kmip does not have any kms secrets, but tlsOptions + kmip: undefined, +}; + +type AutoEncryptionKMSAndTLSOptions = Partial< + Pick +>; + +function extractAutoEncryptionSecrets(data: AutoEncryptionOptions): { + data: AutoEncryptionOptions & AutoEncryptionKMSAndTLSOptions; + secrets: AutoEncryptionKMSAndTLSOptions; +} { + const secrets: AutoEncryptionKMSAndTLSOptions = {}; + // Secrets are stored in a kmsProviders and tlsOptions + const { kmsProviders, tlsOptions, ...result } = data; + + for (const kmsProviderName of Object.keys(kmsProviders ?? {})) { + const key = kmsProviderName.split(':')[0] as + | keyof typeof KMS_PROVIDER_SECRET_PATHS + | undefined; + if (!key) { + continue; + } + + const data = (kmsProviders ?? {})[ + kmsProviderName as keyof typeof kmsProviders + ]; + const secretPaths = KMS_PROVIDER_SECRET_PATHS[key]; + if (!secretPaths) { + continue; + } + _.set( + secrets, + `kmsProviders.${kmsProviderName}`, + _.pick(data, secretPaths) + ); + for (const secretKey of secretPaths) { + _.unset(data, secretKey); + } + } + + for (const key of Object.keys(tlsOptions ?? {})) { + const data = (tlsOptions ?? {})[key]; + if (data?.tlsCertificateKeyFilePassword) { + _.set(secrets, `tlsOptions.${key}`, { + tlsCertificateKeyFilePassword: data.tlsCertificateKeyFilePassword, + }); + delete data.tlsCertificateKeyFilePassword; + } + } + + return { data: _.merge(result, { kmsProviders, tlsOptions }), secrets }; +} diff --git a/packages/connection-storage/src/compass-main-connection-storage.spec.ts b/packages/connection-storage/src/compass-main-connection-storage.spec.ts index 53bb7aa7365..00948063deb 100644 --- a/packages/connection-storage/src/compass-main-connection-storage.spec.ts +++ b/packages/connection-storage/src/compass-main-connection-storage.spec.ts @@ -901,14 +901,20 @@ describe('ConnectionStorage', function () { local: { key: 'my-key', }, + 'local:2': { + key: 'my-key-2', + }, + kmip: { + endpoint: 'kmip://localhost:5696', + }, }, }, }, }, }; - // // Stub encryptSecrets so that we do not call electron.safeStorage.encrypt - // // and make assertions on that. + // Stub encryptSecrets so that we do not call electron.safeStorage.encrypt + // and make assertions on that. const encryptSecretsStub = Sinon.stub( connectionStorage, 'encryptSecrets' as any @@ -918,7 +924,11 @@ describe('ConnectionStorage', function () { const expectedConnection = await readConnection(tmpDir, id); connectionInfo.connectionOptions.fleOptions.autoEncryption.kmsProviders = - {} as any; + { + kmip: { + endpoint: 'kmip://localhost:5696', + }, + } as any; expect(expectedConnection).to.deep.equal({ _id: connectionInfo.id, connectionInfo, @@ -946,6 +956,12 @@ describe('ConnectionStorage', function () { local: { key: 'my-key', }, + 'local:2': { + key: 'my-key-2', + }, + kmip: { + endpoint: 'kmip://localhost:5696', + }, }, }, }, @@ -963,7 +979,11 @@ describe('ConnectionStorage', function () { const expectedConnection = await readConnection(tmpDir, id); connectionInfo.connectionOptions.fleOptions.autoEncryption.kmsProviders = - {} as any; + { + kmip: { + endpoint: 'kmip://localhost:5696', + }, + } as any; expect(expectedConnection).to.deep.equal({ _id: connectionInfo.id, connectionInfo, @@ -981,6 +1001,9 @@ describe('ConnectionStorage', function () { local: { key: 'my-key', }, + 'local:2': { + key: 'my-key-2', + }, }, }, }); diff --git a/packages/databases-collections/src/components/collection-fields/fle2-fields.jsx b/packages/databases-collections/src/components/collection-fields/fle2-fields.jsx index b739a21f673..b3639bb0180 100644 --- a/packages/databases-collections/src/components/collection-fields/fle2-fields.jsx +++ b/packages/databases-collections/src/components/collection-fields/fle2-fields.jsx @@ -12,7 +12,7 @@ import { CodemirrorMultilineEditor } from '@mongodb-js/compass-editor'; const HELP_URL_FLE2 = 'https://dochub.mongodb.org/core/rqe-encrypted-fields'; -const kmsProviderNames = { +const kmsProviderTypes = { local: 'Local', gcp: 'GCP', azure: 'Azure', @@ -20,6 +20,19 @@ const kmsProviderNames = { kmip: 'KMIP', }; +/** + * Get the friendly provider name. + * @param {string} provider + * @returns string + */ +function getKmsProviderName(provider) { + const parts = provider.split(':'); + if (parts.length === 1) { + return kmsProviderTypes[parts[0]]; + } + return `${kmsProviderTypes[parts[0]]} ${parts[1]}`; +} + export const ENCRYPTED_FIELDS_PLACEHOLDER = `{ fields: [ { @@ -50,6 +63,10 @@ const keyEncryptionKeyTemplate = { kmip: '/* No KeyEncryptionKey required */\n{}', }; +function getKMSProviderKeyTemplate(provider) { + return keyEncryptionKeyTemplate[provider.split(':')[0]]; +} + const queryableEncryptedFieldsEditorId = 'queryable-encrypted-fields-editor-id'; const keyEncryptionKeyEditorId = 'key-encryption-key-editor-id'; @@ -107,13 +124,13 @@ function FLE2Fields({ ev.preventDefault(); onChangeField( ['fle2.kmsProvider', 'fle2.keyEncryptionKey'], - [ev.target.value, keyEncryptionKeyTemplate[ev.target.value]] + [ev.target.value, getKMSProviderKeyTemplate(ev.target.value)] ); }} id="createcollection-radioboxgroup" value={fle2.kmsProvider} > - {(configuredKMSProviders || Object.keys(kmsProviderNames)).map( + {(configuredKMSProviders || Object.keys(kmsProviderTypes)).map( (provider) => { return ( - {kmsProviderNames[provider]} + {getKmsProviderName(provider)} ); } diff --git a/packages/databases-collections/src/modules/create-namespace.ts b/packages/databases-collections/src/modules/create-namespace.ts index d7fefce7185..d36b2c7a382 100644 --- a/packages/databases-collections/src/modules/create-namespace.ts +++ b/packages/databases-collections/src/modules/create-namespace.ts @@ -168,13 +168,18 @@ const reducer: Reducer = ( state = INITIAL_STATE, action ) => { - if ( - isAction(action, CreateNamespaceActionTypes.Reset) || - isAction(action, CreateNamespaceActionTypes.Close) - ) { + if (isAction(action, CreateNamespaceActionTypes.Reset)) { return { ...INITIAL_STATE }; } + if (isAction(action, CreateNamespaceActionTypes.Close)) { + // When a modal is closed, we should not clear the connectionMetaData + return { + ...INITIAL_STATE, + connectionMetaData: state.connectionMetaData, + }; + } + if (isAction(action, CreateNamespaceActionTypes.Open)) { return { ...state,