Skip to main content

krabka_replicator/
residency.rs

1//! Deny-wins residency gate. A topic-flow is permitted unless a matching policy
2//! denies it. The semantics mirror Krabka's ACL authorizer: DENY beats ALLOW.
3//! An `allow_zones` list means "only these zones are allowed", which is
4//! deny-by-default for the matched topic.
5
6use crate::{
7    config::{PolicyConfig, Residency},
8    error::ReplicatorError,
9    selector::Selector,
10};
11
12struct Rule {
13    topics: Selector,
14    residency: Residency,
15}
16
17/// Deny-wins residency gate compiled from a slice of [`PolicyConfig`]s.
18pub struct ResidencyGate {
19    rules: Vec<Rule>,
20}
21
22impl ResidencyGate {
23    /// Compile a [`ResidencyGate`] from a slice of policies.
24    ///
25    /// Only policies that carry a [`Residency`] constraint contribute rules. The
26    /// function silently skips a policy without one.
27    ///
28    /// # Errors
29    ///
30    /// Returns [`ReplicatorError::Config`] if any topic-glob pattern is invalid.
31    pub fn compile(policies: &[PolicyConfig]) -> Result<Self, ReplicatorError> {
32        let mut rules = Vec::new();
33        for p in policies {
34            if let Some(res) = &p.residency {
35                rules.push(Rule {
36                    topics: Selector::compile(&p.topics, &[])?,
37                    residency: res.clone(),
38                });
39            }
40        }
41        Ok(Self { rules })
42    }
43
44    /// May `topic` replicate to a target whose compliance zones are `target_zones`?
45    ///
46    /// Returns `true`, which means permitted, unless a matching rule blocks the
47    /// flow:
48    /// - A zone in `deny_zones` always blocks (DENY beats ALLOW).
49    /// - A non-empty `allow_zones` list blocks if the target has none of those zones.
50    /// - An unmatched topic is always permitted.
51    #[must_use]
52    pub fn permits(&self, topic: &str, target_zones: &[String]) -> bool {
53        for rule in &self.rules {
54            if !rule.topics.matches(topic) {
55                continue;
56            }
57            // DENY beats ALLOW.
58            if rule
59                .residency
60                .deny_zones
61                .iter()
62                .any(|z| target_zones.contains(z))
63            {
64                return false;
65            }
66            // allow_zones is a whitelist: if non-empty, the target must have at least one.
67            if !rule.residency.allow_zones.is_empty()
68                && !rule
69                    .residency
70                    .allow_zones
71                    .iter()
72                    .any(|z| target_zones.contains(z))
73            {
74                return false;
75            }
76        }
77        true
78    }
79}
80
81#[cfg(test)]
82mod tests {
83
84    use super::*;
85    use crate::config::{PolicyConfig, Residency};
86
87    fn policies() -> Vec<PolicyConfig> {
88        vec![PolicyConfig {
89            name: "keep-pii-in-eu".into(),
90            topics: vec!["customers".into(), "kyc.*".into()],
91            residency: Some(Residency {
92                allow_zones: vec!["gdpr".into()],
93                deny_zones: vec![],
94            }),
95        }]
96    }
97
98    #[test]
99    fn allows_when_target_zone_in_allow_list() {
100        let gate = ResidencyGate::compile(&policies()).unwrap();
101        assert2::assert!(gate.permits("customers", &["eu".into(), "gdpr".into()]));
102    }
103
104    #[test]
105    fn blocks_when_target_lacks_allowed_zone() {
106        let gate = ResidencyGate::compile(&policies()).unwrap();
107        assert2::assert!(!gate.permits("customers", &["us".into()]));
108        assert2::assert!(!gate.permits("kyc.eu", &["us".into()]));
109    }
110
111    #[test]
112    fn deny_wins_over_allow() {
113        let mut p = policies();
114        p[0].residency = Some(Residency {
115            allow_zones: vec!["gdpr".into()],
116            deny_zones: vec!["gdpr".into()],
117        });
118        let gate = ResidencyGate::compile(&p).unwrap();
119        assert2::assert!(!gate.permits("customers", &["gdpr".into()]));
120    }
121
122    #[test]
123    fn unmatched_topic_is_permitted() {
124        let gate = ResidencyGate::compile(&policies()).unwrap();
125        assert2::assert!(gate.permits("orders", &["us".into()]));
126    }
127}