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 ?? [])],