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

yiguolei pushed a commit to branch branch-4.2
in repository https://gitbox.apache.org/repos/asf/doris.git


The following commit(s) were added to refs/heads/branch-4.2 by this push:
     new 3b2ba9996d8 branch-4.2: [fix](auth) Add privilege checks to 
SHOW/EXPLAIN/REFRESH DICTIONARY #67343 (#70036)
3b2ba9996d8 is described below

commit 3b2ba9996d8300c4372ea931c2c49f7f6da4ac77
Author: morrySnow <[email protected]>
AuthorDate: Tue Nov 3 17:13:24 2026 +0800

    branch-4.2: [fix](auth) Add privilege checks to SHOW/EXPLAIN/REFRESH 
DICTIONARY #67343 (#70036)
    
    picked from master #67343
    
    Co-authored-by: Jerry Hu <[email protected]>
---
 .../plans/commands/ExplainDictionaryCommand.java   |  14 ++-
 .../plans/commands/ShowDictionariesCommand.java    |  26 ++++-
 .../commands/refresh/RefreshDictionaryCommand.java |  14 +++
 .../commands/DictionaryPrivilegeBackportTest.java  | 112 +++++++++++++++++++++
 .../auth_call/test_ddl_dictionary_auth.groovy      |  63 ++++++++++++
 5 files changed, 225 insertions(+), 4 deletions(-)

diff --git 
a/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/commands/ExplainDictionaryCommand.java
 
b/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/commands/ExplainDictionaryCommand.java
index c6a6fa449c7..54f4a982c51 100644
--- 
a/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/commands/ExplainDictionaryCommand.java
+++ 
b/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/commands/ExplainDictionaryCommand.java
@@ -18,10 +18,16 @@
 package org.apache.doris.nereids.trees.plans.commands;
 
 import org.apache.doris.catalog.Column;
+import org.apache.doris.catalog.Env;
 import org.apache.doris.catalog.ScalarType;
+import org.apache.doris.common.AnalysisException;
 import org.apache.doris.common.DdlException;
+import org.apache.doris.common.ErrorCode;
+import org.apache.doris.common.ErrorReport;
+import org.apache.doris.datasource.InternalCatalog;
 import org.apache.doris.dictionary.Dictionary;
 import org.apache.doris.dictionary.DictionaryManager;
+import org.apache.doris.mysql.privilege.PrivPredicate;
 import org.apache.doris.nereids.trees.plans.PlanType;
 import 
org.apache.doris.nereids.trees.plans.commands.info.DictionaryColumnDefinition;
 import org.apache.doris.nereids.trees.plans.visitor.PlanVisitor;
@@ -62,11 +68,17 @@ public class ExplainDictionaryCommand extends ShowCommand {
     }
 
     @Override
-    public ShowResultSet doRun(ConnectContext ctx, StmtExecutor executor) 
throws DdlException {
+    public ShowResultSet doRun(ConnectContext ctx, StmtExecutor executor) 
throws DdlException, AnalysisException {
         List<List<String>> rows = Lists.newArrayList();
 
         DictionaryManager dictionaryManager = 
ctx.getEnv().getDictionaryManager();
         String db = dbName == null ? ctx.getDatabase() : dbName;
+        // Describing a dictionary exposes its schema, so require SHOW on it 
like DESCRIBE on a table.
+        if (!Env.getCurrentEnv().getAccessManager().checkTblPriv(ctx, 
InternalCatalog.INTERNAL_CATALOG_NAME,
+                db, dictionaryName, PrivPredicate.SHOW)) {
+            
ErrorReport.reportAnalysisException(ErrorCode.ERR_TABLEACCESS_DENIED_ERROR, 
"SHOW",
+                    ctx.getQualifiedUser(), ctx.getRemoteIP(), db + ": " + 
dictionaryName);
+        }
         Dictionary dictionary = dictionaryManager.getDictionary(db, 
dictionaryName);
 
         for (DictionaryColumnDefinition column : dictionary.getDicColumns()) {
diff --git 
a/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/commands/ShowDictionariesCommand.java
 
b/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/commands/ShowDictionariesCommand.java
index a5b332ced4d..919d717539c 100644
--- 
a/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/commands/ShowDictionariesCommand.java
+++ 
b/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/commands/ShowDictionariesCommand.java
@@ -19,12 +19,15 @@ package org.apache.doris.nereids.trees.plans.commands;
 
 import org.apache.doris.analysis.RedirectStatus;
 import org.apache.doris.catalog.Column;
+import org.apache.doris.catalog.Env;
 import org.apache.doris.catalog.ScalarType;
 import org.apache.doris.common.AnalysisException;
 import org.apache.doris.common.PatternMatcher;
 import org.apache.doris.common.PatternMatcherWrapper;
+import org.apache.doris.datasource.InternalCatalog;
 import org.apache.doris.dictionary.Dictionary;
 import org.apache.doris.dictionary.DictionaryManager;
+import org.apache.doris.mysql.privilege.PrivPredicate;
 import org.apache.doris.nereids.trees.plans.PlanType;
 import org.apache.doris.nereids.trees.plans.visitor.PlanVisitor;
 import org.apache.doris.qe.ConnectContext;
@@ -73,14 +76,31 @@ public class ShowDictionariesCommand extends ShowCommand {
 
         DictionaryManager dictionaryManager = 
ctx.getEnv().getDictionaryManager();
         List<Dictionary> queryDicts = Lists.newArrayList();
+        String dbName = ctx.getDatabase();
         // getDictionaries() already have read lock
-        Map<String, Dictionary> dbDictionaries = 
dictionaryManager.getDictionaries(ctx.getDatabase());
+        Map<String, Dictionary> dbDictionaries = 
dictionaryManager.getDictionaries(dbName);
         for (Map.Entry<String, Dictionary> entry : dbDictionaries.entrySet()) {
             String dictionaryName = entry.getKey();
             // Apply wild condition filtering if wild pattern is provided
-            if (wild == null || matcher.match(dictionaryName)) {
-                queryDicts.add(entry.getValue());
+            if (wild != null && !matcher.match(dictionaryName)) {
+                continue;
             }
+            // Dictionaries are authorized like tables of the internal 
catalog. Hide the ones the user
+            // may not show, the same way SHOW TABLES hides tables, so the 
source table name, status
+            // and data distribution are not exposed to users without 
privileges on the dictionary.
+            if (!Env.getCurrentEnv().getAccessManager().checkTblPriv(ctx, 
InternalCatalog.INTERNAL_CATALOG_NAME,
+                    dbName, dictionaryName, PrivPredicate.SHOW)) {
+                continue;
+            }
+            queryDicts.add(entry.getValue());
+        }
+
+        // An empty id list means "all dictionaries" to the BE status RPC 
(get_dictionary_status in
+        // BackendService.thrift), so an empty visible set must return before 
status collection:
+        // otherwise it fans RPCs to every alive BE for dictionaries this user 
may not see, logs
+        // them as missing, and a bad response from any BE would fail the 
whole command.
+        if (queryDicts.isEmpty()) {
+            return new ShowResultSet(getMetaData(), rows);
         }
 
         // ignore its return value because we dont update it, just show.
diff --git 
a/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/commands/refresh/RefreshDictionaryCommand.java
 
b/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/commands/refresh/RefreshDictionaryCommand.java
index ca5e01cc8c9..c0f1ebe2a18 100644
--- 
a/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/commands/refresh/RefreshDictionaryCommand.java
+++ 
b/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/commands/refresh/RefreshDictionaryCommand.java
@@ -18,8 +18,13 @@
 package org.apache.doris.nereids.trees.plans.commands.refresh;
 
 import org.apache.doris.analysis.StmtType;
+import org.apache.doris.catalog.Env;
+import org.apache.doris.common.ErrorCode;
+import org.apache.doris.common.ErrorReport;
+import org.apache.doris.datasource.InternalCatalog;
 import org.apache.doris.dictionary.Dictionary;
 import org.apache.doris.dictionary.DictionaryManager;
+import org.apache.doris.mysql.privilege.PrivPredicate;
 import org.apache.doris.nereids.trees.plans.PlanType;
 import org.apache.doris.nereids.trees.plans.commands.Command;
 import org.apache.doris.nereids.trees.plans.commands.ForwardWithSync;
@@ -45,6 +50,15 @@ public class RefreshDictionaryCommand extends Command 
implements ForwardWithSync
     public void run(ConnectContext ctx, StmtExecutor executor) throws 
Exception {
         DictionaryManager dictionaryManager = 
ctx.getEnv().getDictionaryManager();
         String db = dbName == null ? ctx.getDatabase() : dbName;
+        // The reload is an INSERT INTO the dictionary executed as the current 
user, which already
+        // requires LOAD on the dictionary and SELECT on the source columns. 
Check LOAD up front,
+        // like DROP DICTIONARY does, so a user without it can neither probe 
whether the dictionary
+        // exists nor flip its status to LOADING before the INSERT is rejected.
+        if (!Env.getCurrentEnv().getAccessManager().checkTblPriv(ctx, 
InternalCatalog.INTERNAL_CATALOG_NAME,
+                db, dictionaryName, PrivPredicate.LOAD)) {
+            
ErrorReport.reportAnalysisException(ErrorCode.ERR_TABLEACCESS_DENIED_ERROR, 
"LOAD",
+                    ctx.getQualifiedUser(), ctx.getRemoteIP(), db + ": " + 
dictionaryName);
+        }
         Dictionary dictionary = dictionaryManager.getDictionary(db, 
dictionaryName);
         dictionaryManager.dataLoad(ctx, dictionary, false);
     }
diff --git 
a/fe/fe-core/src/test/java/org/apache/doris/nereids/trees/plans/commands/DictionaryPrivilegeBackportTest.java
 
b/fe/fe-core/src/test/java/org/apache/doris/nereids/trees/plans/commands/DictionaryPrivilegeBackportTest.java
new file mode 100644
index 00000000000..46a81badc71
--- /dev/null
+++ 
b/fe/fe-core/src/test/java/org/apache/doris/nereids/trees/plans/commands/DictionaryPrivilegeBackportTest.java
@@ -0,0 +1,112 @@
+// 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.doris.nereids.trees.plans.commands;
+
+import org.apache.doris.catalog.Env;
+import org.apache.doris.common.AnalysisException;
+import org.apache.doris.datasource.InternalCatalog;
+import org.apache.doris.dictionary.Dictionary;
+import org.apache.doris.dictionary.DictionaryManager;
+import org.apache.doris.mysql.privilege.AccessControllerManager;
+import org.apache.doris.mysql.privilege.PrivPredicate;
+import 
org.apache.doris.nereids.trees.plans.commands.refresh.RefreshDictionaryCommand;
+import org.apache.doris.qe.ConnectContext;
+
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableMap;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.mockito.MockedStatic;
+import org.mockito.Mockito;
+
+class DictionaryPrivilegeBackportTest {
+    private ConnectContext context;
+    private DictionaryManager manager;
+    private AccessControllerManager access;
+    private Dictionary dictionary;
+    private MockedStatic<Env> environment;
+
+    @BeforeEach
+    void setUp() throws Exception {
+        Env env = Mockito.mock(Env.class);
+        context = Mockito.mock(ConnectContext.class);
+        manager = Mockito.mock(DictionaryManager.class);
+        access = Mockito.mock(AccessControllerManager.class);
+        dictionary = Mockito.mock(Dictionary.class);
+        Mockito.when(context.getEnv()).thenReturn(env);
+        Mockito.when(context.getDatabase()).thenReturn("db");
+        Mockito.when(context.getQualifiedUser()).thenReturn("viewer");
+        Mockito.when(context.getRemoteIP()).thenReturn("127.0.0.1");
+        Mockito.when(env.getDictionaryManager()).thenReturn(manager);
+        Mockito.when(env.getAccessManager()).thenReturn(access);
+        
Mockito.when(manager.getDictionaries("db")).thenReturn(ImmutableMap.of("dict", 
dictionary));
+        environment = Mockito.mockStatic(Env.class);
+        environment.when(Env::getCurrentEnv).thenReturn(env);
+    }
+
+    @AfterEach
+    void tearDown() {
+        environment.close();
+    }
+
+    @Test
+    void hiddenDictionariesDoNotTriggerStatusRpc() throws Exception {
+        Assertions.assertTrue(new ShowDictionariesCommand(null).doRun(context, 
null).getResultRows().isEmpty());
+        Mockito.verify(manager, 
Mockito.never()).collectDictionaryStatus(Mockito.anyList());
+        Mockito.verify(access).checkTblPriv(context, 
InternalCatalog.INTERNAL_CATALOG_NAME,
+                "db", "dict", PrivPredicate.SHOW);
+    }
+
+    @Test
+    void explainChecksPrivilegeBeforeLookingUpDictionary() throws Exception {
+        Assertions.assertThrows(AnalysisException.class,
+                () -> new ExplainDictionaryCommand(null, 
"dict").doRun(context, null));
+        Mockito.verify(manager, 
Mockito.never()).getDictionary(Mockito.anyString(), Mockito.anyString());
+    }
+
+    @Test
+    void refreshChecksPrivilegeBeforeLookingUpDictionary() throws Exception {
+        Assertions.assertThrows(AnalysisException.class,
+                () -> new RefreshDictionaryCommand(null, "dict").run(context, 
null));
+        Mockito.verify(manager, 
Mockito.never()).getDictionary(Mockito.anyString(), Mockito.anyString());
+        Mockito.verify(access).checkTblPriv(context, 
InternalCatalog.INTERNAL_CATALOG_NAME,
+                "db", "dict", PrivPredicate.LOAD);
+    }
+
+    @Test
+    void authorizedExplainUsesDictionarySchema() throws Exception {
+        Mockito.when(access.checkTblPriv(context, 
InternalCatalog.INTERNAL_CATALOG_NAME,
+                "db", "dict", PrivPredicate.SHOW)).thenReturn(true);
+        Mockito.when(manager.getDictionary("db", 
"dict")).thenReturn(dictionary);
+        
Mockito.when(dictionary.getDicColumns()).thenReturn(ImmutableList.of());
+        Assertions.assertTrue(new ExplainDictionaryCommand(null, "dict")
+                .doRun(context, null).getResultRows().isEmpty());
+        Mockito.verify(dictionary).getDicColumns();
+    }
+
+    @Test
+    void authorizedRefreshStartsLoad() throws Exception {
+        Mockito.when(access.checkTblPriv(context, 
InternalCatalog.INTERNAL_CATALOG_NAME,
+                "db", "dict", PrivPredicate.LOAD)).thenReturn(true);
+        Mockito.when(manager.getDictionary("db", 
"dict")).thenReturn(dictionary);
+        new RefreshDictionaryCommand(null, "dict").run(context, null);
+        Mockito.verify(manager).dataLoad(context, dictionary, false);
+    }
+}
diff --git a/regression-test/suites/auth_call/test_ddl_dictionary_auth.groovy 
b/regression-test/suites/auth_call/test_ddl_dictionary_auth.groovy
index 394969acf73..6f3b50e16b0 100644
--- a/regression-test/suites/auth_call/test_ddl_dictionary_auth.groovy
+++ b/regression-test/suites/auth_call/test_ddl_dictionary_auth.groovy
@@ -84,6 +84,68 @@ suite("test_ddl_dictionary_auth", "p0,auth_call") {
     sql """use ${dbName}"""
     def dictRes = sql """SHOW DICTIONARIES"""
     assertTrue(dictRes.size() == 1)
+    // the initial load queued by CREATE DICTIONARY is asynchronous; a refresh 
while it is still
+    // LOADING fails on the status guard instead of exercising authorization
+    waitDictionaryReady(dictName)
+
+    // A user with privileges on another object of the database can USE the 
database, but must
+    // not learn about dictionaries it has no privilege on, the same way SHOW 
TABLES hides tables.
+    String viewer = 'test_ddl_dictionary_auth_viewer'
+    try_sql("DROP USER ${viewer}")
+    sql """CREATE USER '${viewer}' IDENTIFIED BY '${pwd}'"""
+    sql """grant select_priv on regression_test to ${viewer}"""
+    sql """grant SELECT_PRIV on ${dbName}.${tableName} to ${viewer}"""
+    if (isCloudMode()) {
+        def clusters = sql " SHOW CLUSTERS; "
+        def validCluster = clusters[0][0]
+        sql """GRANT USAGE_PRIV ON CLUSTER `${validCluster}` TO ${viewer}""";
+    }
+    connect(viewer, "${pwd}", context.config.jdbcUrl) {
+        sql """use ${dbName}"""
+        def hiddenDicts = sql """SHOW DICTIONARIES"""
+        assertEquals(0, hiddenDicts.size())
+        test {
+            sql """EXPLAIN DICTIONARY ${dictName}"""
+            exception "denied"
+        }
+        test {
+            sql """REFRESH DICTIONARY ${dictName}"""
+            exception "LOAD command denied"
+        }
+    }
+
+    // SHOW_VIEW makes the dictionary visible, including its source table, but 
refreshing still
+    // needs LOAD on the dictionary. Dictionaries are not tables of the 
database, so GRANT only
+    // accepts them by name for CREATE; these privileges have to be granted on 
the database.
+    sql """grant SHOW_VIEW_PRIV on ${dbName}.* to ${viewer}"""
+    connect(viewer, "${pwd}", context.config.jdbcUrl) {
+        sql """use ${dbName}"""
+        def visibleDicts = sql """SHOW DICTIONARIES"""
+        assertEquals(1, visibleDicts.size())
+        assertEquals(dictName, visibleDicts[0][1])
+        assertEquals("internal.${dbName}.${tableName}".toString(), 
visibleDicts[0][2])
+        def dictColumns = sql """EXPLAIN DICTIONARY ${dictName}"""
+        assertEquals(2, dictColumns.size())
+        test {
+            sql """REFRESH DICTIONARY ${dictName}"""
+            exception "LOAD command denied"
+        }
+    }
+    // rejected by the command preflight, not inside dataLoad(): the 
dictionary was never touched
+    sql """use ${dbName}"""
+    def afterLoadDenied = sql """SHOW DICTIONARIES"""
+    assertEquals("NORMAL", afterLoadDenied[0][4])
+    // LastUpdateResult is "<timestamp>: succeed" after the initial load
+    assertTrue(afterLoadDenied[0][6].toString().endsWith("succeed"))
+
+    // LOAD on the dictionary (the viewer already holds SELECT on the source 
table) allows a refresh
+    sql """grant LOAD_PRIV on ${dbName}.* to ${viewer}"""
+    connect(viewer, "${pwd}", context.config.jdbcUrl) {
+        sql """use ${dbName}"""
+        sql """REFRESH DICTIONARY ${dictName}"""
+    }
+    sql """use ${dbName}"""
+    waitDictionaryReady(dictName)
 
     // dropping needs DROP on the database
     connect(user, "${pwd}", context.config.jdbcUrl) {
@@ -103,4 +165,5 @@ suite("test_ddl_dictionary_auth", "p0,auth_call") {
 
     sql """drop database if exists ${dbName}"""
     try_sql("DROP USER ${user}")
+    try_sql("DROP USER ${viewer}")
 }


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

Reply via email to