From f38ed7357b467ee32dd3cd99a39580e1ffb189d4 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 28 Sep 2026 00:52:23 +0000 Subject: [PATCH 1/2] feat(iam): add platform permission set and org-admin assume alarm (SEC-37) Adds an Identity Center platform group and Platform permission set assigned to the management account, and a CloudTrail alarm on AssumeRole of OrganizationAccountAccessRole. Scripts still assume that role. This change is not deployed. Co-authored-by: Adam Moussa --- .github/workflows/deploy.yaml | 2 +- bin/app.ts | 7 ++++ lib/cis-monitoring.ts | 34 +++++++++++++++++++ lib/platform-access-stack.ts | 64 +++++++++++++++++++++++++++++++++++ 4 files changed, 106 insertions(+), 1 deletion(-) create mode 100644 lib/platform-access-stack.ts diff --git a/.github/workflows/deploy.yaml b/.github/workflows/deploy.yaml index 7588331..e8dd908 100644 --- a/.github/workflows/deploy.yaml +++ b/.github/workflows/deploy.yaml @@ -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 }} diff --git a/bin/app.ts b/bin/app.ts index bc42311..974391a 100644 --- a/bin/app.ts +++ b/bin/app.ts @@ -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" }, diff --git a/lib/cis-monitoring.ts b/lib/cis-monitoring.ts index 3b6debd..2d9ff7f 100644 --- a/lib/cis-monitoring.ts +++ b/lib/cis-monitoring.ts @@ -264,6 +264,40 @@ export class CisMonitoring extends Construct { alarm.addAlarmAction(new cwactions.SnsAction(topic)); } + // SEC-37. Not a CIS control. Counts every AssumeRole of + // OrganizationAccountAccessRole, including failures. Same topic as the + // CIS alarms. SCP exemptions for that role stay in place. + const orgAdminAssume = new logs.MetricFilter( + this, + "OrganizationAccountAccessRoleAssumeFilter", + { + logGroup, + filterPattern: logs.FilterPattern.literal( + '{ ($.eventName = "AssumeRole") && ($.requestParameters.roleArn = "*OrganizationAccountAccessRole") }', + ), + 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, including failed attempts", + 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 }); } } diff --git a/lib/platform-access-stack.ts b/lib/platform-access-stack.ts new file mode 100644 index 0000000..9a9e5e6 --- /dev/null +++ b/lib/platform-access-stack.ts @@ -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", + }); + } +} From 00626f1fc7e3a6d284624ca07b72d70f6399b8cf Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 28 Sep 2026 16:07:57 +0000 Subject: [PATCH 2/2] fix(iam): skip sanctioned org-admin assumes in the alarm (SEC-37) Count failed assumes for every principal. Do not page on a successful assume by the Platform permission set or the two repo script sessions. Co-authored-by: Adam Moussa --- lib/cis-monitoring.ts | 23 ++++++++++++++++++----- 1 file changed, 18 insertions(+), 5 deletions(-) diff --git a/lib/cis-monitoring.ts b/lib/cis-monitoring.ts index 2d9ff7f..9a415f7 100644 --- a/lib/cis-monitoring.ts +++ b/lib/cis-monitoring.ts @@ -264,16 +264,29 @@ export class CisMonitoring extends Construct { alarm.addAlarmAction(new cwactions.SnsAction(topic)); } - // SEC-37. Not a CIS control. Counts every AssumeRole of - // OrganizationAccountAccessRole, including failures. Same topic as the - // CIS alarms. SCP exemptions for that role stay in place. + // 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") }', + '{ ($.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", @@ -289,7 +302,7 @@ export class CisMonitoring extends Construct { .createAlarm(this, "OrganizationAccountAccessRoleAssumeAlarm", { alarmName: "organization-account-access-role-assume", alarmDescription: - "AssumeRole of OrganizationAccountAccessRole, including failed attempts", + "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,