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>
457 lines
15 KiB
Python
457 lines
15 KiB
Python
"""
|
|
Capability-Based Authentication for GT 2.0 Resource Cluster
|
|
|
|
Implements JWT capability token verification with:
|
|
- Cryptographic signature validation
|
|
- Fine-grained resource permissions
|
|
- Rate limiting and constraints enforcement
|
|
- Tenant isolation validation
|
|
- Zero external dependencies
|
|
|
|
GT 2.0 Security Principles:
|
|
- Self-contained: No external auth services
|
|
- Stateless: All permissions in JWT token
|
|
- Cryptographic: RSA signature verification
|
|
- Isolated: Perfect tenant separation
|
|
"""
|
|
|
|
import jwt
|
|
import logging
|
|
from datetime import datetime, timezone
|
|
from typing import Dict, Any, List, Optional
|
|
from dataclasses import dataclass
|
|
from enum import Enum
|
|
|
|
from fastapi import HTTPException, Depends, Header
|
|
from app.core.config import get_settings
|
|
|
|
logger = logging.getLogger(__name__)
|
|
settings = get_settings()
|
|
|
|
|
|
class CapabilityError(Exception):
|
|
"""Capability authentication error"""
|
|
pass
|
|
|
|
|
|
class ResourceType(str, Enum):
|
|
"""Resource types in GT 2.0"""
|
|
LLM = "llm"
|
|
EMBEDDING = "embedding"
|
|
VECTOR_STORAGE = "vector_storage"
|
|
EXTERNAL_SERVICES = "external_services"
|
|
ADMIN = "admin"
|
|
|
|
|
|
class ActionType(str, Enum):
|
|
"""Action types for resources"""
|
|
READ = "read"
|
|
WRITE = "write"
|
|
EXECUTE = "execute"
|
|
ADMIN = "admin"
|
|
|
|
|
|
@dataclass
|
|
class Capability:
|
|
"""Individual capability definition"""
|
|
resource: ResourceType
|
|
actions: List[ActionType]
|
|
constraints: Dict[str, Any]
|
|
expires_at: Optional[datetime] = None
|
|
|
|
def allows_action(self, action: ActionType) -> bool:
|
|
"""Check if capability allows specific action"""
|
|
return action in self.actions
|
|
|
|
def is_expired(self) -> bool:
|
|
"""Check if capability is expired"""
|
|
if not self.expires_at:
|
|
return False
|
|
return datetime.now(timezone.utc) > self.expires_at
|
|
|
|
def check_constraint(self, constraint_name: str, value: Any) -> bool:
|
|
"""Check if value satisfies constraint"""
|
|
if constraint_name not in self.constraints:
|
|
return True # No constraint means allowed
|
|
|
|
constraint_value = self.constraints[constraint_name]
|
|
|
|
if constraint_name == "max_tokens":
|
|
return value <= constraint_value
|
|
elif constraint_name == "allowed_models":
|
|
return value in constraint_value
|
|
elif constraint_name == "max_requests_per_hour":
|
|
# This would be checked separately with rate limiting
|
|
return True
|
|
elif constraint_name == "allowed_tenants":
|
|
return value in constraint_value
|
|
|
|
return True
|
|
|
|
|
|
@dataclass
|
|
class CapabilityToken:
|
|
"""Parsed capability token"""
|
|
subject: str
|
|
tenant_id: str
|
|
capabilities: List[Capability]
|
|
issued_at: datetime
|
|
expires_at: datetime
|
|
issuer: str
|
|
token_version: str
|
|
|
|
def has_capability(self, resource: ResourceType, action: ActionType) -> bool:
|
|
"""Check if token has specific capability"""
|
|
for cap in self.capabilities:
|
|
if cap.resource == resource and cap.allows_action(action) and not cap.is_expired():
|
|
return True
|
|
return False
|
|
|
|
def get_capability(self, resource: ResourceType) -> Optional[Capability]:
|
|
"""Get capability for specific resource"""
|
|
for cap in self.capabilities:
|
|
if cap.resource == resource and not cap.is_expired():
|
|
return cap
|
|
return None
|
|
|
|
def is_expired(self) -> bool:
|
|
"""Check if entire token is expired"""
|
|
return datetime.now(timezone.utc) > self.expires_at
|
|
|
|
|
|
class CapabilityAuthenticator:
|
|
"""
|
|
Handles capability token verification and authorization.
|
|
|
|
Uses JWT tokens with embedded permissions for stateless authentication.
|
|
"""
|
|
|
|
def __init__(self):
|
|
self.settings = get_settings()
|
|
|
|
# In production, this would be loaded from secure storage
|
|
# For development, using the secret key
|
|
self.secret_key = self.settings.secret_key
|
|
self.algorithm = "HS256" # TODO: Upgrade to RS256 with public/private keys
|
|
|
|
logger.info("Capability authenticator initialized")
|
|
|
|
async def verify_token(self, token: str) -> CapabilityToken:
|
|
"""
|
|
Verify and parse capability token.
|
|
|
|
Args:
|
|
token: JWT capability token
|
|
|
|
Returns:
|
|
Parsed capability token
|
|
|
|
Raises:
|
|
CapabilityError: If token is invalid or expired
|
|
"""
|
|
try:
|
|
# Decode JWT token
|
|
payload = jwt.decode(
|
|
token,
|
|
self.secret_key,
|
|
algorithms=[self.algorithm],
|
|
audience="gt2-resource-cluster"
|
|
)
|
|
|
|
# Validate required fields
|
|
required_fields = ["sub", "tenant_id", "capabilities", "iat", "exp", "iss"]
|
|
for field in required_fields:
|
|
if field not in payload:
|
|
raise CapabilityError(f"Missing required field: {field}")
|
|
|
|
# Parse timestamps
|
|
issued_at = datetime.fromtimestamp(payload["iat"], tz=timezone.utc)
|
|
expires_at = datetime.fromtimestamp(payload["exp"], tz=timezone.utc)
|
|
|
|
# Check token expiration
|
|
if datetime.now(timezone.utc) > expires_at:
|
|
raise CapabilityError("Token has expired")
|
|
|
|
# Parse capabilities
|
|
capabilities = []
|
|
for cap_data in payload["capabilities"]:
|
|
try:
|
|
capability = Capability(
|
|
resource=ResourceType(cap_data["resource"]),
|
|
actions=[ActionType(action) for action in cap_data["actions"]],
|
|
constraints=cap_data.get("constraints", {}),
|
|
expires_at=datetime.fromtimestamp(
|
|
cap_data["expires_at"], tz=timezone.utc
|
|
) if cap_data.get("expires_at") else None
|
|
)
|
|
capabilities.append(capability)
|
|
except (KeyError, ValueError) as e:
|
|
logger.warning(f"Invalid capability in token: {e}")
|
|
# Skip invalid capabilities rather than rejecting entire token
|
|
continue
|
|
|
|
# Create capability token
|
|
capability_token = CapabilityToken(
|
|
subject=payload["sub"],
|
|
tenant_id=payload["tenant_id"],
|
|
capabilities=capabilities,
|
|
issued_at=issued_at,
|
|
expires_at=expires_at,
|
|
issuer=payload["iss"],
|
|
token_version=payload.get("token_version", "1.0")
|
|
)
|
|
|
|
logger.debug(f"Capability token verified for {capability_token.subject}")
|
|
return capability_token
|
|
|
|
except jwt.ExpiredSignatureError:
|
|
raise CapabilityError("Token has expired")
|
|
except jwt.InvalidTokenError as e:
|
|
raise CapabilityError(f"Invalid token: {e}")
|
|
except Exception as e:
|
|
logger.error(f"Token verification failed: {e}")
|
|
raise CapabilityError(f"Token verification failed: {e}")
|
|
|
|
async def check_resource_access(
|
|
self,
|
|
capability_token: CapabilityToken,
|
|
resource: ResourceType,
|
|
action: ActionType,
|
|
constraints: Optional[Dict[str, Any]] = None
|
|
) -> bool:
|
|
"""
|
|
Check if token allows access to resource with specific action.
|
|
|
|
Args:
|
|
capability_token: Verified capability token
|
|
resource: Resource type to access
|
|
action: Action to perform
|
|
constraints: Additional constraints to check
|
|
|
|
Returns:
|
|
True if access is allowed
|
|
|
|
Raises:
|
|
CapabilityError: If access is denied
|
|
"""
|
|
try:
|
|
# Check token expiration
|
|
if capability_token.is_expired():
|
|
raise CapabilityError("Token has expired")
|
|
|
|
# Find matching capability
|
|
capability = capability_token.get_capability(resource)
|
|
if not capability:
|
|
raise CapabilityError(f"No capability for resource: {resource}")
|
|
|
|
# Check action permission
|
|
if not capability.allows_action(action):
|
|
raise CapabilityError(f"Action {action} not allowed for resource {resource}")
|
|
|
|
# Check constraints if provided
|
|
if constraints:
|
|
for constraint_name, value in constraints.items():
|
|
if not capability.check_constraint(constraint_name, value):
|
|
raise CapabilityError(
|
|
f"Constraint violation: {constraint_name} = {value}"
|
|
)
|
|
|
|
return True
|
|
|
|
except CapabilityError:
|
|
raise
|
|
except Exception as e:
|
|
logger.error(f"Resource access check failed: {e}")
|
|
raise CapabilityError(f"Access check failed: {e}")
|
|
|
|
|
|
# Global authenticator instance
|
|
capability_authenticator = CapabilityAuthenticator()
|
|
|
|
|
|
async def verify_capability_token(token: str) -> Dict[str, Any]:
|
|
"""
|
|
Verify capability token and return payload.
|
|
|
|
Args:
|
|
token: JWT capability token
|
|
|
|
Returns:
|
|
Token payload as dictionary
|
|
|
|
Raises:
|
|
CapabilityError: If token is invalid
|
|
"""
|
|
capability_token = await capability_authenticator.verify_token(token)
|
|
|
|
return {
|
|
"sub": capability_token.subject,
|
|
"tenant_id": capability_token.tenant_id,
|
|
"capabilities": [
|
|
{
|
|
"resource": cap.resource.value,
|
|
"actions": [action.value for action in cap.actions],
|
|
"constraints": cap.constraints
|
|
}
|
|
for cap in capability_token.capabilities
|
|
],
|
|
"iat": capability_token.issued_at.timestamp(),
|
|
"exp": capability_token.expires_at.timestamp(),
|
|
"iss": capability_token.issuer,
|
|
"token_version": capability_token.token_version
|
|
}
|
|
|
|
|
|
async def get_current_capability(
|
|
authorization: str = Header(..., description="Bearer token")
|
|
) -> Dict[str, Any]:
|
|
"""
|
|
FastAPI dependency to get current capability from Authorization header.
|
|
|
|
Args:
|
|
authorization: Authorization header with Bearer token
|
|
|
|
Returns:
|
|
Capability payload
|
|
|
|
Raises:
|
|
HTTPException: If authentication fails
|
|
"""
|
|
try:
|
|
if not authorization.startswith("Bearer "):
|
|
raise HTTPException(
|
|
status_code=401,
|
|
detail="Invalid authorization header format"
|
|
)
|
|
|
|
token = authorization[7:] # Remove "Bearer " prefix
|
|
payload = await verify_capability_token(token)
|
|
|
|
return payload
|
|
|
|
except CapabilityError as e:
|
|
logger.warning(f"Capability authentication failed: {e}")
|
|
raise HTTPException(status_code=401, detail=str(e))
|
|
except Exception as e:
|
|
logger.error(f"Authentication error: {e}")
|
|
raise HTTPException(status_code=500, detail="Authentication error")
|
|
|
|
|
|
async def require_capability(
|
|
resource: ResourceType,
|
|
action: ActionType,
|
|
constraints: Optional[Dict[str, Any]] = None
|
|
):
|
|
"""
|
|
FastAPI dependency to require specific capability.
|
|
|
|
Args:
|
|
resource: Required resource type
|
|
action: Required action type
|
|
constraints: Additional constraints to check
|
|
|
|
Returns:
|
|
Dependency function
|
|
"""
|
|
async def _check_capability(
|
|
capability_payload: Dict[str, Any] = Depends(get_current_capability)
|
|
) -> Dict[str, Any]:
|
|
try:
|
|
# Reconstruct capability token from payload
|
|
capabilities = []
|
|
for cap_data in capability_payload["capabilities"]:
|
|
capability = Capability(
|
|
resource=ResourceType(cap_data["resource"]),
|
|
actions=[ActionType(action) for action in cap_data["actions"]],
|
|
constraints=cap_data["constraints"]
|
|
)
|
|
capabilities.append(capability)
|
|
|
|
capability_token = CapabilityToken(
|
|
subject=capability_payload["sub"],
|
|
tenant_id=capability_payload["tenant_id"],
|
|
capabilities=capabilities,
|
|
issued_at=datetime.fromtimestamp(capability_payload["iat"], tz=timezone.utc),
|
|
expires_at=datetime.fromtimestamp(capability_payload["exp"], tz=timezone.utc),
|
|
issuer=capability_payload["iss"],
|
|
token_version=capability_payload["token_version"]
|
|
)
|
|
|
|
# Check required capability
|
|
await capability_authenticator.check_resource_access(
|
|
capability_token=capability_token,
|
|
resource=resource,
|
|
action=action,
|
|
constraints=constraints
|
|
)
|
|
|
|
return capability_payload
|
|
|
|
except CapabilityError as e:
|
|
logger.warning(f"Capability check failed: {e}")
|
|
raise HTTPException(status_code=403, detail=str(e))
|
|
except Exception as e:
|
|
logger.error(f"Capability check error: {e}")
|
|
raise HTTPException(status_code=500, detail="Authorization error")
|
|
|
|
return _check_capability
|
|
|
|
|
|
# Convenience functions for common capability checks
|
|
|
|
async def require_llm_capability(
|
|
capability_payload: Dict[str, Any] = Depends(
|
|
require_capability(ResourceType.LLM, ActionType.EXECUTE)
|
|
)
|
|
) -> Dict[str, Any]:
|
|
"""Require LLM execution capability"""
|
|
return capability_payload
|
|
|
|
|
|
async def require_embedding_capability(
|
|
capability_payload: Dict[str, Any] = Depends(
|
|
require_capability(ResourceType.EMBEDDING, ActionType.EXECUTE)
|
|
)
|
|
) -> Dict[str, Any]:
|
|
"""Require embedding generation capability"""
|
|
return capability_payload
|
|
|
|
|
|
async def require_admin_capability(
|
|
capability_payload: Dict[str, Any] = Depends(
|
|
require_capability(ResourceType.ADMIN, ActionType.ADMIN)
|
|
)
|
|
) -> Dict[str, Any]:
|
|
"""Require admin capability"""
|
|
return capability_payload
|
|
|
|
|
|
async def verify_capability_token_dependency(
|
|
authorization: str = Header(..., description="Bearer token")
|
|
) -> Dict[str, Any]:
|
|
"""
|
|
FastAPI dependency for ChromaDB MCP API that verifies capability token.
|
|
|
|
Returns token payload with raw_token field for service layer use.
|
|
"""
|
|
try:
|
|
if not authorization.startswith("Bearer "):
|
|
raise HTTPException(
|
|
status_code=401,
|
|
detail="Invalid authorization header format"
|
|
)
|
|
|
|
token = authorization[7:] # Remove "Bearer " prefix
|
|
payload = await verify_capability_token(token)
|
|
|
|
# Add raw token for service layer
|
|
payload["raw_token"] = token
|
|
|
|
return payload
|
|
|
|
except CapabilityError as e:
|
|
logger.warning(f"Capability authentication failed: {e}")
|
|
raise HTTPException(status_code=401, detail=str(e))
|
|
except Exception as e:
|
|
logger.error(f"Authentication error: {e}")
|
|
raise HTTPException(status_code=500, detail="Authentication error") |