From 7c2c8063396972660bcbd51e220e756c136b5966 Mon Sep 17 00:00:00 2001 From: Adam Moussa <166072409+amoussa1229@users.noreply.github.com> Date: Thu, 1 Oct 2026 21:29:22 -0400 Subject: [PATCH] refactor(iam): forget in-repo HCP exec roles (#120) * refactor(iam): forget in-repo HCP exec roles Org-baseline owns the apply and plan roles, so this workspace can assume them without a bootstrap window. Prod state forgets the old addresses without destroying the live roles. * ci(terraform): pin the isolation check to v1.0.21 The pre-release pin cloned the private .github repo with the caller token and the Terraform job failed. v1.0.21 loads the checker from the workflow commit. --- .github/workflows/ci.yaml | 31 +- SETUP.md | 29 +- terraform/apigateway.tf | 1 - terraform/hcp_iam.tf | 915 -------------------------------- terraform/lambda.tf | 2 +- terraform/lambda_boundary.tf | 147 ----- terraform/locals.tf | 11 +- terraform/outputs.tf | 10 - terraform/removed.tf | 75 +++ terraform/secrets.tf | 3 +- tests/infra/hcpContract.test.js | 33 +- 11 files changed, 124 insertions(+), 1133 deletions(-) delete mode 100644 terraform/hcp_iam.tf delete mode 100644 terraform/lambda_boundary.tf create mode 100644 terraform/removed.tf diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index f13558e..ce23746 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -31,29 +31,14 @@ jobs: terraform: name: Terraform - runs-on: ubuntu-latest - timeout-minutes: 15 - defaults: - run: - working-directory: terraform - steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - persist-credentials: false - - - uses: hashicorp/setup-terraform@dfe3c3f87815947d99a8997f908cb6525fc44e9e # v4.0.1 - with: - terraform_version: "1.16.0" - terraform_wrapper: false - - - name: Terraform fmt - run: terraform fmt -check -recursive - - - name: Terraform init - run: terraform init -backend=false - - - name: Terraform validate - run: terraform validate + uses: Sea-Haven-Industries/.github/.github/workflows/ci-terraform.yaml@47185fa602dffddb8297db5f3525d7c9bc05d7cd # v1.0.21 + with: + terraform-version: "1.16.0" + working-directory: terraform + app-paths: | + src/ + package.json + package-lock.json ci: name: ci / ci diff --git a/SETUP.md b/SETUP.md index 1f2a25e..00cbe9b 100644 --- a/SETUP.md +++ b/SETUP.md @@ -26,21 +26,24 @@ copies of the prod secrets. ## 2. HCP Terraform and GitHub Environments -Prod already applied. A new workspace (dev) uses one bootstrap window: +`hcptf-payments-dashboard`, `hcptf-payments-dashboard-plan`, and +`payments-dashboard-lambda-boundary` live in org-baseline stack +`seahaven-hcptf`, one pair per account. This repo does not create them. +Prod state already has the old copies. `terraform/removed.tf` forgets those +addresses and does not destroy them. 1. Tag the workspace `app:payments-dashboard`. Working directory `terraform`. VCS on `main`. Trigger prefix `terraform/**`. Speculative plans on. Set `environment`, `schedules_enabled=false`, and `boa_base_url`. No project-level variable set. -2. From `seahaven-org-baseline`: - `scripts/create-hcptf-bootstrap-roles.sh --account --allow-workspace payments-dashboard-` -3. Point that workspace's `TFC_AWS_APPLY_ROLE_ARN` / `TFC_AWS_PLAN_ROLE_ARN` at - `hcptf-bootstrap` / `hcptf-bootstrap-plan`. Set `TFC_AWS_PROVIDER_AUTH=true`. -4. One manual apply. This creates the scoped `hcptf-*` roles, the Lambda - boundary, VPC/NAT, and the rest of the stack. -5. Retarget `TFC_AWS_*` to `hcptf-payments-dashboard` / - `hcptf-payments-dashboard-plan`. Re-run the create script with no - `--allow-workspace`. +2. Point `TFC_AWS_APPLY_ROLE_ARN` / `TFC_AWS_PLAN_ROLE_ARN` at + `hcptf-payments-dashboard` / `hcptf-payments-dashboard-plan` in that + account. Set `TFC_AWS_PROVIDER_AUTH=true`. +3. Apply only after `seahaven-hcptf` has created those roles and + `arn:aws:iam:::policy/tf-managed/payments-dashboard-lambda-boundary`. + +Prod roles already exist. A dev apply waits until the same names exist in +`710827005802`. GitHub Environment `dev`: no reviewers. `DEPLOY_ROLE_ARN` is the dev `github_deploy_role_arn`. Environment `prod`: reviewers, branch policy `main` @@ -66,8 +69,10 @@ until cutover. Avoid weekday 9am ET and the 16:00/19:00/22:00 UTC intraday slots. -1. Merge this repo's PR (SAM CD is gone). First HCP apply is the bootstrap - window above with `schedules_enabled=false`. +1. Prod infrastructure is already applied. Exec roles stay + `hcptf-payments-dashboard` and `hcptf-payments-dashboard-plan`, owned by + org-baseline stack `seahaven-hcptf`. `schedules_enabled` stays false until + cutover. 2. Copy DynamoDB `PaymentsDashboard` mgmt → prod. Verify item counts for `payment#`, `boa_recon#`, and `boa_balance#`. Do not copy `seahaven-payments-boa-raw-*`. diff --git a/terraform/apigateway.tf b/terraform/apigateway.tf index 35b564d..c40073c 100644 --- a/terraform/apigateway.tf +++ b/terraform/apigateway.tf @@ -52,7 +52,6 @@ resource "aws_apigatewayv2_stage" "default" { depends_on = [ aws_apigatewayv2_route.slack_events, aws_apigatewayv2_route.expense_events, - aws_iam_role_policy.hcptf_apply_services, ] } diff --git a/terraform/hcp_iam.tf b/terraform/hcp_iam.tf deleted file mode 100644 index 89e1a92..0000000 --- a/terraform/hcp_iam.tf +++ /dev/null @@ -1,915 +0,0 @@ -# HCP plan/apply roles for payments-dashboard-prod (PLAT-79 / PLAT-144). -# Copy of seahaven-org-baseline/examples/hcptf-workspace-iam/hcp_iam.tf.example -# with the payments-dashboard service set. Create, do not import. -# -# Live seahaven-hcptf-iam-management DenySelfMutation blocks DetachRolePolicy -# and PutRolePolicy on hcptf-* (including this role). First-apply sequence: -# 1. seahaven-org-baseline scripts/create-hcptf-bootstrap-roles.sh -# --account prod --allow-workspace payments-dashboard-prod -# 2. Point this workspace's TFC_AWS_* at hcptf-bootstrap / -# hcptf-bootstrap-plan (workspace vars, never a project set). -# 3. One Manual apply (create roles + scoped inline + boundary + stack, -# schedules_enabled=false). -# 4. Point TFC_AWS_* back at hcptf-payments-dashboard / -# hcptf-payments-dashboard-plan. -# 5. Re-run the script without --allow-workspace to pin trust back to -# iam-bootstrap-prod only. -# Later apply-role IAM edits use the same window. Do not add StringLike -# on bootstrap trust. CreatePolicy stays on hcptf-bootstrap only; boundary -# document changes after seal also need that window. - -data "aws_iam_policy_document" "hcptf_apply_trust" { - statement { - sid = "HcpApply" - effect = "Allow" - actions = ["sts:AssumeRoleWithWebIdentity"] - - principals { - type = "Federated" - identifiers = ["arn:aws:iam::${local.account_id}:oidc-provider/app.terraform.io"] - } - - condition { - test = "StringEquals" - variable = "app.terraform.io:aud" - values = ["aws.workload.identity"] - } - - condition { - test = "StringEquals" - variable = "app.terraform.io:sub" - values = [ - "organization:seahaven:project:${local.hcp_project}:workspace:${local.hcp_workspace}:run_phase:apply", - ] - } - } -} - -data "aws_iam_policy_document" "hcptf_plan_trust" { - statement { - sid = "HcpPlan" - effect = "Allow" - actions = ["sts:AssumeRoleWithWebIdentity"] - - principals { - type = "Federated" - identifiers = ["arn:aws:iam::${local.account_id}:oidc-provider/app.terraform.io"] - } - - condition { - test = "StringEquals" - variable = "app.terraform.io:aud" - values = ["aws.workload.identity"] - } - - condition { - test = "StringEquals" - variable = "app.terraform.io:sub" - values = [ - "organization:seahaven:project:${local.hcp_project}:workspace:${local.hcp_workspace}:run_phase:plan", - ] - } - } -} - -data "aws_iam_policy_document" "hcptf_scoped_iam" { - statement { - sid = "DenyCreatePolicy" - effect = "Deny" - actions = [ - "iam:CreatePolicy", - "iam:CreatePolicyVersion", - "iam:DeletePolicy", - "iam:DeletePolicyVersion", - "iam:SetDefaultPolicyVersion", - ] - resources = ["*"] - } - - statement { - sid = "CreateExecRoleWithBoundary" - effect = "Allow" - actions = ["iam:CreateRole"] - resources = [ - "arn:aws:iam::${local.account_id}:role/tf-managed/${local.stack_prefix}*", - ] - - condition { - test = "StringLike" - variable = "iam:PermissionsBoundary" - values = [ - "arn:aws:iam::${local.account_id}:policy/tf-managed/${local.stack_prefix}*", - "arn:aws:iam::${local.account_id}:policy/seahaven-lambda-execution-boundary", - "arn:aws:iam::${local.account_id}:policy/seahaven-lambda-execution-boundary-${local.stack_name}", - ] - } - } - - statement { - sid = "MutateExecRoleWithBoundary" - effect = "Allow" - actions = [ - "iam:AttachRolePolicy", - "iam:PutRolePolicy", - "iam:PutRolePermissionsBoundary", - ] - resources = [ - "arn:aws:iam::${local.account_id}:role/tf-managed/${local.stack_prefix}*", - ] - - condition { - test = "StringLike" - variable = "iam:PermissionsBoundary" - values = [ - "arn:aws:iam::${local.account_id}:policy/tf-managed/${local.stack_prefix}*", - "arn:aws:iam::${local.account_id}:policy/seahaven-lambda-execution-boundary", - "arn:aws:iam::${local.account_id}:policy/seahaven-lambda-execution-boundary-${local.stack_name}", - ] - } - } - - statement { - sid = "WriteExecRoles" - effect = "Allow" - actions = [ - "iam:DeleteRole", - "iam:DeleteRolePolicy", - "iam:DetachRolePolicy", - "iam:TagRole", - "iam:UntagRole", - "iam:UpdateAssumeRolePolicy", - "iam:UpdateRole", - "iam:UpdateRoleDescription", - ] - resources = [ - "arn:aws:iam::${local.account_id}:role/tf-managed/${local.stack_prefix}*", - ] - } - - statement { - sid = "PassExecRolesToLambda" - effect = "Allow" - actions = ["iam:PassRole"] - resources = ["arn:aws:iam::${local.account_id}:role/tf-managed/${local.stack_prefix}*"] - - condition { - test = "StringEquals" - variable = "iam:PassedToService" - values = ["lambda.amazonaws.com"] - } - } - - statement { - sid = "CreateDeployRole" - effect = "Allow" - actions = ["iam:CreateRole"] - resources = ["arn:aws:iam::${local.account_id}:role/tf-managed/${local.deploy_role}"] - - condition { - test = "Null" - variable = "iam:PermissionsBoundary" - values = ["true"] - } - } - - statement { - sid = "WriteDeployRoles" - effect = "Allow" - actions = [ - "iam:AttachRolePolicy", - "iam:DeleteRole", - "iam:DeleteRolePolicy", - "iam:DetachRolePolicy", - "iam:PutRolePolicy", - "iam:TagRole", - "iam:UntagRole", - "iam:UpdateAssumeRolePolicy", - "iam:UpdateRole", - "iam:UpdateRoleDescription", - ] - resources = ["arn:aws:iam::${local.account_id}:role/tf-managed/${local.deploy_role}"] - } - - statement { - sid = "IamReadOnly" - effect = "Allow" - actions = [ - "iam:GetPolicy", - "iam:GetPolicyVersion", - "iam:GetRole", - "iam:GetRolePolicy", - "iam:ListAttachedRolePolicies", - "iam:ListInstanceProfilesForRole", - "iam:ListPolicies", - "iam:ListPolicyVersions", - "iam:ListRolePolicies", - "iam:ListRoleTags", - "iam:ListRoles", - ] - resources = ["*"] - } - - statement { - sid = "DenySelfMutation" - effect = "Deny" - actions = [ - "iam:AttachRolePolicy", - "iam:DeleteRole", - "iam:DeleteRolePolicy", - "iam:DeleteRolePermissionsBoundary", - "iam:DetachRolePolicy", - "iam:PutRolePolicy", - "iam:PutRolePermissionsBoundary", - "iam:UpdateAssumeRolePolicy", - "iam:UpdateRole", - "iam:UpdateRoleDescription", - ] - resources = [ - "arn:aws:iam::${local.account_id}:role/hcptf-*", - "arn:aws:iam::${local.account_id}:role/github-cfn-execution-role", - "arn:aws:iam::${local.account_id}:role/githubdeploy-*", - "arn:aws:iam::${local.account_id}:role/cdk-hnb659fds-*", - "arn:aws:iam::${local.account_id}:role/OrganizationAccountAccessRole", - "arn:aws:iam::${local.account_id}:role/seahaven-*", - ] - } - - statement { - sid = "DenyBoundaryTampering" - effect = "Deny" - actions = [ - "iam:DeleteRolePermissionsBoundary", - "iam:DeleteUserPermissionsBoundary", - ] - resources = [ - "arn:aws:iam::${local.account_id}:role/*", - "arn:aws:iam::${local.account_id}:user/*", - ] - } - - statement { - sid = "DenyBoundaryPolicyEdit" - effect = "Deny" - actions = [ - "iam:CreatePolicyVersion", - "iam:DeletePolicy", - "iam:DeletePolicyVersion", - "iam:SetDefaultPolicyVersion", - ] - resources = ["arn:aws:iam::${local.account_id}:policy/seahaven-*"] - } -} - -data "aws_iam_policy_document" "hcptf_apply_services" { - # checkov:skip=CKV_AWS_111: List/describe, HTTP API log delivery, and VPC/NAT lifecycle APIs require Resource=*. Function, bucket, table, queue, secret, alarm, and SSM writes are ARN-prefixed. - statement { - sid = "LambdaAll" - effect = "Allow" - actions = [ - "lambda:*", - ] - resources = [ - "arn:aws:lambda:${var.aws_region}:${local.account_id}:function:payments-*", - ] - } - - statement { - sid = "LambdaList" - effect = "Allow" - actions = [ - "lambda:ListFunctions", - "lambda:ListLayers", - "lambda:GetAccountSettings", - ] - resources = ["*"] - } - - statement { - sid = "EventBridgeRules" - effect = "Allow" - actions = [ - "events:*", - ] - resources = [ - "arn:aws:events:${var.aws_region}:${local.account_id}:rule/${local.project}-*", - ] - } - - statement { - sid = "EventBridgeList" - effect = "Allow" - actions = ["events:ListRules", "events:ListRuleNamesByTarget"] - resources = ["*"] - } - - statement { - sid = "CloudWatchLogs" - effect = "Allow" - actions = [ - "logs:CreateLogGroup", - "logs:DeleteLogGroup", - "logs:PutRetentionPolicy", - "logs:DeleteRetentionPolicy", - "logs:TagResource", - "logs:UntagResource", - "logs:ListTagsForResource", - "logs:PutMetricFilter", - "logs:DeleteMetricFilter", - "logs:DescribeMetricFilters", - ] - resources = [ - "arn:aws:logs:${var.aws_region}:${local.account_id}:log-group:/aws/lambda/payments-*", - "arn:aws:logs:${var.aws_region}:${local.account_id}:log-group:/aws/apigateway/${local.project}", - "arn:aws:logs:${var.aws_region}:${local.account_id}:log-group:/aws/apigateway/${local.project}:*", - ] - } - - statement { - sid = "CloudWatchLogsDescribe" - effect = "Allow" - actions = ["logs:DescribeLogGroups"] - resources = ["*"] - } - - statement { - sid = "ApiGwAccessLogDelivery" - effect = "Allow" - actions = [ - "logs:CreateLogDelivery", - "logs:GetLogDelivery", - "logs:UpdateLogDelivery", - "logs:DeleteLogDelivery", - "logs:ListLogDeliveries", - "logs:PutResourcePolicy", - "logs:DescribeResourcePolicies", - ] - resources = ["*"] - } - - statement { - sid = "StackBuckets" - effect = "Allow" - actions = [ - "s3:*", - ] - resources = [ - "arn:aws:s3:::${local.artifacts_bucket_name}", - "arn:aws:s3:::${local.artifacts_bucket_name}/*", - "arn:aws:s3:::${local.csv_bucket_name}", - "arn:aws:s3:::${local.csv_bucket_name}/*", - "arn:aws:s3:::${local.boa_raw_bucket_name}", - "arn:aws:s3:::${local.boa_raw_bucket_name}/*", - ] - } - - statement { - sid = "DynamoDBTable" - effect = "Allow" - actions = [ - "dynamodb:*", - ] - resources = [ - "arn:aws:dynamodb:${var.aws_region}:${local.account_id}:table/${local.table_name}", - "arn:aws:dynamodb:${var.aws_region}:${local.account_id}:table/${local.table_name}/*", - ] - } - - statement { - sid = "DynamoDBList" - effect = "Allow" - actions = ["dynamodb:ListTables"] - resources = ["*"] - } - - statement { - sid = "SqsDlq" - effect = "Allow" - actions = [ - "sqs:*", - ] - resources = [ - "arn:aws:sqs:${var.aws_region}:${local.account_id}:payments-processPaymentCsv-async-dlq", - ] - } - - statement { - sid = "SqsList" - effect = "Allow" - actions = ["sqs:ListQueues"] - resources = ["*"] - } - - statement { - sid = "HttpApiManage" - effect = "Allow" - actions = [ - "apigateway:*", - ] - resources = [ - "arn:aws:apigateway:${var.aws_region}::/apis", - "arn:aws:apigateway:${var.aws_region}::/apis/*", - "arn:aws:apigateway:${var.aws_region}::/tags/*", - "arn:aws:apigateway:${var.aws_region}::/vpclinks", - "arn:aws:apigateway:${var.aws_region}::/vpclinks/*", - ] - } - - statement { - sid = "PaymentsSsm" - effect = "Allow" - actions = [ - "ssm:GetParameter", - "ssm:GetParameters", - "ssm:PutParameter", - "ssm:DeleteParameter", - "ssm:AddTagsToResource", - "ssm:RemoveTagsFromResource", - "ssm:ListTagsForResource", - ] - resources = [ - "arn:aws:ssm:${var.aws_region}:${local.account_id}:parameter${local.ssm_prefix}/*", - "arn:aws:ssm:${var.aws_region}:${local.account_id}:parameter${local.dynamodb_cmk_ssm}", - ] - } - - statement { - sid = "SsmDescribeParameters" - effect = "Allow" - actions = ["ssm:DescribeParameters"] - resources = ["*"] - } - - statement { - sid = "SecretsManagerRead" - effect = "Allow" - actions = [ - "secretsmanager:DescribeSecret", - "secretsmanager:GetResourcePolicy", - "secretsmanager:ListSecretVersionIds", - "secretsmanager:TagResource", - "secretsmanager:UntagResource", - ] - resources = [ - "arn:aws:secretsmanager:${var.aws_region}:${local.account_id}:secret:payments-dashboard/*", - ] - } - - statement { - sid = "SecretsManagerList" - effect = "Allow" - actions = ["secretsmanager:ListSecrets"] - resources = ["*"] - } - - statement { - sid = "KmsTableCmk" - effect = "Allow" - actions = [ - "kms:DescribeKey", - "kms:GetKeyPolicy", - "kms:ListResourceTags", - "kms:CreateGrant", - "kms:ListGrants", - "kms:RetireGrant", - "kms:Encrypt", - "kms:Decrypt", - "kms:GenerateDataKey", - "kms:GenerateDataKeyWithoutPlaintext", - ] - resources = [local.dynamodb_cmk_arn] - } - - statement { - sid = "CloudWatchAlarms" - effect = "Allow" - actions = [ - "cloudwatch:PutMetricAlarm", - "cloudwatch:DeleteAlarms", - "cloudwatch:DescribeAlarms", - "cloudwatch:TagResource", - "cloudwatch:UntagResource", - "cloudwatch:ListTagsForResource", - ] - resources = [ - "arn:aws:cloudwatch:${var.aws_region}:${local.account_id}:alarm:payments-*", - ] - } - - statement { - sid = "CloudWatchDescribeAlarms" - effect = "Allow" - actions = ["cloudwatch:DescribeAlarms"] - resources = ["*"] - } - - statement { - sid = "SnsPublishSiteAlerts" - effect = "Allow" - actions = [ - "sns:Publish", - "sns:GetTopicAttributes", - "sns:ListTagsForResource", - ] - resources = [local.site_alerts_arn] - } - - statement { - sid = "ManageTfManagedBoundary" - effect = "Allow" - actions = [ - "iam:GetPolicy", - "iam:GetPolicyVersion", - "iam:ListPolicyVersions", - "iam:ListPolicyTags", - "iam:TagPolicy", - "iam:UntagPolicy", - ] - resources = [ - "arn:aws:iam::${local.account_id}:policy/tf-managed/${local.stack_prefix}*", - ] - } - - statement { - sid = "Ec2VpcManagement" - effect = "Allow" - actions = [ - "ec2:AllocateAddress", - "ec2:AssociateRouteTable", - "ec2:AttachInternetGateway", - "ec2:AuthorizeSecurityGroupEgress", - "ec2:AuthorizeSecurityGroupIngress", - "ec2:CreateInternetGateway", - "ec2:CreateNatGateway", - "ec2:CreateRoute", - "ec2:CreateRouteTable", - "ec2:CreateSecurityGroup", - "ec2:CreateSubnet", - "ec2:CreateVpc", - "ec2:CreateVpcEndpoint", - "ec2:CreateTags", - "ec2:DeleteInternetGateway", - "ec2:DeleteNatGateway", - "ec2:DeleteRoute", - "ec2:DeleteRouteTable", - "ec2:DeleteSecurityGroup", - "ec2:DeleteSubnet", - "ec2:DeleteVpc", - "ec2:DeleteVpcEndpoints", - "ec2:DescribeAccountAttributes", - "ec2:DescribeAddresses", - "ec2:DescribeAddressesAttribute", - "ec2:DescribeAvailabilityZones", - "ec2:DescribeInternetGateways", - "ec2:DescribeNatGateways", - "ec2:DescribeNetworkInterfaces", - "ec2:DescribeRouteTables", - "ec2:DescribeSecurityGroupRules", - "ec2:DescribeSecurityGroups", - "ec2:DescribeSubnets", - "ec2:DescribeTags", - "ec2:DescribeVpcAttribute", - "ec2:DescribeVpcEndpoints", - "ec2:DescribeVpcs", - "ec2:DescribePrefixLists", - "ec2:DetachInternetGateway", - "ec2:DisassociateAddress", - "ec2:DisassociateRouteTable", - "ec2:ModifySubnetAttribute", - "ec2:ModifyVpcAttribute", - "ec2:ModifyVpcEndpoint", - "ec2:ReleaseAddress", - "ec2:RevokeSecurityGroupEgress", - "ec2:RevokeSecurityGroupIngress", - "ec2:UpdateSecurityGroupRuleDescriptionsEgress", - "ec2:UpdateSecurityGroupRuleDescriptionsIngress", - ] - resources = ["*"] - } -} - -data "aws_iam_policy_document" "hcptf_plan_refresh" { - statement { - sid = "RefreshIamRoles" - effect = "Allow" - actions = [ - "iam:GetRole", - "iam:GetRolePolicy", - "iam:ListRolePolicies", - "iam:ListAttachedRolePolicies", - "iam:ListRoleTags", - ] - resources = [ - "arn:aws:iam::${local.account_id}:role/tf-managed/${local.stack_prefix}*", - "arn:aws:iam::${local.account_id}:role/tf-managed/${local.deploy_role}", - "arn:aws:iam::${local.account_id}:role/${local.apply_role}", - "arn:aws:iam::${local.account_id}:role/${local.plan_role}", - ] - } - - statement { - sid = "RefreshManagedPolicies" - effect = "Allow" - actions = [ - "iam:GetPolicy", - "iam:GetPolicyVersion", - ] - resources = ["*"] - } - - statement { - sid = "RefreshLambda" - effect = "Allow" - actions = [ - "lambda:GetFunction", - "lambda:GetFunctionConfiguration", - "lambda:GetPolicy", - "lambda:GetFunctionCodeSigningConfig", - "lambda:GetFunctionConcurrency", - "lambda:GetFunctionEventInvokeConfig", - "lambda:GetFunctionUrlConfig", - "lambda:GetRuntimeManagementConfig", - "lambda:GetFunctionRecursionConfig", - "lambda:ListTags", - "lambda:ListVersionsByFunction", - "lambda:ListAliases", - ] - resources = [ - "arn:aws:lambda:${var.aws_region}:${local.account_id}:function:payments-*", - ] - } - - statement { - sid = "RefreshLambdaList" - effect = "Allow" - actions = [ - "lambda:ListFunctions", - "lambda:ListLayers", - "lambda:GetAccountSettings", - ] - resources = ["*"] - } - - statement { - sid = "RefreshBuckets" - effect = "Allow" - actions = [ - "s3:GetAccelerateConfiguration", - "s3:GetAnalyticsConfiguration", - "s3:GetBucketAcl", - "s3:GetBucketCORS", - "s3:GetBucketLifecycleConfiguration", - "s3:GetBucketLocation", - "s3:GetBucketLogging", - "s3:GetBucketNotification", - "s3:GetBucketObjectLockConfiguration", - "s3:GetBucketOwnershipControls", - "s3:GetBucketPolicy", - "s3:GetBucketPolicyStatus", - "s3:GetBucketPublicAccessBlock", - "s3:GetBucketReplication", - "s3:GetBucketRequestPayment", - "s3:GetBucketTagging", - "s3:GetBucketVersioning", - "s3:GetBucketWebsite", - "s3:GetEncryptionConfiguration", - "s3:GetIntelligentTieringConfiguration", - "s3:GetInventoryConfiguration", - "s3:GetLifecycleConfiguration", - "s3:GetMetricsConfiguration", - "s3:GetObject", - "s3:GetObjectTagging", - "s3:GetObjectVersion", - "s3:GetReplicationConfiguration", - "s3:ListBucket", - ] - resources = [ - "arn:aws:s3:::${local.artifacts_bucket_name}", - "arn:aws:s3:::${local.artifacts_bucket_name}/*", - "arn:aws:s3:::${local.csv_bucket_name}", - "arn:aws:s3:::${local.csv_bucket_name}/*", - "arn:aws:s3:::${local.boa_raw_bucket_name}", - "arn:aws:s3:::${local.boa_raw_bucket_name}/*", - ] - } - - statement { - sid = "RefreshDynamoDB" - effect = "Allow" - actions = [ - "dynamodb:DescribeTable", - "dynamodb:DescribeTimeToLive", - "dynamodb:DescribeContinuousBackups", - "dynamodb:DescribeKinesisStreamingDestination", - "dynamodb:ListTagsOfResource", - ] - resources = [ - "arn:aws:dynamodb:${var.aws_region}:${local.account_id}:table/${local.table_name}", - ] - } - - statement { - sid = "RefreshEventBridge" - effect = "Allow" - actions = [ - "events:DescribeRule", - "events:ListTargetsByRule", - "events:ListTagsForResource", - ] - resources = [ - "arn:aws:events:${var.aws_region}:${local.account_id}:rule/${local.project}-*", - ] - } - - statement { - sid = "RefreshLogs" - effect = "Allow" - actions = [ - "logs:DescribeLogGroups", - "logs:ListTagsForResource", - ] - resources = ["*"] - } - - statement { - sid = "RefreshHttpApi" - effect = "Allow" - actions = [ - "apigateway:GET", - ] - resources = [ - "arn:aws:apigateway:${var.aws_region}::/apis", - "arn:aws:apigateway:${var.aws_region}::/apis/*", - "arn:aws:apigateway:${var.aws_region}::/tags/*", - ] - } - - statement { - sid = "RefreshSsm" - effect = "Allow" - actions = [ - "ssm:GetParameter", - "ssm:GetParameters", - "ssm:ListTagsForResource", - ] - resources = [ - "arn:aws:ssm:${var.aws_region}:${local.account_id}:parameter${local.ssm_prefix}/*", - "arn:aws:ssm:${var.aws_region}:${local.account_id}:parameter${local.dynamodb_cmk_ssm}", - ] - } - - statement { - sid = "RefreshSsmDescribeParameters" - effect = "Allow" - actions = ["ssm:DescribeParameters"] - resources = ["*"] - } - - statement { - sid = "RefreshSecrets" - effect = "Allow" - actions = [ - "secretsmanager:DescribeSecret", - "secretsmanager:GetResourcePolicy", - "secretsmanager:ListSecretVersionIds", - ] - resources = [ - "arn:aws:secretsmanager:${var.aws_region}:${local.account_id}:secret:payments-dashboard/*", - ] - } - - statement { - sid = "RefreshSecretsList" - effect = "Allow" - actions = ["secretsmanager:ListSecrets"] - resources = ["*"] - } - - statement { - sid = "RefreshAlarms" - effect = "Allow" - actions = [ - "cloudwatch:DescribeAlarms", - "cloudwatch:ListTagsForResource", - ] - resources = ["*"] - } - - statement { - sid = "RefreshSns" - effect = "Allow" - actions = [ - "sns:GetTopicAttributes", - "sns:ListTagsForResource", - ] - resources = [local.site_alerts_arn] - } - - statement { - sid = "RefreshSqs" - effect = "Allow" - actions = [ - "sqs:GetQueueAttributes", - "sqs:GetQueueUrl", - "sqs:ListQueueTags", - ] - resources = [ - "arn:aws:sqs:${var.aws_region}:${local.account_id}:payments-processPaymentCsv-async-dlq", - ] - } - - statement { - sid = "RefreshKms" - effect = "Allow" - actions = [ - "kms:DescribeKey", - "kms:GetKeyPolicy", - "kms:ListResourceTags", - "kms:CreateGrant", - "kms:ListGrants", - ] - resources = [local.dynamodb_cmk_arn] - } - - statement { - sid = "RefreshEc2" - effect = "Allow" - actions = [ - "ec2:DescribeAccountAttributes", - "ec2:DescribeAddresses", - "ec2:DescribeAddressesAttribute", - "ec2:DescribeAvailabilityZones", - "ec2:DescribeInternetGateways", - "ec2:DescribeNatGateways", - "ec2:DescribeNetworkInterfaces", - "ec2:DescribeRouteTables", - "ec2:DescribeSecurityGroupRules", - "ec2:DescribeSecurityGroups", - "ec2:DescribeSubnets", - "ec2:DescribeTags", - "ec2:DescribeVpcAttribute", - "ec2:DescribeVpcEndpoints", - "ec2:DescribeVpcs", - "ec2:DescribePrefixLists", - ] - resources = ["*"] - } -} - -resource "aws_iam_role" "hcptf_apply" { - name = local.apply_role - assume_role_policy = data.aws_iam_policy_document.hcptf_apply_trust.json - max_session_duration = 3600 - - tags = { - Owner = "adam@seahavenind.com" - ManagedBy = "terraform" - } -} - -resource "aws_iam_role" "hcptf_plan" { - name = local.plan_role - assume_role_policy = data.aws_iam_policy_document.hcptf_plan_trust.json - max_session_duration = 3600 - - tags = { - Owner = "adam@seahavenind.com" - ManagedBy = "terraform" - } -} - -resource "aws_iam_role_policy" "hcptf_scoped_iam" { - name = "scoped-iam-management" - role = aws_iam_role.hcptf_apply.id - policy = data.aws_iam_policy_document.hcptf_scoped_iam.json -} - -resource "aws_iam_role_policy" "hcptf_apply_services" { - # checkov:skip=CKV_AWS_111: List/describe, HTTP API log delivery, and VPC/NAT lifecycle APIs require Resource=*. Function, bucket, table, queue, secret, alarm, and SSM writes are ARN-prefixed. - name = "payments-dashboard-services" - role = aws_iam_role.hcptf_apply.id - policy = data.aws_iam_policy_document.hcptf_apply_services.json -} - -resource "aws_iam_role_policy" "hcptf_plan_refresh" { - # checkov:skip=CKV_AWS_107: ViewOnlyAccess plus this sidecar is the org HCP plan-role pattern (PLAT-144 / afterhours). Sidecar Get* is scoped to this stack's roles, buckets, table, queues, functions, and parameters. It does not add iam:CreateAccessKey, secretsmanager:GetSecretValue, or ssm:GetParameter on *. - name = "payments-dashboard-plan-refresh" - role = aws_iam_role.hcptf_plan.id - policy = data.aws_iam_policy_document.hcptf_plan_refresh.json -} - -resource "aws_iam_role_policy_attachment" "hcptf_plan_view_only" { - role = aws_iam_role.hcptf_plan.name - policy_arn = "arn:aws:iam::aws:policy/job-function/ViewOnlyAccess" -} - -resource "aws_iam_role_policy_attachments_exclusive" "hcptf_apply" { - role_name = aws_iam_role.hcptf_apply.name - policy_arns = [] -} - -resource "aws_iam_role_policy_attachments_exclusive" "hcptf_plan" { - role_name = aws_iam_role.hcptf_plan.name - policy_arns = [ - "arn:aws:iam::aws:policy/job-function/ViewOnlyAccess", - ] -} diff --git a/terraform/lambda.tf b/terraform/lambda.tf index 610862b..225079b 100644 --- a/terraform/lambda.tf +++ b/terraform/lambda.tf @@ -147,7 +147,7 @@ resource "aws_iam_role" "lambda" { path = "/tf-managed/" description = "Lambda execution role for ${each.value.function_name}" assume_role_policy = data.aws_iam_policy_document.lambda_assume.json - permissions_boundary = aws_iam_policy.lambda_boundary.arn + permissions_boundary = local.lambda_boundary_arn } data "aws_iam_policy_document" "lambda" { diff --git a/terraform/lambda_boundary.tf b/terraform/lambda_boundary.tf deleted file mode 100644 index 0fed759..0000000 --- a/terraform/lambda_boundary.tf +++ /dev/null @@ -1,147 +0,0 @@ -# Per-workload Lambda permissions boundary. Created on the first (bootstrap) -# apply. The scoped apply role denies iam:CreatePolicy / CreatePolicyVersion, -# so later edits to this document need the hcptf-bootstrap window. - -data "aws_iam_policy_document" "lambda_boundary" { - # checkov:skip=CKV_AWS_111: AWS requires Resource=* for logs:DescribeLogGroups, xray Put*, and EC2 ENI lifecycle used by VPC Lambdas. Secrets, table, CMK, buckets, DLQ, and invoke are ARN-pinned. - statement { - sid = "CloudWatchLogsWrite" - effect = "Allow" - actions = [ - "logs:CreateLogGroup", - "logs:CreateLogStream", - "logs:PutLogEvents", - "logs:DescribeLogStreams", - ] - resources = [ - "arn:aws:logs:${var.aws_region}:${local.account_id}:log-group:/aws/lambda*", - ] - } - - statement { - sid = "CloudWatchLogsDescribe" - effect = "Allow" - actions = ["logs:DescribeLogGroups"] - resources = ["*"] - } - - statement { - sid = "XRay" - effect = "Allow" - actions = [ - "xray:PutTraceSegments", - "xray:PutTelemetryRecords", - ] - resources = ["*"] - } - - statement { - sid = "Ec2Eni" - effect = "Allow" - actions = [ - "ec2:CreateNetworkInterface", - "ec2:DescribeNetworkInterfaces", - "ec2:DeleteNetworkInterface", - "ec2:DescribeSubnets", - "ec2:DescribeSecurityGroups", - "ec2:DescribeVpcs", - ] - resources = ["*"] - } - - statement { - sid = "PaymentsSecrets" - effect = "Allow" - actions = [ - "secretsmanager:GetSecretValue", - ] - resources = [for arn in local.secret_arns : arn] - } - - statement { - sid = "PaymentsDynamoDB" - effect = "Allow" - actions = [ - "dynamodb:GetItem", - "dynamodb:PutItem", - "dynamodb:UpdateItem", - "dynamodb:DeleteItem", - "dynamodb:Query", - "dynamodb:Scan", - "dynamodb:BatchGetItem", - "dynamodb:BatchWriteItem", - "dynamodb:DescribeTable", - "dynamodb:ConditionCheckItem", - ] - resources = [ - "arn:aws:dynamodb:${var.aws_region}:${local.account_id}:table/${local.table_name}", - "arn:aws:dynamodb:${var.aws_region}:${local.account_id}:table/${local.table_name}/*", - ] - } - - statement { - sid = "PaymentsCmk" - effect = "Allow" - actions = [ - "kms:Decrypt", - "kms:GenerateDataKey", - "kms:DescribeKey", - ] - resources = [local.dynamodb_cmk_arn] - - condition { - test = "StringEquals" - variable = "kms:ViaService" - values = ["dynamodb.${var.aws_region}.amazonaws.com"] - } - } - - statement { - sid = "PaymentsCsvRead" - effect = "Allow" - actions = [ - "s3:GetObject", - "s3:GetObjectVersion", - ] - resources = ["arn:aws:s3:::${local.csv_bucket_name}/*"] - } - - statement { - sid = "PaymentsBoaRawPut" - effect = "Allow" - actions = [ - "s3:PutObject", - ] - resources = ["arn:aws:s3:::${local.boa_raw_bucket_name}/*"] - } - - statement { - sid = "PaymentsDlqSend" - effect = "Allow" - actions = [ - "sqs:SendMessage", - ] - resources = [ - "arn:aws:sqs:${var.aws_region}:${local.account_id}:payments-processPaymentCsv-async-dlq", - ] - } - - statement { - sid = "PaymentsInvokeExpenseProcessor" - effect = "Allow" - actions = [ - "lambda:InvokeFunction", - ] - resources = [ - "arn:aws:lambda:${var.aws_region}:${local.account_id}:function:payments-expenseProcessor", - ] - } -} - -resource "aws_iam_policy" "lambda_boundary" { - # checkov:skip=CKV_AWS_111: AWS requires Resource=* for logs:DescribeLogGroups, xray Put*, and EC2 ENI lifecycle used by VPC Lambdas. Secrets, table, CMK, buckets, DLQ, and invoke are ARN-pinned. - name = "payments-dashboard-lambda-boundary" - path = "/tf-managed/" - description = "Per-workload Lambda permissions boundary for payments-dashboard (PLAT-79)." - policy = data.aws_iam_policy_document.lambda_boundary.json -} diff --git a/terraform/locals.tf b/terraform/locals.tf index c97d247..4b75140 100644 --- a/terraform/locals.tf +++ b/terraform/locals.tf @@ -6,11 +6,9 @@ locals { hcp_project = "seahaven-${var.environment}" hcp_workspace = "${local.project}-${var.environment}" - apply_role = "hcptf-payments-dashboard" - plan_role = "hcptf-payments-dashboard-plan" deploy_role = "githubdeploy-payments-dashboard" - stack_name = local.project - stack_prefix = "payments-dashboard-" + # Owned by org-baseline stack seahaven-hcptf. This configuration only references it. + lambda_boundary_arn = "arn:aws:iam::${local.account_id}:policy/tf-managed/payments-dashboard-lambda-boundary" artifacts_bucket_name = "payments-dashboard-artifacts-${local.account_id}" csv_bucket_name = "seahaven-payments-csv-${local.account_id}" @@ -41,9 +39,8 @@ locals { } dynamodb_cmk_arn = local.dynamodb_cmk_arns[var.environment] - # Exact ARNs (ticket rule). Hardcoded so the first plan can run as - # hcptf-bootstrap-plan, which cannot ssm:GetParameter / DescribeSecret. - # Dev values are shells created for this workspace. They are not prod secrets. + # Exact ARNs (ticket rule). Dev values are shells created for this workspace. + # They are not prod secrets. secret_arns_by_env = { prod = { "payments-dashboard/slack-bot-token" = "arn:aws:secretsmanager:us-east-1:011934824531:secret:payments-dashboard/slack-bot-token-0pAM3S" diff --git a/terraform/outputs.tf b/terraform/outputs.tf index 1770aef..b92eb3a 100644 --- a/terraform/outputs.tf +++ b/terraform/outputs.tf @@ -42,13 +42,3 @@ output "artifacts_bucket_name" { description = "Lambda artifacts bucket. deploy.yaml uploads functions//.zip." value = aws_s3_bucket.artifacts.id } - -output "hcptf_apply_role_arn" { - description = "HCP apply role ARN. Set TFC_AWS_APPLY_ROLE_ARN after the bootstrap window." - value = aws_iam_role.hcptf_apply.arn -} - -output "hcptf_plan_role_arn" { - description = "HCP plan role ARN. Set TFC_AWS_PLAN_ROLE_ARN after the bootstrap window." - value = aws_iam_role.hcptf_plan.arn -} diff --git a/terraform/removed.tf b/terraform/removed.tf new file mode 100644 index 0000000..e5c413d --- /dev/null +++ b/terraform/removed.tf @@ -0,0 +1,75 @@ +# hcptf-payments-dashboard, hcptf-payments-dashboard-plan, and the Lambda +# boundary moved to the seahaven-hcptf stack in seahaven-org-baseline. +# Prod state already contains them. Forget them here. Do not delete the live roles. + +removed { + from = aws_iam_role.hcptf_apply + + lifecycle { + destroy = false + } +} + +removed { + from = aws_iam_role.hcptf_plan + + lifecycle { + destroy = false + } +} + +removed { + from = aws_iam_role_policy.hcptf_scoped_iam + + lifecycle { + destroy = false + } +} + +removed { + from = aws_iam_role_policy.hcptf_apply_services + + lifecycle { + destroy = false + } +} + +removed { + from = aws_iam_role_policy.hcptf_plan_refresh + + lifecycle { + destroy = false + } +} + +removed { + from = aws_iam_role_policy_attachment.hcptf_plan_view_only + + lifecycle { + destroy = false + } +} + +removed { + from = aws_iam_role_policy_attachments_exclusive.hcptf_apply + + lifecycle { + destroy = false + } +} + +removed { + from = aws_iam_role_policy_attachments_exclusive.hcptf_plan + + lifecycle { + destroy = false + } +} + +removed { + from = aws_iam_policy.lambda_boundary + + lifecycle { + destroy = false + } +} diff --git a/terraform/secrets.tf b/terraform/secrets.tf index b1572b6..929d394 100644 --- a/terraform/secrets.tf +++ b/terraform/secrets.tf @@ -1,2 +1 @@ -# Secret values stay in Secrets Manager. ARNs are pinned in locals.tf so the -# first bootstrap-plan does not need secretsmanager:DescribeSecret. +# Secret values stay in Secrets Manager. ARNs are pinned in locals.tf. diff --git a/tests/infra/hcpContract.test.js b/tests/infra/hcpContract.test.js index 242f178..1798a52 100644 --- a/tests/infra/hcpContract.test.js +++ b/tests/infra/hcpContract.test.js @@ -7,7 +7,7 @@ import { fileURLToPath } from "node:url"; const ROOT = join(dirname(fileURLToPath(import.meta.url)), "..", ".."); const TERRAFORM = join(ROOT, "terraform"); const lambdaTf = readFileSync(join(TERRAFORM, "lambda.tf"), "utf8"); -const hcpIam = readFileSync(join(TERRAFORM, "hcp_iam.tf"), "utf8"); +const removed = readFileSync(join(TERRAFORM, "removed.tf"), "utf8"); const deploy = readFileSync(join(ROOT, ".github", "workflows", "deploy.yaml"), "utf8"); const ci = readFileSync(join(ROOT, ".github", "workflows", "ci.yaml"), "utf8"); const locals = readFileSync(join(TERRAFORM, "locals.tf"), "utf8"); @@ -43,11 +43,18 @@ describe("HCP Terraform seam (PLAT-79)", () => { assert.match(variables, /contains\(\["dev", "prod"\], var\.environment\)/); }); - it("declares in-repo hcptf roles", () => { - assert.match(locals, /apply_role\s+= "hcptf-payments-dashboard"/); - assert.match(locals, /plan_role\s+= "hcptf-payments-dashboard-plan"/); - assert.match(hcpIam, /hcptf_apply/); - assert.match(hcpIam, /DenyCreatePolicy/); + it("does not declare in-repo hcptf roles", () => { + assert.equal(existsSync(join(TERRAFORM, "hcp_iam.tf")), false); + assert.equal(existsSync(join(TERRAFORM, "lambda_boundary.tf")), false); + assert.match(lambdaTf, /permissions_boundary\s+=\s+local\.lambda_boundary_arn/); + assert.match( + locals, + /arn:aws:iam::\$\{local\.account_id\}:policy\/tf-managed\/payments-dashboard-lambda-boundary/, + ); + assert.match(removed, /from = aws_iam_role\.hcptf_apply/); + assert.match(removed, /from = aws_iam_policy\.lambda_boundary/); + assert.match(removed, /destroy\s+=\s+false/); + assert.doesNotMatch(locals, /apply_role/); }); it("calls the Lambda zip reusable for dev and prod", () => { @@ -64,13 +71,14 @@ describe("HCP Terraform seam (PLAT-79)", () => { assert.match(deploy, /function-keys: process_csv,slack_app_home,fetch_boa,expense_receiver,expense_processor/); }); - it("runs npm test and terraform validate behind ci / ci", () => { + it("runs npm test and the Terraform callable behind ci / ci", () => { assert.doesNotMatch(ci, /ci-typescript-cdk/); assert.doesNotMatch(ci, /run-sam-validate/); assert.match(ci, /npm test/); - assert.match(ci, /terraform fmt -check/); - assert.match(ci, /terraform init -backend=false/); - assert.match(ci, /terraform validate/); + assert.match(ci, /ci-terraform\.yaml@47185fa602dffddb8297db5f3525d7c9bc05d7cd/); + assert.match(ci, /src\//); + assert.match(ci, /package\.json/); + assert.match(ci, /package-lock\.json/); assert.match(ci, /name: ci \/ ci/); }); @@ -101,9 +109,4 @@ describe("HCP Terraform seam (PLAT-79)", () => { assert.doesNotMatch(variables, /github_deploy_branch/); }); - it("includes provider-6 S3 Get* needed for refresh", () => { - assert.match(hcpIam, /s3:GetLifecycleConfiguration/); - assert.match(hcpIam, /s3:GetReplicationConfiguration/); - assert.match(hcpIam, /s3:GetBucketReplication/); - }); });