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 8bbe7947a feat(acl): push stored accounts and policies to Apache
brokers, and restructure the ACL page (#6693)
8bbe7947a is described below
commit 8bbe7947a4cb88e2d12c17fa7984ad22cf78eac5
Author: lizhimins <[email protected]>
AuthorDate: Thu Oct 15 10:24:40 2026 +0800
feat(acl): push stored accounts and policies to Apache brokers, and
restructure the ACL page (#6693)
The dashboard's ACL store and the brokers drifted with no way to reconcile
them:
creating or editing a plain access account only wrote the dashboard's own
tables,
and the broker side had a read path but no write path. POST
/api/acl/push-to-broker
now upserts every stored account of an instance on each master broker
through the
ACL 2.0 admin API (createUser/updateUser for credentials, updateAcl for
policies
under the User:<username> subject) in a single admin session. The push is
additive — broker accounts the dashboard does not know about stay untouched
— and
every account/broker pair reports its own outcome, so one unreachable or
ACL-disabled broker cannot hide the successes; rules the broker model cannot
express (prefixed resources, actions outside PUB/SUB/GET) come back as
per-account
skipped reasons. Resources are sent in the typed Topic:/Group: form a real
broker
requires, and when the broker's auth module is off the push answers with a
409
naming the two switches to enable instead of four opaque CODE: 16 failures.
Verified against an ACL-enabled test broker: pushed 4 / failed 0, read back
identical.
The page follows: the user tab leads and is the default, the instance
select moves
above the tabs (it used to live in the rule tab's filter bar, so the user
tab had
no way to change instance), and the push button sits in the page header
beside the
tab-aware add button, disabled with a reason when there is no instance or
the
vendor has no push channel. The banner that claimed stored rules never
reach the
selected instance — true before the push existed, misleading after — now
has three
honest modes: Tencent roles, Apache push, and store-only for cloud vendors
without a push channel.
The deploy skill records that the test cluster may be operated on directly,
plus
the traps found there: the broker store is an emptyDir, so a restart wipes
every
message and topic config; ACL 2.0 needs the four provider class names and a
JSON
initAuthenticationUser or even mqadmin cannot create the first account.
---
.claude/skills/deploy-rocketmq/SKILL.md | 24 ++
.../studio/instance/acl/AclController.java | 10 +
.../rocketmq/studio/instance/acl/AclService.java | 237 ++++++++++++++++
.../studio/instance/acl/PushAclToBrokerDTO.java | 27 ++
.../instance/acl/PushAclToBrokerResultVO.java | 71 +++++
.../studio/instance/acl/AclServiceTest.java | 131 +++++++++
web/src/api/acl.ts | 30 ++
web/src/pages/instance/__tests__/AclPage.test.tsx | 101 ++++++-
web/src/pages/instance/acl.tsx | 305 ++++++++++++++++-----
web/src/services/aclService.ts | 22 ++
10 files changed, 876 insertions(+), 82 deletions(-)
diff --git a/.claude/skills/deploy-rocketmq/SKILL.md
b/.claude/skills/deploy-rocketmq/SKILL.md
index ef1a793d9..b044e4508 100644
--- a/.claude/skills/deploy-rocketmq/SKILL.md
+++ b/.claude/skills/deploy-rocketmq/SKILL.md
@@ -35,6 +35,30 @@ description: 部署 RocketMQ Studio 与开源 RocketMQ。当用户说 部署 stu
- 放行所需端口:模式 A 放行 web 端口(默认 6789);模式 B/C 如需跨机访问放行
9876、10909、10911、10912、20909、20911、20912、8080、8081。
+### 测试集群可直接操作(用户授权 2026-10-14)
+
+为验证 studio 功能,**测试环境(ECS 47.98.43.243 + ACK `rocketmq1` 命名空间)可以直接操作,无需先问**:
+含改 broker 配置、重启 broker / Pod、重启 ECS 上的 `rmq-loadgen` 容器、开 ACL、建演示 topic/group 等
+破坏性动作。前提是操作后**恢复原状**,或在 `rocketmq-studio-docs/progress/` 当日文档里记录留存状态。
+生产/预发集群不在此授权内。
+
+操作前必须知道的三个坑(2026-10-14 实测踩过):
+
+- **`rocketmq1` 的 broker store 是 emptyDir**(`broker-storage` volume 没有
PVC):**重启 broker Pod 会清空
+ 全部消息与 topic / 订阅组配置**。重启后按 studio 元数据库的记录重建:`POST /api/topics/create`、
+ `POST /api/groups/create` 会把配置写回 broker;再重发演示消息、`docker restart
rmq-loadgen`(ECS 上)、
+ 重建集群内的演示消费者 Pod(裸 Pod 删了不会自愈,要重新 apply YAML)。
+- **开 ACL 2.0(apache/rocketmq:5.5.0)的完整配方**:broker.conf 除
`authenticationEnabled=true`、
+ `authorizationEnabled=true` 外,还必须显式配 `authenticationProvider` /
`authenticationMetadataProvider` /
+ `authorizationProvider` / `authorizationMetadataProvider`
四个类名(该镜像默认为空,只开两个开关会报
+ `The authenticationMetadataProvider is not configured`,连 mqadmin 都建不了首个用户);并用
+
`initAuthenticationUser={"username":...,"password":...,"userType":"Super"}`(**User
对象的 JSON**)播种
+ 首个 Super 账号。该账号要用 studio 的 admin 凭据同一对 AK/SK(在 ECS
`/opt/rocketmq-studio/.env` 的
+ `SPRING_APPLICATION_JSON` →
`studio.cluster.admin.credentials.<adminCredentialRef>`),否则开关一开
+ studio 自己的 admin 通道先被拒。验完把这几行删掉再重启即恢复。
+- **broker ACL 2.0 的 resource 必须带类型前缀**:`Topic:<name>` / `Group:<name>`,集群域用
`*`。
+ 裸名字会被解析成 null resource,整条账号写入报 `CODE: 16 The resource is null`。
+
---
## 模式 A:部署 Studio 到目标机器
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 de69c376e..693927999 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
@@ -144,6 +144,16 @@ public class AclController {
return
Result.ok(aclService.createAndUpdatePlainAccessConfig(request.toPlainAccessConfigVO()));
}
+ /**
+ * Writes every stored ACL account of the instance to its master brokers
(ACL 2.0 admin API).
+ * Additive by design: broker accounts the dashboard does not know about
are left untouched.
+ */
+ @PostMapping("/push-to-broker")
+ public Result<PushAclToBrokerResultVO> pushAclToBroker(
+ @Valid @RequestBody PushAclToBrokerDTO request) {
+ return Result.ok(aclService.pushAclToBroker(request.getInstanceId()));
+ }
+
private <T> T requireRequest(T request, String message) {
if (request == null) {
throw new BusinessException(400, message);
diff --git
a/server/src/main/java/org/apache/rocketmq/studio/instance/acl/AclService.java
b/server/src/main/java/org/apache/rocketmq/studio/instance/acl/AclService.java
index 385c44195..fb34a28c4 100644
---
a/server/src/main/java/org/apache/rocketmq/studio/instance/acl/AclService.java
+++
b/server/src/main/java/org/apache/rocketmq/studio/instance/acl/AclService.java
@@ -18,6 +18,11 @@ package org.apache.rocketmq.studio.instance.acl;
import org.springframework.util.StringUtils;
+import org.apache.rocketmq.common.MixAll;
+import org.apache.rocketmq.remoting.protocol.body.AclInfo;
+import org.apache.rocketmq.remoting.protocol.body.ClusterInfo;
+import org.apache.rocketmq.remoting.protocol.body.UserInfo;
+import org.apache.rocketmq.tools.admin.DefaultMQAdminExt;
import org.apache.rocketmq.studio.common.domain.PageResult;
import org.apache.rocketmq.studio.common.exception.BusinessException;
import org.apache.rocketmq.studio.common.util.Pagination;
@@ -27,6 +32,7 @@ import org.apache.rocketmq.studio.common.util.EntityIds;
import org.apache.rocketmq.studio.audit.OperationAuditService;
import org.apache.rocketmq.studio.cluster.broker.BrokerVO;
import org.apache.rocketmq.studio.cluster.broker.ClusterProvider;
+import org.apache.rocketmq.studio.cluster.broker.RuntimeAdminClientResolver;
import org.apache.rocketmq.studio.model.Acl2PolicyContext;
import org.apache.rocketmq.studio.instance.InstanceResolver;
import org.apache.rocketmq.studio.instance.InstanceVO;
@@ -38,9 +44,12 @@ import org.springframework.stereotype.Service;
import java.security.SecureRandom;
import java.time.LocalDateTime;
import java.util.ArrayList;
+import java.util.Arrays;
import java.util.Comparator;
import java.util.List;
import java.util.Locale;
+import java.util.Objects;
+import java.util.Properties;
@Slf4j
@Service
@@ -50,6 +59,13 @@ public class AclService {
private static final SecureRandom CREDENTIAL_RANDOM = new SecureRandom();
private static final int DEFAULT_RULE_PAGE_SIZE = 20;
private static final int MAX_PAGE_SIZE = 100;
+ /** Broker ACL 2.0 subjects are prefixed with {@code User:}; the store
uses {@code user:}. */
+ private static final String SUBJECT_PREFIX = "User:";
+ private static final String USER_TYPE_SUPER = "Super";
+ private static final String USER_TYPE_NORMAL = "Normal";
+ private static final String USER_STATUS_NORMAL = "Normal";
+ private static final String POLICY_TYPE_CUSTOM = "Custom";
+ private static final int MAX_RULES_PER_ACCOUNT = 200;
/**
* Minimum broker version that supports ACL 2.0 (the RocketMQ {@code auth}
module with
@@ -63,6 +79,7 @@ public class AclService {
private final InstanceResolver instanceResolver;
private final TencentAclService tencentAclService;
private final ClusterProvider clusterProvider;
+ private final RuntimeAdminClientResolver runtimeAdminClientResolver;
public AclCapabilitiesVO capabilities(String instanceId) {
if (!StringUtils.hasText(instanceId)) {
@@ -318,6 +335,226 @@ public class AclService {
return saved;
}
+ /**
+ * Pushes every stored ACL account to the master brokers of an Apache
instance through the
+ * broker's ACL 2.0 admin API (account credentials via
createUser/updateUser, policies via
+ * updateAcl). The push is additive: each account is upserted under its
{@code User:<username>}
+ * subject and accounts the dashboard does not know about stay on the
broker. Rules the broker
+ * model cannot express (non-literal resource patterns, actions outside
PUB/SUB/GET) are
+ * reported per account instead of failing the whole push, and every
account/broker pair keeps
+ * its own outcome so one unreachable broker cannot hide the successes.
+ */
+ public PushAclToBrokerResultVO pushAclToBroker(String instanceId) {
+ if (!StringUtils.hasText(instanceId)) {
+ throw new BusinessException(400, "instanceId is required");
+ }
+ requireAcl2Supported(instanceId);
+ if (isTencentInstance(instanceId)) {
+ throw new BusinessException(400, "Broker push is only supported
for Apache instances");
+ }
+ List<PushPlan> plans = new ArrayList<>();
+ for (AclUserVO user : aclRepository.findUsers()) {
+ AclUserVO credentials =
getUserCredentials(String.valueOf(user.getId()), instanceId);
+ List<AclRuleVO> rules = aclRepository.findRulePage(
+ principalOf(user.getUsername()), null, null, null, null, 1,
+ MAX_RULES_PER_ACCOUNT).getItems();
+ plans.add(buildPushPlan(user, credentials, rules));
+ }
+ log.info("Pushing {} ACL account(s) of instance {} to the brokers",
plans.size(), instanceId);
+ return runtimeAdminClientResolver.execute(instanceId, admin -> {
+ DefaultMQAdminExt adminExt = (DefaultMQAdminExt) admin;
+ List<String> masters = masterAddresses(adminExt);
+ if (!masters.isEmpty() && !acl2Enabled(adminExt, masters.get(0))) {
+ throw new BusinessException(409, "Broker ACL 2.0 is not
enabled on "
+ + masters.get(0) + "; set authenticationEnabled=true
and "
+ + "authorizationEnabled=true in the broker
configuration before pushing");
+ }
+ List<PushAclToBrokerResultVO.AccountOutcome> outcomes = new
ArrayList<>();
+ int pushed = 0;
+ int failed = 0;
+ for (PushPlan plan : plans) {
+ List<PushAclToBrokerResultVO.BrokerOutcome> brokerOutcomes =
new ArrayList<>();
+ boolean anySuccess = false;
+ for (String broker : masters) {
+ try {
+ upsertAccount(adminExt, broker, plan);
+
brokerOutcomes.add(PushAclToBrokerResultVO.BrokerOutcome.builder()
+ .broker(broker).success(true).build());
+ anySuccess = true;
+ } catch (Exception ex) {
+
brokerOutcomes.add(PushAclToBrokerResultVO.BrokerOutcome.builder()
+
.broker(broker).success(false).error(rootMessage(ex)).build());
+ }
+ }
+ if (anySuccess) {
+ pushed++;
+ } else {
+ failed++;
+ }
+ recordAudit("PUSH_ACL_TO_BROKER", "ACL_USER", plan.username(),
null,
+ "brokers=" + masters.size() + ", policies=" +
plan.entries().size()
+ + ", success=" + anySuccess);
+ outcomes.add(PushAclToBrokerResultVO.AccountOutcome.builder()
+ .username(plan.username())
+ .subject(plan.subject())
+ .policyCount(plan.entries().size())
+ .skipped(plan.skipped())
+ .brokers(brokerOutcomes)
+ .build());
+ }
+ return PushAclToBrokerResultVO.builder()
+ .instanceId(instanceId)
+ .brokers(masters)
+ .pushed(pushed)
+ .failed(failed)
+ .accounts(outcomes)
+ .build();
+ });
+ }
+
+ private record PushPlan(String username, String subject, UserInfo userInfo,
+ List<AclInfo.PolicyEntryInfo> entries,
List<String> skipped) {
+ }
+
+ private PushPlan buildPushPlan(AclUserVO user, AclUserVO credentials,
List<AclRuleVO> rules) {
+ List<AclInfo.PolicyEntryInfo> entries = new ArrayList<>();
+ List<String> skipped = new ArrayList<>();
+ for (AclRuleVO rule : rules) {
+ String pattern = rule.getResourcePattern() == null ? "" :
rule.getResourcePattern();
+ if (!"LITERAL".equalsIgnoreCase(pattern)) {
+ skipped.add(rule.getResource() + ": " + pattern + " pattern
has no broker ACL equivalent");
+ continue;
+ }
+ List<String> actions = mapAclActions(rule.getActions());
+ if (actions.isEmpty()) {
+ skipped.add(rule.getResource() + ": none of its actions map to
broker PUB/SUB/GET");
+ continue;
+ }
+ String resource = brokerResource(rule);
+ entries.add(AclInfo.PolicyEntryInfo.of(resource, actions,
+ splitWhiteList(user.getWhiteRemoteAddress()),
+ (rule.getDecision() == null ? "" :
rule.getDecision()).toUpperCase(Locale.ROOT)));
+ }
+ UserInfo userInfo = UserInfo.of(user.getUsername(),
credentials.getSecretKey(),
+ user.isAdmin() ? USER_TYPE_SUPER : USER_TYPE_NORMAL,
USER_STATUS_NORMAL);
+ return new PushPlan(user.getUsername(), SUBJECT_PREFIX +
user.getUsername(), userInfo,
+ entries, skipped);
+ }
+
+ private void upsertAccount(DefaultMQAdminExt adminExt, String broker,
PushPlan plan)
+ throws Exception {
+ UserInfo existing = null;
+ try {
+ existing = adminExt.getUser(broker, plan.userInfo().getUsername());
+ } catch (Exception notPresent) {
+ // A broker without the account answers with an error rather than
an empty result.
+ log.debug("No existing broker account {} on {}: {}",
plan.username(), broker,
+ rootMessage(notPresent));
+ }
+ if (existing == null) {
+ adminExt.createUser(broker, plan.userInfo());
+ } else {
+ adminExt.updateUser(broker, plan.userInfo());
+ }
+ if (plan.entries().isEmpty()) {
+ return;
+ }
+ AclInfo acl = new AclInfo();
+ acl.setSubject(plan.subject());
+ AclInfo.PolicyInfo policy = new AclInfo.PolicyInfo();
+ policy.setPolicyType(POLICY_TYPE_CUSTOM);
+ policy.setEntries(plan.entries());
+ acl.setPolicies(List.of(policy));
+ adminExt.updateAcl(broker, acl);
+ }
+
+ /**
+ * ACL 2.0 writes fail on the broker with an opaque "system error" when
the auth module is off,
+ * so read the two switches first and answer with an actionable message
instead. When the config
+ * cannot be read the check stays out of the way and lets the write report
its own error.
+ */
+ private boolean acl2Enabled(DefaultMQAdminExt adminExt, String broker) {
+ try {
+ Properties config = adminExt.getBrokerConfig(broker);
+ return
Boolean.parseBoolean(config.getProperty("authenticationEnabled"))
+ &&
Boolean.parseBoolean(config.getProperty("authorizationEnabled"));
+ } catch (Exception unreadable) {
+ log.debug("Could not read ACL switches from broker {}: {}", broker,
+ rootMessage(unreadable));
+ return true;
+ }
+ }
+
+ private List<String> masterAddresses(DefaultMQAdminExt adminExt) throws
Exception {
+ ClusterInfo clusterInfo = adminExt.examineBrokerClusterInfo();
+ if (clusterInfo == null || clusterInfo.getBrokerAddrTable() == null) {
+ return List.of();
+ }
+ return clusterInfo.getBrokerAddrTable().values().stream()
+ .filter(broker -> broker != null && broker.getBrokerAddrs() !=
null)
+ .map(broker -> broker.getBrokerAddrs().get(MixAll.MASTER_ID))
+ .filter(Objects::nonNull)
+ .distinct()
+ .toList();
+ }
+
+ /**
+ * Broker ACL 2.0 resources are typed strings ("Topic:orders",
"Group:cg"); a bare name parses
+ * to a null resource and the broker rejects the entry with CODE 16.
Cluster-scoped rules map to
+ * the ANY resource, which the broker stores as "*".
+ */
+ private String brokerResource(AclRuleVO rule) {
+ String type = rule.getResourceType() == null ? "" :
rule.getResourceType();
+ if ("Topic".equalsIgnoreCase(type)) {
+ return "Topic:" + rule.getResource();
+ }
+ if ("Group".equalsIgnoreCase(type)) {
+ return "Group:" + rule.getResource();
+ }
+ return "*";
+ }
+
+ /** ALL expands to both message directions; anything else the broker
cannot act on is dropped. */
+ private List<String> mapAclActions(List<String> actions) {
+ List<String> mapped = new ArrayList<>();
+ for (String action : actions == null ? List.<String>of() : actions) {
+ String normalized = action == null ? "" :
action.trim().toUpperCase(Locale.ROOT);
+ List<String> expanded = switch (normalized) {
+ case "ALL" -> List.of("PUB", "SUB");
+ case "PUB", "SUB", "GET" -> List.of(normalized);
+ default -> List.of();
+ };
+ for (String value : expanded) {
+ if (!mapped.contains(value)) {
+ mapped.add(value);
+ }
+ }
+ }
+ return mapped;
+ }
+
+ private List<String> splitWhiteList(String whiteRemoteAddress) {
+ if (!StringUtils.hasText(whiteRemoteAddress)) {
+ return List.of();
+ }
+ return Arrays.stream(whiteRemoteAddress.split(","))
+ .map(String::trim)
+ .filter(StringUtils::hasText)
+ .toList();
+ }
+
+ private String principalOf(String username) {
+ return "user:" + username;
+ }
+
+ private static String rootMessage(Throwable failure) {
+ Throwable cause = failure;
+ while (cause.getCause() != null && cause.getCause() != cause) {
+ cause = cause.getCause();
+ }
+ return cause.getMessage() == null ? cause.getClass().getSimpleName() :
cause.getMessage();
+ }
+
/**
* 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.
diff --git
a/server/src/main/java/org/apache/rocketmq/studio/instance/acl/PushAclToBrokerDTO.java
b/server/src/main/java/org/apache/rocketmq/studio/instance/acl/PushAclToBrokerDTO.java
new file mode 100644
index 000000000..458415b65
--- /dev/null
+++
b/server/src/main/java/org/apache/rocketmq/studio/instance/acl/PushAclToBrokerDTO.java
@@ -0,0 +1,27 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.rocketmq.studio.instance.acl;
+
+import jakarta.validation.constraints.NotBlank;
+import lombok.Data;
+
+@Data
+public class PushAclToBrokerDTO {
+ @NotBlank(message = "instanceId is required")
+ private String instanceId;
+}
diff --git
a/server/src/main/java/org/apache/rocketmq/studio/instance/acl/PushAclToBrokerResultVO.java
b/server/src/main/java/org/apache/rocketmq/studio/instance/acl/PushAclToBrokerResultVO.java
new file mode 100644
index 000000000..de31e03f6
--- /dev/null
+++
b/server/src/main/java/org/apache/rocketmq/studio/instance/acl/PushAclToBrokerResultVO.java
@@ -0,0 +1,71 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.rocketmq.studio.instance.acl;
+
+import lombok.AllArgsConstructor;
+import lombok.Builder;
+import lombok.Data;
+import lombok.NoArgsConstructor;
+
+import java.util.List;
+
+/**
+ * Outcome of pushing the dashboard's stored ACL accounts and policies to the
brokers of an
+ * Apache instance. The push is additive: accounts the dashboard does not know
about are left
+ * untouched on the broker, and every account/broker pair reports its own
result so a single
+ * unreachable broker cannot hide the successes.
+ */
+@Data
+@Builder
+@NoArgsConstructor
+@AllArgsConstructor
+public class PushAclToBrokerResultVO {
+ private String instanceId;
+ /** Master broker addresses the push targeted. */
+ private List<String> brokers;
+ /** Accounts written to at least one broker without error. */
+ private int pushed;
+ /** Accounts that failed on every broker they were pushed to. */
+ private int failed;
+ private List<AccountOutcome> accounts;
+
+ @Data
+ @Builder
+ @NoArgsConstructor
+ @AllArgsConstructor
+ public static class AccountOutcome {
+ private String username;
+ /** Broker ACL 2.0 subject the account was written under, e.g. {@code
User:alice}. */
+ private String subject;
+ /** Policies written for the subject. */
+ private int policyCount;
+ /** Stored rules the broker ACL model cannot express, with the reason.
*/
+ private List<String> skipped;
+ private List<BrokerOutcome> brokers;
+ }
+
+ @Data
+ @Builder
+ @NoArgsConstructor
+ @AllArgsConstructor
+ public static class BrokerOutcome {
+ private String broker;
+ private boolean success;
+ private String error;
+ }
+}
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 4f23095de..97529a464 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
@@ -25,6 +25,13 @@ import
org.apache.rocketmq.studio.common.domain.enums.InstanceType;
import org.apache.rocketmq.studio.common.domain.enums.InstanceVendor;
import org.apache.rocketmq.studio.cluster.broker.BrokerVO;
import org.apache.rocketmq.studio.cluster.broker.ClusterProvider;
+import org.apache.rocketmq.studio.cluster.broker.MqAdminExtFactory;
+import org.apache.rocketmq.studio.cluster.broker.RuntimeAdminClientResolver;
+import org.apache.rocketmq.client.exception.MQBrokerException;
+import org.apache.rocketmq.remoting.protocol.body.AclInfo;
+import org.apache.rocketmq.remoting.protocol.body.ClusterInfo;
+import org.apache.rocketmq.remoting.protocol.route.BrokerData;
+import org.apache.rocketmq.tools.admin.DefaultMQAdminExt;
import org.apache.rocketmq.studio.instance.InstanceResolver;
import org.apache.rocketmq.studio.instance.InstanceVO;
import org.apache.rocketmq.studio.provider.tencent.TencentAclService;
@@ -41,9 +48,12 @@ import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import java.time.LocalDateTime;
+import java.util.HashMap;
import java.util.List;
+import java.util.Map;
import java.util.Locale;
import java.util.Optional;
+import java.util.Properties;
import java.util.stream.Stream;
import static org.assertj.core.api.Assertions.assertThat;
@@ -53,6 +63,7 @@ import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.argThat;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.doThrow;
+import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verifyNoInteractions;
import static org.mockito.Mockito.verify;
@@ -76,6 +87,9 @@ class AclServiceTest {
@Mock
private ClusterProvider clusterProvider;
+ @Mock
+ private RuntimeAdminClientResolver runtimeAdminClientResolver;
+
@InjectMocks
private AclService aclService;
@@ -1139,4 +1153,121 @@ class AclServiceTest {
Arguments.of("", null),
Arguments.of("unknown", null));
}
+
+ @Test
+ void pushAclToBrokerUpsertsAccountsAndPoliciesOnEveryMasterTest() throws
Exception {
+ AclUserVO stored = AclUserVO.builder()
+ .id(7L)
+ .username("orders")
+ .secretKey("secret-1")
+ .admin(false)
+ .whiteRemoteAddress("10.0.0.0/8, 192.168.1.5")
+ .build();
+ when(aclRepository.findUsers()).thenReturn(List.of(stored));
+ when(aclRepository.findUserById(7L)).thenReturn(Optional.of(stored));
+ when(aclRepository.findRulePage("user:orders", null, null, null, null,
1, 200))
+ .thenReturn(PageResult.of(List.of(
+
AclRuleVO.builder().principal("user:orders").resource("topic-a")
+
.resourceType("Topic").resourcePattern("LITERAL")
+
.actions(List.of("PUB")).decision("ALLOW").build(),
+
AclRuleVO.builder().principal("user:orders").resource("topic-")
+
.resourceType("Topic").resourcePattern("PREFIXED")
+
.actions(List.of("SUB")).decision("ALLOW").build()),
+ 2, 1, 200));
+ when(clusterProvider.discoverBrokers("instance-a",
null)).thenReturn(List.of());
+
+ DefaultMQAdminExt admin = mock(DefaultMQAdminExt.class);
+
when(admin.examineBrokerClusterInfo()).thenReturn(clusterInfoWithMaster("10.0.0.1:10911"));
+ when(admin.getUser("10.0.0.1:10911", "orders"))
+ .thenThrow(new MQBrokerException(207, "no such user"));
+ when(runtimeAdminClientResolver.execute(eq("instance-a"),
any(MqAdminExtFactory.AdminAction.class)))
+ .thenAnswer(call -> call.getArgument(1,
MqAdminExtFactory.AdminAction.class).apply(admin));
+
+ PushAclToBrokerResultVO result =
aclService.pushAclToBroker("instance-a");
+
+ assertThat(result.getPushed()).isEqualTo(1);
+ assertThat(result.getFailed()).isZero();
+ assertThat(result.getBrokers()).containsExactly("10.0.0.1:10911");
+ PushAclToBrokerResultVO.AccountOutcome account =
result.getAccounts().get(0);
+ assertThat(account.getSubject()).isEqualTo("User:orders");
+ assertThat(account.getPolicyCount()).isEqualTo(1);
+ assertThat(account.getSkipped()).hasSize(1);
+ assertThat(account.getBrokers())
+ .allSatisfy(broker -> assertThat(broker.isSuccess()).isTrue());
+ verify(admin).createUser(eq("10.0.0.1:10911"), argThat(info ->
+ "orders".equals(info.getUsername())
+ && "secret-1".equals(info.getPassword())
+ && "Normal".equals(info.getUserType())));
+ ArgumentCaptor<AclInfo> aclCaptor =
ArgumentCaptor.forClass(AclInfo.class);
+ verify(admin).updateAcl(eq("10.0.0.1:10911"), aclCaptor.capture());
+ AclInfo pushed = aclCaptor.getValue();
+ assertThat(pushed.getSubject()).isEqualTo("User:orders");
+ assertThat(pushed.getPolicies()).hasSize(1);
+ AclInfo.PolicyEntryInfo entry =
pushed.getPolicies().get(0).getEntries().get(0);
+ assertThat(entry.getResource()).isEqualTo("Topic:topic-a");
+ assertThat(entry.getActions()).containsExactly("PUB");
+ assertThat(entry.getDecision()).isEqualTo("ALLOW");
+ assertThat(entry.getSourceIps()).containsExactly("10.0.0.0/8",
"192.168.1.5");
+ }
+
+ @Test
+ void pushAclToBrokerReportsBrokerFailuresWithoutHidingOtherAccountsTest()
throws Exception {
+ AclUserVO stored =
AclUserVO.builder().id(8L).username("reader").secretKey("secret-2").build();
+ when(aclRepository.findUsers()).thenReturn(List.of(stored));
+ when(aclRepository.findUserById(8L)).thenReturn(Optional.of(stored));
+ when(aclRepository.findRulePage("user:reader", null, null, null, null,
1, 200))
+ .thenReturn(PageResult.of(List.of(), 0, 1, 200));
+ when(clusterProvider.discoverBrokers("instance-a",
null)).thenReturn(List.of());
+
+ DefaultMQAdminExt admin = mock(DefaultMQAdminExt.class);
+
when(admin.examineBrokerClusterInfo()).thenReturn(clusterInfoWithMaster("10.0.0.1:10911"));
+ doThrow(new MQBrokerException(207, "acl disabled")).when(admin)
+ .createUser(eq("10.0.0.1:10911"), any());
+ when(runtimeAdminClientResolver.execute(eq("instance-a"),
any(MqAdminExtFactory.AdminAction.class)))
+ .thenAnswer(call -> call.getArgument(1,
MqAdminExtFactory.AdminAction.class).apply(admin));
+
+ PushAclToBrokerResultVO result =
aclService.pushAclToBroker("instance-a");
+
+ assertThat(result.getPushed()).isZero();
+ assertThat(result.getFailed()).isEqualTo(1);
+ PushAclToBrokerResultVO.BrokerOutcome broker =
+ result.getAccounts().get(0).getBrokers().get(0);
+ assertThat(broker.isSuccess()).isFalse();
+ assertThat(broker.getError()).contains("acl disabled");
+ verify(admin, never()).updateAcl(any(), any(AclInfo.class));
+ }
+
+ private ClusterInfo clusterInfoWithMaster(String address) {
+ ClusterInfo clusterInfo = new ClusterInfo();
+ BrokerData broker = new BrokerData("cluster-a", "broker-a", new
HashMap<>(Map.of(0L, address)));
+ clusterInfo.setBrokerAddrTable(new HashMap<>(Map.of("broker-a",
broker)));
+ return clusterInfo;
+ }
+
+ @Test
+ void
pushAclToBrokerAnswersWithAnActionableErrorWhenTheBrokerAuthModuleIsOffTest()
throws Exception {
+ AclUserVO stored =
AclUserVO.builder().id(9L).username("silent").secretKey("secret-3").build();
+ when(aclRepository.findUsers()).thenReturn(List.of(stored));
+ when(aclRepository.findUserById(9L)).thenReturn(Optional.of(stored));
+ when(aclRepository.findRulePage("user:silent", null, null, null, null,
1, 200))
+ .thenReturn(PageResult.of(List.of(), 0, 1, 200));
+ when(clusterProvider.discoverBrokers("instance-a",
null)).thenReturn(List.of());
+
+ DefaultMQAdminExt admin = mock(DefaultMQAdminExt.class);
+
when(admin.examineBrokerClusterInfo()).thenReturn(clusterInfoWithMaster("10.0.0.1:10911"));
+ Properties config = new Properties();
+ config.setProperty("authenticationEnabled", "false");
+ config.setProperty("authorizationEnabled", "false");
+ when(admin.getBrokerConfig("10.0.0.1:10911")).thenReturn(config);
+ when(runtimeAdminClientResolver.execute(eq("instance-a"),
any(MqAdminExtFactory.AdminAction.class)))
+ .thenAnswer(call -> call.getArgument(1,
MqAdminExtFactory.AdminAction.class).apply(admin));
+
+ assertThatThrownBy(() -> aclService.pushAclToBroker("instance-a"))
+ .isInstanceOfSatisfying(BusinessException.class, error -> {
+ assertThat(error.getCode()).isEqualTo(409);
+
assertThat(error.getMessage()).contains("authenticationEnabled");
+ });
+ verify(admin, never()).createUser(any(), any());
+ verify(admin, never()).updateAcl(any(), any(AclInfo.class));
+ }
}
diff --git a/web/src/api/acl.ts b/web/src/api/acl.ts
index 003a86922..47373a9b4 100644
--- a/web/src/api/acl.ts
+++ b/web/src/api/acl.ts
@@ -144,3 +144,33 @@ export async function
createAndUpdatePlainAccessConfig(data: Partial<PlainAccess
const res = await client.post<{ data: PlainAccessConfig
}>('/acl/plain-access-config', data);
return res.data.data;
}
+
+export interface PushAclBrokerOutcome {
+ broker: string;
+ success: boolean;
+ error?: string | null;
+}
+
+export interface PushAclAccountOutcome {
+ username: string;
+ subject: string;
+ policyCount: number;
+ skipped: string[];
+ brokers: PushAclBrokerOutcome[];
+}
+
+export interface PushAclToBrokerResult {
+ instanceId: string;
+ brokers: string[];
+ pushed: number;
+ failed: number;
+ accounts: PushAclAccountOutcome[];
+}
+
+/** Writes every stored ACL account of the instance to its master brokers
(additive upsert). */
+export async function pushAclToBroker(instanceId: string) {
+ const res = await client.post<{ data: PushAclToBrokerResult
}>('/acl/push-to-broker', {
+ instanceId,
+ });
+ 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 2888f2645..753923fb2 100644
--- a/web/src/pages/instance/__tests__/AclPage.test.tsx
+++ b/web/src/pages/instance/__tests__/AclPage.test.tsx
@@ -38,6 +38,7 @@ vi.mock('../../../services/aclService', () => ({
listAclRules: vi.fn(),
listAclUsers: vi.fn(),
pageAclUsers: vi.fn(),
+ pushAclToBroker: vi.fn(),
updateAclRule: vi.fn(),
updateAclUser: vi.fn(),
}));
@@ -70,6 +71,10 @@ const renderWithProviders = (ui: React.ReactElement,
initialEntry = '/') =>
</App>,
);
+const openRulesTab = async (user: ReturnType<typeof userEvent.setup>) => {
+ await user.click(screen.getByText('ACL 规则'));
+};
+
const deferred = <T,>() => {
let resolve!: (value: T) => void;
let reject!: (reason?: unknown) => void;
@@ -120,6 +125,49 @@ describe('ACL page', () => {
page: 1,
size: 20,
});
+ vi.mocked(aclService.pushAclToBroker).mockResolvedValue({
+ instanceId: 'instance-a',
+ brokers: [],
+ pushed: 0,
+ failed: 0,
+ accounts: [],
+ });
+ });
+
+ it('pushes stored accounts to the broker and summarizes every outcome',
async () => {
+ vi.mocked(aclService.pushAclToBroker).mockResolvedValue({
+ instanceId: 'instance-a',
+ brokers: ['10.0.0.1:10911'],
+ pushed: 1,
+ failed: 1,
+ accounts: [
+ {
+ username: 'remote-admin',
+ subject: 'User:remote-admin',
+ policyCount: 1,
+ skipped: [],
+ brokers: [{ broker: '10.0.0.1:10911', success: true, error: null }],
+ },
+ {
+ username: 'broken',
+ subject: 'User:broken',
+ policyCount: 0,
+ skipped: ['topic-: PREFIXED pattern has no broker ACL equivalent'],
+ brokers: [{ broker: '10.0.0.1:10911', success: false, error: 'acl
disabled' }],
+ },
+ ],
+ });
+ const user = userEvent.setup();
+ renderWithProviders(<AclPage />, '/instance/instance-a/acl');
+ await user.click(await screen.findByText('用户管理'));
+
+ await user.click(screen.getByRole('button', { name: /下发到 Broker/ }));
+
+ await waitFor(() =>
expect(aclService.pushAclToBroker).toHaveBeenCalledTimes(1));
+ const dialog = await screen.findByRole('dialog', { name: '下发到 Broker 结果'
});
+ expect(within(dialog).getByText(/成功 1 个、失败 1 个/)).toBeInTheDocument();
+ expect(within(dialog).getByText('User:broken')).toBeInTheDocument();
+ expect(within(dialog).getByText('acl disabled')).toBeInTheDocument();
});
it('loads ACL rules and users through the service layer', async () => {
@@ -165,6 +213,7 @@ describe('ACL page', () => {
size: 20,
});
renderWithProviders(<AclPage />);
+ await openRulesTab(user);
const row = await screen.findByRole('row', { name: /remote-topic/ });
await user.click(within(row).getByRole('button', { name: /删除/ }));
@@ -274,6 +323,7 @@ describe('ACL page', () => {
renderWithProviders(<AclPage />, '/instance/1/acl');
expect(await screen.findByText('remote-user')).toBeInTheDocument();
+ await user.click(screen.getByText('ACL 规则'));
await user.click(screen.getByRole('button', { name: /添加规则/ }));
const dialog = await screen.findByRole('dialog');
@@ -304,9 +354,41 @@ describe('ACL page', () => {
expect(screen.getByText('cluster-a')).toBeInTheDocument();
});
- it('explains that ACL records are Studio-local metadata', async () => {
+ it('opens on the user tab and lists it before the rule tab', async () => {
+ renderWithProviders(<AclPage />);
+
+ const tabs = await screen.findAllByRole('tab');
+ expect(tabs.map((tab) => tab.textContent)).toEqual(['用户管理', 'ACL 规则', '集群
ACL 配置']);
+ expect(tabs[0]).toHaveAttribute('aria-selected', 'true');
+ // 默认面板就是用户管理,不需要再点一次
+ expect(await screen.findByText('remote-admin')).toBeInTheDocument();
+ });
+
+ it('points Apache instances at the broker push instead of claiming
store-only metadata', async () => {
renderWithProviders(<AclPage />);
+ expect(await
screen.findByTestId('acl-local-metadata-notice')).toHaveTextContent(
+ '当前 ACL 规则和用户保存在 Studio,点「下发到 Broker」后写入选中实例',
+ );
+ });
+
+ it('keeps the store-only wording for cloud vendors without a push channel',
async () => {
+ vi.mocked(instanceService.listInstances).mockResolvedValue([
+ {
+ id: 21,
+ name: 'aliyun-rmq',
+ remark: '',
+ type: 'CLOUD',
+ endpoint: 'rmq-cn-x.aliyuncs.com:8080',
+ vendor: 'ALIYUN',
+ topicCount: 0,
+ consumerGroupCount: 0,
+ gmtCreate: '2026-01-01T00:00:00Z',
+ gmtModified: '2026-01-01T00:00:00Z',
+ },
+ ]);
+ renderWithProviders(<AclPage />, '/instance/aliyun-rmq/acl');
+
expect(await
screen.findByTestId('acl-local-metadata-notice')).toHaveTextContent(
'当前 ACL 规则和用户仅保存为 Studio 本地元数据',
);
@@ -385,6 +467,7 @@ describe('ACL page', () => {
});
renderWithProviders(<AclPage />, '/instance/tencent-rmq/acl');
+ await openRulesTab(user);
await waitFor(() =>
expect(screen.getByTestId('acl-local-metadata-notice')).toHaveTextContent(
@@ -481,6 +564,7 @@ describe('ACL page', () => {
actions: ['PUB'],
});
renderWithProviders(<AclPage />, '/instance/tencent-rmq/acl');
+ await openRulesTab(user);
await waitFor(() =>
expect(screen.getByTestId('acl-local-metadata-notice')).toHaveTextContent(
'Tencent Cloud Role',
@@ -490,6 +574,7 @@ describe('ACL page', () => {
const readerRow = await screen.findByRole('row', { name: /reader-role/
});
await user.click(within(readerRow).getByRole('button', { name: /编辑/ }));
} else {
+ await user.click(screen.getByText('ACL 规则'));
await user.click(screen.getByRole('button', { name: /添加规则/ }));
}
const dialog = await screen.findByRole('dialog');
@@ -549,6 +634,7 @@ describe('ACL page', () => {
aclVersion: 2,
});
renderWithProviders(<AclPage />);
+ await openRulesTab(user);
const row = await screen.findByRole('row', { name: /remote-user/ });
await user.click(within(row).getByRole('button', { name: /编辑/ }));
const dialog = await screen.findByRole('dialog');
@@ -620,8 +706,11 @@ describe('ACL page', () => {
});
renderWithProviders(<AclPage />);
- expect(await screen.findByText('no-time-rule')).toBeInTheDocument();
- expect(screen.getByText('-')).toBeInTheDocument();
+ // The rules pane is mounted but hidden until its tab is active, and
hidden nodes are not
+ // role-queryable, so activate the tab before asserting on the placeholder
dash.
+ await openRulesTab(user);
+ const ruleRow = await screen.findByRole('row', { name: /no-time-rule/ });
+ expect(within(ruleRow).getAllByText('-').length).toBeGreaterThan(0);
await user.click(screen.getByText('用户管理'));
const userPanel = screen.getByRole('tabpanel', { name: '用户管理' });
@@ -723,8 +812,7 @@ describe('ACL page', () => {
renderWithProviders(<AclPage />);
await user.click(await screen.findByText('用户管理'));
- const userPanel = screen.getByRole('tabpanel', { name: '用户管理' });
- await user.click(within(userPanel).getByRole('button', { name: /添加用户/ }));
+ await user.click(screen.getByRole('button', { name: /添加用户/ }));
const dialog = await screen.findByRole('dialog');
await user.type(
@@ -758,8 +846,7 @@ describe('ACL page', () => {
renderWithProviders(<AclPage />);
await user.click(await screen.findByText('用户管理'));
- const userPanel = screen.getByRole('tabpanel', { name: '用户管理' });
- await user.click(within(userPanel).getByRole('button', { name: /添加用户/ }));
+ await user.click(screen.getByRole('button', { name: /添加用户/ }));
const dialog = await screen.findByRole('dialog');
await user.type(
diff --git a/web/src/pages/instance/acl.tsx b/web/src/pages/instance/acl.tsx
index 2cf13d667..51f9eca91 100644
--- a/web/src/pages/instance/acl.tsx
+++ b/web/src/pages/instance/acl.tsx
@@ -27,6 +27,7 @@ import {
Select,
Tabs,
Modal,
+ Tooltip,
Form,
Switch,
Checkbox,
@@ -46,6 +47,7 @@ import {
Eye,
EyeSlash,
Key,
+ UploadSimple,
} from '@phosphor-icons/react';
import { EditOutlined, DeleteOutlined } from '@ant-design/icons';
import type { ColumnsType } from 'antd/es/table';
@@ -64,10 +66,17 @@ import {
examineBrokerClusterAclConfig,
listAclRules,
pageAclUsers,
+ pushAclToBroker,
updateAclRule,
updateAclUser,
} from '../../services/aclService';
-import type { AclRule, AclUser, AclClusterConfig, PlainAccessConfig } from
'../../api/acl';
+import type {
+ AclRule,
+ AclUser,
+ AclClusterConfig,
+ PlainAccessConfig,
+ PushAclToBrokerResult,
+} from '../../api/acl';
import { useInstanceFilter } from '../../hooks/useInstanceFilter';
import { tableScrollX } from '../../utils/table';
import { analyzeAclRisk, type AclRiskIssue } from
'../../utils/aclRiskDiagnostics';
@@ -136,6 +145,17 @@ const AclPageContent = ({
const hasSelectedInstance = Boolean(selectedInstanceId);
const selectedInstance = instances.find((instance) => instance.name ===
selectedInstanceId);
const tencentRoleMode = selectedInstance?.vendor === 'TENCENT';
+ // Apache 实例(以及还没选实例时)可以把 Store 里的账号与策略一键下发到 broker,
+ // 所以「仅本地元数据、请去集群侧管理」的旧口径只对没有下发通道的云厂商成立。
+ const apachePushMode =
+ selectedInstance === undefined ||
+ selectedInstance.vendor === undefined ||
+ selectedInstance.vendor === 'APACHE';
+ const noticeKeys = tencentRoleMode
+ ? { title: 'acl.tencentRoleNotice', description:
'acl.tencentRoleDescription' }
+ : apachePushMode
+ ? { title: 'acl.apachePushNotice', description:
'acl.apachePushDescription' }
+ : { title: 'acl.localMetadataNotice', description:
'acl.localMetadataDescription' };
/* ─── State ─── */
const [rules, setRules] = useState<AclRule[]>([]);
@@ -148,7 +168,7 @@ const AclPageContent = ({
const [userKeyword, setUserKeyword] = useState('');
const [ruleSubmitting, setRuleSubmitting] = useState(false);
const [userSubmitting, setUserSubmitting] = useState(false);
- const [activeTab, setActiveTab] = useState('rules');
+ const [activeTab, setActiveTab] = useState('users');
const [ruleRefreshKey, setRuleRefreshKey] = useState(0);
const [userRefreshKey, setUserRefreshKey] = useState(0);
const [ruleTotal, setRuleTotal] = useState(0);
@@ -184,6 +204,8 @@ const AclPageContent = ({
// Cluster ACL config (examineBrokerClusterAclConfig)
const [clusterConfig, setClusterConfig] = useState<AclClusterConfig |
null>(null);
const [configLoading, setConfigLoading] = useState(false);
+ const [pushing, setPushing] = useState(false);
+ const [pushResult, setPushResult] = useState<PushAclToBrokerResult |
null>(null);
const [clusterIdInput, setClusterIdInput] = useState('DefaultCluster');
const examineRequestGenerationRef = useRef(0);
@@ -512,6 +534,98 @@ const AclPageContent = ({
}
};
+ // 一键下发:把本页维护的账号与规则增量写进 broker(broker 上其他账号不动),
+ // 结果逐账号逐 broker 回报,失败不中断其余账号。
+ const pushDisabledReason = !selectedInstanceId
+ ? t('acl.pushNeedInstance')
+ : !apachePushMode
+ ? t('acl.pushApacheOnly')
+ : undefined;
+
+ const handlePushToBroker = async () => {
+ if (!selectedInstanceId) return;
+ setPushing(true);
+ try {
+ const result = await pushAclToBroker(selectedInstanceId);
+ setPushResult(result);
+ if (result.failed === 0) {
+ message.success(t('acl.pushToBrokerDone', { pushed: result.pushed }));
+ } else {
+ message.warning(
+ t('acl.pushToBrokerPartial', { pushed: result.pushed, failed:
result.failed }),
+ );
+ }
+ } catch {
+ message.error(t('acl.pushToBrokerFailed'));
+ } finally {
+ setPushing(false);
+ }
+ };
+
+ const pushColumns: ColumnsType<PushAclToBrokerResult['accounts'][number]> = [
+ {
+ title: t('acl.pushAccount'),
+ dataIndex: 'username',
+ key: 'username',
+ render: (_, account) => (
+ <Space direction="vertical" size={0}>
+ <Typography.Text strong>{account.username}</Typography.Text>
+ <Typography.Text type="secondary" style={{ fontSize: 14, fontFamily:
'monospace' }}>
+ {account.subject}
+ </Typography.Text>
+ </Space>
+ ),
+ },
+ {
+ title: t('acl.pushPolicies'),
+ dataIndex: 'policyCount',
+ key: 'policyCount',
+ width: 90,
+ align: 'right',
+ },
+ {
+ title: t('acl.pushSkipped'),
+ key: 'skipped',
+ minWidth: 200,
+ render: (_, account) =>
+ account.skipped.length === 0 ? (
+ <Typography.Text type="secondary">—</Typography.Text>
+ ) : (
+ <Space direction="vertical" size={2}>
+ {account.skipped.map((reason) => (
+ <Typography.Text key={reason} type="secondary" style={{
fontSize: 14 }}>
+ {reason}
+ </Typography.Text>
+ ))}
+ </Space>
+ ),
+ },
+ {
+ title: t('acl.pushBrokerResults'),
+ key: 'brokers',
+ width: 260,
+ render: (_, account) => (
+ <Space direction="vertical" size={2}>
+ {account.brokers.map((broker) => (
+ <Space key={broker.broker} size={6}>
+ <Tag color={broker.success ? 'success' : 'error'}>
+ {broker.success ? t('acl.pushBrokerOk') :
t('acl.pushBrokerFailed')}
+ </Tag>
+ <Typography.Text type="secondary" style={{ fontSize: 14,
fontFamily: 'monospace' }}>
+ {broker.broker}
+ </Typography.Text>
+ {broker.error && (
+ <Typography.Text type="secondary" style={{ fontSize: 14 }}>
+ {broker.error}
+ </Typography.Text>
+ )}
+ </Space>
+ ))}
+ </Space>
+ ),
+ },
+ ];
+
const openAddPlainModal = () => {
setEditingPlain(null);
plainForm.resetFields();
@@ -1077,22 +1191,45 @@ const AclPageContent = ({
title={t('acl.title')}
subtitle={t('acl.subtitle', { rules: rules.length, users: users.length
})}
extra={
- <Button
- type="primary"
- icon={<Plus size={14} weight="bold" />}
- onClick={activeTab === 'rules' ? openAddRuleModal :
openAddUserModal}
- >
- {activeTab === 'rules' ? t('acl.addRule') : t('acl.addUser')}
- </Button>
+ <Flex gap={8}>
+ <Tooltip title={pushDisabledReason}>
+ <span>
+ <Button
+ icon={<UploadSimple size={14} />}
+ loading={pushing}
+ disabled={pushDisabledReason !== undefined}
+ onClick={() => void handlePushToBroker()}
+ >
+ {t('acl.pushToBroker')}
+ </Button>
+ </span>
+ </Tooltip>
+ <Button
+ type="primary"
+ icon={<Plus size={14} weight="bold" />}
+ onClick={activeTab === 'rules' ? openAddRuleModal :
openAddUserModal}
+ >
+ {activeTab === 'rules' ? t('acl.addRule') : t('acl.addUser')}
+ </Button>
+ </Flex>
}
/>
+ <div style={{ marginBottom: 12 }}>
+ <InstanceSelect
+ value={selectedInstanceId || undefined}
+ onChange={selectInstance}
+ options={instanceOptions}
+ style={{ width: 260 }}
+ failed={instancesFailed}
+ onRetry={reloadInstances}
+ />
+ </div>
+
<InfoBanner
data-testid="acl-local-metadata-notice"
- title={t(tencentRoleMode ? 'acl.tencentRoleNotice' :
'acl.localMetadataNotice')}
- description={t(
- tencentRoleMode ? 'acl.tencentRoleDescription' :
'acl.localMetadataDescription',
- )}
+ title={t(noticeKeys.title)}
+ description={t(noticeKeys.description)}
/>
<Card variant="borderless" styles={{ body: { padding: 0 } }}>
@@ -1101,8 +1238,57 @@ const AclPageContent = ({
onChange={setActiveTab}
style={{ padding: '0 16px' }}
items={[
+ {
+ key: 'users',
+ forceRender: true,
+ label: (
+ <Space size={6}>
+ <User size={15} />
+ <span>{t('acl.userTab')}</span>
+ </Space>
+ ),
+ children: (
+ <div>
+ <div style={{ padding: '0 0 16px' }}>
+ <Space>
+ <Input.Search
+ value={userKeyword}
+ onChange={(event) => {
+ setUserPage(1);
+ setUserKeyword(event.target.value);
+ }}
+ allowClear
+ style={{ width: 240 }}
+ />
+ </Space>
+ </div>
+
+ <Table
+ columns={userColumns}
+ dataSource={users}
+ rowKey="id"
+ loading={usersLoading}
+ pagination={{
+ current: userPage,
+ pageSize: userPageSize,
+ total: userTotal,
+ showSizeChanger: true,
+ showTotal: (total) => t('acl.totalUsers', { n: total }),
+ onChange: (page, pageSize) => {
+ setUserPage(page);
+ setUserPageSize(pageSize);
+ },
+ }}
+ size="small"
+ tableLayout="fixed"
+ scroll={{ x: tableScrollX(userColumns) }}
+ />
+ </div>
+ ),
+ },
{
key: 'rules',
+ forceRender: true,
label: (
<Space size={6}>
<ShieldCheck size={15} />
@@ -1120,14 +1306,6 @@ const AclPageContent = ({
flexWrap: 'wrap',
}}
>
- <InstanceSelect
- value={selectedInstanceId || undefined}
- onChange={selectInstance}
- options={instanceOptions}
- style={{ width: 220 }}
- failed={instancesFailed}
- onRetry={reloadInstances}
- />
<Input
placeholder={t('acl.searchPrincipal')}
prefix={<MagnifyingGlass size={14} color="#9CA3AF" />}
@@ -1219,60 +1397,6 @@ const AclPageContent = ({
</div>
),
},
- {
- key: 'users',
- label: (
- <Space size={6}>
- <User size={15} />
- <span>{t('acl.userTab')}</span>
- </Space>
- ),
- children: (
- <div>
- <div style={{ padding: '0 0 16px' }}>
- <Space>
- <Button
- type="primary"
- icon={<Plus size={14} weight="bold" />}
- onClick={openAddUserModal}
- >
- {t('acl.addUser')}
- </Button>
- <Input.Search
- value={userKeyword}
- onChange={(event) => {
- setUserPage(1);
- setUserKeyword(event.target.value);
- }}
- allowClear
- style={{ width: 240 }}
- />
- </Space>
- </div>
-
- <Table
- columns={userColumns}
- dataSource={users}
- rowKey="id"
- loading={usersLoading}
- pagination={{
- current: userPage,
- pageSize: userPageSize,
- total: userTotal,
- showSizeChanger: true,
- showTotal: (total) => t('acl.totalUsers', { n: total }),
- onChange: (page, pageSize) => {
- setUserPage(page);
- setUserPageSize(pageSize);
- },
- }}
- size="small"
- tableLayout="fixed"
- scroll={{ x: tableScrollX(userColumns) }}
- />
- </div>
- ),
- },
{
key: 'clusterConfig',
label: (
@@ -1694,6 +1818,37 @@ const AclPageContent = ({
</Form.Item>
</Form>
</Modal>
+
+ <Modal
+ title={t('acl.pushToBrokerTitle')}
+ open={pushResult !== null}
+ onCancel={() => setPushResult(null)}
+ footer={<Button onClick={() =>
setPushResult(null)}>{t('common.close')}</Button>}
+ width={820}
+ destroyOnHidden
+ >
+ {pushResult && (
+ <Flex vertical gap={12}>
+ <InfoBanner
+ description={t('acl.pushToBrokerSummary', {
+ pushed: pushResult.pushed,
+ failed: pushResult.failed,
+ brokers: pushResult.brokers.join(', ') || '-',
+ })}
+ style={{ marginBottom: 0 }}
+ />
+ <Table
+ rowKey="username"
+ size="small"
+ columns={pushColumns}
+ dataSource={pushResult.accounts}
+ pagination={false}
+ tableLayout="fixed"
+ scroll={{ x: tableScrollX(pushColumns) }}
+ />
+ </Flex>
+ )}
+ </Modal>
</div>
);
};
diff --git a/web/src/services/aclService.ts b/web/src/services/aclService.ts
index 2b5084376..6951ec3d4 100644
--- a/web/src/services/aclService.ts
+++ b/web/src/services/aclService.ts
@@ -9,6 +9,7 @@ import type {
AclUserPage,
AclClusterConfig,
PlainAccessConfig,
+ PushAclToBrokerResult,
} from '../api/acl';
import { aclRules as mockRules, aclUsers as mockUsers } from '../mock/acl';
@@ -279,3 +280,24 @@ export async function createAndUpdatePlainAccessConfig(
}
return aclApi.createAndUpdatePlainAccessConfig(data);
}
+
+export async function pushAclToBroker(instanceId: string):
Promise<PushAclToBrokerResult> {
+ if (isMockMode()) {
+ return {
+ instanceId,
+ brokers: ['broker-a:10911'],
+ pushed: 1,
+ failed: 0,
+ accounts: [
+ {
+ username: 'order-producer',
+ subject: 'User:order-producer',
+ policyCount: 1,
+ skipped: [],
+ brokers: [{ broker: 'broker-a:10911', success: true, error: null }],
+ },
+ ],
+ };
+ }
+ return aclApi.pushAclToBroker(instanceId);
+}