Files
ctms/backend/app/core/deps.py
T
Cheng Zhou 0cc87210af 权限系统:完成接口级权限系统第6阶段(测试与文档)
## 主要完成内容

### 1. 接口级权限系统实现
- 新增 ApiEndpointPermission 模型:存储接口级权限配置
- 新增 ApiEndpointRegistry 模型:注册系统中所有API端点
- 实现权限检查优先级:接口级 > 模块级(向后兼容)
- 支持细粒度权限控制(METHOD:/path 格式)

### 2. 权限配置系统
- 创建 api_permissions.py:集中管理接口权限配置
- 定义 API_ENDPOINT_PERMISSIONS:所有端点的权限映射
- 定义 MODULE_TO_ENDPOINTS:模块到接口的映射(向后兼容)
- 支持默认角色配置和权限继承

### 3. 权限检查依赖注入
- 新增 require_api_permission():基于接口的权限检查
- 新增 @register_api_endpoint 装饰器:端点元数据注册
- 集成 FastAPI 依赖注入系统
- 支持权限拒绝时返回 403 Forbidden

### 4. API端点迁移(第1批)
- 迁移 subjects 模块:5个端点
- 迁移 risk_issues 模块:3个端点
- 迁移 fees 模块:8个端点
- 迁移 finance_contracts 模块:5个端点
- 共计 21 个端点完成迁移

### 5. 权限管理API
- GET /studies/{study_id}/api-permissions:获取权限矩阵
- PUT /studies/{study_id}/api-permissions:更新权限矩阵
- 支持权限配置的查询和修改

### 6. 测试与验证
- 单元测试:12 个测试用例,全部通过
- 集成测试:11 个权限管理API测试,全部通过
- 端点测试:22 个已迁移端点测试,全部通过
- 代码覆盖率:87%(超过 80% 目标)
- 总计:45 个测试用例,全部通过

### 7. 文档
- TESTING_SUMMARY.md:详细的测试结果总结
- IMPLEMENTATION_SUMMARY.md:实现细节文档
- TESTING_GUIDE.md:测试指南

## 技术亮点

1. **向后兼容性**:保留模块级权限,接口级权限优先
2. **灵活的权限配置**:支持默认角色和自定义权限
3. **细粒度控制**:支持跨模块数据访问权限
4. **完整的测试覆盖**:单元测试、集成测试、端点测试
5. **清晰的权限检查流程**:接口级 → 模块级 → 拒绝

## 下一步工作

- [ ] 第7阶段:迁移第2批模块(members, sites)
- [ ] 第8阶段:迁移第3批模块
- [ ] 第9阶段:安全审计
- [ ] 第10阶段:性能测试
- [ ] 第11阶段:文档更新

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-05-13 15:18:02 +08:00

284 lines
10 KiB
Python

from typing import Annotated, AsyncGenerator, Callable, Iterable
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.crud import site as site_crud
from app.core.project_permissions import role_has_project_permission, role_has_api_permission
from app.db.session import SessionLocal
from app.schemas.user import TokenPayload
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 require_roles(roles: Iterable[str]) -> Callable:
roles_set = set(roles)
async def dependency(current_user=Depends(get_current_user)):
current_role = current_user.role.value if hasattr(current_user.role, "value") else str(current_user.role)
if current_role not in roles_set:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="权限不足",
)
return current_user
return dependency
async def get_study_member(
study_id: uuid.UUID,
current_user=Depends(get_current_user),
db: AsyncSession = Depends(get_db_session),
):
role_value = current_user.role.value if hasattr(current_user.role, "value") else current_user.role
if role_value == "ADMIN":
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),
):
role_value = current_user.role.value if hasattr(current_user.role, "value") else current_user.role
if role_value == "ADMIN":
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),
):
role_value = current_user.role.value if hasattr(current_user.role, "value") else current_user.role
if allow_system_admin and role_value == "ADMIN":
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_study_permission(module: str, action: str, *, allow_system_admin: bool = True):
async def dependency(
study_id: uuid.UUID,
current_user=Depends(get_current_user),
db: AsyncSession = Depends(get_db_session),
):
role_value = current_user.role.value if hasattr(current_user.role, "value") else current_user.role
if allow_system_admin and role_value == "ADMIN":
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,
)
allowed = await role_has_project_permission(db, study_id, membership.role_in_study, module, action)
if not allowed:
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):
"""基于接口的权限检查
参数:
endpoint_key: 接口标识,格式为 "METHOD:/path",如 "POST:/subjects"
allow_system_admin: 是否允许系统管理员绕过权限检查
"""
async def dependency(
study_id: uuid.UUID,
current_user=Depends(get_current_user),
db: AsyncSession = Depends(get_db_session),
):
role_value = current_user.role.value if hasattr(current_user.role, "value") else current_user.role
if allow_system_admin and role_value == "ADMIN":
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,
)
allowed = await role_has_api_permission(db, study_id, membership.role_in_study, endpoint_key)
if not allowed:
raise AppException(
code="FORBIDDEN",
message="接口权限不足",
status_code=status.HTTP_403_FORBIDDEN,
)
return current_user
return dependency
async def get_cra_site_scope(
db: AsyncSession,
study_id: uuid.UUID,
current_user,
) -> tuple[set[uuid.UUID], set[str]] | None:
role_value = current_user.role.value if hasattr(current_user.role, "value") else current_user.role
if role_value == "ADMIN":
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