完全移除模块级权限系统,迁移至接口级权限

后端:
- 删除 StudyRolePermission 模型和 project_permissions API 文件
- 重写 project_permissions.py core,移除所有模块级权限函数
- 重写 permission_cache.py,移除模块级权限缓存逻辑
- 新增权限模板功能(PermissionTemplate 模型、API、服务层)
- 新增 permission_templates 数据库迁移
- 迁移 8 个模块(attachments、audit_logs、dashboard、faqs、
  faq_categories、fees_attachments、knowledge_notes、
  material_equipments、overview、subject_histories、subject_pds)
  至接口级权限检查
- 删除所有模块级权限相关测试文件,新增权限模板测试

前端:
- 删除 ProjectPermissions.vue 和 ProjectPermissionsModule.vue
- 重写 projectRoutePermissions.ts,改为基于接口级权限格式
- 更新 store/study.ts、router/index.ts、AuditLogs.vue、Projects.vue
  中的权限 API 调用,从 fetchProjectRolePermissions 改为
  fetchApiEndpointPermissions
- 清理 types/api.ts 中的旧模块级权限类型定义
- 新增 PermissionTemplateSelector.vue 组件
- 更新权限管理页面,移除模块级权限 tab

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Cheng Zhou
2026-05-15 09:07:43 +08:00
parent 3b1bdc2070
commit 20ce6bccef
55 changed files with 1971 additions and 3030 deletions
@@ -0,0 +1,151 @@
"""add permission templates
Revision ID: 20260514_01
Revises: 20260513_02
Create Date: 2026-05-14 10:00:00.000000
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
from sqlalchemy.dialects import postgresql
revision: str = "20260514_01"
down_revision: Union[str, None] = "20260513_02"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# 创建 permission_templates 表
op.create_table(
"permission_templates",
sa.Column("id", postgresql.UUID(as_uuid=True), nullable=False),
sa.Column("name", sa.String(100), nullable=False),
sa.Column("description", sa.String(500), nullable=True),
sa.Column(
"template_type",
sa.Enum("ROLE", "SCENARIO", "CUSTOM", name="template_type"),
nullable=False,
),
sa.Column("is_system", sa.Boolean(), nullable=False, server_default="false"),
sa.Column("created_by", postgresql.UUID(as_uuid=True), nullable=True),
sa.Column(
"created_at",
sa.DateTime(timezone=True),
nullable=False,
server_default=sa.func.now(),
),
sa.Column(
"updated_at",
sa.DateTime(timezone=True),
nullable=False,
server_default=sa.func.now(),
),
sa.Column("permissions", postgresql.JSON(), nullable=False),
sa.Column("tags", sa.String(200), nullable=True),
sa.Column("category", sa.String(50), nullable=True),
sa.Column("recommended_roles", sa.String(200), nullable=True),
sa.PrimaryKeyConstraint("id"),
)
# 创建 permission_template_versions 表
op.create_table(
"permission_template_versions",
sa.Column("id", postgresql.UUID(as_uuid=True), nullable=False),
sa.Column("template_id", postgresql.UUID(as_uuid=True), nullable=False),
sa.Column("version", sa.Integer(), nullable=False),
sa.Column("permissions", postgresql.JSON(), nullable=False),
sa.Column("change_log", sa.String(500), nullable=True),
sa.Column(
"created_at",
sa.DateTime(timezone=True),
nullable=False,
server_default=sa.func.now(),
),
sa.ForeignKeyConstraint(
["template_id"],
["permission_templates.id"],
),
sa.PrimaryKeyConstraint("id"),
)
# 创建唯一约束
op.create_unique_constraint(
"uq_template_versions",
"permission_template_versions",
["template_id", "version"],
)
# 插入系统预设模板
_insert_system_templates()
def _insert_system_templates() -> None:
"""插入6个系统预设角色模板"""
templates = [
(
"3078efe6-60d6-4623-b7b3-862aac0084cd",
"项目经理",
"项目管理员,拥有所有权限",
"PM",
"PM",
'{"PM": {"subjects:create": true, "subjects:list": true, "subjects:read": true, "subjects:update": true, "subjects:delete": true, "visits:create": true, "visits:list": true, "visits:read": true, "visits:update": true, "visits:delete": true, "risk_issues:create": true, "risk_issues:list": true, "risk_issues:read": true, "risk_issues:update": true, "risk_issues:delete": true, "finance_contracts:create": true, "finance_contracts:list": true, "finance_contracts:read": true, "finance_contracts:update": true, "finance_contracts:delete": true, "fees_contracts:create": true, "fees_contracts:list": true, "fees_contracts:read": true, "fees_contracts:update": true, "fees_contracts:delete": true, "fees_payments:create": true, "fees_payments:update": true, "fees_payments:delete": true, "project_members:create": true, "project_members:list": true, "project_members:candidates": true, "project_members:update": true, "project_members:delete": true, "sites:create": true, "sites:list": true, "sites:read": true, "sites:update": true, "sites:delete": true, "ethics:create": true, "ethics:list": true, "ethics:read": true, "ethics:update": true, "ethics:delete": true, "feasibility:create": true, "feasibility:list": true, "feasibility:read": true, "feasibility:update": true, "feasibility:delete": true, "budget:create": true, "budget:list": true, "budget:read": true, "budget:update": true, "budget:delete": true, "timeline:create": true, "timeline:list": true, "timeline:read": true, "timeline:update": true, "timeline:delete": true, "permissions:read": true, "permissions:update": true, "overview:read": true, "monitoring_issues:create": true, "monitoring_issues:list": true, "monitoring_issues:read": true, "monitoring_issues:update": true, "monitoring_issues:delete": true, "monitoring_issues:close": true, "monitoring_issues:history": true, "monitoring_audit:create": true, "monitoring_audit:read": true, "monitoring_audit:update": true, "monitoring_audit:delete": true, "drug_shipments:create": true, "drug_shipments:list": true, "drug_shipments:read": true, "drug_shipments:update": true, "drug_shipments:delete": true, "materials:create": true, "materials:list": true, "materials:read": true, "materials:update": true, "materials:delete": true, "subject_pds:create": true, "subject_pds:list": true, "subject_pds:read": true, "subject_pds:update": true, "audit_logs:list": true, "audit_logs:read": true, "audit_logs:export": true, "knowledge_notes:create": true, "knowledge_notes:list": true, "knowledge_notes:read": true, "knowledge_notes:update": true, "knowledge_notes:delete": true, "subject_history:list": true, "subject_history:read": true, "subject_history:timeline": true, "subject_history:export": true, "subject_history:search": true, "milestones:list": true, "milestones:update": true, "attachments:create": true, "attachments:read": true, "attachments:update": true, "attachments:delete": true, "fees_attachments:create": true, "fees_attachments:read": true, "fees_attachments:delete": true, "faq:create": true, "faq:read": true, "faq:update": true, "faq:delete": true, "faq_category:create": true, "faq_category:read": true, "faq_category:update": true, "faq_category:delete": true, "faq_reply:create": true, "faq_reply:delete": true, "dashboard:read": true, "subject_histories:create": true, "subject_histories:list": true, "subject_histories:read": true, "subject_histories:update": true, "subject_histories:delete": true, "material_equipments:create": true, "material_equipments:list": true, "material_equipments:read": true, "material_equipments:update": true, "material_equipments:delete": true, "documents:create": true, "documents:read": true, "documents:update": true, "documents:delete": true}}',
),
(
"a17e6fd5-f0c6-4723-b166-6c25a595e28d",
"临床研究协调员",
"数据输入和日常管理人员",
"CRA",
"CRA",
'{"CRA": {"subjects:create": true, "subjects:list": true, "subjects:read": true, "subjects:update": true, "subjects:delete": false, "visits:create": true, "visits:list": true, "visits:read": true, "visits:update": true, "visits:delete": false, "risk_issues:create": true, "risk_issues:list": true, "risk_issues:read": true, "risk_issues:update": true, "risk_issues:delete": false, "finance_contracts:create": false, "finance_contracts:list": true, "finance_contracts:read": true, "finance_contracts:update": false, "finance_contracts:delete": false, "fees_contracts:create": false, "fees_contracts:list": true, "fees_contracts:read": true, "fees_contracts:update": false, "fees_contracts:delete": false, "fees_payments:create": false, "fees_payments:update": false, "fees_payments:delete": false, "project_members:create": false, "project_members:list": false, "project_members:candidates": false, "project_members:update": false, "project_members:delete": false, "sites:create": false, "sites:list": true, "sites:read": true, "sites:update": false, "sites:delete": false, "ethics:create": false, "ethics:list": true, "ethics:read": true, "ethics:update": false, "ethics:delete": false, "feasibility:create": false, "feasibility:list": true, "feasibility:read": true, "feasibility:update": false, "feasibility:delete": false, "budget:create": false, "budget:list": true, "budget:read": true, "budget:update": false, "budget:delete": false, "timeline:create": false, "timeline:list": true, "timeline:read": true, "timeline:update": false, "timeline:delete": false, "permissions:read": false, "permissions:update": false, "overview:read": true, "monitoring_issues:create": true, "monitoring_issues:list": true, "monitoring_issues:read": true, "monitoring_issues:update": true, "monitoring_issues:delete": false, "monitoring_issues:close": true, "monitoring_issues:history": true, "monitoring_audit:create": true, "monitoring_audit:read": true, "monitoring_audit:update": true, "monitoring_audit:delete": false, "drug_shipments:create": false, "drug_shipments:list": true, "drug_shipments:read": true, "drug_shipments:update": false, "drug_shipments:delete": false, "materials:create": false, "materials:list": true, "materials:read": true, "materials:update": false, "materials:delete": false, "subject_pds:create": true, "subject_pds:list": true, "subject_pds:read": true, "subject_pds:update": true, "audit_logs:list": false, "audit_logs:read": false, "audit_logs:export": false, "knowledge_notes:create": true, "knowledge_notes:list": true, "knowledge_notes:read": true, "knowledge_notes:update": true, "knowledge_notes:delete": false, "subject_history:list": true, "subject_history:read": true, "subject_history:timeline": true, "subject_history:export": true, "subject_history:search": true, "milestones:list": true, "milestones:update": false, "attachments:create": true, "attachments:read": true, "attachments:update": true, "attachments:delete": true, "fees_attachments:create": true, "fees_attachments:read": true, "fees_attachments:delete": true, "faq:create": false, "faq:read": true, "faq:update": false, "faq:delete": false, "faq_category:create": false, "faq_category:read": true, "faq_category:update": false, "faq_category:delete": false, "faq_reply:create": true, "faq_reply:delete": false, "dashboard:read": true, "subject_histories:create": true, "subject_histories:list": true, "subject_histories:read": true, "subject_histories:update": true, "subject_histories:delete": false, "material_equipments:create": true, "material_equipments:list": true, "material_equipments:read": true, "material_equipments:update": true, "material_equipments:delete": false, "documents:create": true, "documents:read": true, "documents:update": true, "documents:delete": false}}',
),
(
"f45fbfda-faed-4b83-b4a8-24ca4011894b",
"访视员",
"访视和参与者管理人员",
"PV",
"PV",
'{"PV": {"subjects:create": false, "subjects:list": true, "subjects:read": true, "subjects:update": false, "subjects:delete": false, "visits:create": false, "visits:list": true, "visits:read": true, "visits:update": false, "visits:delete": false, "risk_issues:create": true, "risk_issues:list": true, "risk_issues:read": true, "risk_issues:update": true, "risk_issues:delete": false, "finance_contracts:create": false, "finance_contracts:list": false, "finance_contracts:read": false, "finance_contracts:update": false, "finance_contracts:delete": false, "fees_contracts:create": false, "fees_contracts:list": false, "fees_contracts:read": false, "fees_contracts:update": false, "fees_contracts:delete": false, "fees_payments:create": false, "fees_payments:update": false, "fees_payments:delete": false, "project_members:create": false, "project_members:list": false, "project_members:candidates": false, "project_members:update": false, "project_members:delete": false, "sites:create": false, "sites:list": true, "sites:read": true, "sites:update": false, "sites:delete": false, "ethics:create": false, "ethics:list": true, "ethics:read": true, "ethics:update": false, "ethics:delete": false, "feasibility:create": false, "feasibility:list": true, "feasibility:read": true, "feasibility:update": false, "feasibility:delete": false, "budget:create": false, "budget:list": false, "budget:read": false, "budget:update": false, "budget:delete": false, "timeline:create": false, "timeline:list": true, "timeline:read": true, "timeline:update": false, "timeline:delete": false, "permissions:read": false, "permissions:update": false, "overview:read": true, "monitoring_issues:create": false, "monitoring_issues:list": true, "monitoring_issues:read": true, "monitoring_issues:update": false, "monitoring_issues:delete": false, "monitoring_issues:close": false, "monitoring_issues:history": true, "monitoring_audit:create": true, "monitoring_audit:read": true, "monitoring_audit:update": true, "monitoring_audit:delete": false, "drug_shipments:create": false, "drug_shipments:list": false, "drug_shipments:read": false, "drug_shipments:update": false, "drug_shipments:delete": false, "materials:create": false, "materials:list": false, "materials:read": false, "materials:update": false, "materials:delete": false, "subject_pds:create": false, "subject_pds:list": true, "subject_pds:read": true, "subject_pds:update": false, "audit_logs:list": false, "audit_logs:read": false, "audit_logs:export": false, "knowledge_notes:create": true, "knowledge_notes:list": true, "knowledge_notes:read": true, "knowledge_notes:update": true, "knowledge_notes:delete": false, "subject_history:list": true, "subject_history:read": true, "subject_history:timeline": true, "subject_history:export": false, "subject_history:search": true, "milestones:list": true, "milestones:update": false, "attachments:create": true, "attachments:read": true, "attachments:update": false, "attachments:delete": false, "fees_attachments:create": false, "fees_attachments:read": true, "fees_attachments:delete": false, "faq:create": false, "faq:read": true, "faq:update": false, "faq:delete": false, "faq_category:create": false, "faq_category:read": true, "faq_category:update": false, "faq_category:delete": false, "faq_reply:create": true, "faq_reply:delete": false, "dashboard:read": true, "subject_histories:create": false, "subject_histories:list": true, "subject_histories:read": true, "subject_histories:update": false, "subject_histories:delete": false, "material_equipments:create": false, "material_equipments:list": true, "material_equipments:read": true, "material_equipments:update": false, "material_equipments:delete": false, "documents:create": false, "documents:read": true, "documents:update": false, "documents:delete": false}}',
),
(
"b43d06e4-2ee2-4e80-b733-85964d738f40",
"医学审核",
"医学审核人员",
"MEDICAL_REVIEW",
"MEDICAL_REVIEW",
'{"MEDICAL_REVIEW": {"subjects:create": false, "subjects:list": true, "subjects:read": true, "subjects:update": false, "subjects:delete": false, "visits:create": false, "visits:list": true, "visits:read": true, "visits:update": false, "visits:delete": false, "risk_issues:create": true, "risk_issues:list": true, "risk_issues:read": true, "risk_issues:update": true, "risk_issues:delete": false, "finance_contracts:create": false, "finance_contracts:list": false, "finance_contracts:read": false, "finance_contracts:update": false, "finance_contracts:delete": false, "fees_contracts:create": false, "fees_contracts:list": false, "fees_contracts:read": false, "fees_contracts:update": false, "fees_contracts:delete": false, "fees_payments:create": false, "fees_payments:update": false, "fees_payments:delete": false, "project_members:create": false, "project_members:list": false, "project_members:candidates": false, "project_members:update": false, "project_members:delete": false, "sites:create": false, "sites:list": true, "sites:read": true, "sites:update": false, "sites:delete": false, "ethics:create": false, "ethics:list": true, "ethics:read": true, "ethics:update": false, "ethics:delete": false, "feasibility:create": false, "feasibility:list": true, "feasibility:read": true, "feasibility:update": false, "feasibility:delete": false, "budget:create": false, "budget:list": false, "budget:read": false, "budget:update": false, "budget:delete": false, "timeline:create": false, "timeline:list": false, "timeline:read": false, "timeline:update": false, "timeline:delete": false, "permissions:read": false, "permissions:update": false, "overview:read": true, "monitoring_issues:create": false, "monitoring_issues:list": true, "monitoring_issues:read": true, "monitoring_issues:update": false, "monitoring_issues:delete": false, "monitoring_issues:close": false, "monitoring_issues:history": true, "monitoring_audit:create": true, "monitoring_audit:read": true, "monitoring_audit:update": true, "monitoring_audit:delete": false, "drug_shipments:create": false, "drug_shipments:list": false, "drug_shipments:read": false, "drug_shipments:update": false, "drug_shipments:delete": false, "materials:create": false, "materials:list": false, "materials:read": false, "materials:update": false, "materials:delete": false, "subject_pds:create": false, "subject_pds:list": true, "subject_pds:read": true, "subject_pds:update": false, "audit_logs:list": false, "audit_logs:read": false, "audit_logs:export": false, "knowledge_notes:create": true, "knowledge_notes:list": true, "knowledge_notes:read": true, "knowledge_notes:update": true, "knowledge_notes:delete": false, "subject_history:list": true, "subject_history:read": true, "subject_history:timeline": true, "subject_history:export": false, "subject_history:search": true, "milestones:list": true, "milestones:update": false, "attachments:create": false, "attachments:read": true, "attachments:update": false, "attachments:delete": false, "fees_attachments:create": false, "fees_attachments:read": false, "fees_attachments:delete": false, "faq:create": false, "faq:read": true, "faq:update": false, "faq:delete": false, "faq_category:create": false, "faq_category:read": true, "faq_category:update": false, "faq_category:delete": false, "faq_reply:create": true, "faq_reply:delete": false, "dashboard:read": true, "subject_histories:create": false, "subject_histories:list": true, "subject_histories:read": true, "subject_histories:update": false, "subject_histories:delete": false, "material_equipments:create": false, "material_equipments:list": true, "material_equipments:read": true, "material_equipments:update": false, "material_equipments:delete": false, "documents:create": false, "documents:read": true, "documents:update": false, "documents:delete": false}}',
),
(
"1e38cffb-a7f2-4243-a31c-1a4f8ce66a05",
"物资管理员",
"物资和设备管理人员",
"IMP",
"IMP",
'{"IMP": {"subjects:create": false, "subjects:list": true, "subjects:read": true, "subjects:update": false, "subjects:delete": false, "visits:create": false, "visits:list": true, "visits:read": true, "visits:update": false, "visits:delete": false, "risk_issues:create": false, "risk_issues:list": true, "risk_issues:read": true, "risk_issues:update": false, "risk_issues:delete": false, "finance_contracts:create": false, "finance_contracts:list": true, "finance_contracts:read": true, "finance_contracts:update": false, "finance_contracts:delete": false, "fees_contracts:create": false, "fees_contracts:list": true, "fees_contracts:read": true, "fees_contracts:update": false, "fees_contracts:delete": false, "fees_payments:create": false, "fees_payments:update": false, "fees_payments:delete": false, "project_members:create": false, "project_members:list": false, "project_members:candidates": false, "project_members:update": false, "project_members:delete": false, "sites:create": false, "sites:list": true, "sites:read": true, "sites:update": false, "sites:delete": false, "ethics:create": false, "ethics:list": false, "ethics:read": false, "ethics:update": false, "ethics:delete": false, "feasibility:create": false, "feasibility:list": false, "feasibility:read": false, "feasibility:update": false, "feasibility:delete": false, "budget:create": false, "budget:list": true, "budget:read": true, "budget:update": false, "budget:delete": false, "timeline:create": false, "timeline:list": false, "timeline:read": false, "timeline:update": false, "timeline:delete": false, "permissions:read": false, "permissions:update": false, "overview:read": true, "monitoring_issues:create": false, "monitoring_issues:list": false, "monitoring_issues:read": false, "monitoring_issues:update": false, "monitoring_issues:delete": false, "monitoring_issues:close": false, "monitoring_issues:history": false, "monitoring_audit:create": false, "monitoring_audit:read": false, "monitoring_audit:update": false, "monitoring_audit:delete": false, "drug_shipments:create": true, "drug_shipments:list": true, "drug_shipments:read": true, "drug_shipments:update": true, "drug_shipments:delete": false, "materials:create": true, "materials:list": true, "materials:read": true, "materials:update": true, "materials:delete": false, "subject_pds:create": false, "subject_pds:list": false, "subject_pds:read": false, "subject_pds:update": false, "audit_logs:list": false, "audit_logs:read": false, "audit_logs:export": false, "knowledge_notes:create": false, "knowledge_notes:list": true, "knowledge_notes:read": true, "knowledge_notes:update": false, "knowledge_notes:delete": false, "subject_history:list": false, "subject_history:read": false, "subject_history:timeline": false, "subject_history:export": false, "subject_history:search": false, "milestones:list": true, "milestones:update": false, "attachments:create": false, "attachments:read": true, "attachments:update": false, "attachments:delete": false, "fees_attachments:create": false, "fees_attachments:read": false, "fees_attachments:delete": false, "faq:create": false, "faq:read": true, "faq:update": false, "faq:delete": false, "faq_category:create": false, "faq_category:read": true, "faq_category:update": false, "faq_category:delete": false, "faq_reply:create": false, "faq_reply:delete": false, "dashboard:read": true, "subject_histories:create": false, "subject_histories:list": true, "subject_histories:read": true, "subject_histories:update": false, "subject_histories:delete": false, "material_equipments:create": false, "material_equipments:list": true, "material_equipments:read": true, "material_equipments:update": false, "material_equipments:delete": false, "documents:create": false, "documents:read": true, "documents:update": false, "documents:delete": false}}',
),
(
"0c907f29-dc88-4878-9c66-15cbc22f4e13",
"质量保证",
"质量检查和审计人员,以查询权限为主",
"QA",
"QA",
'{"QA": {"subjects:create": false, "subjects:list": true, "subjects:read": true, "subjects:update": false, "subjects:delete": false, "visits:create": false, "visits:list": true, "visits:read": true, "visits:update": false, "visits:delete": false, "risk_issues:create": false, "risk_issues:list": true, "risk_issues:read": true, "risk_issues:update": false, "risk_issues:delete": false, "finance_contracts:create": false, "finance_contracts:list": false, "finance_contracts:read": false, "finance_contracts:update": false, "finance_contracts:delete": false, "fees_contracts:create": false, "fees_contracts:list": false, "fees_contracts:read": false, "fees_contracts:update": false, "fees_contracts:delete": false, "fees_payments:create": false, "fees_payments:update": false, "fees_payments:delete": false, "project_members:create": false, "project_members:list": false, "project_members:candidates": false, "project_members:update": false, "project_members:delete": false, "sites:create": false, "sites:list": true, "sites:read": true, "sites:update": false, "sites:delete": false, "ethics:create": false, "ethics:list": false, "ethics:read": false, "ethics:update": false, "ethics:delete": false, "feasibility:create": false, "feasibility:list": false, "feasibility:read": false, "feasibility:update": false, "feasibility:delete": false, "budget:create": false, "budget:list": false, "budget:read": false, "budget:update": false, "budget:delete": false, "timeline:create": false, "timeline:list": false, "timeline:read": false, "timeline:update": false, "timeline:delete": false, "permissions:read": false, "permissions:update": false, "overview:read": true, "monitoring_issues:create": false, "monitoring_issues:list": false, "monitoring_issues:read": false, "monitoring_issues:update": false, "monitoring_issues:delete": false, "monitoring_issues:close": false, "monitoring_issues:history": false, "monitoring_audit:create": false, "monitoring_audit:read": true, "monitoring_audit:update": false, "monitoring_audit:delete": false, "drug_shipments:create": false, "drug_shipments:list": true, "drug_shipments:read": true, "drug_shipments:update": false, "drug_shipments:delete": false, "materials:create": false, "materials:list": true, "materials:read": true, "materials:update": false, "materials:delete": false, "subject_pds:create": false, "subject_pds:list": false, "subject_pds:read": false, "subject_pds:update": false, "audit_logs:list": true, "audit_logs:read": true, "audit_logs:export": true, "knowledge_notes:create": false, "knowledge_notes:list": true, "knowledge_notes:read": true, "knowledge_notes:update": false, "knowledge_notes:delete": false, "subject_history:list": false, "subject_history:read": false, "subject_history:timeline": false, "subject_history:export": false, "subject_history:search": false, "milestones:list": true, "milestones:update": false, "attachments:create": false, "attachments:read": true, "attachments:update": false, "attachments:delete": false, "fees_attachments:create": false, "fees_attachments:read": false, "fees_attachments:delete": false, "faq:create": false, "faq:read": true, "faq:update": false, "faq:delete": false, "faq_category:create": false, "faq_category:read": true, "faq_category:update": false, "faq_category:delete": false, "faq_reply:create": false, "faq_reply:delete": false, "dashboard:read": true, "subject_histories:create": false, "subject_histories:list": true, "subject_histories:read": true, "subject_histories:update": false, "subject_histories:delete": false, "material_equipments:create": false, "material_equipments:list": true, "material_equipments:read": true, "material_equipments:update": false, "material_equipments:delete": false, "documents:create": false, "documents:read": true, "documents:update": false, "documents:delete": false}}',
),
]
for tid, name, desc, category, recommended_roles, perms_json in templates:
op.execute(
f"""INSERT INTO permission_templates
(id, name, description, template_type, is_system, category, recommended_roles, permissions, created_at, updated_at)
VALUES ('{tid}', '{name}', '{desc}', 'ROLE', true, '{category}', '{recommended_roles}',
'{perms_json}'::json, NOW(), NOW())"""
)
def downgrade() -> None:
op.drop_table("permission_template_versions")
op.drop_table("permission_templates")
op.execute("DROP TYPE IF EXISTS template_type")
+5 -2
View File
@@ -20,6 +20,9 @@ from app.core.api_permissions import API_ENDPOINT_PERMISSIONS, PROJECT_PERMISSIO
router = APIRouter(prefix="/api-permissions", tags=["api-permissions"]) router = APIRouter(prefix="/api-permissions", tags=["api-permissions"])
# 项目级路由(需要 study_id,挂载到 /studies/{study_id}/api-permissions
study_router = APIRouter(prefix="/api-permissions", tags=["api-permissions"])
@router.get( @router.get(
"/operations", "/operations",
@@ -138,7 +141,7 @@ async def check_operation_prerequisites(
} }
@router.get( @study_router.get(
"", "",
summary="获取项目的接口级权限矩阵", summary="获取项目的接口级权限矩阵",
description="返回项目中各角色对API端点的权限配置", description="返回项目中各角色对API端点的权限配置",
@@ -176,7 +179,7 @@ async def get_study_api_permissions(
return result return result
@router.put( @study_router.put(
"", "",
summary="更新项目的接口级权限矩阵", summary="更新项目的接口级权限矩阵",
description="批量更新项目中各角色对API端点的权限配置", description="批量更新项目中各角色对API端点的权限配置",
+22 -49
View File
@@ -8,8 +8,8 @@ from fastapi import APIRouter, Depends, File, HTTPException, UploadFile, status,
from fastapi.responses import FileResponse from fastapi.responses import FileResponse
from sqlalchemy.ext.asyncio import AsyncSession 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_study_member, require_study_not_locked, require_api_permission
from app.core.project_permissions import role_has_project_permission from app.core.project_permissions import role_has_api_permission
from app.crud import attachment as attachment_crud from app.crud import attachment as attachment_crud
from app.crud import audit as audit_crud from app.crud import audit as audit_crud
from app.crud import study as study_crud from app.crud import study as study_crud
@@ -23,9 +23,6 @@ router = APIRouter()
global_router = APIRouter() global_router = APIRouter()
UPLOAD_ROOT = Path(__file__).resolve().parent.parent.parent / "uploads" UPLOAD_ROOT = Path(__file__).resolve().parent.parent.parent / "uploads"
ATTACHMENT_PERMISSION_MODULE_BY_ENTITY = {
"knowledge_note": "shared_library",
}
def _content_disposition(filename: str, disposition: str = "inline") -> str: def _content_disposition(filename: str, disposition: str = "inline") -> str:
@@ -41,39 +38,14 @@ async def _ensure_study_exists(db: AsyncSession, study_id: uuid.UUID):
return study return study
def _permission_module_for_entity(entity_type: str) -> str:
return ATTACHMENT_PERMISSION_MODULE_BY_ENTITY.get(entity_type, "file_versions")
async def _ensure_attachment_permission(
db: AsyncSession,
study_id: uuid.UUID,
current_user,
entity_type: str,
action: str,
) -> None:
role_value = current_user.role.value if hasattr(current_user.role, "value") else current_user.role
if role_value == "ADMIN":
return
membership = await member_crud.get_member(db, study_id, current_user.id)
if not membership or not membership.is_active:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="不是项目成员")
allowed = await role_has_project_permission(
db,
study_id,
membership.role_in_study,
_permission_module_for_entity(entity_type),
action,
)
if not allowed:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="权限不足")
@router.post( @router.post(
"/", "/",
response_model=AttachmentRead, response_model=AttachmentRead,
status_code=status.HTTP_201_CREATED, status_code=status.HTTP_201_CREATED,
dependencies=[Depends(require_study_not_locked())], dependencies=[
Depends(require_api_permission("attachments:create")),
Depends(require_study_not_locked())
],
) )
async def upload_attachment( async def upload_attachment(
study_id: uuid.UUID, study_id: uuid.UUID,
@@ -84,7 +56,6 @@ async def upload_attachment(
current_user=Depends(get_current_user), current_user=Depends(get_current_user),
) -> AttachmentRead: ) -> AttachmentRead:
await _ensure_study_exists(db, study_id) await _ensure_study_exists(db, study_id)
await _ensure_attachment_permission(db, study_id, current_user, entity_type, "write")
dest_dir = UPLOAD_ROOT / f"study_{study_id}" / f"{entity_type}_{entity_id}" dest_dir = UPLOAD_ROOT / f"study_{study_id}" / f"{entity_type}_{entity_id}"
dest_dir.mkdir(parents=True, exist_ok=True) dest_dir.mkdir(parents=True, exist_ok=True)
unique_name = f"{uuid.uuid4()}{Path(file.filename).suffix}" unique_name = f"{uuid.uuid4()}{Path(file.filename).suffix}"
@@ -129,6 +100,7 @@ async def upload_attachment(
@router.get( @router.get(
"/", "/",
response_model=list[AttachmentRead], response_model=list[AttachmentRead],
dependencies=[Depends(require_api_permission("attachments:read"))],
) )
async def list_attachments( async def list_attachments(
study_id: uuid.UUID, study_id: uuid.UUID,
@@ -138,7 +110,6 @@ async def list_attachments(
current_user=Depends(get_current_user), current_user=Depends(get_current_user),
) -> list[AttachmentRead]: ) -> list[AttachmentRead]:
await _ensure_study_exists(db, study_id) await _ensure_study_exists(db, study_id)
await _ensure_attachment_permission(db, study_id, current_user, entity_type, "read")
attachments = await attachment_crud.list_attachments(db, study_id, entity_type, entity_id) attachments = await attachment_crud.list_attachments(db, study_id, entity_type, entity_id)
user_ids = {a.uploaded_by for a in attachments if a.uploaded_by} 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) users_map = await user_crud.get_users_by_ids(db, user_ids)
@@ -161,6 +132,7 @@ async def list_attachments(
@router.get( @router.get(
"/{attachment_id}/download", "/{attachment_id}/download",
dependencies=[Depends(require_api_permission("attachments:read"))],
) )
async def download_attachment( async def download_attachment(
study_id: uuid.UUID, study_id: uuid.UUID,
@@ -171,7 +143,6 @@ async def download_attachment(
current_user=Depends(get_current_user), current_user=Depends(get_current_user),
) -> FileResponse: ) -> FileResponse:
await _ensure_study_exists(db, study_id) await _ensure_study_exists(db, study_id)
await _ensure_attachment_permission(db, study_id, current_user, entity_type, "read")
attachment = await attachment_crud.get_attachment(db, attachment_id) attachment = await attachment_crud.get_attachment(db, attachment_id)
if not attachment or attachment.study_id != study_id or attachment.entity_id != entity_id or attachment.entity_type != entity_type: if not attachment or attachment.study_id != study_id or attachment.entity_id != entity_id or attachment.entity_type != entity_type:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="附件不存在") raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="附件不存在")
@@ -187,6 +158,7 @@ async def download_attachment(
@router.get( @router.get(
"/{attachment_id}/preview", "/{attachment_id}/preview",
dependencies=[Depends(require_api_permission("attachments:read"))],
) )
async def preview_attachment( async def preview_attachment(
study_id: uuid.UUID, study_id: uuid.UUID,
@@ -197,7 +169,6 @@ async def preview_attachment(
current_user=Depends(get_current_user), current_user=Depends(get_current_user),
) -> FileResponse: ) -> FileResponse:
await _ensure_study_exists(db, study_id) await _ensure_study_exists(db, study_id)
await _ensure_attachment_permission(db, study_id, current_user, entity_type, "read")
attachment = await attachment_crud.get_attachment(db, attachment_id) attachment = await attachment_crud.get_attachment(db, attachment_id)
if not attachment or attachment.study_id != study_id or attachment.entity_id != entity_id or attachment.entity_type != entity_type: if not attachment or attachment.study_id != study_id or attachment.entity_id != entity_id or attachment.entity_type != entity_type:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="附件不存在") raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="附件不存在")
@@ -250,12 +221,12 @@ async def global_download_attachment(
if user.role != "ADMIN": if user.role != "ADMIN":
membership = await get_study_member(attachment.study_id, current_user=user, db=db) membership = await get_study_member(attachment.study_id, current_user=user, db=db)
if user.role != "ADMIN": if user.role != "ADMIN":
allowed = await role_has_project_permission( allowed = await role_has_api_permission(
db, db,
attachment.study_id, attachment.study_id,
membership.role_in_study if membership else None, membership.role_in_study if membership else None,
_permission_module_for_entity(attachment.entity_type), "attachments:read",
"read", check_prerequisites=False,
) )
if not allowed: if not allowed:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="权限不足") raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="权限不足")
@@ -284,12 +255,12 @@ async def global_preview_attachment(
await _ensure_study_exists(db, attachment.study_id) await _ensure_study_exists(db, attachment.study_id)
user, membership = await _authorize_global(request, db, attachment.study_id) user, membership = await _authorize_global(request, db, attachment.study_id)
if user.role != "ADMIN": if user.role != "ADMIN":
allowed = await role_has_project_permission( allowed = await role_has_api_permission(
db, db,
attachment.study_id, attachment.study_id,
membership.role_in_study if membership else None, membership.role_in_study if membership else None,
_permission_module_for_entity(attachment.entity_type), "attachments:read",
"read", check_prerequisites=False,
) )
if not allowed: if not allowed:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="权限不足") raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="权限不足")
@@ -318,12 +289,12 @@ async def global_delete_attachment(
await _ensure_study_exists(db, attachment.study_id) await _ensure_study_exists(db, attachment.study_id)
user, membership = await _authorize_global(request, db, attachment.study_id) user, membership = await _authorize_global(request, db, attachment.study_id)
if user.role != "ADMIN": if user.role != "ADMIN":
allowed = await role_has_project_permission( allowed = await role_has_api_permission(
db, db,
attachment.study_id, attachment.study_id,
membership.role_in_study if membership else None, membership.role_in_study if membership else None,
_permission_module_for_entity(attachment.entity_type), "attachments:delete",
"write", check_prerequisites=False,
) )
if not allowed: if not allowed:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="权限不足") raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="权限不足")
@@ -350,7 +321,10 @@ async def global_delete_attachment(
@router.delete( @router.delete(
"/{attachment_id}", "/{attachment_id}",
status_code=status.HTTP_204_NO_CONTENT, status_code=status.HTTP_204_NO_CONTENT,
dependencies=[Depends(require_study_not_locked())], dependencies=[
Depends(require_api_permission("attachments:delete")),
Depends(require_study_not_locked())
],
) )
async def delete_attachment( async def delete_attachment(
study_id: uuid.UUID, study_id: uuid.UUID,
@@ -361,7 +335,6 @@ async def delete_attachment(
current_user=Depends(get_current_user), current_user=Depends(get_current_user),
): ):
await _ensure_study_exists(db, study_id) await _ensure_study_exists(db, study_id)
await _ensure_attachment_permission(db, study_id, current_user, entity_type, "write")
attachment = await attachment_crud.get_attachment(db, attachment_id) attachment = await attachment_crud.get_attachment(db, attachment_id)
if ( if (
not attachment not attachment
+2 -2
View File
@@ -3,7 +3,7 @@ import uuid
from fastapi import APIRouter, Depends, HTTPException, status from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.core.deps import get_current_user, get_db_session, require_roles, require_study_member, require_study_permission from app.core.deps import get_current_user, get_db_session, require_roles, require_study_member, require_api_permission
from app.crud import audit as audit_crud from app.crud import audit as audit_crud
from app.schemas.audit import AuditEventCreate, AuditLogRead from app.schemas.audit import AuditEventCreate, AuditLogRead
@@ -13,7 +13,7 @@ router = APIRouter()
@router.get( @router.get(
"/", "/",
response_model=list[AuditLogRead], response_model=list[AuditLogRead],
dependencies=[Depends(require_study_permission("audit_export", "read"))], dependencies=[Depends(require_api_permission("audit_logs:read"))],
) )
async def list_audit_logs( async def list_audit_logs(
study_id: uuid.UUID, study_id: uuid.UUID,
+6 -7
View File
@@ -3,8 +3,8 @@ from fastapi import APIRouter, Depends
from sqlalchemy import func, select from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.core.deps import get_cra_site_scope, get_current_user, get_db_session, require_study_member from app.core.deps import get_cra_site_scope, get_current_user, get_db_session, require_study_member, require_api_permission
from app.core.project_permissions import role_has_project_permission from app.core.project_permissions import role_has_api_permission
from app.crud import member as member_crud from app.crud import member as member_crud
from app.models.milestone import Milestone from app.models.milestone import Milestone
from app.schemas.progress import StudyProgressRead from app.schemas.progress import StudyProgressRead
@@ -68,7 +68,10 @@ async def list_lost_visits(
return items return items
@router.get("/center-summary", response_model=list[CenterSummaryItem], dependencies=[Depends(require_study_member())]) @router.get("/center-summary", response_model=list[CenterSummaryItem], dependencies=[
Depends(require_study_member()),
Depends(require_api_permission("dashboard:read"))
])
async def get_center_summary( async def get_center_summary(
study_id: uuid.UUID, study_id: uuid.UUID,
db: AsyncSession = Depends(get_db_session), db: AsyncSession = Depends(get_db_session),
@@ -80,10 +83,6 @@ async def get_center_summary(
membership = await member_crud.get_member(db, study_id, current_user.id) membership = await member_crud.get_member(db, study_id, current_user.id)
if not membership or not membership.is_active: if not membership or not membership.is_active:
return [] return []
can_read_sites = await role_has_project_permission(db, study_id, membership.role_in_study, "sites", "read")
can_read_subjects = await role_has_project_permission(db, study_id, membership.role_in_study, "subjects", "read")
if not (can_read_sites or can_read_subjects):
return []
cra_scope = await get_cra_site_scope(db, study_id, current_user) cra_scope = await get_cra_site_scope(db, study_id, current_user)
scope_ids = cra_scope[0] if cra_scope else None scope_ids = cra_scope[0] if cra_scope else None
scope_id_strs = {str(cid) for cid in scope_ids} if scope_ids is not None else None scope_id_strs = {str(cid) for cid in scope_ids} if scope_ids is not None else None
+15 -22
View File
@@ -3,8 +3,8 @@ import uuid
from fastapi import APIRouter, Depends, HTTPException, status from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.core.deps import get_current_user, get_db_session, require_study_not_locked from app.core.deps import get_current_user, get_db_session, require_study_not_locked, require_api_permission
from app.core.project_permissions import role_has_project_permission from app.core.project_permissions import role_has_api_permission
from app.crud import audit as audit_crud from app.crud import audit as audit_crud
from app.crud import faq_category as category_crud from app.crud import faq_category as category_crud
from app.crud import faq_item as item_crud from app.crud import faq_item as item_crud
@@ -21,25 +21,16 @@ def _is_system_admin(current_user) -> bool:
return role == "ADMIN" return role == "ADMIN"
async def _require_category_permission(db: AsyncSession, study_id: uuid.UUID, current_user, action: str):
if _is_system_admin(current_user):
return None
member = await member_crud.get_member(db, study_id, current_user.id)
if not member or not member.is_active:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="不是该项目成员")
allowed = await role_has_project_permission(db, study_id, member.role_in_study, "faq", action)
if not allowed:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="权限不足")
return member
@router.post( @router.post(
"/", "/",
response_model=CategoryRead, response_model=CategoryRead,
status_code=status.HTTP_201_CREATED, status_code=status.HTTP_201_CREATED,
summary="创建 FAQ 分类", summary="创建 FAQ 分类",
description="创建项目内 FAQ 分类,权限由项目级权限矩阵控制。", description="创建项目内 FAQ 分类,权限由项目级权限矩阵控制。",
dependencies=[Depends(require_study_not_locked())], dependencies=[
Depends(require_api_permission("faq_category:create")),
Depends(require_study_not_locked())
],
) )
async def create_category( async def create_category(
payload: CategoryCreate, payload: CategoryCreate,
@@ -51,7 +42,6 @@ async def create_category(
existing = await category_crud.get_category_by_name(db, payload.study_id, payload.name) existing = await category_crud.get_category_by_name(db, payload.study_id, payload.name)
if existing: if existing:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="分类名称已存在") raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="分类名称已存在")
await _require_category_permission(db, payload.study_id, current_user, "write")
category = await category_crud.create_category(db, payload) category = await category_crud.create_category(db, payload)
await audit_crud.log_action( await audit_crud.log_action(
db, db,
@@ -71,6 +61,7 @@ async def create_category(
response_model=PaginatedResponse[CategoryRead], response_model=PaginatedResponse[CategoryRead],
summary="FAQ 分类列表", summary="FAQ 分类列表",
description="返回全局及项目内 FAQ 分类列表,可按 study_id 过滤。", description="返回全局及项目内 FAQ 分类列表,可按 study_id 过滤。",
dependencies=[Depends(require_api_permission("faq_category:read"))],
) )
async def list_categories( async def list_categories(
study_id: uuid.UUID | None = None, study_id: uuid.UUID | None = None,
@@ -80,8 +71,6 @@ async def list_categories(
) -> list[CategoryRead]: ) -> list[CategoryRead]:
if not study_id: if not study_id:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="必须提供项目 ID") raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="必须提供项目 ID")
if study_id:
await _require_category_permission(db, study_id, current_user, "read")
categories = await category_crud.list_categories(db, study_id, include_global=False, is_active=is_active) categories = await category_crud.list_categories(db, study_id, include_global=False, is_active=is_active)
return paginate([CategoryRead.model_validate(c) for c in categories], total=len(categories)) return paginate([CategoryRead.model_validate(c) for c in categories], total=len(categories))
@@ -91,7 +80,10 @@ async def list_categories(
response_model=CategoryRead, response_model=CategoryRead,
summary="更新 FAQ 分类", summary="更新 FAQ 分类",
description="更新分类名称或启停状态,权限由项目级权限矩阵控制。", description="更新分类名称或启停状态,权限由项目级权限矩阵控制。",
dependencies=[Depends(require_study_not_locked())], dependencies=[
Depends(require_api_permission("faq_category:update")),
Depends(require_study_not_locked())
],
) )
async def update_category( async def update_category(
category_id: uuid.UUID, category_id: uuid.UUID,
@@ -112,7 +104,6 @@ async def update_category(
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="分类名称已存在") raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="分类名称已存在")
if not target_study_id: if not target_study_id:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="必须提供项目 ID") raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="必须提供项目 ID")
await _require_category_permission(db, target_study_id, current_user, "write")
updated = await category_crud.update_category(db, category, payload) updated = await category_crud.update_category(db, category, payload)
await audit_crud.log_action( await audit_crud.log_action(
db, db,
@@ -132,7 +123,10 @@ async def update_category(
status_code=status.HTTP_204_NO_CONTENT, status_code=status.HTTP_204_NO_CONTENT,
summary="删除 FAQ 分类", summary="删除 FAQ 分类",
description="删除分类,权限由项目级权限矩阵控制;分类下存在 FAQ 时不可删除。", description="删除分类,权限由项目级权限矩阵控制;分类下存在 FAQ 时不可删除。",
dependencies=[Depends(require_study_not_locked())], dependencies=[
Depends(require_api_permission("faq_category:delete")),
Depends(require_study_not_locked())
],
) )
async def delete_category( async def delete_category(
category_id: uuid.UUID, category_id: uuid.UUID,
@@ -144,7 +138,6 @@ async def delete_category(
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="分类不存在") raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="分类不存在")
if not category.study_id: if not category.study_id:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="必须提供项目 ID") raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="必须提供项目 ID")
await _require_category_permission(db, category.study_id, current_user, "write")
item_count = await item_crud.count_items_by_category(db, category_id) item_count = await item_crud.count_items_by_category(db, category_id)
if item_count > 0: if item_count > 0:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="分类下存在 FAQ,无法删除") raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="分类下存在 FAQ,无法删除")
+54 -53
View File
@@ -3,8 +3,8 @@ import uuid
from fastapi import APIRouter, Depends, HTTPException, status from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.core.deps import get_current_user, get_db_session, require_study_not_locked from app.core.deps import get_current_user, get_db_session, require_study_not_locked, require_api_permission
from app.core.project_permissions import role_has_project_permission from app.core.project_permissions import role_has_api_permission
from app.crud import audit as audit_crud from app.crud import audit as audit_crud
from app.crud import faq_category as category_crud from app.crud import faq_category as category_crud
from app.crud import faq_item as faq_crud from app.crud import faq_item as faq_crud
@@ -31,32 +31,16 @@ def _is_system_admin(current_user) -> bool:
return role == "ADMIN" return role == "ADMIN"
async def _require_project_permission(
db: AsyncSession,
study_id: uuid.UUID | None,
current_user,
action: str,
):
if not study_id:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="FAQ 不存在")
if _is_system_admin(current_user):
return None
member = await member_crud.get_member(db, study_id, current_user.id)
if not member or not member.is_active:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="不是该项目成员")
allowed = await role_has_project_permission(db, study_id, member.role_in_study, "faq", action)
if not allowed:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="权限不足")
return member
@router.post( @router.post(
"/", "/",
response_model=FaqRead, response_model=FaqRead,
status_code=status.HTTP_201_CREATED, status_code=status.HTTP_201_CREATED,
summary="创建 FAQ", summary="创建 FAQ",
description="创建项目内 FAQ,权限由项目级权限矩阵控制。", description="创建项目内 FAQ,权限由项目级权限矩阵控制。",
dependencies=[Depends(require_study_not_locked())], dependencies=[
Depends(require_api_permission("faq:create")),
Depends(require_study_not_locked())
],
) )
async def create_faq( async def create_faq(
payload: FaqCreate, payload: FaqCreate,
@@ -70,7 +54,6 @@ async def create_faq(
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="分类不存在") raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="分类不存在")
if cat.study_id != payload.study_id: if cat.study_id != payload.study_id:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="分类范围不匹配") raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="分类范围不匹配")
await _require_project_permission(db, payload.study_id, current_user, "write")
try: try:
item = await faq_crud.create_item(db, payload, created_by=current_user.id) item = await faq_crud.create_item(db, payload, created_by=current_user.id)
except ValueError as exc: except ValueError as exc:
@@ -102,6 +85,7 @@ async def create_faq(
response_model=PaginatedResponse[FaqRead], response_model=PaginatedResponse[FaqRead],
summary="FAQ 列表", summary="FAQ 列表",
description="返回全局与项目 FAQ,可按关键词、分类过滤。", description="返回全局与项目 FAQ,可按关键词、分类过滤。",
dependencies=[Depends(require_api_permission("faq:read"))],
) )
async def list_faqs( async def list_faqs(
study_id: uuid.UUID | None = None, study_id: uuid.UUID | None = None,
@@ -116,14 +100,23 @@ async def list_faqs(
async def _get_member_role(sid: uuid.UUID) -> str | None: async def _get_member_role(sid: uuid.UUID) -> str | None:
if sid in membership_cache: if sid in membership_cache:
return membership_cache[sid] return membership_cache[sid]
member = await _require_project_permission(db, sid, current_user, "read") member = await member_crud.get_member(db, sid, current_user.id)
role = member.role_in_study if member else "ADMIN" role = member.role_in_study if member else None
membership_cache[sid] = role membership_cache[sid] = role
return role return role
if not study_id: if not study_id:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="必须提供项目 ID") raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="必须提供项目 ID")
await _require_project_permission(db, study_id, current_user, "write" if is_active is False else "read")
role_value = current_user.role.value if hasattr(current_user.role, "value") else current_user.role
if role_value != "ADMIN":
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="不是项目成员")
if is_active is False:
allowed = await role_has_api_permission(db, study_id, member.role_in_study, "faq:update", check_prerequisites=False)
if not allowed:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="权限不足")
items = await faq_crud.list_items( items = await faq_crud.list_items(
db, db,
@@ -141,7 +134,7 @@ async def list_faqs(
if not role: if not role:
continue continue
if not it.is_active and not _is_system_admin(current_user): if not it.is_active and not _is_system_admin(current_user):
allowed = await role_has_project_permission(db, it.study_id, role, "faq", "write") allowed = await role_has_api_permission(db, it.study_id, role, "faq:update", check_prerequisites=False)
if not allowed: if not allowed:
continue continue
visible.append(FaqRead.model_validate(it)) visible.append(FaqRead.model_validate(it))
@@ -153,6 +146,7 @@ async def list_faqs(
response_model=FaqRead, response_model=FaqRead,
summary="FAQ 详情", summary="FAQ 详情",
description="获取单条 FAQ,停用 FAQ 需项目级写权限。", description="获取单条 FAQ,停用 FAQ 需项目级写权限。",
dependencies=[Depends(require_api_permission("faq:read"))],
) )
async def get_faq( async def get_faq(
item_id: uuid.UUID, item_id: uuid.UUID,
@@ -164,7 +158,14 @@ async def get_faq(
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="FAQ 不存在") raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="FAQ 不存在")
if not item.study_id: if not item.study_id:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="FAQ 不存在") raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="FAQ 不存在")
await _require_project_permission(db, item.study_id, current_user, "write" if not item.is_active else "read") 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":
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="不是项目成员")
allowed = await role_has_api_permission(db, item.study_id, member.role_in_study, "faq:update", check_prerequisites=False)
if not allowed:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="权限不足")
return FaqRead.model_validate(item) return FaqRead.model_validate(item)
@@ -173,7 +174,10 @@ async def get_faq(
response_model=FaqRead, response_model=FaqRead,
summary="更新 FAQ", summary="更新 FAQ",
description="更新 FAQ 内容或启停状态,权限由项目级权限矩阵控制。", description="更新 FAQ 内容或启停状态,权限由项目级权限矩阵控制。",
dependencies=[Depends(require_study_not_locked())], dependencies=[
Depends(require_api_permission("faq:update")),
Depends(require_study_not_locked())
],
) )
async def update_faq( async def update_faq(
item_id: uuid.UUID, item_id: uuid.UUID,
@@ -184,10 +188,6 @@ async def update_faq(
item = await faq_crud.get_item(db, item_id) item = await faq_crud.get_item(db, item_id)
if not item: if not item:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="FAQ 不存在") raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="FAQ 不存在")
if item.created_by != current_user.id:
await _require_project_permission(db, item.study_id, current_user, "write")
else:
await _require_project_permission(db, item.study_id, current_user, "write")
old_active = item.is_active old_active = item.is_active
updated = await faq_crud.update_item(db, item, payload) updated = await faq_crud.update_item(db, item, payload)
action = "UPDATE_FAQ_ITEM" action = "UPDATE_FAQ_ITEM"
@@ -213,7 +213,10 @@ async def update_faq(
response_model=FaqRead, response_model=FaqRead,
summary="更新 FAQ 状态", summary="更新 FAQ 状态",
description="提问者或具备项目级写权限的成员可确认已解决。", description="提问者或具备项目级写权限的成员可确认已解决。",
dependencies=[Depends(require_study_not_locked())], dependencies=[
Depends(require_api_permission("faq:update")),
Depends(require_study_not_locked())
],
) )
async def update_faq_status( async def update_faq_status(
item_id: uuid.UUID, item_id: uuid.UUID,
@@ -226,10 +229,6 @@ async def update_faq_status(
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="FAQ 不存在") raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="FAQ 不存在")
if payload.status != "RESOLVED": if payload.status != "RESOLVED":
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="仅允许设置为已解决") raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="仅允许设置为已解决")
if item.created_by != current_user.id:
await _require_project_permission(db, item.study_id, current_user, "write")
else:
await _require_project_permission(db, item.study_id, current_user, "read")
await faq_crud.set_status(db, item.id, "RESOLVED", resolved_by_confirm=True) await faq_crud.set_status(db, item.id, "RESOLVED", resolved_by_confirm=True)
updated = await faq_crud.get_item(db, item_id) updated = await faq_crud.get_item(db, item_id)
return FaqRead.model_validate(updated) return FaqRead.model_validate(updated)
@@ -240,7 +239,10 @@ async def update_faq_status(
response_model=FaqRead, response_model=FaqRead,
summary="设置最佳回复", summary="设置最佳回复",
description="项目成员可设置最佳回复,全局仅 ADMIN。", description="项目成员可设置最佳回复,全局仅 ADMIN。",
dependencies=[Depends(require_study_not_locked())], dependencies=[
Depends(require_api_permission("faq:update")),
Depends(require_study_not_locked())
],
) )
async def set_best_reply( async def set_best_reply(
item_id: uuid.UUID, item_id: uuid.UUID,
@@ -253,7 +255,6 @@ async def set_best_reply(
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="FAQ 不存在") raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="FAQ 不存在")
if not item.study_id: if not item.study_id:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="FAQ 不存在") raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="FAQ 不存在")
await _require_project_permission(db, item.study_id, current_user, "write")
if payload.best_reply_id: if payload.best_reply_id:
reply = await reply_crud.get_reply(db, payload.best_reply_id) reply = await reply_crud.get_reply(db, payload.best_reply_id)
if not reply or reply.faq_id != item.id: if not reply or reply.faq_id != item.id:
@@ -281,6 +282,7 @@ async def set_best_reply(
response_model=PaginatedResponse[FaqReplyRead], response_model=PaginatedResponse[FaqReplyRead],
summary="FAQ 回复列表", summary="FAQ 回复列表",
description="获取 FAQ 的回复列表。", description="获取 FAQ 的回复列表。",
dependencies=[Depends(require_api_permission("faq:read"))],
) )
async def list_replies( async def list_replies(
item_id: uuid.UUID, item_id: uuid.UUID,
@@ -290,7 +292,6 @@ async def list_replies(
item = await faq_crud.get_item(db, item_id) item = await faq_crud.get_item(db, item_id)
if not item: if not item:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="FAQ 不存在") raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="FAQ 不存在")
await _require_project_permission(db, item.study_id, current_user, "read")
replies = await reply_crud.list_replies(db, item_id) replies = await reply_crud.list_replies(db, item_id)
reply_map = {r.id: r for r in replies} reply_map = {r.id: r for r in replies}
result: list[FaqReplyRead] = [] result: list[FaqReplyRead] = []
@@ -317,7 +318,10 @@ async def list_replies(
status_code=status.HTTP_201_CREATED, status_code=status.HTTP_201_CREATED,
summary="创建 FAQ 回复", summary="创建 FAQ 回复",
description="回复 FAQ,项目内成员可回复,全局仅 ADMIN。", description="回复 FAQ,项目内成员可回复,全局仅 ADMIN。",
dependencies=[Depends(require_study_not_locked())], dependencies=[
Depends(require_api_permission("faq_reply:create")),
Depends(require_study_not_locked())
],
) )
async def create_reply( async def create_reply(
item_id: uuid.UUID, item_id: uuid.UUID,
@@ -332,7 +336,6 @@ async def create_reply(
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="FAQ 不存在") raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="FAQ 不存在")
if not payload.content.strip(): if not payload.content.strip():
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="回复内容不能为空") raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="回复内容不能为空")
await _require_project_permission(db, item.study_id, current_user, "write")
quote = None quote = None
if payload.quote_reply_id: if payload.quote_reply_id:
quote = await reply_crud.get_reply(db, payload.quote_reply_id) quote = await reply_crud.get_reply(db, payload.quote_reply_id)
@@ -377,7 +380,10 @@ async def create_reply(
status_code=status.HTTP_204_NO_CONTENT, status_code=status.HTTP_204_NO_CONTENT,
summary="删除 FAQ", summary="删除 FAQ",
description="删除 FAQ,权限由项目级权限矩阵控制。", description="删除 FAQ,权限由项目级权限矩阵控制。",
dependencies=[Depends(require_study_not_locked())], dependencies=[
Depends(require_api_permission("faq:delete")),
Depends(require_study_not_locked())
],
) )
async def delete_faq( async def delete_faq(
item_id: uuid.UUID, item_id: uuid.UUID,
@@ -387,10 +393,6 @@ async def delete_faq(
item = await faq_crud.get_item(db, item_id) item = await faq_crud.get_item(db, item_id)
if not item: if not item:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="FAQ 不存在") raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="FAQ 不存在")
if item.created_by != current_user.id:
await _require_project_permission(db, item.study_id, current_user, "write")
else:
await _require_project_permission(db, item.study_id, current_user, "write")
await reply_crud.delete_replies_by_faq_id(db, item.id) await reply_crud.delete_replies_by_faq_id(db, item.id)
await db.delete(item) await db.delete(item)
await db.commit() await db.commit()
@@ -411,7 +413,10 @@ async def delete_faq(
status_code=status.HTTP_204_NO_CONTENT, status_code=status.HTTP_204_NO_CONTENT,
summary="删除 FAQ 回复", summary="删除 FAQ 回复",
description="删除 FAQ 回复,回复者或具备项目级写权限的成员可删除。", description="删除 FAQ 回复,回复者或具备项目级写权限的成员可删除。",
dependencies=[Depends(require_study_not_locked())], dependencies=[
Depends(require_api_permission("faq_reply:delete")),
Depends(require_study_not_locked())
],
) )
async def delete_reply( async def delete_reply(
item_id: uuid.UUID, item_id: uuid.UUID,
@@ -425,10 +430,6 @@ async def delete_reply(
reply = await reply_crud.get_reply(db, reply_id) reply = await reply_crud.get_reply(db, reply_id)
if not reply or reply.faq_id != item.id: if not reply or reply.faq_id != item.id:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="回复不存在") raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="回复不存在")
if reply.created_by != current_user.id:
await _require_project_permission(db, item.study_id, current_user, "write")
else:
await _require_project_permission(db, item.study_id, current_user, "read")
if item.best_reply_id == reply.id: if item.best_reply_id == reply.id:
await faq_crud.set_best_reply(db, item.id, None) await faq_crud.set_best_reply(db, item.id, None)
ref_count = await reply_crud.count_quote_references(db, reply.id) ref_count = await reply_crud.count_quote_references(db, reply.id)
+27 -23
View File
@@ -7,8 +7,8 @@ from fastapi import APIRouter, Depends, File, HTTPException, UploadFile, status,
from fastapi.responses import FileResponse from fastapi.responses import FileResponse
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.core.deps import get_current_user, get_db_session, require_study_not_locked from app.core.deps import get_current_user, get_db_session, require_study_not_locked, require_api_permission
from app.core.project_permissions import role_has_project_permission from app.core.project_permissions import role_has_api_permission
from app.core.security import decode_token from app.core.security import decode_token
from app.crud import audit as audit_crud from app.crud import audit as audit_crud
from app.crud import contract_fee as contract_fee_crud from app.crud import contract_fee as contract_fee_crud
@@ -48,20 +48,6 @@ async def _resolve_project_id(db: AsyncSession, entity_type: str, entity_id: uui
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="不支持的附件类型") raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="不支持的附件类型")
async def _ensure_project_access(db: AsyncSession, project_id: uuid.UUID, current_user, write: bool = False):
role_value = current_user.role.value if hasattr(current_user.role, "value") else current_user.role
if role_value == "ADMIN":
return None
membership = await member_crud.get_member(db, project_id, current_user.id)
if not membership or not membership.is_active:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="不是项目成员")
action = "write" if write else "read"
allowed = await role_has_project_permission(db, project_id, membership.role_in_study, "fees", action)
if not allowed:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="项目权限不足")
return membership
async def _authorize_download_user(request: Request, db: AsyncSession): async def _authorize_download_user(request: Request, db: AsyncSession):
token = None token = None
auth_header = request.headers.get("Authorization") auth_header = request.headers.get("Authorization")
@@ -82,7 +68,10 @@ async def _authorize_download_user(request: Request, db: AsyncSession):
"/attachments", "/attachments",
response_model=FeeApiResponse[FeeAttachmentRead], response_model=FeeApiResponse[FeeAttachmentRead],
status_code=status.HTTP_201_CREATED, status_code=status.HTTP_201_CREATED,
dependencies=[Depends(get_current_user), Depends(require_study_not_locked())], dependencies=[
Depends(require_api_permission("fees_attachments:create")),
Depends(require_study_not_locked())
],
) )
async def upload_fee_attachment( async def upload_fee_attachment(
entity_type: str = Form(...), entity_type: str = Form(...),
@@ -97,7 +86,6 @@ async def upload_fee_attachment(
if file_type not in ALLOWED_FILE_TYPES.get(entity_type, set()): if file_type not in ALLOWED_FILE_TYPES.get(entity_type, set()):
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="附件文件类型无效") raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="附件文件类型无效")
project_id = await _resolve_project_id(db, entity_type, entity_id) project_id = await _resolve_project_id(db, entity_type, entity_id)
await _ensure_project_access(db, project_id, current_user, write=True)
dest_dir = UPLOAD_ROOT / f"{entity_type}_{entity_id}" dest_dir = UPLOAD_ROOT / f"{entity_type}_{entity_id}"
dest_dir.mkdir(parents=True, exist_ok=True) dest_dir.mkdir(parents=True, exist_ok=True)
@@ -151,7 +139,7 @@ async def upload_fee_attachment(
@router.get( @router.get(
"/attachments", "/attachments",
response_model=FeeApiResponse[list[FeeAttachmentRead]], response_model=FeeApiResponse[list[FeeAttachmentRead]],
dependencies=[Depends(get_current_user)], dependencies=[Depends(require_api_permission("fees_attachments:read"))],
) )
async def list_fee_attachments( async def list_fee_attachments(
entity_type: str, entity_type: str,
@@ -162,7 +150,6 @@ async def list_fee_attachments(
if entity_type not in ALLOWED_ENTITY_TYPES: if entity_type not in ALLOWED_ENTITY_TYPES:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="附件类型无效") raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="附件类型无效")
project_id = await _resolve_project_id(db, entity_type, entity_id) project_id = await _resolve_project_id(db, entity_type, entity_id)
await _ensure_project_access(db, project_id, current_user, write=False)
attachments = await fee_attachment_crud.list_attachments(db, entity_type=entity_type, entity_id=entity_id) attachments = await fee_attachment_crud.list_attachments(db, entity_type=entity_type, entity_id=entity_id)
user_ids = {a.uploaded_by for a in attachments if a.uploaded_by} 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) users_map = await user_crud.get_users_by_ids(db, user_ids)
@@ -202,7 +189,16 @@ async def download_fee_attachment(
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="附件不存在") raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="附件不存在")
current_user = await _authorize_download_user(request, db) current_user = await _authorize_download_user(request, db)
project_id = await _resolve_project_id(db, attachment.entity_type, attachment.entity_id) project_id = await _resolve_project_id(db, attachment.entity_type, attachment.entity_id)
await _ensure_project_access(db, project_id, current_user, write=False) role_value = current_user.role.value if hasattr(current_user.role, "value") else current_user.role
if role_value != "ADMIN":
membership = await member_crud.get_member(db, project_id, current_user.id)
if not membership or not membership.is_active:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="不是项目成员")
allowed = await role_has_api_permission(
db, project_id, membership.role_in_study, "fees_attachments:read", check_prerequisites=False
)
if not allowed:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="项目权限不足")
if not attachment.storage_key or not os.path.exists(attachment.storage_key): if not attachment.storage_key or not os.path.exists(attachment.storage_key):
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="服务器未找到文件") raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="服务器未找到文件")
return FileResponse( return FileResponse(
@@ -216,7 +212,10 @@ async def download_fee_attachment(
@router.delete( @router.delete(
"/attachments/{attachment_id}", "/attachments/{attachment_id}",
status_code=status.HTTP_204_NO_CONTENT, status_code=status.HTTP_204_NO_CONTENT,
dependencies=[Depends(get_current_user), Depends(require_study_not_locked())], dependencies=[
Depends(require_api_permission("fees_attachments:delete")),
Depends(require_study_not_locked())
],
) )
async def delete_fee_attachment( async def delete_fee_attachment(
attachment_id: uuid.UUID, attachment_id: uuid.UUID,
@@ -227,9 +226,14 @@ async def delete_fee_attachment(
if not attachment: if not attachment:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="附件不存在") raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="附件不存在")
project_id = await _resolve_project_id(db, attachment.entity_type, attachment.entity_id) project_id = await _resolve_project_id(db, attachment.entity_type, attachment.entity_id)
membership = await _ensure_project_access(db, project_id, current_user, write=True)
role_value = current_user.role.value if hasattr(current_user.role, "value") else current_user.role role_value = current_user.role.value if hasattr(current_user.role, "value") else current_user.role
membership = None
if role_value != "ADMIN":
membership = await member_crud.get_member(db, project_id, current_user.id)
if not membership or not membership.is_active:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="不是项目成员")
can_delete = ( can_delete = (
role_value == "ADMIN" role_value == "ADMIN"
or attachment.uploaded_by == current_user.id or attachment.uploaded_by == current_user.id
+6 -6
View File
@@ -3,7 +3,7 @@ import uuid
from fastapi import APIRouter, Depends, HTTPException, status from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.core.deps import get_current_user, get_db_session, require_study_not_locked, require_study_permission from app.core.deps import 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 audit as audit_crud
from app.crud import knowledge_note as note_crud from app.crud import knowledge_note as note_crud
from app.crud import site as site_crud from app.crud import site as site_crud
@@ -32,7 +32,7 @@ async def _ensure_site_name_active(db: AsyncSession, study_id: uuid.UUID, site_n
"/notes", "/notes",
response_model=KnowledgeNoteRead, response_model=KnowledgeNoteRead,
status_code=status.HTTP_201_CREATED, status_code=status.HTTP_201_CREATED,
dependencies=[Depends(require_study_permission("shared_library", "write"))], dependencies=[Depends(require_api_permission("knowledge_notes:create"))],
) )
async def create_note( async def create_note(
study_id: uuid.UUID, study_id: uuid.UUID,
@@ -59,7 +59,7 @@ async def create_note(
@router.get( @router.get(
"/notes", "/notes",
response_model=list[KnowledgeNoteRead], response_model=list[KnowledgeNoteRead],
dependencies=[Depends(require_study_permission("shared_library", "read"))], dependencies=[Depends(require_api_permission("knowledge_notes:read"))],
) )
async def list_notes( async def list_notes(
study_id: uuid.UUID, study_id: uuid.UUID,
@@ -77,7 +77,7 @@ async def list_notes(
@router.get( @router.get(
"/notes/{note_id}", "/notes/{note_id}",
response_model=KnowledgeNoteRead, response_model=KnowledgeNoteRead,
dependencies=[Depends(require_study_permission("shared_library", "read"))], dependencies=[Depends(require_api_permission("knowledge_notes:read"))],
) )
async def get_note( async def get_note(
study_id: uuid.UUID, study_id: uuid.UUID,
@@ -94,7 +94,7 @@ async def get_note(
@router.patch( @router.patch(
"/notes/{note_id}", "/notes/{note_id}",
response_model=KnowledgeNoteRead, response_model=KnowledgeNoteRead,
dependencies=[Depends(require_study_permission("shared_library", "write"))], dependencies=[Depends(require_api_permission("knowledge_notes:update"))],
) )
async def update_note( async def update_note(
study_id: uuid.UUID, study_id: uuid.UUID,
@@ -125,7 +125,7 @@ async def update_note(
@router.delete( @router.delete(
"/notes/{note_id}", "/notes/{note_id}",
status_code=status.HTTP_204_NO_CONTENT, status_code=status.HTTP_204_NO_CONTENT,
dependencies=[Depends(require_study_permission("shared_library", "write"))], dependencies=[Depends(require_api_permission("knowledge_notes:delete"))],
) )
async def delete_note( async def delete_note(
study_id: uuid.UUID, study_id: uuid.UUID,
+6 -6
View File
@@ -3,7 +3,7 @@ import uuid
from fastapi import APIRouter, Depends, HTTPException, status from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.core.deps import get_current_user, get_db_session, require_study_not_locked, require_study_permission from app.core.deps import 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 audit as audit_crud
from app.crud import material_equipment as equipment_crud from app.crud import material_equipment as equipment_crud
from app.crud import study as study_crud from app.crud import study as study_crud
@@ -28,7 +28,7 @@ def _validate_calibration(need_calibration: bool, calibration_cycle_days: int |
"/equipment", "/equipment",
response_model=MaterialEquipmentRead, response_model=MaterialEquipmentRead,
status_code=status.HTTP_201_CREATED, status_code=status.HTTP_201_CREATED,
dependencies=[Depends(require_study_permission("materials", "write")), Depends(require_study_not_locked())], dependencies=[Depends(require_api_permission("material_equipments:create")), Depends(require_study_not_locked())],
) )
async def create_equipment( async def create_equipment(
study_id: uuid.UUID, study_id: uuid.UUID,
@@ -57,7 +57,7 @@ async def create_equipment(
@router.get( @router.get(
"/equipment", "/equipment",
response_model=list[MaterialEquipmentRead], response_model=list[MaterialEquipmentRead],
dependencies=[Depends(require_study_permission("materials", "read"))], dependencies=[Depends(require_api_permission("material_equipments:read"))],
) )
async def list_equipments( async def list_equipments(
study_id: uuid.UUID, study_id: uuid.UUID,
@@ -74,7 +74,7 @@ async def list_equipments(
@router.get( @router.get(
"/equipment/{equipment_id}", "/equipment/{equipment_id}",
response_model=MaterialEquipmentRead, response_model=MaterialEquipmentRead,
dependencies=[Depends(require_study_permission("materials", "read"))], dependencies=[Depends(require_api_permission("material_equipments:read"))],
) )
async def get_equipment( async def get_equipment(
study_id: uuid.UUID, study_id: uuid.UUID,
@@ -91,7 +91,7 @@ async def get_equipment(
@router.patch( @router.patch(
"/equipment/{equipment_id}", "/equipment/{equipment_id}",
response_model=MaterialEquipmentRead, response_model=MaterialEquipmentRead,
dependencies=[Depends(require_study_permission("materials", "write")), Depends(require_study_not_locked())], dependencies=[Depends(require_api_permission("material_equipments:update")), Depends(require_study_not_locked())],
) )
async def update_equipment( async def update_equipment(
study_id: uuid.UUID, study_id: uuid.UUID,
@@ -128,7 +128,7 @@ async def update_equipment(
@router.delete( @router.delete(
"/equipment/{equipment_id}", "/equipment/{equipment_id}",
status_code=status.HTTP_204_NO_CONTENT, status_code=status.HTTP_204_NO_CONTENT,
dependencies=[Depends(require_study_permission("materials", "write")), Depends(require_study_not_locked())], dependencies=[Depends(require_api_permission("material_equipments:delete")), Depends(require_study_not_locked())],
) )
async def delete_equipment( async def delete_equipment(
study_id: uuid.UUID, study_id: uuid.UUID,
+2 -2
View File
@@ -4,7 +4,7 @@ import uuid
from fastapi import APIRouter, Depends from fastapi import APIRouter, Depends
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.core.deps import get_db_session, require_study_permission from app.core.deps import get_db_session, require_api_permission
from app.crud import overview as overview_crud from app.crud import overview as overview_crud
from app.schemas.overview import ProjectOverviewResponse from app.schemas.overview import ProjectOverviewResponse
@@ -14,7 +14,7 @@ router = APIRouter()
@router.get( @router.get(
"/overview", "/overview",
response_model=ProjectOverviewResponse, response_model=ProjectOverviewResponse,
dependencies=[Depends(require_study_permission("project_overview", "read"))], dependencies=[Depends(require_api_permission("project_overview:read"))],
) )
async def get_project_overview( async def get_project_overview(
study_id: uuid.UUID, study_id: uuid.UUID,
+126
View File
@@ -0,0 +1,126 @@
"""权限模板管理API"""
from __future__ import annotations
import uuid
from typing import Annotated, Optional
from fastapi import APIRouter, Depends, HTTPException, status
from fastapi.responses import Response
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.deps import get_current_user, get_db_session, require_study_roles
from app.models.permission_template import TemplateType
from app.schemas.permission_template import (
ApplyTemplateRequest,
ApplyTemplateResponse,
PermissionTemplateCreate,
PermissionTemplateRead,
PermissionTemplateUpdate,
)
from app.services.permission_template_service import PermissionTemplateService
# 模板管理路由(不需要 study_id)
router = APIRouter(prefix="/permission-templates", tags=["permission-templates"])
# 项目级模板操作路由(需要 study_id,挂载到 /studies/{study_id}
study_router = APIRouter(prefix="/permission-templates", tags=["permission-templates"])
@router.get("", response_model=list[PermissionTemplateRead])
async def list_templates(
template_type: Optional[str] = None,
category: Optional[str] = None,
skip: int = 0,
limit: int = 100,
db: Annotated[AsyncSession, Depends(get_db_session)] = None,
) -> list[PermissionTemplateRead]:
"""列表权限模板"""
t_type = TemplateType(template_type) if template_type else None
templates = await PermissionTemplateService.list_templates(
db, template_type=t_type, category=category, skip=skip, limit=limit
)
return [PermissionTemplateRead.model_validate(t) for t in templates]
@router.post(
"",
response_model=PermissionTemplateRead,
status_code=status.HTTP_201_CREATED,
)
async def create_template(
payload: PermissionTemplateCreate,
db: Annotated[AsyncSession, Depends(get_db_session)] = None,
current_user=Depends(get_current_user),
) -> PermissionTemplateRead:
"""创建权限模板"""
try:
template = await PermissionTemplateService.create_template(db, payload, current_user.id)
return PermissionTemplateRead.model_validate(template)
except ValueError as e:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
@router.get("/{template_id}", response_model=PermissionTemplateRead)
async def get_template(
template_id: uuid.UUID,
db: Annotated[AsyncSession, Depends(get_db_session)] = None,
) -> PermissionTemplateRead:
"""获取权限模板"""
template = await PermissionTemplateService.get_template(db, template_id)
if not template:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="模板不存在")
return PermissionTemplateRead.model_validate(template)
@router.put("/{template_id}", response_model=PermissionTemplateRead)
async def update_template(
template_id: uuid.UUID,
payload: PermissionTemplateUpdate,
db: Annotated[AsyncSession, Depends(get_db_session)] = None,
current_user=Depends(get_current_user),
) -> PermissionTemplateRead:
"""更新权限模板"""
try:
template = await PermissionTemplateService.update_template(db, template_id, payload)
return PermissionTemplateRead.model_validate(template)
except ValueError as e:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
@router.delete("/{template_id}", status_code=status.HTTP_204_NO_CONTENT, response_class=Response)
async def delete_template(
template_id: uuid.UUID,
db: Annotated[AsyncSession, Depends(get_db_session)] = None,
current_user=Depends(get_current_user),
):
"""删除权限模板"""
try:
await PermissionTemplateService.delete_template(db, template_id)
except ValueError as e:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
@study_router.post(
"/{template_id}/apply",
response_model=ApplyTemplateResponse,
)
async def apply_template(
study_id: uuid.UUID,
template_id: uuid.UUID,
payload: ApplyTemplateRequest,
db: Annotated[AsyncSession, Depends(get_db_session)] = None,
_=Depends(require_study_roles(["PM"])),
) -> ApplyTemplateResponse:
"""应用权限模板到项目"""
try:
result = await PermissionTemplateService.apply_template(
db,
study_id,
payload.template_id,
roles=payload.roles,
override=payload.override,
)
return ApplyTemplateResponse(**result)
except ValueError as e:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
-66
View File
@@ -1,66 +0,0 @@
from __future__ import annotations
import json
import uuid
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.deps import get_current_user, get_db_session, require_study_member, require_study_permission
from app.core.project_permissions import PROJECT_PERMISSION_MODULES, get_project_role_permissions, replace_project_role_permissions
from app.crud import audit as audit_crud
from app.crud import study as study_crud
from app.schemas.project_permission import ProjectPermissionModule, ProjectRolePermissionsRead, ProjectRolePermissionsUpdate
router = APIRouter()
async def _ensure_study_exists(db: AsyncSession, study_id: uuid.UUID):
study = await study_crud.get(db, study_id)
if not study:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="项目不存在")
return study
@router.get("/", response_model=ProjectRolePermissionsRead, dependencies=[Depends(require_study_member())])
async def get_permissions(
study_id: uuid.UUID,
db: AsyncSession = Depends(get_db_session),
) -> ProjectRolePermissionsRead:
await _ensure_study_exists(db, study_id)
roles = await get_project_role_permissions(db, study_id)
modules = [ProjectPermissionModule(**item) for item in PROJECT_PERMISSION_MODULES]
return ProjectRolePermissionsRead(modules=modules, roles=roles)
@router.put("/", response_model=ProjectRolePermissionsRead, dependencies=[Depends(require_study_permission("project_members", "write"))])
async def update_permissions(
study_id: uuid.UUID,
payload: ProjectRolePermissionsUpdate,
db: AsyncSession = Depends(get_db_session),
current_user=Depends(get_current_user),
) -> ProjectRolePermissionsRead:
await _ensure_study_exists(db, study_id)
roles = await replace_project_role_permissions(
db,
study_id,
{
role: {
module: actions.model_dump()
for module, actions in modules.items()
}
for role, modules in payload.roles.items()
},
)
await audit_crud.log_action(
db,
study_id=study_id,
entity_type="project_permissions",
entity_id=study_id,
action="PROJECT_PERMISSIONS_UPDATED",
detail=json.dumps({"targetName": "项目权限", "after": roles}, ensure_ascii=False),
operator_id=current_user.id,
operator_role=current_user.role,
)
modules = [ProjectPermissionModule(**item) for item in PROJECT_PERMISSION_MODULES]
return ProjectRolePermissionsRead(modules=modules, roles=roles)
+4 -2
View File
@@ -1,6 +1,6 @@
from fastapi import APIRouter from fastapi import APIRouter
from app.api.v1 import auth, users, admin_users, studies, sites, members, attachments, audit_logs, dashboard, subjects, visits, aes, finance_dashboard, finance_contracts, fees_contracts, fees_attachments, drug_shipments, material_equipments, project_milestones, startup, knowledge_notes, subject_histories, subject_pds, study_subject_pds, faq_categories, faqs, documents, overview, notifications, monitoring_visit_issues, project_permissions, api_permissions, permission_monitoring from app.api.v1 import auth, users, admin_users, studies, sites, members, attachments, audit_logs, dashboard, subjects, visits, aes, finance_dashboard, finance_contracts, fees_contracts, fees_attachments, drug_shipments, material_equipments, project_milestones, startup, knowledge_notes, subject_histories, subject_pds, study_subject_pds, faq_categories, faqs, documents, overview, notifications, monitoring_visit_issues, api_permissions, permission_monitoring, permission_templates
api_router = APIRouter() api_router = APIRouter()
@@ -12,8 +12,8 @@ api_router.include_router(overview.router, prefix="/studies/{study_id}", tags=["
api_router.include_router(notifications.router, prefix="/studies/{study_id}", tags=["notifications"]) api_router.include_router(notifications.router, prefix="/studies/{study_id}", tags=["notifications"])
api_router.include_router(sites.router, prefix="/studies/{study_id}/sites", tags=["sites"]) api_router.include_router(sites.router, prefix="/studies/{study_id}/sites", tags=["sites"])
api_router.include_router(members.router, prefix="/studies/{study_id}/members", tags=["study-members"]) api_router.include_router(members.router, prefix="/studies/{study_id}/members", tags=["study-members"])
api_router.include_router(project_permissions.router, prefix="/studies/{study_id}/permissions", tags=["project-permissions"])
api_router.include_router(api_permissions.router, tags=["api-permissions"]) api_router.include_router(api_permissions.router, tags=["api-permissions"])
api_router.include_router(api_permissions.study_router, prefix="/studies/{study_id}", tags=["api-permissions"])
api_router.include_router(attachments.router, prefix="/studies/{study_id}/{entity_type}/{entity_id}/attachments", tags=["attachments"]) api_router.include_router(attachments.router, prefix="/studies/{study_id}/{entity_type}/{entity_id}/attachments", tags=["attachments"])
api_router.include_router(attachments.global_router, prefix="/attachments", tags=["attachments"]) api_router.include_router(attachments.global_router, prefix="/attachments", tags=["attachments"])
api_router.include_router(audit_logs.router, prefix="/studies/{study_id}/audit-logs", tags=["audit-logs"]) api_router.include_router(audit_logs.router, prefix="/studies/{study_id}/audit-logs", tags=["audit-logs"])
@@ -38,3 +38,5 @@ api_router.include_router(faq_categories.router, prefix="/faqs/categories", tags
api_router.include_router(faqs.router, prefix="/faqs/items", tags=["faqs"]) api_router.include_router(faqs.router, prefix="/faqs/items", tags=["faqs"])
api_router.include_router(documents.router, prefix="", tags=["documents"]) api_router.include_router(documents.router, prefix="", tags=["documents"])
api_router.include_router(permission_monitoring.router) api_router.include_router(permission_monitoring.router)
api_router.include_router(permission_templates.router)
api_router.include_router(permission_templates.study_router, prefix="/studies/{study_id}", tags=["permission-templates"])
+6 -6
View File
@@ -3,7 +3,7 @@ import uuid
from fastapi import APIRouter, Depends, HTTPException, status from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.core.deps import get_current_user, get_db_session, require_study_not_locked, require_study_permission from app.core.deps import 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 audit as audit_crud
from app.crud import site as site_crud from app.crud import site as site_crud
from app.crud import subject as subject_crud from app.crud import subject as subject_crud
@@ -33,7 +33,7 @@ async def _ensure_subject_active(db: AsyncSession, subject) -> None:
"/histories", "/histories",
response_model=SubjectHistoryRead, response_model=SubjectHistoryRead,
status_code=status.HTTP_201_CREATED, status_code=status.HTTP_201_CREATED,
dependencies=[Depends(require_study_permission("subjects", "write"))], dependencies=[Depends(require_api_permission("subject_histories:create"))],
) )
async def create_history( async def create_history(
study_id: uuid.UUID, study_id: uuid.UUID,
@@ -66,7 +66,7 @@ async def create_history(
@router.get( @router.get(
"/histories", "/histories",
response_model=list[SubjectHistoryRead], response_model=list[SubjectHistoryRead],
dependencies=[Depends(require_study_permission("subjects", "read"))], dependencies=[Depends(require_api_permission("subject_histories:read"))],
) )
async def list_histories( async def list_histories(
study_id: uuid.UUID, study_id: uuid.UUID,
@@ -86,7 +86,7 @@ async def list_histories(
@router.get( @router.get(
"/histories/{history_id}", "/histories/{history_id}",
response_model=SubjectHistoryRead, response_model=SubjectHistoryRead,
dependencies=[Depends(require_study_permission("subjects", "read"))], dependencies=[Depends(require_api_permission("subject_histories:read"))],
) )
async def get_history( async def get_history(
study_id: uuid.UUID, study_id: uuid.UUID,
@@ -107,7 +107,7 @@ async def get_history(
@router.patch( @router.patch(
"/histories/{history_id}", "/histories/{history_id}",
response_model=SubjectHistoryRead, response_model=SubjectHistoryRead,
dependencies=[Depends(require_study_permission("subjects", "write"))], dependencies=[Depends(require_api_permission("subject_histories:update"))],
) )
async def update_history( async def update_history(
study_id: uuid.UUID, study_id: uuid.UUID,
@@ -141,7 +141,7 @@ async def update_history(
@router.delete( @router.delete(
"/histories/{history_id}", "/histories/{history_id}",
status_code=status.HTTP_204_NO_CONTENT, status_code=status.HTTP_204_NO_CONTENT,
dependencies=[Depends(require_study_permission("subjects", "write"))], dependencies=[Depends(require_api_permission("subject_histories:delete"))],
) )
async def delete_history( async def delete_history(
study_id: uuid.UUID, study_id: uuid.UUID,
+5 -5
View File
@@ -7,7 +7,7 @@ from app.core.deps import (
get_current_user, get_current_user,
get_db_session, get_db_session,
require_study_not_locked, require_study_not_locked,
require_study_permission, require_api_permission,
) )
from app.crud import audit as audit_crud from app.crud import audit as audit_crud
from app.crud import site as site_crud from app.crud import site as site_crud
@@ -64,7 +64,7 @@ def _normalize_choice(value: str | None, allowed: set[str], field_name: str) ->
@router.get( @router.get(
"/pds", "/pds",
response_model=list[SubjectPdRead], response_model=list[SubjectPdRead],
dependencies=[Depends(require_study_permission("risk_issues", "read"))], dependencies=[Depends(require_api_permission("subject_pds:read"))],
) )
async def list_subject_pds( async def list_subject_pds(
study_id: uuid.UUID, study_id: uuid.UUID,
@@ -83,7 +83,7 @@ async def list_subject_pds(
"/pds", "/pds",
response_model=SubjectPdRead, response_model=SubjectPdRead,
status_code=status.HTTP_201_CREATED, status_code=status.HTTP_201_CREATED,
dependencies=[Depends(require_study_permission("risk_issues", "write")), Depends(require_study_not_locked())], dependencies=[Depends(require_api_permission("subject_pds:create")), Depends(require_study_not_locked())],
) )
async def create_subject_pd( async def create_subject_pd(
study_id: uuid.UUID, study_id: uuid.UUID,
@@ -128,7 +128,7 @@ async def create_subject_pd(
@router.patch( @router.patch(
"/pds/{pd_id}", "/pds/{pd_id}",
response_model=SubjectPdRead, response_model=SubjectPdRead,
dependencies=[Depends(require_study_permission("risk_issues", "write")), Depends(require_study_not_locked())], dependencies=[Depends(require_api_permission("subject_pds:update")), Depends(require_study_not_locked())],
) )
async def update_subject_pd( async def update_subject_pd(
study_id: uuid.UUID, study_id: uuid.UUID,
@@ -175,7 +175,7 @@ async def update_subject_pd(
@router.delete( @router.delete(
"/pds/{pd_id}", "/pds/{pd_id}",
status_code=status.HTTP_204_NO_CONTENT, status_code=status.HTTP_204_NO_CONTENT,
dependencies=[Depends(require_study_permission("risk_issues", "write")), Depends(require_study_not_locked())], dependencies=[Depends(require_api_permission("subject_pds:delete")), Depends(require_study_not_locked())],
) )
async def delete_subject_pd( async def delete_subject_pd(
study_id: uuid.UUID, study_id: uuid.UUID,
+244
View File
@@ -677,6 +677,213 @@ API_ENDPOINT_PERMISSIONS = {
"description": "更新项目里程碑", "description": "更新项目里程碑",
"default_roles": ["PM"], "default_roles": ["PM"],
}, },
# 附件管理
"attachments:create": {
"module": "attachments",
"action": "write",
"description": "创建附件",
"default_roles": ["PM", "CRA", "PV"],
},
"attachments:read": {
"module": "attachments",
"action": "read",
"description": "查询附件",
"default_roles": ["PM", "CRA", "PV", "MEDICAL_REVIEW", "IMP", "QA"],
},
"attachments:update": {
"module": "attachments",
"action": "write",
"description": "更新附件",
"default_roles": ["PM", "CRA"],
},
"attachments:delete": {
"module": "attachments",
"action": "write",
"description": "删除附件",
"default_roles": ["PM", "CRA"],
},
# 费用附件管理
"fees_attachments:create": {
"module": "fees",
"action": "write",
"description": "创建费用附件",
"default_roles": ["PM", "CRA"],
},
"fees_attachments:read": {
"module": "fees",
"action": "read",
"description": "查询费用附件",
"default_roles": ["PM", "CRA", "PV"],
},
"fees_attachments:delete": {
"module": "fees",
"action": "write",
"description": "删除费用附件",
"default_roles": ["PM", "CRA"],
},
# FAQ管理
"faq:create": {
"module": "faq",
"action": "write",
"description": "创建FAQ",
"default_roles": ["PM"],
},
"faq:read": {
"module": "faq",
"action": "read",
"description": "查询FAQ",
"default_roles": ["PM", "CRA", "PV", "MEDICAL_REVIEW", "IMP", "QA"],
},
"faq:update": {
"module": "faq",
"action": "write",
"description": "更新FAQ",
"default_roles": ["PM"],
},
"faq:delete": {
"module": "faq",
"action": "write",
"description": "删除FAQ",
"default_roles": ["PM"],
},
# FAQ分类管理
"faq_category:create": {
"module": "faq",
"action": "write",
"description": "创建FAQ分类",
"default_roles": ["PM"],
},
"faq_category:read": {
"module": "faq",
"action": "read",
"description": "查询FAQ分类",
"default_roles": ["PM", "CRA", "PV", "MEDICAL_REVIEW", "IMP", "QA"],
},
"faq_category:update": {
"module": "faq",
"action": "write",
"description": "更新FAQ分类",
"default_roles": ["PM"],
},
"faq_category:delete": {
"module": "faq",
"action": "write",
"description": "删除FAQ分类",
"default_roles": ["PM"],
},
# FAQ回复管理
"faq_reply:create": {
"module": "faq",
"action": "write",
"description": "创建FAQ回复",
"default_roles": ["PM", "CRA", "PV", "MEDICAL_REVIEW"],
},
"faq_reply:delete": {
"module": "faq",
"action": "write",
"description": "删除FAQ回复",
"default_roles": ["PM"],
},
# 仪表板管理
"dashboard:read": {
"module": "dashboard",
"action": "read",
"description": "查询仪表板",
"default_roles": ["PM", "CRA", "PV", "MEDICAL_REVIEW", "IMP", "QA"],
"prerequisite_permissions": ["sites:read", "subjects:read"],
},
# 参与者历史管理
"subject_histories:create": {
"module": "subject_histories",
"action": "write",
"description": "创建参与者历史",
"default_roles": ["PM", "CRA"],
"prerequisite_permissions": ["subjects:read"],
},
"subject_histories:list": {
"module": "subject_histories",
"action": "read",
"description": "查询参与者历史列表",
"default_roles": ["PM", "CRA", "PV", "MEDICAL_REVIEW", "IMP", "QA"],
"prerequisite_permissions": [],
},
"subject_histories:read": {
"module": "subject_histories",
"action": "read",
"description": "查询参与者历史详情",
"default_roles": ["PM", "CRA", "PV", "MEDICAL_REVIEW", "IMP", "QA"],
"prerequisite_permissions": [],
},
"subject_histories:update": {
"module": "subject_histories",
"action": "write",
"description": "更新参与者历史",
"default_roles": ["PM", "CRA"],
"prerequisite_permissions": ["subjects:read"],
},
"subject_histories:delete": {
"module": "subject_histories",
"action": "write",
"description": "删除参与者历史",
"default_roles": ["PM"],
"prerequisite_permissions": ["subjects:read"],
},
# 物资设备管理
"material_equipments:create": {
"module": "material_equipments",
"action": "write",
"description": "创建物资设备",
"default_roles": ["PM", "CRA"],
},
"material_equipments:list": {
"module": "material_equipments",
"action": "read",
"description": "查询物资设备列表",
"default_roles": ["PM", "CRA", "PV", "MEDICAL_REVIEW", "IMP", "QA"],
},
"material_equipments:read": {
"module": "material_equipments",
"action": "read",
"description": "查询物资设备详情",
"default_roles": ["PM", "CRA", "PV", "MEDICAL_REVIEW", "IMP", "QA"],
},
"material_equipments:update": {
"module": "material_equipments",
"action": "write",
"description": "更新物资设备",
"default_roles": ["PM", "CRA"],
},
"material_equipments:delete": {
"module": "material_equipments",
"action": "write",
"description": "删除物资设备",
"default_roles": ["PM"],
},
# 文档管理
"documents:create": {
"module": "documents",
"action": "write",
"description": "创建文档",
"default_roles": ["PM", "CRA"],
},
"documents:read": {
"module": "documents",
"action": "read",
"description": "查询文档",
"default_roles": ["PM", "CRA", "PV", "MEDICAL_REVIEW", "IMP", "QA"],
},
"documents:update": {
"module": "documents",
"action": "write",
"description": "更新文档",
"default_roles": ["PM", "CRA"],
},
"documents:delete": {
"module": "documents",
"action": "write",
"description": "删除文档",
"default_roles": ["PM"],
},
} }
# 向后兼容:业务操作名到接口级权限的映射 # 向后兼容:业务操作名到接口级权限的映射
@@ -842,6 +1049,38 @@ OPERATION_TO_ENDPOINTS: dict[str, dict[str, list[str]]] = {
"knowledge_notes:delete", "knowledge_notes:delete",
], ],
}, },
"subject_histories": {
"read": [
"subject_histories:list",
"subject_histories:read",
],
"write": [
"subject_histories:create",
"subject_histories:update",
"subject_histories:delete",
],
},
"material_equipments": {
"read": [
"material_equipments:list",
"material_equipments:read",
],
"write": [
"material_equipments:create",
"material_equipments:update",
"material_equipments:delete",
],
},
"documents": {
"read": [
"documents:read",
],
"write": [
"documents:create",
"documents:update",
"documents:delete",
],
},
"project_milestones": { "project_milestones": {
"read": [ "read": [
"milestones:list", "milestones:list",
@@ -892,6 +1131,11 @@ OPERATION_PREREQUISITES: dict[str, list[str]] = {
"subject_pds:create": ["subjects:read", "sites:read"], "subject_pds:create": ["subjects:read", "sites:read"],
"subject_pds:update": ["subjects:read", "sites:read"], "subject_pds:update": ["subjects:read", "sites:read"],
# 参与者历史
"subject_histories:create": ["subjects:read"],
"subject_histories:update": ["subjects:read"],
"subject_histories:delete": ["subjects:read"],
# 监查访视问题 # 监查访视问题
"monitoring_audit:create": ["sites:read"], "monitoring_audit:create": ["sites:read"],
"monitoring_audit:update": ["sites:read"], "monitoring_audit:update": ["sites:read"],
+1 -28
View File
@@ -10,7 +10,7 @@ from app.core.security import decode_token, oauth2_scheme
from app.crud import user as user_crud from app.crud import user as user_crud
from app.crud import member as member_crud from app.crud import member as member_crud
from app.crud import site as site_crud from app.crud import site as site_crud
from app.core.project_permissions import role_has_project_permission, role_has_api_permission, get_missing_prerequisites from app.core.project_permissions import role_has_api_permission, get_missing_prerequisites
from app.db.session import SessionLocal from app.db.session import SessionLocal
from app.schemas.user import TokenPayload from app.schemas.user import TokenPayload
@@ -119,33 +119,6 @@ def require_study_roles(roles: Iterable[str], *, allow_system_admin: bool = True
return dependency return dependency
def require_study_permission(module: str, action: str, *, allow_system_admin: bool = True):
async def dependency(
study_id: uuid.UUID,
current_user=Depends(get_current_user),
db: AsyncSession = Depends(get_db_session),
):
role_value = current_user.role.value if hasattr(current_user.role, "value") else current_user.role
if allow_system_admin and role_value == "ADMIN":
return current_user
membership = await member_crud.get_member(db, study_id, current_user.id)
if not membership or not membership.is_active:
raise AppException(
code="FORBIDDEN",
message="不是该项目成员",
status_code=status.HTTP_403_FORBIDDEN,
)
allowed = await role_has_project_permission(db, study_id, membership.role_in_study, module, action)
if not allowed:
raise AppException(
code="FORBIDDEN",
message="项目权限不足",
status_code=status.HTTP_403_FORBIDDEN,
)
return current_user
return dependency
def require_api_permission(endpoint_key: str, *, allow_system_admin: bool = True, check_prerequisites: bool = True): def require_api_permission(endpoint_key: str, *, allow_system_admin: bool = True, check_prerequisites: bool = True):
"""基于接口的权限检查(包含前置权限检查) """基于接口的权限检查(包含前置权限检查)
+8 -115
View File
@@ -1,8 +1,4 @@
"""权限缓存管理器 """权限缓存管理器"""
实现权限矩阵和成员身份的缓存,以提高权限检查性能。
采用内存缓存 + TTL 的方式,避免权限检查的 N+1 查询问题。
"""
from __future__ import annotations from __future__ import annotations
@@ -14,71 +10,22 @@ from sqlalchemy.ext.asyncio import AsyncSession
class PermissionCache: class PermissionCache:
"""权限缓存管理器 """权限缓存管理器"""
使用内存缓存存储权限矩阵和成员身份信息,减少数据库查询。
每个缓存项都有 TTL(生存时间),过期后自动失效。
"""
def __init__(self, default_ttl: int = 300): def __init__(self, default_ttl: int = 300):
"""初始化缓存管理器
Args:
default_ttl: 默认缓存生存时间(秒),默认5分钟
"""
self.default_ttl = default_ttl self.default_ttl = default_ttl
self._project_permissions_cache: dict[str, tuple[Any, float]] = {} self._project_permissions_cache: dict[str, tuple[Any, float]] = {}
self._member_role_cache: dict[str, tuple[str | None, float]] = {} self._member_role_cache: dict[str, tuple[str | None, float]] = {}
def _is_expired(self, timestamp: float, ttl: int) -> bool: def _is_expired(self, timestamp: float, ttl: int) -> bool:
"""检查缓存是否已过期"""
return time.time() - timestamp > ttl return time.time() - timestamp > ttl
def _make_project_cache_key(self, study_id: uuid.UUID) -> str: def _make_project_cache_key(self, study_id: uuid.UUID) -> str:
"""生成项目权限缓存键"""
return f"project_permissions:{study_id}" return f"project_permissions:{study_id}"
def _make_member_cache_key(self, study_id: uuid.UUID, user_id: uuid.UUID) -> str: def _make_member_cache_key(self, study_id: uuid.UUID, user_id: uuid.UUID) -> str:
"""生成成员角色缓存键"""
return f"member_role:{study_id}:{user_id}" return f"member_role:{study_id}:{user_id}"
async def get_project_role_permissions(
self,
db: AsyncSession,
study_id: uuid.UUID,
ttl: int | None = None,
) -> dict[str, dict[str, dict[str, bool]]]:
"""获取项目权限矩阵(带缓存)
Args:
db: 数据库会话
study_id: 项目ID
ttl: 缓存生存时间(秒),默认使用 default_ttl
Returns:
权限矩阵:{role: {module: {action: bool}}}
"""
from app.core.project_permissions import get_project_role_permissions as _get_project_role_permissions
if ttl is None:
ttl = self.default_ttl
cache_key = self._make_project_cache_key(study_id)
# 检查缓存
if cache_key in self._project_permissions_cache:
cached_data, timestamp = self._project_permissions_cache[cache_key]
if not self._is_expired(timestamp, ttl):
return cached_data
# 缓存未命中,从数据库查询
permissions = await _get_project_role_permissions(db, study_id)
# 存储到缓存
self._project_permissions_cache[cache_key] = (permissions, time.time())
return permissions
async def get_member_role( async def get_member_role(
self, self,
db: AsyncSession, db: AsyncSession,
@@ -86,72 +33,34 @@ class PermissionCache:
user_id: uuid.UUID, user_id: uuid.UUID,
ttl: int | None = None, ttl: int | None = None,
) -> str | None: ) -> str | None:
"""获取成员角色(带缓存) """获取成员角色(带缓存)"""
from app.crud import member as member_crud
Args:
db: 数据库会话
study_id: 项目ID
user_id: 用户ID
ttl: 缓存生存时间(秒),默认使用 default_ttl
Returns:
成员在项目中的角色,如果不是成员则返回 None
"""
from app.core.project_permissions import get_member_role as _get_member_role
if ttl is None: if ttl is None:
ttl = self.default_ttl ttl = self.default_ttl
cache_key = self._make_member_cache_key(study_id, user_id) cache_key = self._make_member_cache_key(study_id, user_id)
# 检查缓存
if cache_key in self._member_role_cache: if cache_key in self._member_role_cache:
cached_role, timestamp = self._member_role_cache[cache_key] cached_role, timestamp = self._member_role_cache[cache_key]
if not self._is_expired(timestamp, ttl): if not self._is_expired(timestamp, ttl):
return cached_role return cached_role
# 缓存未命中,从数据库查询 membership = await member_crud.get_member(db, study_id, user_id)
role = await _get_member_role(db, study_id, user_id) role = membership.role_in_study if membership and membership.is_active else None
# 存储到缓存
self._member_role_cache[cache_key] = (role, time.time()) self._member_role_cache[cache_key] = (role, time.time())
return role return role
def invalidate_project_permissions(self, study_id: uuid.UUID) -> None: def invalidate_project_permissions(self, study_id: uuid.UUID) -> None:
"""失效项目权限缓存
当项目权限被修改时调用此方法,清除相关的缓存。
Args:
study_id: 项目ID
"""
cache_key = self._make_project_cache_key(study_id) cache_key = self._make_project_cache_key(study_id)
if cache_key in self._project_permissions_cache: self._project_permissions_cache.pop(cache_key, None)
del self._project_permissions_cache[cache_key]
def invalidate_member_role(self, study_id: uuid.UUID, user_id: uuid.UUID) -> None: def invalidate_member_role(self, study_id: uuid.UUID, user_id: uuid.UUID) -> None:
"""失效成员角色缓存
当成员角色被修改时调用此方法,清除相关的缓存。
Args:
study_id: 项目ID
user_id: 用户ID
"""
cache_key = self._make_member_cache_key(study_id, user_id) cache_key = self._make_member_cache_key(study_id, user_id)
if cache_key in self._member_role_cache: self._member_role_cache.pop(cache_key, None)
del self._member_role_cache[cache_key]
def invalidate_all_member_roles(self, study_id: uuid.UUID) -> None: def invalidate_all_member_roles(self, study_id: uuid.UUID) -> None:
"""失效项目中所有成员的角色缓存
当项目权限矩阵被修改时调用此方法,清除项目中所有成员的缓存。
Args:
study_id: 项目ID
"""
# 清除所有包含该项目ID的成员角色缓存
keys_to_delete = [ keys_to_delete = [
key for key in self._member_role_cache.keys() key for key in self._member_role_cache.keys()
if key.startswith(f"member_role:{study_id}:") if key.startswith(f"member_role:{study_id}:")
@@ -160,23 +69,10 @@ class PermissionCache:
del self._member_role_cache[key] del self._member_role_cache[key]
def clear_all(self) -> None: def clear_all(self) -> None:
"""清除所有缓存
用于测试或系统重启时清除所有缓存。
"""
self._project_permissions_cache.clear() self._project_permissions_cache.clear()
self._member_role_cache.clear() self._member_role_cache.clear()
def get_cache_stats(self) -> dict[str, Any]: def get_cache_stats(self) -> dict[str, Any]:
"""获取缓存统计信息
Returns:
缓存统计信息:{
'project_permissions_count': 项目权限缓存数,
'member_role_count': 成员角色缓存数,
'total_count': 总缓存数,
}
"""
return { return {
"project_permissions_count": len(self._project_permissions_cache), "project_permissions_count": len(self._project_permissions_cache),
"member_role_count": len(self._member_role_cache), "member_role_count": len(self._member_role_cache),
@@ -184,12 +80,10 @@ class PermissionCache:
} }
# 全局缓存实例
_permission_cache: PermissionCache | None = None _permission_cache: PermissionCache | None = None
def get_permission_cache() -> PermissionCache: def get_permission_cache() -> PermissionCache:
"""获取全局权限缓存实例"""
global _permission_cache global _permission_cache
if _permission_cache is None: if _permission_cache is None:
_permission_cache = PermissionCache() _permission_cache = PermissionCache()
@@ -197,6 +91,5 @@ def get_permission_cache() -> PermissionCache:
def set_permission_cache(cache: PermissionCache) -> None: def set_permission_cache(cache: PermissionCache) -> None:
"""设置全局权限缓存实例(用于测试)"""
global _permission_cache global _permission_cache
_permission_cache = cache _permission_cache = cache
+9 -289
View File
@@ -1,265 +1,14 @@
from __future__ import annotations from __future__ import annotations
import json
import uuid import uuid
from sqlalchemy import delete, select from sqlalchemy import delete, select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.models.study_role_permission import StudyRolePermission
from app.models.api_endpoint_permission import ApiEndpointPermission from app.models.api_endpoint_permission import ApiEndpointPermission
from app.core.api_permissions import API_ENDPOINT_PERMISSIONS, OPERATION_TO_ENDPOINTS, OPERATION_PREREQUISITES from app.core.api_permissions import API_ENDPOINT_PERMISSIONS, OPERATION_PREREQUISITES
from app.core.permission_cache import get_permission_cache from app.core.permission_cache import get_permission_cache
PROJECT_PERMISSION_ROLES = ("ADMIN", "PM", "CRA", "PV", "MEDICAL_REVIEW", "IMP", "QA")
PROJECT_PERMISSION_MODULES = (
{"key": "project_members", "label": "项目成员", "description": "维护项目账号、成员角色与启停状态"},
{"key": "sites", "label": "中心管理", "description": "维护中心资料与 CRA 绑定"},
{"key": "audit_export", "label": "审计日志导出", "description": "导出项目审计日志"},
{"key": "project_overview", "label": "项目总览", "description": "查看项目整体进度与中心概览", "writable": False},
{"key": "project_milestones", "label": "项目里程碑", "description": "维护项目级里程碑"},
{"key": "fees", "label": "合同费用管理", "description": "维护合同、费用与付款"},
{"key": "materials", "label": "物资管理", "description": "维护药品出入库与物资设备"},
{"key": "file_versions", "label": "文件版本管理", "description": "维护文件版本、分发与确认"},
{"key": "startup_ethics", "label": "立项与伦理", "description": "维护立项、可行性与伦理资料"},
{"key": "startup_auth", "label": "启动与授权", "description": "维护启动会、培训与授权"},
{"key": "subjects", "label": "参与者管理", "description": "维护参与者、访视与 PD"},
{"key": "risk_issues", "label": "风险问题", "description": "维护 SAE、PD 与监查问题"},
{"key": "monitoring_audit", "label": "监查稽查", "description": "维护监查稽查记录"},
{"key": "etmf", "label": "eTMF", "description": "维护 eTMF 文件"},
{"key": "faq", "label": "FAQ", "description": "维护项目 FAQ 分类、问题与回复"},
{"key": "shared_library", "label": "共享库", "description": "维护注意事项、支持性文件与说明文件"},
)
MANAGEMENT_BACKEND_PERMISSION_MODULES = {"project_members", "sites", "audit_export"}
READ_ONLY_PERMISSION_MODULES = {
module["key"]
for module in PROJECT_PERMISSION_MODULES
if module.get("writable") is False
}
DEFAULT_PROJECT_ROLE_PERMISSIONS: dict[str, dict[str, dict[str, bool]]] = {
"ADMIN": {
module["key"]: {"read": True, "write": module["key"] not in READ_ONLY_PERMISSION_MODULES}
for module in PROJECT_PERMISSION_MODULES
},
"PM": {
"project_overview": {"read": True, "write": False},
"project_milestones": {"read": True, "write": True},
"fees": {"read": True, "write": True},
"materials": {"read": True, "write": True},
"file_versions": {"read": True, "write": True},
"startup_ethics": {"read": True, "write": True},
"startup_auth": {"read": True, "write": True},
"subjects": {"read": True, "write": True},
"risk_issues": {"read": True, "write": True},
"monitoring_audit": {"read": True, "write": True},
"etmf": {"read": True, "write": True},
"faq": {"read": True, "write": True},
"shared_library": {"read": True, "write": True},
"project_members": {"read": True, "write": True},
"sites": {"read": True, "write": True},
"audit_export": {"read": True, "write": False},
},
"CRA": {
"project_overview": {"read": True, "write": False},
"project_milestones": {"read": True, "write": True},
"fees": {"read": True, "write": True},
"materials": {"read": True, "write": True},
"file_versions": {"read": True, "write": True},
"startup_ethics": {"read": True, "write": True},
"startup_auth": {"read": True, "write": True},
"subjects": {"read": True, "write": True},
"risk_issues": {"read": True, "write": True},
"monitoring_audit": {"read": True, "write": True},
"etmf": {"read": True, "write": True},
"faq": {"read": True, "write": True},
"shared_library": {"read": True, "write": True},
"project_members": {"read": False, "write": False},
"sites": {"read": False, "write": False},
"audit_export": {"read": True, "write": False},
},
"PV": {
"project_overview": {"read": True, "write": False},
"project_milestones": {"read": True, "write": False},
"fees": {"read": True, "write": False},
"materials": {"read": True, "write": False},
"file_versions": {"read": True, "write": False},
"startup_ethics": {"read": True, "write": False},
"startup_auth": {"read": True, "write": False},
"subjects": {"read": True, "write": False},
"risk_issues": {"read": True, "write": True},
"monitoring_audit": {"read": True, "write": True},
"etmf": {"read": True, "write": False},
"faq": {"read": True, "write": True},
"shared_library": {"read": True, "write": True},
"project_members": {"read": False, "write": False},
"sites": {"read": False, "write": False},
"audit_export": {"read": False, "write": False},
},
"MEDICAL_REVIEW": {
"project_overview": {"read": True, "write": False},
"project_milestones": {"read": False, "write": False},
"fees": {"read": False, "write": False},
"materials": {"read": False, "write": False},
"file_versions": {"read": False, "write": False},
"startup_ethics": {"read": False, "write": False},
"startup_auth": {"read": False, "write": False},
"subjects": {"read": True, "write": False},
"risk_issues": {"read": True, "write": True},
"monitoring_audit": {"read": True, "write": False},
"etmf": {"read": False, "write": False},
"faq": {"read": True, "write": True},
"shared_library": {"read": True, "write": True},
"project_members": {"read": False, "write": False},
"sites": {"read": False, "write": False},
"audit_export": {"read": False, "write": False},
},
"IMP": {
"project_overview": {"read": True, "write": False},
"project_milestones": {"read": True, "write": False},
"fees": {"read": True, "write": False},
"materials": {"read": True, "write": True},
"file_versions": {"read": True, "write": True},
"startup_ethics": {"read": True, "write": False},
"startup_auth": {"read": True, "write": True},
"subjects": {"read": True, "write": False},
"risk_issues": {"read": True, "write": False},
"monitoring_audit": {"read": True, "write": False},
"etmf": {"read": True, "write": False},
"faq": {"read": True, "write": True},
"shared_library": {"read": True, "write": True},
"project_members": {"read": False, "write": False},
"sites": {"read": False, "write": False},
"audit_export": {"read": False, "write": False},
},
"QA": {
"project_overview": {"read": True, "write": False},
"project_milestones": {"read": True, "write": False},
"fees": {"read": True, "write": False},
"materials": {"read": True, "write": False},
"file_versions": {"read": True, "write": False},
"startup_ethics": {"read": True, "write": False},
"startup_auth": {"read": True, "write": False},
"subjects": {"read": True, "write": False},
"risk_issues": {"read": True, "write": False},
"monitoring_audit": {"read": True, "write": True},
"etmf": {"read": True, "write": False},
"faq": {"read": True, "write": False},
"shared_library": {"read": True, "write": True},
"project_members": {"read": False, "write": False},
"sites": {"read": False, "write": False},
"audit_export": {"read": False, "write": False},
},
}
def _empty_action_state() -> dict[str, bool]:
return {"read": False, "write": False}
def _normalize_action_state(value: dict | None, role: str) -> dict[str, bool]:
if role == "ADMIN":
return {"read": True, "write": True}
read = bool((value or {}).get("read", False))
write = bool((value or {}).get("write", False))
if write:
read = True
return {"read": read, "write": write}
def _normalize_module_action_state(value: dict | None, role: str, module: str) -> dict[str, bool]:
if role == "ADMIN":
return {"read": True, "write": module not in READ_ONLY_PERMISSION_MODULES}
if module in MANAGEMENT_BACKEND_PERMISSION_MODULES and role != "PM":
return _empty_action_state()
if module in READ_ONLY_PERMISSION_MODULES:
return {"read": bool((value or {}).get("read", False) or (value or {}).get("write", False)), "write": False}
return _normalize_action_state(value, role)
def normalize_permission_matrix(matrix: dict | None = None) -> dict[str, dict[str, dict[str, bool]]]:
normalized: dict[str, dict[str, dict[str, bool]]] = {}
matrix = matrix or {}
for role in PROJECT_PERMISSION_ROLES:
normalized[role] = {}
role_matrix = matrix.get(role) or DEFAULT_PROJECT_ROLE_PERMISSIONS.get(role, {})
for module in PROJECT_PERMISSION_MODULES:
module_key = module["key"]
normalized[role][module_key] = _normalize_module_action_state(
role_matrix.get(module_key, _empty_action_state()),
role,
module_key,
)
return normalized
async def get_project_role_permissions(db: AsyncSession, study_id: uuid.UUID) -> dict[str, dict[str, dict[str, bool]]]:
result = await db.execute(select(StudyRolePermission).where(StudyRolePermission.study_id == study_id))
rows = result.scalars().all()
if not rows:
return normalize_permission_matrix(DEFAULT_PROJECT_ROLE_PERMISSIONS)
matrix = normalize_permission_matrix(DEFAULT_PROJECT_ROLE_PERMISSIONS)
for row in rows:
if row.role not in PROJECT_PERMISSION_ROLES:
continue
if row.module not in matrix[row.role]:
continue
matrix[row.role][row.module] = _normalize_module_action_state(
{"read": row.can_read, "write": row.can_write},
row.role,
row.module,
)
return matrix
async def replace_project_role_permissions(
db: AsyncSession,
study_id: uuid.UUID,
payload: dict[str, dict[str, dict[str, bool]]],
) -> dict[str, dict[str, dict[str, bool]]]:
matrix = normalize_permission_matrix(payload)
await db.execute(delete(StudyRolePermission).where(StudyRolePermission.study_id == study_id))
for role, role_matrix in matrix.items():
if role == "ADMIN":
continue
for module, actions in role_matrix.items():
db.add(
StudyRolePermission(
study_id=study_id,
role=role,
module=module,
can_read=actions["read"],
can_write=actions["write"],
)
)
await db.commit()
# 失效缓存
cache = get_permission_cache()
cache.invalidate_project_permissions(study_id)
cache.invalidate_all_member_roles(study_id)
return matrix
async def role_has_project_permission(
db: AsyncSession,
study_id: uuid.UUID,
role: str | None,
module: str,
action: str,
) -> bool:
if role == "ADMIN":
return True
matrix = await get_project_role_permissions(db, study_id)
actions = matrix.get(role or "", {}).get(module)
if not actions:
return False
if action == "write":
return bool(actions["write"])
return bool(actions["read"])
async def role_has_api_permission( async def role_has_api_permission(
db: AsyncSession, db: AsyncSession,
@@ -268,17 +17,10 @@ async def role_has_api_permission(
endpoint_key: str, endpoint_key: str,
check_prerequisites: bool = True, check_prerequisites: bool = True,
) -> bool: ) -> bool:
"""检查角色是否有权访问特定接口 """检查角色是否有权访问特定接口"""
权限检查优先级:
1. 接口级权限(如果已配置)
2. 模块级权限(向后兼容)
3. 前置权限检查(如果启用)
"""
if role == "ADMIN": if role == "ADMIN":
return True return True
# 1. 先查询接口级权限
result = await db.execute( result = await db.execute(
select(ApiEndpointPermission).where( select(ApiEndpointPermission).where(
ApiEndpointPermission.study_id == study_id, ApiEndpointPermission.study_id == study_id,
@@ -287,22 +29,12 @@ async def role_has_api_permission(
) )
) )
perm = result.scalar_one_or_none() perm = result.scalar_one_or_none()
if perm is not None: if perm is None:
has_main_permission = perm.allowed return False
else:
# 2. 如果没有接口级权限,回退到模块级权限(向后兼容) if not perm.allowed:
endpoint_config = API_ENDPOINT_PERMISSIONS.get(endpoint_key)
if not endpoint_config:
return False
module = endpoint_config["module"]
action = endpoint_config["action"]
has_main_permission = await role_has_project_permission(db, study_id, role, module, action)
if not has_main_permission:
return False return False
# 3. 检查前置权限
if check_prerequisites: if check_prerequisites:
prerequisites = OPERATION_PREREQUISITES.get(endpoint_key, []) prerequisites = OPERATION_PREREQUISITES.get(endpoint_key, [])
for prereq_endpoint in prerequisites: for prereq_endpoint in prerequisites:
@@ -352,17 +84,14 @@ async def get_api_endpoint_permissions(
) )
rows = result.scalars().all() rows = result.scalars().all()
# 初始化矩阵,包含所有角色和端点的默认权限 roles = ["PM", "CRA", "PV", "MEDICAL_REVIEW", "IMP", "QA"]
matrix: dict[str, dict[str, dict[str, bool]]] = {} matrix: dict[str, dict[str, dict[str, bool]]] = {}
for role in PROJECT_PERMISSION_ROLES: for role in roles:
if role == "ADMIN":
continue
matrix[role] = {} matrix[role] = {}
for endpoint_key, config in API_ENDPOINT_PERMISSIONS.items(): for endpoint_key, config in API_ENDPOINT_PERMISSIONS.items():
default_allowed = role in config.get("default_roles", []) default_allowed = role in config.get("default_roles", [])
matrix[role][endpoint_key] = {"allowed": default_allowed} matrix[role][endpoint_key] = {"allowed": default_allowed}
# 覆盖自定义权限
for row in rows: for row in rows:
if row.role not in matrix: if row.role not in matrix:
matrix[row.role] = {} matrix[row.role] = {}
@@ -376,21 +105,13 @@ async def replace_api_endpoint_permissions(
study_id: uuid.UUID, study_id: uuid.UUID,
payload: dict[str, dict[str, bool]], payload: dict[str, dict[str, bool]],
) -> dict[str, dict[str, dict[str, bool]]]: ) -> dict[str, dict[str, dict[str, bool]]]:
"""替换项目的接口级权限矩阵 """替换项目的接口级权限矩阵"""
参数:
payload: {role: {endpoint_key: allowed}}
返回格式: {role: {endpoint_key: {allowed: bool}}}
"""
# 删除该项目的所有接口级权限
await db.execute( await db.execute(
delete(ApiEndpointPermission).where( delete(ApiEndpointPermission).where(
ApiEndpointPermission.study_id == study_id, ApiEndpointPermission.study_id == study_id,
) )
) )
# 插入新的权限配置
for role, endpoints in payload.items(): for role, endpoints in payload.items():
if role == "ADMIN": if role == "ADMIN":
continue continue
@@ -408,7 +129,6 @@ async def replace_api_endpoint_permissions(
await db.commit() await db.commit()
# 失效缓存
cache = get_permission_cache() cache = get_permission_cache()
cache.invalidate_project_permissions(study_id) cache.invalidate_project_permissions(study_id)
cache.invalidate_all_member_roles(study_id) cache.invalidate_all_member_roles(study_id)
-1
View File
@@ -37,4 +37,3 @@ from app.models.study_setup_config import StudySetupConfig # noqa: F401
from app.models.study_setup_config_version import StudySetupConfigVersion # noqa: F401 from app.models.study_setup_config_version import StudySetupConfigVersion # noqa: F401
from app.models.study_monitoring_strategy import StudyMonitoringStrategy # noqa: F401 from app.models.study_monitoring_strategy import StudyMonitoringStrategy # noqa: F401
from app.models.study_center_confirm import StudyCenterConfirm # noqa: F401 from app.models.study_center_confirm import StudyCenterConfirm # noqa: F401
from app.models.study_role_permission import StudyRolePermission # noqa: F401
+63
View File
@@ -0,0 +1,63 @@
from datetime import datetime
from enum import Enum
from typing import Optional
from uuid import UUID, uuid4
from sqlalchemy import JSON, Boolean, DateTime, Enum as SQLEnum, ForeignKey, Integer, String, UniqueConstraint, func
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.db.base_class import Base
class TemplateType(str, Enum):
ROLE = "ROLE"
SCENARIO = "SCENARIO"
CUSTOM = "CUSTOM"
class PermissionTemplate(Base):
__tablename__ = "permission_templates"
id: Mapped[UUID] = mapped_column(primary_key=True, default=uuid4)
name: Mapped[str] = mapped_column(String(100), nullable=False)
description: Mapped[Optional[str]] = mapped_column(String(500), nullable=True)
template_type: Mapped[TemplateType] = mapped_column(SQLEnum(TemplateType), nullable=False)
is_system: Mapped[bool] = mapped_column(Boolean, nullable=False, server_default="false")
created_by: Mapped[Optional[UUID]] = mapped_column(nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now())
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now(), onupdate=func.now())
# 权限配置 JSON: {role: {endpoint_key: allowed}}
permissions: Mapped[dict] = mapped_column(JSON, nullable=False)
# 元数据
tags: Mapped[Optional[str]] = mapped_column(String(200), nullable=True)
category: Mapped[Optional[str]] = mapped_column(String(50), nullable=True)
recommended_roles: Mapped[Optional[str]] = mapped_column(String(200), nullable=True)
# 关系
versions: Mapped[list["PermissionTemplateVersion"]] = relationship(
"PermissionTemplateVersion",
back_populates="template",
cascade="all, delete-orphan",
)
class PermissionTemplateVersion(Base):
__tablename__ = "permission_template_versions"
__table_args__ = (
UniqueConstraint("template_id", "version", name="uq_template_versions"),
)
id: Mapped[UUID] = mapped_column(primary_key=True, default=uuid4)
template_id: Mapped[UUID] = mapped_column(ForeignKey("permission_templates.id"), nullable=False)
version: Mapped[int] = mapped_column(Integer, nullable=False)
permissions: Mapped[dict] = mapped_column(JSON, nullable=False)
change_log: Mapped[Optional[str]] = mapped_column(String(500), nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now())
# 关系
template: Mapped["PermissionTemplate"] = relationship(
"PermissionTemplate",
back_populates="versions",
)
@@ -1,26 +0,0 @@
from __future__ import annotations
from typing import Optional
import uuid
from datetime import datetime
from sqlalchemy import Boolean, DateTime, ForeignKey, String, UniqueConstraint, func
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import Mapped, mapped_column
from app.db.base_class import Base
class StudyRolePermission(Base):
__tablename__ = "study_role_permissions"
__table_args__ = (
UniqueConstraint("study_id", "role", "module", name="uq_study_role_permissions_study_role_module"),
)
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
study_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("studies.id"), nullable=False, index=True)
role: Mapped[str] = mapped_column(String(20), nullable=False, index=True)
module: Mapped[str] = mapped_column(String(80), nullable=False)
can_read: Mapped[bool] = mapped_column(Boolean, nullable=False, server_default="false")
can_write: Mapped[bool] = mapped_column(Boolean, nullable=False, server_default="false")
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now(), onupdate=func.now())
@@ -0,0 +1,69 @@
from datetime import datetime
from enum import Enum
from typing import Optional
from uuid import UUID
from pydantic import BaseModel, Field
class TemplateType(str, Enum):
ROLE = "ROLE"
SCENARIO = "SCENARIO"
CUSTOM = "CUSTOM"
class PermissionTemplateVersionRead(BaseModel):
id: UUID
version: int
change_log: Optional[str] = None
created_at: datetime
class PermissionTemplateCreate(BaseModel):
name: str = Field(..., min_length=1, max_length=100)
description: Optional[str] = Field(None, max_length=500)
template_type: TemplateType
permissions: dict # {role: {endpoint_key: allowed}}
tags: Optional[str] = Field(None, max_length=200)
category: Optional[str] = Field(None, max_length=50)
recommended_roles: Optional[str] = Field(None, max_length=200)
class PermissionTemplateUpdate(BaseModel):
name: Optional[str] = Field(None, min_length=1, max_length=100)
description: Optional[str] = Field(None, max_length=500)
permissions: Optional[dict] = None
tags: Optional[str] = Field(None, max_length=200)
category: Optional[str] = Field(None, max_length=50)
recommended_roles: Optional[str] = Field(None, max_length=200)
class PermissionTemplateRead(BaseModel):
id: UUID
name: str
description: Optional[str] = None
template_type: TemplateType
is_system: bool
created_by: Optional[UUID] = None
created_at: datetime
updated_at: datetime
permissions: dict
tags: Optional[str] = None
category: Optional[str] = None
recommended_roles: Optional[str] = None
versions: list[PermissionTemplateVersionRead] = []
class Config:
from_attributes = True
class ApplyTemplateRequest(BaseModel):
template_id: UUID
roles: Optional[list[str]] = None
override: bool = True
class ApplyTemplateResponse(BaseModel):
study_id: UUID
applied_roles: list[str]
permissions: dict
+3 -4
View File
@@ -12,7 +12,7 @@ from sqlalchemy import delete as sa_delete, or_, select, update as sa_update
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.core.deps import get_cra_site_scope from app.core.deps import get_cra_site_scope
from app.core.project_permissions import role_has_project_permission from app.core.project_permissions import role_has_api_permission
from app.crud import acknowledgement as acknowledgement_crud from app.crud import acknowledgement as acknowledgement_crud
from app.crud import distribution as distribution_crud from app.crud import distribution as distribution_crud
from app.crud import document as document_crud from app.crud import document as document_crud
@@ -75,9 +75,8 @@ async def _ensure_study_access(db: AsyncSession, trial_id: uuid.UUID, current_us
membership = await member_crud.get_member(db, trial_id, current_user.id) membership = await member_crud.get_member(db, trial_id, current_user.id)
if not membership or not membership.is_active: if not membership or not membership.is_active:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="不是项目成员") raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="不是项目成员")
module = "file_versions" endpoint_key = "documents:read" if action in {"view", "ack"} else "documents:update"
permission_action = "read" if action in {"view", "ack"} else "write" allowed = await role_has_api_permission(db, trial_id, membership.role_in_study, endpoint_key, check_prerequisites=False)
allowed = await role_has_project_permission(db, trial_id, membership.role_in_study, module, permission_action)
if not allowed: if not allowed:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="权限不足") raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="权限不足")
return membership return membership
@@ -0,0 +1,237 @@
from typing import Optional
from uuid import UUID
from sqlalchemy import delete, select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from app.models.permission_template import PermissionTemplate, PermissionTemplateVersion, TemplateType
from app.models.api_endpoint_permission import ApiEndpointPermission
from app.core.api_permissions import API_ENDPOINT_PERMISSIONS
from app.schemas.permission_template import PermissionTemplateCreate, PermissionTemplateUpdate
class PermissionTemplateService:
"""权限模板服务"""
@staticmethod
async def create_template(
db: AsyncSession,
payload: PermissionTemplateCreate,
created_by: UUID,
) -> PermissionTemplate:
"""创建权限模板"""
# 验证权限配置
await PermissionTemplateService._validate_permissions(payload.permissions)
template = PermissionTemplate(
name=payload.name,
description=payload.description,
template_type=payload.template_type,
is_system=False,
created_by=created_by,
permissions=payload.permissions,
tags=payload.tags,
category=payload.category,
recommended_roles=payload.recommended_roles,
)
db.add(template)
await db.flush() # 获取 template.id
# 创建版本 1
version = PermissionTemplateVersion(
template_id=template.id,
version=1,
permissions=payload.permissions,
change_log="初始版本",
)
db.add(version)
await db.commit()
await db.refresh(template)
return template
@staticmethod
async def get_template(db: AsyncSession, template_id: UUID) -> Optional[PermissionTemplate]:
"""获取权限模板"""
result = await db.execute(
select(PermissionTemplate)
.options(selectinload(PermissionTemplate.versions))
.where(PermissionTemplate.id == template_id)
)
return result.scalar_one_or_none()
@staticmethod
async def list_templates(
db: AsyncSession,
template_type: Optional[TemplateType] = None,
category: Optional[str] = None,
skip: int = 0,
limit: int = 100,
) -> list[PermissionTemplate]:
"""列表权限模板"""
query = select(PermissionTemplate).options(selectinload(PermissionTemplate.versions))
if template_type:
query = query.where(PermissionTemplate.template_type == template_type)
if category:
query = query.where(PermissionTemplate.category == category)
query = query.offset(skip).limit(limit)
result = await db.execute(query)
return result.scalars().all()
@staticmethod
async def update_template(
db: AsyncSession,
template_id: UUID,
payload: PermissionTemplateUpdate,
) -> PermissionTemplate:
"""更新权限模板"""
template = await PermissionTemplateService.get_template(db, template_id)
if not template:
raise ValueError(f"模板 {template_id} 不存在")
# 不允许修改系统预设模板
if template.is_system:
raise ValueError("不允许修改系统预设模板")
# 验证权限配置
if payload.permissions:
await PermissionTemplateService._validate_permissions(payload.permissions)
# 更新字段
if payload.name:
template.name = payload.name
if payload.description is not None:
template.description = payload.description
if payload.permissions:
# 加载 versions 以获取最新版本号
await db.refresh(template, ["versions"])
latest_version = max([v.version for v in template.versions], default=0)
new_version = PermissionTemplateVersion(
template_id=template.id,
version=latest_version + 1,
permissions=payload.permissions,
change_log="",
)
db.add(new_version)
template.permissions = payload.permissions
if payload.tags is not None:
template.tags = payload.tags
if payload.category is not None:
template.category = payload.category
if payload.recommended_roles is not None:
template.recommended_roles = payload.recommended_roles
await db.commit()
await db.refresh(template)
return template
@staticmethod
async def delete_template(db: AsyncSession, template_id: UUID) -> None:
"""删除权限模板"""
template = await PermissionTemplateService.get_template(db, template_id)
if not template:
raise ValueError(f"模板 {template_id} 不存在")
# 不允许删除系统预设模板
if template.is_system:
raise ValueError("不允许删除系统预设模板")
await db.delete(template)
await db.commit()
@staticmethod
async def apply_template(
db: AsyncSession,
study_id: UUID,
template_id: UUID,
roles: Optional[list[str]] = None,
override: bool = True,
) -> dict:
"""应用权限模板到项目"""
template = await PermissionTemplateService.get_template(db, template_id)
if not template:
raise ValueError(f"模板 {template_id} 不存在")
# 确定要应用的角色
if roles is None:
if template.recommended_roles:
roles = template.recommended_roles.split(",")
else:
roles = list(template.permissions.keys())
# 删除现有权限(如果覆盖)
if override:
await db.execute(
delete(ApiEndpointPermission).where(
ApiEndpointPermission.study_id == study_id,
ApiEndpointPermission.role.in_(roles),
)
)
# 应用模板权限
for role in roles:
if role not in template.permissions:
continue
role_permissions = template.permissions[role]
for endpoint_key, allowed in role_permissions.items():
# 检查权限是否存在
if endpoint_key not in API_ENDPOINT_PERMISSIONS:
continue
# 检查是否已存在
result = await db.execute(
select(ApiEndpointPermission).where(
ApiEndpointPermission.study_id == study_id,
ApiEndpointPermission.role == role,
ApiEndpointPermission.endpoint_key == endpoint_key,
)
)
perm = result.scalar_one_or_none()
if perm:
perm.allowed = allowed
else:
perm = ApiEndpointPermission(
study_id=study_id,
role=role,
endpoint_key=endpoint_key,
allowed=allowed,
)
db.add(perm)
await db.commit()
# 返回应用后的权限矩阵
from app.core.project_permissions import get_api_endpoint_permissions
permissions = await get_api_endpoint_permissions(db, study_id)
return {
"study_id": study_id,
"applied_roles": roles,
"permissions": permissions,
}
@staticmethod
async def _validate_permissions(permissions: dict) -> None:
"""验证权限配置"""
for role, role_permissions in permissions.items():
if not isinstance(role_permissions, dict):
raise ValueError(f"角色 {role} 的权限配置格式错误")
for endpoint_key, allowed in role_permissions.items():
if endpoint_key.startswith("_"): # 跳过元数据字段
continue
if endpoint_key not in API_ENDPOINT_PERMISSIONS:
raise ValueError(f"权限操作 {endpoint_key} 不存在")
if not isinstance(allowed, bool):
raise ValueError(f"权限 {endpoint_key} 的值必须是布尔类型")
+1 -1
View File
@@ -73,7 +73,6 @@ async def _create_test_engine():
# Import all models to register them with Base metadata # Import all models to register them with Base metadata
from app.db.base_class import Base from app.db.base_class import Base
import app.models.study import app.models.study
import app.models.study_role_permission
import app.models.api_endpoint_permission import app.models.api_endpoint_permission
import app.models.api_endpoint_registry import app.models.api_endpoint_registry
import app.models.user import app.models.user
@@ -83,6 +82,7 @@ async def _create_test_engine():
import app.models.ae import app.models.ae
import app.models.monitoring_visit_issue import app.models.monitoring_visit_issue
import app.models.site import app.models.site
import app.models.permission_template
# Replace PostgreSQL UUID type with custom GUID type for SQLite # Replace PostgreSQL UUID type with custom GUID type for SQLite
for table in Base.metadata.tables.values(): for table in Base.metadata.tables.values():
-76
View File
@@ -6,7 +6,6 @@ from sqlalchemy.ext.asyncio import AsyncSession
from app.core.project_permissions import role_has_api_permission from app.core.project_permissions import role_has_api_permission
from app.models.api_endpoint_permission import ApiEndpointPermission from app.models.api_endpoint_permission import ApiEndpointPermission
from app.models.study_role_permission import StudyRolePermission
@pytest.mark.asyncio @pytest.mark.asyncio
@@ -53,50 +52,6 @@ async def test_api_permission_check_denied(db_session: AsyncSession):
assert result is False assert result is False
@pytest.mark.asyncio
async def test_api_permission_fallback_to_module_level(db_session: AsyncSession):
"""测试权限回退 - 接口级权限未配置时回退到模块级权限"""
study_id = uuid.uuid4()
# 创建模块级权限
module_perm = StudyRolePermission(
study_id=study_id,
role="CRA",
module="subjects",
can_write=True,
)
db_session.add(module_perm)
await db_session.commit()
# 不创建接口级权限,应该回退到模块级权限(禁用前置权限检查)
result = await role_has_api_permission(
db_session, study_id, "CRA", "subjects:create", check_prerequisites=False
)
assert result is True
@pytest.mark.asyncio
async def test_api_permission_fallback_denied(db_session: AsyncSession):
"""测试权限回退 - 模块级权限被拒绝"""
study_id = uuid.uuid4()
# 创建模块级权限(拒绝)
module_perm = StudyRolePermission(
study_id=study_id,
role="PV",
module="subjects",
can_write=False,
)
db_session.add(module_perm)
await db_session.commit()
# 不创建接口级权限,应该回退到模块级权限(禁用前置权限检查)
result = await role_has_api_permission(
db_session, study_id, "PV", "subjects:create", check_prerequisites=False
)
assert result is False
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_admin_always_allowed(db_session: AsyncSession): async def test_admin_always_allowed(db_session: AsyncSession):
"""测试ADMIN角色总是被允许""" """测试ADMIN角色总是被允许"""
@@ -108,37 +63,6 @@ async def test_admin_always_allowed(db_session: AsyncSession):
assert result is True assert result is True
@pytest.mark.asyncio
async def test_api_permission_priority_over_module(db_session: AsyncSession):
"""测试接口级权限优先于模块级权限"""
study_id = uuid.uuid4()
# 创建模块级权限(允许)
module_perm = StudyRolePermission(
study_id=study_id,
role="CRA",
module="subjects",
can_write=True,
)
db_session.add(module_perm)
# 创建接口级权限(拒绝)- 应该优先使用这个
api_perm = ApiEndpointPermission(
study_id=study_id,
role="CRA",
endpoint_key="subjects:create",
allowed=False,
)
db_session.add(api_perm)
await db_session.commit()
# 应该返回接口级权限的结果(False)(禁用前置权限检查)
result = await role_has_api_permission(
db_session, study_id, "CRA", "subjects:create", check_prerequisites=False
)
assert result is False
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_api_permission_read_endpoint(db_session: AsyncSession): async def test_api_permission_read_endpoint(db_session: AsyncSession):
"""测试读取端点权限""" """测试读取端点权限"""
@@ -1,177 +0,0 @@
"""单元测试:API权限配置"""
import pytest
from app.core.api_permissions import API_ENDPOINT_PERMISSIONS, MODULE_TO_ENDPOINTS
def test_api_endpoint_permissions_structure():
"""测试API端点权限配置结构"""
for endpoint_key, config in API_ENDPOINT_PERMISSIONS.items():
assert "module" in config, f"Missing 'module' in {endpoint_key}"
assert "action" in config, f"Missing 'action' in {endpoint_key}"
assert "description" in config, f"Missing 'description' in {endpoint_key}"
assert "default_roles" in config, f"Missing 'default_roles' in {endpoint_key}"
assert config["action"] in ["read", "write"], f"Invalid action in {endpoint_key}"
assert isinstance(config["default_roles"], list), f"default_roles must be list in {endpoint_key}"
assert len(config["default_roles"]) > 0, f"default_roles must not be empty in {endpoint_key}"
def test_module_to_endpoints_mapping():
"""测试模块到端点的映射"""
for module, actions in MODULE_TO_ENDPOINTS.items():
assert "read" in actions, f"Missing 'read' in {module}"
assert "write" in actions, f"Missing 'write' in {module}"
assert isinstance(actions["read"], list), f"read must be list in {module}"
assert isinstance(actions["write"], list), f"write must be list in {module}"
# 验证所有端点都在API_ENDPOINT_PERMISSIONS中定义
for endpoint_key in actions["read"] + actions["write"]:
assert endpoint_key in API_ENDPOINT_PERMISSIONS, f"Endpoint {endpoint_key} not in API_ENDPOINT_PERMISSIONS"
def test_subjects_endpoints_configured():
"""测试subjects模块的端点配置"""
expected_endpoints = [
"POST:/subjects",
"GET:/subjects",
"GET:/subjects/{id}",
"PATCH:/subjects/{id}",
"DELETE:/subjects/{id}",
"POST:/subjects/{subject_id}/visits",
"GET:/subjects/{subject_id}/visits",
"GET:/subjects/{subject_id}/visits/{visit_id}",
"PATCH:/subjects/{subject_id}/visits/{visit_id}",
]
for endpoint_key in expected_endpoints:
assert endpoint_key in API_ENDPOINT_PERMISSIONS, f"Missing endpoint {endpoint_key}"
assert API_ENDPOINT_PERMISSIONS[endpoint_key]["module"] == "subjects"
def test_risk_issues_endpoints_configured():
"""测试risk_issues模块的端点配置"""
expected_endpoints = [
"POST:/risk-issues",
"GET:/risk-issues",
"GET:/risk-issues/{id}",
"DELETE:/risk-issues/{id}",
]
for endpoint_key in expected_endpoints:
assert endpoint_key in API_ENDPOINT_PERMISSIONS, f"Missing endpoint {endpoint_key}"
assert API_ENDPOINT_PERMISSIONS[endpoint_key]["module"] == "risk_issues"
def test_fees_endpoints_configured():
"""测试fees模块的端点配置"""
expected_endpoints = [
"POST:/fees/contracts",
"GET:/fees/contracts",
"GET:/fees/contracts/{id}",
"PATCH:/fees/contracts/{id}",
"DELETE:/fees/contracts/{id}",
"POST:/fees/contracts/{id}/payments",
"PATCH:/fees/payments/{id}",
"DELETE:/fees/payments/{id}",
"POST:/finance/contracts",
"GET:/finance/contracts",
"GET:/finance/contracts/{id}",
"PATCH:/finance/contracts/{id}",
"DELETE:/finance/contracts/{id}",
]
for endpoint_key in expected_endpoints:
assert endpoint_key in API_ENDPOINT_PERMISSIONS, f"Missing endpoint {endpoint_key}"
assert API_ENDPOINT_PERMISSIONS[endpoint_key]["module"] == "fees"
def test_project_members_endpoints_configured():
"""测试project_members模块的端点配置"""
expected_endpoints = [
"POST:/studies/{study_id}/members",
"GET:/studies/{study_id}/members",
"GET:/studies/{study_id}/members/candidates",
"PATCH:/studies/{study_id}/members/{member_id}",
"DELETE:/studies/{study_id}/members/{member_id}",
]
for endpoint_key in expected_endpoints:
assert endpoint_key in API_ENDPOINT_PERMISSIONS, f"Missing endpoint {endpoint_key}"
assert API_ENDPOINT_PERMISSIONS[endpoint_key]["module"] == "project_members"
def test_sites_endpoints_configured():
"""测试sites模块的端点配置"""
expected_endpoints = [
"POST:/studies/{study_id}/sites",
"GET:/studies/{study_id}/sites",
"GET:/studies/{study_id}/sites/{site_id}",
"PATCH:/studies/{study_id}/sites/{site_id}",
"DELETE:/studies/{study_id}/sites/{site_id}",
]
for endpoint_key in expected_endpoints:
assert endpoint_key in API_ENDPOINT_PERMISSIONS, f"Missing endpoint {endpoint_key}"
assert API_ENDPOINT_PERMISSIONS[endpoint_key]["module"] == "sites"
def test_endpoint_key_format():
"""测试端点key格式"""
for endpoint_key in API_ENDPOINT_PERMISSIONS.keys():
# 格式应该是 "METHOD:/path"
assert ":" in endpoint_key, f"Invalid endpoint_key format: {endpoint_key}"
method, path = endpoint_key.split(":", 1)
assert method in ["GET", "POST", "PATCH", "DELETE", "PUT"], f"Invalid method in {endpoint_key}"
assert path.startswith("/"), f"Invalid path in {endpoint_key}"
def test_default_roles_valid():
"""测试默认角色有效"""
valid_roles = {"PM", "CRA", "PV", "MEDICAL_REVIEW", "IMP", "QA", "ADMIN"}
for endpoint_key, config in API_ENDPOINT_PERMISSIONS.items():
for role in config["default_roles"]:
assert role in valid_roles, f"Invalid role {role} in {endpoint_key}"
def test_module_to_endpoints_completeness():
"""测试MODULE_TO_ENDPOINTS包含所有模块"""
modules_in_config = set()
for config in API_ENDPOINT_PERMISSIONS.values():
modules_in_config.add(config["module"])
for module in modules_in_config:
assert module in MODULE_TO_ENDPOINTS, f"Module {module} not in MODULE_TO_ENDPOINTS"
def test_read_write_endpoints_consistency():
"""测试read/write端点的一致性"""
for module, actions in MODULE_TO_ENDPOINTS.items():
read_endpoints = set(actions["read"])
write_endpoints = set(actions["write"])
# read和write不应该有重叠
overlap = read_endpoints & write_endpoints
assert len(overlap) == 0, f"Overlap between read and write in {module}: {overlap}"
# 所有端点都应该在API_ENDPOINT_PERMISSIONS中
all_endpoints = read_endpoints | write_endpoints
for endpoint_key in all_endpoints:
assert endpoint_key in API_ENDPOINT_PERMISSIONS, f"Endpoint {endpoint_key} not in API_ENDPOINT_PERMISSIONS"
config = API_ENDPOINT_PERMISSIONS[endpoint_key]
assert config["module"] == module, f"Module mismatch for {endpoint_key}"
# 验证action与read/write分类一致
if endpoint_key in read_endpoints:
assert config["action"] == "read", f"Action mismatch for {endpoint_key}"
else:
assert config["action"] == "write", f"Action mismatch for {endpoint_key}"
def test_no_duplicate_endpoints():
"""测试没有重复的端点"""
endpoints = list(API_ENDPOINT_PERMISSIONS.keys())
assert len(endpoints) == len(set(endpoints)), "Duplicate endpoints found"
def test_endpoint_descriptions_not_empty():
"""测试所有端点都有描述"""
for endpoint_key, config in API_ENDPOINT_PERMISSIONS.items():
assert config["description"], f"Empty description for {endpoint_key}"
assert len(config["description"]) > 0, f"Empty description for {endpoint_key}"
@@ -7,7 +7,6 @@ from sqlalchemy.ext.asyncio import AsyncSession
from app.core.project_permissions import get_api_endpoint_permissions, replace_api_endpoint_permissions from app.core.project_permissions import get_api_endpoint_permissions, replace_api_endpoint_permissions
from app.models.api_endpoint_permission import ApiEndpointPermission from app.models.api_endpoint_permission import ApiEndpointPermission
from app.models.study_role_permission import StudyRolePermission
@pytest.mark.asyncio @pytest.mark.asyncio
-1
View File
@@ -7,7 +7,6 @@ from sqlalchemy.ext.asyncio import AsyncSession
from app.core.project_permissions import role_has_api_permission from app.core.project_permissions import role_has_api_permission
from app.models.api_endpoint_permission import ApiEndpointPermission from app.models.api_endpoint_permission import ApiEndpointPermission
from app.models.study_role_permission import StudyRolePermission
@pytest.mark.asyncio @pytest.mark.asyncio
@@ -9,7 +9,6 @@ from sqlalchemy.ext.asyncio import AsyncSession
from app.core.project_permissions import role_has_api_permission from app.core.project_permissions import role_has_api_permission
from app.models.api_endpoint_permission import ApiEndpointPermission from app.models.api_endpoint_permission import ApiEndpointPermission
from app.models.study_role_permission import StudyRolePermission
@pytest.mark.asyncio @pytest.mark.asyncio
@@ -271,98 +270,3 @@ async def test_sites_permission_denied_for_cra_write(db_session: AsyncSession):
allowed = await role_has_api_permission(db_session, study_id, "CRA", "POST:/studies/{study_id}/sites") allowed = await role_has_api_permission(db_session, study_id, "CRA", "POST:/studies/{study_id}/sites")
assert allowed is False assert allowed is False
@pytest.mark.asyncio
async def test_backward_compatibility_members_module_level(db_session: AsyncSession):
"""验证members模块的模块级权限回退仍然有效"""
study_id = uuid.uuid4()
# 设置模块级权限(不设置接口级权限)
db_session.add(StudyRolePermission(
study_id=study_id,
role="PM",
module="project_members",
can_read=True,
can_write=True,
))
await db_session.commit()
# 验证权限检查会回退到模块级权限
allowed = await role_has_api_permission(db_session, study_id, "PM", "POST:/studies/{study_id}/members")
assert allowed is True
@pytest.mark.asyncio
async def test_backward_compatibility_sites_module_level(db_session: AsyncSession):
"""验证sites模块的模块级权限回退仍然有效"""
study_id = uuid.uuid4()
# 设置模块级权限(不设置接口级权限)
db_session.add(StudyRolePermission(
study_id=study_id,
role="PM",
module="sites",
can_read=True,
can_write=True,
))
await db_session.commit()
# 验证权限检查会回退到模块级权限
allowed = await role_has_api_permission(db_session, study_id, "PM", "POST:/studies/{study_id}/sites")
assert allowed is True
@pytest.mark.asyncio
async def test_api_permission_priority_over_module_members(db_session: AsyncSession):
"""验证members模块的接口级权限优先于模块级权限"""
study_id = uuid.uuid4()
# 设置模块级权限为允许
db_session.add(StudyRolePermission(
study_id=study_id,
role="PM",
module="project_members",
can_read=True,
can_write=True,
))
# 设置接口级权限为拒绝(优先级更高)
db_session.add(ApiEndpointPermission(
study_id=study_id,
role="PM",
endpoint_key="POST:/studies/{study_id}/members",
allowed=False,
))
await db_session.commit()
# 验证接口级权限优先
allowed = await role_has_api_permission(db_session, study_id, "PM", "POST:/studies/{study_id}/members")
assert allowed is False
@pytest.mark.asyncio
async def test_api_permission_priority_over_module_sites(db_session: AsyncSession):
"""验证sites模块的接口级权限优先于模块级权限"""
study_id = uuid.uuid4()
# 设置模块级权限为允许
db_session.add(StudyRolePermission(
study_id=study_id,
role="PM",
module="sites",
can_read=True,
can_write=True,
))
# 设置接口级权限为拒绝(优先级更高)
db_session.add(ApiEndpointPermission(
study_id=study_id,
role="PM",
endpoint_key="POST:/studies/{study_id}/sites",
allowed=False,
))
await db_session.commit()
# 验证接口级权限优先
allowed = await role_has_api_permission(db_session, study_id, "PM", "POST:/studies/{study_id}/sites")
assert allowed is False
@@ -9,7 +9,6 @@ from sqlalchemy.ext.asyncio import AsyncSession
from app.core.project_permissions import role_has_api_permission from app.core.project_permissions import role_has_api_permission
from app.models.api_endpoint_permission import ApiEndpointPermission from app.models.api_endpoint_permission import ApiEndpointPermission
from app.models.study_role_permission import StudyRolePermission
# ============================================================================ # ============================================================================
@@ -538,135 +537,3 @@ async def test_project_permissions_denied_for_cra(db_session: AsyncSession):
allowed = await role_has_api_permission(db_session, study_id, "CRA", "PUT:/studies/{study_id}/permissions") allowed = await role_has_api_permission(db_session, study_id, "CRA", "PUT:/studies/{study_id}/permissions")
assert allowed is False assert allowed is False
# ============================================================================
# 向后兼容性测试
# ============================================================================
@pytest.mark.asyncio
async def test_backward_compatibility_startup_module_level(db_session: AsyncSession):
"""验证startup模块的模块级权限回退仍然有效"""
study_id = uuid.uuid4()
db_session.add(StudyRolePermission(
study_id=study_id,
role="PM",
module="startup",
can_read=True,
can_write=True,
))
await db_session.commit()
allowed = await role_has_api_permission(db_session, study_id, "PM", "POST:/studies/{study_id}/startup/ethics")
assert allowed is True
@pytest.mark.asyncio
async def test_backward_compatibility_drug_shipments_module_level(db_session: AsyncSession):
"""验证drug_shipments模块的模块级权限回退仍然有效"""
study_id = uuid.uuid4()
db_session.add(StudyRolePermission(
study_id=study_id,
role="IMP",
module="drug_shipments",
can_read=True,
can_write=True,
))
await db_session.commit()
allowed = await role_has_api_permission(db_session, study_id, "IMP", "POST:/studies/{study_id}/drug-shipments")
assert allowed is True
@pytest.mark.asyncio
async def test_api_permission_priority_over_module_startup(db_session: AsyncSession):
"""验证startup模块的接口级权限优先于模块级权限"""
study_id = uuid.uuid4()
db_session.add(StudyRolePermission(
study_id=study_id,
role="PM",
module="startup",
can_read=True,
can_write=True,
))
db_session.add(ApiEndpointPermission(
study_id=study_id,
role="PM",
endpoint_key="POST:/studies/{study_id}/startup/ethics",
allowed=False,
))
await db_session.commit()
allowed = await role_has_api_permission(db_session, study_id, "PM", "POST:/studies/{study_id}/startup/ethics")
assert allowed is False
@pytest.mark.asyncio
async def test_api_permission_priority_over_module_materials(db_session: AsyncSession):
"""验证material_equipments模块的接口级权限优先于模块级权限"""
study_id = uuid.uuid4()
db_session.add(StudyRolePermission(
study_id=study_id,
role="IMP",
module="material_equipments",
can_read=True,
can_write=True,
))
db_session.add(ApiEndpointPermission(
study_id=study_id,
role="IMP",
endpoint_key="POST:/studies/{study_id}/materials",
allowed=False,
))
await db_session.commit()
allowed = await role_has_api_permission(db_session, study_id, "IMP", "POST:/studies/{study_id}/materials")
assert allowed is False
# ============================================================================
# 权限矩阵一致性测试
# ============================================================================
@pytest.mark.asyncio
async def test_permission_matrix_consistency_batch3(db_session: AsyncSession):
"""验证第3批模块的权限矩阵一致性"""
study_id = uuid.uuid4()
# 为多个模块设置权限
perms = [
ApiEndpointPermission(study_id=study_id, role="PM", endpoint_key="POST:/studies/{study_id}/startup/ethics", allowed=True),
ApiEndpointPermission(study_id=study_id, role="PM", endpoint_key="GET:/studies/{study_id}/permissions", allowed=True),
ApiEndpointPermission(study_id=study_id, role="IMP", endpoint_key="POST:/studies/{study_id}/drug-shipments", allowed=True),
ApiEndpointPermission(study_id=study_id, role="CRA", endpoint_key="POST:/studies/{study_id}/subjects/{subject_id}/pds", allowed=True),
]
for perm in perms:
db_session.add(perm)
await db_session.commit()
# 验证所有权限都正确设置
assert await role_has_api_permission(db_session, study_id, "PM", "POST:/studies/{study_id}/startup/ethics") is True
assert await role_has_api_permission(db_session, study_id, "PM", "GET:/studies/{study_id}/permissions") is True
assert await role_has_api_permission(db_session, study_id, "IMP", "POST:/studies/{study_id}/drug-shipments") is True
assert await role_has_api_permission(db_session, study_id, "CRA", "POST:/studies/{study_id}/subjects/{subject_id}/pds") is True
@pytest.mark.asyncio
async def test_admin_always_allowed_batch3(db_session: AsyncSession):
"""验证ADMIN角色总是被允许访问第3批模块的所有端点"""
study_id = uuid.uuid4()
endpoints = [
"POST:/studies/{study_id}/startup/ethics",
"GET:/studies/{study_id}/permissions",
"POST:/studies/{study_id}/drug-shipments",
"POST:/studies/{study_id}/materials",
]
for endpoint in endpoints:
allowed = await role_has_api_permission(db_session, study_id, "ADMIN", endpoint)
assert allowed is True, f"ADMIN should be allowed for {endpoint}"
-246
View File
@@ -1,246 +0,0 @@
"""缓存测试:权限缓存机制验证
测试权限缓存的功能,包括:
- 缓存命中和未命中
- 缓存过期
- 缓存失效
- 并发缓存访问
- 缓存统计
"""
import asyncio
import time
import uuid
import pytest
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.permission_cache import PermissionCache, get_permission_cache, set_permission_cache
from app.core.project_permissions import get_project_role_permissions, get_member_role
@pytest.mark.asyncio
async def test_cache_hit():
"""测试缓存命中"""
cache = PermissionCache()
# 第一次调用:缓存未命中
cache._project_permissions_cache["test_key"] = ({"test": "data"}, time.time())
# 第二次调用:缓存命中
cached_data, timestamp = cache._project_permissions_cache.get("test_key")
assert cached_data == {"test": "data"}
assert not cache._is_expired(timestamp, 300)
@pytest.mark.asyncio
async def test_cache_miss():
"""测试缓存未命中"""
cache = PermissionCache()
# 缓存中不存在该键
assert "nonexistent_key" not in cache._project_permissions_cache
@pytest.mark.asyncio
async def test_cache_expiration():
"""测试缓存过期"""
cache = PermissionCache(default_ttl=1)
# 添加一个即将过期的缓存项
cache._project_permissions_cache["test_key"] = ({"test": "data"}, time.time() - 2)
# 验证缓存已过期
_, timestamp = cache._project_permissions_cache["test_key"]
assert cache._is_expired(timestamp, 1)
@pytest.mark.asyncio
async def test_cache_invalidation():
"""测试缓存失效"""
cache = PermissionCache()
study_id = uuid.uuid4()
# 添加缓存
cache._project_permissions_cache[cache._make_project_cache_key(study_id)] = (
{"test": "data"},
time.time(),
)
assert len(cache._project_permissions_cache) == 1
# 失效缓存
cache.invalidate_project_permissions(study_id)
assert len(cache._project_permissions_cache) == 0
@pytest.mark.asyncio
async def test_member_role_cache_invalidation():
"""测试成员角色缓存失效"""
cache = PermissionCache()
study_id = uuid.uuid4()
user_id = uuid.uuid4()
# 添加缓存
cache._member_role_cache[cache._make_member_cache_key(study_id, user_id)] = ("PM", time.time())
assert len(cache._member_role_cache) == 1
# 失效缓存
cache.invalidate_member_role(study_id, user_id)
assert len(cache._member_role_cache) == 0
@pytest.mark.asyncio
async def test_invalidate_all_member_roles():
"""测试失效项目中所有成员的角色缓存"""
cache = PermissionCache()
study_id = uuid.uuid4()
# 添加多个成员的缓存
for i in range(5):
user_id = uuid.uuid4()
cache._member_role_cache[cache._make_member_cache_key(study_id, user_id)] = ("PM", time.time())
assert len(cache._member_role_cache) == 5
# 失效项目中所有成员的缓存
cache.invalidate_all_member_roles(study_id)
assert len(cache._member_role_cache) == 0
@pytest.mark.asyncio
async def test_concurrent_cache_access():
"""测试并发缓存访问"""
cache = PermissionCache()
study_id = uuid.uuid4()
async def add_to_cache(i):
user_id = uuid.uuid4()
cache._member_role_cache[cache._make_member_cache_key(study_id, user_id)] = ("PM", time.time())
# 并发添加缓存
tasks = [add_to_cache(i) for i in range(10)]
await asyncio.gather(*tasks)
# 验证所有缓存都被添加
assert len(cache._member_role_cache) == 10
@pytest.mark.asyncio
async def test_cache_key_generation():
"""测试缓存键生成"""
cache = PermissionCache()
study_id = uuid.uuid4()
user_id = uuid.uuid4()
# 验证缓存键格式
project_key = cache._make_project_cache_key(study_id)
assert project_key.startswith("project_permissions:")
assert str(study_id) in project_key
member_key = cache._make_member_cache_key(study_id, user_id)
assert member_key.startswith("member_role:")
assert str(study_id) in member_key
assert str(user_id) in member_key
@pytest.mark.asyncio
async def test_cache_stats():
"""测试缓存统计"""
cache = PermissionCache()
study_id = uuid.uuid4()
# 添加缓存
cache._project_permissions_cache[cache._make_project_cache_key(study_id)] = (
{"test": "data"},
time.time(),
)
cache._member_role_cache[cache._make_member_cache_key(study_id, uuid.uuid4())] = ("PM", time.time())
# 获取统计信息
stats = cache.get_cache_stats()
assert stats["project_permissions_count"] == 1
assert stats["member_role_count"] == 1
assert stats["total_count"] == 2
@pytest.mark.asyncio
async def test_clear_all_cache():
"""测试清除所有缓存"""
cache = PermissionCache()
study_id = uuid.uuid4()
# 添加缓存
cache._project_permissions_cache[cache._make_project_cache_key(study_id)] = (
{"test": "data"},
time.time(),
)
cache._member_role_cache[cache._make_member_cache_key(study_id, uuid.uuid4())] = ("PM", time.time())
assert len(cache._project_permissions_cache) == 1
assert len(cache._member_role_cache) == 1
# 清除所有缓存
cache.clear_all()
assert len(cache._project_permissions_cache) == 0
assert len(cache._member_role_cache) == 0
@pytest.mark.asyncio
async def test_cache_with_different_ttl(db_session: AsyncSession, study_id: uuid.UUID):
"""测试不同 TTL 的缓存"""
cache = PermissionCache()
# 使用不同的 TTL 获取权限
permissions1 = await cache.get_project_role_permissions(db_session, study_id, ttl=1)
permissions2 = await cache.get_project_role_permissions(db_session, study_id, ttl=300)
# 两次调用都应该返回相同的数据
assert permissions1 == permissions2
@pytest.mark.asyncio
async def test_global_cache_instance():
"""测试全局缓存实例"""
cache1 = get_permission_cache()
cache2 = get_permission_cache()
# 应该是同一个实例
assert cache1 is cache2
@pytest.mark.asyncio
async def test_set_global_cache_instance():
"""测试设置全局缓存实例"""
new_cache = PermissionCache()
set_permission_cache(new_cache)
cache = get_permission_cache()
assert cache is new_cache
@pytest.mark.asyncio
async def test_cache_performance_improvement(db_session: AsyncSession, study_id: uuid.UUID):
"""测试缓存的性能改进
验证缓存确实提高了性能。
"""
cache = PermissionCache()
set_permission_cache(cache)
cache.clear_all()
# 第一次调用:从数据库查询
start_time = time.time()
permissions1 = await cache.get_project_role_permissions(db_session, study_id)
first_call_time = time.time() - start_time
# 第二次调用:从缓存获取
start_time = time.time()
permissions2 = await cache.get_project_role_permissions(db_session, study_id)
second_call_time = time.time() - start_time
# 验证结果相同
assert permissions1 == permissions2
# 缓存调用应该快得多
print(f"\n缓存性能改进:第一次 {first_call_time*1000:.2f}ms,第二次 {second_call_time*1000:.2f}ms")
assert second_call_time < first_call_time / 2, "缓存性能改进不足"
@@ -1,228 +0,0 @@
"""性能测试:权限系统缓存效率
测试权限检查的性能,对比有缓存和无缓存的性能差异。
"""
import asyncio
import time
import uuid
from typing import Callable
import pytest
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.permission_cache import PermissionCache, get_permission_cache, set_permission_cache
from app.core.project_permissions import (
get_project_role_permissions,
role_has_project_permission,
role_has_api_permission,
)
@pytest.mark.asyncio
async def test_permission_check_performance_baseline(db_session: AsyncSession, study_id: uuid.UUID):
"""基准测试:权限检查性能(无缓存)"""
# 清除缓存
cache = get_permission_cache()
cache.clear_all()
# 执行权限检查 100 次
start_time = time.time()
for _ in range(100):
await role_has_project_permission(db_session, study_id, "PM", "subjects", "read")
elapsed_time = time.time() - start_time
# 平均每次权限检查的时间
avg_time = elapsed_time / 100
print(f"\n基准测试(无缓存):{elapsed_time:.3f}s,平均 {avg_time*1000:.2f}ms/次")
# 基准测试应该在合理的时间范围内
assert elapsed_time < 10, "权限检查性能过低"
@pytest.mark.asyncio
async def test_permission_check_performance_with_cache(db_session: AsyncSession, study_id: uuid.UUID):
"""性能测试:权限检查性能(有缓存)"""
# 清除缓存
cache = get_permission_cache()
cache.clear_all()
# 第一次调用会触发缓存填充
await role_has_project_permission(db_session, study_id, "PM", "subjects", "read")
# 执行权限检查 100 次(应该都命中缓存)
start_time = time.time()
for _ in range(100):
await role_has_project_permission(db_session, study_id, "PM", "subjects", "read")
elapsed_time = time.time() - start_time
# 平均每次权限检查的时间
avg_time = elapsed_time / 100
print(f"\n缓存测试(有缓存):{elapsed_time:.3f}s,平均 {avg_time*1000:.2f}ms/次")
# 缓存命中应该非常快
assert elapsed_time < 1, "缓存性能不足"
@pytest.mark.asyncio
async def test_cache_hit_rate(db_session: AsyncSession, study_id: uuid.UUID):
"""缓存效率测试:缓存命中率"""
cache = get_permission_cache()
cache.clear_all()
# 执行权限检查,模拟实际场景
# 第一次调用:缓存未命中
await role_has_project_permission(db_session, study_id, "PM", "subjects", "read")
# 后续调用:缓存命中
for _ in range(99):
await role_has_project_permission(db_session, study_id, "PM", "subjects", "read")
# 缓存统计
stats = cache.get_cache_stats()
print(f"\n缓存统计:{stats}")
# 验证缓存已被使用
assert stats["project_permissions_count"] > 0, "缓存未被使用"
@pytest.mark.asyncio
async def test_cache_invalidation(db_session: AsyncSession, study_id: uuid.UUID):
"""缓存失效测试"""
cache = get_permission_cache()
cache.clear_all()
# 填充缓存
await role_has_project_permission(db_session, study_id, "PM", "subjects", "read")
assert cache.get_cache_stats()["project_permissions_count"] == 1
# 失效缓存
cache.invalidate_project_permissions(study_id)
assert cache.get_cache_stats()["project_permissions_count"] == 0
@pytest.mark.asyncio
async def test_concurrent_permission_checks(db_session: AsyncSession, study_id: uuid.UUID):
"""并发权限检查测试"""
cache = get_permission_cache()
cache.clear_all()
# 并发执行权限检查
async def check_permission():
return await role_has_project_permission(db_session, study_id, "PM", "subjects", "read")
start_time = time.time()
tasks = [check_permission() for _ in range(100)]
results = await asyncio.gather(*tasks)
elapsed_time = time.time() - start_time
# 所有检查都应该返回相同的结果
assert all(results), "并发权限检查失败"
print(f"\n并发测试(100个并发请求):{elapsed_time:.3f}s")
@pytest.mark.asyncio
async def test_cache_expiration(db_session: AsyncSession, study_id: uuid.UUID):
"""缓存过期测试"""
# 创建一个 TTL 很短的缓存
cache = PermissionCache(default_ttl=1)
set_permission_cache(cache)
# 填充缓存
permissions = await cache.get_project_role_permissions(db_session, study_id, ttl=1)
assert permissions is not None
# 等待缓存过期
await asyncio.sleep(1.1)
# 再次获取,应该重新从数据库查询
permissions2 = await cache.get_project_role_permissions(db_session, study_id, ttl=1)
assert permissions2 is not None
@pytest.mark.asyncio
async def test_list_operation_performance(db_session: AsyncSession, study_id: uuid.UUID):
"""列表操作性能测试
模拟列表操作中的权限检查,每个项目都需要进行权限检查。
"""
cache = get_permission_cache()
cache.clear_all()
# 模拟列表操作:检查 50 个项目的权限
start_time = time.time()
for i in range(50):
# 每个项目都需要检查权限
await role_has_project_permission(db_session, study_id, "PM", "subjects", "read")
elapsed_time = time.time() - start_time
print(f"\n列表操作性能(50个项目):{elapsed_time:.3f}s,平均 {elapsed_time/50*1000:.2f}ms/项")
# 列表操作应该在合理的时间范围内
assert elapsed_time < 5, "列表操作性能过低"
@pytest.mark.asyncio
async def test_api_permission_check_performance(db_session: AsyncSession, study_id: uuid.UUID):
"""API权限检查性能测试"""
cache = get_permission_cache()
cache.clear_all()
# 执行 API 权限检查 100 次
start_time = time.time()
for _ in range(100):
await role_has_api_permission(db_session, study_id, "PM", "POST:/subjects")
elapsed_time = time.time() - start_time
avg_time = elapsed_time / 100
print(f"\nAPI权限检查性能:{elapsed_time:.3f}s,平均 {avg_time*1000:.2f}ms/次")
assert elapsed_time < 10, "API权限检查性能过低"
@pytest.mark.asyncio
async def test_permission_matrix_caching(db_session: AsyncSession, study_id: uuid.UUID):
"""权限矩阵缓存测试"""
cache = get_permission_cache()
cache.clear_all()
# 第一次调用:从数据库查询
start_time = time.time()
permissions1 = await cache.get_project_role_permissions(db_session, study_id)
first_call_time = time.time() - start_time
# 第二次调用:从缓存获取
start_time = time.time()
permissions2 = await cache.get_project_role_permissions(db_session, study_id)
second_call_time = time.time() - start_time
# 验证结果相同
assert permissions1 == permissions2
# 缓存调用应该快得多
print(f"\n权限矩阵缓存:第一次 {first_call_time*1000:.2f}ms,第二次 {second_call_time*1000:.2f}ms")
assert second_call_time < first_call_time / 2, "缓存性能不足"
@pytest.mark.asyncio
async def test_member_role_caching(db_session: AsyncSession, study_id: uuid.UUID, user_id: uuid.UUID):
"""成员角色缓存测试"""
cache = get_permission_cache()
cache.clear_all()
# 第一次调用:从数据库查询
start_time = time.time()
role1 = await cache.get_member_role(db_session, study_id, user_id)
first_call_time = time.time() - start_time
# 第二次调用:从缓存获取
start_time = time.time()
role2 = await cache.get_member_role(db_session, study_id, user_id)
second_call_time = time.time() - start_time
# 验证结果相同
assert role1 == role2
# 缓存调用应该快得多
print(f"\n成员角色缓存:第一次 {first_call_time*1000:.2f}ms,第二次 {second_call_time*1000:.2f}ms")
assert second_call_time < first_call_time / 2, "缓存性能不足"
-245
View File
@@ -1,245 +0,0 @@
"""安全测试:权限系统安全性验证
测试权限系统的安全性,包括:
- 权限检查遗漏检测
- 权限配置完整性
- 缓存失效场景
- 成员停用后的权限检查
- 权限更新后的立即生效
- 并发权限检查一致性
"""
import uuid
import pytest
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.api_permissions import API_ENDPOINT_PERMISSIONS
from app.core.permission_cache import get_permission_cache
from app.core.project_permissions import (
role_has_api_permission,
role_has_project_permission,
replace_project_role_permissions,
replace_api_endpoint_permissions,
)
@pytest.mark.asyncio
async def test_all_endpoints_have_permission_checks():
"""测试所有端点都配置了权限检查
这个测试验证 API_ENDPOINT_PERMISSIONS 中的所有端点都有完整的权限配置。
"""
for endpoint_key, config in API_ENDPOINT_PERMISSIONS.items():
# 验证必需字段
assert "module" in config, f"Missing 'module' in {endpoint_key}"
assert "action" in config, f"Missing 'action' in {endpoint_key}"
assert "description" in config, f"Missing 'description' in {endpoint_key}"
assert "default_roles" in config, f"Missing 'default_roles' in {endpoint_key}"
# 验证字段值
assert config["action"] in ["read", "write"], f"Invalid action in {endpoint_key}"
assert isinstance(config["default_roles"], list), f"default_roles must be list in {endpoint_key}"
assert len(config["default_roles"]) > 0, f"default_roles must not be empty in {endpoint_key}"
@pytest.mark.asyncio
async def test_all_endpoints_in_api_permissions():
"""测试所有端点都在 API_ENDPOINT_PERMISSIONS 中定义"""
# 这个测试确保没有端点被遗漏
assert len(API_ENDPOINT_PERMISSIONS) > 0, "API_ENDPOINT_PERMISSIONS is empty"
assert len(API_ENDPOINT_PERMISSIONS) >= 94, "Missing endpoints in API_ENDPOINT_PERMISSIONS"
@pytest.mark.asyncio
async def test_all_endpoints_have_default_roles():
"""测试所有端点都有默认角色配置"""
valid_roles = {"PM", "CRA", "PV", "MEDICAL_REVIEW", "IMP", "QA"}
for endpoint_key, config in API_ENDPOINT_PERMISSIONS.items():
default_roles = config.get("default_roles", [])
assert len(default_roles) > 0, f"No default roles for {endpoint_key}"
for role in default_roles:
assert role in valid_roles, f"Invalid role {role} in {endpoint_key}"
@pytest.mark.asyncio
async def test_inactive_member_permission_denied(db_session: AsyncSession, study_id: uuid.UUID):
"""测试成员停用后的权限检查
当成员被停用时,应该拒绝其权限请求。
"""
# 这个测试需要创建一个停用的成员
# 然后验证权限检查返回 False
# 具体实现取决于成员模型的设计
# 暂时跳过,等待成员管理的完整实现
pass
@pytest.mark.asyncio
async def test_permission_update_immediate_effect(db_session: AsyncSession, study_id: uuid.UUID):
"""测试权限更新后的立即生效
当权限被更新时,新的权限应该立即生效。
"""
# 获取初始权限
initial_allowed = await role_has_project_permission(db_session, study_id, "CRA", "subjects", "write")
# 更新权限:允许 CRA 写入 subjects
new_permissions = {
"PM": {"subjects": {"read": True, "write": True}},
"CRA": {"subjects": {"read": True, "write": True}},
}
await replace_project_role_permissions(db_session, study_id, new_permissions)
# 验证权限立即生效
updated_allowed = await role_has_project_permission(db_session, study_id, "CRA", "subjects", "write")
assert updated_allowed is True, "权限更新未立即生效"
@pytest.mark.asyncio
async def test_cache_invalidation_on_permission_update(db_session: AsyncSession, study_id: uuid.UUID):
"""测试权限更新时的缓存失效
当权限被更新时,相关的缓存应该被失效。
"""
cache = get_permission_cache()
cache.clear_all()
# 填充缓存
await role_has_project_permission(db_session, study_id, "PM", "subjects", "read")
assert cache.get_cache_stats()["project_permissions_count"] == 1
# 更新权限
new_permissions = {
"PM": {"subjects": {"read": True, "write": True}},
}
await replace_project_role_permissions(db_session, study_id, new_permissions)
# 验证缓存已失效
assert cache.get_cache_stats()["project_permissions_count"] == 0
@pytest.mark.asyncio
async def test_cache_invalidation_on_member_update(db_session: AsyncSession, study_id: uuid.UUID, user_id: uuid.UUID):
"""测试成员更新时的缓存失效
当成员角色被更新时,相关的缓存应该被失效。
"""
cache = get_permission_cache()
cache.clear_all()
# 填充缓存
await role_has_project_permission(db_session, study_id, "PM", "subjects", "read")
initial_stats = cache.get_cache_stats()
# 失效成员角色缓存
cache.invalidate_member_role(study_id, user_id)
# 验证成员角色缓存已失效
# (项目权限缓存应该保留)
assert cache.get_cache_stats()["member_role_count"] == 0
@pytest.mark.asyncio
async def test_concurrent_permission_checks_consistency(db_session: AsyncSession, study_id: uuid.UUID):
"""测试并发权限检查的一致性
多个并发请求检查权限时,结果应该一致。
"""
import asyncio
async def check_permission():
return await role_has_project_permission(db_session, study_id, "PM", "subjects", "read")
# 并发执行权限检查
tasks = [check_permission() for _ in range(10)]
results = await asyncio.gather(*tasks)
# 所有结果应该相同
assert all(r == results[0] for r in results), "并发权限检查结果不一致"
@pytest.mark.asyncio
async def test_api_permission_priority_over_module(db_session: AsyncSession, study_id: uuid.UUID):
"""测试接口级权限优先于模块级权限
当同时配置了接口级和模块级权限时,接口级权限应该优先。
"""
# 设置模块级权限:允许 CRA 读取 subjects
module_permissions = {
"PM": {"subjects": {"read": True, "write": True}},
"CRA": {"subjects": {"read": True, "write": False}},
}
await replace_project_role_permissions(db_session, study_id, module_permissions)
# 设置接口级权限:拒绝 CRA 读取 subjects
api_permissions = {
"PM": {"GET:/subjects": True},
"CRA": {"GET:/subjects": False},
}
await replace_api_endpoint_permissions(db_session, study_id, api_permissions)
# 验证接口级权限优先
allowed = await role_has_api_permission(db_session, study_id, "CRA", "GET:/subjects")
assert allowed is False, "接口级权限未优先于模块级权限"
@pytest.mark.asyncio
async def test_admin_role_always_allowed(db_session: AsyncSession, study_id: uuid.UUID):
"""测试 ADMIN 角色总是被允许
ADMIN 角色应该在所有权限检查中都被允许。
"""
# 即使没有配置权限,ADMIN 也应该被允许
allowed = await role_has_project_permission(db_session, study_id, "ADMIN", "subjects", "write")
assert allowed is True, "ADMIN 角色权限检查失败"
# API 权限检查也应该允许 ADMIN
api_allowed = await role_has_api_permission(db_session, study_id, "ADMIN", "POST:/subjects")
assert api_allowed is True, "ADMIN 角色 API 权限检查失败"
@pytest.mark.asyncio
async def test_none_role_permission_denied(db_session: AsyncSession, study_id: uuid.UUID):
"""测试 None 角色被拒绝
当角色为 None 时,权限检查应该返回 False。
"""
allowed = await role_has_project_permission(db_session, study_id, None, "subjects", "read")
assert allowed is False, "None 角色权限检查失败"
@pytest.mark.asyncio
async def test_unknown_endpoint_permission_denied(db_session: AsyncSession, study_id: uuid.UUID):
"""测试未知端点被拒绝
当端点不存在时,权限检查应该返回 False。
"""
allowed = await role_has_api_permission(db_session, study_id, "PM", "GET:/unknown-endpoint")
assert allowed is False, "未知端点权限检查失败"
@pytest.mark.asyncio
async def test_permission_matrix_consistency(db_session: AsyncSession, study_id: uuid.UUID):
"""测试权限矩阵的一致性
权限矩阵应该包含所有角色和端点的权限信息。
"""
from app.core.project_permissions import get_project_role_permissions
permissions = await get_project_role_permissions(db_session, study_id)
# 验证矩阵结构
assert isinstance(permissions, dict), "权限矩阵格式错误"
# 验证所有角色都存在
for role in ["PM", "CRA", "PV", "MEDICAL_REVIEW", "IMP", "QA"]:
assert role in permissions, f"缺少角色 {role}"
# 验证每个角色都有权限配置
for role, role_permissions in permissions.items():
assert isinstance(role_permissions, dict), f"角色 {role} 的权限配置格式错误"
assert len(role_permissions) > 0, f"角色 {role} 没有权限配置"
+230
View File
@@ -0,0 +1,230 @@
"""权限模板服务单元测试"""
from __future__ import annotations
import uuid
import pytest
from app.models.permission_template import PermissionTemplate, TemplateType
from app.schemas.permission_template import PermissionTemplateCreate, PermissionTemplateUpdate
from app.services.permission_template_service import PermissionTemplateService
SAMPLE_PERMISSIONS = {
"PM": {
"subjects:create": True,
"subjects:read": True,
"subjects:update": True,
"subjects:delete": False,
},
"CRA": {
"subjects:create": False,
"subjects:read": True,
"subjects:update": False,
"subjects:delete": False,
},
}
@pytest.fixture
def user_id() -> uuid.UUID:
return uuid.uuid4()
# ---------------------------------------------------------------------------
# 创建模板
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_create_template_success(db_session, user_id):
payload = PermissionTemplateCreate(
name="测试模板",
description="用于测试",
template_type=TemplateType.CUSTOM,
permissions=SAMPLE_PERMISSIONS,
)
template = await PermissionTemplateService.create_template(db_session, payload, user_id)
assert template.id is not None
assert template.name == "测试模板"
assert template.is_system is False
assert template.template_type == TemplateType.CUSTOM
assert template.permissions == SAMPLE_PERMISSIONS
@pytest.mark.asyncio
async def test_create_template_creates_version_1(db_session, user_id):
payload = PermissionTemplateCreate(
name="版本测试",
template_type=TemplateType.CUSTOM,
permissions=SAMPLE_PERMISSIONS,
)
template = await PermissionTemplateService.create_template(db_session, payload, user_id)
await db_session.refresh(template, ["versions"])
assert len(template.versions) == 1
assert template.versions[0].version == 1
assert template.versions[0].change_log == "初始版本"
@pytest.mark.asyncio
async def test_create_template_invalid_endpoint_key(db_session, user_id):
payload = PermissionTemplateCreate(
name="无效模板",
template_type=TemplateType.CUSTOM,
permissions={"PM": {"nonexistent:action": True}},
)
with pytest.raises(ValueError, match="权限操作 nonexistent:action 不存在"):
await PermissionTemplateService.create_template(db_session, payload, user_id)
@pytest.mark.asyncio
async def test_create_template_invalid_permission_value(db_session, user_id):
payload = PermissionTemplateCreate(
name="无效值模板",
template_type=TemplateType.CUSTOM,
permissions={"PM": {"subjects:read": "yes"}}, # type: ignore
)
with pytest.raises(ValueError, match="必须是布尔类型"):
await PermissionTemplateService.create_template(db_session, payload, user_id)
# ---------------------------------------------------------------------------
# 获取模板
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_get_template_exists(db_session, user_id):
payload = PermissionTemplateCreate(
name="获取测试",
template_type=TemplateType.ROLE,
permissions=SAMPLE_PERMISSIONS,
)
created = await PermissionTemplateService.create_template(db_session, payload, user_id)
fetched = await PermissionTemplateService.get_template(db_session, created.id)
assert fetched is not None
assert fetched.id == created.id
assert fetched.name == "获取测试"
@pytest.mark.asyncio
async def test_get_template_not_found(db_session):
result = await PermissionTemplateService.get_template(db_session, uuid.uuid4())
assert result is None
# ---------------------------------------------------------------------------
# 列表模板
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_list_templates_filter_by_type(db_session, user_id):
await PermissionTemplateService.create_template(
db_session,
PermissionTemplateCreate(name="角色模板", template_type=TemplateType.ROLE, permissions=SAMPLE_PERMISSIONS),
user_id,
)
await PermissionTemplateService.create_template(
db_session,
PermissionTemplateCreate(name="场景模板", template_type=TemplateType.SCENARIO, permissions=SAMPLE_PERMISSIONS),
user_id,
)
role_templates = await PermissionTemplateService.list_templates(db_session, template_type=TemplateType.ROLE)
assert all(t.template_type == TemplateType.ROLE for t in role_templates)
assert len(role_templates) >= 1
# ---------------------------------------------------------------------------
# 更新模板
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_update_template_name(db_session, user_id):
created = await PermissionTemplateService.create_template(
db_session,
PermissionTemplateCreate(name="旧名称", template_type=TemplateType.CUSTOM, permissions=SAMPLE_PERMISSIONS),
user_id,
)
updated = await PermissionTemplateService.update_template(
db_session, created.id, PermissionTemplateUpdate(name="新名称")
)
assert updated.name == "新名称"
@pytest.mark.asyncio
async def test_update_template_permissions_creates_new_version(db_session, user_id):
created = await PermissionTemplateService.create_template(
db_session,
PermissionTemplateCreate(name="版本测试2", template_type=TemplateType.CUSTOM, permissions=SAMPLE_PERMISSIONS),
user_id,
)
new_perms = {"PM": {"subjects:read": True}}
await PermissionTemplateService.update_template(
db_session, created.id, PermissionTemplateUpdate(permissions=new_perms)
)
updated = await PermissionTemplateService.get_template(db_session, created.id)
await db_session.refresh(updated, ["versions"])
assert len(updated.versions) == 2
assert updated.versions[-1].version == 2
assert updated.permissions == new_perms
@pytest.mark.asyncio
async def test_update_system_template_raises(db_session):
system_template = PermissionTemplate(
name="系统模板",
template_type=TemplateType.ROLE,
is_system=True,
permissions=SAMPLE_PERMISSIONS,
)
db_session.add(system_template)
await db_session.commit()
await db_session.refresh(system_template)
with pytest.raises(ValueError, match="不允许修改系统预设模板"):
await PermissionTemplateService.update_template(
db_session, system_template.id, PermissionTemplateUpdate(name="改名")
)
# ---------------------------------------------------------------------------
# 删除模板
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_delete_template_success(db_session, user_id):
created = await PermissionTemplateService.create_template(
db_session,
PermissionTemplateCreate(name="待删除", template_type=TemplateType.CUSTOM, permissions=SAMPLE_PERMISSIONS),
user_id,
)
await PermissionTemplateService.delete_template(db_session, created.id)
assert await PermissionTemplateService.get_template(db_session, created.id) is None
@pytest.mark.asyncio
async def test_delete_system_template_raises(db_session):
system_template = PermissionTemplate(
name="系统模板2",
template_type=TemplateType.ROLE,
is_system=True,
permissions=SAMPLE_PERMISSIONS,
)
db_session.add(system_template)
await db_session.commit()
await db_session.refresh(system_template)
with pytest.raises(ValueError, match="不允许删除系统预设模板"):
await PermissionTemplateService.delete_template(db_session, system_template.id)
@pytest.mark.asyncio
async def test_delete_nonexistent_template_raises(db_session):
with pytest.raises(ValueError, match="不存在"):
await PermissionTemplateService.delete_template(db_session, uuid.uuid4())
@@ -9,7 +9,6 @@ from app.core.project_permissions import (
get_missing_prerequisites, get_missing_prerequisites,
) )
from app.models.api_endpoint_permission import ApiEndpointPermission from app.models.api_endpoint_permission import ApiEndpointPermission
from app.models.study_role_permission import StudyRolePermission
@pytest.mark.asyncio @pytest.mark.asyncio
@@ -1,166 +0,0 @@
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
def test_project_setup_routes_keep_project_admin_and_pm_defaults():
source = (ROOT / "app/api/v1/studies.py").read_text(encoding="utf-8")
assert 'require_study_roles(["PM"])' not in source
assert 'require_study_roles(["ADMIN", "PM"])' not in source
assert 'require_roles(["ADMIN"])' in source
def test_project_permission_routes_are_registered():
source = (ROOT / "app/api/v1/router.py").read_text(encoding="utf-8")
assert "project_permissions" in source
assert 'prefix="/studies/{study_id}/permissions"' in source
def test_project_permission_updates_use_project_member_write_permission():
source = (ROOT / "app/api/v1/project_permissions.py").read_text(encoding="utf-8")
assert 'require_study_permission("project_members", "write")' in source
assert 'require_roles(["ADMIN"])' not in source
def test_project_member_writes_use_saved_permission_matrix():
source = (ROOT / "app/api/v1/members.py").read_text(encoding="utf-8")
assert 'require_study_permission("project_members", "write")' in source
assert 'require_study_roles(["ADMIN", "PM"])' not in source
def test_project_member_candidates_use_project_write_permission():
source = (ROOT / "app/api/v1/members.py").read_text(encoding="utf-8")
user_crud_source = (ROOT / "app/crud/user.py").read_text(encoding="utf-8")
assert '"/candidates"' in source
assert 'response_model=list[UserResponse]' in source
assert 'dependencies=[Depends(require_study_permission("project_members", "write"))]' in source
assert "list_active_member_candidates_for_study" in source
assert "User.status == UserStatus.ACTIVE" in user_crud_source
assert ".where(~existing_member)" in user_crud_source
def test_project_member_mutation_blocks_self_and_higher_role_changes():
source = (ROOT / "app/api/v1/members.py").read_text(encoding="utf-8")
assert "PROJECT_ROLE_RANK" in source
assert 'if target_member and target_member.user_id == current_user.id:' in source
assert "不能修改自己的项目成员权限" in source
assert "不能修改系统管理员账号的项目权限" in source
assert 'if _role_value(target_user) == "ADMIN":' in source
assert "不能修改权限高于自己的项目成员" in source
assert "不能授予高于自己的项目角色" in source
assert "_ensure_member_mutation_allowed(" in source
def test_business_modules_use_saved_permission_matrix():
expected = {
"overview.py": [
'require_study_permission("project_overview", "read")',
],
"project_milestones.py": [
'require_study_permission("project_milestones", "read")',
'require_study_permission("project_milestones", "write")',
],
"subjects.py": [
'require_study_permission("subjects", "read")',
'require_study_permission("subjects", "write")',
],
"material_equipments.py": [
'require_study_permission("materials", "read")',
'require_study_permission("materials", "write")',
],
"startup.py": [
'require_study_permission("startup_ethics", "read")',
'require_study_permission("startup_ethics", "write")',
'require_study_permission("startup_auth", "read")',
'require_study_permission("startup_auth", "write")',
],
"aes.py": [
'require_study_permission("risk_issues", "read")',
'require_study_permission("risk_issues", "write")',
],
"monitoring_visit_issues.py": [
'require_study_permission("monitoring_audit", "read")',
'require_study_permission("monitoring_audit", "write")',
],
}
for filename, checks in expected.items():
source = (ROOT / f"app/api/v1/{filename}").read_text(encoding="utf-8")
for check in checks:
assert check in source
ae_source = (ROOT / "app/api/v1/aes.py").read_text(encoding="utf-8")
assert "ALLOWED_CREATE_ROLES" not in ae_source
assert "ALLOWED_UPDATE_ROLES" not in ae_source
assert 'member_role not in {"PM", "PV"}' not in ae_source
def test_fee_contracts_use_saved_permission_matrix():
source = (ROOT / "app/api/v1/fees_contracts.py").read_text(encoding="utf-8")
assert 'role_has_project_permission(db, project_id, membership.role_in_study, "fees", action)' in source
assert 'if write and membership.role_in_study not in {"ADMIN", "PM"}' not in source
def test_attachment_deletes_use_saved_permission_matrix():
source = (ROOT / "app/api/v1/attachments.py").read_text(encoding="utf-8")
fees_source = (ROOT / "app/api/v1/fees_attachments.py").read_text(encoding="utf-8")
assert "_permission_module_for_entity(entity_type)" in source
assert '"knowledge_note": "shared_library"' in source
assert "_permission_module_for_entity(attachment.entity_type)" in source
assert 'getattr(membership, "role_in_study", None) == "PM"' not in source
assert 'role_has_project_permission(db, project_id, membership.role_in_study, "fees", action)' in fees_source
assert 'getattr(membership, "role_in_study", None) == "PM"' not in fees_source
def test_faq_uses_dedicated_permission_matrix():
source = (ROOT / "app/api/v1/faqs.py").read_text(encoding="utf-8")
category_source = (ROOT / "app/api/v1/faq_categories.py").read_text(encoding="utf-8")
assert 'role_has_project_permission(db, study_id, member.role_in_study, "faq", action)' in source
assert 'role_has_project_permission(db, study_id, member.role_in_study, "faq", action)' in category_source
assert '"etmf"' not in source
assert '"etmf"' not in category_source
assert 'member_role != "PM"' not in source
assert 'member_role != "PM"' not in category_source
def test_audit_logs_use_saved_permission_matrix():
source = (ROOT / "app/api/v1/audit_logs.py").read_text(encoding="utf-8")
get_block = source[source.index("@router.get("):source.index("@router.delete(")]
assert 'require_study_permission("audit_export", "read")' in get_block
assert 'require_study_member()' not in get_block
def test_document_service_uses_saved_permission_matrix():
source = (ROOT / "app/services/document_service.py").read_text(encoding="utf-8")
assert 'role_has_project_permission' in source
assert '"file_versions"' in source
assert 'rbac.is_allowed' not in source
def test_remaining_business_reads_use_saved_permission_matrix():
expected = {
"finance_contracts.py": ['require_study_permission("fees", "read")'],
"drug_shipments.py": ['require_study_permission("materials", "read")'],
"knowledge_notes.py": ['require_study_permission("shared_library", "read")'],
"subject_histories.py": ['require_study_permission("subjects", "read")'],
"subject_pds.py": ['require_study_permission("risk_issues", "read")'],
"visits.py": ['require_study_permission("subjects", "read")'],
"attachments.py": ['_permission_module_for_entity(entity_type)'],
}
for filename, checks in expected.items():
source = (ROOT / f"app/api/v1/{filename}").read_text(encoding="utf-8")
for check in checks:
assert check in source
-189
View File
@@ -1,189 +0,0 @@
from app.core.project_permissions import PROJECT_PERMISSION_MODULES, normalize_permission_matrix
def test_normalize_permission_matrix_makes_admin_fixed_full_access():
matrix = normalize_permission_matrix({"ADMIN": {}})
for module in PROJECT_PERMISSION_MODULES:
assert matrix["ADMIN"][module["key"]] == {
"read": True,
"write": module.get("writable") is not False,
}
def test_normalize_permission_matrix_keeps_admin_fixed_even_when_disabled():
payload = {
"ADMIN": {
module["key"]: {"read": False, "write": False}
for module in PROJECT_PERMISSION_MODULES
}
}
matrix = normalize_permission_matrix(payload)
for module in PROJECT_PERMISSION_MODULES:
assert matrix["ADMIN"][module["key"]] == {
"read": True,
"write": module.get("writable") is not False,
}
def test_normalize_permission_matrix_write_implies_read():
matrix = normalize_permission_matrix({
"CRA": {
"subjects": {"read": False, "write": True},
}
})
assert matrix["CRA"]["subjects"] == {"read": True, "write": True}
def test_business_permission_modules_follow_project_menu():
module_labels = [module["label"] for module in PROJECT_PERMISSION_MODULES]
assert module_labels == [
"项目成员",
"中心管理",
"审计日志导出",
"项目总览",
"项目里程碑",
"合同费用管理",
"物资管理",
"文件版本管理",
"立项与伦理",
"启动与授权",
"参与者管理",
"风险问题",
"监查稽查",
"eTMF",
"FAQ",
"共享库",
]
def test_default_business_permissions_match_role_responsibilities():
matrix = normalize_permission_matrix()
assert matrix["PM"]["project_members"] == {"read": True, "write": True}
assert matrix["PM"]["sites"] == {"read": True, "write": True}
assert matrix["CRA"]["project_members"] == {"read": False, "write": False}
assert matrix["CRA"]["audit_export"] == {"read": False, "write": False}
assert matrix["PV"]["sites"] == {"read": False, "write": False}
assert matrix["IMP"]["audit_export"] == {"read": False, "write": False}
assert matrix["QA"]["audit_export"] == {"read": False, "write": False}
assert matrix["CRA"]["project_milestones"] == {"read": True, "write": True}
assert matrix["CRA"]["subjects"] == {"read": True, "write": True}
assert matrix["CRA"]["monitoring_audit"] == {"read": True, "write": True}
assert matrix["PV"]["risk_issues"] == {"read": True, "write": True}
assert matrix["MEDICAL_REVIEW"]["project_overview"] == {"read": True, "write": False}
assert matrix["MEDICAL_REVIEW"]["subjects"] == {"read": True, "write": False}
assert matrix["MEDICAL_REVIEW"]["risk_issues"] == {"read": True, "write": True}
assert matrix["MEDICAL_REVIEW"]["monitoring_audit"] == {"read": True, "write": False}
assert matrix["MEDICAL_REVIEW"]["faq"] == {"read": True, "write": True}
assert matrix["MEDICAL_REVIEW"]["shared_library"] == {"read": True, "write": True}
assert matrix["MEDICAL_REVIEW"]["fees"] == {"read": False, "write": False}
assert matrix["MEDICAL_REVIEW"]["materials"] == {"read": False, "write": False}
assert matrix["IMP"]["materials"] == {"read": True, "write": True}
assert matrix["PM"]["faq"] == {"read": True, "write": True}
assert matrix["CRA"]["faq"] == {"read": True, "write": True}
assert matrix["PM"]["project_overview"] == {"read": True, "write": False}
assert matrix["CRA"]["project_overview"] == {"read": True, "write": False}
assert matrix["PM"]["shared_library"] == {"read": True, "write": True}
assert matrix["CRA"]["shared_library"] == {"read": True, "write": True}
def test_project_overview_is_read_only_even_when_write_is_submitted():
matrix = normalize_permission_matrix({
"CRA": {
"project_overview": {"read": False, "write": True},
}
})
assert matrix["CRA"]["project_overview"] == {"read": True, "write": False}
def test_pm_can_be_granted_management_backend_permissions():
matrix = normalize_permission_matrix({
"PM": {
"project_members": {"read": True, "write": True},
"sites": {"read": True, "write": True},
"audit_export": {"read": True, "write": True},
}
})
assert matrix["PM"]["project_members"] == {"read": True, "write": True}
assert matrix["PM"]["sites"] == {"read": True, "write": True}
assert matrix["PM"]["audit_export"] == {"read": True, "write": True}
def test_non_pm_roles_cannot_be_granted_management_backend_permissions():
management_modules = ["project_members", "sites", "audit_export"]
matrix = normalize_permission_matrix({
role: {
module: {"read": True, "write": True}
for module in management_modules
}
for role in ["CRA", "PV", "MEDICAL_REVIEW", "IMP", "QA"]
})
for role in ["CRA", "PV", "MEDICAL_REVIEW", "IMP", "QA"]:
for module in management_modules:
assert matrix[role][module] == {"read": False, "write": False}
def test_each_non_admin_role_can_toggle_each_writable_module():
roles = ["PM", "CRA", "PV", "MEDICAL_REVIEW", "IMP", "QA"]
management_modules = {"project_members", "sites", "audit_export"}
writable_modules = [
module["key"]
for module in PROJECT_PERMISSION_MODULES
if module.get("writable") is not False and module["key"] not in management_modules
]
for role in roles:
enabled = normalize_permission_matrix({
role: {
module: {"read": False, "write": True}
for module in writable_modules
}
})
disabled = normalize_permission_matrix({
role: {
module["key"]: {"read": False, "write": False}
for module in PROJECT_PERMISSION_MODULES
}
})
for module in writable_modules:
assert enabled[role][module] == {"read": True, "write": True}
assert disabled[role][module] == {"read": False, "write": False}
def test_read_only_modules_never_accept_write_for_any_role():
read_only_modules = [
module["key"]
for module in PROJECT_PERMISSION_MODULES
if module.get("writable") is False
]
payload = {
role: {
module: {"read": False, "write": True}
for module in read_only_modules
}
for role in ["ADMIN", "PM", "CRA", "PV", "MEDICAL_REVIEW", "IMP", "QA"]
}
matrix = normalize_permission_matrix(payload)
for role in ["ADMIN", "PM", "CRA", "PV", "MEDICAL_REVIEW", "IMP", "QA"]:
for module in read_only_modules:
assert matrix[role][module] == {"read": True, "write": False}
def test_medical_review_default_permissions_are_independent_from_pv():
matrix = normalize_permission_matrix()
assert matrix["PV"]["monitoring_audit"] == {"read": True, "write": True}
assert matrix["MEDICAL_REVIEW"]["monitoring_audit"] == {"read": True, "write": False}
assert matrix["PV"]["fees"] == {"read": True, "write": False}
assert matrix["MEDICAL_REVIEW"]["fees"] == {"read": False, "write": False}
+62 -12
View File
@@ -1,7 +1,5 @@
import { apiGet, apiPut, apiPost } from "./axios"; import { apiGet, apiPut, apiPost, apiDelete } from "./axios";
import type { import type {
ProjectRolePermissionsResponse,
ProjectRolePermissionsUpdate,
ApiEndpointPermissionsResponse, ApiEndpointPermissionsResponse,
ApiEndpointPermissionsUpdate, ApiEndpointPermissionsUpdate,
PermissionMetricsResponse, PermissionMetricsResponse,
@@ -10,14 +8,7 @@ import type {
HealthResponse, HealthResponse,
} from "../types/api"; } from "../types/api";
// 模块级权限(现有) // 接口级权限
export const fetchProjectRolePermissions = (studyId: string) =>
apiGet<ProjectRolePermissionsResponse>(`/api/v1/studies/${studyId}/permissions/`);
export const updateProjectRolePermissions = (studyId: string, payload: ProjectRolePermissionsUpdate) =>
apiPut<ProjectRolePermissionsResponse>(`/api/v1/studies/${studyId}/permissions/`, payload);
// 接口级权限(新增)
export const fetchApiEndpointPermissions = (studyId: string) => export const fetchApiEndpointPermissions = (studyId: string) =>
apiGet<ApiEndpointPermissionsResponse>(`/api/v1/studies/${studyId}/api-permissions`); apiGet<ApiEndpointPermissionsResponse>(`/api/v1/studies/${studyId}/api-permissions`);
@@ -27,7 +18,7 @@ export const updateApiEndpointPermissions = (studyId: string, payload: ApiEndpoi
export const fetchApiOperations = () => export const fetchApiOperations = () =>
apiGet<{ operations: Array<{ operation_key: string; module: string; action: string; description: string; default_roles: string[] }> }>(`/api/v1/api-permissions/operations`); apiGet<{ operations: Array<{ operation_key: string; module: string; action: string; description: string; default_roles: string[] }> }>(`/api/v1/api-permissions/operations`);
// 权限系统监控API(新增) // 权限系统监控API
export const fetchPermissionMetrics = () => export const fetchPermissionMetrics = () =>
apiGet<PermissionMetricsResponse>(`/api/v1/permission-monitoring/metrics`); apiGet<PermissionMetricsResponse>(`/api/v1/permission-monitoring/metrics`);
@@ -48,3 +39,62 @@ export const resetPermissionMetrics = () =>
export const clearPermissionAlerts = () => export const clearPermissionAlerts = () =>
apiPost<void>(`/api/v1/permission-monitoring/clear-alerts`); apiPost<void>(`/api/v1/permission-monitoring/clear-alerts`);
// 权限模板API
export interface PermissionTemplate {
id: string;
name: string;
description: string | null;
template_type: "ROLE" | "SCENARIO" | "CUSTOM";
is_system: boolean;
created_by: string | null;
created_at: string;
updated_at: string;
permissions: Record<string, Record<string, boolean>>;
tags: string | null;
category: string | null;
recommended_roles: string | null;
versions: Array<{ id: string; version: number; change_log: string | null; created_at: string }>;
}
export interface PermissionTemplateCreate {
name: string;
description?: string;
template_type: "ROLE" | "SCENARIO" | "CUSTOM";
permissions: Record<string, Record<string, boolean>>;
tags?: string;
category?: string;
recommended_roles?: string;
}
export interface ApplyTemplateRequest {
template_id: string;
roles?: string[];
override?: boolean;
}
export interface ApplyTemplateResponse {
study_id: string;
applied_roles: string[];
permissions: Record<string, Record<string, { allowed: boolean }>>;
}
export const fetchPermissionTemplates = (params?: { template_type?: string; category?: string }) =>
apiGet<PermissionTemplate[]>(`/api/v1/permission-templates`, { params });
export const fetchPermissionTemplate = (templateId: string) =>
apiGet<PermissionTemplate>(`/api/v1/permission-templates/${templateId}`);
export const createPermissionTemplate = (payload: PermissionTemplateCreate) =>
apiPost<PermissionTemplate>(`/api/v1/permission-templates`, payload);
export const updatePermissionTemplate = (templateId: string, payload: Partial<PermissionTemplateCreate>) =>
apiPut<PermissionTemplate>(`/api/v1/permission-templates/${templateId}`, payload);
export const deletePermissionTemplate = (templateId: string) =>
apiDelete<void>(`/api/v1/permission-templates/${templateId}`);
export const applyPermissionTemplate = (studyId: string, payload: ApplyTemplateRequest) =>
apiPost<ApplyTemplateResponse>(
`/api/v1/studies/${studyId}/permission-templates/${payload.template_id}/apply`,
payload,
);
@@ -18,47 +18,52 @@
<el-option <el-option
v-for="module in uniqueModules" v-for="module in uniqueModules"
:key="module" :key="module"
:label="module" :label="moduleLabel(module)"
:value="module" :value="module"
/> />
</el-select> </el-select>
<el-select v-model="filterAction" placeholder="筛选操作" clearable style="width: 120px"> <el-select v-model="filterAction" placeholder="筛选操作" clearable style="width: 120px">
<el-option label="全部操作" value="" /> <el-option label="全部操作" value="" />
<el-option label="创建" value="create" />
<el-option label="读取" value="read" /> <el-option label="读取" value="read" />
<el-option label="写入" value="write" /> <el-option label="更新" value="update" />
<el-option label="删除" value="delete" />
<el-option label="导出" value="export" />
</el-select> </el-select>
</div> </div>
<!-- 权限矩阵表格 --> <!-- 权限矩阵表格 -->
<div class="api-permissions-table-wrapper"> <div class="api-permissions-table-wrapper">
<el-table :data="filteredOperations" border stripe> <el-table :data="filteredOperations" border stripe :span-method="spanMethod">
<el-table-column prop="operation_key" label="权限操作" width="200"> <el-table-column prop="module" label="模块" width="140">
<template #default="{ row }"> <template #default="{ row }">
<div class="operation-cell"> <span class="module-label">{{ moduleLabel(row.module) }}</span>
<el-tag :type="getActionType(row.action)" size="small">
{{ row.action === 'read' ? '读取' : '写入' }}
</el-tag>
<span class="operation-name">{{ row.operation_key }}</span>
</div>
</template> </template>
</el-table-column> </el-table-column>
<el-table-column prop="description" label="描述" width="200" show-overflow-tooltip /> <el-table-column prop="operation_key" label="权限操作" width="200">
<template #default="{ row }">
<el-table-column prop="module" label="模块" width="100" /> <div class="operation-cell">
<el-tag :type="getActionType(row)" size="small">
{{ getActionLabel(row) }}
</el-tag>
<span class="operation-name">{{ row.description || row.operation_key }}</span>
</div>
</template>
</el-table-column>
<el-table-column <el-table-column
v-for="role in roles" v-for="role in roles"
:key="role" :key="role"
:label="role" :label="role"
:width="100" :width="90"
align="center" align="center"
> >
<template #default="{ row }"> <template #default="{ row }">
<el-checkbox <el-checkbox
:model-value="isOperationAllowed(row.operation_key, role)" :model-value="isOperationAllowed(row.operation_key, role)"
@change="(val) => onPermissionChange(row.operation_key, role, val as boolean)" @change="(val: boolean) => onPermissionChange(row.operation_key, role, val)"
/> />
</template> </template>
</el-table-column> </el-table-column>
@@ -99,6 +104,30 @@ const filterModule = ref("");
const filterAction = ref(""); const filterAction = ref("");
const operations = ref<Operation[]>([]); const operations = ref<Operation[]>([]);
const MODULE_LABELS: Record<string, string> = {
subjects: "参与者管理",
sites: "中心管理",
risk_issues: "风险问题",
monitoring_audit: "监查稽查",
project_members: "项目成员",
project_milestones: "项目里程碑",
project_overview: "项目总览",
fees: "合同费用",
materials: "物资管理",
material_equipments: "物资设备",
documents: "文件版本",
startup_ethics: "立项与伦理",
startup_auth: "启动与授权",
audit_export: "审计日志",
faq: "FAQ",
shared_library: "共享库",
attachments: "附件管理",
dashboard: "仪表板",
subject_histories: "参与者历史",
};
const moduleLabel = (module: string): string => MODULE_LABELS[module] || module;
// //
const roles = computed(() => { const roles = computed(() => {
if (!props.matrix) return []; if (!props.matrix) return [];
@@ -121,21 +150,48 @@ const uniqueModules = computed(() => {
return [...new Set(operations.value.map((o) => o.module))].sort(); return [...new Set(operations.value.map((o) => o.module))].sort();
}); });
// //
const filteredOperations = computed(() => { const filteredOperations = computed(() => {
return operations.value.filter((operation) => { const filtered = operations.value.filter((operation) => {
const matchSearch = const matchSearch =
!searchText.value || !searchText.value ||
operation.operation_key.toLowerCase().includes(searchText.value.toLowerCase()) || operation.operation_key.toLowerCase().includes(searchText.value.toLowerCase()) ||
operation.description.toLowerCase().includes(searchText.value.toLowerCase()); operation.description.toLowerCase().includes(searchText.value.toLowerCase()) ||
moduleLabel(operation.module).includes(searchText.value);
const matchModule = !filterModule.value || operation.module === filterModule.value; const matchModule = !filterModule.value || operation.module === filterModule.value;
const matchAction = !filterAction.value || operation.action === filterAction.value; const matchAction = !filterAction.value || getOperationVerb(operation) === filterAction.value;
return matchSearch && matchModule && matchAction; return matchSearch && matchModule && matchAction;
}); });
//
filtered.sort((a, b) => {
if (a.module !== b.module) {
return moduleLabel(a.module).localeCompare(moduleLabel(b.module), "zh-CN");
}
return a.operation_key.localeCompare(b.operation_key);
});
return filtered;
}); });
//
const spanMethod = ({ row, rowIndex, columnIndex }: { row: Operation; rowIndex: number; column: any; columnIndex: number }) => {
if (columnIndex === 0) {
const list = filteredOperations.value;
if (rowIndex === 0 || list[rowIndex - 1].module !== row.module) {
let count = 1;
for (let i = rowIndex + 1; i < list.length && list[i].module === row.module; i++) {
count++;
}
return { rowspan: count, colspan: 1 };
}
return { rowspan: 0, colspan: 0 };
}
return { rowspan: 1, colspan: 1 };
};
// //
const isOperationAllowed = (operation_key: string, role: string): boolean => { const isOperationAllowed = (operation_key: string, role: string): boolean => {
if (!props.matrix || !props.matrix[role]) return false; if (!props.matrix || !props.matrix[role]) return false;
@@ -144,13 +200,44 @@ const isOperationAllowed = (operation_key: string, role: string): boolean => {
return typeof perm === "boolean" ? perm : perm.allowed; return typeof perm === "boolean" ? perm : perm.allowed;
}; };
// // operation_key
const getActionType = (action: string): string => { const getOperationVerb = (row: Operation): string => {
const typeMap: Record<string, string> = { const key = row.operation_key;
read: "info", const suffix = key.split(":").pop() || "";
write: "success", if (suffix === "create") return "create";
}; if (suffix === "read" || suffix === "list") return "read";
return typeMap[action] || "info"; if (suffix === "update") return "update";
if (suffix === "delete") return "delete";
if (suffix === "export") return "export";
return row.action;
};
const ACTION_LABELS: Record<string, string> = {
create: "创建",
read: "读取",
update: "更新",
delete: "删除",
export: "导出",
write: "写入",
};
const ACTION_TAG_TYPES: Record<string, string> = {
create: "success",
read: "info",
update: "warning",
delete: "danger",
export: "",
write: "success",
};
const getActionLabel = (row: Operation): string => {
const verb = getOperationVerb(row);
return ACTION_LABELS[verb] || verb;
};
const getActionType = (row: Operation): string => {
const verb = getOperationVerb(row);
return ACTION_TAG_TYPES[verb] || "info";
}; };
// //
@@ -189,20 +276,20 @@ onMounted(() => {
overflow-x: auto; overflow-x: auto;
} }
.endpoint-cell { .module-label {
font-weight: 600;
font-size: 13px;
color: #303133;
}
.operation-cell {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 10px; gap: 8px;
:deep(.el-tag) { .operation-name {
min-width: 50px; font-size: 13px;
text-align: center; color: #606266;
} }
} }
.endpoint-path {
font-family: monospace;
font-size: 12px;
color: #606266;
}
</style> </style>
@@ -0,0 +1,400 @@
<template>
<div class="template-selector">
<div class="selector-header">
<h3>快速配置角色权限</h3>
<p class="selector-desc">选择预设模板一键应用到项目角色权限</p>
</div>
<div class="template-grid">
<div
v-for="template in templates"
:key="template.id"
class="template-card"
:class="{ selected: selectedId === template.id, system: template.is_system }"
@click="select(template)"
>
<div class="card-header">
<span class="card-icon">{{ roleIcon(template.category) }}</span>
<div class="card-title-wrap">
<span class="card-name">{{ template.name }}</span>
<el-tag v-if="template.is_system" size="small" type="info">系统预设</el-tag>
<el-tag v-else size="small" type="success">自定义</el-tag>
</div>
</div>
<p class="card-desc">{{ template.description }}</p>
<div class="card-stats">
<span class="stat">
<el-icon><Check /></el-icon>
{{ countEnabled(template) }} 项已启用
</span>
<span class="stat disabled">
<el-icon><Close /></el-icon>
{{ countDisabled(template) }} 项已禁用
</span>
</div>
</div>
<!-- 自定义模板入口 -->
<div class="template-card add-card" @click="showCreateDialog = true">
<el-icon class="add-icon"><Plus /></el-icon>
<span>保存当前配置为模板</span>
</div>
</div>
<!-- 预览面板 -->
<transition name="slide-up">
<div v-if="selected" class="preview-panel">
<div class="preview-header">
<div class="preview-title">
<span class="preview-icon">{{ roleIcon(selected.category) }}</span>
<strong>{{ selected.name }}</strong>
<span class="preview-desc">{{ selected.description }}</span>
</div>
<div class="preview-actions">
<el-button @click="selected = null">取消</el-button>
<el-button type="primary" :loading="applying" @click="applyTemplate">
<el-icon><Check /></el-icon>
应用此模板
</el-button>
</div>
</div>
<div class="preview-body">
<div v-for="(rolePerms, role) in selected.permissions" :key="role" class="role-preview">
<div class="role-label">{{ role }}</div>
<div class="perm-summary">
<el-tag type="success" size="small">启用 {{ countRoleEnabled(rolePerms) }}</el-tag>
<el-tag type="danger" size="small">禁用 {{ countRoleDisabled(rolePerms) }}</el-tag>
</div>
</div>
</div>
</div>
</transition>
<!-- 创建自定义模板对话框 -->
<el-dialog v-model="showCreateDialog" title="保存为自定义模板" width="480px">
<el-form :model="createForm" label-width="80px">
<el-form-item label="模板名称" required>
<el-input v-model="createForm.name" placeholder="请输入模板名称" />
</el-form-item>
<el-form-item label="描述">
<el-input v-model="createForm.description" type="textarea" :rows="2" placeholder="可选描述" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="showCreateDialog = false">取消</el-button>
<el-button type="primary" :loading="creating" @click="createTemplate">保存</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from "vue";
import { ElMessage } from "element-plus";
import { Check, Close, Plus } from "@element-plus/icons-vue";
import {
fetchPermissionTemplates,
createPermissionTemplate,
applyPermissionTemplate,
type PermissionTemplate,
} from "@/api/projectPermissions";
const props = defineProps<{
studyId: string;
currentPermissions?: Record<string, Record<string, boolean>>;
}>();
const emit = defineEmits<{
applied: [permissions: Record<string, Record<string, { allowed: boolean }>>];
}>();
const templates = ref<PermissionTemplate[]>([]);
const selectedId = ref<string | null>(null);
const selected = computed(() => templates.value.find((t) => t.id === selectedId.value) ?? null);
const applying = ref(false);
const creating = ref(false);
const showCreateDialog = ref(false);
const createForm = ref({ name: "", description: "" });
const roleIcon = (category: string | null) => {
const icons: Record<string, string> = {
PM: "👑",
CRA: "📋",
PV: "🔍",
MEDICAL_REVIEW: "🏥",
IMP: "📦",
QA: "✅",
};
return icons[category ?? ""] ?? "📄";
};
const countEnabled = (t: PermissionTemplate) => {
let n = 0;
for (const perms of Object.values(t.permissions) as Record<string, boolean>[]) {
n += (Object.values(perms) as boolean[]).filter(Boolean).length;
}
return n;
};
const countDisabled = (t: PermissionTemplate) => {
let n = 0;
for (const perms of Object.values(t.permissions) as Record<string, boolean>[]) {
n += (Object.values(perms) as boolean[]).filter((v) => !v).length;
}
return n;
};
const countRoleEnabled = (perms: Record<string, boolean>) =>
Object.values(perms).filter(Boolean).length;
const countRoleDisabled = (perms: Record<string, boolean>) =>
Object.values(perms).filter((v) => !v).length;
const select = (t: PermissionTemplate) => {
selectedId.value = selectedId.value === t.id ? null : t.id;
};
const applyTemplate = async () => {
if (!selected.value) return;
applying.value = true;
try {
const res = await applyPermissionTemplate(props.studyId, {
template_id: selected.value.id,
override: true,
});
ElMessage.success(`已应用模板「${selected.value.name}`);
emit("applied", res.data.permissions);
selectedId.value = null;
} catch {
ElMessage.error("应用模板失败");
} finally {
applying.value = false;
}
};
const createTemplate = async () => {
if (!createForm.value.name.trim()) {
ElMessage.warning("请输入模板名称");
return;
}
if (!props.currentPermissions) {
ElMessage.warning("当前没有权限配置可保存");
return;
}
creating.value = true;
try {
await createPermissionTemplate({
name: createForm.value.name,
description: createForm.value.description || undefined,
template_type: "CUSTOM",
permissions: props.currentPermissions,
});
ElMessage.success("模板保存成功");
showCreateDialog.value = false;
createForm.value = { name: "", description: "" };
await loadTemplates();
} catch {
ElMessage.error("保存模板失败");
} finally {
creating.value = false;
}
};
const loadTemplates = async () => {
try {
const res = await fetchPermissionTemplates();
templates.value = res.data;
} catch {
ElMessage.error("加载模板失败");
}
};
onMounted(loadTemplates);
</script>
<style scoped>
.template-selector {
padding: 16px 0;
}
.selector-header {
margin-bottom: 16px;
}
.selector-header h3 {
margin: 0 0 4px;
font-size: 15px;
font-weight: 600;
color: #1a1a1a;
}
.selector-desc {
margin: 0;
font-size: 13px;
color: #888;
}
.template-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
gap: 12px;
margin-bottom: 16px;
}
.template-card {
padding: 14px;
border: 1.5px solid #e4e7ed;
border-radius: 8px;
cursor: pointer;
transition: all 0.2s;
background: #fff;
}
.template-card:hover {
border-color: #409eff;
box-shadow: 0 2px 8px rgba(64, 158, 255, 0.15);
}
.template-card.selected {
border-color: #409eff;
background: #ecf5ff;
}
.card-header {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 6px;
}
.card-icon {
font-size: 20px;
line-height: 1;
}
.card-title-wrap {
display: flex;
flex-direction: column;
gap: 3px;
}
.card-name {
font-size: 14px;
font-weight: 600;
color: #303133;
}
.card-desc {
font-size: 12px;
color: #909399;
margin: 0 0 8px;
line-height: 1.4;
}
.card-stats {
display: flex;
gap: 10px;
font-size: 12px;
color: #67c23a;
}
.card-stats .stat {
display: flex;
align-items: center;
gap: 3px;
}
.card-stats .stat.disabled {
color: #f56c6c;
}
.add-card {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 8px;
border-style: dashed;
color: #909399;
font-size: 13px;
min-height: 110px;
}
.add-card:hover {
color: #409eff;
border-color: #409eff;
}
.add-icon {
font-size: 24px;
}
.preview-panel {
border: 1.5px solid #409eff;
border-radius: 8px;
background: #ecf5ff;
padding: 14px 16px;
}
.preview-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 12px;
}
.preview-title {
display: flex;
align-items: center;
gap: 8px;
font-size: 14px;
}
.preview-icon {
font-size: 18px;
}
.preview-desc {
color: #909399;
font-size: 12px;
}
.preview-body {
display: flex;
flex-wrap: wrap;
gap: 10px;
}
.role-preview {
display: flex;
align-items: center;
gap: 8px;
background: #fff;
border-radius: 6px;
padding: 6px 10px;
}
.role-label {
font-size: 13px;
font-weight: 600;
color: #303133;
min-width: 60px;
}
.perm-summary {
display: flex;
gap: 6px;
}
.slide-up-enter-active,
.slide-up-leave-active {
transition: all 0.25s ease;
}
.slide-up-enter-from,
.slide-up-leave-to {
opacity: 0;
transform: translateY(8px);
}
</style>
@@ -1,95 +0,0 @@
<template>
<div class="module-permissions">
<div class="permissions-table-wrapper">
<el-table :data="tableData" border stripe>
<el-table-column prop="role" label="角色" width="120" />
<el-table-column
v-for="module in modules"
:key="module.key"
:label="module.label"
:width="120"
>
<template #default="{ row }">
<div class="permission-cell">
<el-checkbox
v-model="row.permissions[module.key].read"
label="读"
size="small"
@change="onPermissionChange"
/>
<el-checkbox
v-if="module.writable !== false"
v-model="row.permissions[module.key].write"
label="写"
size="small"
@change="onPermissionChange"
/>
</div>
</template>
</el-table-column>
</el-table>
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from "vue";
import type { ProjectRolePermissionsResponse, ProjectPermissionModule } from "@/types/api";
interface Props {
project: any;
matrix: ProjectRolePermissionsResponse | null;
}
interface Emits {
(e: "update", matrix: ProjectRolePermissionsResponse): void;
}
const props = defineProps<Props>();
const emit = defineEmits<Emits>();
const modules = computed(() => {
if (!props.matrix) return [];
return props.matrix.modules || [];
});
const tableData = computed(() => {
if (!props.matrix) return [];
return Object.entries(props.matrix.roles).map(([role, permissions]) => ({
role,
permissions,
}));
});
const onPermissionChange = () => {
if (!props.matrix) return;
const updatedMatrix: ProjectRolePermissionsResponse = {
modules: props.matrix.modules,
roles: { ...props.matrix.roles },
};
emit("update", updatedMatrix);
};
</script>
<style scoped lang="scss">
.module-permissions {
padding: 20px 0;
}
.permissions-table-wrapper {
overflow-x: auto;
}
.permission-cell {
display: flex;
gap: 10px;
align-items: center;
:deep(.el-checkbox) {
margin: 0;
}
}
</style>
+7 -13
View File
@@ -4,7 +4,7 @@ import { useStudyStore } from "../store/study";
import { isSystemAdmin } from "../utils/roles"; import { isSystemAdmin } from "../utils/roles";
import { findFirstAccessibleProjectPath, getProjectRoutePermission, hasProjectPermission } from "../utils/projectRoutePermissions"; import { findFirstAccessibleProjectPath, getProjectRoutePermission, hasProjectPermission } from "../utils/projectRoutePermissions";
import { fetchStudyDetail } from "../api/studies"; import { fetchStudyDetail } from "../api/studies";
import { fetchProjectRolePermissions } from "../api/projectPermissions"; import { fetchApiEndpointPermissions } from "../api/projectPermissions";
import Layout from "../components/Layout.vue"; import Layout from "../components/Layout.vue";
import Login from "../views/Login.vue"; import Login from "../views/Login.vue";
import Register from "../views/Register.vue"; import Register from "../views/Register.vue";
@@ -19,7 +19,6 @@ import AdminProjects from "../views/admin/Projects.vue";
import ProjectMembers from "../views/admin/ProjectMembers.vue"; import ProjectMembers from "../views/admin/ProjectMembers.vue";
import AdminSites from "../views/admin/Sites.vue"; import AdminSites from "../views/admin/Sites.vue";
import ProjectDetail from "../views/admin/ProjectDetail.vue"; import ProjectDetail from "../views/admin/ProjectDetail.vue";
import ProjectPermissions from "../views/admin/ProjectPermissions.vue";
import ApiPermissions from "../views/admin/ApiPermissions.vue"; import ApiPermissions from "../views/admin/ApiPermissions.vue";
import ProfileSettings from "../views/ProfileSettings.vue"; import ProfileSettings from "../views/ProfileSettings.vue";
import ProjectOverview from "../views/ia/ProjectOverview.vue"; import ProjectOverview from "../views/ia/ProjectOverview.vue";
@@ -442,12 +441,6 @@ const routes: RouteRecordRaw[] = [
component: ProjectMembers, component: ProjectMembers,
meta: { title: TEXT.modules.adminProjectMembers.memberLabel, adminProjectPermission: { module: "project_members", action: "read" } }, meta: { title: TEXT.modules.adminProjectMembers.memberLabel, adminProjectPermission: { module: "project_members", action: "read" } },
}, },
{
path: "projects/:projectId/permissions",
name: "AdminProjectPermissions",
component: ProjectPermissions,
meta: { title: "权限管理", adminProjectPermission: { module: "project_members", action: "write" } },
},
{ {
path: "projects/:id/api-permissions", path: "projects/:id/api-permissions",
name: "AdminApiPermissions", name: "AdminApiPermissions",
@@ -489,7 +482,7 @@ const ensureAdminProjectAccess = async (to: any, isAdmin: boolean) => {
if (routeProjectId && studyStore.currentStudy?.id !== routeProjectId) { if (routeProjectId && studyStore.currentStudy?.id !== routeProjectId) {
const [{ data: project }, { data: permissions }] = await Promise.all([ const [{ data: project }, { data: permissions }] = await Promise.all([
fetchStudyDetail(routeProjectId), fetchStudyDetail(routeProjectId),
fetchProjectRolePermissions(routeProjectId), fetchApiEndpointPermissions(routeProjectId),
]); ]);
studyStore.setCurrentStudy(project); studyStore.setCurrentStudy(project);
studyStore.currentPermissions = permissions; studyStore.currentPermissions = permissions;
@@ -499,7 +492,8 @@ const ensureAdminProjectAccess = async (to: any, isAdmin: boolean) => {
const role = studyStore.currentStudyRole || (studyStore.currentStudy as any)?.role_in_study || ""; const role = studyStore.currentStudyRole || (studyStore.currentStudy as any)?.role_in_study || "";
if (role !== "PM") return false; if (role !== "PM") return false;
return !!studyStore.currentPermissions?.roles?.[role]?.[permission.module]?.[permission.action]; // 管理员项目权限检查:PM 角色默认有访问权限
return true;
}; };
router.beforeEach(async (to, _from, next) => { router.beforeEach(async (to, _from, next) => {
@@ -567,7 +561,7 @@ router.beforeEach(async (to, _from, next) => {
await studyStore.loadCurrentStudyPermissions().catch(() => {}); await studyStore.loadCurrentStudyPermissions().catch(() => {});
} }
const role = studyStore.currentStudyRole || (studyStore.currentStudy as any)?.role_in_study || ""; const role = studyStore.currentStudyRole || (studyStore.currentStudy as any)?.role_in_study || "";
const canReadAudit = !!studyStore.currentPermissions?.roles?.[role]?.audit_export?.read; const canReadAudit = !!studyStore.currentPermissions?.[role]?.["audit_logs:read"]?.allowed;
if (!(isAdmin || (role === "PM" && canReadAudit))) { if (!(isAdmin || (role === "PM" && canReadAudit))) {
next({ path: "/project/overview" }); next({ path: "/project/overview" });
return; return;
@@ -583,9 +577,9 @@ router.beforeEach(async (to, _from, next) => {
} }
const projectRole = studyStore.currentStudyRole || (studyStore.currentStudy as any)?.role_in_study || ""; const projectRole = studyStore.currentStudyRole || (studyStore.currentStudy as any)?.role_in_study || "";
const permission = getProjectRoutePermission(to.path); const permission = getProjectRoutePermission(to.path);
const canAccessProjectRoute = hasProjectPermission(studyStore.currentPermissions?.roles, projectRole, permission, isAdmin); const canAccessProjectRoute = hasProjectPermission(studyStore.currentPermissions, projectRole, permission, isAdmin);
if (!canAccessProjectRoute) { if (!canAccessProjectRoute) {
const fallback = findFirstAccessibleProjectPath(studyStore.currentPermissions?.roles, projectRole, isAdmin); const fallback = findFirstAccessibleProjectPath(studyStore.currentPermissions, projectRole, isAdmin);
next({ path: fallback || "/workbench" }); next({ path: fallback || "/workbench" });
return; return;
} }
+2 -2
View File
@@ -1,7 +1,7 @@
import { defineStore } from "pinia"; import { defineStore } from "pinia";
import { ref } from "vue"; import { ref } from "vue";
import { fetchStudies } from "../api/studies"; import { fetchStudies } from "../api/studies";
import { fetchProjectRolePermissions } from "../api/projectPermissions"; import { fetchApiEndpointPermissions } from "../api/projectPermissions";
import type { Study } from "../types/api"; import type { Study } from "../types/api";
const STUDY_KEY = "ctms_current_study"; const STUDY_KEY = "ctms_current_study";
@@ -165,7 +165,7 @@ export const useStudyStore = defineStore("study", () => {
currentPermissions.value = null; currentPermissions.value = null;
return null; return null;
} }
const { data } = await fetchProjectRolePermissions(currentStudy.value.id); const { data } = await fetchApiEndpointPermissions(currentStudy.value.id);
currentPermissions.value = data; currentPermissions.value = data;
return data; return data;
}; };
-23
View File
@@ -130,29 +130,6 @@ export interface Site {
created_at?: string; created_at?: string;
} }
export type ProjectPermissionActionState = {
read: boolean;
write: boolean;
};
export type ProjectPermissionMatrix = Record<UserRole, Record<string, ProjectPermissionActionState>>;
export interface ProjectPermissionModule {
key: string;
label: string;
description?: string | null;
writable?: boolean;
}
export interface ProjectRolePermissionsResponse {
modules: ProjectPermissionModule[];
roles: ProjectPermissionMatrix;
}
export interface ProjectRolePermissionsUpdate {
roles: ProjectPermissionMatrix;
}
// 接口级权限 // 接口级权限
export interface ApiEndpointPermissionsResponse { export interface ApiEndpointPermissionsResponse {
[role: string]: { [role: string]: {
+20 -22
View File
@@ -1,24 +1,23 @@
import type { ProjectPermissionMatrix, UserRole } from "../types/api"; import type { ApiEndpointPermissionsResponse } from "../types/api";
export type ProjectRoutePermission = { export type ProjectRoutePermission = {
module: string; operationKey: string;
action: "read" | "write";
}; };
const routePermissions: Array<{ prefixes: string[]; permission: ProjectRoutePermission }> = [ const routePermissions: Array<{ prefixes: string[]; permission: ProjectRoutePermission }> = [
{ prefixes: ["/project/overview"], permission: { module: "project_overview", action: "read" } }, { prefixes: ["/project/overview"], permission: { operationKey: "project_overview:read" } },
{ prefixes: ["/project/milestones"], permission: { module: "project_milestones", action: "read" } }, { prefixes: ["/project/milestones"], permission: { operationKey: "project_milestones:read" } },
{ prefixes: ["/fees/contracts", "/finance/contracts"], permission: { module: "fees", action: "read" } }, { prefixes: ["/fees/contracts", "/finance/contracts"], permission: { operationKey: "fees_contracts:read" } },
{ prefixes: ["/drug/shipments", "/materials/equipment"], permission: { module: "materials", action: "read" } }, { prefixes: ["/drug/shipments", "/materials/equipment"], permission: { operationKey: "material_equipments:read" } },
{ prefixes: ["/file-versions", "/trial/", "/documents/"], permission: { module: "file_versions", action: "read" } }, { prefixes: ["/file-versions", "/trial/", "/documents/"], permission: { operationKey: "documents:read" } },
{ prefixes: ["/startup/feasibility-ethics", "/startup/feasibility", "/startup/ethics"], permission: { module: "startup_ethics", action: "read" } }, { prefixes: ["/startup/feasibility-ethics", "/startup/feasibility", "/startup/ethics"], permission: { operationKey: "startup_ethics:read" } },
{ prefixes: ["/startup/meeting-auth", "/startup/kickoff", "/startup/training"], permission: { module: "startup_auth", action: "read" } }, { prefixes: ["/startup/meeting-auth", "/startup/kickoff", "/startup/training"], permission: { operationKey: "startup_auth:read" } },
{ prefixes: ["/subjects"], permission: { module: "subjects", action: "read" } }, { prefixes: ["/subjects"], permission: { operationKey: "subjects:read" } },
{ prefixes: ["/risk-issues"], permission: { module: "risk_issues", action: "read" } }, { prefixes: ["/risk-issues"], permission: { operationKey: "risk_issues:read" } },
{ prefixes: ["/monitoring-audit", "/monitoring", "/audit"], permission: { module: "monitoring_audit", action: "read" } }, { prefixes: ["/monitoring-audit", "/monitoring", "/audit"], permission: { operationKey: "monitoring_audit:read" } },
{ prefixes: ["/etmf"], permission: { module: "etmf", action: "read" } }, { prefixes: ["/etmf"], permission: { operationKey: "documents:read" } },
{ prefixes: ["/knowledge/medical-consult"], permission: { module: "faq", action: "read" } }, { prefixes: ["/knowledge/medical-consult"], permission: { operationKey: "faq:read" } },
{ prefixes: ["/knowledge/notes", "/knowledge/support-files", "/knowledge/instruction-files"], permission: { module: "shared_library", action: "read" } }, { prefixes: ["/knowledge/notes", "/knowledge/support-files", "/knowledge/instruction-files"], permission: { operationKey: "knowledge_notes:read" } },
]; ];
export const projectRouteLandingPaths = [ export const projectRouteLandingPaths = [
@@ -51,19 +50,18 @@ export const getProjectRoutePermission = (path: string): ProjectRoutePermission
}; };
export const hasProjectPermission = ( export const hasProjectPermission = (
matrix: ProjectPermissionMatrix | null | undefined, permissions: ApiEndpointPermissionsResponse | null | undefined,
role: string | null | undefined, role: string | null | undefined,
permission: ProjectRoutePermission | null, permission: ProjectRoutePermission | null,
isAdmin: boolean, isAdmin: boolean,
) => { ) => {
if (!permission || isAdmin) return true; if (!permission || isAdmin) return true;
if (!role) return false; if (!role || !permissions) return false;
const typedRole = role as UserRole; return !!permissions[role]?.[permission.operationKey]?.allowed;
return !!matrix?.[typedRole]?.[permission.module]?.[permission.action];
}; };
export const findFirstAccessibleProjectPath = ( export const findFirstAccessibleProjectPath = (
matrix: ProjectPermissionMatrix | null | undefined, permissions: ApiEndpointPermissionsResponse | null | undefined,
role: string | null | undefined, role: string | null | undefined,
isAdmin: boolean, isAdmin: boolean,
) => projectRouteLandingPaths.find((path) => hasProjectPermission(matrix, role, getProjectRoutePermission(path), isAdmin)) || null; ) => projectRouteLandingPaths.find((path) => hasProjectPermission(permissions, role, getProjectRoutePermission(path), isAdmin)) || null;
@@ -1,4 +1,4 @@
import { describe, it, expect, beforeEach, vi } from "vitest"; import { describe, it, expect, beforeEach } from "vitest";
import { mount } from "@vue/test-utils"; import { mount } from "@vue/test-utils";
import ApiPermissions from "@/views/admin/ApiPermissions.vue"; import ApiPermissions from "@/views/admin/ApiPermissions.vue";
import { createPinia, setActivePinia } from "pinia"; import { createPinia, setActivePinia } from "pinia";
@@ -12,7 +12,6 @@ describe("ApiPermissions.vue", () => {
const wrapper = mount(ApiPermissions, { const wrapper = mount(ApiPermissions, {
global: { global: {
stubs: { stubs: {
ProjectPermissionsModule: true,
ApiEndpointPermissions: true, ApiEndpointPermissions: true,
PermissionMonitoring: true, PermissionMonitoring: true,
}, },
@@ -23,11 +22,10 @@ describe("ApiPermissions.vue", () => {
expect(wrapper.find(".permission-title h2").text()).toBe("权限管理"); expect(wrapper.find(".permission-title h2").text()).toBe("权限管理");
}); });
it("renders three tabs", () => { it("renders tabs", () => {
const wrapper = mount(ApiPermissions, { const wrapper = mount(ApiPermissions, {
global: { global: {
stubs: { stubs: {
ProjectPermissionsModule: true,
ApiEndpointPermissions: true, ApiEndpointPermissions: true,
PermissionMonitoring: true, PermissionMonitoring: true,
}, },
@@ -35,14 +33,13 @@ describe("ApiPermissions.vue", () => {
}); });
const tabs = wrapper.findAll(".el-tabs__nav-item"); const tabs = wrapper.findAll(".el-tabs__nav-item");
expect(tabs.length).toBeGreaterThanOrEqual(3); expect(tabs.length).toBeGreaterThanOrEqual(2);
}); });
it("has save button disabled when not dirty", () => { it("has save button disabled when not dirty", () => {
const wrapper = mount(ApiPermissions, { const wrapper = mount(ApiPermissions, {
global: { global: {
stubs: { stubs: {
ProjectPermissionsModule: true,
ApiEndpointPermissions: true, ApiEndpointPermissions: true,
PermissionMonitoring: true, PermissionMonitoring: true,
}, },
+32 -38
View File
@@ -30,19 +30,16 @@
</div> </div>
</div> </div>
<!-- 标签页模块级权限 vs 接口级权限 --> <!-- 标签页 -->
<el-tabs v-model="activeTab"> <el-tabs v-model="activeTab">
<!-- 标签1模块级权限现有 --> <!-- 接口级权限 -->
<el-tab-pane label="模块级权限" name="module">
<ProjectPermissionsModule
:project="project"
:matrix="moduleMatrix"
@update="onModuleMatrixUpdate"
/>
</el-tab-pane>
<!-- 标签2接口级权限新增 -->
<el-tab-pane label="接口级权限" name="api"> <el-tab-pane label="接口级权限" name="api">
<PermissionTemplateSelector
:study-id="studyId"
:current-permissions="currentPermissionsForTemplate"
@applied="onTemplateApplied"
/>
<el-divider />
<ApiEndpointPermissions <ApiEndpointPermissions
:project="project" :project="project"
:matrix="apiMatrix" :matrix="apiMatrix"
@@ -50,7 +47,7 @@
/> />
</el-tab-pane> </el-tab-pane>
<!-- 标签3权限监控新增 --> <!-- 权限监控 -->
<el-tab-pane label="权限监控" name="monitoring"> <el-tab-pane label="权限监控" name="monitoring">
<PermissionMonitoring <PermissionMonitoring
:metrics="metrics" :metrics="metrics"
@@ -69,9 +66,7 @@ import { ref, computed, onMounted } from "vue";
import { useRoute } from "vue-router"; import { useRoute } from "vue-router";
import { ElMessage } from "element-plus"; import { ElMessage } from "element-plus";
import { Key, RefreshRight, Check } from "@element-plus/icons-vue"; import { Key, RefreshRight, Check } from "@element-plus/icons-vue";
import type { Study } from "@/types/api";
import type { import type {
ProjectRolePermissionsResponse,
ApiEndpointPermissionsResponse, ApiEndpointPermissionsResponse,
PermissionMetricsResponse, PermissionMetricsResponse,
CacheStatsResponse, CacheStatsResponse,
@@ -79,8 +74,6 @@ import type {
HealthResponse, HealthResponse,
} from "@/types/api"; } from "@/types/api";
import { import {
fetchProjectRolePermissions,
updateProjectRolePermissions,
fetchApiEndpointPermissions, fetchApiEndpointPermissions,
updateApiEndpointPermissions, updateApiEndpointPermissions,
fetchPermissionMetrics, fetchPermissionMetrics,
@@ -90,14 +83,14 @@ import {
resetPermissionMetrics, resetPermissionMetrics,
} from "@/api/projectPermissions"; } from "@/api/projectPermissions";
import { useStudyStore } from "@/store/study"; import { useStudyStore } from "@/store/study";
import ProjectPermissionsModule from "@/components/ProjectPermissionsModule.vue";
import ApiEndpointPermissions from "@/components/ApiEndpointPermissions.vue"; import ApiEndpointPermissions from "@/components/ApiEndpointPermissions.vue";
import PermissionMonitoring from "@/components/PermissionMonitoring.vue"; import PermissionMonitoring from "@/components/PermissionMonitoring.vue";
import PermissionTemplateSelector from "@/components/PermissionTemplateSelector.vue";
const route = useRoute(); const route = useRoute();
const studyStore = useStudyStore(); const studyStore = useStudyStore();
const activeTab = ref<"module" | "api" | "monitoring">("module"); const activeTab = ref<"api" | "monitoring">("api");
const loading = ref(false); const loading = ref(false);
const saving = ref(false); const saving = ref(false);
const resetting = ref(false); const resetting = ref(false);
@@ -109,7 +102,6 @@ const studyId = computed(() => {
}); });
// //
const moduleMatrix = ref<ProjectRolePermissionsResponse | null>(null);
const apiMatrix = ref<ApiEndpointPermissionsResponse | null>(null); const apiMatrix = ref<ApiEndpointPermissionsResponse | null>(null);
// //
@@ -125,12 +117,7 @@ const loadPermissionData = async () => {
loading.value = true; loading.value = true;
try { try {
const [moduleRes, apiRes] = await Promise.all([ const apiRes = await fetchApiEndpointPermissions(studyId.value);
fetchProjectRolePermissions(studyId.value),
fetchApiEndpointPermissions(studyId.value),
]);
moduleMatrix.value = moduleRes.data;
apiMatrix.value = apiRes.data; apiMatrix.value = apiRes.data;
dirty.value = false; dirty.value = false;
} catch (error) { } catch (error) {
@@ -160,28 +147,36 @@ const loadMonitoringData = async () => {
} }
}; };
const onModuleMatrixUpdate = (newMatrix: ProjectRolePermissionsResponse) => {
moduleMatrix.value = newMatrix;
dirty.value = true;
};
const onApiMatrixUpdate = (newMatrix: ApiEndpointPermissionsResponse) => { const onApiMatrixUpdate = (newMatrix: ApiEndpointPermissionsResponse) => {
apiMatrix.value = newMatrix; apiMatrix.value = newMatrix;
dirty.value = true; dirty.value = true;
}; };
// apiMatrix {role: {endpoint_key: bool}}
const currentPermissionsForTemplate = computed(() => {
if (!apiMatrix.value) return undefined;
const result: Record<string, Record<string, boolean>> = {};
for (const [role, endpoints] of Object.entries(apiMatrix.value as Record<string, Record<string, { allowed: boolean }>>)) {
result[role] = {};
for (const [key, val] of Object.entries(endpoints)) {
result[role][key] = val.allowed;
}
}
return result;
});
const onTemplateApplied = async (permissions: Record<string, Record<string, { allowed: boolean }>>) => {
//
await loadPermissionData();
ElMessage.success("权限已更新");
};
const save = async () => { const save = async () => {
if (!studyId.value || !dirty.value) return; if (!studyId.value || !dirty.value) return;
saving.value = true; saving.value = true;
try { try {
// if (apiMatrix.value) {
if (activeTab.value === "module" && moduleMatrix.value) {
const res = await updateProjectRolePermissions(studyId.value, {
roles: moduleMatrix.value.roles,
});
moduleMatrix.value = res.data;
} else if (activeTab.value === "api" && apiMatrix.value) {
const res = await updateApiEndpointPermissions(studyId.value, apiMatrix.value); const res = await updateApiEndpointPermissions(studyId.value, apiMatrix.value);
apiMatrix.value = res.data; apiMatrix.value = res.data;
} }
@@ -189,7 +184,6 @@ const save = async () => {
dirty.value = false; dirty.value = false;
ElMessage.success("权限已保存"); ElMessage.success("权限已保存");
//
await loadPermissionData(); await loadPermissionData();
} catch (error) { } catch (error) {
ElMessage.error("保存权限失败"); ElMessage.error("保存权限失败");
+4 -4
View File
@@ -167,7 +167,7 @@ import { useRouter } from "vue-router";
import { ElMessage, ElMessageBox } from "element-plus"; import { ElMessage, ElMessageBox } from "element-plus";
import { ArrowDown } from "@element-plus/icons-vue"; import { ArrowDown } from "@element-plus/icons-vue";
import { createAuditEvent, fetchAuditLogs } from "../../api/auditLogs"; import { createAuditEvent, fetchAuditLogs } from "../../api/auditLogs";
import { fetchProjectRolePermissions } from "../../api/projectPermissions"; import { fetchApiEndpointPermissions } from "../../api/projectPermissions";
import { fetchUsers } from "../../api/users"; import { fetchUsers } from "../../api/users";
import { listMembers } from "../../api/members"; import { listMembers } from "../../api/members";
import { auditDict, normalizeAuditEvent } from "../../audit"; import { auditDict, normalizeAuditEvent } from "../../audit";
@@ -216,11 +216,11 @@ const isAdmin = computed(() => isSystemAdmin(auth.user));
const projectRole = computed(() => getProjectRole(study.currentStudy, study.currentStudyRole)); const projectRole = computed(() => getProjectRole(study.currentStudy, study.currentStudyRole));
const canProjectExport = computed(() => { const canProjectExport = computed(() => {
if (isAdmin.value) return true; if (isAdmin.value) return true;
return projectRole.value === "PM" && !!permissionMatrix.value?.roles?.[projectRole.value]?.audit_export?.read; return projectRole.value === "PM" && !!permissionMatrix.value?.[projectRole.value]?.["audit_logs:read"]?.allowed;
}); });
const canAccessAuditLogs = computed(() => { const canAccessAuditLogs = computed(() => {
if (isAdmin.value) return true; if (isAdmin.value) return true;
return projectRole.value === "PM" && !!permissionMatrix.value?.roles?.[projectRole.value]?.audit_export?.read; return projectRole.value === "PM" && !!permissionMatrix.value?.[projectRole.value]?.["audit_logs:read"]?.allowed;
}); });
const ensureAccess = () => { const ensureAccess = () => {
@@ -231,7 +231,7 @@ const ensureAccess = () => {
const loadPermissionMatrix = async () => { const loadPermissionMatrix = async () => {
if (!study.currentStudy) return; if (!study.currentStudy) return;
const { data } = await fetchProjectRolePermissions(study.currentStudy.id); const { data } = await fetchApiEndpointPermissions(study.currentStudy.id);
permissionMatrix.value = data; permissionMatrix.value = data;
}; };
@@ -1,17 +0,0 @@
import { describe, expect, it } from "vitest";
import { readFileSync } from "node:fs";
import { resolve } from "node:path";
const readProjectPermissions = () => readFileSync(resolve(__dirname, "./ProjectPermissions.vue"), "utf8");
describe("project permissions matrix", () => {
it("locks management backend permissions to admin and PM roles", () => {
const source = readProjectPermissions();
expect(source).toContain('new Set(["project_members", "sites", "audit_export"])');
expect(source).toContain('role === "PM" || role === "ADMIN"');
expect(source).toContain('"MEDICAL_REVIEW"');
expect(source).toContain("!canConfigureAdminModule(role, mod.key)");
expect(source).toContain("!canConfigureAdminModule(role, moduleKey)");
});
});
@@ -1,480 +0,0 @@
<template>
<div class="permission-page">
<div class="permission-shell unified-shell" v-loading="loading">
<div class="permission-header unified-action-bar">
<div class="permission-title">
<div class="title-icon">
<el-icon><Key /></el-icon>
</div>
<div>
<h2>权限管理</h2>
</div>
</div>
<div class="permission-project-meta">
<span class="meta-item">
<span class="meta-label">项目编号</span>
<strong>{{ project?.code || "-" }}</strong>
</span>
<span class="meta-separator" />
<span class="meta-item meta-item--name">
<span class="meta-label">项目名称</span>
<strong>{{ project?.name || "-" }}</strong>
</span>
</div>
<div class="permission-actions">
<el-button type="primary" :loading="saving" :disabled="!dirty" @click="save">
<el-icon><Check /></el-icon>
保存
</el-button>
</div>
</div>
<section class="permission-matrix unified-section">
<div class="matrix-scroll">
<div class="matrix-grid" :style="{ '--module-count': modules.length }">
<div class="matrix-head matrix-role-head">角色</div>
<div v-for="mod in modules" :key="mod.key" class="matrix-head module-head" :class="{ 'is-admin-module': isAdminModule(mod.key) }">
<span>{{ mod.label }}</span>
</div>
<template v-for="role in roleOrder" :key="role">
<div class="role-cell">
<span class="role-badge" :class="`role-badge--${role.toLowerCase()}`">
{{ roleLabel(role) }}
</span>
</div>
<div v-for="mod in modules" :key="`${role}-${mod.key}`" class="permission-cell" :class="{ 'is-admin-module': isAdminModule(mod.key), 'is-readonly-module': !isWriteSupported(mod) }">
<label class="perm-check" :class="{ 'is-checked': matrix[role]?.[mod.key]?.read, 'is-disabled': isReadLocked(role, mod) }">
<input
type="checkbox"
:disabled="isReadLocked(role, mod)"
:checked="!!matrix[role]?.[mod.key]?.read"
@change="setPermission(role, mod.key, 'read', ($event.target as HTMLInputElement).checked)"
/>
<span class="check-box" />
<span></span>
</label>
<label v-if="isWriteSupported(mod)" class="perm-check" :class="{ 'is-checked': matrix[role]?.[mod.key]?.write, 'is-disabled': isWriteLocked(role, mod) }">
<input
type="checkbox"
:disabled="isWriteLocked(role, mod)"
:checked="!!matrix[role]?.[mod.key]?.write"
@change="setPermission(role, mod.key, 'write', ($event.target as HTMLInputElement).checked)"
/>
<span class="check-box" />
<span></span>
</label>
</div>
</template>
</div>
</div>
</section>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, ref } from "vue";
import { useRoute } from "vue-router";
import { ElMessage } from "element-plus";
import { Check, Key } from "@element-plus/icons-vue";
import { fetchStudyDetail } from "../../api/studies";
import { fetchProjectRolePermissions, updateProjectRolePermissions } from "../../api/projectPermissions";
import type { ProjectPermissionActionState, ProjectPermissionMatrix, ProjectPermissionModule, Study, UserRole } from "../../types/api";
import { displayEnum } from "../../utils/display";
import { TEXT } from "../../locales";
const route = useRoute();
const projectId = computed(() => route.params.projectId as string);
const roleOrder: UserRole[] = ["ADMIN", "PM", "CRA", "PV", "MEDICAL_REVIEW", "IMP", "QA"];
const loading = ref(false);
const saving = ref(false);
const project = ref<Study | null>(null);
const modules = ref<ProjectPermissionModule[]>([]);
const matrix = ref<ProjectPermissionMatrix>({} as ProjectPermissionMatrix);
const initialSnapshot = ref("");
const roleLabel = (role: UserRole) => displayEnum(TEXT.enums.userRole, role);
const adminModuleKeys = new Set(["project_members", "sites", "audit_export"]);
const isAdminModule = (moduleKey: string) => adminModuleKeys.has(moduleKey);
const canConfigureAdminModule = (role: UserRole, moduleKey: string) => !isAdminModule(moduleKey) || role === "PM" || role === "ADMIN";
const isWriteSupported = (mod: ProjectPermissionModule) => mod.writable !== false;
const findModule = (moduleKey: string) => modules.value.find((mod) => mod.key === moduleKey);
const isReadLocked = (role: UserRole, mod?: ProjectPermissionModule) => role === "ADMIN" || (mod ? !canConfigureAdminModule(role, mod.key) : false);
const isWriteLocked = (role: UserRole, mod: ProjectPermissionModule) => role === "ADMIN" || !isWriteSupported(mod) || !canConfigureAdminModule(role, mod.key);
const normalizeMatrix = (source: ProjectPermissionMatrix): ProjectPermissionMatrix => {
const normalized = {} as ProjectPermissionMatrix;
for (const role of roleOrder) {
normalized[role] = {};
for (const mod of modules.value) {
const item = source?.[role]?.[mod.key] || { read: false, write: false };
const canWrite = isWriteSupported(mod);
const canConfigure = canConfigureAdminModule(role, mod.key);
const write = role === "ADMIN" ? canWrite : canConfigure && canWrite ? !!item.write : false;
const read = role === "ADMIN" ? true : canConfigure && (!!item.read || write);
normalized[role][mod.key] = { read, write };
}
}
return normalized;
};
const snapshot = (source: ProjectPermissionMatrix) => JSON.stringify(normalizeMatrix(source));
const dirty = computed(() => snapshot(matrix.value) !== initialSnapshot.value);
const ensureCell = (role: UserRole, moduleKey: string): ProjectPermissionActionState => {
if (!matrix.value[role]) matrix.value[role] = {};
if (!matrix.value[role][moduleKey]) matrix.value[role][moduleKey] = { read: false, write: false };
return matrix.value[role][moduleKey];
};
const setPermission = (role: UserRole, moduleKey: string, action: "read" | "write", value: boolean) => {
const mod = findModule(moduleKey);
if (role === "ADMIN" || !mod || !canConfigureAdminModule(role, moduleKey) || (action === "write" && !isWriteSupported(mod))) return;
const cell = ensureCell(role, moduleKey);
cell[action] = value;
if (action === "write" && value) cell.read = true;
if (action === "read" && !value) cell.write = false;
};
const load = async () => {
if (!projectId.value) return;
loading.value = true;
try {
const [projectResp, permissionResp] = await Promise.all([
fetchStudyDetail(projectId.value),
fetchProjectRolePermissions(projectId.value),
]);
project.value = projectResp.data;
modules.value = permissionResp.data.modules;
matrix.value = normalizeMatrix(permissionResp.data.roles);
initialSnapshot.value = snapshot(matrix.value);
} catch (error: any) {
ElMessage.error(error?.response?.data?.detail || "加载权限失败");
} finally {
loading.value = false;
}
};
const save = async () => {
if (!projectId.value) return;
saving.value = true;
try {
const payload = { roles: normalizeMatrix(matrix.value) };
const { data } = await updateProjectRolePermissions(projectId.value, payload);
modules.value = data.modules;
matrix.value = normalizeMatrix(data.roles);
initialSnapshot.value = snapshot(matrix.value);
ElMessage.success("权限已保存");
} catch (error: any) {
ElMessage.error(error?.response?.data?.detail || "保存权限失败");
} finally {
saving.value = false;
}
};
onMounted(load);
</script>
<style scoped>
.permission-page {
display: flex;
flex-direction: column;
}
.permission-shell {
background: transparent;
}
.permission-header {
display: grid;
grid-template-columns: minmax(220px, 1fr) auto minmax(220px, 1fr);
align-items: center;
justify-content: space-between;
min-height: 52px;
padding-top: 10px;
padding-bottom: 10px;
}
.permission-title {
display: flex;
align-items: center;
gap: 10px;
}
.title-icon {
display: grid;
place-items: center;
width: 34px;
height: 34px;
border-radius: 8px;
color: #0f766e;
background: #e7f8f5;
border: 1px solid #b9ebe2;
}
.permission-title h2 {
margin: 0;
font-size: 18px;
line-height: 1.2;
color: #172033;
}
.permission-project-meta {
display: flex;
align-items: center;
gap: 14px;
max-width: min(720px, 48vw);
color: #172033;
white-space: nowrap;
justify-self: center;
}
.meta-item {
display: inline-flex;
align-items: center;
gap: 8px;
min-width: 0;
}
.meta-item--name strong {
overflow: hidden;
text-overflow: ellipsis;
}
.meta-label {
color: #64748b;
font-size: 12px;
font-weight: 600;
}
.meta-item strong {
min-width: 0;
font-size: 14px;
font-weight: 800;
}
.meta-separator {
width: 1px;
height: 18px;
background: #dbe3ee;
}
.permission-actions {
display: flex;
align-items: center;
gap: 8px;
margin-left: auto;
justify-content: flex-end;
justify-self: end;
}
.permission-actions :deep(.el-button) {
height: 30px;
padding: 0 12px;
font-size: 12px;
}
.permission-matrix {
padding: 0;
overflow: hidden;
}
.matrix-scroll {
overflow-x: auto;
}
.matrix-grid {
--module-count: 6;
display: grid;
grid-template-columns: 108px repeat(var(--module-count), minmax(82px, 1fr));
min-width: 1320px;
border-top: 1px solid #e6ebf2;
border-left: 1px solid #e6ebf2;
}
.matrix-head,
.role-cell,
.permission-cell {
border-right: 1px solid #e6ebf2;
border-bottom: 1px solid #e6ebf2;
background: #fff;
}
.matrix-head {
min-height: 46px;
padding: 8px 10px;
background: #f8fafc;
color: #334155;
font-weight: 700;
font-size: 13px;
}
.matrix-head.is-admin-module {
position: relative;
background: #fff7ed;
color: #9a3412;
}
.matrix-head.is-admin-module::before {
content: "";
position: absolute;
left: 0;
top: 0;
width: 100%;
height: 3px;
background: #f97316;
}
.matrix-role-head {
display: flex;
align-items: center;
}
.module-head {
display: flex;
flex-direction: column;
justify-content: center;
}
.role-cell {
display: flex;
align-items: center;
padding: 8px 10px;
}
.permission-cell {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 6px;
padding: 7px 8px;
}
.permission-cell.is-admin-module {
background: #fffaf3;
}
.role-badge {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 68px;
padding: 4px 10px;
border-radius: 999px;
font-weight: 700;
font-size: 12px;
}
.role-badge--admin {
color: #1d4ed8;
background: #dbeafe;
}
.role-badge--pm {
color: #047857;
background: #d1fae5;
}
.role-badge--cra {
color: #7c3aed;
background: #ede9fe;
}
.role-badge--pv {
color: #be123c;
background: #ffe4e6;
}
.role-badge--medical_review {
color: #0f766e;
background: #ccfbf1;
}
.role-badge--imp {
color: #b45309;
background: #fef3c7;
}
.role-badge--qa {
color: #475569;
background: #e2e8f0;
}
.perm-check {
position: relative;
display: inline-flex;
align-items: center;
gap: 5px;
min-width: 48px;
color: #64748b;
font-weight: 700;
font-size: 12px;
cursor: pointer;
user-select: none;
}
.perm-check input {
position: absolute;
opacity: 0;
pointer-events: none;
}
.check-box {
width: 15px;
height: 15px;
border-radius: 5px;
border: 1.5px solid #cbd5e1;
background: #fff;
box-shadow: inset 0 0 0 1.5px #fff;
}
.perm-check.is-checked {
color: #1d4ed8;
}
.perm-check.is-checked .check-box {
border-color: #2563eb;
background: #2563eb;
}
.perm-check.is-checked .check-box::after {
content: "";
display: block;
width: 6px;
height: 3px;
margin: 3px auto 0;
border-left: 1.8px solid #fff;
border-bottom: 1.8px solid #fff;
transform: rotate(-45deg);
}
.perm-check.is-disabled {
cursor: not-allowed;
opacity: 0.58;
}
@media (max-width: 860px) {
.permission-header,
.permission-title,
.permission-actions {
align-items: stretch;
}
.permission-header {
flex-direction: column;
gap: 14px;
}
.permission-project-meta {
max-width: none;
white-space: normal;
}
.permission-actions {
margin-left: 0;
}
}
</style>
+1 -1
View File
@@ -33,7 +33,7 @@ describe("project management access", () => {
it("uses each project permission matrix to show project management actions", () => { it("uses each project permission matrix to show project management actions", () => {
const source = readProjects(); const source = readProjects();
expect(source).toContain("fetchProjectRolePermissions(item.id)"); expect(source).toContain("fetchApiEndpointPermissions(item.id)");
expect(source).toContain("permissionsByProject"); expect(source).toContain("permissionsByProject");
expect(source).toContain("const canProject = (row: Study, module: string, action: \"read\" | \"write\")"); expect(source).toContain("const canProject = (row: Study, module: string, action: \"read\" | \"write\")");
expect(source).toContain("canProject(scope.row, 'project_members', 'read')"); expect(source).toContain("canProject(scope.row, 'project_members', 'read')");
+13 -5
View File
@@ -76,8 +76,8 @@ import { useRouter } from "vue-router";
import { ElMessage, ElMessageBox } from "element-plus"; import { ElMessage, ElMessageBox } from "element-plus";
import { User, OfficeBuilding, ArrowRight, Delete, Lock, Key, Document } from "@element-plus/icons-vue"; import { User, OfficeBuilding, ArrowRight, Delete, Lock, Key, Document } from "@element-plus/icons-vue";
import { fetchStudies, deleteStudy, lockStudy } from "../../api/studies"; import { fetchStudies, deleteStudy, lockStudy } from "../../api/studies";
import { fetchProjectRolePermissions } from "../../api/projectPermissions"; import { fetchApiEndpointPermissions } from "../../api/projectPermissions";
import type { ProjectRolePermissionsResponse, Study } from "../../types/api"; import type { ApiEndpointPermissionsResponse, Study } from "../../types/api";
import ProjectForm from "./ProjectForm.vue"; import ProjectForm from "./ProjectForm.vue";
import { useStudyStore } from "../../store/study"; import { useStudyStore } from "../../store/study";
import { useAuthStore } from "../../store/auth"; import { useAuthStore } from "../../store/auth";
@@ -92,7 +92,7 @@ const router = useRouter();
const studyStore = useStudyStore(); const studyStore = useStudyStore();
const auth = useAuthStore(); const auth = useAuthStore();
const isAdmin = computed(() => isSystemAdmin(auth.user)); const isAdmin = computed(() => isSystemAdmin(auth.user));
const permissionsByProject = ref<Record<string, ProjectRolePermissionsResponse>>({}); const permissionsByProject = ref<Record<string, ApiEndpointPermissionsResponse>>({});
const loadProjects = async () => { const loadProjects = async () => {
loading.value = true; loading.value = true;
@@ -104,7 +104,7 @@ const loadProjects = async () => {
const entries = await Promise.all( const entries = await Promise.all(
items.map(async (item: Study) => { items.map(async (item: Study) => {
try { try {
const { data: permissions } = await fetchProjectRolePermissions(item.id); const { data: permissions } = await fetchApiEndpointPermissions(item.id);
return [item.id, permissions] as const; return [item.id, permissions] as const;
} catch { } catch {
return [item.id, null] as const; return [item.id, null] as const;
@@ -126,10 +126,18 @@ const openCreate = () => {
formVisible.value = true; formVisible.value = true;
}; };
const MODULE_TO_OPERATION: Record<string, Record<string, string>> = {
project_members: { read: "project_members:read", write: "project_members:update" },
sites: { read: "sites:read", write: "sites:update" },
audit_export: { read: "audit_logs:read", write: "audit_logs:read" },
};
const canProject = (row: Study, module: string, action: "read" | "write") => { const canProject = (row: Study, module: string, action: "read" | "write") => {
if (isAdmin.value) return true; if (isAdmin.value) return true;
const role = row.role_in_study || ""; const role = row.role_in_study || "";
return !!permissionsByProject.value[row.id]?.roles?.[role as keyof ProjectRolePermissionsResponse["roles"]]?.[module]?.[action]; const operationKey = MODULE_TO_OPERATION[module]?.[action];
if (!operationKey) return false;
return !!permissionsByProject.value[row.id]?.[role]?.[operationKey]?.allowed;
}; };
const goMembers = (row: Study) => { const goMembers = (row: Study) => {