This is an automated email from the ASF dual-hosted git repository.
lizhimins pushed a commit to branch rocketmq-studio
in repository https://gitbox.apache.org/repos/asf/rocketmq-dashboard.git
The following commit(s) were added to refs/heads/rocketmq-studio by this push:
new 9fced5f2d fix(acl): instance lifecycle, paged rule/user inventories
and integrity hardening (#1725)
9fced5f2d is described below
commit 9fced5f2d4cdf10de337a004cb25b573e269a946
Author: aias00 <[email protected]>
AuthorDate: Wed Aug 19 15:41:20 2026 +0800
fix(acl): instance lifecycle, paged rule/user inventories and integrity
hardening (#1725)
* fix: preserve ACL lifecycle and update integrity
Signed-off-by: liuhy <[email protected]>
* fix: preserve ACL instance context after rebase
* fix: harden persisted plain ACL integrity
* Restore ACL user API export parsing
Constraint: Keep PR #2333 scoped to the verified syntax break in
web/src/api/acl.ts.
Rejected: Broader ACL API cleanup | user requested only the stray
duplicated function line removal.
Confidence: high
Scope-risk: narrow
Tested: npm run build; git diff --check
Signed-off-by: liuhy <[email protected]>
* feat: paginate searchable ACL user inventory
* feat: paginate filtered ACL rule inventory
---------
Signed-off-by: liuhy <[email protected]>
---
.../studio/instance/acl/AclController.java | 24 +-
.../studio/instance/acl/AclRepository.java | 4 +-
.../rocketmq/studio/instance/acl/AclService.java | 72 +++-
.../instance/acl/MybatisPlusAclRepository.java | 66 +++-
server/src/main/resources/db/schema.sql | 3 +-
.../studio/instance/acl/AclControllerTest.java | 34 +-
.../studio/instance/acl/AclServiceTest.java | 57 +++-
.../instance/acl/MybatisPlusAclRepositoryTest.java | 122 ++++++-
web/src/api/acl.test.ts | 19 +-
web/src/api/acl.ts | 30 +-
web/src/i18n/translations.ts | 24 +-
web/src/pages/instance/__tests__/AclPage.test.tsx | 364 +++++++++++++++++----
web/src/pages/instance/acl.tsx | 205 +++++++++---
web/src/services/aclService.test.ts | 16 +-
web/src/services/aclService.ts | 47 ++-
15 files changed, 919 insertions(+), 168 deletions(-)
diff --git
a/server/src/main/java/org/apache/rocketmq/studio/instance/acl/AclController.java
b/server/src/main/java/org/apache/rocketmq/studio/instance/acl/AclController.java
index 949e27dfc..e7a2a87fc 100644
---
a/server/src/main/java/org/apache/rocketmq/studio/instance/acl/AclController.java
+++
b/server/src/main/java/org/apache/rocketmq/studio/instance/acl/AclController.java
@@ -17,6 +17,7 @@
package org.apache.rocketmq.studio.instance.acl;
import org.apache.rocketmq.studio.common.domain.DeleteRequestDTO;
+import org.apache.rocketmq.studio.common.domain.PageResult;
import org.apache.rocketmq.studio.common.domain.Result;
import org.apache.rocketmq.studio.common.exception.BusinessException;
import jakarta.validation.Valid;
@@ -55,11 +56,17 @@ public class AclController {
}
@GetMapping("/rules")
- public Result<List<AclRuleVO>> listRules(
- @RequestParam(required = false) String clusterId,
+ public Result<PageResult<AclRuleVO>> listRules(
@RequestParam(required = false) String principal,
- @RequestParam(required = false) String instanceId) {
- return Result.ok(aclService.listRules(clusterId, principal,
instanceId));
+ @RequestParam(required = false) String resource,
+ @RequestParam(required = false) String scope,
+ @RequestParam(required = false) String decision,
+ @RequestParam(required = false) String aclVersion,
+ @RequestParam(required = false) String instanceId,
+ @RequestParam(defaultValue = "1") Integer page,
+ @RequestParam(defaultValue = "20") Integer pageSize) {
+ return Result.ok(aclService.listRules(principal, resource, scope,
decision, aclVersion,
+ instanceId, page, pageSize));
}
@PostMapping("/rules/create")
@@ -86,6 +93,15 @@ public class AclController {
return Result.ok(aclService.listUsers(instanceId));
}
+ @GetMapping("/users/page")
+ public Result<PageResult<AclUserVO>> pageUsers(
+ @RequestParam(required = false) String instanceId,
+ @RequestParam(defaultValue = "1") int page,
+ @RequestParam(defaultValue = "20") int pageSize,
+ @RequestParam(required = false) String keyword) {
+ return Result.ok(aclService.pageUsers(instanceId, page, pageSize,
keyword));
+ }
+
@GetMapping("/users/{id}/credentials")
public ResponseEntity<Result<AclUserVO>> getUserCredentials(@PathVariable
String id,
@RequestParam(required = false) String instanceId) {
diff --git
a/server/src/main/java/org/apache/rocketmq/studio/instance/acl/AclRepository.java
b/server/src/main/java/org/apache/rocketmq/studio/instance/acl/AclRepository.java
index 8f1377b96..944cd4d80 100644
---
a/server/src/main/java/org/apache/rocketmq/studio/instance/acl/AclRepository.java
+++
b/server/src/main/java/org/apache/rocketmq/studio/instance/acl/AclRepository.java
@@ -16,12 +16,14 @@
*/
package org.apache.rocketmq.studio.instance.acl;
+import org.apache.rocketmq.studio.common.domain.PageResult;
import java.util.List;
import java.util.Optional;
public interface AclRepository {
- List<AclRuleVO> findRules(String clusterId, String principal);
+ PageResult<AclRuleVO> findRulePage(String principal, String resource,
String scope,
+ String decision, String aclVersion, int page, int pageSize);
AclRuleVO saveRule(AclRuleVO rule);
diff --git
a/server/src/main/java/org/apache/rocketmq/studio/instance/acl/AclService.java
b/server/src/main/java/org/apache/rocketmq/studio/instance/acl/AclService.java
index b10f945f3..26b818cac 100644
---
a/server/src/main/java/org/apache/rocketmq/studio/instance/acl/AclService.java
+++
b/server/src/main/java/org/apache/rocketmq/studio/instance/acl/AclService.java
@@ -18,7 +18,9 @@ package org.apache.rocketmq.studio.instance.acl;
import org.springframework.util.StringUtils;
+import org.apache.rocketmq.studio.common.domain.PageResult;
import org.apache.rocketmq.studio.common.exception.BusinessException;
+import org.apache.rocketmq.studio.common.util.Pagination;
import org.apache.rocketmq.studio.common.domain.enums.InstanceVendor;
import org.apache.rocketmq.studio.common.util.CredentialUtils;
import org.apache.rocketmq.studio.common.util.EntityIds;
@@ -33,6 +35,7 @@ import org.springframework.stereotype.Service;
import java.security.SecureRandom;
import java.time.LocalDateTime;
+import java.util.Comparator;
import java.util.List;
import java.util.Locale;
@@ -42,6 +45,7 @@ import java.util.Locale;
public class AclService {
private static final SecureRandom CREDENTIAL_RANDOM = new SecureRandom();
+ private static final int DEFAULT_RULE_PAGE_SIZE = 20;
private final AclRepository aclRepository;
private final OperationAuditService operationAuditService;
@@ -64,12 +68,23 @@ public class AclService {
}
- public List<AclRuleVO> listRules(String clusterId, String principal,
String instanceId) {
+ public PageResult<AclRuleVO> listRules(String principal, String resource,
String scope, String decision,
+ String aclVersion, String instanceId, Integer page, Integer
pageSize) {
+ int normalizedPage = normalizePage(page);
+ int normalizedPageSize = normalizePageSize(pageSize);
if (isTencentInstance(instanceId)) {
- return tencentAclService.listRules(instanceId, principal);
+ List<AclRuleVO> filtered = tencentAclService.listRules(instanceId,
principal).stream()
+ .filter(rule -> containsIgnoreCase(rule.getResource(),
resource))
+ .filter(rule -> equalsIgnoreCase(rule.getScope(), scope))
+ .filter(rule -> equalsIgnoreCase(rule.getDecision(),
decision))
+ .filter(rule -> equalsIgnoreCase(rule.getAclVersion(),
aclVersion))
+ .toList();
+ return paginateRules(filtered, normalizedPage, normalizedPageSize);
}
- log.info("Listing ACL rules for clusterId={}, principal={}",
clusterId, principal);
- return aclRepository.findRules(clusterId, principal);
+ log.info("Listing ACL rules for principal={}, resource={}, scope={},
decision={}, aclVersion={}, page={}, pageSize={}",
+ principal, resource, scope, decision, aclVersion,
normalizedPage, normalizedPageSize);
+ return aclRepository.findRulePage(principal, resource, scope,
decision, aclVersion,
+ normalizedPage, normalizedPageSize);
}
@@ -129,6 +144,25 @@ public class AclService {
.toList();
}
+ public PageResult<AclUserVO> pageUsers(String instanceId, int page, int
pageSize, String keyword) {
+ if (page < 1 || pageSize < 1 || pageSize > 100) {
+ throw new BusinessException(400, "page must be >= 1 and pageSize
must be between 1 and 100");
+ }
+ String query = keyword == null ? "" :
keyword.trim().toLowerCase(Locale.ROOT);
+ List<AclUserVO> users = (isTencentInstance(instanceId)
+ ? tencentAclService.listUsers(instanceId) :
aclRepository.findUsers()).stream()
+ .filter(user -> query.isEmpty()
+ || containsIgnoreCase(user.getUsername(), query)
+ || containsIgnoreCase(user.getAccessKey(), query))
+ .sorted(Comparator.comparing(AclUserVO::getGmtCreate,
Comparator.nullsLast(Comparator.reverseOrder()))
+ .thenComparing(AclUserVO::getId,
Comparator.nullsLast(Comparator.naturalOrder())))
+ .toList();
+ int from = (int) Math.min(Pagination.pageOffset(page, pageSize),
users.size());
+ int to = Math.min(from + pageSize, users.size());
+ return PageResult.of(users.subList(from,
to).stream().map(this::maskCredentials).toList(),
+ users.size(), page, pageSize);
+ }
+
public AclUserVO createUser(AclUserVO user, String instanceId) {
if (isTencentInstance(instanceId)) {
@@ -316,6 +350,36 @@ public class AclService {
.build();
}
+ private static int normalizePage(Integer page) {
+ return page == null || page < 1 ? 1 : page;
+ }
+
+ private static int normalizePageSize(Integer pageSize) {
+ return pageSize == null || pageSize < 1 ? DEFAULT_RULE_PAGE_SIZE :
pageSize;
+ }
+
+ private static boolean containsIgnoreCase(String value, String
expectedFragment) {
+ if (!StringUtils.hasText(expectedFragment)) {
+ return true;
+ }
+ return StringUtils.hasText(value)
+ &&
value.toLowerCase(Locale.ROOT).contains(expectedFragment.toLowerCase(Locale.ROOT));
+ }
+
+ private static boolean equalsIgnoreCase(String value, String expected) {
+ if (!StringUtils.hasText(expected)) {
+ return true;
+ }
+ return StringUtils.hasText(value) && value.equalsIgnoreCase(expected);
+ }
+
+ private static PageResult<AclRuleVO> paginateRules(List<AclRuleVO> rules,
int page, int pageSize) {
+ int total = rules.size();
+ int fromIndex = Math.min((page - 1) * pageSize, total);
+ int toIndex = Math.min(fromIndex + pageSize, total);
+ return PageResult.of(rules.subList(fromIndex, toIndex), total, page,
pageSize);
+ }
+
private void auditRule(String operation, AclRuleVO rule) {
recordAudit(operation, "ACL_RULE", String.valueOf(rule.getId()), null,
"principal=" + rule.getPrincipal());
diff --git
a/server/src/main/java/org/apache/rocketmq/studio/instance/acl/MybatisPlusAclRepository.java
b/server/src/main/java/org/apache/rocketmq/studio/instance/acl/MybatisPlusAclRepository.java
index a693e8f3c..4ab94bd05 100644
---
a/server/src/main/java/org/apache/rocketmq/studio/instance/acl/MybatisPlusAclRepository.java
+++
b/server/src/main/java/org/apache/rocketmq/studio/instance/acl/MybatisPlusAclRepository.java
@@ -16,8 +16,11 @@
*/
package org.apache.rocketmq.studio.instance.acl;
+import com.baomidou.mybatisplus.core.metadata.IPage;
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
import com.baomidou.mybatisplus.core.conditions.update.UpdateWrapper;
+import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
+import org.apache.rocketmq.studio.common.domain.PageResult;
import org.apache.rocketmq.studio.common.exception.BusinessException;
import org.apache.rocketmq.studio.common.util.CredentialUtils;
import org.apache.rocketmq.studio.persistence.entity.RmqAclRule;
@@ -32,8 +35,10 @@ import lombok.RequiredArgsConstructor;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.Arrays;
+import java.util.LinkedHashSet;
import java.util.List;
import java.util.Optional;
+import java.util.Set;
import java.util.stream.Collectors;
/**
@@ -48,14 +53,15 @@ public class MybatisPlusAclRepository implements
AclRepository {
private final RmqAclUserMapper userMapper;
@Override
- public List<AclRuleVO> findRules(String clusterId, String principal) {
- QueryWrapper<RmqAclRule> query = new QueryWrapper<RmqAclRule>()
- .eq(clusterId != null && !clusterId.isBlank(), "scope",
clusterId)
- .eq(principal != null && !principal.isBlank(), "principal",
principal)
- .orderByAsc("id");
- return ruleMapper.selectList(query).stream()
+ public PageResult<AclRuleVO> findRulePage(String principal, String
resource, String scope,
+ String decision, String aclVersion, int page, int pageSize) {
+ QueryWrapper<RmqAclRule> query = ruleQuery(principal, resource, scope,
decision, aclVersion);
+ IPage<RmqAclRule> mapperPage = ruleMapper.selectPage(new Page<>(page,
pageSize), query);
+ List<AclRuleVO> items = mapperPage.getRecords().stream()
.map(MybatisPlusAclRepository::toRuleVO)
.collect(Collectors.toList());
+ return PageResult.of(items, mapperPage.getTotal(), (int)
mapperPage.getCurrent(),
+ (int) mapperPage.getSize());
}
@Override
@@ -126,7 +132,9 @@ public class MybatisPlusAclRepository implements
AclRepository {
}
RmqAclUser entity = toUserEntity(user);
entity.setGmtCreate(existing.getGmtCreate());
- userMapper.updateById(entity);
+ if (userMapper.updateById(entity) == 0) {
+ return Optional.empty();
+ }
user.setGmtCreate(existing.getGmtCreate());
return Optional.of(user);
}
@@ -169,8 +177,13 @@ public class MybatisPlusAclRepository implements
AclRepository {
@Override
@Transactional
public PlainAccessConfigVO
createAndUpdatePlainAccessConfig(PlainAccessConfigVO config) {
- RmqAclUser existing = userMapper.selectOne(
+ List<RmqAclUser> existingAccounts = userMapper.selectList(
new QueryWrapper<RmqAclUser>().eq("access_key",
config.getAccessKey()));
+ if (existingAccounts.size() > 1) {
+ throw new BusinessException(409, "Multiple plain access accounts
use accessKey: "
+ + config.getAccessKey());
+ }
+ RmqAclUser existing = existingAccounts.isEmpty() ? null :
existingAccounts.get(0);
boolean secretProvided = StringUtils.hasText(config.getSecretKey());
if (!secretProvided && existing == null) {
throw new BusinessException(400, "secretKey is required for a new
plain access account");
@@ -283,13 +296,16 @@ public class MybatisPlusAclRepository implements
AclRepository {
}
private PlainAccessConfigVO toPlainAccessConfig(AclUserVO user) {
- List<AclRuleVO> userRules = findRules(null, user.getAccessKey());
+ List<AclRuleVO> userRules =
ruleMapper.selectList(ruleQuery(user.getAccessKey(), null, null, null, null))
+ .stream()
+ .map(MybatisPlusAclRepository::toRuleVO)
+ .collect(Collectors.toList());
List<String> topicPerms = new ArrayList<>();
List<String> groupPerms = new ArrayList<>();
String defaultTopicPerm = null;
String defaultGroupPerm = null;
for (AclRuleVO rule : userRules) {
- String actions = rule.getActions() == null ? "" : String.join(",",
rule.getActions());
+ String actions = joinNormalizedCsv(rule.getActions());
if ("Topic".equals(rule.getResourceType())) {
topicPerms.add(rule.getResource() + "=" + actions);
} else if ("Group".equals(rule.getResourceType())) {
@@ -317,6 +333,18 @@ public class MybatisPlusAclRepository implements
AclRepository {
.build();
}
+ private static QueryWrapper<RmqAclRule> ruleQuery(String principal, String
resource, String scope,
+ String decision, String aclVersion) {
+ return new QueryWrapper<RmqAclRule>()
+ .like(StringUtils.hasText(principal), "principal", principal)
+ .like(StringUtils.hasText(resource), "resource", resource)
+ .eq(StringUtils.hasText(scope), "scope", scope)
+ .eq(StringUtils.hasText(decision), "decision", decision)
+ .eq(StringUtils.hasText(aclVersion), "acl_version", aclVersion)
+ .orderByDesc("gmt_create")
+ .orderByDesc("id");
+ }
+
private static String[] splitPerm(String entry) {
if (entry == null) {
return null;
@@ -352,7 +380,7 @@ public class MybatisPlusAclRepository implements
AclRepository {
entity.setResource(rule.getResource());
entity.setResourceType(rule.getResourceType());
entity.setResourcePattern(rule.getResourcePattern());
- entity.setActions(rule.getActions() == null ? null : String.join(",",
rule.getActions()));
+ entity.setActions(joinNormalizedCsv(rule.getActions()));
entity.setDecision(rule.getDecision());
entity.setScope(rule.getScope());
entity.setAclVersion(rule.getAclVersion());
@@ -381,7 +409,7 @@ public class MybatisPlusAclRepository implements
AclRepository {
entity.setAccessKey(user.getAccessKey());
entity.setSecretKey(CredentialUtils.encodeBase64(user.getSecretKey()));
entity.setAdmin(user.isAdmin());
- entity.setClusters(user.getClusters() == null ? null :
String.join(",", user.getClusters()));
+ entity.setClusters(joinNormalizedCsv(user.getClusters()));
entity.setGmtCreate(user.getGmtCreate());
entity.setGmtModified(LocalDateTime.now());
return entity;
@@ -394,6 +422,20 @@ public class MybatisPlusAclRepository implements
AclRepository {
return Arrays.stream(value.split(","))
.map(String::trim)
.filter(part -> !part.isEmpty())
+ .distinct()
.collect(Collectors.toList());
}
+
+ private static String joinNormalizedCsv(List<String> values) {
+ if (values == null) {
+ return null;
+ }
+ Set<String> normalized = new LinkedHashSet<>();
+ for (String value : values) {
+ if (value != null && !value.isBlank()) {
+ normalized.add(value.trim());
+ }
+ }
+ return normalized.isEmpty() ? null : String.join(",", normalized);
+ }
}
diff --git a/server/src/main/resources/db/schema.sql
b/server/src/main/resources/db/schema.sql
index f20f859d6..74063aa21 100644
--- a/server/src/main/resources/db/schema.sql
+++ b/server/src/main/resources/db/schema.sql
@@ -235,7 +235,8 @@ CREATE TABLE IF NOT EXISTS rmq_acl_user (
clusters VARCHAR(1024) COMMENT '逗号分隔的集群/实例 id',
white_remote_address VARCHAR(255) COMMENT 'plain access 账号 IP 白名单,空表示不限制',
PRIMARY KEY (`id`),
- UNIQUE KEY uk_username (username)
+ UNIQUE KEY uk_username (username),
+ UNIQUE KEY uk_access_key (access_key)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- 13. 告警规则
diff --git
a/server/src/test/java/org/apache/rocketmq/studio/instance/acl/AclControllerTest.java
b/server/src/test/java/org/apache/rocketmq/studio/instance/acl/AclControllerTest.java
index bd88a91ac..60d6b14e8 100644
---
a/server/src/test/java/org/apache/rocketmq/studio/instance/acl/AclControllerTest.java
+++
b/server/src/test/java/org/apache/rocketmq/studio/instance/acl/AclControllerTest.java
@@ -18,6 +18,7 @@
package org.apache.rocketmq.studio.instance.acl;
import com.fasterxml.jackson.databind.ObjectMapper;
+import org.apache.rocketmq.studio.common.domain.PageResult;
import org.apache.rocketmq.studio.common.exception.BusinessException;
import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
@@ -102,28 +103,41 @@ class AclControllerTest {
rule.setId(1L);
rule.setGmtCreate(LocalDateTime.of(2026, 1, 1, 0, 0));
- when(aclService.listRules(isNull(), isNull(),
isNull())).thenReturn(List.of(rule));
+ when(aclService.listRules(isNull(), isNull(), isNull(), isNull(),
isNull(), isNull(), eq(1), eq(20)))
+ .thenReturn(PageResult.of(java.util.List.of(rule), 1, 1, 20));
mockMvc.perform(get("/api/acl/rules"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(200))
- .andExpect(jsonPath("$.data").isArray())
- .andExpect(jsonPath("$.data[0].id").value(1))
- .andExpect(jsonPath("$.data[0].principal").value("user1"))
- .andExpect(jsonPath("$.data[0].decision").value("ALLOW"));
+ .andExpect(jsonPath("$.data.items").isArray())
+ .andExpect(jsonPath("$.data.total").value(1))
+ .andExpect(jsonPath("$.data.page").value(1))
+ .andExpect(jsonPath("$.data.size").value(20))
+ .andExpect(jsonPath("$.data.items[0].id").value(1))
+
.andExpect(jsonPath("$.data.items[0].principal").value("user1"))
+
.andExpect(jsonPath("$.data.items[0].decision").value("ALLOW"));
}
@Test
void listRulesShouldPassQueryParams() throws Exception {
- when(aclService.listRules(eq("cluster-1"), eq("user1"),
isNull())).thenReturn(List.of());
+ when(aclService.listRules(eq("user1"), eq("topic-a"), eq("namespace"),
eq("DENY"),
+ eq("1.0"), isNull(), eq(3),
eq(5))).thenReturn(PageResult.empty(3, 5));
mockMvc.perform(get("/api/acl/rules")
- .param("clusterId", "cluster-1")
- .param("principal", "user1"))
+ .param("principal", "user1")
+ .param("resource", "topic-a")
+ .param("scope", "namespace")
+ .param("decision", "DENY")
+ .param("aclVersion", "1.0")
+ .param("page", "3")
+ .param("pageSize", "5"))
.andExpect(status().isOk())
- .andExpect(jsonPath("$.data").isArray());
+ .andExpect(jsonPath("$.data.items").isArray())
+ .andExpect(jsonPath("$.data.page").value(3))
+ .andExpect(jsonPath("$.data.size").value(5));
- verify(aclService).listRules(eq("cluster-1"), eq("user1"), isNull());
+ verify(aclService).listRules(eq("user1"), eq("topic-a"),
eq("namespace"), eq("DENY"),
+ eq("1.0"), isNull(), eq(3), eq(5));
}
@Test
diff --git
a/server/src/test/java/org/apache/rocketmq/studio/instance/acl/AclServiceTest.java
b/server/src/test/java/org/apache/rocketmq/studio/instance/acl/AclServiceTest.java
index 0142d97ff..c951bdafb 100644
---
a/server/src/test/java/org/apache/rocketmq/studio/instance/acl/AclServiceTest.java
+++
b/server/src/test/java/org/apache/rocketmq/studio/instance/acl/AclServiceTest.java
@@ -17,6 +17,7 @@
package org.apache.rocketmq.studio.instance.acl;
+import org.apache.rocketmq.studio.common.domain.PageResult;
import org.apache.rocketmq.studio.common.exception.BusinessException;
import org.apache.rocketmq.studio.audit.OperationAuditService;
import org.apache.rocketmq.studio.model.Acl2PolicyContext;
@@ -88,13 +89,16 @@ class AclServiceTest {
AclRuleVO.builder().principal("user1").resource("topic-1").decision("ALLOW").build(),
AclRuleVO.builder().principal("user2").resource("topic-2").decision("DENY").build()
);
- when(aclRepository.findRules("cluster-1", "user1")).thenReturn(rules);
+ when(aclRepository.findRulePage("user1", "topic", "cluster", "ALLOW",
"2.0", 2, 5))
+ .thenReturn(PageResult.of(rules, 12, 2, 5));
- List<AclRuleVO> result = aclService.listRules("cluster-1", "user1",
null);
+ PageResult<AclRuleVO> result = aclService.listRules("user1", "topic",
"cluster",
+ "ALLOW", "2.0", null, 2, 5);
- assertThat(result).hasSize(2);
- assertThat(result.get(0).getPrincipal()).isEqualTo("user1");
- verify(aclRepository).findRules("cluster-1", "user1");
+ assertThat(result.getItems()).hasSize(2);
+ assertThat(result.getItems().get(0).getPrincipal()).isEqualTo("user1");
+ assertThat(result.getTotal()).isEqualTo(12);
+ verify(aclRepository).findRulePage("user1", "topic", "cluster",
"ALLOW", "2.0", 2, 5);
}
@Test
@@ -128,12 +132,24 @@ class AclServiceTest {
@Test
void listRulesShouldPassNullFilters() {
- when(aclRepository.findRules(null, null)).thenReturn(List.of());
+ when(aclRepository.findRulePage(null, null, null, null, null, 1, 20))
+ .thenReturn(PageResult.empty(1, 20));
+
+ PageResult<AclRuleVO> result = aclService.listRules(null, null, null,
null, null,
+ null, null, null);
+
+ assertThat(result.getItems()).isEmpty();
+ verify(aclRepository).findRulePage(null, null, null, null, null, 1,
20);
+ }
+
+ @Test
+ void listRulesShouldNormalizeInvalidPaginationBounds() {
+ when(aclRepository.findRulePage(null, null, null, null, null, 1, 20))
+ .thenReturn(PageResult.empty(1, 20));
- List<AclRuleVO> result = aclService.listRules(null, null, null);
+ aclService.listRules(null, null, null, null, null, null, 0, 0);
- assertThat(result).isEmpty();
- verify(aclRepository).findRules(null, null);
+ verify(aclRepository).findRulePage(null, null, null, null, null, 1,
20);
}
@Test
@@ -212,7 +228,8 @@ class AclServiceTest {
@Test
void updateRuleShouldRejectUnknownIdInsteadOfCreatingRule() {
when(aclRepository.replaceRule(any(AclRuleVO.class))).thenReturn(Optional.empty());
- when(aclRepository.findRules(null, null)).thenReturn(List.of());
+ when(aclRepository.findRulePage(null, null, null, null, null, 1, 20))
+ .thenReturn(PageResult.empty(1, 20));
AclRuleVO update = AclRuleVO.builder()
.id(999L)
.principal("orders")
@@ -224,7 +241,7 @@ class AclServiceTest {
.isInstanceOf(BusinessException.class)
.hasMessage("ACL rule not found: 999")
.satisfies(ex -> assertThat(((BusinessException)
ex).getCode()).isEqualTo(404));
- assertThat(aclService.listRules(null, null, null)).isEmpty();
+ assertThat(aclService.listRules(null, null, null, null, null, null, 1,
20).getItems()).isEmpty();
verify(aclRepository, never()).saveRule(any(AclRuleVO.class));
}
@@ -498,7 +515,23 @@ class AclServiceTest {
.isInstanceOf(BusinessException.class)
.hasMessage("ACL user not found: 999")
.satisfies(ex -> assertThat(((BusinessException)
ex).getCode()).isEqualTo(404));
- verify(aclRepository, never()).saveUser(any(AclUserVO.class));
+ verify(aclRepository, never()).replaceUser(any(AclUserVO.class));
+ }
+
+ @Test
+ void updateUserShouldRejectConcurrentDeletion() {
+ UpdateAclUserDTO input = new UpdateAclUserDTO();
+ input.setId(1L);
+ input.setUsername("renamed");
+
when(aclRepository.findUserById(1L)).thenReturn(Optional.of(existingUser));
+
when(aclRepository.replaceUser(any(AclUserVO.class))).thenReturn(Optional.empty());
+
+ assertThatThrownBy(() -> aclService.updateUser(input, null))
+ .isInstanceOf(BusinessException.class)
+ .hasMessage("ACL user not found: 1")
+ .satisfies(error -> assertThat(((BusinessException)
error).getCode()).isEqualTo(404));
+
+ verify(operationAuditService, never()).record(any(), any(), any(),
any(), any(), any(), any());
}
@Test
diff --git
a/server/src/test/java/org/apache/rocketmq/studio/instance/acl/MybatisPlusAclRepositoryTest.java
b/server/src/test/java/org/apache/rocketmq/studio/instance/acl/MybatisPlusAclRepositoryTest.java
index efde63eee..a7a5fc195 100644
---
a/server/src/test/java/org/apache/rocketmq/studio/instance/acl/MybatisPlusAclRepositoryTest.java
+++
b/server/src/test/java/org/apache/rocketmq/studio/instance/acl/MybatisPlusAclRepositoryTest.java
@@ -18,6 +18,10 @@ package org.apache.rocketmq.studio.instance.acl;
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
import com.baomidou.mybatisplus.core.conditions.update.UpdateWrapper;
+import com.baomidou.mybatisplus.core.metadata.IPage;
+import com.baomidou.mybatisplus.core.conditions.Wrapper;
+import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
+import org.apache.rocketmq.studio.common.domain.PageResult;
import org.apache.rocketmq.studio.common.exception.BusinessException;
import org.apache.rocketmq.studio.common.util.CredentialUtils;
import org.apache.rocketmq.studio.persistence.entity.RmqAclRule;
@@ -34,6 +38,7 @@ import org.mockito.Mockito;
import org.mockito.junit.jupiter.MockitoExtension;
import java.time.LocalDateTime;
+import java.util.Arrays;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
@@ -57,6 +62,43 @@ class MybatisPlusAclRepositoryTest {
@InjectMocks
private MybatisPlusAclRepository repository;
+ @Test
+ void findRulePageShouldApplyFiltersAndPreserveFilteredTotal() {
+ RmqAclRule entity = new RmqAclRule();
+ entity.setId(7L);
+ entity.setPrincipal("user-orders");
+ entity.setResource("orders-*");
+ entity.setResourceType("Topic");
+ entity.setResourcePattern("PREFIX");
+ entity.setActions("PUB,SUB");
+ entity.setDecision("ALLOW");
+ entity.setScope("cluster");
+ entity.setAclVersion("2.0");
+ entity.setGmtCreate(LocalDateTime.of(2026, 8, 17, 10, 30));
+ Page<RmqAclRule> mapperPage = new Page<RmqAclRule>(2, 5)
+ .setRecords(List.of(entity))
+ .setTotal(17);
+ when(ruleMapper.selectPage(any(IPage.class),
any(Wrapper.class))).thenReturn(mapperPage);
+
+ PageResult<AclRuleVO> result = repository.findRulePage(
+ "user", "orders", "cluster", "ALLOW", "2.0", 2, 5);
+
+ ArgumentCaptor<IPage<RmqAclRule>> pageCaptor =
ArgumentCaptor.forClass(IPage.class);
+ ArgumentCaptor<Wrapper<RmqAclRule>> queryCaptor =
ArgumentCaptor.forClass(Wrapper.class);
+ verify(ruleMapper).selectPage(pageCaptor.capture(),
queryCaptor.capture());
+ assertThat(pageCaptor.getValue().getCurrent()).isEqualTo(2);
+ assertThat(pageCaptor.getValue().getSize()).isEqualTo(5);
+ assertThat(result.getTotal()).isEqualTo(17);
+ assertThat(result.getPage()).isEqualTo(2);
+ assertThat(result.getSize()).isEqualTo(5);
+ assertThat(result.getItems()).singleElement().satisfies(rule -> {
+ assertThat(rule.getPrincipal()).isEqualTo("user-orders");
+ assertThat(rule.getResource()).isEqualTo("orders-*");
+ });
+ assertThat(queryCaptor.getValue().getSqlSegment())
+ .contains("principal", "resource", "scope", "decision",
"acl_version");
+ }
+
@Test
void replaceRuleShouldReturnEmptyWhenConcurrentDeleteWins() {
RmqAclRule existing = new RmqAclRule();
@@ -74,9 +116,28 @@ class MybatisPlusAclRepositoryTest {
assertThat(repository.replaceRule(replacement)).isEmpty();
}
+ @Test
+ void replaceUserShouldNotRecreateAConcurrentlyDeletedUser() {
+ RmqAclUser existing = new RmqAclUser();
+ existing.setId(1L);
+ existing.setGmtCreate(LocalDateTime.of(2026, 1, 1, 0, 0));
+ when(userMapper.selectById(1L)).thenReturn(existing);
+ when(userMapper.updateById(any(RmqAclUser.class))).thenReturn(0);
+ AclUserVO replacement = AclUserVO.builder()
+ .id(1L)
+ .username("renamed")
+ .accessKey("access-key")
+ .secretKey("secret-key")
+ .build();
+
+ assertThat(repository.replaceUser(replacement)).isEmpty();
+
+ verify(userMapper, never()).insert(any(RmqAclUser.class));
+ }
+
@Test
void upsertShouldAssignUniqueRuleIdPerPermission() {
- when(userMapper.selectOne(any(QueryWrapper.class))).thenReturn(null);
+
when(userMapper.selectList(any(QueryWrapper.class))).thenReturn(List.of());
when(userMapper.insert(any(RmqAclUser.class))).thenReturn(1);
when(ruleMapper.delete(any(QueryWrapper.class))).thenReturn(0);
java.util.concurrent.atomic.AtomicLong ruleSequence = new
java.util.concurrent.atomic.AtomicLong();
@@ -114,7 +175,7 @@ class MybatisPlusAclRepositoryTest {
@Test
void upsertShouldReplacePreviousRulesBeforeInsertingNewOnes() {
- when(userMapper.selectOne(any(QueryWrapper.class))).thenReturn(null);
+
when(userMapper.selectList(any(QueryWrapper.class))).thenReturn(List.of());
when(userMapper.insert(any(RmqAclUser.class))).thenReturn(1);
when(ruleMapper.delete(any(QueryWrapper.class))).thenReturn(2);
when(ruleMapper.insert(any(RmqAclRule.class))).thenReturn(1);
@@ -134,7 +195,7 @@ class MybatisPlusAclRepositoryTest {
@Test
void createShouldRejectBlankSecretForNewAccount() {
- when(userMapper.selectOne(any(QueryWrapper.class))).thenReturn(null);
+
when(userMapper.selectList(any(QueryWrapper.class))).thenReturn(List.of());
PlainAccessConfigVO config = PlainAccessConfigVO.builder()
.accessKey("svc-new")
@@ -153,7 +214,7 @@ class MybatisPlusAclRepositoryTest {
void updateWithBlankSecretShouldKeepStoredSecret() {
RmqAclUser existing = userEntity(1L, "svc-x",
CredentialUtils.encodeBase64("kept-secret-value"));
-
when(userMapper.selectOne(any(QueryWrapper.class))).thenReturn(existing);
+
when(userMapper.selectList(any(QueryWrapper.class))).thenReturn(List.of(existing));
when(userMapper.updateById(any(RmqAclUser.class))).thenReturn(1);
when(ruleMapper.delete(any(QueryWrapper.class))).thenReturn(0);
@@ -175,7 +236,7 @@ class MybatisPlusAclRepositoryTest {
@Test
void createShouldPersistWhiteRemoteAddressAndTrimBlanks() {
- when(userMapper.selectOne(any(QueryWrapper.class))).thenReturn(null);
+
when(userMapper.selectList(any(QueryWrapper.class))).thenReturn(List.of());
when(userMapper.insert(any(RmqAclUser.class))).thenReturn(1);
when(ruleMapper.delete(any(QueryWrapper.class))).thenReturn(0);
@@ -195,7 +256,7 @@ class MybatisPlusAclRepositoryTest {
@Test
void createShouldStoreBlankWhiteRemoteAddressAsNull() {
- when(userMapper.selectOne(any(QueryWrapper.class))).thenReturn(null);
+
when(userMapper.selectList(any(QueryWrapper.class))).thenReturn(List.of());
when(userMapper.insert(any(RmqAclUser.class))).thenReturn(1);
when(ruleMapper.delete(any(QueryWrapper.class))).thenReturn(0);
@@ -217,7 +278,7 @@ class MybatisPlusAclRepositoryTest {
RmqAclUser existing = userEntity(1L, "svc-x",
CredentialUtils.encodeBase64("kept-secret-value"));
existing.setWhiteRemoteAddress("10.0.1.0/24");
-
when(userMapper.selectOne(any(QueryWrapper.class))).thenReturn(existing);
+
when(userMapper.selectList(any(QueryWrapper.class))).thenReturn(List.of(existing));
when(userMapper.updateById(any(RmqAclUser.class))).thenReturn(1);
when(userMapper.update(isNull(),
any(UpdateWrapper.class))).thenReturn(1);
when(ruleMapper.delete(any(QueryWrapper.class))).thenReturn(0);
@@ -277,6 +338,53 @@ class MybatisPlusAclRepositoryTest {
assertThat(config.isAclEnabled()).isTrue();
}
+ @Test
+ void plainAccessUpsertShouldRejectDuplicateAccessKeys() {
+ RmqAclUser first = userEntity(1L, "svc-duplicate",
CredentialUtils.encodeBase64("secret-a"));
+ RmqAclUser second = userEntity(2L, "svc-duplicate",
CredentialUtils.encodeBase64("secret-b"));
+
when(userMapper.selectList(any(QueryWrapper.class))).thenReturn(List.of(first,
second));
+
+ assertThatThrownBy(() ->
repository.createAndUpdatePlainAccessConfig(PlainAccessConfigVO.builder()
+ .accessKey("svc-duplicate")
+ .secretKey("replacement")
+ .build()))
+ .isInstanceOf(BusinessException.class)
+ .satisfies(error -> assertThat(((BusinessException)
error).getCode()).isEqualTo(409));
+
+ verify(userMapper, never()).insert(any(RmqAclUser.class));
+ verify(userMapper, never()).updateById(any(RmqAclUser.class));
+ }
+
+ @Test
+ void saveRuleShouldNormalizeActionCollectionsBeforePersistence() {
+ when(ruleMapper.selectById(1L)).thenReturn(null);
+ when(ruleMapper.insert(any(RmqAclRule.class))).thenReturn(1);
+ AclRuleVO rule =
AclRuleVO.builder().id(1L).principal("svc-a").resource("orders")
+ .actions(Arrays.asList(" PUB ", null, "", "SUB", "PUB"))
+ .build();
+
+ repository.saveRule(rule);
+
+ ArgumentCaptor<RmqAclRule> captor =
ArgumentCaptor.forClass(RmqAclRule.class);
+ verify(ruleMapper).insert(captor.capture());
+ assertThat(captor.getValue().getActions()).isEqualTo("PUB,SUB");
+ }
+
+ @Test
+ void saveUserShouldNormalizeClusterCollectionsBeforePersistence() {
+ when(userMapper.selectById(1L)).thenReturn(null);
+ when(userMapper.insert(any(RmqAclUser.class))).thenReturn(1);
+ AclUserVO user =
AclUserVO.builder().id(1L).username("svc-a").accessKey("svc-a")
+ .secretKey("secret").clusters(Arrays.asList(" cluster-a ",
null, "", "cluster-b", "cluster-a"))
+ .build();
+
+ repository.saveUser(user);
+
+ ArgumentCaptor<RmqAclUser> captor =
ArgumentCaptor.forClass(RmqAclUser.class);
+ verify(userMapper).insert(captor.capture());
+
assertThat(captor.getValue().getClusters()).isEqualTo("cluster-a,cluster-b");
+ }
+
private static RmqAclUser userEntity(Long id, String accessKey, String
encodedSecret) {
RmqAclUser entity = new RmqAclUser();
entity.setId(id);
diff --git a/web/src/api/acl.test.ts b/web/src/api/acl.test.ts
index 67b38ee63..abf4c357d 100644
--- a/web/src/api/acl.test.ts
+++ b/web/src/api/acl.test.ts
@@ -44,13 +44,26 @@ describe('ACL API contract', () => {
});
it('uses the controller-supported ACL rule filters', async () => {
- const params = { clusterId: 'cluster-a', principal: 'orders' };
+ const params = {
+ principal: 'orders',
+ resource: 'orders-*',
+ scope: 'cluster',
+ decision: 'ALLOW',
+ aclVersion: '2.0',
+ page: 2,
+ pageSize: 10,
+ };
mock.onGet('/acl/rules').reply((config) => {
expect(config.params).toEqual(params);
- return [200, { code: 200, data: [] }];
+ return [200, { code: 200, data: { items: [], total: 21, page: 2, size:
10 } }];
});
- await expect(listAclRules(params)).resolves.toEqual([]);
+ await expect(listAclRules(params)).resolves.toEqual({
+ items: [],
+ total: 21,
+ page: 2,
+ size: 10,
+ });
});
it('returns records created by rule and user APIs', async () => {
diff --git a/web/src/api/acl.ts b/web/src/api/acl.ts
index 9fc171e7b..cf85446bd 100644
--- a/web/src/api/acl.ts
+++ b/web/src/api/acl.ts
@@ -1,6 +1,13 @@
import client from './client';
// Matches mock/acl.ts
+export interface PageResult<T> {
+ items: T[];
+ total: number;
+ page: number;
+ size: number;
+}
+
export interface AclRule {
id: number;
principal: string;
@@ -15,17 +22,29 @@ export interface AclRule {
}
export interface AclRuleQuery {
- clusterId?: string;
principal?: string;
instanceId?: string;
+ resource?: string;
+ scope?: string;
+ decision?: string;
+ aclVersion?: string;
+ page?: number;
+ pageSize?: number;
}
// Users list query
-interface AclUserQuery {
+export interface AclUserQuery {
keyword?: string;
instanceId?: string;
}
+export interface AclUserPage {
+ items: AclUser[];
+ total: number;
+ page: number;
+ size: number;
+}
+
export interface AclUser {
id: number;
username: string;
@@ -39,7 +58,7 @@ export interface AclUser {
}
export async function listAclRules(params?: AclRuleQuery) {
- const res = await client.get<{ data: AclRule[] }>('/acl/rules', { params });
+ const res = await client.get<{ data: PageResult<AclRule> }>('/acl/rules', {
params });
return res.data.data;
}
@@ -62,6 +81,11 @@ export async function listAclUsers(params?: AclUserQuery) {
return res.data.data;
}
+export async function pageAclUsers(params: AclUserQuery & { page: number;
pageSize: number }) {
+ const res = await client.get<{ data: AclUserPage }>('/acl/users/page', {
params });
+ return res.data.data;
+}
+
export async function getAclUserCredentials(id: number, instanceId?: string) {
const res = await client.get<{ data: AclUser }>(
`/acl/users/${encodeURIComponent(id)}/credentials`,
diff --git a/web/src/i18n/translations.ts b/web/src/i18n/translations.ts
index edfbab170..e77f6a69c 100644
--- a/web/src/i18n/translations.ts
+++ b/web/src/i18n/translations.ts
@@ -322,7 +322,10 @@ const translations: Record<string, Record<Lang, string>> =
{
// ─── AI Page ───
'ai.title': { zh: 'AI 交互', en: 'AI Chat' },
'ai.commonCommands': { zh: '常用指令', en: 'Common Commands' },
- 'ai.mockProviderDisabled': { zh: 'Mock 模式已禁用 AI Provider 调用', en: 'Mock mode
disables AI provider calls' },
+ 'ai.mockProviderDisabled': {
+ zh: 'Mock 模式已禁用 AI Provider 调用',
+ en: 'Mock mode disables AI provider calls',
+ },
'ai.mockProviderDisabledDescription': {
zh: '切换到真实数据模式并配置 LLM Provider 后,才会加载模型、工具目录和对话能力。',
en: 'Models, the tool catalog and chat capabilities are loaded only after
you switch to real data mode and configure an LLM provider.',
@@ -342,13 +345,22 @@ const translations: Record<string, Record<Lang, string>>
= {
'ai.responseStopped': { zh: '回答已停止。', en: 'Response stopped.' },
'ai.requestFailed': { zh: 'AI 请求失败', en: 'AI request failed' },
'ai.runtimeLoadFailed': { zh: 'AI 配置加载失败', en: 'Failed to load AI
configuration' },
- 'ai.providerRequired': { zh: '请先配置并启用 LLM Provider', en: 'Configure and
enable an LLM provider first' },
+ 'ai.providerRequired': {
+ zh: '请先配置并启用 LLM Provider',
+ en: 'Configure and enable an LLM provider first',
+ },
'ai.providerNotReadyDescription': {
zh: '请先在 设置 → AI 助手 中配置并启用 LLM Provider,启用前不会发送请求或返回 stub 回复。',
en: 'Configure and enable an LLM provider under Settings → AI Assistant
first. No requests are sent and stub replies may be returned until it is
enabled.',
},
- 'ai.toolCatalogLoadFailed': { zh: 'AI 工具目录加载失败', en: 'Failed to load the AI
tool catalog' },
- 'ai.clusterListLoadFailed': { zh: '集群列表加载失败,已显示全局工具', en: 'Failed to load
clusters; showing global tools' },
+ 'ai.toolCatalogLoadFailed': {
+ zh: 'AI 工具目录加载失败',
+ en: 'Failed to load the AI tool catalog',
+ },
+ 'ai.clusterListLoadFailed': {
+ zh: '集群列表加载失败,已显示全局工具',
+ en: 'Failed to load clusters; showing global tools',
+ },
// ─── Home Page ───
'home.banner': {
@@ -402,7 +414,9 @@ const translations: Record<string, Record<Lang, string>> = {
'acl.admin': { zh: '管理员', en: 'Admin' },
'acl.username': { zh: '用户名', en: 'Username' },
'acl.associatedClusters': { zh: '关联集群', en: 'Associated Clusters' },
- 'acl.searchPrincipal': { zh: '搜索主体或资源', en: 'Search principal or resource' },
+ 'acl.searchPrincipal': { zh: '搜索主体', en: 'Search principal' },
+ 'acl.searchResource': { zh: '搜索资源', en: 'Search resource' },
+ 'acl.allScopes': { zh: '全部范围', en: 'All Scopes' },
'acl.allVersions': { zh: '全部版本', en: 'All Versions' },
'acl.allDecisions': { zh: '全部决策', en: 'All Decisions' },
'acl.totalRules': { zh: '共 {n} 条规则', en: '{n} rules total' },
diff --git a/web/src/pages/instance/__tests__/AclPage.test.tsx
b/web/src/pages/instance/__tests__/AclPage.test.tsx
index b189abbc9..2d0f86287 100644
--- a/web/src/pages/instance/__tests__/AclPage.test.tsx
+++ b/web/src/pages/instance/__tests__/AclPage.test.tsx
@@ -15,14 +15,15 @@
* limitations under the License.
*/
-import { App } from 'antd';
-import { fireEvent, render, screen, waitFor, within } from
'@testing-library/react';
+import { App, message } from 'antd';
+import { act, fireEvent, render, screen, waitFor, within } from
'@testing-library/react';
import userEvent from '@testing-library/user-event';
import type React from 'react';
import { MemoryRouter } from 'react-router-dom';
import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
import { LangProvider } from '../../../i18n/LangContext';
import * as aclService from '../../../services/aclService';
+import * as instanceService from '../../../services/instanceService';
import AclPage from '../acl';
vi.mock('../../../services/aclService', () => ({
@@ -31,9 +32,11 @@ vi.mock('../../../services/aclService', () => ({
createAndUpdatePlainAccessConfig: vi.fn(),
deleteAclRule: vi.fn(),
deleteAclUser: vi.fn(),
+ getAclUserCredentials: vi.fn(),
examineBrokerClusterAclConfig: vi.fn(),
listAclRules: vi.fn(),
listAclUsers: vi.fn(),
+ pageAclUsers: vi.fn(),
updateAclRule: vi.fn(),
updateAclUser: vi.fn(),
}));
@@ -57,56 +60,119 @@ beforeAll(() => {
});
});
-const renderWithProviders = (ui: React.ReactElement) =>
+const renderWithProviders = (ui: React.ReactElement, initialEntry = '/') =>
render(
<App>
<LangProvider>
- <MemoryRouter>{ui}</MemoryRouter>
+ <MemoryRouter initialEntries={[initialEntry]}>{ui}</MemoryRouter>
</LangProvider>
</App>,
);
+const deferred = <T,>() => {
+ let resolve!: (value: T) => void;
+ let reject!: (reason?: unknown) => void;
+ const promise = new Promise<T>((resolvePromise, rejectPromise) => {
+ resolve = resolvePromise;
+ reject = rejectPromise;
+ });
+ return { promise, resolve, reject };
+};
+
describe('ACL page', () => {
beforeEach(() => {
vi.clearAllMocks();
- vi.mocked(aclService.listAclRules).mockResolvedValue([
+ vi.mocked(instanceService.listInstances).mockResolvedValue([]);
+
+ vi.mocked(aclService.listAclRules).mockResolvedValue({
+ items: [
+ {
+ id: 1,
+ principal: 'remote-user',
+ resource: 'remote-topic',
+ resourceType: 'Topic',
+ resourcePattern: 'LITERAL',
+ actions: ['PUB'],
+ decision: 'ALLOW',
+ scope: 'cluster',
+ aclVersion: 2,
+ gmtCreate: '2026-07-23T00:00:00Z',
+ },
+ ],
+ total: 1,
+ page: 1,
+ size: 20,
+ });
+ vi.mocked(aclService.pageAclUsers).mockResolvedValue({
+ items: [
+ {
+ id: 11,
+ username: 'remote-admin',
+ accessKey: 'acce****3456',
+ secretKey: 'secr****7654',
+ admin: true,
+ clusters: ['cluster-a'],
+ gmtCreate: '2026-07-23T00:00:00Z',
+ },
+ ],
+ total: 1,
+ page: 1,
+ size: 20,
+ });
+ });
+
+ it('loads ACL rules and users through the service layer', async () => {
+ renderWithProviders(<AclPage />);
+
+ expect(await screen.findByText('remote-user')).toBeInTheDocument();
+ expect(screen.getByText('remote-topic')).toBeInTheDocument();
+ expect(aclService.listAclRules).toHaveBeenCalledTimes(1);
+ expect(aclService.pageAclUsers).toHaveBeenCalledTimes(1);
+ });
+
+ it('closes an ACL rule dialog when switching to another instance', async ()
=> {
+ const user = userEvent.setup();
+ vi.mocked(instanceService.listInstances).mockResolvedValue([
{
id: 1,
- principal: 'remote-user',
- resource: 'remote-topic',
- resourceType: 'Topic',
- resourcePattern: 'LITERAL',
- actions: ['PUB'],
- decision: 'ALLOW',
- scope: 'cluster',
- aclVersion: 2,
- gmtCreate: '2026-07-23T00:00:00Z',
+ name: 'Instance A',
+ type: 'DIRECT',
+ endpoint: '127.0.0.1:9876',
+ remark: '',
+ topicCount: 0,
+ consumerGroupCount: 0,
+ gmtCreate: '',
+ gmtModified: '',
},
- ]);
- vi.mocked(aclService.listAclUsers).mockResolvedValue([
{
- id: 11,
- username: 'remote-admin',
- accessKey: 'acce****3456',
- secretKey: 'secr****7654',
- admin: true,
- clusters: ['cluster-a'],
- gmtCreate: '2026-07-23T00:00:00Z',
+ id: 2,
+ name: 'Instance B',
+ type: 'DIRECT',
+ endpoint: '127.0.0.2:9876',
+ remark: '',
+ topicCount: 0,
+ consumerGroupCount: 0,
+ gmtCreate: '',
+ gmtModified: '',
},
]);
- });
+ renderWithProviders(<AclPage />, '/instance/1/acl');
- it('loads ACL rules and users through the service layer', async () => {
- renderWithProviders(<AclPage />);
+ expect(await screen.findByText('remote-user')).toBeInTheDocument();
+ await user.click(screen.getByRole('button', { name: /添加规则/ }));
+ const dialog = await screen.findByRole('dialog');
+
+ await user.click(screen.getAllByRole('combobox')[0]);
+ await user.click(
+ await screen.findByText('Instance B', { selector:
'.ant-select-item-option-content' }),
+ );
expect(await screen.findByText('remote-user')).toBeInTheDocument();
- expect(screen.getByText('remote-topic')).toBeInTheDocument();
- expect(aclService.listAclRules).toHaveBeenCalledTimes(1);
- expect(aclService.listAclUsers).toHaveBeenCalledTimes(1);
+ await waitFor(() => expect(dialog).not.toBeInTheDocument());
});
it('keeps rules available when loading users fails', async () => {
- vi.mocked(aclService.listAclUsers).mockRejectedValue(new Error('users
unavailable'));
+ vi.mocked(aclService.pageAclUsers).mockRejectedValue(new Error('users
unavailable'));
renderWithProviders(<AclPage />);
expect(await screen.findByText('remote-user')).toBeInTheDocument();
@@ -143,31 +209,41 @@ describe('ACL page', () => {
it('shows missing backend timestamps as unavailable', async () => {
const user = userEvent.setup();
- vi.mocked(aclService.listAclRules).mockResolvedValue([
- {
- id: 2,
- principal: 'no-time-rule',
- resource: 'topic-a',
- resourceType: 'Topic',
- resourcePattern: 'LITERAL',
- actions: ['PUB'],
- decision: 'ALLOW',
- scope: 'cluster',
- aclVersion: 2,
- gmtCreate: null,
- },
- ]);
- vi.mocked(aclService.listAclUsers).mockResolvedValue([
- {
- id: 12,
- username: 'no-time-user',
- accessKey: 'acce****3456',
- secretKey: 'secr****7654',
- admin: false,
- clusters: [],
- gmtCreate: null,
- },
- ]);
+ vi.mocked(aclService.listAclRules).mockResolvedValue({
+ items: [
+ {
+ id: 2,
+ principal: 'no-time-rule',
+ resource: 'topic-a',
+ resourceType: 'Topic',
+ resourcePattern: 'LITERAL',
+ actions: ['PUB'],
+ decision: 'ALLOW',
+ scope: 'cluster',
+ aclVersion: 2,
+ gmtCreate: null,
+ },
+ ],
+ total: 1,
+ page: 1,
+ size: 20,
+ });
+ vi.mocked(aclService.pageAclUsers).mockResolvedValue({
+ items: [
+ {
+ id: 12,
+ username: 'no-time-user',
+ accessKey: 'acce****3456',
+ secretKey: 'secr****7654',
+ admin: false,
+ clusters: [],
+ gmtCreate: null,
+ },
+ ],
+ total: 1,
+ page: 1,
+ size: 20,
+ });
renderWithProviders(<AclPage />);
expect(await screen.findByText('no-time-rule')).toBeInTheDocument();
@@ -366,6 +442,184 @@ describe('ACL page', () => {
expect(aclService.examineBrokerClusterAclConfig).toHaveBeenCalledTimes(1);
});
+ it('keeps cluster config ownership with the latest examine request', async
() => {
+ const firstExamine =
+ deferred<Awaited<ReturnType<typeof
aclService.examineBrokerClusterAclConfig>>>();
+ const secondExamine =
+ deferred<Awaited<ReturnType<typeof
aclService.examineBrokerClusterAclConfig>>>();
+ const thirdExamine =
+ deferred<Awaited<ReturnType<typeof
aclService.examineBrokerClusterAclConfig>>>();
+ const successSpy = vi.spyOn(message,
'success').mockImplementation(vi.fn());
+ const errorSpy = vi.spyOn(message, 'error').mockImplementation(vi.fn());
+ const user = userEvent.setup();
+ vi.mocked(aclService.examineBrokerClusterAclConfig)
+ .mockReturnValueOnce(firstExamine.promise)
+ .mockReturnValueOnce(secondExamine.promise)
+ .mockReturnValueOnce(thirdExamine.promise);
+ renderWithProviders(<AclPage />);
+
+ await user.click(await screen.findByText('集群 ACL 配置'));
+ const clusterInput = screen.getByPlaceholderText('请输入集群 ID');
+ const examineButton = screen.getByRole('button', { name: /检\s*查\s*配\s*置/
});
+
+ fireEvent.change(clusterInput, { target: { value: 'cluster-old-1' } });
+ await user.click(examineButton);
+ await waitFor(() =>
+
expect(aclService.examineBrokerClusterAclConfig).toHaveBeenNthCalledWith(1,
'cluster-old-1'),
+ );
+
+ await user.clear(clusterInput);
+ await user.type(clusterInput, 'cluster-old-2{enter}');
+ await waitFor(() =>
+
expect(aclService.examineBrokerClusterAclConfig).toHaveBeenNthCalledWith(2,
'cluster-old-2'),
+ );
+
+ await user.clear(clusterInput);
+ await user.type(clusterInput, 'cluster-latest{enter}');
+ await waitFor(() =>
+
expect(aclService.examineBrokerClusterAclConfig).toHaveBeenNthCalledWith(3,
'cluster-latest'),
+ );
+
+ await act(async () => {
+ firstExamine.resolve({
+ clusterId: 'cluster-old-1',
+ aclEnabled: true,
+ aclVersion: 'ACL stale-1',
+ globalWhiteRemoteAddresses: [],
+ accounts: [
+ {
+ accessKey: 'stale-account-1',
+ admin: true,
+ defaultTopicPerm: 'ALL',
+ defaultGroupPerm: 'ALL',
+ topicPerms: [],
+ groupPerms: [],
+ },
+ ],
+ accountCount: 1,
+ });
+ });
+
+ expect(screen.queryByText('stale-account-1')).not.toBeInTheDocument();
+ expect(successSpy).not.toHaveBeenCalled();
+ expect(errorSpy).not.toHaveBeenCalled();
+ expect(examineButton).toHaveClass('ant-btn-loading');
+
+ await act(async () => {
+ secondExamine.reject(new Error('stale failure'));
+ });
+
+ expect(screen.queryByText('stale-account-1')).not.toBeInTheDocument();
+ expect(successSpy).not.toHaveBeenCalled();
+ expect(errorSpy).not.toHaveBeenCalled();
+ expect(examineButton).toHaveClass('ant-btn-loading');
+
+ await act(async () => {
+ thirdExamine.resolve({
+ clusterId: 'cluster-latest',
+ aclEnabled: true,
+ aclVersion: 'ACL latest',
+ globalWhiteRemoteAddresses: ['10.0.0.0/8'],
+ accounts: [
+ {
+ accessKey: 'latest-account',
+ admin: false,
+ defaultTopicPerm: 'PUB',
+ defaultGroupPerm: 'SUB',
+ topicPerms: ['topic=PUB'],
+ groupPerms: ['group=SUB'],
+ },
+ ],
+ accountCount: 1,
+ });
+ });
+
+ expect(await screen.findByText('latest-account')).toBeInTheDocument();
+ expect(screen.getByText('ACL latest')).toBeInTheDocument();
+ await waitFor(() =>
expect(examineButton).not.toHaveClass('ant-btn-loading'));
+ expect(successSpy).toHaveBeenCalledTimes(1);
+ expect(errorSpy).not.toHaveBeenCalled();
+ });
+
+ it('keeps credential reveal ownership with the latest user generation',
async () => {
+ type CredentialResponse = Awaited<ReturnType<typeof
aclService.getAclUserCredentials>>;
+ const firstReveal = deferred<CredentialResponse>();
+ const secondReveal = deferred<CredentialResponse>();
+ const thirdReveal = deferred<CredentialResponse>();
+ const errorSpy = vi.spyOn(message, 'error').mockImplementation(vi.fn());
+ const user = userEvent.setup();
+ vi.mocked(aclService.getAclUserCredentials)
+ .mockReturnValueOnce(firstReveal.promise)
+ .mockReturnValueOnce(secondReveal.promise)
+ .mockReturnValueOnce(thirdReveal.promise);
+ renderWithProviders(<AclPage />);
+
+ await user.click(await screen.findByText('用户管理'));
+ const row = await screen.findByRole('row', { name: /remote-admin/ });
+ const secretCell = screen.getByText('••••••••••••').closest('td');
+ expect(secretCell).not.toBeNull();
+ const revealButton = within(secretCell as HTMLElement).getByRole('button');
+
+ await user.click(revealButton);
+ await waitFor(() =>
expect(aclService.getAclUserCredentials).toHaveBeenCalledTimes(1));
+ expect(within(row).getByText('加载中…')).toBeInTheDocument();
+
+ await user.click(revealButton);
+ expect(within(row).queryByText('加载中…')).not.toBeInTheDocument();
+
+ await user.click(revealButton);
+ await waitFor(() =>
expect(aclService.getAclUserCredentials).toHaveBeenCalledTimes(2));
+ expect(within(row).getByText('加载中…')).toBeInTheDocument();
+
+ await user.click(revealButton);
+ expect(within(row).queryByText('加载中…')).not.toBeInTheDocument();
+
+ await user.click(revealButton);
+ await waitFor(() =>
expect(aclService.getAclUserCredentials).toHaveBeenCalledTimes(3));
+ expect(within(row).getByText('加载中…')).toBeInTheDocument();
+
+ await act(async () => {
+ firstReveal.resolve({
+ id: 1,
+ username: 'remote-admin',
+ accessKey: 'stale-access-key',
+ secretKey: 'stale-secret-key',
+ admin: true,
+ clusters: ['cluster-a'],
+ gmtCreate: '2026-07-23T00:00:00Z',
+ });
+ });
+
+ expect(within(row).getByText('加载中…')).toBeInTheDocument();
+ expect(screen.queryByText('stale-secret-key')).not.toBeInTheDocument();
+ expect(screen.queryByText('stale-access-key')).not.toBeInTheDocument();
+ expect(errorSpy).not.toHaveBeenCalled();
+
+ await act(async () => {
+ secondReveal.reject(new Error('stale reveal failure'));
+ });
+
+ expect(within(row).getByText('加载中…')).toBeInTheDocument();
+ expect(errorSpy).not.toHaveBeenCalled();
+
+ await act(async () => {
+ thirdReveal.resolve({
+ id: 1,
+ username: 'remote-admin',
+ accessKey: 'latest-access-key',
+ secretKey: 'latest-secret-key',
+ admin: true,
+ clusters: ['cluster-a'],
+ gmtCreate: '2026-07-23T00:00:00Z',
+ });
+ });
+
+ expect(await
within(row).findByText('latest-secret-key')).toBeInTheDocument();
+ expect(within(row).getByText('latest-access-key')).toBeInTheDocument();
+ expect(screen.queryByText('stale-secret-key')).not.toBeInTheDocument();
+ expect(errorSpy).not.toHaveBeenCalled();
+ });
+
it('creates a plain access account', async () => {
const user = userEvent.setup();
vi.mocked(aclService.createAndUpdatePlainAccessConfig).mockResolvedValue({
diff --git a/web/src/pages/instance/acl.tsx b/web/src/pages/instance/acl.tsx
index b6f55ee98..b3d1984e0 100644
--- a/web/src/pages/instance/acl.tsx
+++ b/web/src/pages/instance/acl.tsx
@@ -59,7 +59,7 @@ import {
getAclUserCredentials,
examineBrokerClusterAclConfig,
listAclRules,
- listAclUsers,
+ pageAclUsers,
updateAclRule,
updateAclUser,
} from '../../services/aclService';
@@ -103,21 +103,41 @@ const isFormValidationError = (error: unknown) =>
/* ═══════════════════════════════════════════
ACL Management Page
═══════════════════════════════════════════ */
-const AclPage = () => {
+type AclPageContentProps = Pick<
+ ReturnType<typeof useInstanceFilter>,
+ 'selectedInstanceId' | 'selectInstance' | 'instanceOptions' | 'instances'
+>;
+
+const AclPageContent = ({
+ selectedInstanceId,
+ selectInstance,
+ instanceOptions,
+ instances,
+}: AclPageContentProps) => {
const { t } = useLang();
- const { selectedInstanceId, selectInstance, instanceOptions, instances } =
useInstanceFilter();
+ const hasSelectedInstance = Boolean(selectedInstanceId);
/* ─── State ─── */
const [rules, setRules] = useState<AclRule[]>([]);
const [users, setUsers] = useState<AclUser[]>([]);
- const [rulesLoading, setRulesLoading] = useState(true);
- const [usersLoading, setUsersLoading] = useState(true);
+ const [rulesLoading, setRulesLoading] = useState(hasSelectedInstance);
+ const [usersLoading, setUsersLoading] = useState(hasSelectedInstance);
+ const [userPage, setUserPage] = useState(1);
+ const [userPageSize, setUserPageSize] = useState(20);
+ const [userTotal, setUserTotal] = useState(0);
+ const [userKeyword, setUserKeyword] = useState('');
const [ruleSubmitting, setRuleSubmitting] = useState(false);
const [userSubmitting, setUserSubmitting] = useState(false);
const [activeTab, setActiveTab] = useState('rules');
+ const [ruleRefreshKey, setRuleRefreshKey] = useState(0);
+ const [ruleTotal, setRuleTotal] = useState(0);
+ const [rulePage, setRulePage] = useState(1);
+ const [rulePageSize, setRulePageSize] = useState(20);
// Rule filters
- const [ruleSearch, setRuleSearch] = useState('');
+ const [rulePrincipalFilter, setRulePrincipalFilter] = useState('');
+ const [ruleResourceFilter, setRuleResourceFilter] = useState('');
+ const [ruleScopeFilter, setRuleScopeFilter] = useState<string>('all');
const [ruleVersionFilter, setRuleVersionFilter] = useState<string>('all');
const [ruleDecisionFilter, setRuleDecisionFilter] = useState<string>('all');
@@ -135,6 +155,7 @@ const AclPage = () => {
const [revealedKeys, setRevealedKeys] = useState<Set<number>>(new Set());
const [adminUpdatingIds, setAdminUpdatingIds] = useState<Set<number>>(() =>
new Set());
const adminUpdateInFlightRef = useRef<Set<number>>(new Set());
+ const revealRequestGenerationRef = useRef<Record<number, number>>({});
const [credentialsByUser, setCredentialsByUser] = useState<
Record<number, { accessKey: string; secretKey: string }>
>({});
@@ -143,6 +164,7 @@ const AclPage = () => {
const [clusterConfig, setClusterConfig] = useState<AclClusterConfig |
null>(null);
const [configLoading, setConfigLoading] = useState(false);
const [clusterIdInput, setClusterIdInput] = useState('DefaultCluster');
+ const examineRequestGenerationRef = useRef(0);
// Plain access config modal
const [plainModalOpen, setPlainModalOpen] = useState(false);
@@ -153,9 +175,20 @@ const AclPage = () => {
useEffect(() => {
let mounted = true;
- void listAclRules({ instanceId: selectedInstanceId })
+ void listAclRules({
+ instanceId: selectedInstanceId,
+ principal: rulePrincipalFilter || undefined,
+ resource: ruleResourceFilter || undefined,
+ scope: ruleScopeFilter === 'all' ? undefined : ruleScopeFilter,
+ aclVersion: ruleVersionFilter === 'all' ? undefined : ruleVersionFilter,
+ decision: ruleDecisionFilter === 'all' ? undefined : ruleDecisionFilter,
+ page: rulePage,
+ pageSize: rulePageSize,
+ })
.then((nextRules) => {
- if (mounted) setRules(nextRules.map(normalizeRule));
+ if (!mounted) return;
+ setRules(nextRules.items.map(normalizeRule));
+ setRuleTotal(nextRules.total);
})
.catch(() => {
if (mounted) message.error(t('common.fetchDataFailed'));
@@ -164,9 +197,17 @@ const AclPage = () => {
if (mounted) setRulesLoading(false);
});
- void listAclUsers({ instanceId: selectedInstanceId })
- .then((nextUsers) => {
- if (mounted) setUsers(nextUsers.map(normalizeUser));
+ void pageAclUsers({
+ instanceId: selectedInstanceId,
+ page: userPage,
+ pageSize: userPageSize,
+ keyword: userKeyword || undefined,
+ })
+ .then((result) => {
+ if (mounted) {
+ setUsers(result.items.map(normalizeUser));
+ setUserTotal(result.total);
+ }
})
.catch(() => {
if (mounted) message.error(t('common.fetchDataFailed'));
@@ -178,19 +219,21 @@ const AclPage = () => {
return () => {
mounted = false;
};
- }, [t, selectedInstanceId]);
-
- /* ─── Filtered rules ─── */
- const filteredRules = rules.filter((r) => {
- const aclVersion = String(r.aclVersion);
- const matchSearch =
- !ruleSearch ||
- r.principal.toLowerCase().includes(ruleSearch.toLowerCase()) ||
- r.resource.toLowerCase().includes(ruleSearch.toLowerCase());
- const matchVersion = ruleVersionFilter === 'all' || aclVersion ===
ruleVersionFilter;
- const matchDecision = ruleDecisionFilter === 'all' || r.decision ===
ruleDecisionFilter;
- return matchSearch && matchVersion && matchDecision;
- });
+ }, [
+ t,
+ selectedInstanceId,
+ rulePrincipalFilter,
+ ruleResourceFilter,
+ ruleScopeFilter,
+ ruleVersionFilter,
+ ruleDecisionFilter,
+ rulePage,
+ rulePageSize,
+ ruleRefreshKey,
+ userPage,
+ userPageSize,
+ userKeyword,
+ ]);
/* ─── Rule helpers ─── */
const isAdmin = (principal: string) =>
@@ -239,23 +282,22 @@ const AclPage = () => {
const values = (await ruleForm.validateFields()) as AclRuleFormValues;
setRuleSubmitting(true);
if (editingRule) {
- const updated = await updateAclRule({
+ await updateAclRule({
...editingRule,
...values,
instanceId: selectedInstanceId,
});
- const normalized = normalizeRule(updated);
- setRules((prev) => prev.map((r) => (r.id === editingRule.id ?
normalized : r)));
message.success(t('acl.ruleUpdated'));
} else {
- const created = await createAclRule({
+ await createAclRule({
...values,
aclVersion: '2.0',
instanceId: selectedInstanceId,
});
- setRules((prev) => [normalizeRule(created), ...prev]);
+ setRulePage(1);
message.success(t('acl.ruleAdded'));
}
+ setRuleRefreshKey((prev) => prev + 1);
setRuleModalOpen(false);
} catch (error) {
if (isFormValidationError(error)) return;
@@ -268,7 +310,11 @@ const AclPage = () => {
const handleDeleteRule = async (id: number) => {
try {
await deleteAclRule(id, selectedInstanceId);
- setRules((prev) => prev.filter((r) => r.id !== id));
+ if (rules.length === 1 && rulePage > 1) {
+ setRulePage((prev) => prev - 1);
+ } else {
+ setRuleRefreshKey((prev) => prev + 1);
+ }
message.success(t('acl.ruleDeleted'));
} catch {
message.error(t('common.operationFailed'));
@@ -278,6 +324,8 @@ const AclPage = () => {
/* ─── User helpers ─── */
const toggleRevealKey = async (userId: number) => {
const revealing = !revealedKeys.has(userId);
+ const revealGeneration = (revealRequestGenerationRef.current[userId] ?? 0)
+ 1;
+ revealRequestGenerationRef.current[userId] = revealGeneration;
setRevealedKeys((prev) => {
const next = new Set(prev);
if (next.has(userId)) {
@@ -290,6 +338,7 @@ const AclPage = () => {
if (!revealing || credentialsByUser[userId]) return;
try {
const credentials = await getAclUserCredentials(userId,
selectedInstanceId);
+ if (revealRequestGenerationRef.current[userId] !== revealGeneration)
return;
setCredentialsByUser((prev) => ({
...prev,
[userId]: {
@@ -298,6 +347,7 @@ const AclPage = () => {
},
}));
} catch {
+ if (revealRequestGenerationRef.current[userId] !== revealGeneration)
return;
setRevealedKeys((prev) => {
const next = new Set(prev);
next.delete(userId);
@@ -402,15 +452,21 @@ const AclPage = () => {
message.warning(t('acl.inputRequired', { field: t('acl.examineCluster')
}));
return;
}
+ const requestGeneration = examineRequestGenerationRef.current + 1;
+ examineRequestGenerationRef.current = requestGeneration;
try {
setConfigLoading(true);
const config = await examineBrokerClusterAclConfig(clusterId);
+ if (examineRequestGenerationRef.current !== requestGeneration) return;
setClusterConfig(config);
message.success(t('acl.configExamined'));
} catch {
+ if (examineRequestGenerationRef.current !== requestGeneration) return;
message.error(t('common.operationFailed'));
} finally {
- setConfigLoading(false);
+ if (examineRequestGenerationRef.current === requestGeneration) {
+ setConfigLoading(false);
+ }
}
};
@@ -913,18 +969,47 @@ const AclPage = () => {
options={instanceOptions}
style={{ width: 220 }}
/>
- <Input.Search
+ <Input
placeholder={t('acl.searchPrincipal')}
prefix={<MagnifyingGlass size={14} color="#9CA3AF" />}
- value={ruleSearch}
- onChange={(e) => setRuleSearch(e.target.value)}
- onSearch={setRuleSearch}
+ value={rulePrincipalFilter}
+ onChange={(e) => {
+ setRulePage(1);
+ setRulePrincipalFilter(e.target.value);
+ }}
allowClear
- style={{ width: 260 }}
+ style={{ width: 220 }}
+ />
+ <Input
+ placeholder={t('acl.searchResource')}
+ prefix={<MagnifyingGlass size={14} color="#9CA3AF" />}
+ value={ruleResourceFilter}
+ onChange={(e) => {
+ setRulePage(1);
+ setRuleResourceFilter(e.target.value);
+ }}
+ allowClear
+ style={{ width: 220 }}
+ />
+ <Select
+ value={ruleScopeFilter}
+ onChange={(value) => {
+ setRulePage(1);
+ setRuleScopeFilter(value);
+ }}
+ style={{ width: 180 }}
+ options={[
+ { value: 'all', label: t('acl.allScopes') },
+ { value: 'cluster', label: t('acl.clusterScope') },
+ { value: 'namespace', label: t('acl.namespaceScope') },
+ ]}
/>
<Select
value={ruleVersionFilter}
- onChange={setRuleVersionFilter}
+ onChange={(value) => {
+ setRulePage(1);
+ setRuleVersionFilter(value);
+ }}
style={{ width: 140 }}
options={[
{ value: 'all', label: t('acl.allVersions') },
@@ -934,7 +1019,10 @@ const AclPage = () => {
/>
<Select
value={ruleDecisionFilter}
- onChange={setRuleDecisionFilter}
+ onChange={(value) => {
+ setRulePage(1);
+ setRuleDecisionFilter(value);
+ }}
style={{ width: 140 }}
options={[
{ value: 'all', label: t('acl.allDecisions') },
@@ -947,13 +1035,23 @@ const AclPage = () => {
{/* Rules table */}
<Table
columns={ruleColumns}
- dataSource={filteredRules}
+ dataSource={rules}
rowKey="id"
loading={rulesLoading}
pagination={{
- pageSize: 20,
+ current: rulePage,
+ pageSize: rulePageSize,
+ total: ruleTotal,
showSizeChanger: true,
showTotal: (total) => t('acl.totalRules', { n: total }),
+ onChange: (page, pageSize) => {
+ if (pageSize !== rulePageSize) {
+ setRulePage(1);
+ setRulePageSize(pageSize);
+ return;
+ }
+ setRulePage(page);
+ },
}}
size="small"
/>
@@ -979,6 +1077,15 @@ const AclPage = () => {
>
{t('acl.addUser')}
</Button>
+ <Input.Search
+ value={userKeyword}
+ onChange={(event) => {
+ setUserPage(1);
+ setUserKeyword(event.target.value);
+ }}
+ allowClear
+ style={{ width: 240 }}
+ />
</Space>
</div>
@@ -988,9 +1095,15 @@ const AclPage = () => {
rowKey="id"
loading={usersLoading}
pagination={{
- pageSize: 20,
+ current: userPage,
+ pageSize: userPageSize,
+ total: userTotal,
showSizeChanger: true,
showTotal: (total) => t('acl.totalUsers', { n: total }),
+ onChange: (page, pageSize) => {
+ setUserPage(page);
+ setUserPageSize(pageSize);
+ },
}}
size="small"
/>
@@ -1334,4 +1447,14 @@ const AclPage = () => {
);
};
+const AclPage = () => {
+ const instanceFilter = useInstanceFilter();
+ return (
+ <AclPageContent
+ key={instanceFilter.selectedInstanceId || 'no-selected-instance'}
+ {...instanceFilter}
+ />
+ );
+};
+
export default AclPage;
diff --git a/web/src/services/aclService.test.ts
b/web/src/services/aclService.test.ts
index df01d70f0..98d180c5f 100644
--- a/web/src/services/aclService.test.ts
+++ b/web/src/services/aclService.test.ts
@@ -35,15 +35,15 @@ vi.mock('../config', () => ({
describe('ACL service mock data', () => {
it('returns copied ACL rule rows', async () => {
const first = await listAclRules({ principal: 'user-admin' });
- expect(first[0].principal).toBe('user-admin');
+ expect(first.items[0].principal).toBe('user-admin');
- first[0].principal = 'mutated-principal';
- first[0].actions.push('MUTATED');
+ first.items[0].principal = 'mutated-principal';
+ first.items[0].actions.push('MUTATED');
const second = await listAclRules({ principal: 'user-admin' });
- expect(second[0].principal).toBe('user-admin');
- expect(second[0].actions).toEqual(['ALL']);
- expect(second[0]).not.toBe(first[0]);
+ expect(second.items[0].principal).toBe('user-admin');
+ expect(second.items[0].actions).toEqual(['ALL']);
+ expect(second.items[0]).not.toBe(first.items[0]);
});
it('copies ACL rule arrays on create and update', async () => {
@@ -57,7 +57,7 @@ describe('ACL service mock data', () => {
created.actions.push('MUTATED');
const afterCreate = await listAclRules({ principal:
'user-created-copy-test' });
- expect(afterCreate[0].actions).toEqual(['PUB']);
+ expect(afterCreate.items[0].actions).toEqual(['PUB']);
const updateActions = ['SUB'];
const updated = await updateAclRule({ id: created.id, actions:
updateActions });
@@ -65,7 +65,7 @@ describe('ACL service mock data', () => {
updated.actions.push('MUTATED');
const afterUpdate = await listAclRules({ principal:
'user-created-copy-test' });
- expect(afterUpdate[0].actions).toEqual(['SUB']);
+ expect(afterUpdate.items[0].actions).toEqual(['SUB']);
});
it('returns copied ACL user rows', async () => {
diff --git a/web/src/services/aclService.ts b/web/src/services/aclService.ts
index 3adcb3e23..580ea995e 100644
--- a/web/src/services/aclService.ts
+++ b/web/src/services/aclService.ts
@@ -2,8 +2,10 @@ import { isMockMode } from './dataMode';
import * as aclApi from '../api/acl';
import type {
AclRule,
+ PageResult,
AclRuleQuery,
AclUser,
+ AclUserPage,
AclClusterConfig,
PlainAccessConfig,
} from '../api/acl';
@@ -37,14 +39,36 @@ function copyAclUser(user: AclUser): AclUser {
};
}
-export async function listAclRules(params?: AclRuleQuery): Promise<AclRule[]> {
+export async function listAclRules(params?: AclRuleQuery):
Promise<PageResult<AclRule>> {
if (isMockMode()) {
let result = [...aclRulesState];
if (params?.principal) {
const principal = params.principal.toLowerCase();
result = result.filter((rule) =>
rule.principal.toLowerCase().includes(principal));
}
- return result.map(copyAclRule);
+ if (params?.resource) {
+ const resource = params.resource.toLowerCase();
+ result = result.filter((rule) =>
rule.resource.toLowerCase().includes(resource));
+ }
+ if (params?.scope) {
+ result = result.filter((rule) => rule.scope === params.scope);
+ }
+ if (params?.decision) {
+ result = result.filter((rule) => rule.decision === params.decision);
+ }
+ if (params?.aclVersion) {
+ result = result.filter((rule) => String(rule.aclVersion) ===
params.aclVersion);
+ }
+ const page = Math.max(params?.page ?? 1, 1);
+ const pageSize = Math.max(params?.pageSize ?? 20, 1);
+ const fromIndex = Math.min((page - 1) * pageSize, result.length);
+ const toIndex = Math.min(fromIndex + pageSize, result.length);
+ return {
+ items: result.slice(fromIndex, toIndex).map(copyAclRule),
+ total: result.length,
+ page,
+ size: pageSize,
+ };
}
return aclApi.listAclRules(params);
}
@@ -64,6 +88,25 @@ export async function listAclUsers(params?: {
return aclApi.listAclUsers(params);
}
+export async function pageAclUsers(params: {
+ keyword?: string;
+ instanceId?: string;
+ page: number;
+ pageSize: number;
+}): Promise<AclUserPage> {
+ if (isMockMode()) {
+ const users = await listAclUsers(params);
+ const from = (params.page - 1) * params.pageSize;
+ return {
+ items: users.slice(from, from + params.pageSize),
+ total: users.length,
+ page: params.page,
+ size: params.pageSize,
+ };
+ }
+ return aclApi.pageAclUsers(params);
+}
+
export async function getAclUserCredentials(id: number, instanceId?: string):
Promise<AclUser> {
if (isMockMode()) {
const user = aclUsersState.find((u) => u.id === id);