feat(iam): add frontend Terraform substrate (#133)
Some checks are pending
Deploy / deploy-management (push) Waiting to run
Deploy / deploy-external-dev (push) Waiting to run
Deploy / deploy-security (push) Waiting to run
Deploy / deploy-dev (push) Waiting to run
Deploy / deploy-prod (push) Waiting to run

* feat(iam): add frontend Terraform substrate

* fix(iam): align frontend Terraform substrate

* feat(iam): enable frontend live Terraform roles
This commit is contained in:
Adam Moussa 2026-08-31 02:25:47 +00:00 • committed by GitHub
parent 08191ded4c
commit 6a0713f49d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 829 additions and 8 deletions

View file

@ -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-<env>`, then adds
`HcpTerraformWorkspace=shoc-frontend-new-<env>`. 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**

View file

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

View file

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

View file

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

View file

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

View file

@ -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<string, unknown> => {
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<string, unknown> => ({
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<string, unknown> => ({
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);
}
}