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);
+}

Reply via email to