@aws/nx-plugin 1.0.0-rc.84 → 1.0.0-rc.85

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. package/generators.json +7 -0
  2. package/migrations.json +6 -1
  3. package/package.json +1 -1
  4. package/src/internal/test-matrix/generator.js +16 -0
  5. package/src/internal/test-matrix/generator.js.map +1 -1
  6. package/src/migrations/latest/terraform-project-checkov-target-and-bootstrap-destroy/metadata.json +3 -0
  7. package/src/migrations/latest/terraform-project-checkov-target-and-bootstrap-destroy/migration.d.ts +6 -0
  8. package/src/migrations/latest/terraform-project-checkov-target-and-bootstrap-destroy/migration.js +146 -0
  9. package/src/migrations/latest/terraform-project-checkov-target-and-bootstrap-destroy/migration.js.map +1 -0
  10. package/src/open-api/json-metadata/generator.d.ts +24 -0
  11. package/src/open-api/json-metadata/generator.js +37 -0
  12. package/src/open-api/json-metadata/generator.js.map +1 -0
  13. package/src/open-api/json-metadata/schema.d.js +6 -0
  14. package/src/open-api/json-metadata/schema.d.js.map +1 -0
  15. package/src/open-api/json-metadata/schema.d.ts +9 -0
  16. package/src/open-api/json-metadata/schema.json +20 -0
  17. package/src/py/fast-api/__snapshots__/generator.terraform.spec.ts.snap +1604 -559
  18. package/src/py/fast-api/generator.js +2 -1
  19. package/src/py/fast-api/generator.js.map +1 -1
  20. package/src/sdk/open-api.d.ts +2 -0
  21. package/src/sdk/open-api.js +2 -1
  22. package/src/sdk/open-api.js.map +1 -1
  23. package/src/smithy/ts/api/__snapshots__/generator.spec.ts.snap +1548 -440
  24. package/src/smithy/ts/api/generator.js +2 -1
  25. package/src/smithy/ts/api/generator.js.map +1 -1
  26. package/src/terraform/project/files/application/bootstrap/providers.tf.template +2 -0
  27. package/src/terraform/project/files/application/scripts/bootstrap-destroy.ts.template +111 -0
  28. package/src/terraform/project/files/application/src/providers.tf.template +2 -0
  29. package/src/terraform/project/files/checkov/checkov.yml.template +8 -0
  30. package/src/terraform/project/generator.js +47 -4
  31. package/src/terraform/project/generator.js.map +1 -1
  32. package/src/trpc/backend/__snapshots__/generator.spec.ts.snap +1552 -525
  33. package/src/trpc/backend/files-operations/scripts/generate-operations.ts.template +40 -0
  34. package/src/trpc/backend/generator.js +10 -0
  35. package/src/trpc/backend/generator.js.map +1 -1
  36. package/src/utils/api-constructs/api-constructs.js +11 -0
  37. package/src/utils/api-constructs/api-constructs.js.map +1 -1
  38. package/src/utils/api-constructs/files/terraform/app/apis/http/__apiNameKebabCase__/__apiNameKebabCase__.tf.template +243 -3
  39. package/src/utils/api-constructs/files/terraform/app/apis/rest/__apiNameKebabCase__/__apiNameKebabCase__.tf.template +428 -1
  40. package/src/utils/api-constructs/open-api-metadata.d.ts +20 -3
  41. package/src/utils/api-constructs/open-api-metadata.js +64 -8
  42. package/src/utils/api-constructs/open-api-metadata.js.map +1 -1
  43. package/src/utils/api-constructs/trpc-operations-metadata.d.ts +21 -0
  44. package/src/utils/api-constructs/trpc-operations-metadata.js +55 -0
  45. package/src/utils/api-constructs/trpc-operations-metadata.js.map +1 -0
@@ -387,6 +387,49 @@ resource "random_string" "suffix" {
387
387
  upper = false
388
388
  }
389
389
 
390
+ locals {
391
+ # Generated at build time from the API definition
392
+ operations_file = "\${path.module}/../../../generated/test-api/operations.json"
393
+ operations = fileexists(local.operations_file) ? jsondecode(file(local.operations_file)) : {}
394
+
395
+ operation_slug = { for op in keys(local.operations) : op => replace(op, "/[^a-zA-Z0-9-_]/", "-") }
396
+ operation_hash = { for op in keys(local.operations) : op => substr(sha256(op), 0, 8) }
397
+
398
+ function_name = { for op in keys(local.operations) : op =>
399
+ "\${substr("TestApi-\${local.operation_slug[op]}", 0, 64 - length(local.operation_hash[op]) - length(random_string.suffix.result) - 2)}-\${local.operation_hash[op]}-\${random_string.suffix.result}"
400
+ }
401
+ role_name = { for op in keys(local.operations) : op =>
402
+ "\${substr("TestApi-\${local.operation_slug[op]}-role", 0, 64 - length(local.operation_hash[op]) - length(random_string.suffix.result) - 2)}-\${local.operation_hash[op]}-\${random_string.suffix.result}"
403
+ }
404
+
405
+ operation_path = { for op, details in local.operations : op =>
406
+ startswith(details.path, "/") ? details.path : "/\${details.path}"
407
+ }
408
+ route_key = { for op, details in local.operations : op =>
409
+ "\${upper(details.method)} \${local.operation_path[op]}"
410
+ }
411
+
412
+ permission_source_suffix = { for op, details in local.operations : op =>
413
+ "\${upper(details.method)}\${replace(local.operation_path[op], "/{[^}]*}/", "*")}"
414
+ }
415
+ }
416
+
417
+ resource "terraform_data" "operations_metadata" {
418
+ # Fails the plan when the operations metadata has not been generated
419
+ input = local.operations_file
420
+
421
+ lifecycle {
422
+ precondition {
423
+ condition = fileexists(local.operations_file)
424
+ error_message = "Operations metadata not found at \${local.operations_file}. Build the TestApi project to generate it."
425
+ }
426
+ precondition {
427
+ condition = length(local.operations) > 0
428
+ error_message = "No operations found in \${local.operations_file}. The TestApi API must define at least one operation."
429
+ }
430
+ }
431
+ }
432
+
390
433
  # Resources
391
434
 
392
435
  # Create Lambda ZIP file from the bundle directory
@@ -447,9 +490,10 @@ resource "aws_vpc_security_group_egress_rule" "api_lambda_https" {
447
490
  description = "Allow outbound HTTPS to AWS service endpoints"
448
491
  }
449
492
 
450
- # Lambda function
451
- # This configures a single "router" lambda to serve all requests
452
493
  resource "aws_lambda_function" "api_lambda" {
494
+ # One lambda function per operation, each serving just that operation
495
+ for_each = local.operations
496
+
453
497
  #checkov:skip=CKV_AWS_117:Lambda function is optionally deployed into a VPC via vpc_id/subnet_ids; not required for this use case
454
498
  #checkov:skip=CKV_AWS_116:Dead Letter Queue not required for this simple API use case
455
499
  #checkov:skip=CKV_AWS_272:Code signing not required for this use case
@@ -458,8 +502,8 @@ resource "aws_lambda_function" "api_lambda" {
458
502
  s3_bucket = aws_s3_object.lambda_zip.bucket
459
503
  s3_key = aws_s3_object.lambda_zip.key
460
504
  s3_object_version = aws_s3_object.lambda_zip.version_id
461
- function_name = "TestApiHandler-\${random_string.suffix.result}"
462
- role = aws_iam_role.lambda_execution_role.arn
505
+ function_name = local.function_name[each.key]
506
+ role = aws_iam_role.lambda_execution_role[each.key].arn
463
507
  handler = "run.sh"
464
508
  runtime = "python3.14"
465
509
  layers = ["arn:aws:lambda:\${data.aws_region.current.region}:753240598075:layer:LambdaAdapterLayerX86:24"]
@@ -504,7 +548,9 @@ resource "aws_lambda_function" "api_lambda" {
504
548
 
505
549
  # IAM role for Lambda execution
506
550
  resource "aws_iam_role" "lambda_execution_role" {
507
- name = "TestApiHandler-execution-role-\${random_string.suffix.result}"
551
+ for_each = local.operations
552
+
553
+ name = local.role_name[each.key]
508
554
 
509
555
  assume_role_policy = jsonencode({
510
556
  Version = "2012-10-17"
@@ -524,32 +570,38 @@ resource "aws_iam_role" "lambda_execution_role" {
524
570
 
525
571
  # Attach basic execution policy to Lambda role
526
572
  resource "aws_iam_role_policy_attachment" "lambda_basic_execution" {
573
+ for_each = local.operations
574
+
527
575
  policy_arn = "arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole"
528
- role = aws_iam_role.lambda_execution_role.name
576
+ role = aws_iam_role.lambda_execution_role[each.key].name
529
577
  }
530
578
 
531
579
  # Attach X-Ray tracing policy to Lambda role
532
580
  resource "aws_iam_role_policy_attachment" "lambda_xray_execution" {
581
+ for_each = local.operations
582
+
533
583
  policy_arn = "arn:aws:iam::aws:policy/AWSXRayDaemonWriteAccess"
534
- role = aws_iam_role.lambda_execution_role.name
584
+ role = aws_iam_role.lambda_execution_role[each.key].name
535
585
  }
536
586
 
537
587
  # Attach VPC access policy to Lambda role when deployed into a VPC
538
588
  resource "aws_iam_role_policy_attachment" "lambda_vpc_access" {
539
- count = var.enable_vpc ? 1 : 0
589
+ for_each = var.enable_vpc ? local.operations : {}
590
+
540
591
  policy_arn = "arn:aws:iam::aws:policy/service-role/AWSLambdaVPCAccessExecutionRole"
541
- role = aws_iam_role.lambda_execution_role.name
592
+ role = aws_iam_role.lambda_execution_role[each.key].name
542
593
  }
543
594
 
544
595
  # Additional IAM policies for Lambda (if provided)
545
596
  resource "aws_iam_role_policy" "lambda_additional_policies" {
546
- count = length(local.lambda_policy_statements) > 0 ? 1 : 0
547
- name = "TestApiHandler-additional-policies-\${random_string.suffix.result}"
548
- role = aws_iam_role.lambda_execution_role.id
597
+ for_each = local.operations
598
+
599
+ name = "\${substr(local.function_name[each.key], 0, 55)}-policies"
600
+ role = aws_iam_role.lambda_execution_role[each.key].id
549
601
 
550
602
  policy = jsonencode({
551
603
  Version = "2012-10-17"
552
- Statement = local.lambda_policy_statements
604
+ Statement = local.lambda_policy_document_statements
553
605
  })
554
606
  }
555
607
 
@@ -565,6 +617,15 @@ locals {
565
617
  Resource = ["\${var.appconfig_application_arn}/*"]
566
618
  }
567
619
  ] : [], var.additional_iam_policy_statements)
620
+
621
+ # IAM rejects an empty statement list, so fall back to a no-op deny
622
+ lambda_policy_document_statements = length(local.lambda_policy_statements) > 0 ? local.lambda_policy_statements : [
623
+ {
624
+ Effect = "Deny"
625
+ Action = ["appconfig:GetLatestConfiguration"]
626
+ Resource = ["arn:aws:appconfig:\${data.aws_region.current.region}:\${data.aws_caller_identity.current.account_id}:application/none"]
627
+ }
628
+ ]
568
629
  }
569
630
 
570
631
  # CloudWatch Log Group for Lambda
@@ -572,15 +633,19 @@ resource "aws_cloudwatch_log_group" "lambda_logs" {
572
633
  #checkov:skip=CKV_AWS_158:Using default CloudWatch log encryption
573
634
  #checkov:skip=CKV_AWS_338:Log retention set to forever
574
635
  #checkov:skip=CKV_AWS_66:Log retention set to forever
575
- name = "/aws/lambda/TestApiHandler-\${random_string.suffix.result}"
576
- tags = var.tags
636
+ for_each = local.operations
637
+
638
+ name = "/aws/lambda/\${local.function_name[each.key]}"
639
+ tags = var.tags
577
640
  }
578
641
 
579
642
  # Lambda alias pointing to the latest published version for SnapStart
580
643
  resource "aws_lambda_alias" "live" {
644
+ for_each = local.operations
645
+
581
646
  name = "live"
582
- function_name = aws_lambda_function.api_lambda.function_name
583
- function_version = aws_lambda_function.api_lambda.version
647
+ function_name = aws_lambda_function.api_lambda[each.key].function_name
648
+ function_version = aws_lambda_function.api_lambda[each.key].version
584
649
 
585
650
  depends_on = [aws_lambda_function.api_lambda]
586
651
  }
@@ -600,9 +665,11 @@ resource "aws_apigatewayv2_authorizer" "cognito_authorizer" {
600
665
 
601
666
  # Lambda integration for HTTP API
602
667
  resource "aws_apigatewayv2_integration" "lambda_integration" {
668
+ for_each = local.operations
669
+
603
670
  api_id = module.http_api.api_id
604
671
  integration_type = "AWS_PROXY"
605
- integration_uri = aws_lambda_alias.live.invoke_arn
672
+ integration_uri = aws_lambda_alias.live[each.key].invoke_arn
606
673
 
607
674
  payload_format_version = "2.0"
608
675
  timeout_milliseconds = 30000
@@ -610,15 +677,13 @@ resource "aws_apigatewayv2_integration" "lambda_integration" {
610
677
  depends_on = [aws_lambda_alias.live]
611
678
  }
612
679
 
613
- # Route for proxy integration (catches all requests)
614
- resource "aws_apigatewayv2_route" "proxy_routes" {
615
- # NB: OPTIONS is omitted here since API Gateway manages responding to preflight requests
616
- # when cors settings are configured
617
- for_each = toset(["GET", "POST", "PUT", "PATCH", "DELETE", "HEAD"])
680
+ # One route per operation, each targeting that operation's integration
681
+ resource "aws_apigatewayv2_route" "operation_routes" {
682
+ for_each = local.operations
618
683
 
619
684
  api_id = module.http_api.api_id
620
- route_key = "\${each.key} /{proxy+}"
621
- target = "integrations/\${aws_apigatewayv2_integration.lambda_integration.id}"
685
+ route_key = local.route_key[each.key]
686
+ target = "integrations/\${aws_apigatewayv2_integration.lambda_integration[each.key].id}"
622
687
 
623
688
  authorization_type = "JWT"
624
689
  authorizer_id = aws_apigatewayv2_authorizer.cognito_authorizer.id
@@ -639,12 +704,14 @@ module "add_url_to_runtime_config" {
639
704
 
640
705
  # Lambda permission for API Gateway to invoke the function via alias
641
706
  resource "aws_lambda_permission" "api_gateway_invoke" {
707
+ for_each = local.operations
708
+
642
709
  statement_id = "AllowExecutionFromAPIGateway"
643
710
  action = "lambda:InvokeFunction"
644
- function_name = aws_lambda_function.api_lambda.function_name
645
- qualifier = aws_lambda_alias.live.name
711
+ function_name = aws_lambda_function.api_lambda[each.key].function_name
712
+ qualifier = aws_lambda_alias.live[each.key].name
646
713
  principal = "apigateway.amazonaws.com"
647
- source_arn = "\${module.http_api.api_execution_arn}/*/*"
714
+ source_arn = "\${module.http_api.api_execution_arn}/*/\${local.permission_source_suffix[each.key]}"
648
715
 
649
716
  depends_on = [module.http_api, aws_lambda_alias.live]
650
717
  }
@@ -687,73 +754,73 @@ output "stage_execution_arn" {
687
754
  value = module.http_api.stage_execution_arn
688
755
  }
689
756
 
690
- # Lambda Function Outputs
691
- output "lambda_function_name" {
692
- description = "Name of the Lambda function"
693
- value = aws_lambda_function.api_lambda.function_name
757
+ # Lambda Function Outputs, keyed by operation name
758
+ output "operations" {
759
+ description = "Names of the API operations, each with its own Lambda function"
760
+ value = keys(local.operations)
694
761
  }
695
762
 
696
- output "lambda_function_arn" {
697
- description = "ARN of the Lambda function"
698
- value = aws_lambda_function.api_lambda.arn
763
+ output "lambda_function_names" {
764
+ description = "Name of each operation's Lambda function, keyed by operation name"
765
+ value = { for op, fn in aws_lambda_function.api_lambda : op => fn.function_name }
699
766
  }
700
767
 
701
- output "lambda_invoke_arn" {
702
- description = "Invoke ARN of the Lambda function"
703
- value = aws_lambda_function.api_lambda.invoke_arn
768
+ output "lambda_function_arns" {
769
+ description = "ARN of each operation's Lambda function, keyed by operation name"
770
+ value = { for op, fn in aws_lambda_function.api_lambda : op => fn.arn }
704
771
  }
705
772
 
706
- output "lambda_qualified_arn" {
707
- description = "Qualified ARN of the Lambda function"
708
- value = aws_lambda_function.api_lambda.qualified_arn
773
+ output "lambda_invoke_arns" {
774
+ description = "Invoke ARN of each operation's Lambda function, keyed by operation name"
775
+ value = { for op, fn in aws_lambda_function.api_lambda : op => fn.invoke_arn }
709
776
  }
710
777
 
711
- output "lambda_version" {
712
- description = "Version of the Lambda function"
713
- value = aws_lambda_function.api_lambda.version
778
+ output "lambda_qualified_arns" {
779
+ description = "Qualified ARN of each operation's Lambda function, keyed by operation name"
780
+ value = { for op, fn in aws_lambda_function.api_lambda : op => fn.qualified_arn }
714
781
  }
715
782
 
716
- output "lambda_source_code_hash" {
717
- description = "Base64-encoded SHA256 hash of the Lambda deployment package"
718
- value = aws_lambda_function.api_lambda.source_code_hash
783
+ output "lambda_versions" {
784
+ description = "Version of each operation's Lambda function, keyed by operation name"
785
+ value = { for op, fn in aws_lambda_function.api_lambda : op => fn.version }
719
786
  }
720
787
 
721
- output "lambda_source_code_size" {
722
- description = "Size of the Lambda deployment package in bytes"
723
- value = aws_lambda_function.api_lambda.source_code_size
788
+ output "lambda_source_code_hash" {
789
+ description = "Base64-encoded SHA256 hash of the Lambda deployment package, shared by every operation"
790
+ value = data.archive_file.lambda_zip.output_base64sha256
724
791
  }
725
792
 
726
- # IAM Role Outputs
727
- output "lambda_execution_role_arn" {
728
- description = "ARN of the Lambda execution role"
729
- value = aws_iam_role.lambda_execution_role.arn
793
+ # IAM Role Outputs, keyed by operation name
794
+ output "lambda_execution_role_arns" {
795
+ description = "ARN of each operation's Lambda execution role, keyed by operation name"
796
+ value = { for op, role in aws_iam_role.lambda_execution_role : op => role.arn }
730
797
  }
731
798
 
732
- output "lambda_execution_role_name" {
733
- description = "Name of the Lambda execution role"
734
- value = aws_iam_role.lambda_execution_role.name
799
+ output "lambda_execution_role_names" {
800
+ description = "Name of each operation's Lambda execution role, keyed by operation name. Attach additional policies to a specific operation's role by name."
801
+ value = { for op, role in aws_iam_role.lambda_execution_role : op => role.name }
735
802
  }
736
803
 
737
804
  output "security_group_id" {
738
- description = "Security group ID of the API Lambda function, for use in ingress rules on resources it must reach (e.g. a database). Null unless enable_vpc is true."
805
+ description = "Security group ID shared by the API Lambda functions, for use in ingress rules on resources they must reach (e.g. a database). Null unless enable_vpc is true."
739
806
  value = var.enable_vpc ? aws_security_group.api_lambda[0].id : null
740
807
  }
741
808
 
742
- # Integration Outputs
743
- output "integration_id" {
744
- description = "ID of the Lambda integration"
745
- value = aws_apigatewayv2_integration.lambda_integration.id
809
+ # Integration Outputs, keyed by operation name
810
+ output "integration_ids" {
811
+ description = "ID of each operation's Lambda integration, keyed by operation name"
812
+ value = { for op, i in aws_apigatewayv2_integration.lambda_integration : op => i.id }
746
813
  }
747
814
 
748
- # CloudWatch Log Groups
749
- output "lambda_log_group_name" {
750
- description = "Name of the Lambda CloudWatch log group"
751
- value = aws_cloudwatch_log_group.lambda_logs.name
815
+ # CloudWatch Log Groups, keyed by operation name
816
+ output "lambda_log_group_names" {
817
+ description = "Name of each operation's Lambda CloudWatch log group, keyed by operation name"
818
+ value = { for op, lg in aws_cloudwatch_log_group.lambda_logs : op => lg.name }
752
819
  }
753
820
 
754
- output "lambda_log_group_arn" {
755
- description = "ARN of the Lambda CloudWatch log group"
756
- value = aws_cloudwatch_log_group.lambda_logs.arn
821
+ output "lambda_log_group_arns" {
822
+ description = "ARN of each operation's Lambda CloudWatch log group, keyed by operation name"
823
+ value = { for op, lg in aws_cloudwatch_log_group.lambda_logs : op => lg.arn }
757
824
  }
758
825
 
759
826
  output "api_log_group_name" {
@@ -1145,6 +1212,49 @@ resource "random_string" "suffix" {
1145
1212
  upper = false
1146
1213
  }
1147
1214
 
1215
+ locals {
1216
+ # Generated at build time from the API definition
1217
+ operations_file = "\${path.module}/../../../generated/test-api/operations.json"
1218
+ operations = fileexists(local.operations_file) ? jsondecode(file(local.operations_file)) : {}
1219
+
1220
+ operation_slug = { for op in keys(local.operations) : op => replace(op, "/[^a-zA-Z0-9-_]/", "-") }
1221
+ operation_hash = { for op in keys(local.operations) : op => substr(sha256(op), 0, 8) }
1222
+
1223
+ function_name = { for op in keys(local.operations) : op =>
1224
+ "\${substr("TestApi-\${local.operation_slug[op]}", 0, 64 - length(local.operation_hash[op]) - length(random_string.suffix.result) - 2)}-\${local.operation_hash[op]}-\${random_string.suffix.result}"
1225
+ }
1226
+ role_name = { for op in keys(local.operations) : op =>
1227
+ "\${substr("TestApi-\${local.operation_slug[op]}-role", 0, 64 - length(local.operation_hash[op]) - length(random_string.suffix.result) - 2)}-\${local.operation_hash[op]}-\${random_string.suffix.result}"
1228
+ }
1229
+
1230
+ operation_path = { for op, details in local.operations : op =>
1231
+ startswith(details.path, "/") ? details.path : "/\${details.path}"
1232
+ }
1233
+ route_key = { for op, details in local.operations : op =>
1234
+ "\${upper(details.method)} \${local.operation_path[op]}"
1235
+ }
1236
+
1237
+ permission_source_suffix = { for op, details in local.operations : op =>
1238
+ "\${upper(details.method)}\${replace(local.operation_path[op], "/{[^}]*}/", "*")}"
1239
+ }
1240
+ }
1241
+
1242
+ resource "terraform_data" "operations_metadata" {
1243
+ # Fails the plan when the operations metadata has not been generated
1244
+ input = local.operations_file
1245
+
1246
+ lifecycle {
1247
+ precondition {
1248
+ condition = fileexists(local.operations_file)
1249
+ error_message = "Operations metadata not found at \${local.operations_file}. Build the TestApi project to generate it."
1250
+ }
1251
+ precondition {
1252
+ condition = length(local.operations) > 0
1253
+ error_message = "No operations found in \${local.operations_file}. The TestApi API must define at least one operation."
1254
+ }
1255
+ }
1256
+ }
1257
+
1148
1258
  # Resources
1149
1259
 
1150
1260
  # Create Lambda ZIP file from the bundle directory
@@ -1205,9 +1315,10 @@ resource "aws_vpc_security_group_egress_rule" "api_lambda_https" {
1205
1315
  description = "Allow outbound HTTPS to AWS service endpoints"
1206
1316
  }
1207
1317
 
1208
- # Lambda function
1209
- # This configures a single "router" lambda to serve all requests
1210
1318
  resource "aws_lambda_function" "api_lambda" {
1319
+ # One lambda function per operation, each serving just that operation
1320
+ for_each = local.operations
1321
+
1211
1322
  #checkov:skip=CKV_AWS_117:Lambda function is optionally deployed into a VPC via vpc_id/subnet_ids; not required for this use case
1212
1323
  #checkov:skip=CKV_AWS_116:Dead Letter Queue not required for this simple API use case
1213
1324
  #checkov:skip=CKV_AWS_272:Code signing not required for this use case
@@ -1216,8 +1327,8 @@ resource "aws_lambda_function" "api_lambda" {
1216
1327
  s3_bucket = aws_s3_object.lambda_zip.bucket
1217
1328
  s3_key = aws_s3_object.lambda_zip.key
1218
1329
  s3_object_version = aws_s3_object.lambda_zip.version_id
1219
- function_name = "TestApiHandler-\${random_string.suffix.result}"
1220
- role = aws_iam_role.lambda_execution_role.arn
1330
+ function_name = local.function_name[each.key]
1331
+ role = aws_iam_role.lambda_execution_role[each.key].arn
1221
1332
  handler = "run.sh"
1222
1333
  runtime = "python3.14"
1223
1334
  layers = ["arn:aws:lambda:\${data.aws_region.current.region}:753240598075:layer:LambdaAdapterLayerX86:24"]
@@ -1262,7 +1373,9 @@ resource "aws_lambda_function" "api_lambda" {
1262
1373
 
1263
1374
  # IAM role for Lambda execution
1264
1375
  resource "aws_iam_role" "lambda_execution_role" {
1265
- name = "TestApiHandler-execution-role-\${random_string.suffix.result}"
1376
+ for_each = local.operations
1377
+
1378
+ name = local.role_name[each.key]
1266
1379
 
1267
1380
  assume_role_policy = jsonencode({
1268
1381
  Version = "2012-10-17"
@@ -1282,32 +1395,38 @@ resource "aws_iam_role" "lambda_execution_role" {
1282
1395
 
1283
1396
  # Attach basic execution policy to Lambda role
1284
1397
  resource "aws_iam_role_policy_attachment" "lambda_basic_execution" {
1398
+ for_each = local.operations
1399
+
1285
1400
  policy_arn = "arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole"
1286
- role = aws_iam_role.lambda_execution_role.name
1401
+ role = aws_iam_role.lambda_execution_role[each.key].name
1287
1402
  }
1288
1403
 
1289
1404
  # Attach X-Ray tracing policy to Lambda role
1290
1405
  resource "aws_iam_role_policy_attachment" "lambda_xray_execution" {
1406
+ for_each = local.operations
1407
+
1291
1408
  policy_arn = "arn:aws:iam::aws:policy/AWSXRayDaemonWriteAccess"
1292
- role = aws_iam_role.lambda_execution_role.name
1409
+ role = aws_iam_role.lambda_execution_role[each.key].name
1293
1410
  }
1294
1411
 
1295
1412
  # Attach VPC access policy to Lambda role when deployed into a VPC
1296
1413
  resource "aws_iam_role_policy_attachment" "lambda_vpc_access" {
1297
- count = var.enable_vpc ? 1 : 0
1414
+ for_each = var.enable_vpc ? local.operations : {}
1415
+
1298
1416
  policy_arn = "arn:aws:iam::aws:policy/service-role/AWSLambdaVPCAccessExecutionRole"
1299
- role = aws_iam_role.lambda_execution_role.name
1417
+ role = aws_iam_role.lambda_execution_role[each.key].name
1300
1418
  }
1301
1419
 
1302
1420
  # Additional IAM policies for Lambda (if provided)
1303
1421
  resource "aws_iam_role_policy" "lambda_additional_policies" {
1304
- count = length(local.lambda_policy_statements) > 0 ? 1 : 0
1305
- name = "TestApiHandler-additional-policies-\${random_string.suffix.result}"
1306
- role = aws_iam_role.lambda_execution_role.id
1422
+ for_each = local.operations
1423
+
1424
+ name = "\${substr(local.function_name[each.key], 0, 55)}-policies"
1425
+ role = aws_iam_role.lambda_execution_role[each.key].id
1307
1426
 
1308
1427
  policy = jsonencode({
1309
1428
  Version = "2012-10-17"
1310
- Statement = local.lambda_policy_statements
1429
+ Statement = local.lambda_policy_document_statements
1311
1430
  })
1312
1431
  }
1313
1432
 
@@ -1323,6 +1442,15 @@ locals {
1323
1442
  Resource = ["\${var.appconfig_application_arn}/*"]
1324
1443
  }
1325
1444
  ] : [], var.additional_iam_policy_statements)
1445
+
1446
+ # IAM rejects an empty statement list, so fall back to a no-op deny
1447
+ lambda_policy_document_statements = length(local.lambda_policy_statements) > 0 ? local.lambda_policy_statements : [
1448
+ {
1449
+ Effect = "Deny"
1450
+ Action = ["appconfig:GetLatestConfiguration"]
1451
+ Resource = ["arn:aws:appconfig:\${data.aws_region.current.region}:\${data.aws_caller_identity.current.account_id}:application/none"]
1452
+ }
1453
+ ]
1326
1454
  }
1327
1455
 
1328
1456
  # CloudWatch Log Group for Lambda
@@ -1330,15 +1458,19 @@ resource "aws_cloudwatch_log_group" "lambda_logs" {
1330
1458
  #checkov:skip=CKV_AWS_158:Using default CloudWatch log encryption
1331
1459
  #checkov:skip=CKV_AWS_338:Log retention set to forever
1332
1460
  #checkov:skip=CKV_AWS_66:Log retention set to forever
1333
- name = "/aws/lambda/TestApiHandler-\${random_string.suffix.result}"
1334
- tags = var.tags
1461
+ for_each = local.operations
1462
+
1463
+ name = "/aws/lambda/\${local.function_name[each.key]}"
1464
+ tags = var.tags
1335
1465
  }
1336
1466
 
1337
1467
  # Lambda alias pointing to the latest published version for SnapStart
1338
1468
  resource "aws_lambda_alias" "live" {
1469
+ for_each = local.operations
1470
+
1339
1471
  name = "live"
1340
- function_name = aws_lambda_function.api_lambda.function_name
1341
- function_version = aws_lambda_function.api_lambda.version
1472
+ function_name = aws_lambda_function.api_lambda[each.key].function_name
1473
+ function_version = aws_lambda_function.api_lambda[each.key].version
1342
1474
 
1343
1475
  depends_on = [aws_lambda_function.api_lambda]
1344
1476
  }
@@ -1425,7 +1557,8 @@ resource "aws_apigatewayv2_authorizer" "custom_authorizer" {
1425
1557
  authorizer_type = "REQUEST"
1426
1558
  authorizer_uri = aws_lambda_function.authorizer_lambda.invoke_arn
1427
1559
  authorizer_payload_format_version = "2.0"
1428
- authorizer_result_ttl_in_seconds = 0
1560
+ authorizer_result_ttl_in_seconds = 300
1561
+ identity_sources = ["$request.header.Authorization"]
1429
1562
  enable_simple_responses = true
1430
1563
  name = "TestApiAuthorizer-\${random_string.suffix.result}"
1431
1564
  }
@@ -1440,9 +1573,11 @@ resource "aws_lambda_permission" "authorizer_invoke" {
1440
1573
 
1441
1574
  # Lambda integration for HTTP API
1442
1575
  resource "aws_apigatewayv2_integration" "lambda_integration" {
1576
+ for_each = local.operations
1577
+
1443
1578
  api_id = module.http_api.api_id
1444
1579
  integration_type = "AWS_PROXY"
1445
- integration_uri = aws_lambda_alias.live.invoke_arn
1580
+ integration_uri = aws_lambda_alias.live[each.key].invoke_arn
1446
1581
 
1447
1582
  payload_format_version = "2.0"
1448
1583
  timeout_milliseconds = 30000
@@ -1450,15 +1585,13 @@ resource "aws_apigatewayv2_integration" "lambda_integration" {
1450
1585
  depends_on = [aws_lambda_alias.live]
1451
1586
  }
1452
1587
 
1453
- # Route for proxy integration (catches all requests)
1454
- resource "aws_apigatewayv2_route" "proxy_routes" {
1455
- # NB: OPTIONS is omitted here since API Gateway manages responding to preflight requests
1456
- # when cors settings are configured
1457
- for_each = toset(["GET", "POST", "PUT", "PATCH", "DELETE", "HEAD"])
1588
+ # One route per operation, each targeting that operation's integration
1589
+ resource "aws_apigatewayv2_route" "operation_routes" {
1590
+ for_each = local.operations
1458
1591
 
1459
1592
  api_id = module.http_api.api_id
1460
- route_key = "\${each.key} /{proxy+}"
1461
- target = "integrations/\${aws_apigatewayv2_integration.lambda_integration.id}"
1593
+ route_key = local.route_key[each.key]
1594
+ target = "integrations/\${aws_apigatewayv2_integration.lambda_integration[each.key].id}"
1462
1595
 
1463
1596
  authorization_type = "CUSTOM"
1464
1597
  authorizer_id = aws_apigatewayv2_authorizer.custom_authorizer.id
@@ -1479,12 +1612,14 @@ module "add_url_to_runtime_config" {
1479
1612
 
1480
1613
  # Lambda permission for API Gateway to invoke the function via alias
1481
1614
  resource "aws_lambda_permission" "api_gateway_invoke" {
1615
+ for_each = local.operations
1616
+
1482
1617
  statement_id = "AllowExecutionFromAPIGateway"
1483
1618
  action = "lambda:InvokeFunction"
1484
- function_name = aws_lambda_function.api_lambda.function_name
1485
- qualifier = aws_lambda_alias.live.name
1619
+ function_name = aws_lambda_function.api_lambda[each.key].function_name
1620
+ qualifier = aws_lambda_alias.live[each.key].name
1486
1621
  principal = "apigateway.amazonaws.com"
1487
- source_arn = "\${module.http_api.api_execution_arn}/*/*"
1622
+ source_arn = "\${module.http_api.api_execution_arn}/*/\${local.permission_source_suffix[each.key]}"
1488
1623
 
1489
1624
  depends_on = [module.http_api, aws_lambda_alias.live]
1490
1625
  }
@@ -1527,73 +1662,73 @@ output "stage_execution_arn" {
1527
1662
  value = module.http_api.stage_execution_arn
1528
1663
  }
1529
1664
 
1530
- # Lambda Function Outputs
1531
- output "lambda_function_name" {
1532
- description = "Name of the Lambda function"
1533
- value = aws_lambda_function.api_lambda.function_name
1665
+ # Lambda Function Outputs, keyed by operation name
1666
+ output "operations" {
1667
+ description = "Names of the API operations, each with its own Lambda function"
1668
+ value = keys(local.operations)
1534
1669
  }
1535
1670
 
1536
- output "lambda_function_arn" {
1537
- description = "ARN of the Lambda function"
1538
- value = aws_lambda_function.api_lambda.arn
1671
+ output "lambda_function_names" {
1672
+ description = "Name of each operation's Lambda function, keyed by operation name"
1673
+ value = { for op, fn in aws_lambda_function.api_lambda : op => fn.function_name }
1539
1674
  }
1540
1675
 
1541
- output "lambda_invoke_arn" {
1542
- description = "Invoke ARN of the Lambda function"
1543
- value = aws_lambda_function.api_lambda.invoke_arn
1676
+ output "lambda_function_arns" {
1677
+ description = "ARN of each operation's Lambda function, keyed by operation name"
1678
+ value = { for op, fn in aws_lambda_function.api_lambda : op => fn.arn }
1544
1679
  }
1545
1680
 
1546
- output "lambda_qualified_arn" {
1547
- description = "Qualified ARN of the Lambda function"
1548
- value = aws_lambda_function.api_lambda.qualified_arn
1681
+ output "lambda_invoke_arns" {
1682
+ description = "Invoke ARN of each operation's Lambda function, keyed by operation name"
1683
+ value = { for op, fn in aws_lambda_function.api_lambda : op => fn.invoke_arn }
1549
1684
  }
1550
1685
 
1551
- output "lambda_version" {
1552
- description = "Version of the Lambda function"
1553
- value = aws_lambda_function.api_lambda.version
1686
+ output "lambda_qualified_arns" {
1687
+ description = "Qualified ARN of each operation's Lambda function, keyed by operation name"
1688
+ value = { for op, fn in aws_lambda_function.api_lambda : op => fn.qualified_arn }
1554
1689
  }
1555
1690
 
1556
- output "lambda_source_code_hash" {
1557
- description = "Base64-encoded SHA256 hash of the Lambda deployment package"
1558
- value = aws_lambda_function.api_lambda.source_code_hash
1691
+ output "lambda_versions" {
1692
+ description = "Version of each operation's Lambda function, keyed by operation name"
1693
+ value = { for op, fn in aws_lambda_function.api_lambda : op => fn.version }
1559
1694
  }
1560
1695
 
1561
- output "lambda_source_code_size" {
1562
- description = "Size of the Lambda deployment package in bytes"
1563
- value = aws_lambda_function.api_lambda.source_code_size
1696
+ output "lambda_source_code_hash" {
1697
+ description = "Base64-encoded SHA256 hash of the Lambda deployment package, shared by every operation"
1698
+ value = data.archive_file.lambda_zip.output_base64sha256
1564
1699
  }
1565
1700
 
1566
- # IAM Role Outputs
1567
- output "lambda_execution_role_arn" {
1568
- description = "ARN of the Lambda execution role"
1569
- value = aws_iam_role.lambda_execution_role.arn
1701
+ # IAM Role Outputs, keyed by operation name
1702
+ output "lambda_execution_role_arns" {
1703
+ description = "ARN of each operation's Lambda execution role, keyed by operation name"
1704
+ value = { for op, role in aws_iam_role.lambda_execution_role : op => role.arn }
1570
1705
  }
1571
1706
 
1572
- output "lambda_execution_role_name" {
1573
- description = "Name of the Lambda execution role"
1574
- value = aws_iam_role.lambda_execution_role.name
1707
+ output "lambda_execution_role_names" {
1708
+ description = "Name of each operation's Lambda execution role, keyed by operation name. Attach additional policies to a specific operation's role by name."
1709
+ value = { for op, role in aws_iam_role.lambda_execution_role : op => role.name }
1575
1710
  }
1576
1711
 
1577
1712
  output "security_group_id" {
1578
- description = "Security group ID of the API Lambda function, for use in ingress rules on resources it must reach (e.g. a database). Null unless enable_vpc is true."
1713
+ description = "Security group ID shared by the API Lambda functions, for use in ingress rules on resources they must reach (e.g. a database). Null unless enable_vpc is true."
1579
1714
  value = var.enable_vpc ? aws_security_group.api_lambda[0].id : null
1580
1715
  }
1581
1716
 
1582
- # Integration Outputs
1583
- output "integration_id" {
1584
- description = "ID of the Lambda integration"
1585
- value = aws_apigatewayv2_integration.lambda_integration.id
1717
+ # Integration Outputs, keyed by operation name
1718
+ output "integration_ids" {
1719
+ description = "ID of each operation's Lambda integration, keyed by operation name"
1720
+ value = { for op, i in aws_apigatewayv2_integration.lambda_integration : op => i.id }
1586
1721
  }
1587
1722
 
1588
- # CloudWatch Log Groups
1589
- output "lambda_log_group_name" {
1590
- description = "Name of the Lambda CloudWatch log group"
1591
- value = aws_cloudwatch_log_group.lambda_logs.name
1723
+ # CloudWatch Log Groups, keyed by operation name
1724
+ output "lambda_log_group_names" {
1725
+ description = "Name of each operation's Lambda CloudWatch log group, keyed by operation name"
1726
+ value = { for op, lg in aws_cloudwatch_log_group.lambda_logs : op => lg.name }
1592
1727
  }
1593
1728
 
1594
- output "lambda_log_group_arn" {
1595
- description = "ARN of the Lambda CloudWatch log group"
1596
- value = aws_cloudwatch_log_group.lambda_logs.arn
1729
+ output "lambda_log_group_arns" {
1730
+ description = "ARN of each operation's Lambda CloudWatch log group, keyed by operation name"
1731
+ value = { for op, lg in aws_cloudwatch_log_group.lambda_logs : op => lg.arn }
1597
1732
  }
1598
1733
 
1599
1734
  output "api_log_group_name" {
@@ -1985,6 +2120,49 @@ resource "random_string" "suffix" {
1985
2120
  upper = false
1986
2121
  }
1987
2122
 
2123
+ locals {
2124
+ # Generated at build time from the API definition
2125
+ operations_file = "\${path.module}/../../../generated/test-api/operations.json"
2126
+ operations = fileexists(local.operations_file) ? jsondecode(file(local.operations_file)) : {}
2127
+
2128
+ operation_slug = { for op in keys(local.operations) : op => replace(op, "/[^a-zA-Z0-9-_]/", "-") }
2129
+ operation_hash = { for op in keys(local.operations) : op => substr(sha256(op), 0, 8) }
2130
+
2131
+ function_name = { for op in keys(local.operations) : op =>
2132
+ "\${substr("TestApi-\${local.operation_slug[op]}", 0, 64 - length(local.operation_hash[op]) - length(random_string.suffix.result) - 2)}-\${local.operation_hash[op]}-\${random_string.suffix.result}"
2133
+ }
2134
+ role_name = { for op in keys(local.operations) : op =>
2135
+ "\${substr("TestApi-\${local.operation_slug[op]}-role", 0, 64 - length(local.operation_hash[op]) - length(random_string.suffix.result) - 2)}-\${local.operation_hash[op]}-\${random_string.suffix.result}"
2136
+ }
2137
+
2138
+ operation_path = { for op, details in local.operations : op =>
2139
+ startswith(details.path, "/") ? details.path : "/\${details.path}"
2140
+ }
2141
+ route_key = { for op, details in local.operations : op =>
2142
+ "\${upper(details.method)} \${local.operation_path[op]}"
2143
+ }
2144
+
2145
+ permission_source_suffix = { for op, details in local.operations : op =>
2146
+ "\${upper(details.method)}\${replace(local.operation_path[op], "/{[^}]*}/", "*")}"
2147
+ }
2148
+ }
2149
+
2150
+ resource "terraform_data" "operations_metadata" {
2151
+ # Fails the plan when the operations metadata has not been generated
2152
+ input = local.operations_file
2153
+
2154
+ lifecycle {
2155
+ precondition {
2156
+ condition = fileexists(local.operations_file)
2157
+ error_message = "Operations metadata not found at \${local.operations_file}. Build the TestApi project to generate it."
2158
+ }
2159
+ precondition {
2160
+ condition = length(local.operations) > 0
2161
+ error_message = "No operations found in \${local.operations_file}. The TestApi API must define at least one operation."
2162
+ }
2163
+ }
2164
+ }
2165
+
1988
2166
  # Resources
1989
2167
 
1990
2168
  # Create Lambda ZIP file from the bundle directory
@@ -2045,9 +2223,10 @@ resource "aws_vpc_security_group_egress_rule" "api_lambda_https" {
2045
2223
  description = "Allow outbound HTTPS to AWS service endpoints"
2046
2224
  }
2047
2225
 
2048
- # Lambda function
2049
- # This configures a single "router" lambda to serve all requests
2050
2226
  resource "aws_lambda_function" "api_lambda" {
2227
+ # One lambda function per operation, each serving just that operation
2228
+ for_each = local.operations
2229
+
2051
2230
  #checkov:skip=CKV_AWS_117:Lambda function is optionally deployed into a VPC via vpc_id/subnet_ids; not required for this use case
2052
2231
  #checkov:skip=CKV_AWS_116:Dead Letter Queue not required for this simple API use case
2053
2232
  #checkov:skip=CKV_AWS_272:Code signing not required for this use case
@@ -2056,8 +2235,8 @@ resource "aws_lambda_function" "api_lambda" {
2056
2235
  s3_bucket = aws_s3_object.lambda_zip.bucket
2057
2236
  s3_key = aws_s3_object.lambda_zip.key
2058
2237
  s3_object_version = aws_s3_object.lambda_zip.version_id
2059
- function_name = "TestApiHandler-\${random_string.suffix.result}"
2060
- role = aws_iam_role.lambda_execution_role.arn
2238
+ function_name = local.function_name[each.key]
2239
+ role = aws_iam_role.lambda_execution_role[each.key].arn
2061
2240
  handler = "run.sh"
2062
2241
  runtime = "python3.14"
2063
2242
  layers = ["arn:aws:lambda:\${data.aws_region.current.region}:753240598075:layer:LambdaAdapterLayerX86:24"]
@@ -2102,7 +2281,9 @@ resource "aws_lambda_function" "api_lambda" {
2102
2281
 
2103
2282
  # IAM role for Lambda execution
2104
2283
  resource "aws_iam_role" "lambda_execution_role" {
2105
- name = "TestApiHandler-execution-role-\${random_string.suffix.result}"
2284
+ for_each = local.operations
2285
+
2286
+ name = local.role_name[each.key]
2106
2287
 
2107
2288
  assume_role_policy = jsonencode({
2108
2289
  Version = "2012-10-17"
@@ -2122,32 +2303,38 @@ resource "aws_iam_role" "lambda_execution_role" {
2122
2303
 
2123
2304
  # Attach basic execution policy to Lambda role
2124
2305
  resource "aws_iam_role_policy_attachment" "lambda_basic_execution" {
2306
+ for_each = local.operations
2307
+
2125
2308
  policy_arn = "arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole"
2126
- role = aws_iam_role.lambda_execution_role.name
2309
+ role = aws_iam_role.lambda_execution_role[each.key].name
2127
2310
  }
2128
2311
 
2129
2312
  # Attach X-Ray tracing policy to Lambda role
2130
2313
  resource "aws_iam_role_policy_attachment" "lambda_xray_execution" {
2314
+ for_each = local.operations
2315
+
2131
2316
  policy_arn = "arn:aws:iam::aws:policy/AWSXRayDaemonWriteAccess"
2132
- role = aws_iam_role.lambda_execution_role.name
2317
+ role = aws_iam_role.lambda_execution_role[each.key].name
2133
2318
  }
2134
2319
 
2135
2320
  # Attach VPC access policy to Lambda role when deployed into a VPC
2136
2321
  resource "aws_iam_role_policy_attachment" "lambda_vpc_access" {
2137
- count = var.enable_vpc ? 1 : 0
2322
+ for_each = var.enable_vpc ? local.operations : {}
2323
+
2138
2324
  policy_arn = "arn:aws:iam::aws:policy/service-role/AWSLambdaVPCAccessExecutionRole"
2139
- role = aws_iam_role.lambda_execution_role.name
2325
+ role = aws_iam_role.lambda_execution_role[each.key].name
2140
2326
  }
2141
2327
 
2142
2328
  # Additional IAM policies for Lambda (if provided)
2143
2329
  resource "aws_iam_role_policy" "lambda_additional_policies" {
2144
- count = length(local.lambda_policy_statements) > 0 ? 1 : 0
2145
- name = "TestApiHandler-additional-policies-\${random_string.suffix.result}"
2146
- role = aws_iam_role.lambda_execution_role.id
2330
+ for_each = local.operations
2331
+
2332
+ name = "\${substr(local.function_name[each.key], 0, 55)}-policies"
2333
+ role = aws_iam_role.lambda_execution_role[each.key].id
2147
2334
 
2148
2335
  policy = jsonencode({
2149
2336
  Version = "2012-10-17"
2150
- Statement = local.lambda_policy_statements
2337
+ Statement = local.lambda_policy_document_statements
2151
2338
  })
2152
2339
  }
2153
2340
 
@@ -2163,6 +2350,15 @@ locals {
2163
2350
  Resource = ["\${var.appconfig_application_arn}/*"]
2164
2351
  }
2165
2352
  ] : [], var.additional_iam_policy_statements)
2353
+
2354
+ # IAM rejects an empty statement list, so fall back to a no-op deny
2355
+ lambda_policy_document_statements = length(local.lambda_policy_statements) > 0 ? local.lambda_policy_statements : [
2356
+ {
2357
+ Effect = "Deny"
2358
+ Action = ["appconfig:GetLatestConfiguration"]
2359
+ Resource = ["arn:aws:appconfig:\${data.aws_region.current.region}:\${data.aws_caller_identity.current.account_id}:application/none"]
2360
+ }
2361
+ ]
2166
2362
  }
2167
2363
 
2168
2364
  # CloudWatch Log Group for Lambda
@@ -2170,15 +2366,19 @@ resource "aws_cloudwatch_log_group" "lambda_logs" {
2170
2366
  #checkov:skip=CKV_AWS_158:Using default CloudWatch log encryption
2171
2367
  #checkov:skip=CKV_AWS_338:Log retention set to forever
2172
2368
  #checkov:skip=CKV_AWS_66:Log retention set to forever
2173
- name = "/aws/lambda/TestApiHandler-\${random_string.suffix.result}"
2174
- tags = var.tags
2369
+ for_each = local.operations
2370
+
2371
+ name = "/aws/lambda/\${local.function_name[each.key]}"
2372
+ tags = var.tags
2175
2373
  }
2176
2374
 
2177
2375
  # Lambda alias pointing to the latest published version for SnapStart
2178
2376
  resource "aws_lambda_alias" "live" {
2377
+ for_each = local.operations
2378
+
2179
2379
  name = "live"
2180
- function_name = aws_lambda_function.api_lambda.function_name
2181
- function_version = aws_lambda_function.api_lambda.version
2380
+ function_name = aws_lambda_function.api_lambda[each.key].function_name
2381
+ function_version = aws_lambda_function.api_lambda[each.key].version
2182
2382
 
2183
2383
  depends_on = [aws_lambda_function.api_lambda]
2184
2384
  }
@@ -2186,9 +2386,11 @@ resource "aws_lambda_alias" "live" {
2186
2386
 
2187
2387
  # Lambda integration for HTTP API
2188
2388
  resource "aws_apigatewayv2_integration" "lambda_integration" {
2389
+ for_each = local.operations
2390
+
2189
2391
  api_id = module.http_api.api_id
2190
2392
  integration_type = "AWS_PROXY"
2191
- integration_uri = aws_lambda_alias.live.invoke_arn
2393
+ integration_uri = aws_lambda_alias.live[each.key].invoke_arn
2192
2394
 
2193
2395
  payload_format_version = "2.0"
2194
2396
  timeout_milliseconds = 30000
@@ -2196,15 +2398,13 @@ resource "aws_apigatewayv2_integration" "lambda_integration" {
2196
2398
  depends_on = [aws_lambda_alias.live]
2197
2399
  }
2198
2400
 
2199
- # Route for proxy integration (catches all requests)
2200
- resource "aws_apigatewayv2_route" "proxy_routes" {
2201
- # NB: OPTIONS is omitted here since API Gateway manages responding to preflight requests
2202
- # when cors settings are configured
2203
- for_each = toset(["GET", "POST", "PUT", "PATCH", "DELETE", "HEAD"])
2401
+ # One route per operation, each targeting that operation's integration
2402
+ resource "aws_apigatewayv2_route" "operation_routes" {
2403
+ for_each = local.operations
2204
2404
 
2205
2405
  api_id = module.http_api.api_id
2206
- route_key = "\${each.key} /{proxy+}"
2207
- target = "integrations/\${aws_apigatewayv2_integration.lambda_integration.id}"
2406
+ route_key = local.route_key[each.key]
2407
+ target = "integrations/\${aws_apigatewayv2_integration.lambda_integration[each.key].id}"
2208
2408
 
2209
2409
  authorization_type = "AWS_IAM"
2210
2410
 
@@ -2224,12 +2424,14 @@ module "add_url_to_runtime_config" {
2224
2424
 
2225
2425
  # Lambda permission for API Gateway to invoke the function via alias
2226
2426
  resource "aws_lambda_permission" "api_gateway_invoke" {
2427
+ for_each = local.operations
2428
+
2227
2429
  statement_id = "AllowExecutionFromAPIGateway"
2228
2430
  action = "lambda:InvokeFunction"
2229
- function_name = aws_lambda_function.api_lambda.function_name
2230
- qualifier = aws_lambda_alias.live.name
2431
+ function_name = aws_lambda_function.api_lambda[each.key].function_name
2432
+ qualifier = aws_lambda_alias.live[each.key].name
2231
2433
  principal = "apigateway.amazonaws.com"
2232
- source_arn = "\${module.http_api.api_execution_arn}/*/*"
2434
+ source_arn = "\${module.http_api.api_execution_arn}/*/\${local.permission_source_suffix[each.key]}"
2233
2435
 
2234
2436
  depends_on = [module.http_api, aws_lambda_alias.live]
2235
2437
  }
@@ -2272,73 +2474,73 @@ output "stage_execution_arn" {
2272
2474
  value = module.http_api.stage_execution_arn
2273
2475
  }
2274
2476
 
2275
- # Lambda Function Outputs
2276
- output "lambda_function_name" {
2277
- description = "Name of the Lambda function"
2278
- value = aws_lambda_function.api_lambda.function_name
2477
+ # Lambda Function Outputs, keyed by operation name
2478
+ output "operations" {
2479
+ description = "Names of the API operations, each with its own Lambda function"
2480
+ value = keys(local.operations)
2279
2481
  }
2280
2482
 
2281
- output "lambda_function_arn" {
2282
- description = "ARN of the Lambda function"
2283
- value = aws_lambda_function.api_lambda.arn
2483
+ output "lambda_function_names" {
2484
+ description = "Name of each operation's Lambda function, keyed by operation name"
2485
+ value = { for op, fn in aws_lambda_function.api_lambda : op => fn.function_name }
2284
2486
  }
2285
2487
 
2286
- output "lambda_invoke_arn" {
2287
- description = "Invoke ARN of the Lambda function"
2288
- value = aws_lambda_function.api_lambda.invoke_arn
2488
+ output "lambda_function_arns" {
2489
+ description = "ARN of each operation's Lambda function, keyed by operation name"
2490
+ value = { for op, fn in aws_lambda_function.api_lambda : op => fn.arn }
2289
2491
  }
2290
2492
 
2291
- output "lambda_qualified_arn" {
2292
- description = "Qualified ARN of the Lambda function"
2293
- value = aws_lambda_function.api_lambda.qualified_arn
2493
+ output "lambda_invoke_arns" {
2494
+ description = "Invoke ARN of each operation's Lambda function, keyed by operation name"
2495
+ value = { for op, fn in aws_lambda_function.api_lambda : op => fn.invoke_arn }
2294
2496
  }
2295
2497
 
2296
- output "lambda_version" {
2297
- description = "Version of the Lambda function"
2298
- value = aws_lambda_function.api_lambda.version
2498
+ output "lambda_qualified_arns" {
2499
+ description = "Qualified ARN of each operation's Lambda function, keyed by operation name"
2500
+ value = { for op, fn in aws_lambda_function.api_lambda : op => fn.qualified_arn }
2299
2501
  }
2300
2502
 
2301
- output "lambda_source_code_hash" {
2302
- description = "Base64-encoded SHA256 hash of the Lambda deployment package"
2303
- value = aws_lambda_function.api_lambda.source_code_hash
2503
+ output "lambda_versions" {
2504
+ description = "Version of each operation's Lambda function, keyed by operation name"
2505
+ value = { for op, fn in aws_lambda_function.api_lambda : op => fn.version }
2304
2506
  }
2305
2507
 
2306
- output "lambda_source_code_size" {
2307
- description = "Size of the Lambda deployment package in bytes"
2308
- value = aws_lambda_function.api_lambda.source_code_size
2508
+ output "lambda_source_code_hash" {
2509
+ description = "Base64-encoded SHA256 hash of the Lambda deployment package, shared by every operation"
2510
+ value = data.archive_file.lambda_zip.output_base64sha256
2309
2511
  }
2310
2512
 
2311
- # IAM Role Outputs
2312
- output "lambda_execution_role_arn" {
2313
- description = "ARN of the Lambda execution role"
2314
- value = aws_iam_role.lambda_execution_role.arn
2513
+ # IAM Role Outputs, keyed by operation name
2514
+ output "lambda_execution_role_arns" {
2515
+ description = "ARN of each operation's Lambda execution role, keyed by operation name"
2516
+ value = { for op, role in aws_iam_role.lambda_execution_role : op => role.arn }
2315
2517
  }
2316
2518
 
2317
- output "lambda_execution_role_name" {
2318
- description = "Name of the Lambda execution role"
2319
- value = aws_iam_role.lambda_execution_role.name
2519
+ output "lambda_execution_role_names" {
2520
+ description = "Name of each operation's Lambda execution role, keyed by operation name. Attach additional policies to a specific operation's role by name."
2521
+ value = { for op, role in aws_iam_role.lambda_execution_role : op => role.name }
2320
2522
  }
2321
2523
 
2322
2524
  output "security_group_id" {
2323
- description = "Security group ID of the API Lambda function, for use in ingress rules on resources it must reach (e.g. a database). Null unless enable_vpc is true."
2525
+ description = "Security group ID shared by the API Lambda functions, for use in ingress rules on resources they must reach (e.g. a database). Null unless enable_vpc is true."
2324
2526
  value = var.enable_vpc ? aws_security_group.api_lambda[0].id : null
2325
2527
  }
2326
2528
 
2327
- # Integration Outputs
2328
- output "integration_id" {
2329
- description = "ID of the Lambda integration"
2330
- value = aws_apigatewayv2_integration.lambda_integration.id
2529
+ # Integration Outputs, keyed by operation name
2530
+ output "integration_ids" {
2531
+ description = "ID of each operation's Lambda integration, keyed by operation name"
2532
+ value = { for op, i in aws_apigatewayv2_integration.lambda_integration : op => i.id }
2331
2533
  }
2332
2534
 
2333
- # CloudWatch Log Groups
2334
- output "lambda_log_group_name" {
2335
- description = "Name of the Lambda CloudWatch log group"
2336
- value = aws_cloudwatch_log_group.lambda_logs.name
2535
+ # CloudWatch Log Groups, keyed by operation name
2536
+ output "lambda_log_group_names" {
2537
+ description = "Name of each operation's Lambda CloudWatch log group, keyed by operation name"
2538
+ value = { for op, lg in aws_cloudwatch_log_group.lambda_logs : op => lg.name }
2337
2539
  }
2338
2540
 
2339
- output "lambda_log_group_arn" {
2340
- description = "ARN of the Lambda CloudWatch log group"
2341
- value = aws_cloudwatch_log_group.lambda_logs.arn
2541
+ output "lambda_log_group_arns" {
2542
+ description = "ARN of each operation's Lambda CloudWatch log group, keyed by operation name"
2543
+ value = { for op, lg in aws_cloudwatch_log_group.lambda_logs : op => lg.arn }
2342
2544
  }
2343
2545
 
2344
2546
  output "api_log_group_name" {
@@ -2798,6 +3000,235 @@ resource "random_string" "suffix" {
2798
3000
  upper = false
2799
3001
  }
2800
3002
 
3003
+ locals {
3004
+ # Generated at build time from the API definition
3005
+ operations_file = "\${path.module}/../../../generated/test-api/operations.json"
3006
+ operations = fileexists(local.operations_file) ? jsondecode(file(local.operations_file)) : {}
3007
+
3008
+ operation_slug = { for op in keys(local.operations) : op => replace(op, "/[^a-zA-Z0-9-_]/", "-") }
3009
+ operation_hash = { for op in keys(local.operations) : op => substr(sha256(op), 0, 8) }
3010
+
3011
+ function_name = { for op in keys(local.operations) : op =>
3012
+ "\${substr("TestApi-\${local.operation_slug[op]}", 0, 64 - length(local.operation_hash[op]) - length(random_string.suffix.result) - 2)}-\${local.operation_hash[op]}-\${random_string.suffix.result}"
3013
+ }
3014
+ role_name = { for op in keys(local.operations) : op =>
3015
+ "\${substr("TestApi-\${local.operation_slug[op]}-role", 0, 64 - length(local.operation_hash[op]) - length(random_string.suffix.result) - 2)}-\${local.operation_hash[op]}-\${random_string.suffix.result}"
3016
+ }
3017
+
3018
+ # A REST API needs a resource per path segment, so each operation's path is
3019
+ # split into the distinct path prefixes the resource tree is built from below
3020
+ operation_segments = { for op, details in local.operations : op => compact(split("/", details.path)) }
3021
+
3022
+ path_nodes = merge([
3023
+ for op, segments in local.operation_segments : {
3024
+ for i, segment in segments : join("/", slice(segments, 0, i + 1)) => {
3025
+ depth = i
3026
+ parent = i == 0 ? "" : join("/", slice(segments, 0, i))
3027
+ part = segment
3028
+ }
3029
+ }
3030
+ ]...)
3031
+
3032
+ # Instances of a resource cannot reference one another, so each depth is a
3033
+ # separate resource. Deeper paths are reported by the precondition below.
3034
+ max_path_depth = 16
3035
+ path_nodes_by_depth = { for depth in range(local.max_path_depth) : depth => {
3036
+ for path, node in local.path_nodes : path => node if node.depth == depth
3037
+ } }
3038
+ paths_too_deep = [for path, node in local.path_nodes : path if node.depth >= local.max_path_depth]
3039
+
3040
+ resource_ids = merge(
3041
+ { for path, resource in aws_api_gateway_resource.path_depth_0 : path => resource.id },
3042
+ { for path, resource in aws_api_gateway_resource.path_depth_1 : path => resource.id },
3043
+ { for path, resource in aws_api_gateway_resource.path_depth_2 : path => resource.id },
3044
+ { for path, resource in aws_api_gateway_resource.path_depth_3 : path => resource.id },
3045
+ { for path, resource in aws_api_gateway_resource.path_depth_4 : path => resource.id },
3046
+ { for path, resource in aws_api_gateway_resource.path_depth_5 : path => resource.id },
3047
+ { for path, resource in aws_api_gateway_resource.path_depth_6 : path => resource.id },
3048
+ { for path, resource in aws_api_gateway_resource.path_depth_7 : path => resource.id },
3049
+ { for path, resource in aws_api_gateway_resource.path_depth_8 : path => resource.id },
3050
+ { for path, resource in aws_api_gateway_resource.path_depth_9 : path => resource.id },
3051
+ { for path, resource in aws_api_gateway_resource.path_depth_10 : path => resource.id },
3052
+ { for path, resource in aws_api_gateway_resource.path_depth_11 : path => resource.id },
3053
+ { for path, resource in aws_api_gateway_resource.path_depth_12 : path => resource.id },
3054
+ { for path, resource in aws_api_gateway_resource.path_depth_13 : path => resource.id },
3055
+ { for path, resource in aws_api_gateway_resource.path_depth_14 : path => resource.id },
3056
+ { for path, resource in aws_api_gateway_resource.path_depth_15 : path => resource.id },
3057
+ )
3058
+
3059
+ operation_resource_id = { for op, segments in local.operation_segments : op =>
3060
+ local.resource_ids[join("/", segments)]
3061
+ }
3062
+
3063
+ permission_source_suffix = { for op, details in local.operations : op =>
3064
+ "\${upper(details.method)}/\${replace(join("/", local.operation_segments[op]), "/{[^}]*}/", "*")}"
3065
+ }
3066
+ }
3067
+
3068
+ resource "terraform_data" "operations_metadata" {
3069
+ # Fails the plan when the operations metadata has not been generated
3070
+ input = local.operations_file
3071
+
3072
+ lifecycle {
3073
+ precondition {
3074
+ condition = fileexists(local.operations_file)
3075
+ error_message = "Operations metadata not found at \${local.operations_file}. Build the TestApi project to generate it."
3076
+ }
3077
+ precondition {
3078
+ condition = length(local.operations) > 0
3079
+ error_message = "No operations found in \${local.operations_file}. The TestApi API must define at least one operation."
3080
+ }
3081
+ precondition {
3082
+ condition = length(local.paths_too_deep) == 0
3083
+ error_message = "Operation paths exceed the supported depth of \${local.max_path_depth} segments: \${join(", ", local.paths_too_deep)}. Add further aws_api_gateway_resource levels to this module to support them."
3084
+ }
3085
+ }
3086
+ }
3087
+
3088
+ # API Gateway resources at path depth 0
3089
+ resource "aws_api_gateway_resource" "path_depth_0" {
3090
+ for_each = local.path_nodes_by_depth[0]
3091
+
3092
+ rest_api_id = module.rest_api.api_id
3093
+ parent_id = module.rest_api.api_root_resource_id
3094
+ path_part = each.value.part
3095
+ }
3096
+
3097
+ # API Gateway resources at path depth 1
3098
+ resource "aws_api_gateway_resource" "path_depth_1" {
3099
+ for_each = local.path_nodes_by_depth[1]
3100
+
3101
+ rest_api_id = module.rest_api.api_id
3102
+ parent_id = aws_api_gateway_resource.path_depth_0[each.value.parent].id
3103
+ path_part = each.value.part
3104
+ }
3105
+
3106
+ # API Gateway resources at path depth 2
3107
+ resource "aws_api_gateway_resource" "path_depth_2" {
3108
+ for_each = local.path_nodes_by_depth[2]
3109
+
3110
+ rest_api_id = module.rest_api.api_id
3111
+ parent_id = aws_api_gateway_resource.path_depth_1[each.value.parent].id
3112
+ path_part = each.value.part
3113
+ }
3114
+
3115
+ # API Gateway resources at path depth 3
3116
+ resource "aws_api_gateway_resource" "path_depth_3" {
3117
+ for_each = local.path_nodes_by_depth[3]
3118
+
3119
+ rest_api_id = module.rest_api.api_id
3120
+ parent_id = aws_api_gateway_resource.path_depth_2[each.value.parent].id
3121
+ path_part = each.value.part
3122
+ }
3123
+
3124
+ # API Gateway resources at path depth 4
3125
+ resource "aws_api_gateway_resource" "path_depth_4" {
3126
+ for_each = local.path_nodes_by_depth[4]
3127
+
3128
+ rest_api_id = module.rest_api.api_id
3129
+ parent_id = aws_api_gateway_resource.path_depth_3[each.value.parent].id
3130
+ path_part = each.value.part
3131
+ }
3132
+
3133
+ # API Gateway resources at path depth 5
3134
+ resource "aws_api_gateway_resource" "path_depth_5" {
3135
+ for_each = local.path_nodes_by_depth[5]
3136
+
3137
+ rest_api_id = module.rest_api.api_id
3138
+ parent_id = aws_api_gateway_resource.path_depth_4[each.value.parent].id
3139
+ path_part = each.value.part
3140
+ }
3141
+
3142
+ # API Gateway resources at path depth 6
3143
+ resource "aws_api_gateway_resource" "path_depth_6" {
3144
+ for_each = local.path_nodes_by_depth[6]
3145
+
3146
+ rest_api_id = module.rest_api.api_id
3147
+ parent_id = aws_api_gateway_resource.path_depth_5[each.value.parent].id
3148
+ path_part = each.value.part
3149
+ }
3150
+
3151
+ # API Gateway resources at path depth 7
3152
+ resource "aws_api_gateway_resource" "path_depth_7" {
3153
+ for_each = local.path_nodes_by_depth[7]
3154
+
3155
+ rest_api_id = module.rest_api.api_id
3156
+ parent_id = aws_api_gateway_resource.path_depth_6[each.value.parent].id
3157
+ path_part = each.value.part
3158
+ }
3159
+
3160
+ # API Gateway resources at path depth 8
3161
+ resource "aws_api_gateway_resource" "path_depth_8" {
3162
+ for_each = local.path_nodes_by_depth[8]
3163
+
3164
+ rest_api_id = module.rest_api.api_id
3165
+ parent_id = aws_api_gateway_resource.path_depth_7[each.value.parent].id
3166
+ path_part = each.value.part
3167
+ }
3168
+
3169
+ # API Gateway resources at path depth 9
3170
+ resource "aws_api_gateway_resource" "path_depth_9" {
3171
+ for_each = local.path_nodes_by_depth[9]
3172
+
3173
+ rest_api_id = module.rest_api.api_id
3174
+ parent_id = aws_api_gateway_resource.path_depth_8[each.value.parent].id
3175
+ path_part = each.value.part
3176
+ }
3177
+
3178
+ # API Gateway resources at path depth 10
3179
+ resource "aws_api_gateway_resource" "path_depth_10" {
3180
+ for_each = local.path_nodes_by_depth[10]
3181
+
3182
+ rest_api_id = module.rest_api.api_id
3183
+ parent_id = aws_api_gateway_resource.path_depth_9[each.value.parent].id
3184
+ path_part = each.value.part
3185
+ }
3186
+
3187
+ # API Gateway resources at path depth 11
3188
+ resource "aws_api_gateway_resource" "path_depth_11" {
3189
+ for_each = local.path_nodes_by_depth[11]
3190
+
3191
+ rest_api_id = module.rest_api.api_id
3192
+ parent_id = aws_api_gateway_resource.path_depth_10[each.value.parent].id
3193
+ path_part = each.value.part
3194
+ }
3195
+
3196
+ # API Gateway resources at path depth 12
3197
+ resource "aws_api_gateway_resource" "path_depth_12" {
3198
+ for_each = local.path_nodes_by_depth[12]
3199
+
3200
+ rest_api_id = module.rest_api.api_id
3201
+ parent_id = aws_api_gateway_resource.path_depth_11[each.value.parent].id
3202
+ path_part = each.value.part
3203
+ }
3204
+
3205
+ # API Gateway resources at path depth 13
3206
+ resource "aws_api_gateway_resource" "path_depth_13" {
3207
+ for_each = local.path_nodes_by_depth[13]
3208
+
3209
+ rest_api_id = module.rest_api.api_id
3210
+ parent_id = aws_api_gateway_resource.path_depth_12[each.value.parent].id
3211
+ path_part = each.value.part
3212
+ }
3213
+
3214
+ # API Gateway resources at path depth 14
3215
+ resource "aws_api_gateway_resource" "path_depth_14" {
3216
+ for_each = local.path_nodes_by_depth[14]
3217
+
3218
+ rest_api_id = module.rest_api.api_id
3219
+ parent_id = aws_api_gateway_resource.path_depth_13[each.value.parent].id
3220
+ path_part = each.value.part
3221
+ }
3222
+
3223
+ # API Gateway resources at path depth 15
3224
+ resource "aws_api_gateway_resource" "path_depth_15" {
3225
+ for_each = local.path_nodes_by_depth[15]
3226
+
3227
+ rest_api_id = module.rest_api.api_id
3228
+ parent_id = aws_api_gateway_resource.path_depth_14[each.value.parent].id
3229
+ path_part = each.value.part
3230
+ }
3231
+
2801
3232
  # Resources
2802
3233
 
2803
3234
  # Create Lambda ZIP file from the bundle directory
@@ -2872,8 +3303,10 @@ resource "aws_vpc_security_group_egress_rule" "api_lambda_https" {
2872
3303
  description = "Allow outbound HTTPS to AWS service endpoints"
2873
3304
  }
2874
3305
 
2875
- # Lambda function
2876
3306
  resource "aws_lambda_function" "api_lambda" {
3307
+ # One lambda function per operation, each serving just that operation
3308
+ for_each = local.operations
3309
+
2877
3310
  #checkov:skip=CKV_AWS_117:Lambda function is optionally deployed into a VPC via vpc_id/subnet_ids; not required for this use case
2878
3311
  #checkov:skip=CKV_AWS_116:Dead Letter Queue not required for this simple API use case
2879
3312
  #checkov:skip=CKV_AWS_272:Code signing not required for this use case
@@ -2882,8 +3315,8 @@ resource "aws_lambda_function" "api_lambda" {
2882
3315
  s3_bucket = aws_s3_object.lambda_zip.bucket
2883
3316
  s3_key = aws_s3_object.lambda_zip.key
2884
3317
  s3_object_version = aws_s3_object.lambda_zip.version_id
2885
- function_name = "TestApiHandler-\${random_string.suffix.result}"
2886
- role = aws_iam_role.lambda_execution_role.arn
3318
+ function_name = local.function_name[each.key]
3319
+ role = aws_iam_role.lambda_execution_role[each.key].arn
2887
3320
  handler = "run.sh"
2888
3321
  runtime = "python3.14"
2889
3322
  layers = ["arn:aws:lambda:\${data.aws_region.current.region}:753240598075:layer:LambdaAdapterLayerX86:24"]
@@ -2928,7 +3361,9 @@ resource "aws_lambda_function" "api_lambda" {
2928
3361
 
2929
3362
  # IAM role for Lambda execution
2930
3363
  resource "aws_iam_role" "lambda_execution_role" {
2931
- name = "TestApiHandler-execution-role-\${random_string.suffix.result}"
3364
+ for_each = local.operations
3365
+
3366
+ name = local.role_name[each.key]
2932
3367
 
2933
3368
  assume_role_policy = jsonencode({
2934
3369
  Version = "2012-10-17"
@@ -2948,32 +3383,38 @@ resource "aws_iam_role" "lambda_execution_role" {
2948
3383
 
2949
3384
  # Attach basic execution policy to Lambda role
2950
3385
  resource "aws_iam_role_policy_attachment" "lambda_basic_execution" {
3386
+ for_each = local.operations
3387
+
2951
3388
  policy_arn = "arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole"
2952
- role = aws_iam_role.lambda_execution_role.name
3389
+ role = aws_iam_role.lambda_execution_role[each.key].name
2953
3390
  }
2954
3391
 
2955
3392
  # Attach X-Ray tracing policy to Lambda role
2956
3393
  resource "aws_iam_role_policy_attachment" "lambda_xray_execution" {
3394
+ for_each = local.operations
3395
+
2957
3396
  policy_arn = "arn:aws:iam::aws:policy/AWSXRayDaemonWriteAccess"
2958
- role = aws_iam_role.lambda_execution_role.name
3397
+ role = aws_iam_role.lambda_execution_role[each.key].name
2959
3398
  }
2960
3399
 
2961
3400
  # Attach VPC access policy to Lambda role when deployed into a VPC
2962
3401
  resource "aws_iam_role_policy_attachment" "lambda_vpc_access" {
2963
- count = var.enable_vpc ? 1 : 0
3402
+ for_each = var.enable_vpc ? local.operations : {}
3403
+
2964
3404
  policy_arn = "arn:aws:iam::aws:policy/service-role/AWSLambdaVPCAccessExecutionRole"
2965
- role = aws_iam_role.lambda_execution_role.name
3405
+ role = aws_iam_role.lambda_execution_role[each.key].name
2966
3406
  }
2967
3407
 
2968
3408
  # Additional IAM policies for Lambda (if provided)
2969
3409
  resource "aws_iam_role_policy" "lambda_additional_policies" {
2970
- count = length(local.lambda_policy_statements) > 0 ? 1 : 0
2971
- name = "TestApiHandler-additional-policies-\${random_string.suffix.result}"
2972
- role = aws_iam_role.lambda_execution_role.id
3410
+ for_each = local.operations
3411
+
3412
+ name = "\${substr(local.function_name[each.key], 0, 55)}-policies"
3413
+ role = aws_iam_role.lambda_execution_role[each.key].id
2973
3414
 
2974
3415
  policy = jsonencode({
2975
3416
  Version = "2012-10-17"
2976
- Statement = local.lambda_policy_statements
3417
+ Statement = local.lambda_policy_document_statements
2977
3418
  })
2978
3419
  }
2979
3420
 
@@ -2989,6 +3430,15 @@ locals {
2989
3430
  Resource = ["\${var.appconfig_application_arn}/*"]
2990
3431
  }
2991
3432
  ] : [], var.additional_iam_policy_statements)
3433
+
3434
+ # IAM rejects an empty statement list, so fall back to a no-op deny
3435
+ lambda_policy_document_statements = length(local.lambda_policy_statements) > 0 ? local.lambda_policy_statements : [
3436
+ {
3437
+ Effect = "Deny"
3438
+ Action = ["appconfig:GetLatestConfiguration"]
3439
+ Resource = ["arn:aws:appconfig:\${data.aws_region.current.region}:\${data.aws_caller_identity.current.account_id}:application/none"]
3440
+ }
3441
+ ]
2992
3442
  }
2993
3443
 
2994
3444
  # CloudWatch Log Group for Lambda
@@ -2996,15 +3446,19 @@ resource "aws_cloudwatch_log_group" "lambda_logs" {
2996
3446
  #checkov:skip=CKV_AWS_158:Using default CloudWatch log encryption
2997
3447
  #checkov:skip=CKV_AWS_338:Log retention set to forever
2998
3448
  #checkov:skip=CKV_AWS_66:Log retention set to forever
2999
- name = "/aws/lambda/TestApiHandler-\${random_string.suffix.result}"
3000
- tags = var.tags
3449
+ for_each = local.operations
3450
+
3451
+ name = "/aws/lambda/\${local.function_name[each.key]}"
3452
+ tags = var.tags
3001
3453
  }
3002
3454
 
3003
3455
  # Lambda alias pointing to the latest published version for SnapStart
3004
3456
  resource "aws_lambda_alias" "live" {
3457
+ for_each = local.operations
3458
+
3005
3459
  name = "live"
3006
- function_name = aws_lambda_function.api_lambda.function_name
3007
- function_version = aws_lambda_function.api_lambda.version
3460
+ function_name = aws_lambda_function.api_lambda[each.key].function_name
3461
+ function_version = aws_lambda_function.api_lambda[each.key].version
3008
3462
 
3009
3463
  depends_on = [aws_lambda_function.api_lambda]
3010
3464
  }
@@ -3018,34 +3472,14 @@ resource "aws_api_gateway_authorizer" "cognito_authorizer" {
3018
3472
  identity_source = "method.request.header.Authorization"
3019
3473
  }
3020
3474
 
3021
- # Create proxy resource (captures all paths)
3022
- resource "aws_api_gateway_resource" "proxy_resource" {
3023
- rest_api_id = module.rest_api.api_id
3024
- parent_id = module.rest_api.api_root_resource_id
3025
- path_part = "{proxy+}"
3026
- }
3027
-
3028
- # Lambda integration for REST API
3029
- resource "aws_api_gateway_integration" "lambda_integration" {
3030
- rest_api_id = module.rest_api.api_id
3031
- resource_id = aws_api_gateway_resource.proxy_resource.id
3032
- http_method = aws_api_gateway_method.proxy_method.http_method
3033
-
3034
- integration_http_method = "POST"
3035
- type = "AWS_PROXY"
3036
- # Use the response streaming invocation path with the alias ARN for SnapStart
3037
- uri = "arn:aws:apigateway:\${data.aws_region.current.region}:lambda:path/2021-11-15/functions/\${aws_lambda_alias.live.arn}/response-streaming-invocations"
3038
- response_transfer_mode = "STREAM"
3039
-
3040
- depends_on = [aws_lambda_alias.live]
3041
- }
3042
-
3043
- # Method for proxy integration
3044
- resource "aws_api_gateway_method" "proxy_method" {
3475
+ # Method per operation
3476
+ resource "aws_api_gateway_method" "operation_methods" {
3045
3477
  #checkov:skip=CKV2_AWS_53:Request validation not required for proxy integration as Lambda handles validation
3478
+ for_each = local.operations
3479
+
3046
3480
  rest_api_id = module.rest_api.api_id
3047
- resource_id = aws_api_gateway_resource.proxy_resource.id
3048
- http_method = "ANY"
3481
+ resource_id = local.operation_resource_id[each.key]
3482
+ http_method = upper(each.value.method)
3049
3483
 
3050
3484
  authorization = "COGNITO_USER_POOLS"
3051
3485
  authorizer_id = aws_api_gateway_authorizer.cognito_authorizer.id
@@ -3053,28 +3487,52 @@ resource "aws_api_gateway_method" "proxy_method" {
3053
3487
  # and 'aws.cognito.signin.user.admin' (Cognito admin/SRP auth APIs).
3054
3488
  authorization_scopes = ["openid", "aws.cognito.signin.user.admin"]
3055
3489
 
3490
+ # Path parameters must be declared on the method
3056
3491
  request_parameters = {
3057
- "method.request.path.proxy" = true
3492
+ for segment in local.operation_segments[each.key] :
3493
+ "method.request.path.\${trimsuffix(trimprefix(segment, "{"), "}")}" => true
3494
+ if startswith(segment, "{")
3058
3495
  }
3059
3496
 
3060
3497
  depends_on = [aws_api_gateway_authorizer.cognito_authorizer]
3061
3498
  }
3062
3499
 
3063
- # OPTIONS method for CORS preflight
3500
+ # Lambda integration per operation
3501
+ resource "aws_api_gateway_integration" "lambda_integration" {
3502
+ for_each = local.operations
3503
+
3504
+ rest_api_id = module.rest_api.api_id
3505
+ resource_id = local.operation_resource_id[each.key]
3506
+ http_method = aws_api_gateway_method.operation_methods[each.key].http_method
3507
+
3508
+ integration_http_method = "POST"
3509
+ type = "AWS_PROXY"
3510
+ # Use the response streaming invocation path with the alias ARN for SnapStart
3511
+ uri = "arn:aws:apigateway:\${data.aws_region.current.region}:lambda:path/2021-11-15/functions/\${aws_lambda_alias.live[each.key].arn}/response-streaming-invocations"
3512
+ response_transfer_mode = "STREAM"
3513
+
3514
+ depends_on = [aws_lambda_alias.live]
3515
+ }
3516
+
3517
+ # OPTIONS method for CORS preflight, on every resource an operation is served from
3064
3518
  resource "aws_api_gateway_method" "options_method" {
3065
3519
  #checkov:skip=CKV2_AWS_70:OPTIONS method must be unauthenticated for CORS preflight requests
3066
3520
  #checkov:skip=CKV2_AWS_53:Request validation not required for OPTIONS CORS preflight method
3521
+ for_each = local.resource_ids
3522
+
3067
3523
  rest_api_id = module.rest_api.api_id
3068
- resource_id = aws_api_gateway_resource.proxy_resource.id
3524
+ resource_id = each.value
3069
3525
  http_method = "OPTIONS"
3070
3526
  authorization = "NONE"
3071
3527
  }
3072
3528
 
3073
- # CORS integration for OPTIONS method
3529
+ # CORS integration for OPTIONS methods
3074
3530
  resource "aws_api_gateway_integration" "options_integration" {
3531
+ for_each = local.resource_ids
3532
+
3075
3533
  rest_api_id = module.rest_api.api_id
3076
- resource_id = aws_api_gateway_resource.proxy_resource.id
3077
- http_method = aws_api_gateway_method.options_method.http_method
3534
+ resource_id = each.value
3535
+ http_method = aws_api_gateway_method.options_method[each.key].http_method
3078
3536
 
3079
3537
  type = "MOCK"
3080
3538
  request_templates = {
@@ -3082,11 +3540,13 @@ resource "aws_api_gateway_integration" "options_integration" {
3082
3540
  }
3083
3541
  }
3084
3542
 
3085
- # OPTIONS method response
3543
+ # OPTIONS method responses
3086
3544
  resource "aws_api_gateway_method_response" "options_response" {
3545
+ for_each = local.resource_ids
3546
+
3087
3547
  rest_api_id = module.rest_api.api_id
3088
- resource_id = aws_api_gateway_resource.proxy_resource.id
3089
- http_method = aws_api_gateway_method.options_method.http_method
3548
+ resource_id = each.value
3549
+ http_method = aws_api_gateway_method.options_method[each.key].http_method
3090
3550
  status_code = "204"
3091
3551
 
3092
3552
  response_parameters = {
@@ -3096,18 +3556,22 @@ resource "aws_api_gateway_method_response" "options_response" {
3096
3556
  }
3097
3557
  }
3098
3558
 
3099
- # OPTIONS integration response
3559
+ # OPTIONS integration responses
3100
3560
  resource "aws_api_gateway_integration_response" "options_integration_response" {
3561
+ for_each = local.resource_ids
3562
+
3101
3563
  rest_api_id = module.rest_api.api_id
3102
- resource_id = aws_api_gateway_resource.proxy_resource.id
3103
- http_method = aws_api_gateway_method.options_method.http_method
3104
- status_code = aws_api_gateway_method_response.options_response.status_code
3564
+ resource_id = each.value
3565
+ http_method = aws_api_gateway_method.options_method[each.key].http_method
3566
+ status_code = aws_api_gateway_method_response.options_response[each.key].status_code
3105
3567
 
3106
3568
  response_parameters = {
3107
3569
  "method.response.header.Access-Control-Allow-Headers" = "'\${join(",", var.cors_allow_headers)}'"
3108
3570
  "method.response.header.Access-Control-Allow-Methods" = "'\${join(",", var.cors_allow_methods)}'"
3109
3571
  "method.response.header.Access-Control-Allow-Origin" = "'\${join(",", var.cors_allow_origins)}'"
3110
3572
  }
3573
+
3574
+ depends_on = [aws_api_gateway_integration.options_integration]
3111
3575
  }
3112
3576
 
3113
3577
  # API Gateway deployment
@@ -3115,13 +3579,28 @@ resource "aws_api_gateway_deployment" "api_deployment" {
3115
3579
  rest_api_id = module.rest_api.api_id
3116
3580
 
3117
3581
  triggers = {
3118
- redeployment = sha1(jsonencode([
3119
- aws_api_gateway_resource.proxy_resource.id,
3120
- aws_api_gateway_method.proxy_method.id,
3121
- aws_api_gateway_integration.lambda_integration.id,
3122
- aws_api_gateway_method.options_method.id,
3123
- aws_api_gateway_integration.options_integration.id,
3124
- ]))
3582
+ redeployment = sha1(jsonencode(concat(
3583
+ [for r in aws_api_gateway_resource.path_depth_0 : r.id],
3584
+ [for r in aws_api_gateway_resource.path_depth_1 : r.id],
3585
+ [for r in aws_api_gateway_resource.path_depth_2 : r.id],
3586
+ [for r in aws_api_gateway_resource.path_depth_3 : r.id],
3587
+ [for r in aws_api_gateway_resource.path_depth_4 : r.id],
3588
+ [for r in aws_api_gateway_resource.path_depth_5 : r.id],
3589
+ [for r in aws_api_gateway_resource.path_depth_6 : r.id],
3590
+ [for r in aws_api_gateway_resource.path_depth_7 : r.id],
3591
+ [for r in aws_api_gateway_resource.path_depth_8 : r.id],
3592
+ [for r in aws_api_gateway_resource.path_depth_9 : r.id],
3593
+ [for r in aws_api_gateway_resource.path_depth_10 : r.id],
3594
+ [for r in aws_api_gateway_resource.path_depth_11 : r.id],
3595
+ [for r in aws_api_gateway_resource.path_depth_12 : r.id],
3596
+ [for r in aws_api_gateway_resource.path_depth_13 : r.id],
3597
+ [for r in aws_api_gateway_resource.path_depth_14 : r.id],
3598
+ [for r in aws_api_gateway_resource.path_depth_15 : r.id],
3599
+ [for m in aws_api_gateway_method.operation_methods : m.id],
3600
+ [for i in aws_api_gateway_integration.lambda_integration : i.id],
3601
+ [for m in aws_api_gateway_method.options_method : m.id],
3602
+ [for i in aws_api_gateway_integration.options_integration : i.id],
3603
+ )))
3125
3604
  }
3126
3605
 
3127
3606
  lifecycle {
@@ -3129,7 +3608,7 @@ resource "aws_api_gateway_deployment" "api_deployment" {
3129
3608
  }
3130
3609
 
3131
3610
  depends_on = [
3132
- aws_api_gateway_method.proxy_method,
3611
+ aws_api_gateway_method.operation_methods,
3133
3612
  aws_api_gateway_integration.lambda_integration,
3134
3613
  aws_api_gateway_method.options_method,
3135
3614
  aws_api_gateway_integration.options_integration,
@@ -3257,24 +3736,28 @@ resource "aws_api_gateway_rest_api_policy" "api_policy" {
3257
3736
 
3258
3737
  # Lambda permission for API Gateway to invoke the function via alias
3259
3738
  resource "aws_lambda_permission" "api_gateway_invoke" {
3739
+ for_each = local.operations
3740
+
3260
3741
  statement_id = "AllowExecutionFromAPIGateway"
3261
3742
  action = "lambda:InvokeFunction"
3262
- function_name = aws_lambda_function.api_lambda.function_name
3263
- qualifier = aws_lambda_alias.live.name
3743
+ function_name = aws_lambda_function.api_lambda[each.key].function_name
3744
+ qualifier = aws_lambda_alias.live[each.key].name
3264
3745
  principal = "apigateway.amazonaws.com"
3265
- source_arn = "\${module.rest_api.api_execution_arn}/*/*"
3746
+ source_arn = "\${module.rest_api.api_execution_arn}/*/\${local.permission_source_suffix[each.key]}"
3266
3747
 
3267
3748
  depends_on = [module.rest_api, aws_lambda_alias.live]
3268
3749
  }
3269
3750
 
3270
3751
  # Lambda permission for API Gateway to invoke with response streaming via alias
3271
3752
  resource "aws_lambda_permission" "api_gateway_invoke_streaming" {
3753
+ for_each = local.operations
3754
+
3272
3755
  statement_id = "AllowStreamingFromAPIGateway"
3273
3756
  action = "lambda:InvokeFunctionWithResponseStream"
3274
- function_name = aws_lambda_function.api_lambda.function_name
3275
- qualifier = aws_lambda_alias.live.name
3757
+ function_name = aws_lambda_function.api_lambda[each.key].function_name
3758
+ qualifier = aws_lambda_alias.live[each.key].name
3276
3759
  principal = "apigateway.amazonaws.com"
3277
- source_arn = "\${module.rest_api.api_execution_arn}/*/*"
3760
+ source_arn = "\${module.rest_api.api_execution_arn}/*/\${local.permission_source_suffix[each.key]}"
3278
3761
 
3279
3762
  depends_on = [module.rest_api, aws_lambda_alias.live]
3280
3763
  }
@@ -3338,83 +3821,83 @@ output "stage_id" {
3338
3821
  value = aws_api_gateway_stage.api_stage.id
3339
3822
  }
3340
3823
 
3341
- # Lambda Function Outputs
3342
- output "lambda_function_name" {
3343
- description = "Name of the Lambda function"
3344
- value = aws_lambda_function.api_lambda.function_name
3824
+ # Lambda Function Outputs, keyed by operation name
3825
+ output "operations" {
3826
+ description = "Names of the API operations, each with its own Lambda function"
3827
+ value = keys(local.operations)
3345
3828
  }
3346
3829
 
3347
- output "lambda_function_arn" {
3348
- description = "ARN of the Lambda function"
3349
- value = aws_lambda_function.api_lambda.arn
3830
+ output "lambda_function_names" {
3831
+ description = "Name of each operation's Lambda function, keyed by operation name"
3832
+ value = { for op, fn in aws_lambda_function.api_lambda : op => fn.function_name }
3350
3833
  }
3351
3834
 
3352
- output "lambda_invoke_arn" {
3353
- description = "Invoke ARN of the Lambda function"
3354
- value = aws_lambda_function.api_lambda.invoke_arn
3835
+ output "lambda_function_arns" {
3836
+ description = "ARN of each operation's Lambda function, keyed by operation name"
3837
+ value = { for op, fn in aws_lambda_function.api_lambda : op => fn.arn }
3355
3838
  }
3356
3839
 
3357
- output "lambda_qualified_arn" {
3358
- description = "Qualified ARN of the Lambda function"
3359
- value = aws_lambda_function.api_lambda.qualified_arn
3840
+ output "lambda_invoke_arns" {
3841
+ description = "Invoke ARN of each operation's Lambda function, keyed by operation name"
3842
+ value = { for op, fn in aws_lambda_function.api_lambda : op => fn.invoke_arn }
3360
3843
  }
3361
3844
 
3362
- output "lambda_version" {
3363
- description = "Version of the Lambda function"
3364
- value = aws_lambda_function.api_lambda.version
3845
+ output "lambda_qualified_arns" {
3846
+ description = "Qualified ARN of each operation's Lambda function, keyed by operation name"
3847
+ value = { for op, fn in aws_lambda_function.api_lambda : op => fn.qualified_arn }
3365
3848
  }
3366
3849
 
3367
- output "lambda_source_code_hash" {
3368
- description = "Base64-encoded SHA256 hash of the Lambda deployment package"
3369
- value = aws_lambda_function.api_lambda.source_code_hash
3850
+ output "lambda_versions" {
3851
+ description = "Version of each operation's Lambda function, keyed by operation name"
3852
+ value = { for op, fn in aws_lambda_function.api_lambda : op => fn.version }
3370
3853
  }
3371
3854
 
3372
- output "lambda_source_code_size" {
3373
- description = "Size of the Lambda deployment package in bytes"
3374
- value = aws_lambda_function.api_lambda.source_code_size
3855
+ output "lambda_source_code_hash" {
3856
+ description = "Base64-encoded SHA256 hash of the Lambda deployment package, shared by every operation"
3857
+ value = data.archive_file.lambda_zip.output_base64sha256
3375
3858
  }
3376
3859
 
3377
- # IAM Role Outputs
3378
- output "lambda_execution_role_arn" {
3379
- description = "ARN of the Lambda execution role"
3380
- value = aws_iam_role.lambda_execution_role.arn
3860
+ # IAM Role Outputs, keyed by operation name
3861
+ output "lambda_execution_role_arns" {
3862
+ description = "ARN of each operation's Lambda execution role, keyed by operation name"
3863
+ value = { for op, role in aws_iam_role.lambda_execution_role : op => role.arn }
3381
3864
  }
3382
3865
 
3383
- output "lambda_execution_role_name" {
3384
- description = "Name of the Lambda execution role"
3385
- value = aws_iam_role.lambda_execution_role.name
3866
+ output "lambda_execution_role_names" {
3867
+ description = "Name of each operation's Lambda execution role, keyed by operation name. Attach additional policies to a specific operation's role by name."
3868
+ value = { for op, role in aws_iam_role.lambda_execution_role : op => role.name }
3386
3869
  }
3387
3870
 
3388
3871
  output "security_group_id" {
3389
- description = "Security group ID of the API Lambda function, for use in ingress rules on resources it must reach (e.g. a database). Null unless enable_vpc is true."
3872
+ description = "Security group ID shared by the API Lambda functions, for use in ingress rules on resources they must reach (e.g. a database). Null unless enable_vpc is true."
3390
3873
  value = var.enable_vpc ? aws_security_group.api_lambda[0].id : null
3391
3874
  }
3392
3875
 
3393
- # Integration Outputs
3394
- output "integration_id" {
3395
- description = "ID of the Lambda integration"
3396
- value = aws_api_gateway_integration.lambda_integration.id
3876
+ # Integration Outputs, keyed by operation name
3877
+ output "integration_ids" {
3878
+ description = "ID of each operation's Lambda integration, keyed by operation name"
3879
+ value = { for op, i in aws_api_gateway_integration.lambda_integration : op => i.id }
3397
3880
  }
3398
3881
 
3399
- output "proxy_resource_id" {
3400
- description = "ID of the proxy resource"
3401
- value = aws_api_gateway_resource.proxy_resource.id
3882
+ output "method_ids" {
3883
+ description = "ID of each operation's API Gateway method, keyed by operation name"
3884
+ value = { for op, m in aws_api_gateway_method.operation_methods : op => m.id }
3402
3885
  }
3403
3886
 
3404
- output "proxy_method_id" {
3405
- description = "ID of the proxy method"
3406
- value = aws_api_gateway_method.proxy_method.id
3887
+ output "resource_ids" {
3888
+ description = "ID of each API Gateway resource, keyed by its path"
3889
+ value = local.resource_ids
3407
3890
  }
3408
3891
 
3409
- # CloudWatch Log Groups
3410
- output "lambda_log_group_name" {
3411
- description = "Name of the Lambda CloudWatch log group"
3412
- value = aws_cloudwatch_log_group.lambda_logs.name
3892
+ # CloudWatch Log Groups, keyed by operation name
3893
+ output "lambda_log_group_names" {
3894
+ description = "Name of each operation's Lambda CloudWatch log group, keyed by operation name"
3895
+ value = { for op, lg in aws_cloudwatch_log_group.lambda_logs : op => lg.name }
3413
3896
  }
3414
3897
 
3415
- output "lambda_log_group_arn" {
3416
- description = "ARN of the Lambda CloudWatch log group"
3417
- value = aws_cloudwatch_log_group.lambda_logs.arn
3898
+ output "lambda_log_group_arns" {
3899
+ description = "ARN of each operation's Lambda CloudWatch log group, keyed by operation name"
3900
+ value = { for op, lg in aws_cloudwatch_log_group.lambda_logs : op => lg.arn }
3418
3901
  }
3419
3902
  ",
3420
3903
  }
@@ -3855,13 +4338,242 @@ resource "random_string" "suffix" {
3855
4338
  upper = false
3856
4339
  }
3857
4340
 
3858
- # Resources
4341
+ locals {
4342
+ # Generated at build time from the API definition
4343
+ operations_file = "\${path.module}/../../../generated/test-api/operations.json"
4344
+ operations = fileexists(local.operations_file) ? jsondecode(file(local.operations_file)) : {}
3859
4345
 
3860
- # Create Lambda ZIP file from the bundle directory
3861
- data "archive_file" "lambda_zip" {
3862
- type = "zip"
3863
- source_dir = "\${path.module}/../../../../../../../dist/apps/test_api/bundle-x86"
3864
- output_path = "\${path.module}/../../../../../../../dist/packages/common/terraform/apis/test-api/lambda.zip"
4346
+ operation_slug = { for op in keys(local.operations) : op => replace(op, "/[^a-zA-Z0-9-_]/", "-") }
4347
+ operation_hash = { for op in keys(local.operations) : op => substr(sha256(op), 0, 8) }
4348
+
4349
+ function_name = { for op in keys(local.operations) : op =>
4350
+ "\${substr("TestApi-\${local.operation_slug[op]}", 0, 64 - length(local.operation_hash[op]) - length(random_string.suffix.result) - 2)}-\${local.operation_hash[op]}-\${random_string.suffix.result}"
4351
+ }
4352
+ role_name = { for op in keys(local.operations) : op =>
4353
+ "\${substr("TestApi-\${local.operation_slug[op]}-role", 0, 64 - length(local.operation_hash[op]) - length(random_string.suffix.result) - 2)}-\${local.operation_hash[op]}-\${random_string.suffix.result}"
4354
+ }
4355
+
4356
+ # A REST API needs a resource per path segment, so each operation's path is
4357
+ # split into the distinct path prefixes the resource tree is built from below
4358
+ operation_segments = { for op, details in local.operations : op => compact(split("/", details.path)) }
4359
+
4360
+ path_nodes = merge([
4361
+ for op, segments in local.operation_segments : {
4362
+ for i, segment in segments : join("/", slice(segments, 0, i + 1)) => {
4363
+ depth = i
4364
+ parent = i == 0 ? "" : join("/", slice(segments, 0, i))
4365
+ part = segment
4366
+ }
4367
+ }
4368
+ ]...)
4369
+
4370
+ # Instances of a resource cannot reference one another, so each depth is a
4371
+ # separate resource. Deeper paths are reported by the precondition below.
4372
+ max_path_depth = 16
4373
+ path_nodes_by_depth = { for depth in range(local.max_path_depth) : depth => {
4374
+ for path, node in local.path_nodes : path => node if node.depth == depth
4375
+ } }
4376
+ paths_too_deep = [for path, node in local.path_nodes : path if node.depth >= local.max_path_depth]
4377
+
4378
+ resource_ids = merge(
4379
+ { for path, resource in aws_api_gateway_resource.path_depth_0 : path => resource.id },
4380
+ { for path, resource in aws_api_gateway_resource.path_depth_1 : path => resource.id },
4381
+ { for path, resource in aws_api_gateway_resource.path_depth_2 : path => resource.id },
4382
+ { for path, resource in aws_api_gateway_resource.path_depth_3 : path => resource.id },
4383
+ { for path, resource in aws_api_gateway_resource.path_depth_4 : path => resource.id },
4384
+ { for path, resource in aws_api_gateway_resource.path_depth_5 : path => resource.id },
4385
+ { for path, resource in aws_api_gateway_resource.path_depth_6 : path => resource.id },
4386
+ { for path, resource in aws_api_gateway_resource.path_depth_7 : path => resource.id },
4387
+ { for path, resource in aws_api_gateway_resource.path_depth_8 : path => resource.id },
4388
+ { for path, resource in aws_api_gateway_resource.path_depth_9 : path => resource.id },
4389
+ { for path, resource in aws_api_gateway_resource.path_depth_10 : path => resource.id },
4390
+ { for path, resource in aws_api_gateway_resource.path_depth_11 : path => resource.id },
4391
+ { for path, resource in aws_api_gateway_resource.path_depth_12 : path => resource.id },
4392
+ { for path, resource in aws_api_gateway_resource.path_depth_13 : path => resource.id },
4393
+ { for path, resource in aws_api_gateway_resource.path_depth_14 : path => resource.id },
4394
+ { for path, resource in aws_api_gateway_resource.path_depth_15 : path => resource.id },
4395
+ )
4396
+
4397
+ operation_resource_id = { for op, segments in local.operation_segments : op =>
4398
+ local.resource_ids[join("/", segments)]
4399
+ }
4400
+
4401
+ permission_source_suffix = { for op, details in local.operations : op =>
4402
+ "\${upper(details.method)}/\${replace(join("/", local.operation_segments[op]), "/{[^}]*}/", "*")}"
4403
+ }
4404
+ }
4405
+
4406
+ resource "terraform_data" "operations_metadata" {
4407
+ # Fails the plan when the operations metadata has not been generated
4408
+ input = local.operations_file
4409
+
4410
+ lifecycle {
4411
+ precondition {
4412
+ condition = fileexists(local.operations_file)
4413
+ error_message = "Operations metadata not found at \${local.operations_file}. Build the TestApi project to generate it."
4414
+ }
4415
+ precondition {
4416
+ condition = length(local.operations) > 0
4417
+ error_message = "No operations found in \${local.operations_file}. The TestApi API must define at least one operation."
4418
+ }
4419
+ precondition {
4420
+ condition = length(local.paths_too_deep) == 0
4421
+ error_message = "Operation paths exceed the supported depth of \${local.max_path_depth} segments: \${join(", ", local.paths_too_deep)}. Add further aws_api_gateway_resource levels to this module to support them."
4422
+ }
4423
+ }
4424
+ }
4425
+
4426
+ # API Gateway resources at path depth 0
4427
+ resource "aws_api_gateway_resource" "path_depth_0" {
4428
+ for_each = local.path_nodes_by_depth[0]
4429
+
4430
+ rest_api_id = module.rest_api.api_id
4431
+ parent_id = module.rest_api.api_root_resource_id
4432
+ path_part = each.value.part
4433
+ }
4434
+
4435
+ # API Gateway resources at path depth 1
4436
+ resource "aws_api_gateway_resource" "path_depth_1" {
4437
+ for_each = local.path_nodes_by_depth[1]
4438
+
4439
+ rest_api_id = module.rest_api.api_id
4440
+ parent_id = aws_api_gateway_resource.path_depth_0[each.value.parent].id
4441
+ path_part = each.value.part
4442
+ }
4443
+
4444
+ # API Gateway resources at path depth 2
4445
+ resource "aws_api_gateway_resource" "path_depth_2" {
4446
+ for_each = local.path_nodes_by_depth[2]
4447
+
4448
+ rest_api_id = module.rest_api.api_id
4449
+ parent_id = aws_api_gateway_resource.path_depth_1[each.value.parent].id
4450
+ path_part = each.value.part
4451
+ }
4452
+
4453
+ # API Gateway resources at path depth 3
4454
+ resource "aws_api_gateway_resource" "path_depth_3" {
4455
+ for_each = local.path_nodes_by_depth[3]
4456
+
4457
+ rest_api_id = module.rest_api.api_id
4458
+ parent_id = aws_api_gateway_resource.path_depth_2[each.value.parent].id
4459
+ path_part = each.value.part
4460
+ }
4461
+
4462
+ # API Gateway resources at path depth 4
4463
+ resource "aws_api_gateway_resource" "path_depth_4" {
4464
+ for_each = local.path_nodes_by_depth[4]
4465
+
4466
+ rest_api_id = module.rest_api.api_id
4467
+ parent_id = aws_api_gateway_resource.path_depth_3[each.value.parent].id
4468
+ path_part = each.value.part
4469
+ }
4470
+
4471
+ # API Gateway resources at path depth 5
4472
+ resource "aws_api_gateway_resource" "path_depth_5" {
4473
+ for_each = local.path_nodes_by_depth[5]
4474
+
4475
+ rest_api_id = module.rest_api.api_id
4476
+ parent_id = aws_api_gateway_resource.path_depth_4[each.value.parent].id
4477
+ path_part = each.value.part
4478
+ }
4479
+
4480
+ # API Gateway resources at path depth 6
4481
+ resource "aws_api_gateway_resource" "path_depth_6" {
4482
+ for_each = local.path_nodes_by_depth[6]
4483
+
4484
+ rest_api_id = module.rest_api.api_id
4485
+ parent_id = aws_api_gateway_resource.path_depth_5[each.value.parent].id
4486
+ path_part = each.value.part
4487
+ }
4488
+
4489
+ # API Gateway resources at path depth 7
4490
+ resource "aws_api_gateway_resource" "path_depth_7" {
4491
+ for_each = local.path_nodes_by_depth[7]
4492
+
4493
+ rest_api_id = module.rest_api.api_id
4494
+ parent_id = aws_api_gateway_resource.path_depth_6[each.value.parent].id
4495
+ path_part = each.value.part
4496
+ }
4497
+
4498
+ # API Gateway resources at path depth 8
4499
+ resource "aws_api_gateway_resource" "path_depth_8" {
4500
+ for_each = local.path_nodes_by_depth[8]
4501
+
4502
+ rest_api_id = module.rest_api.api_id
4503
+ parent_id = aws_api_gateway_resource.path_depth_7[each.value.parent].id
4504
+ path_part = each.value.part
4505
+ }
4506
+
4507
+ # API Gateway resources at path depth 9
4508
+ resource "aws_api_gateway_resource" "path_depth_9" {
4509
+ for_each = local.path_nodes_by_depth[9]
4510
+
4511
+ rest_api_id = module.rest_api.api_id
4512
+ parent_id = aws_api_gateway_resource.path_depth_8[each.value.parent].id
4513
+ path_part = each.value.part
4514
+ }
4515
+
4516
+ # API Gateway resources at path depth 10
4517
+ resource "aws_api_gateway_resource" "path_depth_10" {
4518
+ for_each = local.path_nodes_by_depth[10]
4519
+
4520
+ rest_api_id = module.rest_api.api_id
4521
+ parent_id = aws_api_gateway_resource.path_depth_9[each.value.parent].id
4522
+ path_part = each.value.part
4523
+ }
4524
+
4525
+ # API Gateway resources at path depth 11
4526
+ resource "aws_api_gateway_resource" "path_depth_11" {
4527
+ for_each = local.path_nodes_by_depth[11]
4528
+
4529
+ rest_api_id = module.rest_api.api_id
4530
+ parent_id = aws_api_gateway_resource.path_depth_10[each.value.parent].id
4531
+ path_part = each.value.part
4532
+ }
4533
+
4534
+ # API Gateway resources at path depth 12
4535
+ resource "aws_api_gateway_resource" "path_depth_12" {
4536
+ for_each = local.path_nodes_by_depth[12]
4537
+
4538
+ rest_api_id = module.rest_api.api_id
4539
+ parent_id = aws_api_gateway_resource.path_depth_11[each.value.parent].id
4540
+ path_part = each.value.part
4541
+ }
4542
+
4543
+ # API Gateway resources at path depth 13
4544
+ resource "aws_api_gateway_resource" "path_depth_13" {
4545
+ for_each = local.path_nodes_by_depth[13]
4546
+
4547
+ rest_api_id = module.rest_api.api_id
4548
+ parent_id = aws_api_gateway_resource.path_depth_12[each.value.parent].id
4549
+ path_part = each.value.part
4550
+ }
4551
+
4552
+ # API Gateway resources at path depth 14
4553
+ resource "aws_api_gateway_resource" "path_depth_14" {
4554
+ for_each = local.path_nodes_by_depth[14]
4555
+
4556
+ rest_api_id = module.rest_api.api_id
4557
+ parent_id = aws_api_gateway_resource.path_depth_13[each.value.parent].id
4558
+ path_part = each.value.part
4559
+ }
4560
+
4561
+ # API Gateway resources at path depth 15
4562
+ resource "aws_api_gateway_resource" "path_depth_15" {
4563
+ for_each = local.path_nodes_by_depth[15]
4564
+
4565
+ rest_api_id = module.rest_api.api_id
4566
+ parent_id = aws_api_gateway_resource.path_depth_14[each.value.parent].id
4567
+ path_part = each.value.part
4568
+ }
4569
+
4570
+ # Resources
4571
+
4572
+ # Create Lambda ZIP file from the bundle directory
4573
+ data "archive_file" "lambda_zip" {
4574
+ type = "zip"
4575
+ source_dir = "\${path.module}/../../../../../../../dist/apps/test_api/bundle-x86"
4576
+ output_path = "\${path.module}/../../../../../../../dist/packages/common/terraform/apis/test-api/lambda.zip"
3865
4577
  }
3866
4578
 
3867
4579
  resource "aws_s3_object" "lambda_zip" {
@@ -3929,8 +4641,10 @@ resource "aws_vpc_security_group_egress_rule" "api_lambda_https" {
3929
4641
  description = "Allow outbound HTTPS to AWS service endpoints"
3930
4642
  }
3931
4643
 
3932
- # Lambda function
3933
4644
  resource "aws_lambda_function" "api_lambda" {
4645
+ # One lambda function per operation, each serving just that operation
4646
+ for_each = local.operations
4647
+
3934
4648
  #checkov:skip=CKV_AWS_117:Lambda function is optionally deployed into a VPC via vpc_id/subnet_ids; not required for this use case
3935
4649
  #checkov:skip=CKV_AWS_116:Dead Letter Queue not required for this simple API use case
3936
4650
  #checkov:skip=CKV_AWS_272:Code signing not required for this use case
@@ -3939,8 +4653,8 @@ resource "aws_lambda_function" "api_lambda" {
3939
4653
  s3_bucket = aws_s3_object.lambda_zip.bucket
3940
4654
  s3_key = aws_s3_object.lambda_zip.key
3941
4655
  s3_object_version = aws_s3_object.lambda_zip.version_id
3942
- function_name = "TestApiHandler-\${random_string.suffix.result}"
3943
- role = aws_iam_role.lambda_execution_role.arn
4656
+ function_name = local.function_name[each.key]
4657
+ role = aws_iam_role.lambda_execution_role[each.key].arn
3944
4658
  handler = "run.sh"
3945
4659
  runtime = "python3.14"
3946
4660
  layers = ["arn:aws:lambda:\${data.aws_region.current.region}:753240598075:layer:LambdaAdapterLayerX86:24"]
@@ -3985,7 +4699,9 @@ resource "aws_lambda_function" "api_lambda" {
3985
4699
 
3986
4700
  # IAM role for Lambda execution
3987
4701
  resource "aws_iam_role" "lambda_execution_role" {
3988
- name = "TestApiHandler-execution-role-\${random_string.suffix.result}"
4702
+ for_each = local.operations
4703
+
4704
+ name = local.role_name[each.key]
3989
4705
 
3990
4706
  assume_role_policy = jsonencode({
3991
4707
  Version = "2012-10-17"
@@ -4005,32 +4721,38 @@ resource "aws_iam_role" "lambda_execution_role" {
4005
4721
 
4006
4722
  # Attach basic execution policy to Lambda role
4007
4723
  resource "aws_iam_role_policy_attachment" "lambda_basic_execution" {
4724
+ for_each = local.operations
4725
+
4008
4726
  policy_arn = "arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole"
4009
- role = aws_iam_role.lambda_execution_role.name
4727
+ role = aws_iam_role.lambda_execution_role[each.key].name
4010
4728
  }
4011
4729
 
4012
4730
  # Attach X-Ray tracing policy to Lambda role
4013
4731
  resource "aws_iam_role_policy_attachment" "lambda_xray_execution" {
4732
+ for_each = local.operations
4733
+
4014
4734
  policy_arn = "arn:aws:iam::aws:policy/AWSXRayDaemonWriteAccess"
4015
- role = aws_iam_role.lambda_execution_role.name
4735
+ role = aws_iam_role.lambda_execution_role[each.key].name
4016
4736
  }
4017
4737
 
4018
4738
  # Attach VPC access policy to Lambda role when deployed into a VPC
4019
4739
  resource "aws_iam_role_policy_attachment" "lambda_vpc_access" {
4020
- count = var.enable_vpc ? 1 : 0
4740
+ for_each = var.enable_vpc ? local.operations : {}
4741
+
4021
4742
  policy_arn = "arn:aws:iam::aws:policy/service-role/AWSLambdaVPCAccessExecutionRole"
4022
- role = aws_iam_role.lambda_execution_role.name
4743
+ role = aws_iam_role.lambda_execution_role[each.key].name
4023
4744
  }
4024
4745
 
4025
4746
  # Additional IAM policies for Lambda (if provided)
4026
4747
  resource "aws_iam_role_policy" "lambda_additional_policies" {
4027
- count = length(local.lambda_policy_statements) > 0 ? 1 : 0
4028
- name = "TestApiHandler-additional-policies-\${random_string.suffix.result}"
4029
- role = aws_iam_role.lambda_execution_role.id
4748
+ for_each = local.operations
4749
+
4750
+ name = "\${substr(local.function_name[each.key], 0, 55)}-policies"
4751
+ role = aws_iam_role.lambda_execution_role[each.key].id
4030
4752
 
4031
4753
  policy = jsonencode({
4032
4754
  Version = "2012-10-17"
4033
- Statement = local.lambda_policy_statements
4755
+ Statement = local.lambda_policy_document_statements
4034
4756
  })
4035
4757
  }
4036
4758
 
@@ -4046,6 +4768,15 @@ locals {
4046
4768
  Resource = ["\${var.appconfig_application_arn}/*"]
4047
4769
  }
4048
4770
  ] : [], var.additional_iam_policy_statements)
4771
+
4772
+ # IAM rejects an empty statement list, so fall back to a no-op deny
4773
+ lambda_policy_document_statements = length(local.lambda_policy_statements) > 0 ? local.lambda_policy_statements : [
4774
+ {
4775
+ Effect = "Deny"
4776
+ Action = ["appconfig:GetLatestConfiguration"]
4777
+ Resource = ["arn:aws:appconfig:\${data.aws_region.current.region}:\${data.aws_caller_identity.current.account_id}:application/none"]
4778
+ }
4779
+ ]
4049
4780
  }
4050
4781
 
4051
4782
  # CloudWatch Log Group for Lambda
@@ -4053,73 +4784,81 @@ resource "aws_cloudwatch_log_group" "lambda_logs" {
4053
4784
  #checkov:skip=CKV_AWS_158:Using default CloudWatch log encryption
4054
4785
  #checkov:skip=CKV_AWS_338:Log retention set to forever
4055
4786
  #checkov:skip=CKV_AWS_66:Log retention set to forever
4056
- name = "/aws/lambda/TestApiHandler-\${random_string.suffix.result}"
4057
- tags = var.tags
4787
+ for_each = local.operations
4788
+
4789
+ name = "/aws/lambda/\${local.function_name[each.key]}"
4790
+ tags = var.tags
4058
4791
  }
4059
4792
 
4060
4793
  # Lambda alias pointing to the latest published version for SnapStart
4061
4794
  resource "aws_lambda_alias" "live" {
4795
+ for_each = local.operations
4796
+
4062
4797
  name = "live"
4063
- function_name = aws_lambda_function.api_lambda.function_name
4064
- function_version = aws_lambda_function.api_lambda.version
4798
+ function_name = aws_lambda_function.api_lambda[each.key].function_name
4799
+ function_version = aws_lambda_function.api_lambda[each.key].version
4065
4800
 
4066
4801
  depends_on = [aws_lambda_function.api_lambda]
4067
4802
  }
4068
4803
 
4069
4804
 
4070
- # Create proxy resource (captures all paths)
4071
- resource "aws_api_gateway_resource" "proxy_resource" {
4072
- rest_api_id = module.rest_api.api_id
4073
- parent_id = module.rest_api.api_root_resource_id
4074
- path_part = "{proxy+}"
4805
+ # Method per operation
4806
+ resource "aws_api_gateway_method" "operation_methods" {
4807
+ #checkov:skip=CKV2_AWS_53:Request validation not required for proxy integration as Lambda handles validation
4808
+ for_each = local.operations
4809
+
4810
+ rest_api_id = module.rest_api.api_id
4811
+ resource_id = local.operation_resource_id[each.key]
4812
+ http_method = upper(each.value.method)
4813
+
4814
+ authorization = "AWS_IAM"
4815
+
4816
+ # Path parameters must be declared on the method
4817
+ request_parameters = {
4818
+ for segment in local.operation_segments[each.key] :
4819
+ "method.request.path.\${trimsuffix(trimprefix(segment, "{"), "}")}" => true
4820
+ if startswith(segment, "{")
4821
+ }
4822
+
4823
+ depends_on = []
4075
4824
  }
4076
4825
 
4077
- # Lambda integration for REST API
4826
+ # Lambda integration per operation
4078
4827
  resource "aws_api_gateway_integration" "lambda_integration" {
4828
+ for_each = local.operations
4829
+
4079
4830
  rest_api_id = module.rest_api.api_id
4080
- resource_id = aws_api_gateway_resource.proxy_resource.id
4081
- http_method = aws_api_gateway_method.proxy_method.http_method
4831
+ resource_id = local.operation_resource_id[each.key]
4832
+ http_method = aws_api_gateway_method.operation_methods[each.key].http_method
4082
4833
 
4083
4834
  integration_http_method = "POST"
4084
4835
  type = "AWS_PROXY"
4085
4836
  # Use the response streaming invocation path with the alias ARN for SnapStart
4086
- uri = "arn:aws:apigateway:\${data.aws_region.current.region}:lambda:path/2021-11-15/functions/\${aws_lambda_alias.live.arn}/response-streaming-invocations"
4837
+ uri = "arn:aws:apigateway:\${data.aws_region.current.region}:lambda:path/2021-11-15/functions/\${aws_lambda_alias.live[each.key].arn}/response-streaming-invocations"
4087
4838
  response_transfer_mode = "STREAM"
4088
4839
 
4089
4840
  depends_on = [aws_lambda_alias.live]
4090
4841
  }
4091
4842
 
4092
- # Method for proxy integration
4093
- resource "aws_api_gateway_method" "proxy_method" {
4094
- #checkov:skip=CKV2_AWS_53:Request validation not required for proxy integration as Lambda handles validation
4095
- rest_api_id = module.rest_api.api_id
4096
- resource_id = aws_api_gateway_resource.proxy_resource.id
4097
- http_method = "ANY"
4098
-
4099
- authorization = "AWS_IAM"
4100
-
4101
- request_parameters = {
4102
- "method.request.path.proxy" = true
4103
- }
4104
-
4105
- depends_on = []
4106
- }
4107
-
4108
- # OPTIONS method for CORS preflight
4843
+ # OPTIONS method for CORS preflight, on every resource an operation is served from
4109
4844
  resource "aws_api_gateway_method" "options_method" {
4110
4845
  #checkov:skip=CKV2_AWS_70:OPTIONS method must be unauthenticated for CORS preflight requests
4111
4846
  #checkov:skip=CKV2_AWS_53:Request validation not required for OPTIONS CORS preflight method
4847
+ for_each = local.resource_ids
4848
+
4112
4849
  rest_api_id = module.rest_api.api_id
4113
- resource_id = aws_api_gateway_resource.proxy_resource.id
4850
+ resource_id = each.value
4114
4851
  http_method = "OPTIONS"
4115
4852
  authorization = "NONE"
4116
4853
  }
4117
4854
 
4118
- # CORS integration for OPTIONS method
4855
+ # CORS integration for OPTIONS methods
4119
4856
  resource "aws_api_gateway_integration" "options_integration" {
4857
+ for_each = local.resource_ids
4858
+
4120
4859
  rest_api_id = module.rest_api.api_id
4121
- resource_id = aws_api_gateway_resource.proxy_resource.id
4122
- http_method = aws_api_gateway_method.options_method.http_method
4860
+ resource_id = each.value
4861
+ http_method = aws_api_gateway_method.options_method[each.key].http_method
4123
4862
 
4124
4863
  type = "MOCK"
4125
4864
  request_templates = {
@@ -4127,11 +4866,13 @@ resource "aws_api_gateway_integration" "options_integration" {
4127
4866
  }
4128
4867
  }
4129
4868
 
4130
- # OPTIONS method response
4869
+ # OPTIONS method responses
4131
4870
  resource "aws_api_gateway_method_response" "options_response" {
4871
+ for_each = local.resource_ids
4872
+
4132
4873
  rest_api_id = module.rest_api.api_id
4133
- resource_id = aws_api_gateway_resource.proxy_resource.id
4134
- http_method = aws_api_gateway_method.options_method.http_method
4874
+ resource_id = each.value
4875
+ http_method = aws_api_gateway_method.options_method[each.key].http_method
4135
4876
  status_code = "204"
4136
4877
 
4137
4878
  response_parameters = {
@@ -4141,18 +4882,22 @@ resource "aws_api_gateway_method_response" "options_response" {
4141
4882
  }
4142
4883
  }
4143
4884
 
4144
- # OPTIONS integration response
4885
+ # OPTIONS integration responses
4145
4886
  resource "aws_api_gateway_integration_response" "options_integration_response" {
4887
+ for_each = local.resource_ids
4888
+
4146
4889
  rest_api_id = module.rest_api.api_id
4147
- resource_id = aws_api_gateway_resource.proxy_resource.id
4148
- http_method = aws_api_gateway_method.options_method.http_method
4149
- status_code = aws_api_gateway_method_response.options_response.status_code
4890
+ resource_id = each.value
4891
+ http_method = aws_api_gateway_method.options_method[each.key].http_method
4892
+ status_code = aws_api_gateway_method_response.options_response[each.key].status_code
4150
4893
 
4151
4894
  response_parameters = {
4152
4895
  "method.response.header.Access-Control-Allow-Headers" = "'\${join(",", var.cors_allow_headers)}'"
4153
4896
  "method.response.header.Access-Control-Allow-Methods" = "'\${join(",", var.cors_allow_methods)}'"
4154
4897
  "method.response.header.Access-Control-Allow-Origin" = "'\${join(",", var.cors_allow_origins)}'"
4155
4898
  }
4899
+
4900
+ depends_on = [aws_api_gateway_integration.options_integration]
4156
4901
  }
4157
4902
 
4158
4903
  # API Gateway deployment
@@ -4160,13 +4905,28 @@ resource "aws_api_gateway_deployment" "api_deployment" {
4160
4905
  rest_api_id = module.rest_api.api_id
4161
4906
 
4162
4907
  triggers = {
4163
- redeployment = sha1(jsonencode([
4164
- aws_api_gateway_resource.proxy_resource.id,
4165
- aws_api_gateway_method.proxy_method.id,
4166
- aws_api_gateway_integration.lambda_integration.id,
4167
- aws_api_gateway_method.options_method.id,
4168
- aws_api_gateway_integration.options_integration.id,
4169
- ]))
4908
+ redeployment = sha1(jsonencode(concat(
4909
+ [for r in aws_api_gateway_resource.path_depth_0 : r.id],
4910
+ [for r in aws_api_gateway_resource.path_depth_1 : r.id],
4911
+ [for r in aws_api_gateway_resource.path_depth_2 : r.id],
4912
+ [for r in aws_api_gateway_resource.path_depth_3 : r.id],
4913
+ [for r in aws_api_gateway_resource.path_depth_4 : r.id],
4914
+ [for r in aws_api_gateway_resource.path_depth_5 : r.id],
4915
+ [for r in aws_api_gateway_resource.path_depth_6 : r.id],
4916
+ [for r in aws_api_gateway_resource.path_depth_7 : r.id],
4917
+ [for r in aws_api_gateway_resource.path_depth_8 : r.id],
4918
+ [for r in aws_api_gateway_resource.path_depth_9 : r.id],
4919
+ [for r in aws_api_gateway_resource.path_depth_10 : r.id],
4920
+ [for r in aws_api_gateway_resource.path_depth_11 : r.id],
4921
+ [for r in aws_api_gateway_resource.path_depth_12 : r.id],
4922
+ [for r in aws_api_gateway_resource.path_depth_13 : r.id],
4923
+ [for r in aws_api_gateway_resource.path_depth_14 : r.id],
4924
+ [for r in aws_api_gateway_resource.path_depth_15 : r.id],
4925
+ [for m in aws_api_gateway_method.operation_methods : m.id],
4926
+ [for i in aws_api_gateway_integration.lambda_integration : i.id],
4927
+ [for m in aws_api_gateway_method.options_method : m.id],
4928
+ [for i in aws_api_gateway_integration.options_integration : i.id],
4929
+ )))
4170
4930
  }
4171
4931
 
4172
4932
  lifecycle {
@@ -4174,7 +4934,7 @@ resource "aws_api_gateway_deployment" "api_deployment" {
4174
4934
  }
4175
4935
 
4176
4936
  depends_on = [
4177
- aws_api_gateway_method.proxy_method,
4937
+ aws_api_gateway_method.operation_methods,
4178
4938
  aws_api_gateway_integration.lambda_integration,
4179
4939
  aws_api_gateway_method.options_method,
4180
4940
  aws_api_gateway_integration.options_integration,
@@ -4313,24 +5073,28 @@ resource "aws_api_gateway_rest_api_policy" "api_policy" {
4313
5073
 
4314
5074
  # Lambda permission for API Gateway to invoke the function via alias
4315
5075
  resource "aws_lambda_permission" "api_gateway_invoke" {
5076
+ for_each = local.operations
5077
+
4316
5078
  statement_id = "AllowExecutionFromAPIGateway"
4317
5079
  action = "lambda:InvokeFunction"
4318
- function_name = aws_lambda_function.api_lambda.function_name
4319
- qualifier = aws_lambda_alias.live.name
5080
+ function_name = aws_lambda_function.api_lambda[each.key].function_name
5081
+ qualifier = aws_lambda_alias.live[each.key].name
4320
5082
  principal = "apigateway.amazonaws.com"
4321
- source_arn = "\${module.rest_api.api_execution_arn}/*/*"
5083
+ source_arn = "\${module.rest_api.api_execution_arn}/*/\${local.permission_source_suffix[each.key]}"
4322
5084
 
4323
5085
  depends_on = [module.rest_api, aws_lambda_alias.live]
4324
5086
  }
4325
5087
 
4326
5088
  # Lambda permission for API Gateway to invoke with response streaming via alias
4327
5089
  resource "aws_lambda_permission" "api_gateway_invoke_streaming" {
5090
+ for_each = local.operations
5091
+
4328
5092
  statement_id = "AllowStreamingFromAPIGateway"
4329
5093
  action = "lambda:InvokeFunctionWithResponseStream"
4330
- function_name = aws_lambda_function.api_lambda.function_name
4331
- qualifier = aws_lambda_alias.live.name
5094
+ function_name = aws_lambda_function.api_lambda[each.key].function_name
5095
+ qualifier = aws_lambda_alias.live[each.key].name
4332
5096
  principal = "apigateway.amazonaws.com"
4333
- source_arn = "\${module.rest_api.api_execution_arn}/*/*"
5097
+ source_arn = "\${module.rest_api.api_execution_arn}/*/\${local.permission_source_suffix[each.key]}"
4334
5098
 
4335
5099
  depends_on = [module.rest_api, aws_lambda_alias.live]
4336
5100
  }
@@ -4394,83 +5158,83 @@ output "stage_id" {
4394
5158
  value = aws_api_gateway_stage.api_stage.id
4395
5159
  }
4396
5160
 
4397
- # Lambda Function Outputs
4398
- output "lambda_function_name" {
4399
- description = "Name of the Lambda function"
4400
- value = aws_lambda_function.api_lambda.function_name
5161
+ # Lambda Function Outputs, keyed by operation name
5162
+ output "operations" {
5163
+ description = "Names of the API operations, each with its own Lambda function"
5164
+ value = keys(local.operations)
4401
5165
  }
4402
5166
 
4403
- output "lambda_function_arn" {
4404
- description = "ARN of the Lambda function"
4405
- value = aws_lambda_function.api_lambda.arn
5167
+ output "lambda_function_names" {
5168
+ description = "Name of each operation's Lambda function, keyed by operation name"
5169
+ value = { for op, fn in aws_lambda_function.api_lambda : op => fn.function_name }
4406
5170
  }
4407
5171
 
4408
- output "lambda_invoke_arn" {
4409
- description = "Invoke ARN of the Lambda function"
4410
- value = aws_lambda_function.api_lambda.invoke_arn
5172
+ output "lambda_function_arns" {
5173
+ description = "ARN of each operation's Lambda function, keyed by operation name"
5174
+ value = { for op, fn in aws_lambda_function.api_lambda : op => fn.arn }
4411
5175
  }
4412
5176
 
4413
- output "lambda_qualified_arn" {
4414
- description = "Qualified ARN of the Lambda function"
4415
- value = aws_lambda_function.api_lambda.qualified_arn
5177
+ output "lambda_invoke_arns" {
5178
+ description = "Invoke ARN of each operation's Lambda function, keyed by operation name"
5179
+ value = { for op, fn in aws_lambda_function.api_lambda : op => fn.invoke_arn }
4416
5180
  }
4417
5181
 
4418
- output "lambda_version" {
4419
- description = "Version of the Lambda function"
4420
- value = aws_lambda_function.api_lambda.version
5182
+ output "lambda_qualified_arns" {
5183
+ description = "Qualified ARN of each operation's Lambda function, keyed by operation name"
5184
+ value = { for op, fn in aws_lambda_function.api_lambda : op => fn.qualified_arn }
4421
5185
  }
4422
5186
 
4423
- output "lambda_source_code_hash" {
4424
- description = "Base64-encoded SHA256 hash of the Lambda deployment package"
4425
- value = aws_lambda_function.api_lambda.source_code_hash
5187
+ output "lambda_versions" {
5188
+ description = "Version of each operation's Lambda function, keyed by operation name"
5189
+ value = { for op, fn in aws_lambda_function.api_lambda : op => fn.version }
4426
5190
  }
4427
5191
 
4428
- output "lambda_source_code_size" {
4429
- description = "Size of the Lambda deployment package in bytes"
4430
- value = aws_lambda_function.api_lambda.source_code_size
5192
+ output "lambda_source_code_hash" {
5193
+ description = "Base64-encoded SHA256 hash of the Lambda deployment package, shared by every operation"
5194
+ value = data.archive_file.lambda_zip.output_base64sha256
4431
5195
  }
4432
5196
 
4433
- # IAM Role Outputs
4434
- output "lambda_execution_role_arn" {
4435
- description = "ARN of the Lambda execution role"
4436
- value = aws_iam_role.lambda_execution_role.arn
5197
+ # IAM Role Outputs, keyed by operation name
5198
+ output "lambda_execution_role_arns" {
5199
+ description = "ARN of each operation's Lambda execution role, keyed by operation name"
5200
+ value = { for op, role in aws_iam_role.lambda_execution_role : op => role.arn }
4437
5201
  }
4438
5202
 
4439
- output "lambda_execution_role_name" {
4440
- description = "Name of the Lambda execution role"
4441
- value = aws_iam_role.lambda_execution_role.name
5203
+ output "lambda_execution_role_names" {
5204
+ description = "Name of each operation's Lambda execution role, keyed by operation name. Attach additional policies to a specific operation's role by name."
5205
+ value = { for op, role in aws_iam_role.lambda_execution_role : op => role.name }
4442
5206
  }
4443
5207
 
4444
5208
  output "security_group_id" {
4445
- description = "Security group ID of the API Lambda function, for use in ingress rules on resources it must reach (e.g. a database). Null unless enable_vpc is true."
5209
+ description = "Security group ID shared by the API Lambda functions, for use in ingress rules on resources they must reach (e.g. a database). Null unless enable_vpc is true."
4446
5210
  value = var.enable_vpc ? aws_security_group.api_lambda[0].id : null
4447
5211
  }
4448
5212
 
4449
- # Integration Outputs
4450
- output "integration_id" {
4451
- description = "ID of the Lambda integration"
4452
- value = aws_api_gateway_integration.lambda_integration.id
5213
+ # Integration Outputs, keyed by operation name
5214
+ output "integration_ids" {
5215
+ description = "ID of each operation's Lambda integration, keyed by operation name"
5216
+ value = { for op, i in aws_api_gateway_integration.lambda_integration : op => i.id }
4453
5217
  }
4454
5218
 
4455
- output "proxy_resource_id" {
4456
- description = "ID of the proxy resource"
4457
- value = aws_api_gateway_resource.proxy_resource.id
5219
+ output "method_ids" {
5220
+ description = "ID of each operation's API Gateway method, keyed by operation name"
5221
+ value = { for op, m in aws_api_gateway_method.operation_methods : op => m.id }
4458
5222
  }
4459
5223
 
4460
- output "proxy_method_id" {
4461
- description = "ID of the proxy method"
4462
- value = aws_api_gateway_method.proxy_method.id
5224
+ output "resource_ids" {
5225
+ description = "ID of each API Gateway resource, keyed by its path"
5226
+ value = local.resource_ids
4463
5227
  }
4464
5228
 
4465
- # CloudWatch Log Groups
4466
- output "lambda_log_group_name" {
4467
- description = "Name of the Lambda CloudWatch log group"
4468
- value = aws_cloudwatch_log_group.lambda_logs.name
5229
+ # CloudWatch Log Groups, keyed by operation name
5230
+ output "lambda_log_group_names" {
5231
+ description = "Name of each operation's Lambda CloudWatch log group, keyed by operation name"
5232
+ value = { for op, lg in aws_cloudwatch_log_group.lambda_logs : op => lg.name }
4469
5233
  }
4470
5234
 
4471
- output "lambda_log_group_arn" {
4472
- description = "ARN of the Lambda CloudWatch log group"
4473
- value = aws_cloudwatch_log_group.lambda_logs.arn
5235
+ output "lambda_log_group_arns" {
5236
+ description = "ARN of each operation's Lambda CloudWatch log group, keyed by operation name"
5237
+ value = { for op, lg in aws_cloudwatch_log_group.lambda_logs : op => lg.arn }
4474
5238
  }
4475
5239
  ",
4476
5240
  }
@@ -4911,6 +5675,235 @@ resource "random_string" "suffix" {
4911
5675
  upper = false
4912
5676
  }
4913
5677
 
5678
+ locals {
5679
+ # Generated at build time from the API definition
5680
+ operations_file = "\${path.module}/../../../generated/test-api/operations.json"
5681
+ operations = fileexists(local.operations_file) ? jsondecode(file(local.operations_file)) : {}
5682
+
5683
+ operation_slug = { for op in keys(local.operations) : op => replace(op, "/[^a-zA-Z0-9-_]/", "-") }
5684
+ operation_hash = { for op in keys(local.operations) : op => substr(sha256(op), 0, 8) }
5685
+
5686
+ function_name = { for op in keys(local.operations) : op =>
5687
+ "\${substr("TestApi-\${local.operation_slug[op]}", 0, 64 - length(local.operation_hash[op]) - length(random_string.suffix.result) - 2)}-\${local.operation_hash[op]}-\${random_string.suffix.result}"
5688
+ }
5689
+ role_name = { for op in keys(local.operations) : op =>
5690
+ "\${substr("TestApi-\${local.operation_slug[op]}-role", 0, 64 - length(local.operation_hash[op]) - length(random_string.suffix.result) - 2)}-\${local.operation_hash[op]}-\${random_string.suffix.result}"
5691
+ }
5692
+
5693
+ # A REST API needs a resource per path segment, so each operation's path is
5694
+ # split into the distinct path prefixes the resource tree is built from below
5695
+ operation_segments = { for op, details in local.operations : op => compact(split("/", details.path)) }
5696
+
5697
+ path_nodes = merge([
5698
+ for op, segments in local.operation_segments : {
5699
+ for i, segment in segments : join("/", slice(segments, 0, i + 1)) => {
5700
+ depth = i
5701
+ parent = i == 0 ? "" : join("/", slice(segments, 0, i))
5702
+ part = segment
5703
+ }
5704
+ }
5705
+ ]...)
5706
+
5707
+ # Instances of a resource cannot reference one another, so each depth is a
5708
+ # separate resource. Deeper paths are reported by the precondition below.
5709
+ max_path_depth = 16
5710
+ path_nodes_by_depth = { for depth in range(local.max_path_depth) : depth => {
5711
+ for path, node in local.path_nodes : path => node if node.depth == depth
5712
+ } }
5713
+ paths_too_deep = [for path, node in local.path_nodes : path if node.depth >= local.max_path_depth]
5714
+
5715
+ resource_ids = merge(
5716
+ { for path, resource in aws_api_gateway_resource.path_depth_0 : path => resource.id },
5717
+ { for path, resource in aws_api_gateway_resource.path_depth_1 : path => resource.id },
5718
+ { for path, resource in aws_api_gateway_resource.path_depth_2 : path => resource.id },
5719
+ { for path, resource in aws_api_gateway_resource.path_depth_3 : path => resource.id },
5720
+ { for path, resource in aws_api_gateway_resource.path_depth_4 : path => resource.id },
5721
+ { for path, resource in aws_api_gateway_resource.path_depth_5 : path => resource.id },
5722
+ { for path, resource in aws_api_gateway_resource.path_depth_6 : path => resource.id },
5723
+ { for path, resource in aws_api_gateway_resource.path_depth_7 : path => resource.id },
5724
+ { for path, resource in aws_api_gateway_resource.path_depth_8 : path => resource.id },
5725
+ { for path, resource in aws_api_gateway_resource.path_depth_9 : path => resource.id },
5726
+ { for path, resource in aws_api_gateway_resource.path_depth_10 : path => resource.id },
5727
+ { for path, resource in aws_api_gateway_resource.path_depth_11 : path => resource.id },
5728
+ { for path, resource in aws_api_gateway_resource.path_depth_12 : path => resource.id },
5729
+ { for path, resource in aws_api_gateway_resource.path_depth_13 : path => resource.id },
5730
+ { for path, resource in aws_api_gateway_resource.path_depth_14 : path => resource.id },
5731
+ { for path, resource in aws_api_gateway_resource.path_depth_15 : path => resource.id },
5732
+ )
5733
+
5734
+ operation_resource_id = { for op, segments in local.operation_segments : op =>
5735
+ local.resource_ids[join("/", segments)]
5736
+ }
5737
+
5738
+ permission_source_suffix = { for op, details in local.operations : op =>
5739
+ "\${upper(details.method)}/\${replace(join("/", local.operation_segments[op]), "/{[^}]*}/", "*")}"
5740
+ }
5741
+ }
5742
+
5743
+ resource "terraform_data" "operations_metadata" {
5744
+ # Fails the plan when the operations metadata has not been generated
5745
+ input = local.operations_file
5746
+
5747
+ lifecycle {
5748
+ precondition {
5749
+ condition = fileexists(local.operations_file)
5750
+ error_message = "Operations metadata not found at \${local.operations_file}. Build the TestApi project to generate it."
5751
+ }
5752
+ precondition {
5753
+ condition = length(local.operations) > 0
5754
+ error_message = "No operations found in \${local.operations_file}. The TestApi API must define at least one operation."
5755
+ }
5756
+ precondition {
5757
+ condition = length(local.paths_too_deep) == 0
5758
+ error_message = "Operation paths exceed the supported depth of \${local.max_path_depth} segments: \${join(", ", local.paths_too_deep)}. Add further aws_api_gateway_resource levels to this module to support them."
5759
+ }
5760
+ }
5761
+ }
5762
+
5763
+ # API Gateway resources at path depth 0
5764
+ resource "aws_api_gateway_resource" "path_depth_0" {
5765
+ for_each = local.path_nodes_by_depth[0]
5766
+
5767
+ rest_api_id = module.rest_api.api_id
5768
+ parent_id = module.rest_api.api_root_resource_id
5769
+ path_part = each.value.part
5770
+ }
5771
+
5772
+ # API Gateway resources at path depth 1
5773
+ resource "aws_api_gateway_resource" "path_depth_1" {
5774
+ for_each = local.path_nodes_by_depth[1]
5775
+
5776
+ rest_api_id = module.rest_api.api_id
5777
+ parent_id = aws_api_gateway_resource.path_depth_0[each.value.parent].id
5778
+ path_part = each.value.part
5779
+ }
5780
+
5781
+ # API Gateway resources at path depth 2
5782
+ resource "aws_api_gateway_resource" "path_depth_2" {
5783
+ for_each = local.path_nodes_by_depth[2]
5784
+
5785
+ rest_api_id = module.rest_api.api_id
5786
+ parent_id = aws_api_gateway_resource.path_depth_1[each.value.parent].id
5787
+ path_part = each.value.part
5788
+ }
5789
+
5790
+ # API Gateway resources at path depth 3
5791
+ resource "aws_api_gateway_resource" "path_depth_3" {
5792
+ for_each = local.path_nodes_by_depth[3]
5793
+
5794
+ rest_api_id = module.rest_api.api_id
5795
+ parent_id = aws_api_gateway_resource.path_depth_2[each.value.parent].id
5796
+ path_part = each.value.part
5797
+ }
5798
+
5799
+ # API Gateway resources at path depth 4
5800
+ resource "aws_api_gateway_resource" "path_depth_4" {
5801
+ for_each = local.path_nodes_by_depth[4]
5802
+
5803
+ rest_api_id = module.rest_api.api_id
5804
+ parent_id = aws_api_gateway_resource.path_depth_3[each.value.parent].id
5805
+ path_part = each.value.part
5806
+ }
5807
+
5808
+ # API Gateway resources at path depth 5
5809
+ resource "aws_api_gateway_resource" "path_depth_5" {
5810
+ for_each = local.path_nodes_by_depth[5]
5811
+
5812
+ rest_api_id = module.rest_api.api_id
5813
+ parent_id = aws_api_gateway_resource.path_depth_4[each.value.parent].id
5814
+ path_part = each.value.part
5815
+ }
5816
+
5817
+ # API Gateway resources at path depth 6
5818
+ resource "aws_api_gateway_resource" "path_depth_6" {
5819
+ for_each = local.path_nodes_by_depth[6]
5820
+
5821
+ rest_api_id = module.rest_api.api_id
5822
+ parent_id = aws_api_gateway_resource.path_depth_5[each.value.parent].id
5823
+ path_part = each.value.part
5824
+ }
5825
+
5826
+ # API Gateway resources at path depth 7
5827
+ resource "aws_api_gateway_resource" "path_depth_7" {
5828
+ for_each = local.path_nodes_by_depth[7]
5829
+
5830
+ rest_api_id = module.rest_api.api_id
5831
+ parent_id = aws_api_gateway_resource.path_depth_6[each.value.parent].id
5832
+ path_part = each.value.part
5833
+ }
5834
+
5835
+ # API Gateway resources at path depth 8
5836
+ resource "aws_api_gateway_resource" "path_depth_8" {
5837
+ for_each = local.path_nodes_by_depth[8]
5838
+
5839
+ rest_api_id = module.rest_api.api_id
5840
+ parent_id = aws_api_gateway_resource.path_depth_7[each.value.parent].id
5841
+ path_part = each.value.part
5842
+ }
5843
+
5844
+ # API Gateway resources at path depth 9
5845
+ resource "aws_api_gateway_resource" "path_depth_9" {
5846
+ for_each = local.path_nodes_by_depth[9]
5847
+
5848
+ rest_api_id = module.rest_api.api_id
5849
+ parent_id = aws_api_gateway_resource.path_depth_8[each.value.parent].id
5850
+ path_part = each.value.part
5851
+ }
5852
+
5853
+ # API Gateway resources at path depth 10
5854
+ resource "aws_api_gateway_resource" "path_depth_10" {
5855
+ for_each = local.path_nodes_by_depth[10]
5856
+
5857
+ rest_api_id = module.rest_api.api_id
5858
+ parent_id = aws_api_gateway_resource.path_depth_9[each.value.parent].id
5859
+ path_part = each.value.part
5860
+ }
5861
+
5862
+ # API Gateway resources at path depth 11
5863
+ resource "aws_api_gateway_resource" "path_depth_11" {
5864
+ for_each = local.path_nodes_by_depth[11]
5865
+
5866
+ rest_api_id = module.rest_api.api_id
5867
+ parent_id = aws_api_gateway_resource.path_depth_10[each.value.parent].id
5868
+ path_part = each.value.part
5869
+ }
5870
+
5871
+ # API Gateway resources at path depth 12
5872
+ resource "aws_api_gateway_resource" "path_depth_12" {
5873
+ for_each = local.path_nodes_by_depth[12]
5874
+
5875
+ rest_api_id = module.rest_api.api_id
5876
+ parent_id = aws_api_gateway_resource.path_depth_11[each.value.parent].id
5877
+ path_part = each.value.part
5878
+ }
5879
+
5880
+ # API Gateway resources at path depth 13
5881
+ resource "aws_api_gateway_resource" "path_depth_13" {
5882
+ for_each = local.path_nodes_by_depth[13]
5883
+
5884
+ rest_api_id = module.rest_api.api_id
5885
+ parent_id = aws_api_gateway_resource.path_depth_12[each.value.parent].id
5886
+ path_part = each.value.part
5887
+ }
5888
+
5889
+ # API Gateway resources at path depth 14
5890
+ resource "aws_api_gateway_resource" "path_depth_14" {
5891
+ for_each = local.path_nodes_by_depth[14]
5892
+
5893
+ rest_api_id = module.rest_api.api_id
5894
+ parent_id = aws_api_gateway_resource.path_depth_13[each.value.parent].id
5895
+ path_part = each.value.part
5896
+ }
5897
+
5898
+ # API Gateway resources at path depth 15
5899
+ resource "aws_api_gateway_resource" "path_depth_15" {
5900
+ for_each = local.path_nodes_by_depth[15]
5901
+
5902
+ rest_api_id = module.rest_api.api_id
5903
+ parent_id = aws_api_gateway_resource.path_depth_14[each.value.parent].id
5904
+ path_part = each.value.part
5905
+ }
5906
+
4914
5907
  # Resources
4915
5908
 
4916
5909
  # Create Lambda ZIP file from the bundle directory
@@ -4985,8 +5978,10 @@ resource "aws_vpc_security_group_egress_rule" "api_lambda_https" {
4985
5978
  description = "Allow outbound HTTPS to AWS service endpoints"
4986
5979
  }
4987
5980
 
4988
- # Lambda function
4989
5981
  resource "aws_lambda_function" "api_lambda" {
5982
+ # One lambda function per operation, each serving just that operation
5983
+ for_each = local.operations
5984
+
4990
5985
  #checkov:skip=CKV_AWS_117:Lambda function is optionally deployed into a VPC via vpc_id/subnet_ids; not required for this use case
4991
5986
  #checkov:skip=CKV_AWS_116:Dead Letter Queue not required for this simple API use case
4992
5987
  #checkov:skip=CKV_AWS_272:Code signing not required for this use case
@@ -4995,8 +5990,8 @@ resource "aws_lambda_function" "api_lambda" {
4995
5990
  s3_bucket = aws_s3_object.lambda_zip.bucket
4996
5991
  s3_key = aws_s3_object.lambda_zip.key
4997
5992
  s3_object_version = aws_s3_object.lambda_zip.version_id
4998
- function_name = "TestApiHandler-\${random_string.suffix.result}"
4999
- role = aws_iam_role.lambda_execution_role.arn
5993
+ function_name = local.function_name[each.key]
5994
+ role = aws_iam_role.lambda_execution_role[each.key].arn
5000
5995
  handler = "run.sh"
5001
5996
  runtime = "python3.14"
5002
5997
  layers = ["arn:aws:lambda:\${data.aws_region.current.region}:753240598075:layer:LambdaAdapterLayerX86:24"]
@@ -5041,7 +6036,9 @@ resource "aws_lambda_function" "api_lambda" {
5041
6036
 
5042
6037
  # IAM role for Lambda execution
5043
6038
  resource "aws_iam_role" "lambda_execution_role" {
5044
- name = "TestApiHandler-execution-role-\${random_string.suffix.result}"
6039
+ for_each = local.operations
6040
+
6041
+ name = local.role_name[each.key]
5045
6042
 
5046
6043
  assume_role_policy = jsonencode({
5047
6044
  Version = "2012-10-17"
@@ -5061,32 +6058,38 @@ resource "aws_iam_role" "lambda_execution_role" {
5061
6058
 
5062
6059
  # Attach basic execution policy to Lambda role
5063
6060
  resource "aws_iam_role_policy_attachment" "lambda_basic_execution" {
6061
+ for_each = local.operations
6062
+
5064
6063
  policy_arn = "arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole"
5065
- role = aws_iam_role.lambda_execution_role.name
6064
+ role = aws_iam_role.lambda_execution_role[each.key].name
5066
6065
  }
5067
6066
 
5068
6067
  # Attach X-Ray tracing policy to Lambda role
5069
6068
  resource "aws_iam_role_policy_attachment" "lambda_xray_execution" {
6069
+ for_each = local.operations
6070
+
5070
6071
  policy_arn = "arn:aws:iam::aws:policy/AWSXRayDaemonWriteAccess"
5071
- role = aws_iam_role.lambda_execution_role.name
6072
+ role = aws_iam_role.lambda_execution_role[each.key].name
5072
6073
  }
5073
6074
 
5074
6075
  # Attach VPC access policy to Lambda role when deployed into a VPC
5075
6076
  resource "aws_iam_role_policy_attachment" "lambda_vpc_access" {
5076
- count = var.enable_vpc ? 1 : 0
6077
+ for_each = var.enable_vpc ? local.operations : {}
6078
+
5077
6079
  policy_arn = "arn:aws:iam::aws:policy/service-role/AWSLambdaVPCAccessExecutionRole"
5078
- role = aws_iam_role.lambda_execution_role.name
6080
+ role = aws_iam_role.lambda_execution_role[each.key].name
5079
6081
  }
5080
6082
 
5081
6083
  # Additional IAM policies for Lambda (if provided)
5082
6084
  resource "aws_iam_role_policy" "lambda_additional_policies" {
5083
- count = length(local.lambda_policy_statements) > 0 ? 1 : 0
5084
- name = "TestApiHandler-additional-policies-\${random_string.suffix.result}"
5085
- role = aws_iam_role.lambda_execution_role.id
6085
+ for_each = local.operations
6086
+
6087
+ name = "\${substr(local.function_name[each.key], 0, 55)}-policies"
6088
+ role = aws_iam_role.lambda_execution_role[each.key].id
5086
6089
 
5087
6090
  policy = jsonencode({
5088
6091
  Version = "2012-10-17"
5089
- Statement = local.lambda_policy_statements
6092
+ Statement = local.lambda_policy_document_statements
5090
6093
  })
5091
6094
  }
5092
6095
 
@@ -5102,6 +6105,15 @@ locals {
5102
6105
  Resource = ["\${var.appconfig_application_arn}/*"]
5103
6106
  }
5104
6107
  ] : [], var.additional_iam_policy_statements)
6108
+
6109
+ # IAM rejects an empty statement list, so fall back to a no-op deny
6110
+ lambda_policy_document_statements = length(local.lambda_policy_statements) > 0 ? local.lambda_policy_statements : [
6111
+ {
6112
+ Effect = "Deny"
6113
+ Action = ["appconfig:GetLatestConfiguration"]
6114
+ Resource = ["arn:aws:appconfig:\${data.aws_region.current.region}:\${data.aws_caller_identity.current.account_id}:application/none"]
6115
+ }
6116
+ ]
5105
6117
  }
5106
6118
 
5107
6119
  # CloudWatch Log Group for Lambda
@@ -5109,15 +6121,19 @@ resource "aws_cloudwatch_log_group" "lambda_logs" {
5109
6121
  #checkov:skip=CKV_AWS_158:Using default CloudWatch log encryption
5110
6122
  #checkov:skip=CKV_AWS_338:Log retention set to forever
5111
6123
  #checkov:skip=CKV_AWS_66:Log retention set to forever
5112
- name = "/aws/lambda/TestApiHandler-\${random_string.suffix.result}"
5113
- tags = var.tags
6124
+ for_each = local.operations
6125
+
6126
+ name = "/aws/lambda/\${local.function_name[each.key]}"
6127
+ tags = var.tags
5114
6128
  }
5115
6129
 
5116
6130
  # Lambda alias pointing to the latest published version for SnapStart
5117
6131
  resource "aws_lambda_alias" "live" {
6132
+ for_each = local.operations
6133
+
5118
6134
  name = "live"
5119
- function_name = aws_lambda_function.api_lambda.function_name
5120
- function_version = aws_lambda_function.api_lambda.version
6135
+ function_name = aws_lambda_function.api_lambda[each.key].function_name
6136
+ function_version = aws_lambda_function.api_lambda[each.key].version
5121
6137
 
5122
6138
  depends_on = [aws_lambda_function.api_lambda]
5123
6139
  }
@@ -5215,60 +6231,64 @@ resource "aws_lambda_permission" "authorizer_invoke" {
5215
6231
  source_arn = "\${module.rest_api.api_execution_arn}/authorizers/\${aws_api_gateway_authorizer.custom_authorizer.id}"
5216
6232
  }
5217
6233
 
5218
- # Create proxy resource (captures all paths)
5219
- resource "aws_api_gateway_resource" "proxy_resource" {
5220
- rest_api_id = module.rest_api.api_id
5221
- parent_id = module.rest_api.api_root_resource_id
5222
- path_part = "{proxy+}"
6234
+ # Method per operation
6235
+ resource "aws_api_gateway_method" "operation_methods" {
6236
+ #checkov:skip=CKV2_AWS_53:Request validation not required for proxy integration as Lambda handles validation
6237
+ for_each = local.operations
6238
+
6239
+ rest_api_id = module.rest_api.api_id
6240
+ resource_id = local.operation_resource_id[each.key]
6241
+ http_method = upper(each.value.method)
6242
+
6243
+ authorization = "CUSTOM"
6244
+ authorizer_id = aws_api_gateway_authorizer.custom_authorizer.id
6245
+
6246
+ # Path parameters must be declared on the method
6247
+ request_parameters = {
6248
+ for segment in local.operation_segments[each.key] :
6249
+ "method.request.path.\${trimsuffix(trimprefix(segment, "{"), "}")}" => true
6250
+ if startswith(segment, "{")
6251
+ }
6252
+
6253
+ depends_on = [aws_api_gateway_authorizer.custom_authorizer]
5223
6254
  }
5224
6255
 
5225
- # Lambda integration for REST API
6256
+ # Lambda integration per operation
5226
6257
  resource "aws_api_gateway_integration" "lambda_integration" {
6258
+ for_each = local.operations
6259
+
5227
6260
  rest_api_id = module.rest_api.api_id
5228
- resource_id = aws_api_gateway_resource.proxy_resource.id
5229
- http_method = aws_api_gateway_method.proxy_method.http_method
6261
+ resource_id = local.operation_resource_id[each.key]
6262
+ http_method = aws_api_gateway_method.operation_methods[each.key].http_method
5230
6263
 
5231
6264
  integration_http_method = "POST"
5232
6265
  type = "AWS_PROXY"
5233
6266
  # Use the response streaming invocation path with the alias ARN for SnapStart
5234
- uri = "arn:aws:apigateway:\${data.aws_region.current.region}:lambda:path/2021-11-15/functions/\${aws_lambda_alias.live.arn}/response-streaming-invocations"
6267
+ uri = "arn:aws:apigateway:\${data.aws_region.current.region}:lambda:path/2021-11-15/functions/\${aws_lambda_alias.live[each.key].arn}/response-streaming-invocations"
5235
6268
  response_transfer_mode = "STREAM"
5236
6269
 
5237
6270
  depends_on = [aws_lambda_alias.live]
5238
6271
  }
5239
6272
 
5240
- # Method for proxy integration
5241
- resource "aws_api_gateway_method" "proxy_method" {
5242
- #checkov:skip=CKV2_AWS_53:Request validation not required for proxy integration as Lambda handles validation
5243
- rest_api_id = module.rest_api.api_id
5244
- resource_id = aws_api_gateway_resource.proxy_resource.id
5245
- http_method = "ANY"
5246
-
5247
- authorization = "CUSTOM"
5248
- authorizer_id = aws_api_gateway_authorizer.custom_authorizer.id
5249
-
5250
- request_parameters = {
5251
- "method.request.path.proxy" = true
5252
- }
5253
-
5254
- depends_on = [aws_api_gateway_authorizer.custom_authorizer]
5255
- }
5256
-
5257
- # OPTIONS method for CORS preflight
6273
+ # OPTIONS method for CORS preflight, on every resource an operation is served from
5258
6274
  resource "aws_api_gateway_method" "options_method" {
5259
6275
  #checkov:skip=CKV2_AWS_70:OPTIONS method must be unauthenticated for CORS preflight requests
5260
6276
  #checkov:skip=CKV2_AWS_53:Request validation not required for OPTIONS CORS preflight method
6277
+ for_each = local.resource_ids
6278
+
5261
6279
  rest_api_id = module.rest_api.api_id
5262
- resource_id = aws_api_gateway_resource.proxy_resource.id
6280
+ resource_id = each.value
5263
6281
  http_method = "OPTIONS"
5264
6282
  authorization = "NONE"
5265
6283
  }
5266
6284
 
5267
- # CORS integration for OPTIONS method
6285
+ # CORS integration for OPTIONS methods
5268
6286
  resource "aws_api_gateway_integration" "options_integration" {
6287
+ for_each = local.resource_ids
6288
+
5269
6289
  rest_api_id = module.rest_api.api_id
5270
- resource_id = aws_api_gateway_resource.proxy_resource.id
5271
- http_method = aws_api_gateway_method.options_method.http_method
6290
+ resource_id = each.value
6291
+ http_method = aws_api_gateway_method.options_method[each.key].http_method
5272
6292
 
5273
6293
  type = "MOCK"
5274
6294
  request_templates = {
@@ -5276,11 +6296,13 @@ resource "aws_api_gateway_integration" "options_integration" {
5276
6296
  }
5277
6297
  }
5278
6298
 
5279
- # OPTIONS method response
6299
+ # OPTIONS method responses
5280
6300
  resource "aws_api_gateway_method_response" "options_response" {
6301
+ for_each = local.resource_ids
6302
+
5281
6303
  rest_api_id = module.rest_api.api_id
5282
- resource_id = aws_api_gateway_resource.proxy_resource.id
5283
- http_method = aws_api_gateway_method.options_method.http_method
6304
+ resource_id = each.value
6305
+ http_method = aws_api_gateway_method.options_method[each.key].http_method
5284
6306
  status_code = "204"
5285
6307
 
5286
6308
  response_parameters = {
@@ -5290,18 +6312,22 @@ resource "aws_api_gateway_method_response" "options_response" {
5290
6312
  }
5291
6313
  }
5292
6314
 
5293
- # OPTIONS integration response
6315
+ # OPTIONS integration responses
5294
6316
  resource "aws_api_gateway_integration_response" "options_integration_response" {
6317
+ for_each = local.resource_ids
6318
+
5295
6319
  rest_api_id = module.rest_api.api_id
5296
- resource_id = aws_api_gateway_resource.proxy_resource.id
5297
- http_method = aws_api_gateway_method.options_method.http_method
5298
- status_code = aws_api_gateway_method_response.options_response.status_code
6320
+ resource_id = each.value
6321
+ http_method = aws_api_gateway_method.options_method[each.key].http_method
6322
+ status_code = aws_api_gateway_method_response.options_response[each.key].status_code
5299
6323
 
5300
6324
  response_parameters = {
5301
6325
  "method.response.header.Access-Control-Allow-Headers" = "'\${join(",", var.cors_allow_headers)}'"
5302
6326
  "method.response.header.Access-Control-Allow-Methods" = "'\${join(",", var.cors_allow_methods)}'"
5303
6327
  "method.response.header.Access-Control-Allow-Origin" = "'\${join(",", var.cors_allow_origins)}'"
5304
6328
  }
6329
+
6330
+ depends_on = [aws_api_gateway_integration.options_integration]
5305
6331
  }
5306
6332
 
5307
6333
  # API Gateway deployment
@@ -5309,13 +6335,28 @@ resource "aws_api_gateway_deployment" "api_deployment" {
5309
6335
  rest_api_id = module.rest_api.api_id
5310
6336
 
5311
6337
  triggers = {
5312
- redeployment = sha1(jsonencode([
5313
- aws_api_gateway_resource.proxy_resource.id,
5314
- aws_api_gateway_method.proxy_method.id,
5315
- aws_api_gateway_integration.lambda_integration.id,
5316
- aws_api_gateway_method.options_method.id,
5317
- aws_api_gateway_integration.options_integration.id,
5318
- ]))
6338
+ redeployment = sha1(jsonencode(concat(
6339
+ [for r in aws_api_gateway_resource.path_depth_0 : r.id],
6340
+ [for r in aws_api_gateway_resource.path_depth_1 : r.id],
6341
+ [for r in aws_api_gateway_resource.path_depth_2 : r.id],
6342
+ [for r in aws_api_gateway_resource.path_depth_3 : r.id],
6343
+ [for r in aws_api_gateway_resource.path_depth_4 : r.id],
6344
+ [for r in aws_api_gateway_resource.path_depth_5 : r.id],
6345
+ [for r in aws_api_gateway_resource.path_depth_6 : r.id],
6346
+ [for r in aws_api_gateway_resource.path_depth_7 : r.id],
6347
+ [for r in aws_api_gateway_resource.path_depth_8 : r.id],
6348
+ [for r in aws_api_gateway_resource.path_depth_9 : r.id],
6349
+ [for r in aws_api_gateway_resource.path_depth_10 : r.id],
6350
+ [for r in aws_api_gateway_resource.path_depth_11 : r.id],
6351
+ [for r in aws_api_gateway_resource.path_depth_12 : r.id],
6352
+ [for r in aws_api_gateway_resource.path_depth_13 : r.id],
6353
+ [for r in aws_api_gateway_resource.path_depth_14 : r.id],
6354
+ [for r in aws_api_gateway_resource.path_depth_15 : r.id],
6355
+ [for m in aws_api_gateway_method.operation_methods : m.id],
6356
+ [for i in aws_api_gateway_integration.lambda_integration : i.id],
6357
+ [for m in aws_api_gateway_method.options_method : m.id],
6358
+ [for i in aws_api_gateway_integration.options_integration : i.id],
6359
+ )))
5319
6360
  }
5320
6361
 
5321
6362
  lifecycle {
@@ -5323,7 +6364,7 @@ resource "aws_api_gateway_deployment" "api_deployment" {
5323
6364
  }
5324
6365
 
5325
6366
  depends_on = [
5326
- aws_api_gateway_method.proxy_method,
6367
+ aws_api_gateway_method.operation_methods,
5327
6368
  aws_api_gateway_integration.lambda_integration,
5328
6369
  aws_api_gateway_method.options_method,
5329
6370
  aws_api_gateway_integration.options_integration,
@@ -5452,24 +6493,28 @@ resource "aws_api_gateway_rest_api_policy" "api_policy" {
5452
6493
 
5453
6494
  # Lambda permission for API Gateway to invoke the function via alias
5454
6495
  resource "aws_lambda_permission" "api_gateway_invoke" {
6496
+ for_each = local.operations
6497
+
5455
6498
  statement_id = "AllowExecutionFromAPIGateway"
5456
6499
  action = "lambda:InvokeFunction"
5457
- function_name = aws_lambda_function.api_lambda.function_name
5458
- qualifier = aws_lambda_alias.live.name
6500
+ function_name = aws_lambda_function.api_lambda[each.key].function_name
6501
+ qualifier = aws_lambda_alias.live[each.key].name
5459
6502
  principal = "apigateway.amazonaws.com"
5460
- source_arn = "\${module.rest_api.api_execution_arn}/*/*"
6503
+ source_arn = "\${module.rest_api.api_execution_arn}/*/\${local.permission_source_suffix[each.key]}"
5461
6504
 
5462
6505
  depends_on = [module.rest_api, aws_lambda_alias.live]
5463
6506
  }
5464
6507
 
5465
6508
  # Lambda permission for API Gateway to invoke with response streaming via alias
5466
6509
  resource "aws_lambda_permission" "api_gateway_invoke_streaming" {
6510
+ for_each = local.operations
6511
+
5467
6512
  statement_id = "AllowStreamingFromAPIGateway"
5468
6513
  action = "lambda:InvokeFunctionWithResponseStream"
5469
- function_name = aws_lambda_function.api_lambda.function_name
5470
- qualifier = aws_lambda_alias.live.name
6514
+ function_name = aws_lambda_function.api_lambda[each.key].function_name
6515
+ qualifier = aws_lambda_alias.live[each.key].name
5471
6516
  principal = "apigateway.amazonaws.com"
5472
- source_arn = "\${module.rest_api.api_execution_arn}/*/*"
6517
+ source_arn = "\${module.rest_api.api_execution_arn}/*/\${local.permission_source_suffix[each.key]}"
5473
6518
 
5474
6519
  depends_on = [module.rest_api, aws_lambda_alias.live]
5475
6520
  }
@@ -5533,83 +6578,83 @@ output "stage_id" {
5533
6578
  value = aws_api_gateway_stage.api_stage.id
5534
6579
  }
5535
6580
 
5536
- # Lambda Function Outputs
5537
- output "lambda_function_name" {
5538
- description = "Name of the Lambda function"
5539
- value = aws_lambda_function.api_lambda.function_name
6581
+ # Lambda Function Outputs, keyed by operation name
6582
+ output "operations" {
6583
+ description = "Names of the API operations, each with its own Lambda function"
6584
+ value = keys(local.operations)
5540
6585
  }
5541
6586
 
5542
- output "lambda_function_arn" {
5543
- description = "ARN of the Lambda function"
5544
- value = aws_lambda_function.api_lambda.arn
6587
+ output "lambda_function_names" {
6588
+ description = "Name of each operation's Lambda function, keyed by operation name"
6589
+ value = { for op, fn in aws_lambda_function.api_lambda : op => fn.function_name }
5545
6590
  }
5546
6591
 
5547
- output "lambda_invoke_arn" {
5548
- description = "Invoke ARN of the Lambda function"
5549
- value = aws_lambda_function.api_lambda.invoke_arn
6592
+ output "lambda_function_arns" {
6593
+ description = "ARN of each operation's Lambda function, keyed by operation name"
6594
+ value = { for op, fn in aws_lambda_function.api_lambda : op => fn.arn }
5550
6595
  }
5551
6596
 
5552
- output "lambda_qualified_arn" {
5553
- description = "Qualified ARN of the Lambda function"
5554
- value = aws_lambda_function.api_lambda.qualified_arn
6597
+ output "lambda_invoke_arns" {
6598
+ description = "Invoke ARN of each operation's Lambda function, keyed by operation name"
6599
+ value = { for op, fn in aws_lambda_function.api_lambda : op => fn.invoke_arn }
5555
6600
  }
5556
6601
 
5557
- output "lambda_version" {
5558
- description = "Version of the Lambda function"
5559
- value = aws_lambda_function.api_lambda.version
6602
+ output "lambda_qualified_arns" {
6603
+ description = "Qualified ARN of each operation's Lambda function, keyed by operation name"
6604
+ value = { for op, fn in aws_lambda_function.api_lambda : op => fn.qualified_arn }
5560
6605
  }
5561
6606
 
5562
- output "lambda_source_code_hash" {
5563
- description = "Base64-encoded SHA256 hash of the Lambda deployment package"
5564
- value = aws_lambda_function.api_lambda.source_code_hash
6607
+ output "lambda_versions" {
6608
+ description = "Version of each operation's Lambda function, keyed by operation name"
6609
+ value = { for op, fn in aws_lambda_function.api_lambda : op => fn.version }
5565
6610
  }
5566
6611
 
5567
- output "lambda_source_code_size" {
5568
- description = "Size of the Lambda deployment package in bytes"
5569
- value = aws_lambda_function.api_lambda.source_code_size
6612
+ output "lambda_source_code_hash" {
6613
+ description = "Base64-encoded SHA256 hash of the Lambda deployment package, shared by every operation"
6614
+ value = data.archive_file.lambda_zip.output_base64sha256
5570
6615
  }
5571
6616
 
5572
- # IAM Role Outputs
5573
- output "lambda_execution_role_arn" {
5574
- description = "ARN of the Lambda execution role"
5575
- value = aws_iam_role.lambda_execution_role.arn
6617
+ # IAM Role Outputs, keyed by operation name
6618
+ output "lambda_execution_role_arns" {
6619
+ description = "ARN of each operation's Lambda execution role, keyed by operation name"
6620
+ value = { for op, role in aws_iam_role.lambda_execution_role : op => role.arn }
5576
6621
  }
5577
6622
 
5578
- output "lambda_execution_role_name" {
5579
- description = "Name of the Lambda execution role"
5580
- value = aws_iam_role.lambda_execution_role.name
6623
+ output "lambda_execution_role_names" {
6624
+ description = "Name of each operation's Lambda execution role, keyed by operation name. Attach additional policies to a specific operation's role by name."
6625
+ value = { for op, role in aws_iam_role.lambda_execution_role : op => role.name }
5581
6626
  }
5582
6627
 
5583
6628
  output "security_group_id" {
5584
- description = "Security group ID of the API Lambda function, for use in ingress rules on resources it must reach (e.g. a database). Null unless enable_vpc is true."
6629
+ description = "Security group ID shared by the API Lambda functions, for use in ingress rules on resources they must reach (e.g. a database). Null unless enable_vpc is true."
5585
6630
  value = var.enable_vpc ? aws_security_group.api_lambda[0].id : null
5586
6631
  }
5587
6632
 
5588
- # Integration Outputs
5589
- output "integration_id" {
5590
- description = "ID of the Lambda integration"
5591
- value = aws_api_gateway_integration.lambda_integration.id
6633
+ # Integration Outputs, keyed by operation name
6634
+ output "integration_ids" {
6635
+ description = "ID of each operation's Lambda integration, keyed by operation name"
6636
+ value = { for op, i in aws_api_gateway_integration.lambda_integration : op => i.id }
5592
6637
  }
5593
6638
 
5594
- output "proxy_resource_id" {
5595
- description = "ID of the proxy resource"
5596
- value = aws_api_gateway_resource.proxy_resource.id
6639
+ output "method_ids" {
6640
+ description = "ID of each operation's API Gateway method, keyed by operation name"
6641
+ value = { for op, m in aws_api_gateway_method.operation_methods : op => m.id }
5597
6642
  }
5598
6643
 
5599
- output "proxy_method_id" {
5600
- description = "ID of the proxy method"
5601
- value = aws_api_gateway_method.proxy_method.id
6644
+ output "resource_ids" {
6645
+ description = "ID of each API Gateway resource, keyed by its path"
6646
+ value = local.resource_ids
5602
6647
  }
5603
6648
 
5604
- # CloudWatch Log Groups
5605
- output "lambda_log_group_name" {
5606
- description = "Name of the Lambda CloudWatch log group"
5607
- value = aws_cloudwatch_log_group.lambda_logs.name
6649
+ # CloudWatch Log Groups, keyed by operation name
6650
+ output "lambda_log_group_names" {
6651
+ description = "Name of each operation's Lambda CloudWatch log group, keyed by operation name"
6652
+ value = { for op, lg in aws_cloudwatch_log_group.lambda_logs : op => lg.name }
5608
6653
  }
5609
6654
 
5610
- output "lambda_log_group_arn" {
5611
- description = "ARN of the Lambda CloudWatch log group"
5612
- value = aws_cloudwatch_log_group.lambda_logs.arn
6655
+ output "lambda_log_group_arns" {
6656
+ description = "ARN of each operation's Lambda CloudWatch log group, keyed by operation name"
6657
+ value = { for op, lg in aws_cloudwatch_log_group.lambda_logs : op => lg.arn }
5613
6658
  }
5614
6659
  ",
5615
6660
  }