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>
478 lines
14 KiB
Python
478 lines
14 KiB
Python
"""
|
|
Tenant management API endpoints - CB-REST Standard Implementation
|
|
|
|
This is the updated version using the GT 2.0 Capability-Based REST standard
|
|
"""
|
|
from datetime import datetime
|
|
from typing import List, Optional, Dict, Any
|
|
from fastapi import APIRouter, Depends, Query, BackgroundTasks, Request, status
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
from sqlalchemy import select, func, or_
|
|
from pydantic import BaseModel, Field, validator
|
|
import logging
|
|
import uuid
|
|
|
|
from app.core.database import get_db
|
|
from app.core.api_standards import (
|
|
format_response,
|
|
format_error,
|
|
require_capability,
|
|
ErrorCode,
|
|
APIError,
|
|
CapabilityToken
|
|
)
|
|
from app.models.tenant import Tenant
|
|
from app.models.user import User
|
|
|
|
logger = logging.getLogger(__name__)
|
|
router = APIRouter(prefix="/tenants", tags=["tenants"])
|
|
|
|
|
|
# Pydantic models remain the same
|
|
class TenantCreate(BaseModel):
|
|
name: str = Field(..., min_length=1, max_length=100)
|
|
domain: str = Field(..., min_length=1, max_length=50)
|
|
template: str = Field(default="standard")
|
|
max_users: int = Field(default=100, ge=1, le=10000)
|
|
resource_limits: Optional[Dict[str, Any]] = Field(default_factory=dict)
|
|
|
|
@validator('domain')
|
|
def validate_domain(cls, v):
|
|
import re
|
|
if not re.match(r'^[a-z0-9-]+$', v):
|
|
raise ValueError('Domain must contain only lowercase letters, numbers, and hyphens')
|
|
return v
|
|
|
|
|
|
class TenantUpdate(BaseModel):
|
|
name: Optional[str] = Field(None, min_length=1, max_length=100)
|
|
max_users: Optional[int] = Field(None, ge=1, le=10000)
|
|
resource_limits: Optional[Dict[str, Any]] = None
|
|
status: Optional[str] = Field(None, pattern="^(active|suspended|pending|archived)$")
|
|
|
|
|
|
class TenantResponse(BaseModel):
|
|
id: int
|
|
uuid: str
|
|
name: str
|
|
domain: str
|
|
template: str
|
|
status: str
|
|
max_users: int
|
|
resource_limits: Dict[str, Any]
|
|
namespace: str
|
|
created_at: datetime
|
|
updated_at: datetime
|
|
user_count: Optional[int] = 0
|
|
|
|
class Config:
|
|
from_attributes = True
|
|
|
|
|
|
@router.get("/")
|
|
async def list_tenants(
|
|
request: Request,
|
|
page: int = Query(1, ge=1),
|
|
limit: int = Query(20, ge=1, le=100),
|
|
search: Optional[str] = None,
|
|
status: Optional[str] = None,
|
|
db: AsyncSession = Depends(get_db),
|
|
capability: CapabilityToken = Depends(require_capability("tenant", "*", "read"))
|
|
):
|
|
"""
|
|
List all tenants with pagination and filtering
|
|
|
|
CB-REST: Returns standardized response with capability audit trail
|
|
"""
|
|
try:
|
|
# Build query
|
|
query = select(Tenant)
|
|
|
|
# Apply filters
|
|
if search:
|
|
query = query.where(
|
|
or_(
|
|
Tenant.name.ilike(f"%{search}%"),
|
|
Tenant.domain.ilike(f"%{search}%")
|
|
)
|
|
)
|
|
|
|
if status:
|
|
query = query.where(Tenant.status == status)
|
|
|
|
# Get total count
|
|
count_query = select(func.count()).select_from(query.subquery())
|
|
total_result = await db.execute(count_query)
|
|
total = total_result.scalar()
|
|
|
|
# Apply pagination
|
|
query = query.offset((page - 1) * limit).limit(limit)
|
|
|
|
# Execute query
|
|
result = await db.execute(query)
|
|
tenants = result.scalars().all()
|
|
|
|
# Format response data
|
|
response_data = {
|
|
"tenants": [TenantResponse.from_orm(t).dict() for t in tenants],
|
|
"total": total,
|
|
"page": page,
|
|
"limit": limit
|
|
}
|
|
|
|
# Return CB-REST formatted response
|
|
return format_response(
|
|
data=response_data,
|
|
capability_used=f"tenant:*:read",
|
|
request_id=request.state.request_id
|
|
)
|
|
|
|
except Exception as e:
|
|
logger.error(f"Failed to list tenants: {e}")
|
|
raise APIError(
|
|
code=ErrorCode.SYSTEM_ERROR,
|
|
message="Failed to retrieve tenants",
|
|
status_code=500,
|
|
details={"error": str(e)}
|
|
)
|
|
|
|
|
|
@router.post("/", status_code=status.HTTP_201_CREATED)
|
|
async def create_tenant(
|
|
request: Request,
|
|
tenant_data: TenantCreate,
|
|
background_tasks: BackgroundTasks,
|
|
db: AsyncSession = Depends(get_db),
|
|
capability: CapabilityToken = Depends(require_capability("tenant", "*", "create"))
|
|
):
|
|
"""
|
|
Create a new tenant
|
|
|
|
CB-REST: Validates capability and returns standardized response
|
|
"""
|
|
try:
|
|
# Check if domain already exists
|
|
existing = await db.execute(
|
|
select(Tenant).where(Tenant.domain == tenant_data.domain)
|
|
)
|
|
if existing.scalar_one_or_none():
|
|
raise APIError(
|
|
code=ErrorCode.RESOURCE_ALREADY_EXISTS,
|
|
message=f"Tenant with domain '{tenant_data.domain}' already exists",
|
|
status_code=409
|
|
)
|
|
|
|
# Create tenant
|
|
tenant = Tenant(
|
|
uuid=str(uuid.uuid4()),
|
|
name=tenant_data.name,
|
|
domain=tenant_data.domain,
|
|
template=tenant_data.template,
|
|
max_users=tenant_data.max_users,
|
|
resource_limits=tenant_data.resource_limits,
|
|
namespace=f"tenant-{tenant_data.domain}",
|
|
status="pending",
|
|
created_by=capability.sub
|
|
)
|
|
|
|
db.add(tenant)
|
|
await db.commit()
|
|
await db.refresh(tenant)
|
|
|
|
# Schedule deployment in background
|
|
background_tasks.add_task(deploy_tenant, tenant.id)
|
|
|
|
# Format response
|
|
return format_response(
|
|
data={
|
|
"tenant_id": tenant.id,
|
|
"uuid": tenant.uuid,
|
|
"status": tenant.status,
|
|
"namespace": tenant.namespace
|
|
},
|
|
capability_used=f"tenant:*:create",
|
|
request_id=request.state.request_id
|
|
)
|
|
|
|
except APIError:
|
|
raise
|
|
except Exception as e:
|
|
logger.error(f"Failed to create tenant: {e}")
|
|
raise APIError(
|
|
code=ErrorCode.SYSTEM_ERROR,
|
|
message="Failed to create tenant",
|
|
status_code=500,
|
|
details={"error": str(e)}
|
|
)
|
|
|
|
|
|
@router.get("/{tenant_id}")
|
|
async def get_tenant(
|
|
request: Request,
|
|
tenant_id: int,
|
|
db: AsyncSession = Depends(get_db),
|
|
capability: CapabilityToken = Depends(require_capability("tenant", "{tenant_id}", "read"))
|
|
):
|
|
"""
|
|
Get a specific tenant by ID
|
|
|
|
CB-REST: Enforces tenant-specific capability
|
|
"""
|
|
try:
|
|
result = await db.execute(
|
|
select(Tenant).where(Tenant.id == tenant_id)
|
|
)
|
|
tenant = result.scalar_one_or_none()
|
|
|
|
if not tenant:
|
|
raise APIError(
|
|
code=ErrorCode.RESOURCE_NOT_FOUND,
|
|
message=f"Tenant {tenant_id} not found",
|
|
status_code=404
|
|
)
|
|
|
|
# Get user count
|
|
user_count_result = await db.execute(
|
|
select(func.count()).select_from(User).where(User.tenant_id == tenant_id)
|
|
)
|
|
user_count = user_count_result.scalar()
|
|
|
|
# Format response
|
|
tenant_data = TenantResponse.from_orm(tenant).dict()
|
|
tenant_data["user_count"] = user_count
|
|
|
|
return format_response(
|
|
data=tenant_data,
|
|
capability_used=f"tenant:{tenant_id}:read",
|
|
request_id=request.state.request_id
|
|
)
|
|
|
|
except APIError:
|
|
raise
|
|
except Exception as e:
|
|
logger.error(f"Failed to get tenant {tenant_id}: {e}")
|
|
raise APIError(
|
|
code=ErrorCode.SYSTEM_ERROR,
|
|
message="Failed to retrieve tenant",
|
|
status_code=500,
|
|
details={"error": str(e)}
|
|
)
|
|
|
|
|
|
@router.put("/{tenant_id}")
|
|
async def update_tenant(
|
|
request: Request,
|
|
tenant_id: int,
|
|
updates: TenantUpdate,
|
|
db: AsyncSession = Depends(get_db),
|
|
capability: CapabilityToken = Depends(require_capability("tenant", "{tenant_id}", "write"))
|
|
):
|
|
"""
|
|
Update a tenant
|
|
|
|
CB-REST: Requires write capability for specific tenant
|
|
"""
|
|
try:
|
|
result = await db.execute(
|
|
select(Tenant).where(Tenant.id == tenant_id)
|
|
)
|
|
tenant = result.scalar_one_or_none()
|
|
|
|
if not tenant:
|
|
raise APIError(
|
|
code=ErrorCode.RESOURCE_NOT_FOUND,
|
|
message=f"Tenant {tenant_id} not found",
|
|
status_code=404
|
|
)
|
|
|
|
# Track updated fields
|
|
updated_fields = []
|
|
|
|
# Apply updates
|
|
for field, value in updates.dict(exclude_unset=True).items():
|
|
if hasattr(tenant, field):
|
|
setattr(tenant, field, value)
|
|
updated_fields.append(field)
|
|
|
|
tenant.updated_at = datetime.utcnow()
|
|
tenant.updated_by = capability.sub
|
|
|
|
await db.commit()
|
|
await db.refresh(tenant)
|
|
|
|
return format_response(
|
|
data={
|
|
"updated_fields": updated_fields,
|
|
"status": tenant.status
|
|
},
|
|
capability_used=f"tenant:{tenant_id}:write",
|
|
request_id=request.state.request_id
|
|
)
|
|
|
|
except APIError:
|
|
raise
|
|
except Exception as e:
|
|
logger.error(f"Failed to update tenant {tenant_id}: {e}")
|
|
raise APIError(
|
|
code=ErrorCode.SYSTEM_ERROR,
|
|
message="Failed to update tenant",
|
|
status_code=500,
|
|
details={"error": str(e)}
|
|
)
|
|
|
|
|
|
@router.delete("/{tenant_id}", status_code=status.HTTP_204_NO_CONTENT)
|
|
async def delete_tenant(
|
|
request: Request,
|
|
tenant_id: int,
|
|
db: AsyncSession = Depends(get_db),
|
|
capability: CapabilityToken = Depends(require_capability("tenant", "{tenant_id}", "delete"))
|
|
):
|
|
"""
|
|
Delete (archive) a tenant
|
|
|
|
CB-REST: Requires delete capability
|
|
"""
|
|
try:
|
|
result = await db.execute(
|
|
select(Tenant).where(Tenant.id == tenant_id)
|
|
)
|
|
tenant = result.scalar_one_or_none()
|
|
|
|
if not tenant:
|
|
raise APIError(
|
|
code=ErrorCode.RESOURCE_NOT_FOUND,
|
|
message=f"Tenant {tenant_id} not found",
|
|
status_code=404
|
|
)
|
|
|
|
# Soft delete - set status to archived
|
|
tenant.status = "archived"
|
|
tenant.updated_at = datetime.utcnow()
|
|
tenant.updated_by = capability.sub
|
|
|
|
await db.commit()
|
|
|
|
# No content response for successful deletion
|
|
return None
|
|
|
|
except APIError:
|
|
raise
|
|
except Exception as e:
|
|
logger.error(f"Failed to delete tenant {tenant_id}: {e}")
|
|
raise APIError(
|
|
code=ErrorCode.SYSTEM_ERROR,
|
|
message="Failed to delete tenant",
|
|
status_code=500,
|
|
details={"error": str(e)}
|
|
)
|
|
|
|
|
|
@router.post("/bulk")
|
|
async def bulk_tenant_operations(
|
|
request: Request,
|
|
operations: List[Dict[str, Any]],
|
|
transaction: bool = Query(True, description="Execute all operations in a transaction"),
|
|
db: AsyncSession = Depends(get_db),
|
|
capability: CapabilityToken = Depends(require_capability("tenant", "*", "admin"))
|
|
):
|
|
"""
|
|
Perform bulk operations on tenants
|
|
|
|
CB-REST: Admin capability required for bulk operations
|
|
"""
|
|
results = []
|
|
|
|
try:
|
|
if transaction:
|
|
# Start transaction
|
|
async with db.begin():
|
|
for op in operations:
|
|
result = await execute_tenant_operation(db, op, capability.sub)
|
|
results.append(result)
|
|
else:
|
|
# Execute independently
|
|
for op in operations:
|
|
try:
|
|
result = await execute_tenant_operation(db, op, capability.sub)
|
|
results.append(result)
|
|
except Exception as e:
|
|
results.append({
|
|
"operation_id": op.get("id", str(uuid.uuid4())),
|
|
"action": op.get("action"),
|
|
"success": False,
|
|
"error": str(e)
|
|
})
|
|
|
|
# Format bulk response
|
|
succeeded = sum(1 for r in results if r.get("success"))
|
|
failed = len(results) - succeeded
|
|
|
|
return format_response(
|
|
data={
|
|
"operations": results,
|
|
"transaction": transaction,
|
|
"total": len(results),
|
|
"succeeded": succeeded,
|
|
"failed": failed
|
|
},
|
|
capability_used="tenant:*:admin",
|
|
request_id=request.state.request_id
|
|
)
|
|
|
|
except Exception as e:
|
|
logger.error(f"Bulk operation failed: {e}")
|
|
raise APIError(
|
|
code=ErrorCode.SYSTEM_ERROR,
|
|
message="Bulk operation failed",
|
|
status_code=500,
|
|
details={"error": str(e)}
|
|
)
|
|
|
|
|
|
# Helper functions
|
|
async def deploy_tenant(tenant_id: int):
|
|
"""Background task to deploy tenant infrastructure"""
|
|
logger.info(f"Deploying tenant {tenant_id}")
|
|
|
|
try:
|
|
# For now, create the file-based tenant structure
|
|
# In K3s deployment, this will create Kubernetes resources
|
|
from app.services.tenant_provisioning import create_tenant_filesystem
|
|
|
|
# Create tenant filesystem structure
|
|
await create_tenant_filesystem(tenant_id)
|
|
|
|
# Initialize tenant database
|
|
from app.services.tenant_provisioning import init_tenant_database
|
|
await init_tenant_database(tenant_id)
|
|
|
|
logger.info(f"Tenant {tenant_id} deployment completed successfully")
|
|
return {"success": True, "message": f"Tenant {tenant_id} deployed"}
|
|
|
|
except Exception as e:
|
|
logger.error(f"Failed to deploy tenant {tenant_id}: {e}")
|
|
return {"success": False, "error": str(e)}
|
|
|
|
|
|
async def execute_tenant_operation(db: AsyncSession, operation: Dict[str, Any], user: str) -> Dict[str, Any]:
|
|
"""Execute a single tenant operation"""
|
|
action = operation.get("action")
|
|
|
|
if action == "create":
|
|
# Create tenant logic
|
|
pass
|
|
elif action == "update":
|
|
# Update tenant logic
|
|
pass
|
|
elif action == "delete":
|
|
# Delete tenant logic
|
|
pass
|
|
else:
|
|
raise ValueError(f"Unknown action: {action}")
|
|
|
|
return {
|
|
"operation_id": operation.get("id", str(uuid.uuid4())),
|
|
"action": action,
|
|
"success": True
|
|
} |