Files
studio/tests-unit/middleware_test/iam_auth_test.py
T
hanzo-dev 748e335add studio: native IAM auth + multi-tenant isolation + GPU federation
Auth (middleware/iam_auth_middleware.py): local JWKS JWT validation against
Hanzo IAM (signature + exp + iss + aud=hanzo-studio; org from the `owner`
claim). Standard OIDC Authorization Code flow with PKCE and a /callback
session cookie for the standalone studio.hanzo.ai app; anonymous localhost
bypass unchanged. verify_jwt() extracted as a pure, unit-tested function.

Tenancy (folder_paths.py, app/user_manager.py): make get_public_user_directory
org-rooted so multi-tenant userdata resolves under user/orgs/{org}/... (it
previously always returned None). Scope execution outputs to output/orgs/{org}/
via set_execution_org(), bound by the prompt worker from the queue item org_id.
Also block a token subject from escaping into a System User namespace.

Federation (middleware/{worker_client,prompt_router}.py, server.py): move the
worker registry + execute surface to /v1/workers/register, /v1/workers,
/v1/worker/execute; add X-Worker-Token coordinator trust. Outbound pull channel
for NAT'd local boxes (GB10) specced in docs/federation.md.

Deploy: hanzo.yml + .github/workflows/cicd.yml (hanzoai/ci reusable) build
ghcr.io/hanzoai/studio and roll the studio operator Service CR at
studio.hanzo.ai. PyJWT[crypto] pinned.

Tests: tests-unit/middleware_test/iam_auth_test.py (RS256 sign/verify, state
CSRF, path exemptions) + folder_paths_test/org_scoping_test.py; 140 passing
across the auth/tenancy/user-manager/prompt-server suites.
2026-07-03 14:38:19 -07:00

145 lines
4.3 KiB
Python

"""Tests for the IAM auth middleware's pure verification + session helpers.
The JWT path is exercised end to end with a locally generated RSA keypair:
sign an access token like Hanzo IAM would, then verify it through the same
code the middleware uses. Covers signature, exp, iss, aud and the org claim.
"""
import time
import jwt
import pytest
from cryptography.hazmat.primitives.asymmetric import rsa
from middleware import iam_auth_middleware as m
ISS = "https://hanzo.id"
AUD = "hanzo-studio"
@pytest.fixture(scope="module")
def keys():
priv = rsa.generate_private_key(public_exponent=65537, key_size=2048)
return priv, priv.public_key()
def _token(priv, **overrides):
now = int(time.time())
claims = {
"sub": "hanzo/alice",
"iss": ISS,
"aud": AUD,
"exp": now + 300,
"iat": now,
"owner": "acme",
"name": "Alice",
"email": "alice@acme.com",
"picture": "https://cdn/x.png",
}
claims.update(overrides)
for k in [k for k, v in claims.items() if v is None]:
del claims[k]
return jwt.encode(claims, priv, algorithm="RS256", headers={"kid": "test"})
def test_valid_token_extracts_org(keys):
priv, pub = keys
user = m.verify_jwt(_token(priv), pub, ISS, AUD)
assert user is not None
assert user["org_id"] == "acme"
assert user["sub"] == "hanzo/alice"
assert user["name"] == "Alice"
assert user["email"] == "alice@acme.com"
assert user["avatar"] == "https://cdn/x.png"
def test_organization_claim_fallback(keys):
priv, pub = keys
tok = _token(priv, owner=None, organization="globex")
assert m.verify_jwt(tok, pub, ISS, AUD)["org_id"] == "globex"
def test_org_defaults_when_absent(keys):
priv, pub = keys
tok = _token(priv, owner=None, organization=None)
assert m.verify_jwt(tok, pub, ISS, AUD)["org_id"] == "default"
def test_preferred_username_fallback(keys):
priv, pub = keys
tok = _token(priv, name=None, preferred_username="alice")
assert m.verify_jwt(tok, pub, ISS, AUD)["name"] == "alice"
def test_wrong_audience_rejected(keys):
priv, pub = keys
assert m.verify_jwt(_token(priv, aud="someone-else"), pub, ISS, AUD) is None
def test_wrong_issuer_rejected(keys):
priv, pub = keys
assert m.verify_jwt(_token(priv, iss="https://evil.example"), pub, ISS, AUD) is None
def test_expired_rejected(keys):
priv, pub = keys
assert m.verify_jwt(_token(priv, exp=int(time.time()) - 10), pub, ISS, AUD) is None
def test_missing_exp_rejected(keys):
priv, pub = keys
assert m.verify_jwt(_token(priv, exp=None), pub, ISS, AUD) is None
def test_bad_signature_rejected(keys):
priv, _pub = keys
other = rsa.generate_private_key(public_exponent=65537, key_size=2048).public_key()
assert m.verify_jwt(_token(priv), other, ISS, AUD) is None
def test_aud_array_form_accepted(keys):
"""golang-jwt serializes a single audience as a string but multi as array;
accept both."""
priv, pub = keys
assert m.verify_jwt(_token(priv, aud=[AUD, "other"]), pub, ISS, AUD)["org_id"] == "acme"
# --- signed OAuth state (CSRF) ---
def test_state_roundtrip():
payload = {"n": "nonce", "v": "verifier", "t": "/workflows", "exp": int(time.time()) + 60}
blob = m._sign_state(payload, "secret")
got = m._verify_state(blob, "secret")
assert got["n"] == "nonce" and got["t"] == "/workflows"
def test_state_tamper_rejected():
blob = m._sign_state({"n": "n", "exp": int(time.time()) + 60}, "secret")
body, mac = blob.split(".", 1)
assert m._verify_state(f"{body}x.{mac}", "secret") is None
def test_state_wrong_secret_rejected():
blob = m._sign_state({"n": "n", "exp": int(time.time()) + 60}, "secret")
assert m._verify_state(blob, "other") is None
def test_state_expired_rejected():
blob = m._sign_state({"n": "n", "exp": int(time.time()) - 1}, "secret")
assert m._verify_state(blob, "secret") is None
# --- path exemptions ---
@pytest.mark.parametrize("path", [
"/", "/callback", "/health", "/api/features",
"/assets/x.js", "/main.css", "/logo.svg",
"/v1/workers/register", "/v1/worker/execute",
])
def test_public_paths(path):
assert m._is_public_path(path) is True
@pytest.mark.parametrize("path", ["/prompt", "/api/prompt", "/userdata/x", "/v1/compute/config"])
def test_protected_paths(path):
assert m._is_public_path(path) is False