| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433 |
- """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"]
|