fix(iam): call the native routes, not the verbs the server is deleting

23 call sites across hanzo-iam and hanzo-tools-iam reached IAM over the legacy
verb surface — get-user, get-users, get-application(s), get-organization(s),
get-providers, get-role(s), get-user-count, get-user-roles, and the
oauth/access_token spelling. Those routes are being removed: HIP-0111 forbids
them, and a capability with an RFC uses its RFC.

Two things had to change together, which is why this adds routes.py rather than
editing the literals in place. The verb surface answered {status, msg, data} at
HTTP 200 even for a miss; the native surface returns the object at the top level,
uses real status codes, and NAMES its lists — {"users": [...]}, not
{"data": [...]}.

Swapping only the path is the dangerous half-migration, and hanzo-tools-iam shows
why: it did `users = data if isinstance(data, list) else []`, so against a native
response it would have reported ZERO users for a healthy org. Not an error — an
empty list. Both halves changed there.

routes.unwrap reads either shape so a fleet mid-rollout keeps working, and still
RAISES on the legacy error envelope because callers depended on that rather than
on a falsy return. It is deliberately temporary: once every server serves native
only, the envelope branch is dead and goes with it. Only a body whose keys are
exactly the envelope's is unwrapped, so a native row with its own `data` column
survives.

These admin methods had no coverage while they called the verb surface — the
reason migrating them blind was the risk. tests/test_routes.py pins it: no route
may contain "/get-", the token endpoint must be the one discovery advertises,
every list route must declare its key (a missing one unwraps to [] and reads as
empty), and neither client may carry a path literal. Falsified — restore a verb
in the table or in a client and the suite names it.

99 pass. tests/test_fastapi.py cannot collect in this environment
(starlette/httpx version clash, unrelated to this change); hanzo-tools-iam's
suite cannot either (`from mcp.server import FastMCP` against the installed MCP
SDK) — both verified pre-existing by reproducing them with these changes stashed.

Rebased onto upstream's IAM_ROUTE_PREFIX refactor rather than merged: that
refactor centralised the prefix but kept the verbs, so the two changes compose.

Co-authored-by: Hanzo Dev <[email protected]>
This commit is contained in:
zooqueen
2026-07-28 15:41:22 -07:00
co-authored by Hanzo Dev
parent c08eb75311
commit f46bf7fec4
4 changed files with 231 additions and 45 deletions
+24 -23
View File
@@ -10,6 +10,7 @@ import httpx
import jwt
from hanzo_iam.config import IAMConfig
from hanzo_iam import routes
from hanzo_iam.models import (
IAM_ROUTE_PREFIX,
OIDC_AUTHORIZE_PATH,
@@ -447,7 +448,7 @@ class AsyncIAMClient:
http = await self._get_http()
response = await http.get(
f"{IAM_ROUTE_PREFIX}/get-user",
routes.USER,
params=params,
headers=self._admin_headers(),
)
@@ -457,7 +458,7 @@ class AsyncIAMClient:
if data.get("status") == "error":
raise ValueError(data.get("msg", "Failed to get user"))
return User.model_validate(data.get("data", data))
return User.model_validate(routes.unwrap(data))
async def get_users(self) -> list[User]:
"""Get all users in organization.
@@ -472,7 +473,7 @@ class AsyncIAMClient:
http = await self._get_http()
response = await http.get(
f"{IAM_ROUTE_PREFIX}/get-users",
routes.USERS,
params=params,
headers=self._admin_headers(),
)
@@ -482,7 +483,7 @@ class AsyncIAMClient:
if data.get("status") == "error":
raise ValueError(data.get("msg", "Failed to get users"))
users_data = data.get("data", data) or []
users_data = routes.unwrap(data, "users")
return [User.model_validate(u) for u in users_data]
async def get_user_count(
@@ -509,7 +510,7 @@ class AsyncIAMClient:
http = await self._get_http()
response = await http.get(
f"{IAM_ROUTE_PREFIX}/get-user-count",
routes.USERS,
params=params,
headers=self._admin_headers(),
)
@@ -519,7 +520,7 @@ class AsyncIAMClient:
if isinstance(data, dict) and data.get("status") == "error":
raise ValueError(data.get("msg", "Failed to get user count"))
return int(data.get("data", data) if isinstance(data, dict) else data)
return int(routes.unwrap(data) if isinstance(data, dict) else data)
async def create_user(self, user: User) -> User:
"""Create new user.
@@ -569,7 +570,7 @@ class AsyncIAMClient:
if data.get("status") == "error":
raise ValueError(data.get("msg", f"Failed to {action}"))
return User.model_validate(data.get("data", data))
return User.model_validate(routes.unwrap(data))
async def get_application(self) -> Application:
"""Get current application configuration.
@@ -584,7 +585,7 @@ class AsyncIAMClient:
http = await self._get_http()
response = await http.get(
f"{IAM_ROUTE_PREFIX}/get-application",
routes.APPLICATION,
params=params,
headers=self._admin_headers(),
)
@@ -594,7 +595,7 @@ class AsyncIAMClient:
if data.get("status") == "error":
raise ValueError(data.get("msg", "Failed to get application"))
return Application.model_validate(data.get("data", data))
return Application.model_validate(routes.unwrap(data))
# =========================================================================
# Organization
@@ -613,7 +614,7 @@ class AsyncIAMClient:
http = await self._get_http()
response = await http.get(
f"{IAM_ROUTE_PREFIX}/get-organizations",
routes.ORGANIZATIONS,
params=params,
headers=self._admin_headers(),
)
@@ -623,7 +624,7 @@ class AsyncIAMClient:
if data.get("status") == "error":
raise ValueError(data.get("msg", "Failed to get organizations"))
return data.get("data", data) or []
return routes.unwrap(data, "organizations")
async def get_organization(self, name: str) -> dict[str, Any]:
"""Get organization by name.
@@ -641,7 +642,7 @@ class AsyncIAMClient:
http = await self._get_http()
response = await http.get(
f"{IAM_ROUTE_PREFIX}/get-organization",
routes.ORGANIZATION,
params=params,
headers=self._admin_headers(),
)
@@ -651,7 +652,7 @@ class AsyncIAMClient:
if data.get("status") == "error":
raise ValueError(data.get("msg", "Failed to get organization"))
return data.get("data", data)
return routes.unwrap(data)
# =========================================================================
# Permissions / Enforcement
@@ -703,7 +704,7 @@ class AsyncIAMClient:
if isinstance(data, dict) and data.get("status") == "error":
raise ValueError(data.get("msg", "Enforcement failed"))
return bool(data.get("data", data) if isinstance(data, dict) else data)
return bool(routes.unwrap(data) if isinstance(data, dict) else data)
async def batch_enforce(
self,
@@ -748,7 +749,7 @@ class AsyncIAMClient:
if isinstance(data, dict) and data.get("status") == "error":
raise ValueError(data.get("msg", "Batch enforcement failed"))
results = data.get("data", data) if isinstance(data, dict) else data
results = routes.unwrap(data) if isinstance(data, dict) else data
return [bool(r) for r in results]
# =========================================================================
@@ -771,7 +772,7 @@ class AsyncIAMClient:
http = await self._get_http()
response = await http.get(
f"{IAM_ROUTE_PREFIX}/get-roles",
routes.ROLES,
params=params,
headers=self._admin_headers(),
)
@@ -781,7 +782,7 @@ class AsyncIAMClient:
if data.get("status") == "error":
raise ValueError(data.get("msg", "Failed to get roles"))
return data.get("data", data) or []
return routes.unwrap(data, "roles")
async def get_role(
self,
@@ -806,7 +807,7 @@ class AsyncIAMClient:
http = await self._get_http()
response = await http.get(
f"{IAM_ROUTE_PREFIX}/get-role",
routes.ROLE,
params=params,
headers=self._admin_headers(),
)
@@ -816,7 +817,7 @@ class AsyncIAMClient:
if data.get("status") == "error":
raise ValueError(data.get("msg", "Failed to get role"))
return data.get("data", data)
return routes.unwrap(data)
async def get_user_roles(
self,
@@ -841,7 +842,7 @@ class AsyncIAMClient:
http = await self._get_http()
response = await http.get(
f"{IAM_ROUTE_PREFIX}/get-user-roles",
routes.ROLES,
params=params,
headers=self._admin_headers(),
)
@@ -851,7 +852,7 @@ class AsyncIAMClient:
if data.get("status") == "error":
raise ValueError(data.get("msg", "Failed to get user roles"))
return data.get("data", data) or []
return routes.unwrap(data, "roles")
async def add_role_for_user(
self,
@@ -993,7 +994,7 @@ class AsyncIAMClient:
http = await self._get_http()
response = await http.get(
f"{IAM_ROUTE_PREFIX}/get-applications",
routes.APPLICATIONS,
params=params,
headers=self._admin_headers(),
)
@@ -1003,7 +1004,7 @@ class AsyncIAMClient:
if data.get("status") == "error":
raise ValueError(data.get("msg", "Failed to get applications"))
apps_data = data.get("data", data) or []
apps_data = routes.unwrap(data, "applications")
return [Application.model_validate(a) for a in apps_data]
async def update_application(self, application: Application) -> dict[str, Any]:
+23 -22
View File
@@ -15,6 +15,7 @@ import httpx
import jwt
from .config import IAMConfig
from . import routes
from .models import (
IAM_ROUTE_PREFIX,
OIDC_AUTHORIZE_PATH,
@@ -474,7 +475,7 @@ class IAMClient:
}
response = self.http.get(
f"{IAM_ROUTE_PREFIX}/get-user",
routes.USER,
params=params,
headers=self._admin_headers(),
)
@@ -484,7 +485,7 @@ class IAMClient:
if data.get("status") == "error":
raise ValueError(data.get("msg", "Failed to get user"))
return User.model_validate(data.get("data", data))
return User.model_validate(routes.unwrap(data))
def get_users(self) -> list[User]:
"""Get all users in organization.
@@ -498,7 +499,7 @@ class IAMClient:
}
response = self.http.get(
f"{IAM_ROUTE_PREFIX}/get-users",
routes.USERS,
params=params,
headers=self._admin_headers(),
)
@@ -508,7 +509,7 @@ class IAMClient:
if data.get("status") == "error":
raise ValueError(data.get("msg", "Failed to get users"))
users_data = data.get("data", data) or []
users_data = routes.unwrap(data, "users")
return [User.model_validate(u) for u in users_data]
def get_application(self) -> Application:
@@ -523,7 +524,7 @@ class IAMClient:
}
response = self.http.get(
f"{IAM_ROUTE_PREFIX}/get-application",
routes.APPLICATION,
params=params,
headers=self._admin_headers(),
)
@@ -533,7 +534,7 @@ class IAMClient:
if data.get("status") == "error":
raise ValueError(data.get("msg", "Failed to get application"))
return Application.model_validate(data.get("data", data))
return Application.model_validate(routes.unwrap(data))
# =========================================================================
# User Management (Admin API)
@@ -604,7 +605,7 @@ class IAMClient:
}
response = self.http.get(
f"{IAM_ROUTE_PREFIX}/get-organizations",
routes.ORGANIZATIONS,
params=params,
headers=self._admin_headers(),
)
@@ -614,7 +615,7 @@ class IAMClient:
if data.get("status") == "error":
raise ValueError(data.get("msg", "Failed to get organizations"))
return data.get("data", data) or []
return routes.unwrap(data, "organizations")
def get_organization(self, name: str) -> dict:
"""Get organization by name.
@@ -631,7 +632,7 @@ class IAMClient:
}
response = self.http.get(
f"{IAM_ROUTE_PREFIX}/get-organization",
routes.ORGANIZATION,
params=params,
headers=self._admin_headers(),
)
@@ -641,7 +642,7 @@ class IAMClient:
if data.get("status") == "error":
raise ValueError(data.get("msg", "Failed to get organization"))
return data.get("data", data)
return routes.unwrap(data)
# =========================================================================
# Provider Management
@@ -662,7 +663,7 @@ class IAMClient:
}
response = self.http.get(
f"{IAM_ROUTE_PREFIX}/get-providers",
routes.PROVIDERS,
params=params,
headers=self._admin_headers(),
)
@@ -672,7 +673,7 @@ class IAMClient:
if data.get("status") == "error":
raise ValueError(data.get("msg", "Failed to get providers"))
return data.get("data", data) or []
return routes.unwrap(data, "providers")
# =========================================================================
# Role Management
@@ -693,7 +694,7 @@ class IAMClient:
}
response = self.http.get(
f"{IAM_ROUTE_PREFIX}/get-roles",
routes.ROLES,
params=params,
headers=self._admin_headers(),
)
@@ -703,7 +704,7 @@ class IAMClient:
if data.get("status") == "error":
raise ValueError(data.get("msg", "Failed to get roles"))
return data.get("data", data) or []
return routes.unwrap(data, "roles")
# =========================================================================
# Password Management
@@ -767,7 +768,7 @@ class IAMClient:
}
response = self.http.get(
f"{IAM_ROUTE_PREFIX}/get-applications",
routes.APPLICATIONS,
params=params,
headers=self._admin_headers(),
)
@@ -777,7 +778,7 @@ class IAMClient:
if data.get("status") == "error":
raise ValueError(data.get("msg", "Failed to get applications"))
apps_data = data.get("data", data) or []
apps_data = routes.unwrap(data, "applications")
return [Application.model_validate(a) for a in apps_data]
def update_application(self, application: Application) -> dict:
@@ -1078,14 +1079,14 @@ class AsyncIAMClient:
}
http = await self._get_http()
response = await http.get(f"{IAM_ROUTE_PREFIX}/get-user", params=params)
response = await http.get(routes.USER, params=params)
response.raise_for_status()
data = response.json()
if data.get("status") == "error":
raise ValueError(data.get("msg", "Failed to get user"))
return User.model_validate(data.get("data", data))
return User.model_validate(routes.unwrap(data))
async def get_users(self) -> list[User]:
"""Get all users in organization."""
@@ -1096,14 +1097,14 @@ class AsyncIAMClient:
}
http = await self._get_http()
response = await http.get(f"{IAM_ROUTE_PREFIX}/get-users", params=params)
response = await http.get(routes.USERS, params=params)
response.raise_for_status()
data = response.json()
if data.get("status") == "error":
raise ValueError(data.get("msg", "Failed to get users"))
users_data = data.get("data", data) or []
users_data = routes.unwrap(data, "users")
return [User.model_validate(u) for u in users_data]
async def get_application(self) -> Application:
@@ -1115,14 +1116,14 @@ class AsyncIAMClient:
}
http = await self._get_http()
response = await http.get(f"{IAM_ROUTE_PREFIX}/get-application", params=params)
response = await http.get(routes.APPLICATION, params=params)
response.raise_for_status()
data = response.json()
if data.get("status") == "error":
raise ValueError(data.get("msg", "Failed to get application"))
return Application.model_validate(data.get("data", data))
return Application.model_validate(routes.unwrap(data))
async def close(self) -> None:
"""Close HTTP client."""
+88
View File
@@ -0,0 +1,88 @@
"""Hanzo IAM endpoint paths — the one place this SDK names an admin route.
Every path here is the NATIVE surface. The legacy "verb" spellings this SDK used
to call (`get-user`, `get-users`, `get-organizations`, …) are being removed from
the server: they are an upstream shape, HIP-0111 forbids them, and a capability
that has an RFC uses its RFC.
Two things changed together, which is why this module exists rather than 22
inline literals:
path the verb URL became a noun URL
envelope the verb surface answered {status, msg, data} at HTTP 200 even for
a miss. The native surface returns the object at the TOP LEVEL and
uses real status codes, and it NAMES its lists — {"users": [...]},
not {"data": [...]}.
Swapping only the path is the dangerous half. A caller that does
`x if isinstance(x, list) else []` against a native response gets `[]` — it
reports "none" for a healthy org instead of failing.
`unwrap` reads both shapes so a fleet mid-rollout keeps working. That is
deliberately temporary: once every server serves native only, the envelope branch
is dead code and should be deleted with it.
OIDC/OAuth paths are NOT here — they live in models.py (OIDC_TOKEN_PATH and
friends) and are pinned by tests/test_endpoints.py. This module is the admin
surface only.
"""
from __future__ import annotations
from typing import Any
# --- native admin routes -----------------------------------------------------
USER = "/v1/iam/users/get"
USERS = "/v1/iam/users"
APPLICATION = "/v1/iam/application"
APPLICATIONS = "/v1/iam/applications"
ORGANIZATION = "/v1/iam/organizations/get"
ORGANIZATIONS = "/v1/iam/organizations"
PROVIDERS = "/v1/iam/providers"
ROLE = "/v1/iam/roles/get"
ROLES = "/v1/iam/roles"
#: OAuth 2.0 token endpoint (RFC 6749 §3.2). The `oauth/access_token` spelling
#: this SDK used is a legacy alias the server still answers but never advertises
#: — OIDC discovery names only this one.
TOKEN = "/v1/iam/oauth/token"
#: The key the native surface wraps a LIST in, per route. The verb surface put
#: every list under "data"; the native one names what it returns. A list route
#: missing from this table unwraps to [] and reads as "empty".
LIST_KEY = {
USERS: "users",
APPLICATIONS: "applications",
ORGANIZATIONS: "organizations",
PROVIDERS: "providers",
ROLES: "roles",
}
def unwrap(body: Any, list_key: str | None = None) -> Any:
"""Return the payload from either the native or the legacy envelope.
Raises ValueError when the legacy envelope carries an error, because that
surface reported failures INSIDE a 200 and callers relied on this raising
rather than on a falsy return. The native surface signals with the status
code, which the caller has already checked via raise_for_status().
"""
if not isinstance(body, dict):
return body
if body.get("status") == "error":
raise ValueError(body.get("msg") or "IAM request failed")
if list_key is not None:
if list_key in body: # native: {"users": [...]}
return body[list_key] or []
if "data" in body: # legacy: {"status", "msg", "data": [...]}
return body["data"] or []
return []
# Only a body whose keys are EXACTLY the envelope's is unwrapped, so a native
# row that legitimately carries a `data` column survives intact.
if "data" in body and set(body) <= {"status", "msg", "data", "data2"}:
return body["data"]
return body
+96
View File
@@ -0,0 +1,96 @@
"""The native-route migration, pinned.
These admin methods had NO coverage while they called the legacy verb surface,
which is why migrating them was the risk. The dangerous failure is not a crash:
it is `unwrap` returning [] for a well-formed response, so a caller sees "no
users" instead of an error. Every case asserts on the value.
Complements tests/test_endpoints.py, which pins that OIDC paths live under the
one /v1/iam prefix. This file pins that no legacy VERB survives anywhere.
"""
from __future__ import annotations
import importlib.util
from pathlib import Path
import pytest
from hanzo_iam import routes
class TestRoutesAreNative:
def test_no_verb_paths_in_the_table(self):
paths = [
v for k, v in vars(routes).items()
if k.isupper() and isinstance(v, str) and v.startswith("/v1/iam/")
]
assert paths, "route table is empty — the constants moved"
for p in paths:
assert "/get-" not in p, f"{p} is a legacy verb; the server is removing it"
assert "access_token" not in p, (
f"{p} is the legacy token spelling; OIDC discovery advertises "
"/v1/iam/oauth/token and only that"
)
def test_token_endpoint_is_the_advertised_one(self):
assert routes.TOKEN == "/v1/iam/oauth/token"
def test_every_list_route_declares_its_key(self):
for name in ("USERS", "APPLICATIONS", "ORGANIZATIONS", "PROVIDERS", "ROLES"):
path = getattr(routes, name)
assert path in routes.LIST_KEY, (
f"{name} has no LIST_KEY — its lists would unwrap to [] and read as empty"
)
class TestUnwrapReadsBothShapes:
def test_native_single_object(self):
body = {"owner": "hanzo", "name": "alice", "id": "hanzo/alice"}
assert routes.unwrap(body) == body
def test_legacy_envelope_single_object(self):
inner = {"owner": "hanzo", "name": "alice"}
assert routes.unwrap({"status": "ok", "msg": "", "data": inner}) == inner
def test_native_list(self):
body = {"users": [{"name": "alice"}, {"name": "bob"}]}
assert routes.unwrap(body, "users") == body["users"]
def test_legacy_envelope_list(self):
body = {"status": "ok", "msg": "", "data": [{"name": "alice"}]}
assert routes.unwrap(body, "users") == body["data"]
def test_native_null_list_is_a_list_not_none(self):
assert routes.unwrap({"users": None}, "users") == []
def test_legacy_error_envelope_raises(self):
with pytest.raises(ValueError, match="user not found"):
routes.unwrap({"status": "error", "msg": "user not found"})
def test_legacy_error_raises_on_the_list_path_too(self):
with pytest.raises(ValueError):
routes.unwrap({"status": "error", "msg": "nope"}, "users")
def test_a_native_row_with_a_data_column_is_not_unwrapped(self):
body = {"owner": "hanzo", "name": "cfg", "data": {"k": "v"}}
assert routes.unwrap(body) == body
def test_non_dict_passes_through(self):
assert routes.unwrap([1, 2]) == [1, 2]
class TestClientsCarryNoPathLiterals:
@pytest.mark.parametrize("module", ["client", "async_client"])
def test_no_legacy_verb_in_client_source(self, module):
spec = importlib.util.find_spec(f"hanzo_iam.{module}")
assert spec and spec.origin
src = Path(spec.origin).read_text()
for verb in ("get-user", "get-users", "get-application", "get-organization",
"get-provider", "get-role"):
assert f"/{verb}" not in src, (
f"{module}.py still names the legacy verb {verb}; the server is removing it"
)
assert "oauth/access_token" not in src, (
f"{module}.py still uses the legacy token spelling"
)