diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index f40c9f4..264f5f8 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -12,3 +12,47 @@ jobs: uses: Sea-Haven-Industries/.github/.github/workflows/ci-typescript-cdk.yaml@e5691d8a7f96ac4d5a841a82975ff0a4354d53ac # v1.0.7 with: node-version: "24" + + iam-policy-check: + runs-on: ubuntu-latest + timeout-minutes: 30 + permissions: + id-token: write + contents: read + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + fetch-depth: 0 + + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: "24" + cache: npm + + - name: Install dependencies + run: npm ci + + - name: Synthesize organization policies + run: npx cdk synth org-governance -o cdk.out --quiet + + - name: Synthesize base-branch organization policies + run: | + git fetch origin main + git worktree add --detach /tmp/iam-base origin/main + npm ci --prefix /tmp/iam-base + (cd /tmp/iam-base && npx cdk synth org-governance -o /tmp/iam-base-out --quiet) + + - name: Configure AWS credentials + id: aws-creds + continue-on-error: true + uses: aws-actions/configure-aws-credentials@e1253824e5c10ff9df46874f81ed3ec929e19cfd # v6.3.0 + with: + role-to-assume: arn:aws:iam::328440206208:role/githubdeploy-seahaven-org-baseline-policy-check + aws-region: us-east-1 # pragma: allowlist secret + + - name: Note skipped analyzer credentials + if: steps.aws-creds.outcome != 'success' + run: echo "::warning title=Access Analyzer skipped::OIDC assume-role did not succeed, so ValidatePolicy and CheckNoNewAccess did not run. The skip stays until githubdeploy-seahaven-org-baseline-policy-check is deployed." + + - name: Check IAM policies + run: python3 scripts/check_iam_policies.py --cdk-out cdk.out --base-cdk-out /tmp/iam-base-out --base-repo /tmp/iam-base --self-test diff --git a/.github/workflows/deploy.yaml b/.github/workflows/deploy.yaml index 7588331..e8dd908 100644 --- a/.github/workflows/deploy.yaml +++ b/.github/workflows/deploy.yaml @@ -22,7 +22,7 @@ jobs: uses: Sea-Haven-Industries/.github/.github/workflows/cd-cdk.yaml@e5691d8a7f96ac4d5a841a82975ff0a4354d53ac # v1.0.7 with: node-version: "24" - stacks: "account-baseline dynamodb-cmk regional-baseline-us-west-2 regional-baseline-us-east-2 backup-offsite backup org-governance" + stacks: "account-baseline dynamodb-cmk regional-baseline-us-west-2 regional-baseline-us-east-2 backup-offsite backup org-governance platform-access" secrets: deploy-role-arn: ${{ secrets.AWS_DEPLOY_ROLE_ARN }} diff --git a/bin/app.ts b/bin/app.ts index bc42311..974391a 100644 --- a/bin/app.ts +++ b/bin/app.ts @@ -13,6 +13,7 @@ import { AppWebAclStack } from "../lib/app-web-acl-stack"; import { SeahavenSiteHcptfStack } from "../lib/seahaven-site-hcptf-stack"; import { MemberBaselineStack } from "../lib/member-baseline-stack"; import { OrgGovernanceStack } from "../lib/org-governance-stack"; +import { PlatformAccessStack } from "../lib/platform-access-stack"; const ACCOUNT = "328440206208"; const EXTERNAL_DEV_ACCOUNT = "396287094661"; @@ -79,6 +80,12 @@ new OrgGovernanceStack(app, "org-governance", { env: { account: ACCOUNT, region: "us-east-1" }, }); +new PlatformAccessStack(app, "platform-access", { + stackName: "seahaven-platform-access", + // Same management-account region as org-governance above. + env: { account: ACCOUNT, region: "us-east-1" }, // pragma: allowlist secret +}); + new MemberBaselineStack(app, "external-dev-baseline", { stackName: "seahaven-external-dev-baseline", env: { account: EXTERNAL_DEV_ACCOUNT, region: "us-east-1" }, diff --git a/lib/cis-monitoring.ts b/lib/cis-monitoring.ts index 3b6debd..9a415f7 100644 --- a/lib/cis-monitoring.ts +++ b/lib/cis-monitoring.ts @@ -264,6 +264,53 @@ export class CisMonitoring extends Construct { alarm.addAlarmAction(new cwactions.SnsAction(topic)); } + // SEC-37. Not a CIS control. Same topic as the CIS alarms. + // SCP exemptions for OrganizationAccountAccessRole stay in place. + // + // A 1/1 alarm on every assume would page for each Platform operator + // session and each run of the bootstrap and substrate scripts. CIS 4.1 + // treats that single-event paging on a routine path as alert fatigue. + // Successful assumes are excluded only for those callers: + // * the Platform permission set (AWSReservedSSO_Platform_*) + // * session plat-145-hcptf-bootstrap (create-hcptf-bootstrap-roles.sh) + // * session plat-147-tf-substrate (delete-terraform-substrate-prod-dev.sh) + // Any errorCode still counts, including a failed call from those callers. + // + // Residual: roleSessionName is chosen by the caller, so a successful + // assume that copies one of those two session names is not counted. + // The scripts stay on OrganizationAccountAccessRole until the permission + // set is deployed and a real sign-in has assumed the member role. + const orgAdminAssume = new logs.MetricFilter( + this, + "OrganizationAccountAccessRoleAssumeFilter", + { + logGroup, + filterPattern: logs.FilterPattern.literal( + '{ ($.eventName = "AssumeRole") && ($.requestParameters.roleArn = "*OrganizationAccountAccessRole") && (($.errorCode = "*") || (($.userIdentity.arn != "*AWSReservedSSO_Platform_*") && ($.requestParameters.roleSessionName != "plat-145-hcptf-bootstrap") && ($.requestParameters.roleSessionName != "plat-147-tf-substrate"))) }', + ), + metricNamespace: "SeahavenSecurity", + metricName: "OrganizationAccountAccessRoleAssume", + metricValue: "1", + defaultValue: 0, + }, + ); + const orgAdminAlarm = orgAdminAssume + .metric({ + statistic: "Sum", + period: cdk.Duration.minutes(5), + }) + .createAlarm(this, "OrganizationAccountAccessRoleAssumeAlarm", { + alarmName: "organization-account-access-role-assume", + alarmDescription: + "AssumeRole of OrganizationAccountAccessRole outside the Platform permission set and the two repo script sessions. Failed attempts count for every principal.", + threshold: 1, + comparisonOperator: + cloudwatch.ComparisonOperator.GREATER_THAN_OR_EQUAL_TO_THRESHOLD, + evaluationPeriods: 1, + treatMissingData: cloudwatch.TreatMissingData.NOT_BREACHING, + }); + orgAdminAlarm.addAlarmAction(new cwactions.SnsAction(topic)); + new cdk.CfnOutput(this, "CisAlarmTopicArn", { value: topic.topicArn }); } } diff --git a/lib/org-governance-stack.ts b/lib/org-governance-stack.ts index c897b1b..2c201cc 100644 --- a/lib/org-governance-stack.ts +++ b/lib/org-governance-stack.ts @@ -260,7 +260,7 @@ export class OrgGovernanceStack extends cdk.Stack { name: "protect-privileged-roles", type: "SERVICE_CONTROL_POLICY", description: - "prod/nonprod: protect break-glass, CDK exec, githubdeploy, and hcptf-bootstrap roles", + "prod/nonprod: protect break-glass, CDK exec, githubdeploy, hcptf-bootstrap, and /platform/ roles", targetIds: [prodOu.attrId, nonprodOu.attrId], content: scpContent("protect-privileged-roles"), }); @@ -338,6 +338,33 @@ export class OrgGovernanceStack extends cdk.Stack { }, }, }, + { + 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", diff --git a/lib/platform-access-stack.ts b/lib/platform-access-stack.ts new file mode 100644 index 0000000..9a9e5e6 --- /dev/null +++ b/lib/platform-access-stack.ts @@ -0,0 +1,64 @@ +import * as cdk from "aws-cdk-lib"; +import * as identitystore from "aws-cdk-lib/aws-identitystore"; +import * as sso from "aws-cdk-lib/aws-sso"; +import { Construct } from "constructs"; + +const IDENTITY_CENTER_INSTANCE_ARN = + "arn:aws:sso:::instance/ssoins-722321f42ca610e4"; +const IDENTITY_STORE_ID = "d-9067ec8e26"; +const MANAGEMENT_ACCOUNT_ID = "328440206208"; + +/** + * Identity Center group and permission set for platform operators (SEC-37). + * + * Assigned only to the management account. ReadOnlyAccess plus + * sts:AssumeRole on OrganizationAccountAccessRole, so the existing + * bootstrap and teardown scripts keep working after a person uses this + * set. Those scripts still assume OrganizationAccountAccessRole directly. + * Retarget them only after this set is deployed and a real sign-in has + * assumed the member role. + * + * This is not an SCP exemption. /platform/ path denies exempt the + * reserved SSO role name AWSReservedSSO_Platform_* once the set exists. + */ +export class PlatformAccessStack extends cdk.Stack { + constructor(scope: Construct, id: string, props?: cdk.StackProps) { + super(scope, id, props); + + const group = new identitystore.CfnGroup(this, "PlatformGroup", { + identityStoreId: IDENTITY_STORE_ID, + displayName: "platform", + description: + "Platform operators. Management account only. Assumes OrganizationAccountAccessRole for bootstrap.", + }); + + const permissionSet = new sso.CfnPermissionSet(this, "PlatformPermissionSet", { + instanceArn: IDENTITY_CENTER_INSTANCE_ARN, + name: "Platform", + description: + "Read-only in the management account, plus assume OrganizationAccountAccessRole.", + sessionDuration: "PT8H", + managedPolicies: ["arn:aws:iam::aws:policy/ReadOnlyAccess"], + inlinePolicy: { + Version: "2012-10-17", + Statement: [ + { + Sid: "AssumeOrganizationAccountAccessRole", + Effect: "Allow", + Action: "sts:AssumeRole", + Resource: "arn:aws:iam::*:role/OrganizationAccountAccessRole", + }, + ], + }, + }); + + new sso.CfnAssignment(this, "PlatformManagementAssignment", { + instanceArn: IDENTITY_CENTER_INSTANCE_ARN, + permissionSetArn: permissionSet.attrPermissionSetArn, + principalId: group.attrGroupId, + principalType: "GROUP", + targetId: MANAGEMENT_ACCOUNT_ID, + targetType: "AWS_ACCOUNT", + }); + } +} diff --git a/lib/scp/protect-privileged-roles.json b/lib/scp/protect-privileged-roles.json index 202ec22..1d8777b 100644 --- a/lib/scp/protect-privileged-roles.json +++ b/lib/scp/protect-privileged-roles.json @@ -31,6 +31,33 @@ ] } } + }, + { + "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_*" + ] + } + } } ] } diff --git a/scripts/check_iam_policies.py b/scripts/check_iam_policies.py new file mode 100755 index 0000000..4aad5ae --- /dev/null +++ b/scripts/check_iam_policies.py @@ -0,0 +1,669 @@ +#!/usr/bin/env python3 +"""IAM policy checks for seahaven-org-baseline. + +Local invariants always run: +- bootstrap trust templates use StringEquals and do not use StringLike +- the plan refresh template grants no lambda write, including lambda:* + +When --cdk-out is set, synthesized service control policies are collected. +SCP diffs run locally. A new or renamed SCP is compared with an empty +baseline, and a base SCP missing from head is treated as deleted. The diff +fails when access widens: a new Allow, a smaller Deny, a larger Deny +NotAction list, or any Condition change. When AWS credentials can call +sts:GetCallerIdentity, each document is sent to IAM Access Analyzer +ValidatePolicy. A new SCP with a ValidatePolicy ERROR fails, because that +error cannot already exist on main. CheckNoNewAccess compares bootstrap +identity policies with the templates in --base-repo. It rejects +SERVICE_CONTROL_POLICY, so SCPs use the local diff. Missing credentials +skip the AWS calls, emit a warning, and still run the local checks. + +The substrate template is not scanned. Its afi plan role still has lambda:* +until the import apply replaces it. +""" + +from __future__ import annotations + +import argparse +import json +import subprocess +import sys +import tempfile +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +BOOTSTRAP = ROOT / "lib" / "hcptf-bootstrap" +PLACEHOLDERS = { + "__ACCOUNT_ID__": "111111111111", + "__HCP_PROJECT__": "seahaven-prod", + "__BOOTSTRAP_WORKSPACE__": "iam-bootstrap-prod", + "__STACK_PREFIX__": "example", + "__STACK_NAME__": "example", +} +LAMBDA_READ_PREFIXES = ("lambda:Get", "lambda:List", "lambda:Describe") + + +class CheckFailure(Exception): + pass + + +def fail(message: str) -> None: + raise CheckFailure(message) + + +def substitute(text: str) -> str: + for key, value in PLACEHOLDERS.items(): + text = text.replace(key, value) + return text + + +def load_json(path: Path) -> dict: + return json.loads(substitute(path.read_text())) + + +def statement_actions(statement: dict) -> list[str]: + action = statement.get("Action", []) + if isinstance(action, str): + return [action] + return list(action) + + +def lambda_writes(document: dict) -> list[str]: + found: list[str] = [] + statements = document.get("Statement", []) + if isinstance(statements, dict): + statements = [statements] + for statement in statements: + if statement.get("Effect") != "Allow": + continue + for action in statement_actions(statement): + if action in {"*", "lambda:*"}: + found.append(action) + elif action.startswith("lambda:") and not action.startswith(LAMBDA_READ_PREFIXES): + found.append(action) + return found + + +def check_trust_templates() -> None: + paths = sorted(BOOTSTRAP.glob("trust-*.json.tmpl")) + if not paths: + fail(f"no trust templates in {BOOTSTRAP}") + for path in paths: + text = path.read_text() + if "StringLike" in text: + fail(f"{path.name}: trust must stay StringEquals") + if "StringEquals" not in text: + fail(f"{path.name}: missing StringEquals") + document = load_json(path) + condition = document["Statement"][0]["Condition"] + if "StringLike" in condition or "StringEquals" not in condition: + fail(f"{path.name}: trust condition must be StringEquals") + print(f"invariant ok: {path.name} uses StringEquals") + + +def check_plan_refresh() -> None: + path = BOOTSTRAP / "plan-refresh-policy.json.tmpl" + writes = lambda_writes(load_json(path)) + if writes: + fail(f"{path.name}: plan document grants lambda write {writes}") + print(f"invariant ok: {path.name} has no lambda write") + + +def bootstrap_documents(root: Path | None = None) -> dict[str, dict]: + bootstrap = (root or ROOT) / "lib" / "hcptf-bootstrap" + if not bootstrap.is_dir(): + fail(f"bootstrap template directory does not exist: {bootstrap}") + documents: dict[str, dict] = {} + for path in sorted(bootstrap.glob("*.json.tmpl")): + # Trust documents are resource policies without a Resource element. + # ValidatePolicy rejects that shape. The StringEquals invariant covers them. + if path.name.startswith("trust-"): + continue + documents[path.name] = load_json(path) + if not documents: + fail(f"no bootstrap policy templates in {bootstrap}") + return documents + + +def synthesized_scps(cdk_out: Path) -> dict[str, dict]: + found: dict[str, dict] = {} + if not cdk_out.is_dir(): + fail(f"cdk out directory does not exist: {cdk_out}") + for path in sorted(cdk_out.glob("*.template.json")): + template = json.loads(path.read_text()) + for logical, resource in template.get("Resources", {}).items(): + if resource.get("Type") != "AWS::Organizations::Policy": + continue + content = resource["Properties"]["Content"] + if isinstance(content, str): + content = json.loads(content) + name = resource["Properties"].get("Name", logical) + found[f"{path.stem}:{name}"] = content + if not found: + fail(f"no service control policies in {cdk_out}") + return found + + +def aws_available() -> bool: + result = subprocess.run( + ["aws", "sts", "get-caller-identity", "--output", "json"], + capture_output=True, + text=True, + ) + if result.returncode != 0: + print( + "::warning title=Access Analyzer skipped::sts get-caller-identity failed. " + "ValidatePolicy and CheckNoNewAccess did not run." + ) + print("AWS checks skipped: sts get-caller-identity failed", file=sys.stderr) + return False + identity = json.loads(result.stdout) + print(f"AWS checks using account {identity.get('Account')}") + return True + + +def analyzer(command: list[str], document: dict, extra: list[str]) -> dict: + with tempfile.NamedTemporaryFile("w", suffix=".json") as handle: + json.dump(document, handle) + handle.flush() + result = subprocess.run( + ["aws", "accessanalyzer", *command, "--policy-document", f"file://{handle.name}", *extra, "--output", "json"], + capture_output=True, + text=True, + ) + if result.returncode != 0: + fail(f"{' '.join(command)} failed: {result.stderr.strip()}") + return json.loads(result.stdout or "{}") + + +def error_findings(name: str, policy_type: str, document: dict) -> list[tuple[str, str]]: + payload = analyzer( + ["validate-policy"], + document, + ["--policy-type", policy_type], + ) + errors = [ + ( + str(finding.get("issueCode")), + str(finding.get("findingDetails")), + ) + for finding in payload.get("findings", []) + if finding.get("findingType") == "ERROR" + ] + return errors + + +def describe_findings(findings: list[tuple[str, str]]) -> str: + return "; ".join(f"{code}: {details}" for code, details in findings) + + +def check_no_new_access(existing: dict, new: dict, policy_type: str) -> str: + with tempfile.NamedTemporaryFile("w", suffix=".json") as new_file: + json.dump(new, new_file) + new_file.flush() + with tempfile.NamedTemporaryFile("w", suffix=".json") as existing_file: + json.dump(existing, existing_file) + existing_file.flush() + result = subprocess.run( + [ + "aws", + "accessanalyzer", + "check-no-new-access", + "--policy-type", + policy_type, + "--existing-policy-document", + f"file://{existing_file.name}", + "--new-policy-document", + f"file://{new_file.name}", + "--output", + "json", + ], + capture_output=True, + text=True, + ) + if result.returncode != 0: + fail(f"CheckNoNewAccess failed: {result.stderr.strip()}") + payload = json.loads(result.stdout or "{}") + return payload.get("result", "") + + +def as_list(value: object) -> list[str]: + if value is None: + return [] + if isinstance(value, str): + return [value] + return [str(item) for item in value] + + +def statement_list(document: dict) -> list[dict]: + statements = document.get("Statement", []) + if isinstance(statements, dict): + return [statements] + return list(statements) + + +def normalize(value: object) -> object: + if isinstance(value, dict): + return {key: normalize(value[key]) for key in sorted(value)} + if isinstance(value, list): + items = [normalize(item) for item in value] + return sorted(items, key=lambda item: json.dumps(item, sort_keys=True, default=str)) + return value + + +def stable(value: object) -> str: + return json.dumps(normalize(value), sort_keys=True, separators=(",", ":"), default=str) + + +def name_set(statement: dict, field: str) -> set[str]: + return set(as_list(statement.get(field))) + + +def drop_exact(old: list[dict], new: list[dict]) -> tuple[list[dict], list[dict]]: + used = [False] * len(new) + new_keys = [stable(item) for item in new] + remaining_old: list[dict] = [] + for statement in old: + key = stable(statement) + matched = False + for index, candidate in enumerate(new_keys): + if not used[index] and candidate == key: + used[index] = True + matched = True + break + if not matched: + remaining_old.append(statement) + remaining_new = [item for index, item in enumerate(new) if not used[index]] + return remaining_old, remaining_new + + +def pair_widenings(old: dict, new: dict) -> list[str]: + """Ways a matched statement grants more access than it used to.""" + reasons: list[str] = [] + label = str(new.get("Sid") or old.get("Sid") or "statement") + if old.get("Effect") != new.get("Effect"): + return [f"{label}: effect changed"] + if stable(old.get("Condition")) != stable(new.get("Condition")): + reasons.append(f"{label}: condition changed") + if stable(old.get("Principal")) != stable(new.get("Principal")): + reasons.append(f"{label}: principal changed") + if ("Action" in old) != ("Action" in new) or ("NotAction" in old) != ("NotAction" in new): + reasons.append(f"{label}: action form changed") + return reasons + if ("Resource" in old) != ("Resource" in new) or ("NotResource" in old) != ("NotResource" in new): + reasons.append(f"{label}: resource form changed") + return reasons + effect = old.get("Effect") + old_actions, new_actions = name_set(old, "Action"), name_set(new, "Action") + old_not, new_not = name_set(old, "NotAction"), name_set(new, "NotAction") + old_resources, new_resources = name_set(old, "Resource"), name_set(new, "Resource") + old_not_resources = name_set(old, "NotResource") + new_not_resources = name_set(new, "NotResource") + if effect == "Allow": + added = sorted(new_actions - old_actions) + if added: + reasons.append(f"{label}: allow actions added {added}") + removed_exceptions = sorted(old_not - new_not) + if removed_exceptions: + reasons.append(f"{label}: allow NotAction shrank {removed_exceptions}") + if new_resources - old_resources: + reasons.append(f"{label}: allow resources added") + if old_not_resources - new_not_resources: + reasons.append(f"{label}: allow NotResource shrank") + elif effect == "Deny": + removed = sorted(old_actions - new_actions) + if removed: + reasons.append(f"{label}: deny actions removed {removed}") + grown = sorted(new_not - old_not) + if grown: + reasons.append(f"{label}: deny NotAction grew {grown}") + if old_resources - new_resources: + reasons.append(f"{label}: deny resources removed") + if new_not_resources - old_not_resources: + reasons.append(f"{label}: deny NotResource grew") + return reasons + + +def scp_widenings(existing: dict, new: dict) -> list[str]: + """Access added relative to existing. + + CheckNoNewAccess rejects SERVICE_CONTROL_POLICY. A new Allow, a removed + or smaller Deny, a larger Deny NotAction list, or any Condition change + is new access. A new Deny, or a larger Deny Action list, is not. + """ + reasons: list[str] = [] + + def grouped(document: dict) -> tuple[dict[str, list[dict]], list[dict]]: + keyed: dict[str, list[dict]] = {} + loose: list[dict] = [] + for statement in statement_list(document): + sid = statement.get("Sid") + if isinstance(sid, str) and sid: + keyed.setdefault(sid, []).append(statement) + else: + loose.append(statement) + return keyed, loose + + old_keyed, old_loose = grouped(existing) + new_keyed, new_loose = grouped(new) + for sid in sorted(set(old_keyed) | set(new_keyed)): + old_group, new_group = drop_exact(old_keyed.get(sid, []), new_keyed.get(sid, [])) + count = min(len(old_group), len(new_group)) + for old, statement in zip(old_group[:count], new_group[:count]): + reasons.extend(pair_widenings(old, statement)) + for statement in old_group[count:]: + if statement.get("Effect") == "Deny": + reasons.append(f"{sid}: deny statement removed") + for statement in new_group[count:]: + if statement.get("Effect") == "Allow": + reasons.append(f"{sid}: allow statement added") + old_loose, new_loose = drop_exact(old_loose, new_loose) + for statement in old_loose: + if statement.get("Effect") == "Deny": + reasons.append("deny statement removed") + for statement in new_loose: + if statement.get("Effect") == "Allow": + reasons.append("allow statement added") + return reasons + + +def compare_documents( + name: str, + policy_type: str, + existing: dict, + new: dict, + checker=check_no_new_access, +) -> None: + if policy_type != "IDENTITY_POLICY": + reasons = scp_widenings(existing, new) + if reasons: + fail(f"SCP allows new access {name}: {reasons[:8]}") + print(f"SCP allow check ok: {name}") + return + result = checker(existing, new, policy_type) + if result != "PASS": + fail(f"CheckNoNewAccess {name}: {result or 'empty result'}") + print(f"CheckNoNewAccess ok: {name}") + + +def expect_widening(existing: dict, new: dict, label: str) -> None: + if not scp_widenings(existing, new): + fail(f"SCP allow check missed {label}") + + +def expect_same(existing: dict, new: dict, label: str) -> None: + reasons = scp_widenings(existing, new) + if reasons: + fail(f"SCP allow check flagged {label}: {reasons}") + + +def local_self_test() -> None: + deny_only = { + "Statement": [ + {"Sid": "A", "Effect": "Deny", "NotAction": ["iam:*"], "Resource": "*"} + ] + } + larger_not_action = { + "Statement": [ + { + "Sid": "A", + "Effect": "Deny", + "NotAction": ["iam:*", "s3:*"], + "Resource": "*", + } + ] + } + expect_widening(deny_only, larger_not_action, "a larger Deny NotAction list") + expect_widening(deny_only, {"Statement": []}, "a removed Deny") + expect_widening( + { + "Statement": [ + { + "Sid": "A", + "Effect": "Deny", + "Action": ["iam:CreateRole", "iam:DeleteRole"], + "Resource": "*", + } + ] + }, + { + "Statement": [ + {"Sid": "A", "Effect": "Deny", "Action": "iam:CreateRole", "Resource": "*"} + ] + }, + "a smaller Deny Action list", + ) + expect_widening( + { + "Statement": [ + { + "Sid": "A", + "Effect": "Allow", + "Action": "s3:GetObject", + "Resource": "*", + "Condition": {"StringEquals": {"aws:RequestedRegion": "home"}}, + } + ] + }, + { + "Statement": [ + {"Sid": "A", "Effect": "Allow", "Action": "s3:GetObject", "Resource": "*"} + ] + }, + "a removed Allow Condition", + ) + expect_widening( + { + "Statement": [ + { + "Sid": "A", + "Effect": "Deny", + "Action": "iam:CreateRole", + "Resource": "*", + "Condition": { + "ArnNotLike": { + "aws:PrincipalArn": "arn:aws:iam::*:role/OrganizationAccountAccessRole" + } + }, + } + ] + }, + { + "Statement": [ + { + "Sid": "A", + "Effect": "Deny", + "Action": "iam:CreateRole", + "Resource": "*", + "Condition": { + "ArnNotLike": {"aws:PrincipalArn": "arn:aws:iam::*:role/other"} + }, + } + ] + }, + "a changed Deny Condition", + ) + expect_widening( + {"Statement": []}, + { + "Statement": [ + {"Sid": "Wide", "Effect": "Allow", "Action": "*", "Resource": "*"} + ] + }, + "a new SCP Allow *", + ) + expect_widening( + { + "Statement": [ + {"Sid": "A", "Effect": "Allow", "Action": "s3:GetObject", "Resource": "*"} + ] + }, + { + "Statement": [ + {"Sid": "A", "Effect": "Allow", "NotAction": "iam:CreateRole", "Resource": "*"} + ] + }, + "an Allow rewritten as NotAction", + ) + expect_same(deny_only, deny_only, "an identical document") + expect_same( + { + "Statement": [ + {"Sid": "A", "Effect": "Deny", "Action": "iam:CreateRole", "Resource": "*"} + ] + }, + { + "Statement": [ + { + "Sid": "A", + "Effect": "Deny", + "Action": ["iam:CreateRole", "iam:DeleteRole"], + "Resource": "*", + } + ] + }, + "a larger Deny Action list", + ) + plan = load_json(BOOTSTRAP / "plan-refresh-policy.json.tmpl") + widened_plan = json.loads(json.dumps(plan)) + widened_plan["Statement"].append( + {"Sid": "Wide", "Effect": "Allow", "Action": "s3:PutObject", "Resource": "*"} + ) + try: + compare_documents( + "plan-refresh-policy.json.tmpl", + "IDENTITY_POLICY", + plan, + widened_plan, + checker=lambda _existing, _new, _policy_type: "FAIL", + ) + except CheckFailure as exc: + if "CheckNoNewAccess" not in str(exc): + raise + else: + fail("widened plan-refresh template was accepted") + compare_documents( + "plan-refresh-policy.json.tmpl", + "IDENTITY_POLICY", + plan, + plan, + checker=lambda _existing, _new, _policy_type: "PASS", + ) + print("self-test ok: SCP widenings and plan-refresh comparison") + + +def self_test() -> None: + existing = { + "Version": "2012-10-17", + "Statement": [ + {"Effect": "Allow", "Action": "s3:GetObject", "Resource": "*"} + ], + } + widened = { + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Allow", + "Action": ["s3:GetObject", "s3:PutObject"], + "Resource": "*", + } + ], + } + if check_no_new_access(existing, widened, "IDENTITY_POLICY") != "FAIL": + fail("self-test expected FAIL when s3:PutObject is added") + if check_no_new_access(existing, existing, "IDENTITY_POLICY") != "PASS": + fail("self-test expected PASS for an identical policy") + plan = load_json(BOOTSTRAP / "plan-refresh-policy.json.tmpl") + widened_plan = json.loads(json.dumps(plan)) + widened_plan["Statement"].append( + {"Effect": "Allow", "Action": "s3:PutObject", "Resource": "*"} + ) + if check_no_new_access(plan, widened_plan, "IDENTITY_POLICY") != "FAIL": + fail("self-test expected FAIL when plan refresh gains s3:PutObject") + print("self-test ok: CheckNoNewAccess distinguishes added access") + + +def validate_document( + name: str, + policy_type: str, + document: dict, + base_documents: dict[str, dict], + require_identity_base: bool, +) -> None: + head_errors = error_findings(name, policy_type, document) + if name in base_documents: + base_errors = set(error_findings(name, policy_type, base_documents[name])) + introduced = [item for item in head_errors if item not in base_errors] + if introduced: + fail(f"ValidatePolicy new ERROR {name}: {describe_findings(introduced)}") + if head_errors: + print(f"ValidatePolicy existing ERROR {name}: {describe_findings(head_errors)}") + else: + print(f"ValidatePolicy ok: {name} ({policy_type})") + if policy_type == "IDENTITY_POLICY": + compare_documents(name, policy_type, base_documents[name], document) + return + if head_errors: + fail(f"ValidatePolicy ERROR {name}: {describe_findings(head_errors)}") + if policy_type == "IDENTITY_POLICY" and require_identity_base: + fail(f"new bootstrap policy has no base document: {name}") + print(f"ValidatePolicy ok: {name} ({policy_type})") + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument("--cdk-out", type=Path) + parser.add_argument("--base-cdk-out", type=Path) + parser.add_argument("--base-repo", type=Path) + parser.add_argument("--self-test", action="store_true") + args = parser.parse_args() + + try: + check_trust_templates() + check_plan_refresh() + local_self_test() + documents: list[tuple[str, str, dict]] = [ + (name, "IDENTITY_POLICY", document) + for name, document in bootstrap_documents().items() + ] + base_documents: dict[str, dict] = {} + if args.base_repo: + base_documents.update(bootstrap_documents(args.base_repo)) + head_scps: dict[str, dict] = {} + if args.cdk_out: + head_scps = synthesized_scps(args.cdk_out) + for name, document in head_scps.items(): + documents.append((name, "SERVICE_CONTROL_POLICY", document)) + base_scps: dict[str, dict] = {} + if args.base_cdk_out: + base_scps = synthesized_scps(args.base_cdk_out) + base_documents.update(base_scps) + empty: dict = {"Statement": []} + for name, document in head_scps.items(): + compare_documents( + name, + "SERVICE_CONTROL_POLICY", + base_scps.get(name, empty), + document, + ) + for name in sorted(set(base_scps) - set(head_scps)): + compare_documents(name, "SERVICE_CONTROL_POLICY", base_scps[name], empty) + if aws_available(): + if args.self_test: + self_test() + for name, policy_type, document in documents: + validate_document( + name, + policy_type, + document, + base_documents, + require_identity_base=args.base_repo is not None, + ) + print(f"checked {len(documents)} documents") + except CheckFailure as exc: + print(f"FAIL: {exc}", file=sys.stderr) + return 1 + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/create-hcptf-bootstrap-roles.sh b/scripts/create-hcptf-bootstrap-roles.sh index 72ed30a..207980a 100755 --- a/scripts/create-hcptf-bootstrap-roles.sh +++ b/scripts/create-hcptf-bootstrap-roles.sh @@ -16,7 +16,11 @@ # scripts/create-hcptf-bootstrap-roles.sh --account prod --allow-workspace STACK-prod # # --simulate runs iam:SimulatePrincipalPolicy against the apply role. Requires -# the role to already exist. +# the role to already exist. The policy source is the live Role.Arn from +# iam:GetRole, so an existing unpathed role and a /platform/ create both match. +# The platform-path case must come back denied. role/hcptf-* does not cover +# role/platform/*, and ProtectPlatformPath denies that resource for every +# principal except OrganizationAccountAccessRole and the Platform SSO role. # # Default trust is exact StringEquals for workspace iam-bootstrap- only. # HCP workspace names are org-unique, so prod and dev cannot both be @@ -44,7 +48,7 @@ while [[ $# -gt 0 ]]; do --dry-run) DRY_RUN=1; shift ;; --simulate) SIMULATE=1; shift ;; --allow-workspace) ALLOW_WORKSPACE="$2"; shift 2 ;; - -h|--help) sed -n '2,36p' "$0"; exit 0 ;; + -h|--help) sed -n '2,35p' "$0"; exit 0 ;; -*) echo "unknown flag: $1" >&2; exit 2 ;; *) echo "unexpected argument: $1" >&2; exit 2 ;; esac @@ -145,8 +149,41 @@ AWS_SESSION_TOKEN="$(python3 -c 'import json,sys; print(json.load(sys.stdin)["Se echo "account: ${ACCOUNT_ID} (${ACCOUNT_KEY})" echo "caller: $(aws sts get-caller-identity --query Arn --output text)" +# Live ARN, checked against the role path. IAM role names are unique per +# account, so GetRole finds either /hcptf-bootstrap or /platform/hcptf-bootstrap. +role_arn() { + local name="$1" + local line arn path + # Command substitution so set -e stops when GetRole fails. A missing role + # must not fall through to a printed ARN. + line="$(aws iam get-role --role-name "$name" \ + --query 'Role.[Arn,Path]' --output text)" + arn="${line%%$'\t'*}" + path="${line#*$'\t'}" + path="${path%$'\r'}" + case "$path" in + /) + [[ "$arn" == "arn:aws:iam::${ACCOUNT_ID}:role/${name}" ]] || { + echo "${name}: ARN ${arn} does not match path ${path}" >&2 + exit 1 + } + ;; + /platform/) + [[ "$arn" == "arn:aws:iam::${ACCOUNT_ID}:role/platform/${name}" ]] || { + echo "${name}: ARN ${arn} does not match path ${path}" >&2 + exit 1 + } + ;; + *) + echo "${name}: unexpected path ${path} (ARN ${arn})" >&2 + exit 1 + ;; + esac + printf '%s\n' "$arn" +} + if [[ "$SIMULATE" -eq 1 ]]; then - APPLY_ARN="arn:aws:iam::${ACCOUNT_ID}:role/hcptf-bootstrap" + APPLY_ARN="$(role_arn hcptf-bootstrap)" echo "== simulate ${APPLY_ARN} ==" echo "-- CreateRole with tf-managed boundary (expect allowed) --" aws iam simulate-principal-policy \ @@ -171,7 +208,7 @@ if [[ "$SIMULATE" -eq 1 ]]; then --resource-arns "arn:aws:iam::${ACCOUNT_ID}:policy/tf-managed/example" \ --query 'EvaluationResults[].{Action:EvalActionName,Decision:EvalDecision}' \ --output table - echo "-- PutRolePolicy on hcptf-* (expect allowed; import/first-apply path) --" + echo "-- PutRolePolicy on unpathed role/hcptf-example (expect allowed; import/first-apply path) --" aws iam simulate-principal-policy \ --policy-source-arn "$APPLY_ARN" \ --action-names iam:PutRolePolicy iam:DetachRolePolicy \ @@ -187,6 +224,24 @@ if [[ "$SIMULATE" -eq 1 ]]; then "arn:aws:iam::${ACCOUNT_ID}:role/OrganizationAccountAccessRole" \ --query 'EvaluationResults[].{Action:EvalActionName,Resource:EvalResourceName,Decision:EvalDecision}' \ --output table + echo "-- IAM changes on role/platform/hcptf-example (expect denied) --" + platform_sim="$(aws iam simulate-principal-policy \ + --policy-source-arn "$APPLY_ARN" \ + --action-names iam:CreateRole iam:PutRolePolicy iam:DeleteRole \ + --resource-arns "arn:aws:iam::${ACCOUNT_ID}:role/platform/hcptf-example" \ + --query 'EvaluationResults[].{Action:EvalActionName,Decision:EvalDecision}' \ + --output table)" + printf '%s\n' "$platform_sim" + if grep -q 'allowed' <<<"$platform_sim"; then + echo "role/platform/* simulation allowed an IAM change" >&2 + exit 1 + fi + for action in iam:CreateRole iam:PutRolePolicy iam:DeleteRole; do + grep -q "$action" <<<"$platform_sim" || { + echo "role/platform/* simulation missing ${action}" >&2 + exit 1 + } + done exit 0 fi @@ -239,10 +294,14 @@ create_or_update_role() { aws iam update-assume-role-policy --role-name "$name" --policy-document "file://${trust_file}" fi else - echo " $name: create" + echo " $name: create on /platform/" + # Path is create-only. IAM cannot move an existing role onto /platform/. + # Prod and dev already have hcptf-bootstrap and hcptf-bootstrap-plan, so + # this branch does not run for them. Do not delete and recreate to set a path. if [[ "$DRY_RUN" -eq 0 ]]; then aws iam create-role \ --role-name "$name" \ + --path /platform/ \ --assume-role-policy-document "file://${trust_file}" \ --description "HCP Terraform ${name} (PLAT-145). Console/CLI owned. Manual apply only." \ --tags Key=Project,Value=hcp-bootstrap Key=Owner,Value=adam@seahavenind.com Key=ManagedBy,Value=cli @@ -277,9 +336,11 @@ aws iam attach-role-policy \ 2>/dev/null || true echo " hcptf-bootstrap-plan: attached ViewOnlyAccess" +APPLY_ARN="$(role_arn hcptf-bootstrap)" +PLAN_ARN="$(role_arn hcptf-bootstrap-plan)" echo "done. Next: HCP workspace ${BOOTSTRAP_WORKSPACE} in ${HCP_PROJECT}, Manual apply," -echo " TFC_AWS_APPLY_ROLE_ARN=arn:aws:iam::${ACCOUNT_ID}:role/hcptf-bootstrap" -echo " TFC_AWS_PLAN_ROLE_ARN=arn:aws:iam::${ACCOUNT_ID}:role/hcptf-bootstrap-plan" +echo " TFC_AWS_APPLY_ROLE_ARN=${APPLY_ARN}" +echo " TFC_AWS_PLAN_ROLE_ARN=${PLAN_ARN}" if [[ -n "$ALLOW_WORKSPACE" ]]; then echo "First-apply/import window: point workspace ${ALLOW_WORKSPACE} TFC_AWS_* at the pair above," echo " apply, retarget scoped ARNs, then re-run this script with no --allow-workspace."