Skip to content

Commit b9b6e3f

Browse files
committed
Refactor encrypted fields map generator
1 parent 9e5d36d commit b9b6e3f

File tree

6 files changed

+283
-124
lines changed

6 files changed

+283
-124
lines changed

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

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2183,6 +2183,15 @@ public function isView(): bool
21832183
return $this->isView;
21842184
}
21852185

2186+
public function isDocument(): bool
2187+
{
2188+
return ! $this->isView
2189+
&& ! $this->isEmbeddedDocument
2190+
&& ! $this->isFile
2191+
&& ! $this->isQueryResultDocument
2192+
&& ! $this->isMappedSuperclass;
2193+
}
2194+
21862195
/** @param class-string $rootClass */
21872196
public function markViewOf(string $rootClass): void
21882197
{

lib/Doctrine/ODM/MongoDB/SchemaManager.php

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +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;
10+
use Doctrine\ODM\MongoDB\Utility\EncryptedFieldsMapGenerator;
1111
use InvalidArgumentException;
1212
use MongoDB\Driver\Exception\CommandException;
1313
use MongoDB\Driver\Exception\RuntimeException;
@@ -646,7 +646,7 @@ public function createDocumentCollection(string $documentName, ?int $maxTimeMs =
646646

647647
// Encryption is enabled only if the KMS provider is set and at least one field is encrypted
648648
if ($this->dm->getConfiguration()->getKmsProvider()) {
649-
$encryptedFields = (new EncryptionFieldMap($this->dm->getMetadataFactory()))->getEncryptionFieldMap($class->name);
649+
$encryptedFields = (new EncryptedFieldsMapGenerator($this->dm->getMetadataFactory()))->getEncryptedFieldsMapForClass($class->name);
650650

651651
if ($encryptedFields) {
652652
$options['encryptedFields'] = ['fields' => $encryptedFields];

lib/Doctrine/ODM/MongoDB/Utility/EncryptionFieldMap.php renamed to lib/Doctrine/ODM/MongoDB/Utility/EncryptedFieldsMapGenerator.php

Lines changed: 44 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -14,30 +14,67 @@
1414
use function assert;
1515
use function iterator_to_array;
1616

17-
final class EncryptionFieldMap
17+
final class EncryptedFieldsMapGenerator
1818
{
19+
private array $generatorStack = [];
20+
1921
public function __construct(private ClassMetadataFactoryInterface $classMetadataFactory)
2022
{
2123
}
2224

25+
/**
26+
* Returns the full encryption fields map for a document manager
27+
*
28+
* @return array<class-string, array<string, array>>
29+
*/
30+
public function getEncryptedFieldsMap(): array
31+
{
32+
$encryptedFieldsMap = [];
33+
34+
$allMetadata = $this->classMetadataFactory->getAllMetadata();
35+
foreach ($allMetadata as $classMetadata) {
36+
if (! $classMetadata->isDocument()) {
37+
continue;
38+
}
39+
40+
$classMap = iterator_to_array($this->createEncryptedFieldsMapForClass($classMetadata));
41+
if ($classMap === []) {
42+
continue;
43+
}
44+
45+
$encryptedFieldsMap[$classMetadata->getName()] = $classMap;
46+
}
47+
48+
return $encryptedFieldsMap;
49+
}
50+
2351
/**
2452
* Generate the encryption field map from the class metadata.
2553
*
2654
* @param class-string $className
2755
*/
28-
public function getEncryptionFieldMap(string $className): array
56+
public function getEncryptedFieldsMapForClass(string $className): array
2957
{
3058
$classMetadata = $this->classMetadataFactory->getMetadataFor($className);
3159

32-
return iterator_to_array($this->createEncryptionFieldMap($classMetadata));
60+
$this->generatorStack = [];
61+
62+
return iterator_to_array($this->createEncryptedFieldsMapForClass($classMetadata));
3363
}
3464

35-
private function createEncryptionFieldMap(ClassMetadata $classMetadata, string $path = ''): Generator
65+
private function createEncryptedFieldsMapForClass(ClassMetadata $classMetadata, string $path = ''): Generator
3666
{
3767
if ($classMetadata->isEncrypted && ! $classMetadata->isEmbeddedDocument) {
3868
throw MappingException::rootDocumentCannotBeEncrypted($classMetadata->getName());
3969
}
4070

71+
if (isset($this->generatorStack[$classMetadata->getName()])) {
72+
// Prevent infinite recursion due to circular references in the metadata
73+
return;
74+
}
75+
76+
$this->generatorStack[$classMetadata->getName()] = true;
77+
4178
foreach ($classMetadata->fieldMappings as $mapping) {
4279
// @todo support polymorphic types and inheritence?
4380
// Add fields recursively
@@ -49,7 +86,7 @@ private function createEncryptionFieldMap(ClassMetadata $classMetadata, string $
4986
if ($embedMetadata->isEncrypted) {
5087
$mapping['encrypt'] ??= []; // @todo get the keyId
5188
} elseif (! isset($mapping['encrypt'])) {
52-
yield from $this->createEncryptionFieldMap(
89+
yield from $this->createEncryptedFieldsMapForClass(
5390
$embedMetadata,
5491
$path . $mapping['name'] . '.',
5592
);
@@ -80,5 +117,7 @@ private function createEncryptionFieldMap(ClassMetadata $classMetadata, string $
80117

81118
yield $field;
82119
}
120+
121+
unset($this->generatorStack[$classMetadata->getName()]);
83122
}
84123
}

tests/Doctrine/ODM/MongoDB/Tests/Functional/QueryableEncryptionTest.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,8 +11,8 @@
1111
use Documents\Encryption\PatientRecord;
1212
use MongoDB\BSON\Binary;
1313
use MongoDB\Client;
14-
1514
use MongoDB\Model\BSONDocument;
15+
1616
use function iterator_to_array;
1717
use function random_bytes;
1818

Lines changed: 227 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,227 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Doctrine\ODM\MongoDB\Tests\Tools;
6+
7+
use DateTimeImmutable;
8+
use Doctrine\ODM\MongoDB\Configuration;
9+
use Doctrine\ODM\MongoDB\DocumentManager;
10+
use Doctrine\ODM\MongoDB\Mapping\ClassMetadata;
11+
use Doctrine\ODM\MongoDB\Mapping\ClassMetadataFactoryInterface;
12+
use Doctrine\ODM\MongoDB\Mapping\MappingException;
13+
use Doctrine\ODM\MongoDB\Tests\BaseTestCase;
14+
use Doctrine\ODM\MongoDB\Utility\EncryptedFieldsMapGenerator;
15+
use Doctrine\Persistence\Mapping\AbstractClassMetadataFactory;
16+
use Doctrine\Persistence\Mapping\Driver\MappingDriver;
17+
use Doctrine\Persistence\Mapping\ReflectionService;
18+
use Documents\Encryption\Client;
19+
use Documents\Encryption\InvalidRootEncrypt;
20+
use Documents\Encryption\Patient;
21+
use Documents\Encryption\PatientRecord;
22+
use Documents\Encryption\RangeTypes;
23+
use MongoDB\BSON\Decimal128;
24+
use MongoDB\BSON\UTCDateTime;
25+
26+
use function array_map;
27+
28+
class EncryptedFieldsMapGeneratorTest extends BaseTestCase
29+
{
30+
public function testGetEncryptionFieldsMapForClass(): void
31+
{
32+
$factory = new EncryptedFieldsMapGenerator($this->dm->getMetadataFactory());
33+
$encryptedFieldsMap = $factory->getEncryptedFieldsMapForClass(Patient::class);
34+
35+
$expected = [
36+
[
37+
'path' => 'patientRecord.ssn',
38+
'bsonType' => 'string',
39+
'keyId' => null,
40+
'queries' => ['queryType' => 'equality'],
41+
],
42+
[
43+
'path' => 'patientRecord.billing',
44+
'bsonType' => 'object',
45+
'keyId' => null,
46+
],
47+
[
48+
'path' => 'patientRecord.billingAmount',
49+
'bsonType' => 'int',
50+
'keyId' => null,
51+
'queries' => ['queryType' => 'range', 'min' => 100, 'max' => 2000, 'sparsity' => 1, 'trimFactor' => 4],
52+
],
53+
];
54+
55+
self::assertEquals($expected, $encryptedFieldsMap);
56+
}
57+
58+
public function testGetEncryptionFieldsMapForClassForEmbeddedDocument(): void
59+
{
60+
$factory = new EncryptedFieldsMapGenerator($this->dm->getMetadataFactory());
61+
$encryptedFieldsMap = $factory->getEncryptedFieldsMapForClass(Client::class);
62+
63+
$expected = [
64+
[
65+
'path' => 'name',
66+
'bsonType' => 'string',
67+
'keyId' => null,
68+
],
69+
[
70+
'path' => 'clientCards',
71+
'bsonType' => 'array',
72+
'keyId' => null,
73+
],
74+
];
75+
76+
self::assertSame($expected, $encryptedFieldsMap);
77+
}
78+
79+
public function testVariousRangeTypes(): void
80+
{
81+
$factory = new EncryptedFieldsMapGenerator($this->dm->getMetadataFactory());
82+
$encryptedFieldsMap = $factory->getEncryptedFieldsMapForClass(RangeTypes::class);
83+
84+
$expected = [
85+
[
86+
'path' => 'intField',
87+
'bsonType' => 'int',
88+
'keyId' => null,
89+
'queries' => ['queryType' => 'range', 'min' => 5, 'max' => 10],
90+
],
91+
[
92+
'path' => 'floatField',
93+
'bsonType' => 'float',
94+
'keyId' => null,
95+
'queries' => ['queryType' => 'range', 'min' => 5.5, 'max' => 10.5],
96+
],
97+
[
98+
'path' => 'decimalField',
99+
'bsonType' => 'decimal128',
100+
'keyId' => null,
101+
'queries' => ['queryType' => 'range', 'min' => new Decimal128('0.1'), 'max' => new Decimal128('0.2')],
102+
],
103+
[
104+
'path' => 'dateField',
105+
'bsonType' => 'date_immutable',
106+
'keyId' => null,
107+
'queries' => [
108+
'queryType' => 'range',
109+
'min' => new UTCDateTime(new DateTimeImmutable('2000-01-01 00:00:00')),
110+
'max' => new UTCDateTime(new DateTimeImmutable('2100-01-01 00:00:00')),
111+
],
112+
],
113+
];
114+
115+
self::assertEquals($expected, $encryptedFieldsMap);
116+
}
117+
118+
public function testRootDocumentsCannotBeEncrypted(): void
119+
{
120+
$this->expectException(MappingException::class);
121+
$this->expectExceptionMessage('The root document class "Documents\Encryption\InvalidRootEncrypt" cannot be encrypted. Only fields and embedded documents can be encrypted.');
122+
123+
$factory = new EncryptedFieldsMapGenerator($this->dm->getMetadataFactory());
124+
$factory->getEncryptedFieldsMapForClass(InvalidRootEncrypt::class);
125+
}
126+
127+
public function testGetEncryptionFieldsMap(): void
128+
{
129+
$classMetadataFactory = $this->createMetadataFactory(
130+
$this->dm->getMetadataFactory(),
131+
Patient::class,
132+
PatientRecord::class,
133+
);
134+
135+
$factory = new EncryptedFieldsMapGenerator($classMetadataFactory);
136+
$encryptedFieldsMap = $factory->getEncryptedFieldsMap();
137+
138+
$expectedEncryptedFieldsMap = [
139+
Patient::class => [
140+
[
141+
'path' => 'patientRecord.ssn',
142+
'bsonType' => 'string',
143+
'keyId' => null,
144+
'queries' => ['queryType' => 'equality'],
145+
],
146+
[
147+
'path' => 'patientRecord.billing',
148+
'bsonType' => 'object',
149+
'keyId' => null,
150+
],
151+
[
152+
'path' => 'patientRecord.billingAmount',
153+
'bsonType' => 'int',
154+
'keyId' => null,
155+
'queries' => ['queryType' => 'range', 'min' => 100, 'max' => 2000, 'sparsity' => 1, 'trimFactor' => 4],
156+
],
157+
],
158+
];
159+
160+
$this->assertEquals($expectedEncryptedFieldsMap, $encryptedFieldsMap);
161+
}
162+
163+
private function createMetadataFactory(ClassMetadataFactoryInterface $classMetadataFactory, string ...$className): ClassMetadataFactoryInterface
164+
{
165+
return new class ($classMetadataFactory, $className) extends AbstractClassMetadataFactory implements ClassMetadataFactoryInterface
166+
{
167+
public function __construct(private ClassMetadataFactoryInterface $classMetadataFactory, private array $classNames)
168+
{
169+
}
170+
171+
public function getAllMetadata(): array
172+
{
173+
return array_map(
174+
$this->classMetadataFactory->getMetadataFor(...),
175+
$this->classNames,
176+
);
177+
}
178+
179+
public function getMetadataFor(string $className): ClassMetadata
180+
{
181+
return $this->classMetadataFactory->getMetadataFor($className);
182+
}
183+
184+
protected function initialize(): void
185+
{
186+
}
187+
188+
protected function getDriver(): MappingDriver
189+
{
190+
return $this->classMetadataFactory->getDriver();
191+
}
192+
193+
protected function wakeupReflection(\Doctrine\Persistence\Mapping\ClassMetadata $class, ReflectionService $reflService): void
194+
{
195+
$this->classMetadataFactory->wakeupReflection($class, $reflService);
196+
}
197+
198+
protected function initializeReflection(\Doctrine\Persistence\Mapping\ClassMetadata $class, ReflectionService $reflService): void
199+
{
200+
$this->classMetadataFactory->initializeReflection($class, $reflService);
201+
}
202+
203+
protected function isEntity(\Doctrine\Persistence\Mapping\ClassMetadata $class): bool
204+
{
205+
return $this->classMetadataFactory->isEntity($class);
206+
}
207+
208+
protected function doLoadMetadata(\Doctrine\Persistence\Mapping\ClassMetadata $class, ?\Doctrine\Persistence\Mapping\ClassMetadata $parent, bool $rootEntityFound, array $nonSuperclassParents): void
209+
{
210+
$this->classMetadataFactory->doLoadMetadata($class, $parent, $rootEntityFound, $nonSuperclassParents);
211+
}
212+
213+
protected function newClassMetadataInstance(string $className): ClassMetadata
214+
{
215+
return $this->classMetadataFactory->newClassMetadataInstance($className);
216+
}
217+
218+
public function setConfiguration(Configuration $config): void
219+
{
220+
}
221+
222+
public function setDocumentManager(DocumentManager $dm): void
223+
{
224+
}
225+
};
226+
}
227+
}

0 commit comments

Comments
 (0)