A short tale of trying to outsmart AWS and tackle its hard limits in Cognito and IAM.
We are building a product to optimize AWS infrastructure for our customers. To ensure their safety, we donāt store, process or access our customersā data ourselves in any way. After all, you should very rarely allow third-parties to access your AWS account. Our application is merely a smart frontend that calls AWS APIs to suggest some improvements by browsing S3 buckets.
Usually, products like these are based on access via AWS access keys and secret access keys. You create a user and generate keys in AWS IAM or temporarily assume a roleās credentials via AWS STS. After that, you feed these credentials into the applicationās configuration and voila! It magically works.
This would have worked just fine in our case, if there was just one person accessing the application on the customers side. But, what if your customer (tenant) has multiple users? What if they want to have various groups of users with different permissions?
Since the product already requires deploying a piece of infrastructure on the customer's account, a crazy idea popped up ā we could just add an AWS service to handle the user management. Our frontend would simply interact with it to assume credentials for a logged user. And, since we canāt access that service ourselves to create a user, the customerās data would remain safe and intact.
That user management service is Amazon Cognito.
Amazon Cognito is AWS' answer to authentication and authorization in your application. Cognito user pools are there to store your users with their profiles and attributes and handle all sign-up, sign-in, token generation or forgotten password logic. Additionally, its JavaScript SDK allows you to quickly add these mechanisms to your mobile and web applications.
Cognito identity pools are an interesting solution that allow users of your app to interact with your AWS account directly. You can allow them to use S3, DynamoDB or any other AWS service directly and go from serverless to API-less. While this may seem crazy at first (how do you ensure that users donāt blow up your infrastructure?) it has some niche uses, especially when you build internal-facing applications.Ā
Thus, Cognito user pools are here to authenticate and Cognito identity pools to authorize your users. They can obviously be paired together, but most developers use only Cognito user pools to do the authentication and then do the authorization parts inside their APIs and business logic.
As a quick reminder of what we are trying to build: our application accesses a bucket inside a customerās (tenantās) AWS account. Each tenant wants to create multiple users that will use our application. Each user could be allowed to access a different set of directories. Lastly and most importantly ā we (Chaos Gears) should not be allowed to access tenantās data.
You could have a tenant with userA allowed to list 111111110000, user B allowed to list 111111110000 and 111111113333, userC allowed 111111112222 and 111111118888 and so on.
When you mix both Cognito User Pools with Cognito Identity Pools, there are some interesting capabilities. Amazon Cognito documentation has a section concerning attributes for access control with a great example. Their use case goes like this: a user is assigned to a department (like legal, sales, HR etc.) and can access every bucket that contains their department name. You donāt need to build a separate API to access S3 or store usersā data and permissions in DynamoDB. All you need is native Cognito features.
So far so good. A question that popped up immediately is - what if we wanted a user to access data from multiple departments? Can we allow multi-value attributes to control access? If the answer was yes, we could just store bucket directories in a userās attributes and let Cognito do the rest.Ā
Unfortunately, the documentation remains silent on that. First thing everyone does is Google the expected result. After all, this canāt be that odd use case, can it? One-to-many relationships have been well-known for decades. There wasnāt much on the internet either and all Reddit, re.post and StackOverflow posts also remained silent on the issue. So a research PoC phase began.
We had a genuine blast duct taping AWS. Cognito is known to be okay-ish for use cases it documents, and completely horrid for the ones it doesnāt. And so it was this time!
There are three elements that we need to fully understand, before we see the final solution.
AWS IAM stands for identity and access management and, as you know, is here to provide fine-grained access control across all of AWS. With IAM, you can specify who can access which services and resources, and under which conditions.
IAM policies are short JSON documents that you attach to your IAM roles and IAM users, describing what actions on what resource access is allowed or denied. However, in some cases, you might not know the exact name of the resource when you write your policy. If you want userA to access a bucket called userA, and userB to access a bucket called userB and so on, you have to create hundreds of policies that take every possibility into consideration.
Wait, is that so?Ā
Fortunately not. AWS IAM has a concept called IAM Policy Variables. These placeholders are known by AWS āin runtimeā and automatically interpolated. You can use aws:username to insert the caller's username, aws:CurrentTime to get the current time, s3:prefix to get the requested object prefix and so on.Ā
Hereās an example straight from the docs - at the time of evaluation, IAM will replace the ${aws:username} policy variable with the friendly name of the actual current user.
Cognito user pools allow you to define custom attributes for your users. You do not need a separate database to store all first and last names, addresses, emails, phone numbers or other custom information youād want to hold. They can be held directly in Cognito.
Cognito identity pools integrate with AWS IAM. You can specify which user in Cognito can assume what IAM role (and thus obtain a set of policies to interact with AWS).
The second piece of the solution uses the fact that Cognito identity pools can send some of the userās custom attribute values from Cognito user pools straight to AWS IAM as policy variables (this thing from above section).
In the example below, the value of ${custom:ba} attribute from Cognito user pools can be used in AWS IAM as IAM policy variable ${aws:PrincipalTag/ba}.
(technically when you ask Cognito identity pools to give you your IAM credentials, it can take values of specified attributes from your ID token and tag your session when calling AWS STS using the mapping schema you specified in the picture above).
Lastly, an IAM policy statement can be conditional. We can allow accessing a given resource if, and only if, some condition is satisfied. Additionally, these conditions can use IAM policy variables inside them.Ā
The above example policy allows the listing of up to 10 objects in example_bucket at a time.
Finally, we mixed all these features together.
The solution works as follows: every user in Cognito user pool will have a custom attribute that specifies all the directories they are allowed to access. Since, in our case, the directory naming is known, we can use the letter o as a delimiter:
Additionally, that attribute value will be available in IAM policy as ${aws:PrincipalTag/ba}, thanks to Cognito identity pool:
Finally, when the user uses their token from Cognito user pool to ask Cognito identity pool for AWS credentials, they will receive the following policy:
This condition allows listing a given S3 bucket directory if the aws:PrincipalTag/ba (that contains the value of custom:ba attribute from Cognito user pool thanks to Cognito identity pool) contains (asterix stands for ā0 or moreā so the āStringLikeā condition is logically āContainsā) the S3 prefix of a bucket that the given principal is trying to access. Read the previous sentence step by step and it will all fall into one piece.Ā
ā¦it actually works!
Our user is indeed allowed to list the directory 1111111110000, 1111111112222 and 1111111113333 in our bucket.
While it does the trick (you can add a similar GetObject policy and let your users browse and download stuff from directories inside that bucket) there are three important limits you need to be aware of.Ā
First of all, there is a hard limit of how long a Cognito custom attribute value can get. 2048 is the maximum. You could theoretically have more custom attributes (50 maximum), be smart of how you fill them and just have multiple statements for each of the attributes in your IAM policy.
ā¦but then, youāre challenged by the second limit, namely IAM policy document characters length. 10240 is plenty, but if your policies are large and the number of attributes grows, you will inevitably hit it.
Lastly, this solution does not work for subdirectories. If you specify directoryA in the custom attribute, and then try to access directoryA/subdirectoryB, you will be denied. The s3:prefix that IAM gets is different from the one you have in the custom attributeĀ (directoryA/subdirectoryB is not directoryA). You could obviously specify all the directories and subdirectories (and subsubdirectories and subsubsubdirectoriesā¦) in your custom attributes, but then youāll quickly run out of space in your attributes. And ā youād need to know all these subdirectories in advance.
Clever solutions are fine as long as the level of hackyness and the amount of duct tape used is manageable. In our case, we felt that it is too much and we have built an API to generate a dynamic role on a request basis. Time to explain that to our customers, duh.
Oh, and just as we finished the PoC, we actually found an article that describes a similar case. However, they havenāt found our dirty hack, so I decided to write this article up. Make sure you read it to know what exactly your AssumeRole API needs to do.
Well, another layer of duct tape and you can actually get there. Have multiple statements in your IAM policy, one for each of your top level directory. If you run out of IAM policy document length limit of 10240, just create a new role and continue your policy there. If you need more space in your attribute holding users directories (due to the 2048 characters limit), create more custom attributes (and add them to policies).
Finally, create Cognito user pool groups (one for each policy) and assign every user to every group. The ID token your user gets when they sign in contains the cognito:roles field. Iterate over those roles and have your frontend call GetCredentialsForIdentity specifying CustomRoleArn. Fetch parts of your data in every iteration. Voila.
ā¦but at that point, I would seriously consider other options.
We'd love to answer your questions and help you thrive in the cloud.