Skip to content
Open
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
1 change: 1 addition & 0 deletions packages/cubejs-api-gateway/src/query.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 => {
Expand Down
1 change: 1 addition & 0 deletions packages/cubejs-api-gateway/src/types/query.ts
Original file line number Diff line number Diff line change
Expand Up @@ -166,6 +166,7 @@ interface NormalizedQuery extends Query {
filters?: NormalizedQueryFilter[];
rowLimit?: null | number;
order?: { id: string; desc: boolean }[];
maskedMembers?: string[];
}

export {
Expand Down
8 changes: 8 additions & 0 deletions packages/cubejs-backend-shared/src/env.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2325,6 +2325,14 @@ const variables: Record<string, (...args: any) => 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;
Expand Down
69 changes: 69 additions & 0 deletions packages/cubejs-schema-compiler/src/adapter/BaseQuery.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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();
}
Expand Down Expand Up @@ -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 ||
Expand Down Expand Up @@ -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);
}
Expand Down
16 changes: 16 additions & 0 deletions packages/cubejs-schema-compiler/src/compiler/CubeEvaluator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'));
}
}
}

Expand Down
8 changes: 8 additions & 0 deletions packages/cubejs-schema-compiler/src/compiler/CubeSymbols.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}[]
Expand Down Expand Up @@ -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 = {
Expand All @@ -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 = {
Expand Down
27 changes: 26 additions & 1 deletion packages/cubejs-schema-compiler/src/compiler/CubeValidator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ export const nonStringFields = new Set([
'useOriginalSqlPreAggregations',
'readOnly',
'prefix',
'mask',
]);

const identifierRegex = /^[_a-zA-Z][_a-zA-Z0-9]*$/;
Expand Down Expand Up @@ -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(),
Expand All @@ -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 },
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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(),
Expand All @@ -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(),
Expand All @@ -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:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ export const transpiledFieldsPatterns: Array<RegExp> = [
/^(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<String> = new Set<String>();
Expand Down
65 changes: 53 additions & 12 deletions packages/cubejs-server-core/src/core/CompilerApi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -547,6 +547,7 @@ export class CompilerApi {
const cubeFiltersPerCubePerRole: Record<string, Record<string, any[]>> = {};
const viewFiltersPerCubePerRole: Record<string, Record<string, any[]>> = {};
const hasAllowAllForCube: Record<string, boolean> = {};
const maskedMembersSet = new Set<string>();

for (const cubeName of queryCubes) {
const cube = cubeEvaluator.cubeFromPath(cubeName);
Expand Down Expand Up @@ -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;
}

Expand All @@ -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) => {
Expand All @@ -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 };
}
}
Expand All @@ -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 };
}

Expand Down Expand Up @@ -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;
};
Expand Down
Loading
Loading