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 e4321d06 feat: persist ACL rules and users with credential reveal 
(#952)
e4321d06 is described below

commit e4321d06dc4dbe5254317ec78cf8e93c1f08142c
Author: lizhimins <[email protected]>
AuthorDate: Tue Aug 4 15:46:26 2026 +0800

    feat: persist ACL rules and users with credential reveal (#952)
    
    Add the rmq_acl_rule / rmq_acl_user tables (demo seed: 13 rules, 8
    users) behind a MyBatis-Plus repository, replacing the in-memory ACL
    store. User secret keys are stored base64-encoded and a new
    GET /api/acl/users/{id}/credentials endpoint returns the decoded pair
    on demand, so the user table can show a masked Secret Key column with
    an eye toggle to reveal and copy it. Drop the namespace scope option
    from rule creation.
---
 deploy/mysql/upgrade-demo-acl.sql                  |  73 ++++++++
 .../studio/instance/acl/AclController.java         |   6 +
 .../rocketmq/studio/instance/acl/AclRuleVO.java    |   2 +-
 .../rocketmq/studio/instance/acl/AclService.java   |  13 ++
 .../studio/instance/acl/InMemoryAclRepository.java | 104 -----------
 .../instance/acl/MybatisPlusAclRepository.java     | 204 +++++++++++++++++++++
 .../entity/RmqAclRule.java}                        |  32 ++--
 .../entity/RmqAclUser.java}                        |  38 ++--
 .../mapper/RmqAclRuleMapper.java}                  |  27 +--
 .../mapper/RmqAclUserMapper.java}                  |  27 +--
 server/src/main/resources/db/schema.sql            |  61 ++++++
 .../studio/instance/acl/AclServiceTest.java        |  67 ++++---
 web/src/api/acl.ts                                 |   7 +
 web/src/pages/instance/__tests__/AclPage.test.tsx  |   8 +-
 web/src/pages/instance/acl.tsx                     |  72 +++++++-
 web/src/services/aclService.ts                     |  11 +-
 16 files changed, 539 insertions(+), 213 deletions(-)

diff --git a/deploy/mysql/upgrade-demo-acl.sql 
b/deploy/mysql/upgrade-demo-acl.sql
new file mode 100644
index 00000000..75005217
--- /dev/null
+++ b/deploy/mysql/upgrade-demo-acl.sql
@@ -0,0 +1,73 @@
+-- deploy/mysql/upgrade-demo-acl.sql
+-- 存量 MySQL 数据卷增量迁移(2026-08-03):ACL 规则/用户入库
+-- 适用:数据卷已初始化、docker-entrypoint-initdb.d 不会再执行的存量部署。
+-- 全新数据卷由 server/src/main/resources/db/schema.sql 直接覆盖,无需本脚本。
+-- 幂等:可重复执行。
+--
+-- 用法(远程容器内执行):
+--   docker exec -i rocketmq-studio-mysql mysql -uroot -pstudio123 
rocketmq_studio < upgrade-demo-acl.sql
+
+-- 固定连接编码,防止 mysql 客户端以 latin1 解释 UTF-8 字节导致中文双重编码
+SET NAMES utf8mb4;
+
+-- 1. ACL 规则表
+CREATE TABLE IF NOT EXISTS rmq_acl_rule (
+  id VARCHAR(64) PRIMARY KEY,
+  principal VARCHAR(128) NOT NULL,
+  resource VARCHAR(255) NOT NULL,
+  resource_type VARCHAR(32) COMMENT 'Topic/Group/Cluster',
+  resource_pattern VARCHAR(32) COMMENT 'LITERAL/PREFIX',
+  actions VARCHAR(128) COMMENT '逗号分隔:PUB/SUB/ALL',
+  decision VARCHAR(16) COMMENT 'ALLOW/DENY',
+  scope VARCHAR(64) COMMENT '生效范围(集群名/实例 id)',
+  acl_version VARCHAR(16) COMMENT '1.0/2.0',
+  created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
+  updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
+  INDEX idx_principal (principal)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
+
+-- 2. ACL 用户表(secret_key 为 base64 编码后的密码,禁止明文存储)
+CREATE TABLE IF NOT EXISTS rmq_acl_user (
+  id VARCHAR(64) PRIMARY KEY,
+  username VARCHAR(128) NOT NULL,
+  access_key VARCHAR(255) NOT NULL,
+  secret_key VARCHAR(512) NOT NULL COMMENT 'base64 编码的密码',
+  admin TINYINT(1) DEFAULT 0,
+  clusters VARCHAR(1024) COMMENT '逗号分隔的集群/实例 id',
+  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;
+
+-- 3. 规则种子(与 schema.sql 一致)
+INSERT IGNORE INTO rmq_acl_rule
+  (id, principal, resource, resource_type, resource_pattern, actions, 
decision, scope, acl_version)
+VALUES
+  ('acl-001', 'user-order-service',         'order_*',                 
'Topic',   'PREFIX',  'PUB,SUB', 'ALLOW', 'instance-proxy-1',  '2.0'),
+  ('acl-002', 'user-payment-service',       'payment_*',               
'Topic',   'PREFIX',  'PUB,SUB', 'ALLOW', 'instance-proxy-1',  '2.0'),
+  ('acl-003', 'user-admin',                 '*',                       
'Cluster', 'LITERAL', 'ALL',     'ALLOW', 'instance-proxy-1',  '2.0'),
+  ('acl-004', 'user-log-collector',         'audit_operation_log',     
'Topic',   'LITERAL', 'SUB',     'ALLOW', 'instance-direct-2', '1.0'),
+  ('acl-005', 'user-order-service',         'GID_fulfillment_*',       
'Group',   'PREFIX',  'SUB',     'ALLOW', 'instance-proxy-1',  '2.0'),
+  ('acl-006', 'user-inventory-service',     
'inventory_deduct_command','Topic',   'LITERAL', 'PUB,SUB', 'ALLOW', 
'instance-proxy-1',  '2.0'),
+  ('acl-007', 'user-guest',                 'payment_result_notify',   
'Topic',   'LITERAL', 'PUB,SUB', 'DENY',  'instance-proxy-1',  '1.0'),
+  ('acl-008', 'user-notification-service',  'sms_send_command',        
'Topic',   'LITERAL', 'PUB',     'ALLOW', 'instance-proxy-2',  '2.0'),
+  ('acl-009', 'user-risk-control',          'risk_event_alert',        
'Topic',   'LITERAL', 'SUB',     'ALLOW', 'instance-direct-2', '1.0'),
+  ('acl-010', 'user-guest',                 '*',                       
'Cluster', 'LITERAL', 'PUB',     'DENY',  'instance-proxy-2',  '2.0'),
+  ('acl-011', 'user-payment-service',       'GID_payment_*',           
'Group',   'PREFIX',  'SUB',     'ALLOW', 'instance-proxy-1',  '2.0'),
+  ('acl-012', 'user-monitor',               'user_behavior_log',       
'Topic',   'LITERAL', 'SUB',     'ALLOW', 'instance-proxy-3',  '1.0'),
+  ('acl-013', 'user-ai-service',            'click_stream_etl',        
'Topic',   'LITERAL', 'PUB,SUB', 'ALLOW', 'instance-proxy-3',  '2.0');
+
+-- 4. 用户种子(secret_key 为密码的 base64 编码)
+INSERT IGNORE INTO rmq_acl_user
+  (id, username, access_key, secret_key, admin, clusters)
+VALUES
+  ('u-001', 'user-admin',                 'AKSTUDIOadmin0001', 
'QWRtaW5AU3R1ZGlvIzIwMjY=',     1,
+   
'instance-proxy-1,instance-proxy-2,instance-proxy-3,instance-direct-1,instance-direct-2'),
+  ('u-002', 'user-order-service',         'AKSTUDIOordr0002',  
'T3JkZXJTdmNAMjAyNiNQcm9k',     0, 'instance-proxy-1'),
+  ('u-003', 'user-payment-service',       'AKSTUDIOpaym0003',  
'UGF5U3ZjQDIwMjYjUHJvZA==',     0, 'instance-proxy-1'),
+  ('u-004', 'user-log-collector',         'AKSTUDIOlogs0004',  
'TG9nQ29sbGVjdEAyMDI2I09wcw==', 0, 'instance-direct-2'),
+  ('u-005', 'user-guest',                 'AKSTUDIOgues0005',  
'R3Vlc3RAMjAyNiNSZWFk',         0, 'instance-proxy-2'),
+  ('u-006', 'user-inventory-service',     'AKSTUDIOinvn0006',  
'SW52U3ZjQDIwMjYjUHJvZA==',     0, 'instance-proxy-1'),
+  ('u-007', 'user-notification-service',  'AKSTUDIONtfy0007',  
'Tm90aWZ5U3ZjQDIwMjYjTXNn',     0, 'instance-proxy-2'),
+  ('u-008', 'user-monitor',               'AKSTUDIOmonr0008',  
'TW9uaXRvckAyMDI2I09icw==',     0,
+   'instance-proxy-3,instance-direct-2');
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 466c0b11..1acb7a3a 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
@@ -20,6 +20,7 @@ import org.apache.rocketmq.studio.common.domain.Result;
 import jakarta.validation.Valid;
 import lombok.RequiredArgsConstructor;
 import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.PathVariable;
 import org.springframework.web.bind.annotation.PostMapping;
 import org.springframework.web.bind.annotation.RequestBody;
 import org.springframework.web.bind.annotation.RequestMapping;
@@ -63,6 +64,11 @@ public class AclController {
         return Result.ok(aclService.listUsers());
     }
 
+    @GetMapping("/users/{id}/credentials")
+    public Result<AclUserVO> getUserCredentials(@PathVariable String id) {
+        return Result.ok(aclService.getUserCredentials(id));
+    }
+
     @PostMapping("/users/create")
     public Result<AclUserVO> createUser(@RequestBody AclUserVO user) {
         return Result.ok(aclService.createUser(user));
diff --git 
a/server/src/main/java/org/apache/rocketmq/studio/instance/acl/AclRuleVO.java 
b/server/src/main/java/org/apache/rocketmq/studio/instance/acl/AclRuleVO.java
index 74a82884..f95c6b2f 100644
--- 
a/server/src/main/java/org/apache/rocketmq/studio/instance/acl/AclRuleVO.java
+++ 
b/server/src/main/java/org/apache/rocketmq/studio/instance/acl/AclRuleVO.java
@@ -37,6 +37,6 @@ public class AclRuleVO {
     private List<String> actions;
     private String decision;
     private String scope;
-    private Integer aclVersion;
+    private String aclVersion;
     private LocalDateTime createdAt;
 }
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 3fcec4d3..87bb4f43 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
@@ -115,6 +115,19 @@ public class AclService {
         aclRepository.deleteUser(id);
     }
 
+    /**
+     * 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.
+     */
+    public AclUserVO getUserCredentials(String id) {
+        if (isBlank(id)) {
+            throw new BusinessException(400, "ACL user id is required");
+        }
+        log.info("Revealing credentials for ACL user id={}", id);
+        return aclRepository.findUserById(id)
+                .orElseThrow(() -> new BusinessException(404, "ACL user not 
found: " + id));
+    }
+
     private boolean isBlank(String value) {
         return value == null || value.trim().isEmpty();
     }
diff --git 
a/server/src/main/java/org/apache/rocketmq/studio/instance/acl/InMemoryAclRepository.java
 
b/server/src/main/java/org/apache/rocketmq/studio/instance/acl/InMemoryAclRepository.java
deleted file mode 100644
index efe6e8f6..00000000
--- 
a/server/src/main/java/org/apache/rocketmq/studio/instance/acl/InMemoryAclRepository.java
+++ /dev/null
@@ -1,104 +0,0 @@
-/*
- * 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.extern.slf4j.Slf4j;
-import org.springframework.stereotype.Component;
-
-import java.time.LocalDateTime;
-import java.util.ArrayList;
-import java.util.List;
-import java.util.Map;
-import java.util.Optional;
-import java.util.concurrent.ConcurrentHashMap;
-import java.util.stream.Collectors;
-
-@Slf4j
-@Component
-public class InMemoryAclRepository implements AclRepository {
-
-    private final Map<String, AclRuleVO> rules = new ConcurrentHashMap<>();
-    private final Map<String, AclUserVO> users = new ConcurrentHashMap<>();
-
-    @Override
-    public List<AclRuleVO> findRules(String clusterId, String principal) {
-        return rules.values().stream()
-                .filter(r -> clusterId == null || 
clusterId.equals(r.getScope()))
-                .filter(r -> principal == null || 
principal.equals(r.getPrincipal()))
-                .collect(Collectors.toList());
-    }
-
-    @Override
-    public AclRuleVO saveRule(AclRuleVO rule) {
-        rules.put(rule.getId(), rule);
-        log.debug("Saved ACL rule id={}", rule.getId());
-        return rule;
-    }
-
-    @Override
-    public Optional<AclRuleVO> replaceRule(AclRuleVO rule) {
-        String id = rule.getId();
-        AclRuleVO replacedRule = rules.computeIfPresent(id,
-                (key, existingRule) -> copyRule(rule, key, 
existingRule.getCreatedAt()));
-        log.debug("Replaced ACL rule id={}, replaced={}", id, replacedRule != 
null);
-        return Optional.ofNullable(replacedRule);
-    }
-
-    private AclRuleVO copyRule(AclRuleVO rule, String id, LocalDateTime 
createdAt) {
-        return AclRuleVO.builder()
-                .id(id)
-                .principal(rule.getPrincipal())
-                .resource(rule.getResource())
-                .resourceType(rule.getResourceType())
-                .resourcePattern(rule.getResourcePattern())
-                .actions(rule.getActions() == null ? null : new 
ArrayList<>(rule.getActions()))
-                .decision(rule.getDecision())
-                .scope(rule.getScope())
-                .aclVersion(rule.getAclVersion())
-                .createdAt(createdAt)
-                .build();
-    }
-
-    @Override
-    public void deleteRule(String id) {
-        rules.remove(id);
-        log.debug("Deleted ACL rule id={}", id);
-    }
-
-    @Override
-    public List<AclUserVO> findUsers() {
-        return new ArrayList<>(users.values());
-    }
-
-    @Override
-    public Optional<AclUserVO> findUserById(String id) {
-        return Optional.ofNullable(users.get(id));
-    }
-
-    @Override
-    public AclUserVO saveUser(AclUserVO user) {
-        users.put(user.getId(), user);
-        log.debug("Saved ACL user id={}", user.getId());
-        return user;
-    }
-
-    @Override
-    public void deleteUser(String id) {
-        users.remove(id);
-        log.debug("Deleted ACL user id={}", id);
-    }
-}
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
new file mode 100644
index 00000000..9ebef953
--- /dev/null
+++ 
b/server/src/main/java/org/apache/rocketmq/studio/instance/acl/MybatisPlusAclRepository.java
@@ -0,0 +1,204 @@
+/*
+ * 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.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 java.nio.charset.StandardCharsets;
+import java.time.LocalDateTime;
+import java.util.Arrays;
+import java.util.Base64;
+import java.util.List;
+import java.util.Optional;
+import java.util.stream.Collectors;
+
+/**
+ * MySQL-backed ACL repository. User passwords are stored base64-encoded in
+ * {@code rmq_acl_user.secret_key} and decoded when read; plain text is never 
persisted.
+ */
+@Repository
+public class MybatisPlusAclRepository implements AclRepository {
+
+    private final RmqAclRuleMapper ruleMapper;
+    private final RmqAclUserMapper userMapper;
+
+    public MybatisPlusAclRepository(RmqAclRuleMapper ruleMapper, 
RmqAclUserMapper userMapper) {
+        this.ruleMapper = ruleMapper;
+        this.userMapper = userMapper;
+    }
+
+    @Override
+    public List<AclRuleVO> findRules(String clusterId, String principal) {
+        QueryWrapper<RmqAclRule> query = new QueryWrapper<RmqAclRule>()
+                .eq(clusterId != null && !clusterId.isBlank(), "scope", 
clusterId)
+                .eq(principal != null && !principal.isBlank(), "principal", 
principal)
+                .orderByAsc("id");
+        return ruleMapper.selectList(query).stream()
+                .map(MybatisPlusAclRepository::toRuleVO)
+                .collect(Collectors.toList());
+    }
+
+    @Override
+    public AclRuleVO saveRule(AclRuleVO rule) {
+        RmqAclRule entity = toRuleEntity(rule);
+        if (ruleMapper.selectById(entity.getId()) != null) {
+            ruleMapper.updateById(entity);
+        } else {
+            ruleMapper.insert(entity);
+        }
+        return rule;
+    }
+
+    @Override
+    public Optional<AclRuleVO> replaceRule(AclRuleVO rule) {
+        RmqAclRule existing = ruleMapper.selectById(rule.getId());
+        if (existing == null) {
+            return Optional.empty();
+        }
+        RmqAclRule entity = toRuleEntity(rule);
+        entity.setCreatedAt(existing.getCreatedAt());
+        ruleMapper.updateById(entity);
+        rule.setCreatedAt(existing.getCreatedAt());
+        return Optional.of(rule);
+    }
+
+    @Override
+    public void deleteRule(String id) {
+        ruleMapper.deleteById(id);
+    }
+
+    @Override
+    public List<AclUserVO> findUsers() {
+        return userMapper.selectList(new 
QueryWrapper<RmqAclUser>().orderByAsc("id")).stream()
+                .map(MybatisPlusAclRepository::toUserVO)
+                .collect(Collectors.toList());
+    }
+
+    @Override
+    public Optional<AclUserVO> findUserById(String id) {
+        RmqAclUser entity = userMapper.selectById(id);
+        return 
Optional.ofNullable(entity).map(MybatisPlusAclRepository::toUserVO);
+    }
+
+    @Override
+    public AclUserVO saveUser(AclUserVO user) {
+        RmqAclUser entity = toUserEntity(user);
+        if (userMapper.selectById(entity.getId()) != null) {
+            userMapper.updateById(entity);
+        } else {
+            userMapper.insert(entity);
+        }
+        return user;
+    }
+
+    @Override
+    public void deleteUser(String id) {
+        userMapper.deleteById(id);
+    }
+
+    // ── Mapping ────────────────────────────────────────────────────
+
+    private static AclRuleVO toRuleVO(RmqAclRule entity) {
+        return AclRuleVO.builder()
+                .id(entity.getId())
+                .principal(entity.getPrincipal())
+                .resource(entity.getResource())
+                .resourceType(entity.getResourceType())
+                .resourcePattern(entity.getResourcePattern())
+                .actions(splitCsv(entity.getActions()))
+                .decision(entity.getDecision())
+                .scope(entity.getScope())
+                .aclVersion(entity.getAclVersion())
+                .createdAt(entity.getCreatedAt())
+                .build();
+    }
+
+    private static RmqAclRule toRuleEntity(AclRuleVO rule) {
+        RmqAclRule entity = new RmqAclRule();
+        entity.setId(rule.getId());
+        entity.setPrincipal(rule.getPrincipal());
+        entity.setResource(rule.getResource());
+        entity.setResourceType(rule.getResourceType());
+        entity.setResourcePattern(rule.getResourcePattern());
+        entity.setActions(rule.getActions() == null ? null : String.join(",", 
rule.getActions()));
+        entity.setDecision(rule.getDecision());
+        entity.setScope(rule.getScope());
+        entity.setAclVersion(rule.getAclVersion());
+        entity.setCreatedAt(rule.getCreatedAt());
+        entity.setUpdatedAt(LocalDateTime.now());
+        return entity;
+    }
+
+    private static AclUserVO toUserVO(RmqAclUser entity) {
+        return AclUserVO.builder()
+                .id(entity.getId())
+                .username(entity.getUsername())
+                .accessKey(entity.getAccessKey())
+                .secretKey(decodeBase64(entity.getSecretKey()))
+                .admin(Boolean.TRUE.equals(entity.getAdmin()))
+                .clusters(splitCsv(entity.getClusters()))
+                .createdAt(entity.getCreatedAt())
+                .build();
+    }
+
+    private static RmqAclUser toUserEntity(AclUserVO user) {
+        RmqAclUser entity = new RmqAclUser();
+        entity.setId(user.getId());
+        entity.setUsername(user.getUsername());
+        entity.setAccessKey(user.getAccessKey());
+        entity.setSecretKey(encodeBase64(user.getSecretKey()));
+        entity.setAdmin(user.isAdmin());
+        entity.setClusters(user.getClusters() == null ? null : 
String.join(",", user.getClusters()));
+        entity.setCreatedAt(user.getCreatedAt());
+        entity.setUpdatedAt(LocalDateTime.now());
+        return entity;
+    }
+
+    private static List<String> splitCsv(String value) {
+        if (value == null || value.isBlank()) {
+            return List.of();
+        }
+        return Arrays.stream(value.split(","))
+                .map(String::trim)
+                .filter(part -> !part.isEmpty())
+                .collect(Collectors.toList());
+    }
+
+    private static String encodeBase64(String plainText) {
+        if (plainText == null) {
+            return null;
+        }
+        return 
Base64.getEncoder().encodeToString(plainText.getBytes(StandardCharsets.UTF_8));
+    }
+
+    private static String decodeBase64(String stored) {
+        if (stored == null) {
+            return null;
+        }
+        try {
+            return new String(Base64.getDecoder().decode(stored), 
StandardCharsets.UTF_8);
+        } catch (IllegalArgumentException ex) {
+            // tolerate legacy values that were stored without encoding
+            return stored;
+        }
+    }
+}
diff --git 
a/server/src/main/java/org/apache/rocketmq/studio/instance/acl/AclRuleVO.java 
b/server/src/main/java/org/apache/rocketmq/studio/persistence/entity/RmqAclRule.java
similarity index 73%
copy from 
server/src/main/java/org/apache/rocketmq/studio/instance/acl/AclRuleVO.java
copy to 
server/src/main/java/org/apache/rocketmq/studio/persistence/entity/RmqAclRule.java
index 74a82884..e6ef0ac4 100644
--- 
a/server/src/main/java/org/apache/rocketmq/studio/instance/acl/AclRuleVO.java
+++ 
b/server/src/main/java/org/apache/rocketmq/studio/persistence/entity/RmqAclRule.java
@@ -14,29 +14,39 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-package org.apache.rocketmq.studio.instance.acl;
+package org.apache.rocketmq.studio.persistence.entity;
 
-import lombok.AllArgsConstructor;
-import lombok.Builder;
+import com.baomidou.mybatisplus.annotation.IdType;
+import com.baomidou.mybatisplus.annotation.TableId;
+import com.baomidou.mybatisplus.annotation.TableName;
 import lombok.Data;
-import lombok.NoArgsConstructor;
 
 import java.time.LocalDateTime;
-import java.util.List;
 
 @Data
-@Builder
-@NoArgsConstructor
-@AllArgsConstructor
-public class AclRuleVO {
+@TableName("rmq_acl_rule")
+public class RmqAclRule {
+
+    @TableId(type = IdType.INPUT)
     private String id;
+
     private String principal;
+
     private String resource;
+
     private String resourceType;
+
     private String resourcePattern;
-    private List<String> actions;
+
+    private String actions;
+
     private String decision;
+
     private String scope;
-    private Integer aclVersion;
+
+    private String aclVersion;
+
     private LocalDateTime createdAt;
+
+    private LocalDateTime updatedAt;
 }
diff --git 
a/server/src/main/java/org/apache/rocketmq/studio/instance/acl/AclRuleVO.java 
b/server/src/main/java/org/apache/rocketmq/studio/persistence/entity/RmqAclUser.java
similarity index 65%
copy from 
server/src/main/java/org/apache/rocketmq/studio/instance/acl/AclRuleVO.java
copy to 
server/src/main/java/org/apache/rocketmq/studio/persistence/entity/RmqAclUser.java
index 74a82884..6bd13852 100644
--- 
a/server/src/main/java/org/apache/rocketmq/studio/instance/acl/AclRuleVO.java
+++ 
b/server/src/main/java/org/apache/rocketmq/studio/persistence/entity/RmqAclUser.java
@@ -14,29 +14,33 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-package org.apache.rocketmq.studio.instance.acl;
+package org.apache.rocketmq.studio.persistence.entity;
 
-import lombok.AllArgsConstructor;
-import lombok.Builder;
+import com.baomidou.mybatisplus.annotation.IdType;
+import com.baomidou.mybatisplus.annotation.TableId;
+import com.baomidou.mybatisplus.annotation.TableName;
 import lombok.Data;
-import lombok.NoArgsConstructor;
 
 import java.time.LocalDateTime;
-import java.util.List;
 
 @Data
-@Builder
-@NoArgsConstructor
-@AllArgsConstructor
-public class AclRuleVO {
+@TableName("rmq_acl_user")
+public class RmqAclUser {
+
+    @TableId(type = IdType.INPUT)
     private String id;
-    private String principal;
-    private String resource;
-    private String resourceType;
-    private String resourcePattern;
-    private List<String> actions;
-    private String decision;
-    private String scope;
-    private Integer aclVersion;
+
+    private String username;
+
+    private String accessKey;
+
+    private String secretKey;
+
+    private Boolean admin;
+
+    private String clusters;
+
     private LocalDateTime createdAt;
+
+    private LocalDateTime updatedAt;
 }
diff --git 
a/server/src/main/java/org/apache/rocketmq/studio/instance/acl/AclRuleVO.java 
b/server/src/main/java/org/apache/rocketmq/studio/persistence/mapper/RmqAclRuleMapper.java
similarity index 57%
copy from 
server/src/main/java/org/apache/rocketmq/studio/instance/acl/AclRuleVO.java
copy to 
server/src/main/java/org/apache/rocketmq/studio/persistence/mapper/RmqAclRuleMapper.java
index 74a82884..5d710381 100644
--- 
a/server/src/main/java/org/apache/rocketmq/studio/instance/acl/AclRuleVO.java
+++ 
b/server/src/main/java/org/apache/rocketmq/studio/persistence/mapper/RmqAclRuleMapper.java
@@ -14,29 +14,10 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-package org.apache.rocketmq.studio.instance.acl;
+package org.apache.rocketmq.studio.persistence.mapper;
 
-import lombok.AllArgsConstructor;
-import lombok.Builder;
-import lombok.Data;
-import lombok.NoArgsConstructor;
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import org.apache.rocketmq.studio.persistence.entity.RmqAclRule;
 
-import java.time.LocalDateTime;
-import java.util.List;
-
-@Data
-@Builder
-@NoArgsConstructor
-@AllArgsConstructor
-public class AclRuleVO {
-    private String id;
-    private String principal;
-    private String resource;
-    private String resourceType;
-    private String resourcePattern;
-    private List<String> actions;
-    private String decision;
-    private String scope;
-    private Integer aclVersion;
-    private LocalDateTime createdAt;
+public interface RmqAclRuleMapper extends BaseMapper<RmqAclRule> {
 }
diff --git 
a/server/src/main/java/org/apache/rocketmq/studio/instance/acl/AclRuleVO.java 
b/server/src/main/java/org/apache/rocketmq/studio/persistence/mapper/RmqAclUserMapper.java
similarity index 57%
copy from 
server/src/main/java/org/apache/rocketmq/studio/instance/acl/AclRuleVO.java
copy to 
server/src/main/java/org/apache/rocketmq/studio/persistence/mapper/RmqAclUserMapper.java
index 74a82884..53e32fc4 100644
--- 
a/server/src/main/java/org/apache/rocketmq/studio/instance/acl/AclRuleVO.java
+++ 
b/server/src/main/java/org/apache/rocketmq/studio/persistence/mapper/RmqAclUserMapper.java
@@ -14,29 +14,10 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-package org.apache.rocketmq.studio.instance.acl;
+package org.apache.rocketmq.studio.persistence.mapper;
 
-import lombok.AllArgsConstructor;
-import lombok.Builder;
-import lombok.Data;
-import lombok.NoArgsConstructor;
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import org.apache.rocketmq.studio.persistence.entity.RmqAclUser;
 
-import java.time.LocalDateTime;
-import java.util.List;
-
-@Data
-@Builder
-@NoArgsConstructor
-@AllArgsConstructor
-public class AclRuleVO {
-    private String id;
-    private String principal;
-    private String resource;
-    private String resourceType;
-    private String resourcePattern;
-    private List<String> actions;
-    private String decision;
-    private String scope;
-    private Integer aclVersion;
-    private LocalDateTime createdAt;
+public interface RmqAclUserMapper extends BaseMapper<RmqAclUser> {
 }
diff --git a/server/src/main/resources/db/schema.sql 
b/server/src/main/resources/db/schema.sql
index a918d5f9..f85700b1 100644
--- a/server/src/main/resources/db/schema.sql
+++ b/server/src/main/resources/db/schema.sql
@@ -144,6 +144,35 @@ CREATE TABLE IF NOT EXISTS rmq_data_source (
   updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
 ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
 
+-- 11. ACL 规则
+CREATE TABLE IF NOT EXISTS rmq_acl_rule (
+  id VARCHAR(64) PRIMARY KEY,
+  principal VARCHAR(128) NOT NULL,
+  resource VARCHAR(255) NOT NULL,
+  resource_type VARCHAR(32) COMMENT 'Topic/Group/Cluster',
+  resource_pattern VARCHAR(32) COMMENT 'LITERAL/PREFIX',
+  actions VARCHAR(128) COMMENT '逗号分隔:PUB/SUB/ALL',
+  decision VARCHAR(16) COMMENT 'ALLOW/DENY',
+  scope VARCHAR(64) COMMENT '生效范围(集群名/实例 id)',
+  acl_version VARCHAR(16) COMMENT '1.0/2.0',
+  created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
+  updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
+  INDEX idx_principal (principal)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
+
+-- 12. ACL 用户(secret_key 为 base64 编码后的密码,禁止明文存储)
+CREATE TABLE IF NOT EXISTS rmq_acl_user (
+  id VARCHAR(64) PRIMARY KEY,
+  username VARCHAR(128) NOT NULL,
+  access_key VARCHAR(255) NOT NULL,
+  secret_key VARCHAR(512) NOT NULL COMMENT 'base64 编码的密码',
+  admin TINYINT(1) DEFAULT 0,
+  clusters VARCHAR(1024) COMMENT '逗号分隔的集群/实例 id',
+  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;
+
 -- ============================================================
 -- 样例数据(幂等):instance / topic / group 列表以本库为准,创建时写库、读取时读库。
 -- 实例管理页默认 5 个实例:2 个 DIRECT(instance-direct-1/2)+ 3 个 
PROXY(instance-proxy-1/2/3)。
@@ -257,3 +286,35 @@ VALUES
   ('rocketmq-studio', 'instance-direct-2', 'GID_compliance_daily',  'PULL', 
'CLUSTERING',    1, 'ACTIVE', 'seed'),
   ('rocketmq-studio', 'instance-direct-2', 'studio-trace-consumer', 'PUSH', 
'CLUSTERING',   16, 'ACTIVE', 'seed');
 
+-- ACL 规则种子:principal 对应下方 ACL 用户,资源对齐上面的 seed topic/group,scope 为实例 id
+INSERT IGNORE INTO rmq_acl_rule
+  (id, principal, resource, resource_type, resource_pattern, actions, 
decision, scope, acl_version)
+VALUES
+  ('acl-001', 'user-order-service',         'order_*',                 
'Topic',   'PREFIX',  'PUB,SUB', 'ALLOW', 'instance-proxy-1',  '2.0'),
+  ('acl-002', 'user-payment-service',       'payment_*',               
'Topic',   'PREFIX',  'PUB,SUB', 'ALLOW', 'instance-proxy-1',  '2.0'),
+  ('acl-003', 'user-admin',                 '*',                       
'Cluster', 'LITERAL', 'ALL',     'ALLOW', 'instance-proxy-1',  '2.0'),
+  ('acl-004', 'user-log-collector',         'audit_operation_log',     
'Topic',   'LITERAL', 'SUB',     'ALLOW', 'instance-direct-2', '1.0'),
+  ('acl-005', 'user-order-service',         'GID_fulfillment_*',       
'Group',   'PREFIX',  'SUB',     'ALLOW', 'instance-proxy-1',  '2.0'),
+  ('acl-006', 'user-inventory-service',     
'inventory_deduct_command','Topic',   'LITERAL', 'PUB,SUB', 'ALLOW', 
'instance-proxy-1',  '2.0'),
+  ('acl-007', 'user-guest',                 'payment_result_notify',   
'Topic',   'LITERAL', 'PUB,SUB', 'DENY',  'instance-proxy-1',  '1.0'),
+  ('acl-008', 'user-notification-service',  'sms_send_command',        
'Topic',   'LITERAL', 'PUB',     'ALLOW', 'instance-proxy-2',  '2.0'),
+  ('acl-009', 'user-risk-control',          'risk_event_alert',        
'Topic',   'LITERAL', 'SUB',     'ALLOW', 'instance-direct-2', '1.0'),
+  ('acl-010', 'user-guest',                 '*',                       
'Cluster', 'LITERAL', 'PUB',     'DENY',  'instance-proxy-2',  '2.0'),
+  ('acl-011', 'user-payment-service',       'GID_payment_*',           
'Group',   'PREFIX',  'SUB',     'ALLOW', 'instance-proxy-1',  '2.0'),
+  ('acl-012', 'user-monitor',               'user_behavior_log',       
'Topic',   'LITERAL', 'SUB',     'ALLOW', 'instance-proxy-3',  '1.0'),
+  ('acl-013', 'user-ai-service',            'click_stream_etl',        
'Topic',   'LITERAL', 'PUB,SUB', 'ALLOW', 'instance-proxy-3',  '2.0');
+
+-- ACL 用户种子:secret_key 为密码的 base64 编码(如 user-admin 明文 Admin@Studio#2026)
+INSERT IGNORE INTO rmq_acl_user
+  (id, username, access_key, secret_key, admin, clusters)
+VALUES
+  ('u-001', 'user-admin',                 'AKSTUDIOadmin0001', 
'QWRtaW5AU3R1ZGlvIzIwMjY=',     1,
+   
'instance-proxy-1,instance-proxy-2,instance-proxy-3,instance-direct-1,instance-direct-2'),
+  ('u-002', 'user-order-service',         'AKSTUDIOordr0002',  
'T3JkZXJTdmNAMjAyNiNQcm9k',     0, 'instance-proxy-1'),
+  ('u-003', 'user-payment-service',       'AKSTUDIOpaym0003',  
'UGF5U3ZjQDIwMjYjUHJvZA==',     0, 'instance-proxy-1'),
+  ('u-004', 'user-log-collector',         'AKSTUDIOlogs0004',  
'TG9nQ29sbGVjdEAyMDI2I09wcw==', 0, 'instance-direct-2'),
+  ('u-005', 'user-guest',                 'AKSTUDIOgues0005',  
'R3Vlc3RAMjAyNiNSZWFk',         0, 'instance-proxy-2'),
+  ('u-006', 'user-inventory-service',     'AKSTUDIOinvn0006',  
'SW52U3ZjQDIwMjYjUHJvZA==',     0, 'instance-proxy-1'),
+  ('u-007', 'user-notification-service',  'AKSTUDIONtfy0007',  
'Tm90aWZ5U3ZjQDIwMjYjTXNn',     0, 'instance-proxy-2'),
+  ('u-008', 'user-monitor',               'AKSTUDIOmonr0008',  
'TW9uaXRvckAyMDI2I09icw==',     0,
+   'instance-proxy-3,instance-direct-2');
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 9803eb36..c3b82cea 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
@@ -151,8 +151,8 @@ class AclServiceTest {
 
     @Test
     void updateRuleShouldRejectUnknownIdInsteadOfCreatingRule() {
-        InMemoryAclRepository repository = new InMemoryAclRepository();
-        AclService service = new AclService(repository);
+        
when(aclRepository.replaceRule(any(AclRuleVO.class))).thenReturn(Optional.empty());
+        when(aclRepository.findRules(null, null)).thenReturn(List.of());
         AclRuleVO update = AclRuleVO.builder()
                 .id("missing-rule")
                 .principal("orders")
@@ -160,18 +160,38 @@ class AclServiceTest {
                 .decision("DENY")
                 .build();
 
-        assertThatThrownBy(() -> service.updateRule(update))
+        assertThatThrownBy(() -> aclService.updateRule(update))
                 .isInstanceOf(BusinessException.class)
                 .hasMessage("ACL rule not found: missing-rule")
                 .satisfies(ex -> assertThat(((BusinessException) 
ex).getCode()).isEqualTo(404));
-        assertThat(service.listRules(null, null)).isEmpty();
+        assertThat(aclService.listRules(null, null)).isEmpty();
+        verify(aclRepository, never()).saveRule(any(AclRuleVO.class));
     }
 
     @Test
     void updateRuleShouldPreserveStoredCreationTimestamp() {
-        InMemoryAclRepository repository = new InMemoryAclRepository();
-        AclService service = new AclService(repository);
-        AclRuleVO created = service.createRule(AclRuleVO.builder()
+        java.util.concurrent.atomic.AtomicReference<AclRuleVO> stored = new 
java.util.concurrent.atomic.AtomicReference<>();
+        
when(aclRepository.saveRule(any(AclRuleVO.class))).thenAnswer(invocation -> {
+            stored.set(invocation.getArgument(0));
+            return invocation.getArgument(0);
+        });
+        
when(aclRepository.replaceRule(any(AclRuleVO.class))).thenAnswer(invocation -> {
+            AclRuleVO rule = invocation.getArgument(0);
+            // Repository contract: keep the original creation timestamp on 
replace.
+            return Optional.of(AclRuleVO.builder()
+                    .id(rule.getId())
+                    .principal(rule.getPrincipal())
+                    .resource(rule.getResource())
+                    .resourceType(rule.getResourceType())
+                    .resourcePattern(rule.getResourcePattern())
+                    .actions(rule.getActions())
+                    .decision(rule.getDecision())
+                    .scope(rule.getScope())
+                    .aclVersion(rule.getAclVersion())
+                    .createdAt(stored.get().getCreatedAt())
+                    .build());
+        });
+        AclRuleVO created = aclService.createRule(AclRuleVO.builder()
                 .principal("orders")
                 .resource("orders-topic")
                 .decision("ALLOW")
@@ -187,16 +207,11 @@ class AclServiceTest {
                 .createdAt(clientCreatedAt)
                 .build();
 
-        AclRuleVO updated = service.updateRule(update);
-        AclRuleVO stored = service.listRules(null, null).get(0);
+        AclRuleVO updated = aclService.updateRule(update);
 
         assertThat(updated.getCreatedAt()).isEqualTo(originalCreatedAt);
         assertThat(updated.getDecision()).isEqualTo("DENY");
         assertThat(update.getCreatedAt()).isEqualTo(clientCreatedAt);
-        assertThat(service.listRules(null, null)).hasSize(1);
-        assertThat(stored.getId()).isEqualTo(created.getId());
-        assertThat(stored.getDecision()).isEqualTo("DENY");
-        assertThat(stored.getCreatedAt()).isEqualTo(originalCreatedAt);
     }
 
     @Test
@@ -396,18 +411,24 @@ class AclServiceTest {
 
     @Test
     void createListUpdateShouldPreserveStoredCredentials() {
-        InMemoryAclRepository repository = new InMemoryAclRepository();
-        AclService service = new AclService(repository);
-        AclUserVO created = service.createUser(AclUserVO.builder()
+        java.util.concurrent.atomic.AtomicReference<AclUserVO> stored = new 
java.util.concurrent.atomic.AtomicReference<>();
+        
when(aclRepository.saveUser(any(AclUserVO.class))).thenAnswer(invocation -> {
+            stored.set(invocation.getArgument(0));
+            return invocation.getArgument(0);
+        });
+        when(aclRepository.findUserById(any())).thenAnswer(invocation -> 
Optional.ofNullable(stored.get()));
+        when(aclRepository.findUsers()).thenAnswer(invocation -> 
List.of(stored.get()));
+
+        AclUserVO created = aclService.createUser(AclUserVO.builder()
                 .username("orders")
                 .admin(false)
                 .clusters(List.of("cluster-a"))
                 .build());
         String accessKey = created.getAccessKey();
         String secretKey = created.getSecretKey();
-        AclUserVO listed = service.listUsers().get(0);
+        AclUserVO listed = aclService.listUsers().get(0);
 
-        AclUserVO updated = service.updateUser(AclUserVO.builder()
+        AclUserVO updated = aclService.updateUser(AclUserVO.builder()
                 .id(listed.getId())
                 .username("orders-admin")
                 .accessKey(listed.getAccessKey())
@@ -420,11 +441,11 @@ class AclServiceTest {
         assertThat(listed.getSecretKey()).isNotEqualTo(secretKey);
         assertThat(updated.getAccessKey()).isEqualTo(mask(accessKey));
         assertThat(updated.getSecretKey()).isEqualTo(mask(secretKey));
-        AclUserVO stored = 
repository.findUserById(created.getId()).orElseThrow();
-        assertThat(stored.getAccessKey()).isEqualTo(accessKey);
-        assertThat(stored.getSecretKey()).isEqualTo(secretKey);
-        assertThat(stored.getUsername()).isEqualTo("orders-admin");
-        assertThat(stored.isAdmin()).isTrue();
+        AclUserVO storedUser = stored.get();
+        assertThat(storedUser.getAccessKey()).isEqualTo(accessKey);
+        assertThat(storedUser.getSecretKey()).isEqualTo(secretKey);
+        assertThat(storedUser.getUsername()).isEqualTo("orders-admin");
+        assertThat(storedUser.isAdmin()).isTrue();
     }
 
     private String mask(String credential) {
diff --git a/web/src/api/acl.ts b/web/src/api/acl.ts
index 14ab320c..63b710dd 100644
--- a/web/src/api/acl.ts
+++ b/web/src/api/acl.ts
@@ -53,6 +53,13 @@ export async function listAclUsers(params?: { keyword?: 
string }) {
   return res.data.data;
 }
 
+export async function getAclUserCredentials(id: string) {
+  const res = await client.get<{ data: AclUser }>(
+    `/acl/users/${encodeURIComponent(id)}/credentials`,
+  );
+  return res.data.data;
+}
+
 export async function createAclUser(data: Partial<AclUser>) {
   const res = await client.post<{ data: AclUser }>('/acl/users/create', data);
   return res.data.data;
diff --git a/web/src/pages/instance/__tests__/AclPage.test.tsx 
b/web/src/pages/instance/__tests__/AclPage.test.tsx
index c2410a7c..bd4b4104 100644
--- a/web/src/pages/instance/__tests__/AclPage.test.tsx
+++ b/web/src/pages/instance/__tests__/AclPage.test.tsx
@@ -19,6 +19,7 @@ import { App } from 'antd';
 import { render, screen, waitFor, within } from '@testing-library/react';
 import userEvent from '@testing-library/user-event';
 import type React from 'react';
+import { MemoryRouter } from 'react-router-dom';
 import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
 import { LangProvider } from '../../../i18n/LangContext';
 import * as aclService from '../../../services/aclService';
@@ -34,6 +35,9 @@ vi.mock('../../../services/aclService', () => ({
   updateAclRule: vi.fn(),
   updateAclUser: vi.fn(),
 }));
+vi.mock('../../../services/instanceService', () => ({
+  listInstances: vi.fn().mockResolvedValue([]),
+}));
 
 beforeAll(() => {
   Object.defineProperty(window, 'matchMedia', {
@@ -54,7 +58,9 @@ beforeAll(() => {
 const renderWithProviders = (ui: React.ReactElement) =>
   render(
     <App>
-      <LangProvider>{ui}</LangProvider>
+      <LangProvider>
+        <MemoryRouter>{ui}</MemoryRouter>
+      </LangProvider>
     </App>,
   );
 
diff --git a/web/src/pages/instance/acl.tsx b/web/src/pages/instance/acl.tsx
index 9417cbd1..85ae8a80 100644
--- a/web/src/pages/instance/acl.tsx
+++ b/web/src/pages/instance/acl.tsx
@@ -45,12 +45,14 @@ import {
   createAclUser,
   deleteAclRule,
   deleteAclUser,
+  getAclUserCredentials,
   listAclRules,
   listAclUsers,
   updateAclRule,
   updateAclUser,
 } from '../../services/aclService';
 import type { AclRule, AclUser } from '../../api/acl';
+import { useInstanceFilter } from '../../hooks/useInstanceFilter';
 
 type AclRuleFormValues = Pick<
   AclRule,
@@ -89,6 +91,7 @@ const isFormValidationError = (error: unknown) =>
    ═══════════════════════════════════════════ */
 const AclPage = () => {
   const { t } = useLang();
+  const { selectedInstanceId, selectInstance, instanceOptions } = 
useInstanceFilter();
 
   /* ─── State ─── */
   const [rules, setRules] = useState<AclRule[]>([]);
@@ -116,6 +119,9 @@ const AclPage = () => {
 
   // Secret key reveal
   const [revealedKeys, setRevealedKeys] = useState<Set<string>>(new Set());
+  const [credentialsByUser, setCredentialsByUser] = useState<
+    Record<string, { accessKey: string; secretKey: string }>
+  >({});
 
   useEffect(() => {
     let mounted = true;
@@ -206,7 +212,7 @@ const AclPage = () => {
       } else {
         const created = await createAclRule({
           ...values,
-          aclVersion: 2,
+          aclVersion: '2.0',
         });
         setRules((prev) => [normalizeRule(created), ...prev]);
         message.success(t('acl.ruleAdded'));
@@ -231,7 +237,8 @@ const AclPage = () => {
   };
 
   /* ─── User helpers ─── */
-  const toggleRevealKey = (userId: string) => {
+  const toggleRevealKey = async (userId: string) => {
+    const revealing = !revealedKeys.has(userId);
     setRevealedKeys((prev) => {
       const next = new Set(prev);
       if (next.has(userId)) {
@@ -241,6 +248,24 @@ const AclPage = () => {
       }
       return next;
     });
+    if (!revealing || credentialsByUser[userId]) return;
+    try {
+      const credentials = await getAclUserCredentials(userId);
+      setCredentialsByUser((prev) => ({
+        ...prev,
+        [userId]: {
+          accessKey: credentials.accessKey,
+          secretKey: credentials.secretKey,
+        },
+      }));
+    } catch {
+      setRevealedKeys((prev) => {
+        const next = new Set(prev);
+        next.delete(userId);
+        return next;
+      });
+      message.error(t('common.fetchDataFailed'));
+    }
   };
 
   const openAddUserModal = () => {
@@ -489,16 +514,40 @@ const AclPage = () => {
       sorter: (a, b) => a.accessKey.localeCompare(b.accessKey),
       render: (text: string, record: AclUser) => {
         const revealed = revealedKeys.has(record.id);
+        const fullAccessKey = credentialsByUser[record.id]?.accessKey ?? text;
         return (
           <Space size={8}>
-            <Typography.Text copyable={{ text }} style={{ fontFamily: 
'monospace', fontSize: 13 }}>
-              {revealed ? text : text.replace(/(?<=\*{4}).+/, '••••')}
+            <Typography.Text
+              copyable={{ text: fullAccessKey }}
+              style={{ fontFamily: 'monospace', fontSize: 13 }}
+            >
+              {revealed ? fullAccessKey : text}
+            </Typography.Text>
+          </Space>
+        );
+      },
+    },
+    {
+      title: 'Secret Key',
+      dataIndex: 'secretKey',
+      key: 'secretKey',
+      width: 240,
+      render: (_: string, record: AclUser) => {
+        const revealed = revealedKeys.has(record.id);
+        const secret = credentialsByUser[record.id]?.secretKey;
+        return (
+          <Space size={8}>
+            <Typography.Text
+              copyable={revealed && secret ? { text: secret } : false}
+              style={{ fontFamily: 'monospace', fontSize: 13 }}
+            >
+              {revealed ? (secret ?? '加载中…') : '••••••••••••'}
             </Typography.Text>
             <Button
               type="text"
               size="small"
               icon={revealed ? <EyeSlash size={14} /> : <Eye size={14} />}
-              onClick={() => toggleRevealKey(record.id)}
+              onClick={() => void toggleRevealKey(record.id)}
             />
           </Space>
         );
@@ -623,6 +672,14 @@ const AclPage = () => {
                       flexWrap: 'wrap',
                     }}
                   >
+                    <Select
+                      placeholder="选择实例"
+                      value={selectedInstanceId || undefined}
+                      onChange={selectInstance}
+                      options={instanceOptions}
+                      style={{ width: 220 }}
+                      notFoundContent="暂无实例"
+                    />
                     <Input.Search
                       placeholder={t('acl.searchPrincipal')}
                       prefix={<MagnifyingGlass size={14} color="#9CA3AF" />}
@@ -804,10 +861,7 @@ const AclPage = () => {
           <Form.Item name="scope" label={t('acl.effectScope')}>
             <Select
               placeholder={t('acl.selectEffectScope')}
-              options={[
-                { value: 'cluster', label: t('acl.clusterScope') },
-                { value: 'namespace', label: t('acl.namespaceScope') },
-              ]}
+              options={[{ value: 'cluster', label: t('acl.clusterScope') }]}
             />
           </Form.Item>
         </Form>
diff --git a/web/src/services/aclService.ts b/web/src/services/aclService.ts
index 29d56782..13fc55e8 100644
--- a/web/src/services/aclService.ts
+++ b/web/src/services/aclService.ts
@@ -44,6 +44,15 @@ export async function listAclUsers(params?: { keyword?: 
string }): Promise<AclUs
   return aclApi.listAclUsers(params);
 }
 
+export async function getAclUserCredentials(id: string): Promise<AclUser> {
+  if (isMockMode()) {
+    const user = aclUsersState.find((u) => u.id === id);
+    if (!user) throw new Error(`ACL user not found: ${id}`);
+    return copyAclUser(user);
+  }
+  return aclApi.getAclUserCredentials(id);
+}
+
 export async function createAclRule(data: Partial<AclRule>): Promise<AclRule> {
   if (isMockMode()) {
     const rule: AclRule = {
@@ -54,7 +63,7 @@ export async function createAclRule(data: Partial<AclRule>): 
Promise<AclRule> {
       resourcePattern: '',
       decision: '',
       scope: '',
-      aclVersion: 2,
+      aclVersion: '2.0',
       createdAt: new Date().toISOString(),
       ...data,
       actions: [...(data.actions ?? [])],

Reply via email to