Skip to content

Commit ad81453

Browse files
authored
feat: New style RBAC framework (#8766)
This commit introduces a new style Role Based Access Control framework for cubes. User can now define `accessPolicies` on Cubes and Views which will be evaluated into `queryRewrite` and visibility rules. This commit introduces a new config: `contextToRoles(context): string[]`. It should return a list of user role names based on the request context. Access Policies are defined per Cube x Role name like ``` access_policy: - role: "manager" conditions: - if "{ security_context.isNotSuspended }" row_level: filters: - member: `access_level` operator: lt values: [2] member_level: includes: "*" excludes: [`top_secret_field`] ``` Each policy can define a `row_level` and `member_level` rules. Row level rules can be defined as a list of filters or `allow_all: true` Member level rules should specify a list of "included" members that the user with a given role is allowed to see. When evaluating Cube and View level policies: - row level filters are joined via AND (least permissive policy wins) - member level policy at the view always wins (you can expose a hidden member of a Cube on a View) Policies can reference `security_context` (lowercase) when evaluating policy conditions and filter values.
1 parent 3680bc4 commit ad81453

30 files changed

+2315
-26
lines changed

.gitignore

+1-1
Original file line numberDiff line numberDiff line change
@@ -23,4 +23,4 @@ testings/
2323
rust/cubesql/profile.json
2424
.cubestore
2525
.env
26-
26+
.vimspector.json

packages/cubejs-api-gateway/src/gateway.ts

+15-8
Original file line numberDiff line numberDiff line change
@@ -234,7 +234,7 @@ class ApiGateway {
234234
const { query, variables } = req.body;
235235
const compilerApi = await this.getCompilerApi(req.context);
236236

237-
const metaConfig = await compilerApi.metaConfig({
237+
const metaConfig = await compilerApi.metaConfig(req.context, {
238238
requestId: req.context.requestId,
239239
});
240240

@@ -267,7 +267,7 @@ class ApiGateway {
267267
const compilerApi = await this.getCompilerApi(req.context);
268268
let schema = compilerApi.getGraphQLSchema();
269269
if (!schema) {
270-
let metaConfig = await compilerApi.metaConfig({
270+
let metaConfig = await compilerApi.metaConfig(req.context, {
271271
requestId: req.context.requestId,
272272
});
273273
metaConfig = this.filterVisibleItemsInMeta(req.context, metaConfig);
@@ -551,7 +551,7 @@ class ApiGateway {
551551
try {
552552
await this.assertApiScope('meta', context.securityContext);
553553
const compilerApi = await this.getCompilerApi(context);
554-
const metaConfig = await compilerApi.metaConfig({
554+
const metaConfig = await compilerApi.metaConfig(context, {
555555
requestId: context.requestId,
556556
includeCompilerId: includeCompilerId || onlyCompilerId
557557
});
@@ -587,7 +587,7 @@ class ApiGateway {
587587
try {
588588
await this.assertApiScope('meta', context.securityContext);
589589
const compilerApi = await this.getCompilerApi(context);
590-
const metaConfigExtended = await compilerApi.metaConfigExtended({
590+
const metaConfigExtended = await compilerApi.metaConfigExtended(context, {
591591
requestId: context.requestId,
592592
});
593593
const { metaConfig, cubeDefinitions } = metaConfigExtended;
@@ -1010,7 +1010,7 @@ class ApiGateway {
10101010
} else {
10111011
const metaCacheKey = JSON.stringify(ctx);
10121012
if (!metaCache.has(metaCacheKey)) {
1013-
metaCache.set(metaCacheKey, await compiler.metaConfigExtended(ctx));
1013+
metaCache.set(metaCacheKey, await compiler.metaConfigExtended(context, ctx));
10141014
}
10151015

10161016
// checking and fetching result status
@@ -1180,6 +1180,7 @@ class ApiGateway {
11801180
}, context);
11811181

11821182
const startTime = new Date().getTime();
1183+
const compilerApi = await this.getCompilerApi(context);
11831184

11841185
let normalizedQueries: NormalizedQuery[] = await Promise.all(
11851186
queries.map(
@@ -1195,8 +1196,14 @@ class ApiGateway {
11951196
}
11961197

11971198
const normalizedQuery = normalizeQuery(currentQuery, persistent);
1198-
let rewrittenQuery = await this.queryRewrite(
1199+
// First apply cube/view level security policies
1200+
const queryWithRlsFilters = await compilerApi.applyRowLevelSecurity(
11991201
normalizedQuery,
1202+
context
1203+
);
1204+
// Then apply user-supplied queryRewrite
1205+
let rewrittenQuery = await this.queryRewrite(
1206+
queryWithRlsFilters,
12001207
context,
12011208
);
12021209

@@ -1693,7 +1700,7 @@ class ApiGateway {
16931700
await this.getNormalizedQueries(query, context);
16941701

16951702
let metaConfigResult = await (await this
1696-
.getCompilerApi(context)).metaConfig({
1703+
.getCompilerApi(context)).metaConfig(request.context, {
16971704
requestId: context.requestId
16981705
});
16991706

@@ -1803,7 +1810,7 @@ class ApiGateway {
18031810
await this.getNormalizedQueries(query, context, request.streaming, request.memberExpressions);
18041811

18051812
const compilerApi = await this.getCompilerApi(context);
1806-
let metaConfigResult = await compilerApi.metaConfig({
1813+
let metaConfigResult = await compilerApi.metaConfig(request.context, {
18071814
requestId: context.requestId
18081815
});
18091816

packages/cubejs-api-gateway/src/helpers/prepareAnnotation.ts

+2-1
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,8 @@ const annotation = (
4848
) => (member: string | MemberExpression): undefined | [string, ConfigItem] => {
4949
const [cubeName, fieldName] = (<MemberExpression>member).expression ? [(<MemberExpression>member).cubeName, (<MemberExpression>member).name] : (<string>member).split('.');
5050
const memberWithoutGranularity = [cubeName, fieldName].join('.');
51-
const config: ConfigItem = configMap[cubeName][memberType]
51+
const cubeConfig = configMap[cubeName];
52+
const config: ConfigItem = cubeConfig && cubeConfig[memberType]
5253
.find(m => m.name === memberWithoutGranularity);
5354

5455
if (!config) {

packages/cubejs-api-gateway/test/index.test.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -471,7 +471,7 @@ describe('API Gateway', () => {
471471
queryRewrite: async (query, _context) => {
472472
query.limit = 2;
473473
return query;
474-
}
474+
},
475475
}
476476
);
477477

packages/cubejs-api-gateway/test/mocks.ts

+4
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,10 @@ export const compilerApi = jest.fn().mockImplementation(async () => ({
7575
return 'postgres';
7676
},
7777

78+
async applyRowLevelSecurity(query: any) {
79+
return query;
80+
},
81+
7882
async metaConfig() {
7983
return [
8084
{

packages/cubejs-schema-compiler/src/compiler/CompilerCache.ts

+11
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@ import { QueryCache } from '../adapter/QueryCache';
44
export class CompilerCache extends QueryCache {
55
protected readonly queryCache: LRUCache<string, QueryCache>;
66

7+
protected readonly rbacCache: LRUCache<string, any>;
8+
79
public constructor({ maxQueryCacheSize, maxQueryCacheAge }) {
810
super();
911

@@ -12,6 +14,15 @@ export class CompilerCache extends QueryCache {
1214
maxAge: (maxQueryCacheAge * 1000) || 1000 * 60 * 10,
1315
updateAgeOnGet: true
1416
});
17+
18+
this.rbacCache = new LRUCache({
19+
max: 10000,
20+
maxAge: 1000 * 60 * 5, // 5 minutes
21+
});
22+
}
23+
24+
public getRbacCacheInstance(): LRUCache<string, any> {
25+
return this.rbacCache;
1526
}
1627

1728
public getQueryCache(key: unknown): QueryCache {

packages/cubejs-schema-compiler/src/compiler/CubeEvaluator.ts

+77
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,8 @@ export class CubeEvaluator extends CubeSymbols {
6565

6666
public byFileName: Record<string, any> = {};
6767

68+
private isRbacEnabledCache: boolean | null = null;
69+
6870
public constructor(
6971
protected readonly cubeValidator: CubeValidator
7072
) {
@@ -112,9 +114,71 @@ export class CubeEvaluator extends CubeSymbols {
112114

113115
this.prepareHierarchies(cube);
114116

117+
this.prepareAccessPolicy(cube, errorReporter);
118+
115119
return cube;
116120
}
117121

122+
private allMembersOrList(cube: any, specifier: string | string[]): string[] {
123+
const types = ['measures', 'dimensions', 'segments'];
124+
if (specifier === '*') {
125+
const allMembers = R.unnest(types.map(type => Object.keys(cube[type] || {})));
126+
return allMembers;
127+
} else {
128+
return specifier as string[] || [];
129+
}
130+
}
131+
132+
private prepareAccessPolicy(cube: any, errorReporter: ErrorReporter) {
133+
if (!cube.accessPolicy) {
134+
return;
135+
}
136+
137+
const memberMapper = (memberType: string) => (member: string) => {
138+
if (member.indexOf('.') !== -1) {
139+
const cubeName = member.split('.')[0];
140+
if (cubeName !== cube.name) {
141+
errorReporter.error(
142+
`Paths aren't allowed in the accessPolicy policy but '${member}' provided as ${memberType} for ${cube.name}`
143+
);
144+
}
145+
return member;
146+
}
147+
return this.pathFromArray([cube.name, member]);
148+
};
149+
150+
const filterEvaluator = (filter: any) => {
151+
if (filter.member) {
152+
filter.memberReference = this.evaluateReferences(cube.name, filter.member);
153+
filter.memberReference = memberMapper('a filter member reference')(filter.memberReference);
154+
} else {
155+
if (filter.and) {
156+
filter.and.forEach(filterEvaluator);
157+
}
158+
if (filter.or) {
159+
filter.or.forEach(filterEvaluator);
160+
}
161+
}
162+
};
163+
164+
for (const policy of cube.accessPolicy) {
165+
for (const filter of policy?.rowLevel?.filters || []) {
166+
filterEvaluator(filter);
167+
}
168+
169+
if (policy.memberLevel) {
170+
policy.memberLevel.includesMembers = this.allMembersOrList(
171+
cube,
172+
policy.memberLevel.includes || '*'
173+
).map(memberMapper('an includes member'));
174+
policy.memberLevel.excludesMembers = this.allMembersOrList(
175+
cube,
176+
policy.memberLevel.excludes || []
177+
).map(memberMapper('an excludes member'));
178+
}
179+
}
180+
}
181+
118182
private prepareHierarchies(cube: any) {
119183
if (Array.isArray(cube.hierarchies)) {
120184
cube.hierarchies = cube.hierarchies.map(hierarchy => ({
@@ -515,6 +579,19 @@ export class CubeEvaluator extends CubeSymbols {
515579
return path.split('.');
516580
}
517581

582+
public isRbacEnabledForCube(cube: any): boolean {
583+
return cube.accessPolicy && cube.accessPolicy.length;
584+
}
585+
586+
public isRbacEnabled(): boolean {
587+
if (this.isRbacEnabledCache === null) {
588+
this.isRbacEnabledCache = this.cubeNames().some(
589+
cubeName => this.isRbacEnabledForCube(this.cubeFromPath(cubeName))
590+
);
591+
}
592+
return this.isRbacEnabledCache;
593+
}
594+
518595
protected parsePathAnyType(path) {
519596
// Should throw UserError in case of parse error
520597
this.byPathAnyType(path);

packages/cubejs-schema-compiler/src/compiler/CubeSymbols.js

+33
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,10 @@ import { BaseQuery } from '../adapter';
1010
const FunctionRegex = /function\s+\w+\(([A-Za-z0-9_,]*)|\(([\s\S]*?)\)\s*=>|\(?(\w+)\)?\s*=>/;
1111
const CONTEXT_SYMBOLS = {
1212
SECURITY_CONTEXT: 'securityContext',
13+
// SECURITY_CONTEXT has been deprecated, however security_context (lowecase)
14+
// is allowed in RBAC policies for query-time attribute matching
15+
security_context: 'securityContext',
16+
securityContext: 'securityContext',
1317
FILTER_PARAMS: 'filterParams',
1418
FILTER_GROUP: 'filterGroup',
1519
SQL_UTILS: 'sqlUtils'
@@ -139,6 +143,7 @@ export class CubeSymbols {
139143
this.camelCaseTypes(cube.dimensions);
140144
this.camelCaseTypes(cube.segments);
141145
this.camelCaseTypes(cube.preAggregations);
146+
this.camelCaseTypes(cube.accessPolicy);
142147

143148
if (cube.preAggregations) {
144149
this.transformPreAggregations(cube.preAggregations);
@@ -406,6 +411,34 @@ export class CubeSymbols {
406411
});
407412
}
408413

414+
/**
415+
* This method is mainly used for evaluating RLS conditions and filters.
416+
* It allows referencing security_context (lowecase) in dynamic conditions or filter values.
417+
*
418+
* It currently does not support async calls because inner resolveSymbol and
419+
* resolveSymbolsCall are sync. Async support may be added later with deeper
420+
* refactoring.
421+
*/
422+
evaluateContextFunction(cube, contextFn, context = {}) {
423+
const cubeEvaluator = this;
424+
425+
const res = cubeEvaluator.resolveSymbolsCall(contextFn, (name) => {
426+
const resolvedSymbol = this.resolveSymbol(cube, name);
427+
if (resolvedSymbol) {
428+
return resolvedSymbol;
429+
}
430+
throw new UserError(
431+
`Cube references are not allowed when evaluating RLS conditions or filters. Found: ${name} in ${cube.name}`
432+
);
433+
}, {
434+
contextSymbols: {
435+
securityContext: context.securityContext,
436+
}
437+
});
438+
439+
return res;
440+
}
441+
409442
evaluateReferences(cube, referencesFn, options = {}) {
410443
const cubeEvaluator = this;
411444

packages/cubejs-schema-compiler/src/compiler/CubeValidator.ts

+61-1
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ export const nonStringFields = new Set([
2525
'external',
2626
'useOriginalSqlPreAggregations',
2727
'readOnly',
28-
'prefix'
28+
'prefix',
2929
]);
3030

3131
const identifierRegex = /^[_a-zA-Z][_a-zA-Z0-9]*$/;
@@ -615,6 +615,64 @@ const SegmentsSchema = Joi.object().pattern(identifierRegex, Joi.object().keys({
615615
public: Joi.boolean().strict(),
616616
}));
617617

618+
const PolicyFilterSchema = Joi.object().keys({
619+
member: Joi.func().required(),
620+
memberReference: Joi.string(),
621+
operator: Joi.any().valid(
622+
'equals',
623+
'notEquals',
624+
'contains',
625+
'notContains',
626+
'startsWith',
627+
'notStartsWith',
628+
'endsWith',
629+
'notEndsWith',
630+
'gt',
631+
'gte',
632+
'lt',
633+
'lte',
634+
'inDateRange',
635+
'notInDateRange',
636+
'beforeDate',
637+
'beforeOrOnDate',
638+
'afterDate',
639+
'afterOrOnDate',
640+
).required(),
641+
values: Joi.func().required(),
642+
});
643+
644+
const PolicyFilterConditionSchema = Joi.object().keys({
645+
or: Joi.array().items(PolicyFilterSchema, Joi.link('...').description('Filter Condition schema')),
646+
and: Joi.array().items(PolicyFilterSchema, Joi.link('...').description('Filter Condition schema')),
647+
}).xor('or', 'and');
648+
649+
const MemberLevelPolicySchema = Joi.object().keys({
650+
includes: Joi.alternatives([
651+
Joi.string().valid('*'),
652+
Joi.array().items(Joi.string())
653+
]),
654+
excludes: Joi.alternatives([
655+
Joi.string().valid('*'),
656+
Joi.array().items(Joi.string().required())
657+
]),
658+
includesMembers: Joi.array().items(Joi.string().required()),
659+
excludesMembers: Joi.array().items(Joi.string().required()),
660+
});
661+
662+
const RowLevelPolicySchema = Joi.object().keys({
663+
filters: Joi.array().items(PolicyFilterSchema, PolicyFilterConditionSchema),
664+
allowAll: Joi.boolean().valid(true).strict(),
665+
}).xor('filters', 'allowAll');
666+
667+
const RolePolicySchema = Joi.object().keys({
668+
role: Joi.string().required(),
669+
memberLevel: MemberLevelPolicySchema,
670+
rowLevel: RowLevelPolicySchema,
671+
conditions: Joi.array().items(Joi.object().keys({
672+
if: Joi.func().required(),
673+
})),
674+
});
675+
618676
/* *****************************
619677
* ATTENTION:
620678
* In case of adding/removing/changing any Joi.func() field that needs to be transpiled,
@@ -692,6 +750,7 @@ const baseSchema = {
692750
title: Joi.string(),
693751
levels: Joi.func()
694752
})),
753+
accessPolicy: Joi.array().items(RolePolicySchema.required()),
695754
};
696755

697756
const cubeSchema = inherit(baseSchema, {
@@ -726,6 +785,7 @@ const viewSchema = inherit(baseSchema, {
726785
'object.oxor': 'Using split together with prefix is not supported'
727786
})
728787
),
788+
accessPolicy: Joi.array().items(RolePolicySchema.required()),
729789
});
730790

731791
function formatErrorMessageFromDetails(explain, d) {

0 commit comments

Comments
 (0)