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.
Files changed (94) hide show
  1. proxilion/__init__.py +136 -0
  2. proxilion/audit/__init__.py +133 -0
  3. proxilion/audit/base_exporters.py +527 -0
  4. proxilion/audit/compliance/__init__.py +130 -0
  5. proxilion/audit/compliance/base.py +457 -0
  6. proxilion/audit/compliance/eu_ai_act.py +603 -0
  7. proxilion/audit/compliance/iso27001.py +544 -0
  8. proxilion/audit/compliance/soc2.py +491 -0
  9. proxilion/audit/events.py +493 -0
  10. proxilion/audit/explainability.py +1173 -0
  11. proxilion/audit/exporters/__init__.py +58 -0
  12. proxilion/audit/exporters/aws_s3.py +636 -0
  13. proxilion/audit/exporters/azure_storage.py +608 -0
  14. proxilion/audit/exporters/cloud_base.py +468 -0
  15. proxilion/audit/exporters/gcp_storage.py +570 -0
  16. proxilion/audit/exporters/multi_exporter.py +498 -0
  17. proxilion/audit/hash_chain.py +652 -0
  18. proxilion/audit/logger.py +543 -0
  19. proxilion/caching/__init__.py +49 -0
  20. proxilion/caching/tool_cache.py +633 -0
  21. proxilion/context/__init__.py +73 -0
  22. proxilion/context/context_window.py +556 -0
  23. proxilion/context/message_history.py +505 -0
  24. proxilion/context/session.py +735 -0
  25. proxilion/contrib/__init__.py +51 -0
  26. proxilion/contrib/anthropic.py +609 -0
  27. proxilion/contrib/google.py +1012 -0
  28. proxilion/contrib/langchain.py +641 -0
  29. proxilion/contrib/mcp.py +893 -0
  30. proxilion/contrib/openai.py +646 -0
  31. proxilion/core.py +3058 -0
  32. proxilion/decorators.py +966 -0
  33. proxilion/engines/__init__.py +287 -0
  34. proxilion/engines/base.py +266 -0
  35. proxilion/engines/casbin_engine.py +412 -0
  36. proxilion/engines/opa_engine.py +493 -0
  37. proxilion/engines/simple.py +437 -0
  38. proxilion/exceptions.py +887 -0
  39. proxilion/guards/__init__.py +54 -0
  40. proxilion/guards/input_guard.py +522 -0
  41. proxilion/guards/output_guard.py +634 -0
  42. proxilion/observability/__init__.py +198 -0
  43. proxilion/observability/cost_tracker.py +866 -0
  44. proxilion/observability/hooks.py +683 -0
  45. proxilion/observability/metrics.py +798 -0
  46. proxilion/observability/session_cost_tracker.py +1063 -0
  47. proxilion/policies/__init__.py +67 -0
  48. proxilion/policies/base.py +304 -0
  49. proxilion/policies/builtin.py +486 -0
  50. proxilion/policies/registry.py +376 -0
  51. proxilion/providers/__init__.py +201 -0
  52. proxilion/providers/adapter.py +468 -0
  53. proxilion/providers/anthropic_adapter.py +330 -0
  54. proxilion/providers/gemini_adapter.py +391 -0
  55. proxilion/providers/openai_adapter.py +294 -0
  56. proxilion/py.typed +0 -0
  57. proxilion/resilience/__init__.py +81 -0
  58. proxilion/resilience/degradation.py +615 -0
  59. proxilion/resilience/fallback.py +555 -0
  60. proxilion/resilience/retry.py +554 -0
  61. proxilion/scheduling/__init__.py +57 -0
  62. proxilion/scheduling/priority_queue.py +419 -0
  63. proxilion/scheduling/scheduler.py +459 -0
  64. proxilion/security/__init__.py +244 -0
  65. proxilion/security/agent_trust.py +968 -0
  66. proxilion/security/behavioral_drift.py +794 -0
  67. proxilion/security/cascade_protection.py +869 -0
  68. proxilion/security/circuit_breaker.py +428 -0
  69. proxilion/security/cost_limiter.py +690 -0
  70. proxilion/security/idor_protection.py +460 -0
  71. proxilion/security/intent_capsule.py +849 -0
  72. proxilion/security/intent_validator.py +495 -0
  73. proxilion/security/memory_integrity.py +767 -0
  74. proxilion/security/rate_limiter.py +509 -0
  75. proxilion/security/scope_enforcer.py +680 -0
  76. proxilion/security/sequence_validator.py +636 -0
  77. proxilion/security/trust_boundaries.py +784 -0
  78. proxilion/streaming/__init__.py +70 -0
  79. proxilion/streaming/detector.py +761 -0
  80. proxilion/streaming/transformer.py +674 -0
  81. proxilion/timeouts/__init__.py +55 -0
  82. proxilion/timeouts/decorators.py +477 -0
  83. proxilion/timeouts/manager.py +545 -0
  84. proxilion/tools/__init__.py +69 -0
  85. proxilion/tools/decorators.py +493 -0
  86. proxilion/tools/registry.py +732 -0
  87. proxilion/types.py +339 -0
  88. proxilion/validation/__init__.py +93 -0
  89. proxilion/validation/pydantic_schema.py +351 -0
  90. proxilion/validation/schema.py +651 -0
  91. proxilion-0.0.1.dist-info/METADATA +872 -0
  92. proxilion-0.0.1.dist-info/RECORD +94 -0
  93. proxilion-0.0.1.dist-info/WHEEL +4 -0
  94. 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")