Skip to content

Commit

Permalink
WIP: new style access policy framework for Cube
Browse files Browse the repository at this point in the history
  • Loading branch information
bsod90 committed Oct 3, 2024
1 parent f40062b commit 6742250
Show file tree
Hide file tree
Showing 8 changed files with 374 additions and 18 deletions.
23 changes: 15 additions & 8 deletions packages/cubejs-api-gateway/src/gateway.ts
Original file line number Diff line number Diff line change
Expand Up @@ -234,7 +234,7 @@ class ApiGateway {
const { query, variables } = req.body;
const compilerApi = await this.getCompilerApi(req.context);

const metaConfig = await compilerApi.metaConfig({
const metaConfig = await compilerApi.metaConfig(req.context, {
requestId: req.context.requestId,
});

Expand Down Expand Up @@ -267,7 +267,7 @@ class ApiGateway {
const compilerApi = await this.getCompilerApi(req.context);
let schema = compilerApi.getGraphQLSchema();
if (!schema) {
let metaConfig = await compilerApi.metaConfig({
let metaConfig = await compilerApi.metaConfig(req.context, {
requestId: req.context.requestId,
});
metaConfig = this.filterVisibleItemsInMeta(req.context, metaConfig);
Expand Down Expand Up @@ -526,6 +526,7 @@ class ApiGateway {
private filterVisibleItemsInMeta(context: RequestContext, cubes: any[]) {
const isDevMode = getEnv('devMode');
function visibilityFilter(item) {
console.log('visibilityFilter', item, isDevMode, context.signedWithPlaygroundAuthSecret, item.isVisible);
return isDevMode || context.signedWithPlaygroundAuthSecret || item.isVisible;
}

Expand All @@ -551,7 +552,7 @@ class ApiGateway {
try {
await this.assertApiScope('meta', context.securityContext);
const compilerApi = await this.getCompilerApi(context);
const metaConfig = await compilerApi.metaConfig({
const metaConfig = await compilerApi.metaConfig(context, {
requestId: context.requestId,
includeCompilerId: includeCompilerId || onlyCompilerId
});
Expand Down Expand Up @@ -587,7 +588,7 @@ class ApiGateway {
try {
await this.assertApiScope('meta', context.securityContext);
const compilerApi = await this.getCompilerApi(context);
const metaConfigExtended = await compilerApi.metaConfigExtended({
const metaConfigExtended = await compilerApi.metaConfigExtended(context, {
requestId: context.requestId,
});
const { metaConfig, cubeDefinitions } = metaConfigExtended;
Expand Down Expand Up @@ -1010,7 +1011,7 @@ class ApiGateway {
} else {
const metaCacheKey = JSON.stringify(ctx);
if (!metaCache.has(metaCacheKey)) {
metaCache.set(metaCacheKey, await compiler.metaConfigExtended(ctx));
metaCache.set(metaCacheKey, await compiler.metaConfigExtended(context, ctx));
}

// checking and fetching result status
Expand Down Expand Up @@ -1195,8 +1196,14 @@ class ApiGateway {
}

const normalizedQuery = normalizeQuery(currentQuery, persistent);
let rewrittenQuery = await this.queryRewrite(
// First apply cube/view level security policies
let rewrittenQuery = (await this.compilerApi(context)).applyRowLevelSecurity(
normalizedQuery,
context
);
// Then apply user-supplied queryRewrite
rewrittenQuery = await this.queryRewrite(
rewrittenQuery,
context,
);

Expand Down Expand Up @@ -1693,7 +1700,7 @@ class ApiGateway {
await this.getNormalizedQueries(query, context);

let metaConfigResult = await (await this
.getCompilerApi(context)).metaConfig({
.getCompilerApi(context)).metaConfig(request.context, {
requestId: context.requestId
});

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

const compilerApi = await this.getCompilerApi(context);
let metaConfigResult = await compilerApi.metaConfig({
let metaConfigResult = await compilerApi.metaConfig(request.context, {
requestId: context.requestId
});

Expand Down
84 changes: 84 additions & 0 deletions packages/cubejs-schema-compiler/src/compiler/CubeSymbols.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { BaseQuery } from '../adapter';
const FunctionRegex = /function\s+\w+\(([A-Za-z0-9_,]*)|\(([\s\S]*?)\)\s*=>|\(?(\w+)\)?\s*=>/;
const CONTEXT_SYMBOLS = {
SECURITY_CONTEXT: 'securityContext',
security_context: 'securityContext',
FILTER_PARAMS: 'filterParams',
FILTER_GROUP: 'filterGroup',
SQL_UTILS: 'sqlUtils'
Expand Down Expand Up @@ -139,6 +140,7 @@ export class CubeSymbols {
this.camelCaseTypes(cube.dimensions);
this.camelCaseTypes(cube.segments);
this.camelCaseTypes(cube.preAggregations);
this.camelCaseTypes(cube.accessPolicy);

if (cube.preAggregations) {
this.transformPreAggregations(cube.preAggregations);
Expand All @@ -148,6 +150,10 @@ export class CubeSymbols {
this.prepareIncludes(cube, errorReporter, splitViews);
}

if (cube.accessPolicy) {
this.prepareAccessPolicy(cube, errorReporter);
}

return Object.assign(
{ cubeName: () => cube.name, cubeObj: () => cube },
cube.measures || {},
Expand Down Expand Up @@ -213,6 +219,59 @@ export class CubeSymbols {
}
}

/**
* @protected
*/
allMembersOrList(cube, specifier) {
const types = ['measures', 'dimensions'];
if (specifier === '*') {
const allMembers = R.unnest(types.map(type => Object.keys(cube[type] || {})));
console.log('allMembers', allMembers);
return allMembers;
} else {
return specifier || [];
}
}

/**
* @protected
*/
prepareAccessPolicy(cube, errorReporter) {
for (const policy of cube.accessPolicy) {
for (const filter of policy?.rowLevel?.filters || []) {
filter.memberReference = this.evaluateReferences(cube, filter.member);
if (filter.memberReference.indexOf('.') !== -1) {
errorReporter.error(
`Paths aren't allowed in security policy filters but '${filter.memberReference}' provided as member for ${cube.name}`
);
}
filter.memberReference = this.pathFromArray([cube.name, filter.memberReference]);
}

if (policy.memberLevel) {
const memberMapper = (member) => {
if (member.indexOf('.') !== -1) {
errorReporter.error(
`Paths aren't allowed in memberLevel policy but '${member}' provided as a member for ${cube.name}`
);
}
return this.pathFromArray([cube.name, member]);
};

const evaluatedIncludes = this.evaluateReferences(cube, policy.memberLevel.includes);
const evaluatedExcludes = this.evaluateReferences(cube, policy.memberLevel.excludes);

// TODO(maxim): Should includes be '*' by default or must it be explicitly defined?
if (!evaluatedIncludes) {
errorReporter.error(`${cube.name} memberLevel.includes must be defined or set to "*"`);
}

policy.memberLevel.includesMembers = this.allMembersOrList(cube, evaluatedIncludes).map(memberMapper);
policy.memberLevel.excludesMembers = this.allMembersOrList(cube, evaluatedExcludes).map(memberMapper);
}
}
}

/**
* @protected
*/
Expand Down Expand Up @@ -406,6 +465,27 @@ export class CubeSymbols {
});
}

// Used to evaluate access policies to allow referencing security_context at query time
evaluateContextFunction(cube, contextFn, context = {}) {
const cubeEvaluator = this;

const res = cubeEvaluator.resolveSymbolsCall(contextFn, (name) => {
const resolvedSymbol = this.resolveSymbol(cube, name);
if (resolvedSymbol) {
return resolvedSymbol;
}
throw new UserError(
`Cube references are not allowed when evaluating RLS conditions or filters. Found: ${name} in ${cube.name}`
);
}, {
contextSymbols: {
securityContext: context.securityContext,
}
});

return res;
}

evaluateReferences(cube, referencesFn, options = {}) {
const cubeEvaluator = this;

Expand Down Expand Up @@ -458,6 +538,10 @@ export class CubeSymbols {
res = res.fn.apply(null, res.memberNames.map((id) => nameResolver(id.trim())));
}
return res;
} catch (e) {
// TODO(maxim): should we keep this log?
console.log('Error while resolving Cube symbols: ', e);
console.error(e);
} finally {
this.resolveSymbolsCallContext = oldContext;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,7 @@ export class CubeToMetaTransformer {
})),
R.toPairs
)(cube.segments || {}),
accessPolicy: cube.accessPolicy || [],
hierarchies: cube.hierarchies || []
},
};
Expand Down
56 changes: 56 additions & 0 deletions packages/cubejs-schema-compiler/src/compiler/CubeValidator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -615,6 +615,61 @@ const SegmentsSchema = Joi.object().pattern(identifierRegex, Joi.object().keys({
public: Joi.boolean().strict(),
}));

const PolicyFilterSchema = Joi.object().keys({
member: Joi.func().required(),
memberReference: Joi.string().required(),
operator: Joi.any().valid(
'equals',
'notEquals',
'contains',
'notContains',
'startsWith',
'notStartsWith',
'endsWith',
'notEndsWith',
'gt',
'gte',
'lt',
'lte',
'inDateRange',
'notInDateRange',
'beforeDate',
'beforeOrOnDate',
'afterDate',
'afterOrOnDate',
).required(),
values: Joi.func().required(),
});

const MemberLevelPolicySchema = Joi.object().keys({
excludes: Joi.func(),
includes: Joi.func(),
includesMembers: Joi.array().items(Joi.string().required()),
excludesMembers: Joi.array().items(Joi.string().required()),
});

const RowLevelPolicySchema = Joi.object().keys({

filters: Joi.array().items(Joi.alternatives().try(
Joi.object().keys({
or: Joi.array().items(PolicyFilterSchema).required(),
and: Joi.array().items(PolicyFilterSchema).required(),
}),
PolicyFilterSchema,
)).required(),
});

// TODO(maxim): follow the "ATTENTION" thing below
const RolePolicySchema = Joi.object().keys({
role: Joi.string().required(),
memberLevel: MemberLevelPolicySchema,
rowLevel: RowLevelPolicySchema,
conditions: Joi.array().items(Joi.object().keys({
if: Joi.func().required(),
})),
// evaluatedConditions: Joi.array().items(Joi.boolean()),
});

/* *****************************
* ATTENTION:
* In case of adding/removing/changing any Joi.func() field that needs to be transpiled,
Expand Down Expand Up @@ -692,6 +747,7 @@ const baseSchema = {
title: Joi.string(),
levels: Joi.func()
})),
accessPolicy: Joi.array().items(RolePolicySchema),
};

const cubeSchema = inherit(baseSchema, {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,10 @@ export const transpiledFieldsPatterns: Array<RegExp> = [
/^excludes$/,
/^hierarchies\.[0-9]+\.levels$/,
/^cubes\.[0-9]+\.(joinPath|join_path)$/,
/^accessPolicy|access_policy\.[0-9]+\.rowLevel|row_level\.filters\.[0-9]+\.member$/,
/^accessPolicy|access_policy\.[0-9]+\.rowLevel|row_level\.filters\.[0-9]+\.values$/,
/^accessPolicy|access_policy\.[0-9]+\.conditions.[0-9]+\.if$/,
/^accessPolicy|access_policy\.[0-9]+\.memberLevel|member_level\.includes|excludes$/,
];

export const transpiledFields: Set<String> = new Set<String>();
Expand Down
36 changes: 36 additions & 0 deletions packages/cubejs-schema-compiler/test/unit/cube-validator.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -979,5 +979,41 @@ describe('Cube Validation', () => {
expect(validationResult.error).toBeFalsy();
}
});

it('cube defined with accessPolicy', async () => {
const cubeValidator = new CubeValidator(new CubeSymbols());
const cube = {
name: 'name',
sqlTable: () => 'public.Users',
fileName: 'fileName',
accessPolicy: [
{
role: 'admin',
conditions: [
{
if: () => true,
}
],
rowLevel: {
filters: [
{
member: () => 'id',
memberReference: 'name.id',
operator: 'equals',
values: () => [1, 2, 3]
}
]
},
memberLevel: {
includes: () => '*',
excludes: () => ['name', 'email']
},
}
],
};

const validationResult = cubeValidator.validate(cube, new ConsoleErrorReporter());
expect(validationResult.error).toBeFalsy();
});
});
});
Loading

0 comments on commit 6742250

Please sign in to comment.