This is an automated email from the ASF dual-hosted git repository.

CalvinKirs pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/doris.git


The following commit(s) were added to refs/heads/master by this push:
     new 38339c5d1c4 [improvement](auth) Scope mysql.user rows to the caller 
and mask password columns (#67444)
38339c5d1c4 is described below

commit 38339c5d1c4213caa8f7875ac051a3c1f17aa8a2
Author: Calvin Kirs <[email protected]>
AuthorDate: Thu Sep 3 14:55:32 2026 +0800

    [improvement](auth) Scope mysql.user rows to the caller and mask password 
columns (#67444)
    
    ### What this PR does
    
    Adjusts what `mysql.user` returns so the visible rows follow the
    requesting user's privileges, and keeps password-derived columns out of
    the result entirely.
    
    - Rows are now scoped to the caller: role administrators (`ADMIN_PRIV`
    or `GRANT_PRIV`) still see every account; other users see only their own
    account.
    - The `authentication_string` and `password_policy.history_passwords`
    columns are always rendered as `***` for every caller, including
    accounts with an empty password.
    
    To make row scoping possible, the caller identity is threaded through
    `TShowUserRequest` (the same pattern already used by the sibling
    schema-table scanners such as `user_privileges` and `processlist`), so
    the FE can filter rows. A request without an identity returns no rows.
    
    ### Compatibility
    
    - `current_user_ident` is an `optional` Thrift field, wire-compatible in
    both directions.
    - No metadata / editlog / storage-format change, so downgrade is clean.
    - During a rolling window where a new FE talks to an old BE that does
    not set the field, `mysql.user` returns no rows (fail-closed) until the
    BE is also upgraded; upgrading BE before FE avoids this.
    
    ### Tests
    
    - `FrontendServiceImplTest#testShowUser` asserts the administrator,
    normal-user, and no-identity behaviors, including that the password
    columns are masked.
    - `regression-test/suites/auth_p0/test_mysql_user_visibility.groovy`
    covers the end-to-end admin-vs-normal-user visibility and masking.
---
 be/src/information_schema/schema_user_scanner.cpp  |  3 +
 .../org/apache/doris/mysql/privilege/Auth.java     | 27 +++++++-
 .../apache/doris/service/FrontendServiceImpl.java  |  6 +-
 .../doris/service/FrontendServiceImplTest.java     | 54 ++++++++++++++--
 gensrc/thrift/FrontendService.thrift               |  1 +
 .../auth_p0/test_mysql_user_visibility.groovy      | 74 ++++++++++++++++++++++
 6 files changed, 157 insertions(+), 8 deletions(-)

diff --git a/be/src/information_schema/schema_user_scanner.cpp 
b/be/src/information_schema/schema_user_scanner.cpp
index 3b2211ee69b..260ee8a814f 100644
--- a/be/src/information_schema/schema_user_scanner.cpp
+++ b/be/src/information_schema/schema_user_scanner.cpp
@@ -70,6 +70,9 @@ SchemaUserScanner::~SchemaUserScanner() = default;
 
 Status SchemaUserScanner::start(RuntimeState* state) {
     TShowUserRequest request;
+    if (nullptr != _param->common_param->current_user_ident) {
+        
request.__set_current_user_ident(*_param->common_param->current_user_ident);
+    }
     RETURN_IF_ERROR(SchemaHelper::show_user(*(_param->common_param->ip), 
_param->common_param->port,
                                             request, &_user_result));
 
diff --git 
a/fe/fe-core/src/main/java/org/apache/doris/mysql/privilege/Auth.java 
b/fe/fe-core/src/main/java/org/apache/doris/mysql/privilege/Auth.java
index 75fcced4f2a..e2a1182bd22 100644
--- a/fe/fe-core/src/main/java/org/apache/doris/mysql/privilege/Auth.java
+++ b/fe/fe-core/src/main/java/org/apache/doris/mysql/privilege/Auth.java
@@ -101,6 +101,8 @@ public class Auth implements Writable {
     // unknown user does not have any privilege, this is just to be compatible 
with old version.
     public static final String UNKNOWN_USER = "unknown";
     public static final String DEFAULT_CATALOG = 
InternalCatalog.INTERNAL_CATALOG_NAME;
+    // Placeholder shown in mysql.user for password-derived columns, so no 
secret material leaks.
+    private static final String PASSWORD_MASK = "***";
 
     // There is no concurrency control logic inside 
roleManager,userManager,userRoleManage and rpropertyMgr,
     // and it is completely managed by Auth.
@@ -2098,7 +2100,12 @@ public class Auth implements Writable {
     // ====== END CLOUD ======
 
     // for mysql.user table
-    public List<List<String>> getAllUserInfo() {
+    public List<List<String>> getAllUserInfo(UserIdentity currentUser) {
+        // Only role administrators (ADMIN_PRIV or GRANT_PRIV) may see every 
account. A
+        // non-privileged user may only see their own account, so that 
mysql.user does not
+        // leak the cluster's account list and privilege topology to arbitrary 
users.
+        boolean canSeeAll = currentUser != null
+                && 
Env.getCurrentEnv().getAccessManager().checkGlobalPriv(currentUser, 
PrivPredicate.GRANT);
         List<List<String>> userInfos = Lists.newArrayList();
         readLock();
         try {
@@ -2106,8 +2113,18 @@ public class Auth implements Writable {
             for (List<User> users : nameToUsers.values()) {
                 for (User user : users) {
                     if (!user.isSetByDomainResolver()) {
-                        List<String> userInfo = 
Lists.newArrayList(Collections.nCopies(32, ""));
                         UserIdentity userIdent = user.getUserIdentity();
+                        // A non-privileged caller may only see its own 
account. user@hostA and
+                        // user@hostB are distinct accounts with independent 
privileges, so match the
+                        // exact identity (name and host) rather than the name 
alone; otherwise another
+                        // same-named account's host and privilege state would 
leak. The caller identity
+                        // carried here is ConnectContext.currentUserIdentity, 
i.e. the account
+                        // definition that authentication resolved to (a 
domain account resolves back to
+                        // its user@['domain'] identity), so this still 
matches the caller's own row.
+                        if (!canSeeAll && (currentUser == null || 
!userIdent.equals(currentUser))) {
+                            continue;
+                        }
+                        List<String> userInfo = 
Lists.newArrayList(Collections.nCopies(32, ""));
                         userInfo.set(0, userIdent.getHost());
                         userInfo.set(1, userIdent.getQualifiedUser());
                         for (int i = 2; i <= 13; i++) {
@@ -2180,6 +2197,12 @@ public class Auth implements Writable {
                                 userInfo.set(24 + i, 
passWordPolicyInfo.get(i).get(1));
                             }
                         }
+                        // Never expose password-derived material through 
mysql.user. The
+                        // authentication_string hash and the 
password_policy.history_passwords
+                        // digests are always masked, for every caller and 
even when empty, so no
+                        // secret material (or its presence/absence) leaks.
+                        userInfo.set(23, PASSWORD_MASK);
+                        userInfo.set(27, PASSWORD_MASK);
                         userInfos.add(userInfo);
                     }
                 }
diff --git 
a/fe/fe-core/src/main/java/org/apache/doris/service/FrontendServiceImpl.java 
b/fe/fe-core/src/main/java/org/apache/doris/service/FrontendServiceImpl.java
index ea4c8c18a06..6c6fc49ac5f 100644
--- a/fe/fe-core/src/main/java/org/apache/doris/service/FrontendServiceImpl.java
+++ b/fe/fe-core/src/main/java/org/apache/doris/service/FrontendServiceImpl.java
@@ -5648,7 +5648,11 @@ public class FrontendServiceImpl implements 
FrontendService.Iface {
 
     @Override
     public TShowUserResult showUser(TShowUserRequest request) {
-        List<List<String>> userInfo = 
Env.getCurrentEnv().getAuth().getAllUserInfo();
+        UserIdentity currentUser = null;
+        if (request.isSetCurrentUserIdent()) {
+            currentUser = UserIdentity.fromThrift(request.current_user_ident);
+        }
+        List<List<String>> userInfo = 
Env.getCurrentEnv().getAuth().getAllUserInfo(currentUser);
         TShowUserResult result = new TShowUserResult();
         result.setUserinfoList(userInfo);
         return result;
diff --git 
a/fe/fe-core/src/test/java/org/apache/doris/service/FrontendServiceImplTest.java
 
b/fe/fe-core/src/test/java/org/apache/doris/service/FrontendServiceImplTest.java
index bc5bb4d8aab..d698203a66f 100644
--- 
a/fe/fe-core/src/test/java/org/apache/doris/service/FrontendServiceImplTest.java
+++ 
b/fe/fe-core/src/test/java/org/apache/doris/service/FrontendServiceImplTest.java
@@ -66,7 +66,6 @@ import org.apache.doris.thrift.TSchemaTableName;
 import org.apache.doris.thrift.TSchemaTableRequestParams;
 import org.apache.doris.thrift.TShowProcessListRequest;
 import org.apache.doris.thrift.TShowUserRequest;
-import org.apache.doris.thrift.TShowUserResult;
 import org.apache.doris.thrift.TStatusCode;
 import org.apache.doris.thrift.TTableStatus;
 import org.apache.doris.thrift.TTabletLocation;
@@ -583,11 +582,56 @@ public class FrontendServiceImplTest extends 
TestWithFeService {
     }
 
     @Test
-    public void testShowUser() {
+    public void testShowUser() throws Exception {
+        // Column indexes in the mysql.user row layout that carry 
password-derived material.
+        final int authStringIdx = 23;   // authentication_string
+        final int historyPwIdx = 27;    // password_policy.history_passwords
+        final int userNameIdx = 1;      // User
+
+        addUser("show_user_a", true);
+        addUser("show_user_b", true);
+
         FrontendServiceImpl impl = new FrontendServiceImpl(exeEnv);
-        TShowUserRequest request = new TShowUserRequest();
-        TShowUserResult result = impl.showUser(request);
-        System.out.println(result);
+
+        // A role administrator (root has ADMIN_PRIV) sees every account, but 
the password-derived
+        // columns are always masked, even for accounts with an empty password.
+        TShowUserRequest adminRequest = new TShowUserRequest();
+        adminRequest.setCurrentUserIdent(UserIdentity.ROOT.toThrift());
+        List<List<String>> adminRows = 
impl.showUser(adminRequest).getUserinfoList();
+        Assertions.assertTrue(adminRows.size() >= 2, "admin should see all 
accounts");
+        Assertions.assertTrue(adminRows.stream().anyMatch(r -> 
"show_user_a".equals(r.get(userNameIdx))));
+        Assertions.assertTrue(adminRows.stream().anyMatch(r -> 
"show_user_b".equals(r.get(userNameIdx))));
+        for (List<String> row : adminRows) {
+            Assertions.assertEquals("***", row.get(authStringIdx));
+            Assertions.assertEquals("***", row.get(historyPwIdx));
+        }
+
+        // A non-privileged user only sees their own row, with the password 
columns masked, so
+        // mysql.user does not leak the cluster's account list or privilege 
topology.
+        TShowUserRequest userRequest = new TShowUserRequest();
+        userRequest.setCurrentUserIdent(
+                UserIdentity.createAnalyzedUserIdentWithIp("show_user_a", 
"%").toThrift());
+        List<List<String>> userRows = 
impl.showUser(userRequest).getUserinfoList();
+        Assertions.assertEquals(1, userRows.size());
+        Assertions.assertEquals("show_user_a", 
userRows.get(0).get(userNameIdx));
+        Assertions.assertEquals("***", userRows.get(0).get(authStringIdx));
+        Assertions.assertEquals("***", userRows.get(0).get(historyPwIdx));
+
+        // Same name, different host are distinct accounts: a non-privileged 
caller must see only
+        // its exact user@host row, not the same-named account bound to 
another host.
+        executeCommand("create user 'dup_host_user'@'192.168.0.1'");
+        executeCommand("create user 'dup_host_user'@'10.0.0.1'");
+        TShowUserRequest dupRequest = new TShowUserRequest();
+        dupRequest.setCurrentUserIdent(
+                UserIdentity.createAnalyzedUserIdentWithIp("dup_host_user", 
"192.168.0.1").toThrift());
+        List<List<String>> dupRows = 
impl.showUser(dupRequest).getUserinfoList();
+        Assertions.assertEquals(1, dupRows.size());
+        Assertions.assertEquals("dup_host_user", 
dupRows.get(0).get(userNameIdx));
+        Assertions.assertEquals("192.168.0.1", dupRows.get(0).get(0));
+
+        // Fail closed: a request without a caller identity (e.g. a 
pre-upgrade BE that does not
+        // set the field) exposes no rows rather than leaking every account.
+        Assertions.assertTrue(impl.showUser(new 
TShowUserRequest()).getUserinfoList().isEmpty());
     }
 
     @Test
diff --git a/gensrc/thrift/FrontendService.thrift 
b/gensrc/thrift/FrontendService.thrift
index 90f4a4b41d0..f574b5bba40 100644
--- a/gensrc/thrift/FrontendService.thrift
+++ b/gensrc/thrift/FrontendService.thrift
@@ -1623,6 +1623,7 @@ struct TShowProcessListResult {
 }
 
 struct TShowUserRequest {
+    1: optional Types.TUserIdentity current_user_ident // to filter rows by 
the requesting user's privileges
 }
 
 struct TShowUserResult {
diff --git a/regression-test/suites/auth_p0/test_mysql_user_visibility.groovy 
b/regression-test/suites/auth_p0/test_mysql_user_visibility.groovy
new file mode 100644
index 00000000000..17faadda45d
--- /dev/null
+++ b/regression-test/suites/auth_p0/test_mysql_user_visibility.groovy
@@ -0,0 +1,74 @@
+// 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.
+
+suite("test_mysql_user_visibility", "p0,auth") {
+    String suiteName = "test_mysql_user_visibility"
+    String user1 = "${suiteName}_user1"
+    String user2 = "${suiteName}_user2"
+    String pwd = 'C123_567p'
+
+    try_sql("DROP USER ${user1}")
+    try_sql("DROP USER ${user2}")
+    sql """CREATE USER '${user1}' IDENTIFIED BY '${pwd}'"""
+    sql """CREATE USER '${user2}' IDENTIFIED BY '${pwd}'"""
+
+    // cloud-mode: a user needs cluster usage before it can run any query.
+    if (isCloudMode()) {
+        def clusters = sql " SHOW CLUSTERS; "
+        assertTrue(!clusters.isEmpty())
+        def validCluster = clusters[0][0]
+        sql """GRANT USAGE_PRIV ON CLUSTER `${validCluster}` TO ${user1}"""
+        sql """GRANT USAGE_PRIV ON CLUSTER `${validCluster}` TO ${user2}"""
+    }
+
+    // The connection targets the regression_test database, so user1 needs a 
privilege on it
+    // to establish the session. This is unrelated to mysql.user visibility 
(that comes from
+    // the default role's SELECT on mysql.*), it only makes connect() below 
succeed.
+    sql """GRANT SELECT_PRIV ON regression_test TO ${user1}"""
+
+    // A role administrator (root here) sees every account, but the 
password-derived
+    // columns are always masked, even though these users have a non-empty 
password.
+    def adminRows = sql """
+        SELECT User, authentication_string, `password_policy.history_passwords`
+        FROM mysql.user
+    """
+    assertTrue(adminRows.any { it[0] == user1 }, "admin should see ${user1}")
+    assertTrue(adminRows.any { it[0] == user2 }, "admin should see ${user2}")
+    adminRows.each {
+        assertEquals("***", it[1], "authentication_string must be masked for 
admin")
+        assertEquals("***", it[2], "history_passwords must be masked for 
admin")
+    }
+
+    // A non-privileged user only sees their own row, with the password 
columns masked,
+    // and must not be able to enumerate other accounts through mysql.user.
+    connect(user1, "${pwd}", context.config.jdbcUrl) {
+        def rows = sql """
+            SELECT User, authentication_string, 
`password_policy.history_passwords`
+            FROM mysql.user
+        """
+        assertTrue(!rows.isEmpty(), "${user1} should see its own row")
+        rows.each {
+            assertEquals(user1, it[0], "${user1} should only see its own 
account")
+            assertEquals("***", it[1], "authentication_string must be masked")
+            assertEquals("***", it[2], "history_passwords must be masked")
+        }
+        assertFalse(rows.any { it[0] == user2 }, "${user1} must not see 
${user2}")
+    }
+
+    try_sql("DROP USER ${user1}")
+    try_sql("DROP USER ${user2}")
+}


---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]

Reply via email to