Skip to content

Add support for Field-Level Automatic and Queryable Encryption #2759

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Draft
wants to merge 5 commits into
base: 2.11.x
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
55 changes: 54 additions & 1 deletion lib/Doctrine/ODM/MongoDB/Configuration.php
Original file line number Diff line number Diff line change
Expand Up @@ -34,8 +34,12 @@
use ReflectionClass;

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;

Expand All @@ -50,6 +54,11 @@
* $dm = DocumentManager::create(new Connection(), $config);
*
* @phpstan-import-type CommitOptions from UnitOfWork
* @phpstan-type AutoEncryptionOptions array{
* keyVaultNamespace: string,
* kmsProviders: array<string, array<string, string>>,
* tlsOptions?: array{kmip: array{tlsCAFile: string, tlsCertificateKeyFile: string}},
* }
*/
class Configuration
{
Expand Down Expand Up @@ -121,7 +130,8 @@ class Configuration
* persistentCollectionNamespace?: string,
* proxyDir?: string,
* proxyNamespace?: string,
* repositoryFactory?: RepositoryFactory
* repositoryFactory?: RepositoryFactory,
* autoEncryption?: AutoEncryptionOptions,
* }
*/
private array $attributes = [];
Expand Down Expand Up @@ -653,6 +663,49 @@ 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
*
* @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.');
}

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
*
* @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']);
}
}

interface_exists(MappingDriver::class);
42 changes: 36 additions & 6 deletions lib/Doctrine/ODM/MongoDB/DocumentManager.php
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@
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;
Expand Down Expand Up @@ -64,6 +65,8 @@
*/
private Client $client;

private ClientEncryption $clientEncryption;

/**
* The used Configuration.
*/
Expand Down Expand Up @@ -151,12 +154,7 @@
$this->client = $client ?: new Client(
'mongodb://127.0.0.1',
[],
[
'driver' => [
'name' => 'doctrine-odm',
'version' => self::getVersion(),
],
],
$this->getDriverOptions(),
);

$this->classNameResolver = $this->config->isLazyGhostObjectEnabled()
Expand Down Expand Up @@ -225,6 +223,21 @@
return $this->client;
}

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
{
Expand Down Expand Up @@ -924,6 +937,23 @@
return $mapping['targetDocument'];
}

/** @todo move this to the Configuration class, so that it can be use to instantiate the Client outside of the DocumentManager */
private function getDriverOptions(): array

Check failure on line 941 in lib/Doctrine/ODM/MongoDB/DocumentManager.php

View workflow job for this annotation

GitHub Actions / Static Analysis with PHPStan (8.2)

Method Doctrine\ODM\MongoDB\DocumentManager::getDriverOptions() return type has no value type specified in iterable type array.
{
$driverOptions = [
'driver' => [
'name' => 'doctrine-odm',
'version' => self::getVersion(),
],
];

if ($this->config->getAutoEncryption()) {
$driverOptions['autoEncryption'] = $this->config->getAutoEncryption();
}

return $driverOptions;
}

private static function getVersion(): string
{
if (self::$version === null) {
Expand Down
49 changes: 49 additions & 0 deletions lib/Doctrine/ODM/MongoDB/Mapping/Annotations/Encrypt.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
<?php

declare(strict_types=1);

namespace Doctrine\ODM\MongoDB\Mapping\Annotations;

use Attribute;
use Doctrine\Common\Annotations\Annotation\NamedArgumentConstructor;
use InvalidArgumentException;
use MongoDB\BSON\Type;
use MongoDB\Driver\ClientEncryption;

use function in_array;

/**
* Defines an encrypted field mapping.
*
* @see https://www.mongodb.com/docs/manual/core/queryable-encryption/fundamentals/encrypt-and-query/#configure-encrypted-fields-for-optimal-search-and-storage
*
* @Annotation
* @NamedArgumentConstructor
*/
#[Attribute(Attribute::TARGET_PROPERTY)]
final class Encrypt implements Annotation
{
public const QUERY_TYPE_EQUALITY = ClientEncryption::QUERY_TYPE_EQUALITY;
public const QUERY_TYPE_RANGE = ClientEncryption::QUERY_TYPE_RANGE;

Check failure on line 27 in lib/Doctrine/ODM/MongoDB/Mapping/Annotations/Encrypt.php

View workflow job for this annotation

GitHub Actions / Static Analysis with PHPStan (8.2)

Access to undefined constant MongoDB\Driver\ClientEncryption::QUERY_TYPE_RANGE.

/**
* @param self::QUERY_TYPE_*|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 $trimFactor
* @param positive-int|null $contention
*/
public function __construct(

Check failure on line 36 in lib/Doctrine/ODM/MongoDB/Mapping/Annotations/Encrypt.php

View workflow job for this annotation

GitHub Actions / Static Analysis with PHPStan (8.2)

PHPDoc tag @param for parameter $queryType contains unresolvable type.
public ?string $queryType = null,

Check failure on line 37 in lib/Doctrine/ODM/MongoDB/Mapping/Annotations/Encrypt.php

View workflow job for this annotation

GitHub Actions / Static Analysis with PHPStan (8.2)

PHPDoc type for property Doctrine\ODM\MongoDB\Mapping\Annotations\Encrypt::$queryType with type mixed is not subtype of native type string|null.

Check failure on line 37 in lib/Doctrine/ODM/MongoDB/Mapping/Annotations/Encrypt.php

View workflow job for this annotation

GitHub Actions / Static Analysis with PHPStan (8.2)

PHPDoc type for property Doctrine\ODM\MongoDB\Mapping\Annotations\Encrypt::$queryType contains unresolvable type.
public string|int|Type|null $min = null,
public string|int|Type|null $max = null,
public ?int $sparsity = null,
public ?int $prevision = null,
public ?int $trimFactor = null,
public ?int $contention = null,
) {
if ($this->queryType && ! in_array($this->queryType, [self::QUERY_TYPE_EQUALITY, self::QUERY_TYPE_RANGE], true)) {
throw new InvalidArgumentException('Invalid query type');
}
}
}
9 changes: 9 additions & 0 deletions lib/Doctrine/ODM/MongoDB/Mapping/ClassMetadata.php
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,7 @@
* order?: int|string,
* background?: bool,
* enumType?: class-string<BackedEnum>,
* encrypt?: array{queryType?: ?string, min?: mixed, max?: mixed, sparsity?: int<1, 4>, prevision?: int, trimFactor?: int, contention?: int}
* }
* @phpstan-type FieldMapping array{
* type: string,
Expand Down Expand Up @@ -153,6 +154,7 @@
* alsoLoadFields?: list<string>,
* enumType?: class-string<BackedEnum>,
* 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,
Expand Down Expand Up @@ -801,6 +803,13 @@
*/
public $isReadOnly;

/**
* READ-ONLY: A flag for whether or not this document has encrypted fields.
*
* @var bool
*/
public $isEncrypted = false;

/** READ ONLY: stores metadata about the time series collection */
public ?TimeSeries $timeSeriesOptions = null;

Expand Down
4 changes: 3 additions & 1 deletion lib/Doctrine/ODM/MongoDB/Mapping/Driver/AttributeDriver.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
{
Expand Down Expand Up @@ -264,6 +264,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;
}
}

Expand Down
28 changes: 24 additions & 4 deletions lib/Doctrine/ODM/MongoDB/SchemaManager.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -643,10 +644,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?
$options,
);
} else {
$this->dm->getDocumentDatabase($documentName)->createCollection(
$class->getCollection(),
$this->getWriteOptions($maxTimeMs, $writeConcern, $options),
);
}
}

/**
Expand Down
62 changes: 62 additions & 0 deletions lib/Doctrine/ODM/MongoDB/Utility/EncryptionFieldMap.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
<?php

declare(strict_types=1);

namespace Doctrine\ODM\MongoDB\Utility;

use Doctrine\ODM\MongoDB\Mapping\ClassMetadataFactoryInterface;
use Generator;

use function array_filter;
use function iterator_to_array;

final class EncryptionFieldMap
{
public function __construct(private ClassMetadataFactoryInterface $classMetadataFactory)
{
}

/**
* Generate the encryption field map from the class metadata.
*
* @param class-string $className
*/
public function getEncryptionFieldMap(string $className): array

Check failure on line 24 in lib/Doctrine/ODM/MongoDB/Utility/EncryptionFieldMap.php

View workflow job for this annotation

GitHub Actions / Static Analysis with PHPStan (8.2)

Method Doctrine\ODM\MongoDB\Utility\EncryptionFieldMap::getEncryptionFieldMap() return type has no value type specified in iterable type array.
{
return iterator_to_array($this->createEncryptionFieldMap($className));
}

private function createEncryptionFieldMap(string $className, string $path = ''): Generator
{
$classMetadata = $this->classMetadataFactory->getMetadataFor($className);
foreach ($classMetadata->fieldMappings as $mapping) {
// @todo support polymorphic types and inheritence?
// Add fields recursively
if ($mapping['embedded'] ?? false) {
yield from $this->createEncryptionFieldMap($mapping['targetDocument'], $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);
}

yield $field;
}
}
}
10 changes: 8 additions & 2 deletions tests/Doctrine/ODM/MongoDB/Tests/BaseTestCase.php
Original file line number Diff line number Diff line change
Expand Up @@ -129,8 +129,14 @@ protected static function createMetadataDriverImpl(): MappingDriver

protected static function createTestDocumentManager(): DocumentManager
{
$config = static::getConfiguration();
$client = new Client(self::getUri());
$config = static::getConfiguration();
$driverOptions = [];

if ($config->getAutoEncryption()) {
$driverOptions['autoEncryption'] = $config->getAutoEncryption();
}

$client = new Client(self::getUri(), [], $driverOptions);

return DocumentManager::create($client, $config);
}
Expand Down
Loading
Loading