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