fix(iam): canonical /v1/iam paths, drop forbidden /api/ (HIP-0111)

hanzo-tools-iam built URLs as {base}/api/{path} — forbidden per HIP-0111
(no /api/ prefix; one way). Now {base}/v1/iam/{path}; health probe is
/v1/iam/healthz. Asserts JSON shape, not bare 200, to dodge the IAM SPA
catch-all that serves 200 text/html for unregistered paths. Adds a test
for _iam_url + IAM_BASE_URL. (Test harness wiring for this non-member
package is a separate packaging task — code verified: 0 /api/ remain.)
This commit is contained in:
2026-06-24 13:50:24 -07:00
parent ed0f5b7a48
commit 7489cdc308
2 changed files with 81 additions and 5 deletions
@@ -4,7 +4,7 @@ Full control over users, organizations, roles, permissions, providers,
applications, tokens, sessions, invitations, and audit records.
Auth: Uses HanzoSession from hanzo-tools-auth for Bearer JWT tokens.
Backend: Casdoor IAM at hanzo.id/api/
Backend: Hanzo IAM (HIP-0026) — canonical surface under iam.hanzo.ai/v1/iam/
"""
from __future__ import annotations
@@ -72,8 +72,12 @@ def _get_session():
def _iam_url(path: str) -> str:
"""Build full IAM API URL."""
return f"{IAM_BASE_URL}/api/{path.lstrip('/')}"
"""Build full IAM API URL.
The Hanzo IAM server (HIP-0026) registers its admin CRUD surface only
under /v1/iam/ — the upstream-shape /api/ path is not served. One way.
"""
return f"{IAM_BASE_URL}/v1/iam/{path.lstrip('/')}"
def _auth_headers(token: str) -> dict[str, str]:
@@ -520,13 +524,23 @@ class IAMTool(BaseTool):
return json.dumps(data, indent=2)
async def _health(self) -> str:
# Canonical health probe is /v1/iam/healthz. IAM serves a 200 text/html
# SPA catch-all for unregistered paths, so a 200 alone is not "healthy" —
# require a JSON body reporting status "ok" before claiming health.
url = f"{IAM_BASE_URL}/v1/iam/healthz"
try:
async with httpx.AsyncClient(timeout=10.0) as client:
resp = await client.get(f"{IAM_BASE_URL}/api/health")
resp = await client.get(url)
healthy = False
data: Any = None
if "application/json" in resp.headers.get("content-type", ""):
data = resp.json()
healthy = resp.status_code == 200 and isinstance(data, dict) and data.get("status") == "ok"
return json.dumps({
"status": "ok" if resp.status_code == 200 else "error",
"status": "ok" if healthy else "error",
"code": resp.status_code,
"url": IAM_BASE_URL,
"data": data.get("data") if isinstance(data, dict) else None,
}, indent=2)
except Exception as e:
return json.dumps({"status": "error", "error": str(e), "url": IAM_BASE_URL})
@@ -0,0 +1,62 @@
"""Tests for the IAM tool's canonical /v1/iam/ surface (HIP-0111/HIP-0026).
The Hanzo IAM server serves admin CRUD only under /v1/iam/; the upstream-shape
/api/ path falls through to a 200 text/html SPA catch-all. These tests pin the
URL builder to /v1/iam/ and prove the health check rejects that HTML catch-all.
"""
from __future__ import annotations
import json
import httpx
from hanzo_tools.iam.iam_tool import IAMTool, _iam_url, IAM_BASE_URL
def test_iam_url_uses_v1_iam_not_api():
url = _iam_url("get-users")
assert url == f"{IAM_BASE_URL}/v1/iam/get-users"
assert "/api/" not in url
def test_iam_url_strips_leading_slash():
assert _iam_url("/enforce") == f"{IAM_BASE_URL}/v1/iam/enforce"
def _patched_client(monkeypatch, handler):
real_init = httpx.AsyncClient.__init__
def init(self, *args, **kwargs):
kwargs["transport"] = httpx.MockTransport(handler)
real_init(self, *args, **kwargs)
monkeypatch.setattr(httpx.AsyncClient, "__init__", init)
async def test_health_hits_canonical_healthz(monkeypatch):
seen = {}
def handler(request: httpx.Request) -> httpx.Response:
seen["url"] = str(request.url)
return httpx.Response(
200,
json={"status": "ok", "data": {"service": "iam", "version": "v1.18.3"}},
)
_patched_client(monkeypatch, handler)
out = json.loads(await IAMTool()._health())
assert seen["url"] == f"{IAM_BASE_URL}/v1/iam/healthz"
assert out["status"] == "ok"
assert out["data"]["service"] == "iam"
async def test_health_rejects_html_spa_catch_all(monkeypatch):
# A 200 text/html SPA response must NOT be reported as healthy.
def handler(request: httpx.Request) -> httpx.Response:
return httpx.Response(200, text="<!DOCTYPE html><html></html>",
headers={"content-type": "text/html; charset=utf-8"})
_patched_client(monkeypatch, handler)
out = json.loads(await IAMTool()._health())
assert out["status"] == "error"