typesec_integrations/
arcade.rs1use 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#[derive(Debug, Clone, Serialize)]
15pub struct ArcadeToolAuthRequest {
16 pub tool_name: String,
18 pub user_id: String,
20}
21
22#[derive(Debug, Deserialize)]
23struct ArcadeToolAuthResponse {
24 status: String,
25 #[serde(default)]
26 url: Option<String>,
27}
28
29pub 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 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 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 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}