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, ReqwestHttpClient};
15
16/// Request body used to ask Arcade whether a tool is authorized for a user.
17#[derive(Debug, Clone, Serialize)]
18pub struct ArcadeToolAuthRequest {
19    /// Arcade tool name, for example `Gmail.ListEmails`.
20    pub tool_name: String,
21    /// End-user identifier known to Arcade.
22    pub user_id: String,
23}
24
25#[derive(Debug, Deserialize)]
26struct ArcadeToolAuthResponse {
27    status: String,
28    #[serde(default)]
29    url: Option<String>,
30}
31
32/// Policy engine that checks whether a user has authorized an external tool.
33///
34/// The engine maps Typesec resource ids to Arcade tool names. A resource id may
35/// either be present in the explicit mapping or already look like an Arcade tool
36/// name such as `Gmail.ListEmails`.
37pub struct ArcadeToolAuthEngine {
38    api_key: String,
39    base_url: String,
40    tool_map: HashMap<String, String>,
41    http: Arc<dyn HttpClient>,
42}
43
44impl ArcadeToolAuthEngine {
45    /// Create an engine using `https://api.arcade.dev`.
46    pub fn new(api_key: impl Into<String>) -> Self {
47        Self::with_http(
48            api_key,
49            "https://api.arcade.dev",
50            Arc::new(ReqwestHttpClient::new()),
51        )
52    }
53
54    /// Create an engine with custom base URL and HTTP client.
55    pub fn with_http(
56        api_key: impl Into<String>,
57        base_url: impl Into<String>,
58        http: Arc<dyn HttpClient>,
59    ) -> Self {
60        Self {
61            api_key: api_key.into(),
62            base_url: base_url.into().trim_end_matches('/').to_string(),
63            tool_map: HashMap::new(),
64            http,
65        }
66    }
67
68    /// Map a Typesec resource id to an Arcade tool name.
69    pub fn with_tool_mapping(
70        mut self,
71        resource: impl Into<String>,
72        tool: impl Into<String>,
73    ) -> Self {
74        self.tool_map.insert(resource.into(), tool.into());
75        self
76    }
77
78    fn tool_name_for<'a>(&'a self, resource: &'a str) -> Option<&'a str> {
79        self.tool_map
80            .get(resource)
81            .map(String::as_str)
82            .or_else(|| resource.contains('.').then_some(resource))
83    }
84}
85
86impl PolicyEngine for ArcadeToolAuthEngine {
87    fn check(&self, subject: &SubjectId, action: &str, resource: &ResourceId) -> PolicyResult {
88        let subject = subject.as_str();
89        let resource = resource.as_str();
90        debug!(subject, action, resource, "arcade tool authorization check");
91
92        if action != "execute" && action != "read" && action != "write" {
93            return PolicyResult::delegate(
94                "arcade",
95                format!("Arcade tool auth does not handle action '{action}'"),
96            );
97        }
98
99        let Some(tool_name) = self.tool_name_for(resource) else {
100            return PolicyResult::delegate(
101                "arcade",
102                format!("no Arcade tool mapping for resource '{resource}'"),
103            );
104        };
105
106        let url = format!("{}/v1/tools/authorize", self.base_url);
107        let body = json!({
108            "tool_name": tool_name,
109            "user_id": subject,
110        });
111        let headers = [("Authorization", format!("Bearer {}", self.api_key))];
112
113        match self.http.post_json(&url, &headers, &body) {
114            Ok(value) => match serde_json::from_value::<ArcadeToolAuthResponse>(value) {
115                Ok(response) if response.status == "completed" => PolicyResult::Allow,
116                Ok(response) => {
117                    let url = response
118                        .url
119                        .map(|url| format!("; authorize at {url}"))
120                        .unwrap_or_default();
121                    PolicyResult::Deny(format!(
122                        "Arcade authorization for tool '{tool_name}' is '{}'{}",
123                        response.status, url
124                    ))
125                }
126                Err(err) => PolicyResult::Deny(format!("Arcade response parse error: {err}")),
127            },
128            Err(err) => PolicyResult::Deny(format!("Arcade authorization check failed: {err}")),
129        }
130    }
131}
132
133#[cfg(test)]
134mod tests {
135    use super::*;
136    use crate::http::StaticHttpClient;
137    use serde_json::json;
138
139    fn check(
140        engine: &ArcadeToolAuthEngine,
141        subject: &str,
142        action: &str,
143        resource: &str,
144    ) -> PolicyResult {
145        engine.check(
146            &SubjectId::from(subject),
147            action,
148            &ResourceId::from(resource),
149        )
150    }
151
152    #[test]
153    fn allows_completed_authorization() {
154        let http = StaticHttpClient::new().with_response(
155            "https://api.arcade.test/v1/tools/authorize",
156            json!({ "status": "completed" }),
157        );
158        let engine =
159            ArcadeToolAuthEngine::with_http("arc_test", "https://api.arcade.test", Arc::new(http))
160                .with_tool_mapping("gmail/list", "Gmail.ListEmails");
161
162        assert_eq!(
163            check(&engine, "user@example.com", "execute", "gmail/list"),
164            PolicyResult::Allow
165        );
166    }
167
168    #[test]
169    fn denies_pending_authorization_with_url() {
170        let http = StaticHttpClient::new().with_response(
171            "https://api.arcade.test/v1/tools/authorize",
172            json!({ "status": "pending", "url": "https://authorize.example" }),
173        );
174        let engine =
175            ArcadeToolAuthEngine::with_http("arc_test", "https://api.arcade.test", Arc::new(http))
176                .with_tool_mapping("gmail/list", "Gmail.ListEmails");
177
178        let result = check(&engine, "user@example.com", "execute", "gmail/list");
179        assert!(matches!(result, PolicyResult::Deny(reason) if reason.contains("authorize")));
180    }
181}