Skip to content

PYTHON-4580 Add key_expiration_ms option for DEK cache lifetime #2186

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 10 commits into from
Mar 11, 2025
18 changes: 17 additions & 1 deletion doc/changelog.rst
Original file line number Diff line number Diff line change
@@ -1,7 +1,23 @@
Changelog
=========

Changes in Version 4.11.2 (YYYY/MM/DD)
Changes in Version 4.12.0 (YYYY/MM/DD)
--------------------------------------

PyMongo 4.12 brings a number of changes including:

- Support for configuring DEK cache lifetime via the ``key_expiration_ms`` argument to
:class:`~pymongo.encryption_options.AutoEncryptionOpts`.

Issues Resolved
...............

See the `PyMongo 4.12 release notes in JIRA`_ for the list of resolved issues
in this release.

.. _PyMongo 4.12 release notes in JIRA: https://jira.mongodb.org/secure/ReleaseNote.jspa?projectId=10004&version=41916

Changes in Version 4.11.2 (2025/03/05)
--------------------------------------

Version 4.11.2 is a bug fix release.
Expand Down
26 changes: 19 additions & 7 deletions pymongo/asynchronous/encryption.py
Original file line number Diff line number Diff line change
Expand Up @@ -445,6 +445,7 @@ def _get_internal_client(
bypass_encryption=opts._bypass_auto_encryption,
encrypted_fields_map=encrypted_fields_map,
bypass_query_analysis=opts._bypass_query_analysis,
key_expiration_ms=opts._key_expiration_ms,
),
)
self._closed = False
Expand Down Expand Up @@ -547,11 +548,10 @@ class QueryType(str, enum.Enum):


def _create_mongocrypt_options(**kwargs: Any) -> MongoCryptOptions:
opts = MongoCryptOptions(**kwargs)
# Opt into range V2 encryption.
if hasattr(opts, "enable_range_v2"):
opts.enable_range_v2 = True
return opts
# For compat with pymongocrypt <1.13, avoid setting the default key_expiration_ms.
if kwargs.get("key_expiration_ms") is None:
kwargs.pop("key_expiration_ms", None)
return MongoCryptOptions(**kwargs)


class AsyncClientEncryption(Generic[_DocumentType]):
Expand All @@ -564,6 +564,7 @@ def __init__(
key_vault_client: AsyncMongoClient[_DocumentTypeArg],
codec_options: CodecOptions[_DocumentTypeArg],
kms_tls_options: Optional[Mapping[str, Any]] = None,
key_expiration_ms: Optional[int] = None,
) -> None:
"""Explicit client-side field level encryption.

Expand Down Expand Up @@ -630,7 +631,12 @@ def __init__(
Or to supply a client certificate::

kms_tls_options={'kmip': {'tlsCertificateKeyFile': 'client.pem'}}
:param key_expiration_ms: The cache expiration time for data encryption keys.
Defaults to ``None`` which defers to libmongocrypt's default which is currently 60000.
Set to 0 to disable key expiration.

.. versionchanged:: 4.12
Added the `key_expiration_ms` parameter.
.. versionchanged:: 4.0
Added the `kms_tls_options` parameter and the "kmip" KMS provider.

Expand Down Expand Up @@ -666,14 +672,19 @@ def __init__(
key_vault_coll = key_vault_client[db][coll]

opts = AutoEncryptionOpts(
kms_providers, key_vault_namespace, kms_tls_options=kms_tls_options
kms_providers,
key_vault_namespace,
kms_tls_options=kms_tls_options,
key_expiration_ms=key_expiration_ms,
)
self._io_callbacks: Optional[_EncryptionIO] = _EncryptionIO(
None, key_vault_coll, None, opts
)
self._encryption = AsyncExplicitEncrypter(
self._io_callbacks,
_create_mongocrypt_options(kms_providers=kms_providers, schema_map=None),
_create_mongocrypt_options(
kms_providers=kms_providers, schema_map=None, key_expiration_ms=key_expiration_ms
),
)
# Use the same key vault collection as the callback.
assert self._io_callbacks.key_vault_coll is not None
Expand All @@ -700,6 +711,7 @@ async def create_encrypted_collection(
creation. :class:`~pymongo.errors.EncryptionError` will be
raised if the collection already exists.

:param database: the database to create the collection
:param name: the name of the collection to create
:param encrypted_fields: Document that describes the encrypted fields for
Queryable Encryption. The "keyId" may be set to ``None`` to auto-generate the data keys. For example:
Expand Down
10 changes: 8 additions & 2 deletions pymongo/encryption_options.py
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@ def __init__(
crypt_shared_lib_required: bool = False,
bypass_query_analysis: bool = False,
encrypted_fields_map: Optional[Mapping[str, Any]] = None,
key_expiration_ms: Optional[int] = None,
) -> None:
"""Options to configure automatic client-side field level encryption.

Expand Down Expand Up @@ -191,9 +192,14 @@ def __init__(
]
}
}
:param key_expiration_ms: The cache expiration time for data encryption keys.
Defaults to ``None`` which defers to libmongocrypt's default which is currently 60000.
Set to 0 to disable key expiration.

.. versionchanged:: 4.12
Added the `key_expiration_ms` parameter.
.. versionchanged:: 4.2
Added `encrypted_fields_map` `crypt_shared_lib_path`, `crypt_shared_lib_required`,
Added the `encrypted_fields_map`, `crypt_shared_lib_path`, `crypt_shared_lib_required`,
and `bypass_query_analysis` parameters.

.. versionchanged:: 4.0
Expand All @@ -210,7 +216,6 @@ def __init__(
if encrypted_fields_map:
validate_is_mapping("encrypted_fields_map", encrypted_fields_map)
self._encrypted_fields_map = encrypted_fields_map
self._bypass_query_analysis = bypass_query_analysis
self._crypt_shared_lib_path = crypt_shared_lib_path
self._crypt_shared_lib_required = crypt_shared_lib_required
self._kms_providers = kms_providers
Expand All @@ -233,6 +238,7 @@ def __init__(
# Maps KMS provider name to a SSLContext.
self._kms_ssl_contexts = _parse_kms_tls_options(kms_tls_options)
self._bypass_query_analysis = bypass_query_analysis
self._key_expiration_ms = key_expiration_ms


class RangeOpts:
Expand Down
26 changes: 19 additions & 7 deletions pymongo/synchronous/encryption.py
Original file line number Diff line number Diff line change
Expand Up @@ -442,6 +442,7 @@ def _get_internal_client(
bypass_encryption=opts._bypass_auto_encryption,
encrypted_fields_map=encrypted_fields_map,
bypass_query_analysis=opts._bypass_query_analysis,
key_expiration_ms=opts._key_expiration_ms,
),
)
self._closed = False
Expand Down Expand Up @@ -544,11 +545,10 @@ class QueryType(str, enum.Enum):


def _create_mongocrypt_options(**kwargs: Any) -> MongoCryptOptions:
opts = MongoCryptOptions(**kwargs)
# Opt into range V2 encryption.
if hasattr(opts, "enable_range_v2"):
opts.enable_range_v2 = True
return opts
# For compat with pymongocrypt <1.13, avoid setting the default key_expiration_ms.
if kwargs.get("key_expiration_ms") is None:
kwargs.pop("key_expiration_ms", None)
return MongoCryptOptions(**kwargs)


class ClientEncryption(Generic[_DocumentType]):
Expand All @@ -561,6 +561,7 @@ def __init__(
key_vault_client: MongoClient[_DocumentTypeArg],
codec_options: CodecOptions[_DocumentTypeArg],
kms_tls_options: Optional[Mapping[str, Any]] = None,
key_expiration_ms: Optional[int] = None,
) -> None:
"""Explicit client-side field level encryption.

Expand Down Expand Up @@ -627,7 +628,12 @@ def __init__(
Or to supply a client certificate::

kms_tls_options={'kmip': {'tlsCertificateKeyFile': 'client.pem'}}
:param key_expiration_ms: The cache expiration time for data encryption keys.
Defaults to ``None`` which defers to libmongocrypt's default which is currently 60000.
Set to 0 to disable key expiration.

.. versionchanged:: 4.12
Added the `key_expiration_ms` parameter.
.. versionchanged:: 4.0
Added the `kms_tls_options` parameter and the "kmip" KMS provider.

Expand Down Expand Up @@ -659,14 +665,19 @@ def __init__(
key_vault_coll = key_vault_client[db][coll]

opts = AutoEncryptionOpts(
kms_providers, key_vault_namespace, kms_tls_options=kms_tls_options
kms_providers,
key_vault_namespace,
kms_tls_options=kms_tls_options,
key_expiration_ms=key_expiration_ms,
)
self._io_callbacks: Optional[_EncryptionIO] = _EncryptionIO(
None, key_vault_coll, None, opts
)
self._encryption = ExplicitEncrypter(
self._io_callbacks,
_create_mongocrypt_options(kms_providers=kms_providers, schema_map=None),
_create_mongocrypt_options(
kms_providers=kms_providers, schema_map=None, key_expiration_ms=key_expiration_ms
),
)
# Use the same key vault collection as the callback.
assert self._io_callbacks.key_vault_coll is not None
Expand All @@ -693,6 +704,7 @@ def create_encrypted_collection(
creation. :class:`~pymongo.errors.EncryptionError` will be
raised if the collection already exists.

:param database: the database to create the collection
:param name: the name of the collection to create
:param encrypted_fields: Document that describes the encrypted fields for
Queryable Encryption. The "keyId" may be set to ``None`` to auto-generate the data keys. For example:
Expand Down
3 changes: 2 additions & 1 deletion test/asynchronous/unified_format.py
Original file line number Diff line number Diff line change
Expand Up @@ -378,6 +378,7 @@ async def drop(self: AsyncGridFSBucket, *args: Any, **kwargs: Any) -> None:
opts["key_vault_client"],
DEFAULT_CODEC_OPTIONS,
opts.get("kms_tls_options", kms_tls_options),
opts.get("key_expiration_ms"),
)
return
elif entity_type == "thread":
Expand Down Expand Up @@ -439,7 +440,7 @@ class UnifiedSpecTestMixinV1(AsyncIntegrationTest):
a class attribute ``TEST_SPEC``.
"""

SCHEMA_VERSION = Version.from_string("1.21")
SCHEMA_VERSION = Version.from_string("1.22")
RUN_ON_LOAD_BALANCER = True
RUN_ON_SERVERLESS = True
TEST_SPEC: Any
Expand Down
5 changes: 5 additions & 0 deletions test/asynchronous/utils_spec_runner.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
import asyncio
import functools
import os
import time
import unittest
from asyncio import iscoroutinefunction
from collections import abc
Expand Down Expand Up @@ -314,6 +315,10 @@ async def assert_index_not_exists(self, database, collection, index):
coll = self.client[database][collection]
self.assertNotIn(index, [doc["name"] async for doc in await coll.list_indexes()])

async def wait(self, ms):
"""Run the "wait" test operation."""
await asyncio.sleep(ms / 1000.0)

def assertErrorLabelsContain(self, exc, expected_labels):
labels = [l for l in expected_labels if exc.has_error_label(l)]
self.assertEqual(labels, expected_labels)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,7 @@
"replicaset",
"sharded",
"load-balanced"
],
"serverless": "forbid"
]
}
],
"database_name": "default",
Expand Down
Loading
Loading