AppSync Cognito directives
How to restrict access to Cognito Groups in the AppSync schema
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.