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..1a15f89fe 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 PyMongo or require PyMongo>=4.14.0 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..24d9761c9 --- /dev/null +++ b/django_mongodb_backend/encryption.py @@ -0,0 +1,106 @@ +# Queryable Encryption helpers +import os + +KEY_VAULT_COLLECTION_NAME = "__keyVault" +KEY_VAULT_DATABASE_NAME = "keyvault" +KEY_VAULT_NAMESPACE = f"{KEY_VAULT_DATABASE_NAME}.{KEY_VAULT_COLLECTION_NAME}" +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": { + "accessKeyId": os.getenv("AWS_ACCESS_KEY_ID", "not an access key"), + "secretAccessKey": os.getenv("AWS_SECRET_ACCESS_KEY", "not a secret key"), + }, + "azure": { + "tenantId": os.getenv("AZURE_TENANT_ID", "not a tenant ID"), + "clientId": os.getenv("AZURE_CLIENT_ID", "not a client ID"), + "clientSecret": os.getenv("AZURE_CLIENT_SECRET", "not a client secret"), + }, + # TODO: Provide a valid test key + # + # "Failed to parse KMS provider gcp: unable to parse base64 from UTF-8 field privateKey" + # + # "gcp": { + # "email": os.getenv("GCP_EMAIL", "not an email"), + # "privateKey": os.getenv("GCP_PRIVATE_KEY", "not a private key"), + # }, + "kmip": { + "endpoint": os.getenv("KMIP_KMS_ENDPOINT", "not a valid endpoint"), + }, + "local": { + "key": bytes.fromhex( + "000102030405060708090a0b0c0d0e0f" + "101112131415161718191a1b1c1d1e1f" + "202122232425262728292a2b2c2d2e2f" + "303132333435363738393a3b3c3d3e3f" + "404142434445464748494a4b4c4d4e4f" + "505152535455565758595a5b5c5d5e5f" + ) + }, +} + + +class EncryptedRouter: + def _get_db_for_model(self, model): + if getattr(model, "encrypted", False): + return "encrypted" + return "default" + + def db_for_read(self, model, **hints): + return self._get_db_for_model(model) + + def db_for_write(self, model, **hints): + return self._get_db_for_model(model) + + def allow_migrate(self, db, app_label, model_name=None, model=None, **hints): + if model: + return db == self._get_db_for_model(model) + return db == "default" + + def kms_provider(self, model): + return "local" + + def kms_credentials(self, model): + # return KMS_CREDENTIALS.get(provider, None) + return {} + + +class QueryType: + """ + Class that supports building encrypted equality and range queries + for MongoDB's Queryable Encryption. + """ + + @classmethod + def equality(cls, *, contention=None): + query = {"queryType": "equality"} + if contention is not None: + query["contention"] = contention + return query + + @classmethod + def range(cls, *, sparsity=None, precision=None, trimFactor=None): + query = {"queryType": "range"} + if sparsity is not None: + query["sparsity"] = sparsity + if precision is not None: + query["precision"] = precision + if trimFactor is not None: + query["trimFactor"] = trimFactor + return query diff --git a/django_mongodb_backend/features.py b/django_mongodb_backend/features.py index 3e9cc2922..bc7da4878 100644 --- a/django_mongodb_backend/features.py +++ b/django_mongodb_backend/features.py @@ -592,6 +592,10 @@ def django_test_expected_failures(self): def is_mongodb_6_3(self): return self.connection.get_database_version() >= (6, 3) + @cached_property + def is_mongodb_7_0(self): + return self.connection.get_database_version() >= (7, 0) + @cached_property def supports_atlas_search(self): """Does the server support Atlas search queries and search indexes?""" @@ -624,3 +628,19 @@ 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 sharded cluster. + """ + self.connection.ensure_connection() + client = self.connection.connection.admin + build_info = client.command("buildInfo") + is_enterprise = "enterprise" in build_info.get("modules") + # `supports_transactions` already checks if the server is a + # replica set or sharded cluster. + is_not_single = self.supports_transactions + # TODO: check if the server is Atlas + return is_enterprise and is_not_single and self.is_mongodb_7_0 diff --git a/django_mongodb_backend/fields/__init__.py b/django_mongodb_backend/fields/__init__.py index be95fa5ea..112fe3518 100644 --- a/django_mongodb_backend/fields/__init__.py +++ b/django_mongodb_backend/fields/__init__.py @@ -3,6 +3,7 @@ from .duration import register_duration_field from .embedded_model import EmbeddedModelField from .embedded_model_array import EmbeddedModelArrayField +from .encryption import EncryptedCharField, EncryptedIntegerField from .json import register_json_field from .objectid import ObjectIdField @@ -11,6 +12,8 @@ "ArrayField", "EmbeddedModelArrayField", "EmbeddedModelField", + "EncryptedCharField", + "EncryptedIntegerField", "ObjectIdAutoField", "ObjectIdField", ] diff --git a/django_mongodb_backend/fields/encryption.py b/django_mongodb_backend/fields/encryption.py new file mode 100644 index 000000000..75b141043 --- /dev/null +++ b/django_mongodb_backend/fields/encryption.py @@ -0,0 +1,31 @@ +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.encryption"): + path = path.replace( + "django_mongodb_backend.fields.encryption", + "django_mongodb_backend.fields", + ) + + return name, path, args, kwargs + + +class EncryptedCharField(EncryptedFieldMixin, models.CharField): + pass + + +class EncryptedIntegerField(EncryptedFieldMixin, models.IntegerField): + pass 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..de78a9c9b --- /dev/null +++ b/django_mongodb_backend/management/commands/get_encrypted_fields_map.py @@ -0,0 +1,38 @@ +import json + +from django.apps import apps +from django.core.management.base import BaseCommand +from django.db import DEFAULT_DB_ALIAS, connections, router + + +class Command(BaseCommand): + help = "Generate a `schema_map` of encrypted fields for all encrypted" + " models in the database for use with `AutoEncryptionOpts` in" + " production environments." + + 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.", + ) + + def handle(self, *args, **options): + db = options["database"] + connection = connections[db] + schema_map = self.get_encrypted_fields_map(connection) + self.stdout.write(json.dumps(schema_map, indent=2)) + + def get_encrypted_fields_map(self, connection): + return { + "fields": [ + field + for app_config in apps.get_app_configs() + for model in router.get_migratable_models( + app_config, connection.alias, include_auto_created=False + ) + if getattr(model, "encrypted", False) + for field in connection.schema_editor()._get_encrypted_fields_map(model) + ] + } 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..b03bb253b 100644 --- a/django_mongodb_backend/routers.py +++ b/django_mongodb_backend/routers.py @@ -1,6 +1,5 @@ from django.apps import apps - -from django_mongodb_backend.models import EmbeddedModel +from django.db.utils import ConnectionRouter class MongoRouter: @@ -9,10 +8,23 @@ 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 register_routers(): + """ + Patch the ConnectionRouter with methods to get KMS credentials and provider + from the SchemaEditor. + """ + ConnectionRouter.kms_provider = ConnectionRouter._router_func("kms_provider") diff --git a/django_mongodb_backend/schema.py b/django_mongodb_backend/schema.py index da3ec9613..41028b7c4 100644 --- a/django_mongodb_backend/schema.py +++ b/django_mongodb_backend/schema.py @@ -1,10 +1,12 @@ +from django.conf import settings +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, CodecOptions 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 +43,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 +420,51 @@ 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): + """ + If the model is encrypted create an encrypted collection with the + encrypted fields map else create a normal collection. + """ + + db = self.get_database() + if getattr(model, "encrypted", False): + client = self.connection.connection + + options = client._options.auto_encryption_opts + + key_vault_namespace = options._key_vault_namespace + kms_providers = options._kms_providers + codec_options = CodecOptions() + ce = ClientEncryption(kms_providers, key_vault_namespace, client, codec_options) + table = model._meta.db_table + fields = {"fields": self._get_encrypted_fields_map(model)} + provider = router.kms_provider(model) + # TODO: Remove this ternary condition when the `master_key` + # option is not inadvertently set to "default" somewhere + # which then causes the `master_key.copy` in libmongocrypt + # to fail. + credentials = settings.DATABASES[db].KMS_CREDENTIALS if provider != "local" else None + ce.create_encrypted_collection( + db, + table, + fields, + provider, + credentials, + ) + else: + db.create_collection(model._meta.db_table) + + def _get_encrypted_fields_map(self, model): + connection = self.connection + fields = model._meta.fields + + return [ + { + "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..e2fbdf0fb 100644 --- a/docs/source/contents.rst +++ b/docs/source/contents.rst @@ -1,3 +1,5 @@ +:orphan: + ================= Table of contents ================= diff --git a/docs/source/howto/encryption.rst b/docs/source/howto/encryption.rst new file mode 100644 index 000000000..c0b044f4d --- /dev/null +++ b/docs/source/howto/encryption.rst @@ -0,0 +1,47 @@ +================================ +Configuring Queryable Encryption +================================ + +To use Queryable Encryption with Django MongoDB Backend ensure the following +requirements are met: + +- Automatic Encryption Shared Library or libmongocrypt must be installed and + configured. + +- The MongoDB server must be Atlas or Enterprise version 7.0 or later. + +- Django settings must be updated to include + :class:`~pymongo.encryption_options.AutoEncryptionOpts` + with the appropriate configuration for your encryption keys and queryable + encryption settings. + +For development and testing, users may use the helper functions in +:mod:`~django_mongodb_backend.encryption` to generate the necessary +settings for Queryable Encryption. + +Helper Functions and Settings +============================= + +``KEY_VAULT_COLLECTION_NAME`` +----------------------------- + +``KEY_VAULT_DATABASE_NAME`` +--------------------------- + +``KEY_VAULT_NAMESPACE`` +----------------------- + +``KMS_CREDENTIALS`` +------------------- + +``KMS_PROVIDERS`` +----------------- + +``QueryType`` +------------- + +Django settings +=============== + +``DATABASES["encrypted"]["KMS_CREDENTIALS"]`` +--------------------------------------------- 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..bd7419ce4 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 ===== 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/encryption.rst b/docs/source/ref/encryption.rst new file mode 100644 index 000000000..26631e773 --- /dev/null +++ b/docs/source/ref/encryption.rst @@ -0,0 +1,9 @@ +======================== +Encryption API reference +======================== + +.. module:: django_mongodb_backend.encryption + :synopsis: Built-in utilities for using Queryable Encryption in MongoDB. + +This document covers Queryable Encryption helper functions in +``django_mongodb_backend.encryption``. diff --git a/docs/source/ref/index.rst b/docs/source/ref/index.rst index ce12d8d2f..2ae6147ac 100644 --- a/docs/source/ref/index.rst +++ b/docs/source/ref/index.rst @@ -10,3 +10,4 @@ API reference database django-admin utils + encryption diff --git a/docs/source/ref/models/fields.rst b/docs/source/ref/models/fields.rst index 79cafe3d4..2130ace15 100644 --- a/docs/source/ref/models/fields.rst +++ b/docs/source/ref/models/fields.rst @@ -299,6 +299,16 @@ These indexes use 0-based indexing. As described above for :class:`EmbeddedModelField`, :djadmin:`makemigrations` does not yet detect changes to embedded models. +``EncryptedCharField`` +---------------------- + +.. class:: EncryptedCharField + +``EncryptedIntegerField`` +------------------------- + +.. class:: EncryptedIntegerField + ``ObjectIdAutoField`` --------------------- diff --git a/docs/source/ref/models/models.rst b/docs/source/ref/models/models.rst index 32b5fc850..4da1039fc 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,11 @@ 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. + + Encrypted models support the use of encrypted fields which are + encrypted automatically with MongoDB's Queryable Encryption feature. diff --git a/docs/source/releases/5.2.x.rst b/docs/source/releases/5.2.x.rst index e2ab337a7..79fa484d1 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 :ref:`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..ff3e8b7c1 --- /dev/null +++ b/docs/source/topics/encrypted-models.rst @@ -0,0 +1,10 @@ +.. _encrypted-models: + +Encrypted models +================ + +``EncryptedCharField`` +---------------------- + +``EncryptedIntegerField`` +------------------------- 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/tests/backend_/test_features.py b/tests/backend_/test_features.py index 504d3a3fd..c3becb3f6 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): + connection.features.__dict__.pop("supports_queryable_encryption", None) + + 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..84a6791d0 --- /dev/null +++ b/tests/encryption_/models.py @@ -0,0 +1,34 @@ +from django_mongodb_backend.encryption import QueryType as qt +from django_mongodb_backend.fields import EncryptedCharField, EncryptedIntegerField +from django_mongodb_backend.models import EncryptedModel + + +class Billing(EncryptedModel): + class Meta: + required_db_features = {"supports_queryable_encryption"} + + # TODO: Add fields for billing information + + +class PatientRecord(EncryptedModel): + class Meta: + required_db_features = {"supports_queryable_encryption"} + + ssn = EncryptedCharField("ssn", max_length=11, queries=qt.equality(contention=1)) + + # TODO: Embed Billing model + # billing = + + +class Patient(EncryptedModel): + class Meta: + required_db_features = {"supports_queryable_encryption"} + + def __str__(self): + return self.name + + patient_id = EncryptedIntegerField("patient_id") + patient_name = EncryptedCharField("name", max_length=100) + + # TODO: Embed PatientRecord model + # patient_record = diff --git a/tests/encryption_/routers.py b/tests/encryption_/routers.py new file mode 100644 index 000000000..f029d7629 --- /dev/null +++ b/tests/encryption_/routers.py @@ -0,0 +1,13 @@ +# routers.py + + +class TestEncryptedRouter: + 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 "encrypted" + 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..d094ebbb5 --- /dev/null +++ b/tests/encryption_/tests.py @@ -0,0 +1,48 @@ +import json +from io import StringIO + +from django.core.management import call_command +from django.db import connections +from django.test import TestCase, modify_settings, override_settings + +from .models import Patient, PatientRecord +from .routers import TestEncryptedRouter + +EXPECTED_ENCRYPTED_FIELDS_MAP = { + "fields": [ + { + "bsonType": "string", + "path": "ssn", + "queries": {"queryType": "equality", "contention": 1}, + }, + {"bsonType": "int", "path": "patient_id"}, + {"bsonType": "string", "path": "patient_name"}, + ] +} + + +@modify_settings( + INSTALLED_APPS={"prepend": "django_mongodb_backend"}, +) +@override_settings(DATABASE_ROUTERS=[TestEncryptedRouter()]) +class EncryptedModelTests(TestCase): + databases = {"default", "encrypted"} + + @classmethod + def setUpTestData(cls): + cls.patient_record = PatientRecord(ssn="123-45-6789") + cls.patient = Patient(patient_id=1) + cls.patient.save() + + def test_get_encrypted_fields_map_method(self): + self.maxDiff = None + with connections["encrypted"].schema_editor() as editor: + self.assertCountEqual( + {"fields": editor._get_encrypted_fields_map(self.patient)}, + EXPECTED_ENCRYPTED_FIELDS_MAP, + ) + + def test_get_encrypted_fields_map_command(self): + out = StringIO() + call_command("get_encrypted_fields_map", "--database", "encrypted", verbosity=0, stdout=out) + self.assertIn(json.dumps(EXPECTED_ENCRYPTED_FIELDS_MAP, indent=2), out.getvalue())