Skip to main content

zentinel_proxy/agents/
decision.rs

1//! Agent decision types.
2
3use std::collections::HashMap;
4
5use zentinel_agent_protocol::{AgentResponse, AuditMetadata, BodyMutation, Decision, HeaderOp};
6
7/// Agent decision combining all agent responses.
8#[derive(Debug, Clone)]
9pub struct AgentDecision {
10    /// Final decision action
11    pub action: AgentAction,
12    /// ID of the agent that produced the deciding (non-allow) action
13    ///
14    /// `None` for allow decisions and for decisions not attributable to a
15    /// specific agent. Preserved through [`AgentDecision::merge`] so block
16    /// logs can always answer "which agent blocked this request".
17    pub decided_by: Option<String>,
18    /// Header modifications for request
19    pub request_headers: Vec<HeaderOp>,
20    /// Header modifications for response
21    pub response_headers: Vec<HeaderOp>,
22    /// Audit metadata from all agents
23    pub audit: Vec<AuditMetadata>,
24    /// Routing metadata updates
25    pub routing_metadata: HashMap<String, String>,
26    /// Whether agent needs more data to make final decision (streaming mode)
27    pub needs_more: bool,
28    /// Mutation for request body chunk (streaming mode)
29    pub request_body_mutation: Option<BodyMutation>,
30    /// Mutation for response body chunk (streaming mode)
31    pub response_body_mutation: Option<BodyMutation>,
32}
33
34/// Agent action types.
35#[derive(Debug, Clone)]
36pub enum AgentAction {
37    /// Allow request to proceed
38    Allow,
39    /// Block request
40    Block {
41        status: u16,
42        body: Option<String>,
43        headers: Option<HashMap<String, String>>,
44    },
45    /// Redirect request
46    Redirect { url: String, status: u16 },
47    /// Challenge client
48    Challenge {
49        challenge_type: String,
50        params: HashMap<String, String>,
51    },
52}
53
54impl AgentDecision {
55    /// Create default allow decision.
56    pub fn default_allow() -> Self {
57        Self {
58            action: AgentAction::Allow,
59            decided_by: None,
60            request_headers: Vec::new(),
61            response_headers: Vec::new(),
62            audit: Vec::new(),
63            routing_metadata: HashMap::new(),
64            needs_more: false,
65            request_body_mutation: None,
66            response_body_mutation: None,
67        }
68    }
69
70    /// Create block decision.
71    pub fn block(status: u16, message: &str) -> Self {
72        Self {
73            action: AgentAction::Block {
74                status,
75                body: Some(message.to_string()),
76                headers: None,
77            },
78            decided_by: None,
79            request_headers: Vec::new(),
80            response_headers: Vec::new(),
81            audit: Vec::new(),
82            routing_metadata: HashMap::new(),
83            needs_more: false,
84            request_body_mutation: None,
85            response_body_mutation: None,
86        }
87    }
88
89    /// Attribute this decision to the agent that produced it.
90    pub fn with_decided_by(mut self, agent_id: impl Into<String>) -> Self {
91        self.decided_by = Some(agent_id.into());
92        self
93    }
94
95    /// Convert an agent response into a decision attributed to `agent_id`.
96    pub fn from_response(response: AgentResponse, agent_id: &str) -> Self {
97        let mut decision: Self = response.into();
98        decision.decided_by = Some(agent_id.to_string());
99        decision
100    }
101
102    /// Check if decision is to allow.
103    pub fn is_allow(&self) -> bool {
104        matches!(self.action, AgentAction::Allow)
105    }
106
107    /// Merge another decision into this one.
108    ///
109    /// If other decision is not allow, use it as the action.
110    /// Header modifications, audit metadata, and routing metadata are merged.
111    pub fn merge(&mut self, other: AgentDecision) {
112        // If other decision is not allow, use it (and keep its attribution)
113        if !other.is_allow() {
114            self.action = other.action;
115            self.decided_by = other.decided_by;
116        }
117
118        // Merge header modifications
119        self.request_headers.extend(other.request_headers);
120        self.response_headers.extend(other.response_headers);
121
122        // Merge audit metadata
123        self.audit.extend(other.audit);
124
125        // Merge routing metadata
126        self.routing_metadata.extend(other.routing_metadata);
127
128        // Streaming: if any agent needs more, we need more
129        if other.needs_more {
130            self.needs_more = true;
131        }
132
133        // Body mutations: last one wins
134        if other.request_body_mutation.is_some() {
135            self.request_body_mutation = other.request_body_mutation;
136        }
137        if other.response_body_mutation.is_some() {
138            self.response_body_mutation = other.response_body_mutation;
139        }
140    }
141}
142
143impl From<AgentResponse> for AgentDecision {
144    fn from(response: AgentResponse) -> Self {
145        let action = match response.decision {
146            Decision::Allow => AgentAction::Allow,
147            Decision::Block {
148                status,
149                body,
150                headers,
151            } => AgentAction::Block {
152                status,
153                body,
154                headers,
155            },
156            Decision::Redirect { url, status } => AgentAction::Redirect { url, status },
157            Decision::Challenge {
158                challenge_type,
159                params,
160            } => AgentAction::Challenge {
161                challenge_type,
162                params,
163            },
164        };
165
166        Self {
167            action,
168            decided_by: None,
169            request_headers: response.request_headers,
170            response_headers: response.response_headers,
171            audit: vec![response.audit],
172            routing_metadata: response.routing_metadata,
173            needs_more: response.needs_more,
174            request_body_mutation: response.request_body_mutation,
175            response_body_mutation: response.response_body_mutation,
176        }
177    }
178}
179
180#[cfg(test)]
181mod tests {
182    use super::*;
183
184    #[test]
185    fn test_agent_decision_merge() {
186        let mut decision1 = AgentDecision::default_allow();
187        decision1.request_headers.push(HeaderOp::Set {
188            name: "X-Test".to_string(),
189            value: "1".to_string(),
190        });
191
192        let decision2 = AgentDecision::block(403, "Forbidden");
193
194        decision1.merge(decision2);
195        assert!(!decision1.is_allow());
196    }
197
198    #[test]
199    fn merge_preserves_blocking_agent_attribution() {
200        let mut combined = AgentDecision::default_allow().with_decided_by("auth");
201        // Allow decisions carry no attribution worth keeping
202        assert!(combined.is_allow());
203
204        let block = AgentDecision::block(403, "Forbidden").with_decided_by("waf");
205        combined.merge(block);
206
207        assert!(!combined.is_allow());
208        assert_eq!(combined.decided_by.as_deref(), Some("waf"));
209    }
210
211    #[test]
212    fn merge_with_allow_keeps_existing_attribution() {
213        let mut combined = AgentDecision::block(403, "Forbidden").with_decided_by("waf");
214        combined.merge(AgentDecision::default_allow());
215
216        assert_eq!(combined.decided_by.as_deref(), Some("waf"));
217    }
218
219    #[test]
220    fn from_response_sets_decided_by() {
221        let response = AgentResponse::block(403, None);
222        let decision = AgentDecision::from_response(response, "waf");
223        assert!(!decision.is_allow());
224        assert_eq!(decision.decided_by.as_deref(), Some("waf"));
225    }
226}