apcore 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- apcore-0.1.0/PKG-INFO +8 -0
- apcore-0.1.0/README.md +161 -0
- apcore-0.1.0/pyproject.toml +38 -0
- apcore-0.1.0/setup.cfg +4 -0
- apcore-0.1.0/src/apcore/__init__.py +116 -0
- apcore-0.1.0/src/apcore/acl.py +269 -0
- apcore-0.1.0/src/apcore/bindings.py +252 -0
- apcore-0.1.0/src/apcore/config.py +29 -0
- apcore-0.1.0/src/apcore/context.py +60 -0
- apcore-0.1.0/src/apcore/decorator.py +264 -0
- apcore-0.1.0/src/apcore/errors.py +380 -0
- apcore-0.1.0/src/apcore/executor.py +620 -0
- apcore-0.1.0/src/apcore/middleware/__init__.py +15 -0
- apcore-0.1.0/src/apcore/middleware/adapters.py +39 -0
- apcore-0.1.0/src/apcore/middleware/base.py +31 -0
- apcore-0.1.0/src/apcore/middleware/logging.py +88 -0
- apcore-0.1.0/src/apcore/middleware/manager.py +110 -0
- apcore-0.1.0/src/apcore/module.py +58 -0
- apcore-0.1.0/src/apcore/observability/__init__.py +37 -0
- apcore-0.1.0/src/apcore/observability/context_logger.py +169 -0
- apcore-0.1.0/src/apcore/observability/metrics.py +175 -0
- apcore-0.1.0/src/apcore/observability/tracing.py +261 -0
- apcore-0.1.0/src/apcore/registry/__init__.py +36 -0
- apcore-0.1.0/src/apcore/registry/dependencies.py +113 -0
- apcore-0.1.0/src/apcore/registry/entry_point.py +89 -0
- apcore-0.1.0/src/apcore/registry/metadata.py +116 -0
- apcore-0.1.0/src/apcore/registry/registry.py +388 -0
- apcore-0.1.0/src/apcore/registry/scanner.py +147 -0
- apcore-0.1.0/src/apcore/registry/schema_export.py +189 -0
- apcore-0.1.0/src/apcore/registry/types.py +51 -0
- apcore-0.1.0/src/apcore/registry/validation.py +46 -0
- apcore-0.1.0/src/apcore/schema/__init__.py +45 -0
- apcore-0.1.0/src/apcore/schema/annotations.py +62 -0
- apcore-0.1.0/src/apcore/schema/exporter.py +95 -0
- apcore-0.1.0/src/apcore/schema/loader.py +372 -0
- apcore-0.1.0/src/apcore/schema/ref_resolver.py +200 -0
- apcore-0.1.0/src/apcore/schema/strict.py +105 -0
- apcore-0.1.0/src/apcore/schema/types.py +109 -0
- apcore-0.1.0/src/apcore/schema/validator.py +102 -0
- apcore-0.1.0/src/apcore/utils/__init__.py +5 -0
- apcore-0.1.0/src/apcore/utils/pattern.py +46 -0
- apcore-0.1.0/src/apcore.egg-info/PKG-INFO +8 -0
- apcore-0.1.0/src/apcore.egg-info/SOURCES.txt +56 -0
- apcore-0.1.0/src/apcore.egg-info/dependency_links.txt +1 -0
- apcore-0.1.0/src/apcore.egg-info/requires.txt +3 -0
- apcore-0.1.0/src/apcore.egg-info/top_level.txt +1 -0
- apcore-0.1.0/tests/test_acl.py +541 -0
- apcore-0.1.0/tests/test_bindings.py +490 -0
- apcore-0.1.0/tests/test_decorator.py +1217 -0
- apcore-0.1.0/tests/test_executor.py +559 -0
- apcore-0.1.0/tests/test_executor_async.py +233 -0
- apcore-0.1.0/tests/test_executor_types.py +71 -0
- apcore-0.1.0/tests/test_integration_executor.py +297 -0
- apcore-0.1.0/tests/test_logging_middleware.py +251 -0
- apcore-0.1.0/tests/test_middleware.py +163 -0
- apcore-0.1.0/tests/test_middleware_manager.py +382 -0
- apcore-0.1.0/tests/test_public_api.py +288 -0
- apcore-0.1.0/tests/test_redaction.py +173 -0
apcore-0.1.0/PKG-INFO
ADDED
apcore-0.1.0/README.md
ADDED
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
# apcore
|
|
2
|
+
|
|
3
|
+
Schema-driven module development framework for AI-perceivable interfaces.
|
|
4
|
+
|
|
5
|
+
**apcore** provides a unified task orchestration framework with strict type safety, access control, middleware pipelines, and built-in observability. It enables you to define modules with structured input/output schemas that are easily consumed by LLMs and other automated systems.
|
|
6
|
+
|
|
7
|
+
## Features
|
|
8
|
+
|
|
9
|
+
- **Schema-driven modules** -- Define input/output contracts using Pydantic models with automatic validation
|
|
10
|
+
- **10-step execution pipeline** -- Context creation, safety checks, ACL enforcement, validation, middleware chains, and execution with timeout support
|
|
11
|
+
- **`@module` decorator** -- Turn plain functions into fully schema-aware modules with zero boilerplate
|
|
12
|
+
- **YAML bindings** -- Register modules declaratively without modifying source code
|
|
13
|
+
- **Access control (ACL)** -- Pattern-based, first-match-wins rules with wildcard support
|
|
14
|
+
- **Middleware system** -- Composable before/after hooks with error recovery
|
|
15
|
+
- **Observability** -- Tracing (spans), metrics collection, and structured context logging
|
|
16
|
+
- **Async support** -- Seamless sync and async module execution
|
|
17
|
+
- **Safety guards** -- Call depth limits, circular call detection, frequency throttling
|
|
18
|
+
|
|
19
|
+
## Requirements
|
|
20
|
+
|
|
21
|
+
- Python >= 3.11
|
|
22
|
+
|
|
23
|
+
## Installation
|
|
24
|
+
|
|
25
|
+
```bash
|
|
26
|
+
pip install -e .
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
For development:
|
|
30
|
+
|
|
31
|
+
```bash
|
|
32
|
+
pip install -e ".[dev]"
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
## Quick Start
|
|
36
|
+
|
|
37
|
+
### Define a module with the decorator
|
|
38
|
+
|
|
39
|
+
```python
|
|
40
|
+
from apcore import module
|
|
41
|
+
|
|
42
|
+
@module(description="Add two integers", tags=["math"])
|
|
43
|
+
def add(a: int, b: int) -> int:
|
|
44
|
+
return a + b
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
### Define a module with a class
|
|
48
|
+
|
|
49
|
+
```python
|
|
50
|
+
from pydantic import BaseModel
|
|
51
|
+
from apcore import Context
|
|
52
|
+
|
|
53
|
+
class GreetInput(BaseModel):
|
|
54
|
+
name: str
|
|
55
|
+
|
|
56
|
+
class GreetOutput(BaseModel):
|
|
57
|
+
message: str
|
|
58
|
+
|
|
59
|
+
class GreetModule:
|
|
60
|
+
input_schema = GreetInput
|
|
61
|
+
output_schema = GreetOutput
|
|
62
|
+
description = "Greet a user"
|
|
63
|
+
|
|
64
|
+
def execute(self, inputs: dict, context: Context) -> dict:
|
|
65
|
+
return {"message": f"Hello, {inputs['name']}!"}
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
### Register and execute
|
|
69
|
+
|
|
70
|
+
```python
|
|
71
|
+
from apcore import Registry, Executor
|
|
72
|
+
|
|
73
|
+
registry = Registry()
|
|
74
|
+
registry.register("greet", GreetModule())
|
|
75
|
+
|
|
76
|
+
executor = Executor(registry=registry)
|
|
77
|
+
result = executor.call("greet", {"name": "Alice"})
|
|
78
|
+
# {"message": "Hello, Alice!"}
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
### Add middleware
|
|
82
|
+
|
|
83
|
+
```python
|
|
84
|
+
from apcore import LoggingMiddleware, TracingMiddleware
|
|
85
|
+
|
|
86
|
+
executor.use(LoggingMiddleware())
|
|
87
|
+
executor.use(TracingMiddleware())
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
### Access control
|
|
91
|
+
|
|
92
|
+
```python
|
|
93
|
+
from apcore import ACL, ACLRule
|
|
94
|
+
|
|
95
|
+
acl = ACL(rules=[
|
|
96
|
+
ACLRule(callers=["admin.*"], targets=["*"], effect="allow", description="Admins can call anything"),
|
|
97
|
+
ACLRule(callers=["*"], targets=["admin.*"], effect="deny", description="Others cannot call admin modules"),
|
|
98
|
+
])
|
|
99
|
+
executor = Executor(registry=registry, acl=acl)
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
## Project Structure
|
|
103
|
+
|
|
104
|
+
```
|
|
105
|
+
src/apcore/
|
|
106
|
+
__init__.py # Public API
|
|
107
|
+
context.py # Execution context & identity
|
|
108
|
+
executor.py # Core execution engine
|
|
109
|
+
decorator.py # @module decorator
|
|
110
|
+
bindings.py # YAML binding loader
|
|
111
|
+
config.py # Configuration
|
|
112
|
+
acl.py # Access control
|
|
113
|
+
errors.py # Error hierarchy
|
|
114
|
+
module.py # Module annotations & metadata
|
|
115
|
+
middleware/ # Middleware system
|
|
116
|
+
observability/ # Tracing, metrics, logging
|
|
117
|
+
registry/ # Module discovery & registration
|
|
118
|
+
schema/ # Schema loading, validation, export
|
|
119
|
+
utils/ # Utilities
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
## Development
|
|
123
|
+
|
|
124
|
+
### Run tests
|
|
125
|
+
|
|
126
|
+
```bash
|
|
127
|
+
pytest
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
### Run tests with coverage
|
|
131
|
+
|
|
132
|
+
```bash
|
|
133
|
+
pytest --cov=src/apcore --cov-report=html
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
### Lint and format
|
|
137
|
+
|
|
138
|
+
```bash
|
|
139
|
+
ruff check --fix src/ tests/
|
|
140
|
+
ruff format src/ tests/
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
### Type check
|
|
144
|
+
|
|
145
|
+
```bash
|
|
146
|
+
mypy src/ tests/
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
## 📄 License
|
|
151
|
+
|
|
152
|
+
Apache-2.0
|
|
153
|
+
|
|
154
|
+
## 🔗 Links
|
|
155
|
+
|
|
156
|
+
- **Documentation**: [docs/apcore](https://github.com/aipartnerup/apcore) - Complete documentation
|
|
157
|
+
- **Website**: [aipartnerup.com](https://aipartnerup.com)
|
|
158
|
+
- **GitHub**: [aipartnerup/apcore](https://github.com/aipartnerup/apcore)
|
|
159
|
+
- **PyPI**: [apcore](https://pypi.org/project/apcore/)
|
|
160
|
+
- **Issues**: [GitHub Issues](https://github.com/aipartnerup/apcore/issues)
|
|
161
|
+
- **Discussions**: [GitHub Discussions](https://github.com/aipartnerup/apcore/discussions)
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=68.0", "wheel"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "apcore"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Schema-driven module development framework for AI-perceivable interfaces"
|
|
9
|
+
requires-python = ">=3.11"
|
|
10
|
+
dependencies = [
|
|
11
|
+
"pydantic>=2.0",
|
|
12
|
+
"pyyaml>=6.0",
|
|
13
|
+
"pluggy>=1.0",
|
|
14
|
+
]
|
|
15
|
+
|
|
16
|
+
[dependency-groups]
|
|
17
|
+
dev = [
|
|
18
|
+
"pytest>=7.0",
|
|
19
|
+
"pytest-asyncio>=0.21",
|
|
20
|
+
"pytest-cov>=4.0",
|
|
21
|
+
"ruff>=0.1.0",
|
|
22
|
+
"mypy>=1.0",
|
|
23
|
+
]
|
|
24
|
+
|
|
25
|
+
[tool.setuptools.packages.find]
|
|
26
|
+
where = ["src"]
|
|
27
|
+
|
|
28
|
+
[tool.ruff]
|
|
29
|
+
line-length = 120
|
|
30
|
+
target-version = "py311"
|
|
31
|
+
|
|
32
|
+
[tool.mypy]
|
|
33
|
+
python_version = "3.11"
|
|
34
|
+
strict = true
|
|
35
|
+
|
|
36
|
+
[tool.pytest.ini_options]
|
|
37
|
+
asyncio_mode = "auto"
|
|
38
|
+
testpaths = ["tests"]
|
apcore-0.1.0/setup.cfg
ADDED
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
"""apcore - Schema-driven module development framework."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
# Core
|
|
6
|
+
from apcore.context import Context, Identity
|
|
7
|
+
from apcore.registry import Registry
|
|
8
|
+
from apcore.registry.types import ModuleDescriptor
|
|
9
|
+
from apcore.executor import Executor, redact_sensitive, REDACTED_VALUE
|
|
10
|
+
|
|
11
|
+
# Module types
|
|
12
|
+
from apcore.module import ModuleAnnotations, ModuleExample, ValidationResult
|
|
13
|
+
|
|
14
|
+
# Config
|
|
15
|
+
from apcore.config import Config
|
|
16
|
+
|
|
17
|
+
# Errors
|
|
18
|
+
from apcore.errors import (
|
|
19
|
+
ACLDeniedError,
|
|
20
|
+
CallDepthExceededError,
|
|
21
|
+
CallFrequencyExceededError,
|
|
22
|
+
CircularCallError,
|
|
23
|
+
CircularDependencyError,
|
|
24
|
+
ConfigError,
|
|
25
|
+
InvalidInputError,
|
|
26
|
+
ModuleError,
|
|
27
|
+
ModuleNotFoundError,
|
|
28
|
+
ModuleTimeoutError,
|
|
29
|
+
SchemaValidationError,
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
# ACL
|
|
33
|
+
from apcore.acl import ACL, ACLRule
|
|
34
|
+
|
|
35
|
+
# Middleware
|
|
36
|
+
from apcore.middleware import (
|
|
37
|
+
AfterMiddleware,
|
|
38
|
+
BeforeMiddleware,
|
|
39
|
+
LoggingMiddleware,
|
|
40
|
+
Middleware,
|
|
41
|
+
MiddlewareManager,
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
# Decorators
|
|
45
|
+
from apcore.decorator import FunctionModule, module
|
|
46
|
+
|
|
47
|
+
# Bindings
|
|
48
|
+
from apcore.bindings import BindingLoader
|
|
49
|
+
|
|
50
|
+
# Observability
|
|
51
|
+
from apcore.observability import (
|
|
52
|
+
ContextLogger,
|
|
53
|
+
InMemoryExporter,
|
|
54
|
+
MetricsCollector,
|
|
55
|
+
MetricsMiddleware,
|
|
56
|
+
ObsLoggingMiddleware,
|
|
57
|
+
Span,
|
|
58
|
+
StdoutExporter,
|
|
59
|
+
TracingMiddleware,
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
__version__ = "0.1.0"
|
|
63
|
+
|
|
64
|
+
__all__ = [
|
|
65
|
+
# Core
|
|
66
|
+
"Context",
|
|
67
|
+
"Identity",
|
|
68
|
+
"Registry",
|
|
69
|
+
"Executor",
|
|
70
|
+
# Module types
|
|
71
|
+
"ModuleAnnotations",
|
|
72
|
+
"ModuleExample",
|
|
73
|
+
"ValidationResult",
|
|
74
|
+
# Registry types
|
|
75
|
+
"ModuleDescriptor",
|
|
76
|
+
# Config
|
|
77
|
+
"Config",
|
|
78
|
+
# Errors
|
|
79
|
+
"ModuleError",
|
|
80
|
+
"SchemaValidationError",
|
|
81
|
+
"ACLDeniedError",
|
|
82
|
+
"ModuleNotFoundError",
|
|
83
|
+
"ConfigError",
|
|
84
|
+
"CircularDependencyError",
|
|
85
|
+
"InvalidInputError",
|
|
86
|
+
"ModuleTimeoutError",
|
|
87
|
+
"CallDepthExceededError",
|
|
88
|
+
"CircularCallError",
|
|
89
|
+
"CallFrequencyExceededError",
|
|
90
|
+
# ACL
|
|
91
|
+
"ACL",
|
|
92
|
+
"ACLRule",
|
|
93
|
+
# Middleware
|
|
94
|
+
"Middleware",
|
|
95
|
+
"MiddlewareManager",
|
|
96
|
+
"BeforeMiddleware",
|
|
97
|
+
"AfterMiddleware",
|
|
98
|
+
"LoggingMiddleware",
|
|
99
|
+
# Decorators
|
|
100
|
+
"module",
|
|
101
|
+
"FunctionModule",
|
|
102
|
+
# Bindings
|
|
103
|
+
"BindingLoader",
|
|
104
|
+
# Utilities
|
|
105
|
+
"redact_sensitive",
|
|
106
|
+
"REDACTED_VALUE",
|
|
107
|
+
# Observability
|
|
108
|
+
"TracingMiddleware",
|
|
109
|
+
"ContextLogger",
|
|
110
|
+
"ObsLoggingMiddleware",
|
|
111
|
+
"MetricsMiddleware",
|
|
112
|
+
"MetricsCollector",
|
|
113
|
+
"Span",
|
|
114
|
+
"StdoutExporter",
|
|
115
|
+
"InMemoryExporter",
|
|
116
|
+
]
|
|
@@ -0,0 +1,269 @@
|
|
|
1
|
+
"""ACL (Access Control List) types and implementation for apcore.
|
|
2
|
+
|
|
3
|
+
This module defines the ACLRule dataclass and the ACL class that enforces
|
|
4
|
+
pattern-based access control between modules.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import logging
|
|
10
|
+
import os
|
|
11
|
+
from dataclasses import dataclass
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
import yaml
|
|
15
|
+
|
|
16
|
+
from apcore.context import Context
|
|
17
|
+
from apcore.errors import ACLRuleError, ConfigNotFoundError
|
|
18
|
+
from apcore.utils.pattern import match_pattern
|
|
19
|
+
|
|
20
|
+
__all__ = ["ACLRule", "ACL"]
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass
|
|
24
|
+
class ACLRule:
|
|
25
|
+
"""A single access control rule.
|
|
26
|
+
|
|
27
|
+
Rules are evaluated in order by the ACL system. Each rule specifies
|
|
28
|
+
caller patterns, target patterns, and an effect (allow/deny).
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
callers: list[str]
|
|
32
|
+
targets: list[str]
|
|
33
|
+
effect: str
|
|
34
|
+
description: str = ""
|
|
35
|
+
conditions: dict[str, Any] | None = None
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class ACL:
|
|
39
|
+
"""Access Control List with pattern-based rules and first-match-wins evaluation.
|
|
40
|
+
|
|
41
|
+
Implements PROTOCOL_SPEC section 6 for module access control.
|
|
42
|
+
|
|
43
|
+
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().
|
|
47
|
+
"""
|
|
48
|
+
|
|
49
|
+
def __init__(self, rules: list[ACLRule], default_effect: str = "deny") -> None:
|
|
50
|
+
"""Initialize ACL with ordered rules and a default effect.
|
|
51
|
+
|
|
52
|
+
Args:
|
|
53
|
+
rules: Ordered list of ACL rules (first match wins).
|
|
54
|
+
default_effect: Effect when no rule matches ('allow' or 'deny').
|
|
55
|
+
"""
|
|
56
|
+
self._rules: list[ACLRule] = list(rules)
|
|
57
|
+
self._default_effect: str = default_effect
|
|
58
|
+
self._yaml_path: str | None = None
|
|
59
|
+
self.debug: bool = False
|
|
60
|
+
self._logger: logging.Logger = logging.getLogger("apcore.acl")
|
|
61
|
+
|
|
62
|
+
@classmethod
|
|
63
|
+
def load(cls, yaml_path: str) -> ACL:
|
|
64
|
+
"""Load ACL configuration from a YAML file.
|
|
65
|
+
|
|
66
|
+
Args:
|
|
67
|
+
yaml_path: Path to the YAML configuration file.
|
|
68
|
+
|
|
69
|
+
Returns:
|
|
70
|
+
A new ACL instance configured from the YAML file.
|
|
71
|
+
|
|
72
|
+
Raises:
|
|
73
|
+
ConfigNotFoundError: If the file does not exist.
|
|
74
|
+
ACLRuleError: If the YAML is invalid or has structural errors.
|
|
75
|
+
"""
|
|
76
|
+
if not os.path.isfile(yaml_path):
|
|
77
|
+
raise ConfigNotFoundError(config_path=yaml_path)
|
|
78
|
+
|
|
79
|
+
with open(yaml_path) as f:
|
|
80
|
+
try:
|
|
81
|
+
data = yaml.safe_load(f)
|
|
82
|
+
except yaml.YAMLError as e:
|
|
83
|
+
raise ACLRuleError(f"Invalid YAML in {yaml_path}: {e}") from e
|
|
84
|
+
|
|
85
|
+
if not isinstance(data, dict):
|
|
86
|
+
raise ACLRuleError(f"ACL config must be a mapping, got {type(data).__name__}")
|
|
87
|
+
|
|
88
|
+
if "rules" not in data:
|
|
89
|
+
raise ACLRuleError("ACL config missing required 'rules' key")
|
|
90
|
+
|
|
91
|
+
raw_rules = data["rules"]
|
|
92
|
+
if not isinstance(raw_rules, list):
|
|
93
|
+
raise ACLRuleError(f"'rules' must be a list, got {type(raw_rules).__name__}")
|
|
94
|
+
|
|
95
|
+
default_effect: str = data.get("default_effect", "deny")
|
|
96
|
+
rules: list[ACLRule] = []
|
|
97
|
+
|
|
98
|
+
for i, raw_rule in enumerate(raw_rules):
|
|
99
|
+
if not isinstance(raw_rule, dict):
|
|
100
|
+
raise ACLRuleError(f"Rule {i} must be a mapping, got {type(raw_rule).__name__}")
|
|
101
|
+
|
|
102
|
+
for key in ("callers", "targets", "effect"):
|
|
103
|
+
if key not in raw_rule:
|
|
104
|
+
raise ACLRuleError(f"Rule {i} missing required key '{key}'")
|
|
105
|
+
|
|
106
|
+
effect = raw_rule["effect"]
|
|
107
|
+
if effect not in ("allow", "deny"):
|
|
108
|
+
raise ACLRuleError(f"Rule {i} has invalid effect '{effect}', must be 'allow' or 'deny'")
|
|
109
|
+
|
|
110
|
+
callers = raw_rule["callers"]
|
|
111
|
+
if not isinstance(callers, list):
|
|
112
|
+
raise ACLRuleError(f"Rule {i} 'callers' must be a list, got {type(callers).__name__}")
|
|
113
|
+
|
|
114
|
+
targets = raw_rule["targets"]
|
|
115
|
+
if not isinstance(targets, list):
|
|
116
|
+
raise ACLRuleError(f"Rule {i} 'targets' must be a list, got {type(targets).__name__}")
|
|
117
|
+
|
|
118
|
+
rules.append(
|
|
119
|
+
ACLRule(
|
|
120
|
+
callers=callers,
|
|
121
|
+
targets=targets,
|
|
122
|
+
effect=effect,
|
|
123
|
+
description=raw_rule.get("description", ""),
|
|
124
|
+
conditions=raw_rule.get("conditions"),
|
|
125
|
+
)
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
acl = cls(rules=rules, default_effect=default_effect)
|
|
129
|
+
acl._yaml_path = yaml_path
|
|
130
|
+
return acl
|
|
131
|
+
|
|
132
|
+
def check(
|
|
133
|
+
self,
|
|
134
|
+
caller_id: str | None,
|
|
135
|
+
target_id: str,
|
|
136
|
+
context: Context | None = None,
|
|
137
|
+
) -> bool:
|
|
138
|
+
"""Check if a call from caller_id to target_id is allowed.
|
|
139
|
+
|
|
140
|
+
Args:
|
|
141
|
+
caller_id: The calling module ID, or None for external calls.
|
|
142
|
+
target_id: The target module ID being called.
|
|
143
|
+
context: Optional execution context for conditional rules.
|
|
144
|
+
|
|
145
|
+
Returns:
|
|
146
|
+
True if the call is allowed, False if denied.
|
|
147
|
+
"""
|
|
148
|
+
effective_caller = "@external" if caller_id is None else caller_id
|
|
149
|
+
|
|
150
|
+
for rule in self._rules:
|
|
151
|
+
if self._matches_rule(rule, effective_caller, target_id, context):
|
|
152
|
+
decision = rule.effect == "allow"
|
|
153
|
+
self._logger.debug(
|
|
154
|
+
"ACL check: caller=%s target=%s decision=%s rule=%s",
|
|
155
|
+
caller_id,
|
|
156
|
+
target_id,
|
|
157
|
+
"allow" if decision else "deny",
|
|
158
|
+
rule.description or "(no description)",
|
|
159
|
+
)
|
|
160
|
+
return decision
|
|
161
|
+
|
|
162
|
+
default_decision = self._default_effect == "allow"
|
|
163
|
+
self._logger.debug(
|
|
164
|
+
"ACL check: caller=%s target=%s decision=%s rule=default",
|
|
165
|
+
caller_id,
|
|
166
|
+
target_id,
|
|
167
|
+
"allow" if default_decision else "deny",
|
|
168
|
+
)
|
|
169
|
+
return default_decision
|
|
170
|
+
|
|
171
|
+
def _match_pattern(self, pattern: str, value: str, context: Context | None = None) -> bool:
|
|
172
|
+
"""Match a single pattern against a value, with special pattern handling.
|
|
173
|
+
|
|
174
|
+
Handles @external and @system patterns locally, delegates all
|
|
175
|
+
other patterns to the foundation match_pattern() utility.
|
|
176
|
+
"""
|
|
177
|
+
if pattern == "@external":
|
|
178
|
+
return value == "@external"
|
|
179
|
+
if pattern == "@system":
|
|
180
|
+
return context is not None and context.identity is not None and context.identity.type == "system"
|
|
181
|
+
return match_pattern(pattern, value)
|
|
182
|
+
|
|
183
|
+
def _matches_rule(
|
|
184
|
+
self,
|
|
185
|
+
rule: ACLRule,
|
|
186
|
+
caller: str,
|
|
187
|
+
target: str,
|
|
188
|
+
context: Context | None,
|
|
189
|
+
) -> bool:
|
|
190
|
+
"""Check if a single rule matches the caller and target.
|
|
191
|
+
|
|
192
|
+
All of the following must be true for a match:
|
|
193
|
+
1. At least one caller pattern matches the caller (OR logic).
|
|
194
|
+
2. At least one target pattern matches the target (OR logic).
|
|
195
|
+
3. If conditions are present, they must all be satisfied.
|
|
196
|
+
"""
|
|
197
|
+
caller_match = any(self._match_pattern(p, caller, context) for p in rule.callers)
|
|
198
|
+
if not caller_match:
|
|
199
|
+
return False
|
|
200
|
+
|
|
201
|
+
target_match = any(self._match_pattern(p, target, context) for p in rule.targets)
|
|
202
|
+
if not target_match:
|
|
203
|
+
return False
|
|
204
|
+
|
|
205
|
+
if rule.conditions is not None:
|
|
206
|
+
if not self._check_conditions(rule.conditions, context):
|
|
207
|
+
return False
|
|
208
|
+
|
|
209
|
+
return True
|
|
210
|
+
|
|
211
|
+
def _check_conditions(self, conditions: dict[str, Any], context: Context | None) -> bool:
|
|
212
|
+
"""Evaluate conditional rule parameters against the execution context.
|
|
213
|
+
|
|
214
|
+
Returns False if any condition is not satisfied.
|
|
215
|
+
"""
|
|
216
|
+
if context is None:
|
|
217
|
+
return False
|
|
218
|
+
|
|
219
|
+
if "identity_types" in conditions:
|
|
220
|
+
if context.identity is None or context.identity.type not in conditions["identity_types"]:
|
|
221
|
+
return False
|
|
222
|
+
|
|
223
|
+
if "roles" in conditions:
|
|
224
|
+
if context.identity is None:
|
|
225
|
+
return False
|
|
226
|
+
if not set(context.identity.roles) & set(conditions["roles"]):
|
|
227
|
+
return False
|
|
228
|
+
|
|
229
|
+
if "max_call_depth" in conditions:
|
|
230
|
+
if len(context.call_chain) > conditions["max_call_depth"]:
|
|
231
|
+
return False
|
|
232
|
+
|
|
233
|
+
return True
|
|
234
|
+
|
|
235
|
+
def add_rule(self, rule: ACLRule) -> None:
|
|
236
|
+
"""Add a rule at position 0 (highest priority).
|
|
237
|
+
|
|
238
|
+
Args:
|
|
239
|
+
rule: The ACLRule to add.
|
|
240
|
+
"""
|
|
241
|
+
self._rules.insert(0, rule)
|
|
242
|
+
|
|
243
|
+
def remove_rule(self, callers: list[str], targets: list[str]) -> bool:
|
|
244
|
+
"""Remove the first rule matching the given callers and targets.
|
|
245
|
+
|
|
246
|
+
Args:
|
|
247
|
+
callers: The caller patterns to match.
|
|
248
|
+
targets: The target patterns to match.
|
|
249
|
+
|
|
250
|
+
Returns:
|
|
251
|
+
True if a rule was found and removed, False otherwise.
|
|
252
|
+
"""
|
|
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
|
|
258
|
+
|
|
259
|
+
def reload(self) -> None:
|
|
260
|
+
"""Re-read the ACL from the original YAML file.
|
|
261
|
+
|
|
262
|
+
Only works if the ACL was created via ACL.load().
|
|
263
|
+
Raises ACLRuleError if no YAML path was stored.
|
|
264
|
+
"""
|
|
265
|
+
if self._yaml_path is None:
|
|
266
|
+
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
|