Skip to content

Commit

Permalink
Authorization gateway - basic features (#138)
Browse files Browse the repository at this point in the history
implemented full flow with basic features
Implement local policy attachment caching for all resource repositories
  • Loading branch information
tomeresk authored Jun 15, 2020
1 parent d83a26a commit f64ce80
Show file tree
Hide file tree
Showing 22 changed files with 848 additions and 28 deletions.
18 changes: 9 additions & 9 deletions docs/authorization_spec.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,12 +51,12 @@ metadata:
name: my-user
Spec:
type: js-expression
code: { "result": (data.jwt.sub === data.args.userId) ? "allow" : "deny"}
code: { "result": (input.jwt.sub === input.args.userId) ? "allow" : "deny"}
args:
userId: ID!
```

The `args` are available to use on the data object
The `args` are available to use on the input object

_Note the js-expression type is an example of a possible type and not planned to be implemented at this time._

Expand All @@ -72,13 +72,13 @@ Spec:
code: |
allow = false
allow = {
data.args.userId == data.queries.familyQuery.family.members[_].id
input.args.userId == input.queries.familyQuery.family.members[_].id
}
args:
userId: ID!
queries:
- type: graphql
paramName: familyQuery
name: familyQuery
graphql:
query: |
{
Expand All @@ -104,10 +104,10 @@ metadata:
Spec:
type: opa
code: |
query = sprintf(“graphql { user(%s) {family { members { id} } } }”, data.jwt.sub)
query = sprintf(“graphql { user(%s) {family { members { id} } } }”, input.jwt.sub)
allow = false
allow = {
data.args.userId == data.query.family.members[_].id
input.args.userId == input.query.family.members[_].id
}
args:
userId: ID!
Expand All @@ -127,13 +127,13 @@ Spec:
code: |
allow = false
allow = {
data.queries.myUserPolicy == true
input.queries.myUserPolicy == true
}
args:
userId: ID!
queries:
- type: policy
paramName: myUserPolicy
name: myUserPolicy
policy:
policyName: my-user
args:
Expand Down Expand Up @@ -267,7 +267,7 @@ Spec:
code: |
allow = false
allow {
data.jwt.claims[data.args.claims[i]] == data.args.values[i]
input.jwt.claims[input.args.claims[i]] == input.args.values[i]
}
args:
claims: [String]
Expand Down
5 changes: 5 additions & 0 deletions services/package-lock.json

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

1 change: 1 addition & 0 deletions services/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
"license": "ISC",
"dependencies": {
"@apollo/federation": "^0.12.1",
"@open-policy-agent/opa-wasm": "^1.1.0",
"apollo-datasource-rest": "^0.7.0",
"apollo-link-context": "^1.0.19",
"apollo-link-http": "^1.5.16",
Expand Down
7 changes: 6 additions & 1 deletion services/src/gateway.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,16 +12,21 @@ import {
ResourceRepository,
CompositeResourceRepository,
} from './modules/resource-repository';
import {PolicyExecutor} from './modules/directives/policy/policy-executor';

async function run() {
logger.info('Stitch gateway booting up...');

const resourceRepository = getResourceRepository();

const {server, dispose} = createStitchGateway({
resourceGroups: pollForUpdates(getResourceRepository(), config.resourceUpdateInterval),
resourceGroups: pollForUpdates(resourceRepository, config.resourceUpdateInterval),
tracing: config.enableGraphQLTracing,
playground: config.enableGraphQLPlayground,
introspection: config.enableGraphQLIntrospection,
});
await resourceRepository.initializePolicyAttachments();
PolicyExecutor.repo = resourceRepository;

const app = fastify();
app.register(fastifyMetrics, {endpoint: '/metrics'});
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 @@ -5,13 +5,15 @@ import {sdl as restSdl, RestDirective} from './rest';
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';

export const directiveMap: {[visitorName: string]: typeof SchemaDirectiveVisitor} = {
stub: StubDirective,
rest: RestDirective,
gql: GqlDirective,
export: ExportDirective,
select: SelectDirective,
policy: PolicyDirective,
};

export const sdl = concatAST([stubSdl, restSdl, gqlSdl, exportSdl, selectSdl]);
export const sdl = concatAST([stubSdl, restSdl, gqlSdl, exportSdl, selectSdl, policySdl]);
38 changes: 38 additions & 0 deletions services/src/modules/directives/policy/opa.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
// @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, JwtInput} from './types';
import {PolicyArgsObject} from '../../resource-repository';

export async function evaluate(ctx: PolicyExecutionContext): Promise<PolicyExecutionResult> {
const policy = await getWasmPolicy(ctx);
const input = getInput(ctx);

const result = policy.evaluate(input)?.[0]?.result;

return {done: true, allow: result?.allow};
}

async function getWasmPolicy(ctx: PolicyExecutionContext): Promise<any> {
const filename = getCompiledFilename({namespace: ctx.namespace, name: ctx.name});
const wasm = ctx.repo.getPolicyAttachment(filename);

const rego = new Rego();
return rego.load_policy(wasm);
}

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

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

return input;
}

type PolicyInput = {
jwt?: JwtInput;
args?: PolicyArgsObject;
queries?: QueriesResults;
};
72 changes: 72 additions & 0 deletions services/src/modules/directives/policy/policy-executor.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
import {GraphQLResolveInfo} from 'graphql';
import {RequestContext} from '../../context';
import {Policy, GraphQLArguments} from './types';
import {ResourceRepository, Policy as PolicyDefinition, PolicyArgsObject} from '../../resource-repository';
import {evaluate as evaluateOpa} from './opa';
import {injectParameters} from '../../paramInjection';

const typeEvaluators = {
opa: evaluateOpa,
};

export class PolicyExecutor {
static repo: ResourceRepository;
private policyDefinitions: PolicyDefinition[];

constructor(
protected policies: Policy[],
protected parent: any,
protected args: GraphQLArguments,
protected context: RequestContext,
protected info: GraphQLResolveInfo
) {
// TODO: add jwt data
this.policyDefinitions = PolicyExecutor.repo.getResourceGroup().policies;
}

async validatePolicies() {
await Promise.all(this.policies.map(r => this.validatePolicy(r)));
}

async validatePolicy(policy: Policy) {
const policyDefinition = this.getPolicyDefinition(policy.namespace, policy.name);

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

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

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

if (!allow) throw new Error(`Unauthorized by policy ${policy.name} in namespace ${policy.namespace}`);
}

private preparePolicyArgs(supportedPolicyArgs: PolicyArgsObject, policy: Policy): PolicyArgsObject {
return Object.keys(supportedPolicyArgs).reduce<PolicyArgsObject>((policyArgs, policyArgName) => {
if (policy?.args?.[policyArgName] === undefined)
throw new Error(
`Missing arg ${policyArgName} for policy ${policy.name} in namespace ${policy.namespace}`
);

let policyArgValue = policy.args[policyArgName];
if (typeof policyArgValue === 'string') {
policyArgValue = injectParameters(policyArgValue, this.parent, this.args, this.context, this.info)
.value;
}

policyArgs[policyArgName] = policyArgValue;
return policyArgs;
}, {});
}

private getPolicyDefinition(namespace: string, name: string) {
const policyDefinition = this.policyDefinitions.find(({metadata}) => {
return metadata.namespace === namespace && metadata.name === name;
});

if (!policyDefinition) throw new Error(`The policy ${name} in namespace ${namespace} was not found`);
return policyDefinition;
}
}
30 changes: 30 additions & 0 deletions services/src/modules/directives/policy/policy.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import {GraphQLResolveInfo} from 'graphql';
import {RequestContext} from '../../context';
import {SchemaDirectiveVisitor} from 'graphql-tools';
import {GraphQLField, defaultFieldResolver} from 'graphql';
import {gql} from 'apollo-server-core';
import {PolicyExecutor} from './policy-executor';

export class PolicyDirective extends SchemaDirectiveVisitor {
visitFieldDefinition(field: GraphQLField<any, any>) {
const originalResolve = field.resolve || defaultFieldResolver;
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();

return originalResolve.call(field, parent, args, context, info);
};
}
}

export const sdl = gql`
input PolicyDirectivePolicy {
namespace: String!
name: String!
args: JSONObject
}
directive @policy(policies: [PolicyDirectivePolicy!]!) on FIELD_DEFINITION
`;
38 changes: 38 additions & 0 deletions services/src/modules/directives/policy/types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import {PolicyArgsObject, ResourceRepository} from '../../resource-repository/types';

export type Policy = {
namespace: string;
name: string;
args?: PolicyArgsObject;
};

// args here contain the final values after param injection
export type PolicyExecutionContext = {
namespace: string;
name: string;
repo: ResourceRepository;
jwt?: JwtInput;
args?: PolicyArgsObject;
queries?: QueriesResults;
};

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

export type JwtInput = {
[name: string]: string;
};

export type PolicyExecutionResult = {
done: boolean;
allow?: boolean;
query?: {
type: string;
code: string;
};
};

export type GraphQLArguments = {
[name: string]: any;
};
21 changes: 20 additions & 1 deletion services/src/modules/resource-repository/composite.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import {ResourceRepository, FetchLatestResult} from './types';
import {ResourceRepository, FetchLatestResult, ResourceGroup} from './types';
import {applyResourceGroupUpdates} from './updates';

export class CompositeResourceRepository implements ResourceRepository {
Expand All @@ -13,11 +13,30 @@ export class CompositeResourceRepository implements ResourceRepository {
}));
}

getResourceGroup(): ResourceGroup {
const rgs = this.repositories.map(r => r.getResourceGroup());

return rgs.reduce((rg1, rg2) => applyResourceGroupUpdates(rg1, rg2));
}

async update(): Promise<void> {
throw new Error('Multiplexed resource repository cannot handle updates');
}

async writePolicyAttachment(): Promise<void> {
throw new Error('Multiplexed resource repository cannot handle updates');
}

public getPolicyAttachment(filename: string): Buffer {
for (const repo of this.repositories) {
const policyAttachment = repo.getPolicyAttachment(filename);
if (policyAttachment) return policyAttachment;
}

throw new Error(`Policy attachment with the filename ${filename} was not found`);
}

public async initializePolicyAttachments() {
await Promise.all(this.repositories.map(repo => repo.initializePolicyAttachments()));
}
}
Loading

0 comments on commit f64ce80

Please sign in to comment.