proxilion 0.0.1__py3-none-any.whl
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.
- proxilion/__init__.py +136 -0
- proxilion/audit/__init__.py +133 -0
- proxilion/audit/base_exporters.py +527 -0
- proxilion/audit/compliance/__init__.py +130 -0
- proxilion/audit/compliance/base.py +457 -0
- proxilion/audit/compliance/eu_ai_act.py +603 -0
- proxilion/audit/compliance/iso27001.py +544 -0
- proxilion/audit/compliance/soc2.py +491 -0
- proxilion/audit/events.py +493 -0
- proxilion/audit/explainability.py +1173 -0
- proxilion/audit/exporters/__init__.py +58 -0
- proxilion/audit/exporters/aws_s3.py +636 -0
- proxilion/audit/exporters/azure_storage.py +608 -0
- proxilion/audit/exporters/cloud_base.py +468 -0
- proxilion/audit/exporters/gcp_storage.py +570 -0
- proxilion/audit/exporters/multi_exporter.py +498 -0
- proxilion/audit/hash_chain.py +652 -0
- proxilion/audit/logger.py +543 -0
- proxilion/caching/__init__.py +49 -0
- proxilion/caching/tool_cache.py +633 -0
- proxilion/context/__init__.py +73 -0
- proxilion/context/context_window.py +556 -0
- proxilion/context/message_history.py +505 -0
- proxilion/context/session.py +735 -0
- proxilion/contrib/__init__.py +51 -0
- proxilion/contrib/anthropic.py +609 -0
- proxilion/contrib/google.py +1012 -0
- proxilion/contrib/langchain.py +641 -0
- proxilion/contrib/mcp.py +893 -0
- proxilion/contrib/openai.py +646 -0
- proxilion/core.py +3058 -0
- proxilion/decorators.py +966 -0
- proxilion/engines/__init__.py +287 -0
- proxilion/engines/base.py +266 -0
- proxilion/engines/casbin_engine.py +412 -0
- proxilion/engines/opa_engine.py +493 -0
- proxilion/engines/simple.py +437 -0
- proxilion/exceptions.py +887 -0
- proxilion/guards/__init__.py +54 -0
- proxilion/guards/input_guard.py +522 -0
- proxilion/guards/output_guard.py +634 -0
- proxilion/observability/__init__.py +198 -0
- proxilion/observability/cost_tracker.py +866 -0
- proxilion/observability/hooks.py +683 -0
- proxilion/observability/metrics.py +798 -0
- proxilion/observability/session_cost_tracker.py +1063 -0
- proxilion/policies/__init__.py +67 -0
- proxilion/policies/base.py +304 -0
- proxilion/policies/builtin.py +486 -0
- proxilion/policies/registry.py +376 -0
- proxilion/providers/__init__.py +201 -0
- proxilion/providers/adapter.py +468 -0
- proxilion/providers/anthropic_adapter.py +330 -0
- proxilion/providers/gemini_adapter.py +391 -0
- proxilion/providers/openai_adapter.py +294 -0
- proxilion/py.typed +0 -0
- proxilion/resilience/__init__.py +81 -0
- proxilion/resilience/degradation.py +615 -0
- proxilion/resilience/fallback.py +555 -0
- proxilion/resilience/retry.py +554 -0
- proxilion/scheduling/__init__.py +57 -0
- proxilion/scheduling/priority_queue.py +419 -0
- proxilion/scheduling/scheduler.py +459 -0
- proxilion/security/__init__.py +244 -0
- proxilion/security/agent_trust.py +968 -0
- proxilion/security/behavioral_drift.py +794 -0
- proxilion/security/cascade_protection.py +869 -0
- proxilion/security/circuit_breaker.py +428 -0
- proxilion/security/cost_limiter.py +690 -0
- proxilion/security/idor_protection.py +460 -0
- proxilion/security/intent_capsule.py +849 -0
- proxilion/security/intent_validator.py +495 -0
- proxilion/security/memory_integrity.py +767 -0
- proxilion/security/rate_limiter.py +509 -0
- proxilion/security/scope_enforcer.py +680 -0
- proxilion/security/sequence_validator.py +636 -0
- proxilion/security/trust_boundaries.py +784 -0
- proxilion/streaming/__init__.py +70 -0
- proxilion/streaming/detector.py +761 -0
- proxilion/streaming/transformer.py +674 -0
- proxilion/timeouts/__init__.py +55 -0
- proxilion/timeouts/decorators.py +477 -0
- proxilion/timeouts/manager.py +545 -0
- proxilion/tools/__init__.py +69 -0
- proxilion/tools/decorators.py +493 -0
- proxilion/tools/registry.py +732 -0
- proxilion/types.py +339 -0
- proxilion/validation/__init__.py +93 -0
- proxilion/validation/pydantic_schema.py +351 -0
- proxilion/validation/schema.py +651 -0
- proxilion-0.0.1.dist-info/METADATA +872 -0
- proxilion-0.0.1.dist-info/RECORD +94 -0
- proxilion-0.0.1.dist-info/WHEEL +4 -0
- proxilion-0.0.1.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,493 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Open Policy Agent (OPA) policy engine integration for Proxilion.
|
|
3
|
+
|
|
4
|
+
This module provides integration with OPA, enabling policy-as-code
|
|
5
|
+
using the Rego policy language. OPA can run as a sidecar service
|
|
6
|
+
or be embedded in the application.
|
|
7
|
+
|
|
8
|
+
This implementation uses urllib (stdlib) to query an OPA server,
|
|
9
|
+
avoiding external HTTP library dependencies.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import asyncio
|
|
15
|
+
import json
|
|
16
|
+
import logging
|
|
17
|
+
import urllib.error
|
|
18
|
+
import urllib.request
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
from typing import TYPE_CHECKING, Any
|
|
21
|
+
|
|
22
|
+
from proxilion.engines.base import (
|
|
23
|
+
BasePolicyEngine,
|
|
24
|
+
EngineCapabilities,
|
|
25
|
+
PolicyEvaluationError,
|
|
26
|
+
PolicyLoadError,
|
|
27
|
+
)
|
|
28
|
+
from proxilion.types import AuthorizationResult
|
|
29
|
+
|
|
30
|
+
if TYPE_CHECKING:
|
|
31
|
+
from proxilion.types import UserContext
|
|
32
|
+
|
|
33
|
+
logger = logging.getLogger(__name__)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class OPAPolicyEngine(BasePolicyEngine):
|
|
37
|
+
"""
|
|
38
|
+
Policy engine using Open Policy Agent (OPA) for authorization.
|
|
39
|
+
|
|
40
|
+
OPA is a general-purpose policy engine that uses the Rego
|
|
41
|
+
policy language. It can express complex authorization rules
|
|
42
|
+
and is widely used in cloud-native environments.
|
|
43
|
+
|
|
44
|
+
This engine queries an OPA server via REST API. The OPA server
|
|
45
|
+
should be running and accessible at the configured endpoint.
|
|
46
|
+
|
|
47
|
+
Configuration:
|
|
48
|
+
- opa_url: Base URL of the OPA server (default: http://localhost:8181)
|
|
49
|
+
- policy_path: OPA policy path for queries (default: v1/data/proxilion)
|
|
50
|
+
- timeout: Request timeout in seconds (default: 5.0)
|
|
51
|
+
- retry_count: Number of retries on failure (default: 3)
|
|
52
|
+
- retry_delay: Delay between retries in seconds (default: 0.5)
|
|
53
|
+
- fallback_allow: Whether to allow on OPA failure (default: False)
|
|
54
|
+
|
|
55
|
+
Example:
|
|
56
|
+
>>> engine = OPAPolicyEngine({
|
|
57
|
+
... "opa_url": "http://localhost:8181",
|
|
58
|
+
... "policy_path": "v1/data/proxilion/authz",
|
|
59
|
+
... })
|
|
60
|
+
>>> result = engine.evaluate(user, "read", "document")
|
|
61
|
+
|
|
62
|
+
OPA Policy Example (Rego):
|
|
63
|
+
package proxilion.authz
|
|
64
|
+
|
|
65
|
+
default allow := false
|
|
66
|
+
|
|
67
|
+
allow {
|
|
68
|
+
input.action == "read"
|
|
69
|
+
"viewer" in input.user.roles
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
allow {
|
|
73
|
+
input.action == "write"
|
|
74
|
+
"editor" in input.user.roles
|
|
75
|
+
}
|
|
76
|
+
"""
|
|
77
|
+
|
|
78
|
+
name = "opa"
|
|
79
|
+
|
|
80
|
+
def __init__(self, config: dict[str, Any] | None = None) -> None:
|
|
81
|
+
"""
|
|
82
|
+
Initialize the OPA policy engine.
|
|
83
|
+
|
|
84
|
+
Args:
|
|
85
|
+
config: Configuration options including opa_url and policy_path.
|
|
86
|
+
"""
|
|
87
|
+
super().__init__(config)
|
|
88
|
+
|
|
89
|
+
self.opa_url = self.get_config("opa_url", "http://localhost:8181")
|
|
90
|
+
self.policy_path = self.get_config("policy_path", "v1/data/proxilion/authz")
|
|
91
|
+
self.timeout = self.get_config("timeout", 5.0)
|
|
92
|
+
self.retry_count = self.get_config("retry_count", 3)
|
|
93
|
+
self.retry_delay = self.get_config("retry_delay", 0.5)
|
|
94
|
+
self.fallback_allow = self.get_config("fallback_allow", False)
|
|
95
|
+
|
|
96
|
+
# Build the full query URL
|
|
97
|
+
self._query_url = f"{self.opa_url.rstrip('/')}/{self.policy_path.lstrip('/')}"
|
|
98
|
+
|
|
99
|
+
self._initialized = True
|
|
100
|
+
logger.debug(f"OPA engine initialized with URL: {self._query_url}")
|
|
101
|
+
|
|
102
|
+
@property
|
|
103
|
+
def capabilities(self) -> EngineCapabilities:
|
|
104
|
+
"""Get engine capabilities."""
|
|
105
|
+
return EngineCapabilities(
|
|
106
|
+
supports_async=True,
|
|
107
|
+
supports_caching=False, # OPA handles caching
|
|
108
|
+
supports_explain=True,
|
|
109
|
+
supports_partial_eval=True,
|
|
110
|
+
supports_hot_reload=True,
|
|
111
|
+
max_batch_size=1,
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
def _build_input(
|
|
115
|
+
self,
|
|
116
|
+
user: UserContext,
|
|
117
|
+
action: str,
|
|
118
|
+
resource: str,
|
|
119
|
+
context: dict[str, Any] | None,
|
|
120
|
+
) -> dict[str, Any]:
|
|
121
|
+
"""
|
|
122
|
+
Build the OPA input document.
|
|
123
|
+
|
|
124
|
+
Args:
|
|
125
|
+
user: The user context.
|
|
126
|
+
action: The action being attempted.
|
|
127
|
+
resource: The resource being accessed.
|
|
128
|
+
context: Additional context.
|
|
129
|
+
|
|
130
|
+
Returns:
|
|
131
|
+
Dictionary to be sent as OPA input.
|
|
132
|
+
"""
|
|
133
|
+
return {
|
|
134
|
+
"input": {
|
|
135
|
+
"user": {
|
|
136
|
+
"user_id": user.user_id,
|
|
137
|
+
"roles": user.roles,
|
|
138
|
+
"session_id": user.session_id,
|
|
139
|
+
"attributes": user.attributes,
|
|
140
|
+
},
|
|
141
|
+
"action": action,
|
|
142
|
+
"resource": resource,
|
|
143
|
+
"context": context or {},
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
def _query_opa(self, input_data: dict[str, Any]) -> dict[str, Any]:
|
|
148
|
+
"""
|
|
149
|
+
Query the OPA server.
|
|
150
|
+
|
|
151
|
+
Args:
|
|
152
|
+
input_data: The input document for OPA.
|
|
153
|
+
|
|
154
|
+
Returns:
|
|
155
|
+
The OPA response as a dictionary.
|
|
156
|
+
|
|
157
|
+
Raises:
|
|
158
|
+
PolicyEvaluationError: If the query fails after retries.
|
|
159
|
+
"""
|
|
160
|
+
request_data = json.dumps(input_data).encode("utf-8")
|
|
161
|
+
|
|
162
|
+
headers = {
|
|
163
|
+
"Content-Type": "application/json",
|
|
164
|
+
"Accept": "application/json",
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
last_error: Exception | None = None
|
|
168
|
+
|
|
169
|
+
for attempt in range(self.retry_count):
|
|
170
|
+
try:
|
|
171
|
+
req = urllib.request.Request(
|
|
172
|
+
self._query_url,
|
|
173
|
+
data=request_data,
|
|
174
|
+
headers=headers,
|
|
175
|
+
method="POST",
|
|
176
|
+
)
|
|
177
|
+
|
|
178
|
+
with urllib.request.urlopen(req, timeout=self.timeout) as response:
|
|
179
|
+
response_data = response.read().decode("utf-8")
|
|
180
|
+
return json.loads(response_data)
|
|
181
|
+
|
|
182
|
+
except urllib.error.HTTPError as e:
|
|
183
|
+
last_error = e
|
|
184
|
+
logger.warning(
|
|
185
|
+
f"OPA query failed (attempt {attempt + 1}/{self.retry_count}): "
|
|
186
|
+
f"HTTP {e.code}: {e.reason}"
|
|
187
|
+
)
|
|
188
|
+
except urllib.error.URLError as e:
|
|
189
|
+
last_error = e
|
|
190
|
+
logger.warning(
|
|
191
|
+
f"OPA query failed (attempt {attempt + 1}/{self.retry_count}): "
|
|
192
|
+
f"{e.reason}"
|
|
193
|
+
)
|
|
194
|
+
except TimeoutError as e:
|
|
195
|
+
last_error = e
|
|
196
|
+
logger.warning(
|
|
197
|
+
f"OPA query timeout (attempt {attempt + 1}/{self.retry_count})"
|
|
198
|
+
)
|
|
199
|
+
except json.JSONDecodeError as e:
|
|
200
|
+
last_error = e
|
|
201
|
+
logger.warning(
|
|
202
|
+
f"OPA response parse error (attempt {attempt + 1}/{self.retry_count})"
|
|
203
|
+
)
|
|
204
|
+
|
|
205
|
+
# Wait before retry (except on last attempt)
|
|
206
|
+
if attempt < self.retry_count - 1:
|
|
207
|
+
import time
|
|
208
|
+
time.sleep(self.retry_delay * (attempt + 1))
|
|
209
|
+
|
|
210
|
+
raise PolicyEvaluationError(
|
|
211
|
+
f"OPA query failed after {self.retry_count} attempts: {last_error}",
|
|
212
|
+
engine_name=self.name,
|
|
213
|
+
)
|
|
214
|
+
|
|
215
|
+
def _parse_opa_response(
|
|
216
|
+
self,
|
|
217
|
+
response: dict[str, Any],
|
|
218
|
+
user: UserContext,
|
|
219
|
+
action: str,
|
|
220
|
+
resource: str,
|
|
221
|
+
) -> AuthorizationResult:
|
|
222
|
+
"""
|
|
223
|
+
Parse the OPA response into an AuthorizationResult.
|
|
224
|
+
|
|
225
|
+
OPA responses typically have a "result" key containing the
|
|
226
|
+
policy decision. The structure depends on the policy.
|
|
227
|
+
|
|
228
|
+
Expected response formats:
|
|
229
|
+
1. Boolean: {"result": true}
|
|
230
|
+
2. Object with allow: {"result": {"allow": true, "reason": "..."}}
|
|
231
|
+
3. Object with deny reasons: {"result": {"allow": false, "deny": ["reason1"]}}
|
|
232
|
+
|
|
233
|
+
Args:
|
|
234
|
+
response: The OPA response.
|
|
235
|
+
user: The user context.
|
|
236
|
+
action: The action.
|
|
237
|
+
resource: The resource.
|
|
238
|
+
|
|
239
|
+
Returns:
|
|
240
|
+
AuthorizationResult with the decision.
|
|
241
|
+
"""
|
|
242
|
+
result = response.get("result")
|
|
243
|
+
|
|
244
|
+
if result is None:
|
|
245
|
+
# No result means policy doesn't define a decision
|
|
246
|
+
return AuthorizationResult(
|
|
247
|
+
allowed=False,
|
|
248
|
+
reason="OPA policy returned no result (undefined)",
|
|
249
|
+
policies_evaluated=["opa"],
|
|
250
|
+
)
|
|
251
|
+
|
|
252
|
+
# Handle boolean result
|
|
253
|
+
if isinstance(result, bool):
|
|
254
|
+
return AuthorizationResult(
|
|
255
|
+
allowed=result,
|
|
256
|
+
reason=f"OPA {'allowed' if result else 'denied'} {action} on {resource}",
|
|
257
|
+
policies_evaluated=["opa"],
|
|
258
|
+
)
|
|
259
|
+
|
|
260
|
+
# Handle object result
|
|
261
|
+
if isinstance(result, dict):
|
|
262
|
+
allowed = result.get("allow", False)
|
|
263
|
+
|
|
264
|
+
# Look for reason in various places
|
|
265
|
+
reason = result.get("reason")
|
|
266
|
+
if not reason and not allowed:
|
|
267
|
+
deny_reasons = result.get("deny", [])
|
|
268
|
+
if deny_reasons:
|
|
269
|
+
reason = "; ".join(str(r) for r in deny_reasons)
|
|
270
|
+
else:
|
|
271
|
+
reason = f"OPA denied {action} on {resource}"
|
|
272
|
+
elif not reason:
|
|
273
|
+
reason = f"OPA allowed {action} on {resource}"
|
|
274
|
+
|
|
275
|
+
return AuthorizationResult(
|
|
276
|
+
allowed=bool(allowed),
|
|
277
|
+
reason=reason,
|
|
278
|
+
policies_evaluated=["opa"],
|
|
279
|
+
metadata=result,
|
|
280
|
+
)
|
|
281
|
+
|
|
282
|
+
# Unknown format
|
|
283
|
+
return AuthorizationResult(
|
|
284
|
+
allowed=False,
|
|
285
|
+
reason=f"Unexpected OPA result format: {type(result)}",
|
|
286
|
+
policies_evaluated=["opa"],
|
|
287
|
+
)
|
|
288
|
+
|
|
289
|
+
def evaluate(
|
|
290
|
+
self,
|
|
291
|
+
user: UserContext,
|
|
292
|
+
action: str,
|
|
293
|
+
resource: str,
|
|
294
|
+
context: dict[str, Any] | None = None,
|
|
295
|
+
) -> AuthorizationResult:
|
|
296
|
+
"""
|
|
297
|
+
Evaluate an authorization request using OPA.
|
|
298
|
+
|
|
299
|
+
Sends a query to the OPA server and parses the response.
|
|
300
|
+
|
|
301
|
+
Args:
|
|
302
|
+
user: The user context.
|
|
303
|
+
action: The action being attempted.
|
|
304
|
+
resource: The resource being accessed.
|
|
305
|
+
context: Additional context for the decision.
|
|
306
|
+
|
|
307
|
+
Returns:
|
|
308
|
+
AuthorizationResult with the decision.
|
|
309
|
+
"""
|
|
310
|
+
input_data = self._build_input(user, action, resource, context)
|
|
311
|
+
|
|
312
|
+
logger.debug(f"OPA query: {action} on {resource} for user {user.user_id}")
|
|
313
|
+
|
|
314
|
+
try:
|
|
315
|
+
response = self._query_opa(input_data)
|
|
316
|
+
return self._parse_opa_response(response, user, action, resource)
|
|
317
|
+
|
|
318
|
+
except PolicyEvaluationError:
|
|
319
|
+
if self.fallback_allow:
|
|
320
|
+
logger.warning(
|
|
321
|
+
"OPA unavailable, using fallback_allow=True"
|
|
322
|
+
)
|
|
323
|
+
return AuthorizationResult(
|
|
324
|
+
allowed=True,
|
|
325
|
+
reason="OPA unavailable, fallback to allow",
|
|
326
|
+
policies_evaluated=["opa-fallback"],
|
|
327
|
+
)
|
|
328
|
+
raise
|
|
329
|
+
|
|
330
|
+
async def evaluate_async(
|
|
331
|
+
self,
|
|
332
|
+
user: UserContext,
|
|
333
|
+
action: str,
|
|
334
|
+
resource: str,
|
|
335
|
+
context: dict[str, Any] | None = None,
|
|
336
|
+
) -> AuthorizationResult:
|
|
337
|
+
"""
|
|
338
|
+
Async version of evaluate.
|
|
339
|
+
|
|
340
|
+
Uses asyncio to make non-blocking HTTP requests.
|
|
341
|
+
|
|
342
|
+
Args:
|
|
343
|
+
user: The user context.
|
|
344
|
+
action: The action being attempted.
|
|
345
|
+
resource: The resource being accessed.
|
|
346
|
+
context: Additional context.
|
|
347
|
+
|
|
348
|
+
Returns:
|
|
349
|
+
AuthorizationResult with the decision.
|
|
350
|
+
"""
|
|
351
|
+
# Run sync version in thread pool
|
|
352
|
+
loop = asyncio.get_event_loop()
|
|
353
|
+
return await loop.run_in_executor(
|
|
354
|
+
None, self.evaluate, user, action, resource, context
|
|
355
|
+
)
|
|
356
|
+
|
|
357
|
+
def load_policies(self, source: str | Path) -> None:
|
|
358
|
+
"""
|
|
359
|
+
Load policies into OPA.
|
|
360
|
+
|
|
361
|
+
This method uploads Rego policies to the OPA server.
|
|
362
|
+
|
|
363
|
+
Args:
|
|
364
|
+
source: Path to a Rego file or directory of Rego files.
|
|
365
|
+
"""
|
|
366
|
+
path = Path(source)
|
|
367
|
+
|
|
368
|
+
if path.is_file():
|
|
369
|
+
self._upload_policy(path)
|
|
370
|
+
elif path.is_dir():
|
|
371
|
+
for rego_file in path.glob("**/*.rego"):
|
|
372
|
+
self._upload_policy(rego_file)
|
|
373
|
+
else:
|
|
374
|
+
raise PolicyLoadError(
|
|
375
|
+
f"Policy source not found: {source}",
|
|
376
|
+
engine_name=self.name,
|
|
377
|
+
)
|
|
378
|
+
|
|
379
|
+
def _upload_policy(self, policy_path: Path) -> None:
|
|
380
|
+
"""
|
|
381
|
+
Upload a single policy file to OPA.
|
|
382
|
+
|
|
383
|
+
Args:
|
|
384
|
+
policy_path: Path to the Rego policy file.
|
|
385
|
+
"""
|
|
386
|
+
policy_name = policy_path.stem
|
|
387
|
+
policy_content = policy_path.read_text()
|
|
388
|
+
|
|
389
|
+
url = f"{self.opa_url.rstrip('/')}/v1/policies/{policy_name}"
|
|
390
|
+
|
|
391
|
+
try:
|
|
392
|
+
req = urllib.request.Request(
|
|
393
|
+
url,
|
|
394
|
+
data=policy_content.encode("utf-8"),
|
|
395
|
+
headers={"Content-Type": "text/plain"},
|
|
396
|
+
method="PUT",
|
|
397
|
+
)
|
|
398
|
+
|
|
399
|
+
with urllib.request.urlopen(req, timeout=self.timeout) as response:
|
|
400
|
+
if response.status == 200:
|
|
401
|
+
logger.info(f"Uploaded policy: {policy_name}")
|
|
402
|
+
else:
|
|
403
|
+
logger.warning(
|
|
404
|
+
f"Policy upload returned status {response.status}"
|
|
405
|
+
)
|
|
406
|
+
|
|
407
|
+
except urllib.error.HTTPError as e:
|
|
408
|
+
raise PolicyLoadError(
|
|
409
|
+
f"Failed to upload policy {policy_name}: HTTP {e.code}",
|
|
410
|
+
engine_name=self.name,
|
|
411
|
+
) from e
|
|
412
|
+
except urllib.error.URLError as e:
|
|
413
|
+
raise PolicyLoadError(
|
|
414
|
+
f"Failed to connect to OPA: {e.reason}",
|
|
415
|
+
engine_name=self.name,
|
|
416
|
+
) from e
|
|
417
|
+
|
|
418
|
+
def health_check(self) -> bool:
|
|
419
|
+
"""
|
|
420
|
+
Check if the OPA server is healthy.
|
|
421
|
+
|
|
422
|
+
Returns:
|
|
423
|
+
True if OPA is reachable and healthy.
|
|
424
|
+
"""
|
|
425
|
+
health_url = f"{self.opa_url.rstrip('/')}/health"
|
|
426
|
+
|
|
427
|
+
try:
|
|
428
|
+
req = urllib.request.Request(health_url, method="GET")
|
|
429
|
+
with urllib.request.urlopen(req, timeout=self.timeout) as response:
|
|
430
|
+
return response.status == 200
|
|
431
|
+
except Exception:
|
|
432
|
+
return False
|
|
433
|
+
|
|
434
|
+
def explain(
|
|
435
|
+
self,
|
|
436
|
+
user: UserContext,
|
|
437
|
+
action: str,
|
|
438
|
+
resource: str,
|
|
439
|
+
context: dict[str, Any] | None = None,
|
|
440
|
+
) -> dict[str, Any]:
|
|
441
|
+
"""
|
|
442
|
+
Get an explanation of the authorization decision.
|
|
443
|
+
|
|
444
|
+
Uses OPA's explain feature to show how the decision was made.
|
|
445
|
+
|
|
446
|
+
Args:
|
|
447
|
+
user: The user context.
|
|
448
|
+
action: The action being attempted.
|
|
449
|
+
resource: The resource being accessed.
|
|
450
|
+
context: Additional context.
|
|
451
|
+
|
|
452
|
+
Returns:
|
|
453
|
+
Dictionary with explanation details.
|
|
454
|
+
"""
|
|
455
|
+
input_data = self._build_input(user, action, resource, context)
|
|
456
|
+
|
|
457
|
+
# Add explain parameter
|
|
458
|
+
explain_url = f"{self._query_url}?explain=full"
|
|
459
|
+
|
|
460
|
+
try:
|
|
461
|
+
request_data = json.dumps(input_data).encode("utf-8")
|
|
462
|
+
req = urllib.request.Request(
|
|
463
|
+
explain_url,
|
|
464
|
+
data=request_data,
|
|
465
|
+
headers={
|
|
466
|
+
"Content-Type": "application/json",
|
|
467
|
+
"Accept": "application/json",
|
|
468
|
+
},
|
|
469
|
+
method="POST",
|
|
470
|
+
)
|
|
471
|
+
|
|
472
|
+
with urllib.request.urlopen(req, timeout=self.timeout) as response:
|
|
473
|
+
return json.loads(response.read().decode("utf-8"))
|
|
474
|
+
|
|
475
|
+
except Exception as e:
|
|
476
|
+
return {
|
|
477
|
+
"error": str(e),
|
|
478
|
+
"input": input_data,
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
def get_decision_id(self, response: dict[str, Any]) -> str | None:
|
|
482
|
+
"""
|
|
483
|
+
Extract the decision ID from an OPA response.
|
|
484
|
+
|
|
485
|
+
Decision IDs are useful for auditing and debugging.
|
|
486
|
+
|
|
487
|
+
Args:
|
|
488
|
+
response: The OPA response.
|
|
489
|
+
|
|
490
|
+
Returns:
|
|
491
|
+
The decision ID if present.
|
|
492
|
+
"""
|
|
493
|
+
return response.get("decision_id")
|