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: 1 addition & 1 deletion DEVELOPMENT.md
Original file line number Diff line number Diff line change
Expand Up @@ -314,7 +314,7 @@ Used for security properties in `tests/unit/test_security_properties.py`:
|:---------|:----------|
| Encryption roundtrip | `decrypt(encrypt(data)) == data` |
| Compression integrity | `decompress(compress(data)) == data` |
| Tenant isolation | Different keys for different tenants |
| Per-tenant key derivation | Different derived keys for different tenants |

```python
from hypothesis import given, strategies as st
Expand Down
13 changes: 8 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -452,16 +452,22 @@ info = expensive_func.cache_info()
### Environment Variables

```bash
# Backend selection: set exactly ONE of CACHEKIT_REDIS_URL, CACHEKIT_API_KEY,
# CACHEKIT_MEMCACHED_SERVERS, CACHEKIT_FILE_CACHE_DIR. Two or more is a ConfigurationError at
# first call, and decorators relying on env auto-detection run uncached (docs/backends/README.md).
# REDIS_URL is only a fallback and never conflicts.

# Redis Connection (priority: CACHEKIT_REDIS_URL > REDIS_URL)
CACHEKIT_REDIS_URL="redis://localhost:6379" # Primary (preferred)
REDIS_URL="redis://localhost:6379" # Fallback

# CachekitIO SaaS Backend (closed beta — request access at cachekit.io)
CACHEKIT_API_KEY="your-api-key" # For @cache.io() — or pass api_key= directly # pragma: allowlist secret
# For @cache.io() next to Redis, pass api_key= from your secret store instead.
# CACHEKIT_API_KEY="your-api-key" # pragma: allowlist secret
Comment thread
27Bslash6 marked this conversation as resolved.
Comment thread
27Bslash6 marked this conversation as resolved.
CACHEKIT_API_URL="https://api.cachekit.io" # Default SaaS endpoint

# Memcached Backend (optional: pip install cachekit[memcached])
CACHEKIT_MEMCACHED_SERVERS='["mc1:11211", "mc2:11211"]' # Default: 127.0.0.1:11211
# CACHEKIT_MEMCACHED_SERVERS='["mc1:11211", "mc2:11211"]' # Default: 127.0.0.1:11211
CACHEKIT_MEMCACHED_CONNECT_TIMEOUT=2.0 # Default: 2.0 seconds
CACHEKIT_MEMCACHED_TIMEOUT=1.0 # Default: 1.0 seconds
CACHEKIT_MEMCACHED_KEY_PREFIX="myapp:" # Default: "" (none)
Expand All @@ -471,9 +477,6 @@ CACHEKIT_MAX_VALUE_SIZE=104857600
CACHEKIT_ARROW_COMPRESSION=zstd
```

> [!NOTE]
> If both `CACHEKIT_REDIS_URL` and `REDIS_URL` are set, `CACHEKIT_REDIS_URL` takes precedence.

---

## Development
Expand Down
6 changes: 3 additions & 3 deletions SECURITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -145,15 +145,15 @@ downstream of that bound.

### Zero-Knowledge Encryption

When enabled via `@cache.secure`, client-side AES-256-GCM encryption ensures the server never sees plaintext:
When enabled via `@cache.secure`, client-side AES-256-GCM encryption ensures the server never sees plaintext values. The cache key is not encrypted ([details](docs/features/zero-knowledge-encryption.md#cleartext-cache-key-accepted-exposure)):

| Property | Guarantee |
|:---------|:----------|
| Encryption timing | **Before** data touches Redis |
| Server visibility | Opaque ciphertext only |
| Server visibility | Opaque ciphertext values; the cache key stays cleartext |
| Key derivation | HKDF with per-tenant salts |
| Authentication | GCM tags prevent tampering |
| Compliance | GDPR/HIPAA/PCI-DSS ready |
| Compliance | May *reduce* GDPR/HIPAA/PCI DSS scope, subject to assessment — not a compliance guarantee ([details](docs/features/zero-knowledge-encryption.md#compliance-implications)) |

<details>
<summary><strong>🔐 Master Key Security</strong></summary>
Expand Down
3 changes: 3 additions & 0 deletions docs/CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,9 @@ These functions are provided for documentation examples and return mock data:
`CACHEKIT_MASTER_KEY` in the environment because an ambient key can still auto-activate
encryption (deprecated) and breaks plain `@cache` fences that use `serializer="auto"` or custom
serializers.
- `master_key_env` - opt-in pytest fixture for a fence that binds its own key the way an
application does (`secret_key = os.environ["CACHEKIT_MASTER_KEY"]`). Request it in the info
string, `` ```python fixture:master_key_env ``; it sets `CACHEKIT_MASTER_KEY` for that fence only.

## Skipping Examples with `notest`

Expand Down
8 changes: 1 addition & 7 deletions docs/api-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -664,13 +664,7 @@ For comprehensive backend guide with examples and implementation patterns, see *

### Backend Resolution Priority

When `@cache` is used without an explicit `backend` parameter, resolution follows this priority:

1. **Explicit backend** — `@cache(backend=...)`, then a backend inside `config=`
2. **Module-level default** — `set_default_backend(...)`
3. **Environment auto-detection** — `CACHEKIT_API_KEY`, `CACHEKIT_REDIS_URL`, `CACHEKIT_MEMCACHED_SERVERS` or `CACHEKIT_FILE_CACHE_DIR`, with `REDIS_URL` as a fallback

Examples and the auto-detection table: **[Backend Resolution Priority](backends/README.md#backend-resolution-priority)**.
Resolution order, examples and the auto-detection table: **[Backend Resolution Priority](backends/README.md#backend-resolution-priority)**.

### L1-Only Mode (No Backend)

Expand Down
40 changes: 27 additions & 13 deletions docs/backends/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -211,26 +211,40 @@ without `backend=` pins the default when it is first seen — at decoration if
already set, otherwise at first call — so the usual layout (business modules
imported at the top of the file, `set_default_backend()` in `main()`) works.
Later `set_default_backend()` calls do not re-point already-pinned functions.
Exception: `stale_ttl` and `@cache.io`'s default stale window validate SWR
capability at decoration, so set a CachekitIO default *before* importing modules
that use them.
Exception: an explicit `stale_ttl` validates SWR capability at decoration, so set a
CachekitIO default *before* importing modules that use it. `@cache.io` is not affected:
it builds its own `CachekitIOBackend` and never consults `set_default_backend()`.

### 3. Environment Variable Auto-Detection (Lowest Priority)

If no explicit backend and no module-level default, `DefaultBackendProvider`
picks a backend from exactly one environment selector, in this order:

| Priority | Environment variable | Backend |
|----------|-----------------------------|--------------------|
| 1 | `CACHEKIT_API_KEY` | `CachekitIOBackend` (SaaS) |
| 2 | `CACHEKIT_REDIS_URL` | Redis (tenant-scoped, keys prefixed `t:{tenant}:`) |
| 3 | `CACHEKIT_MEMCACHED_SERVERS`| `MemcachedBackend` |
| 4 | `CACHEKIT_FILE_CACHE_DIR` | `FileBackend` |
| 5 | `REDIS_URL`, or nothing set | Redis, as 2 (localhost fallback) |
picks a backend at the function's first call from the one environment selector that is
set. The four prefixed selectors are mutually exclusive, with no precedence between them:
set exactly one.

| Environment variable | Backend |
|-----------------------------|--------------------|
| `CACHEKIT_API_KEY` | `CachekitIOBackend` (SaaS) |
| `CACHEKIT_REDIS_URL` | Redis (tenant-scoped, keys prefixed `t:{tenant}:`) |
| `CACHEKIT_MEMCACHED_SERVERS`| `MemcachedBackend` |
| `CACHEKIT_FILE_CACHE_DIR` | `FileBackend` |
| none of the above: `REDIS_URL`, or nothing set | Redis, as above (localhost fallback) |

Setting more than one of the four `CACHEKIT_*` selectors is ambiguous and raises
`ConfigurationError` at first call. The decorator catches it, logs a WARNING on
the `cachekit.decorators.orchestrator` logger, and runs the function uncached.
the `cachekit.decorators.orchestrator` logger, and runs the function uncached:

```text
Cache operation 'client_creation' failed for key '<redacted:...>': ConfigurationError
```

The misconfiguration never heals on its own. After 5 consecutive failures (the default) the
function's circuit breaker opens and logs one `transitioned to OPEN` WARNING on
`cachekit.reliability.circuit_breaker`. Calls keep running uncached, but no longer log each
failure at WARNING: the breaker re-probes every `recovery_timeout` (30 s by default), and each
probe logs one more `client_creation` failure and one more OPEN WARNING. The function's
`get_health_status()` reports the breaker as `open` and `check_health()` as unhealthy.

`REDIS_URL` is a 12-factor fallback and never counts as a conflict.

The Redis prefix scopes L2 only. L1 is shared by every tenant in the process; see
Expand Down
9 changes: 5 additions & 4 deletions docs/backends/cachekitio.md
Original file line number Diff line number Diff line change
Expand Up @@ -189,7 +189,7 @@ CACHEKIT_TIMEOUT=5.0 # Optional — request timeout in seconds

> *cachekit.io is in closed beta — [request access](https://cachekit.io)*

Compose `@cache.secure` with `CachekitIOBackend` for end-to-end zero-knowledge encryption over managed SaaS storage. The backend stores opaque ciphertext — it never sees plaintext data or your master key.
Compose `@cache.secure` with `CachekitIOBackend` for end-to-end zero-knowledge encryption over managed SaaS storage. The backend stores opaque ciphertext values — it never sees plaintext values or your master key. The cache key stays cleartext ([details](../features/zero-knowledge-encryption.md#cleartext-cache-key-accepted-exposure)).

```python notest
from cachekit import cache
Expand All @@ -206,17 +206,18 @@ def get_user_profile(user_id: str) -> dict:
serialize(result) -> encrypt(HKDF-derived key) -> PUT /v1/cache/{key}
GET /v1/cache/{key} -> decrypt() -> deserialize() -> return result

The cachekit.io API sees only encrypted bytes. Zero-knowledge.
The cachekit.io API sees only encrypted values; the cache key in the URL path is cleartext.
"""
return fetch_user_from_db(user_id)
```

**Why this matters**:
- `@cache.secure` applies AES-256-GCM client-side encryption before any data leaves the process
- Per-tenant key derivation via HKDF — not a tenancy boundary; see [Multi-Tenant Isolation](../features/zero-knowledge-encryption.md#multi-tenant-isolation)
- The SaaS backend is a zero-knowledge conduit: it stores whatever bytes arrive
- With `@cache.secure`: SaaS is out of scope for HIPAA/PCI (stores only ciphertext)
- The SaaS backend stores whatever bytes arrive and never holds a key to decrypt the values
- With `@cache.secure`: the SaaS holds only ciphertext values, which supports a HIPAA/PCI DSS scope-*reduction* argument — not a guarantee, and the cache key stays cleartext; see [Compliance Implications](../features/zero-knowledge-encryption.md#compliance-implications)
- Without encryption: SaaS stores plaintext, may be in compliance scope
- Pass `backend=` explicitly, as above: `@cache.secure` does not pin the SaaS on its own ([why](../features/zero-knowledge-encryption.md#cachesecure-does-not-pin-a-backend))

**Requirements**:

Expand Down
12 changes: 7 additions & 5 deletions docs/backends/none.md
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ No network calls. No serialization to bytes. No backend initialization.

## With Intent Presets

Every preset except `secure` works with `backend=None`:
`@cache.minimal`, `@cache.production`, `@cache.dev` and `@cache.test` accept `backend=None`:

```python notest
from cachekit import cache
Expand All @@ -61,9 +61,11 @@ def fast_lookup(key: str) -> dict:
return fetch_data(key)
```

`@cache.secure(backend=None)` is refused at decoration time with `ConfigurationError`:
L1-only stores raw Python objects, which cannot be ciphertext. The same applies to
`encryption=True` and to an `EncryptionWrapper` serializer.
Three presets do not. `@cache.secure(master_key=…, backend=None)` is refused at decoration
time with `ConfigurationError`: L1-only stores raw Python objects, which cannot be ciphertext.
The same applies to `encryption=True` and to an `EncryptionWrapper` serializer. `@cache.io`
builds its own backend and raises `ConfigurationError` on any `backend=`, `None` included.
`@cache.local` is always in-process and raises `TypeError` on a `backend=` argument.

## Upgrade Path

Expand All @@ -90,7 +92,7 @@ No API changes. No code rewrite. Same decorator, same function signature.
- Shared across processes: No (per-process only)
- Persistence: No (lost on restart)
- TTL support: Yes
- Encryption: No — `@cache.secure` / `encryption=True` / `EncryptionWrapper` with `backend=None` raise `ConfigurationError` (raw objects cannot be ciphertext). A fleet-wide `CACHEKIT_MASTER_KEY` does not encrypt L1-only caches either.
- Encryption: No — `@cache.secure(master_key=…)` / `encryption=True` / `EncryptionWrapper` with `backend=None` raise `ConfigurationError` (raw objects cannot be ciphertext). A fleet-wide `CACHEKIT_MASTER_KEY` does not encrypt L1-only caches either.
- Metrics: Yes (if monitoring configured)

---
Expand Down
4 changes: 2 additions & 2 deletions docs/comparison.md
Original file line number Diff line number Diff line change
Expand Up @@ -105,7 +105,7 @@ def expensive_computation(x: int) -> dict:
> - **L1+L2 caching**: L1 hits ~50ns (local memory), L1 miss → L2 Redis (~2-7ms)
> - **Circuit breaker**: Redis down? Cache gracefully, don't cascade failures
> - **Distributed locking**: Prevents cache stampedes across pods
> - **Encryption**: Client-side AES-256-GCM, Redis never sees plaintext
> - **Encryption**: with `@cache.secure`, client-side AES-256-GCM means Redis sees only ciphertext values (plain `@cache` does not encrypt)
> - **Metrics**: Prometheus counters for hits/misses/errors

```python
Expand Down Expand Up @@ -265,7 +265,7 @@ def get_user(id):

**Why cachekit wins**:
- **CachekitIOBackend**: Drop-in L2 backend backed by `api.cachekit.io` — no Redis cluster to provision, patch, or scale
- **Zero-knowledge compatible**: Pair with `@cache.secure` and the managed backend stores only ciphertext, never your data
- **Zero-knowledge compatible**: Pair with `@cache.secure` and the managed backend stores only ciphertext values, never plaintext values; the cache key stays cleartext ([details](features/zero-knowledge-encryption.md#cleartext-cache-key-accepted-exposure))
- **Same decorator API**: Swap backend by setting `CACHEKIT_API_KEY` — zero code changes

```python notest
Expand Down
9 changes: 8 additions & 1 deletion docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -369,6 +369,8 @@ def secure_function():

> [!IMPORTANT]
> When multiple environment variables could apply, cachekit follows this priority order.
> The four backend selectors are the exception: they have no order, so set exactly one
> ([details](backends/README.md#3-environment-variable-auto-detection-lowest-priority)).

### Redis URL Priority

Expand All @@ -393,7 +395,12 @@ export REDIS_URL=redis://localhost:6379/0 # Ignored - won't be used
| `@cache`, `@cache.production()`, etc. | `CachekitConfig` | `CACHEKIT_REDIS_URL` / `REDIS_URL` |
| `@cache.io()` | `CachekitIOBackendConfig` | `CACHEKIT_API_KEY` |

Setting `REDIS_URL` has no effect on `@cache.io()`, and setting `CACHEKIT_API_KEY` has no effect on Redis-backed decorators.
Setting `REDIS_URL` has no effect on `@cache.io()`. `CACHEKIT_API_KEY` is different: it is also
one of the backend selectors, so a decorator without `backend=` resolves to cachekit.io from it,
and with `CACHEKIT_REDIS_URL` also set it hits a selector conflict and runs uncached. To keep
Redis-backed decorators beside `@cache.io()`, pass `backend=` to them, or pass `@cache.io()` its
key as `api_key=`, read from your secret store (never a literal in source), instead of setting
`CACHEKIT_API_KEY`.

## Common Configuration Patterns

Expand Down
16 changes: 16 additions & 0 deletions docs/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,24 @@

import numpy as np
import pandas as pd
import pytest

from cachekit import cache
from cachekit.config.singleton import reset_settings


@pytest.fixture
def master_key_env(monkeypatch):
"""Set CACHEKIT_MASTER_KEY for one fence that reads its key from the environment.

Opt-in per fence (```python fixture:master_key_env) so the fence can bind the key the
way an application does instead of relying on the injected secret_key. Not global:
an ambient key still auto-activates encryption (deprecated) — see secret_key below.
"""
monkeypatch.setenv("CACHEKIT_MASTER_KEY", "a" * 64)
reset_settings()
yield
reset_settings()


def pytest_markdown_docs_globals():
Expand Down
Loading
Loading