Skip to content

Commit 38ff112

Browse files
committed
Create encrypted collection
1 parent 34f5c62 commit 38ff112

File tree

8 files changed

+205
-40
lines changed

8 files changed

+205
-40
lines changed

lib/Doctrine/ODM/MongoDB/Configuration.php

Lines changed: 54 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,8 +34,12 @@
3434
use ReflectionClass;
3535

3636
use function array_key_exists;
37+
use function array_key_first;
3738
use function class_exists;
39+
use function count;
3840
use function interface_exists;
41+
use function is_array;
42+
use function is_string;
3943
use function trigger_deprecation;
4044
use function trim;
4145

@@ -50,6 +54,11 @@
5054
* $dm = DocumentManager::create(new Connection(), $config);
5155
*
5256
* @phpstan-import-type CommitOptions from UnitOfWork
57+
* @phpstan-type AutoEncryptionOptions array{
58+
* keyVaultNamespace: string,
59+
* kmsProviders: array<string, array<string, string>>,
60+
* tlsOptions?: array{kmip: array{tlsCAFile: string, tlsCertificateKeyFile: string}},
61+
* }
5362
*/
5463
class Configuration
5564
{
@@ -121,7 +130,8 @@ class Configuration
121130
* persistentCollectionNamespace?: string,
122131
* proxyDir?: string,
123132
* proxyNamespace?: string,
124-
* repositoryFactory?: RepositoryFactory
133+
* repositoryFactory?: RepositoryFactory,
134+
* autoEncryption?: AutoEncryptionOptions,
125135
* }
126136
*/
127137
private array $attributes = [];
@@ -653,6 +663,49 @@ public function isLazyGhostObjectEnabled(): bool
653663
{
654664
return $this->useLazyGhostObject;
655665
}
666+
667+
/**
668+
* Set the options for auto-encryption.
669+
*
670+
* @see https://www.php.net/manual/en/mongodb-driver-clientencryption.construct.php
671+
*
672+
* @param AutoEncryptionOptions $options
673+
*
674+
* @throws InvalidArgumentException If the options are invalid.
675+
*/
676+
public function setAutoEncryption(array $options): void
677+
{
678+
if (! isset($options['keyVaultNamespace']) || ! is_string($options['keyVaultNamespace'])) {
679+
throw new InvalidArgumentException('The "keyVaultNamespace" option is required.');
680+
}
681+
682+
if (! isset($options['kmsProviders']) || ! is_array($options['kmsProviders']) || count($options['kmsProviders']) < 1) {
683+
throw new InvalidArgumentException('The "kmsProviders" option is required.');
684+
}
685+
686+
$this->attributes['autoEncryption'] = $options;
687+
}
688+
689+
/**
690+
* Get the options for auto-encryption.
691+
*
692+
* @see https://www.php.net/manual/en/mongodb-driver-clientencryption.construct.php
693+
*
694+
* @return AutoEncryptionOptions
695+
*/
696+
public function getAutoEncryption(): ?array
697+
{
698+
return $this->attributes['autoEncryption'] ?? null;
699+
}
700+
701+
public function getKmsProvider(): ?string
702+
{
703+
if (! isset($this->attributes['autoEncryption'])) {
704+
return null;
705+
}
706+
707+
return array_key_first($this->attributes['autoEncryption']['kmsProviders']);
708+
}
656709
}
657710

658711
interface_exists(MappingDriver::class);

lib/Doctrine/ODM/MongoDB/DocumentManager.php

Lines changed: 36 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@
2929
use MongoDB\Client;
3030
use MongoDB\Collection;
3131
use MongoDB\Database;
32+
use MongoDB\Driver\ClientEncryption;
3233
use MongoDB\Driver\ReadPreference;
3334
use MongoDB\GridFS\Bucket;
3435
use ProxyManager\Proxy\GhostObjectInterface;
@@ -64,6 +65,8 @@ class DocumentManager implements ObjectManager
6465
*/
6566
private Client $client;
6667

68+
private ClientEncryption $clientEncryption;
69+
6770
/**
6871
* The used Configuration.
6972
*/
@@ -151,12 +154,7 @@ protected function __construct(?Client $client = null, ?Configuration $config =
151154
$this->client = $client ?: new Client(
152155
'mongodb://127.0.0.1',
153156
[],
154-
[
155-
'driver' => [
156-
'name' => 'doctrine-odm',
157-
'version' => self::getVersion(),
158-
],
159-
],
157+
$this->getDriverOptions(),
160158
);
161159

162160
$this->classNameResolver = $this->config->isLazyGhostObjectEnabled()
@@ -225,6 +223,21 @@ public function getClient(): Client
225223
return $this->client;
226224
}
227225

226+
public function getClientEncryption(): ClientEncryption
227+
{
228+
$autoEncryptionOptions = $this->config->getAutoEncryption();
229+
230+
if (! $autoEncryptionOptions) {
231+
throw new RuntimeException('Auto-encryption is not enabled.');
232+
}
233+
234+
return $this->clientEncryption ??= $this->client->createClientEncryption([
235+
'keyVaultNamespace' => $autoEncryptionOptions['keyVaultNamespace'],
236+
'kmsProviders' => $autoEncryptionOptions['kmsProviders'],
237+
'tlsOptions' => $autoEncryptionOptions['tlsOptions'] ?? [],
238+
]);
239+
}
240+
228241
/** Gets the metadata factory used to gather the metadata of classes. */
229242
public function getMetadataFactory(): ClassmetadataFactoryInterface
230243
{
@@ -924,6 +937,23 @@ public function getClassNameForAssociation(array $mapping, $data): string
924937
return $mapping['targetDocument'];
925938
}
926939

940+
/** @todo move this to the Configuration class, so that it can be use to instantiate the Client outside of the DocumentManager */
941+
private function getDriverOptions(): array
942+
{
943+
$driverOptions = [
944+
'driver' => [
945+
'name' => 'doctrine-odm',
946+
'version' => self::getVersion(),
947+
],
948+
];
949+
950+
if ($this->config->getAutoEncryption()) {
951+
$driverOptions['autoEncryption'] = $this->config->getAutoEncryption();
952+
}
953+
954+
return $driverOptions;
955+
}
956+
927957
private static function getVersion(): string
928958
{
929959
if (self::$version === null) {

lib/Doctrine/ODM/MongoDB/Mapping/ClassMetadata.php

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -803,6 +803,13 @@
803803
*/
804804
public $isReadOnly;
805805

806+
/**
807+
* READ-ONLY: A flag for whether or not this document has encrypted fields.
808+
*
809+
* @var bool
810+
*/
811+
public $isEncrypted = false;
812+
806813
/** READ ONLY: stores metadata about the time series collection */
807814
public ?TimeSeries $timeSeriesOptions = null;
808815

lib/Doctrine/ODM/MongoDB/SchemaManager.php

Lines changed: 24 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
use Doctrine\ODM\MongoDB\Mapping\ClassMetadata;
88
use Doctrine\ODM\MongoDB\Mapping\ClassMetadataFactoryInterface;
99
use Doctrine\ODM\MongoDB\Repository\ViewRepository;
10+
use Doctrine\ODM\MongoDB\Utility\EncryptionFieldMap;
1011
use InvalidArgumentException;
1112
use MongoDB\Driver\Exception\CommandException;
1213
use MongoDB\Driver\Exception\RuntimeException;
@@ -643,10 +644,29 @@ public function createDocumentCollection(string $documentName, ?int $maxTimeMs =
643644
}
644645
}
645646

646-
$this->dm->getDocumentDatabase($documentName)->createCollection(
647-
$class->getCollection(),
648-
$this->getWriteOptions($maxTimeMs, $writeConcern, $options),
649-
);
647+
// Encryption is enabled only if the KMS provider is set and at least one field is encrypted
648+
if ($this->dm->getConfiguration()->getKmsProvider()) {
649+
$encryptedFields = (new EncryptionFieldMap($this->dm->getMetadataFactory()))->getEncryptionFieldMap($class->name);
650+
651+
if ($encryptedFields) {
652+
$options['encryptedFields'] = ['fields' => $encryptedFields];
653+
}
654+
}
655+
656+
if (isset($options['encryptedFields'])) {
657+
$this->dm->getDocumentDatabase($documentName)->createEncryptedCollection(
658+
$class->getCollection(),
659+
$this->dm->getClientEncryption(),
660+
$this->dm->getConfiguration()->getKmsProvider(),
661+
null, // @todo when is it necessary to set the master key?
662+
$options,
663+
);
664+
} else {
665+
$this->dm->getDocumentDatabase($documentName)->createCollection(
666+
$class->getCollection(),
667+
$this->getWriteOptions($maxTimeMs, $writeConcern, $options),
668+
);
669+
}
650670
}
651671

652672
/**

lib/Doctrine/ODM/MongoDB/Utility/EncryptionFieldMap.php

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ private function createEncryptionFieldMap(string $className, string $path = ''):
3030
{
3131
$classMetadata = $this->classMetadataFactory->getMetadataFor($className);
3232
foreach ($classMetadata->fieldMappings as $mapping) {
33+
// @todo support polymorphic types and inheritence?
3334
// Add fields recursively
3435
if ($mapping['embedded'] ?? false) {
3536
yield from $this->createEncryptionFieldMap($mapping['targetDocument'], $path . $mapping['name'] . '.');
@@ -40,8 +41,8 @@ private function createEncryptionFieldMap(string $className, string $path = ''):
4041
}
4142

4243
$field = [
43-
'name' => $path . $mapping['name'],
44-
'type' => match ($mapping['type']) {
44+
'path' => $path . $mapping['name'],
45+
'bsonType' => match ($mapping['type']) {
4546
'one' => 'object',
4647
'many' => 'array',
4748
default => $mapping['type'],

tests/Doctrine/ODM/MongoDB/Tests/BaseTestCase.php

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -129,8 +129,14 @@ protected static function createMetadataDriverImpl(): MappingDriver
129129

130130
protected static function createTestDocumentManager(): DocumentManager
131131
{
132-
$config = static::getConfiguration();
133-
$client = new Client(self::getUri());
132+
$config = static::getConfiguration();
133+
$driverOptions = [];
134+
135+
if ($config->getAutoEncryption()) {
136+
$driverOptions['autoEncryption'] = $config->getAutoEncryption();
137+
}
138+
139+
$client = new Client(self::getUri(), [], $driverOptions);
134140

135141
return DocumentManager::create($client, $config);
136142
}

tests/Doctrine/ODM/MongoDB/Tests/EncryptionTest.php

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -16,19 +16,19 @@ public function testMetadataIsEncrypted(): void
1616

1717
$expected = [
1818
[
19-
'name' => 'patientRecord.ssn',
20-
'type' => 'string',
19+
'path' => 'patientRecord.ssn',
20+
'bsonType' => 'string',
2121
'keyId' => null,
2222
'queries' => ['queryType' => 'equality'],
2323
],
2424
[
25-
'name' => 'patientRecord.billing',
26-
'type' => 'object',
25+
'path' => 'patientRecord.billing',
26+
'bsonType' => 'object',
2727
'keyId' => null,
2828
],
2929
[
30-
'name' => 'patientRecord.billingAmount',
31-
'type' => 'int',
30+
'path' => 'patientRecord.billingAmount',
31+
'bsonType' => 'int',
3232
'keyId' => null,
3333
'queries' => ['queryType' => 'range', 'min' => 100, 'max' => 2000, 'sparsity' => 1, 'trimFactor' => 4],
3434
],
Lines changed: 67 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,35 +1,83 @@
11
<?php
22

3+
declare(strict_types=1);
4+
35
namespace Doctrine\ODM\MongoDB\Tests\Functional;
46

5-
use Doctrine\ODM\MongoDB\Configuration;
6-
use Doctrine\ODM\MongoDB\Mapping\Annotations as ODM;
7+
use Doctrine\ODM\MongoDB\DocumentManager;
78
use Doctrine\ODM\MongoDB\Tests\BaseTestCase;
8-
use MongoDB\Driver\ClientEncryption;
9+
use Documents\Encryption\Patient;
10+
use Documents\Encryption\PatientBilling;
11+
use Documents\Encryption\PatientRecord;
12+
use MongoDB\BSON\Binary;
13+
use MongoDB\Client;
14+
15+
use function base64_decode;
16+
use function iterator_to_array;
917

1018
class QueryableEncryptionTest extends BaseTestCase
1119
{
12-
public function testBasic(): void
20+
private const LOCAL_MASTERKEY = 'quTJGRzz3TS2yrPUzNf9Ajv+rG2cn0buRsWT6i6BTQihznxZkhYKzyagXZZ05+y/FMEV1kpC79reiJSpysytFyEcXXJChjBsH2iTzBK8uWFN2dN7udzYjWvBJmWKbhhm';
21+
22+
public function testCreateAndQueryEncryptedCollection(): void
1323
{
24+
// @todo skip if not using MongoDB < 7, single node or not enterprise
25+
$client = new Client(self::getUri());
26+
$database = $client->getDatabase(DOCTRINE_MONGODB_DATABASE);
1427

15-
}
28+
// Create the encrypted collection
29+
$this->dm->getSchemaManager()->createDocumentCollection(Patient::class);
1630

31+
// Test created collectionss
32+
$collectionNames = iterator_to_array($database->listCollectionNames());
33+
self::assertContains('patients', $collectionNames);
34+
self::assertContains('datakeys', $collectionNames);
1735

18-
protected static function getConfiguration(): Configuration
19-
{
20-
$config = parent::getConfiguration();
36+
// Insert a document
37+
$patient = new Patient();
38+
$patient->patientName = 'Jon Doe';
39+
$patient->patientId = 12345678;
40+
41+
$patientRecord = new PatientRecord();
42+
$patientRecord->ssn = '987-65-4320';
43+
$patientRecord->billingAmount = 1200;
44+
45+
$billing = new PatientBilling();
46+
$billing->type = 'Visa';
47+
$billing->number = '4111111111111111';
48+
49+
$patientRecord->billing = $billing;
50+
$patient->patientRecord = $patientRecord;
2151

22-
return $config;
52+
$this->dm->persist($patient);
53+
$this->dm->flush();
54+
$this->dm->clear();
55+
56+
// Queryable with equality
57+
$result = $this->dm->getRepository(Patient::class)->findOneBy(['patientRecord.ssn' => '987-65-4320']);
58+
self::assertNotNull($result);
59+
self::assertSame('Jon Doe', $result->patientName);
60+
self::assertSame('987-65-4320', $result->patientRecord->ssn);
61+
62+
// Queryable with range
63+
$result = $this->dm->getRepository(Patient::class)->findOneBy(['patientRecord.billingAmount' => ['$gt' => 1000, '$lt' => 2000]]);
64+
self::assertSame('Jon Doe', $result->patientName);
65+
self::assertSame('987-65-4320', $result->patientRecord->ssn);
66+
self::assertSame('4111111111111111', $result->patientRecord->billing->number);
2367
}
24-
}
2568

26-
#[ODM\Document]
27-
class EncryptedDocument
28-
{
29-
#[ODM\Id]
30-
public string $id;
69+
protected static function createTestDocumentManager(): DocumentManager
70+
{
71+
$config = static::getConfiguration();
72+
$config->setAutoEncryption([
73+
'keyVaultNamespace' => DOCTRINE_MONGODB_DATABASE . '.datakeys',
74+
'kmsProviders' => [
75+
'local' => ['key' => new Binary(base64_decode(self::LOCAL_MASTERKEY))],
76+
],
77+
]);
78+
79+
$client = new Client(self::getUri(), [], ['autoEncryption' => $config->getAutoEncryption()]);
3180

32-
#[ODM\Field]
33-
#[ODM\Encrypt(queryType: ClientEncryption::QUERY_TYPE_EQUALITY)]
34-
private string $sensitiveField;
35-
}
81+
return DocumentManager::create($client, $config);
82+
}
83+
}

0 commit comments

Comments
 (0)