From 2668616dab033b7bc3a7684e829bc7ff337d78f5 Mon Sep 17 00:00:00 2001 From: Alex Vasilev Date: Mon, 18 Aug 2025 19:43:22 -0700 Subject: [PATCH 1/4] feat: introduce groups support --- packages/cubejs-backend-native/js/index.ts | 1 + .../python/cube/src/__init__.py | 2 + .../src/python/cube_config.rs | 1 + .../test/__snapshots__/jinja.test.ts.snap | 294 ------------------ packages/cubejs-backend-native/test/config.py | 10 + .../cubejs-backend-native/test/old-config.py | 12 + .../cubejs-backend-native/test/python.test.ts | 10 + .../src/compiler/CubeSymbols.ts | 3 + .../src/compiler/CubeValidator.ts | 10 +- .../transpilers/CubePropContextTranspiler.ts | 2 - .../test/unit/cube-validator.test.ts | 126 ++++++++ .../src/core/CompilerApi.js | 12 + .../src/core/optionsValidate.ts | 1 + packages/cubejs-server-core/src/core/types.ts | 2 + 14 files changed, 188 insertions(+), 298 deletions(-) delete mode 100644 packages/cubejs-backend-native/test/__snapshots__/jinja.test.ts.snap diff --git a/packages/cubejs-backend-native/js/index.ts b/packages/cubejs-backend-native/js/index.ts index 8c2c190112765..f8bbed4f0f746 100644 --- a/packages/cubejs-backend-native/js/index.ts +++ b/packages/cubejs-backend-native/js/index.ts @@ -527,6 +527,7 @@ export interface PyConfiguration { scheduledRefreshContexts?: (ctx: unknown) => Promise scheduledRefreshTimeZones?: (ctx: unknown) => Promise contextToRoles?: (ctx: unknown) => Promise + contextToGroups?: (ctx: unknown) => Promise } function simplifyExpressRequest(req: ExpressRequest) { diff --git a/packages/cubejs-backend-native/python/cube/src/__init__.py b/packages/cubejs-backend-native/python/cube/src/__init__.py index 6fee80399ad69..f779f8d13864f 100644 --- a/packages/cubejs-backend-native/python/cube/src/__init__.py +++ b/packages/cubejs-backend-native/python/cube/src/__init__.py @@ -78,6 +78,7 @@ class Configuration: pre_aggregations_schema: Union[Callable[[RequestContext], str], str] orchestrator_options: Union[Dict, Callable[[RequestContext], Dict]] context_to_roles: Callable[[RequestContext], list[str]] + context_to_groups: Callable[[RequestContext], list[str]] fast_reload: bool def __init__(self): @@ -128,6 +129,7 @@ def __init__(self): self.pre_aggregations_schema = None self.orchestrator_options = None self.context_to_roles = None + self.context_to_groups = None self.fast_reload = None def __call__(self, func): diff --git a/packages/cubejs-backend-native/src/python/cube_config.rs b/packages/cubejs-backend-native/src/python/cube_config.rs index f9886dd64f209..0dcb71a44531f 100644 --- a/packages/cubejs-backend-native/src/python/cube_config.rs +++ b/packages/cubejs-backend-native/src/python/cube_config.rs @@ -52,6 +52,7 @@ impl CubeConfigPy { "context_to_orchestrator_id", "context_to_cube_store_router_id", "context_to_roles", + "context_to_groups", "db_type", "driver_factory", "extend_context", diff --git a/packages/cubejs-backend-native/test/__snapshots__/jinja.test.ts.snap b/packages/cubejs-backend-native/test/__snapshots__/jinja.test.ts.snap deleted file mode 100644 index 85cf706de4e07..0000000000000 --- a/packages/cubejs-backend-native/test/__snapshots__/jinja.test.ts.snap +++ /dev/null @@ -1,294 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`Jinja (new api) render 01.yml.jinja: 01.yml.jinja 1`] = ` -"cubes: - - name: cube_01_1 - sql: > - SELECT - order_id, - SUM(CASE WHEN payment_method = 'TRANSFER' THEN amount END) AS bank_transfer_amount, - SUM(CASE WHEN payment_method = 'CREDIT' THEN amount END) AS credit_card_amount, - SUM(CASE WHEN payment_method = 'GIFT' THEN amount END) AS gift_card_amount, - SUM(amount) AS total_amount - FROM app_data.payments - GROUP BY 1 - - - name: cube_01_2 - sql: > - SELECT - order_id, - SUM(CASE WHEN payment_method = 'TRANSFER' THEN amount END) AS bank_transfer_amount, - SUM(CASE WHEN payment_method = 'CREDIT' THEN amount END) AS credit_card_amount, - SUM(CASE WHEN payment_method = 'GIFT' THEN amount END) AS gift_card_amount - FROM app_data.payments - GROUP BY 1" -`; - -exports[`Jinja (new api) render 02.yml.jinja: 02.yml.jinja 1`] = ` -"cubes: - - name: cube_02 - sql: > - SELECT - referrer_prop.value AS referrer - href_prop.value AS href - host_prop.value AS host - pathname_prop.value AS pathname - search_prop.value AS search - FROM public.events - LEFT JOIN UNNEST(properties) AS referrer_prop ON referrer_prop.key = 'referrer' - LEFT JOIN UNNEST(properties) AS href_prop ON href_prop.key = 'href' - LEFT JOIN UNNEST(properties) AS host_prop ON host_prop.key = 'host' - LEFT JOIN UNNEST(properties) AS pathname_prop ON pathname_prop.key = 'pathname' - LEFT JOIN UNNEST(properties) AS search_prop ON search_prop.key = 'search'" -`; - -exports[`Jinja (new api) render 03.yml.jinja: 03.yml.jinja 1`] = ` -" - -cubes: - - name: cube_03 - sql: > - SELECT - *, - 'au' as country - FROM au_orders - - UNION ALL - - SELECT - *, - 'us' as country - FROM us_orders - " -`; - -exports[`Jinja (new api) render 04.yml.jinja: 04.yml.jinja 1`] = ` -" - -cubes: - - name: cube_04_\\"base_events\\" - sql: > - SELECT * - FROM public.events - WHERE - {FILTER_PARAMS.cube_04_base_events.timestamp.filter('timestamp')} AND - {FILTER_PARAMS.cube_04_product_purchases.timestamp.filter('timestamp')} AND - {FILTER_PARAMS.cube_04_page_views.timestamp.filter('timestamp')} - - dimensions: - - name: timestamp - sql: timestamp - type: time - - - name: cube_04_product_purchases - extends: \\"base_events\\" - sql_table: public.events - - dimensions: - - name: timestamp - sql: timestamp - type: time - - - name: cube_04_page_views - extends: \\"base_events\\" - sql_table: public.events - - dimensions: - - name: timestamp - sql: timestamp - type: time - " -`; - -exports[`Jinja (new api) render 05.yml.jinja: 05.yml.jinja 1`] = ` -" - -cubes: - - name: cube_05 - sql_table: public.orders - - measures: - - name: \\"day\\" - type: count_distinct - sql: user_id - rolling_window: - trailing: 1 day - offset: start - - - name: \\"mau\\" - type: count_distinct - sql: user_id - rolling_window: - trailing: 30 day - offset: start - - - name: \\"wau\\" - type: count_distinct - sql: user_id - rolling_window: - trailing: 7 day - offset: start - " -`; - -exports[`Jinja (new api) render 06.yml.jinja: 06.yml.jinja 1`] = ` -"cubes: - - name: cube_06 - sql_table: public.orders - - dimensions: - - name: \\"id\\" - sql: \\"id\\" - type: \\"number\\" - primary_key: true - - - name: \\"status\\" - sql: \\"status\\" - type: \\"string\\" - - - name: \\"created_at\\" - sql: \\"created_at\\" - type: \\"time\\" - - - name: \\"completed_at\\" - sql: \\"completed_at\\" - type: \\"time\\" - " -`; - -exports[`Jinja (new api) render 07.yml.jinja: 07.yml.jinja 1`] = ` -"cubes: - - name: cube_07 - sql: > - SELECT - id AS payment_id, - (\\"amount\\" / 100)::NUMERIC(16, 2) AS amount_usd, - ((\\"order_selling_price\\" - \\"order_cost_price\\") / \\"order_cost_price\\") AS markup - FROM app_data.payments" -`; - -exports[`Jinja (new api) render 08.yml.jinja: 08.yml.jinja 1`] = ` -"{ cubes: - - name: cube_08 - sql_table: public.orders - data_source: \\"postgres\\" }" -`; - -exports[`Jinja (new api) render arguments-test.yml.jinja: arguments-test.yml.jinja 1`] = ` -"test: - arg_sum_integers_int_int: 2 - arg_sum_integers_int_float: 4.140000000000001 - arg_bool_true: 1 - arg_bool_false: 0 - arg_str: \\"hello world\\" - arg_null: null - arg_seq_1: [1,2,3,4,5] - arg_seq_2: [5,4,3,2,1] - arg_sum_tuple: 3 - arg_sum_map: 20 - arg_kwargs1: \\"arg1: first value, arg2: second value, kwarg:(three=3 arg)\\" - arg_kwargs2: \\"arg1: first value, arg2: second value, kwarg:(four=4 arg,three=3 arg)\\" - arg_kwargs3: \\"arg1: first value, arg2: second value, kwarg:(five=5 arg,four=4 arg,three=3 arg)\\" - arg_named_arguments1: \\"arg1: 1 arg, arg2: 2 arg\\" - arg_named_arguments2: \\"arg1: 1 arg, arg2: 2 arg\\"" -`; - -exports[`Jinja (new api) render data-model.yml.jinja: data-model.yml.jinja 1`] = ` -"cubes: - - - name: \\"cube_from_api\\" - measures: - - name: \\"count\\" - type: \\"count\\" - - name: \\"total\\" - type: \\"sum\\" - sql: \\"amount\\" - - - name: \\"cube_from_api_with_dimensions\\" - measures: - - name: \\"active_users\\" - type: \\"count_distinct\\" - sql: \\"user_id\\" - dimensions: - - name: \\"city\\" - type: \\"string\\" - sql: \\"city_column\\"" -`; - -exports[`Jinja (new api) render dump_context.yml.jinja: dump_context.yml.jinja 1`] = ` -" -

-
-print:
-  bool_true: true
-  bool_false: false
-  string: \\"test string\\"
-  int: 1
-  float: 3.1415
-  array_int: [9,8,7,6,5,0,1,2,3,4]
-  array_bool: [true,false,false,true]
-  null: null
-  undefined: null
-  security_context:
-    userId: 1
-  env_var:
-    exist: \\"test\\"
-    unknown_fallback: \\"value\\""
-`;
-
-exports[`Jinja (new api) render filters.yml.jinja: filters.yml.jinja 1`] = `
-"variables:
-  str_filter: \\"str from python\\"
-  str_filter_test_arg: \\"my string\\""
-`;
-
-exports[`Jinja (new api) render python.yml: python.yml 1`] = `
-"test:
-  unsafe_string: \\"\\"\\\\\\"unsafe string\\\\\\" <>\\"\\"
-  safe_string: \\"\\"safe string\\" <>\\"
-
-dump:
-  dict_as_obj: \\"{\\\\n    \\\\\\"a_attr\\\\\\": String(\\\\n        \\\\\\"value for attribute a\\\\\\",\\\\n        Normal,\\\\n    ),\\\\n}\\""
-`;
-
-exports[`Jinja (new api) render template_error_python.jinja: template_error_python.jinja 1`] = `
-[Error: could not render block: Call error: Python error: Exception: Random Exception
-Traceback (most recent call last):
-  File "jinja-instance.py", line 120, in throw_exception
-
-------------------------- template_error_python.jinja -------------------------
-   3 | 3
-   4 | 4
-   5 | 5
-   6 > {%- set variable = throw_exception() %}
-     i ^^^^^^^^^^^^^^^^^^^^^^^^ could not render block
-   7 | 7
-   8 | 8
-   9 | 9
--------------------------------------------------------------------------------]
-`;
-
-exports[`Jinja (new api) render template_error_syntax.jinja: template_error_syntax.jinja 1`] = `
-[Error: syntax error: unknown statement unexpected_block_name
-------------------------- template_error_syntax.jinja -------------------------
-   7 | 7
-   8 | {%- for country in countries %}
-   9 | 9
-  10 > {%- unexpected_block_name %}
-     i ^^^^^^^^^^^^^^^^^^^^^^^^ syntax error
-  11 | 11
-  12 | 12
-  13 | 13
--------------------------------------------------------------------------------]
-`;
-
-exports[`Jinja (new api) render variables.yml.jinja: variables.yml.jinja 1`] = `
-"variables:
-  var1: \\"test string\\"
-  var2: true
-  var3: false
-  var4: null
-  var5: {\\"obj_key\\":\\"val\\"}
-  var6: [1,2,3,4,5,6]
-  var7: [6,5,4,3,2,1]"
-`;
diff --git a/packages/cubejs-backend-native/test/config.py b/packages/cubejs-backend-native/test/config.py
index 4e1035532e089..daac2b42ab2c0 100644
--- a/packages/cubejs-backend-native/test/config.py
+++ b/packages/cubejs-backend-native/test/config.py
@@ -106,3 +106,13 @@ def context_to_roles(ctx):
     return [
         "admin",
     ]
+
+
+@config
+def context_to_groups(ctx):
+    print("[python] context_to_groups", ctx)
+
+    return [
+        "dev",
+        "analytics",
+    ]
diff --git a/packages/cubejs-backend-native/test/old-config.py b/packages/cubejs-backend-native/test/old-config.py
index 95c7d547f7141..b81e2f00c8f05 100644
--- a/packages/cubejs-backend-native/test/old-config.py
+++ b/packages/cubejs-backend-native/test/old-config.py
@@ -78,3 +78,15 @@ def logger(msg, params):
     print('[python] logger msg', msg, 'params=', params)
 
 settings.logger = logger
+
+def context_to_roles(ctx):
+    print('[python] context_to_roles', ctx)
+    return ['admin']
+
+settings.context_to_roles = context_to_roles
+
+def context_to_groups(ctx):
+    print('[python] context_to_groups', ctx)
+    return ['dev', 'analytics']
+
+settings.context_to_groups = context_to_groups
diff --git a/packages/cubejs-backend-native/test/python.test.ts b/packages/cubejs-backend-native/test/python.test.ts
index b13972a3f187c..6d83d7066477a 100644
--- a/packages/cubejs-backend-native/test/python.test.ts
+++ b/packages/cubejs-backend-native/test/python.test.ts
@@ -69,6 +69,7 @@ suite('Python Config', () => {
       repositoryFactory: expect.any(Function),
       schemaVersion: expect.any(Function),
       contextToRoles: expect.any(Function),
+      contextToGroups: expect.any(Function),
       scheduledRefreshContexts: expect.any(Function),
       scheduledRefreshTimeZones: expect.any(Function),
     });
@@ -99,6 +100,14 @@ suite('Python Config', () => {
     expect(await config.contextToRoles({})).toEqual(['admin']);
   });
 
+  test('context_to_groups', async () => {
+    if (!config.contextToGroups) {
+      throw new Error('contextToGroups was not defined in config.py');
+    }
+
+    expect(await config.contextToGroups({})).toEqual(['dev', 'analytics']);
+  });
+
   test('context_to_api_scopes', async () => {
     if (!config.contextToApiScopes) {
       throw new Error('contextToApiScopes was not defined in config.py');
@@ -243,6 +252,7 @@ darwinSuite('Old Python Config', () => {
       repositoryFactory: expect.any(Function),
       schemaVersion: expect.any(Function),
       contextToRoles: expect.any(Function),
+      contextToGroups: expect.any(Function),
       scheduledRefreshContexts: expect.any(Function),
       scheduledRefreshTimeZones: expect.any(Function),
     });
diff --git a/packages/cubejs-schema-compiler/src/compiler/CubeSymbols.ts b/packages/cubejs-schema-compiler/src/compiler/CubeSymbols.ts
index 79484ae4fac16..145b124b1f748 100644
--- a/packages/cubejs-schema-compiler/src/compiler/CubeSymbols.ts
+++ b/packages/cubejs-schema-compiler/src/compiler/CubeSymbols.ts
@@ -118,6 +118,9 @@ export type Filter =
     };
 
 export type AccessPolicyDefinition = {
+  role?: string;
+  group?: string;
+  groups?: string[];
   rowLevel?: {
     filters: Filter[];
   };
diff --git a/packages/cubejs-schema-compiler/src/compiler/CubeValidator.ts b/packages/cubejs-schema-compiler/src/compiler/CubeValidator.ts
index a8b99fd77284e..2d8bf36499aef 100644
--- a/packages/cubejs-schema-compiler/src/compiler/CubeValidator.ts
+++ b/packages/cubejs-schema-compiler/src/compiler/CubeValidator.ts
@@ -765,13 +765,19 @@ const RowLevelPolicySchema = Joi.object().keys({
 }).xor('filters', 'allowAll');
 
 const RolePolicySchema = Joi.object().keys({
-  role: Joi.string().required(),
+  role: Joi.string(),
+  group: Joi.string(),
+  groups: Joi.array().items(Joi.string()),
   memberLevel: MemberLevelPolicySchema,
   rowLevel: RowLevelPolicySchema,
   conditions: Joi.array().items(Joi.object().keys({
     if: Joi.func().required(),
   })),
-});
+})
+  .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
 
 /* *****************************
  * ATTENTION:
diff --git a/packages/cubejs-schema-compiler/src/compiler/transpilers/CubePropContextTranspiler.ts b/packages/cubejs-schema-compiler/src/compiler/transpilers/CubePropContextTranspiler.ts
index 4fb43d08868d8..11ee57a6c0910 100644
--- a/packages/cubejs-schema-compiler/src/compiler/transpilers/CubePropContextTranspiler.ts
+++ b/packages/cubejs-schema-compiler/src/compiler/transpilers/CubePropContextTranspiler.ts
@@ -8,8 +8,6 @@ import {
   TranspilerSymbolResolver,
   TraverseObject
 } from './transpiler.interface';
-import type { CubeSymbols } from '../CubeSymbols';
-import type { CubeDictionary } from '../CubeDictionary';
 
 /* this list was generated by getTransformPatterns() with additional variants for snake_case */
 export const transpiledFieldsPatterns: Array = [
diff --git a/packages/cubejs-schema-compiler/test/unit/cube-validator.test.ts b/packages/cubejs-schema-compiler/test/unit/cube-validator.test.ts
index 8cb7943d61e12..c70aa16f422f6 100644
--- a/packages/cubejs-schema-compiler/test/unit/cube-validator.test.ts
+++ b/packages/cubejs-schema-compiler/test/unit/cube-validator.test.ts
@@ -1166,4 +1166,130 @@ describe('Cube Validation', () => {
       }
     });
   });
+
+  describe('Access Policy group/groups support:', () => {
+    const cubeValidator = new CubeValidator(new CubeSymbols());
+
+    it('should allow group instead of role', () => {
+      const cube = {
+        name: 'TestCube',
+        fileName: 'test.js',
+        sql: () => 'SELECT * FROM test',
+        accessPolicy: [{
+          group: 'admin',
+          rowLevel: { allowAll: true }
+        }]
+      };
+
+      const result = cubeValidator.validate(cube, new ConsoleErrorReporter());
+      expect(result.error).toBeFalsy();
+    });
+
+    it('should allow groups as array', () => {
+      const cube = {
+        name: 'TestCube',
+        fileName: 'test.js',
+        sql: () => 'SELECT * FROM test',
+        accessPolicy: [{
+          groups: ['admin', 'user'],
+          rowLevel: { allowAll: true }
+        }]
+      };
+
+      const result = cubeValidator.validate(cube, new ConsoleErrorReporter());
+      expect(result.error).toBeFalsy();
+    });
+
+    it('should allow role as single string (existing behavior)', () => {
+      const cube = {
+        name: 'TestCube',
+        fileName: 'test.js',
+        sql: () => 'SELECT * FROM test',
+        accessPolicy: [{
+          role: 'admin',
+          rowLevel: { allowAll: true }
+        }]
+      };
+
+      const result = cubeValidator.validate(cube, new ConsoleErrorReporter());
+      expect(result.error).toBeFalsy();
+    });
+
+    it('should allow group: "*" syntax', () => {
+      const cube = {
+        name: 'TestCube',
+        fileName: 'test.js',
+        sql: () => 'SELECT * FROM test',
+        accessPolicy: [{
+          group: '*',
+          rowLevel: { allowAll: true }
+        }]
+      };
+
+      const result = cubeValidator.validate(cube, new ConsoleErrorReporter());
+      expect(result.error).toBeFalsy();
+    });
+
+    it('should reject role and group together', () => {
+      const cube = {
+        name: 'TestCube',
+        fileName: 'test.js',
+        sql: () => 'SELECT * FROM test',
+        accessPolicy: [{
+          role: 'admin',
+          group: 'admin',
+          rowLevel: { allowAll: true }
+        }]
+      };
+
+      const result = cubeValidator.validate(cube, new ConsoleErrorReporter());
+      expect(result.error).toBeTruthy();
+    });
+
+    it('should reject role and groups together', () => {
+      const cube = {
+        name: 'TestCube',
+        fileName: 'test.js',
+        sql: () => 'SELECT * FROM test',
+        accessPolicy: [{
+          role: 'admin',
+          groups: ['user'],
+          rowLevel: { allowAll: true }
+        }]
+      };
+
+      const result = cubeValidator.validate(cube, new ConsoleErrorReporter());
+      expect(result.error).toBeTruthy();
+    });
+
+    it('should reject group and groups together', () => {
+      const cube = {
+        name: 'TestCube',
+        fileName: 'test.js',
+        sql: () => 'SELECT * FROM test',
+        accessPolicy: [{
+          group: 'admin',
+          groups: ['user'],
+          rowLevel: { allowAll: true }
+        }]
+      };
+
+      const result = cubeValidator.validate(cube, new ConsoleErrorReporter());
+      expect(result.error).toBeTruthy();
+    });
+
+    it('should reject access policy without role/group/groups', () => {
+      const cube = {
+        name: 'TestCube',
+        fileName: 'test.js',
+        sql: () => 'SELECT * FROM test',
+        accessPolicy: [{
+          rowLevel: { allowAll: true }
+        }]
+      };
+
+      const result = cubeValidator.validate(cube, new ConsoleErrorReporter());
+      expect(result.error).toBeTruthy();
+    });
+  });
 });
diff --git a/packages/cubejs-server-core/src/core/CompilerApi.js b/packages/cubejs-server-core/src/core/CompilerApi.js
index 0a40db37339b4..7e8f499072a2f 100644
--- a/packages/cubejs-server-core/src/core/CompilerApi.js
+++ b/packages/cubejs-server-core/src/core/CompilerApi.js
@@ -31,6 +31,7 @@ export class CompilerApi {
     this.convertTzForRawTimeDimension = this.options.convertTzForRawTimeDimension;
     this.schemaVersion = this.options.schemaVersion;
     this.contextToRoles = this.options.contextToRoles;
+    this.contextToGroups = this.options.contextToGroups;
     this.compileContext = options.compileContext;
     this.allowJsDuplicatePropsInSchema = options.allowJsDuplicatePropsInSchema;
     this.sqlCache = options.sqlCache;
@@ -243,10 +244,21 @@ export class CompilerApi {
     return new Set(await this.contextToRoles(context));
   }
 
+  async getGroupsFromContext(context) {
+    if (!this.contextToGroups) {
+      return new Set();
+    }
+    return new Set(await this.contextToGroups(context));
+  }
+
   userHasRole(userRoles, role) {
     return userRoles.has(role) || role === '*';
   }
 
+  userHasGroup(userGroups, group) {
+    return userGroups.has(group) || group === '*';
+  }
+
   roleMeetsConditions(evaluatedConditions) {
     if (evaluatedConditions?.length) {
       return evaluatedConditions.reduce((a, b) => {
diff --git a/packages/cubejs-server-core/src/core/optionsValidate.ts b/packages/cubejs-server-core/src/core/optionsValidate.ts
index 4d71408ea06c3..7a3c5b9e97027 100644
--- a/packages/cubejs-server-core/src/core/optionsValidate.ts
+++ b/packages/cubejs-server-core/src/core/optionsValidate.ts
@@ -75,6 +75,7 @@ const schemaOptions = Joi.object().keys({
   cacheAndQueueDriver: Joi.string().valid('cubestore', 'memory'),
   contextToAppId: Joi.func(),
   contextToRoles: Joi.func(),
+  contextToGroups: Joi.func(),
   contextToOrchestratorId: Joi.func(),
   contextToCubeStoreRouterId: Joi.func(),
   contextToDataSourceId: Joi.func(),
diff --git a/packages/cubejs-server-core/src/core/types.ts b/packages/cubejs-server-core/src/core/types.ts
index a4b5c749144f1..818f39e37445f 100644
--- a/packages/cubejs-server-core/src/core/types.ts
+++ b/packages/cubejs-server-core/src/core/types.ts
@@ -122,6 +122,7 @@ export type DatabaseType =
 
 export type ContextToAppIdFn = (context: RequestContext) => string | Promise;
 export type ContextToRolesFn = (context: RequestContext) => string[] | Promise;
+export type ContextToGroupsFn = (context: RequestContext) => string[] | Promise;
 export type ContextToOrchestratorIdFn = (context: RequestContext) => string | Promise;
 export type ContextToCubeStoreRouterIdFn = (context: RequestContext) => string | Promise;
 
@@ -195,6 +196,7 @@ export interface CreateOptions {
   cacheAndQueueDriver?: CacheAndQueryDriverType;
   contextToAppId?: ContextToAppIdFn;
   contextToRoles?: ContextToRolesFn;
+  contextToGroups?: ContextToGroupsFn;
   contextToOrchestratorId?: ContextToOrchestratorIdFn;
   contextToCubeStoreRouterId?: ContextToCubeStoreRouterIdFn;
   contextToApiScopes?: ContextToApiScopesFn;

From 0815e6396e3edbf49b86523feabc58d17ee4c34d Mon Sep 17 00:00:00 2001
From: Alex Vasilev 
Date: Mon, 18 Aug 2025 20:04:23 -0700
Subject: [PATCH 2/4] wip

---
 .../test/example-group-config.js              |  48 +++++++
 .../test/example-group-policy.js              | 125 ++++++++++++++++++
 .../test/example-mixed-policies.js            | 121 +++++++++++++++++
 .../src/core/CompilerApi.js                   |  83 +++++++++---
 .../test/unit/index.test.ts                   |  42 ++++++
 .../birdbox-fixtures/rbac/cube.js             |   5 +
 6 files changed, 404 insertions(+), 20 deletions(-)
 create mode 100644 packages/cubejs-backend-native/test/example-group-config.js
 create mode 100644 packages/cubejs-backend-native/test/example-group-policy.js
 create mode 100644 packages/cubejs-backend-native/test/example-mixed-policies.js

diff --git a/packages/cubejs-backend-native/test/example-group-config.js b/packages/cubejs-backend-native/test/example-group-config.js
new file mode 100644
index 0000000000000..d295f4450b712
--- /dev/null
+++ b/packages/cubejs-backend-native/test/example-group-config.js
@@ -0,0 +1,48 @@
+// Example configuration using group-based access control
+module.exports = {
+  // Use contextToGroups for group-based access control
+  // Note: Cannot be used together with contextToRoles
+  contextToGroups: async (context) => context.securityContext.auth?.groups || [],
+  
+  canSwitchSqlUser: async () => true,
+  
+  checkSqlAuth: async (req, user, password) => {
+    if (user === 'analyst') {
+      return {
+        password,
+        superuser: false,
+        securityContext: {
+          auth: {
+            username: 'analyst',
+            groups: ['analytics', 'reporting'],
+          },
+        },
+      };
+    }
+    if (user === 'manager') {
+      return {
+        password,
+        superuser: false,
+        securityContext: {
+          auth: {
+            username: 'manager',
+            groups: ['management', 'hr'],
+          },
+        },
+      };
+    }
+    if (user === 'finance') {
+      return {
+        password,
+        superuser: false,
+        securityContext: {
+          auth: {
+            username: 'finance',
+            groups: ['finance', 'accounting'],
+          },
+        },
+      };
+    }
+    throw new Error(`User "${user}" doesn't exist`);
+  }
+};
\ No newline at end of file
diff --git a/packages/cubejs-backend-native/test/example-group-policy.js b/packages/cubejs-backend-native/test/example-group-policy.js
new file mode 100644
index 0000000000000..969c7e595decd
--- /dev/null
+++ b/packages/cubejs-backend-native/test/example-group-policy.js
@@ -0,0 +1,125 @@
+// Example cube showing group-based access policies
+cube('Example', {
+  sql_table: 'public.example',
+
+  data_source: 'default',
+
+  dimensions: {
+    id: {
+      sql: 'id',
+      type: 'number',
+      primary_key: true,
+    },
+    name: {
+      sql: 'name',
+      type: 'string',
+    },
+    department: {
+      sql: 'department',
+      type: 'string',
+    },
+  },
+
+  measures: {
+    count: {
+      type: 'count',
+    },
+  },
+
+  access_policy: [
+    // Role-based policy (existing functionality)
+    {
+      role: 'admin',
+      memberLevel: {
+        includes: ['*'],
+      },
+      rowLevel: {
+        allowAll: true,
+      },
+    },
+
+    // Group-based policy (new functionality) - single group
+    {
+      group: 'analytics',
+      memberLevel: {
+        includes: ['id', 'name', 'count'],
+      },
+      rowLevel: {
+        filters: [
+          {
+            member: 'department',
+            operator: 'equals',
+            values: ['Analytics'],
+          },
+        ],
+      },
+    },
+
+    // Groups-based policy (plural - preferred for multiple groups)
+    {
+      groups: ['finance', 'accounting'],
+      memberLevel: {
+        includes: ['id', 'name', 'department', 'count'],
+      },
+      rowLevel: {
+        filters: [
+          {
+            member: 'department',
+            operator: 'in',
+            values: ['Finance', 'Accounting'],
+          },
+        ],
+      },
+    },
+
+    // Manager role policy (separate from group-based policies)
+    {
+      role: 'manager',
+      memberLevel: {
+        includes: ['*'],
+      },
+      rowLevel: {
+        filters: [
+          {
+            member: 'department',
+            operator: 'equals',
+            values: ['Management'],
+          },
+        ],
+      },
+    },
+
+    // HR groups policy (using 'groups' with single value)
+    {
+      groups: 'hr',
+      memberLevel: {
+        includes: ['id', 'name', 'department', 'count'],
+      },
+      rowLevel: {
+        filters: [
+          {
+            member: 'department',
+            operator: 'equals',
+            values: ['HR'],
+          },
+        ],
+      },
+    },
+
+    // Default policy for users with no specific role/group
+    {
+      memberLevel: {
+        includes: ['count'],
+      },
+      rowLevel: {
+        filters: [
+          {
+            member: 'department',
+            operator: 'equals',
+            values: ['Public'],
+          },
+        ],
+      },
+    },
+  ],
+});
diff --git a/packages/cubejs-backend-native/test/example-mixed-policies.js b/packages/cubejs-backend-native/test/example-mixed-policies.js
new file mode 100644
index 0000000000000..9f90699d72352
--- /dev/null
+++ b/packages/cubejs-backend-native/test/example-mixed-policies.js
@@ -0,0 +1,121 @@
+// Example cube showing mixed role-based and group-based access policies
+// This demonstrates that contextToRoles and contextToGroups can coexist
+// but individual policies must use either role OR group, not both
+cube('MixedAccess', {
+  sql_table: 'public.mixed_access',
+  
+  data_source: 'default',
+
+  dimensions: {
+    id: {
+      sql: 'id',
+      type: 'number',
+      primary_key: true,
+    },
+    name: {
+      sql: 'name',
+      type: 'string',
+    },
+    department: {
+      sql: 'department',
+      type: 'string',
+    },
+    region: {
+      sql: 'region',
+      type: 'string',
+    },
+  },
+
+  measures: {
+    count: {
+      type: 'count',
+    },
+  },
+
+  access_policy: [
+    // ✅ Role-based policy
+    {
+      role: 'admin',
+      memberLevel: {
+        includes: ['*'],
+      },
+      rowLevel: {
+        allowAll: true,
+      },
+    },
+    
+    // ✅ Another role-based policy
+    {
+      role: 'manager',
+      memberLevel: {
+        includes: ['*'],
+      },
+      rowLevel: {
+        filters: [
+          {
+            member: 'region',
+            operator: 'equals',
+            values: () => COMPILE_CONTEXT.securityContext.region,
+          },
+        ],
+      },
+    },
+
+    // ✅ Group-based policy (single group)
+    {
+      group: 'analytics',
+      memberLevel: {
+        includes: ['id', 'name', 'count'],
+      },
+      rowLevel: {
+        filters: [
+          {
+            member: 'department',
+            operator: 'equals',
+            values: ['Analytics'],
+          },
+        ],
+      },
+    },
+
+    // ✅ Group-based policy (multiple groups)
+    {
+      groups: ['finance', 'accounting'],
+      memberLevel: {
+        includes: ['id', 'name', 'department', 'count'],
+      },
+      rowLevel: {
+        filters: [
+          {
+            member: 'department',
+            operator: 'in',
+            values: ['Finance', 'Accounting'],
+          },
+        ],
+      },
+    },
+
+    // ❌ This would be invalid (mixing role and group in same policy):
+    // {
+    //   role: 'manager',
+    //   group: 'hr',
+    //   memberLevel: { includes: ['*'] }
+    // }
+
+    // Default policy for users without specific roles/groups
+    {
+      memberLevel: {
+        includes: ['count'],
+      },
+      rowLevel: {
+        filters: [
+          {
+            member: 'department',
+            operator: 'equals',
+            values: ['Public'],
+          },
+        ],
+      },
+    },
+  ],
+});
\ No newline at end of file
diff --git a/packages/cubejs-server-core/src/core/CompilerApi.js b/packages/cubejs-server-core/src/core/CompilerApi.js
index 7e8f499072a2f..44d7560c2b10b 100644
--- a/packages/cubejs-server-core/src/core/CompilerApi.js
+++ b/packages/cubejs-server-core/src/core/CompilerApi.js
@@ -256,6 +256,9 @@ export class CompilerApi {
   }
 
   userHasGroup(userGroups, group) {
+    if (Array.isArray(group)) {
+      return group.some(g => userGroups.has(g) || g === '*');
+    }
     return userGroups.has(group) || group === '*';
   }
 
@@ -288,11 +291,46 @@ export class CompilerApi {
     const cacheKey = `${cube.name}_${this.hashRequestContext(context)}`;
     if (!cache.has(cacheKey)) {
       const userRoles = await this.getRolesFromContext(context);
+      const userGroups = await this.getGroupsFromContext(context);
       const policies = cube.accessPolicy.filter(policy => {
+        // Validate that policy doesn't have both role and group/groups - this is invalid
+        if (policy.role && (policy.group || policy.groups)) {
+          const groupValue = policy.group || policy.groups;
+          const groupDisplay = Array.isArray(groupValue) ? groupValue.join(', ') : groupValue;
+          const groupProp = policy.group ? 'group' : 'groups';
+          throw new Error(
+            `Access policy cannot have both 'role' and '${groupProp}' properties.\nPolicy in cube '${cube.name}' has role '${policy.role}' and ${groupProp} '${groupDisplay}'.\nUse either 'role' or '${groupProp}', not both.`
+          );
+        }
+
+        // Validate that policy doesn't have both group and groups
+        if (policy.group && policy.groups) {
+          const groupDisplay = Array.isArray(policy.group) ? policy.group.join(', ') : policy.group;
+          const groupsDisplay = Array.isArray(policy.groups) ? policy.groups.join(', ') : policy.groups;
+          throw new Error(
+            `Access policy cannot have both 'group' and 'groups' properties.\nPolicy in cube '${cube.name}' has group '${groupDisplay}' and groups '${groupsDisplay}'.\nUse either 'group' or 'groups', not both.`
+          );
+        }
+
         const evaluatedConditions = (policy.conditions || []).map(
           condition => compilers.cubeEvaluator.evaluateContextFunction(cube, condition.if, context)
         );
-        const res = this.userHasRole(userRoles, policy.role) && this.roleMeetsConditions(evaluatedConditions);
+
+        // Check if policy matches by role, group, or groups
+        let hasAccess = false;
+
+        if (policy.role) {
+          hasAccess = this.userHasRole(userRoles, policy.role);
+        } else if (policy.group) {
+          hasAccess = this.userHasGroup(userGroups, policy.group);
+        } else if (policy.groups) {
+          hasAccess = this.userHasGroup(userGroups, policy.groups);
+        } else {
+          // If policy has neither role nor group/groups, default to checking role for backward compatibility
+          hasAccess = this.userHasRole(userRoles, '*');
+        }
+
+        const res = hasAccess && this.roleMeetsConditions(evaluatedConditions);
         return res;
       });
       cache.set(cacheKey, policies);
@@ -353,15 +391,20 @@ export class CompilerApi {
       const filtersMap = cube.isView ? viewFiltersPerCubePerRole : cubeFiltersPerCubePerRole;
 
       if (cubeEvaluator.isRbacEnabledForCube(cube)) {
-        let hasRoleWithAccess = false;
+        let hasAccessPermission = false;
         const userPolicies = await this.getApplicablePolicies(cube, context, compilers);
 
         for (const policy of userPolicies) {
-          hasRoleWithAccess = true;
+          hasAccessPermission = true;
           (policy?.rowLevel?.filters || []).forEach(filter => {
             filtersMap[cubeName] = filtersMap[cubeName] || {};
-            filtersMap[cubeName][policy.role] = filtersMap[cubeName][policy.role] || [];
-            filtersMap[cubeName][policy.role].push(
+            // Create a unique key for the policy (either role, group, or groups)
+            const groupValue = policy.group || policy.groups;
+            const policyKey = policy.role ||
+              (Array.isArray(groupValue) ? groupValue.join(',') : groupValue) ||
+              'default';
+            filtersMap[cubeName][policyKey] = filtersMap[cubeName][policyKey] || [];
+            filtersMap[cubeName][policyKey].push(
               this.evaluateNestedFilter(filter, cube, context, cubeEvaluator)
             );
           });
@@ -374,7 +417,7 @@ export class CompilerApi {
           }
         }
 
-        if (!hasRoleWithAccess) {
+        if (!hasAccessPermission) {
           // This is a hack that will make sure that the query returns no result
           query.segments = query.segments || [];
           query.segments.push({
@@ -414,37 +457,37 @@ export class CompilerApi {
 
   buildFinalRlsFilter(cubeFiltersPerCubePerRole, viewFiltersPerCubePerRole, hasAllowAllForCube) {
     // - delete all filters for cubes where the user has allowAll
-    // - combine the rest into per role maps
-    // - join all filters for the same role with AND
-    // - join all filters for different roles with OR
+    // - combine the rest into per policy maps (policies can be role-based or group-based)
+    // - join all filters for the same policy with AND
+    // - join all filters for different policies with OR
     // - join cube and view filters with AND
 
-    const roleReducer = (filtersMap) => (acc, cubeName) => {
+    const policyReducer = (filtersMap) => (acc, cubeName) => {
       if (!hasAllowAllForCube[cubeName]) {
-        Object.keys(filtersMap[cubeName]).forEach(role => {
-          acc[role] = (acc[role] || []).concat(filtersMap[cubeName][role]);
+        Object.keys(filtersMap[cubeName]).forEach(policyKey => {
+          acc[policyKey] = (acc[policyKey] || []).concat(filtersMap[cubeName][policyKey]);
         });
       }
       return acc;
     };
 
-    const cubeFiltersPerRole = Object.keys(cubeFiltersPerCubePerRole).reduce(
-      roleReducer(cubeFiltersPerCubePerRole),
+    const cubeFiltersPerPolicy = Object.keys(cubeFiltersPerCubePerRole).reduce(
+      policyReducer(cubeFiltersPerCubePerRole),
       {}
     );
-    const viewFiltersPerRole = Object.keys(viewFiltersPerCubePerRole).reduce(
-      roleReducer(viewFiltersPerCubePerRole),
+    const viewFiltersPerPolicy = Object.keys(viewFiltersPerCubePerRole).reduce(
+      policyReducer(viewFiltersPerCubePerRole),
       {}
     );
 
     return this.removeEmptyFilters({
       and: [{
-        or: Object.keys(cubeFiltersPerRole).map(role => ({
-          and: cubeFiltersPerRole[role]
+        or: Object.keys(cubeFiltersPerPolicy).map(policyKey => ({
+          and: cubeFiltersPerPolicy[policyKey]
         }))
       }, {
-        or: Object.keys(viewFiltersPerRole).map(role => ({
-          and: viewFiltersPerRole[role]
+        or: Object.keys(viewFiltersPerPolicy).map(policyKey => ({
+          and: viewFiltersPerPolicy[policyKey]
         }))
       }]
     });
diff --git a/packages/cubejs-server-core/test/unit/index.test.ts b/packages/cubejs-server-core/test/unit/index.test.ts
index 5ff8398240763..5e96e6566480e 100644
--- a/packages/cubejs-server-core/test/unit/index.test.ts
+++ b/packages/cubejs-server-core/test/unit/index.test.ts
@@ -409,6 +409,48 @@ describe('index.test', () => {
     });
   });
 
+  describe('CompilerApi validation', () => {
+    test('Should allow both contextToRoles and contextToGroups together', () => {
+      const logger = jest.fn(() => {});
+
+      expect(() => new CompilerApi(
+        repositoryWithoutPreAggregations,
+        async () => 'mysql',
+        {
+          logger,
+          contextToRoles: async () => ['admin'],
+          contextToGroups: async () => ['analytics']
+        }
+      )).not.toThrow();
+    });
+
+    test('Should allow only contextToRoles', () => {
+      const logger = jest.fn(() => {});
+
+      expect(() => new CompilerApi(
+        repositoryWithoutPreAggregations,
+        async () => 'mysql',
+        {
+          logger,
+          contextToRoles: async () => ['admin']
+        }
+      )).not.toThrow();
+    });
+
+    test('Should allow only contextToGroups', () => {
+      const logger = jest.fn(() => {});
+
+      expect(() => new CompilerApi(
+        repositoryWithoutPreAggregations,
+        async () => 'mysql',
+        {
+          logger,
+          contextToGroups: async () => ['analytics']
+        }
+      )).not.toThrow();
+    });
+  });
+
   describe('CompilerApi dataSources method', () => {
     const logger = jest.fn(() => {});
     const compilerApi = new CompilerApi(
diff --git a/packages/cubejs-testing/birdbox-fixtures/rbac/cube.js b/packages/cubejs-testing/birdbox-fixtures/rbac/cube.js
index 6f50f842c97de..9fd301aade759 100644
--- a/packages/cubejs-testing/birdbox-fixtures/rbac/cube.js
+++ b/packages/cubejs-testing/birdbox-fixtures/rbac/cube.js
@@ -1,5 +1,6 @@
 module.exports = {
   contextToRoles: async (context) => context.securityContext.auth?.roles || [],
+  contextToGroups: async (context) => context.securityContext.auth?.groups || [],
   canSwitchSqlUser: async () => true,
   checkSqlAuth: async (req, user, password) => {
     if (user === 'admin') {
@@ -19,6 +20,7 @@ module.exports = {
               minDefaultId: 10000,
             },
             roles: ['admin', 'ownder', 'hr'],
+            groups: ['leadership', 'hr'],
           },
         },
       };
@@ -40,6 +42,7 @@ module.exports = {
               minDefaultId: 10000,
             },
             roles: ['manager'],
+            groups: ['management'],
           },
         },
       };
@@ -61,6 +64,7 @@ module.exports = {
               minDefaultId: 20000,
             },
             roles: [],
+            groups: ['general'],
           },
         },
       };
@@ -82,6 +86,7 @@ module.exports = {
               minDefaultId: 20000,
             },
             roles: ['restricted'],
+            groups: ['restricted'],
           },
         },
       };

From 4bf742617b722f7b3bba2413ea86063f121d45d8 Mon Sep 17 00:00:00 2001
From: Alex Vasilev 
Date: Tue, 19 Aug 2025 11:19:44 -0700
Subject: [PATCH 3/4] wip

---
 .../test/example-group-config.js              |  48 -------
 .../test/example-group-policy.js              | 125 ------------------
 .../test/example-mixed-policies.js            | 121 -----------------
 3 files changed, 294 deletions(-)
 delete mode 100644 packages/cubejs-backend-native/test/example-group-config.js
 delete mode 100644 packages/cubejs-backend-native/test/example-group-policy.js
 delete mode 100644 packages/cubejs-backend-native/test/example-mixed-policies.js

diff --git a/packages/cubejs-backend-native/test/example-group-config.js b/packages/cubejs-backend-native/test/example-group-config.js
deleted file mode 100644
index d295f4450b712..0000000000000
--- a/packages/cubejs-backend-native/test/example-group-config.js
+++ /dev/null
@@ -1,48 +0,0 @@
-// Example configuration using group-based access control
-module.exports = {
-  // Use contextToGroups for group-based access control
-  // Note: Cannot be used together with contextToRoles
-  contextToGroups: async (context) => context.securityContext.auth?.groups || [],
-  
-  canSwitchSqlUser: async () => true,
-  
-  checkSqlAuth: async (req, user, password) => {
-    if (user === 'analyst') {
-      return {
-        password,
-        superuser: false,
-        securityContext: {
-          auth: {
-            username: 'analyst',
-            groups: ['analytics', 'reporting'],
-          },
-        },
-      };
-    }
-    if (user === 'manager') {
-      return {
-        password,
-        superuser: false,
-        securityContext: {
-          auth: {
-            username: 'manager',
-            groups: ['management', 'hr'],
-          },
-        },
-      };
-    }
-    if (user === 'finance') {
-      return {
-        password,
-        superuser: false,
-        securityContext: {
-          auth: {
-            username: 'finance',
-            groups: ['finance', 'accounting'],
-          },
-        },
-      };
-    }
-    throw new Error(`User "${user}" doesn't exist`);
-  }
-};
\ No newline at end of file
diff --git a/packages/cubejs-backend-native/test/example-group-policy.js b/packages/cubejs-backend-native/test/example-group-policy.js
deleted file mode 100644
index 969c7e595decd..0000000000000
--- a/packages/cubejs-backend-native/test/example-group-policy.js
+++ /dev/null
@@ -1,125 +0,0 @@
-// Example cube showing group-based access policies
-cube('Example', {
-  sql_table: 'public.example',
-
-  data_source: 'default',
-
-  dimensions: {
-    id: {
-      sql: 'id',
-      type: 'number',
-      primary_key: true,
-    },
-    name: {
-      sql: 'name',
-      type: 'string',
-    },
-    department: {
-      sql: 'department',
-      type: 'string',
-    },
-  },
-
-  measures: {
-    count: {
-      type: 'count',
-    },
-  },
-
-  access_policy: [
-    // Role-based policy (existing functionality)
-    {
-      role: 'admin',
-      memberLevel: {
-        includes: ['*'],
-      },
-      rowLevel: {
-        allowAll: true,
-      },
-    },
-
-    // Group-based policy (new functionality) - single group
-    {
-      group: 'analytics',
-      memberLevel: {
-        includes: ['id', 'name', 'count'],
-      },
-      rowLevel: {
-        filters: [
-          {
-            member: 'department',
-            operator: 'equals',
-            values: ['Analytics'],
-          },
-        ],
-      },
-    },
-
-    // Groups-based policy (plural - preferred for multiple groups)
-    {
-      groups: ['finance', 'accounting'],
-      memberLevel: {
-        includes: ['id', 'name', 'department', 'count'],
-      },
-      rowLevel: {
-        filters: [
-          {
-            member: 'department',
-            operator: 'in',
-            values: ['Finance', 'Accounting'],
-          },
-        ],
-      },
-    },
-
-    // Manager role policy (separate from group-based policies)
-    {
-      role: 'manager',
-      memberLevel: {
-        includes: ['*'],
-      },
-      rowLevel: {
-        filters: [
-          {
-            member: 'department',
-            operator: 'equals',
-            values: ['Management'],
-          },
-        ],
-      },
-    },
-
-    // HR groups policy (using 'groups' with single value)
-    {
-      groups: 'hr',
-      memberLevel: {
-        includes: ['id', 'name', 'department', 'count'],
-      },
-      rowLevel: {
-        filters: [
-          {
-            member: 'department',
-            operator: 'equals',
-            values: ['HR'],
-          },
-        ],
-      },
-    },
-
-    // Default policy for users with no specific role/group
-    {
-      memberLevel: {
-        includes: ['count'],
-      },
-      rowLevel: {
-        filters: [
-          {
-            member: 'department',
-            operator: 'equals',
-            values: ['Public'],
-          },
-        ],
-      },
-    },
-  ],
-});
diff --git a/packages/cubejs-backend-native/test/example-mixed-policies.js b/packages/cubejs-backend-native/test/example-mixed-policies.js
deleted file mode 100644
index 9f90699d72352..0000000000000
--- a/packages/cubejs-backend-native/test/example-mixed-policies.js
+++ /dev/null
@@ -1,121 +0,0 @@
-// Example cube showing mixed role-based and group-based access policies
-// This demonstrates that contextToRoles and contextToGroups can coexist
-// but individual policies must use either role OR group, not both
-cube('MixedAccess', {
-  sql_table: 'public.mixed_access',
-  
-  data_source: 'default',
-
-  dimensions: {
-    id: {
-      sql: 'id',
-      type: 'number',
-      primary_key: true,
-    },
-    name: {
-      sql: 'name',
-      type: 'string',
-    },
-    department: {
-      sql: 'department',
-      type: 'string',
-    },
-    region: {
-      sql: 'region',
-      type: 'string',
-    },
-  },
-
-  measures: {
-    count: {
-      type: 'count',
-    },
-  },
-
-  access_policy: [
-    // ✅ Role-based policy
-    {
-      role: 'admin',
-      memberLevel: {
-        includes: ['*'],
-      },
-      rowLevel: {
-        allowAll: true,
-      },
-    },
-    
-    // ✅ Another role-based policy
-    {
-      role: 'manager',
-      memberLevel: {
-        includes: ['*'],
-      },
-      rowLevel: {
-        filters: [
-          {
-            member: 'region',
-            operator: 'equals',
-            values: () => COMPILE_CONTEXT.securityContext.region,
-          },
-        ],
-      },
-    },
-
-    // ✅ Group-based policy (single group)
-    {
-      group: 'analytics',
-      memberLevel: {
-        includes: ['id', 'name', 'count'],
-      },
-      rowLevel: {
-        filters: [
-          {
-            member: 'department',
-            operator: 'equals',
-            values: ['Analytics'],
-          },
-        ],
-      },
-    },
-
-    // ✅ Group-based policy (multiple groups)
-    {
-      groups: ['finance', 'accounting'],
-      memberLevel: {
-        includes: ['id', 'name', 'department', 'count'],
-      },
-      rowLevel: {
-        filters: [
-          {
-            member: 'department',
-            operator: 'in',
-            values: ['Finance', 'Accounting'],
-          },
-        ],
-      },
-    },
-
-    // ❌ This would be invalid (mixing role and group in same policy):
-    // {
-    //   role: 'manager',
-    //   group: 'hr',
-    //   memberLevel: { includes: ['*'] }
-    // }
-
-    // Default policy for users without specific roles/groups
-    {
-      memberLevel: {
-        includes: ['count'],
-      },
-      rowLevel: {
-        filters: [
-          {
-            member: 'department',
-            operator: 'equals',
-            values: ['Public'],
-          },
-        ],
-      },
-    },
-  ],
-});
\ No newline at end of file

From 63c91726610048c127c0c72b884fbead8d8e4890 Mon Sep 17 00:00:00 2001
From: Alex Vasilev 
Date: Tue, 19 Aug 2025 18:02:23 -0700
Subject: [PATCH 4/4] updated snapshots

---
 .../test/__snapshots__/jinja.test.ts.snap     | 294 ++++++++++++++++++
 1 file changed, 294 insertions(+)
 create mode 100644 packages/cubejs-backend-native/test/__snapshots__/jinja.test.ts.snap

diff --git a/packages/cubejs-backend-native/test/__snapshots__/jinja.test.ts.snap b/packages/cubejs-backend-native/test/__snapshots__/jinja.test.ts.snap
new file mode 100644
index 0000000000000..85cf706de4e07
--- /dev/null
+++ b/packages/cubejs-backend-native/test/__snapshots__/jinja.test.ts.snap
@@ -0,0 +1,294 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`Jinja (new api) render 01.yml.jinja: 01.yml.jinja 1`] = `
+"cubes:
+  - name: cube_01_1
+    sql: >
+      SELECT
+        order_id,
+        SUM(CASE WHEN payment_method = 'TRANSFER' THEN amount END) AS bank_transfer_amount,
+        SUM(CASE WHEN payment_method = 'CREDIT' THEN amount END) AS credit_card_amount,
+        SUM(CASE WHEN payment_method = 'GIFT' THEN amount END) AS gift_card_amount,
+        SUM(amount) AS total_amount
+      FROM app_data.payments
+      GROUP BY 1
+      
+  - name: cube_01_2
+    sql: >
+      SELECT
+        order_id,
+        SUM(CASE WHEN payment_method = 'TRANSFER' THEN amount END) AS bank_transfer_amount,
+        SUM(CASE WHEN payment_method = 'CREDIT' THEN amount END) AS credit_card_amount,
+        SUM(CASE WHEN payment_method = 'GIFT' THEN amount END) AS gift_card_amount
+      FROM app_data.payments
+      GROUP BY 1"
+`;
+
+exports[`Jinja (new api) render 02.yml.jinja: 02.yml.jinja 1`] = `
+"cubes:
+  - name: cube_02
+    sql: >
+      SELECT
+        referrer_prop.value AS referrer
+        href_prop.value AS href
+        host_prop.value AS host
+        pathname_prop.value AS pathname
+        search_prop.value AS search
+      FROM public.events
+      LEFT JOIN UNNEST(properties) AS referrer_prop ON referrer_prop.key = 'referrer'
+      LEFT JOIN UNNEST(properties) AS href_prop ON href_prop.key = 'href'
+      LEFT JOIN UNNEST(properties) AS host_prop ON host_prop.key = 'host'
+      LEFT JOIN UNNEST(properties) AS pathname_prop ON pathname_prop.key = 'pathname'
+      LEFT JOIN UNNEST(properties) AS search_prop ON search_prop.key = 'search'"
+`;
+
+exports[`Jinja (new api) render 03.yml.jinja: 03.yml.jinja 1`] = `
+"
+
+cubes:
+  - name: cube_03
+    sql: >
+      SELECT 
+        *,
+        'au' as country
+      FROM au_orders
+      
+      UNION ALL
+      
+      SELECT 
+        *,
+        'us' as country
+      FROM us_orders
+      "
+`;
+
+exports[`Jinja (new api) render 04.yml.jinja: 04.yml.jinja 1`] = `
+"
+
+cubes:
+  - name: cube_04_\\"base_events\\"
+    sql: >
+      SELECT *
+      FROM public.events
+      WHERE
+        {FILTER_PARAMS.cube_04_base_events.timestamp.filter('timestamp')} AND 
+        {FILTER_PARAMS.cube_04_product_purchases.timestamp.filter('timestamp')} AND 
+        {FILTER_PARAMS.cube_04_page_views.timestamp.filter('timestamp')}
+
+    dimensions:
+      - name: timestamp
+        sql: timestamp
+        type: time
+  
+  - name: cube_04_product_purchases
+    extends: \\"base_events\\"
+    sql_table: public.events
+
+    dimensions:
+      - name: timestamp
+        sql: timestamp
+        type: time
+  
+  - name: cube_04_page_views
+    extends: \\"base_events\\"
+    sql_table: public.events
+
+    dimensions:
+      - name: timestamp
+        sql: timestamp
+        type: time
+  "
+`;
+
+exports[`Jinja (new api) render 05.yml.jinja: 05.yml.jinja 1`] = `
+"
+
+cubes:
+  - name: cube_05
+    sql_table: public.orders
+
+    measures:
+      - name: \\"day\\"
+        type: count_distinct
+        sql: user_id
+        rolling_window:
+          trailing: 1 day
+          offset: start
+      
+      - name: \\"mau\\"
+        type: count_distinct
+        sql: user_id
+        rolling_window:
+          trailing: 30 day
+          offset: start
+      
+      - name: \\"wau\\"
+        type: count_distinct
+        sql: user_id
+        rolling_window:
+          trailing: 7 day
+          offset: start
+      "
+`;
+
+exports[`Jinja (new api) render 06.yml.jinja: 06.yml.jinja 1`] = `
+"cubes:
+  - name: cube_06
+    sql_table: public.orders
+
+    dimensions:
+      - name: \\"id\\"
+        sql: \\"id\\"
+        type: \\"number\\"
+        primary_key: true
+        
+      - name: \\"status\\"
+        sql: \\"status\\"
+        type: \\"string\\"
+        
+      - name: \\"created_at\\"
+        sql: \\"created_at\\"
+        type: \\"time\\"
+        
+      - name: \\"completed_at\\"
+        sql: \\"completed_at\\"
+        type: \\"time\\"
+        "
+`;
+
+exports[`Jinja (new api) render 07.yml.jinja: 07.yml.jinja 1`] = `
+"cubes:
+  - name: cube_07
+    sql: >
+      SELECT
+        id AS payment_id,
+        (\\"amount\\" / 100)::NUMERIC(16, 2) AS amount_usd,
+        ((\\"order_selling_price\\" - \\"order_cost_price\\") / \\"order_cost_price\\") AS markup
+      FROM app_data.payments"
+`;
+
+exports[`Jinja (new api) render 08.yml.jinja: 08.yml.jinja 1`] = `
+"{ cubes:
+  - name: cube_08
+    sql_table: public.orders
+    data_source: \\"postgres\\" }"
+`;
+
+exports[`Jinja (new api) render arguments-test.yml.jinja: arguments-test.yml.jinja 1`] = `
+"test:
+  arg_sum_integers_int_int: 2
+  arg_sum_integers_int_float: 4.140000000000001
+  arg_bool_true: 1
+  arg_bool_false: 0
+  arg_str: \\"hello world\\"
+  arg_null: null
+  arg_seq_1: [1,2,3,4,5]
+  arg_seq_2: [5,4,3,2,1]
+  arg_sum_tuple: 3
+  arg_sum_map: 20
+  arg_kwargs1: \\"arg1: first value, arg2: second value, kwarg:(three=3 arg)\\"
+  arg_kwargs2: \\"arg1: first value, arg2: second value, kwarg:(four=4 arg,three=3 arg)\\"
+  arg_kwargs3: \\"arg1: first value, arg2: second value, kwarg:(five=5 arg,four=4 arg,three=3 arg)\\"
+  arg_named_arguments1: \\"arg1: 1 arg, arg2: 2 arg\\"
+  arg_named_arguments2: \\"arg1: 1 arg, arg2: 2 arg\\""
+`;
+
+exports[`Jinja (new api) render data-model.yml.jinja: data-model.yml.jinja 1`] = `
+"cubes:
+
+  - name: \\"cube_from_api\\"
+    measures:
+      - name: \\"count\\"
+        type: \\"count\\"
+      - name: \\"total\\"
+        type: \\"sum\\"
+        sql: \\"amount\\"
+
+  - name: \\"cube_from_api_with_dimensions\\"
+    measures:
+      - name: \\"active_users\\"
+        type: \\"count_distinct\\"
+        sql: \\"user_id\\"
+    dimensions:
+      - name: \\"city\\"
+        type: \\"string\\"
+        sql: \\"city_column\\""
+`;
+
+exports[`Jinja (new api) render dump_context.yml.jinja: dump_context.yml.jinja 1`] = `
+"
+

+
+print:
+  bool_true: true
+  bool_false: false
+  string: \\"test string\\"
+  int: 1
+  float: 3.1415
+  array_int: [9,8,7,6,5,0,1,2,3,4]
+  array_bool: [true,false,false,true]
+  null: null
+  undefined: null
+  security_context:
+    userId: 1
+  env_var:
+    exist: \\"test\\"
+    unknown_fallback: \\"value\\""
+`;
+
+exports[`Jinja (new api) render filters.yml.jinja: filters.yml.jinja 1`] = `
+"variables:
+  str_filter: \\"str from python\\"
+  str_filter_test_arg: \\"my string\\""
+`;
+
+exports[`Jinja (new api) render python.yml: python.yml 1`] = `
+"test:
+  unsafe_string: \\"\\"\\\\\\"unsafe string\\\\\\" <>\\"\\"
+  safe_string: \\"\\"safe string\\" <>\\"
+
+dump:
+  dict_as_obj: \\"{\\\\n    \\\\\\"a_attr\\\\\\": String(\\\\n        \\\\\\"value for attribute a\\\\\\",\\\\n        Normal,\\\\n    ),\\\\n}\\""
+`;
+
+exports[`Jinja (new api) render template_error_python.jinja: template_error_python.jinja 1`] = `
+[Error: could not render block: Call error: Python error: Exception: Random Exception
+Traceback (most recent call last):
+  File "jinja-instance.py", line 120, in throw_exception
+
+------------------------- template_error_python.jinja -------------------------
+   3 | 3
+   4 | 4
+   5 | 5
+   6 > {%- set variable = throw_exception() %}
+     i ^^^^^^^^^^^^^^^^^^^^^^^^ could not render block
+   7 | 7
+   8 | 8
+   9 | 9
+-------------------------------------------------------------------------------]
+`;
+
+exports[`Jinja (new api) render template_error_syntax.jinja: template_error_syntax.jinja 1`] = `
+[Error: syntax error: unknown statement unexpected_block_name
+------------------------- template_error_syntax.jinja -------------------------
+   7 | 7
+   8 | {%- for country in countries %}
+   9 | 9
+  10 > {%- unexpected_block_name %}
+     i ^^^^^^^^^^^^^^^^^^^^^^^^ syntax error
+  11 | 11
+  12 | 12
+  13 | 13
+-------------------------------------------------------------------------------]
+`;
+
+exports[`Jinja (new api) render variables.yml.jinja: variables.yml.jinja 1`] = `
+"variables:
+  var1: \\"test string\\"
+  var2: true
+  var3: false
+  var4: null
+  var5: {\\"obj_key\\":\\"val\\"}
+  var6: [1,2,3,4,5,6]
+  var7: [6,5,4,3,2,1]"
+`;