Skip to main content

ldap_acis/aci/
target_filter.rs

1//! Target filter for ACIs.
2
3use crate::entry::LdapEntry;
4
5#[cfg(feature = "serde")]
6use serde::{Deserialize, Serialize};
7
8use std::cmp::Ordering;
9
10/// Target filter for ACIs.
11///
12/// Conservatively partially ordered by "matches more entries" (wider = greater):
13/// - `All` is greatest
14/// - `And` narrows (more conjuncts = less coverage)
15/// - `Or` widens (more disjuncts = more coverage)
16/// - `Raw` is incomparable with everything (opaque)
17/// - Different leaf variants are incomparable without schema knowledge
18#[derive(Debug, Clone, PartialEq, Eq)]
19#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
20#[non_exhaustive]
21pub enum TargetFilter {
22    /// Match all entries
23    All,
24    /// Match DN pattern (supports wildcards)
25    DnPattern(String),
26    /// Match entries with specific object class
27    ObjectClass(String),
28    /// Match entries with specific attribute
29    HasAttribute(String),
30    /// Combination of filters (AND)
31    And(Vec<TargetFilter>),
32    /// Combination of filters (OR)
33    Or(Vec<TargetFilter>),
34    /// Negation filter — matches entries that do NOT match the inner filter
35    Not(Box<TargetFilter>),
36    /// Opaque LDAP filter string, passed through verbatim in generation.
37    Raw(String),
38}
39
40impl TargetFilter {
41    /// Check if an entry matches this filter.
42    pub fn matches(&self, entry: &LdapEntry) -> bool {
43        match self {
44            Self::All => true,
45            Self::DnPattern(pattern) => entry.dn_matches(pattern),
46            Self::ObjectClass(oc) => entry.has_object_class(oc),
47            Self::HasAttribute(attr) => entry.attributes.contains_key(&attr.to_lowercase()),
48            Self::And(filters) => filters.iter().all(|f| f.matches(entry)),
49            Self::Or(filters) => filters.iter().any(|f| f.matches(entry)),
50            Self::Not(inner) => !inner.matches(entry),
51            Self::Raw(_) => false,
52        }
53    }
54
55    /// Whether this filter contains a `!(memberOf=<group_dn>)` negation
56    /// that excludes members of the given group.
57    pub fn has_member_exclusion(&self, group_dn: &str) -> bool {
58        match self {
59            Self::And(filters) => filters.iter().any(|f| f.has_member_exclusion(group_dn)),
60            Self::Or(filters) => filters.iter().all(|f| f.has_member_exclusion(group_dn)),
61            Self::Not(inner) => inner.is_member_of_filter(group_dn),
62            _ => false,
63        }
64    }
65
66    /// Whether this filter contains a `Not(inner)` where `inner` matches
67    /// the given pattern (case-insensitive substring on `Raw` variants).
68    /// Generalizes `has_member_exclusion` to arbitrary filter negations,
69    /// e.g. `has_filter_exclusion("ipaPermissionType=SYSTEM")`.
70    pub fn has_filter_exclusion(&self, pattern: &str) -> bool {
71        match self {
72            Self::And(filters) => filters.iter().any(|f| f.has_filter_exclusion(pattern)),
73            Self::Or(filters) => filters.iter().all(|f| f.has_filter_exclusion(pattern)),
74            Self::Not(inner) => inner.matches_filter_pattern(pattern),
75            _ => false,
76        }
77    }
78
79    fn matches_filter_pattern(&self, pattern: &str) -> bool {
80        let pattern_lower = pattern.to_lowercase();
81        match self {
82            Self::Raw(s) => s.to_lowercase().contains(&pattern_lower),
83            Self::And(filters) | Self::Or(filters) => {
84                filters.iter().any(|f| f.matches_filter_pattern(pattern))
85            }
86            Self::ObjectClass(oc) => {
87                let check = format!("objectclass={}", oc).to_lowercase();
88                check.contains(&pattern_lower) || pattern_lower.contains(&check)
89            }
90            Self::HasAttribute(attr) => {
91                let check = format!("{}=", attr).to_lowercase();
92                pattern_lower.contains(&check)
93            }
94            _ => false,
95        }
96    }
97
98    /// Whether this filter is a `memberOf=<group_dn>` assertion.
99    fn is_member_of_filter(&self, group_dn: &str) -> bool {
100        let group_lower = group_dn.to_lowercase();
101        match self {
102            Self::Raw(s) => {
103                let s_lower = s.to_lowercase();
104                s_lower.contains("memberof=") && s_lower.contains(&group_lower)
105            }
106            Self::HasAttribute(attr) => attr.eq_ignore_ascii_case("memberof"),
107            _ => false,
108        }
109    }
110}
111
112impl PartialOrd for TargetFilter {
113    fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
114        if self == other {
115            return Some(Ordering::Equal);
116        }
117        match (self, other) {
118            (Self::All, _) => Some(Ordering::Greater),
119            (_, Self::All) => Some(Ordering::Less),
120
121            (Self::Raw(_), _) | (_, Self::Raw(_)) => None,
122            (Self::Not(_), _) | (_, Self::Not(_)) => None,
123
124            (Self::And(a), Self::And(b)) => {
125                let a_has_all_b = b.iter().all(|bi| a.contains(bi));
126                let b_has_all_a = a.iter().all(|ai| b.contains(ai));
127                match (a_has_all_b, b_has_all_a) {
128                    (true, true) => Some(Ordering::Equal),
129                    (true, false) => Some(Ordering::Less),
130                    (false, true) => Some(Ordering::Greater),
131                    (false, false) => None,
132                }
133            }
134
135            (Self::Or(a), Self::Or(b)) => {
136                let a_has_all_b = b.iter().all(|bi| a.contains(bi));
137                let b_has_all_a = a.iter().all(|ai| b.contains(ai));
138                match (a_has_all_b, b_has_all_a) {
139                    (true, true) => Some(Ordering::Equal),
140                    (true, false) => Some(Ordering::Greater),
141                    (false, true) => Some(Ordering::Less),
142                    (false, false) => None,
143                }
144            }
145
146            (Self::And(items), x) if items.iter().any(|i| i == x) => Some(Ordering::Less),
147            (x, Self::And(items)) if items.iter().any(|i| i == x) => Some(Ordering::Greater),
148
149            (Self::Or(items), x) if items.iter().any(|i| i == x) => Some(Ordering::Greater),
150            (x, Self::Or(items)) if items.iter().any(|i| i == x) => Some(Ordering::Less),
151
152            _ => None,
153        }
154    }
155}
156
157#[cfg(test)]
158mod tests {
159    use super::*;
160
161    mod target_filter_ordering {
162        use super::*;
163
164        #[test]
165        fn all_is_greatest() {
166            let filters = [
167                TargetFilter::ObjectClass("posixAccount".into()),
168                TargetFilter::DnPattern("uid=*,dc=example,dc=com".into()),
169                TargetFilter::HasAttribute("uid".into()),
170                TargetFilter::And(vec![
171                    TargetFilter::ObjectClass("posixAccount".into()),
172                    TargetFilter::HasAttribute("uid".into()),
173                ]),
174                TargetFilter::Or(vec![
175                    TargetFilter::ObjectClass("posixAccount".into()),
176                    TargetFilter::ObjectClass("inetOrgPerson".into()),
177                ]),
178            ];
179            for f in &filters {
180                assert!(TargetFilter::All > *f, "All should be greater than {:?}", f);
181            }
182        }
183
184        #[test]
185        fn reflexive() {
186            let filters = [
187                TargetFilter::All,
188                TargetFilter::ObjectClass("posixAccount".into()),
189                TargetFilter::Raw("(uid=*)".into()),
190                TargetFilter::And(vec![TargetFilter::ObjectClass("x".into())]),
191            ];
192            for f in &filters {
193                assert_eq!(f.partial_cmp(f), Some(Ordering::Equal));
194            }
195        }
196
197        #[test]
198        fn raw_is_incomparable() {
199            let raw = TargetFilter::Raw("(uid=*)".into());
200            let oc = TargetFilter::ObjectClass("posixAccount".into());
201            assert_eq!(raw.partial_cmp(&oc), None);
202            assert_eq!(oc.partial_cmp(&raw), None);
203            assert_eq!(raw.partial_cmp(&TargetFilter::All), Some(Ordering::Less));
204        }
205
206        #[test]
207        fn and_narrows() {
208            let a = TargetFilter::ObjectClass("posixAccount".into());
209            let b = TargetFilter::HasAttribute("uid".into());
210            let and = TargetFilter::And(vec![a.clone(), b.clone()]);
211            assert!(and < a, "And should be narrower than any conjunct");
212            assert!(and < b);
213        }
214
215        #[test]
216        fn or_widens() {
217            let a = TargetFilter::ObjectClass("posixAccount".into());
218            let b = TargetFilter::ObjectClass("inetOrgPerson".into());
219            let or = TargetFilter::Or(vec![a.clone(), b.clone()]);
220            assert!(or > a, "Or should be wider than any disjunct");
221            assert!(or > b);
222        }
223
224        #[test]
225        fn and_more_conjuncts_is_narrower() {
226            let ab = TargetFilter::And(vec![
227                TargetFilter::ObjectClass("a".into()),
228                TargetFilter::ObjectClass("b".into()),
229            ]);
230            let abc = TargetFilter::And(vec![
231                TargetFilter::ObjectClass("a".into()),
232                TargetFilter::ObjectClass("b".into()),
233                TargetFilter::ObjectClass("c".into()),
234            ]);
235            assert!(abc < ab, "More conjuncts = narrower = less");
236        }
237
238        #[test]
239        fn or_more_disjuncts_is_wider() {
240            let ab = TargetFilter::Or(vec![
241                TargetFilter::ObjectClass("a".into()),
242                TargetFilter::ObjectClass("b".into()),
243            ]);
244            let abc = TargetFilter::Or(vec![
245                TargetFilter::ObjectClass("a".into()),
246                TargetFilter::ObjectClass("b".into()),
247                TargetFilter::ObjectClass("c".into()),
248            ]);
249            assert!(abc > ab, "More disjuncts = wider = greater");
250        }
251
252        #[test]
253        fn different_leaves_incomparable() {
254            let oc = TargetFilter::ObjectClass("posixAccount".into());
255            let ha = TargetFilter::HasAttribute("uid".into());
256            assert_eq!(oc.partial_cmp(&ha), None);
257        }
258
259        #[test]
260        fn disjoint_ands_incomparable() {
261            let and1 = TargetFilter::And(vec![TargetFilter::ObjectClass("a".into())]);
262            let and2 = TargetFilter::And(vec![TargetFilter::ObjectClass("b".into())]);
263            assert_eq!(and1.partial_cmp(&and2), None);
264        }
265
266        #[test]
267        fn not_is_incomparable() {
268            let not = TargetFilter::Not(Box::new(TargetFilter::ObjectClass("a".into())));
269            let oc = TargetFilter::ObjectClass("b".into());
270            assert_eq!(not.partial_cmp(&oc), None);
271            assert_eq!(oc.partial_cmp(&not), None);
272            assert!(not < TargetFilter::All);
273        }
274    }
275
276    mod member_exclusion {
277        use super::*;
278
279        #[test]
280        fn and_with_not_memberof_raw() {
281            let filter = TargetFilter::And(vec![
282                TargetFilter::Not(Box::new(TargetFilter::Raw(
283                    "memberOf=cn=admins,cn=groups,cn=accounts,dc=example,dc=com".into(),
284                ))),
285                TargetFilter::ObjectClass("posixaccount".into()),
286            ]);
287            assert!(
288                filter.has_member_exclusion("cn=admins,cn=groups,cn=accounts,dc=example,dc=com")
289            );
290            assert!(
291                !filter.has_member_exclusion("cn=editors,cn=groups,cn=accounts,dc=example,dc=com")
292            );
293        }
294
295        #[test]
296        fn simple_objectclass_no_exclusion() {
297            let filter = TargetFilter::ObjectClass("posixaccount".into());
298            assert!(
299                !filter.has_member_exclusion("cn=admins,cn=groups,cn=accounts,dc=example,dc=com")
300            );
301        }
302
303        #[test]
304        fn all_no_exclusion() {
305            assert!(!TargetFilter::All.has_member_exclusion("cn=admins"));
306        }
307
308        #[test]
309        fn case_insensitive_match() {
310            let filter = TargetFilter::Not(Box::new(TargetFilter::Raw(
311                "memberOf=CN=Admins,CN=Groups,CN=Accounts,DC=Example,DC=COM".into(),
312            )));
313            assert!(
314                filter.has_member_exclusion("cn=admins,cn=groups,cn=accounts,dc=example,dc=com")
315            );
316        }
317
318        #[test]
319        fn or_requires_all_branches() {
320            let filter = TargetFilter::Or(vec![
321                TargetFilter::And(vec![
322                    TargetFilter::Not(Box::new(TargetFilter::Raw("memberOf=cn=admins".into()))),
323                    TargetFilter::ObjectClass("posixaccount".into()),
324                ]),
325                TargetFilter::ObjectClass("inetorgperson".into()),
326            ]);
327            assert!(!filter.has_member_exclusion("cn=admins"));
328        }
329
330        #[test]
331        fn or_with_exclusion_in_all_branches() {
332            let filter = TargetFilter::Or(vec![
333                TargetFilter::Not(Box::new(TargetFilter::Raw("memberOf=cn=admins".into()))),
334                TargetFilter::Not(Box::new(TargetFilter::Raw("memberOf=cn=admins".into()))),
335            ]);
336            assert!(filter.has_member_exclusion("cn=admins"));
337        }
338    }
339}