Skip to content

[fix](auth) Add privilege checks to SHOW/EXPLAIN/REFRESH DICTIONARY - #67343

Draft
mrhhsg wants to merge 1 commit into
apache:masterfrom
mrhhsg:fix/dictionary-command-privileges
Draft

[fix](auth) Add privilege checks to SHOW/EXPLAIN/REFRESH DICTIONARY#67343
mrhhsg wants to merge 1 commit into
apache:masterfrom
mrhhsg:fix/dictionary-command-privileges

Conversation

@mrhhsg

@mrhhsg mrhhsg commented Aug 31, 2026

Copy link
Copy Markdown
Member

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

    • 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.
    • 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.
    • Yes. The privilege requirements of the three statements should be
      documented.

https://claude.ai/code/session_01X9KukfTLYxHmP6iYyEnQtW

@hello-stephen

Copy link
Copy Markdown
Contributor

Thank you for your contribution to Apache Doris.
Don't know what should be done next? See How to process your PR.

Please clearly describe your PR:

  1. What problem was fixed (it's best to include specific error reporting information). How it was fixed.
  2. Which behaviors were modified. What was the previous behavior, what is it now, why was it modified, and what possible impacts might there be.
  3. What features were added. Why was this function added?
  4. Which code was refactored and why was this part of the code refactored?
  5. Which functions were optimized and what is the difference before and after the optimization?

@mrhhsg

mrhhsg commented Aug 31, 2026

Copy link
Copy Markdown
Member Author

/review

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Request changes. The new checks close the original unauthenticated SHOW/EXPLAIN/REFRESH gap, but the reviewed head still has two authorization-boundary defects and two correctness/test issues:

  • P1: the checks use the physical table privilege key even though dictionaries live in DictionaryManager and can share a name with a physical table. Existing SELECT/column grants on that table can therefore expose the dictionary through SHOW/EXPLAIN, and LOAD grants can authorize REFRESH of the distinct dictionary.
  • P2: REFRESH checks only dictionary LOAD before dataLoad; source SELECT is checked after the shared status is set to LOADING, so an unauthorized request can transiently block concurrent authorized refreshes.
  • P2: the positive regression REFRESH runs immediately after CREATE, whose initial load is asynchronous, so the test can fail on the LOADING guard rather than exercise authorization.

Checkpoint conclusions: the intended privilege goal is only partially met; the command changes are otherwise focused and preserve existing error propagation. The refresh path has a shared-state lifecycle race, while SHOW/EXPLAIN have no new lock change beyond the pre-existing manager snapshot behavior. Internal and Ranger privilege paths should share a dictionary-specific namespace or explicitly reject table/dictionary collisions. The regression test covers the main negative/positive contract but needs readiness synchronization. No protocol, persistence, or configuration changes are involved. Tests were not run because this review bundle forbids builds and test execution. User focus requested no additional focus; the full changed-file sweep found no other distinct issues.

import org.apache.doris.common.ErrorCode;
import org.apache.doris.common.ErrorReport;
import org.apache.doris.datasource.InternalCatalog;
import org.apache.doris.dictionary.Dictionary;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P1] This check uses the physical table privilege namespace, but dictionaries are stored outside Database's table map and can coexist with a same-name table. For example, after creating db.foo and dictionary db.foo, a user with only SELECT (or a column grant) on physical table foo passes PrivPredicate.SHOW here because Role.checkTblPriv treats any matching table/column privilege as SHOW. The command then returns the full dictionary schema (and SHOW DICTIONARIES exposes its source/status), despite no privilege on the dictionary. Please reject collisions or authorize dictionaries in a distinct namespace (and cover both internal and Ranger controllers).

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Dictionaries are authorized with the table privilege key of the internal catalog by design: CREATE/DROP DICTIONARY already use checkTblPriv(internal, db, dictName, ...) (#66218), and GRANT can only target db.<name> (there is no dictionary privilege object). This PR keeps that model and only adds the checks that were missing, so SHOW/EXPLAIN/REFRESH now behave consistently with CREATE/DROP. The name-collision problem is real, but it predates this change and affects CREATE/DROP the same way; fixing it means either rejecting table/dictionary name collisions in both CREATE DICTIONARY and CREATE TABLE, or introducing a dedicated privilege object for internal and Ranger controllers. That is out of scope here and tracked in #67345.

import org.apache.doris.catalog.Env;
import org.apache.doris.common.ErrorCode;
import org.apache.doris.common.ErrorReport;
import org.apache.doris.datasource.InternalCatalog;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P1] checkTblPriv(..., LOAD) also resolves the target solely by the physical table name. Because DictionaryManager allows db.foo (table) and db.foo (dictionary) to coexist, a user granted LOAD on the table foo can pass this check and refresh the dictionary foo without a dictionary grant (provided they can SELECT the source). Please use a dictionary-specific authorization key or disallow table/dictionary name collisions so privileges cannot cross object types.

[P2] The preflight only checks LOAD on the dictionary, but the refresh's generated INSERT also requires SELECT on the source. DictionaryManager.dataLoad() sets the shared dictionary status to LOADING before CheckPrivileges analyzes that source; a user with database LOAD but no source SELECT is rejected only after planning starts, and the catch restores the status afterward. During that window concurrent legitimate refreshes get cannot load now, and repeated requests can starve refreshes on a slow source. Validate source SELECT (or perform deterministic authorization/planning) before publishing LOADING.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P1] Dictionaries are authorized with the table privilege key of the internal catalog by design: CREATE/DROP DICTIONARY already use checkTblPriv(internal, db, dictName, ...) (#66218), and GRANT can only target db.<name> (there is no dictionary privilege object). This PR keeps that model and only adds the checks that were missing, so SHOW/EXPLAIN/REFRESH now behave consistently with CREATE/DROP. The name-collision problem is real, but it predates this change and affects CREATE/DROP the same way; fixing it means either rejecting table/dictionary name collisions in both CREATE DICTIONARY and CREATE TABLE, or introducing a dedicated privilege object for internal and Ranger controllers. That is out of scope here and tracked in #67345.

[P2] Fixed in 9a1fbfe: after the LOAD check the command now also verifies SELECT on the source table (dictionary.getSourceCtlName/DbName/TableName, the same check CREATE DICTIONARY does) before calling dataLoad(), so a request lacking either privilege is rejected before the shared status is switched to LOADING. Covered by the new LOAD-without-source-SELECT case in test_ddl_dictionary_auth.

connect(viewer, "${pwd}", context.config.jdbcUrl) {
sql """use ${dbName}"""
sql """REFRESH DICTIONARY ${dictName}"""
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P2] CREATE DICTIONARY queues its initial dataLoad asynchronously, but this positive REFRESH DICTIONARY runs without waiting for the dictionary to reach NORMAL. If the initial task is still LOADING, dataLoad rejects this refresh with a status-conflict error even though the viewer now has LOAD and source SELECT. Add waitDictionaryReady/waitAllDictionariesReady after creation (and poll after refresh if needed) so this test deterministically exercises authorization.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 9a1fbfe: the suite now calls waitDictionaryReady after CREATE DICTIONARY and after each refresh, so the positive REFRESH DICTIONARY deterministically exercises authorization instead of the LOADING guard. It also adds the case where the viewer has LOAD but no SELECT on the source table, which is now rejected before the dictionary is switched to LOADING.

@mrhhsg

mrhhsg commented Aug 31, 2026

Copy link
Copy Markdown
Member Author

/review

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Automated review of 9a1fbfe84513a6635675d79d110bc0fa25d1bce4REQUEST_CHANGES

Three distinct issues are attached inline.

Critical checkpoint conclusions:

  • Goal and proof: The SHOW/EXPLAIN/REFRESH checks improve whole-table authorization and the readiness waits make the intended regression paths deterministic, but the authorization goal is incomplete because scalar dictionary reads still bypass it, column-authorized refreshes are rejected, and an all-hidden SHOW invokes the all-dictionary status contract.
  • Scope and focus: The diff is focused. There was no additional user-provided review focus. The existing same-name table/dictionary privilege thread, the earlier source-SELECT thread, and the readiness thread were treated as duplicate fences and were not repeated.
  • Concurrency and lifecycle: The changed commands hold no metadata lock across authorization, planning, or RPC. Existing status CAS, failure rollback, concurrent drop, and forwarding behavior introduced no additional finding; the empty-visible-set status RPC is covered inline.
  • Compatibility and parallel paths: No configuration, persistence, wire-format, rolling-upgrade, FE-BE variable, or public symbol compatibility change was found. Column-level native/Ranger grants regress, and the parallel dict_get/dict_get_many read paths remain unauthorized.
  • Tests and results: The new waits remove the initial-load race; the whole-table negative/positive and cloud cases reach their intended guards. Missing coverage includes column-only grants, scalar dictionary reads, and the empty-visible-set status path. No build or tests were run, as required by the review-only task.
  • Observability and performance: An all-hidden SHOW still fans status RPCs to all alive BEs, emits misleading missing-dictionary warnings, and can fail on an unrelated response; this is covered inline. No separate logging or performance issue remains.
  • Transactions, persistence, and data writes: No new transaction or persisted-state format is introduced. The preflight runs before LOADING publication, and later authorization failure retains the existing rollback behavior.

Review status: complete after two rounds. Both Round 2 normal reviewers and the separate risk-focused reviewer returned NO_NEW_VALUABLE_FINDINGS; three candidates were accepted, one real but pre-existing design issue was dismissed after exact base/head comparison, and all remaining candidates were duplicate-fenced or dismissed with evidence.

// 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,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P2] Short-circuit when this filter leaves queryDicts empty. collectDictionaryStatus(emptyList()) does not mean ‘collect none’: the Thrift/BE contract treats an empty ID list as ALL, so a low-privilege user whose correct result is empty still fans status RPCs to every alive BE, logs every returned dictionary as missing from the requested set, and can have SHOW DICTIONARIES fail because of a failed/null response from a contacted alive BE. Return the empty result before status collection (or preserve an explicit none-vs-all distinction).

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 00993be: SHOW DICTIONARIES now returns before status collection when the visible set is empty, so an all-hidden (or empty / non-matching LIKE) result no longer triggers the empty-id-list "ALL" semantics of get_dictionary_status, the per-BE fan-out, the misleading missing-dictionary warnings, or a command failure on a bad BE response.

// The reload also reads the source table as the current user. dataLoad() publishes the
// LOADING status before that INSERT is analyzed, so reject a missing SELECT here instead of
// letting an unauthorized request block concurrent refreshes until planning fails.
if (!Env.getCurrentEnv().getAccessManager().checkTblPriv(ctx, dictionary.getSourceCtlName(),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P2] Use the column-aware source authorization here. The generated dictionary load does not require table-wide SELECT: BindSink.bindDictionarySink projects the source to dictionary.getFullSchema(), then CheckPrivileges authorizes the actually required slots through checkColumnsPriv. A caller with dictionary LOAD plus SELECT_PRIV(id) and SELECT_PRIV(value) for a dictionary containing those source columns is authorized by that load plan (and equivalent Ranger column policies work too), but checkTblPriv(..., SELECT) rejects it before planning. Please preflight the actual dictionary source-column set through the same column-aware contract, or centralize planning/authorization before publishing LOADING, and add a column-only regression case.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 00993be: the source preflight now uses the column-aware contract — it collects the dictionary's source column names and calls AccessControllerManager.checkColumnsPriv(..., SELECT), the same authorization the generated INSERT applies after BindSink projects the source to the dictionary schema. A caller with dictionary LOAD plus column-level SELECT on exactly the dictionary's source columns is authorized again (native and Ranger column policies alike), and a missing privilege is still rejected before LOADING is published. Added a column-only regression case (grant SELECT_PRIV(id)/(username) then REFRESH succeeds), and the negative cases now assert status NORMAL and an unchanged LastUpdateResult to prove the preflight, not the INSERT, rejected the request.

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,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P1] This authorization is bypassed by the actual dictionary read path. DictGet.customSignatureDict and DictGetMany.customSignatureDict call DictionaryManager.getDictionary directly, inspect its schema, and let ExpressionTranslator send its ID/version to the BE without any access-manager check; because SELECT dict_get(...) needs no relation, CheckPrivileges cannot catch it. A user for whom this command returns denied and SHOW DICTIONARIES hides the row can still guess db.dict and read its values (or distinguish dictionary/column errors). Apply the same dictionary read authorization to both scalar functions before lookup/translation, return the same denial for missing and unauthorized names, and add hidden-user read tests.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 00993be: both DictGet.customSignatureDict and DictGetMany.customSignatureDict now check SELECT on the dictionary (internal catalog key, same as the commands) before DictionaryManager.getDictionary, so an unauthorized caller gets the same SELECT command denied whether or not the name exists — no existence or schema probing, and nothing reaches ExpressionTranslator. Internal paths without a ConnectContext are unaffected. Regression now covers: hidden user's dict_get denied; still denied with db-level SHOW_VIEW/LOAD plus source column grants; allowed and returning the value after SELECT on the database is granted (a table-level grant on the dictionary name itself is impossible today because GRANT validates table existence — the namespace issue tracked in #67345).

@mrhhsg
mrhhsg force-pushed the fix/dictionary-command-privileges branch from 9a1fbfe to 00993be Compare August 31, 2026 14:13
@mrhhsg

mrhhsg commented Aug 31, 2026

Copy link
Copy Markdown
Member Author

/review

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Requesting changes: three distinct authorization/lifecycle gaps remain on 00993be115ab21771fd9a2a04637e61fb5c8c6bd.

  1. Nereids SQL-cache replay does not record dictionary privilege or ID/version dependencies, so cached values can survive SELECT revocation or refresh.
  2. REFRESH preflight uses dictionary-DDL column spelling instead of the canonical source-column identity used by the load scan.
  3. Reusable server-prepared short-circuit point queries retain the translated dictionary expression without revalidating privilege or version.

Checkpoint conclusions:

  • Goal, scope, and proof: the six-file change is focused, and direct SHOW/EXPLAIN/REFRESH/dict_get behavior is substantially improved, but the read-authorization and pre-LOADING goals are incomplete because of the three inline findings. Previously raised namespace, empty-status-RPC, table-wide-preflight, and async-wait points were treated as duplicate fences.
  • Concurrency and lifecycle: no additional lock-order, deadlock, drop/recreate, commit/abort, or rollback defect was found. The remaining lifecycle defects are retained SQL/prepared state outliving privilege/version changes and the mixed-case path allowing authorization failure after LOADING publication.
  • Configuration and compatibility: no new configuration, storage format, EditLog schema, function symbol, Thrift field, or FE/BE variable is introduced. The prepared short-circuit issue is reachable under default session settings on a qualifying point table; the SQL-cache issue is reachable with supported BE folding plus the default SQL cache.
  • Parallel and conditional paths: dict_get and dict_get_many share both cache/reuse defects. Fresh direct, ordinary prepared, forwarded, Arrow Flight, internal, native, and Ranger paths were rechecked; no fourth distinct bypass or controller mismatch was found.
  • Tests and results: the regression covers native direct visibility/read/refresh paths, deterministic load waits, column grants, and denial before state mutation. It does not cover the three demonstrated triggers: cache revoke/refresh, mixed-case source columns, and reusable prepared point-query revoke/refresh. No build or test was run because the review prompt is review-only.
  • Persistence, writes, failure handling, observability, and performance: REFRESH otherwise reuses the existing versioned load/commit/rollback flow and fails loudly; existing status/load/RPC diagnostics are adequate, and no separate material performance or persistence issue was found.
  • User focus: no additional user-provided review focus was supplied.

// the lookup so a caller without the privilege cannot even probe whether the dictionary
// exists. ConnectContext may be absent on internal paths, which carry no user to check.
ConnectContext connectContext = ConnectContext.get();
if (connectContext != null && !Env.getCurrentEnv().getAccessManager().checkTblPriv(connectContext,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P1] Revalidate dictionary access on SQL-cache hits

With enable_fold_constant_by_be=true, a literal-only dict_get is authorized once, folded to a Literal, and stored by the FE SQL cache. This relation-free expression records no table/view privilege and no dictionary ID/version dependency, so the same user can repeat the identical SQL after SELECT is revoked and parseFromSqlCache returns the cached value before this check runs; refreshes can likewise leave a stale value reusable. dict_get_many has the same path. Please record and revalidate the dictionary privilege/version dependency, or exclude both functions from SQL-cache insertion/reuse, and cover revoke-after-prime and refresh-after-prime cases.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in c0c06d6: statements that bind dict_get / dict_get_many are now marked cannotProcessExpression in ExpressionAnalyzer (next to the UDF exclusion), so they never enter the FE or BE sql cache — supportSqlCache() gates both tryAddFeSqlCache and tryAddBeCache. That covers the BE-folded literal-only case as well as the default-settings case with a table scan, and also removes the pre-existing staleness after REFRESH DICTIONARY (a refresh changes no table version, so a cached result was never invalidated). Regression test_dictionary_read_auth_cache primes the sql (cache_last_version_interval_second=0, enable_sql_cache=true), checks explain physical plan shows no PhysicalSqlCache, revokes SELECT on the dictionary and expects SELECT command denied on the identical statement.

// contract here so column-level grants keep working, and reject a missing privilege before
// dataLoad() publishes the LOADING status and blocks concurrent refreshes.
Set<String> sourceColumns = dictionary.getDicColumns().stream()
.map(DictionaryColumnDefinition::getName)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P2] Check the canonical source-column names

Dictionary definitions are matched to source columns case-insensitively, but DictionaryColumnDefinition.getName() preserves the DDL spelling while originColumn/fullSchema and the eventual scan use the source column's canonical spelling. Native column grants are compared by exact string, so a source column ID declared as dictionary column id can make this preflight reject a valid canonical grant; a lower-case grant can instead pass here and fail only after dataLoad() publishes LOADING. Please derive this set from the canonical origin/full-schema columns shared with BindSink, and add a mixed-case column-grant regression that preserves the pre-LOADING denial invariant.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in c0c06d6: the preflight now derives the column set from DictionaryColumnDefinition.getOriginColumn().getName() — the source column's own spelling that BindSink / CheckPrivileges compare — instead of the DDL spelling. originColumn has been persisted since the feature's first commit, so no migration concern. Regression adds a dictionary declared as (ID KEY, USERNAME VALUE) over a source table with id / username, grants SELECT_PRIV(id) / SELECT_PRIV(username) plus LOAD, and refreshes successfully.

// the lookup so a caller without the privilege cannot even probe whether the dictionary
// exists. ConnectContext may be absent on internal paths, which carry no user to check.
ConnectContext connectContext = ConnectContext.get();
if (connectContext != null && !Env.getCurrentEnv().getAccessManager().checkTblPriv(connectContext,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P1] Revalidate dictionary access for prepared point-query reuse

A server-prepared point query can project dict_get_many (or dict_get) from a qualifying point scan. Its first execution serializes the dictionary ID/version into ShortCircuitQueryContext; later EXECUTEs take directExecuteShortCircuitQuery without analysis whenever the point table's name and base schema are unchanged. isReusable tracks neither dictionary SELECT nor dictionary ID/version, and these functions are considered deterministic, so revoking only the dictionary grant while retaining SELECT on the point table still reuses the authorized output expression without reaching this check. A refresh can likewise reuse the old version. Please revalidate those dictionary dependencies before direct reuse, or make point-query plans containing either function non-reusable, with revoke-after-prime and refresh-after-prime prepared-statement regressions.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in c0c06d6: LogicalResultSinkToShortCircuitPointQuery no longer sets the short-circuit flag when the projection contains dict_get / dict_get_many, so such point queries take the normal path and every EXECUTE of a server prepared statement is analyzed — and authorized — again; nothing about the dictionary ID/version is retained in a ShortCircuitQueryContext. Regression test_dictionary_read_auth_cache prepares SELECT dict_get(...) FROM <MoW row-store point table> WHERE id = ? over useServerPrepStmts=true, executes once, revokes SELECT on the dictionary from an admin connection and expects the second EXECUTE of the same statement to be denied.

@mrhhsg
mrhhsg force-pushed the fix/dictionary-command-privileges branch from 00993be to c0c06d6 Compare September 1, 2026 04:44
@mrhhsg

mrhhsg commented Sep 1, 2026

Copy link
Copy Markdown
Member Author

/review

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Requesting changes on c0c06d6ea3686b1cd616adbc5a96617e6c96d4b1. Three distinct current-head issues remain:

  1. P1: BE constant folding can erase a dictionary dependency before the new prepared short-circuit guard, allowing reuse across privilege revocation or dictionary refresh.
  2. P1: the function-local read check runs while persisted views are rebound as the invoker, breaking Doris's established view-only privilege boundary.
  3. P2: refresh preflight uses the persisted source-table spelling rather than the resolved table identity, so case-insensitive lookup can disagree with downstream INSERT authorization.

Critical checkpoint conclusions:

  • Goal, scope, and security model: The focused nine-file patch substantially improves dictionary visibility, direct-read authorization, refresh preflight, SQL-cache exclusion, and ordinary prepared revalidation, but the RBAC/revalidation goal remains incomplete through the three inline findings. Restricted authenticated users and privilege isolation are in scope under the repository threat model. No additional user review focus was supplied.
  • Concurrency and lifecycle: Refresh otherwise authorizes before publishing LOADING, retains downstream INSERT authorization, restores failures, and uses exact dictionary-generation fences; no separate lock-order, deadlock, drop/recreate, or status-RPC lifecycle issue remains. The retained ShortCircuitQueryContext lifecycle is the P1 exception.
  • Configuration and compatibility: No new configuration, storage/wire format, public symbol, edit-log schema, FE-BE variable, or mixed-version contract is introduced. The supported enable_fold_constant_by_be setting exposes the folded-dependency path, and persisted-view behavior regresses relative to the existing view authorization contract.
  • Parallel paths and conditions: Both dict_get and dict_get_many, direct/nested/predicate forms, FE/BE SQL cache, ordinary/server-prepared execution, CREATE/ALTER/persisted/nested views, native/external controllers, SHOW/EXPLAIN/REFRESH, follower forwarding, and lower-case table-name modes were traced. Remaining variants deduplicate into the three comments.
  • Tests and expected results: The regressions cover direct RBAC, SHOW/EXPLAIN visibility, pre-LOADING denial, canonical source columns, SQL-cache exclusion, and a nonconstant prepared query. They miss literal folding plus revoke/refresh-after-prime, mixed-case source-table qualifiers, and view-only reads for both functions. Expected-result and cleanup logic were inspected; no build or tests were run because the authoritative review bundle forbids them.
  • Persistence, writes, failure handling, observability, and performance: No new persisted state or transaction protocol is added. Refresh reuses the existing load/rollback flow, although the qualifier mismatch can defer denial until after LOADING. No distinct crash-safety, logging/metrics, or material performance issue remains.

Review convergence: complete after three rounds. Round 3's three normal full passes and separate risk-focused pass found no fourth distinct issue, so the final cap converged and is not capped/incomplete. Existing live threads were treated as hard duplicate fences.

.when(this::scanMatchShortCircuitCondition)
).when(this::filterMatchShortCircuitCondition)))
.thenApply(ctx -> {
if (projectReadsDictionary(ctx.root.child())) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P1] Preserve dictionary dependencies across constant folding

The existing prepared-reuse issue is still reachable when enable_fold_constant_by_be=true. For ResultSink -> Project(dict_get('db.d','v',1)) -> Filter(id = ?) -> Scan(point_table), plan normalization folds the all-literal dictionary call before this table/physical rule runs (FoldConstantRuleOnBE does not skip either dictionary function). This guard then sees only a Literal, marks the query short-circuit, and ShortCircuitQueryContext can reuse that serialized old value after the dictionary privilege or version changes. The new regression uses nonconstant id and never enables BE folding, so it cannot catch this. Please preserve a statement-level dictionary dependency through rewrites (or prevent these calls from folding) and cover literal dict_get/dict_get_many with both revoke-after-prime and refresh-after-prime prepared executions.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in e99a7ec: the reuse decision no longer inspects the plan. ExpressionAnalyzer sets StatementContext.hasDictionaryRead when it binds dict_get / dict_get_many (bind time precedes every rewrite, including FoldConstantRuleOnBE), and LogicalResultSinkToShortCircuitPointQuery.shortCircuit returns without setting the short-circuit flag whenever that mark is present. The same statement-level mark approach also covers alias function bodies (found in my own pre-push review: AliasUdfBuilder analyzes the body without a CascadesContext, so the analyzer now falls back to the current statement's ConnectContext). Regression: prepared SELECT dict_get('db.d','username',1) FROM <point table> WHERE id = ? with enable_fold_constant_by_be=true, executed once, SELECT on the dictionary revoked from an admin connection, second EXECUTE denied.

.map(definition -> definition.getOriginColumn().getName())
.collect(Collectors.toCollection(LinkedHashSet::new));
Env.getCurrentEnv().getAccessManager().checkColumnsPriv(ctx, dictionary.getSourceCtlName(),
dictionary.getSourceDbName(), dictionary.getSourceTableName(), sourceColumns,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P2] Use the resolved source table name in this preflight

The column set is canonical now, but the table argument still comes from the spelling persisted by CREATE DICTIONARY. Under lower_case_table_names=1/2, that spelling can differ from the resolved Table.getName(): native ColPrivilegeKey compares the table string exactly, while the generated INSERT's UserAuthentication check uses the resolved table name. A canonical column grant can therefore be rejected here even though the INSERT would pass; the inverse spelling can pass here and fail only after dataLoad() publishes LOADING. Please authorize with the resolved source qualifiers (or persist them canonically) and add a mixed-case source-table regression with column-only grants.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in e99a7ec: the preflight resolves the source through RelationUtil.getTable(dictionary.getSourceQualifiedName(), ...) (as Dictionary.hasNewerSourceVersion does) and authorizes with the resolved catalog name, DatabaseIf.getFullName() and TableIf.getName() — the identifiers the load scan reports to CheckPrivileges — together with the source columns' own names. A dropped/renamed source now fails there, before LOADING is published. lower_case_table_names is a bootstrap-time FE config the regression environment cannot switch, so this one is covered by the resolved-identity code path rather than a dedicated case; the mixed-case column case from the previous round remains in test_dictionary_read_auth_cache.

// the lookup so a caller without the privilege cannot even probe whether the dictionary
// exists. ConnectContext may be absent on internal paths, which carry no user to check.
ConnectContext connectContext = ConnectContext.get();
if (connectContext != null && !Env.getCurrentEnv().getAccessManager().checkTblPriv(connectContext,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P1] Preserve the persisted-view privilege boundary

This invocation-time check also runs while BindRelation reparses a persisted view definition under the querying user's ConnectContext, before the analyzed body is wrapped in LogicalView. A user granted only SELECT on db.v therefore cannot execute SELECT against a view created as SELECT dict_get('db.d','v',1), even though CheckPrivileges.visitLogicalView explicitly authorizes the view and stops before its protected children (the established behavior covered by test_select_view_auth). DictGetMany has the same check, and translation calls this helper again after view inlining. Please preserve the direct-call and CREATE/ALTER checks while carrying a trusted persisted-view scope through binding and translation, and add view-only regressions for both functions plus a direct-call negative control.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in e99a7ec: the check moved out of customSignatureDict() (which ExpressionTranslator calls again after view inlining) into ExpressionAnalyzer.visitUnboundFunction, through a new DictionaryReadFunction.checkReadPrivilege(ConnectContext) implemented by both functions. It runs once at bind time, right after builder.build and before signature computation resolves the dictionary (so a missing and an unauthorized name still produce the same denial), and only when StatementContext.isAnalyzingView() is false — BindRelation.parseAndAnalyzeView now increments/decrements that depth around the view body's analysis (try/finally), matching the boundary CheckPrivileges.visitLogicalView enforces. Direct calls, alias function bodies and CREATE/ALTER VIEW bodies are still checked against the caller. Regression: views over dict_get and dict_get_many readable by a user holding SELECT on the view only, with direct calls of both functions as negative controls; auth_p0/test_select_view_auth re-run as the existing view-boundary control.

@mrhhsg
mrhhsg force-pushed the fix/dictionary-command-privileges branch from c0c06d6 to e99a7ec Compare September 1, 2026 08:02
### What problem does this PR solve?

Issue Number: None

Related PR: apache#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.
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 (apache#66218).

Dictionaries are authorized like tables of the internal catalog, so:

- `SHOW DICTIONARIES` skips dictionaries the user has no `SHOW` privilege on,
  the same way `SHOW TABLES` filters tables. When nothing is visible it returns
  before collecting BE status: an empty id list means "all dictionaries" to the
  `get_dictionary_status` RPC, so the previous code fanned status RPCs to every
  alive BE for that case (also reachable before this change through an empty
  database or a non-matching `LIKE`).
- `EXPLAIN DICTIONARY` requires `SHOW` on the dictionary, like `DESCRIBE` on a
  table.
- `REFRESH DICTIONARY` checks `LOAD` on the dictionary up front, like `DROP
  DICTIONARY` checks `DROP`. The internal `INSERT INTO` already required it, so
  nobody loses the ability to refresh; the check now happens before the
  dictionary is resolved and before its status is switched to `LOADING`.

The checks run before the dictionary is looked up, so a denied user cannot
probe whether a dictionary exists either.

Reading dictionary values through `dict_get()` / `dict_get_many()` has never
been authorized; that is a separate change and is tracked in apache#67345.

### 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 the denied refresh leaves the dictionary untouched), 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.

Claude-Session: https://claude.ai/code/session_01X9KukfTLYxHmP6iYyEnQtW
@mrhhsg
mrhhsg force-pushed the fix/dictionary-command-privileges branch from e99a7ec to 0b0428f Compare September 1, 2026 14:48
@mrhhsg

mrhhsg commented Sep 2, 2026

Copy link
Copy Markdown
Member Author

run buildall

@hello-stephen

Copy link
Copy Markdown
Contributor

FE UT Coverage Report

Increment line coverage 0.00% (0/16) 🎉
Increment coverage report
Complete coverage report

@hello-stephen

Copy link
Copy Markdown
Contributor
TPC-H: Total hot run time: 16717 ms
machine: 'aliyun_ecs.c7a.8xlarge_32C64G'
scripts: https://github.com/apache/doris/tree/master/tools/tpch-tools
Tpch sf100 test result on commit 0b0428f5f4f78eff67fdf4486b71ca88e170614b, data reload: false

------ Round 1 ----------------------------------
============================================
q1	17556	3062	3061	3061
q2	2177	260	223	223
q3	10148	888	508	508
q4	4670	248	199	199
q5	7682	537	385	385
q6	141	116	94	94
q7	532	488	379	379
q8	9229	883	936	883
q9	3440	2390	2379	2379
q10	6505	853	700	700
q11	392	200	187	187
q12	605	262	194	194
q13	18146	1528	1140	1140
q14	155	147	137	137
q15	q16	430	392	370	370
q17	1398	799	759	759
q18	3114	2268	2233	2233
q19	1309	897	730	730
q20	374	281	204	204
q21	5615	1718	1832	1718
q22	329	274	234	234
Total cold run time: 93947 ms
Total hot run time: 16717 ms

----- Round 2, with runtime_filter_mode=off -----
============================================
q1	3441	3346	3349	3346
q2	527	398	364	364
q3	2179	2254	2186	2186
q4	1181	1150	883	883
q5	2171	2091	2078	2078
q6	164	121	86	86
q7	1057	927	876	876
q8	1600	1405	1420	1405
q9	3119	3101	3077	3077
q10	1849	1777	1627	1627
q11	354	271	250	250
q12	450	423	346	346
q13	1473	1532	1152	1152
q14	174	174	157	157
q15	q16	401	396	351	351
q17	3585	3330	3157	3157
q18	4758	4392	4672	4392
q19	828	789	921	789
q20	994	973	809	809
q21	3873	3094	3299	3094
q22	400	348	322	322
Total cold run time: 34578 ms
Total hot run time: 30747 ms

@hello-stephen

Copy link
Copy Markdown
Contributor
TPC-DS: Total hot run time: 81589 ms
machine: 'aliyun_ecs.c7a.8xlarge_32C64G'
scripts: https://github.com/apache/doris/tree/master/tools/tpcds-tools
TPC-DS sf100 test result on commit 0b0428f5f4f78eff67fdf4486b71ca88e170614b, data reload: false

query5	4280	415	345	345
query6	392	153	134	134
query7	4906	384	245	245
query8	315	124	127	124
query9	8701	2894	2861	2861
query10	402	224	185	185
query11	5380	1042	904	904
query12	119	68	76	68
query13	1193	423	315	315
query14	5951	2169	2051	2051
query14_1	1947	1937	1929	1929
query15	188	120	113	113
query16	924	362	344	344
query17	772	445	354	354
query18	2316	326	229	229
query19	162	132	106	106
query20	69	67	68	67
query21	202	99	84	84
query22	5285	5249	5323	5249
query23	6679	6236	6047	6047
query23_1	6188	5941	6204	5941
query24	7331	1067	778	778
query24_1	770	769	789	769
query25	445	309	270	270
query26	1240	237	129	129
query27	2775	411	257	257
query28	4680	1496	1495	1495
query29	984	462	362	362
query30	254	155	133	133
query31	834	402	323	323
query32	155	75	79	75
query33	465	214	185	185
query34	1012	840	488	488
query35	405	412	346	346
query36	579	572	536	536
query37	132	81	71	71
query38	995	845	803	803
query39	498	502	465	465
query39_1	459	456	471	456
query40	203	91	79	79
query41	62	56	56	56
query42	75	72	74	72
query43	247	243	210	210
query44	1043	554	552	552
query45	112	106	104	104
query46	810	822	548	548
query47	788	751	707	707
query48	310	318	232	232
query49	550	241	186	186
query50	767	257	193	193
query51	8029	7932	7938	7932
query52	71	69	59	59
query53	219	203	148	148
query54	234	161	180	161
query55	75	62	53	53
query56	190	179	166	166
query57	688	660	629	629
query58	201	255	157	157
query59	1199	1215	1074	1074
query60	227	173	166	166
query61	113	113	118	113
query62	363	196	173	173
query63	169	145	140	140
query64	2656	675	572	572
query65	1576	1643	1595	1595
query66	1793	273	223	223
query67	9705	9647	10078	9647
query68	2999	1241	756	756
query69	348	220	211	211
query70	671	618	600	600
query71	251	181	164	164
query72	2375	1708	1583	1583
query73	658	586	343	343
query74	2000	1198	1124	1124
query75	1179	1096	956	956
query76	2367	719	567	567
query77	248	258	197	197
query78	3902	3636	3236	3236
query79	2818	824	579	579
query80	1595	331	277	277
query81	498	159	132	132
query82	731	132	95	95
query83	321	208	188	188
query84	306	109	91	91
query85	880	365	295	295
query86	399	174	170	170
query87	1019	974	879	879
query88	2828	2105	2103	2103
query89	309	195	175	175
query90	1969	127	130	127
query91	137	120	99	99
query92	85	67	71	67
query93	1975	1151	667	667
query94	653	247	229	229
query95	522	312	226	226
query96	849	608	274	274
query97	1042	1060	993	993
query98	169	133	136	133
query99	429	348	306	306
Total cold run time: 178825 ms
Total hot run time: 81589 ms

@hello-stephen

Copy link
Copy Markdown
Contributor
ClickBench: Total hot run time: 14.75 s
machine: 'aliyun_ecs.c7a.8xlarge_32C64G'
scripts: https://github.com/apache/doris/tree/master/tools/clickbench-tools
ClickBench test result on commit 0b0428f5f4f78eff67fdf4486b71ca88e170614b, data reload: false

query1	0.01	0.00	0.01
query2	0.08	0.04	0.03
query3	0.25	0.08	0.10
query4	1.62	0.10	0.09
query5	0.17	0.16	0.16
query6	1.22	0.68	0.69
query7	0.04	0.01	0.00
query8	0.05	0.03	0.03
query9	0.30	0.21	0.22
query10	0.35	0.34	0.35
query11	0.16	0.12	0.12
query12	0.16	0.13	0.12
query13	0.30	0.29	0.31
query14	0.44	0.44	0.45
query15	0.37	0.34	0.35
query16	0.22	0.22	0.24
query17	0.68	0.71	0.71
query18	0.19	0.17	0.18
query19	1.16	1.18	1.19
query20	0.01	0.01	0.01
query21	15.44	0.16	0.12
query22	5.06	0.04	0.04
query23	16.20	0.26	0.10
query24	3.11	0.31	0.28
query25	0.10	0.05	0.03
query26	0.76	0.16	0.12
query27	0.04	0.03	0.03
query28	3.58	0.56	0.26
query29	12.42	3.20	2.68
query30	0.26	0.11	0.11
query31	2.75	0.36	0.17
query32	3.51	0.31	0.24
query33	1.35	1.45	1.36
query34	15.47	2.16	1.76
query35	1.73	1.72	1.71
query36	0.47	0.29	0.28
query37	0.06	0.04	0.04
query38	0.05	0.03	0.03
query39	0.03	0.02	0.02
query40	0.12	0.08	0.08
query41	0.09	0.03	0.02
query42	0.04	0.02	0.03
query43	0.04	0.03	0.03
Total cold run time: 90.46 s
Total hot run time: 14.75 s

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants