清理全局角色残留并修复项目权限判断

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