Skip to main content

mnemo_mesh/
policy.rs

1//! Per-namespace ACL enforcement (v0.4.0 P0-2).
2//!
3//! `MeshPolicyEnforcer` is the trait Mnemo's hardened mode calls
4//! before every privileged op. The default impl
5//! [`StaticPolicyEnforcer`] reads from a static map operators ship in
6//! the manifest; production deployments are expected to swap in an
7//! HTTP- or gRPC-backed enforcer that talks to the Mesh control
8//! plane's authorization service.
9
10use std::collections::{BTreeMap, BTreeSet};
11
12use serde::{Deserialize, Serialize};
13
14use crate::identity::MeshIdentity;
15use crate::{MemOp, Namespace};
16
17/// Outcome of an authorization check. Mirrors the shape of common
18/// Mesh decision objects: allow / explicit-deny / missing-token.
19#[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/// One ACL row keyed by `(SPIFFE ID, Namespace)` granting a set of
50/// `MemOp`s. Anything not enumerated denies by default.
51#[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        // Trust-domain enforcement: refuse if the SPIFFE trust domain
107        // doesn't match the namespace tenant. Cheap defense against
108        // cross-tenant token replay.
109        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        // Caller's SPIFFE trust-domain matches the namespace tenant
162        // (`t1`), so the cross-tenant guard does not fire; with an
163        // empty static policy the row lookup misses and we get the
164        // generic `Deny`. The cross-tenant case is exercised separately
165        // in `cross_tenant_denies_with_namespace_mismatch`.
166        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        // Same tenant, different scope → no rule → Deny.
203        assert_eq!(
204            p.authorize(
205                Some(&caller()),
206                &Namespace::new("t1", "private"),
207                MemOp::Recall
208            ),
209            PolicyDecision::Deny
210        );
211    }
212}