From 78d540ae518293c181a6b15579200c26e1671dc7 Mon Sep 17 00:00:00 2001 From: hdavey-gds <129174608+hdavey-gds@users.noreply.github.com> Date: Thu, 8 Feb 2024 09:52:31 +0000 Subject: [PATCH] DAC-2203 Secret not scheduled for rotation (#536) Add redshift-rotate-secret lambda handler and tests Add IaC code for lambda and related resources Add new ensureDefined util to utils Remove some duplicated code from quicksight-add-users handler Add @aws-sdk/client-secrets-manager dependency and client in clients.ts Add knex and pg dependencies for redshift access Add some external packages to esbuild in build-lambdas script as a result of the new knex and pg dependencies --- .../pull-request-deploy-and-test.yml | 1 + iac/main/base.yml | 8 + iac/main/resources/global.yml | 14 + iac/main/resources/redshift.yml | 87 ++++- package-lock.json | 344 ++++++++++++++++++ package.json | 3 + scripts/build-lambdas.sh | 3 +- .../quicksight-add-users/handler.spec.ts | 39 +- .../redshift-rotate-secret/database-access.ts | 51 +++ .../redshift-rotate-secret/handler.spec.ts | 276 ++++++++++++++ .../redshift-rotate-secret/handler.ts | 262 +++++++++++++ src/shared/clients.ts | 3 + src/shared/utils/utils.spec.ts | 7 + src/shared/utils/utils.ts | 9 + 14 files changed, 1069 insertions(+), 38 deletions(-) create mode 100644 src/handlers/redshift-rotate-secret/database-access.ts create mode 100644 src/handlers/redshift-rotate-secret/handler.spec.ts create mode 100644 src/handlers/redshift-rotate-secret/handler.ts diff --git a/.github/workflows/pull-request-deploy-and-test.yml b/.github/workflows/pull-request-deploy-and-test.yml index 6252497a3..b7d5f5e3f 100644 --- a/.github/workflows/pull-request-deploy-and-test.yml +++ b/.github/workflows/pull-request-deploy-and-test.yml @@ -36,6 +36,7 @@ jobs: deploy-to-feature: needs: [validate-deployment-or-teardown] + if: needs.validate-deployment-or-teardown.outputs.valid == 'true' # These permissions are needed to interact with GitHub's OIDC Token endpoint (enabling the aws-actions/configure-aws-credentials action) permissions: id-token: write diff --git a/iac/main/base.yml b/iac/main/base.yml index 39d400d58..6532633ed 100644 --- a/iac/main/base.yml +++ b/iac/main/base.yml @@ -56,6 +56,14 @@ Parameters: Description: The ARN of the role that will used by secure pipelines to run integration tests Default: none AllowedPattern: (none)|(arn:aws:iam::.*:role/.*) + RedshiftSecretLength: + Type: Number + Description: Length of the redshift password stored in secretsmanager + Default: 16 + RedshiftSecretExcludeCharacters: + Type: String + Description: String of characters to exclude from the redshift password stored in secretsmanager + Default: '"''@/\' Conditions: UseCodeSigning: !Not diff --git a/iac/main/resources/global.yml b/iac/main/resources/global.yml index fbf78d933..18fc6d17f 100644 --- a/iac/main/resources/global.yml +++ b/iac/main/resources/global.yml @@ -548,6 +548,20 @@ VPCEndpointS3: RouteTableIds: - !Ref RouteTableForDAP +VPCEndpointSecretsManager: + Type: AWS::EC2::VPCEndpoint + Properties: + VpcEndpointType: Interface + ServiceName: com.amazonaws.eu-west-2.secretsmanager + VpcId: !Ref VPCForDAP + PrivateDnsEnabled: true + SecurityGroupIds: + - !Ref LambdaSecurityGroup + SubnetIds: + - !Ref SubnetForDAP1 + - !Ref SubnetForDAP2 + - !Ref SubnetForDAP3 + VPCEndpointSQS: Type: AWS::EC2::VPCEndpoint Properties: diff --git a/iac/main/resources/redshift.yml b/iac/main/resources/redshift.yml index ff44efcc3..92c233ea1 100644 --- a/iac/main/resources/redshift.yml +++ b/iac/main/resources/redshift.yml @@ -43,21 +43,98 @@ RedshiftSecret: Properties: Description: This is a Secrets Manager secret for a Redshift cluster GenerateSecretString: - SecretStringTemplate: '{"username": "admin"}' + # based on https://docs.aws.amazon.com/secretsmanager/latest/userguide/reference_secret_json_structure.html#reference_secret_json_structure_RS + SecretStringTemplate: !Sub | + { + "engine": "redshift", + "host": "${Environment}-redshift-serverless-workgroup.${AWS::AccountId}.${AWS::Region}.redshift-serverless.amazonaws.com", + "username": "admin", + "dbname": "${Environment}-redshift", + "port": "5439" + } GenerateStringKey: password - PasswordLength: 16 - ExcludeCharacters: '"''@/\' + PasswordLength: !Ref RedshiftSecretLength + ExcludeCharacters: !Ref RedshiftSecretExcludeCharacters KmsKeyId: !Ref KmsKey Tags: - Key: RedshiftUser Value: admin +RedshiftSecretRotationSchedule: + Type: AWS::SecretsManager::RotationSchedule + Properties: + RotationLambdaARN: !GetAtt RedshiftSecretRotationLambda.Arn + RotationRules: + ScheduleExpression: cron(0 2 1 * ? *) # the first day of every month at 2am + SecretId: !Ref RedshiftSecret + +RedshiftSecretRotationLambda: + # checkov:skip=CKV_AWS_116: DLQ not needed + Type: AWS::Serverless::Function + Properties: + FunctionName: redshift-rotate-secret + Handler: redshift-rotate-secret.handler + Policies: + - AWSLambdaBasicExecutionRole + - Statement: + - Effect: Allow + Action: + - 'sqs:DeleteMessage' + - 'sqs:GetQueueAttributes' + - 'sqs:ReceiveMessage' + Resource: !If [UsePlaceholderTxMAQueue, !GetAtt EventConsumerQueue.Arn, '{{resolve:ssm:TxMAEventQueueARN}}'] + - Effect: Allow + Action: + - secretsmanager:DescribeSecret + - secretsmanager:GetSecretValue + - secretsmanager:PutSecretValue + - secretsmanager:UpdateSecretVersionStage + Resource: !Ref RedshiftSecret + - Effect: Allow + Action: secretsmanager:GetRandomPassword + Resource: '*' + - Effect: Allow + Action: + - kms:Decrypt + - kms:DescribeKey + - kms:GenerateDataKey + Resource: !GetAtt KmsKey.Arn + Condition: + StringEquals: + kms:EncryptionContext:SecretARN: !Ref RedshiftSecret + ReservedConcurrentExecutions: 10 + Environment: + # checkov:skip=CKV_AWS_173: These environment variables do not require encryption + Variables: + PASSWORD_LENGTH: !Ref RedshiftSecretLength + PASSWORD_EXCLUDE_CHARS: !Ref RedshiftSecretExcludeCharacters + ENVIRONMENT: !Ref Environment + Tags: + Environment: !Ref Environment + MemorySize: 256 + VpcConfig: + SecurityGroupIds: + - !Ref LambdaSecurityGroup + - !Ref RedshiftAccessEC2SecurityGroup + SubnetIds: + - !Ref SubnetForDAP1 + - !Ref SubnetForDAP2 + - !Ref SubnetForDAP3 + +RedshiftSecretRotationLambdaPermission: + Type: AWS::Lambda::Permission + Properties: + FunctionName: !Ref RedshiftSecretRotationLambda + Action: lambda:InvokeFunction + Principal: secretsmanager.amazonaws.com + SourceArn: !Ref RedshiftSecret + RedshiftServerlessNamespace: Type: 'AWS::RedshiftServerless::Namespace' Properties: AdminUsername: !Sub '{{resolve:secretsmanager:${RedshiftSecret}::username}}' AdminUserPassword: !Sub '{{resolve:secretsmanager:${RedshiftSecret}::password}}' - DbName: !Sub '${Environment}-redshift' + DbName: !Sub '{{resolve:secretsmanager:${RedshiftSecret}::dbname}}' DefaultIamRoleArn: !GetAtt IAMRoleRedshiftServerless.Arn IamRoles: - !GetAtt IAMRoleRedshiftServerless.Arn @@ -74,7 +151,7 @@ RedshiftServerlessWorkgroup: BaseCapacity: 64 EnhancedVpcRouting: false NamespaceName: !Ref RedshiftServerlessNamespace - Port: 5439 + Port: !Sub '{{resolve:secretsmanager:${RedshiftSecret}::port}}' PubliclyAccessible: false SubnetIds: - !Ref SubnetForDAP1 diff --git a/package-lock.json b/package-lock.json index ceb133777..731ee16ba 100644 --- a/package-lock.json +++ b/package-lock.json @@ -19,6 +19,7 @@ "@aws-sdk/client-quicksight": "^3.504.0", "@aws-sdk/client-redshift-data": "^3.504.0", "@aws-sdk/client-s3": "^3.504.0", + "@aws-sdk/client-secrets-manager": "^3.504.0", "@aws-sdk/client-sfn": "^3.504.0", "@aws-sdk/client-sqs": "^3.504.0", "@aws-sdk/types": "^3.502.0", @@ -43,7 +44,9 @@ "jest": "^29.7.0", "jest-junit": "^16.0.0", "jest-stare": "^2.5.1", + "knex": "^3.1.0", "lint-staged": "^15.2.2", + "pg": "^8.11.3", "prettier": "^3.2.5", "prettier-plugin-sh": "^0.14.0", "typescript": "^5.3.3" @@ -689,6 +692,58 @@ "node": ">=14.0.0" } }, + "node_modules/@aws-sdk/client-secrets-manager": { + "version": "3.504.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/client-secrets-manager/-/client-secrets-manager-3.504.0.tgz", + "integrity": "sha512-JPwsYfQMjs5t74JmA4r1AjpiOG/LEw74d4a8vEdSy3pe2lhl/sSsxSdQtbI30wlJJramngtLNZjxn2+BGDphbg==", + "dev": true, + "dependencies": { + "@aws-crypto/sha256-browser": "3.0.0", + "@aws-crypto/sha256-js": "3.0.0", + "@aws-sdk/client-sts": "3.504.0", + "@aws-sdk/core": "3.496.0", + "@aws-sdk/credential-provider-node": "3.504.0", + "@aws-sdk/middleware-host-header": "3.502.0", + "@aws-sdk/middleware-logger": "3.502.0", + "@aws-sdk/middleware-recursion-detection": "3.502.0", + "@aws-sdk/middleware-signing": "3.502.0", + "@aws-sdk/middleware-user-agent": "3.502.0", + "@aws-sdk/region-config-resolver": "3.502.0", + "@aws-sdk/types": "3.502.0", + "@aws-sdk/util-endpoints": "3.502.0", + "@aws-sdk/util-user-agent-browser": "3.502.0", + "@aws-sdk/util-user-agent-node": "3.502.0", + "@smithy/config-resolver": "^2.1.1", + "@smithy/core": "^1.3.1", + "@smithy/fetch-http-handler": "^2.4.1", + "@smithy/hash-node": "^2.1.1", + "@smithy/invalid-dependency": "^2.1.1", + "@smithy/middleware-content-length": "^2.1.1", + "@smithy/middleware-endpoint": "^2.4.1", + "@smithy/middleware-retry": "^2.1.1", + "@smithy/middleware-serde": "^2.1.1", + "@smithy/middleware-stack": "^2.1.1", + "@smithy/node-config-provider": "^2.2.1", + "@smithy/node-http-handler": "^2.3.1", + "@smithy/protocol-http": "^3.1.1", + "@smithy/smithy-client": "^2.3.1", + "@smithy/types": "^2.9.1", + "@smithy/url-parser": "^2.1.1", + "@smithy/util-base64": "^2.1.1", + "@smithy/util-body-length-browser": "^2.1.1", + "@smithy/util-body-length-node": "^2.2.1", + "@smithy/util-defaults-mode-browser": "^2.1.1", + "@smithy/util-defaults-mode-node": "^2.1.1", + "@smithy/util-endpoints": "^1.1.1", + "@smithy/util-retry": "^2.1.1", + "@smithy/util-utf8": "^2.1.1", + "tslib": "^2.5.0", + "uuid": "^8.3.2" + }, + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/@aws-sdk/client-sfn": { "version": "3.504.0", "resolved": "https://registry.npmjs.org/@aws-sdk/client-sfn/-/client-sfn-3.504.0.tgz", @@ -5870,6 +5925,15 @@ "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", "dev": true }, + "node_modules/buffer-writer": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/buffer-writer/-/buffer-writer-2.0.0.tgz", + "integrity": "sha512-a7ZpuTZU1TRtnwyCNW3I5dc0wWNC3VR9S++Ewyk2HHZdrO3CQJqSpd+95Us590V6AL7JqUAH2IwZ/398PmNFgw==", + "dev": true, + "engines": { + "node": ">=4" + } + }, "node_modules/builtins": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/builtins/-/builtins-5.0.1.tgz", @@ -7336,6 +7400,15 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/esm": { + "version": "3.2.25", + "resolved": "https://registry.npmjs.org/esm/-/esm-3.2.25.tgz", + "integrity": "sha512-U1suiZ2oDVWv4zPO56S0NcR5QriEahGtdN2OR6FiOG4WJvcjBVFB0qI4+eKoWFH483PKGuLuu6V8Z4T5g63UVA==", + "dev": true, + "engines": { + "node": ">=6" + } + }, "node_modules/espree": { "version": "9.6.1", "resolved": "https://registry.npmjs.org/espree/-/espree-9.6.1.tgz", @@ -8146,6 +8219,12 @@ "node": ">=0.10.0" } }, + "node_modules/getopts": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/getopts/-/getopts-2.3.0.tgz", + "integrity": "sha512-5eDf9fuSXwxBL6q5HX+dhDj+dslFGWzU5thZ9kNKUkcPtaPdatmUFKwHFrLb/uf/WpA4BHET+AX3Scl56cAjpA==", + "dev": true + }, "node_modules/glob": { "version": "7.2.3", "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", @@ -8679,6 +8758,15 @@ "node": ">= 0.4" } }, + "node_modules/interpret": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/interpret/-/interpret-2.2.0.tgz", + "integrity": "sha512-Ju0Bz/cEia55xDwUWEa8+olFpCiQoypjnQySseKtmjNrnps3P+xfpUmGr90T7yjlVJmOtybRvPXhKMbHr+fWnw==", + "dev": true, + "engines": { + "node": ">= 0.10" + } + }, "node_modules/is-accessor-descriptor": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/is-accessor-descriptor/-/is-accessor-descriptor-1.0.0.tgz", @@ -12424,6 +12512,72 @@ "node": ">=6" } }, + "node_modules/knex": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/knex/-/knex-3.1.0.tgz", + "integrity": "sha512-GLoII6hR0c4ti243gMs5/1Rb3B+AjwMOfjYm97pu0FOQa7JH56hgBxYf5WK2525ceSbBY1cjeZ9yk99GPMB6Kw==", + "dev": true, + "dependencies": { + "colorette": "2.0.19", + "commander": "^10.0.0", + "debug": "4.3.4", + "escalade": "^3.1.1", + "esm": "^3.2.25", + "get-package-type": "^0.1.0", + "getopts": "2.3.0", + "interpret": "^2.2.0", + "lodash": "^4.17.21", + "pg-connection-string": "2.6.2", + "rechoir": "^0.8.0", + "resolve-from": "^5.0.0", + "tarn": "^3.0.2", + "tildify": "2.0.0" + }, + "bin": { + "knex": "bin/cli.js" + }, + "engines": { + "node": ">=16" + }, + "peerDependenciesMeta": { + "better-sqlite3": { + "optional": true + }, + "mysql": { + "optional": true + }, + "mysql2": { + "optional": true + }, + "pg": { + "optional": true + }, + "pg-native": { + "optional": true + }, + "sqlite3": { + "optional": true + }, + "tedious": { + "optional": true + } + } + }, + "node_modules/knex/node_modules/colorette": { + "version": "2.0.19", + "resolved": "https://registry.npmjs.org/colorette/-/colorette-2.0.19.tgz", + "integrity": "sha512-3tlv/dIP7FWvj3BsbHrGLJ6l/oKh1O3TcgBqMn+yyCagOxc23fyzDS6HypQbgxWbkpDnf52p1LuR4eWDQ/K9WQ==", + "dev": true + }, + "node_modules/knex/node_modules/commander": { + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/commander/-/commander-10.0.1.tgz", + "integrity": "sha512-y4Mg2tXshplEbSGzx7amzPwKKOCGuoSRP/CjEdwwk0FOGlUbq6lKuoyDZTNZkmxHdJtp54hdfY/JUrdL7Xfdug==", + "dev": true, + "engines": { + "node": ">=14" + } + }, "node_modules/leven": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/leven/-/leven-3.1.0.tgz", @@ -12742,6 +12896,12 @@ "node": ">=8" } }, + "node_modules/lodash": { + "version": "4.17.21", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", + "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==", + "dev": true + }, "node_modules/lodash.get": { "version": "4.4.2", "resolved": "https://registry.npmjs.org/lodash.get/-/lodash.get-4.4.2.tgz", @@ -13513,6 +13673,12 @@ "node": ">=6" } }, + "node_modules/packet-reader": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/packet-reader/-/packet-reader-1.0.0.tgz", + "integrity": "sha512-HAKu/fG3HpHFO0AA8WE8q2g+gBJaZ9MG7fcKk+IJPLTGAD6Psw4443l+9DGRbOIh3/aXr7Phy0TjilYivJo5XQ==", + "dev": true + }, "node_modules/param-case": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/param-case/-/param-case-2.1.1.tgz", @@ -13618,6 +13784,97 @@ "node": ">=8" } }, + "node_modules/pg": { + "version": "8.11.3", + "resolved": "https://registry.npmjs.org/pg/-/pg-8.11.3.tgz", + "integrity": "sha512-+9iuvG8QfaaUrrph+kpF24cXkH1YOOUeArRNYIxq1viYHZagBxrTno7cecY1Fa44tJeZvaoG+Djpkc3JwehN5g==", + "dev": true, + "dependencies": { + "buffer-writer": "2.0.0", + "packet-reader": "1.0.0", + "pg-connection-string": "^2.6.2", + "pg-pool": "^3.6.1", + "pg-protocol": "^1.6.0", + "pg-types": "^2.1.0", + "pgpass": "1.x" + }, + "engines": { + "node": ">= 8.0.0" + }, + "optionalDependencies": { + "pg-cloudflare": "^1.1.1" + }, + "peerDependencies": { + "pg-native": ">=3.0.1" + }, + "peerDependenciesMeta": { + "pg-native": { + "optional": true + } + } + }, + "node_modules/pg-cloudflare": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.1.1.tgz", + "integrity": "sha512-xWPagP/4B6BgFO+EKz3JONXv3YDgvkbVrGw2mTo3D6tVDQRh1e7cqVGvyR3BE+eQgAvx1XhW/iEASj4/jCWl3Q==", + "dev": true, + "optional": true + }, + "node_modules/pg-connection-string": { + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.6.2.tgz", + "integrity": "sha512-ch6OwaeaPYcova4kKZ15sbJ2hKb/VP48ZD2gE7i1J+L4MspCtBMAx8nMgz7bksc7IojCIIWuEhHibSMFH8m8oA==", + "dev": true + }, + "node_modules/pg-int8": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz", + "integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==", + "dev": true, + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/pg-pool": { + "version": "3.6.1", + "resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.6.1.tgz", + "integrity": "sha512-jizsIzhkIitxCGfPRzJn1ZdcosIt3pz9Sh3V01fm1vZnbnCMgmGl5wvGGdNN2EL9Rmb0EcFoCkixH4Pu+sP9Og==", + "dev": true, + "peerDependencies": { + "pg": ">=8.0" + } + }, + "node_modules/pg-protocol": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.6.0.tgz", + "integrity": "sha512-M+PDm637OY5WM307051+bsDia5Xej6d9IR4GwJse1qA1DIhiKlksvrneZOYQq42OM+spubpcNYEo2FcKQrDk+Q==", + "dev": true + }, + "node_modules/pg-types": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz", + "integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==", + "dev": true, + "dependencies": { + "pg-int8": "1.0.1", + "postgres-array": "~2.0.0", + "postgres-bytea": "~1.0.0", + "postgres-date": "~1.0.4", + "postgres-interval": "^1.1.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/pgpass": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.5.tgz", + "integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==", + "dev": true, + "dependencies": { + "split2": "^4.1.0" + } + }, "node_modules/picocolors": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.0.0.tgz", @@ -13762,6 +14019,45 @@ "node": ">=0.10.0" } }, + "node_modules/postgres-array": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz", + "integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==", + "dev": true, + "engines": { + "node": ">=4" + } + }, + "node_modules/postgres-bytea": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.0.tgz", + "integrity": "sha512-xy3pmLuQqRBZBXDULy7KbaitYqLcmxigw14Q5sj8QBVLqEwXfeybIKVWiqAXTlcvdvb0+xkOtDbfQMOf4lST1w==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/postgres-date": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz", + "integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/postgres-interval": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz", + "integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==", + "dev": true, + "dependencies": { + "xtend": "^4.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/prelude-ls": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", @@ -13920,6 +14216,18 @@ "integrity": "sha512-xWGDIW6x921xtzPkhiULtthJHoJvBbF3q26fzloPCK0hsvxtPVelvftw3zjbHWSkR2km9Z+4uxbDDK/6Zw9B8w==", "dev": true }, + "node_modules/rechoir": { + "version": "0.8.0", + "resolved": "https://registry.npmjs.org/rechoir/-/rechoir-0.8.0.tgz", + "integrity": "sha512-/vxpCXddiX8NGfGO/mTafwjq4aFa/71pvamip0++IQk3zG8cbCj0fifNPrjjF1XMXUne91jL9OoxmdykoEtifQ==", + "dev": true, + "dependencies": { + "resolve": "^1.20.0" + }, + "engines": { + "node": ">= 10.13.0" + } + }, "node_modules/regex-not": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/regex-not/-/regex-not-1.0.2.tgz", @@ -14960,6 +15268,15 @@ "node": ">=0.10.0" } }, + "node_modules/split2": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz", + "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==", + "dev": true, + "engines": { + "node": ">= 10.x" + } + }, "node_modules/sprintf-js": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz", @@ -15248,6 +15565,15 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/tarn": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/tarn/-/tarn-3.0.2.tgz", + "integrity": "sha512-51LAVKUSZSVfI05vjPESNc5vwqqZpbXCsU+/+wxlOrUjk2SnFTt97v9ZgQrD4YmxYW1Px6w2KjaDitCfkvgxMQ==", + "dev": true, + "engines": { + "node": ">=8.0.0" + } + }, "node_modules/test-exclude": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-6.0.0.tgz", @@ -15268,6 +15594,15 @@ "integrity": "sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==", "dev": true }, + "node_modules/tildify": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/tildify/-/tildify-2.0.0.tgz", + "integrity": "sha512-Cc+OraorugtXNfs50hU9KS369rFXCfgGLpfCfvlc+Ud5u6VWmUQsOAa9HbTvheQdYnrdJqqv1e5oIqXppMYnSw==", + "dev": true, + "engines": { + "node": ">=8" + } + }, "node_modules/tmpl": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/tmpl/-/tmpl-1.0.5.tgz", @@ -15865,6 +16200,15 @@ "integrity": "sha512-huCv9IH9Tcf95zuYCsQraZtWnJvBtLVE0QHMOs8bWyZAFZNDcYjsPq1nEx8jKA9y+Beo9v+7OBPRisQTjinQMw==", "dev": true }, + "node_modules/xtend": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", + "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==", + "dev": true, + "engines": { + "node": ">=0.4" + } + }, "node_modules/y18n": { "version": "5.0.8", "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", diff --git a/package.json b/package.json index 33cd0a7bf..39c46429b 100644 --- a/package.json +++ b/package.json @@ -15,6 +15,7 @@ "@aws-sdk/client-quicksight": "^3.504.0", "@aws-sdk/client-redshift-data": "^3.504.0", "@aws-sdk/client-s3": "^3.504.0", + "@aws-sdk/client-secrets-manager": "^3.504.0", "@aws-sdk/client-sfn": "^3.504.0", "@aws-sdk/client-sqs": "^3.504.0", "@aws-sdk/types": "^3.502.0", @@ -39,7 +40,9 @@ "jest": "^29.7.0", "jest-junit": "^16.0.0", "jest-stare": "^2.5.1", + "knex": "^3.1.0", "lint-staged": "^15.2.2", + "pg": "^8.11.3", "prettier": "^3.2.5", "prettier-plugin-sh": "^0.14.0", "typescript": "^5.3.3" diff --git a/scripts/build-lambdas.sh b/scripts/build-lambdas.sh index 21340ee38..bdc69b05c 100755 --- a/scripts/build-lambdas.sh +++ b/scripts/build-lambdas.sh @@ -11,5 +11,6 @@ for dir in "$SRC"/*; do srcPath="${dir}/handler.ts" lambdaName="${dir##*/}" echo "Building $lambdaName" - esbuild "$srcPath" --bundle --minify --sourcemap --platform=node --target=es2020 --outfile="$DIST/$lambdaName.js" --log-level=warning + esbuild "$srcPath" --bundle --minify --sourcemap --platform=node --target=es2020 --outfile="$DIST/$lambdaName.js" --log-level=warning \ + --external:better-sqlite3 --external:better-mysql2 --external:mysql* --external:oracledb --external:pg-query-stream --external:sqlite3 --external:tedious done diff --git a/src/handlers/quicksight-add-users/handler.spec.ts b/src/handlers/quicksight-add-users/handler.spec.ts index f72e515cb..027a172a0 100644 --- a/src/handlers/quicksight-add-users/handler.spec.ts +++ b/src/handlers/quicksight-add-users/handler.spec.ts @@ -30,7 +30,14 @@ const mockQuicksightClient = mockClient(QuickSightClient); beforeEach(() => { mockCognitoClient.reset(); + mockCognitoClient.callsFake(input => { + throw new Error(`Unexpected Cognito request - ${JSON.stringify(input)}`); + }); + mockQuicksightClient.reset(); + mockQuicksightClient.callsFake(input => { + throw new Error(`Unexpected Quicksight request - ${JSON.stringify(input)}`); + }); process.env.USER_POOL_ID = USER_POOL_ID; }); @@ -47,10 +54,6 @@ test('missing user pool id', async () => { test('successes', async () => { mockCognitoClient - .callsFake(input => { - throw new Error(`Unexpected Cognito request - ${JSON.stringify(input)}`); - }) - .on(AdminGetUserCommand, { UserPoolId: USER_POOL_ID, Username: 'user-a' }) .rejectsOnce(new UserNotFoundException({ message: '', $metadata: {} })) .on(AdminGetUserCommand, { UserPoolId: USER_POOL_ID, Username: 'user-b' }) @@ -66,10 +69,6 @@ test('successes', async () => { .resolvesOnce({}); mockQuicksightClient - .callsFake(input => { - throw new Error(`Unexpected Quicksight request - ${JSON.stringify(input)}`); - }) - .on(DescribeUserCommand, { UserName: 'user-a' }) .rejectsOnce(new ResourceNotFoundException({ message: '', $metadata: {} })) .on(DescribeUserCommand, { UserName: 'user-b' }) @@ -123,10 +122,6 @@ test('successes', async () => { test('user existence failures', async () => { mockCognitoClient - .callsFake(input => { - throw new Error(`Unexpected Cognito request - ${JSON.stringify(input)}`); - }) - .on(AdminGetUserCommand, { UserPoolId: USER_POOL_ID, Username: 'user-a' }) .resolvesOnce(mockCognitoUser('user-a', 'a@a.com')) .on(AdminGetUserCommand, { UserPoolId: USER_POOL_ID, Username: 'user-b' }) @@ -135,10 +130,6 @@ test('user existence failures', async () => { .resolvesOnce(mockCognitoUser('user-c', 'c@c.com')); mockQuicksightClient - .callsFake(input => { - throw new Error(`Unexpected Quicksight request - ${JSON.stringify(input)}`); - }) - .on(DescribeUserCommand, { UserName: 'user-a' }) .rejectsOnce(new ResourceNotFoundException({ message: '', $metadata: {} })) .on(DescribeUserCommand, { UserName: 'user-b' }) @@ -189,10 +180,6 @@ test('user existence failures', async () => { test('user status errors', async () => { mockCognitoClient - .callsFake(input => { - throw new Error(`Unexpected Cognito request - ${JSON.stringify(input)}`); - }) - .on(AdminGetUserCommand, { UserPoolId: USER_POOL_ID, Username: 'user-a' }) .rejectsOnce('Cognito get user error') .on(AdminGetUserCommand, { UserPoolId: USER_POOL_ID, Username: 'user-b' }) @@ -201,10 +188,6 @@ test('user status errors', async () => { .rejectsOnce(new UserNotFoundException({ message: '', $metadata: {} })); mockQuicksightClient - .callsFake(input => { - throw new Error(`Unexpected Quicksight request - ${JSON.stringify(input)}`); - }) - .on(DescribeUserCommand, { UserName: 'user-a' }) .rejectsOnce(new ResourceNotFoundException({ message: '', $metadata: {} })) .on(DescribeUserCommand, { UserName: 'user-b' }) @@ -259,10 +242,6 @@ test('user status errors', async () => { test('user and group add errors', async () => { mockCognitoClient - .callsFake(input => { - throw new Error(`Unexpected Cognito request - ${JSON.stringify(input)}`); - }) - .on(AdminGetUserCommand, { UserPoolId: USER_POOL_ID, Username: 'user-a' }) .rejectsOnce(new UserNotFoundException({ message: '', $metadata: {} })) .on(AdminGetUserCommand, { UserPoolId: USER_POOL_ID, Username: 'user-b' }) @@ -278,10 +257,6 @@ test('user and group add errors', async () => { .resolvesOnce({}); mockQuicksightClient - .callsFake(input => { - throw new Error(`Unexpected Quicksight request - ${JSON.stringify(input)}`); - }) - .on(DescribeUserCommand, { UserName: 'user-a' }) .rejectsOnce(new ResourceNotFoundException({ message: '', $metadata: {} })) .on(DescribeUserCommand, { UserName: 'user-b' }) diff --git a/src/handlers/redshift-rotate-secret/database-access.ts b/src/handlers/redshift-rotate-secret/database-access.ts new file mode 100644 index 000000000..feed037e2 --- /dev/null +++ b/src/handlers/redshift-rotate-secret/database-access.ts @@ -0,0 +1,51 @@ +import type { RedshiftSecret } from './handler'; +import type { Knex } from 'knex'; +import { knex } from 'knex'; +import type { Logger } from '@aws-lambda-powertools/logger'; + +export abstract class Database { + abstract destroy(): Promise; + + abstract raw(query: string): Promise; +} + +export class DatabaseAccess { + private readonly logger: Logger; + + constructor(logger: Logger) { + this.logger = logger; + } + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + async getDatabaseConnection(secret: RedshiftSecret): Promise { + try { + const connectionDetails = { + host: secret.host, + user: secret.username, + password: secret.password, + database: secret.dbname, + port: parseInt(secret.port), + }; + this.logger.info('Connection details', { connectionDetails: { ...connectionDetails, password: undefined } }); + const connection = knex({ + client: 'pg', + connection: connectionDetails, + }); + // this step is needed as knex will return a non-null connection even if elements of the connection are incorrect + // and will only throw an error or hang the first time you attempt to use the connection + return await this.validateConnection(connection); + } catch (error) { + this.logger.error(`Error connecting to ${secret.dbname}`, { error }); + return null; + } + } + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + private async validateConnection(connection: Knex | null): Promise> { + if (connection === null) { + throw new Error('Connection is null'); + } + await connection.raw('select now()').timeout(1000); + return connection; + } +} diff --git a/src/handlers/redshift-rotate-secret/handler.spec.ts b/src/handlers/redshift-rotate-secret/handler.spec.ts new file mode 100644 index 000000000..ed508ac1b --- /dev/null +++ b/src/handlers/redshift-rotate-secret/handler.spec.ts @@ -0,0 +1,276 @@ +import { mockClient } from 'aws-sdk-client-mock'; +import { + DescribeSecretCommand, + GetRandomPasswordCommand, + GetSecretValueCommand, + PutSecretValueCommand, + SecretsManagerClient, + UpdateSecretVersionStageCommand, +} from '@aws-sdk/client-secrets-manager'; +import { databaseAccess, handler } from './handler'; +import type { RedshiftSecret, RotateSecretStep, SecretRotationStage } from './handler'; +import type { Database } from './database-access'; + +const mockSecretsManagerClient = mockClient(SecretsManagerClient); + +const CLIENT_REQUEST_TOKEN = 'token'; + +const PASSWORD_EXCLUDE_CHARS = `"''@/\\`; + +const PASSWORD_LENGTH = '16'; + +const SECRET_ID = 'MySecretId'; + +const getDatabaseConnectionSpy = jest.spyOn(databaseAccess, 'getDatabaseConnection'); + +interface SecretsManagerMockingConfig { + pendingSecretError?: boolean; + versions?: Record; +} + +interface DatabaseConnectionMockingConfig { + connection: boolean; + rawError?: string; +} + +beforeEach(() => { + getDatabaseConnectionSpy.mockReset(); + mockSecretsManagerClient.reset(); + mockSecretsManagerClient.callsFake(input => { + throw new Error(`Unexpected Secrets Manager request - ${JSON.stringify(input)}`); + }); + + process.env.PASSWORD_EXCLUDE_CHARS = PASSWORD_EXCLUDE_CHARS; + process.env.PASSWORD_LENGTH = PASSWORD_LENGTH; +}); + +test('no stage for rotation', async () => { + mockSecretsManager({ versions: { someVersion: ['AWSPENDING'] } }); + + await expect( + handler({ Step: 'createSecret', SecretId: SECRET_ID, ClientRequestToken: CLIENT_REQUEST_TOKEN }), + ).rejects.toThrow(`Secret version ${CLIENT_REQUEST_TOKEN} has no stage for rotation`); + + // describeSecret + expect(mockSecretsManagerClient.calls()).toHaveLength(1); +}); + +test('invalid step', async () => { + mockSecretsManager(); + + await expect( + handler({ + Step: 'invalid' as unknown as RotateSecretStep, + SecretId: SECRET_ID, + ClientRequestToken: CLIENT_REQUEST_TOKEN, + }), + ).rejects.toThrow('Invalid step parameter "invalid"'); + + // describeSecret + expect(mockSecretsManagerClient.calls()).toHaveLength(1); +}); + +test('create secret pending already created', async () => { + mockSecretsManager(); + + await handler({ Step: 'createSecret', SecretId: SECRET_ID, ClientRequestToken: CLIENT_REQUEST_TOKEN }); + + // describeSecret, getSecret + expect(mockSecretsManagerClient.calls()).toHaveLength(2); +}); + +test('create secret create pending', async () => { + mockSecretsManager({ pendingSecretError: true }); + + await handler({ Step: 'createSecret', SecretId: SECRET_ID, ClientRequestToken: CLIENT_REQUEST_TOKEN }); + + // describeSecret, getSecret, getSecret, getRandomPassword, putSecretValue + expect(mockSecretsManagerClient.calls()).toHaveLength(5); +}); + +test('set secret pending already set', async () => { + mockSecretsManager(); + mockDatabaseConnections({ AWSPENDING: { connection: true } }); + + await handler({ Step: 'setSecret', SecretId: SECRET_ID, ClientRequestToken: CLIENT_REQUEST_TOKEN }); + + // describeSecret, getSecret + expect(mockSecretsManagerClient.calls()).toHaveLength(2); +}); + +test('set secret current works', async () => { + mockSecretsManager(); + mockDatabaseConnections({ AWSCURRENT: { connection: true } }); + + await handler({ Step: 'setSecret', SecretId: SECRET_ID, ClientRequestToken: CLIENT_REQUEST_TOKEN }); + + // describeSecret, getSecret, getSecret + expect(mockSecretsManagerClient.calls()).toHaveLength(3); +}); + +test('set secret previous works', async () => { + mockSecretsManager(); + mockDatabaseConnections({ AWSPREVIOUS: { connection: true } }); + + await handler({ Step: 'setSecret', SecretId: SECRET_ID, ClientRequestToken: CLIENT_REQUEST_TOKEN }); + + // describeSecret, getSecret, getSecret, getSecret + expect(mockSecretsManagerClient.calls()).toHaveLength(4); +}); + +test('set secret none works', async () => { + mockSecretsManager(); + mockDatabaseConnections({}); + + await expect( + handler({ Step: 'setSecret', SecretId: SECRET_ID, ClientRequestToken: CLIENT_REQUEST_TOKEN }), + ).rejects.toThrow('setSecret: Unable to log into database with previous, current, or pending secret'); + + // describeSecret, getSecret, getSecret, getSecret + expect(mockSecretsManagerClient.calls()).toHaveLength(4); +}); + +test('set secret error changing password', async () => { + const errorMessage = 'error setting password'; + mockSecretsManager(); + mockDatabaseConnections({ AWSCURRENT: { connection: true, rawError: errorMessage } }); + + await expect( + handler({ Step: 'setSecret', SecretId: SECRET_ID, ClientRequestToken: CLIENT_REQUEST_TOKEN }), + ).rejects.toThrow(`setSecret: Error changing database password - "${errorMessage}"`); + + // describeSecret, getSecret, getSecret + expect(mockSecretsManagerClient.calls()).toHaveLength(3); +}); + +test('test secret success', async () => { + mockSecretsManager(); + mockDatabaseConnections({ AWSPENDING: { connection: true } }); + + await handler({ Step: 'testSecret', SecretId: SECRET_ID, ClientRequestToken: CLIENT_REQUEST_TOKEN }); + + // describeSecret, getSecret + expect(mockSecretsManagerClient.calls()).toHaveLength(2); +}); + +test('test secret bad secret', async () => { + mockSecretsManager({ pendingSecretError: true }); + mockDatabaseConnections({ AWSPENDING: { connection: true } }); + + await expect( + handler({ Step: 'testSecret', SecretId: SECRET_ID, ClientRequestToken: CLIENT_REQUEST_TOKEN }), + ).rejects.toThrow('Error getting secret - pending secret error'); + + // describeSecret, getSecret + expect(mockSecretsManagerClient.calls()).toHaveLength(2); +}); + +test('test secret bad connection', async () => { + mockSecretsManager(); + mockDatabaseConnections({}); + + await expect( + handler({ Step: 'testSecret', SecretId: SECRET_ID, ClientRequestToken: CLIENT_REQUEST_TOKEN }), + ).rejects.toThrow('testSecret: Unable to log into database with pending secret'); + + // describeSecret, getSecret + expect(mockSecretsManagerClient.calls()).toHaveLength(2); +}); + +test('finish secret already marked as current', async () => { + mockSecretsManager({ versions: { [CLIENT_REQUEST_TOKEN]: ['AWSCURRENT'] } }); + + await handler({ Step: 'finishSecret', SecretId: SECRET_ID, ClientRequestToken: CLIENT_REQUEST_TOKEN }); + + // describeSecret + expect(mockSecretsManagerClient.calls()).toHaveLength(1); +}); + +test('finish secret current version exists but is not this one', async () => { + mockSecretsManager({ versions: { [CLIENT_REQUEST_TOKEN]: ['AWSPENDING'], randomVersionId: ['AWSCURRENT'] } }); + + await handler({ Step: 'finishSecret', SecretId: SECRET_ID, ClientRequestToken: CLIENT_REQUEST_TOKEN }); + + // describeSecret, updateSecretVersionStage + expect(mockSecretsManagerClient.calls()).toHaveLength(2); +}); + +test('finish secret no current version', async () => { + mockSecretsManager(); + + await handler({ Step: 'finishSecret', SecretId: SECRET_ID, ClientRequestToken: CLIENT_REQUEST_TOKEN }); + + // describeSecret, updateSecretVersionStage + expect(mockSecretsManagerClient.calls()).toHaveLength(2); +}); + +const mockSecretsManager = (config: SecretsManagerMockingConfig = {}): void => { + const pendingSecretError = config.pendingSecretError ?? false; + const versions = config.versions ?? { [CLIENT_REQUEST_TOKEN]: ['AWSPENDING'] }; + mockSecretsManagerClient + .on(DescribeSecretCommand, { SecretId: SECRET_ID }) + .resolvesOnce({ RotationEnabled: true, VersionIdsToStages: versions }) + .on(GetSecretValueCommand, { SecretId: SECRET_ID }) + .callsFake(async (input: { SecretId: string; VersionStage: SecretRotationStage }) => { + const stage = input.VersionStage; + if (stage === 'AWSPENDING' && pendingSecretError) { + throw new Error('pending secret error'); + } + return { SecretString: getSecretString(input) }; + }) + .on(GetRandomPasswordCommand, { + PasswordLength: parseInt(PASSWORD_LENGTH), + ExcludeCharacters: PASSWORD_EXCLUDE_CHARS, + }) + .resolvesOnce({ RandomPassword: 'password123' }) + .on(PutSecretValueCommand, { SecretId: SECRET_ID }) + .resolvesOnce({}) + .on(UpdateSecretVersionStageCommand, { SecretId: SECRET_ID, VersionStage: 'AWSCURRENT' }) + .resolvesOnce({}); +}; + +const mockDatabaseConnections = ( + configs: Partial>, +): void => { + getDatabaseConnectionSpy.mockImplementation(async (secret: RedshiftSecret) => { + const stage = secret.password.replace('password-', '') as unknown as SecretRotationStage; + switch (stage) { + case 'AWSPENDING': + return mockConnection(configs.AWSPENDING); + case 'AWSCURRENT': + return mockConnection(configs.AWSCURRENT); + case 'AWSPREVIOUS': + return mockConnection(configs.AWSPREVIOUS); + } + }); +}; + +const mockConnection = (config?: DatabaseConnectionMockingConfig): Database | null => { + const connection = config?.connection ?? false; + if (!connection) { + return null; + } + return { + raw: async (query: string) => { + const error = config?.rawError; + if (error !== undefined) { + await Promise.reject(error); + } + await Promise.resolve(); + }, + destroy: async () => { + await Promise.resolve(); + }, + }; +}; + +const getSecretString = (input: { SecretId: string; VersionStage: SecretRotationStage }): string => { + return JSON.stringify({ + engine: 'redshift', + host: 'host', + username: 'admin', + password: `password-${input.VersionStage}`, + dbname: 'dbname', + port: '5439', + }); +}; diff --git a/src/handlers/redshift-rotate-secret/handler.ts b/src/handlers/redshift-rotate-secret/handler.ts new file mode 100644 index 000000000..9f2b5b192 --- /dev/null +++ b/src/handlers/redshift-rotate-secret/handler.ts @@ -0,0 +1,262 @@ +import { ensureDefined, getEnvironmentVariable, getErrorMessage } from '../../shared/utils/utils'; +import { getLogger } from '../../shared/powertools'; +import { secretsManagerClient } from '../../shared/clients'; +import type { DescribeSecretCommandOutput } from '@aws-sdk/client-secrets-manager'; +import { + DescribeSecretCommand, + GetRandomPasswordCommand, + GetSecretValueCommand, + PutSecretValueCommand, + UpdateSecretVersionStageCommand, +} from '@aws-sdk/client-secrets-manager'; +import * as crypto from 'node:crypto'; +import { DatabaseAccess } from './database-access'; + +const logger = getLogger('lambda/redshift-rotate-secret'); + +export type RotateSecretStep = 'createSecret' | 'setSecret' | 'testSecret' | 'finishSecret'; + +interface RotateSecretEvent { + Step: RotateSecretStep; + SecretId: string; + ClientRequestToken: string; +} + +// based on https://docs.aws.amazon.com/secretsmanager/latest/userguide/reference_secret_json_structure.html#reference_secret_json_structure_RS +export interface RedshiftSecret { + engine: 'redshift'; + host: string; + username: string; + password: string; + dbname: string; + port: string; +} + +export type SecretRotationStage = 'AWSPREVIOUS' | 'AWSCURRENT' | 'AWSPENDING'; + +export const databaseAccess = new DatabaseAccess(logger); + +/** + * Most of the code in this function is based on the AWS sample here
+ * {@link https://github.com/aws-samples/aws-secrets-manager-rotation-lambdas/blob/master/SecretsManagerRedshiftRotationSingleUser/lambda_function.py} + * + * and a JavaScript version here (src/rotateSingleUser.js)
+ * {@link https://www.npmjs.com/package/aws-secrets-manager-rotation-lambdas?activeTab=code} + */ +export const handler = async (event: RotateSecretEvent): Promise => { + try { + logger.info('Rotate secret lambda', { event }); + await rotateSecret(event); + } catch (error) { + logger.error(`Error rotating secret ${event.SecretId}`, { error }); + throw error; + } +}; + +const rotateSecret = async (event: RotateSecretEvent): Promise => { + const metadata = await describeSecret(event); + if (metadata.RotationEnabled === false) { + logAndThrow('Secret is not enabled for rotation'); + } + + const versions = metadata.VersionIdsToStages ?? {}; + const version = versions[event.ClientRequestToken]; + if (version === undefined || version.length === 0) { + logAndThrow(`Secret version ${event.ClientRequestToken} has no stage for rotation`); + } + + if (version.includes('AWSCURRENT')) { + logger.info(`Secret version ${event.ClientRequestToken} already set as AWSCURRENT`); + return; + } else if (!version.includes('AWSPENDING')) { + logAndThrow(`Secret version ${event.ClientRequestToken} not set as AWSPENDING`); + } + + if (event.Step === 'createSecret') { + await createSecret(event); + } else if (event.Step === 'setSecret') { + await setSecret(event); + } else if (event.Step === 'testSecret') { + await testSecret(event); + } else if (event.Step === 'finishSecret') { + await finishSecret(event, versions); + } else { + logAndThrow(`Invalid step parameter ${JSON.stringify(event.Step)}`); + } +}; + +// generate a new secret +const createSecret = async (event: RotateSecretEvent): Promise => { + try { + await getSecret(event, 'AWSPENDING'); + logger.info('createSecret: Successfully retrieved secret'); + } catch (error) { + await updateSecretPassword(event); + logger.info(`createSecret: Successfully put secret version ${event.ClientRequestToken}`); + } +}; + +// set the pending secret in the database +const setSecret = async (event: RotateSecretEvent): Promise => { + const pendingSecret = await getSecret(event, 'AWSPENDING'); + let loginSecret = pendingSecret; + logger.info('setSecret: Trying connection with AWSPENDING secret'); + let connection = await databaseAccess.getDatabaseConnection(loginSecret); + if (connection !== null) { + logger.info('setSecret: AWSPENDING secret is already set as password in Redshift DB'); + return; + } + + loginSecret = await getSecret(event, 'AWSCURRENT'); + logger.info('setSecret: Trying connection with AWSCURRENT secret'); + connection = await databaseAccess.getDatabaseConnection(loginSecret); + + if (connection === null) { + loginSecret = await getSecret(event, 'AWSPREVIOUS'); + logger.info('setSecret: Trying connection with AWSPREVIOUS secret'); + connection = await databaseAccess.getDatabaseConnection(loginSecret); + + if (connection === null) { + logAndThrow('setSecret: Unable to log into database with previous, current, or pending secret'); + return; // typescript needs this to believe connection won't be null below, although the previous line throws + } + } + + // if we get a connection, set the admin password to the pending secret password + try { + await connection.raw(`alter user ${loginSecret.username} with password '${hashedPasswordUsername(pendingSecret)}'`); + logger.info(`setSecret: Successfully set password for user ${loginSecret.username} in Redshift DB`); + } catch (error) { + logAndThrow(`setSecret: Error changing database password - ${getErrorMessage(error)}`); + } finally { + await connection.destroy(); + } +}; + +// test the pending secret against the database +const testSecret = async (event: RotateSecretEvent): Promise => { + const secret = await getSecret(event, 'AWSPENDING'); + const connection = await databaseAccess.getDatabaseConnection(secret); + if (connection === null) { + logAndThrow('testSecret: Unable to log into database with pending secret'); + } else { + logger.info('testSecret: Successfully signed into Redshift DB with AWSPENDING secret'); + await connection.destroy(); + } +}; + +// finish the rotation by marking the pending secret as current +const finishSecret = async (event: RotateSecretEvent, versions: Record): Promise => { + let currentVersion: string | undefined; + for (const [versionId, stages] of Object.entries(versions)) { + if (stages.includes('AWSCURRENT')) { + if (versionId === event.ClientRequestToken) { + logger.info(`finishSecret: Version ${versionId} already marked as AWSCURRENT`); + return; + } + currentVersion = versionId; + break; + } + } + + await updateSecretVersionStage(event, currentVersion); + logger.info(`finishSecret: Successfully set AWSCURRENT stage to version ${event.ClientRequestToken} for secret`); +}; + +const getSecret = async (event: RotateSecretEvent, stage: SecretRotationStage): Promise => { + try { + const secretString = await secretsManagerClient + .send( + new GetSecretValueCommand({ + SecretId: event.SecretId, + VersionStage: stage, + ...(stage === 'AWSPENDING' ? { VersionId: event.ClientRequestToken } : {}), + }), + ) + .then(response => ensureDefined(() => response.SecretString)); + return JSON.parse(secretString); + } catch (error) { + throw new Error(`Error getting secret - ${getErrorMessage(error)}`); + } +}; + +const describeSecret = async (event: RotateSecretEvent): Promise => { + try { + return await secretsManagerClient.send( + new DescribeSecretCommand({ + SecretId: event.SecretId, + }), + ); + } catch (error) { + throw new Error(`Error getting secret metadata - ${getErrorMessage(error)}`); + } +}; + +const getRandomPassword = async (): Promise => { + try { + const passwordLength = parseInt(getEnvironmentVariable('PASSWORD_LENGTH')); + const excludeChars = getEnvironmentVariable('PASSWORD_EXCLUDE_CHARS'); + return await secretsManagerClient + .send( + new GetRandomPasswordCommand({ + PasswordLength: passwordLength, + ExcludeCharacters: excludeChars, + }), + ) + .then(response => ensureDefined(() => response.RandomPassword)); + } catch (error) { + throw new Error(`Error getting random password - ${getErrorMessage(error)}`); + } +}; + +const updateSecretPassword = async (event: RotateSecretEvent): Promise => { + try { + const currentSecret = await getSecret(event, 'AWSCURRENT'); + const newPassword = await getRandomPassword(); + await secretsManagerClient.send( + new PutSecretValueCommand({ + SecretId: event.SecretId, + ClientRequestToken: event.ClientRequestToken, + SecretString: JSON.stringify({ + ...currentSecret, + password: newPassword, + }), + VersionStages: ['AWSPENDING'], + }), + ); + } catch (error) { + throw new Error(`Error putting secret value - ${getErrorMessage(error)}`); + } +}; + +const updateSecretVersionStage = async ( + event: RotateSecretEvent, + currentVersion: string | undefined, +): Promise => { + try { + await secretsManagerClient.send( + new UpdateSecretVersionStageCommand({ + SecretId: event.SecretId, + VersionStage: 'AWSCURRENT', + MoveToVersionId: event.ClientRequestToken, + RemoveFromVersionId: currentVersion, + }), + ); + } catch (error) { + throw new Error(`Error updating secret version stage - ${getErrorMessage(error)}`); + } +}; + +const logAndThrow = (message: string): never => { + logger.error(message); + throw new Error(message); +}; + +// redshift can accept the password as an MD5 hash of the password concatenated with the username +// the md5 prefix is needed so it knows to interpret the string as this rather than a normal string password +// this method was convenient to use to avoid any issues with quoting or escaping special characters +// see docs here https://docs.aws.amazon.com/redshift/latest/dg/r_CREATE_USER.html (the PASSWORD section) +const hashedPasswordUsername = (secret: RedshiftSecret): string => { + const passwordUsername = `${secret.password}${secret.username}`; + return `md5${crypto.createHash('md5').update(passwordUsername).digest('hex')}`; +}; diff --git a/src/shared/clients.ts b/src/shared/clients.ts index 6b42b9b2f..be284b017 100644 --- a/src/shared/clients.ts +++ b/src/shared/clients.ts @@ -10,6 +10,7 @@ import { SFNClient } from '@aws-sdk/client-sfn'; import { RedshiftDataClient } from '@aws-sdk/client-redshift-data'; import { QuickSightClient } from '@aws-sdk/client-quicksight'; import { CognitoIdentityProviderClient } from '@aws-sdk/client-cognito-identity-provider'; +import { SecretsManagerClient } from '@aws-sdk/client-secrets-manager'; export const athenaClient = new AthenaClient(AWS_CLIENT_BASE_CONFIG); @@ -30,6 +31,8 @@ export const redshiftClient = new RedshiftDataClient(AWS_CLIENT_BASE_CONFIG); export const s3Client = new S3Client(AWS_CLIENT_BASE_CONFIG); +export const secretsManagerClient = new SecretsManagerClient(AWS_CLIENT_BASE_CONFIG); + export const sqsClient = new SQSClient(AWS_CLIENT_BASE_CONFIG); export const sfnClient = new SFNClient(AWS_CLIENT_BASE_CONFIG); diff --git a/src/shared/utils/utils.spec.ts b/src/shared/utils/utils.spec.ts index c81adef00..4eaba4ddd 100644 --- a/src/shared/utils/utils.spec.ts +++ b/src/shared/utils/utils.spec.ts @@ -2,6 +2,7 @@ import { arrayPartition, decodeObject, encodeObject, + ensureDefined, getAccountId, getAWSEnvironment, getEnvironmentVariable, @@ -171,6 +172,12 @@ test('array partition', () => { expect(arrayPartition([1, 2, 3, 4, 5, 6], 5)).toEqual(expect.arrayContaining([[1, 2, 3, 4, 5], [6]])); }); +test('ensure defined', () => { + const response = { one: 'one', two: undefined }; + expect(ensureDefined(() => response.one)).toEqual('one'); + expect(() => ensureDefined(() => response.two)).toThrow('two is undefined'); +}); + const mockS3Response = (body: unknown): GetObjectCommandOutput => { return { Body: mockS3BodyStream({ stringValue: body }), diff --git a/src/shared/utils/utils.ts b/src/shared/utils/utils.ts index b142f3d3a..abd1c6fc1 100644 --- a/src/shared/utils/utils.ts +++ b/src/shared/utils/utils.ts @@ -122,6 +122,15 @@ export const arrayPartition = (array: T[], partitionSize: number): T[][] => { .filter(chunk => chunk.length > 0); }; +export const ensureDefined = (supplier: () => string | undefined): string => { + const value = supplier(); + if (value === undefined) { + const key = supplier.toString().replace('() => ', ''); + throw new Error(`${key} is undefined`); + } + return value; +}; + // see https://stackoverflow.com/a/65666402 const throwExpression = (message: string): never => { throw new Error(message);