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

后端:
- 删除 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
+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"])
# 项目级路由(需要 study_id,挂载到 /studies/{study_id}/api-permissions
study_router = APIRouter(prefix="/api-permissions", tags=["api-permissions"])
@router.get(
"/operations",
@@ -138,7 +141,7 @@ async def check_operation_prerequisites(
}
@router.get(
@study_router.get(
"",
summary="获取项目的接口级权限矩阵",
description="返回项目中各角色对API端点的权限配置",
@@ -176,7 +179,7 @@ async def get_study_api_permissions(
return result
@router.put(
@study_router.put(
"",
summary="更新项目的接口级权限矩阵",
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 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.project_permissions import role_has_project_permission
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_api_permission
from app.crud import attachment as attachment_crud
from app.crud import audit as audit_crud
from app.crud import study as study_crud
@@ -23,9 +23,6 @@ router = APIRouter()
global_router = APIRouter()
UPLOAD_ROOT = Path(__file__).resolve().parent.parent.parent / "uploads"
ATTACHMENT_PERMISSION_MODULE_BY_ENTITY = {
"knowledge_note": "shared_library",
}
def _content_disposition(filename: str, disposition: str = "inline") -> str:
@@ -41,39 +38,14 @@ async def _ensure_study_exists(db: AsyncSession, study_id: uuid.UUID):
return study
def _permission_module_for_entity(entity_type: str) -> str:
return ATTACHMENT_PERMISSION_MODULE_BY_ENTITY.get(entity_type, "file_versions")
async def _ensure_attachment_permission(
db: AsyncSession,
study_id: uuid.UUID,
current_user,
entity_type: str,
action: str,
) -> None:
role_value = current_user.role.value if hasattr(current_user.role, "value") else current_user.role
if role_value == "ADMIN":
return
membership = await member_crud.get_member(db, study_id, current_user.id)
if not membership or not membership.is_active:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="不是项目成员")
allowed = await role_has_project_permission(
db,
study_id,
membership.role_in_study,
_permission_module_for_entity(entity_type),
action,
)
if not allowed:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="权限不足")
@router.post(
"/",
response_model=AttachmentRead,
status_code=status.HTTP_201_CREATED,
dependencies=[Depends(require_study_not_locked())],
dependencies=[
Depends(require_api_permission("attachments:create")),
Depends(require_study_not_locked())
],
)
async def upload_attachment(
study_id: uuid.UUID,
@@ -84,7 +56,6 @@ async def upload_attachment(
current_user=Depends(get_current_user),
) -> AttachmentRead:
await _ensure_study_exists(db, study_id)
await _ensure_attachment_permission(db, study_id, current_user, entity_type, "write")
dest_dir = UPLOAD_ROOT / f"study_{study_id}" / f"{entity_type}_{entity_id}"
dest_dir.mkdir(parents=True, exist_ok=True)
unique_name = f"{uuid.uuid4()}{Path(file.filename).suffix}"
@@ -129,6 +100,7 @@ async def upload_attachment(
@router.get(
"/",
response_model=list[AttachmentRead],
dependencies=[Depends(require_api_permission("attachments:read"))],
)
async def list_attachments(
study_id: uuid.UUID,
@@ -138,7 +110,6 @@ async def list_attachments(
current_user=Depends(get_current_user),
) -> list[AttachmentRead]:
await _ensure_study_exists(db, study_id)
await _ensure_attachment_permission(db, study_id, current_user, entity_type, "read")
attachments = await attachment_crud.list_attachments(db, study_id, entity_type, entity_id)
user_ids = {a.uploaded_by for a in attachments if a.uploaded_by}
users_map = await user_crud.get_users_by_ids(db, user_ids)
@@ -161,6 +132,7 @@ async def list_attachments(
@router.get(
"/{attachment_id}/download",
dependencies=[Depends(require_api_permission("attachments:read"))],
)
async def download_attachment(
study_id: uuid.UUID,
@@ -171,7 +143,6 @@ async def download_attachment(
current_user=Depends(get_current_user),
) -> FileResponse:
await _ensure_study_exists(db, study_id)
await _ensure_attachment_permission(db, study_id, current_user, entity_type, "read")
attachment = await attachment_crud.get_attachment(db, attachment_id)
if not attachment or attachment.study_id != study_id or attachment.entity_id != entity_id or attachment.entity_type != entity_type:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="附件不存在")
@@ -187,6 +158,7 @@ async def download_attachment(
@router.get(
"/{attachment_id}/preview",
dependencies=[Depends(require_api_permission("attachments:read"))],
)
async def preview_attachment(
study_id: uuid.UUID,
@@ -197,7 +169,6 @@ async def preview_attachment(
current_user=Depends(get_current_user),
) -> FileResponse:
await _ensure_study_exists(db, study_id)
await _ensure_attachment_permission(db, study_id, current_user, entity_type, "read")
attachment = await attachment_crud.get_attachment(db, attachment_id)
if not attachment or attachment.study_id != study_id or attachment.entity_id != entity_id or attachment.entity_type != entity_type:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="附件不存在")
@@ -250,12 +221,12 @@ async def global_download_attachment(
if user.role != "ADMIN":
membership = await get_study_member(attachment.study_id, current_user=user, db=db)
if user.role != "ADMIN":
allowed = await role_has_project_permission(
allowed = await role_has_api_permission(
db,
attachment.study_id,
membership.role_in_study if membership else None,
_permission_module_for_entity(attachment.entity_type),
"read",
"attachments:read",
check_prerequisites=False,
)
if not allowed:
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)
user, membership = await _authorize_global(request, db, attachment.study_id)
if user.role != "ADMIN":
allowed = await role_has_project_permission(
allowed = await role_has_api_permission(
db,
attachment.study_id,
membership.role_in_study if membership else None,
_permission_module_for_entity(attachment.entity_type),
"read",
"attachments:read",
check_prerequisites=False,
)
if not allowed:
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)
user, membership = await _authorize_global(request, db, attachment.study_id)
if user.role != "ADMIN":
allowed = await role_has_project_permission(
allowed = await role_has_api_permission(
db,
attachment.study_id,
membership.role_in_study if membership else None,
_permission_module_for_entity(attachment.entity_type),
"write",
"attachments:delete",
check_prerequisites=False,
)
if not allowed:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="权限不足")
@@ -350,7 +321,10 @@ async def global_delete_attachment(
@router.delete(
"/{attachment_id}",
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(
study_id: uuid.UUID,
@@ -361,7 +335,6 @@ async def delete_attachment(
current_user=Depends(get_current_user),
):
await _ensure_study_exists(db, study_id)
await _ensure_attachment_permission(db, study_id, current_user, entity_type, "write")
attachment = await attachment_crud.get_attachment(db, attachment_id)
if (
not attachment
+2 -2
View File
@@ -3,7 +3,7 @@ import uuid
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.deps import get_current_user, get_db_session, require_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.schemas.audit import AuditEventCreate, AuditLogRead
@@ -13,7 +13,7 @@ router = APIRouter()
@router.get(
"/",
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(
study_id: uuid.UUID,
+6 -7
View File
@@ -3,8 +3,8 @@ from fastapi import APIRouter, Depends
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.deps import get_cra_site_scope, get_current_user, get_db_session, require_study_member
from app.core.project_permissions import role_has_project_permission
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_api_permission
from app.crud import member as member_crud
from app.models.milestone import Milestone
from app.schemas.progress import StudyProgressRead
@@ -68,7 +68,10 @@ async def list_lost_visits(
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(
study_id: uuid.UUID,
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)
if not membership or not membership.is_active:
return []
can_read_sites = await role_has_project_permission(db, study_id, membership.role_in_study, "sites", "read")
can_read_subjects = await role_has_project_permission(db, study_id, membership.role_in_study, "subjects", "read")
if not (can_read_sites or can_read_subjects):
return []
cra_scope = await get_cra_site_scope(db, study_id, current_user)
scope_ids = cra_scope[0] if cra_scope else None
scope_id_strs = {str(cid) for cid in scope_ids} if scope_ids is not None else None
+15 -22
View File
@@ -3,8 +3,8 @@ import uuid
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.deps import get_current_user, get_db_session, require_study_not_locked
from app.core.project_permissions import role_has_project_permission
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_api_permission
from app.crud import audit as audit_crud
from app.crud import faq_category as category_crud
from app.crud import faq_item as item_crud
@@ -21,25 +21,16 @@ def _is_system_admin(current_user) -> bool:
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(
"/",
response_model=CategoryRead,
status_code=status.HTTP_201_CREATED,
summary="创建 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(
payload: CategoryCreate,
@@ -51,7 +42,6 @@ async def create_category(
existing = await category_crud.get_category_by_name(db, payload.study_id, payload.name)
if existing:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="分类名称已存在")
await _require_category_permission(db, payload.study_id, current_user, "write")
category = await category_crud.create_category(db, payload)
await audit_crud.log_action(
db,
@@ -71,6 +61,7 @@ async def create_category(
response_model=PaginatedResponse[CategoryRead],
summary="FAQ 分类列表",
description="返回全局及项目内 FAQ 分类列表,可按 study_id 过滤。",
dependencies=[Depends(require_api_permission("faq_category:read"))],
)
async def list_categories(
study_id: uuid.UUID | None = None,
@@ -80,8 +71,6 @@ async def list_categories(
) -> list[CategoryRead]:
if not study_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)
return paginate([CategoryRead.model_validate(c) for c in categories], total=len(categories))
@@ -91,7 +80,10 @@ async def list_categories(
response_model=CategoryRead,
summary="更新 FAQ 分类",
description="更新分类名称或启停状态,权限由项目级权限矩阵控制。",
dependencies=[Depends(require_study_not_locked())],
dependencies=[
Depends(require_api_permission("faq_category:update")),
Depends(require_study_not_locked())
],
)
async def update_category(
category_id: uuid.UUID,
@@ -112,7 +104,6 @@ async def update_category(
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="分类名称已存在")
if not target_study_id:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="必须提供项目 ID")
await _require_category_permission(db, target_study_id, current_user, "write")
updated = await category_crud.update_category(db, category, payload)
await audit_crud.log_action(
db,
@@ -132,7 +123,10 @@ async def update_category(
status_code=status.HTTP_204_NO_CONTENT,
summary="删除 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(
category_id: uuid.UUID,
@@ -144,7 +138,6 @@ async def delete_category(
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="分类不存在")
if not category.study_id:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="必须提供项目 ID")
await _require_category_permission(db, category.study_id, current_user, "write")
item_count = await item_crud.count_items_by_category(db, category_id)
if item_count > 0:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="分类下存在 FAQ,无法删除")
+54 -53
View File
@@ -3,8 +3,8 @@ import uuid
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.deps import get_current_user, get_db_session, require_study_not_locked
from app.core.project_permissions import role_has_project_permission
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_api_permission
from app.crud import audit as audit_crud
from app.crud import faq_category as category_crud
from app.crud import faq_item as faq_crud
@@ -31,32 +31,16 @@ def _is_system_admin(current_user) -> bool:
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(
"/",
response_model=FaqRead,
status_code=status.HTTP_201_CREATED,
summary="创建 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(
payload: FaqCreate,
@@ -70,7 +54,6 @@ async def create_faq(
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="分类不存在")
if cat.study_id != payload.study_id:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="分类范围不匹配")
await _require_project_permission(db, payload.study_id, current_user, "write")
try:
item = await faq_crud.create_item(db, payload, created_by=current_user.id)
except ValueError as exc:
@@ -102,6 +85,7 @@ async def create_faq(
response_model=PaginatedResponse[FaqRead],
summary="FAQ 列表",
description="返回全局与项目 FAQ,可按关键词、分类过滤。",
dependencies=[Depends(require_api_permission("faq:read"))],
)
async def list_faqs(
study_id: uuid.UUID | None = None,
@@ -116,14 +100,23 @@ async def list_faqs(
async def _get_member_role(sid: uuid.UUID) -> str | None:
if sid in membership_cache:
return membership_cache[sid]
member = await _require_project_permission(db, sid, current_user, "read")
role = member.role_in_study if member else "ADMIN"
member = await member_crud.get_member(db, sid, current_user.id)
role = member.role_in_study if member else None
membership_cache[sid] = role
return role
if not study_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(
db,
@@ -141,7 +134,7 @@ async def list_faqs(
if not role:
continue
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:
continue
visible.append(FaqRead.model_validate(it))
@@ -153,6 +146,7 @@ async def list_faqs(
response_model=FaqRead,
summary="FAQ 详情",
description="获取单条 FAQ,停用 FAQ 需项目级写权限。",
dependencies=[Depends(require_api_permission("faq:read"))],
)
async def get_faq(
item_id: uuid.UUID,
@@ -164,7 +158,14 @@ async def get_faq(
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="FAQ 不存在")
if not item.study_id:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="FAQ 不存在")
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)
@@ -173,7 +174,10 @@ async def get_faq(
response_model=FaqRead,
summary="更新 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(
item_id: uuid.UUID,
@@ -184,10 +188,6 @@ async def update_faq(
item = await faq_crud.get_item(db, item_id)
if not item:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="FAQ 不存在")
if item.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
updated = await faq_crud.update_item(db, item, payload)
action = "UPDATE_FAQ_ITEM"
@@ -213,7 +213,10 @@ async def update_faq(
response_model=FaqRead,
summary="更新 FAQ 状态",
description="提问者或具备项目级写权限的成员可确认已解决。",
dependencies=[Depends(require_study_not_locked())],
dependencies=[
Depends(require_api_permission("faq:update")),
Depends(require_study_not_locked())
],
)
async def update_faq_status(
item_id: uuid.UUID,
@@ -226,10 +229,6 @@ async def update_faq_status(
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="FAQ 不存在")
if payload.status != "RESOLVED":
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="仅允许设置为已解决")
if item.created_by != current_user.id:
await _require_project_permission(db, item.study_id, current_user, "write")
else:
await _require_project_permission(db, item.study_id, current_user, "read")
await faq_crud.set_status(db, item.id, "RESOLVED", resolved_by_confirm=True)
updated = await faq_crud.get_item(db, item_id)
return FaqRead.model_validate(updated)
@@ -240,7 +239,10 @@ async def update_faq_status(
response_model=FaqRead,
summary="设置最佳回复",
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(
item_id: uuid.UUID,
@@ -253,7 +255,6 @@ async def set_best_reply(
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="FAQ 不存在")
if not item.study_id:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="FAQ 不存在")
await _require_project_permission(db, item.study_id, current_user, "write")
if payload.best_reply_id:
reply = await reply_crud.get_reply(db, payload.best_reply_id)
if not reply or reply.faq_id != item.id:
@@ -281,6 +282,7 @@ async def set_best_reply(
response_model=PaginatedResponse[FaqReplyRead],
summary="FAQ 回复列表",
description="获取 FAQ 的回复列表。",
dependencies=[Depends(require_api_permission("faq:read"))],
)
async def list_replies(
item_id: uuid.UUID,
@@ -290,7 +292,6 @@ async def list_replies(
item = await faq_crud.get_item(db, item_id)
if not item:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="FAQ 不存在")
await _require_project_permission(db, item.study_id, current_user, "read")
replies = await reply_crud.list_replies(db, item_id)
reply_map = {r.id: r for r in replies}
result: list[FaqReplyRead] = []
@@ -317,7 +318,10 @@ async def list_replies(
status_code=status.HTTP_201_CREATED,
summary="创建 FAQ 回复",
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(
item_id: uuid.UUID,
@@ -332,7 +336,6 @@ async def create_reply(
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="FAQ 不存在")
if not payload.content.strip():
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="回复内容不能为空")
await _require_project_permission(db, item.study_id, current_user, "write")
quote = None
if payload.quote_reply_id:
quote = await reply_crud.get_reply(db, payload.quote_reply_id)
@@ -377,7 +380,10 @@ async def create_reply(
status_code=status.HTTP_204_NO_CONTENT,
summary="删除 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(
item_id: uuid.UUID,
@@ -387,10 +393,6 @@ async def delete_faq(
item = await faq_crud.get_item(db, item_id)
if not item:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="FAQ 不存在")
if item.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 db.delete(item)
await db.commit()
@@ -411,7 +413,10 @@ async def delete_faq(
status_code=status.HTTP_204_NO_CONTENT,
summary="删除 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(
item_id: uuid.UUID,
@@ -425,10 +430,6 @@ async def delete_reply(
reply = await reply_crud.get_reply(db, reply_id)
if not reply or reply.faq_id != item.id:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="回复不存在")
if reply.created_by != current_user.id:
await _require_project_permission(db, item.study_id, current_user, "write")
else:
await _require_project_permission(db, item.study_id, current_user, "read")
if item.best_reply_id == reply.id:
await faq_crud.set_best_reply(db, item.id, None)
ref_count = await reply_crud.count_quote_references(db, reply.id)
+27 -23
View File
@@ -7,8 +7,8 @@ from fastapi import APIRouter, Depends, File, HTTPException, UploadFile, status,
from fastapi.responses import FileResponse
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.deps import get_current_user, get_db_session, require_study_not_locked
from app.core.project_permissions import role_has_project_permission
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_api_permission
from app.core.security import decode_token
from app.crud import audit as audit_crud
from app.crud import contract_fee as contract_fee_crud
@@ -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="不支持的附件类型")
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):
token = None
auth_header = request.headers.get("Authorization")
@@ -82,7 +68,10 @@ async def _authorize_download_user(request: Request, db: AsyncSession):
"/attachments",
response_model=FeeApiResponse[FeeAttachmentRead],
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(
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()):
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="附件文件类型无效")
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.mkdir(parents=True, exist_ok=True)
@@ -151,7 +139,7 @@ async def upload_fee_attachment(
@router.get(
"/attachments",
response_model=FeeApiResponse[list[FeeAttachmentRead]],
dependencies=[Depends(get_current_user)],
dependencies=[Depends(require_api_permission("fees_attachments:read"))],
)
async def list_fee_attachments(
entity_type: str,
@@ -162,7 +150,6 @@ async def list_fee_attachments(
if entity_type not in ALLOWED_ENTITY_TYPES:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="附件类型无效")
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)
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)
@@ -202,7 +189,16 @@ async def download_fee_attachment(
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="附件不存在")
current_user = await _authorize_download_user(request, db)
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):
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="服务器未找到文件")
return FileResponse(
@@ -216,7 +212,10 @@ async def download_fee_attachment(
@router.delete(
"/attachments/{attachment_id}",
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(
attachment_id: uuid.UUID,
@@ -227,9 +226,14 @@ async def delete_fee_attachment(
if not attachment:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="附件不存在")
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
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 = (
role_value == "ADMIN"
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 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 knowledge_note as note_crud
from app.crud import site as site_crud
@@ -32,7 +32,7 @@ async def _ensure_site_name_active(db: AsyncSession, study_id: uuid.UUID, site_n
"/notes",
response_model=KnowledgeNoteRead,
status_code=status.HTTP_201_CREATED,
dependencies=[Depends(require_study_permission("shared_library", "write"))],
dependencies=[Depends(require_api_permission("knowledge_notes:create"))],
)
async def create_note(
study_id: uuid.UUID,
@@ -59,7 +59,7 @@ async def create_note(
@router.get(
"/notes",
response_model=list[KnowledgeNoteRead],
dependencies=[Depends(require_study_permission("shared_library", "read"))],
dependencies=[Depends(require_api_permission("knowledge_notes:read"))],
)
async def list_notes(
study_id: uuid.UUID,
@@ -77,7 +77,7 @@ async def list_notes(
@router.get(
"/notes/{note_id}",
response_model=KnowledgeNoteRead,
dependencies=[Depends(require_study_permission("shared_library", "read"))],
dependencies=[Depends(require_api_permission("knowledge_notes:read"))],
)
async def get_note(
study_id: uuid.UUID,
@@ -94,7 +94,7 @@ async def get_note(
@router.patch(
"/notes/{note_id}",
response_model=KnowledgeNoteRead,
dependencies=[Depends(require_study_permission("shared_library", "write"))],
dependencies=[Depends(require_api_permission("knowledge_notes:update"))],
)
async def update_note(
study_id: uuid.UUID,
@@ -125,7 +125,7 @@ async def update_note(
@router.delete(
"/notes/{note_id}",
status_code=status.HTTP_204_NO_CONTENT,
dependencies=[Depends(require_study_permission("shared_library", "write"))],
dependencies=[Depends(require_api_permission("knowledge_notes:delete"))],
)
async def delete_note(
study_id: uuid.UUID,
+6 -6
View File
@@ -3,7 +3,7 @@ import uuid
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.deps import get_current_user, get_db_session, require_study_not_locked, require_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 material_equipment as equipment_crud
from app.crud import study as study_crud
@@ -28,7 +28,7 @@ def _validate_calibration(need_calibration: bool, calibration_cycle_days: int |
"/equipment",
response_model=MaterialEquipmentRead,
status_code=status.HTTP_201_CREATED,
dependencies=[Depends(require_study_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(
study_id: uuid.UUID,
@@ -57,7 +57,7 @@ async def create_equipment(
@router.get(
"/equipment",
response_model=list[MaterialEquipmentRead],
dependencies=[Depends(require_study_permission("materials", "read"))],
dependencies=[Depends(require_api_permission("material_equipments:read"))],
)
async def list_equipments(
study_id: uuid.UUID,
@@ -74,7 +74,7 @@ async def list_equipments(
@router.get(
"/equipment/{equipment_id}",
response_model=MaterialEquipmentRead,
dependencies=[Depends(require_study_permission("materials", "read"))],
dependencies=[Depends(require_api_permission("material_equipments:read"))],
)
async def get_equipment(
study_id: uuid.UUID,
@@ -91,7 +91,7 @@ async def get_equipment(
@router.patch(
"/equipment/{equipment_id}",
response_model=MaterialEquipmentRead,
dependencies=[Depends(require_study_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(
study_id: uuid.UUID,
@@ -128,7 +128,7 @@ async def update_equipment(
@router.delete(
"/equipment/{equipment_id}",
status_code=status.HTTP_204_NO_CONTENT,
dependencies=[Depends(require_study_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(
study_id: uuid.UUID,
+3 -3
View File
@@ -4,7 +4,7 @@ import uuid
from fastapi import APIRouter, Depends
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.deps import get_db_session, require_study_permission
from app.core.deps import get_db_session, require_api_permission
from app.crud import overview as overview_crud
from app.schemas.overview import ProjectOverviewResponse
@@ -14,7 +14,7 @@ router = APIRouter()
@router.get(
"/overview",
response_model=ProjectOverviewResponse,
dependencies=[Depends(require_study_permission("project_overview", "read"))],
dependencies=[Depends(require_api_permission("project_overview:read"))],
)
async def get_project_overview(
study_id: uuid.UUID,
@@ -22,7 +22,7 @@ async def get_project_overview(
) -> ProjectOverviewResponse:
"""
获取项目概览数据
返回项目各中心的进度情况,包括:
- 机构立项状态
- 伦理审批状态
+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 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()
@@ -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(sites.router, prefix="/studies/{study_id}/sites", tags=["sites"])
api_router.include_router(members.router, prefix="/studies/{study_id}/members", tags=["study-members"])
api_router.include_router(project_permissions.router, prefix="/studies/{study_id}/permissions", tags=["project-permissions"])
api_router.include_router(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.global_router, prefix="/attachments", tags=["attachments"])
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(documents.router, prefix="", tags=["documents"])
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 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 site as site_crud
from app.crud import subject as subject_crud
@@ -33,7 +33,7 @@ async def _ensure_subject_active(db: AsyncSession, subject) -> None:
"/histories",
response_model=SubjectHistoryRead,
status_code=status.HTTP_201_CREATED,
dependencies=[Depends(require_study_permission("subjects", "write"))],
dependencies=[Depends(require_api_permission("subject_histories:create"))],
)
async def create_history(
study_id: uuid.UUID,
@@ -66,7 +66,7 @@ async def create_history(
@router.get(
"/histories",
response_model=list[SubjectHistoryRead],
dependencies=[Depends(require_study_permission("subjects", "read"))],
dependencies=[Depends(require_api_permission("subject_histories:read"))],
)
async def list_histories(
study_id: uuid.UUID,
@@ -86,7 +86,7 @@ async def list_histories(
@router.get(
"/histories/{history_id}",
response_model=SubjectHistoryRead,
dependencies=[Depends(require_study_permission("subjects", "read"))],
dependencies=[Depends(require_api_permission("subject_histories:read"))],
)
async def get_history(
study_id: uuid.UUID,
@@ -107,7 +107,7 @@ async def get_history(
@router.patch(
"/histories/{history_id}",
response_model=SubjectHistoryRead,
dependencies=[Depends(require_study_permission("subjects", "write"))],
dependencies=[Depends(require_api_permission("subject_histories:update"))],
)
async def update_history(
study_id: uuid.UUID,
@@ -141,7 +141,7 @@ async def update_history(
@router.delete(
"/histories/{history_id}",
status_code=status.HTTP_204_NO_CONTENT,
dependencies=[Depends(require_study_permission("subjects", "write"))],
dependencies=[Depends(require_api_permission("subject_histories:delete"))],
)
async def delete_history(
study_id: uuid.UUID,
+5 -5
View File
@@ -7,7 +7,7 @@ from app.core.deps import (
get_current_user,
get_db_session,
require_study_not_locked,
require_study_permission,
require_api_permission,
)
from app.crud import audit as audit_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(
"/pds",
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(
study_id: uuid.UUID,
@@ -83,7 +83,7 @@ async def list_subject_pds(
"/pds",
response_model=SubjectPdRead,
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(
study_id: uuid.UUID,
@@ -128,7 +128,7 @@ async def create_subject_pd(
@router.patch(
"/pds/{pd_id}",
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(
study_id: uuid.UUID,
@@ -175,7 +175,7 @@ async def update_subject_pd(
@router.delete(
"/pds/{pd_id}",
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(
study_id: uuid.UUID,
+244
View File
@@ -677,6 +677,213 @@ API_ENDPOINT_PERMISSIONS = {
"description": "更新项目里程碑",
"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",
],
},
"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": {
"read": [
"milestones:list",
@@ -892,6 +1131,11 @@ OPERATION_PREREQUISITES: dict[str, list[str]] = {
"subject_pds:create": ["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: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 member as member_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.schemas.user import TokenPayload
@@ -119,33 +119,6 @@ def require_study_roles(roles: Iterable[str], *, allow_system_admin: bool = True
return dependency
def require_study_permission(module: str, action: str, *, allow_system_admin: bool = True):
async def dependency(
study_id: uuid.UUID,
current_user=Depends(get_current_user),
db: AsyncSession = Depends(get_db_session),
):
role_value = current_user.role.value if hasattr(current_user.role, "value") else current_user.role
if allow_system_admin and role_value == "ADMIN":
return current_user
membership = await member_crud.get_member(db, study_id, current_user.id)
if not membership or not membership.is_active:
raise AppException(
code="FORBIDDEN",
message="不是该项目成员",
status_code=status.HTTP_403_FORBIDDEN,
)
allowed = await role_has_project_permission(db, study_id, membership.role_in_study, module, action)
if not allowed:
raise AppException(
code="FORBIDDEN",
message="项目权限不足",
status_code=status.HTTP_403_FORBIDDEN,
)
return current_user
return dependency
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
@@ -14,71 +10,22 @@ from sqlalchemy.ext.asyncio import AsyncSession
class PermissionCache:
"""权限缓存管理器
使用内存缓存存储权限矩阵和成员身份信息减少数据库查询
每个缓存项都有 TTL生存时间过期后自动失效
"""
"""权限缓存管理器"""
def __init__(self, default_ttl: int = 300):
"""初始化缓存管理器
Args:
default_ttl: 默认缓存生存时间默认5分钟
"""
self.default_ttl = default_ttl
self._project_permissions_cache: dict[str, tuple[Any, float]] = {}
self._member_role_cache: dict[str, tuple[str | None, float]] = {}
def _is_expired(self, timestamp: float, ttl: int) -> bool:
"""检查缓存是否已过期"""
return time.time() - timestamp > ttl
def _make_project_cache_key(self, study_id: uuid.UUID) -> str:
"""生成项目权限缓存键"""
return f"project_permissions:{study_id}"
def _make_member_cache_key(self, study_id: uuid.UUID, user_id: uuid.UUID) -> str:
"""生成成员角色缓存键"""
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(
self,
db: AsyncSession,
@@ -86,72 +33,34 @@ class PermissionCache:
user_id: uuid.UUID,
ttl: int | None = None,
) -> str | None:
"""获取成员角色(带缓存)
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
"""获取成员角色(带缓存)"""
from app.crud import member as member_crud
if ttl is None:
ttl = self.default_ttl
cache_key = self._make_member_cache_key(study_id, user_id)
# 检查缓存
if cache_key in self._member_role_cache:
cached_role, timestamp = self._member_role_cache[cache_key]
if not self._is_expired(timestamp, ttl):
return cached_role
# 缓存未命中,从数据库查询
role = await _get_member_role(db, study_id, user_id)
membership = await member_crud.get_member(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())
return role
def invalidate_project_permissions(self, study_id: uuid.UUID) -> None:
"""失效项目权限缓存
当项目权限被修改时调用此方法清除相关的缓存
Args:
study_id: 项目ID
"""
cache_key = self._make_project_cache_key(study_id)
if cache_key in self._project_permissions_cache:
del self._project_permissions_cache[cache_key]
self._project_permissions_cache.pop(cache_key, 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)
if cache_key in self._member_role_cache:
del self._member_role_cache[cache_key]
self._member_role_cache.pop(cache_key, None)
def invalidate_all_member_roles(self, study_id: uuid.UUID) -> None:
"""失效项目中所有成员的角色缓存
当项目权限矩阵被修改时调用此方法清除项目中所有成员的缓存
Args:
study_id: 项目ID
"""
# 清除所有包含该项目ID的成员角色缓存
keys_to_delete = [
key for key in self._member_role_cache.keys()
if key.startswith(f"member_role:{study_id}:")
@@ -160,23 +69,10 @@ class PermissionCache:
del self._member_role_cache[key]
def clear_all(self) -> None:
"""清除所有缓存
用于测试或系统重启时清除所有缓存
"""
self._project_permissions_cache.clear()
self._member_role_cache.clear()
def get_cache_stats(self) -> dict[str, Any]:
"""获取缓存统计信息
Returns:
缓存统计信息{
'project_permissions_count': 项目权限缓存数,
'member_role_count': 成员角色缓存数,
'total_count': 总缓存数,
}
"""
return {
"project_permissions_count": len(self._project_permissions_cache),
"member_role_count": len(self._member_role_cache),
@@ -184,12 +80,10 @@ class PermissionCache:
}
# 全局缓存实例
_permission_cache: PermissionCache | None = None
def get_permission_cache() -> PermissionCache:
"""获取全局权限缓存实例"""
global _permission_cache
if _permission_cache is None:
_permission_cache = PermissionCache()
@@ -197,6 +91,5 @@ def get_permission_cache() -> PermissionCache:
def set_permission_cache(cache: PermissionCache) -> None:
"""设置全局权限缓存实例(用于测试)"""
global _permission_cache
_permission_cache = cache
+9 -289
View File
@@ -1,265 +1,14 @@
from __future__ import annotations
import json
import uuid
from sqlalchemy import delete, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.study_role_permission import StudyRolePermission
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
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(
db: AsyncSession,
@@ -268,17 +17,10 @@ async def role_has_api_permission(
endpoint_key: str,
check_prerequisites: bool = True,
) -> bool:
"""检查角色是否有权访问特定接口
权限检查优先级
1. 接口级权限如果已配置
2. 模块级权限向后兼容
3. 前置权限检查如果启用
"""
"""检查角色是否有权访问特定接口"""
if role == "ADMIN":
return True
# 1. 先查询接口级权限
result = await db.execute(
select(ApiEndpointPermission).where(
ApiEndpointPermission.study_id == study_id,
@@ -287,22 +29,12 @@ async def role_has_api_permission(
)
)
perm = result.scalar_one_or_none()
if perm is not None:
has_main_permission = perm.allowed
else:
# 2. 如果没有接口级权限,回退到模块级权限(向后兼容)
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:
if perm is None:
return False
if not perm.allowed:
return False
# 3. 检查前置权限
if check_prerequisites:
prerequisites = OPERATION_PREREQUISITES.get(endpoint_key, [])
for prereq_endpoint in prerequisites:
@@ -352,17 +84,14 @@ async def get_api_endpoint_permissions(
)
rows = result.scalars().all()
# 初始化矩阵,包含所有角色和端点的默认权限
roles = ["PM", "CRA", "PV", "MEDICAL_REVIEW", "IMP", "QA"]
matrix: dict[str, dict[str, dict[str, bool]]] = {}
for role in PROJECT_PERMISSION_ROLES:
if role == "ADMIN":
continue
for role in roles:
matrix[role] = {}
for endpoint_key, config in API_ENDPOINT_PERMISSIONS.items():
default_allowed = role in config.get("default_roles", [])
matrix[role][endpoint_key] = {"allowed": default_allowed}
# 覆盖自定义权限
for row in rows:
if row.role not in matrix:
matrix[row.role] = {}
@@ -376,21 +105,13 @@ async def replace_api_endpoint_permissions(
study_id: uuid.UUID,
payload: 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(
delete(ApiEndpointPermission).where(
ApiEndpointPermission.study_id == study_id,
)
)
# 插入新的权限配置
for role, endpoints in payload.items():
if role == "ADMIN":
continue
@@ -408,7 +129,6 @@ async def replace_api_endpoint_permissions(
await db.commit()
# 失效缓存
cache = get_permission_cache()
cache.invalidate_project_permissions(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_monitoring_strategy import StudyMonitoringStrategy # noqa: F401
from app.models.study_center_confirm import StudyCenterConfirm # noqa: F401
from app.models.study_role_permission import StudyRolePermission # noqa: F401
+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 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 distribution as distribution_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)
if not membership or not membership.is_active:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="不是项目成员")
module = "file_versions"
permission_action = "read" if action in {"view", "ack"} else "write"
allowed = await role_has_project_permission(db, trial_id, membership.role_in_study, module, permission_action)
endpoint_key = "documents:read" if action in {"view", "ack"} else "documents:update"
allowed = await role_has_api_permission(db, trial_id, membership.role_in_study, endpoint_key, check_prerequisites=False)
if not allowed:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="权限不足")
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} 的值必须是布尔类型")