From 97f27bd8d793be0191b090b4166e56456d7db60f Mon Sep 17 00:00:00 2001 From: Adam Moussa Date: Tue, 14 Jul 2026 13:32:32 -0400 Subject: [PATCH] Add org-governance stack: OU skeleton + generalized SCPs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 2 of the multi-account segregation plan: codifies the OU tree (workloads/prod/nonprod, security, sandbox, graveyard) and three org-wide SCPs (workloads-region-lock, protect-security-baseline, deny-root-user) generalized from the proven external-dev guardrails. All resources Retain — CFN must never detach a live guardrail. New SCPs attach only to the new empty OUs; extending to external-dev is a separate gated targetIds change after live verification. --- bin/app.ts | 9 ++ lib/org-governance-stack.ts | 220 ++++++++++++++++++++++++++++++++++++ 2 files changed, 229 insertions(+) create mode 100644 lib/org-governance-stack.ts diff --git a/bin/app.ts b/bin/app.ts index 1630a51..7c19d66 100644 --- a/bin/app.ts +++ b/bin/app.ts @@ -7,6 +7,7 @@ import { BackupStack } from "../lib/backup-stack"; import { RegionalBaselineStack } from "../lib/regional-baseline-stack"; import { DynamoDbCmkStack } from "../lib/dynamodb-cmk-stack"; import { MemberBaselineStack } from "../lib/member-baseline-stack"; +import { OrgGovernanceStack } from "../lib/org-governance-stack"; const ACCOUNT = "328440206208"; const EXTERNAL_DEV_ACCOUNT = "396287094661"; @@ -45,6 +46,14 @@ new AccountBaselineStack(app, "account-baseline", { // matching the currently deployed stack. const EXTDEV_FLOW_LOG_VPC_IDS: string[] = []; +// ── Org structure: OUs + generalized SCPs (management account only) ───────── +// Existing external-dev OU + its 3 SCPs are adopted into this stack via +// `cdk import` post-deploy — see lib/org-governance-stack.ts header + README. +new OrgGovernanceStack(app, "org-governance", { + stackName: "seahaven-org-governance", + env: { account: ACCOUNT, region: "us-east-1" }, +}); + new MemberBaselineStack(app, "external-dev-baseline", { stackName: "seahaven-external-dev-baseline", env: { account: EXTERNAL_DEV_ACCOUNT, region: "us-east-1" }, diff --git a/lib/org-governance-stack.ts b/lib/org-governance-stack.ts new file mode 100644 index 0000000..262a2eb --- /dev/null +++ b/lib/org-governance-stack.ts @@ -0,0 +1,220 @@ +import * as cdk from "aws-cdk-lib"; +import * as organizations from "aws-cdk-lib/aws-organizations"; +import { Construct } from "constructs"; + +/** + * AWS Organizations structure for org o-9kufuzz6b4: OU skeleton + generalized + * service-control policies (multi-account segregation plan Phase 2, + * 2026-07-14). Deploys to the MANAGEMENT account only — Organizations OU/SCP + * APIs are management-account-scoped. + * + * Target OU tree (root r-nbuj): + * workloads/ new production + nonprod member accounts + * prod/ seahaven-prod (Phase 5) + * nonprod/ seahaven-dev (Phase 4) + * security/ seahaven-security (Phase 3, delegated admin) + * sandbox/ experiments / personal workloads (optional) + * graveyard/ closed/suspended accounts (637423252038) + * external-dev/ EXISTING (ou-nbuj-q34yz3ql) — adopted via `cdk import` + * together with its 3 existing SCPs; see README runbook. + * + * INVARIANTS (safety-critical — reviewed under the mandatory IAM gates): + * - Every resource here carries RemovalPolicy.RETAIN (DeletionPolicy + + * UpdateReplacePolicy). CFN must never detach/delete a live guardrail via + * stack delete or logical-id churn. Keep it that way permanently. + * - CfnPolicy.targetIds is the EXACT live attachment set. Removing an entry + * DETACHES that guardrail on the next deploy — every targetIds edit is a + * live IAM change requiring GPT-4.1 cross-review + /sh-security-review. + * - Policy content must stay a JSON OBJECT (not a string) or drift detection + * on content/attachments silently stops working. + * - SCPs do NOT bind the management account; region-lock exempts global + * services via NotAction (pattern proven on p-i59g24mz). + * + * Rollout discipline (Phase 2 canary): new SCPs attach to the NEW (empty) OUs + * only. Extending any of them to the external-dev OU is a separate, gated + * targetIds change made only after live access verification in 396287094661. + */ +export class OrgGovernanceStack extends cdk.Stack { + constructor(scope: Construct, id: string, props?: cdk.StackProps) { + super(scope, id, props); + + const ROOT_ID = "r-nbuj"; + + // ── OU skeleton ───────────────────────────────────────────────────────── + const retain = (resource: organizations.CfnOrganizationalUnit | organizations.CfnPolicy) => { + resource.cfnOptions.deletionPolicy = cdk.CfnDeletionPolicy.RETAIN; + resource.cfnOptions.updateReplacePolicy = cdk.CfnDeletionPolicy.RETAIN; + }; + + const workloadsOu = new organizations.CfnOrganizationalUnit(this, "WorkloadsOu", { + name: "workloads", + parentId: ROOT_ID, + }); + retain(workloadsOu); + + const prodOu = new organizations.CfnOrganizationalUnit(this, "ProdOu", { + name: "prod", + parentId: workloadsOu.attrId, + }); + retain(prodOu); + + const nonprodOu = new organizations.CfnOrganizationalUnit(this, "NonprodOu", { + name: "nonprod", + parentId: workloadsOu.attrId, + }); + retain(nonprodOu); + + const securityOu = new organizations.CfnOrganizationalUnit(this, "SecurityOu", { + name: "security", + parentId: ROOT_ID, + }); + retain(securityOu); + + const sandboxOu = new organizations.CfnOrganizationalUnit(this, "SandboxOu", { + name: "sandbox", + parentId: ROOT_ID, + }); + retain(sandboxOu); + + const graveyardOu = new organizations.CfnOrganizationalUnit(this, "GraveyardOu", { + name: "graveyard", + parentId: ROOT_ID, + }); + retain(graveyardOu); + + // ── Generalized SCPs ──────────────────────────────────────────────────── + // Patterns generalized from the external-dev OU guardrails (p-i59g24mz / + // p-ivmwtipw), which stay attached to that OU unchanged. Exemption + // principals use cross-account ArnLike patterns because these policies + // serve every future member account. + + // Region lock for workload accounts: us-east-1 (primary) + us-west-2 + // (offsite backup/DR). Global services exempted via NotAction — the same + // list proven on the external-dev region lock. + const workloadsRegionLock = new organizations.CfnPolicy(this, "WorkloadsRegionLock", { + name: "workloads-region-lock", + type: "SERVICE_CONTROL_POLICY", + description: + "Deny workload member accounts outside us-east-1 (primary) and us-west-2 (backup/DR)", + targetIds: [workloadsOu.attrId], + content: { + Version: "2012-10-17", + Statement: [ + { + Sid: "DenyRegionsOutsideApproved", + Effect: "Deny", + NotAction: [ + "iam:*", "organizations:*", "account:*", "sts:*", "route53:*", + "route53domains:*", "cloudfront:*", "waf:*", "shield:*", + "globalaccelerator:*", "budgets:*", "ce:*", "cur:*", "health:*", + "support:*", "supportplans:*", "trustedadvisor:*", "artifact:*", + "aws-portal:*", + ], + Resource: "*", + Condition: { + StringNotEquals: { + "aws:RequestedRegion": ["us-east-1", "us-west-2"], + }, + }, + }, + ], + }, + }); + retain(workloadsRegionLock); + + // Protect detective/security services in every member account. Exemptions + // are the operational principals that legitimately manage these controls: + // the org break-glass role, CDK exec roles, and the baseline Config + // custom-resource roles (their onDelete stops the recorder by design). + const protectSecurity = new organizations.CfnPolicy(this, "ProtectSecurityBaseline", { + name: "protect-security-baseline", + type: "SERVICE_CONTROL_POLICY", + description: + "Deny disabling CloudTrail/Config/GuardDuty/SecurityHub/AccessAnalyzer/Inspector2 and org-leave in member accounts", + targetIds: [ + workloadsOu.attrId, + prodOu.attrId, + nonprodOu.attrId, + securityOu.attrId, + sandboxOu.attrId, + graveyardOu.attrId, + ], + content: { + Version: "2012-10-17", + Statement: [ + { + Sid: "DenyDisablingSecurityServices", + Effect: "Deny", + Action: [ + "cloudtrail:StopLogging", "cloudtrail:DeleteTrail", "cloudtrail:UpdateTrail", + "guardduty:DeleteDetector", "guardduty:UpdateDetector", + "guardduty:DisassociateFromMasterAccount", "guardduty:DisassociateFromAdministratorAccount", + "config:StopConfigurationRecorder", "config:DeleteConfigurationRecorder", + "config:DeleteDeliveryChannel", + "securityhub:DisableSecurityHub", "securityhub:BatchDisableStandards", + "securityhub:DisassociateFromAdministratorAccount", + "accessanalyzer:DeleteAnalyzer", "inspector2:Disable", + ], + Resource: "*", + Condition: { + ArnNotLike: { + "aws:PrincipalArn": [ + "arn:aws:iam::*:role/OrganizationAccountAccessRole", + "arn:aws:iam::*:role/cdk-hnb659fds-*", + "arn:aws:iam::*:role/seahaven-*-config-custom-resource-role", + ], + }, + }, + }, + { + Sid: "DenyLeavingOrganization", + Effect: "Deny", + Action: ["organizations:LeaveOrganization"], + Resource: "*", + }, + ], + }, + }); + retain(protectSecurity); + + // Root-user lockout for member accounts: root has no operational role + // (OrganizationAccountAccessRole + Identity Center cover everything). If a + // genuinely root-only task ever arises (account closure, certain tax + // settings), detach temporarily via a gated targetIds change. + const denyRootUser = new organizations.CfnPolicy(this, "DenyRootUser", { + name: "deny-root-user", + type: "SERVICE_CONTROL_POLICY", + description: "Deny all root-user actions in member accounts", + targetIds: [ + workloadsOu.attrId, + prodOu.attrId, + nonprodOu.attrId, + securityOu.attrId, + sandboxOu.attrId, + graveyardOu.attrId, + ], + content: { + Version: "2012-10-17", + Statement: [ + { + Sid: "DenyRootUser", + Effect: "Deny", + Action: "*", + Resource: "*", + Condition: { + StringLike: { "aws:PrincipalArn": "arn:aws:iam::*:root" }, + }, + }, + ], + }, + }); + retain(denyRootUser); + + new cdk.CfnOutput(this, "WorkloadsOuId", { value: workloadsOu.attrId }); + new cdk.CfnOutput(this, "ProdOuId", { value: prodOu.attrId }); + new cdk.CfnOutput(this, "NonprodOuId", { value: nonprodOu.attrId }); + new cdk.CfnOutput(this, "SecurityOuId", { value: securityOu.attrId }); + new cdk.CfnOutput(this, "SandboxOuId", { value: sandboxOu.attrId }); + new cdk.CfnOutput(this, "GraveyardOuId", { value: graveyardOu.attrId }); + } +}