from __future__ import annotations from typing import Annotated, AsyncGenerator, Callable, Iterable import time import uuid from fastapi import Depends, HTTPException, Request, status from pydantic import ValidationError from sqlalchemy.ext.asyncio import AsyncSession from app.core.exceptions import AppException from app.core.security import decode_token, oauth2_scheme from app.crud import user as user_crud from app.crud import member as member_crud from app.core.api_permissions import SYSTEM_PERMISSIONS from app.core.project_permissions import role_has_api_permission, get_missing_prerequisites from app.db.session import SessionLocal from app.models.study_member import StudyMember from app.schemas.user import TokenPayload from sqlalchemy import select async def get_db_session() -> AsyncGenerator[AsyncSession, None]: async with SessionLocal() as session: yield session async def get_current_user( token: Annotated[str, Depends(oauth2_scheme)], db: Annotated[AsyncSession, Depends(get_db_session)], ): payload = decode_token(token) try: token_data = TokenPayload(**payload) except ValidationError as exc: raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail="无法验证登录凭据", headers={"WWW-Authenticate": "Bearer"}, ) from exc user = await user_crud.get_by_id(db, uuid.UUID(str(token_data.sub))) if not user: raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail="账号不存在", headers={"WWW-Authenticate": "Bearer"}, ) if not user.is_active: raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail="账号已停用", ) return user def is_system_admin(user) -> bool: return bool(getattr(user, "is_admin", False)) def require_roles(roles: Iterable[str]) -> Callable: roles_set = set(roles) async def dependency(current_user=Depends(get_current_user)): if "ADMIN" not in roles_set or not is_system_admin(current_user): raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail="权限不足", ) return current_user return dependency async def list_active_pm_study_ids(db: AsyncSession, user_id: uuid.UUID) -> set[uuid.UUID]: result = await db.execute( select(StudyMember.study_id).where( StudyMember.user_id == user_id, StudyMember.is_active.is_(True), StudyMember.role_in_study == "PM", ) ) return set(result.scalars().all()) async def is_active_project_pm(db: AsyncSession, user_id: uuid.UUID, study_id: uuid.UUID) -> bool: result = await db.execute( select(StudyMember.id).where( StudyMember.study_id == study_id, StudyMember.user_id == user_id, StudyMember.is_active.is_(True), StudyMember.role_in_study == "PM", ) ) return result.scalar_one_or_none() is not None async def get_operator_role_label(db: AsyncSession, study_id: uuid.UUID | None, current_user) -> str: if is_system_admin(current_user): return "ADMIN" if not study_id: return "" membership = await member_crud.get_member(db, study_id, current_user.id) return membership.role_in_study if membership and membership.is_active else "" def require_admin_or_any_project_pm() -> Callable: async def dependency( current_user=Depends(get_current_user), db: AsyncSession = Depends(get_db_session), ): if is_system_admin(current_user): return current_user if await list_active_pm_study_ids(db, current_user.id): return current_user raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail="权限不足", ) return dependency def require_system_permission(permission_key: str) -> Callable: async def dependency( study_id: uuid.UUID, current_user=Depends(get_current_user), db: AsyncSession = Depends(get_db_session), ): permission = SYSTEM_PERMISSIONS.get(permission_key) if not permission: raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail="权限不足", ) allowed_roles = set(permission.get("roles", [])) if is_system_admin(current_user) and "ADMIN" in allowed_roles: return current_user if "PM" in allowed_roles and await is_active_project_pm(db, current_user.id, study_id): return current_user raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail="权限不足", ) return dependency async def get_study_member( study_id: uuid.UUID, current_user=Depends(get_current_user), db: AsyncSession = Depends(get_db_session), ): if is_system_admin(current_user): return None return await member_crud.get_member(db, study_id, current_user.id) def require_study_member(): async def dependency( study_id: uuid.UUID, current_user=Depends(get_current_user), db: AsyncSession = Depends(get_db_session), ): if is_system_admin(current_user): return current_user membership = await member_crud.get_member(db, study_id, current_user.id) if not membership or not membership.is_active: raise AppException( code="FORBIDDEN", message="不是该项目成员", status_code=status.HTTP_403_FORBIDDEN, ) return current_user return dependency def require_study_roles(roles: Iterable[str], *, allow_system_admin: bool = True): roles_set = set(roles) async def dependency( study_id: uuid.UUID, current_user=Depends(get_current_user), db: AsyncSession = Depends(get_db_session), ): if allow_system_admin and is_system_admin(current_user): return current_user membership = await member_crud.get_member(db, study_id, current_user.id) if not membership or not membership.is_active or membership.role_in_study not in roles_set: raise AppException( code="FORBIDDEN", message="项目权限不足", status_code=status.HTTP_403_FORBIDDEN, ) return current_user return dependency def require_api_permission(endpoint_key: str, *, allow_system_admin: bool = True, check_prerequisites: bool = True): """基于接口的权限检查(包含前置权限检查) 参数: endpoint_key: 接口标识,格式为 "subjects:create" allow_system_admin: 是否允许系统管理员绕过权限检查 check_prerequisites: 是否检查前置权限 """ async def dependency( request: Request, study_id: uuid.UUID, current_user=Depends(get_current_user), db: AsyncSession = Depends(get_db_session), ): from app.core.permission_monitor import get_permission_monitor if allow_system_admin and is_system_admin(current_user): _enqueue_permission_log( study_id, current_user.id, endpoint_key, "ADMIN", True, 0.0, request ) return current_user membership = await member_crud.get_member(db, study_id, current_user.id) if not membership or not membership.is_active: raise AppException( code="FORBIDDEN", message="不是该项目成员", status_code=status.HTTP_403_FORBIDDEN, ) start_time = time.perf_counter() error = None try: allowed = await role_has_api_permission( db, study_id, membership.role_in_study, endpoint_key, check_prerequisites=check_prerequisites ) except Exception as e: error = e allowed = False raise finally: elapsed_ms = (time.perf_counter() - start_time) * 1000 if error is not None or elapsed_ms > 50: from app.core.permission_monitor import get_permission_monitor monitor = get_permission_monitor() if error is not None: monitor.record_error_alert(error) else: monitor.record_slow_check_alert(elapsed_ms) _enqueue_permission_log( study_id, current_user.id, endpoint_key, membership.role_in_study, allowed, elapsed_ms, request ) if not allowed: missing_prereqs = await get_missing_prerequisites( db, study_id, membership.role_in_study, endpoint_key ) if missing_prereqs: raise AppException( code="FORBIDDEN", message=f"缺失前置权限: {', '.join(missing_prereqs)}", status_code=status.HTTP_403_FORBIDDEN, ) else: raise AppException( code="FORBIDDEN", message="接口权限不足", status_code=status.HTTP_403_FORBIDDEN, ) return current_user return dependency def _enqueue_permission_log( study_id: uuid.UUID, user_id: uuid.UUID, endpoint_key: str, role: str, allowed: bool, elapsed_ms: float, request: Request, ) -> None: from app.services.permission_log_writer import get_log_writer writer = get_log_writer() if writer: forwarded = request.headers.get("x-forwarded-for") ip = forwarded.split(",")[0].strip() if forwarded else (request.client.host if request.client else None) writer.enqueue({ "study_id": study_id, "user_id": user_id, "endpoint_key": endpoint_key, "role": role, "allowed": allowed, "elapsed_ms": elapsed_ms, "ip_address": ip, }) async def get_cra_site_scope( db: AsyncSession, study_id: uuid.UUID, current_user, ) -> tuple[set[uuid.UUID], set[str]] | None: from app.crud import site as site_crud if is_system_admin(current_user): return None membership = await member_crud.get_member(db, study_id, current_user.id) if not membership or not membership.is_active: return None if membership.role_in_study != "CRA": return None site_ids = await site_crud.list_ids_by_contact_user(db, study_id, current_user.id) site_names = await site_crud.list_names_by_contact_user(db, study_id, current_user.id) return site_ids, site_names def require_study_not_locked(): """检查项目是否被锁定,如果锁定则拒绝所有修改请求""" from app.crud import study as study_crud from app.crud import faq_category as faq_category_crud from app.crud import faq_item as faq_item_crud async def resolve_study_id(request: Request, db: AsyncSession) -> uuid.UUID: raw_study_id = request.path_params.get("study_id") or request.query_params.get("study_id") if raw_study_id: try: return uuid.UUID(str(raw_study_id)) except ValueError as exc: raise HTTPException( status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="项目 ID 格式错误", ) from exc try: body = await request.json() except Exception: body = None if isinstance(body, dict) and body.get("study_id"): try: return uuid.UUID(str(body["study_id"])) except ValueError as exc: raise HTTPException( status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="项目 ID 格式错误", ) from exc raw_category_id = request.path_params.get("category_id") if raw_category_id: try: category_id = uuid.UUID(str(raw_category_id)) except ValueError as exc: raise HTTPException( status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="分类 ID 格式错误", ) from exc category = await faq_category_crud.get_category(db, category_id) if not category or not category.study_id: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="分类不存在") return category.study_id raw_item_id = request.path_params.get("item_id") if raw_item_id: try: item_id = uuid.UUID(str(raw_item_id)) except ValueError as exc: raise HTTPException( status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="FAQ ID 格式错误", ) from exc item = await faq_item_crud.get_item(db, item_id) if not item or not item.study_id: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="FAQ 不存在") return item.study_id raise HTTPException( status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="缺少项目 ID", ) async def dependency( request: Request, db: AsyncSession = Depends(get_db_session), ): study_id = await resolve_study_id(request, db) study = await study_crud.get(db, study_id) if not study: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail="项目不存在", ) if study.is_locked: raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail="项目已锁定,无法进行任何修改操作", ) return study return dependency