# 统一执行器落地代码骨架与接入示例 > 适用范围:`leaudit-platform` 后端权限改造实施阶段 > 文档定位:把“统一数据范围执行器设计”进一步落到代码骨架、接口签名、目录建议和模块接入示例层面,供研发直接照着实施。 --- ## 1. 文档目标 前面几份文档已经回答了: - 为什么要做统一执行器 - 哪些接口会受影响 - 应该按什么阶段实施 - 字段和 SQL 应该如何规范化 这一份只解决一个问题: 后端代码到底怎么改,才能在不推翻现有 service 结构的前提下,把统一执行器落进去。 核心要求: 1. 尽量兼容现有 FastAPI + service 分层风格 2. 优先通过“新增能力 + 渐进替换”推进 3. 先不大改 controller 签名 4. 先把 service 内部角色判断替换掉 --- ## 2. 当前代码风格约束 从现有代码看,后端基本遵循: 1. `Controller` - 负责接 JWT payload - 负责做基础 permission 判断 - 调用 `ServiceImpl` 2. `Service` - 负责业务逻辑 - 直接写 SQL / 组装查询 3. `PermissionServiceImpl` - 当前仅提供布尔权限判断 因此统一执行器落地时,不建议第一步就做成特别重的 AOP 或 decorator 体系。 更适合的落地方式是: 1. 先保留 controller 现状 2. 在 service 内引入统一 `PermissionDecisionService` 3. 逐步把旧的 `_getCurrentUserContext`、`_build...Filters` 替换掉 --- ## 3. 推荐目录结构 建议新增: ```text fastapi_modules/fastapi_leaudit/services/permission_scope/ __init__.py models.py enums.py exceptions.py repositories/ __init__.py permissionGrantRepository.py resourceScopeRepository.py resolvers/ __init__.py dataScopeResolver.py permissionDecisionService.py queryScopeBuilder.py policies/ __init__.py base.py documentPolicy.py govdocPolicy.py usageStatsPolicy.py ragPolicy.py crossReviewPolicy.py rbacAdminPolicy.py contractTemplatePolicy.py facade/ __init__.py scopeAwarePermissionFacade.py ``` 说明: - `repositories` 负责查授权和资源归属 - `resolvers` 负责算决策 - `policies` 负责模块特例 - `facade` 负责给业务 service 暴露统一入口 --- ## 4. 建议新增核心对象 ## 4.1 `models.py` 建议定义: ```python from __future__ import annotations from dataclasses import dataclass, field from typing import Any @dataclass(slots=True) class ScopeContext: user_id: int permission_key: str module: str action: str user_area: str | None request_area: str | None = None target_user_id: int | None = None resource_id: int | None = None route_path: str | None = None extra: dict[str, Any] = field(default_factory=dict) @dataclass(slots=True) class PermissionGrant: role_id: int role_key: str permission_key: str grant_type: str role_scope: str | None permission_scope: str | None priority: int is_system_role: bool condition_filter: dict[str, Any] | None = None @dataclass(slots=True) class PermissionDecision: allowed: bool deny_reason: str | None effective_scope: str | None scope_mode: str allowed_areas: list[str] = field(default_factory=list) allowed_user_ids: list[int] = field(default_factory=list) allow_public: bool = False matched_roles: list[str] = field(default_factory=list) matched_permissions: list[str] = field(default_factory=list) conditions: dict[str, Any] = field(default_factory=dict) @dataclass(slots=True) class ScopeFieldMapping: area_field: str | None = None creator_field: str | None = None owner_field: str | None = None user_field: str | None = None public_field: str | None = None @dataclass(slots=True) class ScopeClause: sql: str params: dict[str, Any] scope_mode: str description: str ``` ## 4.2 `enums.py` 建议定义: ```python class ScopeMode: NONE = "NONE" ALL = "ALL" DEPT = "DEPT" SELF = "SELF" RELATION = "RELATION" PUBLIC_MIXED = "PUBLIC_MIXED" CUSTOM = "CUSTOM" ``` --- ## 5. 推荐新增权限服务能力 ## 5.1 不直接推翻 `IPermissionService` 当前接口是: - `CheckPermission` - `HasAnyPermission` - `HasAllPermissions` 不建议直接改掉这 3 个方法,否则牵涉面太广。 建议做法: 1. 保留原接口 2. 额外新增一个 scope 感知服务接口 ## 5.2 推荐新增接口 建议新增文件: `fastapi_modules/fastapi_leaudit/services/permissionScopeService.py` ```python from abc import ABC, abstractmethod from fastapi_modules.fastapi_leaudit.services.permission_scope.models import PermissionDecision, ScopeContext class IPermissionScopeService(ABC): @abstractmethod async def Decide(self, Context: ScopeContext) -> PermissionDecision: ... @abstractmethod async def Require(self, Context: ScopeContext) -> PermissionDecision: ... ``` 说明: - `Decide()` 返回决策,不抛错 - `Require()` 返回决策,若拒绝直接抛 `403` ## 5.3 为什么不直接塞进 `IPermissionService` 因为当前 `IPermissionService` 的语义非常清晰: - 它是“权限布尔检查器” 如果直接把 scope 决策一起塞进去: - 现有 controller 依赖会变混乱 - service 层也很难渐进迁移 更合理的做法是: - `PermissionServiceImpl` 继续做功能权限布尔校验 - `PermissionScopeServiceImpl` 负责完整决策 --- ## 6. Repository 骨架 ## 6.1 `permissionGrantRepository.py` 职责: - 加载用户所有 role / permission grant 明细 建议接口: ```python class PermissionGrantRepository: async def ListUserPermissionGrants(self, UserId: int, PermissionKey: str) -> list[PermissionGrant]: ... async def GetUserBaseIdentity(self, UserId: int) -> dict[str, Any]: ... ``` 推荐 SQL 思路: ```sql SELECT r.id AS role_id, r.role_key, p.permission_key, rp.grant_type, r.data_scope AS role_scope, rp.data_scope AS permission_scope, COALESCE(r.priority, 0) AS priority, COALESCE(r.is_system_role, FALSE) AS is_system_role, rp.condition_filter FROM user_role ur JOIN roles r ON r.id = ur.role_id JOIN role_permissions rp ON rp.role_id = r.id JOIN permissions p ON p.id = rp.permission_id WHERE ur.user_id = :user_id ``` 然后在 Python 层执行: - 精确匹配 - wildcard 匹配 - `DENY/GRANT` 分类 ## 6.2 `resourceScopeRepository.py` 职责: - 为详情、下载、删除、导出类接口提供主资源回溯 建议接口: ```python class ResourceScopeRepository: async def GetDocumentScopeSnapshot(self, DocumentId: int) -> dict[str, Any] | None: ... async def GetGovdocRunScopeSnapshot(self, RunId: int) -> dict[str, Any] | None: ... async def GetRagDatasetScopeSnapshot(self, DatasetId: int) -> dict[str, Any] | None: ... async def GetCrossReviewProposalScopeSnapshot(self, ProposalId: int) -> dict[str, Any] | None: ... ``` 这些方法不是做最终权限判断,而是提供: - 主资源地区 - 主资源创建者 - 关联任务/数据集/文档 ID 供 policy 判断使用。 --- ## 7. Resolver 骨架 ## 7.1 `dataScopeResolver.py` 职责: - 对 `PermissionGrant` 做范围合并 建议骨架: ```python class DataScopeResolver: _SCOPE_RANK = { "SELF": 1, "DEPT": 2, "ALL": 3, } def resolve(self, grants: list[PermissionGrant]) -> tuple[bool, str | None, list[str]]: exact_denies = [item for item in grants if item.grant_type == "DENY"] if exact_denies: return False, None, [] effective_scope = None matched_roles: list[str] = [] for item in grants: if item.grant_type != "GRANT": continue scope = item.permission_scope or item.role_scope or "SELF" if effective_scope is None or self._SCOPE_RANK.get(scope, 0) > self._SCOPE_RANK.get(effective_scope, 0): effective_scope = scope matched_roles.append(item.role_key) return True, effective_scope, matched_roles ``` 说明: - 第一版先解决 `ALL/DEPT/SELF` - `PUBLIC_MIXED`、`RELATION` 不从库里直接出,而由 `ModulePolicy` 生成 ## 7.2 `permissionDecisionService.py` 职责: - 串联 grant repository、scope resolver、module policy 建议骨架: ```python class PermissionDecisionService: def __init__( self, grantRepository: PermissionGrantRepository, scopeResolver: DataScopeResolver, policyRegistry: ModulePolicyRegistry, ) -> None: self._grantRepository = grantRepository self._scopeResolver = scopeResolver self._policyRegistry = policyRegistry async def Decide(self, Context: ScopeContext) -> PermissionDecision: grants = await self._grantRepository.ListUserPermissionGrants(Context.user_id, Context.permission_key) allowed, effective_scope, matched_roles = self._scopeResolver.resolve(grants) if not allowed: return PermissionDecision( allowed=False, deny_reason="permission_denied", effective_scope=None, scope_mode="NONE", ) base_decision = PermissionDecision( allowed=True, deny_reason=None, effective_scope=effective_scope, scope_mode=effective_scope or "SELF", matched_roles=matched_roles, matched_permissions=[Context.permission_key], ) policy = self._policyRegistry.Get(Context.module) return await policy.Decorate(Context, base_decision) ``` --- ## 8. QueryScopeBuilder 骨架 ## 8.1 核心职责 将 `PermissionDecision` 转成可直接拼接到 SQL 的子句。 ## 8.2 建议骨架 ```python class QueryScopeBuilder: def BuildByMapping( self, Decision: PermissionDecision, Mapping: ScopeFieldMapping, ScopeUserId: int, ScopeArea: str | None, RequestedArea: str | None = None, RequestedUserId: int | None = None, ) -> ScopeClause: if not Decision.allowed: return ScopeClause(sql="1 = 0", params={}, scope_mode="NONE", description="permission denied") if Decision.scope_mode == "ALL": if RequestedArea and Mapping.area_field: return ScopeClause( sql=f"COALESCE({Mapping.area_field}, '') = :requested_area", params={"requested_area": RequestedArea}, scope_mode="ALL", description="global scope with requested area", ) return ScopeClause(sql="1 = 1", params={}, scope_mode="ALL", description="global scope") if Decision.scope_mode == "DEPT": if not ScopeArea: return ScopeClause(sql="1 = 0", params={}, scope_mode="DEPT", description="missing user area") if RequestedArea and RequestedArea != ScopeArea: return ScopeClause(sql="1 = 0", params={}, scope_mode="DEPT", description="requested area out of scope") return ScopeClause( sql=f"COALESCE({Mapping.area_field}, '') = :scope_area", params={"scope_area": ScopeArea}, scope_mode="DEPT", description="same area scope", ) if Decision.scope_mode == "SELF": user_field = Mapping.creator_field or Mapping.owner_field or Mapping.user_field if not user_field: return ScopeClause(sql="1 = 0", params={}, scope_mode="SELF", description="missing self mapping") if RequestedUserId is not None and RequestedUserId != ScopeUserId: return ScopeClause(sql="1 = 0", params={}, scope_mode="SELF", description="requested user out of scope") return ScopeClause( sql=f"{user_field} = :scope_user_id", params={"scope_user_id": ScopeUserId}, scope_mode="SELF", description="self scope", ) return ScopeClause(sql="1 = 0", params={}, scope_mode="CUSTOM", description="unsupported generic scope") ``` --- ## 9. Policy 骨架 ## 9.1 基础接口 建议文件:`policies/base.py` ```python from abc import ABC, abstractmethod from fastapi_modules.fastapi_leaudit.services.permission_scope.models import PermissionDecision, ScopeClause, ScopeContext class BaseModulePolicy(ABC): @abstractmethod async def Decorate(self, Context: ScopeContext, Decision: PermissionDecision) -> PermissionDecision: ... @abstractmethod async def BuildClause(self, Context: ScopeContext, Decision: PermissionDecision) -> ScopeClause | None: ... ``` ## 9.2 `DocumentPolicy` 建议职责: 1. 保持 `ALL/DEPT/SELF` 2. 固定文档模块字段映射为: - `d.region` - `f.created_by` 3. 对详情、状态、附件、确认等接口复用同一资源规则 建议骨架: ```python class DocumentPolicy(BaseModulePolicy): def __init__(self, Builder: QueryScopeBuilder) -> None: self._builder = Builder async def Decorate(self, Context: ScopeContext, Decision: PermissionDecision) -> PermissionDecision: return Decision async def BuildClause(self, Context: ScopeContext, Decision: PermissionDecision) -> ScopeClause: return self._builder.BuildByMapping( Decision=Decision, Mapping=ScopeFieldMapping(area_field="d.region", creator_field="f.created_by"), ScopeUserId=Context.user_id, ScopeArea=Context.user_area, RequestedArea=Context.request_area, RequestedUserId=Context.target_user_id, ) ``` ## 9.3 `RagPolicy` 建议职责: 1. 读接口转为 `PUBLIC_MIXED` 2. 管理接口仍回到 `ALL/DEPT/SELF` 建议骨架: ```python class RagPolicy(BaseModulePolicy): def __init__(self, Builder: QueryScopeBuilder) -> None: self._builder = Builder async def Decorate(self, Context: ScopeContext, Decision: PermissionDecision) -> PermissionDecision: if Context.permission_key in {"rag:app:read", "rag:chat:use", "rag:dataset:read"}: Decision.scope_mode = "PUBLIC_MIXED" Decision.allow_public = True Decision.allowed_areas = [area for area in [Context.user_area, "省级", ""] if area is not None] return Decision async def BuildClause(self, Context: ScopeContext, Decision: PermissionDecision) -> ScopeClause: if Decision.scope_mode == "PUBLIC_MIXED": return ScopeClause( sql="(COALESCE(dataset.area, '') IN :visible_areas OR dataset.is_public = TRUE)", params={"visible_areas": Decision.allowed_areas}, scope_mode="PUBLIC_MIXED", description="rag public mixed scope", ) return self._builder.BuildByMapping( Decision=Decision, Mapping=ScopeFieldMapping(area_field="dataset.area", creator_field="dataset.created_by", public_field="dataset.is_public"), ScopeUserId=Context.user_id, ScopeArea=Context.user_area, RequestedArea=Context.request_area, ) ``` ## 9.4 `CrossReviewPolicy` 建议职责: - 把普通 `SELF/DEPT` 转成关系型 `RELATION` 示意: ```python class CrossReviewPolicy(BaseModulePolicy): async def Decorate(self, Context: ScopeContext, Decision: PermissionDecision) -> PermissionDecision: if Decision.allowed: Decision.scope_mode = "RELATION" return Decision async def BuildClause(self, Context: ScopeContext, Decision: PermissionDecision) -> ScopeClause: return ScopeClause( sql=( "EXISTS (" "SELECT 1 FROM leaudit_cross_review_task_member tm " "WHERE tm.task_id = task.id " "AND tm.user_id = :scope_user_id " "AND tm.deleted_at IS NULL" ")" ), params={"scope_user_id": Context.user_id}, scope_mode="RELATION", description="cross review relation scope", ) ``` --- ## 10. Facade 骨架 建议文件:`facade/scopeAwarePermissionFacade.py` 职责: 给各个业务 service 一个最直接的调用入口。 ```python class ScopeAwarePermissionFacade: def __init__( self, DecisionService: PermissionDecisionService, PolicyRegistry: ModulePolicyRegistry, ) -> None: self._decisionService = DecisionService self._policyRegistry = PolicyRegistry async def Require(self, Context: ScopeContext) -> tuple[PermissionDecision, ScopeClause | None]: decision = await self._decisionService.Decide(Context) if not decision.allowed: raise LeauditException(StatusCodeEnum.HTTP_403_FORBIDDEN, "当前用户没有访问权限") policy = self._policyRegistry.Get(Context.module) clause = await policy.BuildClause(Context, decision) return decision, clause ``` 这样业务 service 的接入就会很轻: ```python decision, scope_clause = await self.ScopeFacade.Require(...) ``` --- ## 11. Controller 接入策略 ## 11.1 第一阶段不强制改 controller 当前 controller 大多已经有: - `verify_access_token` - `PermissionService.CheckPermission()` 第一阶段不建议全部改掉。 建议策略: 1. controller 继续做功能权限预筛 2. service 内再做统一 scope 决策 原因: - 改动更可控 - 避免一次性动太多 controller ## 11.2 第二阶段再统一 controller 等统一执行器稳定后,可以进一步做: - controller 不再自行 `CheckPermission` - 改为 service 内一站式处理 但这不是第一批落地的必要条件。 --- ## 12. `documentServiceImpl` 接入示例 ## 12.1 当前问题 当前文档模块有: - `_getCurrentUserContext` - `_buildDocumentScopeFilters` 它们本质上就是统一执行器的前身,但还停留在角色硬编码层。 ## 12.2 推荐替换方式 ### 原有思路 ```python currentUser = await self._getCurrentUserContext(CurrentUserId) filters = self._buildDocumentScopeFilters(...) ``` ### 改造后思路 ```python context = ScopeContext( user_id=CurrentUserId, permission_key="documents:list:read", module="documents", action="read", user_area=payload_area, request_area=Region, target_user_id=UserId, ) decision, scope_clause = await self.ScopeFacade.Require(context) ``` 然后在 SQL 中: ```python filters = [ "d.deleted_at IS NULL", "f.deleted_at IS NULL", scope_clause.sql, ] params = { **scope_clause.params, } ``` ## 12.3 示例骨架 ```python async def ListDocuments(...): identity = await self.PermissionGrantRepository.GetUserBaseIdentity(CurrentUserId) context = ScopeContext( user_id=CurrentUserId, permission_key="documents:list:read", module="documents", action="read", user_area=identity.get("area"), request_area=Region, target_user_id=UserId, ) _, scope_clause = await self.ScopeFacade.Require(context) where_clauses = [ "d.deleted_at IS NULL", "f.deleted_at IS NULL", scope_clause.sql, ] params: dict[str, Any] = { **scope_clause.params, } if Keyword: where_clauses.append("d.document_name ILIKE :keyword") params["keyword"] = f"%{Keyword}%" sql = f""" SELECT ... FROM leaudit_documents d JOIN leaudit_document_files f ON f.document_id = d.id WHERE {' AND '.join(where_clauses)} """ ``` ## 12.4 详情接口示例 ```python async def GetDocument(self, CurrentUserId: int, Id: int) -> DocumentDetailVO: identity = await self.PermissionGrantRepository.GetUserBaseIdentity(CurrentUserId) context = ScopeContext( user_id=CurrentUserId, permission_key="documents:detail:read", module="documents", action="read", user_area=identity.get("area"), resource_id=Id, ) _, scope_clause = await self.ScopeFacade.Require(context) row = ( await session.execute( text( f""" SELECT ... FROM leaudit_documents d JOIN leaudit_document_files f ON f.document_id = d.id WHERE d.id = :document_id AND {scope_clause.sql} """ ), {"document_id": Id, **scope_clause.params}, ) ).mappings().first() ``` 重点: - 把 scope 直接写进详情查询 - 不再“先查后判” --- ## 13. `govdocServiceImpl` 接入示例 ## 13.1 当前问题 公文模块里最大风险不是列表,而是: - `runId` - `report/docx` - `original` 这些派生接口。 ## 13.2 推荐接法 先统一一个内部方法: ```python async def _getScopedGovdocDocument( self, CurrentUserId: int, PermissionKey: str, DocumentId: int, ) -> dict[str, Any]: ... ``` 所有: - `GetDocumentDetail` - `CreateRun` - `DownloadOriginal` 都先用它。 ### 对 `runId` 类接口 新增一个仓储回溯: ```python snapshot = await self.ResourceScopeRepository.GetGovdocRunScopeSnapshot(runId) document_id = snapshot["document_id"] document = await self._getScopedGovdocDocument(CurrentUserId, "govdoc:run:read", document_id) ``` 这样所有 run 结果、报告下载都回到 document scope。 --- ## 14. `ragDatasetServiceImpl` 接入示例 ## 14.1 当前问题 当前签名大量透传: - `UserArea` - `UserRole` 这就是旧架构的直接暴露。 ## 14.2 第一阶段兼容策略 第一阶段不需要立刻改所有接口签名。 可以先保留: ```python async def GetAdminDatasets(self, CurrentUserId, UserArea, UserRole, Area, OnlyEnabled, Page, PageSize) ``` 但内部不再使用 `UserRole`。 ### 示例 ```python async def GetAdminDatasets(...): context = ScopeContext( user_id=CurrentUserId, permission_key="rag:dataset:manage", module="rag", action="read", user_area=UserArea, request_area=Area, ) _, scope_clause = await self.ScopeFacade.Require(context) ``` 然后把旧逻辑: ```python if UserRole not in ("provincial_admin", "admin", "super_admin"): raise ... ``` 全部删掉。 ## 14.3 第二阶段签名优化 稳定后再把 service 接口签名收敛为: ```python async def GetAdminDatasets(self, CurrentUserId: int, Area: str | None, OnlyEnabled: bool | None, Page: int, PageSize: int) ``` 即: - `UserArea` - `UserRole` 不再从 controller 显式透传。 而是在 service 内统一从 identity/repository 获取。 ## 14.4 数据集子资源示例 ```python async def GetDatasetDocumentDetail(...): identity = await self.PermissionGrantRepository.GetUserBaseIdentity(CurrentUserId) context = ScopeContext( user_id=CurrentUserId, permission_key="rag:dataset:read", module="rag", action="read", user_area=identity.get("area"), resource_id=DatasetId, ) _, dataset_scope_clause = await self.ScopeFacade.Require(context) sql = f""" SELECT ... FROM rag_datasets dataset JOIN rag_documents doc ON doc.dataset_id = dataset.id WHERE dataset.id = :dataset_id AND doc.id = :document_id AND {dataset_scope_clause.sql} """ ``` 重点: - 文档详情跟随 dataset scope - 不再自己判断角色 --- ## 15. `rbacAdminServiceImpl` 接入示例 ## 15.1 当前问题 当前 `_assertManagePermission` 和 `_assertPermission` 双轨并存。 还存在: ```sql bool_or(r.role_key IN ('super_admin', 'provincial_admin', 'admin')) AS can_manage ``` ## 15.2 推荐做法 角色管理对象本身和用户管理对象分开处理。 ### 角色元数据接口 例如: - `ListRoles` - `CreateRole` - `SaveRolePermissions` 主要按功能权限控制,不做 area scope。 ### 用户相关接口 例如: - `ListUsers` - `AssignUserRoles` - `GetUserRoles` 要做用户 area scope。 ## 15.3 `ListUsers` 示例 ```python async def ListUsers(...): identity = await self.PermissionGrantRepository.GetUserBaseIdentity(CurrentUserId) context = ScopeContext( user_id=CurrentUserId, permission_key="rbac:users:read", module="rbac_admin", action="read", user_area=identity.get("area"), request_area=Area, ) _, scope_clause = await self.ScopeFacade.Require(context) sql = f""" SELECT ... FROM sso_users u WHERE u.deleted_at IS NULL AND {scope_clause.sql} """ ``` `RbacAdminPolicy` 内固定映射: - `area_field = u.area` ## 15.4 `AssignUserRoles` 示例 建议先做用户合法性校验: ```python target_context = ScopeContext( user_id=CurrentUserId, permission_key="rbac:user_roles:write", module="rbac_admin", action="write", user_area=identity.get("area"), resource_id=UserId, ) _, scope_clause = await self.ScopeFacade.Require(target_context) ``` 然后先查: ```sql SELECT u.id FROM sso_users u WHERE u.id = :target_user_id AND {scope_clause.sql} ``` 只有通过后才执行角色绑定。 --- ## 16. 接口签名迁移建议 ## 16.1 第一阶段 原则: - 不强行修改所有抽象接口签名 - 优先兼容现有 controller 例如 RAG service 可以暂时保留: - `UserArea` - `UserRole` 但内部只真正使用: - `CurrentUserId` - `UserArea` 并逐步废弃 `UserRole` ## 16.2 第二阶段 当统一执行器稳定后,建议收敛为: 1. controller 只传 `CurrentUserId` 2. service 自己取基础身份 3. `ScopeContext` 统一构造 这一步是“收口优化”,不是第一批落地阻塞项。 --- ## 17. 日志与异常建议 ## 17.1 统一异常 建议新增: ```python class PermissionScopeDeniedException(LeauditException): ... ``` 用于区分: - 功能权限拒绝 - 数据范围拒绝 - 资源不存在 ## 17.2 日志建议 每次 scope 拒绝建议记录: ```python logger.warning( "scope denied", extra={ "user_id": Context.user_id, "permission_key": Context.permission_key, "module": Context.module, "resource_id": Context.resource_id, "request_area": Context.request_area, "scope_mode": decision.scope_mode, "deny_reason": decision.deny_reason, }, ) ``` 这对后续排查误拒绝和越权都很重要。 --- ## 18. 单元测试骨架建议 建议至少补以下测试: ### `DataScopeResolver` - `GRANT SELF + GRANT DEPT -> DEPT` - `GRANT ALL + DENY exact -> denied` - `permission_scope 覆盖 role_scope` ### `QueryScopeBuilder` - `ALL` - `DEPT` - `SELF` - `SELF + requested_user_id != scope_user_id` ### `RagPolicy` - `rag:dataset:read -> PUBLIC_MIXED` - `rag:dataset:update -> 保持 ALL/DEPT/SELF` ### `CrossReviewPolicy` - `allowed -> RELATION` --- ## 19. 推荐实施顺序 代码落地建议按下面顺序推进: 1. 建 `models / resolver / builder / policy base` 2. 建 `DocumentPolicy` 3. 用文档模块先跑通一条完整链路 4. 建 `RagPolicy`,清理 RAG 角色白名单 5. 建 `GovdocPolicy`,收口 run/report/download 6. 建 `UsageStatsPolicy` 7. 建 `RbacAdminPolicy` 8. 最后收敛 service 签名 这样做的好处是: - 先拿最典型模块验证设计 - 再扩到复杂特例 - 避免一开始就改满全仓库 --- ## 20. 最终判断标准 这份代码骨架真正落地完成,不是看文件建没建,而是看下面几件事是否成立: 1. 文档模块不再依赖 `_getCurrentUserContext + role_key IN (...)` 2. RAG 模块不再依赖 `UserRole` 白名单 3. 公文下载/报告接口已通过主资源回溯做 scope 判断 4. RBAC 用户管理域不再通过角色名派生 `can_manage` 5. 新增一个接口时,研发可以直接按 `ScopeContext -> Require -> ScopeClause` 方式接入 如果还做不到第 5 条,说明统一执行器还没有真正成为平台能力。