From a17f4cc522f03db951aeeef7145ce067e1d397a8 Mon Sep 17 00:00:00 2001 From: Cheng Zhou Date: Wed, 13 May 2026 14:16:15 +0800 Subject: [PATCH] =?UTF-8?q?=E5=AE=8C=E5=96=84=E9=A1=B9=E7=9B=AE=E7=BA=A7?= =?UTF-8?q?=E6=9D=83=E9=99=90=E4=B8=8E=E9=A1=B9=E7=9B=AE=E7=AE=A1=E7=90=86?= =?UTF-8?q?=E5=85=A5=E5=8F=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增项目成员候选用户接口,避免 PM 读取全局用户列表 - 按项目角色和权限矩阵控制项目管理、成员、中心与审计入口 - 合并侧边栏管理后台入口,统一从项目管理进入成员等模块 - 加强成员管理安全约束,禁止 PM 修改自己、系统管理员或更高权限角色 - 修复多项目角色缓存串用问题,并补充前后端回归测试 --- backend/app/api/v1/members.py | 84 +++++++++++++- backend/app/api/v1/project_permissions.py | 4 +- backend/app/crud/user.py | 23 ++++ backend/app/schemas/member.py | 4 +- ...t_project_management_permissions_static.py | 32 ++++++ frontend/src/api/members.ts | 5 +- frontend/src/components/Layout.vue | 27 +---- frontend/src/router.test.ts | 14 +-- frontend/src/router/index.ts | 35 +----- frontend/src/store/study.test.ts | 50 +++++++++ frontend/src/store/study.ts | 17 ++- frontend/src/types/api.ts | 1 + frontend/src/views/admin/AuditLogs.test.ts | 3 +- frontend/src/views/admin/AuditLogs.vue | 4 +- .../src/views/admin/ProjectMembers.test.ts | 38 +++++++ frontend/src/views/admin/ProjectMembers.vue | 104 +++++++++++++----- frontend/src/views/admin/Projects.test.ts | 44 ++++++++ frontend/src/views/admin/Projects.vue | 67 +++++++++-- frontend/src/views/admin/SiteForm.test.ts | 12 ++ frontend/src/views/admin/SiteForm.vue | 2 +- frontend/src/views/admin/Sites.vue | 16 +-- 21 files changed, 452 insertions(+), 134 deletions(-) create mode 100644 frontend/src/views/admin/ProjectMembers.test.ts create mode 100644 frontend/src/views/admin/Projects.test.ts diff --git a/backend/app/api/v1/members.py b/backend/app/api/v1/members.py index 764ad09b..330853ad 100644 --- a/backend/app/api/v1/members.py +++ b/backend/app/api/v1/members.py @@ -10,10 +10,20 @@ from app.crud import member as member_crud from app.crud import study as study_crud from app.crud import user as user_crud from app.schemas.member import StudyMemberCreate, StudyMemberRead, StudyMemberReadWithUser, StudyMemberUpdate -from app.schemas.user import UserDisplay +from app.schemas.user import UserResponse router = APIRouter() +PROJECT_ROLE_RANK = { + "ADMIN": 100, + "PM": 80, + "QA": 60, + "PV": 50, + "MEDICAL_REVIEW": 50, + "CRA": 40, + "IMP": 40, +} + async def _ensure_study_exists(db: AsyncSession, study_id: uuid.UUID): study = await study_crud.get(db, study_id) @@ -22,6 +32,44 @@ async def _ensure_study_exists(db: AsyncSession, study_id: uuid.UUID): return study +def _role_value(user) -> str: + return user.role.value if hasattr(user.role, "value") else str(user.role) + + +def _role_rank(role: str | None) -> int: + return PROJECT_ROLE_RANK.get(role or "", 0) + + +async def _ensure_member_mutation_allowed( + db: AsyncSession, + study_id: uuid.UUID, + current_user, + *, + target_member=None, + target_role: str | None = None, +) -> None: + if _role_value(current_user) == "ADMIN": + return + + actor_member = await member_crud.get_member(db, study_id, current_user.id) + actor_role = actor_member.role_in_study if actor_member else None + actor_rank = _role_rank(actor_role) + + if target_member and target_member.user_id == current_user.id: + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="不能修改自己的项目成员权限") + + if target_member: + target_user = await user_crud.get_by_id(db, target_member.user_id) + if _role_value(target_user) == "ADMIN": + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="不能修改系统管理员账号的项目权限") + + if target_member and _role_rank(target_member.role_in_study) > actor_rank: + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="不能修改权限高于自己的项目成员") + + if target_role and _role_rank(target_role) > actor_rank: + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="不能授予高于自己的项目角色") + + @router.post( "/", response_model=StudyMemberRead, @@ -35,8 +83,16 @@ async def add_member( current_user=Depends(get_current_user), ) -> StudyMemberRead: await _ensure_study_exists(db, study_id) + await _ensure_member_mutation_allowed(db, study_id, current_user, target_role=member_in.role_in_study) existing = await member_crud.get_member(db, study_id, member_in.user_id) if existing: + await _ensure_member_mutation_allowed( + db, + study_id, + current_user, + target_member=existing, + target_role=member_in.role_in_study, + ) if not existing.is_active: updated = await member_crud.update_member( db, @@ -106,12 +162,28 @@ async def list_members( role_in_study=m.role_in_study, is_active=m.is_active, added_at=m.added_at, - user=UserDisplay.model_validate(user) if user else None, + user=UserResponse.model_validate(user) if user else None, ) ) return result +@router.get( + "/candidates", + response_model=list[UserResponse], + dependencies=[Depends(require_study_permission("project_members", "write"))], +) +async def list_member_candidates( + study_id: uuid.UUID, + skip: int = 0, + limit: int = 500, + db: AsyncSession = Depends(get_db_session), +) -> list[UserResponse]: + await _ensure_study_exists(db, study_id) + users = await user_crud.list_active_member_candidates_for_study(db, study_id, skip=skip, limit=limit) + return [UserResponse.model_validate(user) for user in users] + + @router.patch( "/{member_id}", response_model=StudyMemberRead, @@ -128,6 +200,13 @@ async def update_member( member = await member_crud.get_member_by_id(db, member_id) if not member or member.study_id != study_id: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="成员不存在") + await _ensure_member_mutation_allowed( + db, + study_id, + current_user, + target_member=member, + target_role=member_in.role_in_study, + ) before_data = {"role_in_study": member.role_in_study, "is_active": member.is_active} updated = await member_crud.update_member(db, member, member_in) after_data = {"role_in_study": updated.role_in_study, "is_active": updated.is_active} @@ -159,6 +238,7 @@ async def remove_member( member = await member_crud.get_member_by_id(db, member_id) if not member or member.study_id != study_id: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="成员不存在") + await _ensure_member_mutation_allowed(db, study_id, current_user, target_member=member) before_data = {"role_in_study": member.role_in_study, "is_active": member.is_active} removed = await member_crud.remove_member(db, member) await audit_crud.log_action( diff --git a/backend/app/api/v1/project_permissions.py b/backend/app/api/v1/project_permissions.py index 6cf13062..35105fa3 100644 --- a/backend/app/api/v1/project_permissions.py +++ b/backend/app/api/v1/project_permissions.py @@ -6,7 +6,7 @@ import uuid from fastapi import APIRouter, Depends, HTTPException, status from sqlalchemy.ext.asyncio import AsyncSession -from app.core.deps import get_current_user, get_db_session, require_roles, require_study_member +from app.core.deps import get_current_user, get_db_session, require_study_member, require_study_permission from app.core.project_permissions import PROJECT_PERMISSION_MODULES, get_project_role_permissions, replace_project_role_permissions from app.crud import audit as audit_crud from app.crud import study as study_crud @@ -33,7 +33,7 @@ async def get_permissions( return ProjectRolePermissionsRead(modules=modules, roles=roles) -@router.put("/", response_model=ProjectRolePermissionsRead, dependencies=[Depends(require_roles(["ADMIN"]))]) +@router.put("/", response_model=ProjectRolePermissionsRead, dependencies=[Depends(require_study_permission("project_members", "write"))]) async def update_permissions( study_id: uuid.UUID, payload: ProjectRolePermissionsUpdate, diff --git a/backend/app/crud/user.py b/backend/app/crud/user.py index dd7b6283..3b7c4667 100644 --- a/backend/app/crud/user.py +++ b/backend/app/crud/user.py @@ -85,6 +85,29 @@ async def list_users(db: AsyncSession, skip: int = 0, limit: int = 100) -> Seque return result.scalars().all() +async def list_active_member_candidates_for_study( + db: AsyncSession, + study_id: uuid.UUID, + skip: int = 0, + limit: int = 100, +) -> Sequence[User]: + existing_member = ( + select(StudyMember.id) + .where(StudyMember.study_id == study_id, StudyMember.user_id == User.id) + .exists() + ) + stmt = ( + select(User) + .where(User.status == UserStatus.ACTIVE) + .where(~existing_member) + .order_by(User.full_name.asc(), User.email.asc()) + .offset(skip) + .limit(limit) + ) + result = await db.execute(stmt) + return result.scalars().all() + + async def count_active_admins(db: AsyncSession) -> int: result = await db.execute( select(func.count()) diff --git a/backend/app/schemas/member.py b/backend/app/schemas/member.py index fb502dd1..6606bbcc 100644 --- a/backend/app/schemas/member.py +++ b/backend/app/schemas/member.py @@ -3,7 +3,7 @@ from datetime import datetime from typing import Literal, Optional from pydantic import BaseModel, ConfigDict, Field -from app.schemas.user import UserDisplay +from app.schemas.user import UserResponse StudyRole = Literal["PM", "CRA", "PV", "MEDICAL_REVIEW", "IMP", "QA", "ADMIN"] @@ -31,4 +31,4 @@ class StudyMemberRead(BaseModel): class StudyMemberReadWithUser(StudyMemberRead): - user: Optional[UserDisplay] = None + user: Optional[UserResponse] = None diff --git a/backend/tests/test_project_management_permissions_static.py b/backend/tests/test_project_management_permissions_static.py index 73382f25..4e88e4b5 100644 --- a/backend/tests/test_project_management_permissions_static.py +++ b/backend/tests/test_project_management_permissions_static.py @@ -19,6 +19,13 @@ def test_project_permission_routes_are_registered(): assert 'prefix="/studies/{study_id}/permissions"' in source +def test_project_permission_updates_use_project_member_write_permission(): + source = (ROOT / "app/api/v1/project_permissions.py").read_text(encoding="utf-8") + + assert 'require_study_permission("project_members", "write")' in source + assert 'require_roles(["ADMIN"])' not in source + + def test_project_member_writes_use_saved_permission_matrix(): source = (ROOT / "app/api/v1/members.py").read_text(encoding="utf-8") @@ -26,6 +33,31 @@ def test_project_member_writes_use_saved_permission_matrix(): assert 'require_study_roles(["ADMIN", "PM"])' not in source +def test_project_member_candidates_use_project_write_permission(): + source = (ROOT / "app/api/v1/members.py").read_text(encoding="utf-8") + user_crud_source = (ROOT / "app/crud/user.py").read_text(encoding="utf-8") + + assert '"/candidates"' in source + assert 'response_model=list[UserResponse]' in source + assert 'dependencies=[Depends(require_study_permission("project_members", "write"))]' in source + assert "list_active_member_candidates_for_study" in source + assert "User.status == UserStatus.ACTIVE" in user_crud_source + assert ".where(~existing_member)" in user_crud_source + + +def test_project_member_mutation_blocks_self_and_higher_role_changes(): + source = (ROOT / "app/api/v1/members.py").read_text(encoding="utf-8") + + assert "PROJECT_ROLE_RANK" in source + assert 'if target_member and target_member.user_id == current_user.id:' in source + assert "不能修改自己的项目成员权限" in source + assert "不能修改系统管理员账号的项目权限" in source + assert 'if _role_value(target_user) == "ADMIN":' in source + assert "不能修改权限高于自己的项目成员" in source + assert "不能授予高于自己的项目角色" in source + assert "_ensure_member_mutation_allowed(" in source + + def test_business_modules_use_saved_permission_matrix(): expected = { "overview.py": [ diff --git a/frontend/src/api/members.ts b/frontend/src/api/members.ts index 4ab83dcb..84027b94 100644 --- a/frontend/src/api/members.ts +++ b/frontend/src/api/members.ts @@ -1,9 +1,12 @@ import { apiDelete, apiGet, apiPatch, apiPost } from "./axios"; -import type { StudyMember } from "../types/api"; +import type { StudyMember, UserInfo } from "../types/api"; export const listMembers = (studyId: string, params?: Record) => apiGet(`/api/v1/studies/${studyId}/members/`, { params }); +export const listMemberCandidates = (studyId: string, params?: Record) => + apiGet(`/api/v1/studies/${studyId}/members/candidates`, { params, suppressErrorMessage: true }); + export const addMember = (studyId: string, payload: { user_id: string; role_in_study: string; is_active?: boolean }) => apiPost(`/api/v1/studies/${studyId}/members/`, payload); diff --git a/frontend/src/components/Layout.vue b/frontend/src/components/Layout.vue index f9883e98..9562668d 100644 --- a/frontend/src/components/Layout.vue +++ b/frontend/src/components/Layout.vue @@ -17,11 +17,11 @@ {{ TEXT.menu.workbench }} - + - + {{ TEXT.menu.accountManagement }} @@ -35,24 +35,6 @@ - - - - - {{ TEXT.modules.adminProjectMembers.memberLabel }} - - - - {{ TEXT.modules.adminSites.title }} - - - - {{ TEXT.menu.auditLogs }} - - -