From 37e61c646bdc617ad1faea5aeb96592ae6cd3e88 Mon Sep 17 00:00:00 2001 From: Ryth Azhur Date: Wed, 6 May 2026 01:32:54 -0400 Subject: [PATCH] Extract Layer 1 to cores/mizan-python (mizan-core) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- Makefile | 1 + backends/mizan-django/pyproject.toml | 4 + .../mizan-django/src/mizan/cache/__init__.py | 2 +- .../mizan-django/src/mizan/client/executor.py | 2 +- .../mizan-django/src/mizan/jwt/functions.py | 2 +- .../mizan-django/src/mizan/tests/test_core.py | 192 +----------------- cores/mizan-python/pyproject.toml | 26 +++ cores/mizan-python/src/mizan_core/__init__.py | 0 .../src/mizan_core/cache/__init__.py | 0 .../src/mizan_core}/cache/keys.py | 0 .../mizan-python/src/mizan_core}/mwt.py | 0 cores/mizan-python/tests/__init__.py | 0 cores/mizan-python/tests/test_keys.py | 68 +++++++ cores/mizan-python/tests/test_mwt.py | 97 +++++++++ 14 files changed, 202 insertions(+), 192 deletions(-) create mode 100644 cores/mizan-python/pyproject.toml create mode 100644 cores/mizan-python/src/mizan_core/__init__.py create mode 100644 cores/mizan-python/src/mizan_core/cache/__init__.py rename {backends/mizan-django/src/mizan => cores/mizan-python/src/mizan_core}/cache/keys.py (100%) rename {backends/mizan-django/src/mizan => cores/mizan-python/src/mizan_core}/mwt.py (100%) create mode 100644 cores/mizan-python/tests/__init__.py create mode 100644 cores/mizan-python/tests/test_keys.py create mode 100644 cores/mizan-python/tests/test_mwt.py diff --git a/Makefile b/Makefile index 7a6ab7e..16bc8dc 100644 --- a/Makefile +++ b/Makefile @@ -6,6 +6,7 @@ REACT = frontends/mizan-react # ─── Setup ─────────────────────────────────────────────────────────────────── install: + cd cores/mizan-python && uv pip install -e . cd $(DJANGO) && uv pip install -e ".[dev,channels]" cd $(REACT) && npm install diff --git a/backends/mizan-django/pyproject.toml b/backends/mizan-django/pyproject.toml index 1d2249f..e48d80a 100644 --- a/backends/mizan-django/pyproject.toml +++ b/backends/mizan-django/pyproject.toml @@ -5,6 +5,7 @@ description = "Django + React server functions framework" readme = "README.md" requires-python = ">=3.10" dependencies = [ + "mizan-core", "django>=5.0", "django-ninja>=1.0", "django-readers>=2.0", @@ -12,6 +13,9 @@ dependencies = [ "PyJWT>=2.0", ] +[tool.uv.sources] +mizan-core = { path = "../../cores/mizan-python", editable = true } + [project.optional-dependencies] cache = [ "redis>=5.0", diff --git a/backends/mizan-django/src/mizan/cache/__init__.py b/backends/mizan-django/src/mizan/cache/__init__.py index a4780b6..eec9d32 100644 --- a/backends/mizan-django/src/mizan/cache/__init__.py +++ b/backends/mizan-django/src/mizan/cache/__init__.py @@ -16,7 +16,7 @@ import threading from typing import Any from .backend import CacheBackend, MemoryCache, RedisCache -from .keys import derive_cache_key, CONTEXT_KEY_PREFIX +from mizan_core.cache.keys import derive_cache_key, CONTEXT_KEY_PREFIX logger = logging.getLogger("mizan.cache") diff --git a/backends/mizan-django/src/mizan/client/executor.py b/backends/mizan-django/src/mizan/client/executor.py index f5bd72f..19af778 100644 --- a/backends/mizan-django/src/mizan/client/executor.py +++ b/backends/mizan-django/src/mizan/client/executor.py @@ -513,7 +513,7 @@ def _try_mwt_auth(request: HttpRequest) -> bool: ) return False - from mizan.mwt import decode_mwt, MWTUser + from mizan_core.mwt import decode_mwt, MWTUser payload = decode_mwt(token, settings.mwt_secret) if payload is None: diff --git a/backends/mizan-django/src/mizan/jwt/functions.py b/backends/mizan-django/src/mizan/jwt/functions.py index bca90b5..bea2aaa 100644 --- a/backends/mizan-django/src/mizan/jwt/functions.py +++ b/backends/mizan-django/src/mizan/jwt/functions.py @@ -10,7 +10,7 @@ from pydantic import BaseModel from mizan.client import client from mizan.jwt.tokens import create_token_pair, refresh_tokens -from mizan.mwt import create_mwt +from mizan_core.mwt import create_mwt class TokenPairOutput(BaseModel): diff --git a/backends/mizan-django/src/mizan/tests/test_core.py b/backends/mizan-django/src/mizan/tests/test_core.py index 55ab631..67a44c7 100644 --- a/backends/mizan-django/src/mizan/tests/test_core.py +++ b/backends/mizan-django/src/mizan/tests/test_core.py @@ -2778,83 +2778,6 @@ class PrivateAndRouteTests(TestCase): # ── Cache conformance tests ──────────────────────────────────────────────── -class CacheKeyDerivationTests(TestCase): - """Tests that HMAC cache key derivation is deterministic and correct.""" - - SECRET = "test-cache-secret" - - def test_deterministic_output(self): - """Same inputs always produce the same key.""" - from mizan.cache.keys import derive_cache_key - - key1 = derive_cache_key(self.SECRET, "user", {"user_id": "5"}) - key2 = derive_cache_key(self.SECRET, "user", {"user_id": "5"}) - self.assertEqual(key1, key2) - self.assertTrue(key1.startswith("ctx:user:")) - self.assertEqual(len(key1), len("ctx:user:") + 64) # prefix + SHA-256 hex - - def test_param_order_irrelevant(self): - """Parameter ordering does not affect the key.""" - from mizan.cache.keys import derive_cache_key - - key1 = derive_cache_key(self.SECRET, "ctx", {"a": "1", "b": "2"}) - key2 = derive_cache_key(self.SECRET, "ctx", {"b": "2", "a": "1"}) - self.assertEqual(key1, key2) - - def test_different_user_ids_different_keys(self): - """Different user_ids produce different cache keys.""" - from mizan.cache.keys import derive_cache_key - - key1 = derive_cache_key(self.SECRET, "user", {"user_id": "5"}, user_id="5") - key2 = derive_cache_key(self.SECRET, "user", {"user_id": "5"}, user_id="6") - self.assertNotEqual(key1, key2) - - def test_rev_changes_key(self): - """Different rev values produce different cache keys.""" - from mizan.cache.keys import derive_cache_key - - key1 = derive_cache_key(self.SECRET, "user", {"user_id": "5"}, rev=0) - key2 = derive_cache_key(self.SECRET, "user", {"user_id": "5"}, rev=1) - self.assertNotEqual(key1, key2) - - def test_no_delimiter_collision(self): - """JSON-canonical form prevents delimiter-free concatenation collisions.""" - from mizan.cache.keys import derive_cache_key - - # "user" + user_id="12" + params="3" vs "user1" + user_id="2" + params="3" - key1 = derive_cache_key(self.SECRET, "user", {"id": "3"}, user_id="12") - key2 = derive_cache_key(self.SECRET, "user1", {"id": "3"}, user_id="2") - self.assertNotEqual(key1, key2) - - def test_public_vs_user_scoped(self): - """Public (no user_id) and user-scoped produce different keys.""" - from mizan.cache.keys import derive_cache_key - - public = derive_cache_key(self.SECRET, "products", {"id": "1"}) - scoped = derive_cache_key(self.SECRET, "products", {"id": "1"}, user_id="5") - self.assertNotEqual(public, scoped) - - def test_cross_language_pin(self): - """Pinned HMAC values — must match TypeScript adapter exactly.""" - from mizan.cache.keys import derive_cache_key - - pin_secret = "test-pin-secret-that-is-32bytes!" - - public_key = derive_cache_key(pin_secret, "user", {"user_id": "5"}, rev=0) - self.assertEqual( - public_key, - "ctx:user:605a1ca5ad5994e9b765c8d1b330474c2a0d51a7b8fbbdc402f992da7ba902f6", - ) - - user_scoped_key = derive_cache_key( - pin_secret, "user", {"user_id": "5"}, user_id="5", rev=0, - ) - self.assertEqual( - user_scoped_key, - "ctx:user:30fc08eb46ee4ff2cf7d317e97dca90fd616511e0587304416f71dc863338dc2", - ) - - class CacheBackendTests(TestCase): """Tests for MemoryCache backend operations.""" @@ -3081,7 +3004,7 @@ class RevParameterTests(TestCase): def test_rev_changes_cache_key(self): """Different rev values produce different HMAC cache keys.""" - from mizan.cache.keys import derive_cache_key + from mizan_core.cache.keys import derive_cache_key key_v0 = derive_cache_key("secret", "ctx", {"id": "1"}, rev=0) key_v1 = derive_cache_key("secret", "ctx", {"id": "1"}, rev=1) @@ -3214,7 +3137,7 @@ class CachePolicyIntegrationTests(TestCase): def test_effective_rev_is_maximum(self): """Context with mixed revs uses the maximum for cache key.""" - from mizan.cache.keys import derive_cache_key + from mizan_core.cache.keys import derive_cache_key from mizan.cache import set_cache, reset_cache from mizan.cache.backend import MemoryCache from mizan.setup.settings import clear_settings_cache @@ -3252,115 +3175,6 @@ class CachePolicyIntegrationTests(TestCase): # ── MWT (Mizan Web Token) tests ──────────────────────────────────────────── -class MWTCreationTests(TestCase): - """Tests for MWT creation and decoding.""" - - SECRET = "test-mwt-secret-that-is-32bytes!" - - def _make_user(self, **kwargs): - user = MagicMock() - user.pk = kwargs.get("pk", 1) - user.is_staff = kwargs.get("is_staff", False) - user.is_superuser = kwargs.get("is_superuser", False) - user.get_all_permissions = MagicMock(return_value=kwargs.get("perms", set())) - return user - - def test_create_and_decode(self): - """Create an MWT and decode it successfully.""" - from mizan.mwt import create_mwt, decode_mwt - - user = self._make_user(pk=42, is_staff=True) - token = create_mwt(user, self.SECRET, ttl=300) - payload = decode_mwt(token, self.SECRET) - - self.assertIsNotNone(payload) - self.assertEqual(payload.sub, "42") - self.assertTrue(payload.staff) - self.assertFalse(payload.super) - self.assertEqual(payload.kid, "v1") - self.assertEqual(len(payload.pkey), 64) - - def test_decode_expired(self): - """Expired MWT returns None.""" - from mizan.mwt import create_mwt, decode_mwt - - user = self._make_user() - token = create_mwt(user, self.SECRET, ttl=-1) - payload = decode_mwt(token, self.SECRET) - self.assertIsNone(payload) - - def test_decode_wrong_secret(self): - """MWT signed with wrong secret returns None.""" - from mizan.mwt import create_mwt, decode_mwt - - user = self._make_user() - token = create_mwt(user, self.SECRET) - payload = decode_mwt(token, "wrong-secret") - self.assertIsNone(payload) - - def test_decode_wrong_audience(self): - """MWT with wrong audience returns None.""" - from mizan.mwt import create_mwt, decode_mwt - - user = self._make_user() - token = create_mwt(user, self.SECRET, audience="app1") - payload = decode_mwt(token, self.SECRET, audience="app2") - self.assertIsNone(payload) - - def test_mwt_user_has_pkey(self): - """MWTUser carries the permission key.""" - from mizan.mwt import create_mwt, decode_mwt, MWTUser - - user = self._make_user(pk=5, perms={"app.view_thing"}) - token = create_mwt(user, self.SECRET) - payload = decode_mwt(token, self.SECRET) - mwt_user = MWTUser(payload) - - self.assertEqual(mwt_user.pk, 5) - self.assertTrue(mwt_user.is_authenticated) - self.assertEqual(len(mwt_user.pkey), 64) - - -class PermissionKeyTests(TestCase): - """Tests for pkey determinism and sensitivity.""" - - def _make_user(self, **kwargs): - user = MagicMock() - user.pk = kwargs.get("pk", 1) - user.is_staff = kwargs.get("is_staff", False) - user.is_superuser = kwargs.get("is_superuser", False) - user.get_all_permissions = MagicMock(return_value=kwargs.get("perms", set())) - return user - - def test_deterministic(self): - """Same permissions produce same pkey.""" - from mizan.mwt import compute_permission_key - - user = self._make_user(perms={"app.view_thing", "app.add_thing"}) - pkey1 = compute_permission_key(user) - pkey2 = compute_permission_key(user) - self.assertEqual(pkey1, pkey2) - - def test_changes_on_permission_change(self): - """Different permissions produce different pkey.""" - from mizan.mwt import compute_permission_key - - user1 = self._make_user(perms={"app.view_thing"}) - user2 = self._make_user(perms={"app.view_thing", "app.add_thing"}) - self.assertNotEqual(compute_permission_key(user1), compute_permission_key(user2)) - - def test_changes_on_staff_change(self): - """Staff status change produces different pkey.""" - from mizan.mwt import compute_permission_key - - user_normal = self._make_user(is_staff=False) - user_staff = self._make_user(is_staff=True) - self.assertNotEqual( - compute_permission_key(user_normal), - compute_permission_key(user_staff), - ) - - class MWTAuthIntegrationTests(TestCase): """Tests for MWT authentication in the executor.""" @@ -3387,7 +3201,7 @@ class MWTAuthIntegrationTests(TestCase): def test_mwt_auth_via_header(self): """Request with valid X-Mizan-Token authenticates.""" - from mizan.mwt import create_mwt + from mizan_core.mwt import create_mwt from mizan.client.executor import _try_mwt_auth from django.test import override_settings diff --git a/cores/mizan-python/pyproject.toml b/cores/mizan-python/pyproject.toml new file mode 100644 index 0000000..72b8786 --- /dev/null +++ b/cores/mizan-python/pyproject.toml @@ -0,0 +1,26 @@ +[project] +name = "mizan-core" +version = "0.1.0" +description = "Mizan Python core — HMAC cache keys, MWT identity. Framework-agnostic primitives shared by every Python backend adapter." +requires-python = ">=3.10" +dependencies = [ + "PyJWT>=2.0", +] + +[project.optional-dependencies] +dev = [ + "pytest>=8.0", +] + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[tool.hatch.build.targets.wheel] +packages = ["src/mizan_core"] + +[tool.pytest.ini_options] +pythonpath = ["src"] +testpaths = ["tests"] +python_classes = ["*Tests", "*Test", "Test*"] +python_functions = ["test_*"] diff --git a/cores/mizan-python/src/mizan_core/__init__.py b/cores/mizan-python/src/mizan_core/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/cores/mizan-python/src/mizan_core/cache/__init__.py b/cores/mizan-python/src/mizan_core/cache/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backends/mizan-django/src/mizan/cache/keys.py b/cores/mizan-python/src/mizan_core/cache/keys.py similarity index 100% rename from backends/mizan-django/src/mizan/cache/keys.py rename to cores/mizan-python/src/mizan_core/cache/keys.py diff --git a/backends/mizan-django/src/mizan/mwt.py b/cores/mizan-python/src/mizan_core/mwt.py similarity index 100% rename from backends/mizan-django/src/mizan/mwt.py rename to cores/mizan-python/src/mizan_core/mwt.py diff --git a/cores/mizan-python/tests/__init__.py b/cores/mizan-python/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/cores/mizan-python/tests/test_keys.py b/cores/mizan-python/tests/test_keys.py new file mode 100644 index 0000000..0f60abf --- /dev/null +++ b/cores/mizan-python/tests/test_keys.py @@ -0,0 +1,68 @@ +"""Unit tests for cache key derivation. Includes the cross-language pin against mizan-ts.""" + +from unittest import TestCase + +from mizan_core.cache.keys import derive_cache_key + + +class CacheKeyDerivationTests(TestCase): + """Tests that HMAC cache key derivation is deterministic and correct.""" + + SECRET = "test-cache-secret" + + def test_deterministic_output(self): + """Same inputs always produce the same key.""" + key1 = derive_cache_key(self.SECRET, "user", {"user_id": "5"}) + key2 = derive_cache_key(self.SECRET, "user", {"user_id": "5"}) + self.assertEqual(key1, key2) + self.assertTrue(key1.startswith("ctx:user:")) + self.assertEqual(len(key1), len("ctx:user:") + 64) # prefix + SHA-256 hex + + def test_param_order_irrelevant(self): + """Parameter ordering does not affect the key.""" + key1 = derive_cache_key(self.SECRET, "ctx", {"a": "1", "b": "2"}) + key2 = derive_cache_key(self.SECRET, "ctx", {"b": "2", "a": "1"}) + self.assertEqual(key1, key2) + + def test_different_user_ids_different_keys(self): + """Different user_ids produce different cache keys.""" + key1 = derive_cache_key(self.SECRET, "user", {"user_id": "5"}, user_id="5") + key2 = derive_cache_key(self.SECRET, "user", {"user_id": "5"}, user_id="6") + self.assertNotEqual(key1, key2) + + def test_rev_changes_key(self): + """Different rev values produce different cache keys.""" + key1 = derive_cache_key(self.SECRET, "user", {"user_id": "5"}, rev=0) + key2 = derive_cache_key(self.SECRET, "user", {"user_id": "5"}, rev=1) + self.assertNotEqual(key1, key2) + + def test_no_delimiter_collision(self): + """JSON-canonical form prevents delimiter-free concatenation collisions.""" + # "user" + user_id="12" + params="3" vs "user1" + user_id="2" + params="3" + key1 = derive_cache_key(self.SECRET, "user", {"id": "3"}, user_id="12") + key2 = derive_cache_key(self.SECRET, "user1", {"id": "3"}, user_id="2") + self.assertNotEqual(key1, key2) + + def test_public_vs_user_scoped(self): + """Public (no user_id) and user-scoped produce different keys.""" + public = derive_cache_key(self.SECRET, "products", {"id": "1"}) + scoped = derive_cache_key(self.SECRET, "products", {"id": "1"}, user_id="5") + self.assertNotEqual(public, scoped) + + def test_cross_language_pin(self): + """Pinned HMAC values — must match TypeScript adapter exactly.""" + pin_secret = "test-pin-secret-that-is-32bytes!" + + public_key = derive_cache_key(pin_secret, "user", {"user_id": "5"}, rev=0) + self.assertEqual( + public_key, + "ctx:user:605a1ca5ad5994e9b765c8d1b330474c2a0d51a7b8fbbdc402f992da7ba902f6", + ) + + user_scoped_key = derive_cache_key( + pin_secret, "user", {"user_id": "5"}, user_id="5", rev=0, + ) + self.assertEqual( + user_scoped_key, + "ctx:user:30fc08eb46ee4ff2cf7d317e97dca90fd616511e0587304416f71dc863338dc2", + ) diff --git a/cores/mizan-python/tests/test_mwt.py b/cores/mizan-python/tests/test_mwt.py new file mode 100644 index 0000000..9171e6f --- /dev/null +++ b/cores/mizan-python/tests/test_mwt.py @@ -0,0 +1,97 @@ +"""Unit tests for MWT creation, decoding, and permission key derivation.""" + +from unittest import TestCase +from unittest.mock import MagicMock + +from mizan_core.mwt import ( + MWTUser, + compute_permission_key, + create_mwt, + decode_mwt, +) + + +def _make_user(**kwargs): + user = MagicMock() + user.pk = kwargs.get("pk", 1) + user.is_staff = kwargs.get("is_staff", False) + user.is_superuser = kwargs.get("is_superuser", False) + user.get_all_permissions = MagicMock(return_value=kwargs.get("perms", set())) + return user + + +class MWTCreationTests(TestCase): + """Tests for MWT creation and decoding.""" + + SECRET = "test-mwt-secret-that-is-32bytes!" + + def test_create_and_decode(self): + """Create an MWT and decode it successfully.""" + user = _make_user(pk=42, is_staff=True) + token = create_mwt(user, self.SECRET, ttl=300) + payload = decode_mwt(token, self.SECRET) + + self.assertIsNotNone(payload) + self.assertEqual(payload.sub, "42") + self.assertTrue(payload.staff) + self.assertFalse(payload.super) + self.assertEqual(payload.kid, "v1") + self.assertEqual(len(payload.pkey), 64) + + def test_decode_expired(self): + """Expired MWT returns None.""" + user = _make_user() + token = create_mwt(user, self.SECRET, ttl=-1) + payload = decode_mwt(token, self.SECRET) + self.assertIsNone(payload) + + def test_decode_wrong_secret(self): + """MWT signed with wrong secret returns None.""" + user = _make_user() + token = create_mwt(user, self.SECRET) + payload = decode_mwt(token, "wrong-secret") + self.assertIsNone(payload) + + def test_decode_wrong_audience(self): + """MWT with wrong audience returns None.""" + user = _make_user() + token = create_mwt(user, self.SECRET, audience="app1") + payload = decode_mwt(token, self.SECRET, audience="app2") + self.assertIsNone(payload) + + def test_mwt_user_has_pkey(self): + """MWTUser carries the permission key.""" + user = _make_user(pk=5, perms={"app.view_thing"}) + token = create_mwt(user, self.SECRET) + payload = decode_mwt(token, self.SECRET) + mwt_user = MWTUser(payload) + + self.assertEqual(mwt_user.pk, 5) + self.assertTrue(mwt_user.is_authenticated) + self.assertEqual(len(mwt_user.pkey), 64) + + +class PermissionKeyTests(TestCase): + """Tests for pkey determinism and sensitivity.""" + + def test_deterministic(self): + """Same permissions produce same pkey.""" + user = _make_user(perms={"app.view_thing", "app.add_thing"}) + pkey1 = compute_permission_key(user) + pkey2 = compute_permission_key(user) + self.assertEqual(pkey1, pkey2) + + def test_changes_on_permission_change(self): + """Different permissions produce different pkey.""" + user1 = _make_user(perms={"app.view_thing"}) + user2 = _make_user(perms={"app.view_thing", "app.add_thing"}) + self.assertNotEqual(compute_permission_key(user1), compute_permission_key(user2)) + + def test_changes_on_staff_change(self): + """Staff status change produces different pkey.""" + user_normal = _make_user(is_staff=False) + user_staff = _make_user(is_staff=True) + self.assertNotEqual( + compute_permission_key(user_normal), + compute_permission_key(user_staff), + )