diff --git a/.evergreen/config.yml b/.evergreen/config.yml
index d59bfe079..0a85658e5 100644
--- a/.evergreen/config.yml
+++ b/.evergreen/config.yml
@@ -48,6 +48,22 @@ functions:
args:
- ./.evergreen/run-tests.sh
+ "run encryption tests":
+ - command: ec2.assume_role
+ params:
+ role_arn: ${aws_role_arn}
+ type: test
+ - command: subprocess.exec
+ params:
+ binary: bash
+ args:
+ - .evergreen/run-encryption-tests.sh
+ working_dir: src
+ include_expansions_in_env:
+ - AWS_ACCESS_KEY_ID
+ - AWS_SECRET_ACCESS_KEY
+ - AWS_SESSION_TOKEN
+
"teardown":
- command: subprocess.exec
params:
@@ -55,6 +71,9 @@ functions:
args:
- ${DRIVERS_TOOLS}/.evergreen/teardown.sh
+
+
+
pre:
- func: setup
- func: bootstrap mongo-orchestration
@@ -66,6 +85,7 @@ tasks:
- name: run-tests
commands:
- func: "run unit tests"
+ - func: "run encryption tests"
buildvariants:
- name: tests-6-noauth-nossl
@@ -90,6 +110,28 @@ buildvariants:
tasks:
- name: run-tests
+ - name: tests-7-noauth-nossl
+ display_name: Run Tests 7.0 NoAuth NoSSL
+ run_on: rhel87-small
+ expansions:
+ MONGODB_VERSION: "7.0"
+ TOPOLOGY: server
+ AUTH: "noauth"
+ SSL: "nossl"
+ tasks:
+ - name: run-tests
+
+ - name: tests-7-auth-ssl
+ display_name: Run Tests 7.0 Auth SSL
+ run_on: rhel87-small
+ expansions:
+ MONGODB_VERSION: "7.0"
+ TOPOLOGY: server
+ AUTH: "auth"
+ SSL: "ssl"
+ tasks:
+ - name: run-tests
+
- name: tests-8-noauth-nossl
display_name: Run Tests 8.0 NoAuth NoSSL
run_on: rhel87-small
diff --git a/.evergreen/run-encryption-tests.sh b/.evergreen/run-encryption-tests.sh
new file mode 100644
index 000000000..7461c97c5
--- /dev/null
+++ b/.evergreen/run-encryption-tests.sh
@@ -0,0 +1,36 @@
+#!/usr/bin/bash
+
+set -eux
+
+# Clone drivers-tools and set var
+git clone https://github.com/mongodb-labs/drivers-evergreen-tools.git drivers_tools
+export DRIVERS_TOOLS=$(pwd)/drivers_tools
+
+git clone https://github.com/mongodb/pymongo/ pymongo_repo
+pushd pymongo_repo
+
+# Setup encryption
+just run-server --topology=replica_set
+just setup-tests encryption
+
+# Install django-mongodb-backend
+/opt/python/3.10/bin/python3 -m venv venv
+. venv/bin/activate
+python -m pip install -U pip
+pip install -e .\[aws,encryption\]
+
+# Install django and test dependencies
+git clone --branch mongodb-5.2.x https://github.com/mongodb-forks/django django_repo
+pushd django_repo/tests/
+pip install -e ..
+pip install -r requirements/py3.txt
+popd
+
+# Copy the test settings file
+cp ./.github/workflows/mongodb_settings.py django_repo/tests/
+
+# Copy the test runner file
+cp ./.github/workflows/runtests.py django_repo/tests/runtests_.py
+
+# Run tests
+python django_repo/tests/runtests_.py
diff --git a/.evergreen/setup.sh b/.evergreen/setup.sh
index 4709ed9bd..63a6913de 100644
--- a/.evergreen/setup.sh
+++ b/.evergreen/setup.sh
@@ -2,6 +2,14 @@
set -eux
+
+# On Evergreen jobs, "CI" will be set, and if "CI" is set, add
+# "/opt/python/Current/bin" to PATH to pick up `just` and `uv`.
+if [ "${CI:-}" == "true" ]; then
+ PATH_EXT="opt/python/Current/bin:\$PATH"
+
+export PATH="$PATH_EXT"
+
# Get the current unique version of this checkout
# shellcheck disable=SC2154
if [ "${is_patch}" = "true" ]; then
diff --git a/django_mongodb_backend/__init__.py b/django_mongodb_backend/__init__.py
index 00700421a..25e431406 100644
--- a/django_mongodb_backend/__init__.py
+++ b/django_mongodb_backend/__init__.py
@@ -14,6 +14,7 @@
from .indexes import register_indexes # noqa: E402
from .lookups import register_lookups # noqa: E402
from .query import register_nodes # noqa: E402
+from .routers import register_routers # noqa: E402
__all__ = ["parse_uri"]
@@ -25,3 +26,4 @@
register_indexes()
register_lookups()
register_nodes()
+register_routers()
diff --git a/django_mongodb_backend/base.py b/django_mongodb_backend/base.py
index fc21fa5b6..8ac07983b 100644
--- a/django_mongodb_backend/base.py
+++ b/django_mongodb_backend/base.py
@@ -286,4 +286,7 @@ def validate_no_broken_transaction(self):
def get_database_version(self):
"""Return a tuple of the database's version."""
- return tuple(self.connection.server_info()["versionArray"])
+ # Avoid using PyMongo to check the database version or require
+ # pymongocrypt>=1.14.2 which will contain a fix for the `buildInfo`
+ # command. https://jira.mongodb.org/browse/PYTHON-5429
+ return tuple(self.connection.admin.command("buildInfo")["versionArray"])
diff --git a/django_mongodb_backend/encryption.py b/django_mongodb_backend/encryption.py
new file mode 100644
index 000000000..b796d464f
--- /dev/null
+++ b/django_mongodb_backend/encryption.py
@@ -0,0 +1,83 @@
+# Queryable Encryption helper classes and settings
+
+import os
+
+KMS_CREDENTIALS = {
+ "aws": {
+ "key": os.getenv("AWS_KEY_ARN", ""),
+ "region": os.getenv("AWS_KEY_REGION", ""),
+ },
+ "azure": {
+ "keyName": os.getenv("AZURE_KEY_NAME", ""),
+ "keyVaultEndpoint": os.getenv("AZURE_KEY_VAULT_ENDPOINT", ""),
+ },
+ "gcp": {
+ "projectId": os.getenv("GCP_PROJECT_ID", ""),
+ "location": os.getenv("GCP_LOCATION", ""),
+ "keyRing": os.getenv("GCP_KEY_RING", ""),
+ "keyName": os.getenv("GCP_KEY_NAME", ""),
+ },
+ "kmip": {},
+ "local": {},
+}
+
+KMS_PROVIDERS = {
+ "aws": {},
+ "azure": {},
+ "gcp": {},
+ "kmip": {
+ "endpoint": os.getenv("KMIP_KMS_ENDPOINT", "not a valid endpoint"),
+ },
+ "local": {
+ "key": bytes.fromhex(
+ "000102030405060708090a0b0c0d0e0f"
+ "101112131415161718191a1b1c1d1e1f"
+ "202122232425262728292a2b2c2d2e2f"
+ "303132333435363738393a3b3c3d3e3f"
+ "404142434445464748494a4b4c4d4e4f"
+ "505152535455565758595a5b5c5d5e5f"
+ )
+ },
+}
+
+
+class EncryptedRouter:
+ def allow_migrate(self, db, app_label, model_name=None, model=None, **hints):
+ if model:
+ return db == (
+ "my_encrypted_database" if getattr(model, "encrypted", False) else "default"
+ )
+ return db == "default"
+
+ def db_for_read(self, model, **hints):
+ if getattr(model, "encrypted", False):
+ return "my_encrypted_database"
+ return "default"
+
+ db_for_write = db_for_read
+
+ def kms_provider(self, model):
+ return "local"
+
+
+class EqualityQuery(dict):
+ def __init__(self, *, contention=None):
+ super().__init__(queryType="equality")
+ if contention is not None:
+ self["contention"] = contention
+
+
+class RangeQuery(dict):
+ def __init__(
+ self, *, contention=None, max=None, min=None, precision=None, sparsity=None, trimFactor=None
+ ):
+ super().__init__(queryType="range")
+ options = {
+ "contention": contention,
+ "max": max,
+ "min": min,
+ "precision": precision,
+ "sparsity": sparsity,
+ "trimFactor": trimFactor,
+ }
+ self.update({k: v for k, v in options.items() if v is not None})
diff --git a/django_mongodb_backend/features.py b/django_mongodb_backend/features.py
index 3e9cc2922..6c92635d3 100644
--- a/django_mongodb_backend/features.py
+++ b/django_mongodb_backend/features.py
@@ -588,9 +588,17 @@ def django_test_expected_failures(self):
},
}
+ @cached_property
+ def mongodb_version(self):
+ return self.connection.get_database_version() # e.g., (6, 3, 0)
+
@cached_property
def is_mongodb_6_3(self):
- return self.connection.get_database_version() >= (6, 3)
+ return self.mongodb_version >= (6, 3)
+
+ @cached_property
+ def is_mongodb_7_0(self):
+ return self.mongodb_version >= (7, 0)
@cached_property
def supports_atlas_search(self):
@@ -624,3 +632,20 @@ def supports_transactions(self):
hello = client.command("hello")
# a replica set or a sharded cluster
return "setName" in hello or hello.get("msg") == "isdbgrid"
+
+ @cached_property
+ def supports_queryable_encryption(self):
+ """
+ Queryable Encryption is supported if the server is Atlas or Enterprise
+ and is configured as a replica set or a sharded cluster.
+ """
+ self.connection.ensure_connection()
+ client = self.connection.connection.admin
+ build_info = client.command("buildInfo")
+ is_enterprise = "enterprise" in build_info.get("modules")
+ # Queryable Encryption requires transaction support which
+ # is only available on replica sets or sharded clusters
+ # which we already check in `supports_transactions`.
+ supports_transactions = self.supports_transactions
+ # TODO: check if the server is Atlas
+ return is_enterprise and supports_transactions and self.is_mongodb_7_0
diff --git a/django_mongodb_backend/fields/__init__.py b/django_mongodb_backend/fields/__init__.py
index be95fa5ea..6169b1735 100644
--- a/django_mongodb_backend/fields/__init__.py
+++ b/django_mongodb_backend/fields/__init__.py
@@ -3,6 +3,23 @@
from .duration import register_duration_field
from .embedded_model import EmbeddedModelField
from .embedded_model_array import EmbeddedModelArrayField
+from .encrypted_model import (
+ EncryptedBigIntegerField,
+ EncryptedBinaryField,
+ EncryptedBooleanField,
+ EncryptedCharField,
+ EncryptedDateField,
+ EncryptedDateTimeField,
+ EncryptedDecimalField,
+ EncryptedEmailField,
+ EncryptedFieldMixin,
+ EncryptedFloatField,
+ EncryptedGenericIPAddressField,
+ EncryptedIntegerField,
+ EncryptedTextField,
+ EncryptedTimeField,
+ EncryptedURLField,
+)
from .json import register_json_field
from .objectid import ObjectIdField
@@ -11,6 +28,21 @@
"ArrayField",
"EmbeddedModelArrayField",
"EmbeddedModelField",
+ "EncryptedBigIntegerField",
+ "EncryptedBinaryField",
+ "EncryptedBooleanField",
+ "EncryptedCharField",
+ "EncryptedDateTimeField",
+ "EncryptedDateField",
+ "EncryptedDecimalField",
+ "EncryptedEmailField",
+ "EncryptedFieldMixin",
+ "EncryptedFloatField",
+ "EncryptedGenericIPAddressField",
+ "EncryptedIntegerField",
+ "EncryptedTextField",
+ "EncryptedTimeField",
+ "EncryptedURLField",
"ObjectIdAutoField",
"ObjectIdField",
]
diff --git a/django_mongodb_backend/fields/encrypted_model.py b/django_mongodb_backend/fields/encrypted_model.py
new file mode 100644
index 000000000..b1493d892
--- /dev/null
+++ b/django_mongodb_backend/fields/encrypted_model.py
@@ -0,0 +1,90 @@
+from django.db import models
+
+
+class EncryptedFieldMixin(models.Field):
+ encrypted = True
+
+ def __init__(self, *args, queries=None, **kwargs):
+ self.queries = queries
+ super().__init__(*args, **kwargs)
+
+ def deconstruct(self):
+ name, path, args, kwargs = super().deconstruct()
+
+ if self.queries is not None:
+ kwargs["queries"] = self.queries
+
+ if path.startswith("django_mongodb_backend.fields.encrypted_model"):
+ path = path.replace(
+ "django_mongodb_backend.fields.encrypted_model",
+ "django_mongodb_backend.fields",
+ )
+
+ return name, path, args, kwargs
+
+
+class EncryptedBigIntegerField(EncryptedFieldMixin, models.BigIntegerField):
+ pass
+
+
+class EncryptedBinaryField(EncryptedFieldMixin, models.BinaryField):
+ pass
+
+
+class EncryptedBooleanField(EncryptedFieldMixin, models.BooleanField):
+ pass
+
+
+class EncryptedCharField(EncryptedFieldMixin, models.CharField):
+ pass
+
+
+class EncryptedDateField(EncryptedFieldMixin, models.DateField):
+ pass
+
+
+class EncryptedDateTimeField(EncryptedFieldMixin, models.DateTimeField):
+ pass
+
+
+class EncryptedDecimalField(EncryptedFieldMixin, models.DecimalField):
+ pass
+
+
+class EncryptedEmailField(EncryptedFieldMixin, models.EmailField):
+ pass
+
+
+class EncryptedFloatField(EncryptedFieldMixin, models.FloatField):
+ pass
+
+
+class EncryptedGenericIPAddressField(EncryptedFieldMixin, models.GenericIPAddressField):
+ pass
+
+
+class EncryptedIntegerField(EncryptedFieldMixin, models.IntegerField):
+ pass
+
+
+class EncryptedSlugField(EncryptedFieldMixin, models.SlugField):
+ pass
+
+
+class EncryptedTimeField(EncryptedFieldMixin, models.TimeField):
+ pass
+
+
+class EncryptedTextField(EncryptedFieldMixin, models.TextField):
+ pass
+
+
+class EncryptedURLField(EncryptedFieldMixin, models.URLField):
+ pass
+
+
+# TODO: Add more encrypted fields
+# - PositiveBigIntegerField
+# - PositiveIntegerField
+# - PositiveSmallIntegerField
+# - SmallIntegerField
diff --git a/django_mongodb_backend/management/commands/get_encrypted_fields_map.py b/django_mongodb_backend/management/commands/get_encrypted_fields_map.py
new file mode 100644
index 000000000..4ac26a699
--- /dev/null
+++ b/django_mongodb_backend/management/commands/get_encrypted_fields_map.py
@@ -0,0 +1,59 @@
+from bson import json_util
+from django.apps import apps
+from django.core.management.base import BaseCommand
+from django.db import DEFAULT_DB_ALIAS, connections, router
+from pymongo.encryption import ClientEncryption
+
+
+class Command(BaseCommand):
+ help = "Generate a `schema_map` of encrypted fields for all encrypted"
+ " models in the database for use with `AutoEncryptionOpts` in"
+ " client configuration."
+
+ def add_arguments(self, parser):
+ parser.add_argument(
+ "--database",
+ default=DEFAULT_DB_ALIAS,
+ help="Specify the database to use for generating the encrypted"
+ "fields map. Defaults to the 'default' database.",
+ )
+ parser.add_argument(
+ "--kms-provider",
+ default="local",
+ help="Specify the KMS provider to use for encryption. Defaults to 'local'.",
+ )
+
+ def handle(self, *args, **options):
+ db = options["database"]
+ kms_provider = options["kms_provider"]
+ connection = connections[db]
+ schema_map = json_util.dumps(
+ self.get_encrypted_fields_map(connection, kms_provider), indent=2
+ )
+ self.stdout.write(schema_map)
+
+ def get_client_encryption(self, connection):
+ client = connection.connection
+ options = client._options.auto_encryption_opts
+ key_vault_namespace = options._key_vault_namespace
+ kms_providers = options._kms_providers
+ return ClientEncryption(kms_providers, key_vault_namespace, client, client.codec_options)
+
+ def get_encrypted_fields_map(self, connection, kms_provider):
+ schema_map = {}
+ for app_config in apps.get_app_configs():
+ for model in router.get_migratable_models(
+ app_config, connection.settings_dict["NAME"], include_auto_created=False
+ ):
+ if getattr(model, "encrypted", False):
+ fields = connection.schema_editor()._get_encrypted_fields_map(model)
+ ce = self.get_client_encryption(connection)
+ master_key = connection.settings_dict.get("KMS_CREDENTIALS").get(kms_provider)
+ for field in fields["fields"]:
+ data_key = ce.create_data_key(
+ kms_provider=kms_provider,
+ master_key=master_key,
+ )
+ field["keyId"] = data_key
+ schema_map[model._meta.db_table] = fields
+ return schema_map
diff --git a/django_mongodb_backend/models.py b/django_mongodb_backend/models.py
index adeba21e5..6dfb7f0f0 100644
--- a/django_mongodb_backend/models.py
+++ b/django_mongodb_backend/models.py
@@ -14,3 +14,10 @@ def delete(self, *args, **kwargs):
def save(self, *args, **kwargs):
raise NotSupportedError("EmbeddedModels cannot be saved.")
+
+
+class EncryptedModel(models.Model):
+ encrypted = True
+
+ class Meta:
+ abstract = True
diff --git a/django_mongodb_backend/routers.py b/django_mongodb_backend/routers.py
index 60e54bbd8..ae8aabc35 100644
--- a/django_mongodb_backend/routers.py
+++ b/django_mongodb_backend/routers.py
@@ -1,6 +1,6 @@
from django.apps import apps
-
-from django_mongodb_backend.models import EmbeddedModel
+from django.core.exceptions import ImproperlyConfigured
+from django.db.utils import ConnectionRouter
class MongoRouter:
@@ -9,10 +9,34 @@ def allow_migrate(self, db, app_label, model_name=None, **hints):
EmbeddedModels don't have their own collection and must be ignored by
dumpdata.
"""
+
if not model_name:
return None
try:
model = apps.get_model(app_label, model_name)
except LookupError:
return None
+
+ # Delay import for `register_routers` patching.
+ from django_mongodb_backend.models import EmbeddedModel
+
return False if issubclass(model, EmbeddedModel) else None
+
+
+def kms_provider(self, model, *args, **kwargs):
+ for router in self.routers:
+ func = getattr(router, "kms_provider", None)
+ if func and callable(func):
+ result = func(model, *args, **kwargs)
+ if result is not None:
+ return result
+ if getattr(model, "encrypted", False):
+ raise ImproperlyConfigured("No kms_provider found in database router.")
+ return None
+
+
+def register_routers():
+ """
+ Patch the ConnectionRouter to use the custom kms_provider method.
+ """
+ ConnectionRouter.kms_provider = kms_provider
diff --git a/django_mongodb_backend/schema.py b/django_mongodb_backend/schema.py
index da3ec9613..02b362c6c 100644
--- a/django_mongodb_backend/schema.py
+++ b/django_mongodb_backend/schema.py
@@ -1,10 +1,11 @@
+from django.db import router
from django.db.backends.base.schema import BaseDatabaseSchemaEditor
from django.db.models import Index, UniqueConstraint
+from pymongo.encryption import ClientEncryption
from pymongo.operations import SearchIndexModel
-from django_mongodb_backend.indexes import SearchIndex
-
from .fields import EmbeddedModelField
+from .indexes import SearchIndex
from .query import wrap_database_errors
from .utils import OperationCollector
@@ -41,7 +42,7 @@ def get_database(self):
@wrap_database_errors
@ignore_embedded_models
def create_model(self, model):
- self.get_database().create_collection(model._meta.db_table)
+ self._create_collection(model)
self._create_model_indexes(model)
# Make implicit M2M tables.
for field in model._meta.local_many_to_many:
@@ -418,3 +419,55 @@ def _field_should_have_unique(self, field):
db_type = field.db_type(self.connection)
# The _id column is automatically unique.
return db_type and field.unique and field.column != "_id"
+
+ def _create_collection(self, model):
+ """
+ Create a collection for the model, with encryption if the model has an
+ `encrypted` attribute set to True.
+
+ If provided, use the `_schema_map` in the client's
+ `auto_encryption_opts`. Otherwise, create the encrypted fields map
+ with `_get_encrypted_fields_map`.
+ """
+ db = self.get_database()
+ client = self.connection.connection
+ options = client._options.auto_encryption_opts
+ db_table = model._meta.db_table
+ if getattr(model, "encrypted", False):
+ schema_map = options._schema_map
+ if schema_map:
+ db.create_collection(db_table, encryptedFields=schema_map[db_table])
+ else:
+ key_vault_namespace = options._key_vault_namespace
+ kms_providers = options._kms_providers
+ ce = ClientEncryption(
+ kms_providers, key_vault_namespace, client, client.codec_options
+ )
+ encrypted_fields_map = self._get_encrypted_fields_map(model)
+ provider = router.kms_provider(model)
+ credentials = self.connection.settings_dict.get("KMS_CREDENTIALS").get(provider)
+ ce.create_encrypted_collection(
+ db,
+ db_table,
+ encrypted_fields_map,
+ provider,
+ credentials,
+ )
+ else:
+ db.create_collection(db_table)
+
+ def _get_encrypted_fields_map(self, model):
+ connection = self.connection
+ fields = model._meta.fields
+
+ return {
+ "fields": [
+ {
+ "bsonType": field.db_type(connection),
+ "path": field.column,
+ **({"queries": field.queries} if getattr(field, "queries", None) else {}),
+ }
+ for field in fields
+ if getattr(field, "encrypted", False)
+ ]
+ }
diff --git a/docs/source/conf.py b/docs/source/conf.py
index 2f1c8675a..a95656167 100644
--- a/docs/source/conf.py
+++ b/docs/source/conf.py
@@ -48,8 +48,6 @@
"manual": ("https://www.mongodb.com/docs/manual/", None),
}
-root_doc = "contents"
-
# -- Options for HTML output -------------------------------------------------
# https://www.sphinx-doc.org/en/master/usage/configuration.html#options-for-html-output
diff --git a/docs/source/contents.rst b/docs/source/contents.rst
index 6a102569f..2def4b8a9 100644
--- a/docs/source/contents.rst
+++ b/docs/source/contents.rst
@@ -1,11 +1,10 @@
+:orphan:
+
=================
Table of contents
=================
-.. toctree::
- :hidden:
-
- index
+.. Keep this toctree in sync with the one at the bottom of index.rst.
.. toctree::
:maxdepth: 2
diff --git a/docs/source/howto/encryption.rst b/docs/source/howto/encryption.rst
new file mode 100644
index 000000000..8f0b12a94
--- /dev/null
+++ b/docs/source/howto/encryption.rst
@@ -0,0 +1,76 @@
+================================
+Configuring Queryable Encryption
+================================
+
+Configuring Queryable Encryption in Django is similar to
+`configuring Queryable Encryption in Python `_
+but with some additional steps to integrate with Django's operations. Below
+are the steps needed to set up Queryable Encryption in a Django project.
+
+Prerequisites
+-------------
+
+.. note:: You can use Queryable Encryption on a MongoDB 7.0 or later replica
+ set or sharded cluster, but not a standalone instance.
+ `This table `_
+ shows which MongoDB server products support which Queryable Encryption mechanisms.
+
+In addition to :doc:`installing ` and
+:doc:`configuring ` Django MongoDB Backend,
+you will need to install PyMongo with Queryable Encryption support::
+
+ pip install pymongo[aws,encryption]
+
+Settings
+--------
+
+Add an encrypted database, encrypted database router and KMS credentials to
+your Django settings.
+
+.. note:: Use of the helpers provided in ``django_mongodb_backend.encryption``
+ requires an encrypted database named "my_encrypted_database".
+
+::
+
+ from django_mongodb_backend import encryption
+ from pymongo.encryption import AutoEncryptionOpts
+
+ DATABASES = {
+ "default": parse_uri(
+ MONGODB_URI,
+ db_name="my_database",
+ ),
+ "my_encrypted_database": parse_uri(
+ MONGODB_URI,
+ db_name="my_encrypted_database",
+ options={
+ "auto_encryption_opts": AutoEncryptionOpts(
+ kms_providers=encryption.KMS_PROVIDERS,
+ key_vault_namespace="my_encrypted_database.keyvault",
+ )
+ },
+ ),
+
+ DATABASES["my_encrypted_database"]["KMS_CREDENTIALS"] = encryption.KMS_CREDENTIALS
+ DATABASE_ROUTERS = [encryption.EncryptedRouter()]
+
+You are now ready to use :doc:`encrypted models ` in your Django project.
+
+
+Helper classes and settings
+===========================
+
+``KMS_CREDENTIALS``
+-------------------
+
+``KMS_PROVIDERS``
+-----------------
+
+``EncryptedRouter``
+-------------------
+
+Query Types
+-----------
+
+- ``EqualityQuery``
+- ``RangeQuery``
diff --git a/docs/source/howto/index.rst b/docs/source/howto/index.rst
index 95d7ef632..65090fdda 100644
--- a/docs/source/howto/index.rst
+++ b/docs/source/howto/index.rst
@@ -11,3 +11,4 @@ Project configuration
:maxdepth: 1
contrib-apps
+ encryption
diff --git a/docs/source/index.rst b/docs/source/index.rst
index dfc1a2ad2..d9490bd0c 100644
--- a/docs/source/index.rst
+++ b/docs/source/index.rst
@@ -45,6 +45,7 @@ Models
**Topic guides:**
- :doc:`topics/embedded-models`
+- :doc:`topics/encrypted-models`
Forms
=====
@@ -61,3 +62,17 @@ Miscellaneous
- :doc:`releases/index`
- :doc:`internals`
+
+.. Keep this toctree in sync with contents.rst.
+
+.. toctree::
+ :hidden:
+ :maxdepth: 2
+
+ intro/index
+ topics/index
+ ref/index
+ howto/index
+ faq
+ releases/index
+ internals
diff --git a/docs/source/intro/configure.rst b/docs/source/intro/configure.rst
index 831cc137e..4fa6c8aa3 100644
--- a/docs/source/intro/configure.rst
+++ b/docs/source/intro/configure.rst
@@ -159,6 +159,9 @@ This constructs a :setting:`DATABASES` setting equivalent to the first example.
Configuring the ``DATABASE_ROUTERS`` setting
============================================
+Embedded models
+---------------
+
If you intend to use :doc:`embedded models `, you must
configure the :setting:`DATABASE_ROUTERS` setting so that a collection for
these models isn't created and so that embedded models won't be treated as
@@ -169,6 +172,21 @@ normal models by :djadmin:`dumpdata`::
(If you've used the :djadmin:`startproject` template, this line is already
present.)
+Queryable Encryption
+--------------------
+
+If you intend to use :doc:`encrypted models `, you may
+optionally configure the :setting:`DATABASE_ROUTERS` setting so that collections
+for encrypted models are created in an encrypted database.
+
+`Router configuration `__
+is unique to each project and beyond the scope of Django MongoDB Backend, but an
+example is included in the :doc:`encryption helpers `
+that routes collection operations for encrypted models to a database named
+"my_encrypted_database"::
+
+ DATABASE_ROUTERS = ["django_mongodb_backend.encryption.EncryptedRouter"]
+
Congratulations, your project is ready to go!
.. seealso::
diff --git a/docs/source/ref/django-admin.rst b/docs/source/ref/django-admin.rst
index 93f90f9f6..a203ccc15 100644
--- a/docs/source/ref/django-admin.rst
+++ b/docs/source/ref/django-admin.rst
@@ -26,3 +26,19 @@ Available commands
Specifies the database in which the cache collection(s) will be created.
Defaults to ``default``.
+
+
+``get_encrypted_fields_map``
+----------------------------
+
+.. django-admin:: get_encrypted_fields_map
+
+ Creates a schema map for the encrypted fields in your encrypted models. This
+ map can be provided to
+ :class:`~pymongo.encryption_options.AutoEncryptionOpts` for use with
+ production deployments of :class:`~pymongo.encryption.ClientEncryption`.
+
+ .. django-admin-option:: --database DATABASE
+
+ Specifies the database to use to generate an encrypted fields map
+ for all encrypted models. Defaults to ``default``.
diff --git a/docs/source/ref/models/fields.rst b/docs/source/ref/models/fields.rst
index 79cafe3d4..038fe660f 100644
--- a/docs/source/ref/models/fields.rst
+++ b/docs/source/ref/models/fields.rst
@@ -299,6 +299,7 @@ These indexes use 0-based indexing.
As described above for :class:`EmbeddedModelField`,
:djadmin:`makemigrations` does not yet detect changes to embedded models.
+
``ObjectIdAutoField``
---------------------
@@ -313,3 +314,51 @@ These indexes use 0-based indexing.
.. class:: ObjectIdField
Stores an :class:`~bson.objectid.ObjectId`.
+
+Encrypted fields
+----------------
+
+Encrypted fields are used to store sensitive data with MongoDB's Queryable
+Encryption feature. They are subclasses of Django's built-in fields, and
+they encrypt the data before storing it in the database.
+
++---------------------------------------+---------------------------------------------------------------------------------------------------------------+
+| Encrypted Field | Django Field |
++=======================================+===============================================================================================================+
+| ``EncryptedBigIntegerField`` | `BigIntegerField `__ |
++---------------------------------------+---------------------------------------------------------------------------------------------------------------+
+| ``EncryptedBooleanField`` | `BooleanField `__ |
++---------------------------------------+---------------------------------------------------------------------------------------------------------------+
+| ``EncryptedCharField`` | `CharField `__ |
++---------------------------------------+---------------------------------------------------------------------------------------------------------------+
+| ``EncryptedDateField`` | `DateField `__ |
++---------------------------------------+---------------------------------------------------------------------------------------------------------------+
+| ``EncryptedDateTimeField`` | `DateTimeField `__ |
++---------------------------------------+---------------------------------------------------------------------------------------------------------------+
+| ``EncryptedDecimalField`` | `DecimalField `__ |
++---------------------------------------+---------------------------------------------------------------------------------------------------------------+
+| ``EncryptedFloatField`` | `FloatField `__ |
++---------------------------------------+---------------------------------------------------------------------------------------------------------------+
+| ``EncryptedGenericIPAddressField`` | `GenericIPAddressField `__ |
++---------------------------------------+---------------------------------------------------------------------------------------------------------------+
+| ``EncryptedIntegerField`` | `IntegerField `__ |
++---------------------------------------+---------------------------------------------------------------------------------------------------------------+
+| ``EncryptedTextField`` | `TextField `__ |
++---------------------------------------+---------------------------------------------------------------------------------------------------------------+
+| ``EncryptedTimeField`` | `TimeField `__ |
++---------------------------------------+---------------------------------------------------------------------------------------------------------------+
+| ``EncryptedURLField`` | `URLField `__ |
++---------------------------------------+---------------------------------------------------------------------------------------------------------------+
+
+.. _encrypted-model-unsupported-fields:
+
+Unsupported fields
+~~~~~~~~~~~~~~~~~~
+
+The following fields are supported by Django MongoDB Backend but not by Queryable Encryption.
+
++---------------------------------------+--------------------------------------------------------------------------------------------------------+
+| ``EncryptedDurationField`` | `DurationField `__ |
++---------------------------------------+--------------------------------------------------------------------------------------------------------+
+| ``EncryptedSlugField`` | `SlugField `__ |
++---------------------------------------+--------------------------------------------------------------------------------------------------------+
diff --git a/docs/source/ref/models/models.rst b/docs/source/ref/models/models.rst
index 32b5fc850..b3c5b63a2 100644
--- a/docs/source/ref/models/models.rst
+++ b/docs/source/ref/models/models.rst
@@ -3,7 +3,7 @@ Model reference
.. module:: django_mongodb_backend.models
-One MongoDB-specific model is available in ``django_mongodb_backend.models``.
+Two MongoDB-specific models are available in ``django_mongodb_backend.models``.
.. class:: EmbeddedModel
@@ -17,3 +17,8 @@ One MongoDB-specific model is available in ``django_mongodb_backend.models``.
Embedded model instances won't have a value for their primary key unless
one is explicitly set.
+
+.. class:: EncryptedModel
+
+ An abstract model which all :doc:`encrypted models `
+ must subclass.
diff --git a/docs/source/releases/5.2.x.rst b/docs/source/releases/5.2.x.rst
index e2ab337a7..7e6dcc198 100644
--- a/docs/source/releases/5.2.x.rst
+++ b/docs/source/releases/5.2.x.rst
@@ -14,6 +14,7 @@ New features
- Added the ``options`` parameter to
:func:`~django_mongodb_backend.utils.parse_uri`.
- Added support for :ref:`database transactions `.
+- Added support for Queryable Encryption.
5.2.0 beta 1
============
diff --git a/docs/source/topics/encrypted-models.rst b/docs/source/topics/encrypted-models.rst
new file mode 100644
index 000000000..420692c83
--- /dev/null
+++ b/docs/source/topics/encrypted-models.rst
@@ -0,0 +1,69 @@
+Encrypted models
+================
+
+Use :class:`~django_mongodb_backend.models.EncryptedModel` and
+:mod:`~django_mongodb_backend.fields` to structure
+your data using `Queryable Encryption `_.
+
+.. _encrypted-model-field-example:
+
+``EncryptedModelField``
+-----------------------
+
+The basics
+~~~~~~~~~~
+
+Let's consider this example::
+
+ from django.db import models
+
+ from django_mongodb_backend.models import EncryptedModel
+ from django_mongodb_backend.fields import EncryptedCharField
+ from django_mongodb_backend.encryption import EqualityQuery
+
+
+ class Patient(EncryptedModel):
+ ssn = EncryptedCharField(max_length=11, queries=EqualityQuery())
+
+ def __str__(self):
+ return self.ssn
+
+The API is similar to that of Django's relational fields, with some
+security-related changes::
+
+ >>> bob = Patient(ssn="123-45-6789")
+ >>> bob.ssn
+ '123-45-6789'
+
+Represented in BSON, from an encrypted client connection, the patient data looks like this:
+
+.. code-block:: js
+
+ {
+ _id: ObjectId('68825b066fac55353a8b2b41'),
+ ssn: '123-45-6789',
+ __safeContent__: [b'\xe0)NOFB\x9a,\x08\xd7\xdd\xb8\xa6\xba$…']
+ }
+
+The ``ssn`` field is only visible from an encrypted client connection. From an unencrypted client connection,
+the patient data looks like this:
+
+.. code-block:: js
+
+ {
+ _id: ObjectId('6882566c586a440cd0649e8f'),
+ ssn: Binary.createFromBase64('DkrbD67ejkt2u…', 6),
+ }
+
+Querying Encrypted Models
+~~~~~~~~~~~~~~~~~~~~~~~~~
+
+You can query encrypted fields in encrypted models using a `limited set of
+query operators `_
+which must be specified in the field definition. For example, to query the ``ssn`` field for equality, you can use the
+``EqualityQuery`` operator as shown in the example above.
+
+ >>> Patient.objects.get(ssn="123-45-6789").ssn
+ '123-45-6789'
+
+If the ``ssn`` field provided in the query matches the encrypted value in the database, the query will succeed.
diff --git a/docs/source/topics/index.rst b/docs/source/topics/index.rst
index 47e0c6dc0..285fd7180 100644
--- a/docs/source/topics/index.rst
+++ b/docs/source/topics/index.rst
@@ -10,4 +10,5 @@ know:
cache
embedded-models
+ encrypted-models
known-issues
diff --git a/docs/source/topics/known-issues.rst b/docs/source/topics/known-issues.rst
index 60628f816..ce19ca485 100644
--- a/docs/source/topics/known-issues.rst
+++ b/docs/source/topics/known-issues.rst
@@ -98,3 +98,12 @@ Caching
Secondly, you must use the :class:`django_mongodb_backend.cache.MongoDBCache`
backend rather than Django's built-in database cache backend,
``django.core.cache.backends.db.DatabaseCache``.
+
+Queryable Encryption
+====================
+
+Consider these
+`limitations and restrictions `_
+before enabling Queryable Encryption. Some operations are unsupported, and others behave differently.
+
+Also see :ref:`unsupported fields `.
diff --git a/tests/backend_/test_features.py b/tests/backend_/test_features.py
index 504d3a3fd..bafa1aa28 100644
--- a/tests/backend_/test_features.py
+++ b/tests/backend_/test_features.py
@@ -44,3 +44,21 @@ def mocked_command(command):
with patch("pymongo.synchronous.database.Database.command", wraps=mocked_command):
self.assertIs(connection.features.supports_transactions, False)
+
+
+class SupportsQueryableEncryptionTests(TestCase):
+ def setUp(self):
+ # Clear the cached property.
+ connection.features.__dict__.pop("supports_queryable_encryption", None)
+
+ def tearDown(self):
+ del connection.features.supports_queryable_encryption
+
+ def test_supports_queryable_encryption(self):
+ def mocked_command(command):
+ if command == "buildInfo":
+ return {"modules": ["enterprise"]}
+ raise Exception("Unexpected command")
+
+ with patch("pymongo.synchronous.database.Database.command", wraps=mocked_command):
+ self.assertIs(connection.features.supports_queryable_encryption, True)
diff --git a/tests/encryption_/__init__.py b/tests/encryption_/__init__.py
new file mode 100644
index 000000000..e69de29bb
diff --git a/tests/encryption_/models.py b/tests/encryption_/models.py
new file mode 100644
index 000000000..4d7ce42af
--- /dev/null
+++ b/tests/encryption_/models.py
@@ -0,0 +1,65 @@
+from django_mongodb_backend.encryption import EqualityQuery, RangeQuery
+from django_mongodb_backend.fields import (
+ EncryptedBigIntegerField,
+ EncryptedBinaryField,
+ EncryptedBooleanField,
+ EncryptedCharField,
+ EncryptedDateField,
+ EncryptedDateTimeField,
+ EncryptedDecimalField,
+ EncryptedEmailField,
+ EncryptedFloatField,
+ EncryptedGenericIPAddressField,
+ EncryptedIntegerField,
+ EncryptedTextField,
+ EncryptedTimeField,
+ EncryptedURLField,
+)
+from django_mongodb_backend.models import EncryptedModel
+
+
+class Appointment(EncryptedModel):
+ time = EncryptedTimeField(queries=EqualityQuery())
+
+
+class Billing(EncryptedModel):
+ cc_type = EncryptedCharField(max_length=20, queries=EqualityQuery())
+ cc_number = EncryptedBigIntegerField(queries=EqualityQuery())
+ account_balance = EncryptedDecimalField(max_digits=10, decimal_places=2, queries=RangeQuery())
+
+ class Meta:
+ db_table = "billing"
+
+
+class PatientPortalUser(EncryptedModel):
+ ip_address = EncryptedGenericIPAddressField(queries=EqualityQuery())
+ url = EncryptedURLField(queries=EqualityQuery())
+
+
+class PatientRecord(EncryptedModel):
+ ssn = EncryptedCharField(max_length=11, queries=EqualityQuery())
+ birth_date = EncryptedDateField(queries=RangeQuery())
+ profile_picture = EncryptedBinaryField(queries=EqualityQuery())
+ patient_age = EncryptedIntegerField("patient_age", queries=RangeQuery())
+ weight = EncryptedFloatField(queries=RangeQuery())
+
+ # TODO: Embed Billing model
+ # billing =
+
+ class Meta:
+ db_table = "patientrecord"
+
+
+class Patient(EncryptedModel):
+ patient_id = EncryptedIntegerField("patient_id", queries=EqualityQuery())
+ patient_name = EncryptedCharField(max_length=100)
+ patient_notes = EncryptedTextField(queries=EqualityQuery())
+ registration_date = EncryptedDateTimeField(queries=EqualityQuery())
+ is_active = EncryptedBooleanField(queries=EqualityQuery())
+ email = EncryptedEmailField(max_length=254, queries=EqualityQuery())
+
+ # TODO: Embed PatientRecord model
+ # patient_record =
+
+ class Meta:
+ db_table = "patient"
diff --git a/tests/encryption_/routers.py b/tests/encryption_/routers.py
new file mode 100644
index 000000000..2277e0e47
--- /dev/null
+++ b/tests/encryption_/routers.py
@@ -0,0 +1,16 @@
+class TestEncryptedRouter:
+ """Router for testing encrypted models in Django. `kms_provider`
+ must be set on the global test router since table creation happens
+ at the start of the test suite, before @override_settings(
+ DATABASE_ROUTERS=[TestEncryptedRouter()]) takes effect.
+ """
+
+ def allow_migrate(self, db, app_label, model_name=None, model=None, **hints):
+ return getattr(model, "encrypted", False)
+
+ def db_for_read(self, model, **hints):
+ if getattr(model, "encrypted", False):
+ return "my_encrypted_database"
+ return None
+
+ db_for_write = db_for_read
diff --git a/tests/encryption_/tests.py b/tests/encryption_/tests.py
new file mode 100644
index 000000000..75ea791f7
--- /dev/null
+++ b/tests/encryption_/tests.py
@@ -0,0 +1,399 @@
+import importlib
+import os
+from datetime import datetime, time, timedelta
+from io import StringIO
+from unittest.mock import patch
+
+import pymongo
+from bson import json_util
+from bson.binary import Binary
+from django.core.management import call_command
+from django.db import connections, models
+from django.test import TestCase, TransactionTestCase, modify_settings, override_settings
+from pymongo_auth_aws.auth import AwsCredential
+
+from django_mongodb_backend.encryption import EqualityQuery
+from django_mongodb_backend.fields import EncryptedFieldMixin
+from django_mongodb_backend.models import EncryptedModel
+
+from .models import (
+ Appointment,
+ Billing,
+ Patient,
+ PatientPortalUser,
+ PatientRecord,
+)
+from .routers import TestEncryptedRouter
+
+EXPECTED_ENCRYPTED_FIELDS_MAP = {
+ "billing": {
+ "fields": [
+ {
+ "bsonType": "string",
+ "path": "cc_type",
+ "queries": {"queryType": "equality"},
+ "keyId": Binary(b" \x901\x89\x1f\xafAX\x9b*\xb1\xc7\xc5\xfdl\xa4", 4),
+ },
+ {
+ "bsonType": "long",
+ "path": "cc_number",
+ "queries": {"queryType": "equality"},
+ "keyId": Binary(b"\x97\xb4\x9d\xb8\xd5\xa6Ay\x85\xfe\x00\xc0\xd4{\xa2\xff", 4),
+ },
+ {
+ "bsonType": "decimal",
+ "path": "account_balance",
+ "queries": {"queryType": "range"},
+ "keyId": Binary(b"\xcc\x01-s\xea\xd9B\x8d\x80\xd7\xf8!n\xc6\xf5U", 4),
+ },
+ ]
+ },
+ "patientrecord": {
+ "fields": [
+ {
+ "bsonType": "string",
+ "path": "ssn",
+ "queries": {"queryType": "equality"},
+ "keyId": Binary(b"\x14F\x89\xde\x8d\x04K7\xa9\x9a\xaf_\xca\x8a\xfb&", 4),
+ },
+ {
+ "bsonType": "date",
+ "path": "birth_date",
+ "queries": {"queryType": "range"},
+ "keyId": Binary(b"@\xdd\xb4\xd2%\xc2B\x94\xb5\x07\xbc(ER[s", 4),
+ },
+ {
+ "bsonType": "binData",
+ "path": "profile_picture",
+ "queries": {"queryType": "equality"},
+ "keyId": Binary(b"Q\xa2\xebc!\xecD,\x8b\xe4$\xb6ul9\x9a", 4),
+ },
+ {
+ "bsonType": "int",
+ "path": "patient_age",
+ "queries": {"queryType": "range"},
+ "keyId": Binary(b"\ro\x80\x1e\x8e1K\xde\xbc_\xc3bi\x95\xa6j", 4),
+ },
+ {
+ "bsonType": "double",
+ "path": "weight",
+ "queries": {"queryType": "range"},
+ "keyId": Binary(b"\x9b\xfd:n\xe1\xd0N\xdd\xb3\xe7e)\x06\xea\x8a\x1d", 4),
+ },
+ ]
+ },
+ "patient": {
+ "fields": [
+ {
+ "bsonType": "int",
+ "path": "patient_id",
+ "queries": {"queryType": "equality"},
+ "keyId": Binary(b"\x8ft\x16:\x8a\x91D\xc7\x8a\xdf\xe5O\n[\xfd\\", 4),
+ },
+ {
+ "bsonType": "string",
+ "path": "patient_name",
+ "keyId": Binary(b"<\x9b\xba\xeb:\xa4@m\x93\x0e\x0c\xcaN\x03\xfb\x05", 4),
+ },
+ {
+ "bsonType": "string",
+ "path": "patient_notes",
+ "queries": {"queryType": "equality"},
+ "keyId": Binary(b"\x01\xe7\xd1isnB$\xa9(gwO\xca\x10\xbd", 4),
+ },
+ {
+ "bsonType": "date",
+ "path": "registration_date",
+ "queries": {"queryType": "equality"},
+ "keyId": Binary(b"F\xfb\xae\x82\xd5\x9a@\xee\xbfJ\xaf#\x9c:-I", 4),
+ },
+ {
+ "bsonType": "bool",
+ "path": "is_active",
+ "queries": {"queryType": "equality"},
+ "keyId": Binary(b"\xb2\xb5\xc4K53A\xda\xb9V\xa6\xa9\x97\x94\xea;", 4),
+ },
+ ]
+ },
+}
+
+
+class EncryptedDurationField(EncryptedFieldMixin, models.DurationField):
+ """
+ Unsupported by MongoDB when used with Queryable Encryption.
+ Included in tests until fix or wontfix.
+ """
+
+
+class EncryptedSlugField(EncryptedFieldMixin, models.SlugField):
+ """
+ Unsupported by MongoDB when used with Queryable Encryption.
+ Included in tests until fix or wontfix.
+ """
+
+
+def reload_module(module):
+ """
+ Reloads a module to ensure that any changes to environment variables
+ or other settings are applied without restarting the test runner.
+ """
+ module = importlib.import_module(module)
+ importlib.reload(module)
+ return module
+
+
+@modify_settings(
+ INSTALLED_APPS={"prepend": "django_mongodb_backend"},
+)
+@override_settings(DATABASE_ROUTERS=[TestEncryptedRouter()])
+class EncryptedFieldTests(TransactionTestCase):
+ databases = {"default", "my_encrypted_database"}
+ available_apps = ["django_mongodb_backend", "encryption_"]
+
+ def setUp(self):
+ self.appointment = Appointment(time="8:00")
+ self.appointment.save()
+
+ self.billing = Billing(cc_type="Visa", cc_number=1234567890123456, account_balance=100.50)
+ self.billing.save()
+
+ self.portal_user = PatientPortalUser(
+ ip_address="127.0.0.1",
+ url="https://example.com",
+ )
+ self.portal_user.save()
+
+ self.patientrecord = PatientRecord(
+ ssn="123-45-6789",
+ birth_date="1970-01-01",
+ profile_picture=b"image data",
+ weight=175.5,
+ patient_age=47,
+ )
+ self.patientrecord.save()
+
+ self.patient = Patient(
+ patient_id=1,
+ patient_name="John Doe",
+ patient_notes="patient notes " * 25,
+ registration_date=datetime(2023, 10, 1, 12, 0, 0),
+ is_active=True,
+ email="john.doe@example.com",
+ )
+ self.patient.save()
+
+ # TODO: Embed billing and patient_record models in patient model then add tests
+
+ @classmethod
+ def setUpClass(cls):
+ super().setUpClass()
+
+ cls.patch_aws = patch(
+ "pymongocrypt.synchronous.credentials.aws_temp_credentials",
+ return_value=AwsCredential(username="", password="", token=""),
+ )
+ cls.patch_aws.start()
+
+ cls.patch_azure = patch(
+ "pymongocrypt.synchronous.credentials._get_azure_credentials", return_value={}
+ )
+ cls.patch_azure.start()
+
+ cls.patch_gcp = patch(
+ "pymongocrypt.synchronous.credentials._get_gcp_credentials", return_value={}
+ )
+ cls.patch_gcp.start()
+
+ @classmethod
+ def tearDownClass(cls):
+ cls.patch_aws.stop()
+ cls.patch_azure.stop()
+ cls.patch_gcp.stop()
+
+ def test_get_encrypted_fields_map_method(self):
+ self.maxDiff = None
+ with connections["my_encrypted_database"].schema_editor() as editor:
+ db_table = self.patient._meta.db_table
+ self.assertCountEqual(
+ {"fields": editor._get_encrypted_fields_map(self.patient)},
+ EXPECTED_ENCRYPTED_FIELDS_MAP[db_table],
+ )
+
+ def test_get_encrypted_fields_map_command(self):
+ # TODO: Find a way to compare output when the data key changes or
+ # remove test.
+ # {
+ # …
+ # "keyId": {
+ # "$binary": {
+ # - "base64": "srXESzUzQdq5Vqapl5TqOw==",
+ # + "base64": "j5nkvg1tS66TGoJV/TxbXg==",
+ # "subType": "04"
+ # }
+ # }
+ # }
+ self.maxDiff = None
+ out = StringIO()
+ call_command(
+ "get_encrypted_fields_map",
+ "--database",
+ "my_encrypted_database",
+ verbosity=0,
+ stdout=out,
+ )
+ with self.assertRaises(AssertionError):
+ self.assertEqual(
+ json_util.dumps(EXPECTED_ENCRYPTED_FIELDS_MAP, indent=2), out.getvalue()
+ )
+
+ def test_set_encrypted_fields_map_in_client(self):
+ # TODO: Create new client with and without schema map provided then
+ # sync database to ensure encrypted collections are created in both
+ pass
+
+ def test_appointment(self):
+ self.assertEqual(Appointment.objects.get(time="8:00").time, time(8, 0))
+
+ # FIXME: Or remove test if wontfix. These tests fail due to
+ # pymongocrypt.errors.MongoCryptError: expected lowerBound to match
+ # index type INT64, got INT32.
+ with self.assertRaises(AssertionError): # noqa: SIM117
+ with self.assertRaises(pymongo.errors.OperationFailure):
+
+ class DurationFieldTest(EncryptedModel):
+ duration = EncryptedDurationField(EqualityQuery())
+
+ appointment = DurationFieldTest(duration=timedelta(hours=2, minutes=30))
+ appointment.save()
+
+ self.assertTrue(
+ DurationFieldTest.objects.filter(duration__gte=timedelta(hours=1, minutes=0))
+ )
+ self.assertFalse(
+ DurationFieldTest.objects.filter(duration__lte=timedelta(hours=8, minutes=0))
+ )
+ self.assertEqual(
+ DurationFieldTest.objects.get(duration=timedelta(hours=2, minutes=30)).duration,
+ timedelta(hours=2, minutes=30),
+ )
+
+ def test_billing(self):
+ self.assertEqual(
+ Billing.objects.get(cc_number=1234567890123456).cc_number, 1234567890123456
+ )
+ self.assertEqual(Billing.objects.get(cc_type="Visa").cc_type, "Visa")
+ self.assertTrue(Billing.objects.filter(account_balance__gte=100.0).exists())
+
+ def test_patientportaluser(self):
+ self.assertEqual(
+ PatientPortalUser.objects.get(ip_address="127.0.0.1").ip_address, "127.0.0.1"
+ )
+
+ # FIXME: Or remove if wontfix.
+ #
+ # This test fails due to
+ # pymongo.errors.OperationFailure: Index not allowed on, or a prefix
+ # of, the encrypted field slug
+ with self.assertRaises(AssertionError): # noqa: SIM117
+ with self.assertRaises(pymongo.errors.OperationFailure):
+
+ class SlugFieldTest(EncryptedModel):
+ slug = EncryptedSlugField(EqualityQuery())
+
+ def test_patientrecord(self):
+ self.assertEqual(PatientRecord.objects.get(ssn="123-45-6789").ssn, "123-45-6789")
+ with self.assertRaises(PatientRecord.DoesNotExist):
+ PatientRecord.objects.get(ssn="000-00-0000")
+ self.assertTrue(PatientRecord.objects.filter(birth_date__gte="1969-01-01").exists())
+ self.assertEqual(
+ PatientRecord.objects.get(ssn="123-45-6789").profile_picture, b"image data"
+ )
+ with self.assertRaises(AssertionError):
+ self.assertEqual(
+ PatientRecord.objects.get(ssn="123-45-6789").profile_picture, b"bad image data"
+ )
+ self.assertTrue(PatientRecord.objects.filter(patient_age__gte=40).exists())
+ self.assertFalse(PatientRecord.objects.filter(patient_age__gte=200).exists())
+ self.assertTrue(PatientRecord.objects.filter(weight__gte=175.0).exists())
+
+ def test_patient(self):
+ self.assertEqual(
+ Patient.objects.get(patient_notes="patient notes " * 25).patient_notes,
+ "patient notes " * 25,
+ )
+ self.assertEqual(
+ Patient.objects.get(
+ registration_date=datetime(2023, 10, 1, 12, 0, 0)
+ ).registration_date,
+ datetime(2023, 10, 1, 12, 0, 0),
+ )
+ self.assertTrue(Patient.objects.get(patient_id=1).is_active)
+ self.assertEqual(
+ Patient.objects.get(email="john.doe@example.com").email, "john.doe@example.com"
+ )
+
+ # Test decrypted patient record in encrypted database.
+ patients = connections["my_encrypted_database"].database.patient.find()
+ self.assertEqual(len(list(patients)), 1)
+ records = connections["my_encrypted_database"].database.patientrecord.find()
+ self.assertTrue("__safeContent__" in records[0])
+
+ # Test encrypted patient record in unencrypted database.
+ conn_params = connections["my_encrypted_database"].get_connection_params()
+ if conn_params.pop("auto_encryption_opts", False):
+ # Call MongoClient instead of get_new_connection because
+ # get_new_connection will return the encrypted connection
+ # from the connection pool.
+ connection = pymongo.MongoClient(**conn_params)
+ patientrecords = connection["test_my_encrypted_database"].patientrecord.find()
+ ssn = patientrecords[0]["ssn"]
+ self.assertTrue(isinstance(ssn, Binary))
+ connection.close()
+
+
+class KMSCredentialsTests(TestCase):
+ def test_env(self):
+ with patch.dict(os.environ, {}, clear=True):
+ encryption = reload_module("django_mongodb_backend.encryption")
+ self.assertEqual(encryption.KMS_CREDENTIALS["aws"]["key"], "")
+ self.assertEqual(encryption.KMS_CREDENTIALS["aws"]["region"], "")
+ self.assertEqual(encryption.KMS_CREDENTIALS["azure"]["keyName"], "")
+ self.assertEqual(encryption.KMS_CREDENTIALS["azure"]["keyVaultEndpoint"], "")
+ self.assertEqual(encryption.KMS_CREDENTIALS["gcp"]["projectId"], "")
+ self.assertEqual(encryption.KMS_CREDENTIALS["gcp"]["location"], "")
+ self.assertEqual(encryption.KMS_CREDENTIALS["gcp"]["keyRing"], "")
+ self.assertEqual(encryption.KMS_CREDENTIALS["gcp"]["keyName"], "")
+ env = {
+ "AWS_KEY_ARN": "TestArn",
+ "AWS_KEY_REGION": "us-x-test",
+ "AZURE_KEY_NAME": "azure-key",
+ "AZURE_KEY_VAULT_ENDPOINT": "https://example.vault.azure.net/",
+ "GCP_PROJECT_ID": "gcp-test-prj",
+ "GCP_LOCATION": "test-loc",
+ "GCP_KEY_RING": "ring1",
+ "GCP_KEY_NAME": "gcp-key",
+ }
+ with patch.dict(os.environ, env, clear=True):
+ encryption = reload_module("django_mongodb_backend.encryption")
+ self.assertEqual(encryption.KMS_CREDENTIALS["azure"]["keyName"], "azure-key")
+ self.assertEqual(
+ encryption.KMS_CREDENTIALS["azure"]["keyVaultEndpoint"],
+ "https://example.vault.azure.net/",
+ )
+ self.assertEqual(encryption.KMS_CREDENTIALS["gcp"]["projectId"], "gcp-test-prj")
+ self.assertEqual(encryption.KMS_CREDENTIALS["gcp"]["location"], "test-loc")
+ self.assertEqual(encryption.KMS_CREDENTIALS["gcp"]["keyRing"], "ring1")
+ self.assertEqual(encryption.KMS_CREDENTIALS["gcp"]["keyName"], "gcp-key")
+
+
+class KMSProvidersTests(TestCase):
+ def test_env(self):
+ with patch.dict(os.environ, {}, clear=True):
+ encryption = reload_module("django_mongodb_backend.encryption")
+ self.assertEqual(encryption.KMS_PROVIDERS["kmip"]["endpoint"], "not a valid endpoint")
+ env = {
+ "KMIP_KMS_ENDPOINT": "kmip://loc",
+ }
+ with patch.dict(os.environ, env, clear=True):
+ encryption = reload_module("django_mongodb_backend.encryption")
+ self.assertEqual(encryption.KMS_PROVIDERS["kmip"]["endpoint"], "kmip://loc")