feat(iam): move seahaven-site exec roles into their own stack (PLAT-225)

Drop the retained roles from the substrate template so the new stack can import them without a second owner.
This commit is contained in:
Adam Moussa 2026-09-24 17:51:17 -04:00
parent 2b2f09a7f4
commit 5b63c703bc
No known key found for this signature in database
5 changed files with 467 additions and 187 deletions

View file

@ -56,6 +56,9 @@ jobs:
uses: Sea-Haven-Industries/.github/.github/workflows/cd-cdk.yaml@e5691d8a7f96ac4d5a841a82975ff0a4354d53ac # v1.0.7
with:
node-version: "24"
# seahaven-site-hcptf stays off this list until `cdk import` adopts the
# live roles. A create fails, and a failed create blocks the import.
# Add the id here in the change that follows a successful import.
stacks: "prod-baseline dynamodb-cmk-prod alarm-topic-prod deploy-substrate-prod terraform-substrate-prod app-web-acl-prod"
stack-name: "seahaven-prod-baseline"
secrets:

View file

@ -32,6 +32,7 @@ are noted):
| `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` | 011934824531, 710827005802 | us-east-1 | Prod/dev HCP substrate: OIDC + shared IAM manager + eight existing `hcptf-*` pairs (DeletionPolicy Retain). Pending PLAT-147 delete after consumer imports. New prod/dev HCP IAM is not added here. |
| `seahaven-site-hcptf` | 011934824531 | us-east-1 | Imported `hcptf-seahaven-site` apply and plan roles (PLAT-225). Adopt with `cdk import` after the site workspace drops them from state. Do not create. |
| `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. Stays (PLAT-148). |
| `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) |

View file

@ -10,6 +10,7 @@ import { DeploySubstrateStack } from "../lib/deploy-substrate-stack";
import { TerraformSubstrateStack } from "../lib/terraform-substrate-stack";
import { DynamoDbCmkStack } from "../lib/dynamodb-cmk-stack";
import { AppWebAclStack } from "../lib/app-web-acl-stack";
import { SeahavenSiteHcptfStack } from "../lib/seahaven-site-hcptf-stack";
import { MemberBaselineStack } from "../lib/member-baseline-stack";
import { OrgGovernanceStack } from "../lib/org-governance-stack";
@ -227,6 +228,14 @@ new AppWebAclStack(app, "app-web-acl-prod", {
env: { account: PROD_ACCOUNT, region: "us-east-1" },
});
// seahaven-site exec roles (PLAT-225). Not part of terraform-substrate.
// First operation is `cdk import`, after the site workspace drops the roles
// from its state. A create fails because the roles already exist.
new SeahavenSiteHcptfStack(app, "seahaven-site-hcptf", {
stackName: "seahaven-site-hcptf",
env: { account: PROD_ACCOUNT, region: "us-east-1" },
});
const terraformSubstrateDev = new TerraformSubstrateStack(
app,
"terraform-substrate-dev",

View file

@ -0,0 +1,451 @@
import * as cdk from "aws-cdk-lib";
import * as iam from "aws-cdk-lib/aws-iam";
import { Construct } from "constructs";
/**
* Prod exec roles for the seahaven-site HCP workspace (PLAT-225).
*
* These roles already exist. Adopt them. Do not create them. The substrate
* template no longer declares them. Its next deploy drops them from that
* stack and retains the live roles.
*
* Import only after that deploy, and after seahaven-site-prod applies its
* `removed` blocks:
*
* npx cdk import seahaven-site-hcptf
*
* Import identifiers are the role names `hcptf-seahaven-site` and
* `hcptf-seahaven-site-plan`. The stack stays off the prod deploy job until
* that import succeeds. A plain create fails because the roles already
* exist, and a failed create blocks the import.
*/
export class SeahavenSiteHcptfStack extends cdk.Stack {
constructor(scope: Construct, id: string, props: cdk.StackProps) {
super(scope, id, props);
const account = this.account;
const deployRole = `arn:aws:iam::${account}:role/tf-managed/githubdeploy-seahaven-site`;
const boundary = `arn:aws:iam::${account}:policy/tf-managed/seahaven-site-githubdeploy-boundary`;
const bucket = "arn:aws:s3:::seahaven-site-prod";
const functionArn = `arn:aws:cloudfront::${account}:function/seahaven-site-prod-directory-index`;
const deployParams = `arn:aws:ssm:us-east-1:${account}:parameter/seahaven-site/deploy/*`;
const wafParam = `arn:aws:ssm:us-east-1:${account}:parameter/seahaven/waf/app-web-acl-arn`;
const githubOidc = `arn:aws:iam::${account}:oidc-provider/token.actions.githubusercontent.com`;
const hcpOidc = `arn:aws:iam::${account}:oidc-provider/app.terraform.io`;
const apply = new iam.CfnRole(this, "ApplyRole", {
roleName: "hcptf-seahaven-site",
maxSessionDuration: 3600,
assumeRolePolicyDocument: trust(hcpOidc, "apply"),
managedPolicyArns: [],
policies: [
{
policyName: "seahaven-site-services",
policyDocument: servicesPolicy(bucket, deployParams, wafParam, githubOidc),
},
{
policyName: "scoped-iam-management",
policyDocument: scopedIamPolicy(account, deployRole, boundary),
},
],
tags: roleTags(),
});
retain(apply);
const plan = new iam.CfnRole(this, "PlanRole", {
roleName: "hcptf-seahaven-site-plan",
maxSessionDuration: 3600,
assumeRolePolicyDocument: trust(hcpOidc, "plan"),
managedPolicyArns: ["arn:aws:iam::aws:policy/job-function/ViewOnlyAccess"],
policies: [
{
policyName: "seahaven-site-plan-refresh",
policyDocument: planPolicy(account, bucket, functionArn, deployParams, wafParam, githubOidc, deployRole),
},
],
tags: roleTags(),
});
retain(plan);
cdk.Tags.of(this).add("Project", "seahaven-site");
cdk.Tags.of(this).add("Owner", "adam@seahavenind.com");
cdk.Tags.of(this).add("ManagedBy", "cdk");
}
}
function retain(role: iam.CfnRole): void {
role.cfnOptions.deletionPolicy = cdk.CfnDeletionPolicy.RETAIN;
role.cfnOptions.updateReplacePolicy = cdk.CfnDeletionPolicy.RETAIN;
}
function roleTags(): cdk.CfnTag[] {
return [
{ key: "Project", value: "seahaven-site" },
{ key: "Owner", value: "adam@seahavenind.com" },
{ key: "ManagedBy", value: "cdk" },
];
}
function trust(providerArn: string, phase: "apply" | "plan"): iam.PolicyDocument {
return iam.PolicyDocument.fromJson({
Version: "2012-10-17",
Statement: [
{
Sid: phase === "apply" ? "HcpApply" : "HcpPlan",
Effect: "Allow",
Action: "sts:AssumeRoleWithWebIdentity",
Principal: { Federated: providerArn },
Condition: {
StringEquals: {
"app.terraform.io:aud": "aws.workload.identity",
"app.terraform.io:sub":
`organization:seahaven:project:seahaven-prod:workspace:seahaven-site-prod:run_phase:${phase}`,
},
},
},
],
});
}
function servicesPolicy(
bucket: string,
deployParams: string,
wafParam: string,
githubOidc: string,
): iam.PolicyDocument {
return iam.PolicyDocument.fromJson({
Version: "2012-10-17",
Statement: [
{
Sid: "OriginBucket",
Effect: "Allow",
Action: "s3:*",
Resource: [bucket, `${bucket}/*`],
},
{
Sid: "ReadGithubOidcProvider",
Effect: "Allow",
Action: "iam:GetOpenIDConnectProvider",
Resource: githubOidc,
},
{
Sid: "CloudFrontManage",
Effect: "Allow",
Action: "cloudfront:*",
Resource: "*",
},
{
Sid: "AcmCreate",
Effect: "Allow",
Action: "acm:RequestCertificate",
Resource: "*",
Condition: { StringEquals: { "aws:RequestTag/Project": "seahaven-site" } },
},
{
Sid: "AcmList",
Effect: "Allow",
Action: ["acm:ListCertificates", "acm:ListTagsForCertificate"],
Resource: "*",
},
{
Sid: "AcmManageTagged",
Effect: "Allow",
Action: [
"acm:AddTagsToCertificate",
"acm:DeleteCertificate",
"acm:DescribeCertificate",
"acm:GetCertificate",
"acm:RemoveTagsFromCertificate",
"acm:RenewCertificate",
],
Resource: "*",
Condition: { StringEquals: { "aws:ResourceTag/Project": "seahaven-site" } },
},
{
Sid: "ReadAppWebAclSsm",
Effect: "Allow",
Action: ["ssm:GetParameter", "ssm:GetParameters"],
Resource: wafParam,
},
{
Sid: "WriteDeployContract",
Effect: "Allow",
Action: [
"ssm:AddTagsToResource",
"ssm:DeleteParameter",
"ssm:GetParameter",
"ssm:GetParameters",
"ssm:ListTagsForResource",
"ssm:PutParameter",
"ssm:RemoveTagsFromResource",
],
Resource: deployParams,
},
{
Sid: "DescribeParameters",
Effect: "Allow",
Action: "ssm:DescribeParameters",
Resource: "*",
},
{
Sid: "ReadWafWebAcl",
Effect: "Allow",
Action: [
"wafv2:GetWebACL",
"wafv2:GetWebACLForResource",
"wafv2:ListResourcesForWebACL",
"wafv2:ListWebACLs",
],
Resource: "*",
},
],
});
}
function scopedIamPolicy(account: string, deployRole: string, boundary: string): iam.PolicyDocument {
return iam.PolicyDocument.fromJson({
Version: "2012-10-17",
Statement: [
{
Sid: "DenyUntaggedCreatePolicy",
Effect: "Deny",
Action: "iam:CreatePolicy",
Resource: "*",
Condition: { Null: { "aws:RequestTag/BoundaryFor": "true" } },
},
{
Sid: "DenyOtherCreatePolicy",
Effect: "Deny",
Action: "iam:CreatePolicy",
Resource: "*",
Condition: {
StringNotEquals: { "aws:RequestTag/BoundaryFor": "githubdeploy-seahaven-site" },
},
},
{
Sid: "DenyOtherPolicyVersions",
Effect: "Deny",
Action: [
"iam:CreatePolicyVersion",
"iam:DeletePolicy",
"iam:DeletePolicyVersion",
"iam:SetDefaultPolicyVersion",
],
NotResource: boundary,
},
{
Sid: "CreateDeployBoundary",
Effect: "Allow",
Action: "iam:CreatePolicy",
Resource: "*",
Condition: {
StringEquals: { "aws:RequestTag/BoundaryFor": "githubdeploy-seahaven-site" },
},
},
{
Sid: "ManageDeployBoundary",
Effect: "Allow",
Action: [
"iam:CreatePolicyVersion",
"iam:DeletePolicy",
"iam:DeletePolicyVersion",
"iam:GetPolicy",
"iam:GetPolicyVersion",
"iam:TagPolicy",
],
Resource: boundary,
},
{
Sid: "WriteDeployRoles",
Effect: "Allow",
Action: [
"iam:AttachRolePolicy",
"iam:DeleteRolePolicy",
"iam:DetachRolePolicy",
"iam:PutRolePolicy",
"iam:TagRole",
"iam:UntagRole",
"iam:UpdateAssumeRolePolicy",
"iam:UpdateRole",
"iam:UpdateRoleDescription",
],
Resource: deployRole,
},
{
Sid: "PutDeployRoleBoundary",
Effect: "Allow",
Action: "iam:PutRolePermissionsBoundary",
Resource: deployRole,
Condition: { StringEquals: { "iam:PermissionsBoundary": boundary } },
},
{
Sid: "IamReadOnly",
Effect: "Allow",
Action: [
"iam:GetPolicy",
"iam:GetPolicyVersion",
"iam:GetRole",
"iam:GetRolePolicy",
"iam:ListAttachedRolePolicies",
"iam:ListInstanceProfilesForRole",
"iam:ListPolicies",
"iam:ListPolicyVersions",
"iam:ListRolePolicies",
"iam:ListRoleTags",
"iam:ListRoles",
],
Resource: "*",
},
{
Sid: "DenySelfMutation",
Effect: "Deny",
Action: [
"iam:AttachRolePolicy",
"iam:DeleteRole",
"iam:DeleteRolePolicy",
"iam:DeleteRolePermissionsBoundary",
"iam:DetachRolePolicy",
"iam:PutRolePolicy",
"iam:PutRolePermissionsBoundary",
"iam:UpdateAssumeRolePolicy",
"iam:UpdateRole",
"iam:UpdateRoleDescription",
],
Resource: [
`arn:aws:iam::${account}:role/hcptf-*`,
`arn:aws:iam::${account}:role/github-cfn-execution-role`,
`arn:aws:iam::${account}:role/githubdeploy-*`,
`arn:aws:iam::${account}:role/cdk-hnb659fds-*`,
`arn:aws:iam::${account}:role/OrganizationAccountAccessRole`,
`arn:aws:iam::${account}:role/seahaven-*`,
],
},
{
Sid: "DenyBoundaryTampering",
Effect: "Deny",
Action: ["iam:DeleteRolePermissionsBoundary", "iam:DeleteUserPermissionsBoundary"],
Resource: [`arn:aws:iam::${account}:role/*`, `arn:aws:iam::${account}:user/*`],
},
{
Sid: "DenyBoundaryPolicyEdit",
Effect: "Deny",
Action: [
"iam:CreatePolicyVersion",
"iam:DeletePolicy",
"iam:DeletePolicyVersion",
"iam:SetDefaultPolicyVersion",
],
Resource: `arn:aws:iam::${account}:policy/seahaven-*`,
},
],
});
}
function planPolicy(
account: string,
bucket: string,
functionArn: string,
deployParams: string,
wafParam: string,
githubOidc: string,
deployRole: string,
): iam.PolicyDocument {
return iam.PolicyDocument.fromJson({
Version: "2012-10-17",
Statement: [
{
Sid: "RefreshDeployRole",
Effect: "Allow",
Action: [
"iam:GetRole",
"iam:GetRolePolicy",
"iam:ListAttachedRolePolicies",
"iam:ListRolePolicies",
"iam:ListRoleTags",
],
Resource: [
deployRole,
`arn:aws:iam::${account}:role/hcptf-seahaven-site`,
`arn:aws:iam::${account}:role/hcptf-seahaven-site-plan`,
],
},
{
Sid: "RefreshGithubOidcProvider",
Effect: "Allow",
Action: "iam:GetOpenIDConnectProvider",
Resource: githubOidc,
},
{
Sid: "RefreshManagedPolicies",
Effect: "Allow",
Action: ["iam:GetPolicy", "iam:GetPolicyVersion"],
Resource: "*",
},
{
Sid: "RefreshOriginBucket",
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: [bucket, `${bucket}/*`],
},
{
Sid: "RefreshCloudFront",
Effect: "Allow",
Action: [
"cloudfront:GetDistribution",
"cloudfront:GetDistributionConfig",
"cloudfront:GetOriginAccessControl",
"cloudfront:ListTagsForResource",
],
Resource: "*",
},
{
Sid: "RefreshCloudFrontFunction",
Effect: "Allow",
Action: ["cloudfront:DescribeFunction", "cloudfront:GetFunction"],
Resource: functionArn,
},
{
Sid: "RefreshAcm",
Effect: "Allow",
Action: [
"acm:DescribeCertificate",
"acm:GetCertificate",
"acm:ListCertificates",
"acm:ListTagsForCertificate",
],
Resource: "*",
},
{
Sid: "RefreshAppWebAclSsm",
Effect: "Allow",
Action: ["ssm:GetParameter", "ssm:GetParameters", "ssm:ListTagsForResource"],
Resource: [wafParam, deployParams],
},
{
Sid: "RefreshWafWebAcl",
Effect: "Allow",
Action: ["wafv2:GetWebACL", "wafv2:ListWebACLs"],
Resource: "*",
},
],
});
}

View file

@ -1648,193 +1648,9 @@ Resources:
Resource:
- !Sub "arn:aws:secretsmanager:us-east-1:${AWS::AccountId}:secret:procurement-ingest/web-ui-auth-token-*"
# ---------------------------------------------------------------------------
# seahaven-site-prod (PLAT-91) — static site S3 + CloudFront + ACM + GHA
# content-deploy role. No Lambda → no boundary widen. No Route53 (apex DNS
# stays OOB in mgmt). Plan role: ViewOnly + plan-refresh sidecar.
# ---------------------------------------------------------------------------
HcptfSeahavenSitePlanRole:
Type: AWS::IAM::Role
Condition: IsProdAccount
DeletionPolicy: Retain
UpdateReplacePolicy: Retain
Properties:
RoleName: hcptf-seahaven-site-plan
AssumeRolePolicyDocument:
Version: "2012-10-17"
Statement:
- Effect: Allow
Principal:
Federated: !Sub "arn:aws:iam::${AWS::AccountId}:oidc-provider/app.terraform.io"
Action: sts:AssumeRoleWithWebIdentity
Condition:
StringEquals:
"app.terraform.io:aud": aws.workload.identity
"app.terraform.io:sub": organization:seahaven:project:seahaven-prod:workspace:seahaven-site-prod:run_phase:plan
ManagedPolicyArns:
- arn:aws:iam::aws:policy/job-function/ViewOnlyAccess
Policies:
- PolicyName: seahaven-site-plan-refresh
PolicyDocument:
Version: "2012-10-17"
Statement:
- Sid: RefreshDeployRole
Effect: Allow
Action:
- iam:GetRole
- iam:GetRolePolicy
- iam:ListRolePolicies
- iam:ListAttachedRolePolicies
- iam:ListRoleTags
Resource:
- !Sub "arn:aws:iam::${AWS::AccountId}:role/tf-managed/githubdeploy-seahaven-site"
- Sid: RefreshGithubOidcProvider
Effect: Allow
Action:
- iam:GetOpenIDConnectProvider
Resource:
- !Sub "arn:aws:iam::${AWS::AccountId}:oidc-provider/token.actions.githubusercontent.com"
- Sid: RefreshManagedPolicies
Effect: Allow
Action:
- iam:GetPolicy
- iam:GetPolicyVersion
Resource: "*"
- Sid: RefreshOriginBucket
Effect: Allow
Action:
- s3:Get*
- s3:ListBucket
Resource:
- arn:aws:s3:::seahaven-site-prod
- arn:aws:s3:::seahaven-site-prod/*
- Sid: RefreshCloudFront
Effect: Allow
Action:
- cloudfront:Get*
- cloudfront:List*
Resource: "*"
# aws_cloudfront_function refresh reads DEVELOPMENT via
# DescribeFunction. Get* does not cover that API (PLAT-106).
- Sid: RefreshCloudFrontFunction
Effect: Allow
Action:
- cloudfront:DescribeFunction
Resource:
- !Sub "arn:aws:cloudfront::${AWS::AccountId}:function/seahaven-site-prod-directory-index"
- Sid: RefreshAcm
Effect: Allow
Action:
- acm:DescribeCertificate
- acm:ListCertificates
- acm:ListTagsForCertificate
- acm:GetCertificate
Resource: "*"
- Sid: RefreshAppWebAclSsm
Effect: Allow
Action:
- ssm:GetParameter
- ssm:GetParameters
Resource:
- !Sub "arn:aws:ssm:us-east-1:${AWS::AccountId}:parameter/seahaven/waf/app-web-acl-arn"
- Sid: RefreshWafWebAcl
Effect: Allow
Action:
- wafv2:GetWebACL
- wafv2:ListWebACLs
Resource: "*"
HcptfSeahavenSiteApplyRole:
Type: AWS::IAM::Role
Condition: IsProdAccount
DeletionPolicy: Retain
UpdateReplacePolicy: Retain
Properties:
RoleName: hcptf-seahaven-site
AssumeRolePolicyDocument:
Version: "2012-10-17"
Statement:
- Effect: Allow
Principal:
Federated: !Sub "arn:aws:iam::${AWS::AccountId}:oidc-provider/app.terraform.io"
Action: sts:AssumeRoleWithWebIdentity
Condition:
StringEquals:
"app.terraform.io:aud": aws.workload.identity
"app.terraform.io:sub": organization:seahaven:project:seahaven-prod:workspace:seahaven-site-prod:run_phase:apply
ManagedPolicyArns:
- !Ref HcptfIamManagementPolicy
Policies:
- PolicyName: seahaven-site-services
PolicyDocument:
Version: "2012-10-17"
Statement:
- Sid: OriginBucket
Effect: Allow
Action:
- s3:*
Resource:
- arn:aws:s3:::seahaven-site-prod
- arn:aws:s3:::seahaven-site-prod/*
- Sid: ReadGithubOidcProvider
Effect: Allow
Action:
- iam:GetOpenIDConnectProvider
Resource:
- !Sub "arn:aws:iam::${AWS::AccountId}:oidc-provider/token.actions.githubusercontent.com"
- Sid: CloudFrontManage
Effect: Allow
Action:
- cloudfront:*
Resource: "*"
# RequestCertificate is account-level; pin via RequestTag matching
# provider default_tags (Project=seahaven-site). Post-create manage
# requires the same ResourceTag.
- Sid: AcmCreate
Effect: Allow
Action:
- acm:RequestCertificate
Resource: "*"
Condition:
StringEquals:
"aws:RequestTag/Project": seahaven-site
- Sid: AcmList
Effect: Allow
Action:
- acm:ListCertificates
- acm:ListTagsForCertificate
Resource: "*"
- Sid: AcmManageTagged
Effect: Allow
Action:
- acm:DescribeCertificate
- acm:GetCertificate
- acm:DeleteCertificate
- acm:AddTagsToCertificate
- acm:RemoveTagsFromCertificate
- acm:RenewCertificate
Resource: "*"
Condition:
StringEquals:
"aws:ResourceTag/Project": seahaven-site
# CloudFront web_acl_id is set via UpdateDistribution (cloudfront:*
# above). Read the shared ACL ARN from SSM (PLAT-92) and allow
# WAFv2 describe so plans/applies can validate the association.
- Sid: ReadAppWebAclSsm
Effect: Allow
Action:
- ssm:GetParameter
- ssm:GetParameters
Resource:
- !Sub "arn:aws:ssm:us-east-1:${AWS::AccountId}:parameter/seahaven/waf/app-web-acl-arn"
- Sid: ReadWafWebAcl
Effect: Allow
Action:
- wafv2:GetWebACL
- wafv2:GetWebACLForResource
- wafv2:ListWebACLs
- wafv2:ListResourcesForWebACL
Resource: "*"
# seahaven-site-prod exec roles moved to stack seahaven-site-hcptf (PLAT-225).
# DeletionPolicy on the removed resources was Retain, so dropping them here
# keeps the live roles for that stack to import.
# ---------------------------------------------------------------------------
# meal-order-manager-prod (PLAT-70, imported to the app workspace in PLAT-146).