Skip to content

Commit 7c0fbe7

Browse files
GromNaNalcaeusjmikola
authored
Add Automatic Queryable Encryption (#2779)
Add Automatic Queryable Encryption to the MongoDB Documents. This feature requires a MongoDB Enterprise license or a MongoDB Atlas cluster. https://jira.mongodb.org/browse/PHPORM-13 --------- Co-authored-by: Andreas Braun <[email protected]> Co-authored-by: Jeremy Mikola <[email protected]>
1 parent 4f3dbd0 commit 7c0fbe7

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

42 files changed

+2090
-114
lines changed

.github/workflows/continuous-integration.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -163,3 +163,4 @@ jobs:
163163
env:
164164
DOCTRINE_MONGODB_SERVER: ${{ steps.setup-mongodb.outputs.cluster-uri }}
165165
USE_LAZY_GHOST_OBJECTS: ${{ matrix.proxy == 'lazy-ghost' && '1' || '0' }}"
166+
CRYPT_SHARED_LIB_PATH: ${{ steps.setup-mongodb.outputs.crypt-shared-lib-path }}

composer.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@
3030
"doctrine/persistence": "^3.2 || ^4",
3131
"friendsofphp/proxy-manager-lts": "^1.0",
3232
"jean85/pretty-package-versions": "^1.3.0 || ^2.0.1",
33-
"mongodb/mongodb": "^1.21 || ^2.0@dev",
33+
"mongodb/mongodb": "^1.21.2 || ^2.1.1",
3434
"psr/cache": "^1.0 || ^2.0 || ^3.0",
3535
"symfony/console": "^5.4 || ^6.0 || ^7.0",
3636
"symfony/deprecation-contracts": "^2.2 || ^3.0",
Lines changed: 271 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,271 @@
1+
Queryable Encryption
2+
====================
3+
4+
This cookbook provides a tutorial on setting up and using Queryable Encryption
5+
(QE) with Doctrine MongoDB ODM to protect sensitive data in your documents.
6+
7+
Introduction
8+
------------
9+
10+
In many applications, you need to store sensitive information like social
11+
security numbers, financial data, or personal details. MongoDB's Queryable
12+
Encryption allows you to encrypt this data on the client-side, store it as
13+
fully randomized encrypted data, and still run expressive queries on it. This
14+
ensures that sensitive data is never exposed in an unencrypted state on the
15+
server, in system logs, or in backups.
16+
17+
This tutorial will guide you through the process of securing a document's
18+
fields using queryable encryption, from defining the document and configuring
19+
the connection to storing and querying the encrypted data.
20+
21+
.. note::
22+
23+
Queryable Encryption is only available on MongoDB Enterprise 7.0+ or
24+
MongoDB Atlas.
25+
26+
The Scenario
27+
------------
28+
29+
We will model a ``Patient`` document that has an embedded ``PatientRecord``.
30+
This record contains sensitive information:
31+
32+
- A Social Security Number (``ssn``), which we need to query for exact
33+
matches.
34+
- A ``billingAmount``, which should support range queries.
35+
- A ``billing`` object, which should be encrypted but not directly queryable.
36+
37+
Defining the Documents
38+
----------------------
39+
40+
First, let's define our ``Patient``, ``PatientRecord``, and ``Billing``
41+
classes. We use the :ref:`#[Encrypt] <encrypt_attribute>` attribute to mark
42+
fields that require encryption.
43+
44+
.. code-block:: php
45+
46+
<?php
47+
48+
namespace Documents;
49+
50+
use Doctrine\ODM\MongoDB\Mapping\Annotations as ODM;
51+
use Doctrine\ODM\MongoDB\Mapping\Annotations\Encrypt;
52+
use Doctrine\ODM\MongoDB\Mapping\Annotations\EncryptQuery;
53+
54+
#[ODM\Document]
55+
class Patient
56+
{
57+
#[ODM\Id]
58+
public string $id;
59+
60+
#[ODM\EmbedOne(targetDocument: PatientRecord::class)]
61+
public PatientRecord $patientRecord;
62+
}
63+
64+
#[ODM\EmbeddedDocument]
65+
class PatientRecord
66+
{
67+
/**
68+
* Encrypted with equality queries.
69+
* This allows us to find a patient by their exact SSN.
70+
*/
71+
#[ODM\Field(type: 'string')]
72+
#[Encrypt(queryType: EncryptQuery::Equality)]
73+
public string $ssn;
74+
75+
/**
76+
* The entire embedded document is encrypted as an object.
77+
* By not specifying a queryType, we make it non-queryable.
78+
*/
79+
#[ODM\EmbedOne(targetDocument: Billing::class)]
80+
#[Encrypt]
81+
public Billing $billing;
82+
83+
/**
84+
* Encrypted with range queries.
85+
* This allows us to query for billing amounts within a certain range.
86+
*/
87+
#[ODM\Field(type: 'int')]
88+
#[Encrypt(queryType: EncryptQuery::Range, min: 0, max: 5000, sparsity: 1)]
89+
public int $billingAmount;
90+
}
91+
92+
#[ODM\EmbeddedDocument]
93+
class Billing
94+
{
95+
#[ODM\Field(type: 'string')]
96+
public string $creditCardNumber;
97+
}
98+
99+
Configuration and Usage
100+
-----------------------
101+
102+
The following example demonstrates how to configure the ``DocumentManager`` for
103+
encryption and how to work with encrypted documents.
104+
105+
Step 1: Configure the DocumentManager
106+
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
107+
108+
First, we configure the ``DocumentManager`` with ``autoEncryption`` options.
109+
For more details on the available options, see the `MongoDB\Driver\Manager`_
110+
documentation. We'll use the ``local`` KMS provider for simplicity. For this
111+
provider, you need a 96-byte master key.
112+
The following code will look for the key in a local file (``master-key.bin``)
113+
and generate it if it doesn't exist. In a production environment, you should
114+
use a non-local key management service (KMS).
115+
For each field marked with ``#[Encrypt]``, the MongoDB driver will generate
116+
a Data Encryption Key (DEK), encrypt it with the master key, and store it in
117+
the key vault collection. In Doctrine ODM, the key vault collection is set
118+
to ``<database>.datakeys`` by default, but you can change it using the
119+
``keyVaultNamespace`` option.
120+
121+
.. code-block:: php
122+
123+
<?php
124+
125+
use Doctrine\ODM\MongoDB\Configuration;
126+
use Doctrine\ODM\MongoDB\Mapping\Driver\AttributeDriver;
127+
use MongoDB\BSON\Binary;
128+
129+
// For the local KMS provider, we need a 96-byte master key.
130+
// We'll store it in a local file. If the file doesn't exist, we generate
131+
// one. In a production environment, ensure this key file is properly
132+
// secured.
133+
$keyFile = __DIR__ . '/master-key.bin';
134+
if (!file_exists($keyFile)) {
135+
file_put_contents($keyFile, random_bytes(96));
136+
}
137+
$masterKey = new Binary(file_get_contents($keyFile), Binary::TYPE_GENERIC);
138+
139+
$config = new Configuration();
140+
// Enable auto encryption and set the KMS provider.
141+
$config->setAutoEncryption([
142+
'keyVaultNamespace' => 'encryption.datakeys'
143+
]);
144+
$config->setKmsProvider([
145+
'type' => 'local',
146+
'key' => new Binary($masterKey),
147+
]);
148+
149+
// Other configuration
150+
$config->setProxyDir(__DIR__ . '/Proxies');
151+
$config->setProxyNamespace('Proxies');
152+
$config->setHydratorDir(__DIR__ . '/Hydrators');
153+
$config->setHydratorNamespace('Hydrators');
154+
$config->setPersistentCollectionDir(__DIR__ . '/PersistentCollections');
155+
$config->setPersistentCollectionNamespace('PersistentCollections');
156+
$config->setDefaultDB('my_db');
157+
$config->setMetadataDriverImpl(new AttributeDriver([__DIR__]));
158+
159+
Step 2: Create the DocumentManager
160+
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
161+
162+
The ``MongoDB\Client`` will be instantiated with the options from the
163+
configuration.
164+
165+
.. code-block:: php
166+
167+
<?php
168+
169+
use Doctrine\ODM\MongoDB\DocumentManager;
170+
use MongoDB\Client;
171+
172+
$client = new Client(
173+
uri: 'mongodb://localhost:27017/',
174+
uriOptions: [],
175+
driverOptions: $config->getDriverOptions(),
176+
);
177+
$documentManager = DocumentManager::create($client, $config);
178+
179+
The ``driverOptions`` passed to the client contain the ``autoEncryption`` option
180+
that was configured in the previous step.
181+
182+
Step 3: Create the Encrypted Collection
183+
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
184+
185+
Next, we use the ``SchemaManager`` to create the collection with the necessary
186+
encryption metadata. To make the example re-runnable, we can drop the collection
187+
first.
188+
189+
.. code-block:: php
190+
191+
<?php
192+
193+
$schemaManager = $documentManager->getSchemaManager();
194+
$schemaManager->dropDocumentCollection(Patient::class);
195+
$schemaManager->createDocumentCollection(Patient::class);
196+
197+
Step 4: Persist and Query Documents
198+
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
199+
200+
Finally, we can persist and query documents as usual. The encryption and
201+
decryption will be handled automatically.
202+
203+
.. code-block:: php
204+
205+
<?php
206+
207+
$patient = new Patient();
208+
$patient->patientRecord = new PatientRecord();
209+
$patient->patientRecord->ssn = '123-456-7890';
210+
$patient->patientRecord->billingAmount = 1500;
211+
$patient->patientRecord->billing = new Billing();
212+
$patient->patientRecord->billing->creditCardNumber = '9876-5432-1098-7654';
213+
214+
$documentManager->persist($patient);
215+
$documentManager->flush();
216+
$documentManager->clear();
217+
218+
// Query the document using an encrypted field
219+
$foundPatient = $documentManager->getRepository(Patient::class)->findOneBy([
220+
'patientRecord.ssn' => '123-456-7890',
221+
]);
222+
223+
// The document is retrieved and its fields are automatically decrypted
224+
assert($foundPatient instanceof Patient);
225+
assert($foundPatient->patientRecord->billingAmount === 1500);
226+
227+
What the Document Looks Like in the Database
228+
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
229+
230+
When you inspect the document directly in the database (e.g., using ``mongosh``
231+
or `MongoDB Compass`_), you will see that the fields marked with ``#[Encrypt]``
232+
are stored as BSON binary data (subtype 6), not the original BSON type. The
233+
driver also adds a ``__safeContent__`` field to the document. For more details,
234+
see the `Queryable Encryption Fundamentals`_ in the MongoDB manual.
235+
236+
.. code-block:: js
237+
238+
{
239+
"_id": ObjectId("..."),
240+
"patientRecord": {
241+
"ssn": Binary("...", 6),
242+
"billing": Binary("...", 6),
243+
"billingAmount": Binary("...", 6)
244+
},
245+
"__safeContent__": [
246+
Binary("...", 0)
247+
]
248+
}
249+
250+
Limitations
251+
-----------
252+
253+
- The ODM simplifies configuration by supporting a single KMS provider per
254+
``DocumentManager`` through ``Configuration::setKmsProvider()``. If you need
255+
to work with multiple KMS providers, you must manually configure the
256+
``kmsProviders`` array and pass it as a driver option, bypassing the ODM's
257+
helper method.
258+
- Automatic generation of the ``encryptedFieldsMap`` is not compatible with
259+
``SINGLE_COLLECTION`` inheritance. Because all classes in the hierarchy
260+
share a single collection, the ``SchemaManager`` cannot merge their encrypted
261+
fields before creating the collection.
262+
- Embedded documents and collections are encrypted as a whole. As such,
263+
they cannot be partially updated. Only the ``set*`` and ``atomicSet*``
264+
collection strategies can be used.
265+
- For a complete list of limitations, please refer to the official
266+
`Queryable Encryption Limitations`_ documentation.
267+
268+
.. _MongoDB\Driver\Manager: https://www.php.net/manual/en/mongodb-driver-manager.construct.php#mongodb-driver-manager.construct-autoencryption
269+
.. _MongoDB Compass: https://www.mongodb.com/products/compass
270+
.. _Queryable Encryption Fundamentals: https://www.mongodb.com/docs/manual/core/queryable-encryption/fundamentals/#behavior
271+
.. _Queryable Encryption Limitations: https://www.mongodb.com/docs/manual/core/queryable-encryption/reference/limitations/

docs/en/reference/attributes-reference.rst

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -336,6 +336,94 @@ 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_attribute:
340+
341+
#[Encrypt]
342+
----------
343+
344+
The ``#[Encrypt]`` attribute is used to define an encrypted field mapping for a
345+
document property. It allows you to configure fields for automatic and queryable
346+
encryption in MongoDB.
347+
348+
Optional arguments:
349+
350+
- ``queryType`` - Specifies the query type for the field. Possible values:
351+
- ``null`` (default) - Field is not queryable.
352+
- ``EncryptQuery::Equality`` - Enables equality queries.
353+
- ``EncryptQuery::Range`` - Enables range queries.
354+
- ``min``, ``max`` - Specify minimum and maximum (inclusive) queryable values
355+
for a field when possible, as smaller bounds improve query efficiency. If
356+
querying values outside of these bounds, MongoDB returns an error.
357+
- ``sparsity``, ``precision``, ``trimFactor``, ``contention`` - For advanced
358+
users only. The default values for these options are suitable for the majority
359+
of use cases, and should only be modified if your use case requires it.
360+
361+
.. note::
362+
363+
Queryable encryption is only supported in MongoDB version 7.0 and later.
364+
365+
Example:
366+
367+
.. code-block:: php
368+
369+
<?php
370+
371+
use Doctrine\ODM\MongoDB\Mapping\Annotations\Encrypt;
372+
use Doctrine\ODM\MongoDB\Mapping\Annotations\EncryptQuery;
373+
374+
#[Document]
375+
class Client
376+
{
377+
#[Field]
378+
#[Encrypt(queryType: EncryptQuery::Equality)]
379+
public string $name;
380+
}
381+
382+
The ``#[Encrypt]`` attribute can be added to a class with `#[EmbeddedDocument]`_.
383+
This will encrypt the entire embedded document, in the field that contains it.
384+
Fields within an encrypted embedded document cannot be individually encrypted.
385+
Queryable encryption is not supported for embedded documents, so the ``queryType``
386+
argument is not applicable. Encrypted embedded documents are stored as a binary
387+
value in the parent document.
388+
389+
.. code-block:: php
390+
391+
<?php
392+
393+
use Doctrine\ODM\MongoDB\Mapping\Annotations\Encrypt;
394+
395+
#[Encrypt]
396+
#[EmbeddedDocument]
397+
class CreditCard
398+
{
399+
#[Field]
400+
public string $number;
401+
402+
#[Field]
403+
public string $expiryDate;
404+
}
405+
406+
#[Document]
407+
class User
408+
{
409+
#[EmbedOne(targetDocument: CreditCard::class)]
410+
public CreditCard $creditCard;
411+
}
412+
413+
For more details, refer to the MongoDB documentation on
414+
`Queryable Encryption <https://www.mongodb.com/docs/manual/core/queryable-encryption/fundamentals/encrypt-and-query/>`_.
415+
416+
417+
.. note::
418+
419+
The encrypted collection must be created with the `Schema Manager`_ before
420+
before inserting documents.
421+
422+
.. note::
423+
424+
Due to the way the encrypted fields map is generated, the queryable encryption
425+
is not compatible with ``SINGLE_COLLECTION`` inheritance.
426+
339427
#[Field]
340428
--------
341429

@@ -1399,5 +1487,6 @@ root class specified in the view mapping.
13991487
.. _DBRef: https://docs.mongodb.com/manual/reference/database-references/#dbrefs
14001488
.. _geoNear command: https://docs.mongodb.com/manual/reference/command/geoNear/
14011489
.. _MongoDB\BSON\ObjectId: https://www.php.net/class.mongodb-bson-objectid
1490+
.. _Schema Manager: ../reference/migrating-schemas
14021491
.. |FQCN| raw:: html
14031492
<abbr title="Fully-Qualified Class Name">FQCN</abbr>

docs/en/reference/basic-mapping.rst

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -145,6 +145,7 @@ Here is a quick overview of the built-in mapping types:
145145
- ``hash``
146146
- ``id``
147147
- ``int``
148+
- ``int64``
148149
- ``key``
149150
- ``object_id``
150151
- ``raw``

0 commit comments

Comments
 (0)