status: done created: 2026-07-06 complexity: 🔴复杂 change_id: org-scoped-role-permission
当前系统已经具备数据中心(技术字段仍为 tenantId / tenant_id)隔离、用户多租户绑定、组织树、角色资源、按钮/API 权限和数据权限拦截能力。用户测试发现“一个用户只能在一个组织”,直接原因是前端用户组织弹窗只保留一个主组织;更深层的问题是角色授权仍是 tenant_id + user_id + role_id 的全局关系,不能表达“同一账号在不同组织拥有不同角色和数据权限”。
本变更把权限模型升级为两层上下文:
tenantId、sys_user_tenant 和 /system/tenant/switch 链路。完成后应达到:
ORG 数据权限只代表当前组织,ORG_AND_CHILD 只代表当前组织及其子组织,不再代表用户绑定的所有组织。sys_user_role 作为迁移和兼容来源,新权限计算以 sys_user_org_role 为准。UserLoadServiceImpl#buildLoginUser 构建,先校验数据中心成员,再加载角色、组织、按钮权限、API 权限和行政区划。路径:forge-server/forge-framework/forge-plugin-parent/forge-plugin-system/src/main/java/com/mdframe/forge/plugin/system/service/impl/UserLoadServiceImpl.java。SysTenantServiceImpl#switchTenant,会重新加载指定数据中心下的 LoginUser 并写回 Session。路径:forge-server/forge-framework/forge-plugin-parent/forge-plugin-system/src/main/java/com/mdframe/forge/plugin/system/service/impl/SysTenantServiceImpl.java。/auth/userInfo 每次会按当前 Session 的 tenantId 重新从 DB 构建 LoginUser,再写回 Session。路径:forge-server/forge-framework/forge-starter-parent/forge-starter-auth/src/main/java/com/mdframe/forge/starter/auth/controller/AuthController.java,方法:getUserInfo。TenantSwitcher.vue 只处理数据中心切换,切换后清理登录相关状态并重新进入首页。路径:forge-admin-ui/src/layouts/components/TenantSwitcher.vue。结论:数据中心上下文链路可复用,但组织上下文需要在 LoginUser 重建时保留当前组织,否则 /auth/userInfo 会把组织切换结果覆盖回默认组织。
SysUserOrg 已映射 sys_user_org,字段包含 tenantId、userId、orgId、isMain。路径:forge-server/forge-framework/forge-plugin-parent/forge-plugin-system/src/main/java/com/mdframe/forge/plugin/system/entity/SysUserOrg.java。SysUserController 已提供 GET /system/user/{userId}/orgs 和 POST /system/user/{userId}/orgs。路径:forge-server/forge-framework/forge-plugin-parent/forge-plugin-system/src/main/java/com/mdframe/forge/plugin/system/controller/SysUserController.java。SysUserServiceImpl#bindUserOrgs 已能按数据中心批量保存用户组织并设置主组织。路径:forge-server/forge-framework/forge-plugin-parent/forge-plugin-system/src/main/java/com/mdframe/forge/plugin/system/service/impl/SysUserServiceImpl.java。PremiumTree 仍使用单选 selected-keys="mainOrgId ? [mainOrgId] : []",读取接口后只取第一个组织,保存时提交 orgIds: [mainOrgId]。路径:forge-admin-ui/src/views/system/user.vue。结论:后端多组织成员关系基础已存在,前端和组织角色联动还未完成。删除用户组织关系时,需要同步清理该组织下的组织角色授权。
SysUserRole 当前只映射 sys_user_role,字段为 tenantId、userId、roleId,没有 orgId。路径:forge-server/forge-framework/forge-plugin-parent/forge-plugin-system/src/main/java/com/mdframe/forge/plugin/system/entity/SysUserRole.java。UserLoadServiceImpl#loadUserRoles 按 userId + tenantId 查询所有 sys_user_role,并把启用角色写入 LoginUser.roleIds/roleKeys。路径:forge-server/forge-framework/forge-plugin-parent/forge-plugin-system/src/main/java/com/mdframe/forge/plugin/system/service/impl/UserLoadServiceImpl.java。SysUserServiceImpl#syncUserRoles 按 userId + tenantId 删除和插入全局角色,普通管理员只能分配自己拥有的角色。路径:forge-server/forge-framework/forge-plugin-parent/forge-plugin-system/src/main/java/com/mdframe/forge/plugin/system/service/impl/SysUserServiceImpl.java。roleIds,没有 orgIds/mainOrgId/orgRoleBindings。路径:forge-server/forge-framework/forge-plugin-parent/forge-plugin-system/src/main/java/com/mdframe/forge/plugin/system/dto/SysUserDTO.java。结论:必须新增 sys_user_org_role 作为真实授权关系。旧 sys_user_role 只能作为迁移来源或旧接口兼容来源,不能继续参与新权限计算。
SysRole 只有角色基础字段和 dataScope,没有适用组织字段。路径:forge-server/forge-framework/forge-plugin-parent/forge-plugin-system/src/main/java/com/mdframe/forge/plugin/system/entity/SysRole.java。SysRoleDTO 没有 orgIds。路径:forge-server/forge-framework/forge-plugin-parent/forge-plugin-system/src/main/java/com/mdframe/forge/plugin/system/dto/SysRoleDTO.java。SysRoleMapper.xml#selectRoleUsers 和 countUsersByRole 都直接查询 sys_user_role。路径:forge-server/forge-framework/forge-plugin-parent/forge-plugin-system/src/main/resources/mapper/SysRoleMapper.xml。结论:需要新增 sys_role_org 表示角色在哪些组织可被分配。角色绑定组织不是用户授权,用户授权仍必须落到 sys_user_org_role。
UserLoadServiceImpl#loadUserPermissions 读取 LoginUser.roleIds 对应的 sys_role_resource。UserLoadServiceImpl#loadApiPermissions 读取同一批角色资源,并缓存到 LoginUser.apiPermissions。SysResourceServiceImpl#getUserResources 基于 LoginUser.roleIds 查询角色资源。路径:forge-server/forge-framework/forge-plugin-parent/forge-plugin-system/src/main/java/com/mdframe/forge/plugin/system/service/impl/SysResourceServiceImpl.java。ApiPermissionInterceptor 通过 PermissionServiceImpl#getCurrentUserApiPermissions 从 Session 的 LoginUser.apiPermissions 判断接口权限。路径:forge-server/forge-framework/forge-starter-parent/forge-starter-auth/src/main/java/com/mdframe/forge/starter/auth/interceptor/ApiPermissionInterceptor.java。结论:只要 LoginUser.roleIds 在登录、数据中心切换和组织切换时按 activeOrgId 重算,菜单、按钮和 API 权限主链路可复用。但所有直接读取 sys_user_role 的旁路必须同步改造。
LoginUser 当前只有 orgIds/mainOrgId,没有 activeOrgId/activeOrgName。路径:forge-server/forge-framework/forge-starter-parent/forge-starter-core/src/main/java/com/mdframe/forge/starter/core/session/LoginUser.java。DataScopeServiceImpl#getCurrentUserDataScope 用 LoginUser.roleIds 算最小数据范围,并把 LoginUser.orgIds 写入 DataScopeContext.orgIds。路径:forge-server/forge-framework/forge-starter-parent/forge-starter-datascope/src/main/java/com/mdframe/forge/starter/datascope/service/impl/DataScopeServiceImpl.java。DataScopeInterceptor#buildDataScopeCondition 的 ORG 直接使用 context.orgIds,ORG_AND_CHILD 使用 dataScopeService.getOrgAndChildIds(context.orgIds)。路径:forge-server/forge-framework/forge-starter-parent/forge-starter-datascope/src/main/java/com/mdframe/forge/starter/datascope/handler/DataScopeInterceptor.java。DynamicDataScopeService 自行读取 DataScopeContext 拼接组织条件,不走 MyBatis 拦截器。路径:forge-server/forge-framework/forge-plugin-parent/forge-plugin-generator/src/main/java/com/mdframe/forge/plugin/generator/service/DynamicDataScopeService.java。LoginUser.roleIds/orgIds。路径:forge-server/forge-framework/forge-plugin-parent/forge-plugin-data/src/main/java/com/mdframe/forge/plugin/data/service/impl/DataDatasetAccessServiceImpl.java、DataDatasetRowScopeServiceImpl.java。结论:数据权限必须统一收窄到当前组织上下文。否则放开多组织后,ORG 会扩大到用户所有组织,形成越权。
sys_user_role 的模块sys_user_role。路径:forge-server/forge-framework/forge-plugin-parent/forge-plugin-generator/src/main/resources/mapper/BusinessMessageChannelMapper.xml。startUserRoleIds 和 startUserOrgIds,当前取的是全局角色和全部组织。路径:forge-server/forge-framework/forge-plugin-parent/forge-plugin-flow/src/main/java/com/mdframe/forge/starter/flow/service/impl/FlowInstanceServiceImpl.java。SysUserRole 判断用户角色编码,未带数据中心和组织上下文。路径:forge-server/forge-framework/forge-plugin-parent/forge-plugin-flow/src/main/java/com/mdframe/forge/starter/flow/service/impl/FlowOrgIntegrationServiceImpl.java。结论:必须排查并迁移所有旁路,否则会出现 UI 和接口主链路正确、消息/流程/数据集仍按全局角色运行的隐性串权。
forge-server/db/migration/ 当前只有 V1.0.0__baseline.sql。forge-server/db/backup/ 是归档目录,不参与本次 Flyway 最新编号判断。结论:当前实际迁移目录最新 Flyway 编号为 V1.0.0,本变更迁移脚本使用 V1.0.1__add_org_scoped_role_permission.sql。禁止修改已执行历史脚本。
sys_user_org_role。ORG 和 ORG_AND_CHILD 不再使用用户全部组织。sys_user_role 数据迁移到组织角色授权,保持历史用户不因升级空菜单。tenantId,再处于该数据中心内某个当前组织 activeOrgId。activeOrgId、mainOrgId、orgIds 只在当前 tenantId 内有效,不能跨数据中心复用。activeOrgId、主组织、组织列表第一个。sys_user_org_role。ALL 和 TENANT 数据范围角色不得由普通组织管理员越权分配。tenant 命名,避免大范围破坏现有代码。| 操作 | 表名 | 字段/索引 | 说明 |
|---|---|---|---|
| 新增 | sys_role_org |
id, tenant_id, role_id, org_id, create_by, create_time, create_dept, update_by, update_time |
角色适用组织范围 |
| 新增 | sys_role_org |
唯一键 uk_role_org(tenant_id, role_id, org_id) |
防止重复绑定 |
| 新增 | sys_role_org |
索引 idx_org_role(tenant_id, org_id, role_id) |
按组织加载可分配角色 |
| 新增 | sys_user_org_role |
id, tenant_id, user_id, org_id, role_id, create_by, create_time, create_dept, update_by, update_time |
用户在组织内的真实角色授权 |
| 新增 | sys_user_org_role |
唯一键 uk_user_org_role(tenant_id, user_id, org_id, role_id) |
防止重复授权 |
| 新增 | sys_user_org_role |
索引 idx_user_org(tenant_id, user_id, org_id), idx_org_role(tenant_id, org_id, role_id) |
支持登录加载和角色反查 |
| 迁移 | sys_role_org |
从当前数据中心内组织补角色适用范围 | 历史角色默认对当前数据中心内所有组织可用,避免迁移后无法授权 |
| 迁移 | sys_user_org_role |
从 sys_user_role 复制到用户所有已绑定组织 |
保持当前行为不收窄,后续由管理员按组织回收 |
迁移脚本要求:
forge-server/db/migration/。V1.0.1__add_org_scoped_role_permission.sql。CREATE TABLE IF NOT EXISTS、INSERT ... SELECT ... WHERE NOT EXISTS 或等效防重复保护。tenant_id 使用 1,禁止写 0。| 操作 | 接口 | 方法 | 变更内容 |
|---|---|---|---|
| 保留增强 | /system/tenant/switch |
POST | 切换数据中心后重新加载该数据中心下组织,选择默认当前组织并重算权限 |
| 新增 | /system/org/current/options |
GET | 查询当前数据中心下当前用户可切换组织,返回当前组织标识 |
| 新增 | /system/org/switch |
POST | 请求参数 orgId,切换当前组织并返回重算后的 LoginUser |
| 新增 | /system/user/{userId}/org-bindings |
GET | 返回用户组织详情、主组织、每个组织的角色摘要 |
| 兼容增强 | /system/user/{userId}/orgs |
GET | 保留返回组织 ID 列表,旧前端兼容 |
| 兼容增强 | /system/user/{userId}/orgs |
POST | 保存多组织和主组织,清理移除组织下的组织角色 |
| 新增 | /system/user/{userId}/org-roles |
GET | 按 orgId 查询用户在该组织下角色 |
| 新增 | /system/user/{userId}/org-roles |
POST | 按 orgId + roleIds 保存用户组织角色 |
| 新增 | /system/role/{roleId}/orgs |
GET | 查询角色适用组织 ID 列表 |
| 新增 | /system/role/{roleId}/orgs |
POST | 保存角色适用组织 |
| 兼容保留 | /system/user/{userId}/roles |
GET/POST | 作为旧接口保留;新 UI 不再作为主入口 |
| 兼容保留 | /system/role/{roleId}/users |
GET | 新增可选 orgId 参数;未传时按角色在当前数据中心下全部组织授权汇总 |
LoginUser 新增字段:
activeOrgId:当前组织 ID。activeOrgName:当前组织名称。orgIds:当前数据中心下可加入组织集合,供切换器使用。mainOrgId:当前数据中心下默认组织。activeOrgIds,避免把多个组织误当成当前权限范围。sys_user_role、历史单组织用户、普通组织管理员、数据中心管理员和超级管理员。sys_user_role 继续参与普通权限计算,组织切换仍会泄漏全局角色。LoginUser.orgIds,放开多组织后 ORG 会扩大到用户所有组织。/auth/userInfo 会重建 LoginUser,组织切换后的 activeOrgId 必须在刷新用户信息时保留。sys_user_role,必须纳入验收,不能只验证登录菜单。code-copilot/changes/org-scoped-role-permission/test-spec.md。/apply 前需确认本 SDD 版本为执行依据。V1.0.0,本变更使用 V1.0.1__add_org_scoped_role_permission.sql。sys_user_org_role,不直接给 sys_user_role 加 org_id。原因是保留迁移窗口和旧接口兼容,降低一次性破坏面。sys_role_org 表示角色适用组织,不复制角色本体。原因是角色仍是数据中心内权限模板,资源绑定继续复用 sys_role_resource。activeOrgId 作为唯一当前组织上下文。原因是多组织成员身份不代表一次请求应拥有所有组织权限。DataScopeContext.orgIds 在运行时只承载 [activeOrgId]。原因是保留已有拦截器接口,同时从源头避免组织范围放大。sys_user_role 到用户所有已绑定组织。原因是保持升级后行为不收窄,不让历史用户突然空菜单,后续再由管理员按组织治理。| Task | 状态 | 实际改动文件 | 备注 |
|---|---|---|---|
| 建档 | completed | spec.md, tasks.md, test-spec.md, execution-log.md |
根据 spec(2).md 和当前代码影响面重新生成 SDD 提案 |
| Task 1-9 | completed | 后端 Java/XML/SQL 见 git diff | 完成数据模型、登录态、组织切换、组织角色授权、角色适用组织、数据权限和旁路适配 |
| Task 10 | completed | forge-admin-ui/src/layouts/components/OrgSwitcher.vue 等 |
当前组织切换器接入 normal/immersive/top/full/nexus 等布局 |
| Task 11 | completed | forge-admin-ui/src/views/system/user.vue, role.vue, UserSelectPanel.vue |
用户多组织/组织角色授权、角色适用组织和角色用户组织维度操作已接入;未新增独立 panel 组件,直接复用现有页面弹窗 |
| Task 12 | partially completed | test-spec.md, execution-log.md, tasks.md, spec.md, InjectionMetaObjectHandler.java |
后端编译、前端构建、Flyway 占位符扫描、git diff --check 通过;补充 create_dept 优先写当前组织;服务级接口/数据库实测待补 |
| Review 修复 | completed | SysUserServiceImpl.java, SysOrgServiceImpl.java, SysRoleServiceImpl.java, SysUserMapper.xml, SysUserQuery.java, UserSelectPanel.vue, role.vue, BusinessMessageChannelService.java, SendMessageActionStepExecutor.java, BusinessTriggerExecutor.java |
修复服务层权限兜底、旧角色加人接口绕过、角色页直接组织用户筛选和业务消息角色接收人组织上下文;后端编译、前端构建和静态检查通过 |
| 超级管理员组织选项边界修复 | completed | SysOrgServiceImpl.java, SysOrgMapper.java, SysOrgMapper.xml, SysUserOrgMapper.java, SysUserOrgMapper.xml, test-spec.md, execution-log.md |
/system/org/current/options 和 /system/org/switch 直接按 sys_user_org 显式绑定组织收窄;仅无任何显式绑定的历史超级管理员账号保留当前数据中心全量组织兜底;前端仍保持扁平下拉展示;新增查询落到 Mapper XML |
当前已进入 apply 并完成代码实施。自审重点:
sys_user_role 的旁路模块纳入影响范围。sys_user_org_role:用户授权按组织保存,角色用户添加/移除也按授权组织调用 /system/user/{id}/org-roles。sys_user_role 仍保留为迁移/兼容写入与清理来源,后续 review 需重点确认没有新增普通权限读取路径回退旧表。LoginUser.activeOrgId 写入 create_dept,避免切换组织后新数据写到非当前组织。@ApiPermissionIgnore 继续遵循既有策略,服务层按用户/组织/角色管理权限限制普通用户访问;超级管理员和数据中心管理员保持现有能力。/system/role/{roleId}/addUsers 已阻断,不再只写 sys_user_role;新前端主流程继续按授权组织写 sys_user_org_role。用户未加入目标组织 拒绝。tenantId + orgId,Session activeOrgId 仅作为兜底。sys_user_org 显式绑定时,组织切换选项和切换校验按真实绑定组织收窄;登录态历史兜底产生的全量 orgIds 不再作为可切换组织依据。无显式绑定的历史超级管理员账号继续保留当前数据中心全量组织兜底,避免升级后无法进入组织上下文。/test org-scoped-role-permission 的服务级验收。apply org-scoped-role-permission 确认进入实施code-copilot/changes/archive/2026-07-06-org-scoped-role-permission/code-copilot/knowledge/tech-org-scoped-role-permission.md;code-copilot/memory/decisions.md 第 15 条;code-copilot/memory/pitfalls.md 第 102 条。git diff --check 已有执行证据;服务级接口/数据库实测仍受当前 dev 远端库和本地 MySQL/Redis 不可用限制,按执行日志保留为后续补测项。