Skip to main content

cortex_core/
policy.rs

1//! Policy outcome lattice and deterministic composition.
2//!
3//! ADR 0026 defines one total order for allow/warn/reject/quarantine/break-glass
4//! decisions. This module is pure shape logic: subsystems register rule ids,
5//! submit rule outcomes, and receive the composed decision with explainability.
6
7use std::collections::BTreeSet;
8
9use chrono::{DateTime, Utc};
10use schemars::JsonSchema;
11use serde::{Deserialize, Serialize};
12
13use crate::claims::ClaimCeiling;
14
15/// Policy outcome total order from weakest to strongest.
16#[derive(
17    Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize, JsonSchema,
18)]
19#[serde(rename_all = "snake_case")]
20pub enum PolicyOutcome {
21    /// Proceed.
22    Allow,
23    /// Proceed with a structured warning.
24    Warn,
25    /// Proceed only under explicit break-glass scope and attestation.
26    BreakGlass,
27    /// Persist only isolated unsafe state.
28    Quarantine,
29    /// Hard stop.
30    Reject,
31}
32
33impl PolicyOutcome {
34    /// Maximum claim ceiling permitted by this policy outcome.
35    #[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/// A registered policy rule id.
45#[derive(
46    Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize, JsonSchema,
47)]
48pub struct PolicyRuleId(String);
49
50impl PolicyRuleId {
51    /// Create a policy rule id.
52    ///
53    /// Empty or whitespace-only ids are rejected because explainability output
54    /// must be machine-correlatable.
55    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    /// Borrow the rule id as a string.
64    #[must_use]
65    pub fn as_str(&self) -> &str {
66        &self.0
67    }
68}
69
70/// One rule's contribution to a composed policy decision.
71#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
72pub struct PolicyContribution {
73    /// Rule id that emitted this contribution.
74    pub rule_id: PolicyRuleId,
75    /// Outcome emitted by the rule.
76    pub outcome: PolicyOutcome,
77    /// Stable operator-facing reason.
78    pub reason: String,
79    /// Whether this rule permits a scoped break-glass override when it returns
80    /// `Reject` or `Quarantine`.
81    pub break_glass_override_allowed: bool,
82}
83
84impl PolicyContribution {
85    /// Construct a contribution.
86    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    /// Mark this contribution as eligible for explicit break-glass override.
104    #[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/// Closed break-glass reason code.
112#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, JsonSchema)]
113#[serde(rename_all = "snake_case")]
114pub enum BreakGlassReasonCode {
115    /// Incident response or containment work.
116    IncidentResponse,
117    /// Backup, restore, or disaster recovery work.
118    RestoreRecovery,
119    /// Explicit operator correction of known bad state.
120    OperatorCorrection,
121    /// Bounded migration or cutover work.
122    DataMigration,
123    /// Diagnostic-only persistence of an otherwise rejected or quarantined
124    /// artifact (ADR 0026 ยง5; `BoundaryQuarantineState::DiagnosticOnly`).
125    /// The persisted artifact MUST carry `forbidden_uses` and MUST NOT
126    /// promote to default trusted history.
127    DiagnosticOnly,
128}
129
130/// Scope bound to a break-glass action.
131#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
132pub struct BreakGlassScope {
133    /// Registered operation type.
134    pub operation_type: String,
135    /// Artifact ids or refs covered by this authorization.
136    pub artifact_refs: Vec<String>,
137    /// Optional validity start.
138    pub not_before: Option<DateTime<Utc>>,
139    /// Optional validity end.
140    pub not_after: Option<DateTime<Utc>>,
141}
142
143impl BreakGlassScope {
144    /// Whether the scope has enough information to bind an authorization.
145    #[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/// Explicit authorization for a break-glass override.
161#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
162pub struct BreakGlassAuthorization {
163    /// Whether the governing rule set permits break-glass for this operation.
164    pub permitted: bool,
165    /// Whether required attestation was verified.
166    pub attested: bool,
167    /// Scope bound to this break-glass action.
168    pub scope: BreakGlassScope,
169    /// Closed reason code.
170    pub reason_code: BreakGlassReasonCode,
171}
172
173impl BreakGlassAuthorization {
174    /// Whether this authorization may turn a reject/quarantine composition into
175    /// a break-glass decision.
176    #[must_use]
177    pub fn is_valid(&self) -> bool {
178        self.permitted && self.attested && self.scope.is_bound()
179    }
180}
181
182/// Audit shape required when break-glass is the final policy outcome.
183#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
184pub struct BreakGlassAuditShape {
185    /// Scope bound to the break-glass decision.
186    pub scope: BreakGlassScope,
187    /// Closed reason code.
188    pub reason_code: BreakGlassReasonCode,
189    /// Rule outcomes that contributed to the composed decision.
190    pub contributing_outcomes: Vec<PolicyContribution>,
191    /// Optional authorization expiry copied from scope.
192    pub expires_at: Option<DateTime<Utc>>,
193}
194
195impl BreakGlassAuditShape {
196    /// Build the mandatory audit shape from a break-glass decision.
197    #[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/// Composed policy decision with explainability.
213#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
214pub struct PolicyDecision {
215    /// Final composed outcome.
216    pub final_outcome: PolicyOutcome,
217    /// Contributions that determined the final outcome.
218    pub contributing: Vec<PolicyContribution>,
219    /// Outcomes discarded by composition.
220    pub discarded: Vec<PolicyContribution>,
221    /// Break-glass authorization used, if any.
222    pub break_glass: Option<BreakGlassAuthorization>,
223}
224
225/// Pure policy engine with registered rule ids.
226#[derive(Debug, Clone, Default, PartialEq, Eq)]
227pub struct PolicyEngine {
228    registered_rules: BTreeSet<PolicyRuleId>,
229}
230
231impl PolicyEngine {
232    /// Create an empty policy engine.
233    #[must_use]
234    pub fn new() -> Self {
235        Self::default()
236    }
237
238    /// Register a rule id.
239    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    /// Compose outcomes for one artifact or operation.
245    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/// Compose policy outcomes without a registry check.
267#[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/// Policy composition error.
320#[derive(Debug, Clone, PartialEq, Eq)]
321pub enum PolicyError {
322    /// Rule id was empty.
323    EmptyRuleId,
324    /// Contribution reason was empty.
325    EmptyReason,
326    /// No contributions were supplied.
327    NoContributions,
328    /// A contribution used an unregistered rule id.
329    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}