diff --git a/core/lambda/contract_ingestor.py b/core/lambda/contract_ingestor.py index c1d0ec4..8e0a03b 100644 --- a/core/lambda/contract_ingestor.py +++ b/core/lambda/contract_ingestor.py @@ -238,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 = . 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): diff --git a/tests/test_contract_ingestor.py b/tests/test_contract_ingestor.py index 8639c9c..0ec683c 100644 --- a/tests/test_contract_ingestor.py +++ b/tests/test_contract_ingestor.py @@ -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" \ No newline at end of file + 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 \ No newline at end of file