Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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()`.
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
22 changes: 18 additions & 4 deletions tink_fields/fields.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand All @@ -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]]:
Expand Down
64 changes: 64 additions & 0 deletions tink_fields/test/test_field_options.py
Original file line number Diff line number Diff line change
@@ -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)
Loading