Skip to content

Commit

Permalink
feat: graphql auth v2 add auth on mutation and subscription resolvers
Browse files Browse the repository at this point in the history
  • Loading branch information
SwaySway committed Sep 24, 2021
1 parent 9f5de06 commit 3ec3fe3
Show file tree
Hide file tree
Showing 26 changed files with 3,026 additions and 695 deletions.
20 changes: 10 additions & 10 deletions packages/amplify-graphql-auth-transformer/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -27,17 +27,17 @@
"watch": "tsc -w"
},
"dependencies": {
"@aws-amplify/graphql-transformer-core": "0.8.1",
"@aws-amplify/graphql-transformer-interfaces": "1.8.1",
"@aws-amplify/graphql-model-transformer": "0.5.1",
"@aws-cdk/aws-appsync": "~1.72.0",
"@aws-cdk/aws-dynamodb": "~1.72.0",
"@aws-cdk/core": "~1.72.0",
"@aws-cdk/aws-iam": "~1.72.0",
"constructs": "^3.0.12",
"@aws-amplify/graphql-transformer-core": "0.9.0",
"@aws-amplify/graphql-transformer-interfaces": "1.9.0",
"@aws-amplify/graphql-model-transformer": "0.6.1",
"@aws-cdk/aws-appsync": "~1.119.0",
"@aws-cdk/aws-dynamodb": "~1.119.0",
"@aws-cdk/core": "~1.119.0",
"@aws-cdk/aws-iam": "~1.119.0",
"constructs": "^3.3.125",
"graphql": "^14.5.8",
"graphql-mapping-template": "4.18.2",
"graphql-transformer-common": "4.19.7",
"graphql-mapping-template": "4.18.3",
"graphql-transformer-common": "4.19.9",
"lodash": "^4.17.21"
},
"devDependencies": {
Expand Down
342 changes: 207 additions & 135 deletions packages/amplify-graphql-auth-transformer/src/graphql-auth-transformer.ts

Large diffs are not rendered by default.

137 changes: 137 additions & 0 deletions packages/amplify-graphql-auth-transformer/src/resolvers/field.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
import { OPERATION_KEY } from '@aws-amplify/graphql-model-transformer';
import { FieldDefinitionNode } from 'graphql';
import {
Expression,
iff,
not,
ref,
equals,
str,
compoundExpression,
printBlock,
toJson,
obj,
set,
methodCall,
nul,
ifElse,
bool,
raw,
forEach,
} from 'graphql-mapping-template';
import {
RoleDefinition,
splitRoles,
COGNITO_AUTH_TYPE,
OIDC_AUTH_TYPE,
ConfiguredAuthProviders,
fieldIsList,
IS_AUTHORIZED_FLAG,
} from '../utils';
import { getOwnerClaim, staticGroupRoleExpression, apiKeyExpression, iamExpression } from './helpers';

// Field Read VTL Functions
const generateDynamicAuthReadExpression = (roles: Array<RoleDefinition>, fields: ReadonlyArray<FieldDefinitionNode>) => {
const ownerExpressions = new Array<Expression>();
const dynamicGroupExpressions = new Array<Expression>();
roles.forEach((role, idx) => {
const entityIsList = fieldIsList(fields, role.entity!);
if (role.strategy === 'owner') {
ownerExpressions.push(
iff(
not(ref(IS_AUTHORIZED_FLAG)),
compoundExpression([
set(ref(`ownerEntity${idx}`), methodCall(ref('util.defaultIfNull'), ref(`ctx.source.${role.entity!}`), nul())),
set(ref(`ownerClaim${idx}`), getOwnerClaim(role.claim!)),
...(entityIsList
? [
forEach(ref('allowedOwner'), ref(`ownerEntity${idx}`), [
iff(
equals(ref('allowedOwner'), ref(`ownerClaim${idx}`)),
compoundExpression([set(ref(IS_AUTHORIZED_FLAG), bool(true)), raw('#break')]),
),
]),
]
: [iff(equals(ref('ownerEntity'), ref(`ownerClaim${idx}`)), set(ref(IS_AUTHORIZED_FLAG), bool(true)))]),
]),
),
);
}
if (role.strategy === 'groups') {
dynamicGroupExpressions.push(
iff(
not(ref(IS_AUTHORIZED_FLAG)),
compoundExpression([
set(ref(`groupEntity${idx}`), methodCall(ref('util.defaultIfNull'), ref(`ctx.source.${role.entity!}`), nul())),
set(ref(`groupClaim${idx}`), getOwnerClaim(role.claim!)),
forEach(ref('userGroup'), ref('dynamicGroupClaim'), [
iff(
entityIsList
? methodCall(ref(`groupEntity${idx}.contains`), ref('userGroup'))
: equals(ref(`groupEntity${idx}`), ref('userGroup')),
compoundExpression([set(ref(IS_AUTHORIZED_FLAG), bool(true)), raw('#break')]),
),
]),
]),
),
);
}
});
return [...(ownerExpressions.length > 0 ? ownerExpressions : []), ...(dynamicGroupExpressions.length > 0 ? dynamicGroupExpressions : [])];
};

export const generateAuthExpressionForField = (
provider: ConfiguredAuthProviders,
roles: Array<RoleDefinition>,
fields: ReadonlyArray<FieldDefinitionNode>,
): string => {
const { cognitoStaticGroupRoles, cognitoDynamicRoles, oidcStaticGroupRoles, oidcDynamicRoles, iamRoles, apiKeyRoles } = splitRoles(roles);
const totalAuthExpressions: Array<Expression> = [set(ref(IS_AUTHORIZED_FLAG), bool(false))];
if (provider.hasApiKey) {
totalAuthExpressions.push(apiKeyExpression(apiKeyRoles));
}
if (provider.hasIAM) {
totalAuthExpressions.push(iamExpression(iamRoles, provider.hasAdminUIEnabled));
}
if (provider.hasUserPools) {
totalAuthExpressions.push(
iff(
equals(ref('util.authType()'), str(COGNITO_AUTH_TYPE)),
compoundExpression([
...staticGroupRoleExpression(cognitoStaticGroupRoles),
...generateDynamicAuthReadExpression(cognitoDynamicRoles, fields),
]),
),
);
}
if (provider.hasOIDC) {
totalAuthExpressions.push(
iff(
equals(ref('util.authType()'), str(OIDC_AUTH_TYPE)),
compoundExpression([
...staticGroupRoleExpression(oidcStaticGroupRoles),
...generateDynamicAuthReadExpression(oidcDynamicRoles, fields),
]),
),
);
}
totalAuthExpressions.push(iff(not(ref(IS_AUTHORIZED_FLAG)), ref('util.unauthorized()')));
return printBlock('Field Authorization Steps')(compoundExpression([...totalAuthExpressions, toJson(obj({}))]));
};

/**
* This is the response resolver for fields to protect subscriptions
* @param subscriptionsEnabled
* @returns
*/
export const generateFieldAuthResponse = (operation: string, fieldName: string, subscriptionsEnabled: boolean): string => {
if (subscriptionsEnabled) {
return printBlock('Checking for allowed operations which can return this field')(
compoundExpression([
set(ref('operation'), methodCall(ref('util.defaultIfNull'), methodCall(ref('ctx.source.get'), str(OPERATION_KEY)), nul())),
ifElse(equals(ref('operation'), str(operation)), toJson(nul()), toJson(ref(`context.source.${fieldName}`))),
]),
);
}
return printBlock('Return Source Field')(toJson(ref(`context.source.${fieldName}`)));
};
142 changes: 142 additions & 0 deletions packages/amplify-graphql-auth-transformer/src/resolvers/helpers.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
import {
qref,
Expression,
ifElse,
iff,
methodCall,
not,
ref,
set,
str,
raw,
obj,
bool,
compoundExpression,
printBlock,
toJson,
forEach,
list,
equals,
or,
} from 'graphql-mapping-template';
import { NONE_VALUE } from 'graphql-transformer-common';
import {
DEFAULT_COGNITO_IDENTITY_CLAIM,
RoleDefinition,
IS_AUTHORIZED_FLAG,
ALLOWED_FIELDS,
API_KEY_AUTH_TYPE,
ADMIN_ROLE,
IAM_AUTH_TYPE,
MANAGE_ROLE,
} from '../utils';

// since the keySet returns a set we can convert it to a list by converting to json and parsing back as a list
export const getInputFields = () =>
compoundExpression([
set(ref('inputFields'), methodCall(ref('util.parseJson'), methodCall(ref('util.toJson'), ref('ctx.args.input.keySet()')))),
iff(
ref('ctx.stash.metadata.modelObjectKey'),
forEach(ref('entry'), ref('ctx.stash.metadata.modelObjectKey.keySet()'), [qref(methodCall(ref('inputFields.remove'), ref('entry')))]),
),
]);

export const getIdentityClaimExp = (value: Expression, defaultValueExp: Expression) => {
return methodCall(ref('util.defaultIfNull'), methodCall(ref('ctx.identity.claims.get'), value), defaultValueExp);
};

// for create mutations and subscriptions
export const addAllowedFieldsIfElse = (fieldKey: string, breakLoop: boolean = false) =>
ifElse(
not(ref(`${fieldKey}.isEmpty()`)),
qref(methodCall(ref(`${ALLOWED_FIELDS}.addAll`), ref(fieldKey))),
compoundExpression([set(ref(IS_AUTHORIZED_FLAG), bool(true)), ...(breakLoop ? [raw('#break')] : [])]),
);

/**
* Behavior of auth v1
* Order of how the owner value is retrieved from the jwt
* if claim is username
* 1. username
* 2. cognito:username
* 3. none value
*
* if claim is custom
* 1. custom
* 2. none value
*/
export const getOwnerClaim = (ownerClaim: string): Expression => {
if (ownerClaim === 'username') {
return getIdentityClaimExp(str(ownerClaim), getIdentityClaimExp(str(DEFAULT_COGNITO_IDENTITY_CLAIM), str(NONE_VALUE)));
}
return getIdentityClaimExp(str(ownerClaim), str(NONE_VALUE));
};

export const responseCheckForErrors = () =>
iff(ref('ctx.error'), methodCall(ref('util.error'), ref('ctx.error.message'), ref('ctx.error.type')));

// Common Expressions

export const staticGroupRoleExpression = (roles: Array<RoleDefinition>): Array<Expression> => {
return roles.length > 0
? [
set(ref('staticGroupRoles'), raw(JSON.stringify(roles.map(r => ({ claim: r.claim, entity: r.entity }))))),
forEach(ref('groupRole'), ref('staticGroupRoles'), [
set(ref('groupsInToken'), getIdentityClaimExp(ref('groupRole.claim'), list([]))),
iff(
methodCall(ref('groupsInToken.contains'), ref('groupRole.entity')),
compoundExpression([set(ref(IS_AUTHORIZED_FLAG), bool(true)), raw(`#break`)]),
),
]),
]
: [];
};

export const apiKeyExpression = (roles: Array<RoleDefinition>) =>
iff(
equals(ref('util.authType()'), str(API_KEY_AUTH_TYPE)),
compoundExpression([
...(roles.length > 0 ? [set(ref(IS_AUTHORIZED_FLAG), bool(true))] : []),
iff(not(ref(IS_AUTHORIZED_FLAG)), methodCall(ref('util.unauthorized'))),
]),
);

export const iamExpression = (roles: Array<RoleDefinition>, adminuiEnabled: boolean = false) => {
const iamCheck = (claim: string, exp: Expression) =>
iff(equals(methodCall(ref('ctx.identity.get'), str('cognitoIdentityAuthType')), str(claim)), exp);
const expression = new Array<Expression>();
// allow if using admin ui
if (adminuiEnabled) {
expression.push(
iff(
or([
methodCall(ref('ctx.identity.userArn.contains'), str(ADMIN_ROLE)),
methodCall(ref('ctx.identity.userArn.contains'), str(MANAGE_ROLE)),
]),
raw('#return($util.toJson({})'),
),
);
}
if (roles.length > 0) {
for (let role of roles) {
expression.push(iff(not(ref(IS_AUTHORIZED_FLAG)), iamCheck(role.claim!, set(ref(IS_AUTHORIZED_FLAG), bool(true)))));
}
}
expression.push(iff(not(ref(IS_AUTHORIZED_FLAG)), methodCall(ref('util.unauthorized'))));
return iff(equals(ref('util.authType()'), str(IAM_AUTH_TYPE)), compoundExpression(expression));
};

// Get Request for Update and Delete
export const generateAuthRequestExpression = () => {
const statements = [
set(ref('GetRequest'), obj({ version: str('2018-05-29'), operation: str('GetItem') })),
ifElse(
ref('ctx.stash.metadata.modelObjectKey'),
set(ref('key'), ref('ctx.stash.metadata.modelObjectKey')),
compoundExpression([set(ref('key'), obj({ id: methodCall(ref('util.dynamodb.toDynamoDB'), ref('ctx.args.input.id')) }))]),
),
qref(methodCall(ref('GetRequest.put'), str('key'), ref('key'))),
toJson(ref('GetRequest')),
];
return printBlock('Get Request template')(compoundExpression(statements));
};
Original file line number Diff line number Diff line change
@@ -1 +1,7 @@
export * from './query';
export { generateAuthExpressionForQueries } from './query';
export { generateAuthExpressionForCreate } from './mutation.create';
export { generateAuthExpressionForUpdate } from './mutation.update';
export { geneateAuthExpressionForDelete } from './mutation.delete';
export { generateAuthExpressionForField, generateFieldAuthResponse } from './field';
export { generateAuthExpressionForSubscriptions } from './subscriptions';
export { generateAuthRequestExpression } from './helpers';
Loading

0 comments on commit 3ec3fe3

Please sign in to comment.