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::{
10 ResourceId, SubjectId,
11 policy::{PolicyEngine, PolicyResult},
12};
13
14use crate::http::{HttpClient, ReqwestHttpClient};
15
16#[derive(Debug, Clone, Serialize)]
18pub struct ArcadeToolAuthRequest {
19 pub tool_name: String,
21 pub user_id: String,
23}
24
25#[derive(Debug, Deserialize)]
26struct ArcadeToolAuthResponse {
27 status: String,
28 #[serde(default)]
29 url: Option<String>,
30}
31
32pub 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 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 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 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}