seahaven-account-baseline/lib/org-governance-stack.ts
seahaven-openswe[bot] 88fa5777d1
Some checks failed
Deploy / deploy-management (push) Has been cancelled
Deploy / deploy-external-dev (push) Has been cancelled
Deploy / deploy-security (push) Has been cancelled
Deploy / deploy-dev (push) Has been cancelled
Deploy / deploy-prod (push) Has been cancelled
fix(scp): carve out Bedrock InvokeModel/Converse to us-east-2 for cross-region inference (#54)
* fix(scp): carve out Bedrock InvokeModel/Converse to us-east-2 for cross-region inference

Add bedrock:InvokeModel, bedrock:InvokeModelWithResponseStream,
bedrock:Converse, and bedrock:ConverseStream to the existing
DenyRegionsOutsideApproved NotAction list so the us-east-1/us-west-2
region condition no longer denies them. Add a companion
DenyBedrockInvokeOutsideInference statement that re-denies those same
four actions outside {us-east-1, us-west-2, us-east-2}, bounding the
carve-out to us-east-2 only.

Without this, Anthropic cross-region inference profiles (us.anthropic.*)
that route InvokeModel to us-east-2 are denied, blocking all Claude
generation in workload accounts.

Refs: #53

* fix: add ACCEPTED RISK disposition, hoist Bedrock actions to shared const, mark security-asymmetry

- ACCEPTED RISK: Bedrock carve-out is resource-unscoped (NotAction
  can't be resource-scoped); us-east-2 window admits four actions
  against any Bedrock resource. Per-account IAM and model-access
  enablement gate actual access.
- Hoist the four Bedrock invoke actions into BEDROCK_INVOKE_ACTIONS
  shared const referenced by both NotAction and DenyBedrockInvoke
  statements to prevent future one-sided edit divergence.
- Mark asymmetry in security-guardrails DenyRegionsOutsideApproved:
  no Bedrock carve-out by design — security account runs no Bedrock
  workloads.

---------

Co-authored-by: amoussa1229 <166072409+amoussa1229@users.noreply.github.com>
2026-07-15 18:52:05 -04:00

431 lines
19 KiB
TypeScript

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<string, unknown> =>
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.
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:*",
...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);
// 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/*",
],
Condition: {
ArnNotLike: {
"aws:PrincipalArn": [
"arn:aws:iam::*:role/OrganizationAccountAccessRole",
"arn:aws:iam::*:role/cdk-hnb659fds-*",
],
},
},
},
{
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 ─────────────────
// 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);
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 });
}
}