feat: conditional data masking with row-level filters in access policies#10803
feat: conditional data masking with row-level filters in access policies#10803paveltiunov merged 16 commits intomasterfrom
Conversation
|
Claude finished @paveltiunov's task in 4m 52s —— View job Code Review: Conditional Data Masking with Row-Level Filters
SummaryThe approach is sound — using CriticalAND vs OR when combining filters across multiple conditional policies ( When multiple policies grant conditional full access (e.g., Policy A for CASE WHEN (data_region = 'RESEARCH' AND data_region = 'DEMO') THEN original ELSE masked ENDThis is always false — no row matches both. The correct behavior is OR across policies (any policy granting access suffices), AND within a policy (all filters in one policy must match). This is consistent with how row-level security elsewhere uses union (see the "R1 ∪ R2 rows visible" diagram at line 617). See inline comment with suggested fix. Medium
Minor / Suggestions
TestingThe 4 new tests cover the core cases well (basic CASE WHEN, AND filters, custom mask, no-filter fallback). Missing scenarios:
What Looks Good
|
| if (conditionalFullAccessPolicies.length > 0 && policiesWithMemberAccess.length > 0) { | ||
| maskedMembersSet.add(memberName); | ||
| const evaluatedFilters = conditionalFullAccessPolicies.flatMap( | ||
| policy => (policy.rowLevel.filters || []).map( | ||
| (filter: any) => this.evaluateNestedFilter(filter, cube, context, cubeEvaluator) | ||
| ) | ||
| ); | ||
| if (evaluatedFilters.length > 0) { | ||
| memberMaskFiltersMap[memberName] = evaluatedFilters.length === 1 | ||
| ? evaluatedFilters[0] | ||
| : { and: evaluatedFilters }; | ||
| } |
There was a problem hiding this comment.
Bug (Critical): Filters from multiple policies should be OR'd, not AND'd
When multiple conditional full-access policies exist (e.g., Policy A grants full access for data_region = 'RESEARCH' rows, Policy B grants full access for data_region = 'DEMO' rows), the member should be unmasked when either filter matches.
The current code flatMaps all filters from all policies into a single list and combines them with { and: evaluatedFilters }. This produces:
CASE WHEN (data_region = 'RESEARCH' AND data_region = 'DEMO') THEN ...This is always false — no row can match both simultaneously.
The correct logic should OR across policies (any policy granting access is sufficient) while ANDing within a policy (all filters within one policy must match):
| if (conditionalFullAccessPolicies.length > 0 && policiesWithMemberAccess.length > 0) { | |
| maskedMembersSet.add(memberName); | |
| const evaluatedFilters = conditionalFullAccessPolicies.flatMap( | |
| policy => (policy.rowLevel.filters || []).map( | |
| (filter: any) => this.evaluateNestedFilter(filter, cube, context, cubeEvaluator) | |
| ) | |
| ); | |
| if (evaluatedFilters.length > 0) { | |
| memberMaskFiltersMap[memberName] = evaluatedFilters.length === 1 | |
| ? evaluatedFilters[0] | |
| : { and: evaluatedFilters }; | |
| } | |
| if (conditionalFullAccessPolicies.length > 0 && policiesWithMemberAccess.length > 0) { | |
| maskedMembersSet.add(memberName); | |
| const policyFilterGroups = conditionalFullAccessPolicies.map( | |
| policy => { | |
| const filters = (policy.rowLevel.filters || []).map( | |
| (filter: any) => this.evaluateNestedFilter(filter, cube, context, cubeEvaluator) | |
| ); | |
| return filters.length === 1 ? filters[0] : { and: filters }; | |
| } | |
| ); | |
| if (policyFilterGroups.length > 0) { | |
| memberMaskFiltersMap[memberName] = policyFilterGroups.length === 1 | |
| ? policyFilterGroups[0] | |
| : { or: policyFilterGroups }; | |
| } |
This is consistent with how row-level security elsewhere in this file uses union (OR) between policies — see the "R1 ∪ R2 rows visible" diagram at line 617.
| return this.defaultMaskSql(symbol.type); | ||
| } | ||
|
|
||
| conditionalMemberMaskSql(cubeName, name, symbol, memberPathArray, maskFilter) { |
There was a problem hiding this comment.
Nit: Unused parameter memberPathArray
This parameter is accepted but never used in the method body. Consider removing it.
| conditionalMemberMaskSql(cubeName, name, symbol, memberPathArray, maskFilter) { | |
| conditionalMemberMaskSql(cubeName, name, symbol, maskFilter) { |
| subqueryJoins: Joi.array().items(subqueryJoin), | ||
| joinHints: Joi.array().items(joinHint), | ||
| maskedMembers: Joi.array().items(Joi.string()), | ||
| memberMaskFilters: Joi.object().pattern(Joi.string(), Joi.any()), |
There was a problem hiding this comment.
Security: Joi.any() is very permissive for filter values
While memberMaskFilters is server-generated (from CompilerApi), Joi.any() provides no structural validation. If this ever becomes reachable from external input, arbitrary objects would pass validation.
Consider adding a recursive filter schema that validates the expected structure ({member, operator, values} or {and: [...]} / {or: [...]}), similar to how queryFilter is validated elsewhere in this file.
| rowLimit?: null | number; | ||
| order?: { id: string; desc: boolean }[]; | ||
| maskedMembers?: string[]; | ||
| memberMaskFilters?: Record<string, any>; |
There was a problem hiding this comment.
Suggestion: Tighten the type
Record<string, any> loses all type information about the filter structure. Consider defining a proper filter type:
interface MaskFilter {
member?: string;
operator?: string;
values?: string[];
and?: MaskFilter[];
or?: MaskFilter[];
}Then: memberMaskFilters?: Record<string, MaskFilter>;
| conditionalMemberMaskSql(cubeName, name, symbol, memberPathArray, maskFilter) { | ||
| const maskedSql = this.memberMaskSql(cubeName, name, symbol); | ||
| const filterSql = this.maskFilterToSql(maskFilter); | ||
| if (!filterSql) { | ||
| return maskedSql; | ||
| } | ||
| const originalSql = this.autoPrefixAndEvaluateSql(cubeName, symbol.sql); | ||
| return `CASE WHEN ${filterSql} THEN ${originalSql} ELSE ${maskedSql} END`; | ||
| } |
There was a problem hiding this comment.
Note: Time dimensions with conditional masking skip timezone conversion
When a time dimension has a conditional mask, the originalSql in the THEN branch is produced via autoPrefixAndEvaluateSql, which does not apply convertTz. The normal (unmasked) path for time dimensions applies convertTz (see around line 3456). This could produce inconsistent timezone behavior for conditionally masked time dimensions.
This may be an edge case in practice, but worth noting for correctness.
| expect(sql).not.toContain('CASE WHEN'); | ||
| expect(sql).toContain('NULL'); | ||
| }); | ||
| }); |
There was a problem hiding this comment.
Testing gap: Missing test for multiple conditional policies
This test suite doesn't include a scenario where multiple conditional full-access policies contribute filters for the same member. Such a test would expose the AND vs OR issue noted in CompilerApi.ts.
Additionally, consider adding tests for:
orfilter combinations (themaskFilterToSqlmethod supports them but they're untested at the CompilerApi level)- Measures with conditional masking (interaction with ungrouped query path)
- An integration test through
CompilerApi.applyRLSandMasking()that exercises the full policy evaluation, not just BaseQuery with pre-computedmemberMaskFilters
Codecov Report❌ Patch coverage is Additional details and impacted files@@ Coverage Diff @@
## master #10803 +/- ##
==========================================
- Coverage 78.79% 78.79% -0.01%
==========================================
Files 470 470
Lines 92135 92179 +44
Branches 3424 3436 +12
==========================================
+ Hits 72597 72630 +33
- Misses 19035 19046 +11
Partials 503 503
Flags with carried forward coverage won't be shown. Click here to find out more. ☔ View full report in Codecov by Sentry. 🚀 New features to boost your workflow:
|
|
Claude finished @paveltiunov's task in 3m 44s —— View job Code Review: Conditional Data Masking with Row-Level Filters
OverviewThe approach is sound — using CriticalAND vs OR when combining filters across multiple conditional policies (CompilerApi.ts:724-732) This was identified in the first review and remains unfixed. When multiple policies grant conditional full access, their filters are MediumTime dimension timezone handling (BaseQuery.js:3509): The THEN branch uses Minor / Suggestions
What Looks Good
TestingThe 4 new unit tests cover the key scenarios well (basic CASE WHEN, AND filters, custom mask, no-filter fallback), plus a good recursion guard test. The smoke test provides integration coverage. Missing test scenarios:
SummaryThe core design is solid, but the AND/OR filter combination bug is critical and should be fixed before merging — it causes conditional masking to be overly aggressive (always masking) when multiple conditional policies exist. The other issues are lower severity and can be addressed at your discretion. |
| const evaluatedFilters = conditionalFullAccessPolicies.flatMap( | ||
| policy => (policy.rowLevel.filters || []).map( | ||
| (filter: any) => this.evaluateNestedFilter(filter, cube, context, cubeEvaluator) | ||
| ) | ||
| ); | ||
| if (evaluatedFilters.length > 0) { | ||
| memberMaskFiltersMap[memberName] = evaluatedFilters.length === 1 | ||
| ? evaluatedFilters[0] | ||
| : { and: evaluatedFilters }; |
There was a problem hiding this comment.
Bug (Critical): Filters from multiple conditional policies should be OR'd, not AND'd
This is the same issue identified in the previous review — it remains unfixed.
When multiple policies grant conditional full access (e.g., Policy A: data_region = 'RESEARCH', Policy B: data_region = 'DEMO'), the member should be unmasked when either filter matches.
flatMap flattens all filters from all policies into a single list and { and: evaluatedFilters } combines them all with AND. Example with two policies:
- Policy A: filters =
[region = 'RESEARCH', lock = 0] - Policy B: filters =
[region = 'DEMO']
Current result: region = 'RESEARCH' AND lock = 0 AND region = 'DEMO' — always false.
Correct result: (region = 'RESEARCH' AND lock = 0) OR (region = 'DEMO')
The correct logic is: AND within a single policy's filters, OR across policies. This is consistent with how row-level security uses union (OR) between policies — see the "R1 ∪ R2 rows visible" diagram at line 618.
| const evaluatedFilters = conditionalFullAccessPolicies.flatMap( | |
| policy => (policy.rowLevel.filters || []).map( | |
| (filter: any) => this.evaluateNestedFilter(filter, cube, context, cubeEvaluator) | |
| ) | |
| ); | |
| if (evaluatedFilters.length > 0) { | |
| memberMaskFiltersMap[memberName] = evaluatedFilters.length === 1 | |
| ? evaluatedFilters[0] | |
| : { and: evaluatedFilters }; | |
| const policyFilterGroups = conditionalFullAccessPolicies.map( | |
| policy => { | |
| const filters = (policy.rowLevel.filters || []).map( | |
| (filter: any) => this.evaluateNestedFilter(filter, cube, context, cubeEvaluator) | |
| ); | |
| return filters.length === 1 ? filters[0] : { and: filters }; | |
| } | |
| ); | |
| if (policyFilterGroups.length > 0) { | |
| memberMaskFiltersMap[memberName] = policyFilterGroups.length === 1 | |
| ? policyFilterGroups[0] | |
| : { or: policyFilterGroups }; | |
| } |
| return this.defaultMaskSql(symbol.type); | ||
| } | ||
|
|
||
| conditionalMemberMaskSql(cubeName, name, symbol, memberPathArray, maskFilter) { |
There was a problem hiding this comment.
Nit: Unused parameter memberPathArray
This parameter is accepted but never referenced in the method body.
| conditionalMemberMaskSql(cubeName, name, symbol, memberPathArray, maskFilter) { | |
| conditionalMemberMaskSql(cubeName, name, symbol, maskFilter) { |
And the call site at line 3319 should be updated accordingly:
return this.conditionalMemberMaskSql(cubeName, name, symbol, maskFilter);| if (!filterSql) { | ||
| return maskedSql; | ||
| } | ||
| const originalSql = this.autoPrefixAndEvaluateSql(cubeName, symbol.sql); |
There was a problem hiding this comment.
Medium: Time dimensions with conditional masking skip timezone conversion
The originalSql in the THEN branch uses autoPrefixAndEvaluateSql, which does not apply convertTz. The normal (unmasked) path for time dimensions calls convertTz (around line 3465). If a time dimension has a conditional mask, the THEN branch will return a raw timestamp while the unmasked path would return a converted one.
This may be rare in practice (masking time dimensions conditionally), but could produce inconsistent timezone behavior when it does happen.
| @@ -195,7 +195,7 @@ const querySchema = Joi.object().keys({ | |||
| responseFormat: Joi.valid('default', 'compact', 'columnar'), | |||
| subqueryJoins: Joi.array().items(subqueryJoin), | |||
| joinHints: Joi.array().items(joinHint), | |||
There was a problem hiding this comment.
Minor: Validation removed entirely rather than updated
The old maskedMembers: Joi.array().items(Joi.string()) was removed with no replacement for the new { member, filter? }[] format. I understand maskedMembers is server-generated (from CompilerApi), so this is low risk. However, if there's any path where it could be influenced by external input, having no validation at all is less safe than even a basic structural check.
If you want to keep validation:
maskedMembers: Joi.array().items(Joi.object({
member: Joi.string().required(),
filter: Joi.any(),
})),| rowLimit?: null | number; | ||
| order?: { id: string; desc: boolean }[]; | ||
| maskedMembers?: string[]; | ||
| maskedMembers?: { member: string; filter?: any }[]; |
There was a problem hiding this comment.
Suggestion: Define a proper type for the filter
filter?: any loses all type safety. Consider defining a recursive MaskFilter interface:
interface MaskFilter {
member?: string;
operator?: string;
values?: string[];
and?: MaskFilter[];
or?: MaskFilter[];
}Then: maskedMembers?: { member: string; filter?: MaskFilter }[];
This would help catch bugs at compile time (e.g., the AND/OR issue in CompilerApi could be type-checked more easily).
| let filter_item = { | ||
| let mut compiler = query_tools.evaluator_compiler().borrow_mut(); | ||
| let mut filter_compiler = FilterCompiler::new(&mut compiler, query_tools.clone()); | ||
| filter_compiler.add_item(native_filter)?; | ||
| let (dimension_filters, _, _) = filter_compiler.extract_result(); | ||
| if dimension_filters.is_empty() { | ||
| return Ok(None); | ||
| } | ||
| if dimension_filters.len() == 1 { | ||
| dimension_filters.into_iter().next().unwrap() | ||
| } else { | ||
| FilterItem::Group(Rc::new(crate::plan::filter::FilterGroup::new( | ||
| crate::plan::filter::FilterGroupOperator::And, | ||
| dimension_filters, | ||
| ))) | ||
| } | ||
| }; | ||
| let context = Rc::new(VisitorContext::new_with_node_processor( | ||
| query_tools.clone(), | ||
| self.input.clone(), | ||
| )); | ||
| let sql = filter_item.to_sql(templates, context)?; | ||
| if sql.is_empty() { | ||
| Ok(None) | ||
| } else { | ||
| Ok(Some("(NULL)".to_string())) | ||
| Ok(Some(sql)) | ||
| } | ||
| } |
There was a problem hiding this comment.
Note: compile_filter_to_sql uses a fresh VisitorContext without the main query's filter context
The new VisitorContext::new_with_node_processor creates a context with an empty CubeRefEvaluator and no all_filters. This is intentional since we just need member resolution for the mask filter, but it means:
FILTER_PARAMSreferences inside the mask filter's member SQL won't resolve (they'll get emptyCubeRefEvaluator)- If a member's SQL uses
{CUBE}references via cube_ref_evaluator, they won't resolve in this context
This is probably fine for typical filter members (simple column references), but could be surprising for members with complex SQL definitions. Worth documenting as a known limitation.
| it('does not recurse when filter member is also masked', async () => { | ||
| const compilers = prepareYamlCompiler(` | ||
| cubes: | ||
| - name: items | ||
| sql_table: public.items | ||
| dimensions: | ||
| - name: id | ||
| sql: id | ||
| type: number | ||
| primary_key: true | ||
| - name: product_id | ||
| sql: product_id | ||
| type: number | ||
| - name: price | ||
| sql: price | ||
| type: number | ||
| mask: -1 | ||
| measures: | ||
| - name: count | ||
| type: count | ||
| `); | ||
|
|
||
| await compilers.compiler.compile(); | ||
|
|
||
| const query = new PostgresQuery(compilers, { | ||
| measures: ['items.count'], | ||
| dimensions: ['items.product_id', 'items.price'], | ||
| maskedMembers: [ | ||
| { | ||
| member: 'items.product_id', | ||
| filter: { member: 'items.product_id', operator: 'lte', values: ['3'] } | ||
| }, | ||
| { | ||
| member: 'items.price', | ||
| filter: { member: 'items.product_id', operator: 'lte', values: ['3'] } | ||
| }, | ||
| ], | ||
| }); | ||
| const [sql] = query.buildSqlAndParams(); | ||
| expect(sql).toContain('CASE WHEN'); | ||
| expect(sql).toMatch(/product_id/); | ||
| expect(sql).not.toMatch(/Maximum call stack/); | ||
| }); | ||
| }); |
There was a problem hiding this comment.
Good: Infinite recursion test
This test is well-constructed — it verifies the skipMasking context flag works when the filter's member (items.product_id) is itself masked. This was a real bug that was caught and fixed in commit 169c640.
Missing test: Consider adding a test for multiple conditional policies with different filters for the same member — this would exercise the AND/OR combination logic in CompilerApi.ts and would catch the bug noted above.
When an access policy grants full member_level access with row_level
filters, and another policy provides masked access, the masking should
be conditional on the row filter. Previously, masking was skipped
entirely when any policy granted full access, even if that access was
restricted by row-level filters.
Now, masked members with conditional full access generate:
CASE WHEN {rowFilter} THEN {originalValue} ELSE {maskedValue} END
This ensures that rows matching the row filter see unmasked values,
while rows outside the filter range see masked values.
Changes:
- CompilerApi: Distinguish unconditional vs conditional full access
when determining masking. Track row filters for conditionally
accessible members as memberMaskFilters.
- BaseQuery: Add conditionalMemberMaskSql() and maskFilterToSql()
methods to generate CASE WHEN SQL for masked members with
associated row filters.
- NormalizedQuery type: Add memberMaskFilters field.
- Tests: Add 4 new tests for conditional masking behavior.
Co-authored-by: Pavel Tiunov <pavel.tiunov@gmail.com>
Instead of a separate memberMaskFilters field, extend maskedMembers to
support both string and {member, filter} object formats:
maskedMembers: (string | { member: string, filter: FilterItem })[]
This ensures Tesseract/native SQL planner also supports conditional
masking. The Rust MaskedSqlNode now generates CASE WHEN SQL when a
member has an associated mask filter.
Changes:
- CompilerApi: Emit maskedMembers with inline filter objects
- BaseQuery.js: Parse both formats from maskedMembers array
- Rust (base_query_options.rs): Add MaskedMemberItem enum (untagged)
- Rust (query_tools.rs): Store mask filters alongside masked members
- Rust (masked.rs): Generate CASE WHEN for conditional masks
- Rust test fixtures: Update to use MaskedMemberItem type
- NormalizedQuery type: Update maskedMembers type signature
- query.js: Update Joi validation for new format
- smoke-rbac.test.ts: Add conditional masking test case
- conditional_masking_test.yaml: Add fixture for smoke test
Co-authored-by: Pavel Tiunov <pavel.tiunov@gmail.com>
- Fix infinite recursion in conditionalMemberMaskSql by adding skipMaskFor context guard to prevent re-entrance - Fix CompilerApi logic: only apply conditional masking when a memberMasking policy actually exists (prevents false positive masking for cubes that only have row_level filters without memberMasking) - Fix lint: object-property-newline and object-curly-spacing in tests - Run cargo fmt on all Rust files Co-authored-by: Pavel Tiunov <pavel.tiunov@gmail.com>
BaseQuery.js: Replace custom and/or rendering in maskFilterToSql with the standard extractFiltersAsTree + initFilter + filterToWhere() pipeline that all other filters use. Rust MaskedSqlNode: Replace custom render_native_filter + render_filter_condition with FilterCompiler::add_item + FilterItem::to_sql using a standard VisitorContext - the same approach used by QueryProperties and Select for all other filter rendering. Co-authored-by: Pavel Tiunov <pavel.tiunov@gmail.com>
- CompilerApi.ts: Replace continue statements with nested if/else to satisfy the no-continue lint rule - Rust MaskedSqlNode: Fix RefCell already borrowed panic by using VisitorContext::new_with_node_processor with self.input (the underlying EvaluateSqlNode) instead of SqlNodesFactory::new() which creates a node processor chain that includes MaskedSqlNode, causing re-entrant borrow when evaluating filter member SQL - Add VisitorContext::new_with_node_processor constructor for creating contexts with custom node processors Co-authored-by: Pavel Tiunov <pavel.tiunov@gmail.com>
The stack overflow occurred when a mask filter referenced a member that was itself masked (e.g., product_id has a mask filter on product_id). The evaluateSymbolSql for the filter member would re-enter the masking code, creating infinite recursion. Fix: Use skipMasking context flag to disable all masking during CASE WHEN evaluation (both the filter SQL and the original value SQL). Also set currentMember to null to prevent memberChildren cycle tracking that caused hasMultiStageMembers infinite loop. Added test case that verifies no recursion when filter member is masked. Co-authored-by: Pavel Tiunov <pavel.tiunov@gmail.com>
…lidation
- maskedMembers now always uses { member: string, filter?: FilterItem }
objects instead of string | object alternatives
- Remove maskedMembers from Joi query validation schema since users
should never provide masking params in the query directly
- Rust MaskedMemberItem: replace untagged enum with simple struct
- Update all consumers and tests to use object format
Co-authored-by: Pavel Tiunov <pavel.tiunov@gmail.com>
The normalizeQuery validation runs after applyRowLevelSecurity sets maskedMembers on the query, so the field must be allowed in the schema. Use Joi.array().items(Joi.object()) to accept the internal format without exposing strict typing to users. Co-authored-by: Pavel Tiunov <pavel.tiunov@gmail.com>
- Strip maskedMembers from user query before first normalizeQuery validation so users cannot inject masking params - After queryRewrite, always restore maskedMembers from the post-RLS query, ensuring rewrites cannot override RLS-determined masking Co-authored-by: Pavel Tiunov <pavel.tiunov@gmail.com>
Instead of silently stripping maskedMembers, throw a UserError if the user attempts to pass it in their query. The Joi schema keeps the correct type definition for internal validation after RLS sets it. Co-authored-by: Pavel Tiunov <pavel.tiunov@gmail.com>
BaseQuery.js: Use caseWhenStatement() method (same template used by measure filters and dimension case types) instead of inline template string. Rust MaskedSqlNode: Use templates.case() from PlanSqlTemplates (same template used by CaseSqlNode for dimension case rendering) instead of inline format! string. Co-authored-by: Pavel Tiunov <pavel.tiunov@gmail.com>
Instead of manually joining clauses with ' AND ', create a proper and-group filter via newGroupFilter when there are multiple filter items, reusing the standard BaseGroupFilter.filterToWhere() rendering. Co-authored-by: Pavel Tiunov <pavel.tiunov@gmail.com>
964b369 to
2835b7d
Compare
Co-authored-by: Pavel Tiunov <pavel.tiunov@gmail.com>
d42113f to
23e71cb
Compare
When multiple policies grant conditional full access, the member should be unmasked when ANY policy's filter matches (OR), not when ALL match (AND). Within a single policy, multiple filters are still AND'd. Example with two policies: Policy A: filters = [region = 'RESEARCH', lock = 0] Policy B: filters = [region = 'DEMO'] Before: region = 'RESEARCH' AND lock = 0 AND region = 'DEMO' (always false) After: (region = 'RESEARCH' AND lock = 0) OR (region = 'DEMO') This is consistent with row-level security which uses union (OR) across policies for row visibility. Co-authored-by: Pavel Tiunov <pavel.tiunov@gmail.com>
Adds a test that verifies when a user has two conditional mask roles: - conditional_mask_role: product_id <= 3 - conditional_mask_role_extra: product_id = 5 The price is unmasked when EITHER filter matches (OR across policies), confirming the fix for the AND vs OR bug. Co-authored-by: Pavel Tiunov <pavel.tiunov@gmail.com>
…berMaskSql Co-authored-by: Pavel Tiunov <pavel.tiunov@gmail.com>
Check List
Description of Changes Made
When an access policy grants full
member_levelaccess withrow_levelfilters, and another policy provides masked access, the masking should be conditional on the row filter. Previously, masking was skipped entirely when any policy granted "full access" for a member, even if that full access was gated by row-level filters.Problem
Given an access policy like:
The
very_sensitive_data_accesspolicy grants full access but only to rows matching the filter. Previously, the system saw "full access exists in some policy" and skipped masking entirely — exposing unmasked values for all rows, including those outside the filter.Solution
The
maskedMemberstype is extended to support both simple strings and objects with filter info:When a member has an associated filter, the generated SQL uses:
CASE WHEN {rowFilter} THEN {originalValue} ELSE {maskedValue} ENDThis ensures:
Tesseract Support
The Rust native SQL planner (
MaskedSqlNode) is updated to support the newmaskedMembersformat withMaskedMemberItemenum (untagged serde), and generatesCASE WHENSQL for conditional masks.Changes
packages/cubejs-server-core/src/core/CompilerApi.tsmaskedMemberswith inline filter objectspackages/cubejs-schema-compiler/src/adapter/BaseQuery.jsmaskedMembers; addconditionalMemberMaskSql()andmaskFilterToSql()packages/cubejs-api-gateway/src/types/query.tsmaskedMemberstype to(string | { member, filter })[]packages/cubejs-api-gateway/src/query.jsrust/.../base_query_options.rsMaskedMemberItemenum with untagged serderust/.../query_tools.rsrust/.../masked.rsCASE WHENfor conditional masks inMaskedSqlNoderust/.../test_fixtures/MaskedMemberItemtypepackages/cubejs-testing/.../conditional_masking_test.yamlpackages/cubejs-testing/test/smoke-rbac.test.tspackages/cubejs-testing/birdbox-fixtures/rbac/cube.jsconditional_mask_userauthpackages/cubejs-schema-compiler/test/unit/yaml-schema.test.tsTest Results