diff --git a/backend/app/api/v1/module_system/dept/model.py b/backend/app/api/v1/module_system/dept/model.py index c7f048fc..a507c5b2 100644 --- a/backend/app/api/v1/module_system/dept/model.py +++ b/backend/app/api/v1/module_system/dept/model.py @@ -3,6 +3,7 @@ from typing import TYPE_CHECKING from sqlalchemy import ForeignKey, Integer, String from sqlalchemy.orm import Mapped, mapped_column, relationship +from app.common.enums import PermissionFilterStrategy from app.core.base_model import ModelMixin if TYPE_CHECKING: @@ -18,6 +19,7 @@ class DeptModel(ModelMixin): __tablename__: str = "sys_dept" __table_args__: dict[str, str] = {"comment": "部门表"} __loader_options__: list[str] = [] + __permission_strategy__: PermissionFilterStrategy = PermissionFilterStrategy.DEPT_BASED name: Mapped[str] = mapped_column(String(64), nullable=False, comment="部门名称") order: Mapped[int] = mapped_column(Integer, nullable=False, default=999, comment="显示排序") diff --git a/backend/app/api/v1/module_system/menu/model.py b/backend/app/api/v1/module_system/menu/model.py index 813aebc2..c6b7814e 100644 --- a/backend/app/api/v1/module_system/menu/model.py +++ b/backend/app/api/v1/module_system/menu/model.py @@ -3,6 +3,7 @@ from typing import TYPE_CHECKING from sqlalchemy import JSON, Boolean, ForeignKey, Integer, String from sqlalchemy.orm import Mapped, mapped_column, relationship +from app.common.enums import PermissionFilterStrategy from app.core.base_model import ModelMixin if TYPE_CHECKING: @@ -23,6 +24,7 @@ class MenuModel(ModelMixin): __tablename__: str = "sys_menu" __table_args__: dict[str, str] = {"comment": "菜单表"} __loader_options__: list[str] = ["roles"] + __permission_strategy__: PermissionFilterStrategy = PermissionFilterStrategy.ROLE_BASED name: Mapped[str] = mapped_column(String(50), nullable=False, comment="菜单名称") type: Mapped[int] = mapped_column( diff --git a/backend/app/api/v1/module_system/role/model.py b/backend/app/api/v1/module_system/role/model.py index 12224dde..2e72fe86 100644 --- a/backend/app/api/v1/module_system/role/model.py +++ b/backend/app/api/v1/module_system/role/model.py @@ -3,6 +3,7 @@ from typing import TYPE_CHECKING from sqlalchemy import ForeignKey, Integer, String from sqlalchemy.orm import Mapped, mapped_column, relationship +from app.common.enums import PermissionFilterStrategy from app.core.base_model import MappedBase, ModelMixin if TYPE_CHECKING: @@ -63,11 +64,14 @@ class RoleDeptsModel(MappedBase): class RoleModel(ModelMixin): """ 角色模型 + + 角色列表只显示当前用户绑定的角色 """ __tablename__: str = "sys_role" __table_args__: dict[str, str] = {"comment": "角色表"} __loader_options__: list[str] = ["menus", "depts"] + __permission_strategy__: PermissionFilterStrategy = PermissionFilterStrategy.USER_ROLE name: Mapped[str] = mapped_column(String(64), nullable=False, comment="角色名称") code: Mapped[str | None] = mapped_column( @@ -81,7 +85,6 @@ class RoleModel(ModelMixin): comment="数据权限范围(1:仅本人 2:本部门 3:本部门及以下 4:全部 5:自定义)", ) - # 关联关系 (继承自UserMixin) menus: Mapped[list["MenuModel"]] = relationship( secondary="sys_role_menus", back_populates="roles", diff --git a/backend/app/common/enums.py b/backend/app/common/enums.py index 7e89ac77..92d7ddb5 100644 --- a/backend/app/common/enums.py +++ b/backend/app/common/enums.py @@ -94,3 +94,16 @@ class QueueEnum(str, Enum): ge = ">=" or "ge" lt = "<" or "lt" le = "<=" or "le" + + +class PermissionFilterStrategy(str, Enum): + """ + 权限过滤策略枚举 + + 定义不同的权限过滤策略,让模型选择合适的过滤方式 + """ + DATA_SCOPE = "data_scope" # 基于数据范围权限(默认) + ROLE_BASED = "role_based" # 基于角色授权(菜单) + DEPT_BASED = "dept_based" # 基于部门关联(部门、角色) + SELF_ONLY = "self_only" # 仅本人数据 + USER_ROLE = "user_role" # 当前用户绑定的角色 diff --git a/backend/app/core/base_model.py b/backend/app/core/base_model.py index 37b33b67..bc4b4ccd 100644 --- a/backend/app/core/base_model.py +++ b/backend/app/core/base_model.py @@ -14,6 +14,7 @@ from sqlalchemy.orm import ( if TYPE_CHECKING: from app.api.v1.module_system.user.model import UserModel +from app.common.enums import PermissionFilterStrategy from app.utils.common_util import uuid4_str @@ -32,6 +33,9 @@ class MappedBase(AsyncAttrs, DeclarativeBase): __abstract__: bool = True + # 权限过滤策略,子类可以覆盖 + __permission_strategy__: PermissionFilterStrategy = PermissionFilterStrategy.DATA_SCOPE + class ModelMixin(MappedBase): """ diff --git a/backend/app/core/permission.py b/backend/app/core/permission.py index c3217ab4..4dce2e9e 100644 --- a/backend/app/core/permission.py +++ b/backend/app/core/permission.py @@ -6,12 +6,15 @@ from sqlalchemy.sql.elements import ColumnElement from app.api.v1.module_system.auth.schema import AuthSchema from app.api.v1.module_system.dept.model import DeptModel from app.api.v1.module_system.user.model import UserModel +from app.common.enums import PermissionFilterStrategy from app.utils.common_util import get_child_id_map, get_child_recursion class Permission: """ 为业务模型提供数据权限过滤功能 + + 使用策略模式,根据模型的 __permission_strategy__ 属性选择合适的过滤策略 """ # 数据权限常量定义,提高代码可读性 @@ -51,18 +54,8 @@ class Permission: async def __permission_condition(self) -> ColumnElement | None: """ 应用数据范围权限隔离 - 基于角色的五种数据权限范围过滤 - 支持五种权限类型: - 1. 仅本人数据权限 - 只能查看自己创建的数据 - 2. 本部门数据权限 - 只能查看同部门的数据 - 3. 本部门及以下数据权限 - 可以查看本部门及所有子部门的数据 - 4. 全部数据权限 - 可以查看所有数据 - 5. 自定义数据权限 - 通过role_dept_relation表定义可访问的部门列表 - 权限处理原则: - - 多个角色的权限取并集(最宽松原则) - - 优先级:全部数据 > 部门权限(2、3、5的并集)> 仅本人 - - 构造权限过滤表达式,返回None表示不限制 + 根据模型的权限过滤策略,选择合适的过滤方法 """ # 如果不需要检查数据权限,则不限制 if not self.auth.user: @@ -72,37 +65,200 @@ class Permission: if not self.auth.check_data_scope: return None - # 如果模型没有创建人created_id字段,则不限制 - if not hasattr(self.model, "created_id"): - return None - # 超级管理员可以查看所有数据 if self.auth.user.is_superuser: return None + # 获取模型的权限过滤策略 + strategy = getattr(self.model, "__permission_strategy__", PermissionFilterStrategy.DATA_SCOPE) + + # 根据策略选择过滤方法 + if strategy == PermissionFilterStrategy.ROLE_BASED: + return await self.__filter_by_role_based() + elif strategy == PermissionFilterStrategy.DEPT_BASED: + return await self.__filter_by_dept_based() + elif strategy == PermissionFilterStrategy.SELF_ONLY: + return await self.__filter_by_self_only() + elif strategy == PermissionFilterStrategy.USER_ROLE: + return await self.__filter_by_user_role() + else: + return await self.__filter_by_data_scope() + + async def __filter_by_role_based(self) -> ColumnElement | None: + """ + 基于角色授权的权限过滤(适用于菜单等) + + 只显示用户角色授权的菜单 + """ + roles = getattr(self.auth.user, "roles", []) or [] + if not roles: + id_attr = getattr(self.model, "id", None) + if id_attr is not None: + return id_attr == -1 + return None + + menu_ids = set() + for role in roles: + if hasattr(role, "menus") and role.menus: + menu_ids.update(menu.id for menu in role.menus if menu.status == "0") + + if menu_ids: + id_attr = getattr(self.model, "id", None) + if id_attr is not None: + return id_attr.in_(list(menu_ids)) + + id_attr = getattr(self.model, "id", None) + if id_attr is not None: + return id_attr == -1 + return None + + async def __filter_by_user_role(self) -> ColumnElement | None: + """ + 基于当前用户绑定角色的权限过滤(适用于角色列表) + + 只显示当前用户绑定的角色 + """ + roles = getattr(self.auth.user, "roles", []) or [] + if not roles: + id_attr = getattr(self.model, "id", None) + if id_attr is not None: + return id_attr == -1 + return None + + role_ids = [role.id for role in roles] + id_attr = getattr(self.model, "id", None) + if id_attr is not None: + return id_attr.in_(role_ids) + return None + + async def __filter_by_dept_based(self) -> ColumnElement | None: + """ + 基于部门关联的权限过滤(适用于部门、角色等) + + 根据用户的部门权限范围过滤数据 + """ + # 如果用户没有角色,则只能查看自己部门的数据 + roles = getattr(self.auth.user, "roles", []) or [] + if not roles: + user_dept_id = getattr(self.auth.user, "dept_id", None) + if user_dept_id is not None and hasattr(self.model, "id"): + id_attr = getattr(self.model, "id", None) + if id_attr is not None: + return id_attr == user_dept_id + return None + + # 获取用户所有角色的权限范围 + data_scopes = set() + custom_dept_ids = set() + + for role in roles: + data_scopes.add(role.data_scope) + if role.data_scope == self.DATA_SCOPE_CUSTOM and hasattr(role, "depts") and role.depts: + custom_dept_ids.update(dept.id for dept in role.depts) + + # 全部数据权限最高优先级 + if self.DATA_SCOPE_ALL in data_scopes: + return None + + # 收集所有可访问的部门ID + accessible_dept_ids = await self.__get_accessible_dept_ids(data_scopes, custom_dept_ids) + + # 根据模型类型过滤 + if self.model.__name__ == "DeptModel": + return self.__filter_dept_model(accessible_dept_ids) + elif self.model.__name__ == "UserModel": + return self.__filter_user_model(accessible_dept_ids) + else: + return None + + async def __filter_by_self_only(self) -> ColumnElement | None: + """ + 仅本人数据权限过滤 + """ + created_id_attr = getattr(self.model, "created_id", None) + if created_id_attr is not None and self.auth.user: + return created_id_attr == self.auth.user.id + return None + + async def __filter_by_data_scope(self) -> ColumnElement | None: + """ + 基于数据范围权限的通用过滤(默认策略) + + 适用于大多数业务模型 + """ + # 如果模型没有创建人created_id字段,则不限制 + if not hasattr(self.model, "created_id"): + return None + # 如果用户没有角色,则只能查看自己的数据 roles = getattr(self.auth.user, "roles", []) or [] if not roles: created_id_attr = getattr(self.model, "created_id", None) - if created_id_attr is not None: + if created_id_attr is not None and self.auth.user: return created_id_attr == self.auth.user.id return None # 获取用户所有角色的权限范围 data_scopes = set() - custom_dept_ids = set() # 自定义权限(data_scope=5)关联的部门ID集合 + custom_dept_ids = set() for role in roles: data_scopes.add(role.data_scope) - # 收集自定义权限(data_scope=5)关联的部门ID if role.data_scope == self.DATA_SCOPE_CUSTOM and hasattr(role, "depts") and role.depts: custom_dept_ids.update(dept.id for dept in role.depts) - # 权限优先级处理:全部数据权限最高优先级 + # 全部数据权限最高优先级 if self.DATA_SCOPE_ALL in data_scopes: return None - # 收集所有可访问的部门ID(2、3、5权限的并集) + # 收集所有可访问的部门ID + accessible_dept_ids = await self.__get_accessible_dept_ids(data_scopes, custom_dept_ids) + + # 如果有部门权限,使用部门过滤 + if accessible_dept_ids: + # 特殊处理:如果模型本身就是UserModel,直接过滤用户的dept_id + if self.model.__name__ == "UserModel" and hasattr(self.model, "dept_id"): + dept_id_attr = getattr(self.model, "dept_id", None) + if dept_id_attr is not None: + return dept_id_attr.in_(list(accessible_dept_ids)) + + # 其他模型:通过created_by关系过滤创建人的部门 + creator_rel = getattr(self.model, "created_by", None) + if creator_rel is not None and hasattr(UserModel, "dept_id"): + return creator_rel.has(UserModel.dept_id.in_(list(accessible_dept_ids))) + + # 降级方案:只能查看自己的数据 + created_id_attr = getattr(self.model, "created_id", None) + if created_id_attr is not None and self.auth.user: + return created_id_attr == self.auth.user.id + return None + + # 处理仅本人数据权限 + if self.DATA_SCOPE_SELF in data_scopes: + created_id_attr = getattr(self.model, "created_id", None) + if created_id_attr is not None and self.auth.user: + return created_id_attr == self.auth.user.id + return None + + # 默认情况:只能查看自己的数据 + created_id_attr = getattr(self.model, "created_id", None) + if created_id_attr is not None and self.auth.user: + return created_id_attr == self.auth.user.id + return None + + async def __get_accessible_dept_ids( + self, data_scopes: set, custom_dept_ids: set + ) -> set[int]: + """ + 获取用户可访问的所有部门ID + + Args: + data_scopes: 用户角色的数据权限范围集合 + custom_dept_ids: 自定义权限关联的部门ID集合 + + Returns: + 可访问的部门ID集合 + """ accessible_dept_ids = set() user_dept_id = getattr(self.auth.user, "dept_id", None) @@ -117,39 +273,38 @@ class Permission: # 处理本部门及以下数据权限(3) if self.DATA_SCOPE_DEPT_AND_CHILD in data_scopes and user_dept_id is not None: try: - # 查询所有部门并递归获取子部门 dept_sql = select(DeptModel) dept_result = await self.auth.db.execute(dept_sql) dept_objs = dept_result.scalars().all() id_map = get_child_id_map(dept_objs) - # get_child_recursion返回的结果已包含自身ID和所有子部门ID dept_with_children_ids = get_child_recursion(id=user_dept_id, id_map=id_map) accessible_dept_ids.update(dept_with_children_ids) except Exception: - # 查询失败时降级到本部门 accessible_dept_ids.add(user_dept_id) - # 如果有部门权限(2、3、5任一),使用部门过滤 + return accessible_dept_ids + + def __filter_dept_model(self, accessible_dept_ids: set[int]) -> ColumnElement | None: + """ + 过滤部门模型 + """ if accessible_dept_ids: - creator_rel = getattr(self.model, "created_by", None) - # 优先使用关系过滤(性能更好) - if creator_rel is not None and hasattr(UserModel, "dept_id"): - return creator_rel.has(UserModel.dept_id.in_(list(accessible_dept_ids))) - # 降级方案:如果模型没有created_by关系但有created_id,则只能查看自己的数据 - created_id_attr = getattr(self.model, "created_id", None) - if created_id_attr is not None: - return created_id_attr == self.auth.user.id - return None + id_attr = getattr(self.model, "id", None) + if id_attr is not None: + return id_attr.in_(list(accessible_dept_ids)) + user_dept_id = getattr(self.auth.user, "dept_id", None) + if user_dept_id is not None: + id_attr = getattr(self.model, "id", None) + if id_attr is not None: + return id_attr == user_dept_id + return None - # 处理仅本人数据权限(1) - if self.DATA_SCOPE_SELF in data_scopes: - created_id_attr = getattr(self.model, "created_id", None) - if created_id_attr is not None: - return created_id_attr == self.auth.user.id - return None - - # 默认情况:如果用户有角色但没有任何有效权限范围,只能查看自己的数据 - created_id_attr = getattr(self.model, "created_id", None) - if created_id_attr is not None: - return created_id_attr == self.auth.user.id + def __filter_user_model(self, accessible_dept_ids: set[int]) -> ColumnElement | None: + """ + 过滤用户模型 + """ + if accessible_dept_ids: + dept_id_attr = getattr(self.model, "dept_id", None) + if dept_id_attr is not None: + return dept_id_attr.in_(list(accessible_dept_ids)) return None diff --git a/backend/app/scripts/data/sys_menu.json b/backend/app/scripts/data/sys_menu.json index 86f30684..c3221a42 100644 --- a/backend/app/scripts/data/sys_menu.json +++ b/backend/app/scripts/data/sys_menu.json @@ -633,6 +633,25 @@ "affix": false, "redirect": null, "description": "初始化数据" + }, + { + "name": "分配权限", + "type": 3, + "icon": null, + "order": 8, + "permission": "module_system:role:permission", + "route_name": null, + "route_path": null, + "component_path": null, + "status": "0", + "keep_alive": true, + "hidden": false, + "always_show": false, + "title": "分配权限", + "params": null, + "affix": false, + "redirect": null, + "description": "初始化数据" } ] }, @@ -2829,7 +2848,7 @@ "name": "流程编排", "type": 2, "icon": "el-icon-Share", - "order": 2, + "order": 3, "permission": "module_task:workflow:query", "route_name": "Workflow", "route_path": "/task/workflow", diff --git a/frontend/src/views/dashboard/index.vue b/frontend/src/views/dashboard/index.vue index c58b277b..9a43e601 100644 --- a/frontend/src/views/dashboard/index.vue +++ b/frontend/src/views/dashboard/index.vue @@ -7,10 +7,18 @@