├── docs ├── drawio │ ├── stub │ └── raw.drawio ├── img │ ├── test_button.png │ ├── test_trigger.png │ ├── rbac_actor_flow.png │ ├── rbac_user_example.png │ ├── consumer_log_output.png │ ├── outsider_log_output.png │ ├── producer_log_output.png │ ├── architecture_diagram_no_flow.png │ └── architecture_diagram_with_flow.png └── architecture.md ├── lib ├── lambda │ ├── requirements.txt │ ├── lib │ │ └── redis_module │ │ │ └── redis_py.zip │ ├── scripts │ │ └── redis_rbac.yml │ └── redis_connect.py ├── redis-rbac-secret-manager.ts └── redis-rbac-stack.ts ├── .npmignore ├── jest.config.js ├── .gitignore ├── CODE_OF_CONDUCT.md ├── tsconfig.json ├── package.json ├── LICENSE ├── bin └── redis-rbac.ts ├── cdk.json ├── README.md ├── test └── redis-rbac.test.ts └── CONTRIBUTING.md /docs/drawio/stub: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /lib/lambda/requirements.txt: -------------------------------------------------------------------------------- 1 | redis -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | *.ts 2 | !*.d.ts 3 | 4 | # CDK asset staging directory 5 | .cdk.staging 6 | cdk.out 7 | -------------------------------------------------------------------------------- /docs/img/test_button.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aws-samples/aws-cdk-elasticache-redis-iam-rbac/HEAD/docs/img/test_button.png -------------------------------------------------------------------------------- /docs/img/test_trigger.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aws-samples/aws-cdk-elasticache-redis-iam-rbac/HEAD/docs/img/test_trigger.png -------------------------------------------------------------------------------- /docs/img/rbac_actor_flow.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aws-samples/aws-cdk-elasticache-redis-iam-rbac/HEAD/docs/img/rbac_actor_flow.png -------------------------------------------------------------------------------- /docs/img/rbac_user_example.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aws-samples/aws-cdk-elasticache-redis-iam-rbac/HEAD/docs/img/rbac_user_example.png -------------------------------------------------------------------------------- /docs/img/consumer_log_output.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aws-samples/aws-cdk-elasticache-redis-iam-rbac/HEAD/docs/img/consumer_log_output.png -------------------------------------------------------------------------------- /docs/img/outsider_log_output.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aws-samples/aws-cdk-elasticache-redis-iam-rbac/HEAD/docs/img/outsider_log_output.png -------------------------------------------------------------------------------- /docs/img/producer_log_output.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aws-samples/aws-cdk-elasticache-redis-iam-rbac/HEAD/docs/img/producer_log_output.png -------------------------------------------------------------------------------- /lib/lambda/lib/redis_module/redis_py.zip: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aws-samples/aws-cdk-elasticache-redis-iam-rbac/HEAD/lib/lambda/lib/redis_module/redis_py.zip -------------------------------------------------------------------------------- /docs/img/architecture_diagram_no_flow.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aws-samples/aws-cdk-elasticache-redis-iam-rbac/HEAD/docs/img/architecture_diagram_no_flow.png -------------------------------------------------------------------------------- /docs/img/architecture_diagram_with_flow.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aws-samples/aws-cdk-elasticache-redis-iam-rbac/HEAD/docs/img/architecture_diagram_with_flow.png -------------------------------------------------------------------------------- /lib/lambda/scripts/redis_rbac.yml: -------------------------------------------------------------------------------- 1 | aws elasticache create-user \ 2 | --user-id "mock_application_user" \ 3 | --user-name "mock_app_user" \ 4 | --engine "REDIS" \ 5 | --passwords "a-str0ng-pa))word" \ 6 | --access-string "off +get ~keys*" -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | // SPDX-License-Identifier: MIT-0 3 | module.exports = { 4 | roots: ['/test'], 5 | testMatch: ['**/*.test.ts'], 6 | transform: { 7 | '^.+\\.tsx?$': 'ts-jest' 8 | } 9 | }; 10 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.js 2 | !jest.config.js 3 | *.d.ts 4 | node_modules 5 | 6 | # CDK asset staging directory 7 | .cdk.staging 8 | cdk.out 9 | drawio/ 10 | scripts/ 11 | 12 | lib/lambda/lib/redis_module 13 | .DS_Store 14 | dump.rdb 15 | mock_app.zip 16 | rbac_cr.zip 17 | package-lock.json 18 | cfn*.txt 19 | -------------------------------------------------------------------------------- /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. 5 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2018", 4 | "module": "commonjs", 5 | "lib": ["es2018"], 6 | "declaration": true, 7 | "strict": true, 8 | "noImplicitAny": true, 9 | "strictNullChecks": true, 10 | "noImplicitThis": true, 11 | "alwaysStrict": true, 12 | "noUnusedLocals": false, 13 | "noUnusedParameters": false, 14 | "noImplicitReturns": true, 15 | "noFallthroughCasesInSwitch": false, 16 | "inlineSourceMap": true, 17 | "inlineSources": true, 18 | "experimentalDecorators": true, 19 | "strictPropertyInitialization": false, 20 | "typeRoots": ["./node_modules/@types"] 21 | }, 22 | "exclude": ["cdk.out"] 23 | } 24 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "redis-rbac", 3 | "version": "0.1.0", 4 | "bin": { 5 | "redis-rbac": "bin/redis-rbac.js" 6 | }, 7 | "scripts": { 8 | "build": "tsc", 9 | "zip": "./build_zips.sh", 10 | "watch": "tsc -w", 11 | "test": "jest", 12 | "cdk": "cdk" 13 | }, 14 | "devDependencies": { 15 | "@types/jest": "^27.5.2", 16 | "@types/node": "10.17.27", 17 | "@types/prettier": "2.6.0", 18 | "jest": "^27.5.1", 19 | "ts-jest": "^27.1.4", 20 | "aws-cdk": "2.43.1", 21 | "ts-node": "^10.9.1", 22 | "typescript": "~3.9.7" 23 | }, 24 | "dependencies": { 25 | "aws-cdk-lib": "2.43.1", 26 | "constructs": "^10.0.0", 27 | "source-map-support": "^0.5.21" 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /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 4 | this software and associated documentation files (the "Software"), to deal in 5 | the Software without restriction, including without limitation the rights to 6 | use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of 7 | the Software, and to 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 10 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS 11 | FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR 12 | COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER 13 | IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN 14 | CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 15 | 16 | -------------------------------------------------------------------------------- /bin/redis-rbac.ts: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | /* 3 | * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 4 | * SPDX-License-Identifier: MIT-0 5 | * 6 | * Permission is hereby granted, free of charge, to any person obtaining a copy of this 7 | * software and associated documentation files (the "Software"), to deal in the Software 8 | * without restriction, including without limitation the rights to use, copy, modify, 9 | * merge, publish, distribute, sublicense, and/or sell copies of the Software, and to 10 | * permit persons to whom the Software is furnished to do so. 11 | * 12 | * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, 13 | * INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A 14 | * PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT 15 | * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 16 | * OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE 17 | * SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 18 | */ 19 | 20 | import 'source-map-support/register'; 21 | import * as cdk from 'aws-cdk-lib'; 22 | import { RedisRbacStack } from '../lib/redis-rbac-stack'; 23 | 24 | const app = new cdk.App(); 25 | new RedisRbacStack(app, 'RedisRbacStack'); 26 | -------------------------------------------------------------------------------- /cdk.json: -------------------------------------------------------------------------------- 1 | { 2 | "app": "npx ts-node --prefer-ts-exts bin/redis-rbac.ts", 3 | "watch": { 4 | "include": [ 5 | "**" 6 | ], 7 | "exclude": [ 8 | "README.md", 9 | "cdk*.json", 10 | "**/*.d.ts", 11 | "**/*.js", 12 | "tsconfig.json", 13 | "package*.json", 14 | "yarn.lock", 15 | "node_modules", 16 | "test" 17 | ] 18 | }, 19 | "context": { 20 | "@aws-cdk/aws-apigateway:usagePlanKeyOrderInsensitiveId": true, 21 | "@aws-cdk/core:stackRelativeExports": true, 22 | "@aws-cdk/aws-rds:lowercaseDbIdentifier": true, 23 | "@aws-cdk/aws-lambda:recognizeVersionProps": true, 24 | "@aws-cdk/aws-lambda:recognizeLayerVersion": true, 25 | "@aws-cdk/aws-cloudfront:defaultSecurityPolicyTLSv1.2_2021": true, 26 | "@aws-cdk-containers/ecs-service-extensions:enableDefaultLogDriver": true, 27 | "@aws-cdk/aws-ec2:uniqueImdsv2TemplateName": true, 28 | "@aws-cdk/core:checkSecretUsage": true, 29 | "@aws-cdk/aws-iam:minimizePolicies": true, 30 | "@aws-cdk/aws-ecs:arnFormatIncludesClusterName": true, 31 | "@aws-cdk/core:validateSnapshotRemovalPolicy": true, 32 | "@aws-cdk/aws-codepipeline:crossAccountKeyAliasStackSafeResourceName": true, 33 | "@aws-cdk/aws-s3:createDefaultLoggingPolicy": true, 34 | "@aws-cdk/aws-sns-subscriptions:restrictSqsDescryption": true, 35 | "@aws-cdk/core:target-partitions": [ 36 | "aws", 37 | "aws-cn" 38 | ] 39 | }, 40 | "profile": "replace_with_profile_name" 41 | } 42 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Managing ElastiCache Redis access with Redis RBAC, AWS SecretsManager and AWS IAM 2 | 3 | This project demonstrates how to manage access to ElastiCache Redis by storing Redis RBAC username and passwords in AWS Secrets Manager. Granting or denying access to the secret will by proxy grant or deny access to Redis via RBAC. 4 | 5 | This project creates an ElastiCache Redis Replication group, IAM roles, Lambdas, Secrets and ElastiCache RBAC users and user groups. 6 | 7 | Details on the architecture can be found [here](docs/architecture.md) 8 | 9 | ## Installing CDK 10 | 11 | This project uses the AWS Cloud Development Kit (CDK). You can find instructions on installing CDK [here](https://docs.aws.amazon.com/cdk/latest/guide/getting_started.html#getting_started_install) 12 | 13 | ## How to build and deploy 14 | 15 | 1. Run `npm install` to install the node dependencies for the project 16 | 1. You may need to run `cdk bootstrap aws:///` to initialize the region to use CDK 17 | 1. Build the zip files which contain lambda functions by calling `npm run-script zip` 18 | 1. Deploy the project by calling `cdk deploy` 19 | 20 | ## Useful commands 21 | 22 | - `npm run-script zip` bundle lambda functions into zip files 23 | - `npm run build` compile typescript to js 24 | - `npm run watch` watch for changes and compile 25 | - `npm run test` perform the jest unit tests 26 | - `cdk deploy` deploy this stack to your default AWS account/region 27 | - `cdk diff` compare deployed stack with current state 28 | - `cdk synth` emits the synthesized CloudFormation template 29 | 30 | ## License 31 | 32 | This library is licensed under the MIT-0 License. See the [LICENSE](/architecture.md) file. 33 | -------------------------------------------------------------------------------- /test/redis-rbac.test.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 3 | * SPDX-License-Identifier: MIT-0 4 | * 5 | * Permission is hereby granted, free of charge, to any person obtaining a copy of this 6 | * software and associated documentation files (the "Software"), to deal in the Software 7 | * without restriction, including without limitation the rights to use, copy, modify, 8 | * merge, publish, distribute, sublicense, and/or sell copies of the Software, and to 9 | * permit persons to whom the Software is furnished to do so. 10 | * 11 | * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, 12 | * INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A 13 | * PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT 14 | * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 15 | * OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE 16 | * SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 17 | */ 18 | 19 | 20 | // import * as cdk from 'aws-cdk-lib'; 21 | // import { Template } from 'aws-cdk-lib/assertions'; 22 | // import * as Test from '../lib/test-stack'; 23 | 24 | // example test. To run these tests, uncomment this file along with the 25 | // example resource in lib/test-stack.ts 26 | test('SQS Queue Created', () => { 27 | // const app = new cdk.App(); 28 | // // WHEN 29 | // const stack = new Test.TestStack(app, 'MyTestStack'); 30 | // // THEN 31 | // const template = Template.fromStack(stack); 32 | 33 | // template.hasResourceProperties('AWS::SQS::Queue', { 34 | // VisibilityTimeout: 300 35 | // }); 36 | }); 37 | -------------------------------------------------------------------------------- /lib/lambda/redis_connect.py: -------------------------------------------------------------------------------- 1 | # Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | # SPDX-License-Identifier: MIT-0 3 | # 4 | # Permission is hereby granted, free of charge, to any person obtaining a copy of this 5 | # software and associated documentation files (the "Software"), to deal in the Software 6 | # without restriction, including without limitation the rights to use, copy, modify, 7 | # merge, publish, distribute, sublicense, and/or sell copies of the Software, and to 8 | # permit persons to whom the Software is furnished to do so. 9 | # 10 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, 11 | # INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A 12 | # PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT 13 | # HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 14 | # OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE 15 | # SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 16 | 17 | import redis 18 | import os 19 | import boto3 20 | import json 21 | from datetime import datetime 22 | 23 | def lambda_handler(event, context): 24 | client = boto3.client('secretsmanager') 25 | response = client.get_secret_value( 26 | SecretId=os.environ['secret_arn'] 27 | ) 28 | 29 | secret = json.loads(response['SecretString']) 30 | 31 | redis_server = redis.Redis( 32 | host=os.environ['redis_endpoint'], 33 | port=os.environ['redis_port'], 34 | username=secret['username'], 35 | password=secret['password'], 36 | ssl=True) 37 | 38 | try: 39 | time_now = datetime.now().strftime("%d/%m/%Y %H:%M:%S") 40 | redis_server.set("time", time_now) 41 | print ("Successfully set key 'time' to "+time_now) 42 | except Exception as e: 43 | print ("Exception trying to SET entry "+str(e)) 44 | 45 | try: 46 | result = redis_server.get("time") 47 | print ("Successfully retrieved key 'time' "+str(result)) 48 | except Exception as e: 49 | print ("Exception trying to GET entry "+str(e)) 50 | 51 | 52 | -------------------------------------------------------------------------------- /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. 60 | -------------------------------------------------------------------------------- /lib/redis-rbac-secret-manager.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 3 | * SPDX-License-Identifier: MIT-0 4 | * 5 | * Permission is hereby granted, free of charge, to any person obtaining a copy of this 6 | * software and associated documentation files (the "Software"), to deal in the Software 7 | * without restriction, including without limitation the rights to use, copy, modify, 8 | * merge, publish, distribute, sublicense, and/or sell copies of the Software, and to 9 | * permit persons to whom the Software is furnished to do so. 10 | * 11 | * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, 12 | * INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A 13 | * PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT 14 | * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 15 | * OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE 16 | * SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 17 | */ 18 | 19 | import * as cdk from 'aws-cdk-lib'; 20 | import { 21 | aws_kms as kms, 22 | aws_iam as iam, 23 | aws_elasticache as elasticache, 24 | aws_secretsmanager as secretsmanager} from 'aws-cdk-lib'; 25 | import { Construct } from 'constructs'; 26 | 27 | export interface RedisRbacUserProps { 28 | redisUserName: string; 29 | redisUserId: string; 30 | accessString?: string; 31 | kmsKey?: kms.Key; 32 | principals?: iam.IPrincipal[] 33 | } 34 | 35 | 36 | export class RedisRbacUser extends Construct { 37 | public readonly response: string; 38 | 39 | private rbacUserSecret: secretsmanager.Secret; 40 | private secretResourcePolicyStatement: iam.PolicyStatement; 41 | private rbacUserName: string; 42 | private rbacUserId: string; 43 | private kmsKey: kms.Key; 44 | 45 | public getSecret(): secretsmanager.Secret { 46 | return this.rbacUserSecret; 47 | } 48 | 49 | public getUserName(): string { 50 | return this.rbacUserName; 51 | } 52 | 53 | public getUserId(): string{ 54 | return this.rbacUserId; 55 | } 56 | 57 | public getKmsKey(): kms.Key { 58 | return this.kmsKey; 59 | } 60 | 61 | public grantReadSecret(principal: iam.IPrincipal){ 62 | if (this.secretResourcePolicyStatement == null) { 63 | this.secretResourcePolicyStatement = new iam.PolicyStatement({ 64 | effect: iam.Effect.ALLOW, 65 | actions: ['secretsmanager:DescribeSecret', 'secretsmanager:GetSecretValue'], 66 | resources: [this.rbacUserSecret.secretArn], 67 | principals: [principal] 68 | }) 69 | 70 | this.rbacUserSecret.addToResourcePolicy(this.secretResourcePolicyStatement) 71 | 72 | } else { 73 | this.secretResourcePolicyStatement.addPrincipals(principal) 74 | } 75 | this.kmsKey.grantDecrypt(principal); 76 | this.rbacUserSecret.grantRead(principal) 77 | } 78 | 79 | constructor(scope: Construct, id: string, props: RedisRbacUserProps) { 80 | super(scope, id); 81 | 82 | this.rbacUserId = props.redisUserId 83 | this.rbacUserName = props.redisUserName 84 | 85 | if (!props.kmsKey) { 86 | this.kmsKey = new kms.Key(this, 'kmsForSecret', { 87 | alias: 'redisRbacUser/'+this.rbacUserName, 88 | enableKeyRotation: true 89 | }); 90 | } else { 91 | this.kmsKey = props.kmsKey; 92 | } 93 | 94 | this.rbacUserSecret = new secretsmanager.Secret(this, 'secret', { 95 | generateSecretString: { 96 | secretStringTemplate: JSON.stringify({ username: props.redisUserName }), 97 | generateStringKey: 'password', 98 | excludeCharacters: '@%*()_+=`~{}|[]\\:";\'?,./' 99 | }, 100 | encryptionKey: this.kmsKey 101 | }); 102 | 103 | const user = new elasticache.CfnUser(this, 'redisuser', { 104 | engine: 'redis', 105 | userName: props.redisUserName, 106 | accessString: props.accessString? props.accessString : "off +get ~keys*", 107 | userId: props.redisUserId, 108 | passwords: [this.rbacUserSecret.secretValueFromJson('password').unsafeUnwrap()] 109 | }) 110 | 111 | user.node.addDependency(this.rbacUserSecret) 112 | 113 | if(props.principals){ 114 | props.principals.forEach( (item) => { 115 | this.grantReadSecret(item) 116 | }); 117 | } 118 | 119 | } 120 | 121 | } 122 | -------------------------------------------------------------------------------- /lib/redis-rbac-stack.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 3 | * SPDX-License-Identifier: MIT-0 4 | * 5 | * Permission is hereby granted, free of charge, to any person obtaining a copy of this 6 | * software and associated documentation files (the "Software"), to deal in the Software 7 | * without restriction, including without limitation the rights to use, copy, modify, 8 | * merge, publish, distribute, sublicense, and/or sell copies of the Software, and to 9 | * permit persons to whom the Software is furnished to do so. 10 | * 11 | * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, 12 | * INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A 13 | * PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT 14 | * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 15 | * OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE 16 | * SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 17 | */ 18 | 19 | 20 | import * as cdk from 'aws-cdk-lib'; 21 | import { 22 | aws_ec2 as ec2, 23 | aws_kms as kms, 24 | aws_iam as iam, 25 | aws_elasticache as elasticache, 26 | aws_lambda as lambda, 27 | aws_secretsmanager as secretsmanager} from 'aws-cdk-lib'; 28 | import { Construct } from 'constructs'; 29 | import path = require('path'); 30 | import { RedisRbacUser } from "./redis-rbac-secret-manager"; 31 | 32 | import fs = require('fs'); 33 | 34 | import { setFlagsFromString } from 'v8'; 35 | 36 | 37 | export class RedisRbacStack extends cdk.Stack { 38 | 39 | constructor(scope: Construct, id: string, props?: cdk.StackProps) { 40 | super(scope, id, props); 41 | 42 | // ----------------------------------------------------------------------------------------------------------- 43 | // This constructor will deploy resources required to link ElastiCache Redis, with SecretsManager and IAM 44 | // ----------------------------------------------------------------------------------------------------------- 45 | // Steps: 46 | // Step 1) create a VPC into which the ElastiCache replication group will be placed 47 | // Step 2) create Redis RBAC users 48 | // a) one secret in Secrets Manager will be created for each 49 | // Step 3) create IAM roles and grant them read access to the appropriate secret 50 | // Step 4) create an ElastiCache Redis replication group 51 | // Step 5) create test functions 52 | 53 | let producerName = 'producer' 54 | let consumerName = 'consumer' 55 | let noAccessName = 'outsider' 56 | let elasticacheReplicationGroupName = 'RedisReplicationGroup' 57 | 58 | // ------------------------------------------------------------------------------------ 59 | // Step 1) Create a VPC into which the ElastiCache replication group will be placed 60 | // a) only private subnets will be used 61 | // b) a Secrets Manager VPC endpoint will be added to allow access to Secrets Manager 62 | // ------------------------------------------------------------------------------------ 63 | 64 | const vpc = new ec2.Vpc(this, "Vpc", { 65 | subnetConfiguration: [ 66 | { 67 | cidrMask: 24, 68 | name: 'Isolated', 69 | subnetType: ec2.SubnetType.PRIVATE_ISOLATED, 70 | } 71 | ] 72 | }); 73 | 74 | const flowLog = new ec2.FlowLog(this, 'VpcFlowLog', { 75 | resourceType: ec2.FlowLogResourceType.fromVpc(vpc) 76 | }) 77 | 78 | const lambdaSecurityGroup = new ec2.SecurityGroup(this, 'LambdaSG', { 79 | vpc: vpc, 80 | description: 'SecurityGroup into which Lambdas will be deployed', 81 | allowAllOutbound: false 82 | }); 83 | 84 | const secretsManagerVpcEndpointSecurityGroup = new ec2.SecurityGroup(this, 'SecretsManagerVPCeSG', { 85 | vpc: vpc, 86 | description: 'SecurityGroup for the VPC Endpoint Secrets Manager', 87 | allowAllOutbound: false, 88 | 89 | }); 90 | 91 | secretsManagerVpcEndpointSecurityGroup.connections.allowFrom(lambdaSecurityGroup, ec2.Port.tcp(443)); 92 | 93 | const secretsManagerEndpoint = vpc.addInterfaceEndpoint('SecretsManagerEndpoint', { 94 | service: ec2.InterfaceVpcEndpointAwsService.SECRETS_MANAGER, 95 | subnets: { 96 | subnetType: ec2.SubnetType.PRIVATE_ISOLATED 97 | }, 98 | open: false, 99 | securityGroups: [secretsManagerVpcEndpointSecurityGroup] 100 | }); 101 | 102 | const ecSecurityGroup = new ec2.SecurityGroup(this, 'ElastiCacheSG', { 103 | vpc: vpc, 104 | description: 'SecurityGroup associated with the ElastiCache Redis Cluster', 105 | allowAllOutbound: false, 106 | }); 107 | 108 | ecSecurityGroup.connections.allowFrom(lambdaSecurityGroup, ec2.Port.tcp(6379), 'Redis ingress 6379'); 109 | ecSecurityGroup.connections.allowTo(lambdaSecurityGroup, ec2.Port.tcp(6379), 'Redis egress 6379'); 110 | 111 | // ------------------------------------------------------------------------------------ 112 | // Step 2) Create IAM roles 113 | // a) each IAM role will be assumed by a lambda function 114 | // b) each IAM role will be granted read and decrypt permissions to a matching secret 115 | // ------------------------------------------------------------------------------------ 116 | const producerRole = new iam.Role(this, producerName+'Role', { 117 | assumedBy: new iam.ServicePrincipal('lambda.amazonaws.com'), 118 | description: 'Role to be assumed by producer lambda', 119 | }); 120 | 121 | producerRole.addManagedPolicy(iam.ManagedPolicy.fromAwsManagedPolicyName("service-role/AWSLambdaBasicExecutionRole")); 122 | producerRole.addManagedPolicy(iam.ManagedPolicy.fromAwsManagedPolicyName("service-role/AWSLambdaVPCAccessExecutionRole")); 123 | 124 | 125 | const consumerRole = new iam.Role(this, consumerName+'Role', { 126 | assumedBy: new iam.ServicePrincipal('lambda.amazonaws.com'), 127 | description: 'Role to be assumed by mock application lambda', 128 | }); 129 | consumerRole.addManagedPolicy(iam.ManagedPolicy.fromAwsManagedPolicyName("service-role/AWSLambdaBasicExecutionRole")); 130 | consumerRole.addManagedPolicy(iam.ManagedPolicy.fromAwsManagedPolicyName("service-role/AWSLambdaVPCAccessExecutionRole")); 131 | 132 | 133 | const noAccessRole = new iam.Role(this, noAccessName+'Role', { 134 | assumedBy: new iam.ServicePrincipal('lambda.amazonaws.com'), 135 | description: 'Role to be assumed by mock application lambda', 136 | }); 137 | noAccessRole.addManagedPolicy(iam.ManagedPolicy.fromAwsManagedPolicyName("service-role/AWSLambdaBasicExecutionRole")); 138 | noAccessRole.addManagedPolicy(iam.ManagedPolicy.fromAwsManagedPolicyName("service-role/AWSLambdaVPCAccessExecutionRole")); 139 | 140 | 141 | // ------------------------------------------------------------------------------------ 142 | // Step 3) Create Redis RBAC users 143 | // a) access strings will dictate operations that can be performed 144 | // b) RedisRbacUser is a class defined in redis-rbac-secret-manager.ts 145 | // c) RedisRbacUser is composed of an AWS::ElastiCache::User and a Secret 146 | // ------------------------------------------------------------------------------------ 147 | const commonKmsKey = new kms.Key(this, 'commonCredentialKey', { 148 | alias: 'redisRbacUser/common', 149 | enableKeyRotation: true 150 | }); 151 | 152 | const producerRbacUser = new RedisRbacUser(this, producerName+'RBAC', { 153 | redisUserName: producerName, 154 | redisUserId: producerName, 155 | accessString: 'on ~* -@all +SET', 156 | kmsKey: commonKmsKey, 157 | principals: [producerRole] 158 | }); 159 | 160 | const consumerRbacUser = new RedisRbacUser(this, consumerName+'RBAC', { 161 | redisUserName: 'consumer', 162 | redisUserId: 'consumer', 163 | accessString: 'on ~* -@all +GET', 164 | kmsKey: commonKmsKey, 165 | principals: [consumerRole] 166 | }); 167 | 168 | const groupDefaultRbacUser = new RedisRbacUser(this, "groupDefaultUser"+'RBAC', { 169 | redisUserName: 'default', 170 | redisUserId: 'groupdefaultuser', 171 | kmsKey: commonKmsKey 172 | }); 173 | 174 | // Create RBAC user group 175 | const mockAppUserGroup = new elasticache.CfnUserGroup(this, 'mockAppUserGroup', { 176 | engine: 'redis', 177 | userGroupId: 'mock-app-user-group', 178 | userIds: [producerRbacUser.getUserId(), groupDefaultRbacUser.getUserId(), consumerRbacUser.getUserId()] 179 | }) 180 | 181 | mockAppUserGroup.node.addDependency(producerRbacUser); 182 | mockAppUserGroup.node.addDependency(groupDefaultRbacUser); 183 | mockAppUserGroup.node.addDependency(consumerRbacUser); 184 | 185 | 186 | // ------------------------------------------------------------------------------------ 187 | // Step 4) Create an ElastiCache Redis Replication group and associate the RBAC user group 188 | // a) an ElastiCache subnet group will be created 189 | // b) the ElastiCache replication group will be associated with the RBAC user group 190 | // ------------------------------------------------------------------------------------ 191 | 192 | let isolatedSubnets: string[] = [] 193 | 194 | vpc.isolatedSubnets.forEach(function(value){ 195 | isolatedSubnets.push(value.subnetId) 196 | }); 197 | 198 | const ecSubnetGroup = new elasticache.CfnSubnetGroup(this, 'ElastiCacheSubnetGroup', { 199 | description: 'Elasticache Subnet Group', 200 | subnetIds: isolatedSubnets, 201 | cacheSubnetGroupName: 'RedisSubnetGroup' 202 | }); 203 | 204 | const elastiCacheKmsKey = new kms.Key(this, 'kmsForSecret', { 205 | alias: 'redisReplicationGroup/'+elasticacheReplicationGroupName, 206 | enableKeyRotation: true 207 | }); 208 | 209 | // elastiCacheKmsKey.grantEncrypt(producerRole); 210 | // elastiCacheKmsKey.grantDecrypt(consumerRole); 211 | 212 | const ecClusterReplicationGroup = new elasticache.CfnReplicationGroup(this, elasticacheReplicationGroupName, { 213 | replicationGroupDescription: 'RedisReplicationGroup-RBAC-Demo', 214 | atRestEncryptionEnabled: true, 215 | multiAzEnabled: true, 216 | cacheNodeType: 'cache.m6g.large', 217 | cacheSubnetGroupName: ecSubnetGroup.cacheSubnetGroupName, 218 | engine: "Redis", 219 | engineVersion: '6.x', 220 | numNodeGroups: 1, 221 | kmsKeyId: elastiCacheKmsKey.keyId, 222 | replicasPerNodeGroup: 1, 223 | securityGroupIds: [ecSecurityGroup.securityGroupId], 224 | transitEncryptionEnabled: true, 225 | userGroupIds: [mockAppUserGroup.userGroupId] 226 | }) 227 | 228 | ecClusterReplicationGroup.node.addDependency(ecSubnetGroup) 229 | ecClusterReplicationGroup.node.addDependency(mockAppUserGroup) 230 | 231 | // ------------------------------------------------------------------------------------ 232 | // Step 5) Create test functions 233 | // a) one producer 234 | // b) one consumer 235 | // c) one that cannot access Redis 236 | // ------------------------------------------------------------------------------------ 237 | const redisPyLayer = new lambda.LayerVersion(this, 'redispy_Layer', { 238 | code: lambda.Code.fromAsset(path.join(__dirname, 'lambda/lib/redis_module/redis_py.zip')), 239 | compatibleRuntimes: [lambda.Runtime.PYTHON_3_8, lambda.Runtime.PYTHON_3_7, lambda.Runtime.PYTHON_3_6], 240 | description: 'A layer that contains the redispy module', 241 | license: 'MIT License' 242 | }); 243 | 244 | 245 | const producerLambda = new lambda.Function(this, producerName+'Fn', { 246 | runtime: lambda.Runtime.PYTHON_3_7, 247 | handler: 'redis_connect.lambda_handler', 248 | code: lambda.Code.fromAsset(path.join(__dirname, 'lambda/mock_app.zip')), 249 | layers: [redisPyLayer], 250 | role: producerRole, 251 | vpc: vpc, 252 | vpcSubnets: {subnetType: ec2.SubnetType.PRIVATE_ISOLATED}, 253 | securityGroups: [lambdaSecurityGroup], 254 | environment: { 255 | redis_endpoint: ecClusterReplicationGroup.attrPrimaryEndPointAddress, 256 | redis_port: ecClusterReplicationGroup.attrPrimaryEndPointPort, 257 | secret_arn: producerRbacUser.getSecret().secretArn, 258 | } 259 | }); 260 | 261 | producerLambda.node.addDependency(redisPyLayer); 262 | producerLambda.node.addDependency(ecClusterReplicationGroup); 263 | producerLambda.node.addDependency(vpc); 264 | producerLambda.node.addDependency(producerRole); 265 | 266 | // Create a function that can only read from Redis 267 | const consumerFunction = new lambda.Function(this, consumerName+'Fn', { 268 | runtime: lambda.Runtime.PYTHON_3_7, 269 | handler: 'redis_connect.lambda_handler', 270 | code: lambda.Code.fromAsset(path.join(__dirname, 'lambda/mock_app.zip')), 271 | layers: [redisPyLayer], 272 | role: consumerRole, 273 | vpc: vpc, 274 | vpcSubnets: {subnetType: ec2.SubnetType.PRIVATE_ISOLATED}, 275 | securityGroups: [lambdaSecurityGroup], 276 | environment: { 277 | redis_endpoint: ecClusterReplicationGroup.attrPrimaryEndPointAddress, 278 | redis_port: ecClusterReplicationGroup.attrPrimaryEndPointPort, 279 | secret_arn: consumerRbacUser.getSecret().secretArn, 280 | } 281 | }); 282 | 283 | consumerFunction.node.addDependency(redisPyLayer); 284 | consumerFunction.node.addDependency(ecClusterReplicationGroup); 285 | consumerFunction.node.addDependency(vpc); 286 | consumerFunction.node.addDependency(consumerRole); 287 | 288 | // Create a function that cannot access Redis 289 | const noAccessFunction = new lambda.Function(this, noAccessName+'Fn', { 290 | runtime: lambda.Runtime.PYTHON_3_7, 291 | handler: 'redis_connect.lambda_handler', 292 | code: lambda.Code.fromAsset(path.join(__dirname, 'lambda/mock_app.zip')), 293 | layers: [redisPyLayer], 294 | role: consumerRole, 295 | vpc: vpc, 296 | vpcSubnets: {subnetType: ec2.SubnetType.PRIVATE_ISOLATED}, 297 | securityGroups: [lambdaSecurityGroup], 298 | environment: { 299 | redis_endpoint: ecClusterReplicationGroup.attrPrimaryEndPointAddress, 300 | redis_port: ecClusterReplicationGroup.attrPrimaryEndPointPort, 301 | secret_arn: producerRbacUser.getSecret().secretArn, 302 | } 303 | }); 304 | 305 | noAccessFunction.node.addDependency(redisPyLayer); 306 | noAccessFunction.node.addDependency(ecClusterReplicationGroup); 307 | noAccessFunction.node.addDependency(vpc); 308 | noAccessFunction.node.addDependency(noAccessRole); 309 | 310 | } 311 | 312 | } 313 | -------------------------------------------------------------------------------- /docs/architecture.md: -------------------------------------------------------------------------------- 1 | # AWS ElastiCache support for Redis Role Based Access Control (RBAC) 2 | 3 | With Amazon ElastiCache for Redis 6, you can control cluster access via a feature called Role-Based Access Control (RBAC). Through RBAC, you can define Access Control Lists (ACLs) that model access patterns – allowing you to better define who can access a Redis cluster and what commands and keys they can access. 4 | 5 | When configured for RBAC, ElastiCache Redis replication groups will authenticate RBAC users based on the username and password provided when connections are established, and Redis commands and key access are authorized by the access strings (defined in Redis ACL syntax) for each RBAC user. 6 | 7 | ![rbac user](img/rbac_user_example.png) 8 | 9 | Redis RBAC users and ACLs, however, are not linked to AWS Identity Access Management (IAM) roles, groups or users; the dissociation between AWS IAM and Redis RBAC means that there is no out-of-the-box way to grant IAM entities (roles, users or groups) read and write access to Redis. 10 | 11 | In this blog, we will present a solution that will allow you to associate IAM entities with ElastiCache RBAC users and ACLs. The overall solution will demonstrate how RBAC users can effectively be associated with IAM through the user of AWS Secrets Manager as a proxy for granting access to RBAC user credentials. 12 | 13 | * A set of Redis RBAC users will be defined; each with usernames, passwords and ACL access strings – this will define the commands and keys that a user has access to. 14 | 15 | * IAM entities (roles, users, groups) will be granted access to RBAC user credentials (username and password) stored in AWS Secrets Manager through secret policies and IAM policies. 16 | 17 | * Users, applications and services that have roles or users that can access RBAC user credentials from Secrets Manager can then use them to connect to ElasticacheRedis by assuming an RBAC user – which will also define which commands and keys they have access to. 18 | 19 | ## Design: Managing ElastiCache Redis access with RBAC, AWS SecretsManager and AWS IAM 20 | 21 | ### Storing Redis RBAC passwords in SecretsManager 22 | 23 | When RBAC users are created (either via AWS CLI, AWS API or AWS Cloudformation), they are specified with a plaintext password and a username. These usernames and passwords must be shared with the actors who will access the Redis replication group via RBAC users (human users or applications). 24 | 25 | The solution that we will present will leverage Secrets Manager to generate a password that will be used when the RBAC user is created meaning that no plaintext passwords exposed and must be retrieved through Secrets Manager. 26 | 27 | ### Managing access to RBAC passwords in SecretsManager with IAM 28 | 29 | Access to secrets in SecretsManager can be restricted to specific IAM entity – these entities can then retrieve the username and password by making the appropriate AWS API or CLI call. 30 | 31 | ### Tying it together: Managing access to Redis with RBAC, SecretsManager and IAM 32 | 33 | The combination of IAM policies of an IAM entity and the policies associated with the secret will determine which entities will be able to access the secret – and the RBAC username and password stored within; effectively linking an IAM entity with an RBAC user. 34 | 35 | ![rbac user actor flow](img/rbac_actor_flow.png) 36 | 37 | The above diagram demonstrates the flow of the solution. First, an actor with an IAM role that has permissions to the “Producer Credentials” secret reads the secret from AWS Secrets manager (1, 2); the actor then establishes a connection with the Producer credentials to an ElastiCache replication group that is configured with an RBAC user group that has the Producer RBAC User in it (3). Once authenticated (4), the user can perform commands and access keys (5), however the commands and keys that can be accessed are dictated by the access string on the Producer RBAC user. 38 | 39 | ## Implementation in AWS Cloud Development Kit (CDK) 40 | 41 | We present the solution to you in AWS Cloud Development Kit (CDK), which is a software development framework that defines infrastructure through object-oriented programming languages -- in our case, Typescript. 42 | 43 | The following will be deployed: 44 | * One VPC with isolated subnets, one AWS Secrets Manager VPC endpoint 45 | * One security group with an ingress rule that allows all traffic in via port 6379 46 | * Three ElasticaCache RBAC users: default, consumer, producer 47 | * Three secrets: default, producer, consumer 48 | * One ElastiCache RBAC user group 49 | * One ElastiCache subnet group 50 | * One ElastiCache replication group 51 | * Three IAM roles: consumer, producer, outsider 52 | * One Lambda layer which contains the redis-py Python module 53 | * Three Lambda functions: producerFn, consumerFn, outsiderFn 54 | 55 | ![architecture diagram](img/architecture_diagram_with_flow.png) 56 | 57 | A VPC is created for the purpose of hosting the ElastiCache replication group and the Lambda functions that will be used to demonstrate how to access ElastiCache. The code snippet defines the VPC with an isolated subnet, which in CDK terms, is a private subnet with no routing to the Internet. In order for resources in the isolated subnet to access Secrets Manager, a Secrets Manager VPC Interface Endpoint is added. 58 | 59 | 60 | ``` 61 | const vpc = new ec2.Vpc(this, "Vpc", { 62 | subnetConfiguration: [ 63 | { 64 | cidrMask: 24, 65 | name: 'Isolated', 66 | subnetType: ec2.SubnetType.PRIVATE_ISOLATED, 67 | } 68 | ] 69 | }); 70 | 71 | const secretsManagerEndpoint = vpc.addInterfaceEndpoint('SecretsManagerEndpoint', { 72 | service: ec2.InterfaceVpcEndpointAwsService.SECRETS_MANAGER, 73 | subnets: { 74 | subnetType: ec2.SubnetType.PRIVATE_ISOLATED 75 | } 76 | }); 77 | 78 | secretsManagerEndpoint.connections.allowDefaultPortFromAnyIpv4(); 79 | 80 | ``` 81 | 82 | To modularize the design of the solution, a RedisRbacUser class is also created. This class is composed of two CDK resources: a Secrets Manager Secret and an ElastiCache CfnUser; these resources are explicitly grouped together since the Secret stores the CfnUser password, and as will be shown later, read and decrypt permissions to the Secret will be granted to an IAM user. 83 | 84 | A note about unsafeUnwrap(); this method was added to the Secrets Manager library in CDK version 2 and is used in place of toString() to explicitly force the developer to understand the consequences of decoded secrets in code. For details, please see the documentation for [unsafeUnwrap()](https://docs.aws.amazon.com/cdk/api/v2/docs/aws-cdk-lib.SecretValue.html#unsafewbrunwrap) in the CDK API documentation. 85 | 86 | ``` 87 | export class RedisRbacUser extends cdk.Construct { 88 | ... 89 | 90 | constructor(scope: cdk.Construct, id: string, props: RedisRbacUserProps) { 91 | super(scope, id); 92 | 93 | ... 94 | 95 | this.rbacUserSecret = new secretsmanager.Secret(this, 'secret', { 96 | generateSecretString: { 97 | secretStringTemplate: JSON.stringify({ username: props.redisUserName }), 98 | generateStringKey: 'password', 99 | excludeCharacters: '@%*()_+=`~{}|[]\\:";\'?,./' 100 | }, 101 | }); 102 | 103 | const user = new elasticache.CfnUser(this, 'redisuser', { 104 | engine: 'redis', 105 | userName: props.redisUserName, 106 | accessString: props.accessString? props.accessString : "off +get ~keys*", 107 | userId: props.redisUserId, 108 | passwords: [this.rbacUserSecret.secretValueFromJson('password').unsafeUnwrap()] 109 | }) 110 | 111 | ... 112 | 113 | } 114 | 115 | } 116 | ``` 117 | 118 | The RedisRbacUser class is instantiated in the following code snippet, with an example of the Redis ACL syntax used in the accessString. 119 | 120 | ``` 121 | const producerRbacUser = new RedisRbacUser(this, producerName+'RBAC', { 122 | redisUserName: producerName, 123 | redisUserId: producerName, 124 | accessString: 'on ~* -@all +SET' 125 | }); 126 | ``` 127 | 128 | An IAM role is granted the ability to read the RedisRbacUser’s secret (the username and password). This association means that the IAM role can decrypt the username and password and use them to establish a connection with Redis as the producerRbacUser. 129 | 130 | ``` 131 | const producerRole = new iam.Role(this, producerName+'Role', { 132 | ... 133 | }); 134 | 135 | producerRbacUser.grantSecretRead(producerRole) 136 | ``` 137 | 138 | The function grantSecretRead in the RedisRbacUser class modifies the role that is passed into it to allow it to perform actions “secretsmanager:GetSecretValue” and “secretsmanager:DescribeSecret”. The same function also modifies the secret by adding a resource policy that allows the same actions and adds the provided role to the principal list – this prevents unlisted principals from attempting to access the secret once the stack is deployed. 139 | 140 | ``` 141 | public grantReadSecret(principal: iam.IPrincipal){ 142 | if (this.secretResourcePolicyStatement == null) { 143 | this.secretResourcePolicyStatement = new iam.PolicyStatement({ 144 | effect: iam.Effect.ALLOW, 145 | actions: ['secretsmanager:DescribeSecret', 'secretsmanager:GetSecretValue'], 146 | resources: [this.rbacUserSecret.secretArn], 147 | principals: [principal] 148 | }) 149 | 150 | this.rbacUserSecret.addToResourcePolicy(this.secretResourcePolicyStatement) 151 | 152 | } else { 153 | this.secretResourcePolicyStatement.addPrincipals(principal) 154 | } 155 | 156 | this.rbacUserSecret.grantRead(principal) 157 | } 158 | ``` 159 | 160 | A Lambda function then uses the IAM role created previously, so that it can decrypt the username and password Secret and access the ElastiCache for Redis replication group. 161 | 162 | ``` 163 | const producerLambda = new lambda.Function(this, producerName+'Fn', { 164 | ... 165 | role: producerRole, 166 | ... 167 | environment: { 168 | redis_endpoint: ecClusterReplicationGroup.attrPrimaryEndPointAddress, 169 | redis_port: ecClusterReplicationGroup.attrPrimaryEndPointPort, 170 | secret_arn: producerRbacUser.getSecret().secretArn, 171 | } 172 | }); 173 | ``` 174 | 175 | ## Deploying the solution 176 | 177 | The infrastructure for this solution is implemented in AWS Cloud Development Kit (CDK) in Typescript and can be cloned from this GitHub repository. 178 | 179 | You can setup your environment for CDK by following the AWS Cloud Development Kit Getting Started document here: https://docs.aws.amazon.com/cdk/latest/guide/getting_started.html#getting_started_prerequisites 180 | 181 | To deploy the solution, you’ll first want to build the lambda zip files that will be used in the Lambda functions; to do so, navigate to the root of the project on your machine and enter the following command in your terminal: 182 | 183 | ``` $ npm run-script zip ``` 184 | 185 | To deploy the solution to your account, run the following command from the root of the project: 186 | 187 | ``` $ cdk deploy ``` 188 | 189 | The command will attempt to deploy the solution in the default AWS profile defined in either your `~/.aws/config` file or your `~/.aws/credentials` file. You can also define a profile by specifying the `--profile profile_name` at the end of the command. 190 | 191 | ## Testing the solution 192 | 193 | Three Lambda functions were deployed as a part of the stack: a Producer, a Consumer and an Outsider function. 194 | 195 | ### Creating a Test JSON for each function 196 | 197 | To test each function, you’ll need to create a test event for each. To create a test object, click the ‘Test’ button in the Lambda console and use the default JSON object in the body – the test functions will not read the event contents. 198 | 199 | ![test json](img/test_trigger.png) 200 | 201 | You can trigger each test by clicking on the test button 202 | 203 | ![test button](img/test_button.png) 204 | 205 | ### Producer Function Reads and Writes to Redis 206 | 207 | This function demonstrates how an IAM role, attached to a Lambda function can be used to retrieve a username and password from Secrets Manager, then use these credentials to establish a connection to Redis and peform a write operation. 208 | 209 | The Producer function will write a key “time” with a value of the current time. 210 | 211 | ![producer log output](img/producer_log_output.png) 212 | 213 | The Producer function will be able to write to Redis, and that is because it’s IAM role allows it to get and decrypt the ‘Producer’ username and password in Secrets Manager and its RBAC user was created with an Redis ACL Access String that allows all SET commands to be performed 214 | 215 | ### Consumer Function Can Read but Cannot Write to Redis 216 | 217 | This function demonstrates the use case where you can allow a specific IAM role to access a Redis RBAC username and password from Secrets Manager and establish a connection with Redis, but the actions it can perform are restricted by an access string setting. 218 | 219 | The Consumer function will attempt to write a key “time” with a value of the current time; it will subsequently attempt to read back the key “time”. 220 | 221 | ![consumer log output](img/consumer_log_output.png) 222 | 223 | The Consumer function will not be able to write to Redis, but it will be able to read from it. Even though the function has an IAM role that permits it to get and decrypt the ‘Consumer’ username and password in Secrets Manager, the ‘Consumer’ RBAC user was created with a Redis ACL Access String value that only allows the ‘GET’ command. 224 | 225 | 226 | ### Outsider Function Cannot Read and Cannot Write to Redis 227 | 228 | This function demonstrates the use case where you can specify an IAM role that cannot access Redis because it cannot decrypt a username and password stored in Secrets Manager. 229 | 230 | The Outsider Lambda function will attempt to get and decrypt the ‘Producer’ username and password from Secrets Manager, then read and write to the Redis cluster. 231 | 232 | ![outsider log output](img/outsider_log_output.png) 233 | 234 | An exception is raised that indicates that it is not permitted to access the ‘Producer’ secret and that is because the IAM role attached to it does not have the permissions to decrypt the ‘Producer’ secret. 235 | 236 | ## Cost of Running the Solution 237 | 238 | The solution to associate an IAM entity with an ElastiCache RBAC user required the deployment of a sample ElastiCache cluster, storing secrets in AWS Secrets Manager and defining an RBAC user and an RBAC user group. 239 | 240 | * Secrets Manager: 241 | * $0.40 per secret per month, prorated for secrets stored less than a month 242 | * $0.05 per 10000 API calls 243 | * Assuming each of the three secrets are called 10 times for testing purposes in one day, the total cost would be (3 * $0.40 / 30) + (3 * 10 / 1000) * $0.05 = $0.04015 244 | 245 | * ElastiCache: 246 | * cache.m4.large node $0.156 per hour 247 | * Assuming that the node used for one day the total cost would be $3.744 248 | 249 | * Lambda Function: 250 | * $0.0000000021 per ms of execution time 251 | * Assuming that each lambda is called 10 times for testing purposes in one day and that the average execution time is 400ms, the total cost would be 3 * 400 * $0.000000021 = $0.00000252 252 | 253 | The total cost of the solution, for 24 hours, assuming that each of the three Lambda functions are called 10 times would be $3.78415252. 254 | 255 | ## Cleanup and Teardown 256 | 257 | To delete all resources from your account, including the VPC, you will need to call the following command from the project root folder: 258 | 259 | `$ cdk destroy` 260 | 261 | 262 | As in the cdk deploy command, the destroy command will attempt to execute on the default profile defined in ~/.aws/config or ~/.aws/credentials. You can specify another profile by providing --profile as a command line option. 263 | 264 | ## Conclusion 265 | 266 | While fine-grained access is now possible with the inclusion of Redis Role Based Access Control (RBAC) users, user groups and access strings in Amazon ElastiCache, there is no out-of-the box ability to associate RBAC users with IAM entities (roles, users and groups). This blog post presented a solution that restricted RBAC credentials (userame and password) access by storing them in AWS Secrets Manager and granting select IAM entities permissions to decrypt these credentials – effectively linking RBAC users with IAM roles. 267 | 268 | ### Additional benefits presented in this solution include: 269 | 270 | * RBAC passwords are not defined, stored or shared in plaintext when RBAC users are created 271 | * RBAC users and groups can be defined wholly in CDK (and by extension CloudFormation) and included as infrastructure-as-code 272 | * You can trace Redis access to IAM users since RBAC usernames and passwords are stored and accessed through AWS Secrets Manager and access to these credentials can be traced via CloudTrail 273 | 274 | 275 | ### Additional Resources 276 | 277 | * Amazon ElastiCache for Redis adds support for Redis 6 with managed Role-Based Access Control (RBAC) 278 | https://aws.amazon.com/about-aws/whats-new/2020/10/amazon-elasticache-redis-support-managed-role-based-access-control/ 279 | 280 | * Authenticating Users with Role-Based Access Control (RBAC) https://docs.aws.amazon.com/AmazonElastiCache/latest/red-ug/Clusters.RBAC.html 281 | 282 | * Granting read access to one secret 283 | https://docs.aws.amazon.com/secretsmanager/latest/userguide/permissions_grant-get-secret-value-to-one-secret.html 284 | 285 | * Redis ACL 286 | https://redis.io/topics/acl 287 | -------------------------------------------------------------------------------- /docs/drawio/raw.drawio: -------------------------------------------------------------------------------- 1 | 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 --------------------------------------------------------------------------------