Skip to content

Commit 692a0f9

Browse files
committed
WIP: new style access policy framework for Cube
1 parent f40062b commit 692a0f9

File tree

10 files changed

+524
-20
lines changed

10 files changed

+524
-20
lines changed

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

Lines changed: 15 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);
@@ -526,6 +526,7 @@ class ApiGateway {
526526
private filterVisibleItemsInMeta(context: RequestContext, cubes: any[]) {
527527
const isDevMode = getEnv('devMode');
528528
function visibilityFilter(item) {
529+
console.log('visibilityFilter', item, isDevMode, context.signedWithPlaygroundAuthSecret, item.isVisible);
529530
return isDevMode || context.signedWithPlaygroundAuthSecret || item.isVisible;
530531
}
531532

@@ -551,7 +552,7 @@ class ApiGateway {
551552
try {
552553
await this.assertApiScope('meta', context.securityContext);
553554
const compilerApi = await this.getCompilerApi(context);
554-
const metaConfig = await compilerApi.metaConfig({
555+
const metaConfig = await compilerApi.metaConfig(context, {
555556
requestId: context.requestId,
556557
includeCompilerId: includeCompilerId || onlyCompilerId
557558
});
@@ -587,7 +588,7 @@ class ApiGateway {
587588
try {
588589
await this.assertApiScope('meta', context.securityContext);
589590
const compilerApi = await this.getCompilerApi(context);
590-
const metaConfigExtended = await compilerApi.metaConfigExtended({
591+
const metaConfigExtended = await compilerApi.metaConfigExtended(context, {
591592
requestId: context.requestId,
592593
});
593594
const { metaConfig, cubeDefinitions } = metaConfigExtended;
@@ -1010,7 +1011,7 @@ class ApiGateway {
10101011
} else {
10111012
const metaCacheKey = JSON.stringify(ctx);
10121013
if (!metaCache.has(metaCacheKey)) {
1013-
metaCache.set(metaCacheKey, await compiler.metaConfigExtended(ctx));
1014+
metaCache.set(metaCacheKey, await compiler.metaConfigExtended(context, ctx));
10141015
}
10151016

10161017
// checking and fetching result status
@@ -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+
let rewrittenQuery = (await this.compilerApi(context)).applyRowLevelSecurity(
11991201
normalizedQuery,
1202+
context
1203+
);
1204+
// Then apply user-supplied queryRewrite
1205+
rewrittenQuery = await this.queryRewrite(
1206+
rewrittenQuery,
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-schema-compiler/src/compiler/CubeSymbols.js

Lines changed: 85 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);
@@ -148,6 +150,10 @@ export class CubeSymbols {
148150
this.prepareIncludes(cube, errorReporter, splitViews);
149151
}
150152

153+
if (cube.accessPolicy) {
154+
this.prepareAccessPolicy(cube, errorReporter);
155+
}
156+
151157
return Object.assign(
152158
{ cubeName: () => cube.name, cubeObj: () => cube },
153159
cube.measures || {},
@@ -213,6 +219,60 @@ export class CubeSymbols {
213219
}
214220
}
215221

222+
/**
223+
* @protected
224+
*/
225+
allMembersOrList(cube, specifier) {
226+
const types = ['measures', 'dimensions'];
227+
if (specifier === '*') {
228+
const allMembers = R.unnest(types.map(type => Object.keys(cube[type] || {})));
229+
return allMembers;
230+
} else {
231+
return specifier || [];
232+
}
233+
}
234+
235+
/**
236+
* @protected
237+
*/
238+
prepareAccessPolicy(cube, errorReporter) {
239+
for (const policy of cube.accessPolicy) {
240+
for (const filter of policy?.rowLevel?.filters || []) {
241+
filter.memberReference = this.evaluateReferences(cube, filter.member);
242+
if (filter.memberReference.indexOf('.') !== -1) {
243+
errorReporter.error(
244+
`Paths aren't allowed in security policy filters but '${filter.memberReference}' provided as member for ${cube.name}`
245+
);
246+
}
247+
filter.memberReference = this.pathFromArray([cube.name, filter.memberReference]);
248+
}
249+
250+
if (policy.memberLevel) {
251+
const memberMapper = (member) => {
252+
if (member.indexOf('.') !== -1) {
253+
errorReporter.error(
254+
`Paths aren't allowed in memberLevel policy but '${member}' provided as a member for ${cube.name}`
255+
);
256+
}
257+
return this.pathFromArray([cube.name, member]);
258+
};
259+
260+
// TODO(maxim): is this even a correct way to figure out if includes is not set?
261+
// const resolvedIncludes = this.resolveSymbol(policy.memberLevel.includes);
262+
// if (!resolvedIncludes) {
263+
// errorReporter.error(`${cube.name} memberLevel.includes must be defined or set to "*"`);
264+
// return;
265+
// }
266+
267+
const evaluatedIncludes = this.evaluateReferences(cube, policy.memberLevel.includes);
268+
const evaluatedExcludes = this.evaluateReferences(cube, policy.memberLevel.excludes);
269+
270+
policy.memberLevel.includesMembers = this.allMembersOrList(cube, evaluatedIncludes).map(memberMapper);
271+
policy.memberLevel.excludesMembers = this.allMembersOrList(cube, evaluatedExcludes).map(memberMapper);
272+
}
273+
}
274+
}
275+
216276
/**
217277
* @protected
218278
*/
@@ -406,6 +466,27 @@ export class CubeSymbols {
406466
});
407467
}
408468

469+
// Used to evaluate access policies to allow referencing security_context at query time
470+
evaluateContextFunction(cube, contextFn, context = {}) {
471+
const cubeEvaluator = this;
472+
473+
const res = cubeEvaluator.resolveSymbolsCall(contextFn, (name) => {
474+
const resolvedSymbol = this.resolveSymbol(cube, name);
475+
if (resolvedSymbol) {
476+
return resolvedSymbol;
477+
}
478+
throw new UserError(
479+
`Cube references are not allowed when evaluating RLS conditions or filters. Found: ${name} in ${cube.name}`
480+
);
481+
}, {
482+
contextSymbols: {
483+
securityContext: context.securityContext,
484+
}
485+
});
486+
487+
return res;
488+
}
489+
409490
evaluateReferences(cube, referencesFn, options = {}) {
410491
const cubeEvaluator = this;
411492

@@ -458,6 +539,10 @@ export class CubeSymbols {
458539
res = res.fn.apply(null, res.memberNames.map((id) => nameResolver(id.trim())));
459540
}
460541
return res;
542+
} catch (e) {
543+
// TODO(maxim): should we keep this log?
544+
console.log('Error while resolving Cube symbols: ', e);
545+
console.error(e);
461546
} finally {
462547
this.resolveSymbolsCallContext = oldContext;
463548
}

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,8 @@ export const nonStringFields = new Set([
2525
'external',
2626
'useOriginalSqlPreAggregations',
2727
'readOnly',
28-
'prefix'
28+
'prefix',
29+
'if'
2930
]);
3031

3132
const identifierRegex = /^[_a-zA-Z][_a-zA-Z0-9]*$/;
@@ -615,6 +616,62 @@ const SegmentsSchema = Joi.object().pattern(identifierRegex, Joi.object().keys({
615616
public: Joi.boolean().strict(),
616617
}));
617618

619+
const PolicyFilterSchema = Joi.object().keys({
620+
member: Joi.func().required(),
621+
memberReference: Joi.string().required(),
622+
operator: Joi.any().valid(
623+
'equals',
624+
'notEquals',
625+
'contains',
626+
'notContains',
627+
'startsWith',
628+
'notStartsWith',
629+
'endsWith',
630+
'notEndsWith',
631+
'gt',
632+
'gte',
633+
'lt',
634+
'lte',
635+
'inDateRange',
636+
'notInDateRange',
637+
'beforeDate',
638+
'beforeOrOnDate',
639+
'afterDate',
640+
'afterOrOnDate',
641+
).required(),
642+
values: Joi.func().required(),
643+
});
644+
645+
const MemberLevelPolicySchema = Joi.object().keys({
646+
// TODO(maxim): how these should be validated?
647+
excludes: Joi.func(),
648+
includes: Joi.func(),
649+
includesMembers: Joi.array().items(Joi.string().required()),
650+
excludesMembers: Joi.array().items(Joi.string().required()),
651+
});
652+
653+
const RowLevelPolicySchema = Joi.object().keys({
654+
655+
filters: Joi.array().items(Joi.alternatives().try(
656+
Joi.object().keys({
657+
or: Joi.array().items(PolicyFilterSchema).required(),
658+
and: Joi.array().items(PolicyFilterSchema).required(),
659+
}),
660+
PolicyFilterSchema,
661+
)).required(),
662+
});
663+
664+
// TODO(maxim): follow the "ATTENTION" thing below
665+
const RolePolicySchema = Joi.object().keys({
666+
role: Joi.string().required(),
667+
memberLevel: MemberLevelPolicySchema,
668+
rowLevel: RowLevelPolicySchema,
669+
conditions: Joi.array().items(Joi.object().keys({
670+
if: Joi.func().required(),
671+
})),
672+
// evaluatedConditions: Joi.array().items(Joi.boolean()),
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/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/test/unit/schema.test.ts

Lines changed: 34 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,37 @@ 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+
});
381+
382+
it('memberLevel accessPolicy should require explicit includes', async () => {
383+
const logger = jest.fn();
384+
const { compiler } = prepareCompiler([
385+
createCubeSchemaWithAccessPolicy('ProtectedCube', `
386+
{
387+
role: 'manager2',
388+
memberLevel: {
389+
// ommitted includes
390+
excludes: [\`min\`, \`max\`]
391+
},
392+
},
393+
`),
394+
]);
395+
await compiler.compile();
396+
compiler.throwIfAnyErrors();
397+
398+
expect(logger.mock.calls.length).toEqual(1);
399+
expect(logger.mock.calls[0]).toEqual([
400+
'ProtectedCube memberLevel.includes must be defined or set to "*"'
401+
]);
402+
});
370403
});

0 commit comments

Comments
 (0)