diff --git a/packages/amplify-appsync-simulator/src/__tests__/velocity/util/list-utils.test.ts b/packages/amplify-appsync-simulator/src/__tests__/velocity/util/list-utils.test.ts index ea1bf7f3298..e1d7731b2ee 100644 --- a/packages/amplify-appsync-simulator/src/__tests__/velocity/util/list-utils.test.ts +++ b/packages/amplify-appsync-simulator/src/__tests__/velocity/util/list-utils.test.ts @@ -1,4 +1,5 @@ import { create } from '../../../velocity/util/index'; +import { map as valueMap } from '../../../velocity/value-mapper/mapper'; import { GraphQLResolveInfo } from 'graphql'; import { map, random } from 'lodash'; import { AppSyncGraphQLExecutionContext } from '../../../utils/graphql-runner'; @@ -19,17 +20,27 @@ beforeEach(() => { }); describe('$utils.list.copyAndRetainAll', () => { - it('should retain', () => { + it('should retain numbers list', () => { const myList = [1, 2, 3, 4, 5]; expect(util.list.copyAndRetainAll(myList, [2, 4])).toEqual([2, 4]); }); + it('should retain java array of java strings', () => { + const myList = valueMap(['foo', 'bar', 'baz', 'qux']); + const result = util.list.copyAndRetainAll(myList, valueMap(['foo', 'bar'])); + expect(result.toJSON()).toEqual(['foo', 'bar']); + }); }); describe('$utils.list.copyAndRemoveAll', () => { - it('should remove', () => { + it('should remove numbers', () => { const myList = [1, 2, 3, 4, 5]; expect(util.list.copyAndRemoveAll(myList, [2, 4])).toEqual([1, 3, 5]); }); + it('should remove java array of java strings', () => { + const myList = valueMap(['foo', 'bar', 'baz', 'qux']); + const result = util.list.copyAndRemoveAll(myList, valueMap(['bar', 'qux'])); + expect(result.toJSON()).toEqual(['foo', 'baz']); + }); }); describe('$utils.list.sortList', () => { diff --git a/packages/amplify-appsync-simulator/src/utils/auth-helpers/helpers.ts b/packages/amplify-appsync-simulator/src/utils/auth-helpers/helpers.ts index 4bb162da9dc..658f6502a64 100644 --- a/packages/amplify-appsync-simulator/src/utils/auth-helpers/helpers.ts +++ b/packages/amplify-appsync-simulator/src/utils/auth-helpers/helpers.ts @@ -18,8 +18,19 @@ export type JWTToken = { nbf?: number; username?: string; email?: string; + groups?: string[]; 'cognito:username'?: string; - 'cognitio:groups'?: string[]; + 'cognito:groups'?: string[]; +}; + +export type IAMToken = { + accountId: string; + userArn: string; + username: string; + cognitoIdentityPoolId?: string; + cognitoIdentityId?: string; + cognitoIdentityAuthType?: string; + cognitoIdentityAuthProvider?: string; }; export function extractJwtToken(authorization: string): JWTToken { diff --git a/packages/amplify-appsync-simulator/src/utils/graphql-runner/index.ts b/packages/amplify-appsync-simulator/src/utils/graphql-runner/index.ts index ed26b2e4d6f..adecf5a0f6c 100644 --- a/packages/amplify-appsync-simulator/src/utils/graphql-runner/index.ts +++ b/packages/amplify-appsync-simulator/src/utils/graphql-runner/index.ts @@ -1,9 +1,10 @@ -import { JWTToken } from '../auth-helpers/helpers'; +import { JWTToken, IAMToken } from '../auth-helpers/helpers'; import { AmplifyAppSyncSimulatorAuthenticationType } from '../../type-definition'; export type AppSyncGraphQLExecutionContext = { - readonly jwt?: JWTToken; readonly sourceIp?: string; + readonly jwt?: JWTToken; + readonly iamToken?: IAMToken; headers: Record; appsyncErrors?: Error[]; requestAuthorizationMode: AmplifyAppSyncSimulatorAuthenticationType; diff --git a/packages/amplify-appsync-simulator/src/velocity/index.ts b/packages/amplify-appsync-simulator/src/velocity/index.ts index 12a21418fe0..98d3d7a6df6 100644 --- a/packages/amplify-appsync-simulator/src/velocity/index.ts +++ b/packages/amplify-appsync-simulator/src/velocity/index.ts @@ -99,7 +99,7 @@ export class VelocityTemplate { info: GraphQLResolveInfo, ): any { const { source, arguments: argument, result, stash, prevResult, error } = ctxValues; - const { jwt } = requestContext; + const { jwt, sourceIp, iamToken } = requestContext; const { iss: issuer, sub, 'cognito:username': cognitoUserName, username } = jwt || {}; const util = createUtil([], new Date(Date.now()), info, requestContext); @@ -110,21 +110,32 @@ export class VelocityTemplate { identity = convertToJavaTypes({ sub, issuer, + sourceIp, claims: requestContext.jwt, }); } else if (requestContext.requestAuthorizationMode === AmplifyAppSyncSimulatorAuthenticationType.AMAZON_COGNITO_USER_POOLS) { identity = convertToJavaTypes({ sub, issuer, + sourceIp, 'cognito:username': cognitoUserName, username: username || cognitoUserName, - sourceIp: requestContext.sourceIp, claims: requestContext.jwt, ...(this.simulatorContext.appSyncConfig.defaultAuthenticationType.authenticationType === AmplifyAppSyncSimulatorAuthenticationType.AMAZON_COGNITO_USER_POOLS ? { defaultAuthStrategy: 'ALLOW' } : {}), }); + } else if (requestContext.requestAuthorizationMode === AmplifyAppSyncSimulatorAuthenticationType.AWS_IAM) { + identity = convertToJavaTypes({ + sourceIp, + username: iamToken.username, + userArn: iamToken.userArn, + cognitoIdentityPoolId: iamToken?.cognitoIdentityPoolId, + cognitoIdentityId: iamToken?.cognitoIdentityId, + cognitoIdentityAuthType: iamToken?.cognitoIdentityAuthType, + cognitoIdentityAuthProvider: iamToken?.cognitoIdentityAuthProvider, + }); } const vtlContext = { diff --git a/packages/amplify-appsync-simulator/src/velocity/util/list-utils.ts b/packages/amplify-appsync-simulator/src/velocity/util/list-utils.ts index a31a2c080fb..f3cc24661b9 100644 --- a/packages/amplify-appsync-simulator/src/velocity/util/list-utils.ts +++ b/packages/amplify-appsync-simulator/src/velocity/util/list-utils.ts @@ -1,11 +1,23 @@ import { identity, isObject, negate, orderBy, some } from 'lodash'; +import { JavaArray } from '../value-mapper/array'; +import { map as valueMap } from '../value-mapper/mapper'; export const listUtils = { copyAndRetainAll(list: any[], intersect: any[]) { - return list.filter(value => intersect.indexOf(value) !== -1); + if (list instanceof JavaArray && intersect instanceof JavaArray) { + return valueMap(list.toJSON().filter(value => intersect.toJSON().includes(value))); + } else { + return list.filter(value => intersect.indexOf(value) !== -1); + } }, copyAndRemoveAll(list: any[], toRemove: any[]) { - return list.filter(value => toRemove.indexOf(value) === -1); + if (list instanceof JavaArray && toRemove instanceof JavaArray) { + // we convert back to js array to filter and then re-create as java array when filtering is done + // this is avoid using filtering within the java array is that can create a '0' entry + return valueMap(list.toJSON().filter(value => !toRemove.toJSON().includes(value))); + } else { + return list.filter(value => toRemove.indexOf(value) === -1); + } }, sortList(list: any[], desc: boolean, property: string) { if (list.length === 0 || list.length > 1000) { diff --git a/packages/amplify-util-mock/src/__tests__/graphql-vtl/model-auth.test.ts b/packages/amplify-util-mock/src/__tests__/graphql-vtl/model-auth.test.ts deleted file mode 100644 index 7d02861aa76..00000000000 --- a/packages/amplify-util-mock/src/__tests__/graphql-vtl/model-auth.test.ts +++ /dev/null @@ -1,74 +0,0 @@ -import { AuthTransformer } from '@aws-amplify/graphql-auth-transformer'; -import { ModelTransformer } from '@aws-amplify/graphql-model-transformer'; -import { GraphQLTransform } from '@aws-amplify/graphql-transformer-core'; -import { AppSyncAuthConfiguration } from '@aws-amplify/graphql-transformer-interfaces'; -import { AmplifyAppSyncSimulatorAuthenticationType, AppSyncGraphQLExecutionContext } from 'amplify-appsync-simulator'; -import { VelocityTemplateSimulator, AppSyncVTLContext, getJWTToken } from '../../velocity'; - -const USER_POOL_ID = 'us-fake-1ID'; - -test('auth transformer validation happy case', () => { - const authConfig: AppSyncAuthConfiguration = { - defaultAuthentication: { - authenticationType: 'AMAZON_COGNITO_USER_POOLS', - }, - additionalAuthenticationProviders: [], - }; - const validSchema = ` - type Post @model @auth(rules: [{ allow: owner }]) { - id: ID! - title: String! - createdAt: String - updatedAt: String - }`; - const transformer = new GraphQLTransform({ - authConfig, - transformers: [new ModelTransformer(), new AuthTransformer()], - }); - const out = transformer.transform(validSchema); - expect(out).toBeDefined(); - - // create the owner type - const ownerContext: AppSyncVTLContext = { - arguments: { - input: { - id: '001', - title: 'sample', - }, - }, - }; - const ownerRequestContext: AppSyncGraphQLExecutionContext = { - jwt: getJWTToken(USER_POOL_ID, 'user1', 'user1@test.com'), - headers: {}, - sourceIp: '', - requestAuthorizationMode: AmplifyAppSyncSimulatorAuthenticationType.AMAZON_COGNITO_USER_POOLS, - }; - - const createMutationTemplate = out.pipelineFunctions['Mutation.createPost.auth.1.req.vtl']; - const createMutationVTL = new VelocityTemplateSimulator({ - authConfig, - template: createMutationTemplate, - }); - const mutationResult = createMutationVTL.render(ownerContext, ownerRequestContext); - expect(mutationResult).toBeDefined(); - expect(mutationResult.stash.hasAuth).toEqual(true); - expect(mutationResult.args).toBeDefined(); - expect(mutationResult.errors).toBeDefined(); - // since we have an owner rule we expect the owner field to be defined in the argument input - expect(mutationResult.args.input.owner).toEqual('user1'); - - // expect the query resolver to contain a filter for the owner - const queryTemplate = out.pipelineFunctions['Query.listPosts.auth.1.req.vtl']; - const queryVTL = new VelocityTemplateSimulator({ - authConfig, - template: queryTemplate, - }); - const queryResponse = queryVTL.render({}, ownerRequestContext); - expect(queryResponse).toBeDefined(); - expect(queryResponse.stash.hasAuth).toEqual(true); - expect(queryResponse.stash.authFilter).toEqual( - expect.objectContaining({ - or: [{ owner: { eq: 'user1' } }], - }), - ); -}); diff --git a/packages/amplify-util-mock/src/__tests__/velocity/model-auth.test.ts b/packages/amplify-util-mock/src/__tests__/velocity/model-auth.test.ts new file mode 100644 index 00000000000..d8be358b6a9 --- /dev/null +++ b/packages/amplify-util-mock/src/__tests__/velocity/model-auth.test.ts @@ -0,0 +1,542 @@ +import { AuthTransformer } from '@aws-amplify/graphql-auth-transformer'; +import { ModelTransformer } from '@aws-amplify/graphql-model-transformer'; +import { GraphQLTransform } from '@aws-amplify/graphql-transformer-core'; +import { AppSyncAuthConfiguration } from '@aws-amplify/graphql-transformer-interfaces'; +import { AmplifyAppSyncSimulatorAuthenticationType, AppSyncGraphQLExecutionContext } from 'amplify-appsync-simulator'; +import { VelocityTemplateSimulator, AppSyncVTLContext, getJWTToken } from '../../velocity'; + +const USER_POOL_ID = 'us-fake-1ID'; + +describe('@model owner mutation checks', () => { + let vtlTemplate: VelocityTemplateSimulator; + let transformer: GraphQLTransform; + const ownerRequest: AppSyncGraphQLExecutionContext = { + requestAuthorizationMode: AmplifyAppSyncSimulatorAuthenticationType.AMAZON_COGNITO_USER_POOLS, + jwt: getJWTToken(USER_POOL_ID, 'user1', 'user1@test.com'), + headers: {}, + sourceIp: '', + }; + + beforeEach(() => { + const authConfig: AppSyncAuthConfiguration = { + defaultAuthentication: { + authenticationType: 'AMAZON_COGNITO_USER_POOLS', + }, + additionalAuthenticationProviders: [], + }; + transformer = new GraphQLTransform({ + authConfig, + transformers: [new ModelTransformer(), new AuthTransformer()], + }); + vtlTemplate = new VelocityTemplateSimulator({ authConfig }); + }); + + test('implicit owner with default owner field', () => { + const validSchema = ` + type Post @model @auth(rules: [{ allow: owner }]) { + id: ID! + title: String! + createdAt: String + updatedAt: String + }`; + const out = transformer.transform(validSchema); + expect(out).toBeDefined(); + // create the owner type + const ownerContext: AppSyncVTLContext = { + arguments: { input: { id: '001', title: 'sample' } }, + }; + + // expect the query resolver to contain a filter for the owner + const queryTemplate = out.pipelineFunctions['Query.listPosts.auth.1.req.vtl']; + const queryResponse = vtlTemplate.render(queryTemplate, { context: {}, requestParameters: ownerRequest }); + expect(queryResponse).toBeDefined(); + expect(queryResponse.stash.hasAuth).toEqual(true); + expect(queryResponse.stash.authFilter).toEqual( + expect.objectContaining({ + or: [{ owner: { eq: 'user1' } }], + }), + ); + + const createRequestTemplate = out.pipelineFunctions['Mutation.createPost.auth.1.req.vtl']; + const createVTLRequest = vtlTemplate.render(createRequestTemplate, { context: ownerContext, requestParameters: ownerRequest }); + expect(createVTLRequest).toBeDefined(); + expect(createVTLRequest.stash.hasAuth).toEqual(true); + expect(createVTLRequest.args).toBeDefined(); + expect(createVTLRequest.hadException).toEqual(false); + // since we have an owner rule we expect the owner field to be defined in the argument input + expect(createVTLRequest.args.input.owner).toEqual('user1'); + + const updateRequestTemplate = out.pipelineFunctions['Mutation.updatePost.auth.1.req.vtl']; + const updateVTLRequest = vtlTemplate.render(updateRequestTemplate, { context: ownerContext, requestParameters: ownerRequest }); + expect(updateVTLRequest).toBeDefined(); + // here we expect a get item payload to verify the owner making the update request is valid + expect(updateVTLRequest.result).toEqual( + expect.objectContaining({ + key: { id: { S: '001' } }, + operation: 'GetItem', + version: '2018-05-29', + }), + ); + // atm there is there is nothing in the stash yet + expect(updateVTLRequest.stash).toEqual({}); + const updateResponseTemplate = out.pipelineFunctions['Mutation.updatePost.auth.1.res.vtl']; + // response where the owner is indeed the owner + const updateVTLResponse = vtlTemplate.render(updateResponseTemplate, { + context: { ...ownerContext, result: { id: '001', owner: 'user1' } }, + requestParameters: ownerRequest, + }); + expect(updateVTLResponse).toBeDefined(); + expect(updateVTLResponse.hadException).toEqual(false); + expect(updateVTLResponse.stash.hasAuth).toEqual(true); + // response where theere is an error + const updateVTLWithError = vtlTemplate.render(updateResponseTemplate, { + context: { ...ownerContext, result: { id: '001', owner: 'user2' } }, + requestParameters: ownerRequest, + }); + expect(updateVTLWithError).toBeDefined(); + expect(updateVTLWithError.hadException).toEqual(true); + expect(updateVTLWithError.errors).toHaveLength(1); + expect(updateVTLWithError.errors[0]).toEqual( + expect.objectContaining({ + errorType: 'Unauthorized', + message: expect.stringContaining('Unauthorized on $util.unauthorized()'), + }), + ); + }); + + test('implicit owner with custom field', () => { + const validSchema = ` + type Post @model @auth(rules: [{ allow: owner, ownerField: "editor" }]) { + id: ID! + title: String! + createdAt: String + updatedAt: String + }`; + const out = transformer.transform(validSchema); + expect(out).toBeDefined(); + // create context and request + const ownerContext: AppSyncVTLContext = { + arguments: { input: { id: '001', title: 'sample' } }, + }; + + const createRequestTemplate = out.pipelineFunctions['Mutation.createPost.auth.1.req.vtl']; + const createVTLRequest = vtlTemplate.render(createRequestTemplate, { context: ownerContext, requestParameters: ownerRequest }); + expect(createVTLRequest).toBeDefined(); + expect(createVTLRequest.stash.hasAuth).toEqual(true); + expect(createVTLRequest.args).toBeDefined(); + expect(createVTLRequest.hadException).toEqual(false); + // since we have an owner rule we expect the owner field to be defined in the argument input + expect(createVTLRequest.args.input.editor).toEqual('user1'); + }); + + test('explicit owner with default field', () => { + const validSchema = ` + type Post @model @auth(rules: [{ allow: owner }]) { + id: ID! + title: String! + createdAt: String + updatedAt: String + owner: String + }`; + const out = transformer.transform(validSchema); + expect(out).toBeDefined(); + // create context and request + const ownerContext: AppSyncVTLContext = { + arguments: { input: { id: '001', title: 'sample' } }, + }; + + const createRequestTemplate = out.pipelineFunctions['Mutation.createPost.auth.1.req.vtl']; + const createVTLRequest = vtlTemplate.render(createRequestTemplate, { context: ownerContext, requestParameters: ownerRequest }); + expect(createVTLRequest).toBeDefined(); + expect(createVTLRequest.stash.hasAuth).toEqual(true); + expect(createVTLRequest.args).toBeDefined(); + expect(createVTLRequest.hadException).toEqual(false); + // since we have an owner rule we expect the owner field to be defined in the argument input + expect(createVTLRequest.args.input.owner).toEqual('user1'); + }); + + test('explicit owner with custom field', () => { + const validSchema = ` + type Post @model @auth(rules: [{ allow: owner, ownerField: "editor" }]) { + id: ID! + title: String! + createdAt: String + updatedAt: String + editor: String + }`; + const out = transformer.transform(validSchema); + expect(out).toBeDefined(); + // create context and request + const ownerContext: AppSyncVTLContext = { + arguments: { input: { id: '001', title: 'sample' } }, + }; + + const createRequestTemplate = out.pipelineFunctions['Mutation.createPost.auth.1.req.vtl']; + const createVTLRequest = vtlTemplate.render(createRequestTemplate, { context: ownerContext, requestParameters: ownerRequest }); + expect(createVTLRequest).toBeDefined(); + expect(createVTLRequest.stash.hasAuth).toEqual(true); + expect(createVTLRequest.args).toBeDefined(); + expect(createVTLRequest.hadException).toEqual(false); + // since we have an owner rule we expect the owner field to be defined in the argument input + expect(createVTLRequest.args.input.editor).toEqual('user1'); + + const differentOwnerContext: AppSyncVTLContext = { arguments: { input: { id: '001', title: 'sample', editor: 'user2' } } }; + const createVTLRequestWithErrors = vtlTemplate.render(createRequestTemplate, { + context: differentOwnerContext, + requestParameters: ownerRequest, + }); + expect(createVTLRequestWithErrors).toBeDefined(); + expect(createVTLRequestWithErrors.hadException).toEqual(true); + expect(createVTLRequestWithErrors.errors).toHaveLength(1); + // should fail since the owner in the input is different than what is in the + expect(createVTLRequestWithErrors.errors[0]).toEqual( + expect.objectContaining({ + errorType: 'Unauthorized', + message: expect.stringContaining('Unauthorized on $util.unauthorized()'), + }), + ); + }); + + test('explicit owner using a custom list field', () => { + const validSchema = ` + type Post @model @auth(rules: [{ allow: owner, ownerField: "editors" }]) { + id: ID! + title: String! + createdAt: String + updatedAt: String + editors: [String] + }`; + const out = transformer.transform(validSchema); + expect(out).toBeDefined(); + // create context and request + const ownerContext: AppSyncVTLContext = { + arguments: { input: { id: '001', title: 'sample' } }, + }; + + const createRequestTemplate = out.pipelineFunctions['Mutation.createPost.auth.1.req.vtl']; + expect(createRequestTemplate).toBeDefined(); + const createVTLRequest = vtlTemplate.render(createRequestTemplate, { context: ownerContext, requestParameters: ownerRequest }); + expect(createVTLRequest).toBeDefined(); + expect(createVTLRequest.stash.hasAuth).toEqual(true); + expect(createVTLRequest.args).toBeDefined(); + expect(createVTLRequest.hadException).toEqual(false); + // since we have an owner rule we expect the owner field to be defined in the argument input + expect(createVTLRequest.args.input.editors).toEqual(['user1']); + + // should fail if the list of users does not contain the currently signed user + const failedCreateVTLRequest = vtlTemplate.render(createRequestTemplate, { + context: { + arguments: { input: { id: '001', title: 'sample', editors: ['user2'] } }, + }, + requestParameters: ownerRequest, + }); + expect(failedCreateVTLRequest.hadException).toEqual(true); + // should fail since the owner in the input is different than what is in the + expect(failedCreateVTLRequest.errors[0]).toEqual( + expect.objectContaining({ + errorType: 'Unauthorized', + message: expect.stringContaining('Unauthorized on $util.unauthorized()'), + }), + ); + }); +}); + +describe('@model operations', () => { + let vtlTemplate: VelocityTemplateSimulator; + let transformer: GraphQLTransform; + const ownerRequest: AppSyncGraphQLExecutionContext = { + requestAuthorizationMode: AmplifyAppSyncSimulatorAuthenticationType.AMAZON_COGNITO_USER_POOLS, + jwt: getJWTToken(USER_POOL_ID, 'user1', 'user1@test.com'), + headers: {}, + sourceIp: '', + }; + const adminGroupRequest: AppSyncGraphQLExecutionContext = { + requestAuthorizationMode: AmplifyAppSyncSimulatorAuthenticationType.AMAZON_COGNITO_USER_POOLS, + jwt: getJWTToken(USER_POOL_ID, 'user2', 'user2@test.com', ['admin']), + headers: {}, + sourceIp: '', + }; + const editorGroupRequest: AppSyncGraphQLExecutionContext = { + requestAuthorizationMode: AmplifyAppSyncSimulatorAuthenticationType.AMAZON_COGNITO_USER_POOLS, + jwt: getJWTToken(USER_POOL_ID, 'user3', 'user3@test.com', ['editor']), + headers: {}, + sourceIp: '', + }; + const createPostInput = (owner?: string): AppSyncVTLContext => { + return { + arguments: { + input: { + id: '001', + name: 'sample', + owner, + }, + }, + }; + }; + + beforeEach(() => { + const authConfig: AppSyncAuthConfiguration = { + defaultAuthentication: { + authenticationType: 'AMAZON_COGNITO_USER_POOLS', + }, + additionalAuthenticationProviders: [], + }; + transformer = new GraphQLTransform({ + authConfig, + transformers: [new ModelTransformer(), new AuthTransformer()], + }); + vtlTemplate = new VelocityTemplateSimulator({ authConfig }); + }); + + test('explicit operations where create/delete restricted', () => { + const validSchema = ` + type Post @model @auth(rules: [ + { allow: owner, operations: [create, read] }, + { allow: groups, groups: ["admin"] }]) { + id: ID + name: String + owner: String + }`; + const out = transformer.transform(validSchema); + expect(out).toBeDefined(); + // load vtl templates + const createRequestTemplate = out.pipelineFunctions['Mutation.createPost.auth.1.req.vtl']; + const readRequestTemplate = out.pipelineFunctions['Query.listPosts.auth.1.req.vtl']; + const updateResponseTemplate = out.pipelineFunctions['Mutation.updatePost.auth.1.res.vtl']; + const deleteResponseTemplate = out.pipelineFunctions['Mutation.deletePost.auth.1.res.vtl']; + + // run create request as owner and admin + // even though they are not the owner it will still pass as they one making the request is in the admin group + const createRequestAsAdmin = vtlTemplate.render(createRequestTemplate, { + context: createPostInput('owner2'), + requestParameters: adminGroupRequest, + }); + expect(createRequestAsAdmin).toBeDefined(); + expect(createRequestAsAdmin.hadException).toEqual(false); + expect(createRequestAsAdmin.stash.hasAuth).toEqual(true); + // run the create request as owner should fail if the input is different the signed in owner + const createRequestAsOwner = vtlTemplate.render(createRequestTemplate, { + context: createPostInput('user2'), + requestParameters: ownerRequest, + }); + expect(createRequestAsOwner.hadException).toEqual(true); + expect(createRequestAsOwner.errors[0]).toEqual( + expect.objectContaining({ + errorType: 'Unauthorized', + message: expect.stringContaining('Unauthorized on $util.unauthorized()'), + }), + ); + // read request for admin shoud not contain the filter + const readRequestAsAdmin = vtlTemplate.render(readRequestTemplate, { context: {}, requestParameters: adminGroupRequest }); + expect(readRequestAsAdmin.stash.hasAuth).toEqual(true); + expect(readRequestAsAdmin.stash.authFilter).not.toBeDefined(); + const readRequestAsOwner = vtlTemplate.render(readRequestTemplate, { context: {}, requestParameters: ownerRequest }); + expect(readRequestAsOwner.stash.hasAuth).toEqual(true); + expect(readRequestAsOwner.stash.authFilter).toEqual( + expect.objectContaining({ + or: [{ owner: { eq: 'user1' } }], + }), + ); + const ddbResponseResult: AppSyncVTLContext = { result: { id: '001', title: 'sample', owner: 'user1' } }; + // update should pass for admin even if they are not the owner of the record + const updateResponseAsAdmin = vtlTemplate.render(updateResponseTemplate, { + context: ddbResponseResult, + requestParameters: adminGroupRequest, + }); + expect(updateResponseAsAdmin.hadException).toEqual(false); + expect(updateResponseAsAdmin.stash.hasAuth).toEqual(true); + // update should fail for owner even though they are the owner of the record + const updateResponseAsOwner = vtlTemplate.render(updateResponseTemplate, { + context: ddbResponseResult, + requestParameters: ownerRequest, + }); + expect(updateResponseAsOwner.hadException).toEqual(true); + // delete should pass for admin even if they are not the owner of the record + const deleteResponseAsAdmin = vtlTemplate.render(deleteResponseTemplate, { + context: ddbResponseResult, + requestParameters: adminGroupRequest, + }); + expect(deleteResponseAsAdmin.hadException).toEqual(false); + // delete should fail for owner even though they are the owner of the record + const deleteResponseAsOwner = vtlTemplate.render(deleteResponseTemplate, { + context: ddbResponseResult, + requestParameters: ownerRequest, + }); + expect(deleteResponseAsOwner.hadException).toEqual(true); + }); + + test('owner restricts create/read and group restricts read/update/delete', () => { + // NOTE: this means that you can only create a record for the same owner + // you can't create a record for other owners even if your in the editor group + const validSchema = ` + type Post @model @auth(rules: [ + { allow: owner, operations: [create, read] }, + { allow: groups, groups: ["editor"], operations: [read, update, delete] }]) { + id: ID + name: String + owner: String + } + `; + const out = transformer.transform(validSchema); + // load vtl templates + const createRequestTemplate = out.pipelineFunctions['Mutation.createPost.auth.1.req.vtl']; + const readRequestTemplate = out.pipelineFunctions['Query.listPosts.auth.1.req.vtl']; + const updateResponseTemplate = out.pipelineFunctions['Mutation.updatePost.auth.1.res.vtl']; + const deleteResponseTemplate = out.pipelineFunctions['Mutation.deletePost.auth.1.res.vtl']; + + // check that a editor member can't create a post under another owner + const createPostAsEditor = vtlTemplate.render(createRequestTemplate, { + context: createPostInput('user1'), + requestParameters: editorGroupRequest, + }); + expect(createPostAsEditor.hadException).toEqual(true); + // check that editor can read posts with no filter + const readPostsAsEditor = vtlTemplate.render(readRequestTemplate, { context: {}, requestParameters: editorGroupRequest }); + expect(readPostsAsEditor.hadException).toEqual(false); + expect(readPostsAsEditor.stash.authFilter).not.toBeDefined(); + // expect owner can read but with an authfilter + const readPostsAsOwner = vtlTemplate.render(readRequestTemplate, { context: {}, requestParameters: ownerRequest }); + expect(readPostsAsOwner.hadException).toEqual(false); + expect(readPostsAsOwner.stash.authFilter).toEqual( + expect.objectContaining({ + or: [{ owner: { eq: 'user1' } }], + }), + ); + // expect owner can't run update or delete + const updateResponseAsOwner = vtlTemplate.render(updateResponseTemplate, { + context: createPostInput('user1'), + requestParameters: ownerRequest, + }); + expect(updateResponseAsOwner.hadException).toEqual(true); + const deleteResponseAsOwner = vtlTemplate.render(deleteResponseTemplate, { + context: createPostInput('user1'), + requestParameters: ownerRequest, + }); + expect(deleteResponseAsOwner.hadException).toEqual(true); + // expect editor to be able to run update and delete + const updateResponseAsEditor = vtlTemplate.render(updateResponseTemplate, { + context: createPostInput('user1'), + requestParameters: editorGroupRequest, + }); + expect(updateResponseAsEditor.hadException).toEqual(false); + const deleteResponseAsEditor = vtlTemplate.render(deleteResponseTemplate, { + context: createPostInput('user1'), + requestParameters: editorGroupRequest, + }); + expect(deleteResponseAsEditor.hadException).toEqual(false); + }); +}); + +describe('@model field auth', () => { + let vtlTemplate: VelocityTemplateSimulator; + let transformer: GraphQLTransform; + const ownerRequest: AppSyncGraphQLExecutionContext = { + requestAuthorizationMode: AmplifyAppSyncSimulatorAuthenticationType.AMAZON_COGNITO_USER_POOLS, + jwt: getJWTToken(USER_POOL_ID, 'user1', 'user1@test.com'), + headers: {}, + sourceIp: '', + }; + const adminGroupRequest: AppSyncGraphQLExecutionContext = { + requestAuthorizationMode: AmplifyAppSyncSimulatorAuthenticationType.AMAZON_COGNITO_USER_POOLS, + jwt: getJWTToken(USER_POOL_ID, 'user2', 'user2@test.com', ['admin']), + headers: {}, + sourceIp: '', + }; + beforeEach(() => { + const authConfig: AppSyncAuthConfiguration = { + defaultAuthentication: { + authenticationType: 'AMAZON_COGNITO_USER_POOLS', + }, + additionalAuthenticationProviders: [], + }; + transformer = new GraphQLTransform({ + authConfig, + transformers: [new ModelTransformer(), new AuthTransformer()], + }); + vtlTemplate = new VelocityTemplateSimulator({ authConfig }); + }); + + test('object level group auth + field level owner auth', () => { + const validSchema = ` + type Student @model @auth(rules:[{ allow: groups, groups: ["admin"] }]) { + id: ID @auth(rules: [{ allow: groups, groups: ["admin"] }, { allow: owner, operations: [read, update] }]) + name: String + email: String! @auth(rules: [{ allow: groups, groups: ["admin"] }, { allow: owner, operations: [read, update] }]) + ssn: String + }`; + const out = transformer.transform(validSchema); + const updateResponseTemplate = out.pipelineFunctions['Mutation.updateStudent.auth.1.res.vtl']; + const updateContext: AppSyncVTLContext = { + arguments: { + input: { + id: '001', + email: 'myNewEmail', + name: 'newName', + }, + }, + result: { + id: '001', + email: 'user1@test.com', + name: 'samplename', + owner: 'user1', + }, + }; + const updateContextOwnerPass: AppSyncVTLContext = { + ...updateContext, + arguments: { + input: { + id: '001', + email: 'newEmail@user1.com', + }, + }, + }; + // update request should fail + const updateResponseAsOwnerFailed = vtlTemplate.render(updateResponseTemplate, { + context: updateContext, + requestParameters: ownerRequest, + }); + expect(updateResponseAsOwnerFailed.hadException).toEqual(true); + expect(updateResponseAsOwnerFailed.errors[0].extensions).toEqual( + expect.objectContaining({ + errorType: 'Unauthorized', + message: 'Unauthorized on [name]', + }), + ); + // update request should pass if the owner is only modifying the allowed fields + const updateResponseAsOwner = vtlTemplate.render(updateResponseTemplate, { + context: updateContextOwnerPass, + requestParameters: ownerRequest, + }); + expect(updateResponseAsOwner.hadException).toEqual(false); + // update request should pass for admin user + const updateResponseAsAdmin = vtlTemplate.render(updateResponseTemplate, { + context: updateContext, + requestParameters: adminGroupRequest, + }); + expect(updateResponseAsAdmin.hadException).toEqual(false); + + // field read checks + const readFieldContext: AppSyncVTLContext = { + source: { + id: '001', + owner: 'user1', + name: 'nameSample', + ssn: '000111111', + }, + }; + ['name', 'ssn'].forEach(field => { + // expect owner to get denied on these fields + const readFieldTemplate = out.pipelineFunctions[`Student.${field}.req.vtl`]; + const ownerReadResponse = vtlTemplate.render(readFieldTemplate, { context: readFieldContext, requestParameters: ownerRequest }); + expect(ownerReadResponse.hadException).toEqual(true); + // expect admin to be allowed + const adminReadResponse = vtlTemplate.render(readFieldTemplate, { context: readFieldContext, requestParameters: adminGroupRequest }); + expect(adminReadResponse.hadException).toEqual(false); + }); + + ['id', 'email'].forEach(field => { + // since the only two roles have access to these fields there are no field resolvers + expect(out.pipelineFunctions?.[`Student.${field}.req.vtl`]).not.toBeDefined(); + }); + }); +}); diff --git a/packages/amplify-util-mock/src/__tests__/velocity/multi-auth.test.ts b/packages/amplify-util-mock/src/__tests__/velocity/multi-auth.test.ts new file mode 100644 index 00000000000..6a4075ad61e --- /dev/null +++ b/packages/amplify-util-mock/src/__tests__/velocity/multi-auth.test.ts @@ -0,0 +1,113 @@ +import { AuthTransformer } from '@aws-amplify/graphql-auth-transformer'; +import { ModelTransformer } from '@aws-amplify/graphql-model-transformer'; +import { GraphQLTransform } from '@aws-amplify/graphql-transformer-core'; +import { AppSyncAuthConfiguration } from '@aws-amplify/graphql-transformer-interfaces'; +import { AmplifyAppSyncSimulatorAuthenticationType, AppSyncGraphQLExecutionContext } from 'amplify-appsync-simulator'; +import { VelocityTemplateSimulator, AppSyncVTLContext, getGenericToken } from '../../velocity'; + +// oidc needs claim values to know where to check in the token otherwise it will use cognito defaults precendence order below +// - owner: 'username' -> 'cognito:username' -> default to null +// - group: 'cognito:groups' -> default to null or empty list +describe('@model + @auth with oidc provider', () => { + let vtlTemplate: VelocityTemplateSimulator; + let transformer: GraphQLTransform; + const subIdUser: AppSyncGraphQLExecutionContext = { + requestAuthorizationMode: AmplifyAppSyncSimulatorAuthenticationType.OPENID_CONNECT, + jwt: getGenericToken('randomIdUser', 'random@user.com'), + headers: {}, + }; + const editorGroupMember: AppSyncGraphQLExecutionContext = { + requestAuthorizationMode: AmplifyAppSyncSimulatorAuthenticationType.OPENID_CONNECT, + jwt: getGenericToken('editorUser', 'editor0@user.com', ['Editor']), + headers: {}, + }; + + beforeEach(() => { + const authConfig: AppSyncAuthConfiguration = { + defaultAuthentication: { + authenticationType: 'OPENID_CONNECT', + openIDConnectConfig: { + name: 'myOIDCProvider', + issuerUrl: 'https://some-oidc-provider/auth', + clientId: 'my-sample-client-id', + }, + }, + additionalAuthenticationProviders: [], + }; + transformer = new GraphQLTransform({ + authConfig, + transformers: [new ModelTransformer(), new AuthTransformer()], + }); + vtlTemplate = new VelocityTemplateSimulator({ authConfig }); + }); + + test('oidc default', () => { + const validSchema = ` + # owner authorization with provider override + type Profile @model @auth(rules: [{ allow: owner, provider: oidc, identityClaim: "sub" }]) { + id: ID! + displayName: String! + }`; + + const createProfileInput: AppSyncVTLContext = { + arguments: { + input: { + id: '001', + displayName: 'FooBar', + }, + }, + }; + + const out = transformer.transform(validSchema); + const createRequestTemplate = out.pipelineFunctions['Mutation.createProfile.auth.1.req.vtl']; + const createRequestAsSubOwner = vtlTemplate.render(createRequestTemplate, { + context: createProfileInput, + requestParameters: subIdUser, + }); + expect(createRequestAsSubOwner.hadException).toEqual(false); + expect(createRequestAsSubOwner.args.input).toEqual( + expect.objectContaining({ + id: '001', + displayName: 'FooBar', + owner: expect.any(String), // since its a uuid we just need to know the owner was added + }), + ); + }); + + test('oidc static groups', () => { + const validSchema = ` + type Comment @model @auth(rules: [{ allow: groups, groups: ["Editor"], groupClaim: "groups", provider: oidc }]) { + id: ID + content: String + }`; + const createCommentInput: AppSyncVTLContext = { + arguments: { + input: { + id: '001', + content: 'Foobar', + }, + }, + }; + const getCommentArgs: AppSyncVTLContext = { + arguments: { + id: '001', + }, + }; + const out = transformer.transform(validSchema); + const createRequestTemplate = out.pipelineFunctions['Mutation.createComment.auth.1.req.vtl']; + const getRequestTemplate = out.pipelineFunctions['Query.getComment.auth.1.req.vtl']; + // mutations + const createRequestAsEditor = vtlTemplate.render(createRequestTemplate, { + context: createCommentInput, + requestParameters: editorGroupMember, + }); + expect(createRequestAsEditor.hadException).toEqual(false); + const createRequestAsUser = vtlTemplate.render(createRequestTemplate, { context: createCommentInput, requestParameters: subIdUser }); + expect(createRequestAsUser.hadException).toEqual(true); + // queries + const getRequestAsEditor = vtlTemplate.render(getRequestTemplate, { context: getCommentArgs, requestParameters: editorGroupMember }); + expect(getRequestAsEditor.hadException).toEqual(false); + const getRequestAsOwner = vtlTemplate.render(getRequestTemplate, { context: getCommentArgs, requestParameters: subIdUser }); + expect(getRequestAsOwner.hadException).toEqual(true); + }); +}); diff --git a/packages/amplify-util-mock/src/velocity/index.ts b/packages/amplify-util-mock/src/velocity/index.ts index 1074a9fce2d..d8648312165 100644 --- a/packages/amplify-util-mock/src/velocity/index.ts +++ b/packages/amplify-util-mock/src/velocity/index.ts @@ -16,17 +16,22 @@ const DEFAULT_SCHEMA = ` }`; export interface VelocityTemplateSimulatorOptions { - template: string; authConfig: AppSyncAuthConfiguration; } export type AppSyncVTLContext = Partial; +export type AppSyncVTLPayload = { + context: Partial; + requestParameters: AppSyncGraphQLExecutionContext; + info?: Partial; +}; + export class VelocityTemplateSimulator { - velocityTemplate: VelocityTemplate; + private gqlSimulator: AmplifyAppSyncSimulator; constructor(opts: VelocityTemplateSimulatorOptions) { - const gqlSimulator = new AmplifyAppSyncSimulator(); - gqlSimulator.init({ + this.gqlSimulator = new AmplifyAppSyncSimulator(); + this.gqlSimulator.init({ schema: { content: DEFAULT_SCHEMA, }, @@ -37,12 +42,12 @@ export class VelocityTemplateSimulator { .additionalAuthenticationProviders as AmplifyAppSyncAuthenticationProviderConfig[], }, }); - this.velocityTemplate = new VelocityTemplate({ content: opts.template }, gqlSimulator); } - render(context: AppSyncVTLContext, requestParameters: AppSyncGraphQLExecutionContext, info: Partial = {}) { - const ctxParameters: AppSyncVTLRenderContext = { source: {}, arguments: { input: {} }, stash: {}, ...context }; - const vtlInfo: any = { fieldNodes: [], fragments: {}, path: { key: '' }, ...info }; - return this.velocityTemplate.render(ctxParameters, requestParameters, vtlInfo); + render(template: string, payload: AppSyncVTLPayload) { + const ctxParameters: AppSyncVTLRenderContext = { source: {}, arguments: { input: {} }, stash: {}, ...payload.context }; + const vtlInfo: any = { fieldNodes: [], fragments: {}, path: { key: '' }, ...(payload.info ?? {}) }; + const vtlTemplate = new VelocityTemplate({ content: template }, this.gqlSimulator); + return vtlTemplate.render(ctxParameters, payload.requestParameters, vtlInfo); } } @@ -63,8 +68,24 @@ export const getJWTToken = ( token_use: tokenType, auth_time: Math.floor(Date.now() / 1000), 'cognito:username': username, - 'cognitio:groups': groups, + 'cognito:groups': groups, email, }; return token; }; + +export const getGenericToken = (username: string, email: string, groups: string[] = [], tokenType: 'id' | 'access' = 'id'): JWTToken => { + return { + iss: 'https://some-oidc-provider/auth', + sub: v4(), + aud: '75pk49boud2olipfda0ke3snic', + exp: Math.floor(Date.now() / 1000) + 10000, + iat: Math.floor(Date.now() / 1000), + event_id: v4(), + token_use: tokenType, + auth_time: Math.floor(Date.now() / 1000), + username, + email, + groups, + }; +};