Extract Layer 1 to cores/mizan-python (mizan-core)
Pull cache/keys.py (HMAC cache key derivation) and mwt.py (Mizan Web Token) out of backends/mizan-django and into a new cores/mizan-python package. mizan-django re-imports them via the new mizan_core module. Naming: directory cores/mizan-python/, distribution mizan-core, importable module mizan_core. mizan-django keeps its existing 'mizan' distribution slot on PyPI; the two coexist as distinct packages. Wiring: - backends/mizan-django/pyproject.toml gains a 'mizan-core' dep with a [tool.uv.sources] path entry (editable install from ../../cores/mizan-python). - Makefile install target prepends 'cd cores/mizan-python && uv pip install -e .' - 3 import sites in mizan-django updated: cache/__init__.py, jwt/functions.py, client/executor.py — all now import from mizan_core. Test split: - 3 unit-test classes (CacheKeyDerivationTests, MWTCreationTests, PermissionKeyTests) move to cores/mizan-python/tests/, rewritten against unittest.TestCase (no Django dep). The cross-language pin test (pinned HMAC hex digests against mizan-ts) moves with CacheKeyDerivationTests. - Integration tests stay in mizan-django (CacheBackendTests, CachePurgeTests, CacheIntegrationTests, RevParameterTests, MWTAuthIntegrationTests) — they need the Django request flow. Verified: - mizan-core: 15/15 pass (incl. cross-language pin) - mizan-django: 348 pass, 21 skip, 0 fail - mizan-ts: edge-compat 34/34 pass — protocol invariant holds, the moved Python derive_cache_key still produces the exact hex digests TS pins against. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
0
cores/mizan-python/src/mizan_core/__init__.py
Normal file
0
cores/mizan-python/src/mizan_core/__init__.py
Normal file
0
cores/mizan-python/src/mizan_core/cache/__init__.py
vendored
Normal file
0
cores/mizan-python/src/mizan_core/cache/__init__.py
vendored
Normal file
59
cores/mizan-python/src/mizan_core/cache/keys.py
vendored
Normal file
59
cores/mizan-python/src/mizan_core/cache/keys.py
vendored
Normal file
@@ -0,0 +1,59 @@
|
||||
"""
|
||||
Cache key derivation — HMAC-SHA256 over JSON-canonical form.
|
||||
|
||||
Protocol-critical: every Mizan adapter must produce identical output
|
||||
for identical inputs. Cross-language conformance verified by pin tests.
|
||||
|
||||
Scoped purge recomputes the key directly — no reverse index needed.
|
||||
Broad purge uses a context prefix scan.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
from typing import Any
|
||||
|
||||
# Context prefix for broad purge (SCAN pattern)
|
||||
CONTEXT_KEY_PREFIX = "ctx:"
|
||||
|
||||
|
||||
def derive_cache_key(
|
||||
secret: str,
|
||||
context: str,
|
||||
params: dict[str, Any],
|
||||
user_id: str | None = None,
|
||||
rev: int = 0,
|
||||
) -> str:
|
||||
"""
|
||||
Derive a deterministic HMAC-SHA256 cache key.
|
||||
|
||||
Returns a prefixed key: "ctx:{context}:{hmac_hex}" so that
|
||||
broad purge can SCAN by prefix "ctx:{context}:*".
|
||||
"""
|
||||
def _normalize(v: Any) -> str:
|
||||
"""Normalize values for cross-language HMAC consistency.
|
||||
Python str(True)="True" but JS String(true)="true". Use JSON-native forms."""
|
||||
if v is True:
|
||||
return "true"
|
||||
if v is False:
|
||||
return "false"
|
||||
if v is None:
|
||||
return "null"
|
||||
return str(v)
|
||||
|
||||
sorted_params = {k: _normalize(v) for k, v in sorted(params.items())}
|
||||
|
||||
key_data: dict[str, Any] = {"c": context, "p": sorted_params, "r": rev}
|
||||
if user_id is not None:
|
||||
key_data["u"] = str(user_id)
|
||||
|
||||
message = json.dumps(key_data, sort_keys=True, separators=(",", ":"))
|
||||
hmac_hex = hmac.new(
|
||||
secret.encode("utf-8"),
|
||||
message.encode("utf-8"),
|
||||
hashlib.sha256,
|
||||
).hexdigest()
|
||||
|
||||
return f"{CONTEXT_KEY_PREFIX}{context}:{hmac_hex}"
|
||||
169
cores/mizan-python/src/mizan_core/mwt.py
Normal file
169
cores/mizan-python/src/mizan_core/mwt.py
Normal file
@@ -0,0 +1,169 @@
|
||||
"""
|
||||
MWT (Mizan Web Token) — Protocol-owned identity layer.
|
||||
|
||||
MWT is a standard JWT (RFC 7519, HMAC-SHA256) with Mizan-specific claims,
|
||||
traveling on the `X-Mizan-Token` header. It provides:
|
||||
|
||||
- `sub`: user_id for HMAC cache key derivation
|
||||
- `pkey`: permission state hash for staleness detection
|
||||
- `kid`: key ID in the JOSE header (per RFC 7515) for secret rotation
|
||||
- `aud`: audience binding to prevent cross-tenant replay
|
||||
- `nbf`: not-before to handle clock skew
|
||||
|
||||
MWT is issued from an authenticated Django session. The app handles
|
||||
authentication (session, social auth, etc.); Mizan issues MWT from
|
||||
the authenticated identity. Edge Workers and the origin-side cache
|
||||
validate MWT to extract user identity for cache operations.
|
||||
|
||||
Usage:
|
||||
from mizan.mwt import create_mwt, decode_mwt, MWTUser
|
||||
|
||||
Configuration:
|
||||
MIZAN_MWT_SECRET: MWT signing key (separate from MIZAN_CACHE_SECRET)
|
||||
MIZAN_MWT_TTL: token lifetime in seconds (default: 300)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import logging
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
import jwt
|
||||
|
||||
logger = logging.getLogger("mizan.mwt")
|
||||
|
||||
|
||||
@dataclass
|
||||
class MWTPayload:
|
||||
"""Decoded MWT claims."""
|
||||
sub: str # user_id
|
||||
staff: bool # is_staff
|
||||
super: bool # is_superuser
|
||||
pkey: str # permission state hash (full SHA-256 hex)
|
||||
kid: str # key ID (from JOSE header)
|
||||
aud: str # audience
|
||||
iat: int # issued at
|
||||
exp: int # expiration
|
||||
|
||||
|
||||
class MWTUser:
|
||||
"""
|
||||
Minimal user object created from MWT claims.
|
||||
|
||||
Used as request.user for MWT-authenticated requests.
|
||||
No database query required — all data comes from the token.
|
||||
"""
|
||||
|
||||
def __init__(self, payload: MWTPayload):
|
||||
self.id = int(payload.sub)
|
||||
self.pk = self.id
|
||||
self.is_staff = payload.staff
|
||||
self.is_superuser = payload.super
|
||||
self.is_authenticated = True
|
||||
self.is_anonymous = False
|
||||
self.is_active = True
|
||||
self.pkey = payload.pkey
|
||||
|
||||
def __str__(self) -> str:
|
||||
return f"MWTUser(id={self.id})"
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"MWTUser(id={self.id}, pkey={self.pkey[:8]}...)"
|
||||
|
||||
|
||||
def compute_permission_key(user: Any) -> str:
|
||||
"""
|
||||
Compute a deterministic hash of the user's permission state.
|
||||
|
||||
Includes is_staff, is_superuser, and all Django permissions.
|
||||
When the MWT expires and is refreshed, the new pkey reflects
|
||||
any permission changes. The short TTL controls the staleness window.
|
||||
|
||||
Returns the full 64-character SHA-256 hex digest.
|
||||
"""
|
||||
perms = sorted(user.get_all_permissions()) if hasattr(user, "get_all_permissions") else []
|
||||
staff = "1" if getattr(user, "is_staff", False) else "0"
|
||||
superuser = "1" if getattr(user, "is_superuser", False) else "0"
|
||||
blob = f"{staff}:{superuser}:{','.join(perms)}"
|
||||
return hashlib.sha256(blob.encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
def create_mwt(
|
||||
user: Any,
|
||||
secret: str,
|
||||
ttl: int = 300,
|
||||
audience: str = "mizan",
|
||||
kid: str = "v1",
|
||||
) -> str:
|
||||
"""
|
||||
Create an MWT from an authenticated Django user.
|
||||
|
||||
Args:
|
||||
user: Django user object (must have pk, is_staff, is_superuser).
|
||||
secret: MIZAN_MWT_SECRET signing key.
|
||||
ttl: Token lifetime in seconds (default: 300 = 5 minutes).
|
||||
audience: Audience claim for cross-tenant protection.
|
||||
kid: Key ID placed in JOSE header (per RFC 7515) for rotation.
|
||||
|
||||
Returns:
|
||||
Encoded JWT string.
|
||||
"""
|
||||
now = int(time.time())
|
||||
payload = {
|
||||
"sub": str(user.pk),
|
||||
"staff": getattr(user, "is_staff", False),
|
||||
"super": getattr(user, "is_superuser", False),
|
||||
"pkey": compute_permission_key(user),
|
||||
"aud": audience,
|
||||
"iat": now,
|
||||
"nbf": now,
|
||||
"exp": now + ttl,
|
||||
}
|
||||
# kid goes in the JOSE header per RFC 7515, not the payload
|
||||
headers = {"kid": kid}
|
||||
return jwt.encode(payload, secret, algorithm="HS256", headers=headers)
|
||||
|
||||
|
||||
def decode_mwt(
|
||||
token: str,
|
||||
secret: str,
|
||||
audience: str = "mizan",
|
||||
) -> MWTPayload | None:
|
||||
"""
|
||||
Decode and validate an MWT.
|
||||
|
||||
Returns MWTPayload on success, None on any failure (expired, invalid
|
||||
signature, wrong audience, not-yet-valid, malformed).
|
||||
"""
|
||||
try:
|
||||
# Decode header first to extract kid
|
||||
unverified_header = jwt.get_unverified_header(token)
|
||||
kid = unverified_header.get("kid", "v1")
|
||||
|
||||
data = jwt.decode(
|
||||
token,
|
||||
secret,
|
||||
algorithms=["HS256"],
|
||||
audience=audience,
|
||||
)
|
||||
except jwt.PyJWTError:
|
||||
logger.debug("MWT decode failed", exc_info=True)
|
||||
return None
|
||||
|
||||
try:
|
||||
return MWTPayload(
|
||||
sub=data["sub"],
|
||||
staff=data.get("staff", False),
|
||||
super=data.get("super", False),
|
||||
pkey=data.get("pkey", ""),
|
||||
kid=kid,
|
||||
aud=audience,
|
||||
iat=data["iat"],
|
||||
exp=data["exp"],
|
||||
)
|
||||
except (KeyError, TypeError):
|
||||
logger.debug("MWT payload missing required claims", exc_info=True)
|
||||
return None
|
||||
Reference in New Issue
Block a user