Merge branch 'main' into cursor/cloud-agent-node-docs-39f3

This commit is contained in:
Adam Moussa 2026-09-28 13:58:47 -04:00
commit 4b6425f568
No known key found for this signature in database
10 changed files with 983 additions and 247 deletions

View file

@ -12,3 +12,47 @@ jobs:
uses: Sea-Haven-Industries/.github/.github/workflows/ci-typescript-cdk.yaml@e5691d8a7f96ac4d5a841a82975ff0a4354d53ac # v1.0.7
with:
node-version: "24"
iam-policy-check:
runs-on: ubuntu-latest
timeout-minutes: 30
permissions:
id-token: write
contents: read
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
fetch-depth: 0
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version: "24"
cache: npm
- name: Install dependencies
run: npm ci
- name: Synthesize organization policies
run: npx cdk synth org-governance -o cdk.out --quiet
- name: Synthesize base-branch organization policies
run: |
git fetch origin main
git worktree add --detach /tmp/iam-base origin/main
npm ci --prefix /tmp/iam-base
(cd /tmp/iam-base && npx cdk synth org-governance -o /tmp/iam-base-out --quiet)
- name: Configure AWS credentials
id: aws-creds
continue-on-error: true
uses: aws-actions/configure-aws-credentials@e1253824e5c10ff9df46874f81ed3ec929e19cfd # v6.3.0
with:
role-to-assume: arn:aws:iam::328440206208:role/githubdeploy-seahaven-org-baseline-policy-check
aws-region: us-east-1 # pragma: allowlist secret
- name: Note skipped analyzer credentials
if: steps.aws-creds.outcome != 'success'
run: echo "::warning title=Access Analyzer skipped::OIDC assume-role did not succeed, so ValidatePolicy and CheckNoNewAccess did not run. The skip stays until githubdeploy-seahaven-org-baseline-policy-check is deployed."
- name: Check IAM policies
run: python3 scripts/check_iam_policies.py --cdk-out cdk.out --base-cdk-out /tmp/iam-base-out --base-repo /tmp/iam-base --self-test

View file

@ -22,7 +22,7 @@ jobs:
uses: Sea-Haven-Industries/.github/.github/workflows/cd-cdk.yaml@e5691d8a7f96ac4d5a841a82975ff0a4354d53ac # v1.0.7
with:
node-version: "24"
stacks: "account-baseline dynamodb-cmk regional-baseline-us-west-2 regional-baseline-us-east-2 backup-offsite backup org-governance"
stacks: "account-baseline dynamodb-cmk regional-baseline-us-west-2 regional-baseline-us-east-2 backup-offsite backup org-governance platform-access"
secrets:
deploy-role-arn: ${{ secrets.AWS_DEPLOY_ROLE_ARN }}

View file

@ -13,6 +13,7 @@ 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";
import { PlatformAccessStack } from "../lib/platform-access-stack";
const ACCOUNT = "328440206208";
const EXTERNAL_DEV_ACCOUNT = "396287094661";
@ -79,6 +80,12 @@ new OrgGovernanceStack(app, "org-governance", {
env: { account: ACCOUNT, region: "us-east-1" },
});
new PlatformAccessStack(app, "platform-access", {
stackName: "seahaven-platform-access",
// Same management-account region as org-governance above.
env: { account: ACCOUNT, region: "us-east-1" }, // pragma: allowlist secret
});
new MemberBaselineStack(app, "external-dev-baseline", {
stackName: "seahaven-external-dev-baseline",
env: { account: EXTERNAL_DEV_ACCOUNT, region: "us-east-1" },

View file

@ -264,6 +264,53 @@ export class CisMonitoring extends Construct {
alarm.addAlarmAction(new cwactions.SnsAction(topic));
}
// SEC-37. Not a CIS control. Same topic as the CIS alarms.
// SCP exemptions for OrganizationAccountAccessRole stay in place.
//
// A 1/1 alarm on every assume would page for each Platform operator
// session and each run of the bootstrap and substrate scripts. CIS 4.1
// treats that single-event paging on a routine path as alert fatigue.
// Successful assumes are excluded only for those callers:
// * the Platform permission set (AWSReservedSSO_Platform_*)
// * session plat-145-hcptf-bootstrap (create-hcptf-bootstrap-roles.sh)
// * session plat-147-tf-substrate (delete-terraform-substrate-prod-dev.sh)
// Any errorCode still counts, including a failed call from those callers.
//
// Residual: roleSessionName is chosen by the caller, so a successful
// assume that copies one of those two session names is not counted.
// The scripts stay on OrganizationAccountAccessRole until the permission
// set is deployed and a real sign-in has assumed the member role.
const orgAdminAssume = new logs.MetricFilter(
this,
"OrganizationAccountAccessRoleAssumeFilter",
{
logGroup,
filterPattern: logs.FilterPattern.literal(
'{ ($.eventName = "AssumeRole") && ($.requestParameters.roleArn = "*OrganizationAccountAccessRole") && (($.errorCode = "*") || (($.userIdentity.arn != "*AWSReservedSSO_Platform_*") && ($.requestParameters.roleSessionName != "plat-145-hcptf-bootstrap") && ($.requestParameters.roleSessionName != "plat-147-tf-substrate"))) }',
),
metricNamespace: "SeahavenSecurity",
metricName: "OrganizationAccountAccessRoleAssume",
metricValue: "1",
defaultValue: 0,
},
);
const orgAdminAlarm = orgAdminAssume
.metric({
statistic: "Sum",
period: cdk.Duration.minutes(5),
})
.createAlarm(this, "OrganizationAccountAccessRoleAssumeAlarm", {
alarmName: "organization-account-access-role-assume",
alarmDescription:
"AssumeRole of OrganizationAccountAccessRole outside the Platform permission set and the two repo script sessions. Failed attempts count for every principal.",
threshold: 1,
comparisonOperator:
cloudwatch.ComparisonOperator.GREATER_THAN_OR_EQUAL_TO_THRESHOLD,
evaluationPeriods: 1,
treatMissingData: cloudwatch.TreatMissingData.NOT_BREACHING,
});
orgAdminAlarm.addAlarmAction(new cwactions.SnsAction(topic));
new cdk.CfnOutput(this, "CisAlarmTopicArn", { value: topic.topicArn });
}
}

View file

@ -134,9 +134,11 @@ Description: >-
# on the synthesized PolicyDocument with ${AWS::AccountId} resolved, and UPDATE
# THE NUMBERS in the same edit.
#
# Shared LambdaExecutionBoundary (legacy ceiling; do not widen): 5986
# characters / 15 statements as of 2026-08-10 (PLAT-100). Headroom 158.
# Leave it unchanged until live roles retarget (PLAT-52 phase 2).
# Shared LambdaExecutionBoundary (floor only; do not widen): 691
# characters / 4 statements as of 2026-09-28 (PLAT-52). Headroom 5453.
# Statements: CloudWatchLogsWrite, CloudWatchLogsDescribe, XRay, Ec2Eni.
# Packed IsProdAccount data-plane statements removed once
# PermissionsBoundaryUsageCount was 0 in prod and dev.
#
# Per-workload policies (floor + own data plane). Compact sizes recorded
# after synth (prod, ${AWS::AccountId}=011934824531):
@ -147,9 +149,10 @@ Description: >-
# seahaven-lambda-execution-boundary-seahaven-site: 1159 / 6 statements
# seahaven-lambda-execution-boundary-seahaven-door-unlock-api: measure after deploy (PLAT-76)
# seahaven-lambda-execution-boundary-paychex-integrations: 3250 / 10 statements (PLAT-228)
# Dev copies are floor-only (691 / 4) via IsProdAccount, except
# meal-order-manager (PLAT-210): DynamoDB/S3/SSM/invoke plus the
# seahaven-dev slack-bot-token ARN. SNS, SQS (Paychex), and SES stay prod.
# The same four floor statements measure 691 / 4 on the dev policies once
# IsProdAccount drops the prod-only statements. meal-order-manager
# (PLAT-210) also keeps DynamoDB/S3/SSM/invoke plus the seahaven-dev
# slack-bot-token ARN. SNS, SQS (Paychex), and SES stay prod.
#
# Guardrail PolicyDocuments also cap at 6,144. Each extra allow-list ARN
# is copied into four Sids in EACH of SamCfnIamManagementPolicy and
@ -214,16 +217,15 @@ Resources:
# intersection of the role's own policies and this boundary, so a misconfigured
# SAM role can never exceed what is listed here.
#
# SCOPING RULE (PLAT-52 phase 1, 2026-08-13). New workloads get their own
# ManagedPolicy seahaven-lambda-execution-boundary-<workload>: the fleet-wide
# floor (CloudWatchLogsWrite / CloudWatchLogsDescribe / XRay / Ec2Eni) plus
# that workload's data plane, derived from ITS OWN template. Do not add new
# data-plane statements to the shared seahaven-lambda-execution-boundary
# document — it is the legacy ceiling for roles not yet retargeted and stays
# unchanged until PermissionsBoundaryUsageCount is 0. INFRA-186 reduced this
# copy to a floor and later migrations packed data plane back into it under
# the 6,144-character cap (PLAT-93 / PLAT-100). Per-workload policies are
# the escape hatch from that cap and from the shared-ceiling residual.
# SCOPING RULE (PLAT-52). Remaining SAM workloads get their own ManagedPolicy
# seahaven-lambda-execution-boundary-<workload>: the four-statement floor
# (CloudWatchLogsWrite / CloudWatchLogsDescribe / XRay / Ec2Eni) plus that
# workload's data plane, derived from its own template. The shared
# seahaven-lambda-execution-boundary document is that same four-statement
# floor. Do not add data-plane statements to it. PermissionsBoundaryUsageCount
# is 0 in prod and dev, so the packed IsProdAccount statements are removed.
# Retiring the SAM allow-list of boundary ARNs in SamCfnIamManagementPolicy
# is a follow-up pull request.
#
# A resource pattern that genuinely CANNOT be scoped keeps its wildcard WITH a
# written justification on the statement: CloudWatchLogsDescribe, XRay and
@ -523,232 +525,20 @@ Resources:
- ec2:DescribeVpcs
Resource: "*"
# ── Shared workload data-plane (PLAT-93 PolicySize consolidation) ───
# Per-workload secret/DDB/S3 SIDs were merged so meal-order-manager
# (PLAT-70) can fit under the 6,144-character managed-policy cap
# without introducing new action wildcards. Exact secret ARNs only.
# End-state isolation remains PLAT-52 / INFRA-187.
#
# WorkloadSecrets covers: afi-backup-monitor (PLAT-56),
# front-integrations (PLAT-72), procurement-ingest (PLAT-86),
# meal-order-manager (PLAT-70).
- !If
- IsProdAccount
- Sid: WorkloadSecrets
Effect: Allow
Action:
- secretsmanager:GetSecretValue
Resource:
- arn:aws:secretsmanager:us-east-1:011934824531:secret:afi-api-key-w0E02a
- arn:aws:secretsmanager:us-east-1:011934824531:secret:afi-slack-webhook-T4oR3G
- arn:aws:secretsmanager:us-east-1:011934824531:secret:front-integrations/front-api-token-UXKv0U
- arn:aws:secretsmanager:us-east-1:011934824531:secret:front-integrations/slack-bot-token-giGfA7
- arn:aws:secretsmanager:us-east-1:011934824531:secret:front-integrations/google-service-account-dqv3Bo
- arn:aws:secretsmanager:us-east-1:011934824531:secret:procurement-ingest/web-ui-auth-token-ApAMmr
- arn:aws:secretsmanager:us-east-1:011934824531:secret:workorder-ingest/shoc-webhook-hmac-puYTcB
- arn:aws:secretsmanager:us-east-1:011934824531:secret:meal-order-manager/slack-bot-token-ZGmSGw
- !Ref AWS::NoValue
# WorkloadDynamoDB: enumerated union of front-integrations CRUD +
# procurement-ingest CRUD/stream actions. Meal-order table ARNs appended.
# Stream actions on non-stream tables are inert at the ceiling.
- !If
- IsProdAccount
- Sid: WorkloadDynamoDB
Effect: Allow
Action:
- dynamodb:GetItem
- dynamodb:PutItem
- dynamodb:UpdateItem
- dynamodb:DeleteItem
- dynamodb:Query
- dynamodb:Scan
- dynamodb:BatchGetItem
- dynamodb:BatchWriteItem
- dynamodb:DescribeTable
- dynamodb:ConditionCheckItem
- dynamodb:GetRecords
- dynamodb:GetShardIterator
- dynamodb:DescribeStream
# ListStreams is a collection API (Resource "*"); ARN-scoping
# it is a silent no-op. Runtime stream consumers use the
# stream ARN via DescribeStream/GetRecords above.
Resource:
- !Sub "arn:aws:dynamodb:us-east-1:${AWS::AccountId}:table/front-sla-alerts"
- !Sub "arn:aws:dynamodb:us-east-1:${AWS::AccountId}:table/front-sla-alerts/index/*"
- !Sub "arn:aws:dynamodb:us-east-1:${AWS::AccountId}:table/purchase-orders"
- !Sub "arn:aws:dynamodb:us-east-1:${AWS::AccountId}:table/purchase-orders/*"
- !Sub "arn:aws:dynamodb:us-east-1:${AWS::AccountId}:table/verified-sites"
- !Sub "arn:aws:dynamodb:us-east-1:${AWS::AccountId}:table/verified-sites/*"
- !Sub "arn:aws:dynamodb:us-east-1:${AWS::AccountId}:table/pending-site-review"
- !Sub "arn:aws:dynamodb:us-east-1:${AWS::AccountId}:table/pending-site-review/*"
- !Sub "arn:aws:dynamodb:us-east-1:${AWS::AccountId}:table/work-orders"
- !Sub "arn:aws:dynamodb:us-east-1:${AWS::AccountId}:table/work-orders/*"
- !Sub "arn:aws:dynamodb:us-east-1:${AWS::AccountId}:table/work-order-comments"
- !Sub "arn:aws:dynamodb:us-east-1:${AWS::AccountId}:table/work-order-comments/*"
- !Sub "arn:aws:dynamodb:us-east-1:${AWS::AccountId}:table/meal-order-manager-orders"
- !Sub "arn:aws:dynamodb:us-east-1:${AWS::AccountId}:table/meal-order-manager-orders/index/*"
- !Ref AWS::NoValue
# ── procurement-ingest remaining data plane + meal-order S3 (PLAT-86/70) ─
# WorkloadS3 keeps the prior ProcurementIngestS3 action list and appends
# meal-order form/reports bucket ARNs (same object CRUD shape).
- !If
- IsProdAccount
- Sid: WorkloadS3
Effect: Allow
Action:
- s3:GetObject*
- s3:GetBucket*
- s3:List*
- s3:PutObject*
- s3:DeleteObject*
- s3:AbortMultipartUpload
Resource:
- !Sub "arn:aws:s3:::po-ingest-emails-${AWS::AccountId}"
- !Sub "arn:aws:s3:::po-ingest-emails-${AWS::AccountId}/*"
- !Sub "arn:aws:s3:::workorder-ingest-emails-${AWS::AccountId}"
- !Sub "arn:aws:s3:::workorder-ingest-emails-${AWS::AccountId}/*"
- !Sub "arn:aws:s3:::meal-order-manager-form-${AWS::AccountId}"
- !Sub "arn:aws:s3:::meal-order-manager-form-${AWS::AccountId}/*"
- !Sub "arn:aws:s3:::meal-order-manager-reports-${AWS::AccountId}"
- !Sub "arn:aws:s3:::meal-order-manager-reports-${AWS::AccountId}/*"
- !Ref AWS::NoValue
- !If
- IsProdAccount
- Sid: ProcurementIngestSqs
Effect: Allow
Action:
- sqs:SendMessage
- sqs:ReceiveMessage
- sqs:DeleteMessage
- sqs:GetQueueAttributes
- sqs:GetQueueUrl
- sqs:ChangeMessageVisibility
Resource:
- !Sub "arn:aws:sqs:us-east-1:${AWS::AccountId}:po-ingest-*"
- !Sub "arn:aws:sqs:us-east-1:${AWS::AccountId}:WorkorderIngestStack-*"
- !Sub "arn:aws:sqs:us-east-1:${AWS::AccountId}:workorder-shoc-emitter-*"
- !Ref AWS::NoValue
- !If
- IsProdAccount
- Sid: ProcurementIngestKms
Effect: Allow
Action:
- kms:Decrypt
- kms:DescribeKey
- kms:Encrypt
- kms:GenerateDataKey*
- kms:ReEncrypt*
Resource:
- arn:aws:kms:us-east-1:011934824531:key/be5fa4cb-c546-40fe-a13d-c7bec79f5d12
- arn:aws:kms:us-east-1:011934824531:key/d10fd1f0-a61a-4405-8568-85e9fd11ba18
- !Ref AWS::NoValue
- !If
- IsProdAccount
- Sid: ProcurementIngestBedrock
Effect: Allow
Action:
- bedrock:InvokeModel
- bedrock:InvokeModelWithResponseStream
Resource:
- !Sub "arn:aws:bedrock:us-east-1:${AWS::AccountId}:inference-profile/us.anthropic.claude-haiku-4-5-20251001-v1:0"
- arn:aws:bedrock:us-east-1::foundation-model/anthropic.claude-haiku-4-5-20251001-v1:0
- arn:aws:bedrock:us-east-2::foundation-model/anthropic.claude-haiku-4-5-20251001-v1:0
- arn:aws:bedrock:us-west-2::foundation-model/anthropic.claude-haiku-4-5-20251001-v1:0
- !Ref AWS::NoValue
# site-alerts publish shared by procurement-ingest alarms and
# meal-order-manager (PLAT-70); no separate MealOrder SNS statement.
- !If
- IsProdAccount
- Sid: ProcurementIngestSns
Effect: Allow
Action:
- sns:Publish
Resource:
- !Sub "arn:aws:sns:us-east-1:${AWS::AccountId}:site-alerts"
- !Ref AWS::NoValue
# ── seahaven-site (PLAT-91) — content-deploy role data plane ────────
# TF creates githubdeploy-seahaven-site under /tf-managed/ with this
# boundary as ceiling. Role policy is S3 sync + CloudFront invalidate
# only; no Lambda. Exact origin bucket + distribution-scoped invalidate.
- !If
- IsProdAccount
- Sid: SeahavenSiteOriginS3
Effect: Allow
Action:
- s3:GetObject
- s3:PutObject
- s3:DeleteObject
- s3:GetObjectTagging
- s3:PutObjectTagging
- s3:ListBucket
- s3:GetBucketLocation
Resource:
- arn:aws:s3:::seahaven-site-prod
- arn:aws:s3:::seahaven-site-prod/*
- !Ref AWS::NoValue
- !If
- IsProdAccount
- Sid: SeahavenSiteCloudFrontInvalidate
Effect: Allow
Action:
- cloudfront:CreateInvalidation
- cloudfront:GetInvalidation
Resource:
- !Sub "arn:aws:cloudfront::${AWS::AccountId}:distribution/*"
- !Ref AWS::NoValue
# ── meal-order-manager (PLAT-70 / PLAT-100) — not covered above ─────
# Secrets → WorkloadSecrets; DynamoDB → WorkloadDynamoDB; S3 → WorkloadS3;
# SNS → ProcurementIngestSns. SSM + Lambda Invoke + execute-api share
# one Sid (scoped Resources only) so PolicySize stays under 6,144 —
# a standalone execute-api Sid is ~243 chars against 241 headroom and
# would fail UPDATE with LimitExceeded. SES stays in its own Sid:
# Resource:"*" must not share a statement with execute-api:Invoke
# (that would allow Invoke on every API in the account).
# Weekly-menu OIDC identity policy pins the API id; boundary pins
# method/path only.
- !If
- IsProdAccount
- Sid: MealOrderManager
Effect: Allow
Action:
- ssm:GetParameter
- lambda:InvokeFunction
- execute-api:Invoke
Resource:
- !Sub "arn:aws:ssm:us-east-1:${AWS::AccountId}:parameter/meal-order-manager/*"
- !Sub "arn:aws:lambda:us-east-1:${AWS::AccountId}:function:meal-order-manager-*"
- !Sub "arn:aws:execute-api:us-east-1:${AWS::AccountId}:*/*/GET/api/publish/settings"
- !Sub "arn:aws:execute-api:us-east-1:${AWS::AccountId}:*/*/POST/api/publish/menu"
- !Ref AWS::NoValue
- !If
- IsProdAccount
- Sid: MealOrderManagerSes
Effect: Allow
Action:
- ses:SendRawEmail
Resource: "*"
- !Ref AWS::NoValue
# ── FURTHER PER-WORKLOAD DATA-PLANE ────────────────────────────────
# Do not add statements here. Remaining SAM stacks: create
# seahaven-lambda-execution-boundary-<stack> below and append its ARN
# to SamCfnIamManagementPolicy only (WIDENING PATH). New HCP stacks
# do not append here. This shared document stays unchanged until live
# roles retarget (PLAT-52 phase 2) and PermissionsBoundaryUsageCount
# reaches 0.
# Do not add statements here. The shared document is the four-statement
# floor (PLAT-52). Remaining SAM stacks use
# seahaven-lambda-execution-boundary-<stack> below. New HCP stacks
# do not append here.
# ---------------------------------------------------------------------------
# Per-workload Lambda execution boundaries (PLAT-52 phase 1)
#
# Each policy is the fleet floor plus that workload's data plane, split from
# the shared document above without editing it. Live roles keep the shared
# ARN until app-repo retargets. Guardrail StringEquals lists include both.
# Floor statements are YAML-anchored on AfiBackupMonitorBoundary; later
# policies alias them. Floor rationale lives on LambdaExecutionBoundary.
# Prod-only data plane stays behind IsProdAccount (same as the shared copy).
# Each policy is the fleet floor plus that workload's data plane.
# Guardrail StringEquals lists still include the shared ARN and each
# per-workload ARN until the allow-list follow-up. Floor statements are
# YAML-anchored on AfiBackupMonitorBoundary; later policies alias them.
# Floor rationale lives on LambdaExecutionBoundary.
# Prod-only data plane on these policies stays behind IsProdAccount.
# ---------------------------------------------------------------------------
AfiBackupMonitorBoundary:
Type: AWS::IAM::ManagedPolicy

View file

@ -260,7 +260,7 @@ export class OrgGovernanceStack extends cdk.Stack {
name: "protect-privileged-roles",
type: "SERVICE_CONTROL_POLICY",
description:
"prod/nonprod: protect break-glass, CDK exec, githubdeploy, and hcptf-bootstrap roles",
"prod/nonprod: protect break-glass, CDK exec, githubdeploy, hcptf-bootstrap, and /platform/ roles",
targetIds: [prodOu.attrId, nonprodOu.attrId],
content: scpContent("protect-privileged-roles"),
});
@ -338,6 +338,33 @@ export class OrgGovernanceStack extends cdk.Stack {
},
},
},
{
Sid: "ProtectPlatformPath",
Effect: "Deny",
Action: [
"iam:CreateRole",
"iam:UpdateAssumeRolePolicy",
"iam:AttachRolePolicy",
"iam:DetachRolePolicy",
"iam:PutRolePolicy",
"iam:DeleteRolePolicy",
"iam:DeleteRole",
"iam:UpdateRole",
"iam:PutRolePermissionsBoundary",
"iam:DeleteRolePermissionsBoundary",
"iam:TagRole",
"iam:UntagRole",
],
Resource: "arn:aws:iam::*:role/platform/*",
Condition: {
ArnNotLike: {
"aws:PrincipalArn": [
"arn:aws:iam::*:role/OrganizationAccountAccessRole",
"arn:aws:iam::*:role/aws-reserved/sso.amazonaws.com/*AWSReservedSSO_Platform_*",
],
},
},
},
{
Sid: "ProtectDelegatedAdminMembership",
Effect: "Deny",

View file

@ -0,0 +1,64 @@
import * as cdk from "aws-cdk-lib";
import * as identitystore from "aws-cdk-lib/aws-identitystore";
import * as sso from "aws-cdk-lib/aws-sso";
import { Construct } from "constructs";
const IDENTITY_CENTER_INSTANCE_ARN =
"arn:aws:sso:::instance/ssoins-722321f42ca610e4";
const IDENTITY_STORE_ID = "d-9067ec8e26";
const MANAGEMENT_ACCOUNT_ID = "328440206208";
/**
* Identity Center group and permission set for platform operators (SEC-37).
*
* Assigned only to the management account. ReadOnlyAccess plus
* sts:AssumeRole on OrganizationAccountAccessRole, so the existing
* bootstrap and teardown scripts keep working after a person uses this
* set. Those scripts still assume OrganizationAccountAccessRole directly.
* Retarget them only after this set is deployed and a real sign-in has
* assumed the member role.
*
* This is not an SCP exemption. /platform/ path denies exempt the
* reserved SSO role name AWSReservedSSO_Platform_* once the set exists.
*/
export class PlatformAccessStack extends cdk.Stack {
constructor(scope: Construct, id: string, props?: cdk.StackProps) {
super(scope, id, props);
const group = new identitystore.CfnGroup(this, "PlatformGroup", {
identityStoreId: IDENTITY_STORE_ID,
displayName: "platform",
description:
"Platform operators. Management account only. Assumes OrganizationAccountAccessRole for bootstrap.",
});
const permissionSet = new sso.CfnPermissionSet(this, "PlatformPermissionSet", {
instanceArn: IDENTITY_CENTER_INSTANCE_ARN,
name: "Platform",
description:
"Read-only in the management account, plus assume OrganizationAccountAccessRole.",
sessionDuration: "PT8H",
managedPolicies: ["arn:aws:iam::aws:policy/ReadOnlyAccess"],
inlinePolicy: {
Version: "2012-10-17",
Statement: [
{
Sid: "AssumeOrganizationAccountAccessRole",
Effect: "Allow",
Action: "sts:AssumeRole",
Resource: "arn:aws:iam::*:role/OrganizationAccountAccessRole",
},
],
},
});
new sso.CfnAssignment(this, "PlatformManagementAssignment", {
instanceArn: IDENTITY_CENTER_INSTANCE_ARN,
permissionSetArn: permissionSet.attrPermissionSetArn,
principalId: group.attrGroupId,
principalType: "GROUP",
targetId: MANAGEMENT_ACCOUNT_ID,
targetType: "AWS_ACCOUNT",
});
}
}

View file

@ -31,6 +31,33 @@
]
}
}
},
{
"Sid": "ProtectPlatformPath",
"Effect": "Deny",
"Action": [
"iam:CreateRole",
"iam:UpdateAssumeRolePolicy",
"iam:AttachRolePolicy",
"iam:DetachRolePolicy",
"iam:PutRolePolicy",
"iam:DeleteRolePolicy",
"iam:DeleteRole",
"iam:UpdateRole",
"iam:PutRolePermissionsBoundary",
"iam:DeleteRolePermissionsBoundary",
"iam:TagRole",
"iam:UntagRole"
],
"Resource": "arn:aws:iam::*:role/platform/*",
"Condition": {
"ArnNotLike": {
"aws:PrincipalArn": [
"arn:aws:iam::*:role/OrganizationAccountAccessRole",
"arn:aws:iam::*:role/aws-reserved/sso.amazonaws.com/*AWSReservedSSO_Platform_*"
]
}
}
}
]
}

669
scripts/check_iam_policies.py Executable file
View file

@ -0,0 +1,669 @@
#!/usr/bin/env python3
"""IAM policy checks for seahaven-org-baseline.
Local invariants always run:
- bootstrap trust templates use StringEquals and do not use StringLike
- the plan refresh template grants no lambda write, including lambda:*
When --cdk-out is set, synthesized service control policies are collected.
SCP diffs run locally. A new or renamed SCP is compared with an empty
baseline, and a base SCP missing from head is treated as deleted. The diff
fails when access widens: a new Allow, a smaller Deny, a larger Deny
NotAction list, or any Condition change. When AWS credentials can call
sts:GetCallerIdentity, each document is sent to IAM Access Analyzer
ValidatePolicy. A new SCP with a ValidatePolicy ERROR fails, because that
error cannot already exist on main. CheckNoNewAccess compares bootstrap
identity policies with the templates in --base-repo. It rejects
SERVICE_CONTROL_POLICY, so SCPs use the local diff. Missing credentials
skip the AWS calls, emit a warning, and still run the local checks.
The substrate template is not scanned. Its afi plan role still has lambda:*
until the import apply replaces it.
"""
from __future__ import annotations
import argparse
import json
import subprocess
import sys
import tempfile
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
BOOTSTRAP = ROOT / "lib" / "hcptf-bootstrap"
PLACEHOLDERS = {
"__ACCOUNT_ID__": "111111111111",
"__HCP_PROJECT__": "seahaven-prod",
"__BOOTSTRAP_WORKSPACE__": "iam-bootstrap-prod",
"__STACK_PREFIX__": "example",
"__STACK_NAME__": "example",
}
LAMBDA_READ_PREFIXES = ("lambda:Get", "lambda:List", "lambda:Describe")
class CheckFailure(Exception):
pass
def fail(message: str) -> None:
raise CheckFailure(message)
def substitute(text: str) -> str:
for key, value in PLACEHOLDERS.items():
text = text.replace(key, value)
return text
def load_json(path: Path) -> dict:
return json.loads(substitute(path.read_text()))
def statement_actions(statement: dict) -> list[str]:
action = statement.get("Action", [])
if isinstance(action, str):
return [action]
return list(action)
def lambda_writes(document: dict) -> list[str]:
found: list[str] = []
statements = document.get("Statement", [])
if isinstance(statements, dict):
statements = [statements]
for statement in statements:
if statement.get("Effect") != "Allow":
continue
for action in statement_actions(statement):
if action in {"*", "lambda:*"}:
found.append(action)
elif action.startswith("lambda:") and not action.startswith(LAMBDA_READ_PREFIXES):
found.append(action)
return found
def check_trust_templates() -> None:
paths = sorted(BOOTSTRAP.glob("trust-*.json.tmpl"))
if not paths:
fail(f"no trust templates in {BOOTSTRAP}")
for path in paths:
text = path.read_text()
if "StringLike" in text:
fail(f"{path.name}: trust must stay StringEquals")
if "StringEquals" not in text:
fail(f"{path.name}: missing StringEquals")
document = load_json(path)
condition = document["Statement"][0]["Condition"]
if "StringLike" in condition or "StringEquals" not in condition:
fail(f"{path.name}: trust condition must be StringEquals")
print(f"invariant ok: {path.name} uses StringEquals")
def check_plan_refresh() -> None:
path = BOOTSTRAP / "plan-refresh-policy.json.tmpl"
writes = lambda_writes(load_json(path))
if writes:
fail(f"{path.name}: plan document grants lambda write {writes}")
print(f"invariant ok: {path.name} has no lambda write")
def bootstrap_documents(root: Path | None = None) -> dict[str, dict]:
bootstrap = (root or ROOT) / "lib" / "hcptf-bootstrap"
if not bootstrap.is_dir():
fail(f"bootstrap template directory does not exist: {bootstrap}")
documents: dict[str, dict] = {}
for path in sorted(bootstrap.glob("*.json.tmpl")):
# Trust documents are resource policies without a Resource element.
# ValidatePolicy rejects that shape. The StringEquals invariant covers them.
if path.name.startswith("trust-"):
continue
documents[path.name] = load_json(path)
if not documents:
fail(f"no bootstrap policy templates in {bootstrap}")
return documents
def synthesized_scps(cdk_out: Path) -> dict[str, dict]:
found: dict[str, dict] = {}
if not cdk_out.is_dir():
fail(f"cdk out directory does not exist: {cdk_out}")
for path in sorted(cdk_out.glob("*.template.json")):
template = json.loads(path.read_text())
for logical, resource in template.get("Resources", {}).items():
if resource.get("Type") != "AWS::Organizations::Policy":
continue
content = resource["Properties"]["Content"]
if isinstance(content, str):
content = json.loads(content)
name = resource["Properties"].get("Name", logical)
found[f"{path.stem}:{name}"] = content
if not found:
fail(f"no service control policies in {cdk_out}")
return found
def aws_available() -> bool:
result = subprocess.run(
["aws", "sts", "get-caller-identity", "--output", "json"],
capture_output=True,
text=True,
)
if result.returncode != 0:
print(
"::warning title=Access Analyzer skipped::sts get-caller-identity failed. "
"ValidatePolicy and CheckNoNewAccess did not run."
)
print("AWS checks skipped: sts get-caller-identity failed", file=sys.stderr)
return False
identity = json.loads(result.stdout)
print(f"AWS checks using account {identity.get('Account')}")
return True
def analyzer(command: list[str], document: dict, extra: list[str]) -> dict:
with tempfile.NamedTemporaryFile("w", suffix=".json") as handle:
json.dump(document, handle)
handle.flush()
result = subprocess.run(
["aws", "accessanalyzer", *command, "--policy-document", f"file://{handle.name}", *extra, "--output", "json"],
capture_output=True,
text=True,
)
if result.returncode != 0:
fail(f"{' '.join(command)} failed: {result.stderr.strip()}")
return json.loads(result.stdout or "{}")
def error_findings(name: str, policy_type: str, document: dict) -> list[tuple[str, str]]:
payload = analyzer(
["validate-policy"],
document,
["--policy-type", policy_type],
)
errors = [
(
str(finding.get("issueCode")),
str(finding.get("findingDetails")),
)
for finding in payload.get("findings", [])
if finding.get("findingType") == "ERROR"
]
return errors
def describe_findings(findings: list[tuple[str, str]]) -> str:
return "; ".join(f"{code}: {details}" for code, details in findings)
def check_no_new_access(existing: dict, new: dict, policy_type: str) -> str:
with tempfile.NamedTemporaryFile("w", suffix=".json") as new_file:
json.dump(new, new_file)
new_file.flush()
with tempfile.NamedTemporaryFile("w", suffix=".json") as existing_file:
json.dump(existing, existing_file)
existing_file.flush()
result = subprocess.run(
[
"aws",
"accessanalyzer",
"check-no-new-access",
"--policy-type",
policy_type,
"--existing-policy-document",
f"file://{existing_file.name}",
"--new-policy-document",
f"file://{new_file.name}",
"--output",
"json",
],
capture_output=True,
text=True,
)
if result.returncode != 0:
fail(f"CheckNoNewAccess failed: {result.stderr.strip()}")
payload = json.loads(result.stdout or "{}")
return payload.get("result", "")
def as_list(value: object) -> list[str]:
if value is None:
return []
if isinstance(value, str):
return [value]
return [str(item) for item in value]
def statement_list(document: dict) -> list[dict]:
statements = document.get("Statement", [])
if isinstance(statements, dict):
return [statements]
return list(statements)
def normalize(value: object) -> object:
if isinstance(value, dict):
return {key: normalize(value[key]) for key in sorted(value)}
if isinstance(value, list):
items = [normalize(item) for item in value]
return sorted(items, key=lambda item: json.dumps(item, sort_keys=True, default=str))
return value
def stable(value: object) -> str:
return json.dumps(normalize(value), sort_keys=True, separators=(",", ":"), default=str)
def name_set(statement: dict, field: str) -> set[str]:
return set(as_list(statement.get(field)))
def drop_exact(old: list[dict], new: list[dict]) -> tuple[list[dict], list[dict]]:
used = [False] * len(new)
new_keys = [stable(item) for item in new]
remaining_old: list[dict] = []
for statement in old:
key = stable(statement)
matched = False
for index, candidate in enumerate(new_keys):
if not used[index] and candidate == key:
used[index] = True
matched = True
break
if not matched:
remaining_old.append(statement)
remaining_new = [item for index, item in enumerate(new) if not used[index]]
return remaining_old, remaining_new
def pair_widenings(old: dict, new: dict) -> list[str]:
"""Ways a matched statement grants more access than it used to."""
reasons: list[str] = []
label = str(new.get("Sid") or old.get("Sid") or "statement")
if old.get("Effect") != new.get("Effect"):
return [f"{label}: effect changed"]
if stable(old.get("Condition")) != stable(new.get("Condition")):
reasons.append(f"{label}: condition changed")
if stable(old.get("Principal")) != stable(new.get("Principal")):
reasons.append(f"{label}: principal changed")
if ("Action" in old) != ("Action" in new) or ("NotAction" in old) != ("NotAction" in new):
reasons.append(f"{label}: action form changed")
return reasons
if ("Resource" in old) != ("Resource" in new) or ("NotResource" in old) != ("NotResource" in new):
reasons.append(f"{label}: resource form changed")
return reasons
effect = old.get("Effect")
old_actions, new_actions = name_set(old, "Action"), name_set(new, "Action")
old_not, new_not = name_set(old, "NotAction"), name_set(new, "NotAction")
old_resources, new_resources = name_set(old, "Resource"), name_set(new, "Resource")
old_not_resources = name_set(old, "NotResource")
new_not_resources = name_set(new, "NotResource")
if effect == "Allow":
added = sorted(new_actions - old_actions)
if added:
reasons.append(f"{label}: allow actions added {added}")
removed_exceptions = sorted(old_not - new_not)
if removed_exceptions:
reasons.append(f"{label}: allow NotAction shrank {removed_exceptions}")
if new_resources - old_resources:
reasons.append(f"{label}: allow resources added")
if old_not_resources - new_not_resources:
reasons.append(f"{label}: allow NotResource shrank")
elif effect == "Deny":
removed = sorted(old_actions - new_actions)
if removed:
reasons.append(f"{label}: deny actions removed {removed}")
grown = sorted(new_not - old_not)
if grown:
reasons.append(f"{label}: deny NotAction grew {grown}")
if old_resources - new_resources:
reasons.append(f"{label}: deny resources removed")
if new_not_resources - old_not_resources:
reasons.append(f"{label}: deny NotResource grew")
return reasons
def scp_widenings(existing: dict, new: dict) -> list[str]:
"""Access added relative to existing.
CheckNoNewAccess rejects SERVICE_CONTROL_POLICY. A new Allow, a removed
or smaller Deny, a larger Deny NotAction list, or any Condition change
is new access. A new Deny, or a larger Deny Action list, is not.
"""
reasons: list[str] = []
def grouped(document: dict) -> tuple[dict[str, list[dict]], list[dict]]:
keyed: dict[str, list[dict]] = {}
loose: list[dict] = []
for statement in statement_list(document):
sid = statement.get("Sid")
if isinstance(sid, str) and sid:
keyed.setdefault(sid, []).append(statement)
else:
loose.append(statement)
return keyed, loose
old_keyed, old_loose = grouped(existing)
new_keyed, new_loose = grouped(new)
for sid in sorted(set(old_keyed) | set(new_keyed)):
old_group, new_group = drop_exact(old_keyed.get(sid, []), new_keyed.get(sid, []))
count = min(len(old_group), len(new_group))
for old, statement in zip(old_group[:count], new_group[:count]):
reasons.extend(pair_widenings(old, statement))
for statement in old_group[count:]:
if statement.get("Effect") == "Deny":
reasons.append(f"{sid}: deny statement removed")
for statement in new_group[count:]:
if statement.get("Effect") == "Allow":
reasons.append(f"{sid}: allow statement added")
old_loose, new_loose = drop_exact(old_loose, new_loose)
for statement in old_loose:
if statement.get("Effect") == "Deny":
reasons.append("deny statement removed")
for statement in new_loose:
if statement.get("Effect") == "Allow":
reasons.append("allow statement added")
return reasons
def compare_documents(
name: str,
policy_type: str,
existing: dict,
new: dict,
checker=check_no_new_access,
) -> None:
if policy_type != "IDENTITY_POLICY":
reasons = scp_widenings(existing, new)
if reasons:
fail(f"SCP allows new access {name}: {reasons[:8]}")
print(f"SCP allow check ok: {name}")
return
result = checker(existing, new, policy_type)
if result != "PASS":
fail(f"CheckNoNewAccess {name}: {result or 'empty result'}")
print(f"CheckNoNewAccess ok: {name}")
def expect_widening(existing: dict, new: dict, label: str) -> None:
if not scp_widenings(existing, new):
fail(f"SCP allow check missed {label}")
def expect_same(existing: dict, new: dict, label: str) -> None:
reasons = scp_widenings(existing, new)
if reasons:
fail(f"SCP allow check flagged {label}: {reasons}")
def local_self_test() -> None:
deny_only = {
"Statement": [
{"Sid": "A", "Effect": "Deny", "NotAction": ["iam:*"], "Resource": "*"}
]
}
larger_not_action = {
"Statement": [
{
"Sid": "A",
"Effect": "Deny",
"NotAction": ["iam:*", "s3:*"],
"Resource": "*",
}
]
}
expect_widening(deny_only, larger_not_action, "a larger Deny NotAction list")
expect_widening(deny_only, {"Statement": []}, "a removed Deny")
expect_widening(
{
"Statement": [
{
"Sid": "A",
"Effect": "Deny",
"Action": ["iam:CreateRole", "iam:DeleteRole"],
"Resource": "*",
}
]
},
{
"Statement": [
{"Sid": "A", "Effect": "Deny", "Action": "iam:CreateRole", "Resource": "*"}
]
},
"a smaller Deny Action list",
)
expect_widening(
{
"Statement": [
{
"Sid": "A",
"Effect": "Allow",
"Action": "s3:GetObject",
"Resource": "*",
"Condition": {"StringEquals": {"aws:RequestedRegion": "home"}},
}
]
},
{
"Statement": [
{"Sid": "A", "Effect": "Allow", "Action": "s3:GetObject", "Resource": "*"}
]
},
"a removed Allow Condition",
)
expect_widening(
{
"Statement": [
{
"Sid": "A",
"Effect": "Deny",
"Action": "iam:CreateRole",
"Resource": "*",
"Condition": {
"ArnNotLike": {
"aws:PrincipalArn": "arn:aws:iam::*:role/OrganizationAccountAccessRole"
}
},
}
]
},
{
"Statement": [
{
"Sid": "A",
"Effect": "Deny",
"Action": "iam:CreateRole",
"Resource": "*",
"Condition": {
"ArnNotLike": {"aws:PrincipalArn": "arn:aws:iam::*:role/other"}
},
}
]
},
"a changed Deny Condition",
)
expect_widening(
{"Statement": []},
{
"Statement": [
{"Sid": "Wide", "Effect": "Allow", "Action": "*", "Resource": "*"}
]
},
"a new SCP Allow *",
)
expect_widening(
{
"Statement": [
{"Sid": "A", "Effect": "Allow", "Action": "s3:GetObject", "Resource": "*"}
]
},
{
"Statement": [
{"Sid": "A", "Effect": "Allow", "NotAction": "iam:CreateRole", "Resource": "*"}
]
},
"an Allow rewritten as NotAction",
)
expect_same(deny_only, deny_only, "an identical document")
expect_same(
{
"Statement": [
{"Sid": "A", "Effect": "Deny", "Action": "iam:CreateRole", "Resource": "*"}
]
},
{
"Statement": [
{
"Sid": "A",
"Effect": "Deny",
"Action": ["iam:CreateRole", "iam:DeleteRole"],
"Resource": "*",
}
]
},
"a larger Deny Action list",
)
plan = load_json(BOOTSTRAP / "plan-refresh-policy.json.tmpl")
widened_plan = json.loads(json.dumps(plan))
widened_plan["Statement"].append(
{"Sid": "Wide", "Effect": "Allow", "Action": "s3:PutObject", "Resource": "*"}
)
try:
compare_documents(
"plan-refresh-policy.json.tmpl",
"IDENTITY_POLICY",
plan,
widened_plan,
checker=lambda _existing, _new, _policy_type: "FAIL",
)
except CheckFailure as exc:
if "CheckNoNewAccess" not in str(exc):
raise
else:
fail("widened plan-refresh template was accepted")
compare_documents(
"plan-refresh-policy.json.tmpl",
"IDENTITY_POLICY",
plan,
plan,
checker=lambda _existing, _new, _policy_type: "PASS",
)
print("self-test ok: SCP widenings and plan-refresh comparison")
def self_test() -> None:
existing = {
"Version": "2012-10-17",
"Statement": [
{"Effect": "Allow", "Action": "s3:GetObject", "Resource": "*"}
],
}
widened = {
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Action": ["s3:GetObject", "s3:PutObject"],
"Resource": "*",
}
],
}
if check_no_new_access(existing, widened, "IDENTITY_POLICY") != "FAIL":
fail("self-test expected FAIL when s3:PutObject is added")
if check_no_new_access(existing, existing, "IDENTITY_POLICY") != "PASS":
fail("self-test expected PASS for an identical policy")
plan = load_json(BOOTSTRAP / "plan-refresh-policy.json.tmpl")
widened_plan = json.loads(json.dumps(plan))
widened_plan["Statement"].append(
{"Effect": "Allow", "Action": "s3:PutObject", "Resource": "*"}
)
if check_no_new_access(plan, widened_plan, "IDENTITY_POLICY") != "FAIL":
fail("self-test expected FAIL when plan refresh gains s3:PutObject")
print("self-test ok: CheckNoNewAccess distinguishes added access")
def validate_document(
name: str,
policy_type: str,
document: dict,
base_documents: dict[str, dict],
require_identity_base: bool,
) -> None:
head_errors = error_findings(name, policy_type, document)
if name in base_documents:
base_errors = set(error_findings(name, policy_type, base_documents[name]))
introduced = [item for item in head_errors if item not in base_errors]
if introduced:
fail(f"ValidatePolicy new ERROR {name}: {describe_findings(introduced)}")
if head_errors:
print(f"ValidatePolicy existing ERROR {name}: {describe_findings(head_errors)}")
else:
print(f"ValidatePolicy ok: {name} ({policy_type})")
if policy_type == "IDENTITY_POLICY":
compare_documents(name, policy_type, base_documents[name], document)
return
if head_errors:
fail(f"ValidatePolicy ERROR {name}: {describe_findings(head_errors)}")
if policy_type == "IDENTITY_POLICY" and require_identity_base:
fail(f"new bootstrap policy has no base document: {name}")
print(f"ValidatePolicy ok: {name} ({policy_type})")
def main() -> int:
parser = argparse.ArgumentParser()
parser.add_argument("--cdk-out", type=Path)
parser.add_argument("--base-cdk-out", type=Path)
parser.add_argument("--base-repo", type=Path)
parser.add_argument("--self-test", action="store_true")
args = parser.parse_args()
try:
check_trust_templates()
check_plan_refresh()
local_self_test()
documents: list[tuple[str, str, dict]] = [
(name, "IDENTITY_POLICY", document)
for name, document in bootstrap_documents().items()
]
base_documents: dict[str, dict] = {}
if args.base_repo:
base_documents.update(bootstrap_documents(args.base_repo))
head_scps: dict[str, dict] = {}
if args.cdk_out:
head_scps = synthesized_scps(args.cdk_out)
for name, document in head_scps.items():
documents.append((name, "SERVICE_CONTROL_POLICY", document))
base_scps: dict[str, dict] = {}
if args.base_cdk_out:
base_scps = synthesized_scps(args.base_cdk_out)
base_documents.update(base_scps)
empty: dict = {"Statement": []}
for name, document in head_scps.items():
compare_documents(
name,
"SERVICE_CONTROL_POLICY",
base_scps.get(name, empty),
document,
)
for name in sorted(set(base_scps) - set(head_scps)):
compare_documents(name, "SERVICE_CONTROL_POLICY", base_scps[name], empty)
if aws_available():
if args.self_test:
self_test()
for name, policy_type, document in documents:
validate_document(
name,
policy_type,
document,
base_documents,
require_identity_base=args.base_repo is not None,
)
print(f"checked {len(documents)} documents")
except CheckFailure as exc:
print(f"FAIL: {exc}", file=sys.stderr)
return 1
return 0
if __name__ == "__main__":
sys.exit(main())

View file

@ -16,7 +16,11 @@
# scripts/create-hcptf-bootstrap-roles.sh --account prod --allow-workspace STACK-prod
#
# --simulate runs iam:SimulatePrincipalPolicy against the apply role. Requires
# the role to already exist.
# the role to already exist. The policy source is the live Role.Arn from
# iam:GetRole, so an existing unpathed role and a /platform/ create both match.
# The platform-path case must come back denied. role/hcptf-* does not cover
# role/platform/*, and ProtectPlatformPath denies that resource for every
# principal except OrganizationAccountAccessRole and the Platform SSO role.
#
# Default trust is exact StringEquals for workspace iam-bootstrap-<env> only.
# HCP workspace names are org-unique, so prod and dev cannot both be
@ -44,7 +48,7 @@ while [[ $# -gt 0 ]]; do
--dry-run) DRY_RUN=1; shift ;;
--simulate) SIMULATE=1; shift ;;
--allow-workspace) ALLOW_WORKSPACE="$2"; shift 2 ;;
-h|--help) sed -n '2,36p' "$0"; exit 0 ;;
-h|--help) sed -n '2,35p' "$0"; exit 0 ;;
-*) echo "unknown flag: $1" >&2; exit 2 ;;
*) echo "unexpected argument: $1" >&2; exit 2 ;;
esac
@ -145,8 +149,41 @@ AWS_SESSION_TOKEN="$(python3 -c 'import json,sys; print(json.load(sys.stdin)["Se
echo "account: ${ACCOUNT_ID} (${ACCOUNT_KEY})"
echo "caller: $(aws sts get-caller-identity --query Arn --output text)"
# Live ARN, checked against the role path. IAM role names are unique per
# account, so GetRole finds either /hcptf-bootstrap or /platform/hcptf-bootstrap.
role_arn() {
local name="$1"
local line arn path
# Command substitution so set -e stops when GetRole fails. A missing role
# must not fall through to a printed ARN.
line="$(aws iam get-role --role-name "$name" \
--query 'Role.[Arn,Path]' --output text)"
arn="${line%%$'\t'*}"
path="${line#*$'\t'}"
path="${path%$'\r'}"
case "$path" in
/)
[[ "$arn" == "arn:aws:iam::${ACCOUNT_ID}:role/${name}" ]] || {
echo "${name}: ARN ${arn} does not match path ${path}" >&2
exit 1
}
;;
/platform/)
[[ "$arn" == "arn:aws:iam::${ACCOUNT_ID}:role/platform/${name}" ]] || {
echo "${name}: ARN ${arn} does not match path ${path}" >&2
exit 1
}
;;
*)
echo "${name}: unexpected path ${path} (ARN ${arn})" >&2
exit 1
;;
esac
printf '%s\n' "$arn"
}
if [[ "$SIMULATE" -eq 1 ]]; then
APPLY_ARN="arn:aws:iam::${ACCOUNT_ID}:role/hcptf-bootstrap"
APPLY_ARN="$(role_arn hcptf-bootstrap)"
echo "== simulate ${APPLY_ARN} =="
echo "-- CreateRole with tf-managed boundary (expect allowed) --"
aws iam simulate-principal-policy \
@ -171,7 +208,7 @@ if [[ "$SIMULATE" -eq 1 ]]; then
--resource-arns "arn:aws:iam::${ACCOUNT_ID}:policy/tf-managed/example" \
--query 'EvaluationResults[].{Action:EvalActionName,Decision:EvalDecision}' \
--output table
echo "-- PutRolePolicy on hcptf-* (expect allowed; import/first-apply path) --"
echo "-- PutRolePolicy on unpathed role/hcptf-example (expect allowed; import/first-apply path) --"
aws iam simulate-principal-policy \
--policy-source-arn "$APPLY_ARN" \
--action-names iam:PutRolePolicy iam:DetachRolePolicy \
@ -187,6 +224,24 @@ if [[ "$SIMULATE" -eq 1 ]]; then
"arn:aws:iam::${ACCOUNT_ID}:role/OrganizationAccountAccessRole" \
--query 'EvaluationResults[].{Action:EvalActionName,Resource:EvalResourceName,Decision:EvalDecision}' \
--output table
echo "-- IAM changes on role/platform/hcptf-example (expect denied) --"
platform_sim="$(aws iam simulate-principal-policy \
--policy-source-arn "$APPLY_ARN" \
--action-names iam:CreateRole iam:PutRolePolicy iam:DeleteRole \
--resource-arns "arn:aws:iam::${ACCOUNT_ID}:role/platform/hcptf-example" \
--query 'EvaluationResults[].{Action:EvalActionName,Decision:EvalDecision}' \
--output table)"
printf '%s\n' "$platform_sim"
if grep -q 'allowed' <<<"$platform_sim"; then
echo "role/platform/* simulation allowed an IAM change" >&2
exit 1
fi
for action in iam:CreateRole iam:PutRolePolicy iam:DeleteRole; do
grep -q "$action" <<<"$platform_sim" || {
echo "role/platform/* simulation missing ${action}" >&2
exit 1
}
done
exit 0
fi
@ -239,10 +294,14 @@ create_or_update_role() {
aws iam update-assume-role-policy --role-name "$name" --policy-document "file://${trust_file}"
fi
else
echo " $name: create"
echo " $name: create on /platform/"
# Path is create-only. IAM cannot move an existing role onto /platform/.
# Prod and dev already have hcptf-bootstrap and hcptf-bootstrap-plan, so
# this branch does not run for them. Do not delete and recreate to set a path.
if [[ "$DRY_RUN" -eq 0 ]]; then
aws iam create-role \
--role-name "$name" \
--path /platform/ \
--assume-role-policy-document "file://${trust_file}" \
--description "HCP Terraform ${name} (PLAT-145). Console/CLI owned. Manual apply only." \
--tags Key=Project,Value=hcp-bootstrap Key=Owner,Value=adam@seahavenind.com Key=ManagedBy,Value=cli
@ -277,9 +336,11 @@ aws iam attach-role-policy \
2>/dev/null || true
echo " hcptf-bootstrap-plan: attached ViewOnlyAccess"
APPLY_ARN="$(role_arn hcptf-bootstrap)"
PLAN_ARN="$(role_arn hcptf-bootstrap-plan)"
echo "done. Next: HCP workspace ${BOOTSTRAP_WORKSPACE} in ${HCP_PROJECT}, Manual apply,"
echo " TFC_AWS_APPLY_ROLE_ARN=arn:aws:iam::${ACCOUNT_ID}:role/hcptf-bootstrap"
echo " TFC_AWS_PLAN_ROLE_ARN=arn:aws:iam::${ACCOUNT_ID}:role/hcptf-bootstrap-plan"
echo " TFC_AWS_APPLY_ROLE_ARN=${APPLY_ARN}"
echo " TFC_AWS_PLAN_ROLE_ARN=${PLAN_ARN}"
if [[ -n "$ALLOW_WORKSPACE" ]]; then
echo "First-apply/import window: point workspace ${ALLOW_WORKSPACE} TFC_AWS_* at the pair above,"
echo " apply, retarget scoped ARNs, then re-run this script with no --allow-workspace."