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")