import * as fs from "fs"; import * as path from "path"; import * as cdk from "aws-cdk-lib"; import * as organizations from "aws-cdk-lib/aws-organizations"; import { Construct } from "constructs"; /** Byte-exact live SCP content (lib/scp/*.json) — see import block below. */ const scpContent = (name: string): Record => JSON.parse( fs.readFileSync(path.join(__dirname, "scp", `${name}.json`), "utf8") ); /** * 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). * SUPERSEDED same day by centralized root access management: member root * credentials are DELETED (none exist to harden), so new accounts go * create-at-ROOT → verify credential-free → baseline → move-account. * Recovery = assume-root + temporary manual SCP detach (README runbook). * - 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 extends battle-tested p-i59g24mz with * BEDROCK_INVOKE_ACTIONS to allow cross-region inference profiles * (us.anthropic.*) that route to us-east-2; a companion * DenyBedrockInvokeOutsideInference statement re-denies those actions * outside {us-east-1, us-west-2, us-east-2}. Regional services (s3, kms, * logs, ssm...) stay region-locked BY DESIGN — do not add them to * NotAction (that would exempt them). * - Bedrock carve-out is resource-unscoped (NotAction cannot be * resource-scoped): the us-east-2 window admits the four * BEDROCK_INVOKE_ACTIONS against ANY Bedrock resource (any provider's * foundation model, marketplace, custom/imported), not just the * us.anthropic.* inference-profile path. ACCEPTED RISK — per-account IAM * policies and model-access enablement gate actual access; the SCP * provides coarse region enforcement only. (Same risk disposition as the * cdk-hnb659fds-* exemption above.) */ /** Bedrock inference actions carved out of the region lock so * cross-region inference profiles (us.anthropic.*) that route to us-east-2 * are not blocked. The DenyBedrockInvokeOutsideInference statement limits this * carve-out to {us-east-1, us-west-2, us-east-2} only. */ const BEDROCK_INVOKE_ACTIONS = [ "bedrock:InvokeModel", "bedrock:InvokeModelWithResponseStream", "bedrock:Converse", "bedrock:ConverseStream", ]; 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. Bedrock Invoke/Converse // are carved out of the general deny and re-denied only outside // {us-east-1, us-west-2, us-east-2} so cross-region inference profiles // (us.anthropic.*) that route to us-east-2 are not blocked. // chatbot:* is exempted because AWS Chatbot ("Amazon Q Developer in chat // applications") is a global management service: the console setup flow // hits its control plane in us-east-2 (observed deny: // chatbot:GetSlackOauthParameters at aws:RequestedRegion=us-east-2), which // is outside the approved region set, so without this exemption the region // deny blocks Slack workspace/channel setup for site-alerts → Slack. A // full-prefix NotAction (region-agnostic, like iam:*/cloudfront:*) is the // right shape: it names a global management service, not a workload running // in an unapproved region. The notification-side resources (the site-alerts // SNS topic, alarms) stay in us-east-1 and remain region-locked. 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:*", "chatbot:*", ...BEDROCK_INVOKE_ACTIONS, ], Resource: "*", Condition: { StringNotEquals: { "aws:RequestedRegion": ["us-east-1", "us-west-2"], }, }, }, { Sid: "DenyBedrockInvokeOutsideInference", Effect: "Deny", Action: BEDROCK_INVOKE_ACTIONS, Resource: "*", Condition: { StringNotEquals: { "aws:RequestedRegion": ["us-east-1", "us-west-2", "us-east-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); // Prod/nonprod privileged-role lock (PLAT-145). Same Sid as security-guardrails, // plus hcptf-bootstrap*. The HCP general apply/plan pair is CLI-owned and is // the factory for first apply; this SCP is the standing control that keeps a // compromised workspace from rewriting those roles. Not attached to // external-dev (PLAT-148). Exempt principals match the security-OU copy. const protectPrivilegedRoles = new organizations.CfnPolicy(this, "ProtectPrivilegedRoles", { name: "protect-privileged-roles", type: "SERVICE_CONTROL_POLICY", description: "prod/nonprod: protect break-glass, CDK exec, githubdeploy, hcptf-bootstrap, and /platform/ roles", targetIds: [prodOu.attrId, nonprodOu.attrId], content: scpContent("protect-privileged-roles"), }); retain(protectPrivilegedRoles); // Guardrails specific to the delegated-security-admin OU (SEC-BASE-C): // the security account is the org's highest-blast-radius member, so it // gets the external-dev-style IAM guardrails plus protection of its // delegated-admin MEMBERSHIP surface (a compromised principal must not be // able to silently eject prod/extdev from org-wide detection). Break-glass // = OrganizationAccountAccessRole; CDK exec roles exempt where they must // manage stack-owned IAM. const securityGuardrails = new organizations.CfnPolicy(this, "SecurityGuardrails", { name: "security-guardrails", type: "SERVICE_CONTROL_POLICY", description: "security OU: region lock, IAM user/key lockout, privileged-role protection, delegated-admin membership protection", targetIds: [securityOu.attrId], content: { Version: "2012-10-17", Statement: [ { Sid: "DenyRegionsOutsideApproved", Effect: "Deny", // NO Bedrock carve-out BY DESIGN — security account runs no // Bedrock workloads; do not sync BEDROCK_INVOKE_ACTIONS from // workloads-region-lock. 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"], }, }, }, { Sid: "DenyIamUserAndAccessKeyCreation", Effect: "Deny", Action: ["iam:CreateUser", "iam:CreateAccessKey", "iam:CreateLoginProfile"], Resource: "*", Condition: { ArnNotLike: { "aws:PrincipalArn": ["arn:aws:iam::*:role/OrganizationAccountAccessRole"], }, }, }, { Sid: "ProtectPrivilegedRoles", Effect: "Deny", Action: [ "iam:UpdateAssumeRolePolicy", "iam:AttachRolePolicy", "iam:DetachRolePolicy", "iam:PutRolePolicy", "iam:DeleteRolePolicy", "iam:DeleteRole", "iam:UpdateRole", "iam:TagRole", "iam:UntagRole", ], Resource: [ "arn:aws:iam::*:role/OrganizationAccountAccessRole", "arn:aws:iam::*:role/cdk-hnb659fds-*", "arn:aws:iam::*:role/githubdeploy-*", "arn:aws:iam::*:role/seahaven-security-config-*", "arn:aws:iam::*:role/aws-service-role/*", "arn:aws:iam::*:role/hcptf-bootstrap*", ], Condition: { ArnNotLike: { "aws:PrincipalArn": [ "arn:aws:iam::*:role/OrganizationAccountAccessRole", "arn:aws:iam::*:role/cdk-hnb659fds-*", ], }, }, }, { Sid: "ProtectPlatformPath", Effect: "Deny", Action: [ "iam:CreateRole", "iam:UpdateAssumeRolePolicy", "iam:AttachRolePolicy", "iam:DetachRolePolicy", "iam:PutRolePolicy", "iam:DeleteRolePolicy", "iam:DeleteRole", "iam:UpdateRole", "iam:PutRolePermissionsBoundary", "iam:DeleteRolePermissionsBoundary", "iam:TagRole", "iam:UntagRole", ], Resource: "arn:aws:iam::*:role/platform/*", Condition: { ArnNotLike: { "aws:PrincipalArn": [ "arn:aws:iam::*:role/OrganizationAccountAccessRole", "arn:aws:iam::*:role/aws-reserved/sso.amazonaws.com/*AWSReservedSSO_Platform_*", ], }, }, }, { Sid: "ProtectDelegatedAdminMembership", Effect: "Deny", Action: [ "guardduty:DisassociateMembers", "guardduty:DeleteMembers", "guardduty:StopMonitoringMembers", "securityhub:DisassociateMembers", "securityhub:DeleteMembers", "inspector2:DisassociateMember", ], Resource: "*", Condition: { ArnNotLike: { "aws:PrincipalArn": ["arn:aws:iam::*:role/OrganizationAccountAccessRole"], }, }, }, ], }, }); retain(securityGuardrails); // Root-user lockout for member accounts: root has no operational role // (OrganizationAccountAccessRole + Identity Center cover everything), and // since 2026-07-14 member root credentials are DELETED via centralized // root access management. This deny ALSO catches centralized root // sessions (sts:AssumeRoot runs as the member root principal; member // SCPs apply) — root recovery starts with a MANUAL, timeboxed // detach-policy call (never a deploy: a concurrent deploy re-attaches), // and for accounts under workloads/ the detach must cover BOTH the child // OU and workloads (inherited attachment). Full runbook in the README. 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, "ou-nbuj-q34yz3ql", // external-dev (imported below; literal id matches its other SCP attachments) ], content: { Version: "2012-10-17", Statement: [ { Sid: "DenyRootUser", Effect: "Deny", Action: "*", Resource: "*", Condition: { StringLike: { "aws:PrincipalArn": "arn:aws:iam::*:root" }, }, }, ], }, }); retain(denyRootUser); // ── Adopted (cdk-imported) external-dev OU + its 3 SCPs, plus one // account-attached SHOC backend deploy-trust SCP ─────────────────── // QUOTA: with deny-root-user attached (2026-07-14) this OU carries 5 SCPs // = the AWS hard limit per target. Any new guardrail for external-dev // must attach at the ACCOUNT (396287094661, own 5-slot budget) or // consolidate into one of these policies — an OU-level attach will fail. // Imported 2026-07-14 by Id (ou-nbuj-q34yz3ql, p-i59g24mz, p-8yty5mnd, // p-ivmwtipw). Properties are byte-exact to the live resources at import // time (content JSON in lib/scp/, descriptions/targets verified via // describe-policy). RULES: content stays a JSON object (string form kills // drift detection); targetIds is the exact live attachment set — any edit // here detaches/attaches a LIVE guardrail on the isolated contractor // account and requires the mandatory review gates; never rename these // logical ids (rename = delete+create; Retain would orphan, not detach, // but the stack would lose the resource). const externalDevOu = new organizations.CfnOrganizationalUnit(this, "ExternalDevOu", { name: "external-dev", parentId: ROOT_ID, }); retain(externalDevOu); const externalDevRegionLock = new organizations.CfnPolicy(this, "ExternalDevRegionLock", { name: "external-dev-region-lock", type: "SERVICE_CONTROL_POLICY", description: "external-dev OU guardrail: external-dev-region-lock", targetIds: ["ou-nbuj-q34yz3ql"], content: scpContent("external-dev-region-lock"), }); retain(externalDevRegionLock); const externalDevIamGuardrails = new organizations.CfnPolicy(this, "ExternalDevIamGuardrails", { name: "external-dev-iam-guardrails", type: "SERVICE_CONTROL_POLICY", description: "external-dev OU guardrail: external-dev-iam-guardrails", targetIds: ["ou-nbuj-q34yz3ql"], content: scpContent("external-dev-iam-guardrails"), }); retain(externalDevIamGuardrails); // Account-attached: the OU is at the 5-SCP quota. This lets // hcptf-shoc-backend-{dev,staging} and hcptf-shoc-frontend-new-{dev,staging} // UpdateAssumeRolePolicy on the matching githubdeploy-* role only. All // other githubdeploy-* and hcptf-* trust mutation stays denied except // org/CDK. const externalDevShocBackendDeployTrust = new organizations.CfnPolicy( this, "ExternalDevShocBackendDeployTrust", { name: "external-dev-shoc-backend-deploy-trust", type: "SERVICE_CONTROL_POLICY", description: "external-dev account: SHOC backend and frontend HCP apply roles may update matching githubdeploy trust", targetIds: ["396287094661"], content: scpContent("external-dev-shoc-backend-deploy-trust"), }, ); retain(externalDevShocBackendDeployTrust); const externalDevProtectSecurity = new organizations.CfnPolicy(this, "ExternalDevProtectSecurity", { name: "external-dev-protect-security", type: "SERVICE_CONTROL_POLICY", description: "external-dev OU guardrail: external-dev-protect-security", targetIds: ["ou-nbuj-q34yz3ql"], content: scpContent("external-dev-protect-security"), }); retain(externalDevProtectSecurity); new cdk.CfnOutput(this, "ExternalDevOuId", { value: externalDevOu.attrId }); 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 }); } }