AppSync Cognito directives

How to restrict access to Cognito Groups in the AppSync schema

Author's image
Tamás Sallai
6 mins

Cognito with AppSync

When you have an AppSync API that you want users to access, you need to add authentication. Cognito User Pools is the AWS-native solution to add sign-up and sign-in functionality, and AppSync integrates with it natively. Cognito supports user groups so that you can implement RBAC (Role-Based Access Control) by specifying what each group can do and it will be effective for all users in that group.

AppSync provides a way to embed access control in the GraphQL schema with a few directives that specify what groups can access a type or a field. This makes it easy to restrict queries and mutations to more privileged users, or to prevent traversing the object graph for less privileged ones.

In theory, at least. As you'll see in this article, authorization-related directives are a mess in AppSync.

For example, your API might support admins and users, and some operations are only available to the former.

type Query {
	currentUser: User

	allUsers: [User]
}

In the above type, all users should be able to query the current user object (currentUser), but only admins should be allowed to get all of them (allUsers).

Let's see how to configure it!

Authorization providers

First, we need to configure the Cognito User Pool for the API. In AppSync terminology, every API needs a primary provider, and it can also have additional ones.

For example, adding AWS IAM as a secondary provider is a practical combo. Privileged administrators and Lambda functions can take advantage of roles and IAM policies to get access to some parts of the API, while normal users use Cognito to log in.

Auth directives

Directives are a GraphQL concept that you can add to types and fields in the schema and they modify how the target entity works. AWS adds a couple of directives specific to AppSync, such as ones that define authorization.

Directives come after the entity they modify, which seems strange if you've worked with Java annotations before. For example, the @aws_cognito_user_pools directive is added for the allUsers field:

type Query {
	currentUser: User

	allUsers: [User]
	@aws_cognito_user_pools(cognito_groups: ["admin"])
}

As usual, the AWS documentation is the best resource to see what configuration is possible. In regards to Cognito User Pools, it mentions 2 directives: the @aws_auth and the @aws_cognito_user_pools.

Unfortunately, it is light on details on how they work and what is the difference between the two. The relevant part of the documentation:

You can’t use the @aws_auth directive along with additional authorization modes. @aws_auth works only in the context of AMAZON_COGNITO_USER_POOLS authorization with no additional authorization modes. However, you can use the @aws_cognito_user_pools directive in place of the @aws_auth directive, using the same arguments. The main difference between the two is that you can specify @aws_cognito_user_pools on any field and object type definitions.

I read that paragraph a few times and still couldn't pinpoint the important part in those few sentences. It turns out that it's not the The main difference ... but the You can't use .... Worse still, it's missing the part that you can't use the @aws_cognito_user_pools when there are no additional authorization modes, as we'll see later in this article.

Configuring the API

For this test, we'll consider 4 ways of API configuration:

  • Cognito auth only, and the default action is ALLOW
  • Cognito auth only, and the default action is DENY
  • Cognito auth by default, IAM as an additional
  • IAM auth by default, Cognito as an additional

Let's see how to configure these!

Cognito auth only

In this setup, only a Cognito User Group can access the API. On the Console, it looks like this:

Notice tht Default action: ALLOW. The second configuration is the same, but the option set to DENY.

Cognito + IAM

In this setup, we add 2 authorization providers: a Cognito User Pool, and AWS IAM. We can do it in two ways: Cognito as the primary, IAM as an additional, and in reverse. You'll see that it makes a difference.

Adding Cognito first and IAM second is configured this way:

Adding IAM first and Cognito second:

Test

The idea is simple: we have the @aws_cognito_user_pools and the @aws_auth directives, and the 4 configurations, and we want to see what combination works and what doesn't.

For this, I'll use this schema:

type Query {
	test_aws_cognito_user_pools_admin: String
		@aws_cognito_user_pools(cognito_groups: ["admin"])

	test_aws_cognito_user_pools_user: String
		@aws_cognito_user_pools(cognito_groups: ["user"])

	test_aws_auth_admin: String
		@aws_auth(cognito_groups: ["admin"])

	test_aws_auth_user: String
		@aws_auth(cognito_groups: ["user"])

	test_nothing: String

	test_both_admin: String
		@aws_cognito_user_pools(cognito_groups: ["admin"])
		@aws_auth(cognito_groups: ["admin"])

	test_both_user: String
		@aws_cognito_user_pools(cognito_groups: ["user"])
		@aws_auth(cognito_groups: ["user"])
}

schema {
	query: Query
}

Results

The Cognito user is in the group user. This gives this table:

Cognito only, ALLOW Cognito only, DENY Cognito + IAM IAM + Cognito
aws_cognito_user_pools_admin ✓ ! - - -
aws_cognito_user_pools_user -
aws_auth_admin - - ✓ ! * -
aws_auth_user ✓ * -
nothing - ✓ * -
both_admin - - - -
both_user

* means it behaves differently depending on whether an @aws_iam directive is also present.

Well, it's a mess and that makes it hard to reason about authorization in AppSync, which is the worst thing you can have in a security setting.

First, let's see the bad parts!

  • @aws_auth works only when Cognito is the only provider, which is stated in the docs.
  • @aws_cognito_user_pools does not work when Cognito is the only provider, which is nowhere in the docs.
  • If IAM is added first then the default decision for Cognito depends on whether there is an @aws_iam directive on the field or not
  • The default decision depends on the order of the authorization providers

For example, you add @aws_auth to restrict access. Later, you realize you need a Lambda function to call a mutation so you add AWS IAM as the secondary provider. You've opened up your whole schema for all Cognito users.

Or you have Cognito as primary and IAM as secondary and you want to deny Lambda functions to call a mutation so you remove the @aws_iam directive from that field. You've just allowed all Cognito users to call that mutation.

Recommendations

Always add Cognito directives to all types/fields, don't rely on defaults. As we've seen, how an API behaves when there is no explicit directive depends on several factors and it's easy to accidentally open up parts of the schema.

If you have only Cognito authorization, you can use @aws_auth with the user pools. But when you add a second provider, you need to keep in mind to replace that with @aws_cognito_user_pools. This is bad behavior, as it makes an unrelated change (adding a secondary provider) to break the authorization in a fail-open mode.

Use both @aws_auth and @aws_cognito_user_pools if you have a single provider. Then when you add another one, only remove @aws_auth when you are sure you'll always have more than providers configured for the API.

December 28, 2021
In this article