mirror of
https://github.com/hanzoai/python-sdk.git
synced 2026-10-23 20:23:00 +00:00
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:
@@ -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"
|
||||
Reference in New Issue
Block a user