Skip to main content

arbiter/
flow.rs

1//! Cedar implementation of the neutral flow gate contract from `converge-core`.
2
3use crate::{
4    ContextIn, DecideRequest, PolicyEngine, PrincipalIn, ResourceIn,
5    decision::{PolicyDecision, PolicyOutcome},
6    engine::EngineError,
7};
8use converge_core::{
9    FlowGateAuthorizer, FlowGateDecision, FlowGateError, FlowGateInput, FlowGateOutcome,
10};
11
12pub use converge_core::{FlowGateContext, FlowGatePrincipal, FlowGateResource};
13
14fn to_decide_request(input: &FlowGateInput) -> DecideRequest {
15    DecideRequest {
16        principal: PrincipalIn {
17            id: input.principal.id.clone(),
18            authority: input.principal.authority,
19            domains: input.principal.domains.clone(),
20            policy_version: input.principal.policy_version.clone(),
21        },
22        resource: ResourceIn {
23            id: input.resource.id.clone(),
24            resource_type: Some(input.resource.kind.clone()),
25            phase: Some(input.resource.phase),
26            gates_passed: Some(input.resource.gates_passed.clone()),
27        },
28        action: input.action,
29        context: Some(ContextIn {
30            commitment_type: input
31                .context
32                .commitment_type
33                .clone()
34                .or_else(|| Some(input.resource.kind.clone().into())),
35            amount: input.context.amount,
36            human_approval_present: input.context.human_approval_present,
37            required_gates_met: input.context.required_gates_met,
38        }),
39        delegation_b64: None,
40    }
41}
42
43impl PolicyEngine {
44    /// Evaluate a policy decision from the canonical flow-facing input.
45    ///
46    /// This is the preferred path for converging flows and application runtimes.
47    pub fn evaluate_flow(&self, input: &FlowGateInput) -> Result<PolicyDecision, EngineError> {
48        self.evaluate(&to_decide_request(input))
49    }
50}
51
52impl FlowGateAuthorizer for PolicyEngine {
53    fn decide(&self, input: &FlowGateInput) -> Result<FlowGateDecision, FlowGateError> {
54        let decision = self
55            .evaluate_flow(input)
56            .map_err(|err| FlowGateError::Authorizer(err.to_string()))?;
57
58        let outcome = match decision.outcome {
59            PolicyOutcome::Promote => FlowGateOutcome::Promote,
60            PolicyOutcome::Reject => FlowGateOutcome::Reject,
61            PolicyOutcome::Escalate => FlowGateOutcome::Escalate,
62        };
63
64        Ok(FlowGateDecision {
65            outcome,
66            reason: decision.reason,
67            source: Some("cedar".into()),
68        })
69    }
70}
71
72#[cfg(test)]
73mod tests {
74    use super::*;
75    use converge_core::FlowAction;
76
77    #[test]
78    fn flow_gate_input_projects_to_decide_request() {
79        let input = FlowGateInput {
80            principal: converge_core::FlowGatePrincipal {
81                id: "agent:finance".into(),
82                authority: converge_core::AuthorityLevel::Supervisory,
83                domains: vec!["finance".into()],
84                policy_version: Some("expense_v1".into()),
85            },
86            resource: converge_core::FlowGateResource {
87                id: "expense:001".into(),
88                kind: "expense".into(),
89                phase: converge_core::FlowPhase::Commitment,
90                gates_passed: vec!["receipt".into()],
91            },
92            action: FlowAction::Validate,
93            context: converge_core::FlowGateContext {
94                commitment_type: None,
95                amount: Some(1_250),
96                human_approval_present: Some(false),
97                required_gates_met: Some(true),
98            },
99        };
100
101        let request = to_decide_request(&input);
102        assert_eq!(request.action, FlowAction::Validate);
103        assert_eq!(request.principal.domains, vec!["finance"]);
104        assert_eq!(request.resource.gates_passed, Some(vec!["receipt".into()]));
105        assert_eq!(
106            request
107                .context
108                .as_ref()
109                .and_then(|ctx| ctx.commitment_type.as_deref()),
110            Some("expense")
111        );
112    }
113
114    #[test]
115    fn policy_engine_implements_neutral_authorizer_contract() {
116        let engine = PolicyEngine::from_policy_str(crate::EXPENSE_APPROVAL_POLICY)
117            .expect("policy should parse");
118
119        let input = FlowGateInput {
120            principal: converge_core::FlowGatePrincipal {
121                id: "agent:finance".into(),
122                authority: converge_core::AuthorityLevel::Supervisory,
123                domains: vec!["finance".into()],
124                policy_version: Some("expense_v1".into()),
125            },
126            resource: converge_core::FlowGateResource {
127                id: "expense:001".into(),
128                kind: "expense".into(),
129                phase: converge_core::FlowPhase::Commitment,
130                gates_passed: vec!["receipt".into()],
131            },
132            action: FlowAction::Validate,
133            context: converge_core::FlowGateContext {
134                commitment_type: Some("expense".into()),
135                amount: Some(1_250),
136                human_approval_present: Some(false),
137                required_gates_met: Some(true),
138            },
139        };
140
141        let decision = engine.decide(&input).expect("authorizer should succeed");
142        assert_eq!(decision.outcome, FlowGateOutcome::Promote);
143        assert_eq!(decision.source.as_deref(), Some("cedar"));
144    }
145}