Skip to content

Fix condition not being moved to prewhere in case there is a row policy#85118

Closed
arthurpassos wants to merge 8 commits intoClickHouse:masterfrom
arthurpassos:row_policy_prewhere_with_optimize
Closed

Fix condition not being moved to prewhere in case there is a row policy#85118
arthurpassos wants to merge 8 commits intoClickHouse:masterfrom
arthurpassos:row_policy_prewhere_with_optimize

Conversation

@arthurpassos
Copy link
Contributor

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):

Fixes #69777 and #83748

Documentation entry for user-facing changes

  • Documentation is written (mandatory for new features)

@arthurpassos
Copy link
Contributor Author

I am not very familiar with where condition analysis, take this PR with a grain of salt :)

@alexey-milovidov
Copy link
Member

alexey-milovidov commented Aug 5, 2025

Is it secure? Check that it's not possible to expose information using the throwIf function or timing attacks.
The problem is that it's not safe to even touch the data that we are not allowed to access (even if the result is discarded).

@arthurpassos
Copy link
Contributor Author

Is it secure? Check that it's not possible to expose information using the throwIf function or timing attacks. The problem is that it's not safe to even touch the data that we are not allowed to access (even if the result is discarded).

Perhaps if I implement it differently and fill

std::optional<ActionsDAG> row_level_filter;
it will be safer. Assuming PrewhereInfo::row_level_filter is checked before PrewhereInfo::prewhere_actions

@UnamedRus
Copy link
Contributor

it will be safer. Assuming PrewhereInfo::row_level_filter is checked before

Wouldn't it be worse?

Ie, if you push user defined prewhere to RLF stage, it does mean that user could trigger exception during RLF scan.

Just an idea
Row level filter: tenant = 'XXXXXX'

Query: PREWHERE throwIf(tenant = 'YYYYYY')

If push to RLF, it will form condition like tenant = 'XXXXXX' AND throwIf(tenant = 'YYYYYY')

@arthurpassos
Copy link
Contributor Author

it will be safer. Assuming PrewhereInfo::row_level_filter is checked before

Wouldn't it be worse?

Ie, if you push user defined prewhere to RLF stage, it does mean that user could trigger exception during RLF scan.

Just an idea Row level filter: tenant = 'XXXXXX'

Query: PREWHERE throwIf(tenant = 'YYYYYY')

If push to RLF, it will form condition like tenant = 'XXXXXX' AND throwIf(tenant = 'YYYYYY')

User defined wouldn't be pushed to RLF stage. Inside the PrewhereInfo object there are two condition fields: row_level_filter and prewhere_actions. Right now, prewhere_actions is being populated with everything and row_level_filter remains empty. What I am suggesting is to actually make use of the row_level_filter for row policies (as it is supposed to, I don't know why it's not being used) and leave prewhere_actions for user defined where

@arthurpassos
Copy link
Contributor Author

But before doing that, I need to understand why is it that the decision to fill row_level_filter or prewhere_actions is based on order of invokation instead of relying on "here's a row policy, put it in row level filter". Maybe @amosbird knows about it?

https://github.com/arthurpassos/ClickHouse/blob/cbaa9e4e335cfef4b734038193df4e64a0fc0de7/src/Planner/PlannerJoinTree.cpp#L913

@arthurpassos
Copy link
Contributor Author

@alexey-milovidov can you enable CI so I can have a better understanding if I broke something?

@arthurpassos
Copy link
Contributor Author

arthurpassos commented Aug 7, 2025

Query condition cache is causing problems with this fix. The cache will map from prewhere to skipped ranges and ignore the row level filters in PrewhereInfo::row_level_filter. So if the row level filter actually skipped some ranges, the following queries will also skip that range even if the row policy is dropped.

More info on: https://github.com/ClickHouse/ClickHouse/pull/69236/files#r2258613976

@arthurpassos
Copy link
Contributor Author

Throughout the codebase the object PrewhereInfo is assumed to contain prewhere_actions whenever it is non-null. It literally assumes it's impossible to have PrewhereInfo::row_level_filter != nullopt while PrewhereInfo::prewhere_actions.empty() == true. This looks like a mistake, but I might be missinng some important details

@arthurpassos
Copy link
Contributor Author

Ok, it seems like additional_table_filters are somewhat equivalent to row policies, and that's why that weird logic of filling PrewhereInfo by execution order "exists" / "works"

@arthurpassos
Copy link
Contributor Author

Background

This PR addresses an issue where WHERE conditions were not being pushed to PREWHERE when a row policy was present. This is a regression from 23.8, where such conditions were moved to PREWHERE. Details: #69777, #83748.

Root Cause

Under the new analyzer, ClickHouse instantiated PrewhereInfo with the row policy. Later, when attempting to push the regular WHERE to PREWHERE, it detected an existing PREWHERE and exited early. Removing this early exit was not enough, the WHERE condition would then override the existing PREWHERE.

Naive solution (not very important)

The fix required:

  1. Removing the early exit.
  2. Merging conditions so that existing PREWHERE clauses are preserved.

However, @alexey-milovidov raised a valid security concern:

Is it secure? Check that it's not possible to expose information using the throwIf function or timing attacks.

It wasn’t secure in the original form. To address this, row policies must be evaluated first, separately from other conditions. ClickHouse already has a row_level_filters field in PrewhereInfo for this purpose, but it was not being used correctly (row policies were stored in prewhere_actions instead).

The real solution

  1. Remove early exit;
  2. Fill PrewhereInfo::row_level_filters with row policy filters instead of putting it in PrewhereInfo:;prewhere_actions
  3. Make PrewhereInfo::prewhere_actions optional as the only prewhere condition might be row_level_filters

By applying the above, the row policies are evaluated first. I have added throwIf tests to validate this behavior — suggestions for additional cases are welcome.

Why the PR Is Large

PrewhereInfo::prewhere_column_name and PrewhereInfo::prewhere_actions were assumed to be non-null whenever PrewhereInfo was non-null. This assumption fails when only row_level_filters are set. I made these fields optional, similar to row_level_filters, which required changes across multiple files.

Additional Consideration – additional_table_filters

Some use additional_table_filters as a shortcut for tenant-based restrictions. These were not designed as a security feature and cannot be safely evaluated with row policies. This PR forces them to be evaluated with the regular WHERE clause. If users have relied on them for security, this may introduce issues. We might want to make this behavior configurable.

@arthurpassos
Copy link
Contributor Author

Perhaps an ordered conjunction of these conditions could also work (i.e, and(row_policy, additional_table_filters, ...)). This would be safe under the assumption no part of clickhouse would rearrange it


SELECT * FROM 03591_test WHERE throwIf(b=1, 'Should throw') SETTINGS optimize_move_to_prewhere = 1; -- {serverError FUNCTION_THROW_IF_VALUE_IS_NON_ZERO}

CREATE ROW POLICY 03591_rp ON 03591_test USING b=2 TO CURRENT_USER;
Copy link
Contributor

Choose a reason for hiding this comment

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

It may be good idea to have a bit more complex row policies, e.g. another PERMISSIVE and another RESTRICTIVE.

Copy link
Contributor

Choose a reason for hiding this comment

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

It may be interesting to add a case where columns used in row policies are not selected.

source_step_with_filter->addFilter(storage_prewhere_info->prewhere_actions->clone(), storage_prewhere_info->prewhere_column_name);
if (storage_prewhere_info->row_level_filter)
source_step_with_filter->addFilter(storage_prewhere_info->row_level_filter->clone(), storage_prewhere_info->row_level_column_name);
}
Copy link
Contributor

@ilejn ilejn Aug 14, 2025

Choose a reason for hiding this comment

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

To me this code looks rather worrying (don't mean actual change).
It seems that some conditions like Limit applied before row policy. Is it true? If yes, is it risky from security standpoint?

Copy link
Member

Choose a reason for hiding this comment

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

The limit should be applied only after all the filters.

Also, it's used only in ReadFromPostgreSQL and ReadFromSystemNumbersStep as I can see.

@alexey-milovidov
Copy link
Member

@arthurpassos We support multiple prewhere steps, so we can make the first (the earliest) prewhere step to only include row policies, and the rest - as usual.

@arthurpassos
Copy link
Contributor Author

@arthurpassos We support multiple prewhere steps, so we can make the first (the earliest) prewhere step to only include row policies, and the rest - as usual.

Isn't this what I implemented in this PR?

@amosbird amosbird added the can be tested Allows running workflows for external contributors label Aug 16, 2025
@clickhouse-gh
Copy link
Contributor

clickhouse-gh bot commented Aug 16, 2025

Workflow [PR], commit [5ceb04a]

Summary:
15 failures out of 107 shown:

job_name test_name status info comment
Style check failure
cpp failure
various failure
Fast test failure
02346_additional_filters FAIL
00950_default_prewhere FAIL
00910_buffer_prewhere_different_types FAIL
02680_illegal_type_of_filter_projection FAIL
03143_prewhere_profile_events FAIL
01917_prewhere_column_type FAIL
Build (amd_debug) dropped
Build (amd_release) dropped
Build (amd_asan) dropped
Build (amd_tsan) dropped
Build (amd_msan) dropped
Build (amd_ubsan) dropped
Build (amd_binary) dropped
Build (arm_release) dropped
Build (arm_asan) dropped
Build (arm_coverage) dropped
Build (arm_binary) dropped
Build (amd_darwin) dropped
Build (arm_darwin) dropped

@clickhouse-gh clickhouse-gh bot added the pr-bugfix Pull request with bugfix, not backported by default label Aug 16, 2025
@alexey-milovidov
Copy link
Member

Thanks.

@arthurpassos
Copy link
Contributor Author

While working on this, I discovered another bug: #85834

It should probably be fixed before this one (otherwise it is too hard to debug the failing tests).

Unfortunately, I don't have time to continue this work right now

@arthurpassos arthurpassos marked this pull request as draft August 18, 2025 19:26
Comment on lines +106 to +127
if (prewhere_info->remove_prewhere_column)
{
removeFromOutput(split_result.first, prewhere_info->prewhere_column_name);
// split_result.first.removeUnusedActions();
}
}

{
std::unordered_set<const ActionsDAG::Node *> first_outputs(
split_result.first.getOutputs().begin(), split_result.first.getOutputs().end());
for (const auto * input : split_result.first.getInputs())
{
if (!first_outputs.contains(input))
{
split_result.first.getOutputs().push_back(input);
/// Add column to second actions as input.
/// Do not add it to result, so it would be removed.
split_result.second.addInput(input->result_name, input->result_type);
}
}
}

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I still don't know what's the right procedure to merge these conditions. This was the closest I got to it (a few lines below it'll and the functions together).

for (const auto * condition : prewhere_nodes_list)
conditions.push_back(split_result.split_nodes_mapping.at(condition));

/// Is it possible that prewhere_info->prewhere_actions was not empty?
Copy link
Contributor Author

Choose a reason for hiding this comment

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

Yes. Just set prewhere manually.


/// Is it possible that prewhere_info->prewhere_actions was not empty?
/// Not sure, but just in case let's merge it
if (prewhere_info->prewhere_actions)
Copy link
Contributor Author

Choose a reason for hiding this comment

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

The thing about this function splitAndFillPrewhereInfo is that the previous implementation simply did not care if PrewhereInfo already contained actions. It would simply override them. (Actually, this was not happening because there was an early return on optimizePrewhere that prevented the code from reaching this function).

All that needs to be done is to properly construct a DAG that is a conjuction of the optimized filter_expression + existing prewhere (preserving row level filters). I just don't know how to do it :).

@arthurpassos
Copy link
Contributor Author

Closing this PR in favor of #87303

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

Labels

can be tested Allows running workflows for external contributors pr-bugfix Pull request with bugfix, not backported by default

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Conditions isn't moved to PREWHERE if a row policy presents

6 participants