Fine Grained Access Control with Cognito Identity Pools

Time to read: 7 minutes

This blog covers configurations I have been using to build the saas-stack. A multi-tenant boiler plate for Serverless Stack apps.

When building that application I wanted to push the boundaries of Cognito multi-tenancy. I want to talk directly to dynamoDB from the browser for reads and be able to cache all my API Gateway GET requests.

But I ask you to think, how could you do this multi-tenant safe? See below for the results:

Example website: https://d1mmk10lcvudwp.cloudfront.net

Trust relationships with assumed roles

In the beginning

For a long time my brain has been mulling over multi-tenancy in AWS. There are a couple different ways to do Multi-Tenancy in AWS; the first decision:

  1. Create an account per tenant with no share infrastructure.
  2. Have multiple tenants in one AWS account.

This blog only thinks about number 2.

So if we want to share an AWS Account we still have some options:

  1. Create a new resource (API Gateway, DynamoDB) per tenant.
  2. Share common resources and use IAM to perform strict data segregation.

🧠 Data segregation in an AWS account has tripped up both of my latest engagements, it takes some pre-meditation to say the least!

This blog only thinks about number 2.

So we are going to have shared infrastructure in a single AWS Account. How will we secure everything?

IAM to the rescue 🦸

AWS IAM

AWS IAM has a concept called fine-grained access control.

This type of Role is designed, with regard to allowing specific Principals (what AWS calls the entities assuming the Role), to access specific Resources.

At this point, I think we need to hit reset and introduce:

  • AWS IAM Roles
  • AWS IAM Principals
  • AWS IAM Trust Relationships

AWS IAM - Basics

If we think of a straight forward example, here, is a lambda function.

The lambda function has a Role, that it needs to assume, in order to do all its lambda things. Specifically, if the lambda needs to interact with other AWS services, it needs to have a Role that allows it to do that.

Trust relationships with assumed roles

The lambda function, wants to assume a role, and so, we setup a trust relationship, that says, when the Principal is lambda, then it can assumed the Role.

The lambda then gets access to all the Actions in the Role, for all of the Resources in the Role.

AWS IAM - Fine grained Access Control

Fine grained control adds another layer to this relationship.

With fine grained control, we go beyond relying solely on the principal and add conditions. These conditions can be added to both the trust policy, and the iam policy.

Here is an example:

Trust relationships with assumed roles

Summarizing the above:

  • Cognito Identity wants to assume the IAM policy, and this is allowed if:
    • The "cognito-identity.amazonaws.com:aud" string is exactly equal to "eu-west-2:e43159e7-b1bd-4cd2-8cbf-xxxxxxxxxxxx".
    • The "cognito-identity.amazonaws.com:amr" has any of its values as "authenticated".

AWS IAM - Principal Tag Mapping

There is a final piece to the puzzle, Principal Tags which AWS defines as...

Control what the person making the request (the principal) is allowed to do based on the tags that are attached to that person's IAM user or role. [1]

In this use case with Amazon Cognito, we are specifically going to associate Principal Tags to the JSON Web Token (JWT) claims that are returned by Cognito.

These policies enhance the Fine Grained case with conditions of the type:

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Condition": {
        "ForAllValues:StringLike": {
          "dynamodb:LeadingKeys": [
              // Wait wait wait
              // what the heck is this thing?
              "${aws:PrincipalTag/org}#*"
          ]
        }
      },
      "Action": [
        "dynamodb:GetItem",
        "dynamodb:Query"
      ],
      "Resource": "::dynamodb:eu-west-2::table/ExampleTableName",
      "Effect": "Allow",
      "Sid": "AllowPrecedingKeysToDynamoDBOrganisation"
    }
  ]
}

Now, all we need to do, is setup cognito to map a principal tag.

Then, when a user assumes this role, they would only be able to see things in DynamoDB table ExampleTableName which have a primary key starting with ${aws:PrincipalTag/org}#

Putting it all together

Please see the saas-stack for a full working Infrastructure as Code example.

Trust relationships with assumed roles

  1. A user signs up, and gets saved in cognito user pool.
  2. The user gets the auto-confirm email.
  3. A post-confirmation lambda is triggered
  4. The post-confirmation lambda tells cognito to insert the UUID of the organisation as an immutable custom attribute on the user.
  5. The user exchanges their JWT for temporary credentials associated with the identity pool role.
  6. The policy attached to those credentials is already secured to only allow access to PUBLIC resources and the tenants own resources (using the org uuid); nothing else!
  7. The tenant can make requests to API Gateway, and DynamoDB, but will have restricted access.

Wrap up

I have been working on this for a while, and I am still waiting for an upstream change in the aws-cdk to make it fully supported and easy to configure: https://github.com/aws/aws-cdk/issues/15908

Once that hits the stable CDK builds, I think this will be a much more secure way to perform IAM, and keep your lambda code clean.

There is a big but!

This is very deep integration into AWS, perhaps as deep as you can go, and its not going to be the most flexible approach; so take this with a pinch of salt 🧂