Source code for codegen_database.types.encrypted

"""Encrypted-at-rest SQLAlchemy column type for codegen_database.

:class:`EncryptedText` stores a Fernet-encrypted ``TEXT`` column: the
mapped attribute reads and writes plaintext, ciphertext is what hits
the database.  Fernet ciphertext is non-deterministic, so an
encrypted column can't drive a ``WHERE`` clause or an index -- right
for secrets (OAuth tokens, API keys), wrong for anything queried.

The encryption key is supplied either as a string or -- the usual
case -- as a zero-argument callable resolved on every encrypt /
decrypt, so a key read from the environment is picked up lazily and
never captured at import time.  Any sufficiently-random secret
works: the raw key material is run through SHA-256 to derive the
actual Fernet key (the same derivation ``sqlalchemy-utils``'s
``FernetEngine`` uses, so columns written by
``StringEncryptedType(..., engine=FernetEngine)`` decrypt
unchanged).

Requires the ``encrypted`` extra
(``pip install 'codegen-database[encrypted]'``) for ``cryptography``.
"""

from __future__ import annotations

import base64
import hashlib
from typing import TYPE_CHECKING, Any

from sqlalchemy import Text
from sqlalchemy import types as sa_types

if TYPE_CHECKING:
    from collections.abc import Callable

    from cryptography.fernet import Fernet


def _load_fernet() -> type[Fernet]:
    """Import :class:`~cryptography.fernet.Fernet`, or fail honestly.

    ``cryptography`` lives behind the ``encrypted`` extra so the core
    package stays light; the import is deferred to first use because
    ``codegen_database.types`` is imported by the package root.
    """
    try:
        # Deferred on purpose (not a circular import): cryptography is
        # the optional ``encrypted`` extra, and this module is imported
        # by the package root -- a top-level import would make plain
        # ``import codegen_database`` require it.
        from cryptography.fernet import Fernet  # noqa: PLC0415

    except ImportError as exc:  # pragma: no cover -- env-dependent
        msg = (
            "EncryptedText requires the 'cryptography' package -- "
            "install the extra: pip install 'codegen-database[encrypted]'"
        )
        raise ImportError(msg) from exc

    return Fernet


[docs] class EncryptedText(sa_types.TypeDecorator[str]): """Store a string Fernet-encrypted in a ``TEXT`` column. Args: key: The encryption key material, or a zero-argument callable returning it. A callable is invoked on every encrypt / decrypt, so keys read from the environment resolve lazily -- pass e.g. ``lambda: os.environ["TOKEN_KEY"]``. Example:: import os from sqlalchemy.orm import Mapped, mapped_column from codegen_database.types import EncryptedText class Connection(Base): ... access_token: Mapped[str] = mapped_column( EncryptedText(key=lambda: os.environ["TOKEN_KEY"]), ) Reading a value encrypted under a different key raises :class:`cryptography.fernet.InvalidToken` -- rotating the key makes previously stored values undecryptable, so treat the key like a database credential. """ impl = Text cache_ok = True def __init__(self, key: str | Callable[[], str]) -> None: """Store the key material (or its lazy resolver).""" self.key = key super().__init__() def _fernet(self) -> Fernet: """Build the Fernet engine from the (lazily resolved) key. The raw key is hashed through SHA-256 to derive the 256-bit urlsafe-base64 key Fernet wants -- so callers can supply any high-entropy string rather than a pre-formatted Fernet key. """ fernet_cls = _load_fernet() material = self.key() if callable(self.key) else self.key digest = hashlib.sha256(material.encode("utf-8")).digest() return fernet_cls(base64.urlsafe_b64encode(digest))
[docs] def process_bind_param( self, value: Any, # noqa: ANN401 dialect: Any, # noqa: ANN401, ARG002 ) -> str | None: """Encrypt *value* on its way into the database.""" if value is None: return None return ( self._fernet() .encrypt(str(value).encode("utf-8")) .decode( "ascii", ) )
[docs] def process_result_value( self, value: Any, # noqa: ANN401 dialect: Any, # noqa: ANN401, ARG002 ) -> str | None: """Decrypt a stored ciphertext back to plaintext.""" if value is None: return None return self._fernet().decrypt(value.encode("ascii")).decode("utf-8")