refactor(iam): forget in-repo HCP exec roles (#120)
Some checks are pending
Deploy / Deploy to dev (push) Waiting to run
Deploy / Deploy to prod (push) Waiting to run

* 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.
This commit is contained in:
Adam Moussa 2026-10-01 21:29:22 -04:00 • committed by GitHub
parent 30a1737345
commit 7c2c806339
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 124 additions and 1133 deletions

View file

@ -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

View file

@ -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 <dev|prod> --allow-workspace payments-dashboard-<env>`
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::<account>: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-*`.

View file

@ -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,
]
}

View file

@ -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",
]
}

View file

@ -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" {

View file

@ -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
}

View file

@ -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"

View file

@ -42,13 +42,3 @@ output "artifacts_bucket_name" {
description = "Lambda artifacts bucket. deploy.yaml uploads functions/<name>/<sha>.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
}

75
terraform/removed.tf Normal file
View file

@ -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
}
}

View file

@ -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.

View file

@ -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/);
});
});