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,513 @@
|
|
|
1
|
+
"""Authentication middleware for multi-tenant API with Kubiya integration"""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import json
|
|
5
|
+
import jwt
|
|
6
|
+
import hashlib
|
|
7
|
+
from datetime import datetime, timedelta
|
|
8
|
+
from typing import Optional, Dict, Any
|
|
9
|
+
from fastapi import Request, HTTPException, status
|
|
10
|
+
from fastapi.security import HTTPBearer
|
|
11
|
+
import httpx
|
|
12
|
+
import structlog
|
|
13
|
+
|
|
14
|
+
from control_plane_api.app.lib.redis_client import get_redis_client
|
|
15
|
+
|
|
16
|
+
logger = structlog.get_logger()
|
|
17
|
+
|
|
18
|
+
security = HTTPBearer(auto_error=False)
|
|
19
|
+
|
|
20
|
+
# Cache TTL settings
|
|
21
|
+
DEFAULT_CACHE_TTL = 3600 # 1 hour default
|
|
22
|
+
MAX_CACHE_TTL = 86400 # 24 hours max
|
|
23
|
+
|
|
24
|
+
# Shared httpx client for auth validation (reuse connections)
|
|
25
|
+
_auth_http_client: Optional[httpx.AsyncClient] = None
|
|
26
|
+
|
|
27
|
+
def get_auth_http_client() -> httpx.AsyncClient:
|
|
28
|
+
"""Get or create shared httpx client for auth validation"""
|
|
29
|
+
global _auth_http_client
|
|
30
|
+
if _auth_http_client is None:
|
|
31
|
+
_auth_http_client = httpx.AsyncClient(
|
|
32
|
+
timeout=3.0,
|
|
33
|
+
limits=httpx.Limits(max_connections=10, max_keepalive_connections=5)
|
|
34
|
+
)
|
|
35
|
+
return _auth_http_client
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def get_token_cache_key(token: str) -> str:
|
|
39
|
+
"""
|
|
40
|
+
Generate a unique cache key for a token using SHA256 hash.
|
|
41
|
+
|
|
42
|
+
This ensures each unique token gets its own cache entry, preventing
|
|
43
|
+
collisions and ensuring proper isolation between different tokens/orgs.
|
|
44
|
+
|
|
45
|
+
Args:
|
|
46
|
+
token: Authentication token
|
|
47
|
+
|
|
48
|
+
Returns:
|
|
49
|
+
Cache key in format: auth:token:sha256:{hash}
|
|
50
|
+
"""
|
|
51
|
+
token_hash = hashlib.sha256(token.encode()).hexdigest()
|
|
52
|
+
return f"auth:token:sha256:{token_hash}"
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
async def extract_token_from_headers(request: Request) -> Optional[str]:
|
|
56
|
+
"""
|
|
57
|
+
Extract authentication token from request headers.
|
|
58
|
+
|
|
59
|
+
Supports multiple header formats for compatibility:
|
|
60
|
+
- Authorization: Bearer <token>
|
|
61
|
+
- Authorization: UserKey <token>
|
|
62
|
+
- Authorization: Baerer <token> (typo compatibility)
|
|
63
|
+
- Authorization: <token> (raw token)
|
|
64
|
+
- UserKey: <token>
|
|
65
|
+
|
|
66
|
+
Args:
|
|
67
|
+
request: FastAPI request object
|
|
68
|
+
|
|
69
|
+
Returns:
|
|
70
|
+
Extracted token or None
|
|
71
|
+
"""
|
|
72
|
+
# Check Authorization header
|
|
73
|
+
auth_header = request.headers.get("authorization")
|
|
74
|
+
if auth_header:
|
|
75
|
+
# Handle "Bearer <token>"
|
|
76
|
+
if auth_header.startswith("Bearer "):
|
|
77
|
+
return auth_header[7:]
|
|
78
|
+
# Handle "UserKey <token>"
|
|
79
|
+
elif auth_header.startswith("UserKey "):
|
|
80
|
+
return auth_header[8:]
|
|
81
|
+
# Handle "Baerer <token>" (common typo)
|
|
82
|
+
elif auth_header.startswith("Baerer "):
|
|
83
|
+
logger.warning("api_key_typo_detected", message="'Baerer' should be 'Bearer'")
|
|
84
|
+
return auth_header[7:]
|
|
85
|
+
# Handle raw token without prefix
|
|
86
|
+
elif " " not in auth_header:
|
|
87
|
+
return auth_header
|
|
88
|
+
|
|
89
|
+
# Check UserKey header (alternative)
|
|
90
|
+
userkey_header = request.headers.get("userkey")
|
|
91
|
+
if userkey_header:
|
|
92
|
+
return userkey_header
|
|
93
|
+
|
|
94
|
+
return None
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def decode_jwt_token(token: str) -> Optional[Dict[str, Any]]:
|
|
98
|
+
"""
|
|
99
|
+
Decode JWT token without verification to extract expiry and metadata.
|
|
100
|
+
|
|
101
|
+
Args:
|
|
102
|
+
token: JWT token string
|
|
103
|
+
|
|
104
|
+
Returns:
|
|
105
|
+
Decoded token payload or None
|
|
106
|
+
"""
|
|
107
|
+
try:
|
|
108
|
+
# Decode without verification (we just need exp and other metadata)
|
|
109
|
+
decoded = jwt.decode(token, options={"verify_signature": False})
|
|
110
|
+
return decoded
|
|
111
|
+
except Exception as e:
|
|
112
|
+
logger.warning("jwt_decode_failed", error=str(e))
|
|
113
|
+
return None
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def get_cache_ttl_from_token(token: str) -> int:
|
|
117
|
+
"""
|
|
118
|
+
Extract expiry from JWT token and calculate appropriate cache TTL.
|
|
119
|
+
|
|
120
|
+
Args:
|
|
121
|
+
token: JWT token string
|
|
122
|
+
|
|
123
|
+
Returns:
|
|
124
|
+
TTL in seconds (minimum 60s, maximum MAX_CACHE_TTL)
|
|
125
|
+
"""
|
|
126
|
+
decoded = decode_jwt_token(token)
|
|
127
|
+
if not decoded or "exp" not in decoded:
|
|
128
|
+
return DEFAULT_CACHE_TTL
|
|
129
|
+
|
|
130
|
+
try:
|
|
131
|
+
exp_timestamp = decoded["exp"]
|
|
132
|
+
exp_datetime = datetime.fromtimestamp(exp_timestamp)
|
|
133
|
+
now = datetime.now()
|
|
134
|
+
|
|
135
|
+
# Calculate time until expiry
|
|
136
|
+
ttl = int((exp_datetime - now).total_seconds())
|
|
137
|
+
|
|
138
|
+
# Ensure TTL is reasonable (at least 60s, max MAX_CACHE_TTL)
|
|
139
|
+
ttl = max(60, min(ttl, MAX_CACHE_TTL))
|
|
140
|
+
|
|
141
|
+
logger.debug("calculated_cache_ttl", ttl=ttl, exp=exp_timestamp)
|
|
142
|
+
return ttl
|
|
143
|
+
except Exception as e:
|
|
144
|
+
logger.warning("ttl_calculation_failed", error=str(e))
|
|
145
|
+
return DEFAULT_CACHE_TTL
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
async def validate_kubiya_api_key(auth_header: str, use_userkey: bool = False) -> Optional[Dict[str, Any]]:
|
|
149
|
+
"""
|
|
150
|
+
Validate API key with Kubiya API by calling /api/v1/users/self or /api/v1/users/current.
|
|
151
|
+
|
|
152
|
+
Args:
|
|
153
|
+
auth_header: Full Authorization header value (e.g., "Bearer xyz123" or "UserKey xyz123")
|
|
154
|
+
use_userkey: If True, use "UserKey" prefix instead of "Bearer" for worker authentication
|
|
155
|
+
|
|
156
|
+
Returns:
|
|
157
|
+
User object from Kubiya API or None if invalid
|
|
158
|
+
"""
|
|
159
|
+
base_url = os.getenv("KUBIYA_API_BASE") or os.getenv("KUBIYA_API_URL") or "https://api.kubiya.ai"
|
|
160
|
+
endpoints = ["/api/v1/users/self", "/api/v1/users/current"]
|
|
161
|
+
|
|
162
|
+
# If use_userkey is True, ensure the header uses "UserKey" prefix
|
|
163
|
+
if use_userkey and not auth_header.startswith("UserKey "):
|
|
164
|
+
# Extract token and reformat with UserKey prefix
|
|
165
|
+
token = auth_header.replace("Bearer ", "").replace("UserKey ", "").strip()
|
|
166
|
+
auth_header = f"UserKey {token}"
|
|
167
|
+
logger.debug("reformatted_auth_header_for_worker", prefix="UserKey")
|
|
168
|
+
|
|
169
|
+
client = get_auth_http_client()
|
|
170
|
+
for endpoint in endpoints:
|
|
171
|
+
try:
|
|
172
|
+
url = f"{base_url}{endpoint}"
|
|
173
|
+
logger.debug("validating_token_with_kubiya", url=url, use_userkey=use_userkey)
|
|
174
|
+
|
|
175
|
+
response = await client.get(
|
|
176
|
+
url,
|
|
177
|
+
headers={"Authorization": auth_header}
|
|
178
|
+
)
|
|
179
|
+
|
|
180
|
+
if response.status_code == 200:
|
|
181
|
+
user_data = response.json()
|
|
182
|
+
logger.info(
|
|
183
|
+
"token_validated_with_kubiya",
|
|
184
|
+
endpoint=endpoint,
|
|
185
|
+
user_id=user_data.get("id"),
|
|
186
|
+
org_id=user_data.get("organization_id"),
|
|
187
|
+
use_userkey=use_userkey
|
|
188
|
+
)
|
|
189
|
+
return user_data
|
|
190
|
+
|
|
191
|
+
logger.debug(
|
|
192
|
+
"kubiya_validation_attempt_failed",
|
|
193
|
+
endpoint=endpoint,
|
|
194
|
+
status=response.status_code,
|
|
195
|
+
use_userkey=use_userkey
|
|
196
|
+
)
|
|
197
|
+
|
|
198
|
+
except Exception as e:
|
|
199
|
+
logger.warning("kubiya_validation_error", endpoint=endpoint, error=str(e))
|
|
200
|
+
continue
|
|
201
|
+
|
|
202
|
+
logger.warning("token_validation_failed_all_endpoints", use_userkey=use_userkey)
|
|
203
|
+
return None
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
async def get_cached_user_data(token: str) -> Optional[Dict[str, Any]]:
|
|
207
|
+
"""
|
|
208
|
+
Get cached user data from Redis using SHA256 hash of token.
|
|
209
|
+
|
|
210
|
+
Args:
|
|
211
|
+
token: Authentication token (full token will be hashed)
|
|
212
|
+
|
|
213
|
+
Returns:
|
|
214
|
+
Cached user data or None
|
|
215
|
+
"""
|
|
216
|
+
redis = get_redis_client()
|
|
217
|
+
if not redis:
|
|
218
|
+
return None
|
|
219
|
+
|
|
220
|
+
try:
|
|
221
|
+
cache_key = get_token_cache_key(token)
|
|
222
|
+
cached_data = await redis.get(cache_key)
|
|
223
|
+
|
|
224
|
+
if cached_data:
|
|
225
|
+
logger.debug("cache_hit", cache_key=cache_key[:40] + "...")
|
|
226
|
+
if isinstance(cached_data, bytes):
|
|
227
|
+
cached_data = cached_data.decode('utf-8')
|
|
228
|
+
return json.loads(cached_data)
|
|
229
|
+
|
|
230
|
+
logger.debug("cache_miss", cache_key=cache_key[:40] + "...")
|
|
231
|
+
return None
|
|
232
|
+
|
|
233
|
+
except Exception as e:
|
|
234
|
+
logger.warning("cache_read_failed", error=str(e))
|
|
235
|
+
return None
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
async def cache_user_data(token: str, user_data: Dict[str, Any]) -> None:
|
|
239
|
+
"""
|
|
240
|
+
Cache user data in Redis with TTL based on token expiry.
|
|
241
|
+
Uses SHA256 hash of the full token as cache key for uniqueness.
|
|
242
|
+
|
|
243
|
+
Args:
|
|
244
|
+
token: Authentication token (full token will be hashed)
|
|
245
|
+
user_data: User data to cache
|
|
246
|
+
"""
|
|
247
|
+
redis = get_redis_client()
|
|
248
|
+
if not redis:
|
|
249
|
+
return
|
|
250
|
+
|
|
251
|
+
try:
|
|
252
|
+
cache_key = get_token_cache_key(token)
|
|
253
|
+
ttl = get_cache_ttl_from_token(token)
|
|
254
|
+
|
|
255
|
+
await redis.set(
|
|
256
|
+
cache_key,
|
|
257
|
+
json.dumps(user_data),
|
|
258
|
+
ex=ttl # Set expiry in seconds
|
|
259
|
+
)
|
|
260
|
+
|
|
261
|
+
logger.info("cache_write_success", cache_key=cache_key[:40] + "...", ttl=ttl)
|
|
262
|
+
|
|
263
|
+
except Exception as e:
|
|
264
|
+
logger.warning("cache_write_failed", error=str(e))
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
async def get_organization_from_worker_token(token: str) -> Optional[dict]:
|
|
268
|
+
"""
|
|
269
|
+
Validate worker token and return organization data.
|
|
270
|
+
|
|
271
|
+
Worker tokens are in format: worker_{uuid} and stored in worker_heartbeats.worker_metadata
|
|
272
|
+
|
|
273
|
+
Args:
|
|
274
|
+
token: Worker authentication token
|
|
275
|
+
|
|
276
|
+
Returns:
|
|
277
|
+
Organization dict or None if invalid
|
|
278
|
+
"""
|
|
279
|
+
if not token.startswith("worker_"):
|
|
280
|
+
return None
|
|
281
|
+
|
|
282
|
+
try:
|
|
283
|
+
from control_plane_api.app.lib.supabase import get_supabase
|
|
284
|
+
client = get_supabase()
|
|
285
|
+
|
|
286
|
+
# Query worker_heartbeats for this token
|
|
287
|
+
result = (
|
|
288
|
+
client.table("worker_heartbeats")
|
|
289
|
+
.select("organization_id, worker_metadata")
|
|
290
|
+
.eq("status", "active")
|
|
291
|
+
.execute()
|
|
292
|
+
)
|
|
293
|
+
|
|
294
|
+
# Find worker with matching token in metadata
|
|
295
|
+
for worker in result.data:
|
|
296
|
+
worker_metadata = worker.get("worker_metadata", {})
|
|
297
|
+
if worker_metadata.get("worker_token") == token:
|
|
298
|
+
# Return minimal org data for worker
|
|
299
|
+
return {
|
|
300
|
+
"id": worker["organization_id"],
|
|
301
|
+
"name": "Worker", # Workers don't need full org details
|
|
302
|
+
"slug": "worker",
|
|
303
|
+
"user_id": "worker",
|
|
304
|
+
"user_email": "worker@system",
|
|
305
|
+
"user_name": "Worker Process"
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
logger.warning("worker_token_not_found", token_prefix=token[:15])
|
|
309
|
+
return None
|
|
310
|
+
|
|
311
|
+
except Exception as e:
|
|
312
|
+
logger.error("worker_token_validation_failed", error=str(e))
|
|
313
|
+
return None
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
async def get_organization_allow_worker(request: Request) -> dict:
|
|
317
|
+
"""
|
|
318
|
+
Dependency that accepts both user tokens and worker tokens.
|
|
319
|
+
|
|
320
|
+
This is used by endpoints that workers need to call (like execution updates).
|
|
321
|
+
|
|
322
|
+
Flow:
|
|
323
|
+
1. Extract token from Authorization header
|
|
324
|
+
2. If token starts with "worker_", validate as worker token
|
|
325
|
+
3. Otherwise, validate as user token (with caching)
|
|
326
|
+
4. Return organization data
|
|
327
|
+
|
|
328
|
+
Args:
|
|
329
|
+
request: FastAPI request object
|
|
330
|
+
|
|
331
|
+
Returns:
|
|
332
|
+
Organization dict with id, name, slug, user_id, etc.
|
|
333
|
+
|
|
334
|
+
Raises:
|
|
335
|
+
HTTPException: 401 if authentication fails
|
|
336
|
+
"""
|
|
337
|
+
# Extract token from headers
|
|
338
|
+
token = await extract_token_from_headers(request)
|
|
339
|
+
|
|
340
|
+
if not token:
|
|
341
|
+
logger.warning("auth_token_missing", path=request.url.path)
|
|
342
|
+
raise HTTPException(
|
|
343
|
+
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
344
|
+
detail={
|
|
345
|
+
"error": "Unauthorized",
|
|
346
|
+
"message": "Authorization header is required",
|
|
347
|
+
"hint": "Include 'Authorization: Bearer <token>' header"
|
|
348
|
+
}
|
|
349
|
+
)
|
|
350
|
+
|
|
351
|
+
# Check if this is a worker token (fast path)
|
|
352
|
+
if token.startswith("worker_"):
|
|
353
|
+
logger.debug("validating_worker_token", path=request.url.path)
|
|
354
|
+
org = await get_organization_from_worker_token(token)
|
|
355
|
+
if org:
|
|
356
|
+
# Store in request state for later use
|
|
357
|
+
request.state.organization = org
|
|
358
|
+
request.state.worker_token = token
|
|
359
|
+
logger.info(
|
|
360
|
+
"worker_authenticated",
|
|
361
|
+
org_id=org["id"],
|
|
362
|
+
path=request.url.path,
|
|
363
|
+
method=request.method,
|
|
364
|
+
)
|
|
365
|
+
return org
|
|
366
|
+
|
|
367
|
+
# Worker token invalid
|
|
368
|
+
logger.warning("worker_token_invalid", path=request.url.path)
|
|
369
|
+
raise HTTPException(
|
|
370
|
+
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
371
|
+
detail={
|
|
372
|
+
"error": "Unauthorized",
|
|
373
|
+
"message": "Invalid or expired worker token",
|
|
374
|
+
"hint": "Worker token not found in active workers"
|
|
375
|
+
}
|
|
376
|
+
)
|
|
377
|
+
|
|
378
|
+
# Fall back to regular user authentication
|
|
379
|
+
return await get_current_organization(request)
|
|
380
|
+
|
|
381
|
+
|
|
382
|
+
async def get_current_organization(request: Request) -> dict:
|
|
383
|
+
"""
|
|
384
|
+
Dependency to get current organization and validate authentication.
|
|
385
|
+
|
|
386
|
+
Flow:
|
|
387
|
+
1. Extract token from Authorization header
|
|
388
|
+
2. Check Redis cache for user data
|
|
389
|
+
3. If not cached, validate with Kubiya API
|
|
390
|
+
4. Cache the user data with TTL based on JWT expiry
|
|
391
|
+
5. Return organization data
|
|
392
|
+
|
|
393
|
+
Args:
|
|
394
|
+
request: FastAPI request object
|
|
395
|
+
|
|
396
|
+
Returns:
|
|
397
|
+
Organization dict:
|
|
398
|
+
{
|
|
399
|
+
"id": "org-uuid",
|
|
400
|
+
"name": "Organization Name",
|
|
401
|
+
"slug": "org-slug",
|
|
402
|
+
"user_id": "user-uuid",
|
|
403
|
+
"user_email": "user@example.com",
|
|
404
|
+
"user_name": "User Name"
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
Raises:
|
|
408
|
+
HTTPException: 401 if authentication fails
|
|
409
|
+
"""
|
|
410
|
+
# Extract token from headers
|
|
411
|
+
token = await extract_token_from_headers(request)
|
|
412
|
+
|
|
413
|
+
if not token:
|
|
414
|
+
logger.warning("auth_token_missing", path=request.url.path)
|
|
415
|
+
raise HTTPException(
|
|
416
|
+
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
417
|
+
detail={
|
|
418
|
+
"error": "Unauthorized",
|
|
419
|
+
"message": "Authorization header is required",
|
|
420
|
+
"hint": "Include 'Authorization: Bearer <token>' header"
|
|
421
|
+
}
|
|
422
|
+
)
|
|
423
|
+
|
|
424
|
+
# Try to get cached user data
|
|
425
|
+
user_data = await get_cached_user_data(token)
|
|
426
|
+
|
|
427
|
+
# Track which auth type succeeded
|
|
428
|
+
auth_type = None
|
|
429
|
+
|
|
430
|
+
# If not cached, validate with Kubiya API
|
|
431
|
+
if not user_data:
|
|
432
|
+
logger.debug("validating_with_kubiya_api", path=request.url.path)
|
|
433
|
+
auth_header = f"Bearer {token}" if not token.startswith("Bearer ") else token
|
|
434
|
+
|
|
435
|
+
# Try Bearer first (for regular user tokens)
|
|
436
|
+
user_data = await validate_kubiya_api_key(auth_header, use_userkey=False)
|
|
437
|
+
if user_data:
|
|
438
|
+
auth_type = "Bearer"
|
|
439
|
+
|
|
440
|
+
# If Bearer fails, try UserKey (for API keys/worker tokens)
|
|
441
|
+
if not user_data:
|
|
442
|
+
logger.debug("bearer_auth_failed_trying_userkey", path=request.url.path)
|
|
443
|
+
user_data = await validate_kubiya_api_key(auth_header, use_userkey=True)
|
|
444
|
+
if user_data:
|
|
445
|
+
auth_type = "UserKey"
|
|
446
|
+
|
|
447
|
+
if not user_data:
|
|
448
|
+
logger.warning("authentication_failed", path=request.url.path)
|
|
449
|
+
raise HTTPException(
|
|
450
|
+
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
451
|
+
detail={
|
|
452
|
+
"error": "Unauthorized",
|
|
453
|
+
"message": "Invalid or expired authentication token",
|
|
454
|
+
"hint": "Ensure you're using a valid Kubiya API token"
|
|
455
|
+
}
|
|
456
|
+
)
|
|
457
|
+
|
|
458
|
+
# Cache the validated user data with auth type
|
|
459
|
+
await cache_user_data(token, {**user_data, "_auth_type": auth_type})
|
|
460
|
+
else:
|
|
461
|
+
# Retrieve auth type from cache
|
|
462
|
+
auth_type = user_data.get("_auth_type", "Bearer")
|
|
463
|
+
|
|
464
|
+
# Extract organization slug from Kubiya API response
|
|
465
|
+
# Kubiya API returns the org slug in the "org" field (e.g., "kubiya-ai")
|
|
466
|
+
# We use this slug as the primary organization identifier throughout the system
|
|
467
|
+
org_slug = user_data.get("org")
|
|
468
|
+
|
|
469
|
+
if not org_slug:
|
|
470
|
+
logger.error("org_slug_missing_in_user_data", user_data=user_data)
|
|
471
|
+
raise HTTPException(
|
|
472
|
+
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
473
|
+
detail={
|
|
474
|
+
"error": "Unauthorized",
|
|
475
|
+
"message": "No organization slug found in user data",
|
|
476
|
+
"hint": "User data must contain 'org' field from Kubiya API"
|
|
477
|
+
}
|
|
478
|
+
)
|
|
479
|
+
|
|
480
|
+
# Build organization object
|
|
481
|
+
# We use the org slug as the primary identifier (id field)
|
|
482
|
+
# This slug is used for all database operations (agents, teams, executions, etc.)
|
|
483
|
+
user_email = user_data.get("email")
|
|
484
|
+
user_name = user_data.get("name") or (user_email.split("@")[0] if user_email else None) # Fallback to email username
|
|
485
|
+
|
|
486
|
+
organization = {
|
|
487
|
+
"id": org_slug, # Use slug as ID (e.g., "kubiya-ai")
|
|
488
|
+
"name": user_data.get("org"), # Also use slug as display name
|
|
489
|
+
"slug": org_slug, # The slug itself
|
|
490
|
+
"user_id": user_data.get("uuid"), # User UUID from Kubiya
|
|
491
|
+
"user_email": user_email,
|
|
492
|
+
"user_name": user_name,
|
|
493
|
+
"user_avatar": user_data.get("picture") or user_data.get("avatar") or user_data.get("image"), # Avatar from Auth0/Kubiya
|
|
494
|
+
"user_status": user_data.get("user_status") or user_data.get("status"),
|
|
495
|
+
"user_groups": user_data.get("groups") or user_data.get("user_groups"),
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
# Store in request state for later use
|
|
499
|
+
request.state.organization = organization
|
|
500
|
+
request.state.kubiya_token = token
|
|
501
|
+
request.state.kubiya_auth_type = auth_type # Store whether to use Bearer or UserKey
|
|
502
|
+
request.state.user_data = user_data
|
|
503
|
+
|
|
504
|
+
logger.info(
|
|
505
|
+
"request_authenticated",
|
|
506
|
+
org_id=organization["id"],
|
|
507
|
+
user_id=organization.get("user_id"),
|
|
508
|
+
path=request.url.path,
|
|
509
|
+
method=request.method,
|
|
510
|
+
cached=user_data is not None
|
|
511
|
+
)
|
|
512
|
+
|
|
513
|
+
return organization
|