mirror of
https://github.com/Sea-Haven-Industries/seahaven-org-baseline.git
synced 2026-09-30 03:23:15 +00:00
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
This commit is contained in:
parent
08191ded4c
commit
6a0713f49d
6 changed files with 829 additions and 8 deletions
59
README.md
59
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-<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**
|
||||
|
|
|
|||
24
bin/app.ts
24
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",
|
||||
),
|
||||
},
|
||||
);
|
||||
|
||||
|
|
|
|||
9
cdk.json
9
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"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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}" }
|
||||
}
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
698
lib/terraform-substrate/shoc-frontend-resources.ts
Normal file
698
lib/terraform-substrate/shoc-frontend-resources.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue