Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 099ed015ac | |||
| cc97a9308d | |||
| c2ca0e4631 | |||
| 225de0f613 | |||
| 69d8496107 |
@@ -112,6 +112,8 @@ def adapt(stack_instance, out_dir):
|
||||
|
||||
stack_name = stack.get("name", "spike")
|
||||
environment = stack.get("environment", "dev")
|
||||
account_id = os.environ.get("ACDL_AWS_ACCOUNT_ID", "581513795199")
|
||||
state_bucket = f"acdl-tfstate-{account_id}-us-east-1"
|
||||
terraform_tf = (
|
||||
'terraform {\n'
|
||||
' required_version = ">= 1.9, < 1.10"\n'
|
||||
@@ -122,7 +124,7 @@ def adapt(stack_instance, out_dir):
|
||||
' }\n'
|
||||
' }\n'
|
||||
' backend "s3" {\n'
|
||||
' bucket = "acdl-tfstate-581513795199-us-east-1"\n'
|
||||
f' bucket = "{state_bucket}"\n'
|
||||
f' key = "spike/{stack_name}/{environment}/terraform.tfstate"\n'
|
||||
' region = "us-east-1"\n'
|
||||
' }\n'
|
||||
@@ -137,7 +139,7 @@ def adapt(stack_instance, out_dir):
|
||||
'data "terraform_remote_state" "platform" {\n'
|
||||
' backend = "s3"\n'
|
||||
' config = {\n'
|
||||
' bucket = "acdl-tfstate-581513795199-us-east-1"\n'
|
||||
f' bucket = "{state_bucket}"\n'
|
||||
f' key = "{remote_state_key}"\n'
|
||||
' region = "us-east-1"\n'
|
||||
' }\n'
|
||||
|
||||
@@ -17,6 +17,7 @@ requests. The invoke policy is scoped via ABAC (consumer repo identity).
|
||||
import datetime
|
||||
import json
|
||||
import os
|
||||
import urllib.error
|
||||
import urllib.parse
|
||||
|
||||
import boto3
|
||||
@@ -154,7 +155,16 @@ def _report_error(payload):
|
||||
with urllib.request.urlopen(req, timeout=10) as resp:
|
||||
search_result = json.loads(resp.read())
|
||||
existing = search_result.get("items", [])
|
||||
except Exception:
|
||||
except urllib.error.HTTPError as e:
|
||||
if e.code == 404:
|
||||
existing = []
|
||||
else:
|
||||
import sys
|
||||
print(f"WARNING: GitHub issue search failed (HTTP {e.code}): {e}", file=sys.stderr)
|
||||
existing = []
|
||||
except urllib.error.URLError as e:
|
||||
import sys
|
||||
print(f"WARNING: GitHub issue search network error: {e}", file=sys.stderr)
|
||||
existing = []
|
||||
|
||||
body = f"""## Deploy Failure Report
|
||||
@@ -228,21 +238,42 @@ def _validate_caller_identity(event, payload):
|
||||
|
||||
If the identity is not available (e.g. local testing or non-IAM auth), the
|
||||
check is skipped (the ABAC policy at the IAM layer enforces the scope).
|
||||
|
||||
v1.14 (REQ-144): also validates contractId format, environment enum, and
|
||||
error length. The ABAC reliance is documented here: the Function URL IAM
|
||||
identity does not expose principal tags in the event, so full enforcement
|
||||
of consumerRepo ownership is at the IAM layer (ABAC via
|
||||
aws:PrincipalTag/acdl:owner). This function validates format only, not
|
||||
ownership.
|
||||
"""
|
||||
identity = event.get("requestContext", {}).get("identity", {})
|
||||
caller_arn = identity.get("userArn", "")
|
||||
if not caller_arn:
|
||||
return # no identity available — rely on IAM ABAC enforcement
|
||||
pass # no identity available — rely on IAM ABAC enforcement
|
||||
payload_repo = payload.get("consumerRepo", "")
|
||||
if not payload_repo:
|
||||
return
|
||||
# Extract the session name or principal tag from the ARN. The ABAC policy
|
||||
# scopes via aws:PrincipalTag/acdl:owner = <consumerRepo>. The Function URL
|
||||
# IAM identity does not expose principal tags in the event, so we do a
|
||||
# best-effort check: the consumerRepo must not be empty and must be a valid
|
||||
# repo identifier (org/repo format). Full enforcement is at the IAM layer.
|
||||
if "/" not in payload_repo or len(payload_repo) > 128:
|
||||
raise ValueError(f"invalid consumerRepo format: {payload_repo!r}")
|
||||
if payload_repo:
|
||||
# consumerRepo must be org/repo format, <=128 chars
|
||||
if "/" not in payload_repo or len(payload_repo) > 128:
|
||||
raise ValueError(f"invalid consumerRepo format: {payload_repo!r}")
|
||||
|
||||
# v1.14 (REQ-144): contractId format validation
|
||||
contract_id = payload.get("contractId", "")
|
||||
if contract_id:
|
||||
import re
|
||||
if not re.match(r'^[a-zA-Z0-9][a-zA-Z0-9_-]{0,63}$', contract_id):
|
||||
raise ValueError(f"invalid contractId format: {contract_id!r} (alphanumeric, hyphen, underscore; max 64 chars)")
|
||||
|
||||
# v1.14 (REQ-144): environment enum validation
|
||||
environment = payload.get("environment", "")
|
||||
if environment:
|
||||
valid_envs = {"dev", "qa", "prod", "dr"}
|
||||
if environment not in valid_envs:
|
||||
raise ValueError(f"invalid environment: {environment!r} (must be one of {valid_envs})")
|
||||
|
||||
# v1.14 (REQ-144): error length cap (for report_error action)
|
||||
error_msg = payload.get("error", "")
|
||||
if error_msg and len(str(error_msg)) > 10000:
|
||||
payload["error"] = str(error_msg)[:10000]
|
||||
|
||||
|
||||
def _validate_change_request(payload):
|
||||
|
||||
@@ -371,8 +371,9 @@ class LocalLambdaStub:
|
||||
return _FakeResponse(
|
||||
json.dumps([{"number": 1, "title": "stub"}]).encode())
|
||||
urllib.request.urlopen = _fake_urlopen
|
||||
except Exception:
|
||||
pass
|
||||
except (AttributeError, TypeError) as e:
|
||||
import sys
|
||||
print(f"WARNING: could not patch urlopen for local Lambda stub: {e}", file=sys.stderr)
|
||||
|
||||
try:
|
||||
event = {
|
||||
|
||||
@@ -97,8 +97,10 @@ def publish_to_ssm(outputs, environment, contract_id):
|
||||
Overwrite=True,
|
||||
)
|
||||
results[name] = param_name
|
||||
except Exception:
|
||||
# Don't fail the pipeline if one output fails to publish
|
||||
except Exception as e:
|
||||
# Don't fail the pipeline if one output fails to publish, but log it
|
||||
import sys
|
||||
print(f"WARNING: SSM put_parameter failed for {name}: {e}", file=sys.stderr)
|
||||
results[name] = None
|
||||
return results
|
||||
|
||||
@@ -165,7 +167,9 @@ def post_github_comment(comment_text, token=None, repo=None, pr_number=None):
|
||||
req.add_header("Accept", "application/vnd.github+json")
|
||||
urllib.request.urlopen(req, timeout=10)
|
||||
return True
|
||||
except Exception:
|
||||
except Exception as e:
|
||||
import sys
|
||||
print(f"WARNING: GitHub PR comment failed: {e}", file=sys.stderr)
|
||||
return False
|
||||
|
||||
|
||||
|
||||
@@ -421,8 +421,10 @@ def _check_s3_state_bucket() -> Tuple[Status, str]:
|
||||
s3 = boto3.client("s3", region_name=env.get("AWS_DEFAULT_REGION", "us-east-1"),
|
||||
aws_access_key_id=env.get("AWS_ACCESS_KEY_ID"),
|
||||
aws_secret_access_key=env.get("AWS_SECRET_ACCESS_KEY"))
|
||||
s3.head_bucket(Bucket="acdl-tfstate-581513795199-us-east-1")
|
||||
r = s3.list_objects_v2(Bucket="acdl-tfstate-581513795199-us-east-1", MaxKeys=5)
|
||||
account_id = os.environ.get("ACDL_AWS_ACCOUNT_ID", "581513795199")
|
||||
state_bucket = f"acdl-tfstate-{account_id}-us-east-1"
|
||||
s3.head_bucket(Bucket=state_bucket)
|
||||
r = s3.list_objects_v2(Bucket=state_bucket, MaxKeys=5)
|
||||
keys = [o["Key"] for o in r.get("Contents", [])]
|
||||
return "Verified", f"state bucket exists, keys={keys}"
|
||||
except Exception as e:
|
||||
|
||||
@@ -6,7 +6,7 @@ resource "aws_lb" "this" {
|
||||
}
|
||||
|
||||
resource "aws_lb_target_group" "this" {
|
||||
name_prefix = "tg-ci-"
|
||||
name_prefix = "${var.name}-"
|
||||
port = var.port
|
||||
protocol = var.protocol
|
||||
vpc_id = var.vpc_id
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
"wires": [
|
||||
{"from": "contract.inputs.name", "to": "alb.inputs.name", "default": "app"},
|
||||
{"from": "contract.inputs.name", "to": "ecr.inputs.name", "default": "app-repo"},
|
||||
{"from": "contract.inputs.name", "to": "roles.inputs.role_name", "default": "app-role"},
|
||||
{"from": "contract.inputs.name", "to": "roles.inputs.role_name", "default": "acdl-app-role"},
|
||||
{"from": "contract.inputs.region", "to": "cluster.inputs.region"},
|
||||
{"from": "contract.inputs.region", "to": "ecr.inputs.region"},
|
||||
{"from": "contract.inputs.region", "to": "roles.inputs.region"},
|
||||
|
||||
@@ -29,7 +29,7 @@ import boto3
|
||||
|
||||
REPO_ROOT = pathlib.Path(__file__).resolve().parent.parent
|
||||
ENV_FILE = REPO_ROOT / ".env.secrets"
|
||||
AWS_ACCOUNT_ID = "581513795199"
|
||||
AWS_ACCOUNT_ID = os.environ.get("ACDL_AWS_ACCOUNT_ID", "581513795199")
|
||||
AWS_REGION = "us-east-1"
|
||||
ECR_REPO_NAME = "acdl-microservice"
|
||||
IMAGE_TAG = "latest"
|
||||
|
||||
@@ -30,7 +30,7 @@ import boto3
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent.parent
|
||||
POLICY_PATH = ROOT / "terraform" / "bootstrap" / "spike_runner_policy.json"
|
||||
ACCOUNT = "581513795199"
|
||||
ACCOUNT = os.environ.get("ACDL_AWS_ACCOUNT_ID", "581513795199")
|
||||
USER = "acdl-spike-runner"
|
||||
POLICY_NAME = "acdl-spike-runner-policy"
|
||||
POLICY_ARN = f"arn:aws:iam::{ACCOUNT}:policy/{POLICY_NAME}"
|
||||
@@ -75,8 +75,10 @@ def apply_managed_policy(iam, policy_doc: str) -> str:
|
||||
try:
|
||||
iam.delete_policy_version(PolicyArn=POLICY_ARN, VersionId=default)
|
||||
print(f"deleted old default version {default}")
|
||||
except iam.exceptions.NoSuchEntityException:
|
||||
pass # already deleted
|
||||
except Exception as e:
|
||||
print(f"could not delete old version {default}: {e}")
|
||||
print(f"WARNING: could not delete old version {default}: {e}")
|
||||
return POLICY_ARN
|
||||
except iam.exceptions.NoSuchEntityException:
|
||||
print(f"creating managed policy {POLICY_NAME}...")
|
||||
|
||||
@@ -30,9 +30,9 @@ import boto3
|
||||
|
||||
|
||||
REGION = os.environ.get("AWS_DEFAULT_REGION", "us-east-1")
|
||||
STATE_BUCKET = "acdl-tfstate-581513795199-us-east-1"
|
||||
ACCOUNT_ID = os.environ.get("ACDL_AWS_ACCOUNT_ID", "581513795199")
|
||||
STATE_BUCKET = f"acdl-tfstate-{ACCOUNT_ID}-us-east-1"
|
||||
OUTBOX_TABLE = "acdl-outbox"
|
||||
ACCOUNT_ID = "581513795199"
|
||||
|
||||
|
||||
def main():
|
||||
@@ -48,12 +48,16 @@ def main():
|
||||
try:
|
||||
s3.head_bucket(Bucket=STATE_BUCKET)
|
||||
print(f"s3: bucket {STATE_BUCKET} already exists")
|
||||
except Exception:
|
||||
kwargs = {"Bucket": STATE_BUCKET}
|
||||
if REGION != "us-east-1":
|
||||
kwargs["CreateBucketConfiguration"] = {"LocationConstraint": REGION}
|
||||
s3.create_bucket(**kwargs)
|
||||
print(f"s3: created bucket {STATE_BUCKET}")
|
||||
except s3.exceptions.ClientError as e:
|
||||
error_code = e.response.get("Error", {}).get("Code", "")
|
||||
if error_code in ("404", "NoSuchBucket", "NotFound"):
|
||||
kwargs = {"Bucket": STATE_BUCKET}
|
||||
if REGION != "us-east-1":
|
||||
kwargs["CreateBucketConfiguration"] = {"LocationConstraint": REGION}
|
||||
s3.create_bucket(**kwargs)
|
||||
print(f"s3: created bucket {STATE_BUCKET}")
|
||||
else:
|
||||
raise
|
||||
# Enable versioning (idempotent)
|
||||
s3.put_bucket_versioning(
|
||||
Bucket=STATE_BUCKET,
|
||||
|
||||
@@ -215,7 +215,10 @@
|
||||
"kms:TagResource",
|
||||
"kms:UntagResource"
|
||||
],
|
||||
"Resource": "*"
|
||||
"Resource": [
|
||||
"arn:aws:kms:*:*:key/*",
|
||||
"arn:aws:kms:*:*:alias/acdl-*"
|
||||
]
|
||||
},
|
||||
{
|
||||
"Effect": "Allow",
|
||||
@@ -233,7 +236,7 @@
|
||||
"iam:TagRole",
|
||||
"iam:UntagRole"
|
||||
],
|
||||
"Resource": "*"
|
||||
"Resource": "arn:aws:iam::*:role/acdl-*"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -500,4 +500,43 @@ class TestValidateChangeRequest:
|
||||
resp = ingestor.lambda_handler(event, None)
|
||||
assert resp["statusCode"] == 200
|
||||
body = json.loads(resp["body"])
|
||||
assert body["action"] == "validate_change_request"
|
||||
assert body["action"] == "validate_change_request"
|
||||
|
||||
|
||||
class TestV14IdentityValidation:
|
||||
"""v1.14 (REQ-144): contractId format, environment enum, error length
|
||||
validation + spoofing resistance."""
|
||||
|
||||
def test_invalid_contract_id_rejected(self, moto_contracts_table, sample_payload):
|
||||
sample_payload["contractId"] = "bad contract!@#"
|
||||
event = {"body": json.dumps(sample_payload), "requestContext": {}}
|
||||
resp = ingestor.lambda_handler(event, None)
|
||||
assert resp["statusCode"] == 400
|
||||
assert "invalid contractId" in resp["body"]
|
||||
|
||||
def test_contract_id_too_long_rejected(self, moto_contracts_table, sample_payload):
|
||||
sample_payload["contractId"] = "a" * 65
|
||||
event = {"body": json.dumps(sample_payload), "requestContext": {}}
|
||||
resp = ingestor.lambda_handler(event, None)
|
||||
assert resp["statusCode"] == 400
|
||||
assert "invalid contractId" in resp["body"]
|
||||
|
||||
def test_invalid_environment_rejected(self, moto_contracts_table, sample_payload):
|
||||
sample_payload["environment"] = "staging"
|
||||
event = {"body": json.dumps(sample_payload), "requestContext": {}}
|
||||
resp = ingestor.lambda_handler(event, None)
|
||||
assert resp["statusCode"] == 400
|
||||
assert "invalid environment" in resp["body"]
|
||||
|
||||
def test_valid_environments_accepted(self, moto_contracts_table, sample_payload):
|
||||
for env in ["dev", "qa", "prod", "dr"]:
|
||||
sample_payload["environment"] = env
|
||||
event = {"body": json.dumps(sample_payload), "requestContext": {}}
|
||||
resp = ingestor.lambda_handler(event, None)
|
||||
assert resp["statusCode"] == 200
|
||||
|
||||
def test_abac_reliance_documented(self):
|
||||
"""The _validate_caller_identity docstring documents the ABAC reliance."""
|
||||
docstring = ingestor._validate_caller_identity.__doc__
|
||||
assert "ABAC" in docstring
|
||||
assert "PrincipalTag" in docstring
|
||||
@@ -176,4 +176,43 @@ class TestIAMPolicyBaseline:
|
||||
res = s.get("Resource", "")
|
||||
if isinstance(res, list):
|
||||
res = " ".join(res)
|
||||
assert res != "*", "iam:PassRole must not be granted to Resource: *"
|
||||
assert res != "*", "iam:PassRole must not be granted to Resource: *"
|
||||
|
||||
def test_iam_role_creation_scoped_to_acdl_prefix(self, policy):
|
||||
"""G-104: iam:CreateRole must be scoped to role/acdl-* (not Resource: *)."""
|
||||
for s in policy["Statement"]:
|
||||
acts = s.get("Action", [])
|
||||
if isinstance(acts, str):
|
||||
acts = [acts]
|
||||
if "iam:CreateRole" in acts:
|
||||
res = s.get("Resource", "")
|
||||
if isinstance(res, list):
|
||||
res = " ".join(res)
|
||||
assert "acdl-*" in res, f"iam:CreateRole must be scoped to acdl-* (got: {res})"
|
||||
|
||||
def test_kms_scoped_to_acdl_alias(self, policy):
|
||||
"""G-104: kms:CreateKey etc. must be scoped to alias/acdl-* (not Resource: *)."""
|
||||
for s in policy["Statement"]:
|
||||
acts = s.get("Action", [])
|
||||
if isinstance(acts, str):
|
||||
acts = [acts]
|
||||
if any(a.startswith("kms:") for a in acts):
|
||||
res = s.get("Resource", "")
|
||||
if isinstance(res, list):
|
||||
res = " ".join(res)
|
||||
assert "acdl-*" in res, f"kms actions must be scoped to acdl-* (got: {res})"
|
||||
|
||||
def test_cloudfront_waf_remain_global(self, policy):
|
||||
"""G-104: CloudFront + WAFv2 (CloudFront scope) ARNs are global;
|
||||
Resource: * is acceptable here (documented constraint, not a defect)."""
|
||||
global_actions = {"cloudfront:", "wafv2:"}
|
||||
for s in policy["Statement"]:
|
||||
acts = s.get("Action", [])
|
||||
if isinstance(acts, str):
|
||||
acts = [acts]
|
||||
if any(any(a.startswith(g) for g in global_actions) for a in acts):
|
||||
res = s.get("Resource", "")
|
||||
if isinstance(res, list):
|
||||
res = res[0] if res else ""
|
||||
# CloudFront/WAFv2 are allowed to be * (global ARNs)
|
||||
assert res == "*" or "acdl" in res
|
||||
Reference in New Issue
Block a user