From 109e5af203424fb7ed87f09342d425c138fd8633 Mon Sep 17 00:00:00 2001 From: SwaySway <7465495+SwaySway@users.noreply.github.com> Date: Thu, 30 Sep 2021 21:39:54 -0700 Subject: [PATCH] fix: @auth fix relational auth, authv2 e2e with utils and fixes --- .circleci/config.yml | 453 ++++++- .../src/accesscontrol/acm.ts | 4 + .../src/graphql-auth-transformer.ts | 156 ++- .../src/resolvers/field.ts | 25 + .../src/resolvers/index.ts | 4 +- .../src/resolvers/mutation.delete.ts | 2 +- .../src/resolvers/mutation.update.ts | 4 +- .../src/resolvers/query.ts | 247 +++- .../src/resolvers/subscriptions.ts | 11 +- .../src/utils/constants.ts | 2 + .../src/utils/definitions.ts | 3 +- .../src/utils/iam.ts | 2 +- .../src/utils/index.ts | 51 +- .../src/utils/schema.ts | 137 +- ...-graphql-function-transformer.test.ts.snap | 4 +- .../src/graphql-function-transformer.ts | 26 +- .../src/schema.ts | 6 +- ...-graphql-has-many-transformer.test.ts.snap | 85 +- ...phql-many-to-many-transformer.test.ts.snap | 20 +- .../src/resolvers.ts | 82 +- ...aphql-searchable-transformer.tests.ts.snap | 8 +- .../src/generate-resolver-vtl.ts | 16 +- .../src/IAMHelper.ts | 42 + .../src/__tests__/IndexWithAuthV2.e2e.test.ts | 292 +++++ .../MultiAuthV2Transformer.e2e.test.ts | 1064 ++++++++++++++++ .../NonModelAuthV2Function.e2e.test.ts | 258 ++++ .../PerFieldAuthV2Transformer.e2e.test.ts | 624 +++++++++ .../RelationalWithAuthV2.e2e.test.ts | 510 ++++++++ .../SearchableWithAuthV2.e2e.test.ts | 610 +++++++++ .../SubscriptionsWithAuthV2.e2e.test.ts | 1110 +++++++++++++++++ .../src/cognitoUtils.ts | 41 +- .../src/deployNestedStacks.ts | 9 +- 32 files changed, 5642 insertions(+), 266 deletions(-) create mode 100644 packages/graphql-transformers-e2e-tests/src/__tests__/IndexWithAuthV2.e2e.test.ts create mode 100644 packages/graphql-transformers-e2e-tests/src/__tests__/MultiAuthV2Transformer.e2e.test.ts create mode 100644 packages/graphql-transformers-e2e-tests/src/__tests__/NonModelAuthV2Function.e2e.test.ts create mode 100644 packages/graphql-transformers-e2e-tests/src/__tests__/PerFieldAuthV2Transformer.e2e.test.ts create mode 100644 packages/graphql-transformers-e2e-tests/src/__tests__/RelationalWithAuthV2.e2e.test.ts create mode 100644 packages/graphql-transformers-e2e-tests/src/__tests__/SearchableWithAuthV2.e2e.test.ts create mode 100644 packages/graphql-transformers-e2e-tests/src/__tests__/SubscriptionsWithAuthV2.e2e.test.ts diff --git a/.circleci/config.yml b/.circleci/config.yml index 7e043b9e570..39af72a7c41 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -9758,6 +9758,36 @@ jobs: AMPLIFY_PATH: /home/circleci/repo/packages/amplify-cli/bin/amplify TEST_SUITE: src/__tests__/IndexTransformer.e2e.test.ts CLI_REGION: us-east-2 + IndexWithAuthV2-e2e-graphql_e2e_tests: + working_directory: ~/repo + parameters: + os: + type: executor + default: linux + executor: << parameters.os >> + steps: + - attach_workspace: + at: ./ + - restore_cache: + key: >- + amplify-cli-yarn-deps-{{ .Branch }}-{{ checksum "yarn.lock" }}-{{ + arch }} + - run: + name: Run GraphQL end-to-end tests + command: | + source .circleci/local_publish_helpers.sh + cd packages/graphql-transformers-e2e-tests/ + retry yarn e2e --maxWorkers=3 $TEST_SUITE + environment: + AMPLIFY_CLI_DISABLE_LOGGING: 'true' + no_output_timeout: 90m + - store_test_results: + path: packages/graphql-transformers-e2e-tests/ + environment: + AMPLIFY_DIR: /home/circleci/repo/packages/amplify-cli/bin + AMPLIFY_PATH: /home/circleci/repo/packages/amplify-cli/bin/amplify + TEST_SUITE: src/__tests__/IndexWithAuthV2.e2e.test.ts + CLI_REGION: us-west-2 KeyTransformer-e2e-graphql_e2e_tests: working_directory: ~/repo parameters: @@ -9787,7 +9817,7 @@ jobs: AMPLIFY_DIR: /home/circleci/repo/packages/amplify-cli/bin AMPLIFY_PATH: /home/circleci/repo/packages/amplify-cli/bin/amplify TEST_SUITE: src/__tests__/KeyTransformer.e2e.test.ts - CLI_REGION: us-west-2 + CLI_REGION: eu-west-2 KeyTransformerLocal-e2e-graphql_e2e_tests: working_directory: ~/repo parameters: @@ -9817,7 +9847,7 @@ jobs: AMPLIFY_DIR: /home/circleci/repo/packages/amplify-cli/bin AMPLIFY_PATH: /home/circleci/repo/packages/amplify-cli/bin/amplify TEST_SUITE: src/__tests__/KeyTransformerLocal.e2e.test.ts - CLI_REGION: eu-west-2 + CLI_REGION: eu-central-1 KeyWithAuth-e2e-graphql_e2e_tests: working_directory: ~/repo parameters: @@ -9847,7 +9877,7 @@ jobs: AMPLIFY_DIR: /home/circleci/repo/packages/amplify-cli/bin AMPLIFY_PATH: /home/circleci/repo/packages/amplify-cli/bin/amplify TEST_SUITE: src/__tests__/KeyWithAuth.e2e.test.ts - CLI_REGION: eu-central-1 + CLI_REGION: ap-northeast-1 ModelAuthTransformer-e2e-graphql_e2e_tests: working_directory: ~/repo parameters: @@ -9877,7 +9907,7 @@ jobs: AMPLIFY_DIR: /home/circleci/repo/packages/amplify-cli/bin AMPLIFY_PATH: /home/circleci/repo/packages/amplify-cli/bin/amplify TEST_SUITE: src/__tests__/ModelAuthTransformer.e2e.test.ts - CLI_REGION: ap-northeast-1 + CLI_REGION: ap-southeast-1 ModelConnectionTransformer-e2e-graphql_e2e_tests: working_directory: ~/repo parameters: @@ -9907,7 +9937,7 @@ jobs: AMPLIFY_DIR: /home/circleci/repo/packages/amplify-cli/bin AMPLIFY_PATH: /home/circleci/repo/packages/amplify-cli/bin/amplify TEST_SUITE: src/__tests__/ModelConnectionTransformer.e2e.test.ts - CLI_REGION: ap-southeast-1 + CLI_REGION: ap-southeast-2 ModelConnectionWithKeyTransformer-e2e-graphql_e2e_tests: working_directory: ~/repo parameters: @@ -9937,7 +9967,7 @@ jobs: AMPLIFY_DIR: /home/circleci/repo/packages/amplify-cli/bin AMPLIFY_PATH: /home/circleci/repo/packages/amplify-cli/bin/amplify TEST_SUITE: src/__tests__/ModelConnectionWithKeyTransformer.e2e.test.ts - CLI_REGION: ap-southeast-2 + CLI_REGION: us-east-2 ModelTransformer-e2e-graphql_e2e_tests: working_directory: ~/repo parameters: @@ -9967,7 +9997,7 @@ jobs: AMPLIFY_DIR: /home/circleci/repo/packages/amplify-cli/bin AMPLIFY_PATH: /home/circleci/repo/packages/amplify-cli/bin/amplify TEST_SUITE: src/__tests__/ModelTransformer.e2e.test.ts - CLI_REGION: us-east-2 + CLI_REGION: us-west-2 MultiAuthModelAuthTransformer-e2e-graphql_e2e_tests: working_directory: ~/repo parameters: @@ -9997,7 +10027,37 @@ jobs: AMPLIFY_DIR: /home/circleci/repo/packages/amplify-cli/bin AMPLIFY_PATH: /home/circleci/repo/packages/amplify-cli/bin/amplify TEST_SUITE: src/__tests__/MultiAuthModelAuthTransformer.e2e.test.ts - CLI_REGION: us-west-2 + CLI_REGION: eu-west-2 + MultiAuthV2Transformer-e2e-graphql_e2e_tests: + working_directory: ~/repo + parameters: + os: + type: executor + default: linux + executor: << parameters.os >> + steps: + - attach_workspace: + at: ./ + - restore_cache: + key: >- + amplify-cli-yarn-deps-{{ .Branch }}-{{ checksum "yarn.lock" }}-{{ + arch }} + - run: + name: Run GraphQL end-to-end tests + command: | + source .circleci/local_publish_helpers.sh + cd packages/graphql-transformers-e2e-tests/ + retry yarn e2e --maxWorkers=3 $TEST_SUITE + environment: + AMPLIFY_CLI_DISABLE_LOGGING: 'true' + no_output_timeout: 90m + - store_test_results: + path: packages/graphql-transformers-e2e-tests/ + environment: + AMPLIFY_DIR: /home/circleci/repo/packages/amplify-cli/bin + AMPLIFY_PATH: /home/circleci/repo/packages/amplify-cli/bin/amplify + TEST_SUITE: src/__tests__/MultiAuthV2Transformer.e2e.test.ts + CLI_REGION: eu-central-1 MutationCondition-e2e-graphql_e2e_tests: working_directory: ~/repo parameters: @@ -10027,7 +10087,7 @@ jobs: AMPLIFY_DIR: /home/circleci/repo/packages/amplify-cli/bin AMPLIFY_PATH: /home/circleci/repo/packages/amplify-cli/bin/amplify TEST_SUITE: src/__tests__/MutationCondition.e2e.test.ts - CLI_REGION: eu-west-2 + CLI_REGION: ap-northeast-1 NestedStacksTest-e2e-graphql_e2e_tests: working_directory: ~/repo parameters: @@ -10057,7 +10117,7 @@ jobs: AMPLIFY_DIR: /home/circleci/repo/packages/amplify-cli/bin AMPLIFY_PATH: /home/circleci/repo/packages/amplify-cli/bin/amplify TEST_SUITE: src/__tests__/NestedStacksTest.e2e.test.ts - CLI_REGION: eu-central-1 + CLI_REGION: ap-southeast-1 NewConnectionTransformer-e2e-graphql_e2e_tests: working_directory: ~/repo parameters: @@ -10087,7 +10147,7 @@ jobs: AMPLIFY_DIR: /home/circleci/repo/packages/amplify-cli/bin AMPLIFY_PATH: /home/circleci/repo/packages/amplify-cli/bin/amplify TEST_SUITE: src/__tests__/NewConnectionTransformer.e2e.test.ts - CLI_REGION: ap-northeast-1 + CLI_REGION: ap-southeast-2 NewConnectionWithAuth-e2e-graphql_e2e_tests: working_directory: ~/repo parameters: @@ -10117,7 +10177,7 @@ jobs: AMPLIFY_DIR: /home/circleci/repo/packages/amplify-cli/bin AMPLIFY_PATH: /home/circleci/repo/packages/amplify-cli/bin/amplify TEST_SUITE: src/__tests__/NewConnectionWithAuth.e2e.test.ts - CLI_REGION: ap-southeast-1 + CLI_REGION: us-east-2 NoneEnvFunctionTransformer-e2e-graphql_e2e_tests: working_directory: ~/repo parameters: @@ -10147,7 +10207,7 @@ jobs: AMPLIFY_DIR: /home/circleci/repo/packages/amplify-cli/bin AMPLIFY_PATH: /home/circleci/repo/packages/amplify-cli/bin/amplify TEST_SUITE: src/__tests__/NoneEnvFunctionTransformer.e2e.test.ts - CLI_REGION: ap-southeast-2 + CLI_REGION: us-west-2 NonModelAuthFunction-e2e-graphql_e2e_tests: working_directory: ~/repo parameters: @@ -10177,7 +10237,37 @@ jobs: AMPLIFY_DIR: /home/circleci/repo/packages/amplify-cli/bin AMPLIFY_PATH: /home/circleci/repo/packages/amplify-cli/bin/amplify TEST_SUITE: src/__tests__/NonModelAuthFunction.e2e.test.ts - CLI_REGION: us-east-2 + CLI_REGION: eu-west-2 + NonModelAuthV2Function-e2e-graphql_e2e_tests: + working_directory: ~/repo + parameters: + os: + type: executor + default: linux + executor: << parameters.os >> + steps: + - attach_workspace: + at: ./ + - restore_cache: + key: >- + amplify-cli-yarn-deps-{{ .Branch }}-{{ checksum "yarn.lock" }}-{{ + arch }} + - run: + name: Run GraphQL end-to-end tests + command: | + source .circleci/local_publish_helpers.sh + cd packages/graphql-transformers-e2e-tests/ + retry yarn e2e --maxWorkers=3 $TEST_SUITE + environment: + AMPLIFY_CLI_DISABLE_LOGGING: 'true' + no_output_timeout: 90m + - store_test_results: + path: packages/graphql-transformers-e2e-tests/ + environment: + AMPLIFY_DIR: /home/circleci/repo/packages/amplify-cli/bin + AMPLIFY_PATH: /home/circleci/repo/packages/amplify-cli/bin/amplify + TEST_SUITE: src/__tests__/NonModelAuthV2Function.e2e.test.ts + CLI_REGION: eu-central-1 PerFieldAuthTests-e2e-graphql_e2e_tests: working_directory: ~/repo parameters: @@ -10207,7 +10297,37 @@ jobs: AMPLIFY_DIR: /home/circleci/repo/packages/amplify-cli/bin AMPLIFY_PATH: /home/circleci/repo/packages/amplify-cli/bin/amplify TEST_SUITE: src/__tests__/PerFieldAuthTests.e2e.test.ts - CLI_REGION: us-west-2 + CLI_REGION: ap-northeast-1 + PerFieldAuthV2Transformer-e2e-graphql_e2e_tests: + working_directory: ~/repo + parameters: + os: + type: executor + default: linux + executor: << parameters.os >> + steps: + - attach_workspace: + at: ./ + - restore_cache: + key: >- + amplify-cli-yarn-deps-{{ .Branch }}-{{ checksum "yarn.lock" }}-{{ + arch }} + - run: + name: Run GraphQL end-to-end tests + command: | + source .circleci/local_publish_helpers.sh + cd packages/graphql-transformers-e2e-tests/ + retry yarn e2e --maxWorkers=3 $TEST_SUITE + environment: + AMPLIFY_CLI_DISABLE_LOGGING: 'true' + no_output_timeout: 90m + - store_test_results: + path: packages/graphql-transformers-e2e-tests/ + environment: + AMPLIFY_DIR: /home/circleci/repo/packages/amplify-cli/bin + AMPLIFY_PATH: /home/circleci/repo/packages/amplify-cli/bin/amplify + TEST_SUITE: src/__tests__/PerFieldAuthV2Transformer.e2e.test.ts + CLI_REGION: ap-southeast-1 PredictionsTransformerTests-e2e-graphql_e2e_tests: working_directory: ~/repo parameters: @@ -10237,7 +10357,7 @@ jobs: AMPLIFY_DIR: /home/circleci/repo/packages/amplify-cli/bin AMPLIFY_PATH: /home/circleci/repo/packages/amplify-cli/bin/amplify TEST_SUITE: src/__tests__/PredictionsTransformerTests.e2e.test.ts - CLI_REGION: eu-west-2 + CLI_REGION: ap-southeast-2 RelationalTransformers-e2e-graphql_e2e_tests: working_directory: ~/repo parameters: @@ -10267,7 +10387,37 @@ jobs: AMPLIFY_DIR: /home/circleci/repo/packages/amplify-cli/bin AMPLIFY_PATH: /home/circleci/repo/packages/amplify-cli/bin/amplify TEST_SUITE: src/__tests__/RelationalTransformers.e2e.test.ts - CLI_REGION: eu-central-1 + CLI_REGION: us-east-2 + RelationalWithAuthV2-e2e-graphql_e2e_tests: + working_directory: ~/repo + parameters: + os: + type: executor + default: linux + executor: << parameters.os >> + steps: + - attach_workspace: + at: ./ + - restore_cache: + key: >- + amplify-cli-yarn-deps-{{ .Branch }}-{{ checksum "yarn.lock" }}-{{ + arch }} + - run: + name: Run GraphQL end-to-end tests + command: | + source .circleci/local_publish_helpers.sh + cd packages/graphql-transformers-e2e-tests/ + retry yarn e2e --maxWorkers=3 $TEST_SUITE + environment: + AMPLIFY_CLI_DISABLE_LOGGING: 'true' + no_output_timeout: 90m + - store_test_results: + path: packages/graphql-transformers-e2e-tests/ + environment: + AMPLIFY_DIR: /home/circleci/repo/packages/amplify-cli/bin + AMPLIFY_PATH: /home/circleci/repo/packages/amplify-cli/bin/amplify + TEST_SUITE: src/__tests__/RelationalWithAuthV2.e2e.test.ts + CLI_REGION: us-west-2 SearchableModelTransformer-e2e-graphql_e2e_tests: working_directory: ~/repo parameters: @@ -10297,7 +10447,7 @@ jobs: AMPLIFY_DIR: /home/circleci/repo/packages/amplify-cli/bin AMPLIFY_PATH: /home/circleci/repo/packages/amplify-cli/bin/amplify TEST_SUITE: src/__tests__/SearchableModelTransformer.e2e.test.ts - CLI_REGION: ap-northeast-1 + CLI_REGION: eu-west-2 SearchableModelTransformerV2-e2e-graphql_e2e_tests: working_directory: ~/repo parameters: @@ -10327,7 +10477,7 @@ jobs: AMPLIFY_DIR: /home/circleci/repo/packages/amplify-cli/bin AMPLIFY_PATH: /home/circleci/repo/packages/amplify-cli/bin/amplify TEST_SUITE: src/__tests__/SearchableModelTransformerV2.e2e.test.ts - CLI_REGION: ap-southeast-1 + CLI_REGION: eu-central-1 SearchableWithAuthTests-e2e-graphql_e2e_tests: working_directory: ~/repo parameters: @@ -10357,7 +10507,37 @@ jobs: AMPLIFY_DIR: /home/circleci/repo/packages/amplify-cli/bin AMPLIFY_PATH: /home/circleci/repo/packages/amplify-cli/bin/amplify TEST_SUITE: src/__tests__/SearchableWithAuthTests.e2e.test.ts - CLI_REGION: ap-southeast-2 + CLI_REGION: ap-northeast-1 + SearchableWithAuthV2-e2e-graphql_e2e_tests: + working_directory: ~/repo + parameters: + os: + type: executor + default: linux + executor: << parameters.os >> + steps: + - attach_workspace: + at: ./ + - restore_cache: + key: >- + amplify-cli-yarn-deps-{{ .Branch }}-{{ checksum "yarn.lock" }}-{{ + arch }} + - run: + name: Run GraphQL end-to-end tests + command: | + source .circleci/local_publish_helpers.sh + cd packages/graphql-transformers-e2e-tests/ + retry yarn e2e --maxWorkers=3 $TEST_SUITE + environment: + AMPLIFY_CLI_DISABLE_LOGGING: 'true' + no_output_timeout: 90m + - store_test_results: + path: packages/graphql-transformers-e2e-tests/ + environment: + AMPLIFY_DIR: /home/circleci/repo/packages/amplify-cli/bin + AMPLIFY_PATH: /home/circleci/repo/packages/amplify-cli/bin/amplify + TEST_SUITE: src/__tests__/SearchableWithAuthV2.e2e.test.ts + CLI_REGION: ap-southeast-1 SubscriptionsWithAuthTest-e2e-graphql_e2e_tests: working_directory: ~/repo parameters: @@ -10387,6 +10567,36 @@ jobs: AMPLIFY_DIR: /home/circleci/repo/packages/amplify-cli/bin AMPLIFY_PATH: /home/circleci/repo/packages/amplify-cli/bin/amplify TEST_SUITE: src/__tests__/SubscriptionsWithAuthTest.e2e.test.ts + CLI_REGION: ap-southeast-2 + SubscriptionsWithAuthV2-e2e-graphql_e2e_tests: + working_directory: ~/repo + parameters: + os: + type: executor + default: linux + executor: << parameters.os >> + steps: + - attach_workspace: + at: ./ + - restore_cache: + key: >- + amplify-cli-yarn-deps-{{ .Branch }}-{{ checksum "yarn.lock" }}-{{ + arch }} + - run: + name: Run GraphQL end-to-end tests + command: | + source .circleci/local_publish_helpers.sh + cd packages/graphql-transformers-e2e-tests/ + retry yarn e2e --maxWorkers=3 $TEST_SUITE + environment: + AMPLIFY_CLI_DISABLE_LOGGING: 'true' + no_output_timeout: 90m + - store_test_results: + path: packages/graphql-transformers-e2e-tests/ + environment: + AMPLIFY_DIR: /home/circleci/repo/packages/amplify-cli/bin + AMPLIFY_PATH: /home/circleci/repo/packages/amplify-cli/bin/amplify + TEST_SUITE: src/__tests__/SubscriptionsWithAuthV2.e2e.test.ts CLI_REGION: us-east-2 TestComplexStackMappingsLocal-e2e-graphql_e2e_tests: working_directory: ~/repo @@ -11824,35 +12034,42 @@ workflows: - api_5-amplify_e2e_tests_pkg - AuthV2Transformer-e2e-graphql_e2e_tests - IndexTransformer-e2e-graphql_e2e_tests - - ModelTransformer-e2e-graphql_e2e_tests - - NonModelAuthFunction-e2e-graphql_e2e_tests - - SubscriptionsWithAuthTest-e2e-graphql_e2e_tests + - ModelConnectionWithKeyTransformer-e2e-graphql_e2e_tests + - NewConnectionWithAuth-e2e-graphql_e2e_tests + - RelationalTransformers-e2e-graphql_e2e_tests + - SubscriptionsWithAuthV2-e2e-graphql_e2e_tests - ConnectionsWithAuthTests-e2e-graphql_e2e_tests - - KeyTransformer-e2e-graphql_e2e_tests - - MultiAuthModelAuthTransformer-e2e-graphql_e2e_tests - - PerFieldAuthTests-e2e-graphql_e2e_tests + - IndexWithAuthV2-e2e-graphql_e2e_tests + - ModelTransformer-e2e-graphql_e2e_tests + - NoneEnvFunctionTransformer-e2e-graphql_e2e_tests + - RelationalWithAuthV2-e2e-graphql_e2e_tests - TestComplexStackMappingsLocal-e2e-graphql_e2e_tests - CustomRoots-e2e-graphql_e2e_tests - - KeyTransformerLocal-e2e-graphql_e2e_tests - - MutationCondition-e2e-graphql_e2e_tests - - PredictionsTransformerTests-e2e-graphql_e2e_tests + - KeyTransformer-e2e-graphql_e2e_tests + - MultiAuthModelAuthTransformer-e2e-graphql_e2e_tests + - NonModelAuthFunction-e2e-graphql_e2e_tests + - SearchableModelTransformer-e2e-graphql_e2e_tests - VersionedModelTransformer-e2e-graphql_e2e_tests - DefaultValueTransformer-e2e-graphql_e2e_tests - - KeyWithAuth-e2e-graphql_e2e_tests - - NestedStacksTest-e2e-graphql_e2e_tests - - RelationalTransformers-e2e-graphql_e2e_tests + - KeyTransformerLocal-e2e-graphql_e2e_tests + - MultiAuthV2Transformer-e2e-graphql_e2e_tests + - NonModelAuthV2Function-e2e-graphql_e2e_tests + - SearchableModelTransformerV2-e2e-graphql_e2e_tests - DynamoDBModelTransformer-e2e-graphql_e2e_tests - - ModelAuthTransformer-e2e-graphql_e2e_tests - - NewConnectionTransformer-e2e-graphql_e2e_tests - - SearchableModelTransformer-e2e-graphql_e2e_tests + - KeyWithAuth-e2e-graphql_e2e_tests + - MutationCondition-e2e-graphql_e2e_tests + - PerFieldAuthTests-e2e-graphql_e2e_tests + - SearchableWithAuthTests-e2e-graphql_e2e_tests - FunctionTransformerTests-e2e-graphql_e2e_tests - - ModelConnectionTransformer-e2e-graphql_e2e_tests - - NewConnectionWithAuth-e2e-graphql_e2e_tests - - SearchableModelTransformerV2-e2e-graphql_e2e_tests + - ModelAuthTransformer-e2e-graphql_e2e_tests + - NestedStacksTest-e2e-graphql_e2e_tests + - PerFieldAuthV2Transformer-e2e-graphql_e2e_tests + - SearchableWithAuthV2-e2e-graphql_e2e_tests - HttpTransformer-e2e-graphql_e2e_tests - - ModelConnectionWithKeyTransformer-e2e-graphql_e2e_tests - - NoneEnvFunctionTransformer-e2e-graphql_e2e_tests - - SearchableWithAuthTests-e2e-graphql_e2e_tests + - ModelConnectionTransformer-e2e-graphql_e2e_tests + - NewConnectionTransformer-e2e-graphql_e2e_tests + - PredictionsTransformerTests-e2e-graphql_e2e_tests + - SubscriptionsWithAuthTest-e2e-graphql_e2e_tests - >- migration_tests-auth-deployment-migration-auth-deployment-secrets-amplify_migration_tests_v4 - update_tests-function_migration_update-amplify_migration_tests_v4 @@ -15500,7 +15717,7 @@ workflows: parameters: os: - linux - - ModelTransformer-e2e-graphql_e2e_tests: + - ModelConnectionWithKeyTransformer-e2e-graphql_e2e_tests: context: - amplify-ecr-image-pull - e2e-test-context @@ -15516,7 +15733,7 @@ workflows: parameters: os: - linux - - NonModelAuthFunction-e2e-graphql_e2e_tests: + - NewConnectionWithAuth-e2e-graphql_e2e_tests: context: - amplify-ecr-image-pull - e2e-test-context @@ -15532,7 +15749,23 @@ workflows: parameters: os: - linux - - SubscriptionsWithAuthTest-e2e-graphql_e2e_tests: + - RelationalTransformers-e2e-graphql_e2e_tests: + context: + - amplify-ecr-image-pull + - e2e-test-context + filters: + branches: + only: + - master + - /tagged-release\/.*/ + - /run-e2e\/.*/ + requires: + - build + matrix: + parameters: + os: + - linux + - SubscriptionsWithAuthV2-e2e-graphql_e2e_tests: context: - amplify-ecr-image-pull - e2e-test-context @@ -15564,7 +15797,7 @@ workflows: parameters: os: - linux - - KeyTransformer-e2e-graphql_e2e_tests: + - IndexWithAuthV2-e2e-graphql_e2e_tests: context: - amplify-ecr-image-pull - e2e-test-context @@ -15580,7 +15813,7 @@ workflows: parameters: os: - linux - - MultiAuthModelAuthTransformer-e2e-graphql_e2e_tests: + - ModelTransformer-e2e-graphql_e2e_tests: context: - amplify-ecr-image-pull - e2e-test-context @@ -15596,7 +15829,23 @@ workflows: parameters: os: - linux - - PerFieldAuthTests-e2e-graphql_e2e_tests: + - NoneEnvFunctionTransformer-e2e-graphql_e2e_tests: + context: + - amplify-ecr-image-pull + - e2e-test-context + filters: + branches: + only: + - master + - /tagged-release\/.*/ + - /run-e2e\/.*/ + requires: + - build + matrix: + parameters: + os: + - linux + - RelationalWithAuthV2-e2e-graphql_e2e_tests: context: - amplify-ecr-image-pull - e2e-test-context @@ -15644,7 +15893,7 @@ workflows: parameters: os: - linux - - KeyTransformerLocal-e2e-graphql_e2e_tests: + - KeyTransformer-e2e-graphql_e2e_tests: context: - amplify-ecr-image-pull - e2e-test-context @@ -15660,7 +15909,7 @@ workflows: parameters: os: - linux - - MutationCondition-e2e-graphql_e2e_tests: + - MultiAuthModelAuthTransformer-e2e-graphql_e2e_tests: context: - amplify-ecr-image-pull - e2e-test-context @@ -15676,7 +15925,23 @@ workflows: parameters: os: - linux - - PredictionsTransformerTests-e2e-graphql_e2e_tests: + - NonModelAuthFunction-e2e-graphql_e2e_tests: + context: + - amplify-ecr-image-pull + - e2e-test-context + filters: + branches: + only: + - master + - /tagged-release\/.*/ + - /run-e2e\/.*/ + requires: + - build + matrix: + parameters: + os: + - linux + - SearchableModelTransformer-e2e-graphql_e2e_tests: context: - amplify-ecr-image-pull - e2e-test-context @@ -15724,7 +15989,7 @@ workflows: parameters: os: - linux - - KeyWithAuth-e2e-graphql_e2e_tests: + - KeyTransformerLocal-e2e-graphql_e2e_tests: context: - amplify-ecr-image-pull - e2e-test-context @@ -15740,7 +16005,7 @@ workflows: parameters: os: - linux - - NestedStacksTest-e2e-graphql_e2e_tests: + - MultiAuthV2Transformer-e2e-graphql_e2e_tests: context: - amplify-ecr-image-pull - e2e-test-context @@ -15756,7 +16021,23 @@ workflows: parameters: os: - linux - - RelationalTransformers-e2e-graphql_e2e_tests: + - NonModelAuthV2Function-e2e-graphql_e2e_tests: + context: + - amplify-ecr-image-pull + - e2e-test-context + filters: + branches: + only: + - master + - /tagged-release\/.*/ + - /run-e2e\/.*/ + requires: + - build + matrix: + parameters: + os: + - linux + - SearchableModelTransformerV2-e2e-graphql_e2e_tests: context: - amplify-ecr-image-pull - e2e-test-context @@ -15788,7 +16069,7 @@ workflows: parameters: os: - linux - - ModelAuthTransformer-e2e-graphql_e2e_tests: + - KeyWithAuth-e2e-graphql_e2e_tests: context: - amplify-ecr-image-pull - e2e-test-context @@ -15804,7 +16085,7 @@ workflows: parameters: os: - linux - - NewConnectionTransformer-e2e-graphql_e2e_tests: + - MutationCondition-e2e-graphql_e2e_tests: context: - amplify-ecr-image-pull - e2e-test-context @@ -15820,7 +16101,23 @@ workflows: parameters: os: - linux - - SearchableModelTransformer-e2e-graphql_e2e_tests: + - PerFieldAuthTests-e2e-graphql_e2e_tests: + context: + - amplify-ecr-image-pull + - e2e-test-context + filters: + branches: + only: + - master + - /tagged-release\/.*/ + - /run-e2e\/.*/ + requires: + - build + matrix: + parameters: + os: + - linux + - SearchableWithAuthTests-e2e-graphql_e2e_tests: context: - amplify-ecr-image-pull - e2e-test-context @@ -15852,7 +16149,7 @@ workflows: parameters: os: - linux - - ModelConnectionTransformer-e2e-graphql_e2e_tests: + - ModelAuthTransformer-e2e-graphql_e2e_tests: context: - amplify-ecr-image-pull - e2e-test-context @@ -15868,7 +16165,7 @@ workflows: parameters: os: - linux - - NewConnectionWithAuth-e2e-graphql_e2e_tests: + - NestedStacksTest-e2e-graphql_e2e_tests: context: - amplify-ecr-image-pull - e2e-test-context @@ -15884,7 +16181,23 @@ workflows: parameters: os: - linux - - SearchableModelTransformerV2-e2e-graphql_e2e_tests: + - PerFieldAuthV2Transformer-e2e-graphql_e2e_tests: + context: + - amplify-ecr-image-pull + - e2e-test-context + filters: + branches: + only: + - master + - /tagged-release\/.*/ + - /run-e2e\/.*/ + requires: + - build + matrix: + parameters: + os: + - linux + - SearchableWithAuthV2-e2e-graphql_e2e_tests: context: - amplify-ecr-image-pull - e2e-test-context @@ -15916,7 +16229,7 @@ workflows: parameters: os: - linux - - ModelConnectionWithKeyTransformer-e2e-graphql_e2e_tests: + - ModelConnectionTransformer-e2e-graphql_e2e_tests: context: - amplify-ecr-image-pull - e2e-test-context @@ -15932,7 +16245,7 @@ workflows: parameters: os: - linux - - NoneEnvFunctionTransformer-e2e-graphql_e2e_tests: + - NewConnectionTransformer-e2e-graphql_e2e_tests: context: - amplify-ecr-image-pull - e2e-test-context @@ -15948,7 +16261,23 @@ workflows: parameters: os: - linux - - SearchableWithAuthTests-e2e-graphql_e2e_tests: + - PredictionsTransformerTests-e2e-graphql_e2e_tests: + context: + - amplify-ecr-image-pull + - e2e-test-context + filters: + branches: + only: + - master + - /tagged-release\/.*/ + - /run-e2e\/.*/ + requires: + - build + matrix: + parameters: + os: + - linux + - SubscriptionsWithAuthTest-e2e-graphql_e2e_tests: context: - amplify-ecr-image-pull - e2e-test-context diff --git a/packages/amplify-graphql-auth-transformer/src/accesscontrol/acm.ts b/packages/amplify-graphql-auth-transformer/src/accesscontrol/acm.ts index 77166997285..a4712b471cd 100644 --- a/packages/amplify-graphql-auth-transformer/src/accesscontrol/acm.ts +++ b/packages/amplify-graphql-auth-transformer/src/accesscontrol/acm.ts @@ -71,6 +71,10 @@ export class AccessControlMatrix { return this.resources; } + public hasResource(resource: string): boolean { + return this.resources.includes(resource); + } + public isAllowed(role: string, resource: string, operation: string): boolean { this.validate({ role, resource, operations: [operation] }); const roleIndex = this.roles.indexOf(role); diff --git a/packages/amplify-graphql-auth-transformer/src/graphql-auth-transformer.ts b/packages/amplify-graphql-auth-transformer/src/graphql-auth-transformer.ts index bd49ca098cd..bbe5851199a 100644 --- a/packages/amplify-graphql-auth-transformer/src/graphql-auth-transformer.ts +++ b/packages/amplify-graphql-auth-transformer/src/graphql-auth-transformer.ts @@ -51,6 +51,9 @@ import { getSearchableConfig, getStackForField, NONE_DS, + hasRelationalDirective, + getTable, + getRelationalPrimaryMap, } from './utils'; import { DirectiveNode, @@ -73,9 +76,13 @@ import { generateAuthExpressionForField, generateFieldAuthResponse, generateAuthExpressionForQueries, + generateAuthExpressionForSearchQueries, generateAuthExpressionForSubscriptions, + setDeniedFieldFlag, + generateAuthExpressionForRelationQuery, } from './resolvers'; import { toUpper } from 'graphql-transformer-common'; +import { generateSandboxExpressionForField } from './resolvers/field'; // @ auth // changing the schema @@ -280,7 +287,8 @@ Static group authorization should perform as expected.`, // protect additional query fields if they exist if (context.metadata.has(indexKeyName)) { for (let index of context.metadata.get>(indexKeyName)!.values()) { - this.protectListResolver(context, def, def.name.value, index, acm); + const [indexName, indexQueryName] = index.split(':'); + this.protectListResolver(context, def, def.name.value, indexQueryName, acm, indexName); } } // check if searchable if included in the typeName @@ -291,15 +299,19 @@ Static group authorization should perform as expected.`, } // get fields specified in the schema // if there is a role that does not have read access on the field then we create a field resolver + // or there is a relational directive on the field then we should protect that as well const readRoles = acm.getRolesPerOperation('read'); - const modelFields = def.fields?.filter(f => acm.getResources().includes(f.name.value)) ?? []; + const modelFields = def.fields?.filter(f => acm.hasResource(f.name.value)) ?? []; for (let field of modelFields) { const allowedRoles = readRoles.filter(r => acm.isAllowed(r, field.name.value, 'read')); - if (allowedRoles.length < readRoles.length) { - if (field.type.kind === Kind.NON_NULL_TYPE) { - throw new InvalidDirectiveError(`\nPer-field auth on the required field ${field.name.value} is not supported with subscriptions. + const needsFieldResolver = allowedRoles.length < readRoles.length; + if (needsFieldResolver && field.type.kind === Kind.NON_NULL_TYPE) { + throw new InvalidDirectiveError(`\nPer-field auth on the required field ${field.name.value} is not supported with subscriptions. Either make the field optional, set auth on the object and not the field, or disable subscriptions for the object (setting level to off or public)\n`); - } + } + if (hasRelationalDirective(field)) { + this.protectRelationalResolver(context, def, modelName, field, needsFieldResolver ? allowedRoles : null); + } else if (needsFieldResolver) { this.protectFieldResolver(context, def, modelName, field.name.value, allowedRoles); } } @@ -367,7 +379,7 @@ Static group authorization should perform as expected.`, // @index queries if (ctx.metadata.has(indexKeyName)) { for (let index of ctx.metadata.get>(indexKeyName)!.values()) { - addServiceDirective(ctx.output.getQueryTypeName(), 'read', index); + addServiceDirective(ctx.output.getQueryTypeName(), 'read', index.split(':')[1]); } } // @searchable @@ -377,7 +389,7 @@ Static group authorization should perform as expected.`, } const subscriptions = modelConfig?.subscriptions; - if (subscriptions.level === SubscriptionLevel.on) { + if (subscriptions && subscriptions.level === SubscriptionLevel.on) { const subscriptionArguments = acm .getRolesPerOperation('read') .map(role => this.roleMap.get(role)!) @@ -412,7 +424,8 @@ Static group authorization should perform as expected.`, ): void => { const resolver = ctx.resolvers.getResolver(typeName, fieldName) as TransformerResolverProvider; const roleDefinitions = acm.getRolesPerOperation('read').map(r => this.roleMap.get(r)!); - const authExpression = generateAuthExpressionForQueries(this.configuredAuthProviders, roleDefinitions, def.fields ?? []); + const primaryFields = getTable(ctx, def).keySchema.map(att => att.attributeName); + const authExpression = generateAuthExpressionForQueries(this.configuredAuthProviders, roleDefinitions, def.fields ?? [], primaryFields); resolver.addToSlot( 'auth', MappingTemplate.s3MappingTemplateFromString(authExpression, `${typeName}.${fieldName}.{slotName}.{slotIndex}.req.vtl`), @@ -424,15 +437,88 @@ Static group authorization should perform as expected.`, typeName: string, fieldName: string, acm: AccessControlMatrix, + indexName?: string, ): void => { const resolver = ctx.resolvers.getResolver(typeName, fieldName) as TransformerResolverProvider; const roleDefinitions = acm.getRolesPerOperation('read').map(r => this.roleMap.get(r)!); - const authExpression = generateAuthExpressionForQueries(this.configuredAuthProviders, roleDefinitions, def.fields ?? []); + let primaryFields: Array; + const table = getTable(ctx, def); + try { + if (indexName) { + primaryFields = table.globalSecondaryIndexes + .find((gsi: any) => gsi.indexName === indexName) + .keySchema.map((att: any) => att.attributeName); + } else { + primaryFields = table.keySchema.map((att: any) => att.attributeName); + } + } catch (err) { + throw new InvalidDirectiveError(`Could not fetch keySchema for ${def.name.value}.`); + } + const authExpression = generateAuthExpressionForQueries( + this.configuredAuthProviders, + roleDefinitions, + def.fields ?? [], + primaryFields, + !!indexName, + ); resolver.addToSlot( 'auth', MappingTemplate.s3MappingTemplateFromString(authExpression, `${typeName}.${fieldName}.{slotName}.{slotIndex}.req.vtl`), ); }; + protectRelationalResolver = ( + ctx: TransformerContextProvider, + def: ObjectTypeDefinitionNode, + typeName: string, + field: FieldDefinitionNode, + fieldRoles: Array | null, + ): void => { + let fieldAuthExpression: string; + let relatedAuthExpression: string; + const relatedModel = getBaseType(field.type); + const relatedModelObject = ctx.output.getObject(relatedModel); + if (this.authModelConfig.has(relatedModel)) { + const acm = this.authModelConfig.get(relatedModel); + const roleDefinitions = acm.getRolesPerOperation('read').map(r => this.roleMap.get(r)!); + const relationalPrimaryMap = getRelationalPrimaryMap(ctx, def, field, relatedModelObject); + relatedAuthExpression = generateAuthExpressionForRelationQuery( + this.configuredAuthProviders, + roleDefinitions, + relatedModelObject.fields ?? [], + relationalPrimaryMap, + ); + } else { + // if the related @model does not have auth we need to add a sandbox mode expression + relatedAuthExpression = generateSandboxExpressionForField((ctx as any).resourceHelper.api.sandboxModeEnabled); + } + // if there is field auth on the relational query then we need to add field auth read rules first + // in the request we then add the rules of the related type + if (fieldRoles) { + const roleDefinitions = fieldRoles.map(r => this.roleMap.get(r)!); + const hasSubsEnabled = this.modelDirectiveConfig.get(typeName)!.subscriptions.level === 'on'; + relatedAuthExpression = setDeniedFieldFlag('Mutation', hasSubsEnabled) + '\n' + relatedAuthExpression; + fieldAuthExpression = generateAuthExpressionForField(this.configuredAuthProviders, roleDefinitions, def.fields ?? []); + } + const resolver = ctx.resolvers.getResolver(typeName, field.name.value) as TransformerResolverProvider; + if (fieldAuthExpression) { + resolver.addToSlot( + 'auth', + MappingTemplate.s3MappingTemplateFromString(fieldAuthExpression, `${typeName}.${field.name.value}.{slotName}.{slotIndex}.req.vtl`), + MappingTemplate.s3MappingTemplateFromString( + relatedAuthExpression, + `${typeName}.${field.name.value}.{slotName}.{slotIndex}.res.vtl`, + ), + ); + } else { + resolver.addToSlot( + 'auth', + MappingTemplate.s3MappingTemplateFromString( + relatedAuthExpression, + `${typeName}.${field.name.value}.{slotName}.{slotIndex}.req.vtl`, + ), + ); + } + }; protectSyncResolver = ( ctx: TransformerContextProvider, def: ObjectTypeDefinitionNode, @@ -443,7 +529,13 @@ Static group authorization should perform as expected.`, if (ctx.isProjectUsingDataStore()) { const resolver = ctx.resolvers.getResolver(typeName, fieldName) as TransformerResolverProvider; const roleDefinitions = acm.getRolesPerOperation('read').map(r => this.roleMap.get(r)!); - const authExpression = generateAuthExpressionForQueries(this.configuredAuthProviders, roleDefinitions, def.fields ?? []); + const primaryFields = getTable(ctx, def).keySchema.map(att => att.attributeName); + const authExpression = generateAuthExpressionForQueries( + this.configuredAuthProviders, + roleDefinitions, + def.fields ?? [], + primaryFields, + ); resolver.addToSlot( 'auth', MappingTemplate.s3MappingTemplateFromString(authExpression, `${typeName}.${fieldName}.{slotName}.{slotIndex}.req.vtl`), @@ -459,7 +551,7 @@ Static group authorization should perform as expected.`, ): void => { const resolver = ctx.resolvers.getResolver(typeName, fieldName) as TransformerResolverProvider; const roleDefinitions = acm.getRolesPerOperation('read').map(r => this.roleMap.get(r)!); - const authExpression = generateAuthExpressionForQueries(this.configuredAuthProviders, roleDefinitions, def.fields ?? [], 'opensearch'); + const authExpression = generateAuthExpressionForSearchQueries(this.configuredAuthProviders, roleDefinitions, def.fields ?? []); resolver.addToSlot( 'auth', MappingTemplate.s3MappingTemplateFromString(authExpression, `${typeName}.${fieldName}.{slotName}.{slotIndex}.req.vtl`), @@ -468,13 +560,11 @@ Static group authorization should perform as expected.`, /* Field Resovler can protect the following - model fields - - relational fields (hasOne, hasMany, belongsTo) - fields on an operation (query/mutation) - protection on predictions/function/no directive Order of precendence - resolver in api host (ex. @function, @predictions) - - resolver in resolver manager (ex. @hasOne, @hasMany @belongsTo) - - no resolver creates a blank non-pipeline resolver will return the source field + - no resolver -> creates a blank resolver will return the source field */ protectFieldResolver = ( ctx: TransformerContextProvider, @@ -501,14 +591,6 @@ Static group authorization should perform as expected.`, stack, ); (fieldResolver.pipelineConfig.functions as string[]).unshift(authFunction.functionId); - } else if (ctx.resolvers.hasResolver(typeName, fieldName)) { - // if there a resolver in the resolver manager we can append to the auth slot - const fieldResolver = ctx.resolvers.getResolver(typeName, fieldName) as TransformerResolverProvider; - const authExpression = generateAuthExpressionForQueries(this.configuredAuthProviders, roleDefinitions, def.fields ?? []); - fieldResolver.addToSlot( - 'auth', - MappingTemplate.s3MappingTemplateFromString(authExpression, `${typeName}.${fieldName}.{slotName}.{slotIndex}.req.vtl`), - ); } else { const fieldAuthExpression = generateAuthExpressionForField(this.configuredAuthProviders, roleDefinitions, def.fields ?? []); const subsEnabled = hasModelDirective ? this.modelDirectiveConfig.get(typeName)!.subscriptions.level === 'on' : false; @@ -839,19 +921,18 @@ Static group authorization should perform as expected.`, const authRoleParameter = (ctx.stackManager.getParameter(IAM_AUTH_ROLE_PARAMETER) as cdk.CfnParameter).valueAsString; const authPolicyDocuments = createPolicyDocumentForManagedPolicy(this.authPolicyResources); const rootStack = ctx.stackManager.rootStack; + // we need to add the arn path as this is something cdk is looking for when using imported roles in policies + const iamAuthRoleArn = iam.Role.fromRoleArn( + rootStack, + 'auth-role-name', + `arn:aws:iam::${cdk.Stack.of(rootStack).account}:role/${authRoleParameter}`, + ); for (let i = 0; i < authPolicyDocuments.length; i++) { const paddedIndex = `${i + 1}`.padStart(2, '0'); const resourceName = `${ResourceConstants.RESOURCES.AuthRolePolicy}${paddedIndex}`; new iam.ManagedPolicy(rootStack, resourceName, { document: iam.PolicyDocument.fromJson(authPolicyDocuments[i]), - // we need to add the arn path as this is something cdk is looking for when using imported roles in policies - roles: [ - iam.Role.fromRoleArn( - rootStack, - 'auth-role-name', - `arn:aws:iam::${cdk.Stack.of(rootStack).account}:role/${authRoleParameter}`, - ), - ], + roles: [iamAuthRoleArn], }); } } @@ -864,18 +945,17 @@ Static group authorization should perform as expected.`, const unauthRoleParameter = (ctx.stackManager.getParameter(IAM_UNAUTH_ROLE_PARAMETER) as cdk.CfnParameter).valueAsString; const unauthPolicyDocuments = createPolicyDocumentForManagedPolicy(this.unauthPolicyResources); const rootStack = ctx.stackManager.rootStack; + const iamUnauthRoleArn = iam.Role.fromRoleArn( + rootStack, + 'unauth-role-name', + `arn:aws:iam::${cdk.Stack.of(rootStack).account}:role/${unauthRoleParameter}`, + ); for (let i = 0; i < unauthPolicyDocuments.length; i++) { const paddedIndex = `${i + 1}`.padStart(2, '0'); const resourceName = `${ResourceConstants.RESOURCES.UnauthRolePolicy}${paddedIndex}`; new iam.ManagedPolicy(ctx.stackManager.rootStack, resourceName, { document: iam.PolicyDocument.fromJson(unauthPolicyDocuments[i]), - roles: [ - iam.Role.fromRoleArn( - rootStack, - 'unauth-role-name', - `arn:aws:iam::${cdk.Stack.of(rootStack).account}:role/${unauthRoleParameter}`, - ), - ], + roles: [iamUnauthRoleArn], }); } } diff --git a/packages/amplify-graphql-auth-transformer/src/resolvers/field.ts b/packages/amplify-graphql-auth-transformer/src/resolvers/field.ts index e5ec9ffe460..cbc7c385dfc 100644 --- a/packages/amplify-graphql-auth-transformer/src/resolvers/field.ts +++ b/packages/amplify-graphql-auth-transformer/src/resolvers/field.ts @@ -17,6 +17,9 @@ import { bool, raw, forEach, + qref, + notEquals, + obj, } from 'graphql-mapping-template'; import { RoleDefinition, @@ -26,6 +29,7 @@ import { ConfiguredAuthProviders, fieldIsList, IS_AUTHORIZED_FLAG, + API_KEY_AUTH_TYPE, } from '../utils'; import { getOwnerClaim, generateStaticRoleExpression, apiKeyExpression, iamExpression, emptyPayload } from './helpers'; @@ -134,3 +138,24 @@ export const generateFieldAuthResponse = (operation: string, fieldName: string, } return printBlock('Return Source Field')(toJson(ref(`context.source.${fieldName}`))); }; + +export const setDeniedFieldFlag = (operation: string, subscriptionsEnabled: boolean): string => { + if (subscriptionsEnabled) { + return printBlock('Check if subscriptions is protected')( + compoundExpression([ + iff( + equals(methodCall(ref('util.defaultIfNull'), methodCall(ref('ctx.source.get'), str(OPERATION_KEY)), nul()), str(operation)), + qref(methodCall(ref('ctx.result.put'), str('deniedField'), bool(true))), + ), + ]), + ); + } + return ''; +}; + +export const generateSandboxExpressionForField = (sandboxEnabled: boolean): string => { + let exp: Expression; + if (sandboxEnabled) exp = iff(notEquals(methodCall(ref('util.authType')), str(API_KEY_AUTH_TYPE)), methodCall(ref('util.unauthorized'))); + else exp = methodCall(ref('util.unauthorized')); + return printBlock(`Sandbox Mode ${sandboxEnabled ? 'Enabled' : 'Disabled'}`)(compoundExpression([exp, toJson(obj({}))])); +}; diff --git a/packages/amplify-graphql-auth-transformer/src/resolvers/index.ts b/packages/amplify-graphql-auth-transformer/src/resolvers/index.ts index 5f9e8476d39..aa407c74075 100644 --- a/packages/amplify-graphql-auth-transformer/src/resolvers/index.ts +++ b/packages/amplify-graphql-auth-transformer/src/resolvers/index.ts @@ -1,7 +1,7 @@ -export { generateAuthExpressionForQueries } from './query'; +export { generateAuthExpressionForQueries, generateAuthExpressionForSearchQueries, generateAuthExpressionForRelationQuery } from './query'; export { generateAuthExpressionForCreate } from './mutation.create'; export { generateAuthExpressionForUpdate } from './mutation.update'; export { geneateAuthExpressionForDelete } from './mutation.delete'; -export { generateAuthExpressionForField, generateFieldAuthResponse } from './field'; +export { generateAuthExpressionForField, generateFieldAuthResponse, setDeniedFieldFlag } from './field'; export { generateAuthExpressionForSubscriptions } from './subscriptions'; export { generateAuthRequestExpression } from './helpers'; diff --git a/packages/amplify-graphql-auth-transformer/src/resolvers/mutation.delete.ts b/packages/amplify-graphql-auth-transformer/src/resolvers/mutation.delete.ts index c5656f061dc..c16ea3d9af6 100644 --- a/packages/amplify-graphql-auth-transformer/src/resolvers/mutation.delete.ts +++ b/packages/amplify-graphql-auth-transformer/src/resolvers/mutation.delete.ts @@ -68,7 +68,7 @@ const iamExpression = (roles: Array, hasAdminUIEnabled: boolean } if (roles.length > 0) { for (let role of roles) { - iamCheck(role.claim!, set(ref(IS_AUTHORIZED_FLAG), bool(true))); + expression.push(iamCheck(role.claim!, set(ref(IS_AUTHORIZED_FLAG), bool(true)))); } } else { expression.push(ref('util.unauthorized()')); diff --git a/packages/amplify-graphql-auth-transformer/src/resolvers/mutation.update.ts b/packages/amplify-graphql-auth-transformer/src/resolvers/mutation.update.ts index a58101a3ce3..7d4f20ec30d 100644 --- a/packages/amplify-graphql-auth-transformer/src/resolvers/mutation.update.ts +++ b/packages/amplify-graphql-auth-transformer/src/resolvers/mutation.update.ts @@ -88,7 +88,7 @@ const iamExpression = (roles: Array, hasAdminUIEnabled: boolean ), ); } else { - iamCheck(role.claim!, set(ref(IS_AUTHORIZED_FLAG), bool(true))); + expression.push(iamCheck(role.claim!, set(ref(IS_AUTHORIZED_FLAG), bool(true)))); } } } else { @@ -300,7 +300,7 @@ export const generateAuthExpressionForUpdate = ( compoundExpression([ forEach(ref('entry'), ref('util.map.copyAndRetainAllKeys($ctx.args.input, $inputFields).entrySet()'), [ iff( - and([methodCall(ref('util.isNull'), ref('entry.value')), not(ref(`${NULL_ALLOWED_FIELDS}.contains($entry.value)`))]), + and([methodCall(ref('util.isNull'), ref('entry.value')), not(ref(`${NULL_ALLOWED_FIELDS}.contains($entry.key)`))]), qref(methodCall(ref(`${DENIED_FIELDS}.put`), ref('entry.key'), str(''))), ), ]), diff --git a/packages/amplify-graphql-auth-transformer/src/resolvers/query.ts b/packages/amplify-graphql-auth-transformer/src/resolvers/query.ts index fc244d65912..03df5623934 100644 --- a/packages/amplify-graphql-auth-transformer/src/resolvers/query.ts +++ b/packages/amplify-graphql-auth-transformer/src/resolvers/query.ts @@ -17,6 +17,7 @@ import { qref, raw, set, + ifElse, } from 'graphql-mapping-template'; import { getIdentityClaimExp, getOwnerClaim, apiKeyExpression, iamExpression, emptyPayload, setHasAuthExpression } from './helpers'; import { @@ -28,6 +29,7 @@ import { IS_AUTHORIZED_FLAG, fieldIsList, QuerySource, + RelationalPrimaryMapConfig, } from '../utils'; import { InvalidDirectiveError } from '@aws-amplify/graphql-transformer-core'; import { NONE_VALUE } from 'graphql-transformer-common'; @@ -59,6 +61,119 @@ const generateStaticRoleExpression = (roles: Array): Array, + primaryFieldMap: RelationalPrimaryMapConfig, +): Array => { + const modelQueryExpression = new Array(); + const primaryRoles = roles.filter(r => primaryFieldMap.has(r.entity)); + if (primaryRoles.length > 0) { + primaryRoles.forEach((role, idx) => { + const { claim, field } = primaryFieldMap.get(role.entity); + modelQueryExpression.push( + set( + ref(`primaryRole${idx}`), + role.strategy === 'owner' ? getOwnerClaim(role.claim!) : getIdentityClaimExp(str(role.claim!), str(NONE_VALUE)), + ), + ifElse( + not(ref(`util.isNull($ctx.${claim}.${field})`)), + compoundExpression([ + iff(equals(ref(`ctx.${claim}.${field}`), ref(`primaryRole${idx}`)), set(ref(IS_AUTHORIZED_FLAG), bool(true))), + ]), + iff( + not(ref(IS_AUTHORIZED_FLAG)), + compoundExpression([ + set(ref('primaryFieldAuth'), bool(true)), + qref(methodCall(ref(`ctx.${claim}.put`), str(field), ref(`primaryRole${idx}`))), + ]), + ), + ), + ); + }); + return [ + iff( + and([not(ref(IS_AUTHORIZED_FLAG)), methodCall(ref('util.isNull'), ref('ctx.stash.authFilter'))]), + compoundExpression(modelQueryExpression), + ), + ]; + } + return modelQueryExpression; +}; + +/** + * In the event that an owner/group field is the same as a primary field we can validate against the args if provided + * if the field is not in the args we include it in the KeyConditionExpression which is formed as a part of the query + */ +const generateAuthOnModelQueryExpression = ( + roles: Array, + primaryFields: Array, + isIndexQuery = false, +): Array => { + const modelQueryExpression = new Array(); + const primaryRoles = roles.filter(r => primaryFields.includes(r.entity)); + if (primaryRoles.length > 0) { + if (isIndexQuery) { + for (let role of primaryRoles) { + const claimExpression = + role.strategy === 'owner' ? getOwnerClaim(role.claim!) : getIdentityClaimExp(str(role.claim!), str(NONE_VALUE)); + modelQueryExpression.push( + ifElse( + not(ref(`util.isNull($ctx.args.${role.entity})`)), + compoundExpression([iff(equals(ref(`ctx.args.${role.entity}`), claimExpression), set(ref(IS_AUTHORIZED_FLAG), bool(true)))]), + qref(methodCall(ref('primaryFieldMap.put'), str(role.entity), claimExpression)), + ), + ); + } + modelQueryExpression.push( + iff( + and([ + not(ref(IS_AUTHORIZED_FLAG)), + methodCall(ref('util.isNull'), ref('ctx.stash.authFilter')), + not(ref('primaryFieldMap.isEmpty()')), + ]), + compoundExpression([ + forEach(ref('entry'), ref('primaryFieldMap.entrySet()'), [ + qref(methodCall(ref('ctx.args.put'), ref('entry.key'), ref('entry.value'))), + ]), + ]), + ), + ); + } else { + for (let role of primaryRoles) { + const claimExpression = + role.strategy === 'owner' ? getOwnerClaim(role.claim!) : getIdentityClaimExp(str(role.claim!), str(NONE_VALUE)); + modelQueryExpression.push( + ifElse( + not(ref(`util.isNull($ctx.args.${role.entity})`)), + compoundExpression([iff(equals(ref(`ctx.args.${role.entity}`), claimExpression), set(ref(IS_AUTHORIZED_FLAG), bool(true)))]), + qref(methodCall(ref('primaryFieldMap.put'), str(role.entity), claimExpression)), + ), + ); + } + modelQueryExpression.push( + iff( + and([ + not(ref(IS_AUTHORIZED_FLAG)), + methodCall(ref('util.isNull'), ref('ctx.stash.authFilter')), + not(ref('primaryFieldMap.isEmpty()')), + ]), + compoundExpression([ + set(ref('modelQueryExpression'), ref('ctx.stash.modelQueryExpression')), + forEach(ref('entry'), ref('primaryFieldMap.entrySet()'), [ + set(ref('modelQueryExpression.expression'), str('${modelQueryExpression.expression} AND #${entry.key} = :${entry.value}')), + qref(ref('modelQueryExpression.expressionNames.put("#${entry.key}", $entry.key)')), + qref(ref('modelQueryExpression.expressionValues.put(":${entry.value}", $util.dynamodb.toDynamoDB($entry.value))')), + ]), + qref(methodCall(ref('ctx.stash.put'), str('modelQueryExpression'), ref('modelQueryExpression'))), + ]), + ), + ); + } + return modelQueryExpression; + } + return []; +}; + const generateAuthFilter = ( roles: Array, fields: ReadonlyArray, @@ -77,14 +192,14 @@ const generateAuthFilter = ( * if groupsField is a string * groupsField: { in: "cognito:groups" } * if groupsField is a list - * groupsField: { contains: "cognito:groups" } + * we create contains experession for each cognito group * */ if (querySource === 'dynamodb') { for (let role of roles) { - const entityIsList = fieldIsList(fields, role.entity!); + const entityIsList = fieldIsList(fields, role.entity); if (role.strategy === 'owner') { const ownerCondition = entityIsList ? 'contains' : 'eq'; - authFilter.push(obj({ [role.entity!]: obj({ [ownerCondition]: getOwnerClaim(role.claim!) }) })); + authFilter.push(obj({ [role.entity]: obj({ [ownerCondition]: getOwnerClaim(role.claim!) }) })); } if (role.strategy === 'groups') { // for fields where the group is a list and the token is a list we must add every group in the claim @@ -95,7 +210,7 @@ const generateAuthFilter = ( groupMap.set(role.claim!, [role.entity]); } } else { - authFilter.push(obj({ [role.entity!]: obj({ in: getIdentityClaimExp(str(role.claim!), list([str(NONE_VALUE)])) }) })); + authFilter.push(obj({ [role.entity]: obj({ in: getIdentityClaimExp(str(role.claim!), list([str(NONE_VALUE)])) }) })); } } } @@ -119,28 +234,32 @@ const generateAuthFilter = ( ), ]; } + /** + * for opensearch + * we create a terms_set where the field (role.entity) has to match at least element in the terms + * if the field is a list it will look for a subset of elements in the list which should exist in the terms list + * */ if (querySource === 'opensearch') { for (let role of roles) { - let claimValue: Expression; + const entityIsList = fieldIsList(fields, role.entity); + const roleKey = entityIsList ? role.entity : `${role.entity}.keyword`; if (role.strategy === 'owner') { - claimValue = getOwnerClaim(role.claim!); authFilter.push( obj({ terms_set: obj({ - [role.entity!]: obj({ - terms: list([claimValue]), + [roleKey]: obj({ + terms: list([getOwnerClaim(role.claim!)]), minimum_should_match_script: obj({ source: str('1') }), }), }), }), ); } else if (role.strategy === 'groups') { - claimValue = getIdentityClaimExp(str(role.claim!), list([str(NONE_VALUE)])); authFilter.push( obj({ terms_set: obj({ - [role.entity!]: obj({ - terms: claimValue, + [roleKey]: obj({ + terms: getIdentityClaimExp(str(role.claim!), list([str(NONE_VALUE)])), minimum_should_match_script: obj({ source: str('1') }), }), }), @@ -162,7 +281,59 @@ export const generateAuthExpressionForQueries = ( providers: ConfiguredAuthProviders, roles: Array, fields: ReadonlyArray, - querySource: QuerySource = 'dynamodb', + primaryFields: Array, + isIndexQuery = false, +): string => { + const { cogntoStaticRoles, cognitoDynamicRoles, oidcStaticRoles, oidcDynamicRoles, apiKeyRoles, iamRoles } = splitRoles(roles); + const getNonPrimaryFieldRoles = (roles: RoleDefinition[]) => roles.filter(roles => !primaryFields.includes(roles.entity)); + const totalAuthExpressions: Array = [ + setHasAuthExpression, + set(ref(IS_AUTHORIZED_FLAG), bool(false)), + set(ref('primaryFieldMap'), obj({})), + ]; + if (providers.hasApiKey) { + totalAuthExpressions.push(apiKeyExpression(apiKeyRoles)); + } + if (providers.hasIAM) { + totalAuthExpressions.push(iamExpression(iamRoles, providers.hasAdminUIEnabled, providers.adminUserPoolID)); + } + if (providers.hasUserPools) { + totalAuthExpressions.push( + iff( + equals(ref('util.authType()'), str(COGNITO_AUTH_TYPE)), + compoundExpression([ + ...generateStaticRoleExpression(cogntoStaticRoles), + ...generateAuthFilter(getNonPrimaryFieldRoles(cognitoDynamicRoles), fields, 'dynamodb'), + ...generateAuthOnModelQueryExpression(cognitoDynamicRoles, primaryFields, isIndexQuery), + ]), + ), + ); + } + if (providers.hasOIDC) { + totalAuthExpressions.push( + iff( + equals(ref('util.authType()'), str(OIDC_AUTH_TYPE)), + compoundExpression([ + ...generateStaticRoleExpression(oidcStaticRoles), + ...generateAuthFilter(getNonPrimaryFieldRoles(oidcDynamicRoles), fields, 'dynamodb'), + ...generateAuthOnModelQueryExpression(oidcDynamicRoles, primaryFields, isIndexQuery), + ]), + ), + ); + } + totalAuthExpressions.push( + iff( + and([not(ref(IS_AUTHORIZED_FLAG)), methodCall(ref('util.isNull'), ref('ctx.stash.authFilter')), ref('primaryFieldMap.isEmpty()')]), + ref('util.unauthorized()'), + ), + ); + return printBlock('Authorization Steps')(compoundExpression([...totalAuthExpressions, emptyPayload])); +}; + +export const generateAuthExpressionForSearchQueries = ( + providers: ConfiguredAuthProviders, + roles: Array, + fields: ReadonlyArray, ): string => { const { cogntoStaticRoles, cognitoDynamicRoles, oidcStaticRoles, oidcDynamicRoles, apiKeyRoles, iamRoles } = splitRoles(roles); const totalAuthExpressions: Array = [setHasAuthExpression, set(ref(IS_AUTHORIZED_FLAG), bool(false))]; @@ -178,7 +349,52 @@ export const generateAuthExpressionForQueries = ( equals(ref('util.authType()'), str(COGNITO_AUTH_TYPE)), compoundExpression([ ...generateStaticRoleExpression(cogntoStaticRoles), - ...generateAuthFilter(cognitoDynamicRoles, fields, querySource), + ...generateAuthFilter(cognitoDynamicRoles, fields, 'opensearch'), + ]), + ), + ); + } + if (providers.hasOIDC) { + totalAuthExpressions.push( + iff( + equals(ref('util.authType()'), str(OIDC_AUTH_TYPE)), + compoundExpression([...generateStaticRoleExpression(oidcStaticRoles), ...generateAuthFilter(oidcDynamicRoles, [], 'opensearch')]), + ), + ); + } + totalAuthExpressions.push( + iff(and([not(ref(IS_AUTHORIZED_FLAG)), methodCall(ref('util.isNull'), ref('ctx.stash.authFilter'))]), ref('util.unauthorized()')), + ); + return printBlock('Authorization Steps')(compoundExpression([...totalAuthExpressions, emptyPayload])); +}; + +export const generateAuthExpressionForRelationQuery = ( + providers: ConfiguredAuthProviders, + roles: Array, + fields: ReadonlyArray, + primaryFieldMap: RelationalPrimaryMapConfig, +) => { + const { cogntoStaticRoles, cognitoDynamicRoles, oidcStaticRoles, oidcDynamicRoles, apiKeyRoles, iamRoles } = splitRoles(roles); + const getNonPrimaryFieldRoles = (roles: RoleDefinition[]) => roles.filter(roles => !primaryFieldMap.has(roles.entity)); + const totalAuthExpressions: Array = [ + setHasAuthExpression, + set(ref(IS_AUTHORIZED_FLAG), bool(false)), + set(ref('primaryFieldAuth'), bool(false)), + ]; + if (providers.hasApiKey) { + totalAuthExpressions.push(apiKeyExpression(apiKeyRoles)); + } + if (providers.hasIAM) { + totalAuthExpressions.push(iamExpression(iamRoles, providers.hasAdminUIEnabled, providers.adminUserPoolID)); + } + if (providers.hasUserPools) { + totalAuthExpressions.push( + iff( + equals(ref('util.authType()'), str(COGNITO_AUTH_TYPE)), + compoundExpression([ + ...generateStaticRoleExpression(cogntoStaticRoles), + ...generateAuthFilter(getNonPrimaryFieldRoles(cognitoDynamicRoles), fields, 'dynamodb'), + ...generateAuthOnRelationalModelQueryExpression(cognitoDynamicRoles, primaryFieldMap), ]), ), ); @@ -188,15 +404,16 @@ export const generateAuthExpressionForQueries = ( iff( equals(ref('util.authType()'), str(OIDC_AUTH_TYPE)), compoundExpression([ - ...generateAuthFilter(oidcDynamicRoles, fields, querySource), ...generateStaticRoleExpression(oidcStaticRoles), + ...generateAuthFilter(getNonPrimaryFieldRoles(oidcDynamicRoles), fields, 'dynamodb'), + ...generateAuthOnRelationalModelQueryExpression(oidcDynamicRoles, primaryFieldMap), ]), ), ); } totalAuthExpressions.push( iff( - and([not(ref(IS_AUTHORIZED_FLAG)), methodCall(ref('util.isNullOrEmpty'), methodCall(ref('ctx.stash.get'), str('authFilter')))]), + and([not(ref(IS_AUTHORIZED_FLAG)), methodCall(ref('util.isNull'), ref('ctx.stash.authFilter')), not(ref('primaryFieldAuth'))]), ref('util.unauthorized()'), ), ); diff --git a/packages/amplify-graphql-auth-transformer/src/resolvers/subscriptions.ts b/packages/amplify-graphql-auth-transformer/src/resolvers/subscriptions.ts index 9f5add04279..b2be6b2a395 100644 --- a/packages/amplify-graphql-auth-transformer/src/resolvers/subscriptions.ts +++ b/packages/amplify-graphql-auth-transformer/src/resolvers/subscriptions.ts @@ -14,7 +14,14 @@ import { printBlock, } from 'graphql-mapping-template'; import { COGNITO_AUTH_TYPE, ConfiguredAuthProviders, IS_AUTHORIZED_FLAG, OIDC_AUTH_TYPE, RoleDefinition, splitRoles } from '../utils'; -import { generateStaticRoleExpression, getOwnerClaim, apiKeyExpression, iamExpression, emptyPayload } from './helpers'; +import { + generateStaticRoleExpression, + getOwnerClaim, + apiKeyExpression, + iamExpression, + emptyPayload, + setHasAuthExpression, +} from './helpers'; const dynamicRoleExpression = (roles: Array): Array => { const ownerExpression = new Array(); @@ -39,7 +46,7 @@ const dynamicRoleExpression = (roles: Array): Array export const generateAuthExpressionForSubscriptions = (providers: ConfiguredAuthProviders, roles: Array): string => { const { cogntoStaticRoles, cognitoDynamicRoles, oidcStaticRoles, oidcDynamicRoles, iamRoles, apiKeyRoles } = splitRoles(roles); - const totalAuthExpressions: Array = [set(ref(IS_AUTHORIZED_FLAG), bool(false)), set(ref('allowedFields'), list([]))]; + const totalAuthExpressions: Array = [setHasAuthExpression, set(ref(IS_AUTHORIZED_FLAG), bool(false))]; if (providers.hasApiKey) { totalAuthExpressions.push(apiKeyExpression(apiKeyRoles)); } diff --git a/packages/amplify-graphql-auth-transformer/src/utils/constants.ts b/packages/amplify-graphql-auth-transformer/src/utils/constants.ts index 7b9d3cb428d..0abe7368518 100644 --- a/packages/amplify-graphql-auth-transformer/src/utils/constants.ts +++ b/packages/amplify-graphql-auth-transformer/src/utils/constants.ts @@ -30,3 +30,5 @@ export const ADMIN_ROLE = '_Full-access/CognitoIdentityCredentials'; export const MANAGE_ROLE = '_Manage-only/CognitoIdentityCredentials'; // resolver export const NONE_DS = 'NONE_DS'; +// relational directives +export const RELATIONAL_DIRECTIVES = ['hasOne', 'belongsTo', 'hasMany', 'manyToMany']; diff --git a/packages/amplify-graphql-auth-transformer/src/utils/definitions.ts b/packages/amplify-graphql-auth-transformer/src/utils/definitions.ts index 786879c3b0e..e8a75a1339a 100644 --- a/packages/amplify-graphql-auth-transformer/src/utils/definitions.ts +++ b/packages/amplify-graphql-auth-transformer/src/utils/definitions.ts @@ -4,8 +4,9 @@ export type AuthProvider = 'apiKey' | 'iam' | 'oidc' | 'userPools'; export type ModelQuery = 'get' | 'list'; export type ModelMutation = 'create' | 'update' | 'delete'; export type ModelOperation = 'create' | 'update' | 'delete' | 'read'; - export type QuerySource = 'dynamodb' | 'opensearch'; + +export type RelationalPrimaryMapConfig = Map; export interface SearchableConfig { queries: { search: string; diff --git a/packages/amplify-graphql-auth-transformer/src/utils/iam.ts b/packages/amplify-graphql-auth-transformer/src/utils/iam.ts index 596f976ceb6..d0bf86b849e 100644 --- a/packages/amplify-graphql-auth-transformer/src/utils/iam.ts +++ b/packages/amplify-graphql-auth-transformer/src/utils/iam.ts @@ -49,7 +49,7 @@ export const createPolicyDocumentForManagedPolicy = (resources: Set) => typeName, }).toString(), ); - resourceSize = RESOURCE_OVERHEAD + typeName.length; + resourceSize += RESOURCE_OVERHEAD + typeName.length; } // // Check size of resource and if needed create a new one and clear the resources and diff --git a/packages/amplify-graphql-auth-transformer/src/utils/index.ts b/packages/amplify-graphql-auth-transformer/src/utils/index.ts index 1b67a600293..e5e7760884a 100644 --- a/packages/amplify-graphql-auth-transformer/src/utils/index.ts +++ b/packages/amplify-graphql-auth-transformer/src/utils/index.ts @@ -1,19 +1,8 @@ -import { ModelDirectiveConfiguration, SubscriptionLevel } from '@aws-amplify/graphql-model-transformer'; -import { DirectiveWrapper } from '@aws-amplify/graphql-transformer-core'; import { AppSyncAuthMode } from '@aws-amplify/graphql-transformer-interfaces'; import { TransformerContextProvider } from '@aws-amplify/graphql-transformer-interfaces'; import { Stack } from '@aws-cdk/core'; -import { DirectiveNode, ObjectTypeDefinitionNode } from 'graphql'; -import { toCamelCase, plurality, graphqlName, toUpper } from 'graphql-transformer-common'; -import { - AuthProvider, - AuthRule, - AuthTransformerConfig, - ConfiguredAuthProviders, - RoleDefinition, - RolesByProvider, - SearchableConfig, -} from './definitions'; +import { ObjectTypeDefinitionNode } from 'graphql'; +import { AuthProvider, AuthRule, AuthTransformerConfig, ConfiguredAuthProviders, RoleDefinition, RolesByProvider } from './definitions'; export * from './constants'; export * from './definitions'; @@ -62,42 +51,6 @@ export const ensureAuthRuleDefaults = (rules: AuthRule[]) => { } }; -export const getModelConfig = (directive: DirectiveNode, typeName: string, isDataStoreEnabled = false): ModelDirectiveConfiguration => { - const directiveWrapped: DirectiveWrapper = new DirectiveWrapper(directive); - const options = directiveWrapped.getArguments({ - queries: { - get: toCamelCase(['get', typeName]), - list: toCamelCase(['list', plurality(typeName, true)]), - ...(isDataStoreEnabled ? { sync: toCamelCase(['sync', plurality(typeName, true)]) } : undefined), - }, - mutations: { - create: toCamelCase(['create', typeName]), - update: toCamelCase(['update', typeName]), - delete: toCamelCase(['delete', typeName]), - }, - subscriptions: { - level: SubscriptionLevel.on, - onCreate: [toCamelCase(['onCreate', typeName])], - onDelete: [toCamelCase(['onDelete', typeName])], - onUpdate: [toCamelCase(['onUpdate', typeName])], - }, - timestamps: { - createdAt: 'createdAt', - updatedAt: 'updatedAt', - }, - }); - return options; -}; - -export const getSearchableConfig = (directive: DirectiveNode, typeName: string): SearchableConfig | null => { - const directiveWrapped: DirectiveWrapper = new DirectiveWrapper(directive); - const options = directiveWrapped.getArguments({ - queries: { - search: graphqlName(`search${plurality(toUpper(typeName), true)}`), - }, - }); - return options; -}; /** * gets stack name if the field is paired with function, predictions, or by itself */ diff --git a/packages/amplify-graphql-auth-transformer/src/utils/schema.ts b/packages/amplify-graphql-auth-transformer/src/utils/schema.ts index d0cb99c4b0e..4317b211aa7 100644 --- a/packages/amplify-graphql-auth-transformer/src/utils/schema.ts +++ b/packages/amplify-graphql-auth-transformer/src/utils/schema.ts @@ -1,21 +1,33 @@ import { ModelDirectiveConfiguration, SubscriptionLevel } from '@aws-amplify/graphql-model-transformer'; +import { + DirectiveWrapper, + InvalidDirectiveError, + InvalidTransformerError, + TransformerContractError, +} from '@aws-amplify/graphql-transformer-core'; import { QueryFieldType, MutationFieldType, TransformerTransformSchemaStepContextProvider, + TransformerContextProvider, } from '@aws-amplify/graphql-transformer-interfaces'; -import { ObjectTypeDefinitionNode, FieldDefinitionNode, DirectiveNode, NamedTypeNode } from 'graphql'; +import { DynamoDbDataSource } from '@aws-cdk/aws-appsync'; +import { ObjectTypeDefinitionNode, FieldDefinitionNode, DirectiveNode, NamedTypeNode, DocumentNode } from 'graphql'; import { blankObjectExtension, extendFieldWithDirectives, extensionWithDirectives, + graphqlName, isListType, makeInputValueDefinition, makeNamedType, + ModelResourceIDs, plurality, toCamelCase, + toUpper, } from 'graphql-transformer-common'; -import { RoleDefinition } from './definitions'; +import { RELATIONAL_DIRECTIVES } from './constants'; +import { RelationalPrimaryMapConfig, RoleDefinition, SearchableConfig } from './definitions'; export const collectFieldNames = (object: ObjectTypeDefinitionNode): Array => { return object.fields!.map((field: FieldDefinitionNode) => field.name.value); @@ -25,6 +37,127 @@ export const fieldIsList = (fields: ReadonlyArray, fieldNam return fields.some(field => field.name.value === fieldName && isListType(field.type)); }; +export const getModelConfig = (directive: DirectiveNode, typeName: string, isDataStoreEnabled = false): ModelDirectiveConfiguration => { + const directiveWrapped: DirectiveWrapper = new DirectiveWrapper(directive); + const options = directiveWrapped.getArguments({ + queries: { + get: toCamelCase(['get', typeName]), + list: toCamelCase(['list', plurality(typeName, true)]), + ...(isDataStoreEnabled ? { sync: toCamelCase(['sync', plurality(typeName, true)]) } : undefined), + }, + mutations: { + create: toCamelCase(['create', typeName]), + update: toCamelCase(['update', typeName]), + delete: toCamelCase(['delete', typeName]), + }, + subscriptions: { + level: SubscriptionLevel.on, + onCreate: [toCamelCase(['onCreate', typeName])], + onDelete: [toCamelCase(['onDelete', typeName])], + onUpdate: [toCamelCase(['onUpdate', typeName])], + }, + timestamps: { + createdAt: 'createdAt', + updatedAt: 'updatedAt', + }, + }); + return options; +}; + +export const getSearchableConfig = (directive: DirectiveNode, typeName: string): SearchableConfig | null => { + const directiveWrapped: DirectiveWrapper = new DirectiveWrapper(directive); + const options = directiveWrapped.getArguments({ + queries: { + search: graphqlName(`search${plurality(toUpper(typeName), true)}`), + }, + }); + return options; +}; +/* + This handles the scenario where a @auth field is also included in the keyschema of a related @model + since a filter expression cannot contain partition key or sort key attributes. We need to run auth on the query expression + https://docs.aws.amazon.com/amazondynamodb/latest/developerguide/Query.html#Query.FilterExpression + @hasMany + - we get the keyschema (default or provided index) and then check that against the fields provided in the argument + - we then create a map of this relation if the field is included in the directive then we use ctx.source.relatedField + otherwise we use ctx.args.relatedField + @hasOne, @belongsTo + - we check the key schema against the fields provided by the directive + - if they don't have the same length then we throw an error + - All of the fields specified are checked against the ctx.source.relatedField + since this isn't a many relational we don't need to get values from ctx.args + */ +export const getRelationalPrimaryMap = ( + ctx: TransformerContextProvider, + def: ObjectTypeDefinitionNode, + field: FieldDefinitionNode, + relatedModel: ObjectTypeDefinitionNode, +): RelationalPrimaryMapConfig => { + const relationalDirective = field.directives.find(dir => RELATIONAL_DIRECTIVES.includes(dir.name.value)); + const directiveWrapped: DirectiveWrapper = new DirectiveWrapper(relationalDirective); + const primaryFieldMap = new Map(); + if (relationalDirective.name.value === 'hasMany') { + const args = directiveWrapped.getArguments({ + indexName: undefined, + fields: undefined, + }); + // we only generate a primary map if a index name or field is specified + // if both are undefined then @hasMany will create a new gsi with a new readonly field + // we don't need a primary map since this readonly field is not a auth field + if (args.indexName || args.fields) { + // get related types keyschema + const fields = args.fields ? args.fields : [getTable(ctx, def).keySchema.find((att: any) => att.keyType === 'HASH').attributeName]; + const relatedTable = args.indexName + ? (getTable(ctx, relatedModel) + .globalSecondaryIndexes.find((gsi: any) => gsi.indexName === args.indexName) + .keySchema.map((att: any) => att.attributeName) as Array) + : (getTable(ctx, relatedModel).keySchema.map((att: any) => att.attributeName) as Array); + relatedTable.forEach((att, idx) => { + primaryFieldMap.set(att, { + claim: fields[idx] ? 'source' : 'args', + field: fields[idx] ?? att, + }); + }); + } + } // manyToMany doesn't need a primaryMap since it will create it's own gsis + // to the join table between related @models + else if (relationalDirective.name.value !== 'manyToMany') { + const args = directiveWrapped.getArguments({ + fields: [toCamelCase([def.name.value, field.name.value, 'id'])], + }); + // get related types keyschema + const relatedPrimaryFields = getTable(ctx, relatedModel).keySchema.map((att: any) => att.attributeName) as Array; + // the fields provided by the directive (implicit/explicit) need to match the total amount of fields used for the primary key in the related table + // otherwise the get request is incomplete + if (args.fields.length !== relatedPrimaryFields.length) { + throw new InvalidDirectiveError( + `Invalid @${relationalDirective.name.value} on ${def.name.value}:${field.name.value}. Provided fields do not match the size of primary key(s) for ${relatedModel.name.value}`, + ); + } + relatedPrimaryFields.forEach((field, idx) => { + primaryFieldMap.set(field, { + claim: 'source', + field: args.fields[idx], + }); + }); + } + return primaryFieldMap; +}; + +export const hasRelationalDirective = (field: FieldDefinitionNode): boolean => { + return field.directives && field.directives.some(dir => RELATIONAL_DIRECTIVES.includes(dir.name.value)); +}; + +export const getTable = (ctx: TransformerContextProvider, def: ObjectTypeDefinitionNode): any => { + try { + const dbSource = ctx.dataSources.get(def) as DynamoDbDataSource; + const tableName = ModelResourceIDs.ModelTableResourceID(def.name.value); + return dbSource.ds.stack.node.findChild(tableName) as any; + } catch (err) { + throw new TransformerContractError(`Could not load primary fields of @model:${def.name.value}`); + } +}; + export const extendTypeWithDirectives = ( ctx: TransformerTransformSchemaStepContextProvider, typeName: string, diff --git a/packages/amplify-graphql-function-transformer/src/__tests__/__snapshots__/amplify-graphql-function-transformer.test.ts.snap b/packages/amplify-graphql-function-transformer/src/__tests__/__snapshots__/amplify-graphql-function-transformer.test.ts.snap index 96ab67a114d..5ca4f06d989 100644 --- a/packages/amplify-graphql-function-transformer/src/__tests__/__snapshots__/amplify-graphql-function-transformer.test.ts.snap +++ b/packages/amplify-graphql-function-transformer/src/__tests__/__snapshots__/amplify-graphql-function-transformer.test.ts.snap @@ -3,7 +3,7 @@ exports[`it generates the expected resources 1`] = ` Object { "InvokeEchofunctionLambdaDataSource.req.vtl": "## [Start] Invoke AWS Lambda data source: EchofunctionLambdaDataSource. ** -$util.toJson({ +{ \\"version\\": \\"2018-05-29\\", \\"operation\\": \\"Invoke\\", \\"payload\\": { @@ -15,7 +15,7 @@ $util.toJson({ \\"request\\": $util.toJson($ctx.request), \\"prev\\": $util.toJson($ctx.prev) } -}) +} ## [End] Invoke AWS Lambda data source: EchofunctionLambdaDataSource. **", "InvokeEchofunctionLambdaDataSource.res.vtl": "## [Start] Handle error or return result. ** #if( $ctx.error ) diff --git a/packages/amplify-graphql-function-transformer/src/graphql-function-transformer.ts b/packages/amplify-graphql-function-transformer/src/graphql-function-transformer.ts index 2fd2e60cd5b..c80fb2754c6 100644 --- a/packages/amplify-graphql-function-transformer/src/graphql-function-transformer.ts +++ b/packages/amplify-graphql-function-transformer/src/graphql-function-transformer.ts @@ -95,21 +95,19 @@ export class FunctionTransformer extends TransformerPluginBase { functionId, MappingTemplate.s3MappingTemplateFromString( printBlock(`Invoke AWS Lambda data source: ${dataSourceId}`)( - toJson( - obj({ - version: str('2018-05-29'), - operation: str('Invoke'), - payload: obj({ - typeName: ref('ctx.stash.get("typeName")'), - fieldName: ref('ctx.stash.get("fieldName")'), - arguments: ref('util.toJson($ctx.arguments)'), - identity: ref('util.toJson($ctx.identity)'), - source: ref('util.toJson($ctx.source)'), - request: ref('util.toJson($ctx.request)'), - prev: ref('util.toJson($ctx.prev)'), - }), + obj({ + version: str('2018-05-29'), + operation: str('Invoke'), + payload: obj({ + typeName: ref('ctx.stash.get("typeName")'), + fieldName: ref('ctx.stash.get("fieldName")'), + arguments: ref('util.toJson($ctx.arguments)'), + identity: ref('util.toJson($ctx.identity)'), + source: ref('util.toJson($ctx.source)'), + request: ref('util.toJson($ctx.request)'), + prev: ref('util.toJson($ctx.prev)'), }), - ), + }), ), `${functionId}.req.vtl`, ), diff --git a/packages/amplify-graphql-index-transformer/src/schema.ts b/packages/amplify-graphql-index-transformer/src/schema.ts index 3ffb35f72ce..ddb5af2c4db 100644 --- a/packages/amplify-graphql-index-transformer/src/schema.ts +++ b/packages/amplify-graphql-index-transformer/src/schema.ts @@ -317,7 +317,7 @@ function replaceDeleteInput(config: PrimaryKeyDirectiveConfiguration, input: Inp } export function ensureQueryField(config: IndexDirectiveConfiguration, ctx: TransformerContextProvider): void { - const { object, queryField, sortKey } = config; + const { name, object, queryField, sortKey } = config; if (!queryField) { return; @@ -326,10 +326,10 @@ export function ensureQueryField(config: IndexDirectiveConfiguration, ctx: Trans const keyName = `${object.name.value}:indicies`; let indicies: Set; if (!ctx.metadata.has(keyName)) { - indicies = new Set([queryField]); + indicies = new Set([`${name}:${queryField}`]); } else { indicies = ctx.metadata.get>(keyName)!; - indicies.add(queryField); + indicies.add(`${name}:${queryField}`); } ctx.metadata.set(keyName, indicies); diff --git a/packages/amplify-graphql-relational-transformer/src/__tests__/__snapshots__/amplify-graphql-has-many-transformer.test.ts.snap b/packages/amplify-graphql-relational-transformer/src/__tests__/__snapshots__/amplify-graphql-has-many-transformer.test.ts.snap index e12a63b0af5..5f5c55f12ed 100644 --- a/packages/amplify-graphql-relational-transformer/src/__tests__/__snapshots__/amplify-graphql-has-many-transformer.test.ts.snap +++ b/packages/amplify-graphql-relational-transformer/src/__tests__/__snapshots__/amplify-graphql-has-many-transformer.test.ts.snap @@ -7964,7 +7964,10 @@ Object { exports[`validates VTL of a complex schema 1`] = ` Object { - "Child.parents.req.vtl": "#if( $util.isNull($ctx.source.id) ) + "Child.parents.req.vtl": "#if( $ctx.source.deniedField ) + #return($util.toJson(null)) +#end +#if( $util.isNull($ctx.source.id) ) #set( $result = { \\"items\\": [] } ) @@ -8074,7 +8077,10 @@ $util.error($ctx.error.message, $ctx.error.type) #end $util.toJson($result) #end", - "Friendship.friend.req.vtl": "#if( $util.isNull($ctx.source.friendID) ) + "Friendship.friend.req.vtl": "#if( $ctx.source.deniedField ) + #return($util.toJson(null)) +#end +#if( $util.isNull($ctx.source.friendID) ) #set( $result = { \\"items\\": [] } ) @@ -12229,7 +12235,10 @@ $util.qr($ctx.result.put(\\"__operation\\", \\"Mutation\\")) $util.toJson($ctx.result) #end ## [End] ResponseTemplate. **", - "Parent.child.req.vtl": "#if( $util.isNull($ctx.source.childID) || $util.isNull($ctx.source.childName) ) + "Parent.child.req.vtl": "#if( $ctx.source.deniedField ) + #return($util.toJson(null)) +#end +#if( $util.isNull($ctx.source.childID) || $util.isNull($ctx.source.childName) ) #return #else #set( $GetRequest = { @@ -12264,7 +12273,10 @@ $util.unauthorized() $util.toJson(null) #end #end", - "Post.author.req.vtl": "#if( $util.isNull($ctx.source.owner) ) + "Post.author.req.vtl": "#if( $ctx.source.deniedField ) + #return($util.toJson(null)) +#end +#if( $util.isNull($ctx.source.owner) ) #return #else #set( $GetRequest = { @@ -12297,7 +12309,10 @@ $util.unauthorized() $util.toJson(null) #end #end", - "Post.authors.req.vtl": "#if( $util.isNull($ctx.source.authorID) ) + "Post.authors.req.vtl": "#if( $ctx.source.deniedField ) + #return($util.toJson(null)) +#end +#if( $util.isNull($ctx.source.authorID) ) #set( $result = { \\"items\\": [] } ) @@ -12429,7 +12444,10 @@ $util.error($ctx.error.message, $ctx.error.type) #end $util.toJson($result) #end", - "Post.comments.req.vtl": "#if( $util.isNull($ctx.source.id) ) + "Post.comments.req.vtl": "#if( $ctx.source.deniedField ) + #return($util.toJson(null)) +#end +#if( $util.isNull($ctx.source.id) ) #set( $result = { \\"items\\": [] } ) @@ -12501,7 +12519,10 @@ $util.error($ctx.error.message, $ctx.error.type) #end $util.toJson($result) #end", - "Post.editors.req.vtl": "#if( $util.isNull($ctx.source.id) ) + "Post.editors.req.vtl": "#if( $ctx.source.deniedField ) + #return($util.toJson(null)) +#end +#if( $util.isNull($ctx.source.id) ) #set( $result = { \\"items\\": [] } ) @@ -12611,7 +12632,10 @@ $util.error($ctx.error.message, $ctx.error.type) #end $util.toJson($result) #end", - "PostAuthor.post.req.vtl": "#if( $util.isNull($ctx.source.postID) ) + "PostAuthor.post.req.vtl": "#if( $ctx.source.deniedField ) + #return($util.toJson(null)) +#end +#if( $util.isNull($ctx.source.postID) ) #return #else #set( $GetRequest = { @@ -12644,7 +12668,10 @@ $util.unauthorized() $util.toJson(null) #end #end", - "PostEditor.editor.req.vtl": "#if( $util.isNull($ctx.source.editorID) ) + "PostEditor.editor.req.vtl": "#if( $ctx.source.deniedField ) + #return($util.toJson(null)) +#end +#if( $util.isNull($ctx.source.editorID) ) #return #else #set( $GetRequest = { @@ -12677,7 +12704,10 @@ $util.unauthorized() $util.toJson(null) #end #end", - "PostEditor.post.req.vtl": "#if( $util.isNull($ctx.source.postID) ) + "PostEditor.post.req.vtl": "#if( $ctx.source.deniedField ) + #return($util.toJson(null)) +#end +#if( $util.isNull($ctx.source.postID) ) #return #else #set( $GetRequest = { @@ -12710,7 +12740,10 @@ $util.unauthorized() $util.toJson(null) #end #end", - "PostModel.authors.req.vtl": "#if( $util.isNull($ctx.source.authorID) ) + "PostModel.authors.req.vtl": "#if( $ctx.source.deniedField ) + #return($util.toJson(null)) +#end +#if( $util.isNull($ctx.source.authorID) ) #set( $result = { \\"items\\": [] } ) @@ -12784,7 +12817,10 @@ $util.error($ctx.error.message, $ctx.error.type) #end $util.toJson($result) #end", - "PostModel.singleAuthor.req.vtl": "#if( $util.isNull($ctx.source.authorID) || $util.isNull($ctx.source.authorName) || $util.isNull($ctx.source.authorSurname) ) + "PostModel.singleAuthor.req.vtl": "#if( $ctx.source.deniedField ) + #return($util.toJson(null)) +#end +#if( $util.isNull($ctx.source.authorID) || $util.isNull($ctx.source.authorName) || $util.isNull($ctx.source.authorSurname) ) #return #else #set( $GetRequest = { @@ -14984,7 +15020,10 @@ $util.toJson({ "Subscription.onUpdateUserModel.res.vtl": "## [Start] Subscription Response template. ** $util.toJson(null) ## [End] Subscription Response template. **", - "Test.otherParts.req.vtl": "#if( $util.isNull($ctx.source.id) ) + "Test.otherParts.req.vtl": "#if( $ctx.source.deniedField ) + #return($util.toJson(null)) +#end +#if( $util.isNull($ctx.source.id) ) #set( $result = { \\"items\\": [] } ) @@ -15116,7 +15155,10 @@ $util.error($ctx.error.message, $ctx.error.type) #end $util.toJson($result) #end", - "Test.testObj.req.vtl": "#if( $util.isNull($ctx.source.id) ) + "Test.testObj.req.vtl": "#if( $ctx.source.deniedField ) + #return($util.toJson(null)) +#end +#if( $util.isNull($ctx.source.id) ) #set( $result = { \\"items\\": [] } ) @@ -15249,7 +15291,10 @@ $util.error($ctx.error.message, $ctx.error.type) #end $util.toJson($result) #end", - "User.friendships.req.vtl": "#if( $util.isNull($ctx.source.id) ) + "User.friendships.req.vtl": "#if( $ctx.source.deniedField ) + #return($util.toJson(null)) +#end +#if( $util.isNull($ctx.source.id) ) #set( $result = { \\"items\\": [] } ) @@ -15359,7 +15404,10 @@ $util.error($ctx.error.message, $ctx.error.type) #end $util.toJson($result) #end", - "User.posts.req.vtl": "#if( $util.isNull($ctx.source.id) ) + "User.posts.req.vtl": "#if( $ctx.source.deniedField ) + #return($util.toJson(null)) +#end +#if( $util.isNull($ctx.source.id) ) #set( $result = { \\"items\\": [] } ) @@ -15469,7 +15517,10 @@ $util.error($ctx.error.message, $ctx.error.type) #end $util.toJson($result) #end", - "UserModel.authorPosts.req.vtl": "#if( $util.isNull($ctx.source.id) ) + "UserModel.authorPosts.req.vtl": "#if( $ctx.source.deniedField ) + #return($util.toJson(null)) +#end +#if( $util.isNull($ctx.source.id) ) #set( $result = { \\"items\\": [] } ) diff --git a/packages/amplify-graphql-relational-transformer/src/__tests__/__snapshots__/amplify-graphql-many-to-many-transformer.test.ts.snap b/packages/amplify-graphql-relational-transformer/src/__tests__/__snapshots__/amplify-graphql-many-to-many-transformer.test.ts.snap index e837c760e3c..68f570a5d23 100644 --- a/packages/amplify-graphql-relational-transformer/src/__tests__/__snapshots__/amplify-graphql-many-to-many-transformer.test.ts.snap +++ b/packages/amplify-graphql-relational-transformer/src/__tests__/__snapshots__/amplify-graphql-many-to-many-transformer.test.ts.snap @@ -263,7 +263,10 @@ input ModelIDKeyConditionInput { exports[`valid schema 2`] = ` Object { - "Bar.foos.req.vtl": "#if( $util.isNull($ctx.source.id) ) + "Bar.foos.req.vtl": "#if( $ctx.source.deniedField ) + #return($util.toJson(null)) +#end +#if( $util.isNull($ctx.source.id) ) #set( $result = { \\"items\\": [] } ) @@ -373,7 +376,10 @@ $util.error($ctx.error.message, $ctx.error.type) #end $util.toJson($result) #end", - "Foo.bars.req.vtl": "#if( $util.isNull($ctx.source.id) ) + "Foo.bars.req.vtl": "#if( $ctx.source.deniedField ) + #return($util.toJson(null)) +#end +#if( $util.isNull($ctx.source.id) ) #set( $result = { \\"items\\": [] } ) @@ -483,7 +489,10 @@ $util.error($ctx.error.message, $ctx.error.type) #end $util.toJson($result) #end", - "FooBar.bar.req.vtl": "#if( $util.isNull($ctx.source.barID) ) + "FooBar.bar.req.vtl": "#if( $ctx.source.deniedField ) + #return($util.toJson(null)) +#end +#if( $util.isNull($ctx.source.barID) ) #return #else #set( $GetRequest = { @@ -516,7 +525,10 @@ $util.unauthorized() $util.toJson(null) #end #end", - "FooBar.foo.req.vtl": "#if( $util.isNull($ctx.source.fooID) ) + "FooBar.foo.req.vtl": "#if( $ctx.source.deniedField ) + #return($util.toJson(null)) +#end +#if( $util.isNull($ctx.source.fooID) ) #return #else #set( $GetRequest = { diff --git a/packages/amplify-graphql-relational-transformer/src/resolvers.ts b/packages/amplify-graphql-relational-transformer/src/resolvers.ts index 3b03a2f818d..f7cf53a4702 100644 --- a/packages/amplify-graphql-relational-transformer/src/resolvers.ts +++ b/packages/amplify-graphql-relational-transformer/src/resolvers.ts @@ -89,35 +89,38 @@ export function makeGetItemConnectionWithKeyResolver(config: HasOneDirectiveConf dataSource as any, MappingTemplate.s3MappingTemplateFromString( print( - ifElse( - or(localFields.map(f => raw(`$util.isNull($ctx.source.${f})`))), - raw('#return'), - compoundExpression([ - set(ref('GetRequest'), obj({ version: str('2018-05-29'), operation: str('Query') })), - qref( - methodCall( - ref('GetRequest.put'), - str('query'), - obj({ - expression: str(totalExpressions.join(' AND ')), - expressionNames: obj(totalExpressionNames), - expressionValues: obj(totalExpressionValues), - }), - ), - ), - iff( - not(isNullOrEmpty(authFilter)), + compoundExpression([ + iff(ref('ctx.source.deniedField'), raw(`#return($util.toJson(null))`)), + ifElse( + or(localFields.map(f => raw(`$util.isNull($ctx.source.${f})`))), + raw('#return'), + compoundExpression([ + set(ref('GetRequest'), obj({ version: str('2018-05-29'), operation: str('Query') })), qref( methodCall( ref('GetRequest.put'), - str('filter'), - methodCall(ref('util.parseJson'), methodCall(ref('util.transform.toDynamoDBFilterExpression'), authFilter)), + str('query'), + obj({ + expression: str(totalExpressions.join(' AND ')), + expressionNames: obj(totalExpressionNames), + expressionValues: obj(totalExpressionValues), + }), ), ), - ), - toJson(ref('GetRequest')), - ]), - ), + iff( + not(isNullOrEmpty(authFilter)), + qref( + methodCall( + ref('GetRequest.put'), + str('filter'), + methodCall(ref('util.parseJson'), methodCall(ref('util.transform.toDynamoDBFilterExpression'), authFilter)), + ), + ), + ), + toJson(ref('GetRequest')), + ]), + ), + ]), ), `${object.name.value}.${field.name.value}.req.vtl`, ), @@ -125,16 +128,14 @@ export function makeGetItemConnectionWithKeyResolver(config: HasOneDirectiveConf print( DynamoDBMappingTemplate.dynamoDBResponse( false, - compoundExpression([ - ifElse( - and([not(ref('ctx.result.items.isEmpty()')), equals(ref('ctx.result.scannedCount'), int(1))]), - toJson(ref('ctx.result.items[0]')), - compoundExpression([ - iff(and([ref('ctx.result.items.isEmpty()'), equals(ref('ctx.result.scannedCount'), int(1))]), ref('util.unauthorized()')), - toJson(nul()), - ]), - ), - ]), + ifElse( + and([not(ref('ctx.result.items.isEmpty()')), equals(ref('ctx.result.scannedCount'), int(1))]), + toJson(ref('ctx.result.items[0]')), + compoundExpression([ + iff(and([ref('ctx.result.items.isEmpty()'), equals(ref('ctx.result.scannedCount'), int(1))]), ref('util.unauthorized()')), + toJson(nul()), + ]), + ), ), ), `${object.name.value}.${field.name.value}.res.vtl`, @@ -225,11 +226,14 @@ export function makeQueryConnectionWithKeyResolver(config: HasManyDirectiveConfi dataSource as any, MappingTemplate.s3MappingTemplateFromString( print( - ifElse( - raw(`$util.isNull($ctx.source.${connectionAttributes[0]})`), - compoundExpression([set(ref('result'), obj({ items: list([]) })), raw('#return($result)')]), - compoundExpression([...setup, queryObj]), - ), + compoundExpression([ + iff(ref('ctx.source.deniedField'), raw(`#return($util.toJson(null))`)), + ifElse( + raw(`$util.isNull($ctx.source.${connectionAttributes[0]})`), + compoundExpression([set(ref('result'), obj({ items: list([]) })), raw('#return($result)')]), + compoundExpression([...setup, queryObj]), + ), + ]), ), `${object.name.value}.${field.name.value}.req.vtl`, ), diff --git a/packages/amplify-graphql-searchable-transformer/src/__tests__/__snapshots__/amplify-graphql-searchable-transformer.tests.ts.snap b/packages/amplify-graphql-searchable-transformer/src/__tests__/__snapshots__/amplify-graphql-searchable-transformer.tests.ts.snap index 293b873f10d..05ba2103ed2 100644 --- a/packages/amplify-graphql-searchable-transformer/src/__tests__/__snapshots__/amplify-graphql-searchable-transformer.tests.ts.snap +++ b/packages/amplify-graphql-searchable-transformer/src/__tests__/__snapshots__/amplify-graphql-searchable-transformer.tests.ts.snap @@ -1060,15 +1060,15 @@ $util.toJson($ListRequest) #if( !$util.isNullOrEmpty($ctx.stash.authFilter) ) #set( $filter = $ctx.stash.authFilter ) #if( !$util.isNullOrEmpty($ctx.args.filter) ) - #set( $filter = [{ + #set( $filter = { \\"bool\\": { - \\"must\\": [$ctx.stash.authFilter, $util.transform.toElasticsearchQueryDSL($ctx.args.filter)] + \\"must\\": [$ctx.stash.authFilter, $util.parseJson($util.transform.toElasticsearchQueryDSL($ctx.args.filter))] } -}] ) +} ) #end #else #if( !$util.isNullOrEmpty($ctx.args.filter) ) - #set( $filter = $ctx.args.filter ) + #set( $filter = $util.parseJson($util.transform.toElasticsearchQueryDSL($ctx.args.filter)) ) #end #end #if( $util.isNullOrEmpty($filter) ) diff --git a/packages/amplify-graphql-searchable-transformer/src/generate-resolver-vtl.ts b/packages/amplify-graphql-searchable-transformer/src/generate-resolver-vtl.ts index 9c93786731f..21520eec474 100644 --- a/packages/amplify-graphql-searchable-transformer/src/generate-resolver-vtl.ts +++ b/packages/amplify-graphql-searchable-transformer/src/generate-resolver-vtl.ts @@ -76,15 +76,21 @@ export function requestTemplate(primaryKey: string, nonKeywordFields: Expression not(isNullOrEmpty(ref('ctx.args.filter'))), set( ref('filter'), - list([ - obj({ - bool: obj({ must: list([ref('ctx.stash.authFilter'), ref('util.transform.toElasticsearchQueryDSL($ctx.args.filter)')]) }), + obj({ + bool: obj({ + must: list([ + ref('ctx.stash.authFilter'), + ref('util.parseJson($util.transform.toElasticsearchQueryDSL($ctx.args.filter))'), + ]), }), - ]), + }), ), ), ]), - iff(not(isNullOrEmpty(ref('ctx.args.filter'))), set(ref('filter'), ref('ctx.args.filter'))), + iff( + not(isNullOrEmpty(ref('ctx.args.filter'))), + set(ref('filter'), ref('util.parseJson($util.transform.toElasticsearchQueryDSL($ctx.args.filter))')), + ), ), iff(isNullOrEmpty(ref('filter')), set(ref('filter'), obj({ match_all: obj({}) }))), SearchableMappingTemplate.searchTemplate({ diff --git a/packages/graphql-transformers-e2e-tests/src/IAMHelper.ts b/packages/graphql-transformers-e2e-tests/src/IAMHelper.ts index 85a488c8986..e5371a64138 100644 --- a/packages/graphql-transformers-e2e-tests/src/IAMHelper.ts +++ b/packages/graphql-transformers-e2e-tests/src/IAMHelper.ts @@ -8,6 +8,48 @@ export class IAMHelper { }); } + /** + * Creates auth and unauth roles + */ + async createRoles(authRoleName: string, unauthRoleName: string): Promise<{ authRole: IAM.Role; unauthRole: IAM.Role }> { + const authRole = await this.client + .createRole({ + RoleName: authRoleName, + AssumeRolePolicyDocument: `{ + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Allow", + "Principal": { + "Federated": "cognito-identity.amazonaws.com" + }, + "Action": "sts:AssumeRoleWithWebIdentity" + } + ] + }`, + }) + .promise(); + const unauthRole = await this.client + .createRole({ + RoleName: unauthRoleName, + AssumeRolePolicyDocument: `{ + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Allow", + "Principal": { + "Federated": "cognito-identity.amazonaws.com" + }, + "Action": "sts:AssumeRoleWithWebIdentity" + } + ] + }`, + }) + .promise(); + + return { authRole: authRole.Role, unauthRole: unauthRole.Role }; + } + async createLambdaExecutionRole(name: string) { return await this.client .createRole({ diff --git a/packages/graphql-transformers-e2e-tests/src/__tests__/IndexWithAuthV2.e2e.test.ts b/packages/graphql-transformers-e2e-tests/src/__tests__/IndexWithAuthV2.e2e.test.ts new file mode 100644 index 00000000000..6480ac22034 --- /dev/null +++ b/packages/graphql-transformers-e2e-tests/src/__tests__/IndexWithAuthV2.e2e.test.ts @@ -0,0 +1,292 @@ +import { IndexTransformer, PrimaryKeyTransformer } from '@aws-amplify/graphql-index-transformer'; +import { ModelTransformer } from '@aws-amplify/graphql-model-transformer'; +import { AuthTransformer } from '@aws-amplify/graphql-auth-transformer'; +import { GraphQLTransform } from '@aws-amplify/graphql-transformer-core'; +import { ResourceConstants } from 'graphql-transformer-common'; +import { CloudFormationClient } from '../CloudFormationClient'; +import { Output } from 'aws-sdk/clients/cloudformation'; +import { GraphQLClient } from '../GraphQLClient'; +import { default as moment } from 'moment'; +import { cleanupStackAfterTest, deploy } from '../deployNestedStacks'; +import { S3Client } from '../S3Client'; +import { S3, CognitoIdentityServiceProvider as CognitoClient } from 'aws-sdk'; +import { + addUserToGroup, + authenticateUser, + configureAmplify, + createGroup, + createUserPool, + createUserPoolClient, + signupUser, +} from '../cognitoUtils'; + +jest.setTimeout(2000000); + +const AWS_REGION = 'us-west-2'; + +const cf = new CloudFormationClient(AWS_REGION); +const customS3Client = new S3Client(AWS_REGION); +const awsS3Client = new S3({ region: AWS_REGION }); +const cognitoClient = new CognitoClient({ apiVersion: '2016-04-19', region: AWS_REGION }); +const BUILD_TIMESTAMP = moment().format('YYYYMMDDHHmmss'); +const STACK_NAME = `IndexAuthTransformerTests-${BUILD_TIMESTAMP}`; +const BUCKET_NAME = `appsync-auth-index-transformer-test-bucket-${BUILD_TIMESTAMP}`; +const LOCAL_FS_BUILD_DIR = '/tmp/index_with_auth_transformer_tests/'; +const S3_ROOT_DIR_KEY = 'deployments'; + +let GRAPHQL_ENDPOINT = undefined; + +/** + * Client 1 is logged in and is a member of the Admin group. + */ +let GRAPHQL_CLIENT_1: GraphQLClient = undefined; + +/** + * Client 2 is logged in and is a member of the Devs group. + */ +let GRAPHQL_CLIENT_2: GraphQLClient = undefined; + +/** + * Client 3 is logged in and has no group memberships. + */ +let GRAPHQL_CLIENT_3: GraphQLClient = undefined; + +let USER_POOL_ID = undefined; + +const USERNAME1 = 'user1@test.com'; +const USERNAME2 = 'user2@test.com'; +const USERNAME3 = 'user3@test.com'; +const TMP_PASSWORD = 'Password123!'; +const REAL_PASSWORD = 'Password1234!'; + +const ADMIN_GROUP_NAME = 'Admin'; +const DEVS_GROUP_NAME = 'Devs'; +const PARTICIPANT_GROUP_NAME = 'Participant'; +const WATCHER_GROUP_NAME = 'Watcher'; + +beforeAll(async () => { + const validSchema = /* GraphQL */ ` + type Order @model @auth(rules: [{ allow: owner, ownerField: "customerEmail" }, { allow: groups, groups: ["Admin"] }]) { + customerEmail: String! @primaryKey(sortKeyFields: ["orderId"]) + createdAt: AWSDateTime + orderId: String! @index(name: "GSI", queryField: "ordersByOrderId") + } + `; + + try { + await awsS3Client.createBucket({ Bucket: BUCKET_NAME }).promise(); + } catch (e) { + console.warn(`Could not create bucket: ${e}`); + } + const userPoolResponse = await createUserPool(cognitoClient, `UserPool${STACK_NAME}`); + USER_POOL_ID = userPoolResponse.UserPool.Id; + const userPoolClientResponse = await createUserPoolClient(cognitoClient, USER_POOL_ID, `UserPool${STACK_NAME}`); + const userPoolClientId = userPoolClientResponse.UserPoolClient.ClientId; + + const transformer = new GraphQLTransform({ + authConfig: { + defaultAuthentication: { + authenticationType: 'AMAZON_COGNITO_USER_POOLS', + }, + additionalAuthenticationProviders: [], + }, + transformers: [ + new ModelTransformer(), + new PrimaryKeyTransformer(), + new IndexTransformer(), + new AuthTransformer({ addAwsIamAuthInOutputSchema: false }), + ], + }); + const out = transformer.transform(validSchema); + const finishedStack = await deploy( + customS3Client, + cf, + STACK_NAME, + out, + { AuthCognitoUserPoolId: USER_POOL_ID }, + LOCAL_FS_BUILD_DIR, + BUCKET_NAME, + S3_ROOT_DIR_KEY, + BUILD_TIMESTAMP, + ); + // Arbitrary wait to make sure everything is ready. + await cf.wait(5, () => Promise.resolve()); + expect(finishedStack).toBeDefined(); + const getApiEndpoint = outputValueSelector(ResourceConstants.OUTPUTS.GraphQLAPIEndpointOutput); + const getApiKey = outputValueSelector(ResourceConstants.OUTPUTS.GraphQLAPIApiKeyOutput); + const apiKey = getApiKey(finishedStack.Outputs); + GRAPHQL_ENDPOINT = getApiEndpoint(finishedStack.Outputs); + expect(apiKey).not.toBeTruthy(); + + // Verify we have all the details + expect(GRAPHQL_ENDPOINT).toBeTruthy(); + expect(USER_POOL_ID).toBeTruthy(); + expect(userPoolClientId).toBeTruthy(); + + // Configure Amplify, create users, and sign in. + configureAmplify(USER_POOL_ID, userPoolClientId); + + await signupUser(USER_POOL_ID, USERNAME1, TMP_PASSWORD); + await signupUser(USER_POOL_ID, USERNAME2, TMP_PASSWORD); + await signupUser(USER_POOL_ID, USERNAME3, TMP_PASSWORD); + await createGroup(USER_POOL_ID, ADMIN_GROUP_NAME); + await createGroup(USER_POOL_ID, PARTICIPANT_GROUP_NAME); + await createGroup(USER_POOL_ID, WATCHER_GROUP_NAME); + await createGroup(USER_POOL_ID, DEVS_GROUP_NAME); + await addUserToGroup(ADMIN_GROUP_NAME, USERNAME1, USER_POOL_ID); + await addUserToGroup(PARTICIPANT_GROUP_NAME, USERNAME1, USER_POOL_ID); + await addUserToGroup(WATCHER_GROUP_NAME, USERNAME1, USER_POOL_ID); + await addUserToGroup(DEVS_GROUP_NAME, USERNAME2, USER_POOL_ID); + const authResAfterGroup: any = await authenticateUser(USERNAME1, TMP_PASSWORD, REAL_PASSWORD); + + const idToken = authResAfterGroup.getIdToken().getJwtToken(); + GRAPHQL_CLIENT_1 = new GraphQLClient(GRAPHQL_ENDPOINT, { Authorization: idToken }); + + const authRes2AfterGroup: any = await authenticateUser(USERNAME2, TMP_PASSWORD, REAL_PASSWORD); + const idToken2 = authRes2AfterGroup.getIdToken().getJwtToken(); + GRAPHQL_CLIENT_2 = new GraphQLClient(GRAPHQL_ENDPOINT, { Authorization: idToken2 }); + + const authRes3: any = await authenticateUser(USERNAME3, TMP_PASSWORD, REAL_PASSWORD); + const idToken3 = authRes3.getIdToken().getJwtToken(); + GRAPHQL_CLIENT_3 = new GraphQLClient(GRAPHQL_ENDPOINT, { Authorization: idToken3 }); +}); + +afterAll(async () => { + await cleanupStackAfterTest(BUCKET_NAME, STACK_NAME, cf, { cognitoClient, userPoolId: USER_POOL_ID }); +}); + +/** + * Test queries below + */ + +test('Test createOrder mutation as admin', async () => { + const response = await createOrder(GRAPHQL_CLIENT_1, USERNAME2, 'order1'); + expect(response.data.createOrder.customerEmail).toBeDefined(); + expect(response.data.createOrder.orderId).toEqual('order1'); + expect(response.data.createOrder.createdAt).toBeDefined(); +}); + +test('Test createOrder mutation as owner', async () => { + const response = await createOrder(GRAPHQL_CLIENT_2, USERNAME2, 'order2'); + expect(response.data.createOrder.customerEmail).toBeDefined(); + expect(response.data.createOrder.orderId).toEqual('order2'); + expect(response.data.createOrder.createdAt).toBeDefined(); +}); + +test('Test createOrder mutation as owner', async () => { + const response = await createOrder(GRAPHQL_CLIENT_3, USERNAME2, 'order3'); + expect(response.data.createOrder).toBeNull(); + expect(response.errors).toHaveLength(1); +}); + +test('Test list orders as owner', async () => { + await createOrder(GRAPHQL_CLIENT_3, USERNAME3, 'owned1'); + await createOrder(GRAPHQL_CLIENT_3, USERNAME3, 'owned2'); + const listResponse = await listOrders(GRAPHQL_CLIENT_3, USERNAME3, { beginsWith: 'owned' }); + expect(listResponse.data.listOrders.items).toHaveLength(2); +}); + +test('Test list orders as non owner', async () => { + await createOrder(GRAPHQL_CLIENT_3, USERNAME3, 'unowned1'); + await createOrder(GRAPHQL_CLIENT_3, USERNAME3, 'unowned2'); + const listResponse = await listOrders(GRAPHQL_CLIENT_2, USERNAME3, { beginsWith: 'unowned' }); + expect(listResponse.data.listOrders).toBeNull(); + expect(listResponse.errors).toHaveLength(1); +}); + +test('Test get orders as owner', async () => { + await createOrder(GRAPHQL_CLIENT_2, USERNAME2, 'myobj'); + const getResponse = await getOrder(GRAPHQL_CLIENT_2, USERNAME2, 'myobj'); + expect(getResponse.data.getOrder.orderId).toEqual('myobj'); +}); + +test('Test get orders as non-owner', async () => { + await createOrder(GRAPHQL_CLIENT_2, USERNAME2, 'notmyobj'); + const getResponse = await getOrder(GRAPHQL_CLIENT_3, USERNAME2, 'notmyobj'); + expect(getResponse.data.getOrder).toBeNull(); + expect(getResponse.errors).toHaveLength(1); +}); + +test('Test query orders as owner', async () => { + await createOrder(GRAPHQL_CLIENT_3, USERNAME3, 'ownedby3a'); + const listResponse = await ordersByOrderId(GRAPHQL_CLIENT_3, 'ownedby3a'); + expect(listResponse.data.ordersByOrderId.items).toHaveLength(1); +}); + +test('Test query orders as non owner', async () => { + await createOrder(GRAPHQL_CLIENT_3, USERNAME3, 'notownedby2a'); + const listResponse = await ordersByOrderId(GRAPHQL_CLIENT_2, 'notownedby2a'); + expect(listResponse.data.ordersByOrderId.items).toHaveLength(0); +}); + +// helper functions +function outputValueSelector(key: string) { + return (outputs: Output[]) => { + const output = outputs.find((o: Output) => o.OutputKey === key); + return output ? output.OutputValue : null; + }; +} + +async function createOrder(client: GraphQLClient, customerEmail: string, orderId: string) { + const result = await client.query( + `mutation CreateOrder($input: CreateOrderInput!) { + createOrder(input: $input) { + customerEmail + orderId + createdAt + } + }`, + { + input: { customerEmail, orderId }, + }, + ); + return result; +} + +async function getOrder(client: GraphQLClient, customerEmail: string, orderId: string) { + const result = await client.query( + `query GetOrder($customerEmail: String!, $orderId: String!) { + getOrder(customerEmail: $customerEmail, orderId: $orderId) { + customerEmail + orderId + createdAt + } + }`, + { customerEmail, orderId }, + ); + return result; +} + +async function listOrders(client: GraphQLClient, customerEmail: string, orderId: { beginsWith: string }) { + const result = await client.query( + `query ListOrder($customerEmail: String, $orderId: ModelStringKeyConditionInput) { + listOrders(customerEmail: $customerEmail, orderId: $orderId) { + items { + customerEmail + orderId + createdAt + } + nextToken + } + }`, + { customerEmail, orderId }, + ); + return result; +} + +async function ordersByOrderId(client: GraphQLClient, orderId: string) { + const result = await client.query( + `query OrdersByOrderId($orderId: String!) { + ordersByOrderId(orderId: $orderId) { + items { + customerEmail + orderId + createdAt + } + nextToken + } + }`, + { orderId }, + ); + return result; +} diff --git a/packages/graphql-transformers-e2e-tests/src/__tests__/MultiAuthV2Transformer.e2e.test.ts b/packages/graphql-transformers-e2e-tests/src/__tests__/MultiAuthV2Transformer.e2e.test.ts new file mode 100644 index 00000000000..09a8d1d7ba1 --- /dev/null +++ b/packages/graphql-transformers-e2e-tests/src/__tests__/MultiAuthV2Transformer.e2e.test.ts @@ -0,0 +1,1064 @@ +import { Auth } from 'aws-amplify'; +import { IndexTransformer, PrimaryKeyTransformer } from '@aws-amplify/graphql-index-transformer'; +import { HasOneTransformer, HasManyTransformer } from '@aws-amplify/graphql-relational-transformer'; +import { ModelTransformer } from '@aws-amplify/graphql-model-transformer'; +import { AuthTransformer } from '@aws-amplify/graphql-auth-transformer'; +import { GraphQLTransform } from '@aws-amplify/graphql-transformer-core'; +import { Output } from 'aws-sdk/clients/cloudformation'; +import { CloudFormationClient } from '../CloudFormationClient'; +import { S3Client } from '../S3Client'; +import { cleanupStackAfterTest, deploy } from '../deployNestedStacks'; +import { CognitoIdentityServiceProvider as CognitoClient, S3, CognitoIdentity, IAM } from 'aws-sdk'; +import moment from 'moment'; +import AWSAppSyncClient, { AUTH_TYPE } from 'aws-appsync'; +import { createUserPool, createIdentityPool, createUserPoolClient, configureAmplify, authenticateUser, signupUser } from '../cognitoUtils'; +import { IAMHelper } from '../IAMHelper'; +import { ResourceConstants } from 'graphql-transformer-common'; +import gql from 'graphql-tag'; +import AWS = require('aws-sdk'); +import 'isomorphic-fetch'; + +// to deal with bug in cognito-identity-js +(global as any).fetch = require('node-fetch'); + +// To overcome of the way of how AmplifyJS picks up currentUserCredentials +const anyAWS = AWS as any; +if (anyAWS && anyAWS.config && anyAWS.config.credentials) { + delete anyAWS.config.credentials; +} + +jest.setTimeout(2000000); + +const AWS_REGION = 'us-west-2'; + +function outputValueSelector(key: string) { + return (outputs: Output[]) => { + const output = outputs.find((o: Output) => o.OutputKey === key); + return output ? output.OutputValue : null; + }; +} + +const cf = new CloudFormationClient(AWS_REGION); +const customS3Client = new S3Client(AWS_REGION); +const cognitoClient = new CognitoClient({ apiVersion: '2016-04-19', region: AWS_REGION }); +const identityClient = new CognitoIdentity({ apiVersion: '2014-06-30', region: AWS_REGION }); +const iamHelper = new IAMHelper(AWS_REGION); +const awsS3Client = new S3({ region: AWS_REGION }); + +// stack info +const BUILD_TIMESTAMP = moment().format('YYYYMMDDHHmmss'); +const STACK_NAME = `MultiAuthV2TransformerTests-${BUILD_TIMESTAMP}`; +const BUCKET_NAME = `appsync-multi-auth-v2-transformer-test-bucket-${BUILD_TIMESTAMP}`; +const LOCAL_FS_BUILD_DIR = '/tmp/multi_authv2_transformer_tests/'; +const S3_ROOT_DIR_KEY = 'deployments'; +const AUTH_ROLE_NAME = `${STACK_NAME}-authRole`; +const UNAUTH_ROLE_NAME = `${STACK_NAME}-unauthRole`; +let USER_POOL_ID: string; +let IDENTITY_POOL_ID: string; +let GRAPHQL_ENDPOINT: string; +let APIKEY_GRAPHQL_CLIENT: AWSAppSyncClient = undefined; +let USER_POOL_AUTH_CLIENT: AWSAppSyncClient = undefined; +let IAM_UNAUTHCLIENT: AWSAppSyncClient = undefined; +let IAM_AUTHCLIENT: AWSAppSyncClient = undefined; + +const USERNAME1 = 'user1@test.com'; +const TMP_PASSWORD = 'Password123!'; +const REAL_PASSWORD = 'Password1234!'; + +beforeAll(async () => { + const validSchema = ` + # Allow anyone to access. This is translated into API_KEY. + type PostPublic @model @auth(rules: [{ allow: public }]) { + id: ID! + title: String + } + + # Allow anyone to access. This is translated to IAM with unauth role. + type PostPublicIAM @model @auth(rules: [{ allow: public, provider: iam }]) { + id: ID! + title: String + } + + # Allow anyone with a valid Amazon Cognito UserPools JWT to access. + type PostPrivate @model @auth(rules: [{ allow: private }]) { + id: ID! + title: String + } + + # Allow anyone with a sigv4 signed request with relevant policy to access. + type PostPrivateIAM @model @auth(rules: [{ allow: private, provider: iam }]) { + id: ID! + title: String + } + + # I have a model that is protected by userPools by default. + # I want to call createPost from my lambda. + type PostOwnerIAM + @model + @auth( + rules: [ + # The cognito user pool owner can CRUD. + { allow: owner } + # A lambda function using IAM can call Mutation.createPost. + { allow: private, provider: iam, operations: [create] } + ] + ) { + id: ID! + title: String + owner: String + } + + type PostSecretFieldIAM + @model + @auth( + rules: [ + # The cognito user pool and can CRUD. + { allow: private } + # iam user can also have CRUD + { allow: private, provider: iam } + ] + ) { + id: ID + title: String + owner: String + secret: String + @auth( + rules: [ + # Only a lambda function using IAM can create/read/update this field + { allow: private, provider: iam, operations: [create,read,update] } + ] + ) + } + + type PostConnection @model @auth(rules: [{ allow: public }]) { + id: ID! + title: String! + comments: [CommentConnection] @hasMany + } + + # allow access via cognito user pools + type CommentConnection @model @auth(rules: [{ allow: private }]) { + id: ID! + content: String! + post: PostConnection @hasOne + } + + type PostIAMWithKeys + @model + @auth( + rules: [ + # API Key can CRUD + { allow: public } + # IAM can read + { allow: public, provider: iam, operations: [read] } + ] + ) { + id: ID! + title: String + type: String + @index( + name: "byDate" + sortKeyFields: ["date"] + queryField: "getPostIAMWithKeysByDate" + ) + date: AWSDateTime + } + + # This type is for the managed policy slicing, only deployment test in this e2e + type TodoWithExtraLongLongLongLongLongLongLongLongLongLongLongLongLongLongLongName + @model(subscriptions: null) + @auth(rules: [{ allow: private, provider: iam }]) { + id: ID! + namenamenamenamenamenamenamenamenamenamenamenamenamenamename001: String! + @auth(rules: [{ allow: private, provider: iam }]) + namenamenamenamenamenamenamenamenamenamenamenamenamenamename002: String! + @auth(rules: [{ allow: private, provider: iam }]) + namenamenamenamenamenamenamenamenamenamenamenamenamenamename003: String! + @auth(rules: [{ allow: private, provider: iam }]) + namenamenamenamenamenamenamenamenamenamenamenamenamenamename004: String! + @auth(rules: [{ allow: private, provider: iam }]) + namenamenamenamenamenamenamenamenamenamenamenamenamenamename005: String! + @auth(rules: [{ allow: private, provider: iam }]) + namenamenamenamenamenamenamenamenamenamenamenamenamenamename006: String! + @auth(rules: [{ allow: private, provider: iam }]) + namenamenamenamenamenamenamenamenamenamenamenamenamenamename007: String! + @auth(rules: [{ allow: private, provider: iam }]) + namenamenamenamenamenamenamenamenamenamenamenamenamenamename008: String! + @auth(rules: [{ allow: private, provider: iam }]) + namenamenamenamenamenamenamenamenamenamenamenamenamenamename009: String! + @auth(rules: [{ allow: private, provider: iam }]) + namenamenamenamenamenamenamenamenamenamenamenamenamenamename010: String! + @auth(rules: [{ allow: private, provider: iam }]) + namenamenamenamenamenamenamenamenamenamenamenamenamenamename011: String! + @auth(rules: [{ allow: private, provider: iam }]) + namenamenamenamenamenamenamenamenamenamenamenamenamenamename012: String! + @auth(rules: [{ allow: private, provider: iam }]) + namenamenamenamenamenamenamenamenamenamenamenamenamenamename013: String! + @auth(rules: [{ allow: private, provider: iam }]) + namenamenamenamenamenamenamenamenamenamenamenamenamenamename014: String! + @auth(rules: [{ allow: private, provider: iam }]) + namenamenamenamenamenamenamenamenamenamenamenamenamenamename015: String! + @auth(rules: [{ allow: private, provider: iam }]) + namenamenamenamenamenamenamenamenamenamenamenamenamenamename016: String! + @auth(rules: [{ allow: private, provider: iam }]) + namenamenamenamenamenamenamenamenamenamenamenamenamenamename017: String! + @auth(rules: [{ allow: private, provider: iam }]) + namenamenamenamenamenamenamenamenamenamenamenamenamenamename018: String! + @auth(rules: [{ allow: private, provider: iam }]) + namenamenamenamenamenamenamenamenamenamenamenamenamenamename019: String! + @auth(rules: [{ allow: private, provider: iam }]) + namenamenamenamenamenamenamenamenamenamenamenamenamenamename020: String! + @auth(rules: [{ allow: private, provider: iam }]) + namenamenamenamenamenamenamenamenamenamenamenamenamenamename021: String! + @auth(rules: [{ allow: private, provider: iam }]) + namenamenamenamenamenamenamenamenamenamenamenamenamenamename022: String! + @auth(rules: [{ allow: private, provider: iam }]) + namenamenamenamenamenamenamenamenamenamenamenamenamenamename023: String! + @auth(rules: [{ allow: private, provider: iam }]) + namenamenamenamenamenamenamenamenamenamenamenamenamenamename024: String! + @auth(rules: [{ allow: private, provider: iam }]) + namenamenamenamenamenamenamenamenamenamenamenamenamenamename025: String! + @auth(rules: [{ allow: private, provider: iam }]) + namenamenamenamenamenamenamenamenamenamenamenamenamenamename026: String! + @auth(rules: [{ allow: private, provider: iam }]) + namenamenamenamenamenamenamenamenamenamenamenamenamenamename027: String! + @auth(rules: [{ allow: private, provider: iam }]) + namenamenamenamenamenamenamenamenamenamenamenamenamenamename028: String! + @auth(rules: [{ allow: private, provider: iam }]) + namenamenamenamenamenamenamenamenamenamenamenamenamenamename029: String! + @auth(rules: [{ allow: private, provider: iam }]) + namenamenamenamenamenamenamenamenamenamenamenamenamenamename030: String! + @auth(rules: [{ allow: private, provider: iam }]) + namenamenamenamenamenamenamenamenamenamenamenamenamenamename031: String! + @auth(rules: [{ allow: private, provider: iam }]) + namenamenamenamenamenamenamenamenamenamenamenamenamenamename032: String! + @auth(rules: [{ allow: private, provider: iam }]) + namenamenamenamenamenamenamenamenamenamenamenamenamenamename033: String! + @auth(rules: [{ allow: private, provider: iam }]) + namenamenamenamenamenamenamenamenamenamenamenamenamenamename034: String! + @auth(rules: [{ allow: private, provider: iam }]) + namenamenamenamenamenamenamenamenamenamenamenamenamenamename035: String! + @auth(rules: [{ allow: private, provider: iam }]) + namenamenamenamenamenamenamenamenamenamenamenamenamenamename036: String! + @auth(rules: [{ allow: private, provider: iam }]) + namenamenamenamenamenamenamenamenamenamenamenamenamenamename037: String! + @auth(rules: [{ allow: private, provider: iam }]) + namenamenamenamenamenamenamenamenamenamenamenamenamenamename038: String! + @auth(rules: [{ allow: private, provider: iam }]) + namenamenamenamenamenamenamenamenamenamenamenamenamenamename039: String! + @auth(rules: [{ allow: private, provider: iam }]) + namenamenamenamenamenamenamenamenamenamenamenamenamenamename040: String! + @auth(rules: [{ allow: private, provider: iam }]) + namenamenamenamenamenamenamenamenamenamenamenamenamenamename041: String! + @auth(rules: [{ allow: private, provider: iam }]) + namenamenamenamenamenamenamenamenamenamenamenamenamenamename042: String! + @auth(rules: [{ allow: private, provider: iam }]) + namenamenamenamenamenamenamenamenamenamenamenamenamenamename043: String! + @auth(rules: [{ allow: private, provider: iam }]) + namenamenamenamenamenamenamenamenamenamenamenamenamenamename044: String! + @auth(rules: [{ allow: private, provider: iam }]) + namenamenamenamenamenamenamenamenamenamenamenamenamenamename045: String! + @auth(rules: [{ allow: private, provider: iam }]) + namenamenamenamenamenamenamenamenamenamenamenamenamenamename046: String! + @auth(rules: [{ allow: private, provider: iam }]) + namenamenamenamenamenamenamenamenamenamenamenamenamenamename047: String! + @auth(rules: [{ allow: private, provider: iam }]) + namenamenamenamenamenamenamenamenamenamenamenamenamenamename048: String! + @auth(rules: [{ allow: private, provider: iam }]) + namenamenamenamenamenamenamenamenamenamenamenamenamenamename049: String! + @auth(rules: [{ allow: private, provider: iam }]) + description: String + } + `; + + const transformer = new GraphQLTransform({ + authConfig: { + defaultAuthentication: { + authenticationType: 'AMAZON_COGNITO_USER_POOLS', + }, + additionalAuthenticationProviders: [ + { + authenticationType: 'API_KEY', + apiKeyConfig: { + description: 'E2E Test API Key', + apiKeyExpirationDays: 300, + }, + }, + { + authenticationType: 'AWS_IAM', + }, + ], + }, + transformers: [ + new ModelTransformer(), + new IndexTransformer(), + new PrimaryKeyTransformer(), + new HasOneTransformer(), + new HasManyTransformer(), + new AuthTransformer({ + addAwsIamAuthInOutputSchema: false, + }), + ], + }); + + try { + await awsS3Client.createBucket({ Bucket: BUCKET_NAME }).promise(); + } catch (e) { + console.error(`Failed to create bucket: ${e}`); + } + + // create userpool + const userPoolResponse = await createUserPool(cognitoClient, `UserPool${STACK_NAME}`); + USER_POOL_ID = userPoolResponse.UserPool.Id; + const userPoolClientResponse = await createUserPoolClient(cognitoClient, USER_POOL_ID, `UserPool${STACK_NAME}`); + const userPoolClientId = userPoolClientResponse.UserPoolClient.ClientId; + // create auth and unauthroles + const roles = await iamHelper.createRoles(AUTH_ROLE_NAME, UNAUTH_ROLE_NAME); + // create identitypool + IDENTITY_POOL_ID = await createIdentityPool(identityClient, `IdentityPool${STACK_NAME}`, { + authRoleArn: roles.authRole.Arn, + unauthRoleArn: roles.unauthRole.Arn, + providerName: `cognito-idp.${AWS_REGION}.amazonaws.com/${USER_POOL_ID}`, + clientId: userPoolClientId, + }); + const out = transformer.transform(validSchema); + const finishedStack = await deploy( + customS3Client, + cf, + STACK_NAME, + out, + { AuthCognitoUserPoolId: USER_POOL_ID, authRoleName: roles.authRole.RoleName, unauthRoleName: roles.unauthRole.RoleName }, + LOCAL_FS_BUILD_DIR, + BUCKET_NAME, + S3_ROOT_DIR_KEY, + BUILD_TIMESTAMP, + ); + // Wait for any propagation to avoid random + // "The security token included in the request is invalid" errors + await new Promise(res => setTimeout(() => res(), 5000)); + + expect(finishedStack).toBeDefined(); + const getApiEndpoint = outputValueSelector(ResourceConstants.OUTPUTS.GraphQLAPIEndpointOutput); + const getApiKey = outputValueSelector(ResourceConstants.OUTPUTS.GraphQLAPIApiKeyOutput); + GRAPHQL_ENDPOINT = getApiEndpoint(finishedStack.Outputs); + + const apiKey = getApiKey(finishedStack.Outputs); + expect(apiKey).toBeTruthy(); + + // Verify we have all the details + expect(GRAPHQL_ENDPOINT).toBeTruthy(); + expect(USER_POOL_ID).toBeTruthy(); + expect(userPoolClientId).toBeTruthy(); + + // Configure Amplify, create users, and sign in + configureAmplify(USER_POOL_ID, userPoolClientId, IDENTITY_POOL_ID); + + const unauthCreds = await Auth.currentCredentials(); + IAM_UNAUTHCLIENT = new AWSAppSyncClient({ + url: GRAPHQL_ENDPOINT, + region: AWS_REGION, + auth: { + type: AUTH_TYPE.AWS_IAM, + credentials: { + accessKeyId: unauthCreds.accessKeyId, + secretAccessKey: unauthCreds.secretAccessKey, + sessionToken: unauthCreds.sessionToken, + }, + }, + disableOffline: true, + }); + + await signupUser(USER_POOL_ID, USERNAME1, TMP_PASSWORD); + const authRes = await authenticateUser(USERNAME1, TMP_PASSWORD, REAL_PASSWORD); + const idToken = authRes.getIdToken().getJwtToken(); + + USER_POOL_AUTH_CLIENT = new AWSAppSyncClient({ + url: GRAPHQL_ENDPOINT, + region: AWS_REGION, + auth: { + type: AUTH_TYPE.AMAZON_COGNITO_USER_POOLS, + jwtToken: () => idToken, + }, + disableOffline: true, + }); + + await Auth.signIn(USERNAME1, REAL_PASSWORD); + const authCreds = await Auth.currentCredentials(); + IAM_AUTHCLIENT = new AWSAppSyncClient({ + url: GRAPHQL_ENDPOINT, + region: AWS_REGION, + auth: { + type: AUTH_TYPE.AWS_IAM, + credentials: { + accessKeyId: authCreds.accessKeyId, + secretAccessKey: authCreds.secretAccessKey, + sessionToken: authCreds.sessionToken, + }, + }, + disableOffline: true, + }); + + APIKEY_GRAPHQL_CLIENT = new AWSAppSyncClient({ + url: GRAPHQL_ENDPOINT, + region: AWS_REGION, + auth: { + type: AUTH_TYPE.API_KEY, + apiKey: apiKey, + }, + disableOffline: true, + }); +}); + +afterAll(async () => { + await cleanupStackAfterTest( + BUCKET_NAME, + STACK_NAME, + cf, + { cognitoClient, userPoolId: USER_POOL_ID }, + { identityClient, identityPoolId: IDENTITY_POOL_ID }, + ); + try { + await iamHelper.deleteRole(AUTH_ROLE_NAME); + } catch (e) { + console.warn(`Error during auth role cleanup ${e}`); + } + try { + await iamHelper.deleteRole(UNAUTH_ROLE_NAME); + } catch (e) { + console.warn(`Error during unauth role cleanup ${e}`); + } +}); + +test("test 'public' authStrategy", async () => { + try { + const createMutation = gql` + mutation { + createPostPublic(input: { title: "Hello, World!" }) { + id + title + } + } + `; + + const getQuery = gql` + query ($id: ID!) { + getPostPublic(id: $id) { + id + title + } + } + `; + const response = await APIKEY_GRAPHQL_CLIENT.mutate({ + mutation: createMutation, + fetchPolicy: 'no-cache', + }); + expect(response.data.createPostPublic.id).toBeDefined(); + expect(response.data.createPostPublic.title).toEqual('Hello, World!'); + const postId = response.data.createPostPublic.id; + + // user authenticated with user pools should fail + await expect( + USER_POOL_AUTH_CLIENT.query({ + query: getQuery, + variables: { id: postId }, + fetchPolicy: 'no-cache', + }), + ).rejects.toThrow('GraphQL error: Not Authorized to access getPostPublic on type Query'); + + // user authenticated with iam should fail + // should be a 401 error since the unauth role does not have a policy to getPostPublic + await expect( + IAM_UNAUTHCLIENT.query({ + query: getQuery, + variables: { id: postId }, + fetchPolicy: 'no-cache', + }), + ).rejects.toThrow('Network error: Response not successful: Received status code 401'); + } catch (err) { + expect(err).not.toBeDefined(); + } +}); + +test(`Test 'public' provider: 'iam' authStrategy`, async () => { + try { + const createMutation = gql` + mutation { + createPostPublicIAM(input: { title: "Hello, World!" }) { + id + title + } + } + `; + + const getQuery = gql` + query ($id: ID!) { + getPostPublicIAM(id: $id) { + id + title + } + } + `; + + const response = await IAM_UNAUTHCLIENT.mutate({ + mutation: createMutation, + fetchPolicy: 'no-cache', + }); + expect(response.data.createPostPublicIAM.id).toBeDefined(); + expect(response.data.createPostPublicIAM.title).toEqual('Hello, World!'); + + const postId = response.data.createPostPublicIAM.id; + + // Authenticate User Pools user must fail + await expect( + USER_POOL_AUTH_CLIENT.query({ + query: getQuery, + fetchPolicy: 'no-cache', + variables: { + id: postId, + }, + }), + ).rejects.toThrow('GraphQL error: Not Authorized to access getPostPublicIAM on type Query'); + + // API Key must fail + await expect( + APIKEY_GRAPHQL_CLIENT.query({ + query: getQuery, + fetchPolicy: 'no-cache', + variables: { + id: postId, + }, + }), + ).rejects.toThrow('GraphQL error: Not Authorized to access getPostPublicIAM on type Query'); + } catch (e) { + expect(e).not.toBeDefined(); + } +}); + +test(`Test 'private' authStrategy`, async () => { + try { + const createMutation = gql` + mutation { + createPostPrivate(input: { title: "Hello, World!" }) { + id + title + } + } + `; + + const getQuery = gql` + query ($id: ID!) { + getPostPrivate(id: $id) { + id + title + } + } + `; + + const response = await USER_POOL_AUTH_CLIENT.mutate({ + mutation: createMutation, + fetchPolicy: 'no-cache', + }); + expect(response.data.createPostPrivate.id).toBeDefined(); + expect(response.data.createPostPrivate.title).toEqual('Hello, World!'); + + const postId = response.data.createPostPrivate.id; + + // Authenticate API Key fail + await expect( + APIKEY_GRAPHQL_CLIENT.query({ + query: getQuery, + fetchPolicy: 'no-cache', + variables: { + id: postId, + }, + }), + ).rejects.toThrow('GraphQL error: Not Authorized to access getPostPrivate on type Query'); + + // IAM with unauth role must fail + await expect( + IAM_UNAUTHCLIENT.query({ + query: getQuery, + fetchPolicy: 'no-cache', + variables: { + id: postId, + }, + }), + ).rejects.toThrowError('Network error: Response not successful: Received status code 401'); + } catch (e) { + expect(e).not.toBeDefined(); + } +}); + +test(`Test 'private' provider: 'iam' authStrategy`, async () => { + // This test reuses the unauth role, but any IAM credentials would work + // in real world scenarios, we've to see if provider override works. + try { + const createMutation = gql` + mutation { + createPostPrivateIAM(input: { title: "Hello, World!" }) { + id + title + } + } + `; + + const getQuery = gql` + query ($id: ID!) { + getPostPrivateIAM(id: $id) { + id + title + } + } + `; + + const response = await IAM_AUTHCLIENT.mutate({ + mutation: createMutation, + fetchPolicy: 'no-cache', + }); + expect(response.data.createPostPrivateIAM.id).toBeDefined(); + expect(response.data.createPostPrivateIAM.title).toEqual('Hello, World!'); + + const postId = response.data.createPostPrivateIAM.id; + + // Authenticate User Pools user must fail + await expect( + USER_POOL_AUTH_CLIENT.query({ + query: getQuery, + fetchPolicy: 'no-cache', + variables: { + id: postId, + }, + }), + ).rejects.toThrow('GraphQL error: Not Authorized to access getPostPrivateIAM on type Query'); + + // API Key must fail + await expect( + APIKEY_GRAPHQL_CLIENT.query({ + query: getQuery, + fetchPolicy: 'no-cache', + variables: { + id: postId, + }, + }), + ).rejects.toThrow('GraphQL error: Not Authorized to access getPostPrivateIAM on type Query'); + + // public iam user must fail + await expect( + IAM_UNAUTHCLIENT.query({ + query: getQuery, + fetchPolicy: 'no-cache', + variables: { + id: postId, + }, + }), + ).rejects.toThrow('Network error: Response not successful: Received status code 401'); + } catch (e) { + console.error(e); + expect(e).not.toBeDefined(); + } +}); + +test(`Test 'private' provider: 'iam' authStrategy`, async () => { + // This test reuses the unauth role, but any IAM credentials would work + // in real world scenarios, we've to see if provider override works. + + // - Create UserPool - Verify owner + // - Create IAM - Verify owner (blank) + // - Get UserPool owner - Verify success + // - Get UserPool non-owner - Verify deny + // - Get IAM - Verify deny + // - Get API Key - Verify deny + + try { + const createMutation = gql` + mutation { + createPostOwnerIAM(input: { title: "Hello, World!" }) { + id + title + owner + } + } + `; + + const getQuery = gql` + query ($id: ID!) { + getPostOwnerIAM(id: $id) { + id + title + owner + } + } + `; + + const response = await USER_POOL_AUTH_CLIENT.mutate({ + mutation: createMutation, + fetchPolicy: 'no-cache', + }); + expect(response.data.createPostOwnerIAM.id).toBeDefined(); + expect(response.data.createPostOwnerIAM.title).toEqual('Hello, World!'); + expect(response.data.createPostOwnerIAM.owner).toEqual(USERNAME1); + + const postIdOwner = response.data.createPostOwnerIAM.id; + + const responseIAM = await IAM_AUTHCLIENT.mutate({ + mutation: createMutation, + fetchPolicy: 'no-cache', + }); + expect(responseIAM.data.createPostOwnerIAM.id).toBeDefined(); + expect(responseIAM.data.createPostOwnerIAM.title).toEqual('Hello, World!'); + expect(responseIAM.data.createPostOwnerIAM.owner).toBeNull(); + + const postIdIAM = responseIAM.data.createPostOwnerIAM.id; + + const responseGetUserPool = await USER_POOL_AUTH_CLIENT.query({ + query: getQuery, + fetchPolicy: 'no-cache', + variables: { + id: postIdOwner, + }, + }); + + expect(responseGetUserPool.data.getPostOwnerIAM.id).toBeDefined(); + expect(responseGetUserPool.data.getPostOwnerIAM.title).toEqual('Hello, World!'); + expect(responseGetUserPool.data.getPostOwnerIAM.owner).toEqual(USERNAME1); + + await expect( + USER_POOL_AUTH_CLIENT.query({ + query: getQuery, + fetchPolicy: 'no-cache', + variables: { id: postIdIAM }, + }), + ).rejects.toThrow('GraphQL error: Not Authorized to access getPostOwnerIAM on type Query'); + + await expect( + IAM_UNAUTHCLIENT.query({ + query: getQuery, + fetchPolicy: 'no-cache', + variables: { id: postIdOwner }, + }), + ).rejects.toThrow('Network error: Response not successful: Received status code 401'); + + await expect( + APIKEY_GRAPHQL_CLIENT.query({ + query: getQuery, + variables: { id: postIdOwner }, + }), + ).rejects.toThrow('GraphQL error: Not Authorized to access getPostOwnerIAM on type Query'); + } catch (e) { + console.error(e); + expect(e).not.toBeDefined(); + } +}); + +describe(`Test IAM protected field operations`, () => { + // This test reuses the unauth role, but any IAM credentials would work + // in real world scenarios, we've to see if provider override works. + + const createMutation = gql` + mutation { + createPostSecretFieldIAM(input: { title: "Hello, World!" }) { + id + title + } + } + `; + + const createMutationWithSecret = gql` + mutation { + createPostSecretFieldIAM(input: { title: "Hello, World!", secret: "42" }) { + id + title + secret + } + } + `; + + const getQuery = gql` + query ($id: ID!) { + getPostSecretFieldIAM(id: $id) { + id + title + } + } + `; + + const getQueryWithSecret = gql` + query ($id: ID!) { + getPostSecretFieldIAM(id: $id) { + id + title + secret + } + } + `; + + let postIdNoSecret = ''; + let postIdSecret = ''; + + beforeAll(async () => { + try { + // - Create UserPool - no secret - Success + const response = await USER_POOL_AUTH_CLIENT.mutate({ + mutation: createMutation, + fetchPolicy: 'no-cache', + }); + + postIdNoSecret = response.data.createPostSecretFieldIAM.id; + + // - Create IAM - with secret - Success + const responseIAMSecret = await IAM_AUTHCLIENT.mutate({ + mutation: createMutationWithSecret, + fetchPolicy: 'no-cache', + }); + + postIdSecret = responseIAMSecret.data.createPostSecretFieldIAM.id; + } catch (e) { + expect(e).not.toBeDefined(); + } + }); + + it('Get UserPool - Succeed', async () => { + const responseGetUserPool = await USER_POOL_AUTH_CLIENT.query({ + query: getQuery, + fetchPolicy: 'no-cache', + variables: { + id: postIdNoSecret, + }, + }); + expect(responseGetUserPool.data.getPostSecretFieldIAM.id).toBeDefined(); + expect(responseGetUserPool.data.getPostSecretFieldIAM.title).toEqual('Hello, World!'); + }); + + it('Get UserPool with secret - Fail', async () => { + expect.assertions(1); + await expect( + USER_POOL_AUTH_CLIENT.query({ + query: getQueryWithSecret, + fetchPolicy: 'no-cache', + variables: { id: postIdSecret }, + }), + ).rejects.toThrow('GraphQL error: Not Authorized to access secret on type String'); + }); +}); + +describe(`IAM Tests`, () => { + const createMutation = gql` + mutation { + createPostIAMWithKeys(input: { title: "Hello, World!", type: "Post", date: "2019-01-01T00:00:00Z" }) { + id + title + type + date + } + } + `; + + const getPostIAMWithKeysByDate = gql` + query { + getPostIAMWithKeysByDate(type: "Post") { + items { + id + title + type + date + } + } + } + `; + + let postId = ''; + + beforeAll(async () => { + try { + // - Create API Key - Success + const response = await APIKEY_GRAPHQL_CLIENT.mutate({ + mutation: createMutation, + fetchPolicy: 'no-cache', + }); + postId = response.data.createPostIAMWithKeys.id; + } catch (e) { + expect(e).not.toBeDefined(); + } + }); + + it('Execute @key query - Succeed', async () => { + const response = await IAM_UNAUTHCLIENT.query({ + query: getPostIAMWithKeysByDate, + fetchPolicy: 'no-cache', + }); + expect(response.data.getPostIAMWithKeysByDate.items).toBeDefined(); + expect(response.data.getPostIAMWithKeysByDate.items.length).toEqual(1); + const post = response.data.getPostIAMWithKeysByDate.items[0]; + expect(post.id).toEqual(postId); + expect(post.title).toEqual('Hello, World!'); + expect(post.type).toEqual('Post'); + expect(post.date).toEqual('2019-01-01T00:00:00Z'); + }); +}); + +describe(`relational tests with @auth on type`, () => { + const createPostMutation = gql` + mutation { + createPostConnection(input: { title: "Hello, World!" }) { + id + title + } + } + `; + + const createCommentMutation = gql` + mutation ($postId: ID!) { + createCommentConnection(input: { content: "Comment", commentConnectionPostId: $postId }) { + id + content + } + } + `; + + const getPostQuery = gql` + query ($postId: ID!) { + getPostConnection(id: $postId) { + id + title + } + } + `; + + const getPostQueryWithComments = gql` + query ($postId: ID!) { + getPostConnection(id: $postId) { + id + title + comments { + items { + id + content + } + } + } + } + `; + + const getCommentQuery = gql` + query ($commentId: ID!) { + getCommentConnection(id: $commentId) { + id + content + } + } + `; + + const getCommentWithPostQuery = gql` + query ($commentId: ID!) { + getCommentConnection(id: $commentId) { + id + content + post { + id + title + } + } + } + `; + + let postId = ''; + let commentId = ''; + + beforeAll(async () => { + try { + // Add a comment with ApiKey - Succeed + const response = await APIKEY_GRAPHQL_CLIENT.mutate({ + mutation: createPostMutation, + fetchPolicy: 'no-cache', + }); + + postId = response.data.createPostConnection.id; + + // Add a comment with UserPool - Succeed + const commentResponse = await USER_POOL_AUTH_CLIENT.mutate({ + mutation: createCommentMutation, + fetchPolicy: 'no-cache', + variables: { + postId, + }, + }); + + commentId = commentResponse.data.createCommentConnection.id; + } catch (e) { + expect(e).not.toBeDefined(); + } + }); + + it('Create a Post with UserPool - Fail', async () => { + expect.assertions(1); + await expect( + USER_POOL_AUTH_CLIENT.mutate({ + mutation: createPostMutation, + fetchPolicy: 'no-cache', + }), + ).rejects.toThrow('GraphQL error: Not Authorized to access createPostConnection on type Mutation'); + }); + + it('Add a comment with ApiKey - Fail', async () => { + expect.assertions(1); + await expect( + APIKEY_GRAPHQL_CLIENT.mutate({ + mutation: createCommentMutation, + fetchPolicy: 'no-cache', + variables: { + postId, + }, + }), + ).rejects.toThrow('Not Authorized to access createCommentConnection on type Mutation'); + }); + + it('Get Post with ApiKey - Succeed', async () => { + const responseGetPost = await APIKEY_GRAPHQL_CLIENT.query({ + query: getPostQuery, + fetchPolicy: 'no-cache', + variables: { + postId, + }, + }); + expect(responseGetPost.data.getPostConnection.id).toEqual(postId); + expect(responseGetPost.data.getPostConnection.title).toEqual('Hello, World!'); + }); + + it('Get Post with UserPool - Fail', async () => { + expect.assertions(1); + await expect( + USER_POOL_AUTH_CLIENT.query({ + query: getPostQuery, + fetchPolicy: 'no-cache', + variables: { + postId, + }, + }), + ).rejects.toThrow('Not Authorized to access getPostConnection on type Query'); + }); + + it('Get Comment with UserPool - Succeed', async () => { + const responseGetComment = await USER_POOL_AUTH_CLIENT.query({ + query: getCommentQuery, + fetchPolicy: 'no-cache', + variables: { + commentId, + }, + }); + expect(responseGetComment.data.getCommentConnection.id).toEqual(commentId); + expect(responseGetComment.data.getCommentConnection.content).toEqual('Comment'); + }); + + it('Get Comment with ApiKey - Fail', async () => { + expect.assertions(1); + await expect( + APIKEY_GRAPHQL_CLIENT.query({ + query: getCommentQuery, + fetchPolicy: 'no-cache', + variables: { + commentId, + }, + }), + ).rejects.toThrow('Not Authorized to access getCommentConnection on type Query'); + }); +}); diff --git a/packages/graphql-transformers-e2e-tests/src/__tests__/NonModelAuthV2Function.e2e.test.ts b/packages/graphql-transformers-e2e-tests/src/__tests__/NonModelAuthV2Function.e2e.test.ts new file mode 100644 index 00000000000..19c8842ec45 --- /dev/null +++ b/packages/graphql-transformers-e2e-tests/src/__tests__/NonModelAuthV2Function.e2e.test.ts @@ -0,0 +1,258 @@ +import { AuthTransformer } from '@aws-amplify/graphql-auth-transformer'; +import { FunctionTransformer } from '@aws-amplify/graphql-function-transformer'; +import { ModelTransformer } from '@aws-amplify/graphql-model-transformer'; +import { Auth } from 'aws-amplify'; +import AWSAppSyncClient, { AUTH_TYPE } from 'aws-appsync'; +import { CognitoIdentity } from 'aws-sdk'; +import { Output } from 'aws-sdk/clients/cloudformation'; +import { default as CognitoClient } from 'aws-sdk/clients/cognitoidentityserviceprovider'; +import { default as S3 } from 'aws-sdk/clients/s3'; +import { ResourceConstants } from 'graphql-transformer-common'; +import gql from 'graphql-tag'; +import { GraphQLTransform } from '@aws-amplify/graphql-transformer-core'; +import 'isomorphic-fetch'; +import { default as moment } from 'moment'; +import { CloudFormationClient } from '../CloudFormationClient'; +import { configureAmplify, createIdentityPool, createUserPool, createUserPoolClient, signupUser, authenticateUser } from '../cognitoUtils'; +import { cleanupStackAfterTest, deploy } from '../deployNestedStacks'; +import { IAMHelper } from '../IAMHelper'; +import { LambdaHelper } from '../LambdaHelper'; +import { S3Client } from '../S3Client'; + +// to deal with bug in cognito-identity-js +(global as any).fetch = require('node-fetch'); + +jest.setTimeout(2000000); + +const REGION = 'us-west-2'; +const cf = new CloudFormationClient(REGION); +const identityClient = new CognitoIdentity({ apiVersion: '2014-06-30', region: REGION }); +const cognitoClient = new CognitoClient({ apiVersion: '2016-04-19', region: REGION }); +const customS3Client = new S3Client(REGION); +const awsS3Client = new S3({ region: REGION }); +const iamHelper = new IAMHelper(REGION); + +const BUILD_TIMESTAMP = moment().format('YYYYMMDDHHmmss'); +const STACK_NAME = `FunctionTransformerTestsV2-${BUILD_TIMESTAMP}`; +const BUCKET_NAME = `appsync-function-v2-transformer-test-bucket-${BUILD_TIMESTAMP}`; +const LOCAL_FS_BUILD_DIR = '/tmp/nonmodel_auth_function_v2_transformer_tests/'; +const S3_ROOT_DIR_KEY = 'deployments'; +const ECHO_FUNCTION_NAME = `long-prefix-e2e-test-functions-echo-dev-${BUILD_TIMESTAMP}`; +const LAMBDA_EXECUTION_ROLE_NAME = `amplify_e2e_tests_lambda_basic_${BUILD_TIMESTAMP}`; +const LAMBDA_EXECUTION_POLICY_NAME = `amplify_e2e_tests_lambda_basic_access_${BUILD_TIMESTAMP}`; +let LAMBDA_EXECUTION_POLICY_ARN = ''; +const AUTH_ROLE_NAME = `${STACK_NAME}-authRole`; +const UNAUTH_ROLE_NAME = `${STACK_NAME}-unauthRole`; +let IAM_AUTH_CLIENT: AWSAppSyncClient = undefined; +let USER_POOL_AUTH_CLIENT: AWSAppSyncClient = undefined; +let USER_POOL_ID: string; +let IDENTITY_POOL_ID: string; +let GRAPHQL_ENDPOINT: string; + +const USERNAME1 = 'user1@test.com'; + +const TMP_PASSWORD = 'Password123!'; +const REAL_PASSWORD = 'Password1234!'; + +const LAMBDA_HELPER = new LambdaHelper(); +const IAM_HELPER = new IAMHelper(); + +function outputValueSelector(key: string) { + return (outputs: Output[]) => { + const output = outputs.find((o: Output) => o.OutputKey === key); + return output ? output.OutputValue : null; + }; +} + +beforeAll(async () => { + try { + const role = await IAM_HELPER.createLambdaExecutionRole(LAMBDA_EXECUTION_ROLE_NAME); + await wait(5000); + const policy = await IAM_HELPER.createLambdaExecutionPolicy(LAMBDA_EXECUTION_POLICY_NAME); + await wait(5000); + LAMBDA_EXECUTION_POLICY_ARN = policy.Policy.Arn; + await IAM_HELPER.attachLambdaExecutionPolicy(policy.Policy.Arn, role.Role.RoleName); + await wait(10000); + await LAMBDA_HELPER.createFunction(ECHO_FUNCTION_NAME, role.Role.Arn, 'echoResolverFunction'); + } catch (e) { + console.warn(`Could not setup function: ${e}`); + } + + const validSchema = ` + type Query { + echo(msg: String!): String! @function(name: "${ECHO_FUNCTION_NAME}") @auth (rules: [{ allow: private, provider: iam }]) + } + `; + + try { + await awsS3Client.createBucket({ Bucket: BUCKET_NAME }).promise(); + } catch (e) { + console.warn(`Could not create bucket: ${e}`); + } + + const transformer = new GraphQLTransform({ + authConfig: { + defaultAuthentication: { + authenticationType: 'AMAZON_COGNITO_USER_POOLS', + }, + additionalAuthenticationProviders: [ + { + authenticationType: 'API_KEY', + apiKeyConfig: { + description: 'E2E Test API Key', + apiKeyExpirationDays: 300, + }, + }, + { + authenticationType: 'AWS_IAM', + }, + ], + }, + transformers: [ + new ModelTransformer(), + new FunctionTransformer(), + new AuthTransformer({ + addAwsIamAuthInOutputSchema: false, + }), + ], + }); + const out = transformer.transform(validSchema); + + // create userpool + const userPoolResponse = await createUserPool(cognitoClient, `UserPool${STACK_NAME}`); + USER_POOL_ID = userPoolResponse.UserPool.Id; + const userPoolClientResponse = await createUserPoolClient(cognitoClient, USER_POOL_ID, `UserPool${STACK_NAME}`); + const userPoolClientId = userPoolClientResponse.UserPoolClient.ClientId; + // create auth and unauth roles + const roles = await iamHelper.createRoles(AUTH_ROLE_NAME, UNAUTH_ROLE_NAME); + // create identity pool + IDENTITY_POOL_ID = await createIdentityPool(identityClient, `IdentityPool${STACK_NAME}`, { + authRoleArn: roles.authRole.Arn, + unauthRoleArn: roles.unauthRole.Arn, + providerName: `cognito-idp.${REGION}.amazonaws.com/${USER_POOL_ID}`, + clientId: userPoolClientId, + }); + + const finishedStack = await deploy( + customS3Client, + cf, + STACK_NAME, + out, + { AuthCognitoUserPoolId: USER_POOL_ID, authRoleName: roles.authRole.RoleName, unauthRoleName: roles.unauthRole.RoleName }, + LOCAL_FS_BUILD_DIR, + BUCKET_NAME, + S3_ROOT_DIR_KEY, + BUILD_TIMESTAMP, + ); + + // Arbitrary wait to make sure everything is ready. + await cf.wait(5, () => Promise.resolve()); + expect(finishedStack).toBeDefined(); + const getApiEndpoint = outputValueSelector(ResourceConstants.OUTPUTS.GraphQLAPIEndpointOutput); + const endpoint = getApiEndpoint(finishedStack.Outputs); + expect(endpoint).toBeDefined(); + GRAPHQL_ENDPOINT = getApiEndpoint(finishedStack.Outputs); + expect(GRAPHQL_ENDPOINT).toBeTruthy(); + + // Verify we have all the details + expect(USER_POOL_ID).toBeTruthy(); + expect(userPoolClientId).toBeTruthy(); + + // Configure Amplify, create users, and sign in. + configureAmplify(USER_POOL_ID, userPoolClientId, IDENTITY_POOL_ID); + + await signupUser(USER_POOL_ID, USERNAME1, TMP_PASSWORD); + const authRes = await authenticateUser(USERNAME1, TMP_PASSWORD, REAL_PASSWORD); + const idToken = authRes.getIdToken().getJwtToken(); + + USER_POOL_AUTH_CLIENT = new AWSAppSyncClient({ + url: endpoint, + region: REGION, + auth: { + type: AUTH_TYPE.AMAZON_COGNITO_USER_POOLS, + jwtToken: () => idToken, + }, + offlineConfig: { + keyPrefix: 'userPools', + }, + disableOffline: true, + }); + + await Auth.signIn(USERNAME1, REAL_PASSWORD); + const authCredentials = await Auth.currentCredentials(); + IAM_AUTH_CLIENT = new AWSAppSyncClient({ + url: GRAPHQL_ENDPOINT, + region: REGION, + auth: { + type: AUTH_TYPE.AWS_IAM, + credentials: authCredentials, + }, + disableOffline: true, + }); +}); + +afterAll(async () => { + await cleanupStackAfterTest( + BUCKET_NAME, + STACK_NAME, + cf, + { cognitoClient, userPoolId: USER_POOL_ID }, + { identityClient, identityPoolId: IDENTITY_POOL_ID }, + ); + + try { + await IAM_HELPER.deleteRole(AUTH_ROLE_NAME); + } catch (e) { + console.warn(`Error during auth role cleanup ${e}`); + } + try { + await IAM_HELPER.deleteRole(UNAUTH_ROLE_NAME); + } catch (e) { + console.warn(`Error during unauth role cleanup ${e}`); + } + + try { + await LAMBDA_HELPER.deleteFunction(ECHO_FUNCTION_NAME); + } catch (e) { + console.warn(`Error during function cleanup: ${e}`); + } + try { + await IAM_HELPER.detachLambdaExecutionPolicy(LAMBDA_EXECUTION_POLICY_ARN, LAMBDA_EXECUTION_ROLE_NAME); + } catch (e) { + console.warn(`Error during policy dissociation: ${e}`); + } + try { + await IAM_HELPER.deleteRole(LAMBDA_EXECUTION_ROLE_NAME); + } catch (e) { + console.warn(`Error during role cleanup: ${e}`); + } + try { + await IAM_HELPER.deletePolicy(LAMBDA_EXECUTION_POLICY_ARN); + } catch (e) { + console.warn(`Error during policy cleanup: ${e}`); + } +}); + +/** + * Test queries below + */ +test('Test calling echo function as a user via IAM', async () => { + const query = gql` + query { + echo(msg: "Hello") + } + `; + + const response = await IAM_AUTH_CLIENT.query<{ echo: string }>({ + query, + fetchPolicy: 'no-cache', + }); + + expect(response.data.echo).toEqual('Hello'); +}); + +function wait(ms: number) { + return new Promise((resolve, reject) => { + setTimeout(() => resolve(), ms); + }); +} diff --git a/packages/graphql-transformers-e2e-tests/src/__tests__/PerFieldAuthV2Transformer.e2e.test.ts b/packages/graphql-transformers-e2e-tests/src/__tests__/PerFieldAuthV2Transformer.e2e.test.ts new file mode 100644 index 00000000000..6126e08fc93 --- /dev/null +++ b/packages/graphql-transformers-e2e-tests/src/__tests__/PerFieldAuthV2Transformer.e2e.test.ts @@ -0,0 +1,624 @@ +import { GraphQLTransform } from '@aws-amplify/graphql-transformer-core'; +import { ModelTransformer } from '@aws-amplify/graphql-model-transformer'; +import { AuthTransformer } from '@aws-amplify/graphql-auth-transformer'; +import { ResourceConstants } from 'graphql-transformer-common'; +import { CloudFormationClient } from '../CloudFormationClient'; +import { Output } from 'aws-sdk/clients/cloudformation'; +import { CreateBucketRequest } from 'aws-sdk/clients/s3'; +import { CognitoIdentityServiceProvider as CognitoClient, S3 } from 'aws-sdk'; +import { GraphQLClient } from '../GraphQLClient'; +import { S3Client } from '../S3Client'; +import { cleanupStackAfterTest, deploy } from '../deployNestedStacks'; +import { default as moment } from 'moment'; +import * as fs from 'fs'; +import { + createUserPool, + createUserPoolClient, + createGroup, + addUserToGroup, + configureAmplify, + signupUser, + authenticateUser, +} from '../cognitoUtils'; +import 'isomorphic-fetch'; + +// to deal with bug in cognito-identity-js +(global as any).fetch = require('node-fetch'); + +jest.setTimeout(2000000); + +const AWS_REGION = 'us-west-2'; + +const cf = new CloudFormationClient(AWS_REGION); +const BUILD_TIMESTAMP = moment().format('YYYYMMDDHHmmss'); +const STACK_NAME = `PerFieldAuthV2Tests-${BUILD_TIMESTAMP}`; +const BUCKET_NAME = `per-field-authv2-tests-bucket-${BUILD_TIMESTAMP}`; +const LOCAL_BUILD_ROOT = '/tmp/per_field_authv2_tests/'; +const DEPLOYMENT_ROOT_KEY = 'deployments'; + +let GRAPHQL_ENDPOINT = undefined; + +/** + * Client 1 is logged in and is a member of the Admin group. + */ +let GRAPHQL_CLIENT_1 = undefined; + +/** + * Client 2 is logged in and is a member of the Devs group. + */ +let GRAPHQL_CLIENT_2 = undefined; + +/** + * Client 3 is logged in and has no group memberships. + */ +let GRAPHQL_CLIENT_3 = undefined; + +let USER_POOL_ID = undefined; + +const USERNAME1 = 'user1@test.com'; +const USERNAME2 = 'user2@test.com'; +const USERNAME3 = 'user3@test.com'; +const TMP_PASSWORD = 'Password123!'; +const REAL_PASSWORD = 'Password1234!'; + +const ADMIN_GROUP_NAME = 'Admin'; +const DEVS_GROUP_NAME = 'Devs'; +const PARTICIPANT_GROUP_NAME = 'Participant'; +const WATCHER_GROUP_NAME = 'Watcher'; +const INSTRUCTOR_GROUP_NAME = 'Instructor'; + +const cognitoClient = new CognitoClient({ apiVersion: '2016-04-19', region: 'us-west-2' }); +const customS3Client = new S3Client('us-west-2'); +const awsS3Client = new S3({ region: 'us-west-2' }); + +function outputValueSelector(key: string) { + return (outputs: Output[]) => { + const output = outputs.find((o: Output) => o.OutputKey === key); + return output ? output.OutputValue : null; + }; +} + +beforeAll(async () => { + // Create a stack for the post model with auth enabled. + if (!fs.existsSync(LOCAL_BUILD_ROOT)) { + fs.mkdirSync(LOCAL_BUILD_ROOT); + } + try { + await awsS3Client.createBucket({ Bucket: BUCKET_NAME }).promise(); + } catch (e) { + console.warn(`Could not create bucket: ${e}`); + } + const validSchema = ` + # Owners may update their owned records. + # Admins may create Employee records. + # Any authenticated user may view Employee ids & e_mails. + # Owners and members of "Admin" group may see employee salaries. + # Owners of "Admin" group may create and update employee salaries. + type Employee + @model(subscriptions: { level: public }) + @auth( + rules: [ + { allow: groups, groups: ["Admin"], operations: [create, read, update] } + { allow: private, operations: [read] } + { allow: owner, ownerField: "e_mail", operations: [read, update] } + ] + ) { + id: ID + # bio and notes are the only field an owner can update + bio: String + + # Fields with ownership conditions take precendence to the Object @auth. + # That means that both the @auth on Object AND the @auth on the field must + # be satisfied. + + # Owners & "Admin"s may view employee e_mail addresses. Only "Admin"s may create/update. + # TODO: { allow: authenticated } would be useful here so that any employee could view. + # Should also allow creation of underscore fields + e_mail: String + @auth( + rules: [ + { allow: groups, groups: ["Admin"], operations: [create, update, read] } + { allow: owner, ownerField: "e_mail", operations: [read] } + ] + ) + + # The owner & "Admin"s may view the salary. Only "Admins" may create/update. + salary: Int + @auth( + rules: [ + { allow: groups, groups: ["Admin"], operations: [create,update, read] } + { allow: owner, ownerField: "e_mail", operations: [read] } + ] + ) + + # The delete operation means you cannot update the value to "null" or "undefined". + # Since delete operations are at the object level, this actually adds auth rules to the update mutation. + notes: String + @auth( + rules: [ + { allow: groups, groups: ["Admin"], operations: [create, read] }, + { + allow: owner + ownerField: "e_mail" + operations: [read, update, delete] + } + ] + ) + } + + type Student @model + @auth(rules: [ + {allow: owner} + {allow: groups, groups: ["Instructor"]} + ]){ + id: String, + name: String, + bio: String, + notes: String @auth(rules: [{allow: owner}]) + } + + type Post @model + @auth(rules: [ + { allow: groups, groups: ["Admin"] }, + { allow: owner, ownerField: "owner1", operations: [read, create] }]) + { + id: ID + owner1: String @auth(rules: [ + { allow: groups, groups: ["Admin"], operations: [create, read, delete] }, + { allow: owner, ownerField: "owner1", operations: [read, create] } + ]) + text: String @auth(rules: [ + { allow: groups, groups: ["Admin"], operations: [create, read] }, + { allow: owner, ownerField: "owner1", operations : [read, update]}]) + }`; + const transformer = new GraphQLTransform({ + authConfig: { + defaultAuthentication: { + authenticationType: 'AMAZON_COGNITO_USER_POOLS', + }, + additionalAuthenticationProviders: [], + }, + transformers: [ + new ModelTransformer(), + new AuthTransformer({ + addAwsIamAuthInOutputSchema: false, + }), + ], + }); + const userPoolResponse = await createUserPool(cognitoClient, `UserPool${STACK_NAME}`); + USER_POOL_ID = userPoolResponse.UserPool.Id; + const userPoolClientResponse = await createUserPoolClient(cognitoClient, USER_POOL_ID, `UserPool${STACK_NAME}`); + const userPoolClientId = userPoolClientResponse.UserPoolClient.ClientId; + try { + // Clean the bucket + const out = transformer.transform(validSchema); + + const finishedStack = await deploy( + customS3Client, + cf, + STACK_NAME, + out, + { AuthCognitoUserPoolId: USER_POOL_ID }, + LOCAL_BUILD_ROOT, + BUCKET_NAME, + DEPLOYMENT_ROOT_KEY, + BUILD_TIMESTAMP, + ); + expect(finishedStack).toBeDefined(); + const getApiEndpoint = outputValueSelector(ResourceConstants.OUTPUTS.GraphQLAPIEndpointOutput); + GRAPHQL_ENDPOINT = getApiEndpoint(finishedStack.Outputs); + + // Verify we have all the details + expect(GRAPHQL_ENDPOINT).toBeTruthy(); + expect(USER_POOL_ID).toBeTruthy(); + expect(userPoolClientId).toBeTruthy(); + + // Configure Amplify, create users, and sign in. + configureAmplify(USER_POOL_ID, userPoolClientId); + + await signupUser(USER_POOL_ID, USERNAME1, TMP_PASSWORD); + await signupUser(USER_POOL_ID, USERNAME2, TMP_PASSWORD); + await signupUser(USER_POOL_ID, USERNAME3, TMP_PASSWORD); + await createGroup(USER_POOL_ID, ADMIN_GROUP_NAME); + await createGroup(USER_POOL_ID, PARTICIPANT_GROUP_NAME); + await createGroup(USER_POOL_ID, WATCHER_GROUP_NAME); + await createGroup(USER_POOL_ID, DEVS_GROUP_NAME); + await createGroup(USER_POOL_ID, INSTRUCTOR_GROUP_NAME); + await addUserToGroup(ADMIN_GROUP_NAME, USERNAME1, USER_POOL_ID); + await addUserToGroup(PARTICIPANT_GROUP_NAME, USERNAME1, USER_POOL_ID); + await addUserToGroup(WATCHER_GROUP_NAME, USERNAME1, USER_POOL_ID); + await addUserToGroup(DEVS_GROUP_NAME, USERNAME2, USER_POOL_ID); + await addUserToGroup(INSTRUCTOR_GROUP_NAME, USERNAME1, USER_POOL_ID); + await addUserToGroup(INSTRUCTOR_GROUP_NAME, USERNAME2, USER_POOL_ID); + + const authResAfterGroup: any = await authenticateUser(USERNAME1, TMP_PASSWORD, REAL_PASSWORD); + const idToken = authResAfterGroup.getIdToken().getJwtToken(); + GRAPHQL_CLIENT_1 = new GraphQLClient(GRAPHQL_ENDPOINT, { Authorization: idToken }); + + const authRes2AfterGroup: any = await authenticateUser(USERNAME2, TMP_PASSWORD, REAL_PASSWORD); + const idToken2 = authRes2AfterGroup.getIdToken().getJwtToken(); + GRAPHQL_CLIENT_2 = new GraphQLClient(GRAPHQL_ENDPOINT, { Authorization: idToken2 }); + + const authRes3: any = await authenticateUser(USERNAME3, TMP_PASSWORD, REAL_PASSWORD); + const idToken3 = authRes3.getIdToken().getJwtToken(); + GRAPHQL_CLIENT_3 = new GraphQLClient(GRAPHQL_ENDPOINT, { Authorization: idToken3 }); + + // Wait for any propagation to avoid random + // "The security token included in the request is invalid" errors + await new Promise(res => setTimeout(() => res(), 5000)); + } catch (e) { + console.error(e); + expect(true).toEqual(false); + } +}); + +afterAll(async () => { + await cleanupStackAfterTest(BUCKET_NAME, STACK_NAME, cf, { cognitoClient, userPoolId: USER_POOL_ID }); +}); + +/** + * Tests + */ +test('Test that only Admins can create Employee records.', async () => { + const createUser1 = await GRAPHQL_CLIENT_1.query( + `mutation { + createEmployee(input: { e_mail: "user2@test.com", salary: 100 }) { + id + e_mail + salary + } + }`, + {}, + ); + expect(createUser1.data.createEmployee.e_mail).toEqual('user2@test.com'); + expect(createUser1.data.createEmployee.salary).toEqual(100); + + const tryToCreateAsNonAdmin = await GRAPHQL_CLIENT_2.query( + `mutation { + createEmployee(input: { e_mail: "user2@test.com", salary: 101 }) { + id + e_mail + salary + } + }`, + {}, + ); + expect(tryToCreateAsNonAdmin.data.createEmployee).toBeNull(); + expect(tryToCreateAsNonAdmin.errors).toHaveLength(1); + + const tryToCreateAsNonAdmin2 = await GRAPHQL_CLIENT_3.query( + `mutation { + createEmployee(input: { e_mail: "user2@test.com", salary: 101 }) { + id + e_mail + salary + } + }`, + {}, + ); + expect(tryToCreateAsNonAdmin2.data.createEmployee).toBeNull(); + expect(tryToCreateAsNonAdmin2.errors).toHaveLength(1); +}); + +test('Test that only Admins may update salary & e_mail.', async () => { + const createUser1 = await GRAPHQL_CLIENT_1.query( + `mutation { + createEmployee(input: { e_mail: "user2@test.com", salary: 100 }) { + id + e_mail + salary + } + }`, + {}, + ); + const employeeId = createUser1.data.createEmployee.id; + expect(employeeId).not.toBeNull(); + expect(createUser1.data.createEmployee.e_mail).toEqual('user2@test.com'); + expect(createUser1.data.createEmployee.salary).toEqual(100); + + const tryToUpdateAsNonAdmin = await GRAPHQL_CLIENT_2.query( + `mutation { + updateEmployee(input: { id: "${employeeId}", salary: 101 }) { + id + e_mail + salary + } + }`, + {}, + ); + expect(tryToUpdateAsNonAdmin.data.updateEmployee).toBeNull(); + expect(tryToUpdateAsNonAdmin.errors).toHaveLength(1); + + const tryToUpdateAsNonAdmin2 = await GRAPHQL_CLIENT_2.query( + `mutation { + updateEmployee(input: { id: "${employeeId}", e_mail: "someonelese@gmail.com" }) { + id + e_mail + salary + } + }`, + {}, + ); + expect(tryToUpdateAsNonAdmin2.data.updateEmployee).toBeNull(); + expect(tryToUpdateAsNonAdmin2.errors).toHaveLength(1); + + const tryToUpdateAsNonAdmin3 = await GRAPHQL_CLIENT_3.query( + `mutation { + updateEmployee(input: { id: "${employeeId}", e_mail: "someonelese@gmail.com" }) { + id + e_mail + salary + } + }`, + {}, + ); + expect(tryToUpdateAsNonAdmin3.data.updateEmployee).toBeNull(); + expect(tryToUpdateAsNonAdmin3.errors).toHaveLength(1); + + const updateAsAdmin = await GRAPHQL_CLIENT_1.query( + `mutation { + updateEmployee(input: { id: "${employeeId}", e_mail: "someonelese@gmail.com" }) { + id + e_mail + salary + } + }`, + {}, + ); + expect(updateAsAdmin.data.updateEmployee.e_mail).toEqual('someonelese@gmail.com'); + expect(updateAsAdmin.data.updateEmployee.salary).toEqual(100); + + const updateAsAdmin2 = await GRAPHQL_CLIENT_1.query( + `mutation { + updateEmployee(input: { id: "${employeeId}", salary: 99 }) { + id + e_mail + salary + } + }`, + {}, + ); + expect(updateAsAdmin2.data.updateEmployee.e_mail).toEqual('someonelese@gmail.com'); + expect(updateAsAdmin2.data.updateEmployee.salary).toEqual(99); +}); + +test('Test that owners may update their bio.', async () => { + const createUser1 = await GRAPHQL_CLIENT_1.query( + `mutation { + createEmployee(input: { e_mail: "user2@test.com", salary: 100 }) { + id + e_mail + salary + } + }`, + {}, + ); // 2afcb900-7fa1-4cb2-aca6-587f3e217c15 + const employeeId = createUser1.data.createEmployee.id; + expect(employeeId).not.toBeNull(); + expect(createUser1.data.createEmployee.e_mail).toEqual('user2@test.com'); + expect(createUser1.data.createEmployee.salary).toEqual(100); + + const tryToUpdateAsNonAdmin = await GRAPHQL_CLIENT_2.query( + `mutation { + updateEmployee(input: { id: "${employeeId}", bio: "Does cool stuff." }) { + id + e_mail + salary + bio + } + }`, + {}, + ); + expect(tryToUpdateAsNonAdmin.data.updateEmployee.bio).toEqual('Does cool stuff.'); + expect(tryToUpdateAsNonAdmin.data.updateEmployee.e_mail).toEqual('user2@test.com'); + expect(tryToUpdateAsNonAdmin.data.updateEmployee.salary).toEqual(100); +}); + +test('Test that everyone may view employee bios.', async () => { + const createUser1 = await GRAPHQL_CLIENT_1.query( + `mutation { + createEmployee(input: { e_mail: "user3@test.com", salary: 100, bio: "Likes long walks on the beach" }) { + id + e_mail + salary + bio + } + }`, + {}, + ); + const employeeId = createUser1.data.createEmployee.id; + expect(employeeId).not.toBeNull(); + expect(createUser1.data.createEmployee.e_mail).toEqual('user3@test.com'); + expect(createUser1.data.createEmployee.salary).toEqual(100); + expect(createUser1.data.createEmployee.bio).toEqual('Likes long walks on the beach'); + + const getAsNonAdmin = await GRAPHQL_CLIENT_2.query( + `query { + getEmployee(id: "${employeeId}") { + id + e_mail + bio + } + }`, + {}, + ); + // Should not be able to view the e_mail as the non owner + expect(getAsNonAdmin.data.getEmployee.e_mail).toBeNull(); + // Should be able to view the bio. + expect(getAsNonAdmin.data.getEmployee.bio).toEqual('Likes long walks on the beach'); + expect(getAsNonAdmin.errors).toHaveLength(1); + + const listAsNonAdmin = await GRAPHQL_CLIENT_2.query( + `query { + listEmployees { + items { + id + bio + } + } + }`, + {}, + ); + expect(listAsNonAdmin.data.listEmployees.items.length).toBeGreaterThan(1); + let seenId = false; + for (const item of listAsNonAdmin.data.listEmployees.items) { + if (item.id === employeeId) { + seenId = true; + expect(item.bio).toEqual('Likes long walks on the beach'); + } + } + expect(seenId).toEqual(true); +}); + +test('Test that only owners may "delete" i.e. update the field to null.', async () => { + const createUser1 = await GRAPHQL_CLIENT_1.query( + `mutation { + createEmployee(input: { e_mail: "user3@test.com", salary: 200, notes: "note1" }) { + id + e_mail + salary + notes + } + }`, + {}, + ); + const employeeId = createUser1.data.createEmployee.id; + expect(employeeId).not.toBeNull(); + expect(createUser1.data.createEmployee.e_mail).toEqual('user3@test.com'); + expect(createUser1.data.createEmployee.salary).toEqual(200); + expect(createUser1.data.createEmployee.notes).toEqual('note1'); + + const tryToDeleteUserNotes = await GRAPHQL_CLIENT_2.query( + `mutation { + updateEmployee(input: { id: "${employeeId}", notes: null }) { + id + notes + } + }`, + {}, + ); + expect(tryToDeleteUserNotes.data.updateEmployee).toBeNull(); + expect(tryToDeleteUserNotes.errors).toHaveLength(1); + + const updateNewsWithNotes = await GRAPHQL_CLIENT_3.query( + `mutation { + updateEmployee(input: { id: "${employeeId}", notes: "something else" }) { + id + notes + } + }`, + {}, + ); + expect(updateNewsWithNotes.data.updateEmployee.notes).toEqual('something else'); + + const updateAsAdmin = await GRAPHQL_CLIENT_1.query( + `mutation { + updateEmployee(input: { id: "${employeeId}", notes: null }) { + id + notes + } + }`, + {}, + ); + expect(updateAsAdmin.data.updateEmployee).toBeNull(); + expect(updateAsAdmin.errors).toHaveLength(1); + + const deleteNotes = await GRAPHQL_CLIENT_3.query( + `mutation { + updateEmployee(input: { id: "${employeeId}", notes: null }) { + id + notes + } + }`, + {}, + ); + expect(deleteNotes.data.updateEmployee.notes).toBeNull(); +}); + +test('Test with auth with subscriptions on default behavior', async () => { + /** + * client 1 and 2 are in the same user pool though client 1 should + * not be able to see notes if they are created by client 2 + * */ + const secureNote1 = 'secureNote1'; + const createStudent2 = await GRAPHQL_CLIENT_2.query( + `mutation { + createStudent(input: {bio: "bio1", name: "student1", notes: "${secureNote1}"}) { + id + bio + name + notes + owner + } + }`, + {}, + ); + expect(createStudent2.data.createStudent.id).toBeDefined(); + const createStudent1queryID = createStudent2.data.createStudent.id; + expect(createStudent2.data.createStudent.bio).toEqual('bio1'); + expect(createStudent2.data.createStudent.notes).toBeNull(); + // running query as username2 should return value + const queryForStudent2 = await GRAPHQL_CLIENT_2.query( + `query { + getStudent(id: "${createStudent1queryID}") { + bio + id + name + notes + owner + } + }`, + {}, + ); + expect(queryForStudent2.data.getStudent.notes).toEqual(secureNote1); + + // running query as username3 should return the type though return notes as null + const queryAsStudent1 = await GRAPHQL_CLIENT_1.query( + `query { + getStudent(id: "${createStudent1queryID}") { + bio + id + name + notes + owner + } + }`, + {}, + ); + expect(queryAsStudent1.data.getStudent.notes).toBeNull(); +}); + +test('AND per-field dynamic auth rule test', async () => { + const createPostResponse = await GRAPHQL_CLIENT_1.query(`mutation CreatePost { + createPost(input: {owner1: "${USERNAME1}", text: "mytext"}) { + id + text + owner1 + } + }`); + const postID1 = createPostResponse.data.createPost.id; + expect(postID1).toBeDefined(); + expect(createPostResponse.data.createPost.text).toEqual('mytext'); + expect(createPostResponse.data.createPost.owner1).toEqual(USERNAME1); + + const badUpdatePostResponse = await GRAPHQL_CLIENT_1.query(`mutation UpdatePost { + updatePost(input: {id: "${postID1}", text: "newText", owner1: "${USERNAME1}"}) { + id + owner1 + text + } + } + `); + expect(badUpdatePostResponse.errors[0].data).toBeNull(); + expect(badUpdatePostResponse.errors[0].errorType).toEqual('Unauthorized'); + + const correctUpdatePostResponse = await GRAPHQL_CLIENT_1.query(`mutation UpdatePost { + updatePost(input: {id: "${postID1}", text: "newText"}) { + id + owner1 + text + } + }`); + expect(correctUpdatePostResponse.data.updatePost.owner1).toEqual(USERNAME1); + expect(correctUpdatePostResponse.data.updatePost.text).toEqual('newText'); +}); diff --git a/packages/graphql-transformers-e2e-tests/src/__tests__/RelationalWithAuthV2.e2e.test.ts b/packages/graphql-transformers-e2e-tests/src/__tests__/RelationalWithAuthV2.e2e.test.ts new file mode 100644 index 00000000000..444a1494367 --- /dev/null +++ b/packages/graphql-transformers-e2e-tests/src/__tests__/RelationalWithAuthV2.e2e.test.ts @@ -0,0 +1,510 @@ +import { IndexTransformer, PrimaryKeyTransformer } from '@aws-amplify/graphql-index-transformer'; +import { ModelTransformer } from '@aws-amplify/graphql-model-transformer'; +import { + BelongsToTransformer, + HasManyTransformer, + HasOneTransformer, + ManyToManyTransformer, +} from '@aws-amplify/graphql-relational-transformer'; +import { AuthTransformer } from '@aws-amplify/graphql-auth-transformer'; +import { GraphQLTransform } from '@aws-amplify/graphql-transformer-core'; +import { ResourceConstants } from 'graphql-transformer-common'; +import { CloudFormationClient } from '../CloudFormationClient'; +import { Output } from 'aws-sdk/clients/cloudformation'; +import { GraphQLClient } from '../GraphQLClient'; +import { cleanupStackAfterTest, deploy } from '../deployNestedStacks'; +import { S3Client } from '../S3Client'; +import { S3, CognitoIdentityServiceProvider as CognitoClient } from 'aws-sdk'; +import { default as moment } from 'moment'; +import { + addUserToGroup, + authenticateUser, + configureAmplify, + createGroup, + createUserPool, + createUserPoolClient, + signupUser, +} from '../cognitoUtils'; +// to deal with bug in cognito-identity-js +(global as any).fetch = require('node-fetch'); + +jest.setTimeout(2000000); + +const cf = new CloudFormationClient('us-west-2'); +const customS3Client = new S3Client('us-west-2'); +const awsS3Client = new S3({ region: 'us-west-2' }); +const cognitoClient = new CognitoClient({ apiVersion: '2016-04-19', region: 'us-west-2' }); +const BUILD_TIMESTAMP = moment().format('YYYYMMDDHHmmss'); +const STACK_NAME = `RelationalAuthV2TransformersTest-${BUILD_TIMESTAMP}`; +const BUCKET_NAME = `appsync-relational-auth-transformer-test-${BUILD_TIMESTAMP}`; +const LOCAL_FS_BUILD_DIR = '/tmp/relational_auth_transformer_tests/'; +const S3_ROOT_DIR_KEY = 'deployments'; + +let GRAPHQL_ENDPOINT = undefined; + +/** + * Client 1 is logged in and is a member of the Admin group. + */ +let GRAPHQL_CLIENT_1 = undefined; + +/** + * Client 2 is logged in and is a member of the Devs group. + */ +let GRAPHQL_CLIENT_2 = undefined; + +/** + * Client 3 is logged in and has no group memberships. + */ +let GRAPHQL_CLIENT_3 = undefined; + +let USER_POOL_ID = undefined; + +const USERNAME1 = 'user1@test.com'; +const USERNAME2 = 'user2@test.com'; +const USERNAME3 = 'user3@test.com'; +const TMP_PASSWORD = 'Password123!'; +const REAL_PASSWORD = 'Password1234!'; + +const ADMIN_GROUP_NAME = 'Admin'; +const DEVS_GROUP_NAME = 'Devs'; +const PARTICIPANT_GROUP_NAME = 'Participant'; +const WATCHER_GROUP_NAME = 'Watcher'; + +function outputValueSelector(key: string) { + return (outputs: Output[]) => { + const output = outputs.find((o: Output) => o.OutputKey === key); + return output ? output.OutputValue : null; + }; +} + +beforeAll(async () => { + const validSchema = ` + type Post @model @auth(rules: [{allow: owner}]) { + id: ID! + title: String! + author: User @belongsTo(fields: ["owner"]) + owner: ID! @index(name: "byOwner", sortKeyFields: ["id"]) + } + + type User @model @auth(rules: [{ allow: owner }]) { + id: ID! + posts: [Post] @hasMany(indexName: "byOwner", fields: ["id"]) + } + + type FieldProtected @model @auth(rules: [{ allow: private }, { allow: owner, operations: [read] }]) { + id: ID! + owner: String + ownerOnly: String @auth(rules: [{allow: owner}]) + } + + type OpenTopLevel @model @auth(rules: [{allow: private}]) { + id: ID! + name: String + owner: String + protected: [ConnectionProtected] @hasMany(indexName: "byTopLevel", fields: ["id"]) + } + + type ConnectionProtected @model(queries: null) @auth(rules: [{allow: owner}]) { + id: ID! + name: String + owner: String + topLevelID: ID! @index(name: "byTopLevel", sortKeyFields: ["id"]) + topLevel: OpenTopLevel @belongsTo(fields: ["topLevelID"]) + }`; + let out; + try { + const modelTransformer = new ModelTransformer(); + const indexTransformer = new IndexTransformer(); + const hasOneTransformer = new HasOneTransformer(); + const transformer = new GraphQLTransform({ + authConfig: { + defaultAuthentication: { + authenticationType: 'AMAZON_COGNITO_USER_POOLS', + }, + additionalAuthenticationProviders: [], + }, + transformers: [ + modelTransformer, + new PrimaryKeyTransformer(), + indexTransformer, + hasOneTransformer, + new HasManyTransformer(), + new BelongsToTransformer(), + new AuthTransformer({ addAwsIamAuthInOutputSchema: false }), + new ManyToManyTransformer(modelTransformer, indexTransformer, hasOneTransformer), + ], + }); + out = transformer.transform(validSchema); + } catch (e) { + console.error(`Failed to transform schema: ${e}`); + expect(true).toEqual(false); + } + try { + await awsS3Client + .createBucket({ + Bucket: BUCKET_NAME, + }) + .promise(); + } catch (e) { + console.error(`Failed to create S3 bucket: ${e}`); + expect(true).toEqual(false); + } + const userPoolResponse = await createUserPool(cognitoClient, `UserPool${STACK_NAME}`); + USER_POOL_ID = userPoolResponse.UserPool.Id; + const userPoolClientResponse = await createUserPoolClient(cognitoClient, USER_POOL_ID, `UserPool${STACK_NAME}`); + const userPoolClientId = userPoolClientResponse.UserPoolClient.ClientId; + try { + const finishedStack = await deploy( + customS3Client, + cf, + STACK_NAME, + out, + { AuthCognitoUserPoolId: USER_POOL_ID }, + LOCAL_FS_BUILD_DIR, + BUCKET_NAME, + S3_ROOT_DIR_KEY, + BUILD_TIMESTAMP, + ); + expect(finishedStack).toBeDefined(); + const getApiEndpoint = outputValueSelector(ResourceConstants.OUTPUTS.GraphQLAPIEndpointOutput); + const getApiKey = outputValueSelector(ResourceConstants.OUTPUTS.GraphQLAPIApiKeyOutput); + const apiKey = getApiKey(finishedStack.Outputs); + GRAPHQL_ENDPOINT = getApiEndpoint(finishedStack.Outputs); + expect(apiKey).not.toBeTruthy(); + + // Verify we have all the details + expect(GRAPHQL_ENDPOINT).toBeTruthy(); + expect(USER_POOL_ID).toBeTruthy(); + expect(userPoolClientId).toBeTruthy(); + + // Configure Amplify, create users, and sign in. + configureAmplify(USER_POOL_ID, userPoolClientId); + + await signupUser(USER_POOL_ID, USERNAME1, TMP_PASSWORD); + await signupUser(USER_POOL_ID, USERNAME2, TMP_PASSWORD); + await signupUser(USER_POOL_ID, USERNAME3, TMP_PASSWORD); + + await createGroup(USER_POOL_ID, ADMIN_GROUP_NAME); + await createGroup(USER_POOL_ID, PARTICIPANT_GROUP_NAME); + await createGroup(USER_POOL_ID, WATCHER_GROUP_NAME); + await createGroup(USER_POOL_ID, DEVS_GROUP_NAME); + await addUserToGroup(ADMIN_GROUP_NAME, USERNAME1, USER_POOL_ID); + await addUserToGroup(PARTICIPANT_GROUP_NAME, USERNAME1, USER_POOL_ID); + await addUserToGroup(WATCHER_GROUP_NAME, USERNAME1, USER_POOL_ID); + await addUserToGroup(DEVS_GROUP_NAME, USERNAME2, USER_POOL_ID); + const authResAfterGroup: any = await authenticateUser(USERNAME1, TMP_PASSWORD, REAL_PASSWORD); + + const idToken = authResAfterGroup.getIdToken().getJwtToken(); + GRAPHQL_CLIENT_1 = new GraphQLClient(GRAPHQL_ENDPOINT, { Authorization: idToken }); + + const authRes2AfterGroup: any = await authenticateUser(USERNAME2, TMP_PASSWORD, REAL_PASSWORD); + const idToken2 = authRes2AfterGroup.getIdToken().getJwtToken(); + GRAPHQL_CLIENT_2 = new GraphQLClient(GRAPHQL_ENDPOINT, { Authorization: idToken2 }); + + const authRes3: any = await authenticateUser(USERNAME3, TMP_PASSWORD, REAL_PASSWORD); + const idToken3 = authRes3.getIdToken().getJwtToken(); + GRAPHQL_CLIENT_3 = new GraphQLClient(GRAPHQL_ENDPOINT, { Authorization: idToken3 }); + + // Wait for any propagation to avoid random + // "The security token included in the request is invalid" errors + await new Promise(res => setTimeout(() => res(), 5000)); + } catch (e) { + console.error(e); + expect(true).toEqual(false); + } +}); + +afterAll(async () => { + await cleanupStackAfterTest(BUCKET_NAME, STACK_NAME, cf, { cognitoClient, userPoolId: USER_POOL_ID }); +}); + +/** + * Test queries below + */ +test('Test creating a post and immediately view it via the User.posts connection.', async () => { + const createUser1 = await GRAPHQL_CLIENT_1.query( + `mutation { + createUser(input: { id: "user1@test.com" }) { + id + } + }`, + {}, + ); + expect(createUser1.data.createUser.id).toEqual('user1@test.com'); + + const response = await GRAPHQL_CLIENT_1.query( + `mutation { + createPost(input: { title: "Hello, World!", owner: "user1@test.com" }) { + id + title + owner + } + }`, + {}, + ); + expect(response.data.createPost.id).toBeDefined(); + expect(response.data.createPost.title).toEqual('Hello, World!'); + expect(response.data.createPost.owner).toBeDefined(); + + const getResponse = await GRAPHQL_CLIENT_1.query( + `query { + getUser(id: "user1@test.com") { + posts { + items { + id + title + owner + author { + id + } + } + } + } + }`, + {}, + ); + expect(getResponse.data.getUser.posts.items[0].id).toBeDefined(); + expect(getResponse.data.getUser.posts.items[0].title).toEqual('Hello, World!'); + expect(getResponse.data.getUser.posts.items[0].owner).toEqual('user1@test.com'); + expect(getResponse.data.getUser.posts.items[0].author.id).toEqual('user1@test.com'); +}); + +test('Testing reading an owner protected field as a non owner', async () => { + const response1 = await GRAPHQL_CLIENT_1.query( + `mutation { + createFieldProtected(input: { id: "1", owner: "${USERNAME1}", ownerOnly: "owner-protected" }) { + id + owner + ownerOnly + } + }`, + {}, + ); + expect(response1.data.createFieldProtected.id).toEqual('1'); + expect(response1.data.createFieldProtected.owner).toEqual(USERNAME1); + expect(response1.data.createFieldProtected.ownerOnly).toEqual(null); + + const response2 = await GRAPHQL_CLIENT_2.query( + `query { + getFieldProtected(id: "1") { + id + owner + ownerOnly + } + }`, + {}, + ); + expect(response2.data.getFieldProtected.ownerOnly).toBeNull(); + expect(response2.errors).toHaveLength(1); + + const response3 = await GRAPHQL_CLIENT_1.query( + `query { + getFieldProtected(id: "1") { + id + owner + ownerOnly + } + }`, + {}, + ); + expect(response3.data.getFieldProtected.id).toEqual('1'); + expect(response3.data.getFieldProtected.owner).toEqual(USERNAME1); + expect(response3.data.getFieldProtected.ownerOnly).toEqual('owner-protected'); +}); + +test('Test that @connection resolvers respect @model read operations.', async () => { + const response1 = await GRAPHQL_CLIENT_1.query( + `mutation { + createOpenTopLevel(input: { id: "1", owner: "${USERNAME1}", name: "open" }) { + id + owner + name + } + }`, + {}, + ); + expect(response1.data.createOpenTopLevel.id).toEqual('1'); + expect(response1.data.createOpenTopLevel.owner).toEqual(USERNAME1); + expect(response1.data.createOpenTopLevel.name).toEqual('open'); + + const response2 = await GRAPHQL_CLIENT_2.query( + `mutation { + createConnectionProtected(input: { id: "1", owner: "${USERNAME2}", name: "closed", topLevelID: "1" }) { + id + owner + name + topLevelID + } + }`, + {}, + ); + expect(response2.data.createConnectionProtected.id).toEqual('1'); + expect(response2.data.createConnectionProtected.owner).toEqual(USERNAME2); + expect(response2.data.createConnectionProtected.name).toEqual('closed'); + + const response3 = await GRAPHQL_CLIENT_1.query( + `query { + getOpenTopLevel(id: "1") { + id + protected { + items { + id + name + owner + } + } + } + }`, + {}, + ); + expect(response3.data.getOpenTopLevel.id).toEqual('1'); + expect(response3.data.getOpenTopLevel.protected.items).toHaveLength(0); + + const response4 = await GRAPHQL_CLIENT_2.query( + `query { + getOpenTopLevel(id: "1") { + id + protected { + items { + id + name + owner + } + } + } + }`, + {}, + ); + expect(response4.data.getOpenTopLevel.id).toEqual('1'); + expect(response4.data.getOpenTopLevel.protected.items).toHaveLength(1); +}); + +// Per field auth in mutations +test('Test that owners cannot set the field of a FieldProtected object unless authorized.', async () => { + const response1 = await GRAPHQL_CLIENT_1.query( + `mutation { + createFieldProtected(input: { id: "2", owner: "${USERNAME1}", ownerOnly: "owner-protected" }) { + id + owner + ownerOnly + } + }`, + {}, + ); + expect(response1.data.createFieldProtected.id).toEqual('2'); + expect(response1.data.createFieldProtected.owner).toEqual(USERNAME1); + expect(response1.data.createFieldProtected.ownerOnly).toEqual(null); + + const response2 = await GRAPHQL_CLIENT_1.query( + `mutation { + createFieldProtected(input: { id: "3", owner: "${USERNAME2}", ownerOnly: "owner-protected" }) { + id + owner + ownerOnly + } + }`, + {}, + ); + expect(response2.data.createFieldProtected).toBeNull(); + expect(response2.errors).toHaveLength(1); + + // The owner auth rule is on ownerOnly. Omitting the "ownerOnly" field will + // not trigger the @auth check + const response3 = await GRAPHQL_CLIENT_1.query( + `mutation { + createFieldProtected(input: { id: "4", owner: "${USERNAME2}" }) { + id + owner + ownerOnly + } + }`, + {}, + ); + expect(response3.data.createFieldProtected.id).toEqual('4'); + expect(response3.data.createFieldProtected.owner).toEqual(USERNAME2); + // The length is one because the 'ownerOnly' field is protected on reads. + // Since the caller is not the owner this will throw after the mutation succeeds + // and return partial results. + expect(response3.errors).toHaveLength(1); +}); + +test('Test that owners cannot update the field of a FieldProtected object unless authorized.', async () => { + const response1 = await GRAPHQL_CLIENT_1.query( + `mutation { + createFieldProtected(input: { owner: "${USERNAME1}", ownerOnly: "owner-protected" }) { + id + owner + ownerOnly + } + }`, + {}, + ); + expect(response1.data.createFieldProtected.id).not.toBeNull(); + expect(response1.data.createFieldProtected.owner).toEqual(USERNAME1); + expect(response1.data.createFieldProtected.ownerOnly).toEqual(null); + + const response2 = await GRAPHQL_CLIENT_2.query( + `mutation { + updateFieldProtected(input: { id: "${response1.data.createFieldProtected.id}", ownerOnly: "owner2-protected" }) { + id + owner + ownerOnly + } + }`, + {}, + ); + expect(response2.data.updateFieldProtected).toBeNull(); + expect(response2.errors).toHaveLength(1); + + // The auth rule is on ownerOnly. Omitting the "ownerOnly" field will + // not trigger the @auth check + const response3 = await GRAPHQL_CLIENT_1.query( + `mutation { + updateFieldProtected(input: { id: "${response1.data.createFieldProtected.id}", ownerOnly: "updated" }) { + id + owner + ownerOnly + } + }`, + {}, + ); + const resposne3ID = response3.data.updateFieldProtected.id; + expect(resposne3ID).toEqual(response1.data.createFieldProtected.id); + expect(response3.data.updateFieldProtected.owner).toEqual(USERNAME1); + + const response3query = await GRAPHQL_CLIENT_1.query(`query getMake1 { + getFieldProtected(id: "${resposne3ID}"){ + id + owner + ownerOnly + } + }`); + expect(response3query.data.getFieldProtected.ownerOnly).toEqual('updated'); + + // This request should succeed since we are not updating the protected field. + const response4 = await GRAPHQL_CLIENT_3.query( + `mutation { + updateFieldProtected(input: { id: "${response1.data.createFieldProtected.id}", owner: "${USERNAME3}" }) { + id + owner + ownerOnly + } + }`, + {}, + ); + expect(response4.data.updateFieldProtected.id).toEqual(response1.data.createFieldProtected.id); + expect(response4.data.updateFieldProtected.owner).toEqual(USERNAME3); + expect(response4.data.updateFieldProtected.ownerOnly).toBeNull(); + + const response5 = await GRAPHQL_CLIENT_3.query( + `query { + getFieldProtected( id: "${response1.data.createFieldProtected.id}" ) { + id + owner + ownerOnly + } + }`, + {}, + ); + expect(response5.data.getFieldProtected.ownerOnly).toEqual('updated'); +}); diff --git a/packages/graphql-transformers-e2e-tests/src/__tests__/SearchableWithAuthV2.e2e.test.ts b/packages/graphql-transformers-e2e-tests/src/__tests__/SearchableWithAuthV2.e2e.test.ts new file mode 100644 index 00000000000..6f4c142aa0f --- /dev/null +++ b/packages/graphql-transformers-e2e-tests/src/__tests__/SearchableWithAuthV2.e2e.test.ts @@ -0,0 +1,610 @@ +import { SearchableModelTransformer } from '@aws-amplify/graphql-searchable-transformer'; +import { ModelTransformer } from '@aws-amplify/graphql-model-transformer'; +import { ResourceConstants } from 'graphql-transformer-common'; +import { AuthTransformer } from '@aws-amplify/graphql-auth-transformer'; +import { GraphQLTransform } from '@aws-amplify/graphql-transformer-core'; +import AWSAppSyncClient, { AUTH_TYPE } from 'aws-appsync'; +import { CloudFormationClient } from '../CloudFormationClient'; +import { S3Client } from '../S3Client'; +import { Output } from 'aws-sdk/clients/cloudformation'; +import { cleanupStackAfterTest, deploy } from '../deployNestedStacks'; +import moment from 'moment'; +import { S3, CognitoIdentityServiceProvider as CognitoClient, CognitoIdentity } from 'aws-sdk'; +import { AWS } from '@aws-amplify/core'; +import { Auth } from 'aws-amplify'; +import { IAMHelper } from '../IAMHelper'; +import gql from 'graphql-tag'; +import { + addUserToGroup, + authenticateUser, + configureAmplify, + createGroup, + createIdentityPool, + createUserPool, + createUserPoolClient, + signupUser, +} from '../cognitoUtils'; +// to deal with bug in cognito-identity-js +(global as any).fetch = require('node-fetch'); +// To overcome of the way of how AmplifyJS picks up currentUserCredentials +const anyAWS = AWS as any; +if (anyAWS && anyAWS.config && anyAWS.config.credentials) { + delete anyAWS.config.credentials; +} + +// tslint:disable: no-magic-numbers +jest.setTimeout(60000 * 60); +const AWS_REGION = 'us-west-2'; + +const cf = new CloudFormationClient(AWS_REGION); +const customS3Client = new S3Client(AWS_REGION); +const awsS3Client = new S3({ region: AWS_REGION }); +const cognitoClient = new CognitoClient({ apiVersion: '2016-04-19', region: AWS_REGION }); +const identityClient = new CognitoIdentity({ apiVersion: '2014-06-30', region: AWS_REGION }); +const iamHelper = new IAMHelper(AWS_REGION); + +const BUILD_TIMESTAMP = moment().format('YYYYMMDDHHmmss'); +const STACK_NAME = `SearchableAuthV2Tests-${BUILD_TIMESTAMP}`; +const BUCKET_NAME = `searchable-authv2-tests-bucket-${BUILD_TIMESTAMP}`; +const LOCAL_FS_BUILD_DIR = '/tmp/searchable_authv2_tests/'; +const S3_ROOT_DIR_KEY = 'deployments'; +const AUTH_ROLE_NAME = `${STACK_NAME}-authRole`; +const UNAUTH_ROLE_NAME = `${STACK_NAME}-unauthRole`; +let USER_POOL_ID: string; +let IDENTITY_POOL_ID: string; +let GRAPHQL_ENDPOINT: string; +let API_KEY: string; + +/** + * Client 1 is logged in and has no group memberships. + */ +let GRAPHQL_CLIENT_1: AWSAppSyncClient = undefined; + +/** + * Client 2 is logged in and is a member of the admin and writer group. + */ +let GRAPHQL_CLIENT_2: AWSAppSyncClient = undefined; + +/** + * Client 3 is logged in and has no group memberships. + */ +let GRAPHQL_CLIENT_3: AWSAppSyncClient = undefined; + +/** + * Auth IAM Client + */ +let GRAPHQL_IAM_AUTH_CLIENT: AWSAppSyncClient = undefined; + +/** + * API Key Client + */ +let GRAPHQL_APIKEY_CLIENT: AWSAppSyncClient = undefined; + +const USERNAME1 = 'user1@test.com'; +const USERNAME2 = 'user2@test.com'; +const USERNAME3 = 'user3@test.com'; +const TMP_PASSWORD = 'Password123!'; +const REAL_PASSWORD = 'password'; +const WRITER_GROUP_NAME = 'writer'; +const ADMIN_GROUP_NAME = 'admin'; + +beforeAll(async () => { + const validSchema = ` + # Owners and Users in writer group + # can execute crud operations their owned records. + type Comment @model + @searchable + @auth(rules: [ + { allow: owner } + { allow: groups, groups: ["writer"]} + ]) { + id: ID! + content: String + } + # only users in the admin group are authorized to view entries in DynamicContent + type Todo @model + @searchable + @auth(rules: [ + { allow: groups, groupsField: "groups"} + ]) { + id: ID! + groups: String + content: String + } + # users with apikey perform crud operations on Post except for secret + # only users with auth role (iam) can view the secret + type Post @model + @searchable + @auth(rules: [ + { allow: public, provider: apiKey } + { allow: private, provider: iam } + ]) { + id: ID! + content: String + secret: String @auth(rules: [{ allow: private, provider: iam }]) + }`; + const transformer = new GraphQLTransform({ + authConfig: { + defaultAuthentication: { + authenticationType: 'AMAZON_COGNITO_USER_POOLS', + }, + additionalAuthenticationProviders: [ + { + authenticationType: 'API_KEY', + apiKeyConfig: { + description: 'E2E Test API Key', + apiKeyExpirationDays: 300, + }, + }, + { + authenticationType: 'AWS_IAM', + }, + ], + }, + transformers: [new ModelTransformer(), new SearchableModelTransformer(), new AuthTransformer({ addAwsIamAuthInOutputSchema: false })], + }); + const userPoolResponse = await createUserPool(cognitoClient, `UserPool${STACK_NAME}`); + USER_POOL_ID = userPoolResponse.UserPool.Id; + const userPoolClientResponse = await createUserPoolClient(cognitoClient, USER_POOL_ID, `UserPool${STACK_NAME}`); + const userPoolClientId = userPoolClientResponse.UserPoolClient.ClientId; + // create auth and unauthroles + const { authRole, unauthRole } = await iamHelper.createRoles(AUTH_ROLE_NAME, UNAUTH_ROLE_NAME); + // create identitypool + IDENTITY_POOL_ID = await createIdentityPool(identityClient, `IdentityPool${STACK_NAME}`, { + authRoleArn: authRole.Arn, + unauthRoleArn: unauthRole.Arn, + providerName: `cognito-idp.${AWS_REGION}.amazonaws.com/${USER_POOL_ID}`, + clientId: userPoolClientId, + }); + try { + await awsS3Client.createBucket({ Bucket: BUCKET_NAME }).promise(); + } catch (e) { + console.error(`Failed to create bucket: ${e}`); + } + try { + const out = transformer.transform(validSchema); + const finishedStack = await deploy( + customS3Client, + cf, + STACK_NAME, + out, + { AuthCognitoUserPoolId: USER_POOL_ID, authRoleName: authRole.RoleName, unauthRoleName: unauthRole.RoleName }, + LOCAL_FS_BUILD_DIR, + BUCKET_NAME, + S3_ROOT_DIR_KEY, + BUILD_TIMESTAMP, + ); + // Arbitrary wait to make sure everything is ready. + await cf.wait(120, () => Promise.resolve()); + expect(finishedStack).toBeDefined(); + const getApiEndpoint = outputValueSelector(ResourceConstants.OUTPUTS.GraphQLAPIEndpointOutput); + const getApiKey = outputValueSelector(ResourceConstants.OUTPUTS.GraphQLAPIApiKeyOutput); + GRAPHQL_ENDPOINT = getApiEndpoint(finishedStack.Outputs); + API_KEY = getApiKey(finishedStack.Outputs); + + expect(API_KEY).toBeDefined(); + expect(GRAPHQL_ENDPOINT).toBeDefined(); + expect(userPoolClientId).toBeTruthy(); + + // Configure Amplify, create users, and sign in + configureAmplify(USER_POOL_ID, userPoolClientId, IDENTITY_POOL_ID); + + await signupUser(USER_POOL_ID, USERNAME1, TMP_PASSWORD); + await signupUser(USER_POOL_ID, USERNAME2, TMP_PASSWORD); + await signupUser(USER_POOL_ID, USERNAME3, TMP_PASSWORD); + await createGroup(USER_POOL_ID, WRITER_GROUP_NAME); + await createGroup(USER_POOL_ID, ADMIN_GROUP_NAME); + await addUserToGroup(WRITER_GROUP_NAME, USERNAME2, USER_POOL_ID); + await addUserToGroup(ADMIN_GROUP_NAME, USERNAME2, USER_POOL_ID); + + const authResAfterGroup: any = await authenticateUser(USERNAME1, TMP_PASSWORD, REAL_PASSWORD); + const idToken = authResAfterGroup.getIdToken().getJwtToken(); + GRAPHQL_CLIENT_1 = new AWSAppSyncClient({ + url: GRAPHQL_ENDPOINT, + region: AWS_REGION, + disableOffline: true, + auth: { + type: AUTH_TYPE.AMAZON_COGNITO_USER_POOLS, + jwtToken: () => idToken, + }, + }); + + // const authRes2AfterGroup: any = await authenticateUser(USERNAME2, TMP_PASSWORD, REAL_PASSWORD); + // const idToken2 = authRes2AfterGroup.getIdToken().getJwtToken(); + await Auth.signOut(); + await Auth.signIn(USERNAME2, REAL_PASSWORD); + const idToken2 = (await Auth.currentSession()).getIdToken().getJwtToken(); + GRAPHQL_CLIENT_2 = new AWSAppSyncClient({ + url: GRAPHQL_ENDPOINT, + region: AWS_REGION, + disableOffline: true, + auth: { + type: AUTH_TYPE.AMAZON_COGNITO_USER_POOLS, + jwtToken: () => idToken2, + }, + }); + + // const authRes3: any = await authenticateUser(USERNAME3, TMP_PASSWORD, REAL_PASSWORD); + // const idToken3 = authRes3.getIdToken().getJwtToken(); + await Auth.signOut(); + await Auth.signIn(USERNAME3, REAL_PASSWORD); + const idToken3 = (await Auth.currentSession()).getIdToken().getJwtToken(); + GRAPHQL_CLIENT_3 = new AWSAppSyncClient({ + url: GRAPHQL_ENDPOINT, + region: AWS_REGION, + disableOffline: true, + auth: { + type: AUTH_TYPE.AMAZON_COGNITO_USER_POOLS, + jwtToken: () => idToken3, + }, + }); + await Auth.signOut(); + await Auth.signIn(USERNAME1, REAL_PASSWORD); + const authCreds = await Auth.currentCredentials(); + GRAPHQL_IAM_AUTH_CLIENT = new AWSAppSyncClient({ + url: GRAPHQL_ENDPOINT, + region: AWS_REGION, + disableOffline: true, + auth: { + type: AUTH_TYPE.AWS_IAM, + credentials: authCreds, + }, + }); + + GRAPHQL_APIKEY_CLIENT = new AWSAppSyncClient({ + url: GRAPHQL_ENDPOINT, + region: AWS_REGION, + auth: { + type: AUTH_TYPE.API_KEY, + apiKey: API_KEY, + }, + disableOffline: true, + }); + + // Create sample mutations to test search queries + await createEntries(); + } catch (e) { + console.error(e); + throw e; + } +}); + +afterAll(async () => { + await cleanupStackAfterTest( + BUCKET_NAME, + STACK_NAME, + cf, + { cognitoClient, userPoolId: USER_POOL_ID }, + { identityClient, identityPoolId: IDENTITY_POOL_ID }, + ); + try { + await iamHelper.deleteRole(AUTH_ROLE_NAME); + } catch (e) { + console.warn(`Error during auth role cleanup ${e}`); + } + try { + await iamHelper.deleteRole(UNAUTH_ROLE_NAME); + } catch (e) { + console.warn(`Error during unauth role cleanup ${e}`); + } +}); + +/** + * Tests + */ + +// cognito owner check +test('test Comments as owner', async () => { + const ownerResponse: any = await GRAPHQL_CLIENT_1.query({ + query: gql` + query SearchComments { + searchComments { + items { + id + content + owner + } + nextToken + } + } + `, + }); + expect(ownerResponse.data.searchComments).toBeDefined(); + expect(ownerResponse.data.searchComments.items.length).toEqual(1); + expect(ownerResponse.data.searchComments.items[0].content).toEqual('ownerContent'); +}); + +// cognito static group check +test('test Comments as user in writer group', async () => { + const writerResponse: any = await GRAPHQL_CLIENT_2.query({ + query: gql` + query SearchComments { + searchComments { + items { + id + content + owner + } + nextToken + } + } + `, + }); + expect(writerResponse.data.searchComments).toBeDefined(); + expect(writerResponse.data.searchComments.items.length).toEqual(4); + // only ownerContent should have the owner name + // because the group permission was met we did not populate an owner field + // therefore there is no owner + expect(writerResponse.data.searchComments.items).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + id: expect.any(String), + content: 'ownerContent', + owner: USERNAME1, + }), + expect.objectContaining({ + id: expect.any(String), + content: 'content1', + owner: null, + }), + expect.objectContaining({ + id: expect.any(String), + content: 'content1', + owner: null, + }), + expect.objectContaining({ + id: expect.any(String), + content: 'content3', + owner: null, + }), + ]), + ); +}); + +// cognito test as unauthorized user +test('test Comments as user that is not an owner nor is in writer group', async () => { + const user3Response: any = await GRAPHQL_CLIENT_3.query({ + query: gql` + query SearchComments { + searchComments { + items { + id + content + owner + } + nextToken + } + } + `, + }); + expect(user3Response.data.searchComments).toBeDefined(); + expect(user3Response.data.searchComments.items.length).toEqual(0); + expect(user3Response.data.searchComments.nextToken).toBeNull(); +}); + +// cognito dynamic group check +test('test Todo as user in the dynamic group admin', async () => { + const adminResponse: any = await GRAPHQL_CLIENT_2.query({ + query: gql` + query SearchTodos { + searchTodos { + items { + id + groups + content + } + nextToken + } + } + `, + }); + expect(adminResponse.data.searchTodos).toBeDefined(); + expect(adminResponse.data.searchTodos.items.length).toEqual(3); + expect(adminResponse.data.searchTodos.items).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + id: expect.any(String), + content: 'adminContent1', + groups: ADMIN_GROUP_NAME, + }), + expect.objectContaining({ + id: expect.any(String), + content: 'adminContent2', + groups: ADMIN_GROUP_NAME, + }), + expect.objectContaining({ + id: expect.any(String), + content: 'adminContent3', + groups: ADMIN_GROUP_NAME, + }), + ]), + ); +}); + +// iam test +test('test Post as authorized user', async () => { + const authUser: any = await GRAPHQL_IAM_AUTH_CLIENT.query({ + query: gql` + query SearchPosts { + searchPosts { + items { + id + content + secret + } + nextToken + } + } + `, + }); + expect(authUser.data.searchPosts).toBeDefined(); + expect(authUser.data.searchPosts.items.length).toEqual(4); + expect(authUser.data.searchPosts.items).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + id: expect.any(String), + content: 'post2', + secret: 'post2secret', + }), + expect.objectContaining({ + id: expect.any(String), + content: 'post1', + secret: 'post1secret', + }), + expect.objectContaining({ + id: expect.any(String), + content: 'post3', + secret: 'post3secret', + }), + expect.objectContaining({ + id: expect.any(String), + content: 'publicPost', + secret: null, + }), + ]), + ); +}); + +// test apikey 2nd scenario +test('test searchPosts with apikey and secret removed', async () => { + const apiKeyResponse: any = await GRAPHQL_APIKEY_CLIENT.query({ + query: gql` + query SearchPosts { + searchPosts { + items { + id + content + } + nextToken + } + } + `, + }); + expect(apiKeyResponse.data.searchPosts).toBeDefined(); + expect(apiKeyResponse.data.searchPosts.items).toHaveLength(4); + expect(apiKeyResponse.data.searchPosts.items).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + id: expect.any(String), + content: 'post2', + }), + expect.objectContaining({ + id: expect.any(String), + content: 'post1', + }), + expect.objectContaining({ + id: expect.any(String), + content: 'post3', + }), + expect.objectContaining({ + id: expect.any(String), + content: 'publicPost', + }), + ]), + ); +}); + +// test iam/apiKey schema with unauth user +test('test post as an cognito user that is not allowed in this schema', async () => { + try { + await GRAPHQL_CLIENT_3.query({ + query: gql` + query SearchPosts { + searchPosts { + items { + id + content + secret + } + nextToken + } + } + `, + }); + } catch (err) { + expect(err.graphQLErrors[0].errorType).toEqual('Unauthorized'); + expect(err.graphQLErrors[0].message).toEqual('Not Authorized to access searchPosts on type Query'); + } +}); + +/** + * Input types + * */ +type CreateCommentInput = { + id?: string | null; + content?: string | null; +}; + +type CreateTodoInput = { + id?: string | null; + groups?: string | null; + content?: string | null; +}; + +type CreatePostInput = { + id?: string | null; + content?: string | null; + secret?: string | null; +}; + +// mutations +async function createComment(client: AWSAppSyncClient, input: CreateCommentInput) { + const create = gql` + mutation CreateComment($input: CreateCommentInput!) { + createComment(input: $input) { + id + content + owner + } + } + `; + return await client.mutate({ mutation: create, variables: { input } }); +} +async function createTodo(client: AWSAppSyncClient, input: CreateTodoInput) { + const create = gql` + mutation CreateTodo($input: CreateTodoInput!) { + createTodo(input: $input) { + id + groups + content + } + } + `; + return await client.mutate({ mutation: create, variables: { input } }); +} +async function createPost(client: AWSAppSyncClient, input: CreatePostInput) { + const create = gql` + mutation CreatePost($input: CreatePostInput!) { + createPost(input: $input) { + id + content + } + } + `; + return await client.mutate({ mutation: create, variables: { input } }); +} + +const createEntries = async () => { + await createComment(GRAPHQL_CLIENT_1, { + content: 'ownerContent', + }); + try { + await createPost(GRAPHQL_APIKEY_CLIENT, { content: 'publicPost' }); + } catch (err) { + // will err since the secret is in the fields response though the post should still go through + } + for (let i = 1; i < 4; i++) { + await createComment(GRAPHQL_CLIENT_2, { content: `content${i}` }); + await createTodo(GRAPHQL_CLIENT_2, { groups: 'admin', content: `adminContent${i}` }); + await createPost(GRAPHQL_IAM_AUTH_CLIENT, { content: `post${i}`, secret: `post${i}secret` }); + } + // Waiting for the ES Cluster + Streaming Lambda infra to be setup + await cf.wait(120, () => Promise.resolve()); +}; + +function outputValueSelector(key: string) { + return (outputs: Output[]) => { + const output = outputs.find((o: Output) => o.OutputKey === key); + return output ? output.OutputValue : null; + }; +} diff --git a/packages/graphql-transformers-e2e-tests/src/__tests__/SubscriptionsWithAuthV2.e2e.test.ts b/packages/graphql-transformers-e2e-tests/src/__tests__/SubscriptionsWithAuthV2.e2e.test.ts new file mode 100644 index 00000000000..ed08239d920 --- /dev/null +++ b/packages/graphql-transformers-e2e-tests/src/__tests__/SubscriptionsWithAuthV2.e2e.test.ts @@ -0,0 +1,1110 @@ +import { ModelTransformer } from '@aws-amplify/graphql-model-transformer'; +import { AuthTransformer } from '@aws-amplify/graphql-auth-transformer'; +import { GraphQLTransform } from '@aws-amplify/graphql-transformer-core'; +import { CloudFormationClient } from '../CloudFormationClient'; +import { ResourceConstants } from 'graphql-transformer-common'; +import { Output } from 'aws-sdk/clients/cloudformation'; +import { CognitoIdentityServiceProvider as CognitoClient, S3, CognitoIdentity } from 'aws-sdk'; +import AWSAppSyncClient, { AUTH_TYPE } from 'aws-appsync'; +import { AWS } from '@aws-amplify/core'; +import { Auth } from 'aws-amplify'; +import gql from 'graphql-tag'; +import { S3Client } from '../S3Client'; +import { cleanupStackAfterTest, deploy } from '../deployNestedStacks'; +import { default as moment } from 'moment'; +import { + createUserPool, + createUserPoolClient, + createGroup, + addUserToGroup, + configureAmplify, + signupUser, + authenticateUser, + createIdentityPool, +} from '../cognitoUtils'; +import 'isomorphic-fetch'; +import { API } from 'aws-amplify'; +import { GRAPHQL_AUTH_MODE } from '@aws-amplify/api'; +import { withTimeOut } from '../promiseWithTimeout'; +import { IAMHelper } from '../IAMHelper'; +import * as Observable from 'zen-observable'; + +// tslint:disable: no-use-before-declare +// to deal with bug in cognito-identity-js +(global as any).fetch = require('node-fetch'); +// to deal with subscriptions in node env +(global as any).WebSocket = require('ws'); + +// To overcome of the way of how AmplifyJS picks up currentUserCredentials +const anyAWS = AWS as any; +if (anyAWS && anyAWS.config && anyAWS.config.credentials) { + delete anyAWS.config.credentials; +} + +// delay times +const SUBSCRIPTION_DELAY = 10000; +const PROPAGATION_DELAY = 5000; +const JEST_TIMEOUT = 2000000; +const SUBSCRIPTION_TIMEOUT = 10000; + +jest.setTimeout(JEST_TIMEOUT); + +function outputValueSelector(key: string) { + return (outputs: Output[]) => { + const output = outputs.find((o: Output) => o.OutputKey === key); + return output ? output.OutputValue : null; + }; +} + +const AWS_REGION = 'us-west-2'; +const cf = new CloudFormationClient(AWS_REGION); +const customS3Client = new S3Client(AWS_REGION); +const cognitoClient = new CognitoClient({ apiVersion: '2016-04-19', region: AWS_REGION }); +const identityClient = new CognitoIdentity({ apiVersion: '2014-06-30', region: AWS_REGION }); +const iamHelper = new IAMHelper(AWS_REGION); +const awsS3Client = new S3({ region: AWS_REGION }); + +// stack info +const BUILD_TIMESTAMP = moment().format('YYYYMMDDHHmmss'); +const STACK_NAME = `SubscriptionAuthV2Tests-${BUILD_TIMESTAMP}`; +const BUCKET_NAME = `subscription-authv2-tests-bucket-${BUILD_TIMESTAMP}`; +const LOCAL_FS_BUILD_DIR = '/tmp/subscription_authv2_tests/'; +const S3_ROOT_DIR_KEY = 'deployments'; +const AUTH_ROLE_NAME = `${STACK_NAME}-authRole`; +const UNAUTH_ROLE_NAME = `${STACK_NAME}-unauthRole`; +let USER_POOL_ID: string; +let IDENTITY_POOL_ID: string; +let GRAPHQL_ENDPOINT: string; +let API_KEY: string; + +/** + * Client 1 is logged in and is a member of the Admin group. + */ +let GRAPHQL_CLIENT_1: AWSAppSyncClient = undefined; + +/** + * Client 2 is logged in and is a member of the Devs group. + */ +let GRAPHQL_CLIENT_2: AWSAppSyncClient = undefined; + +/** + * Auth IAM Client + */ +let GRAPHQL_IAM_AUTH_CLIENT: AWSAppSyncClient = undefined; + +const USERNAME1 = 'user1@test.com'; +const USERNAME2 = 'user2@test.com'; +const USERNAME3 = 'user3@test.com'; +const TMP_PASSWORD = 'Password123!'; +const REAL_PASSWORD = 'Password1234!'; + +const INSTRUCTOR_GROUP_NAME = 'Instructor'; +const MEMBER_GROUP_NAME = 'Member'; +const ADMIN_GROUP_NAME = 'Admin'; + +// interface inputs +interface MemberInput { + id: string; + name?: string; + createdAt?: string; + updatedAt?: string; +} + +interface CreateStudentInput { + id?: string; + name?: string; + email?: string; + ssn?: string; +} + +interface UpdateStudentInput { + id: string; + name?: string; + email?: string; + ssn?: string; +} + +interface CreatePostInput { + id?: string; + title: string; + postOwner: string; +} + +interface CreateTodoInput { + id?: string; + name?: string; + description?: string; +} + +interface UpdateTodoInput { + id: string; + name?: string; + description?: string; +} + +interface DeleteTypeInput { + id: string; +} + +beforeEach(async () => { + try { + await Auth.signOut(); + } catch (ex) { + // don't need to fail tests on this error + } +}); + +beforeAll(async () => { + const validSchema = ` + # Owners may update their owned records. + # Instructors may create Student records. + # Any authenticated user may view Student names & emails. + # Only Owners can see the ssn + + type Student @model + @auth(rules: [ + {allow: owner} + {allow: groups, groups: ["Instructor"]} + ]) { + id: String, + name: String, + email: AWSEmail, + ssn: String @auth(rules: [{allow: owner}]) + } + + type Member @model + @auth(rules: [ + { allow: groups, groups: ["Admin"] } + { allow: groups, groups: ["Member"], operations: [read] } + ]) { + id: ID + name: String + createdAt: AWSDateTime + updatedAt: AWSDateTime + } + + type Post @model + @auth(rules: [ + { allow: owner, ownerField: "postOwner" } + { allow: private, operations: [read], provider: iam } + ]) + { + id: ID! + title: String + postOwner: String + } + + type Todo @model @auth(rules: [ + { allow: private, provider: iam } + { allow: public } + ]){ + id: ID! + name: String @auth(rules: [ + { allow: private, provider: iam } + ]) + description: String + }`; + const transformer = new GraphQLTransform({ + authConfig: { + defaultAuthentication: { + authenticationType: 'AMAZON_COGNITO_USER_POOLS', + }, + additionalAuthenticationProviders: [ + { + authenticationType: 'API_KEY', + apiKeyConfig: { + description: 'E2E Test API Key', + apiKeyExpirationDays: 300, + }, + }, + { + authenticationType: 'AWS_IAM', + }, + ], + }, + transformers: [new ModelTransformer(), new AuthTransformer({ addAwsIamAuthInOutputSchema: false })], + }); + + try { + await awsS3Client.createBucket({ Bucket: BUCKET_NAME }).promise(); + } catch (e) { + console.error(`Failed to create bucket: ${e}`); + } + + // create userpool + const userPoolResponse = await createUserPool(cognitoClient, `UserPool${STACK_NAME}`); + USER_POOL_ID = userPoolResponse.UserPool.Id; + const userPoolClientResponse = await createUserPoolClient(cognitoClient, USER_POOL_ID, `UserPool${STACK_NAME}`); + const userPoolClientId = userPoolClientResponse.UserPoolClient.ClientId; + // create auth and unauthroles + const { authRole, unauthRole } = await iamHelper.createRoles(AUTH_ROLE_NAME, UNAUTH_ROLE_NAME); + // create identitypool + IDENTITY_POOL_ID = await createIdentityPool(identityClient, `IdentityPool${STACK_NAME}`, { + authRoleArn: authRole.Arn, + unauthRoleArn: unauthRole.Arn, + providerName: `cognito-idp.${AWS_REGION}.amazonaws.com/${USER_POOL_ID}`, + clientId: userPoolClientId, + }); + const out = transformer.transform(validSchema); + const finishedStack = await deploy( + customS3Client, + cf, + STACK_NAME, + out, + { AuthCognitoUserPoolId: USER_POOL_ID, authRoleName: authRole.RoleName, unauthRoleName: unauthRole.RoleName }, + LOCAL_FS_BUILD_DIR, + BUCKET_NAME, + S3_ROOT_DIR_KEY, + BUILD_TIMESTAMP, + ); + + expect(finishedStack).toBeDefined(); + const getApiEndpoint = outputValueSelector(ResourceConstants.OUTPUTS.GraphQLAPIEndpointOutput); + const getApiKey = outputValueSelector(ResourceConstants.OUTPUTS.GraphQLAPIApiKeyOutput); + GRAPHQL_ENDPOINT = getApiEndpoint(finishedStack.Outputs); + + API_KEY = getApiKey(finishedStack.Outputs); + expect(API_KEY).toBeTruthy(); + + // Verify we have all the details + expect(GRAPHQL_ENDPOINT).toBeTruthy(); + expect(USER_POOL_ID).toBeTruthy(); + expect(userPoolClientId).toBeTruthy(); + + // Configure Amplify, create users, and sign in + configureAmplify(USER_POOL_ID, userPoolClientId, IDENTITY_POOL_ID); + + await signupUser(USER_POOL_ID, USERNAME1, TMP_PASSWORD); + await signupUser(USER_POOL_ID, USERNAME2, TMP_PASSWORD); + await signupUser(USER_POOL_ID, USERNAME3, TMP_PASSWORD); + + await createGroup(USER_POOL_ID, INSTRUCTOR_GROUP_NAME); + await createGroup(USER_POOL_ID, MEMBER_GROUP_NAME); + await createGroup(USER_POOL_ID, ADMIN_GROUP_NAME); + await addUserToGroup(ADMIN_GROUP_NAME, USERNAME1, USER_POOL_ID); + await addUserToGroup(MEMBER_GROUP_NAME, USERNAME2, USER_POOL_ID); + await addUserToGroup(INSTRUCTOR_GROUP_NAME, USERNAME1, USER_POOL_ID); + await addUserToGroup(INSTRUCTOR_GROUP_NAME, USERNAME2, USER_POOL_ID); + + // authenticate user3 we'll use amplify api for subscription calls + await authenticateUser(USERNAME3, TMP_PASSWORD, REAL_PASSWORD); + + const authResAfterGroup: any = await authenticateUser(USERNAME1, TMP_PASSWORD, REAL_PASSWORD); + const idToken = authResAfterGroup.getIdToken().getJwtToken(); + GRAPHQL_CLIENT_1 = new AWSAppSyncClient({ + url: GRAPHQL_ENDPOINT, + region: AWS_REGION, + disableOffline: true, + auth: { + type: AUTH_TYPE.AMAZON_COGNITO_USER_POOLS, + jwtToken: idToken, + }, + }); + const authRes2AfterGroup: any = await authenticateUser(USERNAME2, TMP_PASSWORD, REAL_PASSWORD); + const idToken2 = authRes2AfterGroup.getIdToken().getJwtToken(); + GRAPHQL_CLIENT_2 = new AWSAppSyncClient({ + url: GRAPHQL_ENDPOINT, + region: AWS_REGION, + disableOffline: true, + auth: { + type: AUTH_TYPE.AMAZON_COGNITO_USER_POOLS, + jwtToken: idToken2, + }, + }); + + await Auth.signIn(USERNAME1, REAL_PASSWORD); + const authCreds = await Auth.currentCredentials(); + GRAPHQL_IAM_AUTH_CLIENT = new AWSAppSyncClient({ + url: GRAPHQL_ENDPOINT, + region: AWS_REGION, + disableOffline: true, + auth: { + type: AUTH_TYPE.AWS_IAM, + credentials: authCreds, + }, + }); + // Wait for any propagation to avoid random + // "The security token included in the request is invalid" errors + await new Promise(res => setTimeout(res, PROPAGATION_DELAY)); +}); + +afterAll(async () => { + await cleanupStackAfterTest( + BUCKET_NAME, + STACK_NAME, + cf, + { cognitoClient, userPoolId: USER_POOL_ID }, + { identityClient, identityPoolId: IDENTITY_POOL_ID }, + ); + await iamHelper.deleteRole(AUTH_ROLE_NAME); + await iamHelper.deleteRole(UNAUTH_ROLE_NAME); +}); + +/** + * Tests + */ + +// tests using cognito +test('Test that only authorized members are allowed to view subscriptions', async () => { + // subscribe to create students as user 2 + reconfigureAmplifyAPI('AMAZON_COGNITO_USER_POOLS'); + await Auth.signIn(USERNAME1, REAL_PASSWORD); + const observer = API.graphql({ + query: gql` + subscription OnCreateStudent { + onCreateStudent { + id + name + email + ssn + owner + } + } + `, + authMode: GRAPHQL_AUTH_MODE.AMAZON_COGNITO_USER_POOLS, + }) as unknown as Observable; + let subscription: ZenObservable.Subscription; + const subscriptionPromise = new Promise((resolve, _) => { + subscription = observer.subscribe((event: any) => { + const student = event.value.data.onCreateStudent; + subscription.unsubscribe(); + expect(student.name).toEqual('student1'); + expect(student.email).toEqual('student1@domain.com'); + expect(student.ssn).toBeNull(); + resolve(undefined); + }); + }); + + await new Promise(res => setTimeout(res, SUBSCRIPTION_DELAY)); + + await createStudent(GRAPHQL_CLIENT_1, { + name: 'student1', + email: 'student1@domain.com', + ssn: 'AAA-01-SSSS', + }); + + return withTimeOut(subscriptionPromise, SUBSCRIPTION_TIMEOUT, 'OnCreateStudent Subscription timed out', () => { + subscription?.unsubscribe(); + }); +}); + +test('Test that an user not in the group is not allowed to view the subscription', async () => { + // subscribe to create students as user 3 + // const observer = onCreateStudent(GRAPHQL_CLIENT_3) + reconfigureAmplifyAPI('AMAZON_COGNITO_USER_POOLS'); + await Auth.signIn(USERNAME3, REAL_PASSWORD); + const observer = API.graphql({ + query: gql` + subscription OnCreateStudent { + onCreateStudent { + id + name + email + ssn + owner + } + } + `, + authMode: GRAPHQL_AUTH_MODE.AMAZON_COGNITO_USER_POOLS, + }) as unknown as Observable; + let subscription: ZenObservable.Subscription; + const subscriptionPromise = new Promise((resolve, _) => { + subscription = observer.subscribe({ + error: (err: any) => { + expect(err.error.errors[0].message).toEqual( + 'Connection failed: {"errors":[{"errorType":"Unauthorized","message":"Not Authorized to access onCreateStudent on type Student"}]}', + ); + resolve(undefined); + }, + }); + }); + + await new Promise(res => setTimeout(res, SUBSCRIPTION_DELAY)); + + await createStudent(GRAPHQL_CLIENT_1, { + name: 'student2', + email: 'student2@domain.com', + ssn: 'BBB-00-SNSN', + }); + + return withTimeOut(subscriptionPromise, SUBSCRIPTION_TIMEOUT, 'Subscription timed out', () => { + subscription?.unsubscribe(); + }); +}); + +test('Test a subscription on update', async () => { + // subscribe to update students as user 2 + reconfigureAmplifyAPI('AMAZON_COGNITO_USER_POOLS'); + await Auth.signIn(USERNAME2, REAL_PASSWORD); + const observer = API.graphql({ + query: gql` + subscription OnUpdateStudent { + onUpdateStudent { + id + name + email + ssn + owner + } + } + `, + authMode: GRAPHQL_AUTH_MODE.AMAZON_COGNITO_USER_POOLS, + }) as unknown as Observable; + let subscription: ZenObservable.Subscription; + const subscriptionPromise = new Promise((resolve, _) => { + subscription = observer.subscribe((event: any) => { + const student = event.value.data.onUpdateStudent; + subscription.unsubscribe(); + expect(student.id).toEqual(student3ID); + expect(student.name).toEqual('student3'); + expect(student.email).toEqual('emailChanged@domain.com'); + expect(student.ssn).toBeNull(); + resolve(undefined); + }); + }); + await new Promise(res => setTimeout(res, SUBSCRIPTION_DELAY)); + + const student3 = await createStudent(GRAPHQL_CLIENT_1, { + name: 'student3', + email: 'changeThisEmail@domain.com', + ssn: 'CCC-01-SNSN', + }); + expect(student3.data.createStudent).toBeDefined(); + const student3ID = student3.data.createStudent.id; + expect(student3.data.createStudent.name).toEqual('student3'); + expect(student3.data.createStudent.email).toEqual('changeThisEmail@domain.com'); + expect(student3.data.createStudent.ssn).toBeNull(); + + await updateStudent(GRAPHQL_CLIENT_1, { + id: student3ID, + email: 'emailChanged@domain.com', + }); + + return withTimeOut(subscriptionPromise, SUBSCRIPTION_TIMEOUT, 'OnUpdateStudent Subscription timed out', () => { + subscription?.unsubscribe(); + }); +}); + +test('Test a subscription on delete', async () => { + // subscribe to onDelete as user 2 + reconfigureAmplifyAPI('AMAZON_COGNITO_USER_POOLS'); + await Auth.signIn(USERNAME2, REAL_PASSWORD); + const observer = API.graphql({ + query: gql` + subscription OnDeleteStudent { + onDeleteStudent { + id + name + email + ssn + owner + } + } + `, + authMode: GRAPHQL_AUTH_MODE.AMAZON_COGNITO_USER_POOLS, + }) as unknown as Observable; + let subscription: ZenObservable.Subscription; + const subscriptionPromise = new Promise((resolve, reject) => { + subscription = observer.subscribe({ + next: event => { + const student = event.value.data.onDeleteStudent; + subscription.unsubscribe(); + expect(student.id).toEqual(student4ID); + expect(student.name).toEqual('student4'); + expect(student.email).toEqual('plsDelete@domain.com'); + expect(student.ssn).toBeNull(); + resolve(undefined); + }, + error: err => { + reject(err); + }, + }); + }); + const student4 = await createStudent(GRAPHQL_CLIENT_1, { + name: 'student4', + email: 'plsDelete@domain.com', + ssn: 'DDD-02-SNSN', + }); + expect(student4).toBeDefined(); + const student4ID = student4.data.createStudent.id; + expect(student4.data.createStudent.email).toEqual('plsDelete@domain.com'); + expect(student4.data.createStudent.ssn).toBeNull(); + + await new Promise(res => setTimeout(res, SUBSCRIPTION_DELAY)); + + await deleteStudent(GRAPHQL_CLIENT_1, { id: student4ID }); + + return withTimeOut(subscriptionPromise, SUBSCRIPTION_TIMEOUT, 'OnDeleteStudent Subscription timed out', () => { + subscription?.unsubscribe(); + }); +}); + +test('test that group is only allowed to listen to subscriptions and listen to onCreate', async () => { + const memberID = '001'; + const memberName = 'username00'; + // test that a user that only read can't mutate + reconfigureAmplifyAPI('AMAZON_COGNITO_USER_POOLS'); + await Auth.signIn(USERNAME2, REAL_PASSWORD); + try { + await createMember(GRAPHQL_CLIENT_2, { id: '001', name: 'notUser' }); + } catch (err) { + expect(err).toBeDefined(); + expect(err.graphQLErrors[0].errorType).toEqual('Unauthorized'); + } + + // though they should see when a new member is created + const observer = API.graphql({ + query: gql` + subscription OnCreateMember { + onCreateMember { + id + name + createdAt + updatedAt + } + } + `, + authMode: GRAPHQL_AUTH_MODE.AMAZON_COGNITO_USER_POOLS, + }) as unknown as Observable; + let subscription: ZenObservable.Subscription; + const subscriptionPromise = new Promise((resolve, _) => { + subscription = observer.subscribe((event: any) => { + const member = event.value.data.onCreateMember; + subscription.unsubscribe(); + expect(member).toBeDefined(); + expect(member.id).toEqual(memberID); + expect(member.name).toEqual(memberName); + resolve(undefined); + }); + }); + await new Promise(res => setTimeout(res, SUBSCRIPTION_DELAY)); + // user that is authorized creates the update the mutation + const createMemberResponse = await createMember(GRAPHQL_CLIENT_1, { id: memberID, name: memberName }); + expect(createMemberResponse.data.createMember.id).toEqual(memberID); + expect(createMemberResponse.data.createMember.name).toEqual(memberName); + + return withTimeOut(subscriptionPromise, SUBSCRIPTION_TIMEOUT, 'OnCreateMember Subscription timed out', () => { + subscription?.unsubscribe(); + }); +}); + +test('authorized group is allowed to listen to onUpdate', async () => { + const memberID = '001update'; + const oldMemberName = 'oldUsername'; + const newMemberName = 'newUsername'; + reconfigureAmplifyAPI('AMAZON_COGNITO_USER_POOLS'); + await Auth.signIn(USERNAME2, REAL_PASSWORD); + + const observer = API.graphql({ + query: gql` + subscription OnUpdateMember { + onUpdateMember { + id + name + createdAt + updatedAt + } + } + `, + authMode: GRAPHQL_AUTH_MODE.AMAZON_COGNITO_USER_POOLS, + }) as unknown as Observable; + let subscription: ZenObservable.Subscription; + + const subscriptionPromise = new Promise((resolve, reject) => { + subscription = observer.subscribe({ + next: event => { + const subResponse = event.value.data.onUpdateMember; + subscription.unsubscribe(); + expect(subResponse).toBeDefined(); + expect(subResponse.id).toEqual(memberID); + expect(subResponse.name).toEqual(newMemberName); + resolve(undefined); + }, + complete: () => {}, + error: err => { + reject(err); + }, + }); + }); + const createMemberResponse = await createMember(GRAPHQL_CLIENT_1, { id: memberID, name: oldMemberName }); + expect(createMemberResponse.data.createMember.id).toEqual(memberID); + expect(createMemberResponse.data.createMember.name).toEqual(oldMemberName); + await new Promise(res => setTimeout(res, SUBSCRIPTION_DELAY)); + // user that is authorized creates the update the mutation + const updateMemberResponse = await updateMember(GRAPHQL_CLIENT_1, { id: memberID, name: newMemberName }); + expect(updateMemberResponse.data.updateMember.id).toEqual(memberID); + expect(updateMemberResponse.data.updateMember.name).toEqual(newMemberName); + + return withTimeOut(subscriptionPromise, SUBSCRIPTION_TIMEOUT, 'OnUpdateMember Subscription timed out', () => { + subscription?.unsubscribe(); + }); +}); + +test('authorized group is allowed to listen to onDelete', async () => { + const memberID = '001delete'; + const memberName = 'newUsername'; + reconfigureAmplifyAPI('AMAZON_COGNITO_USER_POOLS'); + await Auth.signIn(USERNAME2, REAL_PASSWORD); + const observer = API.graphql({ + query: gql` + subscription OnDeleteMember { + onDeleteMember { + id + name + createdAt + updatedAt + } + } + `, + authMode: GRAPHQL_AUTH_MODE.AMAZON_COGNITO_USER_POOLS, + }) as unknown as Observable; + let subscription: ZenObservable.Subscription; + + const subscriptionPromise = new Promise((resolve, reject) => { + subscription = observer.subscribe({ + next: event => { + subscription.unsubscribe(); + const subResponse = event.value.data.onDeleteMember; + subscription.unsubscribe(); + expect(subResponse).toBeDefined(); + expect(subResponse.id).toEqual(memberID); + expect(subResponse.name).toEqual(memberName); + resolve(undefined); + }, + error: err => { + reject(err); + }, + }); + }); + + const createMemberResponse = await createMember(GRAPHQL_CLIENT_1, { id: memberID, name: memberName }); + expect(createMemberResponse.data.createMember.id).toEqual(memberID); + expect(createMemberResponse.data.createMember.name).toEqual(memberName); + await new Promise(res => setTimeout(res, SUBSCRIPTION_DELAY)); + // user that is authorized creates the update the mutation + const deleteMemberResponse = await deleteMember(GRAPHQL_CLIENT_1, { id: memberID }); + expect(deleteMemberResponse.data.deleteMember.id).toEqual(memberID); + + return withTimeOut(subscriptionPromise, SUBSCRIPTION_TIMEOUT, 'OnDeleteMember Subscription timed out', () => { + subscription?.unsubscribe(); + }); +}); + +// ownerField Tests +test('Test subscription onCreatePost with ownerField', async () => { + reconfigureAmplifyAPI('AMAZON_COGNITO_USER_POOLS'); + await Auth.signIn(USERNAME1, REAL_PASSWORD); + const observer = API.graphql({ + query: gql` + subscription OnCreatePost { + onCreatePost(postOwner: "${USERNAME1}") { + id + title + postOwner + } + }`, + authMode: GRAPHQL_AUTH_MODE.AMAZON_COGNITO_USER_POOLS, + }) as unknown as Observable; + let subscription: ZenObservable.Subscription; + const subscriptionPromise = new Promise((resolve, _) => { + subscription = observer.subscribe((event: any) => { + const post = event.value.data.onCreatePost; + subscription.unsubscribe(); + expect(post.title).toEqual('someTitle'); + expect(post.postOwner).toEqual(USERNAME1); + resolve(undefined); + }); + }); + await new Promise(res => setTimeout(res, SUBSCRIPTION_DELAY)); + + const createPostResponse = await createPost(GRAPHQL_CLIENT_1, { + title: 'someTitle', + postOwner: USERNAME1, + }); + expect(createPostResponse.data.createPost.title).toEqual('someTitle'); + expect(createPostResponse.data.createPost.postOwner).toEqual(USERNAME1); + + return withTimeOut(subscriptionPromise, SUBSCRIPTION_TIMEOUT, 'OnCreatePost Subscription timed out', () => { + subscription?.unsubscribe(); + }); +}); + +test('Test onCreatePost with optional argument', async () => { + reconfigureAmplifyAPI('AMAZON_COGNITO_USER_POOLS'); + await Auth.signIn(USERNAME1, REAL_PASSWORD); + const failedObserver = API.graphql({ + query: gql` + subscription OnCreatePost { + onCreatePost { + id + title + postOwner + } + } + `, + authMode: GRAPHQL_AUTH_MODE.AMAZON_COGNITO_USER_POOLS, + }) as unknown as Observable; + let subscription: ZenObservable.Subscription; + const subscriptionPromise = new Promise((resolve, _) => { + subscription = failedObserver.subscribe( + event => {}, + err => { + expect(err.error.errors[0].message).toEqual( + 'Connection failed: {"errors":[{"errorType":"Unauthorized","message":"Not Authorized to access onCreatePost on type Post"}]}', + ); + resolve(undefined); + }, + ); + }); + + return withTimeOut(subscriptionPromise, SUBSCRIPTION_TIMEOUT, 'OnCreatePost Subscription timed out', () => { + subscription?.unsubscribe(); + }); +}); + +// iam tests +test('Test that IAM can listen and read to onCreatePost', async () => { + const postID = 'subscriptionID'; + const postTitle = 'titleMadeByPostOwner'; + + reconfigureAmplifyAPI('AWS_IAM'); + await Auth.signIn(USERNAME1, REAL_PASSWORD); + const observer = API.graphql({ + query: gql` + subscription OnCreatePost { + onCreatePost { + id + title + postOwner + } + } + `, + authMode: GRAPHQL_AUTH_MODE.AWS_IAM, + }) as unknown as Observable; + let subscription: ZenObservable.Subscription; + + const subscriptionPromise = new Promise((resolve, reject) => { + subscription = observer.subscribe( + (event: any) => { + const post = event.value.data.onCreatePost; + subscription.unsubscribe(); + expect(post).toBeDefined(); + expect(post.id).toEqual(postID); + expect(post.title).toEqual(postTitle); + expect(post.postOwner).toEqual(USERNAME1); + resolve(undefined); + }, + err => { + reject(err); + }, + ); + }); + await new Promise(res => setTimeout(res, SUBSCRIPTION_DELAY)); + + const createPostResponse = await createPost(GRAPHQL_CLIENT_1, { id: postID, title: postTitle, postOwner: USERNAME1 }); + expect(createPostResponse.data.createPost.id).toEqual(postID); + expect(createPostResponse.data.createPost.title).toEqual(postTitle); + expect(createPostResponse.data.createPost.postOwner).toEqual(USERNAME1); + await new Promise(res => setTimeout(res, SUBSCRIPTION_DELAY)); + await subscriptionPromise; +}); + +test('test that subcsription with apiKey', async () => { + reconfigureAmplifyAPI('API_KEY', API_KEY); + await Auth.signIn(USERNAME1, REAL_PASSWORD); + const observer = API.graphql({ + query: gql` + subscription OnCreateTodo { + onCreateTodo { + id + description + name + } + } + `, + authMode: GRAPHQL_AUTH_MODE.API_KEY, + }) as unknown as Observable; + let subscription: ZenObservable.Subscription; + + const subscriptionPromise = new Promise((resolve, _) => { + subscription = observer.subscribe((event: any) => { + const post = event.value.data.onCreateTodo; + subscription.unsubscribe(); + expect(post.description).toEqual('someDescription'); + expect(post.name).toBeNull(); + resolve(undefined); + }); + }); + + await new Promise(res => setTimeout(res, SUBSCRIPTION_DELAY)); + + const createTodoResponse = await createTodo(GRAPHQL_IAM_AUTH_CLIENT, { + description: 'someDescription', + name: 'todo1', + }); + expect(createTodoResponse.data.createTodo.description).toEqual('someDescription'); + expect(createTodoResponse.data.createTodo.name).toEqual(null); + + return withTimeOut(subscriptionPromise, SUBSCRIPTION_TIMEOUT, 'OnCreateTodo Subscription timed out', () => { + subscription?.unsubscribe(); + }); +}); + +test('test that subscription with apiKey onUpdate', async () => { + reconfigureAmplifyAPI('API_KEY', API_KEY); + await Auth.signIn(USERNAME1, REAL_PASSWORD); + const observer = API.graphql({ + query: gql` + subscription OnUpdateTodo { + onUpdateTodo { + id + description + name + } + } + `, + authMode: GRAPHQL_AUTH_MODE.API_KEY, + }) as unknown as Observable; + let subscription: ZenObservable.Subscription; + const subscriptionPromise = new Promise((resolve, reject) => { + subscription = observer.subscribe( + (event: any) => { + const todo = event.value.data.onUpdateTodo; + subscription.unsubscribe(); + expect(todo.id).toEqual(todo2ID); + expect(todo.description).toEqual('todo2newDesc'); + expect(todo.name).toBeNull(); + resolve(undefined); + }, + err => { + reject(undefined); + }, + ); + }); + await new Promise(res => setTimeout(res, SUBSCRIPTION_DELAY)); + + const todo2 = await createTodo(GRAPHQL_IAM_AUTH_CLIENT, { + description: 'updateTodoDesc', + name: 'todo2', + }); + expect(todo2.data.createTodo.id).toBeDefined(); + const todo2ID = todo2.data.createTodo.id; + expect(todo2.data.createTodo.description).toEqual('updateTodoDesc'); + expect(todo2.data.createTodo.name).toBeNull(); + + // update the description on todo + const updateResponse = await updateTodo(GRAPHQL_IAM_AUTH_CLIENT, { + id: todo2ID, + description: 'todo2newDesc', + }); + expect(updateResponse.data.updateTodo.id).toEqual(todo2ID); + expect(updateResponse.data.updateTodo.description).toEqual('todo2newDesc'); + + return withTimeOut(subscriptionPromise, SUBSCRIPTION_TIMEOUT, 'createTodo Subscription timed out', () => { + subscription?.unsubscribe(); + }); +}); + +test('test that subscription with apiKey onDelete', async () => { + reconfigureAmplifyAPI('API_KEY', API_KEY); + await Auth.signIn(USERNAME1, REAL_PASSWORD); + const observer = API.graphql({ + query: gql` + subscription OnDeleteTodo { + onDeleteTodo { + id + description + name + } + } + `, + authMode: GRAPHQL_AUTH_MODE.API_KEY, + }) as unknown as Observable; + let subscription: ZenObservable.Subscription; + const subscriptionPromise = new Promise((resolve, _) => { + subscription = observer.subscribe((event: any) => { + const todo = event.value.data.onDeleteTodo; + subscription.unsubscribe(); + expect(todo.id).toEqual(todo3ID); + expect(todo.description).toEqual('deleteTodoDesc'); + expect(todo.name).toBeNull(); + resolve(undefined); + }); + }); + await new Promise(res => setTimeout(res, SUBSCRIPTION_DELAY)); + + const todo3 = await createTodo(GRAPHQL_IAM_AUTH_CLIENT, { + description: 'deleteTodoDesc', + name: 'todo3', + }); + expect(todo3.data.createTodo.id).toBeDefined(); + const todo3ID = todo3.data.createTodo.id; + expect(todo3.data.createTodo.description).toEqual('deleteTodoDesc'); + expect(todo3.data.createTodo.name).toBeNull(); + + // delete todo3 + await deleteTodo(GRAPHQL_IAM_AUTH_CLIENT, { + id: todo3ID, + }); + + return withTimeOut(subscriptionPromise, SUBSCRIPTION_TIMEOUT, ' OnDelete Todo Subscription timed out', () => { + subscription?.unsubscribe(); + }); +}); + +function reconfigureAmplifyAPI(appSyncAuthType: string, apiKey?: string) { + if (appSyncAuthType === 'API_KEY') { + API.configure({ + aws_appsync_graphqlEndpoint: GRAPHQL_ENDPOINT, + aws_appsync_region: AWS_REGION, + aws_appsync_authenticationType: appSyncAuthType, + aws_appsync_apiKey: apiKey, + }); + } else { + API.configure({ + aws_appsync_graphqlEndpoint: GRAPHQL_ENDPOINT, + aws_appsync_region: AWS_REGION, + aws_appsync_authenticationType: appSyncAuthType, + }); + } +} + +// mutations +async function createMember(client: AWSAppSyncClient, input: MemberInput) { + const request = gql` + mutation CreateMember($input: CreateMemberInput!) { + createMember(input: $input) { + id + name + createdAt + updatedAt + } + } + `; + return await client.mutate({ mutation: request, variables: { input } }); +} + +async function updateMember(client: AWSAppSyncClient, input: MemberInput) { + const request = gql` + mutation UpdateMember($input: UpdateMemberInput!) { + updateMember(input: $input) { + id + name + createdAt + updatedAt + } + } + `; + return await client.mutate({ mutation: request, variables: { input } }); +} + +async function deleteMember(client: AWSAppSyncClient, input: MemberInput) { + const request = gql` + mutation DeleteMember($input: DeleteMemberInput!) { + deleteMember(input: $input) { + id + name + createdAt + updatedAt + } + } + `; + return await client.mutate({ mutation: request, variables: { input } }); +} + +async function createStudent(client: AWSAppSyncClient, input: CreateStudentInput) { + const request = gql` + mutation CreateStudent($input: CreateStudentInput!) { + createStudent(input: $input) { + id + name + email + ssn + owner + } + } + `; + return await client.mutate({ mutation: request, variables: { input } }); +} + +async function updateStudent(client: AWSAppSyncClient, input: UpdateStudentInput) { + const request = gql` + mutation UpdateStudent($input: UpdateStudentInput!) { + updateStudent(input: $input) { + id + name + email + ssn + owner + } + } + `; + return await client.mutate({ mutation: request, variables: { input } }); +} + +async function deleteStudent(client: AWSAppSyncClient, input: DeleteTypeInput) { + const request = gql` + mutation DeleteStudent($input: DeleteStudentInput!) { + deleteStudent(input: $input) { + id + name + email + ssn + owner + } + } + `; + return await client.mutate({ mutation: request, variables: { input } }); +} + +async function createPost(client: AWSAppSyncClient, input: CreatePostInput) { + const request = gql` + mutation CreatePost($input: CreatePostInput!) { + createPost(input: $input) { + id + title + postOwner + } + } + `; + return await client.mutate({ mutation: request, variables: { input } }); +} + +async function createTodo(client: AWSAppSyncClient, input: CreateTodoInput) { + const request = gql` + mutation CreateTodo($input: CreateTodoInput!) { + createTodo(input: $input) { + id + description + name + } + } + `; + return await client.mutate({ mutation: request, variables: { input } }); +} + +async function updateTodo(client: AWSAppSyncClient, input: UpdateTodoInput) { + const request = gql` + mutation UpdateTodo($input: UpdateTodoInput!) { + updateTodo(input: $input) { + id + description + name + } + } + `; + return await client.mutate({ mutation: request, variables: { input } }); +} + +async function deleteTodo(client: AWSAppSyncClient, input: DeleteTypeInput) { + const request = gql` + mutation DeleteTodo($input: DeleteTodoInput!) { + deleteTodo(input: $input) { + id + description + name + } + } + `; + return await client.mutate({ mutation: request, variables: { input } }); +} diff --git a/packages/graphql-transformers-e2e-tests/src/cognitoUtils.ts b/packages/graphql-transformers-e2e-tests/src/cognitoUtils.ts index 86409aeed61..a667ec4b276 100644 --- a/packages/graphql-transformers-e2e-tests/src/cognitoUtils.ts +++ b/packages/graphql-transformers-e2e-tests/src/cognitoUtils.ts @@ -12,7 +12,7 @@ import { } from 'aws-sdk/clients/cognitoidentityserviceprovider'; import { ResourceConstants } from 'graphql-transformer-common'; import { IAM as cfnIAM, Cognito as cfnCognito } from 'cloudform-types'; -import { default as CognitoClient } from 'aws-sdk/clients/cognitoidentityserviceprovider'; +import { CognitoIdentityServiceProvider as CognitoClient, CognitoIdentity } from 'aws-sdk'; import TestStorage from './TestStorage'; import DeploymentResources from 'graphql-transformer-core/lib/DeploymentResources'; @@ -100,6 +100,37 @@ export async function addUserToGroup(groupName: string, username: string, userPo }); } +export async function createIdentityPool( + client: CognitoIdentity, + identityPoolName: string, + params: { authRoleArn: string; unauthRoleArn: string; providerName: string; clientId: string }, +): Promise { + const idPool = await client + .createIdentityPool({ + IdentityPoolName: identityPoolName, + AllowUnauthenticatedIdentities: true, + CognitoIdentityProviders: [ + { + ProviderName: params.providerName, + ClientId: params.clientId, + }, + ], + }) + .promise(); + + await client + .setIdentityPoolRoles({ + IdentityPoolId: idPool.IdentityPoolId, + Roles: { + authenticated: params.authRoleArn, + unauthenticated: params.unauthRoleArn, + }, + }) + .promise(); + + return idPool.IdentityPoolId; +} + export async function createUserPool(client: CognitoClient, userPoolName: string): Promise { return new Promise((res, rej) => { const params: CreateUserPoolRequest = { @@ -135,6 +166,14 @@ export async function deleteUserPool(client: CognitoClient, userPoolId: string): }); } +export async function deleteIdentityPool(client: CognitoIdentity, identityPoolId: string) { + await client + .deleteIdentityPool({ + IdentityPoolId: identityPoolId, + }) + .promise(); +} + export async function createUserPoolClient( client: CognitoClient, userPoolId: string, diff --git a/packages/graphql-transformers-e2e-tests/src/deployNestedStacks.ts b/packages/graphql-transformers-e2e-tests/src/deployNestedStacks.ts index 8970de56761..fbdae01c607 100644 --- a/packages/graphql-transformers-e2e-tests/src/deployNestedStacks.ts +++ b/packages/graphql-transformers-e2e-tests/src/deployNestedStacks.ts @@ -3,8 +3,8 @@ import { CloudFormationClient } from './CloudFormationClient'; import * as fs from 'fs'; import * as path from 'path'; import { DeploymentResources } from 'graphql-transformer-core/lib/DeploymentResources'; -import { deleteUserPool } from './cognitoUtils'; -import CognitoIdentityServiceProvider from 'aws-sdk/clients/cognitoidentityserviceprovider'; +import { deleteUserPool, deleteIdentityPool } from './cognitoUtils'; +import { CognitoIdentityServiceProvider, CognitoIdentity } from 'aws-sdk'; import emptyBucket from './emptyBucket'; function deleteDirectory(directory: string) { @@ -203,10 +203,15 @@ export const cleanupStackAfterTest = async ( stackName: string, cf: CloudFormationClient, cognitoParams?: { cognitoClient: CognitoIdentityServiceProvider; userPoolId: string }, + identityParams?: { identityClient: CognitoIdentity; identityPoolId: string }, ) => { try { await cf.deleteStack(stackName); + if (identityParams) { + await deleteIdentityPool(identityParams.identityClient, identityParams.identityPoolId); + } + if (cognitoParams) { await deleteUserPool(cognitoParams.cognitoClient, cognitoParams.userPoolId); }