Skip to content

Commit 4bb2824

Browse files
committed
WIP: new style access policy framework for Cube
1 parent 8fa0d53 commit 4bb2824

File tree

17 files changed

+560
-22
lines changed

17 files changed

+560
-22
lines changed

.gitignore

Lines changed: 1 addition & 1 deletion
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

Lines changed: 14 additions & 8 deletions
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
@@ -1195,8 +1195,14 @@ class ApiGateway {
11951195
}
11961196

11971197
const normalizedQuery = normalizeQuery(currentQuery, persistent);
1198-
let rewrittenQuery = await this.queryRewrite(
1198+
// First apply cube/view level security policies
1199+
let rewrittenQuery = (await this.compilerApi(context)).applyRowLevelSecurity(
11991200
normalizedQuery,
1201+
context
1202+
);
1203+
// Then apply user-supplied queryRewrite
1204+
rewrittenQuery = await this.queryRewrite(
1205+
rewrittenQuery,
12001206
context,
12011207
);
12021208

@@ -1693,7 +1699,7 @@ class ApiGateway {
16931699
await this.getNormalizedQueries(query, context);
16941700

16951701
let metaConfigResult = await (await this
1696-
.getCompilerApi(context)).metaConfig({
1702+
.getCompilerApi(context)).metaConfig(request.context, {
16971703
requestId: context.requestId
16981704
});
16991705

@@ -1803,7 +1809,7 @@ class ApiGateway {
18031809
await this.getNormalizedQueries(query, context, request.streaming, request.memberExpressions);
18041810

18051811
const compilerApi = await this.getCompilerApi(context);
1806-
let metaConfigResult = await compilerApi.metaConfig({
1812+
let metaConfigResult = await compilerApi.metaConfig(request.context, {
18071813
requestId: context.requestId
18081814
});
18091815

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

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -112,9 +112,71 @@ export class CubeEvaluator extends CubeSymbols {
112112

113113
this.prepareHierarchies(cube);
114114

115+
this.prepareAccessPolicy(cube, errorReporter);
116+
115117
return cube;
116118
}
117119

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

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

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ 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: 'securityContext',
1314
FILTER_PARAMS: 'filterParams',
1415
FILTER_GROUP: 'filterGroup',
1516
SQL_UTILS: 'sqlUtils'
@@ -139,6 +140,7 @@ export class CubeSymbols {
139140
this.camelCaseTypes(cube.dimensions);
140141
this.camelCaseTypes(cube.segments);
141142
this.camelCaseTypes(cube.preAggregations);
143+
this.camelCaseTypes(cube.accessPolicy);
142144

143145
if (cube.preAggregations) {
144146
this.transformPreAggregations(cube.preAggregations);
@@ -406,6 +408,27 @@ export class CubeSymbols {
406408
});
407409
}
408410

411+
// Used to evaluate access policies to allow referencing security_context at query time
412+
evaluateContextFunction(cube, contextFn, context = {}) {
413+
const cubeEvaluator = this;
414+
415+
const res = cubeEvaluator.resolveSymbolsCall(contextFn, (name) => {
416+
const resolvedSymbol = this.resolveSymbol(cube, name);
417+
if (resolvedSymbol) {
418+
return resolvedSymbol;
419+
}
420+
throw new UserError(
421+
`Cube references are not allowed when evaluating RLS conditions or filters. Found: ${name} in ${cube.name}`
422+
);
423+
}, {
424+
contextSymbols: {
425+
securityContext: context.securityContext,
426+
}
427+
});
428+
429+
return res;
430+
}
431+
409432
evaluateReferences(cube, referencesFn, options = {}) {
410433
const cubeEvaluator = this;
411434

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,7 @@ export class CubeToMetaTransformer {
105105
})),
106106
R.toPairs
107107
)(cube.segments || {}),
108+
accessPolicy: cube.accessPolicy || [],
108109
hierarchies: cube.hierarchies || []
109110
},
110111
};

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

Lines changed: 59 additions & 1 deletion
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,63 @@ 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+
// TODO(maxim): these should be .func()? Should they even allow references?
651+
includes: Joi.alternatives([
652+
Joi.string().valid('*'),
653+
Joi.array().items(Joi.string().required())
654+
]).required(),
655+
excludes: Joi.alternatives([
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).required(),
664+
});
665+
666+
const RolePolicySchema = Joi.object().keys({
667+
role: Joi.string().required(),
668+
memberLevel: MemberLevelPolicySchema,
669+
rowLevel: RowLevelPolicySchema,
670+
conditions: Joi.array().items(Joi.object().keys({
671+
if: Joi.func().required(),
672+
})),
673+
});
674+
618675
/* *****************************
619676
* ATTENTION:
620677
* In case of adding/removing/changing any Joi.func() field that needs to be transpiled,
@@ -692,6 +749,7 @@ const baseSchema = {
692749
title: Joi.string(),
693750
levels: Joi.func()
694751
})),
752+
accessPolicy: Joi.array().items(RolePolicySchema),
695753
};
696754

697755
const cubeSchema = inherit(baseSchema, {

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

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -146,7 +146,20 @@ export class YamlCompiler {
146146
return this.parsePythonIntoArrowFunction(obj, cubeName, obj, errorsReport);
147147
} else if (Array.isArray(obj)) {
148148
const resultAst = t.program([t.expressionStatement(t.arrayExpression(obj.map(code => {
149-
const ast = this.parsePythonAndTranspileToJs(code, errorsReport);
149+
let ast: t.Program | t.NullLiteral | t.BooleanLiteral | t.NumericLiteral | null = null;
150+
// Special case for accessPolicy.rowLevel.filter.values and other values-like fields
151+
if (propertyPath[propertyPath.length - 1] === 'values') {
152+
if (typeof code === 'string') {
153+
ast = this.parsePythonAndTranspileToJs(`f"${this.escapeDoubleQuotes(code)}"`, errorsReport);
154+
} else if (typeof code === 'boolean') {
155+
ast = t.booleanLiteral(code);
156+
} else if (typeof code === 'number') {
157+
ast = t.numericLiteral(code);
158+
}
159+
}
160+
if (ast === null) {
161+
ast = this.parsePythonAndTranspileToJs(code, errorsReport);
162+
}
150163
return this.extractProgramBodyIfNeeded(ast);
151164
}).filter(ast => !!ast)))]);
152165
return this.astIntoArrowFunction(resultAst, '', cubeName);

packages/cubejs-schema-compiler/src/compiler/transpilers/CubePropContextTranspiler.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,10 @@ export const transpiledFieldsPatterns: Array<RegExp> = [
2525
/^excludes$/,
2626
/^hierarchies\.[0-9]+\.levels$/,
2727
/^cubes\.[0-9]+\.(joinPath|join_path)$/,
28+
/^(accessPolicy|access_policy)\.[0-9]+\.(rowLevel|row_level)\.filters\.[0-9]+.*\.member$/,
29+
/^(accessPolicy|access_policy)\.[0-9]+\.(rowLevel|row_level)\.filters\.[0-9]+.*\.values$/,
30+
/^(accessPolicy|access_policy)\.[0-9]+\.conditions.[0-9]+\.if$/,
31+
// /^(accessPolicy|access_policy)\.[0-9]+\.(memberLevel|member_level)\.(includes|excludes)$/,
2832
];
2933

3034
export const transpiledFields: Set<String> = new Set<String>();

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@ export function camelizeCube(cube: any): unknown {
4848
camelizeObjectPart(cube.dimensions, false);
4949
camelizeObjectPart(cube.preAggregations, false);
5050
camelizeObjectPart(cube.cubes, false);
51+
camelizeObjectPart(cube.accessPolicy, false);
5152

5253
return cube;
5354
}

packages/cubejs-schema-compiler/test/unit/schema.test.ts

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { prepareCompiler } from './PrepareCompiler';
2-
import { createCubeSchema, createCubeSchemaWithCustomGranularities } from './utils';
2+
import { createCubeSchema, createCubeSchemaWithCustomGranularities, createCubeSchemaWithAccessPolicy } from './utils';
33

44
describe('Schema Testing', () => {
55
const schemaCompile = async () => {
@@ -367,4 +367,15 @@ describe('Schema Testing', () => {
367367
CubeD: { relationship: 'belongsTo' }
368368
});
369369
});
370+
371+
it('valid schema with accessPolicy', async () => {
372+
const { compiler, metaTransformer } = prepareCompiler([
373+
createCubeSchemaWithAccessPolicy('ProtectedCube'),
374+
]);
375+
await compiler.compile();
376+
compiler.throwIfAnyErrors();
377+
378+
// TODO(maxim): this should be further validated
379+
expect(metaTransformer.cubes[0].config.accessPolicy).toBeDefined();
380+
});
370381
});

0 commit comments

Comments
 (0)