"""Encrypted model fields backed by access_core.services.phi_crypto. Values are stored as ``enc:v1`` envelopes in a text column and transparently decrypted on read, so application code (models, services, admin) works with plaintext values exactly as before. Legacy plaintext rows without the envelope prefix remain readable after cutover. """ from datetime import date, datetime from django.db import models from access_core.services.phi_crypto import decrypt_value, encrypt_value class EncryptedField(models.TextField): """TextField whose stored value is an enc:v1 ciphertext envelope.""" description = "AES-256-GCM encrypted text (PHI)" def get_prep_value(self, value): if value is None: return None from access_core.services.phi_crypto import is_encrypted if isinstance(value, str) and is_encrypted(value): return value # already an envelope; prevents double-encryption return encrypt_value(str(value)) def from_db_value(self, value, expression, connection): if value is None: return None return decrypt_value(value) class EncryptedDateField(EncryptedField): """DateField stored as an enc:v1 envelope of the ISO date string.""" description = "AES-256-GCM encrypted date (PHI)" def to_python(self, value): # Inherited from TextField this would return a str; ModelForms/admin # must yield real dates so in-memory instances match DB-read values. parsed = models.DateField().to_python(value) if isinstance(parsed, datetime): return parsed.date() return parsed def get_prep_value(self, value): if value is None: return None from access_core.services.phi_crypto import is_encrypted if isinstance(value, str) and is_encrypted(value): return value parsed = models.DateField().to_python(value) if not isinstance(parsed, (date, datetime)): raise TypeError( f"EncryptedDateField received unsupported type: {type(value)!r}" ) if isinstance(parsed, datetime): parsed = parsed.date() return encrypt_value(parsed.isoformat()) def from_db_value(self, value, expression, connection): plain = super().from_db_value(value, expression, connection) if plain is None or not isinstance(plain, str): return plain try: return date.fromisoformat(plain[:10]) except ValueError as exc: from access_core.services.phi_crypto import PhiCryptoError # Never embed the decrypted value in the message — it is PHI and # this exception propagates into logs. raise PhiCryptoError( "EncryptedDateField could not parse stored value as an ISO date" ) from exc