|
| 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/ |
0 commit comments