pypredicate-temporal 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.
- pypredicate_temporal-0.1.0/.gitignore +87 -0
- pypredicate_temporal-0.1.0/LICENSE +21 -0
- pypredicate_temporal-0.1.0/PKG-INFO +208 -0
- pypredicate_temporal-0.1.0/README.md +174 -0
- pypredicate_temporal-0.1.0/examples/README.md +73 -0
- pypredicate_temporal-0.1.0/pyproject.toml +120 -0
- pypredicate_temporal-0.1.0/src/predicate_temporal/__init__.py +13 -0
- pypredicate_temporal-0.1.0/src/predicate_temporal/interceptor.py +196 -0
- pypredicate_temporal-0.1.0/src/predicate_temporal/py.typed +0 -0
- pypredicate_temporal-0.1.0/tests/__init__.py +0 -0
- pypredicate_temporal-0.1.0/tests/test_interceptor.py +251 -0
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
# Byte-compiled / optimized / DLL files
|
|
2
|
+
__pycache__/
|
|
3
|
+
*.py[cod]
|
|
4
|
+
*$py.class
|
|
5
|
+
|
|
6
|
+
# C extensions
|
|
7
|
+
*.so
|
|
8
|
+
|
|
9
|
+
# Distribution / packaging
|
|
10
|
+
.Python
|
|
11
|
+
build/
|
|
12
|
+
develop-eggs/
|
|
13
|
+
dist/
|
|
14
|
+
downloads/
|
|
15
|
+
eggs/
|
|
16
|
+
.eggs/
|
|
17
|
+
lib/
|
|
18
|
+
lib64/
|
|
19
|
+
parts/
|
|
20
|
+
sdist/
|
|
21
|
+
var/
|
|
22
|
+
wheels/
|
|
23
|
+
share/python-wheels/
|
|
24
|
+
*.egg-info/
|
|
25
|
+
.installed.cfg
|
|
26
|
+
*.egg
|
|
27
|
+
MANIFEST
|
|
28
|
+
|
|
29
|
+
# PyInstaller
|
|
30
|
+
*.manifest
|
|
31
|
+
*.spec
|
|
32
|
+
|
|
33
|
+
# Installer logs
|
|
34
|
+
pip-log.txt
|
|
35
|
+
pip-delete-this-directory.txt
|
|
36
|
+
|
|
37
|
+
# Unit test / coverage reports
|
|
38
|
+
htmlcov/
|
|
39
|
+
.tox/
|
|
40
|
+
.nox/
|
|
41
|
+
.coverage
|
|
42
|
+
.coverage.*
|
|
43
|
+
.cache
|
|
44
|
+
nosetests.xml
|
|
45
|
+
coverage.xml
|
|
46
|
+
*.cover
|
|
47
|
+
*.py,cover
|
|
48
|
+
.hypothesis/
|
|
49
|
+
.pytest_cache/
|
|
50
|
+
cover/
|
|
51
|
+
|
|
52
|
+
# Translations
|
|
53
|
+
*.mo
|
|
54
|
+
*.pot
|
|
55
|
+
|
|
56
|
+
# Environments
|
|
57
|
+
.env
|
|
58
|
+
.venv
|
|
59
|
+
env/
|
|
60
|
+
venv/
|
|
61
|
+
ENV/
|
|
62
|
+
env.bak/
|
|
63
|
+
venv.bak/
|
|
64
|
+
|
|
65
|
+
# pyenv
|
|
66
|
+
.python-version
|
|
67
|
+
|
|
68
|
+
# IDEs
|
|
69
|
+
.idea/
|
|
70
|
+
.vscode/
|
|
71
|
+
*.swp
|
|
72
|
+
*.swo
|
|
73
|
+
*~
|
|
74
|
+
|
|
75
|
+
# mypy
|
|
76
|
+
.mypy_cache/
|
|
77
|
+
.dmypy.json
|
|
78
|
+
dmypy.json
|
|
79
|
+
|
|
80
|
+
# Ruff
|
|
81
|
+
.ruff_cache/
|
|
82
|
+
|
|
83
|
+
# macOS
|
|
84
|
+
.DS_Store
|
|
85
|
+
|
|
86
|
+
# Jupyter
|
|
87
|
+
.ipynb_checkpoints/
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2024 Predicate Systems
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: pypredicate-temporal
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Temporal.io Worker Interceptor for Predicate Authority Zero-Trust authorization
|
|
5
|
+
Project-URL: Homepage, https://github.com/PredicateSystems/predicate-temporal-python
|
|
6
|
+
Project-URL: Documentation, https://docs.predicatesystems.dev/integrations/temporal
|
|
7
|
+
Project-URL: Repository, https://github.com/PredicateSystems/predicate-temporal-python
|
|
8
|
+
Project-URL: Issues, https://github.com/PredicateSystems/predicate-temporal-python/issues
|
|
9
|
+
Author-email: Predicate Systems <hello@predicatesystems.dev>
|
|
10
|
+
License-Expression: MIT
|
|
11
|
+
License-File: LICENSE
|
|
12
|
+
Keywords: ai-agents,authorization,predicate-authority,security,temporal,zero-trust
|
|
13
|
+
Classifier: Development Status :: 4 - Beta
|
|
14
|
+
Classifier: Intended Audience :: Developers
|
|
15
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
16
|
+
Classifier: Operating System :: OS Independent
|
|
17
|
+
Classifier: Programming Language :: Python :: 3
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
21
|
+
Classifier: Topic :: Security
|
|
22
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
23
|
+
Classifier: Typing :: Typed
|
|
24
|
+
Requires-Python: >=3.11
|
|
25
|
+
Requires-Dist: predicate-authority>=0.1.0
|
|
26
|
+
Requires-Dist: temporalio>=1.5.0
|
|
27
|
+
Provides-Extra: dev
|
|
28
|
+
Requires-Dist: mypy>=1.8.0; extra == 'dev'
|
|
29
|
+
Requires-Dist: pytest-asyncio>=0.23.0; extra == 'dev'
|
|
30
|
+
Requires-Dist: pytest-cov>=4.1.0; extra == 'dev'
|
|
31
|
+
Requires-Dist: pytest>=8.0.0; extra == 'dev'
|
|
32
|
+
Requires-Dist: ruff>=0.2.0; extra == 'dev'
|
|
33
|
+
Description-Content-Type: text/markdown
|
|
34
|
+
|
|
35
|
+
# predicate-temporal
|
|
36
|
+
|
|
37
|
+
Temporal.io Worker Interceptor for Predicate Authority Zero-Trust authorization.
|
|
38
|
+
|
|
39
|
+
This package provides a pre-execution security gate for all Temporal Activities, enforcing cryptographic authorization mandates before any activity code runs.
|
|
40
|
+
|
|
41
|
+
## Prerequisites
|
|
42
|
+
|
|
43
|
+
This package requires the **Predicate Authority Sidecar** daemon to be running. The sidecar is a lightweight Rust binary that handles policy evaluation and mandate signing.
|
|
44
|
+
|
|
45
|
+
| Resource | Link |
|
|
46
|
+
|----------|------|
|
|
47
|
+
| Sidecar Repository | [github.com/PredicateSystems/predicate-authority-sidecar](https://github.com/PredicateSystems/predicate-authority-sidecar) |
|
|
48
|
+
| Download Binaries | [Latest Releases](https://github.com/PredicateSystems/predicate-authority-sidecar/releases) |
|
|
49
|
+
| License | MIT / Apache 2.0 |
|
|
50
|
+
|
|
51
|
+
### Quick Sidecar Setup
|
|
52
|
+
|
|
53
|
+
```bash
|
|
54
|
+
# Download the latest release for your platform
|
|
55
|
+
# Linux x64, macOS x64/ARM64, Windows x64 available
|
|
56
|
+
|
|
57
|
+
# Extract and run
|
|
58
|
+
tar -xzf predicate-authorityd-*.tar.gz
|
|
59
|
+
chmod +x predicate-authorityd
|
|
60
|
+
|
|
61
|
+
# Start with a policy file
|
|
62
|
+
./predicate-authorityd --port 8787 --policy-file policy.json
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
## Installation
|
|
66
|
+
|
|
67
|
+
```bash
|
|
68
|
+
pip install predicate-temporal
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
## Quick Start
|
|
72
|
+
|
|
73
|
+
```python
|
|
74
|
+
from temporalio.worker import Worker
|
|
75
|
+
from predicate_temporal import PredicateInterceptor
|
|
76
|
+
from predicate_authority import AuthorityClient
|
|
77
|
+
|
|
78
|
+
# Initialize the Predicate Authority client
|
|
79
|
+
ctx = AuthorityClient.from_env()
|
|
80
|
+
|
|
81
|
+
# Create the interceptor
|
|
82
|
+
interceptor = PredicateInterceptor(
|
|
83
|
+
authority_client=ctx.client,
|
|
84
|
+
principal="temporal-worker",
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
# Create worker with the interceptor
|
|
88
|
+
worker = Worker(
|
|
89
|
+
client=temporal_client,
|
|
90
|
+
task_queue="my-task-queue",
|
|
91
|
+
workflows=[MyWorkflow],
|
|
92
|
+
activities=[my_activity],
|
|
93
|
+
interceptors=[interceptor],
|
|
94
|
+
)
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
## How It Works
|
|
98
|
+
|
|
99
|
+
The interceptor sits in the Temporal activity execution pipeline:
|
|
100
|
+
|
|
101
|
+
1. Temporal dispatches an activity to your worker
|
|
102
|
+
2. **Before** the activity code runs, the interceptor extracts:
|
|
103
|
+
- Activity name (action)
|
|
104
|
+
- Activity arguments (context)
|
|
105
|
+
3. The interceptor calls `AuthorityClient.authorize()` to request a mandate
|
|
106
|
+
4. If **denied**: raises `PermissionError` - activity never executes
|
|
107
|
+
5. If **approved**: activity proceeds normally
|
|
108
|
+
|
|
109
|
+
This ensures that no untrusted code or payload reaches your OS until it has been cryptographically authorized.
|
|
110
|
+
|
|
111
|
+
## Configuration
|
|
112
|
+
|
|
113
|
+
### Environment Variables
|
|
114
|
+
|
|
115
|
+
Set these environment variables for the Authority client:
|
|
116
|
+
|
|
117
|
+
```bash
|
|
118
|
+
export PREDICATE_AUTHORITY_POLICY_FILE=/path/to/policy.json
|
|
119
|
+
export PREDICATE_AUTHORITY_SIGNING_KEY=your-secret-key
|
|
120
|
+
export PREDICATE_AUTHORITY_MANDATE_TTL_SECONDS=300
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
### Policy File
|
|
124
|
+
|
|
125
|
+
Create a policy file that defines allowed activities:
|
|
126
|
+
|
|
127
|
+
```json
|
|
128
|
+
{
|
|
129
|
+
"rules": [
|
|
130
|
+
{
|
|
131
|
+
"name": "allow-safe-activities",
|
|
132
|
+
"effect": "allow",
|
|
133
|
+
"principals": ["temporal-worker"],
|
|
134
|
+
"actions": ["process_order", "send_notification"],
|
|
135
|
+
"resources": ["*"]
|
|
136
|
+
},
|
|
137
|
+
{
|
|
138
|
+
"name": "deny-dangerous-activities",
|
|
139
|
+
"effect": "deny",
|
|
140
|
+
"principals": ["*"],
|
|
141
|
+
"actions": ["delete_*", "admin_*"],
|
|
142
|
+
"resources": ["*"]
|
|
143
|
+
}
|
|
144
|
+
]
|
|
145
|
+
}
|
|
146
|
+
```
|
|
147
|
+
|
|
148
|
+
## API Reference
|
|
149
|
+
|
|
150
|
+
### PredicateInterceptor
|
|
151
|
+
|
|
152
|
+
```python
|
|
153
|
+
PredicateInterceptor(
|
|
154
|
+
authority_client: AuthorityClient,
|
|
155
|
+
principal: str = "temporal-worker",
|
|
156
|
+
tenant_id: str | None = None,
|
|
157
|
+
session_id: str | None = None,
|
|
158
|
+
)
|
|
159
|
+
```
|
|
160
|
+
|
|
161
|
+
**Parameters:**
|
|
162
|
+
|
|
163
|
+
- `authority_client`: The Predicate Authority client instance
|
|
164
|
+
- `principal`: Principal ID used for authorization requests (default: "temporal-worker")
|
|
165
|
+
- `tenant_id`: Optional tenant ID for multi-tenant setups
|
|
166
|
+
- `session_id`: Optional session ID for request correlation
|
|
167
|
+
|
|
168
|
+
### PredicateActivityInterceptor
|
|
169
|
+
|
|
170
|
+
The inbound interceptor that performs the actual authorization check. Created automatically by `PredicateInterceptor`.
|
|
171
|
+
|
|
172
|
+
## Error Handling
|
|
173
|
+
|
|
174
|
+
When authorization is denied, the interceptor raises a `PermissionError`:
|
|
175
|
+
|
|
176
|
+
```python
|
|
177
|
+
try:
|
|
178
|
+
await workflow.execute_activity(
|
|
179
|
+
dangerous_activity,
|
|
180
|
+
args,
|
|
181
|
+
start_to_close_timeout=timedelta(seconds=30),
|
|
182
|
+
)
|
|
183
|
+
except ActivityError as e:
|
|
184
|
+
if isinstance(e.cause, ApplicationError):
|
|
185
|
+
# Handle authorization denial
|
|
186
|
+
print(f"Activity blocked: {e.cause.message}")
|
|
187
|
+
```
|
|
188
|
+
|
|
189
|
+
## Development
|
|
190
|
+
|
|
191
|
+
```bash
|
|
192
|
+
# Install dev dependencies
|
|
193
|
+
pip install -e ".[dev]"
|
|
194
|
+
|
|
195
|
+
# Run tests
|
|
196
|
+
pytest
|
|
197
|
+
|
|
198
|
+
# Type checking
|
|
199
|
+
mypy src
|
|
200
|
+
|
|
201
|
+
# Linting
|
|
202
|
+
ruff check src tests
|
|
203
|
+
ruff format src tests
|
|
204
|
+
```
|
|
205
|
+
|
|
206
|
+
## License
|
|
207
|
+
|
|
208
|
+
MIT
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
# predicate-temporal
|
|
2
|
+
|
|
3
|
+
Temporal.io Worker Interceptor for Predicate Authority Zero-Trust authorization.
|
|
4
|
+
|
|
5
|
+
This package provides a pre-execution security gate for all Temporal Activities, enforcing cryptographic authorization mandates before any activity code runs.
|
|
6
|
+
|
|
7
|
+
## Prerequisites
|
|
8
|
+
|
|
9
|
+
This package requires the **Predicate Authority Sidecar** daemon to be running. The sidecar is a lightweight Rust binary that handles policy evaluation and mandate signing.
|
|
10
|
+
|
|
11
|
+
| Resource | Link |
|
|
12
|
+
|----------|------|
|
|
13
|
+
| Sidecar Repository | [github.com/PredicateSystems/predicate-authority-sidecar](https://github.com/PredicateSystems/predicate-authority-sidecar) |
|
|
14
|
+
| Download Binaries | [Latest Releases](https://github.com/PredicateSystems/predicate-authority-sidecar/releases) |
|
|
15
|
+
| License | MIT / Apache 2.0 |
|
|
16
|
+
|
|
17
|
+
### Quick Sidecar Setup
|
|
18
|
+
|
|
19
|
+
```bash
|
|
20
|
+
# Download the latest release for your platform
|
|
21
|
+
# Linux x64, macOS x64/ARM64, Windows x64 available
|
|
22
|
+
|
|
23
|
+
# Extract and run
|
|
24
|
+
tar -xzf predicate-authorityd-*.tar.gz
|
|
25
|
+
chmod +x predicate-authorityd
|
|
26
|
+
|
|
27
|
+
# Start with a policy file
|
|
28
|
+
./predicate-authorityd --port 8787 --policy-file policy.json
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
## Installation
|
|
32
|
+
|
|
33
|
+
```bash
|
|
34
|
+
pip install predicate-temporal
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
## Quick Start
|
|
38
|
+
|
|
39
|
+
```python
|
|
40
|
+
from temporalio.worker import Worker
|
|
41
|
+
from predicate_temporal import PredicateInterceptor
|
|
42
|
+
from predicate_authority import AuthorityClient
|
|
43
|
+
|
|
44
|
+
# Initialize the Predicate Authority client
|
|
45
|
+
ctx = AuthorityClient.from_env()
|
|
46
|
+
|
|
47
|
+
# Create the interceptor
|
|
48
|
+
interceptor = PredicateInterceptor(
|
|
49
|
+
authority_client=ctx.client,
|
|
50
|
+
principal="temporal-worker",
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
# Create worker with the interceptor
|
|
54
|
+
worker = Worker(
|
|
55
|
+
client=temporal_client,
|
|
56
|
+
task_queue="my-task-queue",
|
|
57
|
+
workflows=[MyWorkflow],
|
|
58
|
+
activities=[my_activity],
|
|
59
|
+
interceptors=[interceptor],
|
|
60
|
+
)
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
## How It Works
|
|
64
|
+
|
|
65
|
+
The interceptor sits in the Temporal activity execution pipeline:
|
|
66
|
+
|
|
67
|
+
1. Temporal dispatches an activity to your worker
|
|
68
|
+
2. **Before** the activity code runs, the interceptor extracts:
|
|
69
|
+
- Activity name (action)
|
|
70
|
+
- Activity arguments (context)
|
|
71
|
+
3. The interceptor calls `AuthorityClient.authorize()` to request a mandate
|
|
72
|
+
4. If **denied**: raises `PermissionError` - activity never executes
|
|
73
|
+
5. If **approved**: activity proceeds normally
|
|
74
|
+
|
|
75
|
+
This ensures that no untrusted code or payload reaches your OS until it has been cryptographically authorized.
|
|
76
|
+
|
|
77
|
+
## Configuration
|
|
78
|
+
|
|
79
|
+
### Environment Variables
|
|
80
|
+
|
|
81
|
+
Set these environment variables for the Authority client:
|
|
82
|
+
|
|
83
|
+
```bash
|
|
84
|
+
export PREDICATE_AUTHORITY_POLICY_FILE=/path/to/policy.json
|
|
85
|
+
export PREDICATE_AUTHORITY_SIGNING_KEY=your-secret-key
|
|
86
|
+
export PREDICATE_AUTHORITY_MANDATE_TTL_SECONDS=300
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
### Policy File
|
|
90
|
+
|
|
91
|
+
Create a policy file that defines allowed activities:
|
|
92
|
+
|
|
93
|
+
```json
|
|
94
|
+
{
|
|
95
|
+
"rules": [
|
|
96
|
+
{
|
|
97
|
+
"name": "allow-safe-activities",
|
|
98
|
+
"effect": "allow",
|
|
99
|
+
"principals": ["temporal-worker"],
|
|
100
|
+
"actions": ["process_order", "send_notification"],
|
|
101
|
+
"resources": ["*"]
|
|
102
|
+
},
|
|
103
|
+
{
|
|
104
|
+
"name": "deny-dangerous-activities",
|
|
105
|
+
"effect": "deny",
|
|
106
|
+
"principals": ["*"],
|
|
107
|
+
"actions": ["delete_*", "admin_*"],
|
|
108
|
+
"resources": ["*"]
|
|
109
|
+
}
|
|
110
|
+
]
|
|
111
|
+
}
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
## API Reference
|
|
115
|
+
|
|
116
|
+
### PredicateInterceptor
|
|
117
|
+
|
|
118
|
+
```python
|
|
119
|
+
PredicateInterceptor(
|
|
120
|
+
authority_client: AuthorityClient,
|
|
121
|
+
principal: str = "temporal-worker",
|
|
122
|
+
tenant_id: str | None = None,
|
|
123
|
+
session_id: str | None = None,
|
|
124
|
+
)
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
**Parameters:**
|
|
128
|
+
|
|
129
|
+
- `authority_client`: The Predicate Authority client instance
|
|
130
|
+
- `principal`: Principal ID used for authorization requests (default: "temporal-worker")
|
|
131
|
+
- `tenant_id`: Optional tenant ID for multi-tenant setups
|
|
132
|
+
- `session_id`: Optional session ID for request correlation
|
|
133
|
+
|
|
134
|
+
### PredicateActivityInterceptor
|
|
135
|
+
|
|
136
|
+
The inbound interceptor that performs the actual authorization check. Created automatically by `PredicateInterceptor`.
|
|
137
|
+
|
|
138
|
+
## Error Handling
|
|
139
|
+
|
|
140
|
+
When authorization is denied, the interceptor raises a `PermissionError`:
|
|
141
|
+
|
|
142
|
+
```python
|
|
143
|
+
try:
|
|
144
|
+
await workflow.execute_activity(
|
|
145
|
+
dangerous_activity,
|
|
146
|
+
args,
|
|
147
|
+
start_to_close_timeout=timedelta(seconds=30),
|
|
148
|
+
)
|
|
149
|
+
except ActivityError as e:
|
|
150
|
+
if isinstance(e.cause, ApplicationError):
|
|
151
|
+
# Handle authorization denial
|
|
152
|
+
print(f"Activity blocked: {e.cause.message}")
|
|
153
|
+
```
|
|
154
|
+
|
|
155
|
+
## Development
|
|
156
|
+
|
|
157
|
+
```bash
|
|
158
|
+
# Install dev dependencies
|
|
159
|
+
pip install -e ".[dev]"
|
|
160
|
+
|
|
161
|
+
# Run tests
|
|
162
|
+
pytest
|
|
163
|
+
|
|
164
|
+
# Type checking
|
|
165
|
+
mypy src
|
|
166
|
+
|
|
167
|
+
# Linting
|
|
168
|
+
ruff check src tests
|
|
169
|
+
ruff format src tests
|
|
170
|
+
```
|
|
171
|
+
|
|
172
|
+
## License
|
|
173
|
+
|
|
174
|
+
MIT
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
# Predicate Temporal Python Examples
|
|
2
|
+
|
|
3
|
+
This directory contains examples demonstrating how to use `predicate-temporal` to secure Temporal activities.
|
|
4
|
+
|
|
5
|
+
## Prerequisites
|
|
6
|
+
|
|
7
|
+
1. Install dependencies:
|
|
8
|
+
```bash
|
|
9
|
+
pip install temporalio predicate-authority predicate-temporal
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
2. Start the Predicate Authority daemon:
|
|
13
|
+
```bash
|
|
14
|
+
# Download from https://github.com/PredicateSystems/predicate-authority-sidecar/releases
|
|
15
|
+
./predicate-authorityd --port 8787 --policy-file policy.json
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
3. Start a local Temporal server:
|
|
19
|
+
```bash
|
|
20
|
+
temporal server start-dev
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
## Examples
|
|
24
|
+
|
|
25
|
+
### Basic Example (`basic_worker.py`)
|
|
26
|
+
|
|
27
|
+
A minimal example showing:
|
|
28
|
+
- Setting up activities with Predicate interceptor
|
|
29
|
+
- Running a workflow that executes secured activities
|
|
30
|
+
- Handling authorization denials
|
|
31
|
+
|
|
32
|
+
Run with:
|
|
33
|
+
```bash
|
|
34
|
+
python basic_worker.py
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
### E-commerce Example (`ecommerce_worker.py`)
|
|
38
|
+
|
|
39
|
+
A realistic e-commerce scenario with:
|
|
40
|
+
- Order processing activities
|
|
41
|
+
- Payment handling
|
|
42
|
+
- Inventory management
|
|
43
|
+
- Policy-based access control
|
|
44
|
+
|
|
45
|
+
Run with:
|
|
46
|
+
```bash
|
|
47
|
+
python ecommerce_worker.py
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
## Policy File
|
|
51
|
+
|
|
52
|
+
The `policy.json` file defines which activities are allowed. Example:
|
|
53
|
+
|
|
54
|
+
```json
|
|
55
|
+
{
|
|
56
|
+
"rules": [
|
|
57
|
+
{
|
|
58
|
+
"name": "allow-order-processing",
|
|
59
|
+
"effect": "allow",
|
|
60
|
+
"principals": ["temporal-worker"],
|
|
61
|
+
"actions": ["process_order", "check_inventory", "send_confirmation"],
|
|
62
|
+
"resources": ["*"]
|
|
63
|
+
},
|
|
64
|
+
{
|
|
65
|
+
"name": "deny-admin-actions",
|
|
66
|
+
"effect": "deny",
|
|
67
|
+
"principals": ["*"],
|
|
68
|
+
"actions": ["delete_*", "admin_*"],
|
|
69
|
+
"resources": ["*"]
|
|
70
|
+
}
|
|
71
|
+
]
|
|
72
|
+
}
|
|
73
|
+
```
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "pypredicate-temporal"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Temporal.io Worker Interceptor for Predicate Authority Zero-Trust authorization"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = "MIT"
|
|
11
|
+
requires-python = ">=3.11"
|
|
12
|
+
authors = [
|
|
13
|
+
{ name = "Predicate Systems", email = "hello@predicatesystems.dev" }
|
|
14
|
+
]
|
|
15
|
+
keywords = [
|
|
16
|
+
"temporal",
|
|
17
|
+
"authorization",
|
|
18
|
+
"zero-trust",
|
|
19
|
+
"security",
|
|
20
|
+
"ai-agents",
|
|
21
|
+
"predicate-authority"
|
|
22
|
+
]
|
|
23
|
+
classifiers = [
|
|
24
|
+
"Development Status :: 4 - Beta",
|
|
25
|
+
"Intended Audience :: Developers",
|
|
26
|
+
"License :: OSI Approved :: MIT License",
|
|
27
|
+
"Operating System :: OS Independent",
|
|
28
|
+
"Programming Language :: Python :: 3",
|
|
29
|
+
"Programming Language :: Python :: 3.11",
|
|
30
|
+
"Programming Language :: Python :: 3.12",
|
|
31
|
+
"Programming Language :: Python :: 3.13",
|
|
32
|
+
"Topic :: Security",
|
|
33
|
+
"Topic :: Software Development :: Libraries :: Python Modules",
|
|
34
|
+
"Typing :: Typed",
|
|
35
|
+
]
|
|
36
|
+
dependencies = [
|
|
37
|
+
"temporalio>=1.5.0",
|
|
38
|
+
"predicate-authority>=0.1.0",
|
|
39
|
+
]
|
|
40
|
+
|
|
41
|
+
[project.optional-dependencies]
|
|
42
|
+
dev = [
|
|
43
|
+
"pytest>=8.0.0",
|
|
44
|
+
"pytest-asyncio>=0.23.0",
|
|
45
|
+
"pytest-cov>=4.1.0",
|
|
46
|
+
"mypy>=1.8.0",
|
|
47
|
+
"ruff>=0.2.0",
|
|
48
|
+
]
|
|
49
|
+
|
|
50
|
+
[project.urls]
|
|
51
|
+
Homepage = "https://github.com/PredicateSystems/predicate-temporal-python"
|
|
52
|
+
Documentation = "https://docs.predicatesystems.dev/integrations/temporal"
|
|
53
|
+
Repository = "https://github.com/PredicateSystems/predicate-temporal-python"
|
|
54
|
+
Issues = "https://github.com/PredicateSystems/predicate-temporal-python/issues"
|
|
55
|
+
|
|
56
|
+
[tool.hatch.build.targets.wheel]
|
|
57
|
+
packages = ["src/predicate_temporal"]
|
|
58
|
+
|
|
59
|
+
[tool.hatch.build.targets.sdist]
|
|
60
|
+
include = [
|
|
61
|
+
"/src",
|
|
62
|
+
"/tests",
|
|
63
|
+
"README.md",
|
|
64
|
+
"LICENSE",
|
|
65
|
+
]
|
|
66
|
+
|
|
67
|
+
[tool.pytest.ini_options]
|
|
68
|
+
testpaths = ["tests"]
|
|
69
|
+
asyncio_mode = "auto"
|
|
70
|
+
addopts = "-v --tb=short"
|
|
71
|
+
|
|
72
|
+
[tool.mypy]
|
|
73
|
+
python_version = "3.11"
|
|
74
|
+
strict = true
|
|
75
|
+
warn_return_any = true
|
|
76
|
+
warn_unused_ignores = true
|
|
77
|
+
show_error_codes = true
|
|
78
|
+
|
|
79
|
+
[[tool.mypy.overrides]]
|
|
80
|
+
module = ["temporalio.*"]
|
|
81
|
+
ignore_missing_imports = true
|
|
82
|
+
|
|
83
|
+
[[tool.mypy.overrides]]
|
|
84
|
+
module = ["predicate_authority.*", "predicate_contracts.*"]
|
|
85
|
+
ignore_missing_imports = true
|
|
86
|
+
|
|
87
|
+
[tool.ruff]
|
|
88
|
+
target-version = "py311"
|
|
89
|
+
line-length = 100
|
|
90
|
+
src = ["src", "tests"]
|
|
91
|
+
|
|
92
|
+
[tool.ruff.lint]
|
|
93
|
+
select = [
|
|
94
|
+
"E", # pycodestyle errors
|
|
95
|
+
"W", # pycodestyle warnings
|
|
96
|
+
"F", # Pyflakes
|
|
97
|
+
"I", # isort
|
|
98
|
+
"B", # flake8-bugbear
|
|
99
|
+
"C4", # flake8-comprehensions
|
|
100
|
+
"UP", # pyupgrade
|
|
101
|
+
"ARG", # flake8-unused-arguments
|
|
102
|
+
"SIM", # flake8-simplify
|
|
103
|
+
]
|
|
104
|
+
ignore = [
|
|
105
|
+
"E501", # line too long (handled by formatter)
|
|
106
|
+
]
|
|
107
|
+
|
|
108
|
+
[tool.ruff.lint.isort]
|
|
109
|
+
known-first-party = ["predicate_temporal"]
|
|
110
|
+
|
|
111
|
+
[tool.coverage.run]
|
|
112
|
+
source = ["src/predicate_temporal"]
|
|
113
|
+
branch = true
|
|
114
|
+
|
|
115
|
+
[tool.coverage.report]
|
|
116
|
+
exclude_lines = [
|
|
117
|
+
"pragma: no cover",
|
|
118
|
+
"if TYPE_CHECKING:",
|
|
119
|
+
"raise NotImplementedError",
|
|
120
|
+
]
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
"""Temporal.io Worker Interceptor for Predicate Authority Zero-Trust authorization."""
|
|
2
|
+
|
|
3
|
+
from predicate_temporal.interceptor import (
|
|
4
|
+
PredicateActivityInterceptor,
|
|
5
|
+
PredicateInterceptor,
|
|
6
|
+
)
|
|
7
|
+
|
|
8
|
+
__version__ = "0.1.0"
|
|
9
|
+
|
|
10
|
+
__all__ = [
|
|
11
|
+
"PredicateActivityInterceptor",
|
|
12
|
+
"PredicateInterceptor",
|
|
13
|
+
]
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
"""Predicate Authority interceptor for Temporal.io activities.
|
|
2
|
+
|
|
3
|
+
This module provides a pre-execution security gate for all Temporal Activities,
|
|
4
|
+
enforcing cryptographic authorization mandates before any activity code runs.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import hashlib
|
|
10
|
+
import json
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
from predicate_authority import AuthorityClient
|
|
14
|
+
from predicate_contracts import (
|
|
15
|
+
ActionRequest,
|
|
16
|
+
ActionSpec,
|
|
17
|
+
PrincipalRef,
|
|
18
|
+
StateEvidence,
|
|
19
|
+
VerificationEvidence,
|
|
20
|
+
)
|
|
21
|
+
from temporalio.worker import (
|
|
22
|
+
ActivityInboundInterceptor,
|
|
23
|
+
ExecuteActivityInput,
|
|
24
|
+
Interceptor,
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class PredicateActivityInterceptor(ActivityInboundInterceptor):
|
|
29
|
+
"""Inbound interceptor that enforces Predicate Authority authorization for activities.
|
|
30
|
+
|
|
31
|
+
This interceptor sits in the Temporal activity execution pipeline and ensures
|
|
32
|
+
that every activity is authorized before execution. If authorization is denied,
|
|
33
|
+
a PermissionError is raised and the activity never executes.
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
def __init__(
|
|
37
|
+
self,
|
|
38
|
+
next_interceptor: ActivityInboundInterceptor,
|
|
39
|
+
authority_client: AuthorityClient,
|
|
40
|
+
principal: str,
|
|
41
|
+
tenant_id: str | None = None,
|
|
42
|
+
session_id: str | None = None,
|
|
43
|
+
) -> None:
|
|
44
|
+
"""Initialize the activity interceptor.
|
|
45
|
+
|
|
46
|
+
Args:
|
|
47
|
+
next_interceptor: The next interceptor in the chain.
|
|
48
|
+
authority_client: The Predicate Authority client for authorization.
|
|
49
|
+
principal: Principal ID used for authorization requests.
|
|
50
|
+
tenant_id: Optional tenant ID for multi-tenant setups.
|
|
51
|
+
session_id: Optional session ID for request correlation.
|
|
52
|
+
"""
|
|
53
|
+
super().__init__(next_interceptor)
|
|
54
|
+
self._authority_client = authority_client
|
|
55
|
+
self._principal = principal
|
|
56
|
+
self._tenant_id = tenant_id
|
|
57
|
+
self._session_id = session_id
|
|
58
|
+
|
|
59
|
+
async def execute_activity(self, input: ExecuteActivityInput) -> Any:
|
|
60
|
+
"""Execute activity with Predicate Authority authorization check.
|
|
61
|
+
|
|
62
|
+
This method intercepts the activity execution, extracts the activity name
|
|
63
|
+
and arguments, and requests authorization from Predicate Authority.
|
|
64
|
+
If denied, raises PermissionError. If approved, proceeds with execution.
|
|
65
|
+
|
|
66
|
+
Args:
|
|
67
|
+
input: The activity execution input containing activity name and args.
|
|
68
|
+
|
|
69
|
+
Returns:
|
|
70
|
+
The result of the activity execution.
|
|
71
|
+
|
|
72
|
+
Raises:
|
|
73
|
+
PermissionError: If authorization is denied.
|
|
74
|
+
"""
|
|
75
|
+
activity_name = input.fn.__name__
|
|
76
|
+
activity_args = input.args
|
|
77
|
+
|
|
78
|
+
args_json = json.dumps(
|
|
79
|
+
[self._serialize_arg(arg) for arg in activity_args],
|
|
80
|
+
sort_keys=True,
|
|
81
|
+
default=str,
|
|
82
|
+
)
|
|
83
|
+
args_hash = hashlib.sha256(args_json.encode()).hexdigest()
|
|
84
|
+
|
|
85
|
+
request = ActionRequest(
|
|
86
|
+
principal=PrincipalRef(
|
|
87
|
+
principal_id=self._principal,
|
|
88
|
+
tenant_id=self._tenant_id,
|
|
89
|
+
session_id=self._session_id,
|
|
90
|
+
),
|
|
91
|
+
action_spec=ActionSpec(
|
|
92
|
+
action=activity_name,
|
|
93
|
+
resource="temporal:activity",
|
|
94
|
+
intent=f"execute:{activity_name}",
|
|
95
|
+
),
|
|
96
|
+
state_evidence=StateEvidence(
|
|
97
|
+
source="temporal-worker",
|
|
98
|
+
state_hash=args_hash,
|
|
99
|
+
schema_version="v1",
|
|
100
|
+
),
|
|
101
|
+
verification_evidence=VerificationEvidence(signals=()),
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
decision = self._authority_client.authorize(request)
|
|
105
|
+
|
|
106
|
+
if not decision.allowed:
|
|
107
|
+
raise PermissionError(
|
|
108
|
+
f"Predicate Zero-Trust Denial: Activity '{activity_name}' not authorized. "
|
|
109
|
+
f"Reason: {decision.reason.value}"
|
|
110
|
+
+ (f", violated rule: {decision.violated_rule}" if decision.violated_rule else "")
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
return await super().execute_activity(input)
|
|
114
|
+
|
|
115
|
+
@staticmethod
|
|
116
|
+
def _serialize_arg(arg: Any) -> Any:
|
|
117
|
+
"""Serialize an argument for hashing.
|
|
118
|
+
|
|
119
|
+
Args:
|
|
120
|
+
arg: The argument to serialize.
|
|
121
|
+
|
|
122
|
+
Returns:
|
|
123
|
+
A JSON-serializable representation of the argument.
|
|
124
|
+
"""
|
|
125
|
+
if hasattr(arg, "__dict__"):
|
|
126
|
+
return {k: v for k, v in arg.__dict__.items() if not k.startswith("_")}
|
|
127
|
+
return arg
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
class PredicateInterceptor(Interceptor):
|
|
131
|
+
"""Top-level Temporal interceptor that injects Predicate Authority authorization.
|
|
132
|
+
|
|
133
|
+
Use this interceptor when creating a Temporal Worker to enforce Zero-Trust
|
|
134
|
+
authorization for all activities.
|
|
135
|
+
|
|
136
|
+
Example:
|
|
137
|
+
```python
|
|
138
|
+
from temporalio.worker import Worker
|
|
139
|
+
from predicate_temporal import PredicateInterceptor
|
|
140
|
+
from predicate_authority import AuthorityClient
|
|
141
|
+
|
|
142
|
+
ctx = AuthorityClient.from_env()
|
|
143
|
+
interceptor = PredicateInterceptor(
|
|
144
|
+
authority_client=ctx.client,
|
|
145
|
+
principal="temporal-worker",
|
|
146
|
+
)
|
|
147
|
+
|
|
148
|
+
worker = Worker(
|
|
149
|
+
client=temporal_client,
|
|
150
|
+
task_queue="my-task-queue",
|
|
151
|
+
workflows=[MyWorkflow],
|
|
152
|
+
activities=[my_activity],
|
|
153
|
+
interceptors=[interceptor],
|
|
154
|
+
)
|
|
155
|
+
```
|
|
156
|
+
"""
|
|
157
|
+
|
|
158
|
+
def __init__(
|
|
159
|
+
self,
|
|
160
|
+
authority_client: AuthorityClient,
|
|
161
|
+
principal: str = "temporal-worker",
|
|
162
|
+
tenant_id: str | None = None,
|
|
163
|
+
session_id: str | None = None,
|
|
164
|
+
) -> None:
|
|
165
|
+
"""Initialize the Predicate interceptor.
|
|
166
|
+
|
|
167
|
+
Args:
|
|
168
|
+
authority_client: The Predicate Authority client for authorization.
|
|
169
|
+
principal: Principal ID used for authorization requests (default: "temporal-worker").
|
|
170
|
+
tenant_id: Optional tenant ID for multi-tenant setups.
|
|
171
|
+
session_id: Optional session ID for request correlation.
|
|
172
|
+
"""
|
|
173
|
+
self._authority_client = authority_client
|
|
174
|
+
self._principal = principal
|
|
175
|
+
self._tenant_id = tenant_id
|
|
176
|
+
self._session_id = session_id
|
|
177
|
+
|
|
178
|
+
def intercept_activity(
|
|
179
|
+
self,
|
|
180
|
+
next_interceptor: ActivityInboundInterceptor,
|
|
181
|
+
) -> ActivityInboundInterceptor:
|
|
182
|
+
"""Inject the Predicate activity interceptor into the pipeline.
|
|
183
|
+
|
|
184
|
+
Args:
|
|
185
|
+
next_interceptor: The next interceptor in the chain.
|
|
186
|
+
|
|
187
|
+
Returns:
|
|
188
|
+
The PredicateActivityInterceptor wrapping the next interceptor.
|
|
189
|
+
"""
|
|
190
|
+
return PredicateActivityInterceptor(
|
|
191
|
+
next_interceptor=next_interceptor,
|
|
192
|
+
authority_client=self._authority_client,
|
|
193
|
+
principal=self._principal,
|
|
194
|
+
tenant_id=self._tenant_id,
|
|
195
|
+
session_id=self._session_id,
|
|
196
|
+
)
|
|
File without changes
|
|
File without changes
|
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
"""Tests for the Predicate Temporal interceptor."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from typing import Any
|
|
7
|
+
from unittest.mock import AsyncMock, MagicMock
|
|
8
|
+
|
|
9
|
+
import pytest
|
|
10
|
+
|
|
11
|
+
from predicate_temporal.interceptor import (
|
|
12
|
+
PredicateActivityInterceptor,
|
|
13
|
+
PredicateInterceptor,
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclass
|
|
18
|
+
class MockActivityInput:
|
|
19
|
+
"""Mock for ExecuteActivityInput."""
|
|
20
|
+
|
|
21
|
+
fn: Any
|
|
22
|
+
args: tuple[Any, ...]
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def mock_activity_function(x: int, y: str) -> str:
|
|
26
|
+
"""Mock activity function for testing."""
|
|
27
|
+
return f"{x}-{y}"
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class MockAuthorizationDecision:
|
|
31
|
+
"""Mock authorization decision."""
|
|
32
|
+
|
|
33
|
+
def __init__(self, allowed: bool, reason: str = "allowed", violated_rule: str | None = None):
|
|
34
|
+
self.allowed = allowed
|
|
35
|
+
self.reason = MagicMock(value=reason)
|
|
36
|
+
self.violated_rule = violated_rule
|
|
37
|
+
self.mandate = MagicMock() if allowed else None
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class TestPredicateActivityInterceptor:
|
|
41
|
+
"""Tests for PredicateActivityInterceptor."""
|
|
42
|
+
|
|
43
|
+
@pytest.fixture
|
|
44
|
+
def mock_authority_client(self) -> MagicMock:
|
|
45
|
+
"""Create a mock authority client."""
|
|
46
|
+
return MagicMock()
|
|
47
|
+
|
|
48
|
+
@pytest.fixture
|
|
49
|
+
def mock_next_interceptor(self) -> MagicMock:
|
|
50
|
+
"""Create a mock next interceptor."""
|
|
51
|
+
interceptor = MagicMock()
|
|
52
|
+
interceptor.execute_activity = AsyncMock(return_value="activity_result")
|
|
53
|
+
return interceptor
|
|
54
|
+
|
|
55
|
+
@pytest.fixture
|
|
56
|
+
def interceptor(
|
|
57
|
+
self, mock_next_interceptor: MagicMock, mock_authority_client: MagicMock
|
|
58
|
+
) -> PredicateActivityInterceptor:
|
|
59
|
+
"""Create the interceptor under test."""
|
|
60
|
+
return PredicateActivityInterceptor(
|
|
61
|
+
next_interceptor=mock_next_interceptor,
|
|
62
|
+
authority_client=mock_authority_client,
|
|
63
|
+
principal="test-worker",
|
|
64
|
+
tenant_id="test-tenant",
|
|
65
|
+
session_id="test-session",
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
@pytest.mark.asyncio
|
|
69
|
+
async def test_execute_activity_allowed(
|
|
70
|
+
self,
|
|
71
|
+
interceptor: PredicateActivityInterceptor,
|
|
72
|
+
mock_authority_client: MagicMock,
|
|
73
|
+
mock_next_interceptor: MagicMock,
|
|
74
|
+
) -> None:
|
|
75
|
+
"""Test that allowed activities proceed to execution."""
|
|
76
|
+
mock_authority_client.authorize.return_value = MockAuthorizationDecision(allowed=True)
|
|
77
|
+
|
|
78
|
+
input_data = MockActivityInput(fn=mock_activity_function, args=(42, "hello"))
|
|
79
|
+
|
|
80
|
+
result = await interceptor.execute_activity(input_data) # type: ignore[arg-type]
|
|
81
|
+
|
|
82
|
+
assert result == "activity_result"
|
|
83
|
+
mock_authority_client.authorize.assert_called_once()
|
|
84
|
+
mock_next_interceptor.execute_activity.assert_called_once_with(input_data)
|
|
85
|
+
|
|
86
|
+
@pytest.mark.asyncio
|
|
87
|
+
async def test_execute_activity_denied(
|
|
88
|
+
self,
|
|
89
|
+
interceptor: PredicateActivityInterceptor,
|
|
90
|
+
mock_authority_client: MagicMock,
|
|
91
|
+
mock_next_interceptor: MagicMock,
|
|
92
|
+
) -> None:
|
|
93
|
+
"""Test that denied activities raise PermissionError."""
|
|
94
|
+
mock_authority_client.authorize.return_value = MockAuthorizationDecision(
|
|
95
|
+
allowed=False,
|
|
96
|
+
reason="explicit_deny",
|
|
97
|
+
violated_rule="deny-dangerous",
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
input_data = MockActivityInput(fn=mock_activity_function, args=(42, "hello"))
|
|
101
|
+
|
|
102
|
+
with pytest.raises(PermissionError) as exc_info:
|
|
103
|
+
await interceptor.execute_activity(input_data) # type: ignore[arg-type]
|
|
104
|
+
|
|
105
|
+
assert "Predicate Zero-Trust Denial" in str(exc_info.value)
|
|
106
|
+
assert "mock_activity_function" in str(exc_info.value)
|
|
107
|
+
assert "explicit_deny" in str(exc_info.value)
|
|
108
|
+
assert "deny-dangerous" in str(exc_info.value)
|
|
109
|
+
|
|
110
|
+
mock_next_interceptor.execute_activity.assert_not_called()
|
|
111
|
+
|
|
112
|
+
@pytest.mark.asyncio
|
|
113
|
+
async def test_execute_activity_denied_no_violated_rule(
|
|
114
|
+
self,
|
|
115
|
+
interceptor: PredicateActivityInterceptor,
|
|
116
|
+
mock_authority_client: MagicMock,
|
|
117
|
+
) -> None:
|
|
118
|
+
"""Test denial message without violated rule."""
|
|
119
|
+
mock_authority_client.authorize.return_value = MockAuthorizationDecision(
|
|
120
|
+
allowed=False,
|
|
121
|
+
reason="no_matching_policy",
|
|
122
|
+
violated_rule=None,
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
input_data = MockActivityInput(fn=mock_activity_function, args=(42, "hello"))
|
|
126
|
+
|
|
127
|
+
with pytest.raises(PermissionError) as exc_info:
|
|
128
|
+
await interceptor.execute_activity(input_data) # type: ignore[arg-type]
|
|
129
|
+
|
|
130
|
+
assert "no_matching_policy" in str(exc_info.value)
|
|
131
|
+
assert "violated rule" not in str(exc_info.value)
|
|
132
|
+
|
|
133
|
+
@pytest.mark.asyncio
|
|
134
|
+
async def test_authorization_request_structure(
|
|
135
|
+
self,
|
|
136
|
+
interceptor: PredicateActivityInterceptor,
|
|
137
|
+
mock_authority_client: MagicMock,
|
|
138
|
+
) -> None:
|
|
139
|
+
"""Test that the authorization request has correct structure."""
|
|
140
|
+
mock_authority_client.authorize.return_value = MockAuthorizationDecision(allowed=True)
|
|
141
|
+
|
|
142
|
+
input_data = MockActivityInput(fn=mock_activity_function, args=(42, "hello"))
|
|
143
|
+
|
|
144
|
+
await interceptor.execute_activity(input_data) # type: ignore[arg-type]
|
|
145
|
+
|
|
146
|
+
call_args = mock_authority_client.authorize.call_args
|
|
147
|
+
request = call_args[0][0]
|
|
148
|
+
|
|
149
|
+
assert request.principal.principal_id == "test-worker"
|
|
150
|
+
assert request.principal.tenant_id == "test-tenant"
|
|
151
|
+
assert request.principal.session_id == "test-session"
|
|
152
|
+
assert request.action_spec.action == "mock_activity_function"
|
|
153
|
+
assert request.action_spec.resource == "temporal:activity"
|
|
154
|
+
assert "execute:mock_activity_function" in request.action_spec.intent
|
|
155
|
+
assert request.state_evidence.source == "temporal-worker"
|
|
156
|
+
assert request.state_evidence.state_hash # Non-empty hash
|
|
157
|
+
|
|
158
|
+
def test_serialize_arg_primitive(self) -> None:
|
|
159
|
+
"""Test serialization of primitive types."""
|
|
160
|
+
assert PredicateActivityInterceptor._serialize_arg(42) == 42
|
|
161
|
+
assert PredicateActivityInterceptor._serialize_arg("hello") == "hello"
|
|
162
|
+
assert PredicateActivityInterceptor._serialize_arg(True) is True
|
|
163
|
+
assert PredicateActivityInterceptor._serialize_arg(None) is None
|
|
164
|
+
|
|
165
|
+
def test_serialize_arg_object(self) -> None:
|
|
166
|
+
"""Test serialization of objects with __dict__."""
|
|
167
|
+
|
|
168
|
+
@dataclass
|
|
169
|
+
class TestObject:
|
|
170
|
+
name: str
|
|
171
|
+
value: int
|
|
172
|
+
_private: str = "hidden"
|
|
173
|
+
|
|
174
|
+
obj = TestObject(name="test", value=123, _private="secret")
|
|
175
|
+
serialized = PredicateActivityInterceptor._serialize_arg(obj)
|
|
176
|
+
|
|
177
|
+
assert serialized == {"name": "test", "value": 123}
|
|
178
|
+
assert "_private" not in serialized
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
class TestPredicateInterceptor:
|
|
182
|
+
"""Tests for PredicateInterceptor."""
|
|
183
|
+
|
|
184
|
+
@pytest.fixture
|
|
185
|
+
def mock_authority_client(self) -> MagicMock:
|
|
186
|
+
"""Create a mock authority client."""
|
|
187
|
+
return MagicMock()
|
|
188
|
+
|
|
189
|
+
def test_interceptor_creation(self, mock_authority_client: MagicMock) -> None:
|
|
190
|
+
"""Test interceptor creation with all parameters."""
|
|
191
|
+
interceptor = PredicateInterceptor(
|
|
192
|
+
authority_client=mock_authority_client,
|
|
193
|
+
principal="custom-worker",
|
|
194
|
+
tenant_id="tenant-123",
|
|
195
|
+
session_id="session-456",
|
|
196
|
+
)
|
|
197
|
+
|
|
198
|
+
assert interceptor._authority_client == mock_authority_client
|
|
199
|
+
assert interceptor._principal == "custom-worker"
|
|
200
|
+
assert interceptor._tenant_id == "tenant-123"
|
|
201
|
+
assert interceptor._session_id == "session-456"
|
|
202
|
+
|
|
203
|
+
def test_interceptor_defaults(self, mock_authority_client: MagicMock) -> None:
|
|
204
|
+
"""Test interceptor creation with default parameters."""
|
|
205
|
+
interceptor = PredicateInterceptor(authority_client=mock_authority_client)
|
|
206
|
+
|
|
207
|
+
assert interceptor._principal == "temporal-worker"
|
|
208
|
+
assert interceptor._tenant_id is None
|
|
209
|
+
assert interceptor._session_id is None
|
|
210
|
+
|
|
211
|
+
def test_intercept_activity(self, mock_authority_client: MagicMock) -> None:
|
|
212
|
+
"""Test that intercept_activity returns PredicateActivityInterceptor."""
|
|
213
|
+
interceptor = PredicateInterceptor(
|
|
214
|
+
authority_client=mock_authority_client,
|
|
215
|
+
principal="test-worker",
|
|
216
|
+
tenant_id="tenant-123",
|
|
217
|
+
)
|
|
218
|
+
|
|
219
|
+
mock_next = MagicMock()
|
|
220
|
+
result = interceptor.intercept_activity(mock_next)
|
|
221
|
+
|
|
222
|
+
assert isinstance(result, PredicateActivityInterceptor)
|
|
223
|
+
assert result._authority_client == mock_authority_client
|
|
224
|
+
assert result._principal == "test-worker"
|
|
225
|
+
assert result._tenant_id == "tenant-123"
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
class TestIntegration:
|
|
229
|
+
"""Integration-style tests."""
|
|
230
|
+
|
|
231
|
+
@pytest.mark.asyncio
|
|
232
|
+
async def test_full_interceptor_chain(self) -> None:
|
|
233
|
+
"""Test the full interceptor chain from top-level to activity execution."""
|
|
234
|
+
mock_client = MagicMock()
|
|
235
|
+
mock_client.authorize.return_value = MockAuthorizationDecision(allowed=True)
|
|
236
|
+
|
|
237
|
+
interceptor = PredicateInterceptor(
|
|
238
|
+
authority_client=mock_client,
|
|
239
|
+
principal="integration-worker",
|
|
240
|
+
)
|
|
241
|
+
|
|
242
|
+
mock_next = MagicMock()
|
|
243
|
+
mock_next.execute_activity = AsyncMock(return_value="success")
|
|
244
|
+
|
|
245
|
+
activity_interceptor = interceptor.intercept_activity(mock_next)
|
|
246
|
+
|
|
247
|
+
input_data = MockActivityInput(fn=mock_activity_function, args=("arg1",))
|
|
248
|
+
result = await activity_interceptor.execute_activity(input_data) # type: ignore[arg-type]
|
|
249
|
+
|
|
250
|
+
assert result == "success"
|
|
251
|
+
mock_client.authorize.assert_called_once()
|