diff --git a/core/lambda/contract_ingestor.py b/core/lambda/contract_ingestor.py index 8880d5d..9b8c0b1 100644 --- a/core/lambda/contract_ingestor.py +++ b/core/lambda/contract_ingestor.py @@ -398,6 +398,65 @@ def _validate_change_request(payload): } +def _onboard_consumer(payload): + """P18 (REQ-182): accept a self-service onboarding request. + + Validates the payload against schemas/onboarding.schema.json, then + writes a 'pending' row to nova-contracts (D-119). No AWS resources + are created by this action (D-113); the cross-account role + ABAC + tag grant is offline-proven Terraform (P20/REQ-184). + """ + import jsonschema + schema_path = os.path.join(os.path.dirname(os.path.dirname( + os.path.dirname(os.path.abspath(__file__)))), + "schemas", "onboarding.schema.json") + try: + with open(schema_path) as f: + schema = json.load(f) + # Strip the Lambda dispatch envelope (action) before validating + # against the onboarding schema (the schema is about the request, + # not the Lambda wrapper). + onboarding_payload = {k: v for k, v in payload.items() if k != "action"} + jsonschema.validate(instance=onboarding_payload, schema=schema) + except OSError: + raise ValueError("onboarding schema unavailable") + except jsonschema.ValidationError as e: + raise ValueError(f"onboarding payload invalid: {e.message}") + + consumer_repo = payload["consumerRepo"] + requested_env = payload["requestedEnvironment"] + owner_id = payload["ownerId"] + billing_tag = payload["billingTag"] + submitted_at = _iso8601_now() + + # Write a pending CMDB row (PK consumerRepo, SK onboarding#env#timestamp). + table = _get_dynamodb().Table(TABLE_NAME) + item = { + "consumerRepo": consumer_repo, + "contractId#submittedAt": f"onboarding#{requested_env}#{submitted_at}", + "contractId": f"onboarding-{requested_env}", + "environment": requested_env, + "status": "pending", + "ownerId": owner_id, + "billingTag": billing_tag, + "notes": payload.get("notes", ""), + "submittedAt": submitted_at, + } + table.put_item(TableName=TABLE_NAME, Item=item) + return { + "status": "pending", + "consumerRepo": consumer_repo, + "requestedEnvironment": requested_env, + "action": "onboard_consumer", + "submittedAt": submitted_at, + "message": ( + "Onboarding request received. The platform team will provision " + "the environment binding + cross-account role. Track the status " + "via the nova-contracts table (status=pending → granted)." + ), + } + + def lambda_handler(event, context): """AWS Lambda handler entry point. @@ -426,6 +485,8 @@ def lambda_handler(event, context): result = _report_error(payload) elif action == "validate_change_request": result = _validate_change_request(payload) + elif action == "onboard_consumer": + result = _onboard_consumer(payload) else: return { "statusCode": 400, diff --git a/schemas/onboarding.schema.json b/schemas/onboarding.schema.json new file mode 100644 index 0000000..f982052 --- /dev/null +++ b/schemas/onboarding.schema.json @@ -0,0 +1,39 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://nova.cloudinit.dev/schemas/onboarding.schema.json", + "title": "Nova Consumer Onboarding Request", + "description": "A self-service onboarding request from a consumer repo. Submitted to the contract_ingestor Lambda 'onboard_consumer' action (D-113, P18/REQ-182). The Lambda validates the payload against this schema, then writes a 'pending' CMDB row to nova-contracts. No AWS resources are created by this action (D-119); the cross-account role + ABAC tag grant is offline-proven Terraform (P20/REQ-184).", + "type": "object", + "required": ["consumerRepo", "requestedEnvironment", "ownerId", "billingTag"], + "additionalProperties": false, + "properties": { + "consumerRepo": { + "type": "string", + "description": "The consumer repository in org/repo format.", + "pattern": "^[a-zA-Z0-9_.-]+/[a-zA-Z0-9_.-]+$", + "maxLength": 128 + }, + "requestedEnvironment": { + "type": "string", + "description": "The environment the consumer requests (must exist as a core/environments/.json).", + "enum": ["dev", "qa", "prod", "dr"] + }, + "ownerId": { + "type": "string", + "description": "The owning team or individual (for ABAC nova:owner tag + CMDB).", + "minLength": 1, + "maxLength": 64 + }, + "billingTag": { + "type": "string", + "description": "The cost-center / billing tag for the consumer's resources.", + "minLength": 1, + "maxLength": 64 + }, + "notes": { + "type": "string", + "description": "Optional free-form notes for the platform team.", + "maxLength": 500 + } + } +} \ No newline at end of file diff --git a/tests/test_contract_ingestor.py b/tests/test_contract_ingestor.py index 89a30fd..58d3242 100644 --- a/tests/test_contract_ingestor.py +++ b/tests/test_contract_ingestor.py @@ -593,4 +593,52 @@ class TestV14IdentityValidation: """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 + assert "PrincipalTag" in docstring + +class TestOnboardConsumer: + """P18 (REQ-182): the onboard_consumer action writes a pending CMDB row.""" + + _ARN = "arn:aws:sts::000:assumed-role/nova-deploy/test" + + def test_valid_onboarding_writes_pending_row(self, moto_contracts_table): + payload = { + "action": "onboard_consumer", + "consumerRepo": "acdl/consumer-b", + "requestedEnvironment": "dev", + "ownerId": "team-b", + "billingTag": "cost-center-b", + } + event = {"body": json.dumps(payload), "requestContext": {"identity": {"userArn": self._ARN}}} + resp = ingestor.lambda_handler(event, None) + assert resp["statusCode"] == 200 + body = json.loads(resp["body"]) + assert body["status"] == "pending" + assert body["action"] == "onboard_consumer" + assert body["requestedEnvironment"] == "dev" + + def test_invalid_onboarding_rejected(self, moto_contracts_table): + # An invalid consumerRepo (no /) fails the identity format check + # (which runs for all actions) before the onboarding schema. + payload = { + "action": "onboard_consumer", + "consumerRepo": "not-a-repo-format", + "requestedEnvironment": "dev", + "ownerId": "team-b", + "billingTag": "cost-center-b", + } + event = {"body": json.dumps(payload), "requestContext": {"identity": {"userArn": self._ARN}}} + resp = ingestor.lambda_handler(event, None) + assert resp["statusCode"] == 400 + assert "invalid consumerRepo" in json.loads(resp["body"])["error"] + + def test_missing_onboarding_field_rejected(self, moto_contracts_table): + payload = { + "action": "onboard_consumer", + "consumerRepo": "acdl/consumer-b", + "requestedEnvironment": "dev", + # ownerId + billingTag missing + } + event = {"body": json.dumps(payload), "requestContext": {"identity": {"userArn": self._ARN}}} + resp = ingestor.lambda_handler(event, None) + assert resp["statusCode"] == 400 + assert "onboarding payload invalid" in json.loads(resp["body"])["error"]