Implementing item-level access control to DynamoDB tables
Tags: aws, security, serverless
Published: 2023-09-01 09:14:10

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 <a href="https://aws.amazon.com/dynamodb/" class="link" target="_blank" rel="noopener noreferrer">DynamoDB</a> 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 <a href="https://docs.aws.amazon.com/cognito/latest/developerguide/cognito-user-identity-pools.html" class="link" target="_blank" rel="noopener noreferrer">Cognito User pools</a> and <a href="https://docs.aws.amazon.com/cognito/latest/developerguide/cognito-identity.html" class="link" target="_blank" rel="noopener noreferrer">Identity pools</a>. I already <a href="https://dev.to/aws-builders/controlling-access-to-resources-with-cognito-groups-and-iam-roles-3g66" class="link" target="_blank" rel="noopener noreferrer">wrote a post</a> 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 <a href="https://aws.amazon.com/iam/" class="link" target="_blank" rel="noopener noreferrer">IAM</a> policies that allow access to the table items and attributes based on the criteria above.

## 3. Pre-requisites

The post will build on <a href="https://dev.to/aws-builders/controlling-access-to-resources-with-cognito-groups-and-iam-roles-3g66" class="link" target="_blank" rel="noopener noreferrer">on this article</a>, 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 <a href="https://docs.aws.amazon.com/IAM/latest/UserGuide/reference_policies_variables.html" class="link" target="_blank" rel="noopener noreferrer">tags and policy variables</a> to control which user can access which agent item in the table. We apply the <a href="https://docs.aws.amazon.com/IAM/latest/UserGuide/introduction_attribute-based-access-control.html" class="link" target="_blank" rel="noopener noreferrer">attribute-based access control</a> 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:

```json
{
  "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:

```json
{
  "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](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/9hven24ja7hzyc2sbarx.png)

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:

```json
{
  "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` <a href="https://docs.aws.amazon.com/IAM/latest/UserGuide/reference_policies_condition-single-vs-multi-valued-context-keys.html#reference_policies_condition-multi-valued-context-keys" class="link" target="_blank" rel="noopener noreferrer">set operator</a> 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 <a href="https://react.dev/" class="link" target="_blank" rel="noopener noreferrer">React</a> component.

The relevant part of the code is the following:

```js
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` <a href="https://aws.amazon.com/amplify/" class="link" target="_blank" rel="noopener noreferrer">Amplify</a> 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 <a href="https://docs.aws.amazon.com/IAM/latest/UserGuide/reference_policies_examples_dynamodb_items.html" class="link" target="_blank" rel="noopener noreferrer">AWS documentation</a> 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:

<ul>
  <li>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.</li>
  <li>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.</li>
  <li>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.</li>
</ul>

## 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

<a href="https://docs.aws.amazon.com/IAM/latest/UserGuide/reference_policies_elements_condition_operators.html" class="link" target="_blank" rel="noopener noreferrer">IAM JSON policy elements: Condition operators</a> - More information about the Condition operators

<a href="https://docs.aws.amazon.com/cognito/latest/developerguide/tutorial-create-user-pool.html" class="link" target="_blank" rel="noopener noreferrer">Tutorial: Creating a user pool</a> - How to create a user pool

<a href="https://docs.aws.amazon.com/cognito/latest/developerguide/tutorial-create-identity-pool.html" class="link" target="_blank" rel="noopener noreferrer">Tutorial: Creating an identity pool
</a> - How to create an identity pool

<a href="https://docs.aws.amazon.com/amazondynamodb/latest/developerguide/GettingStartedDynamoDB.html" class="link" target="_blank" rel="noopener noreferrer">Getting started with DynamoDB</a> - Basic DynamoDB user guide

<a href="https://nerdburglars.net/namegenerator/secret-agent-name-generator/" class="link" target="_blank" rel="noopener noreferrer">Secret Agent Name Generator</a> - Essential resource in our toolkit