diff --git a/.github/workflows/coding-standards.yml b/.github/workflows/coding-standards.yml index e57b10f9..32444a03 100644 --- a/.github/workflows/coding-standards.yml +++ b/.github/workflows/coding-standards.yml @@ -4,6 +4,7 @@ on: pull_request: branches: - "*.x" + - "feature/*" push: branches: - "*.x" diff --git a/.github/workflows/composer-lint.yml b/.github/workflows/composer-lint.yml index 24738f32..4c1dabd1 100644 --- a/.github/workflows/composer-lint.yml +++ b/.github/workflows/composer-lint.yml @@ -4,11 +4,13 @@ on: pull_request: branches: - "*.x" + - "feature/*" paths: - "composer.json" push: branches: - "*.x" + - "feature/*" paths: - "composer.json" diff --git a/.github/workflows/continuous-integration.yml b/.github/workflows/continuous-integration.yml index 2dd408eb..d48d65fc 100644 --- a/.github/workflows/continuous-integration.yml +++ b/.github/workflows/continuous-integration.yml @@ -4,7 +4,7 @@ on: pull_request: branches: - "*.x" - - "master" + - "feature/*" push: env: @@ -41,7 +41,7 @@ jobs: - dependencies: "lowest" os: "ubuntu-24.04" php-version: "8.1" - driver-version: "1.16.0" + driver-version: "1.21.0" stability: "stable" symfony-version: "6.4.*" - dependencies: "highest" diff --git a/.github/workflows/static-analysis.yml b/.github/workflows/static-analysis.yml index 718750d9..49106d4d 100644 --- a/.github/workflows/static-analysis.yml +++ b/.github/workflows/static-analysis.yml @@ -4,6 +4,7 @@ on: pull_request: branches: - "*.x" + - "feature/*" paths: - .github/workflows/static-analysis.yml - composer.* diff --git a/composer.json b/composer.json index dbd735bb..8d1c4036 100644 --- a/composer.json +++ b/composer.json @@ -25,7 +25,7 @@ "homepage": "http://www.doctrine-project.org", "require": { "php": "^8.1", - "ext-mongodb": "^1.16 || ^2", + "ext-mongodb": "^1.21 || ^2", "composer-runtime-api": "^2.0", "doctrine/mongodb-odm": "^2.6", "doctrine/persistence": "^3.0 || ^4.0", diff --git a/config/command.php b/config/command.php index 01e4e744..6b5af282 100644 --- a/config/command.php +++ b/config/command.php @@ -5,6 +5,8 @@ use Doctrine\Bundle\MongoDBBundle\Command\ClearMetadataCacheDoctrineODMCommand; use Doctrine\Bundle\MongoDBBundle\Command\CreateSchemaDoctrineODMCommand; use Doctrine\Bundle\MongoDBBundle\Command\DropSchemaDoctrineODMCommand; +use Doctrine\Bundle\MongoDBBundle\Command\Encryption\DiagnosticCommand; +use Doctrine\Bundle\MongoDBBundle\Command\Encryption\DumpFieldsMapCommand; use Doctrine\Bundle\MongoDBBundle\Command\GenerateHydratorsDoctrineODMCommand; use Doctrine\Bundle\MongoDBBundle\Command\GenerateProxiesDoctrineODMCommand; use Doctrine\Bundle\MongoDBBundle\Command\InfoDoctrineODMCommand; @@ -15,12 +17,21 @@ use Symfony\Component\DependencyInjection\Loader\Configurator\ContainerConfigurator; use function Symfony\Component\DependencyInjection\Loader\Configurator\service; +use function Symfony\Component\DependencyInjection\Loader\Configurator\tagged_locator; return static function (ContainerConfigurator $containerConfigurator): void { $containerConfigurator->services() ->set('doctrine_mongodb.odm.command.clear_metadata_cache', ClearMetadataCacheDoctrineODMCommand::class) ->tag('console.command', ['command' => 'doctrine:mongodb:cache:clear-metadata']) + ->set('doctrine_mongodb.odm.command.encryption_diagnostic', DiagnosticCommand::class) + ->tag('console.command', ['command' => 'doctrine:mongodb:encryption:diagnostic']) + ->args([tagged_locator('doctrine_mongodb.connection_diagnostic', 'name')]) + + ->set('doctrine_mongodb.odm.command.encryption_dump_fields_map', DumpFieldsMapCommand::class) + ->tag('console.command', ['command' => 'doctrine:mongodb:encryption:dump-fields-map']) + ->args([tagged_locator('doctrine_mongodb.odm.document_manager', 'name')]) + ->set('doctrine_mongodb.odm.command.create_schema', CreateSchemaDoctrineODMCommand::class) ->tag('console.command', ['command' => 'doctrine:mongodb:schema:create']) diff --git a/config/schema/mongodb-1.0.xsd b/config/schema/mongodb-1.0.xsd index 4a63e8be..90588f99 100644 --- a/config/schema/mongodb-1.0.xsd +++ b/config/schema/mongodb-1.0.xsd @@ -9,7 +9,7 @@ - + @@ -46,8 +46,9 @@ - - + + + @@ -84,6 +85,9 @@ + + + @@ -119,12 +123,109 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - + + @@ -167,10 +268,10 @@ - - - - + + + + diff --git a/docs/config.rst b/docs/config.rst index d6aac188..59978e45 100644 --- a/docs/config.rst +++ b/docs/config.rst @@ -624,6 +624,10 @@ Otherwise you will get a *auth failed* exception. ]); }; +Using Queryable Encryption +-------------------------- + +For details on configuring Queryable Encryption (QE) and Client-Side Field-Level Encryption (CSFLE), see :doc:`encryption`. Full Default Configuration -------------------------- @@ -699,6 +703,29 @@ Full Default Configuration wTimeoutMS: ~ driver_options: context: ~ # stream context to use for connection + autoEncryption: # Options for client-side field-level encryption + keyVaultClient: null # Service ID of a MongoDB\Driver\Manager for the key vault + keyVaultNamespace: null # The namespace for the key vault collection (e.g., "encryption.__keyVault") + kmsProvider: {} # Configuration for Key Management System provider (see specific examples above) + # e.g., { type: "local", key: "YOUR_BASE64_KEY" } + # e.g., { type: "aws", accessKeyId: "...", secretAccessKey: "..." } + masterKey: ~ # Default master key to use when creating a new encrypted collection + schemaMap: [] # Document schemas for explicit encryption + encryptedFieldsMap: [] # Map of collections to their encrypted fields configuration + extraOptions: [] # Extra options for mongocryptd + # mongocryptdURI: "mongodb://localhost:27020" + # mongocryptdBypassSpawn: false + # mongocryptdSpawnPath: "/usr/local/bin/mongocryptd" + # mongocryptdSpawnArgs: ["--idleShutdownTimeoutSecs=60"] + # cryptSharedLibPath: null # Path to the crypt_shared library + # cryptSharedLibRequired: false # If true, fails if the crypt_shared library cannot be loaded + bypassQueryAnalysis: false # Disables automatic analysis of read and write operations for encryption + bypassAutoEncryption: false # Disables auto-encryption + tlsOptions: # TLS options for the Key Vault client (if keyVaultClient is not specified) + tlsCAFile: null # Path to CA file, e.g., /path/to/key-vault-ca.pem + tlsCertificateKeyFile: null # Path to client cert/key file, e.g., /path/to/key-vault-client.pem + tlsCertificateKeyFilePassword: null # Password for client cert/key file + tlsDisableOCSPEndpointCheck: false # Disable OCSP checks proxy_namespace: MongoDBODMProxies proxy_dir: "%kernel.cache_dir%/doctrine/odm/mongodb/Proxies" @@ -825,8 +852,27 @@ Full Default Configuration $config->connection('id') ->server('mongodb://localhost') - ->driverOptions([ - 'context' => null, // stream context to use for connection + ->autoEncryption([ // Options for client-side field-level encryption + 'bypassAutoEncryption' => false, // Disables auto-encryption + 'keyVaultClient' => null, // Service ID of a MongoDB\Driver\Manager for the key vault + 'keyVaultNamespace' => null, // The namespace for the key vault collection (e.g., "encryption.__keyVault") + 'kmsProvider' => [ // Configuration for Key Management System provider + // e.g., ['type' => 'local', 'key' => 'YOUR_BASE64_KEY'] + // e.g., ['type' => 'aws', 'accessKeyId' => '...', 'secretAccessKey' => '...'] + ], + 'schemaMap' => [], // Document schemas for explicit encryption + 'encryptedFieldsMap' => [], // Map of collections to their encrypted fields configuration + 'extraOptions' => [ // Extra options for mongocryptd + // 'cryptSharedLibPath' => null, // Path to the crypt_shared library + // 'cryptSharedLibRequired' => false, // If true, fails if the crypt_shared library cannot be loaded + ], + 'bypassQueryAnalysis' => false, // Disables automatic analysis of read and write operations for encryption + 'tlsOptions' => [ // TLS options for the Key Vault client (if keyVaultClient is not specified) + // 'tlsCAFile' => null, // Path to CA file, e.g., /path/to/key-vault-ca.pem + // 'tlsCertificateKeyFile' => null, // Path to client cert/key file, e.g., /path/to/key-vault-client.pem + // 'tlsCertificateKeyFilePassword' => null, // Password for client cert/key file + // 'tlsDisableOCSPEndpointCheck' => false, // Disable OCSP checks + ], ]) ->options([ 'authMechanism' => null, diff --git a/docs/encryption.rst b/docs/encryption.rst new file mode 100644 index 00000000..3384db79 --- /dev/null +++ b/docs/encryption.rst @@ -0,0 +1,284 @@ +Client-Side Field-Level Encryption (CSFLE) and Queryable Encryption (QE) +============================================================ + +This page documents how to configure and use MongoDB Client-Side Field-Level Encryption (CSFLE) and Queryable Encryption (QE) in DoctrineMongoDBBundle. + +.. note:: + + CSFLE and QE are advanced MongoDB features that allow you to encrypt specific fields in your documents, with optional support for searching encrypted data (Queryable Encryption). + +Configuration +------------- + +.. tip:: + + For a general overview of configuration options, see :doc:`config`. + +To enable CSFLE or QE, you need to configure the ``autoEncryption`` option under +your connection's configuration. At a minimum, you must specify the ``kmsProvider`` +and the ``masterKey`` for KMS provider other than "local". +Additional options are available for advanced use cases. + +.. configuration-block:: + + .. code-block:: yaml + + doctrine_mongodb: + connections: + default: + server: "mongodb://localhost:27017" + autoEncryption: + kmsProvider: + local: + key: "YOUR_BASE64_KEY" + # Optional: see below for more options + + .. code-block:: php + + use Symfony\Config\DoctrineMongodbConfig; + + return static function (DoctrineMongodbConfig $config): void { + $config->connection('default') + ->server('mongodb://localhost:27017') + ->autoEncryption([ + 'kmsProvider' => [ + 'type' => 'local', + 'key' => 'YOUR_BASE64_KEY', + ], + // ... other options ... + ]); + }; + +Supported KMS Providers +----------------------- + +The ``kmsProvider`` option specifies a single KMS provider that will be used for encryption. +The type of KMS provider is specified with the ``type`` property along with its options. + +The configuration for each KMS provider varies and is described in the +`MongoDB Manager constructor documentation `. + +Example of configuration for AWS + + +.. code-block:: yaml + + doctrine_mongodb: + connections: + default: + autoEncryption: + kmsProvider: + type: aws + accessKeyId: YOUR_AWS_ACCESS_KEY_ID + secretAccessKey: YOUR_AWS_SECRET_ACCESS_KEY + masterKey: + region: "eu-west-1" + key: "arn:aws:kms:eu-west-1:123456789012:key/abcd1234-12ab-34cd-56ef-1234567890ab" + + +Encrypted Fields Map +-------------------- + +The encrypted fields are set to the collection when you create it, and the MongoDB +client will query the server for the collection schema before performing any +operations. For additional security, you can also specify the encrypted fields +in the connection configuration, which allows the client to use local rules +instead of downloading the remote schema from the server, that could potentially +be tampered with if an attacker compromises the server. + +The Encrypted Fields Map is a list of all encrypted fields associated with all +the collection namespaces that has encryption enabled. To configure it, you +can run a command that extract the encrypted fields from the server and generate +the ``encryptedFieldsMap`` configuration. + +.. code-block:: console + + php bin/console doctrine:mongodb:encryption:dump-fields-map --format yaml + +The output of the command will be a YAML configuration for the +``autoEncryption.encryptedFieldsMap`` option in the connection configuration. + +- If the connection ``encryptedFieldsMap`` object contains a key for the specified + collection namespace, the client uses that object to perform automatic + Queryable Encryption, rather than using the remote schema. At minimum, the + local rules must encrypt all fields that the remote schema does. + +- If the connection ``encryptedFieldsMap`` object doesn't contain a key for the + specified collection namespace, the client downloads the server-side remote + schema for the collection and uses it instead. + +For more details, see the official MongoDB documentation: +`Encrypted Fields and Enabled Queries `_. + +.. tabs:: + + .. group-tab:: YAML + + .. code-block:: yaml + + doctrine_mongodb: + connections: + default: + autoEncryption: + encryptedFieldsMap: + "mydatabase.mycollection": + fields: + - keyId: { $binary: { base64: 2CSosXLSTEKaYphcSnUuCw==, subType: '04' } } + path: "sensitive_field" + bsonType: "string" + + .. group-tab:: XML + + .. code-block:: xml + + + + + + + + + + .. group-tab:: PHP + + .. code-block:: php + + use Symfony\Config\DoctrineMongodbConfig; + + return static function (DoctrineMongodbConfig $config): void { + $config->connection('default') + ->autoEncryption([ + 'encryptedFieldsMap' => [ + 'mydatabase.mycollection' => [ + 'fields' => [ + [ + 'path' => 'sensitive_field', + // Extended JSON representation of a BSON binary type + // The MongoDB\BSON\Binary class cannot be used here + 'keyId' => ['$binary' => ['base64' => '2CSosXLSTEKaYphcSnUuCw==', 'subType' => '04' ] ], + 'bsonType' => 'string', + ], + ], + ], + ], + ]); + }; + +Automatic Encryption Shared Library +----------------------------------- + +To use automatic encryption, the MongoDB PHP driver requires the `Automatic Encryption Shared Library`_. + +If the driver is not able to find the library, you can specify its path using the ``cryptSharedLibPath`` extra option in your connection configuration. + +.. tabs:: + + .. group-tab:: YAML + + .. code-block:: yaml + + doctrine_mongodb: + connections: + default: + autoEncryption: + extraOptions: + cryptSharedLibPath: '%kernel.project_dir%/bin/mongo_crypt_v1.so' + + .. group-tab:: XML + + .. code-block:: xml + + + + + + + + .. group-tab:: PHP + + .. code-block:: php + + use Symfony\Config\DoctrineMongodbConfig; + + return static function (DoctrineMongodbConfig $config): void { + $config->connection('default') + ->autoEncryption([ + 'extraOptions' => [ + 'cryptSharedLibPath' => '%kernel.project_dir%/bin/mongo_crypt_v1.so', + ], + ]); + }; + +TLS Options +----------- + +If you are not specifying a custom ``keyVaultClient`` service, you can configure +TLS settings for the internal key vault client using the ``tlsOptions`` key: + +.. tabs:: + + .. group-tab:: YAML + + .. code-block:: yaml + + doctrine_mongodb: + connections: + default: + autoEncryption: + tlsOptions: + tlsCAFile: "/path/to/key-vault-ca.pem" + tlsCertificateKeyFile: "/path/to/key-vault-client.pem" + tlsCertificateKeyFilePassword: "keyvaultclientpassword" + tlsDisableOCSPEndpointCheck: false + + .. group-tab:: XML + + .. code-block:: xml + + + + + + + + .. group-tab:: PHP + + .. code-block:: php + + use Symfony\Config\DoctrineMongodbConfig; + + return static function (DoctrineMongodbConfig $config): void { + $config->connection('default') + ->autoEncryption([ + 'tlsOptions' => [ + 'tlsCAFile' => '/path/to/key-vault-ca.pem', + 'tlsCertificateKeyFile' => '/path/to/key-vault-client.pem', + 'tlsCertificateKeyFilePassword' => 'keyvaultclientpassword', + 'tlsDisableOCSPEndpointCheck' => false, + ], + ]); + }; + +Further Reading +--------------- + +- `MongoDB CSFLE documentation `_ +- `MongoDB PHP driver Manager::__construct `_ +- :doc:`config` + +.. _`Automatic Encryption Shared Library`: https://www.mongodb.com/docs/manual/core/queryable-encryption/install-library/ diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon index c09c6edb..dfc9747d 100644 --- a/phpstan-baseline.neon +++ b/phpstan-baseline.neon @@ -36,12 +36,6 @@ parameters: count: 1 path: src/CacheWarmer/ProxyCacheWarmer.php - - - message: '#^Method Doctrine\\Bundle\\MongoDBBundle\\CacheWarmer\\ProxyCacheWarmer\:\:getClassesForProxyGeneration\(\) return type with generic class Doctrine\\ODM\\MongoDB\\Mapping\\ClassMetadata does not specify its types\: T$#' - identifier: missingType.generics - count: 1 - path: src/CacheWarmer/ProxyCacheWarmer.php - - message: '#^Parameter \#1 \$application of static method Doctrine\\Bundle\\MongoDBBundle\\Command\\DoctrineODMCommand\:\:setApplicationDocumentManager\(\) expects Symfony\\Bundle\\FrameworkBundle\\Console\\Application, Symfony\\Component\\Console\\Application\|null given\.$#' identifier: argument.type @@ -102,6 +96,12 @@ parameters: count: 1 path: src/Command/UpdateSchemaDoctrineODMCommand.php + - + message: '#^Method Doctrine\\Bundle\\MongoDBBundle\\DataCollector\\ConnectionDiagnostic\:\:__construct\(\) has parameter \$driverOptions with no value type specified in iterable type array\.$#' + identifier: missingType.iterableValue + count: 1 + path: src/DataCollector/ConnectionDiagnostic.php + - message: '#^Cannot cast array\|bool\|float\|int\|string\|UnitEnum\|null to string\.$#' identifier: cast.string @@ -222,6 +222,12 @@ parameters: count: 1 path: src/DependencyInjection/DoctrineMongoDBExtension.php + - + message: '#^Method Doctrine\\Bundle\\MongoDBBundle\\DependencyInjection\\DoctrineMongoDBExtension\:\:loadConnections\(\) has parameter \$config with no value type specified in iterable type array\.$#' + identifier: missingType.iterableValue + count: 1 + path: src/DependencyInjection/DoctrineMongoDBExtension.php + - message: '#^Method Doctrine\\Bundle\\MongoDBBundle\\DependencyInjection\\DoctrineMongoDBExtension\:\:loadConnections\(\) has parameter \$connections with no value type specified in iterable type array\.$#' identifier: missingType.iterableValue @@ -252,12 +258,6 @@ parameters: count: 1 path: src/DependencyInjection/DoctrineMongoDBExtension.php - - - message: '#^PHPDoc tag @param references unknown parameter\: \$config$#' - identifier: parameter.notFound - count: 1 - path: src/DependencyInjection/DoctrineMongoDBExtension.php - - message: '#^Parameter \#2 \$bundles of method Symfony\\Bridge\\Doctrine\\DependencyInjection\\AbstractDoctrineExtension\:\:fixManagersAutoMappings\(\) expects array, array\|bool\|float\|int\|string\|UnitEnum\|null given\.$#' identifier: argument.type @@ -318,12 +318,6 @@ parameters: count: 1 path: src/Form/ChoiceList/MongoDBQueryBuilderLoader.php - - - message: '#^Method Doctrine\\Bundle\\MongoDBBundle\\Form\\DoctrineMongoDBTypeGuesser\:\:getMetadata\(\) return type with generic class Doctrine\\ODM\\MongoDB\\Mapping\\ClassMetadata does not specify its types\: T$#' - identifier: missingType.generics - count: 1 - path: src/Form/DoctrineMongoDBTypeGuesser.php - - message: '#^Method Doctrine\\Bundle\\MongoDBBundle\\Form\\DoctrineMongoDBTypeGuesser\:\:getMetadata\(\) should return array\{Doctrine\\ODM\\MongoDB\\Mapping\\ClassMetadata, string\}\|null but returns array\{Doctrine\\Persistence\\Mapping\\ClassMetadata\, string\}\.$#' identifier: return.type @@ -366,12 +360,6 @@ parameters: count: 1 path: src/Form/DoctrineMongoDBTypeGuesser.php - - - message: '#^Property Doctrine\\Bundle\\MongoDBBundle\\Form\\DoctrineMongoDBTypeGuesser\:\:\$cache with generic class Doctrine\\ODM\\MongoDB\\Mapping\\ClassMetadata does not specify its types\: T$#' - identifier: missingType.generics - count: 1 - path: src/Form/DoctrineMongoDBTypeGuesser.php - - message: '#^Unable to resolve the template type T in call to method Doctrine\\Persistence\\ObjectManager\:\:getClassMetadata\(\)$#' identifier: argument.templateType @@ -540,18 +528,30 @@ parameters: count: 1 path: tests/DependencyInjection/ConfigurationTest.php - - - message: '#^PHPDoc tag @param references unknown parameter\: \$configs$#' - identifier: parameter.notFound - count: 1 - path: tests/DependencyInjection/ConfigurationTest.php - - message: '#^Parameter \#1 \$input of static method Symfony\\Component\\Yaml\\Yaml\:\:parse\(\) expects string, string\|false given\.$#' identifier: argument.type count: 1 path: tests/DependencyInjection/ConfigurationTest.php + - + message: '#^Call to an undefined method Doctrine\\ODM\\MongoDB\\Configuration\:\:getDefaultKmsProvider\(\)\.$#' + identifier: method.notFound + count: 3 + path: tests/DependencyInjection/DoctrineMongoDBExtensionTest.php + + - + message: '#^Call to an undefined method Doctrine\\ODM\\MongoDB\\Configuration\:\:getDefaultMasterKey\(\)\.$#' + identifier: method.notFound + count: 3 + path: tests/DependencyInjection/DoctrineMongoDBExtensionTest.php + + - + message: '#^Call to an undefined method Doctrine\\ODM\\MongoDB\\Configuration\:\:getDriverOptions\(\)\.$#' + identifier: method.notFound + count: 7 + path: tests/DependencyInjection/DoctrineMongoDBExtensionTest.php + - message: '#^Method Doctrine\\Bundle\\MongoDBBundle\\Tests\\DependencyInjection\\DoctrineMongoDBExtensionTest\:\:assertDICDefinitionMethodCall\(\) has parameter \$params with no value type specified in iterable type array\.$#' identifier: missingType.iterableValue diff --git a/phpstan.neon.dist b/phpstan.neon.dist index bb852bd2..03fa1fc3 100644 --- a/phpstan.neon.dist +++ b/phpstan.neon.dist @@ -8,3 +8,7 @@ parameters: - config - src - tests + + ignoreErrors: + - message: '# with generic class Doctrine\\ODM\\MongoDB\\Mapping\\ClassMetadata#' + identifier: missingType.generics diff --git a/phpunit.xml.dist b/phpunit.xml.dist index 50eaaae1..2ef3bdec 100644 --- a/phpunit.xml.dist +++ b/phpunit.xml.dist @@ -20,6 +20,7 @@ + diff --git a/src/Command/Encryption/DiagnosticCommand.php b/src/Command/Encryption/DiagnosticCommand.php new file mode 100644 index 00000000..0c2290f9 --- /dev/null +++ b/src/Command/Encryption/DiagnosticCommand.php @@ -0,0 +1,180 @@ + $diagnostics */ + public function __construct( + private readonly ServiceProviderInterface $diagnostics, + private readonly EncryptionDiagnostic $encryptionDiagnostic = new EncryptionDiagnostic(), + ) { + parent::__construct(); + } + + protected function configure(): void + { + $this->addOption('connection', 'c', InputOption::VALUE_REQUIRED | InputOption::VALUE_IS_ARRAY, 'The name of the connection to diagnose. If not specified, all connections will be diagnosed.', [], $this->getConnectionNames(...)); + } + + protected function execute(InputInterface $input, OutputInterface $output): int + { + $io = new SymfonyStyle($input, $output); + $io->title('MongoDB Encryption Diagnostics'); + + /** @var string[] $connectionNames */ + $connectionNames = $input->getOption('connection'); + if ($connectionNames) { + if (array_diff($connectionNames, $this->getConnectionNames())) { + $io->error('One or more specified connections do not exist. Available connections: ' . implode(', ', $this->getConnectionNames())); + + return Command::INVALID; + } + } else { + $connectionNames = $this->getConnectionNames(); + } + + $configOk = $this->printAndCheckExtensionInfo($io); + $this->printMongocryptdInfo($io); + + foreach ($connectionNames as $name) { + $diagnostic = $this->diagnostics->get($name); + $configOk = $this->printAndCheckConnectionDiagnostic($name, $diagnostic, $io) && $configOk; + } + + if ($configOk) { + $io->success('System looks ok for encryption support.'); + } else { + $io->warning('Not all requirements for encryption support are met. Please check the diagnostics above.'); + } + + return Command::SUCCESS; + } + + /** @return list */ + private function getConnectionNames(): array + { + return array_keys($this->diagnostics->getProvidedServices()); + } + + /** @return bool True if the server is compatible with auto-encryption configuration, false otherwise. */ + private function printAndCheckConnectionDiagnostic(string $name, ConnectionDiagnostic $diagnostic, SymfonyStyle $io): bool + { + $io->section(sprintf('Connection: %s', $name)); + + $autoEncryptionEnabled = $this->printAutoEncryptionConfiguration($io, $diagnostic); + + if (! $autoEncryptionEnabled) { + return true; + } + + return $this->printAndCheckServerInfo($io, $diagnostic); + } + + /** @return bool True if the driver supports auto-encryption, false otherwise */ + private function printAndCheckExtensionInfo(SymfonyStyle $io): bool + { + $io->text('PHP Environment'); + $phpInfo = $this->encryptionDiagnostic->getPhpExtensionInfo(); + $io->listing([ + 'MongoDB extension loaded: ' . ($phpInfo['extensionLoaded'] ? 'Yes' : 'No'), + 'MongoDB extension version: ' . ($phpInfo['extensionVersion'] ?: '[unknown]'), + 'MongoDB extension supports libmongocrypt: ' . ($phpInfo['extensionSupportsLibmongocrypt'] ? 'Yes' : 'No'), + 'MongoDB library version: ' . ($phpInfo['libraryVersion'] ?: '[unknown]'), + ]); + + $extensionOk = $phpInfo['extensionLoaded'] && $phpInfo['extensionSupportsLibmongocrypt']; + + if (! $extensionOk) { + $io->warning('At least one extension requirement is not met. Encryption may not work.'); + } + + return $extensionOk; + } + + private function printMongocryptdInfo(SymfonyStyle $io): void + { + $io->text('mongocryptd information'); + $mongocryptdInfo = $this->encryptionDiagnostic->getMongocryptdInfo(); + + if ($mongocryptdInfo['mongocryptdPath'] === null) { + $io->listing(['mongocryptd: not found']); + } else { + $io->listing([ + 'mongocryptd path: ' . $mongocryptdInfo['mongocryptdPath'], + 'mongocryptd version: ' . ($mongocryptdInfo['mongocryptdVersion'] ?: '[unknown]'), + ]); + } + } + + /** @return bool True if the server supports auto-encryption, false otherwise */ + private function printAndCheckServerInfo(SymfonyStyle $io, ConnectionDiagnostic $diagnostic): bool + { + $io->text('Server Information'); + $serverInfo = $diagnostic->getServerInfo(); + + $io->listing([ + 'Server Version: ' . ($serverInfo['version'] ?? '[unknown]'), + 'Topology: ' . $serverInfo['topologyName'], + ]); + + if (! $serverInfo['versionSupported']) { + $io->warning('This server version does not support encryption.'); + } + + if (! $serverInfo['topologySupported']) { + $io->warning('This topology does not support encryption.'); + } + + return $serverInfo['versionSupported'] && $serverInfo['topologySupported']; + } + + /** @return bool True if the connection uses auto encryption, false otherwise. */ + private function printAutoEncryptionConfiguration(SymfonyStyle $io, ConnectionDiagnostic $diagnostic): bool + { + $io->text('Auto Encryption Configuration'); + if (! $diagnostic->usesAutoEncryption()) { + $io->text('Auto encryption is not enabled for this connection.'); + + return false; + } + + try { + $autoEncryptionInfo = $diagnostic->getAutoEncryptionInfo(); + + $io->listing([ + 'Auto Encryption Enabled: ' . ($autoEncryptionInfo['autoEncryptionEnabled'] ? 'Yes' : 'No'), + 'Key Vault Namespace: ' . $autoEncryptionInfo['keyVaultNamespace'], + 'Key Count: ' . $autoEncryptionInfo['keyCount'], + ]); + } catch (RuntimeException $e) { + // We typically get an error when mongocryptd is not running or not reachable. + $io->error('Failed to retrieve auto encryption information: ' . $e->getMessage()); + } + + return true; + } +} diff --git a/src/Command/Encryption/DumpFieldsMapCommand.php b/src/Command/Encryption/DumpFieldsMapCommand.php new file mode 100644 index 00000000..4df6cbe9 --- /dev/null +++ b/src/Command/Encryption/DumpFieldsMapCommand.php @@ -0,0 +1,137 @@ + $documentManagers */ + public function __construct(private readonly ServiceCollectionInterface $documentManagers) + { + parent::__construct(); + } + + protected function configure(): void + { + $this->addOption( + 'format', + 'f', + InputOption::VALUE_REQUIRED, + 'The output format for the encrypted fields map (yaml, json, php)', + 'yaml', + ['yaml', 'php', 'json'] + ); + } + + protected function execute(InputInterface $input, OutputInterface $output): int + { + $io = new SymfonyStyle($input, $output); + + $format = $input->getOption('format'); + + $dumper = new Dumper(); + + foreach ($this->documentManagers as $name => $documentManager) { + $encryptedFieldsMap = []; + foreach ($documentManager->getMetadataFactory()->getAllMetadata() as $metadata) { + $database = $documentManager->getDocumentDatabase($metadata->getName()); + $collectionInfoIterator = $database->listCollections(['filter' => ['name' => $metadata->getCollection()]]); + + foreach ($collectionInfoIterator as $collectionInfo) { + if ($collectionInfo['options']['encryptedFields'] ?? null) { + $encryptedFieldsMap[$this->getDocumentNamespace($metadata, $database->getDatabaseName())] = $collectionInfo['options']['encryptedFields']; + } + } + } + + if (empty($encryptedFieldsMap)) { + continue; + } + + // The min/max query options must have the same type as the field. + // But the PHP driver always convert to "int" or "float" when the value fit in the range + foreach ($encryptedFieldsMap as $ns => $encryptedFields) { + $fields = json_decode(PackedArray::fromPHP($encryptedFields['fields'])->toCanonicalExtendedJSON(), true, flags: JSON_BIGINT_AS_STRING | JSON_THROW_ON_ERROR); + foreach ($fields as &$field) { + if ($field['bsonType'] === 'long') { + if (isset($field['queries']['min']['$numberInt'])) { + $field['queries']['min'] = ['$numberLong' => $field['queries']['min']['$numberInt']]; + } + + if (isset($field['queries']['max']['$numberInt'])) { + $field['queries']['max'] = ['$numberLong' => $field['queries']['max']['$numberInt']]; + } + } elseif ($field['bsonType'] === 'decimal') { + if (isset($field['queries']['min']['$numberDouble'])) { + $field['queries']['min'] = ['$numberDecimal' => $field['queries']['min']['$numberDouble']]; + } + + if (isset($field['queries']['max']['$numberDouble'])) { + $field['queries']['max'] = ['$numberDecimal' => $field['queries']['max']['$numberDouble']]; + } + } + } + + // Keep only the "fields" key and ignore "escCollection" and "ecocCollection" + $encryptedFieldsMap[$ns] = ['fields' => $fields]; + } + + $io->section(sprintf('Dumping encrypted fields map for document manager "%s"', $name)); + switch ($format) { + case 'yaml': + $outputContent = $dumper->dump($encryptedFieldsMap, 3); + break; + case 'php': + $outputContent = var_export($encryptedFieldsMap, true); + break; + case 'json': + $outputContent = json_encode($encryptedFieldsMap, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR); + break; + default: + $io->error(sprintf('Unknown format "%s"', $format)); + + return Command::FAILURE; + } + + $output->writeln($outputContent, OutputInterface::VERBOSITY_QUIET); + } + + return Command::SUCCESS; + } + + private function getDocumentNamespace(ClassMetadata $metadata, string $defaultDb): string + { + $db = $metadata->getDatabase() ?: $defaultDb ?: 'doctrine'; + + return $db . '.' . $metadata->getCollection(); + } +} diff --git a/src/DataCollector/ConnectionDiagnostic.php b/src/DataCollector/ConnectionDiagnostic.php new file mode 100644 index 00000000..86a9326a --- /dev/null +++ b/src/DataCollector/ConnectionDiagnostic.php @@ -0,0 +1,89 @@ +driverOptions['autoEncryption'])) { + return null; + } + + $autoEncryption = $this->driverOptions['autoEncryption']; + + $clientEncryptionOpts = array_intersect_key($autoEncryption, array_flip(self::CLIENT_ENCRYPTION_OPTION_NAMES)); + $clientEncryption = $this->client->createClientEncryption($clientEncryptionOpts); + + return [ + 'autoEncryptionEnabled' => true, + 'keyVaultNamespace' => $autoEncryption['keyVaultNamespace'], + 'keyCount' => iterator_count($clientEncryption->getKeys()), + ]; + } + + /** @return array{topologyName: string, topologySupported: bool, version: ?string, versionSupported: bool} */ + public function getServerInfo(): array + { + $server = $this->client->getManager()->selectServer(new ReadPreference(ReadPreference::PRIMARY_PREFERRED)); + $buildInfo = $server->executeCommand('admin', new Command(['buildInfo' => 1]))->toArray()[0] ?? null; + + $version = $buildInfo->version ?? null; + + return [ + 'topologyName' => $this->getTopologyType($server), + 'topologySupported' => $server->getType() !== Server::TYPE_STANDALONE, + 'version' => $version, + 'versionSupported' => $version ? version_compare($version, '7.0.0', '>=') : false, + ]; + } + + public function usesAutoEncryption(): bool + { + return isset($this->driverOptions['autoEncryption']); + } + + private function getTopologyType(Server $server): string + { + return match ($server->getType()) { + Server::TYPE_STANDALONE => 'Standalone', + Server::TYPE_MONGOS => 'Sharded Cluster', + Server::TYPE_RS_PRIMARY, + Server::TYPE_RS_SECONDARY, + Server::TYPE_RS_ARBITER => 'Replica Set', + default => 'Unknown', + }; + } +} diff --git a/src/DataCollector/EncryptionDiagnostic.php b/src/DataCollector/EncryptionDiagnostic.php new file mode 100644 index 00000000..3a02b64a --- /dev/null +++ b/src/DataCollector/EncryptionDiagnostic.php @@ -0,0 +1,106 @@ +getExtensionInfoRow('libmongocrypt') !== 'disabled'; + + return [ + 'extensionLoaded' => extension_loaded('mongodb'), + 'extensionVersion' => phpversion('mongodb') ?: null, + 'extensionSupportsLibmongocrypt' => $libmongocryptAvailable, + 'libraryVersion' => InstalledVersions::getPrettyVersion('mongodb/mongodb'), + ]; + } + + /** @return array{mongocryptdPath: ?string, mongocryptdVersion: ?string} */ + public function getMongocryptdInfo(): array + { + $mongocryptdPath = $this->findMongocryptdPath(); + + return [ + 'mongocryptdPath' => $mongocryptdPath, + 'mongocryptdVersion' => $this->getMongocryptdVersion($mongocryptdPath), + ]; + } + + private function findMongocryptdPath(): ?string + { + $paths = explode(':', getenv('PATH') ?: ''); + + foreach ($paths as $path) { + if (file_exists($path . '/mongocryptd')) { + return $path . '/mongocryptd'; + } + } + + return null; + } + + private function getMongocryptdVersion(?string $mongocryptdPath): ?string + { + if ($mongocryptdPath === null) { + return null; + } + + $output = []; + exec(escapeshellarg($mongocryptdPath) . ' --version', $output); + + if (isset($output[0])) { + return trim($output[0]); + } + + return null; + } + + private function getExtensionInfo(): string + { + $extension = new ReflectionExtension('mongodb'); + + ob_start(); + $extension->info(); + $info = ob_get_contents(); + ob_end_clean(); + + return (string) $info; + } + + private function getExtensionInfoRow(string $row): ?string + { + $pattern = sprintf('/^%s(.*)$/m', preg_quote($row . ' => ')); + + if (preg_match($pattern, $this->getExtensionInfo(), $matches) !== 1) { + return null; + } + + return $matches[1]; + } +} diff --git a/src/DependencyInjection/Configuration.php b/src/DependencyInjection/Configuration.php index 72511dad..c4fec903 100644 --- a/src/DependencyInjection/Configuration.php +++ b/src/DependencyInjection/Configuration.php @@ -12,12 +12,15 @@ use Symfony\Component\Config\Definition\ConfigurationInterface; use function count; +use function in_array; use function is_array; use function is_string; use function json_decode; use function method_exists; use function preg_match; +use const JSON_THROW_ON_ERROR; + /** * FrameworkExtension configuration structure. */ @@ -339,6 +342,147 @@ private function addConnectionsSection(ArrayNodeDefinition $rootNode): void ->end() ->end() ->end() + ->arrayNode('autoEncryption') + ->children() + ->booleanNode('bypassAutoEncryption')->end() + ->scalarNode('keyVaultClient')->end() + ->scalarNode('keyVaultNamespace') + ->validate() + ->ifTrue(static fn ($v) => ! preg_match('/^.+\..+$/', $v)) + ->thenInvalid('Invalid keyVaultNamespace format. It should be "database.collection".') + ->end() + ->end() + ->arrayNode('masterKey') + ->prototype('variable')->end() + ->end() + ->arrayNode('kmsProvider') + ->isRequired() + ->children() + ->scalarNode('type') + ->isRequired() + ->validate() + ->ifTrue(static fn ($v) => ! in_array($v, ['aws', 'azure', 'gcp', 'kmip', 'local'], true)) + ->thenInvalid('Invalid KMS provider type "%s". Valid values are "aws", "azure", "gcp", "kmip", or "local".') + ->end() + ->end() + // AWS + ->scalarNode('accessKeyId')->end() + ->scalarNode('secretAccessKey')->end() + ->scalarNode('sessionToken')->end() + // Azure + ->scalarNode('tenantId')->end() + ->scalarNode('clientId')->end() + ->scalarNode('clientSecret')->end() + ->scalarNode('keyVaultEndpoint')->end() + ->scalarNode('identityPlatformEndpoint')->end() + ->scalarNode('keyName')->end() + ->scalarNode('keyVersion')->end() + // GCP + ->scalarNode('email')->end() + ->scalarNode('privateKey')->end() + ->scalarNode('endpoint')->end() + ->scalarNode('projectId')->end() + ->scalarNode('location')->end() + ->scalarNode('keyRing')->end() + // Attribute already present for another KMS type + //->scalarNode('keyName')->end() + //->scalarNode('keyVersion')->end() + // KMIP + //->scalarNode('endpoint')->end() + // Local + ->scalarNode('key')->end() + ->end() + ->end() + ->arrayNode('schemaMap') + ->prototype('variable')->end() + ->end() + ->arrayNode('encryptedFieldsMap') + ->useAttributeAsKey('name', false) + ->beforeNormalization() + ->always(static function ($v) { + // Create a PHP array representation of the Extended BSON that is later + // converted to JSON string to create a BSON document from this JSON. + // This lets the DI dumper transform the parameters in the string and dump it. + if (is_string($v)) { + return json_decode($v, true, 512, JSON_THROW_ON_ERROR); + } + + return $v; + })->end() + ->prototype('array') + ->children() + ->arrayNode('fields') + ->prototype('array') + ->children() + ->scalarNode('path')->isRequired()->cannotBeEmpty()->end() + ->scalarNode('bsonType')->isRequired()->cannotBeEmpty()->end() + ->variableNode('keyId')->isRequired()->cannotBeEmpty()->end() + ->arrayNode('queries') + ->children() + ->scalarNode('queryType')->isRequired()->cannotBeEmpty()->end() + ->variableNode('min')->end() + ->variableNode('max')->end() + ->integerNode('sparsity')->end() + ->integerNode('precision')->end() + ->integerNode('trimFactor')->end() + ->integerNode('contention')->end() + ->end() + ->end() + ->end() + ->end() + ->end() + ->end() + ->end() + ->end() + ->arrayNode('extraOptions') + ->children() + ->scalarNode('mongocryptdURI')->end() + ->booleanNode('mongocryptdBypassSpawn')->end() + ->scalarNode('mongocryptdSpawnPath')->end() + ->arrayNode('mongocryptdSpawnArgs') + ->beforeNormalization() + ->ifString() + ->then(static fn ($v) => [$v]) + ->end() + ->prototype('scalar')->cannotBeEmpty()->end() + ->end() + ->scalarNode('cryptSharedLibPath')->end() + ->booleanNode('cryptSharedLibRequired')->end() + ->end() + ->end() + ->booleanNode('bypassQueryAnalysis')->end() + ->arrayNode('tlsOptions') + ->children() + ->scalarNode('tlsCAFile')->end() + ->scalarNode('tlsCertificateKeyFile')->end() + ->scalarNode('tlsCertificateKeyFilePassword')->end() + ->booleanNode('tlsDisableOCSPEndpointCheck')->end() + ->end() + ->end() + ->end() + ->validate() + ->always(static function ($v) { + // Remove empty arrays for schemaMap, encryptedFieldsMap, extraOptions, tlsOptions + foreach ( + [ + 'masterKey', + 'schemaMap', + 'encryptedFieldsMap', + 'extraOptions', + 'tlsOptions', + ] as $key + ) { + if (! isset($v[$key]) || ! is_array($v[$key]) || count($v[$key]) !== 0) { + continue; + } + + unset($v[$key]); + } + + return $v; + }) + ->end() + ->end() ->end() ->end() ->end() diff --git a/src/DependencyInjection/DoctrineMongoDBExtension.php b/src/DependencyInjection/DoctrineMongoDBExtension.php index e45117af..fc64651a 100644 --- a/src/DependencyInjection/DoctrineMongoDBExtension.php +++ b/src/DependencyInjection/DoctrineMongoDBExtension.php @@ -7,6 +7,7 @@ use Composer\InstalledVersions; use Doctrine\Bundle\MongoDBBundle\Attribute\AsDocumentListener; use Doctrine\Bundle\MongoDBBundle\Attribute\MapDocument; +use Doctrine\Bundle\MongoDBBundle\DataCollector\ConnectionDiagnostic; use Doctrine\Bundle\MongoDBBundle\DependencyInjection\Compiler\FixturesCompilerPass; use Doctrine\Bundle\MongoDBBundle\DependencyInjection\Compiler\ServiceRepositoryCompilerPass; use Doctrine\Bundle\MongoDBBundle\Fixture\ODMFixtureInterface; @@ -26,6 +27,7 @@ use Doctrine\Persistence\Mapping\Driver\MappingDriverChain; use Doctrine\Persistence\Proxy; use InvalidArgumentException; +use MongoDB\BSON\Document as BsonDocument; use MongoDB\Client; use ProxyManager\Proxy\LazyLoadingInterface; use Symfony\Bridge\Doctrine\DependencyInjection\AbstractDoctrineExtension; @@ -46,6 +48,7 @@ use Symfony\Component\Messenger\MessageBusInterface; use Throwable; +use function array_diff_key; use function array_key_first; use function array_merge; use function class_exists; @@ -53,6 +56,7 @@ use function in_array; use function interface_exists; use function is_dir; +use function json_encode; use function method_exists; use function sprintf; @@ -118,7 +122,7 @@ public function load(array $configs, ContainerBuilder $container): void ->setArgument(5, $config['enable_lazy_ghost_objects'] ? Proxy::class : LazyLoadingInterface::class); // load the connections - $this->loadConnections($config['connections'], $container); + $this->loadConnections($config['connections'], $container, $config); $config['document_managers'] = $this->fixManagersAutoMappings($config['document_managers'], $container->getParameter('kernel.bundles')); @@ -129,6 +133,7 @@ public function load(array $configs, ContainerBuilder $container): void $config['default_database'], $container, $config['enable_lazy_ghost_objects'], + $config['connections'], ); if ($config['resolve_target_documents']) { @@ -219,12 +224,13 @@ protected function overrideParameters(array $options, ContainerBuilder $containe /** * Loads the document managers configuration. * - * @param array $dmConfigs An array of document manager configs - * @param string|null $defaultDM The default document manager name - * @param string $defaultDB The default db name - * @param ContainerBuilder $container A ContainerBuilder instance + * @param array $dmConfigs An array of document manager configs + * @param string|null $defaultDM The default document manager name + * @param string $defaultDB The default db name + * @param ContainerBuilder $container A ContainerBuilder instance + * @param array $connections Configuration of connections */ - protected function loadDocumentManagers(array $dmConfigs, string|null $defaultDM, string $defaultDB, ContainerBuilder $container, bool $useLazyGhostObject = false): void + protected function loadDocumentManagers(array $dmConfigs, string|null $defaultDM, string $defaultDB, ContainerBuilder $container, bool $useLazyGhostObject = false, array $connections = []): void { $dms = []; foreach ($dmConfigs as $name => $documentManager) { @@ -235,6 +241,7 @@ protected function loadDocumentManagers(array $dmConfigs, string|null $defaultDM $defaultDB, $container, $useLazyGhostObject, + $connections, ); $dms[$name] = sprintf('doctrine_mongodb.odm.%s_document_manager', $name); } @@ -245,12 +252,13 @@ protected function loadDocumentManagers(array $dmConfigs, string|null $defaultDM /** * Loads a document manager configuration. * - * @param array $documentManager A document manager configuration array - * @param string|null $defaultDM The default document manager name - * @param string $defaultDB The default db name - * @param ContainerBuilder $container A ContainerBuilder instance + * @param array $documentManager A document manager configuration array + * @param string|null $defaultDM The default document manager name + * @param string $defaultDB The default db name + * @param ContainerBuilder $container A ContainerBuilder instance + * @param array $connections Configuration of connections */ - protected function loadDocumentManager(array $documentManager, string|null $defaultDM, string $defaultDB, ContainerBuilder $container, bool $useLazyGhostObject = false): void + protected function loadDocumentManager(array $documentManager, string|null $defaultDM, string $defaultDB, ContainerBuilder $container, bool $useLazyGhostObject = false, array $connections = []): void { $connectionName = $documentManager['connection'] ?? $documentManager['name']; $configurationId = sprintf('doctrine_mongodb.odm.%s_configuration', $documentManager['name']); @@ -284,6 +292,20 @@ protected function loadDocumentManager(array $documentManager, string|null $defa 'setAutoGeneratePersistentCollectionClasses' => '%doctrine_mongodb.odm.auto_generate_persistent_collection_classes%', ]; + if (isset($connections[$connectionName]['autoEncryption'])) { + if (! method_exists(ODMConfiguration::class, 'setAutoEncryption')) { + throw new InvalidArgumentException(sprintf('The "autoEncryption" option requires doctrine/mongodb-odm version 2.12 or higher, "%s" installed.', self::getODMVersion())); + } + + $autoEncryption = $connections[$connectionName]['autoEncryption']; + $methods['setAutoEncryption'] = array_diff_key( + $this->normalizeAutoEncryption($autoEncryption, $defaultDB), + ['kmsProviders' => false], + ); + $methods['setKmsProvider'] = $autoEncryption['kmsProvider']; + $methods['setDefaultMasterKey'] = $autoEncryption['masterKey'] ?? null; + } + if ($useLazyGhostObject) { $methods['setUseLazyGhostObject'] = $useLazyGhostObject; } @@ -382,7 +404,7 @@ protected function loadDocumentManager(array $documentManager, string|null $defa * @param array $config An array of connections configurations * @param ContainerBuilder $container A ContainerBuilder instance */ - protected function loadConnections(array $connections, ContainerBuilder $container): void + protected function loadConnections(array $connections, ContainerBuilder $container, array $config): void { $cons = []; foreach ($connections as $name => $connection) { @@ -399,11 +421,12 @@ protected function loadConnections(array $connections, ContainerBuilder $contain new Definition(ODMConfiguration::class), ); - $odmConnArgs = [ + $driverOptions = $this->normalizeDriverOptions($connection, $config); + $odmConnArgs = [ $connection['server'] ?? null, /* phpcs:ignore Squiz.Arrays.ArrayDeclaration.ValueNoNewline */ $connection['options'] ?? [], - $this->normalizeDriverOptions($connection), + $driverOptions, ]; $odmConnDef = new Definition(Client::class, $odmConnArgs); @@ -411,6 +434,11 @@ protected function loadConnections(array $connections, ContainerBuilder $contain $id = sprintf('doctrine_mongodb.odm.%s_connection', $name); $container->setDefinition($id, $odmConnDef); $cons[$name] = $id; + + // Diagnostic service + $container->register(sprintf('doctrine_mongodb.odm.%s_connection_diagnostic', $name), ConnectionDiagnostic::class) + ->setArguments([new Reference($id), $driverOptions]) + ->addTag('doctrine_mongodb.connection_diagnostic', ['name' => $name]); } $container->setParameter('doctrine_mongodb.odm.connections', $cons); @@ -463,11 +491,12 @@ private function loadEntityValueResolverServices(ContainerBuilder $container, Fi /** * Normalizes the driver options array * - * @param array $connection + * @param array $connection Connection configuration + * @param array $config Full configuration * * @return array */ - private function normalizeDriverOptions(array $connection): array + private function normalizeDriverOptions(array $connection, array $config): array { $driverOptions = $connection['driver_options'] ?? []; $driverOptions['typeMap'] = DocumentManager::CLIENT_TYPEMAP; @@ -476,6 +505,10 @@ private function normalizeDriverOptions(array $connection): array $driverOptions['context'] = new Reference($driverOptions['context']); } + if (isset($connection['autoEncryption'])) { + $driverOptions['autoEncryption'] = $this->normalizeAutoEncryption($connection['autoEncryption'], $config['default_database']); + } + $driverOptions['driver'] = [ 'name' => 'symfony-mongodb', 'version' => self::getODMVersion(), @@ -484,6 +517,54 @@ private function normalizeDriverOptions(array $connection): array return $driverOptions; } + /** + * Prepare the auto encryption configuration for the connection. + * + * @param array $autoEncryption The AutoEncryption configuration of a connection + * @param string $defaultDB The default database name + * + * @return array + */ + private function normalizeAutoEncryption(array $autoEncryption, string $defaultDB): array + { + if (! isset($autoEncryption['kmsProvider']['type'])) { + throw new InvalidArgumentException('The "kmsProvider" option must contain a "type" key.'); + } + + $provider = $autoEncryption['kmsProvider']['type']; + $providerOpts = array_diff_key($autoEncryption['kmsProvider'], ['type' => true]); + // To use "Automatic Credentials", the provider options must be an empty document. + // Fix the empty array to an empty stdClass object, as the driver expects it. + if ($providerOpts === []) { + $providerOpts = new Definition('stdClass'); + } + + $autoEncryption['kmsProviders'] = [$provider => $providerOpts]; + + if (isset($autoEncryption['tlsOptions'])) { + $autoEncryption['tlsOptions'] = [$provider => $autoEncryption['tlsOptions']]; + } + + unset($autoEncryption['kmsProvider']); + unset($autoEncryption['masterKey']); + + if (isset($autoEncryption['keyVaultClient'])) { + $autoEncryption['keyVaultClient'] = new Reference($autoEncryption['keyVaultClient']); + } + + $autoEncryption['keyVaultNamespace'] ??= $defaultDB . '.datakeys'; + + if (isset($autoEncryption['encryptedFieldsMap'])) { + foreach ($autoEncryption['encryptedFieldsMap'] as &$value) { + // Wrap the encrypted fields in a 'fields' key as required the encryptedFieldsMap structure. + // Some values can be BSON binary, date or numbers, the extended JSON format is used to convert them BSON document. + $value = (new Definition(BsonDocument::class))->setFactory([BsonDocument::class, 'fromJSON'])->setArguments([json_encode($value)]); + } + } + + return $autoEncryption; + } + /** * Loads an ODM document managers bundle mapping information. * diff --git a/tests/Command/Encryption/DiagnosticCommandTest.php b/tests/Command/Encryption/DiagnosticCommandTest.php new file mode 100644 index 00000000..ac3a6e77 --- /dev/null +++ b/tests/Command/Encryption/DiagnosticCommandTest.php @@ -0,0 +1,29 @@ +find('doctrine:mongodb:encryption:diagnostic'); + $commandTester = new CommandTester($command); + $commandTester->execute([]); + + $output = $commandTester->getDisplay(); + $this->assertStringContainsString('MongoDB extension loaded', $output); + $this->assertStringContainsString('mongocryptd', $output); + $this->assertStringContainsString('Connection: default', $output); + $this->assertStringContainsString('Auto encryption is not enabled for this connection.', $output); + } +} diff --git a/tests/DependencyInjection/ConfigurationTest.php b/tests/DependencyInjection/ConfigurationTest.php index b3be8720..a0d8df49 100644 --- a/tests/DependencyInjection/ConfigurationTest.php +++ b/tests/DependencyInjection/ConfigurationTest.php @@ -13,6 +13,7 @@ use Doctrine\ODM\MongoDB\Configuration as ODMConfiguration; use Doctrine\ODM\MongoDB\Repository\DefaultGridFSRepository; use Doctrine\ODM\MongoDB\Repository\DocumentRepository; +use Generator; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\Group; use PHPUnit\Framework\TestCase; @@ -23,6 +24,7 @@ use Symfony\Component\Yaml\Yaml; use function array_key_exists; +use function array_merge; use function file_get_contents; use function method_exists; @@ -127,6 +129,145 @@ public function testFullConfiguration(array $config): void 'wTimeoutMS' => 1000, ], 'driver_options' => ['context' => 'conn1_context_service'], + 'autoEncryption' => [ + 'kmsProvider' => [ + 'type' => 'aws', + 'accessKeyId' => 'MONGODB_AWS_ACCESS_KEY_ID', + 'secretAccessKey' => 'MONGODB_AWS_SECRET_ACCESS_KEY', + 'sessionToken' => 'MONGODB_AWS_SESSION_TOKEN', + ], + 'masterKey' => ['key' => 'MONGODB_AWS_MASTER_KEY'], + 'keyVaultClient' => 'my_key_vault_client_service', + 'keyVaultNamespace' => 'encryption.__keyVault', + 'tlsOptions' => [ + 'tlsCAFile' => '%kernel.project_dir%/config/certificates/mongodb-ca.pem', + 'tlsCertificateKeyFile' => '%kernel.project_dir%/config/certificates/mongodb-client.pem', + 'tlsCertificateKeyFilePassword' => 'MONGODB_TLS_CERTIFICATE_KEY_FILE_PASSWORD', + 'tlsDisableOCSPEndpointCheck' => false, + ], + 'bypassAutoEncryption' => true, + 'bypassQueryAnalysis' => true, + 'encryptedFieldsMap' => [ + 'encrypted.RangeTypes' => [ + 'fields' => [ + [ + 'keyId' => ['$binary' => ['base64' => 'lhZHItpvRkqXevh4Wtqg/g==', 'subType' => '04']], + 'path' => 'intField', + 'bsonType' => 'int', + 'queries' => ['queryType' => 'range', 'contention' => 8, 'min' => 5, 'max' => 10], + ], + [ + 'keyId' => ['$binary' => ['base64' => 'qd9PEKIPTE2J30ev29lMpQ==', 'subType' => '04']], + 'path' => 'floatField', + 'bsonType' => 'double', + 'queries' => ['queryType' => 'range', 'contention' => 8, 'min' => 5.5, 'max' => 10.5, 'precision' => 1], + ], + [ + 'keyId' => ['$binary' => ['base64' => 'zVLg8CF4RSSu4xn7x7dOyQ==', 'subType' => '04']], + 'path' => 'decimalField', + 'bsonType' => 'decimal', + 'queries' => [ + 'queryType' => 'range', + 'contention' => 8, + 'min' => ['$numberDecimal' => '0.1'], + 'max' => ['$numberDecimal' => '0.2'], + 'precision' => 2, + ], + ], + [ + 'keyId' => ['$binary' => ['base64' => 'ySdd8lZ2QBqnwKPJTp/yLA==', 'subType' => '04']], + 'path' => 'immutableDateField', + 'bsonType' => 'date', + 'queries' => [ + 'queryType' => 'range', + 'contention' => 8, + 'min' => ['$date' => '2000-01-01T00:00:00Z'], + 'max' => ['$date' => '2100-01-01T00:00:00Z'], + ], + ], + [ + 'keyId' => ['$binary' => ['base64' => 'NWKI+DyES/OlNkUbJbWJ9w==', 'subType' => '04']], + 'path' => 'dateField', + 'bsonType' => 'date', + ], + [ + 'keyId' => ['$binary' => ['base64' => 'wiiv+0K/QAquyEq3HDxRKw==', 'subType' => '04']], + 'path' => 'binField', + 'bsonType' => 'binData', + ], + [ + 'keyId' => ['$binary' => ['base64' => '2CSosXLSTEKaYphcSnUuCw==', 'subType' => '04']], + 'path' => 'timestampField', + 'bsonType' => 'timestamp', + ], + [ + 'keyId' => ['$binary' => ['base64' => 'h3H6HdG3T5CK+Z2yQ4Ho+Q==', 'subType' => '04']], + 'path' => 'hashField', + 'bsonType' => 'object', + ], + [ + 'keyId' => ['$binary' => ['base64' => 'X78UZZ/HTX2wLw4K3uG42w==', 'subType' => '04']], + 'path' => 'collectionField', + 'bsonType' => 'objectId', + ], + [ + 'keyId' => ['$binary' => ['base64' => 'LugQL/ZXTJOl856Yacmkwg==', 'subType' => '04']], + 'path' => 'boolField', + 'bsonType' => 'bool', + ], + ], + ], + 'encrypted.patients' => [ + 'fields' => [ + [ + 'keyId' => ['$binary' => ['base64' => 'GH25/XvYSaCgTUQLAo1hQw==', 'subType' => '04']], + 'path' => 'pathologies', + 'bsonType' => 'array', + ], + [ + 'keyId' => ['$binary' => ['base64' => 'krVWyFlNTUOaGFMfk+s7UA==', 'subType' => '04']], + 'path' => 'patientRecord.billing', + 'bsonType' => 'object', + ], + [ + 'keyId' => ['$binary' => ['base64' => 'X1ZaSI1GSAKnZ+sPGcmYBA==', 'subType' => '04']], + 'path' => 'patientRecord.billingAmount', + 'bsonType' => 'int', + 'queries' => [ + 'queryType' => 'range', + 'contention' => 8, + 'min' => 100, + 'max' => 2000, + 'sparsity' => 1, + 'trimFactor' => 4, + ], + ], + ], + ], + 'encrypted.client' => [ + 'fields' => [ + [ + 'keyId' => ['$binary' => ['base64' => 'I0Aw18vnRGWzVS1t3uejpQ==', 'subType' => '04']], + 'path' => 'name', + 'bsonType' => 'string', + ], + [ + 'keyId' => ['$binary' => ['base64' => 'XSPRK3vaTLmMZr9IEj/qwQ==', 'subType' => '04']], + 'path' => 'clientCards', + 'bsonType' => 'array', + ], + ], + ], + ], + 'extraOptions' => [ + 'mongocryptdURI' => 'mongodb://localhost:27020', + 'mongocryptdBypassSpawn' => true, + 'mongocryptdSpawnPath' => '%kernel.project_dir%/bin/mongocryptd', + 'mongocryptdSpawnArgs' => ['--pidfilepath=%kernel.project_dir%/var/mongocryptd.pid', '--idleShutdownTimeoutSecs=60'], + 'cryptSharedLibPath' => '%kernel.project_dir%/bin/mongo_crypt_v1.dylib', + 'cryptSharedLibRequired' => true, + ], + ], ], 'conn2' => ['server' => 'mongodb://otherhost'], ], @@ -228,8 +369,8 @@ public static function provideFullConfiguration(): array $xml = XmlUtils::convertDomElementToArray($xml->getElementsByTagName('config')->item(0)); return [ - [$yaml], - [$xml], + 'yaml' => [$yaml], + 'xml' => [$xml], ]; } @@ -355,7 +496,7 @@ public static function provideMergeOptions(): array } /** - * @param array $configs A configuration array to process + * @param array $config A configuration array to process * @param array $expected Array of key/value options expected in the processed configuration */ #[DataProvider('provideNormalizeOptions')] @@ -370,13 +511,11 @@ public function testNormalizeOptions(array $config, array $expected): void } } - /** @return array */ - public static function provideNormalizeOptions(): array + /** @return Generator, 1: array}> */ + public static function provideNormalizeOptions(): Generator { - $cases = []; - // connection versus connections (id is the identifier) - $cases[] = [ + yield [ [ 'connection' => [ ['server' => 'mongodb://abc', 'id' => 'foo'], @@ -392,7 +531,7 @@ public static function provideNormalizeOptions(): array ]; // document_manager versus document_managers (id is the identifier) - $cases[] = [ + yield [ [ 'document_manager' => [ ['connection' => 'conn1', 'id' => 'foo'], @@ -408,7 +547,7 @@ public static function provideNormalizeOptions(): array ]; // mapping configuration that's beneath a specific document manager - $cases[] = [ + yield [ [ 'document_manager' => [ [ @@ -441,7 +580,112 @@ public static function provideNormalizeOptions(): array ], ]; - return $cases; + // Encrypted Field Map can be a JSON string in a + yield [ + [ + 'connection' => [ + [ + 'server' => 'mongodb://abc', + 'id' => 'foo', + 'autoEncryption' => [ + 'kmsProvider' => ['type' => 'local', 'key' => '1234567890123456789012345678901234567890123456789012345678901234'], + 'encryptedFieldsMap' => <<<'JSON' + { + "encrypted.patients": { + "fields": [ + { + "keyId": { "$binary": { "base64": "GH25/XvYSaCgTUQLAo1hQw==", "subType": "04" } }, + "path": "pathologies", + "bsonType": "array" + }, + { + "keyId": { "$binary": { "base64": "krVWyFlNTUOaGFMfk+s7UA==", "subType": "04" } }, + "path": "patientRecord.billing", + "bsonType": "object" + }, + { + "keyId": { "$binary": { "base64": "X1ZaSI1GSAKnZ+sPGcmYBA==", "subType": "04" } }, + "path": "patientRecord.billingAmount", + "bsonType": "int", + "queries": { "queryType": "range", "contention": 8, "min": 100, "max": 2000, "sparsity": 1, "trimFactor": 4 } + } + ] + }, + "encrypted.client": { + "fields": [ + { + "keyId": { "$binary": { "base64": "I0Aw18vnRGWzVS1t3uejpQ==", "subType": "04" } }, + "path": "name", + "bsonType": "string" + }, + { + "keyId": { "$binary": { "base64": "XSPRK3vaTLmMZr9IEj/qwQ==", "subType": "04" } }, + "path": "clientCards", + "bsonType": "array" + } + ] + } + } + JSON, + ], + ], + ], + ], + [ + 'connections' => [ + 'foo' => [ + 'server' => 'mongodb://abc', + 'autoEncryption' => [ + 'kmsProvider' => ['type' => 'local', 'key' => '1234567890123456789012345678901234567890123456789012345678901234'], + 'encryptedFieldsMap' => [ + 'encrypted.patients' => [ + 'fields' => [ + [ + 'keyId' => ['$binary' => ['base64' => 'GH25/XvYSaCgTUQLAo1hQw==', 'subType' => '04']], + 'path' => 'pathologies', + 'bsonType' => 'array', + ], + [ + 'keyId' => ['$binary' => ['base64' => 'krVWyFlNTUOaGFMfk+s7UA==', 'subType' => '04']], + 'path' => 'patientRecord.billing', + 'bsonType' => 'object', + ], + [ + 'keyId' => ['$binary' => ['base64' => 'X1ZaSI1GSAKnZ+sPGcmYBA==', 'subType' => '04']], + 'path' => 'patientRecord.billingAmount', + 'bsonType' => 'int', + 'queries' => [ + 'queryType' => 'range', + 'contention' => 8, + 'min' => 100, + 'max' => 2000, + 'sparsity' => 1, + 'trimFactor' => 4, + ], + ], + ], + ], + 'encrypted.client' => [ + 'fields' => [ + [ + 'keyId' => ['$binary' => ['base64' => 'I0Aw18vnRGWzVS1t3uejpQ==', 'subType' => '04']], + 'path' => 'name', + 'bsonType' => 'string', + ], + [ + 'keyId' => ['$binary' => ['base64' => 'XSPRK3vaTLmMZr9IEj/qwQ==', 'subType' => '04']], + 'path' => 'clientCards', + 'bsonType' => 'array', + ], + ], + ], + ], + ], + + ], + ], + ], + ]; } public function testPasswordAndUsernameShouldBeUnsetIfNull(): void @@ -517,4 +761,63 @@ public function testNullReplicaSetValue(): void $processedConfig = $processor->processConfiguration($configuration, [$config]); $this->assertFalse(array_key_exists('replicaSet', $processedConfig['connections']['conn1']['options'])); } + + /** + * @param array $config + * + * @return array + */ + protected function processConfiguration(array $config): array + { + $processor = new Processor(); + $configuration = new Configuration(); + + return $processor->processConfiguration($configuration, [$this->getMinimalValidConfig($config)]); + } + + /** + * @param array $config + * + * @return array + */ + protected function getMinimalValidConfig(array $config = []): array + { + $baseConfig = [ + 'connections' => [ + 'default' => [ + 'driver_options' => [], // Placeholder for autoEncryption or other options + ], + ], + 'document_managers' => [ + 'default' => [], + ], + ]; + + // Deep merge config into baseConfig + if (isset($config['connections']['default']['driver_options'])) { + $baseConfig['connections']['default']['driver_options'] = array_merge( + $baseConfig['connections']['default']['driver_options'], + $config['connections']['default']['driver_options'], + ); + unset($config['connections']['default']['driver_options']); + } + + if (isset($config['connections']['default'])) { + $baseConfig['connections']['default'] = array_merge( + $baseConfig['connections']['default'], + $config['connections']['default'], + ); + unset($config['connections']['default']); + } + + if (isset($config['connections'])) { + $baseConfig['connections'] = array_merge( + $baseConfig['connections'], + $config['connections'], + ); + unset($config['connections']); + } + + return array_merge($baseConfig, $config); + } } diff --git a/tests/DependencyInjection/DoctrineMongoDBExtensionTest.php b/tests/DependencyInjection/DoctrineMongoDBExtensionTest.php index e12925fb..fb3841be 100644 --- a/tests/DependencyInjection/DoctrineMongoDBExtensionTest.php +++ b/tests/DependencyInjection/DoctrineMongoDBExtensionTest.php @@ -8,11 +8,16 @@ use Composer\InstalledVersions; use Composer\Semver\VersionParser; use Doctrine\Bundle\MongoDBBundle\Attribute\MapDocument; +use Doctrine\Bundle\MongoDBBundle\DependencyInjection\Compiler\ServiceRepositoryCompilerPass; use Doctrine\Bundle\MongoDBBundle\DependencyInjection\DoctrineMongoDBExtension; use Doctrine\Bundle\MongoDBBundle\Tests\DependencyInjection\Fixtures\Bundles\DocumentListenerBundle\EventListener\TestAttributeListener; +use Doctrine\ODM\MongoDB\Configuration; use Doctrine\ODM\MongoDB\Mapping\Annotations; +use InvalidArgumentException; +use MongoDB\Client; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\TestCase; +use stdClass; use Symfony\Bridge\Doctrine\Messenger\DoctrineClearEntityManagerWorkerSubscriber; use Symfony\Component\DependencyInjection\Alias; use Symfony\Component\DependencyInjection\ChildDefinition; @@ -23,6 +28,7 @@ use Symfony\Component\DependencyInjection\Reference; use Symfony\Component\Messenger\MessageBusInterface; +use function array_diff_key; use function array_merge; use function class_exists; use function interface_exists; @@ -51,6 +57,7 @@ public function buildMinimalContainer(): ContainerBuilder return new ContainerBuilder(new ParameterBag([ 'kernel.root_dir' => __DIR__, 'kernel.project_dir' => __DIR__, + 'kernel.cache_dir' => sys_get_temp_dir() . '/doctrine_mongodb_odm_bundle', 'kernel.name' => 'kernel', 'kernel.environment' => 'test', 'kernel.debug' => 'true', @@ -479,4 +486,222 @@ public function testUseTransactionalFlush(): void $configuration->getMethodCalls(), ); } + + public function testAutoEncryptionWithKeyVaultClientService(): void + { + self::requireAutoEncryptionSupportInODM(); + + $container = $this->buildMinimalContainer(); + $loader = new DoctrineMongoDBExtension(); + + // Define a dummy service for the keyVaultClient + $dummyServiceId = 'my_key_vault_client_service'; + $container->setDefinition($dummyServiceId, new Definition(Client::class)); + + $config = [ + 'connections' => [ + 'default' => [ + 'autoEncryption' => [ + 'keyVaultNamespace' => 'db.vault', + 'keyVaultClient' => $dummyServiceId, + 'kmsProvider' => ['type' => 'local', 'key' => 'base64_encoded_key'], + ], + ], + ], + 'document_managers' => ['default' => []], + ]; + + $loader->load([$config], $container); + (new ServiceRepositoryCompilerPass())->process($container); + + $clientDef = $container->getDefinition('doctrine_mongodb.odm.default_connection'); + $driverOptions = $clientDef->getArgument(2); + + self::assertArrayHasKey('autoEncryption', $driverOptions); + self::assertInstanceOf(Reference::class, $driverOptions['autoEncryption']['keyVaultClient']); + self::assertEquals($dummyServiceId, (string) $driverOptions['autoEncryption']['keyVaultClient']); + self::assertEquals('db.vault', $driverOptions['autoEncryption']['keyVaultNamespace']); + self::assertEquals(['local' => ['key' => 'base64_encoded_key']], $driverOptions['autoEncryption']['kmsProviders']); + + // Auto encryption configuration should be set in the ODM configuration + $odmConfiguration = $container->get('doctrine_mongodb.odm.default_configuration'); + self::assertInstanceOf(Configuration::class, $odmConfiguration); + self::assertSame('local', $odmConfiguration->getDefaultKmsProvider()); + self::assertNull($odmConfiguration->getDefaultMasterKey()); + self::assertArrayHasKey('autoEncryption', $odmConfiguration->getDriverOptions()); + self::assertInstanceOf(Client::class, $odmConfiguration->getDriverOptions()['autoEncryption']['keyVaultClient']); + + // Ensure the driver option set in the client matches the ODM configuration + // except for the keyVaultClient, which is a service reference + self::assertEquals( + array_diff_key($driverOptions['autoEncryption'], ['keyVaultClient' => false]), + array_diff_key($odmConfiguration->getDriverOptions()['autoEncryption'], ['keyVaultClient' => false]), + ); + } + + public function testAutoEncryptionWithComplexKmsAndSchemaMap(): void + { + self::requireAutoEncryptionSupportInODM(); + + $container = $this->buildMinimalContainer(); + $loader = new DoctrineMongoDBExtension(); + + $schemaMap = [ + 'db.coll.users' => [ + 'bsonType' => 'object', + 'encryptMetadata' => ['keyId' => '/dataKeyId'], + 'properties' => ['ssn' => ['encrypt' => ['bsonType' => 'string', 'algorithm' => 'AEAD_AES_256_CBC_HMAC_SHA_512-Deterministic']]], + ], + ]; + $masterKey = [ + 'region' => 'eu-west-3', + 'key' => 'arn:aws:kms:eu-west-3:123456789012:key/abcd1234-a123-456a-a12b-a123b4cd56ef', + ]; + $config = [ + 'connections' => [ + 'default' => [ + 'autoEncryption' => [ + 'keyVaultNamespace' => 'db.vault', + 'kmsProvider' => ['type' => 'aws', 'accessKeyId' => 'test', 'secretAccessKey' => 'secret'], + 'schemaMap' => $schemaMap, + 'masterKey' => $masterKey, + ], + ], + ], + 'document_managers' => ['default' => []], + ]; + + $loader->load([$config], $container); + (new ServiceRepositoryCompilerPass())->process($container); + + $clientDef = $container->getDefinition('doctrine_mongodb.odm.default_connection'); + $driverOptions = $clientDef->getArgument(2); + + self::assertArrayHasKey('autoEncryption', $driverOptions); + self::assertEquals(['aws' => ['accessKeyId' => 'test', 'secretAccessKey' => 'secret']], $driverOptions['autoEncryption']['kmsProviders']); + self::assertEquals($schemaMap, $driverOptions['autoEncryption']['schemaMap']); + self::assertEquals('db.vault', $driverOptions['autoEncryption']['keyVaultNamespace']); + + // Auto encryption configuration should be set in the ODM configuration + $odmConfiguration = $container->get('doctrine_mongodb.odm.default_configuration'); + self::assertInstanceOf(Configuration::class, $odmConfiguration); + self::assertSame('aws', $odmConfiguration->getDefaultKmsProvider()); + self::assertSame($masterKey, $odmConfiguration->getDefaultMasterKey()); + self::assertArrayHasKey('autoEncryption', $odmConfiguration->getDriverOptions()); + + // Ensure the driver option set in the client matches the ODM configuration + self::assertEquals($driverOptions['autoEncryption'], $odmConfiguration->getDriverOptions()['autoEncryption']); + } + + public function testAutoEncryptionWithExtraOptions(): void + { + self::requireAutoEncryptionSupportInODM(); + + $container = $this->buildMinimalContainer(); + $loader = new DoctrineMongoDBExtension(); + + $config = [ + 'connections' => [ + 'default' => [ + 'autoEncryption' => [ + 'keyVaultNamespace' => 'db.vault', + 'kmsProvider' => ['type' => 'local', 'key' => 'base64_encoded_key'], + 'extraOptions' => [ + 'cryptSharedLibPath' => '/another/path.so', + 'cryptSharedLibRequired' => false, + 'mongocryptdSpawnPath' => '/custom/mongocryptd', + ], + ], + ], + ], + 'document_managers' => ['default' => []], + ]; + + $loader->load([$config], $container); + (new ServiceRepositoryCompilerPass())->process($container); + + $clientDef = $container->getDefinition('doctrine_mongodb.odm.default_connection'); + $driverOptions = $clientDef->getArgument(2); + + self::assertArrayHasKey('autoEncryption', $driverOptions); + self::assertEquals('/another/path.so', $driverOptions['autoEncryption']['extraOptions']['cryptSharedLibPath']); + self::assertFalse($driverOptions['autoEncryption']['extraOptions']['cryptSharedLibRequired']); + self::assertEquals('/custom/mongocryptd', $driverOptions['autoEncryption']['extraOptions']['mongocryptdSpawnPath']); + + self::assertArrayHasKey('typeMap', $driverOptions); // Default option + self::assertArrayHasKey('driver', $driverOptions); // Added by normalizeDriverOptions + self::assertEquals('symfony-mongodb', $driverOptions['driver']['name']); + self::assertArrayHasKey('version', $driverOptions['driver']); + + // Auto encryption configuration should be set in the ODM configuration + $odmConfiguration = $container->get('doctrine_mongodb.odm.default_configuration'); + self::assertInstanceOf(Configuration::class, $odmConfiguration); + self::assertSame('local', $odmConfiguration->getDefaultKmsProvider()); + self::assertNull($odmConfiguration->getDefaultMasterKey()); + self::assertArrayHasKey('autoEncryption', $odmConfiguration->getDriverOptions()); + + // Ensure the driver option set in the client matches the ODM configuration + self::assertEquals($driverOptions['autoEncryption'], $odmConfiguration->getDriverOptions()['autoEncryption']); + } + + public function testAutoEncryptionWithEmptyKmsProvider(): void + { + self::requireAutoEncryptionSupportInODM(); + + $container = $this->buildMinimalContainer(); + $loader = new DoctrineMongoDBExtension(); + + $config = [ + 'connections' => [ + 'default' => [ + 'autoEncryption' => [ + 'keyVaultNamespace' => 'db.vault', + 'kmsProvider' => ['type' => 'aws'], + ], + ], + ], + 'document_managers' => ['default' => []], + ]; + + $loader->load([$config], $container); + (new ServiceRepositoryCompilerPass())->process($container); + + $clientDef = $container->getDefinition('doctrine_mongodb.odm.default_connection'); + $driverOptions = $clientDef->getArgument(2); + + self::assertArrayHasKey('autoEncryption', $driverOptions); + self::assertEquals(['aws' => new Definition(stdClass::class)], $driverOptions['autoEncryption']['kmsProviders']); + } + + public function testAutoEncryptionMinimumODMVersion(): void + { + if (InstalledVersions::satisfies(new VersionParser(), 'doctrine/mongodb-odm', '>=2.12@dev')) { + self::markTestSkipped('Installed version of doctrine/mongodb-odm does support auto encryption'); + } + + $container = $this->buildMinimalContainer(); + $loader = new DoctrineMongoDBExtension(); + + $config = [ + 'connections' => [ + 'default' => [ + 'autoEncryption' => [ + 'kmsProvider' => ['type' => 'aws'], + ], + ], + ], + 'document_managers' => ['default' => []], + ]; + + self::expectException(InvalidArgumentException::class); + self::expectExceptionMessage('The "autoEncryption" option requires doctrine/mongodb-odm version 2.12 or higher'); + $loader->load([$config], $container); + } + + private static function requireAutoEncryptionSupportInODM(): void + { + if (! InstalledVersions::satisfies(new VersionParser(), 'doctrine/mongodb-odm', '>=2.12@dev')) { + self::markTestSkipped('Installed version of doctrine/mongodb-odm does not support auto encryption'); + } + } } diff --git a/tests/DependencyInjection/Fixtures/config/xml/full.xml b/tests/DependencyInjection/Fixtures/config/xml/full.xml index 363038c3..f76ee3ed 100644 --- a/tests/DependencyInjection/Fixtures/config/xml/full.xml +++ b/tests/DependencyInjection/Fixtures/config/xml/full.xml @@ -7,8 +7,8 @@ http://symfony.com/schema/dic/doctrine/odm/mongodb http://symfony.com/schema/dic/doctrine/odm/mongodb/mongodb-1.0.xsd"> + + + + my_key_vault_client_service + encryption.__keyVault + + + + + + --pidfilepath=%kernel.project_dir%/var/mongocryptd.pid + --idleShutdownTimeoutSecs=60 + + diff --git a/tests/DependencyInjection/Fixtures/config/yml/full.yml b/tests/DependencyInjection/Fixtures/config/yml/full.yml index 5b3e8ef9..d8bc2a29 100644 --- a/tests/DependencyInjection/Fixtures/config/yml/full.yml +++ b/tests/DependencyInjection/Fixtures/config/yml/full.yml @@ -55,6 +55,90 @@ doctrine_mongodb: wTimeoutMS: 1000 driver_options: context: conn1_context_service + autoEncryption: + kmsProvider: + type: 'aws' + accessKeyId: 'MONGODB_AWS_ACCESS_KEY_ID' + secretAccessKey: 'MONGODB_AWS_SECRET_ACCESS_KEY' + sessionToken: 'MONGODB_AWS_SESSION_TOKEN' + masterKey: + key: 'MONGODB_AWS_MASTER_KEY' + keyVaultNamespace: 'encryption.__keyVault' + keyVaultClient: 'my_key_vault_client_service' + tlsOptions: + tlsCAFile: '%kernel.project_dir%/config/certificates/mongodb-ca.pem' + tlsCertificateKeyFile: '%kernel.project_dir%/config/certificates/mongodb-client.pem' + tlsCertificateKeyFilePassword: 'MONGODB_TLS_CERTIFICATE_KEY_FILE_PASSWORD' + tlsDisableOCSPEndpointCheck: false + bypassAutoEncryption: true + bypassQueryAnalysis: true + encryptedFieldsMap: + encrypted.RangeTypes: + fields: + - keyId: { $binary: { base64: lhZHItpvRkqXevh4Wtqg/g==, subType: '04' } } + path: intField + bsonType: int + queries: { queryType: range, contention: 8, min: 5, max: 10 } + - keyId: { $binary: { base64: qd9PEKIPTE2J30ev29lMpQ==, subType: '04' } } + path: floatField + bsonType: double + queries: { queryType: range, contention: 8, min: 5.5, max: 10.5, precision: 1 } + - keyId: { $binary: { base64: zVLg8CF4RSSu4xn7x7dOyQ==, subType: '04' } } + path: decimalField + bsonType: decimal + queries: { queryType: range, contention: 8, min: { $numberDecimal: '0.1' }, max: { $numberDecimal: '0.2' }, precision: 2 } + - keyId: { $binary: { base64: ySdd8lZ2QBqnwKPJTp/yLA==, subType: '04' } } + path: immutableDateField + bsonType: date + queries: { queryType: range, contention: 8, min: { $date: '2000-01-01T00:00:00Z' }, max: { $date: '2100-01-01T00:00:00Z' } } + - keyId: { $binary: { base64: NWKI+DyES/OlNkUbJbWJ9w==, subType: '04' } } + path: dateField + bsonType: date + - keyId: { $binary: { base64: wiiv+0K/QAquyEq3HDxRKw==, subType: '04' } } + path: binField + bsonType: binData + - keyId: { $binary: { base64: 2CSosXLSTEKaYphcSnUuCw==, subType: '04' } } + path: timestampField + bsonType: timestamp + - keyId: { $binary: { base64: h3H6HdG3T5CK+Z2yQ4Ho+Q==, subType: '04' } } + path: hashField + bsonType: object + - keyId: { $binary: { base64: X78UZZ/HTX2wLw4K3uG42w==, subType: '04' } } + path: collectionField + bsonType: objectId + - keyId: { $binary: { base64: LugQL/ZXTJOl856Yacmkwg==, subType: '04' } } + path: boolField + bsonType: bool + encrypted.patients: + fields: + - keyId: { $binary: { base64: GH25/XvYSaCgTUQLAo1hQw==, subType: '04' } } + path: pathologies + bsonType: array + - keyId: { $binary: { base64: krVWyFlNTUOaGFMfk+s7UA==, subType: '04' } } + path: patientRecord.billing + bsonType: object + - keyId: { $binary: { base64: X1ZaSI1GSAKnZ+sPGcmYBA==, subType: '04' } } + path: patientRecord.billingAmount + bsonType: int + queries: { queryType: range, contention: 8, min: 100, max: 2000, sparsity: 1, trimFactor: 4 } + encrypted.client: + fields: + - keyId: { $binary: { base64: I0Aw18vnRGWzVS1t3uejpQ==, subType: '04' } } + path: name + bsonType: string + - keyId: { $binary: { base64: XSPRK3vaTLmMZr9IEj/qwQ==, subType: '04' } } + path: clientCards + bsonType: array + extraOptions: + mongocryptdURI: 'mongodb://localhost:27020' + mongocryptdBypassSpawn: true + mongocryptdSpawnPath: '%kernel.project_dir%/bin/mongocryptd' + mongocryptdSpawnArgs: + - '--pidfilepath=%kernel.project_dir%/var/mongocryptd.pid' + - '--idleShutdownTimeoutSecs=60' + cryptSharedLibPath: '%kernel.project_dir%/bin/mongo_crypt_v1.dylib' + cryptSharedLibRequired: true + conn2: server: mongodb://otherhost diff --git a/tests/TestCase.php b/tests/TestCase.php index b24e0e14..13ffb25d 100644 --- a/tests/TestCase.php +++ b/tests/TestCase.php @@ -7,9 +7,11 @@ use Doctrine\ODM\MongoDB\Configuration; use Doctrine\ODM\MongoDB\DocumentManager; use Doctrine\ODM\MongoDB\Mapping\Driver\AttributeDriver; +use MongoDB\Client; use PHPUnit\Framework\TestCase as BaseTestCase; use Symfony\Component\Cache\Adapter\ArrayAdapter; +use function getenv; use function sys_get_temp_dir; class TestCase extends BaseTestCase @@ -25,7 +27,8 @@ public static function createTestDocumentManager(array $paths = []): DocumentMan $config->setHydratorNamespace('SymfonyTests\Doctrine'); $config->setMetadataDriverImpl(new AttributeDriver($paths)); $config->setMetadataCache(new ArrayAdapter()); + $uri = getenv('DOCTRINE_MONGODB_SERVER') ?: 'mongodb://localhost:27017'; - return DocumentManager::create(null, $config); + return DocumentManager::create(new Client($uri), $config); } }