Skip to content

[pull] master from cube-js:master#518

Merged
pull[bot] merged 2 commits into
code:masterfrom
cube-js:master
Jun 7, 2026
Merged

[pull] master from cube-js:master#518
pull[bot] merged 2 commits into
code:masterfrom
cube-js:master

Conversation

@pull

@pull pull Bot commented Jun 7, 2026

Copy link
Copy Markdown

See Commits and Changes for more details.


Created by pull[bot] (v2.0.0-alpha.4)

Can you help keep this open source service alive? 💖 Please sponsor : )

paveltiunov and others added 2 commits June 6, 2026 23:21
…lti-group member-level union (#11026)

* fix(schema-compiler): avoid invalid SQL for masked aggregate measures with row-level filters

When an access policy combines member masking on an aggregate measure with
row_level filters, conditional masking rendered
  CASE WHEN {rowFilter} THEN {aggregate} ELSE {mask} END
which references the filter column at row grain while the measure is
aggregated. On strict GROUP BY engines (e.g. BigQuery) this fails to compile
whenever the query does not group by the filter column.

The row-level filter is already enforced in the query WHERE clause, so for an
aggregate (grouped) measure whose mask filter references members that are not
part of the GROUP BY we now render the mask value directly (NULL by default)
instead of a per-row CASE WHEN. Conditional masking is still used for
dimensions, for ungrouped (row-grain) measures, and for measures whose filter
members are all in the GROUP BY.

Applied to both the legacy BaseQuery and the Tesseract native SQL planner.

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

* test(schema-compiler): add smoke tests for masked aggregate measure policy scenarios

- single policy match: masked aggregate measure with no conditional filter
  renders the mask without a CASE WHEN and stays valid SQL
- two policy match: conditional filter attached, but not all row filter
  dimensions are in the GROUP BY, so the measure renders the mask instead of
  a CASE WHEN over an ungrouped column

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

* test(cubejs-testing): add RBAC smoke tests for masked aggregate measures

Add two end-to-end smoke tests to the existing RBAC conditional masking
suite (smoke-rbac.test.ts), exercising aggregate-measure masking through
the SQL API against Postgres:

- single filter policy match: querying an aggregate measure (total_price)
  grouped by a non-filter member does not trigger a CASE WHEN and renders
  the mask, compiling successfully.
- multiple policy match: with two row-level filter policies the conditional
  masking is engaged, but because the row filter members are not in the
  GROUP BY the measure renders the mask instead of an invalid CASE WHEN.

Move the equivalent policy-scenario smoke coverage out of the
schema-compiler unit suite (kept the focused BaseQuery masking unit tests).

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

* test(cubejs-testing): group masked aggregate measure by selectable dimension

The conditional_masking_test cube does not expose its primary key 'id' as a
selectable column in the SQL API. Group by 'price' (a selectable, non-filter
dimension) so product_id (the row filter member) stays out of the GROUP BY,
exercising the masked-aggregate-measure path.

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

* fix(server-core): union member-level access across matching policies without row filters

When a user matches multiple access policies that each grant member-level
access to different members, and those policies do not define row_level
filters (row access defaults to allow-all), member-level access must be the
UNION across the matching policies. Previously access required a single
policy to cover every queried member, so a cross-policy query (e.g. fields
from two data-product groups) was denied and returned no data (CUB-2758).

The union is only applied when the covering policies have no row-level
filters, so the disjoint row-range behavior (querying members spanning
policies with different row filters) keeps denying access rather than
silently widening the visible rows.

Adds RBAC smoke tests (multi_group_test view + multi_group_user) covering
single-policy member access and the cross-group union.

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

* refactor(server-core): resolve access-policy row/member access per member

Replace the special-case multi-group union with a single general model in
applyRowLevelSecurity:

- Member access is the UNION across all matching policies: a member is
  accessible if any applicable policy grants it (full or masked). Access is
  denied only when a queried member is granted by no policy.
- Row-level access is resolved per member and intersected: for each queried
  member, OR the row filters of the policies granting it (an allow-all policy
  imposes no restriction); the cube row constraint is the AND of those
  per-member expressions (deduped). This naturally handles every case without
  special-casing:
    * multi-group, filterless policies -> no restriction (union of members);
    * single covering policy -> that policy's filter;
    * members spanning disjoint row ranges -> empty intersection (no leak).

This removes the previous 'policies covering all members' gate (which blocked
the multi-group union) and the now-redundant buildFinalRlsFilter helper; the
two-dimensional overlap behavior (incl. the denial case) is preserved.

All JS-config RBAC smoke tests pass (multi-group union, two-dimensional
overlap Cases 1-4, masking, conditional masking, views).

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

* test(cubejs-testing): cover combined cube + view row-level filtering

Add a smoke test exercising both row-level policy layers at once: the
orders_two_layer_test view (row filter id < 11) over the orders cube (row
filter id IN {1,10,11}). A row must satisfy both the cube and the view
policy, so the visible ids are the intersection {1, 10}. Verifies the
per-member RLS model applies and AND-combines cube-level and view-level row
filters.

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

* docs(access-policies): document the permission-space model

Add a 'permission space' section to the access policies guide explaining the
two-dimensional (members x rows) model, how multiple matching policies combine
(members unioned, rows intersected across queried members, masking, denial),
and a worked diagram with the four query outcomes for overlapping policies.

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

* docs(access-policies): document masking caveats for multiple policies and measures

Add two masking caveats to the access policies guide:
- Masking across multiple policies: unconditional full access wins over
  masking (member unioned); when full access is conditional on a row filter,
  masking becomes conditional (CASE WHEN rowFilter THEN value ELSE mask).
- Conditional masking on measures: an aggregate measure whose row filter
  members are not in the GROUP BY is fully masked (NULL by default) instead of
  emitting an invalid per-row CASE WHEN over the aggregate.

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

* test(cubejs-testing): unmasked measure with row filter when only full-access policy matches

Add an RBAC smoke test for a cube with two policies — a group-scoped masking
policy and a group-scoped full-access policy with a row filter. The test user
matches ONLY the full-access policy, so the masked measure must render its real
aggregated value (no matching masking policy) while the row-level filter is
still applied — verified for a measure-only query with no GROUP BY (a max
measure confirms the filter, the summed measure confirms it is unmasked).

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

* feat(server-core): unmask members when query is already at least as restrictive as the row-security filter

When a member is conditionally masked (CASE WHEN {rowFilter} THEN value ELSE
mask END) and the query itself already constrains rows to a subset of that row
filter, every returned row satisfies the filter, so the conditional mask is
unnecessary. applyRowLevelSecurity now detects this (sound filter-implication
check on the same member: exact match, equals/in subset, and same-direction
numeric range bounds) and unmasks the member.

This also lets a conditionally-masked aggregate measure render its real value
instead of being fully masked to NULL when the query is already scoped to the
filter's rows, even without grouping by the filter's member.

- Adds RBAC smoke tests (query filter equal to / more restrictive than the row
  filter unmasks the aggregate measure / dimension).
- Documents the behavior in the access policies masking caveats.

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

* test(cubejs-testing): negative case for query-filter unmask optimization

Add an RBAC smoke test asserting that a query filter which is NOT at least as
restrictive as the conditional mask's row filter (product_id <= 10, broader
than the mask's product_id <= 3) does NOT unmask: the conditionally-masked
aggregate measure stays masked (NULL) for every row. Confirms the unmask
optimization only applies when the query is provably at least as restrictive.

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

* refactor(schema-compiler): dedupe collected mask filter member paths

Address PR review nit: collapse duplicate member paths collected from nested
and/or filter trees before the group-by membership check in
maskFilterReferencesOnlyGroupByMembers.

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

---------

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: Cursor Agent <cursoragent@cursor.com>
@pull pull Bot locked and limited conversation to collaborators Jun 7, 2026
@pull pull Bot added the ⤵️ pull label Jun 7, 2026
@pull pull Bot merged commit 1a3acd8 into code:master Jun 7, 2026
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant