diff --git a/scripts/check-terraform-import-plan.py b/scripts/check-terraform-import-plan.py index 368fa6f5..59ff6656 100755 --- a/scripts/check-terraform-import-plan.py +++ b/scripts/check-terraform-import-plan.py @@ -13,6 +13,8 @@ from terraform_import_plan_resources import ( ALLOWED_CREATE_ADDRESSES, CONTROLLED_UPDATE_ADDRESSES, ENVIRONMENT_CONFIG, + GITHUB_OIDC_PROVIDER_ARN, + GITHUB_REPO, REQUIRED_IMPORT_IDS, REQUIRED_RESOURCES, ) @@ -336,6 +338,67 @@ def _validate_policy_update( return violations +def _expected_github_deploy_assume_policy(environment: str) -> dict[str, Any]: + return _canonical( + { + "Version": "2012-10-17", + "Statement": [ + { + "Sid": "GithubDeployOidc", + "Effect": "Allow", + "Action": "sts:AssumeRoleWithWebIdentity", + "Principal": {"Federated": GITHUB_OIDC_PROVIDER_ARN}, + "Condition": { + "StringEquals": { + "token.actions.githubusercontent.com:aud": ( + "sts.amazonaws.com" + ), + "token.actions.githubusercontent.com:sub": ( + f"repo:{GITHUB_REPO}:environment:{environment}" + ), + }, + "StringLike": { + "token.actions.githubusercontent.com:job_workflow_ref": [ + ( + f"{GITHUB_REPO}/.github/workflows/" + "deploy-web.yaml@refs/heads/main" + ), + ( + f"{GITHUB_REPO}/.github/workflows/" + "deploy-web.yaml@refs/tags/v*" + ), + ], + }, + }, + } + ], + } + ) + + +def _validate_role_assume_policy( + address: str, + before: dict[str, Any], + after: dict[str, Any], + environment: str, +) -> list[str]: + before_policy, violations = _parse_policy( + before.get("assume_role_policy"), address, "before" + ) + after_policy, after_violations = _parse_policy( + after.get("assume_role_policy"), address, "after" + ) + violations.extend(after_violations) + if before_policy == after_policy: + violations.append(f"{address}: assume_role_policy semantics did not change") + expected_after = _expected_github_deploy_assume_policy(environment) + if after_policy is not None and after_policy != expected_after: + violations.append( + f"{address}: post-adoption assume_role_policy semantics are not exact" + ) + return violations + + def _validate_role_update( address: str, before: dict[str, Any], @@ -362,6 +425,10 @@ def _validate_role_update( violations.append( f"{address}: {tag_attribute} must exactly match adopted ownership tags" ) + if any(path and path[0] == "assume_role_policy" for path in changed): + violations.extend( + _validate_role_assume_policy(address, before, after, environment) + ) return violations diff --git a/scripts/terraform_import_plan_resources.py b/scripts/terraform_import_plan_resources.py index 66247570..fb9b71bc 100755 --- a/scripts/terraform_import_plan_resources.py +++ b/scripts/terraform_import_plan_resources.py @@ -59,6 +59,11 @@ ALLOWED_CREATE_ADDRESSES = frozenset( } ) +GITHUB_REPO = "Sea-Haven-Industries/shoc-frontend-new" +GITHUB_OIDC_PROVIDER_ARN = ( + "arn:aws:iam::396287094661:oidc-provider/token.actions.githubusercontent.com" +) + ENVIRONMENT_CONFIG = { "dev": { "bucket_name": "seahaven-shoc-frontend-dev", diff --git a/scripts/test-terraform-import-plan-check.py b/scripts/test-terraform-import-plan-check.py index 7ba943c3..460a0cc1 100755 --- a/scripts/test-terraform-import-plan-check.py +++ b/scripts/test-terraform-import-plan-check.py @@ -17,6 +17,8 @@ from terraform_import_plan_resources import ( ALLOWED_CREATE_ADDRESSES, CONTROLLED_UPDATE_ADDRESSES, ENVIRONMENT_CONFIG, + GITHUB_OIDC_PROVIDER_ARN, + GITHUB_REPO, REQUIRED_IMPORT_IDS, REQUIRED_RESOURCES, ) @@ -84,6 +86,40 @@ def pre_adoption_bucket_policy(environment: str) -> dict[str, Any]: } +def github_deploy_assume_policy(environment: str) -> dict[str, Any]: + return { + "Version": "2012-10-17", + "Statement": [ + { + "Sid": "GithubDeployOidc", + "Effect": "Allow", + "Action": "sts:AssumeRoleWithWebIdentity", + "Principal": {"Federated": GITHUB_OIDC_PROVIDER_ARN}, + "Condition": { + "StringEquals": { + "token.actions.githubusercontent.com:aud": "sts.amazonaws.com", + "token.actions.githubusercontent.com:sub": ( + f"repo:{GITHUB_REPO}:environment:{environment}" + ), + }, + "StringLike": { + "token.actions.githubusercontent.com:job_workflow_ref": [ + ( + f"{GITHUB_REPO}/.github/workflows/" + "deploy-web.yaml@refs/heads/main" + ), + ( + f"{GITHUB_REPO}/.github/workflows/" + "deploy-web.yaml@refs/tags/v*" + ), + ], + }, + }, + } + ], + } + + def bucket_policy(environment: str) -> dict[str, Any]: bucket = ENVIRONMENT_CONFIG[environment]["bucket_name"] bucket_arn = f"arn:aws:s3:::{bucket}" @@ -426,11 +462,33 @@ class ImportPlanCheckerTests(unittest.TestCase): self.assert_fails(plan, "dev", BUCKET) def test_role_trust_change_is_allowed(self) -> None: - plan = make_plan("dev", mode="controlled", controlled_updates={ROLE}) - role = resource(plan, ROLE)["change"] - role["before"]["assume_role_policy"] = '{"Statement":[]}' - role["after"]["assume_role_policy"] = '{"Statement":[{"Effect":"Allow"}]}' - self.assert_passes(plan, "dev", ROLE) + for environment in REQUIRED_RESOURCES: + plan = make_plan( + environment, mode="controlled", controlled_updates={ROLE} + ) + role = resource(plan, ROLE)["change"] + role["before"]["assume_role_policy"] = '{"Statement":[]}' + role["after"]["assume_role_policy"] = json.dumps( + github_deploy_assume_policy(environment) + ) + with self.subTest(environment=environment): + self.assert_passes(plan, environment, ROLE) + + def test_role_trust_rejects_mutated_document(self) -> None: + for mutation in ("principal", "missing-sub"): + plan = make_plan("dev", mode="controlled", controlled_updates={ROLE}) + role = resource(plan, ROLE)["change"] + policy = github_deploy_assume_policy("dev") + if mutation == "principal": + policy["Statement"][0]["Principal"] = {"AWS": "*"} + else: + del policy["Statement"][0]["Condition"]["StringEquals"][ + "token.actions.githubusercontent.com:sub" + ] + role["before"]["assume_role_policy"] = '{"Statement":[]}' + role["after"]["assume_role_policy"] = json.dumps(policy) + with self.subTest(mutation=mutation): + self.assert_fails(plan, "dev", ROLE) def test_bucket_policy_rejects_malicious_principal_and_extra_statement(self) -> None: for mutation in ("principal", "extra"): @@ -484,6 +542,26 @@ class ImportPlanCheckerTests(unittest.TestCase): CONTROLLED_UPDATE_ADDRESSES, ) + def test_github_deploy_assume_document_matches_module(self) -> None: + source = ( + REPOSITORY / "terraform/live/modules/environment-owned/main.tf" + ).read_text(encoding="utf-8") + document = source.split( + 'data "aws_iam_policy_document" "github_deploy_assume" {', 1 + )[1] + document = document.split( + 'data "aws_iam_policy_document" "github_deploy" {', 1 + )[0] + self.assertIn("GithubDeployOidc", document) + self.assertIn("sts:AssumeRoleWithWebIdentity", document) + self.assertIn("token.actions.githubusercontent.com:aud", document) + self.assertIn("sts.amazonaws.com", document) + self.assertIn("token.actions.githubusercontent.com:sub", document) + self.assertIn("local.github_subject", document) + self.assertIn("token.actions.githubusercontent.com:job_workflow_ref", document) + self.assertIn("deploy-web.yaml@refs/heads/main", document) + self.assertIn("deploy-web.yaml@refs/tags/v*", document) + def test_deploy_policy_controlled_update_passes(self) -> None: self.assert_passes( make_plan(