isb-daemon 1.8.0

The isb serve daemon: the tools behind its API, MCP server and web UI. Use the `isb` crate.
Documentation
//! Who may call which tool: the one authorizer every listener uses
//! ([`authorize_class`]), and the tool lists it judges by.

use serde_json::{Value, json};

use super::{accounts, audit, superadmin};
use crate::error::{Error, Result};
use crate::server::Caller;

/// Tools that reach across orgs: platform admins only.
pub(super) const PLATFORM_TOOLS: &[&str] = &[
    "server_status",
    "org_list",
    "org_create",
    "org_update",
    "org_delete",
    "registry_gc",
    "notification_settings",
    "template_catalog_add",
    "template_catalog_remove",
    "audit_verify",
    "server_add",
    "server_list",
    "server_show",
    "server_remove",
    "server_rotate_cert",
    "server_provision_get",
    "server_upgrade",
    "user_list",
    "user_update",
];

/// Read-only tools that span orgs: any signed-in user, filtered to their
/// orgs by the tool itself (`guide` holds nothing of any org's).
pub(super) const CROSS_ORG_READS: &[&str] = &[
    "overview",
    "events",
    "stack_list",
    "ingress_status",
    "guide",
];

/// Does `tools/list` show `tool`? An org-bound endpoint (`/orgs/<org>/mcp`,
/// `scope` set) is an org's: host, superadmin and platform tools are not on
/// it, for anyone but the unix socket. The unbound `/mcp` lists everything.
pub(super) fn tool_listed(c: &Caller, tool: &str, scope: Option<&crate::org::OrgId>) -> bool {
    scope.is_none()
        || c.is_local()
        || !(superadmin::TOOLS.contains(&tool) || PLATFORM_TOOLS.contains(&tool))
}

/// An audit row's `details`, saying `scope: org <org>` for a downscoped caller.
pub(super) fn scoped(mut details: Value, c: &Caller) -> Value {
    if let Some(org) = c.downscope() {
        details["scope"] = json!(format!("org {org}"));
    }
    details
}

/// The org a tool call names (`org`, default `default`).
pub(super) fn arg_org(args: &Value) -> Result<crate::org::OrgId> {
    match args.get("org").and_then(Value::as_str) {
        Some(o) => crate::org::OrgId::new(o),
        None => Ok(crate::org::OrgId::default_org()),
    }
}

/// May `c` call `tool` (of class `cls`) with `args`? The arguments to use
/// (an org-bound endpoint pins `org`), or the refusal. A token's scopes
/// narrow what its role allows; a viewer runs read-only tools only.
pub(super) fn authorize_class(
    c: &Caller,
    tool: &str,
    cls: audit::Class,
    mut args: Value,
    scope: Option<&crate::org::OrgId>,
    allow_anonymous: bool,
) -> Result<Value> {
    if let Some(org) = scope {
        match args.get("org").and_then(Value::as_str) {
            Some(o) if o != org.as_str() => {
                return Err(Error::Forbidden(format!(
                    "this endpoint acts in org {org}, not {o}"
                )));
            }
            _ => args["org"] = json!(org.as_str()),
        }
    }
    match c {
        Caller::Local { .. } | Caller::Superadmin(_) => Ok(args),
        _ if superadmin::TOOLS.contains(&tool) => Err(Error::Forbidden(format!(
            "{tool} is for superadmins (the unix socket, a superadmin token, or a listed tailnet or Access identity)"
        ))),
        Caller::Unauthenticated { .. } if allow_anonymous => Ok(args),
        Caller::Unauthenticated { .. } => Err(Error::Forbidden(
            "sign in: send an API token as Authorization: Bearer (isb token create)".into(),
        )),
        Caller::Access(id) => Err(Error::Forbidden(format!(
            "{} has no isb account; ask an org admin to invite you",
            id.name()
        ))),
        Caller::User { principal: p } => {
            if !audit::scope_allows(p.scopes(), tool, cls) {
                return Err(Error::Forbidden(format!(
                    "this token's scopes ({}) do not cover {tool}",
                    p.scopes().join(", ")
                )));
            }
            if accounts::TOOLS.contains(&tool) {
                // Judged per call by the account rules (crate::auth::ops),
                // as the identity endpoints are: not by a role in `org`.
                accounts::authorize(p, tool, cls)?;
                if PLATFORM_TOOLS.contains(&tool) && !p.platform_admin {
                    return Err(Error::Forbidden(format!("{tool} is for platform admins")));
                }
                if accounts::names_another_user(tool, &args) && !p.platform_admin {
                    return Err(Error::Forbidden(format!(
                        "{tool} on another user's account, or every org's, is for platform admins"
                    )));
                }
                return Ok(args);
            }
            if PLATFORM_TOOLS.contains(&tool) && !p.platform_admin {
                return Err(Error::Forbidden(format!("{} is for platform admins", tool)));
            }
            if tool == "secret_reencrypt"
                && args.get("all").and_then(Value::as_bool) == Some(true)
                && !p.platform_admin
            {
                return Err(Error::Forbidden(
                    "re-encrypting every org is for platform admins".into(),
                ));
            }
            if (CROSS_ORG_READS.contains(&tool) && scope.is_none())
                || tool == "audit_list"
                || tool == "history_query"
            {
                // They filter to what the caller may see themselves.
                return Ok(args);
            }
            let org = arg_org(&args)?;
            if p.platform_admin {
                return Ok(args);
            }
            match p.role_in(&org) {
                None => Err(Error::Forbidden(format!("no access to org {org}"))),
                Some(_) if cls.read_only => Ok(args),
                Some(_) if p.can_admin_org(&org) => Ok(args),
                Some(r) => Err(Error::Forbidden(format!(
                    "{tool} changes things or reads secrets; a {r} in org {org} only reads"
                ))),
            }
        }
    }
}

/// The orgs a caller may see, or `None` for all of them.
pub(super) fn visible_orgs(c: &Caller) -> Option<Vec<crate::org::OrgId>> {
    match c.principal() {
        Some(p) if !p.platform_admin => Some(p.orgs.iter().map(|(o, _)| o.clone()).collect()),
        _ => None,
    }
}

/// What a cross-org read shows: the caller's visible orgs, narrowed to the
/// call's `org` when it names one (`--org`, or an org-bound endpoint's pin).
/// A bad org name is the call's error, not an empty answer.
pub(super) fn read_orgs(c: &Caller, a: &Value) -> Result<Option<Vec<crate::org::OrgId>>> {
    let visible = visible_orgs(c);
    let Some(o) = a.get("org").and_then(Value::as_str) else {
        return Ok(visible);
    };
    let o = crate::org::OrgId::new(o)?;
    Ok(Some(if visible.as_ref().is_none_or(|v| v.contains(&o)) {
        vec![o]
    } else {
        Vec::new()
    }))
}

/// Events name their stack `org/stack` (or just `stack` in the default org).
pub(super) fn event_visible(orgs: &Option<Vec<crate::org::OrgId>>, stack: &str) -> bool {
    let Some(orgs) = orgs else { return true };
    let org = stack
        .split_once('/')
        .map(|(o, _)| o)
        .unwrap_or(crate::org::DEFAULT_ORG);
    orgs.iter().any(|o| o.as_str() == org)
}