Skip to content

Commit 1f755a1

Browse files
fix: Support not in access policy conditions (cube-js#10767)
* feat: add RBAC smoke test for group-based conditional region row filtering Add a test that verifies group-based conditional row filtering: - region_test cube backed by users table (id, city, count) - region_test_view with an access policy for user_group that conditionally applies a row filter based on region_group membership - region_user (user_group + region_group): sees only San Francisco rows - region_user_no_filter (user_group only): sees all rows The access policy checks security_context.auth.groups for region_group membership. If present, it filters rows by the user's region attribute. If absent, it grants allow_all (no row filter). Co-authored-by: Pavel Tiunov <pavel.tiunov@gmail.com> * fix: use line_items table, group-based conditions, and MEASURE syntax - Switch region_test cube to use public.line_items table to avoid cross-contamination from the users cube's RBAC city filter - Add cube-level allowAll access policy so view policies can layer on top correctly - Use group-based (user_group) policies with conditions to check hasRegionFilter user attribute for conditional row filtering - Use MEASURE() syntax for aggregate count queries - Both users have groups only (no roles needed) — view policy uses group: 'user_group' with conditions for mutual exclusivity Co-authored-by: Pavel Tiunov <pavel.tiunov@gmail.com> * feat: allow security_context evaluation at rowLevel in access policies Enable security_context to be used directly at the rowLevel property of access policies, allowing the entire filter structure to be controlled dynamically based on the user's security context. Changes: - CubePropContextTranspiler: add special handling to transpile rowLevel/row_level when its value is an expression (not an object literal), wrapping it in an arrow function with securityContext - CubeValidator: accept Joi.func() as alternative for rowLevel, alongside the existing RowLevelPolicySchema object - CompilerApi: resolve rowLevel via evaluateContextFunction when it is a function, and process the returned raw filters through a new evaluateRawFilter method that handles uncompiled member references - Update test to use the cleaner syntax where security_context controls the rowLevel structure directly via groups.includes() This enables patterns like: rowLevel: security_context.auth?.groups?.includes('region_group') ? { filters: [{ member: 'col', operator: 'equals', values: ... }] } : { allowAll: true } Co-authored-by: Pavel Tiunov <pavel.tiunov@gmail.com> * refactor: convert region_test_view from JS to YAML Replace the JS view with a YAML view that uses two mutually exclusive conditions on the user_group access policy: - hasRegionFilter (truthy) -> filters by allowedProductIds - noRegionFilter (truthy) -> allow_all Users carry complementary boolean attributes so the YAML conditions (which only support truthy checks, not comparisons or negation) can distinguish the two cases without policy overlap. Co-authored-by: Pavel Tiunov <pavel.tiunov@gmail.com> * refactor: use group-based policies instead of attribute-based conditions Replace attribute-based conditions (hasRegionFilter/noRegionFilter) with pure group-based policy scoping: - group: region_group — row filter by allowedProductIds - group: user_group — allow_all Each user belongs to exactly one group so only one policy matches, avoiding the union overlap problem without needing conditions. Co-authored-by: Pavel Tiunov <pavel.tiunov@gmail.com> * refactor: revert core changes, use conditions with groups.includes() check Revert all changes to cubejs-schema-compiler and cubejs-server-core. Use standard access policy conditions to check security context groups: - Two mutually exclusive user_group policies with conditions: - if: security_context.auth?.groups?.includes('region_group') → filters by allowedProductIds - if: !security_context.auth?.groups?.includes('region_group') → allowAll - region_user has both ['user_group', 'region_group'] and is correctly filtered because the condition routes to the filter policy, not the allowAll policy. Co-authored-by: Pavel Tiunov <pavel.tiunov@gmail.com> * refactor: convert region_test_view to YAML with group-scoped policies Replace JS view with YAML view using two group-scoped policies: - group: region_group — filters by allowedProductIds - group: user_group — allow_all Users belong to exactly one group to avoid the union-overlap problem where allow_all would override the filter. No core changes. Co-authored-by: Pavel Tiunov <pavel.tiunov@gmail.com> * fix: use JS view with groups.includes() conditions for both-groups support A user with both user_group and region_group must be filtered by region_group. This requires mutually exclusive conditions checking groups.includes('region_group') — which needs JS (not YAML, since the YAML Python parser cannot express negation). Single user_group policy with two condition branches: - groups.includes('region_group') → filter by allowedProductIds - !groups.includes('region_group') → allowAll No core changes. region_user now has ['user_group', 'region_group'] and is correctly filtered. Co-authored-by: Pavel Tiunov <pavel.tiunov@gmail.com> * feat: add not/and/or support to YAML Python parser, convert view to YAML Add support for Python boolean operators in the YAML expression parser: - not → JS ! (unary negation) - and → JS && (logical AND) - or → JS || (logical OR) This enables YAML access policy conditions like: if: "{ not (security_context.auth.groups and security_context.auth.groups.includes('region_group')) }" Convert region_test_view from JS to YAML using the new operators for null-safe group membership checks in conditions. Co-authored-by: Pavel Tiunov <pavel.tiunov@gmail.com> --------- Co-authored-by: Cursor Agent <cursoragent@cursor.com>
1 parent d9fa353 commit 1f755a1

5 files changed

Lines changed: 176 additions & 0 deletions

File tree

packages/cubejs-schema-compiler/src/parser/PythonParser.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,12 @@ import Python3Parser, {
1818
Single_string_template_atomContext,
1919
ArglistContext,
2020
CallArgumentsContext,
21+
// eslint-disable-next-line camelcase
22+
Not_testContext,
23+
// eslint-disable-next-line camelcase
24+
And_testContext,
25+
// eslint-disable-next-line camelcase
26+
Or_testContext,
2127
} from './Python3Parser';
2228
import { UserError } from '../compiler/UserError';
2329
import Python3ParserVisitor from './Python3ParserVisitor';
@@ -223,6 +229,21 @@ export class PythonParser {
223229
return { args: children };
224230
} else if (node instanceof LambdefContext) {
225231
return t.arrowFunctionExpression(children[0].args, children[1]);
232+
} else if (node instanceof Not_testContext) {
233+
if (node.getChildCount() === 1) {
234+
return children[0];
235+
}
236+
return t.unaryExpression('!', children[0]);
237+
} else if (node instanceof And_testContext) {
238+
if (children.length === 1) {
239+
return children[0];
240+
}
241+
return children.reduce((left, right) => t.logicalExpression('&&', left, right));
242+
} else if (node instanceof Or_testContext) {
243+
if (children.length === 1) {
244+
return children[0];
245+
}
246+
return children.reduce((left, right) => t.logicalExpression('||', left, right));
226247
} else if (node instanceof ArglistContext) {
227248
return children;
228249
} else {

packages/cubejs-testing/birdbox-fixtures/rbac/cube.js

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -186,6 +186,42 @@ module.exports = {
186186
},
187187
};
188188
}
189+
if (user === 'region_user') {
190+
if (password && password !== 'region_user_password') {
191+
throw new Error(`Password doesn't match for ${user}`);
192+
}
193+
return {
194+
password,
195+
superuser: false,
196+
securityContext: {
197+
auth: {
198+
username: 'region_user',
199+
userAttributes: {
200+
allowedProductIds: [1, 2],
201+
},
202+
roles: [],
203+
groups: ['user_group', 'region_group'],
204+
},
205+
},
206+
};
207+
}
208+
if (user === 'region_user_no_filter') {
209+
if (password && password !== 'region_user_no_filter_password') {
210+
throw new Error(`Password doesn't match for ${user}`);
211+
}
212+
return {
213+
password,
214+
superuser: false,
215+
securityContext: {
216+
auth: {
217+
username: 'region_user_no_filter',
218+
userAttributes: {},
219+
roles: [],
220+
groups: ['user_group'],
221+
},
222+
},
223+
};
224+
}
189225
if (user === 'sc_test') {
190226
if (password && password !== 'sc_test_password') {
191227
throw new Error(`Password doesn't match for ${user}`);
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
cubes:
2+
- name: region_test
3+
sql_table: public.line_items
4+
5+
measures:
6+
- name: count
7+
type: count
8+
9+
dimensions:
10+
- name: id
11+
sql: id
12+
type: number
13+
primary_key: true
14+
15+
- name: product_id
16+
sql: product_id
17+
type: number
18+
19+
access_policy:
20+
- role: "*"
21+
member_level:
22+
includes: "*"
23+
row_level:
24+
allow_all: true
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
views:
2+
- name: region_test_view
3+
cubes:
4+
- join_path: region_test
5+
includes: "*"
6+
access_policy:
7+
- group: user_group
8+
conditions:
9+
- if: "{ security_context.auth.groups and security_context.auth.groups.includes('region_group') }"
10+
member_level:
11+
includes: "*"
12+
row_level:
13+
filters:
14+
- member: product_id
15+
operator: equals
16+
values: security_context.auth.userAttributes.allowedProductIds
17+
18+
- group: user_group
19+
conditions:
20+
- if: "{ not (security_context.auth.groups and security_context.auth.groups.includes('region_group')) }"
21+
member_level:
22+
includes: "*"
23+
row_level:
24+
allow_all: true

packages/cubejs-testing/test/smoke-rbac.test.ts

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1059,6 +1059,77 @@ describe('Cube RBAC Engine', () => {
10591059
});
10601060
});
10611061

1062+
/**
1063+
* Group-based conditional row filtering test.
1064+
*
1065+
* A view (region_test_view) wraps the region_test cube (backed by
1066+
* line_items). A single access policy for "user_group" uses conditions
1067+
* to check security_context.auth.groups for "region_group" membership:
1068+
* - If groups.includes('region_group') is true, the first policy
1069+
* matches and filters rows by product_id using allowedProductIds.
1070+
* - If !groups.includes('region_group'), the second policy matches
1071+
* and grants allowAll.
1072+
*
1073+
* The conditions are mutually exclusive (includes vs !includes) so
1074+
* only one policy matches per user, avoiding the union-overlap
1075+
* problem. A user with both user_group and region_group is correctly
1076+
* filtered because the condition on the first policy evaluates to
1077+
* true, and the condition on the second evaluates to false.
1078+
*
1079+
* Two users:
1080+
* - region_user: groups = ['user_group', 'region_group'],
1081+
* allowedProductIds = [1, 2]
1082+
* → sees only rows with product_id in [1, 2]
1083+
* - region_user_no_filter: groups = ['user_group']
1084+
* → sees all rows
1085+
*/
1086+
describe('RBAC via SQL API region group conditional row filter', () => {
1087+
let regionConn: PgClient;
1088+
let noFilterConn: PgClient;
1089+
1090+
beforeAll(async () => {
1091+
regionConn = await createPostgresClient('region_user', 'region_user_password');
1092+
noFilterConn = await createPostgresClient('region_user_no_filter', 'region_user_no_filter_password');
1093+
});
1094+
1095+
afterAll(async () => {
1096+
await regionConn.end();
1097+
await noFilterConn.end();
1098+
}, JEST_AFTER_ALL_DEFAULT_TIMEOUT);
1099+
1100+
test('user with region_group sees only rows matching their allowed product IDs', async () => {
1101+
const res = await regionConn.query(
1102+
'SELECT * FROM region_test_view ORDER BY id LIMIT 50'
1103+
);
1104+
expect(res.rows.length).toBeGreaterThan(0);
1105+
for (const row of res.rows) {
1106+
expect([1, 2]).toContain(row.product_id);
1107+
}
1108+
});
1109+
1110+
test('user without region_group sees all rows (no row filter)', async () => {
1111+
const res = await noFilterConn.query(
1112+
'SELECT * FROM region_test_view ORDER BY id LIMIT 50'
1113+
);
1114+
expect(res.rows.length).toBeGreaterThan(0);
1115+
const productIds = new Set(res.rows.map((r: any) => r.product_id));
1116+
expect(productIds.size).toBeGreaterThan(2);
1117+
});
1118+
1119+
test('filtered user count is less than unfiltered user count', async () => {
1120+
const filteredRes = await regionConn.query(
1121+
'SELECT MEASURE(count) as cnt FROM region_test_view'
1122+
);
1123+
const unfilteredRes = await noFilterConn.query(
1124+
'SELECT MEASURE(count) as cnt FROM region_test_view'
1125+
);
1126+
const filteredCount = Number(filteredRes.rows[0].cnt);
1127+
const unfilteredCount = Number(unfilteredRes.rows[0].cnt);
1128+
expect(filteredCount).toBeGreaterThan(0);
1129+
expect(unfilteredCount).toBeGreaterThan(filteredCount);
1130+
});
1131+
});
1132+
10621133
describe('RBAC via REST API', () => {
10631134
let client: CubeApi;
10641135
let defaultClient: CubeApi;

0 commit comments

Comments
 (0)