| """
|
| 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 = 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
|
|
|
|
|
| if credentials:
|
| token = credentials.credentials
|
| try:
|
|
|
| payload = decode_jwt_token(token)
|
| user_id = uuid.UUID(payload["sub"])
|
| tenant_id = uuid.UUID(payload["tenant_id"])
|
| email = payload["email"]
|
| role = payload["role"]
|
|
|
|
|
|
|
| return AuthenticatedUser(
|
| user_id=user_id,
|
| tenant_id=tenant_id,
|
| email=email,
|
| role=Role(role),
|
| is_api_client=False,
|
| )
|
| except HTTPException:
|
|
|
| raise
|
| except Exception:
|
|
|
| raise HTTPException(
|
| status_code=status.HTTP_401_UNAUTHORIZED,
|
| detail="Not authenticated",
|
| headers={"WWW-Authenticate": "Bearer"},
|
| )
|
|
|
|
|
| api_key = request.headers.get("X-API-Key")
|
| if api_key:
|
|
|
| from sqlalchemy import select
|
| from backend.db.models import APIKey
|
| from backend.db.session import get_db_session
|
|
|
|
|
| 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:
|
|
|
| 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
|
|
|
|
|
| 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"<AuthenticatedUser(email={self.email}, role={self.role}, tenant_id={self.tenant_id})>"
|
|
|
|
|
| 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:
|
|
|
|
|
| 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:
|
|
|
| if user.is_api_client and user.tenant_id != tenant_id:
|
| raise HTTPException(
|
| status_code=status.HTTP_403_FORBIDDEN,
|
| detail="Cannot access other tenants"
|
| )
|
|
|
|
|
| if user.tenant_id != tenant_id:
|
|
|
| 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
|
|
|
|
|
| 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"<TenantContext(tenant_id={self.tenant_id}, user={self.user.email})>"
|
|
|
|
|
| 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)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
|
|
|
|
|
|
| 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
|
|
|
|
|
|
|
| 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
|
|
|
|
|
|
|
| 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
|
|
|
|
|
|
|
| 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
|
|
|
|
|
|
|
| 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",
|
|
|
| "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",
|
| ]
|
|
|