""" Subscription Tier and Role-Based Access Control Decorators Provides unified access control across all microservices """ from enum import Enum from functools import wraps from typing import List, Callable, Dict, Any, Optional from fastapi import HTTPException, status, Request, Depends import structlog from shared.auth.decorators import get_current_user_dep logger = structlog.get_logger() class SubscriptionTier(Enum): """ Subscription tier hierarchy Matches project-wide subscription plans in tenant service """ STARTER = "starter" PROFESSIONAL = "professional" ENTERPRISE = "enterprise" class UserRole(Enum): """ User role hierarchy Matches project-wide role definitions in tenant member model """ VIEWER = "viewer" MEMBER = "member" ADMIN = "admin" OWNER = "owner" # Tier hierarchy for comparison (higher number = higher tier) TIER_HIERARCHY = { SubscriptionTier.STARTER: 1, SubscriptionTier.PROFESSIONAL: 2, SubscriptionTier.ENTERPRISE: 3, } # Role hierarchy for comparison (higher number = more permissions) ROLE_HIERARCHY = { UserRole.VIEWER: 1, UserRole.MEMBER: 2, UserRole.ADMIN: 3, UserRole.OWNER: 4, } def check_tier_access(user_tier: str, required_tiers: List[str]) -> bool: """ Check if user's subscription tier meets the requirement Args: user_tier: Current user's subscription tier required_tiers: List of allowed tiers Returns: bool: True if access is allowed """ try: user_tier_enum = SubscriptionTier(user_tier.lower()) user_tier_level = TIER_HIERARCHY.get(user_tier_enum, 0) # Get minimum required tier level min_required_level = min( TIER_HIERARCHY.get(SubscriptionTier(tier.lower()), 999) for tier in required_tiers ) return user_tier_level >= min_required_level except (ValueError, KeyError) as e: logger.warning("Invalid tier comparison", user_tier=user_tier, required=required_tiers, error=str(e)) return False def check_role_access(user_role: str, required_roles: List[str]) -> bool: """ Check if user's role meets the requirement Args: user_role: Current user's role required_roles: List of allowed roles Returns: bool: True if access is allowed """ try: user_role_enum = UserRole(user_role.lower()) user_role_level = ROLE_HIERARCHY.get(user_role_enum, 0) # Get minimum required role level min_required_level = min( ROLE_HIERARCHY.get(UserRole(role.lower()), 999) for role in required_roles ) return user_role_level >= min_required_level except (ValueError, KeyError) as e: logger.warning("Invalid role comparison", user_role=user_role, required=required_roles, error=str(e)) return False def require_subscription_tier(allowed_tiers: List[str]): """ Decorator to enforce subscription tier access control Usage: @router.get("/analytics/advanced") @require_subscription_tier(['professional', 'enterprise']) async def get_advanced_analytics(...): ... Args: allowed_tiers: List of subscription tiers allowed to access this endpoint """ def decorator(func: Callable) -> Callable: @wraps(func) async def wrapper(*args, **kwargs): # Get current user from kwargs (injected by get_current_user_dep) current_user = kwargs.get('current_user') if not current_user: # Try to find in args for arg in args: if isinstance(arg, dict) and 'user_id' in arg: current_user = arg break if not current_user: logger.error("Current user not found in request context") raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail="Authentication required" ) # Get tenant's subscription tier from user context # The gateway should inject this information subscription_tier = current_user.get('subscription_tier') if not subscription_tier: logger.warning("Subscription tier not found in user context", user_id=current_user.get('user_id')) raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail="Subscription information unavailable" ) # Check tier access has_access = check_tier_access(subscription_tier, allowed_tiers) if not has_access: logger.warning( "Subscription tier access denied", user_tier=subscription_tier, required_tiers=allowed_tiers, user_id=current_user.get('user_id') ) raise HTTPException( status_code=status.HTTP_402_PAYMENT_REQUIRED, detail={ "error": "subscription_tier_insufficient", "message": f"This feature requires a {' or '.join(allowed_tiers)} subscription plan", "current_plan": subscription_tier, "required_plans": allowed_tiers, "upgrade_url": "/app/settings/profile" } ) logger.debug("Subscription tier check passed", tier=subscription_tier, required=allowed_tiers) return await func(*args, **kwargs) return wrapper return decorator def require_user_role(allowed_roles: List[str]): """ Decorator to enforce role-based access control Usage: @router.delete("/ingredients/{id}") @require_user_role(['admin', 'manager']) async def delete_ingredient(...): ... Args: allowed_roles: List of user roles allowed to access this endpoint """ def decorator(func: Callable) -> Callable: @wraps(func) async def wrapper(*args, **kwargs): # Get current user from kwargs current_user = kwargs.get('current_user') if not current_user: # Try to find in args for arg in args: if isinstance(arg, dict) and 'user_id' in arg: current_user = arg break if not current_user: logger.error("Current user not found in request context") raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail="Authentication required" ) # Get user's role user_role = current_user.get('role', 'user') # Check role access has_access = check_role_access(user_role, allowed_roles) if not has_access: logger.warning( "Role-based access denied", user_role=user_role, required_roles=allowed_roles, user_id=current_user.get('user_id') ) raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail={ "error": "insufficient_permissions", "message": f"This action requires {' or '.join(allowed_roles)} role", "current_role": user_role, "required_roles": allowed_roles } ) logger.debug("Role check passed", role=user_role, required=allowed_roles) return await func(*args, **kwargs) return wrapper return decorator def require_tier_and_role( allowed_tiers: List[str], allowed_roles: List[str] ): """ Combined decorator for both tier and role enforcement Usage: @router.post("/analytics/custom-report") @require_tier_and_role(['professional', 'enterprise'], ['admin', 'manager']) async def create_custom_report(...): ... Args: allowed_tiers: List of subscription tiers allowed allowed_roles: List of user roles allowed """ def decorator(func: Callable) -> Callable: @wraps(func) async def wrapper(*args, **kwargs): # Get current user from kwargs current_user = kwargs.get('current_user') if not current_user: # Try to find in args for arg in args: if isinstance(arg, dict) and 'user_id' in arg: current_user = arg break if not current_user: logger.error("Current user not found in request context") raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail="Authentication required" ) # Check subscription tier subscription_tier = current_user.get('subscription_tier') if subscription_tier: tier_access = check_tier_access(subscription_tier, allowed_tiers) if not tier_access: logger.warning( "Combined access control: tier check failed", user_tier=subscription_tier, required_tiers=allowed_tiers ) raise HTTPException( status_code=status.HTTP_402_PAYMENT_REQUIRED, detail={ "error": "subscription_tier_insufficient", "message": f"This feature requires a {' or '.join(allowed_tiers)} subscription plan", "current_plan": subscription_tier, "required_plans": allowed_tiers, "upgrade_url": "/app/settings/profile" } ) # Check user role user_role = current_user.get('role', 'member') role_access = check_role_access(user_role, allowed_roles) if not role_access: logger.warning( "Combined access control: role check failed", user_role=user_role, required_roles=allowed_roles ) raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail={ "error": "insufficient_permissions", "message": f"This action requires {' or '.join(allowed_roles)} role", "current_role": user_role, "required_roles": allowed_roles } ) logger.debug( "Combined access control passed", tier=subscription_tier, role=user_role, required_tiers=allowed_tiers, required_roles=allowed_roles ) return await func(*args, **kwargs) return wrapper return decorator # Convenience decorators for common patterns analytics_tier_required = require_subscription_tier(['professional', 'enterprise']) enterprise_tier_required = require_subscription_tier(['enterprise']) admin_role_required = require_user_role(['admin', 'owner']) owner_role_required = require_user_role(['owner']) def service_only_access(func: Callable) -> Callable: """ Decorator to restrict endpoint access to service-to-service calls only This decorator validates that: 1. The request has a valid service token (type='service' in JWT) 2. The token is from an authorized internal service Usage: @router.delete("/tenant/{tenant_id}") @service_only_access async def delete_tenant_data( tenant_id: str, current_user: dict = Depends(get_current_user_dep), db = Depends(get_db) ): # Service-only logic here The decorator expects current_user to be injected via get_current_user_dep dependency, which should already contain the user/service context from JWT. """ @wraps(func) async def wrapper(*args, **kwargs): # Get current user from kwargs (injected by get_current_user_dep) current_user = kwargs.get('current_user') if not current_user: # Try to find in args for arg in args: if isinstance(arg, dict) and 'user_id' in arg: current_user = arg break if not current_user: logger.error("Service-only access: current user not found in request context") raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail="Authentication required" ) # Check if this is a service token user_type = current_user.get('type', '') is_service = current_user.get('is_service', False) if user_type != 'service' and not is_service: logger.warning( "Service-only access denied: not a service token", user_id=current_user.get('user_id'), user_type=user_type, is_service=is_service ) raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail="This endpoint is only accessible to internal services" ) # Log successful service access service_name = current_user.get('service', current_user.get('user_id', 'unknown')) logger.info( "Service-only access granted", service=service_name, endpoint=func.__name__ ) return await func(*args, **kwargs) return wrapper def require_verified_subscription_tier( allowed_tiers: List[str], verify_in_database: bool = False ): """ Subscription tier enforcement with optional database verification. Args: allowed_tiers: List of allowed subscription tiers verify_in_database: If True, verify against database (for critical operations) """ def decorator(func): @wraps(func) async def wrapper(*args, **kwargs): request = kwargs.get('request') or args[0] # Get tier from gateway-injected header (from verified JWT) header_tier = request.headers.get("x-subscription-tier", "starter").lower() if header_tier not in [t.lower() for t in allowed_tiers]: raise HTTPException( status_code=status.HTTP_402_PAYMENT_REQUIRED, detail={ "error": "subscription_required", "message": f"This feature requires {', '.join(allowed_tiers)} subscription", "current_tier": header_tier, "required_tiers": allowed_tiers } ) # For critical operations, verify against database if verify_in_database: tenant_id = request.headers.get("x-tenant-id") if tenant_id: db_tier = await _verify_subscription_in_database(tenant_id) if db_tier.lower() != header_tier: logger.error( "Subscription tier mismatch detected!", header_tier=header_tier, db_tier=db_tier, tenant_id=tenant_id, user_id=request.headers.get("x-user-id") ) # Use database tier (authoritative) if db_tier.lower() not in [t.lower() for t in allowed_tiers]: raise HTTPException( status_code=status.HTTP_402_PAYMENT_REQUIRED, detail={ "error": "subscription_verification_failed", "message": "Subscription tier verification failed" } ) return await func(*args, **kwargs) return wrapper return decorator async def _verify_subscription_in_database(tenant_id: str) -> str: """ Direct database verification of subscription tier. Used for critical operations as defense-in-depth. """ from shared.clients.subscription_client import SubscriptionClient client = SubscriptionClient() subscription = await client.get_subscription(tenant_id) return subscription.get("plan", "starter")