diff --git a/config/schema/mongodb-1.0.xsd b/config/schema/mongodb-1.0.xsd index 026d714b..3d336ff6 100644 --- a/config/schema/mongodb-1.0.xsd +++ b/config/schema/mongodb-1.0.xsd @@ -179,26 +179,9 @@ - - - - - - - - - - - - - - - - - - - - + + + diff --git a/docs/encryption.rst b/docs/encryption.rst index 5be85afd..13c6bc7b 100644 --- a/docs/encryption.rst +++ b/docs/encryption.rst @@ -92,7 +92,12 @@ This setting is **recommended** for improved security and performance. specified collection, the client downloads the server-side remote schema for the collection and uses it instead. -For more details, see the official MongoDB documentation: `Encrypted Fields and Enabled Queries `_. +For more details, see the official MongoDB documentation: +`Encrypted Fields and Enabled Queries `_. + +Note that there is no ``fields`` key in the configuration of each collection +for the bundle configuration. Instead, you directly specify the list of +encrypted fields as an array under the collection namespace. .. tabs:: @@ -106,9 +111,9 @@ For more details, see the official MongoDB documentation: `Encrypted Fields and autoEncryption: encryptedFieldsMap: "mydatabase.mycollection": - fields: - - path: "sensitive_field" - bsonType: "string" + - keyId: { $binary: { base64: 2CSosXLSTEKaYphcSnUuCw==, subType: '04' } } + path: "sensitive_field" + bsonType: "string" .. group-tab:: XML @@ -117,9 +122,15 @@ For more details, see the official MongoDB documentation: `Encrypted Fields and - - - + @@ -135,11 +146,10 @@ For more details, see the official MongoDB documentation: `Encrypted Fields and ->autoEncryption([ 'encryptedFieldsMap' => [ 'mydatabase.mycollection' => [ - 'fields' => [ - [ - 'path' => 'sensitive_field', - 'bsonType' => 'string', - ], + [ + 'path' => 'sensitive_field', + 'keyId' => ['$binary' => ['base64' => '2CSosXLSTEKaYphcSnUuCw==', 'subType' => '04' ] ], + 'bsonType' => 'string', ], ], ], diff --git a/phpunit.xml.dist b/phpunit.xml.dist index 50eaaae1..2ef3bdec 100644 --- a/phpunit.xml.dist +++ b/phpunit.xml.dist @@ -20,6 +20,7 @@ + diff --git a/src/Command/DumpEncryptedFieldsMapCommand.php b/src/Command/DumpEncryptedFieldsMapCommand.php index f2c999dc..25b0d630 100644 --- a/src/Command/DumpEncryptedFieldsMapCommand.php +++ b/src/Command/DumpEncryptedFieldsMapCommand.php @@ -6,7 +6,7 @@ use Doctrine\ODM\MongoDB\DocumentManager; use Doctrine\ODM\MongoDB\Mapping\ClassMetadata; -use Doctrine\ODM\MongoDB\Utility\EncryptedFieldsMapGenerator; +use MongoDB\BSON\PackedArray; use Symfony\Component\Console\Attribute\AsCommand; use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Input\InputInterface; @@ -16,10 +16,7 @@ use Symfony\Component\Yaml\Dumper; use Symfony\Contracts\Service\ServiceCollectionInterface; -use function array_combine; -use function array_keys; -use function array_map; -use function array_values; +use function json_decode; use function json_encode; use function sprintf; use function var_export; @@ -63,24 +60,25 @@ protected function execute(InputInterface $input, OutputInterface $output): int $dumper = new Dumper(); foreach ($this->documentManagers as $name => $documentManager) { - $generator = new EncryptedFieldsMapGenerator($documentManager->getMetadataFactory()); - $encryptedFieldsMap = $generator->getEncryptedFieldsMap(); + $encryptedFieldsMap = []; + foreach ($documentManager->getMetadataFactory()->getAllMetadata() as $metadata) { + $database = $documentManager->getDocumentDatabase($metadata->getName()); + $collectionInfoIterator = $database->listCollections(['filter' => ['name' => $metadata->getCollection()]]); + + foreach ($collectionInfoIterator as $collectionInfo) { + if ($collectionInfo['options']['encryptedFields'] ?? null) { + $encryptedFieldsMap[$this->getDocumentNamespace($metadata, $database->getDatabaseName())] = $collectionInfo['options']['encryptedFields']; + } + } + } if (empty($encryptedFieldsMap)) { continue; } - $encryptedFieldsMap = array_combine( - // Convert class names in keys to their full namespaces - array_map( - fn (string $fqcn): string => $this->getDocumentNamespace( - $documentManager->getClassMetadata($fqcn), - $documentManager->getConfiguration()->getDefaultDB(), - ), - array_keys($encryptedFieldsMap), - ), - array_values($encryptedFieldsMap), - ); + foreach ($encryptedFieldsMap as $ns => $encryptedFields) { + $encryptedFieldsMap[$ns] = json_decode(PackedArray::fromPHP($encryptedFields['fields'])->toRelaxedExtendedJSON(), true); + } $io->section(sprintf('Dumping encrypted fields map for document manager "%s"', $name)); switch ($format) { diff --git a/src/DependencyInjection/Configuration.php b/src/DependencyInjection/Configuration.php index 17e483a3..b4b6a2b7 100644 --- a/src/DependencyInjection/Configuration.php +++ b/src/DependencyInjection/Configuration.php @@ -11,7 +11,6 @@ use Symfony\Component\Config\Definition\Builder\TreeBuilder; use Symfony\Component\Config\Definition\ConfigurationInterface; -use function array_is_list; use function count; use function in_array; use function is_array; @@ -20,6 +19,8 @@ use function method_exists; use function preg_match; +use const JSON_THROW_ON_ERROR; + /** * FrameworkExtension configuration structure. */ @@ -401,20 +402,8 @@ private function addConnectionsSection(ArrayNodeDefinition $rootNode): void ->useAttributeAsKey('name', false) ->beforeNormalization() ->always(static function ($v) { - if (isset($v['encryptedFields']) && is_array($v['encryptedFields'])) { - $encryptedFields = $v['encryptedFields']; - if (! array_is_list($encryptedFields)) { - $encryptedFields = [$encryptedFields]; - } - - $v = []; - foreach ($encryptedFields as $field) { - if (is_array($field['field'] ?? null) && ! array_is_list($field['field'])) { - $field['field'] = [$field['field']]; - } - - $v[$field['name'] ?? ''] = $field['field'] ?? []; - } + if (is_string($v)) { + return json_decode($v, true, 512, JSON_THROW_ON_ERROR); } return $v; @@ -424,13 +413,16 @@ private function addConnectionsSection(ArrayNodeDefinition $rootNode): void ->children() ->scalarNode('path')->isRequired()->cannotBeEmpty()->end() ->scalarNode('bsonType')->isRequired()->cannotBeEmpty()->end() + ->variableNode('keyId')->isRequired()->cannotBeEmpty()->end() ->arrayNode('queries') ->children() ->scalarNode('queryType')->isRequired()->cannotBeEmpty()->end() - ->integerNode('min')->end() - ->integerNode('max')->end() + ->variableNode('min')->end() + ->variableNode('max')->end() ->integerNode('sparsity')->end() + ->integerNode('precision')->end() ->integerNode('trimFactor')->end() + ->integerNode('contention')->end() ->end() ->end() ->end() diff --git a/src/DependencyInjection/DoctrineMongoDBExtension.php b/src/DependencyInjection/DoctrineMongoDBExtension.php index 595e5147..66b6ca75 100644 --- a/src/DependencyInjection/DoctrineMongoDBExtension.php +++ b/src/DependencyInjection/DoctrineMongoDBExtension.php @@ -27,6 +27,7 @@ use Doctrine\Persistence\Mapping\Driver\MappingDriverChain; use Doctrine\Persistence\Proxy; use InvalidArgumentException; +use MongoDB\BSON\Document as BsonDocument; use MongoDB\Client; use ProxyManager\Proxy\LazyLoadingInterface; use Symfony\Bridge\Doctrine\DependencyInjection\AbstractDoctrineExtension; @@ -55,6 +56,7 @@ use function in_array; use function interface_exists; use function is_dir; +use function json_encode; use function method_exists; use function sprintf; @@ -538,6 +540,12 @@ private function normalizeAutoEncryption(array $autoEncryption, string $defaultD $autoEncryption['keyVaultNamespace'] ??= $defaultDB . '.datakeys'; + if (isset($autoEncryption['encryptedFieldsMap'])) { + foreach ($autoEncryption['encryptedFieldsMap'] as &$value) { + $value = (new Definition(BsonDocument::class))->setFactory([BsonDocument::class, 'fromJSON'])->setArguments([json_encode(['fields' => $value])]); + } + } + return $autoEncryption; } diff --git a/tests/DependencyInjection/ConfigurationTest.php b/tests/DependencyInjection/ConfigurationTest.php index 47613d78..dae2c3a0 100644 --- a/tests/DependencyInjection/ConfigurationTest.php +++ b/tests/DependencyInjection/ConfigurationTest.php @@ -147,27 +147,108 @@ public function testFullConfiguration(array $config): void 'bypassAutoEncryption' => true, 'bypassQueryAnalysis' => true, 'encryptedFieldsMap' => [ + 'encrypted.RangeTypes' => [ + [ + 'keyId' => ['$binary' => ['base64' => 'lhZHItpvRkqXevh4Wtqg/g==', 'subType' => '04']], + 'path' => 'intField', + 'bsonType' => 'int', + 'queries' => ['queryType' => 'range', 'contention' => 8, 'min' => 5, 'max' => 10], + ], + [ + 'keyId' => ['$binary' => ['base64' => 'qd9PEKIPTE2J30ev29lMpQ==', 'subType' => '04']], + 'path' => 'floatField', + 'bsonType' => 'double', + 'queries' => ['queryType' => 'range', 'contention' => 8, 'min' => 5.5, 'max' => 10.5, 'precision' => 1], + ], + [ + 'keyId' => ['$binary' => ['base64' => 'zVLg8CF4RSSu4xn7x7dOyQ==', 'subType' => '04']], + 'path' => 'decimalField', + 'bsonType' => 'decimal', + 'queries' => [ + 'queryType' => 'range', + 'contention' => 8, + 'min' => ['$numberDecimal' => '0.1'], + 'max' => ['$numberDecimal' => '0.2'], + 'precision' => 2, + ], + ], + [ + 'keyId' => ['$binary' => ['base64' => 'ySdd8lZ2QBqnwKPJTp/yLA==', 'subType' => '04']], + 'path' => 'immutableDateField', + 'bsonType' => 'date', + 'queries' => [ + 'queryType' => 'range', + 'contention' => 8, + 'min' => ['$date' => '2000-01-01T00:00:00Z'], + 'max' => ['$date' => '2100-01-01T00:00:00Z'], + ], + ], + [ + 'keyId' => ['$binary' => ['base64' => 'NWKI+DyES/OlNkUbJbWJ9w==', 'subType' => '04']], + 'path' => 'dateField', + 'bsonType' => 'date', + ], + [ + 'keyId' => ['$binary' => ['base64' => 'wiiv+0K/QAquyEq3HDxRKw==', 'subType' => '04']], + 'path' => 'binField', + 'bsonType' => 'binData', + ], + [ + 'keyId' => ['$binary' => ['base64' => '2CSosXLSTEKaYphcSnUuCw==', 'subType' => '04']], + 'path' => 'timestampField', + 'bsonType' => 'timestamp', + ], + [ + 'keyId' => ['$binary' => ['base64' => 'h3H6HdG3T5CK+Z2yQ4Ho+Q==', 'subType' => '04']], + 'path' => 'hashField', + 'bsonType' => 'object', + ], + [ + 'keyId' => ['$binary' => ['base64' => 'X78UZZ/HTX2wLw4K3uG42w==', 'subType' => '04']], + 'path' => 'collectionField', + 'bsonType' => 'objectId', + ], + [ + 'keyId' => ['$binary' => ['base64' => 'LugQL/ZXTJOl856Yacmkwg==', 'subType' => '04']], + 'path' => 'boolField', + 'bsonType' => 'bool', + ], + ], 'encrypted.patients' => [ [ - 'path' => 'patientRecord.ssn', - 'bsonType' => 'string', - 'queries' => ['queryType' => 'equality'], + 'keyId' => ['$binary' => ['base64' => 'GH25/XvYSaCgTUQLAo1hQw==', 'subType' => '04']], + 'path' => 'pathologies', + 'bsonType' => 'array', ], [ + 'keyId' => ['$binary' => ['base64' => 'krVWyFlNTUOaGFMfk+s7UA==', 'subType' => '04']], 'path' => 'patientRecord.billing', 'bsonType' => 'object', ], [ + 'keyId' => ['$binary' => ['base64' => 'X1ZaSI1GSAKnZ+sPGcmYBA==', 'subType' => '04']], 'path' => 'patientRecord.billingAmount', 'bsonType' => 'int', - 'queries' => ['queryType' => 'range', 'min' => 100, 'max' => 2000, 'sparsity' => 1, 'trimFactor' => 4], + 'queries' => [ + 'queryType' => 'range', + 'contention' => 8, + 'min' => 100, + 'max' => 2000, + 'sparsity' => 1, + 'trimFactor' => 4, + ], ], ], - 'encrypted.users' => [ + 'encrypted.client' => [ [ - 'path' => 'email', + 'keyId' => ['$binary' => ['base64' => 'I0Aw18vnRGWzVS1t3uejpQ==', 'subType' => '04']], + 'path' => 'name', 'bsonType' => 'string', - 'queries' => ['queryType' => 'equality'], + ], + [ + 'keyId' => ['$binary' => ['base64' => 'XSPRK3vaTLmMZr9IEj/qwQ==', 'subType' => '04']], + 'path' => 'clientCards', + 'bsonType' => 'array', ], ], ], @@ -491,7 +572,7 @@ public static function provideNormalizeOptions(): Generator ], ]; - // Encrypted Field Map normalization from XML tags + // Encrypted Field Map can be a JSON string in a yield [ [ 'connection' => [ @@ -500,38 +581,40 @@ public static function provideNormalizeOptions(): Generator 'id' => 'foo', 'autoEncryption' => [ 'kmsProvider' => ['type' => 'local', 'key' => '1234567890123456789012345678901234567890123456789012345678901234'], - 'encryptedFieldsMap' => [ - 'encryptedFields' => [ - [ - 'name' => 'encrypted.patients', - 'field' => [ - [ - 'path' => 'patientRecord.ssn', - 'bsonType' => 'string', - 'queries' => ['queryType' => 'equality'], - ], - [ - 'path' => 'patientRecord.billing', - 'bsonType' => 'object', - ], - [ - 'path' => 'patientRecord.billingAmount', - 'bsonType' => 'int', - 'queries' => ['queryType' => 'range', 'min' => 100, 'max' => 2000, 'sparsity' => 1, 'trimFactor' => 4], - ], - ], - ], - [ - 'name' => 'encrypted.users', - 'field' => - [ - 'path' => 'email', - 'bsonType' => 'string', - 'queries' => ['queryType' => 'equality'], - ], - ], + 'encryptedFieldsMap' => <<<'JSON' + { + "encrypted.patients": [ + { + "keyId": { "$binary": { "base64": "GH25/XvYSaCgTUQLAo1hQw==", "subType": "04" } }, + "path": "pathologies", + "bsonType": "array" + }, + { + "keyId": { "$binary": { "base64": "krVWyFlNTUOaGFMfk+s7UA==", "subType": "04" } }, + "path": "patientRecord.billing", + "bsonType": "object" + }, + { + "keyId": { "$binary": { "base64": "X1ZaSI1GSAKnZ+sPGcmYBA==", "subType": "04" } }, + "path": "patientRecord.billingAmount", + "bsonType": "int", + "queries": { "queryType": "range", "contention": 8, "min": 100, "max": 2000, "sparsity": 1, "trimFactor": 4 } + } ], - ], + "encrypted.client": [ + { + "keyId": { "$binary": { "base64": "I0Aw18vnRGWzVS1t3uejpQ==", "subType": "04" } }, + "path": "name", + "bsonType": "string" + }, + { + "keyId": { "$binary": { "base64": "XSPRK3vaTLmMZr9IEj/qwQ==", "subType": "04" } }, + "path": "clientCards", + "bsonType": "array" + } + ] + } + JSON, ], ], ], @@ -545,25 +628,39 @@ public static function provideNormalizeOptions(): Generator 'encryptedFieldsMap' => [ 'encrypted.patients' => [ [ - 'path' => 'patientRecord.ssn', - 'bsonType' => 'string', - 'queries' => ['queryType' => 'equality'], + 'keyId' => ['$binary' => ['base64' => 'GH25/XvYSaCgTUQLAo1hQw==', 'subType' => '04']], + 'path' => 'pathologies', + 'bsonType' => 'array', ], [ + 'keyId' => ['$binary' => ['base64' => 'krVWyFlNTUOaGFMfk+s7UA==', 'subType' => '04']], 'path' => 'patientRecord.billing', 'bsonType' => 'object', ], [ + 'keyId' => ['$binary' => ['base64' => 'X1ZaSI1GSAKnZ+sPGcmYBA==', 'subType' => '04']], 'path' => 'patientRecord.billingAmount', 'bsonType' => 'int', - 'queries' => ['queryType' => 'range', 'min' => 100, 'max' => 2000, 'sparsity' => 1, 'trimFactor' => 4], + 'queries' => [ + 'queryType' => 'range', + 'contention' => 8, + 'min' => 100, + 'max' => 2000, + 'sparsity' => 1, + 'trimFactor' => 4, + ], ], ], - 'encrypted.users' => [ + 'encrypted.client' => [ [ - 'path' => 'email', + 'keyId' => ['$binary' => ['base64' => 'I0Aw18vnRGWzVS1t3uejpQ==', 'subType' => '04']], + 'path' => 'name', 'bsonType' => 'string', - 'queries' => ['queryType' => 'equality'], + ], + [ + 'keyId' => ['$binary' => ['base64' => 'XSPRK3vaTLmMZr9IEj/qwQ==', 'subType' => '04']], + 'path' => 'clientCards', + 'bsonType' => 'array', ], ], ], diff --git a/tests/DependencyInjection/Fixtures/config/xml/full.xml b/tests/DependencyInjection/Fixtures/config/xml/full.xml index 65cdbdb6..c4a19374 100644 --- a/tests/DependencyInjection/Fixtures/config/xml/full.xml +++ b/tests/DependencyInjection/Fixtures/config/xml/full.xml @@ -7,8 +7,8 @@ http://symfony.com/schema/dic/doctrine/odm/mongodb http://symfony.com/schema/dic/doctrine/odm/mongodb/mongodb-1.0.xsd"> - - - - - - - - - - - - - - + setHydratorNamespace('SymfonyTests\Doctrine'); $config->setMetadataDriverImpl(new AttributeDriver($paths)); $config->setMetadataCache(new ArrayAdapter()); + $uri = getenv('DOCTRINE_MONGODB_SERVER') ?: 'mongodb://localhost:27017'; - return DocumentManager::create(null, $config); + return DocumentManager::create(new Client($uri), $config); } }