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: "*", }, ], }; }