Files
bakery-ia/services/tenant/app/api/tenants.py
Urtzi Alfaro 838d25394b CRITICAL: Add demo session isolation to prevent cross-session data leakage
This commit fixes a critical security issue where multiple concurrent demo
sessions would see each other's data due to sharing the same demo user IDs.

## The Problem:

When two enterprise demo sessions run simultaneously:
- Session A: user_id=Director, tenants=[parent_A, child_A1, child_A2]
- Session B: user_id=Director, tenants=[parent_B, child_B1, child_B2]

The endpoint /api/v1/tenants/user/{user_id}/tenants was querying by user_id
only, so Session A would see BOTH its own tenants AND Session B's tenants!

## The Solution:

Added demo_session_id filtering to get_user_tenants endpoint:
- For demo sessions, use get_virtual_tenants_for_session(demo_session_id)
- This filters tenants by the demo_session_id field (set during cloning)
- Each session now sees ONLY its own virtual tenants

## Implementation:

services/tenant/app/api/tenants.py (lines 180-194):
- Check if user is_demo
- Extract demo_session_id from current_user context (set by gateway)
- Call get_virtual_tenants_for_session() instead of get_user_tenants()
- This method filters by: demo_session_id + is_active + account_type

## Database Schema:

The tenants table has a demo_session_id column (indexed) that links
each virtual tenant to its specific demo session. This is set during
tenant cloning in internal_demo.py.

## Impact:

 Complete isolation between concurrent demo sessions
 Users only see their own session's data
 No performance impact (demo_session_id is indexed)
 Backward compatible (non-demo users unchanged)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2025-12-17 16:03:23 +01:00

275 lines
9.9 KiB
Python

"""
Tenant API - ATOMIC operations
Handles basic CRUD operations for tenants
"""
import structlog
from fastapi import APIRouter, Depends, HTTPException, status, Path, Query
from typing import Dict, Any, List
from uuid import UUID
from app.schemas.tenants import TenantResponse, TenantUpdate
from app.services.tenant_service import EnhancedTenantService
from shared.auth.decorators import get_current_user_dep
from shared.auth.access_control import admin_role_required
from shared.routing.route_builder import RouteBuilder
from shared.database.base import create_database_manager
from shared.monitoring.metrics import track_endpoint_metrics
logger = structlog.get_logger()
router = APIRouter()
route_builder = RouteBuilder("tenants")
# Dependency injection for enhanced tenant service
def get_enhanced_tenant_service():
try:
from app.core.config import settings
database_manager = create_database_manager(settings.DATABASE_URL, "tenant-service")
return EnhancedTenantService(database_manager)
except Exception as e:
logger.error("Failed to create enhanced tenant service", error=str(e))
raise HTTPException(status_code=500, detail="Service initialization failed")
@router.get(route_builder.build_base_route("", include_tenant_prefix=False), response_model=List[TenantResponse])
@track_endpoint_metrics("tenants_list")
async def get_active_tenants(
skip: int = Query(0, ge=0, description="Number of records to skip"),
limit: int = Query(100, ge=1, le=1000, description="Maximum number of records to return"),
current_user: Dict[str, Any] = Depends(get_current_user_dep),
tenant_service: EnhancedTenantService = Depends(get_enhanced_tenant_service)
):
"""Get all active tenants - Available to service accounts and admins"""
logger.info(
"Get active tenants request received",
skip=skip,
limit=limit,
user_id=current_user.get("user_id"),
user_type=current_user.get("type", "user"),
is_service=current_user.get("type") == "service",
role=current_user.get("role"),
service_name=current_user.get("service", "none")
)
# Allow service accounts to call this endpoint
if current_user.get("type") != "service":
# For non-service users, could add additional role checks here if needed
logger.debug(
"Non-service user requesting active tenants",
user_id=current_user.get("user_id"),
role=current_user.get("role")
)
tenants = await tenant_service.get_active_tenants(skip=skip, limit=limit)
logger.debug(
"Get active tenants successful",
count=len(tenants),
skip=skip,
limit=limit
)
return tenants
@router.get(route_builder.build_base_route("{tenant_id}", include_tenant_prefix=False), response_model=TenantResponse)
@track_endpoint_metrics("tenant_get")
async def get_tenant(
tenant_id: UUID = Path(..., description="Tenant ID"),
current_user: Dict[str, Any] = Depends(get_current_user_dep),
tenant_service: EnhancedTenantService = Depends(get_enhanced_tenant_service)
):
"""Get tenant by ID - ATOMIC operation - ENHANCED with logging"""
logger.info(
"Tenant GET request received",
tenant_id=str(tenant_id),
user_id=current_user.get("user_id"),
user_type=current_user.get("type", "user"),
is_service=current_user.get("type") == "service",
role=current_user.get("role"),
service_name=current_user.get("service", "none")
)
tenant = await tenant_service.get_tenant_by_id(str(tenant_id))
if not tenant:
logger.warning(
"Tenant not found",
tenant_id=str(tenant_id),
user_id=current_user.get("user_id")
)
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Tenant not found"
)
logger.debug(
"Tenant GET request successful",
tenant_id=str(tenant_id),
user_id=current_user.get("user_id")
)
return tenant
@router.put(route_builder.build_base_route("{tenant_id}", include_tenant_prefix=False), response_model=TenantResponse)
@admin_role_required
async def update_tenant(
update_data: TenantUpdate,
tenant_id: UUID = Path(..., description="Tenant ID"),
current_user: Dict[str, Any] = Depends(get_current_user_dep),
tenant_service: EnhancedTenantService = Depends(get_enhanced_tenant_service)
):
"""Update tenant information - ATOMIC operation (Admin+ only)"""
try:
result = await tenant_service.update_tenant(
str(tenant_id),
update_data,
current_user["user_id"]
)
return result
except HTTPException:
raise
except Exception as e:
logger.error("Tenant update failed",
tenant_id=str(tenant_id),
user_id=current_user["user_id"],
error=str(e))
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Tenant update failed"
)
@router.get(route_builder.build_base_route("user/{user_id}/tenants", include_tenant_prefix=False), response_model=List[TenantResponse])
@track_endpoint_metrics("user_tenants_list")
async def get_user_tenants(
user_id: str = Path(..., description="User ID"),
current_user: Dict[str, Any] = Depends(get_current_user_dep),
tenant_service: EnhancedTenantService = Depends(get_enhanced_tenant_service)
):
"""Get all tenants accessible by a user"""
logger.info(
"Get user tenants request received",
user_id=user_id,
requesting_user=current_user.get("user_id"),
is_demo=current_user.get("is_demo", False)
)
# Allow demo users to access tenant information for demo-user
is_demo_user = current_user.get("is_demo", False)
is_service_account = current_user.get("type") == "service"
# For demo sessions, when frontend requests with "demo-user", use the actual demo owner ID
actual_user_id = user_id
if is_demo_user and user_id == "demo-user":
actual_user_id = current_user.get("user_id")
logger.info(
"Demo session: mapping demo-user to actual owner",
requested_user_id=user_id,
actual_user_id=actual_user_id
)
if current_user.get("user_id") != actual_user_id and not is_service_account and not (is_demo_user and user_id == "demo-user"):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Can only access own tenants"
)
try:
# For demo sessions, use session-specific filtering to prevent cross-session data leakage
if is_demo_user:
demo_session_id = current_user.get("demo_session_id")
demo_account_type = current_user.get("demo_account_type", "professional")
if demo_session_id:
# Get only tenants for this specific demo session
tenants = await tenant_service.get_virtual_tenants_for_session(demo_session_id, demo_account_type)
logger.debug(
"Get demo session tenants successful",
user_id=user_id,
demo_session_id=demo_session_id,
tenant_count=len(tenants)
)
return tenants
else:
logger.warning(
"Demo user without session ID - falling back to regular user tenants",
user_id=actual_user_id
)
# Regular users or demo fallback: get tenants by ownership and membership
tenants = await tenant_service.get_user_tenants(actual_user_id)
logger.debug(
"Get user tenants successful",
user_id=user_id,
tenant_count=len(tenants)
)
return tenants
except HTTPException:
raise
except Exception as e:
logger.error("Get user tenants failed",
user_id=user_id,
error=str(e))
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Failed to get user tenants"
)
@router.delete(route_builder.build_base_route("{tenant_id}", include_tenant_prefix=False))
@track_endpoint_metrics("tenant_delete")
async def delete_tenant(
tenant_id: UUID = Path(..., description="Tenant ID"),
current_user: Dict[str, Any] = Depends(get_current_user_dep),
tenant_service: EnhancedTenantService = Depends(get_enhanced_tenant_service)
):
"""Delete tenant and all associated data - ATOMIC operation (Owner/Admin or System only)"""
logger.info(
"Tenant DELETE request received",
tenant_id=str(tenant_id),
user_id=current_user.get("user_id"),
user_type=current_user.get("type", "user"),
is_service=current_user.get("type") == "service",
role=current_user.get("role"),
service_name=current_user.get("service", "none")
)
try:
# Allow internal service calls to bypass admin check
skip_admin_check = current_user.get("type") == "service"
result = await tenant_service.delete_tenant(
str(tenant_id),
requesting_user_id=current_user.get("user_id"),
skip_admin_check=skip_admin_check
)
logger.info(
"Tenant DELETE request successful",
tenant_id=str(tenant_id),
user_id=current_user.get("user_id"),
deleted_items=result.get("deleted_items")
)
return {
"message": "Tenant deleted successfully",
"summary": result
}
except HTTPException:
raise
except Exception as e:
logger.error("Tenant deletion failed",
tenant_id=str(tenant_id),
user_id=current_user.get("user_id"),
error=str(e))
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Tenant deletion failed"
)