Files
gt-ai-os-community/apps/tenant-backend/app/core/security.py
HackWeasel b9dfb86260 GT AI OS Community Edition v2.0.33
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>
2025-12-12 17:04:45 -05:00

314 lines
11 KiB
Python

"""
Security module for GT 2.0 Tenant Backend
Provides JWT capability token verification and user authentication.
"""
import os
import jwt
from typing import Dict, Any, Optional
from datetime import datetime, timedelta
from fastapi import Header
import logging
logger = logging.getLogger(__name__)
def get_jwt_secret() -> str:
"""Get JWT secret from environment variable.
The JWT_SECRET is auto-generated by installers using:
openssl rand -hex 32
This provides a 256-bit secret suitable for HS256 signing.
"""
secret = os.environ.get('JWT_SECRET')
if not secret:
raise ValueError("JWT_SECRET environment variable is required. Run the installer to generate one.")
return secret
def verify_capability_token(token: str) -> Optional[Dict[str, Any]]:
"""
Verify JWT capability token using HS256 symmetric key
Args:
token: JWT token string
Returns:
Token payload if valid, None otherwise
"""
try:
secret = get_jwt_secret()
# Verify token with HS256 symmetric key
payload = jwt.decode(token, secret, algorithms=["HS256"])
# Check expiration
if "exp" in payload:
if datetime.utcnow().timestamp() > payload["exp"]:
logger.warning("Token expired")
return None
return payload
except jwt.InvalidTokenError as e:
logger.warning(f"Invalid token: {e}")
return None
except Exception as e:
logger.error(f"Token verification error: {e}")
return None
def create_capability_token(
user_id: str,
tenant_id: str,
capabilities: list,
expires_hours: int = 4
) -> str:
"""
Create JWT capability token using HS256 symmetric key
Args:
user_id: User identifier
tenant_id: Tenant domain
capabilities: List of capability objects
expires_hours: Token expiration in hours
Returns:
JWT token string
"""
try:
secret = get_jwt_secret()
payload = {
"sub": user_id,
"email": user_id,
"user_type": "tenant_user",
# Current tenant context (primary structure)
"current_tenant": {
"id": tenant_id,
"domain": tenant_id,
"name": f"Tenant {tenant_id}",
"role": "tenant_user",
"display_name": user_id,
"email": user_id,
"is_primary": True,
"capabilities": capabilities
},
# Available tenants for tenant switching
"available_tenants": [{
"id": tenant_id,
"domain": tenant_id,
"name": f"Tenant {tenant_id}",
"role": "tenant_user"
}],
# Standard JWT fields
"iat": datetime.utcnow().timestamp(),
"exp": (datetime.utcnow() + timedelta(hours=expires_hours)).timestamp()
}
return jwt.encode(payload, secret, algorithm="HS256")
except Exception as e:
logger.error(f"Failed to create capability token: {e}")
raise ValueError("Failed to create capability token")
async def get_current_user(authorization: str = Header(None)) -> Dict[str, Any]:
"""
Get current user from authorization header - REQUIRED for all endpoints
Raises 401 if authentication fails - following GT 2.0 security principles
"""
from fastapi import HTTPException, status
if not authorization:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Authentication required",
headers={"WWW-Authenticate": "Bearer"}
)
if not authorization.startswith("Bearer "):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid authentication credentials",
headers={"WWW-Authenticate": "Bearer"}
)
# Extract token
token = authorization.replace("Bearer ", "")
payload = verify_capability_token(token)
if not payload:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid or expired token",
headers={"WWW-Authenticate": "Bearer"}
)
# Extract tenant context from new JWT structure
current_tenant = payload.get('current_tenant', {})
available_tenants = payload.get('available_tenants', [])
user_type = payload.get('user_type', 'tenant_user')
# For admin users, allow access to any tenant backend
if user_type == 'super_admin' and current_tenant.get('domain') == 'admin':
# Admin users accessing tenant backends - create tenant context for the current backend
from app.core.config import get_settings
settings = get_settings()
# Override the admin context with the current tenant backend's context
current_tenant = {
'id': settings.tenant_id,
'domain': settings.tenant_domain,
'name': f'Tenant {settings.tenant_domain}',
'role': 'super_admin',
'display_name': payload.get('email', 'Admin User'),
'email': payload.get('email'),
'is_primary': True,
'capabilities': [
{'resource': '*', 'actions': ['*'], 'constraints': {}},
]
}
logger.info(f"Admin user {payload.get('email')} accessing tenant backend {settings.tenant_domain}")
# Validate tenant context exists
if not current_tenant or not current_tenant.get('id'):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="No valid tenant context in token",
headers={"WWW-Authenticate": "Bearer"}
)
# Return user dict with clean tenant context structure
return {
'sub': payload.get('sub'),
'email': payload.get('email'),
'user_id': payload.get('sub'),
'user_type': payload.get('user_type', 'tenant_user'),
# Current tenant context (primary structure)
'tenant_id': str(current_tenant.get('id')),
'tenant_domain': current_tenant.get('domain'),
'tenant_name': current_tenant.get('name'),
'tenant_role': current_tenant.get('role'),
'tenant_display_name': current_tenant.get('display_name'),
'tenant_email': current_tenant.get('email'),
'is_primary_tenant': current_tenant.get('is_primary', False),
# Tenant-specific capabilities
'capabilities': current_tenant.get('capabilities', []),
# Available tenants for tenant switching
'available_tenants': available_tenants
}
def get_current_user_email(authorization: str) -> str:
"""
Extract user email from authorization header
"""
if authorization.startswith("Bearer "):
token = authorization.replace("Bearer ", "")
payload = verify_capability_token(token)
if payload:
current_tenant = payload.get('current_tenant', {})
# Prefer tenant-specific email, fallback to user email, then sub
return (current_tenant.get('email') or
payload.get('email') or
payload.get('sub', 'test@example.com'))
return 'anonymous@example.com'
def get_tenant_info(authorization: str) -> Dict[str, str]:
"""
Extract tenant information from authorization header
"""
if authorization.startswith("Bearer "):
token = authorization.replace("Bearer ", "")
payload = verify_capability_token(token)
if payload:
current_tenant = payload.get('current_tenant', {})
if current_tenant:
return {
'tenant_id': str(current_tenant.get('id')),
'tenant_domain': current_tenant.get('domain'),
'tenant_name': current_tenant.get('name'),
'tenant_role': current_tenant.get('role')
}
return {
'tenant_id': 'default',
'tenant_domain': 'default',
'tenant_name': 'Default Tenant',
'tenant_role': 'tenant_user'
}
def verify_jwt_token(token: str) -> Optional[Dict[str, Any]]:
"""
Verify JWT token - alias for verify_capability_token
"""
return verify_capability_token(token)
async def get_user_context_unified(
authorization: Optional[str] = Header(None),
x_tenant_domain: Optional[str] = Header(None),
x_user_id: Optional[str] = Header(None)
) -> Dict[str, Any]:
"""
Unified authentication for both JWT (user requests) and header-based (service requests).
Supports two auth modes:
1. JWT Authentication: Authorization header with Bearer token (for direct user requests)
2. Header Authentication: X-Tenant-Domain + X-User-ID headers (for internal service requests)
Returns user context with tenant information for both modes.
"""
from fastapi import HTTPException, status
# Mode 1: Header-based authentication (for internal services like MCP)
if x_tenant_domain and x_user_id:
logger.info(f"Using header auth: tenant={x_tenant_domain}, user={x_user_id}")
return {
"tenant_domain": x_tenant_domain,
"tenant_id": x_tenant_domain,
"id": x_user_id,
"sub": x_user_id,
"email": x_user_id,
"user_id": x_user_id,
"user_type": "internal_service",
"tenant_role": "tenant_user"
}
# Mode 2: JWT authentication (for direct user requests)
if authorization and authorization.startswith("Bearer "):
token = authorization.replace("Bearer ", "")
payload = verify_capability_token(token)
if payload:
logger.info(f"Using JWT auth: user={payload.get('sub')}")
# Extract tenant context from JWT structure
current_tenant = payload.get('current_tenant', {})
return {
'sub': payload.get('sub'),
'email': payload.get('email'),
'user_id': payload.get('sub'),
'id': payload.get('sub'),
'user_type': payload.get('user_type', 'tenant_user'),
'tenant_id': str(current_tenant.get('id', 'default')),
'tenant_domain': current_tenant.get('domain', 'default'),
'tenant_name': current_tenant.get('name', 'Default Tenant'),
'tenant_role': current_tenant.get('role', 'tenant_user'),
'capabilities': current_tenant.get('capabilities', [])
}
# No valid authentication provided
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Missing authentication: provide either Authorization header or X-Tenant-Domain + X-User-ID headers"
)