Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions core/wren-core/core/src/logical_plan/analyze/plan.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1046,6 +1046,25 @@ impl ModelSourceNode {
if column.is_calculated {
continue;
}
// Prune columns the caller cannot access under column-level
// security instead of denying. This is an *implicit* all-columns
// expansion (a `count(*)`, or the throwaway inner plan built for
// a model scan referenced with a table alias, whose required
// columns are keyed by the alias so the model is wildcard-
// expanded). A protected column the query never explicitly
// selected must be dropped here — matching `SELECT *` semantics —
// not turned into a hard permission error. Explicit references
// still deny, because they arrive as named required fields (the
// non-wildcard branch / ModelPlanNodeBuilder::build), not here.
let (is_valid, _) = validate_clac_rule(
model.name(),
&column,
&session_properties,
Some(Arc::clone(&analyzed_wren_mdl)),
)?;
if !is_valid {
continue;
}
fields_buffer.insert((
Some(TableReference::bare(quoted(model.name()))),
Arc::new(Field::new(
Expand Down
129 changes: 129 additions & 0 deletions core/wren-core/core/src/mdl/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3093,6 +3093,135 @@ mod test {
Ok(())
}

#[tokio::test]
async fn test_clac_unreferenced_column_pruned_not_denied() -> Result<()> {
// A CLS-protected column the query does NOT reference must be pruned,
// not denied — even when the model is referenced with a table alias
// (which builds a throwaway inner plan with empty required fields, so the
// model gets wildcard-expanded), and for `count(*)`. Before the fix, an
// aliased scan of `customer` — or `count(*)` — that never selected
// `c_name` still denied `customer.c_name`.
let ctx = create_wren_ctx(None, None);
let manifest = ManifestBuilder::new()
.catalog("wren")
.schema("test")
.model(
ModelBuilder::new("customer")
.table_reference("customer")
.column(ColumnBuilder::new("c_custkey", "int").build())
.column(
ColumnBuilder::new("c_name", "string")
.column_level_access_control(
"cls rule",
vec![SessionProperty::new_required("session_level")],
ColumnLevelOperator::Equals,
"1",
)
.build(),
)
.column(
ColumnBuilder::new("c_secret", "string")
.column_level_access_control(
"cls rule 2",
vec![SessionProperty::new_required("session_level")],
ColumnLevelOperator::Equals,
"2",
)
.build(),
)
.primary_key("c_custkey")
.build(),
)
.model(
ModelBuilder::new("orders")
.table_reference("orders")
.column(ColumnBuilder::new("o_orderkey", "int").build())
.column(ColumnBuilder::new("o_custkey", "int").build())
.primary_key("o_orderkey")
.build(),
)
.relationship(
RelationshipBuilder::new("customer_orders")
.model("customer")
.model("orders")
.join_type(JoinType::OneToMany)
.condition("customer.c_custkey = orders.o_custkey")
.build(),
)
.build();

// session_level=0 → NO access to the protected columns (c_name, c_secret).
let headers = Arc::new(build_headers(&[(
"session_level".to_string(),
Some("0".to_string()),
)]));
let analyzed_mdl = Arc::new(AnalyzedWrenMDL::analyze(
manifest.clone(),
headers.clone(),
Mode::Unparse,
)?);

// The protected columns are never referenced → each of these must succeed
// with them pruned, not denied.
let prune_cases = [
("unaliased join", "SELECT customer.c_custkey, orders.o_orderkey FROM customer JOIN orders ON customer.c_custkey = orders.o_custkey"),
("aliased join", "SELECT e.c_custkey, o.o_orderkey FROM customer e JOIN orders o ON e.c_custkey = o.o_custkey"),
("aliased standalone", "SELECT e.c_custkey FROM customer e"),
("unaliased standalone", "SELECT customer.c_custkey FROM customer"),
("aliased count(*)", "SELECT count(*) FROM customer e"),
("unaliased count(*)", "SELECT count(*) FROM customer"),
("qualified wildcard", "SELECT e.* FROM customer e"),
("bare wildcard", "SELECT * FROM customer"),
];
for (label, sql) in prune_cases {
if let Err(e) = transform_sql_with_ctx(
&ctx,
Arc::clone(&analyzed_mdl),
&[],
headers.clone(),
sql,
)
.await
{
panic!(
"[{label}] unreferenced CLS column should be pruned, not denied: {e}"
);
}
}

// An EXPLICIT reference to a protected column must still be denied, wherever
// it appears — the fix only prunes columns the query never references.
let deny_cases = [
("aliased explicit select", "SELECT e.c_custkey, e.c_name FROM customer e JOIN orders o ON e.c_custkey = o.o_custkey"),
("aliased explicit standalone", "SELECT e.c_name FROM customer e"),
("unaliased explicit select", "SELECT c_name FROM customer"),
("second protected column", "SELECT e.c_secret FROM customer e"),
("explicit in WHERE", "SELECT e.c_custkey FROM customer e WHERE e.c_name = 'x'"),
("explicit in GROUP BY", "SELECT count(*) FROM customer e GROUP BY e.c_name"),
("explicit in JOIN ON", "SELECT e.c_custkey FROM customer e JOIN orders o ON e.c_name = o.o_custkey"),
];
for (label, sql) in deny_cases {
match transform_sql_with_ctx(
&ctx,
Arc::clone(&analyzed_mdl),
&[],
headers.clone(),
sql,
)
.await
{
Ok(out) => {
panic!("[{label}] must deny explicit CLS reference, got: {out}")
}
Err(e) => assert!(
e.to_string().contains("violates access control rule"),
"[{label}] expected CLS denial, got: {e}"
),
}
}
Ok(())
}

#[tokio::test]
async fn test_calc_primary_key() -> Result<()> {
let ctx = create_wren_ctx(None, None);
Expand Down
Loading