From 22b04c4e75369b5f6acb3030fbe40a051d3624d8 Mon Sep 17 00:00:00 2001 From: Adam Moussa <166072409+amoussa1229@users.noreply.github.com> Date: Tue, 14 Jul 2026 14:02:30 -0400 Subject: [PATCH] Org governance: OU skeleton + generalized SCPs (Phase 2) (#44) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Add org-governance stack: OU skeleton + generalized SCPs 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. * Record SCP cross-review dispositions in org-governance Root hardening must precede the OU move (deny-root-user blocks root MFA enrollment), delegated-admin flows ride service-linked roles that SCPs never evaluate, and the cdk exec-role exemption is accepted risk mirroring the external-dev guardrails. * Add org-governance to the management deploy job Explicit stack selectors require every new stack to join exactly one CD job (SH-ORG-005 discipline documented in this file). --- .github/workflows/deploy.yaml | 2 +- bin/app.ts | 9 ++ lib/org-governance-stack.ts | 236 ++++++++++++++++++++++++++++++++++ 3 files changed, 246 insertions(+), 1 deletion(-) create mode 100644 lib/org-governance-stack.ts diff --git a/.github/workflows/deploy.yaml b/.github/workflows/deploy.yaml index 7d2717f..d21575a 100644 --- a/.github/workflows/deploy.yaml +++ b/.github/workflows/deploy.yaml @@ -21,7 +21,7 @@ jobs: uses: Sea-Haven-Industries/.github/.github/workflows/cd-cdk.yaml@3cde673b9d05c0e68aac4d997d582f2543853d20 # main with: node-version: "24" - stacks: "account-baseline dynamodb-cmk regional-baseline-us-west-2 regional-baseline-us-east-2 backup-offsite backup" + stacks: "account-baseline dynamodb-cmk regional-baseline-us-west-2 regional-baseline-us-east-2 backup-offsite backup org-governance" secrets: deploy-role-arn: ${{ secrets.AWS_DEPLOY_ROLE_ARN }} 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..4990e20 --- /dev/null +++ b/lib/org-governance-stack.ts @@ -0,0 +1,236 @@ +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. + * + * Cross-review dispositions (GPT-4.1, 2026-07-14): + * - deny-root-user blocks root MFA enrollment (iam:EnableMFADevice as root). + * OPERATIONAL REQUIREMENT: create new accounts at the org ROOT, complete + * root hardening (MFA, contacts), THEN move-account into the target OU. + * - Delegated-admin ops (Phase 3) are unaffected by protect-security-baseline: + * org-managed GuardDuty/SecurityHub act on members via service-linked + * roles, which SCPs do not evaluate. If a legitimate admin action is ever + * denied, exemptions change only through the mandatory gates. + * - `arn:aws:iam::*:role/cdk-hnb659fds-*` exemption is ACCEPTED RISK (same + * decision as the external-dev guardrails): it is the only generic + * cross-account expression for CDK exec roles; member baselines protect + * those roles from takeover (ProtectPrivilegedRoles pattern). + * - Region-lock NotAction list deliberately matches battle-tested + * p-i59g24mz; regional services (s3, kms, logs, ssm...) stay region-locked + * BY DESIGN — do not add them to NotAction (that would exempt them). + */ +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 }); + } +}