Skip to content

Commit a67fa3c

Browse files
authored
fix(tesseract): keep pre-aggregations for RBAC access-denied 1=0 segment (cube-js#11123)
1 parent cb35d10 commit a67fa3c

5 files changed

Lines changed: 240 additions & 10 deletions

File tree

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
import { PostgresQuery } from '../../../src/adapter/PostgresQuery';
2+
import { prepareJsCompiler } from '../../unit/PrepareCompiler';
3+
4+
// When a cube's access policy denies the queried members, RBAC
5+
// (CompilerApi.applyRowLevelSecurity) appends a member-expression segment
6+
// `{ expression: () => '1 = 0', cubeName, name: 'rlsAccessDenied' }`. The rollup
7+
// must still be selected (the `1 = 0` is just a constant filter on top of it).
8+
// This guards that the segment doesn't disqualify pre-aggregation matching.
9+
describe('PreAggregations access-denied segment', () => {
10+
jest.setTimeout(200000);
11+
12+
const { compiler, joinGraph, cubeEvaluator } = prepareJsCompiler(`
13+
cube('rls_visitors', {
14+
sql: 'select * from visitors',
15+
sqlAlias: 'rlsv',
16+
17+
measures: {
18+
count: {
19+
type: 'count'
20+
}
21+
},
22+
23+
dimensions: {
24+
id: {
25+
type: 'number',
26+
sql: 'id',
27+
primaryKey: true
28+
},
29+
status: {
30+
type: 'number',
31+
sql: 'status'
32+
}
33+
},
34+
35+
preAggregations: {
36+
statusRollup: {
37+
type: 'rollup',
38+
measures: [CUBE.count],
39+
dimensions: [CUBE.status],
40+
}
41+
}
42+
});
43+
44+
view('rls_visitors_view', {
45+
cubes: [
46+
{
47+
join_path: 'rls_visitors',
48+
includes: '*',
49+
},
50+
]
51+
});
52+
`);
53+
54+
it('selects the rollup despite the access-denied segment', async () => {
55+
await compiler.compile();
56+
57+
const query = new PostgresQuery(
58+
{ joinGraph, cubeEvaluator, compiler },
59+
{
60+
measures: ['rls_visitors_view.count'],
61+
// Byte-for-byte the segment CompilerApi.applyRowLevelSecurity injects on denial.
62+
segments: [
63+
{
64+
expression: () => '1 = 0',
65+
cubeName: 'rls_visitors',
66+
name: 'rlsAccessDenied',
67+
},
68+
],
69+
timezone: 'America/Los_Angeles',
70+
preAggregationsSchema: '',
71+
}
72+
);
73+
74+
const preAggregationsDescription: any = query.preAggregations?.preAggregationsDescription();
75+
const [sql] = query.buildSqlAndParams();
76+
77+
expect(preAggregationsDescription[0].tableName).toEqual('rlsv_status_rollup');
78+
expect(sql).toContain('rlsv_status_rollup');
79+
expect(sql).toContain('1 = 0');
80+
});
81+
});

rust/cube/cubesqlplanner/cubesqlplanner/src/logical_plan/optimizers/pre_aggregation/dimension_matcher.rs

Lines changed: 65 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -315,7 +315,16 @@ impl<'a> DimensionMatcher<'a> {
315315
Ok(result)
316316
}
317317
FilterItem::Segment(segment) => {
318-
self.try_match_symbol(&segment.member_evaluator(), add_to_matched_dimension)
318+
let symbol = segment.member_evaluator();
319+
// An ad-hoc member-expression segment that references no members
320+
// (a constant like the `1 = 0` rlsAccessDenied segment RBAC injects
321+
// on access denial) is a filter pushable on top of any rollup, so it
322+
// doesn't need pre-aggregation coverage. Named segments still must be
323+
// covered and fall through to the regular matcher.
324+
if segment.is_member_expression() && symbol.get_dependencies().is_empty() {
325+
return Ok(MatchState::Full);
326+
}
327+
self.try_match_symbol(&symbol, add_to_matched_dimension)
319328
}
320329
}
321330
}
@@ -339,26 +348,55 @@ impl<'a> DimensionMatcher<'a> {
339348
#[cfg(test)]
340349
mod tests {
341350
use super::*;
351+
use crate::cube_bridge::member_expression::MemberExpressionExpressionDef;
352+
use crate::cube_bridge::member_sql::MemberSql;
353+
use crate::cube_bridge::options_member::OptionsMember;
342354
use crate::logical_plan::optimizers::pre_aggregation::{
343355
PreAggregationFullName, PreAggregationsCompiler,
344356
};
345-
use crate::test_fixtures::cube_bridge::MockSchema;
357+
use crate::test_fixtures::cube_bridge::{
358+
MockMemberExpressionDefinition, MockMemberSql, MockSchema,
359+
};
346360
use crate::test_fixtures::test_utils::TestContext;
347361
use indoc::indoc;
362+
use std::rc::Rc;
348363

349364
fn create_test_context() -> TestContext {
350365
let schema = MockSchema::from_yaml_file("common/pre_aggregation_matching_test.yaml");
351366
TestContext::new(schema).unwrap()
352367
}
353368

369+
/// Builds an ad-hoc member-expression segment (no registered `segments:`
370+
/// path) from constant SQL — mirrors the `rlsAccessDenied` `1 = 0` segment.
371+
fn const_expr_segment(name: &str, cube: &str, sql: &str) -> OptionsMember {
372+
let member_sql: Rc<dyn MemberSql> = Rc::new(MockMemberSql::new(sql).unwrap());
373+
let expr = MockMemberExpressionDefinition::builder()
374+
.expression_name(Some(name.to_string()))
375+
.cube_name(Some(cube.to_string()))
376+
.expression(MemberExpressionExpressionDef::Sql(member_sql))
377+
.build();
378+
OptionsMember::MemberExpression(Rc::new(expr))
379+
}
380+
354381
fn match_pre_agg(ctx: &TestContext, pre_agg_name: &str, query_yaml: &str) -> MatchState {
382+
match_pre_agg_with_segments(ctx, pre_agg_name, query_yaml, vec![])
383+
}
384+
385+
fn match_pre_agg_with_segments(
386+
ctx: &TestContext,
387+
pre_agg_name: &str,
388+
query_yaml: &str,
389+
extra_segments: Vec<OptionsMember>,
390+
) -> MatchState {
355391
let cube_names = vec!["orders".to_string()];
356392
let mut compiler =
357393
PreAggregationsCompiler::try_new(ctx.query_tools().clone(), &cube_names).unwrap();
358394
let name = PreAggregationFullName::new("orders".to_string(), pre_agg_name.to_string());
359395
let pre_agg = compiler.compile_pre_aggregation(&name).unwrap();
360396

361-
let qp = ctx.create_query_properties(query_yaml).unwrap();
397+
let qp = ctx
398+
.create_query_properties_with_segments(query_yaml, extra_segments)
399+
.unwrap();
362400
let mut matcher = DimensionMatcher::new(ctx.query_tools().query_tools().clone(), &pre_agg);
363401
matcher
364402
.try_match(
@@ -743,6 +781,30 @@ mod tests {
743781
);
744782
}
745783

784+
#[test]
785+
fn test_constant_member_expression_segment_full_match() {
786+
let ctx = create_test_context();
787+
// The ad-hoc `1 = 0` rlsAccessDenied segment references no members, so it's a
788+
// constant filter pushable on top of any rollup and imposes no coverage
789+
// requirement: with both rollup dimensions selected the match stays Full
790+
// (without the fix the bare segment forces NotMatched).
791+
assert_eq!(
792+
match_pre_agg_with_segments(
793+
&ctx,
794+
"main_rollup",
795+
indoc! {"
796+
measures:
797+
- orders.count
798+
dimensions:
799+
- orders.status
800+
- orders.city
801+
"},
802+
vec![const_expr_segment("rlsAccessDenied", "orders", "1 = 0")],
803+
),
804+
MatchState::Full,
805+
);
806+
}
807+
746808
#[test]
747809
fn test_segment_not_matched_missing_in_pre_agg() {
748810
let ctx = create_test_context();

rust/cube/cubesqlplanner/cubesqlplanner/src/planner/filter/base_segment.rs

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,9 @@ pub struct BaseSegment {
1414
member_evaluator: Rc<MemberSymbol>,
1515
cube_name: String,
1616
name: String,
17+
/// True when this segment is an ad-hoc query-level member expression (no
18+
/// registered `segments:` path), as opposed to a named cube segment.
19+
is_member_expression: bool,
1720
}
1821

1922
impl PartialEq for BaseSegment {
@@ -38,6 +41,7 @@ impl BaseSegment {
3841
None,
3942
vec![cube_name.clone()],
4043
)?;
44+
let is_member_expression = full_name.is_none();
4145
let full_name = full_name.unwrap_or(member_expression_symbol.full_name());
4246
let member_evaluator = MemberSymbol::new_member_expression(member_expression_symbol);
4347

@@ -46,8 +50,13 @@ impl BaseSegment {
4650
member_evaluator,
4751
cube_name,
4852
name,
53+
is_member_expression,
4954
}))
5055
}
56+
57+
pub fn is_member_expression(&self) -> bool {
58+
self.is_member_expression
59+
}
5160
pub fn full_name(&self) -> String {
5261
self.full_name.clone()
5362
}

rust/cube/cubesqlplanner/cubesqlplanner/src/test_fixtures/test_utils/test_context.rs

Lines changed: 38 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
use crate::cube_bridge::base_query_options::{BaseQueryOptions, MaskedMemberItem};
22
use crate::cube_bridge::join_hints::JoinHintItem;
3+
use crate::cube_bridge::options_member::OptionsMember;
34
use crate::logical_plan::PreAggregationUsage;
45
#[cfg(feature = "integration-postgres")]
56
use crate::logical_plan::{PreAggregation, PreAggregationSource, PreAggregationTable};
@@ -347,6 +348,17 @@ impl TestContext {
347348
///
348349
/// Panics if YAML cannot be parsed.
349350
pub fn create_query_options_from_yaml(&self, yaml: &str) -> Rc<dyn BaseQueryOptions> {
351+
self.create_query_options_from_yaml_with_segments(yaml, vec![])
352+
}
353+
354+
/// Like `create_query_options_from_yaml`, but appends extra (typically
355+
/// member-expression) segments that can't be expressed as YAML member
356+
/// names — e.g. the constant `1 = 0` `rlsAccessDenied` segment RBAC injects.
357+
pub fn create_query_options_from_yaml_with_segments(
358+
&self,
359+
yaml: &str,
360+
extra_segments: Vec<OptionsMember>,
361+
) -> Rc<dyn BaseQueryOptions> {
350362
let yaml_options: YamlBaseQueryOptions = serde_yaml::from_str(yaml)
351363
.unwrap_or_else(|e| panic!("Failed to parse YAML query options: {}", e));
352364

@@ -360,10 +372,14 @@ impl TestContext {
360372
.map(|d| members_from_strings(d))
361373
.filter(|d| !d.is_empty());
362374

363-
let segments = yaml_options
364-
.segments
365-
.map(|s| members_from_strings(s))
366-
.filter(|s| !s.is_empty());
375+
let segments = {
376+
let mut segments = yaml_options
377+
.segments
378+
.map(|s| members_from_strings(s))
379+
.unwrap_or_default();
380+
segments.extend(extra_segments);
381+
Some(segments).filter(|s| !s.is_empty())
382+
};
367383

368384
let order = yaml_options
369385
.order
@@ -444,7 +460,15 @@ impl TestContext {
444460
}
445461

446462
pub fn create_query_properties(&self, yaml: &str) -> Result<Rc<QueryProperties>, CubeError> {
447-
let options = self.create_query_options_from_yaml(yaml);
463+
self.create_query_properties_with_segments(yaml, vec![])
464+
}
465+
466+
pub fn create_query_properties_with_segments(
467+
&self,
468+
yaml: &str,
469+
extra_segments: Vec<OptionsMember>,
470+
) -> Result<Rc<QueryProperties>, CubeError> {
471+
let options = self.create_query_options_from_yaml_with_segments(yaml, extra_segments);
448472
QueryPropertiesCompiler::new(self.query_tools.clone()).build(options)
449473
}
450474

@@ -469,7 +493,15 @@ impl TestContext {
469493
&self,
470494
query: &str,
471495
) -> Result<(String, Vec<PreAggregationUsage>), cubenativeutils::CubeError> {
472-
let options = self.create_query_options_from_yaml(query);
496+
self.build_sql_with_used_pre_aggregations_with_segments(query, vec![])
497+
}
498+
499+
pub fn build_sql_with_used_pre_aggregations_with_segments(
500+
&self,
501+
query: &str,
502+
extra_segments: Vec<OptionsMember>,
503+
) -> Result<(String, Vec<PreAggregationUsage>), cubenativeutils::CubeError> {
504+
let options = self.create_query_options_from_yaml_with_segments(query, extra_segments);
473505
let ctx = self.for_options(options.as_ref())?;
474506
let request = QueryPropertiesCompiler::new(ctx.query_tools.clone()).build(options)?;
475507
let planner = TopLevelPlanner::new(request, ctx.query_tools.clone(), true);

rust/cube/cubesqlplanner/cubesqlplanner/src/tests/integration/pre_aggregations/sql_generation.rs

Lines changed: 47 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,15 @@
33
//! These tests verify that queries correctly match and use pre-aggregations,
44
//! checking that the generated SQL contains references to pre-aggregation tables.
55
6-
use crate::test_fixtures::cube_bridge::MockSchema;
6+
use crate::cube_bridge::member_expression::MemberExpressionExpressionDef;
7+
use crate::cube_bridge::member_sql::MemberSql;
8+
use crate::cube_bridge::options_member::OptionsMember;
9+
use crate::test_fixtures::cube_bridge::{
10+
MockMemberExpressionDefinition, MockMemberSql, MockSchema,
11+
};
712
use crate::test_fixtures::test_utils::TestContext;
813
use indoc::indoc;
14+
use std::rc::Rc;
915

1016
#[tokio::test(flavor = "multi_thread")]
1117
async fn test_basic_pre_agg_sql() {
@@ -495,6 +501,46 @@ async fn test_base_and_calculated_measure_parital_match() {
495501

496502
// --- Segment matching tests ---
497503

504+
// When a cube's access policy denies the queried members, RBAC
505+
// (CompilerApi.applyRowLevelSecurity) appends a member-expression segment
506+
// `{ expression: () => '1 = 0', cubeName, name: 'rlsAccessDenied' }`. It
507+
// references no members (empty dependencies), so it's a constant filter on top
508+
// of the rollup and must not disqualify pre-aggregation matching.
509+
#[tokio::test(flavor = "multi_thread")]
510+
async fn test_constant_member_expression_segment_keeps_pre_aggregation() {
511+
let schema = MockSchema::from_yaml_file("common/pre_aggregation_matching_test.yaml")
512+
.only_pre_aggregations(&["main_rollup"]);
513+
let ctx = TestContext::new(schema).unwrap();
514+
515+
let query_yaml = indoc! {"
516+
measures:
517+
- orders.count
518+
dimensions:
519+
- orders.status
520+
"};
521+
522+
let access_denied_segment = {
523+
let sql: Rc<dyn MemberSql> = Rc::new(MockMemberSql::new("1 = 0").unwrap());
524+
let expr = MockMemberExpressionDefinition::builder()
525+
.expression_name(Some("rlsAccessDenied".to_string()))
526+
.cube_name(Some("orders".to_string()))
527+
.expression(MemberExpressionExpressionDef::Sql(sql))
528+
.build();
529+
OptionsMember::MemberExpression(Rc::new(expr))
530+
};
531+
532+
let (sql, pre_aggrs) = ctx
533+
.build_sql_with_used_pre_aggregations_with_segments(query_yaml, vec![access_denied_segment])
534+
.unwrap();
535+
536+
assert_eq!(pre_aggrs.len(), 1);
537+
assert_eq!(pre_aggrs[0].name(), "main_rollup");
538+
assert!(
539+
sql.contains("1 = 0"),
540+
"expected the constant access-denied segment in SQL, got:\n{sql}"
541+
);
542+
}
543+
498544
#[tokio::test(flavor = "multi_thread")]
499545
async fn test_segment_full_match() {
500546
let schema = MockSchema::from_yaml_file("common/pre_aggregation_matching_test.yaml")

0 commit comments

Comments
 (0)