1use super::{reason_codes, Decision, DecisionOrigin, DecisionOutcomeKind, FulfillmentDecisionPath};
2use serde::{Deserialize, Serialize};
3
4pub const DENY_PRECEDENCE_VERSION_V1: &str = "wave40_v1";
5
6#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
7#[serde(rename_all = "snake_case")]
8pub enum DenyClassificationSource {
9 OutcomeKind,
10 OriginContext,
11 FulfillmentPath,
12 LegacyDecision,
13 NotDeny,
14}
15
16#[derive(Debug, Clone, Copy, PartialEq, Eq)]
17pub struct DenyConvergenceProjection {
18 pub policy_deny: bool,
19 pub fail_closed_deny: bool,
20 pub enforcement_deny: bool,
21 pub classification_source: DenyClassificationSource,
22 pub legacy_fallback_applied: bool,
23 pub deny_convergence_reason: &'static str,
24}
25
26pub fn project_deny_convergence(
27 decision_outcome_kind: Option<DecisionOutcomeKind>,
28 decision_origin: Option<DecisionOrigin>,
29 fulfillment_decision_path: Option<FulfillmentDecisionPath>,
30 decision: Decision,
31 fail_closed_applied: bool,
32 reason_code: &str,
33) -> DenyConvergenceProjection {
34 if let Some(kind) = decision_outcome_kind {
35 return match kind {
36 DecisionOutcomeKind::PolicyDeny => projection(
37 true,
38 false,
39 false,
40 DenyClassificationSource::OutcomeKind,
41 false,
42 "outcome_policy_deny",
43 ),
44 DecisionOutcomeKind::FailClosedDeny => projection(
45 false,
46 true,
47 false,
48 DenyClassificationSource::OutcomeKind,
49 false,
50 "outcome_fail_closed_deny",
51 ),
52 DecisionOutcomeKind::EnforcementDeny => projection(
53 false,
54 false,
55 true,
56 DenyClassificationSource::OutcomeKind,
57 false,
58 "outcome_enforcement_deny",
59 ),
60 DecisionOutcomeKind::ObligationApplied
61 | DecisionOutcomeKind::ObligationSkipped
62 | DecisionOutcomeKind::ObligationError => not_deny_projection(
63 DenyClassificationSource::OutcomeKind,
64 false,
65 "outcome_not_deny",
66 ),
67 };
68 }
69
70 if let Some(origin_projection) =
71 project_from_origin(decision_origin, decision, fail_closed_applied, reason_code)
72 {
73 return origin_projection;
74 }
75
76 if let Some(path_projection) = project_from_fulfillment_path(fulfillment_decision_path) {
77 return path_projection;
78 }
79
80 project_from_legacy_decision(decision, fail_closed_applied, reason_code)
81}
82
83fn projection(
84 policy_deny: bool,
85 fail_closed_deny: bool,
86 enforcement_deny: bool,
87 classification_source: DenyClassificationSource,
88 legacy_fallback_applied: bool,
89 deny_convergence_reason: &'static str,
90) -> DenyConvergenceProjection {
91 DenyConvergenceProjection {
92 policy_deny,
93 fail_closed_deny,
94 enforcement_deny,
95 classification_source,
96 legacy_fallback_applied,
97 deny_convergence_reason,
98 }
99}
100
101fn not_deny_projection(
102 source: DenyClassificationSource,
103 fallback: bool,
104 reason: &'static str,
105) -> DenyConvergenceProjection {
106 projection(false, false, false, source, fallback, reason)
107}
108
109fn project_from_origin(
110 decision_origin: Option<DecisionOrigin>,
111 decision: Decision,
112 fail_closed_applied: bool,
113 reason_code: &str,
114) -> Option<DenyConvergenceProjection> {
115 let origin = decision_origin?;
116 match origin {
117 DecisionOrigin::FailClosedMatrix => Some(projection(
118 false,
119 true,
120 false,
121 DenyClassificationSource::OriginContext,
122 true,
123 "origin_fail_closed_matrix",
124 )),
125 DecisionOrigin::RuntimeEnforcement => Some(projection(
126 false,
127 false,
128 true,
129 DenyClassificationSource::OriginContext,
130 true,
131 "origin_runtime_enforcement",
132 )),
133 DecisionOrigin::PolicyEngine => match decision {
134 Decision::Deny => Some(projection(
135 true,
136 false,
137 false,
138 DenyClassificationSource::OriginContext,
139 true,
140 "origin_policy_engine_deny",
141 )),
142 Decision::Error => Some(projection(
143 false,
144 false,
145 true,
146 DenyClassificationSource::OriginContext,
147 true,
148 "origin_policy_engine_error",
149 )),
150 Decision::Allow => Some(not_deny_projection(
151 DenyClassificationSource::OriginContext,
152 true,
153 "origin_policy_engine_allow",
154 )),
155 },
156 DecisionOrigin::ObligationExecutor => Some(project_from_legacy_decision(
157 decision,
158 fail_closed_applied,
159 reason_code,
160 )),
161 }
162}
163
164fn project_from_fulfillment_path(
165 fulfillment_decision_path: Option<FulfillmentDecisionPath>,
166) -> Option<DenyConvergenceProjection> {
167 let path = fulfillment_decision_path?;
168 match path {
169 FulfillmentDecisionPath::PolicyDeny => Some(projection(
170 true,
171 false,
172 false,
173 DenyClassificationSource::FulfillmentPath,
174 true,
175 "fulfillment_policy_deny",
176 )),
177 FulfillmentDecisionPath::FailClosedDeny => Some(projection(
178 false,
179 true,
180 false,
181 DenyClassificationSource::FulfillmentPath,
182 true,
183 "fulfillment_fail_closed_deny",
184 )),
185 FulfillmentDecisionPath::DecisionError => Some(projection(
186 false,
187 false,
188 true,
189 DenyClassificationSource::FulfillmentPath,
190 true,
191 "fulfillment_decision_error",
192 )),
193 FulfillmentDecisionPath::PolicyAllow => Some(not_deny_projection(
194 DenyClassificationSource::FulfillmentPath,
195 true,
196 "fulfillment_policy_allow",
197 )),
198 }
199}
200
201fn project_from_legacy_decision(
202 decision: Decision,
203 fail_closed_applied: bool,
204 reason_code: &str,
205) -> DenyConvergenceProjection {
206 match decision {
207 Decision::Deny => {
208 if fail_closed_applied {
209 projection(
210 false,
211 true,
212 false,
213 DenyClassificationSource::LegacyDecision,
214 true,
215 "legacy_fail_closed_deny",
216 )
217 } else if is_enforcement_deny_reason(reason_code) {
218 projection(
219 false,
220 false,
221 true,
222 DenyClassificationSource::LegacyDecision,
223 true,
224 "legacy_enforcement_deny",
225 )
226 } else {
227 projection(
228 true,
229 false,
230 false,
231 DenyClassificationSource::LegacyDecision,
232 true,
233 "legacy_policy_deny",
234 )
235 }
236 }
237 Decision::Error => projection(
238 false,
239 false,
240 true,
241 DenyClassificationSource::LegacyDecision,
242 true,
243 "legacy_decision_error",
244 ),
245 Decision::Allow => not_deny_projection(
246 DenyClassificationSource::NotDeny,
247 true,
248 "legacy_decision_allow",
249 ),
250 }
251}
252
253fn is_enforcement_deny_reason(reason_code: &str) -> bool {
254 reason_code.starts_with("M_")
255 || matches!(
256 reason_code,
257 reason_codes::P_APPROVAL_REQUIRED
258 | reason_codes::P_RESTRICT_SCOPE
259 | reason_codes::P_REDACT_ARGS
260 | reason_codes::P_MANDATE_REQUIRED
261 )
262}
263
264#[cfg(test)]
265mod tests {
266 use super::*;
267
268 #[test]
269 fn prefers_outcome_kind_for_policy_deny() {
270 let projection = project_deny_convergence(
271 Some(DecisionOutcomeKind::PolicyDeny),
272 Some(DecisionOrigin::PolicyEngine),
273 Some(FulfillmentDecisionPath::PolicyDeny),
274 Decision::Deny,
275 false,
276 reason_codes::P_POLICY_DENY,
277 );
278 assert!(projection.policy_deny);
279 assert!(!projection.fail_closed_deny);
280 assert!(!projection.enforcement_deny);
281 assert_eq!(
282 projection.classification_source,
283 DenyClassificationSource::OutcomeKind
284 );
285 assert!(!projection.legacy_fallback_applied);
286 assert_eq!(projection.deny_convergence_reason, "outcome_policy_deny");
287 }
288
289 #[test]
290 fn falls_back_to_origin_context() {
291 let projection = project_deny_convergence(
292 None,
293 Some(DecisionOrigin::FailClosedMatrix),
294 Some(FulfillmentDecisionPath::PolicyDeny),
295 Decision::Deny,
296 true,
297 reason_codes::S_DB_ERROR,
298 );
299 assert!(!projection.policy_deny);
300 assert!(projection.fail_closed_deny);
301 assert!(!projection.enforcement_deny);
302 assert_eq!(
303 projection.classification_source,
304 DenyClassificationSource::OriginContext
305 );
306 assert!(projection.legacy_fallback_applied);
307 assert_eq!(
308 projection.deny_convergence_reason,
309 "origin_fail_closed_matrix"
310 );
311 }
312
313 #[test]
314 fn falls_back_to_fulfillment_path() {
315 let projection = project_deny_convergence(
316 None,
317 None,
318 Some(FulfillmentDecisionPath::DecisionError),
319 Decision::Error,
320 false,
321 reason_codes::S_INTERNAL_ERROR,
322 );
323 assert!(!projection.policy_deny);
324 assert!(!projection.fail_closed_deny);
325 assert!(projection.enforcement_deny);
326 assert_eq!(
327 projection.classification_source,
328 DenyClassificationSource::FulfillmentPath
329 );
330 assert!(projection.legacy_fallback_applied);
331 assert_eq!(
332 projection.deny_convergence_reason,
333 "fulfillment_decision_error"
334 );
335 }
336
337 #[test]
338 fn falls_back_to_legacy_decision() {
339 let projection = project_deny_convergence(
340 None,
341 None,
342 None,
343 Decision::Deny,
344 false,
345 reason_codes::P_POLICY_DENY,
346 );
347 assert!(projection.policy_deny);
348 assert!(!projection.fail_closed_deny);
349 assert!(!projection.enforcement_deny);
350 assert_eq!(
351 projection.classification_source,
352 DenyClassificationSource::LegacyDecision
353 );
354 assert!(projection.legacy_fallback_applied);
355 assert_eq!(projection.deny_convergence_reason, "legacy_policy_deny");
356 }
357}