How to use Cognito with AppSync
Access control for Cognito users in AppSync
Access control in AppSync
AWS AppSync provides a managed GraphQL API that you can use to implement backend functionality for users. AppSync integrates with Cognito User Pools, which makes it easy to add sign-in (and sign-up) functionality to an API.
After signing in, you might want to implement different access levels for the users. Maybe some users can use queries and mutations others can not, or different users can access different objects.
In this article we'll look into how to implement different authorization modes, and what tools Cognito and AppSync gives to define who can do what in a GraphQL API.
Authorization providers
To use any authorization mode, you need to configure that for the API. Since this article is about Cognito User Pools, we'll add only that with a default of DENY.
How you configure the API auth providers changes a lot. Check out this article for a longer explanation.
Data model
The data model we'll use is rather simple but it contains all the common access control schemes. There are users who can sign in and they can be admin
or user
. Normal users can query only themselves, but admins can get all the users in the database.
Then there are documents. There is an access level marking them PUBLIC
or SECRET
. Users have permission lists that define whether they can
access SECRET documents or not. These lists are stored in the database for each user.
In GraphQL, this data model translates to this schema:
type User {
sub: ID!
permissions: [String!]!
}
enum Level {
SECRET
PUBLIC
}
type Document {
level: Level!
text: String!
}
type Query {
user(sub: ID!): User
me: User
documents: [Document!]!
allUsers: [User!]!
}
And we'll implement these access control points:
- Only admins can call
allUsers
- Non-admins can only get themselves in the
user
query documents
returnSECRET
documents only if the caller has thesecret_documents
permission
Schema-based access control
AppSync provides a set of directives that you can use to define access control right in the GraphQL schema. This is convenient as it will be self-documenting and also you get it for free: just add the directive, and everything else is managed by AppSync.
There are some downsides to this approach though. The granularity you can use is the Cognito group, so you can define what different types of users can do, but you can not use, for example, a database value here. This is RBAC (Role-Based Access Control, don't confuse it with IAM Roles).
The other downside is that what the directives do is kind of a magic and also the default (without a directive) changes in non-intuitive ways depending on the API configuration. It's easy to end up breaking authorization if you are not careful.
You can add authorization directives to types and fields. You can define who can run a specific query or mutation, and also who can traverse the graph using a specific field.
In our data model, we can use this type of access control for the allUsers
query as we want to only allow it for admin users and nobody else. So, defining
this is just adding some directives:
type Query {
allUsers: [User!]!
@aws_cognito_user_pools(cognito_groups: ["admin"])
@aws_auth(cognito_groups: ["admin"])
}
Why use @aws_cognito_user_pools
and @aws_auth
together? The former is effective if there are more than one authorization providers, and the latter
if when there is only one. Using both is a safer way to define authorization.
Resolvers-based access control
But auth directives only go this far. If a query can be run by both admins and regular users but with different allowed parameters, directives are not enough on their own. This is implemented in the resolvers.
Decision based on user id and groups
AppSync provides information about the current user in the $context.identity
value that resolvers can use to check access.
In our data model, the user
query takes a sub
argument. It should allow only admins to query any user, and normal users can only query themselves.
To implement this, the $ctx.identity.sub
is the Cognito ID of the user, and the $ctx.identity.groups
is the list of groups the user belongs to.
#if (!$ctx.identity.groups.contains("admin") && $ctx.identity.sub != $ctx.args.sub)
$util.unauthorized()
#else
{
"version" : "2018-05-29",
"operation" : "GetItem",
"key" : {
"sub": {"S": $util.toJson($ctx.args.sub)}
},
"consistentRead" : true
}
#end
The $util.unauthorized()
throws an error. The above template checks the permissions (#if ...
), and either throws an Unauthorized
error or
sends a request to DynamoDB.
Decision based on database value
The $ctx.identity
allows more fine-grained control, but sometimes it's not enough. It is especially the case when access is determined based on a database
value.
In our data model, the user objects in the database have a permissions
list. If it is needed for an access decision then we need to read it first.
This is where pipeline resolvers are needed as AppSync needs to do multiple things to provide the result.
The first step is to read the user object from the database and store it in the stash. The stash is a special place that is available for all resolvers in the pipeline, making it an ideal place to store objects that might be needed in later steps.
The request mapping:
{
"version" : "2018-05-29",
"operation" : "GetItem",
"key" : {
"sub": {"S": $util.toJson($ctx.identity.sub)}
},
"consistentRead" : true
}
This uses the $ctx.identity.sub
as that is the unique ID of the user returned by Cognito. In DynamoDB, that is the partition key for the user objects.
The response mapping:
#if ($ctx.error)
$util.error($ctx.error.message, $ctx.error.type)
#end
$util.qr($ctx.stash.put("user", $ctx.result))
{}
The first step is error handling, as AppSync should terminate the processing if it can
not read the user. Then it puts the $ctx.result
to the $ctx.stash.user
, so it will be available in the later resolvers, and they can use it to throw
Unauthorized errors or not.
Filtering results
A special case of access control is to filter result elements. In our data model, all users can query the documents but only with a special permission will
the result include SECRET
ones.
To implement this, we'll use the user object in the stash stored in the previous step. Then the resolver queries the documents from the database:
{
"version" : "2018-05-29",
"operation" : "Scan"
}
This returns all the items (we don't implement pagination here to make the example simple) and the response template needs to filter the results:
#if ($ctx.error)
$util.error($ctx.error.message, $ctx.error.type)
#end
#set($results = [])
#foreach($res in $ctx.result.items)
#if($res.level == "PUBLIC" || $ctx.stash.user.permissions.contains("secret_documents"))
$util.qr($results.add($res))
#end
#end
$util.toJson($results)
With this resolver, the decision to include a given element in the result is based on a value stored in the database for the current user.
Conclusion
AppSync provides several ways to define access control for a GraphQL API. You can implement RBAC using Cognito groups using directives in the schema, and you can use more fine-grained logic in the resolvers.