88
99import logging
1010import os
11+ import threading
1112from dataclasses import dataclass
1213from typing import Any
1314
@@ -41,9 +42,8 @@ class ACL:
4142 Implements PROTOCOL_SPEC section 6 for module access control.
4243
4344 Thread safety:
44- - check() is read-only and fully thread-safe for concurrent calls.
45- - add_rule(), remove_rule(), and reload() mutate the rules list and
46- require external locking if called concurrently with check().
45+ Internally synchronized. All public methods (check, add_rule,
46+ remove_rule, reload) are safe to call concurrently.
4747 """
4848
4949 def __init__ (self , rules : list [ACLRule ], default_effect : str = "deny" ) -> None :
@@ -58,6 +58,7 @@ def __init__(self, rules: list[ACLRule], default_effect: str = "deny") -> None:
5858 self ._yaml_path : str | None = None
5959 self .debug : bool = False
6060 self ._logger : logging .Logger = logging .getLogger ("apcore.acl" )
61+ self ._lock = threading .Lock ()
6162
6263 @classmethod
6364 def load (cls , yaml_path : str ) -> ACL :
@@ -83,37 +84,49 @@ def load(cls, yaml_path: str) -> ACL:
8384 raise ACLRuleError (f"Invalid YAML in { yaml_path } : { e } " ) from e
8485
8586 if not isinstance (data , dict ):
86- raise ACLRuleError (f"ACL config must be a mapping, got { type (data ).__name__ } " )
87+ raise ACLRuleError (
88+ f"ACL config must be a mapping, got { type (data ).__name__ } "
89+ )
8790
8891 if "rules" not in data :
8992 raise ACLRuleError ("ACL config missing required 'rules' key" )
9093
9194 raw_rules = data ["rules" ]
9295 if not isinstance (raw_rules , list ):
93- raise ACLRuleError (f"'rules' must be a list, got { type (raw_rules ).__name__ } " )
96+ raise ACLRuleError (
97+ f"'rules' must be a list, got { type (raw_rules ).__name__ } "
98+ )
9499
95100 default_effect : str = data .get ("default_effect" , "deny" )
96101 rules : list [ACLRule ] = []
97102
98103 for i , raw_rule in enumerate (raw_rules ):
99104 if not isinstance (raw_rule , dict ):
100- raise ACLRuleError (f"Rule { i } must be a mapping, got { type (raw_rule ).__name__ } " )
105+ raise ACLRuleError (
106+ f"Rule { i } must be a mapping, got { type (raw_rule ).__name__ } "
107+ )
101108
102109 for key in ("callers" , "targets" , "effect" ):
103110 if key not in raw_rule :
104111 raise ACLRuleError (f"Rule { i } missing required key '{ key } '" )
105112
106113 effect = raw_rule ["effect" ]
107114 if effect not in ("allow" , "deny" ):
108- raise ACLRuleError (f"Rule { i } has invalid effect '{ effect } ', must be 'allow' or 'deny'" )
115+ raise ACLRuleError (
116+ f"Rule { i } has invalid effect '{ effect } ', must be 'allow' or 'deny'"
117+ )
109118
110119 callers = raw_rule ["callers" ]
111120 if not isinstance (callers , list ):
112- raise ACLRuleError (f"Rule { i } 'callers' must be a list, got { type (callers ).__name__ } " )
121+ raise ACLRuleError (
122+ f"Rule { i } 'callers' must be a list, got { type (callers ).__name__ } "
123+ )
113124
114125 targets = raw_rule ["targets" ]
115126 if not isinstance (targets , list ):
116- raise ACLRuleError (f"Rule { i } 'targets' must be a list, got { type (targets ).__name__ } " )
127+ raise ACLRuleError (
128+ f"Rule { i } 'targets' must be a list, got { type (targets ).__name__ } "
129+ )
117130
118131 rules .append (
119132 ACLRule (
@@ -147,7 +160,11 @@ def check(
147160 """
148161 effective_caller = "@external" if caller_id is None else caller_id
149162
150- for rule in self ._rules :
163+ with self ._lock :
164+ rules = list (self ._rules )
165+ default_effect = self ._default_effect
166+
167+ for rule in rules :
151168 if self ._matches_rule (rule , effective_caller , target_id , context ):
152169 decision = rule .effect == "allow"
153170 self ._logger .debug (
@@ -159,7 +176,7 @@ def check(
159176 )
160177 return decision
161178
162- default_decision = self . _default_effect == "allow"
179+ default_decision = default_effect == "allow"
163180 self ._logger .debug (
164181 "ACL check: caller=%s target=%s decision=%s rule=default" ,
165182 caller_id ,
@@ -168,7 +185,9 @@ def check(
168185 )
169186 return default_decision
170187
171- def _match_pattern (self , pattern : str , value : str , context : Context | None = None ) -> bool :
188+ def _match_pattern (
189+ self , pattern : str , value : str , context : Context | None = None
190+ ) -> bool :
172191 """Match a single pattern against a value, with special pattern handling.
173192
174193 Handles @external and @system patterns locally, delegates all
@@ -177,7 +196,11 @@ def _match_pattern(self, pattern: str, value: str, context: Context | None = Non
177196 if pattern == "@external" :
178197 return value == "@external"
179198 if pattern == "@system" :
180- return context is not None and context .identity is not None and context .identity .type == "system"
199+ return (
200+ context is not None
201+ and context .identity is not None
202+ and context .identity .type == "system"
203+ )
181204 return match_pattern (pattern , value )
182205
183206 def _matches_rule (
@@ -194,11 +217,15 @@ def _matches_rule(
194217 2. At least one target pattern matches the target (OR logic).
195218 3. If conditions are present, they must all be satisfied.
196219 """
197- caller_match = any (self ._match_pattern (p , caller , context ) for p in rule .callers )
220+ caller_match = any (
221+ self ._match_pattern (p , caller , context ) for p in rule .callers
222+ )
198223 if not caller_match :
199224 return False
200225
201- target_match = any (self ._match_pattern (p , target , context ) for p in rule .targets )
226+ target_match = any (
227+ self ._match_pattern (p , target , context ) for p in rule .targets
228+ )
202229 if not target_match :
203230 return False
204231
@@ -208,7 +235,9 @@ def _matches_rule(
208235
209236 return True
210237
211- def _check_conditions (self , conditions : dict [str , Any ], context : Context | None ) -> bool :
238+ def _check_conditions (
239+ self , conditions : dict [str , Any ], context : Context | None
240+ ) -> bool :
212241 """Evaluate conditional rule parameters against the execution context.
213242
214243 Returns False if any condition is not satisfied.
@@ -217,7 +246,10 @@ def _check_conditions(self, conditions: dict[str, Any], context: Context | None)
217246 return False
218247
219248 if "identity_types" in conditions :
220- if context .identity is None or context .identity .type not in conditions ["identity_types" ]:
249+ if (
250+ context .identity is None
251+ or context .identity .type not in conditions ["identity_types" ]
252+ ):
221253 return False
222254
223255 if "roles" in conditions :
@@ -238,7 +270,8 @@ def add_rule(self, rule: ACLRule) -> None:
238270 Args:
239271 rule: The ACLRule to add.
240272 """
241- self ._rules .insert (0 , rule )
273+ with self ._lock :
274+ self ._rules .insert (0 , rule )
242275
243276 def remove_rule (self , callers : list [str ], targets : list [str ]) -> bool :
244277 """Remove the first rule matching the given callers and targets.
@@ -250,20 +283,24 @@ def remove_rule(self, callers: list[str], targets: list[str]) -> bool:
250283 Returns:
251284 True if a rule was found and removed, False otherwise.
252285 """
253- for i , rule in enumerate (self ._rules ):
254- if rule .callers == callers and rule .targets == targets :
255- self ._rules .pop (i )
256- return True
257- return False
286+ with self ._lock :
287+ for i , rule in enumerate (self ._rules ):
288+ if rule .callers == callers and rule .targets == targets :
289+ self ._rules .pop (i )
290+ return True
291+ return False
258292
259293 def reload (self ) -> None :
260294 """Re-read the ACL from the original YAML file.
261295
262296 Only works if the ACL was created via ACL.load().
263297 Raises ACLRuleError if no YAML path was stored.
264298 """
265- if self ._yaml_path is None :
299+ with self ._lock :
300+ yaml_path = self ._yaml_path
301+ if yaml_path is None :
266302 raise ACLRuleError ("Cannot reload: ACL was not loaded from a YAML file" )
267- reloaded = ACL .load (self ._yaml_path )
268- self ._rules = reloaded ._rules
269- self ._default_effect = reloaded ._default_effect
303+ reloaded = ACL .load (yaml_path )
304+ with self ._lock :
305+ self ._rules = reloaded ._rules
306+ self ._default_effect = reloaded ._default_effect
0 commit comments