Skip to content

Commit 72ee408

Browse files
authored
Add support for Field-Level Automatic and Queryable Encryption (#2759)
* Add test document classes from tutorial https://github.com/mongodb/docs/blob/master/source/includes/qe-tutorials/node/queryable-encryption-tutorial.js * Create encryption field map * Create encrypted collection * Use promoted properties for encrypted document tests * Revert changes in BaseTestCase * Rename EncryptionFieldMapTest * Create Configuration::getDriverOptions() to create the client * Support class-level #[Encrypt] attribute * Skip QE tests on non-supported server configuration * Add documentation on #[Encrypt] attribute * Add XML mapping for "encrypt" tag * Create specific xsd type for embedded documents to enable <encrypt> tag * Fix import class Throwable * Fix access to $version property before initialization * Use an enum for query type * Make getClientEncryption internal * Improve type of min/max bounds for range queries * Use getWriteOptions with createEncryptedCollection * Use random local master key * Add assertion on non-decrypted data * Ignore all DOCTRINE_MONGODB_DATABASE phpstan errors * Baseline phpstan * Fix CS and skip phpstan issues
1 parent b0909ca commit 72ee408

28 files changed

+1020
-84
lines changed

docs/en/reference/attributes-reference.rst

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -336,6 +336,46 @@ Unlike normal documents, embedded documents cannot specify their own database or
336336
collection. That said, a single embedded document class may be used with
337337
multiple document classes, and even other embedded documents!
338338

339+
#[Encrypt]
340+
----------
341+
342+
The ``#[Encrypt]`` attribute is used to define an encrypted field mapping for a
343+
document property. It allows you to configure fields for encryption and queryable
344+
encryption in MongoDB.
345+
346+
Optional arguments:
347+
348+
- ``queryType`` - Specifies the query type for the field. Possible values:
349+
- ``null`` (default) - Field is not queryable.
350+
- ``EncryptQuery::Equality`` - Enables equality queries.
351+
- ``EncryptQuery::Range`` - Enables range queries.
352+
- ``min``, ``max`` - Specify minimum and maximum (inclusive) queryable values
353+
for a field when possible, as smaller bounds improve query efficiency. If
354+
querying values outside of these bounds, MongoDB returns an error.
355+
- ``sparsity``, ``prevision``, ``trimFactor``, ``contention`` - For advanced
356+
users only. The default values for these options are suitable for the majority
357+
of use cases, and should only be modified if your use case requires it.
358+
359+
Example:
360+
361+
.. code-block:: php
362+
363+
<?php
364+
365+
use Doctrine\ODM\MongoDB\Mapping\Annotations\Encrypt;
366+
use Doctrine\ODM\MongoDB\Mapping\Annotations\EncryptQuery;
367+
368+
#[Document]
369+
class Client
370+
{
371+
#[Field]
372+
#[Encrypt(queryType: EncryptQuery::Equality)]
373+
public string $name;
374+
}
375+
376+
For more details, refer to the MongoDB documentation on
377+
`Queryable Encryption <https://www.mongodb.com/docs/manual/core/queryable-encryption/fundamentals/encrypt-and-query/>`_.
378+
339379
#[Field]
340380
--------
341381

doctrine-mongo-mapping.xsd

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@
3131
<xs:element name="field" type="odm:field" minOccurs="0" maxOccurs="unbounded" />
3232
<xs:element name="embed-one" type="odm:embed-one" minOccurs="0" maxOccurs="unbounded" />
3333
<xs:element name="embed-many" type="odm:embed-many" minOccurs="0" maxOccurs="unbounded" />
34+
<xs:element name="encrypt" type="odm:encrypt-field" minOccurs="0" maxOccurs="1" />
3435
<xs:element name="reference-one" type="odm:reference-one" minOccurs="0" maxOccurs="unbounded" />
3536
<xs:element name="reference-many" type="odm:reference-many" minOccurs="0" maxOccurs="unbounded" />
3637
<xs:element name="discriminator-field" type="odm:discriminator-field" minOccurs="0" />
@@ -183,7 +184,31 @@
183184
<xs:attribute name="mode" type="odm:read-preference-values" />
184185
</xs:complexType>
185186

187+
<xs:complexType name="encrypt-field">
188+
<xs:attribute name="queryType">
189+
<xs:simpleType>
190+
<xs:restriction base="xs:string">
191+
<xs:enumeration value="equality" />
192+
<xs:enumeration value="range" />
193+
</xs:restriction>
194+
</xs:simpleType>
195+
</xs:attribute>
196+
<xs:attribute name="min" type="xs:string" />
197+
<xs:attribute name="max" type="xs:string" />
198+
<xs:attribute name="sparsity" type="xs:integer" />
199+
<xs:attribute name="prevision" type="xs:integer" />
200+
<xs:attribute name="trimFactor" type="xs:integer" />
201+
<xs:attribute name="contention" type="xs:integer" />
202+
</xs:complexType>
203+
204+
<xs:complexType name="encrypt-embedded-document">
205+
</xs:complexType>
206+
186207
<xs:complexType name="field">
208+
<xs:choice minOccurs="0" maxOccurs="unbounded">
209+
<xs:element name="encrypt" type="odm:encrypt-field" minOccurs="0" maxOccurs="1" />
210+
</xs:choice>
211+
187212
<xs:attribute name="name" type="xs:NMTOKEN" />
188213
<xs:attribute name="type" type="xs:NMTOKEN" />
189214
<xs:attribute name="strategy" type="odm:storage-strategy" default="set" />

lib/Doctrine/ODM/MongoDB/Configuration.php

Lines changed: 93 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@
2424
use Doctrine\Persistence\Mapping\Driver\MappingDriver;
2525
use Doctrine\Persistence\ObjectRepository;
2626
use InvalidArgumentException;
27+
use Jean85\PrettyVersions;
2728
use LogicException;
2829
use MongoDB\Driver\WriteConcern;
2930
use ProxyManager\Configuration as ProxyManagerConfiguration;
@@ -32,10 +33,15 @@
3233
use ProxyManager\GeneratorStrategy\FileWriterGeneratorStrategy;
3334
use Psr\Cache\CacheItemPoolInterface;
3435
use ReflectionClass;
36+
use Throwable;
3537

3638
use function array_key_exists;
39+
use function array_key_first;
3740
use function class_exists;
41+
use function count;
3842
use function interface_exists;
43+
use function is_array;
44+
use function is_string;
3945
use function trigger_deprecation;
4046
use function trim;
4147

@@ -50,6 +56,11 @@
5056
* $dm = DocumentManager::create(new Connection(), $config);
5157
*
5258
* @phpstan-import-type CommitOptions from UnitOfWork
59+
* @phpstan-type AutoEncryptionOptions array{
60+
* keyVaultNamespace: string,
61+
* kmsProviders: array<string, array<string, string>>,
62+
* tlsOptions?: array{kmip: array{tlsCAFile: string, tlsCertificateKeyFile: string}},
63+
* }
5364
*/
5465
class Configuration
5566
{
@@ -121,7 +132,8 @@ class Configuration
121132
* persistentCollectionNamespace?: string,
122133
* proxyDir?: string,
123134
* proxyNamespace?: string,
124-
* repositoryFactory?: RepositoryFactory
135+
* repositoryFactory?: RepositoryFactory,
136+
* autoEncryption?: AutoEncryptionOptions,
125137
* }
126138
*/
127139
private array $attributes = [];
@@ -135,6 +147,29 @@ class Configuration
135147

136148
private bool $useLazyGhostObject = false;
137149

150+
private static string $version;
151+
152+
/**
153+
* Provides the driver options to be used when creating the MongoDB client.
154+
*
155+
* @return array<string, mixed>
156+
*/
157+
public function getDriverOptions(): array
158+
{
159+
$driverOptions = [
160+
'driver' => [
161+
'name' => 'doctrine-odm',
162+
'version' => self::getVersion(),
163+
],
164+
];
165+
166+
if (isset($this->attributes['autoEncryption'])) {
167+
$driverOptions['autoEncryption'] = $this->attributes['autoEncryption'];
168+
}
169+
170+
return $driverOptions;
171+
}
172+
138173
/**
139174
* Adds a namespace under a certain alias.
140175
*/
@@ -651,6 +686,63 @@ public function isLazyGhostObjectEnabled(): bool
651686
{
652687
return $this->useLazyGhostObject;
653688
}
689+
690+
/**
691+
* Set the options for auto-encryption.
692+
*
693+
* @see https://www.php.net/manual/en/mongodb-driver-clientencryption.construct.php
694+
*
695+
* @phpstan-param AutoEncryptionOptions $options
696+
*
697+
* @throws InvalidArgumentException If the options are invalid.
698+
*/
699+
public function setAutoEncryption(array $options): void
700+
{
701+
if (! isset($options['keyVaultNamespace']) || ! is_string($options['keyVaultNamespace'])) {
702+
throw new InvalidArgumentException('The "keyVaultNamespace" option is required.');
703+
}
704+
705+
// @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
706+
if (! isset($options['kmsProviders']) || ! is_array($options['kmsProviders']) || count($options['kmsProviders']) < 1) {
707+
throw new InvalidArgumentException('The "kmsProviders" option is required.');
708+
}
709+
710+
$this->attributes['autoEncryption'] = $options;
711+
}
712+
713+
/**
714+
* Get the options for auto-encryption.
715+
*
716+
* @see https://www.php.net/manual/en/mongodb-driver-clientencryption.construct.php
717+
*
718+
* @phpstan-return AutoEncryptionOptions
719+
*/
720+
public function getAutoEncryption(): ?array
721+
{
722+
return $this->attributes['autoEncryption'] ?? null;
723+
}
724+
725+
public function getKmsProvider(): ?string
726+
{
727+
if (! isset($this->attributes['autoEncryption'])) {
728+
return null;
729+
}
730+
731+
return array_key_first($this->attributes['autoEncryption']['kmsProviders']);
732+
}
733+
734+
private static function getVersion(): string
735+
{
736+
if (! isset(self::$version)) {
737+
try {
738+
self::$version = PrettyVersions::getVersion('doctrine/mongodb-odm')->getPrettyVersion();
739+
} catch (Throwable) {
740+
return self::$version = 'unknown';
741+
}
742+
}
743+
744+
return self::$version;
745+
}
654746
}
655747

656748
interface_exists(MappingDriver::class);

lib/Doctrine/ODM/MongoDB/DocumentManager.php

Lines changed: 20 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -25,10 +25,10 @@
2525
use Doctrine\Persistence\ObjectManager;
2626
use Doctrine\Persistence\ObjectRepository;
2727
use InvalidArgumentException;
28-
use Jean85\PrettyVersions;
2928
use MongoDB\Client;
3029
use MongoDB\Collection;
3130
use MongoDB\Database;
31+
use MongoDB\Driver\ClientEncryption;
3232
use MongoDB\Driver\ReadPreference;
3333
use MongoDB\GridFS\Bucket;
3434
use ProxyManager\Proxy\GhostObjectInterface;
@@ -64,6 +64,8 @@ class DocumentManager implements ObjectManager
6464
*/
6565
private Client $client;
6666

67+
private ClientEncryption $clientEncryption;
68+
6769
/**
6870
* The used Configuration.
6971
*/
@@ -138,8 +140,6 @@ class DocumentManager implements ObjectManager
138140
/** @var ProxyClassNameResolver&ClassNameResolver */
139141
private ProxyClassNameResolver $classNameResolver;
140142

141-
private static ?string $version = null;
142-
143143
/**
144144
* Creates a new Document that operates on the given Mongo connection
145145
* and uses the given Configuration.
@@ -151,12 +151,7 @@ protected function __construct(?Client $client = null, ?Configuration $config =
151151
$this->client = $client ?: new Client(
152152
'mongodb://127.0.0.1',
153153
[],
154-
[
155-
'driver' => [
156-
'name' => 'doctrine-odm',
157-
'version' => self::getVersion(),
158-
],
159-
],
154+
$this->config->getDriverOptions(),
160155
);
161156

162157
$this->classNameResolver = $this->config->isLazyGhostObjectEnabled()
@@ -225,6 +220,22 @@ public function getClient(): Client
225220
return $this->client;
226221
}
227222

223+
/** @internal */
224+
public function getClientEncryption(): ClientEncryption
225+
{
226+
$autoEncryptionOptions = $this->config->getAutoEncryption();
227+
228+
if (! $autoEncryptionOptions) {
229+
throw new RuntimeException('Auto-encryption is not enabled.');
230+
}
231+
232+
return $this->clientEncryption ??= $this->client->createClientEncryption([
233+
'keyVaultNamespace' => $autoEncryptionOptions['keyVaultNamespace'],
234+
'kmsProviders' => $autoEncryptionOptions['kmsProviders'],
235+
'tlsOptions' => $autoEncryptionOptions['tlsOptions'] ?? [],
236+
]);
237+
}
238+
228239
/** Gets the metadata factory used to gather the metadata of classes. */
229240
public function getMetadataFactory(): ClassmetadataFactoryInterface
230241
{
@@ -923,17 +934,4 @@ public function getClassNameForAssociation(array $mapping, $data): string
923934

924935
return $mapping['targetDocument'];
925936
}
926-
927-
private static function getVersion(): string
928-
{
929-
if (self::$version === null) {
930-
try {
931-
self::$version = PrettyVersions::getVersion('doctrine/mongodb-odm')->getPrettyVersion();
932-
} catch (Throwable) {
933-
return 'unknown';
934-
}
935-
}
936-
937-
return self::$version;
938-
}
939937
}
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Doctrine\ODM\MongoDB\Mapping\Annotations;
6+
7+
use Attribute;
8+
use DateTimeInterface;
9+
use Doctrine\Common\Annotations\Annotation\NamedArgumentConstructor;
10+
use MongoDB\BSON\Decimal128;
11+
use MongoDB\BSON\Int64;
12+
use MongoDB\BSON\UTCDateTime;
13+
14+
/**
15+
* Defines an encrypted field mapping.
16+
*
17+
* @see https://www.mongodb.com/docs/manual/core/queryable-encryption/fundamentals/encrypt-and-query/#configure-encrypted-fields-for-optimal-search-and-storage
18+
*
19+
* @Annotation
20+
* @NamedArgumentConstructor
21+
*/
22+
#[Attribute(Attribute::TARGET_CLASS | Attribute::TARGET_PROPERTY)]
23+
final class Encrypt implements Annotation
24+
{
25+
public int|float|Int64|Decimal128|UTCDateTime|null $min;
26+
public int|float|Int64|Decimal128|UTCDateTime|null $max;
27+
28+
/**
29+
* @param EncryptQuery|null $queryType Set the query type for the field, null if not queryable.
30+
* @param int<1, 4>|null $sparsity
31+
* @param positive-int|null $prevision
32+
* @param positive-int|null $trimFactor
33+
* @param positive-int|null $contention
34+
*/
35+
public function __construct(
36+
public ?EncryptQuery $queryType = null,
37+
int|float|Int64|Decimal128|UTCDateTime|DateTimeInterface|null $min = null,
38+
int|float|Int64|Decimal128|UTCDateTime|DateTimeInterface|null $max = null,
39+
public ?int $sparsity = null,
40+
public ?int $prevision = null,
41+
public ?int $trimFactor = null,
42+
public ?int $contention = null,
43+
) {
44+
$this->min = $min instanceof DateTimeInterface ? new UTCDateTime($min) : $min;
45+
$this->max = $max instanceof DateTimeInterface ? new UTCDateTime($max) : $max;
46+
}
47+
}
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Doctrine\ODM\MongoDB\Mapping\Annotations;
6+
7+
use MongoDB\Driver\ClientEncryption;
8+
9+
enum EncryptQuery: string
10+
{
11+
case Equality = ClientEncryption::QUERY_TYPE_EQUALITY;
12+
case Range = ClientEncryption::QUERY_TYPE_RANGE;
13+
}

0 commit comments

Comments
 (0)