diff --git a/backend/alembic/versions/20260527_08_remove_generic_attachment_permissions.py b/backend/alembic/versions/20260527_08_remove_generic_attachment_permissions.py new file mode 100644 index 00000000..9e722bde --- /dev/null +++ b/backend/alembic/versions/20260527_08_remove_generic_attachment_permissions.py @@ -0,0 +1,213 @@ +"""remove generic attachment permissions + +Revision ID: 20260527_08 +Revises: 20260527_07_permissions +Create Date: 2026-05-27 17:10:00.000000 + +""" + +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +revision: str = "20260527_08" +down_revision: Union[str, None] = "20260527_07_permissions" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +GENERIC_TO_MODULE_KEYS = { + "attachments:create": ( + "fees_contracts_attachments:create", + "startup_initiation_attachments:create", + "startup_ethics_attachments:create", + "startup_auth_attachments:create", + "drug_shipments_attachments:create", + "precautions_attachments:create", + "faq_attachments:create", + ), + "attachments:read": ( + "fees_contracts_attachments:read", + "startup_initiation_attachments:read", + "startup_ethics_attachments:read", + "startup_auth_attachments:read", + "drug_shipments_attachments:read", + "precautions_attachments:read", + "faq_attachments:read", + ), + "attachments:delete": ( + "fees_contracts_attachments:delete", + "startup_initiation_attachments:delete", + "startup_ethics_attachments:delete", + "startup_auth_attachments:delete", + "drug_shipments_attachments:delete", + "precautions_attachments:delete", + "faq_attachments:delete", + ), +} + + +def _table_exists(inspector: sa.Inspector, table_name: str) -> bool: + return table_name in inspector.get_table_names() + + +def _expand_api_endpoint_permission(old_key: str, new_key: str) -> None: + op.execute( + f""" + INSERT INTO api_endpoint_permissions (id, study_id, role, endpoint_key, allowed, updated_at) + SELECT (md5(random()::text || clock_timestamp()::text))::uuid, source.study_id, source.role, '{new_key}', source.allowed, NOW() + FROM api_endpoint_permissions AS source + WHERE source.endpoint_key = '{old_key}' + AND NOT EXISTS ( + SELECT 1 + FROM api_endpoint_permissions AS target + WHERE target.study_id = source.study_id + AND target.role = source.role + AND target.endpoint_key = '{new_key}' + ) + """ + ) + + +def _remove_api_endpoint_permissions(keys: tuple[str, ...]) -> None: + quoted_keys = ", ".join(f"'{key}'" for key in keys) + op.execute(f"DELETE FROM api_endpoint_permissions WHERE endpoint_key IN ({quoted_keys})") + + +def _expand_template_permission_key(table_name: str, old_key: str, new_key: str, *, touch_updated_at: bool = False) -> None: + updated_at_assignment = ", updated_at = NOW()" if touch_updated_at else "" + op.execute( + f""" + UPDATE {table_name} + SET permissions = ( + SELECT jsonb_object_agg( + role_key, + CASE + WHEN role_permissions ? '{old_key}' AND NOT role_permissions ? '{new_key}' + THEN role_permissions || jsonb_build_object('{new_key}', role_permissions -> '{old_key}') + ELSE role_permissions + END + ) + FROM jsonb_each(permissions::jsonb) AS role_entries(role_key, role_permissions) + ){updated_at_assignment} + WHERE EXISTS ( + SELECT 1 + FROM jsonb_each(permissions::jsonb) AS role_entries(role_key, role_permissions) + WHERE role_permissions ? '{old_key}' + ) + """ + ) + + +def _remove_template_permission_key(table_name: str, key: str, *, touch_updated_at: bool = False) -> None: + updated_at_assignment = ", updated_at = NOW()" if touch_updated_at else "" + op.execute( + f""" + UPDATE {table_name} + SET permissions = ( + SELECT jsonb_object_agg(role_key, role_permissions - '{key}') + FROM jsonb_each(permissions::jsonb) AS role_entries(role_key, role_permissions) + ){updated_at_assignment} + WHERE EXISTS ( + SELECT 1 + FROM jsonb_each(permissions::jsonb) AS role_entries(role_key, role_permissions) + WHERE role_permissions ? '{key}' + ) + """ + ) + + +def _restore_api_endpoint_permission(old_key: str, new_keys: tuple[str, ...]) -> None: + quoted_new_keys = ", ".join(f"'{key}'" for key in new_keys) + op.execute( + f""" + INSERT INTO api_endpoint_permissions (id, study_id, role, endpoint_key, allowed, updated_at) + SELECT (md5(random()::text || clock_timestamp()::text))::uuid, source.study_id, source.role, '{old_key}', bool_or(source.allowed), NOW() + FROM api_endpoint_permissions AS source + WHERE source.endpoint_key IN ({quoted_new_keys}) + GROUP BY source.study_id, source.role + HAVING NOT EXISTS ( + SELECT 1 + FROM api_endpoint_permissions AS target + WHERE target.study_id = source.study_id + AND target.role = source.role + AND target.endpoint_key = '{old_key}' + ) + """ + ) + + +def _restore_template_permission_key(table_name: str, old_key: str, new_keys: tuple[str, ...], *, touch_updated_at: bool = False) -> None: + updated_at_assignment = ", updated_at = NOW()" if touch_updated_at else "" + new_key_checks = " OR ".join(f"role_permissions ? '{key}'" for key in new_keys) + new_key_values = ", ".join(f"role_permissions -> '{key}'" for key in new_keys) + op.execute( + f""" + UPDATE {table_name} + SET permissions = ( + SELECT jsonb_object_agg( + role_key, + CASE + WHEN NOT role_permissions ? '{old_key}' AND ({new_key_checks}) + THEN role_permissions || jsonb_build_object('{old_key}', COALESCE({new_key_values})) + ELSE role_permissions + END + ) + FROM jsonb_each(permissions::jsonb) AS role_entries(role_key, role_permissions) + ){updated_at_assignment} + WHERE EXISTS ( + SELECT 1 + FROM jsonb_each(permissions::jsonb) AS role_entries(role_key, role_permissions) + WHERE {new_key_checks} + ) + """ + ) + + +def upgrade() -> None: + bind = op.get_bind() + inspector = sa.inspect(bind) + old_keys = tuple(GENERIC_TO_MODULE_KEYS) + + if _table_exists(inspector, "api_endpoint_permissions"): + for old_key, new_keys in GENERIC_TO_MODULE_KEYS.items(): + for new_key in new_keys: + _expand_api_endpoint_permission(old_key, new_key) + _remove_api_endpoint_permissions(old_keys) + + if _table_exists(inspector, "permission_templates"): + for old_key, new_keys in GENERIC_TO_MODULE_KEYS.items(): + for new_key in new_keys: + _expand_template_permission_key("permission_templates", old_key, new_key, touch_updated_at=True) + _remove_template_permission_key("permission_templates", old_key, touch_updated_at=True) + + if _table_exists(inspector, "permission_template_versions"): + for old_key, new_keys in GENERIC_TO_MODULE_KEYS.items(): + for new_key in new_keys: + _expand_template_permission_key("permission_template_versions", old_key, new_key) + _remove_template_permission_key("permission_template_versions", old_key) + + +def downgrade() -> None: + bind = op.get_bind() + inspector = sa.inspect(bind) + module_keys = tuple(key for new_keys in GENERIC_TO_MODULE_KEYS.values() for key in new_keys) + + if _table_exists(inspector, "api_endpoint_permissions"): + for old_key, new_keys in GENERIC_TO_MODULE_KEYS.items(): + _restore_api_endpoint_permission(old_key, new_keys) + _remove_api_endpoint_permissions(module_keys) + + if _table_exists(inspector, "permission_templates"): + for old_key, new_keys in GENERIC_TO_MODULE_KEYS.items(): + _restore_template_permission_key("permission_templates", old_key, new_keys, touch_updated_at=True) + for new_key in new_keys: + _remove_template_permission_key("permission_templates", new_key, touch_updated_at=True) + + if _table_exists(inspector, "permission_template_versions"): + for old_key, new_keys in GENERIC_TO_MODULE_KEYS.items(): + _restore_template_permission_key("permission_template_versions", old_key, new_keys) + for new_key in new_keys: + _remove_template_permission_key("permission_template_versions", new_key) diff --git a/backend/app/api/v1/attachments.py b/backend/app/api/v1/attachments.py index aa3db661..fc71609d 100644 --- a/backend/app/api/v1/attachments.py +++ b/backend/app/api/v1/attachments.py @@ -8,11 +8,16 @@ 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, require_api_permission +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_api_permission from app.crud import attachment as attachment_crud from app.crud import audit as audit_crud +from app.crud import contract_fee as contract_fee_crud +from app.crud import drug_shipment as drug_shipment_crud +from app.crud import faq_reply as faq_reply_crud +from app.crud import precaution as precaution_crud from app.crud import study as study_crud +from app.crud import startup as startup_crud from app.crud import user as user_crud from app.crud import member as member_crud from app.core.security import decode_token @@ -23,6 +28,34 @@ router = APIRouter() global_router = APIRouter() UPLOAD_ROOT = Path(__file__).resolve().parent.parent.parent / "uploads" +FEE_ATTACHMENT_ENTITY_TYPES = { + "contract_fee_contract", + "contract_fee_voucher", + "contract_fee_invoice", +} +STARTUP_AUTH_ATTACHMENT_ENTITY_TYPES = { + "startup_kickoff", + "startup_kickoff_minutes", + "startup_kickoff_signin", + "startup_kickoff_ppt", + "training_authorization", +} + +ATTACHMENT_PERMISSION_MODULES = { + "contract_fee_contract": "fees_contracts_attachments", + "contract_fee_voucher": "fees_contracts_attachments", + "contract_fee_invoice": "fees_contracts_attachments", + "startup_feasibility": "startup_initiation_attachments", + "startup_ethics": "startup_ethics_attachments", + "startup_kickoff": "startup_auth_attachments", + "startup_kickoff_minutes": "startup_auth_attachments", + "startup_kickoff_signin": "startup_auth_attachments", + "startup_kickoff_ppt": "startup_auth_attachments", + "training_authorization": "startup_auth_attachments", + "drug_shipment": "drug_shipments_attachments", + "precaution": "precautions_attachments", + "faq_replies": "faq_attachments", +} def _content_disposition(filename: str, disposition: str = "inline") -> str: @@ -38,12 +71,132 @@ async def _ensure_study_exists(db: AsyncSession, study_id: uuid.UUID): return study +def _role_value(user) -> str: + return user.role.value if hasattr(user.role, "value") else user.role + + +def _attachment_operation(action: str) -> str: + if action in {"create", "read", "delete"}: + return action + return "read" if action == "read" else "create" + + +async def _resolve_attachment_parent_permission( + db: AsyncSession, + study_id: uuid.UUID, + entity_type: str, + entity_id: uuid.UUID, + action: str, +) -> str | None: + parent_action = "read" if action == "read" else "update" + + if entity_type in FEE_ATTACHMENT_ENTITY_TYPES: + contract = await contract_fee_crud.get_contract_fee(db, entity_id) + if not contract or contract.project_id != study_id: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="合同费用不存在") + return "fees_contracts:read" if action == "read" else "fees_contracts:update" + + if entity_type == "startup_feasibility": + record = await startup_crud.get_feasibility(db, entity_id) + if not record or record.study_id != study_id: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="立项记录不存在") + return f"startup_initiation:{parent_action}" + + if entity_type == "startup_ethics": + record = await startup_crud.get_ethics(db, entity_id) + if not record or record.study_id != study_id: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="伦理记录不存在") + return f"startup_ethics:{parent_action}" + + if entity_type in STARTUP_AUTH_ATTACHMENT_ENTITY_TYPES: + if entity_type == "training_authorization": + record = await startup_crud.get_training_authorization(db, entity_id) + missing_detail = "培训授权不存在" + else: + record = await startup_crud.get_kickoff(db, entity_id) + missing_detail = "启动会不存在" + if not record or record.study_id != study_id: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=missing_detail) + return "startup_auth:read" if action == "read" else "startup_auth:update" + + if entity_type == "drug_shipment": + shipment = await drug_shipment_crud.get_shipment(db, entity_id) + if not shipment or shipment.study_id != study_id: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="药物发货不存在") + return "drug_shipments:read" if action == "read" else "drug_shipments:update" + + if entity_type == "precaution": + precaution = await precaution_crud.get_precaution(db, entity_id) + if not precaution or precaution.study_id != study_id: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="注意事项不存在") + return "precautions:read" if action == "read" else "precautions:update" + + if entity_type == "faq_replies": + reply = await faq_reply_crud.get_reply(db, entity_id) + if not reply or reply.study_id != study_id: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="FAQ回复不存在") + if action == "read": + return "faq:read" + if action == "delete": + return "faq_reply:delete" + return "faq_reply:create" + + return None + + +async def _ensure_attachment_permission( + db: AsyncSession, + study_id: uuid.UUID, + entity_type: str, + entity_id: uuid.UUID, + action: str, + current_user, + membership=None, +) -> None: + parent_permission = await _resolve_attachment_parent_permission(db, study_id, entity_type, entity_id, action) + role_value = _role_value(current_user) + if role_value == "ADMIN": + return + + if membership is None: + 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="不是项目成员") + + operation = _attachment_operation(action) + module_prefix = ATTACHMENT_PERMISSION_MODULES.get(entity_type) + if not module_prefix: + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="附件类型未配置权限") + + attachment_allowed = await role_has_api_permission( + db, + study_id, + membership.role_in_study, + f"{module_prefix}:{operation}", + check_prerequisites=False, + ) + if not attachment_allowed: + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="接口权限不足") + + if not parent_permission: + return + + allowed = await role_has_api_permission( + db, + study_id, + membership.role_in_study, + parent_permission, + check_prerequisites=False, + ) + 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_api_permission("attachments:create")), Depends(require_study_not_locked()) ], ) @@ -56,6 +209,7 @@ 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, entity_type, entity_id, "create", current_user) 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}" @@ -100,7 +254,6 @@ 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, @@ -110,6 +263,7 @@ 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, entity_type, entity_id, "read", current_user) 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) @@ -132,7 +286,6 @@ async def list_attachments( @router.get( "/{attachment_id}/download", - dependencies=[Depends(require_api_permission("attachments:read"))], ) async def download_attachment( study_id: uuid.UUID, @@ -143,6 +296,7 @@ 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, entity_type, entity_id, "read", current_user) 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="附件不存在") @@ -158,7 +312,6 @@ async def download_attachment( @router.get( "/{attachment_id}/preview", - dependencies=[Depends(require_api_permission("attachments:read"))], ) async def preview_attachment( study_id: uuid.UUID, @@ -169,6 +322,7 @@ 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, entity_type, entity_id, "read", current_user) 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="附件不存在") @@ -195,7 +349,7 @@ async def _authorize_global(request: Request, db: AsyncSession, study_id: uuid.U user = await user_crud.get_by_id(db, uuid.UUID(str(payload.get("sub")))) if not user or not user.is_active: raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="账号不存在或已停用") - if user.role == "ADMIN": + if _role_value(user) == "ADMIN": return user, None membership = await member_crud.get_member(db, study_id, user.id) if not membership or not membership.is_active: @@ -216,20 +370,10 @@ async def global_download_attachment( if not attachment: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="附件不存在") await _ensure_study_exists(db, attachment.study_id) - user, _ = await _authorize_global(request, db, attachment.study_id) - membership = None - 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_api_permission( - db, - attachment.study_id, - membership.role_in_study if membership else None, - "attachments:read", - check_prerequisites=False, - ) - if not allowed: - raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="权限不足") + user, membership = await _authorize_global(request, db, attachment.study_id) + await _ensure_attachment_permission( + db, attachment.study_id, attachment.entity_type, attachment.entity_id, "read", user, membership + ) if not os.path.exists(attachment.file_path): raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="服务器未找到文件") return FileResponse( @@ -254,16 +398,9 @@ async def global_preview_attachment( raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="附件不存在") 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_api_permission( - db, - attachment.study_id, - membership.role_in_study if membership else None, - "attachments:read", - check_prerequisites=False, - ) - if not allowed: - raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="权限不足") + await _ensure_attachment_permission( + db, attachment.study_id, attachment.entity_type, attachment.entity_id, "read", user, membership + ) if not os.path.exists(attachment.file_path): raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="服务器未找到文件") return FileResponse( @@ -288,18 +425,11 @@ async def global_delete_attachment( raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="附件不存在") 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_api_permission( - db, - attachment.study_id, - membership.role_in_study if membership else None, - "attachments:delete", - check_prerequisites=False, - ) - if not allowed: - raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="权限不足") + await _ensure_attachment_permission( + db, attachment.study_id, attachment.entity_type, attachment.entity_id, "delete", user, membership + ) can_delete = ( - user.role == "ADMIN" + _role_value(user) == "ADMIN" or attachment.uploaded_by == user.id or membership is not None ) @@ -322,7 +452,6 @@ async def global_delete_attachment( "/{attachment_id}", status_code=status.HTTP_204_NO_CONTENT, dependencies=[ - Depends(require_api_permission("attachments:delete")), Depends(require_study_not_locked()) ], ) @@ -335,6 +464,7 @@ async def delete_attachment( current_user=Depends(get_current_user), ): await _ensure_study_exists(db, study_id) + await _ensure_attachment_permission(db, study_id, entity_type, entity_id, "delete", current_user) attachment = await attachment_crud.get_attachment(db, attachment_id) if ( not attachment @@ -346,11 +476,11 @@ async def delete_attachment( raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="附件不存在") membership = None - if current_user.role != "ADMIN": + if _role_value(current_user) != "ADMIN": membership = await get_study_member(study_id, current_user=current_user, db=db) can_delete = ( - current_user.role == "ADMIN" + _role_value(current_user) == "ADMIN" or attachment.uploaded_by == current_user.id or membership is not None ) diff --git a/backend/app/api/v1/fees_attachments.py b/backend/app/api/v1/fees_attachments.py index 8f04c59f..7922a51f 100644 --- a/backend/app/api/v1/fees_attachments.py +++ b/backend/app/api/v1/fees_attachments.py @@ -69,7 +69,7 @@ async def _authorize_download_user(request: Request, db: AsyncSession): response_model=FeeApiResponse[FeeAttachmentRead], status_code=status.HTTP_201_CREATED, dependencies=[ - Depends(require_api_permission("fees_attachments:create")), + Depends(require_api_permission("fees_contracts:update")), Depends(require_study_not_locked()) ], ) @@ -139,7 +139,7 @@ async def upload_fee_attachment( @router.get( "/attachments", response_model=FeeApiResponse[list[FeeAttachmentRead]], - dependencies=[Depends(require_api_permission("fees_attachments:read"))], + dependencies=[Depends(require_api_permission("fees_contracts:read"))], ) async def list_fee_attachments( entity_type: str, @@ -195,7 +195,7 @@ async def download_fee_attachment( 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 + db, project_id, membership.role_in_study, "fees_contracts:read", check_prerequisites=False ) if not allowed: raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="项目权限不足") @@ -213,7 +213,7 @@ async def download_fee_attachment( "/attachments/{attachment_id}", status_code=status.HTTP_204_NO_CONTENT, dependencies=[ - Depends(require_api_permission("fees_attachments:delete")), + Depends(require_api_permission("fees_contracts:update")), Depends(require_study_not_locked()) ], ) diff --git a/frontend/src/components/attachments/AttachmentList.vue b/frontend/src/components/attachments/AttachmentList.vue index 104c5b0a..2a2de501 100644 --- a/frontend/src/components/attachments/AttachmentList.vue +++ b/frontend/src/components/attachments/AttachmentList.vue @@ -3,7 +3,7 @@