2025-10-03 14:09:34 +02:00
|
|
|
"""
|
|
|
|
|
Demo Session Middleware
|
|
|
|
|
Handles demo account restrictions and virtual tenant injection
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
from fastapi import Request, HTTPException
|
|
|
|
|
from fastapi.responses import JSONResponse
|
|
|
|
|
from starlette.middleware.base import BaseHTTPMiddleware
|
|
|
|
|
from starlette.responses import Response
|
|
|
|
|
from typing import Optional
|
|
|
|
|
import httpx
|
|
|
|
|
import structlog
|
|
|
|
|
|
|
|
|
|
logger = structlog.get_logger()
|
|
|
|
|
|
|
|
|
|
# Demo tenant IDs (base templates)
|
|
|
|
|
DEMO_TENANT_IDS = {
|
|
|
|
|
"a1b2c3d4-e5f6-g7h8-i9j0-k1l2m3n4o5p6", # Panadería San Pablo
|
|
|
|
|
"b2c3d4e5-f6g7-h8i9-j0k1-l2m3n4o5p6q7", # Panadería La Espiga
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
# Allowed operations for demo accounts (limited write)
|
|
|
|
|
DEMO_ALLOWED_OPERATIONS = {
|
|
|
|
|
# Read operations - all allowed
|
|
|
|
|
"GET": ["*"],
|
|
|
|
|
|
|
|
|
|
# Limited write operations for realistic testing
|
|
|
|
|
"POST": [
|
|
|
|
|
"/api/pos/sales",
|
|
|
|
|
"/api/pos/sessions",
|
|
|
|
|
"/api/orders",
|
|
|
|
|
"/api/inventory/adjustments",
|
|
|
|
|
"/api/sales",
|
|
|
|
|
"/api/production/batches",
|
|
|
|
|
# Note: Forecast generation is explicitly blocked (see DEMO_BLOCKED_PATHS)
|
|
|
|
|
],
|
|
|
|
|
|
|
|
|
|
"PUT": [
|
|
|
|
|
"/api/pos/sales/*",
|
|
|
|
|
"/api/orders/*",
|
|
|
|
|
"/api/inventory/stock/*",
|
|
|
|
|
],
|
|
|
|
|
|
|
|
|
|
# Blocked operations
|
|
|
|
|
"DELETE": [], # No deletes allowed
|
|
|
|
|
"PATCH": [], # No patches allowed
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
# Explicitly blocked paths for demo accounts (even if method would be allowed)
|
|
|
|
|
# These require trained AI models which demo tenants don't have
|
|
|
|
|
DEMO_BLOCKED_PATHS = [
|
|
|
|
|
"/api/forecasts/single",
|
|
|
|
|
"/api/forecasts/multi-day",
|
|
|
|
|
"/api/forecasts/batch",
|
|
|
|
|
]
|
|
|
|
|
|
|
|
|
|
DEMO_BLOCKED_PATH_MESSAGE = {
|
|
|
|
|
"forecasts": {
|
|
|
|
|
"message": "La generación de pronósticos no está disponible para cuentas demo. "
|
|
|
|
|
"Las cuentas demo no tienen modelos de IA entrenados.",
|
|
|
|
|
"message_en": "Forecast generation is not available for demo accounts. "
|
|
|
|
|
"Demo accounts do not have trained AI models.",
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class DemoMiddleware(BaseHTTPMiddleware):
|
|
|
|
|
"""Middleware to handle demo session logic"""
|
|
|
|
|
|
|
|
|
|
def __init__(self, app, demo_session_url: str = "http://demo-session-service:8000"):
|
|
|
|
|
super().__init__(app)
|
|
|
|
|
self.demo_session_url = demo_session_url
|
|
|
|
|
|
|
|
|
|
async def dispatch(self, request: Request, call_next) -> Response:
|
|
|
|
|
"""Process request through demo middleware"""
|
|
|
|
|
|
|
|
|
|
# Skip demo middleware for demo service endpoints
|
|
|
|
|
demo_service_paths = [
|
|
|
|
|
"/api/v1/demo/accounts",
|
2025-10-07 07:15:07 +02:00
|
|
|
"/api/v1/demo/sessions",
|
2025-10-03 14:09:34 +02:00
|
|
|
"/api/v1/demo/stats",
|
2025-10-07 07:15:07 +02:00
|
|
|
"/api/v1/demo/operations",
|
2025-10-03 14:09:34 +02:00
|
|
|
]
|
|
|
|
|
|
|
|
|
|
if any(request.url.path.startswith(path) or request.url.path == path for path in demo_service_paths):
|
|
|
|
|
return await call_next(request)
|
|
|
|
|
|
|
|
|
|
# Extract session ID from header or cookie
|
|
|
|
|
session_id = (
|
|
|
|
|
request.headers.get("X-Demo-Session-Id") or
|
|
|
|
|
request.cookies.get("demo_session_id")
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
logger.info(f"🎭 DemoMiddleware - path: {request.url.path}, session_id: {session_id}")
|
|
|
|
|
|
|
|
|
|
# Extract tenant ID from request
|
|
|
|
|
tenant_id = request.headers.get("X-Tenant-Id")
|
|
|
|
|
|
|
|
|
|
# Check if this is a demo session request
|
|
|
|
|
if session_id:
|
|
|
|
|
try:
|
|
|
|
|
# Get session info from demo service
|
|
|
|
|
session_info = await self._get_session_info(session_id)
|
|
|
|
|
|
2025-10-12 18:47:33 +02:00
|
|
|
# Accept pending, ready, partial, failed (if data exists), and active (deprecated) statuses
|
|
|
|
|
# Even "failed" sessions can be usable if some services succeeded
|
|
|
|
|
valid_statuses = ["pending", "ready", "partial", "failed", "active"]
|
|
|
|
|
current_status = session_info.get("status") if session_info else None
|
|
|
|
|
|
|
|
|
|
if session_info and current_status in valid_statuses:
|
2025-10-03 14:09:34 +02:00
|
|
|
# Inject virtual tenant ID
|
|
|
|
|
request.state.tenant_id = session_info["virtual_tenant_id"]
|
|
|
|
|
request.state.is_demo_session = True
|
|
|
|
|
request.state.demo_account_type = session_info["demo_account_type"]
|
2025-10-12 18:47:33 +02:00
|
|
|
request.state.demo_session_status = current_status # Track status for monitoring
|
2025-10-03 14:09:34 +02:00
|
|
|
|
|
|
|
|
# Inject demo user context for auth middleware
|
2025-10-12 18:47:33 +02:00
|
|
|
# Map demo account type to the actual demo user IDs from seed_demo_users.py
|
|
|
|
|
DEMO_USER_IDS = {
|
|
|
|
|
"individual_bakery": "c1a2b3c4-d5e6-47a8-b9c0-d1e2f3a4b5c6", # María García López
|
|
|
|
|
"central_baker": "d2e3f4a5-b6c7-48d9-e0f1-a2b3c4d5e6f7" # Carlos Martínez Ruiz
|
|
|
|
|
}
|
|
|
|
|
demo_user_id = DEMO_USER_IDS.get(
|
|
|
|
|
session_info.get("demo_account_type", "individual_bakery"),
|
|
|
|
|
DEMO_USER_IDS["individual_bakery"]
|
|
|
|
|
)
|
|
|
|
|
|
2025-10-03 14:09:34 +02:00
|
|
|
# This allows the request to pass through AuthMiddleware
|
|
|
|
|
request.state.user = {
|
2025-10-12 18:47:33 +02:00
|
|
|
"user_id": demo_user_id, # Use actual demo user UUID
|
2025-10-03 14:09:34 +02:00
|
|
|
"email": f"demo-{session_id}@demo.local",
|
|
|
|
|
"tenant_id": session_info["virtual_tenant_id"],
|
2025-10-12 18:47:33 +02:00
|
|
|
"role": "owner", # Demo users have owner role
|
2025-10-03 14:09:34 +02:00
|
|
|
"is_demo": True,
|
2025-10-12 18:47:33 +02:00
|
|
|
"demo_session_id": session_id,
|
|
|
|
|
"demo_session_status": current_status
|
2025-10-03 14:09:34 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
# Update activity
|
|
|
|
|
await self._update_session_activity(session_id)
|
|
|
|
|
|
|
|
|
|
# Check if path is explicitly blocked
|
|
|
|
|
blocked_reason = self._check_blocked_path(request.url.path)
|
|
|
|
|
if blocked_reason:
|
|
|
|
|
return JSONResponse(
|
|
|
|
|
status_code=403,
|
|
|
|
|
content={
|
|
|
|
|
"error": "demo_restriction",
|
|
|
|
|
**blocked_reason,
|
|
|
|
|
"upgrade_url": "/pricing",
|
|
|
|
|
"session_expires_at": session_info.get("expires_at")
|
|
|
|
|
}
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
# Check if operation is allowed
|
|
|
|
|
if not self._is_operation_allowed(request.method, request.url.path):
|
|
|
|
|
return JSONResponse(
|
|
|
|
|
status_code=403,
|
|
|
|
|
content={
|
|
|
|
|
"error": "demo_restriction",
|
|
|
|
|
"message": "Esta operación no está permitida en cuentas demo. "
|
|
|
|
|
"Las sesiones demo se eliminan automáticamente después de 30 minutos. "
|
|
|
|
|
"Suscríbete para obtener acceso completo.",
|
|
|
|
|
"message_en": "This operation is not allowed in demo accounts. "
|
|
|
|
|
"Demo sessions are automatically deleted after 30 minutes. "
|
|
|
|
|
"Subscribe for full access.",
|
|
|
|
|
"upgrade_url": "/pricing",
|
|
|
|
|
"session_expires_at": session_info.get("expires_at")
|
|
|
|
|
}
|
|
|
|
|
)
|
|
|
|
|
else:
|
2025-10-12 18:47:33 +02:00
|
|
|
# Session expired, invalid, or in failed/destroyed state
|
|
|
|
|
logger.warning(f"Invalid demo session state", session_id=session_id, status=current_status)
|
2025-10-03 14:09:34 +02:00
|
|
|
return JSONResponse(
|
|
|
|
|
status_code=401,
|
|
|
|
|
content={
|
|
|
|
|
"error": "session_expired",
|
|
|
|
|
"message": "Tu sesión demo ha expirado. Crea una nueva sesión para continuar.",
|
2025-10-12 18:47:33 +02:00
|
|
|
"message_en": "Your demo session has expired. Create a new session to continue.",
|
|
|
|
|
"session_status": current_status
|
2025-10-03 14:09:34 +02:00
|
|
|
}
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
except Exception as e:
|
|
|
|
|
logger.error("Demo middleware error", error=str(e), session_id=session_id, path=request.url.path)
|
|
|
|
|
# On error, return 401 instead of continuing
|
|
|
|
|
return JSONResponse(
|
|
|
|
|
status_code=401,
|
|
|
|
|
content={
|
|
|
|
|
"error": "session_error",
|
|
|
|
|
"message": "Error validando sesión demo. Por favor, inténtalo de nuevo.",
|
|
|
|
|
"message_en": "Error validating demo session. Please try again."
|
|
|
|
|
}
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
# Check if this is a demo tenant (base template)
|
|
|
|
|
elif tenant_id in DEMO_TENANT_IDS:
|
|
|
|
|
# Direct access to demo tenant without session - block writes
|
|
|
|
|
request.state.is_demo_session = True
|
|
|
|
|
request.state.tenant_id = tenant_id
|
|
|
|
|
|
|
|
|
|
if request.method not in ["GET", "HEAD", "OPTIONS"]:
|
|
|
|
|
return JSONResponse(
|
|
|
|
|
status_code=403,
|
|
|
|
|
content={
|
|
|
|
|
"error": "demo_restriction",
|
|
|
|
|
"message": "Acceso directo al tenant demo no permitido. Crea una sesión demo.",
|
|
|
|
|
"message_en": "Direct access to demo tenant not allowed. Create a demo session."
|
|
|
|
|
}
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
# Proceed with request
|
|
|
|
|
response = await call_next(request)
|
|
|
|
|
|
|
|
|
|
# Add demo session header to response if demo session
|
|
|
|
|
if hasattr(request.state, "is_demo_session") and request.state.is_demo_session:
|
|
|
|
|
response.headers["X-Demo-Session"] = "true"
|
|
|
|
|
|
|
|
|
|
return response
|
|
|
|
|
|
|
|
|
|
async def _get_session_info(self, session_id: str) -> Optional[dict]:
|
|
|
|
|
"""Get session information from demo service"""
|
|
|
|
|
try:
|
|
|
|
|
async with httpx.AsyncClient(timeout=5.0) as client:
|
|
|
|
|
response = await client.get(
|
2025-10-07 07:15:07 +02:00
|
|
|
f"{self.demo_session_url}/api/v1/demo/sessions/{session_id}"
|
2025-10-03 14:09:34 +02:00
|
|
|
)
|
|
|
|
|
if response.status_code == 200:
|
|
|
|
|
return response.json()
|
|
|
|
|
return None
|
|
|
|
|
except Exception as e:
|
|
|
|
|
logger.error("Failed to get session info", session_id=session_id, error=str(e))
|
|
|
|
|
return None
|
|
|
|
|
|
|
|
|
|
async def _update_session_activity(self, session_id: str):
|
|
|
|
|
"""Update session activity timestamp"""
|
2025-10-07 07:15:07 +02:00
|
|
|
# Note: Activity tracking is handled by the demo service internally
|
|
|
|
|
# No explicit endpoint needed - activity is updated on session access
|
|
|
|
|
pass
|
2025-10-03 14:09:34 +02:00
|
|
|
|
|
|
|
|
def _check_blocked_path(self, path: str) -> Optional[dict]:
|
|
|
|
|
"""Check if path is explicitly blocked for demo accounts"""
|
|
|
|
|
for blocked_path in DEMO_BLOCKED_PATHS:
|
|
|
|
|
if blocked_path in path:
|
|
|
|
|
# Determine which category of blocked path
|
|
|
|
|
if "forecast" in blocked_path:
|
|
|
|
|
return DEMO_BLOCKED_PATH_MESSAGE["forecasts"]
|
|
|
|
|
# Can add more categories here in the future
|
|
|
|
|
return {
|
|
|
|
|
"message": "Esta funcionalidad no está disponible para cuentas demo.",
|
|
|
|
|
"message_en": "This functionality is not available for demo accounts."
|
|
|
|
|
}
|
|
|
|
|
return None
|
|
|
|
|
|
|
|
|
|
def _is_operation_allowed(self, method: str, path: str) -> bool:
|
|
|
|
|
"""Check if method + path combination is allowed for demo"""
|
|
|
|
|
|
|
|
|
|
allowed_paths = DEMO_ALLOWED_OPERATIONS.get(method, [])
|
|
|
|
|
|
|
|
|
|
# Check for wildcard
|
|
|
|
|
if "*" in allowed_paths:
|
|
|
|
|
return True
|
|
|
|
|
|
|
|
|
|
# Check for exact match or pattern match
|
|
|
|
|
for allowed_path in allowed_paths:
|
|
|
|
|
if allowed_path.endswith("*"):
|
|
|
|
|
# Pattern match: /api/orders/* matches /api/orders/123
|
|
|
|
|
if path.startswith(allowed_path[:-1]):
|
|
|
|
|
return True
|
|
|
|
|
elif path == allowed_path:
|
|
|
|
|
# Exact match
|
|
|
|
|
return True
|
|
|
|
|
|
|
|
|
|
return False
|