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
6 changes: 6 additions & 0 deletions packages/cubejs-api-gateway/src/gateway.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1343,6 +1343,10 @@ class ApiGateway {
currentQuery = this.parseMemberExpressionsInQuery(currentQuery);
}

if ((currentQuery as any).maskedMembers) {
throw new UserError('maskedMembers cannot be provided in the query');
}

return {
normalizedQuery: (normalizeQuery(currentQuery, persistent, cacheMode)),
hasExpressionsInQuery
Expand Down Expand Up @@ -1372,6 +1376,8 @@ class ApiGateway {
context
) : queryWithRlsFilters;

rewrittenQuery.maskedMembers = queryWithRlsFilters.maskedMembers;

// applyRowLevelSecurity may add new filters which may contain raw member expressions
// if that's the case, we should run an extra pass of parsing here to make sure
// nothing breaks down the road
Expand Down
5 changes: 4 additions & 1 deletion packages/cubejs-api-gateway/src/query.js
Original file line number Diff line number Diff line change
Expand Up @@ -195,7 +195,10 @@ const querySchema = Joi.object().keys({
responseFormat: Joi.valid('default', 'compact', 'columnar'),
subqueryJoins: Joi.array().items(subqueryJoin),
joinHints: Joi.array().items(joinHint),
maskedMembers: Joi.array().items(Joi.string()),
maskedMembers: Joi.array().items(Joi.object().keys({
member: Joi.string().required(),
filter: Joi.object(),
})),
});

const normalizeQueryOrder = order => {
Expand Down
2 changes: 1 addition & 1 deletion packages/cubejs-api-gateway/src/types/query.ts
Original file line number Diff line number Diff line change
Expand Up @@ -166,7 +166,7 @@ interface NormalizedQuery extends Query {
filters?: NormalizedQueryFilter[];
rowLimit?: null | number;
order?: { id: string; desc: boolean }[];
maskedMembers?: string[];
maskedMembers?: { member: string; filter?: any }[];
}

export {
Expand Down
47 changes: 45 additions & 2 deletions packages/cubejs-schema-compiler/src/adapter/BaseQuery.js
Original file line number Diff line number Diff line change
Expand Up @@ -253,7 +253,14 @@ export class BaseQuery {
securityContext: {},
...this.options.contextSymbols,
};
this.maskedMembers = new Set(this.options.maskedMembers || []);
this.maskedMembers = new Set();
this.memberMaskFilters = {};
for (const item of this.options.maskedMembers || []) {
this.maskedMembers.add(item.member);
if (item.filter) {
this.memberMaskFilters[item.member] = item.filter;
}
}
this.compilerCache = this.compilers.compiler.compilerCache;
this.queryCache = this.compilerCache.getQueryCache({
measures: this.options.measures,
Expand Down Expand Up @@ -3299,13 +3306,18 @@ export class BaseQuery {

this.safeEvaluateSymbolContext().currentMember = memberPath;
try {
if (this.maskedMembers && this.maskedMembers.has(memberPath) && !memberExpressionType) {
if (this.maskedMembers && this.maskedMembers.has(memberPath) && !memberExpressionType &&
!this.safeEvaluateSymbolContext().skipMasking) {
// In ungrouped queries, only apply static masks to measures.
// SQL masks (mask.sql) reference columns that don't apply per-row.
const isMeasure = type === 'measure';
const isUngrouped = this.options.ungrouped;
const hasSqlMask = symbol.mask && typeof symbol.mask === 'object' && symbol.mask.sql;
if (!isMeasure || !isUngrouped || !hasSqlMask) {
const maskFilter = this.memberMaskFilters && this.memberMaskFilters[memberPath];
if (maskFilter) {
return this.conditionalMemberMaskSql(cubeName, name, symbol, maskFilter);
}
return this.memberMaskSql(cubeName, name, symbol);
}
}
Expand Down Expand Up @@ -3486,6 +3498,37 @@ export class BaseQuery {
return this.defaultMaskSql(symbol.type);
}

conditionalMemberMaskSql(cubeName, name, symbol, maskFilter) {
const maskedSql = this.memberMaskSql(cubeName, name, symbol);
const result = this.evaluateSymbolSqlWithContext(
() => {
const filterSql = this.maskFilterToSql(maskFilter);
if (!filterSql) {
return maskedSql;
}
const originalSql = this.autoPrefixAndEvaluateSql(cubeName, symbol.sql);
return this.caseWhenStatement([{ sql: filterSql, label: originalSql }], maskedSql);
},
{ skipMasking: true, currentMember: null }
);
return result;
}

maskFilterToSql(filter) {
if (!filter) return null;
const filterItems = this.extractFiltersAsTree([filter]);
if (!filterItems.length) return null;
const initialized = filterItems.map(this.initFilter.bind(this));
if (initialized.length === 1) {
return initialized[0].filterToWhere();
}
const groupFilter = this.newGroupFilter({
operator: 'and',
values: initialized,
});
return groupFilter.filterToWhere();
}

defaultMaskSql(memberType) {
const envMasks = {
string: getEnv('accessPolicyMaskString'),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -410,7 +410,7 @@ describe('Transpilers', () => {
{
measures: ['Test.count'],
dimensions: ['Test.secret'],
maskedMembers: ['Test.secret'],
maskedMembers: [{ member: 'Test.secret' }],
}
);
const sql = query.buildSqlAndParams();
Expand Down
214 changes: 212 additions & 2 deletions packages/cubejs-schema-compiler/test/unit/yaml-schema.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1665,7 +1665,7 @@ cubes:
{
measures: ['orders.count'],
dimensions: ['orders.status'],
maskedMembers: ['orders.status'],
maskedMembers: [{ member: 'orders.status' }],
contextSymbols: {
securityContext: { cubeCloud: { userAttributes: { hasStatusAccess: true } } }
}
Expand Down Expand Up @@ -1806,7 +1806,7 @@ views:
const query = new PostgresQuery(compilers, {
measures: ['users_secure_view.users_count'],
dimensions: ['users_secure_view.users_city_sensitive_masked'],
maskedMembers: ['users_secure_view.users_city_sensitive_masked'],
maskedMembers: [{ member: 'users_secure_view.users_city_sensitive_masked' }],
contextSymbols: {
securityContext: { cubeCloud: { groups } }
},
Expand Down Expand Up @@ -1845,4 +1845,214 @@ views:
});
});
});

describe('Conditional masking with row-level filters (memberMaskFilters)', () => {
it('generates CASE WHEN with row filter for masked members that have conditional full access', async () => {
const compilers = prepareYamlCompiler(`
cubes:
- name: users
sql_table: public.users
dimensions:
- name: id
sql: id
type: number
primary_key: true
- name: city
sql: city
type: string
- name: data_region
sql: data_region
type: string
measures:
- name: count
type: count
`);

await compilers.compiler.compile();

const query = new PostgresQuery(compilers, {
measures: ['users.count'],
dimensions: ['users.city'],
maskedMembers: [{
member: 'users.city',
filter: {
member: 'users.data_region',
operator: 'equals',
values: ['RESEARCH', 'DEMO'],
}
}],
});
const [sql] = query.buildSqlAndParams();
expect(sql).toMatch(/CASE\s+WHEN/);
expect(sql).toMatch(/WHEN.*data_region.*THEN.*city.*ELSE.*NULL.*END/s);
});

it('generates CASE WHEN with AND row filter for multiple filter conditions', async () => {
const compilers = prepareYamlCompiler(`
cubes:
- name: users
sql_table: public.users
dimensions:
- name: id
sql: id
type: number
primary_key: true
- name: city
sql: city
type: string
- name: data_region
sql: data_region
type: string
- name: region_lock
sql: region_lock
type: number
measures:
- name: count
type: count
`);

await compilers.compiler.compile();

const query = new PostgresQuery(compilers, {
measures: ['users.count'],
dimensions: ['users.city'],
maskedMembers: [{
member: 'users.city',
filter: {
and: [
{
member: 'users.data_region',
operator: 'equals',
values: ['RESEARCH'],
},
{
member: 'users.region_lock',
operator: 'equals',
values: ['0'],
}
]
}
}],
});
const [sql] = query.buildSqlAndParams();
expect(sql).toMatch(/CASE\s+WHEN/);
expect(sql).toMatch(/WHEN.*AND.*THEN.*city.*ELSE.*NULL.*END/s);
});

it('uses mask.sql as the ELSE branch when dimension has a custom mask', async () => {
const compilers = prepareYamlCompiler(`
cubes:
- name: users
sql_table: public.users
dimensions:
- name: id
sql: id
type: number
primary_key: true
- name: city
sql: city
type: string
mask:
sql: "'***MASKED***'"
- name: data_region
sql: data_region
type: string
measures:
- name: count
type: count
`);

await compilers.compiler.compile();

const query = new PostgresQuery(compilers, {
measures: ['users.count'],
dimensions: ['users.city'],
maskedMembers: [{
member: 'users.city',
filter: {
member: 'users.data_region',
operator: 'equals',
values: ['RESEARCH'],
}
}],
});
const [sql] = query.buildSqlAndParams();
expect(sql).toMatch(/CASE\s+WHEN/);
expect(sql).toMatch(/WHEN.*data_region.*THEN.*city.*ELSE.*MASKED.*END/s);
});

it('applies regular masking (no CASE WHEN) when no memberMaskFilters', async () => {
const compilers = prepareYamlCompiler(`
cubes:
- name: users
sql_table: public.users
dimensions:
- name: id
sql: id
type: number
primary_key: true
- name: city
sql: city
type: string
measures:
- name: count
type: count
`);

await compilers.compiler.compile();

const query = new PostgresQuery(compilers, {
measures: ['users.count'],
dimensions: ['users.city'],
maskedMembers: [{ member: 'users.city' }],
});
const [sql] = query.buildSqlAndParams();
expect(sql).not.toMatch(/CASE\s+WHEN/);
expect(sql).toContain('NULL');
});

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).toMatch(/CASE\s+WHEN/);
expect(sql).toMatch(/product_id/);
expect(sql).not.toMatch(/Maximum call stack/);
});
});
});
Loading
Loading