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
134 changes: 132 additions & 2 deletions packages/cubejs-server-core/src/core/CompilerApi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -521,7 +521,26 @@ export class CompilerApi {
return { query, denied: false };
}

const queryCubes = await this.getCubesFromQuery(evaluatedQuery, context);
// Get the SQL to extract member names from the query
const sql = await this.getSql(evaluatedQuery, { requestId: context?.requestId });
const queryMemberNames = new Set(sql.memberNames);
const queryCubes = new Set(sql.memberNames.map(memberName => memberName.split('.')[0]));

// Identify cubes that are accessed through views.
// Similar to PostgreSQL views: views act as a security boundary for member access.
// When a cube is accessed via a view, we skip the cube's member-level restrictions
// and only apply row-level filters. The view controls what members are exposed.
const cubesAccessedViaView = new Set<string>();
for (const cubeName of queryCubes) {
const cube = cubeEvaluator.cubeFromPath(cubeName);
if (cube.isView) {
// Track which underlying cubes are accessed through this view
const underlyingCubes = new Set(
(cube.includedMembers || []).map((m: any) => m.memberPath.split('.')[0])
);
underlyingCubes.forEach(c => cubesAccessedViaView.add(c));
}
}

// We collect Cube and View filters separately because they have to be
// applied in "two layers": first Cube filters, then View filters on top
Expand All @@ -537,7 +556,118 @@ export class CompilerApi {
let hasAccessPermission = false;
const userPolicies = await this.getApplicablePolicies(cube, context, compilers);

for (const policy of userPolicies) {
// Filter out policies that don't grant member-level access to query members
//
// Policies define access in two dimensions: Members (columns) and Rows.
// We first filter by member access, then apply row-level filters.
//
// Example setup:
// - Policy 1 covers members: a, b (with row filter R1)
// - Policy 2 covers members: b, c (with row filter R2)
//
// Members
// ^
// | ┌─────────────────────────────┐
// c | │ Policy 2 │
// | ┌───┼─────────────┐ │
// b | │ │ (overlap) │ │
// | │ └─────────────┼───────────────┘
// a | │ Policy 1 │
// | └─────────────────┘
// └──────────────────────────────────────────> Rows
// R1 rows R2 rows
//
// ═══════════════════════════════════════════════════════════════════
// Case 1: Query members (a, b)
// Only Policy 1 covers ALL queried members → R1 rows visible
//
// Members
// ^
// | ┌─────────────────────────────┐
// c | │ Policy 2 │
// | ┌───┼─────────────┐ │
// b | │░░░│░░(query)░░░░│ │
// | │░░░└─────────────┼───────────────┘
// a | │░░░░Policy 1░░░░░│
// | └─────────────────┘
// └──────────────────────────────────────────> Rows
// ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑
// R1 rows visible
//
// ═══════════════════════════════════════════════════════════════════
// Case 2: Query members (b, c)
// Only Policy 2 covers ALL queried members → R2 rows visible
//
// Members
// ^
// | ┌─────────────────────────────┐
// c | │░░░░░░░░░░Policy 2░░░░░░░░░░░│
// | ┌───┼─────────────┐░░░░░░░░░░░░░░░│
// b | │ │░░(query)░░░░│░░░░░░░░░░░░░░░│
// | │ └─────────────┼───────────────┘
// a | │ Policy 1 │
// | └─────────────────┘
// └──────────────────────────────────────────> Rows
// ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑
// R2 rows visible
//
// ═══════════════════════════════════════════════════════════════════
// Case 3: Query member (b) only
// Both policies cover member b → Union of R1 ∪ R2 rows visible
//
// Members
// ^
// | ┌─────────────────────────────┐
// c | │ Policy 2 │
// | ┌───┼─────────────┐ │
// b | │░░░│░░(query)░░░░│░░░░░░░░░░░░░░░│
// | │ └─────────────┼───────────────┘
// a | │ Policy 1 │
// | └─────────────────┘
// └──────────────────────────────────────────> Rows
// ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑
// R1 ∪ R2 rows visible (union)
//
// ═══════════════════════════════════════════════════════════════════
// Case 4: Query members (a, b, c)
// Neither policy covers ALL three → NO rows visible (denied)
//
// Members
// ^
// | ┌─────────────────────────────┐
// c | │ Policy 2 │
// | ┌───┼─────────────┐ │
// b | │ │ (query) │ │
// | │ └─────────────┼───────────────┘
// a | │ Policy 1 │
// | └─────────────────┘
// └──────────────────────────────────────────> Rows
//
// 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) {
return true;
}

// PostgreSQL-style view behavior: if this cube is accessed through a view,
// the view grants access to all members it exposes.
// We only apply row-level filters from the cube, not member-level restrictions.
if (cubesAccessedViaView.has(cubeName)) {
return true;
}

const cubeMembersInQuery = Array.from(queryMemberNames).filter(
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));
});

for (const policy of policiesWithMemberAccess) {
hasAccessPermission = true;
(policy?.rowLevel?.filters || []).forEach((filter: any) => {
filtersMap[cubeName] = filtersMap[cubeName] || {};
Expand Down
41 changes: 41 additions & 0 deletions packages/cubejs-testing/birdbox-fixtures/rbac/cube.js
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,47 @@ module.exports = {
},
};
}
// Developer user for testing overlapping policies scenario
// where group "*" has empty member includes and "developer" has row filter
if (user === 'developer') {
if (password && password !== 'developer_password') {
throw new Error(`Password doesn't match for ${user}`);
}
return {
password,
superuser: false,
securityContext: {
auth: {
username: 'developer',
userAttributes: {
region: 'CA',
allowedCities: ['Los Angeles', 'New York'],
},
roles: [],
groups: ['developer'],
},
},
};
}
// User for testing two-dimensional policy overlap (matches diagram in CompilerApi.ts)
// Has policy2_role, so both Policy 1 (*) and Policy 2 (policy2_role) apply
if (user === 'policy_test') {
if (password && password !== 'policy_test_password') {
throw new Error(`Password doesn't match for ${user}`);
}
return {
password,
superuser: false,
securityContext: {
auth: {
username: 'policy_test',
userAttributes: {},
roles: ['policy2_role'],
groups: [],
},
},
};
}
throw new Error(`User "${user}" doesn't exist`);
}
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
# Test case for overlapping access policies with member-level and row-level filters.
#
# This tests the scenario where:
# - Policy 1: group "*" with memberLevel.includes: [] (no members)
# - Policy 2: group "developer" with memberLevel.includes: "*" and row_level filters
# - Policy 3: group "admin" with memberLevel.includes: "*" and allowAll
#
# The row-level filter from the developer policy SHOULD be applied when a developer
# queries for members, because:
#
# Members
# ^
# | ┌─────────────────┐
# | │ Policy 1 │ (no members, no row filter)
# | │ ┌─────────────┼───────────────┐
# | │ │ │ │
# | └───┼─────────────┘ Policy 2 │ (all members, with row filter)
# | │ │
# | └─────────────────────────────┘
# └──────────────────────────────────────────> Rows
#
# Policy 1 covers no members (empty includes), so it should not affect row filtering.
# Policy 2 covers all members with a row filter, so the filter MUST be applied.

cubes:
- name: customers
sql_table: users

measures:
- name: count
type: count

- name: total_count
sql: "1"
type: sum

dimensions:
- name: id
sql: id
type: number
primary_key: true

- name: city
sql: city
type: string

access_policy:
# Policy 1: All groups, but grants access to NO members
- group: "*"
member_level:
includes: []

# Policy 2: Developers get all members, but with row-level filter on city
- group: developer
member_level:
includes: "*"
row_level:
filters:
- member: city
operator: equals
values: security_context.auth.userAttributes.allowedCities

# Policy 3: Admins get all members with no row restrictions
- group: leadership
member_level:
includes: "*"
row_level:
allow_all: true

Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
# Test view for validating two-dimensional policy behavior
# Matches the diagram in CompilerApi.ts:559-647
#
# Base cube has no policies - the view applies the access policies
#
# Policy 1: covers members a, b with row filter R1 (id < 500)
# Policy 2: covers members b, c with row filter R2 (id >= 500)
#
# Expected behavior:
# Query (a, b) → Only Policy 1 applies → R1 rows (id < 500)
# Query (b, c) → Only Policy 2 applies → R2 rows (id >= 500)
# Query (b) → Both policies apply → R1 ∪ R2 rows (all rows)
# Query (a, b, c) → Neither covers all → Empty result (denied)

cubes:
# Base cube with no access policy
- name: policy_overlap_base
sql_table: public.line_items

dimensions:
- name: id
sql: id
type: number
primary_key: true

# Member "a" - only covered by Policy 1
- name: member_a
sql: order_id
type: number

# Member "b" - covered by both Policy 1 and Policy 2
- name: member_b
sql: product_id
type: number

# Member "c" - only covered by Policy 2
- name: member_c
sql: quantity
type: number

measures:
- name: count
type: count

views:
# View with two-dimensional access policies
- name: policy_overlap_test
cubes:
- join_path: policy_overlap_base
includes: "*"

access_policy:
# Policy 1: covers members a, b (and count, id for filtering) with row filter R1 (id < 500)
- role: "*"
member_level:
includes:
- id
- count
- member_a
- member_b
row_level:
filters:
- member: id
operator: lt
values:
- "500"

# Policy 2: covers members b, c (and count, id for filtering) with row filter R2 (id >= 500)
- role: "policy2_role"
member_level:
includes:
- id
- count
- member_b
- member_c
row_level:
filters:
- member: id
operator: gte
values:
- "500"
Loading
Loading