Implementing item-level access control to DynamoDB tables - Authenticated users in Cognito Identity pools, Part 2

We can restrict access to specific DynamoDB items and attributes with IAM identity-based policies. This way, it will be easy to hide specific fields or allow users to see only the items we want them to see.

1. Scenarios

Confidentiality is one of the main aspects of security. We regularly have data that must be accessible only to specific users.

What if we run an application where each user might only view their data but no one else’s? What if we work with some sensitive fields in the database that should only be visible to selected users?

In this example, we’ll have a DynamoDB table where we’ll restrict access to selected attributes. We’ll also apply some techniques to ensure that users can only see their items and nothing else.

Our application processes secret agent’s data. Because the agents’ identity must remain super-secret, only the agency lead might see their real name. But regular users cannot! Also, when the office administrators (they will be the users in this example) retrieve agent data, they should only access those belonging to their department. They might not see agents whom other admins manage. It’s top secret, after all.

The example architecture builds on Cognito User pools and Identity pools. I already wrote a post about a similar setup recently, so I’ll use that architecture to demonstrate this use case.

2. The DynamoDB table

Let’s start with AgentsTable, which contains everything about our secret agents. The partition key called userId is the corresponding office administrator’s user pool ID, also known as sub. This way, we can add the agents to the relevant admin who provides them with all documentation and information for their next assignment.

It’s important to note that each user (office administrator) only sees the agent’s data whom they manage. They can’t retrieve data for agents whose administrator is a different user.

The table also has a sort key called fieldName, and many other attributes, which I won’t list. One is realName, which administrators might not see when they retrieve data from the table. Only the boss should know the agent’s real name!

In the following few sections, we’ll create IAM policies that allow access to the table items and attributes based on the criteria above.

3. Pre-requisites

The post will build on this article, and it won’t discuss how to create a user pool, identity pool, DynamoDB table and set up credentials for the application. I covered most of these in the referred post.

4. Permissions

In the post referred to above, I talked about a way to assign temporary permissions to users who belong to different groups. Say that the office administrators group will work with the permissions of the OfficeAdminRole. After they log in to the application, the identity pool will assume this role as stated in the other article.

4.1 Restricting items

Let’s start with the item restrictions. We can use tags and policy variables to control which user can access which agent item in the table. We apply the attribute-based access control strategy.

First, we should add the sts:TagSession action to OfficeAdminRole’s trust policy. It is because we’ll dynamically add the user-related value of aws:PrincipalTag to the role, so Identity pools must have permission to do it.

With this change, the trust policy will look like this:

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Principal": {
        "Federated": "cognito-identity.amazonaws.com"
      },
      "Action": [
        "sts:AssumeRoleWithWebIdentity",
        "sts:TagSession"
      ],
      "Condition": {
        "StringEquals": {
          "cognito-identity.amazonaws.com:aud": "IDENTITY_POOL_ID"
        },
        "ForAnyValue:StringLike": {
          "cognito-identity.amazonaws.com:amr": "authenticated"
        }
      }
    }
  ]
}

Authenticated users (amr) will use the role’s credentials because they will log in to the application before using it to connect to DynamoDB.

There is more to see in the role’s permissions policy that will be similar to the following:

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Condition": {
        "ForAllValues:StringEquals": {
          "dynamodb:LeadingKeys": [
            "${aws:PrincipalTag/userid}"
          ]
        }
      },
      "Action": [
        "dynamodb:Query",
        "dynamodb:GetItem",
        "dynamodb:BatchGetItem"
      ],
      "Resource": "arn:aws:dynamodb:eu-central-1:123456789012:table/AgentsTable",
      "Effect": "Allow",
    }
  ]
}

The Condition element will do the heavy lifting and allow access only to the items that belong to the corresponding user.

The dynamodb:LeadingKeys context key refers to the partition key. This key-value pair says that the partition key must match the userid tag’s value on the principal. The principal in this example is the OfficeAdminRole because the application uses the role’s credentials to access the table.

Example

Say that Jane Doe’s Cognito ID (i.e., the sub value) is COGNITO_ID_1. If the partition key in the table is COGNITO_ID_1, then the role must have a userid: COGNITO_ID_1 tag after Jane has signed in to the application.

If the role has a userid: COGNITO_ID_2 tag where COGNITO_ID_2 is John Smith’s ID, DynamoDB will only return items where the partition key is COGNITO_ID_2. But if we try to get Jane’s agents with John’s tag, or there’s no userid tag in the request, IAM will respond with an error message (see below).

4.2. Tag mapping

So far, it sounds great, but how do we add the userid tag to the role?

Tag mapping
Tag mapping

Cognito Identity pools has an attribute mapping option in the User access/Identity providers section where we can take values from the ID token and add them to principal tags.

We can create a userid tag and assign it the token’s sub value. When the identity pool assumes OfficeAdminRole after the user’s log-in, it will add userid as a session tag to the role. For this reason, we should add sts:TagSession to the role’s trust policy. If we forget it, we’ll get an error message (see below).

4.3. Restricting attributes

The agents’ real names should not be visible to office administrators! Even though the table contains this attribute, the response should not have this field when the application retrieves agent data.

To implement it we should add the following condition to OfficeAdminRole’s permissions policy:

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Condition": {
        "ForAllValues:StringEquals": {
          "dynamodb:Attributes": [
            "userId",
            "fieldName",
            "isOnAssignment"
          ],
        },
        "StringEqualsIfExists": {
          "dynamodb:Select": "SPECIFIC_ATTRIBUTES"
        }
      },
      "Action": [
        "dynamodb:Query",
        "dynamodb:GetItem",
        "dynamodb:BatchGetItem"
      ],
      "Resource": "arn:aws:dynamodb:eu-central-1:123456789012:table/AgentsTable",
      "Effect": "Allow",
    }
  ]
}

The dynamodb:Attributes context key is intuitive and clearly expresses its purpose. The values array contains the attributes we might view (userId, fieldName, isOnAssignment). We can optionally add dynamodb:Select if we query an index on the table.

The ForAllValues set operator tests if the condition set includes the value of every key in the request. Our query request should not contain fields other than userId, fieldName, and isOnAssignment. If we send realName in the request, we’ll get an AccessDenied error. It comes from IAM because we want an attribute we haven’t defined as allowed in the policy.

5. Code

The code builds on the referred post where I created a very simple React component.

The relevant part of the code is the following:

function App({ signOut, user }) {
  // ...
  const sub = user.attributes.sub;
  // ... DynamoDB client config and other code here ...
  const input = {
    ExpressionAttributeValues: {
      ':sub': sub,
    },
    KeyConditionExpression: 'userId = :sub',
    TableName: 'cognito-ddb-items-AgentsTable-16EQBEJV1DR6B',
    ProjectionExpression: 'userId,fieldName,isOnAssignment',
  };
const command = new QueryCommand(input);

We extract the sub value from the user object that the withAuthenticator Amplify wrapper provides.

input must contain the partition key in a KeyConditionExpression (since it’s a Query). The partition key for each agent is the Cognito user ID of the office administrator, that is, the sub claim from the ID token.

We must also specify ProjectionExpression because of the attribute restrictions in the role’s permissions policy. This way, the attributes will be part of the request, and the ForAllValues set operator requires just that. We don’t have to add all three, but we can’t request more.

6. Considerations

When I worked on this demo, I wanted to find answers to some questions.

If we specify sub in the input, what’s the point of having the policy? The user will get the items that belong to them anyway.

Yes, but if we try the query with another user’s Cognito ID in the code, we’ll see that IAM will reject the request. The sub claim in the token (same as the userid principal tag’s value) will differ from the requested partition key. Also, SDK is not the only way to query the table. We can use the CLI or call the API with the returned AWS credentials and specify any partition key we want. The policy will protect the data in these cases.

How about referring the Cognito ID directly?

According to the AWS documentation it’s possible to use the Cognito user ID in the role’s permission policy.

I found this part of the documentation confusing. One thing seems sure that the ${cognito-identity.amazonaws.com:sub} is not the user’s user pool ID. I contacted AWS and asked for clarification, and they kindly responded. I’ll update the post when I figure out how to use this policy variable.

UPDATE

The upcoming post will describe a solution on how to use sub in the policy.

7. Errors

When we create this architecture, we might see the following - dreaded - error:

arn:aws:sts::123456789012:assumed-role/TasksAutheticatedUserRole/ \
CognitoIdentityCredentials is not authorized to perform: dynamodb:Query \
 on resource: arn:aws:dynamodb:eu-central-1:123456789012:table/AgentsTable \
 because no identity-based policy allows the dynamodb:Query action

I spent some time debugging the error until I came up with the solution described above. From my experience, the error comes up in one of the following cases:

  • The role doesn't have a principal tag, or the tag has an incorrect value. Check if the referred claim is on the ID token. If not, the identity pool can't map its value to the tag. Check the token, identity pool attribute mapping, and principal tag in the policy.
  • The query input doesn't have ProjectionExpression - unless it's a good error. That is, we want to retrieve an attribute that the policy denies.
  • Trying the ${cognito-identity.amazonaws.com:sub} policy variable with the user's Cognito user pool ID. Although it would make sense, it doesn't work because the policy variable refers to the identity pool, not the user pool.

8. Summary

DynamoDB allows us to write policies that only allow access to items that belong to the relevant users. We can also hide attributes we don’t want to return in the query response.

One solution is to use Cognito groups with specific roles assigned to them. If we map the user’s user pool ID to a principal tag, we can use it in the role’s permissions policy to control access to the table’s items.

9. References, further reading

IAM JSON policy elements: Condition operators - More information about the Condition operators

Tutorial: Creating a user pool - How to create a user pool

Tutorial: Creating an identity pool - How to create an identity pool

Getting started with DynamoDB - Basic DynamoDB user guide

Secret Agent Name Generator - Essential resource in our toolkit