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:
2026-08-24 03:42:08 -07:00
co-authored by Hanzo Dev
parent 95e2025681
commit d6ecee069c
9 changed files with 4604 additions and 3072 deletions
+4 -4
View File
@@ -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 [
+4 -4
View File
@@ -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")]
+26 -21
View File
@@ -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,
+29 -5
View File
@@ -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"})
+1435
View File
File diff suppressed because it is too large Load Diff
-1
View File
@@ -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
+3079 -3030
View File
File diff suppressed because it is too large Load Diff