├── .gitignore ├── .npmignore ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md ├── LICENSE ├── README.md ├── bin └── main.ts ├── cdk.json ├── docs ├── apigw-console-test-panel.png ├── apigw-endpoint.png ├── architecture.png ├── deployed-resources.png ├── deployment-finished.png └── test-dataset.png ├── lib └── dynamodb-access-control-saas-stack.ts ├── package.json ├── resources └── lambda_function.py ├── setup.sh └── tsconfig.json /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | .cdk.staging 3 | cdk.out 4 | .DS_Store 5 | .parcel-cache 6 | .idea/* 7 | test-commands.txt 8 | package-lock.json 9 | 10 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | # CDK asset staging directory 2 | .cdk.staging 3 | cdk.out 4 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | ## Code of Conduct 2 | This project has adopted the [Amazon Open Source Code of Conduct](https://aws.github.io/code-of-conduct). 3 | For more information see the [Code of Conduct FAQ](https://aws.github.io/code-of-conduct-faq) or contact 4 | opensource-codeofconduct@amazon.com with any additional questions or comments. -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing Guidelines 2 | 3 | Thank you for your interest in contributing to our project. Whether it's a bug report, new feature, correction, or additional 4 | documentation, we greatly value feedback and contributions from our community. 5 | 6 | Please read through this document before submitting any issues or pull requests to ensure we have all the necessary 7 | information to effectively respond to your bug report or contribution. 8 | 9 | 10 | ## Reporting Bugs/Feature Requests 11 | 12 | We welcome you to use the GitHub issue tracker to report bugs or suggest features. 13 | 14 | When filing an issue, please check existing open, or recently closed, issues to make sure somebody else hasn't already 15 | reported the issue. Please try to include as much information as you can. Details like these are incredibly useful: 16 | 17 | * A reproducible test case or series of steps 18 | * The version of our code being used 19 | * Any modifications you've made relevant to the bug 20 | * Anything unusual about your environment or deployment 21 | 22 | 23 | ## Contributing via Pull Requests 24 | Contributions via pull requests are much appreciated. Before sending us a pull request, please ensure that: 25 | 26 | 1. You are working against the latest source on the *main* branch. 27 | 2. You check existing open, and recently merged, pull requests to make sure someone else hasn't addressed the problem already. 28 | 3. You open an issue to discuss any significant work - we would hate for your time to be wasted. 29 | 30 | To send us a pull request, please: 31 | 32 | 1. Fork the repository. 33 | 2. Modify the source; please focus on the specific change you are contributing. If you also reformat all the code, it will be hard for us to focus on your change. 34 | 3. Ensure local tests pass. 35 | 4. Commit to your fork using clear commit messages. 36 | 5. Send us a pull request, answering any default questions in the pull request interface. 37 | 6. Pay attention to any automated CI failures reported in the pull request, and stay involved in the conversation. 38 | 39 | GitHub provides additional document on [forking a repository](https://help.github.com/articles/fork-a-repo/) and 40 | [creating a pull request](https://help.github.com/articles/creating-a-pull-request/). 41 | 42 | 43 | ## Finding contributions to work on 44 | Looking at the existing issues is a great way to find something to contribute on. As our projects, by default, use the default GitHub issue labels (enhancement/bug/duplicate/help wanted/invalid/question/wontfix), looking at any 'help wanted' issues is a great place to start. 45 | 46 | 47 | ## Code of Conduct 48 | This project has adopted the [Amazon Open Source Code of Conduct](https://aws.github.io/code-of-conduct). 49 | For more information see the [Code of Conduct FAQ](https://aws.github.io/code-of-conduct-faq) or contact 50 | opensource-codeofconduct@amazon.com with any additional questions or comments. 51 | 52 | 53 | ## Security issue notifications 54 | If you discover a potential security issue in this project we ask that you notify AWS/Amazon Security via our [vulnerability reporting page](http://aws.amazon.com/security/vulnerability-reporting/). Please do **not** create a public github issue. 55 | 56 | 57 | ## Licensing 58 | 59 | See the [LICENSE](LICENSE) file for our project's licensing. We will ask you to confirm the licensing of your contribution. -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy of this 4 | software and associated documentation files (the "Software"), to deal in the Software 5 | without restriction, including without limitation the rights to use, copy, modify, 6 | merge, publish, distribute, sublicense, and/or sell copies of the Software, and to 7 | permit persons to whom the Software is furnished to do so. 8 | 9 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, 10 | INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A 11 | PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT 12 | HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 13 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE 14 | SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 | # Amazon DynamoDB fine-grained access control for multi-tenant SaaS 3 | 4 | The purpose of this solution is to illustrate how [Amazon DynamoDB](https://aws.amazon.com/pm/dynamodb) tables can be shared across tenants in a multi-tenant SaaS (Software-as-Service) solution with enforced security. This implementation leverages DynamoDB fine-grained access control using a dynamically generated [AWS IAM](https://aws.amazon.com/iam/) policy. It ensures preventing the cross-tenant data access with shared DynamoDB tables, which also helps to control potential noisy neighbors at the storage layer. This repository provides necessary cdk scripts and sample code to illustrate this particular security mechanism. 5 | 6 | (Note that, you could also leverage strategies such as attribute-based access control (ABAC) and role-based access control (RBAC) that implement SaaS data isolation, which you can find more details from [this blog post](https://aws.amazon.com/blogs/security/how-to-implement-saas-tenant-isolation-with-abac-and-aws-iam/)). 7 | 8 | ## Solution Architecture 9 | The below diagram shows what we are going to implement. It simulates a multi-tenant environment where we allow to store and manage data from different tenants with a shared DynamoDB table. It is fronted by a Lambda microservice that will be used by all tenants to perform DB operations. The microservice creates a DynamoDB client-connection which provides a scoped-access to the shared table using a dynamically generated IAM policy with tenant-context. Using this connection, the microservice ensures that each tenant will have the access to their own dataset to conduct DB operations preventing cross-tenant data access. During the testing of this solution, we will simulate the cross-tenant data access purposefully, and see how it would make sure to reject such operations. The Lambda microservice is fronted by the a REST API with Amazon API Gateway which has attached to an [IAM authorizer](https://docs.aws.amazon.com/apigateway/latest/developerguide/permissions.html) to protect the API resources from unauthorised access. In order to simulate client API calls from different tenants, we will use [in-build testing panel](https://docs.aws.amazon.com/apigateway/latest/developerguide/how-to-test-method.html#how-to-test-method-console) at the API Gateway console. 10 | 11 | ![saas-dynamodb-fgac-poc-arch](./docs/architecture.png) 12 | 13 | First, let's go through the solution workflow in the architecture. 14 | 1. API Gateway console will send test requests to `/items` REST API endpoint that supports following operations 15 | - `POST /items` with request body `{tenant_id}` - creates items in the DynamoDB table 16 | - `GET /items` with query string parameter `{tenant_id}` - fetches all items by tenant id 17 | - `GET /items` with query string parameters `{tenant_id, product_id, shard_id}` - fetches the item specified by the given parameters 18 | 19 | Note that, Each request contains the `tenant_id` parameter to denote which tenant the requests belong to. Here, for testing purposes, we are using the request body and query parameters to pass through the `tenant_id` to the microservice. In real life, there are multiple ways of handling this scenario. One implementation strategy is to leverage a JSON Web Token that represent `tenant_id`, and pass it through to the downstream microservices seamlessly via HTTP headers in each request. 20 | 2. API Gateway has a Lambda integration directing both `GET` and `POST` requests of of `/items` API resource to the same microservice. 21 | 3. There are 2 IAM roles created via the CloudFormation stack 22 | - `lambdaServiceRole`: Provides the [AWSLambdaBasicExecutionRole](https://docs.aws.amazon.com/lambda/latest/dg/lambda-intro-execution-role.html) as the service role for the Lambda function to be able to access necessary AWS services such as [AWS Cloudwatch](https://aws.amazon.com/cloudwatch/) and [AWS Security Token Service(STS)](https://docs.aws.amazon.com/STS/latest/APIReference/welcome.html). 23 | - `tenantAssumeRole`: Specifies the generic access permission for the DynamoDB table. The idea would be to trim down the scope of the permission of this role to provide a more scoped access for the tenant-specific API operations. So, we make sure each tenant will conduct the DB operations only with their own tenant-specific dataset ensuring data isolation. The permission we specify with this role depends on the use-case. For the purpose of this solution, we are going to use it as follows. 24 | ```json 25 | { 26 | "Version": "2012-10-17", 27 | "Statement": [ 28 | { 29 | "Action": [ 30 | "dynamodb:BatchGetItem", 31 | "dynamodb:BatchWriteItem", 32 | "dynamodb:DeleteItem", 33 | "dynamodb:GetItem", 34 | "dynamodb:PutItem", 35 | "dynamodb:Query", 36 | "dynamodb:UpdateItem" 37 | ], 38 | "Resource": DYNAMODB_TABLE_ARN, 39 | "Effect": "Allow" 40 | }] 41 | } 42 | ``` 43 | 4. In order to modify the scope of the permissions of the `tenantAssumeRole`, the microservice dynamically generates a tenant-aware IAM policy as follows. This policy allows to execute the specified DB operations with a selected dataset where partition key (Shard Id) of the DynamoDB table is leading/starting with the value of `TENANTID` of the API request. In order to make this happen, the microservice will pass this policy along with the `tenantAssumeRole` to the [AssumeRole API](https://docs.aws.amazon.com/STS/latest/APIReference/API_AssumeRole.html) in AWS STS. 44 | ```json 45 | { 46 | "Version": "2012-10-17", 47 | "Statement": [ 48 | { 49 | "Effect": "Allow", 50 | "Action": [ 51 | "dynamodb:GetItem", 52 | "dynamodb:PutItem" 53 | ], 54 | "Resource": [ 55 | DYNAMODB_TABLE_ARN 56 | ], 57 | "Condition": { 58 | "ForAllValues:StringLike": { 59 | "dynamodb:LeadingKeys": [ 60 | "{TENANTID}-*" 61 | ] 62 | } 63 | } 64 | } 65 | ] 66 | } 67 | ``` 68 | 69 | 5. AWS STS uses the above IAM policy to trim down the permission of `tenantAssumeRole` that refers to a tenant-specific, scoped-access to the DynamoDB table. AssumeRole API will then convert it to a [STS Credentials](https://docs.aws.amazon.com/STS/latest/APIReference/API_Credentials.html) object that includes `AccessKeyID`, `SecretAccessKey` and a `SessionToken` that represents the tenant-wise permission and access to the table 70 | 6. Finally, the microservice leverages this STS credentials object to initialize a scoped DynamoDB client connection which specifically has the access only to the dataset belong to the given tenant for DB operations. This way, it will enforce to restrict one tenant accessing other tenants' data in the shared table 71 | 72 | For more details about this design pattern, please refer to this blog post: [Partitioning Pooled Multi-Tenant SaaS Data with Amazon DynamoDB](https://aws.amazon.com/blogs/apn/partitioning-pooled-multi-tenant-saas-data-with-amazon-dynamodb/) 73 | 74 | Also, in order to generate the dynamic IAM policy and STS tokens at scale, evaluate a Token vending machine pattern explained in this blog post: [Isolating SaaS Tenants with Dynamically Generated IAM Policies 75 | ](https://aws.amazon.com/blogs/apn/isolating-saas-tenants-with-dynamically-generated-iam-policies/) 76 | 77 | ## Setup Instructions 78 | Pls refer the following setup instructions to deploy the solution via your laptop/computer or an [AWS Cloud9](https://aws.amazon.com/cloud9) environment. 79 | 1. [Install AWS CLI V2.0](https://docs.aws.amazon.com/cli/latest/userguide/install-cliv2.html) 80 | 2. [Install npm and node with latest versions](https://docs.npmjs.com/downloading-and-installing-node-js-and-npm) 81 | 3. [Install AWS CDK](https://docs.aws.amazon.com/cdk/v2/guide/cli.html) 82 | 83 | ## Build and Deployment instructions 84 | 1. Clone this repository to your environment. This solution is a CDK project, and following are some important files and resources to note. 85 | - `./bin/main.ts` : The entry point that initializes the CDK project via the `./package.json` 86 | - `./lib/dynamodb-access-control-saas-stack.ts` : The main CDK stack that creates, configures and integrates the AWS infrastructure needed to run the solution 87 | - `./resources/lambda_function.py` : The content of the Lambda function for the ItemService microservice 88 | - `./setup.sh` : The setup file that cleans, builds and deploys the CDK project on the AWS Account configured with AWS CLI 89 | 2. Run the setup file as follows on a terminal and follow the in-line instructions 90 | ```shell script 91 | $ sh setup.sh 92 | ``` 93 | 3. Provide the AWS region the solution has to be deployed to 94 | ``` 95 | Enter the AWS region name you want to deploy the solution below (Just press Enter if it's for us-east-1) : us-west-1 96 | ``` 97 | 4. The stack will then build the solution and deploy it on the given region. Checkout the terminal output for the verbose logs of the process, or, refer the AWS CloudFormation Service console for the status. 98 | 5. Once the deployment is finished, the terminal will show a confirmation as follows with the CDK output. 99 | ![deployment-finished](./docs/deployment-finished.png) 100 | 101 | If you look at the AWS CloudFormation console in your region, you will find the deployed resources in the stack as follows 102 | ![deployed resources](./docs/deployed-resources.png) 103 | 104 | ## Testing the Solution 105 | For testing, we are going to use the in-built testing panel at the [Amazon API Gateway console](https://console.aws.amazon.com/apigateway/). It will allow us to simulate the API testing securely with no authorization details required, following the same API request/response format. Read more about it [here](https://docs.aws.amazon.com/apigateway/latest/developerguide/how-to-test-method.html#how-to-test-method-console). 106 | 1. First, lets have a look of the DynamoDB Table and Lambda microservice that we just created 107 | - Open the AWS CloudFormation console of the region, point to the stack `DynamodbAccessControlSaasStack` and refer the link to the table from `DDBFGACItemsTable` item in the Resources tab. 108 | - Access the Lambda microservice logic from `DDBFGACItemService` resource in the CloudFormation stack 109 | 110 | 2. Let's open up the REST API endpoint that we created in the API Gateway console. 111 | - From the CloudFormation stack, expand the `DDBFGACApiGateway` resource that will provide a link to open the API Gateway resource `/items`. You can also access it from the API Gateway console directly by clicking `DDBFGACApiGateway` API name 112 | - Click `/items` API resource which will show you the `GET` and `POST` methods as follows, and note both are secured with `AWS IAM Authorizatoin` 113 | ![API Gateway Endpoint](./docs/apigw-endpoint.png) 114 | 115 | 3. It's time to load some data into the DynamoDB Table for `tenant1`. 116 | - For that, we need to send a `POST` request to `/items` API endpoint. Click `POST` under `/items` which will open up the method execution flow. Click the link `TEST` in the first section there that opens up the testing panel 117 | ![API Gateway Endpoint](./docs/apigw-console-test-panel.png) 118 | - This `POST` endpoint expects to have `tenant_id` in the request body to create new items in the Dynamo table under given tenant. In this solution, We are loading 3 items per POST request. 119 | - Let's load items for `tenant1`. Add the following parameters in the `Request Body` section and click the button `Test` at the end of the panel 120 | ``` 121 | {"tenant_id": "tenant1"} 122 | ``` 123 | - The API will respond with the partition keys of the new items created as follows, and you can see that output as the `Response Body` at the right side of the testing panel itself. 124 | ``` 125 | Operation Success. Items with these Shard Ids created : 126 | tenant1-5,tenant1-6,tenant1-19 127 | ``` 128 | 129 | 4. From the same testing panel (`POST` of `/items`), load some data for `tenant2` as well. For that, use the following request body parameter 130 | ``` 131 | {"tenant_id": "tenant2"} 132 | ``` 133 | - Once done, if you check out the DynamoDB table, our test dataset would look like as follows. 134 | ![test-dataset](docs/test-dataset.png) 135 | - Note that there is a random suffix that you can find in the format of `tenant_id-suffix` for each ShardID(partition key). It will help to distribute the data set of each tenant randomly for multiple physical partitions via unique partition keys. This trick specifically helps to control the potential hot partitions in the shared table for larger data-sets due to noisy neighbors. Refer the section of "Addressing the Hot Partition Challenge" in [this blog](https://aws.amazon.com/blogs/apn/partitioning-pooled-multi-tenant-saas-data-with-amazon-dynamodb/), or [here](https://docs.aws.amazon.com/amazondynamodb/latest/developerguide/bp-partition-key-sharding.html) for more details. 136 | 137 | 5. We have made the `POST /items` API implementation tenant-specific, so, it enforce to create items only related to the tenant id specified by the request 138 | - If you look at the code in the Lambda microservice, The entry point of this implementation would look liks this. 139 | ```python 140 | def _create_test_items(tenant_id, no_of_items): 141 | shard_ids = [] 142 | table = _get_scoped_ddb_table_by_tenant(tenant_id) 143 | # Load 3 new items for a given tenantID in the Dynamo Table 144 | for x in range(0, no_of_items): 145 | shard_id = tenant_id + '-' + str(_get_shard_suffix()) 146 | shard_ids.append(shard_id) 147 | _put_item(table, shard_id, _get_product_id()) 148 | return ','.join(shard_ids) 149 | 150 | ``` 151 | - The `_get_scoped_ddb_table_by_tenant` function that it refers is the key implementation here, that helps to create the DynamoDB object that provides the scoped access to the table for the given tenant_id. The function contains the logic as follows 152 | ```python 153 | def _get_scoped_ddb_table_by_tenant(tenant_id): 154 | # Step 01 : Creates the IAM policy document that defines operations that can be performed targeting 155 | # a tenant specific dataset in the DynamoDB table 156 | sts_client = boto3.client("sts", region_name=AWS_REGION_NAME) 157 | assumed_role = sts_client.assume_role( 158 | RoleArn = DYNAMO_ASSUME_ROLE_ARN, 159 | RoleSessionName = "tenant-aware-product", 160 | Policy = get_policy(tenant_id), 161 | ) 162 | # Step 02 : Extracts the short-living credentials 163 | credentials = assumed_role["Credentials"] 164 | 165 | # Step 03 : Creates a scoped DB session that has the access to the dataset belong to given tenant ID 166 | session = boto3.Session( 167 | aws_access_key_id=credentials['AccessKeyId'], 168 | aws_secret_access_key=credentials['SecretAccessKey'], 169 | aws_session_token=credentials["SessionToken"], 170 | ) 171 | 172 | # Step 04 : Cretaes the DDB table object from the scoped session that can perform DB operations 173 | dynamodb = session.resource('dynamodb', region_name=AWS_REGION_NAME) 174 | table = dynamodb.Table(DYNAMO_TABLE_NAME) 175 | 176 | return table; 177 | 178 | ``` 179 | - This is where the fine-grained access control is in action to make it a tenant-specific operation. Here, we are passing the value `tenant_id` to fill the placeholder `{TENANTID}` in the dynamic IAM policy that we discussed during the step 04 in the "Solution Architecture" section. Then, we are using that policy to create credentials object which helps to create the DynamoDB `table` object that has the tenant-specific scoped access to the table. This way, we explicitly ensure the items we creating only belong to the tenant specified by tenant_id. 180 | - We will reuse the `_get_scoped_ddb_table_by_tenant` function for other API operations such as `GET /items`, so they would ensure the enforced security as well. 181 | 182 | 6. Let's fetch the full dataset of `tenant1` the table via the API. 183 | - Get back to `/items` resource in the API Gateway console and open up the TEST panel for the `GET` method. 184 | - Enter the following parameters as the `Query String` and hit the button `TEST` for the API call to query the table and fetch all the items of tenant1. 185 | ``` 186 | tenant_id=tenant1 187 | ``` 188 | - It will result the following output in the Response body of the panel 189 | ``` 190 | Operation Success. Items : 191 | [[{'ProductId': {'S': '18983'}, 'ShardID': {'S': 'tenant1-6'}, 'data': {'S': '{sample data}'}}, 192 | {'ProductId': {'S': '16776'}, 'ShardID': {'S': 'tenant1-5'}, 'data': {'S': '{sample data}'}}, 193 | {'ProductId': {'S': '15700'}, 'ShardID': {'S': 'tenant1-19'}, 'data': {'S': '{sample data}'}}]] 194 | ``` 195 | - In the Lambda microservice, check out the `_get_all_items_by_tenantId` function logic, which will look like as follows 196 | - Note that it fetches the dataset by each partition key separately, using a multi-threaded model to make sure we explicitly refer the tenant-specific dataset in a more efficient way 197 | ```python 198 | def _get_all_items_by_tenantId(tenant_id): 199 | threads = [] 200 | get_all_items_response.clear() 201 | 202 | for suffix in range(SUFFIX_START, SUFFIX_END + 1): 203 | partition_id = tenant_id+'-'+str(suffix) 204 | thread = threading.Thread(target=get_tenant_data, args=[partition_id]) 205 | threads.append(thread) 206 | 207 | # Start threads 208 | for thread in threads: 209 | thread.start() 210 | # Ensure all threads are finished 211 | for thread in threads: 212 | thread.join() 213 | 214 | return get_all_items_response 215 | ``` 216 | - Checkout the `get_tenant_data` function in the Lambda microservice, that acts as the target for each thread, which queries the DynamoDB table by partition key (shared id) 217 | - It has also leveraged the same `get_scoped_ddb_table_by_tenant` function described in the previous step to make sure the DB operation has a tenant-specific scoped access 218 | 219 | 220 | 7. In order for us to access a given item in the DB uniquely, we need to know 2 data points. `ShardID` - which is the partition key of the Item, that has the format of `tenant_id-suffix`, and, the `ProductId` - which is a unique identifier given per Item, per tenant basis. Let's try to access an Item from the table. Now, this is purely a simulation of a tenant accessing its own data from the shared table. 221 | - Add the following parameters as the Query string in the `GET /items` testing panel. We have taken the values for the parameters from the JSON response of the previous step 222 | ``` 223 | tenant_id=tenant1&shard_id=tenant1-6&product_id=18983 224 | ``` 225 | - This will fetch the correct Item object as follows, because here the tenant1 trys to access an item of its own 226 | ``` 227 | Operation Successful. 228 | {'ProductId': '18983', 'ShardID': 'tenant1-6', 'data': '{sample data}'} 229 | ``` 230 | - If you refer to the `_get_item_by_primarykey` function in the Lambda code, you will note that this API call is also leveraging `get_scoped_ddb_table_by_tenant` function described above in the 5th step to enforce the tenant-specific access control. 231 | - Change the `shard_id` and `product_id` based on your own DynamoDB table items, and try out the API call. It should return the correct Item object without any issue. 232 | 233 | 8. Now, let's simulate the cross-tenant data access and see what happens. Send a `GET` request with the following query string parameters. Note that we are requesting the same Item object as above, but with a different tenant (`tenant_id=tenant2`) this time. 234 | ``` 235 | tenant_id=tenant2&shard_id=tenant1-6&product_id=18983 236 | ``` 237 | 238 | The output at the Response body should look like something similar to this : 239 | ``` python 240 | Operation Failed. 241 | Traceback (most recent call last): 242 | File "/var/task/lambda_function.py", line 36, in _get_item 243 | item = _get_item_by_primarykey(params['shard_id'], params['product_id'], params['tenant_id']); 244 | File "/var/task/lambda_function.py", line 88, in _get_item_by_primarykey 245 | response = table.get_item( 246 | File "/var/lang/lib/python3.11/site-packages/boto3/resources/factory.py", line 580, in do_action 247 | response = action(self, *args, **kwargs) 248 | File "/var/lang/lib/python3.11/site-packages/boto3/resources/action.py", line 88, in __call__ 249 | response = getattr(parent.meta.client, operation_name)(*args, **params) 250 | File "/var/lang/lib/python3.11/site-packages/botocore/client.py", line 534, in _api_call 251 | return self._make_api_call(operation_name, kwargs) 252 | File "/var/lang/lib/python3.11/site-packages/botocore/client.py", line 976, in _make_api_call 253 | raise error_class(parsed_response, operation_name) 254 | botocore.exceptions.ClientError: An error occurred (AccessDeniedException) when calling the GetItem operation: User: arn:aws:sts::xxxxxxxxxxxx:assumed-role/DynamodbAccessControlSaas-DDBFGACTenantAssumeRole9-xxxxxxxxxxxx/tenant-aware-product is not authorized to perform: dynamodb:GetItem on resource: arn:aws:dynamodb:us-west-1:xxxxxxxxxxxx:table/DynamodbAccessControlSaasStack-DDBFGACItemsTable4E79F4D0-xxxxxxxxxxxx because no session policy allows the dynamodb:GetItem action 255 | ``` 256 | 257 | The `AccessDeniedException` is due to the cross-tenant data access where `tenant2` is trying to access data belong to `tenant1`. Even though the DynamoDB table contains data of different tenants, the dynamically generated tenant-specific IAM policy and STS tokens enforce the security by preventing one tenant accessing the data belong to other tenants. 258 | 259 | If we simulate the cross-tenant data access for other API operation this solution supports such with `POST /items` and `GET /items` for fetching all items, they would also behave the similar way ensuring the enforced data isolation. 260 | 261 | Thus, The fine-grained access control with shared DynamoDB tables could be used as a security enforcement for implementing tenant-specific data isolation strategy with multi-tenant SaaS solutions. 262 | 263 | 264 | ## License 265 | This library is licensed under the MIT-0 License. See the LICENSE file. 266 | 267 | -------------------------------------------------------------------------------- /bin/main.ts: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | import 'source-map-support/register'; 3 | import * as cdk from 'aws-cdk-lib'; 4 | import { DynamodbAccessControlSaasStack } from '../lib/dynamodb-access-control-saas-stack'; 5 | 6 | const app = new cdk.App(); 7 | new DynamodbAccessControlSaasStack(app, 'DynamodbAccessControlSaasStack'); -------------------------------------------------------------------------------- /cdk.json: -------------------------------------------------------------------------------- 1 | { 2 | "app": "npx ts-node --prefer-ts-exts bin/main.ts", 3 | "requireApproval": "never" 4 | } 5 | -------------------------------------------------------------------------------- /docs/apigw-console-test-panel.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aws-samples/aws-saas-factory-dynamodb-fine-grained-access-control-for-saas/d53dc06bb71df61808384ea35ae4874f9f83b7ed/docs/apigw-console-test-panel.png -------------------------------------------------------------------------------- /docs/apigw-endpoint.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aws-samples/aws-saas-factory-dynamodb-fine-grained-access-control-for-saas/d53dc06bb71df61808384ea35ae4874f9f83b7ed/docs/apigw-endpoint.png -------------------------------------------------------------------------------- /docs/architecture.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aws-samples/aws-saas-factory-dynamodb-fine-grained-access-control-for-saas/d53dc06bb71df61808384ea35ae4874f9f83b7ed/docs/architecture.png -------------------------------------------------------------------------------- /docs/deployed-resources.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aws-samples/aws-saas-factory-dynamodb-fine-grained-access-control-for-saas/d53dc06bb71df61808384ea35ae4874f9f83b7ed/docs/deployed-resources.png -------------------------------------------------------------------------------- /docs/deployment-finished.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aws-samples/aws-saas-factory-dynamodb-fine-grained-access-control-for-saas/d53dc06bb71df61808384ea35ae4874f9f83b7ed/docs/deployment-finished.png -------------------------------------------------------------------------------- /docs/test-dataset.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aws-samples/aws-saas-factory-dynamodb-fine-grained-access-control-for-saas/d53dc06bb71df61808384ea35ae4874f9f83b7ed/docs/test-dataset.png -------------------------------------------------------------------------------- /lib/dynamodb-access-control-saas-stack.ts: -------------------------------------------------------------------------------- 1 | import {Stack, StackProps, CfnOutput, Duration} from 'aws-cdk-lib'; 2 | import { 3 | aws_lambda as lambda, 4 | aws_iam as iam, 5 | aws_dynamodb as dynamodb, 6 | aws_apigateway as apigw, 7 | aws_logs as logs 8 | } from 'aws-cdk-lib'; 9 | import { Effect, PolicyStatement } from 'aws-cdk-lib/aws-iam'; 10 | import {Construct} from 'constructs'; 11 | 12 | /** 13 | * CDK Stack Class that integrates deploys the AWS services to run the 14 | * DynamoDB Fine-Grained-Access-Control demo solution 15 | */ 16 | export class DynamodbAccessControlSaasStack extends Stack { 17 | constructor(scope: Construct, id: string, props?: StackProps) { 18 | super(scope, id, props); 19 | 20 | const table = new dynamodb.Table(this, 'DDBFGACItemsTable', { 21 | partitionKey: {name: 'ShardID', type: dynamodb.AttributeType.STRING}, 22 | sortKey: {name: 'ProductId', type: dynamodb.AttributeType.STRING} 23 | }); 24 | 25 | const lambdaServiceRole = new iam.Role(this, 'DDBFGACLambdaServiceRole', { 26 | assumedBy: new iam.ServicePrincipal('lambda.amazonaws.com'), 27 | managedPolicies: [ 28 | iam.ManagedPolicy.fromAwsManagedPolicyName('service-role/AWSLambdaBasicExecutionRole') 29 | ] 30 | }); 31 | 32 | const ddbQueryPermissionPolicy = new PolicyStatement({ 33 | effect: Effect.ALLOW, 34 | resources: [table.tableArn], 35 | actions: [ 36 | "dynamodb:Query" 37 | ] 38 | }); 39 | lambdaServiceRole.addToPolicy(ddbQueryPermissionPolicy); 40 | 41 | const tenantAssumeRole = new iam.Role(this, 'DDBFGACTenantAssumeRole', { 42 | assumedBy: new iam.ArnPrincipal(lambdaServiceRole.roleArn) 43 | }); 44 | 45 | const describeAcmCertificates = new PolicyStatement({ 46 | effect: Effect.ALLOW, 47 | resources: [table.tableArn], 48 | actions: [ 49 | "dynamodb:BatchGetItem", 50 | "dynamodb:BatchWriteItem", 51 | "dynamodb:DeleteItem", 52 | "dynamodb:GetItem", 53 | "dynamodb:PutItem", 54 | "dynamodb:Query", 55 | "dynamodb:UpdateItem" 56 | ] 57 | }); 58 | tenantAssumeRole.addToPolicy(describeAcmCertificates); 59 | 60 | 61 | const lambdaHander = new lambda.Function(this, "DDBFGACItemService", { 62 | role: lambdaServiceRole, 63 | runtime: lambda.Runtime.PYTHON_3_11, 64 | code: lambda.Code.fromAsset("resources"), 65 | handler: "lambda_function.lambda_handler", 66 | timeout: Duration.seconds(30), 67 | environment: { 68 | DYNAMO_TABLE_ARN: table.tableArn, 69 | DYNAMO_TABLE_NAME: table.tableName, 70 | DYNAMO_ASSUME_ROLE_ARN: tenantAssumeRole.roleArn, 71 | AWS_REGION_NAME: this.region 72 | } 73 | }); 74 | 75 | const prdLogGroup = new logs.LogGroup(this, "APIGWAccessLogs"); 76 | 77 | const restApi = new apigw.RestApi(this, 'DDBFGACApiGateway', { 78 | deployOptions: { 79 | accessLogDestination: new apigw.LogGroupLogDestination(prdLogGroup), 80 | accessLogFormat: apigw.AccessLogFormat.jsonWithStandardFields({ 81 | caller: true, 82 | httpMethod: true, 83 | ip: true, 84 | protocol: true, 85 | requestTime: true, 86 | resourcePath: true, 87 | responseLength: true, 88 | status: true, 89 | user: true, 90 | }), 91 | }, 92 | }); 93 | 94 | const apiModel = new apigw.Model(this, "model-validator", { 95 | restApi: restApi, 96 | contentType: "application/json", 97 | description: "To validate the request body", 98 | modelName: "ddbfgacapimodel", 99 | schema: { 100 | type: apigw.JsonSchemaType.OBJECT, 101 | required: ["tenant_id"], 102 | properties: { 103 | tenant_id: { type: apigw.JsonSchemaType.STRING } 104 | }, 105 | }, 106 | }); 107 | 108 | 109 | const itemResource = restApi.root.addResource('items'); 110 | itemResource.addMethod("POST", 111 | new apigw.LambdaIntegration(lambdaHander), { 112 | authorizationType: apigw.AuthorizationType.IAM, 113 | requestValidator: new apigw.RequestValidator( 114 | this, 115 | "body-validator", 116 | { 117 | restApi: restApi, 118 | requestValidatorName: "body-validator", 119 | validateRequestBody: true 120 | } 121 | ), 122 | requestModels: { 123 | "application/json": apiModel 124 | }, 125 | }); 126 | 127 | itemResource.addMethod("GET", 128 | new apigw.LambdaIntegration(lambdaHander), { 129 | authorizationType: apigw.AuthorizationType.IAM, 130 | requestParameters: { 131 | 'method.request.querystring.tenant_id': true 132 | }, 133 | requestValidatorOptions: { 134 | validateRequestParameters: true, 135 | }, 136 | 137 | }); 138 | 139 | new CfnOutput(this, 'AWS-Account-Id', {value: this.account}); 140 | new CfnOutput(this, 'AWS-Region', {value: this.region}); 141 | new CfnOutput(this, 'API Endpoint', {value: restApi.url}); 142 | 143 | } 144 | 145 | } 146 | 147 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "s3-object-lambda", 3 | "version": "1.0.0", 4 | "bin": { 5 | "s3-object-lambda": "ts-node bin/main.ts" 6 | }, 7 | "scripts": { 8 | "build": "tsc", 9 | "watch": "tsc -w", 10 | "test": "jest", 11 | "cdk": "cdk" 12 | }, 13 | "devDependencies": { 14 | "@types/node": "18.11.17", 15 | "aws-cdk": "*", 16 | "ts-node": "^10.9.1", 17 | "typescript": "~4.9.4" 18 | }, 19 | "dependencies": { 20 | "aws-cdk-lib": "^2.0.0", 21 | "aws-sdk": "^2.1281.0", 22 | "constructs": "^10.1.190", 23 | "source-map-support": "^0.5.21" 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /resources/lambda_function.py: -------------------------------------------------------------------------------- 1 | import json 2 | import random 3 | import boto3 4 | import os 5 | import traceback 6 | import threading 7 | from boto3.dynamodb.conditions import Attr 8 | from enum import Enum 9 | 10 | AWS_REGION_NAME = os.environ['AWS_REGION_NAME'] 11 | DYNAMO_TABLE_ARN = os.environ['DYNAMO_TABLE_ARN'] 12 | DYNAMO_TABLE_NAME = os.environ['DYNAMO_TABLE_NAME'] 13 | DYNAMO_ASSUME_ROLE_ARN = os.environ['DYNAMO_ASSUME_ROLE_ARN'] 14 | 15 | get_all_items_response = [] 16 | 17 | SUFFIX_START=1 18 | SUFFIX_END=10 19 | 20 | class OpStatus(Enum): 21 | SUCCESSFUL = 'Successful' 22 | FAILED = 'Failed' 23 | 24 | # Default Lambda handler of the function that is the entry point for the REST API calls coming in 25 | def lambda_handler(event, context): 26 | no_of_test_items_per_call = 3 27 | 28 | # List of Operation definitions supported to be called via the REST API 29 | def _create_items(tenant_id): 30 | shard_ids = _create_test_items(tenant_id, no_of_test_items_per_call); 31 | print("ShardIds : ", shard_ids) 32 | return _return_response('Success', 'Items with these Shard Ids created : ' + shard_ids) 33 | def _get_all_items(tenant_id): 34 | items = _get_all_items_by_tenantId(tenant_id); 35 | return _return_response('Success', 'Items : \n' + str(items)) 36 | def _get_item(params): 37 | response = '' 38 | op_status = OpStatus.SUCCESSFUL.value 39 | try: 40 | item = _get_item_by_primarykey(params['shard_id'], params['product_id'], params['tenant_id']); 41 | response = str(item) 42 | except: 43 | op_status = OpStatus.FAILED.value 44 | response = traceback.format_exc() 45 | finally: 46 | return _return_response(op_status, '\n' + response) 47 | 48 | # Processing the request, identifying the requested Operation and returning the data 49 | http_op = event['httpMethod'] 50 | if http_op == "GET": 51 | params = event['queryStringParameters'] 52 | param_size = len(params) 53 | if "product_id" in params and "shard_id" in params : 54 | # if parameter list is tenant_id, product_id & shard_id, then its for get_item 55 | return _get_item(params) 56 | elif param_size == 1: 57 | # if there is one parameter passed in then it's tenant_id, so it's for get_all_items 58 | return _get_all_items(params['tenant_id']) 59 | else: 60 | return _return_response('Not Supported', 'Parameter list is not supported. Refer the README.md for supported API operations of /items') 61 | elif http_op == "POST": 62 | params = json.loads(event['body']) 63 | return _create_items(params['tenant_id']) 64 | else: 65 | return _return_response('Not Supported', 'HTTP operation : "{}" is not yet supported!'.format(http_op)) 66 | 67 | 68 | # The common function that generates the return response 69 | def _return_response(status, msg): 70 | response = { 71 | 'statusCode': 200, 72 | 'body': '\nOperation ' + status + '. ' + msg + '\n\n' 73 | } 74 | 75 | return response 76 | 77 | 78 | # Returns All items belong to a given tenant 79 | def _get_all_items_by_tenantId(tenant_id): 80 | threads = [] 81 | get_all_items_response.clear() 82 | 83 | for suffix in range(SUFFIX_START, SUFFIX_END + 1): 84 | partition_id = tenant_id+'-'+str(suffix) 85 | thread = threading.Thread(target=get_tenant_data, args=[partition_id]) 86 | threads.append(thread) 87 | 88 | # Start threads 89 | for thread in threads: 90 | thread.start() 91 | # Ensure all threads are finished 92 | for thread in threads: 93 | thread.join() 94 | 95 | return get_all_items_response 96 | 97 | # A thread target. Queries all items by partition_id and assign them in get_all_items_response 98 | def get_tenant_data(partition_id): 99 | ddb_client = boto3.client('dynamodb') 100 | response = ddb_client.query( 101 | TableName=DYNAMO_TABLE_NAME, 102 | ExpressionAttributeValues={ 103 | ':partition_id': { 104 | 'S': partition_id, 105 | }, 106 | }, 107 | KeyConditionExpression='ShardID = :partition_id' 108 | ) 109 | if (len(response.get('Items')) > 0): 110 | print(response.get('Items')) 111 | get_all_items_response.append(response.get('Items')) 112 | 113 | # Returns the Item specified by the composite primary key which is the combination of shardId and productId 114 | # Needs the tenantID to validate the cross-tenant access 115 | def _get_item_by_primarykey(shardId, productId, tenantID): 116 | return_val = "" 117 | table = _get_scoped_ddb_table_by_tenant(tenantID) 118 | response = table.get_item( 119 | Key={ 120 | 'ShardID': shardId, 121 | 'ProductId' : productId 122 | } 123 | ) 124 | 125 | if "Item" in response: 126 | return_val = response['Item'] 127 | else: 128 | return_val = "But, There is no Item found in the DB for the given input values" 129 | 130 | return return_val 131 | 132 | 133 | # Saves an item to the DB via a connection scoped by the tenantID with given shardId and productId 134 | def _put_item(table, shard_id, product_id): 135 | # Get Scoped access to the Dynamo table by TenantID 136 | 137 | item={ 138 | 'ShardID': shard_id, 139 | 'ProductId' : product_id, 140 | 'data': _get_sample_product_json() 141 | } 142 | response = table.put_item( 143 | Item = item 144 | ) 145 | print(item, ' -- ', response) 146 | return response 147 | 148 | 149 | # Returns the scoped DDB table connection by given tenantID 150 | def _get_scoped_ddb_table_by_tenant(tenant_id): 151 | # Step 01 : Creates the IAM policy document that defines operations that can be performed targeting 152 | # a tenant specific dataset in the DynamoDB table 153 | sts_client = boto3.client("sts", region_name=AWS_REGION_NAME) 154 | assumed_role = sts_client.assume_role( 155 | RoleArn = DYNAMO_ASSUME_ROLE_ARN, 156 | RoleSessionName = "tenant-aware-product", 157 | Policy = _get_policy(tenant_id), 158 | ) 159 | # Step 02 : Extracts the short-living credentials 160 | credentials = assumed_role["Credentials"] 161 | 162 | # Step 03 : Creates a scoped DB session that has the access to the dataset belong to given tenant ID 163 | session = boto3.Session( 164 | aws_access_key_id=credentials['AccessKeyId'], 165 | aws_secret_access_key=credentials['SecretAccessKey'], 166 | aws_session_token=credentials["SessionToken"], 167 | ) 168 | 169 | # Step 04 : Cretaes the DDB table object from the scoped session that can perform DB operations 170 | dynamodb = session.resource('dynamodb', region_name=AWS_REGION_NAME) 171 | table = dynamodb.Table(DYNAMO_TABLE_NAME) 172 | 173 | return table; 174 | 175 | 176 | # Returns the IAM policy with Tenant Context 177 | def _get_policy(tenant_id): 178 | policy_template = { 179 | "Version": "2012-10-17", 180 | "Statement": [ 181 | { 182 | "Effect": "Allow", 183 | "Action": [ 184 | "dynamodb:GetItem", 185 | "dynamodb:PutItem" 186 | ], 187 | "Resource": [ 188 | DYNAMO_TABLE_ARN 189 | ], 190 | "Condition": { 191 | "ForAllValues:StringLike": { 192 | "dynamodb:LeadingKeys": [ 193 | "{TENANTID}-*" 194 | ] 195 | } 196 | } 197 | } 198 | ] 199 | } 200 | return json.dumps(policy_template).replace("{TENANTID}", tenant_id) 201 | 202 | 203 | # Loads 3 random items to the DB for a given tenantID 204 | def _create_test_items(tenant_id, no_of_items): 205 | shard_ids = [] 206 | table = _get_scoped_ddb_table_by_tenant(tenant_id) 207 | # Load 3 new items for a given tenantID in the Dynamo Table 208 | for x in range(0, no_of_items): 209 | shard_id = tenant_id + '-' + str(_get_shard_suffix()) 210 | shard_ids.append(shard_id) 211 | _put_item(table, shard_id, _get_product_id()) 212 | 213 | return ','.join(shard_ids) 214 | 215 | # Returns a sample content to be used as the data of an Item 216 | def _get_sample_product_json(): 217 | return "{sample data}" 218 | 219 | # Returns a random value to be used as the Product Id of the items 220 | def _get_product_id(): 221 | return str(_get_random_number(10000, 19999)) 222 | 223 | # Returns a random value to be used as the post-fix of the ShardId which is = TenantID + "_"+ Post_Fix 224 | def _get_shard_suffix(): 225 | return str(_get_random_number(SUFFIX_START, SUFFIX_END)) 226 | 227 | # Generates a cryptographically secure random number 228 | def _get_random_number(min, max): 229 | system_random = random.SystemRandom() 230 | return str(system_random.randint(min, max)) -------------------------------------------------------------------------------- /setup.sh: -------------------------------------------------------------------------------- 1 | echo "\nCleaning the local workspace and building the solution..." 2 | rm -rf cdk.out/ 3 | rm -rf node_modules/ 4 | rm package-lock.json 5 | npm install 6 | 7 | echo "Your current Region is : $AWS_REGION" 8 | printf "Enter the AWS region name you want to deploy the solution (Just press Enter if it's for \"$AWS_REGION\") : " 9 | read -r region 10 | 11 | if [ -n "$region" ] 12 | then 13 | export AWS_REGION=$region 14 | fi 15 | 16 | echo "Region to deploy the solution : $AWS_REGION" 17 | 18 | printf "Bootstrapping the environment to run CDK app..." 19 | ACC_ID=`echo \`aws sts get-caller-identity --query "Account" --output text\`` 20 | cdk bootstrap "aws://$ACC_ID/$AWS_REGION" 21 | 22 | # Build the Cloudformation scripts and and deploy the AWS components on the AWS Account 23 | cdk ls; cdk synth; cdk deploy; 24 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2022", 4 | "module": "commonjs", 5 | "lib": [ 6 | "ES2022" 7 | ], 8 | "declaration": true, 9 | "strict": true, 10 | "noImplicitAny": true, 11 | "strictNullChecks": true, 12 | "noImplicitThis": true, 13 | "alwaysStrict": true, 14 | "noUnusedLocals": false, 15 | "noUnusedParameters": false, 16 | "noImplicitReturns": true, 17 | "noFallthroughCasesInSwitch": false, 18 | "inlineSourceMap": true, 19 | "inlineSources": true, 20 | "experimentalDecorators": true, 21 | "strictPropertyInitialization": false, 22 | "typeRoots": [ 23 | "./node_modules/@types" 24 | ] 25 | }, 26 | "exclude": [ 27 | "node_modules", 28 | "cdk.out" 29 | ] 30 | } 31 | --------------------------------------------------------------------------------