Skip to main content

car_engine/
authz.rs

1//! Authorization pipeline for tool execution.
2//!
3//! Provides a structured pre-execution decision path with typed decisions.
4//! Each stage can Allow, Deny, or AskUser, and the pipeline short-circuits
5//! on the first Deny.
6
7use car_ir::{Action, ActionType, ToolSchema};
8use car_policy::PolicyEngine;
9use car_state::StateStore;
10use serde::{Deserialize, Serialize};
11use std::collections::HashMap;
12
13/// The stage that produced an authorization decision.
14#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
15#[serde(rename_all = "snake_case")]
16pub enum AuthzStage {
17    /// Tool existence check.
18    ToolExists,
19    /// Capability check (agent-level whitelist/blacklist).
20    Capability,
21    /// Permission mode / approval policy.
22    Permission,
23    /// Permanent restrictions (never bypassable).
24    Restriction,
25    /// Policy engine rules.
26    Policy,
27    /// Executor-level parameter validation.
28    Validation,
29}
30
31/// The authorization decision.
32#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
33#[serde(rename_all = "snake_case")]
34pub enum AuthzDecision {
35    /// Execution is allowed to proceed.
36    Allow,
37    /// Execution requires user approval before proceeding.
38    AskUser,
39    /// Execution is denied.
40    Deny,
41}
42
43/// A complete authorization result from the pipeline.
44#[derive(Debug, Clone, Serialize, Deserialize)]
45pub struct AuthzResult {
46    /// The final decision.
47    pub decision: AuthzDecision,
48    /// The stage that produced the decision (for Deny/AskUser, the stage that stopped it).
49    pub stage: AuthzStage,
50    /// Machine-readable reason code.
51    pub reason_code: String,
52    /// Human-readable explanation.
53    pub explanation: String,
54    /// Results from each stage that was evaluated.
55    pub stage_results: Vec<StageResult>,
56}
57
58/// Result from a single authorization stage.
59#[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
103/// A permanent restriction that can never be bypassed.
104pub 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/// Callback for permission mode decisions (allow/ask/deny).
128/// Products implement this to integrate their approval UX.
129#[async_trait::async_trait]
130pub trait PermissionHandler: Send + Sync {
131    /// Decide whether to allow, ask, or deny a tool call.
132    async fn check(&self, tool_name: &str, action: &Action) -> AuthzDecision;
133}
134
135/// Default permission handler that allows everything.
136pub 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
145/// The authorization pipeline.
146pub 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    /// Add a permanent restriction.
160    pub fn add_restriction(&mut self, restriction: Restriction) {
161        self.restrictions.push(restriction);
162    }
163
164    /// Set the permission handler.
165    pub fn set_permission_handler(&mut self, handler: Box<dyn PermissionHandler>) {
166        self.permission_handler = handler;
167    }
168
169    /// Run the full authorization pipeline for an action.
170    ///
171    /// Stages (in order):
172    /// 1. Tool exists
173    /// 2. Capability allows it
174    /// 3. Permission mode / approval
175    /// 4. Permanent restrictions
176    /// 5. Policy engine
177    /// 6. Executor-level validation
178    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        // Stage 1: Tool exists
189        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        // Stage 2: Capability check
211        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        // Stage 3: Permission mode
235        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        // Stage 4: Permanent restrictions
267        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        // Stage 5: Policy engine
289        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        // Stage 6: Validation (deferred to caller — we just mark it as passed here)
314        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        // All 6 stages should be present when everything passes
489        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        // Unregistered tool should short-circuit at stage 1
511        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}