# Nova platform infrastructure — contract ingestion Lambda + DynamoDB (D-051) # # Deploys: # - DynamoDB table nova-contracts (PK consumerRepo, SK contractId#submittedAt, SSE via CMK, PITR) # - KMS customer-managed key for DynamoDB + SSM (shared CMK) # - Lambda function nova-contract-ingestor (Python 3.12, handler contract_ingestor.lambda_handler) # - Lambda Function URL (IAM auth — consumers invoke via SigV4) # - Secrets Manager secret nova/github-token (stores the Lambda's GitHub PAT for issue creation) # - IAM execution role for the Lambda (DynamoDB write + Secrets Manager read + KMS decrypt) # # State: terraform/platform/terraform.tfstate (separate from spike/ and microservice/) terraform { required_version = ">= 1.9, < 1.10" required_providers { aws = { source = "hashicorp/aws" version = "~> 5.0" } } backend "s3" { bucket = "nova-tfstate-581513795199-us-east-1" key = "platform/terraform.tfstate" region = "us-east-1" } } provider "aws" { region = "us-east-1" } # v1.14 (REQ-154): VPC CIDR is parameterized (default 10.0.0.0/16). variable "vpc_cidr" { description = "CIDR block for the shared platform VPC (default 10.0.0.0/16)." type = string default = "10.0.0.0/16" } # KMS customer-managed key for DynamoDB SSE + SSM Parameter Store encryption resource "aws_kms_key" "nova_platform" { description = "Nova platform KMS key (DynamoDB SSE + SSM + Secrets Manager)" enable_key_rotation = true deletion_window_in_days = 30 } resource "aws_kms_alias" "nova_platform" { name = "alias/nova-platform" target_key_id = aws_kms_key.nova_platform.key_id } # DynamoDB table for contract ingestion resource "aws_dynamodb_table" "nova_contracts" { name = "nova-contracts" billing_mode = "PAY_PER_REQUEST" hash_key = "consumerRepo" range_key = "contractId#submittedAt" attribute { name = "consumerRepo" type = "S" } attribute { name = "contractId#submittedAt" type = "S" } point_in_time_recovery { enabled = true } server_side_encryption { enabled = true kms_key_arn = aws_kms_key.nova_platform.arn } tags = { "nova:owner" = "acdl" "nova:contract" = "platform" "nova:environment" = "prod" "nova:cost-center" = "nova-default" } } # Secrets Manager secret for the Lambda's GitHub token (issue creation) resource "aws_secretsmanager_secret" "github_token" { name = "nova/github-token" description = "GitHub PAT for the platform Lambda to create issues on the platform repo (D-055)." kms_key_id = aws_kms_key.nova_platform.arn tags = { "nova:owner" = "acdl" "nova:contract" = "platform" "nova:environment" = "prod" "nova:cost-center" = "nova-default" } } # IAM execution role for the Lambda resource "aws_iam_role" "lambda_exec" { name = "nova-contract-ingestor-role" assume_role_policy = jsonencode({ Version = "2012-10-17" Statement = [{ Action = "sts:AssumeRole" Effect = "Allow" Principal = { Service = "lambda.amazonaws.com" } }] }) } resource "aws_iam_role_policy" "lambda_permissions" { name = "nova-contract-ingestor-policy" role = aws_iam_role.lambda_exec.id policy = jsonencode({ Version = "2012-10-17" Statement = [ { Effect = "Allow" Action = ["dynamodb:PutItem", "dynamodb:GetItem", "dynamodb:Query", "dynamodb:UpdateItem"] Resource = aws_dynamodb_table.nova_contracts.arn }, { Effect = "Allow" Action = ["dynamodb:GetItem", "dynamodb:Query"] Resource = aws_dynamodb_table.nova_change_requests.arn }, { Effect = "Allow" Action = ["secretsmanager:GetSecretValue"] Resource = aws_secretsmanager_secret.github_token.arn }, { Effect = "Allow" Action = ["kms:Decrypt"] Resource = aws_kms_key.nova_platform.arn }, { Effect = "Allow" Action = ["logs:CreateLogGroup", "logs:CreateLogStream", "logs:PutLogEvents"] Resource = "arn:aws:logs:*:*:*" } ] }) } # Lambda function (conditional — only created when contract_ingestor.zip exists) # The lifecycle pipeline only needs the VPC; the full platform deploy builds the zip first. locals { lambda_zip_exists = fileexists("contract_ingestor.zip") } resource "aws_lambda_function" "contract_ingestor" { count = local.lambda_zip_exists ? 1 : 0 function_name = "nova-contract-ingestor" handler = "contract_ingestor.lambda_handler" runtime = "python3.12" role = aws_iam_role.lambda_exec.arn filename = "contract_ingestor.zip" source_code_hash = local.lambda_zip_exists ? filebase64sha256("contract_ingestor.zip") : "placeholder" environment { variables = { CONTRACTS_TABLE = aws_dynamodb_table.nova_contracts.name GITHUB_TOKEN_SECRET_ID = aws_secretsmanager_secret.github_token.name PLATFORM_REPO = "nova/acdl" } } tags = { "nova:owner" = "acdl" "nova:contract" = "platform" "nova:environment" = "prod" "nova:cost-center" = "nova-default" } } # Lambda Function URL (IAM auth — consumers invoke via SigV4) resource "aws_lambda_function_url" "contract_ingestor" { count = local.lambda_zip_exists ? 1 : 0 function_name = aws_lambda_function.contract_ingestor[0].function_name authorization_type = "AWS_IAM" } # P1-6: Render the consumer invoke policy with the live AWS account ID. # The JSON template (consumer_invoke_policy.json) uses ${account_id} and # ${region} placeholders. Terraform renders them at apply time using the # caller's live account ID — no hardcoded placeholder account IDs. data "aws_caller_identity" "current" {} data "aws_region" "current" {} locals { invoke_policy_template = file("${path.module}/consumer_invoke_policy.json") rendered_invoke_policy = replace( replace(local.invoke_policy_template, "$${account_id}", data.aws_caller_identity.current.account_id), "$${region}", data.aws_region.current.name ) } output "consumer_invoke_policy_rendered" { value = local.rendered_invoke_policy description = "The consumer invoke policy JSON with the live account ID rendered. Distribute this to consumer accounts during onboarding." } # REQ-93: DynamoDB table for change requests (CMDB for decommission validation) resource "aws_dynamodb_table" "nova_change_requests" { name = "nova-change-requests" billing_mode = "PAY_PER_REQUEST" hash_key = "changeRequestId" range_key = "submittedAt" attribute { name = "changeRequestId" type = "S" } attribute { name = "submittedAt" type = "S" } point_in_time_recovery { enabled = true } server_side_encryption { enabled = true kms_key_arn = aws_kms_key.nova_platform.arn } tags = { "nova:owner" = "acdl" "nova:contract" = "platform" "nova:environment" = "prod" "nova:cost-center" = "nova-default" } } # REQ-107: SNS topic for separation-of-duties halt artifacts. # route_halt_artifact publishes here when NOVA_SOD_HALT_TOPIC_ARN is set. resource "aws_sns_topic" "nova_sod_halt" { name = "nova-sod-halt" kms_master_key_id = aws_kms_key.nova_platform.id tags = { "nova:owner" = "acdl" "nova:contract" = "platform" "nova:environment" = "prod" "nova:cost-center" = "nova-default" } } output "nova_sod_halt_topic_arn" { value = aws_sns_topic.nova_sod_halt.arn } # --------------------------------------------------------------------------- # P58: Single shared platform VPC — all consumer stacks reference this VPC # via terraform_remote_state (data source). No per-contract VPC ever again. # --------------------------------------------------------------------------- resource "aws_vpc" "nova_shared" { cidr_block = var.vpc_cidr tags = { Name = "nova-shared" "nova:owner" = "acdl" "nova:contract" = "platform" "nova:environment" = "shared" "nova:cost-center" = "nova-default" } } resource "aws_subnet" "nova_shared" { count = length(data.aws_availability_zones.available.names) vpc_id = aws_vpc.nova_shared.id cidr_block = cidrsubnet(aws_vpc.nova_shared.cidr_block, 8, count.index + 1) availability_zone = data.aws_availability_zones.available.names[count.index] tags = { Name = "nova-shared-subnet-${count.index}" "nova:owner" = "acdl" "nova:contract" = "platform" "nova:environment" = "shared" "nova:cost-center" = "nova-default" } } data "aws_availability_zones" "available" { state = "available" } resource "aws_internet_gateway" "nova_shared" { vpc_id = aws_vpc.nova_shared.id tags = { Name = "nova-shared-igw" "nova:owner" = "acdl" "nova:contract" = "platform" "nova:environment" = "shared" "nova:cost-center" = "nova-default" } } resource "aws_route_table" "nova_shared" { vpc_id = aws_vpc.nova_shared.id route { cidr_block = "0.0.0.0/0" gateway_id = aws_internet_gateway.nova_shared.id } tags = { Name = "nova-shared-rt" "nova:owner" = "acdl" "nova:contract" = "platform" "nova:environment" = "shared" "nova:cost-center" = "nova-default" } } resource "aws_route_table_association" "nova_shared" { count = 2 subnet_id = aws_subnet.nova_shared[count.index].id route_table_id = aws_route_table.nova_shared.id } resource "aws_security_group" "ecs" { name = "nova-ecs-sg" description = "Security group for ECS Fargate services (platform VPC)" vpc_id = aws_vpc.nova_shared.id # Ingress on port 80 is open to 0.0.0.0/0 — this is acceptable because # the ECS service is fronted by a public-facing ALB (the ALB terminates # TLS + routes to the target group). The ECS SG should not be attached # directly to resources without an ALB in front. v1.14 (REQ-154). ingress { from_port = 80 to_port = 80 protocol = "tcp" cidr_blocks = ["0.0.0.0/0"] } egress { from_port = 0 to_port = 0 protocol = "-1" cidr_blocks = ["0.0.0.0/0"] } tags = { Name = "nova-ecs-sg" "nova:owner" = "acdl" "nova:contract" = "platform" "nova:environment" = "shared" "nova:cost-center" = "nova-default" } } output "vpc_id" { value = aws_vpc.nova_shared.id description = "The shared platform VPC ID. Consumer stacks reference this via terraform_remote_state." } output "subnet_ids" { value = join(",", aws_subnet.nova_shared[*].id) description = "Comma-separated subnet IDs in the shared platform VPC." } output "ecs_security_group_id" { value = aws_security_group.ecs.id description = "Security group ID for ECS Fargate services in the platform VPC." }