How to use Cognito with AppSync

Access control for Cognito users in AppSync

Author's image
Tamás Sallai
6 mins

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 return SECRET documents only if the caller has the secret_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.

January 4, 2022