fix(菜单权限): 授权树按操作者置灰不可授予项 + 放行保留既有授权
普通管理员勾到无权授予的菜单后,ensureGrantable 抛 403,而 createUser/updateUser 都是 @Transactional,整笔回滚——线上表现就是「后台不能保存权限,也不能创建用户」 (2026-09-16 周丽娥账号创建用户与保存 uid=45 权限双双失败,库里查无新用户)。 根因是校验 2026-07-28 就加了,但授权树一直拿全量菜单,两边规则不一致。 - permission-menus 按操作者标记 grantable;授权树据此置灰(不隐藏:授权是整树替换, 隐藏会把超管授予过、操作者自己没有的菜单当取消勾选删掉,与 09-13「权限自己没掉」同类) - ensureGrantable 放行目标已持有的授权,只拦新增,不构成提权 - GlobalExceptionHandler 补业务异常日志:此前普通业务异常一行都不记,本次排查只能靠 反推响应体字节数(nginx body_bytes_sent 含 chunked 开销)才定位到根因
This commit is contained in:
@@ -77,7 +77,7 @@ watch(
|
|||||||
node-key="id"
|
node-key="id"
|
||||||
show-checkbox
|
show-checkbox
|
||||||
default-expand-all
|
default-expand-all
|
||||||
:props="{ label: 'name', children: 'children' }"
|
:props="{ label: 'name', children: 'children', disabled: 'disabled' }"
|
||||||
@check="onCheck"
|
@check="onCheck"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -89,7 +89,7 @@ watch(
|
|||||||
node-key="id"
|
node-key="id"
|
||||||
show-checkbox
|
show-checkbox
|
||||||
default-expand-all
|
default-expand-all
|
||||||
:props="{ label: 'name', children: 'children' }"
|
:props="{ label: 'name', children: 'children', disabled: 'disabled' }"
|
||||||
@check="onCheck"
|
@check="onCheck"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -16,6 +16,11 @@ export interface MenuOptionNode {
|
|||||||
parentId: number | null
|
parentId: number | null
|
||||||
/** 所属菜单类型(admin 后台 / app 前端客户端),提交时按类型分区落库。 */
|
/** 所属菜单类型(admin 后台 / app 前端客户端),提交时按类型分区落库。 */
|
||||||
type: string
|
type: string
|
||||||
|
/**
|
||||||
|
* 当前操作者无权授予(后端 grantable=false)时置灰:仍展示并回显已勾选,
|
||||||
|
* 但不允许改勾选。非超管只能授自己已有的菜单,勾到越权项会让整笔保存回滚。
|
||||||
|
*/
|
||||||
|
disabled?: boolean
|
||||||
children?: MenuOptionNode[]
|
children?: MenuOptionNode[]
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -39,6 +44,8 @@ export function parsePermissionMenuItem(raw: unknown, type = ''): MenuOptionNode
|
|||||||
sort: sortRaw === null ? 0 : sortRaw,
|
sort: sortRaw === null ? 0 : sortRaw,
|
||||||
parentId: parentId === null ? null : parentId,
|
parentId: parentId === null ? null : parentId,
|
||||||
type,
|
type,
|
||||||
|
// 缺省(菜单管理页等未标记的接口)按可授予处理,保持旧行为
|
||||||
|
disabled: record.grantable === false,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,86 @@
|
|||||||
|
import test from 'node:test'
|
||||||
|
import assert from 'node:assert/strict'
|
||||||
|
import { readSource } from './helpers.ts'
|
||||||
|
import {
|
||||||
|
buildMenuOptionTree,
|
||||||
|
compactDirectGrantIds,
|
||||||
|
parseMenuOptionList,
|
||||||
|
parsePermissionMenuItem,
|
||||||
|
} from '../src/pages/account/user-menu-auth.ts'
|
||||||
|
|
||||||
|
// 2026-09-16 线上事故:非超管(普通管理员)在授权树里勾到自己无权授予的菜单后,
|
||||||
|
// 后端 ensureGrantable 抛 403 并回滚整笔事务——创建用户与保存权限双双失败,
|
||||||
|
// 前端只显示「普通管理员只能分配自己已有的菜单权限」。
|
||||||
|
// 修复:菜单列表按操作者标记 grantable,前端把不可授予的节点置灰不可勾。
|
||||||
|
//
|
||||||
|
// 注意:这里是「置灰」而非「隐藏」。授权保存是整树替换,隐藏会让超管早先授予、
|
||||||
|
// 而操作者自己没有的菜单在提交时被当作取消勾选删掉(与 09-13「权限自己没掉」同类)。
|
||||||
|
|
||||||
|
test('align_user_menu_grantable_false_maps_to_disabled_node', () => {
|
||||||
|
const locked = parsePermissionMenuItem(
|
||||||
|
{ id: 5, name: '查询ASIN', parent_id: null, sort_order: 1, grantable: false },
|
||||||
|
'admin',
|
||||||
|
)
|
||||||
|
assert.equal(locked?.disabled, true, 'grantable=false → 节点置灰')
|
||||||
|
|
||||||
|
const allowed = parsePermissionMenuItem(
|
||||||
|
{ id: 6, name: '店铺管理', parent_id: null, sort_order: 2, grantable: true },
|
||||||
|
'admin',
|
||||||
|
)
|
||||||
|
assert.equal(allowed?.disabled, false, 'grantable=true → 可勾选')
|
||||||
|
|
||||||
|
// 菜单管理页等未标记 grantable 的接口必须保持旧行为(全部可勾选)
|
||||||
|
const unmarked = parsePermissionMenuItem({ id: 7, name: '菜单权限配置', parent_id: null, sort_order: 3 }, 'admin')
|
||||||
|
assert.equal(unmarked?.disabled, false, '缺省 grantable 视为可授予')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('align_user_menu_grantable_survives_tree_build', () => {
|
||||||
|
const nodes = parseMenuOptionList(
|
||||||
|
[
|
||||||
|
{ id: 98, name: '账号与权限', parent_id: null, sort_order: 1, grantable: false },
|
||||||
|
{ id: 7, name: '用户管理', parent_id: 98, sort_order: 1, grantable: false },
|
||||||
|
{ id: 100, name: '店铺管理', parent_id: null, sort_order: 2, grantable: true },
|
||||||
|
],
|
||||||
|
'admin',
|
||||||
|
)
|
||||||
|
const tree = buildMenuOptionTree(nodes)
|
||||||
|
const account = tree.find((node) => node.id === 98)
|
||||||
|
assert.equal(account?.disabled, true, '分组节点置灰')
|
||||||
|
assert.equal(account?.children?.[0]?.disabled, true, '子节点置灰随树保留')
|
||||||
|
assert.equal(tree.find((node) => node.id === 100)?.disabled, false, '可授予节点不受影响')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('align_user_menu_grantable_disabled_node_still_compactable', () => {
|
||||||
|
// 已持有但无权授予的节点会保持勾选并原样提交,压缩逻辑不能因 disabled 漏掉它
|
||||||
|
const tree = buildMenuOptionTree(
|
||||||
|
parseMenuOptionList(
|
||||||
|
[
|
||||||
|
{ id: 98, name: '账号与权限', parent_id: null, sort_order: 1, grantable: false },
|
||||||
|
{ id: 7, name: '用户管理', parent_id: 98, sort_order: 1, grantable: false },
|
||||||
|
],
|
||||||
|
'admin',
|
||||||
|
),
|
||||||
|
)
|
||||||
|
assert.deepEqual(compactDirectGrantIds([98, 7], tree), [98], '父级已勾选时仍压缩掉后代')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('align_user_menu_grantable_wired_end_to_end', () => {
|
||||||
|
const tree = readSource('src/pages/account/UserMenuAuthTree.vue')
|
||||||
|
assert.match(tree, /disabled: 'disabled'/, 'el-tree 按 disabled 键置灰节点')
|
||||||
|
|
||||||
|
const vo = readSource(
|
||||||
|
'../backend-java/src/main/java/com/nanri/aiimage/modules/permission/model/vo/PermissionMenuItemVo.java',
|
||||||
|
)
|
||||||
|
assert.match(vo, /private Boolean grantable;/, 'VO 暴露 grantable')
|
||||||
|
|
||||||
|
const controller = readSource(
|
||||||
|
'../backend-java/src/main/java/com/nanri/aiimage/modules/permission/controller/PermissionMenuController.java',
|
||||||
|
)
|
||||||
|
assert.match(controller, /permissionMenuService\.list\(requireAdmin\(request\), menuType\)/, '列表接口传入操作者')
|
||||||
|
|
||||||
|
const service = readSource(
|
||||||
|
'../backend-java/src/main/java/com/nanri/aiimage/modules/permission/service/PermissionMenuService.java',
|
||||||
|
)
|
||||||
|
assert.match(service, /resolveGrantableMenuIds/, '按操作者计算可授予集')
|
||||||
|
assert.match(service, /ensureGrantable\(operator, grantIds, userId\)/, '保存校验传入目标用户以放行既有授权')
|
||||||
|
})
|
||||||
+18
-2
@@ -1,6 +1,7 @@
|
|||||||
package com.nanri.aiimage.common.exception;
|
package com.nanri.aiimage.common.exception;
|
||||||
|
|
||||||
import com.nanri.aiimage.common.api.ApiResponse;
|
import com.nanri.aiimage.common.api.ApiResponse;
|
||||||
|
import com.nanri.aiimage.common.security.DeviceSessionPolicy;
|
||||||
import com.nanri.aiimage.common.service.TaskOwnerForwardService;
|
import com.nanri.aiimage.common.service.TaskOwnerForwardService;
|
||||||
import com.nanri.aiimage.config.TaskOperationLockConfig;
|
import com.nanri.aiimage.config.TaskOperationLockConfig;
|
||||||
import jakarta.servlet.http.HttpServletRequest;
|
import jakarta.servlet.http.HttpServletRequest;
|
||||||
@@ -60,14 +61,23 @@ public class GlobalExceptionHandler {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@ExceptionHandler(BusinessException.class)
|
@ExceptionHandler(BusinessException.class)
|
||||||
public ApiResponse<Void> handleBusinessException(BusinessException ex) {
|
public ApiResponse<Void> handleBusinessException(BusinessException ex, HttpServletRequest request) {
|
||||||
|
// 业务异常此前完全不记日志:2026-09-16 线上「保存权限/创建用户」双双失败时,
|
||||||
|
// 服务端只留 RequestTraceFilter 的 200 一行,根因只能靠反推响应体字节数才定位到。
|
||||||
|
// 401/4011(未登录、被顶下线)属于轮询类接口的常态噪声,降为 debug 以免淹没真实业务错。
|
||||||
|
if (isRoutineAuthNoise(ex.getCode())) {
|
||||||
|
log.debug("[business] {} {} code={} message={}", request.getMethod(), request.getRequestURI(),
|
||||||
|
ex.getCode(), ex.getMessage());
|
||||||
|
} else {
|
||||||
|
log.warn("[business] {} {} code={} message={}", request.getMethod(), request.getRequestURI(),
|
||||||
|
ex.getCode(), ex.getMessage());
|
||||||
|
}
|
||||||
if (Integer.valueOf(BusinessCodes.TASK_ALREADY_FINISHED).equals(ex.getCode())) {
|
if (Integer.valueOf(BusinessCodes.TASK_ALREADY_FINISHED).equals(ex.getCode())) {
|
||||||
// 幂等忽略:任务已结束时的重复提交无副作用,按成功返回,避免客户端反复重试
|
// 幂等忽略:任务已结束时的重复提交无副作用,按成功返回,避免客户端反复重试
|
||||||
return ApiResponse.success("任务已结束,忽略重复提交", null);
|
return ApiResponse.success("任务已结束,忽略重复提交", null);
|
||||||
}
|
}
|
||||||
if (Integer.valueOf(BusinessCodes.TASK_BUSY).equals(ex.getCode())) {
|
if (Integer.valueOf(BusinessCodes.TASK_BUSY).equals(ex.getCode())) {
|
||||||
// 锁竞争:必须如实返回失败 + 可重试码,否则 worker 会把「未落库」当成功而停止重试
|
// 锁竞争:必须如实返回失败 + 可重试码,否则 worker 会把「未落库」当成功而停止重试
|
||||||
log.warn("[business] 任务忙,调用方应稍后重试: {}", ex.getMessage());
|
|
||||||
return ApiResponse.fail(BusinessCodes.TASK_BUSY, ex.getMessage());
|
return ApiResponse.fail(BusinessCodes.TASK_BUSY, ex.getMessage());
|
||||||
}
|
}
|
||||||
return ex.getCode() == null
|
return ex.getCode() == null
|
||||||
@@ -75,6 +85,12 @@ public class GlobalExceptionHandler {
|
|||||||
: ApiResponse.fail(ex.getCode(), ex.getMessage());
|
: ApiResponse.fail(ex.getCode(), ex.getMessage());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 未登录 / 登录态失效 / 被其他设备顶下线:按 401 语义的常态噪声,不占 WARN。 */
|
||||||
|
private boolean isRoutineAuthNoise(Integer code) {
|
||||||
|
return Integer.valueOf(401).equals(code)
|
||||||
|
|| Integer.valueOf(DeviceSessionPolicy.CODE_KICKED).equals(code);
|
||||||
|
}
|
||||||
|
|
||||||
@ExceptionHandler(MethodArgumentNotValidException.class)
|
@ExceptionHandler(MethodArgumentNotValidException.class)
|
||||||
public ApiResponse<Void> handleMethodArgumentNotValidException(MethodArgumentNotValidException ex) {
|
public ApiResponse<Void> handleMethodArgumentNotValidException(MethodArgumentNotValidException ex) {
|
||||||
String message = ex.getBindingResult().getFieldError() != null
|
String message = ex.getBindingResult().getFieldError() != null
|
||||||
|
|||||||
+2
-2
@@ -56,8 +56,8 @@ public class PermissionMenuController {
|
|||||||
@Operation(summary = "查询菜单权限列表")
|
@Operation(summary = "查询菜单权限列表")
|
||||||
public ApiResponse<List<PermissionMenuItemVo>> listMenus(HttpServletRequest request,
|
public ApiResponse<List<PermissionMenuItemVo>> listMenus(HttpServletRequest request,
|
||||||
@Parameter(description = "菜单类型: app/admin") @RequestParam(required = false) String menuType) {
|
@Parameter(description = "菜单类型: app/admin") @RequestParam(required = false) String menuType) {
|
||||||
requireAdmin(request);
|
// 传入操作者:授权树据此把「非超管无权授予」的菜单标记为不可勾选
|
||||||
return ApiResponse.success(permissionMenuService.list(menuType));
|
return ApiResponse.success(permissionMenuService.list(requireAdmin(request), menuType));
|
||||||
}
|
}
|
||||||
|
|
||||||
@PostMapping("/permission-menus")
|
@PostMapping("/permission-menus")
|
||||||
|
|||||||
+6
@@ -24,4 +24,10 @@ public class PermissionMenuItemVo {
|
|||||||
private Integer sortOrder;
|
private Integer sortOrder;
|
||||||
@JsonProperty("created_at")
|
@JsonProperty("created_at")
|
||||||
private LocalDateTime createdAt;
|
private LocalDateTime createdAt;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 当前操作者能否把该菜单授予他人。仅授权树接口(list)填充;菜单 CRUD 回显等
|
||||||
|
* 其他接口留空,语义为「不限制」。
|
||||||
|
*/
|
||||||
|
private Boolean grantable;
|
||||||
}
|
}
|
||||||
|
|||||||
+44
-6
@@ -57,8 +57,35 @@ public class PermissionMenuService {
|
|||||||
|
|
||||||
/** Returns the flat menu catalog, including each item's direct parent ID. */
|
/** Returns the flat menu catalog, including each item's direct parent ID. */
|
||||||
public List<PermissionMenuItemVo> list(String menuType) {
|
public List<PermissionMenuItemVo> list(String menuType) {
|
||||||
|
return list(null, menuType);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 授权树用:非超管只能授予自己已持有的菜单(与 {@link #ensureGrantable} 同一判据),
|
||||||
|
* 这里把结论标进 {@code grantable},让前端把不可授予的节点置灰,避免「勾了才在保存时
|
||||||
|
* 整单被 403 回滚」——创建用户与保存权限共用这一棵树,勾到一个越权项会连建号一起失败。
|
||||||
|
* operator 为空或超管时不限制。
|
||||||
|
*/
|
||||||
|
public List<PermissionMenuItemVo> list(AdminUserEntity operator, String menuType) {
|
||||||
List<PermissionMenuEntity> menus = loadMenus(menuType);
|
List<PermissionMenuEntity> menus = loadMenus(menuType);
|
||||||
return toItemVos(menus, menus);
|
List<PermissionMenuItemVo> items = toItemVos(menus, menus);
|
||||||
|
Set<Long> grantableIds = resolveGrantableMenuIds(operator);
|
||||||
|
for (PermissionMenuItemVo item : items) {
|
||||||
|
item.setGrantable(grantableIds == null || grantableIds.contains(item.getId()));
|
||||||
|
}
|
||||||
|
return items;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 操作者可授予的菜单 id 全集(自身直接授权 + 其后代)。
|
||||||
|
*
|
||||||
|
* @return null 表示不限制(超管或无操作者)
|
||||||
|
*/
|
||||||
|
private Set<Long> resolveGrantableMenuIds(AdminUserEntity operator) {
|
||||||
|
if (operator == null || operator.getId() == null || isSuperAdmin(operator)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return expandDescendantIds(new LinkedHashSet<>(loadDirectColumnIds(operator.getId())), loadMenus(null));
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -543,7 +570,7 @@ public class PermissionMenuService {
|
|||||||
.filter(id -> !protectedIds.contains(id))
|
.filter(id -> !protectedIds.contains(id))
|
||||||
.toList();
|
.toList();
|
||||||
}
|
}
|
||||||
ensureGrantable(operator, grantIds);
|
ensureGrantable(operator, grantIds, userId);
|
||||||
LinkedHashSet<Long> finalGrantIds = new LinkedHashSet<>(grantIds);
|
LinkedHashSet<Long> finalGrantIds = new LinkedHashSet<>(grantIds);
|
||||||
|
|
||||||
if (!protectedIds.isEmpty()) {
|
if (!protectedIds.isEmpty()) {
|
||||||
@@ -833,14 +860,25 @@ public class PermissionMenuService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Throws 403 when a non-super admin requests grants outside their own effective set. */
|
/**
|
||||||
private void ensureGrantable(AdminUserEntity operator, List<Long> requestedIds) {
|
* Throws 403 when a non-super admin **新增** grants outside their own effective set.
|
||||||
|
*
|
||||||
|
* <p>目标用户已持有的直接授权不参与校验:那是既有事实(通常由超管分配),普通管理员
|
||||||
|
* 编辑该用户时整树提交会把它原样带回,若一并判为越权,整笔事务会回滚——连建号、改密
|
||||||
|
* 都做不成(生产 2026-09-16 现象:勾到一个越权项,创建用户与保存权限双双失败)。
|
||||||
|
* 只放行「保留已有」不放行「新增」,因此不构成提权。
|
||||||
|
*/
|
||||||
|
private void ensureGrantable(AdminUserEntity operator, List<Long> requestedIds, Long targetUserId) {
|
||||||
if (operator == null || isSuperAdmin(operator)) {
|
if (operator == null || isSuperAdmin(operator)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
List<PermissionMenuEntity> menus = loadMenus(null);
|
// 操作者自身 id 缺失(异常数据)时按「无任何可授予项」从严处理,不放行
|
||||||
Set<Long> effective = expandDescendantIds(new LinkedHashSet<>(loadDirectColumnIds(operator.getId())), menus);
|
Set<Long> effective = Objects.requireNonNullElse(resolveGrantableMenuIds(operator), Set.of());
|
||||||
|
Set<Long> keptIds = targetUserId == null
|
||||||
|
? Set.of()
|
||||||
|
: new LinkedHashSet<>(loadDirectColumnIds(targetUserId));
|
||||||
Set<Long> denied = requestedIds.stream()
|
Set<Long> denied = requestedIds.stream()
|
||||||
|
.filter(id -> !keptIds.contains(id))
|
||||||
.filter(id -> !effective.contains(id))
|
.filter(id -> !effective.contains(id))
|
||||||
.collect(Collectors.toCollection(LinkedHashSet::new));
|
.collect(Collectors.toCollection(LinkedHashSet::new));
|
||||||
if (!denied.isEmpty()) {
|
if (!denied.isEmpty()) {
|
||||||
|
|||||||
+6
-5
@@ -19,6 +19,7 @@ import java.util.List;
|
|||||||
|
|
||||||
import static org.assertj.core.api.Assertions.assertThat;
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||||
|
import static org.mockito.ArgumentMatchers.any;
|
||||||
import static org.mockito.ArgumentMatchers.anyLong;
|
import static org.mockito.ArgumentMatchers.anyLong;
|
||||||
import static org.mockito.ArgumentMatchers.eq;
|
import static org.mockito.ArgumentMatchers.eq;
|
||||||
import static org.mockito.Mockito.mock;
|
import static org.mockito.Mockito.mock;
|
||||||
@@ -125,12 +126,12 @@ class PermissionMenuControllerTest {
|
|||||||
when(request.getHeader("X-Internal-Token")).thenReturn("shared-token");
|
when(request.getHeader("X-Internal-Token")).thenReturn("shared-token");
|
||||||
when(request.getParameter("operatorId")).thenReturn("12");
|
when(request.getParameter("operatorId")).thenReturn("12");
|
||||||
when(service.requireAdminOperator(12L)).thenReturn(operator);
|
when(service.requireAdminOperator(12L)).thenReturn(operator);
|
||||||
when(service.list("app")).thenReturn(List.of());
|
when(service.list(operator, "app")).thenReturn(List.of());
|
||||||
|
|
||||||
controller.listMenus(request, "app");
|
controller.listMenus(request, "app");
|
||||||
|
|
||||||
verify(service).requireAdminOperator(12L);
|
verify(service).requireAdminOperator(12L);
|
||||||
verify(service).list("app");
|
verify(service).list(operator, "app");
|
||||||
verify(request, never()).getParameter("superAdmin");
|
verify(request, never()).getParameter("superAdmin");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -149,7 +150,7 @@ class PermissionMenuControllerTest {
|
|||||||
assertThatThrownBy(() -> controller.listMenus(request, "app"))
|
assertThatThrownBy(() -> controller.listMenus(request, "app"))
|
||||||
.isSameAs(authFailure);
|
.isSameAs(authFailure);
|
||||||
verify(service, never()).requireAdminOperator(anyLong());
|
verify(service, never()).requireAdminOperator(anyLong());
|
||||||
verify(service, never()).list("app");
|
verify(service, never()).list(any(AdminUserEntity.class), eq("app"));
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -170,12 +171,12 @@ class PermissionMenuControllerTest {
|
|||||||
when(request.getHeader("X-Internal-Token")).thenReturn("file-token");
|
when(request.getHeader("X-Internal-Token")).thenReturn("file-token");
|
||||||
when(request.getParameter("operatorId")).thenReturn("12");
|
when(request.getParameter("operatorId")).thenReturn("12");
|
||||||
when(service.requireAdminOperator(12L)).thenReturn(operator);
|
when(service.requireAdminOperator(12L)).thenReturn(operator);
|
||||||
when(service.list("admin")).thenReturn(List.of());
|
when(service.list(operator, "admin")).thenReturn(List.of());
|
||||||
|
|
||||||
controller.listMenus(request, "admin");
|
controller.listMenus(request, "admin");
|
||||||
|
|
||||||
verify(service).requireAdminOperator(12L);
|
verify(service).requireAdminOperator(12L);
|
||||||
verify(service).list("admin");
|
verify(service).list(operator, "admin");
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
|||||||
+104
@@ -25,6 +25,7 @@ import java.util.List;
|
|||||||
|
|
||||||
import static org.assertj.core.api.Assertions.assertThat;
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||||
|
import static org.assertj.core.api.Assertions.tuple;
|
||||||
import static org.mockito.ArgumentMatchers.any;
|
import static org.mockito.ArgumentMatchers.any;
|
||||||
import static org.mockito.ArgumentMatchers.eq;
|
import static org.mockito.ArgumentMatchers.eq;
|
||||||
import static org.mockito.Mockito.mock;
|
import static org.mockito.Mockito.mock;
|
||||||
@@ -1168,6 +1169,109 @@ class PermissionMenuServiceTest {
|
|||||||
assertThat(updated.getValue().getName()).isEqualTo("改名");
|
assertThat(updated.getValue().getName()).isEqualTo("改名");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 授权树接口:非超管只能授予自己已持有的菜单,越界的项必须被标记为不可授予,
|
||||||
|
* 否则前端会渲染成可勾选,用户勾了才在保存时整单被 403 回滚。
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
void listMarksMenusOutsideOperatorGrantableSetAsNonGrantable() {
|
||||||
|
PermissionMenuMapper menuMapper = mock(PermissionMenuMapper.class);
|
||||||
|
UserColumnPermissionMapper permissionMapper = mock(UserColumnPermissionMapper.class);
|
||||||
|
AdminUserMapper userMapper = mock(AdminUserMapper.class);
|
||||||
|
PermissionMenuService service = new PermissionMenuService(menuMapper, permissionMapper, userMapper);
|
||||||
|
when(menuMapper.selectList(any())).thenReturn(List.of(
|
||||||
|
menu(1L, null, "admin", 1),
|
||||||
|
menu(2L, 1L, "admin", 2),
|
||||||
|
menu(3L, null, "admin", 3)));
|
||||||
|
// 操作者只有 1(子菜单 2 随父展开),3 在他可授予范围之外
|
||||||
|
when(permissionMapper.selectByUserId(30L)).thenReturn(List.of(grant(30L, 1L)));
|
||||||
|
|
||||||
|
List<PermissionMenuItemVo> items = service.list(user(30L, "admin", 1), null);
|
||||||
|
|
||||||
|
assertThat(items)
|
||||||
|
.extracting(PermissionMenuItemVo::getId, PermissionMenuItemVo::getGrantable)
|
||||||
|
.containsExactlyInAnyOrder(tuple(1L, true), tuple(2L, true), tuple(3L, false));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void listMarksEveryMenuGrantableForSuperAdmin() {
|
||||||
|
PermissionMenuMapper menuMapper = mock(PermissionMenuMapper.class);
|
||||||
|
UserColumnPermissionMapper permissionMapper = mock(UserColumnPermissionMapper.class);
|
||||||
|
AdminUserMapper userMapper = mock(AdminUserMapper.class);
|
||||||
|
PermissionMenuService service = new PermissionMenuService(menuMapper, permissionMapper, userMapper);
|
||||||
|
when(menuMapper.selectList(any())).thenReturn(List.of(
|
||||||
|
menu(1L, null, "admin", 1),
|
||||||
|
menu(3L, null, "admin", 3)));
|
||||||
|
|
||||||
|
List<PermissionMenuItemVo> items = service.list(user(31L, "super_admin", 1), null);
|
||||||
|
|
||||||
|
assertThat(items).extracting(PermissionMenuItemVo::getGrantable).containsOnly(true);
|
||||||
|
verify(permissionMapper, never()).selectByUserId(any());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 目标用户已持有的授权(通常由超管分配)不参与「只能授自己已有的」校验:
|
||||||
|
* 普通管理员编辑该用户时整树提交会把它原样带回,若一并判为越权,整笔事务回滚,
|
||||||
|
* 连建号/改密都做不成——即生产 2026-09-16 的现象。
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
void allowsNonSuperAdminToKeepExistingGrantsOutsideOwnSet() {
|
||||||
|
PermissionMenuMapper menuMapper = mock(PermissionMenuMapper.class);
|
||||||
|
UserColumnPermissionMapper permissionMapper = mock(UserColumnPermissionMapper.class);
|
||||||
|
AdminUserMapper userMapper = mock(AdminUserMapper.class);
|
||||||
|
PermissionMenuService service = new PermissionMenuService(menuMapper, permissionMapper, userMapper);
|
||||||
|
when(userMapper.selectById(7L)).thenReturn(ownedNormalUser(7L, 30L));
|
||||||
|
when(menuMapper.selectList(any())).thenReturn(List.of(
|
||||||
|
menu(1L, null, "admin", 1),
|
||||||
|
menu(2L, null, "admin", 2)));
|
||||||
|
when(menuMapper.selectCount(any())).thenReturn(2L);
|
||||||
|
when(menuMapper.selectOne(any())).thenReturn(null);
|
||||||
|
when(permissionMapper.selectByUserId(30L)).thenReturn(List.of(grant(30L, 2L)));
|
||||||
|
when(permissionMapper.selectByUserId(7L)).thenReturn(List.of(grant(7L, 1L)));
|
||||||
|
|
||||||
|
UserColumnPermissionUpdateRequest request = new UserColumnPermissionUpdateRequest();
|
||||||
|
request.setColumnIds(List.of(1L, 2L));
|
||||||
|
service.updateUserColumnPermissions(user(30L, "admin", 1), 7L, request);
|
||||||
|
|
||||||
|
ArgumentCaptor<UserColumnPermissionEntity> captor = ArgumentCaptor.forClass(UserColumnPermissionEntity.class);
|
||||||
|
verify(permissionMapper, times(2)).insert(captor.capture());
|
||||||
|
assertThat(captor.getAllValues())
|
||||||
|
.extracting(UserColumnPermissionEntity::getColumnId)
|
||||||
|
.containsExactly(1L, 2L);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 只放行「保留已有」,不放行「新增」:越权新增仍然拒绝,不构成提权。 */
|
||||||
|
@Test
|
||||||
|
void rejectsNewGrantOutsideOperatorSet() {
|
||||||
|
PermissionMenuMapper menuMapper = mock(PermissionMenuMapper.class);
|
||||||
|
UserColumnPermissionMapper permissionMapper = mock(UserColumnPermissionMapper.class);
|
||||||
|
AdminUserMapper userMapper = mock(AdminUserMapper.class);
|
||||||
|
PermissionMenuService service = new PermissionMenuService(menuMapper, permissionMapper, userMapper);
|
||||||
|
when(userMapper.selectById(7L)).thenReturn(ownedNormalUser(7L, 30L));
|
||||||
|
when(menuMapper.selectList(any())).thenReturn(List.of(
|
||||||
|
menu(1L, null, "admin", 1),
|
||||||
|
menu(2L, null, "admin", 2),
|
||||||
|
menu(3L, null, "admin", 3)));
|
||||||
|
when(menuMapper.selectCount(any())).thenReturn(1L);
|
||||||
|
when(menuMapper.selectOne(any())).thenReturn(null);
|
||||||
|
when(permissionMapper.selectByUserId(30L)).thenReturn(List.of(grant(30L, 2L)));
|
||||||
|
when(permissionMapper.selectByUserId(7L)).thenReturn(List.of(grant(7L, 1L)));
|
||||||
|
|
||||||
|
UserColumnPermissionUpdateRequest request = new UserColumnPermissionUpdateRequest();
|
||||||
|
request.setColumnIds(List.of(3L));
|
||||||
|
|
||||||
|
assertThatThrownBy(() -> service.updateUserColumnPermissions(user(30L, "admin", 1), 7L, request))
|
||||||
|
.isInstanceOf(BusinessException.class)
|
||||||
|
.hasMessageContaining("只能分配自己已有的菜单权限");
|
||||||
|
verify(permissionMapper, never()).insert(any(UserColumnPermissionEntity.class));
|
||||||
|
}
|
||||||
|
|
||||||
|
private AdminUserEntity ownedNormalUser(Long id, Long createdById) {
|
||||||
|
AdminUserEntity entity = user(id, "normal", 0);
|
||||||
|
entity.setCreatedById(createdById);
|
||||||
|
return entity;
|
||||||
|
}
|
||||||
|
|
||||||
private PermissionMenuCreateRequest createRequest(Long parentId, String menuType) {
|
private PermissionMenuCreateRequest createRequest(Long parentId, String menuType) {
|
||||||
PermissionMenuCreateRequest request = new PermissionMenuCreateRequest();
|
PermissionMenuCreateRequest request = new PermissionMenuCreateRequest();
|
||||||
request.setName("child");
|
request.setName("child");
|
||||||
|
|||||||
Reference in New Issue
Block a user