Make SQL SECURITY views an optimization barrier - #112847
Make SQL SECURITY views an optimization barrier#112847alexey-milovidov wants to merge 145 commits into
Conversation
A view with SQL SECURITY DEFINER or NONE runs its inner query as another
user, so the rows it filters out are rows the invoker has no right to see.
Today the outer WHERE and the view's own WHERE are merged into a single
filter over the source table, so an invoker-supplied expression can observe
the hidden rows through an exception, through timing, or through resource
consumption:
SELECT * FROM v WHERE throwIf(hidden_column = 'x', 'oracle');
Mark the steps of the view's subplan that decide which rows the view exposes
and refuse to merge into them, push a filter below them, or pull them into
PREWHERE together with a condition from outside.
Only views that can actually drop rows become barriers: a view that is a
plain projection over its source is left fully optimizable.
typeid_cast compares types exactly, so neither SourceStepWithFilter nor ISourceStep would ever match a concrete reading step, and every view ended up a barrier.
With enable_analyzer = 0 the view is substituted into the outer query as a subquery and TreeRewriter merges the outer predicate into the view's own WHERE, before any query plan exists — so the plan-level barrier cannot see it, and setting enable_analyzer = 0 bypassed the fix entirely. Read such a view through StorageView::read instead, which keeps the outer predicate in a step above it.
|
Workflow [PR], commit [4383b6b] Summary: ⏳
AI ReviewSummaryThis PR turns row-hiding Final VerdictStatus: ✅ Approve |
`check_gaps_in_tests_numbers` in `ci/jobs/check_style.py` fails when a new stateless test number is more than 100 above the previous one, and `05021_sql_security_view_barrier` left a gap of 357. Rename it to `04670_sql_security_view_barrier`.
LLVM Coverage Report
Changed lines: Changed C/C++ lines covered: 124/133 (93.23%) · Uncovered code |
`analyzer_inline_views` replaces an ordinary view with its defining subquery in the query tree, before a plan exists. For a view with `SQL SECURITY DEFINER` or `SQL SECURITY NONE` that reopens the leak this pull request closes: `StorageView::readImpl` never runs, so no step is marked with `IQueryPlanStep::isSecurityBarrier`, and the analyzer merges the invoker's `WHERE` into the view's own `WHERE` exactly as before. `QueryAnalyzer::inlineViewSubqueryIfNeeded` now skips such a view, mirroring what `InterpreterSelectQuery` already does for the old analyzer.
`IQueryPlanStep::security_barrier` lived only in memory, so a distributed worker deserialized a plan fragment in which every barrier had disappeared and then optimized it again in `DistributedPlanExecutor`. The flag is now written per step and restored on the worker. An older peer cannot be told about the flag, and silently optimizing across a barrier is the very disclosure the flag prevents, so `QueryPlan::serialize` fails closed on a plan that carries one: query plan serialization version 5 adds the flag, and a plan with a barrier is refused below that version.
The test now also runs the oracle with `analyzer_inline_views = 1` and through a shard with `serialize_query_plan = 1`. The two negative controls used to assert that a non-barrier view still gets the outer predicate into `PREWHERE`. Whether a given condition ends up in `PREWHERE` depends on settings the test runner randomizes (`query_plan_optimize_prewhere`, `enable_multiple_prewhere_read_steps`, ...), which made them fail in seven CI configurations. They now assert the property the barrier is actually about - whether the outer predicate is merged into the view's own filter - counting `Filter` steps with every relevant setting pinned, and the surviving `PREWHERE` check pins its settings too. Verified against a local server: with `sql_security_views_are_optimization_barriers = 0` every one of these lines changes, so none of them passes vacuously.
|
🕵 @groeneai, investigate the failure: It is unrelated to this pull request: the same test fails in the same configuration on other pull requests (#110180 on 2026-07-31 and 2026-08-02, #106231 on 2026-07-15), and it passes locally on this branch. This pull request only changes view inlining for |
|
Confirmed unrelated to this pull request. The discriminator is not randomization. Two events in that check flavour, both
In both, the diverging lines are a subset of exactly the four cases Why that rendering is fragile. For those four cases the inlined form is produced by What makes it not land in that specific flavour I have not isolated yet: the test passes for me 3/3 with I am preparing the fix as a separate PR and will link it here. For completeness, #112211 is not the fix: that one corrected parameterized-view classification for non-flat |
|
Correction to my previous comment: I was wrong. This is not unrelated to this pull request, and the The failure is caused by this PR's own diff, so I am not opening a separate PR. The fix belongs here, and which fix is right is your call. The premise I accepted, that a parameterized view defaults to CREATE VIEW 04105_pv_security_none SQL SECURITY NONE AS ...
CREATE VIEW 04105_pv_security_definer DEFINER = CURRENT_USER SQL SECURITY DEFINER AS ...So the only two lines the change selects are the only two that move. The other ten are byte-identical. At the failing sha const bool inline_view = view && !StorageView::isSecurityBarrier(*metadata_snapshot, context);
if (inline_view)
StorageView::replaceWithSubquery(getSelectQuery(), view_table, metadata_snapshot, view->isParameterizedView());
...
if (inline_view) // :868
query_info.view_query = StorageView::restoreViewName(getSelectQuery(), view_table);On master both are unconditional inside The rows you cited are three different signatures: the 2026-07-31 Two remedies, and I do not know which you intend. If the output change is unintended, narrow the gate so the barrier does not suppress inlining for diagnostics: I am happy to open the PR for whichever you pick. |
Algunenano
left a comment
There was a problem hiding this comment.
Failures look related. Also, I wonder which other cases we should cover with a security barrier beyond rows being filtered
`EXPLAIN SYNTAX` builds `InterpreterSelectQuery` with `only_analyze`, and suppressing the `StorageView::replaceWithSubquery` / `restoreViewName` pair there changed what it prints for a `SQL SECURITY DEFINER` / `NONE` view: no `subquery` is installed, so the formatter falls back to the `table_function` branch and renders `pv(equals(n, 1))` instead of the inlined form. That is what made `04105_explain_syntax_parameterized_view` fail with the old analyzer. `only_analyze` needs no barrier: the plan it builds reads from `ReadNothingStep`, so no expression of the outer query is ever evaluated on a row of the source table. Gate the suppression on it, and the diagnostic output is what it has always been.
…ation Marking the plan steps keeps an invoker-supplied expression from running below the view, but two analysis paths still walked straight past the barrier and handed the outer predicate to the source: - `optimizePrimaryKeyConditionAndLimit` walks up from the reading step and calls `addFilter` for every `FilterStep` it meets, so the outer predicate reached the source's key condition; - `StorageView::readImpl` forwarded `query_info.filter_actions_dag` into the view's inner analyzer, where `Planner::collectFiltersForAnalysis` injects it into the inner plan and the filters it collects reach the inner tables' index analysis. Either one skips parts and granules by the values of the rows the view hides, and the `read_rows` of the query then tells the invoker whether such a row exists — a one-bit oracle per query that needs no exception to read out, which is exactly what the setting's documentation claims to close. Measured on a `DEFINER` view that exposes no row, over 100000 rows sorted by `key`, reading `WHERE key = <hidden row>` versus `WHERE key = <absent>`: 576 rows versus 0 before this commit, and 1000000 versus 1000000 after it, on both the analyzer and the old analyzer. `04758_sql_security_view_barrier_read_rows` pins that, and prints `DISCLOSED` on both paths with `sql_security_views_are_optimization_barriers = 0`, so it does not pass vacuously.
|
🕵 @groeneai, the two remaining reds at
Neither stack touches views, plan optimization, or SQL security. The branch has been merged with |
`ci/jobs/scripts/check_style/various_checks.sh` requires every stateless test that reads `system.query_log` to constrain it with `current_database = currentDatabase()`, and a red `Style check` drops every other job in the workflow. Also assert that both probes were actually found, so the comparison cannot pass on an empty match.
|
Both confirmed from the report artifacts at
#111997 is done on my side and is blocked on Caveat on my own fix: it corrects the gate that adds Timing, offered as timing and not as a bisection: 1 to 3 hits a day and no master hits through 08-02, #112501 is yours and is
|
… randomizer The test asserted an exact number of `Apply runtime join filter` steps, and both halves of that assertion were unstable under randomized settings: - the positive control (the `SQL SECURITY INVOKER` twin, which must still get its filter) dropped to zero when the join order pass swapped the join sides back, so the view no longer ended up on the probe side where the filter is planted; - the number of steps is not one either - with `query_plan_optimize_prewhere = 0` the same filter is applied by two steps instead of one. Pin the join order pass off (`query_plan_optimize_join_order_limit`, `query_plan_optimize_join_order_randomize`, `use_hash_table_stats_for_join_reordering`) and turn the oracle into a yes/no. The property under test is unchanged: the barrier view gets no runtime filter, the `INVOKER` twin does. Report: https://s3.amazonaws.com/clickhouse-test-reports/praktika.html?PR=112847&sha=909f590dba70c160a99d0670263dee807788fb8a&name_0=PR&name_1=Stateless%20tests%20%28amd_debug%2C%20flaky%20check%29 PR: #112847 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…CURITY` seal `where_const_view` is a `SQL SECURITY DEFINER` view with a `WHERE`, so it hides rows and becomes an optimization barrier: the `Convert VIEW subquery result to VIEW table structure` step that seals it is no longer merged into the steps above it, and the plan of that one `EXPLAIN` is printed as two `Expression` steps rather than one. Only that block of the reference changes. Everything the test is about is unchanged - the encryption key is still `[HIDDEN]` in every masked plan, no plaintext or key text appears anywhere in the output, and all 14 `ACCESS_DENIED` refusals on the legacy analyzer still fire. Report: https://s3.amazonaws.com/clickhouse-test-reports/praktika.html?PR=112847&sha=909f590dba70c160a99d0670263dee807788fb8a&name_0=PR&name_1=Stateless%20tests%20%28amd_debug%2C%20parallel%29 PR: #112847 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
🕵 Both stateless reds at 1. Nothing the test is about changes: the encryption key is still 2.
So the join order pass is now pinned off ( Verified locally against a server built from this branch: replaying the failing job's exact randomized client settings, the test passes 3/3 (it produced The 0 unresolved review threads; the AI review verdict on |
…at operator `StorageView::effectiveContextCanHideRows` also rejected a definer profile carrying `max_rows_to_group_by` / `group_by_overflow_mode`, `max_rows_to_sort` / `sort_overflow_mode` or `max_rows_in_distinct` / `distinct_overflow_mode`, and it is used as an AST-independent short-circuit of `canHideRows`. Those limits truncate only a query that actually aggregates, sorts or deduplicates, so a projection-only `SQL SECURITY DEFINER` view whose definer profile happened to carry, say, `group_by_overflow_mode = 'any'` became an optimization barrier and lost inlining, `PREWHERE` forwarding, shard skipping and the `ORDER BY ... LIMIT` pushdown for no security benefit. They move to a new `StorageView::shapeDependentOverflowCanHideRows`, applied by `canHideRows` once the shape of the `SELECT` is known, and by `pushOrderByIntoView` with `has_sort = true` because that optimization injects a sort into the view's inner query itself. Test `05184_sql_security_view_barrier_shape_overflow_settings`: with such a profile a projection-only view plans exactly like its `SQL SECURITY INVOKER` twin on all three analyzer paths, while the same sort limit over a view that does have an inner `ORDER BY` still fails closed. Non-vacuity proven with a control binary that ignores the shape flags: all nine `same` lines flip to `different`. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Conflicts: master took query-plan serialization version 17 for the `always_read_till_end` flag of `LimitByStep`, so the security-barrier flag moves to version 18 (`DBMS_QUERY_PLAN_SERIALIZATION_VERSION` and `DBMS_MIN_QUERY_PLAN_SERIALIZATION_VERSION_WITH_SECURITY_BARRIER`), and the NativeProtocol spec is updated accordingly. In `optimizeTree.cpp` master removed the `validateDistributedPlanBucketCounts` forward declaration; the branch's `planHasSecurityBarrier` helper is kept. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…ead of failing closed on any entry `StorageView::effectiveContextCanHideRows` treated every non-empty `additional_table_filters` of the view's effective security context as row-hiding, so a definer profile that filters some unrelated table turned every projection-only `SQL SECURITY DEFINER` view of that user into an optimization barrier: it lost inlining, `PREWHERE` / outer-filter forwarding and the `ORDER BY ... LIMIT` pushdown although the filter never applied to it. The entries of the setting are keyed by table, so `canHideRows` now matches them against the source table once it has resolved it, with the rule the interpreters use (`parseAdditionalFilterAstIfNeeded`, `parseAdditionalFilterConditionForTable`): the alias of the table expression, the bare table name under the current database, or the qualified name; the storage a proxy or `Alias` table forwards the read to is matched as well. The shared helper `StorageView::additionalTableFiltersApplyTo` also serves the view-keyed check `hasAdditionalTableFilter` and the view's own `SETTINGS additional_table_filters` clause, which `settingsClauseCanHideRows` now hands back to the caller through a predicate instead of rejecting by name; without a resolved source (no `FROM`, a `FROM` subquery, the `ORDER BY ... LIMIT` pushdown) the setting still fails closed. A malformed value counts as applying. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…table keeps a `DEFINER` view transparent A definer profile whose `additional_table_filters` names a table the view never reads (by qualified or by bare name), or the same entry in the view's own `SETTINGS` clause, leaves a projection-only `SQL SECURITY DEFINER` view planning exactly like its `SQL SECURITY INVOKER` twin on the legacy analyzer, the analyzer and `analyzer_inline_views = 1`, while an entry naming the view's own source table - by qualified name, by bare name, by alias, or in the view's own `SETTINGS` clause - still fails closed and still filters. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
`tryPushBucketTopKIntoAggregation` (`query_plan_aggregation_bucket_top_k`, default on) walked `Limit` -> `Sorting` -> `Expression`* -> `Aggregating` without checking `isSecurityBarrier`, so the invoker's `ORDER BY count() LIMIT n` over a `SQL SECURITY DEFINER` / `NONE` view could still call `AggregatingStep::enableBucketTopK` on the view's own aggregation, retuning the processing inside the view by the invoker's query. Fail closed on any marked step of the chain, like `tryOptimizeGroupByTopK`. Test `05211_sql_security_view_barrier_aggregation_bucket_top_k`: the `INVOKER` twin carries `Bucket top-K`, the `DEFINER` and `NONE` views do not (verified failing on the pre-fix binary). Addresses the AI review Major on #112847. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…arrier Master took query-plan serialization version 18 for the `Filling` step, so the security-barrier flag moves to version 19 (`DBMS_QUERY_PLAN_SERIALIZATION_VERSION = 19`, `DBMS_MIN_QUERY_PLAN_SERIALIZATION_VERSION_WITH_SECURITY_BARRIER = 19`); the `NativeProtocol` spec is updated accordingly.
…use by the query shape `StorageView::settingsClauseCanHideRows` rejected `max_rows_to_group_by` / `group_by_overflow_mode`, `max_rows_to_sort` / `max_bytes_to_sort` / `sort_overflow_mode` and `max_rows_in_distinct` / `max_bytes_in_distinct` / `distinct_overflow_mode` unconditionally, because any name outside the execution-only allowlist counted as row-hiding. A projection-only `SQL SECURITY DEFINER` view whose own query carries, say, `SETTINGS max_rows_to_group_by = 1, group_by_overflow_mode = 'any'` has no `GROUP BY`, so those settings cannot drop a row, yet `canHideRows` returned `true` and the view lost inlining, `PREWHERE` forwarding and the `ORDER BY ... LIMIT` pushdown. The profile side already made this split in `shapeDependentOverflowCanHideRows`; the clause side now takes the same `has_sort` / `has_grouping` / `has_distinct` flags and accepts such a setting only when the query provably lacks the operator (a `FROM` subquery fails closed on all of them, since its shape is not inspected at that level; the pushdown passes `has_sort = true` because it injects a sort itself). Test `05212_sql_security_view_barrier_shape_overflow_settings_clause` pins that the three clause forms leave a projection-only `DEFINER` view planning byte-identically to its `INVOKER` twin on all three analyzer paths, while the same sort limit over a view with an inner `ORDER BY` and a read limit under `read_overflow_mode = 'break'` still fail closed. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
|
🕵 The only red on |
…allel-replicas shortcut `StorageView::getUnderlyingMergeTreeStorageForParallelReplicas` declined the `parallel_replicas_allow_view_over_mergetree` shortcut for a security barrier view on any non-empty `additional_table_filters` of the caller, because the alias the outer query gives the view was not known there. An entry keyed to an unrelated table cannot make a replica read the view through `StorageView::readImpl` (the duplication 05105 guards against needs the replica to decline inlining, which it does only for an entry that applies to the view), yet a projection-only `SQL SECURITY DEFINER` / `NONE` view lost the shortcut for it: with `serialize_query_plan = 1` the outer aggregation stayed on the initiator over rows the view's own inner query fetched with parallel replicas, while the `SQL SECURITY INVOKER` twin shipped the aggregation to the replicas. The callers now hand the original alias of the table expression over (`canUseTableForParallelReplicas`, `parallelReplicasEnabledForStorage`, the nested-view recursion, and `readImpl` through `SelectQueryInfo::table_expression`), and the gate declines exactly when `QueryAnalyzer::inlineViewSubqueryIfNeeded` would decline to inline: when an entry applies to the view by name or by that alias (`hasAdditionalTableFilter`). An entry keyed to an internal `__table<N>` alias also counts, because the query text a replica receives names the view that way. A caller that does not know the alias still fails closed on any entry. Test `05213_sql_security_view_barrier_unrelated_filter_view_over_mergetree`: with an unrelated qualified or bare-name entry the `DEFINER` / `NONE` views merge the aggregation on the initiator like the `INVOKER` twin (`MergingAggregated`, verified `0` before the fix and `1` after), the view-name and alias entries still decline the shortcut, and every row comes back once. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…s through `StorageView::canHideRows` resolved an `Alias` table to the storage that serves the read and then checked the row policy of that storage only. A read through an `Alias` combines the policies of the `Alias` and of its target (`getEffectiveRowPolicyFilter`, `InterpreterSelectQuery`), so a `SQL SECURITY DEFINER` view over an `Alias` with a policy defined on the `Alias` alone was classified as projection-only: the invoker's predicate reached the index analysis of the source read and `read_rows` disclosed whether a hidden row matched. Now every table of the proxy / `Alias` chain is checked. Test `05214_sql_security_view_barrier_alias_row_policy` (`DISCLOSED` on both planners before the fix). Also lifts the exact `__table<digits>` matcher comment in `StorageView.h`. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…ble<digits>` `additionalTableFiltersApplyToInternalAlias` treated any key starting with `__table` as the alias the analyzer gives a table expression in the shipped query text, so an unrelated entry keyed to a user table like `__table_prod` made a projection-only `SQL SECURITY` view decline the `parallel_replicas_allow_view_over_mergetree` shortcut under `serialize_query_plan = 1`. The matcher now requires a non-empty digit suffix, the same rule as `isPlannerGeneratedTableAlias` in `QueryResultCache.cpp`. Test `05215_sql_security_view_barrier_user_table_prefixed_filter`. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
… rewrites `tryLowerArrayJoinFunction` rebuilt a barrier `FilterStep` / `ExpressionStep` into three fresh unmarked steps, and `tryFuseFilterIntoArrayJoin` then replaced the rebuilt barrier filter with an unmarked pass-through `ExpressionStep`. With `query_plan_lower_array_join_function = 1` a view-keyed `additional_table_filters` entry such as `arrayJoin(tags) = 'public'` on a `SQL SECURITY DEFINER` / `NONE` view lost its barrier, the invoker's `WHERE` merged into the rebuilt filter and was pushed below the `ArrayJoinStep`, and `throwIf` observed the hidden rows on both planners. Both rewrites now propagate the flag to every step they create: the three pieces of the lowered filter, and the `ArrayJoinStep` carrying the fused element filter plus its pass-through replacement. The rewrites still fire for barrier views; only merging into or crossing them is refused. Test `05216_sql_security_view_barrier_lowered_array_join_filter` covers both rewrites on both planners with an `INVOKER` positive control. A row policy cannot carry `arrayJoin` (rejected at `CREATE ROW POLICY`), so the test uses the `additional_table_filters` vector. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…a barrier filter `tryPushDownFilter` refused to push an outer filter below a barrier child, but when the `FilterStep` being pushed was itself the barrier (the `WHERE` of a `SQL SECURITY DEFINER` / `NONE` view), every step it was rebuilt into came out unmarked: the pushed `FilterStep` and the replacement `ExpressionStep` of `addNewFilterStepOrThrow` and of the join pushdown, the copies made for the branches of a `UnionStep`, and the plain `FilterStep` that `ReadFromMerge::addFilter` and `ReadFromLocalParallelReplicaStep::addFilter` inject into their separately optimized child plans. For a view over a `Merge` table the optimized plan ended up with no barrier step at all. Propagate the flag through every one of these rewrites, and mark the `ReadFromMerge` / `ReadFromLocalParallelReplicaStep` step itself once a barrier filter has been sunk into it, since it now hides the rows that filter drops. `ReadFromMerge` remembers the flag next to each pushed filter so that child plans created later get marked steps as well. Test 05218 covers a `DEFINER` / `NONE` view over a `Merge` table on both planners: the invoker's predicate stays out of the child plan's `PREWHERE` (the `INVOKER` twin shows it there), `read_rows` does not depend on whether a hidden row matches, and an `additional_result_filter` of the invoker never sees a hidden row. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…ad paths do `StorageView::canHideRows` resolved a proxy or `Alias` source table down to the storage that serves the read and then treated an `additional_table_filters` entry keyed by that target as a filter of the view. Neither filter-application path does that: `parseAdditionalFilterConditionForTable` and `parseAdditionalFilterAstIfNeeded` match the table expression of the query (its alias, its name, the storage id it resolves to, which is the `Alias` itself), and `StorageAlias::read` and `StorageProxy::read` forward the already parsed filter without matching it again. So an entry keyed by the target never filtered anything, yet it turned a projection-only `SQL SECURITY DEFINER` / `NONE` view over the `Alias` into a barrier. Match the names the read paths match: the name the view's query uses and the storage id it resolves to. The row policies of every table of the chain are still honoured, as before. Test 05217: projection-only views over an `Alias` table whose `SETTINGS` clause keys an `additional_table_filters` entry either to the target of the `Alias` (filters nothing, the view keeps the plan of its `INVOKER` twin) or to the `Alias` itself (filters the read, the view is sealed and the plans differ), on both planners. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Changelog category (leave one):
Changelog entry (a user-readable short description of the changes that goes into CHANGELOG.md):
A view with
SQL SECURITY DEFINERorSQL SECURITY NONEis now an optimization barrier, so an expression in the query reading the view is never evaluated on rows that the view itself filters out. Previously such an expression could observe the hidden rows through an exception, and a view used to restrict which rows a user may see did not actually restrict them.The problem
SQL SECURITY DEFINERis widely used to build a view that restricts which rows a user may see:alicehas no grant on the source table, only on the view. But the outerWHEREand the view's ownWHEREare merged into a single filter over the source table, and nothing guarantees which of the two decides first. Any function that can signal through a side channel therefore observes the rows the view is supposed to hide:That is a one-bit oracle per query. Exception messages carry the offending value, which turns it into a plain read of the hidden rows:
Both work on
SQL SECURITY NONEas well, and both work withenable_analyzer = 0.Row policies on the source tables are not affected: such a row policy is a separate
row_level_filterthat the reading step always applies before PREWHERE and before any pushed-down filter. This PR gives a view's own filtering the same standing. A row policy on the view itself is applied by both planners as aFilterstep above the view's subplan (StorageViewtakes noPREWHERE, so it is never pushed into the read), and that step is sealed as well: it decides which rows the invoker sees, so the invoker's own predicate must not merge into it.The fix
IQueryPlanStepgets asecurity_barrierflag. AfterStorageView::readImplbuilds the view's subplan, every step that can drop rows is marked, and optimizations that move a step down the plan refuse to cross a marked step:tryMergeExpressions,tryMergeFilters,tryPushDownFilterandtryMergeFilterIntoJoinConditionrefuse when the child is a barrier;tryPushDownVolumeReducingFunction(default on) refuses when the parent or the child is a barrier: it splices the invoker'slength/lengthUTF8/empty/notEmptybelow aFilterorSortingstep, where the function would scan the payload of the rows the step drops before the barrier;optimizePrewhererefuses to pull an outer filter into a barrier source — conditions are combined into the prewhere DAG withand, which gives no ordering guarantee — and transfers the barrier onto the source when it absorbs the view's own filter;trySplitFiltermoves the flag onto the new lowerFilterStep, which is the one that still drops rows.tryPushDownLimitrefuses to move the invoker'sLimitStepbelow a barrier step: once across the seal it would seedDistinctStep::limit_hintor a sorting limit inside the view's subplan, andoptimizeLimitForAggregationInOrderwalked through the seal to seedAggregatingStep::limit_hintthe same way — hints that stop reading the source once enough visible rows are produced, soread_rows, progress and timing depended on the rows the view drops or collapses. Both walks, andpushLimitByIntoSort, fail closed on a barrier step.Blocking the steps that evaluate expressions is not enough on its own, because index analysis reaches the source by a different route and skips granules by the values of the rows the view hides —
read_rowsthen tells the invoker whether such a row exists, with no exception needed. Ten walks are fenced as well:optimizePrimaryKeyConditionAndLimitwalks up from the reading step and hands everyFilterStepit meets to the source's key condition; it now stops once it has consumed a barrier. The barrier's own condition still reaches the source — it is the definer's, and it is what decides which rows exist for the view — but nothing above it does.StorageView::readImplforwardedquery_info.filter_actions_daginto the view's inner analyzer, wherePlanner::collectFiltersForAnalysisinjects it into the inner plan and the filters it collects reach the inner tables' index analysis. It is no longer forwarded for a barrier view, which costs such a view overDistributedits shard skipping on the outer predicate.buildSortingDAGin the read-in-order analysis descended through the view subplan and pulled outerFilterSteppredicates into the fixed columns and the merged DAG, so an outerORDER BY/GROUP BY/DISTINCT/LIMIT BYcould still shape how the source below the barrier reads. It now reports a barrier in the chain and every consumer (sorting, aggregation,DISTINCT,LIMIT BY, the normal-projection choice, top-K, and theMergechild-plan walk) skips the analysis for that chain. Unlike the primary-key walk, the barrier step cannot be consumed here: the sort description belongs to the top of the chain, and a DAG missing the steps above the barrier could resolve a renamed column by name to the wrong source column and change the result order — so the analysis is skipped entirely, which is fail-closed and keeps correctness because the sorting step stays in the plan.tryOptimizeTopKrewrites anORDER BY ... LIMITinto a dynamic__topKFilterPREWHERE and minmax-skip-index granule pruning on the source, walkingLimitStep→SortingStep→ExpressionStep→FilterStep→ReadFromMergeTree— and both rewrites are on by default. The walk now fails closed on the first barrier step it meets, including a reading step that carries the barrier afteroptimizePrewhereabsorbed the view's own filter.tryTopKThroughJoinpeels the expression chain between the invoker'sSortingand aJoinand grafts the invoker'sSort + Limitonto the join's preserved input, re-running the optimization passes on that subtree. For a barrier view whose inner query is a join, the graft landed below the seal — verified live pre-fix on both analyzers. The pass now fails closed on a markedLimit/Sorting(the pattern lies inside a view), a marked peeled expression (the seal), and a marked join (a join of a barrier view is always marked, not being row-preserving). Grafting above the seal of the invoker's own join input stays allowed: the insertedSortconsumes its whole input and the re-run passes are individually fenced.registerLeftSideIndexAnalysisSecondPassof the join runtime filters walked from the__applyFilterstep (which the fenced filter pushdown correctly keeps above the seal) down through every single-child expression or filter step — sealed or not — to theReadFromMergeTreeinside the view, and registered the invoker's build-side keys for granule pruning there. The walk now fails closed on the first barrier step. A live disclosure could not be constructed — the descriptor's key name has to match the read's namespace and the consumption path declined in every configuration tried — so this fence makes the contract structural rather than incidental. The filter itself is now refused as well:tryAddJoinRuntimeFilterfails closed when either side's subtree carries a barrier. Besides the disclosure (the keys of the rows a view hides leaving the seal, and the view'sread_rowsdepending on the invoker's expressions), that is a wrong-result hazard — the seal keeps the view's read local while the other side is read by parallel replicas, so the filter is built from the coordinated side's own share of the ranges only and drops every row of the sealed side whose key went to another replica.ORDER BY. Both now fail closed on a barrier step the same way.QueryDAG::buildinprojectionsCommon.cppcollects every filter of the chain below the aggregation — the invoker's predicates together with the view's own filtering — and bothoptimizeUseAggregateProjections(includingminmax_count_projection) andoptimizeUseNormalProjectionsprune parts and marks with it, so a projection-enabled table under a filtering barrier view still let the invoker's predicate shape the read. The DAG build now fails closed on the first barrier step, which makes the projection optimizations decline the read entirely.addChildQueryGraphinoptimizeJoin.cpppeeled the sealing step — as a trivial pass-through step, or by merging it into the child join, whichquery_plan_merge_expression_into_joinenables by default — and then flattened the join inside the view into the join graph of the invoker, where the relations are reordered by their estimated sizes. A sealed subplan is now one opaque relation of the graph: the seal is neither peeled nor merged, a barrier child join is neither flattened nor pre-optimized for statistics, and the two walks that mirror this one (the overlapping-column-name pre-check and the exposed-column check) are fenced identically so that they keep agreeing with it.optimizeJoinByShards(settingquery_plan_join_shard_by_pk_ranges) merged the expression and filter DAGs of the chain through the seal and could then make the reading inside the view emit one output port per primary-key range of the invoker's join keys. The walk now produces no result for a barrier step, so nothing propagates above the seal; the sharding of the joins the view itself contains, which the invoker does not control, is still applied.One more family reaches the source without going through its index analysis at all.
optimizeDistinctPerPartition,optimizeLimitByPerPartitionandoptimizeAggregationPerPartitionwalk down through the sealing step and ask the reading to output each partition through a separate port, andapplyStreamDisjointnesscarries the resulting partition disjointness back up across the seal, so the invoker'sDISTINCT/LIMIT BY/GROUP BYskips its stream merging as well. Read scheduling, progress and resource consumption below the view then depend on how the rows the view drops are spread over the partitions, and all threeallow_*_partitions_independentlysettings default to1. Both directions now fail closed on a barrier step.Five passes endanger the barrier by rebuilding steps rather than by walking past them, and each now fails closed on a barrier step:
Expression/Filterstep of the chain into a main and a lazy half, and the rebuilt steps do not carry the barrier flag, so the post-lazytryMergeExpressions/tryMergeFilterspasses saw an unmarked chain and could merge an invoker predicate into the view's own filtering — reopening the exception oracle itself, not just the read-shaping one;tryLiftUpUnionrebuilds theUnionStepand clones the parent step into the branches as fresh unmarked steps, so a barrier view overUNION ALLlost its seal andtryPushDownFiltercould then duplicate an invoker predicate into the branches;tryExecuteFunctionsAfterSortingreplaces the expression under aSortingStepwith two new unmarked steps, which would strip the seal of a wrapper view and let the read-in-order and top-K walks descend through it again.tryOptimizeGroupByTopK, default on) walksLimit→Sorting→Expression→Aggregatingwith the seal matched as the expression, turns the view's own aggregation into a bounded heap sized by the invoker'sLIMIT, and for a bareLIMITinserts a synthesized, unmarked sorting step below the seal — live on both analyzers.tryPushBucketTopKIntoAggregation,query_plan_aggregation_bucket_top_k, default on) walksLimit→Sorting→Expression* →Aggregatingand turns onAggregatingStep::enableBucketTopKfor the view's own aggregation, sized and directed by the invoker'sORDER BY count() LIMIT— live on the analyzer.tryLiftUpArrayJoinsplits the expression or filter above anArrayJoinStep, moves one half below theARRAY JOIN, and rebuilds both halves as fresh unmarked steps. When the parent is the seal of a view whose plan containsARRAY JOIN(the seal is non-trivial whenever the view declares explicit column names or types), the invoker's predicate descended below theArrayJoinStepand was evaluated on rows hidden by empty arrays — a live disclosure through the exception oracle, on both analyzers.tryLowerArrayJoinFunction(opt-in throughquery_plan_lower_array_join_function) rebuilds a filter or expression containingarrayJoinintoExpression→ArrayJoin→Filteras fresh unmarked steps, and the default-ontryFuseFilterIntoArrayJointhen replaces the filter with an unmarked pass-throughExpressionStep. A view-keyedadditional_table_filtersentry such asarrayJoin(tags) = 'public'lost its barrier this way, the invoker'sWHEREmerged into the rebuilt filter and descended below theArrayJoinStep, andthrowIffired on the hidden rows on both planners. Both rewrites now put the flag on every step they create — the pieces of the lowered filter and theArrayJoinStepthat carries the fused element filter — so the rewrite still happens and nothing from above can merge into or cross it.tryPushDownFilterrefused to push an outer filter below a barrier child, but when theFilterStepbeing pushed was itself the barrier — theWHEREof the view — every step it was rebuilt into came out unmarked: the pushedFilterStepand the replacementExpressionStepofaddNewFilterStepOrThrowand of the join pushdown, the copies made for the branches of aUnionStep, and the plainFilterStepthatReadFromMerge::addFilterandReadFromLocalParallelReplicaStep::addFilterinject into their separately optimized child plans. For a view over aMergetable the optimized plan ended up with no barrier step at all. The flag now travels through every one of these rewrites, and aReadFromMerge/ReadFromLocalParallelReplicaStepstep that has swallowed a barrier filter becomes a barrier itself. Contract hardening rather than a measured leak: in every shape measured the seal or a neighbouring fence still blocked the invoker's predicate.Measured on a
DEFINERview that exposes no row, over 100000 rows sorted bykey, readingWHERE key = <a key only a hidden row has>againstWHERE key = <a key nothing has>: 576 rows read against 0 without this, and 1000000 against 1000000 with it, on bothenable_analyzer = 1andenable_analyzer = 0.EXPLAIN SYNTAXis left alone. It buildsInterpreterSelectQuerywithonly_analyze, whose plan reads fromReadNothingStep, so no expression of the outer query is ever evaluated on a row and the view is still inlined for the diagnostic.Two paths substitute the view into the outer query before a plan exists, so a plan-level barrier cannot see them, and both are closed the same way — such a view is not inlined and is read through
StorageView::read, which keeps the outer predicate in a step above it:enable_analyzer = 0,InterpreterSelectQueryreplaces the view with a subquery andTreeRewritermerges the predicates;analyzer_inline_views = 1,QueryAnalyzer::inlineViewSubqueryIfNeededdoes the same in the query tree.Without this,
SET enable_analyzer = 0orSET analyzer_inline_views = 1would bypass the fix entirely.The flag also travels with a serialized query plan. A distributed worker deserializes a fragment and optimizes it again, so a barrier it does not know about is a barrier it will optimize across.
QueryPlan::serializewrites the flag per step and fails closed when the negotiated query plan serialization version predates it (the version is bumped to 15), rather than sending a plan that silently loses its protection. The Cascades optimizer (make_distributed_plan = 1together withenable_cascades_optimizer = 1) rebuilds the plan from its own memo, and its rules neither see nor preserve the flag, so it stays off for a plan that carries a barrier; the rule-based distributed passes, which are fenced step by step, run instead.Task-based parallel replicas bypass the plan entirely: they ship the view's inner query as SQL text to the other replicas, where it is re-planned under the connection's own identity — the replica applies the row policies of its connecting user and of the initial user (the invoker), but the definer is neither of those, so the definer's row policies on the inner tables and the definer profile's
additional_table_filterswere silently dropped and the rows they hide came back through the union into the invoker's plan, above the barrier (deterministic withparallel_replicas_local_plan = 0, a scheduling race otherwise — which is how the ParallelReplicas CI configuration caught it).StorageView::readImplnow reads the inner query of a row-hiding barrier view without parallel replicas, failing closed. This costs cross-node parallelism for such views, which is why04545_parameterized_view_sql_securitynow asserts the fence for its filteringDEFINERview and keeps its "parallel replicas are really used" guard on a new parameterizedDEFINERview that provably hides no rows.Plan-based parallel replicas (
parallel_replicas_plan_based = 1) do not go through that fence: the planner builds a plain local plan, andapplyParallelReplicasthen plants aParallelReplicasSplitStepabove every eligibleMergeTreeread, lifts it through the expression and filter steps above, and ships the fragment to be deserialized and executed on the other replicas under the connection's identity. It found theReadFromMergeTreebelow a view's barrier just like any other read and lifted the split through the barrier steps, so the view's inner query left the initiator anyway. The pass now stops at a security barrier:collectReadsToDistributereturns nothing below a barrier step (the view's root is sealed, so its whole subplan stays local), theMergeexpansion skips barrier subtrees, the union and join lifters refuse a barrier node, and a split marker reaching a barrier parent is a logical error. The join lifter also refuses to lift a split when the broadcast side of the join contains a barrier: that side is not coordinated, so it carries no split marker of its own, but it is cloned into the fragment and executed in full by every replica, which would ship a protected view sitting on the non-coordinated side of aLEFT/RIGHTjoin.05100_sql_security_view_barrier_plan_based_parallel_replicaschecks that the invoker's plan over aDEFINER/NONEview has no remote parallel replicas read while theINVOKERcontrol over the same table does.A third parallel-replicas path reached below the barrier without touching the view's plan at all: with
parallel_replicas_allow_view_over_mergetree = 1the planner looks through a "simple" view and reads theMergeTreetable below it,StorageView::getUnderlyingMergeTreeStorageForParallelReplicasbeing the decision. The storage it returns is announced and read under the outer query's context and identity, so the view's filtering — its own row policies, the definer's row policies on the inner table, the definer profile's filters — is not part of that read. It now fails closed for a security barrier view that can hide rows, the same rule the other pre-plan decisions use, which also coversfindParallelReplicasQueryand thegetViewContextfast path since all of them go through that one function; a view that provably hides nothing keeps the optimization.05104_sql_security_view_barrier_view_over_mergetreechecks that the invoker's plan over aDEFINER/NONEview carries no parallel-replicas read with the setting on, while theINVOKERcontrol over the same table does — withsql_security_views_are_optimization_barriers = 0both barrier views distribute, so the check does not pass vacuously.The same shortcut also produced a wrong result, not only a disclosure. It only decides that the query can use parallel replicas; the query the replicas receive still reads the view, and a replica plans it on its own. When an
additional_table_filtersentry of the invoker applies to the view, the barrier keeps the view a table expression instead of inlining it, so the replica reads it throughStorageView::readImpl, which switches parallel replicas off for the inner query — every replica then read the whole view with no coordination and the result was the union of all of them, one copy of every row per replica.getUnderlyingMergeTreeStorageForParallelReplicasnow declines the shortcut exactly when the replica would decline to inline the view: when an entry applies to the view by its name or by the alias the outer query gives it, the samehasAdditionalTableFilterrule as inQueryAnalyzer::inlineViewSubqueryIfNeeded(the callers hand the table expression's original alias over; an entry keyed to an internal__table<N>alias counts too, since that is how the shipped query text names the view; a caller that does not know the alias fails closed on any entry). An entry keyed to an unrelated table cannot make a replica take that path, so a projection-only barrier view keeps the shortcut with it, like itsINVOKERtwin.Views that hide nothing are left alone
Only a view that can actually drop rows becomes a barrier. A step is row-preserving if it is an
ExpressionStep, aSortingStepwithout a limit, or a source step without PREWHERE; if none of the view's steps is anything else, nothing is marked and the plan is what it is today.The pre-plan paths make the same distinction.
StorageView::canHideRowsproves over the view's definition that the inner query preserves every row of a plainly readable source, and only a view for which that proof fails loses inlining and the forwarded outer filter. The proof fails closed: filters, limits, aggregation,DISTINCT, joins,ARRAY JOIN,SAMPLE/FINAL, multi-select unions, table functions, and aFROMthat is itself a view or a view-wrapping engine (Merge,Buffer, anything remote) all count as able to hide rows. The expression-level carriers are found behind SQL user-defined functions too: the classifier runs before UDF expansion, so it follows the lambda body of every SQL UDF it meets, recursively, and fails closed on a recursive definition, on a nesting deeper than 16, and on a body that is not a SQL lambda, soCREATE FUNCTION f AS (a) -> arrayJoin(a)cannot disguise a row-hiding view as projection-only. The proof classifies the storage that actually serves the read, not the object the name resolves to: proxy layers (a lazily loaded table of a database withlazy_load_tables = 1, or a table created from a table function) andAliastables are unwrapped first, failing closed on a chain that cannot be resolved, and a storage that rewrites its own reads withFINALand a_signfilter (MaterializedPostgreSQL) counts as able to hide rows even without another wrapper. The row-policy check then covers every table of that chain, not only the one that serves the read: a read through anAliascombines the policies of theAliasand of its target, so a policy defined on theAliasalone hides rows too. ASETTINGSclause of the view's own query is inspected rather than rejected outright: only settings from a fixed allowlist of execution tuning (max_threads,max_block_size, memory and read-method knobs, plan optimization switches, logging) leave the proof intact, while any other change —final,limit,max_rows_to_readunder aread_overflow_mode = 'break'profile,prefer_column_name_to_alias, a reset toDEFAULT, query parameters — counts as able to hide rows. The proof also runs against the view's effective security context, where the settings-only part of it lives inStorageView::effectiveContextCanHideRows: a definer profile that setslimit/offset,additional_result_filter,final, or any quota-like limit paired with a non-throwing overflow mode (max_rows_to_read/max_bytes_to_readand their_leaftwins,max_execution_timeand its_leaftwin), and a row policy on the source table that applies to the definer, count as hiding rows as well. The limits ofGROUP BY, sorting andDISTINCT(max_rows_to_group_by,max_rows_to_sort/max_bytes_to_sort,max_rows_in_distinct/max_bytes_in_distinctwith their overflow modes) hide rows only of a query that contains the corresponding operator, so they live inStorageView::shapeDependentOverflowCanHideRowsinstead and are applied once the shape of the query is known — a projection-only view under a definer profile that merely carriesgroup_by_overflow_modestays fully optimizable. TheSETTINGSclause of the view's own query gets the same split:settingsClauseCanHideRowstakes the shape flags and accepts those overflow settings only for a query that provably lacks the operator (aFROMsubquery fails closed on all of them, as its shape is not inspected at that level), soSELECT owner, secret FROM t SETTINGS max_rows_to_group_by = 1, group_by_overflow_mode = 'any'is projection-only as well.max_result_rows/max_result_bytesare not part of that set:getViewSubqueryContextresets them for the view's own subquery, so they never truncate the inner query's result.additional_table_filtersis shape-dependent in the same way, but on the sources rather than on the operators: an entry only grows a filter step below the view when it names one of the view's own sources, by alias, by unqualified name under the current database, or fully qualified — the three formsparseAdditionalFilterAstIfNeededandparseAdditionalFilterConditionForTablematch at execution time.canHideRowsmatches the entries against the source table once it has resolved it (StorageView::additionalTableFiltersApplyTo, shared with the view-keyed checkhasAdditionalTableFilter): the name the view's query uses, its alias, and the storage a proxy orAliastable forwards the read to, with a malformed value counting as applying. The same proof serves the view's ownSETTINGS additional_table_filtersclause, whichsettingsClauseCanHideRowsnow hands back to the caller instead of rejecting by name; a query whose source is not a single plainly named table (aFROMsubquery, noFROM) has nothing to match the clause against and fails closed on it. So a definer whose profile filters some unrelated table of their own no longer turns every one of theirSQL SECURITYprojection views into a barrier. The analyzer-timeORDER BY ... LIMITpushdown into a view (pushOrderByIntoView) consults the same effective context:additional_result_filtergrows a filter step on top of the inner query's result after the inner plan is built, so an injected innerLIMITtruncated the rows before the filter dropped its share and the view returned fewer rows than the filtered top-N — a wrong result; the pushdown now callsStorageView::effectiveContextCanHideRowsitself, so it rejects exactly the same set and the two guards cannot drift apart. It also callsStorageView::shapeDependentOverflowCanHideRowswithhas_sort, because the rewrite injects a sort into the view's inner query itself, so a definer profilesort_overflow_mode = 'break'with a sort limit would truncate the injected top-N even where the view has noORDER BYof its own.additional_table_filtersneeds no guard there: an entry of the definer profile keyed by the view's source table is applied at the source read, below the injectedORDER BY ... LIMIT, exactly like aWHEREof the view's query (which the pushdown allows), and an entry keyed by the underlyingDistributedtable is forwarded to the shards byparseAdditionalFilterAstIfNeeded; aSETTINGS additional_table_filtersclause of the view's own query still fails closed there, as the pushdown does not resolve the source. Its shape guard is now complete as well: aGROUP BY ALLview body keeps thegroupBy()expression list empty and only raises thegroup_by_allflag, so an aggregating view still got the rewrite and every shard aggregated its own rows with aTop-Kon the group keys instead of the coordinator merging the aggregate states of all rows first — a shard could drop a group of the global top-N. The flag, theWITH TOTALS/ROLLUP/CUBE/GROUPING SETSmarkers,limit_by_all/order_by_alland theLIMIT BYpayload are all checked now, mirroring the shape test of the trivial-view pushdown path. So a projection-onlyDEFINERview produces exactly the plan of the same view declaredSQL SECURITY INVOKERon every path, which the test pins byte-for-byte. Anadditional_table_filtersentry (from the definer's profile or from the view's ownSETTINGSclause) counts as a filter of the view only when it is keyed the way the filter-application paths match it — by the name the view's query uses, by its alias, or by the storage id that name resolves to. For a source that is anAliasor a lazy proxy that is theAlias/ proxy itself:StorageAlias::readandStorageProxy::readforward the already parsed filter without matching it again, so an entry keyed by the target of theAliasnever filters anything and does not turn a projection-only view into a barrier (the row policies of every table of the chain are still honoured).The caller's own
additional_table_filters/additional_result_filterno longer reach the view's inner query at all.StorageInMemoryMetadata::getSQLSecurityOverriddenContextused to replay every changed setting of the caller into the definer's (or, forSQL SECURITY NONE, the unrestricted) context, so a filter keyed on the view's inner table had its expression — scalar subqueries and table functions included — evaluated with the definer's privileges over columns the view does not expose, and a caller'sadditional_result_filterreplaced the one of the definer's profile. Both settings are now dropped from the replayed changes, assystem.user_query_logalready does; the caller's filters still apply to the caller's own query, and a filter keyed by the view itself keeps reaching the inner query asquery_info.additional_filter_ast.optimize_trivial_view_pushdown_to_distributedis the last of those pre-plan paths. It replaces a trivial view over aDistributedtable with the view's inner query and reads theDistributedtable directly, soStorageView::readImplnever runs and the plan has no sealing step at all: the invoker's predicate is merged with the view's ownWHEREand evaluated on the shards below it.StorageView::tryGetUnderlyingDistributedused to reject onlySQL SECURITY DEFINER, so a row-hidingSQL SECURITY NONEview still took the rewrite; it now declines it for any barrier view thatcanHideRows. The proof runs with one relaxation on this path,remote_source_is_read_identically: the alternative to the rewrite reads the very sameDistributedtable throughStorageDistributed::read, and whatever a shard resolves that table to is subject to that shard's own barrier, so a remote source is not by itself a reason to give up on the view - without the relaxation the guard would also drop the pushdown for the projection-only views the optimization exists for. It is not propagated into nested subqueries.Measured on 20M rows with
SELECT sum(length(payload)) FROM v WHERE tag = 'RARE', fromsystem.query_log:DEFINER, filters rowsDEFINER, filters rowsDEFINER, projection onlyDEFINER, projection onlyA projection-only view is unaffected. A filtering view does pay: PREWHERE then holds only the view's own condition, so it no longer skips granules on the outer predicate. That is the inherent price of the guarantee — PostgreSQL's
security_barrierviews behave the same way — and it applies only to views that restrict rows, which are exactly the ones where it matters.The new server setting
sql_security_views_are_optimization_barriers(default1) turns it off. It is deliberately a server setting and not a user setting: a user setting would be turned off by the very query that is trying to read the hidden rows.Testing
04758_sql_security_view_barrier_read_rowscovers theread_rowsoracle through index analysis, on both analyzers, and printsDISCLOSEDon both with the setting off.04670_sql_security_view_barriercovers the leak onDEFINERand onNONE, withenable_analyzer = 1, withenable_analyzer = 0and withanalyzer_inline_views = 1, the value leak through a cast error message, the same oracle through a shard withserialize_query_plan = 1, that a projection-onlyDEFINERview and anINVOKERview still have the outer predicate merged into the view's own filter, and that a projection-only view keeps PREWHERE.04813_sql_security_view_barrier_read_in_orderpins the read-in-order fence: theINVOKERtwin and a projection-onlyDEFINERview readInOrder, a filteringDEFINERview does not, under both analyzers, with unchanged results.04817_sql_security_view_barrier_top_kpins the top-K fence: theINVOKERtwin gets the__topKFilter, the filteringDEFINERview does not, andread_rowsof anORDER BY ... LIMIT 1over twin views is identical whether or not the hidden row holds the extreme minimum of the sort column that minmax pruning would rank first.04818_sql_security_view_barrier_lazy_materializationpins the lazy-materialization fence the same way and checks that an invoker predicate over the view is never evaluated on the hidden row.04821_sql_security_view_barrier_projectionspins the projection fence: theINVOKERtwin uses both a normal and an aggregate projection, the filteringDEFINERview uses neither, andread_rowsof a predicate probe over twin views is identical whether or not the hidden row matches it.04825_sql_security_view_barrier_unionpins that an outer predicate over a filteringDEFINERview onUNION ALLstays in a single filter above the union — before thetryLiftUpUnionfix it was duplicated into the branches — while theINVOKERtwin keeps the pushdown.04826_sql_security_view_barrier_functions_after_sortingpins that anORDER BY ... LIMITover a wrapperDEFINERview (aMergetable over a nested filtering view) produces no in-order reading and no__topKFilterwithquery_plan_execute_functions_after_sortingon, while theINVOKERtwin exploits the source order.04827_sql_security_view_barrier_masked_wrapperspins that the classification survives engine masking: aDEFINERview over aMergewrapper behind a lazyTableProxy(re-masked before every round, since planning materializes the proxy) or behind anAliastable plans differently from itsINVOKERtwin on both analyzers and withanalyzer_inline_views = 1.04832_sql_security_view_barrier_limit_pushdownpins the LIMIT fence: over aDISTINCTDEFINERview the invoker'sLimitStepstays above the sealing step on both analyzers — before the fix it crossed the seal and sat directly on theDistinctStep, where it seeds the hint — andread_rowsof anORDER BY ... LIMIT 1over twin in-orderGROUP BYviews is identical whether the first group holds one raw row or almost all of them.04837_sql_security_view_barrier_per_partitionpins the per-partition fence, with theallow_*_partitions_independentlysettings pinned to their defaults (the harness randomizes them): an outerDISTINCT/LIMIT BYover a filteringDEFINERview produces none of theSkip stream merging/Read each partition through separate portmarkers itsINVOKERtwin gets, and the disjointness of a view whose own innerDISTINCTlegitimately requests per-partition reading does not propagate across the seal into the invoker'sGROUP BY/LIMIT BY— before the fix everyDEFINERcase was identical to its twin.04840_sql_security_view_barrier_array_joinpins theARRAY JOINlift-up fence with an exception oracle on both analyzers: theINVOKERtwin's predicate legitimately descends below theARRAY JOINand throws on the row an empty array hides, while theDEFINERview counts without throwing and its plan keeps everythrowIfline above theArrayJoinstep — before the fix theDEFINERview threw as well.04891_sql_security_view_barrier_top_k_through_joinpins the top-K-through-join fence: theINVOKERtwin of a view over aLEFT JOINgets the preserved-sideSort + Limitgraft below the join, theDEFINERtwin keeps its join input untouched — before the fix theDEFINERplan got the graft below the seal.04892_sql_security_view_barrier_join_runtime_filterpins the join-runtime-filter contract: withenable_join_runtime_filters_index_analysis = 1, twin filteringDEFINERviews over tables identical except for the hidden row's primary-key value read exactly the same number of rows, while theINVOKERcontrol is pruned by the build-side key. Every setting the plan shape depends on is pinned, because the test also runs with randomized settings.04893_sql_security_view_barrier_join_reorderpins the join-reordering fence: withquery_plan_merge_expression_into_join = 1and no overlapping column names between the relations, theINVOKERtwin of a view over anINNER JOINwith aWHEREis flattened and reordered into a three-relation graph with no step left converting the view subquery result, while theDEFINERtwin keeps that step and stays one relation, with equal results.04894_sql_security_view_barrier_join_shard_by_pkpins the join-sharding fence: withquery_plan_join_shard_by_pk_ranges = 1andjoin_algorithm = 'full_sorting_merge', plain tables, aDEFINERview that hides nothing and theINVOKERtwin of the filtering view all get aShardingline, while the filteringDEFINERview gets none, with equal row counts. Those two fences are contract-made-real rather than live leaks: with the fence removed theDEFINERtwin behaves the same, because a neighbouring fence of this PR happens to block the pass first — which is exactly why the barrier is checked explicitly. Withsql_security_views_are_optimization_barriers = 0every one of those lines changes, so none of them passes vacuously.05042_sql_security_view_barrier_group_by_top_kpins the GROUP BY top-K fence on both analyzers: theINVOKERtwin of aGROUP BYview under a bareLIMITgets theTop-Kheap and the synthesizedSorting for GROUP BY top-Kstep, theDEFINERview gets neither — before the fix it got both.05211_sql_security_view_barrier_aggregation_bucket_top_kpins the bucket top-K fence: underquery_plan_aggregation_bucket_top_k = 1theINVOKERtwin carriesBucket top-K, theDEFINERandNONEviews do not — before the fix they did.05043_sql_security_view_barrier_additional_result_filterpins the effective-context fence: an invoker predicate over a projection-onlyDEFINERview whose definer profile setsadditional_result_filternever observes the rows the filter hides, under both analyzers and withanalyzer_inline_views = 1— before the fix thethrowIforacle disclosed them on every path.05059_sql_security_view_barrier_parallel_replicaspins the parallel-replicas fence with the settings set explicitly (parallel_replicas_local_plan = 0made the leak deterministic before the fix): nothrowIfdisclosure and the correct visible rows through aDEFINERview whose definer has a row policy on the source table, on both analyzers and withanalyzer_inline_views = 1.05060_sql_security_view_barrier_order_by_pushdownpins theORDER BY ... LIMITpushdown guard:SELECT k FROM v ORDER BY k LIMIT 1over a projectionDEFINERview whose definer profile setsadditional_result_filter = 'k = 2'returns2on every path — before the fix it returned no row — and the twin view without the filter still gets the inner sort pushed.05063_trivial_view_pushdown_security_barrierpins the trivial-view pushdown fence: withoptimize_trivial_view_pushdown_to_distributed = 1, a filteringSQL SECURITY NONEview over aDistributedtable keeps its subquery step, while the projection-only twin is still rewritten - withsql_security_views_are_optimization_barriers = 0the filtering view is rewritten too, so the check does not pass vacuously.05065_sql_security_view_barrier_array_join_functionpins thearrayJoinfunction form of theARRAY JOINcarrier:DEFINERandNONEviews that expand rows througharrayJoinin the projection keep the invoker's predicate above the expansion on both analyzers and withanalyzer_inline_views = 1, while theINVOKERtwin discloses the row an empty array hides.05066_view_inline_additional_table_filterspins thatanalyzer_inline_views = 1no longer drops a view-keyedadditional_table_filtersentry - before the fix the filtered row came back in the result of anINVOKERview.05067_trivial_view_pushdown_additional_filter_barrierpins that a barrier view whose only row hiding comes from a view-keyedadditional_table_filtersentry declines the trivialDistributedpushdown, while the same view without the entry still takes it.05097_sql_security_view_caller_additional_filterspins that a caller'sadditional_table_filtersentry keyed on the inner table of aDEFINER/NONEview is not evaluated inside the view (thethrowIfprobe on the unexposed column never fires) and that a caller'sadditional_result_filterdoes not replace the definer profile's own, on both analyzers and withanalyzer_inline_views = 1, while a filter keyed by the view and the caller's result filter on the caller's own query are still honoured.05098_sql_security_view_barrier_cascadespins the Cascades fence: the plan of aGROUP BY ... ORDER BYover a filteringDEFINER/NONEview is identical with and withoutenable_cascades_optimizer, while theINVOKERtwin is rebuilt.05101_sql_security_view_barrier_row_policy_filterpins that the row policy filter of a projection-onlyDEFINER/NONEview keeps its ownFilter (Row-level security filter)step on both analyzers while theINVOKERcontrol merges it with the outerWHERE, and thatthrowIfwithout short-circuit evaluation never fires on the hidden row.05102_sql_security_view_barrier_volume_reducing_functionspins the volume-reducing fence: the[volume-reducing functions]marker appears for anINVOKERview whose row policy filter reads the function argument and not for theDEFINER/NONEtwins, forlengthandnotEmpty.05104_sql_security_view_barrier_view_over_mergetreepins theparallel_replicas_allow_view_over_mergetreefence: with query-based parallel replicas and the setting on, the plan of aDEFINERview whose definer has a row policy on the source table and of a filteringNONEview has no parallel-replicas read, while theINVOKERtwin still distributes.05105_sql_security_view_barrier_additional_table_filterspins what an invoker'sadditional_table_filterspredicate does to a barrier view underparallel_replicas_allow_view_over_mergetree = 1, withserialize_query_planpinned to0and to1: matched by the view's qualified name or by its alias, the query leaves the parallel-replicas path for aDEFINER/NONEview and returns each row once — before the fence every row came back once per replica — while the "no filter" line is the control showing the shortcut is engaged for this shape. The alias-keyed entry is not asserted for theINVOKERtwin: dropping it and returning the rows once per replica reproduces for a plainMergeTreetable read with a shipped plan too, which has nothing to do with a view.05213_sql_security_view_barrier_unrelated_filter_view_over_mergetreepins the other side: with an entry keyed to an unrelated table, qualified or by bare name, theDEFINER/NONEviews merge the aggregation on the initiator like theINVOKERtwin underserialize_query_plan = 1(MergingAggregated;0before the fix), the view-name and alias entries still decline the shortcut, and every row comes back once.05215_sql_security_view_barrier_user_table_prefixed_filterpins that the internal-alias rule matches exactly__table<digits>: an entry keyed to a user table named__table_prod, bare or qualified, is an unrelated entry and keeps the shortcut for theDEFINER/NONEviews (0for the bare key before the fix), while a__table1entry still declines it.05214_sql_security_view_barrier_alias_row_policypins theAliashalf of the row-policy check: a projection-onlyDEFINERview over anAliaswhoseSELECTpolicy is defined on theAliasonly plans differently from itsINVOKERtwin and reads the same number of rows whether or not the hidden row matches the invoker's key predicate, on both planners — before the fix theread_rowsoracle printedDISCLOSEDon both.05216_sql_security_view_barrier_lowered_array_join_filterpins the twoarrayJoinrewrites: withquery_plan_lower_array_join_function = 1, with and without filter fusion, a view-keyedadditional_table_filtersentry usingarrayJoin(tags)keeps the invoker'sWHEREin a separate step above it and an outerthrowIfon a hidden row never fires for theDEFINER/NONEviews on both planners, while theINVOKERtwin shows the merge and the exception — before the fix the barrier views behaved like theINVOKERone.05217_sql_security_view_barrier_alias_target_filterpins theAliashalf of theadditional_table_filtersproof: projection-onlyDEFINER/NONEviews over anAliastable whoseSETTINGSclause keys an entry to the target of theAliasreturn every row and plan exactly like theirINVOKERtwin on both planners, while the same entry keyed to theAliasitself filters the read and seals the view.05218_sql_security_view_barrier_merge_pushed_filterpins the pushdown propagation over aMergetable on both planners: the invoker's predicate stays out of the child plan'sPREWHERE(theINVOKERtwin shows it there),read_rowsdoes not depend on whether a hidden row matches, and anadditional_result_filterof the invoker never sees a hidden row.05106_sql_security_view_barrier_effective_context_settingspins the settings inherited from the definer's profile rather than written in the view's AST, on all three paths: a profile of pure execution tuning still plans exactly like theINVOKERtwin, a profilemax_rows_to_readwithread_overflow_mode = 'break'no longer does, a profilefinal = 1plans exactly like the samefinalin the view's ownSETTINGSclause, and the version it hides is never observed.05103_sql_security_view_barrier_settings_clausepins that aDEFINER/NONEview with only execution settings in itsSETTINGSclause plans byte-identically to itsINVOKERtwin on all three paths and keepsPREWHERE, while views withfinal,max_rows_to_readorprefer_column_name_to_aliasin the clause still differ from their twins and the version hidden byfinalis never observed.05107_view_orderby_pushdown_settings_clausepins that theORDER BY ... LIMITpushdown into a view over aDistributedtable declines a view whose ownSETTINGSclause carriesadditional_result_filter,additional_table_filters,final, a limit with a non-throwing overflow mode or a reset to a default, while a clause of pure execution tuning keeps the pushdown — the guard reuses the very allowlistStorageView::canHideRowsapplies, so the two cannot drift apart.05108_view_orderby_pushdown_group_by_allpins the shape guard of the same pushdown: aGROUP BY ALLview body — which leaves thegroupBy()list empty and only raises thegroup_by_allflag — gets no per-shardTop-Kon the group keys and keeps the counts of both shards, like its explicitGROUP BYtwin and theWITH TOTALSform, while the plain projection view still takes the pushdown; before the fence theGROUP BY ALLplan carriedTop-K: limit=3in both shardAggregatingsteps.05142_sql_security_view_barrier_join_broadcast_sidepins the broadcast-side fence of plan-based parallel replicas: forplain LEFT JOIN viewand for theRIGHTmirror the plan root of a barrier view stays the localJoin, with theUnionof the local plan and the remote read on the coordinated side only, while theSQL SECURITY INVOKERcontrol still ships the whole join - before the fence the barrier view shipped as well.05143_sql_security_view_barrier_prefer_column_name_to_aliaspins that the inner query of aDEFINERview is resolved with the view's effective context even where the projection-only path still inlines it: with a definer profile settingprefer_column_name_to_alias = 1, the columnleakofSELECT secret AS public, public AS leak FROM tis the source columnpublicon all three analyzer paths, while theINVOKERtwin binds it to the alias under the caller's default.05182_sql_security_view_barrier_join_runtime_filterpins the runtime-filter fence: withquery_plan_join_swap_table = true, which puts the view on the probe side, the barrier view's plan carries noApply runtime join filterstep and returns every visible row, while theSQL SECURITY INVOKERtwin still gets the filter.05183_sql_security_view_barrier_limit_rangepins theLIMIT n AFTER expr UNTIL exprcarrier, whichcanHideRowsnow fails closed on likeLIMIT/OFFSET: on all three analyzer paths the view exposes only the rows of its range, an invoker expression never runs on a row outside of it, and the view keeps the sealingConvert VIEW subquery result to VIEW table structurestep with nothing merged into it, while the projection-only twin keeps the merge and is inlined away withanalyzer_inline_views = 1.05184_sql_security_view_barrier_shape_overflow_settingspins the shape-dependent half of the effective-context proof: a definer profile carrying onlymax_rows_to_group_by,max_rows_in_distinctormax_rows_to_sortwith a non-throwing overflow mode leaves a projection-onlyDEFINERview planning exactly like itsSQL SECURITY INVOKERtwin on all three analyzer paths — those limits cannot drop a row of a query that never aggregates, sorts or deduplicates — while the same sort limit over a view that does have an innerORDER BYstill fails closed.05212_sql_security_view_barrier_shape_overflow_settings_clausepins the same for the view's ownSETTINGSclause: the three clause forms leave the projection-onlyDEFINERview byte-identical to itsINVOKERtwin on all three analyzer paths, while the sort limit over a sorting view and amax_rows_to_readunderread_overflow_mode = 'break'in the clause still fail closed.05210_sql_security_view_barrier_unrelated_additional_table_filterspins the source-aware half: a definer profile whoseadditional_table_filtersnames a table the view never reads (by qualified or by bare name), or the same entry in the view's ownSETTINGSclause, leaves a projection-onlyDEFINERview planning exactly like itsSQL SECURITY INVOKERtwin on all three analyzer paths, while the same setting naming the view's own source — by qualified name, by bare name under the current database, by the alias the view's query gives it, or in the view's ownSETTINGSclause — still fails closed and still hides the rows it names.Ran 542 existing tests matching
view,prewhere,push_down,pushdown,row_policy,sql_securityanddefiner. 60 fail in my local environment, and the identical 60 fail with the barrier disabled on the same binary, so this introduces no regressions among them.Not covered here
throwIfnor the failing-cast oracle leaks throughserialize_query_plan = 1,make_distributed_plan = 1, or aDistributedtable, because the initiator optimizes the fragment before shipping it. The flag is serialized so that the guarantee does not depend on that.Workflow [PR]
Sync PR [sync-upstream/pr/112847]