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>
This commit is contained in:
314
apps/tenant-backend/app/core/security.py
Normal file
314
apps/tenant-backend/app/core/security.py
Normal file
@@ -0,0 +1,314 @@
|
||||
"""
|
||||
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"
|
||||
)
|
||||
Reference in New Issue
Block a user