Skip to content

Commit 2c79aee

Browse files
committed
wip PHPLIB-913: Database::createEncryptedCollection() helper
1 parent fb7a1bf commit 2c79aee

File tree

4 files changed

+406
-0
lines changed

4 files changed

+406
-0
lines changed

src/Database.php

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
namespace MongoDB;
1919

2020
use Iterator;
21+
use MongoDB\Driver\ClientEncryption;
2122
use MongoDB\Driver\Cursor;
2223
use MongoDB\Driver\Exception\RuntimeException as DriverRuntimeException;
2324
use MongoDB\Driver\Manager;
@@ -44,7 +45,10 @@
4445
use MongoDB\Operation\Watch;
4546
use Traversable;
4647

48+
use function array_key_exists;
4749
use function is_array;
50+
use function is_object;
51+
use function property_exists;
4852
use function strlen;
4953

5054
class Database
@@ -300,6 +304,60 @@ public function createCollection(string $collectionName, array $options = [])
300304
return $result;
301305
}
302306

307+
/**
308+
* Create a new encrypted collection.
309+
*
310+
* This function will automatically create data keys for any encrypted
311+
* fields where the "keyId" option is null. This function will return a copy
312+
* of the modified "encryptedFields" option in addition to the result from
313+
* createCollection().
314+
*
315+
* This function requires that the "encryptedFields" option be specified.
316+
*
317+
* If any error is encountered while creating data keys or invoking
318+
* createCollection(), a CreateEncryptedCollectionException will be thrown.
319+
* The original exception and modified "encryptedFields" option can be
320+
* accessed via getPrevious() and getEncryptedFields(), respectively.
321+
*
322+
* @see CreateCollection::__construct() for supported options
323+
* @return array A tuple consisting of the createCollection() result and modified "encryptedFields" option
324+
* @throws InvalidArgumentException for parameter/option parsing errors
325+
* @throws CreateEncryptedCollectionException for errors generating data keys or invoking createCollection
326+
*/
327+
public function createEncryptedCollection(string $collectionName, ClientEncryption $clientEncryption, string $kmsProvider, ?array $masterKey, array $options = []): array
328+
{
329+
if (! isset($options['encryptedFields']) || ! is_array($options['encryptedFields']) && ! is_object($options['encryptedFields'])) {
330+
throw InvalidArgumentException::invalidType('"encryptedFields" option', $options['encryptedFields'] ?? null, 'array or object');
331+
}
332+
333+
$encryptedFields = (array) recursive_copy($options['encryptedFields']);
334+
335+
$createDataKeyArgs = [
336+
$kmsProvider,
337+
isset($masterKey) ? ['masterKey' => $masterKey] : [],
338+
];
339+
340+
try {
341+
if (isset($encryptedFields['fields']) && is_array($encryptedFields['fields'])) {
342+
foreach ($encryptedFields['fields'] as &$field) {
343+
if (is_array($field) && array_key_exists('keyId', $field) && $field['keyId'] === null) {
344+
$field['keyId'] = $clientEncryption->createDataKey(...$createDataKeyArgs);
345+
} elseif (is_object($field) && property_exists($field, 'keyId') && $field->keyId === null) {
346+
$field->keyId = $clientEncryption->createDataKey(...$createDataKeyArgs);
347+
}
348+
}
349+
350+
$options['encryptedFields'] = $encryptedFields;
351+
}
352+
353+
$result = $this->createCollection($collectionName, $options);
354+
355+
return [$result, $encryptedFields];
356+
} catch (Exception $e) {
357+
throw new CreateEncryptedCollectionException($e, $encryptedFields);
358+
}
359+
}
360+
303361
/**
304362
* Drop this database.
305363
*
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
<?php
2+
/*
3+
* Copyright 2023-present MongoDB, Inc.
4+
*
5+
* Licensed under the Apache License, Version 2.0 (the "License");
6+
* you may not use this file except in compliance with the License.
7+
* You may obtain a copy of the License at
8+
*
9+
* https://www.apache.org/licenses/LICENSE-2.0
10+
*
11+
* Unless required by applicable law or agreed to in writing, software
12+
* distributed under the License is distributed on an "AS IS" BASIS,
13+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14+
* See the License for the specific language governing permissions and
15+
* limitations under the License.
16+
*/
17+
18+
namespace MongoDB\Exception;
19+
20+
use Throwable;
21+
22+
use function get_class;
23+
use function sprintf;
24+
25+
/**
26+
* @internal
27+
* @see \MongoDB\Database::createEncryptedCollection()
28+
*/
29+
final class CreateEncryptedCollectionException extends RuntimeException
30+
{
31+
private array $encryptedFields;
32+
33+
public function __construct(Throwable $previous, array $encryptedFields)
34+
{
35+
parent::__construct(sprintf('Creating encrypted collection failed due to previous %s: %s', get_class($previous), $previous->getMessage()), 0, $previous);
36+
37+
$this->encryptedFields = $encryptedFields;
38+
}
39+
40+
/**
41+
* Returns the encryptedFields option in its last known state before the
42+
* operation was interrupted.
43+
*
44+
* This can be used to infer which data keys were successfully created;
45+
* however, it is possible that additional data keys were successfully
46+
* created and are not present in the returned value. For example, if the
47+
* operation was interrupted by a timeout error when creating a data key,
48+
* the write may actually have succeeded on the server but the key will not
49+
* be present in the returned value.
50+
*/
51+
public function getEncryptedFields(): array
52+
{
53+
return $this->encryptedFields;
54+
}
55+
}
Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,130 @@
1+
<?php
2+
3+
namespace MongoDB\Tests\SpecTests\ClientSideEncryption;
4+
5+
use MongoDB\BSON\Int64;
6+
use MongoDB\Client;
7+
use MongoDB\Driver\WriteConcern;
8+
use MongoDB\Tests\SpecTests\FunctionalTestCase as BaseFunctionalTestCase;
9+
use PHPUnit\Framework\Assert;
10+
use stdClass;
11+
12+
use function explode;
13+
use function getenv;
14+
use function is_executable;
15+
use function is_readable;
16+
use function sprintf;
17+
use function strlen;
18+
use function unserialize;
19+
20+
use const DIRECTORY_SEPARATOR;
21+
use const PATH_SEPARATOR;
22+
23+
/**
24+
* Base class for client-side encryption prose tests.
25+
*
26+
* @see https://github.com/mongodb/specifications/blob/bc37892f360cab9df4082922384e0f4d4233f6d3/source/client-side-encryption/tests/README.rst
27+
*/
28+
abstract class FunctionalTestCase extends BaseFunctionalTestCase
29+
{
30+
public const LOCAL_MASTERKEY = 'Mng0NCt4ZHVUYUJCa1kxNkVyNUR1QURhZ2h2UzR2d2RrZzh0cFBwM3R6NmdWMDFBMUN3YkQ5aXRRMkhGRGdQV09wOGVNYUMxT2k3NjZKelhaQmRCZGJkTXVyZG9uSjFk';
31+
32+
public function setUp(): void
33+
{
34+
parent::setUp();
35+
36+
$this->skipIfClientSideEncryptionIsNotSupported();
37+
38+
if (! static::isCryptSharedLibAvailable() && ! static::isMongocryptdAvailable()) {
39+
$this->markTestSkipped('Neither crypt_shared nor mongocryptd are available');
40+
}
41+
}
42+
43+
public static function createTestClient(?string $uri = null, array $options = [], array $driverOptions = []): Client
44+
{
45+
if (isset($driverOptions['autoEncryption']) && getenv('CRYPT_SHARED_LIB_PATH')) {
46+
$driverOptions['autoEncryption']['extraOptions']['cryptSharedLibPath'] = getenv('CRYPT_SHARED_LIB_PATH');
47+
}
48+
49+
return parent::createTestClient($uri, $options, $driverOptions);
50+
}
51+
52+
protected static function getAWSCredentials(): array
53+
{
54+
return [
55+
'accessKeyId' => static::getEnv('AWS_ACCESS_KEY_ID'),
56+
'secretAccessKey' => static::getEnv('AWS_SECRET_ACCESS_KEY'),
57+
];
58+
}
59+
60+
protected static function insertKeyVaultData(Client $client, ?array $keyVaultData = null): void
61+
{
62+
$collection = $client->selectCollection('keyvault', 'datakeys', ['writeConcern' => new WriteConcern(WriteConcern::MAJORITY)]);
63+
$collection->drop();
64+
65+
if (empty($keyVaultData)) {
66+
return;
67+
}
68+
69+
$collection->insertMany($keyVaultData);
70+
}
71+
72+
private function createInt64(string $value): Int64
73+
{
74+
$array = sprintf('a:1:{s:7:"integer";s:%d:"%s";}', strlen($value), $value);
75+
$int64 = sprintf('C:%d:"%s":%d:{%s}', strlen(Int64::class), Int64::class, strlen($array), $array);
76+
77+
return unserialize($int64);
78+
}
79+
80+
private function createTestCollection(?stdClass $encryptedFields = null, ?stdClass $jsonSchema = null): void
81+
{
82+
$context = $this->getContext();
83+
$options = $context->defaultWriteOptions;
84+
85+
if (! empty($encryptedFields)) {
86+
$options['encryptedFields'] = $encryptedFields;
87+
}
88+
89+
if (! empty($jsonSchema)) {
90+
$options['validator'] = ['$jsonSchema' => $jsonSchema];
91+
}
92+
93+
$context->getDatabase()->createCollection($context->collectionName, $options);
94+
}
95+
96+
private static function getEnv(string $name): string
97+
{
98+
$value = getenv($name);
99+
100+
if ($value === false) {
101+
Assert::markTestSkipped(sprintf('Environment variable "%s" is not defined', $name));
102+
}
103+
104+
return $value;
105+
}
106+
107+
private static function isCryptSharedLibAvailable(): bool
108+
{
109+
$cryptSharedLibPath = getenv('CRYPT_SHARED_LIB_PATH');
110+
111+
if ($cryptSharedLibPath === false) {
112+
return false;
113+
}
114+
115+
return is_readable($cryptSharedLibPath);
116+
}
117+
118+
private static function isMongocryptdAvailable(): bool
119+
{
120+
$paths = explode(PATH_SEPARATOR, getenv("PATH"));
121+
122+
foreach ($paths as $path) {
123+
if (is_executable($path . DIRECTORY_SEPARATOR . 'mongocryptd')) {
124+
return true;
125+
}
126+
}
127+
128+
return false;
129+
}
130+
}

0 commit comments

Comments
 (0)