Skip to content

Commit 4d3bcbe

Browse files
feat: conditional data masking with row-level filters in access policies (cube-js#10803)
* feat: conditional data masking with row-level filters in access policies 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> * refactor: extend maskedMembers type to carry filter info inline 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: resolve lint, fmt, and stack overflow issues - 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> * refactor: use standard filter pipeline for mask filter SQL generation 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> * fix: resolve lint no-continue errors and Rust RefCell borrow panic - 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> * fix: prevent infinite recursion when filter member is also masked 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> * refactor: use { member, filter? } struct for maskedMembers, remove validation - 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> * fix: keep maskedMembers in Joi schema for internal query validation 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> * fix: prevent maskedMembers injection from user queries and rewrites - 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> * fix: throw error if maskedMembers is provided in user query 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> * refactor: reuse standard CASE WHEN templates for conditional masking 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> * refactor: use BaseGroupFilter for multiple mask filter clauses 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> * docs: add TODO for FILTER_PARAMS support in mask filter SQL Co-authored-by: Pavel Tiunov <pavel.tiunov@gmail.com> * fix: OR across policies, AND within policy for mask filters 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> * test: add smoke test for OR across multiple conditional mask policies 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> * refactor: remove unused memberPathArray parameter from conditionalMemberMaskSql Co-authored-by: Pavel Tiunov <pavel.tiunov@gmail.com> --------- Co-authored-by: Cursor Agent <cursoragent@cursor.com>
1 parent 8f590a3 commit 4d3bcbe

17 files changed

Lines changed: 610 additions & 37 deletions

File tree

packages/cubejs-api-gateway/src/gateway.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1343,6 +1343,10 @@ class ApiGateway {
13431343
currentQuery = this.parseMemberExpressionsInQuery(currentQuery);
13441344
}
13451345

1346+
if ((currentQuery as any).maskedMembers) {
1347+
throw new UserError('maskedMembers cannot be provided in the query');
1348+
}
1349+
13461350
return {
13471351
normalizedQuery: (normalizeQuery(currentQuery, persistent, cacheMode)),
13481352
hasExpressionsInQuery
@@ -1372,6 +1376,8 @@ class ApiGateway {
13721376
context
13731377
) : queryWithRlsFilters;
13741378

1379+
rewrittenQuery.maskedMembers = queryWithRlsFilters.maskedMembers;
1380+
13751381
// applyRowLevelSecurity may add new filters which may contain raw member expressions
13761382
// if that's the case, we should run an extra pass of parsing here to make sure
13771383
// nothing breaks down the road

packages/cubejs-api-gateway/src/query.js

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -195,7 +195,10 @@ const querySchema = Joi.object().keys({
195195
responseFormat: Joi.valid('default', 'compact', 'columnar'),
196196
subqueryJoins: Joi.array().items(subqueryJoin),
197197
joinHints: Joi.array().items(joinHint),
198-
maskedMembers: Joi.array().items(Joi.string()),
198+
maskedMembers: Joi.array().items(Joi.object().keys({
199+
member: Joi.string().required(),
200+
filter: Joi.object(),
201+
})),
199202
});
200203

201204
const normalizeQueryOrder = order => {

packages/cubejs-api-gateway/src/types/query.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -166,7 +166,7 @@ interface NormalizedQuery extends Query {
166166
filters?: NormalizedQueryFilter[];
167167
rowLimit?: null | number;
168168
order?: { id: string; desc: boolean }[];
169-
maskedMembers?: string[];
169+
maskedMembers?: { member: string; filter?: any }[];
170170
}
171171

172172
export {

packages/cubejs-schema-compiler/src/adapter/BaseQuery.js

Lines changed: 45 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -253,7 +253,14 @@ export class BaseQuery {
253253
securityContext: {},
254254
...this.options.contextSymbols,
255255
};
256-
this.maskedMembers = new Set(this.options.maskedMembers || []);
256+
this.maskedMembers = new Set();
257+
this.memberMaskFilters = {};
258+
for (const item of this.options.maskedMembers || []) {
259+
this.maskedMembers.add(item.member);
260+
if (item.filter) {
261+
this.memberMaskFilters[item.member] = item.filter;
262+
}
263+
}
257264
this.compilerCache = this.compilers.compiler.compilerCache;
258265
this.queryCache = this.compilerCache.getQueryCache({
259266
measures: this.options.measures,
@@ -3299,13 +3306,18 @@ export class BaseQuery {
32993306

33003307
this.safeEvaluateSymbolContext().currentMember = memberPath;
33013308
try {
3302-
if (this.maskedMembers && this.maskedMembers.has(memberPath) && !memberExpressionType) {
3309+
if (this.maskedMembers && this.maskedMembers.has(memberPath) && !memberExpressionType &&
3310+
!this.safeEvaluateSymbolContext().skipMasking) {
33033311
// In ungrouped queries, only apply static masks to measures.
33043312
// SQL masks (mask.sql) reference columns that don't apply per-row.
33053313
const isMeasure = type === 'measure';
33063314
const isUngrouped = this.options.ungrouped;
33073315
const hasSqlMask = symbol.mask && typeof symbol.mask === 'object' && symbol.mask.sql;
33083316
if (!isMeasure || !isUngrouped || !hasSqlMask) {
3317+
const maskFilter = this.memberMaskFilters && this.memberMaskFilters[memberPath];
3318+
if (maskFilter) {
3319+
return this.conditionalMemberMaskSql(cubeName, name, symbol, maskFilter);
3320+
}
33093321
return this.memberMaskSql(cubeName, name, symbol);
33103322
}
33113323
}
@@ -3486,6 +3498,37 @@ export class BaseQuery {
34863498
return this.defaultMaskSql(symbol.type);
34873499
}
34883500

3501+
conditionalMemberMaskSql(cubeName, name, symbol, maskFilter) {
3502+
const maskedSql = this.memberMaskSql(cubeName, name, symbol);
3503+
const result = this.evaluateSymbolSqlWithContext(
3504+
() => {
3505+
const filterSql = this.maskFilterToSql(maskFilter);
3506+
if (!filterSql) {
3507+
return maskedSql;
3508+
}
3509+
const originalSql = this.autoPrefixAndEvaluateSql(cubeName, symbol.sql);
3510+
return this.caseWhenStatement([{ sql: filterSql, label: originalSql }], maskedSql);
3511+
},
3512+
{ skipMasking: true, currentMember: null }
3513+
);
3514+
return result;
3515+
}
3516+
3517+
maskFilterToSql(filter) {
3518+
if (!filter) return null;
3519+
const filterItems = this.extractFiltersAsTree([filter]);
3520+
if (!filterItems.length) return null;
3521+
const initialized = filterItems.map(this.initFilter.bind(this));
3522+
if (initialized.length === 1) {
3523+
return initialized[0].filterToWhere();
3524+
}
3525+
const groupFilter = this.newGroupFilter({
3526+
operator: 'and',
3527+
values: initialized,
3528+
});
3529+
return groupFilter.filterToWhere();
3530+
}
3531+
34893532
defaultMaskSql(memberType) {
34903533
const envMasks = {
34913534
string: getEnv('accessPolicyMaskString'),

packages/cubejs-schema-compiler/test/unit/transpilers.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -410,7 +410,7 @@ describe('Transpilers', () => {
410410
{
411411
measures: ['Test.count'],
412412
dimensions: ['Test.secret'],
413-
maskedMembers: ['Test.secret'],
413+
maskedMembers: [{ member: 'Test.secret' }],
414414
}
415415
);
416416
const sql = query.buildSqlAndParams();

packages/cubejs-schema-compiler/test/unit/yaml-schema.test.ts

Lines changed: 212 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1665,7 +1665,7 @@ cubes:
16651665
{
16661666
measures: ['orders.count'],
16671667
dimensions: ['orders.status'],
1668-
maskedMembers: ['orders.status'],
1668+
maskedMembers: [{ member: 'orders.status' }],
16691669
contextSymbols: {
16701670
securityContext: { cubeCloud: { userAttributes: { hasStatusAccess: true } } }
16711671
}
@@ -1806,7 +1806,7 @@ views:
18061806
const query = new PostgresQuery(compilers, {
18071807
measures: ['users_secure_view.users_count'],
18081808
dimensions: ['users_secure_view.users_city_sensitive_masked'],
1809-
maskedMembers: ['users_secure_view.users_city_sensitive_masked'],
1809+
maskedMembers: [{ member: 'users_secure_view.users_city_sensitive_masked' }],
18101810
contextSymbols: {
18111811
securityContext: { cubeCloud: { groups } }
18121812
},
@@ -1845,4 +1845,214 @@ views:
18451845
});
18461846
});
18471847
});
1848+
1849+
describe('Conditional masking with row-level filters (memberMaskFilters)', () => {
1850+
it('generates CASE WHEN with row filter for masked members that have conditional full access', async () => {
1851+
const compilers = prepareYamlCompiler(`
1852+
cubes:
1853+
- name: users
1854+
sql_table: public.users
1855+
dimensions:
1856+
- name: id
1857+
sql: id
1858+
type: number
1859+
primary_key: true
1860+
- name: city
1861+
sql: city
1862+
type: string
1863+
- name: data_region
1864+
sql: data_region
1865+
type: string
1866+
measures:
1867+
- name: count
1868+
type: count
1869+
`);
1870+
1871+
await compilers.compiler.compile();
1872+
1873+
const query = new PostgresQuery(compilers, {
1874+
measures: ['users.count'],
1875+
dimensions: ['users.city'],
1876+
maskedMembers: [{
1877+
member: 'users.city',
1878+
filter: {
1879+
member: 'users.data_region',
1880+
operator: 'equals',
1881+
values: ['RESEARCH', 'DEMO'],
1882+
}
1883+
}],
1884+
});
1885+
const [sql] = query.buildSqlAndParams();
1886+
expect(sql).toMatch(/CASE\s+WHEN/);
1887+
expect(sql).toMatch(/WHEN.*data_region.*THEN.*city.*ELSE.*NULL.*END/s);
1888+
});
1889+
1890+
it('generates CASE WHEN with AND row filter for multiple filter conditions', async () => {
1891+
const compilers = prepareYamlCompiler(`
1892+
cubes:
1893+
- name: users
1894+
sql_table: public.users
1895+
dimensions:
1896+
- name: id
1897+
sql: id
1898+
type: number
1899+
primary_key: true
1900+
- name: city
1901+
sql: city
1902+
type: string
1903+
- name: data_region
1904+
sql: data_region
1905+
type: string
1906+
- name: region_lock
1907+
sql: region_lock
1908+
type: number
1909+
measures:
1910+
- name: count
1911+
type: count
1912+
`);
1913+
1914+
await compilers.compiler.compile();
1915+
1916+
const query = new PostgresQuery(compilers, {
1917+
measures: ['users.count'],
1918+
dimensions: ['users.city'],
1919+
maskedMembers: [{
1920+
member: 'users.city',
1921+
filter: {
1922+
and: [
1923+
{
1924+
member: 'users.data_region',
1925+
operator: 'equals',
1926+
values: ['RESEARCH'],
1927+
},
1928+
{
1929+
member: 'users.region_lock',
1930+
operator: 'equals',
1931+
values: ['0'],
1932+
}
1933+
]
1934+
}
1935+
}],
1936+
});
1937+
const [sql] = query.buildSqlAndParams();
1938+
expect(sql).toMatch(/CASE\s+WHEN/);
1939+
expect(sql).toMatch(/WHEN.*AND.*THEN.*city.*ELSE.*NULL.*END/s);
1940+
});
1941+
1942+
it('uses mask.sql as the ELSE branch when dimension has a custom mask', async () => {
1943+
const compilers = prepareYamlCompiler(`
1944+
cubes:
1945+
- name: users
1946+
sql_table: public.users
1947+
dimensions:
1948+
- name: id
1949+
sql: id
1950+
type: number
1951+
primary_key: true
1952+
- name: city
1953+
sql: city
1954+
type: string
1955+
mask:
1956+
sql: "'***MASKED***'"
1957+
- name: data_region
1958+
sql: data_region
1959+
type: string
1960+
measures:
1961+
- name: count
1962+
type: count
1963+
`);
1964+
1965+
await compilers.compiler.compile();
1966+
1967+
const query = new PostgresQuery(compilers, {
1968+
measures: ['users.count'],
1969+
dimensions: ['users.city'],
1970+
maskedMembers: [{
1971+
member: 'users.city',
1972+
filter: {
1973+
member: 'users.data_region',
1974+
operator: 'equals',
1975+
values: ['RESEARCH'],
1976+
}
1977+
}],
1978+
});
1979+
const [sql] = query.buildSqlAndParams();
1980+
expect(sql).toMatch(/CASE\s+WHEN/);
1981+
expect(sql).toMatch(/WHEN.*data_region.*THEN.*city.*ELSE.*MASKED.*END/s);
1982+
});
1983+
1984+
it('applies regular masking (no CASE WHEN) when no memberMaskFilters', async () => {
1985+
const compilers = prepareYamlCompiler(`
1986+
cubes:
1987+
- name: users
1988+
sql_table: public.users
1989+
dimensions:
1990+
- name: id
1991+
sql: id
1992+
type: number
1993+
primary_key: true
1994+
- name: city
1995+
sql: city
1996+
type: string
1997+
measures:
1998+
- name: count
1999+
type: count
2000+
`);
2001+
2002+
await compilers.compiler.compile();
2003+
2004+
const query = new PostgresQuery(compilers, {
2005+
measures: ['users.count'],
2006+
dimensions: ['users.city'],
2007+
maskedMembers: [{ member: 'users.city' }],
2008+
});
2009+
const [sql] = query.buildSqlAndParams();
2010+
expect(sql).not.toMatch(/CASE\s+WHEN/);
2011+
expect(sql).toContain('NULL');
2012+
});
2013+
2014+
it('does not recurse when filter member is also masked', async () => {
2015+
const compilers = prepareYamlCompiler(`
2016+
cubes:
2017+
- name: items
2018+
sql_table: public.items
2019+
dimensions:
2020+
- name: id
2021+
sql: id
2022+
type: number
2023+
primary_key: true
2024+
- name: product_id
2025+
sql: product_id
2026+
type: number
2027+
- name: price
2028+
sql: price
2029+
type: number
2030+
mask: -1
2031+
measures:
2032+
- name: count
2033+
type: count
2034+
`);
2035+
2036+
await compilers.compiler.compile();
2037+
2038+
const query = new PostgresQuery(compilers, {
2039+
measures: ['items.count'],
2040+
dimensions: ['items.product_id', 'items.price'],
2041+
maskedMembers: [
2042+
{
2043+
member: 'items.product_id',
2044+
filter: { member: 'items.product_id', operator: 'lte', values: ['3'] }
2045+
},
2046+
{
2047+
member: 'items.price',
2048+
filter: { member: 'items.product_id', operator: 'lte', values: ['3'] }
2049+
},
2050+
],
2051+
});
2052+
const [sql] = query.buildSqlAndParams();
2053+
expect(sql).toMatch(/CASE\s+WHEN/);
2054+
expect(sql).toMatch(/product_id/);
2055+
expect(sql).not.toMatch(/Maximum call stack/);
2056+
});
2057+
});
18482058
});

0 commit comments

Comments
 (0)