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 da34a5890 fix(acl): keep the stored admin flag on a partial 
plain-access update (#7139)
da34a5890 is described below

commit da34a5890decb280c24816ee3156bf1159de5387
Author: btlqql <[email protected]>
AuthorDate: Mon Oct 26 20:40:18 2026 +0800

    fix(acl): keep the stored admin flag on a partial plain-access update 
(#7139)
    
    * fix(acl): keep the stored admin flag on a partial plain-access update
    admin is optional in the documented plain-access write contract, but the 
write path could not tell "not provided" from "explicitly false": the request 
DTO and the write model carried a primitive boolean and 
createAndUpdatePlainAccessConfig always wrote it. Saving an existing admin 
account without naming the flag - rotating its secret, editing its whitelist or 
its permission entries - silently demoted it, answered admin:false and dropped 
it out of the ACL risk view's admin count. Follo [...]
    * docs(api-spec): spell out the tri-state admin flag on plain-access upsert
    The flag became optional with keep-stored semantics; the request row still 
read as
    a plain boolean and the response said nothing about which value comes back.
---
 docs/api-spec.md                                   |  5 +-
 .../rocketmq/studio/instance/acl/AclService.java   |  2 +-
 .../instance/acl/MybatisPlusAclRepository.java     |  9 +++-
 .../studio/instance/acl/PlainAccessConfigVO.java   |  7 ++-
 .../instance/acl/UpsertPlainAccessConfigDTO.java   |  6 ++-
 .../instance/acl/MybatisPlusAclRepositoryTest.java | 63 +++++++++++++++++++++-
 6 files changed, 83 insertions(+), 9 deletions(-)

diff --git a/docs/api-spec.md b/docs/api-spec.md
index 8c9782aa3..eaac7d7f5 100644
--- a/docs/api-spec.md
+++ b/docs/api-spec.md
@@ -1772,13 +1772,14 @@ POST /api/acl/plain-access-config
 | `accessKey` | `string` | 是 | 账号 AccessKey(更新时不可变更) |
 | `secretKey` | `string` | 条件 | 新建账号必填;更新时留空表示保留原密钥 |
 | `whiteRemoteAddress` | `string` | 否 | IP 白名单,持久化存储,空表示不限制 |
-| `admin` | `boolean` | 否 | 是否管理员 |
+| `admin` | `boolean` | 否 | 是否管理员;**省略 = 保留存量值**(新建账号默认 `false`),显式传值才覆盖 |
 | `defaultTopicPerm` | `string` | 否 | 默认 Topic 权限 |
 | `defaultGroupPerm` | `string` | 否 | 默认 Group 权限 |
 | `topicPerms` | `string[]` | 否 | 逐 Topic 权限,格式 `resource=action` |
 | `groupPerms` | `string[]` | 否 | 逐 Group 权限,格式 `resource=action` |
 
-**Response `data`:** `PlainAccessConfig`。`secretKey` 仅在本次显式提交时回显,保留原密钥时返回 
`null`。
+**Response `data`:** `PlainAccessConfig`。`secretKey` 仅在本次显式提交时回显,保留原密钥时返回 
`null`;
+`admin` 回显**生效值**(省略写入时为保留后的存量值)。
 
 ### 7.10 查看单个用户明文凭证
 
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 7c7536659..224d2482d 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
@@ -333,7 +333,7 @@ public class AclService {
         }
         log.info("Creating/updating plain access config accessKey={}", 
config.getAccessKey());
         PlainAccessConfigVO saved = 
aclRepository.createAndUpdatePlainAccessConfig(config);
-        String auditDetail = "admin=" + saved.isAdmin()
+        String auditDetail = "admin=" + Boolean.TRUE.equals(saved.getAdmin())
                 + ", whiteRemoteAddressConfigured="
                 + StringUtils.hasText(saved.getWhiteRemoteAddress());
         recordAudit("UPSERT_PLAIN_ACCESS_CONFIG", "ACL_USER", 
saved.getAccessKey(), null,
diff --git 
a/server/src/main/java/org/apache/rocketmq/studio/instance/acl/MybatisPlusAclRepository.java
 
b/server/src/main/java/org/apache/rocketmq/studio/instance/acl/MybatisPlusAclRepository.java
index 5f751e992..f51eee559 100644
--- 
a/server/src/main/java/org/apache/rocketmq/studio/instance/acl/MybatisPlusAclRepository.java
+++ 
b/server/src/main/java/org/apache/rocketmq/studio/instance/acl/MybatisPlusAclRepository.java
@@ -262,7 +262,12 @@ public class MybatisPlusAclRepository implements 
AclRepository {
             // Blank secret on an existing account keeps the stored secret 
unchanged.
             entity.setSecretKey(existing.getSecretKey());
         }
-        entity.setAdmin(config.isAdmin());
+        // The flag is optional in the write contract: an omitted flag keeps 
the stored value for an
+        // existing account (as the sibling user update does), a new account 
defaults to false, and
+        // an explicit value always wins.
+        boolean admin = config.getAdmin() != null ? config.getAdmin()
+                : existing != null && Boolean.TRUE.equals(existing.getAdmin());
+        entity.setAdmin(admin);
         entity.setClusters(null);
         
entity.setWhiteRemoteAddress(normalizeWhiteRemoteAddress(config.getWhiteRemoteAddress()));
         entity.setGmtModified(LocalDateTime.now());
@@ -300,7 +305,7 @@ public class MybatisPlusAclRepository implements 
AclRepository {
                 // hidden (read-back views always mask it).
                 .secretKey(secretProvided ? config.getSecretKey() : null)
                 .whiteRemoteAddress(entity.getWhiteRemoteAddress())
-                .admin(config.isAdmin())
+                .admin(admin)
                 .defaultTopicPerm(config.getDefaultTopicPerm())
                 .defaultGroupPerm(config.getDefaultGroupPerm())
                 .topicPerms(config.getTopicPerms() == null ? null : new 
ArrayList<>(config.getTopicPerms()))
diff --git 
a/server/src/main/java/org/apache/rocketmq/studio/instance/acl/PlainAccessConfigVO.java
 
b/server/src/main/java/org/apache/rocketmq/studio/instance/acl/PlainAccessConfigVO.java
index e1c167c39..95be05839 100644
--- 
a/server/src/main/java/org/apache/rocketmq/studio/instance/acl/PlainAccessConfigVO.java
+++ 
b/server/src/main/java/org/apache/rocketmq/studio/instance/acl/PlainAccessConfigVO.java
@@ -51,8 +51,11 @@ public class PlainAccessConfigVO {
     /** IP whitelist pattern for this account; persisted, empty/null means no 
restriction. */
     private String whiteRemoteAddress;
 
-    /** Whether this account has admin privileges. */
-    private boolean admin;
+    /**
+     * Whether this account has admin privileges. Null on a write that did not 
name the flag: the
+     * write path then keeps the stored value, and the response carries the 
effective value.
+     */
+    private Boolean admin;
 
     /** Default permission applied to topics, e.g. DENY / PUB / SUB / ALL. */
     private String defaultTopicPerm;
diff --git 
a/server/src/main/java/org/apache/rocketmq/studio/instance/acl/UpsertPlainAccessConfigDTO.java
 
b/server/src/main/java/org/apache/rocketmq/studio/instance/acl/UpsertPlainAccessConfigDTO.java
index 4eb259c9d..c045abdb6 100644
--- 
a/server/src/main/java/org/apache/rocketmq/studio/instance/acl/UpsertPlainAccessConfigDTO.java
+++ 
b/server/src/main/java/org/apache/rocketmq/studio/instance/acl/UpsertPlainAccessConfigDTO.java
@@ -39,7 +39,11 @@ public class UpsertPlainAccessConfigDTO {
 
     private String whiteRemoteAddress;
 
-    private boolean admin;
+    /**
+     * Null when the admin flag was not part of the write, in which case an 
existing account keeps
+     * its stored value and a new account defaults to false; an explicit value 
always wins.
+     */
+    private Boolean admin;
 
     private String defaultTopicPerm;
 
diff --git 
a/server/src/test/java/org/apache/rocketmq/studio/instance/acl/MybatisPlusAclRepositoryTest.java
 
b/server/src/test/java/org/apache/rocketmq/studio/instance/acl/MybatisPlusAclRepositoryTest.java
index 6b5b984d9..e4b071b96 100644
--- 
a/server/src/test/java/org/apache/rocketmq/studio/instance/acl/MybatisPlusAclRepositoryTest.java
+++ 
b/server/src/test/java/org/apache/rocketmq/studio/instance/acl/MybatisPlusAclRepositoryTest.java
@@ -458,7 +458,68 @@ class MybatisPlusAclRepositoryTest {
                 .isEqualTo(CredentialUtils.encodeBase64("kept-secret-value"));
         // The kept secret is not echoed back.
         assertThat(result.getSecretKey()).isNull();
-        assertThat(result.isAdmin()).isTrue();
+        assertThat(result.getAdmin()).isTrue();
+    }
+
+    @Test
+    void updateWithoutTheAdminFlagShouldKeepTheStoredAdminFlag() {
+        RmqAclUser existing = userEntity(1L, "svc-x",
+                CredentialUtils.encodeBase64("kept-secret-value"));
+        existing.setAdmin(true);
+        
when(userMapper.selectList(any(QueryWrapper.class))).thenReturn(List.of(existing));
+        when(userMapper.updateById(any(RmqAclUser.class))).thenReturn(1);
+        when(ruleMapper.delete(any(QueryWrapper.class))).thenReturn(0);
+
+        // admin is optional in the write contract: rotating the secret must 
not demote the account.
+        PlainAccessConfigVO config = 
PlainAccessConfigVO.builder().accessKey("svc-x").build();
+
+        PlainAccessConfigVO result = 
repository.createAndUpdatePlainAccessConfig(config);
+
+        ArgumentCaptor<RmqAclUser> captor = 
ArgumentCaptor.forClass(RmqAclUser.class);
+        verify(userMapper).updateById(captor.capture());
+        assertThat(captor.getValue().getAdmin()).isTrue();
+        assertThat(result.getAdmin()).isTrue();
+    }
+
+    @Test
+    void updateWithAnExplicitFalseShouldClearTheStoredAdminFlag() {
+        RmqAclUser existing = userEntity(1L, "svc-x",
+                CredentialUtils.encodeBase64("kept-secret-value"));
+        existing.setAdmin(true);
+        
when(userMapper.selectList(any(QueryWrapper.class))).thenReturn(List.of(existing));
+        when(userMapper.updateById(any(RmqAclUser.class))).thenReturn(1);
+        when(ruleMapper.delete(any(QueryWrapper.class))).thenReturn(0);
+
+        PlainAccessConfigVO config = PlainAccessConfigVO.builder()
+                .accessKey("svc-x")
+                .admin(false)
+                .build();
+
+        PlainAccessConfigVO result = 
repository.createAndUpdatePlainAccessConfig(config);
+
+        ArgumentCaptor<RmqAclUser> captor = 
ArgumentCaptor.forClass(RmqAclUser.class);
+        verify(userMapper).updateById(captor.capture());
+        assertThat(captor.getValue().getAdmin()).isFalse();
+        assertThat(result.getAdmin()).isFalse();
+    }
+
+    @Test
+    void createWithoutTheAdminFlagShouldStoreFalse() {
+        
when(userMapper.selectList(any(QueryWrapper.class))).thenReturn(List.of());
+        when(userMapper.insert(any(RmqAclUser.class))).thenReturn(1);
+        when(ruleMapper.delete(any(QueryWrapper.class))).thenReturn(0);
+
+        PlainAccessConfigVO config = PlainAccessConfigVO.builder()
+                .accessKey("svc-new")
+                .secretKey("secret-new")
+                .build();
+
+        PlainAccessConfigVO result = 
repository.createAndUpdatePlainAccessConfig(config);
+
+        ArgumentCaptor<RmqAclUser> captor = 
ArgumentCaptor.forClass(RmqAclUser.class);
+        verify(userMapper).insert(captor.capture());
+        assertThat(captor.getValue().getAdmin()).isFalse();
+        assertThat(result.getAdmin()).isFalse();
     }
 
     @Test

Reply via email to