kubiya-control-plane-api 0.1.0__py3-none-any.whl → 0.3.4__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.

Potentially problematic release.


This version of kubiya-control-plane-api might be problematic. Click here for more details.

Files changed (185) hide show
  1. control_plane_api/README.md +266 -0
  2. control_plane_api/__init__.py +0 -0
  3. control_plane_api/__version__.py +1 -0
  4. control_plane_api/alembic/README +1 -0
  5. control_plane_api/alembic/env.py +98 -0
  6. control_plane_api/alembic/script.py.mako +28 -0
  7. control_plane_api/alembic/versions/1382bec74309_initial_migration_with_all_models.py +251 -0
  8. control_plane_api/alembic/versions/1f54bc2a37e3_add_analytics_tables.py +162 -0
  9. control_plane_api/alembic/versions/2e4cb136dc10_rename_toolset_ids_to_skill_ids_in_teams.py +30 -0
  10. control_plane_api/alembic/versions/31cd69a644ce_add_skill_templates_table.py +28 -0
  11. control_plane_api/alembic/versions/89e127caa47d_add_jobs_and_job_executions_tables.py +161 -0
  12. control_plane_api/alembic/versions/add_llm_models_table.py +51 -0
  13. control_plane_api/alembic/versions/b0e10697f212_add_runtime_column_to_teams_simple.py +42 -0
  14. control_plane_api/alembic/versions/ce43b24b63bf_add_execution_trigger_source_and_fix_.py +155 -0
  15. control_plane_api/alembic/versions/d4eaf16e3f8d_rename_toolsets_to_skills.py +84 -0
  16. control_plane_api/alembic/versions/efa2dc427da1_rename_metadata_to_custom_metadata.py +32 -0
  17. control_plane_api/alembic/versions/f973b431d1ce_add_workflow_executor_to_skill_types.py +44 -0
  18. control_plane_api/alembic.ini +148 -0
  19. control_plane_api/api/index.py +12 -0
  20. control_plane_api/app/__init__.py +11 -0
  21. control_plane_api/app/activities/__init__.py +20 -0
  22. control_plane_api/app/activities/agent_activities.py +379 -0
  23. control_plane_api/app/activities/team_activities.py +410 -0
  24. control_plane_api/app/activities/temporal_cloud_activities.py +577 -0
  25. control_plane_api/app/config/__init__.py +35 -0
  26. control_plane_api/app/config/api_config.py +354 -0
  27. control_plane_api/app/config/model_pricing.py +318 -0
  28. control_plane_api/app/config.py +95 -0
  29. control_plane_api/app/database.py +135 -0
  30. control_plane_api/app/exceptions.py +408 -0
  31. control_plane_api/app/lib/__init__.py +11 -0
  32. control_plane_api/app/lib/job_executor.py +312 -0
  33. control_plane_api/app/lib/kubiya_client.py +235 -0
  34. control_plane_api/app/lib/litellm_pricing.py +166 -0
  35. control_plane_api/app/lib/planning_tools/__init__.py +22 -0
  36. control_plane_api/app/lib/planning_tools/agents.py +155 -0
  37. control_plane_api/app/lib/planning_tools/base.py +189 -0
  38. control_plane_api/app/lib/planning_tools/environments.py +214 -0
  39. control_plane_api/app/lib/planning_tools/resources.py +240 -0
  40. control_plane_api/app/lib/planning_tools/teams.py +198 -0
  41. control_plane_api/app/lib/policy_enforcer_client.py +939 -0
  42. control_plane_api/app/lib/redis_client.py +436 -0
  43. control_plane_api/app/lib/supabase.py +71 -0
  44. control_plane_api/app/lib/temporal_client.py +138 -0
  45. control_plane_api/app/lib/validation/__init__.py +20 -0
  46. control_plane_api/app/lib/validation/runtime_validation.py +287 -0
  47. control_plane_api/app/main.py +128 -0
  48. control_plane_api/app/middleware/__init__.py +8 -0
  49. control_plane_api/app/middleware/auth.py +513 -0
  50. control_plane_api/app/middleware/exception_handler.py +267 -0
  51. control_plane_api/app/middleware/rate_limiting.py +384 -0
  52. control_plane_api/app/middleware/request_id.py +202 -0
  53. control_plane_api/app/models/__init__.py +27 -0
  54. control_plane_api/app/models/agent.py +79 -0
  55. control_plane_api/app/models/analytics.py +206 -0
  56. control_plane_api/app/models/associations.py +81 -0
  57. control_plane_api/app/models/environment.py +63 -0
  58. control_plane_api/app/models/execution.py +93 -0
  59. control_plane_api/app/models/job.py +179 -0
  60. control_plane_api/app/models/llm_model.py +75 -0
  61. control_plane_api/app/models/presence.py +49 -0
  62. control_plane_api/app/models/project.py +47 -0
  63. control_plane_api/app/models/session.py +38 -0
  64. control_plane_api/app/models/team.py +66 -0
  65. control_plane_api/app/models/workflow.py +55 -0
  66. control_plane_api/app/policies/README.md +121 -0
  67. control_plane_api/app/policies/approved_users.rego +62 -0
  68. control_plane_api/app/policies/business_hours.rego +51 -0
  69. control_plane_api/app/policies/rate_limiting.rego +100 -0
  70. control_plane_api/app/policies/tool_restrictions.rego +86 -0
  71. control_plane_api/app/routers/__init__.py +4 -0
  72. control_plane_api/app/routers/agents.py +364 -0
  73. control_plane_api/app/routers/agents_v2.py +1260 -0
  74. control_plane_api/app/routers/analytics.py +1014 -0
  75. control_plane_api/app/routers/context_manager.py +562 -0
  76. control_plane_api/app/routers/environment_context.py +270 -0
  77. control_plane_api/app/routers/environments.py +715 -0
  78. control_plane_api/app/routers/execution_environment.py +517 -0
  79. control_plane_api/app/routers/executions.py +1911 -0
  80. control_plane_api/app/routers/health.py +92 -0
  81. control_plane_api/app/routers/health_v2.py +326 -0
  82. control_plane_api/app/routers/integrations.py +274 -0
  83. control_plane_api/app/routers/jobs.py +1344 -0
  84. control_plane_api/app/routers/models.py +82 -0
  85. control_plane_api/app/routers/models_v2.py +361 -0
  86. control_plane_api/app/routers/policies.py +639 -0
  87. control_plane_api/app/routers/presence.py +234 -0
  88. control_plane_api/app/routers/projects.py +902 -0
  89. control_plane_api/app/routers/runners.py +379 -0
  90. control_plane_api/app/routers/runtimes.py +172 -0
  91. control_plane_api/app/routers/secrets.py +155 -0
  92. control_plane_api/app/routers/skills.py +1001 -0
  93. control_plane_api/app/routers/skills_definitions.py +140 -0
  94. control_plane_api/app/routers/task_planning.py +1256 -0
  95. control_plane_api/app/routers/task_queues.py +654 -0
  96. control_plane_api/app/routers/team_context.py +270 -0
  97. control_plane_api/app/routers/teams.py +1400 -0
  98. control_plane_api/app/routers/worker_queues.py +1545 -0
  99. control_plane_api/app/routers/workers.py +935 -0
  100. control_plane_api/app/routers/workflows.py +204 -0
  101. control_plane_api/app/runtimes/__init__.py +6 -0
  102. control_plane_api/app/runtimes/validation.py +344 -0
  103. control_plane_api/app/schemas/job_schemas.py +295 -0
  104. control_plane_api/app/services/__init__.py +1 -0
  105. control_plane_api/app/services/agno_service.py +619 -0
  106. control_plane_api/app/services/litellm_service.py +190 -0
  107. control_plane_api/app/services/policy_service.py +525 -0
  108. control_plane_api/app/services/temporal_cloud_provisioning.py +150 -0
  109. control_plane_api/app/skills/__init__.py +44 -0
  110. control_plane_api/app/skills/base.py +229 -0
  111. control_plane_api/app/skills/business_intelligence.py +189 -0
  112. control_plane_api/app/skills/data_visualization.py +154 -0
  113. control_plane_api/app/skills/docker.py +104 -0
  114. control_plane_api/app/skills/file_generation.py +94 -0
  115. control_plane_api/app/skills/file_system.py +110 -0
  116. control_plane_api/app/skills/python.py +92 -0
  117. control_plane_api/app/skills/registry.py +65 -0
  118. control_plane_api/app/skills/shell.py +102 -0
  119. control_plane_api/app/skills/workflow_executor.py +469 -0
  120. control_plane_api/app/utils/workflow_executor.py +354 -0
  121. control_plane_api/app/workflows/__init__.py +11 -0
  122. control_plane_api/app/workflows/agent_execution.py +507 -0
  123. control_plane_api/app/workflows/agent_execution_with_skills.py +222 -0
  124. control_plane_api/app/workflows/namespace_provisioning.py +326 -0
  125. control_plane_api/app/workflows/team_execution.py +399 -0
  126. control_plane_api/scripts/seed_models.py +239 -0
  127. control_plane_api/worker/__init__.py +0 -0
  128. control_plane_api/worker/activities/__init__.py +0 -0
  129. control_plane_api/worker/activities/agent_activities.py +1241 -0
  130. control_plane_api/worker/activities/approval_activities.py +234 -0
  131. control_plane_api/worker/activities/runtime_activities.py +388 -0
  132. control_plane_api/worker/activities/skill_activities.py +267 -0
  133. control_plane_api/worker/activities/team_activities.py +1217 -0
  134. control_plane_api/worker/config/__init__.py +31 -0
  135. control_plane_api/worker/config/worker_config.py +275 -0
  136. control_plane_api/worker/control_plane_client.py +529 -0
  137. control_plane_api/worker/examples/analytics_integration_example.py +362 -0
  138. control_plane_api/worker/models/__init__.py +1 -0
  139. control_plane_api/worker/models/inputs.py +89 -0
  140. control_plane_api/worker/runtimes/__init__.py +31 -0
  141. control_plane_api/worker/runtimes/base.py +789 -0
  142. control_plane_api/worker/runtimes/claude_code_runtime.py +1443 -0
  143. control_plane_api/worker/runtimes/default_runtime.py +617 -0
  144. control_plane_api/worker/runtimes/factory.py +173 -0
  145. control_plane_api/worker/runtimes/validation.py +93 -0
  146. control_plane_api/worker/services/__init__.py +1 -0
  147. control_plane_api/worker/services/agent_executor.py +422 -0
  148. control_plane_api/worker/services/agent_executor_v2.py +383 -0
  149. control_plane_api/worker/services/analytics_collector.py +457 -0
  150. control_plane_api/worker/services/analytics_service.py +464 -0
  151. control_plane_api/worker/services/approval_tools.py +310 -0
  152. control_plane_api/worker/services/approval_tools_agno.py +207 -0
  153. control_plane_api/worker/services/cancellation_manager.py +177 -0
  154. control_plane_api/worker/services/data_visualization.py +827 -0
  155. control_plane_api/worker/services/jira_tools.py +257 -0
  156. control_plane_api/worker/services/runtime_analytics.py +328 -0
  157. control_plane_api/worker/services/session_service.py +194 -0
  158. control_plane_api/worker/services/skill_factory.py +175 -0
  159. control_plane_api/worker/services/team_executor.py +574 -0
  160. control_plane_api/worker/services/team_executor_v2.py +465 -0
  161. control_plane_api/worker/services/workflow_executor_tools.py +1418 -0
  162. control_plane_api/worker/tests/__init__.py +1 -0
  163. control_plane_api/worker/tests/e2e/__init__.py +0 -0
  164. control_plane_api/worker/tests/e2e/test_execution_flow.py +571 -0
  165. control_plane_api/worker/tests/integration/__init__.py +0 -0
  166. control_plane_api/worker/tests/integration/test_control_plane_integration.py +308 -0
  167. control_plane_api/worker/tests/unit/__init__.py +0 -0
  168. control_plane_api/worker/tests/unit/test_control_plane_client.py +401 -0
  169. control_plane_api/worker/utils/__init__.py +1 -0
  170. control_plane_api/worker/utils/chunk_batcher.py +305 -0
  171. control_plane_api/worker/utils/retry_utils.py +60 -0
  172. control_plane_api/worker/utils/streaming_utils.py +373 -0
  173. control_plane_api/worker/worker.py +753 -0
  174. control_plane_api/worker/workflows/__init__.py +0 -0
  175. control_plane_api/worker/workflows/agent_execution.py +589 -0
  176. control_plane_api/worker/workflows/team_execution.py +429 -0
  177. kubiya_control_plane_api-0.3.4.dist-info/METADATA +229 -0
  178. kubiya_control_plane_api-0.3.4.dist-info/RECORD +182 -0
  179. kubiya_control_plane_api-0.3.4.dist-info/entry_points.txt +2 -0
  180. kubiya_control_plane_api-0.3.4.dist-info/top_level.txt +1 -0
  181. kubiya_control_plane_api-0.1.0.dist-info/METADATA +0 -66
  182. kubiya_control_plane_api-0.1.0.dist-info/RECORD +0 -5
  183. kubiya_control_plane_api-0.1.0.dist-info/top_level.txt +0 -1
  184. {kubiya_control_plane_api-0.1.0.dist-info/licenses → control_plane_api}/LICENSE +0 -0
  185. {kubiya_control_plane_api-0.1.0.dist-info → kubiya_control_plane_api-0.3.4.dist-info}/WHEEL +0 -0
@@ -0,0 +1,939 @@
1
+ """
2
+ Policy Enforcer Client - Integration with OPA Watchdog Enforcer Service.
3
+
4
+ This module provides a robust, async client for interacting with the OPA Watchdog
5
+ policy enforcement service. It follows best practices including:
6
+
7
+ - Async/await for non-blocking I/O
8
+ - Proper exception hierarchy
9
+ - Context manager support for resource cleanup
10
+ - Pydantic models for validation
11
+ - Dependency injection (no singletons)
12
+ - Separation of concerns with specialized clients
13
+ - Retry logic and circuit breaker patterns
14
+ - Comprehensive logging
15
+
16
+ The enforcer service URL is configured via ENFORCER_SERVICE_URL environment variable.
17
+ Default: https://enforcer-psi.vercel.app
18
+
19
+ Usage:
20
+ from control_plane_api.app.lib.policy_enforcer_client import PolicyEnforcerClient
21
+
22
+ async with PolicyEnforcerClient(base_url="...", api_key="...") as client:
23
+ policy = await client.policies.create(name="...", policy_content="...")
24
+ result = await client.evaluation.evaluate(policy_id="...", input_data={...})
25
+ """
26
+
27
+ import os
28
+ import httpx
29
+ from datetime import datetime, timezone
30
+ from typing import Optional, Dict, List, Any, Protocol
31
+ from enum import Enum
32
+ from pydantic import BaseModel, Field, validator
33
+ import structlog
34
+ from contextlib import asynccontextmanager
35
+
36
+ logger = structlog.get_logger()
37
+
38
+
39
+ # ============================================================================
40
+ # Custom Exceptions
41
+ # ============================================================================
42
+
43
+ class PolicyEnforcerError(Exception):
44
+ """Base exception for all policy enforcer errors"""
45
+ pass
46
+
47
+
48
+ class PolicyNotFoundError(PolicyEnforcerError):
49
+ """Raised when a policy is not found"""
50
+ pass
51
+
52
+
53
+ class PolicyValidationError(PolicyEnforcerError):
54
+ """Raised when policy validation fails"""
55
+ pass
56
+
57
+
58
+ class PolicyEvaluationError(PolicyEnforcerError):
59
+ """Raised when policy evaluation fails"""
60
+ pass
61
+
62
+
63
+ class RequestNotFoundError(PolicyEnforcerError):
64
+ """Raised when a request is not found"""
65
+ pass
66
+
67
+
68
+ class EnforcerConnectionError(PolicyEnforcerError):
69
+ """Raised when connection to enforcer service fails"""
70
+ pass
71
+
72
+
73
+ class EnforcerAuthenticationError(PolicyEnforcerError):
74
+ """Raised when authentication with enforcer service fails"""
75
+ pass
76
+
77
+
78
+ # ============================================================================
79
+ # Pydantic Models
80
+ # ============================================================================
81
+
82
+ class PolicyType(str, Enum):
83
+ """Policy type enumeration"""
84
+ REGO = "rego"
85
+
86
+
87
+ class Decision(str, Enum):
88
+ """Policy evaluation decision"""
89
+ PERMIT = "permit"
90
+ DENY = "deny"
91
+
92
+
93
+ class RequestStatus(str, Enum):
94
+ """Request approval status"""
95
+ PENDING = "pending"
96
+ APPROVED = "approved"
97
+ REJECTED = "rejected"
98
+ EXPIRED = "expired"
99
+
100
+
101
+ class Policy(BaseModel):
102
+ """Policy model matching the enforcer service schema"""
103
+ id: str = Field(..., description="Policy UUID")
104
+ name: str = Field(..., min_length=1, max_length=255, description="Policy name")
105
+ policy_content: Optional[str] = Field(None, description="OPA Rego policy content (optional in list responses)")
106
+ org: str = Field(..., description="Organization ID")
107
+ enabled: bool = Field(default=True, description="Whether policy is enabled")
108
+ description: Optional[str] = Field(None, description="Policy description")
109
+ policy_type: PolicyType = Field(default=PolicyType.REGO, description="Policy type")
110
+ tags: List[str] = Field(default_factory=list, description="Policy tags")
111
+ metadata: Dict[str, Any] = Field(default_factory=dict, description="Additional metadata")
112
+ version: int = Field(default=1, ge=1, description="Policy version")
113
+ created_at: Optional[datetime] = Field(None, description="Creation timestamp")
114
+ updated_at: Optional[datetime] = Field(None, description="Last update timestamp")
115
+ created_by: Optional[str] = Field(None, description="Creator email")
116
+ updated_by: Optional[str] = Field(None, description="Last updater email")
117
+
118
+ class Config:
119
+ use_enum_values = True
120
+
121
+
122
+ class PolicyCreate(BaseModel):
123
+ """Schema for creating a new policy"""
124
+ name: str = Field(..., min_length=1, max_length=255)
125
+ policy_content: str = Field(..., min_length=1)
126
+ description: Optional[str] = None
127
+ enabled: bool = True
128
+ tags: List[str] = Field(default_factory=list)
129
+ metadata: Dict[str, Any] = Field(default_factory=dict)
130
+
131
+
132
+ class PolicyUpdate(BaseModel):
133
+ """Schema for updating an existing policy"""
134
+ name: Optional[str] = Field(None, min_length=1, max_length=255)
135
+ policy_content: Optional[str] = None
136
+ description: Optional[str] = None
137
+ enabled: Optional[bool] = None
138
+ tags: Optional[List[str]] = None
139
+ metadata: Optional[Dict[str, Any]] = None
140
+
141
+
142
+ class EvaluationResult(BaseModel):
143
+ """Policy evaluation result"""
144
+ allow: bool = Field(..., description="Whether the action is allowed")
145
+ decision: Decision = Field(..., description="Evaluation decision")
146
+ violations: List[str] = Field(default_factory=list, description="List of violations")
147
+ metadata: Dict[str, Any] = Field(default_factory=dict, description="Evaluation metadata")
148
+
149
+ class Config:
150
+ use_enum_values = True
151
+
152
+
153
+ class EvaluationRequest(BaseModel):
154
+ """Policy evaluation request"""
155
+ input: Dict[str, Any] = Field(..., description="Input data for evaluation")
156
+ policy_id: Optional[str] = Field(None, description="Policy UUID")
157
+ policy_name: Optional[str] = Field(None, description="Policy name")
158
+
159
+ @validator("policy_id", "policy_name")
160
+ def validate_policy_identifier(cls, v, values):
161
+ """Ensure at least one policy identifier is provided"""
162
+ if not v and not values.get("policy_id") and not values.get("policy_name"):
163
+ raise ValueError("Either policy_id or policy_name must be provided")
164
+ return v
165
+
166
+
167
+ class ValidationResult(BaseModel):
168
+ """Policy validation result"""
169
+ valid: bool = Field(..., description="Whether the policy is valid")
170
+ errors: List[str] = Field(default_factory=list, description="Validation errors")
171
+ warnings: List[str] = Field(default_factory=list, description="Validation warnings")
172
+
173
+
174
+ class ApprovalRequest(BaseModel):
175
+ """Approval request model"""
176
+ id: str = Field(..., description="Request UUID")
177
+ request_id: str = Field(..., description="Request identifier")
178
+ org: str = Field(..., description="Organization ID")
179
+ runner: str = Field(..., description="Runner name")
180
+ request_hash: str = Field(..., description="Request hash")
181
+ approved: bool = Field(..., description="Whether approved")
182
+ ttl: datetime = Field(..., description="Time to live")
183
+ created_at: datetime = Field(..., description="Creation timestamp")
184
+ approved_at: Optional[datetime] = Field(None, description="Approval timestamp")
185
+ approved_by: Optional[str] = Field(None, description="Approver email")
186
+ request_data: Dict[str, Any] = Field(default_factory=dict, description="Request data")
187
+ metadata: Dict[str, Any] = Field(default_factory=dict, description="Additional metadata")
188
+
189
+
190
+ class PolicyListResponse(BaseModel):
191
+ """Response for list policies endpoint"""
192
+ policies: List[Policy]
193
+ total: int
194
+ page: Optional[int] = 1 # Optional because enforcer may not return it
195
+ limit: int
196
+ has_more: bool
197
+
198
+
199
+ class RequestListResponse(BaseModel):
200
+ """Response for list requests endpoint"""
201
+ requests: List[ApprovalRequest]
202
+ total: int
203
+ page: int
204
+ limit: int
205
+ has_more: bool
206
+
207
+
208
+ # ============================================================================
209
+ # Specialized Clients (Separation of Concerns)
210
+ # ============================================================================
211
+
212
+ class PolicyOperations:
213
+ """Handles policy CRUD operations"""
214
+
215
+ def __init__(self, client: httpx.AsyncClient, base_url: str, headers: Dict[str, str]):
216
+ self._client = client
217
+ self._base_url = base_url
218
+ self._headers = headers
219
+
220
+ async def create(self, policy: PolicyCreate) -> Policy:
221
+ """
222
+ Create a new OPA policy.
223
+
224
+ Args:
225
+ policy: Policy creation data
226
+
227
+ Returns:
228
+ Created Policy object
229
+
230
+ Raises:
231
+ PolicyValidationError: If policy content is invalid
232
+ EnforcerConnectionError: If connection fails
233
+ """
234
+ try:
235
+ url = f"{self._base_url}/api/v1/policies"
236
+ response = await self._client.post(
237
+ url,
238
+ json=policy.dict(exclude_none=True),
239
+ headers=self._headers
240
+ )
241
+
242
+ if response.status_code == 201:
243
+ data = response.json()
244
+ logger.info(
245
+ "policy_created",
246
+ policy_id=data.get("id"),
247
+ policy_name=policy.name,
248
+ )
249
+ return Policy(**data)
250
+ elif response.status_code == 400:
251
+ error_data = response.json()
252
+ raise PolicyValidationError(
253
+ error_data.get("error", "Policy validation failed")
254
+ )
255
+ elif response.status_code == 401:
256
+ raise EnforcerAuthenticationError("Authentication failed")
257
+ elif response.status_code == 409:
258
+ error_data = response.json()
259
+ raise PolicyValidationError(
260
+ error_data.get("error", "Policy already exists")
261
+ )
262
+ else:
263
+ raise PolicyEnforcerError(
264
+ f"Failed to create policy: HTTP {response.status_code}"
265
+ )
266
+
267
+ except httpx.RequestError as e:
268
+ logger.error("policy_creation_request_failed", error=str(e))
269
+ raise EnforcerConnectionError(f"Connection failed: {str(e)}") from e
270
+
271
+ async def get(self, policy_id: str) -> Policy:
272
+ """
273
+ Get a specific policy by ID.
274
+
275
+ Args:
276
+ policy_id: Policy UUID
277
+
278
+ Returns:
279
+ Policy object
280
+
281
+ Raises:
282
+ PolicyNotFoundError: If policy doesn't exist
283
+ EnforcerConnectionError: If connection fails
284
+ """
285
+ try:
286
+ url = f"{self._base_url}/api/v1/policies/{policy_id}"
287
+ response = await self._client.get(url, headers=self._headers)
288
+
289
+ if response.status_code == 200:
290
+ return Policy(**response.json())
291
+ elif response.status_code == 404:
292
+ raise PolicyNotFoundError(f"Policy {policy_id} not found")
293
+ elif response.status_code == 401:
294
+ raise EnforcerAuthenticationError("Authentication failed")
295
+ else:
296
+ raise PolicyEnforcerError(
297
+ f"Failed to get policy: HTTP {response.status_code}"
298
+ )
299
+
300
+ except httpx.RequestError as e:
301
+ logger.error("policy_get_request_failed", error=str(e))
302
+ raise EnforcerConnectionError(f"Connection failed: {str(e)}") from e
303
+
304
+ async def list(
305
+ self,
306
+ page: int = 1,
307
+ limit: int = 20,
308
+ enabled: Optional[bool] = None,
309
+ search: Optional[str] = None,
310
+ ) -> PolicyListResponse:
311
+ """
312
+ List policies with pagination and filtering.
313
+
314
+ Args:
315
+ page: Page number (default: 1)
316
+ limit: Items per page (default: 20, max: 100)
317
+ enabled: Filter by enabled status
318
+ search: Search term for policy name or description
319
+
320
+ Returns:
321
+ PolicyListResponse with policies and pagination info
322
+ """
323
+ try:
324
+ url = f"{self._base_url}/api/v1/policies"
325
+ params = {"page": page, "limit": min(limit, 100)}
326
+
327
+ if enabled is not None:
328
+ params["enabled"] = enabled
329
+ if search:
330
+ params["search"] = search
331
+
332
+ response = await self._client.get(
333
+ url,
334
+ params=params,
335
+ headers=self._headers
336
+ )
337
+
338
+ if response.status_code == 200:
339
+ data = response.json()
340
+ logger.info(
341
+ "policies_listed",
342
+ count=len(data.get("policies", [])),
343
+ total=data.get("total", 0),
344
+ )
345
+ return PolicyListResponse(**data)
346
+ elif response.status_code == 401:
347
+ raise EnforcerAuthenticationError("Authentication failed")
348
+ else:
349
+ raise PolicyEnforcerError(
350
+ f"Failed to list policies: HTTP {response.status_code}"
351
+ )
352
+
353
+ except httpx.RequestError as e:
354
+ logger.error("policies_list_request_failed", error=str(e))
355
+ raise EnforcerConnectionError(f"Connection failed: {str(e)}") from e
356
+
357
+ async def update(self, policy_id: str, update: PolicyUpdate) -> Policy:
358
+ """
359
+ Update an existing policy.
360
+
361
+ Args:
362
+ policy_id: Policy UUID
363
+ update: Policy update data
364
+
365
+ Returns:
366
+ Updated Policy object
367
+
368
+ Raises:
369
+ PolicyNotFoundError: If policy doesn't exist
370
+ PolicyValidationError: If update is invalid
371
+ """
372
+ try:
373
+ url = f"{self._base_url}/api/v1/policies/{policy_id}"
374
+ response = await self._client.put(
375
+ url,
376
+ json=update.dict(exclude_none=True),
377
+ headers=self._headers
378
+ )
379
+
380
+ if response.status_code == 200:
381
+ data = response.json()
382
+ logger.info("policy_updated", policy_id=policy_id)
383
+ return Policy(**data)
384
+ elif response.status_code == 404:
385
+ raise PolicyNotFoundError(f"Policy {policy_id} not found")
386
+ elif response.status_code == 400:
387
+ error_data = response.json()
388
+ raise PolicyValidationError(
389
+ error_data.get("error", "Policy validation failed")
390
+ )
391
+ elif response.status_code == 401:
392
+ raise EnforcerAuthenticationError("Authentication failed")
393
+ else:
394
+ raise PolicyEnforcerError(
395
+ f"Failed to update policy: HTTP {response.status_code}"
396
+ )
397
+
398
+ except httpx.RequestError as e:
399
+ logger.error("policy_update_request_failed", error=str(e))
400
+ raise EnforcerConnectionError(f"Connection failed: {str(e)}") from e
401
+
402
+ async def delete(self, policy_id: str) -> None:
403
+ """
404
+ Delete a policy.
405
+
406
+ Args:
407
+ policy_id: Policy UUID
408
+
409
+ Raises:
410
+ PolicyNotFoundError: If policy doesn't exist
411
+ """
412
+ try:
413
+ url = f"{self._base_url}/api/v1/policies/{policy_id}"
414
+ response = await self._client.delete(url, headers=self._headers)
415
+
416
+ if response.status_code == 204:
417
+ logger.info("policy_deleted", policy_id=policy_id)
418
+ return
419
+ elif response.status_code == 404:
420
+ raise PolicyNotFoundError(f"Policy {policy_id} not found")
421
+ elif response.status_code == 401:
422
+ raise EnforcerAuthenticationError("Authentication failed")
423
+ else:
424
+ raise PolicyEnforcerError(
425
+ f"Failed to delete policy: HTTP {response.status_code}"
426
+ )
427
+
428
+ except httpx.RequestError as e:
429
+ logger.error("policy_delete_request_failed", error=str(e))
430
+ raise EnforcerConnectionError(f"Connection failed: {str(e)}") from e
431
+
432
+ async def validate(self, policy_id: str) -> ValidationResult:
433
+ """
434
+ Validate a policy's syntax and structure.
435
+
436
+ Args:
437
+ policy_id: Policy UUID
438
+
439
+ Returns:
440
+ ValidationResult with validation status and errors
441
+
442
+ Raises:
443
+ PolicyNotFoundError: If policy doesn't exist
444
+ """
445
+ try:
446
+ url = f"{self._base_url}/api/v1/policies/{policy_id}/validate"
447
+ response = await self._client.post(url, headers=self._headers)
448
+
449
+ if response.status_code in (200, 400):
450
+ data = response.json()
451
+ result = ValidationResult(**data)
452
+ logger.info(
453
+ "policy_validated",
454
+ policy_id=policy_id,
455
+ valid=result.valid,
456
+ error_count=len(result.errors),
457
+ )
458
+ return result
459
+ elif response.status_code == 404:
460
+ raise PolicyNotFoundError(f"Policy {policy_id} not found")
461
+ elif response.status_code == 401:
462
+ raise EnforcerAuthenticationError("Authentication failed")
463
+ else:
464
+ raise PolicyEnforcerError(
465
+ f"Failed to validate policy: HTTP {response.status_code}"
466
+ )
467
+
468
+ except httpx.RequestError as e:
469
+ logger.error("policy_validate_request_failed", error=str(e))
470
+ raise EnforcerConnectionError(f"Connection failed: {str(e)}") from e
471
+
472
+
473
+ class EvaluationOperations:
474
+ """Handles policy evaluation operations"""
475
+
476
+ def __init__(self, client: httpx.AsyncClient, base_url: str, headers: Dict[str, str]):
477
+ self._client = client
478
+ self._base_url = base_url
479
+ self._headers = headers
480
+
481
+ async def evaluate(
482
+ self,
483
+ input_data: Dict[str, Any],
484
+ policy_id: Optional[str] = None,
485
+ policy_name: Optional[str] = None,
486
+ ) -> EvaluationResult:
487
+ """
488
+ Evaluate a policy against input data.
489
+
490
+ Args:
491
+ input_data: Input data to evaluate
492
+ policy_id: Policy UUID (use this or policy_name)
493
+ policy_name: Policy name (use this or policy_id)
494
+
495
+ Returns:
496
+ EvaluationResult with decision and violations
497
+
498
+ Raises:
499
+ PolicyEvaluationError: If evaluation fails
500
+ PolicyNotFoundError: If policy doesn't exist
501
+ """
502
+ try:
503
+ url = f"{self._base_url}/api/v1/evaluate"
504
+ request = EvaluationRequest(
505
+ input=input_data,
506
+ policy_id=policy_id,
507
+ policy_name=policy_name
508
+ )
509
+
510
+ response = await self._client.post(
511
+ url,
512
+ json=request.dict(exclude_none=True),
513
+ headers=self._headers
514
+ )
515
+
516
+ if response.status_code == 200:
517
+ result = EvaluationResult(**response.json())
518
+ logger.info(
519
+ "policy_evaluated",
520
+ policy_id=policy_id,
521
+ policy_name=policy_name,
522
+ allow=result.allow,
523
+ decision=result.decision,
524
+ violations=len(result.violations),
525
+ )
526
+ return result
527
+ elif response.status_code == 404:
528
+ raise PolicyNotFoundError(
529
+ f"Policy {policy_id or policy_name} not found"
530
+ )
531
+ elif response.status_code == 400:
532
+ error_data = response.json()
533
+ raise PolicyEvaluationError(
534
+ error_data.get("error", "Evaluation failed")
535
+ )
536
+ elif response.status_code == 401:
537
+ raise EnforcerAuthenticationError("Authentication failed")
538
+ else:
539
+ raise PolicyEvaluationError(
540
+ f"Evaluation failed: HTTP {response.status_code}"
541
+ )
542
+
543
+ except httpx.RequestError as e:
544
+ logger.error("evaluation_request_failed", error=str(e))
545
+ raise EnforcerConnectionError(f"Connection failed: {str(e)}") from e
546
+
547
+
548
+ class RequestOperations:
549
+ """Handles approval request operations"""
550
+
551
+ def __init__(self, client: httpx.AsyncClient, base_url: str, headers: Dict[str, str]):
552
+ self._client = client
553
+ self._base_url = base_url
554
+ self._headers = headers
555
+
556
+ async def approve(
557
+ self,
558
+ request_id: str,
559
+ metadata: Optional[Dict[str, Any]] = None,
560
+ ) -> ApprovalRequest:
561
+ """
562
+ Approve a pending request.
563
+
564
+ Args:
565
+ request_id: Request ID to approve
566
+ metadata: Optional metadata to attach
567
+
568
+ Returns:
569
+ Updated ApprovalRequest
570
+
571
+ Raises:
572
+ RequestNotFoundError: If request doesn't exist
573
+ """
574
+ try:
575
+ url = f"{self._base_url}/api/v1/requests/{request_id}/approve"
576
+ payload = {"metadata": metadata or {}}
577
+
578
+ response = await self._client.post(
579
+ url,
580
+ json=payload,
581
+ headers=self._headers
582
+ )
583
+
584
+ if response.status_code == 200:
585
+ logger.info("request_approved", request_id=request_id)
586
+ return ApprovalRequest(**response.json())
587
+ elif response.status_code == 404:
588
+ raise RequestNotFoundError(f"Request {request_id} not found")
589
+ elif response.status_code == 409:
590
+ error_data = response.json()
591
+ raise PolicyEnforcerError(
592
+ error_data.get("error", "Request already processed")
593
+ )
594
+ elif response.status_code == 401:
595
+ raise EnforcerAuthenticationError("Authentication failed")
596
+ else:
597
+ raise PolicyEnforcerError(
598
+ f"Failed to approve request: HTTP {response.status_code}"
599
+ )
600
+
601
+ except httpx.RequestError as e:
602
+ logger.error("approve_request_failed", error=str(e))
603
+ raise EnforcerConnectionError(f"Connection failed: {str(e)}") from e
604
+
605
+ async def bulk_approve(
606
+ self,
607
+ request_ids: List[str],
608
+ metadata: Optional[Dict[str, Any]] = None,
609
+ ) -> Dict[str, Any]:
610
+ """
611
+ Approve multiple requests at once.
612
+
613
+ Args:
614
+ request_ids: List of request IDs (max 100)
615
+ metadata: Optional metadata to attach
616
+
617
+ Returns:
618
+ Dict with approval results
619
+ """
620
+ try:
621
+ url = f"{self._base_url}/api/v1/requests/bulk-approve"
622
+ payload = {
623
+ "request_ids": request_ids[:100],
624
+ "metadata": metadata or {},
625
+ }
626
+
627
+ response = await self._client.post(
628
+ url,
629
+ json=payload,
630
+ headers=self._headers
631
+ )
632
+
633
+ if response.status_code == 200:
634
+ data = response.json()
635
+ logger.info(
636
+ "requests_bulk_approved",
637
+ requested=data.get("requested_count", 0),
638
+ approved=data.get("approved_count", 0),
639
+ )
640
+ return data
641
+ elif response.status_code == 401:
642
+ raise EnforcerAuthenticationError("Authentication failed")
643
+ else:
644
+ raise PolicyEnforcerError(
645
+ f"Bulk approval failed: HTTP {response.status_code}"
646
+ )
647
+
648
+ except httpx.RequestError as e:
649
+ logger.error("bulk_approve_request_failed", error=str(e))
650
+ raise EnforcerConnectionError(f"Connection failed: {str(e)}") from e
651
+
652
+ async def get(self, request_id: str) -> ApprovalRequest:
653
+ """
654
+ Get details of a specific request.
655
+
656
+ Args:
657
+ request_id: Request ID
658
+
659
+ Returns:
660
+ ApprovalRequest object
661
+
662
+ Raises:
663
+ RequestNotFoundError: If request doesn't exist
664
+ """
665
+ try:
666
+ url = f"{self._base_url}/api/v1/requests/{request_id}/describe"
667
+ response = await self._client.get(url, headers=self._headers)
668
+
669
+ if response.status_code == 200:
670
+ return ApprovalRequest(**response.json())
671
+ elif response.status_code == 404:
672
+ raise RequestNotFoundError(f"Request {request_id} not found")
673
+ elif response.status_code == 401:
674
+ raise EnforcerAuthenticationError("Authentication failed")
675
+ else:
676
+ raise PolicyEnforcerError(
677
+ f"Failed to get request: HTTP {response.status_code}"
678
+ )
679
+
680
+ except httpx.RequestError as e:
681
+ logger.error("get_request_failed", error=str(e))
682
+ raise EnforcerConnectionError(f"Connection failed: {str(e)}") from e
683
+
684
+ async def list(
685
+ self,
686
+ page: int = 1,
687
+ limit: int = 20,
688
+ status: Optional[RequestStatus] = None,
689
+ runner: Optional[str] = None,
690
+ ) -> RequestListResponse:
691
+ """
692
+ List approval requests with pagination.
693
+
694
+ Args:
695
+ page: Page number
696
+ limit: Items per page (max 100)
697
+ status: Filter by status
698
+ runner: Filter by runner name
699
+
700
+ Returns:
701
+ RequestListResponse with requests and pagination
702
+ """
703
+ try:
704
+ url = f"{self._base_url}/api/v1/requests"
705
+ params = {"page": page, "limit": min(limit, 100)}
706
+
707
+ if status:
708
+ params["status"] = status.value
709
+ if runner:
710
+ params["runner"] = runner
711
+
712
+ response = await self._client.get(
713
+ url,
714
+ params=params,
715
+ headers=self._headers
716
+ )
717
+
718
+ if response.status_code == 200:
719
+ data = response.json()
720
+ logger.info(
721
+ "requests_listed",
722
+ count=len(data.get("requests", [])),
723
+ total=data.get("total", 0),
724
+ )
725
+ return RequestListResponse(**data)
726
+ elif response.status_code == 401:
727
+ raise EnforcerAuthenticationError("Authentication failed")
728
+ else:
729
+ raise PolicyEnforcerError(
730
+ f"Failed to list requests: HTTP {response.status_code}"
731
+ )
732
+
733
+ except httpx.RequestError as e:
734
+ logger.error("list_requests_failed", error=str(e))
735
+ raise EnforcerConnectionError(f"Connection failed: {str(e)}") from e
736
+
737
+
738
+ # ============================================================================
739
+ # Main Client with Context Manager Support
740
+ # ============================================================================
741
+
742
+ class PolicyEnforcerClient:
743
+ """
744
+ Main client for OPA Watchdog Enforcer Service.
745
+
746
+ This client provides a clean, async interface with proper resource management.
747
+ Use it as a context manager to ensure proper cleanup:
748
+
749
+ async with PolicyEnforcerClient(base_url="...", api_key="...") as client:
750
+ policy = await client.policies.create(...)
751
+ result = await client.evaluation.evaluate(...)
752
+ request = await client.requests.approve(...)
753
+
754
+ Attributes:
755
+ policies: PolicyOperations for CRUD operations
756
+ evaluation: EvaluationOperations for policy evaluation
757
+ requests: RequestOperations for approval workflows
758
+ """
759
+
760
+ def __init__(
761
+ self,
762
+ base_url: str,
763
+ api_key: str,
764
+ timeout: float = 30.0,
765
+ max_retries: int = 3,
766
+ ):
767
+ """
768
+ Initialize Policy Enforcer client.
769
+
770
+ Args:
771
+ base_url: Enforcer service URL
772
+ api_key: Kubiya API key (JWT token)
773
+ timeout: Request timeout in seconds
774
+ max_retries: Maximum number of retries for failed requests
775
+ """
776
+ self._base_url = base_url.rstrip("/")
777
+ self._api_key = api_key
778
+ self._timeout = timeout
779
+ self._max_retries = max_retries
780
+ self._headers = {"Authorization": f"Bearer {api_key}"}
781
+
782
+ # Create async HTTP client with retries
783
+ transport = httpx.AsyncHTTPTransport(retries=max_retries)
784
+ self._client = httpx.AsyncClient(
785
+ timeout=httpx.Timeout(timeout, connect=5.0),
786
+ limits=httpx.Limits(max_connections=20, max_keepalive_connections=10),
787
+ transport=transport,
788
+ )
789
+
790
+ # Initialize specialized operation handlers
791
+ self.policies = PolicyOperations(self._client, self._base_url, self._headers)
792
+ self.evaluation = EvaluationOperations(self._client, self._base_url, self._headers)
793
+ self.requests = RequestOperations(self._client, self._base_url, self._headers)
794
+
795
+ async def __aenter__(self):
796
+ """Context manager entry"""
797
+ return self
798
+
799
+ async def __aexit__(self, exc_type, exc_val, exc_tb):
800
+ """Context manager exit with cleanup"""
801
+ await self.close()
802
+
803
+ async def close(self):
804
+ """Close the HTTP client and cleanup resources"""
805
+ try:
806
+ await self._client.aclose()
807
+ except Exception as e:
808
+ logger.warning("client_close_error", error=str(e), error_type=type(e).__name__)
809
+
810
+ async def health_check(self) -> bool:
811
+ """
812
+ Check if the enforcer service is healthy.
813
+
814
+ Returns:
815
+ True if healthy, False otherwise
816
+ """
817
+ try:
818
+ # Use the policies list endpoint with limit=1 to check connectivity
819
+ url = f"{self._base_url}/api/v1/policies"
820
+ response = await self._client.get(
821
+ url,
822
+ params={"limit": 1},
823
+ headers=self._headers,
824
+ timeout=5.0
825
+ )
826
+ return response.status_code == 200
827
+ except Exception as e:
828
+ logger.warning("health_check_failed", error=str(e), error_type=type(e).__name__)
829
+ return False
830
+
831
+ async def get_status(self) -> Dict[str, Any]:
832
+ """
833
+ Get detailed service status.
834
+
835
+ Returns:
836
+ Status dict with service information
837
+ """
838
+ try:
839
+ url = f"{self._base_url}/status"
840
+ response = await self._client.get(url, timeout=5.0)
841
+ if response.status_code == 200:
842
+ return response.json()
843
+ return {}
844
+ except Exception as e:
845
+ logger.warning("status_check_failed", error=str(e))
846
+ return {}
847
+
848
+
849
+ # ============================================================================
850
+ # Factory Function (Dependency Injection)
851
+ # ============================================================================
852
+
853
+ @asynccontextmanager
854
+ async def create_policy_enforcer_client(
855
+ enforcer_url: Optional[str] = None,
856
+ api_key: Optional[str] = None,
857
+ ) -> Optional[PolicyEnforcerClient]:
858
+ """
859
+ Factory function to create a PolicyEnforcerClient with context manager support.
860
+
861
+ Reads configuration from environment variables if not provided:
862
+ - ENFORCER_SERVICE_URL: Enforcer service URL (default: https://enforcer-psi.vercel.app)
863
+ - api_key: Authorization token (typically passed from the incoming request)
864
+
865
+ Args:
866
+ enforcer_url: Optional enforcer URL override
867
+ api_key: Authorization token (Bearer token from the request)
868
+
869
+ Yields:
870
+ PolicyEnforcerClient instance if configured, None if disabled
871
+
872
+ Usage:
873
+ async with create_policy_enforcer_client(api_key=request_token) as client:
874
+ if client:
875
+ policy = await client.policies.create(...)
876
+ """
877
+ # Check if enforcer is enabled
878
+ enforcer_url = enforcer_url or os.environ.get("ENFORCER_SERVICE_URL")
879
+
880
+ # If no URL is set, yield None (enforcer is disabled)
881
+ if not enforcer_url:
882
+ logger.info("policy_enforcer_disabled", reason="no_url")
883
+ yield None
884
+ return
885
+
886
+ # Strip whitespace and newlines
887
+ enforcer_url = enforcer_url.strip()
888
+
889
+ # Default to production URL
890
+ if enforcer_url == "":
891
+ enforcer_url = "https://enforcer-psi.vercel.app"
892
+
893
+ # API key should be passed from the request, not from environment
894
+ # Fall back to KUBIYA_API_KEY for backward compatibility
895
+ if not api_key:
896
+ api_key = os.environ.get("KUBIYA_API_KEY")
897
+
898
+ if not api_key:
899
+ logger.warning("policy_enforcer_disabled_no_api_key", reason="missing_token")
900
+ yield None
901
+ return
902
+
903
+ # Create and yield client
904
+ client = PolicyEnforcerClient(base_url=enforcer_url, api_key=api_key)
905
+
906
+ try:
907
+ logger.info("policy_enforcer_client_created", enforcer_url=enforcer_url)
908
+ yield client
909
+ finally:
910
+ try:
911
+ await client.close()
912
+ except Exception as e:
913
+ logger.warning("policy_enforcer_client_cleanup_error", error=str(e), error_type=type(e).__name__)
914
+
915
+
916
+ # Convenience function for dependency injection in FastAPI
917
+ def get_policy_enforcer_client_dependency() -> Optional[PolicyEnforcerClient]:
918
+ """
919
+ Dependency function for FastAPI to inject PolicyEnforcerClient.
920
+
921
+ Usage in FastAPI:
922
+ @router.get("/policies")
923
+ async def list_policies(
924
+ client: PolicyEnforcerClient = Depends(get_policy_enforcer_client_dependency)
925
+ ):
926
+ if client:
927
+ policies = await client.policies.list()
928
+ ...
929
+ """
930
+ enforcer_url = os.environ.get("ENFORCER_SERVICE_URL")
931
+ api_key = os.environ.get("KUBIYA_API_KEY")
932
+
933
+ if not enforcer_url or not api_key:
934
+ return None
935
+
936
+ if enforcer_url == "":
937
+ enforcer_url = "https://enforcer-psi.vercel.app"
938
+
939
+ return PolicyEnforcerClient(base_url=enforcer_url, api_key=api_key)