use car_ir::{Action, ActionType, ToolSchema};
use car_policy::PolicyEngine;
use car_state::StateStore;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum AuthzStage {
ToolExists,
Capability,
Permission,
Restriction,
Policy,
Validation,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum AuthzDecision {
Allow,
AskUser,
Deny,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AuthzResult {
pub decision: AuthzDecision,
pub stage: AuthzStage,
pub reason_code: String,
pub explanation: String,
pub stage_results: Vec<StageResult>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct StageResult {
pub stage: AuthzStage,
pub decision: AuthzDecision,
pub reason: String,
}
impl AuthzResult {
pub fn allowed(stage: AuthzStage) -> Self {
Self {
decision: AuthzDecision::Allow,
stage,
reason_code: "allowed".to_string(),
explanation: "All authorization checks passed".to_string(),
stage_results: Vec::new(),
}
}
pub fn denied(stage: AuthzStage, reason_code: &str, explanation: &str) -> Self {
Self {
decision: AuthzDecision::Deny,
stage,
reason_code: reason_code.to_string(),
explanation: explanation.to_string(),
stage_results: Vec::new(),
}
}
pub fn ask_user(stage: AuthzStage, reason_code: &str, explanation: &str) -> Self {
Self {
decision: AuthzDecision::AskUser,
stage,
reason_code: reason_code.to_string(),
explanation: explanation.to_string(),
stage_results: Vec::new(),
}
}
fn with_stages(mut self, stages: Vec<StageResult>) -> Self {
self.stage_results = stages;
self
}
}
pub struct Restriction {
pub name: String,
pub description: String,
check: Box<dyn Fn(&Action) -> Option<String> + Send + Sync>,
}
impl Restriction {
pub fn new<F>(name: &str, description: &str, check: F) -> Self
where
F: Fn(&Action) -> Option<String> + Send + Sync + 'static,
{
Self {
name: name.to_string(),
description: description.to_string(),
check: Box::new(check),
}
}
fn check(&self, action: &Action) -> Option<String> {
(self.check)(action)
}
}
#[async_trait::async_trait]
pub trait PermissionHandler: Send + Sync {
async fn check(&self, tool_name: &str, action: &Action) -> AuthzDecision;
}
pub struct AllowAllPermissions;
#[async_trait::async_trait]
impl PermissionHandler for AllowAllPermissions {
async fn check(&self, _tool_name: &str, _action: &Action) -> AuthzDecision {
AuthzDecision::Allow
}
}
pub struct AuthzPipeline {
restrictions: Vec<Restriction>,
permission_handler: Box<dyn PermissionHandler>,
}
impl AuthzPipeline {
pub fn new() -> Self {
Self {
restrictions: Vec::new(),
permission_handler: Box::new(AllowAllPermissions),
}
}
pub fn add_restriction(&mut self, restriction: Restriction) {
self.restrictions.push(restriction);
}
pub fn set_permission_handler(&mut self, handler: Box<dyn PermissionHandler>) {
self.permission_handler = handler;
}
pub async fn authorize(
&self,
action: &Action,
tools: &HashMap<String, ToolSchema>,
capabilities: Option<&crate::capabilities::CapabilitySet>,
policies: &PolicyEngine,
state: &StateStore,
) -> AuthzResult {
let mut stages = Vec::new();
if let Some(tool_name) = &action.tool {
if action.action_type == ActionType::ToolCall && !tools.contains_key(tool_name) {
stages.push(StageResult {
stage: AuthzStage::ToolExists,
decision: AuthzDecision::Deny,
reason: format!("tool '{}' not registered", tool_name),
});
return AuthzResult::denied(
AuthzStage::ToolExists,
"tool_not_found",
&format!("Tool '{}' is not registered", tool_name),
)
.with_stages(stages);
}
}
stages.push(StageResult {
stage: AuthzStage::ToolExists,
decision: AuthzDecision::Allow,
reason: "tool registered".to_string(),
});
if let Some(caps) = capabilities {
if let Some(tool_name) = &action.tool {
if !caps.tool_allowed(tool_name) {
stages.push(StageResult {
stage: AuthzStage::Capability,
decision: AuthzDecision::Deny,
reason: format!("tool '{}' not in capability set", tool_name),
});
return AuthzResult::denied(
AuthzStage::Capability,
"capability_denied",
&format!("Tool '{}' denied by capability set", tool_name),
)
.with_stages(stages);
}
}
}
stages.push(StageResult {
stage: AuthzStage::Capability,
decision: AuthzDecision::Allow,
reason: "capability check passed".to_string(),
});
if let Some(tool_name) = &action.tool {
let perm = self.permission_handler.check(tool_name, action).await;
stages.push(StageResult {
stage: AuthzStage::Permission,
decision: perm,
reason: format!("permission handler returned {:?}", perm),
});
if perm == AuthzDecision::Deny {
return AuthzResult::denied(
AuthzStage::Permission,
"permission_denied",
&format!("Permission denied for tool '{}'", tool_name),
)
.with_stages(stages);
}
if perm == AuthzDecision::AskUser {
return AuthzResult::ask_user(
AuthzStage::Permission,
"approval_required",
&format!("Tool '{}' requires user approval", tool_name),
)
.with_stages(stages);
}
} else {
stages.push(StageResult {
stage: AuthzStage::Permission,
decision: AuthzDecision::Allow,
reason: "no tool name, skipped".to_string(),
});
}
for restriction in &self.restrictions {
if let Some(reason) = restriction.check(action) {
stages.push(StageResult {
stage: AuthzStage::Restriction,
decision: AuthzDecision::Deny,
reason: reason.clone(),
});
return AuthzResult::denied(
AuthzStage::Restriction,
&format!("restriction_{}", restriction.name),
&format!("Permanent restriction '{}': {}", restriction.name, reason),
)
.with_stages(stages);
}
}
stages.push(StageResult {
stage: AuthzStage::Restriction,
decision: AuthzDecision::Allow,
reason: "all restrictions passed".to_string(),
});
let violations = policies.check(action, state);
if !violations.is_empty() {
let reasons: Vec<String> = violations
.iter()
.map(|v| format!("{}: {}", v.policy_name, v.reason))
.collect();
stages.push(StageResult {
stage: AuthzStage::Policy,
decision: AuthzDecision::Deny,
reason: reasons.join("; "),
});
return AuthzResult::denied(
AuthzStage::Policy,
"policy_violation",
&format!("Policy violations: {}", reasons.join("; ")),
)
.with_stages(stages);
}
stages.push(StageResult {
stage: AuthzStage::Policy,
decision: AuthzDecision::Allow,
reason: "all policies passed".to_string(),
});
stages.push(StageResult {
stage: AuthzStage::Validation,
decision: AuthzDecision::Allow,
reason: "validation deferred".to_string(),
});
AuthzResult::allowed(AuthzStage::Validation).with_stages(stages)
}
}
impl Default for AuthzPipeline {
fn default() -> Self {
Self::new()
}
}
pub struct TierPermissionHandler {
gate: std::sync::Arc<tokio::sync::RwLock<car_policy::PermissionGate>>,
log: Option<std::sync::Arc<tokio::sync::Mutex<car_eventlog::EventLog>>>,
}
impl TierPermissionHandler {
pub fn new(
gate: std::sync::Arc<tokio::sync::RwLock<car_policy::PermissionGate>>,
) -> Self {
Self { gate, log: None }
}
pub fn with_event_log(
mut self,
log: std::sync::Arc<tokio::sync::Mutex<car_eventlog::EventLog>>,
) -> Self {
self.log = Some(log);
self
}
pub async fn record_approval(
&self,
action: &Action,
approve: bool,
reviewer: &str,
reason: &str,
evidence: Option<String>,
) -> car_policy::ApprovalRecord {
let record = {
let mut gate = self.gate.write().await;
if approve {
gate.approve(action, reviewer, reason, evidence)
} else {
gate.reject(action, reviewer, reason, evidence)
}
};
if let Some(log) = &self.log {
let mut data = HashMap::new();
data.insert("fingerprint".into(), record.fingerprint.clone().into());
data.insert(
"approval".into(),
match record.decision {
car_policy::ApprovalDecision::Approved => "approved",
car_policy::ApprovalDecision::Rejected => "rejected",
}
.into(),
);
data.insert("required_tier".into(), record.required_tier.as_str().into());
data.insert("reviewer".into(), record.reviewer.clone().into());
data.insert("reason".into(), record.reason.clone().into());
if let Some(ev) = &record.evidence {
data.insert("evidence".into(), ev.clone().into());
}
log.lock().await.append(
car_eventlog::EventKind::ApprovalRecorded,
Some(&action.id),
None,
data,
);
}
record
}
fn map_decision(decision: &car_policy::GateDecision) -> AuthzDecision {
match decision {
car_policy::GateDecision::Allow { .. } => AuthzDecision::Allow,
car_policy::GateDecision::NeedsApproval { .. } => AuthzDecision::AskUser,
car_policy::GateDecision::Deny { .. } => AuthzDecision::Deny,
}
}
fn decision_data(
decision: &car_policy::GateDecision,
) -> HashMap<String, serde_json::Value> {
use car_policy::GateDecision::*;
let mut data = HashMap::new();
match decision {
Allow { required, granted } => {
data.insert("gate_decision".into(), "allow".into());
data.insert("required_tier".into(), required.as_str().into());
data.insert("granted_tier".into(), granted.as_str().into());
}
NeedsApproval {
required,
granted,
fingerprint,
reason,
} => {
data.insert("gate_decision".into(), "needs_approval".into());
data.insert("required_tier".into(), required.as_str().into());
data.insert("granted_tier".into(), granted.as_str().into());
data.insert("fingerprint".into(), fingerprint.clone().into());
data.insert("reason".into(), reason.clone().into());
}
Deny {
required,
fingerprint,
reason,
} => {
data.insert("gate_decision".into(), "deny".into());
data.insert("required_tier".into(), required.as_str().into());
data.insert("fingerprint".into(), fingerprint.clone().into());
data.insert("reason".into(), reason.clone().into());
}
}
data
}
}
#[async_trait::async_trait]
impl PermissionHandler for TierPermissionHandler {
async fn check(&self, _tool_name: &str, action: &Action) -> AuthzDecision {
let decision = {
let gate = self.gate.read().await;
gate.evaluate(action)
};
if let Some(log) = &self.log {
let data = Self::decision_data(&decision);
log.lock().await.append(
car_eventlog::EventKind::PermissionDecision,
Some(&action.id),
None,
data,
);
}
Self::map_decision(&decision)
}
}
#[cfg(test)]
mod tests {
use super::*;
use car_ir::{Action, ActionType, FailureBehavior, ToolSchema};
fn test_action(tool: &str) -> Action {
Action {
id: "test-1".to_string(),
action_type: ActionType::ToolCall,
tool: Some(tool.to_string()),
parameters: Default::default(),
preconditions: vec![],
expected_effects: Default::default(),
state_dependencies: Vec::new(),
read_set: vec![],
write_set: vec![],
assumptions: vec![],
idempotent: false,
max_retries: 3,
failure_behavior: FailureBehavior::Abort,
timeout_ms: None,
metadata: Default::default(),
}
}
fn test_tools() -> HashMap<String, ToolSchema> {
let mut m = HashMap::new();
m.insert(
"read".to_string(),
ToolSchema {
name: "read".to_string(),
description: "Read a file".to_string(),
parameters: serde_json::json!({"type": "object"}),
returns: None,
idempotent: true,
cache_ttl_secs: None,
rate_limit: None,
},
);
m
}
#[tokio::test]
async fn test_allow_registered_tool() {
let pipeline = AuthzPipeline::new();
let tools = test_tools();
let policies = PolicyEngine::new();
let state = StateStore::new();
let result = pipeline
.authorize(&test_action("read"), &tools, None, &policies, &state)
.await;
assert_eq!(result.decision, AuthzDecision::Allow);
assert_eq!(result.stage_results.len(), 6);
}
#[tokio::test]
async fn test_deny_unregistered_tool() {
let pipeline = AuthzPipeline::new();
let tools = test_tools();
let policies = PolicyEngine::new();
let state = StateStore::new();
let result = pipeline
.authorize(&test_action("delete"), &tools, None, &policies, &state)
.await;
assert_eq!(result.decision, AuthzDecision::Deny);
assert_eq!(result.stage, AuthzStage::ToolExists);
assert_eq!(result.reason_code, "tool_not_found");
}
#[tokio::test]
async fn test_capability_denial() {
let pipeline = AuthzPipeline::new();
let tools = test_tools();
let policies = PolicyEngine::new();
let state = StateStore::new();
let mut caps = crate::capabilities::CapabilitySet::default();
caps.denied_tools.insert("read".to_string());
let result = pipeline
.authorize(&test_action("read"), &tools, Some(&caps), &policies, &state)
.await;
assert_eq!(result.decision, AuthzDecision::Deny);
assert_eq!(result.stage, AuthzStage::Capability);
}
#[tokio::test]
async fn test_restriction() {
let mut pipeline = AuthzPipeline::new();
pipeline.add_restriction(Restriction::new("no_read", "Never allow read", |action| {
if action.tool.as_deref() == Some("read") {
Some("reads are restricted".to_string())
} else {
None
}
}));
let tools = test_tools();
let policies = PolicyEngine::new();
let state = StateStore::new();
let result = pipeline
.authorize(&test_action("read"), &tools, None, &policies, &state)
.await;
assert_eq!(result.decision, AuthzDecision::Deny);
assert_eq!(result.stage, AuthzStage::Restriction);
}
#[tokio::test]
async fn test_policy_violation() {
let pipeline = AuthzPipeline::new();
let tools = test_tools();
let state = StateStore::new();
let mut policies = PolicyEngine::new();
policies.register(
"deny_all",
Box::new(|_action: &Action, _state: &StateStore| Some("denied by test".to_string())),
"test policy",
);
let result = pipeline
.authorize(&test_action("read"), &tools, None, &policies, &state)
.await;
assert_eq!(result.decision, AuthzDecision::Deny);
assert_eq!(result.stage, AuthzStage::Policy);
}
#[tokio::test]
async fn test_ask_user_permission() {
struct AskPermissions;
#[async_trait::async_trait]
impl PermissionHandler for AskPermissions {
async fn check(&self, _tool_name: &str, _action: &Action) -> AuthzDecision {
AuthzDecision::AskUser
}
}
let mut pipeline = AuthzPipeline::new();
pipeline.set_permission_handler(Box::new(AskPermissions));
let tools = test_tools();
let policies = PolicyEngine::new();
let state = StateStore::new();
let result = pipeline
.authorize(&test_action("read"), &tools, None, &policies, &state)
.await;
assert_eq!(result.decision, AuthzDecision::AskUser);
assert_eq!(result.stage, AuthzStage::Permission);
assert_eq!(result.reason_code, "approval_required");
}
#[tokio::test]
async fn test_stage_results_trace() {
let pipeline = AuthzPipeline::new();
let tools = test_tools();
let policies = PolicyEngine::new();
let state = StateStore::new();
let result = pipeline
.authorize(&test_action("read"), &tools, None, &policies, &state)
.await;
let stage_names: Vec<AuthzStage> = result.stage_results.iter().map(|s| s.stage).collect();
assert_eq!(
stage_names,
vec![
AuthzStage::ToolExists,
AuthzStage::Capability,
AuthzStage::Permission,
AuthzStage::Restriction,
AuthzStage::Policy,
AuthzStage::Validation,
]
);
}
#[tokio::test]
async fn test_short_circuit_on_deny() {
let pipeline = AuthzPipeline::new();
let tools = test_tools();
let policies = PolicyEngine::new();
let state = StateStore::new();
let result = pipeline
.authorize(&test_action("nonexistent"), &tools, None, &policies, &state)
.await;
assert_eq!(result.stage_results.len(), 1);
assert_eq!(result.stage_results[0].stage, AuthzStage::ToolExists);
}
#[tokio::test]
async fn test_serde_roundtrip() {
let result = AuthzResult::denied(AuthzStage::Policy, "policy_violation", "Test violation");
let json = serde_json::to_string(&result).unwrap();
let roundtripped: AuthzResult = serde_json::from_str(&json).unwrap();
assert_eq!(roundtripped.decision, AuthzDecision::Deny);
assert_eq!(roundtripped.stage, AuthzStage::Policy);
assert_eq!(roundtripped.reason_code, "policy_violation");
}
use std::sync::Arc;
use tokio::sync::RwLock;
fn deploy_action() -> Action {
let mut a = test_action("deploy_service");
a.id = "deploy-1".to_string();
a
}
#[tokio::test]
async fn tier_handler_asks_user_for_full_access() {
let gate = Arc::new(RwLock::new(car_policy::PermissionGate::new(
car_policy::PermissionTier::SandboxEdit,
)));
let handler = TierPermissionHandler::new(gate);
assert_eq!(
handler.check("deploy_service", &deploy_action()).await,
AuthzDecision::AskUser
);
}
#[tokio::test]
async fn tier_handler_allows_after_approval_and_audits() {
let gate = Arc::new(RwLock::new(car_policy::PermissionGate::new(
car_policy::PermissionTier::SandboxEdit,
)));
let log = Arc::new(tokio::sync::Mutex::new(car_eventlog::EventLog::new()));
let handler = TierPermissionHandler::new(gate.clone()).with_event_log(log.clone());
let action = deploy_action();
assert_eq!(
handler.check("deploy_service", &action).await,
AuthzDecision::AskUser
);
handler
.record_approval(&action, true, "matt", "reviewed", Some("diff".into()))
.await;
assert_eq!(
handler.check("deploy_service", &action).await,
AuthzDecision::Allow
);
let log = log.lock().await;
let decisions: Vec<_> = log
.events()
.iter()
.filter(|e| e.kind == car_eventlog::EventKind::PermissionDecision)
.collect();
assert_eq!(decisions.len(), 2);
assert_eq!(
decisions[0].data.get("gate_decision").unwrap(),
"needs_approval"
);
assert_eq!(decisions[1].data.get("gate_decision").unwrap(), "allow");
let approvals: Vec<_> = log
.events()
.iter()
.filter(|e| e.kind == car_eventlog::EventKind::ApprovalRecorded)
.collect();
assert_eq!(approvals.len(), 1);
assert_eq!(approvals[0].data.get("approval").unwrap(), "approved");
assert_eq!(approvals[0].data.get("reviewer").unwrap(), "matt");
}
#[tokio::test]
async fn tier_handler_denies_after_rejection() {
let gate = Arc::new(RwLock::new(car_policy::PermissionGate::new(
car_policy::PermissionTier::FullAccess,
)));
let handler = TierPermissionHandler::new(gate.clone());
let action = deploy_action();
gate.write()
.await
.reject(&action, "matt", "not authorized", None);
assert_eq!(
handler.check("deploy_service", &action).await,
AuthzDecision::Deny
);
}
}