Skip to content

Commit 71a611d

Browse files
aclark4lifetimgraham
authored andcommitted
INTPYTHON-527 Add Queryable Encryption support
1 parent e8ecf50 commit 71a611d

34 files changed

+1998
-10
lines changed
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
# Settings for django_mongodb_backend/tests when encryption is supported.
2+
import os
3+
4+
from mongodb_settings import * # noqa: F403
5+
from pymongo.encryption import AutoEncryptionOpts
6+
7+
DATABASES["encrypted"] = { # noqa: F405
8+
"ENGINE": "django_mongodb_backend",
9+
"NAME": "djangotests_encrypted",
10+
"OPTIONS": {
11+
"auto_encryption_opts": AutoEncryptionOpts(
12+
key_vault_namespace="djangotests_encrypted.__keyVault",
13+
kms_providers={"local": {"key": os.urandom(96)}},
14+
),
15+
"directConnection": True,
16+
},
17+
"KMS_CREDENTIALS": {},
18+
}
19+
20+
21+
class EncryptedRouter:
22+
def db_for_read(self, model, **hints):
23+
if model._meta.app_label == "encryption_":
24+
return "encrypted"
25+
return None
26+
27+
db_for_write = db_for_read
28+
29+
def allow_migrate(self, db, app_label, model_name=None, **hints):
30+
# The encryption_ app's models are only created in the encrypted
31+
# database.
32+
if app_label == "encryption_":
33+
return db == "encrypted"
34+
# Don't create other app's models in the encrypted database.
35+
if db == "encrypted":
36+
return False
37+
return None
38+
39+
40+
DATABASE_ROUTERS.append(EncryptedRouter()) # noqa: F405
Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
1-
# Settings for django_mongodb_backend/tests.
1+
# Settings for django_mongodb_backend/tests when encryption isn't supported.
22
from django_settings import * # noqa: F403
33

4+
DATABASES["encrypted"] = {} # noqa: F405
45
DATABASE_ROUTERS = ["django_mongodb_backend.routers.MongoRouter"]

.github/workflows/test-python-atlas.yml

Lines changed: 24 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ jobs:
2828
- name: install django-mongodb-backend
2929
run: |
3030
pip3 install --upgrade pip
31-
pip3 install -e .
31+
pip3 install -e .[encryption]
3232
- name: Checkout Django
3333
uses: actions/checkout@v5
3434
with:
@@ -51,8 +51,30 @@ jobs:
5151
run: cp .github/workflows/runtests.py django_repo/tests/runtests_.py
5252
- name: Start local Atlas
5353
working-directory: .
54-
run: bash .github/workflows/start_local_atlas.sh mongodb/mongodb-atlas-local:7
54+
run: bash .github/workflows/start_local_atlas.sh mongodb/mongodb-atlas-local:8.0.15
55+
- name: Install mongosh
56+
run: |
57+
wget -q https://downloads.mongodb.com/compass/mongosh-2.2.10-linux-x64.tgz
58+
tar -xzf mongosh-*-linux-x64.tgz
59+
sudo cp mongosh-*-linux-x64/bin/mongosh /usr/local/bin/
60+
mongosh --version
61+
- name: Install mongocryptd from Enterprise tarball
62+
run: |
63+
curl -sSL -o mongodb-enterprise.tgz "https://downloads.mongodb.com/linux/mongodb-linux-x86_64-enterprise-ubuntu2204-8.0.15.tgz"
64+
tar -xzf mongodb-enterprise.tgz
65+
sudo cp mongodb-linux-x86_64-enterprise-ubuntu2204-8.0.15/bin/mongocryptd /usr/local/bin/
66+
- name: Start mongocryptd
67+
run: |
68+
nohup mongocryptd --logpath=/tmp/mongocryptd.log &
69+
- name: Verify MongoDB installation
70+
run: |
71+
mongosh --eval 'db.runCommand({ connectionStatus: 1 })'
72+
- name: Verify mongocryptd is running
73+
run: |
74+
pgrep mongocryptd
5575
- name: Run tests
5676
run: python3 django_repo/tests/runtests_.py
5777
permissions:
5878
contents: read
79+
env:
80+
DJANGO_SETTINGS_MODULE: "encrypted_settings"

django_mongodb_backend/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
from .indexes import register_indexes # noqa: E402
1515
from .lookups import register_lookups # noqa: E402
1616
from .query import register_nodes # noqa: E402
17+
from .routers import register_routers # noqa: E402
1718

1819
__all__ = ["parse_uri"]
1920

@@ -25,3 +26,4 @@
2526
register_indexes()
2627
register_lookups()
2728
register_nodes()
29+
register_routers()

django_mongodb_backend/base.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
from django.utils.functional import cached_property
1212
from pymongo.collection import Collection
1313
from pymongo.driver_info import DriverInfo
14+
from pymongo.encryption import ClientEncryption
1415
from pymongo.mongo_client import MongoClient
1516
from pymongo.uri_parser import parse_uri
1617

@@ -241,6 +242,16 @@ def get_database(self):
241242
return OperationDebugWrapper(self)
242243
return self.database
243244

245+
@cached_property
246+
def client_encryption(self):
247+
auto_encryption_opts = self.connection._options.auto_encryption_opts
248+
return ClientEncryption(
249+
auto_encryption_opts._kms_providers,
250+
auto_encryption_opts._key_vault_namespace,
251+
self.connection,
252+
self.connection.codec_options,
253+
)
254+
244255
@cached_property
245256
def database(self):
246257
"""Connect to the database the first time it's accessed."""

django_mongodb_backend/creation.py

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,20 @@
11
from django.conf import settings
2-
from django.db.backends.base.creation import BaseDatabaseCreation
2+
from django.db.backends.base.creation import TEST_DATABASE_PREFIX, BaseDatabaseCreation
33

44

55
class DatabaseCreation(BaseDatabaseCreation):
66
def _execute_create_test_db(self, cursor, parameters, keepdb=False):
77
# Close the connection (which may point to the non-test database) so
88
# that a new connection to the test database can be established later.
99
self.connection.close_pool()
10+
# Use a test _key_vault_namespace. This assumes the key vault database
11+
# is the same as the encrypted database so that _destroy_test_db() can
12+
# reset the collection by dropping it.
13+
opts = self.connection.settings_dict["OPTIONS"].get("auto_encryption_opts")
14+
if opts:
15+
self.connection.settings_dict["OPTIONS"][
16+
"auto_encryption_opts"
17+
]._key_vault_namespace = TEST_DATABASE_PREFIX + opts._key_vault_namespace
1018
if not keepdb:
1119
self._destroy_test_db(parameters["dbname"], verbosity=0)
1220

@@ -24,3 +32,9 @@ def destroy_test_db(self, old_database_name=None, verbosity=1, keepdb=False, suf
2432
super().destroy_test_db(old_database_name, verbosity, keepdb, suffix)
2533
# Close the connection to the test database.
2634
self.connection.close_pool()
35+
# Restore the original _key_vault_namespace.
36+
opts = self.connection.settings_dict["OPTIONS"].get("auto_encryption_opts")
37+
if opts:
38+
self.connection.settings_dict["OPTIONS"][
39+
"auto_encryption_opts"
40+
]._key_vault_namespace = opts._key_vault_namespace[len(TEST_DATABASE_PREFIX) :]

django_mongodb_backend/features.py

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -588,9 +588,21 @@ def django_test_skips(self):
588588
skips.update(self._django_test_skips)
589589
return skips
590590

591+
@cached_property
592+
def mongodb_version(self):
593+
return self.connection.get_database_version() # e.g., (6, 3, 0)
594+
591595
@cached_property
592596
def is_mongodb_6_3(self):
593-
return self.connection.get_database_version() >= (6, 3)
597+
return self.mongodb_version >= (6, 3)
598+
599+
@cached_property
600+
def is_mongodb_7_0(self):
601+
return self.mongodb_version >= (7, 0)
602+
603+
@cached_property
604+
def is_mongodb_8_0(self):
605+
return self.mongodb_version >= (8, 0)
594606

595607
@cached_property
596608
def supports_atlas_search(self):
@@ -620,3 +632,18 @@ def _supports_transactions(self):
620632
hello = client.command("hello")
621633
# a replica set or a sharded cluster
622634
return "setName" in hello or hello.get("msg") == "isdbgrid"
635+
636+
@cached_property
637+
def supports_queryable_encryption(self):
638+
"""
639+
Queryable Encryption requires a MongoDB 8.0 or later replica set or sharded
640+
cluster, as well as MongoDB Atlas or Enterprise.
641+
"""
642+
self.connection.ensure_connection()
643+
build_info = self.connection.connection.admin.command("buildInfo")
644+
is_enterprise = "enterprise" in build_info.get("modules")
645+
return (
646+
(is_enterprise or self.supports_atlas_search)
647+
and self._supports_transactions
648+
and self.is_mongodb_8_0
649+
)

django_mongodb_backend/fields/__init__.py

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,33 @@
33
from .duration import register_duration_field
44
from .embedded_model import EmbeddedModelField
55
from .embedded_model_array import EmbeddedModelArrayField
6+
from .encryption import (
7+
EncryptedArrayField,
8+
EncryptedBigIntegerField,
9+
EncryptedBinaryField,
10+
EncryptedBooleanField,
11+
EncryptedCharField,
12+
EncryptedDateField,
13+
EncryptedDateTimeField,
14+
EncryptedDecimalField,
15+
EncryptedDurationField,
16+
EncryptedEmailField,
17+
EncryptedEmbeddedModelArrayField,
18+
EncryptedEmbeddedModelField,
19+
EncryptedFieldMixin,
20+
EncryptedFloatField,
21+
EncryptedGenericIPAddressField,
22+
EncryptedIntegerField,
23+
EncryptedObjectIdField,
24+
EncryptedPositiveBigIntegerField,
25+
EncryptedPositiveIntegerField,
26+
EncryptedPositiveSmallIntegerField,
27+
EncryptedSmallIntegerField,
28+
EncryptedTextField,
29+
EncryptedTimeField,
30+
EncryptedURLField,
31+
EncryptedUUIDField,
32+
)
633
from .json import register_json_field
734
from .objectid import ObjectIdField
835
from .polymorphic_embedded_model import PolymorphicEmbeddedModelField
@@ -12,6 +39,31 @@
1239
"ArrayField",
1340
"EmbeddedModelArrayField",
1441
"EmbeddedModelField",
42+
"EncryptedArrayField",
43+
"EncryptedBigIntegerField",
44+
"EncryptedBinaryField",
45+
"EncryptedBooleanField",
46+
"EncryptedCharField",
47+
"EncryptedDateField",
48+
"EncryptedDateTimeField",
49+
"EncryptedDecimalField",
50+
"EncryptedDurationField",
51+
"EncryptedEmailField",
52+
"EncryptedEmbeddedModelArrayField",
53+
"EncryptedEmbeddedModelField",
54+
"EncryptedFieldMixin",
55+
"EncryptedFloatField",
56+
"EncryptedGenericIPAddressField",
57+
"EncryptedIntegerField",
58+
"EncryptedObjectIdField",
59+
"EncryptedPositiveBigIntegerField",
60+
"EncryptedPositiveIntegerField",
61+
"EncryptedPositiveSmallIntegerField",
62+
"EncryptedSmallIntegerField",
63+
"EncryptedTextField",
64+
"EncryptedTimeField",
65+
"EncryptedURLField",
66+
"EncryptedUUIDField",
1567
"ObjectIdAutoField",
1668
"ObjectIdField",
1769
"PolymorphicEmbeddedModelArrayField",
Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,130 @@
1+
from django.db import models
2+
3+
from django_mongodb_backend.fields import ArrayField, EmbeddedModelArrayField, EmbeddedModelField
4+
from django_mongodb_backend.fields.objectid import ObjectIdField
5+
6+
7+
class EncryptedFieldMixin:
8+
encrypted = True
9+
10+
def __init__(self, *args, queries=None, db_index=False, null=False, unique=False, **kwargs):
11+
if db_index:
12+
raise ValueError("'db_index=True' is not supported on encrypted fields.")
13+
if null:
14+
raise ValueError("'null=True' is not supported on encrypted fields.")
15+
if unique:
16+
raise ValueError("'unique=True' is not supported on encrypted fields.")
17+
self.queries = queries
18+
super().__init__(*args, **kwargs)
19+
20+
def deconstruct(self):
21+
name, path, args, kwargs = super().deconstruct()
22+
23+
if self.queries is not None:
24+
kwargs["queries"] = self.queries
25+
26+
if path.startswith("django_mongodb_backend.fields.encryption"):
27+
path = path.replace(
28+
"django_mongodb_backend.fields.encryption",
29+
"django_mongodb_backend.fields",
30+
)
31+
32+
return name, path, args, kwargs
33+
34+
35+
# Django fields
36+
class EncryptedBinaryField(EncryptedFieldMixin, models.BinaryField):
37+
pass
38+
39+
40+
class EncryptedBigIntegerField(EncryptedFieldMixin, models.BigIntegerField):
41+
pass
42+
43+
44+
class EncryptedBooleanField(EncryptedFieldMixin, models.BooleanField):
45+
pass
46+
47+
48+
class EncryptedCharField(EncryptedFieldMixin, models.CharField):
49+
pass
50+
51+
52+
class EncryptedDateField(EncryptedFieldMixin, models.DateField):
53+
pass
54+
55+
56+
class EncryptedDateTimeField(EncryptedFieldMixin, models.DateTimeField):
57+
pass
58+
59+
60+
class EncryptedDecimalField(EncryptedFieldMixin, models.DecimalField):
61+
pass
62+
63+
64+
class EncryptedDurationField(EncryptedFieldMixin, models.DurationField):
65+
pass
66+
67+
68+
class EncryptedEmailField(EncryptedFieldMixin, models.EmailField):
69+
pass
70+
71+
72+
class EncryptedFloatField(EncryptedFieldMixin, models.FloatField):
73+
pass
74+
75+
76+
class EncryptedGenericIPAddressField(EncryptedFieldMixin, models.GenericIPAddressField):
77+
pass
78+
79+
80+
class EncryptedIntegerField(EncryptedFieldMixin, models.IntegerField):
81+
pass
82+
83+
84+
class EncryptedPositiveBigIntegerField(EncryptedFieldMixin, models.PositiveBigIntegerField):
85+
pass
86+
87+
88+
class EncryptedPositiveIntegerField(EncryptedFieldMixin, models.PositiveIntegerField):
89+
pass
90+
91+
92+
class EncryptedPositiveSmallIntegerField(EncryptedFieldMixin, models.PositiveSmallIntegerField):
93+
pass
94+
95+
96+
class EncryptedSmallIntegerField(EncryptedFieldMixin, models.SmallIntegerField):
97+
pass
98+
99+
100+
class EncryptedTextField(EncryptedFieldMixin, models.TextField):
101+
pass
102+
103+
104+
class EncryptedTimeField(EncryptedFieldMixin, models.TimeField):
105+
pass
106+
107+
108+
class EncryptedURLField(EncryptedFieldMixin, models.URLField):
109+
pass
110+
111+
112+
class EncryptedUUIDField(EncryptedFieldMixin, models.UUIDField):
113+
pass
114+
115+
116+
# MongoDB fields
117+
class EncryptedArrayField(EncryptedFieldMixin, ArrayField):
118+
pass
119+
120+
121+
class EncryptedEmbeddedModelArrayField(EncryptedFieldMixin, EmbeddedModelArrayField):
122+
pass
123+
124+
125+
class EncryptedEmbeddedModelField(EncryptedFieldMixin, EmbeddedModelField):
126+
pass
127+
128+
129+
class EncryptedObjectIdField(EncryptedFieldMixin, ObjectIdField):
130+
pass

0 commit comments

Comments
 (0)