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/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..44d7560c2b10b 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,24 @@ 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) { + if (Array.isArray(group)) { + return group.some(g => userGroups.has(g) || g === '*'); + } + return userGroups.has(group) || group === '*'; + } + roleMeetsConditions(evaluatedConditions) { if (evaluatedConditions?.length) { return evaluatedConditions.reduce((a, b) => { @@ -276,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); @@ -341,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) ); }); @@ -362,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({ @@ -402,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/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; 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'], }, }, };