权限管理:接入项目级权限矩阵

新增项目级角色权限矩阵,覆盖 PM、CRA、PV、IMP、QA 等项目角色,并通过迁移表持久化各业务模块的读写权限。

后端将参与者、合同费用、物资、启动、里程碑、风险问题、监查稽查、FAQ、共享库、文件版本等接口接入矩阵校验,修复审计日志导出权限和登录 502 的依赖导入问题。

前端新增权限管理页面和项目路由权限映射,按矩阵控制导航显示、直接访问拦截、操作按钮启停,并限制管理后台仅 admin 和授权 PM 可进入。

补充后端权限归一化与接口静态测试、前端路由/权限工具/权限管理页面/工作台等回归测试。
This commit is contained in:
Cheng Zhou
2026-05-13 08:58:03 +08:00
parent 77e842637d
commit 939fc70532
63 changed files with 1952 additions and 387 deletions
@@ -4,13 +4,131 @@ from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
def test_project_management_write_routes_allow_project_admin_and_pm():
for relative_path in (
"app/api/v1/studies.py",
"app/api/v1/sites.py",
"app/api/v1/members.py",
):
source = (ROOT / relative_path).read_text(encoding="utf-8")
def test_project_setup_routes_keep_project_admin_and_pm_defaults():
source = (ROOT / "app/api/v1/studies.py").read_text(encoding="utf-8")
assert 'require_study_roles(["PM"])' not in source
assert 'require_study_roles(["ADMIN", "PM"])' in source
assert 'require_study_roles(["PM"])' not in source
assert 'require_study_roles(["ADMIN", "PM"])' not in source
assert 'require_roles(["ADMIN"])' in source
def test_project_permission_routes_are_registered():
source = (ROOT / "app/api/v1/router.py").read_text(encoding="utf-8")
assert "project_permissions" in source
assert 'prefix="/studies/{study_id}/permissions"' in source
def test_project_member_writes_use_saved_permission_matrix():
source = (ROOT / "app/api/v1/members.py").read_text(encoding="utf-8")
assert 'require_study_permission("project_members", "write")' in source
assert 'require_study_roles(["ADMIN", "PM"])' not in source
def test_business_modules_use_saved_permission_matrix():
expected = {
"overview.py": [
'require_study_permission("project_overview", "read")',
],
"project_milestones.py": [
'require_study_permission("project_milestones", "read")',
'require_study_permission("project_milestones", "write")',
],
"subjects.py": [
'require_study_permission("subjects", "read")',
'require_study_permission("subjects", "write")',
],
"material_equipments.py": [
'require_study_permission("materials", "read")',
'require_study_permission("materials", "write")',
],
"startup.py": [
'require_study_permission("startup_ethics", "read")',
'require_study_permission("startup_ethics", "write")',
'require_study_permission("startup_auth", "read")',
'require_study_permission("startup_auth", "write")',
],
"aes.py": [
'require_study_permission("risk_issues", "read")',
'require_study_permission("risk_issues", "write")',
],
"monitoring_visit_issues.py": [
'require_study_permission("monitoring_audit", "read")',
'require_study_permission("monitoring_audit", "write")',
],
}
for filename, checks in expected.items():
source = (ROOT / f"app/api/v1/{filename}").read_text(encoding="utf-8")
for check in checks:
assert check in source
ae_source = (ROOT / "app/api/v1/aes.py").read_text(encoding="utf-8")
assert "ALLOWED_CREATE_ROLES" not in ae_source
assert "ALLOWED_UPDATE_ROLES" not in ae_source
assert 'member_role not in {"PM", "PV"}' not in ae_source
def test_fee_contracts_use_saved_permission_matrix():
source = (ROOT / "app/api/v1/fees_contracts.py").read_text(encoding="utf-8")
assert 'role_has_project_permission(db, project_id, membership.role_in_study, "fees", action)' in source
assert 'if write and membership.role_in_study not in {"ADMIN", "PM"}' not in source
def test_attachment_deletes_use_saved_permission_matrix():
source = (ROOT / "app/api/v1/attachments.py").read_text(encoding="utf-8")
fees_source = (ROOT / "app/api/v1/fees_attachments.py").read_text(encoding="utf-8")
assert "_permission_module_for_entity(entity_type)" in source
assert '"knowledge_note": "shared_library"' in source
assert "_permission_module_for_entity(attachment.entity_type)" in source
assert 'getattr(membership, "role_in_study", None) == "PM"' not in source
assert 'role_has_project_permission(db, project_id, membership.role_in_study, "fees", action)' in fees_source
assert 'getattr(membership, "role_in_study", None) == "PM"' not in fees_source
def test_faq_uses_dedicated_permission_matrix():
source = (ROOT / "app/api/v1/faqs.py").read_text(encoding="utf-8")
category_source = (ROOT / "app/api/v1/faq_categories.py").read_text(encoding="utf-8")
assert 'role_has_project_permission(db, study_id, member.role_in_study, "faq", action)' in source
assert 'role_has_project_permission(db, study_id, member.role_in_study, "faq", action)' in category_source
assert '"etmf"' not in source
assert '"etmf"' not in category_source
assert 'member_role != "PM"' not in source
assert 'member_role != "PM"' not in category_source
def test_audit_logs_use_saved_permission_matrix():
source = (ROOT / "app/api/v1/audit_logs.py").read_text(encoding="utf-8")
get_block = source[source.index("@router.get("):source.index("@router.delete(")]
assert 'require_study_permission("audit_export", "read")' in get_block
assert 'require_study_member()' not in get_block
def test_document_service_uses_saved_permission_matrix():
source = (ROOT / "app/services/document_service.py").read_text(encoding="utf-8")
assert 'role_has_project_permission' in source
assert '"file_versions"' in source
assert 'rbac.is_allowed' not in source
def test_remaining_business_reads_use_saved_permission_matrix():
expected = {
"finance_contracts.py": ['require_study_permission("fees", "read")'],
"drug_shipments.py": ['require_study_permission("materials", "read")'],
"knowledge_notes.py": ['require_study_permission("shared_library", "read")'],
"subject_histories.py": ['require_study_permission("subjects", "read")'],
"subject_pds.py": ['require_study_permission("risk_issues", "read")'],
"visits.py": ['require_study_permission("subjects", "read")'],
"attachments.py": ['_permission_module_for_entity(entity_type)'],
}
for filename, checks in expected.items():
source = (ROOT / f"app/api/v1/{filename}").read_text(encoding="utf-8")
for check in checks:
assert check in source
+172
View File
@@ -0,0 +1,172 @@
from app.core.project_permissions import PROJECT_PERMISSION_MODULES, normalize_permission_matrix
def test_normalize_permission_matrix_makes_admin_fixed_full_access():
matrix = normalize_permission_matrix({"ADMIN": {}})
for module in PROJECT_PERMISSION_MODULES:
assert matrix["ADMIN"][module["key"]] == {
"read": True,
"write": module.get("writable") is not False,
}
def test_normalize_permission_matrix_keeps_admin_fixed_even_when_disabled():
payload = {
"ADMIN": {
module["key"]: {"read": False, "write": False}
for module in PROJECT_PERMISSION_MODULES
}
}
matrix = normalize_permission_matrix(payload)
for module in PROJECT_PERMISSION_MODULES:
assert matrix["ADMIN"][module["key"]] == {
"read": True,
"write": module.get("writable") is not False,
}
def test_normalize_permission_matrix_write_implies_read():
matrix = normalize_permission_matrix({
"CRA": {
"subjects": {"read": False, "write": True},
}
})
assert matrix["CRA"]["subjects"] == {"read": True, "write": True}
def test_business_permission_modules_follow_project_menu():
module_labels = [module["label"] for module in PROJECT_PERMISSION_MODULES]
assert module_labels == [
"项目成员",
"中心管理",
"审计日志导出",
"项目总览",
"项目里程碑",
"合同费用管理",
"物资管理",
"文件版本管理",
"立项与伦理",
"启动与授权",
"参与者管理",
"风险问题",
"监查稽查",
"eTMF",
"FAQ",
"共享库",
]
def test_default_business_permissions_match_role_responsibilities():
matrix = normalize_permission_matrix()
assert matrix["PM"]["project_members"] == {"read": True, "write": True}
assert matrix["PM"]["sites"] == {"read": True, "write": True}
assert matrix["CRA"]["project_members"] == {"read": False, "write": False}
assert matrix["CRA"]["audit_export"] == {"read": False, "write": False}
assert matrix["PV"]["sites"] == {"read": False, "write": False}
assert matrix["IMP"]["audit_export"] == {"read": False, "write": False}
assert matrix["QA"]["audit_export"] == {"read": False, "write": False}
assert matrix["CRA"]["project_milestones"] == {"read": True, "write": True}
assert matrix["CRA"]["subjects"] == {"read": True, "write": True}
assert matrix["CRA"]["monitoring_audit"] == {"read": True, "write": True}
assert matrix["PV"]["risk_issues"] == {"read": True, "write": True}
assert matrix["IMP"]["materials"] == {"read": True, "write": True}
assert matrix["PM"]["faq"] == {"read": True, "write": True}
assert matrix["CRA"]["faq"] == {"read": True, "write": True}
assert matrix["PM"]["project_overview"] == {"read": True, "write": False}
assert matrix["CRA"]["project_overview"] == {"read": True, "write": False}
assert matrix["PM"]["shared_library"] == {"read": True, "write": True}
assert matrix["CRA"]["shared_library"] == {"read": True, "write": True}
def test_project_overview_is_read_only_even_when_write_is_submitted():
matrix = normalize_permission_matrix({
"CRA": {
"project_overview": {"read": False, "write": True},
}
})
assert matrix["CRA"]["project_overview"] == {"read": True, "write": False}
def test_pm_can_be_granted_management_backend_permissions():
matrix = normalize_permission_matrix({
"PM": {
"project_members": {"read": True, "write": True},
"sites": {"read": True, "write": True},
"audit_export": {"read": True, "write": True},
}
})
assert matrix["PM"]["project_members"] == {"read": True, "write": True}
assert matrix["PM"]["sites"] == {"read": True, "write": True}
assert matrix["PM"]["audit_export"] == {"read": True, "write": True}
def test_non_pm_roles_cannot_be_granted_management_backend_permissions():
management_modules = ["project_members", "sites", "audit_export"]
matrix = normalize_permission_matrix({
role: {
module: {"read": True, "write": True}
for module in management_modules
}
for role in ["CRA", "PV", "IMP", "QA"]
})
for role in ["CRA", "PV", "IMP", "QA"]:
for module in management_modules:
assert matrix[role][module] == {"read": False, "write": False}
def test_each_non_admin_role_can_toggle_each_writable_module():
roles = ["PM", "CRA", "PV", "IMP", "QA"]
management_modules = {"project_members", "sites", "audit_export"}
writable_modules = [
module["key"]
for module in PROJECT_PERMISSION_MODULES
if module.get("writable") is not False and module["key"] not in management_modules
]
for role in roles:
enabled = normalize_permission_matrix({
role: {
module: {"read": False, "write": True}
for module in writable_modules
}
})
disabled = normalize_permission_matrix({
role: {
module["key"]: {"read": False, "write": False}
for module in PROJECT_PERMISSION_MODULES
}
})
for module in writable_modules:
assert enabled[role][module] == {"read": True, "write": True}
assert disabled[role][module] == {"read": False, "write": False}
def test_read_only_modules_never_accept_write_for_any_role():
read_only_modules = [
module["key"]
for module in PROJECT_PERMISSION_MODULES
if module.get("writable") is False
]
payload = {
role: {
module: {"read": False, "write": True}
for module in read_only_modules
}
for role in ["ADMIN", "PM", "CRA", "PV", "IMP", "QA"]
}
matrix = normalize_permission_matrix(payload)
for role in ["ADMIN", "PM", "CRA", "PV", "IMP", "QA"]:
for module in read_only_modules:
assert matrix[role][module] == {"read": True, "write": False}