From 6a0713f49dafc0a8289f59c21519559668e19b75 Mon Sep 17 00:00:00 2001 From: Adam Moussa <166072409+amoussa1229@users.noreply.github.com> Date: Mon, 31 Aug 2026 02:25:47 +0000 Subject: [PATCH] feat(iam): add frontend Terraform substrate (#133) * feat(iam): add frontend Terraform substrate * fix(iam): align frontend Terraform substrate * feat(iam): enable frontend live Terraform roles --- README.md | 59 +- bin/app.ts | 24 + cdk.json | 9 +- lib/scp/external-dev-iam-guardrails.json | 11 +- lib/terraform-substrate-stack.ts | 36 +- .../shoc-frontend-resources.ts | 698 ++++++++++++++++++ 6 files changed, 829 insertions(+), 8 deletions(-) create mode 100644 lib/terraform-substrate/shoc-frontend-resources.ts diff --git a/README.md b/README.md index ff88e17..61b74e2 100644 --- a/README.md +++ b/README.md @@ -31,7 +31,7 @@ are noted): | `seahaven-backup-offsite` | 328440206208 | us-west-2 | Governance-locked offsite copy vault (C-7) | | `seahaven-org-governance` | 328440206208 | us-east-1 | AWS Organizations OU tree + SCPs (incl. the cdk-imported external-dev guardrails) | | `seahaven-external-dev-baseline` | 396287094661 | us-east-1 | Member-account baseline: Config, GuardDuty, Security Hub (FSBP + CIS v3.0), Access Analyzer, flow logs, budget | -| `seahaven-terraform-substrate` | 396287094661 | us-east-1 | Staged manually until SHOC role imports complete; HCP roles/deploy boundaries for the backend rehearsal, referencing the existing OIDC provider | +| `seahaven-terraform-substrate` | 396287094661 | us-east-1 | Staged manually for SHOC backend/frontend adoption; exact HCP roles and deploy boundaries referencing the existing OIDC provider | | `seahaven-security-baseline` | 001520130573 | us-east-1 | Member-account baseline for the delegated security-admin account (same construct set) | | `seahaven-dev-baseline` | 710827005802 | us-east-1 | Member-account baseline for internal dev/staging (org-managed detection — no local GuardDuty/SecurityHub) | | `seahaven-prod-baseline` | 011934824531 | us-east-1 | Member-account baseline for production workloads (org-managed detection; mgmt account frozen for new workloads) | @@ -375,9 +375,64 @@ hosted-zone IDs and API record names. Current compact policy-document sizes are 1,387 / 1,873 / 1,844 characters for the POC/dev/staging deploy boundaries and 1,176 / 1,779 / 1,656 for their runtime boundaries, each below IAM's 6,144-character managed-policy limit. The -external-dev IAM guardrail SCP is 4,922 compact characters against its +external-dev IAM guardrail SCP is 5,095 compact characters against its 5,120-character Organizations limit; keep size assertions in every change. +**External-dev SHOC frontend adoption uses separate gates and creates its +boundaries first.** The three retained boundaries are +`shoc-frontend-new-{tf-poc,dev,staging}-deploy-boundary`. Each permits only +bucket location/list/version reads, object get/put/current and version delete, +and invalidation create/read for one exact distribution. Dev is pinned to +`E2CWLM1AFB964P`; staging is pinned to `E2JDVEZ6EGD49J`. The tf-poc +distribution, OAC, function, hosted-zone, and certificate identifiers are +intentionally empty in `cdk.json`. They must come from the frontend shared +creator outputs; this substrate does not reuse the backend tf-poc zone or +certificate. Its site name is `frontend-tf-poc.seahaven.com`. While the +identifier set is empty, its boundary omits invalidation access and +`ShouldManageShocFrontendPocRoles` remains false even if its role gate is +mistakenly enabled. + +The frontend role transition is manual and is not part of the external-dev CD +job: + +1. Deploy `terraform-substrate-external-dev` with both frontend role gates + false. Verify the three boundary documents before touching any GitHub role. +2. For dev and staging, an administrator attaches the matching dedicated + boundary to `githubdeploy-shoc-frontend-new-`, then adds + `HcpTerraformWorkspace=shoc-frontend-new-`. Verify the current GitHub + deployment still uploads to only the exact bucket and invalidates only the + exact distribution. The SCP blocks HCP from adding or changing this manager + tag itself. +3. Set `enableShocFrontendLiveRoles=true` and run a reviewed reconcile update. + This creates the exact plan/apply pairs with 3,600-second sessions and + phase-specific HCP `StringEquals` trust. It does not import or replace the + existing GitHub roles. +4. Reconcile the frontend Terraform roots with `removed { destroy = false }` + ownership handoff for HCP roles/boundaries where applicable. Import the + existing site resources only after a no-replacement plan. Apply-role writes + are limited to ordinary tags, `PutRolePolicy` on the exact deploy role, + `PutBucketPolicy` on the exact bucket, and A/AAAA changes for the exact site + name with CREATE/DELETE/UPSERT conditions. +5. For a future tf-poc, first provision and inventory the site outside these + adoption roles. Set all five `shocFrontendPoc*` identifiers from the + frontend shared creator outputs while its role gate remains false, deploy + and reconcile the boundary, attach it and the matching manager tag to the + exact GitHub role, then set + `enableShocFrontendPocRoles=true`. +6. Keep `terraform-substrate-external-dev` out of automatic deployment until + all enabled frontend roles and target-role guardrails are proven. Do not use + a false gate as rollback after CloudFormation owns a role. + +The apply roles explicitly deny role lifecycle/trust/boundary/managed-policy +changes, `PassRole`, secret and parameter reads, CloudFront/S3 infrastructure +mutation, and deletion of inline role or bucket policies. IAM does not expose a +condition key for an inline policy name, so `PutRolePolicy` is constrained to +the exact target-role ARN and requires the exact dedicated deploy boundary to +already be attached. The boundary limits effective permissions, and the SCP +requires the target role's locked `HcpTerraformWorkspace` tag to equal the +apply role's principal tag. The Terraform resource must retain the inventoried +inline policy name. + **HCP Terraform layout (org-level setup, console):** one org `seahaven` (free tier: 500 managed resources, 1 concurrent run); one HCP **project per AWS account** (`seahaven-prod`, `seahaven-dev`); one **workspace per stack** diff --git a/bin/app.ts b/bin/app.ts index 6dc8911..0ed3034 100644 --- a/bin/app.ts +++ b/bin/app.ts @@ -38,6 +38,13 @@ const contextBoolean = (key: string): boolean => { throw new Error(`${key} must be true or false`); }; +const contextString = (key: string): string => { + const value = app.node.tryGetContext(key); + if (value === undefined) return ""; + if (typeof value === "string") return value; + throw new Error(`${key} must be a string`); +}; + new AccountBaselineStack(app, "account-baseline", { stackName: "seahaven-account-baseline", env: { account: ACCOUNT, region: "us-east-1" }, @@ -248,6 +255,23 @@ const terraformSubstrateExternalDev = new TerraformSubstrateStack( createOidcProvider: false, enableShocBackendPocRoles: contextBoolean("enableShocBackendPocRoles"), enableShocBackendLiveRoles: contextBoolean("enableShocBackendLiveRoles"), + enableShocFrontendPocRoles: contextBoolean("enableShocFrontendPocRoles"), + enableShocFrontendLiveRoles: contextBoolean("enableShocFrontendLiveRoles"), + shocFrontendPocDistributionId: contextString( + "shocFrontendPocDistributionId", + ), + shocFrontendPocOriginAccessControlId: contextString( + "shocFrontendPocOriginAccessControlId", + ), + shocFrontendPocFunctionName: contextString( + "shocFrontendPocFunctionName", + ), + shocFrontendPocHostedZoneId: contextString( + "shocFrontendPocHostedZoneId", + ), + shocFrontendPocCertificateArn: contextString( + "shocFrontendPocCertificateArn", + ), }, ); diff --git a/cdk.json b/cdk.json index 69c9475..a7bae96 100644 --- a/cdk.json +++ b/cdk.json @@ -19,6 +19,13 @@ "@aws-cdk/core:checkSecretUsage": true, "@aws-cdk/core:target-partitions": ["aws"], "enableShocBackendPocRoles": true, - "enableShocBackendLiveRoles": true + "enableShocBackendLiveRoles": true, + "enableShocFrontendPocRoles": true, + "enableShocFrontendLiveRoles": true, + "shocFrontendPocDistributionId": "E73KH1SPNFL00", + "shocFrontendPocOriginAccessControlId": "E14MP8Z5YRWO93", + "shocFrontendPocFunctionName": "us-east-1shocfrontendtfpocSpaRewrite4B1A4F5F", + "shocFrontendPocHostedZoneId": "Z10433621DH3UOWM8663D", + "shocFrontendPocCertificateArn": "arn:aws:acm:us-east-1:396287094661:certificate/3dbc8c23-3467-47db-9f6c-39236ca11682" } } diff --git a/lib/scp/external-dev-iam-guardrails.json b/lib/scp/external-dev-iam-guardrails.json index 3e049c3..542bafe 100644 --- a/lib/scp/external-dev-iam-guardrails.json +++ b/lib/scp/external-dev-iam-guardrails.json @@ -21,7 +21,10 @@ "Sid": "ProtectShocBoundaries", "Effect": "Deny", "Action": ["iam:CreatePolicyVersion", "iam:SetDefaultPolicyVersion", "iam:DeletePolicy", "iam:DeletePolicyVersion"], - "Resource": "arn:aws:iam::396287094661:policy/shoc-backend-*-boundary", + "Resource": [ + "arn:aws:iam::396287094661:policy/shoc-backend-*-boundary", + "arn:aws:iam::396287094661:policy/shoc-frontend-new-*-deploy-boundary" + ], "Condition": { "ArnNotLike": { "aws:PrincipalArn": ["arn:aws:iam::396287094661:role/OrganizationAccountAccessRole", "arn:aws:iam::396287094661:role/cdk-hnb659fds-*"] } } }, { @@ -63,7 +66,7 @@ "Effect": "Deny", "Action": ["iam:PutRolePolicy", "iam:TagRole", "iam:UntagRole"], "Resource": "arn:aws:iam::396287094661:role/githubdeploy-*", - "Condition": { "ArnNotLike": { "aws:PrincipalArn": ["arn:aws:iam::396287094661:role/OrganizationAccountAccessRole", "arn:aws:iam::396287094661:role/cdk-hnb659fds-*", "arn:aws:iam::396287094661:role/hcptf-shoc-backend-tf-poc", "arn:aws:iam::396287094661:role/hcptf-shoc-backend-dev", "arn:aws:iam::396287094661:role/hcptf-shoc-backend-staging"] } } + "Condition": { "ArnNotLike": { "aws:PrincipalArn": ["arn:aws:iam::396287094661:role/OrganizationAccountAccessRole", "arn:aws:iam::396287094661:role/cdk-hnb659fds-*", "arn:aws:iam::396287094661:role/hcptf-shoc-backend-tf-poc", "arn:aws:iam::396287094661:role/hcptf-shoc-backend-dev", "arn:aws:iam::396287094661:role/hcptf-shoc-backend-staging", "arn:aws:iam::396287094661:role/hcptf-shoc-frontend-new-tf-poc", "arn:aws:iam::396287094661:role/hcptf-shoc-frontend-new-dev", "arn:aws:iam::396287094661:role/hcptf-shoc-frontend-new-staging"] } } }, { "Sid": "DenyUnmanagedGithubRole", @@ -71,7 +74,7 @@ "Action": ["iam:PutRolePolicy", "iam:TagRole", "iam:UntagRole"], "Resource": "arn:aws:iam::396287094661:role/githubdeploy-*", "Condition": { - "ArnLike": { "aws:PrincipalArn": ["arn:aws:iam::396287094661:role/hcptf-shoc-backend-tf-poc", "arn:aws:iam::396287094661:role/hcptf-shoc-backend-dev", "arn:aws:iam::396287094661:role/hcptf-shoc-backend-staging"] }, + "ArnLike": { "aws:PrincipalArn": ["arn:aws:iam::396287094661:role/hcptf-shoc-backend-*", "arn:aws:iam::396287094661:role/hcptf-shoc-frontend-new-*"] }, "Null": { "aws:ResourceTag/HcpTerraformWorkspace": "true" } } }, @@ -81,7 +84,7 @@ "Action": ["iam:PutRolePolicy", "iam:TagRole", "iam:UntagRole"], "Resource": "arn:aws:iam::396287094661:role/githubdeploy-*", "Condition": { - "ArnLike": { "aws:PrincipalArn": ["arn:aws:iam::396287094661:role/hcptf-shoc-backend-tf-poc", "arn:aws:iam::396287094661:role/hcptf-shoc-backend-dev", "arn:aws:iam::396287094661:role/hcptf-shoc-backend-staging"] }, + "ArnLike": { "aws:PrincipalArn": ["arn:aws:iam::396287094661:role/hcptf-shoc-backend-*", "arn:aws:iam::396287094661:role/hcptf-shoc-frontend-new-*"] }, "StringNotEquals": { "aws:ResourceTag/HcpTerraformWorkspace": "${aws:PrincipalTag/HcpTerraformWorkspace}" } } }, diff --git a/lib/terraform-substrate-stack.ts b/lib/terraform-substrate-stack.ts index c70adfa..7b54179 100644 --- a/lib/terraform-substrate-stack.ts +++ b/lib/terraform-substrate-stack.ts @@ -2,6 +2,7 @@ import * as cdk from "aws-cdk-lib"; import * as cfninc from "aws-cdk-lib/cloudformation-include"; import * as path from "path"; import { Construct } from "constructs"; +import { ShocFrontendResources } from "./terraform-substrate/shoc-frontend-resources"; export interface TerraformSubstrateStackProps extends cdk.StackProps { /** @@ -30,6 +31,25 @@ export interface TerraformSubstrateStackProps extends cdk.StackProps { * resource import, never a normal create/update. */ enableShocBackendLiveRoles?: boolean; + + /** + * Enable the SHOC frontend tf-poc HCP roles after its exact CloudFront + * identifiers and deploy-role guardrails have been reconciled. + */ + enableShocFrontendPocRoles?: boolean; + + /** + * Enable the SHOC frontend dev/staging HCP roles after the existing GitHub + * deploy roles have their exact boundaries and manager tags. + */ + enableShocFrontendLiveRoles?: boolean; + + /** Exact tf-poc site identifiers; empty values keep its roles disabled. */ + shocFrontendPocDistributionId?: string; + shocFrontendPocOriginAccessControlId?: string; + shocFrontendPocFunctionName?: string; + shocFrontendPocHostedZoneId?: string; + shocFrontendPocCertificateArn?: string; } /** @@ -62,7 +82,7 @@ export class TerraformSubstrateStack extends cdk.Stack { ) { super(scope, id, props); - new cfninc.CfnInclude(this, "Substrate", { + const substrate = new cfninc.CfnInclude(this, "Substrate", { templateFile: path.join( __dirname, "terraform-substrate", @@ -77,6 +97,20 @@ export class TerraformSubstrateStack extends cdk.Stack { }, }); + if (props?.env?.account === "396287094661") { + new ShocFrontendResources(this, "ShocFrontend", { + template: substrate, + enablePocRoles: props?.enableShocFrontendPocRoles === true, + enableLiveRoles: props?.enableShocFrontendLiveRoles === true, + pocDistributionId: props?.shocFrontendPocDistributionId ?? "", + pocOriginAccessControlId: + props?.shocFrontendPocOriginAccessControlId ?? "", + pocFunctionName: props?.shocFrontendPocFunctionName ?? "", + pocHostedZoneId: props?.shocFrontendPocHostedZoneId ?? "", + pocCertificateArn: props?.shocFrontendPocCertificateArn ?? "", + }); + } + cdk.Tags.of(this).add("Project", "account-baseline"); cdk.Tags.of(this).add("Owner", "adam@seahavenind.com"); cdk.Tags.of(this).add("ManagedBy", "cdk"); diff --git a/lib/terraform-substrate/shoc-frontend-resources.ts b/lib/terraform-substrate/shoc-frontend-resources.ts new file mode 100644 index 0000000..319d549 --- /dev/null +++ b/lib/terraform-substrate/shoc-frontend-resources.ts @@ -0,0 +1,698 @@ +import * as cdk from "aws-cdk-lib"; +import * as cfninc from "aws-cdk-lib/cloudformation-include"; +import * as iam from "aws-cdk-lib/aws-iam"; +import { CfnTag } from "aws-cdk-lib/core"; +import { Construct } from "constructs"; + +const ACCOUNT_ID = "396287094661"; +const CACHE_POLICY_ID = "658327ea-f89d-4fab-a63d-7e88639e58f6"; +const SHARED_CERTIFICATE_ARN = + "arn:aws:acm:us-east-1:396287094661:certificate/2b78e74f-7b65-4b82-a413-7a498b102f00"; +const EXECUTION_BOUNDARY_ARN = + "arn:aws:iam::396287094661:policy/external-dev-execution-boundary"; +const HCP_PROVIDER_ARN = + "arn:aws:iam::396287094661:oidc-provider/app.terraform.io"; +const GITHUB_PROVIDER_ARN = + "arn:aws:iam::396287094661:oidc-provider/token.actions.githubusercontent.com"; +const FORBIDDEN_POC_IDENTIFIERS = new Set([ + "E2CWLM1AFB964P", + "E30VSIK87N8H64", + "us-east-1shocfrontenddevSpaRewrite58674DB8", + "Z07671212N75U4YLPWZR8", + "E2JDVEZ6EGD49J", + "E1PF5R6QQNBZAI", + "us-east-1shocfrontendstagingSpaRewriteE9C0CBDA", + "Z02602739VQWBWCAGXP4", + "Z02451891BSZD93CMMGDU", + SHARED_CERTIFICATE_ARN, +]); + +interface FrontendEnvironment { + readonly key: "tf-poc" | "dev" | "staging"; + readonly workspace: string; + readonly bucketName: string; + readonly domainName: string; + readonly hostedZoneId: string; + readonly certificateArn: string; + readonly deployRoleName: string; + readonly distributionId: string; + readonly originAccessControlId: string; + readonly functionName: string; + readonly roleCondition: cdk.CfnCondition; + readonly invalidationCondition?: cdk.CfnCondition; +} + +interface ShocFrontendResourcesProps { + readonly template: cfninc.CfnInclude; + readonly enablePocRoles: boolean; + readonly enableLiveRoles: boolean; + readonly pocDistributionId: string; + readonly pocOriginAccessControlId: string; + readonly pocFunctionName: string; + readonly pocHostedZoneId: string; + readonly pocCertificateArn: string; +} + +const retain = (resource: cdk.CfnResource): void => { + resource.cfnOptions.deletionPolicy = cdk.CfnDeletionPolicy.RETAIN; + resource.cfnOptions.updateReplacePolicy = cdk.CfnDeletionPolicy.RETAIN; +}; + +const roleArn = (roleName: string): string => + `arn:aws:iam::${ACCOUNT_ID}:role/${roleName}`; + +const bucketArn = (bucketName: string): string => `arn:aws:s3:::${bucketName}`; + +const distributionArn = (distributionId: string): string => + `arn:aws:cloudfront::${ACCOUNT_ID}:distribution/${distributionId}`; + +const functionArn = (functionName: string): string => + `arn:aws:cloudfront::${ACCOUNT_ID}:function/${functionName}`; + +const originAccessControlArn = (originAccessControlId: string): string => + `arn:aws:cloudfront::${ACCOUNT_ID}:origin-access-control/${originAccessControlId}`; + +const hostedZoneArn = (hostedZoneId: string): string => + `arn:aws:route53:::hostedzone/${hostedZoneId}`; + +const frontendReadPolicy = ( + environment: FrontendEnvironment, +): Record => { + const siteBucketArn = bucketArn(environment.bucketName); + return { + Version: "2012-10-17", + Statement: [ + { + Sid: "CallerIdentity", + Effect: "Allow", + Action: "sts:GetCallerIdentity", + Resource: "*", + }, + { + Sid: "ReadExactSiteBucket", + Effect: "Allow", + Action: [ + "s3:GetAccelerateConfiguration", + "s3:GetBucketAcl", + "s3:GetBucketCORS", + "s3:GetBucketLocation", + "s3:GetBucketLogging", + "s3:GetBucketObjectLockConfiguration", + "s3:GetBucketOwnershipControls", + "s3:GetBucketPolicy", + "s3:GetBucketPolicyStatus", + "s3:GetBucketPublicAccessBlock", + "s3:GetBucketRequestPayment", + "s3:GetBucketTagging", + "s3:GetBucketVersioning", + "s3:GetBucketWebsite", + "s3:GetEncryptionConfiguration", + "s3:GetLifecycleConfiguration", + "s3:GetReplicationConfiguration", + "s3:ListBucket", + ], + Resource: siteBucketArn, + }, + { + Sid: "ReadExactCloudFrontResources", + Effect: "Allow", + Action: [ + "cloudfront:DescribeFunction", + "cloudfront:GetDistribution", + "cloudfront:GetDistributionConfig", + "cloudfront:GetFunction", + "cloudfront:GetOriginAccessControl", + "cloudfront:ListTagsForResource", + ], + Resource: [ + distributionArn(environment.distributionId), + functionArn(environment.functionName), + originAccessControlArn(environment.originAccessControlId), + ], + }, + { + Sid: "ListCloudFrontInventory", + Effect: "Allow", + Action: [ + "cloudfront:ListDistributions", + "cloudfront:ListFunctions", + "cloudfront:ListOriginAccessControls", + ], + Resource: "*", + }, + { + Sid: "ReadManagedCachePolicy", + Effect: "Allow", + Action: "cloudfront:GetCachePolicy", + Resource: `arn:aws:cloudfront::${ACCOUNT_ID}:cache-policy/${CACHE_POLICY_ID}`, + }, + { + Sid: "ListCachePolicies", + Effect: "Allow", + Action: "cloudfront:ListCachePolicies", + Resource: "*", + }, + { + Sid: "ReadExactDeployRole", + Effect: "Allow", + Action: [ + "iam:GetRole", + "iam:GetRolePolicy", + "iam:ListAttachedRolePolicies", + "iam:ListRolePolicies", + "iam:ListRoleTags", + ], + Resource: roleArn(environment.deployRoleName), + }, + { + Sid: "ReadGithubOidcProvider", + Effect: "Allow", + Action: "iam:GetOpenIDConnectProvider", + Resource: GITHUB_PROVIDER_ARN, + }, + { + Sid: "ListOidcProviders", + Effect: "Allow", + Action: "iam:ListOpenIDConnectProviders", + Resource: "*", + }, + { + Sid: "ReadExactCertificate", + Effect: "Allow", + Action: [ + "acm:DescribeCertificate", + "acm:GetCertificate", + "acm:ListTagsForCertificate", + ], + Resource: environment.certificateArn, + }, + { + Sid: "ListCertificates", + Effect: "Allow", + Action: "acm:ListCertificates", + Resource: "*", + }, + { + Sid: "ReadExactDns", + Effect: "Allow", + Action: [ + "route53:GetHostedZone", + "route53:ListResourceRecordSets", + "route53:ListTagsForResource", + ], + Resource: hostedZoneArn(environment.hostedZoneId), + }, + { + Sid: "FindHostedZone", + Effect: "Allow", + Action: ["route53:ListHostedZones", "route53:ListHostedZonesByName"], + Resource: "*", + }, + { + Sid: "ReadDnsChanges", + Effect: "Allow", + Action: "route53:GetChange", + Resource: "arn:aws:route53:::change/*", + }, + ], + }; +}; + +const frontendApplyPolicy = ( + environment: FrontendEnvironment, +): Record => ({ + Version: "2012-10-17", + Statement: [ + { + Sid: "DenyRoleLifecycleAndTrustMutation", + Effect: "Deny", + Action: [ + "iam:AttachRolePolicy", + "iam:CreateRole", + "iam:CreateServiceLinkedRole", + "iam:DeleteRole", + "iam:DeleteRolePermissionsBoundary", + "iam:DeleteRolePolicy", + "iam:DetachRolePolicy", + "iam:PassRole", + "iam:PutRolePermissionsBoundary", + "iam:UpdateAssumeRolePolicy", + "iam:UpdateRole", + "iam:UpdateRoleDescription", + ], + Resource: "*", + }, + { + Sid: "DenyManagedPolicyMutation", + Effect: "Deny", + Action: [ + "iam:CreatePolicy", + "iam:CreatePolicyVersion", + "iam:DeletePolicy", + "iam:DeletePolicyVersion", + "iam:SetDefaultPolicyVersion", + ], + Resource: "*", + }, + { + Sid: "DenyInfrastructureReplacement", + Effect: "Deny", + Action: [ + "cloudfront:CreateDistribution", + "cloudfront:CreateFunction", + "cloudfront:CreateOriginAccessControl", + "cloudfront:DeleteDistribution", + "cloudfront:DeleteFunction", + "cloudfront:DeleteOriginAccessControl", + "cloudfront:PublishFunction", + "cloudfront:UpdateDistribution", + "cloudfront:UpdateFunction", + "cloudfront:UpdateOriginAccessControl", + "s3:CreateBucket", + "s3:DeleteBucket", + "s3:DeleteBucketEncryption", + "s3:DeleteBucketOwnershipControls", + "s3:DeleteBucketPolicy", + "s3:DeleteBucketPublicAccessBlock", + "s3:PutBucketOwnershipControls", + "s3:PutBucketPublicAccessBlock", + "s3:PutBucketVersioning", + "s3:PutEncryptionConfiguration", + ], + Resource: "*", + }, + { + Sid: "DenySecretAccess", + Effect: "Deny", + Action: [ + "kms:Decrypt", + "secretsmanager:*", + "ssm:GetParameter", + "ssm:GetParameters", + "ssm:GetParametersByPath", + ], + Resource: "*", + }, + { + Sid: "LockHcpTerraformWorkspaceTag", + Effect: "Deny", + Action: ["iam:TagRole", "iam:UntagRole"], + Resource: "*", + Condition: { + "ForAnyValue:StringEquals": { + "aws:TagKeys": "HcpTerraformWorkspace", + }, + }, + }, + { + Sid: "TagExactSiteBucket", + Effect: "Allow", + Action: "s3:PutBucketTagging", + Resource: bucketArn(environment.bucketName), + }, + { + Sid: "ReplaceExactBucketPolicy", + Effect: "Allow", + Action: "s3:PutBucketPolicy", + Resource: bucketArn(environment.bucketName), + }, + { + Sid: "TagExactCloudFrontResources", + Effect: "Allow", + Action: ["cloudfront:TagResource", "cloudfront:UntagResource"], + Resource: [ + distributionArn(environment.distributionId), + functionArn(environment.functionName), + ], + }, + { + Sid: "ReplaceExactDeployInlinePolicy", + Effect: "Allow", + Action: "iam:PutRolePolicy", + Resource: roleArn(environment.deployRoleName), + Condition: { + StringEquals: { + "iam:PermissionsBoundary": `arn:aws:iam::${ACCOUNT_ID}:policy/shoc-frontend-new-${environment.key}-deploy-boundary`, + }, + }, + }, + { + Sid: "TagExactDeployRole", + Effect: "Allow", + Action: ["iam:TagRole", "iam:UntagRole"], + Resource: roleArn(environment.deployRoleName), + }, + { + Sid: "ChangeExactSiteAliases", + Effect: "Allow", + Action: "route53:ChangeResourceRecordSets", + Resource: hostedZoneArn(environment.hostedZoneId), + Condition: { + "ForAllValues:StringEquals": { + "route53:ChangeResourceRecordSetsActions": [ + "CREATE", + "DELETE", + "UPSERT", + ], + "route53:ChangeResourceRecordSetsNormalizedRecordNames": [ + environment.domainName, + ], + "route53:ChangeResourceRecordSetsRecordTypes": ["A", "AAAA"], + }, + }, + }, + ], +}); + +const assumeRolePolicy = ( + workspace: string, + runPhase: "plan" | "apply", +): Record => ({ + Version: "2012-10-17", + Statement: [ + { + Effect: "Allow", + Principal: { Federated: HCP_PROVIDER_ARN }, + Action: "sts:AssumeRoleWithWebIdentity", + Condition: { + StringEquals: { + "app.terraform.io:aud": "aws.workload.identity", + "app.terraform.io:sub": + `organization:seahaven:project:seahaven-external-dev:` + + `workspace:${workspace}:run_phase:${runPhase}`, + }, + }, + }, + ], +}); + +const roleTags = ( + environment: FrontendEnvironment, + includeManagerTag: boolean, +): CfnTag[] => { + const tags = [ + { key: "Environment", value: environment.key }, + { key: "Workspace", value: environment.workspace }, + ]; + if (includeManagerTag) { + tags.push({ + key: "HcpTerraformWorkspace", + value: environment.workspace, + }); + } + return tags; +}; + +export class ShocFrontendResources extends Construct { + constructor(scope: Construct, id: string, props: ShocFrontendResourcesProps) { + super(scope, id); + + this.validatePocIdentifiers(props); + + const pocInvalidationCondition = new cdk.CfnCondition( + this, + "HasShocFrontendPocDistribution", + { + expression: cdk.Fn.conditionNot( + cdk.Fn.conditionEquals(props.pocDistributionId, ""), + ), + }, + ); + pocInvalidationCondition.overrideLogicalId( + "HasShocFrontendPocDistribution", + ); + const pocRoleCondition = new cdk.CfnCondition( + this, + "ShouldManageShocFrontendPocRoles", + { + expression: cdk.Fn.conditionAnd( + cdk.Fn.conditionEquals(cdk.Aws.ACCOUNT_ID, ACCOUNT_ID), + cdk.Fn.conditionEquals( + props.enablePocRoles ? "true" : "false", + "true", + ), + cdk.Fn.conditionNot( + cdk.Fn.conditionEquals(props.pocDistributionId, ""), + ), + cdk.Fn.conditionNot( + cdk.Fn.conditionEquals(props.pocOriginAccessControlId, ""), + ), + cdk.Fn.conditionNot( + cdk.Fn.conditionEquals(props.pocFunctionName, ""), + ), + cdk.Fn.conditionNot( + cdk.Fn.conditionEquals(props.pocHostedZoneId, ""), + ), + cdk.Fn.conditionNot( + cdk.Fn.conditionEquals(props.pocCertificateArn, ""), + ), + ), + }, + ); + pocRoleCondition.overrideLogicalId("ShouldManageShocFrontendPocRoles"); + const liveRoleCondition = new cdk.CfnCondition( + this, + "ShouldManageShocFrontendLiveRoles", + { + expression: cdk.Fn.conditionAnd( + cdk.Fn.conditionEquals(cdk.Aws.ACCOUNT_ID, ACCOUNT_ID), + cdk.Fn.conditionEquals( + props.enableLiveRoles ? "true" : "false", + "true", + ), + ), + }, + ); + liveRoleCondition.overrideLogicalId("ShouldManageShocFrontendLiveRoles"); + const externalDevCondition = props.template.getCondition( + "IsExternalDevAccount", + ); + + const environments: FrontendEnvironment[] = [ + { + key: "tf-poc", + workspace: "shoc-frontend-new-tf-poc", + bucketName: "seahaven-shoc-frontend-tf-poc", + domainName: "frontend-tf-poc.seahaven.com", + hostedZoneId: props.pocHostedZoneId, + certificateArn: props.pocCertificateArn, + deployRoleName: "githubdeploy-shoc-frontend-new-tf-poc", + distributionId: props.pocDistributionId, + originAccessControlId: props.pocOriginAccessControlId, + functionName: props.pocFunctionName, + roleCondition: pocRoleCondition, + invalidationCondition: pocInvalidationCondition, + }, + { + key: "dev", + workspace: "shoc-frontend-new-dev", + bucketName: "seahaven-shoc-frontend-dev", + domainName: "dev.seahaven.com", + hostedZoneId: "Z07671212N75U4YLPWZR8", + certificateArn: SHARED_CERTIFICATE_ARN, + deployRoleName: "githubdeploy-shoc-frontend-new-dev", + distributionId: "E2CWLM1AFB964P", + originAccessControlId: "E30VSIK87N8H64", + functionName: "us-east-1shocfrontenddevSpaRewrite58674DB8", + roleCondition: liveRoleCondition, + }, + { + key: "staging", + workspace: "shoc-frontend-new-staging", + bucketName: "seahaven-shoc-frontend-staging", + domainName: "staging.seahaven.com", + hostedZoneId: "Z02602739VQWBWCAGXP4", + certificateArn: SHARED_CERTIFICATE_ARN, + deployRoleName: "githubdeploy-shoc-frontend-new-staging", + distributionId: "E2JDVEZ6EGD49J", + originAccessControlId: "E1PF5R6QQNBZAI", + functionName: "us-east-1shocfrontendstagingSpaRewriteE9C0CBDA", + roleCondition: liveRoleCondition, + }, + ]; + + for (const environment of environments) { + this.addEnvironment(environment, externalDevCondition); + } + } + + private validatePocIdentifiers(props: ShocFrontendResourcesProps): void { + const distributionPattern = /^E[A-Z0-9]+$/; + const functionPattern = /^[A-Za-z0-9_-]+$/; + const hostedZonePattern = /^Z[A-Z0-9]+$/; + const certificatePattern = + /^arn:aws:acm:us-east-1:396287094661:certificate\/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/; + const identifiers = [ + props.pocDistributionId, + props.pocOriginAccessControlId, + props.pocFunctionName, + props.pocHostedZoneId, + props.pocCertificateArn, + ]; + const hasPartialIdentifiers = + identifiers.some((value) => value !== "") && + identifiers.some((value) => value === ""); + if (hasPartialIdentifiers) { + throw new Error( + "All five shocFrontendPoc identifiers must be set together", + ); + } + if ( + props.pocDistributionId !== "" && + (!distributionPattern.test(props.pocDistributionId) || + !distributionPattern.test(props.pocOriginAccessControlId) || + !functionPattern.test(props.pocFunctionName) || + !hostedZonePattern.test(props.pocHostedZoneId) || + !certificatePattern.test(props.pocCertificateArn)) + ) { + throw new Error("Invalid shocFrontendPoc identifier"); + } + if ( + identifiers.some((identifier) => + FORBIDDEN_POC_IDENTIFIERS.has(identifier), + ) + ) { + throw new Error( + "shocFrontendPoc identifiers must not reuse live frontend or backend tf-poc resources", + ); + } + if (props.enablePocRoles && props.pocDistributionId === "") { + throw new Error( + "enableShocFrontendPocRoles requires all five identifiers", + ); + } + } + + private addEnvironment( + environment: FrontendEnvironment, + externalDevCondition: cdk.CfnCondition, + ): void { + const logicalSuffix = + environment.key === "tf-poc" + ? "Poc" + : environment.key.charAt(0).toUpperCase() + environment.key.slice(1); + const siteBucketArn = bucketArn(environment.bucketName); + const exactDistributionArn = distributionArn(environment.distributionId); + const boundaryStatements: unknown[] = [ + { + Sid: "ReadDeploymentBucket", + Effect: "Allow", + Action: [ + "s3:GetBucketLocation", + "s3:GetBucketVersioning", + "s3:ListBucket", + "s3:ListBucketVersions", + ], + Resource: siteBucketArn, + }, + { + Sid: "PublishRollbackAndPruneSiteObjects", + Effect: "Allow", + Action: [ + "s3:DeleteObject", + "s3:DeleteObjectVersion", + "s3:GetObject", + "s3:GetObjectVersion", + "s3:PutObject", + ], + Resource: `${siteBucketArn}/*`, + }, + ]; + const invalidationStatement = { + Sid: "InvalidateExactDistribution", + Effect: "Allow", + Action: ["cloudfront:CreateInvalidation", "cloudfront:GetInvalidation"], + Resource: exactDistributionArn, + }; + boundaryStatements.push( + environment.invalidationCondition === undefined + ? invalidationStatement + : cdk.Fn.conditionIf( + environment.invalidationCondition.logicalId, + invalidationStatement, + cdk.Aws.NO_VALUE, + ), + ); + + const deployBoundary = new iam.CfnManagedPolicy( + this, + `ShocFrontend${logicalSuffix}DeployBoundary`, + { + managedPolicyName: `shoc-frontend-new-${environment.key}-deploy-boundary`, + description: + `Maximum content deployment permissions for ` + + `${environment.deployRoleName}.`, + policyDocument: { + Version: "2012-10-17", + Statement: boundaryStatements, + }, + }, + ); + deployBoundary.cfnOptions.condition = externalDevCondition; + deployBoundary.overrideLogicalId( + `ShocFrontend${logicalSuffix}DeployBoundary`, + ); + retain(deployBoundary); + + const planRole = new iam.CfnRole( + this, + `HcptfShocFrontend${logicalSuffix}PlanRole`, + { + roleName: `${environment.workspace}-plan`.replace( + "shoc-frontend-new", + "hcptf-shoc-frontend-new", + ), + description: `Read-only HCP Terraform plan role for ${environment.workspace}.`, + permissionsBoundary: EXECUTION_BOUNDARY_ARN, + maxSessionDuration: 3600, + assumeRolePolicyDocument: assumeRolePolicy( + environment.workspace, + "plan", + ), + policies: [ + { + policyName: `${environment.workspace}-import-read`, + policyDocument: frontendReadPolicy(environment), + }, + ], + tags: roleTags(environment, false), + }, + ); + planRole.cfnOptions.condition = environment.roleCondition; + planRole.overrideLogicalId(`HcptfShocFrontend${logicalSuffix}PlanRole`); + retain(planRole); + + const applyRole = new iam.CfnRole( + this, + `HcptfShocFrontend${logicalSuffix}ApplyRole`, + { + roleName: environment.workspace.replace( + "shoc-frontend-new", + "hcptf-shoc-frontend-new", + ), + description: `Constrained HCP Terraform apply role for ${environment.workspace}.`, + permissionsBoundary: EXECUTION_BOUNDARY_ARN, + maxSessionDuration: 3600, + assumeRolePolicyDocument: assumeRolePolicy( + environment.workspace, + "apply", + ), + policies: [ + { + policyName: `${environment.workspace}-import-read`, + policyDocument: frontendReadPolicy(environment), + }, + { + policyName: `${environment.workspace}-import-apply`, + policyDocument: frontendApplyPolicy(environment), + }, + ], + tags: roleTags(environment, true), + }, + ); + applyRole.cfnOptions.condition = environment.roleCondition; + applyRole.overrideLogicalId(`HcptfShocFrontend${logicalSuffix}ApplyRole`); + applyRole.addResourceDependency(deployBoundary); + retain(applyRole); + } +}