Skip to content

Commit 6b088ff

Browse files
authored
PYTHON-3241 Add Queryable Encryption API to AutoEncryptionOpts (#957)
1 parent d98e44e commit 6b088ff

File tree

70 files changed

+12489
-38
lines changed

Some content is hidden

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

70 files changed

+12489
-38
lines changed

pymongo/collection.py

Lines changed: 18 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@
3535
from bson.raw_bson import RawBSONDocument
3636
from bson.son import SON
3737
from bson.timestamp import Timestamp
38-
from pymongo import common, helpers, message
38+
from pymongo import ASCENDING, common, helpers, message
3939
from pymongo.aggregation import (
4040
_CollectionAggregationCommand,
4141
_CollectionRawAggregationCommand,
@@ -44,6 +44,7 @@
4444
from pymongo.change_stream import CollectionChangeStream
4545
from pymongo.collation import validate_collation_or_none
4646
from pymongo.command_cursor import CommandCursor, RawBatchCommandCursor
47+
from pymongo.common import _ecc_coll_name, _ecoc_coll_name, _esc_coll_name
4748
from pymongo.cursor import Cursor, RawBatchCursor
4849
from pymongo.errors import (
4950
ConfigurationError,
@@ -115,6 +116,7 @@ def __init__(
115116
write_concern: Optional[WriteConcern] = None,
116117
read_concern: Optional["ReadConcern"] = None,
117118
session: Optional["ClientSession"] = None,
119+
encrypted_fields: Optional[Mapping[str, Any]] = None,
118120
**kwargs: Any,
119121
) -> None:
120122
"""Get / create a Mongo collection.
@@ -197,7 +199,6 @@ def __init__(
197199
write_concern or database.write_concern,
198200
read_concern or database.read_concern,
199201
)
200-
201202
if not isinstance(name, str):
202203
raise TypeError("name must be an instance of str")
203204

@@ -215,7 +216,16 @@ def __init__(
215216
self.__name = name
216217
self.__full_name = "%s.%s" % (self.__database.name, self.__name)
217218
if create or kwargs or collation:
218-
self.__create(kwargs, collation, session)
219+
if encrypted_fields:
220+
common.validate_is_mapping("encrypted_fields", encrypted_fields)
221+
opts = {"clusteredIndex": {"key": {"_id": 1}, "unique": True}}
222+
self.__create(_esc_coll_name(encrypted_fields, name), opts, None, session)
223+
self.__create(_ecc_coll_name(encrypted_fields, name), opts, None, session)
224+
self.__create(_ecoc_coll_name(encrypted_fields, name), opts, None, session)
225+
self.__create(name, kwargs, collation, session, encrypted_fields=encrypted_fields)
226+
self.create_index([("__safeContent__", ASCENDING)], session)
227+
else:
228+
self.__create(name, kwargs, collation, session)
219229

220230
self.__write_response_codec_options = self.codec_options._replace(
221231
unicode_decode_error_handler="replace", document_class=dict
@@ -286,9 +296,12 @@ def _command(
286296
user_fields=user_fields,
287297
)
288298

289-
def __create(self, options, collation, session):
299+
def __create(self, name, options, collation, session, encrypted_fields=None):
290300
"""Sends a create command with the given options."""
291-
cmd = SON([("create", self.__name)])
301+
cmd = SON([("create", name)])
302+
if encrypted_fields:
303+
cmd["encryptedFields"] = encrypted_fields
304+
292305
if options:
293306
if "size" in options:
294307
options["size"] = float(options["size"])

pymongo/common.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -792,6 +792,18 @@ def get_validated_options(
792792
return validated_options
793793

794794

795+
def _esc_coll_name(encrypted_fields, name):
796+
return encrypted_fields.get("escCollection", f"enxcol_.{name}.esc")
797+
798+
799+
def _ecc_coll_name(encrypted_fields, name):
800+
return encrypted_fields.get("eccCollection", f"enxcol_.{name}.ecc")
801+
802+
803+
def _ecoc_coll_name(encrypted_fields, name):
804+
return encrypted_fields.get("ecocCollection", f"enxcol_.{name}.ecoc")
805+
806+
795807
# List of write-concern-related options.
796808
WRITE_CONCERN_OPTIONS = frozenset(["w", "wtimeout", "wtimeoutms", "fsync", "j", "journal"])
797809

pymongo/database.py

Lines changed: 103 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@
3838
from pymongo.change_stream import DatabaseChangeStream
3939
from pymongo.collection import Collection
4040
from pymongo.command_cursor import CommandCursor
41+
from pymongo.common import _ecc_coll_name, _ecoc_coll_name, _esc_coll_name
4142
from pymongo.errors import CollectionInvalid, InvalidName
4243
from pymongo.read_preferences import ReadPreference, _ServerMode
4344
from pymongo.typings import _CollationIn, _DocumentType, _Pipeline
@@ -290,6 +291,7 @@ def create_collection(
290291
write_concern: Optional["WriteConcern"] = None,
291292
read_concern: Optional["ReadConcern"] = None,
292293
session: Optional["ClientSession"] = None,
294+
encrypted_fields: Optional[Mapping[str, Any]] = None,
293295
**kwargs: Any,
294296
) -> Collection[_DocumentType]:
295297
"""Create a new :class:`~pymongo.collection.Collection` in this
@@ -321,6 +323,29 @@ def create_collection(
321323
:class:`~pymongo.collation.Collation`.
322324
- `session` (optional): a
323325
:class:`~pymongo.client_session.ClientSession`.
326+
- `encrypted_fields`: Document that describes the encrypted fields for Queryable
327+
Encryption.
328+
For example::
329+
330+
{
331+
"escCollection": "enxcol_.encryptedCollection.esc",
332+
"eccCollection": "enxcol_.encryptedCollection.ecc",
333+
"ecocCollection": "enxcol_.encryptedCollection.ecoc",
334+
"fields": [
335+
{
336+
"path": "firstName",
337+
"keyId": Binary.from_uuid(UUID('00000000-0000-0000-0000-000000000000')),
338+
"bsonType": "string",
339+
"queries": {"queryType": "equality"}
340+
},
341+
{
342+
"path": "ssn",
343+
"keyId": Binary.from_uuid(UUID('04104104-1041-0410-4104-104104104104')),
344+
"bsonType": "string"
345+
}
346+
]
347+
348+
} }
324349
- `**kwargs` (optional): additional keyword arguments will
325350
be passed as options for the `create collection command`_
326351
@@ -369,14 +394,24 @@ def create_collection(
369394
.. _create collection command:
370395
https://mongodb.com/docs/manual/reference/command/create
371396
"""
397+
if (
398+
not encrypted_fields
399+
and self.client.options.auto_encryption_opts
400+
and self.client.options.auto_encryption_opts._encrypted_fields_map
401+
):
402+
encrypted_fields = self.client.options.auto_encryption_opts._encrypted_fields_map.get(
403+
"%s.%s" % (self.name, name)
404+
)
405+
if encrypted_fields:
406+
common.validate_is_mapping("encrypted_fields", encrypted_fields)
407+
372408
with self.__client._tmp_session(session) as s:
373409
# Skip this check in a transaction where listCollections is not
374410
# supported.
375411
if (not s or not s.in_transaction) and name in self.list_collection_names(
376412
filter={"name": name}, session=s
377413
):
378414
raise CollectionInvalid("collection %s already exists" % name)
379-
380415
return Collection(
381416
self,
382417
name,
@@ -386,6 +421,7 @@ def create_collection(
386421
write_concern,
387422
read_concern,
388423
session=s,
424+
encrypted_fields=encrypted_fields,
389425
**kwargs,
390426
)
391427

@@ -874,11 +910,27 @@ def list_collection_names(
874910

875911
return [result["name"] for result in self.list_collections(session=session, **kwargs)]
876912

913+
def _drop_helper(self, name, session=None, comment=None):
914+
command = SON([("drop", name)])
915+
if comment is not None:
916+
command["comment"] = comment
917+
918+
with self.__client._socket_for_writes(session) as sock_info:
919+
return self._command(
920+
sock_info,
921+
command,
922+
allowable_errors=["ns not found", 26],
923+
write_concern=self._write_concern_for(session),
924+
parse_write_concern_error=True,
925+
session=session,
926+
)
927+
877928
def drop_collection(
878929
self,
879930
name_or_collection: Union[str, Collection],
880931
session: Optional["ClientSession"] = None,
881932
comment: Optional[Any] = None,
933+
encrypted_fields: Optional[Mapping[str, Any]] = None,
882934
) -> Dict[str, Any]:
883935
"""Drop a collection.
884936
@@ -889,6 +941,29 @@ def drop_collection(
889941
:class:`~pymongo.client_session.ClientSession`.
890942
- `comment` (optional): A user-provided comment to attach to this
891943
command.
944+
- `encrypted_fields`: Document that describes the encrypted fields for Queryable
945+
Encryption.
946+
For example::
947+
948+
{
949+
"escCollection": "enxcol_.encryptedCollection.esc",
950+
"eccCollection": "enxcol_.encryptedCollection.ecc",
951+
"ecocCollection": "enxcol_.encryptedCollection.ecoc",
952+
"fields": [
953+
{
954+
"path": "firstName",
955+
"keyId": Binary.from_uuid(UUID('00000000-0000-0000-0000-000000000000')),
956+
"bsonType": "string",
957+
"queries": {"queryType": "equality"}
958+
},
959+
{
960+
"path": "ssn",
961+
"keyId": Binary.from_uuid(UUID('04104104-1041-0410-4104-104104104104')),
962+
"bsonType": "string"
963+
}
964+
]
965+
966+
}
892967
893968
894969
.. note:: The :attr:`~pymongo.database.Database.write_concern` of
@@ -911,20 +986,34 @@ def drop_collection(
911986

912987
if not isinstance(name, str):
913988
raise TypeError("name_or_collection must be an instance of str")
914-
915-
command = SON([("drop", name)])
916-
if comment is not None:
917-
command["comment"] = comment
918-
919-
with self.__client._socket_for_writes(session) as sock_info:
920-
return self._command(
921-
sock_info,
922-
command,
923-
allowable_errors=["ns not found", 26],
924-
write_concern=self._write_concern_for(session),
925-
parse_write_concern_error=True,
926-
session=session,
989+
full_name = "%s.%s" % (self.name, name)
990+
if (
991+
not encrypted_fields
992+
and self.client.options.auto_encryption_opts
993+
and self.client.options.auto_encryption_opts._encrypted_fields_map
994+
):
995+
encrypted_fields = self.client.options.auto_encryption_opts._encrypted_fields_map.get(
996+
full_name
997+
)
998+
if not encrypted_fields and self.client.options.auto_encryption_opts:
999+
colls = list(
1000+
self.list_collections(filter={"name": name}, session=session, comment=comment)
9271001
)
1002+
if colls and colls[0]["options"].get("encryptedFields"):
1003+
encrypted_fields = colls[0]["options"]["encryptedFields"]
1004+
if encrypted_fields:
1005+
common.validate_is_mapping("encrypted_fields", encrypted_fields)
1006+
self._drop_helper(
1007+
_esc_coll_name(encrypted_fields, name), session=session, comment=comment
1008+
)
1009+
self._drop_helper(
1010+
_ecc_coll_name(encrypted_fields, name), session=session, comment=comment
1011+
)
1012+
self._drop_helper(
1013+
_ecoc_coll_name(encrypted_fields, name), session=session, comment=comment
1014+
)
1015+
1016+
return self._drop_helper(name, session, comment)
9281017

9291018
def validate_collection(
9301019
self,

pymongo/encryption.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -264,6 +264,11 @@ def __init__(self, client, opts):
264264
schema_map = None
265265
else:
266266
schema_map = _dict_to_bson(opts._schema_map, False, _DATA_KEY_OPTS)
267+
268+
if opts._encrypted_fields_map is None:
269+
encrypted_fields_map = None
270+
else:
271+
encrypted_fields_map = _dict_to_bson(opts._encrypted_fields_map, False, _DATA_KEY_OPTS)
267272
self._bypass_auto_encryption = opts._bypass_auto_encryption
268273
self._internal_client = None
269274

@@ -304,6 +309,7 @@ def _get_internal_client(encrypter, mongo_client):
304309
crypt_shared_lib_path=opts._crypt_shared_lib_path,
305310
crypt_shared_lib_required=opts._crypt_shared_lib_required,
306311
bypass_encryption=opts._bypass_auto_encryption,
312+
encrypted_fields_map=encrypted_fields_map,
307313
bypass_query_analysis=opts._bypass_query_analysis,
308314
),
309315
)

pymongo/encryption_options.py

Lines changed: 31 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@
2323
except ImportError:
2424
_HAVE_PYMONGOCRYPT = False
2525

26+
from pymongo.common import validate_is_mapping
2627
from pymongo.errors import ConfigurationError
2728
from pymongo.uri_parser import _parse_kms_tls_options
2829

@@ -48,6 +49,7 @@ def __init__(
4849
crypt_shared_lib_path: Optional[str] = None,
4950
crypt_shared_lib_required: bool = False,
5051
bypass_query_analysis: bool = False,
52+
encrypted_fields_map: Optional[Mapping] = None,
5153
) -> None:
5254
"""Options to configure automatic client-side field level encryption.
5355
@@ -150,10 +152,33 @@ def __init__(
150152
outgoing commands. Set `bypass_query_analysis` to use explicit
151153
encryption on indexed fields without the MongoDB Enterprise Advanced
152154
licensed crypt_shared library.
155+
- `encrypted_fields_map`: Map of collection namespace ("db.coll") to documents that
156+
described the encrypted fields for Queryable Encryption. For example::
157+
158+
{
159+
"db.encryptedCollection": {
160+
"escCollection": "enxcol_.encryptedCollection.esc",
161+
"eccCollection": "enxcol_.encryptedCollection.ecc",
162+
"ecocCollection": "enxcol_.encryptedCollection.ecoc",
163+
"fields": [
164+
{
165+
"path": "firstName",
166+
"keyId": Binary.from_uuid(UUID('00000000-0000-0000-0000-000000000000')),
167+
"bsonType": "string",
168+
"queries": {"queryType": "equality"}
169+
},
170+
{
171+
"path": "ssn",
172+
"keyId": Binary.from_uuid(UUID('04104104-1041-0410-4104-104104104104')),
173+
"bsonType": "string"
174+
}
175+
]
176+
}
177+
}
153178
154179
.. versionchanged:: 4.2
155-
Added `crypt_shared_lib_path`, `crypt_shared_lib_required`, and `bypass_query_analysis`
156-
parameters.
180+
Added `encrypted_fields_map` `crypt_shared_lib_path`, `crypt_shared_lib_required`,
181+
and `bypass_query_analysis` parameters.
157182
158183
.. versionchanged:: 4.0
159184
Added the `kms_tls_options` parameter and the "kmip" KMS provider.
@@ -166,6 +191,10 @@ def __init__(
166191
"install a compatible version with: "
167192
"python -m pip install 'pymongo[encryption]'"
168193
)
194+
if encrypted_fields_map:
195+
validate_is_mapping("encrypted_fields_map", encrypted_fields_map)
196+
self._encrypted_fields_map = encrypted_fields_map
197+
self._bypass_query_analysis = bypass_query_analysis
169198
self._crypt_shared_lib_path = crypt_shared_lib_path
170199
self._crypt_shared_lib_required = crypt_shared_lib_required
171200
self._kms_providers = kms_providers

0 commit comments

Comments
 (0)