REFACTOR ALL APIs

This commit is contained in:
Urtzi Alfaro
2025-10-06 15:27:01 +02:00
parent dc8221bd2f
commit 38fb98bc27
166 changed files with 18454 additions and 13605 deletions

View File

@@ -108,6 +108,11 @@ class AuthMiddleware(BaseHTTPMiddleware):
content={"detail": f"Access denied to tenant {tenant_id}"}
)
# Get tenant subscription tier and inject into user context
subscription_tier = await self._get_tenant_subscription_tier(tenant_id, request)
if subscription_tier:
user_context["subscription_tier"] = subscription_tier
# Set tenant context in request state
request.state.tenant_id = tenant_id
request.state.tenant_verified = True
@@ -115,6 +120,7 @@ class AuthMiddleware(BaseHTTPMiddleware):
logger.debug(f"Tenant access verified",
user_id=user_context["user_id"],
tenant_id=tenant_id,
subscription_tier=subscription_tier,
path=request.url.path)
# ✅ STEP 5: Inject user context into request
@@ -386,7 +392,72 @@ class AuthMiddleware(BaseHTTPMiddleware):
b"x-tenant-id", tenant_id.encode()
))
# Add subscription tier if available
subscription_tier = user_context.get("subscription_tier", "")
if subscription_tier:
request.headers.__dict__["_list"].append((
b"x-subscription-tier", subscription_tier.encode()
))
# Add gateway identification
request.headers.__dict__["_list"].append((
b"x-forwarded-by", b"bakery-gateway"
))
))
async def _get_tenant_subscription_tier(self, tenant_id: str, request: Request) -> Optional[str]:
"""
Get tenant subscription tier from tenant service
Args:
tenant_id: Tenant ID
request: FastAPI request for headers
Returns:
Subscription tier string or None
"""
try:
# Check cache first
if self.redis_client:
cache_key = f"tenant:tier:{tenant_id}"
try:
cached_tier = await self.redis_client.get(cache_key)
if cached_tier:
if isinstance(cached_tier, bytes):
cached_tier = cached_tier.decode()
logger.debug("Subscription tier from cache", tenant_id=tenant_id, tier=cached_tier)
return cached_tier
except Exception as e:
logger.warning(f"Cache lookup failed for tenant tier: {e}")
# Get from tenant service
async with httpx.AsyncClient(timeout=5.0) as client:
headers = {"Authorization": request.headers.get("Authorization", "")}
response = await client.get(
f"{settings.TENANT_SERVICE_URL}/api/v1/tenants/{tenant_id}",
headers=headers
)
if response.status_code == 200:
tenant_data = response.json()
subscription_tier = tenant_data.get("subscription_tier", "basic")
# Cache for 5 minutes
if self.redis_client:
try:
await self.redis_client.setex(
f"tenant:tier:{tenant_id}",
300, # 5 minutes
subscription_tier
)
except Exception as e:
logger.warning(f"Failed to cache tenant tier: {e}")
logger.debug("Subscription tier from service", tenant_id=tenant_id, tier=subscription_tier)
return subscription_tier
else:
logger.warning(f"Failed to get tenant subscription tier: {response.status_code}")
return "basic" # Default to basic
except Exception as e:
logger.error(f"Error getting tenant subscription tier: {e}")
return "basic" # Default to basic on error

View File

@@ -1,5 +1,6 @@
"""
Subscription Middleware - Enforces subscription limits and feature access
Updated to support standardized URL structure with tier-based access control
"""
import re
@@ -9,7 +10,7 @@ from fastapi import Request, Response, HTTPException
from fastapi.responses import JSONResponse
from starlette.middleware.base import BaseHTTPMiddleware
import httpx
from typing import Dict, Any, Optional
from typing import Dict, Any, Optional, List
import asyncio
from app.core.config import settings
@@ -18,48 +19,71 @@ logger = structlog.get_logger()
class SubscriptionMiddleware(BaseHTTPMiddleware):
"""Middleware to enforce subscription-based access control"""
"""
Middleware to enforce subscription-based access control
Supports standardized URL structure:
- Base routes (/api/v1/tenants/{tenant_id}/{service}/{resource}): ALL tiers
- Dashboard routes (/api/v1/tenants/{tenant_id}/{service}/dashboard/*): ALL tiers
- Analytics routes (/api/v1/tenants/{tenant_id}/{service}/analytics/*): PROFESSIONAL+
- Operations routes (/api/v1/tenants/{tenant_id}/{service}/operations/*): ALL tiers (role-based)
"""
def __init__(self, app, tenant_service_url: str):
super().__init__(app)
self.tenant_service_url = tenant_service_url.rstrip('/')
# Define route patterns that require subscription validation
# Using new standardized URL structure
self.protected_routes = {
# Analytics routes - require different levels based on actual app routes
r'/api/v1/tenants/[^/]+/analytics/.*': {
# ===== ANALYTICS ROUTES - PROFESSIONAL/ENTERPRISE ONLY =====
# Any service analytics endpoint
r'^/api/v1/tenants/[^/]+/[^/]+/analytics/.*': {
'feature': 'analytics',
'minimum_level': 'basic' # Changed to basic to allow all tiers access to analytics
'minimum_tier': 'professional',
'allowed_tiers': ['professional', 'enterprise'],
'description': 'Analytics features (Professional/Enterprise only)'
},
r'/api/v1/tenants/[^/]+/forecasts/.*': {
'feature': 'analytics',
'minimum_level': 'basic' # Changed to basic to allow all tiers access to forecasting
# ===== TRAINING SERVICE - ALL TIERS =====
r'^/api/v1/tenants/[^/]+/training/.*': {
'feature': 'ml_training',
'minimum_tier': 'basic',
'allowed_tiers': ['basic', 'professional', 'enterprise'],
'description': 'Machine learning model training (Available for all tiers)'
},
r'/api/v1/tenants/[^/]+/predictions/.*': {
'feature': 'analytics',
'minimum_level': 'basic' # Changed to basic to allow all tiers access to predictions
# ===== ADVANCED FEATURES - PROFESSIONAL/ENTERPRISE =====
# Advanced reporting and exports
r'^/api/v1/tenants/[^/]+/[^/]+/export/advanced.*': {
'feature': 'advanced_exports',
'minimum_tier': 'professional',
'allowed_tiers': ['professional', 'enterprise'],
'description': 'Advanced export formats (Professional/Enterprise only)'
},
# Training and AI models - Now available to all tiers
r'/api/v1/tenants/[^/]+/training/.*': {
'feature': 'analytics',
'minimum_level': 'basic' # Changed to basic to allow all tiers access to training
# Bulk operations
r'^/api/v1/tenants/[^/]+/[^/]+/bulk/.*': {
'feature': 'bulk_operations',
'minimum_tier': 'professional',
'allowed_tiers': ['professional', 'enterprise'],
'description': 'Bulk operations (Professional/Enterprise only)'
},
r'/api/v1/tenants/[^/]+/models/.*': {
'feature': 'analytics',
'minimum_level': 'basic' # Changed to basic to allow all tiers access to models
},
# Advanced production features - Professional+
r'/api/v1/tenants/[^/]+/production/optimization/.*': {
'feature': 'analytics',
'minimum_level': 'basic'
},
# Enterprise-only features
r'/api/v1/tenants/[^/]+/statistics.*': {
'feature': 'analytics',
'minimum_level': 'basic' # Advanced stats for Enterprise only
}
}
# Routes that are explicitly allowed for all tiers (no check needed)
self.public_tier_routes = [
# Base CRUD operations - ALL TIERS
r'^/api/v1/tenants/[^/]+/[^/]+/(?!analytics|export/advanced|bulk)[^/]+/?$',
r'^/api/v1/tenants/[^/]+/[^/]+/(?!analytics|export/advanced|bulk)[^/]+/[^/]+/?$',
# Dashboard routes - ALL TIERS
r'^/api/v1/tenants/[^/]+/[^/]+/dashboard/.*',
# Operations routes - ALL TIERS (role-based control applies)
r'^/api/v1/tenants/[^/]+/[^/]+/operations/.*',
]
async def dispatch(self, request: Request, call_next):
"""Process the request and check subscription requirements"""
@@ -67,6 +91,10 @@ class SubscriptionMiddleware(BaseHTTPMiddleware):
if self._should_skip_subscription_check(request):
return await call_next(request)
# Check if route is explicitly allowed for all tiers
if self._is_public_tier_route(request.url.path):
return await call_next(request)
# Check if route requires subscription validation
subscription_requirement = self._get_subscription_requirement(request.url.path)
if not subscription_requirement:
@@ -84,25 +112,28 @@ class SubscriptionMiddleware(BaseHTTPMiddleware):
}
)
# Validate subscription
validation_result = await self._validate_subscription(
# Validate subscription with new tier-based system
validation_result = await self._validate_subscription_tier(
request,
tenant_id,
subscription_requirement['feature'],
subscription_requirement['minimum_level']
subscription_requirement.get('feature'),
subscription_requirement.get('minimum_tier'),
subscription_requirement.get('allowed_tiers', [])
)
if not validation_result['allowed']:
return JSONResponse(
status_code=403,
status_code=402, # Payment Required for tier limitations
content={
"error": "subscription_required",
"error": "subscription_tier_insufficient",
"message": validation_result['message'],
"code": "SUBSCRIPTION_UPGRADE_REQUIRED",
"details": {
"required_feature": subscription_requirement['feature'],
"required_level": subscription_requirement['minimum_level'],
"current_plan": validation_result.get('current_plan', 'unknown'),
"required_feature": subscription_requirement.get('feature'),
"minimum_tier": subscription_requirement.get('minimum_tier'),
"allowed_tiers": subscription_requirement.get('allowed_tiers', []),
"current_tier": validation_result.get('current_tier', 'unknown'),
"description": subscription_requirement.get('description', ''),
"upgrade_url": "/app/settings/profile"
}
}
@@ -112,6 +143,22 @@ class SubscriptionMiddleware(BaseHTTPMiddleware):
response = await call_next(request)
return response
def _is_public_tier_route(self, path: str) -> bool:
"""
Check if route is explicitly allowed for all subscription tiers
Args:
path: Request path
Returns:
True if route is allowed for all tiers
"""
for pattern in self.public_tier_routes:
if re.match(pattern, path):
logger.debug("Route allowed for all tiers", path=path, pattern=pattern)
return True
return False
def _should_skip_subscription_check(self, request: Request) -> bool:
"""Check if subscription validation should be skipped"""
path = request.url.path
@@ -163,20 +210,33 @@ class SubscriptionMiddleware(BaseHTTPMiddleware):
return None
async def _validate_subscription(
async def _validate_subscription_tier(
self,
request: Request,
tenant_id: str,
feature: str,
minimum_level: str
feature: Optional[str],
minimum_tier: str,
allowed_tiers: List[str]
) -> Dict[str, Any]:
"""Validate subscription feature access using the same pattern as other gateway services"""
"""
Validate subscription tier access using tenant service
Args:
request: FastAPI request
tenant_id: Tenant ID
feature: Feature name (optional, for additional checks)
minimum_tier: Minimum required subscription tier
allowed_tiers: List of allowed subscription tiers
Returns:
Dict with 'allowed' boolean and additional metadata
"""
try:
# Use the same authentication pattern as gateway routes
headers = dict(request.headers)
headers.pop("host", None)
# Add user context headers if available (same as _proxy_request)
# Add user context headers if available
if hasattr(request.state, 'user') and request.state.user:
user = request.state.user
headers["x-user-id"] = str(user.get('user_id', ''))
@@ -185,64 +245,58 @@ class SubscriptionMiddleware(BaseHTTPMiddleware):
headers["x-user-full-name"] = str(user.get('full_name', ''))
headers["x-tenant-id"] = str(user.get('tenant_id', ''))
# Call tenant service to check subscription with gateway-appropriate timeout
# Call tenant service to get subscription tier with gateway-appropriate timeout
timeout_config = httpx.Timeout(
connect=2.0, # Connection timeout - short for gateway
read=10.0, # Read timeout
write=2.0, # Write timeout
pool=2.0 # Pool timeout
)
async with httpx.AsyncClient(timeout=timeout_config) as client:
# Check feature access
feature_response = await client.get(
f"{settings.TENANT_SERVICE_URL}/api/v1/subscriptions/{tenant_id}/features/{feature}",
# Get tenant subscription information
tenant_response = await client.get(
f"{settings.TENANT_SERVICE_URL}/api/v1/tenants/{tenant_id}",
headers=headers
)
if feature_response.status_code != 200:
if tenant_response.status_code != 200:
logger.warning(
"Failed to check feature access",
"Failed to get tenant subscription",
tenant_id=tenant_id,
feature=feature,
status_code=feature_response.status_code,
response_text=feature_response.text,
url=f"{settings.TENANT_SERVICE_URL}/api/v1/subscriptions/{tenant_id}/features/{feature}"
status_code=tenant_response.status_code,
response_text=tenant_response.text
)
# Fail open for availability (let service handle detailed check if needed)
# Fail open for availability
return {
'allowed': True,
'message': 'Access granted (validation service unavailable)',
'current_plan': 'unknown'
'current_tier': 'unknown'
}
feature_data = feature_response.json()
logger.info("Feature check response",
tenant_id=tenant_id,
feature=feature,
response=feature_data)
tenant_data = tenant_response.json()
current_tier = tenant_data.get('subscription_tier', 'basic').lower()
if not feature_data.get('has_feature'):
logger.debug("Subscription tier check",
tenant_id=tenant_id,
current_tier=current_tier,
minimum_tier=minimum_tier,
allowed_tiers=allowed_tiers)
# Check if current tier is in allowed tiers
if current_tier not in [tier.lower() for tier in allowed_tiers]:
tier_names = ', '.join(allowed_tiers)
return {
'allowed': False,
'message': f'Feature "{feature}" not available in your current plan',
'current_plan': feature_data.get('plan', 'unknown')
'message': f'This feature requires a {tier_names} subscription plan',
'current_tier': current_tier
}
# Check feature level if it's analytics
if feature == 'analytics':
feature_level = feature_data.get('feature_value', 'basic')
if not self._check_analytics_level(feature_level, minimum_level):
return {
'allowed': False,
'message': f'Analytics level "{minimum_level}" required. Current level: "{feature_level}"',
'current_plan': feature_data.get('plan', 'unknown')
}
# Tier check passed
return {
'allowed': True,
'message': 'Access granted',
'current_plan': feature_data.get('plan', 'unknown')
'current_tier': current_tier
}
except asyncio.TimeoutError:
@@ -284,15 +338,3 @@ class SubscriptionMiddleware(BaseHTTPMiddleware):
'current_plan': 'unknown'
}
def _check_analytics_level(self, current_level: str, required_level: str) -> bool:
"""Check if current analytics level meets the requirement"""
level_hierarchy = {
'basic': 1,
'advanced': 2,
'predictive': 3
}
current_rank = level_hierarchy.get(current_level, 0)
required_rank = level_hierarchy.get(required_level, 0)
return current_rank >= required_rank