diff --git a/src/cachekit/cache_handler.py b/src/cachekit/cache_handler.py index 87204fd..51ddd42 100644 --- a/src/cachekit/cache_handler.py +++ b/src/cachekit/cache_handler.py @@ -224,8 +224,9 @@ class CacheSerializationHandler: - Encryption: Defines WHETHER to encrypt (security layer on top, orthogonal) - Tenant extraction: For multi-tenant encryption key isolation (FAIL CLOSED) - Modes: - - encryption=False: Direct serialization (plaintext in cache) + Modes (encryption is tri-state: None=auto / True=force-on / False=hard opt-out): + - encryption=None: Auto-detect from CACHEKIT_MASTER_KEY (single-tenant if a key is present) + - encryption=False: Explicit opt-out — direct serialization (plaintext), even if a master key is set - encryption=True, tenant_extractor=None: Single-tenant encrypted (nil UUID) - encryption=True, tenant_extractor provided: Multi-tenant encrypted (FAIL CLOSED) @@ -265,7 +266,7 @@ class CacheSerializationHandler: def __init__( self, serializer_name: Union[str, SerializerProtocol] = "default", # type: ignore[name-defined] - encryption: bool = False, + encryption: bool | None = None, tenant_extractor: Any | None = None, single_tenant_mode: bool = False, deployment_uuid: Optional[str] = None, @@ -278,7 +279,12 @@ def __init__( serializer_name: Serializer instance or name. Accepts either: - String name: "default" (MessagePack), "arrow" (DataFrame zero-copy), "orjson" (JSON) - SerializerProtocol instance: Custom serializer implementing the protocol - encryption: Enable encryption layer (wraps serializer with EncryptionWrapper) + encryption: Tri-state encryption control (wraps serializer with EncryptionWrapper): + - None (default): auto-detect from CACHEKIT_MASTER_KEY. Single-tenant mode + is auto-enabled when a master key is present. + - True: force encryption ON (requires a master key + explicit tenant mode). + - False: explicit hard opt-out. Never encrypts, even when CACHEKIT_MASTER_KEY + is set fleet-wide. This is the deliberate per-function escape hatch. tenant_extractor: Optional TenantContextExtractor for multi-tenant encryption. Only used if encryption=True. If None: single-tenant mode (uses nil UUID). @@ -305,16 +311,24 @@ def __init__( self.enable_integrity_checking = enable_integrity_checking self._deployment_uuid_value: Optional[str] = None - # Auto-detect encryption from CACHEKIT_MASTER_KEY when not explicitly configured. - # This is the single convergence point for ALL backends and presets. - if not encryption and master_key is None and tenant_extractor is None: - from cachekit.config.singleton import get_settings - - settings = get_settings() - if settings.master_key: - encryption = True - master_key = settings.master_key.get_secret_value() - single_tenant_mode = True + # Tri-state encryption resolution. `encryption` is None/True/False: + # None -> auto-detect from CACHEKIT_MASTER_KEY (fleet-wide convergence point) + # True -> explicit force-on (validated below) + # False -> explicit hard opt-out; honored even when a master key is present + # + # Auto-detection is the ONLY path that may flip encryption on and auto-set + # single_tenant_mode. An explicit False MUST NOT be promoted to True just + # because CACHEKIT_MASTER_KEY exists (issue #128). + if encryption is None: + encryption = False + if master_key is None and tenant_extractor is None: + from cachekit.config.singleton import get_settings + + settings = get_settings() + if settings.master_key: + encryption = True + master_key = settings.master_key.get_secret_value() + single_tenant_mode = True self.encryption = encryption self.tenant_extractor = tenant_extractor diff --git a/src/cachekit/config/nested.py b/src/cachekit/config/nested.py index 965d15e..ac8f892 100644 --- a/src/cachekit/config/nested.py +++ b/src/cachekit/config/nested.py @@ -290,20 +290,35 @@ class EncryptionConfig: single_tenant_mode automatically; if using EncryptionConfig directly (e.g. with @cache.io), you must set it explicitly. + Tri-state ``enabled`` (issue #128): a plain bool cannot tell "user left it unset" + from "user explicitly disabled", so a deliberate opt-out was silently overridden by + fleet-wide CACHEKIT_MASTER_KEY auto-detection. ``enabled`` is therefore None/True/False: + - None (default): unset — defer to CACHEKIT_MASTER_KEY auto-detection downstream. + - True: force client-side encryption ON (requires master_key + tenant mode). + - False: explicit hard opt-out — never encrypt, even when a master key is present. + Attributes: - enabled: Enable client-side encryption (default: False) - master_key: Hex-encoded master key for key derivation (required if enabled) + enabled: Tri-state encryption flag (default: None = unset/auto-detect). + True = force-on, False = explicit opt-out. + master_key: Hex-encoded master key for key derivation (required if enabled=True) tenant_extractor: Optional callable for per-tenant key derivation (default: None) single_tenant_mode: Explicitly enable single-tenant mode (default: False) deployment_uuid: Optional deployment-specific UUID for single-tenant mode (default: None) Examples: - Disabled by default (no encryption): + Unset by default (defers to auto-detection, no encryption forced): >>> config = EncryptionConfig() - >>> config.enabled + >>> config.enabled is None + True + >>> config.validate() # No error when unset + + Explicit opt-out (never encrypts, even with CACHEKIT_MASTER_KEY set): + + >>> off = EncryptionConfig(enabled=False) + >>> off.enabled False - >>> config.validate() # No error when disabled + >>> off.validate() # No error when explicitly disabled Single-tenant encryption: @@ -326,7 +341,7 @@ class EncryptionConfig: cachekit.config.validation.ConfigurationError: Encryption requires explicit tenant mode... """ - enabled: bool = False + enabled: bool | None = None master_key: str | None = field(default=None, repr=False) tenant_extractor: Callable[..., str] | None = None single_tenant_mode: bool = False @@ -335,10 +350,18 @@ class EncryptionConfig: def validate(self) -> None: """Validate encryption configuration. + Only the explicit force-on state (enabled=True) requires a master key. The + unset (None) and explicit opt-out (False) states are both falsy and skip + validation — None defers to downstream auto-detection, False never encrypts. + + The master key may be supplied inline or via the CACHEKIT_MASTER_KEY env var + (resolved here so force-on works fleet-wide without inlining the key, matching + the handler's own resolution). + Raises: - ConfigurationError: If encryption enabled but master_key not set + ConfigurationError: If encryption enabled but no master_key (inline or env) """ - if self.enabled and not self.master_key: + if self.enabled and not self._resolve_master_key(): raise ConfigurationError( "encryption.enabled=True requires encryption.master_key. " "Set CACHEKIT_MASTER_KEY environment variable or pass master_key parameter." @@ -357,3 +380,17 @@ def validate(self) -> None: "Cannot use both tenant_extractor and single_tenant_mode. " "Choose multi-tenant (tenant_extractor) OR single-tenant (single_tenant_mode=True)." ) + + def _resolve_master_key(self) -> str | None: + """Resolve the master key from the inline value, falling back to env settings. + + Inline master_key takes precedence; otherwise read CACHEKIT_MASTER_KEY via the + settings singleton. Mirrors validate_encryption_config so the config-level and + handler-level views of "is a key available?" stay consistent. + """ + if self.master_key: + return self.master_key + from cachekit.config.singleton import get_settings + + settings = get_settings() + return settings.master_key.get_secret_value() if settings.master_key else None diff --git a/src/cachekit/config/validation.py b/src/cachekit/config/validation.py index 035613e..f1288a9 100644 --- a/src/cachekit/config/validation.py +++ b/src/cachekit/config/validation.py @@ -30,14 +30,15 @@ class ConfigurationError(Exception): pass -def validate_encryption_config(encryption: bool = False, master_key: str | None = None) -> None: +def validate_encryption_config(encryption: bool | None = False, master_key: str | None = None) -> None: """Validate encryption configuration when encryption is enabled. Checks for a master key: first from the explicit parameter, then from CACHEKIT_MASTER_KEY env var via pydantic-settings. Args: - encryption: Whether encryption is enabled. If False, no validation. + encryption: Tri-state encryption flag. Falsy (None unset / False opt-out) skips + validation; only an explicit True requires a resolvable master key. master_key: Explicit master key (hex string). Takes precedence over env var. Raises: diff --git a/src/cachekit/decorators/intent.py b/src/cachekit/decorators/intent.py index 99fc5e1..54d1e5e 100644 --- a/src/cachekit/decorators/intent.py +++ b/src/cachekit/decorators/intent.py @@ -144,6 +144,28 @@ def decorator(f: F) -> F: existing_l1 = manual_overrides.pop("l1", L1CacheConfig()) manual_overrides["l1"] = replace(existing_l1, enabled=l1_enabled) + # Map flattened tri-state encryption flag + related kwargs to nested EncryptionConfig. + # Tri-state (issue #128): @cache(encryption=False) is a DELIBERATE opt-out that must + # survive fleet-wide CACHEKIT_MASTER_KEY auto-detection. None=auto, True=force, False=off. + # + # Scope: ONLY the bare/default decorator path (no config=, no _intent). Intent presets + # (.secure, .io, ...) own their encryption-param handling, and config= is the RORO form. + # An already-constructed EncryptionConfig passes through untouched — wrapping it again + # would nest EncryptionConfig inside EncryptionConfig.enabled. + if config is None and _intent is None: + from cachekit.config.nested import EncryptionConfig + + _enc_passthrough = isinstance(manual_overrides.get("encryption"), EncryptionConfig) + _enc_keys = {"encryption", "master_key", "tenant_extractor", "single_tenant_mode", "deployment_uuid"} + if not _enc_passthrough and (_enc_keys & manual_overrides.keys()): + enc_overrides: dict[str, Any] = {} + if "encryption" in manual_overrides: + enc_overrides["enabled"] = manual_overrides.pop("encryption") + for _k in ("master_key", "tenant_extractor", "single_tenant_mode", "deployment_uuid"): + if _k in manual_overrides: + enc_overrides[_k] = manual_overrides.pop(_k) + manual_overrides["encryption"] = replace(EncryptionConfig(), **enc_overrides) + # RORO config takes highest precedence if config is not None: # DecoratorConfig instance provided - use it directly with overrides diff --git a/src/cachekit/decorators/wrapper.py b/src/cachekit/decorators/wrapper.py index 131d82d..9365b28 100644 --- a/src/cachekit/decorators/wrapper.py +++ b/src/cachekit/decorators/wrapper.py @@ -281,7 +281,7 @@ def create_cache_wrapper( # Serialization & Security serializer: Union[str, SerializerProtocol] = "default", # type: ignore[name-defined] integrity_checking: bool = True, - encryption: bool = False, + encryption: bool | None = None, tenant_extractor: TenantContextExtractor | None = None, single_tenant_mode: bool = False, deployment_uuid: str | None = None, @@ -318,8 +318,13 @@ def create_cache_wrapper( serializer: Serializer instance or name. Accepts either: - String name: "default" (MessagePack), "arrow" (DataFrame zero-copy) - SerializerProtocol instance: Custom serializer implementing the protocol - encryption: Enable zero-knowledge encryption layer (AES-256-GCM). - Orthogonal to serializer - wraps ANY serializer with encryption. + encryption: Tri-state zero-knowledge encryption control (AES-256-GCM), orthogonal + to serializer - wraps ANY serializer with encryption. + - None (default): auto-detect from CACHEKIT_MASTER_KEY (fleet-wide opt-in). + - True: force encryption ON. + - False: explicit per-function opt-out — never encrypts, even when + CACHEKIT_MASTER_KEY is set. Use to exclude a single function from + fleet-wide encryption (issue #128). tenant_extractor: Optional tenant ID extractor for multi-tenant encryption. Only used if encryption=True. If None: single-tenant mode (uses nil UUID for encryption). diff --git a/tests/unit/config/test_nested_configs.py b/tests/unit/config/test_nested_configs.py index f836792..7431b22 100644 --- a/tests/unit/config/test_nested_configs.py +++ b/tests/unit/config/test_nested_configs.py @@ -288,9 +288,13 @@ class TestEncryptionConfig: """Test EncryptionConfig validation (master_key + tenant mode).""" def test_defaults(self) -> None: - """Test default values.""" + """Test default values. + + enabled defaults to None (tri-state, issue #128): unset means "defer to + CACHEKIT_MASTER_KEY auto-detection", distinct from an explicit False opt-out. + """ config = EncryptionConfig() - assert config.enabled is False + assert config.enabled is None assert config.master_key is None assert config.tenant_extractor is None assert config.single_tenant_mode is False diff --git a/tests/unit/test_cache_handler_encryption_autodetect.py b/tests/unit/test_cache_handler_encryption_autodetect.py index e9e569b..c0cf380 100644 --- a/tests/unit/test_cache_handler_encryption_autodetect.py +++ b/tests/unit/test_cache_handler_encryption_autodetect.py @@ -1,17 +1,33 @@ """Tests for CacheSerializationHandler encryption auto-detection. -When CACHEKIT_MASTER_KEY is set and encryption is not explicitly configured, -the handler auto-enables encryption with single_tenant_mode=True. +When CACHEKIT_MASTER_KEY is set and encryption is not explicitly configured +(encryption=None), the handler auto-enables encryption with single_tenant_mode=True. + +Encryption is tri-state (issue #128): None=auto-detect, True=force-on, False=hard +opt-out. An explicit False must survive fleet-wide CACHEKIT_MASTER_KEY auto-detection. """ from __future__ import annotations +from typing import Any + import pytest +from cachekit import cache from cachekit.cache_handler import CacheSerializationHandler from cachekit.config.singleton import reset_settings +from cachekit.serializers.base import SerializationMetadata +from cachekit.serializers.wrapper import SerializationWrapper _FAKE_KEY = "ab" * 32 # pragma: allowlist secret +_DEPLOYMENT_UUID = "00000000-0000-0000-0000-000000000001" + + +def _envelope_is_encrypted(handler: CacheSerializationHandler, data: object, cache_key: str) -> bool: + """Serialize and inspect the on-the-wire envelope's encrypted metadata flag.""" + blob = handler.serialize_data(data, cache_key=cache_key) + _serialized, metadata_dict, _name = SerializationWrapper.unwrap(blob) + return SerializationMetadata.from_dict(metadata_dict).encrypted @pytest.mark.unit @@ -74,5 +90,166 @@ def extractor(*a, **kw): tenant_extractor=extractor, ) - # User passed tenant_extractor without encryption=True — auto-detect respects that + # Explicit encryption=False is a hard opt-out — auto-detect never runs assert handler.encryption is False + + +@pytest.mark.unit +class TestEncryptionTriState: + """Tri-state encryption: None=auto, True=force-on, False=explicit opt-out (issue #128).""" + + @pytest.fixture(autouse=True) + def _reset(self): + yield + reset_settings() + + def test_default_param_is_none_auto_detects(self, monkeypatch: pytest.MonkeyPatch) -> None: + """encryption defaults to None (unset) and auto-detects from the env key.""" + monkeypatch.setenv("CACHEKIT_MASTER_KEY", _FAKE_KEY) + monkeypatch.setenv("CACHEKIT_DEPLOYMENT_UUID", _DEPLOYMENT_UUID) + reset_settings() + + handler = CacheSerializationHandler(serializer_name="default") + + assert handler.encryption is True + assert _envelope_is_encrypted(handler, {"x": 1}, "ck:auto") is True + + def test_explicit_false_opts_out_despite_master_key(self, monkeypatch: pytest.MonkeyPatch) -> None: + """REGRESSION (issue #128): encryption=False must NOT auto-encrypt when a key is set. + + Before the tri-state fix, the auto-detect guard couldn't distinguish "unset" from + "explicitly False" (both were the bool False), so a deliberate opt-out was silently + promoted to encryption=True by fleet-wide CACHEKIT_MASTER_KEY. + """ + monkeypatch.setenv("CACHEKIT_MASTER_KEY", _FAKE_KEY) + monkeypatch.setenv("CACHEKIT_DEPLOYMENT_UUID", _DEPLOYMENT_UUID) + reset_settings() + + handler = CacheSerializationHandler(serializer_name="default", encryption=False) + + assert handler.encryption is False + assert handler.master_key is None + # The on-the-wire envelope must be plaintext, not ciphertext. + assert _envelope_is_encrypted(handler, {"x": 1}, "ck:optout") is False + + def test_explicit_true_forces_encryption_on(self, monkeypatch: pytest.MonkeyPatch) -> None: + """encryption=True forces encryption on (single-tenant) even via the env key.""" + monkeypatch.setenv("CACHEKIT_MASTER_KEY", _FAKE_KEY) + monkeypatch.setenv("CACHEKIT_DEPLOYMENT_UUID", _DEPLOYMENT_UUID) + reset_settings() + + handler = CacheSerializationHandler( + serializer_name="default", + encryption=True, + single_tenant_mode=True, + ) + + assert handler.encryption is True + assert _envelope_is_encrypted(handler, {"x": 1}, "ck:forced") is True + + def test_decorator_explicit_false_yields_plaintext_bare_encrypts(self, monkeypatch: pytest.MonkeyPatch) -> None: + """End-to-end via @cache: bare encrypts, encryption=False opts out, encryption=True forces on.""" + monkeypatch.setenv("CACHEKIT_MASTER_KEY", _FAKE_KEY) + monkeypatch.setenv("CACHEKIT_DEPLOYMENT_UUID", _DEPLOYMENT_UUID) + reset_settings() + + from cachekit.config.decorator import DecoratorConfig + from cachekit.config.nested import EncryptionConfig + + # Bare @cache leaves encryption unset (None) -> auto-detect path stays available. + # Construction runs full validation via __post_init__; no raise == valid. + bare = DecoratorConfig(backend=None) + assert bare.encryption.enabled is None + + # @cache(encryption=False) maps to an explicit opt-out that survives the env key. + # Explicit False never requires a master key (validates on construction). + opted_out = DecoratorConfig(backend=None, encryption=EncryptionConfig(enabled=False)) + assert opted_out.encryption.enabled is False + + # @cache(encryption=True) validates against the env-resolved key (force-on). + # No inline key needed: __post_init__ resolves CACHEKIT_MASTER_KEY from env. + forced = DecoratorConfig( + backend=None, + encryption=EncryptionConfig(enabled=True, single_tenant_mode=True), + ) + assert forced.encryption.enabled is True + + +@pytest.mark.unit +class TestDecoratorEncryptionFlattening: + """`@cache(...)` folds flat encryption kwargs into a nested EncryptionConfig (issue #128). + + Covers the bare-decorator mapping block in ``cachekit.decorators.intent.cache`` that turns + flat ``encryption`` / ``master_key`` / ``tenant_extractor`` / ``single_tenant_mode`` / + ``deployment_uuid`` kwargs into ``DecoratorConfig.encryption``. This is the path that lets a + deliberate per-function ``encryption=False`` survive all the way to config resolution. + + The wrapper factory is patched out so we assert on the resolved DecoratorConfig directly, + without constructing a real (Rust-backed) cache wrapper or touching a backend. + """ + + @pytest.fixture + def captured_config(self, monkeypatch: pytest.MonkeyPatch) -> dict[str, Any]: + import cachekit.decorators.intent as intent_mod + + captured: dict[str, Any] = {} + + def _fake_wrapper(func: Any, *, config: Any, _l1_only_mode: bool = False, **_kwargs: Any) -> Any: + captured["config"] = config + return func + + monkeypatch.setattr(intent_mod, "create_cache_wrapper", _fake_wrapper) + return captured + + def test_flat_encryption_false_maps_to_opt_out(self, captured_config: dict[str, Any]) -> None: + from cachekit.config.nested import EncryptionConfig + + @cache(encryption=False, backend=None) + def fn() -> int: + return 1 + + enc = captured_config["config"].encryption + assert isinstance(enc, EncryptionConfig) + assert enc.enabled is False + + def test_flat_encryption_true_maps_to_force_on(self, captured_config: dict[str, Any]) -> None: + from cachekit.config.nested import EncryptionConfig + + @cache(encryption=True, master_key=_FAKE_KEY, single_tenant_mode=True, backend=None) + def fn() -> int: + return 1 + + enc = captured_config["config"].encryption + assert isinstance(enc, EncryptionConfig) + assert enc.enabled is True + assert enc.single_tenant_mode is True + + def test_flat_key_params_fold_in_without_encryption_flag(self, captured_config: dict[str, Any]) -> None: + """master_key / single_tenant_mode / deployment_uuid fold in even when `encryption` + is omitted — `enabled` stays None (auto), exercising the per-key loop branch.""" + from cachekit.config.nested import EncryptionConfig + + @cache(master_key=_FAKE_KEY, single_tenant_mode=True, deployment_uuid=_DEPLOYMENT_UUID, backend=None) + def fn() -> int: + return 1 + + enc = captured_config["config"].encryption + assert isinstance(enc, EncryptionConfig) + assert enc.enabled is None + assert enc.master_key == _FAKE_KEY + assert enc.single_tenant_mode is True + assert enc.deployment_uuid == _DEPLOYMENT_UUID + + def test_prebuilt_encryption_config_passes_through_unwrapped(self, captured_config: dict[str, Any]) -> None: + """An already-constructed EncryptionConfig is NOT re-wrapped (would nest a config in + `.enabled`); the passthrough guard skips the mapping block.""" + from cachekit.config.nested import EncryptionConfig + + @cache(encryption=EncryptionConfig(enabled=False), backend=None) + def fn() -> int: + return 1 + + enc = captured_config["config"].encryption + assert isinstance(enc, EncryptionConfig) + # If the guard failed, enabled would be an EncryptionConfig, not the bool False. + assert enc.enabled is False