fix(terraform): validate exact pre-adoption policies

This commit is contained in:
Adam Moussa 2026-08-30 23:49:40 -04:00
parent a9cdd947bf
commit 111eb55659
No known key found for this signature in database
3 changed files with 269 additions and 14 deletions

View file

@ -177,6 +177,53 @@ def _distribution_id(
return None return None
def _expected_pre_adoption_bucket_policy(
environment: str,
distribution_id: str,
) -> dict[str, Any]:
config = ENVIRONMENT_CONFIG[environment]
bucket_arn = f"arn:aws:s3:::{config['bucket_name']}"
distribution_arn = (
f"arn:aws:cloudfront::396287094661:distribution/{distribution_id}"
)
return _canonical(
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Principal": {
"AWS": config["bucket_auto_delete_helper_role_arn"]
},
"Action": [
"s3:DeleteObject*",
"s3:GetBucket*",
"s3:List*",
"s3:PutBucketPolicy",
],
"Resource": [bucket_arn, f"{bucket_arn}/*"],
},
{
"Effect": "Allow",
"Principal": {"Service": "cloudfront.amazonaws.com"},
"Action": "s3:GetObject",
"Resource": f"{bucket_arn}/*",
"Condition": {
"StringEquals": {"AWS:SourceArn": distribution_arn}
},
},
{
"Effect": "Deny",
"Principal": {"AWS": "*"},
"Action": "s3:*",
"Resource": [bucket_arn, f"{bucket_arn}/*"],
"Condition": {"Bool": {"aws:SecureTransport": "false"}},
},
],
}
)
def _expected_bucket_policy(environment: str, distribution_id: str) -> dict[str, Any]: def _expected_bucket_policy(environment: str, distribution_id: str) -> dict[str, Any]:
bucket = ENVIRONMENT_CONFIG[environment]["bucket_name"] bucket = ENVIRONMENT_CONFIG[environment]["bucket_name"]
bucket_arn = f"arn:aws:s3:::{bucket}" bucket_arn = f"arn:aws:s3:::{bucket}"
@ -208,6 +255,66 @@ def _expected_bucket_policy(environment: str, distribution_id: str) -> dict[str,
) )
def _expected_pre_adoption_deploy_policy(
environment: str,
distribution_id: str,
) -> dict[str, Any]:
config = ENVIRONMENT_CONFIG[environment]
bucket_arn = f"arn:aws:s3:::{config['bucket_name']}"
distribution_arn = (
f"arn:aws:cloudfront::396287094661:distribution/{distribution_id}"
)
statements: list[dict[str, Any]] = []
if environment == "dev":
statements.append(
{
"Sid": "AssumeCdkBootstrapRoles",
"Effect": "Allow",
"Action": "sts:AssumeRole",
"Resource": "arn:aws:iam::396287094661:role/cdk-hnb659fds-*",
}
)
statements.extend(
[
{
"Sid": "DescribeStack",
"Effect": "Allow",
"Action": "cloudformation:DescribeStacks",
"Resource": (
"arn:aws:cloudformation:us-east-1:396287094661:stack/"
f"{config['cloudformation_stack_name']}/*"
),
},
{
"Effect": "Allow",
"Action": [
"s3:Abort*",
"s3:DeleteObject*",
"s3:GetBucket*",
"s3:GetObject*",
"s3:List*",
"s3:PutObject",
"s3:PutObjectLegalHold",
"s3:PutObjectRetention",
"s3:PutObjectTagging",
"s3:PutObjectVersionTagging",
],
"Resource": [bucket_arn, f"{bucket_arn}/*"],
},
{
"Sid": "InvalidateDistribution",
"Effect": "Allow",
"Action": [
"cloudfront:CreateInvalidation",
"cloudfront:GetInvalidation",
],
"Resource": distribution_arn,
},
]
)
return _canonical({"Version": "2012-10-17", "Statement": statements})
def _expected_deploy_policy(environment: str, distribution_id: str) -> dict[str, Any]: def _expected_deploy_policy(environment: str, distribution_id: str) -> dict[str, Any]:
bucket = ENVIRONMENT_CONFIG[environment]["bucket_name"] bucket = ENVIRONMENT_CONFIG[environment]["bucket_name"]
bucket_arn = f"arn:aws:s3:::{bucket}" bucket_arn = f"arn:aws:s3:::{bucket}"
@ -325,12 +432,19 @@ def _validate_policy_update(
f"{address}: cannot verify policy without the pinned distribution ID" f"{address}: cannot verify policy without the pinned distribution ID"
) )
return violations return violations
expected = ( expected_before = (
_expected_pre_adoption_bucket_policy(environment, distribution_id)
if address == BUCKET_POLICY_ADDRESS
else _expected_pre_adoption_deploy_policy(environment, distribution_id)
)
expected_after = (
_expected_bucket_policy(environment, distribution_id) _expected_bucket_policy(environment, distribution_id)
if address == BUCKET_POLICY_ADDRESS if address == BUCKET_POLICY_ADDRESS
else _expected_deploy_policy(environment, distribution_id) else _expected_deploy_policy(environment, distribution_id)
) )
if after_policy is not None and after_policy != expected: if before_policy is not None and before_policy != expected_before:
violations.append(f"{address}: pre-adoption policy semantics are not exact")
if after_policy is not None and after_policy != expected_after:
violations.append(f"{address}: post-adoption policy semantics are not exact") violations.append(f"{address}: post-adoption policy semantics are not exact")
return violations return violations

View file

@ -47,16 +47,31 @@ CONTROLLED_UPDATE_ADDRESSES = frozenset(
ENVIRONMENT_CONFIG = { ENVIRONMENT_CONFIG = {
"dev": { "dev": {
"bucket_name": "seahaven-shoc-frontend-dev", "bucket_name": "seahaven-shoc-frontend-dev",
"bucket_auto_delete_helper_role_arn": (
"arn:aws:iam::396287094661:role/"
"shoc-frontend-dev-CustomS3AutoDeleteObjectsCustomRe-dmSDIY8EH7KV"
),
"cloudformation_stack_name": "shoc-frontend-dev",
"distribution_id": "E2CWLM1AFB964P", "distribution_id": "E2CWLM1AFB964P",
"workspace_name": "shoc-frontend-new-dev", "workspace_name": "shoc-frontend-new-dev",
}, },
"staging": { "staging": {
"bucket_name": "seahaven-shoc-frontend-staging", "bucket_name": "seahaven-shoc-frontend-staging",
"bucket_auto_delete_helper_role_arn": (
"arn:aws:iam::396287094661:role/"
"shoc-frontend-staging-CustomS3AutoDeleteObjectsCust-QbMDqZbl7YQ3"
),
"cloudformation_stack_name": "shoc-frontend-staging",
"distribution_id": "E2JDVEZ6EGD49J", "distribution_id": "E2JDVEZ6EGD49J",
"workspace_name": "shoc-frontend-new-staging", "workspace_name": "shoc-frontend-new-staging",
}, },
"tf-poc": { "tf-poc": {
"bucket_name": "seahaven-shoc-frontend-tf-poc", "bucket_name": "seahaven-shoc-frontend-tf-poc",
"bucket_auto_delete_helper_role_arn": (
"arn:aws:iam::396287094661:role/"
"shoc-frontend-tf-poc-CustomS3AutoDeleteObjectsCusto-GBCGk2k2ZORz"
),
"cloudformation_stack_name": "shoc-frontend-tf-poc",
"distribution_id": None, "distribution_id": None,
"workspace_name": "shoc-frontend-new-tf-poc", "workspace_name": "shoc-frontend-new-tf-poc",
}, },

View file

@ -43,6 +43,47 @@ def distribution_id(environment: str) -> str:
return configured if isinstance(configured, str) else "ETFPOCGENERATED123" return configured if isinstance(configured, str) else "ETFPOCGENERATED123"
def pre_adoption_bucket_policy(environment: str) -> dict[str, Any]:
config = ENVIRONMENT_CONFIG[environment]
bucket_arn = f"arn:aws:s3:::{config['bucket_name']}"
source = (
"arn:aws:cloudfront::396287094661:distribution/"
f"{distribution_id(environment)}"
)
return {
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Principal": {
"AWS": config["bucket_auto_delete_helper_role_arn"]
},
"Action": [
"s3:DeleteObject*",
"s3:GetBucket*",
"s3:List*",
"s3:PutBucketPolicy",
],
"Resource": [bucket_arn, f"{bucket_arn}/*"],
},
{
"Effect": "Allow",
"Principal": {"Service": "cloudfront.amazonaws.com"},
"Action": "s3:GetObject",
"Resource": f"{bucket_arn}/*",
"Condition": {"StringEquals": {"AWS:SourceArn": source}},
},
{
"Effect": "Deny",
"Principal": {"AWS": "*"},
"Action": "s3:*",
"Resource": [bucket_arn, f"{bucket_arn}/*"],
"Condition": {"Bool": {"aws:SecureTransport": "false"}},
},
],
}
def bucket_policy(environment: str) -> dict[str, Any]: def bucket_policy(environment: str) -> dict[str, Any]:
bucket = ENVIRONMENT_CONFIG[environment]["bucket_name"] bucket = ENVIRONMENT_CONFIG[environment]["bucket_name"]
bucket_arn = f"arn:aws:s3:::{bucket}" bucket_arn = f"arn:aws:s3:::{bucket}"
@ -71,6 +112,64 @@ def bucket_policy(environment: str) -> dict[str, Any]:
} }
def pre_adoption_deploy_policy(environment: str) -> dict[str, Any]:
config = ENVIRONMENT_CONFIG[environment]
bucket_arn = f"arn:aws:s3:::{config['bucket_name']}"
distribution_arn = (
"arn:aws:cloudfront::396287094661:distribution/"
f"{distribution_id(environment)}"
)
statements: list[dict[str, Any]] = []
if environment == "dev":
statements.append(
{
"Sid": "AssumeCdkBootstrapRoles",
"Effect": "Allow",
"Action": "sts:AssumeRole",
"Resource": "arn:aws:iam::396287094661:role/cdk-hnb659fds-*",
}
)
statements.extend(
[
{
"Sid": "DescribeStack",
"Effect": "Allow",
"Action": "cloudformation:DescribeStacks",
"Resource": (
"arn:aws:cloudformation:us-east-1:396287094661:stack/"
f"{config['cloudformation_stack_name']}/*"
),
},
{
"Effect": "Allow",
"Action": [
"s3:Abort*",
"s3:DeleteObject*",
"s3:GetBucket*",
"s3:GetObject*",
"s3:List*",
"s3:PutObject",
"s3:PutObjectLegalHold",
"s3:PutObjectRetention",
"s3:PutObjectTagging",
"s3:PutObjectVersionTagging",
],
"Resource": [bucket_arn, f"{bucket_arn}/*"],
},
{
"Sid": "InvalidateDistribution",
"Effect": "Allow",
"Action": [
"cloudfront:CreateInvalidation",
"cloudfront:GetInvalidation",
],
"Resource": distribution_arn,
},
]
)
return {"Version": "2012-10-17", "Statement": statements}
def deploy_policy(environment: str) -> dict[str, Any]: def deploy_policy(environment: str) -> dict[str, Any]:
bucket = ENVIRONMENT_CONFIG[environment]["bucket_name"] bucket = ENVIRONMENT_CONFIG[environment]["bucket_name"]
bucket_arn = f"arn:aws:s3:::{bucket}" bucket_arn = f"arn:aws:s3:::{bucket}"
@ -152,12 +251,16 @@ def tag_change(environment: str, address: str) -> dict[str, Any]:
def policy_change(environment: str, address: str) -> dict[str, Any]: def policy_change(environment: str, address: str) -> dict[str, Any]:
before_policy = (
pre_adoption_bucket_policy(environment)
if address == BUCKET_POLICY
else pre_adoption_deploy_policy(environment)
)
after_policy = ( after_policy = (
bucket_policy(environment) bucket_policy(environment)
if address == BUCKET_POLICY if address == BUCKET_POLICY
else deploy_policy(environment) else deploy_policy(environment)
) )
before_policy = {"Version": "2012-10-17", "Statement": []}
return { return {
"actions": ["update"], "actions": ["update"],
"before": {"policy": json.dumps(before_policy)}, "before": {"policy": json.dumps(before_policy)},
@ -384,17 +487,18 @@ class ImportPlanCheckerTests(unittest.TestCase):
self.assert_fails(plan, "dev", post_import=True) self.assert_fails(plan, "dev", post_import=True)
def test_every_allowed_controlled_diff_passes(self) -> None: def test_every_allowed_controlled_diff_passes(self) -> None:
for address in CONTROLLED_UPDATE_ADDRESSES: for environment in REQUIRED_RESOURCES:
with self.subTest(address=address): for address in CONTROLLED_UPDATE_ADDRESSES:
self.assert_passes( with self.subTest(environment=environment, address=address):
make_plan( self.assert_passes(
"tf-poc", make_plan(
mode="controlled", environment,
controlled_updates={address}, mode="controlled",
), controlled_updates={address},
"tf-poc", ),
address, environment,
) address,
)
def test_full_exact_controlled_allowlist_passes(self) -> None: def test_full_exact_controlled_allowlist_passes(self) -> None:
addresses = tuple(sorted(CONTROLLED_UPDATE_ADDRESSES)) addresses = tuple(sorted(CONTROLLED_UPDATE_ADDRESSES))
@ -480,6 +584,28 @@ class ImportPlanCheckerTests(unittest.TestCase):
with self.subTest(mutation=mutation): with self.subTest(mutation=mutation):
self.assert_fails(plan, "staging", DEPLOY_POLICY) self.assert_fails(plan, "staging", DEPLOY_POLICY)
def test_policy_updates_require_exact_pre_adoption_state(self) -> None:
for environment in REQUIRED_RESOURCES:
for address in (BUCKET_POLICY, DEPLOY_POLICY):
plan = make_plan(
environment,
mode="controlled",
controlled_updates={address},
)
change = resource(plan, address)["change"]
before = json.loads(change["before"]["policy"])
before["Statement"].append(
{
"Sid": "UnexpectedDrift",
"Effect": "Deny",
"Action": "*",
"Resource": "*",
}
)
change["before"]["policy"] = json.dumps(before)
with self.subTest(environment=environment, address=address):
self.assert_fails(plan, environment, address)
def test_controlled_update_rejects_unknown_and_replace_paths(self) -> None: def test_controlled_update_rejects_unknown_and_replace_paths(self) -> None:
for field, value in ( for field, value in (
("after_unknown", {"tags": {"ManagedBy": True}}), ("after_unknown", {"tags": {"ManagedBy": True}}),