macp_runtime/
policy_engine.rs1use crate::security::AuthIdentity;
21use macp_core::policy::PolicyDecision;
22use macp_core::session::Session;
23use macp_pb::pb::Envelope;
24
25#[async_trait::async_trait]
27pub trait PolicyEngine: Send + Sync {
28 async fn evaluate_session_start(
32 &self,
33 identity: &AuthIdentity,
34 mode: &str,
35 env: &Envelope,
36 ) -> PolicyDecision;
37
38 async fn evaluate_message(
41 &self,
42 identity: &AuthIdentity,
43 session: &Session,
44 env: &Envelope,
45 ) -> PolicyDecision;
46
47 async fn evaluate_session_access(
50 &self,
51 identity: &AuthIdentity,
52 session: &Session,
53 ) -> PolicyDecision;
54}
55
56pub 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 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 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}