Skip to content

Commit

Permalink
Policy definition args - allow setting a default value for an arg and… (
Browse files Browse the repository at this point in the history
#248)

Co-authored-by: AleF83 <[email protected]>
  • Loading branch information
tomeresk and AleF83 authored Jan 4, 2021
1 parent f7bb40a commit 6f42ba8
Show file tree
Hide file tree
Showing 25 changed files with 426 additions and 105 deletions.
8 changes: 5 additions & 3 deletions docs/authentication.md
Original file line number Diff line number Diff line change
Expand Up @@ -60,8 +60,11 @@ code: |
input.args.aud == input.args.allowedAudience
}
args:
aud: String!
allowedAudience: String!
aud:
type: String!
default: '{jwt.aud}'
allowedAudience:
type: String!
```
Base policy:
Expand All @@ -71,7 +74,6 @@ Base policy:
"namespace": "infra",
"name": "check_audience",
"args": {
"aud": "{jwt.aud}",
"allowedAudience": "<<< my audience >>>"
}
}
Expand Down
18 changes: 13 additions & 5 deletions docs/authorization.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,9 @@ code: |
input.args.userRoles[_] == "admin"
}
args:
userRoles: [String]
userRoles:
type: [String!]
default: '{jwt.roles}'
```
Explanation:
Expand All @@ -45,7 +47,11 @@ Explanation:
- `args`: derived from `@policy` directive arguments evaluation.
- `query`: the result of `query` policy property execution. The query is executed on the Stitch schema without effect of authorization policies (a.k.a. admin privileges). See examples below.

- **_args_**: Arguments mapping. The key is argument name. The value is argument Graphql type. In this example the `userRole` argument can get value of `roles` claim from request JWT. (See `@policy` directive definition below).
- **_args_**: Arguments mapping. The key is argument name, the value is the options for the argument. In this example the `userRole` argument gets the value of the `roles` claim from the request JWT by default. The argument can be given a different value when setting the policy on a field or object, which will override the default.
The options for an argument are:

- `type`: The Graphql type of this argument. If the type is nullable type the argument is optional.
- `default`: The default value (argument injection supported) for the argument. Can be overridden when setting the policy on a field/object.

---

Expand All @@ -62,7 +68,8 @@ code: |
input.query.user.roles[_] == "admin"
}
args:
userId: ID
userId:
type: ID
query:
gql: |
query($id: ID!) {
Expand Down Expand Up @@ -112,7 +119,8 @@ code: |
input.query.policy.another_ns___userIsActive.allow
}
args:
userId: ID
userId:
type: ID
query:
gql: |
query($id: ID!) {
Expand Down Expand Up @@ -144,7 +152,7 @@ type User {
}
```

The policy is defined as in example 1 (See above).
The policy is defined as in example 1 (See above). Note that since the policy definition has the same default value for the `userRoles` argument, the `args` parameter could have been omitted in this case.

In the example above, the `roles` argument value is received by using the Argument Injection mechanism. In this example, it is received from the `roles` claim in the request's JWT.

Expand Down
34 changes: 23 additions & 11 deletions docs/specs/authorization_spec.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,9 @@ Spec:
type: js-expression
code: { "result": (input.args.issuer === "abc.com") ? "allow" : "deny" }
args:
issuer: String!
issuer:
type: String!
default: "{jwt.issuer}"
```
The `args` are available to use on the input object.
Expand All @@ -56,8 +58,10 @@ Spec:
type: js-expression
code: { "result": (input.args.sub === input.args.userId) ? "allow" : "deny"}
args:
userId: ID!
sub: ID!
userId:
type: ID!
sub:
type: ID!
```
_Note the js-expression type is an example of a possible type and not planned to be implemented at this time._
Expand All @@ -77,8 +81,10 @@ Spec:
input.args.userId == input.query.user.family.members[_].id
}
args:
userId: ID!
sub: ID!
userId:
type: ID!
sub:
type: ID!
query:
gql: |
query ($sub: String!) {
Expand Down Expand Up @@ -112,8 +118,10 @@ Spec:
input.args.userId == input.query.family.members[_].id
}
args:
userId: ID!
sub: ID!
userId:
type: ID!
sub:
type: ID!
```

This example will always evaluate the graphql query, but generally this approach should be used when conditional side effect evaluation is needed
Expand All @@ -133,7 +141,8 @@ Spec:
input.query.myUserPolicy == true
}
args:
userId: ID!
userId:
type: ID!
query:
gql: |
query(userId: ID!) {
Expand Down Expand Up @@ -275,9 +284,12 @@ Spec:
input.args.jwtClaims[input.args.claims[i]] == input.args.values[i]
}
args:
claims: [String]
values: [String]
jwtClaims: JSONObject
claims:
type: [String]
values:
type: [String]
jwtClaims:
type: JSONObject
```

Usage:
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP

exports[`Policy Executor One required arg. Has no value 1`] = `[Error: Missing arg arg1 for policy policy in namespace policy-executor-test]`;

exports[`Policy Executor One required arg. Has undefined value 1`] = `[Error: Missing arg arg1 for policy policy in namespace policy-executor-test]`;
182 changes: 182 additions & 0 deletions services/src/modules/directives/policy/policy-executor.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,182 @@
import { when } from 'jest-when';
import {
PolicyArgsDefinitions,
PolicyArgsObject,
PolicyDefinition,
PolicyType,
ResourceMetadata,
} from '../../resource-repository';
import { Policy, PolicyEvaluationContext } from './types';

interface TestCase {
policyArgs?: PolicyArgsObject;
policyArgsDefinitions?: PolicyArgsDefinitions;
expectedArgs?: Record<string, unknown>;
shouldThrow?: boolean;
}

const testCases: [string, TestCase][] = [
[
'Without args',
{
expectedArgs: {},
},
],
[
'Empty args',
{
policyArgs: {},
policyArgsDefinitions: {},
expectedArgs: {},
},
],
[
'One required arg. Has value',
{
policyArgs: { arg1: 'Hello' },
policyArgsDefinitions: { arg1: { type: 'String!' } },
expectedArgs: { arg1: 'Hello' },
},
],
[
'One required arg. Has no value',
{
policyArgs: {},
policyArgsDefinitions: { arg1: { type: 'String!' } },
shouldThrow: true,
},
],
[
'One required arg. Has null value',
{
policyArgs: { arg1: null },
policyArgsDefinitions: { arg1: { type: 'String!' } },
expectedArgs: { arg1: null },
},
],
[
'One required arg. Has undefined value',
{
policyArgs: { arg1: undefined },
policyArgsDefinitions: { arg1: { type: 'String!' } },
shouldThrow: true,
},
],
[
'One optional arg. Has no value',
{
policyArgs: {},
policyArgsDefinitions: { arg1: { type: 'String', optional: true } },
expectedArgs: { arg1: null },
},
],
[
'One optional arg. Has no value and default',
{
policyArgs: {},
policyArgsDefinitions: { arg1: { type: 'String', default: 'Hey', optional: true } },
expectedArgs: { arg1: 'Hey' },
},
],
[
'One required arg. Has no value and default',
{
policyArgs: {},
policyArgsDefinitions: { arg1: { type: 'String!', default: 'Hey' } },
expectedArgs: { arg1: 'Hey' },
},
],
[
'One required arg. Has value and default',
{
policyArgs: { arg1: 'Hello' },
policyArgsDefinitions: { arg1: { type: 'String!', default: 'Hey' } },
expectedArgs: { arg1: 'Hello' },
},
],
[
'Two arguments',
{
policyArgs: { arg1: 'Hello' },
policyArgsDefinitions: { arg1: { type: 'String!' }, arg2: { type: 'String!', default: 'World' } },
expectedArgs: { arg1: 'Hello', arg2: 'World' },
},
],
[
'Two arguments with injected values',
{
policyArgs: { arg1: '{ "H" + "I" }' },
policyArgsDefinitions: { arg1: { type: 'String!' }, arg2: { type: 'String!', default: 'W{1-1}rld' } },
expectedArgs: { arg1: 'HI', arg2: 'W0rld' },
},
],
];

const typedUndefined = <T>() => (undefined as unknown) as T;

describe('Policy Executor', () => {
const policyMetadata: ResourceMetadata = {
namespace: 'policy-executor-test',
name: 'policy',
};

const opaEvaluateMock = jest.fn();

afterAll(() => {
jest.restoreAllMocks();
});

it.each(testCases)('%s', async (_, { policyArgs, policyArgsDefinitions, expectedArgs, shouldThrow }) => {
const ctx: PolicyEvaluationContext = {
...policyMetadata,
args: expectedArgs,
query: undefined,
policyAttachments: typedUndefined(),
};

when(opaEvaluateMock)
.mockReturnValue({ done: true, allow: false })
.calledWith(ctx)
.mockReturnValue({ done: true, allow: true });
jest.mock('./opa', () => ({
evaluate: opaEvaluateMock,
}));

const { default: PolicyExecutor } = await import('./policy-executor');

const executor = new PolicyExecutor();

const policy: Policy = {
...policyMetadata,
args: policyArgs,
};

const policyDefinition: PolicyDefinition = {
metadata: policyMetadata,
type: PolicyType.opa,
code: 'Some code',
args: policyArgsDefinitions,
};

const context = {
resourceGroup: {
policies: [policyDefinition],
},
} as any;

try {
const result = await executor.evaluatePolicy(
policy,
typedUndefined(),
typedUndefined(),
context,
typedUndefined()
);
expect(result).toBeTruthy();
expect(shouldThrow).not.toBeTruthy();
} catch (err) {
expect(shouldThrow).toBeTruthy();
expect(err).toMatchSnapshot();
}
});
});
43 changes: 27 additions & 16 deletions services/src/modules/directives/policy/policy-executor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -112,22 +112,33 @@ export default class PolicyExecutor {
const supportedPolicyArgs = ctx.policyDefinition.args;
if (!supportedPolicyArgs) return;

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

let policyArgValue = ctx.policy.args[policyArgName];
if (typeof policyArgValue === 'string') {
const { source, gqlArgs: args, requestContext: context, info } = ctx;
policyArgValue = inject(policyArgValue, { source, args, context, info });
}

policyArgs[policyArgName] = policyArgValue;
return policyArgs;
}, {});
return Object.entries(supportedPolicyArgs).reduce<PolicyArgsObject>(
(policyArgs, [policyArgName, { default: defaultArg, optional = false }]) => {
const isPolicyArgProvided =
ctx.policy.args && Object.prototype.hasOwnProperty.call(ctx.policy.args, policyArgName);

let policyArgValue = isPolicyArgProvided ? ctx.policy.args?.[policyArgName] : defaultArg;

if (policyArgValue === undefined) {
if (!optional) {
throw new Error(
`Missing arg ${policyArgName} for policy ${ctx.policy.name} in namespace ${ctx.policy.namespace}`
);
}

policyArgValue = null;
}

if (typeof policyArgValue === 'string') {
const { source, gqlArgs: args, requestContext: context, info } = ctx;
policyArgValue = inject(policyArgValue, { source, args, context, info });
}

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

Expand Down
2 changes: 1 addition & 1 deletion services/src/modules/graphql-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,7 @@ export function createGraphQLService(config: { resourceGroups: Observable<Resour
function buildPolicyGqlQuery(policy: PolicyDefinition): DocumentNode {
const argStr = policy.args
? `(${Object.entries(policy.args)
.map(([argName, argType]) => `${argName}: ${argType}`)
.map(([argName, { type }]) => `${argName}: ${type}`)
.join(',')})`
: '';

Expand Down
Loading

0 comments on commit 6f42ba8

Please sign in to comment.