mirror of
https://github.com/hanzoai/python-sdk.git
synced 2026-10-23 20:23:00 +00:00
iam: pass the scope through, and let the server's refusal speak
Which list routes insist on an owner is the server's rule, and it moves. The deployed binary requires one on users, sessions and applications and not on roles, permissions, providers, workspaces, invitations, tokens or audit-logs; the change that resolves a listing's tenant centrally is written but unreleased. So nothing here keeps a copy of that rule. The IAM tool no longer refuses a missing owner on applications and sessions, get_applications takes an owner rather than demanding one, and no docstring or parameter description promises that leaving it off makes the credential decide — today that answers 400. A refusal now reaches the caller as the reason IAM gave rather than as a transcribed body: the RFC 9457 detail, or the envelope's msg where the route answers the envelope, which is how memberships refuses. Co-authored-by: Hanzo Dev <[email protected]>
This commit is contained in:
@@ -440,7 +440,7 @@ class AsyncIAMClient:
|
||||
return User.model_validate(body)
|
||||
|
||||
async def get_users(self, owner: str | None = None) -> list[User]:
|
||||
"""List users. Omitting `owner` lets the server scope the read."""
|
||||
"""List users in `owner`, or in whatever scope the server picks without one."""
|
||||
params = {"owner": owner} if owner else {}
|
||||
http = await self._get_http()
|
||||
body = routes.check(
|
||||
@@ -661,12 +661,12 @@ class AsyncIAMClient:
|
||||
)
|
||||
return Application.model_validate(body)
|
||||
|
||||
async def get_applications(self, owner: str) -> list[Application]:
|
||||
"""List an organization's applications. IAM requires the owner here."""
|
||||
async def get_applications(self, owner: str | None = None) -> list[Application]:
|
||||
"""List an organization's applications."""
|
||||
http = await self._get_http()
|
||||
body = routes.check(
|
||||
await http.get(
|
||||
routes.APPLICATIONS, params={"owner": owner}, headers=self._admin_headers()
|
||||
routes.APPLICATIONS, params=({"owner": owner} if owner else {}), headers=self._admin_headers()
|
||||
)
|
||||
)
|
||||
return [
|
||||
|
||||
@@ -373,7 +373,7 @@ class IAMClient:
|
||||
return User.model_validate(body)
|
||||
|
||||
def get_users(self, owner: str | None = None) -> list[User]:
|
||||
"""List users. Omitting `owner` lets the server scope the read."""
|
||||
"""List users in `owner`, or in whatever scope the server picks without one."""
|
||||
params = {"owner": owner} if owner else {}
|
||||
body = routes.check(
|
||||
self.http.get(routes.USERS, params=params, headers=self._admin_headers())
|
||||
@@ -575,11 +575,11 @@ class IAMClient:
|
||||
)
|
||||
return Application.model_validate(body)
|
||||
|
||||
def get_applications(self, owner: str) -> list[Application]:
|
||||
"""List an organization's applications. IAM requires the owner here."""
|
||||
def get_applications(self, owner: str | None = None) -> list[Application]:
|
||||
"""List an organization's applications."""
|
||||
body = routes.check(
|
||||
self.http.get(
|
||||
routes.APPLICATIONS, params={"owner": owner}, headers=self._admin_headers()
|
||||
routes.APPLICATIONS, params=({"owner": owner} if owner else {}), headers=self._admin_headers()
|
||||
)
|
||||
)
|
||||
return [Application.model_validate(a) for a in routes.listing(body, "applications")]
|
||||
|
||||
@@ -11,10 +11,10 @@ Addresses: a collection is a plural noun and one row of it is
|
||||
plural — {"users": [...], "total": N} — a row GET answers the record itself,
|
||||
absence is 404, and a refusal is an RFC 9457 problem document.
|
||||
|
||||
Scope: IAM decides which organizations a caller may read. Omitting `owner` asks
|
||||
for the caller's own scope, which is the right default; naming someone else's org
|
||||
is refused rather than silently reinterpreted. Applications and sessions are the
|
||||
two routes that require an owner, so those ask for one.
|
||||
Scope: `owner` is passed through when the caller names one and left off when
|
||||
they do not. Which routes insist on one is the server's to decide and it varies
|
||||
by release, so this file keeps no copy of that rule — a refusal arrives as a 400
|
||||
carrying the reason, and the reason is what the caller sees.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -57,11 +57,11 @@ Role and permission actions:
|
||||
|
||||
Provider and application actions:
|
||||
- providers: List auth providers (params: owner)
|
||||
- apps: List applications (params: owner, required)
|
||||
- apps: List applications (params: owner)
|
||||
|
||||
Token and session actions:
|
||||
- tokens: List tokens (params: owner)
|
||||
- sessions: List sessions (params: owner, required)
|
||||
- sessions: List sessions (params: owner)
|
||||
|
||||
Invitation actions:
|
||||
- invitations: List invitations (params: owner)
|
||||
@@ -140,8 +140,19 @@ def _rows(data: Any, key: str) -> list:
|
||||
return data[key] or []
|
||||
|
||||
|
||||
def _reason(response: httpx.Response) -> str:
|
||||
"""The reason IAM gave: the RFC 9457 detail, the envelope's msg, else the body."""
|
||||
try:
|
||||
body = response.json()
|
||||
except ValueError:
|
||||
return response.text[:200]
|
||||
if isinstance(body, dict):
|
||||
return str(body.get("detail") or body.get("title") or body.get("msg") or body)
|
||||
return str(body)
|
||||
|
||||
|
||||
def _scope(owner: str | None) -> dict[str, str]:
|
||||
"""Query scope. Omitted means the caller's own, which the server decides."""
|
||||
"""The scope the caller named, or nothing when they named none."""
|
||||
return {"owner": owner} if owner else {}
|
||||
|
||||
|
||||
@@ -227,12 +238,10 @@ class IAMTool(BaseTool):
|
||||
except RuntimeError as e:
|
||||
return json.dumps({"error": str(e)})
|
||||
except httpx.HTTPStatusError as e:
|
||||
body = e.response.text
|
||||
try:
|
||||
body = e.response.json()
|
||||
except Exception:
|
||||
pass
|
||||
return json.dumps({"error": f"IAM API error {e.response.status_code}", "detail": body})
|
||||
return json.dumps({
|
||||
"error": f"IAM API error {e.response.status_code}",
|
||||
"detail": _reason(e.response),
|
||||
})
|
||||
except Exception as e:
|
||||
logger.exception(f"IAM tool error: {e}")
|
||||
return json.dumps({"error": f"IAM error: {e}"})
|
||||
@@ -391,9 +400,7 @@ class IAMTool(BaseTool):
|
||||
return json.dumps({"count": len(result), "providers": result}, indent=2)
|
||||
|
||||
async def _apps(self, owner: str | None) -> str:
|
||||
if not owner:
|
||||
return json.dumps({"error": "Required: owner (IAM scopes applications by owner)"})
|
||||
data = await _iam("GET", "applications", params={"owner": owner})
|
||||
data = await _iam("GET", "applications", params=_scope(owner))
|
||||
result = [
|
||||
{
|
||||
"name": a.get("name"),
|
||||
@@ -422,9 +429,7 @@ class IAMTool(BaseTool):
|
||||
return json.dumps({"count": len(result), "tokens": result}, indent=2)
|
||||
|
||||
async def _sessions(self, owner: str | None) -> str:
|
||||
if not owner:
|
||||
return json.dumps({"error": "Required: owner (IAM scopes sessions by owner)"})
|
||||
data = await _iam("GET", "sessions", params={"owner": owner})
|
||||
data = await _iam("GET", "sessions", params=_scope(owner))
|
||||
result = [
|
||||
{
|
||||
"name": s.get("name"),
|
||||
@@ -514,8 +519,8 @@ class IAMTool(BaseTool):
|
||||
str | None,
|
||||
Field(
|
||||
description=(
|
||||
"Organization to read. Omit for your own scope; apps and"
|
||||
" sessions require one."
|
||||
"Organization to read. Some routes insist on one and say"
|
||||
" so when they do."
|
||||
)
|
||||
),
|
||||
] = None,
|
||||
|
||||
@@ -185,10 +185,34 @@ async def test_absence_is_reported_as_a_404(monkeypatch):
|
||||
assert "404" in out["error"]
|
||||
|
||||
|
||||
async def test_a_route_that_needs_an_owner_asks_for_one(monkeypatch):
|
||||
_, handler = _recorder(LIST_BODY)
|
||||
@pytest.mark.parametrize("action", ["users", "apps", "sessions", "roles", "providers"])
|
||||
async def test_the_scope_is_passed_through_never_second_guessed(monkeypatch, action):
|
||||
"""Which routes insist on an owner is the server's rule, and it moves.
|
||||
|
||||
v8.5.150 requires it on users, sessions and applications and not on the
|
||||
rest; the commit that drops the requirement is written but unreleased. A
|
||||
copy of that rule here would refuse calls the server would have answered,
|
||||
so the tool sends what the caller said and nothing more.
|
||||
"""
|
||||
seen, handler = _recorder(LIST_BODY)
|
||||
_serve(monkeypatch, handler)
|
||||
|
||||
for action in ("apps", "sessions"):
|
||||
out = json.loads(await IAMTool().call(None, action=action))
|
||||
assert "owner" in out["error"]
|
||||
await IAMTool().call(None, action=action)
|
||||
assert "owner=" not in seen["url"]
|
||||
|
||||
await IAMTool().call(None, action=action, owner="hanzo")
|
||||
assert seen["url"].endswith("?owner=hanzo")
|
||||
|
||||
|
||||
async def test_a_refusal_reaches_the_caller_with_the_reason_iam_gave(monkeypatch):
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
return httpx.Response(
|
||||
400,
|
||||
json={"type": "about:blank", "title": "bad request", "detail": "owner is required"},
|
||||
headers={"content-type": "application/problem+json"},
|
||||
)
|
||||
|
||||
_serve(monkeypatch, handler)
|
||||
out = json.loads(await IAMTool().call(None, action="users"))
|
||||
assert "400" in out["error"]
|
||||
assert out["detail"] == "owner is required"
|
||||
|
||||
@@ -108,6 +108,17 @@ def _rows(data: Any, key: str) -> list:
|
||||
return data[key] or []
|
||||
|
||||
|
||||
def _reason(response: httpx.Response) -> str:
|
||||
"""The reason IAM gave: the RFC 9457 detail, the envelope's msg, else the body."""
|
||||
try:
|
||||
body = response.json()
|
||||
except ValueError:
|
||||
return response.text[:200]
|
||||
if isinstance(body, dict):
|
||||
return str(body.get("detail") or body.get("title") or body.get("msg") or body)
|
||||
return str(body)
|
||||
|
||||
|
||||
def _carried(data: Any) -> Any:
|
||||
"""Read the {status, msg, data} envelope memberships and account answer."""
|
||||
if not isinstance(data, dict):
|
||||
@@ -164,12 +175,10 @@ class TeamTool(BaseTool):
|
||||
except RuntimeError as e:
|
||||
return json.dumps({"error": str(e)})
|
||||
except httpx.HTTPStatusError as e:
|
||||
body = e.response.text
|
||||
try:
|
||||
body = e.response.json()
|
||||
except Exception:
|
||||
pass
|
||||
return json.dumps({"error": f"IAM API error {e.response.status_code}", "detail": body})
|
||||
return json.dumps({
|
||||
"error": f"IAM API error {e.response.status_code}",
|
||||
"detail": _reason(e.response),
|
||||
})
|
||||
except Exception as e:
|
||||
logger.exception(f"Team tool error: {e}")
|
||||
return json.dumps({"error": f"Team error: {e}"})
|
||||
@@ -177,7 +186,6 @@ class TeamTool(BaseTool):
|
||||
# -- Workspace actions ---------------------------------------------------
|
||||
|
||||
async def _workspaces(self, owner: str | None) -> str:
|
||||
# Omitting owner asks for the caller's own scope, which IAM decides.
|
||||
params = {"owner": owner} if owner else {}
|
||||
data = await _iam("GET", "workspaces", params=params)
|
||||
result = [
|
||||
|
||||
@@ -147,6 +147,18 @@ async def test_a_stated_error_in_the_envelope_is_raised(monkeypatch):
|
||||
assert out["error"] == "org required"
|
||||
|
||||
|
||||
async def test_a_refusal_reaches_the_caller_with_the_reason_iam_gave(monkeypatch):
|
||||
"""memberships refuses with a 400 carrying the envelope, not problem+json."""
|
||||
|
||||
def refuse(request: httpx.Request) -> httpx.Response:
|
||||
return httpx.Response(400, json={"status": "error", "msg": "org or user is required"})
|
||||
|
||||
_serve(monkeypatch, refuse)
|
||||
out = json.loads(await TeamTool().call(None, action="members", org="hanzo"))
|
||||
assert "400" in out["error"]
|
||||
assert out["detail"] == "org or user is required"
|
||||
|
||||
|
||||
async def test_a_missing_workspace_is_reported_as_a_404(monkeypatch):
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
return httpx.Response(404, json={"title": "not found"})
|
||||
|
||||
Generated
+1435
File diff suppressed because it is too large
Load Diff
@@ -282,7 +282,6 @@ def register(entity: str) -> None:
|
||||
@click.option("--limit", type=int, default=0)
|
||||
@click.option("--offset", type=int, default=0)
|
||||
def _list(org: str, limit: int, offset: int):
|
||||
# No org asks for the caller's own scope, which IAM decides.
|
||||
scope = {SCOPE.get(entity, "owner"): org} if org else {}
|
||||
if limit:
|
||||
scope["limit"] = limit
|
||||
|
||||
Generated
+3079
-3030
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user