Security hardening release addressing CodeQL and Dependabot alerts: - Fix stack trace exposure in error responses - Add SSRF protection with DNS resolution checking - Implement proper URL hostname validation (replaces substring matching) - Add centralized path sanitization to prevent path traversal - Fix ReDoS vulnerability in email validation regex - Improve HTML sanitization in validation utilities - Fix capability wildcard matching in auth utilities - Update glob dependency to address CVE - Add CodeQL suppression comments for verified false positives 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
380 lines
14 KiB
Python
380 lines
14 KiB
Python
"""
|
|
GT 2.0 Tenant Backend - Capability Client
|
|
Generate JWT capability tokens for Resource Cluster API calls
|
|
"""
|
|
|
|
import json
|
|
import time
|
|
from datetime import datetime, timedelta
|
|
from typing import Dict, Any, List, Optional
|
|
from jose import jwt
|
|
from app.core.config import get_settings
|
|
import logging
|
|
import httpx
|
|
|
|
logger = logging.getLogger(__name__)
|
|
settings = get_settings()
|
|
|
|
|
|
class CapabilityClient:
|
|
"""Generates capability-based JWT tokens for Resource Cluster access"""
|
|
|
|
def __init__(self):
|
|
# Use tenant-specific secret key for token signing
|
|
self.secret_key = settings.secret_key
|
|
self.algorithm = "HS256"
|
|
self.issuer = f"gt2-tenant-{settings.tenant_id}"
|
|
self.http_client = httpx.AsyncClient(timeout=10.0)
|
|
self.control_panel_url = settings.control_panel_url
|
|
|
|
async def generate_capability_token(
|
|
self,
|
|
user_email: str,
|
|
tenant_id: str,
|
|
resources: List[str],
|
|
expires_hours: int = 24,
|
|
additional_claims: Optional[Dict[str, Any]] = None
|
|
) -> str:
|
|
"""
|
|
Generate a JWT capability token for Resource Cluster API access.
|
|
|
|
Args:
|
|
user_email: Email of the user making the request
|
|
tenant_id: Tenant identifier
|
|
resources: List of resource capabilities (e.g., ['external_services', 'rag_processing'])
|
|
expires_hours: Token expiration time in hours
|
|
additional_claims: Additional JWT claims to include
|
|
|
|
Returns:
|
|
Signed JWT token string
|
|
"""
|
|
|
|
now = datetime.utcnow()
|
|
expiry = now + timedelta(hours=expires_hours)
|
|
|
|
# Build capability token payload
|
|
payload = {
|
|
# Standard JWT claims
|
|
"iss": self.issuer, # Issuer
|
|
"sub": user_email, # Subject (user)
|
|
"aud": "gt2-resource-cluster", # Audience
|
|
"iat": int(now.timestamp()), # Issued at
|
|
"exp": int(expiry.timestamp()), # Expiration
|
|
"nbf": int(now.timestamp()), # Not before
|
|
"jti": f"{tenant_id}-{user_email}-{int(now.timestamp())}", # JWT ID
|
|
|
|
# GT 2.0 specific claims
|
|
"tenant_id": tenant_id,
|
|
"user_email": user_email,
|
|
"user_type": "tenant_user",
|
|
|
|
# Capability grants
|
|
"capabilities": await self._build_capabilities(resources, tenant_id, expiry),
|
|
|
|
# Security metadata
|
|
"capability_hash": self._generate_capability_hash(resources, tenant_id),
|
|
"token_version": "2.0",
|
|
"security_level": "standard"
|
|
}
|
|
|
|
# Add any additional claims
|
|
if additional_claims:
|
|
payload.update(additional_claims)
|
|
|
|
# Sign the token
|
|
try:
|
|
token = jwt.encode(
|
|
payload,
|
|
self.secret_key,
|
|
algorithm=self.algorithm
|
|
)
|
|
|
|
logger.info(
|
|
f"Generated capability token for {user_email} with resources: {resources}"
|
|
)
|
|
|
|
return token
|
|
|
|
except Exception as e:
|
|
logger.error(f"Failed to generate capability token: {e}")
|
|
raise RuntimeError(f"Token generation failed: {e}")
|
|
|
|
async def _build_capabilities(
|
|
self,
|
|
resources: List[str],
|
|
tenant_id: str,
|
|
expiry: datetime
|
|
) -> List[Dict[str, Any]]:
|
|
"""
|
|
Build capability grants for resources with constraints from Control Panel.
|
|
|
|
For LLM resources, fetches real rate limits from Control Panel API.
|
|
For other resources, uses default constraints.
|
|
"""
|
|
capabilities = []
|
|
|
|
for resource in resources:
|
|
capability = {
|
|
"resource": resource,
|
|
"actions": self._get_default_actions(resource),
|
|
"constraints": await self._get_constraints_for_resource(resource, tenant_id),
|
|
"valid_until": expiry.isoformat()
|
|
}
|
|
capabilities.append(capability)
|
|
|
|
return capabilities
|
|
|
|
async def _get_constraints_for_resource(
|
|
self,
|
|
resource: str,
|
|
tenant_id: str
|
|
) -> Dict[str, Any]:
|
|
"""
|
|
Get constraints for a resource, fetching from Control Panel for LLM resources.
|
|
|
|
GT 2.0 Principle: Single source of truth in database.
|
|
Fails fast if Control Panel is unreachable for LLM resources.
|
|
"""
|
|
# For LLM resources, fetch real config from Control Panel
|
|
if resource in ["llm", "llm_inference"]:
|
|
# Note: We don't have model_id at this point in the flow
|
|
# This is called during general capability token generation
|
|
# For now, return default constraints that will be overridden
|
|
# when model-specific tokens are generated
|
|
return self._get_default_constraints(resource)
|
|
|
|
# For non-LLM resources, use defaults
|
|
return self._get_default_constraints(resource)
|
|
|
|
async def _fetch_tenant_model_config(
|
|
self,
|
|
tenant_id: str,
|
|
model_id: str
|
|
) -> Optional[Dict[str, Any]]:
|
|
"""
|
|
Fetch tenant model configuration from Control Panel API.
|
|
|
|
Returns rate limits from database (single source of truth).
|
|
Fails fast if Control Panel is unreachable (no fallbacks).
|
|
|
|
Args:
|
|
tenant_id: Tenant identifier
|
|
model_id: Model identifier
|
|
|
|
Returns:
|
|
Model config with rate_limits, or None if not found
|
|
|
|
Raises:
|
|
RuntimeError: If Control Panel API is unreachable (fail fast)
|
|
"""
|
|
try:
|
|
url = f"{self.control_panel_url}/api/v1/tenant-models/tenants/{tenant_id}/models/{model_id}"
|
|
|
|
logger.debug(f"Fetching model config from Control Panel: {url}")
|
|
|
|
response = await self.http_client.get(url)
|
|
|
|
if response.status_code == 404:
|
|
logger.warning(f"Model {model_id} not configured for tenant {tenant_id}")
|
|
return None
|
|
|
|
response.raise_for_status()
|
|
|
|
config = response.json()
|
|
logger.info(f"Fetched model config for {model_id}: rate_limits={config.get('rate_limits')}")
|
|
|
|
return config
|
|
|
|
except httpx.HTTPStatusError as e:
|
|
logger.error(f"Control Panel API error: {e.response.status_code}")
|
|
raise RuntimeError(
|
|
f"Failed to fetch model config from Control Panel: HTTP {e.response.status_code}"
|
|
)
|
|
except httpx.RequestError as e:
|
|
logger.error(f"Control Panel API unreachable: {e}")
|
|
raise RuntimeError(
|
|
f"Control Panel API unreachable - cannot generate capability token. "
|
|
f"Ensure Control Panel is running at {self.control_panel_url}"
|
|
)
|
|
except Exception as e:
|
|
logger.error(f"Unexpected error fetching model config: {e}")
|
|
raise RuntimeError(f"Failed to fetch model config: {e}")
|
|
|
|
def _get_default_actions(self, resource: str) -> List[str]:
|
|
"""Get default actions for a resource type"""
|
|
|
|
action_mappings = {
|
|
"external_services": ["create", "read", "update", "delete", "health_check", "sso_token"],
|
|
"rag_processing": ["process_document", "generate_embeddings", "vector_search"],
|
|
"llm_inference": ["chat_completion", "streaming", "function_calling"],
|
|
"llm": ["execute"], # Use valid ActionType from resource cluster
|
|
"agent_orchestration": ["execute", "status", "interrupt"],
|
|
"ai_literacy": ["play_games", "solve_puzzles", "dialogue", "analytics"],
|
|
"app_integrations": ["read", "write", "webhook"],
|
|
"admin": ["all"],
|
|
# MCP Server Resources
|
|
"mcp:rag": ["search_datasets", "query_documents", "list_user_datasets", "get_dataset_info", "get_relevant_chunks"]
|
|
}
|
|
|
|
return action_mappings.get(resource, ["read"])
|
|
|
|
def _get_default_constraints(self, resource: str) -> Dict[str, Any]:
|
|
"""Get default constraints for a resource type"""
|
|
|
|
constraint_mappings = {
|
|
"external_services": {
|
|
"max_instances_per_user": 10,
|
|
"max_cpu_per_instance": "2000m",
|
|
"max_memory_per_instance": "4Gi",
|
|
"max_storage_per_instance": "50Gi",
|
|
"allowed_service_types": ["ctfd", "canvas", "guacamole"]
|
|
},
|
|
"rag_processing": {
|
|
"max_document_size_mb": 100,
|
|
"max_batch_size": 50,
|
|
"max_requests_per_hour": 1000
|
|
},
|
|
"llm_inference": {
|
|
"max_tokens_per_request": 4000,
|
|
"max_requests_per_hour": 100,
|
|
"allowed_models": [] # Models dynamically determined by admin backend
|
|
},
|
|
"llm": {
|
|
"max_tokens_per_request": 4000,
|
|
"max_requests_per_hour": 100,
|
|
"allowed_models": [] # Models dynamically determined by admin backend
|
|
},
|
|
"agent_orchestration": {
|
|
"max_concurrent_agents": 5,
|
|
"max_execution_time_minutes": 30
|
|
},
|
|
"ai_literacy": {
|
|
"max_sessions_per_day": 20,
|
|
"max_session_duration_hours": 4
|
|
},
|
|
"app_integrations": {
|
|
"max_api_calls_per_hour": 500,
|
|
"allowed_domains": ["api.example.com"]
|
|
},
|
|
# MCP Server Resources
|
|
"mcp:rag": {
|
|
"max_requests_per_hour": 500,
|
|
"max_results_per_query": 50
|
|
}
|
|
}
|
|
|
|
return constraint_mappings.get(resource, {})
|
|
|
|
def _generate_capability_hash(self, resources: List[str], tenant_id: str) -> str:
|
|
"""Generate a hash of the capabilities for verification"""
|
|
import hashlib
|
|
|
|
# Create a deterministic string from capabilities
|
|
capability_string = f"{tenant_id}:{':'.join(sorted(resources))}"
|
|
|
|
# Hash with SHA-256
|
|
hash_object = hashlib.sha256(capability_string.encode())
|
|
return hash_object.hexdigest()[:16] # First 16 characters
|
|
|
|
async def verify_capability_token(self, token: str) -> Dict[str, Any]:
|
|
"""
|
|
Verify and decode a capability token.
|
|
|
|
Args:
|
|
token: JWT token to verify
|
|
|
|
Returns:
|
|
Decoded token payload
|
|
|
|
Raises:
|
|
ValueError: If token is invalid or expired
|
|
"""
|
|
|
|
try:
|
|
# Decode and verify the token
|
|
payload = jwt.decode(
|
|
token,
|
|
self.secret_key,
|
|
algorithms=[self.algorithm],
|
|
audience="gt2-resource-cluster"
|
|
)
|
|
|
|
# Additional validation
|
|
if payload.get("iss") != self.issuer:
|
|
raise ValueError("Invalid token issuer")
|
|
|
|
# Check if token is still valid
|
|
now = datetime.utcnow()
|
|
if payload.get("exp", 0) < now.timestamp():
|
|
raise ValueError("Token has expired")
|
|
|
|
if payload.get("nbf", 0) > now.timestamp():
|
|
raise ValueError("Token not yet valid")
|
|
|
|
logger.debug(f"Verified capability token for user {payload.get('user_email')}")
|
|
|
|
return payload
|
|
|
|
except jwt.ExpiredSignatureError:
|
|
raise ValueError("Token has expired")
|
|
except jwt.JWTClaimsError as e:
|
|
raise ValueError(f"Token claims validation failed: {e}")
|
|
except jwt.JWTError as e:
|
|
raise ValueError(f"Token validation failed: {e}")
|
|
except Exception as e:
|
|
logger.error(f"Capability token verification failed: {e}")
|
|
raise ValueError(f"Invalid token: {e}")
|
|
|
|
async def refresh_capability_token(
|
|
self,
|
|
current_token: str,
|
|
extend_hours: int = 24
|
|
) -> str:
|
|
"""
|
|
Refresh an existing capability token with extended expiration.
|
|
|
|
Args:
|
|
current_token: Current JWT token
|
|
extend_hours: Hours to extend from now
|
|
|
|
Returns:
|
|
New JWT token with extended expiration
|
|
"""
|
|
|
|
# Verify current token
|
|
payload = await self.verify_capability_token(current_token)
|
|
|
|
# Extract current capabilities
|
|
resources = [cap.get("resource") for cap in payload.get("capabilities", [])]
|
|
|
|
# Generate new token with extended expiration
|
|
return await self.generate_capability_token(
|
|
user_email=payload.get("user_email"),
|
|
tenant_id=payload.get("tenant_id"),
|
|
resources=resources,
|
|
expires_hours=extend_hours
|
|
)
|
|
|
|
def get_token_info(self, token: str) -> Dict[str, Any]:
|
|
"""
|
|
Get information about a token without full verification.
|
|
Useful for debugging and logging.
|
|
"""
|
|
|
|
try:
|
|
# Decode without verification to get claims
|
|
payload = jwt.get_unverified_claims(token)
|
|
|
|
return {
|
|
"user_email": payload.get("user_email"),
|
|
"tenant_id": payload.get("tenant_id"),
|
|
"resources": [cap.get("resource") for cap in payload.get("capabilities", [])],
|
|
"expires_at": datetime.fromtimestamp(payload.get("exp", 0)).isoformat(),
|
|
"issued_at": datetime.fromtimestamp(payload.get("iat", 0)).isoformat(),
|
|
"token_version": payload.get("token_version"),
|
|
"security_level": payload.get("security_level")
|
|
}
|
|
|
|
except Exception as e:
|
|
logger.error(f"Failed to get token info: {e}")
|
|
return {"error": str(e)} |