diff --git a/docs/features.md b/docs/features.md index 752b99f..7183465 100644 --- a/docs/features.md +++ b/docs/features.md @@ -13,4 +13,8 @@ ## DynamoDB Table -- [[DynamoDB.2] DynamoDB tables should have point-in-time recovery enabled](https://docs.aws.amazon.com/securityhub/latest/userguide/dynamodb-controls.html#dynamodb-2) \ No newline at end of file +- [[DynamoDB.2] DynamoDB tables should have point-in-time recovery enabled](https://docs.aws.amazon.com/securityhub/latest/userguide/dynamodb-controls.html#dynamodb-2) + +## IAM + +- Managed policy for accessing the S3 bucket and keys as well as the DynamoDB table. diff --git a/src/terraformStateBackend.ts b/src/terraformStateBackend.ts index 63a901e..e46d4c8 100644 --- a/src/terraformStateBackend.ts +++ b/src/terraformStateBackend.ts @@ -1,4 +1,11 @@ -import { aws_dynamodb as dynamodb, aws_s3 as s3, Duration, RemovalPolicy } from 'aws-cdk-lib'; +import { + aws_dynamodb as dynamodb, + aws_s3 as s3, + Duration, + RemovalPolicy, + CfnOutput, +} from 'aws-cdk-lib'; +import * as iam from 'aws-cdk-lib/aws-iam'; import { Construct } from 'constructs'; import { TerraformStateBackendProperties } from './terraformStateBackendProperties'; @@ -53,5 +60,60 @@ export class TerraformStateBackend extends Construct { pointInTimeRecovery: true, removalPolicy: RemovalPolicy.DESTROY, }); + + this.createIamPolicies(); + + new CfnOutput(this, 'output-table', + { + description: 'ARN of the DynamoDB table', + exportName: 'tableArn', + value: this.table.tableArn, + }); + + new CfnOutput(this, 'output-bucket', + { + description: 'ARN of the S3 bucket', + exportName: 'bucketArn', + value: this.bucket.bucketArn, + }); + + } + + private createIamPolicies() { + // Policy Statement for reading/ writing DyanmoDB table + const ddbStatement = new iam.PolicyStatement({ + sid: 'DynamoDBTable', + effect: iam.Effect.ALLOW, + actions: [ + 'dynamodb:DescribeTable', + 'dynamodb:GetItem', + 'dynamodb:PutItem', + 'dynamodb:DeleteItem', + ], + resources: [this.table.tableArn], + }); + // Policy Statement for reading/ writing to the S3 bucket and objects + const s3Statement = new iam.PolicyStatement({ + sid: 'S3Bucket', + effect: iam.Effect.ALLOW, + actions: [ + 's3:ListBucket', + 's3:GetObject', + 's3:PutObject', + 's3:DeleteObject', + ], + resources: [this.bucket.bucketArn, this.bucket.bucketArn + '/*'], + }); + + new iam.PolicyDocument({ + statements: [ddbStatement, s3Statement], + }); + new iam.ManagedPolicy(this, 'managed-policy', { + description: 'Managed policy for Terraform state backend', + statements: [ddbStatement, s3Statement], + managedPolicyName: 'TerraformStateBackendPolicy', + }); + + } } diff --git a/test/terraformStateBackend.test.ts b/test/terraformStateBackend.test.ts index 2430b8c..8d36723 100644 --- a/test/terraformStateBackend.test.ts +++ b/test/terraformStateBackend.test.ts @@ -1,7 +1,11 @@ import * as cdk from 'aws-cdk-lib'; import { Aspects, assertions } from 'aws-cdk-lib'; import { Annotations, Match } from 'aws-cdk-lib/assertions'; -import { AwsSolutionsChecks, HIPAASecurityChecks, NagSuppressions } from 'cdk-nag'; +import { + AwsSolutionsChecks, + HIPAASecurityChecks, + NagSuppressions, +} from 'cdk-nag'; import { TerraformStateBackend } from '../src'; describe('Ensure passing AWSSolutionChecks', () => { @@ -11,12 +15,22 @@ describe('Ensure passing AWSSolutionChecks', () => { app = new cdk.App(); stack = new cdk.Stack(app, 'stack', {}); NagSuppressions.addStackSuppressions(stack, [ - { id: 'AwsSolutions-S1', reason: 'Access Logs for Terraform Bucket not implemented.' }, + { + id: 'AwsSolutions-S1', + reason: 'Access Logs for Terraform Bucket not implemented.', + }, + { + id: 'AwsSolutions-IAM5', + reason: + 'Wilcard permission in place for accessing objects in an S3 bucket.', + }, ]); - Aspects.of(app).add(new AwsSolutionsChecks({ - verbose: true, - })); + Aspects.of(app).add( + new AwsSolutionsChecks({ + verbose: true, + }), + ); new TerraformStateBackend(stack, 'backend', { bucketName: 'tf-state-bucket', @@ -48,16 +62,31 @@ describe('Ensure passing HIPAASecurityChecks', () => { app = new cdk.App(); stack = new cdk.Stack(app, 'stack', {}); NagSuppressions.addStackSuppressions(stack, [ - { id: 'HIPAA.Security-S3BucketLoggingEnabled', reason: 'Access Logs for Terraform Bucket not implemented.' }, - { id: 'HIPAA.Security-S3BucketReplicationEnabled', reason: 'Cross-region replication for Terraform Bucket not implemented.' }, - { id: 'HIPAA.Security-DynamoDBInBackupPlan', reason: 'Backup plan for DynamoDB table not implemented.' }, - { id: 'HIPAA.Security-S3DefaultEncryptionKMS', reason: 'KMS usage currently not implemented.' }, + { + id: 'HIPAA.Security-S3BucketLoggingEnabled', + reason: 'Access Logs for Terraform Bucket not implemented.', + }, + { + id: 'HIPAA.Security-S3BucketReplicationEnabled', + reason: + 'Cross-region replication for Terraform Bucket not implemented.', + }, + { + id: 'HIPAA.Security-DynamoDBInBackupPlan', + reason: 'Backup plan for DynamoDB table not implemented.', + }, + { + id: 'HIPAA.Security-S3DefaultEncryptionKMS', + reason: 'KMS usage currently not implemented.', + }, ]); - Aspects.of(app).add(new HIPAASecurityChecks({ - reports: true, - verbose: true, - })); + Aspects.of(app).add( + new HIPAASecurityChecks({ + reports: true, + verbose: true, + }), + ); new TerraformStateBackend(stack, 'backend', { bucketName: 'tf-state-bucket', @@ -128,51 +157,44 @@ describe('Bucket Configuration', () => { const template = assertions.Template.fromStack(stack); template.resourceCountIs('AWS::S3::BucketPolicy', 1); - const logicalId = stack.getLogicalId(backend.bucket.node.defaultChild as cdk.CfnResource); + const logicalId = stack.getLogicalId( + backend.bucket.node.defaultChild as cdk.CfnResource, + ); - template.hasResourceProperties( - 'AWS::S3::BucketPolicy', - { - PolicyDocument: { - Statement: [ - { - Action: 's3:*', - Condition: { - Bool: { - 'aws:SecureTransport': 'false', - }, + template.hasResourceProperties('AWS::S3::BucketPolicy', { + PolicyDocument: { + Statement: [ + { + Action: 's3:*', + Condition: { + Bool: { + 'aws:SecureTransport': 'false', }, - Effect: 'Deny', - Principal: { - AWS: '*', + }, + Effect: 'Deny', + Principal: { + AWS: '*', + }, + Resource: [ + { + 'Fn::GetAtt': [logicalId, 'Arn'], }, - Resource: [ - { - 'Fn::GetAtt': [ - logicalId, - 'Arn', - ], - }, - { - 'Fn::Join': [ - '', - [ - { - 'Fn::GetAtt': [ - logicalId, - 'Arn', - ], - }, - '/*', - ], + { + 'Fn::Join': [ + '', + [ + { + 'Fn::GetAtt': [logicalId, 'Arn'], + }, + '/*', ], - }, - ], - }, - ], - }, + ], + }, + ], + }, + ], }, - ); + }); }); test('[S3.14] S3 buckets should use versioning', () => { @@ -234,3 +256,77 @@ describe('DynamoDB Configuration', () => { ); }); }); + +describe('IAM Configuration', () => { + let stack: cdk.Stack; + let app: cdk.App; + let backend: TerraformStateBackend; + beforeEach(() => { + app = new cdk.App(); + stack = new cdk.Stack(app, 'stack', {}); + + backend = new TerraformStateBackend(stack, 'backend', { + bucketName: 'tf-state-bucket', + tableName: 'tf-state-lock', + }); + }); + + test('Managed policy for accessing S3 Bucket and DynamoDB', () => { + + const logicalIdTable = stack.getLogicalId( + backend.table.node.defaultChild as cdk.CfnResource, + ); + + const logicalIdBucket = stack.getLogicalId( + backend.bucket.node.defaultChild as cdk.CfnResource, + ); + assertions.Template.fromStack(stack).hasResourceProperties( + 'AWS::IAM::ManagedPolicy', + { + ManagedPolicyName: 'TerraformStateBackendPolicy', + PolicyDocument: { + Version: '2012-10-17', + Statement: [ + { + Effect: 'Allow', + Sid: 'DynamoDBTable', + Action: [ + 'dynamodb:DescribeTable', + 'dynamodb:GetItem', + 'dynamodb:PutItem', + 'dynamodb:DeleteItem', + ], + Resource: { 'Fn::GetAtt': [logicalIdTable, 'Arn'] }, + }, + { + Sid: 'S3Bucket', + Effect: 'Allow', + Action: [ + 's3:ListBucket', + 's3:GetObject', + 's3:PutObject', + 's3:DeleteObject', + ], + Resource: [ + { + 'Fn::GetAtt': [logicalIdBucket, 'Arn'], + }, + { + 'Fn::Join': [ + '', + [ + { + 'Fn::GetAtt': [logicalIdBucket, 'Arn'], + }, + '/*', + ], + ], + }, + ], + }, + ], + }, + }, + ); + }); +});