From a52566415c7656d9765bb7d5fa1a5526dbf821f6 Mon Sep 17 00:00:00 2001 From: Isaac Elbaz Date: Sat, 5 Sep 2026 11:15:29 -0400 Subject: [PATCH] Validate resolved field options and disable randomized slug indexes --- CHANGELOG.md | 2 + README.md | 2 +- tink_fields/fields.py | 22 +++++++-- tink_fields/test/test_field_options.py | 64 ++++++++++++++++++++++++++ 4 files changed, 85 insertions(+), 5 deletions(-) create mode 100644 tink_fields/test/test_field_options.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 576e8d7..2569261 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- Validate positional database options after Django resolves them. Randomized slug fields now default to `db_index=False`; existing applications should generate and apply the resulting index-removal migration. + - Encrypt binary buffer contents before driver adaptation, fixing PostgreSQL writes that encrypted the string representation of a `psycopg.Binary` adapter. Previously corrupted values require application-specific recovery; this fix does not rewrite stored rows. - Synchronize primitive construction with cache invalidation so an in-flight load cannot republish a stale primitive after `clear_keyset_cache()`. diff --git a/README.md b/README.md index a400784..17f051b 100644 --- a/README.md +++ b/README.md @@ -93,7 +93,7 @@ Values are ordinary Python objects on model instances. Django validates them usi | `EncryptedURLField` | `URLField` | | `EncryptedUUIDField` | `UUIDField` | -Randomized fields deliberately reject `primary_key`, `unique`, `db_index`, and `db_default`. They support `isnull` queries, including the equivalent `field=None`; every lookup that compares values raises `FieldError`. Inherited JSON key lookups, date transforms, and custom registered lookups are also rejected because they would operate on ciphertext. Database expressions such as `F()` assignments are also rejected because the database cannot encrypt them. +Randomized fields deliberately reject `primary_key`, `unique`, `db_index`, and `db_default`. They support `isnull` queries, including the equivalent `field=None`; every lookup that compares values raises `FieldError`. `EncryptedSlugField` defaults to `db_index=False`, unlike Django's plaintext slug field. Applications upgrading from an earlier version should run `makemigrations` and review the generated index-removal migration. Inherited JSON key lookups, date transforms, and custom registered lookups are also rejected because they would operate on ciphertext. Database expressions such as `F()` assignments are also rejected because the database cannot encrypt them. ### Deterministic fields and exact lookups diff --git a/tink_fields/fields.py b/tink_fields/fields.py index d2d24e3..60b2262 100644 --- a/tink_fields/fields.py +++ b/tink_fields/fields.py @@ -21,6 +21,7 @@ from django.conf import settings from django.core.exceptions import FieldError, ImproperlyConfigured from django.db import models +from django.db.models.fields import NOT_PROVIDED from django.db.models.lookups import Exact, IsNull, Lookup from django.utils import timezone from django.utils.encoding import force_bytes, force_str @@ -305,10 +306,11 @@ def __init__(self, *args: Any, **kwargs: Any) -> None: keyset: Name of the keyset to use (default: "default") aad_callback: Callable for additional authenticated data """ - # Validate unsupported properties - for prop in self._unsupported_properties: - if (prop == "db_default" and prop in kwargs) or kwargs.get(prop): - raise ImproperlyConfigured(f"Field `{self.__class__.__name__}` does not support property `{prop}`.") + # SlugField normally defaults to an index, which randomized ciphertext + # cannot use for meaningful equality queries. + if isinstance(self, models.SlugField) and "db_index" in self._unsupported_properties: + kwargs.setdefault("db_index", False) + has_db_default = "db_default" in kwargs # Extract custom parameters self._keyset = kwargs.pop("keyset", DEFAULT_KEYSET) @@ -322,6 +324,18 @@ def __init__(self, *args: Any, **kwargs: Any) -> None: # Call parent constructor first super().__init__(*args, **kwargs) + # Inspect resolved options so positional arguments and parent defaults + # receive the same checks as keyword arguments. Check primary_key before + # unique, since Django treats all primary keys as unique. + for prop in ("primary_key", "db_index", "unique", "db_default"): + if prop not in self._unsupported_properties: + continue + enabled = ( + (has_db_default or self.db_default is not NOT_PROVIDED) if prop == "db_default" else getattr(self, prop) + ) + if enabled: + raise ImproperlyConfigured(f"Field `{self.__class__.__name__}` does not support property `{prop}`.") + self._keyset_manager = KeysetManager(self._keyset, self._aad_callback) def deconstruct(self) -> tuple[str | None, str, Sequence[Any], dict[str, Any]]: diff --git a/tink_fields/test/test_field_options.py b/tink_fields/test/test_field_options.py new file mode 100644 index 0000000..03ed8bb --- /dev/null +++ b/tink_fields/test/test_field_options.py @@ -0,0 +1,64 @@ +"""Validate field options after Django has resolved arguments and defaults.""" + +from inspect import signature + +import pytest +from django.core.exceptions import ImproperlyConfigured +from django.db import connection +from django.db.models import Field + +from tink_fields import DeterministicEncryptedCharField, EncryptedSlugField, EncryptedTextField + +from . import models + + +@pytest.mark.parametrize( + "args, option", + [ + ((None, None, True), "primary_key"), + ((None, None, False, None, True), "unique"), + ((None, None, False, None, False, False, False, True), "db_index"), + ], +) +def test_randomized_fields_reject_positional_database_options(args, option): + with pytest.raises(ImproperlyConfigured, match=f"property `{option}`"): + EncryptedTextField(*args) + + +def test_deterministic_fields_reject_positional_primary_keys(): + with pytest.raises(ImproperlyConfigured, match="property `primary_key`"): + DeterministicEncryptedCharField(None, None, True, 25) + + +def test_deterministic_fields_keep_positional_indexes_and_uniqueness(): + field = DeterministicEncryptedCharField(None, None, False, 25, True, False, False, True) + assert field.unique is True + assert field.db_index is True + + +def test_randomized_slug_defaults_to_no_index_and_serializes_it(): + field = EncryptedSlugField() + assert field.db_index is False + assert field.clone().db_index is False + assert field.deconstruct()[3]["db_index"] is False + + +def test_randomized_slug_still_rejects_explicit_indexes(): + with pytest.raises(ImproperlyConfigured, match="property `db_index`"): + EncryptedSlugField(db_index=True) + + +@pytest.mark.django_db +def test_randomized_slug_has_no_database_index(): + with connection.cursor() as cursor: + constraints = connection.introspection.get_constraints(cursor, models.EncryptedExtended._meta.db_table) + assert not any(constraint["index"] and constraint["columns"] == ["slug"] for constraint in constraints.values()) + + +@pytest.mark.parametrize("field_cls", [EncryptedTextField, DeterministicEncryptedCharField]) +def test_positional_database_defaults_are_rejected(field_cls): + parameters = signature(Field).parameters + arguments = [parameter.default for parameter in parameters.values()] + arguments[list(parameters).index("db_default")] = None + with pytest.raises(ImproperlyConfigured, match="property `db_default`"): + field_cls(*arguments)