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]