Skip to content

PHPLIB-913: Database::createEncryptedCollection() helper #1050

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 21 commits into from
Apr 12, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
6d76fba
PHPLIB-913: Database::createEncryptedCollection() helper
jmikola Mar 18, 2023
14d6a26
Docs for Database::createEncryptedCollection()
jmikola Mar 21, 2023
89d03f4
Revise docs and require $options since encryptedFields is required
jmikola Mar 21, 2023
e78f478
Revise docs and exception output
jmikola Mar 21, 2023
c02d343
Add example for creating new Client with auto encryption
jmikola Mar 22, 2023
390299f
Fix exception handling and assertions in invalid keyId test
jmikola Mar 22, 2023
5107633
Permit coding standard deviations in CSFLE prose tests
jmikola Mar 22, 2023
0ba8a28
Fix psalm errors in createEncryptedCollection()
jmikola Mar 22, 2023
d244f07
Use more specific type hint in data provider
jmikola Mar 22, 2023
27dca00
Remove typed property, which requires PHP 7.4+
jmikola Mar 31, 2023
8d4eeb1
Extract Database methods to CreateEncryptedCollection operation
jmikola Mar 31, 2023
521855d
Fix references in function doc blocks
jmikola Mar 31, 2023
e5d736e
Update docs for createEncryptedCollection()
jmikola Mar 31, 2023
801784d
Functional tests for CreateEncryptedCollection::createDataKeys()
jmikola Mar 31, 2023
f334816
Require replica sets for CreateEncryptedCollectionFunctionalTest
jmikola Mar 31, 2023
8dd84f4
Re-order CreateEncryptedCollection methods
jmikola Apr 1, 2023
7c4c13c
Fix class reference in CreateEncryptedCollectionExceptionTest
jmikola Apr 1, 2023
28b86e1
Add reference links to CreateCollection operation docs
jmikola Apr 3, 2023
645fdee
Test that createDataKey() doesn't modify encryptedFields object option
jmikola Apr 3, 2023
8f088e2
Apply suggestions from code review
jmikola Apr 11, 2023
7c7b0a9
Merge branch 'master' into phplib-913
jmikola Apr 11, 2023
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
source:
file: apiargs-common-param.yaml
ref: $collectionName
replacement:
subject: "encrypted collection"
action: " to create"
---
arg_name: param
name: $clientEncryption
type: :php:`MongoDB\\Driver\\ClientEncryption <mongodb-driver-clientencryption>`
description: |
The ClientEncryption object used to create data keys.
interface: phpmethod
operation: ~
optional: false
---
arg_name: param
name: $kmsProvider
type: string
description: |
KMS provider (e.g. "local", "aws") that will be used to encrypt new data keys.
This corresponds to the ``$kmsProvider`` parameter for
:php:`MongoDB\\Driver\\ClientEncryption::createDataKey() <mongodb-driver-clientencryption.createdatakey>`.
interface: phpmethod
operation: ~
optional: false
---
arg_name: param
name: $masterKey
type: array|null
description: |
KMS-specific key options that will be used to encrypt new data keys. This
corresponds to the ``masterKey`` option for
:php:`MongoDB\\Driver\\ClientEncryption::createDataKey() <mongodb-driver-clientencryption.createdatakey>`.

If ``$kmsProvider`` is "local", this should be ``null``.
interface: phpmethod
operation: ~
optional: false
---
source:
file: apiargs-common-param.yaml
ref: $options
optional: false
post: |
The ``encryptedFields`` option is required.
...
1 change: 1 addition & 0 deletions docs/reference/class/MongoDBDatabase.txt
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ Methods
/reference/method/MongoDBDatabase-aggregate
/reference/method/MongoDBDatabase-command
/reference/method/MongoDBDatabase-createCollection
/reference/method/MongoDBDatabase-createEncryptedCollection
/reference/method/MongoDBDatabase-drop
/reference/method/MongoDBDatabase-dropCollection
/reference/method/MongoDBDatabase-getDatabaseName
Expand Down
15 changes: 15 additions & 0 deletions docs/reference/exception-classes.txt
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,21 @@ MongoDB\\Exception\\BadMethodCallException

----

MongoDB\\Exception\\CreateEncryptedCollectionException
------------------------------------------------------

.. phpclass:: MongoDB\\Exception\\CreateEncryptedCollectionException

Thrown by :phpmethod:`MongoDB\\Database::createEncryptedCollection()` if any
error is encountered while creating data keys or creating the collection. The
original exception and modified ``encryptedFields`` option can be accessed
via the ``getPrevious()`` and ``getEncryptedFields()`` methods, respectively.

This class extends the library's :phpclass:`RuntimeException
<MongoDB\\Exception\\RuntimeException>` class.

----

MongoDB\\Exception\\InvalidArgumentException
--------------------------------------------

Expand Down
144 changes: 144 additions & 0 deletions docs/reference/method/MongoDBDatabase-createEncryptedCollection.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
==============================================
MongoDB\\Database::createEncryptedCollection()
==============================================

.. versionadded:: 1.16

.. default-domain:: mongodb

.. contents:: On this page
:local:
:backlinks: none
:depth: 1
:class: singlecol

.. note::

Queryable Encryption is in public preview and available for evaluation
purposes. It is not yet recommended for production deployments as breaking
changes may be introduced. See the
`Queryable Encryption Preview <https://www.mongodb.com/blog/post/mongodb-releases-queryable-encryption-preview/>`_
blog post for more information.
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I realized I never added this note to the previous Queryable Encryption APIs we introduced in 1.13. I opened PHPLIB-1095 and #1053 to address that.


Definition
----------

.. phpmethod:: MongoDB\\Database::createEncryptedCollection()

Explicitly creates an encrypted collection.

.. code-block:: php

function createEncryptedCollection(string $collectionName, MongoDB\Driver\ClientEncryption $clientEncryption, string $kmsProvider, ?array $masterKey, array $options): array

This method will automatically create data keys for any encrypted fields
where ``keyId`` is ``null``. Data keys will be created using
:php:`MongoDB\\Driver\\ClientEncryption::createDataKey() <mongodb-driver-clientencryption.createdatakey>`
and the provided ``$kmsProvider`` and ``$masterKey`` parameters. A copy of
the modified ``encryptedFields`` option will be returned in addition to the
result from creating the collection.

This method does not affect any auto encryption settings on existing
:phpclass:`MongoDB\\Client` objects. Users must configure auto encryption
after creating the encrypted collection with ``createEncryptedCollection()``.

This method has the following parameters:

.. include:: /includes/apiargs/MongoDBDatabase-method-createEncryptedCollection-param.rst

The ``$options`` parameter supports the same options as
:phpmethod:`MongoDB\\Database::createCollection()`. The ``encryptedFields``
option is required.

Return Values
-------------

A tuple (i.e. two-element array) containing the result document from the
:manual:`create </reference/command/create>` command (an array or object
according to the ``typeMap`` option) and the modified ``encryptedFields``
option.

Errors/Exceptions
-----------------

:phpclass:`MongoDB\\Exception\\CreateEncryptedCollectionException` if any error
is encountered creating data keys or the collection. The original exception and
modified ``encryptedFields`` option can be accessed via the ``getPrevious()``
and ``getEncryptedFields()`` methods, respectively.

.. include:: /includes/extracts/error-invalidargumentexception.rst

Example
-------

The following example creates an encrypted ``users`` collection in the ``test``
database. The ``ssn`` field within the ``users`` collection will be defined as
an encrypted string field.

.. code-block:: php

<?php

// 96-byte master key used to encrypt/decrypt data keys
define('LOCAL_MASTERKEY', '...');

$client = new MongoDB\Client;

$clientEncryption = $client->createClientEncryption([
'keyVaultNamespace' => 'keyvault.datakeys',
'kmsProviders' => [
'local' => ['key' => new MongoDB\BSON\Binary(base64_decode(LOCAL_MASTERKEY), 0)],
],
);

[$result, $encryptedFields] = $client->test->createEncryptedCollection(
'users',
$clientEncryption,
'local',
null,
[
'encryptedFields' => [
'fields' => [
['path' => 'ssn', 'bsonType' => 'string', 'keyId' => null],
],
],
]
);

If the encrypted collection was successfully created, ``$result`` will contain
the response document from the ``create`` command and
``$encryptedFields['fields'][0]['keyId']`` will contain a
:php:`MongoDB\\BSON\\Binary <class.mongodb-bson-binary>` object with subtype 4
(i.e. UUID).

The modified ``encryptedFields`` option can then be used to construct a new
:phpclass:`MongoDB\\Client` with auto encryption enabled.

.. code-block:: php

<?php

$encryptedClient = new MongoDB\Client(
null, // Connection string
[], // Additional connection string options
[
'autoEncryption' => [
'keyVaultNamespace' => 'keyvault.datakeys',
'kmsProviders' => [
'local' => ['key' => new MongoDB\BSON\Binary(base64_decode(LOCAL_MASTERKEY), 0)],
],
'encryptedFieldsMap' => [
'test.users' => $encryptedFields,
],
],
]
);

See Also
--------

- :phpmethod:`MongoDB\\Database::createCollection()`
- :phpmethod:`MongoDB\\Client::createClientEncryption()`
- :php:`MongoDB\\Driver\\ClientEncryption::createDataKey() <mongodb-driver-clientencryption.createdatakey>`
- :manual:`create </reference/command/create>` command reference in the MongoDB
manual
4 changes: 4 additions & 0 deletions phpcs.xml.dist
Original file line number Diff line number Diff line change
Expand Up @@ -175,6 +175,7 @@
<exclude-pattern>/src/GridFS/StreamWrapper</exclude-pattern>
<exclude-pattern>/tests/DocumentationExamplesTest.php</exclude-pattern>
<exclude-pattern>/tests/GridFS/UnusableStream.php</exclude-pattern>
<exclude-pattern>/tests/SpecTests/ClientSideEncryption/Prose*</exclude-pattern>
</rule>
<rule ref="PSR1.Classes.ClassDeclaration.MultipleClasses">
<exclude-pattern>/examples</exclude-pattern>
Expand All @@ -183,4 +184,7 @@
<rule ref="Squiz.Classes.ClassFileName.NoMatch">
<exclude-pattern>/examples</exclude-pattern>
</rule>
<rule ref="Squiz.Classes.ValidClassName.NotCamelCaps">
<exclude-pattern>/tests/SpecTests/ClientSideEncryption/Prose*</exclude-pattern>
</rule>
</ruleset>
6 changes: 6 additions & 0 deletions psalm-baseline.xml
Original file line number Diff line number Diff line change
Expand Up @@ -491,6 +491,12 @@
<code>$options['writeConcern']</code>
</MixedAssignment>
</file>
<file src="src/Operation/CreateEncryptedCollection.php">
<DocblockTypeContradiction occurrences="2">
<code>! is_array($encryptedFields['fields'])</code>
<code>! is_array($field) &amp;&amp; ! is_object($field)</code>
</DocblockTypeContradiction>
</file>
<file src="src/Operation/CreateIndexes.php">
<DocblockTypeContradiction occurrences="1">
<code>is_array($index)</code>
Expand Down
76 changes: 56 additions & 20 deletions src/Database.php
Original file line number Diff line number Diff line change
Expand Up @@ -18,12 +18,14 @@
namespace MongoDB;

use Iterator;
use MongoDB\Driver\ClientEncryption;
use MongoDB\Driver\Cursor;
use MongoDB\Driver\Exception\RuntimeException as DriverRuntimeException;
use MongoDB\Driver\Manager;
use MongoDB\Driver\ReadConcern;
use MongoDB\Driver\ReadPreference;
use MongoDB\Driver\WriteConcern;
use MongoDB\Exception\CreateEncryptedCollectionException;
use MongoDB\Exception\InvalidArgumentException;
use MongoDB\Exception\UnexpectedValueException;
use MongoDB\Exception\UnsupportedException;
Expand All @@ -33,7 +35,7 @@
use MongoDB\Model\CollectionInfoIterator;
use MongoDB\Operation\Aggregate;
use MongoDB\Operation\CreateCollection;
use MongoDB\Operation\CreateIndexes;
use MongoDB\Operation\CreateEncryptedCollection;
use MongoDB\Operation\DatabaseCommand;
use MongoDB\Operation\DropCollection;
use MongoDB\Operation\DropDatabase;
Expand All @@ -42,6 +44,7 @@
use MongoDB\Operation\ModifyCollection;
use MongoDB\Operation\RenameCollection;
use MongoDB\Operation\Watch;
use Throwable;
use Traversable;

use function is_array;
Expand Down Expand Up @@ -256,7 +259,13 @@ public function command($command, array $options = [])
/**
* Create a new collection explicitly.
*
* If the "encryptedFields" option is specified, this method additionally
* creates related metadata collections and an index on the encrypted
* collection.
*
* @see CreateCollection::__construct() for supported options
* @see https://github.com/mongodb/specifications/blob/master/source/client-side-encryption/client-side-encryption.rst#create-collection-helper
* @see https://www.mongodb.com/docs/manual/core/queryable-encryption/fundamentals/manage-collections/
* @return array|object Command result document
* @throws UnsupportedException if options are not supported by the selected server
* @throws InvalidArgumentException for parameter/option parsing errors
Expand All @@ -268,36 +277,63 @@ public function createCollection(string $collectionName, array $options = [])
$options['typeMap'] = $this->typeMap;
}

$server = select_server($this->manager, $options);

if (! isset($options['writeConcern']) && ! is_in_transaction($options)) {
$options['writeConcern'] = $this->writeConcern;
}

$encryptedFields = $options['encryptedFields']
?? get_encrypted_fields_from_driver($this->databaseName, $collectionName, $this->manager)
?? null;
if (! isset($options['encryptedFields'])) {
$options['encryptedFields'] = get_encrypted_fields_from_driver($this->databaseName, $collectionName, $this->manager);
}

if ($encryptedFields !== null) {
// encryptedFields is passed to the create command
$options['encryptedFields'] = $encryptedFields;
$operation = isset($options['encryptedFields'])
? new CreateEncryptedCollection($this->databaseName, $collectionName, $options)
: new CreateCollection($this->databaseName, $collectionName, $options);

$encryptedFields = (array) $encryptedFields;
$enxcolOptions = ['clusteredIndex' => ['key' => ['_id' => 1], 'unique' => true]];
(new CreateCollection($this->databaseName, $encryptedFields['escCollection'] ?? 'enxcol_.' . $collectionName . '.esc', $enxcolOptions))->execute($server);
(new CreateCollection($this->databaseName, $encryptedFields['eccCollection'] ?? 'enxcol_.' . $collectionName . '.ecc', $enxcolOptions))->execute($server);
(new CreateCollection($this->databaseName, $encryptedFields['ecocCollection'] ?? 'enxcol_.' . $collectionName . '.ecoc', $enxcolOptions))->execute($server);
}
$server = select_server($this->manager, $options);

$operation = new CreateCollection($this->databaseName, $collectionName, $options);
return $operation->execute($server);
}

$result = $operation->execute($server);
/**
* Create a new encrypted collection explicitly.
*
* The "encryptedFields" option is required.
*
* This method will automatically create data keys for any encrypted fields
* where "keyId" is null. A copy of the modified "encryptedFields" option
* will be returned in addition to the result from creating the collection.
*
* If any error is encountered creating data keys or the collection, a
* CreateEncryptedCollectionException will be thrown. The original exception
* and modified "encryptedFields" option can be accessed via the
* getPrevious() and getEncryptedFields() methods, respectively.
*
* @see CreateCollection::__construct() for supported options
* @return array A tuple containing the command result document from creating the collection and the modified "encryptedFields" option
* @throws InvalidArgumentException for parameter/option parsing errors
* @throws CreateEncryptedCollectionException for any errors creating data keys or creating the collection
*/
public function createEncryptedCollection(string $collectionName, ClientEncryption $clientEncryption, string $kmsProvider, ?array $masterKey, array $options): array
{
if (! isset($options['typeMap'])) {
$options['typeMap'] = $this->typeMap;
}

if ($encryptedFields !== null) {
(new CreateIndexes($this->databaseName, $collectionName, [['key' => ['__safeContent__' => 1]]]))->execute($server);
if (! isset($options['writeConcern']) && ! is_in_transaction($options)) {
$options['writeConcern'] = $this->writeConcern;
}

return $result;
$operation = new CreateEncryptedCollection($this->databaseName, $collectionName, $options);
$server = select_server($this->manager, $options);

try {
$operation->createDataKeys($clientEncryption, $kmsProvider, $masterKey, $encryptedFields);
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I originally implemented two operation classes:

  • CreateCollectionForQueryableEncryption for createCollection() with an encryptedFields option, which composed several CreateCollection operations and one CreateIndexes operation.
  • CreateEncryptedCollection for this helper, which composed a CreateCollectionForQueryableEncryption.

In that approach, CreateEncryptedCollection::execute() created data keys before calling CreateCollectionForQueryableEncryption::execute() and contained the logic for CreateEncryptedCollectionException. The downside was updating the options for the inner-most CreateCollection operation when encryptedFields changed.

I also wasn't too happy with the naming for both operations. It made sense for CreateEncryptedCollection to match the helper method, but it was only responsible for creating data keys. CreateCollectionForQueryableEncryption was actually creating an encrypted collection.


I then combined both operations into CreateEncryptedCollection and moved the key generation logic to a separate createDataKeys() method to be called prior to execute(). This made it easier to reconstruct the inner CreateCollection operation.

Using a pass-by-reference parameter to expose modifications to encryptedFields allowed me to avoid dealing with CreateEncryptedCollectionException in the operation class entirely. I briefly explored returning encryptedFields and throwing the exception when needed, and it made both createDataKeys() and the helper's try/catch more complicated.

$result = $operation->execute($server);

return [$result, $encryptedFields];
} catch (Throwable $e) {
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I considered limiting this to \Exception, but that would have required silencing a phpcs warning. I think it's unlikely we'd ever get an internal PHP error here, but there's no harm in wrapping it.

This is unlike UnifiedSpecTest.php, which has a specific reason to avoid catching errors.

throw new CreateEncryptedCollectionException($e, $encryptedFields ?? []);
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

$encryptedFields ?? [] satisfies two psalm warnings:

  • PossiblyUndefinedVariable: Possibly undefined variable $encryptedFields defined in try block (see https://psalm.dev/018)
  • PossiblyNullArgument: Argument 2 of CreateEncryptedCollectionException::__construct cannot be null, possibly null value provided (see https://psalm.dev/078)

In practice, I don't think that's possible since it is assigned in the first line of createDataKeys() and we've already verified that the option exists in the operation constructor.

I'm open to any feedback, but this seemed like a harmless, defensive fix.

}
}

/**
Expand Down
Loading