统一项目权限读取模型

1、合并列表与详情读取权限,统一使用 subjects:read、project_members:read、monitoring_issues:read 等业务读取 key。

2、增加历史读取权限 key 的兼容映射,确保存量项目权限覆盖值不会丢失。

3、同步前端权限工具、路由权限与权限管理页面,避免继续引用已合并的旧权限 key。

4、补充项目角色启停保护与抽屉未保存变更守卫,防止停用 PM 或仍有关联成员的角色。
This commit is contained in:
Cheng Zhou
2026-06-04 11:06:03 +08:00
parent 44d69c2d7b
commit ab4f0d93ed
22 changed files with 852 additions and 349 deletions
+176 -64
View File
@@ -60,12 +60,107 @@ def test_all_backend_permission_guards_are_configurable():
assert used_keys <= set(API_ENDPOINT_PERMISSIONS)
def test_read_permissions_do_not_duplicate_list_and_detail_entries():
"""同一业务对象的列表和详情读取能力应在权限矩阵中合并为一个读取权限。"""
merged_legacy_keys = {
"subjects:list",
"subject_aes:list",
"fees_contracts:list",
"project_members:list",
"project_members:candidates",
"sites:list",
"startup_ethics:list",
"startup_initiation:list",
"monitoring_issues:list",
"drug_shipments:list",
"audit_logs:list",
"precautions:list",
"subject_histories:list",
"material_equipments:list",
}
assert merged_legacy_keys.isdisjoint(API_ENDPOINT_PERMISSIONS)
expected_read_keys = {
"subjects:read",
"subject_aes:read",
"fees_contracts:read",
"project_members:read",
"sites:read",
"startup_ethics:read",
"startup_initiation:read",
"monitoring_issues:read",
"drug_shipments:read",
"audit_logs:read",
"precautions:read",
"subject_histories:read",
"material_equipments:read",
}
assert expected_read_keys <= set(API_ENDPOINT_PERMISSIONS)
@pytest.mark.asyncio
async def test_legacy_read_permission_overrides_are_folded_into_canonical_read_key(db_session: AsyncSession):
"""历史项目权限中的列表读取 key 应继续作用到合并后的读取权限。"""
study_id = uuid.uuid4()
db_session.add_all(
(
ApiEndpointPermission(
study_id=study_id,
role="CRA",
endpoint_key="subjects:list",
allowed=False,
),
ApiEndpointPermission(
study_id=study_id,
role="CRA",
endpoint_key="project_members:candidates",
allowed=True,
),
)
)
await db_session.commit()
matrix = await get_api_endpoint_permissions(db_session, study_id)
assert "subjects:list" not in matrix["CRA"]
assert matrix["CRA"]["subjects:read"]["allowed"] is False
assert "project_members:candidates" not in matrix["CRA"]
assert matrix["CRA"]["project_members:read"]["allowed"] is True
def test_backend_read_routes_use_canonical_read_permission_keys():
"""列表接口和详情接口应共用合并后的读取权限 key。"""
api_dir = Path(__file__).resolve().parents[1] / "app" / "api" / "v1"
merged_legacy_keys = {
"subjects:list",
"subject_aes:list",
"fees_contracts:list",
"project_members:list",
"project_members:candidates",
"sites:list",
"startup_ethics:list",
"startup_initiation:list",
"monitoring_issues:list",
"drug_shipments:list",
"audit_logs:list",
"precautions:list",
"subject_histories:list",
"material_equipments:list",
}
source = "\n".join(path.read_text() for path in api_dir.rglob("*.py"))
for endpoint_key in merged_legacy_keys:
assert f'require_api_permission("{endpoint_key}")' not in source
assert f'_ensure_study_access(db, study_id, current_user, "{endpoint_key}")' not in source
def test_subject_history_permissions_are_labeled_as_medical_history_under_subjects():
"""病史记录权限应归入参与者管理,而不是独立的历史模块。"""
expected_descriptions = {
"subject_histories:create": "创建病史记录",
"subject_histories:list": "查询病史记录列表",
"subject_histories:read": "查询病史记录详情",
"subject_histories:read": "查询病史记录",
"subject_histories:update": "更新病史记录",
"subject_histories:delete": "删除病史记录",
}
@@ -84,8 +179,7 @@ def test_contract_fee_permissions_do_not_include_legacy_finance_contracts():
expected_descriptions = {
"fees_contracts:create": "创建合同费用",
"fees_contracts:list": "查询合同费用列表",
"fees_contracts:read": "查询合同费用详情",
"fees_contracts:read": "查询合同费用",
"fees_contracts:update": "更新合同费用",
"fees_contracts:delete": "删除合同费用",
}
@@ -95,46 +189,56 @@ def test_contract_fee_permissions_do_not_include_legacy_finance_contracts():
assert API_ENDPOINT_PERMISSIONS[endpoint_key]["description"] == description
def test_setup_config_delete_version_permission_is_labeled_as_delete():
"""删除立项配置版本应在权限矩阵中展示为删除,而不是写入。"""
permission = API_ENDPOINT_PERMISSIONS["setup_config:delete_version"]
assert permission["module"] == "setup_config"
assert permission["action"] == "delete"
assert permission["description"] == "删除立项配置版本"
def test_generic_attachment_permissions_are_removed_from_matrix():
"""前端和模板迁移后,项目权限矩阵不再暴露通用 attachments:*。"""
assert not any(endpoint_key.startswith("attachments:") for endpoint_key in API_ENDPOINT_PERMISSIONS)
def test_module_attachment_permissions_are_available():
"""模块附件权限应接入权限矩阵。"""
"""附件权限矩阵只保留独立删除项,创建与读取由业务主记录权限控制"""
expected_keys = {
"fees_contracts_attachments:create",
"fees_contracts_attachments:read",
"fees_contracts_attachments:delete",
"startup_initiation_attachments:create",
"startup_initiation_attachments:read",
"startup_initiation_attachments:delete",
"startup_ethics_attachments:create",
"startup_ethics_attachments:read",
"startup_ethics_attachments:delete",
"startup_auth_attachments:create",
"startup_auth_attachments:read",
"startup_auth_attachments:delete",
"drug_shipments_attachments:create",
"drug_shipments_attachments:read",
"drug_shipments_attachments:delete",
"precautions_attachments:create",
"precautions_attachments:read",
"material_equipments_attachments:delete",
"precautions_attachments:delete",
"faq_attachments:create",
"faq_attachments:read",
"faq_attachments:delete",
}
assert expected_keys <= set(API_ENDPOINT_PERMISSIONS)
removed_action_keys = {
f"{prefix}:{action}"
for prefix in (
"fees_contracts_attachments",
"startup_initiation_attachments",
"startup_ethics_attachments",
"startup_auth_attachments",
"drug_shipments_attachments",
"material_equipments_attachments",
"precautions_attachments",
"faq_attachments",
)
for action in ("create", "read")
}
assert removed_action_keys.isdisjoint(API_ENDPOINT_PERMISSIONS)
def test_precautions_permissions_use_business_aligned_keys():
"""注意事项权限应使用业务一致的英文 key,并归入共享库父模块。"""
expected_descriptions = {
"precautions:create": "创建注意事项",
"precautions:list": "查询注意事项列表",
"precautions:read": "查询注意事项详情",
"precautions:read": "查询注意事项",
"precautions:update": "更新注意事项",
"precautions:delete": "删除注意事项",
}
@@ -200,31 +304,11 @@ def test_legacy_fee_attachment_runtime_code_is_removed():
assert offenders == []
def test_attachment_permissions_use_module_permissions():
"""附件鉴权应使用模块附件权限,不再回退通用 attachments:*。"""
def test_attachment_permissions_do_not_fall_back_to_generic_permissions():
"""附件鉴权回退通用 attachments:*,业务附件由父业务权限或删除附件权限控制"""
attachments_path = Path(__file__).resolve().parents[1] / "app" / "api" / "v1" / "attachments.py"
source = attachments_path.read_text()
expected_entity_modules = {
'"contract_fee_contract": "fees_contracts_attachments"',
'"contract_fee_voucher": "fees_contracts_attachments"',
'"contract_fee_invoice": "fees_contracts_attachments"',
'"startup_feasibility": "startup_initiation_attachments"',
'"startup_ethics": "startup_ethics_attachments"',
'"startup_kickoff": "startup_auth_attachments"',
'"startup_kickoff_minutes": "startup_auth_attachments"',
'"startup_kickoff_signin": "startup_auth_attachments"',
'"startup_kickoff_ppt": "startup_auth_attachments"',
'"training_authorization": "startup_auth_attachments"',
'"drug_shipment": "drug_shipments_attachments"',
'"precaution": "precautions_attachments"',
'"faq_replies": "faq_attachments"',
}
for expected in expected_entity_modules:
assert expected in source
assert 'f"{module_prefix}:{operation}"' in source
assert 'f"attachments:{operation}"' not in source
assert "role_has_api_permission(" in source
assert "parent_permission" in source
@@ -237,15 +321,30 @@ def test_attachment_parent_permissions_cover_business_modules():
source = attachments_path.read_text()
expected_parent_permissions = {
'"fees_contracts:read" if action == "read" else "fees_contracts:update"',
'return f"startup_initiation:{parent_action}"',
'return f"startup_ethics:{parent_action}"',
'"startup_auth:read" if action == "read" else "startup_auth:update"',
'"drug_shipments:read" if action == "read" else "drug_shipments:update"',
'"precautions:read" if action == "read" else "precautions:update"',
'return "faq:read"',
'return "faq_reply:delete"',
'return "faq_reply:create"',
'"create": "fees_contracts:create"',
'"read": "fees_contracts:read"',
'"delete": "fees_contracts_attachments:delete"',
'"create": "startup_initiation:create"',
'"read": "startup_initiation:read"',
'"delete": "startup_initiation_attachments:delete"',
'"create": "startup_ethics:create"',
'"read": "startup_ethics:read"',
'"delete": "startup_ethics_attachments:delete"',
'"create": "startup_auth:create"',
'"read": "startup_auth:read"',
'"delete": "startup_auth_attachments:delete"',
'"create": "drug_shipments:create"',
'"read": "drug_shipments:read"',
'"delete": "drug_shipments_attachments:delete"',
'"create": "material_equipments:update"',
'"read": "material_equipments:read"',
'"delete": "material_equipments_attachments:delete"',
'"create": "precautions:create"',
'"read": "precautions:read"',
'"delete": "precautions_attachments:delete"',
'"create": "faq_reply:create"',
'"read": "faq:read"',
'"delete": "faq_attachments:delete"',
}
for expected in expected_parent_permissions:
@@ -256,13 +355,11 @@ def test_startup_ethics_permissions_use_visible_business_language():
"""立项与伦理权限矩阵应使用前台一致的业务名称。"""
expected_descriptions = {
"startup_initiation:create": "创建立项记录",
"startup_initiation:list": "查询立项记录列表",
"startup_initiation:read": "查询立项记录详情",
"startup_initiation:read": "查询立项记录",
"startup_initiation:update": "更新立项记录",
"startup_initiation:delete": "删除立项记录",
"startup_ethics:create": "创建伦理记录",
"startup_ethics:list": "查询伦理记录列表",
"startup_ethics:read": "查询伦理记录详情",
"startup_ethics:read": "查询伦理记录",
"startup_ethics:update": "更新伦理记录",
"startup_ethics:delete": "删除伦理记录",
}
@@ -294,6 +391,19 @@ def test_startup_auth_permissions_only_include_wired_operations():
assert stale_keys.isdisjoint(endpoint_keys)
def test_training_authorization_list_can_filter_by_site_name():
"""启动会详情页查询培训授权时应支持按当前中心过滤,避免串中心显示。"""
route_path = Path(__file__).resolve().parents[1] / "app" / "api" / "v1" / "startup.py"
crud_path = Path(__file__).resolve().parents[1] / "app" / "crud" / "startup.py"
route_source = route_path.read_text()
crud_source = crud_path.read_text()
assert "site_name: str | None = None" in route_source
assert "site_names=site_names, site_name=site_name" in route_source
assert "site_name: str | None = None" in crud_source
assert "TrainingAuthorization.site_name == site_name" in crud_source
def test_visit_list_permission_matches_wired_visit_endpoints():
"""访视当前没有详情查询接口,列表接口应只使用 visits:list。"""
visits_path = Path(__file__).resolve().parents[1] / "app" / "api" / "v1" / "visits.py"
@@ -313,8 +423,8 @@ def test_ae_subject_and_summary_share_read_permission():
list_chunk = source[source.index('@router.get(\n "/",') : source.index('@router.get(\n "/{ae_id}"')]
detail_chunk = source[source.index('response_model=AERead,\n dependencies=[Depends(require_api_permission("subject_aes:read"))]') : source.index('@router.patch')]
assert 'require_api_permission("subject_aes:list")' in summary_chunk
assert 'require_api_permission("subject_aes:list")' in list_chunk
assert 'require_api_permission("subject_aes:read")' in summary_chunk
assert 'require_api_permission("subject_aes:read")' in list_chunk
assert 'require_api_permission("subject_aes:read")' in detail_chunk
@@ -322,8 +432,7 @@ def test_ae_mutation_permissions_are_under_subject_management():
"""AE维护发生在参与者详情内,风险问题模块只保留汇总读取权限。"""
expected_descriptions = {
"subject_aes:create": "创建参与者AE",
"subject_aes:list": "查询参与者AE列表",
"subject_aes:read": "查询参与者AE详情",
"subject_aes:read": "查询参与者AE",
"subject_aes:update": "更新参与者AE",
"subject_aes:delete": "删除参与者AE",
}
@@ -371,8 +480,7 @@ def test_risk_issue_monitoring_visit_uses_monitoring_issue_permissions():
import_chunk = source[source.index('@router.post(\n "/issues/import"') :]
expected_descriptions = {
"monitoring_issues:list": "查询监查访视问题列表",
"monitoring_issues:read": "查询监查访视问题详情",
"monitoring_issues:read": "查询监查访视问题",
"monitoring_issues:create": "创建监查访视问题",
"monitoring_issues:update": "更新监查访视问题",
"monitoring_issues:delete": "删除监查访视问题",
@@ -381,8 +489,8 @@ def test_risk_issue_monitoring_visit_uses_monitoring_issue_permissions():
assert API_ENDPOINT_PERMISSIONS[endpoint_key]["module"] == "risk_issues"
assert API_ENDPOINT_PERMISSIONS[endpoint_key]["description"] == description
assert 'require_api_permission("monitoring_issues:list")' in list_chunk
assert 'require_api_permission("monitoring_issues:list")' in export_chunk
assert 'require_api_permission("monitoring_issues:read")' in list_chunk
assert 'require_api_permission("monitoring_issues:read")' in export_chunk
assert 'require_api_permission("monitoring_issues:read")' in detail_chunk
assert 'require_api_permission("monitoring_issues:create")' in create_chunk
assert 'require_api_permission("monitoring_issues:create")' in import_chunk
@@ -404,6 +512,7 @@ def test_document_service_uses_specific_document_permission_keys():
source = service_path.read_text()
assert '"create_document": "documents:create"' in source
assert '"update_document": "documents:update"' in source
assert '"create_version": "documents:update"' in source
assert '"distribute": "documents:update"' in source
assert '"delete_document": "documents:delete"' in source
@@ -416,11 +525,14 @@ def test_document_routes_do_not_hardcode_admin_for_matrix_controlled_actions():
route_path = Path(__file__).resolve().parents[1] / "app" / "api" / "v1" / "documents.py"
source = route_path.read_text()
update_document_chunk = source[source.index("async def update_document") : source.index("@router.delete", source.index("async def update_document"))]
delete_document_chunk = source[source.index("async def delete_document") : source.index("@router.post", source.index("async def delete_document"))]
delete_version_chunk = source[source.index("async def delete_version") : source.index("@router.post", source.index("async def delete_version"))]
assert "require_roles" not in update_document_chunk
assert "require_roles" not in delete_document_chunk
assert "require_roles" not in delete_version_chunk
assert "仅管理员可编辑文档" not in update_document_chunk
assert "仅管理员可删除文档" not in delete_document_chunk
assert "仅管理员可删除版本" not in delete_version_chunk