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 ffaff10d feat: add ACL management page (#792)
ffaff10d is described below
commit ffaff10dfc8563877cee10b84b16d22cfa7108da
Author: zhaohai <[email protected]>
AuthorDate: Tue Aug 11 14:05:13 2026 +0800
feat: add ACL management page (#792)
---
deploy/mysql/upgrade-demo-acl.sql | 17 +
docs/api-spec.md | 74 +++-
.../studio/instance/acl/AclClusterConfigVO.java | 59 ++++
.../studio/instance/acl/AclController.java | 18 +
.../studio/instance/acl/AclRepository.java | 15 +
.../rocketmq/studio/instance/acl/AclService.java | 29 ++
.../rocketmq/studio/instance/acl/AclUserVO.java | 2 +
.../instance/acl/MybatisPlusAclRepository.java | 195 +++++++++++
.../studio/instance/acl/PlainAccessConfigVO.java | 70 ++++
.../instance/acl/UpsertPlainAccessConfigDTO.java | 61 ++++
.../studio/persistence/entity/RmqAclUser.java | 2 +
server/src/main/resources/db/schema.sql | 1 +
.../studio/instance/acl/AclControllerTest.java | 81 +++++
.../studio/instance/acl/AclServiceTest.java | 51 +++
.../instance/acl/MybatisPlusAclRepositoryTest.java | 240 +++++++++++++
.../studio/settings/SettingsServiceTest.java | 1 +
web/src/api/acl.test.ts | 42 +++
web/src/api/acl.ts | 35 ++
web/src/api/llm.test.ts | 12 +-
web/src/api/producer.test.ts | 11 +-
web/src/i18n/translations.ts | 36 +-
web/src/pages/cluster/clients.tsx | 9 +-
.../pages/home/__tests__/DashboardPage.test.tsx | 32 +-
web/src/pages/instance/__tests__/AclPage.test.tsx | 70 ++++
web/src/pages/instance/__tests__/DLQPage.test.tsx | 9 +-
web/src/pages/instance/acl.tsx | 385 ++++++++++++++++++++-
web/src/pages/instance/dlq.tsx | 23 +-
web/src/pages/studio/Ops.tsx | 13 +-
.../pages/studio/__tests__/BrokerCluster.test.tsx | 11 +-
web/src/pages/studio/__tests__/Proxy.test.tsx | 11 +-
web/src/services/aclService.test.ts | 46 +++
web/src/services/aclService.ts | 81 ++++-
32 files changed, 1697 insertions(+), 45 deletions(-)
diff --git a/deploy/mysql/upgrade-demo-acl.sql
b/deploy/mysql/upgrade-demo-acl.sql
index acdbbd25..ed9ae197 100644
--- a/deploy/mysql/upgrade-demo-acl.sql
+++ b/deploy/mysql/upgrade-demo-acl.sql
@@ -34,11 +34,28 @@ CREATE TABLE IF NOT EXISTS rmq_acl_user (
secret_key VARCHAR(512) NOT NULL COMMENT 'base64 编码的密码',
admin TINYINT(1) DEFAULT 0,
clusters VARCHAR(1024) COMMENT '逗号分隔的集群/实例 id',
+ white_remote_address VARCHAR(255) COMMENT 'plain access 账号 IP 白名单,空表示不限制',
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
UNIQUE KEY uk_username (username)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
+-- 2.1 已建表的数据卷补列(幂等):rmq_acl_user.white_remote_address
+SET @schema_name := DATABASE();
+SET @white_remote_address_column_exists := (
+ SELECT COUNT(*)
+ FROM information_schema.columns
+ WHERE table_schema = @schema_name
+ AND table_name = 'rmq_acl_user'
+ AND column_name = 'white_remote_address'
+);
+SET @white_remote_address_sql := IF(@white_remote_address_column_exists = 0,
+ "ALTER TABLE rmq_acl_user ADD COLUMN white_remote_address VARCHAR(255)
COMMENT 'plain access 账号 IP 白名单,空表示不限制' AFTER clusters",
+ 'SELECT 1');
+PREPARE white_remote_address_statement FROM @white_remote_address_sql;
+EXECUTE white_remote_address_statement;
+DEALLOCATE PREPARE white_remote_address_statement;
+
-- 3. 规则种子(与 schema.sql 一致)
INSERT IGNORE INTO rmq_acl_rule
(id, principal, resource, resource_type, resource_pattern, actions,
decision, scope, acl_version)
diff --git a/docs/api-spec.md b/docs/api-spec.md
index 46062bec..5fc9efa3 100644
--- a/docs/api-spec.md
+++ b/docs/api-spec.md
@@ -114,10 +114,13 @@
| 71 | GET | `/api/ai/tools` | 可用工具列表 |
| 72 | POST | `/api/ai/tools/:name/execute` | 执行只读 AI 工具 |
| 73 | POST | `/api/metrics/query` | 查询监控指标数据 |
-| 74 | GET | `/api/metrics/grafana/dashboards` | Grafana 看板列表 |
-| 75 | GET | `/api/metrics/grafana/dashboards/:uid` | Grafana 看板 JSON 模型 |
-| 76 | GET | `/api/metrics/grafana/dashboards/:uid/export` | 导出单个 Grafana 看板
JSON |
-| 77 | GET | `/api/metrics/grafana/dashboards/export` | 打包导出全部 Grafana 看板 |
+| 74 | GET | `/api/acl/cluster-config` | 集群 ACL 配置概要(存储级) |
+| 75 | POST | `/api/acl/plain-access-config` | 创建/更新 Plain Access 账号 |
+| 76 | GET | `/api/acl/users/:id/credentials` | 查看单个用户明文凭证 |
+| 77 | GET | `/api/metrics/grafana/dashboards` | Grafana 看板列表 |
+| 78 | GET | `/api/metrics/grafana/dashboards/:uid` | Grafana 看板 JSON 模型 |
+| 79 | GET | `/api/metrics/grafana/dashboards/:uid/export` | 导出单个 Grafana 看板
JSON |
+| 80 | GET | `/api/metrics/grafana/dashboards/export` | 打包导出全部 Grafana 看板 |
## 通用响应格式
@@ -1150,6 +1153,69 @@ POST /api/acl/users/delete
**Response `data`:** `null`
+### 7.8 获取集群 ACL 配置概要
+
+```
+GET /api/acl/cluster-config?clusterId={clusterId}
+```
+
+**该接口返回 Dashboard 存储(`rmq_acl_user` / `rmq_acl_rule`)的存储级概要,不是对 Broker
运行时状态的实时查询。** `clusterId` 用于圈定概要范围:账号的集群绑定为空视为全局生效,否则仅当其集群列表包含 `clusterId`
时纳入。因此:
+
+- `aclVersion` 表示存储所管理的账号模型(当前固定为 `ACL 2.0`),不反映 Broker 实际开启的 ACL 版本;
+- `aclEnabled` 表示该集群是否存在已配置的账号,不等于 Broker 端鉴权开关;
+- `globalWhiteRemoteAddresses` 当前存储未建模,固定返回空数组;
+- `accounts` 中每个账号的 `secretKey` 均为脱敏值,明文仅通过 7.10 的显式凭证接口获取。
+
+**Query Parameters:**
+
+| 参数 | 类型 | 必填 | 说明 |
+|------|------|------|------|
+| `clusterId` | `string` | 是 | 集群 ID,用于圈定账号范围 |
+
+**Response `data`:** `AclClusterConfig`
+
+| 字段 | 类型 | 说明 |
+|------|------|------|
+| `clusterId` | `string` | 请求的集群 ID(回显) |
+| `aclEnabled` | `boolean` | 存储中该集群是否存在已配置账号 |
+| `aclVersion` | `string` | 存储管理的账号模型版本 |
+| `globalWhiteRemoteAddresses` | `string[]` | 全局 IP 白名单(当前固定为空) |
+| `accounts` | `PlainAccessConfig[]` | 账号列表,`secretKey` 为脱敏值 |
+| `accountCount` | `number` | 账号数量 |
+
+### 7.9 创建/更新 Plain Access 账号
+
+```
+POST /api/acl/plain-access-config
+```
+
+以 `accessKey` 为账号标识执行 upsert:账号身份写入 `rmq_acl_user`,资源权限以先删后插方式整体替换写入
`rmq_acl_rule`(每条资源权限生成唯一规则 ID `plain-{accessKey}-t-{index}` /
`plain-{accessKey}-g-{index}`),两步在同一事务内完成,中途失败会整体回滚。
+
+**Request Body:**
+
+| 字段 | 类型 | 必填 | 说明 |
+|------|------|------|------|
+| `accessKey` | `string` | 是 | 账号 AccessKey(更新时不可变更) |
+| `secretKey` | `string` | 条件 | 新建账号必填;更新时留空表示保留原密钥 |
+| `whiteRemoteAddress` | `string` | 否 | IP 白名单,持久化存储,空表示不限制 |
+| `admin` | `boolean` | 否 | 是否管理员 |
+| `defaultTopicPerm` | `string` | 否 | 默认 Topic 权限 |
+| `defaultGroupPerm` | `string` | 否 | 默认 Group 权限 |
+| `topicPerms` | `string[]` | 否 | 逐 Topic 权限,格式 `resource=action` |
+| `groupPerms` | `string[]` | 否 | 逐 Group 权限,格式 `resource=action` |
+
+**Response `data`:** `PlainAccessConfig`。`secretKey` 仅在本次显式提交时回显,保留原密钥时返回
`null`。
+
+### 7.10 查看单个用户明文凭证
+
+```
+GET /api/acl/users/:id/credentials
+```
+
+返回单个用户的明文 AccessKey / SecretKey(存储为 base64,读取时解码)。明文凭证只通过该显式端点提供;用户列表与集群 ACL
概要均只返回脱敏值。
+
+**Response `data`:** `AclUser`(`accessKey` / `secretKey` 为明文)
+
---
## 8. 消息查询 Message
diff --git
a/server/src/main/java/org/apache/rocketmq/studio/instance/acl/AclClusterConfigVO.java
b/server/src/main/java/org/apache/rocketmq/studio/instance/acl/AclClusterConfigVO.java
new file mode 100644
index 00000000..ad9f3fb8
--- /dev/null
+++
b/server/src/main/java/org/apache/rocketmq/studio/instance/acl/AclClusterConfigVO.java
@@ -0,0 +1,59 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.rocketmq.studio.instance.acl;
+
+import lombok.AllArgsConstructor;
+import lombok.Builder;
+import lombok.Data;
+import lombok.NoArgsConstructor;
+
+import java.util.List;
+
+/**
+ * Store-level summary of a cluster's ACL configuration.
+ *
+ * <p>Returned by {@code examineBrokerClusterAclConfig}. This is a view over
the dashboard's
+ * MySQL store, not a live broker query: {@link #aclVersion} is the account
model the store
+ * manages, {@link #aclEnabled} reports whether any accounts are provisioned
for the cluster,
+ * and {@link #accounts} carries the stored plain access accounts scoped to
the cluster with
+ * their secrets masked. Plaintext secrets are only served by the explicit
per-user
+ * credentials endpoint.
+ */
+@Data
+@Builder
+@NoArgsConstructor
+@AllArgsConstructor
+public class AclClusterConfigVO {
+
+ /** Cluster the configuration belongs to. */
+ private String clusterId;
+
+ /** Whether ACL is enabled on the cluster. */
+ private boolean aclEnabled;
+
+ /** Active ACL version, e.g. "ACL 2.0". */
+ private String aclVersion;
+
+ /** Cluster-wide IP whitelist entries. */
+ private List<String> globalWhiteRemoteAddresses;
+
+ /** Plain access accounts configured for the cluster. */
+ private List<PlainAccessConfigVO> accounts;
+
+ /** Number of accounts; kept in sync with {@link #accounts}. */
+ private int accountCount;
+}
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 e02e8f10..a636415b 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
@@ -87,6 +87,24 @@ public class AclController {
return Result.ok();
}
+ /**
+ * Returns a store-level summary of the ACL accounts provisioned for the
given cluster. The
+ * {@code clusterId} scopes which stored accounts are included; this
endpoint does not query
+ * live broker state, so {@code aclVersion} / {@code aclEnabled} describe
the dashboard store,
+ * not broker runtime configuration.
+ */
+ @GetMapping("/cluster-config")
+ public Result<AclClusterConfigVO> examineBrokerClusterAclConfig(
+ @RequestParam(required = false) String clusterId) {
+ return Result.ok(aclService.examineBrokerClusterAclConfig(clusterId));
+ }
+
+ @PostMapping("/plain-access-config")
+ public Result<PlainAccessConfigVO> createAndUpdatePlainAccessConfig(
+ @Valid @RequestBody UpsertPlainAccessConfigDTO request) {
+ return
Result.ok(aclService.createAndUpdatePlainAccessConfig(request.toPlainAccessConfigVO()));
+ }
+
private <T> T requireRequest(T request, String message) {
if (request == null) {
throw new BusinessException(400, message);
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 d1ed8aab..87724a8e 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
@@ -40,4 +40,19 @@ public interface AclRepository {
AclUserVO saveUser(AclUserVO user);
boolean deleteUser(String id);
+
+ /**
+ * Examines the effective ACL configuration of a broker cluster: the
enabled
+ * flag, ACL version, the global IP whitelist and the list of plain access
+ * accounts provisioned for the cluster. Reads from the MySQL-backed
+ * {@code rmq_acl_user} / {@code rmq_acl_rule} tables.
+ */
+ AclClusterConfigVO examineBrokerClusterAclConfig(String clusterId);
+
+ /**
+ * Creates a new plain access account or updates an existing one (keyed by
+ * access key). Persists the account identity to {@code rmq_acl_user} and
the
+ * per-resource permissions to {@code rmq_acl_rule}.
+ */
+ PlainAccessConfigVO createAndUpdatePlainAccessConfig(PlainAccessConfigVO
config);
}
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 1916f62b..8d0e3490 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
@@ -130,6 +130,34 @@ public class AclService {
recordAudit("DELETE_ACL_USER", "ACL_USER", id, null, null);
}
+ /**
+ * Returns a store-level summary of the ACL accounts provisioned for the
cluster from the
+ * MySQL-backed store. This does not query live broker state; the {@code
clusterId} only
+ * scopes which stored accounts are included.
+ */
+ public AclClusterConfigVO examineBrokerClusterAclConfig(String clusterId) {
+ if (!StringUtils.hasText(clusterId)) {
+ throw new BusinessException(400, "clusterId is required");
+ }
+ log.info("Examining broker cluster ACL config for clusterId={}",
clusterId);
+ return aclRepository.examineBrokerClusterAclConfig(clusterId);
+ }
+
+ /**
+ * Creates a new plain access account or updates an existing one. The
account identity is
+ * persisted to {@code rmq_acl_user} (including the IP whitelist) and the
per-resource
+ * permissions to {@code rmq_acl_rule} via the MySQL-backed repository.
The user row and the
+ * rule replacement happen in one transaction; a blank secret on an
existing account keeps
+ * the stored secret unchanged.
+ */
+ public PlainAccessConfigVO
createAndUpdatePlainAccessConfig(PlainAccessConfigVO config) {
+ if (config == null || !StringUtils.hasText(config.getAccessKey())) {
+ throw new BusinessException(400, "accessKey is required");
+ }
+ log.info("Creating/updating plain access config accessKey={}",
config.getAccessKey());
+ return aclRepository.createAndUpdatePlainAccessConfig(config);
+ }
+
/**
* Returns the plain-text credentials of a user for the "view password"
action.
* The secret is stored base64-encoded in the database and decoded here.
@@ -151,6 +179,7 @@ public class AclService {
.secretKey(CredentialUtils.mask(user.getSecretKey()))
.admin(user.isAdmin())
.clusters(user.getClusters() == null ? null :
List.copyOf(user.getClusters()))
+ .whiteRemoteAddress(user.getWhiteRemoteAddress())
.createdAt(user.getCreatedAt())
.build();
}
diff --git
a/server/src/main/java/org/apache/rocketmq/studio/instance/acl/AclUserVO.java
b/server/src/main/java/org/apache/rocketmq/studio/instance/acl/AclUserVO.java
index 2c51c4f8..998dbae0 100644
---
a/server/src/main/java/org/apache/rocketmq/studio/instance/acl/AclUserVO.java
+++
b/server/src/main/java/org/apache/rocketmq/studio/instance/acl/AclUserVO.java
@@ -38,5 +38,7 @@ public class AclUserVO {
private String secretKey;
private boolean admin;
private List<String> clusters;
+ /** IP whitelist pattern for plain access accounts; empty means no
restriction. */
+ private String whiteRemoteAddress;
private LocalDateTime createdAt;
}
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 2aeccfa7..42f3f781 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
@@ -17,15 +17,19 @@
package org.apache.rocketmq.studio.instance.acl;
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
+import org.apache.rocketmq.studio.common.exception.BusinessException;
import org.apache.rocketmq.studio.common.util.CredentialUtils;
import org.apache.rocketmq.studio.persistence.entity.RmqAclRule;
import org.apache.rocketmq.studio.persistence.entity.RmqAclUser;
import org.apache.rocketmq.studio.persistence.mapper.RmqAclRuleMapper;
import org.apache.rocketmq.studio.persistence.mapper.RmqAclUserMapper;
import org.springframework.stereotype.Repository;
+import org.springframework.transaction.annotation.Transactional;
+import org.springframework.util.StringUtils;
import lombok.RequiredArgsConstructor;
import java.time.LocalDateTime;
+import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
import java.util.Optional;
@@ -111,6 +115,196 @@ public class MybatisPlusAclRepository implements
AclRepository {
return userMapper.deleteById(id) > 0;
}
+ /**
+ * Summarizes the ACL accounts provisioned in the dashboard store for a
cluster. This is a
+ * store-level view, not a live broker query: accounts are read from
{@code rmq_acl_user} /
+ * {@code rmq_acl_rule} and scoped to the cluster — an account belongs to
the cluster when its
+ * cluster binding is empty (globally provisioned) or explicitly lists the
cluster id.
+ */
+ @Override
+ public AclClusterConfigVO examineBrokerClusterAclConfig(String clusterId) {
+ List<PlainAccessConfigVO> accounts = findUsers().stream()
+ .filter(user -> appliesToCluster(user.getClusters(),
clusterId))
+ .map(this::toPlainAccessConfig)
+ .collect(Collectors.toList());
+ return AclClusterConfigVO.builder()
+ .clusterId(clusterId)
+ .aclEnabled(!accounts.isEmpty())
+ .aclVersion("ACL 2.0")
+ .globalWhiteRemoteAddresses(List.of())
+ .accounts(accounts)
+ .accountCount(accounts.size())
+ .build();
+ }
+
+ private static boolean appliesToCluster(List<String> clusters, String
clusterId) {
+ return clusters == null || clusters.isEmpty() ||
clusters.contains(clusterId);
+ }
+
+ /**
+ * Upserts the account identity and replaces its per-resource permissions
atomically, so a
+ * failure midway cannot leave the account with a partially replaced rule
set.
+ */
+ @Override
+ @Transactional
+ public PlainAccessConfigVO
createAndUpdatePlainAccessConfig(PlainAccessConfigVO config) {
+ RmqAclUser existing = userMapper.selectOne(
+ new QueryWrapper<RmqAclUser>().eq("access_key",
config.getAccessKey()));
+ boolean secretProvided = StringUtils.hasText(config.getSecretKey());
+ if (!secretProvided && existing == null) {
+ throw new BusinessException(400, "secretKey is required for a new
plain access account");
+ }
+ RmqAclUser entity = new RmqAclUser();
+ if (existing != null) {
+ entity.setId(existing.getId());
+ entity.setCreatedAt(existing.getCreatedAt());
+ } else {
+ entity.setId("plain-" + config.getAccessKey());
+ entity.setCreatedAt(LocalDateTime.now());
+ }
+ entity.setUsername(config.getAccessKey());
+ entity.setAccessKey(config.getAccessKey());
+ if (secretProvided) {
+
entity.setSecretKey(CredentialUtils.encodeBase64(config.getSecretKey()));
+ } else {
+ // Blank secret on an existing account keeps the stored secret
unchanged.
+ entity.setSecretKey(existing.getSecretKey());
+ }
+ entity.setAdmin(config.isAdmin());
+ entity.setClusters(null);
+
entity.setWhiteRemoteAddress(normalizeWhiteRemoteAddress(config.getWhiteRemoteAddress()));
+ entity.setUpdatedAt(LocalDateTime.now());
+ if (existing != null) {
+ userMapper.updateById(entity);
+ } else {
+ userMapper.insert(entity);
+ }
+
+ upsertPlainAccessRules(config);
+
+ return PlainAccessConfigVO.builder()
+ .accessKey(config.getAccessKey())
+ // The secret is echoed only when it was just provided;
otherwise it stays
+ // hidden (read-back views always mask it).
+ .secretKey(secretProvided ? config.getSecretKey() : null)
+ .whiteRemoteAddress(entity.getWhiteRemoteAddress())
+ .admin(config.isAdmin())
+ .defaultTopicPerm(config.getDefaultTopicPerm())
+ .defaultGroupPerm(config.getDefaultGroupPerm())
+ .topicPerms(config.getTopicPerms() == null ? null : new
ArrayList<>(config.getTopicPerms()))
+ .groupPerms(config.getGroupPerms() == null ? null : new
ArrayList<>(config.getGroupPerms()))
+ .createdAt(entity.getCreatedAt())
+ .build();
+ }
+
+ private static String normalizeWhiteRemoteAddress(String value) {
+ if (value == null) {
+ return null;
+ }
+ String trimmed = value.trim();
+ return trimmed.isEmpty() ? null : trimmed;
+ }
+
+ private void upsertPlainAccessRules(PlainAccessConfigVO config) {
+ ruleMapper.delete(new QueryWrapper<RmqAclRule>()
+ .eq("principal", config.getAccessKey())
+ .eq("acl_version", "2.0")
+ .likeRight("id", "plain-" + config.getAccessKey() + "-"));
+ List<RmqAclRule> rules = new ArrayList<>();
+ if (config.getDefaultTopicPerm() != null) {
+ rules.add(plainRule(config.getAccessKey(), "*", "Cluster",
config.getDefaultTopicPerm(), "dt"));
+ }
+ if (config.getDefaultGroupPerm() != null) {
+ rules.add(plainRule(config.getAccessKey(), "*", "Cluster",
config.getDefaultGroupPerm(), "dg"));
+ }
+ if (config.getTopicPerms() != null) {
+ int index = 0;
+ for (String entry : config.getTopicPerms()) {
+ String[] parts = splitPerm(entry);
+ if (parts != null) {
+ rules.add(plainRule(config.getAccessKey(), parts[0],
"Topic", parts[1], "t-" + index));
+ index++;
+ }
+ }
+ }
+ if (config.getGroupPerms() != null) {
+ int index = 0;
+ for (String entry : config.getGroupPerms()) {
+ String[] parts = splitPerm(entry);
+ if (parts != null) {
+ rules.add(plainRule(config.getAccessKey(), parts[0],
"Group", parts[1], "g-" + index));
+ index++;
+ }
+ }
+ }
+ for (RmqAclRule rule : rules) {
+ ruleMapper.insert(rule);
+ }
+ }
+
+ private RmqAclRule plainRule(String principal, String resource, String
resourceType,
+ String actions, String idSuffix) {
+ RmqAclRule rule = new RmqAclRule();
+ rule.setId("plain-" + principal + "-" + idSuffix);
+ rule.setPrincipal(principal);
+ rule.setResource(resource);
+ rule.setResourceType(resourceType);
+ rule.setResourcePattern("LITERAL");
+ rule.setActions(actions);
+ rule.setDecision("ALLOW");
+ rule.setScope("*");
+ rule.setAclVersion("2.0");
+ rule.setCreatedAt(LocalDateTime.now());
+ rule.setUpdatedAt(LocalDateTime.now());
+ return rule;
+ }
+
+ private PlainAccessConfigVO toPlainAccessConfig(AclUserVO user) {
+ List<AclRuleVO> userRules = findRules(null, user.getAccessKey());
+ 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());
+ if ("Topic".equals(rule.getResourceType())) {
+ topicPerms.add(rule.getResource() + "=" + actions);
+ } else if ("Group".equals(rule.getResourceType())) {
+ groupPerms.add(rule.getResource() + "=" + actions);
+ } else if ("Cluster".equals(rule.getResourceType()) &&
"*".equals(rule.getResource())) {
+ if (rule.getId() != null && rule.getId().endsWith("-dt")) {
+ defaultTopicPerm = actions;
+ } else if (rule.getId() != null &&
rule.getId().endsWith("-dg")) {
+ defaultGroupPerm = actions;
+ }
+ }
+ }
+ return PlainAccessConfigVO.builder()
+ .accessKey(user.getAccessKey())
+ // Read-back views never expose the plaintext secret; only the
explicit
+ // per-user credentials endpoint does.
+ .secretKey(CredentialUtils.mask(user.getSecretKey()))
+ .whiteRemoteAddress(user.getWhiteRemoteAddress())
+ .admin(user.isAdmin())
+ .defaultTopicPerm(defaultTopicPerm)
+ .defaultGroupPerm(defaultGroupPerm)
+ .topicPerms(topicPerms)
+ .groupPerms(groupPerms)
+ .createdAt(user.getCreatedAt())
+ .build();
+ }
+
+ private static String[] splitPerm(String entry) {
+ if (entry == null) {
+ return null;
+ }
+ int idx = entry.lastIndexOf('=');
+ if (idx <= 0) {
+ return null;
+ }
+ return new String[]{entry.substring(0, idx).trim(),
entry.substring(idx + 1).trim()};
+ }
+
// ── Mapping ────────────────────────────────────────────────────
private static AclRuleVO toRuleVO(RmqAclRule entity) {
@@ -152,6 +346,7 @@ public class MybatisPlusAclRepository implements
AclRepository {
.secretKey(CredentialUtils.decodeBase64(entity.getSecretKey()))
.admin(Boolean.TRUE.equals(entity.getAdmin()))
.clusters(splitCsv(entity.getClusters()))
+ .whiteRemoteAddress(entity.getWhiteRemoteAddress())
.createdAt(entity.getCreatedAt())
.build();
}
diff --git
a/server/src/main/java/org/apache/rocketmq/studio/instance/acl/PlainAccessConfigVO.java
b/server/src/main/java/org/apache/rocketmq/studio/instance/acl/PlainAccessConfigVO.java
new file mode 100644
index 00000000..4b8bf46e
--- /dev/null
+++
b/server/src/main/java/org/apache/rocketmq/studio/instance/acl/PlainAccessConfigVO.java
@@ -0,0 +1,70 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.rocketmq.studio.instance.acl;
+
+import lombok.AllArgsConstructor;
+import lombok.Builder;
+import lombok.Data;
+import lombok.NoArgsConstructor;
+
+import java.time.LocalDateTime;
+import java.util.List;
+
+/**
+ * Plain access configuration (ACL 1.0 / plain_acl.yml account model).
+ *
+ * <p>Each instance represents one access identity with its access key,
optional
+ * secret key, IP whitelist and the default / per-resource permissions it
carries.
+ * This mirrors the {@code PlainAccessData} model used by the broker's plain
ACL
+ * provider and by ACL 2.0 account export.
+ */
+@Data
+@Builder
+@NoArgsConstructor
+@AllArgsConstructor
+public class PlainAccessConfigVO {
+
+ /** Unique access key identifying the account. */
+ private String accessKey;
+
+ /**
+ * Secret key. Written base64-encoded to the store; only echoed back by
the write endpoint
+ * when it was just provided. Read-back views return a masked value, and
the plaintext is
+ * available solely through the explicit per-user credentials endpoint.
+ */
+ private String secretKey;
+
+ /** IP whitelist pattern for this account; persisted, empty/null means no
restriction. */
+ private String whiteRemoteAddress;
+
+ /** Whether this account has admin privileges. */
+ private boolean admin;
+
+ /** Default permission applied to topics, e.g. DENY / PUB / SUB / ALL. */
+ private String defaultTopicPerm;
+
+ /** Default permission applied to groups, e.g. DENY / PUB / SUB / ALL. */
+ private String defaultGroupPerm;
+
+ /** Per-topic permission entries, e.g. "order-*=PUB". */
+ private List<String> topicPerms;
+
+ /** Per-group permission entries, e.g. "cg-order-*=SUB". */
+ private List<String> groupPerms;
+
+ private LocalDateTime createdAt;
+}
diff --git
a/server/src/main/java/org/apache/rocketmq/studio/instance/acl/UpsertPlainAccessConfigDTO.java
b/server/src/main/java/org/apache/rocketmq/studio/instance/acl/UpsertPlainAccessConfigDTO.java
new file mode 100644
index 00000000..67675645
--- /dev/null
+++
b/server/src/main/java/org/apache/rocketmq/studio/instance/acl/UpsertPlainAccessConfigDTO.java
@@ -0,0 +1,61 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.rocketmq.studio.instance.acl;
+
+import jakarta.validation.constraints.NotBlank;
+import lombok.Data;
+
+import java.util.List;
+
+/**
+ * Write payload for creating or updating a plain access account. Write
endpoints accept this
+ * validated DTO instead of the read-side VO; {@code secretKey} may be left
blank on updates, in
+ * which case the stored secret is kept unchanged.
+ */
+@Data
+public class UpsertPlainAccessConfigDTO {
+
+ @NotBlank(message = "accessKey is required")
+ private String accessKey;
+
+ private String secretKey;
+
+ private String whiteRemoteAddress;
+
+ private boolean admin;
+
+ private String defaultTopicPerm;
+
+ private String defaultGroupPerm;
+
+ private List<String> topicPerms;
+
+ private List<String> groupPerms;
+
+ public PlainAccessConfigVO toPlainAccessConfigVO() {
+ return PlainAccessConfigVO.builder()
+ .accessKey(accessKey == null ? null : accessKey.trim())
+ .secretKey(secretKey)
+ .whiteRemoteAddress(whiteRemoteAddress)
+ .admin(admin)
+ .defaultTopicPerm(defaultTopicPerm)
+ .defaultGroupPerm(defaultGroupPerm)
+ .topicPerms(topicPerms)
+ .groupPerms(groupPerms)
+ .build();
+ }
+}
diff --git
a/server/src/main/java/org/apache/rocketmq/studio/persistence/entity/RmqAclUser.java
b/server/src/main/java/org/apache/rocketmq/studio/persistence/entity/RmqAclUser.java
index 6bd13852..86ab50e2 100644
---
a/server/src/main/java/org/apache/rocketmq/studio/persistence/entity/RmqAclUser.java
+++
b/server/src/main/java/org/apache/rocketmq/studio/persistence/entity/RmqAclUser.java
@@ -40,6 +40,8 @@ public class RmqAclUser {
private String clusters;
+ private String whiteRemoteAddress;
+
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
diff --git a/server/src/main/resources/db/schema.sql
b/server/src/main/resources/db/schema.sql
index a9a4c15a..82194c75 100644
--- a/server/src/main/resources/db/schema.sql
+++ b/server/src/main/resources/db/schema.sql
@@ -173,6 +173,7 @@ CREATE TABLE IF NOT EXISTS rmq_acl_user (
secret_key VARCHAR(512) NOT NULL COMMENT 'base64 编码的密码',
admin TINYINT(1) DEFAULT 0,
clusters VARCHAR(1024) COMMENT '逗号分隔的集群/实例 id',
+ white_remote_address VARCHAR(255) COMMENT 'plain access 账号 IP 白名单,空表示不限制',
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
UNIQUE KEY uk_username (username)
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 f987ff9e..d6ca17c2 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
@@ -344,4 +344,85 @@ class AclControllerTest {
verifyNoInteractions(aclService);
}
+
+ // ── Plain access / cluster config inspection (PR-7) ──────────────
+
+ @Test
+ void examineClusterConfigShouldRequireClusterId() throws Exception {
+ when(aclService.examineBrokerClusterAclConfig(any()))
+ .thenThrow(new BusinessException(400, "clusterId is
required"));
+
+ mockMvc.perform(get("/api/acl/cluster-config"))
+ .andExpect(status().isBadRequest())
+ .andExpect(jsonPath("$.code").value(400))
+ .andExpect(jsonPath("$.message").value("clusterId is
required"));
+ }
+
+ @Test
+ void examineClusterConfigShouldReturnConfig() throws Exception {
+ AclClusterConfigVO config = AclClusterConfigVO.builder()
+ .clusterId("cluster-a")
+ .aclEnabled(true)
+ .aclVersion("ACL 2.0")
+ .globalWhiteRemoteAddresses(List.of("10.0.0.0/8"))
+ .accounts(List.of(PlainAccessConfigVO.builder()
+ .accessKey("rocketmq-admin")
+ .admin(true)
+ .build()))
+ .accountCount(1)
+ .build();
+
when(aclService.examineBrokerClusterAclConfig("cluster-a")).thenReturn(config);
+
+ mockMvc.perform(get("/api/acl/cluster-config").param("clusterId",
"cluster-a"))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.code").value(200))
+ .andExpect(jsonPath("$.data.clusterId").value("cluster-a"))
+ .andExpect(jsonPath("$.data.aclEnabled").value(true))
+ .andExpect(jsonPath("$.data.aclVersion").value("ACL 2.0"))
+ .andExpect(jsonPath("$.data.accountCount").value(1))
+
.andExpect(jsonPath("$.data.accounts[0].accessKey").value("rocketmq-admin"));
+
+ verify(aclService).examineBrokerClusterAclConfig("cluster-a");
+ }
+
+ @Test
+ void createUpdatePlainAccessConfigShouldRequireAccessKey() throws
Exception {
+ mockMvc.perform(post("/api/acl/plain-access-config")
+ .contentType(MediaType.APPLICATION_JSON)
+
.content(objectMapper.writeValueAsString(Map.of("admin", false))))
+ .andExpect(status().isBadRequest())
+ .andExpect(jsonPath("$.code").value(400))
+ .andExpect(jsonPath("$.message").value("accessKey is
required"));
+
+ verifyNoInteractions(aclService);
+ }
+
+ @Test
+ void createUpdatePlainAccessConfigShouldReturnSavedConfig() throws
Exception {
+ PlainAccessConfigVO saved = PlainAccessConfigVO.builder()
+ .accessKey("svc-x")
+ .admin(false)
+ .defaultTopicPerm("PUB")
+ .topicPerms(List.of("order-*=PUB"))
+ .build();
+
when(aclService.createAndUpdatePlainAccessConfig(any(PlainAccessConfigVO.class))).thenReturn(saved);
+
+ mockMvc.perform(post("/api/acl/plain-access-config")
+ .contentType(MediaType.APPLICATION_JSON)
+ .content(objectMapper.writeValueAsString(Map.of(
+ "accessKey", "svc-x", "admin", false,
+ "defaultTopicPerm", "PUB", "topicPerms",
List.of("order-*=PUB")))))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.code").value(200))
+ .andExpect(jsonPath("$.data.accessKey").value("svc-x"))
+ .andExpect(jsonPath("$.data.admin").value(false))
+
.andExpect(jsonPath("$.data.topicPerms[0]").value("order-*=PUB"));
+
+ ArgumentCaptor<PlainAccessConfigVO> captor =
ArgumentCaptor.forClass(PlainAccessConfigVO.class);
+ verify(aclService).createAndUpdatePlainAccessConfig(captor.capture());
+ PlainAccessConfigVO request = captor.getValue();
+ assertThat(request.getAccessKey()).isEqualTo("svc-x");
+ assertThat(request.getDefaultTopicPerm()).isEqualTo("PUB");
+ assertThat(request.getTopicPerms()).containsExactly("order-*=PUB");
+ }
}
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 b3cd90c4..b397eed0 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
@@ -508,6 +508,57 @@ class AclServiceTest {
assertThat(storedUser.isAdmin()).isTrue();
}
+ // ── Plain access / cluster config inspection (PR-7) ──────────────
+
+ @Test
+ void examineBrokerClusterAclConfigShouldRequireClusterId() {
+ assertThatThrownBy(() ->
aclService.examineBrokerClusterAclConfig(null))
+ .isInstanceOf(BusinessException.class)
+ .satisfies(ex -> assertThat(((BusinessException)
ex).getCode()).isEqualTo(400));
+ verify(aclRepository, never()).examineBrokerClusterAclConfig(any());
+ }
+
+ @Test
+ void examineBrokerClusterAclConfigShouldDelegateToRepository() {
+ AclClusterConfigVO expected = AclClusterConfigVO.builder()
+ .clusterId("c1")
+ .aclEnabled(true)
+ .aclVersion("ACL 2.0")
+ .accounts(List.of())
+ .accountCount(0)
+ .build();
+
when(aclRepository.examineBrokerClusterAclConfig("c1")).thenReturn(expected);
+
+ AclClusterConfigVO result =
aclService.examineBrokerClusterAclConfig("c1");
+
+ assertThat(result).isSameAs(expected);
+ verify(aclRepository).examineBrokerClusterAclConfig("c1");
+ }
+
+ @Test
+ void createAndUpdatePlainAccessConfigShouldRequireAccessKey() {
+ PlainAccessConfigVO blank = PlainAccessConfigVO.builder().accessKey("
").build();
+ assertThatThrownBy(() ->
aclService.createAndUpdatePlainAccessConfig(blank))
+ .isInstanceOf(BusinessException.class)
+ .satisfies(ex -> assertThat(((BusinessException)
ex).getCode()).isEqualTo(400));
+ verify(aclRepository, never()).createAndUpdatePlainAccessConfig(any());
+ }
+
+ @Test
+ void createAndUpdatePlainAccessConfigShouldDelegateToRepository() {
+ PlainAccessConfigVO config = PlainAccessConfigVO.builder()
+ .accessKey("ak-1")
+ .secretKey("sk-1")
+ .admin(false)
+ .build();
+
when(aclRepository.createAndUpdatePlainAccessConfig(config)).thenReturn(config);
+
+ PlainAccessConfigVO result =
aclService.createAndUpdatePlainAccessConfig(config);
+
+ assertThat(result).isSameAs(config);
+ verify(aclRepository).createAndUpdatePlainAccessConfig(config);
+ }
+
private String mask(String credential) {
return credential.substring(0, 4) + "****" +
credential.substring(credential.length() - 4);
}
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
new file mode 100644
index 00000000..5eaf35db
--- /dev/null
+++
b/server/src/test/java/org/apache/rocketmq/studio/instance/acl/MybatisPlusAclRepositoryTest.java
@@ -0,0 +1,240 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.rocketmq.studio.instance.acl;
+
+import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
+import org.apache.rocketmq.studio.common.exception.BusinessException;
+import org.apache.rocketmq.studio.common.util.CredentialUtils;
+import org.apache.rocketmq.studio.persistence.entity.RmqAclRule;
+import org.apache.rocketmq.studio.persistence.entity.RmqAclUser;
+import org.apache.rocketmq.studio.persistence.mapper.RmqAclRuleMapper;
+import org.apache.rocketmq.studio.persistence.mapper.RmqAclUserMapper;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.ArgumentCaptor;
+import org.mockito.InOrder;
+import org.mockito.InjectMocks;
+import org.mockito.Mock;
+import org.mockito.Mockito;
+import org.mockito.junit.jupiter.MockitoExtension;
+
+import java.time.LocalDateTime;
+import java.util.List;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.times;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+@ExtendWith(MockitoExtension.class)
+class MybatisPlusAclRepositoryTest {
+
+ @Mock
+ private RmqAclRuleMapper ruleMapper;
+
+ @Mock
+ private RmqAclUserMapper userMapper;
+
+ @InjectMocks
+ private MybatisPlusAclRepository repository;
+
+ @Test
+ void upsertShouldAssignUniqueRuleIdPerPermission() {
+ when(userMapper.selectOne(any(QueryWrapper.class))).thenReturn(null);
+ when(userMapper.insert(any(RmqAclUser.class))).thenReturn(1);
+ when(ruleMapper.delete(any(QueryWrapper.class))).thenReturn(0);
+ when(ruleMapper.insert(any(RmqAclRule.class))).thenReturn(1);
+
+ PlainAccessConfigVO config = PlainAccessConfigVO.builder()
+ .accessKey("svc-x")
+ .secretKey("secret-x")
+ .defaultTopicPerm("DENY")
+ .defaultGroupPerm("DENY")
+ .topicPerms(List.of("order-*=PUB", "payment-*=SUB"))
+ .groupPerms(List.of("cg-order=SUB", "cg-payment=SUB"))
+ .build();
+
+ repository.createAndUpdatePlainAccessConfig(config);
+
+ ArgumentCaptor<RmqAclRule> captor =
ArgumentCaptor.forClass(RmqAclRule.class);
+ verify(ruleMapper, times(6)).insert(captor.capture());
+ List<RmqAclRule> rules = captor.getAllValues();
+
+ // Every permission gets a distinct primary key so no entry overwrites
another.
+ assertThat(rules).extracting(RmqAclRule::getId)
+ .containsExactly("plain-svc-x-dt", "plain-svc-x-dg",
+ "plain-svc-x-t-0", "plain-svc-x-t-1",
+ "plain-svc-x-g-0", "plain-svc-x-g-1")
+ .doesNotHaveDuplicates();
+ assertThat(rules).extracting(RmqAclRule::getResource).containsExactly(
+ "*", "*", "order-*", "payment-*", "cg-order", "cg-payment");
+ }
+
+ @Test
+ void upsertShouldReplacePreviousRulesBeforeInsertingNewOnes() {
+ when(userMapper.selectOne(any(QueryWrapper.class))).thenReturn(null);
+ when(userMapper.insert(any(RmqAclUser.class))).thenReturn(1);
+ when(ruleMapper.delete(any(QueryWrapper.class))).thenReturn(2);
+ when(ruleMapper.insert(any(RmqAclRule.class))).thenReturn(1);
+
+ PlainAccessConfigVO config = PlainAccessConfigVO.builder()
+ .accessKey("svc-x")
+ .secretKey("secret-x")
+ .topicPerms(List.of("order-*=PUB"))
+ .build();
+
+ repository.createAndUpdatePlainAccessConfig(config);
+
+ InOrder ordered = Mockito.inOrder(ruleMapper);
+ ordered.verify(ruleMapper).delete(any(QueryWrapper.class));
+ ordered.verify(ruleMapper).insert(any(RmqAclRule.class));
+ }
+
+ @Test
+ void createShouldRejectBlankSecretForNewAccount() {
+ when(userMapper.selectOne(any(QueryWrapper.class))).thenReturn(null);
+
+ PlainAccessConfigVO config = PlainAccessConfigVO.builder()
+ .accessKey("svc-new")
+ .secretKey(" ")
+ .build();
+
+ assertThatThrownBy(() ->
repository.createAndUpdatePlainAccessConfig(config))
+ .isInstanceOf(BusinessException.class)
+ .satisfies(ex -> assertThat(((BusinessException)
ex).getCode()).isEqualTo(400));
+
+ verify(userMapper, never()).insert(any(RmqAclUser.class));
+ verify(ruleMapper, never()).insert(any(RmqAclRule.class));
+ }
+
+ @Test
+ void updateWithBlankSecretShouldKeepStoredSecret() {
+ RmqAclUser existing = userEntity("plain-svc-x", "svc-x",
+ CredentialUtils.encodeBase64("kept-secret-value"));
+
when(userMapper.selectOne(any(QueryWrapper.class))).thenReturn(existing);
+ when(userMapper.updateById(any(RmqAclUser.class))).thenReturn(1);
+ when(ruleMapper.delete(any(QueryWrapper.class))).thenReturn(0);
+
+ PlainAccessConfigVO config = PlainAccessConfigVO.builder()
+ .accessKey("svc-x")
+ .admin(true)
+ .build();
+
+ PlainAccessConfigVO result =
repository.createAndUpdatePlainAccessConfig(config);
+
+ ArgumentCaptor<RmqAclUser> captor =
ArgumentCaptor.forClass(RmqAclUser.class);
+ verify(userMapper).updateById(captor.capture());
+ assertThat(captor.getValue().getSecretKey())
+ .isEqualTo(CredentialUtils.encodeBase64("kept-secret-value"));
+ // The kept secret is not echoed back.
+ assertThat(result.getSecretKey()).isNull();
+ assertThat(result.isAdmin()).isTrue();
+ }
+
+ @Test
+ void createShouldPersistWhiteRemoteAddressAndTrimBlanks() {
+ when(userMapper.selectOne(any(QueryWrapper.class))).thenReturn(null);
+ when(userMapper.insert(any(RmqAclUser.class))).thenReturn(1);
+ when(ruleMapper.delete(any(QueryWrapper.class))).thenReturn(0);
+
+ PlainAccessConfigVO config = PlainAccessConfigVO.builder()
+ .accessKey("svc-x")
+ .secretKey("secret-x")
+ .whiteRemoteAddress(" 10.0.1.0/24 ")
+ .build();
+
+ PlainAccessConfigVO result =
repository.createAndUpdatePlainAccessConfig(config);
+
+ ArgumentCaptor<RmqAclUser> captor =
ArgumentCaptor.forClass(RmqAclUser.class);
+ verify(userMapper).insert(captor.capture());
+
assertThat(captor.getValue().getWhiteRemoteAddress()).isEqualTo("10.0.1.0/24");
+ assertThat(result.getWhiteRemoteAddress()).isEqualTo("10.0.1.0/24");
+ }
+
+ @Test
+ void createShouldStoreBlankWhiteRemoteAddressAsNull() {
+ when(userMapper.selectOne(any(QueryWrapper.class))).thenReturn(null);
+ when(userMapper.insert(any(RmqAclUser.class))).thenReturn(1);
+ when(ruleMapper.delete(any(QueryWrapper.class))).thenReturn(0);
+
+ PlainAccessConfigVO config = PlainAccessConfigVO.builder()
+ .accessKey("svc-x")
+ .secretKey("secret-x")
+ .whiteRemoteAddress(" ")
+ .build();
+
+ repository.createAndUpdatePlainAccessConfig(config);
+
+ ArgumentCaptor<RmqAclUser> captor =
ArgumentCaptor.forClass(RmqAclUser.class);
+ verify(userMapper).insert(captor.capture());
+ assertThat(captor.getValue().getWhiteRemoteAddress()).isNull();
+ }
+
+ @Test
+ void examineShouldMaskAccountSecrets() {
+ String plaintext = "supersecret-abcdef";
+ RmqAclUser user = userEntity("plain-svc-x", "svc-x",
+ CredentialUtils.encodeBase64(plaintext));
+
when(userMapper.selectList(any(QueryWrapper.class))).thenReturn(List.of(user));
+
when(ruleMapper.selectList(any(QueryWrapper.class))).thenReturn(List.of());
+
+ AclClusterConfigVO config =
repository.examineBrokerClusterAclConfig("cluster-a");
+
+ assertThat(config.getAccounts()).hasSize(1);
+ PlainAccessConfigVO account = config.getAccounts().get(0);
+
assertThat(account.getSecretKey()).doesNotContain(plaintext).contains("****");
+ assertThat(config.getClusterId()).isEqualTo("cluster-a");
+ }
+
+ @Test
+ void examineShouldScopeAccountsToRequestedCluster() {
+ RmqAclUser boundHere = userEntity("plain-a", "svc-a",
+ CredentialUtils.encodeBase64("secret-a-value"));
+ boundHere.setClusters("cluster-a,cluster-b");
+ RmqAclUser global = userEntity("plain-g", "svc-g",
+ CredentialUtils.encodeBase64("secret-g-value"));
+ RmqAclUser boundElsewhere = userEntity("plain-e", "svc-e",
+ CredentialUtils.encodeBase64("secret-e-value"));
+ boundElsewhere.setClusters("cluster-c");
+ when(userMapper.selectList(any(QueryWrapper.class)))
+ .thenReturn(List.of(boundHere, global, boundElsewhere));
+
when(ruleMapper.selectList(any(QueryWrapper.class))).thenReturn(List.of());
+
+ AclClusterConfigVO config =
repository.examineBrokerClusterAclConfig("cluster-a");
+
+ // Global accounts (no cluster binding) appear for every cluster;
accounts bound to
+ // other clusters are excluded.
+
assertThat(config.getAccounts()).extracting(PlainAccessConfigVO::getAccessKey)
+ .containsExactly("svc-a", "svc-g");
+ assertThat(config.getAccountCount()).isEqualTo(2);
+ assertThat(config.isAclEnabled()).isTrue();
+ }
+
+ private static RmqAclUser userEntity(String id, String accessKey, String
encodedSecret) {
+ RmqAclUser entity = new RmqAclUser();
+ entity.setId(id);
+ entity.setUsername(accessKey);
+ entity.setAccessKey(accessKey);
+ entity.setSecretKey(encodedSecret);
+ entity.setAdmin(false);
+ entity.setCreatedAt(LocalDateTime.of(2026, 1, 1, 0, 0));
+ return entity;
+ }
+}
diff --git
a/server/src/test/java/org/apache/rocketmq/studio/settings/SettingsServiceTest.java
b/server/src/test/java/org/apache/rocketmq/studio/settings/SettingsServiceTest.java
index a37a3266..0b264c72 100644
---
a/server/src/test/java/org/apache/rocketmq/studio/settings/SettingsServiceTest.java
+++
b/server/src/test/java/org/apache/rocketmq/studio/settings/SettingsServiceTest.java
@@ -80,6 +80,7 @@ class SettingsServiceTest {
};
}
+
@AfterEach
void tearDown() {
prometheusServer.stop(0);
diff --git a/web/src/api/acl.test.ts b/web/src/api/acl.test.ts
index 6188841b..73ce99d6 100644
--- a/web/src/api/acl.test.ts
+++ b/web/src/api/acl.test.ts
@@ -21,8 +21,10 @@ import client from './client';
import {
createAclRule,
createAclUser,
+ createAndUpdatePlainAccessConfig,
deleteAclRule,
deleteAclUser,
+ examineBrokerClusterAclConfig,
listAclRules,
updateAclRule,
updateAclUser,
@@ -124,4 +126,44 @@ describe('ACL API contract', () => {
await expect(deleteAclRule(rule.id)).resolves.toBeUndefined();
await expect(deleteAclUser(user.id)).resolves.toBeUndefined();
});
+
+ it('fetches cluster ACL config by clusterId', async () => {
+ mock.onGet('/acl/cluster-config').reply((config) => {
+ expect(config.params).toEqual({ clusterId: 'cluster-a' });
+ return [
+ 200,
+ {
+ code: 200,
+ data: {
+ clusterId: 'cluster-a',
+ aclEnabled: true,
+ aclVersion: 'ACL 2.0',
+ globalWhiteRemoteAddresses: ['10.0.0.0/8'],
+ accounts: [],
+ accountCount: 0,
+ },
+ },
+ ];
+ });
+
+ const result = await examineBrokerClusterAclConfig('cluster-a');
+ expect(result.clusterId).toBe('cluster-a');
+ expect(result.aclVersion).toBe('ACL 2.0');
+ expect(result.accountCount).toBe(0);
+ });
+
+ it('posts plain access config to create or update', async () => {
+ const payload = {
+ accessKey: 'svc-x',
+ admin: false,
+ defaultTopicPerm: 'PUB',
+ topicPerms: ['t=PUB'],
+ };
+ mock.onPost('/acl/plain-access-config').reply((config) => {
+ expect(JSON.parse(config.data)).toEqual(payload);
+ return [200, { code: 200, data: payload }];
+ });
+
+ await
expect(createAndUpdatePlainAccessConfig(payload)).resolves.toEqual(payload);
+ });
});
diff --git a/web/src/api/acl.ts b/web/src/api/acl.ts
index 41fdb22b..bbb25a7d 100644
--- a/web/src/api/acl.ts
+++ b/web/src/api/acl.ts
@@ -73,3 +73,38 @@ export async function updateAclUser(data: Partial<AclUser>) {
export async function deleteAclUser(id: string) {
await client.post('/acl/users/delete', { id });
}
+
+// ============ ACL 2.0: cluster config & plain access ============
+
+export interface PlainAccessConfig {
+ accessKey: string;
+ secretKey?: string | null;
+ whiteRemoteAddress?: string | null;
+ admin: boolean;
+ defaultTopicPerm?: string;
+ defaultGroupPerm?: string;
+ topicPerms?: string[];
+ groupPerms?: string[];
+ createdAt?: string | null;
+}
+
+export interface AclClusterConfig {
+ clusterId: string;
+ aclEnabled: boolean;
+ aclVersion: string;
+ globalWhiteRemoteAddresses: string[];
+ accounts: PlainAccessConfig[];
+ accountCount: number;
+}
+
+export async function examineBrokerClusterAclConfig(clusterId: string) {
+ const res = await client.get<{ data: AclClusterConfig
}>('/acl/cluster-config', {
+ params: { clusterId },
+ });
+ return res.data.data;
+}
+
+export async function createAndUpdatePlainAccessConfig(data:
Partial<PlainAccessConfig>) {
+ const res = await client.post<{ data: PlainAccessConfig
}>('/acl/plain-access-config', data);
+ return res.data.data;
+}
diff --git a/web/src/api/llm.test.ts b/web/src/api/llm.test.ts
index a2d97fce..ca52aa7f 100644
--- a/web/src/api/llm.test.ts
+++ b/web/src/api/llm.test.ts
@@ -128,13 +128,11 @@ describe('LLM API', () => {
{ id: 'gpt-4o', name: 'GPT-4o' },
{ id: 'gpt-4-turbo', name: 'GPT-4 Turbo' },
];
- mock
- .onGet('/llm/models')
- .reply(200, {
- code: 200,
- message: 'success',
- data: { status: 0, data: models, source: 'provider' },
- });
+ mock.onGet('/llm/models').reply(200, {
+ code: 200,
+ message: 'success',
+ data: { status: 0, data: models, source: 'provider' },
+ });
const result = await getLlmModels();
expect(result.status).toBe(0);
diff --git a/web/src/api/producer.test.ts b/web/src/api/producer.test.ts
index 8245cfd1..26b39f9b 100644
--- a/web/src/api/producer.test.ts
+++ b/web/src/api/producer.test.ts
@@ -41,10 +41,13 @@ describe('Producer API', () => {
it('fetches Studio topic records sorted alphabetically', async () => {
mock.onGet('/topics').reply((config) => {
expect(config.params.instanceId).toBe('instance-1');
- return [200, {
- code: 200,
- data: [{ name: 'order-events' }, { name: 'user-signup' }, { name:
'batch-process' }],
- }];
+ return [
+ 200,
+ {
+ code: 200,
+ data: [{ name: 'order-events' }, { name: 'user-signup' }, { name:
'batch-process' }],
+ },
+ ];
});
const result = await fetchTopicList('instance-1');
diff --git a/web/src/i18n/translations.ts b/web/src/i18n/translations.ts
index 2199fe09..6ae498fd 100644
--- a/web/src/i18n/translations.ts
+++ b/web/src/i18n/translations.ts
@@ -379,9 +379,43 @@ const translations: Record<string, Record<Lang, string>> =
{
'acl.clusterScope': { zh: '集群 (cluster)', en: 'Cluster (cluster)' },
'acl.namespaceScope': { zh: '命名空间 (namespace)', en: 'Namespace (namespace)'
},
'acl.usernamePlaceholder': { zh: '例:user-order-service', en: 'e.g.
user-order-service' },
- 'acl.autoOrManual': { zh: '自动生成或手动输入', en: 'Auto-generate or enter manually'
},
+ 'acl.secretCreateHint': { zh: '请输入密钥', en: 'Enter the secret key' },
+ 'acl.secretKeepUnchanged': {
+ zh: '留空表示保持原密钥不变',
+ en: 'Leave blank to keep the current secret',
+ },
'acl.required': { zh: '请选择{field}', en: 'Please select {field}' },
'acl.inputRequired': { zh: '请输入{field}', en: 'Please enter {field}' },
+ 'acl.clusterConfigTab': { zh: '集群 ACL 配置', en: 'Cluster ACL Config' },
+ 'acl.examineTitle': { zh: '检查 Broker 集群 ACL 配置', en: 'Examine Broker Cluster
ACL Config' },
+ 'acl.examineCluster': { zh: '集群', en: 'Cluster' },
+ 'acl.examineClusterPlaceholder': { zh: '请输入集群 ID', en: 'Enter cluster id' },
+ 'acl.examine': { zh: '检查配置', en: 'Examine' },
+ 'acl.aclEnabled': { zh: 'ACL 已启用', en: 'ACL Enabled' },
+ 'acl.aclDisabled': { zh: 'ACL 未启用', en: 'ACL Disabled' },
+ 'acl.aclVersionLabel': { zh: 'ACL 版本', en: 'ACL Version' },
+ 'acl.globalWhitelist': { zh: '全局 IP 白名单', en: 'Global IP Whitelist' },
+ 'acl.accountCount': { zh: '账号数', en: 'Accounts' },
+ 'acl.noAccounts': { zh: '暂未配置 Plain Access 账号', en: 'No plain access
accounts configured' },
+ 'acl.plainAccessTitle': { zh: 'Plain Access 账号', en: 'Plain Access Account'
},
+ 'acl.plainAccessSubtitle': {
+ zh: '创建或更新 ACL 1.0 / 2.0 明文访问账号',
+ en: 'Create or update a plain access account (ACL 1.0 / 2.0)',
+ },
+ 'acl.addPlainAccess': { zh: '新增 Plain Access 账号', en: 'Add Plain Access
Account' },
+ 'acl.editPlainAccess': { zh: '编辑 Plain Access 账号', en: 'Edit Plain Access
Account' },
+ 'acl.accessKey': { zh: 'Access Key', en: 'Access Key' },
+ 'acl.secretKey': { zh: 'Secret Key', en: 'Secret Key' },
+ 'acl.whiteRemoteAddress': { zh: 'IP 白名单', en: 'IP Whitelist' },
+ 'acl.defaultTopicPerm': { zh: '默认 Topic 权限', en: 'Default Topic Perm' },
+ 'acl.defaultGroupPerm': { zh: '默认 Group 权限', en: 'Default Group Perm' },
+ 'acl.topicPerms': { zh: 'Topic 权限', en: 'Topic Perms' },
+ 'acl.groupPerms': { zh: 'Group 权限', en: 'Group Perms' },
+ 'acl.topicPermsPlaceholder': { zh: '如 topicA=PUB,topicB=SUB', en: 'e.g.
topicA=PUB,topicB=SUB' },
+ 'acl.groupPermsPlaceholder': { zh: '如 groupA=SUB', en: 'e.g. groupA=SUB' },
+ 'acl.permType': { zh: '权限类型', en: 'Permission' },
+ 'acl.plainAccessSaved': { zh: 'Plain Access 账号已保存', en: 'Plain access
account saved' },
+ 'acl.configExamined': { zh: '集群 ACL 配置已加载', en: 'Cluster ACL config loaded'
},
// ─── Topic Page (unique keys, duplicates merged into Topic section below)
───
'topic.name': { zh: 'Topic 名称', en: 'Topic Name' },
diff --git a/web/src/pages/cluster/clients.tsx
b/web/src/pages/cluster/clients.tsx
index 92efe529..16c97bbb 100644
--- a/web/src/pages/cluster/clients.tsx
+++ b/web/src/pages/cluster/clients.tsx
@@ -119,7 +119,6 @@ const ClientsPage = () => {
useEffect(() => {
let cancelled = false;
- setLoading(true);
void listInstances()
.then((nextInstances) => {
if (cancelled) return;
@@ -377,7 +376,13 @@ const ClientsPage = () => {
message={loadError}
style={{ marginBottom: 16 }}
action={
- <Button size="small" onClick={() => setInstanceLoadKey((key) =>
key + 1)}>
+ <Button
+ size="small"
+ onClick={() => {
+ setLoading(true);
+ setInstanceLoadKey((key) => key + 1);
+ }}
+ >
重试
</Button>
}
diff --git a/web/src/pages/home/__tests__/DashboardPage.test.tsx
b/web/src/pages/home/__tests__/DashboardPage.test.tsx
index 4d28874c..e0d6c5ef 100644
--- a/web/src/pages/home/__tests__/DashboardPage.test.tsx
+++ b/web/src/pages/home/__tests__/DashboardPage.test.tsx
@@ -83,8 +83,28 @@ beforeAll(() => {
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(instanceService.listInstances).mockResolvedValue([
- { id: 'instance-a', name: 'Instance A', endpoint: 'a:9876', type:
'DIRECT', remark: '', topicCount: 0, consumerGroupCount: 0, createdAt: '',
updatedAt: '' },
- { id: 'instance-b', name: 'Instance B', endpoint: 'b:9876', type:
'DIRECT', remark: '', topicCount: 0, consumerGroupCount: 0, createdAt: '',
updatedAt: '' },
+ {
+ id: 'instance-a',
+ name: 'Instance A',
+ endpoint: 'a:9876',
+ type: 'DIRECT',
+ remark: '',
+ topicCount: 0,
+ consumerGroupCount: 0,
+ createdAt: '',
+ updatedAt: '',
+ },
+ {
+ id: 'instance-b',
+ name: 'Instance B',
+ endpoint: 'b:9876',
+ type: 'DIRECT',
+ remark: '',
+ topicCount: 0,
+ consumerGroupCount: 0,
+ createdAt: '',
+ updatedAt: '',
+ },
]);
});
@@ -102,9 +122,13 @@ describe('DashboardPage', () => {
await screen.findByText('initial-cluster');
const selector = screen.getByRole('combobox', { name: 'Dashboard instance'
});
await user.click(selector);
- await user.click(await screen.findByText('Instance A', { selector:
'.ant-select-item-option-content' }));
+ await user.click(
+ await screen.findByText('Instance A', { selector:
'.ant-select-item-option-content' }),
+ );
await user.click(selector);
- await user.click(await screen.findByText('Instance B', { selector:
'.ant-select-item-option-content' }));
+ await user.click(
+ await screen.findByText('Instance B', { selector:
'.ant-select-item-option-content' }),
+ );
instanceB.resolve(dashboard('instance-b-cluster'));
expect(await screen.findByText('instance-b-cluster')).toBeInTheDocument();
diff --git a/web/src/pages/instance/__tests__/AclPage.test.tsx
b/web/src/pages/instance/__tests__/AclPage.test.tsx
index 379535de..c4680512 100644
--- a/web/src/pages/instance/__tests__/AclPage.test.tsx
+++ b/web/src/pages/instance/__tests__/AclPage.test.tsx
@@ -28,8 +28,10 @@ import AclPage from '../acl';
vi.mock('../../../services/aclService', () => ({
createAclRule: vi.fn(),
createAclUser: vi.fn(),
+ createAndUpdatePlainAccessConfig: vi.fn(),
deleteAclRule: vi.fn(),
deleteAclUser: vi.fn(),
+ examineBrokerClusterAclConfig: vi.fn(),
listAclRules: vi.fn(),
listAclUsers: vi.fn(),
updateAclRule: vi.fn(),
@@ -305,4 +307,72 @@ describe('ACL page', () => {
clusters: ['cluster-b'],
});
});
+
+ it('examines the broker cluster ACL config', async () => {
+ const user = userEvent.setup();
+ vi.mocked(aclService.examineBrokerClusterAclConfig).mockResolvedValue({
+ clusterId: 'DefaultCluster',
+ aclEnabled: true,
+ aclVersion: 'ACL 2.0',
+ globalWhiteRemoteAddresses: ['10.0.0.0/8'],
+ accounts: [
+ {
+ accessKey: 'rocketmq-admin',
+ admin: true,
+ defaultTopicPerm: 'ALL',
+ defaultGroupPerm: 'ALL',
+ topicPerms: ['*=ALL'],
+ groupPerms: ['*=ALL'],
+ },
+ {
+ accessKey: 'user-order-service',
+ admin: false,
+ defaultTopicPerm: 'PUB',
+ defaultGroupPerm: 'SUB',
+ topicPerms: ['order-*=PUB'],
+ groupPerms: ['cg-order-*=SUB'],
+ },
+ ],
+ accountCount: 2,
+ });
+ renderWithProviders(<AclPage />);
+
+ await user.click(await screen.findByText('集群 ACL 配置'));
+ const clusterInput = screen.getByPlaceholderText('请输入集群 ID');
+ await user.type(clusterInput, 'DefaultCluster');
+ await user.click(await screen.findByRole('button', { name: /检\s*查\s*配\s*置/
}));
+
+ expect(await screen.findByText('rocketmq-admin')).toBeInTheDocument();
+ expect(screen.getByText('ACL 2.0')).toBeInTheDocument();
+ expect(aclService.examineBrokerClusterAclConfig).toHaveBeenCalledTimes(1);
+ });
+
+ it('creates a plain access account', async () => {
+ const user = userEvent.setup();
+ vi.mocked(aclService.createAndUpdatePlainAccessConfig).mockResolvedValue({
+ accessKey: 'new-svc',
+ admin: false,
+ defaultTopicPerm: 'DENY',
+ defaultGroupPerm: 'DENY',
+ topicPerms: [],
+ groupPerms: [],
+ createdAt: '2026-08-01T00:00:00Z',
+ });
+ renderWithProviders(<AclPage />);
+
+ await user.click(await screen.findByText('集群 ACL 配置'));
+ await user.click(screen.getByRole('button', { name: /新增/ }));
+ const dialog = await screen.findByRole('dialog');
+
+ await user.type(within(dialog).getByPlaceholderText('e.g.
user-order-service'), 'new-svc');
+ await user.type(within(dialog).getByPlaceholderText('请输入密钥'),
'new-secret');
+ await user.click(within(dialog).getByRole('button', { name: /添\s*加/ }));
+
+ await waitFor(() =>
+
expect(aclService.createAndUpdatePlainAccessConfig).toHaveBeenCalledTimes(1),
+ );
+ expect(aclService.createAndUpdatePlainAccessConfig).toHaveBeenCalledWith(
+ expect.objectContaining({ accessKey: 'new-svc' }),
+ );
+ });
});
diff --git a/web/src/pages/instance/__tests__/DLQPage.test.tsx
b/web/src/pages/instance/__tests__/DLQPage.test.tsx
index 5b62299d..5d0e8d6c 100644
--- a/web/src/pages/instance/__tests__/DLQPage.test.tsx
+++ b/web/src/pages/instance/__tests__/DLQPage.test.tsx
@@ -263,7 +263,14 @@ describe('DLQ page', () => {
await user.click(screen.getByRole('button', { name: '确认重投' }));
await waitFor(() =>
expect(messageService.listDLQGroups).toHaveBeenCalledTimes(2));
- await waitFor(() =>
expect(within(orderRow).getByRole('checkbox')).toBeDisabled());
+ await waitFor(() => {
+ // The refresh clears the group list before repopulating it, remounting
+ // the row; look the row up freshly instead of reusing the pre-refresh
+ // reference, which may point at a detached node.
+ const refreshedRow = screen.getAllByText('cg-order')[0]?.closest('tr');
+ if (!refreshedRow) throw new Error('DLQ group row not found after
refresh');
+ expect(within(refreshedRow).getByRole('checkbox')).toBeDisabled();
+ });
expect(screen.getByRole('button', { name: /批量导出/ })).toBeDisabled();
});
diff --git a/web/src/pages/instance/acl.tsx b/web/src/pages/instance/acl.tsx
index 186ceba2..98ebdce0 100644
--- a/web/src/pages/instance/acl.tsx
+++ b/web/src/pages/instance/acl.tsx
@@ -36,7 +36,15 @@ import {
Alert,
message,
} from 'antd';
-import { Plus, MagnifyingGlass, ShieldCheck, User, Eye, EyeSlash } from
'@phosphor-icons/react';
+import {
+ Plus,
+ MagnifyingGlass,
+ ShieldCheck,
+ User,
+ Eye,
+ EyeSlash,
+ Key,
+} from '@phosphor-icons/react';
import { EditOutlined, DeleteOutlined } from '@ant-design/icons';
import type { ColumnsType } from 'antd/es/table';
import PageHeader from '../../components/PageHeader';
@@ -44,15 +52,17 @@ import { useLang } from '../../i18n/LangContext';
import {
createAclRule,
createAclUser,
+ createAndUpdatePlainAccessConfig,
deleteAclRule,
deleteAclUser,
getAclUserCredentials,
+ examineBrokerClusterAclConfig,
listAclRules,
listAclUsers,
updateAclRule,
updateAclUser,
} from '../../services/aclService';
-import type { AclRule, AclUser } from '../../api/acl';
+import type { AclRule, AclUser, AclClusterConfig, PlainAccessConfig } from
'../../api/acl';
import { useInstanceFilter } from '../../hooks/useInstanceFilter';
type AclRuleFormValues = Pick<
@@ -126,6 +136,17 @@ const AclPage = () => {
Record<string, { accessKey: string; secretKey: string }>
>({});
+ // Cluster ACL config (examineBrokerClusterAclConfig)
+ const [clusterConfig, setClusterConfig] = useState<AclClusterConfig |
null>(null);
+ const [configLoading, setConfigLoading] = useState(false);
+ const [clusterIdInput, setClusterIdInput] = useState('DefaultCluster');
+
+ // Plain access config modal
+ const [plainModalOpen, setPlainModalOpen] = useState(false);
+ const [editingPlain, setEditingPlain] = useState<PlainAccessConfig |
null>(null);
+ const [plainSubmitting, setPlainSubmitting] = useState(false);
+ const [plainForm] = Form.useForm();
+
useEffect(() => {
let mounted = true;
@@ -353,6 +374,87 @@ const AclPage = () => {
}
};
+ /* ─── Cluster ACL config helpers ─── */
+ const handleExamine = async () => {
+ const clusterId = clusterIdInput.trim();
+ if (!clusterId) {
+ message.warning(t('acl.inputRequired', { field: t('acl.examineCluster')
}));
+ return;
+ }
+ try {
+ setConfigLoading(true);
+ const config = await examineBrokerClusterAclConfig(clusterId);
+ setClusterConfig(config);
+ message.success(t('acl.configExamined'));
+ } catch {
+ message.error(t('common.operationFailed'));
+ } finally {
+ setConfigLoading(false);
+ }
+ };
+
+ const openAddPlainModal = () => {
+ setEditingPlain(null);
+ plainForm.resetFields();
+ plainForm.setFieldsValue({
+ admin: false,
+ defaultTopicPerm: 'DENY',
+ defaultGroupPerm: 'DENY',
+ topicPerms: [],
+ groupPerms: [],
+ });
+ setPlainModalOpen(true);
+ };
+
+ const openEditPlainModal = (account: PlainAccessConfig) => {
+ setEditingPlain(account);
+ plainForm.setFieldsValue({
+ accessKey: account.accessKey,
+ // Read-back views only carry a masked secret; leave the field blank so
the
+ // stored secret is kept unless the user explicitly types a new one.
+ secretKey: '',
+ whiteRemoteAddress: account.whiteRemoteAddress ?? '',
+ admin: account.admin,
+ defaultTopicPerm: account.defaultTopicPerm ?? 'DENY',
+ defaultGroupPerm: account.defaultGroupPerm ?? 'DENY',
+ topicPerms: [...(account.topicPerms ?? [])],
+ groupPerms: [...(account.groupPerms ?? [])],
+ });
+ setPlainModalOpen(true);
+ };
+
+ const handlePlainSubmit = async () => {
+ try {
+ const values = (await plainForm.validateFields()) as
Partial<PlainAccessConfig>;
+ setPlainSubmitting(true);
+ const saved = await createAndUpdatePlainAccessConfig({
+ ...values,
+ accessKey: (values.accessKey ?? '').trim(),
+ });
+ const normalized: PlainAccessConfig = {
+ ...saved,
+ accessKey: saved.accessKey,
+ topicPerms: saved.topicPerms ?? [],
+ groupPerms: saved.groupPerms ?? [],
+ };
+ setClusterConfig((prev) => {
+ if (!prev) return prev;
+ const exists = prev.accounts.some((a) => a.accessKey ===
normalized.accessKey);
+ const accounts = exists
+ ? prev.accounts.map((a) => (a.accessKey === normalized.accessKey ?
normalized : a))
+ : [normalized, ...prev.accounts];
+ return { ...prev, accounts, accountCount: accounts.length };
+ });
+ message.success(t('acl.plainAccessSaved'));
+ setPlainModalOpen(false);
+ } catch (error) {
+ if (isFormValidationError(error)) return;
+ message.error(t('common.operationFailed'));
+ } finally {
+ setPlainSubmitting(false);
+ }
+ };
+
const formatDate = (iso?: string | null) => {
if (!iso) return '-';
const d = new Date(iso);
@@ -639,6 +741,99 @@ const AclPage = () => {
},
];
+ const permTagColor: Record<string, string> = {
+ ALL: 'purple',
+ PUB: 'blue',
+ SUB: 'green',
+ DENY: 'red',
+ };
+
+ const plainColumns: ColumnsType<PlainAccessConfig> = [
+ {
+ title: t('acl.accessKey'),
+ dataIndex: 'accessKey',
+ key: 'accessKey',
+ width: 220,
+ render: (text: string) => (
+ <Space size={6}>
+ <Key size={14} color="#8c8c8c" weight="fill" />
+ <span style={{ fontFamily: 'monospace', fontWeight: 500
}}>{text}</span>
+ </Space>
+ ),
+ },
+ {
+ title: t('acl.admin'),
+ dataIndex: 'admin',
+ key: 'admin',
+ width: 90,
+ render: (val: boolean) =>
+ val ? (
+ <Tag color="purple">{t('acl.adminBadge')}</Tag>
+ ) : (
+ <span style={{ color: '#8c8c8c' }}>-</span>
+ ),
+ },
+ {
+ title: t('acl.defaultTopicPerm'),
+ dataIndex: 'defaultTopicPerm',
+ key: 'defaultTopicPerm',
+ width: 140,
+ render: (val: string) => <Tag color={permTagColor[val] ??
'default'}>{val}</Tag>,
+ },
+ {
+ title: t('acl.defaultGroupPerm'),
+ dataIndex: 'defaultGroupPerm',
+ key: 'defaultGroupPerm',
+ width: 140,
+ render: (val: string) => <Tag color={permTagColor[val] ??
'default'}>{val}</Tag>,
+ },
+ {
+ title: t('acl.topicPerms'),
+ dataIndex: 'topicPerms',
+ key: 'topicPerms',
+ width: 220,
+ render: (perms: string[]) => (
+ <Space size={4} wrap>
+ {(perms ?? []).map((p) => (
+ <Tag key={p} color="blue" style={{ fontSize: 11 }}>
+ {p}
+ </Tag>
+ ))}
+ </Space>
+ ),
+ },
+ {
+ title: t('acl.groupPerms'),
+ dataIndex: 'groupPerms',
+ key: 'groupPerms',
+ width: 200,
+ render: (perms: string[]) => (
+ <Space size={4} wrap>
+ {(perms ?? []).map((p) => (
+ <Tag key={p} color="green" style={{ fontSize: 11 }}>
+ {p}
+ </Tag>
+ ))}
+ </Space>
+ ),
+ },
+ {
+ title: t('common.actions'),
+ key: 'plainActions',
+ width: 100,
+ render: (_: unknown, record: PlainAccessConfig) => (
+ <Button
+ size="small"
+ icon={<EditOutlined />}
+ style={{ borderColor: '#1677ff', color: '#1677ff' }}
+ onClick={() => openEditPlainModal(record)}
+ >
+ {t('common.edit')}
+ </Button>
+ ),
+ },
+ ];
+
/* ═══════════════════════════════════════════
Render
═══════════════════════════════════════════ */
@@ -784,6 +979,101 @@ const AclPage = () => {
</div>
),
},
+ {
+ key: 'clusterConfig',
+ label: (
+ <Space size={6}>
+ <ShieldCheck size={15} />
+ <span>{t('acl.clusterConfigTab')}</span>
+ </Space>
+ ),
+ children: (
+ <div>
+ {/* Examine cluster ACL config */}
+ <div
+ style={{
+ display: 'flex',
+ gap: 12,
+ padding: '16px 0',
+ flexWrap: 'wrap',
+ alignItems: 'center',
+ }}
+ >
+ <Input
+ placeholder={t('acl.examineClusterPlaceholder')}
+ prefix={<ShieldCheck size={14} color="#9CA3AF" />}
+ value={clusterIdInput}
+ onChange={(e) => setClusterIdInput(e.target.value)}
+ onPressEnter={handleExamine}
+ style={{ width: 260 }}
+ />
+ <Button
+ type="primary"
+ icon={<ShieldCheck size={14} weight="bold" />}
+ loading={configLoading}
+ onClick={handleExamine}
+ >
+ {t('acl.examine')}
+ </Button>
+ <Button icon={<Plus size={14} weight="bold" />}
onClick={openAddPlainModal}>
+ {t('acl.addPlainAccess')}
+ </Button>
+ </div>
+
+ {clusterConfig && (
+ <div style={{ paddingBottom: 16 }}>
+ {/* Summary cards */}
+ <div
+ style={{
+ display: 'flex',
+ gap: 12,
+ flexWrap: 'wrap',
+ marginBottom: 16,
+ }}
+ >
+ <Tag
+ color={clusterConfig.aclEnabled ? 'green' :
'default'}
+ style={{ fontSize: 13, padding: '4px 10px' }}
+ >
+ {clusterConfig.aclEnabled ? t('acl.aclEnabled') :
t('acl.aclDisabled')}
+ </Tag>
+ <Tag color="geekblue" style={{ fontSize: 13, padding:
'4px 10px' }}>
+ {clusterConfig.aclVersion}
+ </Tag>
+ <Tag style={{ fontSize: 13, padding: '4px 10px' }}>
+ {t('acl.accountCount')}: {clusterConfig.accountCount}
+ </Tag>
+ </div>
+
+ <div style={{ marginBottom: 12, color: '#8c8c8c',
fontSize: 13 }}>
+ {t('acl.globalWhitelist')}:{' '}
+ {clusterConfig.globalWhiteRemoteAddresses.length === 0
? (
+ <span>-</span>
+ ) : (
+ clusterConfig.globalWhiteRemoteAddresses.map((ip) =>
(
+ <Tag key={ip} color="cyan" style={{ fontSize: 11
}}>
+ {ip}
+ </Tag>
+ ))
+ )}
+ </div>
+
+ {/* Accounts table */}
+ <Table<PlainAccessConfig>
+ columns={plainColumns}
+ dataSource={clusterConfig.accounts}
+ rowKey="accessKey"
+ pagination={false}
+ size="small"
+ locale={{
+ emptyText: t('acl.noAccounts'),
+ }}
+ />
+ </div>
+ )}
+ </div>
+ ),
+ },
]}
/>
</Card>
@@ -923,6 +1213,97 @@ const AclPage = () => {
</Form.Item>
</Form>
</Modal>
+
+ {/* ─── Add/Edit Plain Access Config Modal ─── */}
+ <Modal
+ title={editingPlain ? t('acl.editPlainAccess') :
t('acl.addPlainAccess')}
+ open={plainModalOpen}
+ onCancel={() => setPlainModalOpen(false)}
+ onOk={handlePlainSubmit}
+ okText={editingPlain ? t('acl.save') : t('acl.add')}
+ cancelText={t('common.cancel')}
+ confirmLoading={plainSubmitting}
+ width={560}
+ destroyOnClose
+ >
+ <Form form={plainForm} layout="vertical" style={{ marginTop: 16 }}>
+ <Form.Item
+ name="accessKey"
+ label={t('acl.accessKey')}
+ rules={[
+ { required: true, message: t('acl.inputRequired', { field:
t('acl.accessKey') }) },
+ ]}
+ >
+ <Input
+ placeholder="e.g. user-order-service"
+ disabled={!!editingPlain}
+ prefix={<Key size={14} color="#9CA3AF" />}
+ />
+ </Form.Item>
+
+ <Form.Item
+ name="secretKey"
+ label={t('acl.secretKey')}
+ rules={[
+ {
+ required: !editingPlain,
+ message: t('acl.inputRequired', { field: t('acl.secretKey') }),
+ },
+ ]}
+ >
+ <Input.Password
+ placeholder={editingPlain ? t('acl.secretKeepUnchanged') :
t('acl.secretCreateHint')}
+ prefix={<Key size={14} color="#9CA3AF" />}
+ />
+ </Form.Item>
+
+ <Form.Item name="whiteRemoteAddress"
label={t('acl.whiteRemoteAddress')}>
+ <Input placeholder="e.g. 10.0.1.0/24" />
+ </Form.Item>
+
+ <Form.Item name="admin" label={t('acl.admin')}
valuePropName="checked">
+ <Switch checkedChildren={t('common.yes')}
unCheckedChildren={t('common.no')} />
+ </Form.Item>
+
+ <Form.Item name="defaultTopicPerm" label={t('acl.defaultTopicPerm')}>
+ <Select
+ options={[
+ { value: 'DENY', label: 'DENY' },
+ { value: 'PUB', label: 'PUB' },
+ { value: 'SUB', label: 'SUB' },
+ { value: 'ALL', label: 'ALL' },
+ ]}
+ />
+ </Form.Item>
+
+ <Form.Item name="defaultGroupPerm" label={t('acl.defaultGroupPerm')}>
+ <Select
+ options={[
+ { value: 'DENY', label: 'DENY' },
+ { value: 'PUB', label: 'PUB' },
+ { value: 'SUB', label: 'SUB' },
+ { value: 'ALL', label: 'ALL' },
+ ]}
+ />
+ </Form.Item>
+
+ <Form.Item name="topicPerms" label={t('acl.topicPerms')}>
+ <Select
+ mode="tags"
+ tokenSeparators={[',']}
+ placeholder={t('acl.topicPermsPlaceholder')}
+ />
+ </Form.Item>
+
+ <Form.Item name="groupPerms" label={t('acl.groupPerms')}>
+ <Select
+ mode="tags"
+ tokenSeparators={[',']}
+ placeholder={t('acl.groupPermsPlaceholder')}
+ />
+ </Form.Item>
+ </Form>
+ </Modal>
</div>
);
};
diff --git a/web/src/pages/instance/dlq.tsx b/web/src/pages/instance/dlq.tsx
index 353d9c2f..91f990d8 100644
--- a/web/src/pages/instance/dlq.tsx
+++ b/web/src/pages/instance/dlq.tsx
@@ -122,13 +122,16 @@ const DLQPage = () => {
const [loadError, setLoadError] = useState<string | null>(null);
const [retryError, setRetryError] = useState<string | null>(null);
- useEffect(() => {
- let cancelled = false;
-
- // The retry dialog owns a group name that is meaningful only for the
- // currently selected instance. Clear all instance-scoped state before
- // starting the next request so an old group cannot be retried on a new
- // instance while that request is in flight.
+ // The retry dialog owns a group name that is meaningful only for the
+ // currently selected instance. Clear all instance-scoped state before
+ // starting the next request so an old group cannot be retried on a new
+ // instance while that request is in flight. Done as render-time state
+ // adjustment (rather than inside the load effect) so state is reset
+ // before the next fetch without cascading effect renders.
+ const scopeKey = `${selectedInstanceId}:${refreshKey}`;
+ const [prevScopeKey, setPrevScopeKey] = useState(scopeKey);
+ if (prevScopeKey !== scopeKey) {
+ setPrevScopeKey(scopeKey);
setGroups([]);
setSelectedGroupNames([]);
setDetailGroup(null);
@@ -137,6 +140,11 @@ const DLQPage = () => {
setRetryTargetTopic('');
setRetryError(null);
setLoadError(null);
+ setLoading(true);
+ }
+
+ useEffect(() => {
+ let cancelled = false;
if (!selectedInstanceId) {
void Promise.resolve().then(() => {
@@ -148,7 +156,6 @@ const DLQPage = () => {
};
}
- setLoading(true);
void listDLQGroups(selectedInstanceId)
.then((nextGroups) => {
if (!cancelled) {
diff --git a/web/src/pages/studio/Ops.tsx b/web/src/pages/studio/Ops.tsx
index d042f1d2..bd7f3fba 100644
--- a/web/src/pages/studio/Ops.tsx
+++ b/web/src/pages/studio/Ops.tsx
@@ -16,7 +16,18 @@
*/
import React, { useEffect, useRef, useState } from 'react';
-import { Alert, App, Button, Input, Popconfirm, Select, Space, Switch,
Tooltip, Typography } from 'antd';
+import {
+ Alert,
+ App,
+ Button,
+ Input,
+ Popconfirm,
+ Select,
+ Space,
+ Switch,
+ Tooltip,
+ Typography,
+} from 'antd';
import { FloppyDisk, Plus, Trash } from '@phosphor-icons/react';
import { useLang } from '../../i18n/LangContext';
import useAuthStore from '../../stores/authStore';
diff --git a/web/src/pages/studio/__tests__/BrokerCluster.test.tsx
b/web/src/pages/studio/__tests__/BrokerCluster.test.tsx
index 9eb034a0..5bb1aa48 100644
--- a/web/src/pages/studio/__tests__/BrokerCluster.test.tsx
+++ b/web/src/pages/studio/__tests__/BrokerCluster.test.tsx
@@ -295,15 +295,16 @@ describe('BrokerCluster Page', () => {
expect(screen.getByText('[2001:db8::10]:8081')).toBeInTheDocument();
});
it('renders unrecognized broker statuses as unavailable instead of running',
async () => {
- vi.mocked(listClusters).mockResolvedValue([{
- ...clusterFixture[0],
- brokers: [{ ...clusterFixture[0].brokers[0], status: 'mystery' }],
- }]);
+ vi.mocked(listClusters).mockResolvedValue([
+ {
+ ...clusterFixture[0],
+ brokers: [{ ...clusterFixture[0].brokers[0], status: 'mystery' }],
+ },
+ ]);
renderWithProviders(<BrokerCluster />);
await screen.findByText('broker-api-a');
expect(screen.getByText('N/A')).toBeInTheDocument();
expect(screen.queryByText('运行中')).not.toBeInTheDocument();
});
-
});
diff --git a/web/src/pages/studio/__tests__/Proxy.test.tsx
b/web/src/pages/studio/__tests__/Proxy.test.tsx
index f67552f9..c5d0f2b6 100644
--- a/web/src/pages/studio/__tests__/Proxy.test.tsx
+++ b/web/src/pages/studio/__tests__/Proxy.test.tsx
@@ -141,15 +141,16 @@ describe('ProxyPage', () => {
const refresh = screen.getByRole('button', { name: '刷新' });
await user.click(refresh);
await user.click(refresh);
- await act(async () => latest.resolve({
- proxyAddrList: ['127.0.0.2:8081'],
- currentProxyAddr: '127.0.0.2:8081',
- }));
+ await act(async () =>
+ latest.resolve({
+ proxyAddrList: ['127.0.0.2:8081'],
+ currentProxyAddr: '127.0.0.2:8081',
+ }),
+ );
expect(await screen.findByText('127.0.0.2:8081')).toBeInTheDocument();
await act(async () => older.resolve(proxyHome));
expect(screen.getByText('127.0.0.2:8081')).toBeInTheDocument();
expect(screen.queryByText('127.0.0.1:8081')).not.toBeInTheDocument();
});
-
});
diff --git a/web/src/services/aclService.test.ts
b/web/src/services/aclService.test.ts
index feaee9e3..df01d70f 100644
--- a/web/src/services/aclService.test.ts
+++ b/web/src/services/aclService.test.ts
@@ -19,6 +19,8 @@ import { describe, expect, it, vi } from 'vitest';
import {
createAclRule,
createAclUser,
+ createAndUpdatePlainAccessConfig,
+ examineBrokerClusterAclConfig,
listAclRules,
listAclUsers,
updateAclRule,
@@ -99,4 +101,48 @@ describe('ACL service mock data', () => {
const afterUpdate = await listAclUsers({ keyword: 'user-created-copy-test'
});
expect(afterUpdate[0].clusters).toEqual(['rmq-updated']);
});
+
+ it('builds cluster ACL config from mock accounts', async () => {
+ const config = await examineBrokerClusterAclConfig('DefaultCluster');
+ expect(config.aclEnabled).toBe(true);
+ expect(config.aclVersion).toBe('ACL 2.0');
+ expect(config.globalWhiteRemoteAddresses).toContain('192.168.0.0/16');
+ expect(config.accountCount).toBe(config.accounts.length);
+ expect(config.accounts[0].accessKey).toBe('user-admin');
+ });
+
+ it('creates and updates a plain access account in mock state', async () => {
+ const created = await createAndUpdatePlainAccessConfig({
+ accessKey: 'svc-mock',
+ secretKey: 'svc-mock-secret-value',
+ admin: false,
+ defaultTopicPerm: 'PUB',
+ topicPerms: ['a=PUB'],
+ });
+ expect(created.accessKey).toBe('svc-mock');
+ // The secret is echoed only when it was just provided.
+ expect(created.secretKey).toBe('svc-mock-secret-value');
+
+ const updated = await createAndUpdatePlainAccessConfig({
+ accessKey: 'svc-mock',
+ admin: true,
+ defaultTopicPerm: 'ALL',
+ });
+ expect(updated.admin).toBe(true);
+ // A blank secret keeps the stored one and is not echoed back.
+ expect(updated.secretKey).toBeNull();
+
+ const config = await examineBrokerClusterAclConfig('c');
+ const account = config.accounts.find((a) => a.accessKey === 'svc-mock');
+ expect(account && account.admin).toBe(true);
+ // Read-back views mask the secret instead of exposing the plaintext.
+ expect(account?.secretKey).not.toBe('svc-mock-secret-value');
+ expect(account?.secretKey).toContain('****');
+ });
+
+ it('rejects a new plain access account without a secret', async () => {
+ await expect(createAndUpdatePlainAccessConfig({ accessKey: 'svc-no-secret'
})).rejects.toThrow(
+ 'secretKey is required',
+ );
+ });
});
diff --git a/web/src/services/aclService.ts b/web/src/services/aclService.ts
index 13fc55e8..34e85ea7 100644
--- a/web/src/services/aclService.ts
+++ b/web/src/services/aclService.ts
@@ -1,10 +1,27 @@
import { isMockMode } from './dataMode';
import * as aclApi from '../api/acl';
-import type { AclRule, AclRuleQuery, AclUser } from '../api/acl';
+import type {
+ AclRule,
+ AclRuleQuery,
+ AclUser,
+ AclClusterConfig,
+ PlainAccessConfig,
+} from '../api/acl';
import { aclRules as mockRules, aclUsers as mockUsers } from '../mock/acl';
const aclRulesState = mockRules as unknown as AclRule[];
const aclUsersState = mockUsers as unknown as AclUser[];
+const aclPlainAccessState = (mockUsers as unknown as AclUser[]).map((u):
PlainAccessConfig => ({
+ accessKey: u.username,
+ secretKey: u.secretKey,
+ whiteRemoteAddress: '',
+ admin: u.admin,
+ defaultTopicPerm: 'DENY',
+ defaultGroupPerm: 'DENY',
+ topicPerms: u.admin ? ['*=ALL'] : [],
+ groupPerms: u.admin ? ['*=ALL'] : [],
+ createdAt: u.createdAt,
+}));
function copyAclRule(rule: AclRule): AclRule {
return {
@@ -137,3 +154,65 @@ export async function deleteAclUser(id: string):
Promise<void> {
}
return aclApi.deleteAclUser(id);
}
+
+/* ═══════════════════════════════════════════
+ ACL 2.0: cluster config & plain access
+ ═════════════════════════════════════════ */
+
+function maskCredential(value: string | null | undefined): string | null {
+ if (!value) return null;
+ if (value.length < 17) return '****';
+ return `${value.slice(0, 4)}****${value.slice(-4)}`;
+}
+
+export async function examineBrokerClusterAclConfig(clusterId: string):
Promise<AclClusterConfig> {
+ if (isMockMode()) {
+ // Read-back views only carry masked secrets, mirroring the backend
contract.
+ const accounts = aclPlainAccessState.map((a) => ({
+ ...a,
+ secretKey: maskCredential(a.secretKey),
+ }));
+ return {
+ clusterId,
+ aclEnabled: true,
+ aclVersion: 'ACL 2.0',
+ globalWhiteRemoteAddresses: ['192.168.0.0/16', '10.0.0.0/8'],
+ accounts,
+ accountCount: accounts.length,
+ };
+ }
+ return aclApi.examineBrokerClusterAclConfig(clusterId);
+}
+
+export async function createAndUpdatePlainAccessConfig(
+ data: Partial<PlainAccessConfig>,
+): Promise<PlainAccessConfig> {
+ if (isMockMode()) {
+ const accessKey = (data.accessKey ?? '').trim();
+ const providedSecret = (data.secretKey ?? '').trim();
+ const existing = aclPlainAccessState.find((a) => a.accessKey ===
accessKey);
+ if (!providedSecret && !existing) {
+ throw new Error('secretKey is required for a new plain access account');
+ }
+ // A blank secret on an existing account keeps the stored secret unchanged.
+ const storedSecret = providedSecret || existing?.secretKey || '';
+ const saved: PlainAccessConfig = {
+ accessKey,
+ secretKey: providedSecret || null,
+ whiteRemoteAddress: data.whiteRemoteAddress ??
existing?.whiteRemoteAddress ?? '',
+ admin: data.admin ?? existing?.admin ?? false,
+ defaultTopicPerm: data.defaultTopicPerm ?? existing?.defaultTopicPerm ??
'DENY',
+ defaultGroupPerm: data.defaultGroupPerm ?? existing?.defaultGroupPerm ??
'DENY',
+ topicPerms: [...(data.topicPerms ?? existing?.topicPerms ?? [])],
+ groupPerms: [...(data.groupPerms ?? existing?.groupPerms ?? [])],
+ createdAt: existing?.createdAt ?? new Date().toISOString(),
+ };
+ if (existing) {
+ Object.assign(existing, saved, { secretKey: storedSecret });
+ } else {
+ aclPlainAccessState.push({ ...saved, secretKey: storedSecret });
+ }
+ return { ...saved };
+ }
+ return aclApi.createAndUpdatePlainAccessConfig(data);
+}