Skip to content

Check access rights in EXPLAIN QUERY TREE and EXPLAIN SYNTAX - #110180

Open
alexey-milovidov wants to merge 49 commits into
masterfrom
fix-explain-query-tree-access
Open

Check access rights in EXPLAIN QUERY TREE and EXPLAIN SYNTAX#110180
alexey-milovidov wants to merge 49 commits into
masterfrom
fix-explain-query-tree-access

Conversation

@alexey-milovidov

Copy link
Copy Markdown
Member

Closes: #78938

EXPLAIN QUERY TREE and EXPLAIN SYNTAX (in the analyzer) resolve the query and dump table metadata such as column names and types, but unlike EXPLAIN PLAN they do not build a query plan. The SELECT access check that the planner performs in prepareBuildQueryPlanForTableExpression was therefore skipped, so a user with no privileges could read the column names and data types (including Enum element lists) of tables they are not allowed to access, while SELECT, EXPLAIN PLAN and EXPLAIN PIPELINE are correctly rejected.

This adds a SELECT access check for every table referenced anywhere in the query tree (including tables inside subqueries in expressions such as WHERE x IN (SELECT ... FROM t)). The check mirrors the planner: it validates access to the columns that are actually read, with the trivial-count fallback (access is granted if at least one column is accessible) for queries that read no specific column, e.g. SELECT count() FROM t. As a result, a user with a column-level grant sees the same behavior as for a plain SELECT (EXPLAIN QUERY TREE SELECT granted_col FROM t is allowed, ... other_col ... is denied).

buildQueryTree only builds the tree; table identifiers are bound to storages and columns are resolved by the query analysis pass. The check runs on the resolved tree: on query_tree directly when the passes already resolved it, or on a throwaway resolved copy otherwise (e.g. EXPLAIN QUERY TREE run_passes = 0, which intentionally dumps the unresolved tree, so the tree that gets dumped is unchanged).

Changelog category (leave one):

  • Bug Fix (user-visible misbehavior in an official stable release)

Changelog entry (a user-readable short description of the changes that goes into CHANGELOG.md):

Fix EXPLAIN QUERY TREE and EXPLAIN SYNTAX not checking access rights on the referenced tables, which allowed a user without privileges to read table column names and data types.

Documentation entry for user-facing changes

  • Documentation is written (mandatory for new features)

🤖 Generated with Claude Code

`EXPLAIN QUERY TREE` and `EXPLAIN SYNTAX` (in the analyzer) resolve the
query and dump table metadata such as column names and types, but unlike
`EXPLAIN PLAN` they do not build a query plan. The `SELECT` access check
that the planner performs in `prepareBuildQueryPlanForTableExpression`
was therefore skipped, so a user with no privileges could read the
column names and data types (including `Enum` element lists) of tables
they are not allowed to access.

Add a `SELECT` access check for every table referenced anywhere in the
query tree. The check mirrors the planner: it validates access to the
columns that are actually read, with the trivial-count fallback (access
is granted if at least one column is accessible) for queries that read
no specific column, e.g. `SELECT count() FROM t`.

`buildQueryTree` only builds the tree; table identifiers are bound to
storages and columns are resolved by the query analysis pass. The check
runs on the resolved tree: on `query_tree` directly when the passes
already resolved it, or on a throwaway resolved copy otherwise (e.g.
`EXPLAIN QUERY TREE run_passes = 0`, which intentionally dumps the
unresolved tree).

Closes: #78938

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@clickhouse-gh

clickhouse-gh Bot commented Jul 12, 2026

Copy link
Copy Markdown
Contributor

Workflow [PR], commit [fcc47e8]

Summary:

job_name test_name status info comment
Stress test (amd_asan_ubsan) FAIL
Sanitizer (STID: 2064-34a1) FAIL cidb

AI Review

Summary

This PR substantially tightens access checks for EXPLAIN QUERY TREE, EXPLAIN SYNTAX, and EXPLAIN AST optimize = 1, including parameterized views, nested subqueries, and analyzer-off paths. The remaining issue is that the nested regular-view check now analyzes entire nested subqueries just to recover view access inputs, which can turn a formatting-only EXPLAIN into a slow/networked operation on unrelated sources.

Findings

⚠️ Majors

  • [src/Interpreters/InterpreterExplainQuery.cpp:462] checkNestedSelectsViewBaseTableAccess analyzes the whole nested SELECT copy with InterpreterSelectQuery just to recover the view's requested columns. That also resolves unrelated table functions in the same subquery, so EXPLAIN SYNTAX / EXPLAIN AST optimize = 1 on a query like ... IN (SELECT v.a FROM v JOIN paimonAzure(...) AS p ON ...) can still open the remote connection and wait on its timeout even though the final dump leaves that nested subquery unexpanded. The later catch (...) only hides the exception; it does not restore the previous no-side-effect behavior.
    Suggested fix: fail-close here the same way the top-level helpers do and skip the nested access pass when the subquery would need unrelated table-function resolution, or derive the view-specific access inputs without fully analyzing the whole nested subquery.
Performance & Safety
  • The nested-view access pass now adds avoidable latency and external side effects to EXPLAIN on queries that do not need those remote resolutions for the final dump.
Final Verdict

Needs changes before merge: the new nested-view check can still turn EXPLAIN SYNTAX / EXPLAIN AST optimize = 1 into a remote, timeout-bound operation on unrelated sources.

LLVM Coverage Report

Metric Baseline Current Δ
Lines 86.60% 86.60% +0.00%
Functions 91.90% 91.90% +0.00%
Branches 78.80% 78.90% +0.10%

Changed lines: Changed C/C++ lines covered: 394/414 (95.17%) · Uncovered code

Full report · Diff report

@clickhouse-gh clickhouse-gh Bot added the pr-bugfix Pull request with bugfix, not backported by default label Jul 12, 2026
The `SELECT` access check added for `EXPLAIN QUERY TREE` and `EXPLAIN SYNTAX`
resolved a throwaway copy of the query tree in the branch that dumps the
*unresolved* tree (`EXPLAIN SYNTAX`, `EXPLAIN QUERY TREE run_passes = 0`).
Resolution throws for queries that these statements are specifically designed
to format without resolving - e.g. fuzzed or invalid expressions, a `JOIN`
subquery without an alias (`ALIAS_REQUIRED`), `GROUP BY grouping sets ...
WITH TOTALS` (`NOT_IMPLEMENTED`), or `executable('', ...)` whose arguments the
analyzer intentionally does not evaluate for `EXPLAIN SYNTAX` (`BAD_ARGUMENTS`).
This broke `03773_join_on_formatting`, `03761_inconsistent_ast_formatting`,
`03771_tuple_inconsistent_formatting`, `03601_inconsistent_table_names`,
`01883_with_grouping_sets` and `02377_executable_function_settings` in the
`Fast test`.

The dumped tree in this branch is the unresolved `query_tree`, which never
carries resolved column names or types, so the dump itself cannot leak table
metadata. When resolution of the throwaway copy fails, there is no resolved
metadata to protect and a real query would fail with the same resolution error
before the planner's access check, so we skip the access check instead of
turning a formatting request into a resolution error. An `ACCESS_DENIED`
raised during resolution is still propagated, so `EXPLAIN SYNTAX` of a table
the user cannot read stays denied.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Comment thread src/Interpreters/InterpreterExplainQuery.cpp Outdated
alexey-milovidov and others added 3 commits July 13, 2026 06:18
`checkAccessRightsForQueryTree` collected every `TableNode` in the resolved
query tree and re-checked all of them against the outer `query_context`. That
is wrong for scopes that do not execute under the top-level context: when a
view is inlined (`analyzer_inline_views = 1`), its body is resolved under
`StorageView::getViewSubqueryContext`, and the planner checks the base-table
privileges with that per-scope context (`buildPlannerContext` /
`prepareBuildQueryPlanForTableExpression` use `QueryNode::getContext`).

Flattening every table into one global check made `EXPLAIN QUERY TREE` and
analyzer `EXPLAIN SYNTAX` deny a valid `SELECT ... FROM definer_view` when the
user has `SELECT` on a `SQL SECURITY DEFINER` / `NONE` view but not on its
base tables.

Track the scope context with `InDepthQueryTreeVisitorWithContext` and check
each table with the context of its own scope, reproducing the planner. Columns
are now collected per `TableNode` instance (`collectSelectedColumnsForTableNode`)
so a base table referenced both directly and inside an inlined view is checked
with the correct column set in each scope.

Addresses the AI review on
#110180

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Covers the per-scope access-check fix: a user with SELECT on a
`SQL SECURITY DEFINER` / `NONE` view but not on its base table can still
`EXPLAIN QUERY TREE` / `EXPLAIN SYNTAX` it with `analyzer_inline_views = 1`,
matching what a plain SELECT through the view allows; a user with no access is
still denied. Verified locally against a freshly built server.
@alexey-milovidov

Copy link
Copy Markdown
Member Author

Addressed the AI-review Major (per-scope access check for inlined SQL SECURITY DEFINER / NONE views) in db3b852, with test 04539_explain_query_tree_access_definer_view in c4b40b5 — verified locally against a freshly built server. Details in the resolved review thread.

Out of scope, but flagging a pre-existing issue found while testing this: with analyzer_inline_views = 1 a user with no grant on a SQL SECURITY DEFINER view can run a plain SELECT on it and read the data:

-- outsider has NO grant on v_definer, only the definer has SELECT on the base table
SELECT * FROM db.v_definer SETTINGS analyzer_inline_views = 0;  -- ACCESS_DENIED (correct)
SELECT * FROM db.v_definer SETTINGS analyzer_inline_views = 1;  -- returns the data (bypass)

QueryAnalyzer::inlineViewSubqueryIfNeeded checks row policies but never verifies the invoker's SELECT grant on the view before replacing it with its (definer-resolved) body, so the view's own access check is skipped when inlined. This is independent of EXPLAIN — the EXPLAIN access check in this PR faithfully matches the (buggy) SELECT behavior — so I left it alone here. Happy to open a separate issue if useful.

Comment thread tests/queries/0_stateless/04538_explain_query_tree_access_check.sh Outdated
alexey-milovidov and others added 2 commits July 13, 2026 10:09
The `run` helper in `04538_explain_query_tree_access_check.sh` and
`04539_explain_query_tree_access_definer_view.sh` mapped every
non-`ACCESS_DENIED` outcome to `OK`, so a positive case that started
throwing a different exception (e.g. `UNKNOWN_IDENTIFIER`,
`NOT_IMPLEMENTED`) would still pass and the tests would no longer prove
the fix.

Now `OK` is printed only when the client exits with status 0,
`ACCESS_DENIED` only for that specific exception, and any other
exception is emitted verbatim, which makes the reference diff fail. The
expected `OK` / `ACCESS_DENIED` outputs are unchanged, so both reference
files stay valid.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Comment thread src/Interpreters/InterpreterExplainQuery.cpp Outdated
alexey-milovidov and others added 3 commits July 13, 2026 14:19
…atabase tables

The AI review found that `checkAccessRightsForQueryTree` returned early on
`!storage_id.hasDatabase()` before it knew whether the query was a
trivial-count / `SELECT 1` case. The planner's `PlannerJoinTree::checkAccessRights`
guards only the explicit-column branch with `hasDatabase` and always runs the
"at least one accessible column" fallback first, with `ContextAccess` resolving
an empty database name to the current database. As written, `EXPLAIN QUERY TREE`
and analyzer `EXPLAIN SYNTAX` could still succeed for `count()`-style queries on
empty-database `TableNode`s (cross-replication) even when the real `SELECT` would
be denied.

Move the `hasDatabase` guard so it only skips the explicit-column check, and let
the trivial-count fallback run unconditionally, matching the planner. Extend
`04538_explain_query_tree_access_check` to cover the trivial-count path.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Comment thread src/Interpreters/InterpreterExplainQuery.cpp Outdated
…XPLAIN SYNTAX

`EXPLAIN SYNTAX` inlines a `SQL SECURITY INVOKER` parameterized view call
`pv(...)` into its inner subquery via `ExpandParameterizedViewsMatcher` before the
query tree is built, so the view object is no longer present in the tree that
`checkAccessRightsForQueryTree` inspects — only the base tables were checked. Real
execution instead turns `pv(...)` into a fake `TableNode` for the view in
`QueryAnalyzer::resolveTableFunction` and checks `SELECT` on it in
`prepareBuildQueryPlanForTableExpression`, so a user with access to the base tables
but no grant on the view could `EXPLAIN SYNTAX SELECT ... FROM pv(...)` even though
the real `SELECT` is denied.

Run the access check on the original (unexpanded) query when a parameterized view
was expanded: there the view still resolves to its own `TableNode`, so its `SELECT`
grant (and its selected columns) are enforced exactly as a real `SELECT` does, while
the base tables inside its body are still checked under the per-scope context. The
check on the expanded dump tree is skipped in that case to avoid a redundant,
incomplete check. Non-parameterized-view queries are unaffected.

Extracted the resolve-then-check logic into `resolveThenCheckAccessRights` shared by
both paths.

Test: `04600_explain_syntax_parameterized_view_access.sh`.
Comment thread src/Interpreters/InterpreterExplainQuery.cpp
…ews in EXPLAIN

A regular, non-parameterized view that is not inlined (`analyzer_inline_views = 0`,
the default) stays as a single `TableNode` in the query tree, so
`checkAccessRightsForQueryTree` only enforced `SELECT` on the view object and never
re-checked the base tables. Real execution does check them: `StorageView::readImpl`
builds the view's inner query under the view's own context (the definer for
`SQL SECURITY DEFINER`, the invoker otherwise) and checks the base-table privileges
there. As a result a user with `SELECT` on a default `SQL SECURITY INVOKER` view but
not on its base table could `EXPLAIN QUERY TREE` / `EXPLAIN SYNTAX SELECT * FROM v`
and learn the view's projected columns/types even though the actual `SELECT` is denied.

Reproduce the inner access pass: when the check encounters a non-inlined regular view
`TableNode`, build its inner query under `StorageView::getViewSubqueryContext`, resolve
it, and run the access check recursively (which also covers nested views). Parameterized
views are expanded and checked separately; inlined views already expose their base tables
as `TableNode`s, so neither is double-checked.

Test `04601_explain_query_tree_access_invoker_view`.
Comment thread src/Interpreters/InterpreterExplainQuery.cpp
alexey-milovidov and others added 2 commits July 15, 2026 09:40
…yzer-only

The AI review asked to run the parameterized-view object access check in the
legacy (allow_experimental_analyzer = 0) EXPLAIN SYNTAX fallback as well. That
would over-deny: the old interpreter never requires a SELECT grant on the
parameterized view object — Context::executeTableFunction resolves pv(...)
without any access check and InterpreterSelectQuery skips
checkAccessRightsForSelect for table functions — so a real
SELECT ... FROM pv(...) succeeds with only the base-table grants (verified
against a live server). The legacy fallback already checks the base tables via
InterpreterSelectQuery inside ExplainAnalyzedSyntaxVisitor, which is exactly
what real legacy execution enforces, so EXPLAIN SYNTAX is allowed precisely
when the real SELECT is allowed and denied (without leaking anything) when it
is not.

Add a comment explaining this at the analyzer gate and a legacy-path
regression test 04602_explain_syntax_parameterized_view_access_legacy that
pins the faithfulness contract: base-only user -> real SELECT and
EXPLAIN SYNTAX both allowed; user without grants -> both denied.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Comment thread src/Interpreters/InterpreterExplainQuery.cpp
…s in legacy EXPLAIN SYNTAX

`ExplainAnalyzedSyntaxMatcher` (the `allow_experimental_analyzer = 0` fallback used by
`EXPLAIN SYNTAX`) only checked `SELECT` on the view object itself, via
`checkAccessRightsForSelect` inside `InterpreterSelectQuery::analyze`. A real `SELECT`
through a regular, non-parameterized view goes on to check the base-table privileges
too, but only once the pipeline is actually built, in `StorageView::readImpl` - which
`EXPLAIN SYNTAX` never reaches. So a user with `SELECT` on the view but not on its base
table could `EXPLAIN SYNTAX` the expanded view body (with `allow_experimental_analyzer
= 0`) while the real `SELECT` is denied.

Reproduce that inner check by resolving the view straight from the AST (before it is
rewritten into a subquery) and running the base-table access check on its inner query
under the view's own security context, recursing through nested views the same way the
analyzer path (`checkAccessRightsForQueryTree`) already does for `EXPLAIN QUERY TREE`
and the analyzer's `EXPLAIN SYNTAX`. Factored the shared recursion into
`checkViewBaseTableAccess`, used by both paths.

Test: `04603_explain_syntax_invoker_view_access_legacy.sh`.
Comment thread src/Interpreters/InterpreterExplainQuery.cpp Outdated
Address the AI review on #110180.

A parameterized view is not resolved into a `TableNode`: `QueryAnalyzer::resolveTableFunction`
turns `pv(...)` into a `TableFunctionNode` that owns the view storage built by
`Context::buildParameterizedViewStorage`, and the planner checks `SELECT` on that view object for
exactly this node type in `prepareBuildQueryPlanForTableExpression`. `checkAccessRightsForQueryTree`
collected only `TableNode`s, so every `EXPLAIN` form skipped the view object's own grant: a user with
`SELECT` on the base table but none on the view could read the view definition through
`EXPLAIN QUERY TREE`, `EXPLAIN SYNTAX` and `EXPLAIN AST optimize = 1` while a real `SELECT` is
rejected. Collect parameterized-view `TableFunctionNode`s as well (other table functions stay out, as
in the planner: their check happens in `ITableFunction::execute`), and check them with the same
column-aware `SELECT` check and the same recursive base-table pass through `checkViewBaseTableAccess`.

`collectSelectedColumnsForTableExpression` generalizes `collectSelectedColumnsForTableNode` to match
columns by any table expression instance, because the columns selected from a parameterized view have
its `TableFunctionNode` as their column source.

`EXPLAIN AST optimize = 1` no longer ties the view-object pre-check to the explain-time expansion
having happened: expansion is deliberately skipped for `FINAL` / `SAMPLE` and for
`SQL SECURITY DEFINER` / `NONE` views, and the legacy rewriting visitor still inlines the view body
for those, so the check now runs for every referenced parameterized view. New test
`04656_explain_ast_optimize_definer_parameterized_view_object_access`.

The mode the pre-check follows is now derived only from the fully applied context, not from
`session || explained query`: `EXPLAIN AST` forces the legacy interpreter as an implementation detail,
so the effective value is read from a copy of the session context that override never touched, with
the explained statement's own `SETTINGS` applied. An explicit
`SETTINGS allow_experimental_analyzer = 0` therefore no longer denies an `EXPLAIN AST optimize = 1`
whose real `SELECT` succeeds with the base-table grants alone. New test
`04657_explain_ast_optimize_analyzer_disabled_in_query_settings`.

`joined_subquery_requires_alias` is relaxed for the throwaway tree the access check resolves: it is a
restriction on how a query may be written, not an access rule, and an unexpanded parameterized view
call in a `JOIN` carries no alias, which would otherwise skip the check and fall back to dumping the
query unexpanded (`04105_explain_syntax_parameterized_view`).
Comment thread src/Interpreters/InterpreterExplainQuery.cpp
…cy EXPLAIN

Address review: the legacy formatting path (`ExplainAnalyzedSyntaxVisitor`, used
by `EXPLAIN SYNTAX` with `enable_analyzer = 0` and by `EXPLAIN AST optimize = 1`)
unconditionally rewrote the main-`FROM` parameterized view call into a subquery,
even though `ExpandParameterizedViewsMatcher` deliberately keeps `pv(...)` intact
when the table expression carries `FINAL` or `SAMPLE` (those modifiers are
rejected on a subquery). The result was `(SELECT ...) FINAL` - a query the
executor rejects - while the real `SELECT ... FROM pv(...) FINAL` is valid.

Skipping the rewrite alone is not enough: the `analyze()`-mode
`InterpreterSelectQuery` mutates the call in place (its own
`StorageView::replaceWithSubquery` / `restoreViewName` round trip leaves a fake
table identifier holding the view name instead of the original `pv(...)` call),
so the original table expression is snapshotted up front and restored afterwards.

Test `04660_explain_parameterized_view_final_sample_legacy` pins the executable
output for `FINAL` and `SAMPLE` under both `EXPLAIN SYNTAX` and
`EXPLAIN AST optimize = 1`, and that the view body is still expanded without
those modifiers.
Comment thread src/Planner/collectSelectedColumnsFromTable.cpp
…ected columns

`collectSelectedColumnsForTableExpression` matches a parameterized view's `TableFunctionNode`,
but `isAliasColumn` only recognised `QueryTreeNodeType::TABLE` as a carrier of `ALIAS` columns.
The planner's `CollectTableExpressionData::isAliasColumn` accepts `TABLE_FUNCTION` too, and keeps
only the selected alias name in `TableExpressionData::getSelectedColumnsNames`. Descending into an
alias column's defining expression here would collect the columns that expression reads on top of
the alias name, so the access check could demand grants the real `SELECT` never asks for and
`EXPLAIN` would reject a query that plain execution allows.

The over-denial is not reachable through a parameterized view today, because
`Context::buildParameterizedViewStorage` builds the view's `ColumnsDescription` from the inner
query's sample block, which materialises the outputs as ordinary rather than `ALIAS` columns. The
other caller, `IStorage::getDependentViewsByColumn`, only collects columns whose source is a
`TableNode`, so it is unaffected either way. Keeping the two visitors in sync is still the right
contract, and the new test pins `EXPLAIN` outcome to plain `SELECT` outcome so it keeps holding.
… setting

The test dumps the expanded parameterized view body, and the harness randomizes
`query_plan_optimize_prewhere`. When it is off, the move-to-prewhere rewrite is applied to the AST
instead of to the query plan, so the body's `WHERE` is printed as `PREWHERE` and the reference no
longer matches. This showed up as a reproducible failure in several flaky-check jobs.

Pin `optimize_move_to_prewhere = 0` so the dumped body is stable. The prewhere placement is not
what this test is about - it checks that a parameterized view call carrying `FINAL` or `SAMPLE`
stays unexpanded on the legacy formatting path.
…PLE view calls

Keeping a parameterized view call with `FINAL` or `SAMPLE` unexpanded on the legacy formatting path
also changes `EXPLAIN SYNTAX` output when the server defaults to the old analyzer, and
`04105_explain_syntax_parameterized_view` has a separate `.oldanalyzer.reference` that still
expected the inlined body. Only the two `04105_modifiers_pv` blocks change, matching the already
updated `.reference`; the reproducible failure was in the `old analyzer, s3 storage, DBReplicated`
stateless job.
Comment thread src/Interpreters/InterpreterExplainQuery.cpp
…gacy EXPLAIN path

`ExplainAnalyzedSyntaxVisitor` stops descending at an `ASTSelectQuery`, so
`checkNonParameterizedViewBaseTableAccess` only ever ran for the outermost `SELECT` of the explained
query. A regular `SQL SECURITY INVOKER` view read from a nested subquery - `FROM (SELECT ... FROM v)`,
`WHERE x IN (SELECT ... FROM v)`, a scalar subquery - therefore never got its base tables checked:
`InterpreterSelectQuery::analyze` checks only `SELECT` on the view object, while the base-table denial
of a real query happens in `StorageView::readImpl`, which `EXPLAIN` never executes. `EXPLAIN SYNTAX` /
`EXPLAIN AST optimize = 1` were then allowed where the real `SELECT` is `ACCESS_DENIED`.

The same check now runs for every nested `SELECT` that reads from such a view, with the same
column-level precision: each nested `SELECT` is analyzed on a copy (so the query being dumped is not
touched, and nested view references stay unexpanded as before) and the columns real execution requests
from the view are taken from that analysis. Nested `SELECT`s that reference no regular view are not
analyzed at all, and a table name that does not resolve here - a `WITH` table of the enclosing query,
an unknown database - is left to the query's own analysis, so nothing that used to be explainable
starts failing.

New test `04666_explain_syntax_nested_subquery_view_access_legacy`.
Comment thread src/Interpreters/InterpreterExplainQuery.cpp Outdated
@clickhouse-gh

clickhouse-gh Bot commented Aug 2, 2026

Copy link
Copy Markdown
Contributor

LLVM Coverage Report

Metric Baseline Current Δ
Lines 86.60% 86.50% -0.10%
Functions 91.90% 91.90% +0.00%
Branches 78.80% 78.70% -0.10%

Changed lines: Changed C/C++ lines covered: 378/399 (94.74%) · Uncovered code

Full report · Diff report

…LAIN SYNTAX

`EXPLAIN SYNTAX` ran the analyzer-side access pre-check on the original query only when
`ExpandParameterizedViewsMatcher` had actually expanded a parameterized view call. Expansion is
deliberately skipped for `FINAL` / `SAMPLE` calls and for views created with `SQL SECURITY DEFINER`
or `NONE`, and for a statement that merely wraps a `SELECT` (`INSERT INTO dst SELECT ... FROM
pv(...)`) `explainQueryTree` declines the non-`SELECT` root, so the legacy formatting visitor was
reached and inlined the view body through `StorageView::replaceWithSubquery` without ever enforcing
the `SELECT` grant on the view object that `prepareBuildQueryPlanForTableExpression` requires for a
real `SELECT ... FROM pv(...)`.

Reproduced with `enable_analyzer = 1` and a user holding `SELECT` on the base table but not on the
view: `INSERT INTO dst SELECT * FROM pv_def(n = 1)` was `ACCESS_DENIED` while
`EXPLAIN SYNTAX INSERT INTO dst SELECT * FROM pv_def(n = 1)` printed the inlined body; the
`FINAL` form was likewise dumped instead of being denied.

Gate the pre-check on `referenced_parameterized_view` instead, matching what the
`EXPLAIN AST optimize = 1` path already does. The check stays analyzer-only, so
`enable_analyzer = 0` still dumps what its real execution allows.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Comment thread src/Interpreters/InterpreterExplainQuery.cpp Outdated
alexey-milovidov and others added 2 commits August 4, 2026 15:02
…ck could not run

`checkAccessRightsForQueryTree` recurses into a non-inlined view's inner query
(`checkViewBaseTableAccess`) to reproduce the base-table `SELECT` check real
execution runs in `StorageView::readImpl`. That recursive pass skips itself when
the inner query cannot be resolved by the analyzer (e.g. a view created under
`enable_analyzer = 0` with `GROUP BY GROUPING SETS (...) WITH TOTALS`), but the
analyzer path dropped the signal: `EXPLAIN QUERY TREE SELECT * FROM v` (and the
analyzer `EXPLAIN SYNTAX run_query_tree_passes = 1`) still dumped the resolved
outer tree after only the view-object grant, handing out the view's resolved
columns and types although the base-table pass never ran.

Propagate the skip through `checkAccessRightsForQueryTree` /
`resolveThenCheckAccessRights` / `checkAccessForExplainedSelect` and make
`explainQueryTree` fall back to dumping a freshly built, unresolved tree - the
user's own query text - matching the fail-close the legacy `EXPLAIN SYNTAX`
formatter already implements by keeping such a view unexpanded. A real `SELECT`
through such a view fails while resolving the inner query, so no successfully
running query loses its resolved `EXPLAIN`.

Test `04703_explain_query_tree_view_unresolvable_access` pins the matrix:
outsider denied on the view object; view-only and full users both get the
unresolved fallback with no resolved metadata revealed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@alexey-milovidov

Copy link
Copy Markdown
Member Author

🕵 @groeneai, both remaining CI failures on cf1eac5 are fleet-wide master regressions unrelated to this PR (the PR only touches EXPLAIN access checks; no JOIN or distributed-query code paths). Please investigate each and provide a fix in a separate PR — or link the fix here if one is already in progress:

  1. Stress test (amd_debug): Logical error '(isConst() || isSparse() || isReplicated() || rhs.isConst() || rhs.isSparse() || rhs.isReplicated()) ? getDataType() == rhs.getDataType() : typeid(*this) == typeid(rhs)' (STID: 2508-2e0c). The stack is entirely in the hash-join residual-filter path: IColumn::insertFrom type mismatch inside DB::buildAdditionalFilterHashJoinMethods<...>::joinRightColumnsWithAdditionalFilter (src/Interpreters/HashJoin/HashJoinMethodsImpl.h:807). CIDB shows the flare started around 2026-08-04 12:00 UTC — including master runs (pull_request_number = 0) — and has since hit 40+ unrelated PRs across all stress-test variants. It cannot be Fix wrong results for hash joins on a single LowCardinality wide-integer key #113230 (merged 2026-08-04 16:36 UTC, after the first master failure). No tracking issue exists yet.

  2. BuzzHouse (amd_msan): Logical error Sending a distributed query with unknown (zero) client version. The query context was not initialized as an initial query (STID: 4148-4291). Also new since 2026-08-04, seen on 8 unrelated PRs (e.g. Add spill support with a configurable in-memory buffer for packed part writes #113321, Reject WITH FILL bounds that do not fit the ORDER BY column type #112650, Add symbols and lines to error tables #100374, Fix ATTACH of Kafka tables with a large kafka_num_consumers #113390, Propagate the cluster-function read-task callback into SQL SECURITY views #113371, Fix possible logical error "Unexpected substream ... for column ..." during bump of compatibility setting #109496). No tracking issue exists yet.

@groeneai

groeneai commented Aug 5, 2026

Copy link
Copy Markdown
Contributor

Thanks, and your triage is right on both counts: neither is caused by this PR.

1. STID 2508-2e0c, hash-join residual filter: fixing PR #107957 (mine, open).

The frame you quote is exactly the line that PR changes. From your Stress test (amd_debug) artifact the stack is IColumn::assertTypeEquality (IColumn.h:941) <- inlined insertFrom (IColumn.h:255) <- buildAdditionalFilter (HashJoinMethodsImpl.h:807) <- joinRightColumnsWithAdditionalFilter (:997), and HashJoinMethodsImpl.h:807 is col->insertFrom(*src_col, row_pos); both in your build and on master. The destination column is built from the residual filter's required columns, which reference the raw right-table inputs, while the saved right block may already carry join-output nullability; #107957 reconciles the two representations at that line. I checked all 1853 open PRs and no other one touches that site.

Two corrections to the numbers, both in the direction you suspected. Keying on the stack frame rather than the STID (per-STID undercounts this family, and assertTypeEquality became symmetric in #101105, so the old assertion text undercounts too) gives 57 rows / 47 PRs / 4 master over the last 14 days as of 11:22 UTC: 1 to 3 per day from 07-25 to 08-03, then 24 rows / 19 PRs / 4 master on 08-04 and 25 rows / 23 PRs on 08-05 so far. It is still climbing, so those last two figures move. Your 2508-30f6 (30 rows) and 2508-2e0c (23 rows) are the same frame reached by different entry paths, so they are one defect with two fingerprints. Your exclusion of #113230 also holds: its merge 6b45106b8941 is not an ancestor of the first master failure a81fa9978195.

On the flare onset, the trigger is mine. #112831 (merged 08-04 11:04:38Z, 1h50m before that first master failure) makes full_sorting_merge and direct decline a mixed ON condition, so those queries fall back to hash join and reach buildAdditionalFilter more often. Measured on the tree CI actually builds (the merge commit, not the head): the mixed-ON refusal count in PlannerJoins.cpp is 2 in all 12 carriers from the flare onward and 1 in all 6 carriers before it. The bug itself predates that PR (rows from 07-25), so #112831 widened the reach rather than introducing it, and the queries it redirects are valid. #107957 remains the fix; it has been waiting on review since 06-24.

2. STID 4148-4291, zero client version: a fix task is at pending, and I will post the fixing PR link here when it opens.

One correction that matters for your read: this row is not a stale base. CI builds the merge commit, and the job log for BuzzHouse (amd_msan) shows HEAD is now at 1ab111471 Merge cf1eac5f8de3 into 46d902271dfe. Probing that built tree, both the RemoteQueryExecutor guard and the setClientVersionFromConnectionIfUnknown root-cause fix from your #109408 are present (its merge 3bc26f40 is an ancestor). Re-probing all 8 rows on their own built trees, the fix is present in 8 of 8 (controls: master 1/1, pre-merge parent 08938c2f 0/0). Probing the head shas instead reports absent for 4 of those 8, which is what made this look like stale bases.

So #109408 is doing its job as an assertion and has uncovered a context that neither fill site covers. The stack is uniform across all 8: RemoteQueryExecutor.cpp:509 <- getStructureOfRemoteTable.cpp:98 <- getStructureOfRemoteTable.cpp:174 <- StorageDistributed::StorageDistributed (StorageDistributed.cpp:466). Every failing query is CREATE [TEMPORARY] TABLE ... ENGINE = Distributed(...) with no column list, so the constructor fetches the remote structure at DDL time using the storage's own context, and that context is neither a fresh INITIAL_QUERY filled at Session.cpp:739 nor a TCP peer filled at TCPHandler.cpp:2491. It is not specific to system_logs_export: the 113321 carrier uses test_cluster_two_shard_three_replicas_localhost. Census is 8 rows / 8 carriers / 0 master over 14 days, all BuzzHouse; over 90 days the only earlier rows are 3 on 07-04 on #109408 itself. Note 113371 in your list is mine and already merged, so that line belongs to this investigation and not to its diff.

Nothing here needs a change to this PR.

@groeneai

groeneai commented Aug 5, 2026

Copy link
Copy Markdown
Contributor

Following up on item 2 (STID 4148-4291, zero client version) with the fixing PR link I promised: #113220 (mine, open).

It was already open when I wrote the analysis above and I missed it, because it was raised from the access-control angle and its title and body never mention the client version, so no keyword from this signature finds it. It changes the same line the stack ends at: registerStorageDistributed infers an omitted structure under args.getLocalContext() and passes it to the constructor, so the constructor's columns_.empty() branch at StorageDistributed.cpp:466 is never entered and the global context is never used for the send. That is the same shape registerStorageRemote has used all along (StorageDistributed.cpp:2612), whose comment already documents the underlying problem.

I verified it covers this signature rather than assuming it: applying its src/ change to a pristine master build turns the abort into a successful CREATE, on both the plain and the CREATE TEMPORARY TABLE shapes, and the same holds for a regression test I wrote separately for the version axis. The three call sites that can reach getStructureOfRemoteTable are the constructor, registerStorageRemote and TableFunctionRemote::getActualTableStructure; the latter two already infer under the query context, so the constructor was the only one left.

Nothing here needs a change to this PR.

Comment thread src/Interpreters/InterpreterExplainQuery.cpp
alexey-milovidov and others added 4 commits August 8, 2026 15:38
…le expressions are all genuine table functions

The throwaway resolution that reproduces the planner's access check for
the unresolved dump also resolves every table function in the query, and
resolving a remote one may connect to a remote server: `mysql(...)`
fetches the table structure, so `EXPLAIN QUERY TREE run_passes = 0` - a
form that deliberately dumps the unresolved tree so that no server is
contacted - produced connection attempts and error-log noise, failing
04657_mysql_tls_credentials_query_tree across all stateless CI jobs on
https://s3.amazonaws.com/clickhouse-test-reports/json.html?PR=110180&sha=e3743bceb849a90e7d8e1517c9a57d9121467a66&name_0=PR&name_1=Stateless%20tests%20%28amd_debug%2C%20parallel%29

The check can only ever guard a plain table or view (only a table
identifier resolves into a `TableNode`) or a parameterized view, which
is called like a table function but with a name no registered table
function has. Skip the throwaway resolution when the explained query
references neither, so a query reading only from genuine table
functions is dumped without resolving them - exactly as on master.

PR: #110180
…t view access check too

`checkNestedSelectsViewBaseTableAccess` swallowed only `DB::Exception`,
so a nested legacy subquery mentioning both an `INVOKER` view and a
table function that throws a non-ClickHouse exception during resolution
(`paimonAzure` / `icebergAzure` raise `std::runtime_error`) turned
`EXPLAIN SYNTAX` / `EXPLAIN AST optimize = 1` into a connection
exception, although master dumps the query and nothing from the nested
subquery beyond the user's own text is printed. Mirror the `catch (...)`
of `resolveThenCheckAccessRights`: such an exception is not an access
denial, and the nested `SELECT` is left unchecked exactly as an
unresolvable one is. Add a regression test over a nested view plus a
failing remote table function.

PR: #110180
…allowing

The style check requires a comment containing `Ok` in a `catch (...)` block
that intentionally swallows exceptions, as the sibling block in
`resolveThenCheckAccessRights` already has.

https://s3.amazonaws.com/clickhouse-test-reports/json.html?PR=110180&sha=afc4af6ba10f88d4b9efa884ce8df17f544196ba&name_0=PR&name_1=Style%20check
#110180

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The style check rejects test files whose name contains `fail`; rename
`04823_explain_syntax_nested_view_failing_table_function_legacy` to
`04823_explain_syntax_nested_view_throwing_table_function_legacy`.

https://s3.amazonaws.com/clickhouse-test-reports/json.html?PR=110180&sha=afc4af6ba10f88d4b9efa884ce8df17f544196ba&name_0=PR&name_1=Style%20check
#110180

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
ASTPtr nested_select_copy = nested_select_node->clone();
try
{
InterpreterSelectQuery interpreter(

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.

checkNestedSelectsViewBaseTableAccess now re-runs a full InterpreterSelectQuery analysis for every nested SELECT that mentions a regular view. That analysis resolves every other table expression in the subquery too, so a shape like ... IN (SELECT v.a FROM v JOIN paimonAzure(...) AS p ON ...) still opens the remote table-function connection and waits on its timeout even though the final dump leaves that nested subquery unexpanded. The new catch (...) only hides the exception; it does not restore the previous "dump without side effects" behavior.

Can we fail-close here the same way the top-level helpers do and skip this nested access pass when checking it would require resolving unrelated table functions, or otherwise derive the view-specific access inputs without analyzing the whole nested subquery?

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.

You are right about the side effect, and I agree it is still there: the analysis resolves the other table expressions, so the connection and its timeout are paid even though the dump leaves the nested subquery unexpanded. That is the same thing I measured in the earlier thread (0.14s on master vs 15.1s on this branch against a blackholed address, identical before and after the catch). The catch fixes the error, not the side effect.

But I measured the proposed remedy, and skipping the nested pass when checking would require resolving unrelated table functions reopens the leak this PR closes.

Debug builds, build IDs asserted against SELECT lower(buildId()): pristine master f9801d56 (c29b56ab..., 0 occurrences of checkNestedSelectsViewBaseTableAccess) and this branch's nested block (cf11c5b1...). Fixture: base(a, secret) which the reader cannot select, v = SELECT a FROM base, plus an outer table pub the reader can select, so any denial is attributable to the nested pass.

SET enable_analyzer = 0;
EXPLAIN SYNTAX SELECT x FROM pub
WHERE x IN (SELECT v.a FROM v AS v JOIN numbers(3) AS n ON v.a = n.number);
arm reader
pristine master dump produced (the leak)
this branch Code: 497 ... grant SELECT ON base
this branch, full user dump produced

The real SELECT for that shape is denied with the same grant and succeeds for the full user, so EXPLAIN SYNTAX is denied exactly where execution is denied. That nested subquery does require resolving an unrelated table expression (numbers(3)), so a "skip when resolution would be required" predicate skips it and hands the reader the dump again.

The asymmetry with the top level is what makes referencesCheckableTables sound there but not here. At the top level the predicate is a whole-query one and checkAccessRightsForQueryTree can only deny on a table identifier or a parameterized view, so an all-table-functions query has nothing to lose. The nested pass is entered only after referencesNonParameterizedView already returned true, i.e. only when the subquery does read a view whose base tables are the thing to check. A subquery mixing a view with a table function has both a checkable part and an unresolvable one, and one predicate cannot drop the resolution without dropping the denial.

Also, a predicate that has to resolve in order to learn whether resolution was needed cannot avoid the connection, so it does not recover the no-side-effect property either. A purely syntactic predicate ("mentions any table function") does avoid it, and that is the over-broad form measured above.

On what is lost today when the analysis does fail: with paimonAzure at an unroutable address the subquery is left unchecked and the reader gets the fallback dump, but grep for the base table name and for the hidden column is 0 there, with a positive control scoring 1 for a view in the main FROM. So the swallow costs a skipped check on an unresolvable subquery, never a disclosure, matching the reasoning already in the comment at the top of that function.

That leaves a design fork rather than a defect, and it is yours to pick: keep the residual side effect, or derive the view's access inputs without a full InterpreterSelectQuery analysis. The second removes the side effect properly but is a much bigger change than catch placement. I have not pushed anything; the measurements above are all I am adding here.

@clickhouse-gh

clickhouse-gh Bot commented Aug 9, 2026

Copy link
Copy Markdown
Contributor

Build profile diff (arm_release)

Comparing fcc47e859 with master fd2595cd4 (stripped binary size, per-symbol sizes and ThinLTO time; object sizes against the warmup build of d134c6da3; compile times per translation unit against the most recent warmup build that recompiled it).

✅ No significant changes.

Binary sizes
Binary Master PR Δ
programs/clickhouse-stripped 690.64 MiB 687.65 MiB -2.99 MiB (-0.43%)

Only the stripped binary is compared: the official master build keeps debug symbols while PR builds strip them, so the other binaries differ by construction.

Object file sizes

11 object files changed (+87.32 KiB total), 0 added.

Object file Master PR Δ
src/CMakeFiles/dbms.dir/Interpreters/InterpreterExplainQuery.cpp.o 508.32 KiB 596.36 KiB +88.04 KiB (+17.32%)

737 more object files are built by the master warmup baseline only (it builds every object-file target, a pull request build only clickhouse-bundle) and not compared.

Compile time of recompiled translation units

585 translation units recompiled, 1041 s compile time in total, 70 of them have a recent master baseline.

Translation units without a recent master baseline:

  • contrib/ai-sdk-cpp-cmake/__/ai-sdk-cpp/src/providers/anthropic/anthropic_stream.cpp: 7.5 s
  • contrib/ai-sdk-cpp-cmake/__/ai-sdk-cpp/src/http/http_request_handler.cpp: 7.0 s
  • contrib/rocksdb-cmake/__/rocksdb/db/db_impl/db_impl_open.cc: 6.0 s
  • contrib/wasmedge-cmake/__/wasmedge/lib/executor/engine/proxy.cpp: 5.4 s
  • contrib/rocksdb-cmake/__/rocksdb/utilities/transactions/lock/point/point_lock_manager.cc: 5.4 s
  • contrib/llvm-project-cmake/__/llvm-project/llvm/lib/Analysis/ValueTracking.cpp: 5.3 s
  • contrib/wasmedge-cmake/__/wasmedge/lib/executor/instantiate/function.cpp: 5.0 s

Job report

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

Labels

pr-bugfix Pull request with bugfix, not backported by default

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Potential Issue: EXPLAIN QUERY TREE statement does not check permissions on the table

2 participants