feat(iam): add platform permission set and org-admin assume alarm (SEC-37) (#161)
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

* 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 <amoussa1229@users.noreply.github.com>

* 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 <amoussa1229@users.noreply.github.com>

---------

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: Adam Moussa <amoussa1229@users.noreply.github.com>
This commit is contained in:
Adam Moussa 2026-09-28 16:27:12 +00:00 • committed by GitHub
parent 8cbc98d927
commit ff492de58d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 119 additions and 1 deletions

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

@ -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",
});
}
}