From 3acd685bd03314cb84059e359b1db7a15727d40a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=A9r=C3=B4me=20Tamarelle?= Date: Thu, 12 Jun 2025 15:01:10 +0200 Subject: [PATCH 01/22] Add support for Field-Level Automatic and Queryable Encryption (#2759) * Add test document classes from tutorial https://github.com/mongodb/docs/blob/master/source/includes/qe-tutorials/node/queryable-encryption-tutorial.js * Create encryption field map * Create encrypted collection * Use promoted properties for encrypted document tests * Revert changes in BaseTestCase * Rename EncryptionFieldMapTest * Create Configuration::getDriverOptions() to create the client * Support class-level #[Encrypt] attribute * Skip QE tests on non-supported server configuration * Add documentation on #[Encrypt] attribute * Add XML mapping for "encrypt" tag * Create specific xsd type for embedded documents to enable tag * Fix import class Throwable * Fix access to $version property before initialization * Use an enum for query type * Make getClientEncryption internal * Improve type of min/max bounds for range queries * Use getWriteOptions with createEncryptedCollection * Use random local master key * Add assertion on non-decrypted data * Ignore all DOCTRINE_MONGODB_DATABASE phpstan errors * Baseline phpstan * Fix CS and skip phpstan issues --- docs/en/reference/attributes-reference.rst | 40 ++++++ doctrine-mongo-mapping.xsd | 25 ++++ lib/Doctrine/ODM/MongoDB/Configuration.php | 94 ++++++++++++- lib/Doctrine/ODM/MongoDB/DocumentManager.php | 42 +++--- .../MongoDB/Mapping/Annotations/Encrypt.php | 47 +++++++ .../Mapping/Annotations/EncryptQuery.php | 13 ++ .../ODM/MongoDB/Mapping/ClassMetadata.php | 7 + .../Mapping/Driver/AttributeDriver.php | 6 +- .../ODM/MongoDB/Mapping/Driver/XmlDriver.php | 24 ++++ .../ODM/MongoDB/Mapping/MappingException.php | 18 +++ lib/Doctrine/ODM/MongoDB/SchemaManager.php | 28 +++- .../MongoDB/Utility/EncryptionFieldMap.php | 84 ++++++++++++ phpstan-baseline.neon | 129 ++++++++++-------- phpstan.neon.dist | 3 + .../ODM/MongoDB/Tests/BaseTestCase.php | 19 +++ .../Functional/QueryableEncryptionTest.php | 99 ++++++++++++++ .../Mapping/Driver/AbstractDriverTestCase.php | 70 ++++++++++ .../Documents.Encryption.ClientCard.dcm.xml | 14 ++ ...Documents.Encryption.PatientRecord.dcm.xml | 20 +++ .../Documents.Encryption.RangeTypes.dcm.xml | 27 ++++ .../Tests/Tools/EncryptionFieldMapTest.php | 116 ++++++++++++++++ tests/Documents/Encryption/Client.php | 25 ++++ tests/Documents/Encryption/ClientCard.php | 20 +++ .../Encryption/InvalidRootEncrypt.php | 21 +++ tests/Documents/Encryption/Patient.php | 24 ++++ tests/Documents/Encryption/PatientBilling.php | 19 +++ tests/Documents/Encryption/PatientRecord.php | 27 ++++ tests/Documents/Encryption/RangeTypes.php | 42 ++++++ 28 files changed, 1020 insertions(+), 83 deletions(-) create mode 100644 lib/Doctrine/ODM/MongoDB/Mapping/Annotations/Encrypt.php create mode 100644 lib/Doctrine/ODM/MongoDB/Mapping/Annotations/EncryptQuery.php create mode 100644 lib/Doctrine/ODM/MongoDB/Utility/EncryptionFieldMap.php create mode 100644 tests/Doctrine/ODM/MongoDB/Tests/Functional/QueryableEncryptionTest.php create mode 100644 tests/Doctrine/ODM/MongoDB/Tests/Mapping/Driver/fixtures/xml/Documents.Encryption.ClientCard.dcm.xml create mode 100644 tests/Doctrine/ODM/MongoDB/Tests/Mapping/Driver/fixtures/xml/Documents.Encryption.PatientRecord.dcm.xml create mode 100644 tests/Doctrine/ODM/MongoDB/Tests/Mapping/Driver/fixtures/xml/Documents.Encryption.RangeTypes.dcm.xml create mode 100644 tests/Doctrine/ODM/MongoDB/Tests/Tools/EncryptionFieldMapTest.php create mode 100644 tests/Documents/Encryption/Client.php create mode 100644 tests/Documents/Encryption/ClientCard.php create mode 100644 tests/Documents/Encryption/InvalidRootEncrypt.php create mode 100644 tests/Documents/Encryption/Patient.php create mode 100644 tests/Documents/Encryption/PatientBilling.php create mode 100644 tests/Documents/Encryption/PatientRecord.php create mode 100644 tests/Documents/Encryption/RangeTypes.php diff --git a/docs/en/reference/attributes-reference.rst b/docs/en/reference/attributes-reference.rst index 5e8d8d408..fb8d53ebb 100644 --- a/docs/en/reference/attributes-reference.rst +++ b/docs/en/reference/attributes-reference.rst @@ -336,6 +336,46 @@ Unlike normal documents, embedded documents cannot specify their own database or collection. That said, a single embedded document class may be used with multiple document classes, and even other embedded documents! +#[Encrypt] +---------- + +The ``#[Encrypt]`` attribute is used to define an encrypted field mapping for a +document property. It allows you to configure fields for encryption and queryable +encryption in MongoDB. + +Optional arguments: + +- ``queryType`` - Specifies the query type for the field. Possible values: + - ``null`` (default) - Field is not queryable. + - ``EncryptQuery::Equality`` - Enables equality queries. + - ``EncryptQuery::Range`` - Enables range queries. +- ``min``, ``max`` - Specify minimum and maximum (inclusive) queryable values + for a field when possible, as smaller bounds improve query efficiency. If + querying values outside of these bounds, MongoDB returns an error. +- ``sparsity``, ``prevision``, ``trimFactor``, ``contention`` - For advanced + users only. The default values for these options are suitable for the majority + of use cases, and should only be modified if your use case requires it. + +Example: + +.. code-block:: php + + `_. + #[Field] -------- diff --git a/doctrine-mongo-mapping.xsd b/doctrine-mongo-mapping.xsd index 6a17933ba..29d7b04ab 100644 --- a/doctrine-mongo-mapping.xsd +++ b/doctrine-mongo-mapping.xsd @@ -31,6 +31,7 @@ + @@ -183,7 +184,31 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/lib/Doctrine/ODM/MongoDB/Configuration.php b/lib/Doctrine/ODM/MongoDB/Configuration.php index aa8a6d457..5e4d28b9d 100644 --- a/lib/Doctrine/ODM/MongoDB/Configuration.php +++ b/lib/Doctrine/ODM/MongoDB/Configuration.php @@ -24,6 +24,7 @@ use Doctrine\Persistence\Mapping\Driver\MappingDriver; use Doctrine\Persistence\ObjectRepository; use InvalidArgumentException; +use Jean85\PrettyVersions; use LogicException; use MongoDB\Driver\WriteConcern; use ProxyManager\Configuration as ProxyManagerConfiguration; @@ -32,10 +33,15 @@ use ProxyManager\GeneratorStrategy\FileWriterGeneratorStrategy; use Psr\Cache\CacheItemPoolInterface; use ReflectionClass; +use Throwable; use function array_key_exists; +use function array_key_first; use function class_exists; +use function count; use function interface_exists; +use function is_array; +use function is_string; use function trigger_deprecation; use function trim; @@ -50,6 +56,11 @@ * $dm = DocumentManager::create(new Connection(), $config); * * @phpstan-import-type CommitOptions from UnitOfWork + * @phpstan-type AutoEncryptionOptions array{ + * keyVaultNamespace: string, + * kmsProviders: array>, + * tlsOptions?: array{kmip: array{tlsCAFile: string, tlsCertificateKeyFile: string}}, + * } */ class Configuration { @@ -121,7 +132,8 @@ class Configuration * persistentCollectionNamespace?: string, * proxyDir?: string, * proxyNamespace?: string, - * repositoryFactory?: RepositoryFactory + * repositoryFactory?: RepositoryFactory, + * autoEncryption?: AutoEncryptionOptions, * } */ private array $attributes = []; @@ -135,6 +147,29 @@ class Configuration private bool $useLazyGhostObject = false; + private static string $version; + + /** + * Provides the driver options to be used when creating the MongoDB client. + * + * @return array + */ + public function getDriverOptions(): array + { + $driverOptions = [ + 'driver' => [ + 'name' => 'doctrine-odm', + 'version' => self::getVersion(), + ], + ]; + + if (isset($this->attributes['autoEncryption'])) { + $driverOptions['autoEncryption'] = $this->attributes['autoEncryption']; + } + + return $driverOptions; + } + /** * Adds a namespace under a certain alias. */ @@ -651,6 +686,63 @@ public function isLazyGhostObjectEnabled(): bool { return $this->useLazyGhostObject; } + + /** + * Set the options for auto-encryption. + * + * @see https://www.php.net/manual/en/mongodb-driver-clientencryption.construct.php + * + * @phpstan-param AutoEncryptionOptions $options + * + * @throws InvalidArgumentException If the options are invalid. + */ + public function setAutoEncryption(array $options): void + { + if (! isset($options['keyVaultNamespace']) || ! is_string($options['keyVaultNamespace'])) { + throw new InvalidArgumentException('The "keyVaultNamespace" option is required.'); + } + + // @todo Throw en exception if multiple KMS providers are defined. This is not supported yet and would require a setting for the KMS provider to use when creating a new collection + if (! isset($options['kmsProviders']) || ! is_array($options['kmsProviders']) || count($options['kmsProviders']) < 1) { + throw new InvalidArgumentException('The "kmsProviders" option is required.'); + } + + $this->attributes['autoEncryption'] = $options; + } + + /** + * Get the options for auto-encryption. + * + * @see https://www.php.net/manual/en/mongodb-driver-clientencryption.construct.php + * + * @phpstan-return AutoEncryptionOptions + */ + public function getAutoEncryption(): ?array + { + return $this->attributes['autoEncryption'] ?? null; + } + + public function getKmsProvider(): ?string + { + if (! isset($this->attributes['autoEncryption'])) { + return null; + } + + return array_key_first($this->attributes['autoEncryption']['kmsProviders']); + } + + private static function getVersion(): string + { + if (! isset(self::$version)) { + try { + self::$version = PrettyVersions::getVersion('doctrine/mongodb-odm')->getPrettyVersion(); + } catch (Throwable) { + return self::$version = 'unknown'; + } + } + + return self::$version; + } } interface_exists(MappingDriver::class); diff --git a/lib/Doctrine/ODM/MongoDB/DocumentManager.php b/lib/Doctrine/ODM/MongoDB/DocumentManager.php index 462188a6f..197578fa6 100644 --- a/lib/Doctrine/ODM/MongoDB/DocumentManager.php +++ b/lib/Doctrine/ODM/MongoDB/DocumentManager.php @@ -25,10 +25,10 @@ use Doctrine\Persistence\ObjectManager; use Doctrine\Persistence\ObjectRepository; use InvalidArgumentException; -use Jean85\PrettyVersions; use MongoDB\Client; use MongoDB\Collection; use MongoDB\Database; +use MongoDB\Driver\ClientEncryption; use MongoDB\Driver\ReadPreference; use MongoDB\GridFS\Bucket; use ProxyManager\Proxy\GhostObjectInterface; @@ -64,6 +64,8 @@ class DocumentManager implements ObjectManager */ private Client $client; + private ClientEncryption $clientEncryption; + /** * The used Configuration. */ @@ -138,8 +140,6 @@ class DocumentManager implements ObjectManager /** @var ProxyClassNameResolver&ClassNameResolver */ private ProxyClassNameResolver $classNameResolver; - private static ?string $version = null; - /** * Creates a new Document that operates on the given Mongo connection * and uses the given Configuration. @@ -151,12 +151,7 @@ protected function __construct(?Client $client = null, ?Configuration $config = $this->client = $client ?: new Client( 'mongodb://127.0.0.1', [], - [ - 'driver' => [ - 'name' => 'doctrine-odm', - 'version' => self::getVersion(), - ], - ], + $this->config->getDriverOptions(), ); $this->classNameResolver = $this->config->isLazyGhostObjectEnabled() @@ -225,6 +220,22 @@ public function getClient(): Client return $this->client; } + /** @internal */ + public function getClientEncryption(): ClientEncryption + { + $autoEncryptionOptions = $this->config->getAutoEncryption(); + + if (! $autoEncryptionOptions) { + throw new RuntimeException('Auto-encryption is not enabled.'); + } + + return $this->clientEncryption ??= $this->client->createClientEncryption([ + 'keyVaultNamespace' => $autoEncryptionOptions['keyVaultNamespace'], + 'kmsProviders' => $autoEncryptionOptions['kmsProviders'], + 'tlsOptions' => $autoEncryptionOptions['tlsOptions'] ?? [], + ]); + } + /** Gets the metadata factory used to gather the metadata of classes. */ public function getMetadataFactory(): ClassmetadataFactoryInterface { @@ -923,17 +934,4 @@ public function getClassNameForAssociation(array $mapping, $data): string return $mapping['targetDocument']; } - - private static function getVersion(): string - { - if (self::$version === null) { - try { - self::$version = PrettyVersions::getVersion('doctrine/mongodb-odm')->getPrettyVersion(); - } catch (Throwable) { - return 'unknown'; - } - } - - return self::$version; - } } diff --git a/lib/Doctrine/ODM/MongoDB/Mapping/Annotations/Encrypt.php b/lib/Doctrine/ODM/MongoDB/Mapping/Annotations/Encrypt.php new file mode 100644 index 000000000..8a5a51a35 --- /dev/null +++ b/lib/Doctrine/ODM/MongoDB/Mapping/Annotations/Encrypt.php @@ -0,0 +1,47 @@ +|null $sparsity + * @param positive-int|null $prevision + * @param positive-int|null $trimFactor + * @param positive-int|null $contention + */ + public function __construct( + public ?EncryptQuery $queryType = null, + int|float|Int64|Decimal128|UTCDateTime|DateTimeInterface|null $min = null, + int|float|Int64|Decimal128|UTCDateTime|DateTimeInterface|null $max = null, + public ?int $sparsity = null, + public ?int $prevision = null, + public ?int $trimFactor = null, + public ?int $contention = null, + ) { + $this->min = $min instanceof DateTimeInterface ? new UTCDateTime($min) : $min; + $this->max = $max instanceof DateTimeInterface ? new UTCDateTime($max) : $max; + } +} diff --git a/lib/Doctrine/ODM/MongoDB/Mapping/Annotations/EncryptQuery.php b/lib/Doctrine/ODM/MongoDB/Mapping/Annotations/EncryptQuery.php new file mode 100644 index 000000000..71b5ea6d0 --- /dev/null +++ b/lib/Doctrine/ODM/MongoDB/Mapping/Annotations/EncryptQuery.php @@ -0,0 +1,13 @@ +, + * encrypt?: array{queryType?: ?string, min?: mixed, max?: mixed, sparsity?: int<1, 4>, prevision?: int, trimFactor?: int, contention?: int} * } * @phpstan-type FieldMapping array{ * type: string, @@ -153,6 +154,7 @@ * alsoLoadFields?: list, * enumType?: class-string, * storeEmptyArray?: bool, + * encrypt?: array{queryType?: ?string, min?: mixed, max?: mixed, sparsity?: int<1, 4>, prevision?: int, trimFactor?: int, contention?: int}, * } * @phpstan-type AssociationFieldMapping array{ * type?: string, @@ -801,6 +803,11 @@ */ public $isReadOnly; + /** + * READ-ONLY: A flag for whether or not this document has encrypted fields. + */ + public bool $isEncrypted = false; + /** READ ONLY: stores metadata about the time series collection */ public ?TimeSeries $timeSeriesOptions = null; diff --git a/lib/Doctrine/ODM/MongoDB/Mapping/Driver/AttributeDriver.php b/lib/Doctrine/ODM/MongoDB/Mapping/Driver/AttributeDriver.php index 6a0ed2ee2..0aff1e901 100644 --- a/lib/Doctrine/ODM/MongoDB/Mapping/Driver/AttributeDriver.php +++ b/lib/Doctrine/ODM/MongoDB/Mapping/Driver/AttributeDriver.php @@ -32,7 +32,7 @@ use function trigger_deprecation; /** - * The AtttributeDriver reads the mapping metadata from attributes. + * The AttributeDriver reads the mapping metadata from attributes. */ class AttributeDriver implements MappingDriver { @@ -149,6 +149,8 @@ public function loadMetadataForClass($className, PersistenceClassMetadata $metad if (isset($attribute->level)) { $metadata->setValidationLevel($attribute->level); } + } elseif ($attribute instanceof ODM\Encrypt) { + $metadata->isEncrypted = true; } } @@ -264,6 +266,8 @@ public function loadMetadataForClass($className, PersistenceClassMetadata $metad $mapping['version'] = true; } elseif ($propertyAttribute instanceof ODM\Lock) { $mapping['lock'] = true; + } elseif ($propertyAttribute instanceof ODM\Encrypt) { + $mapping['encrypt'] = (array) $propertyAttribute; } } diff --git a/lib/Doctrine/ODM/MongoDB/Mapping/Driver/XmlDriver.php b/lib/Doctrine/ODM/MongoDB/Mapping/Driver/XmlDriver.php index 2b4454b0d..4e362e064 100644 --- a/lib/Doctrine/ODM/MongoDB/Mapping/Driver/XmlDriver.php +++ b/lib/Doctrine/ODM/MongoDB/Mapping/Driver/XmlDriver.php @@ -4,16 +4,20 @@ namespace Doctrine\ODM\MongoDB\Mapping\Driver; +use DateTimeImmutable; use Doctrine\ODM\MongoDB\Mapping\Annotations\TimeSeries; use Doctrine\ODM\MongoDB\Mapping\ClassMetadata; use Doctrine\ODM\MongoDB\Mapping\MappingException; use Doctrine\ODM\MongoDB\Mapping\TimeSeries\Granularity; +use Doctrine\ODM\MongoDB\Types\Type; use Doctrine\ODM\MongoDB\Utility\CollectionHelper; use Doctrine\Persistence\Mapping\Driver\FileDriver; use DOMDocument; use InvalidArgumentException; use LibXMLError; +use MongoDB\BSON\Decimal128; use MongoDB\BSON\Document; +use MongoDB\BSON\UTCDateTime; use MongoDB\Driver\Exception\UnexpectedValueException; use SimpleXMLElement; @@ -98,6 +102,9 @@ public function loadMetadataForClass($className, \Doctrine\Persistence\Mapping\C $metadata->isMappedSuperclass = true; } elseif ($xmlRoot->getName() === 'embedded-document') { $metadata->isEmbeddedDocument = true; + if (isset($xmlRoot->encrypt)) { + $metadata->isEncrypted = true; + } } elseif ($xmlRoot->getName() === 'query-result-document') { $metadata->isQueryResultDocument = true; } elseif ($xmlRoot->getName() === 'view') { @@ -307,6 +314,23 @@ public function loadMetadataForClass($className, \Doctrine\Persistence\Mapping\C $mapping['lock'] = ((string) $attributes['lock'] === 'true'); } + if (isset($field->encrypt)) { + $mapping['encrypt'] = []; + foreach ($field->encrypt->attributes() as $encryptKey => $encryptValue) { + $mapping['encrypt'][$encryptKey] = match ($encryptKey) { + 'queryType' => (string) $encryptValue, + 'min', 'max' => match ($mapping['type']) { + Type::INT => (int) $encryptValue, + Type::FLOAT => (float) $encryptValue, + Type::DECIMAL128 => new Decimal128((string) $encryptValue), + Type::DATE, Type::DATE_IMMUTABLE => new UTCDateTime(new DateTimeImmutable((string) $encryptValue)), + default => null, // Invalid + }, + 'sparsity', 'prevision', 'trimFactor', 'contention' => (int) $encryptValue, + }; + } + } + $this->addFieldMapping($metadata, $mapping); } } diff --git a/lib/Doctrine/ODM/MongoDB/Mapping/MappingException.php b/lib/Doctrine/ODM/MongoDB/Mapping/MappingException.php index e51e21c0a..e1c6d5fb2 100644 --- a/lib/Doctrine/ODM/MongoDB/Mapping/MappingException.php +++ b/lib/Doctrine/ODM/MongoDB/Mapping/MappingException.php @@ -306,4 +306,22 @@ public static function timeSeriesFieldNotFound(string $className, string $fieldN $fieldName, )); } + + public static function rootDocumentCannotBeEncrypted(string $className): self + { + return new self(sprintf( + 'The root document class "%s" cannot be encrypted. Only fields and embedded documents can be encrypted.', + $className, + )); + } + + public static function invalidEncryptedQueryRangeType(string $className, string $fieldName, string $type): self + { + return new self(sprintf( + 'The field type "%s" for field "%s::%s" is not supported for "range" query on encrypted field.', + $type, + $className, + $fieldName, + )); + } } diff --git a/lib/Doctrine/ODM/MongoDB/SchemaManager.php b/lib/Doctrine/ODM/MongoDB/SchemaManager.php index 5e62e521b..204f8f192 100644 --- a/lib/Doctrine/ODM/MongoDB/SchemaManager.php +++ b/lib/Doctrine/ODM/MongoDB/SchemaManager.php @@ -7,6 +7,7 @@ use Doctrine\ODM\MongoDB\Mapping\ClassMetadata; use Doctrine\ODM\MongoDB\Mapping\ClassMetadataFactoryInterface; use Doctrine\ODM\MongoDB\Repository\ViewRepository; +use Doctrine\ODM\MongoDB\Utility\EncryptionFieldMap; use InvalidArgumentException; use MongoDB\Driver\Exception\CommandException; use MongoDB\Driver\Exception\RuntimeException; @@ -644,10 +645,29 @@ public function createDocumentCollection(string $documentName, ?int $maxTimeMs = } } - $this->dm->getDocumentDatabase($documentName)->createCollection( - $class->getCollection(), - $this->getWriteOptions($maxTimeMs, $writeConcern, $options), - ); + // Encryption is enabled only if the KMS provider is set and at least one field is encrypted + if ($this->dm->getConfiguration()->getKmsProvider()) { + $encryptedFields = (new EncryptionFieldMap($this->dm->getMetadataFactory()))->getEncryptionFieldMap($class->name); + + if ($encryptedFields) { + $options['encryptedFields'] = ['fields' => $encryptedFields]; + } + } + + if (isset($options['encryptedFields'])) { + $this->dm->getDocumentDatabase($documentName)->createEncryptedCollection( + $class->getCollection(), + $this->dm->getClientEncryption(), + $this->dm->getConfiguration()->getKmsProvider(), + null, // @todo when is it necessary to set the master key? + $this->getWriteOptions($maxTimeMs, $writeConcern, $options), + ); + } else { + $this->dm->getDocumentDatabase($documentName)->createCollection( + $class->getCollection(), + $this->getWriteOptions($maxTimeMs, $writeConcern, $options), + ); + } } /** diff --git a/lib/Doctrine/ODM/MongoDB/Utility/EncryptionFieldMap.php b/lib/Doctrine/ODM/MongoDB/Utility/EncryptionFieldMap.php new file mode 100644 index 000000000..b346ea7c9 --- /dev/null +++ b/lib/Doctrine/ODM/MongoDB/Utility/EncryptionFieldMap.php @@ -0,0 +1,84 @@ +classMetadataFactory->getMetadataFor($className); + + return iterator_to_array($this->createEncryptionFieldMap($classMetadata)); + } + + private function createEncryptionFieldMap(ClassMetadata $classMetadata, string $path = ''): Generator + { + if ($classMetadata->isEncrypted && ! $classMetadata->isEmbeddedDocument) { + throw MappingException::rootDocumentCannotBeEncrypted($classMetadata->getName()); + } + + foreach ($classMetadata->fieldMappings as $mapping) { + // @todo support polymorphic types and inheritence? + // Add fields recursively + if ($mapping['embedded'] ?? false) { + $embedMetadata = $this->classMetadataFactory->getMetadataFor($mapping['targetDocument']); + + // When the embedded document class is encrypted, the field is encrypted, + // but none of the embedded fields are encrypted separately. + if ($embedMetadata->isEncrypted) { + $mapping['encrypt'] ??= []; // @todo get the keyId + } elseif (! isset($mapping['encrypt'])) { + yield from $this->createEncryptionFieldMap( + $embedMetadata, + $path . $mapping['name'] . '.', + ); + } + } + + if (! isset($mapping['encrypt'])) { + continue; + } + + $field = [ + 'path' => $path . $mapping['name'], + 'bsonType' => match ($mapping['type']) { + 'one' => 'object', + 'many' => 'array', + default => $mapping['type'], + }, + // @todo allow setting a keyId in #[Encrypt] attribute + 'keyId' => null, // Generate the key automatically + ]; + + // When queryType is null, the field is not queryable + if (isset($mapping['encrypt']['queryType'])) { + $field['queries'] = array_filter($mapping['encrypt'], static fn ($v) => $v !== null); + assert($field['queries']['queryType'] instanceof EncryptQuery); + $field['queries']['queryType'] = $field['queries']['queryType']->value; + } + + yield $field; + } + } +} diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon index f1b77b740..a0a5c65cd 100644 --- a/phpstan-baseline.neon +++ b/phpstan-baseline.neon @@ -1,8 +1,20 @@ parameters: ignoreErrors: + - + message: '#^Call to an undefined method MongoDB\\Driver\\Monitoring\\CommandFailedEvent\|MongoDB\\Driver\\Monitoring\\CommandSucceededEvent\:\:getServer\(\)\.$#' + identifier: method.notFound + count: 1 + path: lib/Doctrine/ODM/MongoDB/APM/Command.php + - message: '#^Call to function assert\(\) with true will always evaluate to true\.$#' identifier: function.alreadyNarrowedType + count: 2 + path: lib/Doctrine/ODM/MongoDB/Aggregation/Aggregation.php + + - + message: '#^Instanceof between MongoDB\\Driver\\CursorInterface and Iterator will always evaluate to true\.$#' + identifier: instanceof.alwaysTrue count: 1 path: lib/Doctrine/ODM/MongoDB/Aggregation/Aggregation.php @@ -720,12 +732,6 @@ parameters: count: 1 path: lib/Doctrine/ODM/MongoDB/Mapping/ClassMetadata.php - - - message: '#^Call to function is_subclass_of\(\) with class\-string and mixed will always evaluate to true\.$#' - identifier: function.alreadyNarrowedType - count: 1 - path: lib/Doctrine/ODM/MongoDB/Mapping/ClassMetadata.php - - message: '#^Class Doctrine\\ODM\\MongoDB\\Mapping\\ClassMetadata has type alias SearchIndexDefinition with no value type specified in iterable type array\.$#' identifier: missingType.iterableValue @@ -942,6 +948,12 @@ parameters: count: 1 path: lib/Doctrine/ODM/MongoDB/Mapping/Driver/AttributeReader.php + - + message: '#^Match expression does not handle remaining value\: string$#' + identifier: match.unhandled + count: 1 + path: lib/Doctrine/ODM/MongoDB/Mapping/Driver/XmlDriver.php + - message: '#^Method Doctrine\\ODM\\MongoDB\\Mapping\\Driver\\XmlDriver\:\:getSearchIndexFieldDefinition\(\) return type has no value type specified in iterable type array\.$#' identifier: missingType.iterableValue @@ -949,7 +961,7 @@ parameters: path: lib/Doctrine/ODM/MongoDB/Mapping/Driver/XmlDriver.php - - message: '#^Parameter \#2 \$mapping of method Doctrine\\ODM\\MongoDB\\Mapping\\Driver\\XmlDriver\:\:addFieldMapping\(\) expects array\{type\?\: string, fieldName\?\: string, name\?\: string, strategy\?\: string, association\?\: int, id\?\: bool, isOwningSide\?\: bool, collectionClass\?\: class\-string, \.\.\.\}, array\\|string\> given\.$#' + message: '#^Parameter \#2 \$mapping of method Doctrine\\ODM\\MongoDB\\Mapping\\Driver\\XmlDriver\:\:addFieldMapping\(\) expects array\{type\?\: string, fieldName\?\: string, name\?\: string, strategy\?\: string, association\?\: int, id\?\: bool, isOwningSide\?\: bool, collectionClass\?\: class\-string, \.\.\.\}, array\\|string, float\|int\|MongoDB\\BSON\\Decimal128\|MongoDB\\BSON\\UTCDateTime\|string\|null\>\|bool\|string\> given\.$#' identifier: argument.type count: 1 path: lib/Doctrine/ODM/MongoDB/Mapping/Driver/XmlDriver.php @@ -1107,7 +1119,7 @@ parameters: - message: '#^Call to function assert\(\) with true will always evaluate to true\.$#' identifier: function.alreadyNarrowedType - count: 1 + count: 2 path: lib/Doctrine/ODM/MongoDB/Persisters/DocumentPersister.php - @@ -1122,6 +1134,12 @@ parameters: count: 1 path: lib/Doctrine/ODM/MongoDB/Persisters/DocumentPersister.php + - + message: '#^Instanceof between MongoDB\\Driver\\CursorInterface and Iterator will always evaluate to true\.$#' + identifier: instanceof.alwaysTrue + count: 1 + path: lib/Doctrine/ODM/MongoDB/Persisters/DocumentPersister.php + - message: '#^Instanceof between MongoDB\\Driver\\CursorInterface and MongoDB\\Driver\\CursorInterface will always evaluate to true\.$#' identifier: instanceof.alwaysTrue @@ -1218,6 +1236,12 @@ parameters: count: 1 path: lib/Doctrine/ODM/MongoDB/Persisters/DocumentPersister.php + - + message: '#^Result of && is always true\.$#' + identifier: booleanAnd.alwaysTrue + count: 1 + path: lib/Doctrine/ODM/MongoDB/Persisters/DocumentPersister.php + - message: '#^Call to an undefined static method Doctrine\\ODM\\MongoDB\\Proxy\\Factory\\LazyGhostProxyFactory\:\:createLazyGhost\(\)\.$#' identifier: staticMethod.notFound @@ -1458,6 +1482,18 @@ parameters: count: 1 path: lib/Doctrine/ODM/MongoDB/UnitOfWork.php + - + message: '#^Method Doctrine\\ODM\\MongoDB\\Utility\\EncryptionFieldMap\:\:createEncryptionFieldMap\(\) has parameter \$classMetadata with generic class Doctrine\\ODM\\MongoDB\\Mapping\\ClassMetadata but does not specify its types\: T$#' + identifier: missingType.generics + count: 1 + path: lib/Doctrine/ODM/MongoDB/Utility/EncryptionFieldMap.php + + - + message: '#^Method Doctrine\\ODM\\MongoDB\\Utility\\EncryptionFieldMap\:\:getEncryptionFieldMap\(\) return type has no value type specified in iterable type array\.$#' + identifier: missingType.iterableValue + count: 1 + path: lib/Doctrine/ODM/MongoDB/Utility/EncryptionFieldMap.php + - message: '#^Unable to resolve the template type T in call to method Doctrine\\ODM\\MongoDB\\DocumentManager\:\:getClassMetadata\(\)$#' identifier: argument.templateType @@ -1518,12 +1554,6 @@ parameters: count: 1 path: tests/Doctrine/ODM/MongoDB/Tests/Aggregation/Stage/SetWindowFieldsTest.php - - - message: '#^Constant DOCTRINE_MONGODB_DATABASE not found\.$#' - identifier: constant.notFound - count: 5 - path: tests/Doctrine/ODM/MongoDB/Tests/BaseTestCase.php - - message: '#^Constant DOCTRINE_MONGODB_SERVER not found\.$#' identifier: constant.notFound @@ -1542,12 +1572,6 @@ parameters: count: 1 path: tests/Doctrine/ODM/MongoDB/Tests/BaseTestCase.php - - - message: '#^Used constant DOCTRINE_MONGODB_DATABASE not found\.$#' - identifier: constant.notFound - count: 1 - path: tests/Doctrine/ODM/MongoDB/Tests/BaseTestCase.php - - message: '#^Used constant DOCTRINE_MONGODB_SERVER not found\.$#' identifier: constant.notFound @@ -1560,12 +1584,6 @@ parameters: count: 1 path: tests/Doctrine/ODM/MongoDB/Tests/DocumentManagerTest.php - - - message: '#^Constant DOCTRINE_MONGODB_DATABASE not found\.$#' - identifier: constant.notFound - count: 1 - path: tests/Doctrine/ODM/MongoDB/Tests/DocumentRepositoryTest.php - - message: '#^Parameter \#2 \$projects of class Documents\\Developer constructor expects Doctrine\\Common\\Collections\\Collection\\|null, Doctrine\\Common\\Collections\\ArrayCollection\ given\.$#' identifier: argument.type @@ -1573,11 +1591,6 @@ parameters: path: tests/Doctrine/ODM/MongoDB/Tests/DocumentRepositoryTest.php - - message: '#^Used constant DOCTRINE_MONGODB_DATABASE not found\.$#' - identifier: constant.notFound - count: 1 - path: tests/Doctrine/ODM/MongoDB/Tests/DocumentRepositoryTest.php - - message: '#^Parameter \#2 \$collections of method Doctrine\\ODM\\MongoDB\\Persisters\\CollectionPersister\:\:delete\(\) expects array\, array\\|Doctrine\\Common\\Collections\\Collection\\> given\.$#' identifier: argument.type @@ -1656,6 +1669,30 @@ parameters: count: 1 path: tests/Doctrine/ODM/MongoDB/Tests/Functional/NestedDocumentsTest.php + - + message: '#^Access to an undefined property MongoDB\\Model\\BSONDocument\:\:\$patientId\.$#' + identifier: property.notFound + count: 1 + path: tests/Doctrine/ODM/MongoDB/Tests/Functional/QueryableEncryptionTest.php + + - + message: '#^Access to an undefined property MongoDB\\Model\\BSONDocument\:\:\$patientName\.$#' + identifier: property.notFound + count: 1 + path: tests/Doctrine/ODM/MongoDB/Tests/Functional/QueryableEncryptionTest.php + + - + message: '#^Access to an undefined property MongoDB\\Model\\BSONDocument\:\:\$patientRecord\.$#' + identifier: property.notFound + count: 3 + path: tests/Doctrine/ODM/MongoDB/Tests/Functional/QueryableEncryptionTest.php + + - + message: '#^Parameter \#1 \$options of method Doctrine\\ODM\\MongoDB\\Configuration\:\:setAutoEncryption\(\) expects array\{keyVaultNamespace\: string, kmsProviders\: array\\>, tlsOptions\?\: array\{kmip\: array\{tlsCAFile\: string, tlsCertificateKeyFile\: string\}\}\}, array\{keyVaultNamespace\: non\-falsy\-string, kmsProviders\: array\{local\: array\{key\: MongoDB\\BSON\\Binary\}\}\} given\.$#' + identifier: argument.type + count: 1 + path: tests/Doctrine/ODM/MongoDB/Tests/Functional/QueryableEncryptionTest.php + - message: '#^Parameter \$discriminatorMap of attribute class Doctrine\\ODM\\MongoDB\\Mapping\\Annotations\\ReferenceOne constructor expects array\\|null, array\ given\.$#' identifier: argument.type @@ -1728,18 +1765,6 @@ parameters: count: 1 path: tests/Doctrine/ODM/MongoDB/Tests/Functional/Ticket/MODM116Test.php - - - message: '#^Constant DOCTRINE_MONGODB_DATABASE not found\.$#' - identifier: constant.notFound - count: 2 - path: tests/Doctrine/ODM/MongoDB/Tests/Id/IncrementGeneratorTest.php - - - - message: '#^Used constant DOCTRINE_MONGODB_DATABASE not found\.$#' - identifier: constant.notFound - count: 1 - path: tests/Doctrine/ODM/MongoDB/Tests/Id/IncrementGeneratorTest.php - - message: '#^Property Doctrine\\ODM\\MongoDB\\Tests\\Mapping\\AnnotationDriverTestSuper\:\:\$private is unused\.$#' identifier: property.unused @@ -1908,12 +1933,6 @@ parameters: count: 1 path: tests/Doctrine/ODM/MongoDB/Tests/Query/BuilderTest.php - - - message: '#^Constant DOCTRINE_MONGODB_DATABASE not found\.$#' - identifier: constant.notFound - count: 2 - path: tests/Doctrine/ODM/MongoDB/Tests/QueryTest.php - - message: '#^Method Doctrine\\ODM\\MongoDB\\Tests\\QueryTest\:\:createCursorMock\(\) return type has no value type specified in iterable type Traversable\.$#' identifier: missingType.iterableValue @@ -1926,12 +1945,6 @@ parameters: count: 1 path: tests/Doctrine/ODM/MongoDB/Tests/QueryTest.php - - - message: '#^Used constant DOCTRINE_MONGODB_DATABASE not found\.$#' - identifier: constant.notFound - count: 1 - path: tests/Doctrine/ODM/MongoDB/Tests/QueryTest.php - - message: '#^Instantiated class MongoDB\\Model\\CollectionInfoCommandIterator not found\.$#' identifier: class.notFound @@ -2070,6 +2083,12 @@ parameters: count: 1 path: tests/Documents/Ecommerce/StockItem.php + - + message: '#^Method Documents\\Encryption\\Client\:\:__construct\(\) has parameter \$clientCards with generic interface Doctrine\\Common\\Collections\\Collection but does not specify its types\: TKey, T$#' + identifier: missingType.generics + count: 1 + path: tests/Documents/Encryption/Client.php + - message: '#^Property Documents\\Event\:\:\$id is never written, only read\.$#' identifier: property.onlyRead diff --git a/phpstan.neon.dist b/phpstan.neon.dist index 7a8327137..2c2c3e198 100644 --- a/phpstan.neon.dist +++ b/phpstan.neon.dist @@ -29,6 +29,9 @@ parameters: - message: '#with generic class Doctrine\\ODM\\MongoDB\\Mapping\\ClassMetadata does not specify its types: T$#' identifier: missingType.generics + - message: '#^(Constant|Used constant) DOCTRINE_MONGODB_DATABASE not found\.$#' + identifier: constant.notFound + path: tests/ # To be removed when reaching phpstan level 6 checkMissingVarTagTypehint: true diff --git a/tests/Doctrine/ODM/MongoDB/Tests/BaseTestCase.php b/tests/Doctrine/ODM/MongoDB/Tests/BaseTestCase.php index 13b366d69..1d2915831 100644 --- a/tests/Doctrine/ODM/MongoDB/Tests/BaseTestCase.php +++ b/tests/Doctrine/ODM/MongoDB/Tests/BaseTestCase.php @@ -12,6 +12,7 @@ use Doctrine\ODM\MongoDB\UnitOfWork; use Doctrine\Persistence\Mapping\Driver\MappingDriver; use MongoDB\Client; +use MongoDB\Driver\Command; use MongoDB\Driver\Manager; use MongoDB\Driver\Server; use MongoDB\Model\DatabaseInfo; @@ -192,6 +193,24 @@ protected function skipTestIfSharded(string $className): void $this->markTestSkipped('Test does not apply on sharded clusters'); } + protected function skipTestIfQueryableEncryptionNotSupported(): void + { + if ($this->getPrimaryServer()->getType() === Server::TYPE_STANDALONE) { + $this->markTestSkipped('Queryable Encryption test requires a replica set or sharded cluster'); + } + + $buildInfo = $this->getPrimaryServer()->executeCommand( + DOCTRINE_MONGODB_DATABASE, + new Command(['buildInfo' => 1]), + )->toArray()[0]; + + if (! in_array('enterprise', $buildInfo->modules ?? [])) { + $this->markTestSkipped('Queryable Encryption test requires MongoDB Atlas or Enterprise'); + } + + $this->requireVersion($buildInfo->version, '8.0', '<', 'Queryable Encryption test requires MongoDB 8.0 or higher'); + } + protected function requireVersion(string $installedVersion, string $requiredVersion, ?string $operator, string $message): void { if (! version_compare($installedVersion, $requiredVersion, $operator)) { diff --git a/tests/Doctrine/ODM/MongoDB/Tests/Functional/QueryableEncryptionTest.php b/tests/Doctrine/ODM/MongoDB/Tests/Functional/QueryableEncryptionTest.php new file mode 100644 index 000000000..00eec7144 --- /dev/null +++ b/tests/Doctrine/ODM/MongoDB/Tests/Functional/QueryableEncryptionTest.php @@ -0,0 +1,99 @@ +skipTestIfQueryableEncryptionNotSupported(); + } + + public function testCreateAndQueryEncryptedCollection(): void + { + $nonEncryptedClient = new Client(self::getUri()); + $nonEncryptedDatabase = $nonEncryptedClient->getDatabase(DOCTRINE_MONGODB_DATABASE); + + // Create the encrypted collection + $this->dm->getSchemaManager()->createDocumentCollection(Patient::class); + + // Test created collectionss + $collectionNames = iterator_to_array($nonEncryptedDatabase->listCollectionNames()); + self::assertContains('patients', $collectionNames); + self::assertContains('datakeys', $collectionNames); + + // Insert a document + $patient = new Patient( + patientName: 'Jon Doe', + patientId: 12345678, + patientRecord: new PatientRecord( + ssn: '987-65-4320', + billing: new PatientBilling( + type: 'Visa', + number: '4111111111111111', + ), + billingAmount: 1200, + ), + ); + + $this->dm->persist($patient); + $this->dm->flush(); + $this->dm->clear(); + + // Data is encrypted + $document = $nonEncryptedDatabase->getCollection('patients')->findOne(['patientName' => 'Jon Doe']); + self::assertInstanceOf(BSONDocument::class, $document); + self::assertSame('Jon Doe', $document->patientName); + self::assertSame(12345678, $document->patientId); + self::assertInstanceOf(Binary::class, $document->patientRecord->ssn); + self::assertInstanceOf(Binary::class, $document->patientRecord->billing); + self::assertInstanceOf(Binary::class, $document->patientRecord->billingAmount); + + // Queryable with equality + $result = $this->dm->getRepository(Patient::class)->findOneBy(['patientRecord.ssn' => '987-65-4320']); + self::assertNotNull($result); + self::assertSame('Jon Doe', $result->patientName); + self::assertSame('987-65-4320', $result->patientRecord->ssn); + self::assertSame('4111111111111111', $result->patientRecord->billing->number); + + $this->dm->clear(); + + // Queryable with range + $result = $this->dm->getRepository(Patient::class)->findOneBy(['patientRecord.billingAmount' => ['$gt' => 1000, '$lt' => 2000]]); + self::assertNotNull($result); + self::assertSame('Jon Doe', $result->patientName); + self::assertSame('987-65-4320', $result->patientRecord->ssn); + self::assertSame('4111111111111111', $result->patientRecord->billing->number); + } + + protected static function createTestDocumentManager(): DocumentManager + { + $config = static::getConfiguration(); + $config->setAutoEncryption([ + 'keyVaultNamespace' => DOCTRINE_MONGODB_DATABASE . '.datakeys', + 'kmsProviders' => [ + 'local' => ['key' => new Binary(random_bytes(96))], + ], + ]); + + $client = new Client(self::getUri(), [], ['autoEncryption' => $config->getAutoEncryption()]); + + return DocumentManager::create($client, $config); + } +} diff --git a/tests/Doctrine/ODM/MongoDB/Tests/Mapping/Driver/AbstractDriverTestCase.php b/tests/Doctrine/ODM/MongoDB/Tests/Mapping/Driver/AbstractDriverTestCase.php index 40dd5623c..320dec945 100644 --- a/tests/Doctrine/ODM/MongoDB/Tests/Mapping/Driver/AbstractDriverTestCase.php +++ b/tests/Doctrine/ODM/MongoDB/Tests/Mapping/Driver/AbstractDriverTestCase.php @@ -4,13 +4,20 @@ namespace Doctrine\ODM\MongoDB\Tests\Mapping\Driver; +use DateTimeImmutable; use Doctrine\ODM\MongoDB\Mapping\ClassMetadata; use Doctrine\Persistence\Mapping\Driver\MappingDriver; use Documents\Account; use Documents\Address; +use Documents\Encryption\ClientCard; +use Documents\Encryption\PatientRecord; +use Documents\Encryption\RangeTypes; use Documents\Group; use Documents\Phonenumber; use Documents\Profile; +use MongoDB\BSON\Decimal128; +use MongoDB\BSON\UTCDateTime; +use MongoDB\Driver\ClientEncryption; use PHPUnit\Framework\TestCase; use TestDocuments\EmbeddedDocument; use TestDocuments\NullableFieldsDocument; @@ -521,4 +528,67 @@ public function testNullableFieldsMapping(): void 'storeEmptyArray' => false, ], $classMetadata->fieldMappings['groups']); } + + public function testEncryptFieldMapping(): void + { + $classMetadata = new ClassMetadata(PatientRecord::class); + $this->driver->loadMetadataForClass(PatientRecord::class, $classMetadata); + + self::assertFalse($classMetadata->isEncrypted); + + self::assertSame([ + 'queryType' => ClientEncryption::QUERY_TYPE_EQUALITY, + ], $classMetadata->fieldMappings['ssn']['encrypt']); + + self::assertSame([], $classMetadata->fieldMappings['billing']['encrypt']); + + self::assertSame([ + 'queryType' => ClientEncryption::QUERY_TYPE_RANGE, + 'sparsity' => 1, + 'trimFactor' => 4, + 'min' => 100, + 'max' => 2000, + ], $classMetadata->fieldMappings['billingAmount']['encrypt']); + } + + public function testEncryptEmbeddedDocumentMapping(): void + { + $classMetadata = new ClassMetadata(ClientCard::class); + $this->driver->loadMetadataForClass(ClientCard::class, $classMetadata); + + self::assertTrue($classMetadata->isEncrypted); + + self::assertArrayNotHasKey('encrypt', $classMetadata->fieldMappings['type']); + self::assertArrayNotHasKey('encrypt', $classMetadata->fieldMappings['number']); + } + + public function testEncryptQueryRangeTypes(): void + { + $classMetadata = new ClassMetadata(RangeTypes::class); + $this->driver->loadMetadataForClass(RangeTypes::class, $classMetadata); + + self::assertEquals([ + 'queryType' => ClientEncryption::QUERY_TYPE_RANGE, + 'min' => 5, + 'max' => 10, + ], $classMetadata->fieldMappings['intField']['encrypt']); + + self::assertEquals([ + 'queryType' => ClientEncryption::QUERY_TYPE_RANGE, + 'min' => 5.5, + 'max' => 10.5, + ], $classMetadata->fieldMappings['floatField']['encrypt']); + + self::assertEquals([ + 'queryType' => ClientEncryption::QUERY_TYPE_RANGE, + 'min' => new Decimal128('0.1'), + 'max' => new Decimal128('0.2'), + ], $classMetadata->fieldMappings['decimalField']['encrypt']); + + self::assertEquals([ + 'queryType' => ClientEncryption::QUERY_TYPE_RANGE, + 'min' => new UTCDateTime(new DateTimeImmutable('2000-01-01 00:00:00')), + 'max' => new UTCDateTime(new DateTimeImmutable('2100-01-01 00:00:00')), + ], $classMetadata->fieldMappings['dateField']['encrypt']); + } } diff --git a/tests/Doctrine/ODM/MongoDB/Tests/Mapping/Driver/fixtures/xml/Documents.Encryption.ClientCard.dcm.xml b/tests/Doctrine/ODM/MongoDB/Tests/Mapping/Driver/fixtures/xml/Documents.Encryption.ClientCard.dcm.xml new file mode 100644 index 000000000..92e4f2b3b --- /dev/null +++ b/tests/Doctrine/ODM/MongoDB/Tests/Mapping/Driver/fixtures/xml/Documents.Encryption.ClientCard.dcm.xml @@ -0,0 +1,14 @@ + + + + + + + + + + + diff --git a/tests/Doctrine/ODM/MongoDB/Tests/Mapping/Driver/fixtures/xml/Documents.Encryption.PatientRecord.dcm.xml b/tests/Doctrine/ODM/MongoDB/Tests/Mapping/Driver/fixtures/xml/Documents.Encryption.PatientRecord.dcm.xml new file mode 100644 index 000000000..8909c230e --- /dev/null +++ b/tests/Doctrine/ODM/MongoDB/Tests/Mapping/Driver/fixtures/xml/Documents.Encryption.PatientRecord.dcm.xml @@ -0,0 +1,20 @@ + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/tests/Doctrine/ODM/MongoDB/Tests/Mapping/Driver/fixtures/xml/Documents.Encryption.RangeTypes.dcm.xml b/tests/Doctrine/ODM/MongoDB/Tests/Mapping/Driver/fixtures/xml/Documents.Encryption.RangeTypes.dcm.xml new file mode 100644 index 000000000..1499e3554 --- /dev/null +++ b/tests/Doctrine/ODM/MongoDB/Tests/Mapping/Driver/fixtures/xml/Documents.Encryption.RangeTypes.dcm.xml @@ -0,0 +1,27 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/tests/Doctrine/ODM/MongoDB/Tests/Tools/EncryptionFieldMapTest.php b/tests/Doctrine/ODM/MongoDB/Tests/Tools/EncryptionFieldMapTest.php new file mode 100644 index 000000000..89298d9f0 --- /dev/null +++ b/tests/Doctrine/ODM/MongoDB/Tests/Tools/EncryptionFieldMapTest.php @@ -0,0 +1,116 @@ +dm->getMetadataFactory()); + $encryptedFieldsMap = $factory->getEncryptionFieldMap(Patient::class); + + $expected = [ + [ + 'path' => 'patientRecord.ssn', + 'bsonType' => 'string', + 'keyId' => null, + 'queries' => ['queryType' => 'equality'], + ], + [ + 'path' => 'patientRecord.billing', + 'bsonType' => 'object', + 'keyId' => null, + ], + [ + 'path' => 'patientRecord.billingAmount', + 'bsonType' => 'int', + 'keyId' => null, + 'queries' => ['queryType' => 'range', 'min' => 100, 'max' => 2000, 'sparsity' => 1, 'trimFactor' => 4], + ], + ]; + + self::assertEquals($expected, $encryptedFieldsMap); + } + + public function testEncryptEmbeddedDocument(): void + { + $factory = new EncryptionFieldMap($this->dm->getMetadataFactory()); + $encryptedFieldsMap = $factory->getEncryptionFieldMap(Client::class); + + $expected = [ + [ + 'path' => 'name', + 'bsonType' => 'string', + 'keyId' => null, + ], + [ + 'path' => 'clientCards', + 'bsonType' => 'array', + 'keyId' => null, + ], + ]; + + self::assertSame($expected, $encryptedFieldsMap); + } + + public function testVariousRangeTypes(): void + { + $factory = new EncryptionFieldMap($this->dm->getMetadataFactory()); + $encryptedFieldsMap = $factory->getEncryptionFieldMap(RangeTypes::class); + + $expected = [ + [ + 'path' => 'intField', + 'bsonType' => 'int', + 'keyId' => null, + 'queries' => ['queryType' => 'range', 'min' => 5, 'max' => 10], + ], + [ + 'path' => 'floatField', + 'bsonType' => 'float', + 'keyId' => null, + 'queries' => ['queryType' => 'range', 'min' => 5.5, 'max' => 10.5], + ], + [ + 'path' => 'decimalField', + 'bsonType' => 'decimal128', + 'keyId' => null, + 'queries' => ['queryType' => 'range', 'min' => new Decimal128('0.1'), 'max' => new Decimal128('0.2')], + ], + [ + 'path' => 'dateField', + 'bsonType' => 'date_immutable', + 'keyId' => null, + 'queries' => [ + 'queryType' => 'range', + 'min' => new UTCDateTime(new DateTimeImmutable('2000-01-01 00:00:00')), + 'max' => new UTCDateTime(new DateTimeImmutable('2100-01-01 00:00:00')), + ], + ], + ]; + + self::assertEquals($expected, $encryptedFieldsMap); + } + + public function testRootDocumentsCannotBeEncrypted(): void + { + $this->expectException(MappingException::class); + $this->expectExceptionMessage('The root document class "Documents\Encryption\InvalidRootEncrypt" cannot be encrypted. Only fields and embedded documents can be encrypted.'); + + $factory = new EncryptionFieldMap($this->dm->getMetadataFactory()); + $factory->getEncryptionFieldMap(InvalidRootEncrypt::class); + } +} diff --git a/tests/Documents/Encryption/Client.php b/tests/Documents/Encryption/Client.php new file mode 100644 index 000000000..8c1be9eff --- /dev/null +++ b/tests/Documents/Encryption/Client.php @@ -0,0 +1,25 @@ + Date: Fri, 13 Jun 2025 16:01:11 +0200 Subject: [PATCH 02/22] [Encryption] Set master key when creating an encrypted collection (#2780) --- lib/Doctrine/ODM/MongoDB/Configuration.php | 115 ++++++++++++----- .../ODM/MongoDB/ConfigurationException.php | 27 ++++ lib/Doctrine/ODM/MongoDB/DocumentManager.php | 14 +- lib/Doctrine/ODM/MongoDB/SchemaManager.php | 6 +- .../ODM/MongoDB/Tests/ConfigurationTest.php | 120 +++++++++++++++++- .../Functional/QueryableEncryptionTest.php | 11 +- 6 files changed, 246 insertions(+), 47 deletions(-) diff --git a/lib/Doctrine/ODM/MongoDB/Configuration.php b/lib/Doctrine/ODM/MongoDB/Configuration.php index 5e4d28b9d..b4b0b4adf 100644 --- a/lib/Doctrine/ODM/MongoDB/Configuration.php +++ b/lib/Doctrine/ODM/MongoDB/Configuration.php @@ -26,6 +26,8 @@ use InvalidArgumentException; use Jean85\PrettyVersions; use LogicException; +use MongoDB\Client; +use MongoDB\Driver\Manager; use MongoDB\Driver\WriteConcern; use ProxyManager\Configuration as ProxyManagerConfiguration; use ProxyManager\Factory\LazyLoadingGhostFactory; @@ -35,12 +37,11 @@ use ReflectionClass; use Throwable; +use function array_diff_key; +use function array_intersect_key; use function array_key_exists; -use function array_key_first; use function class_exists; -use function count; use function interface_exists; -use function is_array; use function is_string; use function trigger_deprecation; use function trim; @@ -56,11 +57,7 @@ * $dm = DocumentManager::create(new Connection(), $config); * * @phpstan-import-type CommitOptions from UnitOfWork - * @phpstan-type AutoEncryptionOptions array{ - * keyVaultNamespace: string, - * kmsProviders: array>, - * tlsOptions?: array{kmip: array{tlsCAFile: string, tlsCertificateKeyFile: string}}, - * } + * @phpstan-type KmsProvider array{type: string, ...} */ class Configuration { @@ -133,7 +130,9 @@ class Configuration * proxyDir?: string, * proxyNamespace?: string, * repositoryFactory?: RepositoryFactory, - * autoEncryption?: AutoEncryptionOptions, + * kmsProvider?: KmsProvider, + * defaultMasterKey?: array|null, + * autoEncryption?: array, * } */ private array $attributes = []; @@ -163,13 +162,34 @@ public function getDriverOptions(): array ], ]; - if (isset($this->attributes['autoEncryption'])) { - $driverOptions['autoEncryption'] = $this->attributes['autoEncryption']; + if (isset($this->attributes['kmsProvider'])) { + $driverOptions['autoEncryption'] = $this->getAutoEncryptionOptions(); } return $driverOptions; } + /** + * Get options to create a ClientEncryption instance. + * + * @see https://www.php.net/manual/en/mongodb-driver-clientencryption.construct.php + * + * @return array{keyVaultClient?: Client|Manager, keyVaultNamespace: string, kmsProviders: array, tlsOptions?: array} + */ + public function getClientEncryptionOptions(): array + { + if (! isset($this->attributes['kmsProvider'])) { + throw ConfigurationException::clientEncryptionOptionsNotSet(); + } + + return array_intersect_key($this->getAutoEncryptionOptions(), [ + 'keyVaultClient' => 1, + 'keyVaultNamespace' => 1, + 'kmsProviders' => 1, + 'tlsOptions' => 1, + ]); + } + /** * Adds a namespace under a certain alias. */ @@ -688,47 +708,72 @@ public function isLazyGhostObjectEnabled(): bool } /** - * Set the options for auto-encryption. + * Set the KMS provider to use for auto-encryption. The name of the KMS provider + * must be specified in the 'type' key of the array. * * @see https://www.php.net/manual/en/mongodb-driver-clientencryption.construct.php * - * @phpstan-param AutoEncryptionOptions $options - * - * @throws InvalidArgumentException If the options are invalid. + * @param KmsProvider $kmsProvider */ - public function setAutoEncryption(array $options): void + public function setKmsProvider(array $kmsProvider): void { - if (! isset($options['keyVaultNamespace']) || ! is_string($options['keyVaultNamespace'])) { - throw new InvalidArgumentException('The "keyVaultNamespace" option is required.'); + if (! isset($kmsProvider['type'])) { + throw ConfigurationException::kmsProviderTypeRequired(); } - // @todo Throw en exception if multiple KMS providers are defined. This is not supported yet and would require a setting for the KMS provider to use when creating a new collection - if (! isset($options['kmsProviders']) || ! is_array($options['kmsProviders']) || count($options['kmsProviders']) < 1) { - throw new InvalidArgumentException('The "kmsProviders" option is required.'); + if (! is_string($kmsProvider['type'])) { + throw ConfigurationException::kmsProviderTypeMustBeString(); } - $this->attributes['autoEncryption'] = $options; + $this->attributes['kmsProvider'] = $kmsProvider; } /** - * Get the options for auto-encryption. + * Set the default master key to use when creating encrypted collections. * - * @see https://www.php.net/manual/en/mongodb-driver-clientencryption.construct.php + * @param array|null $masterKey + */ + public function setDefaultMasterKey(?array $masterKey): void + { + $this->attributes['defaultMasterKey'] = $masterKey; + } + + /** + * Set the options for auto-encryption. + * + * @see https://www.php.net/manual/en/mongodb-driver-manager.construct.php * - * @phpstan-return AutoEncryptionOptions + * @param array{ keyVaultClient?: Client|Manager, keyVaultNamespace?: string, tlsOptions?: array, schemaMap?: array, encryptedFieldsMap?: array, extraOptions?: array} $options */ - public function getAutoEncryption(): ?array + public function setAutoEncryption(array $options): void { - return $this->attributes['autoEncryption'] ?? null; + if (isset($options['kmsProviders'])) { + throw ConfigurationException::kmsProvidersOptionMustUseSetter(); + } + + $this->attributes['autoEncryption'] = $options; } - public function getKmsProvider(): ?string + /** + * Get the default KMS provider name used when creating encrypted collections. + */ + public function getDefaultKmsProvider(): ?string { - if (! isset($this->attributes['autoEncryption'])) { + return $this->attributes['kmsProvider']['type'] ?? null; + } + + /** + * Get the default master key used when creating encrypted collections. + * + * @return array|null + */ + public function getDefaultMasterKey(): ?array + { + if (! isset($this->attributes['kmsProvider']) || $this->attributes['kmsProvider']['type'] === 'local') { return null; } - return array_key_first($this->attributes['autoEncryption']['kmsProviders']); + return $this->attributes['defaultMasterKey'] ?? throw ConfigurationException::masterKeyRequired($this->attributes['kmsProvider']['type']); } private static function getVersion(): string @@ -743,6 +788,16 @@ private static function getVersion(): string return self::$version; } + + /** @return array */ + private function getAutoEncryptionOptions(): array + { + return [ + 'kmsProviders' => [$this->attributes['kmsProvider']['type'] => array_diff_key($this->attributes['kmsProvider'], ['type' => 0])], + 'keyVaultNamespace' => $this->getDefaultDB() . '.datakeys', + ...$this->attributes['autoEncryption'] ?? [], + ]; + } } interface_exists(MappingDriver::class); diff --git a/lib/Doctrine/ODM/MongoDB/ConfigurationException.php b/lib/Doctrine/ODM/MongoDB/ConfigurationException.php index dd1bad9a4..506fd72fa 100644 --- a/lib/Doctrine/ODM/MongoDB/ConfigurationException.php +++ b/lib/Doctrine/ODM/MongoDB/ConfigurationException.php @@ -6,6 +6,8 @@ use Exception; +use function sprintf; + final class ConfigurationException extends Exception { public static function persistentCollectionDirMissing(): self @@ -27,4 +29,29 @@ public static function proxyDirMissing(): self { return new self('No proxy directory was configured. Please set a target directory first!'); } + + public static function clientEncryptionOptionsNotSet(): self + { + return new self('MongoDB client encryption options are not set in configuration'); + } + + public static function kmsProviderTypeRequired(): self + { + return new self('The KMS provider "type" is required.'); + } + + public static function kmsProviderTypeMustBeString(): self + { + return new self('The KMS provider "type" must be a non-empty string.'); + } + + public static function kmsProvidersOptionMustUseSetter(): self + { + return new self('The "kmsProviders" encryption option must be set using the "setKmsProvider()" method.'); + } + + public static function masterKeyRequired(string $provider): self + { + return new self(sprintf('The "masterKey" configuration is required for the KMS provider "%s".', $provider)); + } } diff --git a/lib/Doctrine/ODM/MongoDB/DocumentManager.php b/lib/Doctrine/ODM/MongoDB/DocumentManager.php index 197578fa6..b9f440aef 100644 --- a/lib/Doctrine/ODM/MongoDB/DocumentManager.php +++ b/lib/Doctrine/ODM/MongoDB/DocumentManager.php @@ -223,17 +223,17 @@ public function getClient(): Client /** @internal */ public function getClientEncryption(): ClientEncryption { - $autoEncryptionOptions = $this->config->getAutoEncryption(); + if (isset($this->clientEncryption)) { + return $this->clientEncryption; + } + + $options = $this->config->getClientEncryptionOptions(); - if (! $autoEncryptionOptions) { + if (! $options) { throw new RuntimeException('Auto-encryption is not enabled.'); } - return $this->clientEncryption ??= $this->client->createClientEncryption([ - 'keyVaultNamespace' => $autoEncryptionOptions['keyVaultNamespace'], - 'kmsProviders' => $autoEncryptionOptions['kmsProviders'], - 'tlsOptions' => $autoEncryptionOptions['tlsOptions'] ?? [], - ]); + return $this->clientEncryption = $this->client->createClientEncryption($options); } /** Gets the metadata factory used to gather the metadata of classes. */ diff --git a/lib/Doctrine/ODM/MongoDB/SchemaManager.php b/lib/Doctrine/ODM/MongoDB/SchemaManager.php index 204f8f192..d5d5f57b0 100644 --- a/lib/Doctrine/ODM/MongoDB/SchemaManager.php +++ b/lib/Doctrine/ODM/MongoDB/SchemaManager.php @@ -646,7 +646,7 @@ public function createDocumentCollection(string $documentName, ?int $maxTimeMs = } // Encryption is enabled only if the KMS provider is set and at least one field is encrypted - if ($this->dm->getConfiguration()->getKmsProvider()) { + if ($this->dm->getConfiguration()->getDefaultKmsProvider()) { $encryptedFields = (new EncryptionFieldMap($this->dm->getMetadataFactory()))->getEncryptionFieldMap($class->name); if ($encryptedFields) { @@ -658,8 +658,8 @@ public function createDocumentCollection(string $documentName, ?int $maxTimeMs = $this->dm->getDocumentDatabase($documentName)->createEncryptedCollection( $class->getCollection(), $this->dm->getClientEncryption(), - $this->dm->getConfiguration()->getKmsProvider(), - null, // @todo when is it necessary to set the master key? + $this->dm->getConfiguration()->getDefaultKmsProvider(), + $this->dm->getConfiguration()->getDefaultMasterKey(), $this->getWriteOptions($maxTimeMs, $writeConcern, $options), ); } else { diff --git a/tests/Doctrine/ODM/MongoDB/Tests/ConfigurationTest.php b/tests/Doctrine/ODM/MongoDB/Tests/ConfigurationTest.php index 38797b822..f5c3affe7 100644 --- a/tests/Doctrine/ODM/MongoDB/Tests/ConfigurationTest.php +++ b/tests/Doctrine/ODM/MongoDB/Tests/ConfigurationTest.php @@ -5,10 +5,13 @@ namespace Doctrine\ODM\MongoDB\Tests; use Doctrine\ODM\MongoDB\Configuration; +use Doctrine\ODM\MongoDB\ConfigurationException; use Doctrine\ODM\MongoDB\PersistentCollection\PersistentCollectionFactory; use Doctrine\ODM\MongoDB\PersistentCollection\PersistentCollectionGenerator; +use MongoDB\Driver\Manager; +use PHPUnit\Framework\TestCase; -class ConfigurationTest extends BaseTestCase +class ConfigurationTest extends TestCase { public function testDefaultPersistentCollectionFactory(): void { @@ -40,4 +43,119 @@ public function testEnableTransactionalFlush(): void $c->setUseTransactionalFlush(false); self::assertFalse($c->isTransactionalFlushEnabled(), 'Transactional flush is disabled after setTransactionalFlush(false)'); } + + public function testLocalKmsProvider(): void + { + $c = new Configuration(); + $c->setKmsProvider(['type' => 'local', 'key' => '1234567890123456789012345678901234567890123456789012345678901234']); + $c->setAutoEncryption(['extraOptions' => ['mongocryptdURI' => 'mongodb://localhost:27020']]); + $c->setDefaultDB('default_database'); + + self::assertSame('local', $c->getDefaultKmsProvider()); + self::assertNull($c->getDefaultMasterKey()); + self::assertEquals([ + 'kmsProviders' => [ + 'local' => ['key' => '1234567890123456789012345678901234567890123456789012345678901234'], + ], + 'extraOptions' => ['mongocryptdURI' => 'mongodb://localhost:27020'], + // Default key vault namespace + 'keyVaultNamespace' => 'default_database.datakeys', + ], $c->getDriverOptions()['autoEncryption']); + } + + public function testKmsProvider(): void + { + $c = new Configuration(); + $c->setKmsProvider(['type' => 'aws', 'accessKeyId' => 'AKIA', 'secretAccessKey' => 'SECRET']); + $c->setAutoEncryption(['keyVaultNamespace' => 'keyvault.datakeys']); + $c->setDefaultMasterKey($masterKey = ['region' => 'us-east-1', 'key' => 'arn:aws:kms:us-east-1:123456789012:key/abcd1234-ab12-cd34-ef56-1234567890ab']); + + self::assertSame('aws', $c->getDefaultKmsProvider()); + self::assertSame($masterKey, $c->getDefaultMasterKey()); + self::assertEquals([ + 'kmsProviders' => [ + 'aws' => ['accessKeyId' => 'AKIA', 'secretAccessKey' => 'SECRET'], + ], + // Key vault namespace from the configuration + 'keyVaultNamespace' => 'keyvault.datakeys', + ], $c->getDriverOptions()['autoEncryption']); + } + + public function testAutoEncryptionOptions(): void + { + $c = new Configuration(); + $c->setAutoEncryption([ + 'keyVaultClient' => $keyVaultClient = new Manager(), + 'keyVaultNamespace' => 'keyvault.datakeys', + 'extraOptions' => ['mongocryptdURI' => 'mongodb://localhost:27020'], + 'tlsOptions' => ['tlsDisableOCSPEndpointCheck' => true], + ]); + $c->setKmsProvider(['type' => 'local', 'key' => '1234567890123456789012345678901234567890123456789012345678901234']); + + self::assertSame([ + 'kmsProviders' => [ + 'local' => ['key' => '1234567890123456789012345678901234567890123456789012345678901234'], + ], + 'keyVaultNamespace' => 'keyvault.datakeys', + 'keyVaultClient' => $keyVaultClient, + 'extraOptions' => ['mongocryptdURI' => 'mongodb://localhost:27020'], + 'tlsOptions' => ['tlsDisableOCSPEndpointCheck' => true], + ], $c->getDriverOptions()['autoEncryption']); + + self::assertSame([ + 'kmsProviders' => [ + 'local' => ['key' => '1234567890123456789012345678901234567890123456789012345678901234'], + ], + 'keyVaultNamespace' => 'keyvault.datakeys', + 'keyVaultClient' => $keyVaultClient, + 'tlsOptions' => ['tlsDisableOCSPEndpointCheck' => true], + ], $c->getClientEncryptionOptions()); + } + + public function testMissingDefaultMasterKey(): void + { + $c = new Configuration(); + $c->setKmsProvider(['type' => 'aws', 'accessKeyId' => 'AKIA', 'secretAccessKey' => 'SECRET']); + + self::expectException(ConfigurationException::class); + self::expectExceptionMessage('The "masterKey" configuration is required for the KMS provider "aws".'); + $c->getDefaultMasterKey(); + } + + public function testKmsProvidersIsForbiddenInAutoEncryptionOptions(): void + { + $c = new Configuration(); + + self::expectException(ConfigurationException::class); + self::expectExceptionMessage('The "kmsProviders" encryption option must be set using the "setKmsProvider()" method.'); + $c->setAutoEncryption(['kmsProviders' => ['aws' => ['accessKeyId' => 'AKIA', 'secretAccessKey' => 'SECRET']]]); + } + + public function testClientEncryptionOptionsNotSet(): void + { + $c = new Configuration(); + self::expectException(ConfigurationException::class); + self::expectExceptionMessage('MongoDB client encryption options are not set in configuration'); + $c->getClientEncryptionOptions(); + } + + public function testKmsProviderTypeRequired(): void + { + $c = new Configuration(); + self::expectException(ConfigurationException::class); + self::expectExceptionMessage('The KMS provider "type" is required.'); + + // @phpstan-ignore argument.type + $c->setKmsProvider(['foo' => 'bar']); + } + + public function testKmsProviderTypeMustBeString(): void + { + $c = new Configuration(); + self::expectException(ConfigurationException::class); + self::expectExceptionMessage('The KMS provider "type" must be a non-empty string.'); + + // @phpstan-ignore argument.type + $c->setKmsProvider(['type' => ['not', 'a', 'string']]); + } } diff --git a/tests/Doctrine/ODM/MongoDB/Tests/Functional/QueryableEncryptionTest.php b/tests/Doctrine/ODM/MongoDB/Tests/Functional/QueryableEncryptionTest.php index 00eec7144..75585d412 100644 --- a/tests/Doctrine/ODM/MongoDB/Tests/Functional/QueryableEncryptionTest.php +++ b/tests/Doctrine/ODM/MongoDB/Tests/Functional/QueryableEncryptionTest.php @@ -85,14 +85,13 @@ public function testCreateAndQueryEncryptedCollection(): void protected static function createTestDocumentManager(): DocumentManager { $config = static::getConfiguration(); - $config->setAutoEncryption([ - 'keyVaultNamespace' => DOCTRINE_MONGODB_DATABASE . '.datakeys', - 'kmsProviders' => [ - 'local' => ['key' => new Binary(random_bytes(96))], - ], + $config->setDefaultDB(DOCTRINE_MONGODB_DATABASE); + $config->setKmsProvider([ + 'type' => 'local', + 'key' => new Binary(random_bytes(96)), ]); - $client = new Client(self::getUri(), [], ['autoEncryption' => $config->getAutoEncryption()]); + $client = new Client(self::getUri(), [], $config->getDriverOptions()); return DocumentManager::create($client, $config); } From 8fc9d01628dfe11e1e56fee4032f33e5b0ed7ff0 Mon Sep 17 00:00:00 2001 From: Andreas Braun Date: Wed, 18 Jun 2025 10:03:02 +0200 Subject: [PATCH 03/22] =?UTF-8?q?[Encryption]=C2=A0Refactor=20encrypted=20?= =?UTF-8?q?fields=20map=20generator=20(#2783)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../ODM/MongoDB/Mapping/ClassMetadata.php | 9 + lib/Doctrine/ODM/MongoDB/SchemaManager.php | 4 +- ...ap.php => EncryptedFieldsMapGenerator.php} | 57 ++++- phpstan-baseline.neon | 90 ++++++- .../Tools/EncryptedFieldsMapGeneratorTest.php | 227 ++++++++++++++++++ .../Tests/Tools/EncryptionFieldMapTest.php | 116 --------- 6 files changed, 367 insertions(+), 136 deletions(-) rename lib/Doctrine/ODM/MongoDB/Utility/{EncryptionFieldMap.php => EncryptedFieldsMapGenerator.php} (59%) create mode 100644 tests/Doctrine/ODM/MongoDB/Tests/Tools/EncryptedFieldsMapGeneratorTest.php delete mode 100644 tests/Doctrine/ODM/MongoDB/Tests/Tools/EncryptionFieldMapTest.php diff --git a/lib/Doctrine/ODM/MongoDB/Mapping/ClassMetadata.php b/lib/Doctrine/ODM/MongoDB/Mapping/ClassMetadata.php index 138f1867d..d05e4c4b7 100644 --- a/lib/Doctrine/ODM/MongoDB/Mapping/ClassMetadata.php +++ b/lib/Doctrine/ODM/MongoDB/Mapping/ClassMetadata.php @@ -2183,6 +2183,15 @@ public function isView(): bool return $this->isView; } + public function isDocument(): bool + { + return ! $this->isView + && ! $this->isEmbeddedDocument + && ! $this->isFile + && ! $this->isQueryResultDocument + && ! $this->isMappedSuperclass; + } + /** @param class-string $rootClass */ public function markViewOf(string $rootClass): void { diff --git a/lib/Doctrine/ODM/MongoDB/SchemaManager.php b/lib/Doctrine/ODM/MongoDB/SchemaManager.php index d5d5f57b0..68b4c55ab 100644 --- a/lib/Doctrine/ODM/MongoDB/SchemaManager.php +++ b/lib/Doctrine/ODM/MongoDB/SchemaManager.php @@ -7,7 +7,7 @@ use Doctrine\ODM\MongoDB\Mapping\ClassMetadata; use Doctrine\ODM\MongoDB\Mapping\ClassMetadataFactoryInterface; use Doctrine\ODM\MongoDB\Repository\ViewRepository; -use Doctrine\ODM\MongoDB\Utility\EncryptionFieldMap; +use Doctrine\ODM\MongoDB\Utility\EncryptedFieldsMapGenerator; use InvalidArgumentException; use MongoDB\Driver\Exception\CommandException; use MongoDB\Driver\Exception\RuntimeException; @@ -647,7 +647,7 @@ public function createDocumentCollection(string $documentName, ?int $maxTimeMs = // Encryption is enabled only if the KMS provider is set and at least one field is encrypted if ($this->dm->getConfiguration()->getDefaultKmsProvider()) { - $encryptedFields = (new EncryptionFieldMap($this->dm->getMetadataFactory()))->getEncryptionFieldMap($class->name); + $encryptedFields = (new EncryptedFieldsMapGenerator($this->dm->getMetadataFactory()))->getEncryptedFieldsMapForClass($class->name); if ($encryptedFields) { $options['encryptedFields'] = ['fields' => $encryptedFields]; diff --git a/lib/Doctrine/ODM/MongoDB/Utility/EncryptionFieldMap.php b/lib/Doctrine/ODM/MongoDB/Utility/EncryptedFieldsMapGenerator.php similarity index 59% rename from lib/Doctrine/ODM/MongoDB/Utility/EncryptionFieldMap.php rename to lib/Doctrine/ODM/MongoDB/Utility/EncryptedFieldsMapGenerator.php index b346ea7c9..08492e0d4 100644 --- a/lib/Doctrine/ODM/MongoDB/Utility/EncryptionFieldMap.php +++ b/lib/Doctrine/ODM/MongoDB/Utility/EncryptedFieldsMapGenerator.php @@ -14,30 +14,74 @@ use function assert; use function iterator_to_array; -final class EncryptionFieldMap +final class EncryptedFieldsMapGenerator { public function __construct(private ClassMetadataFactoryInterface $classMetadataFactory) { } + /** + * Returns the full encryption fields map for a document manager + * + * @return array> + */ + public function getEncryptedFieldsMap(): array + { + $encryptedFieldsMap = []; + + $allMetadata = $this->classMetadataFactory->getAllMetadata(); + foreach ($allMetadata as $classMetadata) { + if (! $classMetadata->isDocument()) { + continue; + } + + $classMap = iterator_to_array($this->createEncryptedFieldsMapForClass($classMetadata)); + if ($classMap === []) { + continue; + } + + $encryptedFieldsMap[$classMetadata->getName()] = $classMap; + } + + return $encryptedFieldsMap; + } + /** * Generate the encryption field map from the class metadata. * * @param class-string $className + * + * @return array */ - public function getEncryptionFieldMap(string $className): array + public function getEncryptedFieldsMapForClass(string $className): array { $classMetadata = $this->classMetadataFactory->getMetadataFor($className); - return iterator_to_array($this->createEncryptionFieldMap($classMetadata)); + return iterator_to_array($this->createEncryptedFieldsMapForClass($classMetadata)); } - private function createEncryptionFieldMap(ClassMetadata $classMetadata, string $path = ''): Generator - { + /** + * @param array $visitedClasses + * @phpstan-param ClassMetadata $classMetadata + * + * @return Generator + * + * @template T of object + */ + private function createEncryptedFieldsMapForClass( + ClassMetadata $classMetadata, + string $path = '', + array $visitedClasses = [], + ): Generator { if ($classMetadata->isEncrypted && ! $classMetadata->isEmbeddedDocument) { throw MappingException::rootDocumentCannotBeEncrypted($classMetadata->getName()); } + if (isset($visitedClasses[$classMetadata->getName()])) { + // Prevent infinite recursion due to circular references in the metadata + return; + } + foreach ($classMetadata->fieldMappings as $mapping) { // @todo support polymorphic types and inheritence? // Add fields recursively @@ -49,9 +93,10 @@ private function createEncryptionFieldMap(ClassMetadata $classMetadata, string $ if ($embedMetadata->isEncrypted) { $mapping['encrypt'] ??= []; // @todo get the keyId } elseif (! isset($mapping['encrypt'])) { - yield from $this->createEncryptionFieldMap( + yield from $this->createEncryptedFieldsMapForClass( $embedMetadata, $path . $mapping['name'] . '.', + $visitedClasses + [$classMetadata->getName() => true], ); } } diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon index a0a5c65cd..86d01561c 100644 --- a/phpstan-baseline.neon +++ b/phpstan-baseline.neon @@ -1482,18 +1482,6 @@ parameters: count: 1 path: lib/Doctrine/ODM/MongoDB/UnitOfWork.php - - - message: '#^Method Doctrine\\ODM\\MongoDB\\Utility\\EncryptionFieldMap\:\:createEncryptionFieldMap\(\) has parameter \$classMetadata with generic class Doctrine\\ODM\\MongoDB\\Mapping\\ClassMetadata but does not specify its types\: T$#' - identifier: missingType.generics - count: 1 - path: lib/Doctrine/ODM/MongoDB/Utility/EncryptionFieldMap.php - - - - message: '#^Method Doctrine\\ODM\\MongoDB\\Utility\\EncryptionFieldMap\:\:getEncryptionFieldMap\(\) return type has no value type specified in iterable type array\.$#' - identifier: missingType.iterableValue - count: 1 - path: lib/Doctrine/ODM/MongoDB/Utility/EncryptionFieldMap.php - - message: '#^Unable to resolve the template type T in call to method Doctrine\\ODM\\MongoDB\\DocumentManager\:\:getClassMetadata\(\)$#' identifier: argument.templateType @@ -1981,6 +1969,84 @@ parameters: count: 1 path: tests/Doctrine/ODM/MongoDB/Tests/SchemaManagerTest.php + - + message: '#^Call to an undefined method Doctrine\\ODM\\MongoDB\\Mapping\\ClassMetadataFactoryInterface\:\:doLoadMetadata\(\)\.$#' + identifier: method.notFound + count: 1 + path: tests/Doctrine/ODM/MongoDB/Tests/Tools/EncryptedFieldsMapGeneratorTest.php + + - + message: '#^Call to an undefined method Doctrine\\ODM\\MongoDB\\Mapping\\ClassMetadataFactoryInterface\:\:getDriver\(\)\.$#' + identifier: method.notFound + count: 1 + path: tests/Doctrine/ODM/MongoDB/Tests/Tools/EncryptedFieldsMapGeneratorTest.php + + - + message: '#^Call to an undefined method Doctrine\\ODM\\MongoDB\\Mapping\\ClassMetadataFactoryInterface\:\:initializeReflection\(\)\.$#' + identifier: method.notFound + count: 1 + path: tests/Doctrine/ODM/MongoDB/Tests/Tools/EncryptedFieldsMapGeneratorTest.php + + - + message: '#^Call to an undefined method Doctrine\\ODM\\MongoDB\\Mapping\\ClassMetadataFactoryInterface\:\:isEntity\(\)\.$#' + identifier: method.notFound + count: 1 + path: tests/Doctrine/ODM/MongoDB/Tests/Tools/EncryptedFieldsMapGeneratorTest.php + + - + message: '#^Call to an undefined method Doctrine\\ODM\\MongoDB\\Mapping\\ClassMetadataFactoryInterface\:\:newClassMetadataInstance\(\)\.$#' + identifier: method.notFound + count: 1 + path: tests/Doctrine/ODM/MongoDB/Tests/Tools/EncryptedFieldsMapGeneratorTest.php + + - + message: '#^Call to an undefined method Doctrine\\ODM\\MongoDB\\Mapping\\ClassMetadataFactoryInterface\:\:wakeupReflection\(\)\.$#' + identifier: method.notFound + count: 1 + path: tests/Doctrine/ODM/MongoDB/Tests/Tools/EncryptedFieldsMapGeneratorTest.php + + - + message: '#^Method Doctrine\\Persistence\\Mapping\\AbstractClassMetadataFactory@anonymous/tests/Doctrine/ODM/MongoDB/Tests/Tools/EncryptedFieldsMapGeneratorTest\.php\:165\:\:__construct\(\) has parameter \$classNames with no value type specified in iterable type array\.$#' + identifier: missingType.iterableValue + count: 1 + path: tests/Doctrine/ODM/MongoDB/Tests/Tools/EncryptedFieldsMapGeneratorTest.php + + - + message: '#^Method Doctrine\\Persistence\\Mapping\\AbstractClassMetadataFactory@anonymous/tests/Doctrine/ODM/MongoDB/Tests/Tools/EncryptedFieldsMapGeneratorTest\.php\:165\:\:doLoadMetadata\(\) has parameter \$class with generic interface Doctrine\\Persistence\\Mapping\\ClassMetadata but does not specify its types\: T$#' + identifier: missingType.generics + count: 1 + path: tests/Doctrine/ODM/MongoDB/Tests/Tools/EncryptedFieldsMapGeneratorTest.php + + - + message: '#^Method Doctrine\\Persistence\\Mapping\\AbstractClassMetadataFactory@anonymous/tests/Doctrine/ODM/MongoDB/Tests/Tools/EncryptedFieldsMapGeneratorTest\.php\:165\:\:doLoadMetadata\(\) has parameter \$parent with generic interface Doctrine\\Persistence\\Mapping\\ClassMetadata but does not specify its types\: T$#' + identifier: missingType.generics + count: 1 + path: tests/Doctrine/ODM/MongoDB/Tests/Tools/EncryptedFieldsMapGeneratorTest.php + + - + message: '#^Method Doctrine\\Persistence\\Mapping\\AbstractClassMetadataFactory@anonymous/tests/Doctrine/ODM/MongoDB/Tests/Tools/EncryptedFieldsMapGeneratorTest\.php\:165\:\:getAllMetadata\(\) return type with generic interface Doctrine\\Persistence\\Mapping\\ClassMetadata does not specify its types\: T$#' + identifier: missingType.generics + count: 1 + path: tests/Doctrine/ODM/MongoDB/Tests/Tools/EncryptedFieldsMapGeneratorTest.php + + - + message: '#^Method Doctrine\\Persistence\\Mapping\\AbstractClassMetadataFactory@anonymous/tests/Doctrine/ODM/MongoDB/Tests/Tools/EncryptedFieldsMapGeneratorTest\.php\:165\:\:initializeReflection\(\) has parameter \$class with generic interface Doctrine\\Persistence\\Mapping\\ClassMetadata but does not specify its types\: T$#' + identifier: missingType.generics + count: 1 + path: tests/Doctrine/ODM/MongoDB/Tests/Tools/EncryptedFieldsMapGeneratorTest.php + + - + message: '#^Method Doctrine\\Persistence\\Mapping\\AbstractClassMetadataFactory@anonymous/tests/Doctrine/ODM/MongoDB/Tests/Tools/EncryptedFieldsMapGeneratorTest\.php\:165\:\:isEntity\(\) has parameter \$class with generic interface Doctrine\\Persistence\\Mapping\\ClassMetadata but does not specify its types\: T$#' + identifier: missingType.generics + count: 1 + path: tests/Doctrine/ODM/MongoDB/Tests/Tools/EncryptedFieldsMapGeneratorTest.php + + - + message: '#^Method Doctrine\\Persistence\\Mapping\\AbstractClassMetadataFactory@anonymous/tests/Doctrine/ODM/MongoDB/Tests/Tools/EncryptedFieldsMapGeneratorTest\.php\:165\:\:wakeupReflection\(\) has parameter \$class with generic interface Doctrine\\Persistence\\Mapping\\ClassMetadata but does not specify its types\: T$#' + identifier: missingType.generics + count: 1 + path: tests/Doctrine/ODM/MongoDB/Tests/Tools/EncryptedFieldsMapGeneratorTest.php + - message: '#^Property Doctrine\\ODM\\MongoDB\\Tests\\Tools\\GH297\\User\:\:\$id is never written, only read\.$#' identifier: property.onlyRead diff --git a/tests/Doctrine/ODM/MongoDB/Tests/Tools/EncryptedFieldsMapGeneratorTest.php b/tests/Doctrine/ODM/MongoDB/Tests/Tools/EncryptedFieldsMapGeneratorTest.php new file mode 100644 index 000000000..569d99043 --- /dev/null +++ b/tests/Doctrine/ODM/MongoDB/Tests/Tools/EncryptedFieldsMapGeneratorTest.php @@ -0,0 +1,227 @@ +dm->getMetadataFactory()); + $encryptedFieldsMap = $factory->getEncryptedFieldsMapForClass(Patient::class); + + $expected = [ + [ + 'path' => 'patientRecord.ssn', + 'bsonType' => 'string', + 'keyId' => null, + 'queries' => ['queryType' => 'equality'], + ], + [ + 'path' => 'patientRecord.billing', + 'bsonType' => 'object', + 'keyId' => null, + ], + [ + 'path' => 'patientRecord.billingAmount', + 'bsonType' => 'int', + 'keyId' => null, + 'queries' => ['queryType' => 'range', 'min' => 100, 'max' => 2000, 'sparsity' => 1, 'trimFactor' => 4], + ], + ]; + + self::assertEquals($expected, $encryptedFieldsMap); + } + + public function testGetEncryptionFieldsMapForClassForEmbeddedDocument(): void + { + $factory = new EncryptedFieldsMapGenerator($this->dm->getMetadataFactory()); + $encryptedFieldsMap = $factory->getEncryptedFieldsMapForClass(Client::class); + + $expected = [ + [ + 'path' => 'name', + 'bsonType' => 'string', + 'keyId' => null, + ], + [ + 'path' => 'clientCards', + 'bsonType' => 'array', + 'keyId' => null, + ], + ]; + + self::assertSame($expected, $encryptedFieldsMap); + } + + public function testVariousRangeTypes(): void + { + $factory = new EncryptedFieldsMapGenerator($this->dm->getMetadataFactory()); + $encryptedFieldsMap = $factory->getEncryptedFieldsMapForClass(RangeTypes::class); + + $expected = [ + [ + 'path' => 'intField', + 'bsonType' => 'int', + 'keyId' => null, + 'queries' => ['queryType' => 'range', 'min' => 5, 'max' => 10], + ], + [ + 'path' => 'floatField', + 'bsonType' => 'float', + 'keyId' => null, + 'queries' => ['queryType' => 'range', 'min' => 5.5, 'max' => 10.5], + ], + [ + 'path' => 'decimalField', + 'bsonType' => 'decimal128', + 'keyId' => null, + 'queries' => ['queryType' => 'range', 'min' => new Decimal128('0.1'), 'max' => new Decimal128('0.2')], + ], + [ + 'path' => 'dateField', + 'bsonType' => 'date_immutable', + 'keyId' => null, + 'queries' => [ + 'queryType' => 'range', + 'min' => new UTCDateTime(new DateTimeImmutable('2000-01-01 00:00:00')), + 'max' => new UTCDateTime(new DateTimeImmutable('2100-01-01 00:00:00')), + ], + ], + ]; + + self::assertEquals($expected, $encryptedFieldsMap); + } + + public function testRootDocumentsCannotBeEncrypted(): void + { + $this->expectException(MappingException::class); + $this->expectExceptionMessage('The root document class "Documents\Encryption\InvalidRootEncrypt" cannot be encrypted. Only fields and embedded documents can be encrypted.'); + + $factory = new EncryptedFieldsMapGenerator($this->dm->getMetadataFactory()); + $factory->getEncryptedFieldsMapForClass(InvalidRootEncrypt::class); + } + + public function testGetEncryptionFieldsMap(): void + { + $classMetadataFactory = $this->createMetadataFactory( + $this->dm->getMetadataFactory(), + Patient::class, + PatientRecord::class, + ); + + $factory = new EncryptedFieldsMapGenerator($classMetadataFactory); + $encryptedFieldsMap = $factory->getEncryptedFieldsMap(); + + $expectedEncryptedFieldsMap = [ + Patient::class => [ + [ + 'path' => 'patientRecord.ssn', + 'bsonType' => 'string', + 'keyId' => null, + 'queries' => ['queryType' => 'equality'], + ], + [ + 'path' => 'patientRecord.billing', + 'bsonType' => 'object', + 'keyId' => null, + ], + [ + 'path' => 'patientRecord.billingAmount', + 'bsonType' => 'int', + 'keyId' => null, + 'queries' => ['queryType' => 'range', 'min' => 100, 'max' => 2000, 'sparsity' => 1, 'trimFactor' => 4], + ], + ], + ]; + + $this->assertEquals($expectedEncryptedFieldsMap, $encryptedFieldsMap); + } + + private function createMetadataFactory(ClassMetadataFactoryInterface $classMetadataFactory, string ...$className): ClassMetadataFactoryInterface + { + return new class ($classMetadataFactory, $className) extends AbstractClassMetadataFactory implements ClassMetadataFactoryInterface + { + public function __construct(private ClassMetadataFactoryInterface $classMetadataFactory, private array $classNames) + { + } + + public function getAllMetadata(): array + { + return array_map( + $this->classMetadataFactory->getMetadataFor(...), + $this->classNames, + ); + } + + public function getMetadataFor(string $className): ClassMetadata + { + return $this->classMetadataFactory->getMetadataFor($className); + } + + protected function initialize(): void + { + } + + protected function getDriver(): MappingDriver + { + return $this->classMetadataFactory->getDriver(); + } + + protected function wakeupReflection(\Doctrine\Persistence\Mapping\ClassMetadata $class, ReflectionService $reflService): void + { + $this->classMetadataFactory->wakeupReflection($class, $reflService); + } + + protected function initializeReflection(\Doctrine\Persistence\Mapping\ClassMetadata $class, ReflectionService $reflService): void + { + $this->classMetadataFactory->initializeReflection($class, $reflService); + } + + protected function isEntity(\Doctrine\Persistence\Mapping\ClassMetadata $class): bool + { + return $this->classMetadataFactory->isEntity($class); + } + + protected function doLoadMetadata(\Doctrine\Persistence\Mapping\ClassMetadata $class, ?\Doctrine\Persistence\Mapping\ClassMetadata $parent, bool $rootEntityFound, array $nonSuperclassParents): void + { + $this->classMetadataFactory->doLoadMetadata($class, $parent, $rootEntityFound, $nonSuperclassParents); + } + + protected function newClassMetadataInstance(string $className): ClassMetadata + { + return $this->classMetadataFactory->newClassMetadataInstance($className); + } + + public function setConfiguration(Configuration $config): void + { + } + + public function setDocumentManager(DocumentManager $dm): void + { + } + }; + } +} diff --git a/tests/Doctrine/ODM/MongoDB/Tests/Tools/EncryptionFieldMapTest.php b/tests/Doctrine/ODM/MongoDB/Tests/Tools/EncryptionFieldMapTest.php deleted file mode 100644 index 89298d9f0..000000000 --- a/tests/Doctrine/ODM/MongoDB/Tests/Tools/EncryptionFieldMapTest.php +++ /dev/null @@ -1,116 +0,0 @@ -dm->getMetadataFactory()); - $encryptedFieldsMap = $factory->getEncryptionFieldMap(Patient::class); - - $expected = [ - [ - 'path' => 'patientRecord.ssn', - 'bsonType' => 'string', - 'keyId' => null, - 'queries' => ['queryType' => 'equality'], - ], - [ - 'path' => 'patientRecord.billing', - 'bsonType' => 'object', - 'keyId' => null, - ], - [ - 'path' => 'patientRecord.billingAmount', - 'bsonType' => 'int', - 'keyId' => null, - 'queries' => ['queryType' => 'range', 'min' => 100, 'max' => 2000, 'sparsity' => 1, 'trimFactor' => 4], - ], - ]; - - self::assertEquals($expected, $encryptedFieldsMap); - } - - public function testEncryptEmbeddedDocument(): void - { - $factory = new EncryptionFieldMap($this->dm->getMetadataFactory()); - $encryptedFieldsMap = $factory->getEncryptionFieldMap(Client::class); - - $expected = [ - [ - 'path' => 'name', - 'bsonType' => 'string', - 'keyId' => null, - ], - [ - 'path' => 'clientCards', - 'bsonType' => 'array', - 'keyId' => null, - ], - ]; - - self::assertSame($expected, $encryptedFieldsMap); - } - - public function testVariousRangeTypes(): void - { - $factory = new EncryptionFieldMap($this->dm->getMetadataFactory()); - $encryptedFieldsMap = $factory->getEncryptionFieldMap(RangeTypes::class); - - $expected = [ - [ - 'path' => 'intField', - 'bsonType' => 'int', - 'keyId' => null, - 'queries' => ['queryType' => 'range', 'min' => 5, 'max' => 10], - ], - [ - 'path' => 'floatField', - 'bsonType' => 'float', - 'keyId' => null, - 'queries' => ['queryType' => 'range', 'min' => 5.5, 'max' => 10.5], - ], - [ - 'path' => 'decimalField', - 'bsonType' => 'decimal128', - 'keyId' => null, - 'queries' => ['queryType' => 'range', 'min' => new Decimal128('0.1'), 'max' => new Decimal128('0.2')], - ], - [ - 'path' => 'dateField', - 'bsonType' => 'date_immutable', - 'keyId' => null, - 'queries' => [ - 'queryType' => 'range', - 'min' => new UTCDateTime(new DateTimeImmutable('2000-01-01 00:00:00')), - 'max' => new UTCDateTime(new DateTimeImmutable('2100-01-01 00:00:00')), - ], - ], - ]; - - self::assertEquals($expected, $encryptedFieldsMap); - } - - public function testRootDocumentsCannotBeEncrypted(): void - { - $this->expectException(MappingException::class); - $this->expectExceptionMessage('The root document class "Documents\Encryption\InvalidRootEncrypt" cannot be encrypted. Only fields and embedded documents can be encrypted.'); - - $factory = new EncryptionFieldMap($this->dm->getMetadataFactory()); - $factory->getEncryptionFieldMap(InvalidRootEncrypt::class); - } -} From 4776dcf74f90ba2f25cd0ee606353b14ca4da9b2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=A9r=C3=B4me=20Tamarelle?= Date: Mon, 23 Jun 2025 08:35:40 +0200 Subject: [PATCH 04/22] Drop metadata collection when dropping encrypted collection (#2781) --- lib/Doctrine/ODM/MongoDB/SchemaManager.php | 8 ++++++++ phpstan-baseline.neon | 6 ++++++ .../MongoDB/Tests/Functional/QueryableEncryptionTest.php | 8 ++++++++ 3 files changed, 22 insertions(+) diff --git a/lib/Doctrine/ODM/MongoDB/SchemaManager.php b/lib/Doctrine/ODM/MongoDB/SchemaManager.php index 68b4c55ab..32610fae3 100644 --- a/lib/Doctrine/ODM/MongoDB/SchemaManager.php +++ b/lib/Doctrine/ODM/MongoDB/SchemaManager.php @@ -700,6 +700,14 @@ public function dropDocumentCollection(string $documentName, ?int $maxTimeMs = n $options = $this->getWriteOptions($maxTimeMs, $writeConcern); + // When automatic encryption is enabled, we need to drop the metadata collections, + // we don't check if the class metadata has encrypted fields, because + // that does not mean that the existing collection is encrypted or not. + // "esc" and "ecoc" collections cannot be configured + if ($this->dm->getConfiguration()->getKmsProvider()) { + $options['encryptedFields'] = []; + } + $this->dm->getDocumentCollection($documentName)->drop($options); if (! $class->isFile) { diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon index 86d01561c..8368c8f31 100644 --- a/phpstan-baseline.neon +++ b/phpstan-baseline.neon @@ -1681,6 +1681,12 @@ parameters: count: 1 path: tests/Doctrine/ODM/MongoDB/Tests/Functional/QueryableEncryptionTest.php + - + message: '#^Parameter \#1 \$value of function count expects array\|Countable, Iterator\ given\.$#' + identifier: argument.type + count: 1 + path: tests/Doctrine/ODM/MongoDB/Tests/Functional/QueryableEncryptionTest.php + - message: '#^Parameter \$discriminatorMap of attribute class Doctrine\\ODM\\MongoDB\\Mapping\\Annotations\\ReferenceOne constructor expects array\\|null, array\ given\.$#' identifier: argument.type diff --git a/tests/Doctrine/ODM/MongoDB/Tests/Functional/QueryableEncryptionTest.php b/tests/Doctrine/ODM/MongoDB/Tests/Functional/QueryableEncryptionTest.php index 75585d412..c12030688 100644 --- a/tests/Doctrine/ODM/MongoDB/Tests/Functional/QueryableEncryptionTest.php +++ b/tests/Doctrine/ODM/MongoDB/Tests/Functional/QueryableEncryptionTest.php @@ -13,6 +13,7 @@ use MongoDB\Client; use MongoDB\Model\BSONDocument; +use function count; use function iterator_to_array; use function random_bytes; @@ -80,6 +81,13 @@ public function testCreateAndQueryEncryptedCollection(): void self::assertSame('Jon Doe', $result->patientName); self::assertSame('987-65-4320', $result->patientRecord->ssn); self::assertSame('4111111111111111', $result->patientRecord->billing->number); + + // Drop the encrypted collection + $collectionCount = count($nonEncryptedDatabase->listCollectionNames()); + $this->dm->getSchemaManager()->dropDocumentCollection(Patient::class); + $collectionNames = iterator_to_array($nonEncryptedDatabase->listCollectionNames()); + self::assertNotContains('patients', $collectionNames); + self::assertSame($collectionCount - 3, count($collectionNames), 'The 2 metadata collections should also be dropped'); } protected static function createTestDocumentManager(): DocumentManager From 9d7cdbc866ebb68a76b7c0bb64448a080c1a71ff Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=A9r=C3=B4me=20Tamarelle?= Date: Mon, 23 Jun 2025 15:48:37 +0200 Subject: [PATCH 05/22] Fix getDefaultKmsProvider method name (#2784) --- lib/Doctrine/ODM/MongoDB/SchemaManager.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/Doctrine/ODM/MongoDB/SchemaManager.php b/lib/Doctrine/ODM/MongoDB/SchemaManager.php index 32610fae3..5b76b284b 100644 --- a/lib/Doctrine/ODM/MongoDB/SchemaManager.php +++ b/lib/Doctrine/ODM/MongoDB/SchemaManager.php @@ -704,7 +704,7 @@ public function dropDocumentCollection(string $documentName, ?int $maxTimeMs = n // we don't check if the class metadata has encrypted fields, because // that does not mean that the existing collection is encrypted or not. // "esc" and "ecoc" collections cannot be configured - if ($this->dm->getConfiguration()->getKmsProvider()) { + if ($this->dm->getConfiguration()->getDefaultKmsProvider()) { $options['encryptedFields'] = []; } From 5f4256276d887456363577ed8eb113dfd15580ef Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=A9r=C3=B4me=20Tamarelle?= Date: Tue, 24 Jun 2025 14:21:22 +0200 Subject: [PATCH 06/22] [Encryption] Fix BSON types and query option name (#2785) --- .github/workflows/continuous-integration.yml | 1 + .../MongoDB/Mapping/Annotations/Encrypt.php | 4 ++-- .../ODM/MongoDB/Mapping/ClassMetadata.php | 4 ++-- .../ODM/MongoDB/Mapping/Driver/XmlDriver.php | 13 ++--------- .../Utility/EncryptedFieldsMapGenerator.php | 19 ++++++++++++---- phpstan-baseline.neon | 22 +++++++------------ .../Functional/QueryableEncryptionTest.php | 10 +++++++++ .../Tools/EncryptedFieldsMapGeneratorTest.php | 13 ++++++----- tests/Documents/Encryption/RangeTypes.php | 13 ++++++++--- 9 files changed, 58 insertions(+), 41 deletions(-) diff --git a/.github/workflows/continuous-integration.yml b/.github/workflows/continuous-integration.yml index 4702126ed..4495b308b 100644 --- a/.github/workflows/continuous-integration.yml +++ b/.github/workflows/continuous-integration.yml @@ -163,3 +163,4 @@ jobs: env: DOCTRINE_MONGODB_SERVER: ${{ steps.setup-mongodb.outputs.cluster-uri }} USE_LAZY_GHOST_OBJECTS: ${{ matrix.proxy == 'lazy-ghost' && '1' || '0' }}" + CRYPT_SHARED_LIB_PATH: ${{ steps.setup-mongodb.outputs.crypt-shared-lib-path }} diff --git a/lib/Doctrine/ODM/MongoDB/Mapping/Annotations/Encrypt.php b/lib/Doctrine/ODM/MongoDB/Mapping/Annotations/Encrypt.php index 8a5a51a35..1f7fbc90e 100644 --- a/lib/Doctrine/ODM/MongoDB/Mapping/Annotations/Encrypt.php +++ b/lib/Doctrine/ODM/MongoDB/Mapping/Annotations/Encrypt.php @@ -28,7 +28,7 @@ final class Encrypt implements Annotation /** * @param EncryptQuery|null $queryType Set the query type for the field, null if not queryable. * @param int<1, 4>|null $sparsity - * @param positive-int|null $prevision + * @param positive-int|null $precision * @param positive-int|null $trimFactor * @param positive-int|null $contention */ @@ -37,7 +37,7 @@ public function __construct( int|float|Int64|Decimal128|UTCDateTime|DateTimeInterface|null $min = null, int|float|Int64|Decimal128|UTCDateTime|DateTimeInterface|null $max = null, public ?int $sparsity = null, - public ?int $prevision = null, + public ?int $precision = null, public ?int $trimFactor = null, public ?int $contention = null, ) { diff --git a/lib/Doctrine/ODM/MongoDB/Mapping/ClassMetadata.php b/lib/Doctrine/ODM/MongoDB/Mapping/ClassMetadata.php index d05e4c4b7..2db3dc562 100644 --- a/lib/Doctrine/ODM/MongoDB/Mapping/ClassMetadata.php +++ b/lib/Doctrine/ODM/MongoDB/Mapping/ClassMetadata.php @@ -107,7 +107,7 @@ * order?: int|string, * background?: bool, * enumType?: class-string, - * encrypt?: array{queryType?: ?string, min?: mixed, max?: mixed, sparsity?: int<1, 4>, prevision?: int, trimFactor?: int, contention?: int} + * encrypt?: array{queryType?: ?string, min?: mixed, max?: mixed, sparsity?: int<1, 4>, precision?: int, trimFactor?: int, contention?: int} * } * @phpstan-type FieldMapping array{ * type: string, @@ -154,7 +154,7 @@ * alsoLoadFields?: list, * enumType?: class-string, * storeEmptyArray?: bool, - * encrypt?: array{queryType?: ?string, min?: mixed, max?: mixed, sparsity?: int<1, 4>, prevision?: int, trimFactor?: int, contention?: int}, + * encrypt?: array{queryType?: ?string, min?: mixed, max?: mixed, sparsity?: int<1, 4>, precision?: int, trimFactor?: int, contention?: int}, * } * @phpstan-type AssociationFieldMapping array{ * type?: string, diff --git a/lib/Doctrine/ODM/MongoDB/Mapping/Driver/XmlDriver.php b/lib/Doctrine/ODM/MongoDB/Mapping/Driver/XmlDriver.php index 4e362e064..70b9708d6 100644 --- a/lib/Doctrine/ODM/MongoDB/Mapping/Driver/XmlDriver.php +++ b/lib/Doctrine/ODM/MongoDB/Mapping/Driver/XmlDriver.php @@ -4,7 +4,6 @@ namespace Doctrine\ODM\MongoDB\Mapping\Driver; -use DateTimeImmutable; use Doctrine\ODM\MongoDB\Mapping\Annotations\TimeSeries; use Doctrine\ODM\MongoDB\Mapping\ClassMetadata; use Doctrine\ODM\MongoDB\Mapping\MappingException; @@ -15,9 +14,7 @@ use DOMDocument; use InvalidArgumentException; use LibXMLError; -use MongoDB\BSON\Decimal128; use MongoDB\BSON\Document; -use MongoDB\BSON\UTCDateTime; use MongoDB\Driver\Exception\UnexpectedValueException; use SimpleXMLElement; @@ -319,14 +316,8 @@ public function loadMetadataForClass($className, \Doctrine\Persistence\Mapping\C foreach ($field->encrypt->attributes() as $encryptKey => $encryptValue) { $mapping['encrypt'][$encryptKey] = match ($encryptKey) { 'queryType' => (string) $encryptValue, - 'min', 'max' => match ($mapping['type']) { - Type::INT => (int) $encryptValue, - Type::FLOAT => (float) $encryptValue, - Type::DECIMAL128 => new Decimal128((string) $encryptValue), - Type::DATE, Type::DATE_IMMUTABLE => new UTCDateTime(new DateTimeImmutable((string) $encryptValue)), - default => null, // Invalid - }, - 'sparsity', 'prevision', 'trimFactor', 'contention' => (int) $encryptValue, + 'min', 'max' => Type::getType($mapping['type'])->convertToDatabaseValue((string) $encryptValue), + 'sparsity', 'precision', 'trimFactor', 'contention' => (int) $encryptValue, }; } } diff --git a/lib/Doctrine/ODM/MongoDB/Utility/EncryptedFieldsMapGenerator.php b/lib/Doctrine/ODM/MongoDB/Utility/EncryptedFieldsMapGenerator.php index 08492e0d4..b426852c2 100644 --- a/lib/Doctrine/ODM/MongoDB/Utility/EncryptedFieldsMapGenerator.php +++ b/lib/Doctrine/ODM/MongoDB/Utility/EncryptedFieldsMapGenerator.php @@ -8,11 +8,14 @@ use Doctrine\ODM\MongoDB\Mapping\ClassMetadata; use Doctrine\ODM\MongoDB\Mapping\ClassMetadataFactoryInterface; use Doctrine\ODM\MongoDB\Mapping\MappingException; +use Doctrine\ODM\MongoDB\Types\Type; use Generator; +use LogicException; use function array_filter; use function assert; use function iterator_to_array; +use function sprintf; final class EncryptedFieldsMapGenerator { @@ -108,11 +111,19 @@ private function createEncryptedFieldsMapForClass( $field = [ 'path' => $path . $mapping['name'], 'bsonType' => match ($mapping['type']) { - 'one' => 'object', - 'many' => 'array', - default => $mapping['type'], + ClassMetadata::ONE, Type::HASH => 'object', + ClassMetadata::MANY, Type::COLLECTION => 'array', + Type::INT, Type::INTEGER => 'int', + Type::FLOAT => 'double', + Type::DECIMAL128 => 'decimal', + Type::DATE, Type::DATE_IMMUTABLE => 'date', + Type::TIMESTAMP => 'timestamp', + Type::OBJECTID => 'objectId', + Type::STRING => 'string', + Type::BINDATA, Type::BINDATABYTEARRAY, Type::BINDATAFUNC, Type::BINDATACUSTOM, Type::BINDATAUUID, Type::BINDATAMD5, Type::BINDATAUUIDRFC4122 => 'binData', + Type::BOOL, Type::BOOLEAN => 'bool', + default => throw new LogicException(sprintf('Type "%s" is not supported in encrypted fields map.', $mapping['type'])), }, - // @todo allow setting a keyId in #[Encrypt] attribute 'keyId' => null, // Generate the key automatically ]; diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon index 8368c8f31..bd4711a5d 100644 --- a/phpstan-baseline.neon +++ b/phpstan-baseline.neon @@ -961,7 +961,7 @@ parameters: path: lib/Doctrine/ODM/MongoDB/Mapping/Driver/XmlDriver.php - - message: '#^Parameter \#2 \$mapping of method Doctrine\\ODM\\MongoDB\\Mapping\\Driver\\XmlDriver\:\:addFieldMapping\(\) expects array\{type\?\: string, fieldName\?\: string, name\?\: string, strategy\?\: string, association\?\: int, id\?\: bool, isOwningSide\?\: bool, collectionClass\?\: class\-string, \.\.\.\}, array\\|string, float\|int\|MongoDB\\BSON\\Decimal128\|MongoDB\\BSON\\UTCDateTime\|string\|null\>\|bool\|string\> given\.$#' + message: '#^Parameter \#2 \$mapping of method Doctrine\\ODM\\MongoDB\\Mapping\\Driver\\XmlDriver\:\:addFieldMapping\(\) expects array\{type\?\: string, fieldName\?\: string, name\?\: string, strategy\?\: string, association\?\: int, id\?\: bool, isOwningSide\?\: bool, collectionClass\?\: class\-string, \.\.\.\}, array\\|string, mixed\>\|bool\|string\> given\.$#' identifier: argument.type count: 1 path: lib/Doctrine/ODM/MongoDB/Mapping/Driver/XmlDriver.php @@ -1675,12 +1675,6 @@ parameters: count: 3 path: tests/Doctrine/ODM/MongoDB/Tests/Functional/QueryableEncryptionTest.php - - - message: '#^Parameter \#1 \$options of method Doctrine\\ODM\\MongoDB\\Configuration\:\:setAutoEncryption\(\) expects array\{keyVaultNamespace\: string, kmsProviders\: array\\>, tlsOptions\?\: array\{kmip\: array\{tlsCAFile\: string, tlsCertificateKeyFile\: string\}\}\}, array\{keyVaultNamespace\: non\-falsy\-string, kmsProviders\: array\{local\: array\{key\: MongoDB\\BSON\\Binary\}\}\} given\.$#' - identifier: argument.type - count: 1 - path: tests/Doctrine/ODM/MongoDB/Tests/Functional/QueryableEncryptionTest.php - - message: '#^Parameter \#1 \$value of function count expects array\|Countable, Iterator\ given\.$#' identifier: argument.type @@ -2012,43 +2006,43 @@ parameters: path: tests/Doctrine/ODM/MongoDB/Tests/Tools/EncryptedFieldsMapGeneratorTest.php - - message: '#^Method Doctrine\\Persistence\\Mapping\\AbstractClassMetadataFactory@anonymous/tests/Doctrine/ODM/MongoDB/Tests/Tools/EncryptedFieldsMapGeneratorTest\.php\:165\:\:__construct\(\) has parameter \$classNames with no value type specified in iterable type array\.$#' + message: '#^Method Doctrine\\Persistence\\Mapping\\AbstractClassMetadataFactory@anonymous/tests/Doctrine/ODM/MongoDB/Tests/Tools/EncryptedFieldsMapGeneratorTest\.php\:168\:\:__construct\(\) has parameter \$classNames with no value type specified in iterable type array\.$#' identifier: missingType.iterableValue count: 1 path: tests/Doctrine/ODM/MongoDB/Tests/Tools/EncryptedFieldsMapGeneratorTest.php - - message: '#^Method Doctrine\\Persistence\\Mapping\\AbstractClassMetadataFactory@anonymous/tests/Doctrine/ODM/MongoDB/Tests/Tools/EncryptedFieldsMapGeneratorTest\.php\:165\:\:doLoadMetadata\(\) has parameter \$class with generic interface Doctrine\\Persistence\\Mapping\\ClassMetadata but does not specify its types\: T$#' + message: '#^Method Doctrine\\Persistence\\Mapping\\AbstractClassMetadataFactory@anonymous/tests/Doctrine/ODM/MongoDB/Tests/Tools/EncryptedFieldsMapGeneratorTest\.php\:168\:\:doLoadMetadata\(\) has parameter \$class with generic interface Doctrine\\Persistence\\Mapping\\ClassMetadata but does not specify its types\: T$#' identifier: missingType.generics count: 1 path: tests/Doctrine/ODM/MongoDB/Tests/Tools/EncryptedFieldsMapGeneratorTest.php - - message: '#^Method Doctrine\\Persistence\\Mapping\\AbstractClassMetadataFactory@anonymous/tests/Doctrine/ODM/MongoDB/Tests/Tools/EncryptedFieldsMapGeneratorTest\.php\:165\:\:doLoadMetadata\(\) has parameter \$parent with generic interface Doctrine\\Persistence\\Mapping\\ClassMetadata but does not specify its types\: T$#' + message: '#^Method Doctrine\\Persistence\\Mapping\\AbstractClassMetadataFactory@anonymous/tests/Doctrine/ODM/MongoDB/Tests/Tools/EncryptedFieldsMapGeneratorTest\.php\:168\:\:doLoadMetadata\(\) has parameter \$parent with generic interface Doctrine\\Persistence\\Mapping\\ClassMetadata but does not specify its types\: T$#' identifier: missingType.generics count: 1 path: tests/Doctrine/ODM/MongoDB/Tests/Tools/EncryptedFieldsMapGeneratorTest.php - - message: '#^Method Doctrine\\Persistence\\Mapping\\AbstractClassMetadataFactory@anonymous/tests/Doctrine/ODM/MongoDB/Tests/Tools/EncryptedFieldsMapGeneratorTest\.php\:165\:\:getAllMetadata\(\) return type with generic interface Doctrine\\Persistence\\Mapping\\ClassMetadata does not specify its types\: T$#' + message: '#^Method Doctrine\\Persistence\\Mapping\\AbstractClassMetadataFactory@anonymous/tests/Doctrine/ODM/MongoDB/Tests/Tools/EncryptedFieldsMapGeneratorTest\.php\:168\:\:getAllMetadata\(\) return type with generic interface Doctrine\\Persistence\\Mapping\\ClassMetadata does not specify its types\: T$#' identifier: missingType.generics count: 1 path: tests/Doctrine/ODM/MongoDB/Tests/Tools/EncryptedFieldsMapGeneratorTest.php - - message: '#^Method Doctrine\\Persistence\\Mapping\\AbstractClassMetadataFactory@anonymous/tests/Doctrine/ODM/MongoDB/Tests/Tools/EncryptedFieldsMapGeneratorTest\.php\:165\:\:initializeReflection\(\) has parameter \$class with generic interface Doctrine\\Persistence\\Mapping\\ClassMetadata but does not specify its types\: T$#' + message: '#^Method Doctrine\\Persistence\\Mapping\\AbstractClassMetadataFactory@anonymous/tests/Doctrine/ODM/MongoDB/Tests/Tools/EncryptedFieldsMapGeneratorTest\.php\:168\:\:initializeReflection\(\) has parameter \$class with generic interface Doctrine\\Persistence\\Mapping\\ClassMetadata but does not specify its types\: T$#' identifier: missingType.generics count: 1 path: tests/Doctrine/ODM/MongoDB/Tests/Tools/EncryptedFieldsMapGeneratorTest.php - - message: '#^Method Doctrine\\Persistence\\Mapping\\AbstractClassMetadataFactory@anonymous/tests/Doctrine/ODM/MongoDB/Tests/Tools/EncryptedFieldsMapGeneratorTest\.php\:165\:\:isEntity\(\) has parameter \$class with generic interface Doctrine\\Persistence\\Mapping\\ClassMetadata but does not specify its types\: T$#' + message: '#^Method Doctrine\\Persistence\\Mapping\\AbstractClassMetadataFactory@anonymous/tests/Doctrine/ODM/MongoDB/Tests/Tools/EncryptedFieldsMapGeneratorTest\.php\:168\:\:isEntity\(\) has parameter \$class with generic interface Doctrine\\Persistence\\Mapping\\ClassMetadata but does not specify its types\: T$#' identifier: missingType.generics count: 1 path: tests/Doctrine/ODM/MongoDB/Tests/Tools/EncryptedFieldsMapGeneratorTest.php - - message: '#^Method Doctrine\\Persistence\\Mapping\\AbstractClassMetadataFactory@anonymous/tests/Doctrine/ODM/MongoDB/Tests/Tools/EncryptedFieldsMapGeneratorTest\.php\:165\:\:wakeupReflection\(\) has parameter \$class with generic interface Doctrine\\Persistence\\Mapping\\ClassMetadata but does not specify its types\: T$#' + message: '#^Method Doctrine\\Persistence\\Mapping\\AbstractClassMetadataFactory@anonymous/tests/Doctrine/ODM/MongoDB/Tests/Tools/EncryptedFieldsMapGeneratorTest\.php\:168\:\:wakeupReflection\(\) has parameter \$class with generic interface Doctrine\\Persistence\\Mapping\\ClassMetadata but does not specify its types\: T$#' identifier: missingType.generics count: 1 path: tests/Doctrine/ODM/MongoDB/Tests/Tools/EncryptedFieldsMapGeneratorTest.php diff --git a/tests/Doctrine/ODM/MongoDB/Tests/Functional/QueryableEncryptionTest.php b/tests/Doctrine/ODM/MongoDB/Tests/Functional/QueryableEncryptionTest.php index c12030688..644e9005c 100644 --- a/tests/Doctrine/ODM/MongoDB/Tests/Functional/QueryableEncryptionTest.php +++ b/tests/Doctrine/ODM/MongoDB/Tests/Functional/QueryableEncryptionTest.php @@ -14,6 +14,7 @@ use MongoDB\Model\BSONDocument; use function count; +use function getenv; use function iterator_to_array; use function random_bytes; @@ -99,6 +100,15 @@ protected static function createTestDocumentManager(): DocumentManager 'key' => new Binary(random_bytes(96)), ]); + $autoEncryptionOptions = []; + + $cryptSharedLibPath = getenv('CRYPT_SHARED_LIB_PATH'); + if ($cryptSharedLibPath) { + $autoEncryptionOptions['extraOptions']['cryptSharedLibPath'] = $cryptSharedLibPath; + } + + $config->setAutoEncryption($autoEncryptionOptions); + $client = new Client(self::getUri(), [], $config->getDriverOptions()); return DocumentManager::create($client, $config); diff --git a/tests/Doctrine/ODM/MongoDB/Tests/Tools/EncryptedFieldsMapGeneratorTest.php b/tests/Doctrine/ODM/MongoDB/Tests/Tools/EncryptedFieldsMapGeneratorTest.php index 569d99043..028fe827e 100644 --- a/tests/Doctrine/ODM/MongoDB/Tests/Tools/EncryptedFieldsMapGeneratorTest.php +++ b/tests/Doctrine/ODM/MongoDB/Tests/Tools/EncryptedFieldsMapGeneratorTest.php @@ -90,24 +90,27 @@ public function testVariousRangeTypes(): void ], [ 'path' => 'floatField', - 'bsonType' => 'float', + 'bsonType' => 'double', 'keyId' => null, - 'queries' => ['queryType' => 'range', 'min' => 5.5, 'max' => 10.5], + 'queries' => ['queryType' => 'range', 'min' => 5.5, 'max' => 10.5, 'precision' => 1], ], [ 'path' => 'decimalField', - 'bsonType' => 'decimal128', + 'bsonType' => 'decimal', 'keyId' => null, - 'queries' => ['queryType' => 'range', 'min' => new Decimal128('0.1'), 'max' => new Decimal128('0.2')], + 'queries' => ['queryType' => 'range', 'min' => new Decimal128('0.1'), 'max' => new Decimal128('0.2'), 'precision' => 2], ], [ 'path' => 'dateField', - 'bsonType' => 'date_immutable', + 'bsonType' => 'date', 'keyId' => null, 'queries' => [ 'queryType' => 'range', 'min' => new UTCDateTime(new DateTimeImmutable('2000-01-01 00:00:00')), 'max' => new UTCDateTime(new DateTimeImmutable('2100-01-01 00:00:00')), + 'sparsity' => 1, + 'trimFactor' => 3, + 'contention' => 4, ], ], ]; diff --git a/tests/Documents/Encryption/RangeTypes.php b/tests/Documents/Encryption/RangeTypes.php index a16c60afb..a0684506f 100644 --- a/tests/Documents/Encryption/RangeTypes.php +++ b/tests/Documents/Encryption/RangeTypes.php @@ -29,14 +29,21 @@ class RangeTypes public int $intField; #[Field(type: Type::FLOAT)] - #[Encrypt(EncryptQuery::Range, min: 5.5, max: 10.5)] + #[Encrypt(EncryptQuery::Range, min: 5.5, max: 10.5, precision: 1)] public float $floatField; #[Field(type: Type::DECIMAL128)] - #[Encrypt(EncryptQuery::Range, min: new Decimal128('0.1'), max: new Decimal128('0.2'))] + #[Encrypt(EncryptQuery::Range, min: new Decimal128('0.1'), max: new Decimal128('0.2'), precision: 2)] public Decimal128 $decimalField; #[Field(type: Type::DATE_IMMUTABLE)] - #[Encrypt(EncryptQuery::Range, min: new DateTimeImmutable('2000-01-01 00:00:00'), max: new DateTimeImmutable('2100-01-01 00:00:00'))] + #[Encrypt( + queryType: EncryptQuery::Range, + min: new DateTimeImmutable('2000-01-01 00:00:00'), + max: new DateTimeImmutable('2100-01-01 00:00:00'), + sparsity: 1, + trimFactor: 3, + contention: 4, + )] public DateTimeImmutable $dateField; } From f852bd2d856916501266258f41ff7d6872905567 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=A9r=C3=B4me=20Tamarelle?= Date: Thu, 10 Jul 2025 13:07:55 +0200 Subject: [PATCH 07/22] Remove encryptedFields option when dropping collection (#2788) The driver already detects encrypted collections --- lib/Doctrine/ODM/MongoDB/SchemaManager.php | 8 -------- 1 file changed, 8 deletions(-) diff --git a/lib/Doctrine/ODM/MongoDB/SchemaManager.php b/lib/Doctrine/ODM/MongoDB/SchemaManager.php index 5b76b284b..68b4c55ab 100644 --- a/lib/Doctrine/ODM/MongoDB/SchemaManager.php +++ b/lib/Doctrine/ODM/MongoDB/SchemaManager.php @@ -700,14 +700,6 @@ public function dropDocumentCollection(string $documentName, ?int $maxTimeMs = n $options = $this->getWriteOptions($maxTimeMs, $writeConcern); - // When automatic encryption is enabled, we need to drop the metadata collections, - // we don't check if the class metadata has encrypted fields, because - // that does not mean that the existing collection is encrypted or not. - // "esc" and "ecoc" collections cannot be configured - if ($this->dm->getConfiguration()->getDefaultKmsProvider()) { - $options['encryptedFields'] = []; - } - $this->dm->getDocumentCollection($documentName)->drop($options); if (! $class->isFile) { From 097c4d51a2fa662f0a82ed81e6d346e2a70b4eb8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=A9r=C3=B4me=20Tamarelle?= Date: Tue, 5 Aug 2025 14:12:39 +0200 Subject: [PATCH 08/22] [Encryption] PHPORM-360 Document limitations of encryption with collection inheritance (#2790) --- docs/en/reference/attributes-reference.rst | 46 ++++++++++++++++++++++ docs/en/reference/migrating-schemas.rst | 38 ++++++++++++++++++ 2 files changed, 84 insertions(+) diff --git a/docs/en/reference/attributes-reference.rst b/docs/en/reference/attributes-reference.rst index fb8d53ebb..b60467268 100644 --- a/docs/en/reference/attributes-reference.rst +++ b/docs/en/reference/attributes-reference.rst @@ -356,6 +356,10 @@ Optional arguments: users only. The default values for these options are suitable for the majority of use cases, and should only be modified if your use case requires it. +.. note:: + + Queryable encryption is only supported in MongoDB version 8.0 and later. + Example: .. code-block:: php @@ -373,9 +377,50 @@ Example: public string $name; } +The ``#[Encrypt]`` attribute can be added to a class with `#[EmbeddedDocument]`_. +This will encrypt the entire embedded document, in the field that contains it. +Queryable encryption is not supported for embedded documents, so the ``queryType`` +argument is not applicable. Encrypted embedded documents are stored as a binary +value in the parent document. + +.. code-block:: php + + `_. + +.. note:: + + The encrypted collection must be created with the `Schema Manager`_ before + before inserting documents. + +.. note:: + + Due to the way the encrypted fields map is generated, the queryable encryption + is not compatible with ``SINGLE_COLLECTION`` inheritance. + #[Field] -------- @@ -1439,5 +1484,6 @@ root class specified in the view mapping. .. _DBRef: https://docs.mongodb.com/manual/reference/database-references/#dbrefs .. _geoNear command: https://docs.mongodb.com/manual/reference/command/geoNear/ .. _MongoDB\BSON\ObjectId: https://www.php.net/class.mongodb-bson-objectid +.. _Schema Manager: ../reference/migrating-schemas .. |FQCN| raw:: html FQCN diff --git a/docs/en/reference/migrating-schemas.rst b/docs/en/reference/migrating-schemas.rst index 85fce2d0f..0b979572b 100644 --- a/docs/en/reference/migrating-schemas.rst +++ b/docs/en/reference/migrating-schemas.rst @@ -15,6 +15,44 @@ problem! for the Google App Engine datastore. Additional information may be found in the `Objectify schema migration`_ documentation. +Creating a collection +-------------------- + +Collections are automatically created by the MongoDB server upon first insertion. +You must explicitly create the collections if you need specific options, such as +validation rules. In particular, encrypted collections must be created explicitly. + +.. code-block:: php + + getSchemaManager(); + +To create the collections for all the document classes, you can use the +`createCollections()` method on the ``DocumentManager``: + +.. code-block:: php + + createCollections(); + +For a specific document class, you can use the `createDocumentCollection()` +method with the class name as an argument: + + createDocumentCollection(Person::class); + +Once the collection is created, you can also set up indexes with ``ensureIndexes``, +and search indexes with ``createSearchIndexes``: + + ensureIndexes(); + $schemaManager->createSearchIndexes(); + Renaming a Field ---------------- From bbb913481b8df5d27b5f6d066f7799d32a44c430 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=A9r=C3=B4me=20Tamarelle?= Date: Fri, 8 Aug 2025 09:05:18 +0200 Subject: [PATCH 09/22] =?UTF-8?q?[Encryption]=C2=A0Add=20cookbook=20for=20?= =?UTF-8?q?queryable=20encryption=20(#2793)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/en/cookbook/queryable-encryption.rst | 265 +++++++++++++++++++++ docs/en/reference/attributes-reference.rst | 2 + 2 files changed, 267 insertions(+) create mode 100644 docs/en/cookbook/queryable-encryption.rst diff --git a/docs/en/cookbook/queryable-encryption.rst b/docs/en/cookbook/queryable-encryption.rst new file mode 100644 index 000000000..87e41fc07 --- /dev/null +++ b/docs/en/cookbook/queryable-encryption.rst @@ -0,0 +1,265 @@ +Queryable Encryption +==================== + +This cookbook provides a tutorial on setting up and using Queryable Encryption +(QE) with Doctrine MongoDB ODM to protect sensitive data in your documents. + +Introduction +------------ + +In many applications, you need to store sensitive information like social +security numbers, financial data, or personal details. MongoDB's Queryable +Encryption allows you to encrypt this data on the client-side, store it as +fully randomized encrypted data, and still run expressive queries on it. This +ensures that sensitive data is never exposed in an unencrypted state on the +server, in system logs, or in backups. + +This tutorial will guide you through the process of securing a document's +fields using queryable encryption, from defining the document and configuring +the connection to storing and querying the encrypted data. + +.. note:: + + Queryable Encryption is only available on MongoDB Enterprise 8.0+ or + MongoDB Atlas. + +The Scenario +------------ + +We will model a ``Patient`` document that has an embedded ``PatientRecord``. +This record contains sensitive information: + +- A Social Security Number (``ssn``), which we need to query for exact + matches. +- A ``billingAmount``, which should support range queries. +- A ``billing`` object, which should be encrypted but not directly queryable. + +Defining the Documents +---------------------- + +First, let's define our ``Patient``, ``PatientRecord``, and ``Billing`` +classes. We use the :ref:`#[Encrypt] ` attribute to mark +fields that require encryption. + +.. code-block:: php + + setAutoEncryption([ + 'keyVaultNamespace' => 'encryption.datakeys' + ]); + $config->setKmsProvider([ + 'type' => 'local', + 'key' => new Binary($masterKey), + ]); + + // Other configuration + $config->setProxyDir(__DIR__ . '/Proxies'); + $config->setProxyNamespace('Proxies'); + $config->setHydratorDir(__DIR__ . '/Hydrators'); + $config->setHydratorNamespace('Hydrators'); + $config->setPersistentCollectionDir(__DIR__ . '/PersistentCollections'); + $config->setPersistentCollectionNamespace('PersistentCollections'); + $config->setDefaultDB('my_db'); + $config->setMetadataDriverImpl(new AttributeDriver([__DIR__])); + +Step 2: Create the DocumentManager +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +The ``MongoDB\Client`` will be instantiated with the options from the +configuration. + +.. code-block:: php + + getDriverOptions(), + ); + $documentManager = DocumentManager::create($client, $config); + +The ``driverOptions`` passed to the client contain the ``autoEncryption`` option +that was configured in the previous step. + +Step 3: Create the Encrypted Collection +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +Next, we use the ``SchemaManager`` to create the collection with the necessary +encryption metadata. To make the example re-runnable, we can drop the collection +first. + +.. code-block:: php + + getSchemaManager(); + $schemaManager->dropDocumentCollection(Patient::class); + $schemaManager->createDocumentCollection(Patient::class); + +Step 4: Persist and Query Documents +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +Finally, we can persist and query documents as usual. The encryption and +decryption will be handled automatically. + +.. code-block:: php + + patientRecord = new PatientRecord(); + $patient->patientRecord->ssn = '123-456-7890'; + $patient->patientRecord->billingAmount = 1500; + $patient->patientRecord->billing = new Billing(); + $patient->patientRecord->billing->creditCardNumber = '9876-5432-1098-7654'; + + $documentManager->persist($patient); + $documentManager->flush(); + $documentManager->clear(); + + // Query the document using an encrypted field + $foundPatient = $documentManager->getRepository(Patient::class)->findOneBy([ + 'patientRecord.ssn' => '123-456-7890', + ]); + + // The document is retrieved and its fields are automatically decrypted + assert($foundPatient instanceof Patient); + assert($foundPatient->patientRecord->billingAmount === 1500); + +What the Document Looks Like in the Database +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +When you inspect the document directly in the database (e.g., using ``mongosh`` +or `MongoDB Compass`_), you will see that the fields marked with ``#[Encrypt]`` +are stored as BSON binary data (subtype 6), not the original BSON type. The +driver also adds a ``__safeContent__`` field to the document. For more details, +see the `Queryable Encryption Fundamentals`_ in the MongoDB manual. + +.. code-block:: js + + { + "_id": ObjectId("..."), + "patientRecord": { + "ssn": Binary("...", 6), + "billing": Binary("...", 6), + "billingAmount": Binary("...", 6) + }, + "__safeContent__": [ + Binary("...", 0) + ] + } + +Limitations +----------- + +- The ODM simplifies configuration by supporting a single KMS provider per + ``DocumentManager`` through ``Configuration::setKmsProvider()``. If you need + to work with multiple KMS providers, you must manually configure the + ``kmsProviders`` array and pass it as a driver option, bypassing the ODM's + helper method. +- Automatic generation of the ``encryptedFieldsMap`` is not compatible with + ``SINGLE_COLLECTION`` inheritance. Because all classes in the hierarchy + share a single collection, they must also share a single encryption schema. + To use QE with inheritance, you must manually define the complete + ``encryptedFieldsMap`` for the entire hierarchy and provide it directly in + the client options, bypassing the ODM's automatic generation. +- For a complete list of hard limitations, please refer to the official + `Queryable Encryption Limitations`_ documentation. + +.. _MongoDB\Driver\Manager: https://www.php.net/manual/en/mongodb-driver-manager.construct.php#mongodb-driver-manager.construct-autoencryption +.. _MongoDB Compass: https://www.mongodb.com/products/compass +.. _Queryable Encryption Fundamentals: https://www.mongodb.com/docs/manual/core/queryable-encryption/fundamentals/#behavior +.. _Queryable Encryption Limitations: https://www.mongodb.com/docs/manual/core/queryable-encryption/reference/limitations/ diff --git a/docs/en/reference/attributes-reference.rst b/docs/en/reference/attributes-reference.rst index b60467268..2b5fb91e0 100644 --- a/docs/en/reference/attributes-reference.rst +++ b/docs/en/reference/attributes-reference.rst @@ -336,6 +336,8 @@ Unlike normal documents, embedded documents cannot specify their own database or collection. That said, a single embedded document class may be used with multiple document classes, and even other embedded documents! +.. _encrypt_attribute: + #[Encrypt] ---------- From 6c797fa5189ef2405deb07cefb7c674cbfde9a87 Mon Sep 17 00:00:00 2001 From: Andreas Braun Date: Fri, 8 Aug 2025 12:06:31 +0200 Subject: [PATCH 10/22] Fix typo in precision mapping option Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- docs/en/reference/attributes-reference.rst | 2 +- doctrine-mongo-mapping.xsd | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/en/reference/attributes-reference.rst b/docs/en/reference/attributes-reference.rst index 2b5fb91e0..dfd59c532 100644 --- a/docs/en/reference/attributes-reference.rst +++ b/docs/en/reference/attributes-reference.rst @@ -354,7 +354,7 @@ Optional arguments: - ``min``, ``max`` - Specify minimum and maximum (inclusive) queryable values for a field when possible, as smaller bounds improve query efficiency. If querying values outside of these bounds, MongoDB returns an error. -- ``sparsity``, ``prevision``, ``trimFactor``, ``contention`` - For advanced +- ``sparsity``, ``precision``, ``trimFactor``, ``contention`` - For advanced users only. The default values for these options are suitable for the majority of use cases, and should only be modified if your use case requires it. diff --git a/doctrine-mongo-mapping.xsd b/doctrine-mongo-mapping.xsd index 29d7b04ab..cad7a27b0 100644 --- a/doctrine-mongo-mapping.xsd +++ b/doctrine-mongo-mapping.xsd @@ -196,7 +196,7 @@ - + From 20241f1a7e7a383cd76208b862f417789e01ef3b Mon Sep 17 00:00:00 2001 From: Andreas Braun Date: Fri, 8 Aug 2025 12:07:03 +0200 Subject: [PATCH 11/22] Fix typos Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .../ODM/MongoDB/Utility/EncryptedFieldsMapGenerator.php | 2 +- .../ODM/MongoDB/Tests/Functional/QueryableEncryptionTest.php | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/Doctrine/ODM/MongoDB/Utility/EncryptedFieldsMapGenerator.php b/lib/Doctrine/ODM/MongoDB/Utility/EncryptedFieldsMapGenerator.php index b426852c2..941f33e85 100644 --- a/lib/Doctrine/ODM/MongoDB/Utility/EncryptedFieldsMapGenerator.php +++ b/lib/Doctrine/ODM/MongoDB/Utility/EncryptedFieldsMapGenerator.php @@ -86,7 +86,7 @@ private function createEncryptedFieldsMapForClass( } foreach ($classMetadata->fieldMappings as $mapping) { - // @todo support polymorphic types and inheritence? + // @todo support polymorphic types and inheritance? // Add fields recursively if ($mapping['embedded'] ?? false) { $embedMetadata = $this->classMetadataFactory->getMetadataFor($mapping['targetDocument']); diff --git a/tests/Doctrine/ODM/MongoDB/Tests/Functional/QueryableEncryptionTest.php b/tests/Doctrine/ODM/MongoDB/Tests/Functional/QueryableEncryptionTest.php index 644e9005c..d56e828fe 100644 --- a/tests/Doctrine/ODM/MongoDB/Tests/Functional/QueryableEncryptionTest.php +++ b/tests/Doctrine/ODM/MongoDB/Tests/Functional/QueryableEncryptionTest.php @@ -35,7 +35,7 @@ public function testCreateAndQueryEncryptedCollection(): void // Create the encrypted collection $this->dm->getSchemaManager()->createDocumentCollection(Patient::class); - // Test created collectionss + // Test created collections $collectionNames = iterator_to_array($nonEncryptedDatabase->listCollectionNames()); self::assertContains('patients', $collectionNames); self::assertContains('datakeys', $collectionNames); From 40c8b8e5345dcd38f197a1696a3fcb2eb167abf5 Mon Sep 17 00:00:00 2001 From: Andreas Braun Date: Fri, 8 Aug 2025 12:07:25 +0200 Subject: [PATCH 12/22] Fix wrong namespace in documentation Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- docs/en/cookbook/queryable-encryption.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/en/cookbook/queryable-encryption.rst b/docs/en/cookbook/queryable-encryption.rst index 87e41fc07..6bc8b400f 100644 --- a/docs/en/cookbook/queryable-encryption.rst +++ b/docs/en/cookbook/queryable-encryption.rst @@ -49,7 +49,7 @@ fields that require encryption. use Doctrine\ODM\MongoDB\Mapping\Annotations as ODM; use Doctrine\ODM\MongoDB\Mapping\Annotations\Encrypt; - use Doctrine\ODM\MongoDB\Query\EncryptQuery; + use Doctrine\ODM\MongoDB\Mapping\Annotations\EncryptQuery; #[ODM\Document] class Patient From ae66c28185cc40fcca6f8e3563818e867c47220d Mon Sep 17 00:00:00 2001 From: Andreas Braun Date: Fri, 8 Aug 2025 12:07:59 +0200 Subject: [PATCH 13/22] Remove duplicate field mapping in test document Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .../fixtures/xml/Documents.Encryption.RangeTypes.dcm.xml | 3 --- 1 file changed, 3 deletions(-) diff --git a/tests/Doctrine/ODM/MongoDB/Tests/Mapping/Driver/fixtures/xml/Documents.Encryption.RangeTypes.dcm.xml b/tests/Doctrine/ODM/MongoDB/Tests/Mapping/Driver/fixtures/xml/Documents.Encryption.RangeTypes.dcm.xml index 1499e3554..eaa900923 100644 --- a/tests/Doctrine/ODM/MongoDB/Tests/Mapping/Driver/fixtures/xml/Documents.Encryption.RangeTypes.dcm.xml +++ b/tests/Doctrine/ODM/MongoDB/Tests/Mapping/Driver/fixtures/xml/Documents.Encryption.RangeTypes.dcm.xml @@ -19,9 +19,6 @@ - - - From b72a0f6a8fa5085c204df527d0f0e7ff88243e10 Mon Sep 17 00:00:00 2001 From: Andreas Braun Date: Fri, 8 Aug 2025 12:10:10 +0200 Subject: [PATCH 14/22] Fix wrong embedded mapping in test document Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .../fixtures/xml/Documents.Encryption.PatientRecord.dcm.xml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/Doctrine/ODM/MongoDB/Tests/Mapping/Driver/fixtures/xml/Documents.Encryption.PatientRecord.dcm.xml b/tests/Doctrine/ODM/MongoDB/Tests/Mapping/Driver/fixtures/xml/Documents.Encryption.PatientRecord.dcm.xml index 8909c230e..d42c32fb1 100644 --- a/tests/Doctrine/ODM/MongoDB/Tests/Mapping/Driver/fixtures/xml/Documents.Encryption.PatientRecord.dcm.xml +++ b/tests/Doctrine/ODM/MongoDB/Tests/Mapping/Driver/fixtures/xml/Documents.Encryption.PatientRecord.dcm.xml @@ -9,9 +9,9 @@ - + - + From 33e150f09c8648adb0743902a8950cdfe05dc04c Mon Sep 17 00:00:00 2001 From: Andreas Braun Date: Fri, 8 Aug 2025 12:21:13 +0200 Subject: [PATCH 15/22] Fix wrong suggestion from Copilot --- .../fixtures/xml/Documents.Encryption.PatientRecord.dcm.xml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/Doctrine/ODM/MongoDB/Tests/Mapping/Driver/fixtures/xml/Documents.Encryption.PatientRecord.dcm.xml b/tests/Doctrine/ODM/MongoDB/Tests/Mapping/Driver/fixtures/xml/Documents.Encryption.PatientRecord.dcm.xml index d42c32fb1..d5ba16e9f 100644 --- a/tests/Doctrine/ODM/MongoDB/Tests/Mapping/Driver/fixtures/xml/Documents.Encryption.PatientRecord.dcm.xml +++ b/tests/Doctrine/ODM/MongoDB/Tests/Mapping/Driver/fixtures/xml/Documents.Encryption.PatientRecord.dcm.xml @@ -9,7 +9,7 @@ - + @@ -17,4 +17,4 @@ - \ No newline at end of file + From 36ca89b60ef44ee363c2e8fb39eb193535d93686 Mon Sep 17 00:00:00 2001 From: Andreas Braun Date: Fri, 8 Aug 2025 14:12:00 +0200 Subject: [PATCH 16/22] Support encrypted mappings of embedded documents in XML mappings (#2795) * Support encrypted mappings of embedded documents in XML mappings * Add functional encryption test for XML mappings * Use encrypt enum in XML driver tests --- doctrine-mongo-mapping.xsd | 2 ++ .../ODM/MongoDB/Mapping/Driver/XmlDriver.php | 28 +++++++++++++------ phpstan-baseline.neon | 1 - .../XmlMappingQueryableEncryptionTest.php | 16 +++++++++++ .../Mapping/Driver/AbstractDriverTestCase.php | 14 +++++----- .../xml/Documents.Encryption.Patient.dcm.xml | 15 ++++++++++ ...ocuments.Encryption.PatientBilling.dcm.xml | 13 +++++++++ ...Documents.Encryption.PatientRecord.dcm.xml | 2 +- 8 files changed, 74 insertions(+), 17 deletions(-) create mode 100644 tests/Doctrine/ODM/MongoDB/Tests/Functional/XmlMappingQueryableEncryptionTest.php create mode 100644 tests/Doctrine/ODM/MongoDB/Tests/Mapping/Driver/fixtures/xml/Documents.Encryption.Patient.dcm.xml create mode 100644 tests/Doctrine/ODM/MongoDB/Tests/Mapping/Driver/fixtures/xml/Documents.Encryption.PatientBilling.dcm.xml diff --git a/doctrine-mongo-mapping.xsd b/doctrine-mongo-mapping.xsd index cad7a27b0..6851bd9ed 100644 --- a/doctrine-mongo-mapping.xsd +++ b/doctrine-mongo-mapping.xsd @@ -279,6 +279,7 @@ + @@ -294,6 +295,7 @@ + diff --git a/lib/Doctrine/ODM/MongoDB/Mapping/Driver/XmlDriver.php b/lib/Doctrine/ODM/MongoDB/Mapping/Driver/XmlDriver.php index 70b9708d6..2edcf1fcd 100644 --- a/lib/Doctrine/ODM/MongoDB/Mapping/Driver/XmlDriver.php +++ b/lib/Doctrine/ODM/MongoDB/Mapping/Driver/XmlDriver.php @@ -4,6 +4,7 @@ namespace Doctrine\ODM\MongoDB\Mapping\Driver; +use Doctrine\ODM\MongoDB\Mapping\Annotations\EncryptQuery; use Doctrine\ODM\MongoDB\Mapping\Annotations\TimeSeries; use Doctrine\ODM\MongoDB\Mapping\ClassMetadata; use Doctrine\ODM\MongoDB\Mapping\MappingException; @@ -312,14 +313,7 @@ public function loadMetadataForClass($className, \Doctrine\Persistence\Mapping\C } if (isset($field->encrypt)) { - $mapping['encrypt'] = []; - foreach ($field->encrypt->attributes() as $encryptKey => $encryptValue) { - $mapping['encrypt'][$encryptKey] = match ($encryptKey) { - 'queryType' => (string) $encryptValue, - 'min', 'max' => Type::getType($mapping['type'])->convertToDatabaseValue((string) $encryptValue), - 'sparsity', 'precision', 'trimFactor', 'contention' => (int) $encryptValue, - }; - } + $mapping['encrypt'] = $this->addEncryptionMapping($field->encrypt, $mapping['type']); } $this->addFieldMapping($metadata, $mapping); @@ -464,6 +458,10 @@ private function addEmbedMapping(ClassMetadata $class, SimpleXMLElement $embed, $mapping['defaultDiscriminatorValue'] = (string) $embed->{'default-discriminator-value'}['value']; } + if (isset($embed->encrypt)) { + $mapping['encrypt'] = $this->addEncryptionMapping($embed->encrypt, $mapping['type']); + } + if (isset($attributes['not-saved'])) { $mapping['notSaved'] = ((string) $attributes['not-saved'] === 'true'); } @@ -934,6 +932,20 @@ private function addGridFSMappings(ClassMetadata $class, SimpleXMLElement $xmlRo $xmlRoot->metadata->addAttribute('field', 'metadata'); $this->addEmbedMapping($class, $xmlRoot->metadata, ClassMetadata::ONE); } + + private function addEncryptionMapping(?SimpleXMLElement $encrypt, $type): array + { + $encryptMapping = []; + foreach ($encrypt->attributes() as $encryptKey => $encryptValue) { + $encryptMapping[$encryptKey] = match ($encryptKey) { + 'queryType' => EncryptQuery::from((string) $encryptValue), + 'min', 'max' => Type::getType($type)->convertToDatabaseValue((string) $encryptValue), + 'sparsity', 'precision', 'trimFactor', 'contention' => (int) $encryptValue, + }; + } + + return $encryptMapping; + } } interface_exists(ClassMetadata::class); diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon index bd4711a5d..de0d01ead 100644 --- a/phpstan-baseline.neon +++ b/phpstan-baseline.neon @@ -1578,7 +1578,6 @@ parameters: count: 1 path: tests/Doctrine/ODM/MongoDB/Tests/DocumentRepositoryTest.php - - - message: '#^Parameter \#2 \$collections of method Doctrine\\ODM\\MongoDB\\Persisters\\CollectionPersister\:\:delete\(\) expects array\, array\\|Doctrine\\Common\\Collections\\Collection\\> given\.$#' identifier: argument.type diff --git a/tests/Doctrine/ODM/MongoDB/Tests/Functional/XmlMappingQueryableEncryptionTest.php b/tests/Doctrine/ODM/MongoDB/Tests/Functional/XmlMappingQueryableEncryptionTest.php new file mode 100644 index 000000000..6f159196f --- /dev/null +++ b/tests/Doctrine/ODM/MongoDB/Tests/Functional/XmlMappingQueryableEncryptionTest.php @@ -0,0 +1,16 @@ +isEncrypted); self::assertSame([ - 'queryType' => ClientEncryption::QUERY_TYPE_EQUALITY, + 'queryType' => EncryptQuery::Equality, ], $classMetadata->fieldMappings['ssn']['encrypt']); self::assertSame([], $classMetadata->fieldMappings['billing']['encrypt']); self::assertSame([ - 'queryType' => ClientEncryption::QUERY_TYPE_RANGE, + 'queryType' => EncryptQuery::Range, 'sparsity' => 1, 'trimFactor' => 4, 'min' => 100, @@ -568,25 +568,25 @@ public function testEncryptQueryRangeTypes(): void $this->driver->loadMetadataForClass(RangeTypes::class, $classMetadata); self::assertEquals([ - 'queryType' => ClientEncryption::QUERY_TYPE_RANGE, + 'queryType' => EncryptQuery::Range, 'min' => 5, 'max' => 10, ], $classMetadata->fieldMappings['intField']['encrypt']); self::assertEquals([ - 'queryType' => ClientEncryption::QUERY_TYPE_RANGE, + 'queryType' => EncryptQuery::Range, 'min' => 5.5, 'max' => 10.5, ], $classMetadata->fieldMappings['floatField']['encrypt']); self::assertEquals([ - 'queryType' => ClientEncryption::QUERY_TYPE_RANGE, + 'queryType' => EncryptQuery::Range, 'min' => new Decimal128('0.1'), 'max' => new Decimal128('0.2'), ], $classMetadata->fieldMappings['decimalField']['encrypt']); self::assertEquals([ - 'queryType' => ClientEncryption::QUERY_TYPE_RANGE, + 'queryType' => EncryptQuery::Range, 'min' => new UTCDateTime(new DateTimeImmutable('2000-01-01 00:00:00')), 'max' => new UTCDateTime(new DateTimeImmutable('2100-01-01 00:00:00')), ], $classMetadata->fieldMappings['dateField']['encrypt']); diff --git a/tests/Doctrine/ODM/MongoDB/Tests/Mapping/Driver/fixtures/xml/Documents.Encryption.Patient.dcm.xml b/tests/Doctrine/ODM/MongoDB/Tests/Mapping/Driver/fixtures/xml/Documents.Encryption.Patient.dcm.xml new file mode 100644 index 000000000..3fe0acc85 --- /dev/null +++ b/tests/Doctrine/ODM/MongoDB/Tests/Mapping/Driver/fixtures/xml/Documents.Encryption.Patient.dcm.xml @@ -0,0 +1,15 @@ + + + + + + + + + + + + diff --git a/tests/Doctrine/ODM/MongoDB/Tests/Mapping/Driver/fixtures/xml/Documents.Encryption.PatientBilling.dcm.xml b/tests/Doctrine/ODM/MongoDB/Tests/Mapping/Driver/fixtures/xml/Documents.Encryption.PatientBilling.dcm.xml new file mode 100644 index 000000000..ce657992f --- /dev/null +++ b/tests/Doctrine/ODM/MongoDB/Tests/Mapping/Driver/fixtures/xml/Documents.Encryption.PatientBilling.dcm.xml @@ -0,0 +1,13 @@ + + + + + + + + + + diff --git a/tests/Doctrine/ODM/MongoDB/Tests/Mapping/Driver/fixtures/xml/Documents.Encryption.PatientRecord.dcm.xml b/tests/Doctrine/ODM/MongoDB/Tests/Mapping/Driver/fixtures/xml/Documents.Encryption.PatientRecord.dcm.xml index d5ba16e9f..bfe171f7f 100644 --- a/tests/Doctrine/ODM/MongoDB/Tests/Mapping/Driver/fixtures/xml/Documents.Encryption.PatientRecord.dcm.xml +++ b/tests/Doctrine/ODM/MongoDB/Tests/Mapping/Driver/fixtures/xml/Documents.Encryption.PatientRecord.dcm.xml @@ -9,7 +9,7 @@ - + From cf3c0b70d3b9f3dc5d724387784dd0df63702e70 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=A9r=C3=B4me=20Tamarelle?= Date: Wed, 13 Aug 2025 20:31:54 +0200 Subject: [PATCH 17/22] Cast KMS provider to object to support AWS empty config (#2801) --- lib/Doctrine/ODM/MongoDB/Configuration.php | 3 ++- .../ODM/MongoDB/Tests/ConfigurationTest.php | 18 +++++++++--------- 2 files changed, 11 insertions(+), 10 deletions(-) diff --git a/lib/Doctrine/ODM/MongoDB/Configuration.php b/lib/Doctrine/ODM/MongoDB/Configuration.php index b4b0b4adf..760caed96 100644 --- a/lib/Doctrine/ODM/MongoDB/Configuration.php +++ b/lib/Doctrine/ODM/MongoDB/Configuration.php @@ -793,7 +793,8 @@ private static function getVersion(): string private function getAutoEncryptionOptions(): array { return [ - 'kmsProviders' => [$this->attributes['kmsProvider']['type'] => array_diff_key($this->attributes['kmsProvider'], ['type' => 0])], + // Each kmsProvider must be an object, it can be empty + 'kmsProviders' => [$this->attributes['kmsProvider']['type'] => (object) array_diff_key($this->attributes['kmsProvider'], ['type' => 0])], 'keyVaultNamespace' => $this->getDefaultDB() . '.datakeys', ...$this->attributes['autoEncryption'] ?? [], ]; diff --git a/tests/Doctrine/ODM/MongoDB/Tests/ConfigurationTest.php b/tests/Doctrine/ODM/MongoDB/Tests/ConfigurationTest.php index f5c3affe7..45d9f2537 100644 --- a/tests/Doctrine/ODM/MongoDB/Tests/ConfigurationTest.php +++ b/tests/Doctrine/ODM/MongoDB/Tests/ConfigurationTest.php @@ -55,7 +55,7 @@ public function testLocalKmsProvider(): void self::assertNull($c->getDefaultMasterKey()); self::assertEquals([ 'kmsProviders' => [ - 'local' => ['key' => '1234567890123456789012345678901234567890123456789012345678901234'], + 'local' => (object) ['key' => '1234567890123456789012345678901234567890123456789012345678901234'], ], 'extraOptions' => ['mongocryptdURI' => 'mongodb://localhost:27020'], // Default key vault namespace @@ -74,7 +74,7 @@ public function testKmsProvider(): void self::assertSame($masterKey, $c->getDefaultMasterKey()); self::assertEquals([ 'kmsProviders' => [ - 'aws' => ['accessKeyId' => 'AKIA', 'secretAccessKey' => 'SECRET'], + 'aws' => (object) ['accessKeyId' => 'AKIA', 'secretAccessKey' => 'SECRET'], ], // Key vault namespace from the configuration 'keyVaultNamespace' => 'keyvault.datakeys', @@ -88,27 +88,27 @@ public function testAutoEncryptionOptions(): void 'keyVaultClient' => $keyVaultClient = new Manager(), 'keyVaultNamespace' => 'keyvault.datakeys', 'extraOptions' => ['mongocryptdURI' => 'mongodb://localhost:27020'], - 'tlsOptions' => ['tlsDisableOCSPEndpointCheck' => true], + 'tlsOptions' => ['local' => ['tlsDisableOCSPEndpointCheck' => true]], ]); $c->setKmsProvider(['type' => 'local', 'key' => '1234567890123456789012345678901234567890123456789012345678901234']); - self::assertSame([ + self::assertEquals([ 'kmsProviders' => [ - 'local' => ['key' => '1234567890123456789012345678901234567890123456789012345678901234'], + 'local' => (object) ['key' => '1234567890123456789012345678901234567890123456789012345678901234'], ], 'keyVaultNamespace' => 'keyvault.datakeys', 'keyVaultClient' => $keyVaultClient, 'extraOptions' => ['mongocryptdURI' => 'mongodb://localhost:27020'], - 'tlsOptions' => ['tlsDisableOCSPEndpointCheck' => true], + 'tlsOptions' => ['local' => ['tlsDisableOCSPEndpointCheck' => true]], ], $c->getDriverOptions()['autoEncryption']); - self::assertSame([ + self::assertEquals([ 'kmsProviders' => [ - 'local' => ['key' => '1234567890123456789012345678901234567890123456789012345678901234'], + 'local' => (object) ['key' => '1234567890123456789012345678901234567890123456789012345678901234'], ], 'keyVaultNamespace' => 'keyvault.datakeys', 'keyVaultClient' => $keyVaultClient, - 'tlsOptions' => ['tlsDisableOCSPEndpointCheck' => true], + 'tlsOptions' => ['local' => ['tlsDisableOCSPEndpointCheck' => true]], ], $c->getClientEncryptionOptions()); } From 376fee48e66cf134c2857f346c7ffe5946718c9f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=A9r=C3=B4me=20Tamarelle?= Date: Thu, 14 Aug 2025 11:30:18 +0200 Subject: [PATCH 18/22] Fix minimum PHP version with QE support Co-authored-by: Jeremy Mikola --- docs/en/cookbook/queryable-encryption.rst | 2 +- docs/en/reference/attributes-reference.rst | 2 +- tests/Doctrine/ODM/MongoDB/Tests/BaseTestCase.php | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/en/cookbook/queryable-encryption.rst b/docs/en/cookbook/queryable-encryption.rst index 6bc8b400f..d71e540a4 100644 --- a/docs/en/cookbook/queryable-encryption.rst +++ b/docs/en/cookbook/queryable-encryption.rst @@ -20,7 +20,7 @@ the connection to storing and querying the encrypted data. .. note:: - Queryable Encryption is only available on MongoDB Enterprise 8.0+ or + Queryable Encryption is only available on MongoDB Enterprise 7.0+ or MongoDB Atlas. The Scenario diff --git a/docs/en/reference/attributes-reference.rst b/docs/en/reference/attributes-reference.rst index dfd59c532..2b5e35777 100644 --- a/docs/en/reference/attributes-reference.rst +++ b/docs/en/reference/attributes-reference.rst @@ -360,7 +360,7 @@ Optional arguments: .. note:: - Queryable encryption is only supported in MongoDB version 8.0 and later. + Queryable encryption is only supported in MongoDB version 7.0 and later. Example: diff --git a/tests/Doctrine/ODM/MongoDB/Tests/BaseTestCase.php b/tests/Doctrine/ODM/MongoDB/Tests/BaseTestCase.php index 1d2915831..2eb082d44 100644 --- a/tests/Doctrine/ODM/MongoDB/Tests/BaseTestCase.php +++ b/tests/Doctrine/ODM/MongoDB/Tests/BaseTestCase.php @@ -208,7 +208,7 @@ protected function skipTestIfQueryableEncryptionNotSupported(): void $this->markTestSkipped('Queryable Encryption test requires MongoDB Atlas or Enterprise'); } - $this->requireVersion($buildInfo->version, '8.0', '<', 'Queryable Encryption test requires MongoDB 8.0 or higher'); + $this->requireVersion($buildInfo->version, '7.0', '<', 'Queryable Encryption test requires MongoDB 7.0 or higher'); } protected function requireVersion(string $installedVersion, string $requiredVersion, ?string $operator, string $message): void From 774834a2ee9e0eecf0bc88eb610ac39f71b6b22e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=A9r=C3=B4me=20Tamarelle?= Date: Thu, 14 Aug 2025 16:01:41 +0200 Subject: [PATCH 19/22] Add missing options in Configuration::setAutoEncryption --- lib/Doctrine/ODM/MongoDB/Configuration.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/Doctrine/ODM/MongoDB/Configuration.php b/lib/Doctrine/ODM/MongoDB/Configuration.php index 760caed96..9637c8ce0 100644 --- a/lib/Doctrine/ODM/MongoDB/Configuration.php +++ b/lib/Doctrine/ODM/MongoDB/Configuration.php @@ -741,9 +741,9 @@ public function setDefaultMasterKey(?array $masterKey): void /** * Set the options for auto-encryption. * - * @see https://www.php.net/manual/en/mongodb-driver-manager.construct.php + * @see https://www.php.net/manual/en/mongodb-driver-manager.construct.php#mongodb-driver-manager.construct-autoencryption * - * @param array{ keyVaultClient?: Client|Manager, keyVaultNamespace?: string, tlsOptions?: array, schemaMap?: array, encryptedFieldsMap?: array, extraOptions?: array} $options + * @param array{ keyVaultClient?: Client|Manager, keyVaultNamespace?: string, tlsOptions?: array, schemaMap?: array, bypassAutoEncryption?: bool, bypassQueryAnalysis?: bool, encryptedFieldsMap?: array, extraOptions?: array} $options */ public function setAutoEncryption(array $options): void { From 46fca5b56fb6639f8813b562a48a99a950577696 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=A9r=C3=B4me=20Tamarelle?= Date: Thu, 14 Aug 2025 16:01:58 +0200 Subject: [PATCH 20/22] Remove trailing period from exception messages --- lib/Doctrine/ODM/MongoDB/ConfigurationException.php | 8 ++++---- tests/Doctrine/ODM/MongoDB/Tests/ConfigurationTest.php | 8 ++++---- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/lib/Doctrine/ODM/MongoDB/ConfigurationException.php b/lib/Doctrine/ODM/MongoDB/ConfigurationException.php index 506fd72fa..21ff64657 100644 --- a/lib/Doctrine/ODM/MongoDB/ConfigurationException.php +++ b/lib/Doctrine/ODM/MongoDB/ConfigurationException.php @@ -37,21 +37,21 @@ public static function clientEncryptionOptionsNotSet(): self public static function kmsProviderTypeRequired(): self { - return new self('The KMS provider "type" is required.'); + return new self('The KMS provider "type" is required'); } public static function kmsProviderTypeMustBeString(): self { - return new self('The KMS provider "type" must be a non-empty string.'); + return new self('The KMS provider "type" must be a non-empty string'); } public static function kmsProvidersOptionMustUseSetter(): self { - return new self('The "kmsProviders" encryption option must be set using the "setKmsProvider()" method.'); + return new self('The "kmsProviders" encryption option must be set using the "setKmsProvider()" method'); } public static function masterKeyRequired(string $provider): self { - return new self(sprintf('The "masterKey" configuration is required for the KMS provider "%s".', $provider)); + return new self(sprintf('The "masterKey" configuration is required for the KMS provider "%s"', $provider)); } } diff --git a/tests/Doctrine/ODM/MongoDB/Tests/ConfigurationTest.php b/tests/Doctrine/ODM/MongoDB/Tests/ConfigurationTest.php index 45d9f2537..f44e9c4e5 100644 --- a/tests/Doctrine/ODM/MongoDB/Tests/ConfigurationTest.php +++ b/tests/Doctrine/ODM/MongoDB/Tests/ConfigurationTest.php @@ -118,7 +118,7 @@ public function testMissingDefaultMasterKey(): void $c->setKmsProvider(['type' => 'aws', 'accessKeyId' => 'AKIA', 'secretAccessKey' => 'SECRET']); self::expectException(ConfigurationException::class); - self::expectExceptionMessage('The "masterKey" configuration is required for the KMS provider "aws".'); + self::expectExceptionMessage('The "masterKey" configuration is required for the KMS provider "aws"'); $c->getDefaultMasterKey(); } @@ -127,7 +127,7 @@ public function testKmsProvidersIsForbiddenInAutoEncryptionOptions(): void $c = new Configuration(); self::expectException(ConfigurationException::class); - self::expectExceptionMessage('The "kmsProviders" encryption option must be set using the "setKmsProvider()" method.'); + self::expectExceptionMessage('The "kmsProviders" encryption option must be set using the "setKmsProvider()" method'); $c->setAutoEncryption(['kmsProviders' => ['aws' => ['accessKeyId' => 'AKIA', 'secretAccessKey' => 'SECRET']]]); } @@ -143,7 +143,7 @@ public function testKmsProviderTypeRequired(): void { $c = new Configuration(); self::expectException(ConfigurationException::class); - self::expectExceptionMessage('The KMS provider "type" is required.'); + self::expectExceptionMessage('The KMS provider "type" is required'); // @phpstan-ignore argument.type $c->setKmsProvider(['foo' => 'bar']); @@ -153,7 +153,7 @@ public function testKmsProviderTypeMustBeString(): void { $c = new Configuration(); self::expectException(ConfigurationException::class); - self::expectExceptionMessage('The KMS provider "type" must be a non-empty string.'); + self::expectExceptionMessage('The KMS provider "type" must be a non-empty string'); // @phpstan-ignore argument.type $c->setKmsProvider(['type' => ['not', 'a', 'string']]); From f3df7a8de96b2ffab961f7accda8b2b832f988db Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=A9r=C3=B4me=20Tamarelle?= Date: Thu, 14 Aug 2025 19:33:50 +0200 Subject: [PATCH 21/22] Fix psalm type of field mapping --- .../ODM/MongoDB/Mapping/ClassMetadata.php | 17 +++++++++++++++-- .../ODM/MongoDB/Mapping/Driver/XmlDriver.php | 4 +++- phpstan-baseline.neon | 2 +- 3 files changed, 19 insertions(+), 4 deletions(-) diff --git a/lib/Doctrine/ODM/MongoDB/Mapping/ClassMetadata.php b/lib/Doctrine/ODM/MongoDB/Mapping/ClassMetadata.php index 2db3dc562..97b0b0034 100644 --- a/lib/Doctrine/ODM/MongoDB/Mapping/ClassMetadata.php +++ b/lib/Doctrine/ODM/MongoDB/Mapping/ClassMetadata.php @@ -13,6 +13,7 @@ use Doctrine\Instantiator\InstantiatorInterface; use Doctrine\ODM\MongoDB\Id\IdGenerator; use Doctrine\ODM\MongoDB\LockException; +use Doctrine\ODM\MongoDB\Mapping\Annotations\EncryptQuery; use Doctrine\ODM\MongoDB\Mapping\Annotations\TimeSeries; use Doctrine\ODM\MongoDB\Proxy\InternalProxy; use Doctrine\ODM\MongoDB\Types\Incrementable; @@ -25,6 +26,9 @@ use Doctrine\Persistence\Reflection\EnumReflectionProperty; use InvalidArgumentException; use LogicException; +use MongoDB\BSON\Decimal128; +use MongoDB\BSON\Int64; +use MongoDB\BSON\UTCDateTime; use ProxyManager\Proxy\GhostObjectInterface; use ReflectionClass; use ReflectionEnum; @@ -67,6 +71,15 @@ * get the whole class name, namespace inclusive, prepended to every property in * the serialized representation). * + * @phpstan-type EncryptConfig array{ + * queryType?: ?EncryptQuery, + * min?: float|int|Decimal128|Int64|UTCDateTime|null, + * max?: float|int|Decimal128|Int64|UTCDateTime|null, + * sparsity?: int<1, 4>, + * precision?: positive-int, + * trimFactor?: positive-int, + * contention?: positive-int, + * } * @phpstan-type FieldMappingConfig array{ * type?: string, * fieldName?: string, @@ -107,7 +120,7 @@ * order?: int|string, * background?: bool, * enumType?: class-string, - * encrypt?: array{queryType?: ?string, min?: mixed, max?: mixed, sparsity?: int<1, 4>, precision?: int, trimFactor?: int, contention?: int} + * encrypt?: EncryptConfig, * } * @phpstan-type FieldMapping array{ * type: string, @@ -154,7 +167,7 @@ * alsoLoadFields?: list, * enumType?: class-string, * storeEmptyArray?: bool, - * encrypt?: array{queryType?: ?string, min?: mixed, max?: mixed, sparsity?: int<1, 4>, precision?: int, trimFactor?: int, contention?: int}, + * encrypt?: EncryptConfig, * } * @phpstan-type AssociationFieldMapping array{ * type?: string, diff --git a/lib/Doctrine/ODM/MongoDB/Mapping/Driver/XmlDriver.php b/lib/Doctrine/ODM/MongoDB/Mapping/Driver/XmlDriver.php index 2edcf1fcd..abf830ee1 100644 --- a/lib/Doctrine/ODM/MongoDB/Mapping/Driver/XmlDriver.php +++ b/lib/Doctrine/ODM/MongoDB/Mapping/Driver/XmlDriver.php @@ -47,6 +47,7 @@ * XmlDriver is a metadata driver that enables mapping through XML files. * * @phpstan-import-type FieldMappingConfig from ClassMetadata + * @phpstan-import-type EncryptConfig from ClassMetadata * @template-extends FileDriver */ class XmlDriver extends FileDriver @@ -933,7 +934,8 @@ private function addGridFSMappings(ClassMetadata $class, SimpleXMLElement $xmlRo $this->addEmbedMapping($class, $xmlRoot->metadata, ClassMetadata::ONE); } - private function addEncryptionMapping(?SimpleXMLElement $encrypt, $type): array + /** @psalm-return EncryptConfig */ + private function addEncryptionMapping(SimpleXMLElement $encrypt, string $type): array { $encryptMapping = []; foreach ($encrypt->attributes() as $encryptKey => $encryptValue) { diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon index de0d01ead..7b531508b 100644 --- a/phpstan-baseline.neon +++ b/phpstan-baseline.neon @@ -961,7 +961,7 @@ parameters: path: lib/Doctrine/ODM/MongoDB/Mapping/Driver/XmlDriver.php - - message: '#^Parameter \#2 \$mapping of method Doctrine\\ODM\\MongoDB\\Mapping\\Driver\\XmlDriver\:\:addFieldMapping\(\) expects array\{type\?\: string, fieldName\?\: string, name\?\: string, strategy\?\: string, association\?\: int, id\?\: bool, isOwningSide\?\: bool, collectionClass\?\: class\-string, \.\.\.\}, array\\|string, mixed\>\|bool\|string\> given\.$#' + message: '#^Parameter \#2 \$mapping of method Doctrine\\ODM\\MongoDB\\Mapping\\Driver\\XmlDriver\:\:addFieldMapping\(\) expects array\{type\?\: string, fieldName\?\: string, name\?\: string, strategy\?\: string, association\?\: int, id\?\: bool, isOwningSide\?\: bool, collectionClass\?\: class\-string, \.\.\.\}, array\, Doctrine\\ODM\\MongoDB\\Mapping\\Annotations\\EncryptQuery\|float\|int\|MongoDB\\BSON\\Decimal128\|MongoDB\\BSON\\Int64\|MongoDB\\BSON\\UTCDateTime\|string\|null\>\|bool\|string\> given\.$#' identifier: argument.type count: 1 path: lib/Doctrine/ODM/MongoDB/Mapping/Driver/XmlDriver.php From c46fb0266d1af837c028eae70cbb03fa8a753733 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=A9r=C3=B4me=20Tamarelle?= Date: Mon, 18 Aug 2025 11:37:16 +0200 Subject: [PATCH 22/22] Update test on dropping encrypted collection (#2803) --- composer.json | 2 +- phpstan-baseline.neon | 8 +------- .../Functional/QueryableEncryptionTest.php | 18 +++++++++++++----- 3 files changed, 15 insertions(+), 13 deletions(-) diff --git a/composer.json b/composer.json index 56564f0d8..e9b653913 100644 --- a/composer.json +++ b/composer.json @@ -30,7 +30,7 @@ "doctrine/persistence": "^3.2 || ^4", "friendsofphp/proxy-manager-lts": "^1.0", "jean85/pretty-package-versions": "^1.3.0 || ^2.0.1", - "mongodb/mongodb": "^1.21 || ^2.0@dev", + "mongodb/mongodb": "~1.21.2 || ^2.1.1@dev", "psr/cache": "^1.0 || ^2.0 || ^3.0", "symfony/console": "^5.4 || ^6.0 || ^7.0", "symfony/deprecation-contracts": "^2.2 || ^3.0", diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon index 7b531508b..c2b84b411 100644 --- a/phpstan-baseline.neon +++ b/phpstan-baseline.neon @@ -1671,13 +1671,7 @@ parameters: - message: '#^Access to an undefined property MongoDB\\Model\\BSONDocument\:\:\$patientRecord\.$#' identifier: property.notFound - count: 3 - path: tests/Doctrine/ODM/MongoDB/Tests/Functional/QueryableEncryptionTest.php - - - - message: '#^Parameter \#1 \$value of function count expects array\|Countable, Iterator\ given\.$#' - identifier: argument.type - count: 1 + count: 6 path: tests/Doctrine/ODM/MongoDB/Tests/Functional/QueryableEncryptionTest.php - diff --git a/tests/Doctrine/ODM/MongoDB/Tests/Functional/QueryableEncryptionTest.php b/tests/Doctrine/ODM/MongoDB/Tests/Functional/QueryableEncryptionTest.php index d56e828fe..f6219276a 100644 --- a/tests/Doctrine/ODM/MongoDB/Tests/Functional/QueryableEncryptionTest.php +++ b/tests/Doctrine/ODM/MongoDB/Tests/Functional/QueryableEncryptionTest.php @@ -10,10 +10,10 @@ use Documents\Encryption\PatientBilling; use Documents\Encryption\PatientRecord; use MongoDB\BSON\Binary; +use MongoDB\BSON\Regex; use MongoDB\Client; use MongoDB\Model\BSONDocument; -use function count; use function getenv; use function iterator_to_array; use function random_bytes; @@ -27,6 +27,13 @@ public function setUp(): void $this->skipTestIfQueryableEncryptionNotSupported(); } + public function tearDown(): void + { + $this->dm?->getDocumentCollection(Patient::class)?->drop(['encryptedFields' => []]); + + parent::tearDown(); + } + public function testCreateAndQueryEncryptedCollection(): void { $nonEncryptedClient = new Client(self::getUri()); @@ -64,8 +71,11 @@ public function testCreateAndQueryEncryptedCollection(): void self::assertSame('Jon Doe', $document->patientName); self::assertSame(12345678, $document->patientId); self::assertInstanceOf(Binary::class, $document->patientRecord->ssn); + self::assertSame(Binary::TYPE_ENCRYPTED, $document->patientRecord->ssn->getType()); self::assertInstanceOf(Binary::class, $document->patientRecord->billing); + self::assertSame(Binary::TYPE_ENCRYPTED, $document->patientRecord->billing->getType()); self::assertInstanceOf(Binary::class, $document->patientRecord->billingAmount); + self::assertSame(Binary::TYPE_ENCRYPTED, $document->patientRecord->billingAmount->getType()); // Queryable with equality $result = $this->dm->getRepository(Patient::class)->findOneBy(['patientRecord.ssn' => '987-65-4320']); @@ -84,11 +94,9 @@ public function testCreateAndQueryEncryptedCollection(): void self::assertSame('4111111111111111', $result->patientRecord->billing->number); // Drop the encrypted collection - $collectionCount = count($nonEncryptedDatabase->listCollectionNames()); $this->dm->getSchemaManager()->dropDocumentCollection(Patient::class); - $collectionNames = iterator_to_array($nonEncryptedDatabase->listCollectionNames()); - self::assertNotContains('patients', $collectionNames); - self::assertSame($collectionCount - 3, count($collectionNames), 'The 2 metadata collections should also be dropped'); + $collectionNames = iterator_to_array($nonEncryptedDatabase->listCollectionNames(['filter' => ['name' => new Regex('patients')]])); + self::assertSame([], $collectionNames, 'The 2 metadata collections should also be dropped'); } protected static function createTestDocumentManager(): DocumentManager