""" FastAPI Permission Dependencies for RBAC Provides FastAPI dependency functions for role-based access control and tenant-scoped access enforcement. """ import uuid from typing import Optional from fastapi import Depends, HTTPException, Request, status from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials from sqlalchemy.ext.asyncio import AsyncSession from security.rbac import RBAC, Role, Permission, RBACContext from security.tenant_scope import TenantScope, set_tenant_scope, get_tenant_scope from backend.db.session import get_db_session from backend.db.models import User, Tenant # Security scheme security = HTTPBearer(auto_error=False) async def get_current_user( request: Request, credentials: Optional[HTTPAuthorizationCredentials] = None, ) -> "AuthenticatedUser": """ Get the current authenticated user from the request. CRITICAL: This validates JWT signature FIRST (no DB), then only queries DB if the token is valid. This ensures 401 is returned BEFORE any database access for unauthenticated requests. Args: request: FastAPI request object credentials: HTTP Authorization credentials Returns: AuthenticatedUser object with user info Raises: HTTPException: If authentication fails (401 returned BEFORE DB access) """ from security.auth_middleware import decode_jwt_token, hash_api_key user = None # Try JWT token first - VALIDATE WITHOUT DB (fails fast) if credentials: token = credentials.credentials try: # Step 1: Validate JWT signature and expiration (NO DB ACCESS) payload = decode_jwt_token(token) user_id = uuid.UUID(payload["sub"]) tenant_id = uuid.UUID(payload["tenant_id"]) email = payload["email"] role = payload["role"] # Token is valid - return user without DB query for now # The tenant_id and role are embedded in the token return AuthenticatedUser( user_id=user_id, tenant_id=tenant_id, email=email, role=Role(role), is_api_client=False, ) except HTTPException: # Re-raise HTTP exceptions (401, etc) raise except Exception: # Token invalid or expired - return 401 BEFORE any DB access raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail="Not authenticated", headers={"WWW-Authenticate": "Bearer"}, ) # Try API key - this DOES require DB but check header first api_key = request.headers.get("X-API-Key") if api_key: # Need DB for API key verification - but header must exist first from sqlalchemy import select from backend.db.models import APIKey from backend.db.session import get_db_session # Get a fresh DB session for API key async for db in get_db_session(): try: key_hash = hash_api_key(api_key) query = select(APIKey).where( APIKey.key_hash == key_hash, APIKey.active == True, ) result = await db.execute(query) api_key_obj = result.scalar_one_or_none() if api_key_obj: # Update last used from datetime import datetime api_key_obj.last_used = datetime.utcnow() await db.commit() return AuthenticatedUser( user_id=api_key_obj.id, tenant_id=api_key_obj.tenant_id, email=f"api:{api_key_obj.owner}", role=Role.API_CLIENT, is_api_client=True, ) finally: break # No valid authentication provided raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail="Not authenticated", headers={"WWW-Authenticate": "Bearer"}, ) async def get_current_user_optional( request: Request, db: AsyncSession = Depends(get_db_session), ) -> Optional["AuthenticatedUser"]: """ Get the current authenticated user, optionally. Returns None if no valid authentication is provided. Args: request: FastAPI request object db: Database session Returns: AuthenticatedUser object or None """ try: return await get_current_user(request, None, db) except HTTPException: return None class AuthenticatedUser: """ Represents an authenticated user with tenant and role information. """ def __init__( self, user_id: uuid.UUID, tenant_id: uuid.UUID, email: str, role: Role, is_api_client: bool = False, ): self.user_id = user_id self.tenant_id = tenant_id self.email = email self.role = role self.is_api_client = is_api_client def to_rbac_context(self) -> RBACContext: """Convert to RBAC context for permission checks.""" return RBACContext( user_id=self.user_id, tenant_id=self.tenant_id, role=self.role, ) def has_permission(self, permission: Permission) -> bool: """Check if user has a specific permission.""" return RBAC.has_permission(self.role, permission) def __repr__(self) -> str: return f"" def require_authenticated_user( user: AuthenticatedUser = Depends(get_current_user), ) -> AuthenticatedUser: """ Require an authenticated user. Usage: @router.get("/protected") async def protected_endpoint(user: AuthenticatedUser = Depends(require_authenticated_user)): ... """ return user def require_role(required_role: Role): """ Require a specific role. Usage: @router.get("/admin/users") async def list_users(user: AuthenticatedUser = Depends(require_role(Role.ADMIN))): ... Args: required_role: The role required to access the endpoint Returns: FastAPI dependency that checks role """ async def role_checker( user: AuthenticatedUser = Depends(get_current_user), ) -> AuthenticatedUser: if user.role != required_role: # Check if user has the required role # ADMIN can do everything if required_role != Role.ADMIN and user.role == Role.ADMIN: return user raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail=f"Requires {required_role.value} role" ) return user return role_checker def require_permission(required_permission: Permission): """ Require a specific permission. Usage: @router.post("/jobs") async def create_job( user: AuthenticatedUser = Depends(require_permission(Permission.CREATE_JOB)) ): ... Args: required_permission: The permission required to access the endpoint Returns: FastAPI dependency that checks permission """ async def permission_checker( user: AuthenticatedUser = Depends(get_current_user), ) -> AuthenticatedUser: if not user.has_permission(required_permission): raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail=f"Requires {required_permission.value} permission" ) return user return permission_checker def require_tenant_access(tenant_id: uuid.UUID): """ Require access to a specific tenant. This ensures users can only access their own tenant's data. Usage: @router.get("/tenants/{tenant_id}/users") async def list_tenant_users( tenant_id: uuid.UUID, user: AuthenticatedUser = Depends(require_tenant_access(tenant_id)) ): ... Args: tenant_id: The tenant ID to check access for Returns: FastAPI dependency that checks tenant access """ async def tenant_checker( user: AuthenticatedUser = Depends(get_current_user), ) -> AuthenticatedUser: # API clients can only access their own tenant if user.is_api_client and user.tenant_id != tenant_id: raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail="Cannot access other tenants" ) # Regular users can only access their own tenant if user.tenant_id != tenant_id: # Only platform admins can access other tenants if user.role != Role.ADMIN: raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail="Cannot access other tenants" ) return user return tenant_checker class TenantContext: """ Tenant context for the current request. This ensures all database queries are scoped to the current tenant. """ def __init__( self, user: AuthenticatedUser = Depends(get_current_user), ): self.user = user self.tenant_id = user.tenant_id self.user_id = user.user_id self.role = user.role # Set tenant scope in context scope = TenantScope(tenant_id=self.tenant_id) set_tenant_scope(scope) def get_tenant_filter(self, table_class) -> dict: """ Get tenant filter for a table. Args: table_class: SQLAlchemy model class Returns: Dictionary with tenant_id filter """ return {"tenant_id": self.tenant_id} def __repr__(self) -> str: return f"" def get_tenant_context( user: AuthenticatedUser = Depends(get_current_user), ) -> TenantContext: """ Get the current tenant context. This dependency ensures all queries are scoped to the current tenant and sets up the tenant context for the request. Usage: @router.get("/jobs") async def list_jobs( ctx: TenantContext = Depends(get_tenant_context) ): # All queries will automatically be scoped to tenant ... """ return TenantContext(user) # ============================================================================= # Permission-guarded endpoints helpers # ============================================================================= # Job permissions def can_create_job(user: AuthenticatedUser = Depends(get_current_user)) -> AuthenticatedUser: """Require permission to create jobs.""" if not user.has_permission(Permission.CREATE_JOB): raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail="Requires create_job permission" ) return user def can_view_job(user: AuthenticatedUser = Depends(get_current_user)) -> AuthenticatedUser: """Require permission to view jobs.""" if not user.has_permission(Permission.VIEW_JOB): raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail="Requires view_job permission" ) return user def can_cancel_job(user: AuthenticatedUser = Depends(get_current_user)) -> AuthenticatedUser: """Require permission to cancel jobs.""" if not user.has_permission(Permission.CANCEL_JOB): raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail="Requires cancel_job permission" ) return user def can_delete_job(user: AuthenticatedUser = Depends(get_current_user)) -> AuthenticatedUser: """Require permission to delete jobs.""" if not user.has_permission(Permission.DELETE_JOB): raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail="Requires delete_job permission" ) return user # Report permissions def can_export_report(user: AuthenticatedUser = Depends(get_current_user)) -> AuthenticatedUser: """Require permission to export reports.""" if not user.has_permission(Permission.EXPORT_REPORT): raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail="Requires export_report permission" ) return user # API Key permissions def can_manage_api_keys(user: AuthenticatedUser = Depends(get_current_user)) -> AuthenticatedUser: """Require permission to manage API keys.""" if not user.has_permission(Permission.MANAGE_API_KEYS): raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail="Requires manage_api_keys permission" ) return user # Monitoring permissions def can_view_monitoring(user: AuthenticatedUser = Depends(get_current_user)) -> AuthenticatedUser: """Require permission to view monitoring.""" if not user.has_permission(Permission.VIEW_MONITORING): raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail="Requires view_monitoring permission" ) return user # User management permissions def can_manage_users(user: AuthenticatedUser = Depends(get_current_user)) -> AuthenticatedUser: """Require permission to manage users.""" if not user.has_permission(Permission.MANAGE_USERS): raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail="Requires manage_users permission" ) return user # Release permissions def can_approve_release(user: AuthenticatedUser = Depends(get_current_user)) -> AuthenticatedUser: """Require permission to approve releases.""" if not user.has_permission(Permission.APPROVE_RELEASE): raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail="Requires approve_release permission" ) return user __all__ = [ "AuthenticatedUser", "TenantContext", "get_current_user", "get_current_user_optional", "get_tenant_context", "require_authenticated_user", "require_role", "require_permission", "require_tenant_access", # Permission helpers "can_create_job", "can_view_job", "can_cancel_job", "can_delete_job", "can_export_report", "can_manage_api_keys", "can_view_monitoring", "can_manage_users", "can_approve_release", ]