diff --git a/packages/cubejs-api-gateway/src/query.js b/packages/cubejs-api-gateway/src/query.js index a2b91358b2b35..543c83cc0d2c7 100644 --- a/packages/cubejs-api-gateway/src/query.js +++ b/packages/cubejs-api-gateway/src/query.js @@ -195,6 +195,7 @@ const querySchema = Joi.object().keys({ responseFormat: Joi.valid('default', 'compact'), subqueryJoins: Joi.array().items(subqueryJoin), joinHints: Joi.array().items(joinHint), + maskedMembers: Joi.array().items(Joi.string()), }); const normalizeQueryOrder = order => { diff --git a/packages/cubejs-api-gateway/src/types/query.ts b/packages/cubejs-api-gateway/src/types/query.ts index de9add137b8f5..8224edb0f5266 100644 --- a/packages/cubejs-api-gateway/src/types/query.ts +++ b/packages/cubejs-api-gateway/src/types/query.ts @@ -166,6 +166,7 @@ interface NormalizedQuery extends Query { filters?: NormalizedQueryFilter[]; rowLimit?: null | number; order?: { id: string; desc: boolean }[]; + maskedMembers?: string[]; } export { diff --git a/packages/cubejs-backend-shared/src/env.ts b/packages/cubejs-backend-shared/src/env.ts index 523d02ee9cbd5..0348648e0f658 100644 --- a/packages/cubejs-backend-shared/src/env.ts +++ b/packages/cubejs-backend-shared/src/env.ts @@ -2325,6 +2325,14 @@ const variables: Record any> = { fastReload: () => get('CUBEJS_FAST_RELOAD_ENABLED') .default('false') .asBoolStrict(), + accessPolicyMaskString: () => get('CUBEJS_ACCESS_POLICY_MASK_STRING') + .asString(), + accessPolicyMaskTime: () => get('CUBEJS_ACCESS_POLICY_MASK_TIME') + .asString(), + accessPolicyMaskBoolean: () => get('CUBEJS_ACCESS_POLICY_MASK_BOOLEAN') + .asString(), + accessPolicyMaskNumber: () => get('CUBEJS_ACCESS_POLICY_MASK_NUMBER') + .asString(), }; type Vars = typeof variables; diff --git a/packages/cubejs-schema-compiler/src/adapter/BaseQuery.js b/packages/cubejs-schema-compiler/src/adapter/BaseQuery.js index 1b148e60a2a79..cf985999b37f9 100644 --- a/packages/cubejs-schema-compiler/src/adapter/BaseQuery.js +++ b/packages/cubejs-schema-compiler/src/adapter/BaseQuery.js @@ -253,6 +253,7 @@ export class BaseQuery { securityContext: {}, ...this.options.contextSymbols, }; + this.maskedMembers = new Set(this.options.maskedMembers || []); this.compilerCache = this.compilers.compiler.compilerCache; this.queryCache = this.compilerCache.getQueryCache({ measures: this.options.measures, @@ -284,6 +285,7 @@ export class BaseQuery { multiStageTimeDimensions: this.options.multiStageTimeDimensions, subqueryJoins: this.options.subqueryJoins, joinHints: this.options.joinHints, + maskedMembers: this.options.maskedMembers, }); this.from = this.options.from; this.multiStageQuery = this.options.multiStageQuery; @@ -949,6 +951,7 @@ export class BaseQuery { joinHints: this.options.joinHints, cubestoreSupportMultistage: this.options.cubestoreSupportMultistage ?? getEnv('cubeStoreRollingWindowJoin'), disableExternalPreAggregations: !!this.options.disableExternalPreAggregations, + maskedMembers: this.options.maskedMembers, }; try { @@ -1014,6 +1017,24 @@ export class BaseQuery { return Object.keys(fromPath.measures).concat(Object.keys(fromPath.dimensions)); } + resolveMaskSql(memberPath) { + const [cubeName, memberName] = memberPath.split('.'); + let symbol = this.cubeEvaluator.byPathAnyType([cubeName, memberName]); + // For view members that alias an underlying cube member, look up the + // original cube's member definition so mask.sql with {CUBE} references + // resolves against the correct table. + let resolvedCubeName = cubeName; + if (symbol.aliasMember) { + const [origCube, origMember] = symbol.aliasMember.split('.'); + symbol = this.cubeEvaluator.byPathAnyType([origCube, origMember]); + resolvedCubeName = origCube; + } + return this.compilers.compiler.withQuery( + this, + () => this.memberMaskSql(resolvedCubeName, memberName, symbol), + ); + } + getAllocatedParams() { return this.paramAllocator.getParams(); } @@ -3278,6 +3299,10 @@ export class BaseQuery { this.safeEvaluateSymbolContext().currentMember = memberPath; try { + if (this.maskedMembers && this.maskedMembers.has(memberPath) && !memberExpressionType) { + return this.memberMaskSql(cubeName, name, symbol); + } + if (type === 'measure') { let parentMeasure; if (this.safeEvaluateSymbolContext().compositeCubeMeasures || @@ -3419,6 +3444,50 @@ export class BaseQuery { } } + memberMaskSql(cubeName, name, symbol) { + const { mask } = symbol; + if (mask !== undefined && mask !== null) { + if (typeof mask === 'object' && mask.sql) { + const sqlCubeName = symbol.aliasMember ? symbol.aliasMember.split('.')[0] : cubeName; + return this.autoPrefixAndEvaluateSql(sqlCubeName, mask.sql); + } + if (typeof mask === 'number') { + return `${mask}`; + } + if (typeof mask === 'boolean') { + return mask ? 'TRUE' : 'FALSE'; + } + if (typeof mask === 'string') { + return this.escapeStringLiteral(mask); + } + } + return this.defaultMaskSql(symbol.type); + } + + defaultMaskSql(memberType) { + const envMasks = { + string: getEnv('accessPolicyMaskString'), + time: getEnv('accessPolicyMaskTime'), + boolean: getEnv('accessPolicyMaskBoolean'), + number: getEnv('accessPolicyMaskNumber'), + }; + const envMask = envMasks[memberType]; + if (envMask !== undefined && envMask !== null) { + if (memberType === 'number') { + return `${envMask}`; + } + if (memberType === 'boolean') { + return envMask.toLowerCase() === 'true' ? 'TRUE' : 'FALSE'; + } + return this.escapeStringLiteral(envMask); + } + return 'NULL'; + } + + escapeStringLiteral(str) { + return `'${str.replace(/'/g, "''")}'`; + } + autoPrefixAndEvaluateSql(cubeName, sql, isMemberExpr = false) { return this.autoPrefixWithCubeName(cubeName, this.evaluateSql(cubeName, sql), isMemberExpr); } diff --git a/packages/cubejs-schema-compiler/src/compiler/CubeEvaluator.ts b/packages/cubejs-schema-compiler/src/compiler/CubeEvaluator.ts index 6a8a56a4c735b..fe8d36d7d70ae 100644 --- a/packages/cubejs-schema-compiler/src/compiler/CubeEvaluator.ts +++ b/packages/cubejs-schema-compiler/src/compiler/CubeEvaluator.ts @@ -267,6 +267,22 @@ export class CubeEvaluator extends CubeSymbols { policy.memberLevel.excludes || [] ).map(memberMapper('an excludes member')); } + + if (policy.memberMasking) { + if (!policy.memberLevel) { + errorReporter.error( + `accessPolicy for ${cube.name} defines memberMasking without memberLevel. memberLevel is required when memberMasking is used` + ); + } + policy.memberMasking.includesMembers = this.allMembersOrList( + cube, + policy.memberMasking.includes || '*' + ).map(memberMapper('a masking includes member')); + policy.memberMasking.excludesMembers = this.allMembersOrList( + cube, + policy.memberMasking.excludes || [] + ).map(memberMapper('a masking excludes member')); + } } } diff --git a/packages/cubejs-schema-compiler/src/compiler/CubeSymbols.ts b/packages/cubejs-schema-compiler/src/compiler/CubeSymbols.ts index 44ac7b760f31e..437dabc29fa17 100644 --- a/packages/cubejs-schema-compiler/src/compiler/CubeSymbols.ts +++ b/packages/cubejs-schema-compiler/src/compiler/CubeSymbols.ts @@ -136,6 +136,12 @@ export type AccessPolicyDefinition = { includesMembers?: string[]; excludesMembers?: string[]; }; + memberMasking?: { + includes?: string | string[]; + excludes?: string | string[]; + includesMembers?: string[]; + excludesMembers?: string[]; + }; conditions?: { if: Function; }[] @@ -977,6 +983,7 @@ export class CubeSymbols implements TranspilerSymbolResolver, CompilerInterface ...(resolvedMember.orderBy && { orderBy: resolvedMember.orderBy }), ...(processedDrillMembers && { drillMembers: processedDrillMembers }), ...(resolvedMember.drillMembersGrouped && { drillMembersGrouped: resolvedMember.drillMembersGrouped }), + ...(resolvedMember.mask !== undefined ? { mask: resolvedMember.mask } : {}), }; } else if (type === 'dimensions') { memberDefinition = { @@ -989,6 +996,7 @@ export class CubeSymbols implements TranspilerSymbolResolver, CompilerInterface ...(resolvedMember.granularities ? { granularities: resolvedMember.granularities } : {}), ...(resolvedMember.multiStage && { multiStage: resolvedMember.multiStage }), ...(resolvedMember.keyReference && this.processKeyReferenceForView(resolvedMember.keyReference, targetCube.name, viewAllMembers, memberRef.member)), + ...(resolvedMember.mask !== undefined ? { mask: resolvedMember.mask } : {}), }; } else if (type === 'segments') { memberDefinition = { diff --git a/packages/cubejs-schema-compiler/src/compiler/CubeValidator.ts b/packages/cubejs-schema-compiler/src/compiler/CubeValidator.ts index 59c0a39110d18..9d24eb5caace3 100644 --- a/packages/cubejs-schema-compiler/src/compiler/CubeValidator.ts +++ b/packages/cubejs-schema-compiler/src/compiler/CubeValidator.ts @@ -27,6 +27,7 @@ export const nonStringFields = new Set([ 'useOriginalSqlPreAggregations', 'readOnly', 'prefix', + 'mask', ]); const identifierRegex = /^[_a-zA-Z][_a-zA-Z0-9]*$/; @@ -258,6 +259,13 @@ const dimensionNumericFormatSchema = Joi.alternatives([ customNumericFormatSchema ]); +const MaskSchema = Joi.alternatives([ + Joi.object().keys({ sql: Joi.func().required() }), + Joi.number(), + Joi.boolean().strict(), + Joi.string(), +]); + const BaseDimensionWithoutSubQuery = { aliases: Joi.array().items(Joi.string()), type: Joi.any().valid('string', 'number', 'boolean', 'time', 'geo').required(), @@ -270,6 +278,7 @@ const BaseDimensionWithoutSubQuery = { description: Joi.string(), suggestFilterValues: Joi.boolean().strict(), enableSuggestions: Joi.boolean().strict(), + mask: MaskSchema, format: Joi.when('type', { switch: [ { is: 'time', then: timeFormatSchema }, @@ -390,6 +399,7 @@ const BaseMeasure = { // TODO: Deprecate and remove, please use public shown: Joi.boolean().strict(), cumulative: Joi.boolean().strict(), + mask: MaskSchema, filters: Joi.array().items( Joi.object().keys({ sql: Joi.func().required() @@ -941,6 +951,19 @@ const MemberLevelPolicySchema = Joi.object().keys({ excludesMembers: Joi.array().items(Joi.string().required()), }); +const MemberMaskingPolicySchema = Joi.object().keys({ + includes: Joi.alternatives([ + Joi.string().valid('*'), + Joi.array().items(Joi.string()) + ]), + excludes: Joi.alternatives([ + Joi.string().valid('*'), + Joi.array().items(Joi.string().required()) + ]), + includesMembers: Joi.array().items(Joi.string().required()), + excludesMembers: Joi.array().items(Joi.string().required()), +}); + const RowLevelPolicySchema = Joi.object().keys({ filters: Joi.array().items(PolicyFilterSchema, PolicyFilterConditionSchema), allowAll: Joi.boolean().valid(true).strict(), @@ -951,6 +974,7 @@ const RolePolicySchema = Joi.object().keys({ group: Joi.string(), groups: Joi.array().items(Joi.string()), memberLevel: MemberLevelPolicySchema, + memberMasking: MemberMaskingPolicySchema, rowLevel: RowLevelPolicySchema, conditions: Joi.array().items(Joi.object().keys({ if: Joi.func().required(), @@ -959,7 +983,8 @@ const RolePolicySchema = Joi.object().keys({ .nand('group', 'groups') // Cannot have both group and groups .nand('role', 'group') // Cannot have both role and group .nand('role', 'groups') // Cannot have both role and groups - .or('role', 'group', 'groups'); // Must have at least one + .or('role', 'group', 'groups') // Must have at least one + .with('memberMasking', 'memberLevel'); // memberMasking requires memberLevel /* ***************************** * ATTENTION: diff --git a/packages/cubejs-schema-compiler/src/compiler/transpilers/CubePropContextTranspiler.ts b/packages/cubejs-schema-compiler/src/compiler/transpilers/CubePropContextTranspiler.ts index 4bc4665a0870c..06beef569bc6b 100644 --- a/packages/cubejs-schema-compiler/src/compiler/transpilers/CubePropContextTranspiler.ts +++ b/packages/cubejs-schema-compiler/src/compiler/transpilers/CubePropContextTranspiler.ts @@ -34,6 +34,7 @@ export const transpiledFieldsPatterns: Array = [ /^(accessPolicy|access_policy)\.[0-9]+\.(rowLevel|row_level)\.filters\.[0-9]+.*\.member$/, /^(accessPolicy|access_policy)\.[0-9]+\.(rowLevel|row_level)\.filters\.[0-9]+.*\.values$/, /^(accessPolicy|access_policy)\.[0-9]+\.conditions.[0-9]+\.if$/, + /^(measures|dimensions)\.[_a-zA-Z][_a-zA-Z0-9]*\.mask\.sql$/, ]; export const transpiledFields: Set = new Set(); diff --git a/packages/cubejs-server-core/src/core/CompilerApi.ts b/packages/cubejs-server-core/src/core/CompilerApi.ts index d67285a409c8e..86181946109ea 100644 --- a/packages/cubejs-server-core/src/core/CompilerApi.ts +++ b/packages/cubejs-server-core/src/core/CompilerApi.ts @@ -547,6 +547,7 @@ export class CompilerApi { const cubeFiltersPerCubePerRole: Record> = {}; const viewFiltersPerCubePerRole: Record> = {}; const hasAllowAllForCube: Record = {}; + const maskedMembersSet = new Set(); for (const cubeName of queryCubes) { const cube = cubeEvaluator.cubeFromPath(cubeName); @@ -646,8 +647,8 @@ export class CompilerApi { // No policy covers {a,b,c} → Access denied, empty result // const policiesWithMemberAccess = userPolicies.filter((policy: any) => { - // If there's no memberLevel policy, all members are accessible - if (!policy.memberLevel) { + // If there's no memberLevel and no memberMasking policy, all members are accessible + if (!policy.memberLevel && !policy.memberMasking) { return true; } @@ -662,11 +663,47 @@ export class CompilerApi { memberName => memberName.startsWith(`${cubeName}.`) ); - // Check if the policy grants access to all members used in the query - return [...cubeMembersInQuery].every(memberName => policy.memberLevel.includesMembers.includes(memberName) && - !policy.memberLevel.excludesMembers.includes(memberName)); + // A policy covers a member if it's in memberLevel includes (full access) + // or in memberMasking includes (masked access) + return [...cubeMembersInQuery].every(memberName => { + const hasFullAccess = !policy.memberLevel || + (policy.memberLevel.includesMembers.includes(memberName) && + !policy.memberLevel.excludesMembers.includes(memberName)); + if (hasFullAccess) return true; + + if (policy.memberMasking) { + return policy.memberMasking.includesMembers.includes(memberName) && + !policy.memberMasking.excludesMembers.includes(memberName); + } + return false; + }); }); + // Determine which members need masking: a member is masked if no covering + // policy grants it full access via memberLevel AND at least one covering + // policy defines memberMasking that includes the member. + // Masking follows the same pattern as row-level security: it is applied + // at both cube and view levels. When a cube is accessed through a view, + // both the cube's and the view's masking policies are evaluated. + const cubeMembersInQuery = Array.from(queryMemberNames).filter( + memberName => memberName.startsWith(`${cubeName}.`) + ); + for (const memberName of cubeMembersInQuery) { + const hasFullAccessInAnyPolicy = policiesWithMemberAccess.some(policy => { + if (!policy.memberLevel) return true; + return policy.memberLevel.includesMembers.includes(memberName) && + !policy.memberLevel.excludesMembers.includes(memberName); + }); + if (!hasFullAccessInAnyPolicy && policiesWithMemberAccess.length > 0) { + const isMaskedByAnyPolicy = policiesWithMemberAccess.some( + (policy) => policy.memberMasking && policy.memberMasking.includesMembers.includes(memberName) && !policy.memberMasking.excludesMembers.includes(memberName) + ); + if (isMaskedByAnyPolicy) { + maskedMembersSet.add(memberName); + } + } + } + for (const policy of policiesWithMemberAccess) { hasAccessPermission = true; (policy?.rowLevel?.filters || []).forEach((filter: any) => { @@ -683,22 +720,17 @@ export class CompilerApi { }); if (!policy?.rowLevel || policy?.rowLevel?.allowAll) { hasAllowAllForCube[cubeName] = true; - // We don't have a way to add an "all allowed" filter like `WHERE 1 = 1` or something. - // Instead, we'll just mark that the user has "all" access to a given cube and remove - // all filters later break; } } if (!hasAccessPermission) { - // This is a hack that will make sure that the query returns no result query.segments = query.segments || []; query.segments.push({ expression: () => '1 = 0', cubeName: cube.name, name: 'rlsAccessDenied', } as unknown as MemberExpression); - // If we hit this condition there's no need to evaluate the rest of the policy return { query, denied: true }; } } @@ -713,6 +745,9 @@ export class CompilerApi { query.filters = query.filters || []; query.filters.push(rlsFilter); } + if (maskedMembersSet.size > 0) { + query.maskedMembers = Array.from(maskedMembersSet); + } return { query, denied: false }; } @@ -851,10 +886,16 @@ export class CompilerApi { !policy.memberLevel.excludesMembers.includes(item.name)) { return true; } - } else { - // If there's no memberLevel policy, we assume that all members are visible + } else if (!policy.memberMasking) { + // If there's no memberLevel and no memberMasking policy, all members are visible return true; } + if (policy.memberMasking) { + if (policy.memberMasking.includesMembers.includes(item.name) && + !policy.memberMasking.excludesMembers.includes(item.name)) { + return true; + } + } } return false; }; diff --git a/packages/cubejs-testing/birdbox-fixtures/rbac/cube.js b/packages/cubejs-testing/birdbox-fixtures/rbac/cube.js index 3814ea6306784..864fbafbbc2b9 100644 --- a/packages/cubejs-testing/birdbox-fixtures/rbac/cube.js +++ b/packages/cubejs-testing/birdbox-fixtures/rbac/cube.js @@ -132,6 +132,60 @@ module.exports = { }, }; } + // User for masking tests - no special roles, sees only masked values + if (user === 'masking_viewer') { + if (password && password !== 'masking_viewer_password') { + throw new Error(`Password doesn't match for ${user}`); + } + return { + password, + superuser: false, + securityContext: { + auth: { + username: 'masking_viewer', + userAttributes: {}, + roles: [], + groups: [], + }, + }, + }; + } + // User for masking tests - has full access role + if (user === 'masking_full') { + if (password && password !== 'masking_full_password') { + throw new Error(`Password doesn't match for ${user}`); + } + return { + password, + superuser: false, + securityContext: { + auth: { + username: 'masking_full', + userAttributes: {}, + roles: ['masking_full_access'], + groups: [], + }, + }, + }; + } + // User for masking tests - has partial access + masking + if (user === 'masking_partial') { + if (password && password !== 'masking_partial_password') { + throw new Error(`Password doesn't match for ${user}`); + } + return { + password, + superuser: false, + securityContext: { + auth: { + username: 'masking_partial', + userAttributes: {}, + roles: ['masking_partial'], + groups: [], + }, + }, + }; + } throw new Error(`User "${user}" doesn't exist`); } }; 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 new file mode 100644 index 0000000000000..35bf2ccae398c --- /dev/null +++ b/packages/cubejs-testing/birdbox-fixtures/rbac/model/cubes/masking_test.yaml @@ -0,0 +1,196 @@ +cubes: + - name: masking_test + sql_table: public.line_items + + dimensions: + - name: id + sql: id + type: number + primary_key: true + + - name: secret_string + sql: product_id + mask: + sql: "CONCAT('***', RIGHT(CAST({CUBE}.product_id AS TEXT), 2))" + type: string + + - name: secret_number + sql: price + mask: -1 + type: number + + - name: secret_boolean + sql: "CASE WHEN {CUBE}.quantity > 3 THEN TRUE ELSE FALSE END" + mask: FALSE + type: boolean + + - name: public_dim + sql: order_id + type: number + + measures: + - name: count + mask: 12345 + type: count + + - name: count_d + sql: product_id + mask: 34567 + type: count_distinct + + - name: total_quantity + sql: quantity + type: sum + + access_policy: + - role: "*" + member_level: + includes: [] + member_masking: + includes: "*" + + - role: "masking_full_access" + member_level: + includes: "*" + row_level: + allow_all: true + + - role: "masking_partial" + member_level: + includes: + - id + - public_dim + - total_quantity + member_masking: + includes: "*" + row_level: + allow_all: true + + # Cube where all members are hidden by policy. + # Members carry mask definitions so a view can apply masking on top. + - name: masking_hidden_cube + sql_table: public.line_items + + dimensions: + - name: id + sql: id + type: number + primary_key: true + + - name: secret_string + sql: product_id + mask: + sql: "CONCAT('***', RIGHT(CAST({CUBE}.product_id AS TEXT), 2))" + type: string + + - name: secret_number + sql: price + mask: -1 + type: number + + - name: public_dim + sql: order_id + type: number + + measures: + - name: count + mask: 12345 + type: count + + - name: total_quantity + sql: quantity + type: sum + + access_policy: + - role: "*" + member_level: + 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) + - name: masking_view + cubes: + - join_path: masking_test + includes: + - secret_number + - public_dim + - count + - count_d + - total_quantity + access_policy: + - role: "*" + member_level: + includes: "*" + row_level: + allow_all: true + + # View with its own masking policy: all members masked for "*", full access for masking_full_access + - name: masking_view_masked + cubes: + - join_path: masking_test + includes: + - secret_string + - secret_number + - public_dim + - count + - count_d + - total_quantity + access_policy: + - role: "*" + member_level: + includes: [] + member_masking: + includes: "*" + row_level: + allow_all: true + - role: "masking_full_access" + member_level: + includes: "*" + row_level: + allow_all: true + + # View with partial masking: public_dim and total_quantity unmasked, rest masked + # Excludes members with {CUBE} references in SQL (secret_string, secret_boolean) + - name: masking_view_partial + cubes: + - join_path: masking_test + includes: + - secret_number + - public_dim + - count + - count_d + - total_quantity + access_policy: + - role: "*" + member_level: + includes: + - public_dim + - total_quantity + member_masking: + includes: "*" + row_level: + allow_all: true + + # View over a cube where all members are hidden. + # The view adds its own masking policy — members that are invisible at + # the cube level become accessible (some masked, some real) through the view. + - name: masking_view_over_hidden_cube + cubes: + - join_path: masking_hidden_cube + includes: "*" + access_policy: + - role: "*" + member_level: + includes: + - public_dim + - total_quantity + member_masking: + includes: "*" + row_level: + allow_all: true + - role: "masking_full_access" + member_level: + includes: "*" + row_level: + allow_all: true diff --git a/packages/cubejs-testing/test/smoke-rbac.test.ts b/packages/cubejs-testing/test/smoke-rbac.test.ts index 87e1bce85404c..f58b5b5c4866c 100644 --- a/packages/cubejs-testing/test/smoke-rbac.test.ts +++ b/packages/cubejs-testing/test/smoke-rbac.test.ts @@ -378,6 +378,383 @@ describe('Cube RBAC Engine', () => { }); }); + /** + * Data masking tests via member_masking access policies. + * + * masking_test cube has dimensions and measures with mask definitions: + * - secret_string: mask with SQL expression CONCAT('***', RIGHT(..., 2)) + * - secret_number: mask with static -1 + * - secret_boolean: mask with FALSE + * - count measure: mask with 12345 + * - count_d measure: mask with 34567 + * + * Three user profiles: + * - masking_viewer: role "*" only → all members masked (memberLevel includes=[]) + * - masking_full: has masking_full_access role → full access to all members + * - masking_partial: has masking_partial role → id, public_dim, total_quantity unmasked; rest masked + */ + describe('RBAC data masking via SQL API (masking_viewer)', () => { + let connection: PgClient; + + beforeAll(async () => { + connection = await createPostgresClient('masking_viewer', 'masking_viewer_password'); + }); + + afterAll(async () => { + await connection.end(); + }, JEST_AFTER_ALL_DEFAULT_TIMEOUT); + + test('SELECT * from masking_test returns masked values', async () => { + const res = await connection.query( + 'SELECT * FROM masking_test LIMIT 5' + ); + expect(res.rows.length).toBeGreaterThan(0); + for (const row of res.rows) { + // All members should be masked for masking_viewer + expect(row.secret_number).toBe(-1); + expect(row.secret_boolean).toBe(false); + expect(row.public_dim).toBeNull(); + expect(Number(row.count)).toBe(12345); + expect(Number(row.count_d)).toBe(34567); + // SQL mask: CONCAT('***', RIGHT(CAST(product_id AS TEXT), 2)) + expect(row.secret_string).toMatch(/^\*\*\*.{1,2}$/); + } + }); + }); + + describe('RBAC data masking via SQL API (masking_full)', () => { + let connection: PgClient; + + beforeAll(async () => { + connection = await createPostgresClient('masking_full', 'masking_full_password'); + }); + + afterAll(async () => { + await connection.end(); + }, JEST_AFTER_ALL_DEFAULT_TIMEOUT); + + test('SELECT from masking_test returns real values', async () => { + const res = await connection.query( + 'SELECT * FROM masking_test LIMIT 5' + ); + expect(res.rows.length).toBeGreaterThan(0); + for (const row of res.rows) { + // Full access user should see actual values, not masks + expect(row.secret_number).not.toBe(-1); + expect(Number(row.count)).not.toBe(12345); + } + }); + }); + + describe('RBAC data masking via SQL API (masking_partial)', () => { + let connection: PgClient; + + beforeAll(async () => { + connection = await createPostgresClient('masking_partial', 'masking_partial_password'); + }); + + afterAll(async () => { + await connection.end(); + }, JEST_AFTER_ALL_DEFAULT_TIMEOUT); + + test('SELECT mix of unmasked and masked members', async () => { + const res = await connection.query( + 'SELECT * FROM masking_test LIMIT 5' + ); + expect(res.rows.length).toBeGreaterThan(0); + for (const row of res.rows) { + // public_dim, total_quantity are in memberLevel includes → unmasked + expect(row.public_dim).not.toBeNull(); + expect(row.total_quantity).not.toBeNull(); + // secret_number is not in memberLevel includes → masked + expect(row.secret_number).toBe(-1); + // count measure is not in memberLevel includes → masked + expect(Number(row.count)).toBe(12345); + } + }); + }); + + /** + * View masking tests — masking follows the RLS pattern and is applied at + * both cube and view levels. If a cube masks a member, it stays masked + * even when accessed through a view that grants full access. + * + * Views: + * masking_view — full access at view level, but underlying cube masks for "*" + * masking_view_masked — all members masked for "*"; full access for masking_full_access + * masking_view_partial — public_dim + total_quantity unmasked; rest masked for "*" + * masking_view_over_hidden_cube — view over a cube where all members are hidden; + * view adds masking so members become accessible through it + */ + describe('RBAC data masking via SQL API — views (masking_viewer)', () => { + let connection: PgClient; + + beforeAll(async () => { + connection = await createPostgresClient('masking_viewer', 'masking_viewer_password'); + }); + + afterAll(async () => { + await connection.end(); + }, JEST_AFTER_ALL_DEFAULT_TIMEOUT); + + test('masking_view_masked returns masked values including SQL mask for default role', async () => { + const res = await connection.query('SELECT * FROM masking_view_masked LIMIT 5'); + expect(res.rows.length).toBeGreaterThan(0); + for (const row of res.rows) { + expect(row.secret_number).toBe(-1); + expect(row.public_dim).toBeNull(); + expect(Number(row.count)).toBe(12345); + expect(Number(row.count_d)).toBe(34567); + // Dynamic SQL mask: CONCAT('***', RIGHT(CAST(product_id AS TEXT), 2)) + expect(row.secret_string).toMatch(/^\*\*\*.{1,2}$/); + } + }); + + test('masking_view_over_hidden_cube returns masked values for default role', async () => { + // The underlying cube hides all members (memberLevel.includes: []). + // The view re-exposes them with its own masking policy. + const res = await connection.query('SELECT * FROM masking_view_over_hidden_cube LIMIT 5'); + expect(res.rows.length).toBeGreaterThan(0); + for (const row of res.rows) { + // public_dim, total_quantity in view memberLevel includes → unmasked + expect(row.public_dim).not.toBeNull(); + expect(row.total_quantity).not.toBeNull(); + // secret_number not in view memberLevel includes → masked + expect(row.secret_number).toBe(-1); + expect(Number(row.count)).toBe(12345); + // Dynamic SQL mask on secret_string: {CUBE} resolves to underlying cube table + expect(row.secret_string).toMatch(/^\*\*\*.{1,2}$/); + } + }); + }); + + describe('RBAC data masking via SQL API — views (masking_full)', () => { + let connection: PgClient; + + beforeAll(async () => { + connection = await createPostgresClient('masking_full', 'masking_full_password'); + }); + + afterAll(async () => { + await connection.end(); + }, JEST_AFTER_ALL_DEFAULT_TIMEOUT); + + test('masking_view_masked returns real values for masking_full_access role', async () => { + const res = await connection.query('SELECT * FROM masking_view_masked LIMIT 5'); + expect(res.rows.length).toBeGreaterThan(0); + for (const row of res.rows) { + expect(row.secret_number).not.toBe(-1); + expect(Number(row.count)).not.toBe(12345); + } + }); + + test('masking_view_over_hidden_cube returns real values for masking_full_access role', async () => { + // The underlying cube hides all members, but masking_full_access role + // gets full access through the view's own policy. + const res = await connection.query('SELECT * FROM masking_view_over_hidden_cube LIMIT 5'); + expect(res.rows.length).toBeGreaterThan(0); + for (const row of res.rows) { + expect(row.secret_number).not.toBe(-1); + expect(Number(row.count)).not.toBe(12345); + } + }); + }); + + describe('RBAC data masking via REST API', () => { + let maskingViewerClient: CubeApi; + let maskingFullClient: CubeApi; + let maskingPartialClient: CubeApi; + + const MASKING_VIEWER_TOKEN = sign({ + auth: { + username: 'masking_viewer', + userAttributes: {}, + roles: [], + }, + }, DEFAULT_CONFIG.CUBEJS_API_SECRET, { + expiresIn: '2 days' + }); + + const MASKING_FULL_TOKEN = sign({ + auth: { + username: 'masking_full', + userAttributes: {}, + roles: ['masking_full_access'], + }, + }, DEFAULT_CONFIG.CUBEJS_API_SECRET, { + expiresIn: '2 days' + }); + + const MASKING_PARTIAL_TOKEN = sign({ + auth: { + username: 'masking_partial', + userAttributes: {}, + roles: ['masking_partial'], + }, + }, DEFAULT_CONFIG.CUBEJS_API_SECRET, { + expiresIn: '2 days' + }); + + beforeAll(async () => { + maskingViewerClient = cubejs(async () => MASKING_VIEWER_TOKEN, { + apiUrl: birdbox.configuration.apiUrl, + }); + maskingFullClient = cubejs(async () => MASKING_FULL_TOKEN, { + apiUrl: birdbox.configuration.apiUrl, + }); + maskingPartialClient = cubejs(async () => MASKING_PARTIAL_TOKEN, { + apiUrl: birdbox.configuration.apiUrl, + }); + }); + + test('cube: masking_viewer sees masked values', async () => { + const result = await maskingViewerClient.load({ + measures: ['masking_test.count'], + dimensions: ['masking_test.secret_number'], + }); + const rows = result.rawData(); + expect(rows.length).toBeGreaterThan(0); + for (const row of rows) { + expect(row['masking_test.secret_number']).toBe(-1); + expect(row['masking_test.count']).toBe(12345); + } + }); + + test('cube: masking_full sees real values', async () => { + const result = await maskingFullClient.load({ + measures: ['masking_test.count'], + dimensions: ['masking_test.public_dim'], + order: { 'masking_test.public_dim': 'asc' }, + limit: 5, + }); + const rows = result.rawData(); + expect(rows.length).toBeGreaterThan(0); + for (const row of rows) { + expect(row['masking_test.count']).not.toBe(12345); + } + }); + + test('cube: masking_partial sees mixed values', async () => { + const result = await maskingPartialClient.load({ + measures: ['masking_test.total_quantity', 'masking_test.count'], + dimensions: ['masking_test.public_dim'], + order: { 'masking_test.public_dim': 'asc' }, + limit: 5, + }); + const rows = result.rawData(); + expect(rows.length).toBeGreaterThan(0); + for (const row of rows) { + expect(row['masking_test.total_quantity']).not.toBeNull(); + expect(row['masking_test.count']).toBe(12345); + expect(row['masking_test.public_dim']).not.toBeNull(); + } + }); + + test('view: masking_view_masked — viewer sees masked values', async () => { + const result = await maskingViewerClient.load({ + measures: ['masking_view_masked.count'], + dimensions: ['masking_view_masked.secret_number'], + }); + const rows = result.rawData(); + expect(rows.length).toBeGreaterThan(0); + for (const row of rows) { + expect(row['masking_view_masked.secret_number']).toBe(-1); + expect(row['masking_view_masked.count']).toBe(12345); + } + }); + + test('view: masking_view_masked — dynamic SQL mask works through view', async () => { + const result = await maskingViewerClient.load({ + dimensions: ['masking_view_masked.secret_string'], + limit: 5, + }); + const rows = result.rawData(); + expect(rows.length).toBeGreaterThan(0); + for (const row of rows) { + // SQL mask: CONCAT('***', RIGHT(CAST(product_id AS TEXT), 2)) + // {CUBE} in mask SQL should resolve to the underlying cube table, not the view + expect(row['masking_view_masked.secret_string']).toMatch(/^\*\*\*.{1,2}$/); + } + }); + + test('view: masking_view_masked — full access sees real values', async () => { + const result = await maskingFullClient.load({ + measures: ['masking_view_masked.count'], + dimensions: ['masking_view_masked.public_dim'], + order: { 'masking_view_masked.public_dim': 'asc' }, + limit: 5, + }); + const rows = result.rawData(); + expect(rows.length).toBeGreaterThan(0); + for (const row of rows) { + expect(row['masking_view_masked.count']).not.toBe(12345); + } + }); + + test('view: masking_view — cube masking still applied through view', async () => { + // masking_view grants full access at view level, but the underlying + // cube masks all members for role "*". Masking follows RLS pattern. + const result = await maskingViewerClient.load({ + measures: ['masking_view.count'], + dimensions: ['masking_view.secret_number'], + limit: 5, + }); + const rows = result.rawData(); + expect(rows.length).toBeGreaterThan(0); + for (const row of rows) { + expect(row['masking_view.secret_number']).toBe(-1); + expect(row['masking_view.count']).toBe(12345); + } + }); + + test('view over hidden cube: viewer sees masked values', async () => { + // Underlying cube hides all members. View re-exposes them with masking. + const result = await maskingViewerClient.load({ + measures: ['masking_view_over_hidden_cube.total_quantity', 'masking_view_over_hidden_cube.count'], + dimensions: ['masking_view_over_hidden_cube.public_dim'], + order: { 'masking_view_over_hidden_cube.public_dim': 'asc' }, + limit: 5, + }); + const rows = result.rawData(); + expect(rows.length).toBeGreaterThan(0); + for (const row of rows) { + // public_dim, total_quantity in view memberLevel → real values + expect(row['masking_view_over_hidden_cube.total_quantity']).not.toBeNull(); + expect(row['masking_view_over_hidden_cube.public_dim']).not.toBeNull(); + // count not in view memberLevel → masked + expect(row['masking_view_over_hidden_cube.count']).toBe(12345); + } + }); + + test('view over hidden cube: dynamic SQL mask works through view', async () => { + const result = await maskingViewerClient.load({ + dimensions: ['masking_view_over_hidden_cube.secret_string'], + limit: 5, + }); + const rows = result.rawData(); + expect(rows.length).toBeGreaterThan(0); + for (const row of rows) { + // SQL mask: CONCAT('***', RIGHT(CAST(product_id AS TEXT), 2)) + // {CUBE} in mask.sql resolves to the underlying hidden cube's table + expect(row['masking_view_over_hidden_cube.secret_string']).toMatch(/^\*\*\*.{1,2}$/); + } + }); + + test('view over hidden cube: full access sees real values', async () => { + const result = await maskingFullClient.load({ + measures: ['masking_view_over_hidden_cube.count'], + dimensions: ['masking_view_over_hidden_cube.public_dim'], + order: { 'masking_view_over_hidden_cube.public_dim': 'asc' }, + limit: 5, + }); + const rows = result.rawData(); + expect(rows.length).toBeGreaterThan(0); + for (const row of rows) { + expect(row['masking_view_over_hidden_cube.count']).not.toBe(12345); + } + }); + }); + describe('RBAC via REST API', () => { let client: CubeApi; let defaultClient: CubeApi; diff --git a/rust/cubesqlplanner/cubesqlplanner/src/cube_bridge/base_query_options.rs b/rust/cubesqlplanner/cubesqlplanner/src/cube_bridge/base_query_options.rs index 4c1cac9cc792f..3196071960449 100644 --- a/rust/cubesqlplanner/cubesqlplanner/src/cube_bridge/base_query_options.rs +++ b/rust/cubesqlplanner/cubesqlplanner/src/cube_bridge/base_query_options.rs @@ -73,6 +73,8 @@ pub struct BaseQueryOptionsStatic { pub disable_external_pre_aggregations: bool, #[serde(rename = "preAggregationId")] pub pre_aggregation_id: Option, + #[serde(rename = "maskedMembers")] + pub masked_members: Option>, } #[nativebridge::native_bridge(BaseQueryOptionsStatic)] diff --git a/rust/cubesqlplanner/cubesqlplanner/src/cube_bridge/base_tools.rs b/rust/cubesqlplanner/cubesqlplanner/src/cube_bridge/base_tools.rs index a80f802569558..19b189027394f 100644 --- a/rust/cubesqlplanner/cubesqlplanner/src/cube_bridge/base_tools.rs +++ b/rust/cubesqlplanner/cubesqlplanner/src/cube_bridge/base_tools.rs @@ -47,4 +47,6 @@ pub trait BaseTools { &self, hints: Vec, ) -> Result, CubeError>; + + fn resolve_mask_sql(&self, member_path: String) -> Result; } diff --git a/rust/cubesqlplanner/cubesqlplanner/src/planner/base_query.rs b/rust/cubesqlplanner/cubesqlplanner/src/planner/base_query.rs index b53b30859ec12..3caadc6dbe3af 100644 --- a/rust/cubesqlplanner/cubesqlplanner/src/planner/base_query.rs +++ b/rust/cubesqlplanner/cubesqlplanner/src/planner/base_query.rs @@ -34,6 +34,7 @@ impl BaseQuery { options.join_graph()?, options.static_data().timezone.clone(), options.static_data().export_annotated_sql, + options.static_data().masked_members.clone(), )?; let request = QueryProperties::try_new(query_tools.clone(), options)?; diff --git a/rust/cubesqlplanner/cubesqlplanner/src/planner/query_tools.rs b/rust/cubesqlplanner/cubesqlplanner/src/planner/query_tools.rs index a32a4be8b465e..f672e860fdc74 100644 --- a/rust/cubesqlplanner/cubesqlplanner/src/planner/query_tools.rs +++ b/rust/cubesqlplanner/cubesqlplanner/src/planner/query_tools.rs @@ -15,7 +15,7 @@ use chrono_tz::Tz; use cubenativeutils::CubeError; use itertools::Itertools; use std::cell::{Ref, RefCell, RefMut}; -use std::collections::HashMap; +use std::collections::{HashMap, HashSet}; use std::rc::Rc; pub struct QueryToolsCachedData { @@ -114,6 +114,7 @@ pub struct QueryTools { evaluator_compiler: Rc>, cached_data: RefCell, timezone: Tz, + masked_members: HashSet, } impl QueryTools { @@ -124,6 +125,7 @@ impl QueryTools { join_graph: Rc, timezone_name: Option, export_annotated_sql: bool, + masked_members: Option>, ) -> Result, CubeError> { let templates_render = base_tools.sql_templates()?; let timezone = if let Some(timezone) = timezone_name { @@ -148,9 +150,18 @@ impl QueryTools { evaluator_compiler, cached_data: RefCell::new(QueryToolsCachedData::new()), timezone, + masked_members: masked_members.unwrap_or_default().into_iter().collect(), })) } + pub fn is_member_masked(&self, member_path: &str) -> bool { + self.masked_members.contains(member_path) + } + + pub fn resolve_mask_sql(&self, member_path: &str) -> Result { + self.base_tools.resolve_mask_sql(member_path.to_string()) + } + pub fn cube_evaluator(&self) -> &Rc { &self.cube_evaluator } 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 bce97f970cbf3..23ad13d8c9cd8 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 @@ -80,9 +80,13 @@ impl DimensionSymbol { query_tools: Rc, templates: &PlanSqlTemplates, ) -> Result { + let full_name = self.full_name(); + if query_tools.is_member_masked(&full_name) { + return query_tools.resolve_mask_sql(&full_name); + } self.kind.evaluate_sql( &self.name, - &self.full_name(), + &full_name, visitor, node_processor, query_tools, 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 7010d4b6bb0da..b16980e8b7859 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 @@ -265,13 +265,12 @@ impl MeasureSymbol { query_tools: Rc, templates: &PlanSqlTemplates, ) -> Result { - self.kind.evaluate_sql( - &self.full_name(), - visitor, - node_processor, - query_tools, - templates, - ) + let full_name = self.full_name(); + if query_tools.is_member_masked(&full_name) { + return query_tools.resolve_mask_sql(&full_name); + } + self.kind + .evaluate_sql(&full_name, visitor, node_processor, query_tools, templates) } pub fn apply_to_deps) -> Result, CubeError>>( diff --git a/rust/cubesqlplanner/cubesqlplanner/src/test_fixtures/cube_bridge/base_query_options.rs b/rust/cubesqlplanner/cubesqlplanner/src/test_fixtures/cube_bridge/base_query_options.rs index 07a6b1931a009..eb30dbddf728f 100644 --- a/rust/cubesqlplanner/cubesqlplanner/src/test_fixtures/cube_bridge/base_query_options.rs +++ b/rust/cubesqlplanner/cubesqlplanner/src/test_fixtures/cube_bridge/base_query_options.rs @@ -67,6 +67,8 @@ pub struct MockBaseQueryOptions { disable_external_pre_aggregations: bool, #[builder(default)] pre_aggregation_id: Option, + #[builder(default)] + masked_members: Option>, } impl_static_data!( @@ -85,7 +87,8 @@ impl_static_data!( total_query, cubestore_support_multistage, disable_external_pre_aggregations, - pre_aggregation_id + pre_aggregation_id, + masked_members ); pub fn members_from_strings(strings: Vec) -> Vec { diff --git a/rust/cubesqlplanner/cubesqlplanner/src/test_fixtures/cube_bridge/mock_base_tools.rs b/rust/cubesqlplanner/cubesqlplanner/src/test_fixtures/cube_bridge/mock_base_tools.rs index c6584cf70d2de..46b8b6cd1031a 100644 --- a/rust/cubesqlplanner/cubesqlplanner/src/test_fixtures/cube_bridge/mock_base_tools.rs +++ b/rust/cubesqlplanner/cubesqlplanner/src/test_fixtures/cube_bridge/mock_base_tools.rs @@ -121,4 +121,8 @@ impl BaseTools for MockBaseTools { let result = self.join_graph.build_join(hints)?; Ok(result as Rc) } + + fn resolve_mask_sql(&self, _member_path: String) -> Result { + Ok("NULL".to_string()) + } } diff --git a/rust/cubesqlplanner/cubesqlplanner/src/test_fixtures/test_utils/test_context.rs b/rust/cubesqlplanner/cubesqlplanner/src/test_fixtures/test_utils/test_context.rs index 763bd66944b73..3edfd43941df0 100644 --- a/rust/cubesqlplanner/cubesqlplanner/src/test_fixtures/test_utils/test_context.rs +++ b/rust/cubesqlplanner/cubesqlplanner/src/test_fixtures/test_utils/test_context.rs @@ -40,6 +40,7 @@ impl TestContext { join_graph, Some(timezone.to_string()), false, // export_annotated_sql + None, // masked_members )?; Ok(Self {