1use std::collections::HashMap;
4
5use zentinel_agent_protocol::{AgentResponse, AuditMetadata, BodyMutation, Decision, HeaderOp};
6
7#[derive(Debug, Clone)]
9pub struct AgentDecision {
10 pub action: AgentAction,
12 pub decided_by: Option<String>,
18 pub request_headers: Vec<HeaderOp>,
20 pub response_headers: Vec<HeaderOp>,
22 pub audit: Vec<AuditMetadata>,
24 pub routing_metadata: HashMap<String, String>,
26 pub needs_more: bool,
28 pub request_body_mutation: Option<BodyMutation>,
30 pub response_body_mutation: Option<BodyMutation>,
32}
33
34#[derive(Debug, Clone)]
36pub enum AgentAction {
37 Allow,
39 Block {
41 status: u16,
42 body: Option<String>,
43 headers: Option<HashMap<String, String>>,
44 },
45 Redirect { url: String, status: u16 },
47 Challenge {
49 challenge_type: String,
50 params: HashMap<String, String>,
51 },
52}
53
54impl AgentDecision {
55 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 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 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 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 pub fn is_allow(&self) -> bool {
104 matches!(self.action, AgentAction::Allow)
105 }
106
107 pub fn merge(&mut self, other: AgentDecision) {
112 if !other.is_allow() {
114 self.action = other.action;
115 self.decided_by = other.decided_by;
116 }
117
118 self.request_headers.extend(other.request_headers);
120 self.response_headers.extend(other.response_headers);
121
122 self.audit.extend(other.audit);
124
125 self.routing_metadata.extend(other.routing_metadata);
127
128 if other.needs_more {
130 self.needs_more = true;
131 }
132
133 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 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}