Files
zooqueenandHanzo Dev 7ef22afa72 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]>
2026-07-28 15:41:22 -07:00

89 lines
3.4 KiB
Python

"""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