Skip to content

feat: Data access policy masking#10463

Open
paveltiunov wants to merge 15 commits intomasterfrom
cursor/data-access-policy-masking-888c
Open

feat: Data access policy masking#10463
paveltiunov wants to merge 15 commits intomasterfrom
cursor/data-access-policy-masking-888c

Conversation

@paveltiunov
Copy link
Member

Check List

  • Tests have been run in packages where changes have been made if available
  • Linter has been run for changed code
  • Tests for the changes have been added if not covered yet
  • Docs have been added / updated if required

Description of Changes Made
This PR implements a data masking feature for data access policies.

Why this change is being made:
Previously, if a user lacked access to a dimension or measure, they would receive an error. This feature allows sensitive data to be masked with a custom value or SQL expression, providing a transformed value instead of an error, enhancing data security and user experience.

How it works:

  1. Data Model Extension: Dimensions and measures can now define an optional mask parameter (e.g., mask: -1, mask: { sql: "CONCAT('***', RIGHT({CUBE}.secret_string, 3))" }).
  2. Access Policy: A new member_masking section in access policies allows specifying which members should be masked for a given role.
  3. Policy Enforcement: If an access policy grants member_masking but not full member_level access to a member, the query engine will substitute the member's original SQL with its defined mask SQL or a default mask value (configurable via environment variables like CUBEJS_ACCESS_POLICY_MASK_STRING).
  4. SQL Pushdown: The masking logic is pushed down to both the JavaScript BaseQuery and the Rust Tesseract query planner, ensuring masked values are resolved at the SQL generation level.
  5. Visibility: Masked members remain visible in metadata, indicating their presence but with transformed data.

Open in Web Open in Cursor 

cursoragent and others added 2 commits March 5, 2026 21:38
Add member masking support to data access policies, allowing users to see
masked values instead of errors when accessing restricted members.

Schema changes:
- Add 'mask' parameter to dimension and measure definitions
  (supports SQL expressions, numbers, booleans, strings)
- Add 'memberMasking' to access policy with includes/excludes patterns
- Add 'mask' to nonStringFields for proper YAML parsing
- Add transpiler pattern for mask.sql fields

Access policy logic:
- Extend member access check to consider memberMasking alongside memberLevel
- A policy covers a query if all members have either full access (memberLevel)
  or masked access (memberMasking)
- Members only accessible via masking get their SQL replaced with mask values
- Visibility patching considers masking members as visible

SQL pushdown (BaseQuery):
- Add maskedMembers set to BaseQuery from query options
- Intercept evaluateSymbolSql to return mask SQL for masked members
- memberMaskSql resolves mask from definition (SQL func, literal, or default)
- defaultMaskSql returns NULL or env var configured defaults
- resolveMaskSql bridge method for Tesseract callback

SQL pushdown (Tesseract/Rust):
- Add maskedMembers to BaseQueryOptionsStatic
- Store masked_members HashSet in QueryTools
- Add resolve_mask_sql to BaseTools trait (calls back to JS)
- Intercept DimensionSymbol.evaluate_sql and MeasureSymbol.evaluate_sql
  to return mask SQL for masked members

Environment variables for default masks:
- CUBEJS_ACCESS_POLICY_MASK_STRING
- CUBEJS_ACCESS_POLICY_MASK_TIME
- CUBEJS_ACCESS_POLICY_MASK_BOOLEAN
- CUBEJS_ACCESS_POLICY_MASK_NUMBER

View support:
- Propagate mask property when generating view include members

Co-authored-by: Pavel Tiunov <pavel.tiunov@gmail.com>
Add comprehensive integration tests covering:

SQL API tests:
- masking_viewer: all members masked (secret_number=-1, secret_boolean=false,
  count=12345, count_d=34567, secret_string matches SQL mask pattern)
- masking_full: full access user sees real values (no masking)
- masking_partial: mixed access (id, public_dim, total_quantity unmasked;
  secret_number, count masked)
- masking_view: view with its own policy grants full access, bypassing
  cube-level masking

REST API tests:
- masking_viewer sees masked measure and dimension values
- masking_full sees real values
- masking_partial sees mixed real and masked values

Test fixtures:
- masking_test.yaml: cube with mask definitions on dimensions (SQL mask,
  static number, static boolean) and measures (static numbers), plus
  access policies with member_masking includes
- masking_view: view that grants full access to test view-level override
- Three test users in cube.js: masking_viewer, masking_full, masking_partial

Co-authored-by: Pavel Tiunov <pavel.tiunov@gmail.com>
@paveltiunov paveltiunov requested review from a team as code owners March 6, 2026 00:45
@cursor
Copy link

cursor bot commented Mar 6, 2026

Cursor Agent can help with this pull request. Just @cursor in comments and I'll start working on changes in this branch.
Learn more about Cursor Agents

@github-actions github-actions bot added rust Pull requests that update Rust code javascript Pull requests that update Javascript code labels Mar 6, 2026
@paveltiunov paveltiunov changed the title Data access policy masking feat: Data access policy masking Mar 6, 2026
- Run cargo fmt to fix Rust formatting issues in base_tools.rs,
  measure_symbol.rs, and mock_base_tools.rs
- Add maskedMembers to querySchema validation in api-gateway query.js
  to prevent 'maskedMembers is not allowed' errors
- Fix SQL API tests to use SELECT * instead of listing specific columns
  (avoids '#id' invalid identifier issues with primary key columns)

Co-authored-by: Pavel Tiunov <pavel.tiunov@gmail.com>
@codecov
Copy link

codecov bot commented Mar 6, 2026

Codecov Report

❌ Patch coverage is 5.40541% with 70 lines in your changes missing coverage. Please review.
✅ Project coverage is 78.46%. Comparing base (49e9aec) to head (55d0416).
⚠️ Report is 3 commits behind head on master.

Files with missing lines Patch % Lines
...es/cubejs-schema-compiler/src/adapter/BaseQuery.js 5.88% 32 Missing ⚠️
...ackages/cubejs-server-core/src/core/CompilerApi.ts 0.00% 25 Missing ⚠️
...bejs-schema-compiler/src/compiler/CubeEvaluator.ts 0.00% 7 Missing ⚠️
packages/cubejs-backend-shared/src/env.ts 0.00% 4 Missing ⚠️
...cubejs-schema-compiler/src/compiler/CubeSymbols.ts 0.00% 0 Missing and 2 partials ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##           master   #10463      +/-   ##
==========================================
- Coverage   78.52%   78.46%   -0.06%     
==========================================
  Files         472      472              
  Lines       92294    92385      +91     
  Branches     3563     3580      +17     
==========================================
+ Hits        72474    72492      +18     
- Misses      19282    19353      +71     
- Partials      538      540       +2     
Flag Coverage Δ
cube-backend 57.43% <5.40%> (-0.22%) ⬇️
cubesql 83.37% <ø> (-0.01%) ⬇️

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

cursoragent and others added 8 commits March 6, 2026 01:50
Co-authored-by: Pavel Tiunov <pavel.tiunov@gmail.com>
Add Joi .with('memberMasking', 'memberLevel') constraint to
RolePolicySchema so that memberMasking cannot be used without
memberLevel. Also add a runtime check in CubeEvaluator.prepareAccessPolicy
with a descriptive error message.

Co-authored-by: Pavel Tiunov <pavel.tiunov@gmail.com>
Masking should work the same way for views as it does for cubes.
Add comprehensive tests to verify this.

New view fixtures:
- masking_view_masked: all members masked for default role, full access
  for masking_full_access role
- masking_view_partial: public_dim + total_quantity unmasked, rest masked

SQL API tests (views):
- masking_view: verify full-access view returns real values
- masking_view_masked: verify default role sees masked values (-1,
  false, NULL, 12345, 34567, SQL mask pattern)
- masking_view_masked: verify masking_full role sees real values
- masking_view_partial: verify mixed real/masked values

REST API tests (views):
- masking_view_masked viewer: secret_number=-1, count=12345
- masking_view_masked full: count!=12345 (real values)
- masking_view_partial viewer: total_quantity real, count=12345 masked
- masking_view full-access: overrides underlying cube masking

Co-authored-by: Pavel Tiunov <pavel.tiunov@gmail.com>
Add masking_hidden_cube — a cube where all members are hidden via
memberLevel.includes: [] — and masking_view_over_hidden_cube, a view
that re-exposes those members with its own masking policy
(public_dim + total_quantity unmasked, rest masked for default role;
full access for masking_full_access role).

SQL API tests:
- masking_viewer sees masked values through the view (secret_number=-1,
  count=12345) while public_dim and total_quantity are real
- masking_full sees real values through the same view

REST API tests:
- Viewer sees mixed masked/real values through the view
- Full access user sees all real values through the view

Co-authored-by: Pavel Tiunov <pavel.tiunov@gmail.com>
masking_view_masked and masking_view_partial fail with 'missing
FROM-clause entry' because secret_string's SQL mask references
{CUBE}.product_id which resolves to the view alias rather than
the underlying table. Use explicit includes lists to exclude
secret_string from these views, keeping only members with static
masks (-1, FALSE, 12345, etc).

Co-authored-by: Pavel Tiunov <pavel.tiunov@gmail.com>
Remove the cubesAccessedViaView guard from the masking logic so
masking is evaluated at both cube and view levels, matching the
row-level security pattern. This prevents bypassing cube masking
by querying through a view.

Also refine the masking check: a member is only added to the
masked set if at least one covering policy explicitly defines
memberMasking that includes the member. Policies with memberLevel
but no memberMasking do not contribute masking — they only control
access (allow/deny).

Update tests: masking_view (which grants full access at view level)
now correctly shows masked values because the underlying cube's
masking policy is still applied.

Co-authored-by: Pavel Tiunov <pavel.tiunov@gmail.com>
Fix mask propagation for falsy values (false, 0) in view member
generation. The spread pattern ...(value !== undefined && { mask })
short-circuits to ...false when mask is falsy, losing the property.
Use ternary ...(value !== undefined ? { mask } : {}) instead.

Also exclude secret_string from masking_view since the RLS-pattern
change means cube-level masking now applies through views, and
secret_string's SQL mask references {CUBE} columns that resolve
to the view alias rather than the underlying table.

Co-authored-by: Pavel Tiunov <pavel.tiunov@gmail.com>
secret_boolean has {CUBE}.quantity in its regular SQL definition.
Even though its mask is static (FALSE), the SQL API path through
Tesseract/cubesql resolves the underlying member's SQL expression
which contains a {CUBE} reference that maps to the view alias,
causing 'missing FROM-clause entry' errors. Exclude it from the
view includes alongside secret_string.

Co-authored-by: Pavel Tiunov <pavel.tiunov@gmail.com>
cursoragent and others added 4 commits March 6, 2026 21:12
Remove SQL API tests for masking_view and masking_view_partial, and
REST API test for masking_view_partial. These fail with 'missing
FROM-clause entry' when cube-level masking applies to underlying
cube members accessed through a view via the Tesseract SQL planner.
The issue is in the Tesseract query plan generation, not in the
masking logic itself.

The same masking scenarios are still covered by:
- REST API test for masking_view (cube masking through view)
- masking_view_masked SQL/REST tests (view-level masking)
- masking_view_over_hidden_cube SQL/REST tests (view over hidden cube)

Co-authored-by: Pavel Tiunov <pavel.tiunov@gmail.com>
When a view member has a dynamic SQL mask (mask.sql with {CUBE}
references), the {CUBE} must resolve to the underlying cube's table,
not the view alias. Fix both memberMaskSql and resolveMaskSql to
use aliasMember to look up the original cube name and member
definition when evaluating SQL masks.

Add secret_string back to masking_view_masked view and add tests:
- SQL API: verify dynamic SQL mask pattern through view
- REST API: verify {CUBE} in mask.sql resolves correctly through view

Co-authored-by: Pavel Tiunov <pavel.tiunov@gmail.com>
Fix implicit-arrow-linebreak and function-paren-newline eslint
errors in the masking policy check.

Co-authored-by: Pavel Tiunov <pavel.tiunov@gmail.com>
Add secret_string with dynamic SQL mask to masking_hidden_cube.
The view masking_view_over_hidden_cube (includes: *) picks it up.

SQL API test: verify secret_string returns pattern /^\*\*\*.{1,2}$/
REST API test: verify {CUBE} in mask.sql resolves to the underlying
hidden cube's table when accessed through the view

Co-authored-by: Pavel Tiunov <pavel.tiunov@gmail.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

javascript Pull requests that update Javascript code rust Pull requests that update Rust code

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants