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:
5
apps/tenant-backend/app/middleware/__init__.py
Normal file
5
apps/tenant-backend/app/middleware/__init__.py
Normal file
@@ -0,0 +1,5 @@
|
||||
"""
|
||||
GT 2.0 Tenant Backend Middleware
|
||||
|
||||
Security and isolation middleware for tenant applications.
|
||||
"""
|
||||
385
apps/tenant-backend/app/middleware/oauth2_auth.py
Normal file
385
apps/tenant-backend/app/middleware/oauth2_auth.py
Normal file
@@ -0,0 +1,385 @@
|
||||
"""
|
||||
OAuth2 Authentication Middleware for GT 2.0 Tenant Backend
|
||||
|
||||
Handles OAuth2 authentication headers from OAuth2 Proxy and extracts
|
||||
user information for tenant isolation and access control.
|
||||
"""
|
||||
|
||||
from fastapi import Request, HTTPException, status
|
||||
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
|
||||
from starlette.middleware.base import BaseHTTPMiddleware
|
||||
from typing import Optional, Dict, Any
|
||||
import logging
|
||||
import json
|
||||
import base64
|
||||
from urllib.parse import unquote
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class OAuth2AuthMiddleware(BaseHTTPMiddleware):
|
||||
"""
|
||||
Middleware to handle OAuth2 authentication from OAuth2 Proxy.
|
||||
|
||||
Extracts user information from OAuth2 Proxy headers and sets
|
||||
user context for downstream handlers.
|
||||
"""
|
||||
|
||||
# Routes that don't require authentication
|
||||
EXEMPT_PATHS = {
|
||||
"/health",
|
||||
"/metrics",
|
||||
"/docs",
|
||||
"/openapi.json",
|
||||
"/api/v1/health",
|
||||
"/api/v1/auth/login",
|
||||
"/api/v1/auth/refresh",
|
||||
"/api/v1/auth/logout"
|
||||
}
|
||||
|
||||
def __init__(self, app, require_auth: bool = True):
|
||||
super().__init__(app)
|
||||
self.require_auth = require_auth
|
||||
|
||||
async def dispatch(self, request: Request, call_next):
|
||||
"""Process OAuth2 authentication headers"""
|
||||
|
||||
# Skip authentication for exempt paths
|
||||
if request.url.path in self.EXEMPT_PATHS:
|
||||
return await call_next(request)
|
||||
|
||||
# Try OAuth2 headers first, then fallback to JWT token authentication
|
||||
user_info = self._extract_oauth2_headers(request)
|
||||
|
||||
# If no OAuth2 headers found, try JWT token authentication
|
||||
if not user_info:
|
||||
user_info = await self._extract_jwt_user(request)
|
||||
|
||||
if self.require_auth and not user_info:
|
||||
logger.warning(f"Authentication required but no valid OAuth2 headers found for {request.url.path}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Authentication required",
|
||||
headers={"WWW-Authenticate": "Bearer"}
|
||||
)
|
||||
|
||||
# Set user context in request state
|
||||
if user_info:
|
||||
request.state.user = user_info
|
||||
request.state.authenticated = True
|
||||
logger.info(f"Authenticated user: {user_info.get('email', 'unknown')} for {request.url.path}")
|
||||
else:
|
||||
request.state.user = None
|
||||
request.state.authenticated = False
|
||||
|
||||
# Continue with request processing
|
||||
response = await call_next(request)
|
||||
|
||||
# Add authentication-related headers to response
|
||||
if user_info:
|
||||
response.headers["X-Authenticated-User"] = user_info.get("email", "unknown")
|
||||
response.headers["X-Auth-Source"] = user_info.get("auth_source", "oauth2-proxy")
|
||||
|
||||
return response
|
||||
|
||||
def _extract_oauth2_headers(self, request: Request) -> Optional[Dict[str, Any]]:
|
||||
"""
|
||||
Extract user information from OAuth2 Proxy headers.
|
||||
|
||||
OAuth2 Proxy sets the following headers:
|
||||
- X-Auth-Request-User: Username/email
|
||||
- X-Auth-Request-Email: User email
|
||||
- X-Auth-Request-Access-Token: Access token
|
||||
- Authorization: Bearer token (if configured)
|
||||
"""
|
||||
|
||||
# Extract user information from OAuth2 Proxy headers
|
||||
user_email = request.headers.get("X-Auth-Request-Email")
|
||||
user_name = request.headers.get("X-Auth-Request-User")
|
||||
access_token = request.headers.get("X-Auth-Request-Access-Token")
|
||||
|
||||
# Also check Authorization header for bearer token
|
||||
auth_header = request.headers.get("Authorization")
|
||||
bearer_token = None
|
||||
if auth_header and auth_header.startswith("Bearer "):
|
||||
bearer_token = auth_header[7:] # Remove "Bearer " prefix
|
||||
|
||||
if not user_email and not user_name:
|
||||
logger.debug("No OAuth2 authentication headers found")
|
||||
return None
|
||||
|
||||
user_info = {
|
||||
"email": user_email,
|
||||
"username": user_name or user_email,
|
||||
"access_token": access_token,
|
||||
"bearer_token": bearer_token,
|
||||
"auth_source": "oauth2-proxy",
|
||||
"authenticated_at": request.headers.get("X-Auth-Request-Timestamp"),
|
||||
}
|
||||
|
||||
# Extract additional user attributes if present
|
||||
if groups_header := request.headers.get("X-Auth-Request-Groups"):
|
||||
try:
|
||||
# Groups might be base64 encoded or comma-separated
|
||||
if self._is_base64(groups_header):
|
||||
groups_decoded = base64.b64decode(groups_header).decode('utf-8')
|
||||
user_info["groups"] = json.loads(groups_decoded)
|
||||
else:
|
||||
user_info["groups"] = groups_header.split(",")
|
||||
except (json.JSONDecodeError, UnicodeDecodeError) as e:
|
||||
logger.warning(f"Failed to decode groups header: {e}")
|
||||
user_info["groups"] = []
|
||||
|
||||
# Extract user roles if present
|
||||
if roles_header := request.headers.get("X-Auth-Request-Roles"):
|
||||
try:
|
||||
if self._is_base64(roles_header):
|
||||
roles_decoded = base64.b64decode(roles_header).decode('utf-8')
|
||||
user_info["roles"] = json.loads(roles_decoded)
|
||||
else:
|
||||
user_info["roles"] = roles_header.split(",")
|
||||
except (json.JSONDecodeError, UnicodeDecodeError) as e:
|
||||
logger.warning(f"Failed to decode roles header: {e}")
|
||||
user_info["roles"] = []
|
||||
|
||||
# Extract tenant information from headers or JWT token
|
||||
tenant_id = self._extract_tenant_info(request, user_info)
|
||||
if tenant_id:
|
||||
user_info["tenant_id"] = tenant_id
|
||||
|
||||
return user_info
|
||||
|
||||
def _extract_tenant_info(self, request: Request, user_info: Dict[str, Any]) -> Optional[str]:
|
||||
"""
|
||||
Extract tenant information from request headers or JWT token.
|
||||
|
||||
Tenant information can come from:
|
||||
1. X-Tenant-ID header (set by load balancer based on domain)
|
||||
2. JWT token claims
|
||||
3. Domain name parsing
|
||||
"""
|
||||
|
||||
# Check for explicit tenant header
|
||||
if tenant_header := request.headers.get("X-Tenant-ID"):
|
||||
return tenant_header
|
||||
|
||||
# Extract tenant from domain name
|
||||
host = request.headers.get("Host", "")
|
||||
if host and "." in host:
|
||||
# Assume format: tenant.gt2.com
|
||||
potential_tenant = host.split(".")[0]
|
||||
if potential_tenant != "www" and potential_tenant != "api":
|
||||
return potential_tenant
|
||||
|
||||
# Try to extract from JWT token if present
|
||||
if bearer_token := user_info.get("bearer_token"):
|
||||
tenant_from_jwt = self._extract_tenant_from_jwt(bearer_token)
|
||||
if tenant_from_jwt:
|
||||
return tenant_from_jwt
|
||||
|
||||
logger.warning(f"Could not determine tenant for user {user_info.get('email', 'unknown')}")
|
||||
return None
|
||||
|
||||
def _extract_tenant_from_jwt(self, token: str) -> Optional[str]:
|
||||
"""
|
||||
Extract tenant information from JWT token without verifying signature.
|
||||
|
||||
Note: This is just for extracting claims, not for security validation.
|
||||
Security validation should be done by OAuth2 Proxy.
|
||||
"""
|
||||
try:
|
||||
# Split JWT token (header.payload.signature)
|
||||
parts = token.split(".")
|
||||
if len(parts) != 3:
|
||||
return None
|
||||
|
||||
# Decode payload (add padding if needed)
|
||||
payload = parts[1]
|
||||
# Add padding if needed for base64 decoding
|
||||
payload += "=" * (4 - len(payload) % 4)
|
||||
|
||||
decoded_payload = base64.urlsafe_b64decode(payload)
|
||||
claims = json.loads(decoded_payload)
|
||||
|
||||
# Look for tenant in various claim fields
|
||||
tenant_claims = ["tenant_id", "tenant", "org_id", "organization"]
|
||||
for claim in tenant_claims:
|
||||
if claim in claims:
|
||||
return str(claims[claim])
|
||||
|
||||
except (json.JSONDecodeError, UnicodeDecodeError, ValueError) as e:
|
||||
logger.debug(f"Failed to decode JWT payload: {e}")
|
||||
|
||||
return None
|
||||
|
||||
def _is_base64(self, s: str) -> bool:
|
||||
"""Check if a string is base64 encoded"""
|
||||
try:
|
||||
if isinstance(s, str):
|
||||
s = s.encode('ascii')
|
||||
return base64.b64encode(base64.b64decode(s)) == s
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
async def _extract_jwt_user(self, request: Request) -> Optional[Dict[str, Any]]:
|
||||
"""
|
||||
Extract user information from JWT token in Authorization header.
|
||||
|
||||
This provides fallback authentication when OAuth2 proxy headers are not present.
|
||||
"""
|
||||
from app.core.security import get_current_user
|
||||
|
||||
try:
|
||||
# Get Authorization header
|
||||
auth_header = request.headers.get("Authorization", "")
|
||||
if not auth_header.startswith("Bearer "):
|
||||
return None
|
||||
|
||||
# Use the security module to validate and extract user info
|
||||
user_data = await get_current_user(auth_header)
|
||||
|
||||
# Convert security module format to middleware format
|
||||
if user_data:
|
||||
return {
|
||||
"email": user_data.get("email", user_data.get("user_id", "unknown")),
|
||||
"username": user_data.get("tenant_display_name", user_data.get("email", "unknown")),
|
||||
"tenant_id": user_data.get("tenant_id", "1"),
|
||||
"tenant_domain": user_data.get("tenant_domain", "default"),
|
||||
"tenant_name": user_data.get("tenant_name", "Default Tenant"),
|
||||
"tenant_role": user_data.get("tenant_role", "tenant_user"),
|
||||
"user_type": user_data.get("user_type", "tenant_user"),
|
||||
"capabilities": user_data.get("capabilities", []),
|
||||
"resource_limits": user_data.get("resource_limits", {}),
|
||||
"auth_source": "jwt-token",
|
||||
"bearer_token": auth_header[7:], # Remove "Bearer " prefix
|
||||
"authenticated_at": None,
|
||||
"is_primary_tenant": user_data.get("is_primary_tenant", False)
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.debug(f"Failed to authenticate via JWT token: {e}")
|
||||
return None
|
||||
|
||||
return None
|
||||
|
||||
|
||||
class OAuth2SecurityDependency:
|
||||
"""
|
||||
FastAPI dependency to get current authenticated user from OAuth2 context.
|
||||
|
||||
Usage:
|
||||
@app.get("/api/v1/user/profile")
|
||||
async def get_profile(user: dict = Depends(get_current_user)):
|
||||
return {"user": user}
|
||||
"""
|
||||
|
||||
def __call__(self, request: Request) -> Dict[str, Any]:
|
||||
"""Get current authenticated user from request state"""
|
||||
|
||||
if not hasattr(request.state, "authenticated") or not request.state.authenticated:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Authentication required",
|
||||
headers={"WWW-Authenticate": "Bearer"}
|
||||
)
|
||||
|
||||
return request.state.user
|
||||
|
||||
|
||||
# Singleton instance for dependency injection
|
||||
get_current_user = OAuth2SecurityDependency()
|
||||
|
||||
|
||||
def get_current_user_optional(request: Request) -> Optional[Dict[str, Any]]:
|
||||
"""
|
||||
Get current authenticated user (optional - doesn't raise exception if not authenticated).
|
||||
|
||||
Usage:
|
||||
@app.get("/api/v1/public/info")
|
||||
async def get_info(user: Optional[dict] = Depends(get_current_user_optional)):
|
||||
if user:
|
||||
return {"message": f"Hello {user['email']}"}
|
||||
return {"message": "Hello anonymous user"}
|
||||
"""
|
||||
|
||||
if hasattr(request.state, "authenticated") and request.state.authenticated:
|
||||
return request.state.user
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def require_tenant_access(required_tenant: Optional[str] = None):
|
||||
"""
|
||||
Dependency to ensure user has access to specified tenant.
|
||||
|
||||
Usage:
|
||||
@app.get("/api/v1/tenant/{tenant_id}/data")
|
||||
async def get_tenant_data(
|
||||
tenant_id: str,
|
||||
user: dict = Depends(get_current_user),
|
||||
_: None = Depends(require_tenant_access)
|
||||
):
|
||||
# User is guaranteed to have access to tenant_id
|
||||
return {"data": "tenant specific data"}
|
||||
"""
|
||||
|
||||
def dependency(request: Request, user: Dict[str, Any] = Depends(get_current_user)) -> None:
|
||||
"""Check tenant access for current user"""
|
||||
|
||||
user_tenant = user.get("tenant_id")
|
||||
|
||||
# If no required tenant specified, use the one from user context
|
||||
target_tenant = required_tenant or user_tenant
|
||||
|
||||
if not target_tenant:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Tenant information not available"
|
||||
)
|
||||
|
||||
# Check if user has access to the required tenant
|
||||
if user_tenant != target_tenant:
|
||||
logger.warning(
|
||||
f"User {user.get('email', 'unknown')} attempted to access tenant {target_tenant} "
|
||||
f"but belongs to tenant {user_tenant}"
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Access denied: insufficient tenant permissions"
|
||||
)
|
||||
|
||||
return dependency
|
||||
|
||||
|
||||
def require_roles(*required_roles: str):
|
||||
"""
|
||||
Dependency to ensure user has one of the required roles.
|
||||
|
||||
Usage:
|
||||
@app.delete("/api/v1/admin/users/{user_id}")
|
||||
async def delete_user(
|
||||
user_id: str,
|
||||
user: dict = Depends(get_current_user),
|
||||
_: None = Depends(require_roles("admin", "user_manager"))
|
||||
):
|
||||
# User has admin or user_manager role
|
||||
return {"deleted": user_id}
|
||||
"""
|
||||
|
||||
def dependency(user: Dict[str, Any] = Depends(get_current_user)) -> None:
|
||||
"""Check role requirements for current user"""
|
||||
|
||||
user_roles = set(user.get("roles", []))
|
||||
required_roles_set = set(required_roles)
|
||||
|
||||
if not user_roles.intersection(required_roles_set):
|
||||
logger.warning(
|
||||
f"User {user.get('email', 'unknown')} with roles {list(user_roles)} "
|
||||
f"attempted to access endpoint requiring roles {list(required_roles_set)}"
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail=f"Access denied: requires one of roles: {', '.join(required_roles)}"
|
||||
)
|
||||
|
||||
return dependency
|
||||
89
apps/tenant-backend/app/middleware/rate_limiting.py
Normal file
89
apps/tenant-backend/app/middleware/rate_limiting.py
Normal file
@@ -0,0 +1,89 @@
|
||||
"""
|
||||
Rate Limiting Middleware for GT 2.0
|
||||
|
||||
Basic rate limiting implementation for tenant protection.
|
||||
"""
|
||||
|
||||
from fastapi import Request, Response
|
||||
from starlette.middleware.base import BaseHTTPMiddleware
|
||||
from starlette.responses import JSONResponse
|
||||
import time
|
||||
from typing import Dict, Tuple
|
||||
import logging
|
||||
|
||||
from app.core.config import get_settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
settings = get_settings()
|
||||
|
||||
|
||||
class RateLimitMiddleware(BaseHTTPMiddleware):
|
||||
"""Simple in-memory rate limiting middleware"""
|
||||
|
||||
# Operational endpoints that don't need rate limiting
|
||||
EXEMPT_PATHS = {
|
||||
"/health",
|
||||
"/ready",
|
||||
"/metrics",
|
||||
"/api/v1/health"
|
||||
}
|
||||
|
||||
def __init__(self, app):
|
||||
super().__init__(app)
|
||||
self._rate_limits: Dict[str, Tuple[int, float]] = {} # ip -> (count, window_start)
|
||||
|
||||
async def dispatch(self, request: Request, call_next):
|
||||
# Skip rate limiting for operational endpoints
|
||||
if request.url.path in self.EXEMPT_PATHS:
|
||||
return await call_next(request)
|
||||
|
||||
client_ip = self._get_client_ip(request)
|
||||
|
||||
if self._is_rate_limited(client_ip):
|
||||
logger.warning(f"Rate limit exceeded for IP: {client_ip} - Path: {request.url.path}")
|
||||
# Return proper JSONResponse instead of raising HTTPException to prevent ASGI violations
|
||||
return JSONResponse(
|
||||
status_code=429,
|
||||
content={"detail": "Too many requests. Please try again later."},
|
||||
headers={"Retry-After": str(settings.rate_limit_window_seconds)}
|
||||
)
|
||||
|
||||
response = await call_next(request)
|
||||
return response
|
||||
|
||||
def _get_client_ip(self, request: Request) -> str:
|
||||
"""Extract client IP address"""
|
||||
# Check for forwarded IP first (behind proxy/load balancer)
|
||||
forwarded_for = request.headers.get("X-Forwarded-For")
|
||||
if forwarded_for:
|
||||
return forwarded_for.split(",")[0].strip()
|
||||
|
||||
# Check for real IP header
|
||||
real_ip = request.headers.get("X-Real-IP")
|
||||
if real_ip:
|
||||
return real_ip
|
||||
|
||||
# Fall back to direct client IP
|
||||
return request.client.host if request.client else "unknown"
|
||||
|
||||
def _is_rate_limited(self, client_ip: str) -> bool:
|
||||
"""Check if client IP is rate limited"""
|
||||
current_time = time.time()
|
||||
|
||||
if client_ip not in self._rate_limits:
|
||||
self._rate_limits[client_ip] = (1, current_time)
|
||||
return False
|
||||
|
||||
count, window_start = self._rate_limits[client_ip]
|
||||
|
||||
# Check if we're still in the same window
|
||||
if current_time - window_start < settings.rate_limit_window_seconds:
|
||||
if count >= settings.rate_limit_requests:
|
||||
return True # Rate limited
|
||||
else:
|
||||
self._rate_limits[client_ip] = (count + 1, window_start)
|
||||
return False
|
||||
else:
|
||||
# New window, reset count
|
||||
self._rate_limits[client_ip] = (1, current_time)
|
||||
return False
|
||||
36
apps/tenant-backend/app/middleware/security.py
Normal file
36
apps/tenant-backend/app/middleware/security.py
Normal file
@@ -0,0 +1,36 @@
|
||||
"""
|
||||
Security Headers Middleware for GT 2.0
|
||||
|
||||
Adds security headers to all responses.
|
||||
"""
|
||||
|
||||
from fastapi import Request, Response
|
||||
from starlette.middleware.base import BaseHTTPMiddleware
|
||||
import uuid
|
||||
|
||||
|
||||
class SecurityHeadersMiddleware(BaseHTTPMiddleware):
|
||||
"""Middleware to add security headers to all responses"""
|
||||
|
||||
async def dispatch(self, request: Request, call_next):
|
||||
# Generate request ID for tracing
|
||||
request_id = str(uuid.uuid4())
|
||||
request.state.request_id = request_id
|
||||
|
||||
response = await call_next(request)
|
||||
|
||||
# Add security headers
|
||||
response.headers["X-Request-ID"] = request_id
|
||||
response.headers["X-Content-Type-Options"] = "nosniff"
|
||||
response.headers["X-Frame-Options"] = "DENY"
|
||||
response.headers["X-XSS-Protection"] = "1; mode=block"
|
||||
response.headers["Referrer-Policy"] = "strict-origin-when-cross-origin"
|
||||
response.headers["Content-Security-Policy"] = (
|
||||
"default-src 'self'; "
|
||||
"script-src 'self'; "
|
||||
"style-src 'self' 'unsafe-inline'; "
|
||||
"img-src 'self' data: https:; "
|
||||
"connect-src 'self' ws: wss:;"
|
||||
)
|
||||
|
||||
return response
|
||||
156
apps/tenant-backend/app/middleware/session_validation.py
Normal file
156
apps/tenant-backend/app/middleware/session_validation.py
Normal file
@@ -0,0 +1,156 @@
|
||||
"""
|
||||
GT 2.0 Session Validation Middleware
|
||||
|
||||
OWASP/NIST Compliant Server-Side Session Validation (Issue #264)
|
||||
- Validates session_id from JWT against server-side session state
|
||||
- Updates session activity on every authenticated request
|
||||
- Adds X-Session-Warning header when < 5 minutes remaining
|
||||
- Returns 401 with X-Session-Expired header when session is invalid
|
||||
"""
|
||||
|
||||
from fastapi import Request
|
||||
from fastapi.responses import JSONResponse
|
||||
from starlette.middleware.base import BaseHTTPMiddleware
|
||||
import httpx
|
||||
import logging
|
||||
import jwt
|
||||
from app.core.config import get_settings
|
||||
|
||||
settings = get_settings()
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class SessionValidationMiddleware(BaseHTTPMiddleware):
|
||||
"""
|
||||
Middleware to validate server-side sessions on every authenticated request.
|
||||
|
||||
The server-side session is the authoritative source of truth for session validity.
|
||||
JWT expiration is secondary - the session can expire before the JWT does.
|
||||
|
||||
Response Headers:
|
||||
- X-Session-Warning: <seconds> - Added when session is about to expire
|
||||
- X-Session-Expired: idle|absolute - Added on 401 when session expired
|
||||
"""
|
||||
|
||||
def __init__(self, app, control_panel_url: str = None, service_auth_token: str = None):
|
||||
super().__init__(app)
|
||||
self.control_panel_url = control_panel_url or settings.control_panel_url or "http://control-panel-backend:8001"
|
||||
self.service_auth_token = service_auth_token or settings.service_auth_token or "internal-service-token"
|
||||
|
||||
async def dispatch(self, request: Request, call_next):
|
||||
"""Process request and validate server-side session"""
|
||||
|
||||
# Skip session validation for public endpoints
|
||||
skip_paths = [
|
||||
"/health",
|
||||
"/api/v1/auth/login",
|
||||
"/api/v1/auth/register",
|
||||
"/api/v1/auth/refresh",
|
||||
"/api/v1/auth/password-reset",
|
||||
"/api/v1/public",
|
||||
"/docs",
|
||||
"/openapi.json",
|
||||
"/redoc"
|
||||
]
|
||||
|
||||
if any(request.url.path.startswith(path) for path in skip_paths):
|
||||
return await call_next(request)
|
||||
|
||||
# Extract JWT from Authorization header
|
||||
auth_header = request.headers.get("Authorization")
|
||||
if not auth_header or not auth_header.startswith("Bearer "):
|
||||
return await call_next(request)
|
||||
|
||||
token = auth_header.split(" ")[1]
|
||||
|
||||
# Decode JWT to get session_id (without verification - that's done elsewhere)
|
||||
try:
|
||||
# We just need to extract the session_id claim
|
||||
# Full JWT verification happens in the auth dependency
|
||||
payload = jwt.decode(token, options={"verify_signature": False})
|
||||
session_id = payload.get("session_id")
|
||||
except jwt.InvalidTokenError:
|
||||
# Let the normal auth flow handle invalid tokens
|
||||
return await call_next(request)
|
||||
|
||||
# If no session_id in JWT, skip session validation (backwards compatibility)
|
||||
# This allows old tokens without session_id to work until they expire
|
||||
if not session_id:
|
||||
logger.debug("No session_id in JWT, skipping server-side validation")
|
||||
return await call_next(request)
|
||||
|
||||
# Validate session with control panel
|
||||
validation_result = await self._validate_session(session_id)
|
||||
|
||||
if validation_result is None:
|
||||
# Control panel unavailable - FAIL CLOSED for security (OWASP best practice)
|
||||
# Reject the request rather than allowing potentially expired sessions through
|
||||
logger.error("Session validation failed - control panel unavailable, rejecting request")
|
||||
return JSONResponse(
|
||||
status_code=503,
|
||||
content={
|
||||
"detail": "Session validation service unavailable",
|
||||
"code": "SESSION_VALIDATION_UNAVAILABLE"
|
||||
},
|
||||
headers={"X-Session-Warning": "validation-unavailable"}
|
||||
)
|
||||
|
||||
if not validation_result.get("is_valid", False):
|
||||
# Session is invalid - return 401 with expiry reason
|
||||
# Ensure expiry_reason is never None (causes header encode error)
|
||||
expiry_reason = validation_result.get("expiry_reason") or "unknown"
|
||||
logger.info(f"Session expired: {expiry_reason}")
|
||||
|
||||
return JSONResponse(
|
||||
status_code=401,
|
||||
content={
|
||||
"detail": f"Session expired ({expiry_reason})",
|
||||
"code": "SESSION_EXPIRED",
|
||||
"expiry_reason": expiry_reason
|
||||
},
|
||||
headers={"X-Session-Expired": expiry_reason}
|
||||
)
|
||||
|
||||
# Session is valid - process request
|
||||
response = await call_next(request)
|
||||
|
||||
# Add warning header if session is about to expire
|
||||
if validation_result.get("show_warning", False):
|
||||
seconds_remaining = validation_result.get("seconds_remaining", 0)
|
||||
response.headers["X-Session-Warning"] = str(seconds_remaining)
|
||||
logger.debug(f"Session warning: {seconds_remaining}s remaining")
|
||||
|
||||
return response
|
||||
|
||||
async def _validate_session(self, session_token: str) -> dict | None:
|
||||
"""
|
||||
Validate session with control panel internal API.
|
||||
|
||||
Returns:
|
||||
dict with is_valid, expiry_reason, seconds_remaining, show_warning
|
||||
or None if control panel is unavailable
|
||||
"""
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=5.0) as client:
|
||||
response = await client.post(
|
||||
f"{self.control_panel_url}/internal/sessions/validate",
|
||||
json={"session_token": session_token},
|
||||
headers={
|
||||
"X-Service-Auth": self.service_auth_token,
|
||||
"X-Service-Name": "tenant-backend"
|
||||
}
|
||||
)
|
||||
|
||||
if response.status_code == 200:
|
||||
return response.json()
|
||||
else:
|
||||
logger.error(f"Session validation failed: {response.status_code} - {response.text}")
|
||||
return None
|
||||
|
||||
except httpx.RequestError as e:
|
||||
logger.error(f"Session validation request failed: {e}")
|
||||
return None
|
||||
except Exception as e:
|
||||
logger.error(f"Unexpected error during session validation: {e}")
|
||||
return None
|
||||
48
apps/tenant-backend/app/middleware/tenant_isolation.py
Normal file
48
apps/tenant-backend/app/middleware/tenant_isolation.py
Normal file
@@ -0,0 +1,48 @@
|
||||
"""
|
||||
Tenant Isolation Middleware for GT 2.0
|
||||
|
||||
Ensures perfect tenant isolation for all requests.
|
||||
"""
|
||||
|
||||
from fastapi import Request, Response
|
||||
from starlette.middleware.base import BaseHTTPMiddleware
|
||||
import logging
|
||||
|
||||
from app.core.config import get_settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
settings = get_settings()
|
||||
|
||||
|
||||
class TenantIsolationMiddleware(BaseHTTPMiddleware):
|
||||
"""Middleware to enforce tenant isolation boundaries"""
|
||||
|
||||
async def dispatch(self, request: Request, call_next):
|
||||
# Add tenant context to request
|
||||
request.state.tenant_id = settings.tenant_id
|
||||
request.state.tenant_domain = settings.tenant_domain
|
||||
|
||||
# Validate tenant isolation
|
||||
await self._validate_tenant_isolation(request)
|
||||
|
||||
response = await call_next(request)
|
||||
|
||||
# Add tenant headers to response
|
||||
response.headers["X-Tenant-Domain"] = settings.tenant_domain
|
||||
response.headers["X-Tenant-Isolated"] = "true"
|
||||
|
||||
return response
|
||||
|
||||
async def _validate_tenant_isolation(self, request: Request):
|
||||
"""Validate that all operations are tenant-isolated"""
|
||||
# This is where we would add tenant boundary validation
|
||||
# For now, we just log the tenant context
|
||||
logger.debug(
|
||||
"Tenant isolation validated",
|
||||
extra={
|
||||
"tenant_id": settings.tenant_id,
|
||||
"tenant_domain": settings.tenant_domain,
|
||||
"path": request.url.path,
|
||||
"method": request.method,
|
||||
}
|
||||
)
|
||||
Reference in New Issue
Block a user