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]