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..9a415f7 100644 --- a/lib/cis-monitoring.ts +++ b/lib/cis-monitoring.ts @@ -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 }); } } 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", + }); + } +}