iam: address identity by path under /v1/iam

Users, organizations, roles, applications, providers, permissions,
invitations, tokens, sessions and audit rows are addressed as
/v1/iam/{plural} for a list and /v1/iam/{plural}/{owner}/{name} for a row,
in both the Rust `hanzo` router and the TypeScript iam/auth tools. `id`
splits into its owner and name path segments and a malformed one is refused
rather than sent. Deleting a user is a DELETE on the row; updating one reads
the row and PUTs {"user": merged}.

`owner` is scope: it goes on the wire only when the caller names one, and the
server resolves the rest from the credential. /v1/iam/applications is the one
list that requires it, and asks.

A non-2xx answer is now an error carrying its status and reason — RFC 9457
`detail`, else the envelope `msg`, else `title` — so KMS, PaaS and IAM
refusals surface instead of reading as data. Tests cover the row address, the
id split, and a problem+json refusal against a local listener.

Identity questions belong to the iam service alone, so paas no longer
duplicates users/orgs/roles. Actions with no address behind them (enforce,
system_info, health, mfa status) are gone.

Co-authored-by: Hanzo Dev <[email protected]>
This commit is contained in:
2026-08-24 03:26:38 -07:00
co-authored by Hanzo Dev
parent 7d3b47ffba
commit 08159273e4
3 changed files with 345 additions and 180 deletions
+25
View File
@@ -93,6 +93,31 @@ the index's to answer and say so rather than spawning a server.
LSP lives UNDER `/v1/code` beside `search`, `context`, `ask`, `index` — one
home for code intelligence.
## `iam` — how identity is addressed
IAM's CRUD lives under `/v1/iam/` and nowhere else. A row is a path, not a query:
`/v1/iam/{plural}/{owner}/{name}` for users, organizations, roles, applications,
providers, permissions, invitations and tokens; sessions carry the application
too (`/v1/iam/sessions/{owner}/{name}/{application}`). Audit rows are
`/v1/iam/audit-logs`.
A list GET answers one object keyed by the plural (`{"users":[…],"total":N}`); an
item GET answers the bare record, except providers, which wrap
(`{"provider":{…}}`). A refusal is RFC 9457 problem+json and absence is 404 —
so a non-2xx answer is an error to raise, never a payload to read. The
`{status,msg,data}` envelope survives on `/v1/iam/account`, `/v1/iam/memberships`,
`/v1/iam/keys/{principal,org}` and `POST /v1/iam/delete-membership`. Write bodies
are the flat row, except users: `{"user":{…},"password":"…"}`.
`owner` is scope, not a constant. Send it only when the caller names one and let
the server resolve the rest from the credential — a hardcoded org answers 403 to
everyone outside it, and IAM's capability allowlists decide the rest.
`/v1/iam/applications` is the one list that requires `owner`; ask for it.
Two callers: `src/tools/hanzo-cloud.ts` (the `iam` and `auth` tools) and
`rust/src/tools/hanzo_tool.rs` (the `hanzo` router's `iam` service). Identity
questions belong to `iam` alone — `paas` answers for deployments.
## Canonical role
Part of the AI/agents SDK line. This TS package (`@hanzo/mcp`) is canonical; the
Python `hanzo-mcp` (PyPI) and Rust `hanzo-mcp::brain` mirror the same tool surface
+229 -106
View File
@@ -222,10 +222,24 @@ impl HanzoTool {
req = req.bearer_auth(k);
}
let resp = req.send().await?;
let status = resp.status().as_u16();
let status = resp.status();
let text = resp.text().await?;
Ok(serde_json::from_str::<Value>(&text)
.unwrap_or_else(|_| json!({ "status": status, "body": text })))
let parsed = serde_json::from_str::<Value>(&text).ok();
if !status.is_success() {
// RFC 9457 `detail`, else the envelope `msg`, else `title`, else the body.
let why = parsed
.as_ref()
.and_then(|v| {
["detail", "msg", "title"]
.iter()
.find_map(|k| v.get(*k).and_then(Value::as_str))
.filter(|s| !s.is_empty())
})
.map(str::to_string)
.unwrap_or_else(|| text.chars().take(300).collect());
anyhow::bail!("{} {}: {}", status.as_u16(), path, why);
}
Ok(parsed.unwrap_or_else(|| json!({ "status": status.as_u16(), "body": text })))
}
pub async fn execute(&self, args: HanzoToolArgs) -> Result<Value> {
@@ -350,106 +364,116 @@ impl HanzoTool {
async fn iam(&self, action: &str, p: &Map<String, Value>) -> Result<Value> {
let ok = |d| Ok(envelope_ok("hanzo", action, d));
let owner = pstr(p, "owner").unwrap_or_else(|| "hanzo".to_string());
let id = pstr(p, "id");
// Scope follows the caller: name an owner only when the caller names one,
// and the server resolves the rest from the credential.
let owner = pstr(p, "owner");
let scope: Vec<(String, String)> =
owner.iter().map(|o| ("owner".to_string(), o.clone())).collect();
let with = |keys: &[&str]| {
let mut q = scope.clone();
for k in keys {
if let Some(v) = pstr(p, k) {
q.push(((*k).to_string(), v));
}
}
q
};
match action {
"users" => ok(self.call("iam", "GET", "/v1/iam/get-users", &[("owner".into(), owner)], None).await?),
"user" => match pstr(p, "id") {
Some(id) => ok(self.call("iam", "GET", "/v1/iam/get-user", &[("id".into(), id)], None).await?),
None => Ok(need(action, "id")),
"users" => ok(self
.call("iam", "GET", "/v1/iam/users", &with(&["email", "limit", "offset"]), None)
.await?),
"user" => match row("users", id.as_deref()) {
Some(path) => ok(self.call("iam", "GET", &path, &[], None).await?),
None => Ok(need(action, "id as owner/name")),
},
"create_user" => {
let (name, email) = (pstr(p, "name"), pstr(p, "email"));
let (Some(name), Some(email)) = (name, email) else {
return Ok(need(action, "name, email"));
let (Some(owner), Some(name), Some(email)) =
(owner.clone(), pstr(p, "name"), pstr(p, "email"))
else {
return Ok(need(action, "owner, name, email"));
};
let display = pstr(p, "display_name").unwrap_or_else(|| name.clone());
let mut user = json!({
"owner": owner,
"name": name,
"email": email,
"displayName": display,
let mut body = json!({
"user": { "owner": owner, "name": name, "email": email, "displayName": display }
});
if let Some(pw) = pstr(p, "password") {
user["password"] = json!(pw);
body["password"] = json!(pw);
}
ok(self.call("iam", "POST", "/v1/iam/add-user", &[], Some(json!({ "user": user }))).await?)
ok(self.call("iam", "POST", "/v1/iam/users", &[], Some(body)).await?)
}
"update_user" | "delete_user" => {
let Some(id) = pstr(p, "id") else {
return Ok(need(action, "id"));
"update_user" => {
let Some(path) = row("users", id.as_deref()) else {
return Ok(need(action, "id as owner/name"));
};
let current = self
.call("iam", "GET", "/v1/iam/get-user", &[("id".into(), id.clone())], None)
.await?;
let Value::Object(mut user) = current else {
return Ok(envelope_err("hanzo", action, "NOT_FOUND", format!("User not found: {}", id)));
let Value::Object(mut user) = self.call("iam", "GET", &path, &[], None).await? else {
return Ok(envelope_err("hanzo", action, "UPSTREAM", "user record is not an object"));
};
let endpoint = if action == "update_user" {
for k in ["name", "email", "display_name"] {
if let Some(v) = pstr(p, k) {
let field = if k == "display_name" { "displayName" } else { k };
user.insert(field.into(), json!(v));
}
for k in ["name", "email", "display_name"] {
if let Some(v) = pstr(p, k) {
let field = if k == "display_name" { "displayName" } else { k };
user.insert(field.into(), json!(v));
}
"/v1/iam/update-user"
} else {
"/v1/iam/delete-user"
};
ok(self.call("iam", "POST", endpoint, &[], Some(json!({ "user": user }))).await?)
}
"orgs" => ok(self.call("iam", "GET", "/v1/iam/get-organizations", &[("owner".into(), "admin".into())], None).await?),
"org" => match pstr(p, "id") {
Some(id) => ok(self.call("iam", "GET", "/v1/iam/get-organization", &[("id".into(), id)], None).await?),
None => Ok(need(action, "id")),
},
"roles" => ok(self.call("iam", "GET", "/v1/iam/get-roles", &[("owner".into(), owner)], None).await?),
"role" => match pstr(p, "id") {
Some(id) => ok(self.call("iam", "GET", "/v1/iam/get-role", &[("id".into(), id)], None).await?),
None => Ok(need(action, "id")),
},
"permissions" => ok(self.call("iam", "GET", "/v1/iam/get-permissions", &[("owner".into(), owner)], None).await?),
"enforce" => {
let (model, resource, check) = (
pstr(p, "model"),
pstr(p, "resource"),
pstr(p, "permission_action").or_else(|| pstr(p, "action_check")),
);
let (Some(model), Some(resource), Some(check)) = (model, resource, check) else {
return Ok(need(action, "model, resource, permission_action"));
};
let q = vec![
("owner".into(), owner),
("model".into(), model),
("resource".into(), resource),
("action".into(), check),
];
ok(self.call("iam", "GET", "/v1/iam/enforce", &q, None).await?)
}
"providers" => ok(self.call("iam", "GET", "/v1/iam/get-providers", &[("owner".into(), "admin".into())], None).await?),
"apps" => ok(self.call("iam", "GET", "/v1/iam/get-applications", &[("owner".into(), "admin".into())], None).await?),
"tokens" => ok(self.call("iam", "GET", "/v1/iam/get-tokens", &[("owner".into(), "admin".into())], None).await?),
"sessions" => ok(self.call("iam", "GET", "/v1/iam/get-sessions", &[("owner".into(), "admin".into())], None).await?),
"invitations" => ok(self.call("iam", "GET", "/v1/iam/get-invitations", &[("owner".into(), "admin".into())], None).await?),
"invite" => match pstr(p, "email") {
Some(email) => {
let invitation = json!({
"owner": "admin",
"name": email.replace('@', "-at-").replace('.', "-"),
"email": email,
"organization": pstr(p, "org").unwrap_or_else(|| "hanzo".to_string()),
});
ok(self.call("iam", "POST", "/v1/iam/add-invitation", &[], Some(json!({ "invitation": invitation }))).await?)
}
None => Ok(need(action, "email")),
ok(self.call("iam", "PUT", &path, &[], Some(json!({ "user": user }))).await?)
}
"delete_user" => match row("users", id.as_deref()) {
Some(path) => ok(self.call("iam", "DELETE", &path, &[], None).await?),
None => Ok(need(action, "id as owner/name")),
},
"records" => ok(self.call("iam", "GET", "/v1/iam/get-records", &[("owner".into(), "admin".into())], None).await?),
"system_info" => ok(self.call("iam", "GET", "/v1/iam/get-system-info", &[], None).await?),
"health" => ok(self.call("iam", "GET", "/v1/iam/healthz", &[], None).await?),
_ => Ok(unknown(action, &[
"users", "user", "create_user", "update_user", "delete_user", "orgs", "org",
"roles", "role", "permissions", "enforce", "providers", "apps", "tokens",
"sessions", "invitations", "invite", "records", "system_info", "health",
])),
"orgs" => {
let mut q = Vec::new();
if let Some(v) = pstr(p, "q").or_else(|| pstr(p, "query")) {
q.push(("q".to_string(), v));
}
for k in ["limit", "cursor"] {
if let Some(v) = pstr(p, k) {
q.push((k.to_string(), v));
}
}
ok(self.call("iam", "GET", "/v1/iam/organizations", &q, None).await?)
}
"org" => match row("organizations", id.as_deref()) {
Some(path) => ok(self.call("iam", "GET", &path, &[], None).await?),
None => Ok(need(action, "id as owner/name")),
},
"roles" => ok(self.call("iam", "GET", "/v1/iam/roles", &scope, None).await?),
"role" => match row("roles", id.as_deref()) {
Some(path) => ok(self.call("iam", "GET", &path, &[], None).await?),
None => Ok(need(action, "id as owner/name")),
},
"permissions" => ok(self.call("iam", "GET", "/v1/iam/permissions", &scope, None).await?),
"providers" => ok(self.call("iam", "GET", "/v1/iam/providers", &scope, None).await?),
// Applications are listed one owner at a time; ask rather than guess.
"apps" => {
if owner.is_none() {
return Ok(need(action, "owner"));
}
ok(self.call("iam", "GET", "/v1/iam/applications", &scope, None).await?)
}
"tokens" => ok(self
.call("iam", "GET", "/v1/iam/tokens", &with(&["organization"]), None)
.await?),
"sessions" => ok(self
.call("iam", "GET", "/v1/iam/sessions", &with(&["name", "application"]), None)
.await?),
"invitations" => ok(self.call("iam", "GET", "/v1/iam/invitations", &scope, None).await?),
"invite" => {
let (Some(owner), Some(email)) = (owner.clone(), pstr(p, "email")) else {
return Ok(need(action, "owner, email"));
};
let mut invitation = json!({
"owner": owner,
"name": email.replace('@', "-at-").replace('.', "-"),
"email": email,
});
if let Some(org) = pstr(p, "org").or_else(|| pstr(p, "organization")) {
invitation["organization"] = json!(org);
}
ok(self.call("iam", "POST", "/v1/iam/invitations", &[], Some(invitation)).await?)
}
"records" => ok(self.call("iam", "GET", "/v1/iam/audit-logs", &scope, None).await?),
_ => Ok(unknown(action, IAM_ACTIONS)),
}
}
@@ -525,9 +549,6 @@ impl HanzoTool {
);
match action {
"whoami" => Ok(self.auth("whoami")),
"users" => ok(self.call("iam", "GET", "/v1/iam/get-users", &[("owner".into(), "hanzo".into())], None).await?),
"orgs" => ok(self.call("iam", "GET", "/v1/iam/get-organizations", &[("owner".into(), "admin".into())], None).await?),
"roles" => ok(self.call("iam", "GET", "/v1/iam/get-roles", &[("owner".into(), "hanzo".into())], None).await?),
"projects" => match org {
Some(org) => ok(self.call("paas", "GET", &format!("/v1/org/{}/project", org), &[], None).await?),
None => Ok(need(action, "org")),
@@ -567,10 +588,7 @@ impl HanzoTool {
let templates = self.call("paas", "GET", "/v1/cluster/templates", &[], None).await?;
ok(json!({ "cluster": info, "templates": templates }))
}
_ => Ok(unknown(action, &[
"whoami", "users", "orgs", "roles", "projects", "env", "deployments",
"deploy", "logs", "redeploy", "services",
])),
_ => Ok(unknown(action, PAAS_ACTIONS)),
}
}
@@ -873,20 +891,32 @@ impl HanzoTool {
}
}
const IAM_ACTIONS: &[&str] = &[
"users", "user", "create_user", "update_user", "delete_user", "orgs", "org", "roles",
"role", "permissions", "providers", "apps", "tokens", "sessions", "invitations",
"invite", "records",
];
const PAAS_ACTIONS: &[&str] = &[
"whoami", "projects", "env", "deployments", "deploy", "logs", "redeploy", "services",
];
/// Address of one typed IAM record. `id` is spelled `owner/name`.
fn row(kind: &str, id: Option<&str>) -> Option<String> {
let (owner, name) = id?.split_once('/')?;
if owner.is_empty() || name.is_empty() || name.contains('/') {
return None;
}
Some(format!("/v1/iam/{}/{}/{}", kind, owner, name))
}
/// Actions advertised for a service when called with no action (progressive reveal).
fn actions_for(service: &str) -> Vec<&'static str> {
match service {
"iam" => vec![
"users", "user", "create_user", "update_user", "delete_user", "orgs", "org",
"roles", "role", "permissions", "enforce", "providers", "apps", "tokens",
"sessions", "invitations", "invite", "records", "system_info", "health",
],
"iam" => IAM_ACTIONS.to_vec(),
"auth" => vec!["status", "whoami", "logout", "refresh"],
"kms" => vec!["list", "get", "set", "delete", "inject"],
"paas" => vec![
"whoami", "users", "orgs", "roles", "projects", "env", "deployments", "deploy",
"logs", "redeploy", "services",
],
"paas" => PAAS_ACTIONS.to_vec(),
"ingress" => vec![
"routers", "services", "middlewares", "entrypoints", "overview", "tcp_routers",
"domains", "add_domain", "remove_domain", "verify_domain", "tls_status",
@@ -930,6 +960,41 @@ fn unknown(action: &str, available: &[&str]) -> Value {
mod tests {
use super::*;
/// Serialises the tests that read or write the process environment.
static ENV: std::sync::Mutex<()> = std::sync::Mutex::new(());
/// A listener that answers one request, and reports what it was asked.
fn one_shot(status: &str, body: &str) -> (String, std::sync::mpsc::Receiver<String>) {
use std::io::{Read, Write};
let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
let addr = listener.local_addr().unwrap();
let (tx, rx) = std::sync::mpsc::channel();
let (status, body) = (status.to_string(), body.to_string());
std::thread::spawn(move || {
let (mut sock, _) = listener.accept().unwrap();
let mut buf = [0u8; 4096];
let n = sock.read(&mut buf).unwrap();
let _ = tx.send(String::from_utf8_lossy(&buf[..n]).to_string());
let resp = format!(
"HTTP/1.1 {status}\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{body}",
body.len()
);
let _ = sock.write_all(resp.as_bytes());
});
(format!("http://{addr}"), rx)
}
async fn iam_call(status: &str, body: &str, args: HanzoToolArgs) -> (Value, String) {
let _guard = ENV.lock().unwrap_or_else(|e| e.into_inner());
let (base, rx) = one_shot(status, body);
std::env::set_var("IAM_URL", &base);
std::env::set_var("HANZO_API_KEY", "sk-test");
let out = HanzoTool::new().execute(args).await.unwrap();
let req = rx.recv().unwrap();
std::env::remove_var("IAM_URL");
(out, req)
}
#[tokio::test]
async fn test_hanzo_list_resources() {
let tool = HanzoTool::new();
@@ -996,6 +1061,7 @@ mod tests {
#[test]
fn test_base_urls_match_python_defaults() {
let _guard = ENV.lock().unwrap_or_else(|e| e.into_inner());
std::env::remove_var("IAM_URL");
std::env::remove_var("HANZO_KMS_URL");
std::env::remove_var("HANZO_PAAS_URL");
@@ -1011,7 +1077,64 @@ mod tests {
#[test]
fn test_join_normalizes_slashes() {
assert_eq!(join("https://hanzo.id", "/v1/iam/get-users"), "https://hanzo.id/v1/iam/get-users");
assert_eq!(join("https://hanzo.id", "/v1/iam/users"), "https://hanzo.id/v1/iam/users");
assert_eq!(join("https://api.hanzo.ai/team", "/workspaces"), "https://api.hanzo.ai/team/workspaces");
}
#[test]
fn test_row_needs_owner_and_name() {
assert_eq!(row("users", Some("hanzo/z")).unwrap(), "/v1/iam/users/hanzo/z");
assert_eq!(row("roles", Some("admin/ops")).unwrap(), "/v1/iam/roles/admin/ops");
for bad in ["z", "/z", "hanzo/", "a/b/c", ""] {
assert!(row("users", Some(bad)).is_none(), "accepted {bad}");
}
assert!(row("users", None).is_none());
}
#[tokio::test]
async fn test_iam_reads_a_row_by_owner_and_name() {
let (out, req) = iam_call(
"200 OK",
r#"{"owner":"hanzo","name":"z"}"#,
HanzoToolArgs {
resource: Some("iam".into()),
action: Some("user".into()),
id: Some("hanzo/z".into()),
..Default::default()
},
)
.await;
assert!(req.starts_with("GET /v1/iam/users/hanzo/z "), "{req}");
assert_eq!(out["ok"], true);
assert_eq!(out["data"]["name"], "z");
}
#[tokio::test]
async fn test_refusal_carries_its_reason() {
let (out, _) = iam_call(
"404 Not Found",
r#"{"type":"about:blank","title":"Not Found","status":404,"detail":"no such user"}"#,
HanzoToolArgs {
resource: Some("iam".into()),
action: Some("user".into()),
id: Some("hanzo/nobody".into()),
..Default::default()
},
)
.await;
assert_eq!(out["ok"], false);
assert_eq!(out["error"]["code"], "UPSTREAM");
let msg = out["error"]["message"].as_str().unwrap();
assert!(msg.contains("404") && msg.contains("no such user"), "{msg}");
}
#[test]
fn test_iam_owns_the_identity_actions() {
let iam = actions_for("iam");
assert!(iam.contains(&"users") && iam.contains(&"orgs") && iam.contains(&"roles"));
let paas = actions_for("paas");
for identity in ["users", "orgs", "roles"] {
assert!(!paas.contains(&identity), "paas still answers {identity}");
}
}
}
+91 -74
View File
@@ -10,7 +10,7 @@
* referrals, affiliates, webhooks, reviews, promotions,
* pricing, tax, returns, payment methods, invoices, meters)
* Storage (api.hanzo.ai/v1) — object storage: buckets, objects, upload, presign
* Auth (iam.hanzo.ai) — authentication: login, token, account, sessions, whoami, mfa
* Auth (iam.hanzo.ai) — authentication: login, token, account, sessions, whoami
* API (api.hanzo.ai/v1) — LLM gateway: models, chat, embeddings, images, audio, files
*/
@@ -27,9 +27,10 @@ async function api(base: string, path: string, opts: RequestInit = {}): Promise<
const t = token();
if (!t) throw new Error('HANZO_API_KEY required');
const r = await fetch(`${base}${path}`, { ...opts, headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${t}`, ...(opts.headers || {}) } });
if (!r.ok) { const b = await r.text().catch(() => ''); throw new Error(`${r.status}: ${b.substring(0, 200)}`); }
const txt = await r.text();
try { return JSON.parse(txt); } catch { return txt; }
let body: any; try { body = JSON.parse(txt); } catch { body = txt; }
if (!r.ok) throw new Error(`${r.status} ${path}: ${body?.detail || body?.msg || body?.title || txt.substring(0, 200)}`);
return body;
}
function j(d: any): string { return JSON.stringify(d, null, 2); }
@@ -41,6 +42,22 @@ function arr(d: any, ...keys: string[]): any[] {
return d?.data || [];
}
// IAM answers a list as one object keyed by the plural, and a row as the bare record.
function list(d: any, key: string): any[] { return Array.isArray(d?.[key]) ? d[key] : []; }
function q(params: Record<string, any>): string {
const s = new URLSearchParams();
for (const [k, v] of Object.entries(params)) if (v !== undefined && v !== null && v !== '') s.set(k, String(v));
return s.toString() ? `?${s}` : '';
}
/** Address of one IAM record. `id` is spelled owner/name — sessions add the application. */
function at(kind: string, id: string, segments = 2): string {
const parts = String(id || '').split('/');
if (parts.length !== segments || parts.some(x => !x)) {
throw new Error(`id must be ${segments === 3 ? 'owner/name/application' : 'owner/name'}: ${id}`);
}
return `/v1/iam/${kind}/${parts.map(encodeURIComponent).join('/')}`;
}
// =============================================
// IAM — identity, users, orgs, roles, apps, providers, tokens
// =============================================
@@ -58,161 +75,162 @@ export const iamTool: Tool = {
'list_apps', 'get_app', 'create_app',
'list_providers', 'list_tokens', 'create_token', 'delete_token',
'list_permissions', 'assign_role', 'remove_role',
'list_sessions', 'delete_session',
'health'
'list_sessions', 'delete_session'
] },
owner: { type: 'string', default: 'admin' },
id: { type: 'string', description: 'Entity ID (owner/name format for users)' },
owner: { type: 'string', description: 'Organization owning the row; omit to let the credential decide' },
id: { type: 'string', description: 'owner/name — owner/name/application for delete_session' },
name: { type: 'string' }, email: { type: 'string' }, password: { type: 'string' },
displayName: { type: 'string' }, phone: { type: 'string' },
organization: { type: 'string', default: 'hanzo' },
role: { type: 'string', description: 'Role name (assign_role, remove_role)' },
user: { type: 'string', description: 'User ID (assign_role, remove_role)' },
organization: { type: 'string' },
application: { type: 'string', description: 'Application filter (list_sessions)' },
role: { type: 'string', description: 'Role id as owner/name (assign_role, remove_role)' },
user: { type: 'string', description: 'User id as owner/name (assign_role, remove_role)' },
data: { type: 'object', description: 'Create/update payload' },
q: { type: 'string', description: 'Search text (list_orgs)' },
cursor: { type: 'string', description: 'Page cursor (list_orgs)' },
limit: { type: 'number', default: 50 }
},
required: ['action']
},
handler: async (args) => {
try {
const o = args.owner || 'admin';
const owner = args.owner;
const lim = args.limit || 50;
switch (args.action) {
case 'list_users': {
const u = arr(await api(IAM_URL, `/api/get-users?owner=${o}&limit=${lim}`));
const u = list(await api(IAM_URL, `/v1/iam/users${q({ owner, limit: lim })}`), 'users');
return ok(`Users (${u.length}):\n${u.map((x: any) => `${x.name} <${x.email || ''}> [${x.type || 'normal-user'}]`).join('\n')}`);
}
case 'get_user': {
if (!args.id) return fail('id required');
return ok(j(await api(IAM_URL, `/api/get-user?id=${args.id.includes('/') ? args.id : `admin/${args.id}`}`)));
return ok(j(await api(IAM_URL, at('users', args.id))));
}
case 'create_user': {
if (!args.name || !args.email || !args.password) return fail('name, email, password required');
await api(IAM_URL, '/api/add-user', { method: 'POST', body: JSON.stringify({ owner: o, name: args.name, email: args.email, password: args.password, organization: args.organization || 'hanzo', type: 'normal-user' }) });
if (!owner || !args.name || !args.email || !args.password) return fail('owner, name, email, password required');
const user: any = { owner, name: args.name, email: args.email, displayName: args.displayName || args.name, type: 'normal-user' };
if (args.organization) user.organization = args.organization;
await api(IAM_URL, '/v1/iam/users', { method: 'POST', body: JSON.stringify({ user, password: args.password }) });
return ok(`Created: ${args.name} <${args.email}>`);
}
case 'update_user': {
if (!args.id) return fail('id required');
const id = args.id.includes('/') ? args.id : `admin/${args.id}`;
const u = await api(IAM_URL, `/api/get-user?id=${id}`);
const path = at('users', args.id);
const u = await api(IAM_URL, path);
if (args.email) u.email = args.email;
if (args.displayName) u.displayName = args.displayName;
if (args.phone) u.phone = args.phone;
if (args.data) Object.assign(u, args.data);
await api(IAM_URL, '/api/update-user', { method: 'POST', body: JSON.stringify(u) });
return ok(`Updated: ${id}`);
await api(IAM_URL, path, { method: 'PUT', body: JSON.stringify({ user: u }) });
return ok(`Updated: ${args.id}`);
}
case 'delete_user': {
if (!args.id) return fail('id required');
const id = args.id.includes('/') ? args.id : `admin/${args.id}`;
const [ow, nm] = id.split('/');
await api(IAM_URL, '/api/delete-user', { method: 'POST', body: JSON.stringify({ owner: ow, name: nm }) });
return ok(`Deleted: ${id}`);
await api(IAM_URL, at('users', args.id), { method: 'DELETE' });
return ok(`Deleted: ${args.id}`);
}
case 'list_orgs': {
const orgs = arr(await api(IAM_URL, `/api/get-organizations?owner=${o}`));
const orgs = list(await api(IAM_URL, `/v1/iam/organizations${q({ q: args.q, limit: lim, cursor: args.cursor })}`), 'organizations');
return ok(`Orgs (${orgs.length}):\n${orgs.map((x: any) => `${x.name}: ${x.displayName || ''} [${(x.users || []).length} members]`).join('\n')}`);
}
case 'get_org': {
if (!args.id) return fail('id required');
return ok(j(await api(IAM_URL, `/api/get-organization?id=${args.id.includes('/') ? args.id : `admin/${args.id}`}`)));
return ok(j(await api(IAM_URL, at('organizations', args.id))));
}
case 'create_org': {
if (!args.name) return fail('name required');
await api(IAM_URL, '/api/add-organization', { method: 'POST', body: JSON.stringify({ owner: o, name: args.name, displayName: args.displayName || args.name, ...(args.data || {}) }) });
if (!owner || !args.name) return fail('owner, name required');
await api(IAM_URL, '/v1/iam/organizations', { method: 'POST', body: JSON.stringify({ owner, name: args.name, displayName: args.displayName || args.name, ...(args.data || {}) }) });
return ok(`Created org: ${args.name}`);
}
case 'update_org': {
if (!args.id) return fail('id required');
const id = args.id.includes('/') ? args.id : `admin/${args.id}`;
const org = await api(IAM_URL, `/api/get-organization?id=${id}`);
const path = at('organizations', args.id);
const org = await api(IAM_URL, path);
if (args.displayName) org.displayName = args.displayName;
if (args.data) Object.assign(org, args.data);
await api(IAM_URL, '/api/update-organization', { method: 'POST', body: JSON.stringify(org) });
return ok(`Updated org: ${id}`);
await api(IAM_URL, path, { method: 'PUT', body: JSON.stringify(org) });
return ok(`Updated org: ${args.id}`);
}
case 'delete_org': {
if (!args.id) return fail('id required');
const id = args.id.includes('/') ? args.id : `admin/${args.id}`;
const [ow, nm] = id.split('/');
await api(IAM_URL, '/api/delete-organization', { method: 'POST', body: JSON.stringify({ owner: ow, name: nm }) });
return ok(`Deleted org: ${id}`);
await api(IAM_URL, at('organizations', args.id), { method: 'DELETE' });
return ok(`Deleted org: ${args.id}`);
}
case 'list_roles': {
const roles = arr(await api(IAM_URL, `/api/get-roles?owner=${o}`));
const roles = list(await api(IAM_URL, `/v1/iam/roles${q({ owner })}`), 'roles');
return ok(`Roles (${roles.length}):\n${roles.map((x: any) => `${x.name}: ${(x.users || []).length} users, ${(x.roles || []).length} sub-roles`).join('\n')}`);
}
case 'get_role': {
if (!args.id) return fail('id required');
return ok(j(await api(IAM_URL, `/api/get-role?id=${args.id.includes('/') ? args.id : `admin/${args.id}`}`)));
return ok(j(await api(IAM_URL, at('roles', args.id))));
}
case 'create_role': {
if (!args.name) return fail('name required');
await api(IAM_URL, '/api/add-role', { method: 'POST', body: JSON.stringify({ owner: o, name: args.name, displayName: args.displayName || args.name, ...(args.data || {}) }) });
if (!owner || !args.name) return fail('owner, name required');
await api(IAM_URL, '/v1/iam/roles', { method: 'POST', body: JSON.stringify({ owner, name: args.name, displayName: args.displayName || args.name, ...(args.data || {}) }) });
return ok(`Created role: ${args.name}`);
}
case 'update_role': {
if (!args.id) return fail('id required');
const id = args.id.includes('/') ? args.id : `admin/${args.id}`;
const role = await api(IAM_URL, `/api/get-role?id=${id}`);
const path = at('roles', args.id);
const role = await api(IAM_URL, path);
if (args.data) Object.assign(role, args.data);
await api(IAM_URL, '/api/update-role', { method: 'POST', body: JSON.stringify(role) });
return ok(`Updated role: ${id}`);
await api(IAM_URL, path, { method: 'PUT', body: JSON.stringify(role) });
return ok(`Updated role: ${args.id}`);
}
case 'delete_role': {
if (!args.id) return fail('id required');
const id = args.id.includes('/') ? args.id : `admin/${args.id}`;
const [ow, nm] = id.split('/');
await api(IAM_URL, '/api/delete-role', { method: 'POST', body: JSON.stringify({ owner: ow, name: nm }) });
return ok(`Deleted role: ${id}`);
await api(IAM_URL, at('roles', args.id), { method: 'DELETE' });
return ok(`Deleted role: ${args.id}`);
}
case 'list_apps': {
const apps = arr(await api(IAM_URL, `/api/get-applications?owner=${o}`));
if (!owner) return fail('owner required');
const apps = list(await api(IAM_URL, `/v1/iam/applications${q({ owner })}`), 'applications');
return ok(`Apps (${apps.length}):\n${apps.map((x: any) => `${x.name}: ${x.displayName || ''} [${x.clientId || ''}]`).join('\n')}`);
}
case 'get_app': {
if (!args.id) return fail('id required');
return ok(j(await api(IAM_URL, `/api/get-application?id=${args.id.includes('/') ? args.id : `admin/${args.id}`}`)));
return ok(j(await api(IAM_URL, at('applications', args.id))));
}
case 'create_app': {
if (!args.name) return fail('name required');
await api(IAM_URL, '/api/add-application', { method: 'POST', body: JSON.stringify({ owner: o, name: args.name, displayName: args.displayName || args.name, organization: args.organization || 'hanzo', ...(args.data || {}) }) });
if (!owner || !args.name) return fail('owner, name required');
const app: any = { owner, name: args.name, displayName: args.displayName || args.name, ...(args.data || {}) };
if (args.organization) app.organization = args.organization;
await api(IAM_URL, '/v1/iam/applications', { method: 'POST', body: JSON.stringify(app) });
return ok(`Created app: ${args.name}`);
}
case 'list_providers': return ok(j(await api(IAM_URL, `/api/get-providers?owner=${o}`)));
case 'list_tokens': return ok(j(await api(IAM_URL, `/api/get-tokens?owner=${o}&limit=${lim}`)));
case 'list_providers': return ok(j(await api(IAM_URL, `/v1/iam/providers${q({ owner })}`)));
case 'list_tokens': return ok(j(await api(IAM_URL, `/v1/iam/tokens${q({ owner, organization: args.organization })}`)));
case 'create_token': {
if (!args.name) return fail('name required');
return ok(j(await api(IAM_URL, '/api/add-token', { method: 'POST', body: JSON.stringify({ owner: o, name: args.name, ...(args.data || {}) }) })));
if (!owner || !args.name) return fail('owner, name required');
return ok(j(await api(IAM_URL, '/v1/iam/tokens', { method: 'POST', body: JSON.stringify({ owner, name: args.name, ...(args.data || {}) }) })));
}
case 'delete_token': {
if (!args.id) return fail('id required');
await api(IAM_URL, '/api/delete-token', { method: 'POST', body: JSON.stringify({ owner: o, name: args.id }) });
await api(IAM_URL, at('tokens', args.id), { method: 'DELETE' });
return ok(`Deleted token: ${args.id}`);
}
case 'list_permissions': return ok(j(await api(IAM_URL, `/api/get-permissions?owner=${o}`)));
case 'list_permissions': return ok(j(await api(IAM_URL, `/v1/iam/permissions${q({ owner })}`)));
case 'assign_role': {
if (!args.role || !args.user) return fail('role and user required');
const r = await api(IAM_URL, `/api/get-role?id=${args.role.includes('/') ? args.role : `admin/${args.role}`}`);
r.users = r.users || [];
r.users.push(args.user);
await api(IAM_URL, '/api/update-role', { method: 'POST', body: JSON.stringify(r) });
const path = at('roles', args.role);
const r = await api(IAM_URL, path);
r.users = [...new Set([...(r.users || []), args.user])];
await api(IAM_URL, path, { method: 'PUT', body: JSON.stringify(r) });
return ok(`Assigned role ${args.role} to ${args.user}`);
}
case 'remove_role': {
if (!args.role || !args.user) return fail('role and user required');
const r = await api(IAM_URL, `/api/get-role?id=${args.role.includes('/') ? args.role : `admin/${args.role}`}`);
const path = at('roles', args.role);
const r = await api(IAM_URL, path);
r.users = (r.users || []).filter((u: string) => u !== args.user);
await api(IAM_URL, '/api/update-role', { method: 'POST', body: JSON.stringify(r) });
await api(IAM_URL, path, { method: 'PUT', body: JSON.stringify(r) });
return ok(`Removed role ${args.role} from ${args.user}`);
}
case 'list_sessions': return ok(j(await api(IAM_URL, `/api/get-sessions?owner=${o}&limit=${lim}`)));
case 'list_sessions': return ok(j(await api(IAM_URL, `/v1/iam/sessions${q({ owner, name: args.name, application: args.application })}`)));
case 'delete_session': {
if (!args.id) return fail('id required');
await api(IAM_URL, '/api/delete-session', { method: 'POST', body: JSON.stringify({ owner: o, name: args.id }) });
await api(IAM_URL, at('sessions', args.id, 3), { method: 'DELETE' });
return ok(`Deleted session: ${args.id}`);
}
case 'health': return ok(j(await api(IAM_URL, '/api/health')));
default: return fail(`Unknown action: ${args.action}`);
}
} catch (e: any) { return fail(e.message); }
@@ -899,19 +917,19 @@ export const storageTool: Tool = {
};
// =============================================
// Auth — authentication, sessions, MFA
// Auth — authentication, sessions
// =============================================
export const authTool: Tool = {
name: 'auth',
description: 'Authentication: whoami, token info, account, sessions, login, logout, refresh, permissions, MFA status',
description: 'Authentication: whoami, token info, account, sessions, login, logout, refresh, permissions',
inputSchema: {
type: 'object',
properties: {
action: { type: 'string', enum: [
'whoami', 'token', 'account',
'login', 'logout', 'refresh',
'sessions', 'permissions', 'mfa'
'sessions', 'permissions'
] },
email: { type: 'string' }, password: { type: 'string' },
application: { type: 'string', default: 'app-hanzo' },
@@ -925,19 +943,18 @@ export const authTool: Tool = {
switch (args.action) {
case 'whoami':
case 'token': return ok(j(await api(IAM_URL, '/v1/iam/oauth/userinfo')));
case 'account': return ok(j(await api(IAM_URL, '/api/get-account')));
case 'account': { const d = await api(IAM_URL, '/v1/iam/account'); return ok(j(d?.data ?? d)); }
case 'login': {
if (!args.email || !args.password) return fail('email and password required');
return ok(j(await api(IAM_URL, '/v1/iam/login', { method: 'POST', body: JSON.stringify({ type: 'token', username: args.email, password: args.password, application: args.application || 'app-hanzo', organization: args.organization || 'hanzo' }) })));
}
case 'logout': return ok(j(await api(IAM_URL, '/api/logout', { method: 'POST' })));
case 'logout': return ok(j(await api(IAM_URL, '/v1/iam/oauth/logout', { method: 'POST' })));
case 'refresh': {
if (!args.refreshToken) return fail('refreshToken required');
return ok(j(await api(IAM_URL, '/v1/iam/login', { method: 'POST', body: JSON.stringify({ type: 'refresh_token', refreshToken: args.refreshToken }) })));
}
case 'sessions': return ok(j(await api(IAM_URL, '/api/get-sessions')));
case 'permissions': return ok(j(await api(IAM_URL, '/api/get-permissions')));
case 'mfa': return ok(j(await api(IAM_URL, '/api/mfa-status')));
case 'sessions': return ok(j(await api(IAM_URL, '/v1/iam/sessions')));
case 'permissions': return ok(j(await api(IAM_URL, '/v1/iam/permissions')));
default: return fail(`Unknown action: ${args.action}`);
}
} catch (e: any) { return fail(e.message); }