1use std::collections::{BTreeMap, BTreeSet};
11
12use serde::{Deserialize, Serialize};
13
14use crate::identity::MeshIdentity;
15use crate::{MemOp, Namespace};
16
17#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
20pub enum PolicyDecision {
21 Allow,
22 Deny,
23 DenyMissingIdentity,
24 DenyEmptyAttestation,
25 DenyNamespaceMismatch,
26}
27
28impl PolicyDecision {
29 pub fn as_str(&self) -> &'static str {
30 match self {
31 PolicyDecision::Allow => "allow",
32 PolicyDecision::Deny => "deny",
33 PolicyDecision::DenyMissingIdentity => "deny_missing_identity",
34 PolicyDecision::DenyEmptyAttestation => "deny_empty_attestation",
35 PolicyDecision::DenyNamespaceMismatch => "deny_namespace_mismatch",
36 }
37 }
38
39 pub fn is_allow(&self) -> bool {
40 matches!(self, PolicyDecision::Allow)
41 }
42}
43
44pub trait MeshPolicyEnforcer: Send + Sync {
45 fn authorize(&self, caller: Option<&MeshIdentity>, ns: &Namespace, op: MemOp)
46 -> PolicyDecision;
47}
48
49#[derive(Debug, Default, Clone, Serialize, Deserialize)]
52pub struct StaticPolicy {
53 pub rules: BTreeMap<(String, String), BTreeSet<String>>,
54}
55
56impl StaticPolicy {
57 pub fn allow(
58 &mut self,
59 spiffe_id: impl Into<String>,
60 ns: &Namespace,
61 ops: &[MemOp],
62 ) -> &mut Self {
63 let key = (spiffe_id.into(), ns.as_label());
64 let entry = self.rules.entry(key).or_default();
65 for op in ops {
66 entry.insert(op.as_str().to_string());
67 }
68 self
69 }
70
71 pub fn permits(&self, spiffe_id: &str, ns: &Namespace, op: MemOp) -> bool {
72 self.rules
73 .get(&(spiffe_id.to_string(), ns.as_label()))
74 .map(|ops| ops.contains(op.as_str()))
75 .unwrap_or(false)
76 }
77}
78
79pub struct StaticPolicyEnforcer {
80 policy: StaticPolicy,
81}
82
83impl StaticPolicyEnforcer {
84 pub fn new(policy: StaticPolicy) -> Self {
85 Self { policy }
86 }
87
88 pub fn policy(&self) -> &StaticPolicy {
89 &self.policy
90 }
91}
92
93impl MeshPolicyEnforcer for StaticPolicyEnforcer {
94 fn authorize(
95 &self,
96 caller: Option<&MeshIdentity>,
97 ns: &Namespace,
98 op: MemOp,
99 ) -> PolicyDecision {
100 let Some(c) = caller else {
101 return PolicyDecision::DenyMissingIdentity;
102 };
103 if c.attestation.raw.is_empty() {
104 return PolicyDecision::DenyEmptyAttestation;
105 }
106 if let Some(td) = c.trust_domain()
110 && td != ns.tenant
111 && self.policy.rules.is_empty()
112 {
113 return PolicyDecision::DenyNamespaceMismatch;
114 }
115 if self.policy.permits(&c.workload_spiffe_id, ns, op) {
116 PolicyDecision::Allow
117 } else {
118 PolicyDecision::Deny
119 }
120 }
121}
122
123#[cfg(test)]
124mod tests {
125 use super::*;
126 use crate::identity::AttestationToken;
127
128 fn caller() -> MeshIdentity {
129 MeshIdentity::new(
130 "spiffe://t1/agent-1",
131 AttestationToken::new(vec![1, 2, 3], "k"),
132 )
133 }
134
135 #[test]
136 fn missing_identity_denies() {
137 let p = StaticPolicyEnforcer::new(StaticPolicy::default());
138 let d = p.authorize(None, &Namespace::new("t1", "shared"), MemOp::Recall);
139 assert_eq!(d, PolicyDecision::DenyMissingIdentity);
140 }
141
142 #[test]
143 fn empty_attestation_denies() {
144 let p = StaticPolicyEnforcer::new(StaticPolicy::default());
145 let bad = MeshIdentity::new(
146 "spiffe://t1/x",
147 AttestationToken::new(Vec::<u8>::new(), "k"),
148 );
149 let d = p.authorize(Some(&bad), &Namespace::new("t1", "shared"), MemOp::Recall);
150 assert_eq!(d, PolicyDecision::DenyEmptyAttestation);
151 }
152
153 #[test]
154 fn missing_acl_row_denies_by_default() {
155 let p = StaticPolicyEnforcer::new(StaticPolicy::default());
156 let d = p.authorize(
157 Some(&caller()),
158 &Namespace::new("t1", "shared"),
159 MemOp::Recall,
160 );
161 assert_eq!(d, PolicyDecision::Deny);
167 }
168
169 #[test]
170 fn cross_tenant_denies_with_namespace_mismatch() {
171 let p = StaticPolicyEnforcer::new(StaticPolicy::default());
172 let cross = MeshIdentity::new(
173 "spiffe://t-other/agent-1",
174 AttestationToken::new(vec![1], "k"),
175 );
176 let d = p.authorize(Some(&cross), &Namespace::new("t1", "shared"), MemOp::Recall);
177 assert_eq!(d, PolicyDecision::DenyNamespaceMismatch);
178 }
179
180 #[test]
181 fn matching_acl_row_allows() {
182 let mut policy = StaticPolicy::default();
183 let ns = Namespace::new("t1", "shared");
184 policy.allow("spiffe://t1/agent-1", &ns, &[MemOp::Recall, MemOp::Write]);
185 let p = StaticPolicyEnforcer::new(policy);
186 assert_eq!(
187 p.authorize(Some(&caller()), &ns, MemOp::Recall),
188 PolicyDecision::Allow
189 );
190 assert_eq!(
191 p.authorize(Some(&caller()), &ns, MemOp::Forget),
192 PolicyDecision::Deny
193 );
194 }
195
196 #[test]
197 fn cross_namespace_denies() {
198 let mut policy = StaticPolicy::default();
199 let allowed = Namespace::new("t1", "shared");
200 policy.allow("spiffe://t1/agent-1", &allowed, &[MemOp::Recall]);
201 let p = StaticPolicyEnforcer::new(policy);
202 assert_eq!(
204 p.authorize(
205 Some(&caller()),
206 &Namespace::new("t1", "private"),
207 MemOp::Recall
208 ),
209 PolicyDecision::Deny
210 );
211 }
212}