Using Cognito user ID to set up item-level access control to tables
Arpad Toth
Posted on September 7, 2023
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.
The last post showed a solution based on tags and attribute-based access control. We can map the user's user pool ID to a principal tag, which becomes a session tag when Identity pools assumes the assigned group's role.
But AWS recommends 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 Part 1 and Part 2 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:
{
"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 JSON Web Token (JWT). 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 documentation's wording confusing. It mentions Amazon Cognito user ID
at one point and Amazon Cognito ID
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:
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 IAM 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 GetOpenIdToken
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:
{
"IdentityId": "IDENTITY_ID",
"Logins":{
"cognito-idp.eu-central-1.amazonaws.com/USER_POOL_ID": "USER_POOL_ID_TOKEN"
}
}
We can also use the SDK 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:
{
// 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 React component:
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
Tutorial: Creating a user pool - How to create a user pool
Tutorial: Creating an identity pool
- How to create an identity pool
IAM JSON policy elements: Condition operators - More information about the Condition operators
Getting started with DynamoDB - Basic DynamoDB user guide
Posted on September 7, 2023
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.