Migration from old pyutilz package name (which, in turn, came from
[pyutils.git] / src / pyutils / security / acl.py
1 #!/usr/bin/env python3
2
3 # © Copyright 2021-2022, Scott Gasch
4
5 """This module defines various flavors of Access Control Lists."""
6
7 import enum
8 import fnmatch
9 import logging
10 import re
11 from abc import ABC, abstractmethod
12 from typing import Any, Callable, List, Optional, Sequence, Set
13
14 from overrides import overrides
15
16 # This module is commonly used by others in here and should avoid
17 # taking any unnecessary dependencies back on them.
18
19 logger = logging.getLogger(__name__)
20
21
22 class Order(enum.Enum):
23     """A helper to express the order of evaluation for allows/denies
24     in an Access Control List.
25     """
26
27     UNDEFINED = 0
28     ALLOW_DENY = 1
29     DENY_ALLOW = 2
30
31
32 class SimpleACL(ABC):
33     """A simple Access Control List interface."""
34
35     def __init__(self, *, order_to_check_allow_deny: Order, default_answer: bool):
36         if order_to_check_allow_deny not in (
37             Order.ALLOW_DENY,
38             Order.DENY_ALLOW,
39         ):
40             raise Exception(
41                 'order_to_check_allow_deny must be Order.ALLOW_DENY or '
42                 + 'Order.DENY_ALLOW'
43             )
44         self.order_to_check_allow_deny = order_to_check_allow_deny
45         self.default_answer = default_answer
46
47     def __call__(self, x: Any) -> bool:
48         """Returns True if x is allowed, False otherwise."""
49         logger.debug('SimpleACL checking %s', x)
50         if self.order_to_check_allow_deny == Order.ALLOW_DENY:
51             logger.debug('Checking allowed first...')
52             if self.check_allowed(x):
53                 logger.debug('%s was allowed explicitly.', x)
54                 return True
55             logger.debug('Checking denied next...')
56             if self.check_denied(x):
57                 logger.debug('%s was denied explicitly.', x)
58                 return False
59         elif self.order_to_check_allow_deny == Order.DENY_ALLOW:
60             logger.debug('Checking denied first...')
61             if self.check_denied(x):
62                 logger.debug('%s was denied explicitly.', x)
63                 return False
64             if self.check_allowed(x):
65                 logger.debug('%s was allowed explicitly.', x)
66                 return True
67
68         logger.debug(
69             f'{x} was not explicitly allowed or denied; '
70             + f'using default answer ({self.default_answer})'
71         )
72         return self.default_answer
73
74     @abstractmethod
75     def check_allowed(self, x: Any) -> bool:
76         """Return True if x is explicitly allowed, False otherwise."""
77         pass
78
79     @abstractmethod
80     def check_denied(self, x: Any) -> bool:
81         """Return True if x is explicitly denied, False otherwise."""
82         pass
83
84
85 class SetBasedACL(SimpleACL):
86     """An ACL that allows or denies based on membership in a set."""
87
88     def __init__(
89         self,
90         *,
91         allow_set: Optional[Set[Any]] = None,
92         deny_set: Optional[Set[Any]] = None,
93         order_to_check_allow_deny: Order,
94         default_answer: bool,
95     ) -> None:
96         super().__init__(
97             order_to_check_allow_deny=order_to_check_allow_deny,
98             default_answer=default_answer,
99         )
100         self.allow_set = allow_set
101         self.deny_set = deny_set
102
103     @overrides
104     def check_allowed(self, x: Any) -> bool:
105         if self.allow_set is None:
106             return False
107         return x in self.allow_set
108
109     @overrides
110     def check_denied(self, x: Any) -> bool:
111         if self.deny_set is None:
112             return False
113         return x in self.deny_set
114
115
116 class AllowListACL(SetBasedACL):
117     """Convenience subclass for a list that only allows known items.
118     i.e. a 'allowlist'
119     """
120
121     def __init__(self, *, allow_set: Optional[Set[Any]]) -> None:
122         super().__init__(
123             allow_set=allow_set,
124             order_to_check_allow_deny=Order.ALLOW_DENY,
125             default_answer=False,
126         )
127
128
129 class DenyListACL(SetBasedACL):
130     """Convenience subclass for a list that only disallows known items.
131     i.e. a 'blocklist'
132     """
133
134     def __init__(self, *, deny_set: Optional[Set[Any]]) -> None:
135         super().__init__(
136             deny_set=deny_set,
137             order_to_check_allow_deny=Order.ALLOW_DENY,
138             default_answer=True,
139         )
140
141
142 class BlockListACL(SetBasedACL):
143     """Convenience subclass for a list that only disallows known items.
144     i.e. a 'blocklist'
145     """
146
147     def __init__(self, *, deny_set: Optional[Set[Any]]) -> None:
148         super().__init__(
149             deny_set=deny_set,
150             order_to_check_allow_deny=Order.ALLOW_DENY,
151             default_answer=True,
152         )
153
154
155 class PredicateListBasedACL(SimpleACL):
156     """An ACL that allows or denies by applying predicates."""
157
158     def __init__(
159         self,
160         *,
161         allow_predicate_list: Sequence[Callable[[Any], bool]] = None,
162         deny_predicate_list: Sequence[Callable[[Any], bool]] = None,
163         order_to_check_allow_deny: Order,
164         default_answer: bool,
165     ) -> None:
166         super().__init__(
167             order_to_check_allow_deny=order_to_check_allow_deny,
168             default_answer=default_answer,
169         )
170         self.allow_predicate_list = allow_predicate_list
171         self.deny_predicate_list = deny_predicate_list
172
173     @overrides
174     def check_allowed(self, x: Any) -> bool:
175         if self.allow_predicate_list is None:
176             return False
177         return any(predicate(x) for predicate in self.allow_predicate_list)
178
179     @overrides
180     def check_denied(self, x: Any) -> bool:
181         if self.deny_predicate_list is None:
182             return False
183         return any(predicate(x) for predicate in self.deny_predicate_list)
184
185
186 class StringWildcardBasedACL(PredicateListBasedACL):
187     """An ACL that allows or denies based on string glob :code:`(*, ?)`
188     patterns.
189     """
190
191     def __init__(
192         self,
193         *,
194         allowed_patterns: Optional[List[str]] = None,
195         denied_patterns: Optional[List[str]] = None,
196         order_to_check_allow_deny: Order,
197         default_answer: bool,
198     ) -> None:
199         allow_predicates = []
200         if allowed_patterns is not None:
201             for pattern in allowed_patterns:
202                 allow_predicates.append(
203                     lambda x, pattern=pattern: fnmatch.fnmatch(x, pattern)
204                 )
205         deny_predicates = None
206         if denied_patterns is not None:
207             deny_predicates = []
208             for pattern in denied_patterns:
209                 deny_predicates.append(
210                     lambda x, pattern=pattern: fnmatch.fnmatch(x, pattern)
211                 )
212
213         super().__init__(
214             allow_predicate_list=allow_predicates,
215             deny_predicate_list=deny_predicates,
216             order_to_check_allow_deny=order_to_check_allow_deny,
217             default_answer=default_answer,
218         )
219
220
221 class StringREBasedACL(PredicateListBasedACL):
222     """An ACL that allows or denies by applying regexps."""
223
224     def __init__(
225         self,
226         *,
227         allowed_regexs: Optional[List[re.Pattern]] = None,
228         denied_regexs: Optional[List[re.Pattern]] = None,
229         order_to_check_allow_deny: Order,
230         default_answer: bool,
231     ) -> None:
232         allow_predicates = None
233         if allowed_regexs is not None:
234             allow_predicates = []
235             for pattern in allowed_regexs:
236                 allow_predicates.append(
237                     lambda x, pattern=pattern: pattern.match(x) is not None
238                 )
239         deny_predicates = None
240         if denied_regexs is not None:
241             deny_predicates = []
242             for pattern in denied_regexs:
243                 deny_predicates.append(
244                     lambda x, pattern=pattern: pattern.match(x) is not None
245                 )
246         super().__init__(
247             allow_predicate_list=allow_predicates,
248             deny_predicate_list=deny_predicates,
249             order_to_check_allow_deny=order_to_check_allow_deny,
250             default_answer=default_answer,
251         )
252
253
254 class AnyCompoundACL(SimpleACL):
255     """An ACL that allows if any of its subacls allow."""
256
257     def __init__(
258         self,
259         *,
260         subacls: Optional[List[SimpleACL]] = None,
261         order_to_check_allow_deny: Order,
262         default_answer: bool,
263     ) -> None:
264         super().__init__(
265             order_to_check_allow_deny=order_to_check_allow_deny,
266             default_answer=default_answer,
267         )
268         self.subacls = subacls
269
270     @overrides
271     def check_allowed(self, x: Any) -> bool:
272         if self.subacls is None:
273             return False
274         return any(acl(x) for acl in self.subacls)
275
276     @overrides
277     def check_denied(self, x: Any) -> bool:
278         if self.subacls is None:
279             return False
280         return any(not acl(x) for acl in self.subacls)
281
282
283 class AllCompoundACL(SimpleACL):
284     """An ACL that allows if all of its subacls allow."""
285
286     def __init__(
287         self,
288         *,
289         subacls: Optional[List[SimpleACL]] = None,
290         order_to_check_allow_deny: Order,
291         default_answer: bool,
292     ) -> None:
293         super().__init__(
294             order_to_check_allow_deny=order_to_check_allow_deny,
295             default_answer=default_answer,
296         )
297         self.subacls = subacls
298
299     @overrides
300     def check_allowed(self, x: Any) -> bool:
301         if self.subacls is None:
302             return False
303         return all(acl(x) for acl in self.subacls)
304
305     @overrides
306     def check_denied(self, x: Any) -> bool:
307         if self.subacls is None:
308             return False
309         return any(not acl(x) for acl in self.subacls)