Skip to main content

macp_runtime/
policy_engine.rs

1//! Pluggable ingress policy engine (E3, master plan §4.6).
2//!
3//! This is the identity-aware, async authorization surface for external
4//! engines (OPA, Cedar, org-specific services). It is deliberately distinct
5//! from [`macp_core::policy::PolicyEvaluator`]:
6//!
7//! - `PolicyEvaluator` governs **commitment evaluation** and must be a pure,
8//!   deterministic function of bound rules + accepted history (RFC-MACP-0012
9//!   §6.3) — it replays.
10//! - `PolicyEngine` governs **ingress**: whether an authenticated identity may
11//!   start a session, send a message, or observe a session. Rejected traffic
12//!   never enters accepted history, so replay only ever sees engine-approved
13//!   envelopes — an async, non-deterministic external engine here cannot
14//!   diverge replay, by the same reasoning that keeps authentication outside
15//!   the replay boundary (RFC-MACP-0003).
16//!
17//! Failure semantics are **deny-on-error**: an engine that cannot answer is a
18//! denial, never an allow.
19
20use crate::security::AuthIdentity;
21use macp_core::policy::PolicyDecision;
22use macp_core::session::Session;
23use macp_pb::pb::Envelope;
24
25/// Decision points an external engine may govern at ingress.
26#[async_trait::async_trait]
27pub trait PolicyEngine: Send + Sync {
28    /// May `identity` start a session in `mode`? Runs after authentication
29    /// and the security layer's own checks, before the kernel accepts the
30    /// SessionStart.
31    async fn evaluate_session_start(
32        &self,
33        identity: &AuthIdentity,
34        mode: &str,
35        env: &Envelope,
36    ) -> PolicyDecision;
37
38    /// May `identity` send this session-scoped envelope? Runs after mode
39    /// binding is known, before kernel acceptance.
40    async fn evaluate_message(
41        &self,
42        identity: &AuthIdentity,
43        session: &Session,
44        env: &Envelope,
45    ) -> PolicyDecision;
46
47    /// May `identity` observe this session (GetSession / StreamSession
48    /// subscribe)? Purely a read gate; never replayed.
49    async fn evaluate_session_access(
50        &self,
51        identity: &AuthIdentity,
52        session: &Session,
53    ) -> PolicyDecision;
54}
55
56/// Convert an engine decision into a transport error, fail closed.
57pub fn require_allow(decision: PolicyDecision, what: &str) -> Result<(), tonic::Status> {
58    match decision {
59        PolicyDecision::Allow { .. } => Ok(()),
60        PolicyDecision::Deny { reasons } => Err(tonic::Status::permission_denied(format!(
61            "policy engine denied {what}: {}",
62            reasons.join("; ")
63        ))),
64        other => Err(tonic::Status::permission_denied(format!(
65            "policy engine returned unrecognized decision for {what} (fail closed): {other:?}"
66        ))),
67    }
68}
69
70#[cfg(test)]
71mod tests {
72    use super::*;
73    use tonic::Code;
74
75    /// Minimal inline engine returning a fixed decision at every point.
76    struct FixedEngine {
77        decision: PolicyDecision,
78    }
79
80    #[async_trait::async_trait]
81    impl PolicyEngine for FixedEngine {
82        async fn evaluate_session_start(
83            &self,
84            _identity: &AuthIdentity,
85            _mode: &str,
86            _env: &Envelope,
87        ) -> PolicyDecision {
88            self.decision.clone()
89        }
90
91        async fn evaluate_message(
92            &self,
93            _identity: &AuthIdentity,
94            _session: &Session,
95            _env: &Envelope,
96        ) -> PolicyDecision {
97            self.decision.clone()
98        }
99
100        async fn evaluate_session_access(
101            &self,
102            _identity: &AuthIdentity,
103            _session: &Session,
104        ) -> PolicyDecision {
105            self.decision.clone()
106        }
107    }
108
109    fn identity(sender: &str) -> AuthIdentity {
110        AuthIdentity {
111            sender: sender.into(),
112            allowed_modes: None,
113            can_start_sessions: true,
114            max_open_sessions: None,
115            can_manage_mode_registry: false,
116            is_observer: false,
117        }
118    }
119
120    fn session() -> Session {
121        Session::builder("s1", "macp.mode.decision.v1", "agent-a").build()
122    }
123
124    #[test]
125    fn require_allow_maps_allow_to_ok() {
126        assert!(require_allow(PolicyDecision::Allow { reasons: vec![] }, "send").is_ok());
127        // Reasons on an Allow are advisory and must not affect the outcome.
128        assert!(require_allow(
129            PolicyDecision::Allow {
130                reasons: vec!["matched rule r1".into()]
131            },
132            "session start",
133        )
134        .is_ok());
135    }
136
137    #[test]
138    fn require_allow_maps_deny_to_permission_denied_with_reasons() {
139        let err = require_allow(
140            PolicyDecision::Deny {
141                reasons: vec!["sender not on roster".into(), "mode locked".into()],
142            },
143            "session start",
144        )
145        .expect_err("deny must map to an error");
146        assert_eq!(err.code(), Code::PermissionDenied);
147        assert!(err.message().contains("policy engine denied session start"));
148        assert!(
149            err.message().contains("sender not on roster; mode locked"),
150            "deny reasons must be joined into the message: {}",
151            err.message()
152        );
153    }
154
155    #[test]
156    fn require_allow_deny_with_no_reasons_still_denies() {
157        let err = require_allow(PolicyDecision::Deny { reasons: vec![] }, "message")
158            .expect_err("deny must map to an error even without reasons");
159        assert_eq!(err.code(), Code::PermissionDenied);
160        assert!(err.message().contains("policy engine denied message"));
161    }
162
163    #[tokio::test]
164    async fn allow_engine_decisions_pass_all_ingress_points() {
165        let engine = FixedEngine {
166            decision: PolicyDecision::Allow { reasons: vec![] },
167        };
168        let id = identity("agent-a");
169        let sess = session();
170        let env = Envelope::default();
171
172        let d = engine
173            .evaluate_session_start(&id, "macp.mode.decision.v1", &env)
174            .await;
175        assert!(require_allow(d, "session start").is_ok());
176        let d = engine.evaluate_message(&id, &sess, &env).await;
177        assert!(require_allow(d, "send").is_ok());
178        let d = engine.evaluate_session_access(&id, &sess).await;
179        assert!(require_allow(d, "session access").is_ok());
180    }
181
182    #[tokio::test]
183    async fn deny_engine_decisions_fail_closed_at_all_ingress_points() {
184        let engine = FixedEngine {
185            decision: PolicyDecision::Deny {
186                reasons: vec!["external engine said no".into()],
187            },
188        };
189        let id = identity("agent-a");
190        let sess = session();
191        let env = Envelope::default();
192
193        let d = engine
194            .evaluate_session_start(&id, "macp.mode.decision.v1", &env)
195            .await;
196        let err = require_allow(d, "session start").expect_err("must deny");
197        assert_eq!(err.code(), Code::PermissionDenied);
198
199        let d = engine.evaluate_message(&id, &sess, &env).await;
200        let err = require_allow(d, "send").expect_err("must deny");
201        assert_eq!(err.code(), Code::PermissionDenied);
202        assert!(err.message().contains("external engine said no"));
203
204        let d = engine.evaluate_session_access(&id, &sess).await;
205        let err = require_allow(d, "session access").expect_err("must deny");
206        assert_eq!(err.code(), Code::PermissionDenied);
207    }
208}