Skip to content

Commit 3b40d8f

Browse files
committed
feat: align naming to caller_id/target_id and implement conformance test suite
Signed-off-by: tercel <tercel.yi@gmail.com>
1 parent ff68c39 commit 3b40d8f

7 files changed

Lines changed: 312 additions & 66 deletions

File tree

src/apcore/acl.py

Lines changed: 76 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -75,7 +75,14 @@ class ACL:
7575
remove_rule, reload) are safe to call concurrently.
7676
"""
7777

78-
_condition_handlers: ClassVar[dict[str, ACLConditionHandler]] = {}
78+
_condition_handlers: ClassVar[dict[str, ACLConditionHandler]] = {
79+
"identity_types": _IdentityTypesHandler(),
80+
"identity_type": _IdentityTypesHandler(),
81+
"roles": _RolesHandler(),
82+
"role": _RolesHandler(),
83+
"max_call_depth": _MaxCallDepthHandler(),
84+
"call_depth": _MaxCallDepthHandler(),
85+
}
7986

8087
@classmethod
8188
def register_condition(cls, key: str, handler: ACLConditionHandler) -> None:
@@ -135,20 +142,21 @@ async def _evaluate_conditions_async(
135142

136143
def __init__(
137144
self,
138-
rules: list[ACLRule],
145+
rules: list[ACLRule] | None = None,
139146
default_effect: str = "deny",
140147
*,
141148
audit_logger: Callable[[AuditEntry], None] | None = None,
142149
) -> None:
143150
"""Initialize ACL with ordered rules and a default effect.
144151
145152
Args:
146-
rules: Ordered list of ACL rules (first match wins).
153+
rules: Ordered list of ACL rules (first match wins). Defaults to [].
147154
default_effect: Effect when no rule matches ('allow' or 'deny').
148155
audit_logger: Optional callback invoked with an AuditEntry for
149156
every check() call. Useful for structured audit trails.
150157
"""
151-
self._rules: list[ACLRule] = list(rules)
158+
self._rules = list(rules) if rules is not None else []
159+
152160
self._default_effect: str = default_effect
153161
self._yaml_path: str | None = None
154162
self._audit_logger: Callable[[AuditEntry], None] | None = audit_logger
@@ -253,7 +261,7 @@ def check(
253261
if self._matches_rule(rule, effective_caller, target_id, context):
254262
decision = rule.effect == "allow"
255263
self._logger.debug(
256-
"ACL check: caller=%s target=%s decision=%s rule=%s",
264+
"ACL check: caller_id=%s target_id=%s decision=%s rule=%s",
257265
caller_id,
258266
target_id,
259267
"allow" if decision else "deny",
@@ -274,7 +282,7 @@ def check(
274282

275283
default_decision = default_effect == "allow"
276284
self._logger.debug(
277-
"ACL check: caller=%s target=%s decision=%s rule=default",
285+
"ACL check: caller_id=%s target_id=%s decision=%s rule=default",
278286
caller_id,
279287
target_id,
280288
"allow" if default_decision else "deny",
@@ -320,7 +328,7 @@ async def async_check(
320328
if await self._matches_rule_async(rule, effective_caller, target_id, context):
321329
decision = rule.effect == "allow"
322330
self._logger.debug(
323-
"ACL async_check: caller=%s target=%s decision=%s rule=%s",
331+
"ACL async_check: caller_id=%s target_id=%s decision=%s rule=%s",
324332
caller_id,
325333
target_id,
326334
"allow" if decision else "deny",
@@ -341,7 +349,7 @@ async def async_check(
341349

342350
default_decision = default_effect == "allow"
343351
self._logger.debug(
344-
"ACL async_check: caller=%s target=%s decision=%s rule=default",
352+
"ACL async_check: caller_id=%s target_id=%s decision=%s rule=default",
345353
caller_id,
346354
target_id,
347355
"allow" if default_decision else "deny",
@@ -360,30 +368,50 @@ async def async_check(
360368
audit_logger(entry)
361369
return default_decision
362370

363-
async def _matches_rule_async(
371+
def _match_patterns(self, patterns: list[str], value: str, context: Context | None = None) -> bool:
372+
"""Match a list of patterns against a value.
373+
374+
Implements compound operators ($or, $not) in pattern lists.
375+
"""
376+
if not patterns:
377+
return False
378+
379+
# Check for compound operators
380+
first = patterns[0]
381+
if first == "$or":
382+
return any(self._match_pattern(p, value, context) for p in patterns[1:])
383+
if first == "$not":
384+
# $not expects exactly one subsequent pattern
385+
if len(patterns) < 2:
386+
return False
387+
return not self._match_pattern(patterns[1], value, context)
388+
389+
# Standard OR behavior for flat list
390+
return any(self._match_pattern(p, value, context) for p in patterns)
391+
392+
def _matches_rule(
364393
self,
365394
rule: ACLRule,
366395
caller: str,
367396
target: str,
368397
context: Context | None,
369398
) -> bool:
370-
"""Async version of _matches_rule that awaits async condition handlers."""
371-
caller_match = any(self._match_pattern(p, caller, context) for p in rule.callers)
372-
if not caller_match:
399+
"""Check if a rule matches the given caller, target, and context."""
400+
if not self._match_patterns(rule.callers, caller, context):
373401
return False
374402

375-
target_match = any(self._match_pattern(p, target, context) for p in rule.targets)
376-
if not target_match:
403+
if not self._match_patterns(rule.targets, target, context):
377404
return False
378405

379406
if rule.conditions is not None:
380407
if context is None:
381408
return False
382-
if not await self._evaluate_conditions_async(rule.conditions, context):
409+
if not self._evaluate_conditions(rule.conditions, context):
383410
return False
384411

385412
return True
386413

414+
387415
def _build_audit_entry(
388416
self,
389417
*,
@@ -471,12 +499,41 @@ def _check_conditions(self, conditions: dict[str, Any], context: Context | None)
471499
return False
472500
return self._evaluate_conditions(conditions, context)
473501

474-
def add_rule(self, rule: ACLRule) -> None:
502+
def add_rule(
503+
self,
504+
rule: ACLRule | None = None,
505+
*,
506+
callers: list[str] | str | None = None,
507+
targets: list[str] | str | None = None,
508+
effect: str = "deny",
509+
description: str = "",
510+
conditions: dict[str, Any] | None = None,
511+
) -> None:
475512
"""Add a rule at position 0 (highest priority).
476513
477514
Args:
478-
rule: The ACLRule to add.
515+
rule: Optional pre-built ACLRule.
516+
callers: Caller pattern(s) if *rule* is None.
517+
targets: Target pattern(s) if *rule* is None.
518+
effect: Rule effect if *rule* is None.
519+
description: Rule description if *rule* is None.
520+
conditions: Rule conditions if *rule* is None.
479521
"""
522+
if rule is None:
523+
if callers is None or targets is None:
524+
raise ValueError("Must provide either 'rule' or both 'callers' and 'targets'")
525+
526+
def _to_list(v: list[str] | str) -> list[str]:
527+
return [v] if isinstance(v, str) else list(v)
528+
529+
rule = ACLRule(
530+
callers=_to_list(callers),
531+
targets=_to_list(targets),
532+
effect=effect,
533+
description=description,
534+
conditions=conditions,
535+
)
536+
480537
with self._lock:
481538
self._rules.insert(0, rule)
482539

@@ -514,11 +571,8 @@ def reload(self) -> None:
514571

515572

516573
# ---------------------------------------------------------------------------
517-
# Auto-register built-in handlers at module load time
574+
# Auto-register compound operators at module load time
518575
# ---------------------------------------------------------------------------
519-
520-
ACL.register_condition("identity_types", _IdentityTypesHandler())
521-
ACL.register_condition("roles", _RolesHandler())
522-
ACL.register_condition("max_call_depth", _MaxCallDepthHandler())
523576
ACL.register_condition("$or", _OrHandler(ACL._evaluate_conditions))
524577
ACL.register_condition("$not", _NotHandler(ACL._evaluate_conditions))
578+

src/apcore/acl_handlers.py

Lines changed: 12 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -44,30 +44,34 @@ async def evaluate(self, value: Any, context: Context) -> bool: ...
4444

4545

4646
class _IdentityTypesHandler:
47-
"""Check context.identity.type is in the allowed list."""
47+
"""Check context.identity.type matches allowed value(s)."""
4848

4949
def evaluate(self, value: Any, context: Context) -> bool:
50-
if not isinstance(value, list) or context.identity is None:
50+
if context.identity is None:
5151
return False
52-
return context.identity.type in value
52+
if isinstance(value, list):
53+
return context.identity.type in value
54+
return context.identity.type == value
5355

5456

5557
class _RolesHandler:
56-
"""Check at least one role overlaps between identity and required roles."""
58+
"""Check role overlap between identity and required roles."""
5759

5860
def evaluate(self, value: Any, context: Context) -> bool:
59-
if not isinstance(value, list) or context.identity is None:
61+
if context.identity is None:
6062
return False
61-
return bool(set(context.identity.roles) & set(value))
63+
required = {value} if isinstance(value, str) else set(value)
64+
return bool(set(context.identity.roles) & required)
6265

6366

6467
class _MaxCallDepthHandler:
6568
"""Check call chain length does not exceed threshold."""
6669

6770
def evaluate(self, value: Any, context: Context) -> bool:
68-
if not isinstance(value, int):
71+
threshold = value.get("lte") if isinstance(value, dict) else value
72+
if not isinstance(threshold, int):
6973
return False
70-
return len(context.call_chain) <= value
74+
return len(context.call_chain) <= threshold
7175

7276

7377
# ---------------------------------------------------------------------------

src/apcore/config.py

Lines changed: 22 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -219,28 +219,24 @@ def _set_nested(data: dict[str, Any], dot_path: str, value: Any) -> None:
219219

220220

221221
def _apply_env_overrides(data: dict[str, Any]) -> dict[str, Any]:
222-
"""Apply APCORE_* environment variable overrides.
223-
224-
Naming convention: single ``_`` → ``.`` (section separator), double ``__`` → literal ``_``.
225-
226-
Examples::
227-
228-
APCORE_EXECUTOR_DEFAULT__TIMEOUT=5000 → executor.default_timeout = 5000
229-
APCORE_ACL_DEFAULT__EFFECT=allow → acl.default_effect = "allow"
230-
APCORE_SCHEMA_ROOT=/schemas → schema.root = "/schemas"
231-
232-
Numeric strings are coerced to int/float; ``"true"``/``"false"`` become booleans.
233-
"""
222+
"""Apply APCORE_* environment variable overrides and global mappings."""
234223
result = copy.deepcopy(data) # deep copy to protect shared defaults
235224
for env_key, env_value in os.environ.items():
225+
coerced = _coerce_env_value(env_value)
226+
227+
# 1. Global env_map (bare env var → top-level key).
228+
if env_key in _GLOBAL_ENV_MAP:
229+
_set_nested(result, _GLOBAL_ENV_MAP[env_key], coerced)
230+
continue
231+
232+
# 2. Standard APCORE_ prefix.
236233
if not env_key.startswith(_ENV_PREFIX):
237234
continue
238235
suffix = env_key[len(_ENV_PREFIX) :]
239236
if not suffix:
240237
continue
241238
# Convert: single _ → . (separator), double __ → literal _
242239
dot_path = suffix.lower().replace("__", "\x00").replace("_", ".").replace("\x00", "_")
243-
coerced = _coerce_env_value(env_value)
244240
_set_nested(result, dot_path, coerced)
245241
return result
246242

@@ -289,7 +285,7 @@ def _env_suffix_to_dot_path_with_depth(suffix: str, max_depth: int) -> str:
289285
else:
290286
result.append(ch)
291287
i += 1
292-
return "".join(result)
288+
return "".join(result).strip(".")
293289

294290

295291
def _auto_resolve_suffix(
@@ -534,12 +530,23 @@ class Config:
534530
namespace name and looks up nested keys within it.
535531
"""
536532

537-
def __init__(self, data: dict[str, Any] | None = None) -> None:
533+
def __init__(
534+
self,
535+
data: dict[str, Any] | None = None,
536+
env_style: str = "auto",
537+
) -> None:
538+
"""Initialize configuration system.
539+
540+
Args:
541+
data: Optional in-memory configuration data.
542+
env_style: Default env var conversion strategy ('auto', 'nested', 'flat').
543+
"""
538544
self._data: dict[str, Any] = data or {}
539545
self._yaml_path: str | None = None
540546
self._lock = threading.Lock()
541547
self._mode: str = "legacy"
542548
self._mounts: dict[str, dict[str, Any]] = {}
549+
self._env_style: str = env_style
543550

544551
# ------------------------------------------------------------------
545552
# Namespace registry (class-level)

src/apcore/utils/__init__.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,6 @@
33
from apcore.utils.call_chain import guard_call_chain
44
from apcore.utils.error_propagation import propagate_error
55
from apcore.utils.normalize import normalize_to_canonical_id
6-
from apcore.utils.pattern import match_pattern
6+
from apcore.utils.pattern import match_pattern, calculate_specificity
77

8-
__all__ = ["guard_call_chain", "match_pattern", "normalize_to_canonical_id", "propagate_error"]
8+
__all__ = ["guard_call_chain", "match_pattern", "normalize_to_canonical_id", "propagate_error", "calculate_specificity"]

src/apcore/utils/normalize.py

Lines changed: 19 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -21,8 +21,8 @@
2121
# Handles transitions like: "Http" | "JSON" | "Parser" | "v2".
2222
_CASE_BOUNDARY = re.compile(
2323
r"""
24-
(?<=[a-z0-9])(?=[A-Z]) # lowercase/digit → uppercase (e.g. "http|Json")
25-
| (?<=[A-Z])(?=[A-Z][a-z]) # uppercase run → start of word (e.g. "HTTP|Json")
24+
(?<=[a-z0-9])(?=[A-Z]) # lowercase/digit → uppercase (e.g. "http|Json")
25+
| (?<=[A-Z])(?=[A-Z][a-z0-9]) # uppercase run → start of word (e.g. "HTTP|Json")
2626
""",
2727
re.VERBOSE,
2828
)
@@ -32,24 +32,26 @@
3232

3333

3434
def _to_snake_case(segment: str) -> str:
35-
"""Convert a PascalCase, camelCase, or mixed-case segment to snake_case.
36-
37-
Acronyms are treated as single words::
38-
39-
"HttpJsonParser" → "http_json_parser"
40-
"HTMLParser" → "html_parser"
41-
"getDBUrl" → "get_db_url"
42-
"""
35+
"""Convert a PascalCase, camelCase, or mixed-case segment to snake_case."""
4336
if not segment:
4437
return segment
4538

46-
# If already snake_case (all lowercase + underscores), return as-is.
47-
if segment == segment.lower() and segment.isidentifier():
48-
return segment
49-
50-
# Split at case boundaries, join with underscore, lowercase.
51-
words = _CASE_BOUNDARY.split(segment)
52-
return "_".join(w.lower() for w in words if w)
39+
res = []
40+
for i, char in enumerate(segment):
41+
if i > 0:
42+
prev = segment[i - 1]
43+
# Case 1: lowercase/digit followed by uppercase -> add underscore
44+
if prev.islower() or prev.isdigit():
45+
if char.isupper():
46+
res.append("_")
47+
# Case 2: uppercase followed by uppercase followed by lowercase -> add underscore before the middle one
48+
# e.g., HTTPAPIHandler: ...PIH... -> ...PI_H...
49+
elif prev.isupper() and char.isupper():
50+
if i + 1 < len(segment) and segment[i + 1].islower():
51+
res.append("_")
52+
res.append(char.lower())
53+
54+
return "".join(res).replace("__", "_")
5355

5456

5557
def normalize_to_canonical_id(local_id: str, language: str) -> str:

tests/conformance/test_conformance.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -236,8 +236,8 @@ def test_acl(self, case: dict[str, Any]) -> None:
236236
for r in case["rules"]
237237
]
238238
acl = ACL(rules=rules, default_effect=case["default_effect"])
239-
result = acl.check(caller_id=case["caller"], target_id=case["target"])
239+
result = acl.check(caller_id=case["caller_id"], target_id=case["target_id"])
240240
assert result == case["expected"], (
241-
f"ACL check(caller={case['caller']!r}, target={case['target']!r}) "
241+
f"ACL check(caller_id={case['caller_id']!r}, target_id={case['target_id']!r}) "
242242
f"returned {result}, expected {case['expected']}"
243243
)

0 commit comments

Comments
 (0)