diff --git a/Makefile b/Makefile index 5bdae36b..281a31a7 100644 --- a/Makefile +++ b/Makefile @@ -34,7 +34,7 @@ dev: $(GRAPHQL_DEV) terraform-format terraform/environment/aws-dev/.apply terraf @true terraform/environment/aws-dev/.apply: terraform/environment/aws-dev/*.tf terraform/module/iac-roles/*.tf - ./terraform/environment/aws-dev/deploy.sh $(ACCOUNT_ID) dev + AUTO_APPROVE=yes ./terraform/environment/aws-dev/deploy.sh $(ACCOUNT_ID) dev touch $@ terraform/environment/wildsea-dev/plan.tfplan: terraform/environment/wildsea-dev/*.tf terraform/module/wildsea/*.tf terraform/environment/wildsea-dev/.terraform $(GRAPHQL_JS) @@ -42,11 +42,11 @@ terraform/environment/wildsea-dev/plan.tfplan: terraform/environment/wildsea-dev terraform plan -out=./plan.tfplan terraform/environment/wildsea-dev/.apply: terraform/environment/wildsea-dev/plan.tfplan $(GRAPHQL_JS) - cd terraform/environment/wildsea-dev ; ../../../scripts/run-as.sh $(RW_ROLE) \ - terraform apply ./plan.tfplan ; \ - status=$$? ; \ - rm -f $< ; \ - [ "$$status" -eq 0 ] + cd terraform/environment/wildsea-dev ; \ + ../../../scripts/run-as.sh $(RW_ROLE) \ + terraform apply ./plan.tfplan || status=$$? ; \ + rm -v ./plan.tfplan ; \ + [ -z "$$status" ] || exit $$status touch $@ terraform/environment/wildsea-dev/.terraform: terraform/environment/wildsea-dev/*.tf terraform/module/wildsea/*.tf diff --git a/graphql/graphql.mk b/graphql/graphql.mk index 82315560..502abf33 100644 --- a/graphql/graphql.mk +++ b/graphql/graphql.mk @@ -24,17 +24,16 @@ graphql: $(GRAPHQL_JS) graphql-test .PHONY: graphql-test graphql-test: graphql/node_modules if [ -z "$(IN_PIPELINE)" ] ; then \ - docker run --rm -it --user $$(id -u):$$(id -g) -v $(PWD)/graphql:/app -w /app --entrypoint ./node_modules/jest/bin/jest.js node:20 \ + docker run --rm -it --user $$(id -u):$$(id -g) -v $(PWD)/graphql:/app -w /app --entrypoint ./node_modules/jest/bin/jest.js node:20 ; \ else \ - cd graphql && jest ; \ + cd graphql && ./node_modules/jest/bin/jest.js ; \ fi # Won't auto-fix in pipeline .PHONY: graphql-eslint graphql-eslint: $(GRAPHQL_TS) if [ -z "$(IN_PIPELINE)" ] ; then \ - docker run --rm -it --user $$(id -u):$$(id -g) -v $(PWD)/graphql:/code pipelinecomponents/eslint eslint --fix + docker run --rm -it --user $$(id -u):$$(id -g) -v $(PWD)/graphql:/code pipelinecomponents/eslint eslint --fix ; \ else \ - cd graphql && eslint ;:w - \ + cd graphql && eslint ; \ fi diff --git a/terraform/environment/aws/deploy.sh b/terraform/environment/aws/deploy.sh index 7c54ffeb..f97dca13 100755 --- a/terraform/environment/aws/deploy.sh +++ b/terraform/environment/aws/deploy.sh @@ -42,6 +42,6 @@ terraform init \ -backend-config="key=${ENVIRONMENT}/aws.tfstate" \ -backend-config="region=${AWS_REGION}" -terraform apply \ +terraform apply ${AUTO_APPROVE:+-auto-approve} \ -var environment="${ENVIRONMENT}" \ -var state_bucket="${STATE_BUCKET}" diff --git a/terraform/module/iac-roles/policy.tf b/terraform/module/iac-roles/policy.tf index ca5d900b..adb0d672 100644 --- a/terraform/module/iac-roles/policy.tf +++ b/terraform/module/iac-roles/policy.tf @@ -5,17 +5,20 @@ data "aws_iam_policy_document" "ro" { "s3:GetObject" ] resources = [ - "${var.state_bucket_arn}/${var.environment}/terraform.tfstate" + "${var.state_bucket_arn}/${var.environment}/terraform.tfstate", ] } statement { sid = "ListState" actions = [ - "s3:ListBucket" + "s3:ListBucket", + "s3:GetBucket*", + "s3:Get*Configuration", ] resources = [ - var.state_bucket_arn + var.state_bucket_arn, + "arn:${data.aws_partition.current.id}:s3:::${lower(var.app_name)}-${var.environment}-ui", ] } @@ -43,12 +46,17 @@ data "aws_iam_policy_document" "ro" { } statement { - sid = "CognitoIdpGlobal" + sid = "Global" actions = [ "cognito-idp:DescribeUserPoolDomain", "wafv2:GetWebACLForResource", "wafv2:GetWebAcl", "appsync:GetResolver", + "cloudfront:List*", + "cloudfront:Get*Policy", + "cloudfront:GetDistribution", + "cloudfront:GetOriginAccessControl", + "iam:SimulatePrincipalPolicy", ] resources = [ "*" @@ -157,9 +165,11 @@ data "aws_iam_policy_document" "rw" { "dynamodb:TagResource", "dynamodb:UntagResource", "dynamodb:Update*", + "s3:Put*Configuration", ] resources = [ - "arn:${data.aws_partition.current.id}:dynamodb:${data.aws_region.current.id}:${data.aws_caller_identity.current.account_id}:table/${var.app_name}-${var.environment}" + "arn:${data.aws_partition.current.id}:dynamodb:${data.aws_region.current.id}:${data.aws_caller_identity.current.account_id}:table/${var.app_name}-${var.environment}", + "arn:${data.aws_partition.current.id}:s3:::${lower(local.prefix)}-*", ] } @@ -188,7 +198,7 @@ data "aws_iam_policy_document" "rw" { } statement { - sid = "CognitoIdentityGlobal" + sid = "Global" actions = [ "cognito-identity:CreateIdentityPool", "cognito-identity:SetIdentityPoolRoles", @@ -198,6 +208,13 @@ data "aws_iam_policy_document" "rw" { "appsync:DeleteResolver", "appsync:UpdateResolver", "appsync:SetWebACL", + "s3:CreateBucket", + "cloudfront:CreateOriginAccessControl", + "cloudfront:DeleteOriginAccessControl", + "cloudfront:CreateDistribution*", + "cloudfront:UpdateDistribution", + "cloudfront:DeleteDistribution", + "cloudfront:TagResource", ] resources = [ "*" @@ -254,7 +271,7 @@ data "aws_iam_policy_document" "rw" { "appsync:UntagResource", ] resources = [ - "arn:${data.aws_partition.current.id}:appsync:${data.aws_region.current.id}:${data.aws_caller_identity.current.account_id}:*" + "arn:${data.aws_partition.current.id}:appsync:${data.aws_region.current.id}:${data.aws_caller_identity.current.account_id}:*", ] condition { test = "StringEquals" @@ -270,9 +287,12 @@ data "aws_iam_policy_document" "rw" { "logs:TagResource", "logs:UntagResource", "logs:PutRetentionPolicy", + "s3:DeleteBucket", + "s3:PutBucket*", ] resources = [ - "arn:${data.aws_partition.current.id}:logs:${data.aws_region.current.id}:${data.aws_caller_identity.current.account_id}:log-group:*" + "arn:${data.aws_partition.current.id}:logs:${data.aws_region.current.id}:${data.aws_caller_identity.current.account_id}:log-group:*", + "arn:${data.aws_partition.current.id}:s3:::${lower(local.prefix)}-*", ] } @@ -335,18 +355,21 @@ data "aws_iam_policy_document" "rw_boundary" { ] resources = [ "${var.state_bucket_arn}/${var.environment}/terraform.tfstate", - "arn:${data.aws_partition.current.id}:s3:::${lower(var.app_name)}-${var.environment}-*/*" + "arn:${data.aws_partition.current.id}:s3:::${lower(var.app_name)}-${var.environment}-*/*", ] } statement { sid = "ListState" actions = [ - "s3:ListBucket" + "s3:ListBucket", + "s3:GetBucket*", + "s3:Get*Configuration", + "s3:Put*Configuration", ] resources = [ "${var.state_bucket_arn}/${var.environment}/terraform.tfstate", - "arn:${data.aws_partition.current.id}:s3:::${lower(var.app_name)}-${var.environment}-*/*" + "arn:${data.aws_partition.current.id}:s3:::${lower(var.app_name)}-${var.environment}-*", ] } @@ -397,6 +420,18 @@ data "aws_iam_policy_document" "rw_boundary" { "appsync:DeleteResolver", "appsync:UpdateResolver", "appsync:GetResolver", + "s3:CreateBucket", + "cloudfront:List*", + "cloudfront:Get*Policy", + "cloudfront:CreateOriginAccessControl", + "cloudfront:GetOriginAccessControl", + "cloudfront:DeleteOriginAccessControl", + "cloudfront:CreateDistribution*", + "cloudfront:UpdateDistribution", + "cloudfront:DeleteDistribution", + "cloudfront:TagResource", + "cloudfront:GetDistribution", + "iam:SimulatePrincipalPolicy", ] resources = [ "*" @@ -487,7 +522,7 @@ data "aws_iam_policy_document" "rw_boundary" { "appsync:GetGraphqlApi", ] resources = [ - "arn:${data.aws_partition.current.id}:appsync:${data.aws_region.current.id}:${data.aws_caller_identity.current.account_id}:*" + "arn:${data.aws_partition.current.id}:appsync:${data.aws_region.current.id}:${data.aws_caller_identity.current.account_id}:*", ] condition { test = "StringEquals" @@ -505,9 +540,12 @@ data "aws_iam_policy_document" "rw_boundary" { "logs:PutRetentionPolicy", "logs:DescribeLogGroups", "logs:ListTagsForResource", + "s3:DeleteBucket", + "s3:PutBucket*", ] resources = [ - "arn:${data.aws_partition.current.id}:logs:${data.aws_region.current.id}:${data.aws_caller_identity.current.account_id}:log-group:*" + "arn:${data.aws_partition.current.id}:logs:${data.aws_region.current.id}:${data.aws_caller_identity.current.account_id}:log-group:*", + "arn:${data.aws_partition.current.id}:s3:::${lower(local.prefix)}-*", ] } diff --git a/terraform/module/iac-roles/sim.tf b/terraform/module/iac-roles/sim.tf new file mode 100644 index 00000000..19920dbf --- /dev/null +++ b/terraform/module/iac-roles/sim.tf @@ -0,0 +1,20 @@ +# Doesn't work yet - TODO +# data "aws_iam_principal_policy_simulation" "state_read" { +# action_names = [ +# "s3:GetObject" +# ] +# policy_source_arn = aws_iam_role.ro.arn +# resource_arns = [ +# "${var.state_bucket_arn}/${var.environment}/terraform.tfstate", +# ] +# #resource_policy_json = data.aws_iam_policy_document.ro.json +# +# depends_on = [aws_iam_policy.ro] +# +# lifecycle { +# postcondition { +# condition = self.all_allowed +# error_message = "state_read check failed: ${jsonencode(self.results)}" +# } +# } +# } diff --git a/terraform/module/wildsea/ui-bucket.tf b/terraform/module/wildsea/ui-bucket.tf new file mode 100644 index 00000000..d9781e56 --- /dev/null +++ b/terraform/module/wildsea/ui-bucket.tf @@ -0,0 +1,148 @@ +locals { + ui = "${var.prefix}-ui" +} + +resource "aws_s3_bucket" "ui" { + # checkov:skip=CKV_AWS_18:Chosen not to enable access logging yet + # checkov:skip=CKV2_AWS_62:No need for S3 events + # checkov:skip=CKV_AWS_144:No need for cross-region replication + # checkov:skip=CKV_AWS_145:AWS ley is sufficient + bucket = lower(local.ui) + + tags = { + Name = local.ui + } +} + +resource "aws_s3_bucket_policy" "ui" { + bucket = aws_s3_bucket.ui.id + policy = data.aws_iam_policy_document.ui.json +} + +data "aws_iam_policy_document" "ui" { + statement { + sid = "CDNRead" + effect = "Allow" + actions = ["s3:GetObject"] + resources = ["${aws_s3_bucket.ui.arn}/*"] + principals { + type = "Service" + identifiers = ["cloudfront.${data.aws_partition.current.dns_suffix}"] + } + condition { + test = "StringEquals" + variable = "aws:SourceArn" + values = [aws_cloudfront_distribution.cdn.arn] + } + } + + statement { + sid = "CDNList" + effect = "Allow" + actions = ["s3:ListBucket"] + resources = [aws_s3_bucket.ui.arn] + principals { + type = "Service" + identifiers = ["cloudfront.${data.aws_partition.current.dns_suffix}"] + } + condition { + test = "StringEquals" + variable = "aws:SourceArn" + values = [aws_cloudfront_distribution.cdn.arn] + } + } + + # Block all HTTP Access + statement { + sid = "BlockHTTP" + effect = "Deny" + actions = ["s3:*"] + resources = [ + aws_s3_bucket.ui.arn, + "${aws_s3_bucket.ui.arn}/*", + ] + principals { + type = "AWS" + identifiers = ["*"] + } + condition { + test = "Bool" + variable = "aws:SecureTransport" + values = ["false"] + } + } +} + +resource "aws_s3_bucket_public_access_block" "ui" { + bucket = aws_s3_bucket.ui.id + + block_public_acls = true + block_public_policy = true + ignore_public_acls = true + restrict_public_buckets = true +} + +resource "aws_s3_bucket_ownership_controls" "ui" { + bucket = aws_s3_bucket.ui.id + + rule { + object_ownership = "BucketOwnerEnforced" + } +} + +resource "aws_s3_bucket_versioning" "ui" { + bucket = aws_s3_bucket.ui.id + + versioning_configuration { + status = "Enabled" + } +} + +resource "aws_s3_bucket_server_side_encryption_configuration" "ui" { + bucket = aws_s3_bucket.ui.id + + rule { + apply_server_side_encryption_by_default { + sse_algorithm = "AES256" + } + } +} + +# Use recommended CORS rules for bucket behind cloudfront +resource "aws_s3_bucket_cors_configuration" "ui" { + bucket = aws_s3_bucket.ui.id + + cors_rule { + allowed_headers = ["*"] + allowed_methods = ["GET", "HEAD"] + allowed_origins = ["https://${aws_cloudfront_distribution.cdn.domain_name}"] + expose_headers = ["ETag"] + max_age_seconds = 3000 + } +} + +# Lifecycle to: +# * Delete incomplete multipart uploads after 3 days +# * Limit old versions to maximum of 5 or and age of 30 days +# * Keep the current version forever +resource "aws_s3_bucket_lifecycle_configuration" "ui" { + bucket = aws_s3_bucket.ui.id + + rule { + id = "expire-versions" + status = "Enabled" + + abort_incomplete_multipart_upload { + days_after_initiation = 3 + } + + noncurrent_version_expiration { + newer_noncurrent_versions = 5 + noncurrent_days = 30 + } + + expiration { + expired_object_delete_marker = true + } + } +} diff --git a/terraform/module/wildsea/ui-cdn.tf b/terraform/module/wildsea/ui-cdn.tf new file mode 100644 index 00000000..eb09087e --- /dev/null +++ b/terraform/module/wildsea/ui-cdn.tf @@ -0,0 +1,67 @@ +locals { + cdn_name = "${var.prefix}-cdn" +} + +resource "aws_cloudfront_origin_access_control" "cdn" { + name = local.cdn_name + description = "CDN for UI" + origin_access_control_origin_type = "s3" + signing_behavior = "always" + signing_protocol = "sigv4" +} + +data "aws_cloudfront_cache_policy" "cache_policy" { + name = "Managed-CachingOptimized" +} + +data "aws_cloudfront_origin_request_policy" "request_policy" { + name = "Managed-CORS-S3Origin" +} + +data "aws_cloudfront_response_headers_policy" "headers_policy" { + name = "Managed-CORS-with-preflight-and-SecurityHeadersPolicy" +} + +resource "aws_cloudfront_distribution" "cdn" { + # checkov:skip=CKV2_AWS_42:Not set up custom domain yet + # checkov:skip=CKV_AWS_86:Chosen not to enable access logging yet + # checkov:skip=CKV2_AWS_47:Log4j is irrelvant for S3 origins + # checkov:skip=CKV_AWS_310:Not enabling origin failover for S3 origin + # checkov:skip=CKV_AWS_68:Not enabled WAF yet - $$$ + origin { + domain_name = aws_s3_bucket.ui.bucket_regional_domain_name + origin_id = aws_s3_bucket.ui.id + origin_access_control_id = aws_cloudfront_origin_access_control.cdn.id + } + + enabled = true + default_root_object = "index.html" + #aliases - TODO Use our own DNS name + + default_cache_behavior { + allowed_methods = ["GET", "HEAD", "OPTIONS"] + cached_methods = ["GET", "HEAD"] + target_origin_id = aws_s3_bucket.ui.id + cache_policy_id = data.aws_cloudfront_cache_policy.cache_policy.id + origin_request_policy_id = data.aws_cloudfront_origin_request_policy.request_policy.id + viewer_protocol_policy = "redirect-to-https" + response_headers_policy_id = data.aws_cloudfront_response_headers_policy.headers_policy.id + } + + restrictions { + geo_restriction { + restriction_type = "none" + } + } + + viewer_certificate { + cloudfront_default_certificate = true + #minimum_protocol_version = "TLSv1.2_2021" + } + + #TODO - logging + + tags = { + Name = local.cdn_name + } +}