diff --git a/.github/workflows/push.yml b/.github/workflows/push.yml
index c281069090c6e..bef378b93e90d 100644
--- a/.github/workflows/push.yml
+++ b/.github/workflows/push.yml
@@ -546,6 +546,7 @@ jobs:
matrix:
node-version: [ 22.x ]
python-version: [ 3.11 ]
+ use_tesseract_sql_planner: [ true, false ]
fail-fast: false
steps:
@@ -605,6 +606,8 @@ jobs:
chmod +x ./rust/cubestore/downloaded/latest/bin/cubestored
- name: Run Integration smoke tests
timeout-minutes: 30
+ env:
+ CUBEJS_TESSERACT_SQL_PLANNER: ${{ matrix.use_tesseract_sql_planner }}
run: ./.github/actions/smoke.sh
docker-image-latest-set-tag:
diff --git a/docs/content/product/auth/data-access-policies.mdx b/docs/content/product/auth/data-access-policies.mdx
index e18b36c39f8a5..6b405c46d11d1 100644
--- a/docs/content/product/auth/data-access-policies.mdx
+++ b/docs/content/product/auth/data-access-policies.mdx
@@ -1,9 +1,9 @@
# Access policies
-Access policies provide a holistic mechanism to manage [member-level](#member-level-access)
-and [row-level](#row-level-access) security for different user groups.
-You can define access control rules in data model files, allowing for an organized
-and maintainable approach to security.
+Access policies provide a holistic mechanism to manage [member-level](#member-level-access),
+[row-level](#row-level-access) security, and [data masking](#data-masking) for
+different user groups. You can define access control rules in data model files,
+allowing for an organized and maintainable approach to security.
## Policies
@@ -116,6 +116,136 @@ filtered by the row-level security rules of both views.
+### Data masking
+
+With data masking, you can return masked values for restricted members instead
+of denying access entirely. Users who don't have full access to a member will
+see a transformed value (e.g., `***`, `-1`, `NULL`) rather than receiving an error.
+
+To use data masking, define a [`mask` parameter][ref-ref-mask-dim] on dimensions
+or measures, and add `member_masking` to your access policy alongside `member_level`.
+Members in `member_level` get real values; members not in `member_level` but in
+`member_masking` get masked values; members in neither are denied.
+
+
+
+```yaml
+cubes:
+ - name: orders
+ # ...
+
+ dimensions:
+ - name: status
+ sql: status
+ type: string
+
+ - name: secret_code
+ sql: secret_code
+ type: string
+ mask:
+ sql: "CONCAT('***', RIGHT({CUBE}.secret_code, 3))"
+
+ - name: revenue
+ sql: revenue
+ type: number
+ mask: -1
+
+ measures:
+ - name: count
+ type: count
+ mask: 0
+
+ access_policy:
+ - group: manager
+ member_level:
+ includes:
+ - status
+ - count
+ member_masking:
+ includes: "*"
+```
+
+```javascript
+cube(`orders`, {
+ // ...
+
+ dimensions: {
+ status: {
+ sql: `status`,
+ type: `string`
+ },
+
+ secret_code: {
+ sql: `secret_code`,
+ type: `string`,
+ mask: {
+ sql: `CONCAT('***', RIGHT(${CUBE}.secret_code, 3))`
+ }
+ },
+
+ revenue: {
+ sql: `revenue`,
+ type: `number`,
+ mask: -1
+ }
+ },
+
+ measures: {
+ count: {
+ type: `count`,
+ mask: 0
+ }
+ },
+
+ access_policy: [
+ {
+ group: `manager`,
+ member_level: {
+ includes: [`status`, `count`]
+ },
+ member_masking: {
+ includes: `*`
+ }
+ }
+ ]
+})
+```
+
+
+
+With this policy, users in the `manager` group will see:
+
+| Member | Value |
+| --- | --- |
+| `status` | Real value (full access via `member_level`) |
+| `count` | Real value (full access via `member_level`) |
+| `secret_code` | Masked via SQL: `***xyz` |
+| `revenue` | Masked: `-1` |
+
+If no `mask` is defined on a member, the default mask value is `NULL`. You can
+customize defaults with the `CUBEJS_ACCESS_POLICY_MASK_STRING`,
+`CUBEJS_ACCESS_POLICY_MASK_NUMBER`, `CUBEJS_ACCESS_POLICY_MASK_BOOLEAN`, and
+`CUBEJS_ACCESS_POLICY_MASK_TIME` environment variables.
+
+
+
+SQL masks (`mask: { sql: "..." }`) on measures are not applied in ungrouped
+queries (e.g., `SELECT *` via the SQL API), because SQL mask expressions
+typically reference columns that are not meaningful in a per-row context.
+Static masks (`mask: -1`, `mask: 0`) are applied in all cases.
+
+If you need to mask a measure in ungrouped queries with a dynamic expression,
+define it as a dimension with an SQL mask instead, and reference that masked
+dimension in your query.
+
+
+
+_When querying a view,_ data masking follows the same pattern as row-level
+security: masking rules from both the view and relevant cubes are applied.
+
+For more details on available parameters, check out the
+[`member_masking` reference][ref-ref-dap-masking].
+
## Common patterns
### Restrict access to specific groups
@@ -252,6 +382,60 @@ view(`deals_view`, {
+### Mask sensitive members
+
+You can mask sensitive members for most users while granting full access to
+privileged groups:
+
+
+
+```yaml
+views:
+ - name: orders_view
+ # ...
+
+ access_policy:
+ # Default: all members masked
+ - group: "*"
+ member_level:
+ includes: []
+ member_masking:
+ includes: "*"
+
+ # Admins: full access
+ - group: admin
+ member_level:
+ includes: "*"
+```
+
+```javascript
+view(`orders_view`, {
+ // ...
+
+ access_policy: [
+ {
+ // Default: all members masked
+ group: `*`,
+ member_level: {
+ includes: []
+ },
+ member_masking: {
+ includes: `*`
+ }
+ },
+ {
+ // Admins: full access
+ group: `admin`,
+ member_level: {
+ includes: `*`
+ }
+ }
+ ]
+})
+```
+
+
+
### Mandatory filters
You can apply mandatory row-level filters to specific groups to ensure they only see data matching certain criteria:
@@ -379,4 +563,6 @@ cube(`orders`, {
[ref-sec-ctx]: /product/auth/context
[ref-ref-dap]: /product/data-modeling/reference/data-access-policies
[ref-ref-dap-role]: /product/data-modeling/reference/data-access-policies#role
+[ref-ref-dap-masking]: /product/data-modeling/reference/data-access-policies#member-masking
+[ref-ref-mask-dim]: /product/data-modeling/reference/dimensions#mask
[ref-core-data-apis]: /product/apis-integrations/core-data-apis
\ No newline at end of file
diff --git a/docs/content/product/auth/member-level-security.mdx b/docs/content/product/auth/member-level-security.mdx
index 912cb719f7ae0..9741647ea7243 100644
--- a/docs/content/product/auth/member-level-security.mdx
+++ b/docs/content/product/auth/member-level-security.mdx
@@ -132,6 +132,13 @@ Access policies also respect member-level security restrictions configured via
`public` parameters. For more details, see the [access policies
reference][ref-dap-ref].
+
+
+If you want to return masked values for restricted members instead of hiding
+them entirely, see [data masking][ref-data-masking] in access policies.
+
+
+
[ref-data-modeling-concepts]: /product/data-modeling/concepts
[ref-apis]: /product/apis-integrations
@@ -150,4 +157,5 @@ reference][ref-dap-ref].
[ref-hierarchies-public]: /product/data-modeling/reference/hierarchies#public
[ref-segments-public]: /product/data-modeling/reference/segments#public
[ref-dynamic-data-modeling]: /product/data-modeling/dynamic
-[ref-security-context]: /product/auth/context
\ No newline at end of file
+[ref-security-context]: /product/auth/context
+[ref-data-masking]: /product/auth/data-access-policies#data-masking
\ No newline at end of file
diff --git a/docs/content/product/data-modeling/reference/data-access-policies.mdx b/docs/content/product/data-modeling/reference/data-access-policies.mdx
index 15feb3986724a..6efb66f2b1e6d 100644
--- a/docs/content/product/data-modeling/reference/data-access-policies.mdx
+++ b/docs/content/product/data-modeling/reference/data-access-policies.mdx
@@ -13,6 +13,8 @@ can be configured using the following parameters:
takes effect.
- [`member_level`](#member-level) and [`row_level`](#row-level) parameters are used
to configure [member-level][ref-dap-mls] and [row-level][ref-dap-rls] access.
+- [`member_masking`](#member-masking) can be optionally used to configure
+[data masking][ref-dap-masking] for members not included in `member_level`.
@@ -295,6 +297,60 @@ Note that access policies also respect [member-level security][ref-mls] restrict
configured via `public` parameters. See [member-level access][ref-dap-mls] to
learn more about policy evaluation.
+### `member_masking`
+
+The optional `member_masking` parameter, when present, configures [data
+masking][ref-dap-masking] for a policy. It requires `member_level` to be
+defined in the same policy.
+
+Members included in `member_level` get full access. Members not in
+`member_level` but included in `member_masking` return masked values instead
+of being denied. The mask value is defined by the [`mask` parameter][ref-mask-dim]
+on each dimension or measure.
+
+You can provide a list of maskable members with `includes`, or a list of
+non-maskable members with `excludes`. Use `"*"` as a shorthand for all members.
+
+
+
+```yaml
+cubes:
+ - name: orders
+ # ...
+
+ access_policy:
+ - group: manager
+ member_level:
+ includes:
+ - status
+ - count
+ member_masking:
+ includes: "*"
+```
+
+```javascript
+cube(`orders`, {
+ // ...
+
+ access_policy: [
+ {
+ group: `manager`,
+ member_level: {
+ includes: [
+ `status`,
+ `count`
+ ]
+ },
+ member_masking: {
+ includes: `*`
+ }
+ }
+ ]
+})
+```
+
+
+
### `row_level`
The optional `row_level` parameter, when present, configures [row-level
@@ -406,6 +462,8 @@ cube(`orders`, {
[ref-rls]: /product/auth/row-level-security
[ref-sec-ctx]: /product/auth/context
[ref-core-data-apis]: /product/apis-integrations/core-data-apis
+[ref-dap-masking]: /product/auth/data-access-policies#data-masking
+[ref-mask-dim]: /product/data-modeling/reference/dimensions#mask
[ref-rest-query-filters]: /product/apis-integrations/rest-api/query-format#filters-format
[ref-rest-query-ops]: /product/apis-integrations/rest-api/query-format#filters-operators
[ref-rest-boolean-ops]: /product/apis-integrations/rest-api/query-format#boolean-logical-operators
diff --git a/docs/content/product/data-modeling/reference/dimensions.mdx b/docs/content/product/data-modeling/reference/dimensions.mdx
index 7a0852e966e5b..9d3a286a5d9c2 100644
--- a/docs/content/product/data-modeling/reference/dimensions.mdx
+++ b/docs/content/product/data-modeling/reference/dimensions.mdx
@@ -578,6 +578,60 @@ cube(`orders`, {
+### `mask`
+
+The optional `mask` parameter defines the replacement value used when the
+dimension is masked by a [data masking][ref-data-masking] access policy.
+
+The mask can be a static value (number, boolean, or string) or a SQL expression:
+
+
+
+```yaml
+cubes:
+ - name: orders
+ # ...
+
+ dimensions:
+ - name: secret_code
+ sql: secret_code
+ type: string
+ mask:
+ sql: "CONCAT('***', RIGHT({CUBE}.secret_code, 3))"
+
+ - name: revenue
+ sql: revenue
+ type: number
+ mask: -1
+```
+
+```javascript
+cube(`orders`, {
+ // ...
+
+ dimensions: {
+ secret_code: {
+ sql: `secret_code`,
+ type: `string`,
+ mask: {
+ sql: `CONCAT('***', RIGHT(${CUBE}.secret_code, 3))`
+ }
+ },
+
+ revenue: {
+ sql: `revenue`,
+ type: `number`,
+ mask: -1
+ }
+ }
+})
+```
+
+
+
+If no `mask` is defined, the default mask value is `NULL`. See
+[data masking][ref-data-masking] for more details.
+
### `sub_query`
The `sub_query` statement allows you to reference a measure in a dimension. It's
@@ -960,3 +1014,4 @@ cube(`fiscal_calendar`, {
[ref-time-shift]: /product/data-modeling/concepts/multi-stage-calculations#time-shift
[ref-cube-calendar]: /product/data-modeling/reference/cube#calendar
[ref-measure-time-shift]: /product/data-modeling/reference/measures#time_shift
+[ref-data-masking]: /product/auth/data-access-policies#data-masking
diff --git a/docs/content/product/data-modeling/reference/measures.mdx b/docs/content/product/data-modeling/reference/measures.mdx
index f35edc5da2d65..1a2c817469e19 100644
--- a/docs/content/product/data-modeling/reference/measures.mdx
+++ b/docs/content/product/data-modeling/reference/measures.mdx
@@ -238,6 +238,80 @@ Depending on the measure [type](#type), the `sql` parameter would either:
function according to the measure type (in case of the `avg`, `count_distinct`,
`count_distinct_approx`, `min`, `max`, and `sum` types).
+### `mask`
+
+The optional `mask` parameter defines the replacement value used when the
+measure is masked by a [data masking][ref-data-masking] access policy.
+
+The mask can be a static value (number, boolean, or string) or a SQL expression.
+When using a SQL expression, it should be an aggregate expression (the same way
+as the measure's [`sql`](#sql) parameter for `number` type measures), because
+the mask replaces the entire measure expression including aggregation:
+
+
+
+```yaml
+cubes:
+ - name: orders
+ # ...
+
+ measures:
+ - name: count
+ type: count
+ mask: 0
+
+ - name: total_revenue
+ sql: revenue
+ type: sum
+ mask: -1
+
+ - name: avg_revenue
+ sql: revenue
+ type: avg
+ mask:
+ sql: "AVG(CASE WHEN {CUBE}.is_public THEN {CUBE}.revenue END)"
+```
+
+```javascript
+cube(`orders`, {
+ // ...
+
+ measures: {
+ count: {
+ type: `count`,
+ mask: 0
+ },
+
+ total_revenue: {
+ sql: `revenue`,
+ type: `sum`,
+ mask: -1
+ },
+
+ avg_revenue: {
+ sql: `revenue`,
+ type: `avg`,
+ mask: {
+ sql: `AVG(CASE WHEN ${CUBE}.is_public THEN ${CUBE}.revenue END)`
+ }
+ }
+ }
+})
+```
+
+
+
+If no `mask` is defined, the default mask value is `NULL`. See
+[data masking][ref-data-masking] for more details.
+
+
+
+SQL masks on measures are not applied in ungrouped queries (e.g., `SELECT *`
+via the SQL API). If you need dynamic masking in ungrouped mode, use a
+masked dimension instead.
+
+
+
### `filters`
If you want to add some conditions for a metric's calculation, you should use
@@ -1187,4 +1261,5 @@ cubes:
[ref-time-shift]: /product/data-modeling/concepts/multi-stage-calculations#time-shift
[ref-nested-aggregate]: /product/data-modeling/concepts/multi-stage-calculations#nested-aggregate
[ref-calendar-cubes]: /product/data-modeling/concepts/calendar-cubes
-[ref-switch-dimensions]: /product/data-modeling/reference/types-and-formats#switch
\ No newline at end of file
+[ref-switch-dimensions]: /product/data-modeling/reference/types-and-formats#switch
+[ref-data-masking]: /product/auth/data-access-policies#data-masking
\ No newline at end of file
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..7a5b16fa766b1 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 {
@@ -3278,6 +3281,17 @@ export class BaseQuery {
this.safeEvaluateSymbolContext().currentMember = memberPath;
try {
+ if (this.maskedMembers && this.maskedMembers.has(memberPath) && !memberExpressionType) {
+ // 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) {
+ return this.memberMaskSql(cubeName, name, symbol);
+ }
+ }
+
if (type === 'measure') {
let parentMeasure;
if (this.safeEvaluateSymbolContext().compositeCubeMeasures ||
@@ -3419,6 +3433,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.paramAllocator.allocateParam(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.paramAllocator.allocateParam(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..73ad1dfa240ec 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'));
+ }
}
}
@@ -651,6 +667,34 @@ export class CubeEvaluator extends CubeSymbols {
if (aliasMember) {
members[memberName].aliasMember = aliasMember;
}
+
+ // Expose maskSql getter for the Tesseract bridge. It normalizes both
+ // SQL masks (mask.sql) and static masks into a callable function.
+ // Non-enumerable so it doesn't pollute serialization.
+ const memberMask = members[memberName].mask;
+ if (memberMask !== undefined && memberMask !== null) {
+ if (typeof memberMask === 'object' && memberMask.sql) {
+ Object.defineProperty(members[memberName], 'maskSql', {
+ get: () => memberMask.sql,
+ enumerable: false,
+ });
+ } else {
+ let maskLiteral: string;
+ if (typeof memberMask === 'number') {
+ maskLiteral = `(${memberMask})`;
+ } else if (typeof memberMask === 'boolean') {
+ maskLiteral = memberMask ? '(TRUE)' : '(FALSE)';
+ } else {
+ maskLiteral = `'${String(memberMask).replace(/'/g, "''")}'`;
+ }
+ // eslint-disable-next-line no-new-func
+ const maskFn = new Function(`return \`${maskLiteral}\`;`);
+ Object.defineProperty(members[memberName], 'maskSql', {
+ get: () => maskFn,
+ enumerable: false,
+ });
+ }
+ }
}
}
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-schema-compiler/test/integration/mssql/mssql-pre-aggregations.test.ts b/packages/cubejs-schema-compiler/test/integration/mssql/mssql-pre-aggregations.test.ts
index 83d50986257db..b83342964f4e3 100644
--- a/packages/cubejs-schema-compiler/test/integration/mssql/mssql-pre-aggregations.test.ts
+++ b/packages/cubejs-schema-compiler/test/integration/mssql/mssql-pre-aggregations.test.ts
@@ -264,7 +264,7 @@ describe('MSSqlPreAggregations', () => {
expect(preAggregationsDescription[0].invalidateKeyQueries[0][0].replace(/(\r\n|\n|\r)/gm, '')
.replace(/\s+/g, ' '))
- .toMatch('SELECT CASE WHEN CURRENT_TIMESTAMP < DATEADD(day, 7, CAST(@_1 AS DATETIMEOFFSET)) THEN FLOOR((-28800 + DATEDIFF(SECOND,\'1970-01-01\', GETUTCDATE())) / 3600) END as refresh_key');
+ .toMatch(/SELECT CASE WHEN CURRENT_TIMESTAMP < DATEADD\(day, 7, CAST\(@_1 AS DATETIMEOFFSET\)\) THEN FLOOR\(\(-(?:28800|25200) \+ DATEDIFF\(SECOND,'1970-01-01', GETUTCDATE\(\)\)\) \/ 3600\) END as refresh_key/);
return dbRunner
.evaluateQueryWithPreAggregations(query)
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..af65080d0f48d
--- /dev/null
+++ b/packages/cubejs-testing/birdbox-fixtures/rbac/model/cubes/masking_test.yaml
@@ -0,0 +1,201 @@
+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
+ # Excludes secret_string (SQL mask with {CUBE} references causes FROM-clause issues in SQL API)
+ - name: masking_view_masked
+ cubes:
+ - join_path: masking_test
+ includes:
+ - 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.
+ # Excludes secret_string (SQL mask with {CUBE} references causes FROM-clause issues in SQL API)
+ - name: masking_view_over_hidden_cube
+ cubes:
+ - join_path: masking_hidden_cube
+ includes:
+ - secret_number
+ - public_dim
+ - count
+ - total_quantity
+ 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..8a0221420ddb6 100644
--- a/packages/cubejs-testing/test/smoke-rbac.test.ts
+++ b/packages/cubejs-testing/test/smoke-rbac.test.ts
@@ -378,6 +378,410 @@ 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) {
+ 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);
+ 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) {
+ expect(row.public_dim).not.toBeNull();
+ expect(row.total_quantity).not.toBeNull();
+ expect(row.secret_number).toBe(-1);
+ expect(Number(row.count)).toBe(12345);
+ }
+ });
+
+ test('masked MEASURE() grouped by real dimension', async () => {
+ const res = await connection.query(
+ 'SELECT public_dim, MEASURE("masking_test"."count") AS "count" FROM masking_test GROUP BY 1 ORDER BY 1 LIMIT 5'
+ );
+ expect(res.rows.length).toBeGreaterThan(0);
+ for (const row of res.rows) {
+ expect(row.public_dim).not.toBeNull();
+ expect(Number(row.count)).toBe(12345);
+ }
+ });
+
+ test('masked MEASURE() grouped by masked dimension', async () => {
+ const res = await connection.query(
+ 'SELECT secret_number, MEASURE("masking_test"."count") AS "count" FROM masking_test GROUP BY 1 LIMIT 5'
+ );
+ expect(res.rows.length).toBeGreaterThan(0);
+ for (const row of res.rows) {
+ expect(row.secret_number).toBe(-1);
+ 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 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);
+ }
+ });
+
+ test('masking_view_over_hidden_cube returns masked values for default role', async () => {
+ 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.public_dim).not.toBeNull();
+ expect(row.total_quantity).not.toBeNull();
+ expect(row.secret_number).toBe(-1);
+ expect(Number(row.count)).toBe(12345);
+ }
+ });
+ });
+
+ 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('cube: masked measure grouped by masked dimension', async () => {
+ const result = await maskingViewerClient.load({
+ measures: ['masking_test.count'],
+ dimensions: ['masking_test.secret_number'],
+ limit: 5,
+ });
+ 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: masked measure grouped by real dimension (partial access)', async () => {
+ const result = await maskingPartialClient.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.public_dim']).not.toBeNull();
+ expect(row['masking_test.count']).toBe(12345);
+ }
+ });
+
+ test('cube: multiple masked measures grouped by real dimension', async () => {
+ const result = await maskingPartialClient.load({
+ measures: ['masking_test.count', 'masking_test.total_quantity'],
+ 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.public_dim']).not.toBeNull();
+ // count is masked, total_quantity is real
+ expect(row['masking_test.count']).toBe(12345);
+ expect(row['masking_test.total_quantity']).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 — 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: 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/dimension_definition.rs b/rust/cubesqlplanner/cubesqlplanner/src/cube_bridge/dimension_definition.rs
index 2f25a0d42d351..e82870529fb84 100644
--- a/rust/cubesqlplanner/cubesqlplanner/src/cube_bridge/dimension_definition.rs
+++ b/rust/cubesqlplanner/cubesqlplanner/src/cube_bridge/dimension_definition.rs
@@ -48,4 +48,7 @@ pub trait DimensionDefinition {
#[nbridge(field, vec, optional)]
fn time_shift(&self) -> Result