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

mrhhsg 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 c7a44c738e6 [fix](auth) Add privilege checks to SHOW/EXPLAIN/REFRESH 
DICTIONARY (#67343)
c7a44c738e6 is described below

commit c7a44c738e6a8cbe6b03fd50129f9ca77eba4ef9
Author: Jerry Hu <[email protected]>
AuthorDate: Mon Sep 7 20:46:49 2026 +0800

    [fix](auth) Add privilege checks to SHOW/EXPLAIN/REFRESH DICTIONARY (#67343)
    
    ### What problem does this PR solve?
    
    Issue Number: None
    
    Related PR: #66218
    
    Problem Summary:
    
    `SHOW DICTIONARIES` and `EXPLAIN DICTIONARY` did not check any
    privilege. Any
    user who can `USE` a database (which only needs a privilege on some
    table of
    that database) could list every dictionary of the database together with
    its
    source table name, status and BE data distribution, and describe its
    columns.
    `REFRESH DICTIONARY` only failed inside the internal `INSERT INTO`,
    after the
    dictionary had been looked up and switched to `LOADING`.
    
    This is inconsistent with `SHOW TABLES`, which hides tables the user
    cannot
    show, and with `CREATE/DROP DICTIONARY`, which already require
    privileges on the
    dictionary name (#66218).
    
    Dictionaries are authorized like tables of the internal catalog, so:
    
    - `SHOW DICTIONARIES` now skips dictionaries the user has no `SHOW`
    privilege
      on, the same way `SHOW TABLES` filters tables.
    - `EXPLAIN DICTIONARY` requires `SHOW` on the dictionary, like
    `DESCRIBE` on a
      table.
    - `REFRESH DICTIONARY` checks `LOAD` on the dictionary up front. This is
    the
    privilege the internal `INSERT INTO` already required, so nobody loses
    the
    ability to refresh; the check now happens before the dictionary is
    resolved
      and before its status is flipped to `LOADING`.
    
    The checks run before the dictionary is looked up, so a denied user
    cannot
    probe whether a dictionary exists either.
    
    ### Release note
    
    None
    
    ### Check List (For Author)
    
    - Test
    - [x] Regression test: `auth_call/test_ddl_dictionary_auth` now covers a
    user with a privilege on another table of the database (must not see,
    describe or refresh the dictionary), `SHOW_VIEW` on the database (sees
          the dictionary and its source table, may describe it, still cannot
          refresh), and `LOAD` on the database (may refresh).
        - [ ] Unit Test
        - [ ] Manual test (add detailed scripts or steps below)
        - [ ] No need to test or manual test. Explain why:
    
    - Behavior changed:
        - [ ] No.
        - [x] Yes. Users without `SHOW` on a dictionary no longer see it in
          `SHOW DICTIONARIES` and cannot `EXPLAIN DICTIONARY` it. `REFRESH
    DICTIONARY` still needs `LOAD` on the dictionary, but is now rejected
          before the dictionary is touched.
    
    - Does this need documentation?
        - [ ] No.
    - [x] Yes. The privilege requirements of the three statements should be
          documented.
    
    https://claude.ai/code/session_01X9KukfTLYxHmP6iYyEnQtW
---
 .../plans/commands/ExplainDictionaryCommand.java   | 14 ++++-
 .../plans/commands/ShowDictionariesCommand.java    | 26 +++++++--
 .../commands/refresh/RefreshDictionaryCommand.java | 14 +++++
 .../auth_call/test_ddl_dictionary_auth.groovy      | 63 ++++++++++++++++++++++
 4 files changed, 113 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/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