diff --git a/README.md b/README.md index 717f4a3..9d9f10e 100644 --- a/README.md +++ b/README.md @@ -34,7 +34,7 @@ are noted): | `seahaven-view-access` | 328440206208 | us-east-1 | Identity Center group `view`. Permission set `View` is `ViewOnlyAccess` on all five accounts. No data-plane reads and no assume-role. Membership is outside this stack. | | `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 | Removed from the CDK app and from CD (PLAT-147). Live stacks remain until `scripts/delete-terraform-substrate-prod-dev.sh`. The six imported prod pairs are already forgotten. | -| `seahaven-hcptf` | 011934824531, 710827005802 | us-east-1 | payments-dashboard HCP apply and plan roles, scoped policies, and the Lambda boundary in prod and dev. Prod also owns the imported `hcptf-seahaven-site` apply and plan roles (PLAT-225), with policies at `/tf-managed/`. Trust is pinned per workspace. On the dev and prod deploy jobs. Do not create the roles. | +| `seahaven-hcptf` | 011934824531, 710827005802 | us-east-1 | payments-dashboard HCP apply and plan roles, scoped policies, and the Lambda boundary in prod and dev. Prod also owns the imported `hcptf-seahaven-site` apply and plan roles (PLAT-225) and the created `hcptf-mta-sts` apply and plan roles (PLAT-243), with policies at `/tf-managed/`. Trust is pinned per workspace. On the dev and prod deploy jobs. Do not create the payments-dashboard or seahaven-site roles by hand. | | `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) | @@ -239,14 +239,16 @@ Console / one-shot CLI owns only: Do not manage prod/dev workload IAM (`hcptf-` pairs, Lambda exec roles, `policy/tf-managed/` ceilings) in the console. Do not append -new prod/dev `hcptf-` pairs to this template. New prod/dev HCP stacks -do not need an org-baseline IAM PR. Prod HCP exec roles for -payments-dashboard and seahaven-site both live in `seahaven-hcptf`. -Dev `seahaven-hcptf` is payments-dashboard only. Both account copies -are imported and are on the deploy jobs. The payments deploy creates +new prod/dev `hcptf-` pairs to this template. Prod HCP exec roles for +payments-dashboard, seahaven-site, and mta-sts all live in `seahaven-hcptf` +(`lib/seahaven-hcptf-stack.ts`, `lib/seahaven-site-hcptf-stack.ts`, +`lib/mta-sts-hcptf-stack.ts`). A new prod HCP workspace adds a nested +construct there. Dev `seahaven-hcptf` is payments-dashboard only. Both +account copies are on the deploy jobs. The payments deploy creates `payments-dashboard-hcptf-iam`, `payments-dashboard-hcptf-services`, and `payments-dashboard-hcptf-plan`, and removes the inline policies. Do not -create the roles, the boundary, or the seahaven-site roles. +create the payments-dashboard roles, the boundary, or the seahaven-site +roles by hand; they are imported. The mta-sts roles are a plain create. **External-dev IAM stays in this repo (PLAT-148).** SHOC backend/frontend HCP roles, SHOC deploy/runtime boundaries, `shoc-frontend-resources.ts`, and diff --git a/bin/app.ts b/bin/app.ts index 361f7d9..3951cf9 100644 --- a/bin/app.ts +++ b/bin/app.ts @@ -244,6 +244,7 @@ new AppWebAclStack(app, "app-web-acl-prod", { // payments-dashboard HCP roles, scoped policies, and the Lambda boundary. // Prod also includes the imported seahaven-site apply and plan roles // (PLAT-225). A create fails. Import site with `-c hcptfSiteImport=true`. +// Prod also creates the mta-sts apply and plan roles (PLAT-243). // Trust is pinned per workspace. const hcptfPaymentsImport = contextBoolean("hcptfPaymentsImport"); const hcptfSiteImport = contextBoolean("hcptfSiteImport"); @@ -274,6 +275,7 @@ new SeahavenHcptfStack(app, "seahaven-hcptf", { importExisting: hcptfPaymentsImport, includeSeahavenSite: true, siteImportExisting: hcptfSiteImport, + includeMtaSts: true, }); new SeahavenHcptfStack(app, "seahaven-hcptf-dev", { diff --git a/lib/mta-sts-hcptf-stack.ts b/lib/mta-sts-hcptf-stack.ts new file mode 100644 index 0000000..f3a828f --- /dev/null +++ b/lib/mta-sts-hcptf-stack.ts @@ -0,0 +1,563 @@ +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 mta-sts HCP workspace (PLAT-243). + * + * Nested in the prod seahaven-hcptf stack beside SeahavenSiteRoles. These + * roles are new. Plain CloudFormation create, no import template. + * + * The workspace owns four S3 origin buckets named `mta-sts-prod-`, + * four CloudFront distributions with OACs, four exact-name ACM + * certificates tagged Project=mta-sts, the SSM deploy contract under + * `/mta-sts/deploy/`, and the GitHub content-deploy role + * `githubdeploy-mta-sts` with its boundary. Policy files are published by + * GitHub Actions, not Terraform, so no object-level grants beyond the + * bucket itself are needed here. + * + * CloudFront distribution and ACM writes are gated on Project=mta-sts + * request and resource tags. The workspace provider must set that tag in + * default_tags, or the first apply fails on CreateDistribution. + * + * Inline policies are managed policies at /tf-managed/. Do not rename + * the roles. + */ +export class MtaStsRoles extends Construct { + constructor(scope: Construct, id: string) { + super(scope, id); + + // Overrides the parent stack's Project=payments-dashboard tag. + cdk.Tags.of(this).add("Project", "mta-sts", { priority: 200 }); + + const account = cdk.Stack.of(this).account; + const deployRole = `arn:aws:iam::${account}:role/tf-managed/githubdeploy-mta-sts`; + const boundary = `arn:aws:iam::${account}:policy/tf-managed/mta-sts-githubdeploy-boundary`; + // One ARN covers the four buckets and their objects. `*` spans `/`, so a + // second `mta-sts-prod-*/*` entry is redundant (Access Analyzer flags it). + const buckets = ["arn:aws:s3:::mta-sts-prod-*"]; + const deployParams = `arn:aws:ssm:us-east-1:${account}:parameter/mta-sts/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 iamPolicy = managedPolicy( + this, + "IamPolicy", + "mta-sts-hcptf-iam", + scopedIamPolicy(account, deployRole, boundary), + ); + const services = managedPolicy( + this, + "ServicesPolicy", + "mta-sts-hcptf-services", + servicesPolicy(account, buckets, deployParams, wafParam, githubOidc), + ); + const planRefresh = managedPolicy( + this, + "PlanPolicy", + "mta-sts-hcptf-plan", + planPolicy(buckets, deployParams, wafParam, githubOidc, deployRole, boundary), + ); + + const apply = new iam.CfnRole(this, "ApplyRole", { + roleName: "hcptf-mta-sts", + maxSessionDuration: 3600, + assumeRolePolicyDocument: trust(hcpOidc, "apply"), + managedPolicyArns: [iamPolicy.ref, services.ref], + tags: roleTags(), + }); + retain(apply); + + const plan = new iam.CfnRole(this, "PlanRole", { + roleName: "hcptf-mta-sts-plan", + maxSessionDuration: 3600, + assumeRolePolicyDocument: trust(hcpOidc, "plan"), + managedPolicyArns: [ + "arn:aws:iam::aws:policy/job-function/ViewOnlyAccess", + planRefresh.ref, + ], + tags: roleTags(), + }); + retain(plan); + + const applyArn = new cdk.CfnOutput(this, "ApplyRoleArn", { value: apply.attrArn }); + const planArn = new cdk.CfnOutput(this, "PlanRoleArn", { value: plan.attrArn }); + applyArn.overrideLogicalId("MtaStsApplyRoleArn"); + planArn.overrideLogicalId("MtaStsPlanRoleArn"); + } +} + +function managedPolicy( + scope: Construct, + id: string, + name: string, + policyDocument: object, +): iam.CfnManagedPolicy { + const policy = new iam.CfnManagedPolicy(scope, id, { + managedPolicyName: name, + path: "/tf-managed/", + policyDocument, + }); + retain(policy); + return policy; +} + +function retain(resource: cdk.CfnResource): void { + resource.cfnOptions.deletionPolicy = cdk.CfnDeletionPolicy.RETAIN; + resource.cfnOptions.updateReplacePolicy = cdk.CfnDeletionPolicy.RETAIN; +} + +function roleTags(): cdk.CfnTag[] { + return [ + { key: "Project", value: "mta-sts" }, + { 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:mta-sts-prod:run_phase:${phase}`, + }, + }, + }, + ], + }); +} + +function servicesPolicy( + account: string, + buckets: string[], + deployParams: string, + wafParam: string, + githubOidc: string, +): object { + const distributions = `arn:aws:cloudfront::${account}:distribution/*`; + const oacs = `arn:aws:cloudfront::${account}:origin-access-control/*`; + return { + Version: "2012-10-17", + Statement: [ + { + Sid: "OriginBuckets", + Effect: "Allow", + Action: "s3:*", + Resource: buckets, + }, + { + Sid: "ReadGithubOidcProvider", + Effect: "Allow", + Action: "iam:GetOpenIDConnectProvider", + Resource: githubOidc, + }, + { + Sid: "CloudFrontRead", + Effect: "Allow", + Action: [ + "cloudfront:GetDistribution", + "cloudfront:GetDistributionConfig", + "cloudfront:GetInvalidation", + "cloudfront:GetOriginAccessControl", + "cloudfront:ListTagsForResource", + ], + Resource: "*", + }, + { + // The provider calls the CreateDistributionWithTags API, authorized + // as cloudfront:CreateDistribution. That action has no resource type + // and only accepts Resource "*". Request tags come from the + // workspace's default_tags. + Sid: "CloudFrontCreateTagged", + Effect: "Allow", + Action: "cloudfront:CreateDistribution", + Resource: "*", + Condition: { StringEquals: { "aws:RequestTag/Project": "mta-sts" } }, + }, + { + // Tagging at create time, before the distribution has any tags. The + // Null condition keeps this off every distribution that already has + // a Project tag, so another workspace's distribution cannot be + // re-tagged into CloudFrontManageTagged's scope. + Sid: "CloudFrontTagUntagged", + Effect: "Allow", + Action: "cloudfront:TagResource", + Resource: distributions, + Condition: { + StringEquals: { "aws:RequestTag/Project": "mta-sts" }, + Null: { "aws:ResourceTag/Project": "true" }, + }, + }, + { + // Mutation of a distribution another workspace owns is denied by the + // resource tag, the same pattern AcmManageTagged uses. + Sid: "CloudFrontManageTagged", + Effect: "Allow", + Action: [ + "cloudfront:CreateInvalidation", + "cloudfront:DeleteDistribution", + "cloudfront:TagResource", + "cloudfront:UntagResource", + "cloudfront:UpdateDistribution", + ], + Resource: distributions, + Condition: { StringEquals: { "aws:ResourceTag/Project": "mta-sts" } }, + }, + { + // CreateOriginAccessControl has no resource type; Resource "*" only. + Sid: "CloudFrontCreateOac", + Effect: "Allow", + Action: "cloudfront:CreateOriginAccessControl", + Resource: "*", + }, + { + // Origin access controls do not support tags, so the OAC ARN type is + // the tightest available scope. + Sid: "CloudFrontManageOac", + Effect: "Allow", + Action: [ + "cloudfront:DeleteOriginAccessControl", + "cloudfront:UpdateOriginAccessControl", + ], + Resource: oacs, + }, + { + Sid: "AcmCreate", + Effect: "Allow", + Action: "acm:RequestCertificate", + Resource: "*", + Condition: { StringEquals: { "aws:RequestTag/Project": "mta-sts" } }, + }, + { + Sid: "AcmListTags", + Effect: "Allow", + Action: "acm:ListTagsForCertificate", + Resource: "*", + }, + { + // The aws_acm_certificate resource reads with DescribeCertificate and + // reconciles tags with Add and Remove. No GetCertificate, Renew, or + // ListCertificates; those belong to the data source and early renewal. + Sid: "AcmManageTagged", + Effect: "Allow", + Action: [ + "acm:AddTagsToCertificate", + "acm:DeleteCertificate", + "acm:DescribeCertificate", + "acm:RemoveTagsFromCertificate", + ], + Resource: "*", + Condition: { StringEquals: { "aws:ResourceTag/Project": "mta-sts" } }, + }, + { + 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): object { + return { + 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-mta-sts" }, + }, + }, + { + Sid: "DenyOtherPolicyVersions", + Effect: "Deny", + Action: [ + "iam:CreatePolicyVersion", + "iam:DeletePolicy", + "iam:DeletePolicyVersion", + "iam:SetDefaultPolicyVersion", + ], + NotResource: boundary, + }, + { + // Only the boundary ARN may be created. The request tag stays as a + // second gate so the two Deny statements above keep their meaning. + Sid: "CreateDeployBoundary", + Effect: "Allow", + Action: "iam:CreatePolicy", + Resource: boundary, + Condition: { + StringEquals: { "aws:RequestTag/BoundaryFor": "githubdeploy-mta-sts" }, + }, + }, + { + Sid: "ManageDeployBoundary", + Effect: "Allow", + Action: [ + "iam:CreatePolicyVersion", + "iam:DeletePolicy", + "iam:DeletePolicyVersion", + "iam:GetPolicy", + "iam:GetPolicyVersion", + "iam:ListPolicyTags", + "iam:SetDefaultPolicyVersion", + "iam:TagPolicy", + "iam:UntagPolicy", + ], + Resource: boundary, + }, + { + // Fresh role. Creation requires the deploy boundary to be set. + Sid: "CreateDeployRoleWithBoundary", + Effect: "Allow", + Action: "iam:CreateRole", + Resource: deployRole, + Condition: { StringEquals: { "iam:PermissionsBoundary": boundary } }, + }, + { + Sid: "WriteDeployRoles", + Effect: "Allow", + Action: [ + "iam:AttachRolePolicy", + "iam:DeleteRole", + "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( + buckets: string[], + deployParams: string, + wafParam: string, + githubOidc: string, + deployRole: string, + boundary: string, +): object { + return { + Version: "2012-10-17", + Statement: [ + { + Sid: "RefreshDeployRole", + Effect: "Allow", + Action: [ + "iam:GetRole", + "iam:GetRolePolicy", + "iam:ListAttachedRolePolicies", + "iam:ListRolePolicies", + "iam:ListRoleTags", + ], + Resource: deployRole, + }, + { + Sid: "RefreshGithubOidcProvider", + Effect: "Allow", + Action: "iam:GetOpenIDConnectProvider", + Resource: githubOidc, + }, + { + // The boundary is the only managed policy in Terraform state. + // ViewOnlyAccess carries iam:List* but not GetPolicy or + // GetPolicyVersion, so those are granted here on the exact ARN. + Sid: "RefreshDeployBoundary", + Effect: "Allow", + Action: [ + "iam:GetPolicy", + "iam:GetPolicyVersion", + "iam:ListPolicyTags", + "iam:ListPolicyVersions", + ], + Resource: boundary, + }, + { + Sid: "RefreshOriginBuckets", + 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: buckets, + }, + { + Sid: "RefreshCloudFront", + Effect: "Allow", + Action: [ + "cloudfront:GetDistribution", + "cloudfront:GetDistributionConfig", + "cloudfront:GetOriginAccessControl", + "cloudfront:ListTagsForResource", + ], + Resource: "*", + }, + { + Sid: "RefreshAcm", + Effect: "Allow", + Action: ["acm:DescribeCertificate", "acm:ListTagsForCertificate"], + Resource: "*", + }, + { + Sid: "RefreshSsm", + Effect: "Allow", + Action: ["ssm:GetParameter", "ssm:GetParameters", "ssm:ListTagsForResource"], + Resource: [wafParam, deployParams], + }, + { + // DescribeParameters accepts only Resource "*". The AWS provider + // calls it while refreshing aws_ssm_parameter. + Sid: "DescribeParameters", + Effect: "Allow", + Action: "ssm:DescribeParameters", + Resource: "*", + }, + { + Sid: "RefreshWafWebAcl", + Effect: "Allow", + Action: ["wafv2:GetWebACL", "wafv2:ListWebACLs"], + Resource: "*", + }, + ], + }; +} diff --git a/lib/seahaven-hcptf-stack.ts b/lib/seahaven-hcptf-stack.ts index 93121ee..92a0daf 100644 --- a/lib/seahaven-hcptf-stack.ts +++ b/lib/seahaven-hcptf-stack.ts @@ -2,12 +2,13 @@ import * as cdk from "aws-cdk-lib"; import * as iam from "aws-cdk-lib/aws-iam"; import { Construct } from "constructs"; import { HcptfPolicyAspect } from "./hcptf-policy-aspect"; +import { MtaStsRoles } from "./mta-sts-hcptf-stack"; import { SeahavenSiteRoles } from "./seahaven-site-hcptf-stack"; /** * HCP exec roles. One stack per account. Prod is 011934824531 and also - * hosts the seahaven-site apply and plan roles. Dev is 710827005802 and - * stays payments-dashboard only. + * hosts the seahaven-site and mta-sts apply and plan roles. Dev is + * 710827005802 and stays payments-dashboard only. * * payments-dashboard: workspace payments-dashboard-prod, project * seahaven-prod, or workspace payments-dashboard-dev, project seahaven-dev. @@ -48,6 +49,8 @@ export interface SeahavenHcptfStackProps extends cdk.StackProps { * `-c hcptfSiteImport=true`. Omits site role tags and site outputs. */ siteImportExisting?: boolean; + /** Prod only. Fold mta-sts exec roles into this stack (PLAT-243). */ + includeMtaSts?: boolean; } export class SeahavenHcptfStack extends cdk.Stack { @@ -62,6 +65,9 @@ export class SeahavenHcptfStack extends cdk.Stack { importExisting: props.siteImportExisting === true, }); } + if (props.includeMtaSts) { + new MtaStsRoles(this, "MtaSts"); + } cdk.Aspects.of(this).add(new HcptfPolicyAspect(props.importExisting === true)); } }