1use std::collections::BTreeSet;
8
9use chrono::{DateTime, Utc};
10use schemars::JsonSchema;
11use serde::{Deserialize, Serialize};
12
13use crate::claims::ClaimCeiling;
14
15#[derive(
17 Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize, JsonSchema,
18)]
19#[serde(rename_all = "snake_case")]
20pub enum PolicyOutcome {
21 Allow,
23 Warn,
25 BreakGlass,
27 Quarantine,
29 Reject,
31}
32
33impl PolicyOutcome {
34 #[must_use]
36 pub const fn claim_ceiling(self) -> ClaimCeiling {
37 match self {
38 Self::Allow | Self::Warn | Self::BreakGlass => ClaimCeiling::AuthorityGrade,
39 Self::Quarantine | Self::Reject => ClaimCeiling::DevOnly,
40 }
41 }
42}
43
44#[derive(
46 Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize, JsonSchema,
47)]
48pub struct PolicyRuleId(String);
49
50impl PolicyRuleId {
51 pub fn new(value: impl Into<String>) -> Result<Self, PolicyError> {
56 let value = value.into();
57 if value.trim().is_empty() {
58 return Err(PolicyError::EmptyRuleId);
59 }
60 Ok(Self(value))
61 }
62
63 #[must_use]
65 pub fn as_str(&self) -> &str {
66 &self.0
67 }
68}
69
70#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
72pub struct PolicyContribution {
73 pub rule_id: PolicyRuleId,
75 pub outcome: PolicyOutcome,
77 pub reason: String,
79 pub break_glass_override_allowed: bool,
82}
83
84impl PolicyContribution {
85 pub fn new(
87 rule_id: impl Into<String>,
88 outcome: PolicyOutcome,
89 reason: impl Into<String>,
90 ) -> Result<Self, PolicyError> {
91 let reason = reason.into();
92 if reason.trim().is_empty() {
93 return Err(PolicyError::EmptyReason);
94 }
95 Ok(Self {
96 rule_id: PolicyRuleId::new(rule_id)?,
97 outcome,
98 reason,
99 break_glass_override_allowed: false,
100 })
101 }
102
103 #[must_use]
105 pub const fn allow_break_glass_override(mut self) -> Self {
106 self.break_glass_override_allowed = true;
107 self
108 }
109}
110
111#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, JsonSchema)]
113#[serde(rename_all = "snake_case")]
114pub enum BreakGlassReasonCode {
115 IncidentResponse,
117 RestoreRecovery,
119 OperatorCorrection,
121 DataMigration,
123 DiagnosticOnly,
128}
129
130#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
132pub struct BreakGlassScope {
133 pub operation_type: String,
135 pub artifact_refs: Vec<String>,
137 pub not_before: Option<DateTime<Utc>>,
139 pub not_after: Option<DateTime<Utc>>,
141}
142
143impl BreakGlassScope {
144 #[must_use]
146 pub fn is_bound(&self) -> bool {
147 !self.operation_type.trim().is_empty()
148 && !self.artifact_refs.is_empty()
149 && self
150 .artifact_refs
151 .iter()
152 .all(|artifact_ref| !artifact_ref.trim().is_empty())
153 && self
154 .not_before
155 .zip(self.not_after)
156 .is_none_or(|(not_before, not_after)| not_before <= not_after)
157 }
158}
159
160#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
162pub struct BreakGlassAuthorization {
163 pub permitted: bool,
165 pub attested: bool,
167 pub scope: BreakGlassScope,
169 pub reason_code: BreakGlassReasonCode,
171}
172
173impl BreakGlassAuthorization {
174 #[must_use]
177 pub fn is_valid(&self) -> bool {
178 self.permitted && self.attested && self.scope.is_bound()
179 }
180}
181
182#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
184pub struct BreakGlassAuditShape {
185 pub scope: BreakGlassScope,
187 pub reason_code: BreakGlassReasonCode,
189 pub contributing_outcomes: Vec<PolicyContribution>,
191 pub expires_at: Option<DateTime<Utc>>,
193}
194
195impl BreakGlassAuditShape {
196 #[must_use]
198 pub fn from_decision(decision: &PolicyDecision) -> Option<Self> {
199 let authorization = decision.break_glass.as_ref()?;
200 if decision.final_outcome != PolicyOutcome::BreakGlass || !authorization.is_valid() {
201 return None;
202 }
203 Some(Self {
204 scope: authorization.scope.clone(),
205 reason_code: authorization.reason_code,
206 contributing_outcomes: decision.contributing.clone(),
207 expires_at: authorization.scope.not_after,
208 })
209 }
210}
211
212#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
214pub struct PolicyDecision {
215 pub final_outcome: PolicyOutcome,
217 pub contributing: Vec<PolicyContribution>,
219 pub discarded: Vec<PolicyContribution>,
221 pub break_glass: Option<BreakGlassAuthorization>,
223}
224
225#[derive(Debug, Clone, Default, PartialEq, Eq)]
227pub struct PolicyEngine {
228 registered_rules: BTreeSet<PolicyRuleId>,
229}
230
231impl PolicyEngine {
232 #[must_use]
234 pub fn new() -> Self {
235 Self::default()
236 }
237
238 pub fn register_rule(&mut self, rule_id: impl Into<String>) -> Result<(), PolicyError> {
240 self.registered_rules.insert(PolicyRuleId::new(rule_id)?);
241 Ok(())
242 }
243
244 pub fn compose(
246 &self,
247 contributions: Vec<PolicyContribution>,
248 break_glass: Option<BreakGlassAuthorization>,
249 ) -> Result<PolicyDecision, PolicyError> {
250 if contributions.is_empty() {
251 return Err(PolicyError::NoContributions);
252 }
253
254 for contribution in &contributions {
255 if !self.registered_rules.contains(&contribution.rule_id) {
256 return Err(PolicyError::UnregisteredRule(
257 contribution.rule_id.as_str().to_string(),
258 ));
259 }
260 }
261
262 Ok(compose_policy_outcomes(contributions, break_glass))
263 }
264}
265
266#[must_use]
268pub fn compose_policy_outcomes(
269 mut contributions: Vec<PolicyContribution>,
270 break_glass: Option<BreakGlassAuthorization>,
271) -> PolicyDecision {
272 contributions.sort_by(|a, b| {
273 b.outcome
274 .cmp(&a.outcome)
275 .then_with(|| a.rule_id.cmp(&b.rule_id))
276 });
277
278 let strongest = contributions
279 .first()
280 .map(|contribution| contribution.outcome)
281 .unwrap_or(PolicyOutcome::Allow);
282 let has_break_glass = contributions
283 .iter()
284 .any(|contribution| contribution.outcome == PolicyOutcome::BreakGlass);
285 let every_blocking_rule_allows_break_glass = contributions
286 .iter()
287 .filter(|contribution| {
288 matches!(
289 contribution.outcome,
290 PolicyOutcome::Reject | PolicyOutcome::Quarantine
291 )
292 })
293 .all(|contribution| contribution.break_glass_override_allowed);
294 let break_glass_valid = break_glass
295 .as_ref()
296 .is_some_and(BreakGlassAuthorization::is_valid);
297 let final_outcome = if matches!(strongest, PolicyOutcome::Reject | PolicyOutcome::Quarantine)
298 && has_break_glass
299 && every_blocking_rule_allows_break_glass
300 && break_glass_valid
301 {
302 PolicyOutcome::BreakGlass
303 } else {
304 strongest
305 };
306
307 let (contributing, discarded) = contributions
308 .into_iter()
309 .partition(|contribution| contribution.outcome == final_outcome);
310
311 PolicyDecision {
312 final_outcome,
313 contributing,
314 discarded,
315 break_glass: break_glass.filter(BreakGlassAuthorization::is_valid),
316 }
317}
318
319#[derive(Debug, Clone, PartialEq, Eq)]
321pub enum PolicyError {
322 EmptyRuleId,
324 EmptyReason,
326 NoContributions,
328 UnregisteredRule(String),
330}
331
332#[cfg(test)]
333mod tests {
334 use super::*;
335
336 fn contribution(rule_id: &str, outcome: PolicyOutcome) -> PolicyContribution {
337 PolicyContribution::new(rule_id, outcome, format!("{rule_id} reason")).unwrap()
338 }
339
340 fn break_glassable_contribution(rule_id: &str, outcome: PolicyOutcome) -> PolicyContribution {
341 contribution(rule_id, outcome).allow_break_glass_override()
342 }
343
344 fn break_glass_scope() -> BreakGlassScope {
345 BreakGlassScope {
346 operation_type: "memory.override".into(),
347 artifact_refs: vec!["mem_01".into()],
348 not_before: None,
349 not_after: None,
350 }
351 }
352
353 #[test]
354 fn policy_outcome_order_matches_adr_0026() {
355 assert!(PolicyOutcome::Reject > PolicyOutcome::Quarantine);
356 assert!(PolicyOutcome::Quarantine > PolicyOutcome::BreakGlass);
357 assert!(PolicyOutcome::BreakGlass > PolicyOutcome::Warn);
358 assert!(PolicyOutcome::Warn > PolicyOutcome::Allow);
359 }
360
361 #[test]
362 fn reject_and_quarantine_cap_claims_to_dev_only() {
363 assert_eq!(PolicyOutcome::Reject.claim_ceiling(), ClaimCeiling::DevOnly);
364 assert_eq!(
365 PolicyOutcome::Quarantine.claim_ceiling(),
366 ClaimCeiling::DevOnly
367 );
368 assert_eq!(
369 PolicyOutcome::BreakGlass.claim_ceiling(),
370 ClaimCeiling::AuthorityGrade
371 );
372 }
373
374 #[test]
375 fn policy_engine_rejects_unregistered_rules() {
376 let engine = PolicyEngine::new();
377 let err = engine
378 .compose(
379 vec![contribution("memory.closure", PolicyOutcome::Reject)],
380 None,
381 )
382 .unwrap_err();
383
384 assert_eq!(err, PolicyError::UnregisteredRule("memory.closure".into()));
385 }
386
387 #[test]
388 fn strongest_outcome_wins_with_deterministic_explainability() {
389 let mut engine = PolicyEngine::new();
390 engine.register_rule("pack.strict").unwrap();
391 engine.register_rule("memory.closure").unwrap();
392 engine.register_rule("tier.warn").unwrap();
393
394 let decision = engine
395 .compose(
396 vec![
397 contribution("tier.warn", PolicyOutcome::Warn),
398 contribution("pack.strict", PolicyOutcome::Reject),
399 contribution("memory.closure", PolicyOutcome::Reject),
400 ],
401 None,
402 )
403 .unwrap();
404
405 assert_eq!(decision.final_outcome, PolicyOutcome::Reject);
406 assert_eq!(decision.contributing.len(), 2);
407 assert_eq!(decision.contributing[0].rule_id.as_str(), "memory.closure");
408 assert_eq!(decision.contributing[1].rule_id.as_str(), "pack.strict");
409 assert_eq!(decision.discarded[0].outcome, PolicyOutcome::Warn);
410 }
411
412 #[test]
413 fn break_glass_cannot_override_without_attestation() {
414 let decision = compose_policy_outcomes(
415 vec![
416 contribution("memory.closure", PolicyOutcome::Reject),
417 contribution("operator.override", PolicyOutcome::BreakGlass),
418 ],
419 Some(BreakGlassAuthorization {
420 permitted: true,
421 attested: false,
422 scope: break_glass_scope(),
423 reason_code: BreakGlassReasonCode::OperatorCorrection,
424 }),
425 );
426
427 assert_eq!(decision.final_outcome, PolicyOutcome::Reject);
428 assert!(decision.break_glass.is_none());
429 }
430
431 #[test]
432 fn valid_break_glass_can_be_final_when_explicitly_authorized() {
433 let decision = compose_policy_outcomes(
434 vec![
435 break_glassable_contribution("memory.closure", PolicyOutcome::Quarantine),
436 contribution("operator.override", PolicyOutcome::BreakGlass),
437 ],
438 Some(BreakGlassAuthorization {
439 permitted: true,
440 attested: true,
441 scope: break_glass_scope(),
442 reason_code: BreakGlassReasonCode::IncidentResponse,
443 }),
444 );
445
446 assert_eq!(decision.final_outcome, PolicyOutcome::BreakGlass);
447 assert_eq!(decision.contributing.len(), 1);
448 assert_eq!(decision.contributing[0].outcome, PolicyOutcome::BreakGlass);
449 assert!(decision.break_glass.is_some());
450 let audit_shape = BreakGlassAuditShape::from_decision(&decision).unwrap();
451 assert_eq!(
452 audit_shape.reason_code,
453 BreakGlassReasonCode::IncidentResponse
454 );
455 assert_eq!(audit_shape.scope.operation_type, "memory.override");
456 }
457
458 #[test]
459 fn break_glass_cannot_bypass_non_overridable_reject() {
460 for rule_id in [
461 "actor.attestation.0010",
462 "canonical.hash.0022",
463 "trust.tier.0019",
464 ] {
465 let decision = compose_policy_outcomes(
466 vec![
467 contribution(rule_id, PolicyOutcome::Reject),
468 break_glassable_contribution("operator.override", PolicyOutcome::BreakGlass),
469 ],
470 Some(BreakGlassAuthorization {
471 permitted: true,
472 attested: true,
473 scope: break_glass_scope(),
474 reason_code: BreakGlassReasonCode::IncidentResponse,
475 }),
476 );
477
478 assert_eq!(
479 decision.final_outcome,
480 PolicyOutcome::Reject,
481 "{rule_id} must remain non-overridable"
482 );
483 assert!(decision.break_glass.is_some());
484 assert!(BreakGlassAuditShape::from_decision(&decision).is_none());
485 }
486 }
487
488 #[test]
489 fn break_glass_scope_requires_operation_and_artifact_refs() {
490 let mut scope = break_glass_scope();
491 assert!(scope.is_bound());
492
493 scope.artifact_refs.clear();
494 assert!(!scope.is_bound());
495
496 scope.artifact_refs.push("mem_01".into());
497 scope.operation_type.clear();
498 assert!(!scope.is_bound());
499 }
500}