mirror of
https://github.com/Sea-Haven-Industries/seahaven-org-baseline.git
synced 2026-09-30 06:53:17 +00:00
Org governance: OU skeleton + generalized SCPs (Phase 2) (#44)
* 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).
This commit is contained in:
parent
3ab3bc773a
commit
22b04c4e75
3 changed files with 246 additions and 1 deletions
2
.github/workflows/deploy.yaml
vendored
2
.github/workflows/deploy.yaml
vendored
|
|
@ -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 }}
|
||||
|
||||
|
|
|
|||
|
|
@ -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" },
|
||||
|
|
|
|||
236
lib/org-governance-stack.ts
Normal file
236
lib/org-governance-stack.ts
Normal file
|
|
@ -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 });
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue