diff --git a/.github/workflows/composer-lint.yml b/.github/workflows/composer-lint.yml index 08bbce8a..4c1dabd1 100644 --- a/.github/workflows/composer-lint.yml +++ b/.github/workflows/composer-lint.yml @@ -10,6 +10,7 @@ on: push: branches: - "*.x" + - "feature/*" paths: - "composer.json" diff --git a/config/command.php b/config/command.php index 0a7e12d0..9b064fa3 100644 --- a/config/command.php +++ b/config/command.php @@ -6,6 +6,7 @@ use Doctrine\Bundle\MongoDBBundle\Command\ConnectionDiagnosticCommand; use Doctrine\Bundle\MongoDBBundle\Command\CreateSchemaDoctrineODMCommand; use Doctrine\Bundle\MongoDBBundle\Command\DropSchemaDoctrineODMCommand; +use Doctrine\Bundle\MongoDBBundle\Command\DumpEncryptedFieldsMapCommand; use Doctrine\Bundle\MongoDBBundle\Command\GenerateHydratorsDoctrineODMCommand; use Doctrine\Bundle\MongoDBBundle\Command\GenerateProxiesDoctrineODMCommand; use Doctrine\Bundle\MongoDBBundle\Command\InfoDoctrineODMCommand; @@ -27,6 +28,10 @@ ->tag('console.command', ['command' => 'doctrine:mongodb:connection:diagnostic']) ->args([tagged_locator('doctrine_mongodb.connection_diagnostic', 'name')]) + ->set('doctrine_mongodb.odm.command.dump_encrypted_fields_map', DumpEncryptedFieldsMapCommand::class) + ->tag('console.command', ['command' => 'doctrine:mongodb:dump-encrypted-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/docs/encryption.rst b/docs/encryption.rst index cbf4c47e..5be85afd 100644 --- a/docs/encryption.rst +++ b/docs/encryption.rst @@ -76,10 +76,23 @@ Example of configuration for AWS key: "arn:aws:kms:eu-west-1:123456789012:key/abcd1234-12ab-34cd-56ef-1234567890ab" -Queryable Encryption (QE) -------------------------- +Encrypted Fields Map +-------------------- -Queryable Encryption (QE) allows you to run queries on encrypted fields. To use QE, you may need to provide an ``encryptedFieldsMap`` or use a schema map, depending on your driver and use case. +You can configure which fields are encrypted in each collection by specifying the +``autoEncryption.encryptedFieldsMap`` option in the connection configuration. +This setting is **recommended** for improved security and performance. + +- If the connection ``encryptedFieldsMap`` object contains a key for the specified + collection, 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, 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:: @@ -133,6 +146,51 @@ Queryable Encryption (QE) allows you to run queries on encrypted fields. To use ]); }; +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 ----------- @@ -221,3 +279,5 @@ 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 ef9043d8..8a0884eb 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 @@ -120,42 +114,12 @@ parameters: count: 1 path: src/Command/UpdateSchemaDoctrineODMCommand.php - - - message: '#^Expression on left side of \?\? is not nullable\.$#' - identifier: nullCoalesce.expr - count: 1 - path: src/DataCollector/ConnectionDiagnostic.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: '#^Method Doctrine\\Bundle\\MongoDBBundle\\DataCollector\\ConnectionDiagnostic\:\:getAutoEncryptionInfo\(\) return type has no value type specified in iterable type array\.$#' - identifier: missingType.iterableValue - count: 1 - path: src/DataCollector/ConnectionDiagnostic.php - - - - message: '#^Method Doctrine\\Bundle\\MongoDBBundle\\DataCollector\\ConnectionDiagnostic\:\:getPhpExtensionInfo\(\) return type has no value type specified in iterable type array\.$#' - identifier: missingType.iterableValue - count: 1 - path: src/DataCollector/ConnectionDiagnostic.php - - - - message: '#^Method Doctrine\\Bundle\\MongoDBBundle\\DataCollector\\ConnectionDiagnostic\:\:getServerInfo\(\) return type has no value type specified in iterable type array\.$#' - identifier: missingType.iterableValue - count: 1 - path: src/DataCollector/ConnectionDiagnostic.php - - - - message: '#^Unreachable statement \- code above always terminates\.$#' - identifier: deadCode.unreachable - count: 1 - path: src/DataCollector/ConnectionDiagnostic.php - - message: '#^Cannot cast array\|bool\|float\|int\|string\|UnitEnum\|null to string\.$#' identifier: cast.string @@ -372,12 +336,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 @@ -420,12 +378,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 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/src/Command/ConnectionDiagnosticCommand.php b/src/Command/ConnectionDiagnosticCommand.php index 0f6ed4c7..7dce530c 100644 --- a/src/Command/ConnectionDiagnosticCommand.php +++ b/src/Command/ConnectionDiagnosticCommand.php @@ -5,6 +5,8 @@ namespace Doctrine\Bundle\MongoDBBundle\Command; use Doctrine\Bundle\MongoDBBundle\DataCollector\ConnectionDiagnostic; +use Doctrine\Bundle\MongoDBBundle\DataCollector\EncryptionDiagnostic; +use MongoDB\Driver\Exception\RuntimeException; use Symfony\Component\Console\Attribute\AsCommand; use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Input\InputInterface; @@ -12,7 +14,6 @@ use Symfony\Component\Console\Output\OutputInterface; use Symfony\Component\Console\Style\SymfonyStyle; use Symfony\Contracts\Service\ServiceProviderInterface; -use Throwable; use function array_diff; use function array_keys; @@ -27,8 +28,10 @@ final class ConnectionDiagnosticCommand extends Command { /** @param ServiceProviderInterface $diagnostics */ - public function __construct(private readonly ServiceProviderInterface $diagnostics) - { + public function __construct( + private readonly ServiceProviderInterface $diagnostics, + private readonly EncryptionDiagnostic $encryptionDiagnostic = new EncryptionDiagnostic(), + ) { parent::__construct(); } @@ -54,59 +57,18 @@ protected function execute(InputInterface $input, OutputInterface $output): int $connectionNames = $this->getConnectionNames(); } + $configOk = $this->printAndCheckExtensionInfo($io); + $this->printMongocryptdInfo($io); + foreach ($connectionNames as $name) { $diagnostic = $this->diagnostics->get($name); - $io->section(sprintf('Connection: %s', $name)); - - $io->text('PHP Environment'); - try { - $phpInfo = $diagnostic->getPhpExtensionInfo(); - $io->listing([ - 'ext-mongodb loaded: ' . ($phpInfo['ext-mongodb loaded'] ? 'Yes' : 'No'), - 'ext-mongodb version: ' . ($phpInfo['ext-mongodb version'] ?: '[unknown]'), - 'library version: ' . ($phpInfo['library version'] ?: '[unknown]'), - ]); - } catch (Throwable $exception) { - $io->error('Could not retrieve PHP extension info: ' . $exception->getMessage()); - } - - $io->text('Server Information'); - try { - $serverInfo = $diagnostic->getServerInfo(); - $io->listing([ - 'MongoDB Version: ' . ($serverInfo['version'] ?? '[unknown]'), - 'Modules: ' . (isset($serverInfo['modules']) ? implode(', ', $serverInfo['modules']) : '[unknown]'), - 'crypt_shared version: ' . ($serverInfo['crypt_shared_version'] ?? '[unknown]'), - 'crypt_shared path: ' . ($serverInfo['crypt_shared_path'] ?? '[unknown]'), - 'Topology: ' . ($serverInfo['topology'] ?? '[unknown]'), - ]); - } catch (Throwable $exception) { - $io->error('Could not retrieve server info: ' . $exception->getMessage()); - } - - $io->text('Auto Encryption Configuration'); - try { - $autoEncryptionInfo = $diagnostic->getAutoEncryptionInfo(); - if ($autoEncryptionInfo) { - $io->listing([ - 'Auto Encryption Enabled: ' . ($autoEncryptionInfo['autoEncryption enabled'] ? 'Yes' : 'No'), - 'Key Vault Namespace: ' . $autoEncryptionInfo['keyVaultNamespace'], - 'Key Count: ' . $autoEncryptionInfo['keyCount'], - ]); - } else { - $io->text('No auto encryption configuration found for this connection.'); - } - } catch (Throwable $exception) { - $io->error('Could not retrieve auto encryption info: ' . $exception->getMessage()); - } + $configOk = $this->printAndCheckConnectionDiagnostic($name, $diagnostic, $io) && $configOk; + } - $mongocryptdVersion = $diagnostic->getMongocryptdVersion(); - if ($mongocryptdVersion) { - $io->text('mongocryptd Version'); - $io->text($mongocryptdVersion); - } else { - $io->text('mongocryptd not found'); - } + 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; @@ -117,4 +79,102 @@ 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/DumpEncryptedFieldsMapCommand.php b/src/Command/DumpEncryptedFieldsMapCommand.php new file mode 100644 index 00000000..f2c999dc --- /dev/null +++ b/src/Command/DumpEncryptedFieldsMapCommand.php @@ -0,0 +1,116 @@ + $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, 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) { + $generator = new EncryptedFieldsMapGenerator($documentManager->getMetadataFactory()); + $encryptedFieldsMap = $generator->getEncryptedFieldsMap(); + + if (empty($encryptedFieldsMap)) { + continue; + } + + $encryptedFieldsMap = array_combine( + // Convert class names in keys to their full namespaces + array_map( + fn (string $fqcn): string => $this->getDocumentNamespace( + $documentManager->getClassMetadata($fqcn), + $documentManager->getConfiguration()->getDefaultDB(), + ), + array_keys($encryptedFieldsMap), + ), + array_values($encryptedFieldsMap), + ); + + $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; + } + + $io->block($outputContent); + } + + return Command::SUCCESS; + } + + private function getDocumentNamespace(ClassMetadata $metadata, string $defaultDb): string + { + $db = $metadata->getDatabase(); + $db = $db ?: $defaultDb; + $db = $db ?: 'doctrine'; + + return $db . '.' . $metadata->getCollection(); + } +} diff --git a/src/DataCollector/ConnectionDiagnostic.php b/src/DataCollector/ConnectionDiagnostic.php index 9c6ae8bd..59bda32c 100644 --- a/src/DataCollector/ConnectionDiagnostic.php +++ b/src/DataCollector/ConnectionDiagnostic.php @@ -4,56 +4,44 @@ namespace Doctrine\Bundle\MongoDBBundle\DataCollector; -use Composer\InstalledVersions; use MongoDB\Client; use MongoDB\Driver\Command; use MongoDB\Driver\ReadPreference; +use MongoDB\Driver\Server; -use function dd; -use function exec; -use function explode; -use function extension_loaded; -use function file_exists; -use function getenv; -use function phpversion; -use function trim; - -// Check if mongocryptd (enterprise installed locally) or crypt_shared is available -// Install openssl +use function array_flip; +use function array_intersect_key; +use function in_array; +use function iterator_count; +use function version_compare; + +/** @internal */ class ConnectionDiagnostic { - public function __construct(private Client $client, private array $driverOptions) - { - } - - public function getServerInfo(): array - { - $server = $this->client->getManager()->selectServer(new ReadPreference(ReadPreference::PRIMARY)); - $buildInfo = $server->executeCommand('admin', new Command(['buildInfo' => 1]))->toArray()[0] ?? null; - // command not supported for auto encryption: serverStatus - // $serverStatus = $server->executeCommand('admin', new Command(['serverStatus' => 1]))->toArray()[0] ?? null; - - return [ - 'version' => $buildInfo->version ?? null, - 'modules' => $buildInfo->modules ?? null, - //'crypt_shared_version' => $serverStatus->crypt_shared ?? null, - //'crypt_shared_path' => $serverStatus->crypt_shared_path ?? null, - 'topology' => $server->getType() ?? null, - ]; - } - - public function getPhpExtensionInfo(): array - { - return [ - 'ext-mongodb loaded' => extension_loaded('mongodb'), - 'ext-mongodb version' => phpversion('mongodb') ?: null, - 'library version' => InstalledVersions::getPrettyVersion('mongodb/mongodb'), - ]; + private const CLIENT_ENCRYPTION_OPTION_NAMES = [ + 'keyVaultClient', + 'keyVaultNamespace', + 'kmsProviders', + 'tlsOptions', + ]; + + private const SUPPORTED_SERVER_TYPES = [ + Server::TYPE_MONGOS, + Server::TYPE_RS_PRIMARY, + Server::TYPE_RS_SECONDARY, + ]; + + public function __construct( + private readonly Client $client, + private readonly array $driverOptions, + ) { } /** * Get the list of auto encryption providers configured for the MongoDB client * and an indication of whether the configuration is valid. + * + * @return array{autoEncryptionEnabled: bool, keyVaultNamespace: string, keyCount: int}|null */ public function getAutoEncryptionInfo(): ?array { @@ -63,47 +51,46 @@ public function getAutoEncryptionInfo(): ?array $autoEncryption = $this->driverOptions['autoEncryption']; - // Check if the "keyVaultNamespace" collection exists and is properly formatted - $keyVaultNamespace = explode('.', $autoEncryption['keyVaultNamespace'], 2); - $keyCount = $this->client->getCollection($keyVaultNamespace[0], $keyVaultNamespace[1])->countDocuments(); - $clientEncryption = $this->client->createClientEncryption([]); - $clientEncryption->getKeys(); - dd($clientEncryption->getKeys()); + $clientEncryptionOpts = array_intersect_key($autoEncryption, array_flip(self::CLIENT_ENCRYPTION_OPTION_NAMES)); + $clientEncryption = $this->client->createClientEncryption($clientEncryptionOpts); return [ - 'autoEncryption enabled' => true, + 'autoEncryptionEnabled' => true, 'keyVaultNamespace' => $autoEncryption['keyVaultNamespace'], - 'keyCount' => $keyCount, + 'keyCount' => iterator_count($clientEncryption->getKeys()), ]; } - public function getMongocryptdVersion(): ?string + /** @return array{topologyName: string, topologySupported: bool, version: ?string, versionSupported: bool} */ + public function getServerInfo(): array { - $mongocryptdPath = $this->findMongocryptdPath(); - if ($mongocryptdPath === null) { - return null; - } - - $output = []; - exec($mongocryptdPath . ' --version', $output); + $server = $this->client->getManager()->selectServer(new ReadPreference(ReadPreference::PRIMARY_PREFERRED)); + $buildInfo = $server->executeCommand('admin', new Command(['buildInfo' => 1]))->toArray()[0] ?? null; - if (isset($output[0])) { - return trim($output[0]); - } + $version = $buildInfo->version ?? null; - return null; + return [ + 'topologyName' => $this->getTopologyType($server), + 'topologySupported' => in_array($server->getType(), self::SUPPORTED_SERVER_TYPES), + 'version' => $version, + 'versionSupported' => $version ? version_compare($version, '8.0.0', '>=') : false, + ]; } - private function findMongocryptdPath(): ?string + public function usesAutoEncryption(): bool { - $paths = explode(':', getenv('PATH') ?: ''); - - foreach ($paths as $path) { - if (file_exists($path . '/mongocryptd')) { - return $path . '/mongocryptd'; - } - } + return isset($this->driverOptions['autoEncryption']); + } - return null; + 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..1d55bcea --- /dev/null +++ b/src/DataCollector/EncryptionDiagnostic.php @@ -0,0 +1,105 @@ +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($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/DoctrineMongoDBExtension.php b/src/DependencyInjection/DoctrineMongoDBExtension.php index c21bcabc..595e5147 100644 --- a/src/DependencyInjection/DoctrineMongoDBExtension.php +++ b/src/DependencyInjection/DoctrineMongoDBExtension.php @@ -182,22 +182,6 @@ public function load(array $configs, ContainerBuilder $container): void $this->loadMessengerServices($container, $loader); $this->loadEntityValueResolverServices($container, $loader, $config); - - // Register EncryptionDiagnostics for each connection - $diagnosticsRefs = []; - foreach ($config['connections'] as $connName => $connConfig) { - $connService = sprintf('doctrine_mongodb.odm.%s_connection', $connName); - $driverOptions = $connConfig['driver_options'] ?? []; - $diagServiceId = sprintf('doctrine_mongodb.encryption_diagnostics.%s', $connName); - $container->setDefinition( - $diagServiceId, - new Definition(ConnectionDiagnostic::class, [ - new Reference($connService), // Use the connection service, which is a MongoDB\Client - $driverOptions, - ]), - ); - $diagnosticsRefs[$connName] = new Reference($diagServiceId); - } } /** diff --git a/tests/Command/ConnectionDiagnosticCommandTest.php b/tests/Command/ConnectionDiagnosticCommandTest.php new file mode 100644 index 00000000..0948236c --- /dev/null +++ b/tests/Command/ConnectionDiagnosticCommandTest.php @@ -0,0 +1,28 @@ +find('doctrine:mongodb:connection: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 4ca9cbae..3cb843f5 100644 --- a/tests/DependencyInjection/ConfigurationTest.php +++ b/tests/DependencyInjection/ConfigurationTest.php @@ -176,7 +176,7 @@ public function testFullConfiguration(array $config): void 'mongocryptdBypassSpawn' => true, 'mongocryptdSpawnPath' => '%kernel.project_dir%/bin/mongocryptd', 'mongocryptdSpawnArgs' => '--pidfilepath=%kernel.project_dir%/var/mongocryptd.pid --idleShutdownTimeoutSecs=60', - 'cryptSharedLibPath' => '%kernel.project_dir%/bin/libmongocrypt.so', + 'cryptSharedLibPath' => '%kernel.project_dir%/bin/mongo_crypt_v1.dylib', ], ], ], diff --git a/tests/DependencyInjection/Fixtures/config/xml/full.xml b/tests/DependencyInjection/Fixtures/config/xml/full.xml index b6f6701d..65cdbdb6 100644 --- a/tests/DependencyInjection/Fixtures/config/xml/full.xml +++ b/tests/DependencyInjection/Fixtures/config/xml/full.xml @@ -106,7 +106,7 @@ mongocryptdBypassSpawn="true" mongocryptdSpawnPath="%kernel.project_dir%/bin/mongocryptd" mongocryptdSpawnArgs="--pidfilepath=%kernel.project_dir%/var/mongocryptd.pid --idleShutdownTimeoutSecs=60" - cryptSharedLibPath="%kernel.project_dir%/bin/libmongocrypt.so" + cryptSharedLibPath="%kernel.project_dir%/bin/mongo_crypt_v1.dylib" /> diff --git a/tests/DependencyInjection/Fixtures/config/yml/full.yml b/tests/DependencyInjection/Fixtures/config/yml/full.yml index 7b1614fb..e2f4215c 100644 --- a/tests/DependencyInjection/Fixtures/config/yml/full.yml +++ b/tests/DependencyInjection/Fixtures/config/yml/full.yml @@ -90,7 +90,7 @@ doctrine_mongodb: mongocryptdBypassSpawn: true mongocryptdSpawnPath: '%kernel.project_dir%/bin/mongocryptd' mongocryptdSpawnArgs: '--pidfilepath=%kernel.project_dir%/var/mongocryptd.pid --idleShutdownTimeoutSecs=60' - cryptSharedLibPath: '%kernel.project_dir%/bin/libmongocrypt.so' + cryptSharedLibPath: '%kernel.project_dir%/bin/mongo_crypt_v1.dylib' conn2: server: mongodb://otherhost