Using Cognito user ID to set up item-level access control to tables
Tags: aws, secury, serverless
Published: 2023-09-07 05:54:10

Part 1 of the series discussed IAM roles in Cognito and the access to DynamoDB tables based on user groups. In Part 2, I presented a way to control access to individual items and attributes in DynamoDB using user groups and principal tags. This blog will merge the findings from the previous posts and show an easier way to implement item-level access control in the table.

## 1. OK, but why again?

Let's quickly recap the scenario first. Every authenticated user (i.e., users who sign in to the application with their username and password) can only retrieve items relevant to them from the table. If we assign only items A and B to User1, that user might not view C or any other items.

I demonstrated a working solution through a simple but very realistic and in-demand example with a secret agent application where office administrators can only retrieve agents' data whom they manage.

<a href="https://dev.to/aws-builders/implementing-item-level-access-control-to-dynamodb-tables-498l" class="link" target="_blank" rel="noopener noreferrer">The last post</a> showed a solution based on <a href="https://docs.aws.amazon.com/IAM/latest/UserGuide/introduction_attribute-based-access-control.html" class="link" target="_blank" rel="noopener noreferrer">tags and attribute-based access control</a>. We can map the user's <a href="https://docs.aws.amazon.com/cognito/latest/developerguide/cognito-user-identity-pools.html" class="link" target="_blank" rel="noopener noreferrer">user pool</a> ID to a <a href="https://docs.aws.amazon.com/IAM/latest/UserGuide/access_iam-tags.html#access_iam-tags_control-principals" class="link" target="_blank" rel="noopener noreferrer">principal tag</a>, which becomes a **session tag** when Identity pools assumes the assigned group's role.

But AWS <a href="https://docs.aws.amazon.com/IAM/latest/UserGuide/reference_policies_examples_dynamodb_items.html" class="link" target="_blank" rel="noopener noreferrer">recommends</a> using the `sub` claim in the role's permissions policy, which is a different - and probably easier - way to achieve the same goal. Since I found some history of questions and confusion about the documentation, I decided to follow up on this issue and do my best to make it work.

The resulting solution involves some more code but is less complicated than described in the last post.

## 2. Pre-requisites

I made the previous two articles and this one a series because their concepts are closely related. It's probably a good idea to read <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">Part 1</a> and <a href="https://dev.to/aws-builders/implementing-item-level-access-control-to-dynamodb-tables-498l" class="link" target="_blank" rel="noopener noreferrer">Part 2</a> first.

Although I'll repeat some concepts in this post, you'll find more detailed explanations in the  other articles.

## 3. Cognito user ID

We want the following statement in the permissions policy as per the official solution:

```json
{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Condition": {
        "ForAllValues:StringEquals": {
          "dynamodb:LeadingKeys": [
            "${cognito-identity.amazonaws.com:sub}"
          ]
        }
      },
      "Action": [
        "dynamodb:Query"
      ],
      "Resource": "arn:aws:dynamodb:eu-central-1:123456789012:table/AgentsTable",
      "Effect": "Allow",
    }
  ]
}
```

AWS recommends using the `sub` claim to identify the authenticated user. Thus, we should use the `sub` value as a **partition key** in the table. This way, users can only retrieve data where the partition key is the same as their Cognito user ID.

### 3.1. About `sub`

When the user signs in, Cognito User pools will issue an **ID token**, which is a <a href="https://jwt.io/" class="link" target="_blank" rel="noopener noreferrer">JSON Web Token (JWT)</a>. `sub` is one of the claims in the token and its value is the **user pool ID** of the user. It seems intuitive that the `sub` in the policy variable is the same as the user pool ID `sub`.

But it's **NOT**, and this is where many developers - including myself - went wrong. Instead, it refers to the **Cognito identity pool user ID**.

I found the <a href="https://docs.aws.amazon.com/IAM/latest/UserGuide/reference_policies_examples_dynamodb_items.html" class="link" target="_blank" rel="noopener noreferrer">documentation's wording</a> confusing. It mentions <q>Amazon Cognito user ID</q> at one point and <q>Amazon Cognito ID</q> at another. It's not straightforward to me which ID I'm supposed to use. Although the `${cognito-identity.amazonaws.com:sub}` policy variable gives us a hint (`cognito-identity` for Identity pools vs. `cognito-idp` for User pools), it's easy to overlook. At least, it was easy for me.

### 3.2. How can we get the identity pool user ID?

An authenticated user has both a user pool and - if used - an identity pool ID. We'll need the **identity pool user ID**.

As discussed in Part 1, we should make two API calls to get credentials to AWS services via Identity pools. These are `GetId` and `GetCredentialsForIdentity`. The `fromCognitoIdentityPools` credentials provider method also calls these APIs.

For example, we can make calls to DynamoDB after we have configured the client like this:

```js
const ddbClient = new DynamoDBClient({
  region: 'eu-central-1',
  credentials: fromCognitoIdentityPool({
    clientConfig: { region: 'eu-central-1' },
    identityPoolId: 'IDENTITY_POOL_ID',
    logins: {
      'cognito-idp.eu-central-1.amazonaws.com/USER_POOL_ID':
        'ID_TOKEN',
    },
  }),
});
```

`ID_TOKEN` here is the ID token that the **user pool** returns after the user has signed in. That token doesn't contain the identity pool user ID.

But we can retrieve it from the `GetId` API. The response will contain the `IdentityId` that is the same **identity pool user ID** we need here (see below).

### 3.3. Behind the scenes - the Identity pools ID token

It's great, but where will <a href="https://aws.amazon.com/iam/" class="link" target="_blank" rel="noopener noreferrer">IAM</a> get the `sub`'s value from? The `${cognito-identity.amazonaws.com:sub}` policy variable refers to it, so there must be something somewhere that contains a `sub` property.

Cognito Identity pools can also return an **ID token**. We don't see this in the example code because everything happens in the background, but we can catch it by making an API call.

We can get the identity pool's ID token from the <a href="https://docs.aws.amazon.com/cognitoidentity/latest/APIReference/API_GetOpenIdToken.html" class="link" target="_blank" rel="noopener noreferrer">`GetOpenIdToken`</a> endpoint. We can call it with the `IdentityId` (i.e., the identity pool user ID, which is what we need in this solution) and - for authenticated users - the `Logins` object:

```json
{
  "IdentityId": "IDENTITY_ID",
  "Logins":{
      "cognito-idp.eu-central-1.amazonaws.com/USER_POOL_ID": "USER_POOL_ID_TOKEN"
  }
}
```

We can also use the <a href="https://docs.aws.amazon.com/AWSJavaScriptSDK/v3/latest/client/cognito-identity/command/GetOpenIdTokenCommand/" class="link" target="_blank" rel="noopener noreferrer">SDK</a> if we need to for some reason.

The response will contain a `Token` property. The value will be the Cognito identity pool ID token whose `sub` property will be the same as the `IdentityId`. The token is valid for 10 minutes, and its payload looks like this:

```jsonc
{
  // identity pool user ID - this is what we need for this solution!
  "sub": "eu-central-1:6f8d10b7-98fb-41ca-977d-f55318b0508f",
  // identity pool ID - we can get it from the Console
  "aud": "eu-central-1:121ebb41-a712-4ee7-9d33-800b4cb96a1c",
  "amr": [
    // shows that the user has signed in and authenticated
    "authenticated",
    // user pool ID - we can get it from the Console
    "cognito-idp.eu-central-1.amazonaws.com/eu-central-1_SOMETHING",
    // user pool user ID - the sub in the user pool ID token
    "cognito-idp.eu-central-1.amazonaws.com/eu-central-1_SOMETHING:CognitoSignIn:f439853a-e70c-46d6-bd29-fb4a626a62d2"
  ],
  "https://aws.amazon.com/tags": {
    "principal_tags": {
      // the userid principal tag from Part 2 - we mapped the user's sub to it
      "userid": [
        "439853a-e70c-46d6-bd29-fb4a626a62d2"
      ]
    }
  },
  "iss": "https://cognito-identity.amazonaws.com",
  // ... more properties
}
```

This token contains everything IAM needs to know to read the `sub`'s (and the `userid` tag's) value for authorization.

## 4. Code

So we could call the `GetId` endpoint and extract the identity pool user ID, i.e., `IdentityId` from the response.

We can do something like this in the <a href="https://react.dev/" class="link" target="_blank" rel="noopener noreferrer">React</a> component:

```jsx
import { GetIdCommand, CognitoIdentityClient } from '@aws-sdk/client-cognito-identity';
// ...more imports

const identityPoolClient = new CognitoIdentityClient({ region: 'eu-central-1' });

function App({ user }) {
  const [cognitoId, setCognitoId] = React.useState('');

  // 1. Get the ID token issued by the USER POOL
  const idToken = user.signInUserSession.idToken.jwtToken;

  // 2. Call GetId to receive the IDENTITY POOL user ID and store it
  const getIdInput = {
    IdentityPoolId: 'IDENTITY_POOL_ID',
    Logins: {
      'cognito-idp.eu-central-1.amazonaws.com/USER_POOL_ID': idToken,
    },
  };
  const getIdCommand = new GetIdCommand(getIdInput);

  useEffect(() => {
    async function getId() {
      const response = await identityPoolClient.send(getIdCommand);
      setCognitoId(response.IdentityId);
    }

    getId();
  }, []);

  // ...configure client and do other stuff

  const queryInput = {
    ExpressionAttributeValues: {
      // 3. Refer to the IDENTITY POOL user ID in the query
      ':sub': cognitoId,
    },
    KeyConditionExpression: 'userId = :sub',
    TableName: 'AgentsTable',
  };
  const command = new QueryCommand(queryInput);

  // ... call Query API and display the items
}
```

First, we need the **ID token** that the user pool has issued (1). The token will identify the user. Next, we'll call `GetId` with the ID token and the **identity pool ID** (2). The response's `IdentityId` will be the **identity pool user ID**. We'll take it and query the table for the specific items (3).

## 5. Considerations and acknowledgment

**Have the correct partition key**

The **identity pool user ID** has a format of `REGION:UUID`, the same format as the identity pool ID. It's another potential source of confusion!

So we should have the **partition key** in this format, for example, `eu-central-1:6f8d10b7-98fb-41ca-977d-f55318b0508f`. If we only have `6f8d10b7-98fb-41ca-977d-f55318b0508f`, it won't work. The `sub` in the policy variable refers, and the `sub` in the identity pool's ID token should match.

**Optimize the code**

The above React code is here for demonstration purposes only, and you can (and should) optimize it further. It's **NOT** a production-level code.

**Thanks to AWS support**

I approached the AWS documentation support with my confusion about the wording, and they responded quickly. Their response helped and took me several steps closer to this solution. Thanks!

## 6. Summary

We can control access to specific DynamoDB items in different ways. The AWS official documentation recommends using the `${cognito-identity.amazonaws.com:sub}` policy variable. The `sub` value is the identity pool user ID, which we can get by calling the `GetId` API. We must ensure the item's partition key in the table matches this ID.

## 7. References, further reading

<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/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/amazondynamodb/latest/developerguide/GettingStartedDynamoDB.html" class="link" target="_blank" rel="noopener noreferrer">Getting started with DynamoDB</a> - Basic DynamoDB user guide