Skip to content

Commit c0c06d6

Browse files
committed
[fix](auth) Add privilege checks to SHOW/EXPLAIN/REFRESH DICTIONARY
### 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`. Reading values through `dict_get()` / `dict_get_many()` never checked any privilege at all. 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 and column-aware `SELECT` on the dictionary's source columns up front (`checkColumnsPriv` on the source columns' own names, the same contract the generated `INSERT` enforces through `CheckPrivileges`, so column-level grants keep working even when the dictionary definition spells the columns differently). These are the privileges the reload already required; the checks now happen before the dictionary is resolved and before its status is flipped to `LOADING`, so an unauthorized request can no longer block concurrent refreshes while its INSERT is being planned. - `dict_get()` / `dict_get_many()` require `SELECT` on the dictionary before resolving it, so a user who cannot see a dictionary cannot read its values or probe whether it exists. - Statements that read a dictionary are kept out of the sql cache and are not short-circuited as reusable point-query plans of a server prepared statement: neither mechanism records the dictionary's privilege or version, so a cached result / reused plan could survive a revoke or a refresh. Dictionaries are a cache already, so the lost sql-cache hit is not a real cost. - `SHOW DICTIONARIES` returns early when the visible set is empty: an empty id list means "all dictionaries" to the BE status RPC (`get_dictionary_status`), so the previous code fanned status RPCs to every alive BE (also reachable before this change via an empty database or a non-matching `LIKE`). 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), `LOAD` on the database without `SELECT` on the source table (rejected before the dictionary starts loading), column-level `SELECT` on the source columns (may refresh), and `dict_get()` denied until `SELECT` on the dictionary is granted. New `auth_call/test_dictionary_read_auth_cache` (nonConcurrent) covers a dictionary spelling its columns differently from the source table, a primed sql-cache statement that is denied right after the revoke, and a server prepared point query whose second EXECUTE is denied after the revoke. - [ ] 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 and (column-aware) `SELECT` on the source columns, but is now rejected before the dictionary is touched. `dict_get()` / `dict_get_many()` now require `SELECT` on the dictionary; internal paths without a user context are unaffected. Statements reading a dictionary no longer use the sql cache or a reused short-circuit point-query plan. - Does this need documentation? - [ ] No. - [x] Yes. The privilege requirements of the three statements should be documented. Claude-Session: https://claude.ai/code/session_01X9KukfTLYxHmP6iYyEnQtW
1 parent 4a9956e commit c0c06d6

9 files changed

Lines changed: 401 additions & 6 deletions

File tree

fe/fe-core/src/main/java/org/apache/doris/nereids/rules/analysis/ExpressionAnalyzer.java

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,8 @@
7878
import org.apache.doris.nereids.trees.expressions.functions.agg.AggregateFunction;
7979
import org.apache.doris.nereids.trees.expressions.functions.agg.NullableAggregateFunction;
8080
import org.apache.doris.nereids.trees.expressions.functions.agg.SupportMultiDistinct;
81+
import org.apache.doris.nereids.trees.expressions.functions.scalar.DictGet;
82+
import org.apache.doris.nereids.trees.expressions.functions.scalar.DictGetMany;
8183
import org.apache.doris.nereids.trees.expressions.functions.scalar.ElementAt;
8284
import org.apache.doris.nereids.trees.expressions.functions.scalar.Lambda;
8385
import org.apache.doris.nereids.trees.expressions.functions.udf.AliasUdfBuilder;
@@ -588,8 +590,12 @@ public Expression visitUnboundFunction(UnboundFunction unboundFunction, Expressi
588590
}
589591
if (wantToParseSqlFromSqlCache) {
590592
sqlCacheContext = context.cascadesContext.getStatementContext().getSqlCacheContext();
593+
// Dictionary reads depend on the dictionary's privilege and version, which the sql cache
594+
// neither records nor revalidates on a hit, so keep such statements out of it.
591595
if (builder instanceof AliasUdfBuilder
592-
|| buildResult.second instanceof Udf) {
596+
|| buildResult.second instanceof Udf
597+
|| buildResult.second instanceof DictGet
598+
|| buildResult.second instanceof DictGetMany) {
593599
if (sqlCacheContext.isPresent()) {
594600
sqlCacheContext.get().setCannotProcessExpression(true);
595601
}

fe/fe-core/src/main/java/org/apache/doris/nereids/rules/rewrite/LogicalResultSinkToShortCircuitPointQuery.java

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,9 +26,12 @@
2626
import org.apache.doris.nereids.trees.expressions.EqualTo;
2727
import org.apache.doris.nereids.trees.expressions.Expression;
2828
import org.apache.doris.nereids.trees.expressions.SlotReference;
29+
import org.apache.doris.nereids.trees.expressions.functions.scalar.DictGet;
30+
import org.apache.doris.nereids.trees.expressions.functions.scalar.DictGetMany;
2931
import org.apache.doris.nereids.trees.plans.Plan;
3032
import org.apache.doris.nereids.trees.plans.logical.LogicalFilter;
3133
import org.apache.doris.nereids.trees.plans.logical.LogicalOlapScan;
34+
import org.apache.doris.nereids.trees.plans.logical.LogicalProject;
3235
import org.apache.doris.qe.ConnectContext;
3336

3437
import com.google.common.collect.ImmutableList;
@@ -72,6 +75,14 @@ private boolean scanMatchShortCircuitCondition(LogicalOlapScan olapScan) {
7275
&& olapTable.storeRowColumn();
7376
}
7477

78+
// A short-circuit plan of a server prepared statement is reused by later EXECUTEs without
79+
// re-analysis. Dictionary reads depend on the dictionary's privilege and version, neither of
80+
// which the reuse check tracks, so such point queries take the normal path every time.
81+
private boolean projectReadsDictionary(LogicalProject<?> project) {
82+
return project.getProjects().stream().anyMatch(
83+
expr -> expr.anyMatch(e -> e instanceof DictGet || e instanceof DictGetMany));
84+
}
85+
7586
// set short circuit flag and return the original plan
7687
private Plan shortCircuit(Plan root, OlapTable olapTable,
7788
Set<Expression> conjuncts, StatementContext statementContext) {
@@ -98,8 +109,10 @@ public List<Rule> buildRules() {
98109
.when(this::scanMatchShortCircuitCondition)
99110
).when(this::filterMatchShortCircuitCondition)))
100111
.thenApply(ctx -> {
112+
if (projectReadsDictionary(ctx.root.child())) {
113+
return ctx.root;
114+
}
101115
return shortCircuit(ctx.root, ctx.root.child().child().child().getTable(),
102-
103116
ctx.root.child().child().getConjuncts(), ctx.statementContext);
104117
})),
105118
RuleType.SHOR_CIRCUIT_POINT_QUERY.build(

fe/fe-core/src/main/java/org/apache/doris/nereids/trees/expressions/functions/scalar/DictGet.java

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,9 +21,11 @@
2121
import org.apache.doris.catalog.FunctionSignature;
2222
import org.apache.doris.common.DdlException;
2323
import org.apache.doris.common.Pair;
24+
import org.apache.doris.datasource.InternalCatalog;
2425
import org.apache.doris.dictionary.Dictionary;
2526
import org.apache.doris.dictionary.DictionaryManager;
2627
import org.apache.doris.dictionary.LayoutType;
28+
import org.apache.doris.mysql.privilege.PrivPredicate;
2729
import org.apache.doris.nereids.exceptions.AnalysisException;
2830
import org.apache.doris.nereids.trees.expressions.Expression;
2931
import org.apache.doris.nereids.trees.expressions.functions.AlwaysNullable;
@@ -32,6 +34,7 @@
3234
import org.apache.doris.nereids.trees.expressions.visitor.ExpressionVisitor;
3335
import org.apache.doris.nereids.types.DataType;
3436
import org.apache.doris.nereids.util.TypeCoercionUtils;
37+
import org.apache.doris.qe.ConnectContext;
3538

3639
import com.google.common.base.Preconditions;
3740

@@ -93,6 +96,16 @@ public Pair<FunctionSignature, Dictionary> customSignatureDict() {
9396
String dictName = firstNames[1];
9497
String colName = ((Literal) getArgument(1)).getStringValue();
9598

99+
// Reading dictionary values must be authorized like reading its source data. Check before
100+
// the lookup so a caller without the privilege cannot even probe whether the dictionary
101+
// exists. ConnectContext may be absent on internal paths, which carry no user to check.
102+
ConnectContext connectContext = ConnectContext.get();
103+
if (connectContext != null && !Env.getCurrentEnv().getAccessManager().checkTblPriv(connectContext,
104+
InternalCatalog.INTERNAL_CATALOG_NAME, dbName, dictName, PrivPredicate.SELECT)) {
105+
throw new AnalysisException("SELECT command denied to user " + connectContext.getQualifiedUser()
106+
+ " for dictionary '" + dbName + "." + dictName + "'");
107+
}
108+
96109
Dictionary dictionary;
97110
try {
98111
dictionary = dicMgr.getDictionary(dbName, dictName);

fe/fe-core/src/main/java/org/apache/doris/nereids/trees/expressions/functions/scalar/DictGetMany.java

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,9 +22,11 @@
2222
import org.apache.doris.catalog.FunctionSignature;
2323
import org.apache.doris.common.DdlException;
2424
import org.apache.doris.common.Pair;
25+
import org.apache.doris.datasource.InternalCatalog;
2526
import org.apache.doris.dictionary.Dictionary;
2627
import org.apache.doris.dictionary.DictionaryManager;
2728
import org.apache.doris.dictionary.LayoutType;
29+
import org.apache.doris.mysql.privilege.PrivPredicate;
2830
import org.apache.doris.nereids.exceptions.AnalysisException;
2931
import org.apache.doris.nereids.trees.expressions.Expression;
3032
import org.apache.doris.nereids.trees.expressions.functions.AlwaysNotNullable;
@@ -36,6 +38,7 @@
3638
import org.apache.doris.nereids.types.StructField;
3739
import org.apache.doris.nereids.types.StructType;
3840
import org.apache.doris.nereids.util.TypeCoercionUtils;
41+
import org.apache.doris.qe.ConnectContext;
3942

4043
import com.google.common.base.Preconditions;
4144

@@ -99,6 +102,16 @@ public Pair<FunctionSignature, Dictionary> customSignatureDict() {
99102
String dictName = firstNames[1];
100103
List<Literal> colNames = ((ArrayLiteral) getArgument(1)).getValue();
101104

105+
// Reading dictionary values must be authorized like reading its source data. Check before
106+
// the lookup so a caller without the privilege cannot even probe whether the dictionary
107+
// exists. ConnectContext may be absent on internal paths, which carry no user to check.
108+
ConnectContext connectContext = ConnectContext.get();
109+
if (connectContext != null && !Env.getCurrentEnv().getAccessManager().checkTblPriv(connectContext,
110+
InternalCatalog.INTERNAL_CATALOG_NAME, dbName, dictName, PrivPredicate.SELECT)) {
111+
throw new AnalysisException("SELECT command denied to user " + connectContext.getQualifiedUser()
112+
+ " for dictionary '" + dbName + "." + dictName + "'");
113+
}
114+
102115
Dictionary dictionary;
103116
try {
104117
dictionary = dicMgr.getDictionary(dbName, dictName);

fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/commands/ExplainDictionaryCommand.java

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,10 +18,16 @@
1818
package org.apache.doris.nereids.trees.plans.commands;
1919

2020
import org.apache.doris.catalog.Column;
21+
import org.apache.doris.catalog.Env;
2122
import org.apache.doris.catalog.ScalarType;
23+
import org.apache.doris.common.AnalysisException;
2224
import org.apache.doris.common.DdlException;
25+
import org.apache.doris.common.ErrorCode;
26+
import org.apache.doris.common.ErrorReport;
27+
import org.apache.doris.datasource.InternalCatalog;
2328
import org.apache.doris.dictionary.Dictionary;
2429
import org.apache.doris.dictionary.DictionaryManager;
30+
import org.apache.doris.mysql.privilege.PrivPredicate;
2531
import org.apache.doris.nereids.trees.plans.PlanType;
2632
import org.apache.doris.nereids.trees.plans.commands.info.DictionaryColumnDefinition;
2733
import org.apache.doris.nereids.trees.plans.visitor.PlanVisitor;
@@ -62,11 +68,17 @@ public ShowResultSetMetaData getMetaData() {
6268
}
6369

6470
@Override
65-
public ShowResultSet doRun(ConnectContext ctx, StmtExecutor executor) throws DdlException {
71+
public ShowResultSet doRun(ConnectContext ctx, StmtExecutor executor) throws DdlException, AnalysisException {
6672
List<List<String>> rows = Lists.newArrayList();
6773

6874
DictionaryManager dictionaryManager = ctx.getEnv().getDictionaryManager();
6975
String db = dbName == null ? ctx.getDatabase() : dbName;
76+
// Describing a dictionary exposes its schema, so require SHOW on it like DESCRIBE on a table.
77+
if (!Env.getCurrentEnv().getAccessManager().checkTblPriv(ctx, InternalCatalog.INTERNAL_CATALOG_NAME,
78+
db, dictionaryName, PrivPredicate.SHOW)) {
79+
ErrorReport.reportAnalysisException(ErrorCode.ERR_TABLEACCESS_DENIED_ERROR, "SHOW",
80+
ctx.getQualifiedUser(), ctx.getRemoteIP(), db + ": " + dictionaryName);
81+
}
7082
Dictionary dictionary = dictionaryManager.getDictionary(db, dictionaryName);
7183

7284
for (DictionaryColumnDefinition column : dictionary.getDicColumns()) {

fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/commands/ShowDictionariesCommand.java

Lines changed: 23 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -19,12 +19,15 @@
1919

2020
import org.apache.doris.analysis.RedirectStatus;
2121
import org.apache.doris.catalog.Column;
22+
import org.apache.doris.catalog.Env;
2223
import org.apache.doris.catalog.ScalarType;
2324
import org.apache.doris.common.AnalysisException;
2425
import org.apache.doris.common.PatternMatcher;
2526
import org.apache.doris.common.PatternMatcherWrapper;
27+
import org.apache.doris.datasource.InternalCatalog;
2628
import org.apache.doris.dictionary.Dictionary;
2729
import org.apache.doris.dictionary.DictionaryManager;
30+
import org.apache.doris.mysql.privilege.PrivPredicate;
2831
import org.apache.doris.nereids.trees.plans.PlanType;
2932
import org.apache.doris.nereids.trees.plans.visitor.PlanVisitor;
3033
import org.apache.doris.qe.ConnectContext;
@@ -73,14 +76,31 @@ public ShowResultSet doRun(ConnectContext ctx, StmtExecutor executor) throws Ana
7376

7477
DictionaryManager dictionaryManager = ctx.getEnv().getDictionaryManager();
7578
List<Dictionary> queryDicts = Lists.newArrayList();
79+
String dbName = ctx.getDatabase();
7680
// getDictionaries() already have read lock
77-
Map<String, Dictionary> dbDictionaries = dictionaryManager.getDictionaries(ctx.getDatabase());
81+
Map<String, Dictionary> dbDictionaries = dictionaryManager.getDictionaries(dbName);
7882
for (Map.Entry<String, Dictionary> entry : dbDictionaries.entrySet()) {
7983
String dictionaryName = entry.getKey();
8084
// Apply wild condition filtering if wild pattern is provided
81-
if (wild == null || matcher.match(dictionaryName)) {
82-
queryDicts.add(entry.getValue());
85+
if (wild != null && !matcher.match(dictionaryName)) {
86+
continue;
8387
}
88+
// Dictionaries are authorized like tables of the internal catalog. Hide the ones the user
89+
// may not show, the same way SHOW TABLES hides tables, so the source table name, status
90+
// and data distribution are not exposed to users without privileges on the dictionary.
91+
if (!Env.getCurrentEnv().getAccessManager().checkTblPriv(ctx, InternalCatalog.INTERNAL_CATALOG_NAME,
92+
dbName, dictionaryName, PrivPredicate.SHOW)) {
93+
continue;
94+
}
95+
queryDicts.add(entry.getValue());
96+
}
97+
98+
// An empty id list means "all dictionaries" to the BE status RPC (get_dictionary_status in
99+
// BackendService.thrift), so an empty visible set must return before status collection:
100+
// otherwise it fans RPCs to every alive BE for dictionaries this user may not see, logs
101+
// them as missing, and a bad response from any BE would fail the whole command.
102+
if (queryDicts.isEmpty()) {
103+
return new ShowResultSet(getMetaData(), rows);
84104
}
85105

86106
// ignore its return value because we dont update it, just show.

fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/commands/refresh/RefreshDictionaryCommand.java

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,15 +18,24 @@
1818
package org.apache.doris.nereids.trees.plans.commands.refresh;
1919

2020
import org.apache.doris.analysis.StmtType;
21+
import org.apache.doris.catalog.Env;
22+
import org.apache.doris.common.ErrorCode;
23+
import org.apache.doris.common.ErrorReport;
24+
import org.apache.doris.datasource.InternalCatalog;
2125
import org.apache.doris.dictionary.Dictionary;
2226
import org.apache.doris.dictionary.DictionaryManager;
27+
import org.apache.doris.mysql.privilege.PrivPredicate;
2328
import org.apache.doris.nereids.trees.plans.PlanType;
2429
import org.apache.doris.nereids.trees.plans.commands.Command;
2530
import org.apache.doris.nereids.trees.plans.commands.ForwardWithSync;
2631
import org.apache.doris.nereids.trees.plans.visitor.PlanVisitor;
2732
import org.apache.doris.qe.ConnectContext;
2833
import org.apache.doris.qe.StmtExecutor;
2934

35+
import java.util.LinkedHashSet;
36+
import java.util.Set;
37+
import java.util.stream.Collectors;
38+
3039
/**
3140
* Refresh table command.
3241
*/
@@ -45,7 +54,28 @@ public RefreshDictionaryCommand(String dbName, String dicName) {
4554
public void run(ConnectContext ctx, StmtExecutor executor) throws Exception {
4655
DictionaryManager dictionaryManager = ctx.getEnv().getDictionaryManager();
4756
String db = dbName == null ? ctx.getDatabase() : dbName;
57+
// The reload is an INSERT INTO the dictionary executed as the current user, which requires
58+
// LOAD on the dictionary (and SELECT on the source table). Check LOAD up front so a user
59+
// without it can neither probe whether the dictionary exists nor flip it to LOADING.
60+
if (!Env.getCurrentEnv().getAccessManager().checkTblPriv(ctx, InternalCatalog.INTERNAL_CATALOG_NAME,
61+
db, dictionaryName, PrivPredicate.LOAD)) {
62+
ErrorReport.reportAnalysisException(ErrorCode.ERR_TABLEACCESS_DENIED_ERROR, "LOAD",
63+
ctx.getQualifiedUser(), ctx.getRemoteIP(), db + ": " + dictionaryName);
64+
}
4865
Dictionary dictionary = dictionaryManager.getDictionary(db, dictionaryName);
66+
// The reload also reads the source table as the current user, and its INSERT authorizes
67+
// exactly the dictionary's source columns through the column-aware contract (BindSink
68+
// projects the source to the dictionary schema before CheckPrivileges runs). Use the same
69+
// contract and the same column spelling here: the dictionary definition may spell a column
70+
// differently from the source table, but column grants are compared against the source
71+
// column's own name. Reject a missing privilege before dataLoad() publishes the LOADING
72+
// status and blocks concurrent refreshes.
73+
Set<String> sourceColumns = dictionary.getDicColumns().stream()
74+
.map(definition -> definition.getOriginColumn().getName())
75+
.collect(Collectors.toCollection(LinkedHashSet::new));
76+
Env.getCurrentEnv().getAccessManager().checkColumnsPriv(ctx, dictionary.getSourceCtlName(),
77+
dictionary.getSourceDbName(), dictionary.getSourceTableName(), sourceColumns,
78+
PrivPredicate.SELECT);
4979
dictionaryManager.dataLoad(ctx, dictionary, false);
5080
}
5181

0 commit comments

Comments
 (0)