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;
15use crate::provider::{ProviderHttpEngine, ProviderHttpError};
16
17#[derive(Debug, Clone, Serialize)]
19pub struct ArcadeToolAuthRequest {
20 pub tool_name: String,
22 pub user_id: String,
24}
25
26#[derive(Debug, Deserialize)]
27struct ArcadeToolAuthResponse {
28 status: String,
29 #[serde(default)]
30 url: Option<String>,
31}
32
33pub struct ArcadeToolAuthEngine {
39 engine: ProviderHttpEngine,
40 tool_map: HashMap<String, String>,
41}
42
43impl ArcadeToolAuthEngine {
44 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 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 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;