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.
- control_plane_api/README.md +266 -0
- control_plane_api/__init__.py +0 -0
- control_plane_api/__version__.py +1 -0
- control_plane_api/alembic/README +1 -0
- control_plane_api/alembic/env.py +98 -0
- control_plane_api/alembic/script.py.mako +28 -0
- control_plane_api/alembic/versions/1382bec74309_initial_migration_with_all_models.py +251 -0
- control_plane_api/alembic/versions/1f54bc2a37e3_add_analytics_tables.py +162 -0
- control_plane_api/alembic/versions/2e4cb136dc10_rename_toolset_ids_to_skill_ids_in_teams.py +30 -0
- control_plane_api/alembic/versions/31cd69a644ce_add_skill_templates_table.py +28 -0
- control_plane_api/alembic/versions/89e127caa47d_add_jobs_and_job_executions_tables.py +161 -0
- control_plane_api/alembic/versions/add_llm_models_table.py +51 -0
- control_plane_api/alembic/versions/b0e10697f212_add_runtime_column_to_teams_simple.py +42 -0
- control_plane_api/alembic/versions/ce43b24b63bf_add_execution_trigger_source_and_fix_.py +155 -0
- control_plane_api/alembic/versions/d4eaf16e3f8d_rename_toolsets_to_skills.py +84 -0
- control_plane_api/alembic/versions/efa2dc427da1_rename_metadata_to_custom_metadata.py +32 -0
- control_plane_api/alembic/versions/f973b431d1ce_add_workflow_executor_to_skill_types.py +44 -0
- control_plane_api/alembic.ini +148 -0
- control_plane_api/api/index.py +12 -0
- control_plane_api/app/__init__.py +11 -0
- control_plane_api/app/activities/__init__.py +20 -0
- control_plane_api/app/activities/agent_activities.py +379 -0
- control_plane_api/app/activities/team_activities.py +410 -0
- control_plane_api/app/activities/temporal_cloud_activities.py +577 -0
- control_plane_api/app/config/__init__.py +35 -0
- control_plane_api/app/config/api_config.py +354 -0
- control_plane_api/app/config/model_pricing.py +318 -0
- control_plane_api/app/config.py +95 -0
- control_plane_api/app/database.py +135 -0
- control_plane_api/app/exceptions.py +408 -0
- control_plane_api/app/lib/__init__.py +11 -0
- control_plane_api/app/lib/job_executor.py +312 -0
- control_plane_api/app/lib/kubiya_client.py +235 -0
- control_plane_api/app/lib/litellm_pricing.py +166 -0
- control_plane_api/app/lib/planning_tools/__init__.py +22 -0
- control_plane_api/app/lib/planning_tools/agents.py +155 -0
- control_plane_api/app/lib/planning_tools/base.py +189 -0
- control_plane_api/app/lib/planning_tools/environments.py +214 -0
- control_plane_api/app/lib/planning_tools/resources.py +240 -0
- control_plane_api/app/lib/planning_tools/teams.py +198 -0
- control_plane_api/app/lib/policy_enforcer_client.py +939 -0
- control_plane_api/app/lib/redis_client.py +436 -0
- control_plane_api/app/lib/supabase.py +71 -0
- control_plane_api/app/lib/temporal_client.py +138 -0
- control_plane_api/app/lib/validation/__init__.py +20 -0
- control_plane_api/app/lib/validation/runtime_validation.py +287 -0
- control_plane_api/app/main.py +128 -0
- control_plane_api/app/middleware/__init__.py +8 -0
- control_plane_api/app/middleware/auth.py +513 -0
- control_plane_api/app/middleware/exception_handler.py +267 -0
- control_plane_api/app/middleware/rate_limiting.py +384 -0
- control_plane_api/app/middleware/request_id.py +202 -0
- control_plane_api/app/models/__init__.py +27 -0
- control_plane_api/app/models/agent.py +79 -0
- control_plane_api/app/models/analytics.py +206 -0
- control_plane_api/app/models/associations.py +81 -0
- control_plane_api/app/models/environment.py +63 -0
- control_plane_api/app/models/execution.py +93 -0
- control_plane_api/app/models/job.py +179 -0
- control_plane_api/app/models/llm_model.py +75 -0
- control_plane_api/app/models/presence.py +49 -0
- control_plane_api/app/models/project.py +47 -0
- control_plane_api/app/models/session.py +38 -0
- control_plane_api/app/models/team.py +66 -0
- control_plane_api/app/models/workflow.py +55 -0
- control_plane_api/app/policies/README.md +121 -0
- control_plane_api/app/policies/approved_users.rego +62 -0
- control_plane_api/app/policies/business_hours.rego +51 -0
- control_plane_api/app/policies/rate_limiting.rego +100 -0
- control_plane_api/app/policies/tool_restrictions.rego +86 -0
- control_plane_api/app/routers/__init__.py +4 -0
- control_plane_api/app/routers/agents.py +364 -0
- control_plane_api/app/routers/agents_v2.py +1260 -0
- control_plane_api/app/routers/analytics.py +1014 -0
- control_plane_api/app/routers/context_manager.py +562 -0
- control_plane_api/app/routers/environment_context.py +270 -0
- control_plane_api/app/routers/environments.py +715 -0
- control_plane_api/app/routers/execution_environment.py +517 -0
- control_plane_api/app/routers/executions.py +1911 -0
- control_plane_api/app/routers/health.py +92 -0
- control_plane_api/app/routers/health_v2.py +326 -0
- control_plane_api/app/routers/integrations.py +274 -0
- control_plane_api/app/routers/jobs.py +1344 -0
- control_plane_api/app/routers/models.py +82 -0
- control_plane_api/app/routers/models_v2.py +361 -0
- control_plane_api/app/routers/policies.py +639 -0
- control_plane_api/app/routers/presence.py +234 -0
- control_plane_api/app/routers/projects.py +902 -0
- control_plane_api/app/routers/runners.py +379 -0
- control_plane_api/app/routers/runtimes.py +172 -0
- control_plane_api/app/routers/secrets.py +155 -0
- control_plane_api/app/routers/skills.py +1001 -0
- control_plane_api/app/routers/skills_definitions.py +140 -0
- control_plane_api/app/routers/task_planning.py +1256 -0
- control_plane_api/app/routers/task_queues.py +654 -0
- control_plane_api/app/routers/team_context.py +270 -0
- control_plane_api/app/routers/teams.py +1400 -0
- control_plane_api/app/routers/worker_queues.py +1545 -0
- control_plane_api/app/routers/workers.py +935 -0
- control_plane_api/app/routers/workflows.py +204 -0
- control_plane_api/app/runtimes/__init__.py +6 -0
- control_plane_api/app/runtimes/validation.py +344 -0
- control_plane_api/app/schemas/job_schemas.py +295 -0
- control_plane_api/app/services/__init__.py +1 -0
- control_plane_api/app/services/agno_service.py +619 -0
- control_plane_api/app/services/litellm_service.py +190 -0
- control_plane_api/app/services/policy_service.py +525 -0
- control_plane_api/app/services/temporal_cloud_provisioning.py +150 -0
- control_plane_api/app/skills/__init__.py +44 -0
- control_plane_api/app/skills/base.py +229 -0
- control_plane_api/app/skills/business_intelligence.py +189 -0
- control_plane_api/app/skills/data_visualization.py +154 -0
- control_plane_api/app/skills/docker.py +104 -0
- control_plane_api/app/skills/file_generation.py +94 -0
- control_plane_api/app/skills/file_system.py +110 -0
- control_plane_api/app/skills/python.py +92 -0
- control_plane_api/app/skills/registry.py +65 -0
- control_plane_api/app/skills/shell.py +102 -0
- control_plane_api/app/skills/workflow_executor.py +469 -0
- control_plane_api/app/utils/workflow_executor.py +354 -0
- control_plane_api/app/workflows/__init__.py +11 -0
- control_plane_api/app/workflows/agent_execution.py +507 -0
- control_plane_api/app/workflows/agent_execution_with_skills.py +222 -0
- control_plane_api/app/workflows/namespace_provisioning.py +326 -0
- control_plane_api/app/workflows/team_execution.py +399 -0
- control_plane_api/scripts/seed_models.py +239 -0
- control_plane_api/worker/__init__.py +0 -0
- control_plane_api/worker/activities/__init__.py +0 -0
- control_plane_api/worker/activities/agent_activities.py +1241 -0
- control_plane_api/worker/activities/approval_activities.py +234 -0
- control_plane_api/worker/activities/runtime_activities.py +388 -0
- control_plane_api/worker/activities/skill_activities.py +267 -0
- control_plane_api/worker/activities/team_activities.py +1217 -0
- control_plane_api/worker/config/__init__.py +31 -0
- control_plane_api/worker/config/worker_config.py +275 -0
- control_plane_api/worker/control_plane_client.py +529 -0
- control_plane_api/worker/examples/analytics_integration_example.py +362 -0
- control_plane_api/worker/models/__init__.py +1 -0
- control_plane_api/worker/models/inputs.py +89 -0
- control_plane_api/worker/runtimes/__init__.py +31 -0
- control_plane_api/worker/runtimes/base.py +789 -0
- control_plane_api/worker/runtimes/claude_code_runtime.py +1443 -0
- control_plane_api/worker/runtimes/default_runtime.py +617 -0
- control_plane_api/worker/runtimes/factory.py +173 -0
- control_plane_api/worker/runtimes/validation.py +93 -0
- control_plane_api/worker/services/__init__.py +1 -0
- control_plane_api/worker/services/agent_executor.py +422 -0
- control_plane_api/worker/services/agent_executor_v2.py +383 -0
- control_plane_api/worker/services/analytics_collector.py +457 -0
- control_plane_api/worker/services/analytics_service.py +464 -0
- control_plane_api/worker/services/approval_tools.py +310 -0
- control_plane_api/worker/services/approval_tools_agno.py +207 -0
- control_plane_api/worker/services/cancellation_manager.py +177 -0
- control_plane_api/worker/services/data_visualization.py +827 -0
- control_plane_api/worker/services/jira_tools.py +257 -0
- control_plane_api/worker/services/runtime_analytics.py +328 -0
- control_plane_api/worker/services/session_service.py +194 -0
- control_plane_api/worker/services/skill_factory.py +175 -0
- control_plane_api/worker/services/team_executor.py +574 -0
- control_plane_api/worker/services/team_executor_v2.py +465 -0
- control_plane_api/worker/services/workflow_executor_tools.py +1418 -0
- control_plane_api/worker/tests/__init__.py +1 -0
- control_plane_api/worker/tests/e2e/__init__.py +0 -0
- control_plane_api/worker/tests/e2e/test_execution_flow.py +571 -0
- control_plane_api/worker/tests/integration/__init__.py +0 -0
- control_plane_api/worker/tests/integration/test_control_plane_integration.py +308 -0
- control_plane_api/worker/tests/unit/__init__.py +0 -0
- control_plane_api/worker/tests/unit/test_control_plane_client.py +401 -0
- control_plane_api/worker/utils/__init__.py +1 -0
- control_plane_api/worker/utils/chunk_batcher.py +305 -0
- control_plane_api/worker/utils/retry_utils.py +60 -0
- control_plane_api/worker/utils/streaming_utils.py +373 -0
- control_plane_api/worker/worker.py +753 -0
- control_plane_api/worker/workflows/__init__.py +0 -0
- control_plane_api/worker/workflows/agent_execution.py +589 -0
- control_plane_api/worker/workflows/team_execution.py +429 -0
- kubiya_control_plane_api-0.3.4.dist-info/METADATA +229 -0
- kubiya_control_plane_api-0.3.4.dist-info/RECORD +182 -0
- kubiya_control_plane_api-0.3.4.dist-info/entry_points.txt +2 -0
- kubiya_control_plane_api-0.3.4.dist-info/top_level.txt +1 -0
- kubiya_control_plane_api-0.1.0.dist-info/METADATA +0 -66
- kubiya_control_plane_api-0.1.0.dist-info/RECORD +0 -5
- kubiya_control_plane_api-0.1.0.dist-info/top_level.txt +0 -1
- {kubiya_control_plane_api-0.1.0.dist-info/licenses → control_plane_api}/LICENSE +0 -0
- {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)
|