Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Support for policy query #143

Merged
merged 8 commits into from
Jul 1, 2020
Merged
Show file tree
Hide file tree
Changes from 5 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions services/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions services/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
"dependencies": {
"@apollo/federation": "^0.12.1",
"@open-policy-agent/opa-wasm": "^1.1.0",
"@types/ramda": "^0.27.6",
"apollo-datasource-rest": "^0.7.0",
"apollo-link-context": "^1.0.19",
"apollo-link-http": "^1.5.16",
Expand All @@ -36,6 +37,7 @@
"p-limit": "^2.2.2",
"pino": "^5.16.0",
"pino-pretty": "^3.6.1",
"ramda": "^0.27.0",
"rxjs": "^6.5.4",
"tslib": "^1.11.0"
},
Expand Down
11 changes: 10 additions & 1 deletion services/src/modules/baseSchema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import GraphQLJSON, {GraphQLJSONObject} from 'graphql-type-json';
import {GraphQLDate, GraphQLDateTime, GraphQLTime} from 'graphql-iso-date';
import {concatAST} from 'graphql';
import {sdl as directivesSdl} from './directives';
import {GraphQLResolverMap} from 'apollo-graphql';

export const baseTypeDef = gql`
scalar JSON
Expand All @@ -11,11 +12,19 @@ export const baseTypeDef = gql`
scalar Date
scalar Time
scalar DateTime

type PolicyResult {
allow: Boolean!
}

type Policy {
default: PolicyResult!
}
`;

export const typeDef = concatAST([baseTypeDef, directivesSdl]);

export const resolvers = {
export const resolvers: GraphQLResolverMap<{}> = {
JSON: GraphQLJSON,
JSONObject: GraphQLJSONObject,
Date: GraphQLDate,
Expand Down
4 changes: 3 additions & 1 deletion services/src/modules/directives/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {sdl as gqlSdl, GqlDirective} from './gql';
import {sdl as exportSdl, ExportDirective} from './export';
import {sdl as selectSdl, SelectDirective} from './select';
import {sdl as policySdl, PolicyDirective} from './policy/policy';
import {sdl as policyQuerySdl, PolicyQueryDirective} from './policy/policy-query';

export const directiveMap: {[visitorName: string]: typeof SchemaDirectiveVisitor} = {
stub: StubDirective,
Expand All @@ -14,6 +15,7 @@ export const directiveMap: {[visitorName: string]: typeof SchemaDirectiveVisitor
export: ExportDirective,
select: SelectDirective,
policy: PolicyDirective,
policyQuery: PolicyQueryDirective,
};

export const sdl = concatAST([stubSdl, restSdl, gqlSdl, exportSdl, selectSdl, policySdl]);
export const sdl = concatAST([stubSdl, restSdl, gqlSdl, exportSdl, selectSdl, policySdl, policyQuerySdl]);
12 changes: 6 additions & 6 deletions services/src/modules/directives/policy/opa.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
// @ts-ignore opa-wasm already has TS typings merged, but not yet published on npm
import * as Rego from '@open-policy-agent/opa-wasm';
import {getCompiledFilename} from '../../opaHelper';
import {PolicyExecutionContext, PolicyExecutionResult, QueriesResults} from './types';
import {PolicyExecutionContext, PolicyExecutionResult, QueryResults} from './types';
import {PolicyArgsObject} from '../../resource-repository';

export async function evaluate(ctx: PolicyExecutionContext): Promise<PolicyExecutionResult> {
Expand All @@ -21,16 +21,16 @@ async function getWasmPolicy(ctx: PolicyExecutionContext): Promise<any> {
return rego.load_policy(wasm);
}

function getInput(ctx: PolicyExecutionContext): PolicyInput {
const input: PolicyInput = {};
function getInput(ctx: PolicyExecutionContext): PolicyOpaInput {
const input: PolicyOpaInput = {};

if (ctx.args) input.args = ctx.args;
if (ctx.queries) input.queries = ctx.queries;
if (ctx.query) input.query = ctx.query;

return input;
}

type PolicyInput = {
type PolicyOpaInput = {
args?: PolicyArgsObject;
queries?: QueriesResults;
query?: QueryResults;
};
45 changes: 40 additions & 5 deletions services/src/modules/directives/policy/policy-executor.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import {GraphQLResolveInfo} from 'graphql';
import {GraphQLResolveInfo, graphql} from 'graphql';
import {RequestContext} from '../../context';
import {Policy, GraphQLArguments} from './types';
import {Policy as PolicyDefinition, PolicyArgsObject, PolicyAttachments} from '../../resource-repository';
import {Policy, GraphQLArguments, QueryResults} from './types';
import {Policy as PolicyDefinition, PolicyArgsObject, PolicyAttachments, PolicyQuery} from '../../resource-repository';
import {evaluate as evaluateOpa} from './opa';
import {injectParameters} from '../../paramInjection';

Expand All @@ -28,22 +28,28 @@ export class PolicyExecutor {
await Promise.all(this.policies.map(r => this.validatePolicy(r)));
}

async validatePolicy(policy: Policy) {
async evaluatePolicy(policy: Policy): Promise<boolean> {
const policyDefinition = this.getPolicyDefinition(policy.namespace, policy.name);

const args = policyDefinition.args && this.preparePolicyArgs(policyDefinition.args, policy);
// TODO: evaluate queries

const query = policyDefinition.query && (await this.evaluatePolicyQuery(policyDefinition.query, args));
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It should also support param injection from jwt in addition to args, though this is something I'm adding in my next PR. We should probably wait until after both PRs are merged and then add jwt injection support to queries.

Probably worth adding a TODO for it though to be safe


const evaluate = typeEvaluators[policyDefinition.type];
if (!evaluate) throw new Error(`Unsupported policy type ${policyDefinition.type}`);

const {done, allow} = await evaluate({
...policy,
args,
query,
policyAttachments: this.policyAttachments,
});
if (!done) throw new Error('in-line query evaluation not yet supported');
return allow || false;
}

async validatePolicy(policy: Policy): Promise<void> {
const allow = await this.evaluatePolicy(policy);
if (!allow) throw new Error(`Unauthorized by policy ${policy.name} in namespace ${policy.namespace}`);
}

Expand Down Expand Up @@ -73,4 +79,33 @@ export class PolicyExecutor {
if (!policyDefinition) throw new Error(`The policy ${name} in namespace ${namespace} was not found`);
return policyDefinition;
}

private async evaluatePolicyQuery(
query: PolicyQuery,
args: PolicyArgsObject = {}
): Promise<QueryResults | undefined> {
let variableValues =
query.variables &&
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The query definition is very different from the spec, if you make changes you should also update the spec to match this

Object.entries(query.variables).reduce<{[key: string]: any}>((policyArgs, [varName, varValue]) => {
if (typeof varValue === 'string') {
varValue = injectParameters(varValue, this.parent, args, this.context, this.info).value;
}
policyArgs[varName] = varValue;
return policyArgs;
}, {});

// TODO: Run with admin permissions
const context: RequestContext = {...this.context, ignorePolicies: true};
const gqlResult = await graphql(this.info.schema, query.gql, undefined, context, variableValues);
return gqlResult.data || undefined;
}
}

declare module '../../context' {
interface RequestContext {
/**
* This flag indicates that request should be resolved without invoking authorization policies evaluation
*/
ignorePolicies: boolean;
}
}
31 changes: 31 additions & 0 deletions services/src/modules/directives/policy/policy-query.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import {SchemaDirectiveVisitor} from 'graphql-tools';
import {GraphQLField, GraphQLResolveInfo} from 'graphql';
import {RequestContext} from '../../context';
import {gql} from 'apollo-server-core';
import {PolicyResult, Policy} from './types';
import {PolicyExecutor} from './policy-executor';

export class PolicyQueryDirective extends SchemaDirectiveVisitor {
visitFieldDefinition(field: GraphQLField<any, any>) {
field.resolve = async (
parent: any,
args: any,
context: RequestContext,
info: GraphQLResolveInfo
): Promise<PolicyResult> => {
const policy: Policy = {
namespace: this.args.namespace,
name: this.args.name,
args: args,
};

const executor = new PolicyExecutor([], parent, args, context, info);
const allow = await executor.evaluatePolicy(policy);
return {allow};
};
}
}

export const sdl = gql`
directive @policyQuery(namespace: String!, name: String!) on FIELD_DEFINITION
`;
6 changes: 4 additions & 2 deletions services/src/modules/directives/policy/policy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,10 @@ export class PolicyDirective extends SchemaDirectiveVisitor {
const policies = this.args.policies;

field.resolve = async (parent: any, args: any, context: RequestContext, info: GraphQLResolveInfo) => {
const executor = new PolicyExecutor(policies, parent, args, context, info);
await executor.validatePolicies();
if (!context.ignorePolicies) {
const executor = new PolicyExecutor(policies, parent, args, context, info);
await executor.validatePolicies();
}

return originalResolve.call(field, parent, args, context, info);
};
Expand Down
10 changes: 7 additions & 3 deletions services/src/modules/directives/policy/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,11 +12,11 @@ export type PolicyExecutionContext = {
name: string;
policyAttachments: PolicyAttachments;
args?: PolicyArgsObject;
queries?: QueriesResults;
query?: QueryResults;
};

export type QueriesResults = {
[name: string]: string;
export type QueryResults = {
[name: string]: any;
};

export type PolicyExecutionResult = {
Expand All @@ -28,6 +28,10 @@ export type PolicyExecutionResult = {
};
};

export interface PolicyResult {
allow: boolean;
}

export type GraphQLArguments = {
[name: string]: any;
};
48 changes: 41 additions & 7 deletions services/src/modules/graphqlService.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
import {Unsubscriber, GraphQLServiceConfig, SchemaChangeCallback} from 'apollo-server-core';
import {parse, execute} from 'graphql';
import {Unsubscriber, GraphQLServiceConfig, SchemaChangeCallback, gql} from 'apollo-server-core';
import {parse, execute, DocumentNode} from 'graphql';
import {Observable, Subscription} from 'rxjs';
import {shareReplay, map, take, tap, catchError, skip} from 'rxjs/operators';

import {directiveMap} from './directives';
import {ResourceGroup, Policy, PolicyAttachments} from './resource-repository';
import {ResourceGroup, Policy, PolicyAttachments, Schema} from './resource-repository';
import {buildSchemaFromFederatedTypeDefs} from './buildFederatedSchema';
import * as baseSchema from './baseSchema';
import {ActiveDirectoryAuth} from './auth/activeDirectoryAuth';
Expand Down Expand Up @@ -55,13 +55,28 @@ export function createGraphQLService(config: {resourceGroups: Observable<Resourc
};
}

function buildPolicyGqlQuery(policy: Policy): DocumentNode {
const argStr = policy.args
? `(${Object.entries(policy.args)
.map(([argName, argType]) => `${argName}: ${argType}`)
.join(',')})`
: '';

return gql`
extend type Policy {
${policy.metadata.namespace}___${policy.metadata.name}${argStr}: PolicyResult! @policyQuery(namespace: "${policy.metadata.namespace}", name: "${policy.metadata.name}")
}
`;
}

export function createSchemaConfig(rg: ResourceGroup): GraphQLServiceConfig {
const activeDirectoryAuth = new ActiveDirectoryAuth();
const upstreamsByHost = new Map(rg.upstreams.map(u => [u.host, u]));
const upstreamClientCredentialsByAuthority = new Map(
rg.upstreamClientCredentials.map(u => [u.activeDirectory.authority, u])
);
const schemas = rg.schemas.length === 0 ? [defaultSchema] : rg.schemas;
const schemas = rg.schemas.length === 0 ? [defaultSchema] : [initialSchema, ...rg.schemas];
const policies = rg.policies ?? [];

const authenticationConfig: AuthenticationConfig = {
getUpstreamByHost(host: string) {
Expand All @@ -73,15 +88,19 @@ export function createSchemaConfig(rg: ResourceGroup): GraphQLServiceConfig {
activeDirectoryAuth,
};

const schemaTypeDefs = schemas.map(s => [`${s.metadata.namespace}/${s.metadata.name}`, parse(s.schema)]);
const policyQueryTypeDefs = policies.map(p => [
`${p.metadata.namespace}___${p.metadata.name}`,
buildPolicyGqlQuery(p),
]);
const schema = buildSchemaFromFederatedTypeDefs({
typeDefs: Object.fromEntries(schemas.map(s => [`${s.metadata.namespace}/${s.metadata.name}`, parse(s.schema)])),
typeDefs: Object.fromEntries([...schemaTypeDefs, ...policyQueryTypeDefs]),
baseTypeDefs: baseSchema.baseTypeDef,
directiveTypeDefs: directivesSdl,
resolvers: baseSchema.resolvers,
schemaDirectives: directiveMap,
schemaDirectivesContext: {authenticationConfig},
});

return {
schema,
executor(requestContext) {
Expand All @@ -100,11 +119,26 @@ export function createSchemaConfig(rg: ResourceGroup): GraphQLServiceConfig {
};
}

const defaultSchema = {
const defaultSchema: Schema = {
metadata: {namespace: '__internal__', name: 'default'},
schema: 'type Query { default: String! @stub(value: "default") }',
};

const initialSchema: Schema = {
metadata: {
namespace: '__internal__',
name: '__initial__',
},
schema: `
type Query {
policy: Policy! @stub(value: {
default: {
allow: true
}
})
}`,
};

declare module './context' {
interface RequestContext {
authenticationConfig: AuthenticationConfig;
Expand Down
Loading