feat(iam): add seahaven-hcptf and managed site policies (PLAT-79) (#174)
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

Add seahaven-hcptf in prod and dev for the imported payments-dashboard HCP roles and Lambda boundary, and move seahaven-site-hcptf inline policies to managed policies.
This commit is contained in:
Adam Moussa 2026-10-01 21:22:44 -04:00 • committed by GitHub
parent 86666b8d0b
commit c6de4e0c7f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 1448 additions and 43 deletions

View file

@ -47,7 +47,9 @@ jobs:
uses: Sea-Haven-Industries/.github/.github/workflows/cd-cdk.yaml@e5691d8a7f96ac4d5a841a82975ff0a4354d53ac # v1.0.7
with:
node-version: "24"
stacks: "dev-baseline deploy-substrate-dev"
# seahaven-hcptf-dev was imported. The deploy creates the three managed
# policies and removes the inline policies. Do not create the roles.
stacks: "dev-baseline deploy-substrate-dev seahaven-hcptf-dev"
stack-name: "seahaven-dev-baseline"
secrets:
deploy-role-arn: ${{ secrets.AWS_DEPLOY_ROLE_ARN_DEV }}
@ -57,7 +59,9 @@ jobs:
with:
node-version: "24"
# seahaven-site-hcptf was imported after the roles left terraform-substrate.
stacks: "prod-baseline dynamodb-cmk-prod alarm-topic-prod deploy-substrate-prod app-web-acl-prod seahaven-site-hcptf"
# seahaven-hcptf was imported. The deploy creates the three managed
# policies and removes the inline policies. Do not create the roles.
stacks: "prod-baseline dynamodb-cmk-prod alarm-topic-prod deploy-substrate-prod app-web-acl-prod seahaven-site-hcptf seahaven-hcptf"
stack-name: "seahaven-prod-baseline"
secrets:
deploy-role-arn: ${{ secrets.AWS_DEPLOY_ROLE_ARN_PROD }}

View file

@ -33,7 +33,8 @@ are noted):
| `seahaven-engineering-access` | 328440206208 | us-east-1 | Identity Center group `engineering`. `EngineeringProd` reads payments-dashboard configuration and seahaven-site in 011934824531. `EngineeringDev` has no allow (PLAT-235, PLAT-236). |
| `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-site-hcptf` | 011934824531 | us-east-1 | Imported `hcptf-seahaven-site` apply and plan roles (PLAT-225). On the prod deploy job. Do not create. |
| `seahaven-site-hcptf` | 011934824531 | us-east-1 | Imported `hcptf-seahaven-site` apply and plan roles (PLAT-225). Policies are managed at `/tf-managed/`. On the prod deploy job. 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. Trust is pinned to `payments-dashboard-prod` in project `seahaven-prod`, and to `payments-dashboard-dev` in project `seahaven-dev`. Roles and boundary are imported. On the dev and prod deploy jobs. |
| `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) |
@ -77,6 +78,9 @@ the TypeScript source — no separate compile step needed for `cdk synth` /
| `dynamodb-cmk-prod` | `seahaven-dynamodb-cmk` | 011934824531 | us-east-1 | `lib/dynamodb-cmk-stack.ts` |
| `alarm-topic-prod` | `seahaven-alarm-topic` | 011934824531 | us-east-1 | `lib/alarm-topic-stack.ts` |
| `app-web-acl-prod` | `seahaven-app-web-acl` | 011934824531 | us-east-1 | `lib/app-web-acl-stack.ts` |
| `seahaven-site-hcptf` | `seahaven-site-hcptf` | 011934824531 | us-east-1 | `lib/seahaven-site-hcptf-stack.ts` |
| `seahaven-hcptf` | `seahaven-hcptf` | 011934824531 | us-east-1 | `lib/seahaven-hcptf-stack.ts` |
| `seahaven-hcptf-dev` | `seahaven-hcptf` | 710827005802 | us-east-1 | `lib/seahaven-hcptf-stack.ts` |
Member-account stacks deploy with per-account credentials — the CD workflow
runs one job per account, each assuming that account's OIDC deploy role. Local
@ -236,7 +240,12 @@ Console / one-shot CLI owns only:
Do not manage prod/dev workload IAM (`hcptf-<stack>` pairs, Lambda exec
roles, `policy/tf-managed/<stack>` ceilings) in the console. Do not append
new prod/dev `hcptf-<stack>` pairs to this template. New prod/dev HCP stacks
do not need an org-baseline IAM PR.
do not need an org-baseline IAM PR. payments-dashboard HCP roles and
the Lambda boundary are stack `seahaven-hcptf` in prod and dev. Both copies
are imported and are on the deploy jobs. The 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 or the boundary.
**External-dev IAM stays in this repo (PLAT-148).** SHOC backend/frontend HCP
roles, SHOC deploy/runtime boundaries, `shoc-frontend-resources.ts`, and

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 { SeahavenHcptfStack } from "../lib/seahaven-hcptf-stack";
import { SeahavenSiteHcptfStack } from "../lib/seahaven-site-hcptf-stack";
import { MemberBaselineStack } from "../lib/member-baseline-stack";
import { OrgGovernanceStack } from "../lib/org-governance-stack";
@ -232,11 +233,62 @@ new AppWebAclStack(app, "app-web-acl-prod", {
// seahaven-site exec roles (PLAT-225). Not part of terraform-substrate.
// Imported. On the prod deploy job. A create fails because the roles already exist.
// Inline policies are managed policies at /tf-managed/. Do not recreate the roles.
new SeahavenSiteHcptfStack(app, "seahaven-site-hcptf", {
stackName: "seahaven-site-hcptf",
env: { account: PROD_ACCOUNT, region: "us-east-1" },
});
// payments-dashboard HCP roles, scoped policies, and the Lambda boundary.
// The same three names already exist in prod and dev. Import them with
// `-c hcptfPaymentsImport=true`. A create fails. Neither stack is on a
// deploy job until that import succeeds. Trust is pinned per workspace.
const hcptfPaymentsImport = contextBoolean("hcptfPaymentsImport");
const paymentsSecrets = (
account: string,
suffixes: readonly string[],
): string[] =>
suffixes.map(
(suffix) =>
`arn:aws:secretsmanager:us-east-1:${account}:secret:payments-dashboard/${suffix}`,
);
new SeahavenHcptfStack(app, "seahaven-hcptf", {
stackName: "seahaven-hcptf",
env: { account: PROD_ACCOUNT, region: "us-east-1" },
hcpProject: "seahaven-prod",
hcpWorkspace: "payments-dashboard-prod",
dynamodbCmkArn:
"arn:aws:kms:us-east-1:011934824531:key/be5fa4cb-c546-40fe-a13d-c7bec79f5d12",
secretArns: paymentsSecrets(PROD_ACCOUNT, [
"slack-bot-token-0pAM3S",
"slack-signing-secret-u0T6h8",
"boa-check-mgmt-LEbC65",
"boa-reporting-JoR9lq",
"expense-slack-token-SeMg3s",
"expense-slack-signing-secret-lbb78J",
]),
importExisting: hcptfPaymentsImport,
});
new SeahavenHcptfStack(app, "seahaven-hcptf-dev", {
stackName: "seahaven-hcptf",
env: { account: DEV_ACCOUNT, region: "us-east-1" },
hcpProject: "seahaven-dev",
hcpWorkspace: "payments-dashboard-dev",
dynamodbCmkArn:
"arn:aws:kms:us-east-1:710827005802:key/600997e6-418e-4b7f-9d63-cd42a7505a95",
secretArns: paymentsSecrets(DEV_ACCOUNT, [
"slack-bot-token-KhPaLp",
"slack-signing-secret-CDxsUK",
"boa-check-mgmt-kkEnCw",
"boa-reporting-uMHHVo",
"expense-slack-token-05OZg3",
"expense-slack-signing-secret-DQAoXS",
]),
importExisting: hcptfPaymentsImport,
});
// External-dev already has app.terraform.io federation. Both role gates start
// false in cdk.json: POC is enabled by a normal update; dev/staging only by
// CloudFormation import after Terraform relinquishes those four live roles.

View file

@ -0,0 +1,11 @@
{
"PaymentsDashboardApplyRole": {
"RoleName": "hcptf-payments-dashboard"
},
"PaymentsDashboardPlanRole": {
"RoleName": "hcptf-payments-dashboard-plan"
},
"PaymentsDashboardLambdaBoundary": {
"PolicyArn": "arn:aws:iam::710827005802:policy/tf-managed/payments-dashboard-lambda-boundary"
}
}

View file

@ -0,0 +1,11 @@
{
"PaymentsDashboardApplyRole": {
"RoleName": "hcptf-payments-dashboard"
},
"PaymentsDashboardPlanRole": {
"RoleName": "hcptf-payments-dashboard-plan"
},
"PaymentsDashboardLambdaBoundary": {
"PolicyArn": "arn:aws:iam::011934824531:policy/tf-managed/payments-dashboard-lambda-boundary"
}
}

View file

@ -0,0 +1,58 @@
import * as cdk from "aws-cdk-lib";
import * as iam from "aws-cdk-lib/aws-iam";
import { IConstruct } from "constructs";
/** IAM managed-policy quota, whitespace excluded. */
const MAX_POLICY_CHARS = 6144;
/**
* Fails synth when an HCP stack's managed policy document reaches the IAM
* size quota, or when a role in that stack carries an inline policy.
* Register it on seahaven-hcptf and seahaven-site-hcptf only.
*
* `allowInlinePolicies` is only for the one-time `cdk import` template.
* That template has to name the live inline policies so the following
* deploy can delete them. The default template still rejects inline policies.
*/
export class HcptfPolicyAspect implements cdk.IAspect {
constructor(private readonly allowInlinePolicies = false) {}
public visit(node: IConstruct): void {
if (node instanceof iam.CfnManagedPolicy) {
const size = JSON.stringify(node.policyDocument).replace(/\s/g, "").length;
if (size >= MAX_POLICY_CHARS) {
cdk.Annotations.of(node).addError(
`managed policy document is ${size} characters, whitespace excluded (limit ${MAX_POLICY_CHARS})`,
);
}
}
if (node instanceof iam.CfnRole) {
const policies = node.policies;
if (Array.isArray(policies)) {
for (const policy of policies) {
if (cdk.Token.isUnresolved(policy) || !("policyDocument" in policy)) {
continue;
}
const size = JSON.stringify(policy.policyDocument)
.replace(/\s/g, "")
.length;
if (size >= MAX_POLICY_CHARS) {
cdk.Annotations.of(node).addError(
`inline policy document is ${size} characters, whitespace excluded (limit ${MAX_POLICY_CHARS})`,
);
}
}
if (!this.allowInlinePolicies && policies.length > 0) {
cdk.Annotations.of(node).addError(
"inline policy is not allowed on this role",
);
}
}
}
if (node instanceof iam.CfnPolicy) {
cdk.Annotations.of(node).addError("inline policy is not allowed in this stack");
}
}
}

1232
lib/seahaven-hcptf-stack.ts Normal file

File diff suppressed because it is too large Load diff

View file

@ -1,6 +1,7 @@
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";
/**
* Prod exec roles for the seahaven-site HCP workspace (PLAT-225).
@ -10,7 +11,9 @@ import { Construct } from "constructs";
* The stack is on the prod deploy job.
*
* Import identifiers were the role names `hcptf-seahaven-site` and
* `hcptf-seahaven-site-plan`.
* `hcptf-seahaven-site-plan`. Construct ids stay ApplyRole and PlanRole.
* Inline policies are managed policies at /tf-managed/. Do not rename
* the roles.
*/
export class SeahavenSiteHcptfStack extends cdk.Stack {
constructor(scope: Construct, id: string, props: cdk.StackProps) {
@ -26,21 +29,39 @@ export class SeahavenSiteHcptfStack extends cdk.Stack {
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",
"seahaven-site-hcptf-iam",
scopedIamPolicy(account, deployRole, boundary),
);
const services = managedPolicy(
this,
"ServicesPolicy",
"seahaven-site-hcptf-services",
servicesPolicy(bucket, deployParams, wafParam, githubOidc),
);
const planRefresh = managedPolicy(
this,
"PlanPolicy",
"seahaven-site-hcptf-plan",
planPolicy(
account,
bucket,
functionArn,
deployParams,
wafParam,
githubOidc,
deployRole,
boundary,
),
);
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),
},
],
managedPolicyArns: [iamPolicy.ref, services.ref],
tags: roleTags(),
});
retain(apply);
@ -49,35 +70,42 @@ export class SeahavenSiteHcptfStack extends cdk.Stack {
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,
boundary,
),
},
managedPolicyArns: [
"arn:aws:iam::aws:policy/job-function/ViewOnlyAccess",
planRefresh.ref,
],
tags: roleTags(),
});
retain(plan);
new cdk.CfnOutput(this, "ApplyRoleArn", { value: apply.attrArn });
new cdk.CfnOutput(this, "PlanRoleArn", { value: plan.attrArn });
cdk.Tags.of(this).add("Project", "seahaven-site");
cdk.Tags.of(this).add("Owner", "adam@seahavenind.com");
cdk.Tags.of(this).add("ManagedBy", "cdk");
cdk.Aspects.of(this).add(new HcptfPolicyAspect());
}
}
function retain(role: iam.CfnRole): void {
role.cfnOptions.deletionPolicy = cdk.CfnDeletionPolicy.RETAIN;
role.cfnOptions.updateReplacePolicy = cdk.CfnDeletionPolicy.RETAIN;
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[] {
@ -114,8 +142,8 @@ function servicesPolicy(
deployParams: string,
wafParam: string,
githubOidc: string,
): iam.PolicyDocument {
return iam.PolicyDocument.fromJson({
): object {
return {
Version: "2012-10-17",
Statement: [
{
@ -201,11 +229,11 @@ function servicesPolicy(
Resource: "*",
},
],
});
};
}
function scopedIamPolicy(account: string, deployRole: string, boundary: string): iam.PolicyDocument {
return iam.PolicyDocument.fromJson({
function scopedIamPolicy(account: string, deployRole: string, boundary: string): object {
return {
Version: "2012-10-17",
Statement: [
{
@ -343,7 +371,7 @@ function scopedIamPolicy(account: string, deployRole: string, boundary: string):
Resource: `arn:aws:iam::${account}:policy/seahaven-*`,
},
],
});
};
}
function planPolicy(
@ -355,8 +383,8 @@ function planPolicy(
githubOidc: string,
deployRole: string,
boundary: string,
): iam.PolicyDocument {
return iam.PolicyDocument.fromJson({
): object {
return {
Version: "2012-10-17",
Statement: [
{
@ -467,5 +495,5 @@ function planPolicy(
Resource: "*",
},
],
});
};
}