"""B6主案ACL查询、动态继承及权限全集差异保存。""" from __future__ import annotations from datetime import datetime, timezone from typing import Any from sqlalchemy import select from sqlalchemy.exc import IntegrityError from dms.common.enums import ( AuditAction, AuditTarget, DocumentType, EnabledStatus, SubjectType, VisibilityType, ) from dms.common.errors import ( AttachmentHasNoAclError, ConflictError, DuplicatePermissionSubjectError, InvalidArgumentError, ResourceNotFoundError, SubPlanPermissionInheritedError, ) from dms.common.response import serialize_id from dms.database.transaction import execute_with_deadlock_retry, transaction from dms.extensions import db from dms.models import Document, Organization, Permission, User from dms.security.auth_context import get_auth_context from dms.services.audit_service import business_audit from dms.services.authorization_service import require_security_clearance PERMISSION_FIELDS = { "subjectType", "subjectId", "canView", "canDownload", "canEdit", "canManagePermission", "canDelete", } SAVE_FIELDS = {"visibilityType", "documentRowVersion", "entries"} ACTION_FIELDS = ( "canView", "canDownload", "canEdit", "canManagePermission", "canDelete", ) def _now() -> datetime: return datetime.now(timezone.utc).replace(tzinfo=None) def _string_id(value: Any, name: str) -> int: if not isinstance(value, str) or not value.isdecimal() or int(value) <= 0: raise InvalidArgumentError(f"{name}必须是正整数形式的字符串ID") return int(value) def _version(value: Any) -> int: if type(value) is not int or value < 0: raise InvalidArgumentError("documentRowVersion必须是非负整数") return value def _document(document_id: int, *, lock: bool = False) -> Document: statement = select(Document).where( Document.id == document_id, Document.is_deleted.is_(False), ) if lock: statement = statement.with_for_update() document = db.session.scalar(statement) if document is None: raise ResourceNotFoundError("文档不存在") return document def _permission_source(document: Document, *, lock: bool = False) -> Document: if document.document_type == DocumentType.ATTACHMENT.value: raise AttachmentHasNoAclError() require_security_clearance(document, get_auth_context()) if document.document_type == DocumentType.MAIN.value: return document statement = select(Document).where( Document.id == document.root_document_id, Document.document_type == DocumentType.MAIN.value, Document.is_deleted.is_(False), ) if lock: statement = statement.with_for_update() source = db.session.scalar(statement) if source is None: raise ResourceNotFoundError("子方案所属主案不存在") return source def _entry(permission: Permission) -> dict[str, object]: return { "id": serialize_id(permission.id), "subjectType": permission.subject_type, "subjectId": serialize_id(permission.subject_id), "subjectName": permission.subject_name, "canView": bool(permission.can_view), "canDownload": bool(permission.can_download), "canEdit": bool(permission.can_edit), "canManagePermission": bool(permission.can_manage_permission), "canDelete": bool(permission.can_delete), } def _active_permissions(source_id: int, *, lock: bool = False) -> list[Permission]: statement = ( select(Permission) .where( Permission.document_id == source_id, Permission.is_deleted.is_(False), ) .order_by( Permission.subject_type.asc(), Permission.subject_id.asc(), Permission.id.asc(), ) ) if lock: statement = statement.with_for_update() return list(db.session.scalars(statement).all()) def _detail( document: Document, source: Document, permissions: list[Permission], ) -> dict[str, object]: return { "documentId": serialize_id(document.id), "sourceDocumentId": serialize_id(source.id), "inherited": document.document_type == DocumentType.SUB_PLAN.value, "visibilityType": source.visibility_type, "documentRowVersion": source.row_version, "entries": [_entry(permission) for permission in permissions], } def get_permissions(document_id: int) -> dict[str, object]: document = _document(document_id) source = _permission_source(document) return _detail(document, source, _active_permissions(source.id)) def _payload(payload: Any) -> tuple[str, int, list[dict[str, Any]]]: if not isinstance(payload, dict) or set(payload) != SAVE_FIELDS: raise InvalidArgumentError( "请求字段必须且只能包含visibilityType、documentRowVersion和entries" ) try: visibility = VisibilityType(payload["visibilityType"]).value except (TypeError, ValueError) as exc: raise InvalidArgumentError("visibilityType不是有效枚举值") from exc expected_version = _version(payload["documentRowVersion"]) raw_entries = payload["entries"] if not isinstance(raw_entries, list): raise InvalidArgumentError("entries必须是数组") entries: list[dict[str, Any]] = [] seen: set[tuple[str, int]] = set() for index, raw_entry in enumerate(raw_entries): if not isinstance(raw_entry, dict) or set(raw_entry) != PERMISSION_FIELDS: raise InvalidArgumentError( f"entries[{index}]字段必须完整且不能包含未知字段" ) try: subject_type = SubjectType(raw_entry["subjectType"]).value except (TypeError, ValueError) as exc: raise InvalidArgumentError( f"entries[{index}].subjectType不是有效枚举值" ) from exc subject_id = _string_id( raw_entry["subjectId"], f"entries[{index}].subjectId" ) for field in ACTION_FIELDS: if type(raw_entry[field]) is not bool: raise InvalidArgumentError( f"entries[{index}].{field}必须是boolean" ) key = (subject_type, subject_id) if key in seen: raise DuplicatePermissionSubjectError( details={ "subjectType": subject_type, "subjectId": serialize_id(subject_id), } ) seen.add(key) entries.append( { "subjectType": subject_type, "subjectId": subject_id, **{field: raw_entry[field] for field in ACTION_FIELDS}, } ) if visibility == VisibilityType.ALL_AUTHENTICATED.value and entries: raise InvalidArgumentError("ALL_AUTHENTICATED的entries必须为空") if visibility == VisibilityType.ORGANIZATION.value: if not entries: raise InvalidArgumentError("ORGANIZATION至少需要一个ORG权限主体") if any( entry["subjectType"] != SubjectType.ORG.value for entry in entries ): raise InvalidArgumentError("ORGANIZATION只允许ORG权限主体") return visibility, expected_version, entries def _locked_subject_names( entries: list[dict[str, Any]], ) -> dict[tuple[str, int], str]: organization_ids = sorted( entry["subjectId"] for entry in entries if entry["subjectType"] == SubjectType.ORG.value ) user_ids = sorted( entry["subjectId"] for entry in entries if entry["subjectType"] == SubjectType.USER.value ) names: dict[tuple[str, int], str] = {} if organization_ids: organizations = db.session.scalars( select(Organization) .where(Organization.id.in_(organization_ids)) .order_by(Organization.id.asc()) .with_for_update() ).all() valid = { item.id: item for item in organizations if not item.is_deleted and item.status == EnabledStatus.ENABLED.value } if set(valid) != set(organization_ids): raise ResourceNotFoundError("权限组织不存在、已删除或已禁用") names.update( { (SubjectType.ORG.value, item.id): item.org_name for item in valid.values() } ) if user_ids: users = db.session.scalars( select(User) .where(User.id.in_(user_ids)) .order_by(User.id.asc()) .with_for_update() ).all() valid = { item.id: item for item in users if not item.is_deleted and item.status == EnabledStatus.ENABLED.value } if set(valid) != set(user_ids): raise ResourceNotFoundError("权限用户不存在、已删除或已禁用") names.update( { (SubjectType.USER.value, item.id): item.real_name for item in valid.values() } ) return names def _current_version(document_id: int) -> int | None: return db.session.scalar( select(Document.row_version).where( Document.id == document_id, Document.is_deleted.is_(False), ) ) def save_permissions(document_id: int, payload: Any) -> dict[str, object]: visibility, expected_version, entries = _payload(payload) context = get_auth_context() def operation() -> dict[str, object]: with transaction() as session: document = _document(document_id, lock=True) if document.document_type == DocumentType.ATTACHMENT.value: raise AttachmentHasNoAclError() require_security_clearance(document, context) if document.document_type == DocumentType.SUB_PLAN.value: raise SubPlanPermissionInheritedError() if document.document_type != DocumentType.MAIN.value: raise ResourceNotFoundError("主案不存在或类型不匹配") if document.row_version != expected_version: raise ConflictError( "数据已被其他用户修改,请刷新后重试", details={"currentRowVersion": document.row_version}, ) current = _active_permissions(document.id, lock=True) subject_names = _locked_subject_names(entries) current_by_key = { (permission.subject_type, permission.subject_id): permission for permission in current } requested_by_key = { (entry["subjectType"], entry["subjectId"]): entry for entry in entries } added: list[dict[str, str]] = [] updated: list[dict[str, str]] = [] deleted: list[dict[str, str]] = [] now = _now() for key, permission in current_by_key.items(): requested = requested_by_key.get(key) if requested is None: permission.is_deleted = True permission.deleted_at = now permission.updated_at = now permission.updated_by = context.user_id permission.row_version += 1 deleted.append( { "subjectType": key[0], "subjectId": serialize_id(key[1]), } ) continue new_name = subject_names[key] changed = permission.subject_name != new_name or any( bool(getattr(permission, _snake_action(field))) != requested[field] for field in ACTION_FIELDS ) if changed: permission.subject_name = new_name for field in ACTION_FIELDS: setattr( permission, _snake_action(field), requested[field], ) permission.updated_at = now permission.updated_by = context.user_id permission.row_version += 1 updated.append( { "subjectType": key[0], "subjectId": serialize_id(key[1]), } ) for key, requested in requested_by_key.items(): if key in current_by_key: continue permission = Permission( document_id=document.id, subject_type=key[0], subject_id=key[1], subject_name=subject_names[key], created_by=context.user_id, updated_by=context.user_id, **{ _snake_action(field): requested[field] for field in ACTION_FIELDS }, ) session.add(permission) added.append( { "subjectType": key[0], "subjectId": serialize_id(key[1]), } ) previous_visibility = document.visibility_type document.visibility_type = visibility document.updated_by = context.user_id document.updated_by_name = context.real_name document.updated_at = now document.row_version += 1 session.flush() session.add( business_audit( action=AuditAction.CHANGE_PERMISSION, target=AuditTarget.PERMISSION, target_id=document.id, target_name=document.document_name, detail={ "mainPlanId": serialize_id(document.id), "visibilityTypeBefore": previous_visibility, "visibilityTypeAfter": visibility, "added": added, "updated": updated, "deleted": deleted, "documentRowVersion": document.row_version, }, ) ) return _detail( document, document, _active_permissions(document.id), ) try: return execute_with_deadlock_retry(operation) except IntegrityError as exc: db.session.rollback() raise ConflictError( "权限集合已被并发修改,请刷新后重试", details={"currentRowVersion": _current_version(document_id)}, ) from exc def _snake_action(field: str) -> str: return { "canView": "can_view", "canDownload": "can_download", "canEdit": "can_edit", "canManagePermission": "can_manage_permission", "canDelete": "can_delete", }[field] __all__ = ["get_permissions", "save_permissions"]