-
Notifications
You must be signed in to change notification settings - Fork 24
INTPYTHON-527 Add Queryable Encryption support #329
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
bc52c8e
38fb110
65bd15a
e08945b
7b34b44
8e83ada
4da895c
ed54a9b
8a7766c
eab2f2e
10a361e
01d5485
db32487
b2be223
27d4b8e
c4d1c66
2772aff
d2ddf4e
e25357e
6487086
bc76db3
4dbaa8f
9cc5ad2
c751b2d
b13a07f
534da6b
13578ab
3342d7f
9fd21e4
9bbe741
d1eb737
176f016
264b37a
1771f56
819058a
9a3c18e
071192e
b2a0534
81cc887
be3dd16
a2342e2
05a7610
96b3fda
1eb71d5
08209d3
90fe562
8c2b84c
ab680fd
4a267f5
3fdc1f7
d562a76
163758d
b95c343
5205a0b
b07c3e6
e557632
c5f8888
a7bc5c5
09423bc
c756cf8
841797c
2386397
d685d2a
08ea317
3e839d7
75c6936
534452f
bf26a8a
bf078ad
2780e32
31d3feb
b005726
e7290e4
76deec0
02ce21e
c8a5118
39f1cbc
e504fc5
0aa423f
c27be37
13de3bb
7e3cd34
4a9daa7
516642f
a319e8e
5807033
37e7e06
f19c901
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,129 @@ | ||
# Queryable Encryption helpers | ||
import os | ||
|
||
from bson.binary import STANDARD | ||
from bson.codec_options import CodecOptions | ||
from pymongo.encryption import AutoEncryptionOpts, ClientEncryption | ||
|
||
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" | ||
|
||
|
||
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 | ||
|
||
|
||
def get_auto_encryption_opts( | ||
*, key_vault_namespace, crypt_shared_lib_path=None, kms_providers=None, schema_map=None | ||
): | ||
""" | ||
Returns an `AutoEncryptionOpts` instance for use with Queryable Encryption. | ||
""" | ||
# WARNING: Provide a schema map for production use. You can generate a schema map | ||
# with the management command `get_encrypted_fields_map` after adding | ||
# django_mongodb_backend to INSTALLED_APPS. | ||
return AutoEncryptionOpts( | ||
key_vault_namespace=key_vault_namespace, | ||
aclark4life marked this conversation as resolved.
Show resolved
Hide resolved
|
||
kms_providers=kms_providers, | ||
crypt_shared_lib_path=crypt_shared_lib_path, | ||
schema_map=schema_map, | ||
) | ||
|
||
|
||
def get_client_encryption(client, key_vault_namespace=None, kms_providers=None): | ||
""" | ||
Returns a `ClientEncryption` instance for use with Queryable Encryption. | ||
""" | ||
|
||
codec_options = CodecOptions(uuid_representation=STANDARD) | ||
return ClientEncryption(kms_providers, key_vault_namespace, client, codec_options) |
Original file line number | Diff line number | Diff line change | ||||||
---|---|---|---|---|---|---|---|---|
|
@@ -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,18 @@ 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 | ||||||||
return is_enterprise and is_not_single and self.is_mongodb_7_0 | ||||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggested change
|
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,23 @@ | ||
from django.db import models | ||
|
||
|
||
class EncryptedCharField(models.CharField): | ||
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 |
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -38,7 +38,9 @@ | |
Trim, | ||
Upper, | ||
) | ||
from django.db.utils import ConnectionRouter | ||
|
||
from .encryption import KMS_CREDENTIALS | ||
from .query_utils import process_lhs | ||
|
||
MONGO_OPERATORS = { | ||
|
@@ -268,10 +270,20 @@ def trunc_time(self, compiler, connection): | |
} | ||
|
||
|
||
def kms_credentials(self, provider): # noqa: ARG001 | ||
return KMS_CREDENTIALS.get(provider, None) | ||
|
||
|
||
def kms_provider(self): # noqa: ARG001 | ||
return getattr(settings, "KMS_PROVIDER", None) | ||
Comment on lines
+277
to
+278
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. You misunderstood what I meant. The idea is that the All that said, The documenation for ClientEncryption says, "Explicit client-side field level encryption." so I'm confused. I guess it's also needed for auto-encryption? |
||
|
||
|
||
def register_functions(): | ||
Cast.as_mql = cast | ||
Concat.as_mql = concat | ||
ConcatPair.as_mql = concat_pair | ||
ConnectionRouter.kms_credentials = kms_credentials | ||
ConnectionRouter.kms_provider = kms_provider | ||
Comment on lines
+285
to
+286
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This file is a list of database functions and |
||
Cot.as_mql = cot | ||
Extract.as_mql = extract | ||
Func.as_mql = func | ||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,45 @@ | ||
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 `get_autoencryption_opts` 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.generate_encrypted_fields_schema_map(connection) | ||
|
||
self.stdout.write(json.dumps(schema_map, indent=2)) | ||
|
||
def generate_encrypted_fields_schema_map(self, conn): | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. There are 10 instances of |
||
schema_map = {} | ||
|
||
for app_config in apps.get_app_configs(): | ||
for model in router.get_migratable_models( | ||
app_config, conn.alias, include_auto_created=False | ||
): | ||
if getattr(model, "encrypted", False): | ||
encrypted_fields = self.get_encrypted_fields(model, conn) | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. You can still initialize |
||
if encrypted_fields: | ||
collection = model._meta.db_table | ||
schema_map[collection] = {"fields": encrypted_fields} | ||
|
||
return schema_map | ||
|
||
def get_encrypted_fields(self, model, conn): | ||
return conn.schema_editor()._get_encrypted_fields_map(model) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The correct verb style (per some PEP) is "Return".