Implementing item-level access control to DynamoDB tables - Authenticated users in Cognito Identity pools, Part 2
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?
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