AWS Amplify Authorization Pattern

In this post I want to discuss one option for authorizing users to do "stuff" in a React web app built using AWS Amplify. We've walked through the process for creating React web apps that leverage AWS Amplify in other posts so I am going to assume if you are reading this you are familiar with the process.

Getting Started

I am going to leverage the @auth directive provided by Amplify. You can (and should) read more about that here: https://aws-amplify.github.io/docs/cli/graphql#auth.

In order to use @auth there are some prerequisites.
  1. Authentication - we must be using AWS Cognito authentication from Amplify as outlined here (amplify add auth): https://aws-amplify.github.io/docs/js/authentication.
  2. API - we must be using AWS AppSync (GraphQL) for our API from Amplify as outlined here (amplify add api): https://aws-amplify.github.io/docs/js/api.
  3. We must be using the @model directive to tie our API to an AWS DynamoDB backend as outlined here: https://aws-amplify.github.io/docs/cli/graphql#model.
Fortunately, these prerequisites represent a common pattern for using Amplify (we have followed this same pattern in other posts, such as: https://www.fullsapps.com/2019/02/calling-aws-appsync-or-any-graphql-api_14.html).

With our app setup per the prerequisites above we can use the @auth directive. This allows us to add authorization rules to the GraphQL schema we are already defining using the @model directive. Using the @auth directive we can accomplish a lot with very little code.

Authorization Pattern

I'm interested in exploring a pretty typical authorization pattern for a web or mobile app. A user will create an account which they will use to login to the app. This account will then limit what the user can see to the data they "own". This will prevent one user from seeing another user's data.

This app also consists of back-end services that run in the background and perform operations for users. These services  generate data that users will have access to. So, we need to be able to assign data generated by a service to specific users.

It is very straightforward to apply the @auth directive to manage this authorization scenario. I am going to apply auth rules by adding the @auth directive to the models (in the auto generated schema.graphal file) where this scenario applies. It look like this:
...
type Something
  @model
  @auth(rules: [{ allow: owner }, { allow: groups, groups: ["Admins"] }]) {
  id: ID!
...
  owner: String
}
...

There are two rules being applied here. Let's talk about each of them in more detail.

{ allow: owner }

In this rule the owner is granted full control over this type, but only to their own data.

Note that I am also adding a field called owner to the type. This is where most of the magic happens. I believe I could leave this field out and it would get added automatically but for the sake of more easily read code I am including it. It is intentionally not a required field. In fact, anytime a user operates on this type (performs any queries or mutations) I am not going to include anything in the owner field. By adding the @auth directive the resolvers that get automatically generated (via Amplify) will append the owner to the request based on the currently authenticated (logged in) user. In fact, each of the resolvers that get created for this type will apply different rules based on the authenticated user. This is covered well in the docs but I think it is worth repeating here. These are the resolvers that get automatically generated for any type and how the authenticated user is included:

  • get - if the authenticated user does not match the owner field an unauthorized result is returned
  • list - results are filters to those where the authenticated user matches the owner field (this is really handy, it prevents one user from seeing another's data)
  • create - the authenticated user is added to the owner field automatically (this puts the whole thing in motion)
  • update - if the authenticated user does not match the owner field the update is rejected
  • delete - if the authenticated user does not match the owner field the delete is rejected

{ allow: groups, groups: ["Admins"] }

This rule states that anyone in the group Admins has full control over this type.

The Admins group is a group that I have manually created in Cognito in the user pool automatically generated by Amplify (based on amplify add auth). I have created a new "service" user and added them to the Admins group. This give the "service" user access to the data of all users. Now, if we want/need to do something on behalf of a user all we have to do is authenticate to Cognito with this user (or any other user account that is part of the Admins group).

The last key part is to have this service account include the username of the user that should own the data in the owner field. It's worth noting that if we do include an owner in the owner field the resolvers we discussed above will not attempt to replace it (which is exactly what we want). We can treat the owner field as we would any other field.

Example of including owner when creating data (this sample follows what we created in this post: https://www.fullsapps.com/2019/02/calling-aws-appsync-or-any-graphql-api_14.html.):

    const mutation = `
        mutation CreateTodo($input:CreateTodoInput!) {
          createTodo(input:$input) {
            id
            name
            description
            owner
          }
        }
    `;
    const variables = `
        {
        "input": {
            "name": "todo from Lambda",
            "description": "New Todo created in a Lambda function",
            "owner": "some_username"
          }
        }
    `;

    graphQLClient.request(mutation, variables);

More Options

There are many more options for how you can apply authorization rules. For example, you can chose a different name for the owner field if you wish. You simply have to include the name definition in the rule (the docs cover this). Also, you can get much more granular with how the rules are applied. For example, a user in one group might have permissions to mutate a type, while another group might only have query permissions. Or more granular still, a user might only have create permissions and nothing else (the docs cover this).

Conclusion

If you are happy to follow the patterns of AWS Amplify the @auth directive is another really important feature that can save a ton of time.

Comments

Popular posts from this blog

Calling a REST API from AWS Lambda (The Easy Way)

Calling AWS AppSync, or any GraphQL API, from AWS Lambda, part 1

32. Media Library - Uploading Images, Part 1