From 44d69c2d7bf4aa706e75490df72b40717f27cdae Mon Sep 17 00:00:00 2001 From: Cheng Zhou Date: Fri, 29 May 2026 10:20:42 +0800 Subject: [PATCH] =?UTF-8?q?=E6=B8=85=E7=90=86=E5=85=A8=E5=B1=80=E8=A7=92?= =?UTF-8?q?=E8=89=B2=E6=AE=8B=E7=95=99=E5=B9=B6=E4=BF=AE=E5=A4=8D=E9=A1=B9?= =?UTF-8?q?=E7=9B=AE=E6=9D=83=E9=99=90=E5=88=A4=E6=96=AD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../20260529_01_remove_user_global_role.py | 44 ++++++++++++ backend/app/api/v1/admin_users.py | 12 ++-- backend/app/api/v1/aes.py | 8 +-- backend/app/api/v1/api_permissions.py | 12 ++-- backend/app/api/v1/attachments.py | 23 +++--- backend/app/api/v1/audit_logs.py | 6 +- backend/app/api/v1/auth.py | 5 +- backend/app/api/v1/dashboard.py | 21 +++--- backend/app/api/v1/documents.py | 9 +-- backend/app/api/v1/drug_shipments.py | 8 +-- backend/app/api/v1/faq_categories.py | 11 ++- backend/app/api/v1/faqs.py | 21 +++--- backend/app/api/v1/fees_contracts.py | 32 ++++----- backend/app/api/v1/finance_dashboard.py | 5 +- backend/app/api/v1/material_equipments.py | 8 +-- backend/app/api/v1/members.py | 23 +++--- backend/app/api/v1/monitoring_visit_issues.py | 10 +-- backend/app/api/v1/permission_monitoring.py | 10 +-- backend/app/api/v1/permission_templates.py | 7 +- backend/app/api/v1/precautions.py | 8 +-- backend/app/api/v1/project_milestones.py | 4 +- backend/app/api/v1/sites.py | 8 +-- backend/app/api/v1/startup.py | 24 +++---- backend/app/api/v1/studies.py | 33 +++++---- backend/app/api/v1/subject_histories.py | 8 +-- backend/app/api/v1/subject_pds.py | 7 +- backend/app/api/v1/subjects.py | 8 +-- backend/app/api/v1/users.py | 6 +- backend/app/api/v1/visits.py | 10 +-- backend/app/core/api_permissions.py | 13 ---- backend/app/core/deps.py | 64 ++++++++++++----- backend/app/core/rbac.py | 3 +- backend/app/core/security.py | 2 - backend/app/crud/user.py | 9 +-- backend/app/models/user.py | 17 +---- backend/app/schemas/user.py | 2 - backend/app/services/document_service.py | 30 ++++---- backend/app/services/etmf_service.py | 11 +-- backend/tests/test_admin_pm_permissions.py | 65 ++++++++++++----- backend/tests/test_api_permissions.py | 70 ++++++++++++++++++- .../test_api_permissions_prerequisites.py | 29 +++++++- backend/tests/test_etmf_nodes.py | 4 +- .../tests/test_permission_monitoring_api.py | 64 +++++++++-------- backend/tests/test_protected_admin.py | 4 +- backend/tests/test_rbac_project_role.py | 8 +-- backend/tests/test_registration.py | 6 +- backend/tests/test_study_role_context.py | 10 +-- database/init.sql | 23 +++--- frontend/src/router.test.ts | 13 ++++ frontend/src/router/index.ts | 38 ++++++++-- frontend/src/types/api.ts | 1 - frontend/src/utils/permission.test.ts | 6 ++ frontend/src/utils/permission.ts | 24 ++++++- frontend/src/views/admin/AuditLogs.test.ts | 6 +- frontend/src/views/admin/AuditLogs.vue | 3 +- .../views/admin/PermissionManagement.test.ts | 24 ++++++- .../src/views/admin/PermissionManagement.vue | 65 +++++++++++++---- .../src/views/admin/ProjectDetail.test.ts | 28 +++++++- frontend/src/views/admin/ProjectDetail.vue | 64 +++++++++-------- .../src/views/admin/ProjectMembers.test.ts | 13 +++- frontend/src/views/admin/ProjectMembers.vue | 50 ++++++++----- frontend/src/views/admin/Projects.test.ts | 16 ++++- frontend/src/views/admin/Projects.vue | 6 +- 63 files changed, 739 insertions(+), 443 deletions(-) create mode 100644 backend/alembic/versions/20260529_01_remove_user_global_role.py diff --git a/backend/alembic/versions/20260529_01_remove_user_global_role.py b/backend/alembic/versions/20260529_01_remove_user_global_role.py new file mode 100644 index 00000000..4b669e5b --- /dev/null +++ b/backend/alembic/versions/20260529_01_remove_user_global_role.py @@ -0,0 +1,44 @@ +"""remove user global role + +Revision ID: 20260529_01 +Revises: 20260528_01 +Create Date: 2026-05-29 10:15:00.000000 + +""" + +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +revision: str = "20260529_01" +down_revision: Union[str, None] = "20260528_01" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + conn = op.get_bind() + inspector = sa.inspect(conn) + columns = {column["name"] for column in inspector.get_columns("users")} + + if "is_admin" not in columns: + op.add_column("users", sa.Column("is_admin", sa.Boolean(), nullable=False, server_default=sa.false())) + + if "role" in columns: + op.execute("UPDATE users SET is_admin = true WHERE role::text = 'ADMIN'") + op.drop_column("users", "role") + + op.execute("DROP TYPE IF EXISTS user_role") + + +def downgrade() -> None: + op.execute("CREATE TYPE user_role AS ENUM ('ADMIN', 'PM', 'CRA', 'PV', 'QA', 'CTA')") + op.add_column( + "users", + sa.Column("role", sa.Enum("ADMIN", "PM", "CRA", "PV", "QA", "CTA", name="user_role"), nullable=True), + ) + op.execute("UPDATE users SET role = CASE WHEN is_admin THEN 'ADMIN'::user_role ELSE 'PV'::user_role END") + op.alter_column("users", "role", nullable=False) + op.drop_column("users", "is_admin") diff --git a/backend/app/api/v1/admin_users.py b/backend/app/api/v1/admin_users.py index d6b8be7c..cec261d5 100644 --- a/backend/app/api/v1/admin_users.py +++ b/backend/app/api/v1/admin_users.py @@ -3,9 +3,9 @@ import uuid from fastapi import APIRouter, Depends, HTTPException, Query, status from sqlalchemy.ext.asyncio import AsyncSession -from app.core.deps import get_db_session, require_roles +from app.core.deps import get_db_session, is_system_admin, require_roles from app.crud import user as user_crud -from app.models.user import User, UserRole, UserStatus +from app.models.user import User, UserStatus from app.schemas.user import AdminUserListResponse, UserAdminReviewRequest, UserResponse router = APIRouter(prefix="/users") @@ -15,7 +15,7 @@ router = APIRouter(prefix="/users") async def list_users_for_review( status_filter: UserStatus = Query(default=UserStatus.PENDING, alias="status"), db: AsyncSession = Depends(get_db_session), - current_user=Depends(require_roles([UserRole.ADMIN.value])), + current_user=Depends(require_roles(["ADMIN"])), ) -> AdminUserListResponse: users = await user_crud.list_users_by_status(db, status=status_filter) return AdminUserListResponse(items=list(users), total=len(users)) @@ -25,7 +25,7 @@ async def _get_review_user(db: AsyncSession, user_id: uuid.UUID) -> User: user = await user_crud.get_by_id(db, user_id) if not user: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="用户不存在") - if user.role == UserRole.ADMIN: + if is_system_admin(user): raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="管理员账号不允许审核") return user @@ -35,7 +35,7 @@ async def approve_user( user_id: uuid.UUID, review: UserAdminReviewRequest, db: AsyncSession = Depends(get_db_session), - current_user=Depends(require_roles([UserRole.ADMIN.value])), + current_user=Depends(require_roles(["ADMIN"])), ) -> UserResponse: if review.action != "approve": raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="非法操作") @@ -51,7 +51,7 @@ async def reject_user( user_id: uuid.UUID, review: UserAdminReviewRequest, db: AsyncSession = Depends(get_db_session), - current_user=Depends(require_roles([UserRole.ADMIN.value])), + current_user=Depends(require_roles(["ADMIN"])), ) -> UserResponse: if review.action not in ("reject", "approve"): raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="非法操作") diff --git a/backend/app/api/v1/aes.py b/backend/app/api/v1/aes.py index a625f0fc..992eff50 100644 --- a/backend/app/api/v1/aes.py +++ b/backend/app/api/v1/aes.py @@ -5,7 +5,7 @@ from datetime import date from fastapi import APIRouter, Depends, HTTPException, status from sqlalchemy.ext.asyncio import AsyncSession -from app.core.deps import get_cra_site_scope, get_current_user, get_db_session, require_study_not_locked, require_api_permission +from app.core.deps import get_operator_role_label, get_cra_site_scope, get_current_user, get_db_session, require_study_not_locked, require_api_permission from app.crud import ae as ae_crud from app.crud import audit as audit_crud from app.crud import member as member_crud @@ -79,7 +79,7 @@ async def create_ae( action="CREATE_AE", detail=f"AE {ae.id} 已创建", operator_id=current_user.id, - operator_role=current_user.role, + operator_role=await get_operator_role_label(db, study_id, current_user), ) data = AERead.model_validate(ae) data.is_overdue = _is_overdue(data) @@ -249,7 +249,7 @@ async def update_ae( action=action, detail=json.dumps({"before": detail_before, "after": detail_after}, ensure_ascii=False, default=str), operator_id=current_user.id, - operator_role=current_user.role, + operator_role=await get_operator_role_label(db, study_id, current_user), ) data = AERead.model_validate(updated) data.is_overdue = _is_overdue(data) @@ -281,5 +281,5 @@ async def delete_ae( action="DELETE_AE", detail=f"AE {ae_id} 已删除", operator_id=current_user.id, - operator_role=current_user.role, + operator_role=await get_operator_role_label(db, study_id, current_user), ) diff --git a/backend/app/api/v1/api_permissions.py b/backend/app/api/v1/api_permissions.py index 02635069..a849684c 100644 --- a/backend/app/api/v1/api_permissions.py +++ b/backend/app/api/v1/api_permissions.py @@ -9,7 +9,7 @@ from fastapi import APIRouter, Depends, HTTPException, status from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession -from app.core.deps import get_current_user, get_db_session, require_study_member, require_study_roles +from app.core.deps import get_current_user, get_db_session, is_system_admin, require_study_member, require_system_permission from app.core.project_permissions import ( get_api_endpoint_permissions, replace_api_endpoint_permissions, @@ -163,8 +163,7 @@ async def get_my_study_api_permissions( db: Annotated[AsyncSession, Depends(get_db_session)] = None, ): """获取当前用户当前项目角色的有效权限。""" - role_value = current_user.role.value if hasattr(current_user.role, "value") else str(current_user.role) - if role_value == "ADMIN": + if is_system_admin(current_user): return { "ADMIN": { endpoint_key: {"allowed": True} @@ -192,7 +191,7 @@ async def get_my_study_api_permissions( ) async def get_study_api_permissions( study_id: uuid.UUID, - _=Depends(require_study_roles(["PM"])), + _=Depends(require_system_permission("system:permissions:project_config")), db: Annotated[AsyncSession, Depends(get_db_session)] = None, ): """获取项目的接口级权限矩阵 @@ -233,7 +232,7 @@ async def get_study_api_permissions( async def update_study_api_permissions( study_id: uuid.UUID, payload: dict[str, dict[str, bool]], - _=Depends(require_study_roles(["PM"])), + _=Depends(require_system_permission("system:permissions:project_config")), current_user=Depends(get_current_user), db: Annotated[AsyncSession, Depends(get_db_session)] = None, ): @@ -248,13 +247,12 @@ async def update_study_api_permissions( """ # 验证输入 configurable_roles = set(await _get_configurable_roles(db, study_id)) - current_role = current_user.role.value if hasattr(current_user.role, "value") else str(current_user.role) for role in payload.keys(): if role == "ADMIN": continue if role not in configurable_roles: raise ValueError(f"无效的角色: {role}") - if role == "PM" and current_role != "ADMIN": + if role == "PM" and not is_system_admin(current_user): raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail="仅系统管理员可修改项目负责人权限", diff --git a/backend/app/api/v1/attachments.py b/backend/app/api/v1/attachments.py index fc71609d..af4f97db 100644 --- a/backend/app/api/v1/attachments.py +++ b/backend/app/api/v1/attachments.py @@ -8,7 +8,7 @@ from fastapi import APIRouter, Depends, File, HTTPException, UploadFile, status, from fastapi.responses import FileResponse from sqlalchemy.ext.asyncio import AsyncSession -from app.core.deps import get_current_user, get_db_session, get_study_member, require_study_not_locked +from app.core.deps import get_current_user, get_db_session, get_operator_role_label, get_study_member, is_system_admin, require_study_not_locked from app.core.project_permissions import role_has_api_permission from app.crud import attachment as attachment_crud from app.crud import audit as audit_crud @@ -71,10 +71,6 @@ 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 user.role - - def _attachment_operation(action: str) -> str: if action in {"create", "read", "delete"}: return action @@ -154,8 +150,7 @@ async def _ensure_attachment_permission( membership=None, ) -> None: parent_permission = await _resolve_attachment_parent_permission(db, study_id, entity_type, entity_id, action) - role_value = _role_value(current_user) - if role_value == "ADMIN": + if is_system_admin(current_user): return if membership is None: @@ -238,7 +233,7 @@ async def upload_attachment( action="UPLOAD_FILE", detail=f"文件已上传:{file.filename}", operator_id=current_user.id, - operator_role=current_user.role, + operator_role=await get_operator_role_label(db, study_id, current_user), ) return AttachmentRead( id=attachment.id, @@ -349,7 +344,7 @@ async def _authorize_global(request: Request, db: AsyncSession, study_id: uuid.U user = await user_crud.get_by_id(db, uuid.UUID(str(payload.get("sub")))) if not user or not user.is_active: raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="账号不存在或已停用") - if _role_value(user) == "ADMIN": + if is_system_admin(user): return user, None membership = await member_crud.get_member(db, study_id, user.id) if not membership or not membership.is_active: @@ -429,7 +424,7 @@ async def global_delete_attachment( db, attachment.study_id, attachment.entity_type, attachment.entity_id, "delete", user, membership ) can_delete = ( - _role_value(user) == "ADMIN" + is_system_admin(user) or attachment.uploaded_by == user.id or membership is not None ) @@ -444,7 +439,7 @@ async def global_delete_attachment( action="DELETE_ATTACHMENT", detail=f"文件已删除:{attachment.filename}", operator_id=user.id, - operator_role=user.role, + operator_role=await get_operator_role_label(db, attachment.study_id, user), ) @@ -476,11 +471,11 @@ async def delete_attachment( raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="附件不存在") membership = None - if _role_value(current_user) != "ADMIN": + if not is_system_admin(current_user): membership = await get_study_member(study_id, current_user=current_user, db=db) can_delete = ( - _role_value(current_user) == "ADMIN" + is_system_admin(current_user) or attachment.uploaded_by == current_user.id or membership is not None ) @@ -496,5 +491,5 @@ async def delete_attachment( action="DELETE_ATTACHMENT", detail=f"文件已删除:{attachment.filename}", operator_id=current_user.id, - operator_role=current_user.role, + operator_role=await get_operator_role_label(db, study_id, current_user), ) diff --git a/backend/app/api/v1/audit_logs.py b/backend/app/api/v1/audit_logs.py index 9f38496d..51cd39a3 100644 --- a/backend/app/api/v1/audit_logs.py +++ b/backend/app/api/v1/audit_logs.py @@ -3,7 +3,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, require_api_permission +from app.core.deps import get_operator_role_label, get_current_user, get_db_session, require_roles, require_api_permission from app.crud import audit as audit_crud from app.schemas.audit import AuditEventCreate, AuditLogRead @@ -57,11 +57,11 @@ async def delete_audit_log( @router.post( "/events", status_code=status.HTTP_201_CREATED, - dependencies=[Depends(require_study_member())], ) async def create_audit_event( study_id: uuid.UUID, payload: AuditEventCreate, + _export_permission=Depends(require_api_permission("audit_logs:export")), db: AsyncSession = Depends(get_db_session), current_user=Depends(get_current_user), ): @@ -76,6 +76,6 @@ async def create_audit_event( action=payload.action, detail=payload.detail, operator_id=current_user.id, - operator_role=current_user.role, + operator_role=await get_operator_role_label(db, study_id, current_user), ) return {"ok": True} diff --git a/backend/app/api/v1/auth.py b/backend/app/api/v1/auth.py index 52ba8773..fa0481db 100644 --- a/backend/app/api/v1/auth.py +++ b/backend/app/api/v1/auth.py @@ -11,7 +11,7 @@ from app.core.login_crypto import create_login_challenge, decrypt_login_payload, from app.core.security import create_access_token, decode_token_allow_expired, oauth2_scheme, verify_password from app.core.deps import get_current_user, get_db_session from app.crud import user as user_crud -from app.models.user import UserRole, UserStatus +from app.models.user import UserStatus from app.schemas.user import Token, UserRead, UserRegisterRequest, UserSelfUpdate, UserUpdate from fastapi.responses import FileResponse @@ -57,7 +57,6 @@ def issue_user_token(db_user) -> Token: session_start = datetime.now(timezone.utc) access_token = create_access_token( user_id=str(db_user.id), - role=db_user.role.value if hasattr(db_user.role, "value") else db_user.role, expires_minutes=None, session_start=session_start, ) @@ -191,7 +190,6 @@ async def extend_access_token( session_start = datetime.now(timezone.utc) new_token = create_access_token( user_id=str(db_user.id), - role=db_user.role.value if hasattr(db_user.role, "value") else db_user.role, expires_minutes=None, session_start=session_start, ) @@ -210,7 +208,6 @@ async def unlock_session( session_start = datetime.now(timezone.utc) access_token = create_access_token( user_id=str(db_user.id), - role=db_user.role.value if hasattr(db_user.role, "value") else db_user.role, expires_minutes=None, session_start=session_start, ) diff --git a/backend/app/api/v1/dashboard.py b/backend/app/api/v1/dashboard.py index e677d483..4f2dc891 100644 --- a/backend/app/api/v1/dashboard.py +++ b/backend/app/api/v1/dashboard.py @@ -3,7 +3,7 @@ from fastapi import APIRouter, Depends from sqlalchemy import func, select from sqlalchemy.ext.asyncio import AsyncSession -from app.core.deps import get_cra_site_scope, get_current_user, get_db_session, require_study_member, require_api_permission +from app.core.deps import get_cra_site_scope, get_current_user, get_db_session, is_system_admin, require_api_permission from app.core.project_permissions import role_has_api_permission from app.crud import member as member_crud from app.models.milestone import Milestone @@ -16,13 +16,10 @@ from app.crud import overview as overview_crud router = APIRouter() -def _role_value(user) -> str: - return user.role.value if hasattr(user.role, "value") else str(user.role) - - -@router.get("/progress", response_model=StudyProgressRead, dependencies=[Depends(require_study_member())]) +@router.get("/progress", response_model=StudyProgressRead) async def get_progress( study_id: uuid.UUID, + _=Depends(require_api_permission("project_overview:read")), db: AsyncSession = Depends(get_db_session), ) -> StudyProgressRead: milestone_total_stmt = select(func.count()).select_from(Milestone).where(Milestone.study_id == study_id) @@ -42,10 +39,11 @@ async def get_progress( ) -@router.get("/lost-visits", response_model=list[VisitLostItem], dependencies=[Depends(require_study_member())]) +@router.get("/lost-visits", response_model=list[VisitLostItem]) async def list_lost_visits( study_id: uuid.UUID, limit: int = 20, + _=Depends(require_api_permission("project_overview:read")), db: AsyncSession = Depends(get_db_session), current_user=Depends(get_current_user), ) -> list[VisitLostItem]: @@ -68,18 +66,15 @@ async def list_lost_visits( return items -@router.get("/center-summary", response_model=list[CenterSummaryItem], dependencies=[ - Depends(require_study_member()), - Depends(require_api_permission("project_overview:read")) -]) +@router.get("/center-summary", response_model=list[CenterSummaryItem]) async def get_center_summary( study_id: uuid.UUID, + _=Depends(require_api_permission("project_overview:read")), db: AsyncSession = Depends(get_db_session), current_user=Depends(get_current_user), ) -> list[CenterSummaryItem]: - role_value = _role_value(current_user) membership = None - if role_value != "ADMIN": + if not is_system_admin(current_user): membership = await member_crud.get_member(db, study_id, current_user.id) if not membership or not membership.is_active: return [] diff --git a/backend/app/api/v1/documents.py b/backend/app/api/v1/documents.py index 6075c054..89202e48 100644 --- a/backend/app/api/v1/documents.py +++ b/backend/app/api/v1/documents.py @@ -1,7 +1,7 @@ import uuid from typing import Optional -from fastapi import APIRouter, Depends, File, Form, HTTPException, Query, UploadFile, status +from fastapi import APIRouter, Depends, File, Form, Query, UploadFile, status from fastapi.responses import FileResponse from sqlalchemy.ext.asyncio import AsyncSession @@ -11,7 +11,6 @@ from app.schemas.common import PaginatedResponse from app.schemas.distribution import DistributionCreate, DistributionRead from app.schemas.document import DocumentCreate, DocumentDetail, DocumentSummary from app.schemas.document_version import DocumentVersionRead -from app.models.user import UserRole from app.services import document_service from app.utils.pagination import paginate @@ -73,9 +72,6 @@ async def delete_document( db: AsyncSession = Depends(get_db_session), current_user=Depends(get_current_user), ) -> DocumentSummary: - role_value = current_user.role.value if hasattr(current_user.role, "value") else current_user.role - if role_value != UserRole.ADMIN.value: - raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="仅管理员可删除文档") doc = await document_service.delete_document(db, document_id, current_user) return DocumentSummary.model_validate(doc) @@ -123,9 +119,6 @@ async def delete_version( db: AsyncSession = Depends(get_db_session), current_user=Depends(get_current_user), ) -> None: - role_value = current_user.role.value if hasattr(current_user.role, "value") else current_user.role - if role_value != UserRole.ADMIN.value: - raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="仅管理员可删除版本") await document_service.delete_version(db, version_id, current_user) diff --git a/backend/app/api/v1/drug_shipments.py b/backend/app/api/v1/drug_shipments.py index a3d8eaec..f3671f40 100644 --- a/backend/app/api/v1/drug_shipments.py +++ b/backend/app/api/v1/drug_shipments.py @@ -3,7 +3,7 @@ import uuid from fastapi import APIRouter, Depends, HTTPException, status from sqlalchemy.ext.asyncio import AsyncSession -from app.core.deps import get_cra_site_scope, get_current_user, get_db_session, require_study_not_locked, require_api_permission +from app.core.deps import get_operator_role_label, get_cra_site_scope, get_current_user, get_db_session, require_study_not_locked, require_api_permission from app.crud import audit as audit_crud from app.crud import drug_shipment as shipment_crud from app.crud import site as site_crud @@ -56,7 +56,7 @@ async def create_shipment( action="CREATE_DRUG_SHIPMENT", detail=f"药品运输 {shipment.id} 已创建", operator_id=current_user.id, - operator_role=current_user.role, + operator_role=await get_operator_role_label(db, study_id, current_user), ) return DrugShipmentRead.model_validate(shipment) @@ -152,7 +152,7 @@ async def update_shipment( action="UPDATE_DRUG_SHIPMENT", detail=f"药品运输 {shipment_id} 已更新", operator_id=current_user.id, - operator_role=current_user.role, + operator_role=await get_operator_role_label(db, study_id, current_user), ) return DrugShipmentRead.model_validate(shipment) @@ -185,5 +185,5 @@ async def delete_shipment( action="DELETE_DRUG_SHIPMENT", detail=f"药品运输 {shipment_id} 已删除", operator_id=current_user.id, - operator_role=current_user.role, + operator_role=await get_operator_role_label(db, study_id, current_user), ) diff --git a/backend/app/api/v1/faq_categories.py b/backend/app/api/v1/faq_categories.py index f5ce4596..16d43ab4 100644 --- a/backend/app/api/v1/faq_categories.py +++ b/backend/app/api/v1/faq_categories.py @@ -3,7 +3,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_study_not_locked, require_api_permission +from app.core.deps import get_current_user, get_db_session, get_operator_role_label, is_system_admin, require_study_not_locked, require_api_permission from app.core.project_permissions import role_has_api_permission from app.crud import audit as audit_crud from app.crud import faq_category as category_crud @@ -17,8 +17,7 @@ router = APIRouter() def _is_system_admin(current_user) -> bool: - role = current_user.role.value if hasattr(current_user.role, "value") else current_user.role - return role == "ADMIN" + return is_system_admin(current_user) @router.post( @@ -51,7 +50,7 @@ async def create_category( action="CREATE_FAQ_CATEGORY", detail=f"FAQ 分类 {category.name} 已创建", operator_id=current_user.id, - operator_role=current_user.role, + operator_role=await get_operator_role_label(db, study_id, current_user), ) return CategoryRead.model_validate(category) @@ -113,7 +112,7 @@ async def update_category( action="UPDATE_FAQ_CATEGORY", detail=f"FAQ 分类 {category_id} 已更新", operator_id=current_user.id, - operator_role=current_user.role, + operator_role=await get_operator_role_label(db, study_id, current_user), ) return CategoryRead.model_validate(updated) @@ -151,5 +150,5 @@ async def delete_category( action="DELETE_FAQ_CATEGORY", detail=f"FAQ 分类 {category_id} 已删除", operator_id=current_user.id, - operator_role=current_user.role, + operator_role=await get_operator_role_label(db, study_id, current_user), ) diff --git a/backend/app/api/v1/faqs.py b/backend/app/api/v1/faqs.py index aee03e90..61fd0b65 100644 --- a/backend/app/api/v1/faqs.py +++ b/backend/app/api/v1/faqs.py @@ -3,7 +3,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_study_not_locked, require_api_permission +from app.core.deps import get_current_user, get_db_session, get_operator_role_label, is_system_admin, require_study_not_locked, require_api_permission from app.core.project_permissions import role_has_api_permission from app.crud import audit as audit_crud from app.crud import faq_category as category_crud @@ -27,8 +27,7 @@ router = APIRouter() def _is_system_admin(current_user) -> bool: - role = current_user.role.value if hasattr(current_user.role, "value") else current_user.role - return role == "ADMIN" + return is_system_admin(current_user) @router.post( @@ -75,7 +74,7 @@ async def create_faq( action="CREATE_FAQ_ITEM", detail="FAQ 已创建", operator_id=current_user.id, - operator_role=current_user.role, + operator_role=await get_operator_role_label(db, study_id, current_user), ) return FaqRead.model_validate(item) @@ -108,8 +107,7 @@ async def list_faqs( if not study_id: raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="必须提供项目 ID") - role_value = current_user.role.value if hasattr(current_user.role, "value") else current_user.role - if role_value != "ADMIN": + if not is_system_admin(current_user): member = await member_crud.get_member(db, study_id, current_user.id) if not member or not member.is_active: raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="不是项目成员") @@ -158,8 +156,7 @@ async def get_faq( raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="FAQ 不存在") if not item.study_id: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="FAQ 不存在") - role_value = current_user.role.value if hasattr(current_user.role, "value") else current_user.role - if not item.is_active and role_value != "ADMIN": + if not item.is_active and not is_system_admin(current_user): member = await member_crud.get_member(db, item.study_id, current_user.id) if not member or not member.is_active: raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="不是项目成员") @@ -203,7 +200,7 @@ async def update_faq( action=action, detail=detail, operator_id=current_user.id, - operator_role=current_user.role, + operator_role=await get_operator_role_label(db, study_id, current_user), ) return FaqRead.model_validate(updated) @@ -359,7 +356,7 @@ async def create_reply( action="CREATE_FAQ_REPLY", detail="FAQ 已回复", operator_id=current_user.id, - operator_role=current_user.role, + operator_role=await get_operator_role_label(db, study_id, current_user), ) data = FaqReplyRead.model_validate(reply) if quote: @@ -404,7 +401,7 @@ async def delete_faq( action="DELETE_FAQ_ITEM", detail="FAQ 已删除", operator_id=current_user.id, - operator_role=current_user.role, + operator_role=await get_operator_role_label(db, study_id, current_user), ) @@ -457,5 +454,5 @@ async def delete_reply( action="DELETE_FAQ_REPLY", detail="FAQ 回复已删除", operator_id=current_user.id, - operator_role=current_user.role, + operator_role=await get_operator_role_label(db, study_id, current_user), ) diff --git a/backend/app/api/v1/fees_contracts.py b/backend/app/api/v1/fees_contracts.py index 02bbecf0..c0defcdb 100644 --- a/backend/app/api/v1/fees_contracts.py +++ b/backend/app/api/v1/fees_contracts.py @@ -6,7 +6,7 @@ from fastapi import APIRouter, Depends, HTTPException, Query, status from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy import select -from app.core.deps import get_cra_site_scope, get_current_user, get_db_session, require_study_not_locked, require_api_permission +from app.core.deps import get_cra_site_scope, get_current_user, get_db_session, get_operator_role_label, is_system_admin, require_study_not_locked, require_api_permission from app.core.project_permissions import role_has_api_permission from app.crud import audit as audit_crud from app.crud import contract_fee as contract_fee_crud @@ -22,7 +22,7 @@ from app.schemas.contract_fee_payment import ( ContractFeePaymentUpdate, ) from app.schemas.fee_common import FeeApiResponse -from app.schemas.fee_attachment import FeeAttachmentRead +from app.schemas.attachment import AttachmentRead from app.schemas.user import UserDisplay from app.models.attachment import Attachment @@ -33,8 +33,7 @@ async def _ensure_project_access(db: AsyncSession, project_id: uuid.UUID, curren study = await study_crud.get(db, project_id) if not study: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="项目不存在") - role_value = current_user.role.value if hasattr(current_user.role, "value") else current_user.role - if role_value == "ADMIN": + if is_system_admin(current_user): return None membership = await member_crud.get_member(db, project_id, current_user.id) if not membership or not membership.is_active: @@ -163,7 +162,7 @@ async def get_contract_fee( user_ids = {a.uploaded_by for a in attachments if a.uploaded_by} users_map = await user_crud.get_users_by_ids(db, user_ids) - attachments_map: dict[str, list[FeeAttachmentRead]] = { + attachments_map: dict[str, list[AttachmentRead]] = { "contract": [], "voucher": [], "invoice": [], @@ -173,16 +172,11 @@ async def get_contract_fee( attachments_map.setdefault(key, []) user = users_map.get(attachment.uploaded_by) attachments_map[key].append( - FeeAttachmentRead( + AttachmentRead( id=attachment.id, - entity_type="contract_fee", - entity_id=attachment.entity_id, - file_type=key, filename=attachment.filename, - mime_type=attachment.content_type, - size=attachment.file_size, - storage_key=attachment.file_path, - url=None, + file_size=attachment.file_size, + content_type=attachment.content_type, uploaded_by_id=attachment.uploaded_by, uploaded_by=UserDisplay.model_validate(user) if user else None, uploaded_at=attachment.uploaded_at, @@ -244,7 +238,7 @@ async def create_contract_fee( action="CREATE_CONTRACT_FEE", detail="合同费用已创建", operator_id=current_user.id, - operator_role=current_user.role, + operator_role=await get_operator_role_label(db, study_id, current_user), ) return FeeApiResponse(data=ContractFeeRead.model_validate(contract)) @@ -274,7 +268,7 @@ async def update_contract_fee( action="UPDATE_CONTRACT_FEE", detail="合同费用已更新", operator_id=current_user.id, - operator_role=current_user.role, + operator_role=await get_operator_role_label(db, study_id, current_user), ) return FeeApiResponse(data=ContractFeeRead.model_validate(contract)) @@ -303,7 +297,7 @@ async def delete_contract_fee( action="DELETE_CONTRACT_FEE", detail="合同费用已删除", operator_id=current_user.id, - operator_role=current_user.role, + operator_role=await get_operator_role_label(db, study_id, current_user), ) @@ -333,7 +327,7 @@ async def create_contract_payment( action="CREATE_CONTRACT_FEE_PAYMENT", detail="合同费用分期已创建", operator_id=current_user.id, - operator_role=current_user.role, + operator_role=await get_operator_role_label(db, study_id, current_user), ) return FeeApiResponse(data=ContractFeePaymentRead.model_validate(payment)) @@ -374,7 +368,7 @@ async def update_contract_payment( action="UPDATE_CONTRACT_FEE_PAYMENT", detail="合同费用分期已更新", operator_id=current_user.id, - operator_role=current_user.role, + operator_role=await get_operator_role_label(db, study_id, current_user), ) return FeeApiResponse(data=ContractFeePaymentRead.model_validate(payment)) @@ -406,5 +400,5 @@ async def delete_contract_payment( action="DELETE_CONTRACT_FEE_PAYMENT", detail="合同费用分期已删除", operator_id=current_user.id, - operator_role=current_user.role, + operator_role=await get_operator_role_label(db, study_id, current_user), ) diff --git a/backend/app/api/v1/finance_dashboard.py b/backend/app/api/v1/finance_dashboard.py index 6e7ecaa6..04517f04 100644 --- a/backend/app/api/v1/finance_dashboard.py +++ b/backend/app/api/v1/finance_dashboard.py @@ -4,7 +4,7 @@ from datetime import date from fastapi import APIRouter, Depends from sqlalchemy.ext.asyncio import AsyncSession -from app.core.deps import get_db_session, require_study_member +from app.core.deps import get_db_session, require_api_permission from app.crud import finance as finance_crud from app.crud import study as study_crud from app.schemas.finance import FinanceSummaryRead @@ -12,11 +12,12 @@ from app.schemas.finance import FinanceSummaryRead router = APIRouter() -@router.get("/summary", response_model=FinanceSummaryRead, dependencies=[Depends(require_study_member())]) +@router.get("/summary", response_model=FinanceSummaryRead) async def finance_summary( study_id: uuid.UUID, date_from: date | None = None, date_to: date | None = None, + _=Depends(require_api_permission("fees_contracts:read")), db: AsyncSession = Depends(get_db_session), ) -> FinanceSummaryRead: # ensure study exists diff --git a/backend/app/api/v1/material_equipments.py b/backend/app/api/v1/material_equipments.py index bba901dc..7a8b87e0 100644 --- a/backend/app/api/v1/material_equipments.py +++ b/backend/app/api/v1/material_equipments.py @@ -3,7 +3,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_study_not_locked, require_api_permission +from app.core.deps import get_operator_role_label, get_current_user, get_db_session, require_study_not_locked, require_api_permission from app.crud import audit as audit_crud from app.crud import material_equipment as equipment_crud from app.crud import study as study_crud @@ -49,7 +49,7 @@ async def create_equipment( action="CREATE_MATERIAL_EQUIPMENT", detail=f"设备 {item.id} 已创建", operator_id=current_user.id, - operator_role=current_user.role, + operator_role=await get_operator_role_label(db, study_id, current_user), ) return MaterialEquipmentRead.model_validate(item) @@ -120,7 +120,7 @@ async def update_equipment( action="UPDATE_MATERIAL_EQUIPMENT", detail=f"设备 {equipment_id} 已更新", operator_id=current_user.id, - operator_role=current_user.role, + operator_role=await get_operator_role_label(db, study_id, current_user), ) return MaterialEquipmentRead.model_validate(item) @@ -149,5 +149,5 @@ async def delete_equipment( action="DELETE_MATERIAL_EQUIPMENT", detail=f"设备 {equipment_id} 已删除", operator_id=current_user.id, - operator_role=current_user.role, + operator_role=await get_operator_role_label(db, study_id, current_user), ) diff --git a/backend/app/api/v1/members.py b/backend/app/api/v1/members.py index e722600a..38994e6d 100644 --- a/backend/app/api/v1/members.py +++ b/backend/app/api/v1/members.py @@ -4,7 +4,7 @@ import json 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_study_member, require_api_permission, require_study_not_locked +from app.core.deps import get_current_user, get_db_session, is_system_admin, require_api_permission, require_study_not_locked from app.core.decorators import register_api_endpoint from app.crud import audit as audit_crud from app.crud import member as member_crud @@ -32,8 +32,11 @@ 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) +async def _operator_project_role(db: AsyncSession, study_id: uuid.UUID, current_user) -> str: + if is_system_admin(current_user): + return "ADMIN" + member = await member_crud.get_member(db, study_id, current_user.id) + return member.role_in_study if member and member.is_active else "" def _role_rank(role: str | None) -> int: @@ -48,7 +51,7 @@ async def _ensure_member_mutation_allowed( target_member=None, target_role: str | None = None, ) -> None: - if _role_value(current_user) == "ADMIN": + if is_system_admin(current_user): return actor_member = await member_crud.get_member(db, study_id, current_user.id) @@ -60,7 +63,7 @@ async def _ensure_member_mutation_allowed( if target_member: target_user = await user_crud.get_by_id(db, target_member.user_id) - if _role_value(target_user) == "ADMIN": + if is_system_admin(target_user): raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="不能修改系统管理员账号的项目权限") if target_member and _role_rank(target_member.role_in_study) >= actor_rank: @@ -118,7 +121,7 @@ async def add_member( "after": {"is_active": updated.is_active, "role_in_study": updated.role_in_study}, }, ensure_ascii=False), operator_id=current_user.id, - operator_role=current_user.role, + operator_role=await _operator_project_role(db, study_id, current_user), ) return updated raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="成员已存在") @@ -131,7 +134,7 @@ async def add_member( action="PROJECT_MEMBER_ADDED", detail=json.dumps({"targetName": str(member.user_id), "after": {"role_in_study": member.role_in_study, "is_active": member.is_active}}, ensure_ascii=False), operator_id=current_user.id, - operator_role=current_user.role, + operator_role=await _operator_project_role(db, study_id, current_user), ) return member @@ -246,7 +249,7 @@ async def update_member( action="PROJECT_MEMBER_UPDATED", detail=json.dumps({"targetName": str(updated.user_id), "before": before_data, "after": after_data}, ensure_ascii=False), operator_id=current_user.id, - operator_role=current_user.role, + operator_role=await _operator_project_role(db, study_id, current_user), ) return updated @@ -274,7 +277,7 @@ async def remove_member( if not member or member.study_id != study_id: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="成员不存在") target_user = await user_crud.get_by_id(db, member.user_id) - if target_user and _role_value(target_user) == "ADMIN": + if target_user and is_system_admin(target_user): raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, 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} @@ -287,6 +290,6 @@ async def remove_member( action="PROJECT_MEMBER_REMOVED", detail=json.dumps({"targetName": str(removed.user_id), "before": before_data, "after": {"is_active": removed.is_active}}, ensure_ascii=False), operator_id=current_user.id, - operator_role=current_user.role, + operator_role=await _operator_project_role(db, study_id, current_user), ) return removed diff --git a/backend/app/api/v1/monitoring_visit_issues.py b/backend/app/api/v1/monitoring_visit_issues.py index 7d7d0f85..b49bfc1b 100644 --- a/backend/app/api/v1/monitoring_visit_issues.py +++ b/backend/app/api/v1/monitoring_visit_issues.py @@ -11,7 +11,7 @@ from openpyxl import Workbook, load_workbook from sqlalchemy.exc import IntegrityError from sqlalchemy.ext.asyncio import AsyncSession -from app.core.deps import get_current_user, get_db_session, require_study_not_locked, require_api_permission +from app.core.deps import get_operator_role_label, get_current_user, get_db_session, require_study_not_locked, require_api_permission from app.crud import audit as audit_crud from app.crud import monitoring_visit_issue as issue_crud from app.crud import site as site_crud @@ -413,7 +413,7 @@ async def create_monitoring_visit_issue( action="CREATE_MONITORING_VISIT_ISSUE", detail=f"监查访视问题 {item.issue_no} 已创建", operator_id=current_user.id, - operator_role=current_user.role, + operator_role=await get_operator_role_label(db, study_id, current_user), ) return _to_read(item) @@ -617,7 +617,7 @@ async def update_monitoring_visit_issue( action="UPDATE_MONITORING_VISIT_ISSUE", detail=json.dumps(detail_payload, ensure_ascii=False), operator_id=current_user.id, - operator_role=current_user.role, + operator_role=await get_operator_role_label(db, study_id, current_user), ) return _to_read(updated) @@ -648,7 +648,7 @@ async def delete_monitoring_visit_issue( action="DELETE_MONITORING_VISIT_ISSUE", detail=f"监查访视问题 {issue_no} 已删除", operator_id=current_user.id, - operator_role=current_user.role, + operator_role=await get_operator_role_label(db, study_id, current_user), ) @@ -765,7 +765,7 @@ async def import_monitoring_visit_issues( action="IMPORT_MONITORING_VISIT_ISSUE", detail=f"导入监查访视问题:新增 {created_count} 条,更新 {updated_count} 条,跳过 {len(skipped_rows)} 条", operator_id=current_user.id, - operator_role=current_user.role, + operator_role=await get_operator_role_label(db, study_id, current_user), ) return MonitoringVisitIssueImportSummary( diff --git a/backend/app/api/v1/permission_monitoring.py b/backend/app/api/v1/permission_monitoring.py index b31c0025..ee09ce29 100644 --- a/backend/app/api/v1/permission_monitoring.py +++ b/backend/app/api/v1/permission_monitoring.py @@ -14,7 +14,7 @@ from fastapi import APIRouter, Depends, HTTPException, Query, status from sqlalchemy import func, select, desc from sqlalchemy.ext.asyncio import AsyncSession -from app.core.deps import get_current_user, get_db_session, list_active_pm_study_ids +from app.core.deps import get_current_user, get_db_session, is_system_admin, list_active_pm_study_ids from app.core.permission_monitor import get_permission_monitor from app.models.permission_access_log import PermissionAccessLog from app.models.permission_metric_snapshot import PermissionMetricSnapshot @@ -36,14 +36,8 @@ class MonitoringScope: return study_id is not None and study_id in self.study_ids -def _role_value(user) -> str: - if not hasattr(user, "role"): - return "ADMIN" - return user.role.value if hasattr(user.role, "value") else str(user.role) - - async def resolve_monitoring_scope(db: AsyncSession, current_user) -> MonitoringScope: - if _role_value(current_user) == "ADMIN": + if is_system_admin(current_user): return MonitoringScope(is_admin=True, study_ids=set()) return MonitoringScope( is_admin=False, diff --git a/backend/app/api/v1/permission_templates.py b/backend/app/api/v1/permission_templates.py index 8fc4d4c9..20c40d88 100644 --- a/backend/app/api/v1/permission_templates.py +++ b/backend/app/api/v1/permission_templates.py @@ -11,7 +11,6 @@ from sqlalchemy.ext.asyncio import AsyncSession from app.core.deps import get_current_user, get_db_session, require_roles, require_study_roles from app.models.permission_template import TemplateType -from app.models.user import UserRole from app.schemas.permission_template import ( ApplyTemplateRequest, ApplyTemplateResponse, @@ -52,7 +51,7 @@ async def list_templates( async def create_template( payload: PermissionTemplateCreate, db: Annotated[AsyncSession, Depends(get_db_session)] = None, - current_user=Depends(require_roles([UserRole.ADMIN.value])), + current_user=Depends(require_roles(["ADMIN"])), ) -> PermissionTemplateRead: """创建权限模板""" try: @@ -79,7 +78,7 @@ async def update_template( template_id: uuid.UUID, payload: PermissionTemplateUpdate, db: Annotated[AsyncSession, Depends(get_db_session)] = None, - current_user=Depends(require_roles([UserRole.ADMIN.value])), + current_user=Depends(require_roles(["ADMIN"])), ) -> PermissionTemplateRead: """更新权限模板""" try: @@ -93,7 +92,7 @@ async def update_template( async def delete_template( template_id: uuid.UUID, db: Annotated[AsyncSession, Depends(get_db_session)] = None, - current_user=Depends(require_roles([UserRole.ADMIN.value])), + current_user=Depends(require_roles(["ADMIN"])), ): """删除权限模板""" try: diff --git a/backend/app/api/v1/precautions.py b/backend/app/api/v1/precautions.py index b6b34503..3c06770f 100644 --- a/backend/app/api/v1/precautions.py +++ b/backend/app/api/v1/precautions.py @@ -3,7 +3,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_study_not_locked, require_api_permission +from app.core.deps import get_operator_role_label, get_current_user, get_db_session, require_study_not_locked, require_api_permission from app.crud import audit as audit_crud from app.crud import precaution as precaution_crud from app.crud import site as site_crud @@ -51,7 +51,7 @@ async def create_precaution( action="CREATE_PRECAUTION", detail=f"注意事项 {precaution.title} 已创建", operator_id=current_user.id, - operator_role=current_user.role, + operator_role=await get_operator_role_label(db, study_id, current_user), ) return PrecautionRead.model_validate(precaution) @@ -117,7 +117,7 @@ async def update_precaution( action="UPDATE_PRECAUTION", detail=f"注意事项 {precaution_id} 已更新", operator_id=current_user.id, - operator_role=current_user.role, + operator_role=await get_operator_role_label(db, study_id, current_user), ) return PrecautionRead.model_validate(precaution) @@ -147,5 +147,5 @@ async def delete_precaution( action="DELETE_PRECAUTION", detail=f"注意事项 {precaution_id} 已删除", operator_id=current_user.id, - operator_role=current_user.role, + operator_role=await get_operator_role_label(db, study_id, current_user), ) diff --git a/backend/app/api/v1/project_milestones.py b/backend/app/api/v1/project_milestones.py index e6f2ae35..1bc5a48a 100644 --- a/backend/app/api/v1/project_milestones.py +++ b/backend/app/api/v1/project_milestones.py @@ -3,7 +3,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_study_not_locked, require_api_permission +from app.core.deps import get_operator_role_label, get_current_user, get_db_session, require_study_not_locked, require_api_permission from app.crud import audit as audit_crud from app.crud import project_milestone as milestone_crud from app.crud import study as study_crud @@ -69,6 +69,6 @@ async def update_project_milestone( action="UPDATE_PROJECT_MILESTONE", detail=f"项目里程碑 {milestone_id} 已更新", operator_id=current_user.id, - operator_role=current_user.role, + operator_role=await get_operator_role_label(db, study_id, current_user), ) return ProjectMilestoneRead.model_validate(item) diff --git a/backend/app/api/v1/sites.py b/backend/app/api/v1/sites.py index cebeb6aa..c0078efb 100644 --- a/backend/app/api/v1/sites.py +++ b/backend/app/api/v1/sites.py @@ -4,7 +4,7 @@ import json from fastapi import APIRouter, Depends, HTTPException, status from sqlalchemy.ext.asyncio import AsyncSession -from app.core.deps import get_cra_site_scope, get_current_user, get_db_session, require_study_member, require_api_permission, require_study_not_locked +from app.core.deps import get_operator_role_label, get_cra_site_scope, get_current_user, get_db_session, require_study_member, require_api_permission, require_study_not_locked from app.core.decorators import register_api_endpoint from app.crud import audit as audit_crud from app.crud import site as site_crud @@ -64,7 +64,7 @@ async def create_site( action="SITE_CREATED", detail=json.dumps({"targetName": site.name, "after": {"name": site.name, "is_active": site.is_active}}, ensure_ascii=False), operator_id=current_user.id, - operator_role=current_user.role, + operator_role=await get_operator_role_label(db, study_id, current_user), ) return site @@ -152,7 +152,7 @@ async def update_site( action=action, detail=json.dumps({"targetName": updated.name, "before": before_data, "after": after_data}, ensure_ascii=False), operator_id=current_user.id, - operator_role=current_user.role, + operator_role=await get_operator_role_label(db, study_id, current_user), ) return updated @@ -196,6 +196,6 @@ async def delete_site( action="SITE_DELETED", detail=json.dumps({"targetName": site.name, "before": before_data, "after": {"deleted": True}}, ensure_ascii=False), operator_id=current_user.id, - operator_role=current_user.role, + operator_role=await get_operator_role_label(db, study_id, current_user), ) return None diff --git a/backend/app/api/v1/startup.py b/backend/app/api/v1/startup.py index 89e852d4..9fd8a9b0 100644 --- a/backend/app/api/v1/startup.py +++ b/backend/app/api/v1/startup.py @@ -3,7 +3,7 @@ import uuid from fastapi import APIRouter, Depends, HTTPException, status from sqlalchemy.ext.asyncio import AsyncSession -from app.core.deps import get_cra_site_scope, get_current_user, get_db_session, require_api_permission +from app.core.deps import get_operator_role_label, get_cra_site_scope, get_current_user, get_db_session, require_api_permission from app.crud import audit as audit_crud from app.crud import site as site_crud from app.crud import startup as startup_crud @@ -75,7 +75,7 @@ async def create_feasibility( action="CREATE_STARTUP_FEASIBILITY", detail="立项记录已创建", operator_id=current_user.id, - operator_role=current_user.role, + operator_role=await get_operator_role_label(db, study_id, current_user), ) return StartupFeasibilityRead.model_validate(record) @@ -149,7 +149,7 @@ async def update_feasibility( action="UPDATE_STARTUP_FEASIBILITY", detail="立项记录已更新", operator_id=current_user.id, - operator_role=current_user.role, + operator_role=await get_operator_role_label(db, study_id, current_user), ) return StartupFeasibilityRead.model_validate(record) @@ -182,7 +182,7 @@ async def delete_feasibility( action="DELETE_STARTUP_FEASIBILITY", detail="立项记录已删除", operator_id=current_user.id, - operator_role=current_user.role, + operator_role=await get_operator_role_label(db, study_id, current_user), ) @@ -212,7 +212,7 @@ async def create_ethics( action="CREATE_STARTUP_ETHICS", detail="伦理记录已创建", operator_id=current_user.id, - operator_role=current_user.role, + operator_role=await get_operator_role_label(db, study_id, current_user), ) return StartupEthicsRead.model_validate(record) @@ -286,7 +286,7 @@ async def update_ethics( action="UPDATE_STARTUP_ETHICS", detail="伦理记录已更新", operator_id=current_user.id, - operator_role=current_user.role, + operator_role=await get_operator_role_label(db, study_id, current_user), ) return StartupEthicsRead.model_validate(record) @@ -319,7 +319,7 @@ async def delete_ethics( action="DELETE_STARTUP_ETHICS", detail="伦理记录已删除", operator_id=current_user.id, - operator_role=current_user.role, + operator_role=await get_operator_role_label(db, study_id, current_user), ) @@ -349,7 +349,7 @@ async def create_kickoff( action="CREATE_KICKOFF_MEETING", detail="启动会记录已创建", operator_id=current_user.id, - operator_role=current_user.role, + operator_role=await get_operator_role_label(db, study_id, current_user), ) return KickoffMeetingRead.model_validate(meeting) @@ -423,7 +423,7 @@ async def update_kickoff( action="UPDATE_KICKOFF_MEETING", detail="启动会记录已更新", operator_id=current_user.id, - operator_role=current_user.role, + operator_role=await get_operator_role_label(db, study_id, current_user), ) return KickoffMeetingRead.model_validate(meeting) @@ -454,7 +454,7 @@ async def create_training_authorization( action="CREATE_TRAINING_AUTH", detail="培训授权人员已创建", operator_id=current_user.id, - operator_role=current_user.role, + operator_role=await get_operator_role_label(db, study_id, current_user), ) return TrainingAuthorizationRead.model_validate(record) @@ -528,7 +528,7 @@ async def update_training_authorization( action="UPDATE_TRAINING_AUTH", detail="培训授权人员已更新", operator_id=current_user.id, - operator_role=current_user.role, + operator_role=await get_operator_role_label(db, study_id, current_user), ) return TrainingAuthorizationRead.model_validate(record) @@ -561,5 +561,5 @@ async def delete_training_authorization( action="DELETE_TRAINING_AUTH", detail="培训授权人员已删除", operator_id=current_user.id, - operator_role=current_user.role, + operator_role=await get_operator_role_label(db, study_id, current_user), ) diff --git a/backend/app/api/v1/studies.py b/backend/app/api/v1/studies.py index e2ecd01f..e0bba359 100644 --- a/backend/app/api/v1/studies.py +++ b/backend/app/api/v1/studies.py @@ -10,6 +10,8 @@ from sqlalchemy.ext.asyncio import AsyncSession from app.core.deps import ( get_current_user, get_db_session, + get_operator_role_label, + is_system_admin, require_api_permission, require_roles, require_study_member, @@ -50,9 +52,6 @@ def _study_read_with_role(study, role_in_study: str | None = None) -> StudyRead: return data -def _role_value(user) -> str: - return user.role.value if hasattr(user.role, "value") else str(user.role) - def _raise_validation_error(errors: list[dict[str, str]]) -> None: raise HTTPException( status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, @@ -769,7 +768,7 @@ async def create_study( action="CREATE_SETUP_CONFIG", detail="初始化立项配置(创建项目时回填基础信息)", operator_id=current_user.id, - operator_role=current_user.role, + operator_role=await get_operator_role_label(db, study_id, current_user), ) return study @@ -782,7 +781,7 @@ async def list_studies( db: AsyncSession = Depends(get_db_session), current_user=Depends(get_current_user), ) -> PaginatedResponse[StudyRead]: - if _role_value(current_user) == "ADMIN": + if is_system_admin(current_user): studies = await study_crud.list_studies(db, skip=skip, limit=limit) total = await study_crud.list_studies(db, skip=0, limit=10_000_000) items = [_study_read_with_role(study) for study in studies] @@ -805,7 +804,7 @@ async def get_study( study = await study_crud.get(db, study_id) if not study: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="项目不存在") - if _role_value(current_user) == "ADMIN": + if is_system_admin(current_user): return _study_read_with_role(study) member = await member_crud.get_member(db, study_id, current_user.id) return _study_read_with_role(study, member.role_in_study if member else None) @@ -893,7 +892,7 @@ async def lock_study( action="LOCK_STUDY", detail=f"项目已锁定:{study.name} ({study.code})", operator_id=current_user.id, - operator_role=current_user.role, + operator_role=await get_operator_role_label(db, study_id, current_user), ) return locked_study @@ -925,7 +924,7 @@ async def unlock_study( action="UNLOCK_STUDY", detail=f"项目已解锁:{study.name} ({study.code})", operator_id=current_user.id, - operator_role=current_user.role, + operator_role=await get_operator_role_label(db, study_id, current_user), ) return unlocked_study @@ -964,7 +963,7 @@ async def get_study_setup_config( action="CREATE_SETUP_CONFIG", detail="初始化立项配置", operator_id=current_user.id, - operator_role=current_user.role, + operator_role=await get_operator_role_label(db, study_id, current_user), ) saved_by_name = None @@ -1047,7 +1046,7 @@ async def upsert_study_setup_config( action="UPDATE_SETUP_CONFIG", detail=_top_level_diff_summary(old_config, record.config), operator_id=current_user.id, - operator_role=current_user.role, + operator_role=await get_operator_role_label(db, study_id, current_user), ) saved_by_name = current_user.full_name or current_user.username or current_user.email @@ -1159,7 +1158,7 @@ async def publish_study_setup_config( skipped_items=[{"site_id": item.site_id, "reason": item.reason} for item in projection.skipped_items], ), operator_id=current_user.id, - operator_role=current_user.role, + operator_role=await get_operator_role_label(db, study_id, current_user), auto_commit=False, ) published.config = study_setup_config_crud.empty_draft_payload() @@ -1259,7 +1258,7 @@ async def rollback_study_setup_config( action="ROLLBACK_SETUP_CONFIG", detail=f"立项配置已回滚并替换当前发布为 {target_label},草稿分支已切换到对应分支基线", operator_id=current_user.id, - operator_role=current_user.role, + operator_role=await get_operator_role_label(db, study_id, current_user), ) operator_name = current_user.full_name or current_user.username or current_user.email published_by_name = None @@ -1318,7 +1317,7 @@ async def checkout_study_setup_config_branch_draft( action="CHECKOUT_SETUP_CONFIG_BRANCH_DRAFT", detail=f"立项配置草稿已切换到 {target_label} 对应分支", operator_id=current_user.id, - operator_role=current_user.role, + operator_role=await get_operator_role_label(db, study_id, current_user), ) operator_name = current_user.full_name or current_user.username or current_user.email published_by_name = None @@ -1370,7 +1369,7 @@ async def clear_study_setup_config_draft( action="CLEAR_SETUP_CONFIG_DRAFT", detail="立项配置草稿已清空", operator_id=current_user.id, - operator_role=current_user.role, + operator_role=await get_operator_role_label(db, study_id, current_user), ) operator_name = current_user.full_name or current_user.username or current_user.email published_by_name = None @@ -1424,7 +1423,7 @@ async def refill_study_setup_config_draft( action="REFILL_SETUP_CONFIG_DRAFT", detail="立项配置草稿已从当前发布版本一键回填", operator_id=current_user.id, - operator_role=current_user.role, + operator_role=await get_operator_role_label(db, study_id, current_user), ) operator_name = current_user.full_name or current_user.username or current_user.email published_by_name = None @@ -1511,7 +1510,7 @@ async def merge_study_setup_config_to_main( action="MERGE_SETUP_CONFIG_TO_MAIN", detail=f"已将发布版本 {source_label} 合并到主分支并生成新主版本", operator_id=current_user.id, - operator_role=current_user.role, + operator_role=await get_operator_role_label(db, study_id, current_user), auto_commit=False, ) merged.config = study_setup_config_crud.empty_draft_payload() @@ -1578,5 +1577,5 @@ async def delete_study_setup_config_version( action="DELETE_SETUP_CONFIG_VERSION", detail=f"删除发布版本快照 v{target_version}", operator_id=current_user.id, - operator_role=current_user.role, + operator_role=await get_operator_role_label(db, study_id, current_user), ) diff --git a/backend/app/api/v1/subject_histories.py b/backend/app/api/v1/subject_histories.py index fd1d658e..660c7e21 100644 --- a/backend/app/api/v1/subject_histories.py +++ b/backend/app/api/v1/subject_histories.py @@ -3,7 +3,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_study_not_locked, require_api_permission +from app.core.deps import get_operator_role_label, get_current_user, get_db_session, require_study_not_locked, require_api_permission from app.crud import audit as audit_crud from app.crud import site as site_crud from app.crud import subject as subject_crud @@ -58,7 +58,7 @@ async def create_history( action="CREATE_SUBJECT_HISTORY", detail="病史记录已创建", operator_id=current_user.id, - operator_role=current_user.role, + operator_role=await get_operator_role_label(db, study_id, current_user), ) return SubjectHistoryRead.model_validate(history) @@ -133,7 +133,7 @@ async def update_history( action="UPDATE_SUBJECT_HISTORY", detail="病史记录已更新", operator_id=current_user.id, - operator_role=current_user.role, + operator_role=await get_operator_role_label(db, study_id, current_user), ) return SubjectHistoryRead.model_validate(history) @@ -166,5 +166,5 @@ async def delete_history( action="DELETE_SUBJECT_HISTORY", detail="病史记录已删除", operator_id=current_user.id, - operator_role=current_user.role, + operator_role=await get_operator_role_label(db, study_id, current_user), ) diff --git a/backend/app/api/v1/subject_pds.py b/backend/app/api/v1/subject_pds.py index 4650c081..c2471e1d 100644 --- a/backend/app/api/v1/subject_pds.py +++ b/backend/app/api/v1/subject_pds.py @@ -6,6 +6,7 @@ from sqlalchemy.ext.asyncio import AsyncSession from app.core.deps import ( get_current_user, get_db_session, + get_operator_role_label, require_study_not_locked, require_api_permission, ) @@ -120,7 +121,7 @@ async def create_subject_pd( action="CREATE_SUBJECT_PD", detail=f"PD记录 {item.pd_no} 已创建", operator_id=current_user.id, - operator_role=current_user.role, + operator_role=await get_operator_role_label(db, study_id, current_user), ) return SubjectPdRead.model_validate(item) @@ -167,7 +168,7 @@ async def update_subject_pd( action="UPDATE_SUBJECT_PD", detail=f"PD记录 {updated.pd_no} 已更新", operator_id=current_user.id, - operator_role=current_user.role, + operator_role=await get_operator_role_label(db, study_id, current_user), ) return SubjectPdRead.model_validate(updated) @@ -201,5 +202,5 @@ async def delete_subject_pd( action="DELETE_SUBJECT_PD", detail=f"PD记录 {item.pd_no} 已删除", operator_id=current_user.id, - operator_role=current_user.role, + operator_role=await get_operator_role_label(db, study_id, current_user), ) diff --git a/backend/app/api/v1/subjects.py b/backend/app/api/v1/subjects.py index 0645ec2a..4f09d543 100644 --- a/backend/app/api/v1/subjects.py +++ b/backend/app/api/v1/subjects.py @@ -4,7 +4,7 @@ from fastapi import APIRouter, Depends, HTTPException, status from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession -from app.core.deps import get_cra_site_scope, get_current_user, get_db_session, require_study_not_locked, require_api_permission +from app.core.deps import get_operator_role_label, get_cra_site_scope, get_current_user, get_db_session, require_study_not_locked, require_api_permission from app.crud import audit as audit_crud from app.crud import site as site_crud from app.crud import subject as subject_crud @@ -58,7 +58,7 @@ async def create_subject( action="CREATE_SUBJECT", detail=f"参与者 {subject.subject_no} 已创建", operator_id=current_user.id, - operator_role=current_user.role, + operator_role=await get_operator_role_label(db, study_id, current_user), ) return subject @@ -188,7 +188,7 @@ async def update_subject( action="SUBJECT_STATUS_CHANGE" if detail else "UPDATE_SUBJECT", detail=detail or "参与者已更新", operator_id=current_user.id, - operator_role=current_user.role, + operator_role=await get_operator_role_label(db, study_id, current_user), ) return updated @@ -220,5 +220,5 @@ async def delete_subject( action="DELETE_SUBJECT", detail=f"参与者 {subject_id} 已删除", operator_id=current_user.id, - operator_role=current_user.role, + operator_role=await get_operator_role_label(db, study_id, current_user), ) diff --git a/backend/app/api/v1/users.py b/backend/app/api/v1/users.py index c6858f76..f486ddf9 100644 --- a/backend/app/api/v1/users.py +++ b/backend/app/api/v1/users.py @@ -3,7 +3,7 @@ import uuid from fastapi import APIRouter, Depends, HTTPException, status from sqlalchemy.ext.asyncio import AsyncSession -from app.core.deps import get_db_session, require_roles +from app.core.deps import get_db_session, is_system_admin, require_roles from app.schemas.common import PaginatedResponse from app.crud import user as user_crud from app.crud import member as member_crud @@ -59,7 +59,7 @@ async def update_user( requested_status = "ACTIVE" if user_in.is_active else "DISABLED" if requested_status is not None and requested_status != "ACTIVE": raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="系统管理员不允许停用") - if db_user.role.value == "ADMIN": + if is_system_admin(db_user): requested_status = user_in.status if user_in.is_active is not None: requested_status = "ACTIVE" if user_in.is_active else "DISABLED" @@ -85,7 +85,7 @@ async def delete_user( raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="系统管理员账号不允许删除") if db_user.id == current_user.id: raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="不允许删除自己") - if db_user.role.value == "ADMIN" and db_user.status.value == "ACTIVE": + if is_system_admin(db_user) and db_user.status.value == "ACTIVE": active_admins = await user_crud.count_active_admins(db) if active_admins <= 1: raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="至少保留一个管理员账号") diff --git a/backend/app/api/v1/visits.py b/backend/app/api/v1/visits.py index 68921050..78f1c12c 100644 --- a/backend/app/api/v1/visits.py +++ b/backend/app/api/v1/visits.py @@ -3,7 +3,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_study_not_locked, require_api_permission +from app.core.deps import get_operator_role_label, get_current_user, get_db_session, require_study_not_locked, require_api_permission from app.crud import audit as audit_crud from app.crud import site as site_crud from app.crud import subject as subject_crud @@ -107,7 +107,7 @@ async def create_visit( action="CREATE_VISIT", detail=f"访视 {visit.visit_code} 已创建", operator_id=current_user.id, - operator_role=current_user.role, + operator_role=await get_operator_role_label(db, study_id, current_user), ) return visit @@ -160,7 +160,7 @@ async def create_early_termination( action="CREATE_EARLY_TERMINATION", detail=f"参与者 {subject.subject_no} 已提前终止", operator_id=current_user.id, - operator_role=current_user.role, + operator_role=await get_operator_role_label(db, study_id, current_user), ) return visit @@ -204,7 +204,7 @@ async def update_visit( action="VISIT_STATUS_CHANGE" if detail else "UPDATE_VISIT", detail=detail or "访视已更新", operator_id=current_user.id, - operator_role=current_user.role, + operator_role=await get_operator_role_label(db, study_id, current_user), ) return updated @@ -236,5 +236,5 @@ async def delete_visit( action="DELETE_VISIT", detail=f"访视 {visit_id} 已删除", operator_id=current_user.id, - operator_role=current_user.role, + operator_role=await get_operator_role_label(db, study_id, current_user), ) diff --git a/backend/app/core/api_permissions.py b/backend/app/core/api_permissions.py index 0f9ef063..435958a6 100644 --- a/backend/app/core/api_permissions.py +++ b/backend/app/core/api_permissions.py @@ -269,19 +269,6 @@ API_ENDPOINT_PERMISSIONS = { "description": "删除立项记录", "default_roles": ["PM"], }, - # 项目权限管理 - "permissions:read": { - "module": "project_members", - "action": "read", - "description": "查询项目权限矩阵", - "default_roles": ["PM"], - }, - "permissions:update": { - "module": "project_members", - "action": "write", - "description": "更新项目权限矩阵", - "default_roles": ["PM"], - }, # 项目概览 "project_overview:read": { "module": "project_overview", diff --git a/backend/app/core/deps.py b/backend/app/core/deps.py index 2ae3eb1d..c0730f84 100644 --- a/backend/app/core/deps.py +++ b/backend/app/core/deps.py @@ -12,6 +12,7 @@ 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 @@ -53,12 +54,15 @@ async def get_current_user( 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)): - current_role = current_user.role.value if hasattr(current_user.role, "value") else str(current_user.role) - if current_role not in roles_set: + if "ADMIN" not in roles_set or not is_system_admin(current_user): raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail="权限不足", @@ -68,10 +72,6 @@ def require_roles(roles: Iterable[str]) -> Callable: return dependency -def _role_value(user) -> str: - return user.role.value if hasattr(user.role, "value") else str(user.role) - - 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( @@ -95,12 +95,21 @@ async def is_active_project_pm(db: AsyncSession, user_id: uuid.UUID, study_id: u 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 _role_value(current_user) == "ADMIN": + if is_system_admin(current_user): return current_user if await list_active_pm_study_ids(db, current_user.id): return current_user @@ -112,13 +121,38 @@ def require_admin_or_any_project_pm() -> Callable: 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), ): - role_value = _role_value(current_user) - if role_value == "ADMIN": + if is_system_admin(current_user): return None return await member_crud.get_member(db, study_id, current_user.id) @@ -129,8 +163,7 @@ def require_study_member(): current_user=Depends(get_current_user), db: AsyncSession = Depends(get_db_session), ): - role_value = _role_value(current_user) - if role_value == "ADMIN": + 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: @@ -152,8 +185,7 @@ def require_study_roles(roles: Iterable[str], *, allow_system_admin: bool = True current_user=Depends(get_current_user), db: AsyncSession = Depends(get_db_session), ): - role_value = _role_value(current_user) - if allow_system_admin and role_value == "ADMIN": + 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: @@ -183,8 +215,7 @@ def require_api_permission(endpoint_key: str, *, allow_system_admin: bool = True db: AsyncSession = Depends(get_db_session), ): from app.core.permission_monitor import get_permission_monitor - role_value = _role_value(current_user) - if allow_system_admin and role_value == "ADMIN": + 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 ) @@ -275,8 +306,7 @@ async def get_cra_site_scope( ) -> tuple[set[uuid.UUID], set[str]] | None: from app.crud import site as site_crud - role_value = current_user.role.value if hasattr(current_user.role, "value") else current_user.role - if role_value == "ADMIN": + 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: diff --git a/backend/app/core/rbac.py b/backend/app/core/rbac.py index a35f0a95..41b777bb 100644 --- a/backend/app/core/rbac.py +++ b/backend/app/core/rbac.py @@ -30,8 +30,7 @@ ROLE_ACTIONS: dict[str, set[str]] = { def is_allowed(action: str, user, membership) -> bool: - role_value = user.role.value if hasattr(user.role, "value") else str(user.role) - if role_value == "ADMIN": + if getattr(user, "is_admin", False): return action in ROLE_ACTIONS["ADMIN"] member_role = getattr(membership, "role_in_study", None) if member_role: diff --git a/backend/app/core/security.py b/backend/app/core/security.py index 8bd788ef..aa9ec667 100644 --- a/backend/app/core/security.py +++ b/backend/app/core/security.py @@ -17,7 +17,6 @@ oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/v1/auth/login", scheme_name= def create_access_token( *, user_id: str, - role: str, expires_minutes: Optional[int] = None, session_start: Optional[datetime] = None, ) -> str: @@ -26,7 +25,6 @@ def create_access_token( session_start_time = session_start or now to_encode: Dict[str, Any] = { "sub": user_id, - "role": role, "exp": expire, "iat": int(now.timestamp()), "orig_iat": int(session_start_time.timestamp()), diff --git a/backend/app/crud/user.py b/backend/app/crud/user.py index 11cf458d..8888af47 100644 --- a/backend/app/crud/user.py +++ b/backend/app/crud/user.py @@ -1,7 +1,5 @@ from __future__ import annotations -from __future__ import annotations - import uuid from typing import Sequence @@ -16,7 +14,7 @@ from app.core.config import ( ) from app.core.security import hash_password from app.models.study_member import StudyMember -from app.models.user import User, UserRole, UserStatus +from app.models.user import User, UserStatus from app.schemas.user import UserCreate, UserRegisterRequest, UserUpdate @@ -46,7 +44,6 @@ async def create_user( email=user_in.email, password_hash=hash_password(user_in.password), full_name=user_in.full_name, - role=UserRole.PV, clinical_department=user_in.clinical_department, status=status_value, ) @@ -114,7 +111,7 @@ async def count_active_admins(db: AsyncSession) -> int: result = await db.execute( select(func.count()) .select_from(User) - .where(User.role == UserRole.ADMIN, User.status == UserStatus.ACTIVE) + .where(User.is_admin.is_(True), User.status == UserStatus.ACTIVE) ) return int(result.scalar_one() or 0) @@ -138,7 +135,7 @@ async def ensure_admin_exists(db: AsyncSession, *, default_password: str = PROTE email=PROTECTED_ADMIN_EMAIL, password_hash=hash_password(default_password), full_name=PROTECTED_ADMIN_FULL_NAME, - role=UserRole.ADMIN, + is_admin=True, clinical_department=PROTECTED_ADMIN_CLINICAL_DEPARTMENT, status=UserStatus.ACTIVE, ) diff --git a/backend/app/models/user.py b/backend/app/models/user.py index 090c7619..3785c7ba 100644 --- a/backend/app/models/user.py +++ b/backend/app/models/user.py @@ -5,22 +5,13 @@ import enum import uuid from datetime import datetime -from sqlalchemy import DateTime, Enum, ForeignKey, String, func +from sqlalchemy import Boolean, DateTime, Enum, ForeignKey, String, func from sqlalchemy.dialects.postgresql import UUID from sqlalchemy.orm import Mapped, mapped_column from app.db.base_class import Base -class UserRole(str, enum.Enum): - ADMIN = "ADMIN" - PM = "PM" - CRA = "CRA" - PV = "PV" - QA = "QA" - CTA = "CTA" - - class UserStatus(str, enum.Enum): PENDING = "PENDING" ACTIVE = "ACTIVE" @@ -35,8 +26,8 @@ class User(Base): email: Mapped[str] = mapped_column(String(255), unique=True, index=True, nullable=False) password_hash: Mapped[str] = mapped_column(String(255), nullable=False) full_name: Mapped[str] = mapped_column(String(255), nullable=False) - role: Mapped[UserRole] = mapped_column(Enum(UserRole, name="user_role"), nullable=False) clinical_department: Mapped[str] = mapped_column(String(255), nullable=False) + is_admin: Mapped[bool] = mapped_column(Boolean, nullable=False, server_default="false", default=False) status: Mapped[UserStatus] = mapped_column( Enum(UserStatus, name="user_status"), nullable=False, @@ -66,10 +57,6 @@ class User(Base): def is_active(self) -> bool: # compatibility helper for existing checks return self.status == UserStatus.ACTIVE - @property - def is_admin(self) -> bool: - return self.role == UserRole.ADMIN - @property def username(self) -> str: # backward compatibility for existing UI copy return self.email diff --git a/backend/app/schemas/user.py b/backend/app/schemas/user.py index 33941f6a..25a3ba36 100644 --- a/backend/app/schemas/user.py +++ b/backend/app/schemas/user.py @@ -5,7 +5,6 @@ from typing import Literal, Optional from pydantic import BaseModel, ConfigDict, EmailStr, Field, field_validator -UserRole = Literal["PM", "CRA", "PV", "QA", "CTA", "ADMIN"] UserStatus = Literal["PENDING", "ACTIVE", "REJECTED", "DISABLED"] PASSWORD_REGEX = re.compile(r"^(?=.*[A-Za-z])(?=.*\d).{8,}$") @@ -103,5 +102,4 @@ class Token(BaseModel): class TokenPayload(BaseModel): sub: uuid.UUID - role: str exp: Optional[int] = None diff --git a/backend/app/services/document_service.py b/backend/app/services/document_service.py index 76f306a6..a63854cd 100644 --- a/backend/app/services/document_service.py +++ b/backend/app/services/document_service.py @@ -14,6 +14,7 @@ from sqlalchemy import delete as sa_delete, or_, select, update as sa_update from sqlalchemy.ext.asyncio import AsyncSession from app.core.deps import get_cra_site_scope +from app.core.deps import get_operator_role_label, is_system_admin from app.core.project_permissions import role_has_api_permission from app.crud import acknowledgement as acknowledgement_crud from app.crud import distribution as distribution_crud @@ -43,14 +44,11 @@ DOCUMENT_ACTION_PERMISSIONS = { "ack": "documents:read", "create_document": "documents:create", "create_version": "documents:update", + "distribute": "documents:update", "delete_document": "documents:delete", } -def _role_value(user) -> str: - return user.role.value if hasattr(user.role, "value") else str(user.role) - - def _audit_detail(before: dict | None, after: dict | None) -> str: payload = {"before": before, "after": after} return json.dumps(payload, ensure_ascii=True) @@ -82,7 +80,7 @@ async def _ensure_study_access(db: AsyncSession, trial_id: uuid.UUID, current_us study = await study_crud.get(db, trial_id) if not study: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="项目不存在") - if current_user.role == "ADMIN": + if is_system_admin(current_user): return None membership = await member_crud.get_member(db, trial_id, current_user.id) if not membership or not membership.is_active: @@ -98,7 +96,7 @@ async def _ensure_study_member(db: AsyncSession, trial_id: uuid.UUID, current_us study = await study_crud.get(db, trial_id) if not study: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="项目不存在") - if current_user.role == "ADMIN": + if is_system_admin(current_user): return None membership = await member_crud.get_member(db, trial_id, current_user.id) if not membership or not membership.is_active: @@ -154,7 +152,7 @@ async def create_document( action="DOCUMENT_CREATED", detail=_audit_detail(None, _doc_snapshot(doc)), operator_id=current_user.id, - operator_role=_role_value(current_user), + operator_role=await get_operator_role_label(db, doc.trial_id, current_user), ) ) await db.commit() @@ -345,7 +343,7 @@ async def create_version( action="VERSION_CREATED", detail=_audit_detail(None, _version_snapshot(version)), operator_id=current_user.id, - operator_role=_role_value(current_user), + operator_role=await get_operator_role_label(db, doc.trial_id, current_user), ) ) @@ -429,7 +427,7 @@ async def delete_version( action="VERSION_DELETED", detail=_audit_detail(before, None), operator_id=current_user.id, - operator_role=_role_value(current_user), + operator_role=await get_operator_role_label(db, doc.trial_id, current_user), ) ) await db.commit() @@ -466,7 +464,7 @@ async def delete_document( action="DOCUMENT_ARCHIVED", detail=_audit_detail(before, _doc_snapshot(doc)), operator_id=current_user.id, - operator_role=_role_value(current_user), + operator_role=await get_operator_role_label(db, doc.trial_id, current_user), ) ) await db.commit() @@ -511,7 +509,7 @@ async def create_distributions( doc = await document_crud.get(db, version.document_id) if not doc: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="文档不存在") - await _ensure_study_member(db, doc.trial_id, current_user) + await _ensure_study_access(db, doc.trial_id, current_user, action="distribute") if version.status != DocumentVersionStatus.EFFECTIVE: raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="只有生效版本可分发") @@ -536,7 +534,7 @@ async def create_distributions( action="DISTRIBUTION_CREATED", detail=_audit_detail(None, {"version_id": str(version.id), "target_id": target.target_id}), operator_id=current_user.id, - operator_role=_role_value(current_user), + operator_role=await get_operator_role_label(db, doc.trial_id, current_user), ) ) await db.commit() @@ -599,8 +597,7 @@ async def create_acknowledgement( if distribution.target_type == DistributionTargetType.USER and distribution.target_id != str(current_user.id): raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="不在分发范围内") if distribution.target_type == DistributionTargetType.ROLE: - role_value = _role_value(current_user) - if distribution.target_id not in (role_value, getattr(membership, "role_in_study", "")): + if distribution.target_id not in ("ADMIN" if is_system_admin(current_user) else "", getattr(membership, "role_in_study", "")): raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="不在分发范围内") if payload.ack_type != AcknowledgementType.RECEIVED: @@ -633,7 +630,7 @@ async def create_acknowledgement( action="ACK_CREATED", detail=_audit_detail(None, {"distribution_id": str(distribution.id), "ack_type": payload.ack_type}), operator_id=current_user.id, - operator_role=_role_value(current_user), + operator_role=await get_operator_role_label(db, doc.trial_id, current_user), ) ) await db.commit() @@ -693,9 +690,8 @@ async def list_distribution_notifications( skip: int = 0, limit: int = 20, ) -> list[NotificationItem]: - role_value = _role_value(current_user) membership = None - if role_value != "ADMIN": + if not is_system_admin(current_user): membership = await member_crud.get_member(db, study_id, current_user.id) role_in_study = membership.role_in_study if membership else None diff --git a/backend/app/services/etmf_service.py b/backend/app/services/etmf_service.py index 1fa6fdeb..704b9cd1 100644 --- a/backend/app/services/etmf_service.py +++ b/backend/app/services/etmf_service.py @@ -10,6 +10,7 @@ from sqlalchemy.ext.asyncio import AsyncSession from app.crud import document as document_crud from app.crud import etmf as etmf_crud +from app.core.deps import get_operator_role_label from app.models.document import Document, DocumentStatus from app.models.etmf import EtmfNode from app.models.audit_log import AuditLog @@ -17,12 +18,6 @@ from app.schemas.document import DocumentCreate, DocumentSummary from app.schemas.etmf import EtmfNodeCreate, EtmfNodeRead, EtmfNodeStatus, EtmfTreeNode, EtmfNodeUpdate -def _role_value(user) -> str: - if user is None: - return "SYSTEM" - return user.role.value if hasattr(user.role, "value") else str(user.role) - - def calculate_node_status(node: EtmfNode, documents: Iterable[Document]) -> EtmfNodeStatus: docs = list(documents) if not node.is_active: @@ -109,7 +104,7 @@ async def create_node(db: AsyncSession, payload: EtmfNodeCreate, current_user) - action="ETMF_NODE_CREATED", detail=f'{{"code":"{node.code}","name":"{node.name}"}}', operator_id=current_user.id, - operator_role=_role_value(current_user), + operator_role=await get_operator_role_label(db, payload.study_id, current_user), ) ) await db.commit() @@ -138,7 +133,7 @@ async def update_node(db: AsyncSession, node_id: uuid.UUID, payload: EtmfNodeUpd action="ETMF_NODE_UPDATED", detail="{}", operator_id=current_user.id, - operator_role=_role_value(current_user), + operator_role=await get_operator_role_label(db, node.study_id, current_user), ) ) await db.commit() diff --git a/backend/tests/test_admin_pm_permissions.py b/backend/tests/test_admin_pm_permissions.py index cdaf36de..1733fd7e 100644 --- a/backend/tests/test_admin_pm_permissions.py +++ b/backend/tests/test_admin_pm_permissions.py @@ -10,22 +10,22 @@ from app.api.v1.api_permissions import get_my_study_api_permissions, update_stud from app.api.v1.members import update_member from app.api.v1.permission_monitoring import resolve_monitoring_scope from app.api.v1.system_permissions import list_system_permissions -from app.core.deps import require_admin_or_any_project_pm +from app.core.deps import require_admin_or_any_project_pm, require_system_permission from app.schemas.member import StudyMemberUpdate @dataclass class UserStub: id: uuid.UUID - role: str + is_admin: bool = False -async def _seed_user(db: AsyncSession, user_id: uuid.UUID, role: str = "PM") -> None: +async def _seed_user(db: AsyncSession, user_id: uuid.UUID, *, is_admin: bool = False) -> None: await db.execute( text( """ - INSERT INTO users (id, email, password_hash, full_name, role, clinical_department, status) - VALUES (:id, :email, :password_hash, :full_name, :role, :clinical_department, :status) + INSERT INTO users (id, email, password_hash, full_name, clinical_department, is_admin, status) + VALUES (:id, :email, :password_hash, :full_name, :clinical_department, :is_admin, :status) """ ), { @@ -33,8 +33,8 @@ async def _seed_user(db: AsyncSession, user_id: uuid.UUID, role: str = "PM") -> "email": f"{user_id.hex}@example.com", "password_hash": "hash", "full_name": f"User {user_id.hex[:6]}", - "role": role, "clinical_department": "Clinical", + "is_admin": is_admin, "status": "ACTIVE", }, ) @@ -114,7 +114,7 @@ async def test_project_pm_can_view_system_permission_definitions(db_session: Asy await db_session.commit() dependency = require_admin_or_any_project_pm() - await dependency(current_user=UserStub(id=pm_id, role="PM"), db=db_session) + await dependency(current_user=UserStub(id=pm_id), db=db_session) data = await list_system_permissions() assert data["permissions"] @@ -124,14 +124,45 @@ async def test_project_pm_can_view_system_permission_definitions(db_session: Asy async def test_non_pm_cannot_view_system_permission_definitions(db_session: AsyncSession): cra_id = uuid.uuid4() study_id = uuid.uuid4() - await _seed_user(db_session, cra_id, role="CRA") + await _seed_user(db_session, cra_id) await _seed_study(db_session, study_id, "CRA-SYSTEM-PERMS") await _seed_member(db_session, study_id, cra_id, "CRA") await db_session.commit() with pytest.raises(HTTPException) as exc_info: dependency = require_admin_or_any_project_pm() - await dependency(current_user=UserStub(id=cra_id, role="CRA"), db=db_session) + await dependency(current_user=UserStub(id=cra_id), db=db_session) + + assert exc_info.value.status_code == 403 + + +@pytest.mark.asyncio +async def test_project_pm_can_open_project_permission_config_without_global_pm_role(db_session: AsyncSession): + user_id = uuid.uuid4() + study_id = uuid.uuid4() + await _seed_user(db_session, user_id) + await _seed_study(db_session, study_id, "PROJECT-PM-NO-GLOBAL-ROLE") + await _seed_member(db_session, study_id, user_id, "PM") + await db_session.commit() + + dependency = require_system_permission("system:permissions:project_config") + result = await dependency(study_id=study_id, current_user=UserStub(id=user_id), db=db_session) + + assert result.id == user_id + + +@pytest.mark.asyncio +async def test_non_project_pm_cannot_open_project_permission_config_even_with_active_membership(db_session: AsyncSession): + user_id = uuid.uuid4() + study_id = uuid.uuid4() + await _seed_user(db_session, user_id) + await _seed_study(db_session, study_id, "PROJECT-NON-PM-DENIED") + await _seed_member(db_session, study_id, user_id, "CRA") + await db_session.commit() + + dependency = require_system_permission("system:permissions:project_config") + with pytest.raises(HTTPException) as exc_info: + await dependency(study_id=study_id, current_user=UserStub(id=user_id), db=db_session) assert exc_info.value.status_code == 403 @@ -141,7 +172,7 @@ async def test_pm_permission_update_does_not_persist_admin_or_pm_overrides(db_se study_id = uuid.uuid4() admin_id = uuid.uuid4() await _seed_study(db_session, study_id, "PM-PERM-SKIP") - await _seed_user(db_session, admin_id, role="ADMIN") + await _seed_user(db_session, admin_id, is_admin=True) await db_session.commit() result = await update_study_api_permissions( @@ -151,7 +182,7 @@ async def test_pm_permission_update_does_not_persist_admin_or_pm_overrides(db_se "PM": {"subjects:delete": False}, "CRA": {"subjects:delete": True}, }, - current_user=UserStub(id=admin_id, role="ADMIN"), + current_user=UserStub(id=admin_id, is_admin=True), db=db_session, ) @@ -177,14 +208,14 @@ async def test_pm_permission_update_does_not_persist_admin_or_pm_overrides(db_se async def test_project_member_can_read_own_effective_permissions(db_session: AsyncSession): cra_id = uuid.uuid4() study_id = uuid.uuid4() - await _seed_user(db_session, cra_id, role="CRA") + await _seed_user(db_session, cra_id) await _seed_study(db_session, study_id, "CRA-MY-PERMS") await _seed_member(db_session, study_id, cra_id, "CRA") await db_session.commit() result = await get_my_study_api_permissions( study_id=study_id, - current_user=UserStub(id=cra_id, role="CRA"), + current_user=UserStub(id=cra_id), db=db_session, ) @@ -204,7 +235,7 @@ async def test_project_pm_monitoring_scope_is_limited_to_own_projects(db_session await _seed_member(db_session, own_study_id, pm_id, "PM") await db_session.commit() - scope = await resolve_monitoring_scope(db_session, UserStub(id=pm_id, role="PM")) + scope = await resolve_monitoring_scope(db_session, UserStub(id=pm_id)) assert scope.is_admin is False assert scope.study_ids == {own_study_id} @@ -229,7 +260,7 @@ async def test_project_pm_cannot_update_peer_pm_member(db_session: AsyncSession) study_id=study_id, member_id=peer_member_id, member_in=StudyMemberUpdate(role_in_study="CRA"), - current_user=UserStub(id=actor_id, role="PM"), + current_user=UserStub(id=actor_id), db=db_session, ) @@ -243,7 +274,7 @@ async def test_project_pm_cannot_grant_peer_pm_role(db_session: AsyncSession): cra_id = uuid.uuid4() await _seed_study(db_session, study_id, "PM-GRANT-PM") await _seed_user(db_session, actor_id) - await _seed_user(db_session, cra_id, role="CRA") + await _seed_user(db_session, cra_id) await _seed_member(db_session, study_id, actor_id, "PM") cra_member_id = await _seed_member_return_id(db_session, study_id, cra_id, "CRA") await db_session.commit() @@ -253,7 +284,7 @@ async def test_project_pm_cannot_grant_peer_pm_role(db_session: AsyncSession): study_id=study_id, member_id=cra_member_id, member_in=StudyMemberUpdate(role_in_study="PM"), - current_user=UserStub(id=actor_id, role="PM"), + current_user=UserStub(id=actor_id), db=db_session, ) diff --git a/backend/tests/test_api_permissions.py b/backend/tests/test_api_permissions.py index e0e2661b..7abe6e56 100644 --- a/backend/tests/test_api_permissions.py +++ b/backend/tests/test_api_permissions.py @@ -7,14 +7,13 @@ import ast from sqlalchemy import delete from sqlalchemy.ext.asyncio import AsyncSession -from app.core.api_permissions import API_ENDPOINT_PERMISSIONS, OPERATION_TO_ENDPOINTS, PROJECT_PERMISSION_ROLES +from app.core.api_permissions import API_ENDPOINT_PERMISSIONS, OPERATION_TO_ENDPOINTS, PROJECT_PERMISSION_ROLES, SYSTEM_PERMISSIONS from app.core.project_permissions import role_has_api_permission from app.core.project_permissions import get_api_endpoint_permissions, replace_api_endpoint_permissions from app.core.permission_cache import PermissionCache, set_permission_cache from app.core.permission_monitor import PermissionMonitor, set_permission_monitor from app.models.api_endpoint_permission import ApiEndpointPermission from app.models.study import Study -from app.models.user import UserRole from app.schemas.member import StudyMemberCreate @@ -183,6 +182,24 @@ def test_legacy_fee_attachment_router_is_not_registered(): assert "fees_attachments" not in router_source +def test_legacy_fee_attachment_runtime_code_is_removed(): + """旧费用附件运行时代码应删除,避免绕过模块附件权限的死代码被重新注册。""" + backend_root = Path(__file__).resolve().parents[1] / "app" + legacy_tokens = { + "fees_attachments", + "fee_attachment", + "FeeAttachment", + "fee_attachments", + } + offenders: list[str] = [] + for path in backend_root.rglob("*.py"): + source = path.read_text(encoding="utf-8") + if any(token in source or token in path.name for token in legacy_tokens): + offenders.append(str(path.relative_to(backend_root))) + + assert offenders == [] + + def test_attachment_permissions_use_module_permissions(): """附件鉴权应使用模块附件权限,不再回退通用 attachments:*。""" attachments_path = Path(__file__).resolve().parents[1] / "app" / "api" / "v1" / "attachments.py" @@ -388,12 +405,59 @@ def test_document_service_uses_specific_document_permission_keys(): assert '"create_document": "documents:create"' in source assert '"create_version": "documents:update"' in source + assert '"distribute": "documents:update"' in source assert '"delete_document": "documents:delete"' in source assert 'else "documents:update"' not in source + assert "await _ensure_study_access(db, doc.trial_id, current_user, action=\"distribute\")" in source + + +def test_document_routes_do_not_hardcode_admin_for_matrix_controlled_actions(): + """文档删除和版本删除应由项目权限矩阵控制,而不是路由层硬编码 ADMIN。""" + route_path = Path(__file__).resolve().parents[1] / "app" / "api" / "v1" / "documents.py" + source = route_path.read_text() + + delete_document_chunk = source[source.index("async def delete_document") : source.index("@router.post", source.index("async def delete_document"))] + delete_version_chunk = source[source.index("async def delete_version") : source.index("@router.post", source.index("async def delete_version"))] + + assert "require_roles" not in delete_document_chunk + assert "require_roles" not in delete_version_chunk + assert "仅管理员可删除文档" not in delete_document_chunk + assert "仅管理员可删除版本" not in delete_version_chunk + + +def test_project_permission_config_is_system_level_permission(): + """项目权限配置应归属系统级权限,不能作为项目矩阵里的自管理权限。""" + assert "permissions:read" not in API_ENDPOINT_PERMISSIONS + assert "permissions:update" not in API_ENDPOINT_PERMISSIONS + assert SYSTEM_PERMISSIONS["system:permissions:project_config"]["description"] == "配置项目接口权限" + assert "PM" in SYSTEM_PERMISSIONS["system:permissions:project_config"]["roles"] + + +def test_project_api_permission_routes_use_system_project_config_permission(): + """项目权限矩阵 API 应明确依赖 system:permissions:project_config。""" + route_path = Path(__file__).resolve().parents[1] / "app" / "api" / "v1" / "api_permissions.py" + source = route_path.read_text() + get_chunk = source[source.index("async def get_study_api_permissions") : source.index("@study_router.put")] + update_chunk = source[source.index("async def update_study_api_permissions") :] + + assert 'require_system_permission("system:permissions:project_config")' in get_chunk + assert 'require_system_permission("system:permissions:project_config")' in update_chunk + assert 'require_study_roles(["PM"])' not in get_chunk + assert 'require_study_roles(["PM"])' not in update_chunk + + +def test_audit_export_event_uses_export_permission(): + """审计导出事件应使用 audit_logs:export,而不是仅要求项目成员。""" + route_path = Path(__file__).resolve().parents[1] / "app" / "api" / "v1" / "audit_logs.py" + source = route_path.read_text() + event_chunk = source[source.index("async def create_audit_event") :] + + assert 'require_api_permission("audit_logs:export")' in event_chunk + assert "require_study_member()" not in event_chunk def test_user_role_contains_current_project_roles(): - assert {"QA", "CTA"} <= {role.value for role in UserRole} + assert {"QA", "CTA"} <= set(PROJECT_PERMISSION_ROLES) @pytest.mark.asyncio diff --git a/backend/tests/test_api_permissions_prerequisites.py b/backend/tests/test_api_permissions_prerequisites.py index 137b6740..87feef09 100644 --- a/backend/tests/test_api_permissions_prerequisites.py +++ b/backend/tests/test_api_permissions_prerequisites.py @@ -49,8 +49,8 @@ async def test_material_equipment_operations_are_grouped_under_materials_module( @pytest.mark.asyncio -async def test_materials_module_only_contains_drug_flow_and_equipment_sections(): - """测试物资管理模块只包含药品流向管理和设备管理""" +async def test_materials_module_only_contains_drug_flow_attachments_and_equipment_sections(): + """测试物资管理模块只包含药品流向、药品流向附件和设备管理""" data = await list_api_operations() operations = data["operations"] @@ -60,7 +60,7 @@ async def test_materials_module_only_contains_drug_flow_and_equipment_sections() if op["module"] == "materials" } - assert material_prefixes == {"drug_shipments", "material_equipments"} + assert material_prefixes == {"drug_shipments", "drug_shipments_attachments", "material_equipments"} @pytest.mark.asyncio @@ -90,6 +90,29 @@ def test_dashboard_summary_uses_project_overview_permission(): assert 'require_api_permission("dashboard:read")' not in source +def test_project_overview_dashboard_endpoints_require_project_overview_permission(): + """项目总览聚合接口应统一使用 project_overview:read,而不是仅要求项目成员。""" + source = Path(__file__).parents[1].joinpath("app/api/v1/dashboard.py").read_text() + progress_chunk = source[source.index("async def get_progress") : source.index("@router.get", source.index("async def get_progress"))] + lost_visits_chunk = source[source.index("async def list_lost_visits") : source.index("@router.get", source.index("async def list_lost_visits"))] + center_summary_chunk = source[source.index("async def get_center_summary") :] + + assert 'require_api_permission("project_overview:read")' in progress_chunk + assert 'require_api_permission("project_overview:read")' in lost_visits_chunk + assert 'require_api_permission("project_overview:read")' in center_summary_chunk + assert "require_study_member()" not in progress_chunk + assert "require_study_member()" not in lost_visits_chunk + + +def test_finance_summary_requires_contract_fee_read_permission(): + """项目首页财务汇总应使用费用读取权限,避免绕过费用模块权限。""" + source = Path(__file__).parents[1].joinpath("app/api/v1/finance_dashboard.py").read_text() + summary_chunk = source[source.index("async def finance_summary") :] + + assert 'require_api_permission("fees_contracts:read")' in summary_chunk + assert "require_study_member()" not in summary_chunk + + @pytest.mark.asyncio async def test_list_operation_prerequisites_endpoint(): """测试获取所有操作的前置权限依赖""" diff --git a/backend/tests/test_etmf_nodes.py b/backend/tests/test_etmf_nodes.py index b9e81554..d0ffcc8b 100644 --- a/backend/tests/test_etmf_nodes.py +++ b/backend/tests/test_etmf_nodes.py @@ -12,13 +12,13 @@ from app.models.document import Document, DocumentScopeType from app.models.document_version import DocumentVersion # noqa: F401 from app.models.distribution import Distribution # noqa: F401 from app.models.acknowledgement import Acknowledgement # noqa: F401 -from app.models.user import UserRole, UserStatus +from app.models.user import UserStatus class UserStub: def __init__(self) -> None: self.id = uuid.uuid4() - self.role = UserRole.ADMIN + self.is_admin = True self.status = UserStatus.ACTIVE diff --git a/backend/tests/test_permission_monitoring_api.py b/backend/tests/test_permission_monitoring_api.py index 404c1a31..ec650f97 100644 --- a/backend/tests/test_permission_monitoring_api.py +++ b/backend/tests/test_permission_monitoring_api.py @@ -18,6 +18,10 @@ class FakeIpInfo: self.location = f"中国 / {province} / {city} / {isp}" +class AdminUserStub: + is_admin = True + + async def _seed_permission_log(db_session, study_id: uuid.UUID, user_id: uuid.UUID, *, allowed: bool, elapsed_ms: float) -> None: study_exists = ( await db_session.execute(text("SELECT id FROM studies WHERE id = :id"), {"id": str(study_id)}) @@ -48,8 +52,8 @@ async def _seed_permission_log(db_session, study_id: uuid.UUID, user_id: uuid.UU await db_session.execute( text( """ - INSERT INTO users (id, email, password_hash, full_name, role, clinical_department, status) - VALUES (:id, :email, :password_hash, :full_name, :role, :clinical_department, :status) + INSERT INTO users (id, email, password_hash, full_name, clinical_department, is_admin, status) + VALUES (:id, :email, :password_hash, :full_name, :clinical_department, :is_admin, :status) """ ), { @@ -57,8 +61,8 @@ async def _seed_permission_log(db_session, study_id: uuid.UUID, user_id: uuid.UU "email": f"{user_id.hex}@example.com", "password_hash": "hash", "full_name": "Permission Monitoring User", - "role": "PM", "clinical_department": "临床运营", + "is_admin": False, "status": "ACTIVE", }, ) @@ -97,7 +101,7 @@ async def test_get_permission_metrics(db_session): await _seed_permission_log(db_session, study_id, user_id, allowed=True, elapsed_ms=5) await _seed_permission_log(db_session, study_id, user_id, allowed=False, elapsed_ms=3) - data = await permission_monitoring.get_permission_metrics(db=db_session, _=object(), hours=24) + data = await permission_monitoring.get_permission_metrics(db=db_session, _=AdminUserStub(), hours=24) assert "check_metrics" in data assert "cache_metrics" in data assert data["check_metrics"]["total_checks"] == 2 @@ -115,7 +119,7 @@ async def test_get_cache_statistics(db_session): monitor.record_cache_hit() monitor.record_cache_miss() - data = await permission_monitoring.get_cache_statistics(_=object(), db=db_session) + data = await permission_monitoring.get_cache_statistics(_=AdminUserStub(), db=db_session) assert "cache_metrics" in data assert data["cache_metrics"]["total_accesses"] == 3 assert data["cache_metrics"]["cache_hits"] == 2 @@ -129,7 +133,7 @@ async def test_get_alerts(db_session): monitor.record_slow_check_alert(100) - data = await permission_monitoring.get_alerts(_=object(), db=db_session) + data = await permission_monitoring.get_alerts(_=AdminUserStub(), db=db_session) assert "alerts" in data assert data["total"] > 0 @@ -143,7 +147,7 @@ async def test_get_alerts_with_level_filter(db_session): monitor.record_slow_check_alert(100) monitor.record_error_alert(ValueError("test error")) - data = await permission_monitoring.get_alerts(level="warning", _=object(), db=db_session) + data = await permission_monitoring.get_alerts(level="warning", _=AdminUserStub(), db=db_session) assert "alerts" in data assert all(alert["level"] == "warning" for alert in data["alerts"]) @@ -157,7 +161,7 @@ async def test_get_alerts_with_limit(db_session): for _ in range(20): monitor.record_slow_check_alert(100) - data = await permission_monitoring.get_alerts(limit=5, _=object(), db=db_session) + data = await permission_monitoring.get_alerts(limit=5, _=AdminUserStub(), db=db_session) assert len(data["alerts"]) <= 5 @@ -170,7 +174,7 @@ async def test_reset_metrics(db_session): monitor.record_cache_hit() assert monitor.metrics.cache_metrics.total_accesses == 1 - result = await permission_monitoring.reset_metrics(_=object(), db=db_session) + result = await permission_monitoring.reset_metrics(_=AdminUserStub(), db=db_session) assert result["message"] == "指标已重置" assert monitor.metrics.cache_metrics.total_accesses == 0 @@ -185,7 +189,7 @@ async def test_clear_alerts(db_session): monitor.record_slow_check_alert(100) assert len(monitor.get_alerts()) > 0 - result = await permission_monitoring.clear_alerts(_=object(), db=db_session) + result = await permission_monitoring.clear_alerts(_=AdminUserStub(), db=db_session) assert result["message"] == "告警已清除" assert len(monitor.get_alerts()) == 0 @@ -200,7 +204,7 @@ async def test_permission_system_health_healthy(db_session): for _ in range(100): monitor.record_cache_hit() - data = await permission_monitoring.permission_system_health(db=db_session, _=object()) + data = await permission_monitoring.permission_system_health(db=db_session, _=AdminUserStub()) assert data["status"] == "healthy" assert data["health_score"] >= 80 @@ -218,7 +222,7 @@ async def test_permission_system_health_degraded(db_session): for _ in range(100): monitor.record_cache_miss() - data = await permission_monitoring.permission_system_health(db=db_session, _=object()) + data = await permission_monitoring.permission_system_health(db=db_session, _=AdminUserStub()) assert "status" in data assert "health_score" in data assert "issues" in data @@ -233,7 +237,7 @@ async def test_permission_system_health_includes_metrics(db_session): monitor = PermissionMonitor() set_permission_monitor(monitor) - data = await permission_monitoring.permission_system_health(db=db_session, _=object()) + data = await permission_monitoring.permission_system_health(db=db_session, _=AdminUserStub()) assert "last_hour" in data assert "cache_stats" in data assert "total_checks" in data["last_hour"] @@ -271,8 +275,8 @@ async def test_ip_locations_counts_unique_users_per_location(db_session, monkeyp await db_session.execute( text( """ - INSERT INTO users (id, email, password_hash, full_name, role, clinical_department, status) - VALUES (:id, :email, :password_hash, :full_name, :role, :clinical_department, :status) + INSERT INTO users (id, email, password_hash, full_name, clinical_department, is_admin, status) + VALUES (:id, :email, :password_hash, :full_name, :clinical_department, :is_admin, :status) """ ), { @@ -280,8 +284,8 @@ async def test_ip_locations_counts_unique_users_per_location(db_session, monkeyp "email": email, "password_hash": "hash", "full_name": email, - "role": "PM", "clinical_department": "临床运营", + "is_admin": False, "status": "ACTIVE", }, ) @@ -323,7 +327,7 @@ async def test_ip_locations_counts_unique_users_per_location(db_session, monkeyp lambda ip: FakeIpInfo("广东省", "深圳市"), ) - result = await permission_monitoring.get_ip_locations(db=db_session, _=object(), days=7, limit=10) + result = await permission_monitoring.get_ip_locations(db=db_session, _=AdminUserStub(), days=7, limit=10) assert result["items"][0]["province"] == "广东省" assert result["items"][0]["total_count"] == 3 @@ -363,8 +367,8 @@ async def test_ip_locations_merges_same_region_with_different_isp(db_session, mo await db_session.execute( text( """ - INSERT INTO users (id, email, password_hash, full_name, role, clinical_department, status) - VALUES (:id, :email, :password_hash, :full_name, :role, :clinical_department, :status) + INSERT INTO users (id, email, password_hash, full_name, clinical_department, is_admin, status) + VALUES (:id, :email, :password_hash, :full_name, :clinical_department, :is_admin, :status) """ ), { @@ -372,8 +376,8 @@ async def test_ip_locations_merges_same_region_with_different_isp(db_session, mo "email": "region-merge@example.com", "password_hash": "hash", "full_name": "属地合并用户", - "role": "PM", "clinical_department": "临床运营", + "is_admin": False, "status": "ACTIVE", }, ) @@ -410,7 +414,7 @@ async def test_ip_locations_merges_same_region_with_different_isp(db_session, mo lambda ip: ip_info_by_address[ip], ) - result = await permission_monitoring.get_ip_locations(db=db_session, _=object(), days=7, limit=10) + result = await permission_monitoring.get_ip_locations(db=db_session, _=AdminUserStub(), days=7, limit=10) assert len(result["items"]) == 1 assert result["items"][0]["province"] == "重庆" @@ -448,8 +452,8 @@ async def test_ip_locations_keeps_private_network_location_label(db_session, mon await db_session.execute( text( """ - INSERT INTO users (id, email, password_hash, full_name, role, clinical_department, status) - VALUES (:id, :email, :password_hash, :full_name, :role, :clinical_department, :status) + INSERT INTO users (id, email, password_hash, full_name, clinical_department, is_admin, status) + VALUES (:id, :email, :password_hash, :full_name, :clinical_department, :is_admin, :status) """ ), { @@ -457,8 +461,8 @@ async def test_ip_locations_keeps_private_network_location_label(db_session, mon "email": "private-network@example.com", "password_hash": "hash", "full_name": "局域网用户", - "role": "PM", "clinical_department": "临床运营", + "is_admin": False, "status": "ACTIVE", }, ) @@ -484,7 +488,7 @@ async def test_ip_locations_keeps_private_network_location_label(db_session, mon ) await db_session.commit() - result = await permission_monitoring.get_ip_locations(db=db_session, _=object(), days=7, limit=10) + result = await permission_monitoring.get_ip_locations(db=db_session, _=AdminUserStub(), days=7, limit=10) assert result["items"][0]["location"] == "局域网" assert result["items"][0]["province"] == "" @@ -525,8 +529,8 @@ async def test_access_logs_include_user_behavior_summary(db_session): await db_session.execute( text( """ - INSERT INTO users (id, email, password_hash, full_name, role, clinical_department, status) - VALUES (:id, :email, :password_hash, :full_name, :role, :clinical_department, :status) + INSERT INTO users (id, email, password_hash, full_name, clinical_department, is_admin, status) + VALUES (:id, :email, :password_hash, :full_name, :clinical_department, :is_admin, :status) """ ), { @@ -534,8 +538,8 @@ async def test_access_logs_include_user_behavior_summary(db_session): "email": email, "password_hash": "hash", "full_name": name, - "role": role, "clinical_department": "临床运营", + "is_admin": False, "status": "ACTIVE", }, ) @@ -573,7 +577,7 @@ async def test_access_logs_include_user_behavior_summary(db_session): result = await permission_monitoring.get_access_logs( db=db_session, - _=object(), + _=AdminUserStub(), study_id=None, user_id=None, endpoint_key=None, @@ -621,7 +625,7 @@ async def test_security_access_logs_include_anonymous_ip_attempts(db_session): result = await permission_monitoring.get_security_access_logs( db=db_session, - _=object(), + _=AdminUserStub(), status_min=400, auth_status=None, page=1, diff --git a/backend/tests/test_protected_admin.py b/backend/tests/test_protected_admin.py index 62ea3382..cf822c86 100644 --- a/backend/tests/test_protected_admin.py +++ b/backend/tests/test_protected_admin.py @@ -1,7 +1,7 @@ import pytest from app.crud import user as user_crud -from app.models.user import UserRole, UserStatus +from app.models.user import UserStatus class _ScalarResult: @@ -37,6 +37,6 @@ async def test_ensure_admin_exists_creates_protected_admin(): assert len(session.added) == 1 admin = session.added[0] assert admin.email == "admin@huapont.cn" - assert admin.role == UserRole.ADMIN + assert admin.is_admin is True assert admin.status == UserStatus.ACTIVE assert session.commit_count == 1 diff --git a/backend/tests/test_rbac_project_role.py b/backend/tests/test_rbac_project_role.py index 90ce3a47..c35d6fb4 100644 --- a/backend/tests/test_rbac_project_role.py +++ b/backend/tests/test_rbac_project_role.py @@ -5,7 +5,7 @@ from app.core import rbac @dataclass class UserStub: - role: str + is_admin: bool = False @dataclass @@ -14,12 +14,12 @@ class MemberStub: def test_non_admin_global_role_does_not_grant_project_document_permission(): - assert not rbac.is_allowed("delete_document", UserStub(role="PM"), MemberStub(role_in_study="CRA")) + assert not rbac.is_allowed("delete_document", UserStub(), MemberStub(role_in_study="CRA")) def test_project_role_grants_project_document_permission(): - assert rbac.is_allowed("delete_document", UserStub(role="CRA"), MemberStub(role_in_study="PM")) + assert rbac.is_allowed("delete_document", UserStub(), MemberStub(role_in_study="PM")) def test_global_admin_keeps_system_document_permission_without_membership(): - assert rbac.is_allowed("delete_document", UserStub(role="ADMIN"), None) + assert rbac.is_allowed("delete_document", UserStub(is_admin=True), None) diff --git a/backend/tests/test_registration.py b/backend/tests/test_registration.py index 8e72bfc9..aaf2b347 100644 --- a/backend/tests/test_registration.py +++ b/backend/tests/test_registration.py @@ -19,7 +19,7 @@ from app.crud import user as user_crud from app.db.base_class import Base from tests.conftest import GUID from app.models.study_member import StudyMember -from app.models.user import User, UserRole, UserStatus +from app.models.user import User, UserStatus from app.schemas.user import UserRegisterRequest TEST_DATABASE_URL = "sqlite+aiosqlite:///:memory:" @@ -99,7 +99,7 @@ async def client_and_db(): password_hash=hash_password("admin123"), full_name="Admin", clinical_department="Admin", - role=UserRole.ADMIN, + is_admin=True, status=UserStatus.ACTIVE, ) session.add(admin) @@ -128,7 +128,7 @@ async def test_register_creates_pending_user(client_and_db): user = await user_crud.get_by_email(session, payload["email"]) assert user is not None assert user.status == UserStatus.PENDING - assert user.role == UserRole.PV + assert user.is_admin is False member_rows = ( await session.execute(select(StudyMember).where(StudyMember.user_id == user.id)) ).scalars().all() diff --git a/backend/tests/test_study_role_context.py b/backend/tests/test_study_role_context.py index a27563f9..313ec1d0 100644 --- a/backend/tests/test_study_role_context.py +++ b/backend/tests/test_study_role_context.py @@ -6,17 +6,17 @@ import pytest from app.api.v1 import studies as studies_api from app.models.study import Study from app.models.study_member import StudyMember -from app.models.user import User, UserRole, UserStatus +from app.models.user import User, UserStatus -def _make_user(role: UserRole = UserRole.CRA) -> User: +def _make_user(*, is_admin: bool = False) -> User: return User( id=uuid.uuid4(), email="cra-pm@test.com", password_hash="hashed", full_name="CRA PM", clinical_department="Clinical", - role=role, + is_admin=is_admin, status=UserStatus.ACTIVE, ) @@ -36,7 +36,7 @@ def _make_study() -> Study: @pytest.mark.asyncio async def test_list_studies_returns_project_role_for_non_admin(monkeypatch): - current_user = _make_user(UserRole.CRA) + current_user = _make_user() study = _make_study() member = StudyMember( id=uuid.uuid4(), @@ -62,7 +62,7 @@ async def test_list_studies_returns_project_role_for_non_admin(monkeypatch): @pytest.mark.asyncio async def test_list_studies_does_not_map_system_admin_to_project_admin(monkeypatch): - current_user = _make_user(UserRole.ADMIN) + current_user = _make_user(is_admin=True) study = _make_study() async def fake_list_studies(_db, skip=0, limit=100): diff --git a/database/init.sql b/database/init.sql index 18fe3a06..4d3c374c 100644 --- a/database/init.sql +++ b/database/init.sql @@ -1,12 +1,5 @@ -- Idempotent schema for CTMS (derived from current SQLAlchemy models) -DO $$ -BEGIN - CREATE TYPE public.user_role AS ENUM ('ADMIN', 'PM', 'CRA', 'PV', 'IMP', 'MEDICAL_REVIEW'); -EXCEPTION - WHEN duplicate_object THEN null; -END $$; - DO $$ BEGIN CREATE TYPE public.user_status AS ENUM ('PENDING', 'ACTIVE', 'REJECTED', 'DISABLED'); @@ -19,8 +12,8 @@ CREATE TABLE IF NOT EXISTS public.users ( email character varying(255) NOT NULL, password_hash character varying(255) NOT NULL, full_name character varying(255) NOT NULL, - role public.user_role NOT NULL, clinical_department character varying(255) NOT NULL, + is_admin boolean NOT NULL DEFAULT false, status public.user_status NOT NULL DEFAULT 'PENDING', created_at timestamp with time zone NOT NULL DEFAULT now(), updated_at timestamp with time zone NOT NULL DEFAULT now(), @@ -528,18 +521,18 @@ CREATE INDEX IF NOT EXISTS ix_faq_replies_study_id ON public.faq_replies (study_ -- DEMO SEED DATA (idempotent) -- ========================================= INSERT INTO public.users ( - id, email, password_hash, full_name, role, clinical_department, status, created_at, updated_at + id, email, password_hash, full_name, clinical_department, is_admin, status, created_at, updated_at ) VALUES - ('11111111-1111-1111-1111-111111111111', 'admin@example.com', '$2y$12$FMfyBt7YfZWeh/x8WMEIA.S6VUTIMryk2NxWHyOOuJvd6YrDwHAdu', 'System Admin', 'ADMIN', 'SYSTEM', 'ACTIVE', '2025-01-05 08:00:00+00', '2025-01-05 08:00:00+00'), - ('22222222-2222-2222-2222-222222222222', 'pm@example.com', '$2y$12$P9UDKu48ru84uVrlquqhXufmw/CfMEzQosC0X8eT2EmY/PgM/03j2', '项目经理', 'PM', 'PMO', 'ACTIVE', '2025-01-05 08:10:00+00', '2025-01-05 08:10:00+00'), - ('33333333-3333-3333-3333-333333333333', 'cra@example.com', '$2y$12$D/.ZeDrUByPZFPnAl9MnKuzKn2G4ctKQ9IAS/CqS2sfMdE/5NaKAK', 'CRA 用户', 'CRA', 'CRA', 'ACTIVE', '2025-01-05 08:20:00+00', '2025-01-05 08:20:00+00'), - ('55555555-5555-5555-5555-555555555555', 'pv@example.com', '$2y$12$P9UDKu48ru84uVrlquqhXufmw/CfMEzQosC0X8eT2EmY/PgM/03j2', 'PV 用户', 'PV', 'PV', 'ACTIVE', '2025-01-05 08:40:00+00', '2025-01-05 08:40:00+00'), - ('66666666-6666-6666-6666-666666666666', 'imp@example.com', '$2y$12$P9UDKu48ru84uVrlquqhXufmw/CfMEzQosC0X8eT2EmY/PgM/03j2', '药品管理员', 'IMP', 'IMP', 'ACTIVE', '2025-01-05 08:50:00+00', '2025-01-05 08:50:00+00') + ('11111111-1111-1111-1111-111111111111', 'admin@example.com', '$2y$12$FMfyBt7YfZWeh/x8WMEIA.S6VUTIMryk2NxWHyOOuJvd6YrDwHAdu', 'System Admin', 'SYSTEM', true, 'ACTIVE', '2025-01-05 08:00:00+00', '2025-01-05 08:00:00+00'), + ('22222222-2222-2222-2222-222222222222', 'pm@example.com', '$2y$12$P9UDKu48ru84uVrlquqhXufmw/CfMEzQosC0X8eT2EmY/PgM/03j2', '项目经理', 'PMO', false, 'ACTIVE', '2025-01-05 08:10:00+00', '2025-01-05 08:10:00+00'), + ('33333333-3333-3333-3333-333333333333', 'cra@example.com', '$2y$12$D/.ZeDrUByPZFPnAl9MnKuzKn2G4ctKQ9IAS/CqS2sfMdE/5NaKAK', 'CRA 用户', 'CRA', false, 'ACTIVE', '2025-01-05 08:20:00+00', '2025-01-05 08:20:00+00'), + ('55555555-5555-5555-5555-555555555555', 'pv@example.com', '$2y$12$P9UDKu48ru84uVrlquqhXufmw/CfMEzQosC0X8eT2EmY/PgM/03j2', 'PV 用户', 'PV', false, 'ACTIVE', '2025-01-05 08:40:00+00', '2025-01-05 08:40:00+00'), + ('66666666-6666-6666-6666-666666666666', 'imp@example.com', '$2y$12$P9UDKu48ru84uVrlquqhXufmw/CfMEzQosC0X8eT2EmY/PgM/03j2', '药品管理员', 'IMP', false, 'ACTIVE', '2025-01-05 08:50:00+00', '2025-01-05 08:50:00+00') ON CONFLICT (email) DO UPDATE SET password_hash = EXCLUDED.password_hash, full_name = EXCLUDED.full_name, - role = EXCLUDED.role, clinical_department = EXCLUDED.clinical_department, + is_admin = EXCLUDED.is_admin, status = EXCLUDED.status, updated_at = EXCLUDED.updated_at; diff --git a/frontend/src/router.test.ts b/frontend/src/router.test.ts index 9969a660..83c07614 100644 --- a/frontend/src/router.test.ts +++ b/frontend/src/router.test.ts @@ -26,6 +26,19 @@ describe("admin project route permissions", () => { expect(source).toContain("studyStore.currentPermissions?.[role]?.[operationKey]"); }); + it("guards project permission configuration with the system-level project config permission", () => { + const source = readRouter(); + const projectPermissionRouteStart = source.indexOf('name: "AdminPermissionsProject"'); + const projectPermissionRouteEnd = source.indexOf('name: "AdminPermissionsMonitoring"', projectPermissionRouteStart); + const projectPermissionRoute = source.slice(projectPermissionRouteStart, projectPermissionRouteEnd); + + expect(source).toContain('const SYSTEM_PERMISSION_PROJECT_CONFIG = "system:permissions:project_config";'); + expect(projectPermissionRoute).toContain("systemPermission: SYSTEM_PERMISSION_PROJECT_CONFIG"); + expect(projectPermissionRoute).not.toContain("requiresProjectPm: true"); + expect(source).toContain("to.meta.systemPermission"); + expect(source).toContain("hasSystemPermissionAccess(to.meta.systemPermission as string, isAdmin)"); + }); + it("keeps login landing independent from PM management backend access", () => { const source = readRouter(); diff --git a/frontend/src/router/index.ts b/frontend/src/router/index.ts index 072bd932..8ba70d57 100644 --- a/frontend/src/router/index.ts +++ b/frontend/src/router/index.ts @@ -57,6 +57,10 @@ import SubjectForm from "../views/subjects/SubjectForm.vue"; import SubjectDetail from "../views/subjects/SubjectDetail.vue"; import { TEXT } from "../locales"; +const SYSTEM_PERMISSION_READ = "system:permissions:read"; +const SYSTEM_PERMISSION_PROJECT_CONFIG = "system:permissions:project_config"; +const SYSTEM_PERMISSION_MONITORING_METRICS = "system:monitoring:metrics"; + const routes: RouteRecordRaw[] = [ { path: "/login", @@ -414,19 +418,19 @@ const routes: RouteRecordRaw[] = [ path: "permissions/system", name: "AdminPermissionsSystem", component: PermissionManagement, - meta: { title: "系统级权限", requiresProjectPm: true }, + meta: { title: "系统级权限", systemPermission: SYSTEM_PERMISSION_READ }, }, { path: "permissions/project", name: "AdminPermissionsProject", component: PermissionManagement, - meta: { title: "项目权限配置", requiresProjectPm: true }, + meta: { title: "项目权限配置", systemPermission: SYSTEM_PERMISSION_PROJECT_CONFIG }, }, { path: "permissions/monitoring", name: "AdminPermissionsMonitoring", component: PermissionManagement, - meta: { title: "权限监控", requiresProjectPm: true }, + meta: { title: "权限监控", systemPermission: SYSTEM_PERMISSION_MONITORING_METRICS }, }, ], }, @@ -442,11 +446,30 @@ const router = createRouter({ }); const ADMIN_PROJECT_OPERATION_KEYS: Record> = { - audit_export: { read: "audit_logs:read", write: "audit_logs:read" }, + audit_export: { read: "audit_logs:read", write: "audit_logs:export" }, project_members: { read: "project_members:list", write: "project_members:update" }, sites: { read: "sites:read", write: "sites:update" }, }; +const SYSTEM_PERMISSION_ROLES: Record = { + [SYSTEM_PERMISSION_READ]: ["ADMIN", "PM"], + [SYSTEM_PERMISSION_PROJECT_CONFIG]: ["ADMIN", "PM"], + [SYSTEM_PERMISSION_MONITORING_METRICS]: ["ADMIN", "PM"], +}; + +const hasSystemPermissionAccess = async (permissionKey: string, isAdmin: boolean) => { + const roles = SYSTEM_PERMISSION_ROLES[permissionKey] || []; + if (isAdmin) return roles.includes("ADMIN"); + if (!roles.includes("PM")) return false; + + const studyStore = useStudyStore(); + const hasPmProject = studyStore.currentStudyRole === "PM" || (studyStore.currentStudy as any)?.role_in_study === "PM"; + if (!hasPmProject) { + await studyStore.ensureDefaultPmStudy().catch(() => {}); + } + return studyStore.currentStudyRole === "PM" || (studyStore.currentStudy as any)?.role_in_study === "PM"; +}; + const ensureAdminProjectAccess = async (to: any, isAdmin: boolean) => { if (isAdmin) return true; const studyStore = useStudyStore(); @@ -548,6 +571,13 @@ router.beforeEach(async (to, _from, next) => { return; } } + if (to.meta.systemPermission) { + const allowed = await hasSystemPermissionAccess(to.meta.systemPermission as string, isAdmin); + if (!allowed) { + next({ path: isAdmin ? "/admin/users" : "/admin/projects" }); + return; + } + } if (to.meta.requiresProjectPm && !isAdmin) { const hasPmProject = studyStore.currentStudyRole === "PM" || (studyStore.currentStudy as any)?.role_in_study === "PM"; if (!hasPmProject) { diff --git a/frontend/src/types/api.ts b/frontend/src/types/api.ts index 506b9800..0ee15b06 100644 --- a/frontend/src/types/api.ts +++ b/frontend/src/types/api.ts @@ -37,7 +37,6 @@ export interface LoginKeyResponse { expires_at: string; } -export type UserRole = "PM" | "CRA" | "PV" | "QA" | "CTA" | "ADMIN"; export type UserStatus = "PENDING" | "ACTIVE" | "REJECTED" | "DISABLED"; export interface UserInfo { diff --git a/frontend/src/utils/permission.test.ts b/frontend/src/utils/permission.test.ts index d831d8c2..8a9478b7 100644 --- a/frontend/src/utils/permission.test.ts +++ b/frontend/src/utils/permission.test.ts @@ -18,6 +18,11 @@ describe("permission project role model", () => { expect(source).toContain('"fees.contract.create": "fees_contracts:create"'); expect(source).toContain('"fees.contract.update": "fees_contracts:update"'); expect(source).toContain('"fees.contract.delete": "fees_contracts:delete"'); + expect(source).toContain('"project.members.list": "project_members:list"'); + expect(source).toContain('"project.members.candidates": "project_members:candidates"'); + expect(source).toContain('"project.members.create": "project_members:create"'); + expect(source).toContain('"project.members.update": "project_members:update"'); + expect(source).toContain('"project.members.delete": "project_members:delete"'); expect(source).toContain('"documents.create": "documents:create"'); expect(source).toContain('"documents.delete": "documents:delete"'); expect(source).toContain("isApiPermissionAllowed"); @@ -26,5 +31,6 @@ describe("permission project role model", () => { expect(source).not.toContain('"faq.edit": "etmf'); expect(source).not.toContain('"ADMIN", "PM"'); expect(source).not.toContain('"fees.contract.write": "fees_contracts:create"'); + expect(source).not.toContain("project.members.manage"); }); }); diff --git a/frontend/src/utils/permission.ts b/frontend/src/utils/permission.ts index d217a34b..24820e6b 100644 --- a/frontend/src/utils/permission.ts +++ b/frontend/src/utils/permission.ts @@ -15,13 +15,19 @@ const PERMISSIONS: Record = { "faq.edit": ["ADMIN"], "faq.create": ["ADMIN"], "faq.reply": ["ADMIN"], - "project.members.manage": ["ADMIN"], + "project.members.list": ["ADMIN"], + "project.members.candidates": ["ADMIN"], + "project.members.create": ["ADMIN"], + "project.members.update": ["ADMIN"], + "project.members.delete": ["ADMIN"], "site.manage": ["ADMIN"], "site.cra.bind": ["ADMIN"], "fees.contract.create": ["ADMIN"], "fees.contract.update": ["ADMIN"], "fees.contract.delete": ["ADMIN"], + "documents.read": ["ADMIN"], "documents.create": ["ADMIN"], + "documents.update": ["ADMIN"], "documents.delete": ["ADMIN"], }; @@ -33,13 +39,19 @@ const REASONS: Record = { "faq.edit": TEXT.modules.permissions.faqEdit, "faq.create": TEXT.modules.permissions.faqCreate, "faq.reply": TEXT.modules.permissions.faqReply, - "project.members.manage": TEXT.modules.permissions.projectMembersManage, + "project.members.list": TEXT.modules.permissions.projectMembersManage, + "project.members.candidates": TEXT.modules.permissions.projectMembersManage, + "project.members.create": TEXT.modules.permissions.projectMembersManage, + "project.members.update": TEXT.modules.permissions.projectMembersManage, + "project.members.delete": TEXT.modules.permissions.projectMembersManage, "site.manage": TEXT.modules.permissions.siteManage, "site.cra.bind": TEXT.modules.permissions.siteCraBind, "fees.contract.create": TEXT.modules.permissions.feeContractsWrite, "fees.contract.update": TEXT.modules.permissions.feeContractsWrite, "fees.contract.delete": TEXT.modules.permissions.feeContractsWrite, + "documents.read": TEXT.modules.permissions.default, "documents.create": TEXT.modules.permissions.default, + "documents.update": TEXT.modules.permissions.default, "documents.delete": TEXT.modules.permissions.default, }; @@ -67,13 +79,19 @@ export const usePermission = () => { "faq.reply": "faq_reply:create", "precautions.read": "precautions:read", "precautions.write": "precautions:create", - "project.members.manage": "project_members:update", + "project.members.list": "project_members:list", + "project.members.candidates": "project_members:candidates", + "project.members.create": "project_members:create", + "project.members.update": "project_members:update", + "project.members.delete": "project_members:delete", "site.manage": "sites:update", "site.cra.bind": "sites:update", "fees.contract.create": "fees_contracts:create", "fees.contract.update": "fees_contracts:update", "fees.contract.delete": "fees_contracts:delete", + "documents.read": "documents:read", "documents.create": "documents:create", + "documents.update": "documents:update", "documents.delete": "documents:delete", "audit.export.read": "audit_logs:read", }; diff --git a/frontend/src/views/admin/AuditLogs.test.ts b/frontend/src/views/admin/AuditLogs.test.ts index b4ed455a..aaac54be 100644 --- a/frontend/src/views/admin/AuditLogs.test.ts +++ b/frontend/src/views/admin/AuditLogs.test.ts @@ -15,8 +15,10 @@ describe("audit logs access", () => { expect(source).toContain('projectRole.value === "PM"'); expect(source).toContain("isApiPermissionAllowed"); expect(source).toContain('permissionMatrix.value?.[projectRole.value]?.["audit_logs:read"]'); - expect(source).not.toContain('const role = auth.user?.role'); - expect(source).not.toContain('role !== "ADMIN"'); + expect(source).toContain('permissionMatrix.value?.[projectRole.value]?.["audit_logs:export"]'); + expect(source).toContain('if (scope === "project" && !canProjectExport.value) return;'); + expect(source).not.toMatch(/auth\.user\?\.role/); + expect(source).not.toContain('!== "ADMIN"'); }); it("does not expose legacy startup ethics business labels", () => { diff --git a/frontend/src/views/admin/AuditLogs.vue b/frontend/src/views/admin/AuditLogs.vue index 2c2011ae..4ee51dac 100644 --- a/frontend/src/views/admin/AuditLogs.vue +++ b/frontend/src/views/admin/AuditLogs.vue @@ -328,7 +328,7 @@ const isAdmin = computed(() => isSystemAdmin(auth.user)); const projectRole = computed(() => getProjectRole(study.currentStudy, study.currentStudyRole)); const canProjectExport = computed(() => { if (isAdmin.value) return true; - return projectRole.value === "PM" && isApiPermissionAllowed(permissionMatrix.value?.[projectRole.value]?.["audit_logs:read"]); + return projectRole.value === "PM" && isApiPermissionAllowed(permissionMatrix.value?.[projectRole.value]?.["audit_logs:export"]); }); const canAccessAuditLogs = computed(() => { if (isAdmin.value) return true; @@ -510,6 +510,7 @@ const handleExportCommand = (command: string) => { const confirmExport = async (scope: "system" | "project") => { const currentStudy = study.currentStudy; if (!currentStudy) return; + if (scope === "project" && !canProjectExport.value) return; const ok = await ElMessageBox.confirm( TEXT.modules.adminAuditLogs.exportConfirm, TEXT.modules.adminAuditLogs.exportConfirmTitle, diff --git a/frontend/src/views/admin/PermissionManagement.test.ts b/frontend/src/views/admin/PermissionManagement.test.ts index dae7bd4c..efb0e7f0 100644 --- a/frontend/src/views/admin/PermissionManagement.test.ts +++ b/frontend/src/views/admin/PermissionManagement.test.ts @@ -108,6 +108,20 @@ describe("permission management custom roles", () => { expect(source).toContain("await addMember(selectedStudyId.value, addMemberForm);"); }); + it("uses granular project member permissions for member management actions", () => { + const source = readSource(); + + expect(source).toContain('selectedProjectPermissionAllowed("project_members:list")'); + expect(source).toContain('selectedProjectPermissionAllowed("project_members:candidates")'); + expect(source).toContain('selectedProjectPermissionAllowed("project_members:create")'); + expect(source).toContain('selectedProjectPermissionAllowed("project_members:update")'); + expect(source).toContain('selectedProjectPermissionAllowed("project_members:delete")'); + expect(source).toContain('v-if="canAddProjectMember"'); + expect(source).toContain(":disabled=\"!canUpdateProjectMember(row)"); + expect(source).toContain(":disabled=\"!canDeleteProjectMember(row)\""); + expect(source).not.toContain("project.members.manage"); + }); + it("uses QA and CTA as preset project permission role keys", () => { const source = readSource(); @@ -173,13 +187,17 @@ describe("permission management custom roles", () => { expect(source).not.toContain('subjects: "参与者基础信息"'); }); - it("shows startup ethics permissions in role editor with business section names", () => { + it("shows startup ethics permissions in role editor with two business section names", () => { const source = readSource(); const moduleSource = readProjectPermissionModulesSource(); expect(moduleSource).toContain('startup_ethics: "立项与伦理"'); - expect(source).toContain('startup_initiation: "立项记录"'); - expect(source).toContain('startup_ethics: "伦理记录"'); + expect(source).toContain('startup_initiation: "立项"'); + expect(source).toContain('startup_initiation_attachments: "立项"'); + expect(source).toContain('startup_ethics: "伦理"'); + expect(source).toContain('startup_ethics_attachments: "伦理"'); + expect(source).not.toContain('startup_initiation: "立项记录"'); + expect(source).not.toContain('startup_ethics: "伦理记录"'); }); it("does not expose the legacy dashboard module after merging into project overview", () => { diff --git a/frontend/src/views/admin/PermissionManagement.vue b/frontend/src/views/admin/PermissionManagement.vue index c3e90a76..7c359fd5 100644 --- a/frontend/src/views/admin/PermissionManagement.vue +++ b/frontend/src/views/admin/PermissionManagement.vue @@ -73,7 +73,7 @@ {{ memberRows.length }} 名成员
- + 添加成员 @@ -96,7 +96,7 @@ size="small" style="width: 130px" @change="(val: string) => updateMemberRole(row.id, val)" - :disabled="!canEditMember(row) || !row.is_active || row.effectiveStatus === 'DISABLED_GLOBAL'" + :disabled="!canUpdateProjectMember(row) || !row.is_active || row.effectiveStatus === 'DISABLED_GLOBAL'" > {{ row.is_active ? "停用" : "启用" }} - + 移除 @@ -480,6 +480,18 @@ const isAdmin = computed(() => isSystemAdmin(auth.user)); const selectedProjectRole = computed(() => selectedStudy.value?.role_in_study || ""); const isSelectedProjectPm = computed(() => selectedProjectRole.value === "PM"); const canManageSelectedProject = computed(() => isAdmin.value || isSelectedProjectPm.value); +const selectedProjectPermissionAllowed = (operationKey: string) => { + if (isAdmin.value) return true; + const role = selectedProjectRole.value; + if (!role || !apiMatrix.value) return false; + return isApiPermissionAllowed(apiMatrix.value?.[role]?.[operationKey]); +}; +const canListProjectMembers = computed(() => selectedProjectPermissionAllowed("project_members:list")); +const canListProjectMemberCandidates = computed(() => selectedProjectPermissionAllowed("project_members:candidates")); +const canCreateProjectMember = computed(() => selectedProjectPermissionAllowed("project_members:create")); +const canUpdateProjectMembers = computed(() => selectedProjectPermissionAllowed("project_members:update")); +const canDeleteProjectMembers = computed(() => selectedProjectPermissionAllowed("project_members:delete")); +const canAddProjectMember = computed(() => canCreateProjectMember.value && canListProjectMemberCandidates.value); const { roleLabel, roleDescription, roleIcon, compareRolesByTemplateOrder, loadRoleTemplates } = useRoleTemplateMeta(); const ROLE_RANK: Record = { @@ -495,7 +507,7 @@ const assignableRoleLabels = computed(() => { return result; }); const memberRoleLabels = (row: MemberRoleRow) => { - if (canEditMember(row)) return assignableRoleLabels.value; + if (canUpdateProjectMember(row)) return assignableRoleLabels.value; return { ...assignableRoleLabels.value, [row.role_in_study]: roleLabel(row.role_in_study), @@ -574,7 +586,8 @@ const onStudyChange = async (id: string) => { candidates.value = []; activeRolesDraft.value = []; router.replace({ query: { ...route.query, projectId: id } }); - await Promise.all([loadPermissionData(), loadMembers(), loadActiveRoles()]); + await loadPermissionData(); + await Promise.all([loadMembers(), loadActiveRoles()]); loadCandidates(); }; @@ -617,13 +630,14 @@ const addMemberRules: FormRules = { role_in_study: [{ required: true, message: "请选择角色", trigger: "change" }], }; -const canEditMember = (row: MemberRoleRow) => { - if (!canManageSelectedProject.value) return false; +const canMutateProjectMember = (row: MemberRoleRow) => { if (row.user_id === auth.user?.id) return false; if (row.user?.is_admin) return false; if (!isAdmin.value && (ROLE_RANK[row.role_in_study] ?? 0) >= ROLE_RANK.PM) return false; return true; }; +const canUpdateProjectMember = (row: MemberRoleRow) => canUpdateProjectMembers.value && canMutateProjectMember(row); +const canDeleteProjectMember = (row: MemberRoleRow) => canDeleteProjectMembers.value && canMutateProjectMember(row); const memberRows = computed(() => members.value.map((m) => { @@ -639,7 +653,7 @@ const availableCandidates = computed(() => { }); const loadMembers = async () => { - if (!selectedStudyId.value) return; + if (!canListProjectMembers.value || !selectedStudyId.value) return; membersLoading.value = true; try { const { data } = await listMembers(selectedStudyId.value, { limit: 500, include_inactive: true }); @@ -652,7 +666,10 @@ const loadMembers = async () => { }; const loadCandidates = async () => { - if (!selectedStudyId.value) return; + if (!canListProjectMemberCandidates.value || !selectedStudyId.value) { + candidates.value = []; + return; + } try { const { data } = await listMemberCandidates(selectedStudyId.value, { limit: 500 }); candidates.value = data ?? []; @@ -662,6 +679,7 @@ const loadCandidates = async () => { }; const openAddMember = () => { + if (!canAddProjectMember.value) return; addMemberForm.user_id = ""; addMemberForm.role_in_study = Object.keys(assignableRoleLabels.value)[0] || ""; addMemberVisible.value = true; @@ -670,6 +688,10 @@ const openAddMember = () => { const resetAddMemberForm = () => { addMemberFormRef.value?.clearValidate(); }; const submitAddMember = async () => { + if (!canAddProjectMember.value) { + ElMessage.warning("无权限添加成员"); + return; + } const valid = await addMemberFormRef.value?.validate().catch(() => false); if (!valid) return; addMemberSaving.value = true; @@ -686,6 +708,12 @@ const submitAddMember = async () => { }; const updateMemberRole = async (memberId: string, role: string) => { + const row = members.value.find((item) => item.id === memberId); + if (!row || !canUpdateProjectMember(row)) { + ElMessage.warning("无权限更新成员"); + loadMembers(); + return; + } try { await updateMember(selectedStudyId.value, memberId, { role_in_study: role }); ElMessage.success("角色已更新"); @@ -696,6 +724,10 @@ const updateMemberRole = async (memberId: string, role: string) => { }; const toggleMemberActive = async (row: any) => { + if (!canUpdateProjectMember(row)) { + ElMessage.warning("无权限更新成员"); + return; + } if (row.is_active) { const ok = await ElMessageBox.confirm("确认停用该成员?", "停用成员", { type: "warning" }).catch(() => null); if (!ok) return; @@ -710,6 +742,10 @@ const toggleMemberActive = async (row: any) => { }; const deleteMember = async (row: any) => { + if (!canDeleteProjectMember(row)) { + ElMessage.warning("无权限移除成员"); + return; + } const ok = await ElMessageBox.confirm("确认将该账号从本项目移除?账号本身不会被删除。", "移除成员", { type: "warning", }).catch(() => null); @@ -973,8 +1009,10 @@ const PERMISSION_SECTION_LABELS: Record = { subject_histories: "病史", monitoring_issues: "监查访视问题", project_milestones: "项目里程碑", - startup_initiation: "立项记录", - startup_ethics: "伦理记录", + startup_initiation: "立项", + startup_initiation_attachments: "立项", + startup_ethics: "伦理", + startup_ethics_attachments: "伦理", precautions: "注意事项", precautions_attachments: "注意事项", faq: "医学咨询/FAQ", @@ -1147,7 +1185,8 @@ onMounted(async () => { if (initialStudyId) { selectedStudyId.value = initialStudyId; if (qSub === "members") projectSubTab.value = "members"; - await Promise.all([loadPermissionData(), loadMembers(), loadActiveRoles()]); + await loadPermissionData(); + await Promise.all([loadMembers(), loadActiveRoles()]); loadCandidates(); } diff --git a/frontend/src/views/admin/ProjectDetail.test.ts b/frontend/src/views/admin/ProjectDetail.test.ts index fc1ee363..a47511f6 100644 --- a/frontend/src/views/admin/ProjectDetail.test.ts +++ b/frontend/src/views/admin/ProjectDetail.test.ts @@ -88,6 +88,32 @@ describe("project detail management role", () => { expect(source).toContain("isSystemAdmin(authStore.user)"); expect(source).not.toContain('setupRole.value === "ADMIN"'); - expect(source).not.toContain("project.value?.role_in_study || authStore.user?.role"); + expect(source).not.toMatch(/authStore\.user\?\.role/); + }); + + it("uses fine-grained setup config permissions for each setup action", () => { + const source = readProjectDetail(); + + expect(source).toContain("const canReadSetupConfig = computed"); + expect(source).toContain("const canUpdateSetupConfig = computed"); + expect(source).toContain("const canPublishSetupConfig = computed"); + expect(source).toContain("const canRollbackSetupConfig = computed"); + expect(source).toContain("const canDeleteSetupVersion = computed"); + expect(source).toContain('selectedProjectPermissionAllowed("setup_config:read")'); + expect(source).toContain('selectedProjectPermissionAllowed("setup_config:update")'); + expect(source).toContain('selectedProjectPermissionAllowed("setup_config:publish")'); + expect(source).toContain('selectedProjectPermissionAllowed("setup_config:rollback")'); + expect(source).toContain('selectedProjectPermissionAllowed("setup_config:delete_version")'); + expect(source).toContain('v-if="canUpdateSetupConfig" type="success"'); + expect(source).toContain('v-if="canPublishSetupConfig" type="warning"'); + expect(source).toContain('v-if="canRollbackSetupConfig"'); + expect(source).toContain('v-if="canDeleteSetupVersion"'); + expect(source).toContain("if (!canReadSetupConfig.value)"); + expect(source).toContain("if (!canUpdateSetupConfig.value)"); + expect(source).toContain("if (!canPublishAction.value) return"); + expect(source).toContain("if (!canRollbackSetupConfig.value) return"); + expect(source).toContain("if (!canDeleteSetupVersion.value) return"); + expect(source).not.toContain("const hasSetupReadPermission = computed"); + expect(source).not.toContain("const canManageSetup = computed"); }); }); diff --git a/frontend/src/views/admin/ProjectDetail.vue b/frontend/src/views/admin/ProjectDetail.vue index 193cbc04..fd7ee592 100644 --- a/frontend/src/views/admin/ProjectDetail.vue +++ b/frontend/src/views/admin/ProjectDetail.vue @@ -56,10 +56,10 @@
- 保存配置 - 一键回填 - 清空草稿 - 发布版本 + 保存配置 + 一键回填 + 清空草稿 + 发布版本
- + 回滚 - + 新建分支草稿 - + 合并主线(新版本) @@ -1521,7 +1521,7 @@ 下载 - + ({ const storageKey = computed(() => `ctms_setup_config_draft_${String(route.params.projectId || "")}`); const projectDraftStorageKey = computed(() => `ctms_setup_project_draft_${String(route.params.projectId || "")}`); const projectPermissions = ref> | null>(null); -const hasSetupReadPermission = computed(() => { +const selectedProjectPermissionAllowed = (operationKey: string) => { if (isSystemAdmin(authStore.user)) return true; if (!projectPermissions.value) return false; const rolePerms = Object.values(projectPermissions.value)[0]; if (!rolePerms) return false; - return isApiPermissionAllowed(rolePerms["setup_config:update"]); -}); -const canManageSetup = computed(() => { - if (isSystemAdmin(authStore.user)) return true; - if (!projectPermissions.value) return false; - const rolePerms = Object.values(projectPermissions.value)[0]; - if (!rolePerms) return false; - return isApiPermissionAllowed(rolePerms["setup_config:update"]); -}); + return isApiPermissionAllowed(rolePerms[operationKey]); +}; +const canReadSetupConfig = computed(() => selectedProjectPermissionAllowed("setup_config:read")); +const canUpdateSetupConfig = computed(() => selectedProjectPermissionAllowed("setup_config:update")); +const canPublishSetupConfig = computed(() => selectedProjectPermissionAllowed("setup_config:publish")); +const canRollbackSetupConfig = computed(() => selectedProjectPermissionAllowed("setup_config:rollback")); +const canDeleteSetupVersion = computed(() => selectedProjectPermissionAllowed("setup_config:delete_version")); const isPreviewView = computed(() => setupViewMode.value === "preview"); const isPublishedVersionView = computed(() => setupViewMode.value === "published"); const isPublishedView = computed(() => isPreviewView.value || isPublishedVersionView.value); @@ -2429,7 +2427,7 @@ const currentSetupDraft = computed(() => { return setupDraft; }); const canEditSetup = computed( - () => Boolean(project.value && !project.value?.is_locked && canManageSetup.value && !isPublishedView.value) + () => Boolean(project.value && !project.value?.is_locked && canUpdateSetupConfig.value && !isPublishedView.value) ); const canMutateDraft = () => canEditSetup.value && !isPublishedView.value; const canEditStep1BasicGroup = (group: Exclude) => @@ -2482,8 +2480,8 @@ const closeIndexedEditor = (controller: IndexedEditorController, successMessage: controller.index.value = -1; ElMessage.success(successMessage); }; -const canSaveDraftAction = computed(() => Boolean(project.value && canManageSetup.value && !project.value?.is_locked && !isPublishedView.value)); -const canPublishAction = computed(() => Boolean(project.value && canManageSetup.value && !project.value?.is_locked)); +const canSaveDraftAction = computed(() => Boolean(project.value && canUpdateSetupConfig.value && !project.value?.is_locked && !isPublishedView.value)); +const canPublishAction = computed(() => Boolean(project.value && canPublishSetupConfig.value && !project.value?.is_locked)); const currentStepTitle = computed(() => `第${activeStep.value + 1}步:${steps[activeStep.value]?.label || ""}`); const setupPublishedVersionText = computed(() => { return setupPublishedVersion.value || "v0"; @@ -4397,7 +4395,7 @@ const loadProject = async () => { project.value = data as Study; projectPermissions.value = permRes.data as any; - if (!hasSetupReadPermission.value) { + if (!canReadSetupConfig.value) { ElMessage.warning("当前角色无权访问立项配置"); router.replace("/admin/projects"); return; @@ -4450,7 +4448,7 @@ const startEdit = () => { ElMessage.info("当前为只读视图,请先切换到草稿后再编辑"); return; } - if (!canManageSetup.value) { + if (!canUpdateSetupConfig.value) { ElMessage.warning("当前角色无权编辑立项配置"); return; } @@ -4634,7 +4632,8 @@ const saveConfigNow = async (): Promise => { }; const doPublishConfigNow = async (): Promise => { - if (!project.value || !canPublishAction.value) return false; + if (!project.value) return false; + if (!canPublishAction.value) return false; try { if (hasFormUnsavedChanges.value) { syncProjectInfoIntoSetupDraft(); @@ -4695,7 +4694,8 @@ const doPublishConfigNow = async (): Promise => { }; const publishConfigNow = async () => { - if (!project.value || !canManageSetup.value) return; + if (!project.value) return; + if (!canPublishAction.value) return; reconcileDraftSyncStateBeforePublish(); if (hasSetupDraftUnsavedChanges.value) { ElMessage.warning("当前存在未上传的本地草稿,请先点击“保存配置”"); @@ -4960,13 +4960,14 @@ const isLatestPublishedVersion = (rawVersion: number): boolean => { }; const openRollbackDialog = async () => { - if (!project.value || !canManageSetup.value) return; + if (!project.value || !canReadSetupConfig.value) return; await loadSetupVersionHistory(); rollbackDialogVisible.value = true; }; const selectRollbackVersion = async (targetVersion: number) => { if (!project.value) return; + if (!canRollbackSetupConfig.value) return; const targetDisplayVersion = getDisplayVersionLabel(targetVersion); try { await ElMessageBox.confirm( @@ -5018,6 +5019,7 @@ const selectRollbackVersion = async (targetVersion: number) => { const checkoutBranchDraftFromVersion = async (targetVersion: number) => { if (!project.value) return; + if (!canUpdateSetupConfig.value) return; const targetDisplayVersion = getDisplayVersionLabel(targetVersion); try { await ElMessageBox.confirm( @@ -5344,6 +5346,7 @@ ${buildExcelWorksheetXml("第6步-中心确认", metaRows, step6Headers, step6Ro const removeVersion = async (targetVersion: number) => { if (!project.value) return; + if (!canDeleteSetupVersion.value) return; if (isLatestPublishedVersion(targetVersion)) { ElMessage.warning("当前发布版本不能删除"); return; @@ -5372,6 +5375,7 @@ const removeVersion = async (targetVersion: number) => { const mergeVersionToMain = async (sourceVersion: number) => { if (!project.value) return; + if (!canPublishSetupConfig.value) return; const sourceVersionLabel = getDisplayVersionLabel(sourceVersion); try { await ElMessageBox.confirm( @@ -5420,7 +5424,7 @@ const mergeVersionToMain = async (sourceVersion: number) => { }; const handleRefillDraft = async () => { - if (!project.value || !canManageSetup.value) return; + if (!project.value || !canUpdateSetupConfig.value) return; try { await ElMessageBox.confirm( `确认将草稿一键回填为当前发布版本 ${setupPublishedVersionText.value} 的内容?`, @@ -5468,7 +5472,7 @@ const handleRefillDraft = async () => { }; const handleClearDraft = async () => { - if (!project.value || !canManageSetup.value) return; + if (!project.value || !canUpdateSetupConfig.value) return; try { await ElMessageBox.confirm( "确认清空当前立项配置草稿的所有数据?该操作不会影响已发布版本。", @@ -5521,7 +5525,7 @@ const ensureCanSaveDraftAction = (): boolean => { ElMessage.warning("项目信息尚未加载完成,请稍后重试"); return false; } - if (!canManageSetup.value) { + if (!canUpdateSetupConfig.value) { ElMessage.warning("当前角色无权保存立项配置"); return false; } @@ -5641,7 +5645,7 @@ const ensureStep2Editable = (): boolean => { ElMessage.info("当前为只读视图,请先切换到草稿后再操作"); return false; } - if (!canManageSetup.value) { + if (!canUpdateSetupConfig.value) { ElMessage.warning("当前角色无权编辑立项配置"); return false; } diff --git a/frontend/src/views/admin/ProjectMembers.test.ts b/frontend/src/views/admin/ProjectMembers.test.ts index 4d2c86c5..40d93a47 100644 --- a/frontend/src/views/admin/ProjectMembers.test.ts +++ b/frontend/src/views/admin/ProjectMembers.test.ts @@ -10,11 +10,17 @@ describe("ProjectMembers user directory access", () => { expect(source).not.toContain("fetchUsers"); expect(source).not.toContain("../../api/users"); - expect(source).toContain('const canManageMembers = computed(() => permission.can("project.members.manage"));'); - expect(source).toContain("if (!canManageMembers.value"); + expect(source).toContain('const canListMembers = computed(() => permission.can("project.members.list"));'); + expect(source).toContain('const canListMemberCandidates = computed(() => permission.can("project.members.candidates"));'); + expect(source).toContain('const canCreateMember = computed(() => permission.can("project.members.create"));'); + expect(source).toContain('const canUpdateMember = computed(() => permission.can("project.members.update"));'); + expect(source).toContain('const canDeleteMember = computed(() => permission.can("project.members.delete"));'); + expect(source).toContain("if (!canListMembers.value"); + expect(source).toContain("if (!canListMemberCandidates.value"); expect(source).toContain("return;"); - expect(source).toContain('v-if="canManageMembers"'); + expect(source).toContain('v-if="canAddMember"'); expect(source).toContain("listMemberCandidates(projectId.value, { limit: 500 })"); + expect(source).not.toContain("project.members.manage"); }); it("uses the member API embedded user data when the global directory is unavailable", () => { @@ -34,6 +40,7 @@ describe("ProjectMembers user directory access", () => { expect(source).toContain("const canAssignRole = (role: string)"); expect(source).toContain(":disabled=\"!canAssignRole(role.value)\""); expect(source).toContain(':disabled="!canEditMember(scope.row)'); + expect(source).toContain(':disabled="!canDeleteProjectMember(scope.row)"'); }); it("uses permission template names for project role display options", () => { diff --git a/frontend/src/views/admin/ProjectMembers.vue b/frontend/src/views/admin/ProjectMembers.vue index 1fbd1ce9..5ccf6f92 100644 --- a/frontend/src/views/admin/ProjectMembers.vue +++ b/frontend/src/views/admin/ProjectMembers.vue @@ -3,7 +3,7 @@
- + {{ TEXT.common.actions.add }}{{ TEXT.modules.adminProjectMembers.memberLabel }}
@@ -50,7 +50,7 @@ /> {{ TEXT.modules.adminProjectMembers.disabledGlobalDesc }} - {{ TEXT.common.actions.delete }} + {{ TEXT.common.actions.delete }} permission.can("project.members.manage")); +const canListMembers = computed(() => permission.can("project.members.list")); +const canListMemberCandidates = computed(() => permission.can("project.members.candidates")); +const canCreateMember = computed(() => permission.can("project.members.create")); +const canUpdateMember = computed(() => permission.can("project.members.update")); +const canDeleteMember = computed(() => permission.can("project.members.delete")); +const canAddMember = computed(() => canCreateMember.value && canListMemberCandidates.value); const projectRole = computed(() => project.value?.role_in_study || ""); const ROLE_KEYS = ["PM", "CRA", "PV", "QA", "CTA", "ADMIN"]; const roleOptions = computed(() => roleOptionsFor(ROLE_KEYS)); @@ -144,13 +149,14 @@ const roleRank: Record = { }; const currentRoleRank = computed(() => auth.user?.is_admin ? Number.POSITIVE_INFINITY : roleRank[projectRole.value] || 0); const canAssignRole = (role: string) => (auth.user?.is_admin ? true : (roleRank[role] || 0) <= currentRoleRank.value); -const canEditMember = (row: StudyMember) => { - if (!canManageMembers.value) return false; +const canMutateProjectMember = (row: StudyMember) => { if (row.user?.is_admin) return false; if (auth.user?.is_admin) return true; if (row.user_id === auth.user?.id) return false; return (roleRank[row.role_in_study] || 0) <= currentRoleRank.value; }; +const canEditMember = (row: StudyMember) => canUpdateMember.value && canMutateProjectMember(row); +const canDeleteProjectMember = (row: StudyMember) => canDeleteMember.value && canMutateProjectMember(row); const addRules = reactive({ user_id: [{ required: true, message: requiredMessage(TEXT.modules.adminProjectMembers.user), trigger: "change" }], @@ -168,16 +174,12 @@ const loadProject = async () => { }; const loadMembers = async () => { - if (!projectId.value) return; + if (!canListMembers.value || !projectId.value) return; loading.value = true; try { const { data } = await listMembers(projectId.value, { limit: 500, include_inactive: true }); members.value = Array.isArray(data) ? data : data.items || []; - if (!canManageMembers.value) { - users.value = members.value - .map((member) => member.user) - .filter((user): user is UserInfo => Boolean(user?.id)) as UserInfo[]; - } + if (!canListMemberCandidates.value) syncUsersFromMembers(); } catch (e: any) { ElMessage.error(e?.response?.data?.message || TEXT.modules.adminProjectMembers.loadFailed); } finally { @@ -185,9 +187,15 @@ const loadMembers = async () => { } }; +const syncUsersFromMembers = () => { + users.value = members.value + .map((member) => member.user) + .filter((user): user is UserInfo => Boolean(user?.id)) as UserInfo[]; +}; + const loadUsers = async () => { - if (!canManageMembers.value || !projectId.value) { - users.value = []; + if (!canListMemberCandidates.value || !projectId.value) { + syncUsersFromMembers(); return; } try { @@ -211,7 +219,7 @@ const memberRows = computed(() => ); const openAdd = () => { - if (!canManageMembers.value) return; + if (!canAddMember.value) return; newMember.user_id = ""; newMember.role_in_study = canAssignRole("PM") ? "PM" : "CRA"; addVisible.value = true; @@ -219,9 +227,13 @@ const openAdd = () => { const submitAdd = async () => { if (!projectId.value) return; + if (!canAddMember.value) { + ElMessage.warning(TEXT.common.messages.noPermission); + return; + } const decision = evaluateAction({ actorRole: auth.user?.is_admin ? "ADMIN" : null, - requiredPermission: "project.members.manage", + requiredPermission: "project.members.create", target: { projectId: projectId.value }, }); if (!decision.allowed) { @@ -256,7 +268,7 @@ const updateRole = async (memberId: string, role: string) => { } const decision = evaluateAction({ actorRole: auth.user?.is_admin ? "ADMIN" : null, - requiredPermission: "project.members.manage", + requiredPermission: "project.members.update", target: { projectId: projectId.value, memberId }, }); if (!decision.allowed) { @@ -281,7 +293,7 @@ const toggleActive = async (row: StudyMember) => { } const decision = evaluateAction({ actorRole: auth.user?.is_admin ? "ADMIN" : null, - requiredPermission: "project.members.manage", + requiredPermission: "project.members.update", target: { projectId: projectId.value, memberId: row.id }, }); if (!decision.allowed) { @@ -314,13 +326,13 @@ const toggleActive = async (row: StudyMember) => { const onDelete = async (row: StudyMember) => { if (!projectId.value) return; - if (!canEditMember(row)) { + if (!canDeleteProjectMember(row)) { ElMessage.warning(TEXT.common.messages.noPermission); return; } const decision = evaluateAction({ actorRole: auth.user?.is_admin ? "ADMIN" : null, - requiredPermission: "project.members.manage", + requiredPermission: "project.members.delete", target: { projectId: projectId.value, memberId: row.id }, }); if (!decision.allowed) { diff --git a/frontend/src/views/admin/Projects.test.ts b/frontend/src/views/admin/Projects.test.ts index f663a797..7afc7b5c 100644 --- a/frontend/src/views/admin/Projects.test.ts +++ b/frontend/src/views/admin/Projects.test.ts @@ -52,12 +52,22 @@ describe("project management access", () => { expect(source).toContain("canProject(scope.row, 'audit_export', 'read')"); }); - it("opens project names in management detail when the user has setup config write permission", () => { + it("opens project names in management detail when the user has setup config read permission", () => { const source = readProjects(); - expect(source).toContain("v-if=\"canProject(scope.row, 'setup_config', 'write')\""); + expect(source).toContain("v-if=\"canProject(scope.row, 'setup_config', 'read')\""); expect(source).toContain('@click="goProject(scope.row)" class="project-name"'); expect(source).toContain('@click="enterStudy(scope.row)"'); - expect(source).toContain('if (!canProject(row, "setup_config", "write"))'); + expect(source).toContain('if (!canProject(row, "setup_config", "read"))'); + }); + + it("keeps audit export write mappings on the export operation", () => { + const projects = readProjects(); + const router = readRouter(); + + expect(projects).toContain('audit_export: { read: "audit_logs:read", write: "audit_logs:export" }'); + expect(router).toContain('audit_export: { read: "audit_logs:read", write: "audit_logs:export" }'); + expect(projects).not.toContain('audit_export: { read: "audit_logs:read", write: "audit_logs:read" }'); + expect(router).not.toContain('audit_export: { read: "audit_logs:read", write: "audit_logs:read" }'); }); }); diff --git a/frontend/src/views/admin/Projects.vue b/frontend/src/views/admin/Projects.vue index 617668f1..e133db92 100644 --- a/frontend/src/views/admin/Projects.vue +++ b/frontend/src/views/admin/Projects.vue @@ -74,7 +74,7 @@
- {{ scope.row.name }} + {{ scope.row.name }} {{ scope.row.name }} {{ scope.row.code || '-' }}
@@ -210,7 +210,7 @@ const openCreate = () => { const MODULE_TO_OPERATION: Record> = { project_members: { read: "project_members:list", write: "project_members:update" }, sites: { read: "sites:read", write: "sites:update" }, - audit_export: { read: "audit_logs:read", write: "audit_logs:read" }, + audit_export: { read: "audit_logs:read", write: "audit_logs:export" }, setup_config: { read: "setup_config:read", write: "setup_config:update" }, }; @@ -335,7 +335,7 @@ const goDetail = (row: Study) => { }; const goProject = (row: Study) => { - if (!canProject(row, "setup_config", "write")) { + if (!canProject(row, "setup_config", "read")) { ElMessage.warning("当前角色无权访问该项目的立项配置"); return; }