Files
ctms/backend/app/core/deps.py
T
Cheng Zhou 6cefa620e4 权限监控与管理界面全面美化
重新设计权限系统所有 UI 组件的视觉风格,统一配色、圆角、阴影和交互动效:
- 实时概览:统计卡片加图标和渐变色条,健康评分改为环形进度,告警改为卡片式
- 趋势分析:图表卡片加彩色图标标识,ECharts 配色升级为渐变面积填充
- 访问日志:指标卡片带图标,日志审计改为卡片式入口,IP排行前三高亮
- IP属地:工具栏重设计,排行列表前三渐变高亮,指标卡片统一新风格
- 系统级权限:从 el-table 改为自定义卡片列表,模块块独立圆角卡片
- 项目权限配置:空状态引导优化,成员表格加头像,工具栏加背景容器
- 角色概览卡片:加进度条可视化,hover 微动效
- 接口权限矩阵:工具栏分离布局,表格圆角包裹
- 角色管理抽屉:侧边栏选中态渐变,操作行 hover 高亮

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-20 14:36:35 +08:00

321 lines
12 KiB
Python

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.crud import site as site_crud
from app.core.project_permissions import role_has_api_permission, get_missing_prerequisites
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_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
role_value = current_user.role.value if hasattr(current_user.role, "value") else current_user.role
if allow_system_admin and role_value == "ADMIN":
_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
monitor = get_permission_monitor()
monitor.record_permission_check(
allowed=allowed, elapsed_time=elapsed_ms / 1000, error=error
)
_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:
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