Skip to content

Feature Queryable Encryption #2779

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

Open
wants to merge 6 commits into
base: 2.12.x
Choose a base branch
from
Open
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
1 change: 1 addition & 0 deletions .github/workflows/continuous-integration.yml
Original file line number Diff line number Diff line change
Expand Up @@ -155,3 +155,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 }}
40 changes: 40 additions & 0 deletions docs/en/reference/attributes-reference.rst
Original file line number Diff line number Diff line change
Expand Up @@ -336,6 +336,46 @@ Unlike normal documents, embedded documents cannot specify their own database or
collection. That said, a single embedded document class may be used with
multiple document classes, and even other embedded documents!

#[Encrypt]
----------

The ``#[Encrypt]`` attribute is used to define an encrypted field mapping for a
document property. It allows you to configure fields for encryption and queryable
encryption in MongoDB.

Optional arguments:

- ``queryType`` - Specifies the query type for the field. Possible values:
- ``null`` (default) - Field is not queryable.
- ``EncryptQuery::Equality`` - Enables equality queries.
- ``EncryptQuery::Range`` - Enables range queries.
- ``min``, ``max`` - Specify minimum and maximum (inclusive) queryable values
for a field when possible, as smaller bounds improve query efficiency. If
querying values outside of these bounds, MongoDB returns an error.
- ``sparsity``, ``prevision``, ``trimFactor``, ``contention`` - For advanced
users only. The default values for these options are suitable for the majority
of use cases, and should only be modified if your use case requires it.

Example:

.. code-block:: php

<?php

use Doctrine\ODM\MongoDB\Mapping\Annotations\Encrypt;
use Doctrine\ODM\MongoDB\Mapping\Annotations\EncryptQuery;

#[Document]
class Client
{
#[Field]
#[Encrypt(queryType: EncryptQuery::Equality)]
public string $name;
}

For more details, refer to the MongoDB documentation on
`Queryable Encryption <https://www.mongodb.com/docs/manual/core/queryable-encryption/fundamentals/encrypt-and-query/>`_.

#[Field]
--------

Expand Down
25 changes: 25 additions & 0 deletions doctrine-mongo-mapping.xsd
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@
<xs:element name="field" type="odm:field" minOccurs="0" maxOccurs="unbounded" />
<xs:element name="embed-one" type="odm:embed-one" minOccurs="0" maxOccurs="unbounded" />
<xs:element name="embed-many" type="odm:embed-many" minOccurs="0" maxOccurs="unbounded" />
<xs:element name="encrypt" type="odm:encrypt-field" minOccurs="0" maxOccurs="1" />
<xs:element name="reference-one" type="odm:reference-one" minOccurs="0" maxOccurs="unbounded" />
<xs:element name="reference-many" type="odm:reference-many" minOccurs="0" maxOccurs="unbounded" />
<xs:element name="discriminator-field" type="odm:discriminator-field" minOccurs="0" />
Expand Down Expand Up @@ -183,7 +184,31 @@
<xs:attribute name="mode" type="odm:read-preference-values" />
</xs:complexType>

<xs:complexType name="encrypt-field">
<xs:attribute name="queryType">
<xs:simpleType>
<xs:restriction base="xs:string">
<xs:enumeration value="equality" />
<xs:enumeration value="range" />
</xs:restriction>
</xs:simpleType>
</xs:attribute>
<xs:attribute name="min" type="xs:string" />
<xs:attribute name="max" type="xs:string" />
<xs:attribute name="sparsity" type="xs:integer" />
<xs:attribute name="prevision" type="xs:integer" />
<xs:attribute name="trimFactor" type="xs:integer" />
<xs:attribute name="contention" type="xs:integer" />
</xs:complexType>

<xs:complexType name="encrypt-embedded-document">
</xs:complexType>

<xs:complexType name="field">
<xs:choice minOccurs="0" maxOccurs="unbounded">
<xs:element name="encrypt" type="odm:encrypt-field" minOccurs="0" maxOccurs="1" />
</xs:choice>

<xs:attribute name="name" type="xs:NMTOKEN" />
<xs:attribute name="type" type="xs:NMTOKEN" />
<xs:attribute name="strategy" type="odm:storage-strategy" default="set" />
Expand Down
149 changes: 148 additions & 1 deletion lib/Doctrine/ODM/MongoDB/Configuration.php
Original file line number Diff line number Diff line change
Expand Up @@ -24,18 +24,25 @@
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;
use ProxyManager\GeneratorStrategy\EvaluatingGeneratorStrategy;
use ProxyManager\GeneratorStrategy\FileWriterGeneratorStrategy;
use Psr\Cache\CacheItemPoolInterface;
use ReflectionClass;
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;

Expand All @@ -50,6 +57,7 @@
* $dm = DocumentManager::create(new Connection(), $config);
*
* @phpstan-import-type CommitOptions from UnitOfWork
* @phpstan-type KmsProvider array{type: string, ...}
*/
class Configuration
{
Expand Down Expand Up @@ -121,7 +129,10 @@ class Configuration
* persistentCollectionNamespace?: string,
* proxyDir?: string,
* proxyNamespace?: string,
* repositoryFactory?: RepositoryFactory
* repositoryFactory?: RepositoryFactory,
* kmsProvider?: KmsProvider,
* defaultMasterKey?: array<string, mixed>|null,
* autoEncryption?: array<string, mixed>,
* }
*/
private array $attributes = [];
Expand All @@ -135,6 +146,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<string, mixed>
*/
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<string, mixed>, tlsOptions?: array<string, mixed>}
*/
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.
*/
Expand Down Expand Up @@ -651,6 +706,98 @@ 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<string, mixed>|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
*
* @param array{ keyVaultClient?: Client|Manager, keyVaultNamespace?: string, tlsOptions?: array<string, mixed>, schemaMap?: array<string, mixed>, encryptedFieldsMap?: array<string, mixed>, extraOptions?: array<string, mixed>} $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<string, mixed>|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<string, mixed> */
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);
27 changes: 27 additions & 0 deletions lib/Doctrine/ODM/MongoDB/ConfigurationException.php
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@

use Exception;

use function sprintf;

final class ConfigurationException extends Exception
{
public static function persistentCollectionDirMissing(): self
Expand All @@ -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));
}
}
Loading