1use car_ir::{Action, ActionType, ToolSchema};
8use car_policy::PolicyEngine;
9use car_state::StateStore;
10use serde::{Deserialize, Serialize};
11use std::collections::HashMap;
12
13#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
15#[serde(rename_all = "snake_case")]
16pub enum AuthzStage {
17 ToolExists,
19 Capability,
21 Permission,
23 Restriction,
25 Policy,
27 Validation,
29}
30
31#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
33#[serde(rename_all = "snake_case")]
34pub enum AuthzDecision {
35 Allow,
37 AskUser,
39 Deny,
41}
42
43#[derive(Debug, Clone, Serialize, Deserialize)]
45pub struct AuthzResult {
46 pub decision: AuthzDecision,
48 pub stage: AuthzStage,
50 pub reason_code: String,
52 pub explanation: String,
54 pub stage_results: Vec<StageResult>,
56}
57
58#[derive(Debug, Clone, Serialize, Deserialize)]
60pub struct StageResult {
61 pub stage: AuthzStage,
62 pub decision: AuthzDecision,
63 pub reason: String,
64}
65
66impl AuthzResult {
67 pub fn allowed(stage: AuthzStage) -> Self {
68 Self {
69 decision: AuthzDecision::Allow,
70 stage,
71 reason_code: "allowed".to_string(),
72 explanation: "All authorization checks passed".to_string(),
73 stage_results: Vec::new(),
74 }
75 }
76
77 pub fn denied(stage: AuthzStage, reason_code: &str, explanation: &str) -> Self {
78 Self {
79 decision: AuthzDecision::Deny,
80 stage,
81 reason_code: reason_code.to_string(),
82 explanation: explanation.to_string(),
83 stage_results: Vec::new(),
84 }
85 }
86
87 pub fn ask_user(stage: AuthzStage, reason_code: &str, explanation: &str) -> Self {
88 Self {
89 decision: AuthzDecision::AskUser,
90 stage,
91 reason_code: reason_code.to_string(),
92 explanation: explanation.to_string(),
93 stage_results: Vec::new(),
94 }
95 }
96
97 fn with_stages(mut self, stages: Vec<StageResult>) -> Self {
98 self.stage_results = stages;
99 self
100 }
101}
102
103pub struct Restriction {
105 pub name: String,
106 pub description: String,
107 check: Box<dyn Fn(&Action) -> Option<String> + Send + Sync>,
108}
109
110impl Restriction {
111 pub fn new<F>(name: &str, description: &str, check: F) -> Self
112 where
113 F: Fn(&Action) -> Option<String> + Send + Sync + 'static,
114 {
115 Self {
116 name: name.to_string(),
117 description: description.to_string(),
118 check: Box::new(check),
119 }
120 }
121
122 fn check(&self, action: &Action) -> Option<String> {
123 (self.check)(action)
124 }
125}
126
127#[async_trait::async_trait]
130pub trait PermissionHandler: Send + Sync {
131 async fn check(&self, tool_name: &str, action: &Action) -> AuthzDecision;
133}
134
135pub struct AllowAllPermissions;
137
138#[async_trait::async_trait]
139impl PermissionHandler for AllowAllPermissions {
140 async fn check(&self, _tool_name: &str, _action: &Action) -> AuthzDecision {
141 AuthzDecision::Allow
142 }
143}
144
145pub struct AuthzPipeline {
147 restrictions: Vec<Restriction>,
148 permission_handler: Box<dyn PermissionHandler>,
149}
150
151impl AuthzPipeline {
152 pub fn new() -> Self {
153 Self {
154 restrictions: Vec::new(),
155 permission_handler: Box::new(AllowAllPermissions),
156 }
157 }
158
159 pub fn add_restriction(&mut self, restriction: Restriction) {
161 self.restrictions.push(restriction);
162 }
163
164 pub fn set_permission_handler(&mut self, handler: Box<dyn PermissionHandler>) {
166 self.permission_handler = handler;
167 }
168
169 pub async fn authorize(
179 &self,
180 action: &Action,
181 tools: &HashMap<String, ToolSchema>,
182 capabilities: Option<&crate::capabilities::CapabilitySet>,
183 policies: &PolicyEngine,
184 state: &StateStore,
185 ) -> AuthzResult {
186 let mut stages = Vec::new();
187
188 if let Some(tool_name) = &action.tool {
190 if action.action_type == ActionType::ToolCall && !tools.contains_key(tool_name) {
191 stages.push(StageResult {
192 stage: AuthzStage::ToolExists,
193 decision: AuthzDecision::Deny,
194 reason: format!("tool '{}' not registered", tool_name),
195 });
196 return AuthzResult::denied(
197 AuthzStage::ToolExists,
198 "tool_not_found",
199 &format!("Tool '{}' is not registered", tool_name),
200 )
201 .with_stages(stages);
202 }
203 }
204 stages.push(StageResult {
205 stage: AuthzStage::ToolExists,
206 decision: AuthzDecision::Allow,
207 reason: "tool registered".to_string(),
208 });
209
210 if let Some(caps) = capabilities {
212 if let Some(tool_name) = &action.tool {
213 if !caps.tool_allowed(tool_name) {
214 stages.push(StageResult {
215 stage: AuthzStage::Capability,
216 decision: AuthzDecision::Deny,
217 reason: format!("tool '{}' not in capability set", tool_name),
218 });
219 return AuthzResult::denied(
220 AuthzStage::Capability,
221 "capability_denied",
222 &format!("Tool '{}' denied by capability set", tool_name),
223 )
224 .with_stages(stages);
225 }
226 }
227 }
228 stages.push(StageResult {
229 stage: AuthzStage::Capability,
230 decision: AuthzDecision::Allow,
231 reason: "capability check passed".to_string(),
232 });
233
234 if let Some(tool_name) = &action.tool {
236 let perm = self.permission_handler.check(tool_name, action).await;
237 stages.push(StageResult {
238 stage: AuthzStage::Permission,
239 decision: perm,
240 reason: format!("permission handler returned {:?}", perm),
241 });
242 if perm == AuthzDecision::Deny {
243 return AuthzResult::denied(
244 AuthzStage::Permission,
245 "permission_denied",
246 &format!("Permission denied for tool '{}'", tool_name),
247 )
248 .with_stages(stages);
249 }
250 if perm == AuthzDecision::AskUser {
251 return AuthzResult::ask_user(
252 AuthzStage::Permission,
253 "approval_required",
254 &format!("Tool '{}' requires user approval", tool_name),
255 )
256 .with_stages(stages);
257 }
258 } else {
259 stages.push(StageResult {
260 stage: AuthzStage::Permission,
261 decision: AuthzDecision::Allow,
262 reason: "no tool name, skipped".to_string(),
263 });
264 }
265
266 for restriction in &self.restrictions {
268 if let Some(reason) = restriction.check(action) {
269 stages.push(StageResult {
270 stage: AuthzStage::Restriction,
271 decision: AuthzDecision::Deny,
272 reason: reason.clone(),
273 });
274 return AuthzResult::denied(
275 AuthzStage::Restriction,
276 &format!("restriction_{}", restriction.name),
277 &format!("Permanent restriction '{}': {}", restriction.name, reason),
278 )
279 .with_stages(stages);
280 }
281 }
282 stages.push(StageResult {
283 stage: AuthzStage::Restriction,
284 decision: AuthzDecision::Allow,
285 reason: "all restrictions passed".to_string(),
286 });
287
288 let violations = policies.check(action, state);
290 if !violations.is_empty() {
291 let reasons: Vec<String> = violations
292 .iter()
293 .map(|v| format!("{}: {}", v.policy_name, v.reason))
294 .collect();
295 stages.push(StageResult {
296 stage: AuthzStage::Policy,
297 decision: AuthzDecision::Deny,
298 reason: reasons.join("; "),
299 });
300 return AuthzResult::denied(
301 AuthzStage::Policy,
302 "policy_violation",
303 &format!("Policy violations: {}", reasons.join("; ")),
304 )
305 .with_stages(stages);
306 }
307 stages.push(StageResult {
308 stage: AuthzStage::Policy,
309 decision: AuthzDecision::Allow,
310 reason: "all policies passed".to_string(),
311 });
312
313 stages.push(StageResult {
315 stage: AuthzStage::Validation,
316 decision: AuthzDecision::Allow,
317 reason: "validation deferred".to_string(),
318 });
319
320 AuthzResult::allowed(AuthzStage::Validation).with_stages(stages)
321 }
322}
323
324impl Default for AuthzPipeline {
325 fn default() -> Self {
326 Self::new()
327 }
328}
329
330#[cfg(test)]
331mod tests {
332 use super::*;
333 use car_ir::{Action, ActionType, FailureBehavior, ToolSchema};
334
335 fn test_action(tool: &str) -> Action {
336 Action {
337 id: "test-1".to_string(),
338 action_type: ActionType::ToolCall,
339 tool: Some(tool.to_string()),
340 parameters: Default::default(),
341 preconditions: vec![],
342 expected_effects: Default::default(),
343 state_dependencies: Vec::new(),
344 idempotent: false,
345 max_retries: 3,
346 failure_behavior: FailureBehavior::Abort,
347 timeout_ms: None,
348 metadata: Default::default(),
349 }
350 }
351
352 fn test_tools() -> HashMap<String, ToolSchema> {
353 let mut m = HashMap::new();
354 m.insert(
355 "read".to_string(),
356 ToolSchema {
357 name: "read".to_string(),
358 description: "Read a file".to_string(),
359 parameters: serde_json::json!({"type": "object"}),
360 returns: None,
361 idempotent: true,
362 cache_ttl_secs: None,
363 rate_limit: None,
364 },
365 );
366 m
367 }
368
369 #[tokio::test]
370 async fn test_allow_registered_tool() {
371 let pipeline = AuthzPipeline::new();
372 let tools = test_tools();
373 let policies = PolicyEngine::new();
374 let state = StateStore::new();
375
376 let result = pipeline
377 .authorize(&test_action("read"), &tools, None, &policies, &state)
378 .await;
379 assert_eq!(result.decision, AuthzDecision::Allow);
380 assert_eq!(result.stage_results.len(), 6);
381 }
382
383 #[tokio::test]
384 async fn test_deny_unregistered_tool() {
385 let pipeline = AuthzPipeline::new();
386 let tools = test_tools();
387 let policies = PolicyEngine::new();
388 let state = StateStore::new();
389
390 let result = pipeline
391 .authorize(&test_action("delete"), &tools, None, &policies, &state)
392 .await;
393 assert_eq!(result.decision, AuthzDecision::Deny);
394 assert_eq!(result.stage, AuthzStage::ToolExists);
395 assert_eq!(result.reason_code, "tool_not_found");
396 }
397
398 #[tokio::test]
399 async fn test_capability_denial() {
400 let pipeline = AuthzPipeline::new();
401 let tools = test_tools();
402 let policies = PolicyEngine::new();
403 let state = StateStore::new();
404 let mut caps = crate::capabilities::CapabilitySet::default();
405 caps.denied_tools.insert("read".to_string());
406
407 let result = pipeline
408 .authorize(&test_action("read"), &tools, Some(&caps), &policies, &state)
409 .await;
410 assert_eq!(result.decision, AuthzDecision::Deny);
411 assert_eq!(result.stage, AuthzStage::Capability);
412 }
413
414 #[tokio::test]
415 async fn test_restriction() {
416 let mut pipeline = AuthzPipeline::new();
417 pipeline.add_restriction(Restriction::new("no_read", "Never allow read", |action| {
418 if action.tool.as_deref() == Some("read") {
419 Some("reads are restricted".to_string())
420 } else {
421 None
422 }
423 }));
424 let tools = test_tools();
425 let policies = PolicyEngine::new();
426 let state = StateStore::new();
427
428 let result = pipeline
429 .authorize(&test_action("read"), &tools, None, &policies, &state)
430 .await;
431 assert_eq!(result.decision, AuthzDecision::Deny);
432 assert_eq!(result.stage, AuthzStage::Restriction);
433 }
434
435 #[tokio::test]
436 async fn test_policy_violation() {
437 let pipeline = AuthzPipeline::new();
438 let tools = test_tools();
439 let state = StateStore::new();
440 let mut policies = PolicyEngine::new();
441 policies.register(
442 "deny_all",
443 Box::new(|_action: &Action, _state: &StateStore| Some("denied by test".to_string())),
444 "test policy",
445 );
446
447 let result = pipeline
448 .authorize(&test_action("read"), &tools, None, &policies, &state)
449 .await;
450 assert_eq!(result.decision, AuthzDecision::Deny);
451 assert_eq!(result.stage, AuthzStage::Policy);
452 }
453
454 #[tokio::test]
455 async fn test_ask_user_permission() {
456 struct AskPermissions;
457 #[async_trait::async_trait]
458 impl PermissionHandler for AskPermissions {
459 async fn check(&self, _tool_name: &str, _action: &Action) -> AuthzDecision {
460 AuthzDecision::AskUser
461 }
462 }
463
464 let mut pipeline = AuthzPipeline::new();
465 pipeline.set_permission_handler(Box::new(AskPermissions));
466 let tools = test_tools();
467 let policies = PolicyEngine::new();
468 let state = StateStore::new();
469
470 let result = pipeline
471 .authorize(&test_action("read"), &tools, None, &policies, &state)
472 .await;
473 assert_eq!(result.decision, AuthzDecision::AskUser);
474 assert_eq!(result.stage, AuthzStage::Permission);
475 assert_eq!(result.reason_code, "approval_required");
476 }
477
478 #[tokio::test]
479 async fn test_stage_results_trace() {
480 let pipeline = AuthzPipeline::new();
481 let tools = test_tools();
482 let policies = PolicyEngine::new();
483 let state = StateStore::new();
484
485 let result = pipeline
486 .authorize(&test_action("read"), &tools, None, &policies, &state)
487 .await;
488 let stage_names: Vec<AuthzStage> = result.stage_results.iter().map(|s| s.stage).collect();
490 assert_eq!(
491 stage_names,
492 vec![
493 AuthzStage::ToolExists,
494 AuthzStage::Capability,
495 AuthzStage::Permission,
496 AuthzStage::Restriction,
497 AuthzStage::Policy,
498 AuthzStage::Validation,
499 ]
500 );
501 }
502
503 #[tokio::test]
504 async fn test_short_circuit_on_deny() {
505 let pipeline = AuthzPipeline::new();
506 let tools = test_tools();
507 let policies = PolicyEngine::new();
508 let state = StateStore::new();
509
510 let result = pipeline
512 .authorize(&test_action("nonexistent"), &tools, None, &policies, &state)
513 .await;
514 assert_eq!(result.stage_results.len(), 1);
515 assert_eq!(result.stage_results[0].stage, AuthzStage::ToolExists);
516 }
517
518 #[tokio::test]
519 async fn test_serde_roundtrip() {
520 let result = AuthzResult::denied(AuthzStage::Policy, "policy_violation", "Test violation");
521 let json = serde_json::to_string(&result).unwrap();
522 let roundtripped: AuthzResult = serde_json::from_str(&json).unwrap();
523 assert_eq!(roundtripped.decision, AuthzDecision::Deny);
524 assert_eq!(roundtripped.stage, AuthzStage::Policy);
525 assert_eq!(roundtripped.reason_code, "policy_violation");
526 }
527}