Skip to content

fix(core): prune inaccessible CLS columns in wildcard expansion instead of denying - #2449

Merged
goldmedal merged 1 commit into
Canner:mainfrom
fmcmac:fix/cls-prune-aliased-wildcard
Jul 9, 2026
Merged

goldmedal merged 1 commit into
Canner:mainfrom
fmcmac:fix/cls-prune-aliased-wildcard

Conversation

@fmcmac

@fmcmac fmcmac commented Jul 8, 2026 •

Copy link
Copy Markdown
Contributor

Problem

Column-level access control (CLAC/CLS) denies any query that references a model with a table alias, even when the protected column is never selected. SELECT * FROM model correctly prunes an inaccessible CLS column, but SELECT alias.other_col FROM model alias (or a join using an alias) fails with Access denied to column … violates access control rule …. SELECT count(*) FROM model on a model with a CLS column is denied for the same reason.

Root cause

For FROM customer c, the plan is Projection → SubqueryAlias(c) → TableScan(customer). ModelAnalyzeRule collects required columns keyed by the alias c, but the bottom-up transform processes the inner bare TableScan(customer) first with no alias, looks required columns up under the model name, and finds none. That builds a throwaway inner ModelPlanNode with empty required fields, which triggers the wildcard expansion in RelationChain::source (ModelSourceNode::new). That loop expanded every physical column and called get_remote_column_exp per column, which hard-denies via validate_clac_rule. analyze_subquery_alias_model later rebuilds the node correctly with the alias's required columns, but the throwaway build errors first.

SELECT * avoids this because it doesn't route through the empty-required wildcard path.

Fix

In the wildcard-expansion loop, prune columns the caller cannot access (validate_clac_rule + continue) instead of denying — matching SELECT * semantics. This path is an implicit all-columns expansion (count(*), or the aliased throwaway plan); a protected column the query never explicitly selected should be dropped, not turned into a hard error. Explicit references still deny, because they arrive as named required fields through ModelPlanNodeBuilder::build (the non-wildcard branch), not this path. Pruning a column can only ever remove it, so it cannot widen access.

Tests

Adds test_clac_unreferenced_column_pruned_not_denied:

  • Pruned (must succeed): aliased/unaliased join, aliased/unaliased standalone, aliased/unaliased count(*), model-qualified alias.*, and bare * — none reference the protected columns.
  • Still denied (must error with "violates access control rule"): explicit reference to a protected column in SELECT (aliased and unaliased), a second protected column, and references in WHERE, GROUP BY, and JOIN ON.

Verified locally against main: the new test passes and the fix is a minimal, localized change in ModelSourceNode::new.

Summary by CodeRabbit

  • Bug Fixes
    • Wildcard queries now omit columns the caller is not allowed to access, instead of failing with a permission error.
    • Protected columns that are not explicitly referenced are now safely pruned, including in aliased queries and count(*)/* scenarios.
    • Explicitly referenced protected columns continue to return access-denied errors as expected.

…ad of denying

A model referenced with a table alias (e.g. `FROM customer c`) makes
ModelAnalyzeRule build a throwaway inner ModelPlanNode with empty required
fields: the required columns are keyed by the alias, but the bottom-up
transform processes the bare inner TableScan first under the model name and
finds none. Empty required fields trigger the wildcard expansion in
RelationChain::source, which expanded every physical column and hard-denied
any column-level-access-controlled column the caller cannot read — even one
the query never referenced. This made any aliased query against a model with
a CLS column fail, and also broke `SELECT count(*)` on such models.

Prune inaccessible CLS columns in the wildcard expansion (validate_clac_rule
+ continue), matching `SELECT *` semantics. Explicit references still deny:
they arrive as named required fields via ModelPlanNodeBuilder::build, not
this path. Pruning a column can never leak it.

Adds a regression test covering aliased/unaliased joins and standalones,
count(*), qualified/bare wildcards (all pruned), plus explicit references in
SELECT/WHERE/GROUP BY/JOIN ON and a second CLS column (all still denied).
@github-actions github-actions Bot added rust Pull requests that update rust code core labels Jul 8, 2026
@coderabbitai

coderabbitai Bot commented Jul 8, 2026 •

Copy link
Copy Markdown
Contributor

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro

Run ID: def1db57-e8e3-4629-a959-38b4430415cd

📥 Commits

Reviewing files that changed from the base of the PR and between 7db2651 and d99efa3.

📒 Files selected for processing (2)
  • core/wren-core/core/src/logical_plan/analyze/plan.rs
  • core/wren-core/core/src/mdl/mod.rs

Walkthrough

Wildcard expansion in ModelSourceNode::new now validates each physical column against column-level access-control rules (CLAC), pruning inaccessible columns instead of unconditionally including them. A regression test verifies unreferenced protected columns are pruned while explicitly referenced ones still deny.

Changes

CLAC Wildcard Pruning

Layer / File(s) Summary
Prune invalid columns during wildcard expansion
core/wren-core/core/src/logical_plan/analyze/plan.rs
Wildcard expansion now calls validate_clac_rule per physical column and skips columns failing validation from both schema fields and required remote expressions.
Regression test for pruning vs denial
core/wren-core/core/src/mdl/mod.rs
New async test verifies unreferenced CLS-protected columns are pruned across aliased, count(*), and wildcard queries, while explicitly referenced protected columns still fail with access-control errors.

Estimated code review effort: 2 (Simple) | ~10 minutes

Poem

A rabbit hopped through columns wide,
Pruning what it shouldn't hide,
Wildcards now politely skip
The fields that need a permission chip,
Tests confirm the burrow's tight — 🐇✨

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly states the primary change: pruning inaccessible CLS columns during wildcard expansion instead of denying them.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@goldmedal goldmedal left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Thanks @fmcmac, makes sense to me.

One note for future readers (no change requested): the aliased-scan case here routes through a build → discard → rebuild — under the bottom-up transform the bare TableScan is visited before its SubqueryAlias, looked up by model name instead of the alias, so required columns miss → empty → wildcard expansion → a ModelPlanNode that's then discarded and rebuilt by analyze_subquery_alias_model.

That's a separate, behavior-neutral inefficiency (and fixing it wouldn't remove the need for this PR — genuine count(*) still needs the prune here). Opened #2451 to track it. Not a blocker.

@goldmedal
goldmedal merged commit a8a7519 into Canner:main Jul 9, 2026
17 checks passed
AmirF194 added a commit to AmirF194/WrenAI that referenced this pull request Jul 28, 2026
… scans

For a model scanned through a table alias (FROM customer AS e), the
bottom-up ModelAnalyzeRule walk visits the bare TableScan before its
SubqueryAlias parent. Required columns are keyed by the alias, so this
first pass finds none and builds a ModelPlanNode via the wildcard-
expansion path; analyze_subquery_alias_model then discards it and
rebuilds the node correctly once the alias is known. The first build
runs the full RLAC/CLAC parsing for nothing.

PR Canner#2449 already fixed the correctness bug this caused (the throwaway
build was denying access to columns under column-level security instead
of pruning them); the wasted build itself remained.

This adds a pre-order TreeNodeRewriter pass that recognizes
SubqueryAlias -> TableScan for a model directly and builds the
ModelPlanNode once, with the alias's real required columns, skipping
the discarded intermediate build. Everything else keeps using the
original bottom-up analyze_model_internal pass.

Fixes Canner#2451
AmirF194 added a commit to AmirF194/WrenAI that referenced this pull request Aug 5, 2026
… scans

For a model scanned through a table alias (FROM customer AS e), the
bottom-up ModelAnalyzeRule walk visits the bare TableScan before its
SubqueryAlias parent. Required columns are keyed by the alias, so this
first pass finds none and builds a ModelPlanNode via the wildcard-
expansion path; analyze_subquery_alias_model then discards it and
rebuilds the node correctly once the alias is known. The first build
runs the full RLAC/CLAC parsing for nothing.

PR Canner#2449 already fixed the correctness bug this caused (the throwaway
build was denying access to columns under column-level security instead
of pruning them); the wasted build itself remained.

This adds a pre-order TreeNodeRewriter pass that recognizes
SubqueryAlias -> TableScan for a model directly and builds the
ModelPlanNode once, with the alias's real required columns, skipping
the discarded intermediate build. Everything else keeps using the
original bottom-up analyze_model_internal pass.

Fixes Canner#2451
goldmedal pushed a commit that referenced this pull request Aug 6, 2026
… scans

For a model scanned through a table alias (FROM customer AS e), the
bottom-up ModelAnalyzeRule walk visits the bare TableScan before its
SubqueryAlias parent. Required columns are keyed by the alias, so this
first pass finds none and builds a ModelPlanNode via the wildcard-
expansion path; analyze_subquery_alias_model then discards it and
rebuilds the node correctly once the alias is known. The first build
runs the full RLAC/CLAC parsing for nothing.

PR #2449 already fixed the correctness bug this caused (the throwaway
build was denying access to columns under column-level security instead
of pruning them); the wasted build itself remained.

This adds a pre-order TreeNodeRewriter pass that recognizes
SubqueryAlias -> TableScan for a model directly and builds the
ModelPlanNode once, with the alias's real required columns, skipping
the discarded intermediate build. Everything else keeps using the
original bottom-up analyze_model_internal pass.

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

Labels

core rust Pull requests that update rust code

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants