diff --git a/lib/Doctrine/ODM/MongoDB/Configuration.php b/lib/Doctrine/ODM/MongoDB/Configuration.php index 5e4d28b9de..b4b0b4adf8 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 dd1bad9a43..506fd72faa 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 197578fa66..b9f440aefb 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 a39ce8ad3f..afbc3a28de 100644 --- a/lib/Doctrine/ODM/MongoDB/SchemaManager.php +++ b/lib/Doctrine/ODM/MongoDB/SchemaManager.php @@ -645,7 +645,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) { @@ -657,8 +657,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 38797b822c..f5c3affe72 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 00eec71447..75585d4124 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); }