mirror of
https://github.com/hanzoai/mcp.git
synced 2026-10-15 12:30:12 +00:00
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:
@@ -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
@@ -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
@@ -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); }
|
||||
|
||||
Reference in New Issue
Block a user