diff --git a/packages/cubejs-schema-compiler/src/adapter/BaseQuery.js b/packages/cubejs-schema-compiler/src/adapter/BaseQuery.js index 7adad3e85ec0d..186116124c423 100644 --- a/packages/cubejs-schema-compiler/src/adapter/BaseQuery.js +++ b/packages/cubejs-schema-compiler/src/adapter/BaseQuery.js @@ -2846,14 +2846,19 @@ export class BaseQuery { return R.pipe( R.map(f => f.getMembers()), R.flatten, - R.map(s => ( - (cache || this.compilerCache).cache( + R.map(s => { + const memberPath = s.path() ? s.path().join('.') : null; + const hasSqlMask = memberPath && + this.maskedMembers && this.maskedMembers.size > 0 && + this.maskedMembers.has(memberPath) && + s.definition()?.mask && typeof s.definition().mask === 'object' && s.definition().mask.sql; + return (cache || (hasSqlMask ? this.queryCache : this.compilerCache)).cache( ['collectFrom'].concat(methodCacheKey).concat( - s.path() ? [s.path().join('.')] : [s.cube().name, s.expression?.toString() || s.expressionName || s.definition().sql] + memberPath ? [memberPath] : [s.cube().name, s.expression?.toString() || s.expressionName || s.definition().sql] ), () => fn(() => this.traverseSymbol(s)) - ) - )), + ); + }), R.unnest, R.uniq, R.filter(R.identity) diff --git a/packages/cubejs-schema-compiler/test/unit/transpilers.test.ts b/packages/cubejs-schema-compiler/test/unit/transpilers.test.ts index 6493b6305757b..c2ec0c71a8e39 100644 --- a/packages/cubejs-schema-compiler/test/unit/transpilers.test.ts +++ b/packages/cubejs-schema-compiler/test/unit/transpilers.test.ts @@ -353,6 +353,70 @@ describe('Transpilers', () => { expect(transpiledSql!.toString()).toMatch('SECURITY_CONTEXT.cubeCloud.userAttributes'); }); + it('CubePropContextTranspiler with userAttributes shorthand in mask.sql should transpile to SECURITY_CONTEXT', async () => { + const { cubeEvaluator, compiler } = prepareJsCompiler(` + cube(\`Test\`, { + sql: 'SELECT * FROM users', + dimensions: { + userId: { + sql: \`userId\`, + type: 'string' + }, + masked_dim: { + sql: \`price\`, + type: 'number', + mask: { + sql: \`CAST(\${userAttributes.tenantId} AS INTEGER)\`, + } + } + } + }) + `); + + await compiler.compile(); + + const transpiledMaskSql = (cubeEvaluator.cubeFromPath('Test').dimensions.masked_dim as any).mask.sql; + expect(transpiledMaskSql!.toString()).toMatch('SECURITY_CONTEXT.cubeCloud.userAttributes'); + }); + + it('CubePropContextTranspiler mask.sql with CUBE reference should resolve correctly', async () => { + const compilers = prepareJsCompiler(` + cube(\`Test\`, { + sql_table: 'public.test', + dimensions: { + id: { + sql: \`id\`, + type: 'number', + primary_key: true, + }, + secret: { + sql: \`secret_val\`, + type: 'string', + mask: { + sql: \`CONCAT('***', RIGHT(CAST(\${CUBE}.secret_val AS TEXT), 2))\`, + } + } + }, + measures: { + count: { type: 'count' } + } + }) + `); + + await compilers.compiler.compile(); + + const query = new PostgresQuery( + compilers, + { + measures: ['Test.count'], + dimensions: ['Test.secret'], + maskedMembers: ['Test.secret'], + } + ); + const sql = query.buildSqlAndParams(); + expect(sql[0]).toContain('"test".secret_val'); + }); + it('CubePropContextTranspiler should not transform groups shorthand when a cube member named groups exists', async () => { const { cubeEvaluator, compiler } = prepareJsCompiler(` cube(\`Test\`, { diff --git a/packages/cubejs-schema-compiler/test/unit/yaml-schema.test.ts b/packages/cubejs-schema-compiler/test/unit/yaml-schema.test.ts index 340fd109eadef..a03ce9a47e16b 100644 --- a/packages/cubejs-schema-compiler/test/unit/yaml-schema.test.ts +++ b/packages/cubejs-schema-compiler/test/unit/yaml-schema.test.ts @@ -1,4 +1,5 @@ import { prepareYamlCompiler } from './PrepareCompiler'; +import { PostgresQuery } from '../../src'; describe('Yaml Schema Testing', () => { describe('Duplicate member detection', () => { @@ -1114,4 +1115,122 @@ cubes: } }); }); + + describe('Mask SQL with shorthand', () => { + it('userAttributes shorthand in mask sql should compile and resolve', async () => { + const compilers = prepareYamlCompiler(` +cubes: + - name: orders + sql_table: public.orders + dimensions: + - name: id + sql: id + type: number + primary_key: true + - name: status + sql: status + type: string + mask: + sql: "CASE WHEN { userAttributes.hasStatusAccess } THEN {CUBE}.status ELSE '***' END" + measures: + - name: count + type: count + access_policy: + - role: "*" + member_level: + includes: [] + member_masking: + includes: "*" + `); + + await compilers.compiler.compile(); + + const dim = compilers.cubeEvaluator.cubeFromPath('orders').dimensions.status; + const maskSql = (dim as any).mask.sql.toString(); + expect(maskSql).toContain('SECURITY_CONTEXT.cubeCloud.userAttributes.hasStatusAccess'); + expect(maskSql).toContain('CUBE'); + expect(maskSql).not.toMatch(/[^.}]userAttributes\.hasStatusAccess/); + + const query = new PostgresQuery( + compilers, + { + measures: ['orders.count'], + dimensions: ['orders.status'], + maskedMembers: ['orders.status'], + contextSymbols: { + securityContext: { cubeCloud: { userAttributes: { hasStatusAccess: true } } } + } + } + ); + const sql = query.buildSqlAndParams(); + expect(sql[0]).toContain('"orders".status'); + expect(sql[0]).toContain('CASE WHEN'); + }); + + it('user_attributes shorthand in mask sql should compile and resolve', async () => { + const compilers = prepareYamlCompiler(` +cubes: + - name: orders + sql_table: public.orders + dimensions: + - name: id + sql: id + type: number + primary_key: true + - name: status + sql: status + type: string + mask: + sql: "CASE WHEN { user_attributes.hasStatusAccess } THEN {CUBE}.status ELSE '***' END" + measures: + - name: count + type: count + access_policy: + - role: "*" + member_level: + includes: [] + member_masking: + includes: "*" + `); + + await compilers.compiler.compile(); + + const dim = compilers.cubeEvaluator.cubeFromPath('orders').dimensions.status; + const maskSql = (dim as any).mask.sql.toString(); + expect(maskSql).toContain('SECURITY_CONTEXT.cubeCloud.userAttributes.hasStatusAccess'); + }); + + it('groups shorthand in mask sql should compile and resolve', async () => { + const compilers = prepareYamlCompiler(` +cubes: + - name: orders + sql_table: public.orders + dimensions: + - name: id + sql: id + type: number + primary_key: true + - name: secret + sql: price + type: number + mask: + sql: "CASE WHEN {CUBE}.product_id IN ({groups}) THEN {CUBE}.price ELSE -1 END" + measures: + - name: count + type: count + access_policy: + - role: "*" + member_level: + includes: [] + member_masking: + includes: "*" + `); + + await compilers.compiler.compile(); + + const dim = compilers.cubeEvaluator.cubeFromPath('orders').dimensions.secret; + const maskSql = (dim as any).mask.sql.toString(); + expect(maskSql).toContain('SECURITY_CONTEXT.cubeCloud.groups'); + }); + }); }); diff --git a/packages/cubejs-testing/birdbox-fixtures/rbac/model/cubes/masking_test.yaml b/packages/cubejs-testing/birdbox-fixtures/rbac/model/cubes/masking_test.yaml index af65080d0f48d..3c4290ba800c4 100644 --- a/packages/cubejs-testing/birdbox-fixtures/rbac/model/cubes/masking_test.yaml +++ b/packages/cubejs-testing/birdbox-fixtures/rbac/model/cubes/masking_test.yaml @@ -106,6 +106,32 @@ cubes: member_level: includes: [] + - name: yaml_ua_mask_test + sql_table: public.line_items + + dimensions: + - name: id + sql: id + type: number + primary_key: true + + - name: masked_status + sql: product_id + type: number + mask: + sql: "CASE WHEN { userAttributes.tenantId } = '1' THEN {CUBE}.product_id ELSE -1 END" + + measures: + - name: count + type: count + + access_policy: + - role: "*" + member_level: + includes: [] + member_masking: + includes: "*" + views: # View with full access at view level - but cube masking still applies (RLS pattern) # Excludes members with {CUBE} references in SQL (secret_string, secret_boolean) diff --git a/packages/cubejs-testing/birdbox-fixtures/rbac/model/cubes/security_context_test.js b/packages/cubejs-testing/birdbox-fixtures/rbac/model/cubes/security_context_test.js index 30d3c5dfd37bb..9b674b9a9934d 100644 --- a/packages/cubejs-testing/birdbox-fixtures/rbac/model/cubes/security_context_test.js +++ b/packages/cubejs-testing/birdbox-fixtures/rbac/model/cubes/security_context_test.js @@ -66,6 +66,132 @@ cube('sc_interpolation_test', { }, }); +cube('sc_ua_mask_test', { + sql_table: 'public.line_items', + + dimensions: { + id: { + sql: 'id', + type: 'number', + primary_key: true, + }, + product_id: { + sql: 'product_id', + type: 'number', + }, + masked_price: { + sql: 'price', + type: 'number', + mask: { + sql: `CAST(${userAttributes.tenantId} AS INTEGER)`, + }, + }, + }, + + measures: { + count: { + type: 'count', + }, + }, + + accessPolicy: [ + { + role: '*', + memberLevel: { + includes: [], + }, + memberMasking: { + includes: '*', + }, + }, + ], +}); + +cube('sc_cube_mask_test', { + sql_table: 'public.line_items', + + dimensions: { + id: { + sql: 'id', + type: 'number', + primary_key: true, + }, + masked_product: { + sql: `${CUBE}.product_id`, + type: 'number', + mask: { + sql: `${CUBE}.product_id * -1`, + }, + }, + }, + + measures: { + count: { + type: 'count', + }, + }, + + accessPolicy: [ + { + role: '*', + memberLevel: { + includes: [], + }, + memberMasking: { + includes: '*', + }, + }, + ], +}); + +cube('sc_joined_mask_test', { + sql_table: 'public.line_items', + + joins: { + orders: { + relationship: 'many_to_one', + sql: `${orders}.id = ${sc_joined_mask_test}.order_id`, + }, + }, + + dimensions: { + id: { + sql: 'id', + type: 'number', + primary_key: true, + }, + order_id: { + sql: `${CUBE}.order_id`, + type: 'number', + }, + masked_order_id: { + sql: `${CUBE}.order_id`, + type: 'number', + mask: { + sql: `${orders.id}`, + }, + }, + }, + + measures: { + count: { + type: 'count', + }, + }, + + accessPolicy: [ + { + role: '*', + memberLevel: { + includes: [], + }, + memberMasking: { + includes: '*', + }, + }, + ], +}); + cube('sc_groups_shorthand_test', { sql_table: 'public.line_items', diff --git a/packages/cubejs-testing/test/smoke-rbac.test.ts b/packages/cubejs-testing/test/smoke-rbac.test.ts index 494072a845659..7cb7fdc8f7f65 100644 --- a/packages/cubejs-testing/test/smoke-rbac.test.ts +++ b/packages/cubejs-testing/test/smoke-rbac.test.ts @@ -820,6 +820,53 @@ describe('Cube RBAC Engine', () => { ); expect(res.rows.length).toBeGreaterThan(0); }); + + test('userAttributes shorthand in mask sql', async () => { + const res = await connection.query( + 'SELECT * FROM sc_ua_mask_test LIMIT 5' + ); + expect(res.rows.length).toBeGreaterThan(0); + for (const row of res.rows) { + // mask.sql is CAST(${userAttributes.tenantId} AS INTEGER) + // sc_test user has tenantId = '1', so masked_price should be 1 + expect(row.masked_price).toBe(1); + } + }); + + test('CUBE context in mask sql', async () => { + const res = await connection.query( + 'SELECT * FROM sc_cube_mask_test LIMIT 5' + ); + expect(res.rows.length).toBeGreaterThan(0); + for (const row of res.rows) { + // mask.sql is ${CUBE}.product_id * -1, so masked_product should be negative + expect(row.masked_product).toBeLessThan(0); + } + }); + + test('userAttributes shorthand in YAML mask sql', async () => { + const res = await connection.query( + 'SELECT * FROM yaml_ua_mask_test LIMIT 5' + ); + expect(res.rows.length).toBeGreaterThan(0); + for (const row of res.rows) { + // sc_test user has tenantId = '1', so the CASE WHEN evaluates to true + // and masked_status should be the actual product_id (positive) + expect(row.masked_status).toBeGreaterThan(0); + } + }); + + test('joined cube reference in mask sql', async () => { + const res = await connection.query( + 'SELECT * FROM sc_joined_mask_test LIMIT 5' + ); + expect(res.rows.length).toBeGreaterThan(0); + for (const row of res.rows) { + // mask.sql is ${orders.id} which joins orders and returns orders.id + // The join should be resolved and masked_order_id should be a positive integer + expect(row.masked_order_id).toBeGreaterThan(0); + } + }); }); describe('SECURITY_CONTEXT.cubeCloud features via REST API', () => { @@ -883,6 +930,59 @@ describe('Cube RBAC Engine', () => { expect(rows.length).toBe(1); expect(rows[0]['sc_groups_shorthand_test.count']).toBeDefined(); }); + + test('userAttributes shorthand in mask sql via REST', async () => { + const result = await scClient.load({ + measures: ['sc_ua_mask_test.count'], + dimensions: ['sc_ua_mask_test.masked_price'], + }); + const rows = result.rawData(); + expect(rows.length).toBeGreaterThan(0); + for (const row of rows) { + // mask.sql is CAST(${userAttributes.tenantId} AS INTEGER) + // sc_test user has tenantId = '1', so masked_price should be 1 + expect(row['sc_ua_mask_test.masked_price']).toBe(1); + } + }); + + test('CUBE context in mask sql via REST', async () => { + const result = await scClient.load({ + measures: ['sc_cube_mask_test.count'], + dimensions: ['sc_cube_mask_test.masked_product'], + }); + const rows = result.rawData(); + expect(rows.length).toBeGreaterThan(0); + for (const row of rows) { + // mask.sql is ${CUBE}.product_id * -1, so masked_product should be negative + expect(row['sc_cube_mask_test.masked_product']).toBeLessThan(0); + } + }); + + test('userAttributes shorthand in YAML mask sql via REST', async () => { + const result = await scClient.load({ + measures: ['yaml_ua_mask_test.count'], + dimensions: ['yaml_ua_mask_test.masked_status'], + }); + const rows = result.rawData(); + expect(rows.length).toBeGreaterThan(0); + for (const row of rows) { + // sc_test user has tenantId = '1', so masked_status should be actual product_id + expect(row['yaml_ua_mask_test.masked_status']).toBeGreaterThan(0); + } + }); + + test('joined cube reference in mask sql via REST', async () => { + const result = await scClient.load({ + measures: ['sc_joined_mask_test.count'], + dimensions: ['sc_joined_mask_test.masked_order_id'], + }); + const rows = result.rawData(); + expect(rows.length).toBeGreaterThan(0); + for (const row of rows) { + // mask.sql references ${orders.id} from a joined cube — the join must be resolved + expect(row['sc_joined_mask_test.masked_order_id']).toBeGreaterThan(0); + } + }); }); describe('RBAC via REST API', () => { @@ -980,6 +1080,171 @@ describe('Cube RBAC Engine', () => { }); }); +describe('Cube RBAC Engine [Tesseract]', () => { + jest.setTimeout(60 * 5 * 1000); + let db: StartedTestContainer; + let birdbox: BirdBox; + + beforeAll(async () => { + db = await PostgresDBRunner.startContainer({}); + await PostgresDBRunner.loadEcom(db); + birdbox = await getBirdbox( + 'postgres', + { + ...DEFAULT_CONFIG, + CUBEJS_DEV_MODE: 'false', + NODE_ENV: 'production', + // + CUBEJS_DB_TYPE: 'postgres', + CUBEJS_DB_HOST: db.getHost(), + CUBEJS_DB_PORT: `${db.getMappedPort(5432)}`, + CUBEJS_DB_NAME: 'test', + CUBEJS_DB_USER: 'test', + CUBEJS_DB_PASS: 'test', + // + CUBEJS_PG_SQL_PORT: `${PG_PORT}`, + CUBESQL_SQL_PUSH_DOWN: 'true', + }, + { + schemaDir: 'rbac/model', + cubejsConfig: 'rbac/cube.js', + } + ); + }, JEST_BEFORE_ALL_DEFAULT_TIMEOUT); + + afterAll(async () => { + await birdbox.stop(); + await db.stop(); + }, JEST_AFTER_ALL_DEFAULT_TIMEOUT); + + describe('Shorthand and mask tests via SQL API [Tesseract]', () => { + let connection: PgClient; + + beforeAll(async () => { + connection = await createPostgresClient('sc_test', 'sc_test_password'); + }); + + afterAll(async () => { + await connection.end(); + }, JEST_AFTER_ALL_DEFAULT_TIMEOUT); + + test('userAttributes shorthand in mask sql', async () => { + const res = await connection.query( + 'SELECT * FROM sc_ua_mask_test LIMIT 5' + ); + expect(res.rows.length).toBeGreaterThan(0); + for (const row of res.rows) { + expect(row.masked_price).toBe(1); + } + }); + + test('CUBE context in mask sql', async () => { + const res = await connection.query( + 'SELECT * FROM sc_cube_mask_test LIMIT 5' + ); + expect(res.rows.length).toBeGreaterThan(0); + for (const row of res.rows) { + expect(row.masked_product).toBeLessThan(0); + } + }); + + test('userAttributes shorthand in YAML mask sql', async () => { + const res = await connection.query( + 'SELECT * FROM yaml_ua_mask_test LIMIT 5' + ); + expect(res.rows.length).toBeGreaterThan(0); + for (const row of res.rows) { + expect(row.masked_status).toBeGreaterThan(0); + } + }); + + test('joined cube reference in mask sql', async () => { + const res = await connection.query( + 'SELECT * FROM sc_joined_mask_test LIMIT 5' + ); + expect(res.rows.length).toBeGreaterThan(0); + for (const row of res.rows) { + expect(row.masked_order_id).toBeGreaterThan(0); + } + }); + }); + + describe('Shorthand and mask tests via REST API [Tesseract]', () => { + let scClient: CubeApi; + + const SC_TEST_TOKEN = sign({ + cubeCloud: { + userAttributes: { + tenantId: '1', + }, + groups: ['1', '2'], + }, + auth: { + username: 'sc_test', + userAttributes: {}, + roles: [], + groups: [], + }, + }, DEFAULT_CONFIG.CUBEJS_API_SECRET, { + expiresIn: '2 days' + }); + + beforeAll(async () => { + scClient = cubejs(async () => SC_TEST_TOKEN, { + apiUrl: birdbox.configuration.apiUrl, + }); + }); + + test('userAttributes shorthand in mask sql via REST', async () => { + const result = await scClient.load({ + measures: ['sc_ua_mask_test.count'], + dimensions: ['sc_ua_mask_test.masked_price'], + }); + const rows = result.rawData(); + expect(rows.length).toBeGreaterThan(0); + for (const row of rows) { + expect(row['sc_ua_mask_test.masked_price']).toBe(1); + } + }); + + test('CUBE context in mask sql via REST', async () => { + const result = await scClient.load({ + measures: ['sc_cube_mask_test.count'], + dimensions: ['sc_cube_mask_test.masked_product'], + }); + const rows = result.rawData(); + expect(rows.length).toBeGreaterThan(0); + for (const row of rows) { + expect(row['sc_cube_mask_test.masked_product']).toBeLessThan(0); + } + }); + + test('userAttributes shorthand in YAML mask sql via REST', async () => { + const result = await scClient.load({ + measures: ['yaml_ua_mask_test.count'], + dimensions: ['yaml_ua_mask_test.masked_status'], + }); + const rows = result.rawData(); + expect(rows.length).toBeGreaterThan(0); + for (const row of rows) { + expect(row['yaml_ua_mask_test.masked_status']).toBeGreaterThan(0); + } + }); + + test('joined cube reference in mask sql via REST', async () => { + const result = await scClient.load({ + measures: ['sc_joined_mask_test.count'], + dimensions: ['sc_joined_mask_test.masked_order_id'], + }); + const rows = result.rawData(); + expect(rows.length).toBeGreaterThan(0); + for (const row of rows) { + expect(row['sc_joined_mask_test.masked_order_id']).toBeGreaterThan(0); + } + }); + }); +}); + describe('Cube RBAC Engine [dev mode]', () => { jest.setTimeout(60 * 5 * 1000); let db: StartedTestContainer; diff --git a/rust/cubesqlplanner/cubesqlplanner/src/planner/sql_evaluator/symbols/dimension_symbol.rs b/rust/cubesqlplanner/cubesqlplanner/src/planner/sql_evaluator/symbols/dimension_symbol.rs index 56d71dcf7d6e3..2494b32720ece 100644 --- a/rust/cubesqlplanner/cubesqlplanner/src/planner/sql_evaluator/symbols/dimension_symbol.rs +++ b/rust/cubesqlplanner/cubesqlplanner/src/planner/sql_evaluator/symbols/dimension_symbol.rs @@ -226,19 +226,30 @@ impl DimensionSymbol { ) -> Result, CubeError> { let mut result = self.clone(); result.kind = self.kind.apply_to_deps(f)?; + if let Some(mask) = &self.mask_sql { + result.mask_sql = Some(mask.apply_recursive(f)?); + } Ok(MemberSymbol::new_dimension(Rc::new(result))) } pub fn iter_sql_calls(&self) -> Box> + '_> { - self.kind.iter_sql_calls() + Box::new(self.kind.iter_sql_calls().chain(self.mask_sql.iter())) } pub fn get_dependencies(&self) -> Vec> { - self.kind.get_dependencies() + let mut deps = self.kind.get_dependencies(); + if let Some(mask) = &self.mask_sql { + mask.extract_symbol_deps(&mut deps); + } + deps } pub fn get_cube_refs(&self) -> Vec { - self.kind.get_cube_refs() + let mut refs = self.kind.get_cube_refs(); + if let Some(mask) = &self.mask_sql { + mask.extract_cube_refs(&mut refs); + } + refs } pub fn cube_name(&self) -> String { diff --git a/rust/cubesqlplanner/cubesqlplanner/src/planner/sql_evaluator/symbols/measure_symbol.rs b/rust/cubesqlplanner/cubesqlplanner/src/planner/sql_evaluator/symbols/measure_symbol.rs index 9a50f3f3dd561..a05d75d273376 100644 --- a/rust/cubesqlplanner/cubesqlplanner/src/planner/sql_evaluator/symbols/measure_symbol.rs +++ b/rust/cubesqlplanner/cubesqlplanner/src/planner/sql_evaluator/symbols/measure_symbol.rs @@ -305,6 +305,10 @@ impl MeasureSymbol { result.case = Some(case.apply_to_deps(f)?) } + if let Some(mask) = &self.mask_sql { + result.mask_sql = Some(mask.apply_recursive(f)?); + } + Ok(MemberSymbol::new_measure(Rc::new(result))) } @@ -314,7 +318,8 @@ impl MeasureSymbol { let result = self .kind .iter_sql_calls() - .chain(self.case.iter().flat_map(|case| case.iter_sql_calls())); + .chain(self.case.iter().flat_map(|case| case.iter_sql_calls())) + .chain(self.mask_sql.iter()); Box::new(result) } @@ -332,6 +337,9 @@ impl MeasureSymbol { if let Some(case) = &self.case { case.extract_symbol_deps(&mut deps); } + if let Some(mask) = &self.mask_sql { + mask.extract_symbol_deps(&mut deps); + } deps } @@ -349,6 +357,9 @@ impl MeasureSymbol { if let Some(case) = &self.case { case.extract_cube_refs(&mut refs); } + if let Some(mask) = &self.mask_sql { + mask.extract_cube_refs(&mut refs); + } refs }