Skip to content

feat: support explicit per-function encryption opt-out (tri-state)#151

Merged
27Bslash6 merged 3 commits into
mainfrom
feat/encryption-explicit-opt-out
Jun 7, 2026
Merged

feat: support explicit per-function encryption opt-out (tri-state)#151
27Bslash6 merged 3 commits into
mainfrom
feat/encryption-explicit-opt-out

Conversation

@27Bslash6

@27Bslash6 27Bslash6 commented Jun 6, 2026

Copy link
Copy Markdown
Contributor

Summary

Make the encryption flag tri-state so a deliberate @cache(encryption=False) is honored even when CACHEKIT_MASTER_KEY is set fleet-wide.

  • None (default): auto-detect from CACHEKIT_MASTER_KEY (fleet-wide opt-in)
  • True: force encryption ON
  • False: explicit hard opt-out — never encrypts, even with a master key present

Why

Fleet-wide auto-detection (PR #127) turns encryption on whenever CACHEKIT_MASTER_KEY is set. Because encryption was a plain bool, the auto-detect guard (if not encryption and master_key is None ...) could not tell "user left it unset" from "user explicitly disabled" — both were False. The result: a deliberate @cache(encryption=False) was silently promoted to encryption=True and the value was encrypted anyway (issue #128). There was no per-function escape hatch from fleet-wide encryption.

What changed

  • cache_handler.pyencryption param is now bool | None (default None). The auto-detect guard runs only when encryption is None; it is the sole path that may flip encryption on and auto-set single_tenant_mode. An explicit False is never promoted.
  • config/nested.pyEncryptionConfig.enabled defaults to None (tri-state) so config-driven decorators still auto-detect. validate() now resolves CACHEKIT_MASTER_KEY from the env (matching the handler), so force-on (True) works without inlining the key. None/False are both falsy and skip validation.
  • decorators/wrapper.py — public encryption: bool | None = None; the three-state value is threaded to the handler without collapsing NoneFalse.
  • decorators/intent.py — the bare @cache decorator maps a flattened encryption=<bool> (plus related scalar kwargs) to a nested EncryptionConfig. Intent presets (.secure, .io, …) and the RORO config= form are left untouched; an already-constructed EncryptionConfig passes through without double-wrapping.
  • config/validation.pyvalidate_encryption_config accepts bool | None.

How verified

  • New focused tests in tests/unit/test_cache_handler_encryption_autodetect.py (with CACHEKIT_MASTER_KEY set via monkeypatch, since PR test: fix MASTER_KEY env leak that broke 14 integration tests post #127 #133 removed the autouse env): assert @cache(encryption=False) yields a non-encrypted envelope while bare @cache encrypts, and encryption=True still forces on — checked at the handler level via handler.encryption and on the wire via the serialized envelope's metadata.encrypted flag, plus end-to-end through DecoratorConfig.
  • Updated the EncryptionConfig default-value test to the new tri-state contract (enabled is None).
  • uv run ruff check . ✓ · ruff format --check . ✓ · basedpyright src/ ✓ (0 errors) · doctests on all changed modules ✓
  • pytest tests/critical/ tests/unit/ → 1713 passed (1 unrelated flaky file-backend FD test, passes in isolation; touches no files in this PR).

Originally branched off feat/drop-python-3.9 (stacked on #148). #148 has since merged into main, so this PR targets main directly; the diff contains only the tri-state change.

Closes #128

Summary by CodeRabbit

  • New Features

    • Encryption configuration now supports tri-state control: auto-detection (default), forced encryption, or explicit opt-out.
    • Master key auto-detection improved to read from environment configuration when encryption state is unset.
  • Tests

    • Added comprehensive test coverage for encryption tri-state behaviour and master key auto-detection scenarios.

@coderabbitai

coderabbitai Bot commented Jun 6, 2026

Copy link
Copy Markdown

Review Change Stack

Walkthrough

The pull request implements tri-state encryption control across the cachekit library. Type signatures for encryption parameters are updated from bool to bool | None with default changed from False to None. Auto-detection now occurs only when encryption is None; explicit encryption=False is honoured regardless of environment configuration. Decorators flatten encryption-related kwargs into nested EncryptionConfig objects, and test coverage validates all three states including envelope metadata inspection.

Changes

Tri-State Encryption Parameter Implementation

Layer / File(s) Summary
Type signatures and field contracts to tri-state bool | None
src/cachekit/cache_handler.py, src/cachekit/config/nested.py, src/cachekit/config/validation.py, src/cachekit/decorators/wrapper.py
encryption parameter types updated from bool = False to `bool
Tri-state encryption semantics in API documentation
src/cachekit/cache_handler.py, src/cachekit/config/nested.py, src/cachekit/decorators/wrapper.py
Docstrings across CacheSerializationHandler, EncryptionConfig, and create_cache_wrapper are updated to define None as auto-detect from CACHEKIT_MASTER_KEY, True as force-on (requiring resolved key), and False as explicit plaintext opt-out that persists even when environment key is present.
Master-key resolution and auto-detect logic
src/cachekit/config/nested.py, src/cachekit/cache_handler.py
Adds EncryptionConfig._resolve_master_key() to prioritise inline master_key then fall back to CACHEKIT_MASTER_KEY from environment settings, updates EncryptionConfig.validate() to use the resolver when enabled=True, and rewrites CacheSerializationHandler.__init__ auto-detection guard so only encryption is None can trigger auto-enable from environment; explicit encryption=False is never promoted to encrypted mode.
Decorator flattening of encryption-related kwargs to EncryptionConfig
src/cachekit/decorators/intent.py
The @cache decorator now converts flat encryption-related parameters (encryption, master_key, single_tenant_mode, deployment_uuid, tenant_extractor) into a nested EncryptionConfig object while preserving tri-state encryption value and avoiding double-nesting if EncryptionConfig is already provided.
Tri-state encryption test coverage and validation helpers
tests/unit/config/test_nested_configs.py, tests/unit/test_cache_handler_encryption_autodetect.py
New TestEncryptionTriState suite verifies encryption=None auto-detects when CACHEKIT_MASTER_KEY is set (with encrypted envelope metadata), encryption=False hard opt-out persists despite environment key (with plaintext envelope), and encryption=True forces encryption. New TestDecoratorEncryptionFlattening verifies flat decorator kwargs map into EncryptionConfig correctly. Added _envelope_is_encrypted() helper to inspect serialization metadata. Updated test_defaults to expect enabled is None.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~20 minutes

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 60.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly and concisely summarizes the main change: introducing tri-state encryption support with explicit opt-out capability.
Description check ✅ Passed The description is comprehensive, covering motivation, changes across multiple files, and verification steps. It follows the intent of the template despite not using its exact structure.
Linked Issues check ✅ Passed All code changes directly address issue #128 objectives: tri-state encryption parameter, distinguishing unset from explicit disable, auto-detect only on None, explicit False honoured despite CACHEKIT_MASTER_KEY presence.
Out of Scope Changes check ✅ Passed All changes are scoped to encryption tri-state implementation. Files modified are directly related to encryption handling, configuration, validation, and testing with no unrelated alterations.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/encryption-explicit-opt-out

Comment @coderabbitai help to get the list of available commands and usage tips.

Fleet-wide auto-detection (PR #127) enables encryption whenever
CACHEKIT_MASTER_KEY is set. Because the encryption flag was a plain bool,
the auto-detect guard could not distinguish "user left it unset" from
"user explicitly disabled" — so a deliberate @cache(encryption=False) was
silently promoted to encryption=True and the value was encrypted anyway.

Make the encryption flag tri-state across the handler, decorator wrapper,
and EncryptionConfig:
  - None  (default): auto-detect from CACHEKIT_MASTER_KEY (fleet-wide opt-in)
  - True  : force encryption ON
  - False : explicit hard opt-out, honored even when a master key is present

The handler now only runs auto-detection (and auto-sets single_tenant_mode)
when encryption is None; explicit False is never promoted. EncryptionConfig.enabled
defaults to None so config-driven decorators still auto-detect, and its validate()
resolves CACHEKIT_MASTER_KEY from the env so force-on (True) works without
inlining the key. The bare @cache decorator now maps a flattened encryption=
bool to a nested EncryptionConfig, leaving intent presets and the RORO config
form untouched.

Closes #128
@27Bslash6 27Bslash6 force-pushed the feat/encryption-explicit-opt-out branch from e24dd7b to 55473e1 Compare June 6, 2026 13:58
@codecov

codecov Bot commented Jun 6, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 96.66667% with 1 line in your changes missing coverage. Please review.
✅ All tests successful. No failed tests found.

Files with missing lines Patch % Lines
src/cachekit/cache_handler.py 88.88% 0 Missing and 1 partial ⚠️

📢 Thoughts on this report? Let us know!

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/cachekit/cache_handler.py`:
- Around line 317-326: The forced-on encryption path must also materialize the
env master key: when encryption is True (or config.encryption is set) and
master_key is None and tenant_extractor is None, read
get_settings().master_key.get_secret_value() and set master_key and
single_tenant_mode just like the encryption is None branch; ensure this change
touches the same symbols used in creation flow (master_key, encryption,
tenant_extractor) so that create_cache_wrapper() and EncryptionConfig.validate()
end up forwarding a non-None master_key to
_get_cached_encryption_wrapper()/EncryptionWrapper.

In `@src/cachekit/config/nested.py`:
- Around line 384-396: The helper _resolve_master_key currently calls
settings.master_key.get_secret_value() and returns plaintext; change
_resolve_master_key to return the SecretStr (or None) instead of unwrapping it
(i.e., return settings.master_key directly) and update EncryptionConfig.validate
to test presence/emptiness using the SecretStr API (or truthiness) without
calling get_secret_value(); leave any bytes.fromhex(...) or other plaintext
conversion to the crypto boundary code that actually performs
encryption/decryption so only those functions unwrap the SecretStr. Use
get_settings(), settings.master_key, _resolve_master_key, and
EncryptionConfig.validate to locate and update the logic accordingly.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 07eba6b1-46b4-4c2e-91f3-fb3fc13a6e51

📥 Commits

Reviewing files that changed from the base of the PR and between 1bb9953 and b618e05.

📒 Files selected for processing (8)
  • .secrets.baseline
  • src/cachekit/cache_handler.py
  • src/cachekit/config/nested.py
  • src/cachekit/config/validation.py
  • src/cachekit/decorators/intent.py
  • src/cachekit/decorators/wrapper.py
  • tests/unit/config/test_nested_configs.py
  • tests/unit/test_cache_handler_encryption_autodetect.py

Comment on lines +317 to +326
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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Resolve CACHEKIT_MASTER_KEY for the forced-on path as well.

This branch only materialises master_key from settings when encryption is None. In the explicit encryption=True path, EncryptionConfig.validate() can succeed on an env key, but create_cache_wrapper() still forwards config.encryption.master_key=None, so self.master_key stays unset and _get_cached_encryption_wrapper() later builds the EncryptionWrapper without a key. That breaks the advertised encryption=True + env-only behaviour.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/cachekit/cache_handler.py` around lines 317 - 326, The forced-on
encryption path must also materialize the env master key: when encryption is
True (or config.encryption is set) and master_key is None and tenant_extractor
is None, read get_settings().master_key.get_secret_value() and set master_key
and single_tenant_mode just like the encryption is None branch; ensure this
change touches the same symbols used in creation flow (master_key, encryption,
tenant_extractor) so that create_cache_wrapper() and EncryptionConfig.validate()
end up forwarding a non-None master_key to
_get_cached_encryption_wrapper()/EncryptionWrapper.

Comment on lines +384 to +396
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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Audit master-key typing and unwrapping sites before changing the surface to SecretStr.
rg -n -C2 '\bmaster_key\b|SecretStr|get_secret_value\(' src/cachekit tests

Repository: cachekit-io/cachekit-py

Length of output: 50380


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Show EncryptionConfig type definitions + _resolve_master_key usage
sed -n '320,430p' src/cachekit/config/nested.py | cat -n

# Show validate_encryption_config master-key resolution
sed -n '1,140p' src/cachekit/config/validation.py | cat -n

# Show cache_handler master_key typing/autodetect and where it unwraps settings.master_key
sed -n '240,620p' src/cachekit/cache_handler.py | cat -n

Repository: cachekit-io/cachekit-py

Length of output: 30483


🏁 Script executed:

#!/bin/bash
set -euo pipefail

rg -n "\. _resolve_master_key\(|_resolve_master_key\(" src/cachekit/config/nested.py src/cachekit | head -n 200

Repository: cachekit-io/cachekit-py

Length of output: 421


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Show CachekitConfig.master_key definition + type
rg -n "class CachekitConfig|master_key: Optional\\[SecretStr\\]" -n src/cachekit/config/settings.py
sed -n '200,260p' src/cachekit/config/settings.py | cat -n

# Also inspect get_settings/singleton for master_key typing
rg -n "def get_settings|master_key" src/cachekit/config/singleton.py src/cachekit/config/__init__.py 2>/dev/null || true
ls src/cachekit/config | head

Repository: cachekit-io/cachekit-py

Length of output: 2719


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Inspect singleton typing for master_key
sed -n '1,120p' src/cachekit/config/singleton.py | cat -n

# Inspect EncryptionConfig definition in nested.py (imports + field types)
sed -n '1,120p' src/cachekit/config/nested.py | cat -n
sed -n '240,430p' src/cachekit/config/nested.py | cat -n

Repository: cachekit-io/cachekit-py

Length of output: 15812


Avoid unwrapping settings.master_key to plaintext in _resolve_master_key()

src/cachekit/config/nested.py currently converts settings.master_key from SecretStr to str via get_secret_value() in _resolve_master_key() (used by EncryptionConfig.validate() for a presence check). This breaks the secret-handling rule by spreading plaintext key material across config/runtime layers. Change _resolve_master_key() (and validate()) to keep the resolved value as SecretStr (or avoid get_secret_value() entirely for the “is a key available?” check), and only unwrap to plaintext at the crypto boundary where bytes.fromhex(...) is actually required.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/cachekit/config/nested.py` around lines 384 - 396, The helper
_resolve_master_key currently calls settings.master_key.get_secret_value() and
returns plaintext; change _resolve_master_key to return the SecretStr (or None)
instead of unwrapping it (i.e., return settings.master_key directly) and update
EncryptionConfig.validate to test presence/emptiness using the SecretStr API (or
truthiness) without calling get_secret_value(); leave any bytes.fromhex(...) or
other plaintext conversion to the crypto boundary code that actually performs
encryption/decryption so only those functions unwrap the SecretStr. Use
get_settings(), settings.master_key, _resolve_master_key, and
EncryptionConfig.validate to locate and update the logic accordingly.

Source: Coding guidelines

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

♻️ Duplicate comments (1)
src/cachekit/cache_handler.py (1)

322-331: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Materialise CACHEKIT_MASTER_KEY for encryption=True as well.

Only the encryption is None branch resolves the env key. If callers pass encryption=True and rely on the upstream EncryptionConfig.validate() env fallback, self.master_key still stays None here, so _get_cached_encryption_wrapper() later builds EncryptionWrapper(..., master_key=None). That leaves the documented force-on + env-only path broken.

Suggested fix
-        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
+        resolved_env_master_key: str | None = None
+        if master_key is None:
+            settings = get_settings()
+            resolved_env_master_key = settings.master_key.get_secret_value() if settings.master_key else None
+
+        if encryption is None:
+            encryption = False
+            if resolved_env_master_key is not None and tenant_extractor is None:
+                encryption = True
+                master_key = resolved_env_master_key
+                single_tenant_mode = True
+        elif encryption and master_key is None:
+            master_key = resolved_env_master_key
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/cachekit/cache_handler.py` around lines 322 - 331, The code only resolves
CACHEKIT_MASTER_KEY when encryption is None, leaving callers who pass
encryption=True with master_key=None; update the logic in the CacheHandler
initializer so that when encryption is True (or encryption is None and resolved
to True) and master_key is None and tenant_extractor is None you call
get_settings(), set master_key = settings.master_key.get_secret_value(), set
encryption = True and single_tenant_mode = True; ensure this change affects the
same variables used by _get_cached_encryption_wrapper() / EncryptionWrapper so
EncryptionConfig.validate() fallback and the force-on env-only path work
correctly.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Duplicate comments:
In `@src/cachekit/cache_handler.py`:
- Around line 322-331: The code only resolves CACHEKIT_MASTER_KEY when
encryption is None, leaving callers who pass encryption=True with
master_key=None; update the logic in the CacheHandler initializer so that when
encryption is True (or encryption is None and resolved to True) and master_key
is None and tenant_extractor is None you call get_settings(), set master_key =
settings.master_key.get_secret_value(), set encryption = True and
single_tenant_mode = True; ensure this change affects the same variables used by
_get_cached_encryption_wrapper() / EncryptionWrapper so
EncryptionConfig.validate() fallback and the force-on env-only path work
correctly.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: a7a91b1f-d131-4f9a-ab90-96d90622fbcc

📥 Commits

Reviewing files that changed from the base of the PR and between b618e05 and c49ba0a.

📒 Files selected for processing (1)
  • src/cachekit/cache_handler.py

@27Bslash6 27Bslash6 merged commit bf86c43 into main Jun 7, 2026
32 checks passed
@27Bslash6 27Bslash6 deleted the feat/encryption-explicit-opt-out branch June 7, 2026 00:32
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Encryption auto-detect is all-or-nothing — no per-function opt-out

1 participant