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/composer.json b/composer.json index 56564f0d8..86dc1ad24 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", "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/docs/en/cookbook/queryable-encryption.rst b/docs/en/cookbook/queryable-encryption.rst new file mode 100644 index 000000000..d71e540a4 --- /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 7.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 5e8d8d408..601934d6e 100644 --- a/docs/en/reference/attributes-reference.rst +++ b/docs/en/reference/attributes-reference.rst @@ -336,6 +336,94 @@ 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] +---------- + +The ``#[Encrypt]`` attribute is used to define an encrypted field mapping for a +document property. It allows you to configure fields for automatic 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``, ``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. + +.. note:: + + Queryable encryption is only supported in MongoDB version 7.0 and later. + +Example: + +.. 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] -------- @@ -1399,5 +1487,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 ---------------- diff --git a/doctrine-mongo-mapping.xsd b/doctrine-mongo-mapping.xsd index 6a17933ba..6ff939cd5 100644 --- a/doctrine-mongo-mapping.xsd +++ b/doctrine-mongo-mapping.xsd @@ -31,6 +31,7 @@ + @@ -183,7 +184,31 @@ + + + + + + + + + + + + + + + + + + + + + + + + @@ -254,6 +279,7 @@ + @@ -269,6 +295,7 @@ + @@ -535,7 +562,7 @@ - + diff --git a/lib/Doctrine/ODM/MongoDB/Configuration.php b/lib/Doctrine/ODM/MongoDB/Configuration.php index aa8a6d457..8ea5c844c 100644 --- a/lib/Doctrine/ODM/MongoDB/Configuration.php +++ b/lib/Doctrine/ODM/MongoDB/Configuration.php @@ -24,7 +24,10 @@ use Doctrine\Persistence\Mapping\Driver\MappingDriver; use Doctrine\Persistence\ObjectRepository; 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; @@ -32,10 +35,15 @@ use ProxyManager\GeneratorStrategy\FileWriterGeneratorStrategy; use Psr\Cache\CacheItemPoolInterface; use ReflectionClass; +use stdClass; +use Throwable; +use function array_diff_key; +use function array_intersect_key; use function array_key_exists; use function class_exists; use function interface_exists; +use function is_string; use function trigger_deprecation; use function trim; @@ -50,6 +58,7 @@ * $dm = DocumentManager::create(new Connection(), $config); * * @phpstan-import-type CommitOptions from UnitOfWork + * @phpstan-type KmsProvider array{type: string, ...} */ class Configuration { @@ -121,7 +130,10 @@ class Configuration * persistentCollectionNamespace?: string, * proxyDir?: string, * proxyNamespace?: string, - * repositoryFactory?: RepositoryFactory + * repositoryFactory?: RepositoryFactory, + * kmsProvider?: KmsProvider, + * defaultMasterKey?: array|null, + * autoEncryption?: array, * } */ private array $attributes = []; @@ -135,6 +147,50 @@ 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['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. */ @@ -651,6 +707,107 @@ public function isLazyGhostObjectEnabled(): bool { return $this->useLazyGhostObject; } + + /** + * 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 + * + * @param KmsProvider $kmsProvider + */ + public function setKmsProvider(array $kmsProvider): void + { + if (! isset($kmsProvider['type'])) { + throw ConfigurationException::kmsProviderTypeRequired(); + } + + if (! is_string($kmsProvider['type'])) { + throw ConfigurationException::kmsProviderTypeMustBeString(); + } + + $this->attributes['kmsProvider'] = $kmsProvider; + } + + /** + * Set the default master key to use when creating encrypted collections. + * + * @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#mongodb-driver-manager.construct-autoencryption + * + * @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 + { + if (isset($options['kmsProviders'])) { + throw ConfigurationException::kmsProvidersOptionMustUseSetter(); + } + + $this->attributes['autoEncryption'] = $options; + } + + /** + * Get the default KMS provider name used when creating encrypted collections. + */ + public function getDefaultKmsProvider(): ?string + { + 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 $this->attributes['defaultMasterKey'] ?? throw ConfigurationException::masterKeyRequired($this->attributes['kmsProvider']['type']); + } + + 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; + } + + /** @return array */ + private function getAutoEncryptionOptions(): array + { + $kmsProviderName = $this->attributes['kmsProvider']['type']; + $kmsProviderOpts = array_diff_key($this->attributes['kmsProvider'], ['type' => 0]); + // To use "Automatic Credentials", the provider options must be an empty document. + // Fix the empty array to an empty stdClass object, as the driver expects it. + if ($kmsProviderOpts === []) { + $kmsProviderOpts = new stdClass(); + } + + return [ + // Each kmsProvider must be an object, it can be empty + 'kmsProviders' => [$kmsProviderName => $kmsProviderOpts], + '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..21ff64657 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 462188a6f..b9f440aef 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 + { + if (isset($this->clientEncryption)) { + return $this->clientEncryption; + } + + $options = $this->config->getClientEncryptionOptions(); + + if (! $options) { + throw new RuntimeException('Auto-encryption is not enabled.'); + } + + return $this->clientEncryption = $this->client->createClientEncryption($options); + } + /** 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..1f7fbc90e --- /dev/null +++ b/lib/Doctrine/ODM/MongoDB/Mapping/Annotations/Encrypt.php @@ -0,0 +1,47 @@ +|null $sparsity + * @param positive-int|null $precision + * @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 $precision = 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 @@ +, + * precision?: positive-int, + * trimFactor?: positive-int, + * contention?: positive-int, + * } * @phpstan-type FieldMappingConfig array{ * type?: string, * fieldName?: string, @@ -107,6 +122,7 @@ * order?: int|string, * background?: bool, * enumType?: class-string, + * encrypt?: EncryptConfig, * } * @phpstan-type FieldMapping array{ * type: string, @@ -153,6 +169,7 @@ * alsoLoadFields?: list, * enumType?: class-string, * storeEmptyArray?: bool, + * encrypt?: EncryptConfig, * } * @phpstan-type AssociationFieldMapping array{ * type?: string, @@ -801,6 +818,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; @@ -2176,6 +2198,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/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..96b64b504 100644 --- a/lib/Doctrine/ODM/MongoDB/Mapping/Driver/XmlDriver.php +++ b/lib/Doctrine/ODM/MongoDB/Mapping/Driver/XmlDriver.php @@ -4,10 +4,12 @@ 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; 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; @@ -45,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 @@ -98,6 +101,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 +313,10 @@ public function loadMetadataForClass($className, \Doctrine\Persistence\Mapping\C $mapping['lock'] = ((string) $attributes['lock'] === 'true'); } + if (isset($field->encrypt)) { + $mapping['encrypt'] = $this->addEncryptionMapping($field->encrypt, $mapping['type']); + } + $this->addFieldMapping($metadata, $mapping); } } @@ -449,6 +459,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'); } @@ -919,6 +933,21 @@ private function addGridFSMappings(ClassMetadata $class, SimpleXMLElement $xmlRo $xmlRoot->metadata->addAttribute('field', 'metadata'); $this->addEmbedMapping($class, $xmlRoot->metadata, ClassMetadata::ONE); } + + /** @psalm-return EncryptConfig */ + private function addEncryptionMapping(SimpleXMLElement $encrypt, string $type): array + { + $encryptMapping = []; + foreach ($encrypt->attributes() as $key => $value) { + $encryptMapping[$key] = match ($key) { + 'queryType' => EncryptQuery::from((string) $value), + 'min', 'max' => Type::getType($type)->convertToDatabaseValue((string) $value), + 'sparsity', 'precision', 'trimFactor', 'contention' => (int) $value, + }; + } + + return $encryptMapping; + } } interface_exists(ClassMetadata::class); diff --git a/lib/Doctrine/ODM/MongoDB/Mapping/MappingException.php b/lib/Doctrine/ODM/MongoDB/Mapping/MappingException.php index e51e21c0a..7b7d46d1b 100644 --- a/lib/Doctrine/ODM/MongoDB/Mapping/MappingException.php +++ b/lib/Doctrine/ODM/MongoDB/Mapping/MappingException.php @@ -306,4 +306,12 @@ 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, + )); + } } diff --git a/lib/Doctrine/ODM/MongoDB/MongoDBException.php b/lib/Doctrine/ODM/MongoDB/MongoDBException.php index a9d14a140..0735611a6 100644 --- a/lib/Doctrine/ODM/MongoDB/MongoDBException.php +++ b/lib/Doctrine/ODM/MongoDB/MongoDBException.php @@ -160,4 +160,9 @@ public static function transactionalSessionMismatch(): self { return new self('The transactional operation cannot be executed because it was started in a different session.'); } + + public static function notADocumentClass(string $className): self + { + return new self(sprintf('The class "%s" is not a document class.', $className)); + } } diff --git a/lib/Doctrine/ODM/MongoDB/SchemaManager.php b/lib/Doctrine/ODM/MongoDB/SchemaManager.php index 5e62e521b..da2e29eb4 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\EncryptedFieldsMapGenerator; 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()->getDefaultKmsProvider()) { + $encryptedFields = (new EncryptedFieldsMapGenerator($this->dm->getMetadataFactory()))->getEncryptedFieldsMapForClass($class->name); + + if ($encryptedFields) { + $options['encryptedFields'] = $encryptedFields; + } + } + + if (isset($options['encryptedFields'])) { + $this->dm->getDocumentDatabase($documentName)->createEncryptedCollection( + $class->getCollection(), + $this->dm->getClientEncryption(), + $this->dm->getConfiguration()->getDefaultKmsProvider(), + $this->dm->getConfiguration()->getDefaultMasterKey(), + $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/EncryptedFieldsMapGenerator.php b/lib/Doctrine/ODM/MongoDB/Utility/EncryptedFieldsMapGenerator.php new file mode 100644 index 000000000..69d17e81b --- /dev/null +++ b/lib/Doctrine/ODM/MongoDB/Utility/EncryptedFieldsMapGenerator.php @@ -0,0 +1,147 @@ +}> + */ + 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()] = ['fields' => $classMap]; + } + + return $encryptedFieldsMap; + } + + /** + * Generate the encryption field map from the class metadata. + * + * @param class-string $className + * + * @return array{fields: array}|null + */ + public function getEncryptedFieldsMapForClass(string $className): ?array + { + $classMetadata = $this->classMetadataFactory->getMetadataFor($className); + + if (! $classMetadata->isDocument()) { + throw MongoDBException::notADocumentClass($className); + } + + $fields = iterator_to_array($this->createEncryptedFieldsMapForClass($classMetadata)); + + if ($fields === []) { + return null; + } + + return ['fields' => $fields]; + } + + /** + * @param array $visitedClasses + * @phpstan-param ClassMetadata $classMetadata + * + * @return Generator + * + * @template T of object + */ + private function createEncryptedFieldsMapForClass( + ClassMetadata $classMetadata, + string $parentPath = '', + 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) { + // 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'] ??= []; + } elseif (! isset($mapping['encrypt'])) { + yield from $this->createEncryptedFieldsMapForClass( + $embedMetadata, + $parentPath . $mapping['name'] . '.', + $visitedClasses + [$classMetadata->getName() => true], + ); + } + } + + if (! isset($mapping['encrypt'])) { + continue; + } + + $field = [ + 'path' => $parentPath . $mapping['name'], + 'bsonType' => match ($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'])), + }, + '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); + $field['queries']['queryType'] = $field['queries']['queryType']->value; + } + + yield $field; + } + } +} diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon index f1b77b740..1e6dd4eb7 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\, 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 @@ -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 @@ -1518,12 +1542,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 +1560,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,24 +1572,12 @@ 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 count: 1 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 +1656,24 @@ 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: 6 + 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 +1746,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 +1914,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 +1926,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 @@ -1968,6 +1962,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\:199\:\:__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\:199\:\: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\:199\:\: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\:199\:\: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\:199\:\: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\:199\:\: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\:199\:\: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 @@ -2070,6 +2142,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..2eb082d44 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, '7.0', '<', 'Queryable Encryption test requires MongoDB 7.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/ConfigurationTest.php b/tests/Doctrine/ODM/MongoDB/Tests/ConfigurationTest.php index 38797b822..b78050b50 100644 --- a/tests/Doctrine/ODM/MongoDB/Tests/ConfigurationTest.php +++ b/tests/Doctrine/ODM/MongoDB/Tests/ConfigurationTest.php @@ -5,10 +5,14 @@ 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; +use stdClass; -class ConfigurationTest extends BaseTestCase +class ConfigurationTest extends TestCase { public function testDefaultPersistentCollectionFactory(): void { @@ -40,4 +44,133 @@ 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 testEmptyKmsProviderOptions(): void + { + $c = new Configuration(); + $c->setKmsProvider(['type' => 'aws']); + $c->setAutoEncryption(['keyVaultNamespace' => 'keyvault.datakeys']); + + self::assertEquals([ + 'kmsProviders' => [ + 'aws' => new stdClass(), + ], + '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' => ['local' => ['tlsDisableOCSPEndpointCheck' => true]], + ]); + $c->setKmsProvider(['type' => 'local', 'key' => '1234567890123456789012345678901234567890123456789012345678901234']); + + self::assertEquals([ + 'kmsProviders' => [ + 'local' => ['key' => '1234567890123456789012345678901234567890123456789012345678901234'], + ], + 'keyVaultNamespace' => 'keyvault.datakeys', + 'keyVaultClient' => $keyVaultClient, + 'extraOptions' => ['mongocryptdURI' => 'mongodb://localhost:27020'], + 'tlsOptions' => ['local' => ['tlsDisableOCSPEndpointCheck' => true]], + ], $c->getDriverOptions()['autoEncryption']); + + self::assertEquals([ + 'kmsProviders' => [ + 'local' => ['key' => '1234567890123456789012345678901234567890123456789012345678901234'], + ], + 'keyVaultNamespace' => 'keyvault.datakeys', + 'keyVaultClient' => $keyVaultClient, + 'tlsOptions' => ['local' => ['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 new file mode 100644 index 000000000..f6219276a --- /dev/null +++ b/tests/Doctrine/ODM/MongoDB/Tests/Functional/QueryableEncryptionTest.php @@ -0,0 +1,124 @@ +skipTestIfQueryableEncryptionNotSupported(); + } + + public function tearDown(): void + { + $this->dm?->getDocumentCollection(Patient::class)?->drop(['encryptedFields' => []]); + + parent::tearDown(); + } + + 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 collections + $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::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']); + 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); + + // Drop the encrypted collection + $this->dm->getSchemaManager()->dropDocumentCollection(Patient::class); + $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 + { + $config = static::getConfiguration(); + $config->setDefaultDB(DOCTRINE_MONGODB_DATABASE); + $config->setKmsProvider([ + 'type' => 'local', + '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/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 @@ + 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' => EncryptQuery::Equality, + ], $classMetadata->fieldMappings['ssn']['encrypt']); + + self::assertSame([], $classMetadata->fieldMappings['billing']['encrypt']); + + self::assertSame([ + 'queryType' => EncryptQuery::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' => EncryptQuery::Range, + 'min' => 5, + 'max' => 10, + ], $classMetadata->fieldMappings['intField']['encrypt']); + + self::assertEquals([ + 'queryType' => EncryptQuery::Range, + 'min' => 5.5, + 'max' => 10.5, + ], $classMetadata->fieldMappings['floatField']['encrypt']); + + self::assertEquals([ + 'queryType' => EncryptQuery::Range, + 'min' => new Decimal128('0.1'), + 'max' => new Decimal128('0.2'), + ], $classMetadata->fieldMappings['decimalField']['encrypt']); + + self::assertEquals([ + '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.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.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 new file mode 100644 index 000000000..bfe171f7f --- /dev/null +++ b/tests/Doctrine/ODM/MongoDB/Tests/Mapping/Driver/fixtures/xml/Documents.Encryption.PatientRecord.dcm.xml @@ -0,0 +1,20 @@ + + + + + + + + + + + + + + + + + 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..eaa900923 --- /dev/null +++ b/tests/Doctrine/ODM/MongoDB/Tests/Mapping/Driver/fixtures/xml/Documents.Encryption.RangeTypes.dcm.xml @@ -0,0 +1,24 @@ + + + + + + + + + + + + + + + + + + + + + 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..e1384c4f7 --- /dev/null +++ b/tests/Doctrine/ODM/MongoDB/Tests/Tools/EncryptedFieldsMapGeneratorTest.php @@ -0,0 +1,261 @@ +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(['fields' => $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::assertEquals(['fields' => $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' => 'double', + 'keyId' => null, + 'queries' => ['queryType' => 'range', 'min' => 5.5, 'max' => 10.5, 'precision' => 1], + ], + [ + 'path' => 'decimalField', + 'bsonType' => 'decimal', + 'keyId' => null, + 'queries' => ['queryType' => 'range', 'min' => new Decimal128('0.1'), 'max' => new Decimal128('0.2'), 'precision' => 2], + ], + [ + 'path' => 'dateField', + '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, + ], + ], + ]; + + self::assertEquals(['fields' => $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 => [ + 'fields' => [ + [ + '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); + } + + public function testNoEncryptedFields(): void + { + $classMetadataFactory = $this->createMetadataFactory( + $this->dm->getMetadataFactory(), + Bar::class, + ); + + $factory = new EncryptedFieldsMapGenerator($classMetadataFactory); + + self::assertSame([], $factory->getEncryptedFieldsMap()); + self::assertNull($factory->getEncryptedFieldsMapForClass(Bar::class)); + } + + public function testNotADocumentClass(): void + { + $classMetadataFactory = $this->createMetadataFactory( + $this->dm->getMetadataFactory(), + PatientRecord::class, + ); + + $this->expectException(MongoDBException::class); + $this->expectExceptionMessage('The class "Documents\Encryption\PatientRecord" is not a document class.'); + + $factory = new EncryptedFieldsMapGenerator($classMetadataFactory); + $factory->getEncryptedFieldsMapForClass(PatientRecord::class); + } + + 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/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 @@ +