From 939fc70532c95a2e2134917b96de11c83af22d64 Mon Sep 17 00:00:00 2001 From: Cheng Zhou Date: Wed, 13 May 2026 08:58:03 +0800 Subject: [PATCH] =?UTF-8?q?=E6=9D=83=E9=99=90=E7=AE=A1=E7=90=86=EF=BC=9A?= =?UTF-8?q?=E6=8E=A5=E5=85=A5=E9=A1=B9=E7=9B=AE=E7=BA=A7=E6=9D=83=E9=99=90?= =?UTF-8?q?=E7=9F=A9=E9=98=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增项目级角色权限矩阵,覆盖 PM、CRA、PV、IMP、QA 等项目角色,并通过迁移表持久化各业务模块的读写权限。 后端将参与者、合同费用、物资、启动、里程碑、风险问题、监查稽查、FAQ、共享库、文件版本等接口接入矩阵校验,修复审计日志导出权限和登录 502 的依赖导入问题。 前端新增权限管理页面和项目路由权限映射,按矩阵控制导航显示、直接访问拦截、操作按钮启停,并限制管理后台仅 admin 和授权 PM 可进入。 补充后端权限归一化与接口静态测试、前端路由/权限工具/权限管理页面/工作台等回归测试。 --- .../20260512_03_add_study_role_permissions.py | 57 +++ backend/app/api/v1/aes.py | 39 +- backend/app/api/v1/attachments.py | 88 +++- backend/app/api/v1/audit_logs.py | 4 +- backend/app/api/v1/dashboard.py | 12 +- backend/app/api/v1/drug_shipments.py | 12 +- backend/app/api/v1/faq_categories.py | 48 +- backend/app/api/v1/faqs.py | 129 ++--- backend/app/api/v1/fees_attachments.py | 7 +- backend/app/api/v1/fees_contracts.py | 5 +- backend/app/api/v1/finance_contracts.py | 12 +- backend/app/api/v1/knowledge_notes.py | 12 +- backend/app/api/v1/material_equipments.py | 12 +- backend/app/api/v1/members.py | 8 +- backend/app/api/v1/monitoring_visit_issues.py | 16 +- backend/app/api/v1/overview.py | 4 +- backend/app/api/v1/project_milestones.py | 6 +- backend/app/api/v1/project_permissions.py | 66 +++ backend/app/api/v1/router.py | 3 +- backend/app/api/v1/sites.py | 6 +- backend/app/api/v1/startup.py | 40 +- backend/app/api/v1/studies.py | 18 +- backend/app/api/v1/subject_histories.py | 12 +- backend/app/api/v1/subject_pds.py | 11 +- backend/app/api/v1/subjects.py | 12 +- backend/app/api/v1/visits.py | 12 +- backend/app/core/deps.py | 29 ++ backend/app/core/project_permissions.py | 233 +++++++++ backend/app/db/base.py | 1 + backend/app/models/study_role_permission.py | 25 + backend/app/schemas/project_permission.py | 33 ++ backend/app/services/document_service.py | 7 +- ...t_project_management_permissions_static.py | 136 ++++- backend/tests/test_project_permissions.py | 172 +++++++ frontend/src/api/projectPermissions.ts | 8 + frontend/src/components/Layout.vue | 76 ++- frontend/src/components/StudySelector.vue | 3 +- .../components/attachments/AttachmentList.vue | 11 +- .../components/fees/FeeAttachmentPanel.vue | 10 +- frontend/src/main.ts | 1 + frontend/src/router.test.ts | 17 + frontend/src/router/index.ts | 71 ++- frontend/src/store/auth.ts | 1 + frontend/src/store/study.ts | 16 + frontend/src/types/api.ts | 23 + frontend/src/utils/permission.test.ts | 16 +- frontend/src/utils/permission.ts | 56 ++- .../src/utils/projectRoutePermissions.test.ts | 24 + frontend/src/utils/projectRoutePermissions.ts | 69 +++ frontend/src/utils/roles.test.ts | 5 +- frontend/src/utils/roles.ts | 2 - frontend/src/views/admin/AuditLogs.test.ts | 3 +- frontend/src/views/admin/AuditLogs.vue | 22 +- .../src/views/admin/ProjectDetail.test.ts | 6 +- frontend/src/views/admin/ProjectDetail.vue | 8 +- .../views/admin/ProjectPermissions.test.ts | 16 + .../src/views/admin/ProjectPermissions.vue | 475 ++++++++++++++++++ frontend/src/views/admin/Projects.vue | 38 +- frontend/src/views/ia/KnowledgeNotes.vue | 6 +- frontend/src/views/knowledge/NoteDetail.vue | 7 +- frontend/src/views/knowledge/NoteForm.vue | 7 +- .../src/views/workbench/MyWorkbench.test.ts | 8 +- frontend/src/views/workbench/MyWorkbench.vue | 47 +- 63 files changed, 1952 insertions(+), 387 deletions(-) create mode 100644 backend/alembic/versions/20260512_03_add_study_role_permissions.py create mode 100644 backend/app/api/v1/project_permissions.py create mode 100644 backend/app/core/project_permissions.py create mode 100644 backend/app/models/study_role_permission.py create mode 100644 backend/app/schemas/project_permission.py create mode 100644 backend/tests/test_project_permissions.py create mode 100644 frontend/src/api/projectPermissions.ts create mode 100644 frontend/src/router.test.ts create mode 100644 frontend/src/utils/projectRoutePermissions.test.ts create mode 100644 frontend/src/utils/projectRoutePermissions.ts create mode 100644 frontend/src/views/admin/ProjectPermissions.test.ts create mode 100644 frontend/src/views/admin/ProjectPermissions.vue diff --git a/backend/alembic/versions/20260512_03_add_study_role_permissions.py b/backend/alembic/versions/20260512_03_add_study_role_permissions.py new file mode 100644 index 00000000..0e7cb694 --- /dev/null +++ b/backend/alembic/versions/20260512_03_add_study_role_permissions.py @@ -0,0 +1,57 @@ +"""add study role permissions + +Revision ID: 20260512_03 +Revises: 20260512_02 +Create Date: 2026-05-12 10:45:00.000000 + +""" + +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import postgresql + + +revision: str = "20260512_03" +down_revision: Union[str, None] = "20260512_02" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def _table_exists(inspector: sa.Inspector, table_name: str) -> bool: + return table_name in inspector.get_table_names() + + +def upgrade() -> None: + bind = op.get_bind() + inspector = sa.inspect(bind) + if _table_exists(inspector, "study_role_permissions"): + return + + op.create_table( + "study_role_permissions", + sa.Column("id", postgresql.UUID(as_uuid=True), nullable=False), + sa.Column("study_id", postgresql.UUID(as_uuid=True), nullable=False), + sa.Column("role", sa.String(length=20), nullable=False), + sa.Column("module", sa.String(length=80), nullable=False), + sa.Column("can_read", sa.Boolean(), server_default="false", nullable=False), + sa.Column("can_write", sa.Boolean(), server_default="false", nullable=False), + sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False), + sa.ForeignKeyConstraint(["study_id"], ["studies.id"]), + sa.PrimaryKeyConstraint("id"), + sa.UniqueConstraint("study_id", "role", "module", name="uq_study_role_permissions_study_role_module"), + ) + op.create_index("ix_study_role_permissions_study_id", "study_role_permissions", ["study_id"]) + op.create_index("ix_study_role_permissions_role", "study_role_permissions", ["role"]) + + +def downgrade() -> None: + bind = op.get_bind() + inspector = sa.inspect(bind) + if not _table_exists(inspector, "study_role_permissions"): + return + + op.drop_index("ix_study_role_permissions_role", table_name="study_role_permissions") + op.drop_index("ix_study_role_permissions_study_id", table_name="study_role_permissions") + op.drop_table("study_role_permissions") diff --git a/backend/app/api/v1/aes.py b/backend/app/api/v1/aes.py index a4e9f4d8..ae24a306 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_member, require_study_not_locked +from app.core.deps import get_cra_site_scope, get_current_user, get_db_session, require_study_not_locked, require_study_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 @@ -16,9 +16,6 @@ from app.schemas.ae import AECreate, AERead, AEUpdate router = APIRouter() -ALLOWED_CREATE_ROLES = {"PM", "CRA", "PV"} -ALLOWED_UPDATE_ROLES = {"PM", "PV", "CRA"} - async def _ensure_study_exists(db: AsyncSession, study_id: uuid.UUID): study = await study_crud.get(db, study_id) @@ -43,11 +40,6 @@ async def _ensure_ae_visible(db: AsyncSession, study_id: uuid.UUID, ae: AERead | raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="AE 不存在") -async def _get_member_role(db: AsyncSession, study_id: uuid.UUID, user_id: uuid.UUID) -> str | None: - member = await member_crud.get_member(db, study_id, user_id) - return member.role_in_study if member else None - - def _is_overdue(ae: AERead) -> bool: return bool(ae.report_due_date and date.today() > ae.report_due_date and ae.status != "CLOSED") @@ -56,7 +48,7 @@ def _is_overdue(ae: AERead) -> bool: "/", response_model=AERead, status_code=status.HTTP_201_CREATED, - dependencies=[Depends(require_study_member()), Depends(require_study_not_locked())], + dependencies=[Depends(require_study_permission("risk_issues", "write")), Depends(require_study_not_locked())], ) async def create_ae( study_id: uuid.UUID, @@ -75,9 +67,6 @@ async def create_ae( raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="权限不足") if ae_in.onset_date and ae_in.onset_date > date.today(): raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="发生日期不能晚于今天") - member_role = await _get_member_role(db, study_id, current_user.id) - if current_user.role != "ADMIN" and member_role not in ALLOWED_CREATE_ROLES: - raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="权限不足") try: ae = await ae_crud.create_ae(db, study_id, ae_in, created_by=current_user.id) except ValueError as exc: @@ -100,7 +89,7 @@ async def create_ae( @router.get( "/", response_model=list[AERead], - dependencies=[Depends(require_study_member())], + dependencies=[Depends(require_study_permission("risk_issues", "read"))], ) async def list_ae( study_id: uuid.UUID, @@ -138,7 +127,7 @@ async def list_ae( @router.get( "/{ae_id}", response_model=AERead, - dependencies=[Depends(require_study_member())], + dependencies=[Depends(require_study_permission("risk_issues", "read"))], ) async def get_ae( study_id: uuid.UUID, @@ -166,7 +155,7 @@ async def get_ae( @router.patch( "/{ae_id}", response_model=AERead, - dependencies=[Depends(require_study_member()), Depends(require_study_not_locked())], + dependencies=[Depends(require_study_permission("risk_issues", "write")), Depends(require_study_not_locked())], ) async def update_ae( study_id: uuid.UUID, @@ -183,19 +172,6 @@ async def update_ae( raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="AE 不存在") await _ensure_ae_visible(db, study_id, AERead.model_validate(ae)) - member_role = await _get_member_role(db, study_id, current_user.id) - if current_user.role == "ADMIN": - pass - elif member_role in ALLOWED_UPDATE_ROLES: - pass - elif member_role == "CRA": - if ae.created_by != current_user.id: - raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="仅创建人可更新 AE") - if ae_in.status == "CLOSED": - raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="CRA 无法关闭 AE") - else: - raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="权限不足") - old_status = ae.status detail_before = { "event": ae.term, @@ -234,7 +210,7 @@ async def update_ae( @router.delete( "/{ae_id}", status_code=status.HTTP_204_NO_CONTENT, - dependencies=[Depends(require_study_member()), Depends(require_study_not_locked())], + dependencies=[Depends(require_study_permission("risk_issues", "write")), Depends(require_study_not_locked())], ) async def delete_ae( study_id: uuid.UUID, @@ -247,9 +223,6 @@ async def delete_ae( if not ae or ae.study_id != study_id: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="AE 不存在") await _ensure_ae_visible(db, study_id, AERead.model_validate(ae)) - member_role = await _get_member_role(db, study_id, current_user.id) - if current_user.role != "ADMIN" and member_role not in {"PM", "PV"}: - raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="权限不足") await ae_crud.delete_ae(db, ae) await audit_crud.log_action( db, diff --git a/backend/app/api/v1/attachments.py b/backend/app/api/v1/attachments.py index 4efa2241..f95c14cd 100644 --- a/backend/app/api/v1/attachments.py +++ b/backend/app/api/v1/attachments.py @@ -8,7 +8,8 @@ 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, require_study_member, get_study_member, require_study_not_locked +from app.core.deps import get_current_user, get_db_session, get_study_member, require_study_not_locked +from app.core.project_permissions import role_has_project_permission from app.crud import attachment as attachment_crud from app.crud import audit as audit_crud from app.crud import study as study_crud @@ -22,6 +23,9 @@ router = APIRouter() global_router = APIRouter() UPLOAD_ROOT = Path(__file__).resolve().parent.parent.parent / "uploads" +ATTACHMENT_PERMISSION_MODULE_BY_ENTITY = { + "knowledge_note": "shared_library", +} def _content_disposition(filename: str, disposition: str = "inline") -> str: @@ -37,11 +41,39 @@ async def _ensure_study_exists(db: AsyncSession, study_id: uuid.UUID): return study +def _permission_module_for_entity(entity_type: str) -> str: + return ATTACHMENT_PERMISSION_MODULE_BY_ENTITY.get(entity_type, "file_versions") + + +async def _ensure_attachment_permission( + db: AsyncSession, + study_id: uuid.UUID, + current_user, + entity_type: str, + action: str, +) -> None: + role_value = current_user.role.value if hasattr(current_user.role, "value") else current_user.role + if role_value == "ADMIN": + return + membership = await member_crud.get_member(db, study_id, current_user.id) + if not membership or not membership.is_active: + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="不是项目成员") + allowed = await role_has_project_permission( + db, + study_id, + membership.role_in_study, + _permission_module_for_entity(entity_type), + action, + ) + if not allowed: + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="权限不足") + + @router.post( "/", response_model=AttachmentRead, status_code=status.HTTP_201_CREATED, - dependencies=[Depends(require_study_member()), Depends(require_study_not_locked())], + dependencies=[Depends(require_study_not_locked())], ) async def upload_attachment( study_id: uuid.UUID, @@ -52,6 +84,7 @@ async def upload_attachment( current_user=Depends(get_current_user), ) -> AttachmentRead: await _ensure_study_exists(db, study_id) + await _ensure_attachment_permission(db, study_id, current_user, entity_type, "write") dest_dir = UPLOAD_ROOT / f"study_{study_id}" / f"{entity_type}_{entity_id}" dest_dir.mkdir(parents=True, exist_ok=True) unique_name = f"{uuid.uuid4()}{Path(file.filename).suffix}" @@ -96,15 +129,16 @@ async def upload_attachment( @router.get( "/", response_model=list[AttachmentRead], - dependencies=[Depends(require_study_member())], ) async def list_attachments( study_id: uuid.UUID, entity_type: str, entity_id: uuid.UUID, db: AsyncSession = Depends(get_db_session), + current_user=Depends(get_current_user), ) -> list[AttachmentRead]: await _ensure_study_exists(db, study_id) + await _ensure_attachment_permission(db, study_id, current_user, entity_type, "read") attachments = await attachment_crud.list_attachments(db, study_id, entity_type, entity_id) 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) @@ -127,7 +161,6 @@ async def list_attachments( @router.get( "/{attachment_id}/download", - dependencies=[Depends(require_study_member())], ) async def download_attachment( study_id: uuid.UUID, @@ -135,8 +168,10 @@ async def download_attachment( entity_id: uuid.UUID, attachment_id: uuid.UUID, db: AsyncSession = Depends(get_db_session), + current_user=Depends(get_current_user), ) -> FileResponse: await _ensure_study_exists(db, study_id) + await _ensure_attachment_permission(db, study_id, current_user, entity_type, "read") attachment = await attachment_crud.get_attachment(db, attachment_id) if not attachment or attachment.study_id != study_id or attachment.entity_id != entity_id or attachment.entity_type != entity_type: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="附件不存在") @@ -152,7 +187,6 @@ async def download_attachment( @router.get( "/{attachment_id}/preview", - dependencies=[Depends(require_study_member())], ) async def preview_attachment( study_id: uuid.UUID, @@ -160,8 +194,10 @@ async def preview_attachment( entity_id: uuid.UUID, attachment_id: uuid.UUID, db: AsyncSession = Depends(get_db_session), + current_user=Depends(get_current_user), ) -> FileResponse: await _ensure_study_exists(db, study_id) + await _ensure_attachment_permission(db, study_id, current_user, entity_type, "read") attachment = await attachment_crud.get_attachment(db, attachment_id) if not attachment or attachment.study_id != study_id or attachment.entity_id != entity_id or attachment.entity_type != entity_type: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="附件不存在") @@ -210,6 +246,19 @@ async def global_download_attachment( raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="附件不存在") await _ensure_study_exists(db, attachment.study_id) user, _ = await _authorize_global(request, db, attachment.study_id) + membership = None + if user.role != "ADMIN": + membership = await get_study_member(attachment.study_id, current_user=user, db=db) + if user.role != "ADMIN": + allowed = await role_has_project_permission( + db, + attachment.study_id, + membership.role_in_study if membership else None, + _permission_module_for_entity(attachment.entity_type), + "read", + ) + if not allowed: + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="权限不足") if not os.path.exists(attachment.file_path): raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="服务器未找到文件") return FileResponse( @@ -233,7 +282,17 @@ async def global_preview_attachment( if not attachment: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="附件不存在") await _ensure_study_exists(db, attachment.study_id) - await _authorize_global(request, db, attachment.study_id) + user, membership = await _authorize_global(request, db, attachment.study_id) + if user.role != "ADMIN": + allowed = await role_has_project_permission( + db, + attachment.study_id, + membership.role_in_study if membership else None, + _permission_module_for_entity(attachment.entity_type), + "read", + ) + if not allowed: + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="权限不足") if not os.path.exists(attachment.file_path): raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="服务器未找到文件") return FileResponse( @@ -258,10 +317,20 @@ async def global_delete_attachment( raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="附件不存在") await _ensure_study_exists(db, attachment.study_id) user, membership = await _authorize_global(request, db, attachment.study_id) + if user.role != "ADMIN": + allowed = await role_has_project_permission( + db, + attachment.study_id, + membership.role_in_study if membership else None, + _permission_module_for_entity(attachment.entity_type), + "write", + ) + if not allowed: + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="权限不足") can_delete = ( user.role == "ADMIN" or attachment.uploaded_by == user.id - or (membership and getattr(membership, "role_in_study", None) == "PM") + or membership is not None ) if not can_delete: raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="无权限删除附件") @@ -281,7 +350,7 @@ async def global_delete_attachment( @router.delete( "/{attachment_id}", status_code=status.HTTP_204_NO_CONTENT, - dependencies=[Depends(require_study_member()), Depends(require_study_not_locked())], + dependencies=[Depends(require_study_not_locked())], ) async def delete_attachment( study_id: uuid.UUID, @@ -292,6 +361,7 @@ async def delete_attachment( current_user=Depends(get_current_user), ): await _ensure_study_exists(db, study_id) + await _ensure_attachment_permission(db, study_id, current_user, entity_type, "write") attachment = await attachment_crud.get_attachment(db, attachment_id) if ( not attachment @@ -309,7 +379,7 @@ async def delete_attachment( can_delete = ( current_user.role == "ADMIN" or attachment.uploaded_by == current_user.id - or (membership and getattr(membership, "role_in_study", None) == "PM") + or membership is not None ) if not can_delete: raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="无权限删除附件") diff --git a/backend/app/api/v1/audit_logs.py b/backend/app/api/v1/audit_logs.py index b5aff1a0..3bd7f2ea 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 +from app.core.deps import get_current_user, get_db_session, require_roles, require_study_member, require_study_permission from app.crud import audit as audit_crud from app.schemas.audit import AuditEventCreate, AuditLogRead @@ -13,7 +13,7 @@ router = APIRouter() @router.get( "/", response_model=list[AuditLogRead], - dependencies=[Depends(require_study_member())], + dependencies=[Depends(require_study_permission("audit_export", "read"))], ) async def list_audit_logs( study_id: uuid.UUID, diff --git a/backend/app/api/v1/dashboard.py b/backend/app/api/v1/dashboard.py index 32ab8916..585efa82 100644 --- a/backend/app/api/v1/dashboard.py +++ b/backend/app/api/v1/dashboard.py @@ -4,6 +4,7 @@ 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 +from app.core.project_permissions import role_has_project_permission from app.crud import member as member_crud from app.models.milestone import Milestone from app.schemas.progress import StudyProgressRead @@ -74,12 +75,15 @@ async def get_center_summary( current_user=Depends(get_current_user), ) -> list[CenterSummaryItem]: role_value = _role_value(current_user) - member_role = None + membership = None if role_value != "ADMIN": membership = await member_crud.get_member(db, study_id, current_user.id) - member_role = membership.role_in_study if membership and membership.is_active else None - if role_value != "ADMIN" and member_role not in {"PM", "CRA"}: - return [] + if not membership or not membership.is_active: + return [] + can_read_sites = await role_has_project_permission(db, study_id, membership.role_in_study, "sites", "read") + can_read_subjects = await role_has_project_permission(db, study_id, membership.role_in_study, "subjects", "read") + if not (can_read_sites or can_read_subjects): + return [] cra_scope = await get_cra_site_scope(db, study_id, current_user) scope_ids = cra_scope[0] if cra_scope else None scope_id_strs = {str(cid) for cid in scope_ids} if scope_ids is not None else None diff --git a/backend/app/api/v1/drug_shipments.py b/backend/app/api/v1/drug_shipments.py index 8d6c4a5a..ff2ad2bd 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_member, require_study_roles, require_study_not_locked +from app.core.deps import get_cra_site_scope, get_current_user, get_db_session, require_study_not_locked, require_study_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 @@ -33,7 +33,7 @@ async def _ensure_center_active(db: AsyncSession, study_id: uuid.UUID, center_id "/shipments", response_model=DrugShipmentRead, status_code=status.HTTP_201_CREATED, - dependencies=[Depends(require_study_roles(["PM", "CRA"])), Depends(require_study_not_locked())], + dependencies=[Depends(require_study_permission("materials", "write")), Depends(require_study_not_locked())], ) async def create_shipment( study_id: uuid.UUID, @@ -64,7 +64,7 @@ async def create_shipment( @router.get( "/shipments", response_model=list[DrugShipmentRead], - dependencies=[Depends(require_study_member())], + dependencies=[Depends(require_study_permission("materials", "read"))], ) async def list_shipments( study_id: uuid.UUID, @@ -101,7 +101,7 @@ async def list_shipments( @router.get( "/shipments/{shipment_id}", response_model=DrugShipmentRead, - dependencies=[Depends(require_study_member())], + dependencies=[Depends(require_study_permission("materials", "read"))], ) async def get_shipment( study_id: uuid.UUID, @@ -122,7 +122,7 @@ async def get_shipment( @router.patch( "/shipments/{shipment_id}", response_model=DrugShipmentRead, - dependencies=[Depends(require_study_roles(["PM", "CRA"])), Depends(require_study_not_locked())], + dependencies=[Depends(require_study_permission("materials", "write")), Depends(require_study_not_locked())], ) async def update_shipment( study_id: uuid.UUID, @@ -160,7 +160,7 @@ async def update_shipment( @router.delete( "/shipments/{shipment_id}", status_code=status.HTTP_204_NO_CONTENT, - dependencies=[Depends(require_study_roles(["PM", "CRA"])), Depends(require_study_not_locked())], + dependencies=[Depends(require_study_permission("materials", "write")), Depends(require_study_not_locked())], ) async def delete_shipment( study_id: uuid.UUID, diff --git a/backend/app/api/v1/faq_categories.py b/backend/app/api/v1/faq_categories.py index d83a8d66..ac638a1c 100644 --- a/backend/app/api/v1/faq_categories.py +++ b/backend/app/api/v1/faq_categories.py @@ -4,6 +4,7 @@ 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 +from app.core.project_permissions import role_has_project_permission from app.crud import audit as audit_crud from app.crud import faq_category as category_crud from app.crud import faq_item as item_crud @@ -15,9 +16,21 @@ from app.utils.pagination import paginate router = APIRouter() -def _check_permission_for_scope(study_id: uuid.UUID, current_user, member_role: str | None): - if current_user.role != "ADMIN" and member_role != "PM": +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" + + +async def _require_category_permission(db: AsyncSession, study_id: uuid.UUID, current_user, action: str): + if _is_system_admin(current_user): + return None + 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="不是该项目成员") + allowed = await role_has_project_permission(db, study_id, member.role_in_study, "faq", action) + if not allowed: raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="权限不足") + return member @router.post( @@ -25,7 +38,7 @@ def _check_permission_for_scope(study_id: uuid.UUID, current_user, member_role: response_model=CategoryRead, status_code=status.HTTP_201_CREATED, summary="创建 FAQ 分类", - description="创建全局或项目内 FAQ 分类,项目 PM/ADMIN 可用,全局仅 ADMIN。", + description="创建项目内 FAQ 分类,权限由项目级权限矩阵控制。", dependencies=[Depends(require_study_not_locked())], ) async def create_category( @@ -38,12 +51,7 @@ async def create_category( existing = await category_crud.get_category_by_name(db, payload.study_id, payload.name) if existing: raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="分类名称已存在") - member_role = None - member = await member_crud.get_member(db, payload.study_id, current_user.id) - if not member and current_user.role != "ADMIN": - raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="不是该项目成员") - member_role = member.role_in_study if member else None - _check_permission_for_scope(payload.study_id, current_user, member_role) + await _require_category_permission(db, payload.study_id, current_user, "write") category = await category_crud.create_category(db, payload) await audit_crud.log_action( db, @@ -73,9 +81,7 @@ async def list_categories( if not study_id: raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="必须提供项目 ID") if study_id: - member = await member_crud.get_member(db, study_id, current_user.id) - if not member and current_user.role != "ADMIN": - raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="不是该项目成员") + await _require_category_permission(db, study_id, current_user, "read") categories = await category_crud.list_categories(db, study_id, include_global=False, is_active=is_active) return paginate([CategoryRead.model_validate(c) for c in categories], total=len(categories)) @@ -84,7 +90,7 @@ async def list_categories( "/{category_id}", response_model=CategoryRead, summary="更新 FAQ 分类", - description="更新分类名称或启停状态,项目 PM/ADMIN 或全局 ADMIN。", + description="更新分类名称或启停状态,权限由项目级权限矩阵控制。", dependencies=[Depends(require_study_not_locked())], ) async def update_category( @@ -96,22 +102,17 @@ async def update_category( category = await category_crud.get_category(db, category_id) if not category: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="分类不存在") - member_role = None update_data = payload.model_dump(exclude_unset=True) target_study_id = update_data.get("study_id", category.study_id) target_name = update_data.get("name", category.name) - if "study_id" in update_data and update_data["study_id"] != category.study_id and current_user.role != "ADMIN": + if "study_id" in update_data and update_data["study_id"] != category.study_id and not _is_system_admin(current_user): raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="仅管理员可修改分类范围") existing = await category_crud.get_category_by_name(db, target_study_id, target_name) if existing and existing.id != category.id: raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="分类名称已存在") if not target_study_id: raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="必须提供项目 ID") - member = await member_crud.get_member(db, target_study_id, current_user.id) - member_role = member.role_in_study if member else None - if not member and current_user.role != "ADMIN": - raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="不是该项目成员") - _check_permission_for_scope(target_study_id, current_user, member_role) + await _require_category_permission(db, target_study_id, current_user, "write") updated = await category_crud.update_category(db, category, payload) await audit_crud.log_action( db, @@ -130,7 +131,7 @@ async def update_category( "/{category_id}", status_code=status.HTTP_204_NO_CONTENT, summary="删除 FAQ 分类", - description="仅 ADMIN 可删除分类,分类下存在 FAQ 时不可删除。", + description="删除分类,权限由项目级权限矩阵控制;分类下存在 FAQ 时不可删除。", dependencies=[Depends(require_study_not_locked())], ) async def delete_category( @@ -141,8 +142,9 @@ async def delete_category( category = await category_crud.get_category(db, category_id) if not category: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="分类不存在") - if current_user.role != "ADMIN": - raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="仅管理员可删除 FAQ 分类") + if not category.study_id: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="必须提供项目 ID") + await _require_category_permission(db, category.study_id, current_user, "write") item_count = await item_crud.count_items_by_category(db, category_id) if item_count > 0: raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="分类下存在 FAQ,无法删除") diff --git a/backend/app/api/v1/faqs.py b/backend/app/api/v1/faqs.py index 63fc1642..d13eb515 100644 --- a/backend/app/api/v1/faqs.py +++ b/backend/app/api/v1/faqs.py @@ -4,6 +4,7 @@ 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 +from app.core.project_permissions import role_has_project_permission from app.crud import audit as audit_crud from app.crud import faq_category as category_crud from app.crud import faq_item as faq_crud @@ -25,14 +26,28 @@ from app.utils.pagination import paginate router = APIRouter() -def _check_write_permission(study_id: uuid.UUID, current_user, member_role: str | None): - if current_user.role != "ADMIN" and member_role != "PM": - raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="权限不足") +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" -def _check_create_permission(current_user, is_member: bool): - if current_user.role != "ADMIN" and not is_member: +async def _require_project_permission( + db: AsyncSession, + study_id: uuid.UUID | None, + current_user, + action: str, +): + if not study_id: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="FAQ 不存在") + if _is_system_admin(current_user): + return None + 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="不是该项目成员") + allowed = await role_has_project_permission(db, study_id, member.role_in_study, "faq", action) + if not allowed: + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="权限不足") + return member @router.post( @@ -40,7 +55,7 @@ def _check_create_permission(current_user, is_member: bool): response_model=FaqRead, status_code=status.HTTP_201_CREATED, summary="创建 FAQ", - description="创建全局或项目内 FAQ,项目 FAQ 需项目 PM/ADMIN 权限。", + description="创建项目内 FAQ,权限由项目级权限矩阵控制。", dependencies=[Depends(require_study_not_locked())], ) async def create_faq( @@ -55,12 +70,7 @@ async def create_faq( raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="分类不存在") if cat.study_id != payload.study_id: raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="分类范围不匹配") - member_role = None - is_member = False - member = await member_crud.get_member(db, payload.study_id, current_user.id) - member_role = member.role_in_study if member else None - is_member = member is not None - _check_create_permission(current_user, is_member) + await _require_project_permission(db, payload.study_id, current_user, "write") try: item = await faq_crud.create_item(db, payload, created_by=current_user.id) except ValueError as exc: @@ -106,22 +116,14 @@ async def list_faqs( async def _get_member_role(sid: uuid.UUID) -> str | None: if sid in membership_cache: return membership_cache[sid] - member = await member_crud.get_member(db, sid, current_user.id) - role = member.role_in_study if member else None + member = await _require_project_permission(db, sid, current_user, "read") + role = member.role_in_study if member else "ADMIN" membership_cache[sid] = role return role if not study_id: raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="必须提供项目 ID") - if current_user.role != "ADMIN": - role = await _get_member_role(study_id) - if not role: - raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="不是该项目成员") - - if is_active is False and current_user.role != "ADMIN": - role = await _get_member_role(study_id) - if role != "PM": - raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="权限不足") + await _require_project_permission(db, study_id, current_user, "write" if is_active is False else "read") items = await faq_crud.list_items( db, @@ -134,13 +136,13 @@ async def list_faqs( visible: list[FaqRead] = [] for it in items: - if current_user.role != "ADMIN": + if not _is_system_admin(current_user): role = await _get_member_role(it.study_id) if not role: continue - if not it.is_active and current_user.role != "ADMIN": - role = await _get_member_role(it.study_id) - if role != "PM": + if not it.is_active and not _is_system_admin(current_user): + allowed = await role_has_project_permission(db, it.study_id, role, "faq", "write") + if not allowed: continue visible.append(FaqRead.model_validate(it)) return paginate(visible, total=len(visible)) @@ -150,7 +152,7 @@ async def list_faqs( "/{item_id}", response_model=FaqRead, summary="FAQ 详情", - description="获取单条 FAQ,非 PM 不能查看停用 FAQ。", + description="获取单条 FAQ,停用 FAQ 需项目级写权限。", ) async def get_faq( item_id: uuid.UUID, @@ -162,15 +164,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 不存在") - if current_user.role != "ADMIN": - member = await member_crud.get_member(db, item.study_id, current_user.id) - if not member: - raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="不是该项目成员") - if not item.is_active and current_user.role not in {"ADMIN"}: - member = await member_crud.get_member(db, item.study_id, current_user.id) - member_role = member.role_in_study if member else None - if member_role != "PM": - raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="FAQ 已停用") + await _require_project_permission(db, item.study_id, current_user, "write" if not item.is_active else "read") return FaqRead.model_validate(item) @@ -178,7 +172,7 @@ async def get_faq( "/{item_id}", response_model=FaqRead, summary="更新 FAQ", - description="更新 FAQ 内容或启停状态,项目内需 PM/ADMIN 权限。", + description="更新 FAQ 内容或启停状态,权限由项目级权限矩阵控制。", dependencies=[Depends(require_study_not_locked())], ) async def update_faq( @@ -191,11 +185,9 @@ async def update_faq( if not item: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="FAQ 不存在") if item.created_by != current_user.id: - member_role = None - if item.study_id: - member = await member_crud.get_member(db, item.study_id, current_user.id) - member_role = member.role_in_study if member else None - _check_write_permission(item.study_id, current_user, member_role) + await _require_project_permission(db, item.study_id, current_user, "write") + else: + await _require_project_permission(db, item.study_id, current_user, "write") old_active = item.is_active updated = await faq_crud.update_item(db, item, payload) action = "UPDATE_FAQ_ITEM" @@ -220,7 +212,7 @@ async def update_faq( "/{item_id}/status", response_model=FaqRead, summary="更新 FAQ 状态", - description="提问者或项目 PM/ADMIN 可确认已解决。", + description="提问者或具备项目级写权限的成员可确认已解决。", dependencies=[Depends(require_study_not_locked())], ) async def update_faq_status( @@ -234,13 +226,10 @@ async def update_faq_status( raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="FAQ 不存在") if payload.status != "RESOLVED": raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="仅允许设置为已解决") - if item.created_by != current_user.id and current_user.role != "ADMIN": - member_role = None - if item.study_id: - member = await member_crud.get_member(db, item.study_id, current_user.id) - member_role = member.role_in_study if member else None - if member_role != "PM": - raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="权限不足") + if item.created_by != current_user.id: + await _require_project_permission(db, item.study_id, current_user, "write") + else: + await _require_project_permission(db, item.study_id, current_user, "read") await faq_crud.set_status(db, item.id, "RESOLVED", resolved_by_confirm=True) updated = await faq_crud.get_item(db, item_id) return FaqRead.model_validate(updated) @@ -264,10 +253,7 @@ async def set_best_reply( 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 不存在") - is_member = False - member = await member_crud.get_member(db, item.study_id, current_user.id) - is_member = member is not None - _check_create_permission(current_user, is_member) + await _require_project_permission(db, item.study_id, current_user, "write") if payload.best_reply_id: reply = await reply_crud.get_reply(db, payload.best_reply_id) if not reply or reply.faq_id != item.id: @@ -304,10 +290,7 @@ async def list_replies( item = await faq_crud.get_item(db, item_id) if not item: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="FAQ 不存在") - if item.study_id and current_user.role != "ADMIN": - member = await member_crud.get_member(db, item.study_id, current_user.id) - if not member: - raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="不是该项目成员") + await _require_project_permission(db, item.study_id, current_user, "read") replies = await reply_crud.list_replies(db, item_id) reply_map = {r.id: r for r in replies} result: list[FaqReplyRead] = [] @@ -349,10 +332,7 @@ async def create_reply( raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="FAQ 不存在") if not payload.content.strip(): raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="回复内容不能为空") - is_member = False - member = await member_crud.get_member(db, item.study_id, current_user.id) - is_member = member is not None - _check_create_permission(current_user, is_member) + await _require_project_permission(db, item.study_id, current_user, "write") quote = None if payload.quote_reply_id: quote = await reply_crud.get_reply(db, payload.quote_reply_id) @@ -396,7 +376,7 @@ async def create_reply( "/{item_id}", status_code=status.HTTP_204_NO_CONTENT, summary="删除 FAQ", - description="删除 FAQ,项目内需 PM/ADMIN 权限,全局仅 ADMIN。", + description="删除 FAQ,权限由项目级权限矩阵控制。", dependencies=[Depends(require_study_not_locked())], ) async def delete_faq( @@ -408,11 +388,9 @@ async def delete_faq( if not item: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="FAQ 不存在") if item.created_by != current_user.id: - member_role = None - if item.study_id: - member = await member_crud.get_member(db, item.study_id, current_user.id) - member_role = member.role_in_study if member else None - _check_write_permission(item.study_id, current_user, member_role) + await _require_project_permission(db, item.study_id, current_user, "write") + else: + await _require_project_permission(db, item.study_id, current_user, "write") await reply_crud.delete_replies_by_faq_id(db, item.id) await db.delete(item) await db.commit() @@ -432,7 +410,7 @@ async def delete_faq( "/{item_id}/replies/{reply_id}", status_code=status.HTTP_204_NO_CONTENT, summary="删除 FAQ 回复", - description="删除 FAQ 回复,管理员、项目 PM 或回复者可删除。", + description="删除 FAQ 回复,回复者或具备项目级写权限的成员可删除。", dependencies=[Depends(require_study_not_locked())], ) async def delete_reply( @@ -447,13 +425,10 @@ async def delete_reply( reply = await reply_crud.get_reply(db, reply_id) if not reply or reply.faq_id != item.id: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="回复不存在") - if reply.created_by != current_user.id and current_user.role != "ADMIN": - member_role = None - if item.study_id: - member = await member_crud.get_member(db, item.study_id, current_user.id) - member_role = member.role_in_study if member else None - if member_role != "PM": - raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="权限不足") + if reply.created_by != current_user.id: + await _require_project_permission(db, item.study_id, current_user, "write") + else: + await _require_project_permission(db, item.study_id, current_user, "read") if item.best_reply_id == reply.id: await faq_crud.set_best_reply(db, item.id, None) ref_count = await reply_crud.count_quote_references(db, reply.id) diff --git a/backend/app/api/v1/fees_attachments.py b/backend/app/api/v1/fees_attachments.py index 32928ccb..ab7d61e4 100644 --- a/backend/app/api/v1/fees_attachments.py +++ b/backend/app/api/v1/fees_attachments.py @@ -8,6 +8,7 @@ from fastapi.responses import FileResponse from sqlalchemy.ext.asyncio import AsyncSession from app.core.deps import get_current_user, get_db_session, require_study_not_locked +from app.core.project_permissions import role_has_project_permission from app.core.security import decode_token from app.crud import audit as audit_crud from app.crud import contract_fee as contract_fee_crud @@ -54,7 +55,9 @@ async def _ensure_project_access(db: AsyncSession, project_id: uuid.UUID, curren membership = await member_crud.get_member(db, project_id, current_user.id) if not membership or not membership.is_active: raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="不是项目成员") - if write and membership.role_in_study not in {"ADMIN", "PM"}: + action = "write" if write else "read" + allowed = await role_has_project_permission(db, project_id, membership.role_in_study, "fees", action) + if not allowed: raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="项目权限不足") return membership @@ -230,7 +233,7 @@ async def delete_fee_attachment( can_delete = ( role_value == "ADMIN" or attachment.uploaded_by == current_user.id - or (membership and getattr(membership, "role_in_study", None) == "PM") + or membership is not None ) if not can_delete: raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="无权限删除附件") diff --git a/backend/app/api/v1/fees_contracts.py b/backend/app/api/v1/fees_contracts.py index 39efd221..8d7c7435 100644 --- a/backend/app/api/v1/fees_contracts.py +++ b/backend/app/api/v1/fees_contracts.py @@ -7,6 +7,7 @@ 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 +from app.core.project_permissions import role_has_project_permission from app.crud import audit as audit_crud from app.crud import contract_fee as contract_fee_crud from app.crud import contract_fee_payment as payment_crud @@ -38,7 +39,9 @@ async def _ensure_project_access(db: AsyncSession, project_id: uuid.UUID, curren membership = await member_crud.get_member(db, project_id, current_user.id) if not membership or not membership.is_active: raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="不是项目成员") - if write and membership.role_in_study not in {"ADMIN", "PM"}: + action = "write" if write else "read" + allowed = await role_has_project_permission(db, project_id, membership.role_in_study, "fees", action) + if not allowed: raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="项目权限不足") return membership diff --git a/backend/app/api/v1/finance_contracts.py b/backend/app/api/v1/finance_contracts.py index ffee27b2..1aedf942 100644 --- a/backend/app/api/v1/finance_contracts.py +++ b/backend/app/api/v1/finance_contracts.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_member, require_study_roles, require_study_not_locked +from app.core.deps import get_cra_site_scope, get_current_user, get_db_session, require_study_not_locked, require_study_permission from app.crud import audit as audit_crud from app.crud import finance_contract as contract_crud from app.crud import site as site_crud @@ -32,7 +32,7 @@ async def _ensure_site_name_active(db: AsyncSession, study_id: uuid.UUID, site_n "/contracts", response_model=FinanceContractRead, status_code=status.HTTP_201_CREATED, - dependencies=[Depends(require_study_roles(["PM", "CRA"]))], + dependencies=[Depends(require_study_permission("fees", "write"))], ) async def create_contract( study_id: uuid.UUID, @@ -62,7 +62,7 @@ async def create_contract( @router.get( "/contracts", response_model=list[FinanceContractRead], - dependencies=[Depends(require_study_member())], + dependencies=[Depends(require_study_permission("fees", "read"))], ) async def list_contracts( study_id: uuid.UUID, @@ -93,7 +93,7 @@ async def list_contracts( @router.get( "/contracts/{contract_id}", response_model=FinanceContractRead, - dependencies=[Depends(require_study_member())], + dependencies=[Depends(require_study_permission("fees", "read"))], ) async def get_contract( study_id: uuid.UUID, @@ -114,7 +114,7 @@ async def get_contract( @router.patch( "/contracts/{contract_id}", response_model=FinanceContractRead, - dependencies=[Depends(require_study_roles(["PM", "CRA"]))], + dependencies=[Depends(require_study_permission("fees", "write"))], ) async def update_contract( study_id: uuid.UUID, @@ -148,7 +148,7 @@ async def update_contract( @router.delete( "/contracts/{contract_id}", status_code=status.HTTP_204_NO_CONTENT, - dependencies=[Depends(require_study_roles(["PM", "CRA"]))], + dependencies=[Depends(require_study_permission("fees", "write"))], ) async def delete_contract( study_id: uuid.UUID, diff --git a/backend/app/api/v1/knowledge_notes.py b/backend/app/api/v1/knowledge_notes.py index cc491008..5b000203 100644 --- a/backend/app/api/v1/knowledge_notes.py +++ b/backend/app/api/v1/knowledge_notes.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_member, require_study_roles, require_study_not_locked +from app.core.deps import get_current_user, get_db_session, require_study_not_locked, require_study_permission from app.crud import audit as audit_crud from app.crud import knowledge_note as note_crud from app.crud import site as site_crud @@ -32,7 +32,7 @@ async def _ensure_site_name_active(db: AsyncSession, study_id: uuid.UUID, site_n "/notes", response_model=KnowledgeNoteRead, status_code=status.HTTP_201_CREATED, - dependencies=[Depends(require_study_roles(["PM", "CRA"]))], + dependencies=[Depends(require_study_permission("shared_library", "write"))], ) async def create_note( study_id: uuid.UUID, @@ -59,7 +59,7 @@ async def create_note( @router.get( "/notes", response_model=list[KnowledgeNoteRead], - dependencies=[Depends(require_study_member())], + dependencies=[Depends(require_study_permission("shared_library", "read"))], ) async def list_notes( study_id: uuid.UUID, @@ -77,7 +77,7 @@ async def list_notes( @router.get( "/notes/{note_id}", response_model=KnowledgeNoteRead, - dependencies=[Depends(require_study_member())], + dependencies=[Depends(require_study_permission("shared_library", "read"))], ) async def get_note( study_id: uuid.UUID, @@ -94,7 +94,7 @@ async def get_note( @router.patch( "/notes/{note_id}", response_model=KnowledgeNoteRead, - dependencies=[Depends(require_study_roles(["PM", "CRA"]))], + dependencies=[Depends(require_study_permission("shared_library", "write"))], ) async def update_note( study_id: uuid.UUID, @@ -125,7 +125,7 @@ async def update_note( @router.delete( "/notes/{note_id}", status_code=status.HTTP_204_NO_CONTENT, - dependencies=[Depends(require_study_roles(["PM", "CRA"]))], + dependencies=[Depends(require_study_permission("shared_library", "write"))], ) async def delete_note( study_id: uuid.UUID, diff --git a/backend/app/api/v1/material_equipments.py b/backend/app/api/v1/material_equipments.py index 469dfff5..a17fed00 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_member, require_study_not_locked, require_study_roles +from app.core.deps import get_current_user, get_db_session, require_study_not_locked, require_study_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 @@ -28,7 +28,7 @@ def _validate_calibration(need_calibration: bool, calibration_cycle_days: int | "/equipment", response_model=MaterialEquipmentRead, status_code=status.HTTP_201_CREATED, - dependencies=[Depends(require_study_roles(["PM", "CRA"])), Depends(require_study_not_locked())], + dependencies=[Depends(require_study_permission("materials", "write")), Depends(require_study_not_locked())], ) async def create_equipment( study_id: uuid.UUID, @@ -57,7 +57,7 @@ async def create_equipment( @router.get( "/equipment", response_model=list[MaterialEquipmentRead], - dependencies=[Depends(require_study_member())], + dependencies=[Depends(require_study_permission("materials", "read"))], ) async def list_equipments( study_id: uuid.UUID, @@ -74,7 +74,7 @@ async def list_equipments( @router.get( "/equipment/{equipment_id}", response_model=MaterialEquipmentRead, - dependencies=[Depends(require_study_member())], + dependencies=[Depends(require_study_permission("materials", "read"))], ) async def get_equipment( study_id: uuid.UUID, @@ -91,7 +91,7 @@ async def get_equipment( @router.patch( "/equipment/{equipment_id}", response_model=MaterialEquipmentRead, - dependencies=[Depends(require_study_roles(["PM", "CRA"])), Depends(require_study_not_locked())], + dependencies=[Depends(require_study_permission("materials", "write")), Depends(require_study_not_locked())], ) async def update_equipment( study_id: uuid.UUID, @@ -128,7 +128,7 @@ async def update_equipment( @router.delete( "/equipment/{equipment_id}", status_code=status.HTTP_204_NO_CONTENT, - dependencies=[Depends(require_study_roles(["PM", "CRA"])), Depends(require_study_not_locked())], + dependencies=[Depends(require_study_permission("materials", "write")), Depends(require_study_not_locked())], ) async def delete_equipment( study_id: uuid.UUID, diff --git a/backend/app/api/v1/members.py b/backend/app/api/v1/members.py index 27a259b2..764ad09b 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_study_roles, require_study_not_locked +from app.core.deps import get_current_user, get_db_session, require_study_member, require_study_permission, require_study_not_locked from app.crud import audit as audit_crud from app.crud import member as member_crud from app.crud import study as study_crud @@ -26,7 +26,7 @@ async def _ensure_study_exists(db: AsyncSession, study_id: uuid.UUID): "/", response_model=StudyMemberRead, status_code=status.HTTP_201_CREATED, - dependencies=[Depends(require_study_roles(["ADMIN", "PM"])), Depends(require_study_not_locked())], + dependencies=[Depends(require_study_permission("project_members", "write")), Depends(require_study_not_locked())], ) async def add_member( study_id: uuid.UUID, @@ -115,7 +115,7 @@ async def list_members( @router.patch( "/{member_id}", response_model=StudyMemberRead, - dependencies=[Depends(require_study_roles(["ADMIN", "PM"])), Depends(require_study_not_locked())], + dependencies=[Depends(require_study_permission("project_members", "write")), Depends(require_study_not_locked())], ) async def update_member( study_id: uuid.UUID, @@ -147,7 +147,7 @@ async def update_member( @router.delete( "/{member_id}", response_model=StudyMemberRead, - dependencies=[Depends(require_study_roles(["ADMIN", "PM"])), Depends(require_study_not_locked())], + dependencies=[Depends(require_study_permission("project_members", "write")), Depends(require_study_not_locked())], ) async def remove_member( study_id: uuid.UUID, diff --git a/backend/app/api/v1/monitoring_visit_issues.py b/backend/app/api/v1/monitoring_visit_issues.py index 710fc65e..aecadd36 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_member, require_study_not_locked, require_study_roles +from app.core.deps import get_current_user, get_db_session, require_study_not_locked, require_study_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 @@ -332,7 +332,7 @@ def _normalize_file_rows(filename: str, content: bytes) -> list[dict[str, object @router.get( "/issues", response_model=list[MonitoringVisitIssueRead], - dependencies=[Depends(require_study_member())], + dependencies=[Depends(require_study_permission("monitoring_audit", "read"))], ) async def list_monitoring_visit_issues( study_id: uuid.UUID, @@ -382,7 +382,7 @@ async def list_monitoring_visit_issues( "/issues", response_model=MonitoringVisitIssueRead, status_code=status.HTTP_201_CREATED, - dependencies=[Depends(require_study_roles(["PM", "CRA"])), Depends(require_study_not_locked())], + dependencies=[Depends(require_study_permission("monitoring_audit", "write")), Depends(require_study_not_locked())], ) async def create_monitoring_visit_issue( study_id: uuid.UUID, @@ -421,7 +421,7 @@ async def create_monitoring_visit_issue( @router.get( "/issues/export", response_class=StreamingResponse, - dependencies=[Depends(require_study_member())], + dependencies=[Depends(require_study_permission("monitoring_audit", "read"))], ) async def export_monitoring_visit_issues( study_id: uuid.UUID, @@ -553,7 +553,7 @@ async def export_monitoring_visit_issues( @router.get( "/issues/{issue_id}", response_model=MonitoringVisitIssueRead, - dependencies=[Depends(require_study_member())], + dependencies=[Depends(require_study_permission("monitoring_audit", "read"))], ) async def get_monitoring_visit_issue( study_id: uuid.UUID, @@ -570,7 +570,7 @@ async def get_monitoring_visit_issue( @router.patch( "/issues/{issue_id}", response_model=MonitoringVisitIssueRead, - dependencies=[Depends(require_study_roles(["PM", "CRA"])), Depends(require_study_not_locked())], + dependencies=[Depends(require_study_permission("monitoring_audit", "write")), Depends(require_study_not_locked())], ) async def update_monitoring_visit_issue( study_id: uuid.UUID, @@ -625,7 +625,7 @@ async def update_monitoring_visit_issue( @router.delete( "/issues/{issue_id}", status_code=status.HTTP_204_NO_CONTENT, - dependencies=[Depends(require_study_roles(["PM", "CRA"])), Depends(require_study_not_locked())], + dependencies=[Depends(require_study_permission("monitoring_audit", "write")), Depends(require_study_not_locked())], ) async def delete_monitoring_visit_issue( study_id: uuid.UUID, @@ -655,7 +655,7 @@ async def delete_monitoring_visit_issue( @router.post( "/issues/import", response_model=MonitoringVisitIssueImportSummary, - dependencies=[Depends(require_study_roles(["PM", "CRA"])), Depends(require_study_not_locked())], + dependencies=[Depends(require_study_permission("monitoring_audit", "write")), Depends(require_study_not_locked())], ) async def import_monitoring_visit_issues( study_id: uuid.UUID, diff --git a/backend/app/api/v1/overview.py b/backend/app/api/v1/overview.py index bcf650bb..fa5e3e1c 100644 --- a/backend/app/api/v1/overview.py +++ b/backend/app/api/v1/overview.py @@ -4,7 +4,7 @@ import uuid 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_study_permission from app.crud import overview as overview_crud from app.schemas.overview import ProjectOverviewResponse @@ -14,7 +14,7 @@ router = APIRouter() @router.get( "/overview", response_model=ProjectOverviewResponse, - dependencies=[Depends(require_study_member())], + dependencies=[Depends(require_study_permission("project_overview", "read"))], ) async def get_project_overview( study_id: uuid.UUID, diff --git a/backend/app/api/v1/project_milestones.py b/backend/app/api/v1/project_milestones.py index fbaab559..94b22193 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_member, require_study_not_locked, require_study_roles +from app.core.deps import get_current_user, get_db_session, require_study_not_locked, require_study_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 @@ -29,7 +29,7 @@ def _validate_date_range(start, end, label: str): @router.get( "/milestones", response_model=list[ProjectMilestoneRead], - dependencies=[Depends(require_study_member())], + dependencies=[Depends(require_study_permission("project_milestones", "read"))], ) async def list_project_milestones( study_id: uuid.UUID, @@ -43,7 +43,7 @@ async def list_project_milestones( @router.patch( "/milestones/{milestone_id}", response_model=ProjectMilestoneRead, - dependencies=[Depends(require_study_roles(["PM", "CRA"])), Depends(require_study_not_locked())], + dependencies=[Depends(require_study_permission("project_milestones", "write")), Depends(require_study_not_locked())], ) async def update_project_milestone( study_id: uuid.UUID, diff --git a/backend/app/api/v1/project_permissions.py b/backend/app/api/v1/project_permissions.py new file mode 100644 index 00000000..6cf13062 --- /dev/null +++ b/backend/app/api/v1/project_permissions.py @@ -0,0 +1,66 @@ +from __future__ import annotations + +import json +import uuid + +from fastapi import APIRouter, Depends, HTTPException, status +from sqlalchemy.ext.asyncio import AsyncSession + +from app.core.deps import get_current_user, get_db_session, require_roles, require_study_member +from app.core.project_permissions import PROJECT_PERMISSION_MODULES, get_project_role_permissions, replace_project_role_permissions +from app.crud import audit as audit_crud +from app.crud import study as study_crud +from app.schemas.project_permission import ProjectPermissionModule, ProjectRolePermissionsRead, ProjectRolePermissionsUpdate + +router = APIRouter() + + +async def _ensure_study_exists(db: AsyncSession, study_id: uuid.UUID): + study = await study_crud.get(db, study_id) + if not study: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="项目不存在") + return study + + +@router.get("/", response_model=ProjectRolePermissionsRead, dependencies=[Depends(require_study_member())]) +async def get_permissions( + study_id: uuid.UUID, + db: AsyncSession = Depends(get_db_session), +) -> ProjectRolePermissionsRead: + await _ensure_study_exists(db, study_id) + roles = await get_project_role_permissions(db, study_id) + modules = [ProjectPermissionModule(**item) for item in PROJECT_PERMISSION_MODULES] + return ProjectRolePermissionsRead(modules=modules, roles=roles) + + +@router.put("/", response_model=ProjectRolePermissionsRead, dependencies=[Depends(require_roles(["ADMIN"]))]) +async def update_permissions( + study_id: uuid.UUID, + payload: ProjectRolePermissionsUpdate, + db: AsyncSession = Depends(get_db_session), + current_user=Depends(get_current_user), +) -> ProjectRolePermissionsRead: + await _ensure_study_exists(db, study_id) + roles = await replace_project_role_permissions( + db, + study_id, + { + role: { + module: actions.model_dump() + for module, actions in modules.items() + } + for role, modules in payload.roles.items() + }, + ) + await audit_crud.log_action( + db, + study_id=study_id, + entity_type="project_permissions", + entity_id=study_id, + action="PROJECT_PERMISSIONS_UPDATED", + detail=json.dumps({"targetName": "项目权限", "after": roles}, ensure_ascii=False), + operator_id=current_user.id, + operator_role=current_user.role, + ) + modules = [ProjectPermissionModule(**item) for item in PROJECT_PERMISSION_MODULES] + return ProjectRolePermissionsRead(modules=modules, roles=roles) diff --git a/backend/app/api/v1/router.py b/backend/app/api/v1/router.py index b811f85d..b7f1c455 100644 --- a/backend/app/api/v1/router.py +++ b/backend/app/api/v1/router.py @@ -1,6 +1,6 @@ from fastapi import APIRouter -from app.api.v1 import auth, users, admin_users, studies, sites, members, attachments, audit_logs, dashboard, subjects, visits, aes, finance_dashboard, finance_contracts, fees_contracts, fees_attachments, drug_shipments, material_equipments, project_milestones, startup, knowledge_notes, subject_histories, subject_pds, study_subject_pds, faq_categories, faqs, documents, overview, notifications, monitoring_visit_issues +from app.api.v1 import auth, users, admin_users, studies, sites, members, attachments, audit_logs, dashboard, subjects, visits, aes, finance_dashboard, finance_contracts, fees_contracts, fees_attachments, drug_shipments, material_equipments, project_milestones, startup, knowledge_notes, subject_histories, subject_pds, study_subject_pds, faq_categories, faqs, documents, overview, notifications, monitoring_visit_issues, project_permissions api_router = APIRouter() @@ -12,6 +12,7 @@ api_router.include_router(overview.router, prefix="/studies/{study_id}", tags=[" api_router.include_router(notifications.router, prefix="/studies/{study_id}", tags=["notifications"]) api_router.include_router(sites.router, prefix="/studies/{study_id}/sites", tags=["sites"]) api_router.include_router(members.router, prefix="/studies/{study_id}/members", tags=["study-members"]) +api_router.include_router(project_permissions.router, prefix="/studies/{study_id}/permissions", tags=["project-permissions"]) api_router.include_router(attachments.router, prefix="/studies/{study_id}/{entity_type}/{entity_id}/attachments", tags=["attachments"]) api_router.include_router(attachments.global_router, prefix="/attachments", tags=["attachments"]) api_router.include_router(audit_logs.router, prefix="/studies/{study_id}/audit-logs", tags=["audit-logs"]) diff --git a/backend/app/api/v1/sites.py b/backend/app/api/v1/sites.py index 08af9013..5ff81369 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_study_roles, require_study_not_locked +from app.core.deps import get_cra_site_scope, get_current_user, get_db_session, require_study_member, require_study_permission, require_study_not_locked from app.crud import audit as audit_crud from app.crud import site as site_crud from app.crud import startup as startup_crud @@ -26,7 +26,7 @@ async def _ensure_study_exists(db: AsyncSession, study_id: uuid.UUID): "/", response_model=SiteRead, status_code=status.HTTP_201_CREATED, - dependencies=[Depends(require_study_roles(["ADMIN", "PM"])), Depends(require_study_not_locked())], + dependencies=[Depends(require_study_permission("sites", "write")), Depends(require_study_not_locked())], ) async def create_site( study_id: uuid.UUID, @@ -91,7 +91,7 @@ async def list_sites( @router.patch( "/{site_id}", response_model=SiteRead, - dependencies=[Depends(require_study_roles(["ADMIN", "PM"])), Depends(require_study_not_locked())], + dependencies=[Depends(require_study_permission("sites", "write")), Depends(require_study_not_locked())], ) async def update_site( study_id: uuid.UUID, diff --git a/backend/app/api/v1/startup.py b/backend/app/api/v1/startup.py index 4852cc2a..17117349 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_study_member +from app.core.deps import get_cra_site_scope, get_current_user, get_db_session, require_study_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 @@ -53,7 +53,7 @@ async def _ensure_site_active(db: AsyncSession, site_id: uuid.UUID | None): "/feasibility", response_model=StartupFeasibilityRead, status_code=status.HTTP_201_CREATED, - dependencies=[Depends(require_study_member())], + dependencies=[Depends(require_study_permission("startup_ethics", "write"))], ) async def create_feasibility( study_id: uuid.UUID, @@ -83,7 +83,7 @@ async def create_feasibility( @router.get( "/feasibility", response_model=list[StartupFeasibilityRead], - dependencies=[Depends(require_study_member())], + dependencies=[Depends(require_study_permission("startup_ethics", "read"))], ) async def list_feasibilities( study_id: uuid.UUID, @@ -102,7 +102,7 @@ async def list_feasibilities( @router.get( "/feasibility/{record_id}", response_model=StartupFeasibilityRead, - dependencies=[Depends(require_study_member())], + dependencies=[Depends(require_study_permission("startup_ethics", "read"))], ) async def get_feasibility( study_id: uuid.UUID, @@ -123,7 +123,7 @@ async def get_feasibility( @router.patch( "/feasibility/{record_id}", response_model=StartupFeasibilityRead, - dependencies=[Depends(require_study_member())], + dependencies=[Depends(require_study_permission("startup_ethics", "write"))], ) async def update_feasibility( study_id: uuid.UUID, @@ -157,7 +157,7 @@ async def update_feasibility( @router.delete( "/feasibility/{record_id}", status_code=status.HTTP_204_NO_CONTENT, - dependencies=[Depends(require_study_member())], + dependencies=[Depends(require_study_permission("startup_ethics", "write"))], ) async def delete_feasibility( study_id: uuid.UUID, @@ -190,7 +190,7 @@ async def delete_feasibility( "/ethics", response_model=StartupEthicsRead, status_code=status.HTTP_201_CREATED, - dependencies=[Depends(require_study_member())], + dependencies=[Depends(require_study_permission("startup_ethics", "write"))], ) async def create_ethics( study_id: uuid.UUID, @@ -220,7 +220,7 @@ async def create_ethics( @router.get( "/ethics", response_model=list[StartupEthicsRead], - dependencies=[Depends(require_study_member())], + dependencies=[Depends(require_study_permission("startup_ethics", "read"))], ) async def list_ethics( study_id: uuid.UUID, @@ -239,7 +239,7 @@ async def list_ethics( @router.get( "/ethics/{record_id}", response_model=StartupEthicsRead, - dependencies=[Depends(require_study_member())], + dependencies=[Depends(require_study_permission("startup_ethics", "read"))], ) async def get_ethics( study_id: uuid.UUID, @@ -260,7 +260,7 @@ async def get_ethics( @router.patch( "/ethics/{record_id}", response_model=StartupEthicsRead, - dependencies=[Depends(require_study_member())], + dependencies=[Depends(require_study_permission("startup_ethics", "write"))], ) async def update_ethics( study_id: uuid.UUID, @@ -294,7 +294,7 @@ async def update_ethics( @router.delete( "/ethics/{record_id}", status_code=status.HTTP_204_NO_CONTENT, - dependencies=[Depends(require_study_member())], + dependencies=[Depends(require_study_permission("startup_ethics", "write"))], ) async def delete_ethics( study_id: uuid.UUID, @@ -327,7 +327,7 @@ async def delete_ethics( "/kickoff", response_model=KickoffMeetingRead, status_code=status.HTTP_201_CREATED, - dependencies=[Depends(require_study_member())], + dependencies=[Depends(require_study_permission("startup_auth", "write"))], ) async def create_kickoff( study_id: uuid.UUID, @@ -357,7 +357,7 @@ async def create_kickoff( @router.get( "/kickoff", response_model=list[KickoffMeetingRead], - dependencies=[Depends(require_study_member())], + dependencies=[Depends(require_study_permission("startup_auth", "read"))], ) async def list_kickoffs( study_id: uuid.UUID, @@ -376,7 +376,7 @@ async def list_kickoffs( @router.get( "/kickoff/{meeting_id}", response_model=KickoffMeetingRead, - dependencies=[Depends(require_study_member())], + dependencies=[Depends(require_study_permission("startup_auth", "read"))], ) async def get_kickoff( study_id: uuid.UUID, @@ -397,7 +397,7 @@ async def get_kickoff( @router.patch( "/kickoff/{meeting_id}", response_model=KickoffMeetingRead, - dependencies=[Depends(require_study_member())], + dependencies=[Depends(require_study_permission("startup_auth", "write"))], ) async def update_kickoff( study_id: uuid.UUID, @@ -432,7 +432,7 @@ async def update_kickoff( "/training-authorizations", response_model=TrainingAuthorizationRead, status_code=status.HTTP_201_CREATED, - dependencies=[Depends(require_study_member())], + dependencies=[Depends(require_study_permission("startup_auth", "write"))], ) async def create_training_authorization( study_id: uuid.UUID, @@ -462,7 +462,7 @@ async def create_training_authorization( @router.get( "/training-authorizations", response_model=list[TrainingAuthorizationRead], - dependencies=[Depends(require_study_member())], + dependencies=[Depends(require_study_permission("startup_auth", "read"))], ) async def list_training_authorizations( study_id: uuid.UUID, @@ -481,7 +481,7 @@ async def list_training_authorizations( @router.get( "/training-authorizations/{record_id}", response_model=TrainingAuthorizationRead, - dependencies=[Depends(require_study_member())], + dependencies=[Depends(require_study_permission("startup_auth", "read"))], ) async def get_training_authorization( study_id: uuid.UUID, @@ -502,7 +502,7 @@ async def get_training_authorization( @router.patch( "/training-authorizations/{record_id}", response_model=TrainingAuthorizationRead, - dependencies=[Depends(require_study_member())], + dependencies=[Depends(require_study_permission("startup_auth", "write"))], ) async def update_training_authorization( study_id: uuid.UUID, @@ -536,7 +536,7 @@ async def update_training_authorization( @router.delete( "/training-authorizations/{record_id}", status_code=status.HTTP_204_NO_CONTENT, - dependencies=[Depends(require_study_member())], + dependencies=[Depends(require_study_permission("startup_auth", "write"))], ) async def delete_training_authorization( study_id: uuid.UUID, diff --git a/backend/app/api/v1/studies.py b/backend/app/api/v1/studies.py index bde5ac0d..0f8b23ed 100644 --- a/backend/app/api/v1/studies.py +++ b/backend/app/api/v1/studies.py @@ -813,7 +813,7 @@ async def get_study( @router.patch( "/{study_id}", response_model=StudyRead, - dependencies=[Depends(require_study_roles(["ADMIN", "PM"]))], + dependencies=[Depends(require_roles(["ADMIN"]))], ) async def update_study( study_id: uuid.UUID, @@ -990,7 +990,7 @@ async def get_study_setup_config( @router.put( "/{study_id}/setup-config", response_model=StudySetupConfigRead, - dependencies=[Depends(require_study_roles(["ADMIN", "PM"])), Depends(require_study_not_locked())], + dependencies=[Depends(require_roles(["ADMIN"])), Depends(require_study_not_locked())], ) async def upsert_study_setup_config( study_id: uuid.UUID, @@ -1068,7 +1068,7 @@ async def upsert_study_setup_config( @router.post( "/{study_id}/setup-config/publish", response_model=StudySetupConfigRead, - dependencies=[Depends(require_study_roles(["ADMIN", "PM"])), Depends(require_study_not_locked())], + dependencies=[Depends(require_roles(["ADMIN"])), Depends(require_study_not_locked())], ) async def publish_study_setup_config( study_id: uuid.UUID, @@ -1221,7 +1221,7 @@ async def list_study_setup_config_versions( @router.post( "/{study_id}/setup-config/rollback", response_model=StudySetupConfigRead, - dependencies=[Depends(require_study_roles(["ADMIN", "PM"])), Depends(require_study_not_locked())], + dependencies=[Depends(require_roles(["ADMIN"])), Depends(require_study_not_locked())], ) async def rollback_study_setup_config( study_id: uuid.UUID, @@ -1279,7 +1279,7 @@ async def rollback_study_setup_config( @router.post( "/{study_id}/setup-config/draft/checkout-branch", response_model=StudySetupConfigRead, - dependencies=[Depends(require_study_roles(["ADMIN", "PM"])), Depends(require_study_not_locked())], + dependencies=[Depends(require_roles(["ADMIN"])), Depends(require_study_not_locked())], ) async def checkout_study_setup_config_branch_draft( study_id: uuid.UUID, @@ -1338,7 +1338,7 @@ async def checkout_study_setup_config_branch_draft( @router.post( "/{study_id}/setup-config/draft/clear", response_model=StudySetupConfigRead, - dependencies=[Depends(require_study_roles(["ADMIN", "PM"])), Depends(require_study_not_locked())], + dependencies=[Depends(require_roles(["ADMIN"])), Depends(require_study_not_locked())], ) async def clear_study_setup_config_draft( study_id: uuid.UUID, @@ -1390,7 +1390,7 @@ async def clear_study_setup_config_draft( @router.post( "/{study_id}/setup-config/draft/refill", response_model=StudySetupConfigRead, - dependencies=[Depends(require_study_roles(["ADMIN", "PM"])), Depends(require_study_not_locked())], + dependencies=[Depends(require_roles(["ADMIN"])), Depends(require_study_not_locked())], ) async def refill_study_setup_config_draft( study_id: uuid.UUID, @@ -1444,7 +1444,7 @@ async def refill_study_setup_config_draft( @router.post( "/{study_id}/setup-config/merge-main", response_model=StudySetupConfigRead, - dependencies=[Depends(require_study_roles(["ADMIN", "PM"])), Depends(require_study_not_locked())], + dependencies=[Depends(require_roles(["ADMIN"])), Depends(require_study_not_locked())], ) async def merge_study_setup_config_to_main( study_id: uuid.UUID, @@ -1539,7 +1539,7 @@ async def merge_study_setup_config_to_main( @router.delete( "/{study_id}/setup-config/versions/{target_version}", status_code=status.HTTP_204_NO_CONTENT, - dependencies=[Depends(require_study_roles(["ADMIN", "PM"])), Depends(require_study_not_locked())], + dependencies=[Depends(require_roles(["ADMIN"])), Depends(require_study_not_locked())], ) async def delete_study_setup_config_version( study_id: uuid.UUID, diff --git a/backend/app/api/v1/subject_histories.py b/backend/app/api/v1/subject_histories.py index 53cd36a1..ef589be0 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_member, require_study_roles, require_study_not_locked +from app.core.deps import get_current_user, get_db_session, require_study_not_locked, require_study_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 @@ -33,7 +33,7 @@ async def _ensure_subject_active(db: AsyncSession, subject) -> None: "/histories", response_model=SubjectHistoryRead, status_code=status.HTTP_201_CREATED, - dependencies=[Depends(require_study_roles(["PM", "CRA"]))], + dependencies=[Depends(require_study_permission("subjects", "write"))], ) async def create_history( study_id: uuid.UUID, @@ -66,7 +66,7 @@ async def create_history( @router.get( "/histories", response_model=list[SubjectHistoryRead], - dependencies=[Depends(require_study_member())], + dependencies=[Depends(require_study_permission("subjects", "read"))], ) async def list_histories( study_id: uuid.UUID, @@ -86,7 +86,7 @@ async def list_histories( @router.get( "/histories/{history_id}", response_model=SubjectHistoryRead, - dependencies=[Depends(require_study_member())], + dependencies=[Depends(require_study_permission("subjects", "read"))], ) async def get_history( study_id: uuid.UUID, @@ -107,7 +107,7 @@ async def get_history( @router.patch( "/histories/{history_id}", response_model=SubjectHistoryRead, - dependencies=[Depends(require_study_roles(["PM", "CRA"]))], + dependencies=[Depends(require_study_permission("subjects", "write"))], ) async def update_history( study_id: uuid.UUID, @@ -141,7 +141,7 @@ async def update_history( @router.delete( "/histories/{history_id}", status_code=status.HTTP_204_NO_CONTENT, - dependencies=[Depends(require_study_roles(["PM", "CRA"]))], + dependencies=[Depends(require_study_permission("subjects", "write"))], ) async def delete_history( study_id: uuid.UUID, diff --git a/backend/app/api/v1/subject_pds.py b/backend/app/api/v1/subject_pds.py index 2529855d..581f3577 100644 --- a/backend/app/api/v1/subject_pds.py +++ b/backend/app/api/v1/subject_pds.py @@ -6,9 +6,8 @@ from sqlalchemy.ext.asyncio import AsyncSession from app.core.deps import ( get_current_user, get_db_session, - require_study_member, require_study_not_locked, - require_study_roles, + require_study_permission, ) from app.crud import audit as audit_crud from app.crud import site as site_crud @@ -65,7 +64,7 @@ def _normalize_choice(value: str | None, allowed: set[str], field_name: str) -> @router.get( "/pds", response_model=list[SubjectPdRead], - dependencies=[Depends(require_study_member())], + dependencies=[Depends(require_study_permission("risk_issues", "read"))], ) async def list_subject_pds( study_id: uuid.UUID, @@ -84,7 +83,7 @@ async def list_subject_pds( "/pds", response_model=SubjectPdRead, status_code=status.HTTP_201_CREATED, - dependencies=[Depends(require_study_roles(["PM", "CRA"])), Depends(require_study_not_locked())], + dependencies=[Depends(require_study_permission("risk_issues", "write")), Depends(require_study_not_locked())], ) async def create_subject_pd( study_id: uuid.UUID, @@ -129,7 +128,7 @@ async def create_subject_pd( @router.patch( "/pds/{pd_id}", response_model=SubjectPdRead, - dependencies=[Depends(require_study_roles(["PM", "CRA"])), Depends(require_study_not_locked())], + dependencies=[Depends(require_study_permission("risk_issues", "write")), Depends(require_study_not_locked())], ) async def update_subject_pd( study_id: uuid.UUID, @@ -176,7 +175,7 @@ async def update_subject_pd( @router.delete( "/pds/{pd_id}", status_code=status.HTTP_204_NO_CONTENT, - dependencies=[Depends(require_study_roles(["PM", "CRA"])), Depends(require_study_not_locked())], + dependencies=[Depends(require_study_permission("risk_issues", "write")), Depends(require_study_not_locked())], ) async def delete_subject_pd( study_id: uuid.UUID, diff --git a/backend/app/api/v1/subjects.py b/backend/app/api/v1/subjects.py index 8c33e466..4d8e4f43 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_member, require_study_roles, require_study_not_locked +from app.core.deps import get_cra_site_scope, get_current_user, get_db_session, require_study_not_locked, require_study_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 @@ -34,7 +34,7 @@ async def _ensure_subject_active(db: AsyncSession, subject) -> None: "/", response_model=SubjectRead, status_code=status.HTTP_201_CREATED, - dependencies=[Depends(require_study_roles(["PM", "CRA"])), Depends(require_study_not_locked())], + dependencies=[Depends(require_study_permission("subjects", "write")), Depends(require_study_not_locked())], ) async def create_subject( study_id: uuid.UUID, @@ -66,7 +66,7 @@ async def create_subject( @router.get( "/", response_model=list[SubjectRead], - dependencies=[Depends(require_study_member())], + dependencies=[Depends(require_study_permission("subjects", "read"))], ) async def list_subjects( study_id: uuid.UUID, @@ -115,7 +115,7 @@ async def list_subjects( @router.get( "/{subject_id}", response_model=SubjectRead, - dependencies=[Depends(require_study_member())], + dependencies=[Depends(require_study_permission("subjects", "read"))], ) async def get_subject( study_id: uuid.UUID, @@ -154,7 +154,7 @@ async def get_subject( @router.patch( "/{subject_id}", response_model=SubjectRead, - dependencies=[Depends(require_study_roles(["PM", "CRA"])), Depends(require_study_not_locked())], + dependencies=[Depends(require_study_permission("subjects", "write")), Depends(require_study_not_locked())], ) async def update_subject( study_id: uuid.UUID, @@ -195,7 +195,7 @@ async def update_subject( @router.delete( "/{subject_id}", status_code=status.HTTP_204_NO_CONTENT, - dependencies=[Depends(require_study_roles(["PM", "CRA"])), Depends(require_study_not_locked())], + dependencies=[Depends(require_study_permission("subjects", "write")), Depends(require_study_not_locked())], ) async def delete_subject( study_id: uuid.UUID, diff --git a/backend/app/api/v1/visits.py b/backend/app/api/v1/visits.py index 0de029b4..8a1fd4b5 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_member, require_study_roles, require_study_not_locked +from app.core.deps import get_current_user, get_db_session, require_study_not_locked, require_study_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 @@ -33,7 +33,7 @@ async def _ensure_subject_active(db: AsyncSession, subject) -> None: @router.get( "/", response_model=list[VisitRead], - dependencies=[Depends(require_study_member())], + dependencies=[Depends(require_study_permission("subjects", "read"))], ) async def list_visits( study_id: uuid.UUID, @@ -50,7 +50,7 @@ async def list_visits( "/", response_model=VisitRead, status_code=status.HTTP_201_CREATED, - dependencies=[Depends(require_study_roles(["PM", "CRA"])), Depends(require_study_not_locked())], + dependencies=[Depends(require_study_permission("subjects", "write")), Depends(require_study_not_locked())], ) async def create_visit( study_id: uuid.UUID, @@ -115,7 +115,7 @@ async def create_visit( "/early-termination", response_model=VisitRead, status_code=status.HTTP_201_CREATED, - dependencies=[Depends(require_study_roles(["PM", "CRA"])), Depends(require_study_not_locked())], + dependencies=[Depends(require_study_permission("subjects", "write")), Depends(require_study_not_locked())], ) async def create_early_termination( study_id: uuid.UUID, @@ -168,7 +168,7 @@ async def create_early_termination( @router.patch( "/{visit_id}", response_model=VisitRead, - dependencies=[Depends(require_study_roles(["PM", "CRA"])), Depends(require_study_not_locked())], + dependencies=[Depends(require_study_permission("subjects", "write")), Depends(require_study_not_locked())], ) async def update_visit( study_id: uuid.UUID, @@ -211,7 +211,7 @@ async def update_visit( @router.delete( "/{visit_id}", status_code=status.HTTP_204_NO_CONTENT, - dependencies=[Depends(require_study_roles(["PM", "CRA"])), Depends(require_study_not_locked())], + dependencies=[Depends(require_study_permission("subjects", "write")), Depends(require_study_not_locked())], ) async def delete_visit( study_id: uuid.UUID, diff --git a/backend/app/core/deps.py b/backend/app/core/deps.py index b18dd050..7b172193 100644 --- a/backend/app/core/deps.py +++ b/backend/app/core/deps.py @@ -10,6 +10,7 @@ from app.core.security import decode_token, oauth2_scheme from app.crud import user as user_crud from app.crud import member as member_crud from app.crud import site as site_crud +from app.core.project_permissions import role_has_project_permission from app.db.session import SessionLocal from app.schemas.user import TokenPayload @@ -118,6 +119,34 @@ def require_study_roles(roles: Iterable[str], *, allow_system_admin: bool = True return dependency +def require_study_permission(module: str, action: str, *, allow_system_admin: bool = True): + async def dependency( + study_id: uuid.UUID, + current_user=Depends(get_current_user), + db: AsyncSession = Depends(get_db_session), + ): + role_value = current_user.role.value if hasattr(current_user.role, "value") else current_user.role + if allow_system_admin and role_value == "ADMIN": + return current_user + membership = await member_crud.get_member(db, study_id, current_user.id) + if not membership or not membership.is_active: + raise AppException( + code="FORBIDDEN", + message="不是该项目成员", + status_code=status.HTTP_403_FORBIDDEN, + ) + allowed = await role_has_project_permission(db, study_id, membership.role_in_study, module, action) + if not allowed: + raise AppException( + code="FORBIDDEN", + message="项目权限不足", + status_code=status.HTTP_403_FORBIDDEN, + ) + return current_user + + return dependency + + async def get_cra_site_scope( db: AsyncSession, study_id: uuid.UUID, diff --git a/backend/app/core/project_permissions.py b/backend/app/core/project_permissions.py new file mode 100644 index 00000000..3bedd659 --- /dev/null +++ b/backend/app/core/project_permissions.py @@ -0,0 +1,233 @@ +from __future__ import annotations + +import uuid + +from sqlalchemy import delete, select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.models.study_role_permission import StudyRolePermission + +PROJECT_PERMISSION_ROLES = ("ADMIN", "PM", "CRA", "PV", "IMP", "QA") + +PROJECT_PERMISSION_MODULES = ( + {"key": "project_members", "label": "项目成员", "description": "维护项目账号、成员角色与启停状态"}, + {"key": "sites", "label": "中心管理", "description": "维护中心资料与 CRA 绑定"}, + {"key": "audit_export", "label": "审计日志导出", "description": "导出项目审计日志"}, + {"key": "project_overview", "label": "项目总览", "description": "查看项目整体进度与中心概览", "writable": False}, + {"key": "project_milestones", "label": "项目里程碑", "description": "维护项目级里程碑"}, + {"key": "fees", "label": "合同费用管理", "description": "维护合同、费用与付款"}, + {"key": "materials", "label": "物资管理", "description": "维护药品出入库与物资设备"}, + {"key": "file_versions", "label": "文件版本管理", "description": "维护文件版本、分发与确认"}, + {"key": "startup_ethics", "label": "立项与伦理", "description": "维护立项、可行性与伦理资料"}, + {"key": "startup_auth", "label": "启动与授权", "description": "维护启动会、培训与授权"}, + {"key": "subjects", "label": "参与者管理", "description": "维护参与者、访视与 PD"}, + {"key": "risk_issues", "label": "风险问题", "description": "维护 SAE、PD 与监查问题"}, + {"key": "monitoring_audit", "label": "监查稽查", "description": "维护监查稽查记录"}, + {"key": "etmf", "label": "eTMF", "description": "维护 eTMF 文件"}, + {"key": "faq", "label": "FAQ", "description": "维护项目 FAQ 分类、问题与回复"}, + {"key": "shared_library", "label": "共享库", "description": "维护注意事项、支持性文件与说明文件"}, +) +MANAGEMENT_BACKEND_PERMISSION_MODULES = {"project_members", "sites", "audit_export"} +READ_ONLY_PERMISSION_MODULES = { + module["key"] + for module in PROJECT_PERMISSION_MODULES + if module.get("writable") is False +} + +DEFAULT_PROJECT_ROLE_PERMISSIONS: dict[str, dict[str, dict[str, bool]]] = { + "ADMIN": { + module["key"]: {"read": True, "write": module["key"] not in READ_ONLY_PERMISSION_MODULES} + for module in PROJECT_PERMISSION_MODULES + }, + "PM": { + "project_overview": {"read": True, "write": False}, + "project_milestones": {"read": True, "write": True}, + "fees": {"read": True, "write": True}, + "materials": {"read": True, "write": True}, + "file_versions": {"read": True, "write": True}, + "startup_ethics": {"read": True, "write": True}, + "startup_auth": {"read": True, "write": True}, + "subjects": {"read": True, "write": True}, + "risk_issues": {"read": True, "write": True}, + "monitoring_audit": {"read": True, "write": True}, + "etmf": {"read": True, "write": True}, + "faq": {"read": True, "write": True}, + "shared_library": {"read": True, "write": True}, + "project_members": {"read": True, "write": True}, + "sites": {"read": True, "write": True}, + "audit_export": {"read": True, "write": False}, + }, + "CRA": { + "project_overview": {"read": True, "write": False}, + "project_milestones": {"read": True, "write": True}, + "fees": {"read": True, "write": True}, + "materials": {"read": True, "write": True}, + "file_versions": {"read": True, "write": True}, + "startup_ethics": {"read": True, "write": True}, + "startup_auth": {"read": True, "write": True}, + "subjects": {"read": True, "write": True}, + "risk_issues": {"read": True, "write": True}, + "monitoring_audit": {"read": True, "write": True}, + "etmf": {"read": True, "write": True}, + "faq": {"read": True, "write": True}, + "shared_library": {"read": True, "write": True}, + "project_members": {"read": False, "write": False}, + "sites": {"read": False, "write": False}, + "audit_export": {"read": True, "write": False}, + }, + "PV": { + "project_overview": {"read": True, "write": False}, + "project_milestones": {"read": True, "write": False}, + "fees": {"read": True, "write": False}, + "materials": {"read": True, "write": False}, + "file_versions": {"read": True, "write": False}, + "startup_ethics": {"read": True, "write": False}, + "startup_auth": {"read": True, "write": False}, + "subjects": {"read": True, "write": False}, + "risk_issues": {"read": True, "write": True}, + "monitoring_audit": {"read": True, "write": True}, + "etmf": {"read": True, "write": False}, + "faq": {"read": True, "write": True}, + "shared_library": {"read": True, "write": True}, + "project_members": {"read": False, "write": False}, + "sites": {"read": False, "write": False}, + "audit_export": {"read": False, "write": False}, + }, + "IMP": { + "project_overview": {"read": True, "write": False}, + "project_milestones": {"read": True, "write": False}, + "fees": {"read": True, "write": False}, + "materials": {"read": True, "write": True}, + "file_versions": {"read": True, "write": True}, + "startup_ethics": {"read": True, "write": False}, + "startup_auth": {"read": True, "write": True}, + "subjects": {"read": True, "write": False}, + "risk_issues": {"read": True, "write": False}, + "monitoring_audit": {"read": True, "write": False}, + "etmf": {"read": True, "write": False}, + "faq": {"read": True, "write": True}, + "shared_library": {"read": True, "write": True}, + "project_members": {"read": False, "write": False}, + "sites": {"read": False, "write": False}, + "audit_export": {"read": False, "write": False}, + }, + "QA": { + "project_overview": {"read": True, "write": False}, + "project_milestones": {"read": True, "write": False}, + "fees": {"read": True, "write": False}, + "materials": {"read": True, "write": False}, + "file_versions": {"read": True, "write": False}, + "startup_ethics": {"read": True, "write": False}, + "startup_auth": {"read": True, "write": False}, + "subjects": {"read": True, "write": False}, + "risk_issues": {"read": True, "write": False}, + "monitoring_audit": {"read": True, "write": True}, + "etmf": {"read": True, "write": False}, + "faq": {"read": True, "write": False}, + "shared_library": {"read": True, "write": True}, + "project_members": {"read": False, "write": False}, + "sites": {"read": False, "write": False}, + "audit_export": {"read": False, "write": False}, + }, +} + + +def _empty_action_state() -> dict[str, bool]: + return {"read": False, "write": False} + + +def _normalize_action_state(value: dict | None, role: str) -> dict[str, bool]: + if role == "ADMIN": + return {"read": True, "write": True} + read = bool((value or {}).get("read", False)) + write = bool((value or {}).get("write", False)) + if write: + read = True + return {"read": read, "write": write} + + +def _normalize_module_action_state(value: dict | None, role: str, module: str) -> dict[str, bool]: + if role == "ADMIN": + return {"read": True, "write": module not in READ_ONLY_PERMISSION_MODULES} + if module in MANAGEMENT_BACKEND_PERMISSION_MODULES and role != "PM": + return _empty_action_state() + if module in READ_ONLY_PERMISSION_MODULES: + return {"read": bool((value or {}).get("read", False) or (value or {}).get("write", False)), "write": False} + return _normalize_action_state(value, role) + + +def normalize_permission_matrix(matrix: dict | None = None) -> dict[str, dict[str, dict[str, bool]]]: + normalized: dict[str, dict[str, dict[str, bool]]] = {} + matrix = matrix or {} + for role in PROJECT_PERMISSION_ROLES: + normalized[role] = {} + role_matrix = matrix.get(role) or DEFAULT_PROJECT_ROLE_PERMISSIONS.get(role, {}) + for module in PROJECT_PERMISSION_MODULES: + module_key = module["key"] + normalized[role][module_key] = _normalize_module_action_state( + role_matrix.get(module_key, _empty_action_state()), + role, + module_key, + ) + return normalized + + +async def get_project_role_permissions(db: AsyncSession, study_id: uuid.UUID) -> dict[str, dict[str, dict[str, bool]]]: + result = await db.execute(select(StudyRolePermission).where(StudyRolePermission.study_id == study_id)) + rows = result.scalars().all() + if not rows: + return normalize_permission_matrix(DEFAULT_PROJECT_ROLE_PERMISSIONS) + matrix = normalize_permission_matrix(DEFAULT_PROJECT_ROLE_PERMISSIONS) + for row in rows: + if row.role not in PROJECT_PERMISSION_ROLES: + continue + if row.module not in matrix[row.role]: + continue + matrix[row.role][row.module] = _normalize_module_action_state( + {"read": row.can_read, "write": row.can_write}, + row.role, + row.module, + ) + return matrix + + +async def replace_project_role_permissions( + db: AsyncSession, + study_id: uuid.UUID, + payload: dict[str, dict[str, dict[str, bool]]], +) -> dict[str, dict[str, dict[str, bool]]]: + matrix = normalize_permission_matrix(payload) + await db.execute(delete(StudyRolePermission).where(StudyRolePermission.study_id == study_id)) + for role, role_matrix in matrix.items(): + if role == "ADMIN": + continue + for module, actions in role_matrix.items(): + db.add( + StudyRolePermission( + study_id=study_id, + role=role, + module=module, + can_read=actions["read"], + can_write=actions["write"], + ) + ) + await db.commit() + return matrix + + +async def role_has_project_permission( + db: AsyncSession, + study_id: uuid.UUID, + role: str | None, + module: str, + action: str, +) -> bool: + if role == "ADMIN": + return True + matrix = await get_project_role_permissions(db, study_id) + actions = matrix.get(role or "", {}).get(module) + if not actions: + return False + if action == "write": + return bool(actions["write"]) + return bool(actions["read"]) diff --git a/backend/app/db/base.py b/backend/app/db/base.py index 5b6ae59c..bd9a9ee5 100644 --- a/backend/app/db/base.py +++ b/backend/app/db/base.py @@ -37,3 +37,4 @@ from app.models.study_setup_config import StudySetupConfig # noqa: F401 from app.models.study_setup_config_version import StudySetupConfigVersion # noqa: F401 from app.models.study_monitoring_strategy import StudyMonitoringStrategy # noqa: F401 from app.models.study_center_confirm import StudyCenterConfirm # noqa: F401 +from app.models.study_role_permission import StudyRolePermission # noqa: F401 diff --git a/backend/app/models/study_role_permission.py b/backend/app/models/study_role_permission.py new file mode 100644 index 00000000..b72ca076 --- /dev/null +++ b/backend/app/models/study_role_permission.py @@ -0,0 +1,25 @@ +from __future__ import annotations + +import uuid +from datetime import datetime + +from sqlalchemy import Boolean, DateTime, ForeignKey, String, UniqueConstraint, func +from sqlalchemy.dialects.postgresql import UUID +from sqlalchemy.orm import Mapped, mapped_column + +from app.db.base_class import Base + + +class StudyRolePermission(Base): + __tablename__ = "study_role_permissions" + __table_args__ = ( + UniqueConstraint("study_id", "role", "module", name="uq_study_role_permissions_study_role_module"), + ) + + id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + study_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("studies.id"), nullable=False, index=True) + role: Mapped[str] = mapped_column(String(20), nullable=False, index=True) + module: Mapped[str] = mapped_column(String(80), nullable=False) + can_read: Mapped[bool] = mapped_column(Boolean, nullable=False, server_default="false") + can_write: Mapped[bool] = mapped_column(Boolean, nullable=False, server_default="false") + updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now(), onupdate=func.now()) diff --git a/backend/app/schemas/project_permission.py b/backend/app/schemas/project_permission.py new file mode 100644 index 00000000..3271d3f4 --- /dev/null +++ b/backend/app/schemas/project_permission.py @@ -0,0 +1,33 @@ +from __future__ import annotations + +from typing import Literal + +from pydantic import BaseModel, Field + + +ProjectPermissionRole = Literal["ADMIN", "PM", "CRA", "PV", "IMP", "QA"] +ProjectPermissionAction = Literal["read", "write"] + + +class ProjectPermissionActionState(BaseModel): + read: bool = False + write: bool = False + + +class ProjectPermissionModule(BaseModel): + key: str + label: str + description: str | None = None + writable: bool = True + + +class ProjectRolePermissionMatrix(BaseModel): + roles: dict[ProjectPermissionRole, dict[str, ProjectPermissionActionState]] + + +class ProjectRolePermissionsRead(ProjectRolePermissionMatrix): + modules: list[ProjectPermissionModule] + + +class ProjectRolePermissionsUpdate(ProjectRolePermissionMatrix): + roles: dict[ProjectPermissionRole, dict[str, ProjectPermissionActionState]] = Field(default_factory=dict) diff --git a/backend/app/services/document_service.py b/backend/app/services/document_service.py index 0db34988..86982844 100644 --- a/backend/app/services/document_service.py +++ b/backend/app/services/document_service.py @@ -11,8 +11,8 @@ from fastapi.responses import FileResponse from sqlalchemy import delete as sa_delete, or_, select, update as sa_update from sqlalchemy.ext.asyncio import AsyncSession -from app.core import rbac from app.core.deps import get_cra_site_scope +from app.core.project_permissions import role_has_project_permission from app.crud import acknowledgement as acknowledgement_crud from app.crud import distribution as distribution_crud from app.crud import document as document_crud @@ -75,7 +75,10 @@ async def _ensure_study_access(db: AsyncSession, trial_id: uuid.UUID, current_us membership = await member_crud.get_member(db, trial_id, current_user.id) if not membership or not membership.is_active: raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="不是项目成员") - if not rbac.is_allowed(action, current_user, membership): + module = "file_versions" + permission_action = "read" if action in {"view", "ack"} else "write" + allowed = await role_has_project_permission(db, trial_id, membership.role_in_study, module, permission_action) + if not allowed: raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="权限不足") return membership diff --git a/backend/tests/test_project_management_permissions_static.py b/backend/tests/test_project_management_permissions_static.py index c0d241e1..73382f25 100644 --- a/backend/tests/test_project_management_permissions_static.py +++ b/backend/tests/test_project_management_permissions_static.py @@ -4,13 +4,131 @@ from pathlib import Path ROOT = Path(__file__).resolve().parents[1] -def test_project_management_write_routes_allow_project_admin_and_pm(): - for relative_path in ( - "app/api/v1/studies.py", - "app/api/v1/sites.py", - "app/api/v1/members.py", - ): - source = (ROOT / relative_path).read_text(encoding="utf-8") +def test_project_setup_routes_keep_project_admin_and_pm_defaults(): + source = (ROOT / "app/api/v1/studies.py").read_text(encoding="utf-8") - assert 'require_study_roles(["PM"])' not in source - assert 'require_study_roles(["ADMIN", "PM"])' in source + assert 'require_study_roles(["PM"])' not in source + assert 'require_study_roles(["ADMIN", "PM"])' not in source + assert 'require_roles(["ADMIN"])' in source + + +def test_project_permission_routes_are_registered(): + source = (ROOT / "app/api/v1/router.py").read_text(encoding="utf-8") + + assert "project_permissions" in source + assert 'prefix="/studies/{study_id}/permissions"' in source + + +def test_project_member_writes_use_saved_permission_matrix(): + source = (ROOT / "app/api/v1/members.py").read_text(encoding="utf-8") + + assert 'require_study_permission("project_members", "write")' in source + assert 'require_study_roles(["ADMIN", "PM"])' not in source + + +def test_business_modules_use_saved_permission_matrix(): + expected = { + "overview.py": [ + 'require_study_permission("project_overview", "read")', + ], + "project_milestones.py": [ + 'require_study_permission("project_milestones", "read")', + 'require_study_permission("project_milestones", "write")', + ], + "subjects.py": [ + 'require_study_permission("subjects", "read")', + 'require_study_permission("subjects", "write")', + ], + "material_equipments.py": [ + 'require_study_permission("materials", "read")', + 'require_study_permission("materials", "write")', + ], + "startup.py": [ + 'require_study_permission("startup_ethics", "read")', + 'require_study_permission("startup_ethics", "write")', + 'require_study_permission("startup_auth", "read")', + 'require_study_permission("startup_auth", "write")', + ], + "aes.py": [ + 'require_study_permission("risk_issues", "read")', + 'require_study_permission("risk_issues", "write")', + ], + "monitoring_visit_issues.py": [ + 'require_study_permission("monitoring_audit", "read")', + 'require_study_permission("monitoring_audit", "write")', + ], + } + + for filename, checks in expected.items(): + source = (ROOT / f"app/api/v1/{filename}").read_text(encoding="utf-8") + for check in checks: + assert check in source + + ae_source = (ROOT / "app/api/v1/aes.py").read_text(encoding="utf-8") + assert "ALLOWED_CREATE_ROLES" not in ae_source + assert "ALLOWED_UPDATE_ROLES" not in ae_source + assert 'member_role not in {"PM", "PV"}' not in ae_source + + +def test_fee_contracts_use_saved_permission_matrix(): + source = (ROOT / "app/api/v1/fees_contracts.py").read_text(encoding="utf-8") + + assert 'role_has_project_permission(db, project_id, membership.role_in_study, "fees", action)' in source + assert 'if write and membership.role_in_study not in {"ADMIN", "PM"}' not in source + + +def test_attachment_deletes_use_saved_permission_matrix(): + source = (ROOT / "app/api/v1/attachments.py").read_text(encoding="utf-8") + fees_source = (ROOT / "app/api/v1/fees_attachments.py").read_text(encoding="utf-8") + + assert "_permission_module_for_entity(entity_type)" in source + assert '"knowledge_note": "shared_library"' in source + assert "_permission_module_for_entity(attachment.entity_type)" in source + assert 'getattr(membership, "role_in_study", None) == "PM"' not in source + assert 'role_has_project_permission(db, project_id, membership.role_in_study, "fees", action)' in fees_source + assert 'getattr(membership, "role_in_study", None) == "PM"' not in fees_source + + +def test_faq_uses_dedicated_permission_matrix(): + source = (ROOT / "app/api/v1/faqs.py").read_text(encoding="utf-8") + category_source = (ROOT / "app/api/v1/faq_categories.py").read_text(encoding="utf-8") + + assert 'role_has_project_permission(db, study_id, member.role_in_study, "faq", action)' in source + assert 'role_has_project_permission(db, study_id, member.role_in_study, "faq", action)' in category_source + assert '"etmf"' not in source + assert '"etmf"' not in category_source + assert 'member_role != "PM"' not in source + assert 'member_role != "PM"' not in category_source + + +def test_audit_logs_use_saved_permission_matrix(): + source = (ROOT / "app/api/v1/audit_logs.py").read_text(encoding="utf-8") + get_block = source[source.index("@router.get("):source.index("@router.delete(")] + + assert 'require_study_permission("audit_export", "read")' in get_block + assert 'require_study_member()' not in get_block + + +def test_document_service_uses_saved_permission_matrix(): + source = (ROOT / "app/services/document_service.py").read_text(encoding="utf-8") + + assert 'role_has_project_permission' in source + assert '"file_versions"' in source + assert 'rbac.is_allowed' not in source + + +def test_remaining_business_reads_use_saved_permission_matrix(): + expected = { + "finance_contracts.py": ['require_study_permission("fees", "read")'], + "drug_shipments.py": ['require_study_permission("materials", "read")'], + "knowledge_notes.py": ['require_study_permission("shared_library", "read")'], + "subject_histories.py": ['require_study_permission("subjects", "read")'], + "subject_pds.py": ['require_study_permission("risk_issues", "read")'], + "visits.py": ['require_study_permission("subjects", "read")'], + "attachments.py": ['_permission_module_for_entity(entity_type)'], + } + + for filename, checks in expected.items(): + source = (ROOT / f"app/api/v1/{filename}").read_text(encoding="utf-8") + for check in checks: + assert check in source diff --git a/backend/tests/test_project_permissions.py b/backend/tests/test_project_permissions.py new file mode 100644 index 00000000..094d34e9 --- /dev/null +++ b/backend/tests/test_project_permissions.py @@ -0,0 +1,172 @@ +from app.core.project_permissions import PROJECT_PERMISSION_MODULES, normalize_permission_matrix + + +def test_normalize_permission_matrix_makes_admin_fixed_full_access(): + matrix = normalize_permission_matrix({"ADMIN": {}}) + + for module in PROJECT_PERMISSION_MODULES: + assert matrix["ADMIN"][module["key"]] == { + "read": True, + "write": module.get("writable") is not False, + } + + +def test_normalize_permission_matrix_keeps_admin_fixed_even_when_disabled(): + payload = { + "ADMIN": { + module["key"]: {"read": False, "write": False} + for module in PROJECT_PERMISSION_MODULES + } + } + + matrix = normalize_permission_matrix(payload) + + for module in PROJECT_PERMISSION_MODULES: + assert matrix["ADMIN"][module["key"]] == { + "read": True, + "write": module.get("writable") is not False, + } + + +def test_normalize_permission_matrix_write_implies_read(): + matrix = normalize_permission_matrix({ + "CRA": { + "subjects": {"read": False, "write": True}, + } + }) + + assert matrix["CRA"]["subjects"] == {"read": True, "write": True} + + +def test_business_permission_modules_follow_project_menu(): + module_labels = [module["label"] for module in PROJECT_PERMISSION_MODULES] + + assert module_labels == [ + "项目成员", + "中心管理", + "审计日志导出", + "项目总览", + "项目里程碑", + "合同费用管理", + "物资管理", + "文件版本管理", + "立项与伦理", + "启动与授权", + "参与者管理", + "风险问题", + "监查稽查", + "eTMF", + "FAQ", + "共享库", + ] + + +def test_default_business_permissions_match_role_responsibilities(): + matrix = normalize_permission_matrix() + + assert matrix["PM"]["project_members"] == {"read": True, "write": True} + assert matrix["PM"]["sites"] == {"read": True, "write": True} + assert matrix["CRA"]["project_members"] == {"read": False, "write": False} + assert matrix["CRA"]["audit_export"] == {"read": False, "write": False} + assert matrix["PV"]["sites"] == {"read": False, "write": False} + assert matrix["IMP"]["audit_export"] == {"read": False, "write": False} + assert matrix["QA"]["audit_export"] == {"read": False, "write": False} + assert matrix["CRA"]["project_milestones"] == {"read": True, "write": True} + assert matrix["CRA"]["subjects"] == {"read": True, "write": True} + assert matrix["CRA"]["monitoring_audit"] == {"read": True, "write": True} + assert matrix["PV"]["risk_issues"] == {"read": True, "write": True} + assert matrix["IMP"]["materials"] == {"read": True, "write": True} + assert matrix["PM"]["faq"] == {"read": True, "write": True} + assert matrix["CRA"]["faq"] == {"read": True, "write": True} + assert matrix["PM"]["project_overview"] == {"read": True, "write": False} + assert matrix["CRA"]["project_overview"] == {"read": True, "write": False} + assert matrix["PM"]["shared_library"] == {"read": True, "write": True} + assert matrix["CRA"]["shared_library"] == {"read": True, "write": True} + + +def test_project_overview_is_read_only_even_when_write_is_submitted(): + matrix = normalize_permission_matrix({ + "CRA": { + "project_overview": {"read": False, "write": True}, + } + }) + + assert matrix["CRA"]["project_overview"] == {"read": True, "write": False} + + +def test_pm_can_be_granted_management_backend_permissions(): + matrix = normalize_permission_matrix({ + "PM": { + "project_members": {"read": True, "write": True}, + "sites": {"read": True, "write": True}, + "audit_export": {"read": True, "write": True}, + } + }) + + assert matrix["PM"]["project_members"] == {"read": True, "write": True} + assert matrix["PM"]["sites"] == {"read": True, "write": True} + assert matrix["PM"]["audit_export"] == {"read": True, "write": True} + + +def test_non_pm_roles_cannot_be_granted_management_backend_permissions(): + management_modules = ["project_members", "sites", "audit_export"] + matrix = normalize_permission_matrix({ + role: { + module: {"read": True, "write": True} + for module in management_modules + } + for role in ["CRA", "PV", "IMP", "QA"] + }) + + for role in ["CRA", "PV", "IMP", "QA"]: + for module in management_modules: + assert matrix[role][module] == {"read": False, "write": False} + + +def test_each_non_admin_role_can_toggle_each_writable_module(): + roles = ["PM", "CRA", "PV", "IMP", "QA"] + management_modules = {"project_members", "sites", "audit_export"} + writable_modules = [ + module["key"] + for module in PROJECT_PERMISSION_MODULES + if module.get("writable") is not False and module["key"] not in management_modules + ] + + for role in roles: + enabled = normalize_permission_matrix({ + role: { + module: {"read": False, "write": True} + for module in writable_modules + } + }) + disabled = normalize_permission_matrix({ + role: { + module["key"]: {"read": False, "write": False} + for module in PROJECT_PERMISSION_MODULES + } + }) + + for module in writable_modules: + assert enabled[role][module] == {"read": True, "write": True} + assert disabled[role][module] == {"read": False, "write": False} + + +def test_read_only_modules_never_accept_write_for_any_role(): + read_only_modules = [ + module["key"] + for module in PROJECT_PERMISSION_MODULES + if module.get("writable") is False + ] + payload = { + role: { + module: {"read": False, "write": True} + for module in read_only_modules + } + for role in ["ADMIN", "PM", "CRA", "PV", "IMP", "QA"] + } + + matrix = normalize_permission_matrix(payload) + + for role in ["ADMIN", "PM", "CRA", "PV", "IMP", "QA"]: + for module in read_only_modules: + assert matrix[role][module] == {"read": True, "write": False} diff --git a/frontend/src/api/projectPermissions.ts b/frontend/src/api/projectPermissions.ts new file mode 100644 index 00000000..4d887b4a --- /dev/null +++ b/frontend/src/api/projectPermissions.ts @@ -0,0 +1,8 @@ +import { apiGet, apiPut } from "./axios"; +import type { ProjectRolePermissionsResponse, ProjectRolePermissionsUpdate } from "../types/api"; + +export const fetchProjectRolePermissions = (studyId: string) => + apiGet(`/api/v1/studies/${studyId}/permissions/`); + +export const updateProjectRolePermissions = (studyId: string, payload: ProjectRolePermissionsUpdate) => + apiPut(`/api/v1/studies/${studyId}/permissions/`, payload); diff --git a/frontend/src/components/Layout.vue b/frontend/src/components/Layout.vue index b09ca542..f9883e98 100644 --- a/frontend/src/components/Layout.vue +++ b/frontend/src/components/Layout.vue @@ -34,73 +34,91 @@ {{ TEXT.menu.auditLogs }} + + + + + + {{ TEXT.modules.adminProjectMembers.memberLabel }} + + + + {{ TEXT.modules.adminSites.title }} + + + + {{ TEXT.menu.auditLogs }} + + - + - + {{ TEXT.menu.projectOverview }} - + {{ TEXT.menu.projectMilestones }} - + {{ TEXT.menu.feeContracts }} - + - {{ TEXT.menu.drugShipments }} - {{ TEXT.menu.materialEquipment }} + {{ TEXT.menu.drugShipments }} + {{ TEXT.menu.materialEquipment }} - + {{ TEXT.menu.fileVersionManagement }} - + {{ TEXT.menu.startupFeasibilityEthics }} - + {{ TEXT.menu.startupMeetingAuth }} - + {{ TEXT.menu.subjects }} - + - {{ TEXT.menu.riskIssueSae }} - {{ TEXT.menu.riskIssuePd }} - {{ TEXT.menu.riskIssueMonitoringVisits }} + {{ TEXT.menu.riskIssueSae }} + {{ TEXT.menu.riskIssuePd }} + {{ TEXT.menu.riskIssueMonitoringVisits }} - + {{ TEXT.menu.monitoringAudit }} - + {{ TEXT.menu.etmf }} - + - {{ TEXT.menu.knowledgeMedicalConsult }} - {{ TEXT.menu.knowledgeNotes }} - {{ TEXT.menu.knowledgeSupportFiles }} - {{ TEXT.menu.knowledgeInstructionFiles }} + {{ TEXT.menu.knowledgeMedicalConsult }} + {{ TEXT.menu.knowledgeNotes }} + {{ TEXT.menu.knowledgeSupportFiles }} + {{ TEXT.menu.knowledgeInstructionFiles }} @@ -217,6 +235,7 @@ import { CircleCheck, Box, Coin, Notebook, Document, ArrowDown, SwitchButton, Files } from "@element-plus/icons-vue"; import { ElMessage } from "element-plus"; +import { getProjectRoutePermission, hasProjectPermission, projectRouteLandingPaths } from "../utils/projectRoutePermissions"; const auth = useAuthStore(); const study = useStudyStore(); @@ -224,6 +243,16 @@ const router = useRouter(); const route = useRoute(); const isAdmin = computed(() => auth.user?.role === "ADMIN"); const isCollapsed = ref(localStorage.getItem("ctms_sidebar_collapsed") === "1"); +const projectRole = computed(() => study.currentStudyRole || (study.currentStudy as any)?.role_in_study || ""); +const canAccessPmAdminModule = (module: string) => + !isAdmin.value && projectRole.value === "PM" && !!study.currentPermissions?.roles?.PM?.[module]?.read; +const canAccessPmAdminBackend = computed(() => + !!study.currentStudy && ["project_members", "sites", "audit_export"].some((module) => canAccessPmAdminModule(module)) +); +const canAccessProjectPath = (path: string) => + hasProjectPermission(study.currentPermissions?.roles, projectRole.value, getProjectRoutePermission(path), isAdmin.value); +const canAccessAnyProjectPath = (paths: string[]) => paths.some((path) => canAccessProjectPath(path)); +const hasAnyProjectModuleAccess = computed(() => canAccessAnyProjectPath(projectRouteLandingPaths)); /* 动态设置 body 上的侧边栏宽度 CSS 变量,用于全局弹窗定位偏移 */ const hasSidebar = computed(() => isAdmin.value || !!study.currentStudy); @@ -375,9 +404,10 @@ const breadcrumbs = computed(() => { return items; }); -const handleBreadcrumbCommand = (cmd: any) => { +const handleBreadcrumbCommand = async (cmd: any) => { if (cmd.type === 'study') { study.setCurrentStudy(cmd.value); + await study.loadCurrentStudyPermissions().catch(() => {}); router.push("/project/overview"); } else if (cmd.type === 'site') { study.setCurrentSite(cmd.value); diff --git a/frontend/src/components/StudySelector.vue b/frontend/src/components/StudySelector.vue index db9c3fd9..c98c642a 100644 --- a/frontend/src/components/StudySelector.vue +++ b/frontend/src/components/StudySelector.vue @@ -57,7 +57,7 @@ const loadStudies = async () => { } }; -const onCommand = (cmd: any) => { +const onCommand = async (cmd: any) => { if (cmd === "clear") { study.clearCurrentStudy(); router.push("/workbench"); @@ -65,6 +65,7 @@ const onCommand = (cmd: any) => { } if (cmd?.type === "switch" && cmd.study) { study.setCurrentStudy(cmd.study as Study); + await study.loadCurrentStudyPermissions().catch(() => {}); router.push("/project/overview"); } }; diff --git a/frontend/src/components/attachments/AttachmentList.vue b/frontend/src/components/attachments/AttachmentList.vue index a5b275ab..104c5b0a 100644 --- a/frontend/src/components/attachments/AttachmentList.vue +++ b/frontend/src/components/attachments/AttachmentList.vue @@ -67,7 +67,8 @@ import AttachmentUploader from "./AttachmentUploader.vue"; import { formatFileSize } from "./attachmentUtils"; import { useAuthStore } from "../../store/auth"; import { useStudyStore } from "../../store/study"; -import { getProjectRole, isSystemAdmin } from "../../utils/roles"; +import { isSystemAdmin } from "../../utils/roles"; +import { usePermission } from "../../utils/permission"; import { listMembers } from "../../api/members"; import { displayDateTime, displayUser, getMemberDisplayName, getUserDisplayName } from "../../utils/display"; import { TEXT } from "../../locales"; @@ -84,6 +85,7 @@ const attachments = ref([]); const loading = ref(false); const auth = useAuthStore(); const study = useStudyStore(); +const permission = usePermission(); const members = ref([]); const previewVisible = ref(false); const previewUrl = ref(""); @@ -203,11 +205,8 @@ const preview = (row: any) => { const canDelete = (row: any) => { if (props.readonly) return false; - const userId = auth.user?.id; - const projectRole = getProjectRole(study.currentStudy, study.currentStudyRole); - const ownerId = - row.uploaded_by_id || (row.uploaded_by && typeof row.uploaded_by === "object" ? row.uploaded_by.id : row.uploaded_by); - return userId === ownerId || isSystemAdmin(auth.user) || projectRole === "PM"; + const action = props.entityType === "knowledge_note" ? "shared.library.write" : "file.attachment.delete"; + return isSystemAdmin(auth.user) || permission.can(action); }; const remove = async (row: any) => { diff --git a/frontend/src/components/fees/FeeAttachmentPanel.vue b/frontend/src/components/fees/FeeAttachmentPanel.vue index 2729ee57..94185042 100644 --- a/frontend/src/components/fees/FeeAttachmentPanel.vue +++ b/frontend/src/components/fees/FeeAttachmentPanel.vue @@ -83,7 +83,8 @@ import { fetchAttachments, uploadAttachment, deleteAttachment } from "../../api/ import { listMembers } from "../../api/members"; import { useAuthStore } from "../../store/auth"; import { useStudyStore } from "../../store/study"; -import { getProjectRole, isSystemAdmin } from "../../utils/roles"; +import { isSystemAdmin } from "../../utils/roles"; +import { usePermission } from "../../utils/permission"; import { formatFileSize } from "../attachments/attachmentUtils"; import { displayUser, getMemberDisplayName, getUserDisplayName } from "../../utils/display"; import StateEmpty from "../StateEmpty.vue"; @@ -110,6 +111,7 @@ const grouped = reactive>({}); const progressMap = reactive>({}); const auth = useAuthStore(); const study = useStudyStore(); +const permission = usePermission(); const members = ref([]); const previewVisible = ref(false); const previewUrl = ref(""); @@ -269,11 +271,7 @@ const download = (row: any) => { const canDelete = (row: any) => { if (props.readonly) return false; - const userId = auth.user?.id; - const projectRole = getProjectRole(study.currentStudy, study.currentStudyRole); - const ownerId = - row.uploaded_by_id || (row.uploaded_by && typeof row.uploaded_by === "object" ? row.uploaded_by.id : row.uploaded_by); - return userId === ownerId || isSystemAdmin(auth.user) || projectRole === "PM"; + return isSystemAdmin(auth.user) || permission.can("fees.attachment.delete"); }; const remove = async (row: any) => { diff --git a/frontend/src/main.ts b/frontend/src/main.ts index 55b95d23..0f385dd9 100644 --- a/frontend/src/main.ts +++ b/frontend/src/main.ts @@ -24,6 +24,7 @@ const studyStore = useStudyStore(); studyStore.loadCurrentStudy(); if (getToken()) { await studyStore.rehydrateStudyForLastUser(); + await studyStore.loadCurrentStudyPermissions().catch(() => {}); } app.use(router); diff --git a/frontend/src/router.test.ts b/frontend/src/router.test.ts new file mode 100644 index 00000000..f8a4d81c --- /dev/null +++ b/frontend/src/router.test.ts @@ -0,0 +1,17 @@ +import { describe, expect, it } from "vitest"; +import { readFileSync } from "node:fs"; +import { resolve } from "node:path"; + +const readRouter = () => readFileSync(resolve(__dirname, "./router/index.ts"), "utf8"); + +describe("admin project route permissions", () => { + it("allows project admin pages through matrix-authorized PMs instead of all project roles", () => { + const source = readRouter(); + + expect(source).toContain('adminProjectPermission: { module: "project_members", action: "read" }'); + expect(source).toContain('adminProjectPermission: { module: "sites", action: "read" }'); + expect(source).toContain('adminProjectPermission: { module: "audit_export", action: "read" }'); + expect(source).toContain('if (role !== "PM") return false;'); + expect(source).toContain("roles?.PM?.[permission.module]?.[permission.action]"); + }); +}); diff --git a/frontend/src/router/index.ts b/frontend/src/router/index.ts index b874246c..a79fd20b 100644 --- a/frontend/src/router/index.ts +++ b/frontend/src/router/index.ts @@ -1,7 +1,10 @@ import { createRouter, createWebHistory, RouteRecordRaw } from "vue-router"; import { useAuthStore } from "../store/auth"; import { useStudyStore } from "../store/study"; -import { getProjectRole, isSystemAdmin } from "../utils/roles"; +import { isSystemAdmin } from "../utils/roles"; +import { findFirstAccessibleProjectPath, getProjectRoutePermission, hasProjectPermission } from "../utils/projectRoutePermissions"; +import { fetchStudyDetail } from "../api/studies"; +import { fetchProjectRolePermissions } from "../api/projectPermissions"; import Layout from "../components/Layout.vue"; import Login from "../views/Login.vue"; import Register from "../views/Register.vue"; @@ -16,6 +19,7 @@ import AdminProjects from "../views/admin/Projects.vue"; import ProjectMembers from "../views/admin/ProjectMembers.vue"; import AdminSites from "../views/admin/Sites.vue"; import ProjectDetail from "../views/admin/ProjectDetail.vue"; +import ProjectPermissions from "../views/admin/ProjectPermissions.vue"; import ProfileSettings from "../views/ProfileSettings.vue"; import ProjectOverview from "../views/ia/ProjectOverview.vue"; import ProjectMilestones from "../views/ia/ProjectMilestones.vue"; @@ -435,19 +439,25 @@ const routes: RouteRecordRaw[] = [ path: "projects/:projectId/members", name: "AdminProjectMembers", component: ProjectMembers, - meta: { title: TEXT.modules.adminProjectMembers.memberLabel, requiresAdmin: true }, + meta: { title: TEXT.modules.adminProjectMembers.memberLabel, adminProjectPermission: { module: "project_members", action: "read" } }, + }, + { + path: "projects/:projectId/permissions", + name: "AdminProjectPermissions", + component: ProjectPermissions, + meta: { title: "权限管理", requiresAdmin: true }, }, { path: "projects/:projectId/sites", name: "AdminSites", component: AdminSites, - meta: { title: TEXT.modules.adminSites.title, requiresAdmin: true }, + meta: { title: TEXT.modules.adminSites.title, adminProjectPermission: { module: "sites", action: "read" } }, }, { path: "audit-logs", name: "AdminAuditLogs", component: AuditLogs, - meta: { title: TEXT.menu.auditLogs, requiresAdmin: true }, + meta: { title: TEXT.menu.auditLogs, adminProjectPermission: { module: "audit_export", action: "read" } }, }, ], }, @@ -462,6 +472,29 @@ const router = createRouter({ routes, }); +const ensureAdminProjectAccess = async (to: any, isAdmin: boolean) => { + if (isAdmin) return true; + const studyStore = useStudyStore(); + const permission = to.meta.adminProjectPermission as { module: string; action: "read" | "write" } | undefined; + if (!permission) return true; + + const routeProjectId = typeof to.params.projectId === "string" ? to.params.projectId : ""; + if (routeProjectId && studyStore.currentStudy?.id !== routeProjectId) { + const [{ data: project }, { data: permissions }] = await Promise.all([ + fetchStudyDetail(routeProjectId), + fetchProjectRolePermissions(routeProjectId), + ]); + studyStore.setCurrentStudy(project); + studyStore.currentPermissions = permissions; + } else if (!studyStore.currentPermissions) { + await studyStore.loadCurrentStudyPermissions(); + } + + const role = studyStore.currentStudyRole || (studyStore.currentStudy as any)?.role_in_study || ""; + if (role !== "PM") return false; + return !!studyStore.currentPermissions?.roles?.PM?.[permission.module]?.[permission.action]; +}; + router.beforeEach(async (to, _from, next) => { const auth = useAuthStore(); const studyStore = useStudyStore(); @@ -511,13 +544,24 @@ router.beforeEach(async (to, _from, next) => { return; } } + if (to.meta.adminProjectPermission) { + const allowed = await ensureAdminProjectAccess(to, isAdmin).catch(() => false); + if (!allowed) { + next({ path: isAdmin ? "/admin/users" : "/workbench" }); + return; + } + } if (to.meta.requiresAdmin && !isAdmin) { next({ path: "/workbench" }); return; } - if (to.name === "AuditLogs") { - const projectRole = getProjectRole(studyStore.currentStudy, studyStore.currentStudyRole); - if (!(isAdmin || projectRole === "PM")) { + if (to.name === "AdminAuditLogs") { + if (studyStore.currentStudy && !studyStore.currentPermissions) { + await studyStore.loadCurrentStudyPermissions().catch(() => {}); + } + const role = studyStore.currentStudyRole || (studyStore.currentStudy as any)?.role_in_study || ""; + const canReadAudit = !!studyStore.currentPermissions?.roles?.[role]?.audit_export?.read; + if (!(isAdmin || (role === "PM" && canReadAudit))) { next({ path: "/project/overview" }); return; } @@ -526,6 +570,19 @@ router.beforeEach(async (to, _from, next) => { next({ path: isAdmin ? "/admin/users" : "/workbench" }); return; } + if (to.meta.requiresStudy && studyStore.currentStudy) { + if (!studyStore.currentPermissions) { + await studyStore.loadCurrentStudyPermissions().catch(() => {}); + } + const projectRole = studyStore.currentStudyRole || (studyStore.currentStudy as any)?.role_in_study || ""; + const permission = getProjectRoutePermission(to.path); + const canAccessProjectRoute = hasProjectPermission(studyStore.currentPermissions?.roles, projectRole, permission, isAdmin); + if (!canAccessProjectRoute) { + const fallback = findFirstAccessibleProjectPath(studyStore.currentPermissions?.roles, projectRole, isAdmin); + next({ path: fallback || "/workbench" }); + return; + } + } next(); }); diff --git a/frontend/src/store/auth.ts b/frontend/src/store/auth.ts index 6520edf6..34e3b870 100644 --- a/frontend/src/store/auth.ts +++ b/frontend/src/store/auth.ts @@ -37,6 +37,7 @@ export const useAuthStore = defineStore("auth", () => { const studyStore = useStudyStore(); const userKey = me?.email || email; await studyStore.restoreStudyForUser(userKey, { preferActive: me?.role === "ADMIN" }); + await studyStore.loadCurrentStudyPermissions().catch(() => {}); forceLogin.value = false; } finally { loading.value = false; diff --git a/frontend/src/store/study.ts b/frontend/src/store/study.ts index 118d31b9..8c9b3f78 100644 --- a/frontend/src/store/study.ts +++ b/frontend/src/store/study.ts @@ -1,6 +1,7 @@ import { defineStore } from "pinia"; import { ref } from "vue"; import { fetchStudies } from "../api/studies"; +import { fetchProjectRolePermissions } from "../api/projectPermissions"; import type { Study } from "../types/api"; const STUDY_KEY = "ctms_current_study"; @@ -13,6 +14,7 @@ export const useStudyStore = defineStore("study", () => { const currentStudyRole = ref(null); const currentSite = ref(null); const viewContext = ref<{ siteName?: string } | null>(null); + const currentPermissions = ref(null); const normalizeUserKey = (value: string) => value.trim().toLowerCase(); const readLastStudyMap = (): Record => { @@ -49,6 +51,7 @@ export const useStudyStore = defineStore("study", () => { if (role) { localStorage.setItem(STUDY_ROLE_KEY, role); } + currentPermissions.value = null; const loginEmail = localStorage.getItem("ctms_last_login_email") || ""; if (loginEmail) { rememberStudyForUser(loginEmail, study); @@ -157,6 +160,17 @@ export const useStudyStore = defineStore("study", () => { localStorage.removeItem(STUDY_KEY); localStorage.removeItem(STUDY_ROLE_KEY); localStorage.removeItem(SITE_KEY); + currentPermissions.value = null; + }; + + const loadCurrentStudyPermissions = async () => { + if (!currentStudy.value?.id) { + currentPermissions.value = null; + return null; + } + const { data } = await fetchProjectRolePermissions(currentStudy.value.id); + currentPermissions.value = data; + return data; }; const rememberCurrentStudyForUser = (userKey: string) => { @@ -221,6 +235,8 @@ export const useStudyStore = defineStore("study", () => { ensureDefaultActiveStudy, restoreStudyForUser, rehydrateStudyForLastUser, + loadCurrentStudyPermissions, + currentPermissions, clearCurrentStudy, }; }); diff --git a/frontend/src/types/api.ts b/frontend/src/types/api.ts index 5e842099..5dfa2fd0 100644 --- a/frontend/src/types/api.ts +++ b/frontend/src/types/api.ts @@ -128,3 +128,26 @@ export interface Site { is_active: boolean; created_at?: string; } + +export type ProjectPermissionActionState = { + read: boolean; + write: boolean; +}; + +export type ProjectPermissionMatrix = Record>; + +export interface ProjectPermissionModule { + key: string; + label: string; + description?: string | null; + writable?: boolean; +} + +export interface ProjectRolePermissionsResponse { + modules: ProjectPermissionModule[]; + roles: ProjectPermissionMatrix; +} + +export interface ProjectRolePermissionsUpdate { + roles: ProjectPermissionMatrix; +} diff --git a/frontend/src/utils/permission.test.ts b/frontend/src/utils/permission.test.ts index 65e5752b..8313cb3d 100644 --- a/frontend/src/utils/permission.test.ts +++ b/frontend/src/utils/permission.test.ts @@ -5,21 +5,15 @@ import { resolve } from "node:path"; const readPermissionSource = () => readFileSync(resolve(__dirname, "./permission.ts"), "utf8"); describe("permission project role model", () => { - it("does not fall back to non-admin global roles for project permissions", () => { + it("does not fall back to project role names for permission decisions", () => { const source = readPermissionSource(); expect(source).toContain("isSystemAdmin"); expect(source).toContain("getProjectRole"); expect(source).toContain("study.currentStudyRole"); - expect(source).not.toContain("role_in_study || auth.user?.role"); - expect(source).not.toContain("role_in_study || userRole.value"); - }); - - it("allows project administrators and PMs to manage project configuration", () => { - const source = readPermissionSource(); - - expect(source).toContain('"project.members.manage": ["ADMIN", "PM"]'); - expect(source).toContain('"site.manage": ["ADMIN", "PM"]'); - expect(source).toContain('"site.cra.bind": ["ADMIN", "PM"]'); + expect(source).toContain("study.currentPermissions"); + expect(source).toContain('"faq.edit": { module: "faq", action: "write" }'); + expect(source).not.toContain('"faq.edit": { module: "etmf"'); + expect(source).not.toContain('"ADMIN", "PM"'); }); }); diff --git a/frontend/src/utils/permission.ts b/frontend/src/utils/permission.ts index 304e527e..a3fd1041 100644 --- a/frontend/src/utils/permission.ts +++ b/frontend/src/utils/permission.ts @@ -5,19 +5,19 @@ import { TEXT } from "../locales"; import { getProjectRole, isSystemAdmin } from "./roles"; const PERMISSIONS: Record = { - "subject.create": ["ADMIN", "PM", "CRA"], - "subject.enroll": ["ADMIN", "PM", "CRA"], - "subject.complete": ["ADMIN", "PM", "CRA"], - "subject.drop": ["ADMIN", "PM", "CRA"], - "ae.create": ["ADMIN", "PM", "CRA", "PV"], - "ae.close": ["ADMIN", "PM", "PV", "CRA"], - "faq.edit": ["ADMIN", "PM"], - "faq.create": ["ADMIN", "PM", "CRA", "PV", "IMP"], - "faq.reply": ["ADMIN", "PM", "CRA", "PV", "IMP"], - "project.members.manage": ["ADMIN", "PM"], - "site.manage": ["ADMIN", "PM"], - "site.cra.bind": ["ADMIN", "PM"], - "fees.contract.write": ["ADMIN", "PM"], + "subject.create": ["ADMIN"], + "subject.enroll": ["ADMIN"], + "subject.complete": ["ADMIN"], + "subject.drop": ["ADMIN"], + "ae.create": ["ADMIN"], + "ae.close": ["ADMIN"], + "faq.edit": ["ADMIN"], + "faq.create": ["ADMIN"], + "faq.reply": ["ADMIN"], + "project.members.manage": ["ADMIN"], + "site.manage": ["ADMIN"], + "site.cra.bind": ["ADMIN"], + "fees.contract.write": ["ADMIN"], }; const REASONS: Record = { @@ -40,13 +40,37 @@ export const usePermission = () => { const systemAdmin = computed(() => isSystemAdmin(auth.user)); const projectRole = computed(() => getProjectRole(study.currentStudy, study.currentStudyRole)); + const projectPermissions = computed(() => study.currentPermissions); const can = (action: string): boolean => { if (systemAdmin.value) return true; - const allowList = PERMISSIONS[action]; - if (!allowList) return false; if (!projectRole.value) return false; - return allowList.includes(projectRole.value); + const permissionMap: Record = { + "subject.create": { module: "subjects", action: "write" }, + "subject.enroll": { module: "subjects", action: "write" }, + "subject.complete": { module: "subjects", action: "write" }, + "subject.drop": { module: "subjects", action: "write" }, + "ae.create": { module: "risk_issues", action: "write" }, + "ae.close": { module: "risk_issues", action: "write" }, + "project.overview.read": { module: "project_overview", action: "read" }, + "faq.edit": { module: "faq", action: "write" }, + "faq.create": { module: "faq", action: "write" }, + "faq.reply": { module: "faq", action: "write" }, + "knowledge.notes.write": { module: "shared_library", action: "write" }, + "shared.library.read": { module: "shared_library", action: "read" }, + "shared.library.write": { module: "shared_library", action: "write" }, + "project.members.manage": { module: "project_members", action: "write" }, + "site.manage": { module: "sites", action: "write" }, + "site.cra.bind": { module: "sites", action: "write" }, + "fees.contract.write": { module: "fees", action: "write" }, + "fees.attachment.delete": { module: "fees", action: "write" }, + "file.attachment.delete": { module: "file_versions", action: "write" }, + "audit.export.read": { module: "audit_export", action: "read" }, + }; + const mapped = permissionMap[action]; + if (!mapped) return false; + const allowed = projectPermissions.value?.roles?.[projectRole.value]?.[mapped.module]?.[mapped.action]; + return !!allowed; }; const reason = (action: string): string => { diff --git a/frontend/src/utils/projectRoutePermissions.test.ts b/frontend/src/utils/projectRoutePermissions.test.ts new file mode 100644 index 00000000..e57c2f4f --- /dev/null +++ b/frontend/src/utils/projectRoutePermissions.test.ts @@ -0,0 +1,24 @@ +import { describe, expect, it } from "vitest"; +import { findFirstAccessibleProjectPath, getProjectRoutePermission, hasProjectPermission } from "./projectRoutePermissions"; + +describe("project route permissions", () => { + it("maps project routes to read permission modules", () => { + expect(getProjectRoutePermission("/project/overview")).toEqual({ module: "project_overview", action: "read" }); + expect(getProjectRoutePermission("/knowledge/medical-consult/abc")).toEqual({ module: "faq", action: "read" }); + expect(getProjectRoutePermission("/knowledge/notes/new")).toEqual({ module: "shared_library", action: "read" }); + expect(getProjectRoutePermission("/startup/kickoff/new")).toEqual({ module: "startup_auth", action: "read" }); + }); + + it("rejects project routes when the role lacks module read permission", () => { + const matrix = { + CRA: { + project_overview: { read: false, write: false }, + shared_library: { read: true, write: true }, + }, + } as any; + + expect(hasProjectPermission(matrix, "CRA", getProjectRoutePermission("/project/overview"), false)).toBe(false); + expect(hasProjectPermission(matrix, "CRA", getProjectRoutePermission("/knowledge/notes"), false)).toBe(true); + expect(findFirstAccessibleProjectPath(matrix, "CRA", false)).toBe("/knowledge/notes"); + }); +}); diff --git a/frontend/src/utils/projectRoutePermissions.ts b/frontend/src/utils/projectRoutePermissions.ts new file mode 100644 index 00000000..4cb398e0 --- /dev/null +++ b/frontend/src/utils/projectRoutePermissions.ts @@ -0,0 +1,69 @@ +import type { ProjectPermissionMatrix, UserRole } from "../types/api"; + +export type ProjectRoutePermission = { + module: string; + action: "read" | "write"; +}; + +const routePermissions: Array<{ prefixes: string[]; permission: ProjectRoutePermission }> = [ + { prefixes: ["/project/overview"], permission: { module: "project_overview", action: "read" } }, + { prefixes: ["/project/milestones"], permission: { module: "project_milestones", action: "read" } }, + { prefixes: ["/fees/contracts", "/finance/contracts"], permission: { module: "fees", action: "read" } }, + { prefixes: ["/drug/shipments", "/materials/equipment"], permission: { module: "materials", action: "read" } }, + { prefixes: ["/file-versions", "/trial/", "/documents/"], permission: { module: "file_versions", action: "read" } }, + { prefixes: ["/startup/feasibility-ethics", "/startup/feasibility", "/startup/ethics"], permission: { module: "startup_ethics", action: "read" } }, + { prefixes: ["/startup/meeting-auth", "/startup/kickoff", "/startup/training"], permission: { module: "startup_auth", action: "read" } }, + { prefixes: ["/subjects"], permission: { module: "subjects", action: "read" } }, + { prefixes: ["/risk-issues"], permission: { module: "risk_issues", action: "read" } }, + { prefixes: ["/monitoring-audit", "/monitoring", "/audit"], permission: { module: "monitoring_audit", action: "read" } }, + { prefixes: ["/etmf"], permission: { module: "etmf", action: "read" } }, + { prefixes: ["/knowledge/medical-consult"], permission: { module: "faq", action: "read" } }, + { prefixes: ["/knowledge/notes", "/knowledge/support-files", "/knowledge/instruction-files"], permission: { module: "shared_library", action: "read" } }, +]; + +export const projectRouteLandingPaths = [ + "/project/overview", + "/project/milestones", + "/fees/contracts", + "/drug/shipments", + "/materials/equipment", + "/file-versions", + "/startup/feasibility-ethics", + "/startup/meeting-auth", + "/subjects", + "/risk-issues/sae", + "/monitoring-audit", + "/etmf", + "/knowledge/medical-consult", + "/knowledge/notes", + "/knowledge/support-files", + "/knowledge/instruction-files", +]; + +export const getProjectRoutePermission = (path: string): ProjectRoutePermission | null => { + const normalized = path || ""; + for (const item of routePermissions) { + if (item.prefixes.some((prefix) => normalized === prefix || normalized.startsWith(prefix.endsWith("/") ? prefix : `${prefix}/`))) { + return item.permission; + } + } + return null; +}; + +export const hasProjectPermission = ( + matrix: ProjectPermissionMatrix | null | undefined, + role: string | null | undefined, + permission: ProjectRoutePermission | null, + isAdmin: boolean, +) => { + if (!permission || isAdmin) return true; + if (!role) return false; + const typedRole = role as UserRole; + return !!matrix?.[typedRole]?.[permission.module]?.[permission.action]; +}; + +export const findFirstAccessibleProjectPath = ( + matrix: ProjectPermissionMatrix | null | undefined, + role: string | null | undefined, + isAdmin: boolean, +) => projectRouteLandingPaths.find((path) => hasProjectPermission(matrix, role, getProjectRoutePermission(path), isAdmin)) || null; diff --git a/frontend/src/utils/roles.test.ts b/frontend/src/utils/roles.test.ts index cb0face0..bc5c2567 100644 --- a/frontend/src/utils/roles.test.ts +++ b/frontend/src/utils/roles.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { getProjectRole, getSystemRole, isProjectAdminOrPm, isSystemAdmin } from "./roles"; +import { getProjectRole, getSystemRole, isSystemAdmin } from "./roles"; describe("role helpers", () => { it("keeps system admin separate from project admin", () => { @@ -11,8 +11,5 @@ describe("role helpers", () => { it("uses only project role fields for project permissions", () => { expect(getProjectRole({ role_in_study: "PM" } as any, null)).toBe("PM"); expect(getProjectRole({ role_in_study: null } as any, "ADMIN")).toBe("ADMIN"); - expect(isProjectAdminOrPm("ADMIN")).toBe(true); - expect(isProjectAdminOrPm("PM")).toBe(true); - expect(isProjectAdminOrPm("CRA")).toBe(false); }); }); diff --git a/frontend/src/utils/roles.ts b/frontend/src/utils/roles.ts index 0db8c700..30e8920d 100644 --- a/frontend/src/utils/roles.ts +++ b/frontend/src/utils/roles.ts @@ -6,5 +6,3 @@ export const isSystemAdmin = (user?: Pick | null) => getSystem export const getProjectRole = (study?: Pick | null, currentStudyRole?: string | null) => currentStudyRole || study?.role_in_study || null; - -export const isProjectAdminOrPm = (role?: string | null) => role === "ADMIN" || role === "PM"; diff --git a/frontend/src/views/admin/AuditLogs.test.ts b/frontend/src/views/admin/AuditLogs.test.ts index bebb18bf..2ec2fd08 100644 --- a/frontend/src/views/admin/AuditLogs.test.ts +++ b/frontend/src/views/admin/AuditLogs.test.ts @@ -5,7 +5,7 @@ import { resolve } from "node:path"; const readAuditLogsView = () => readFileSync(resolve(__dirname, "./AuditLogs.vue"), "utf8"); describe("audit logs access", () => { - it("allows current project PM access without relying on global PM role", () => { + it("allows only admins or authorized PMs to use audit export", () => { const source = readAuditLogsView(); expect(source).toContain("const canAccessAuditLogs = computed"); @@ -13,6 +13,7 @@ describe("audit logs access", () => { expect(source).toContain("getProjectRole"); expect(source).toContain("study.currentStudyRole"); expect(source).toContain('projectRole.value === "PM"'); + expect(source).toContain("roles?.PM?.audit_export?.read"); expect(source).not.toContain('const role = auth.user?.role'); expect(source).not.toContain('role !== "ADMIN"'); }); diff --git a/frontend/src/views/admin/AuditLogs.vue b/frontend/src/views/admin/AuditLogs.vue index 26e4fb16..47100ca1 100644 --- a/frontend/src/views/admin/AuditLogs.vue +++ b/frontend/src/views/admin/AuditLogs.vue @@ -167,6 +167,7 @@ import { useRouter } from "vue-router"; import { ElMessage, ElMessageBox } from "element-plus"; import { ArrowDown } from "@element-plus/icons-vue"; import { createAuditEvent, fetchAuditLogs } from "../../api/auditLogs"; +import { fetchProjectRolePermissions } from "../../api/projectPermissions"; import { fetchUsers } from "../../api/users"; import { listMembers } from "../../api/members"; import { auditDict, normalizeAuditEvent } from "../../audit"; @@ -187,6 +188,7 @@ const exportLoading = ref(false); const logs = ref([]); const users = ref([]); const allLogs = ref([]); +const permissionMatrix = ref(null); const page = ref(1); const pageSize = ref(20); const total = ref(0); @@ -212,8 +214,14 @@ const resolveUserDisplayName = (u: any): string => { const userOptions = computed(() => users.value.map((u: any) => ({ label: resolveUserDisplayName(u), value: u.id }))); const isAdmin = computed(() => isSystemAdmin(auth.user)); const projectRole = computed(() => getProjectRole(study.currentStudy, study.currentStudyRole)); -const canProjectExport = computed(() => !!study.currentStudy && isAdmin.value); -const canAccessAuditLogs = computed(() => isAdmin.value || projectRole.value === "PM"); +const canProjectExport = computed(() => { + if (isAdmin.value) return true; + return projectRole.value === "PM" && !!permissionMatrix.value?.roles?.PM?.audit_export?.read; +}); +const canAccessAuditLogs = computed(() => { + if (isAdmin.value) return true; + return projectRole.value === "PM" && !!permissionMatrix.value?.roles?.PM?.audit_export?.read; +}); const ensureAccess = () => { if (!canAccessAuditLogs.value) { @@ -221,6 +229,12 @@ const ensureAccess = () => { } }; +const loadPermissionMatrix = async () => { + if (!study.currentStudy) return; + const { data } = await fetchProjectRolePermissions(study.currentStudy.id); + permissionMatrix.value = data; +}; + const loadUsers = async () => { try { if (isAdmin.value) { @@ -244,6 +258,9 @@ const loadLogs = async () => { if (!study.currentStudy) return; loading.value = true; try { + if (!permissionMatrix.value) { + await loadPermissionMatrix(); + } const batchLimit = 500; let skip = 0; const allItems: any[] = []; @@ -418,6 +435,7 @@ onMounted(async () => { if (!auth.user && auth.token) { await auth.fetchMe().catch(() => {}); } + await loadPermissionMatrix().catch(() => {}); ensureAccess(); await loadUsers(); await loadLogs(); diff --git a/frontend/src/views/admin/ProjectDetail.test.ts b/frontend/src/views/admin/ProjectDetail.test.ts index 2d08c2c6..fc1ee363 100644 --- a/frontend/src/views/admin/ProjectDetail.test.ts +++ b/frontend/src/views/admin/ProjectDetail.test.ts @@ -83,11 +83,11 @@ describe("ProjectDetail setup draft publish workflow", () => { }); describe("project detail management role", () => { - it("allows only current project administrators and PMs to manage setup content", () => { + it("allows only system administrators to manage setup content", () => { const source = readProjectDetail(); - expect(source).toContain("project.value?.role_in_study"); - expect(source).toContain('["ADMIN", "PM"].includes(setupRole.value)'); + 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"); }); }); diff --git a/frontend/src/views/admin/ProjectDetail.vue b/frontend/src/views/admin/ProjectDetail.vue index fbb312fa..671c1333 100644 --- a/frontend/src/views/admin/ProjectDetail.vue +++ b/frontend/src/views/admin/ProjectDetail.vue @@ -1932,6 +1932,7 @@ import StateLoading from "../../components/StateLoading.vue"; import { useStudyStore } from "../../store/study"; import { useAuthStore } from "../../store/auth"; import { TEXT, requiredMessage } from "../../locales"; +import { isSystemAdmin } from "../../utils/roles"; import { groupErrorsBySection, parseFieldPath, type SetupValidationError } from "../../utils/setupFieldLocator"; import { buildSetupReadableDiffRows, serializeDiffValue, type SetupDiffRow } from "../../utils/setupDiffRows"; import { @@ -2402,8 +2403,7 @@ const setupDraft = reactive({ const storageKey = computed(() => `ctms_setup_config_draft_${String(route.params.projectId || "")}`); const projectDraftStorageKey = computed(() => `ctms_setup_project_draft_${String(route.params.projectId || "")}`); -const setupRole = computed(() => String(project.value?.role_in_study || "").toUpperCase()); -const canManageSetup = computed(() => ["ADMIN", "PM"].includes(setupRole.value)); +const canManageSetup = computed(() => isSystemAdmin(authStore.user)); const isPreviewView = computed(() => setupViewMode.value === "preview"); const isPublishedVersionView = computed(() => setupViewMode.value === "published"); const isPublishedView = computed(() => isPreviewView.value || isPublishedVersionView.value); @@ -4419,6 +4419,7 @@ const refreshProjectDisplayAfterPublish = async () => { formBaselineSnapshot.value = serializeFormForCompare(); if (studyStore.currentStudy?.id === project.value.id) { studyStore.setCurrentStudy({ ...(studyStore.currentStudy as Study), ...project.value } as Study); + await studyStore.loadCurrentStudyPermissions().catch(() => {}); } } catch { // Ignore refresh errors to avoid breaking publish success flow. @@ -5559,9 +5560,10 @@ const confirmPublishConfig = async () => { } }; -const enterProject = () => { +const enterProject = async () => { if (!project.value) return; studyStore.setCurrentStudy(project.value); + await studyStore.loadCurrentStudyPermissions().catch(() => {}); router.push("/project/overview"); }; const goMembers = () => { diff --git a/frontend/src/views/admin/ProjectPermissions.test.ts b/frontend/src/views/admin/ProjectPermissions.test.ts new file mode 100644 index 00000000..635e21c7 --- /dev/null +++ b/frontend/src/views/admin/ProjectPermissions.test.ts @@ -0,0 +1,16 @@ +import { describe, expect, it } from "vitest"; +import { readFileSync } from "node:fs"; +import { resolve } from "node:path"; + +const readProjectPermissions = () => readFileSync(resolve(__dirname, "./ProjectPermissions.vue"), "utf8"); + +describe("project permissions matrix", () => { + it("locks management backend permissions to admin and PM roles", () => { + const source = readProjectPermissions(); + + expect(source).toContain('new Set(["project_members", "sites", "audit_export"])'); + expect(source).toContain('role === "PM" || role === "ADMIN"'); + expect(source).toContain("!canConfigureAdminModule(role, mod.key)"); + expect(source).toContain("!canConfigureAdminModule(role, moduleKey)"); + }); +}); diff --git a/frontend/src/views/admin/ProjectPermissions.vue b/frontend/src/views/admin/ProjectPermissions.vue new file mode 100644 index 00000000..0c9f417d --- /dev/null +++ b/frontend/src/views/admin/ProjectPermissions.vue @@ -0,0 +1,475 @@ + + + + + diff --git a/frontend/src/views/admin/Projects.vue b/frontend/src/views/admin/Projects.vue index b870456b..682348ce 100644 --- a/frontend/src/views/admin/Projects.vue +++ b/frontend/src/views/admin/Projects.vue @@ -30,12 +30,15 @@ 未锁定 - +