Skip to main content

typesec_integrations/
arcade.rs

1//! Arcade-style tool authorization integration.
2
3use std::collections::HashMap;
4use std::sync::Arc;
5
6use serde::{Deserialize, Serialize};
7use serde_json::json;
8use tracing::debug;
9use typesec_core::{
10    ResourceId, SubjectId,
11    policy::{PolicyEngine, PolicyResult},
12};
13
14use crate::http::HttpClient;
15use crate::provider::{ProviderHttpEngine, ProviderHttpError};
16
17/// Request body used to ask Arcade whether a tool is authorized for a user.
18#[derive(Debug, Clone, Serialize)]
19pub struct ArcadeToolAuthRequest {
20    /// Arcade tool name, for example `Gmail.ListEmails`.
21    pub tool_name: String,
22    /// End-user identifier known to Arcade.
23    pub user_id: String,
24}
25
26#[derive(Debug, Deserialize)]
27struct ArcadeToolAuthResponse {
28    status: String,
29    #[serde(default)]
30    url: Option<String>,
31}
32
33/// Policy engine that checks whether a user has authorized an external tool.
34///
35/// The engine maps Typesec resource ids to Arcade tool names. A resource id may
36/// either be present in the explicit mapping or already look like an Arcade tool
37/// name such as `Gmail.ListEmails`.
38pub struct ArcadeToolAuthEngine {
39    engine: ProviderHttpEngine,
40    tool_map: HashMap<String, String>,
41}
42
43impl ArcadeToolAuthEngine {
44    /// Create an engine using `https://api.arcade.dev`.
45    pub fn new(api_key: impl Into<String>) -> Self {
46        Self {
47            engine: ProviderHttpEngine::new(api_key, "https://api.arcade.dev"),
48            tool_map: HashMap::new(),
49        }
50    }
51
52    /// Create an engine with custom base URL and HTTP client.
53    pub fn with_http(
54        api_key: impl Into<String>,
55        base_url: impl Into<String>,
56        http: Arc<dyn HttpClient>,
57    ) -> Self {
58        Self {
59            engine: ProviderHttpEngine::with_http(api_key, base_url, http),
60            tool_map: HashMap::new(),
61        }
62    }
63
64    /// Map a Typesec resource id to an Arcade tool name.
65    pub fn with_tool_mapping(
66        mut self,
67        resource: impl Into<String>,
68        tool: impl Into<String>,
69    ) -> Self {
70        self.tool_map.insert(resource.into(), tool.into());
71        self
72    }
73
74    fn tool_name_for<'a>(&'a self, resource: &'a str) -> Option<&'a str> {
75        self.tool_map
76            .get(resource)
77            .map(String::as_str)
78            .or_else(|| resource.contains('.').then_some(resource))
79    }
80}
81
82impl PolicyEngine for ArcadeToolAuthEngine {
83    fn check(&self, subject: &SubjectId, action: &str, resource: &ResourceId) -> PolicyResult {
84        let subject = subject.as_str();
85        let resource = resource.as_str();
86        debug!(subject, action, resource, "arcade tool authorization check");
87
88        if action != "execute" && action != "read" && action != "write" {
89            return PolicyResult::delegate(
90                "arcade",
91                format!("Arcade tool auth does not handle action '{action}'"),
92            );
93        }
94
95        let Some(tool_name) = self.tool_name_for(resource) else {
96            return PolicyResult::delegate(
97                "arcade",
98                format!("no Arcade tool mapping for resource '{resource}'"),
99            );
100        };
101
102        let url = format!("{}/v1/tools/authorize", self.engine.base_url());
103        let body = json!({
104            "tool_name": tool_name,
105            "user_id": subject,
106        });
107
108        match self
109            .engine
110            .bearer_post::<ArcadeToolAuthResponse>(&url, &body)
111        {
112            Ok(response) if response.status == "completed" => PolicyResult::Allow,
113            Ok(response) => {
114                let url = response
115                    .url
116                    .map(|url| format!("; authorize at {url}"))
117                    .unwrap_or_default();
118                PolicyResult::Deny(format!(
119                    "Arcade authorization for tool '{tool_name}' is '{}'{}",
120                    response.status, url
121                ))
122            }
123            Err(ProviderHttpError::Parse(err)) => {
124                PolicyResult::Deny(format!("Arcade response parse error: {err}"))
125            }
126            Err(ProviderHttpError::Transport(err)) => {
127                PolicyResult::Deny(format!("Arcade authorization check failed: {err}"))
128            }
129        }
130    }
131}
132
133#[cfg(test)]
134mod tests;