@aws/nx-plugin 1.0.0-rc.83 → 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 (104) hide show
  1. package/generators.json +7 -0
  2. package/migrations.json +31 -1
  3. package/package.json +1 -1
  4. package/src/agentcore-gateway/__snapshots__/generator.spec.ts.snap +51 -2
  5. package/src/agentcore-harness/__snapshots__/generator.spec.ts.snap +4 -5
  6. package/src/agentcore-harness/files/project/README.md.template +4 -5
  7. package/src/internal/test-matrix/generator.js +16 -0
  8. package/src/internal/test-matrix/generator.js.map +1 -1
  9. package/src/migrations/latest/agentcore-harness-no-tools-by-default/__snapshots__/migration.spec.ts.snap +79 -0
  10. package/src/migrations/latest/agentcore-harness-no-tools-by-default/metadata.json +3 -0
  11. package/src/migrations/latest/agentcore-harness-no-tools-by-default/migration.d.ts +6 -0
  12. package/src/migrations/latest/agentcore-harness-no-tools-by-default/migration.js +87 -0
  13. package/src/migrations/latest/agentcore-harness-no-tools-by-default/migration.js.map +1 -0
  14. package/src/migrations/latest/rdb-terraform-aurora-app-module-defaults/__snapshots__/migration.spec.ts.snap +54 -0
  15. package/src/migrations/latest/rdb-terraform-aurora-app-module-defaults/metadata.json +3 -0
  16. package/src/migrations/latest/rdb-terraform-aurora-app-module-defaults/migration.d.ts +6 -0
  17. package/src/migrations/latest/rdb-terraform-aurora-app-module-defaults/migration.js +86 -0
  18. package/src/migrations/latest/rdb-terraform-aurora-app-module-defaults/migration.js.map +1 -0
  19. package/src/migrations/latest/terraform-declare-used-providers/metadata.json +3 -0
  20. package/src/migrations/latest/terraform-declare-used-providers/migration.d.ts +6 -0
  21. package/src/migrations/latest/terraform-declare-used-providers/migration.js +147 -0
  22. package/src/migrations/latest/terraform-declare-used-providers/migration.js.map +1 -0
  23. package/src/migrations/latest/terraform-harness-vpc-role-and-memory-grant/__snapshots__/migration.spec.ts.snap +521 -0
  24. package/src/migrations/latest/terraform-harness-vpc-role-and-memory-grant/metadata.json +3 -0
  25. package/src/migrations/latest/terraform-harness-vpc-role-and-memory-grant/migration.d.ts +6 -0
  26. package/src/migrations/latest/terraform-harness-vpc-role-and-memory-grant/migration.js +504 -0
  27. package/src/migrations/latest/terraform-harness-vpc-role-and-memory-grant/migration.js.map +1 -0
  28. package/src/migrations/latest/terraform-project-checkov-target-and-bootstrap-destroy/metadata.json +3 -0
  29. package/src/migrations/latest/terraform-project-checkov-target-and-bootstrap-destroy/migration.d.ts +6 -0
  30. package/src/migrations/latest/terraform-project-checkov-target-and-bootstrap-destroy/migration.js +146 -0
  31. package/src/migrations/latest/terraform-project-checkov-target-and-bootstrap-destroy/migration.js.map +1 -0
  32. package/src/migrations/latest/waf-log-group-cmk-encryption/__snapshots__/migration.spec.ts.snap +286 -0
  33. package/src/migrations/latest/waf-log-group-cmk-encryption/metadata.json +3 -0
  34. package/src/migrations/latest/waf-log-group-cmk-encryption/migration.d.ts +6 -0
  35. package/src/migrations/latest/waf-log-group-cmk-encryption/migration.js +238 -0
  36. package/src/migrations/latest/waf-log-group-cmk-encryption/migration.js.map +1 -0
  37. package/src/open-api/json-metadata/generator.d.ts +24 -0
  38. package/src/open-api/json-metadata/generator.js +37 -0
  39. package/src/open-api/json-metadata/generator.js.map +1 -0
  40. package/src/open-api/json-metadata/schema.d.js +6 -0
  41. package/src/open-api/json-metadata/schema.d.js.map +1 -0
  42. package/src/open-api/json-metadata/schema.d.ts +9 -0
  43. package/src/open-api/json-metadata/schema.json +20 -0
  44. package/src/py/agent/__snapshots__/generator.protocols.spec.ts.snap +12 -0
  45. package/src/py/fast-api/__snapshots__/generator.terraform.spec.ts.snap +1875 -623
  46. package/src/py/fast-api/generator.js +2 -1
  47. package/src/py/fast-api/generator.js.map +1 -1
  48. package/src/py/lambda-function/__snapshots__/generator.spec.ts.snap +8 -0
  49. package/src/py/mcp-server/__snapshots__/generator.spec.ts.snap +4 -0
  50. package/src/py/rdb/__snapshots__/generator.spec.ts.snap +31 -2
  51. package/src/sdk/open-api.d.ts +2 -0
  52. package/src/sdk/open-api.js +2 -1
  53. package/src/sdk/open-api.js.map +1 -1
  54. package/src/smithy/ts/api/__snapshots__/generator.spec.ts.snap +1756 -457
  55. package/src/smithy/ts/api/generator.js +2 -1
  56. package/src/smithy/ts/api/generator.js.map +1 -1
  57. package/src/terraform/project/files/application/bootstrap/providers.tf.template +11 -0
  58. package/src/terraform/project/files/application/scripts/bootstrap-destroy.ts.template +111 -0
  59. package/src/terraform/project/files/application/src/providers.tf.template +6 -0
  60. package/src/terraform/project/files/checkov/checkov.yml.template +8 -0
  61. package/src/terraform/project/files/library/src/main.tf.template +9 -0
  62. package/src/terraform/project/generator.js +47 -4
  63. package/src/terraform/project/generator.js.map +1 -1
  64. package/src/trpc/backend/__snapshots__/generator.spec.ts.snap +1848 -614
  65. package/src/trpc/backend/files-operations/scripts/generate-operations.ts.template +40 -0
  66. package/src/trpc/backend/generator.js +10 -0
  67. package/src/trpc/backend/generator.js.map +1 -1
  68. package/src/ts/agent/__snapshots__/generator.spec.ts.snap +8 -0
  69. package/src/ts/dcr-proxy/__snapshots__/generator.spec.ts.snap +4 -0
  70. package/src/ts/lambda-function/__snapshots__/generator.spec.ts.snap +8 -0
  71. package/src/ts/mcp-server/__snapshots__/generator.spec.ts.snap +4 -0
  72. package/src/ts/rdb/__snapshots__/generator.spec.ts.snap +62 -4
  73. package/src/ts/react-website/app/__snapshots__/generator.spec.ts.snap +4 -0
  74. package/src/ts/react-website/cognito-auth/__snapshots__/generator.spec.ts.snap +35 -8
  75. package/src/ts/react-website/cognito-auth/__snapshots__/generator.terraform.spec.ts.snap +4 -0
  76. package/src/utils/agent-core-constructs/files/cdk/app/agentcore-harness/__nameKebabCase__/__nameKebabCase__.ts.template +39 -1
  77. package/src/utils/agent-core-constructs/files/terraform/app/agent-core/__nameKebabCase__/__nameKebabCase__.tf.template +8 -1
  78. package/src/utils/agent-core-constructs/files/terraform/app/agentcore-gateway/__nameKebabCase__/__nameKebabCase__.tf.template +57 -2
  79. package/src/utils/agent-core-constructs/files/terraform/app/agentcore-harness/__nameKebabCase__/__nameKebabCase__.tf.template +258 -24
  80. package/src/utils/agent-core-constructs/files/terraform/core/agent-core/runtime.tf.template +4 -0
  81. package/src/utils/api-constructs/api-constructs.js +11 -0
  82. package/src/utils/api-constructs/api-constructs.js.map +1 -1
  83. package/src/utils/api-constructs/files/terraform/app/apis/http/__apiNameKebabCase__/__apiNameKebabCase__.tf.template +251 -3
  84. package/src/utils/api-constructs/files/terraform/app/apis/rest/__apiNameKebabCase__/__apiNameKebabCase__.tf.template +437 -2
  85. package/src/utils/api-constructs/files/terraform/core/api/rest/rest-api/rest-api.tf.template +60 -7
  86. package/src/utils/api-constructs/open-api-metadata.d.ts +20 -3
  87. package/src/utils/api-constructs/open-api-metadata.js +64 -8
  88. package/src/utils/api-constructs/open-api-metadata.js.map +1 -1
  89. package/src/utils/api-constructs/trpc-operations-metadata.d.ts +21 -0
  90. package/src/utils/api-constructs/trpc-operations-metadata.js +55 -0
  91. package/src/utils/api-constructs/trpc-operations-metadata.js.map +1 -0
  92. package/src/utils/dcr-proxy-constructs/files/terraform/app/dcr-proxies/__nameKebabCase__/__nameKebabCase__.tf.template +4 -0
  93. package/src/utils/files/terraform/src/metrics/providers.tf.template +12 -0
  94. package/src/utils/function-constructs/files/terraform/app/lambda-functions/__nameKebabCase__/__nameKebabCase__.tf.template +4 -0
  95. package/src/utils/identity-constructs/files/cdk/core/user-identity.ts.template +33 -8
  96. package/src/utils/identity-constructs/files/terraform/core/user-identity/add-callback-url/add-callback-url.tf.template +4 -0
  97. package/src/utils/identity-constructs/files/terraform/core/user-identity/identity/identity.tf.template +51 -2
  98. package/src/utils/rdb-constructs/files/cdk/core/rdb/aurora.ts.template +18 -0
  99. package/src/utils/rdb-constructs/files/terraform/app/dbs/__nameKebabCase__/__nameKebabCase__.tf.template +15 -4
  100. package/src/utils/rdb-constructs/files/terraform/core/rdb/aurora/aurora.tf.template +2 -2
  101. package/src/utils/rdb-constructs/rdb-constructs.d.ts +12 -0
  102. package/src/utils/rdb-constructs/rdb-constructs.js +24 -3
  103. package/src/utils/rdb-constructs/rdb-constructs.js.map +1 -1
  104. package/src/utils/website-constructs/files/terraform/core/static-website/static-website.tf.template +4 -0
@@ -3420,10 +3420,18 @@ output "api_log_group_arn" {
3420
3420
  required_version = ">= 1.0"
3421
3421
 
3422
3422
  required_providers {
3423
+ archive = {
3424
+ source = "hashicorp/archive"
3425
+ version = "2.8.0"
3426
+ }
3423
3427
  aws = {
3424
3428
  source = "hashicorp/aws"
3425
3429
  version = "6.61.0"
3426
3430
  }
3431
+ random = {
3432
+ source = "hashicorp/random"
3433
+ version = "3.9.0"
3434
+ }
3427
3435
  }
3428
3436
  }
3429
3437
 
@@ -3543,6 +3551,49 @@ resource "random_string" "suffix" {
3543
3551
  upper = false
3544
3552
  }
3545
3553
 
3554
+ locals {
3555
+ # Generated at build time from the API definition
3556
+ operations_file = "\${path.module}/../../../generated/test-api/operations.json"
3557
+ operations = fileexists(local.operations_file) ? jsondecode(file(local.operations_file)) : {}
3558
+
3559
+ operation_slug = { for op in keys(local.operations) : op => replace(op, "/[^a-zA-Z0-9-_]/", "-") }
3560
+ operation_hash = { for op in keys(local.operations) : op => substr(sha256(op), 0, 8) }
3561
+
3562
+ function_name = { for op in keys(local.operations) : op =>
3563
+ "\${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}"
3564
+ }
3565
+ role_name = { for op in keys(local.operations) : op =>
3566
+ "\${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}"
3567
+ }
3568
+
3569
+ operation_path = { for op, details in local.operations : op =>
3570
+ startswith(details.path, "/") ? details.path : "/\${details.path}"
3571
+ }
3572
+ route_key = { for op, details in local.operations : op =>
3573
+ "\${upper(details.method)} \${local.operation_path[op]}"
3574
+ }
3575
+
3576
+ permission_source_suffix = { for op, details in local.operations : op =>
3577
+ "\${upper(details.method)}\${replace(local.operation_path[op], "/{[^}]*}/", "*")}"
3578
+ }
3579
+ }
3580
+
3581
+ resource "terraform_data" "operations_metadata" {
3582
+ # Fails the plan when the operations metadata has not been generated
3583
+ input = local.operations_file
3584
+
3585
+ lifecycle {
3586
+ precondition {
3587
+ condition = fileexists(local.operations_file)
3588
+ error_message = "Operations metadata not found at \${local.operations_file}. Build the TestApi project to generate it."
3589
+ }
3590
+ precondition {
3591
+ condition = length(local.operations) > 0
3592
+ error_message = "No operations found in \${local.operations_file}. The TestApi API must define at least one operation."
3593
+ }
3594
+ }
3595
+ }
3596
+
3546
3597
  # Resources
3547
3598
 
3548
3599
  # Create Lambda ZIP file from the bundle directory
@@ -3603,9 +3654,10 @@ resource "aws_vpc_security_group_egress_rule" "api_lambda_https" {
3603
3654
  description = "Allow outbound HTTPS to AWS service endpoints"
3604
3655
  }
3605
3656
 
3606
- # Lambda function
3607
- # This configures a single "router" lambda to serve all requests
3608
3657
  resource "aws_lambda_function" "api_lambda" {
3658
+ # One lambda function per operation, each serving just that operation
3659
+ for_each = local.operations
3660
+
3609
3661
  #checkov:skip=CKV_AWS_117:Lambda function is optionally deployed into a VPC via vpc_id/subnet_ids; not required for this use case
3610
3662
  #checkov:skip=CKV_AWS_116:Dead Letter Queue not required for this simple API use case
3611
3663
  #checkov:skip=CKV_AWS_272:Code signing not required for this use case
@@ -3614,8 +3666,8 @@ resource "aws_lambda_function" "api_lambda" {
3614
3666
  s3_bucket = aws_s3_object.lambda_zip.bucket
3615
3667
  s3_key = aws_s3_object.lambda_zip.key
3616
3668
  s3_object_version = aws_s3_object.lambda_zip.version_id
3617
- function_name = "TestApiHandler-\${random_string.suffix.result}"
3618
- role = aws_iam_role.lambda_execution_role.arn
3669
+ function_name = local.function_name[each.key]
3670
+ role = aws_iam_role.lambda_execution_role[each.key].arn
3619
3671
  handler = "index.handler"
3620
3672
  runtime = "nodejs22.x"
3621
3673
  timeout = 30
@@ -3651,7 +3703,9 @@ resource "aws_lambda_function" "api_lambda" {
3651
3703
 
3652
3704
  # IAM role for Lambda execution
3653
3705
  resource "aws_iam_role" "lambda_execution_role" {
3654
- name = "TestApiHandler-execution-role-\${random_string.suffix.result}"
3706
+ for_each = local.operations
3707
+
3708
+ name = local.role_name[each.key]
3655
3709
 
3656
3710
  assume_role_policy = jsonencode({
3657
3711
  Version = "2012-10-17"
@@ -3671,32 +3725,38 @@ resource "aws_iam_role" "lambda_execution_role" {
3671
3725
 
3672
3726
  # Attach basic execution policy to Lambda role
3673
3727
  resource "aws_iam_role_policy_attachment" "lambda_basic_execution" {
3728
+ for_each = local.operations
3729
+
3674
3730
  policy_arn = "arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole"
3675
- role = aws_iam_role.lambda_execution_role.name
3731
+ role = aws_iam_role.lambda_execution_role[each.key].name
3676
3732
  }
3677
3733
 
3678
3734
  # Attach X-Ray tracing policy to Lambda role
3679
3735
  resource "aws_iam_role_policy_attachment" "lambda_xray_execution" {
3736
+ for_each = local.operations
3737
+
3680
3738
  policy_arn = "arn:aws:iam::aws:policy/AWSXRayDaemonWriteAccess"
3681
- role = aws_iam_role.lambda_execution_role.name
3739
+ role = aws_iam_role.lambda_execution_role[each.key].name
3682
3740
  }
3683
3741
 
3684
3742
  # Attach VPC access policy to Lambda role when deployed into a VPC
3685
3743
  resource "aws_iam_role_policy_attachment" "lambda_vpc_access" {
3686
- count = var.enable_vpc ? 1 : 0
3744
+ for_each = var.enable_vpc ? local.operations : {}
3745
+
3687
3746
  policy_arn = "arn:aws:iam::aws:policy/service-role/AWSLambdaVPCAccessExecutionRole"
3688
- role = aws_iam_role.lambda_execution_role.name
3747
+ role = aws_iam_role.lambda_execution_role[each.key].name
3689
3748
  }
3690
3749
 
3691
3750
  # Additional IAM policies for Lambda (if provided)
3692
3751
  resource "aws_iam_role_policy" "lambda_additional_policies" {
3693
- count = length(local.lambda_policy_statements) > 0 ? 1 : 0
3694
- name = "TestApiHandler-additional-policies-\${random_string.suffix.result}"
3695
- role = aws_iam_role.lambda_execution_role.id
3752
+ for_each = local.operations
3753
+
3754
+ name = "\${substr(local.function_name[each.key], 0, 55)}-policies"
3755
+ role = aws_iam_role.lambda_execution_role[each.key].id
3696
3756
 
3697
3757
  policy = jsonencode({
3698
3758
  Version = "2012-10-17"
3699
- Statement = local.lambda_policy_statements
3759
+ Statement = local.lambda_policy_document_statements
3700
3760
  })
3701
3761
  }
3702
3762
 
@@ -3712,6 +3772,15 @@ locals {
3712
3772
  Resource = ["\${var.appconfig_application_arn}/*"]
3713
3773
  }
3714
3774
  ] : [], var.additional_iam_policy_statements)
3775
+
3776
+ # IAM rejects an empty statement list, so fall back to a no-op deny
3777
+ lambda_policy_document_statements = length(local.lambda_policy_statements) > 0 ? local.lambda_policy_statements : [
3778
+ {
3779
+ Effect = "Deny"
3780
+ Action = ["appconfig:GetLatestConfiguration"]
3781
+ Resource = ["arn:aws:appconfig:\${data.aws_region.current.region}:\${data.aws_caller_identity.current.account_id}:application/none"]
3782
+ }
3783
+ ]
3715
3784
  }
3716
3785
 
3717
3786
  # CloudWatch Log Group for Lambda
@@ -3719,8 +3788,10 @@ resource "aws_cloudwatch_log_group" "lambda_logs" {
3719
3788
  #checkov:skip=CKV_AWS_158:Using default CloudWatch log encryption
3720
3789
  #checkov:skip=CKV_AWS_338:Log retention set to forever
3721
3790
  #checkov:skip=CKV_AWS_66:Log retention set to forever
3722
- name = "/aws/lambda/TestApiHandler-\${random_string.suffix.result}"
3723
- tags = var.tags
3791
+ for_each = local.operations
3792
+
3793
+ name = "/aws/lambda/\${local.function_name[each.key]}"
3794
+ tags = var.tags
3724
3795
  }
3725
3796
 
3726
3797
 
@@ -3739,9 +3810,11 @@ resource "aws_apigatewayv2_authorizer" "cognito_authorizer" {
3739
3810
 
3740
3811
  # Lambda integration for HTTP API
3741
3812
  resource "aws_apigatewayv2_integration" "lambda_integration" {
3813
+ for_each = local.operations
3814
+
3742
3815
  api_id = module.http_api.api_id
3743
3816
  integration_type = "AWS_PROXY"
3744
- integration_uri = aws_lambda_function.api_lambda.invoke_arn
3817
+ integration_uri = aws_lambda_function.api_lambda[each.key].invoke_arn
3745
3818
 
3746
3819
  payload_format_version = "2.0"
3747
3820
  timeout_milliseconds = 30000
@@ -3749,15 +3822,13 @@ resource "aws_apigatewayv2_integration" "lambda_integration" {
3749
3822
  depends_on = [aws_lambda_function.api_lambda]
3750
3823
  }
3751
3824
 
3752
- # Route for proxy integration (catches all requests)
3753
- resource "aws_apigatewayv2_route" "proxy_routes" {
3754
- # NB: OPTIONS is omitted here since API Gateway manages responding to preflight requests
3755
- # when cors settings are configured
3756
- for_each = toset(["GET", "POST", "PUT", "PATCH", "DELETE", "HEAD"])
3825
+ # One route per operation, each targeting that operation's integration
3826
+ resource "aws_apigatewayv2_route" "operation_routes" {
3827
+ for_each = local.operations
3757
3828
 
3758
3829
  api_id = module.http_api.api_id
3759
- route_key = "\${each.key} /{proxy+}"
3760
- target = "integrations/\${aws_apigatewayv2_integration.lambda_integration.id}"
3830
+ route_key = local.route_key[each.key]
3831
+ target = "integrations/\${aws_apigatewayv2_integration.lambda_integration[each.key].id}"
3761
3832
 
3762
3833
  authorization_type = "JWT"
3763
3834
  authorizer_id = aws_apigatewayv2_authorizer.cognito_authorizer.id
@@ -3778,11 +3849,13 @@ module "add_url_to_runtime_config" {
3778
3849
 
3779
3850
  # Lambda permission for API Gateway to invoke the function
3780
3851
  resource "aws_lambda_permission" "api_gateway_invoke" {
3852
+ for_each = local.operations
3853
+
3781
3854
  statement_id = "AllowExecutionFromAPIGateway"
3782
3855
  action = "lambda:InvokeFunction"
3783
- function_name = aws_lambda_function.api_lambda.function_name
3856
+ function_name = aws_lambda_function.api_lambda[each.key].function_name
3784
3857
  principal = "apigateway.amazonaws.com"
3785
- source_arn = "\${module.http_api.api_execution_arn}/*/*"
3858
+ source_arn = "\${module.http_api.api_execution_arn}/*/\${local.permission_source_suffix[each.key]}"
3786
3859
 
3787
3860
  depends_on = [module.http_api, aws_lambda_function.api_lambda]
3788
3861
  }
@@ -3825,73 +3898,73 @@ output "stage_execution_arn" {
3825
3898
  value = module.http_api.stage_execution_arn
3826
3899
  }
3827
3900
 
3828
- # Lambda Function Outputs
3829
- output "lambda_function_name" {
3830
- description = "Name of the Lambda function"
3831
- value = aws_lambda_function.api_lambda.function_name
3901
+ # Lambda Function Outputs, keyed by operation name
3902
+ output "operations" {
3903
+ description = "Names of the API operations, each with its own Lambda function"
3904
+ value = keys(local.operations)
3832
3905
  }
3833
3906
 
3834
- output "lambda_function_arn" {
3835
- description = "ARN of the Lambda function"
3836
- value = aws_lambda_function.api_lambda.arn
3907
+ output "lambda_function_names" {
3908
+ description = "Name of each operation's Lambda function, keyed by operation name"
3909
+ value = { for op, fn in aws_lambda_function.api_lambda : op => fn.function_name }
3837
3910
  }
3838
3911
 
3839
- output "lambda_invoke_arn" {
3840
- description = "Invoke ARN of the Lambda function"
3841
- value = aws_lambda_function.api_lambda.invoke_arn
3912
+ output "lambda_function_arns" {
3913
+ description = "ARN of each operation's Lambda function, keyed by operation name"
3914
+ value = { for op, fn in aws_lambda_function.api_lambda : op => fn.arn }
3842
3915
  }
3843
3916
 
3844
- output "lambda_qualified_arn" {
3845
- description = "Qualified ARN of the Lambda function"
3846
- value = aws_lambda_function.api_lambda.qualified_arn
3917
+ output "lambda_invoke_arns" {
3918
+ description = "Invoke ARN of each operation's Lambda function, keyed by operation name"
3919
+ value = { for op, fn in aws_lambda_function.api_lambda : op => fn.invoke_arn }
3847
3920
  }
3848
3921
 
3849
- output "lambda_version" {
3850
- description = "Version of the Lambda function"
3851
- value = aws_lambda_function.api_lambda.version
3922
+ output "lambda_qualified_arns" {
3923
+ description = "Qualified ARN of each operation's Lambda function, keyed by operation name"
3924
+ value = { for op, fn in aws_lambda_function.api_lambda : op => fn.qualified_arn }
3852
3925
  }
3853
3926
 
3854
- output "lambda_source_code_hash" {
3855
- description = "Base64-encoded SHA256 hash of the Lambda deployment package"
3856
- value = aws_lambda_function.api_lambda.source_code_hash
3927
+ output "lambda_versions" {
3928
+ description = "Version of each operation's Lambda function, keyed by operation name"
3929
+ value = { for op, fn in aws_lambda_function.api_lambda : op => fn.version }
3857
3930
  }
3858
3931
 
3859
- output "lambda_source_code_size" {
3860
- description = "Size of the Lambda deployment package in bytes"
3861
- value = aws_lambda_function.api_lambda.source_code_size
3932
+ output "lambda_source_code_hash" {
3933
+ description = "Base64-encoded SHA256 hash of the Lambda deployment package, shared by every operation"
3934
+ value = data.archive_file.lambda_zip.output_base64sha256
3862
3935
  }
3863
3936
 
3864
- # IAM Role Outputs
3865
- output "lambda_execution_role_arn" {
3866
- description = "ARN of the Lambda execution role"
3867
- value = aws_iam_role.lambda_execution_role.arn
3937
+ # IAM Role Outputs, keyed by operation name
3938
+ output "lambda_execution_role_arns" {
3939
+ description = "ARN of each operation's Lambda execution role, keyed by operation name"
3940
+ value = { for op, role in aws_iam_role.lambda_execution_role : op => role.arn }
3868
3941
  }
3869
3942
 
3870
- output "lambda_execution_role_name" {
3871
- description = "Name of the Lambda execution role"
3872
- value = aws_iam_role.lambda_execution_role.name
3943
+ output "lambda_execution_role_names" {
3944
+ description = "Name of each operation's Lambda execution role, keyed by operation name. Attach additional policies to a specific operation's role by name."
3945
+ value = { for op, role in aws_iam_role.lambda_execution_role : op => role.name }
3873
3946
  }
3874
3947
 
3875
3948
  output "security_group_id" {
3876
- 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."
3949
+ 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."
3877
3950
  value = var.enable_vpc ? aws_security_group.api_lambda[0].id : null
3878
3951
  }
3879
3952
 
3880
- # Integration Outputs
3881
- output "integration_id" {
3882
- description = "ID of the Lambda integration"
3883
- value = aws_apigatewayv2_integration.lambda_integration.id
3953
+ # Integration Outputs, keyed by operation name
3954
+ output "integration_ids" {
3955
+ description = "ID of each operation's Lambda integration, keyed by operation name"
3956
+ value = { for op, i in aws_apigatewayv2_integration.lambda_integration : op => i.id }
3884
3957
  }
3885
3958
 
3886
- # CloudWatch Log Groups
3887
- output "lambda_log_group_name" {
3888
- description = "Name of the Lambda CloudWatch log group"
3889
- value = aws_cloudwatch_log_group.lambda_logs.name
3959
+ # CloudWatch Log Groups, keyed by operation name
3960
+ output "lambda_log_group_names" {
3961
+ description = "Name of each operation's Lambda CloudWatch log group, keyed by operation name"
3962
+ value = { for op, lg in aws_cloudwatch_log_group.lambda_logs : op => lg.name }
3890
3963
  }
3891
3964
 
3892
- output "lambda_log_group_arn" {
3893
- description = "ARN of the Lambda CloudWatch log group"
3894
- value = aws_cloudwatch_log_group.lambda_logs.arn
3965
+ output "lambda_log_group_arns" {
3966
+ description = "ARN of each operation's Lambda CloudWatch log group, keyed by operation name"
3967
+ value = { for op, lg in aws_cloudwatch_log_group.lambda_logs : op => lg.arn }
3895
3968
  }
3896
3969
 
3897
3970
  output "api_log_group_name" {
@@ -4162,10 +4235,18 @@ output "api_log_group_arn" {
4162
4235
  required_version = ">= 1.0"
4163
4236
 
4164
4237
  required_providers {
4238
+ archive = {
4239
+ source = "hashicorp/archive"
4240
+ version = "2.8.0"
4241
+ }
4165
4242
  aws = {
4166
4243
  source = "hashicorp/aws"
4167
4244
  version = "6.61.0"
4168
4245
  }
4246
+ random = {
4247
+ source = "hashicorp/random"
4248
+ version = "3.9.0"
4249
+ }
4169
4250
  }
4170
4251
  }
4171
4252
 
@@ -4275,6 +4356,49 @@ resource "random_string" "suffix" {
4275
4356
  upper = false
4276
4357
  }
4277
4358
 
4359
+ locals {
4360
+ # Generated at build time from the API definition
4361
+ operations_file = "\${path.module}/../../../generated/test-api/operations.json"
4362
+ operations = fileexists(local.operations_file) ? jsondecode(file(local.operations_file)) : {}
4363
+
4364
+ operation_slug = { for op in keys(local.operations) : op => replace(op, "/[^a-zA-Z0-9-_]/", "-") }
4365
+ operation_hash = { for op in keys(local.operations) : op => substr(sha256(op), 0, 8) }
4366
+
4367
+ function_name = { for op in keys(local.operations) : op =>
4368
+ "\${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}"
4369
+ }
4370
+ role_name = { for op in keys(local.operations) : op =>
4371
+ "\${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}"
4372
+ }
4373
+
4374
+ operation_path = { for op, details in local.operations : op =>
4375
+ startswith(details.path, "/") ? details.path : "/\${details.path}"
4376
+ }
4377
+ route_key = { for op, details in local.operations : op =>
4378
+ "\${upper(details.method)} \${local.operation_path[op]}"
4379
+ }
4380
+
4381
+ permission_source_suffix = { for op, details in local.operations : op =>
4382
+ "\${upper(details.method)}\${replace(local.operation_path[op], "/{[^}]*}/", "*")}"
4383
+ }
4384
+ }
4385
+
4386
+ resource "terraform_data" "operations_metadata" {
4387
+ # Fails the plan when the operations metadata has not been generated
4388
+ input = local.operations_file
4389
+
4390
+ lifecycle {
4391
+ precondition {
4392
+ condition = fileexists(local.operations_file)
4393
+ error_message = "Operations metadata not found at \${local.operations_file}. Build the TestApi project to generate it."
4394
+ }
4395
+ precondition {
4396
+ condition = length(local.operations) > 0
4397
+ error_message = "No operations found in \${local.operations_file}. The TestApi API must define at least one operation."
4398
+ }
4399
+ }
4400
+ }
4401
+
4278
4402
  # Resources
4279
4403
 
4280
4404
  # Create Lambda ZIP file from the bundle directory
@@ -4335,9 +4459,10 @@ resource "aws_vpc_security_group_egress_rule" "api_lambda_https" {
4335
4459
  description = "Allow outbound HTTPS to AWS service endpoints"
4336
4460
  }
4337
4461
 
4338
- # Lambda function
4339
- # This configures a single "router" lambda to serve all requests
4340
4462
  resource "aws_lambda_function" "api_lambda" {
4463
+ # One lambda function per operation, each serving just that operation
4464
+ for_each = local.operations
4465
+
4341
4466
  #checkov:skip=CKV_AWS_117:Lambda function is optionally deployed into a VPC via vpc_id/subnet_ids; not required for this use case
4342
4467
  #checkov:skip=CKV_AWS_116:Dead Letter Queue not required for this simple API use case
4343
4468
  #checkov:skip=CKV_AWS_272:Code signing not required for this use case
@@ -4346,8 +4471,8 @@ resource "aws_lambda_function" "api_lambda" {
4346
4471
  s3_bucket = aws_s3_object.lambda_zip.bucket
4347
4472
  s3_key = aws_s3_object.lambda_zip.key
4348
4473
  s3_object_version = aws_s3_object.lambda_zip.version_id
4349
- function_name = "TestApiHandler-\${random_string.suffix.result}"
4350
- role = aws_iam_role.lambda_execution_role.arn
4474
+ function_name = local.function_name[each.key]
4475
+ role = aws_iam_role.lambda_execution_role[each.key].arn
4351
4476
  handler = "index.handler"
4352
4477
  runtime = "nodejs22.x"
4353
4478
  timeout = 30
@@ -4383,7 +4508,9 @@ resource "aws_lambda_function" "api_lambda" {
4383
4508
 
4384
4509
  # IAM role for Lambda execution
4385
4510
  resource "aws_iam_role" "lambda_execution_role" {
4386
- name = "TestApiHandler-execution-role-\${random_string.suffix.result}"
4511
+ for_each = local.operations
4512
+
4513
+ name = local.role_name[each.key]
4387
4514
 
4388
4515
  assume_role_policy = jsonencode({
4389
4516
  Version = "2012-10-17"
@@ -4403,32 +4530,38 @@ resource "aws_iam_role" "lambda_execution_role" {
4403
4530
 
4404
4531
  # Attach basic execution policy to Lambda role
4405
4532
  resource "aws_iam_role_policy_attachment" "lambda_basic_execution" {
4533
+ for_each = local.operations
4534
+
4406
4535
  policy_arn = "arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole"
4407
- role = aws_iam_role.lambda_execution_role.name
4536
+ role = aws_iam_role.lambda_execution_role[each.key].name
4408
4537
  }
4409
4538
 
4410
4539
  # Attach X-Ray tracing policy to Lambda role
4411
4540
  resource "aws_iam_role_policy_attachment" "lambda_xray_execution" {
4541
+ for_each = local.operations
4542
+
4412
4543
  policy_arn = "arn:aws:iam::aws:policy/AWSXRayDaemonWriteAccess"
4413
- role = aws_iam_role.lambda_execution_role.name
4544
+ role = aws_iam_role.lambda_execution_role[each.key].name
4414
4545
  }
4415
4546
 
4416
4547
  # Attach VPC access policy to Lambda role when deployed into a VPC
4417
4548
  resource "aws_iam_role_policy_attachment" "lambda_vpc_access" {
4418
- count = var.enable_vpc ? 1 : 0
4549
+ for_each = var.enable_vpc ? local.operations : {}
4550
+
4419
4551
  policy_arn = "arn:aws:iam::aws:policy/service-role/AWSLambdaVPCAccessExecutionRole"
4420
- role = aws_iam_role.lambda_execution_role.name
4552
+ role = aws_iam_role.lambda_execution_role[each.key].name
4421
4553
  }
4422
4554
 
4423
4555
  # Additional IAM policies for Lambda (if provided)
4424
4556
  resource "aws_iam_role_policy" "lambda_additional_policies" {
4425
- count = length(local.lambda_policy_statements) > 0 ? 1 : 0
4426
- name = "TestApiHandler-additional-policies-\${random_string.suffix.result}"
4427
- role = aws_iam_role.lambda_execution_role.id
4557
+ for_each = local.operations
4558
+
4559
+ name = "\${substr(local.function_name[each.key], 0, 55)}-policies"
4560
+ role = aws_iam_role.lambda_execution_role[each.key].id
4428
4561
 
4429
4562
  policy = jsonencode({
4430
4563
  Version = "2012-10-17"
4431
- Statement = local.lambda_policy_statements
4564
+ Statement = local.lambda_policy_document_statements
4432
4565
  })
4433
4566
  }
4434
4567
 
@@ -4444,6 +4577,15 @@ locals {
4444
4577
  Resource = ["\${var.appconfig_application_arn}/*"]
4445
4578
  }
4446
4579
  ] : [], var.additional_iam_policy_statements)
4580
+
4581
+ # IAM rejects an empty statement list, so fall back to a no-op deny
4582
+ lambda_policy_document_statements = length(local.lambda_policy_statements) > 0 ? local.lambda_policy_statements : [
4583
+ {
4584
+ Effect = "Deny"
4585
+ Action = ["appconfig:GetLatestConfiguration"]
4586
+ Resource = ["arn:aws:appconfig:\${data.aws_region.current.region}:\${data.aws_caller_identity.current.account_id}:application/none"]
4587
+ }
4588
+ ]
4447
4589
  }
4448
4590
 
4449
4591
  # CloudWatch Log Group for Lambda
@@ -4451,8 +4593,10 @@ resource "aws_cloudwatch_log_group" "lambda_logs" {
4451
4593
  #checkov:skip=CKV_AWS_158:Using default CloudWatch log encryption
4452
4594
  #checkov:skip=CKV_AWS_338:Log retention set to forever
4453
4595
  #checkov:skip=CKV_AWS_66:Log retention set to forever
4454
- name = "/aws/lambda/TestApiHandler-\${random_string.suffix.result}"
4455
- tags = var.tags
4596
+ for_each = local.operations
4597
+
4598
+ name = "/aws/lambda/\${local.function_name[each.key]}"
4599
+ tags = var.tags
4456
4600
  }
4457
4601
 
4458
4602
 
@@ -4538,7 +4682,8 @@ resource "aws_apigatewayv2_authorizer" "custom_authorizer" {
4538
4682
  authorizer_type = "REQUEST"
4539
4683
  authorizer_uri = aws_lambda_function.authorizer_lambda.invoke_arn
4540
4684
  authorizer_payload_format_version = "2.0"
4541
- authorizer_result_ttl_in_seconds = 0
4685
+ authorizer_result_ttl_in_seconds = 300
4686
+ identity_sources = ["$request.header.Authorization"]
4542
4687
  enable_simple_responses = true
4543
4688
  name = "TestApiAuthorizer-\${random_string.suffix.result}"
4544
4689
  }
@@ -4553,9 +4698,11 @@ resource "aws_lambda_permission" "authorizer_invoke" {
4553
4698
 
4554
4699
  # Lambda integration for HTTP API
4555
4700
  resource "aws_apigatewayv2_integration" "lambda_integration" {
4701
+ for_each = local.operations
4702
+
4556
4703
  api_id = module.http_api.api_id
4557
4704
  integration_type = "AWS_PROXY"
4558
- integration_uri = aws_lambda_function.api_lambda.invoke_arn
4705
+ integration_uri = aws_lambda_function.api_lambda[each.key].invoke_arn
4559
4706
 
4560
4707
  payload_format_version = "2.0"
4561
4708
  timeout_milliseconds = 30000
@@ -4563,15 +4710,13 @@ resource "aws_apigatewayv2_integration" "lambda_integration" {
4563
4710
  depends_on = [aws_lambda_function.api_lambda]
4564
4711
  }
4565
4712
 
4566
- # Route for proxy integration (catches all requests)
4567
- resource "aws_apigatewayv2_route" "proxy_routes" {
4568
- # NB: OPTIONS is omitted here since API Gateway manages responding to preflight requests
4569
- # when cors settings are configured
4570
- for_each = toset(["GET", "POST", "PUT", "PATCH", "DELETE", "HEAD"])
4713
+ # One route per operation, each targeting that operation's integration
4714
+ resource "aws_apigatewayv2_route" "operation_routes" {
4715
+ for_each = local.operations
4571
4716
 
4572
4717
  api_id = module.http_api.api_id
4573
- route_key = "\${each.key} /{proxy+}"
4574
- target = "integrations/\${aws_apigatewayv2_integration.lambda_integration.id}"
4718
+ route_key = local.route_key[each.key]
4719
+ target = "integrations/\${aws_apigatewayv2_integration.lambda_integration[each.key].id}"
4575
4720
 
4576
4721
  authorization_type = "CUSTOM"
4577
4722
  authorizer_id = aws_apigatewayv2_authorizer.custom_authorizer.id
@@ -4592,11 +4737,13 @@ module "add_url_to_runtime_config" {
4592
4737
 
4593
4738
  # Lambda permission for API Gateway to invoke the function
4594
4739
  resource "aws_lambda_permission" "api_gateway_invoke" {
4740
+ for_each = local.operations
4741
+
4595
4742
  statement_id = "AllowExecutionFromAPIGateway"
4596
4743
  action = "lambda:InvokeFunction"
4597
- function_name = aws_lambda_function.api_lambda.function_name
4744
+ function_name = aws_lambda_function.api_lambda[each.key].function_name
4598
4745
  principal = "apigateway.amazonaws.com"
4599
- source_arn = "\${module.http_api.api_execution_arn}/*/*"
4746
+ source_arn = "\${module.http_api.api_execution_arn}/*/\${local.permission_source_suffix[each.key]}"
4600
4747
 
4601
4748
  depends_on = [module.http_api, aws_lambda_function.api_lambda]
4602
4749
  }
@@ -4639,73 +4786,73 @@ output "stage_execution_arn" {
4639
4786
  value = module.http_api.stage_execution_arn
4640
4787
  }
4641
4788
 
4642
- # Lambda Function Outputs
4643
- output "lambda_function_name" {
4644
- description = "Name of the Lambda function"
4645
- value = aws_lambda_function.api_lambda.function_name
4789
+ # Lambda Function Outputs, keyed by operation name
4790
+ output "operations" {
4791
+ description = "Names of the API operations, each with its own Lambda function"
4792
+ value = keys(local.operations)
4646
4793
  }
4647
4794
 
4648
- output "lambda_function_arn" {
4649
- description = "ARN of the Lambda function"
4650
- value = aws_lambda_function.api_lambda.arn
4795
+ output "lambda_function_names" {
4796
+ description = "Name of each operation's Lambda function, keyed by operation name"
4797
+ value = { for op, fn in aws_lambda_function.api_lambda : op => fn.function_name }
4651
4798
  }
4652
4799
 
4653
- output "lambda_invoke_arn" {
4654
- description = "Invoke ARN of the Lambda function"
4655
- value = aws_lambda_function.api_lambda.invoke_arn
4800
+ output "lambda_function_arns" {
4801
+ description = "ARN of each operation's Lambda function, keyed by operation name"
4802
+ value = { for op, fn in aws_lambda_function.api_lambda : op => fn.arn }
4656
4803
  }
4657
4804
 
4658
- output "lambda_qualified_arn" {
4659
- description = "Qualified ARN of the Lambda function"
4660
- value = aws_lambda_function.api_lambda.qualified_arn
4805
+ output "lambda_invoke_arns" {
4806
+ description = "Invoke ARN of each operation's Lambda function, keyed by operation name"
4807
+ value = { for op, fn in aws_lambda_function.api_lambda : op => fn.invoke_arn }
4661
4808
  }
4662
4809
 
4663
- output "lambda_version" {
4664
- description = "Version of the Lambda function"
4665
- value = aws_lambda_function.api_lambda.version
4810
+ output "lambda_qualified_arns" {
4811
+ description = "Qualified ARN of each operation's Lambda function, keyed by operation name"
4812
+ value = { for op, fn in aws_lambda_function.api_lambda : op => fn.qualified_arn }
4666
4813
  }
4667
4814
 
4668
- output "lambda_source_code_hash" {
4669
- description = "Base64-encoded SHA256 hash of the Lambda deployment package"
4670
- value = aws_lambda_function.api_lambda.source_code_hash
4815
+ output "lambda_versions" {
4816
+ description = "Version of each operation's Lambda function, keyed by operation name"
4817
+ value = { for op, fn in aws_lambda_function.api_lambda : op => fn.version }
4671
4818
  }
4672
4819
 
4673
- output "lambda_source_code_size" {
4674
- description = "Size of the Lambda deployment package in bytes"
4675
- value = aws_lambda_function.api_lambda.source_code_size
4820
+ output "lambda_source_code_hash" {
4821
+ description = "Base64-encoded SHA256 hash of the Lambda deployment package, shared by every operation"
4822
+ value = data.archive_file.lambda_zip.output_base64sha256
4676
4823
  }
4677
4824
 
4678
- # IAM Role Outputs
4679
- output "lambda_execution_role_arn" {
4680
- description = "ARN of the Lambda execution role"
4681
- value = aws_iam_role.lambda_execution_role.arn
4825
+ # IAM Role Outputs, keyed by operation name
4826
+ output "lambda_execution_role_arns" {
4827
+ description = "ARN of each operation's Lambda execution role, keyed by operation name"
4828
+ value = { for op, role in aws_iam_role.lambda_execution_role : op => role.arn }
4682
4829
  }
4683
4830
 
4684
- output "lambda_execution_role_name" {
4685
- description = "Name of the Lambda execution role"
4686
- value = aws_iam_role.lambda_execution_role.name
4831
+ output "lambda_execution_role_names" {
4832
+ description = "Name of each operation's Lambda execution role, keyed by operation name. Attach additional policies to a specific operation's role by name."
4833
+ value = { for op, role in aws_iam_role.lambda_execution_role : op => role.name }
4687
4834
  }
4688
4835
 
4689
4836
  output "security_group_id" {
4690
- 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."
4837
+ 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."
4691
4838
  value = var.enable_vpc ? aws_security_group.api_lambda[0].id : null
4692
4839
  }
4693
4840
 
4694
- # Integration Outputs
4695
- output "integration_id" {
4696
- description = "ID of the Lambda integration"
4697
- value = aws_apigatewayv2_integration.lambda_integration.id
4841
+ # Integration Outputs, keyed by operation name
4842
+ output "integration_ids" {
4843
+ description = "ID of each operation's Lambda integration, keyed by operation name"
4844
+ value = { for op, i in aws_apigatewayv2_integration.lambda_integration : op => i.id }
4698
4845
  }
4699
4846
 
4700
- # CloudWatch Log Groups
4701
- output "lambda_log_group_name" {
4702
- description = "Name of the Lambda CloudWatch log group"
4703
- value = aws_cloudwatch_log_group.lambda_logs.name
4847
+ # CloudWatch Log Groups, keyed by operation name
4848
+ output "lambda_log_group_names" {
4849
+ description = "Name of each operation's Lambda CloudWatch log group, keyed by operation name"
4850
+ value = { for op, lg in aws_cloudwatch_log_group.lambda_logs : op => lg.name }
4704
4851
  }
4705
4852
 
4706
- output "lambda_log_group_arn" {
4707
- description = "ARN of the Lambda CloudWatch log group"
4708
- value = aws_cloudwatch_log_group.lambda_logs.arn
4853
+ output "lambda_log_group_arns" {
4854
+ description = "ARN of each operation's Lambda CloudWatch log group, keyed by operation name"
4855
+ value = { for op, lg in aws_cloudwatch_log_group.lambda_logs : op => lg.arn }
4709
4856
  }
4710
4857
 
4711
4858
  output "api_log_group_name" {
@@ -4976,10 +5123,18 @@ output "api_log_group_arn" {
4976
5123
  required_version = ">= 1.0"
4977
5124
 
4978
5125
  required_providers {
5126
+ archive = {
5127
+ source = "hashicorp/archive"
5128
+ version = "2.8.0"
5129
+ }
4979
5130
  aws = {
4980
5131
  source = "hashicorp/aws"
4981
5132
  version = "6.61.0"
4982
5133
  }
5134
+ random = {
5135
+ source = "hashicorp/random"
5136
+ version = "3.9.0"
5137
+ }
4983
5138
  }
4984
5139
  }
4985
5140
 
@@ -5089,6 +5244,49 @@ resource "random_string" "suffix" {
5089
5244
  upper = false
5090
5245
  }
5091
5246
 
5247
+ locals {
5248
+ # Generated at build time from the API definition
5249
+ operations_file = "\${path.module}/../../../generated/test-api/operations.json"
5250
+ operations = fileexists(local.operations_file) ? jsondecode(file(local.operations_file)) : {}
5251
+
5252
+ operation_slug = { for op in keys(local.operations) : op => replace(op, "/[^a-zA-Z0-9-_]/", "-") }
5253
+ operation_hash = { for op in keys(local.operations) : op => substr(sha256(op), 0, 8) }
5254
+
5255
+ function_name = { for op in keys(local.operations) : op =>
5256
+ "\${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}"
5257
+ }
5258
+ role_name = { for op in keys(local.operations) : op =>
5259
+ "\${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}"
5260
+ }
5261
+
5262
+ operation_path = { for op, details in local.operations : op =>
5263
+ startswith(details.path, "/") ? details.path : "/\${details.path}"
5264
+ }
5265
+ route_key = { for op, details in local.operations : op =>
5266
+ "\${upper(details.method)} \${local.operation_path[op]}"
5267
+ }
5268
+
5269
+ permission_source_suffix = { for op, details in local.operations : op =>
5270
+ "\${upper(details.method)}\${replace(local.operation_path[op], "/{[^}]*}/", "*")}"
5271
+ }
5272
+ }
5273
+
5274
+ resource "terraform_data" "operations_metadata" {
5275
+ # Fails the plan when the operations metadata has not been generated
5276
+ input = local.operations_file
5277
+
5278
+ lifecycle {
5279
+ precondition {
5280
+ condition = fileexists(local.operations_file)
5281
+ error_message = "Operations metadata not found at \${local.operations_file}. Build the TestApi project to generate it."
5282
+ }
5283
+ precondition {
5284
+ condition = length(local.operations) > 0
5285
+ error_message = "No operations found in \${local.operations_file}. The TestApi API must define at least one operation."
5286
+ }
5287
+ }
5288
+ }
5289
+
5092
5290
  # Resources
5093
5291
 
5094
5292
  # Create Lambda ZIP file from the bundle directory
@@ -5149,9 +5347,10 @@ resource "aws_vpc_security_group_egress_rule" "api_lambda_https" {
5149
5347
  description = "Allow outbound HTTPS to AWS service endpoints"
5150
5348
  }
5151
5349
 
5152
- # Lambda function
5153
- # This configures a single "router" lambda to serve all requests
5154
5350
  resource "aws_lambda_function" "api_lambda" {
5351
+ # One lambda function per operation, each serving just that operation
5352
+ for_each = local.operations
5353
+
5155
5354
  #checkov:skip=CKV_AWS_117:Lambda function is optionally deployed into a VPC via vpc_id/subnet_ids; not required for this use case
5156
5355
  #checkov:skip=CKV_AWS_116:Dead Letter Queue not required for this simple API use case
5157
5356
  #checkov:skip=CKV_AWS_272:Code signing not required for this use case
@@ -5160,8 +5359,8 @@ resource "aws_lambda_function" "api_lambda" {
5160
5359
  s3_bucket = aws_s3_object.lambda_zip.bucket
5161
5360
  s3_key = aws_s3_object.lambda_zip.key
5162
5361
  s3_object_version = aws_s3_object.lambda_zip.version_id
5163
- function_name = "TestApiHandler-\${random_string.suffix.result}"
5164
- role = aws_iam_role.lambda_execution_role.arn
5362
+ function_name = local.function_name[each.key]
5363
+ role = aws_iam_role.lambda_execution_role[each.key].arn
5165
5364
  handler = "index.handler"
5166
5365
  runtime = "nodejs22.x"
5167
5366
  timeout = 30
@@ -5197,7 +5396,9 @@ resource "aws_lambda_function" "api_lambda" {
5197
5396
 
5198
5397
  # IAM role for Lambda execution
5199
5398
  resource "aws_iam_role" "lambda_execution_role" {
5200
- name = "TestApiHandler-execution-role-\${random_string.suffix.result}"
5399
+ for_each = local.operations
5400
+
5401
+ name = local.role_name[each.key]
5201
5402
 
5202
5403
  assume_role_policy = jsonencode({
5203
5404
  Version = "2012-10-17"
@@ -5217,32 +5418,38 @@ resource "aws_iam_role" "lambda_execution_role" {
5217
5418
 
5218
5419
  # Attach basic execution policy to Lambda role
5219
5420
  resource "aws_iam_role_policy_attachment" "lambda_basic_execution" {
5421
+ for_each = local.operations
5422
+
5220
5423
  policy_arn = "arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole"
5221
- role = aws_iam_role.lambda_execution_role.name
5424
+ role = aws_iam_role.lambda_execution_role[each.key].name
5222
5425
  }
5223
5426
 
5224
5427
  # Attach X-Ray tracing policy to Lambda role
5225
5428
  resource "aws_iam_role_policy_attachment" "lambda_xray_execution" {
5429
+ for_each = local.operations
5430
+
5226
5431
  policy_arn = "arn:aws:iam::aws:policy/AWSXRayDaemonWriteAccess"
5227
- role = aws_iam_role.lambda_execution_role.name
5432
+ role = aws_iam_role.lambda_execution_role[each.key].name
5228
5433
  }
5229
5434
 
5230
5435
  # Attach VPC access policy to Lambda role when deployed into a VPC
5231
5436
  resource "aws_iam_role_policy_attachment" "lambda_vpc_access" {
5232
- count = var.enable_vpc ? 1 : 0
5437
+ for_each = var.enable_vpc ? local.operations : {}
5438
+
5233
5439
  policy_arn = "arn:aws:iam::aws:policy/service-role/AWSLambdaVPCAccessExecutionRole"
5234
- role = aws_iam_role.lambda_execution_role.name
5440
+ role = aws_iam_role.lambda_execution_role[each.key].name
5235
5441
  }
5236
5442
 
5237
5443
  # Additional IAM policies for Lambda (if provided)
5238
5444
  resource "aws_iam_role_policy" "lambda_additional_policies" {
5239
- count = length(local.lambda_policy_statements) > 0 ? 1 : 0
5240
- name = "TestApiHandler-additional-policies-\${random_string.suffix.result}"
5241
- role = aws_iam_role.lambda_execution_role.id
5445
+ for_each = local.operations
5446
+
5447
+ name = "\${substr(local.function_name[each.key], 0, 55)}-policies"
5448
+ role = aws_iam_role.lambda_execution_role[each.key].id
5242
5449
 
5243
5450
  policy = jsonencode({
5244
5451
  Version = "2012-10-17"
5245
- Statement = local.lambda_policy_statements
5452
+ Statement = local.lambda_policy_document_statements
5246
5453
  })
5247
5454
  }
5248
5455
 
@@ -5258,6 +5465,15 @@ locals {
5258
5465
  Resource = ["\${var.appconfig_application_arn}/*"]
5259
5466
  }
5260
5467
  ] : [], var.additional_iam_policy_statements)
5468
+
5469
+ # IAM rejects an empty statement list, so fall back to a no-op deny
5470
+ lambda_policy_document_statements = length(local.lambda_policy_statements) > 0 ? local.lambda_policy_statements : [
5471
+ {
5472
+ Effect = "Deny"
5473
+ Action = ["appconfig:GetLatestConfiguration"]
5474
+ Resource = ["arn:aws:appconfig:\${data.aws_region.current.region}:\${data.aws_caller_identity.current.account_id}:application/none"]
5475
+ }
5476
+ ]
5261
5477
  }
5262
5478
 
5263
5479
  # CloudWatch Log Group for Lambda
@@ -5265,17 +5481,21 @@ resource "aws_cloudwatch_log_group" "lambda_logs" {
5265
5481
  #checkov:skip=CKV_AWS_158:Using default CloudWatch log encryption
5266
5482
  #checkov:skip=CKV_AWS_338:Log retention set to forever
5267
5483
  #checkov:skip=CKV_AWS_66:Log retention set to forever
5268
- name = "/aws/lambda/TestApiHandler-\${random_string.suffix.result}"
5269
- tags = var.tags
5484
+ for_each = local.operations
5485
+
5486
+ name = "/aws/lambda/\${local.function_name[each.key]}"
5487
+ tags = var.tags
5270
5488
  }
5271
5489
 
5272
5490
 
5273
5491
 
5274
5492
  # Lambda integration for HTTP API
5275
5493
  resource "aws_apigatewayv2_integration" "lambda_integration" {
5494
+ for_each = local.operations
5495
+
5276
5496
  api_id = module.http_api.api_id
5277
5497
  integration_type = "AWS_PROXY"
5278
- integration_uri = aws_lambda_function.api_lambda.invoke_arn
5498
+ integration_uri = aws_lambda_function.api_lambda[each.key].invoke_arn
5279
5499
 
5280
5500
  payload_format_version = "2.0"
5281
5501
  timeout_milliseconds = 30000
@@ -5283,15 +5503,13 @@ resource "aws_apigatewayv2_integration" "lambda_integration" {
5283
5503
  depends_on = [aws_lambda_function.api_lambda]
5284
5504
  }
5285
5505
 
5286
- # Route for proxy integration (catches all requests)
5287
- resource "aws_apigatewayv2_route" "proxy_routes" {
5288
- # NB: OPTIONS is omitted here since API Gateway manages responding to preflight requests
5289
- # when cors settings are configured
5290
- for_each = toset(["GET", "POST", "PUT", "PATCH", "DELETE", "HEAD"])
5506
+ # One route per operation, each targeting that operation's integration
5507
+ resource "aws_apigatewayv2_route" "operation_routes" {
5508
+ for_each = local.operations
5291
5509
 
5292
5510
  api_id = module.http_api.api_id
5293
- route_key = "\${each.key} /{proxy+}"
5294
- target = "integrations/\${aws_apigatewayv2_integration.lambda_integration.id}"
5511
+ route_key = local.route_key[each.key]
5512
+ target = "integrations/\${aws_apigatewayv2_integration.lambda_integration[each.key].id}"
5295
5513
 
5296
5514
  authorization_type = "AWS_IAM"
5297
5515
 
@@ -5311,11 +5529,13 @@ module "add_url_to_runtime_config" {
5311
5529
 
5312
5530
  # Lambda permission for API Gateway to invoke the function
5313
5531
  resource "aws_lambda_permission" "api_gateway_invoke" {
5532
+ for_each = local.operations
5533
+
5314
5534
  statement_id = "AllowExecutionFromAPIGateway"
5315
5535
  action = "lambda:InvokeFunction"
5316
- function_name = aws_lambda_function.api_lambda.function_name
5536
+ function_name = aws_lambda_function.api_lambda[each.key].function_name
5317
5537
  principal = "apigateway.amazonaws.com"
5318
- source_arn = "\${module.http_api.api_execution_arn}/*/*"
5538
+ source_arn = "\${module.http_api.api_execution_arn}/*/\${local.permission_source_suffix[each.key]}"
5319
5539
 
5320
5540
  depends_on = [module.http_api, aws_lambda_function.api_lambda]
5321
5541
  }
@@ -5358,73 +5578,73 @@ output "stage_execution_arn" {
5358
5578
  value = module.http_api.stage_execution_arn
5359
5579
  }
5360
5580
 
5361
- # Lambda Function Outputs
5362
- output "lambda_function_name" {
5363
- description = "Name of the Lambda function"
5364
- value = aws_lambda_function.api_lambda.function_name
5581
+ # Lambda Function Outputs, keyed by operation name
5582
+ output "operations" {
5583
+ description = "Names of the API operations, each with its own Lambda function"
5584
+ value = keys(local.operations)
5365
5585
  }
5366
5586
 
5367
- output "lambda_function_arn" {
5368
- description = "ARN of the Lambda function"
5369
- value = aws_lambda_function.api_lambda.arn
5587
+ output "lambda_function_names" {
5588
+ description = "Name of each operation's Lambda function, keyed by operation name"
5589
+ value = { for op, fn in aws_lambda_function.api_lambda : op => fn.function_name }
5370
5590
  }
5371
5591
 
5372
- output "lambda_invoke_arn" {
5373
- description = "Invoke ARN of the Lambda function"
5374
- value = aws_lambda_function.api_lambda.invoke_arn
5592
+ output "lambda_function_arns" {
5593
+ description = "ARN of each operation's Lambda function, keyed by operation name"
5594
+ value = { for op, fn in aws_lambda_function.api_lambda : op => fn.arn }
5375
5595
  }
5376
5596
 
5377
- output "lambda_qualified_arn" {
5378
- description = "Qualified ARN of the Lambda function"
5379
- value = aws_lambda_function.api_lambda.qualified_arn
5597
+ output "lambda_invoke_arns" {
5598
+ description = "Invoke ARN of each operation's Lambda function, keyed by operation name"
5599
+ value = { for op, fn in aws_lambda_function.api_lambda : op => fn.invoke_arn }
5380
5600
  }
5381
5601
 
5382
- output "lambda_version" {
5383
- description = "Version of the Lambda function"
5384
- value = aws_lambda_function.api_lambda.version
5602
+ output "lambda_qualified_arns" {
5603
+ description = "Qualified ARN of each operation's Lambda function, keyed by operation name"
5604
+ value = { for op, fn in aws_lambda_function.api_lambda : op => fn.qualified_arn }
5385
5605
  }
5386
5606
 
5387
- output "lambda_source_code_hash" {
5388
- description = "Base64-encoded SHA256 hash of the Lambda deployment package"
5389
- value = aws_lambda_function.api_lambda.source_code_hash
5607
+ output "lambda_versions" {
5608
+ description = "Version of each operation's Lambda function, keyed by operation name"
5609
+ value = { for op, fn in aws_lambda_function.api_lambda : op => fn.version }
5390
5610
  }
5391
5611
 
5392
- output "lambda_source_code_size" {
5393
- description = "Size of the Lambda deployment package in bytes"
5394
- value = aws_lambda_function.api_lambda.source_code_size
5612
+ output "lambda_source_code_hash" {
5613
+ description = "Base64-encoded SHA256 hash of the Lambda deployment package, shared by every operation"
5614
+ value = data.archive_file.lambda_zip.output_base64sha256
5395
5615
  }
5396
5616
 
5397
- # IAM Role Outputs
5398
- output "lambda_execution_role_arn" {
5399
- description = "ARN of the Lambda execution role"
5400
- value = aws_iam_role.lambda_execution_role.arn
5617
+ # IAM Role Outputs, keyed by operation name
5618
+ output "lambda_execution_role_arns" {
5619
+ description = "ARN of each operation's Lambda execution role, keyed by operation name"
5620
+ value = { for op, role in aws_iam_role.lambda_execution_role : op => role.arn }
5401
5621
  }
5402
5622
 
5403
- output "lambda_execution_role_name" {
5404
- description = "Name of the Lambda execution role"
5405
- value = aws_iam_role.lambda_execution_role.name
5623
+ output "lambda_execution_role_names" {
5624
+ description = "Name of each operation's Lambda execution role, keyed by operation name. Attach additional policies to a specific operation's role by name."
5625
+ value = { for op, role in aws_iam_role.lambda_execution_role : op => role.name }
5406
5626
  }
5407
5627
 
5408
5628
  output "security_group_id" {
5409
- 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."
5629
+ 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."
5410
5630
  value = var.enable_vpc ? aws_security_group.api_lambda[0].id : null
5411
5631
  }
5412
5632
 
5413
- # Integration Outputs
5414
- output "integration_id" {
5415
- description = "ID of the Lambda integration"
5416
- value = aws_apigatewayv2_integration.lambda_integration.id
5633
+ # Integration Outputs, keyed by operation name
5634
+ output "integration_ids" {
5635
+ description = "ID of each operation's Lambda integration, keyed by operation name"
5636
+ value = { for op, i in aws_apigatewayv2_integration.lambda_integration : op => i.id }
5417
5637
  }
5418
5638
 
5419
- # CloudWatch Log Groups
5420
- output "lambda_log_group_name" {
5421
- description = "Name of the Lambda CloudWatch log group"
5422
- value = aws_cloudwatch_log_group.lambda_logs.name
5639
+ # CloudWatch Log Groups, keyed by operation name
5640
+ output "lambda_log_group_names" {
5641
+ description = "Name of each operation's Lambda CloudWatch log group, keyed by operation name"
5642
+ value = { for op, lg in aws_cloudwatch_log_group.lambda_logs : op => lg.name }
5423
5643
  }
5424
5644
 
5425
- output "lambda_log_group_arn" {
5426
- description = "ARN of the Lambda CloudWatch log group"
5427
- value = aws_cloudwatch_log_group.lambda_logs.arn
5645
+ output "lambda_log_group_arns" {
5646
+ description = "ARN of each operation's Lambda CloudWatch log group, keyed by operation name"
5647
+ value = { for op, lg in aws_cloudwatch_log_group.lambda_logs : op => lg.arn }
5428
5648
  }
5429
5649
 
5430
5650
  output "api_log_group_name" {
@@ -5643,22 +5863,75 @@ resource "aws_wafv2_web_acl" "api_waf" {
5643
5863
  }
5644
5864
  }
5645
5865
 
5866
+ resource "random_id" "waf_log_suffix" {
5867
+ count = var.enable_waf ? 1 : 0
5868
+ byte_length = 4
5869
+ }
5870
+
5871
+ locals {
5872
+ waf_log_group_name = var.enable_waf ? "aws-waf-logs-\${var.api_name}-\${random_id.waf_log_suffix[0].hex}" : null
5873
+ }
5874
+
5875
+ # KMS key for encrypting WAF request logs at rest
5876
+ resource "aws_kms_key" "waf_logs" {
5877
+ count = var.enable_waf ? 1 : 0
5878
+
5879
+ description = "\${var.api_name} API WAF log encryption"
5880
+ deletion_window_in_days = 7
5881
+ enable_key_rotation = true
5882
+
5883
+ policy = jsonencode({
5884
+ Version = "2012-10-17"
5885
+ Statement = [
5886
+ {
5887
+ Sid = "Enable IAM User Permissions"
5888
+ Effect = "Allow"
5889
+ Principal = { AWS = "arn:aws:iam::\${data.aws_caller_identity.current.account_id}:root" }
5890
+ Action = "kms:*"
5891
+ Resource = "*"
5892
+ },
5893
+ {
5894
+ Sid = "Allow CloudWatch Logs"
5895
+ Effect = "Allow"
5896
+ Principal = { Service = "logs.\${data.aws_region.current.region}.amazonaws.com" }
5897
+ Action = [
5898
+ "kms:Encrypt",
5899
+ "kms:Decrypt",
5900
+ "kms:ReEncrypt*",
5901
+ "kms:GenerateDataKey*",
5902
+ "kms:DescribeKey"
5903
+ ]
5904
+ Resource = "*"
5905
+ Condition = {
5906
+ ArnEquals = {
5907
+ "kms:EncryptionContext:aws:logs:arn" = "arn:aws:logs:\${data.aws_region.current.region}:\${data.aws_caller_identity.current.account_id}:log-group:\${local.waf_log_group_name}"
5908
+ }
5909
+ }
5910
+ }
5911
+ ]
5912
+ })
5913
+
5914
+ tags = var.tags
5915
+ }
5916
+
5917
+ resource "aws_kms_alias" "waf_logs" {
5918
+ count = var.enable_waf ? 1 : 0
5919
+
5920
+ name = "alias/\${var.api_name}-\${random_id.waf_log_suffix[0].hex}-waf-logs"
5921
+ target_key_id = aws_kms_key.waf_logs[0].key_id
5922
+ }
5923
+
5646
5924
  # CloudWatch Log Group for WAF request logs. Name must start with \`aws-waf-logs-\`.
5647
5925
  resource "aws_cloudwatch_log_group" "api_waf_logs" {
5648
- #checkov:skip=CKV_AWS_158:Using default CloudWatch log encryption
5649
5926
  #checkov:skip=CKV_AWS_338:Log retention set to one month which is sufficient for WAF logs
5650
5927
  count = var.enable_waf ? 1 : 0
5651
5928
 
5652
- name = "aws-waf-logs-\${var.api_name}-\${random_id.waf_log_suffix[0].hex}"
5929
+ name = local.waf_log_group_name
5653
5930
  retention_in_days = 30
5931
+ kms_key_id = aws_kms_key.waf_logs[0].arn
5654
5932
  tags = var.tags
5655
5933
  }
5656
5934
 
5657
- resource "random_id" "waf_log_suffix" {
5658
- count = var.enable_waf ? 1 : 0
5659
- byte_length = 4
5660
- }
5661
-
5662
5935
  resource "aws_wafv2_web_acl_logging_configuration" "api_waf_logging" {
5663
5936
  count = var.enable_waf ? 1 : 0
5664
5937
 
@@ -5704,10 +5977,18 @@ output "waf_web_acl_arn" {
5704
5977
  required_version = ">= 1.0"
5705
5978
 
5706
5979
  required_providers {
5980
+ archive = {
5981
+ source = "hashicorp/archive"
5982
+ version = "2.8.0"
5983
+ }
5707
5984
  aws = {
5708
5985
  source = "hashicorp/aws"
5709
5986
  version = "6.61.0"
5710
5987
  }
5988
+ random = {
5989
+ source = "hashicorp/random"
5990
+ version = "3.9.0"
5991
+ }
5711
5992
  }
5712
5993
  }
5713
5994
 
@@ -5823,73 +6104,302 @@ resource "random_string" "suffix" {
5823
6104
  upper = false
5824
6105
  }
5825
6106
 
5826
- # Resources
6107
+ locals {
6108
+ # Generated at build time from the API definition
6109
+ operations_file = "\${path.module}/../../../generated/test-api/operations.json"
6110
+ operations = fileexists(local.operations_file) ? jsondecode(file(local.operations_file)) : {}
5827
6111
 
5828
- # Create Lambda ZIP file from the bundle directory
5829
- data "archive_file" "lambda_zip" {
5830
- type = "zip"
5831
- source_dir = "\${path.module}/../../../../../../../dist/apps/test-api/bundle"
5832
- output_path = "\${path.module}/../../../../../../../dist/packages/common/terraform/apis/test-api/lambda.zip"
5833
- }
6112
+ operation_slug = { for op in keys(local.operations) : op => replace(op, "/[^a-zA-Z0-9-_]/", "-") }
6113
+ operation_hash = { for op in keys(local.operations) : op => substr(sha256(op), 0, 8) }
5834
6114
 
5835
- resource "aws_s3_object" "lambda_zip" {
5836
- bucket = var.asset_bucket_name
5837
- key = "apis/test-api/\${data.archive_file.lambda_zip.output_sha256}.zip"
5838
- source = data.archive_file.lambda_zip.output_path
5839
- source_hash = data.archive_file.lambda_zip.output_base64sha256
5840
- etag = data.archive_file.lambda_zip.output_md5
5841
- }
6115
+ function_name = { for op in keys(local.operations) : op =>
6116
+ "\${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}"
6117
+ }
6118
+ role_name = { for op in keys(local.operations) : op =>
6119
+ "\${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}"
6120
+ }
5842
6121
 
5843
- # Use the core REST API module
5844
- # Account-level CloudWatch role required for REST API access logging
5845
- module "account" {
5846
- source = "../../../core/api/api-gateway-account"
6122
+ # A REST API needs a resource per path segment, so each operation's path is
6123
+ # split into the distinct path prefixes the resource tree is built from below
6124
+ operation_segments = { for op, details in local.operations : op => compact(split("/", details.path)) }
6125
+
6126
+ path_nodes = merge([
6127
+ for op, segments in local.operation_segments : {
6128
+ for i, segment in segments : join("/", slice(segments, 0, i + 1)) => {
6129
+ depth = i
6130
+ parent = i == 0 ? "" : join("/", slice(segments, 0, i))
6131
+ part = segment
6132
+ }
6133
+ }
6134
+ ]...)
6135
+
6136
+ # Instances of a resource cannot reference one another, so each depth is a
6137
+ # separate resource. Deeper paths are reported by the precondition below.
6138
+ max_path_depth = 16
6139
+ path_nodes_by_depth = { for depth in range(local.max_path_depth) : depth => {
6140
+ for path, node in local.path_nodes : path => node if node.depth == depth
6141
+ } }
6142
+ paths_too_deep = [for path, node in local.path_nodes : path if node.depth >= local.max_path_depth]
6143
+
6144
+ resource_ids = merge(
6145
+ { for path, resource in aws_api_gateway_resource.path_depth_0 : path => resource.id },
6146
+ { for path, resource in aws_api_gateway_resource.path_depth_1 : path => resource.id },
6147
+ { for path, resource in aws_api_gateway_resource.path_depth_2 : path => resource.id },
6148
+ { for path, resource in aws_api_gateway_resource.path_depth_3 : path => resource.id },
6149
+ { for path, resource in aws_api_gateway_resource.path_depth_4 : path => resource.id },
6150
+ { for path, resource in aws_api_gateway_resource.path_depth_5 : path => resource.id },
6151
+ { for path, resource in aws_api_gateway_resource.path_depth_6 : path => resource.id },
6152
+ { for path, resource in aws_api_gateway_resource.path_depth_7 : path => resource.id },
6153
+ { for path, resource in aws_api_gateway_resource.path_depth_8 : path => resource.id },
6154
+ { for path, resource in aws_api_gateway_resource.path_depth_9 : path => resource.id },
6155
+ { for path, resource in aws_api_gateway_resource.path_depth_10 : path => resource.id },
6156
+ { for path, resource in aws_api_gateway_resource.path_depth_11 : path => resource.id },
6157
+ { for path, resource in aws_api_gateway_resource.path_depth_12 : path => resource.id },
6158
+ { for path, resource in aws_api_gateway_resource.path_depth_13 : path => resource.id },
6159
+ { for path, resource in aws_api_gateway_resource.path_depth_14 : path => resource.id },
6160
+ { for path, resource in aws_api_gateway_resource.path_depth_15 : path => resource.id },
6161
+ )
6162
+
6163
+ operation_resource_id = { for op, segments in local.operation_segments : op =>
6164
+ local.resource_ids[join("/", segments)]
6165
+ }
6166
+
6167
+ permission_source_suffix = { for op, details in local.operations : op =>
6168
+ "\${upper(details.method)}/\${replace(join("/", local.operation_segments[op]), "/{[^}]*}/", "*")}"
6169
+ }
6170
+ }
6171
+
6172
+ resource "terraform_data" "operations_metadata" {
6173
+ # Fails the plan when the operations metadata has not been generated
6174
+ input = local.operations_file
6175
+
6176
+ lifecycle {
6177
+ precondition {
6178
+ condition = fileexists(local.operations_file)
6179
+ error_message = "Operations metadata not found at \${local.operations_file}. Build the TestApi project to generate it."
6180
+ }
6181
+ precondition {
6182
+ condition = length(local.operations) > 0
6183
+ error_message = "No operations found in \${local.operations_file}. The TestApi API must define at least one operation."
6184
+ }
6185
+ precondition {
6186
+ condition = length(local.paths_too_deep) == 0
6187
+ 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."
6188
+ }
6189
+ }
5847
6190
  }
5848
6191
 
5849
- module "rest_api" {
5850
- source = "../../../core/api/rest-api"
6192
+ # API Gateway resources at path depth 0
6193
+ resource "aws_api_gateway_resource" "path_depth_0" {
6194
+ for_each = local.path_nodes_by_depth[0]
5851
6195
 
5852
- api_name = "TestApi-\${random_string.suffix.result}"
5853
- api_description = "TestApi REST API"
5854
- stage_name = "prod"
5855
- stage_auto_deploy = true
6196
+ rest_api_id = module.rest_api.api_id
6197
+ parent_id = module.rest_api.api_root_resource_id
6198
+ path_part = each.value.part
6199
+ }
5856
6200
 
5857
- # WAF Configuration
5858
- enable_waf = var.enable_waf
6201
+ # API Gateway resources at path depth 1
6202
+ resource "aws_api_gateway_resource" "path_depth_1" {
6203
+ for_each = local.path_nodes_by_depth[1]
5859
6204
 
5860
- # CORS Configuration
5861
- cors_allow_headers = var.cors_allow_headers
5862
- cors_allow_methods = var.cors_allow_methods
5863
- cors_allow_origins = var.cors_allow_origins
6205
+ rest_api_id = module.rest_api.api_id
6206
+ parent_id = aws_api_gateway_resource.path_depth_0[each.value.parent].id
6207
+ path_part = each.value.part
6208
+ }
5864
6209
 
5865
- # Tags
5866
- tags = var.tags
6210
+ # API Gateway resources at path depth 2
6211
+ resource "aws_api_gateway_resource" "path_depth_2" {
6212
+ for_each = local.path_nodes_by_depth[2]
6213
+
6214
+ rest_api_id = module.rest_api.api_id
6215
+ parent_id = aws_api_gateway_resource.path_depth_1[each.value.parent].id
6216
+ path_part = each.value.part
5867
6217
  }
5868
6218
 
5869
- resource "aws_wafv2_web_acl_association" "api_waf_association" {
5870
- count = var.enable_waf ? 1 : 0
6219
+ # API Gateway resources at path depth 3
6220
+ resource "aws_api_gateway_resource" "path_depth_3" {
6221
+ for_each = local.path_nodes_by_depth[3]
5871
6222
 
5872
- resource_arn = aws_api_gateway_stage.api_stage.arn
5873
- web_acl_arn = module.rest_api.waf_web_acl_arn
6223
+ rest_api_id = module.rest_api.api_id
6224
+ parent_id = aws_api_gateway_resource.path_depth_2[each.value.parent].id
6225
+ path_part = each.value.part
6226
+ }
5874
6227
 
5875
- depends_on = [aws_api_gateway_stage.api_stage]
6228
+ # API Gateway resources at path depth 4
6229
+ resource "aws_api_gateway_resource" "path_depth_4" {
6230
+ for_each = local.path_nodes_by_depth[4]
6231
+
6232
+ rest_api_id = module.rest_api.api_id
6233
+ parent_id = aws_api_gateway_resource.path_depth_3[each.value.parent].id
6234
+ path_part = each.value.part
5876
6235
  }
5877
6236
 
5878
- # Security group for the API Lambda function, used when deployed into a VPC
5879
- resource "aws_security_group" "api_lambda" {
5880
- count = var.enable_vpc ? 1 : 0
6237
+ # API Gateway resources at path depth 5
6238
+ resource "aws_api_gateway_resource" "path_depth_5" {
6239
+ for_each = local.path_nodes_by_depth[5]
5881
6240
 
5882
- #checkov:skip=CKV2_AWS_5:Attached to api_lambda via vpc_config block; Checkov cannot resolve this reference
5883
- name_prefix = "test-api-lambda-"
5884
- description = "Security group for the TestApi API Lambda function"
5885
- vpc_id = var.vpc_id
5886
- tags = var.tags
6241
+ rest_api_id = module.rest_api.api_id
6242
+ parent_id = aws_api_gateway_resource.path_depth_4[each.value.parent].id
6243
+ path_part = each.value.part
5887
6244
  }
5888
6245
 
5889
- resource "aws_vpc_security_group_egress_rule" "api_lambda_https" {
5890
- count = var.enable_vpc ? 1 : 0
6246
+ # API Gateway resources at path depth 6
6247
+ resource "aws_api_gateway_resource" "path_depth_6" {
6248
+ for_each = local.path_nodes_by_depth[6]
5891
6249
 
5892
- security_group_id = aws_security_group.api_lambda[0].id
6250
+ rest_api_id = module.rest_api.api_id
6251
+ parent_id = aws_api_gateway_resource.path_depth_5[each.value.parent].id
6252
+ path_part = each.value.part
6253
+ }
6254
+
6255
+ # API Gateway resources at path depth 7
6256
+ resource "aws_api_gateway_resource" "path_depth_7" {
6257
+ for_each = local.path_nodes_by_depth[7]
6258
+
6259
+ rest_api_id = module.rest_api.api_id
6260
+ parent_id = aws_api_gateway_resource.path_depth_6[each.value.parent].id
6261
+ path_part = each.value.part
6262
+ }
6263
+
6264
+ # API Gateway resources at path depth 8
6265
+ resource "aws_api_gateway_resource" "path_depth_8" {
6266
+ for_each = local.path_nodes_by_depth[8]
6267
+
6268
+ rest_api_id = module.rest_api.api_id
6269
+ parent_id = aws_api_gateway_resource.path_depth_7[each.value.parent].id
6270
+ path_part = each.value.part
6271
+ }
6272
+
6273
+ # API Gateway resources at path depth 9
6274
+ resource "aws_api_gateway_resource" "path_depth_9" {
6275
+ for_each = local.path_nodes_by_depth[9]
6276
+
6277
+ rest_api_id = module.rest_api.api_id
6278
+ parent_id = aws_api_gateway_resource.path_depth_8[each.value.parent].id
6279
+ path_part = each.value.part
6280
+ }
6281
+
6282
+ # API Gateway resources at path depth 10
6283
+ resource "aws_api_gateway_resource" "path_depth_10" {
6284
+ for_each = local.path_nodes_by_depth[10]
6285
+
6286
+ rest_api_id = module.rest_api.api_id
6287
+ parent_id = aws_api_gateway_resource.path_depth_9[each.value.parent].id
6288
+ path_part = each.value.part
6289
+ }
6290
+
6291
+ # API Gateway resources at path depth 11
6292
+ resource "aws_api_gateway_resource" "path_depth_11" {
6293
+ for_each = local.path_nodes_by_depth[11]
6294
+
6295
+ rest_api_id = module.rest_api.api_id
6296
+ parent_id = aws_api_gateway_resource.path_depth_10[each.value.parent].id
6297
+ path_part = each.value.part
6298
+ }
6299
+
6300
+ # API Gateway resources at path depth 12
6301
+ resource "aws_api_gateway_resource" "path_depth_12" {
6302
+ for_each = local.path_nodes_by_depth[12]
6303
+
6304
+ rest_api_id = module.rest_api.api_id
6305
+ parent_id = aws_api_gateway_resource.path_depth_11[each.value.parent].id
6306
+ path_part = each.value.part
6307
+ }
6308
+
6309
+ # API Gateway resources at path depth 13
6310
+ resource "aws_api_gateway_resource" "path_depth_13" {
6311
+ for_each = local.path_nodes_by_depth[13]
6312
+
6313
+ rest_api_id = module.rest_api.api_id
6314
+ parent_id = aws_api_gateway_resource.path_depth_12[each.value.parent].id
6315
+ path_part = each.value.part
6316
+ }
6317
+
6318
+ # API Gateway resources at path depth 14
6319
+ resource "aws_api_gateway_resource" "path_depth_14" {
6320
+ for_each = local.path_nodes_by_depth[14]
6321
+
6322
+ rest_api_id = module.rest_api.api_id
6323
+ parent_id = aws_api_gateway_resource.path_depth_13[each.value.parent].id
6324
+ path_part = each.value.part
6325
+ }
6326
+
6327
+ # API Gateway resources at path depth 15
6328
+ resource "aws_api_gateway_resource" "path_depth_15" {
6329
+ for_each = local.path_nodes_by_depth[15]
6330
+
6331
+ rest_api_id = module.rest_api.api_id
6332
+ parent_id = aws_api_gateway_resource.path_depth_14[each.value.parent].id
6333
+ path_part = each.value.part
6334
+ }
6335
+
6336
+ # Resources
6337
+
6338
+ # Create Lambda ZIP file from the bundle directory
6339
+ data "archive_file" "lambda_zip" {
6340
+ type = "zip"
6341
+ source_dir = "\${path.module}/../../../../../../../dist/apps/test-api/bundle"
6342
+ output_path = "\${path.module}/../../../../../../../dist/packages/common/terraform/apis/test-api/lambda.zip"
6343
+ }
6344
+
6345
+ resource "aws_s3_object" "lambda_zip" {
6346
+ bucket = var.asset_bucket_name
6347
+ key = "apis/test-api/\${data.archive_file.lambda_zip.output_sha256}.zip"
6348
+ source = data.archive_file.lambda_zip.output_path
6349
+ source_hash = data.archive_file.lambda_zip.output_base64sha256
6350
+ etag = data.archive_file.lambda_zip.output_md5
6351
+ }
6352
+
6353
+ # Use the core REST API module
6354
+ # Account-level CloudWatch role required for REST API access logging
6355
+ module "account" {
6356
+ source = "../../../core/api/api-gateway-account"
6357
+ }
6358
+
6359
+ module "rest_api" {
6360
+ source = "../../../core/api/rest-api"
6361
+
6362
+ api_name = "TestApi-\${random_string.suffix.result}"
6363
+ api_description = "TestApi REST API"
6364
+ stage_name = "prod"
6365
+ stage_auto_deploy = true
6366
+
6367
+ # WAF Configuration
6368
+ enable_waf = var.enable_waf
6369
+
6370
+ # CORS Configuration
6371
+ cors_allow_headers = var.cors_allow_headers
6372
+ cors_allow_methods = var.cors_allow_methods
6373
+ cors_allow_origins = var.cors_allow_origins
6374
+
6375
+ # Tags
6376
+ tags = var.tags
6377
+ }
6378
+
6379
+ resource "aws_wafv2_web_acl_association" "api_waf_association" {
6380
+ count = var.enable_waf ? 1 : 0
6381
+
6382
+ resource_arn = aws_api_gateway_stage.api_stage.arn
6383
+ web_acl_arn = module.rest_api.waf_web_acl_arn
6384
+
6385
+ depends_on = [aws_api_gateway_stage.api_stage]
6386
+ }
6387
+
6388
+ # Security group for the API Lambda function, used when deployed into a VPC
6389
+ resource "aws_security_group" "api_lambda" {
6390
+ count = var.enable_vpc ? 1 : 0
6391
+
6392
+ #checkov:skip=CKV2_AWS_5:Attached to api_lambda via vpc_config block; Checkov cannot resolve this reference
6393
+ name_prefix = "test-api-lambda-"
6394
+ description = "Security group for the TestApi API Lambda function"
6395
+ vpc_id = var.vpc_id
6396
+ tags = var.tags
6397
+ }
6398
+
6399
+ resource "aws_vpc_security_group_egress_rule" "api_lambda_https" {
6400
+ count = var.enable_vpc ? 1 : 0
6401
+
6402
+ security_group_id = aws_security_group.api_lambda[0].id
5893
6403
  cidr_ipv4 = "0.0.0.0/0"
5894
6404
  from_port = 443
5895
6405
  to_port = 443
@@ -5897,8 +6407,10 @@ resource "aws_vpc_security_group_egress_rule" "api_lambda_https" {
5897
6407
  description = "Allow outbound HTTPS to AWS service endpoints"
5898
6408
  }
5899
6409
 
5900
- # Lambda function
5901
6410
  resource "aws_lambda_function" "api_lambda" {
6411
+ # One lambda function per operation, each serving just that operation
6412
+ for_each = local.operations
6413
+
5902
6414
  #checkov:skip=CKV_AWS_117:Lambda function is optionally deployed into a VPC via vpc_id/subnet_ids; not required for this use case
5903
6415
  #checkov:skip=CKV_AWS_116:Dead Letter Queue not required for this simple API use case
5904
6416
  #checkov:skip=CKV_AWS_272:Code signing not required for this use case
@@ -5907,8 +6419,8 @@ resource "aws_lambda_function" "api_lambda" {
5907
6419
  s3_bucket = aws_s3_object.lambda_zip.bucket
5908
6420
  s3_key = aws_s3_object.lambda_zip.key
5909
6421
  s3_object_version = aws_s3_object.lambda_zip.version_id
5910
- function_name = "TestApiHandler-\${random_string.suffix.result}"
5911
- role = aws_iam_role.lambda_execution_role.arn
6422
+ function_name = local.function_name[each.key]
6423
+ role = aws_iam_role.lambda_execution_role[each.key].arn
5912
6424
  handler = "index.handler"
5913
6425
  runtime = "nodejs22.x"
5914
6426
  timeout = 30
@@ -5944,7 +6456,9 @@ resource "aws_lambda_function" "api_lambda" {
5944
6456
 
5945
6457
  # IAM role for Lambda execution
5946
6458
  resource "aws_iam_role" "lambda_execution_role" {
5947
- name = "TestApiHandler-execution-role-\${random_string.suffix.result}"
6459
+ for_each = local.operations
6460
+
6461
+ name = local.role_name[each.key]
5948
6462
 
5949
6463
  assume_role_policy = jsonencode({
5950
6464
  Version = "2012-10-17"
@@ -5964,32 +6478,38 @@ resource "aws_iam_role" "lambda_execution_role" {
5964
6478
 
5965
6479
  # Attach basic execution policy to Lambda role
5966
6480
  resource "aws_iam_role_policy_attachment" "lambda_basic_execution" {
6481
+ for_each = local.operations
6482
+
5967
6483
  policy_arn = "arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole"
5968
- role = aws_iam_role.lambda_execution_role.name
6484
+ role = aws_iam_role.lambda_execution_role[each.key].name
5969
6485
  }
5970
6486
 
5971
6487
  # Attach X-Ray tracing policy to Lambda role
5972
6488
  resource "aws_iam_role_policy_attachment" "lambda_xray_execution" {
6489
+ for_each = local.operations
6490
+
5973
6491
  policy_arn = "arn:aws:iam::aws:policy/AWSXRayDaemonWriteAccess"
5974
- role = aws_iam_role.lambda_execution_role.name
6492
+ role = aws_iam_role.lambda_execution_role[each.key].name
5975
6493
  }
5976
6494
 
5977
6495
  # Attach VPC access policy to Lambda role when deployed into a VPC
5978
6496
  resource "aws_iam_role_policy_attachment" "lambda_vpc_access" {
5979
- count = var.enable_vpc ? 1 : 0
6497
+ for_each = var.enable_vpc ? local.operations : {}
6498
+
5980
6499
  policy_arn = "arn:aws:iam::aws:policy/service-role/AWSLambdaVPCAccessExecutionRole"
5981
- role = aws_iam_role.lambda_execution_role.name
6500
+ role = aws_iam_role.lambda_execution_role[each.key].name
5982
6501
  }
5983
6502
 
5984
6503
  # Additional IAM policies for Lambda (if provided)
5985
6504
  resource "aws_iam_role_policy" "lambda_additional_policies" {
5986
- count = length(local.lambda_policy_statements) > 0 ? 1 : 0
5987
- name = "TestApiHandler-additional-policies-\${random_string.suffix.result}"
5988
- role = aws_iam_role.lambda_execution_role.id
6505
+ for_each = local.operations
6506
+
6507
+ name = "\${substr(local.function_name[each.key], 0, 55)}-policies"
6508
+ role = aws_iam_role.lambda_execution_role[each.key].id
5989
6509
 
5990
6510
  policy = jsonencode({
5991
6511
  Version = "2012-10-17"
5992
- Statement = local.lambda_policy_statements
6512
+ Statement = local.lambda_policy_document_statements
5993
6513
  })
5994
6514
  }
5995
6515
 
@@ -6005,6 +6525,15 @@ locals {
6005
6525
  Resource = ["\${var.appconfig_application_arn}/*"]
6006
6526
  }
6007
6527
  ] : [], var.additional_iam_policy_statements)
6528
+
6529
+ # IAM rejects an empty statement list, so fall back to a no-op deny
6530
+ lambda_policy_document_statements = length(local.lambda_policy_statements) > 0 ? local.lambda_policy_statements : [
6531
+ {
6532
+ Effect = "Deny"
6533
+ Action = ["appconfig:GetLatestConfiguration"]
6534
+ Resource = ["arn:aws:appconfig:\${data.aws_region.current.region}:\${data.aws_caller_identity.current.account_id}:application/none"]
6535
+ }
6536
+ ]
6008
6537
  }
6009
6538
 
6010
6539
  # CloudWatch Log Group for Lambda
@@ -6012,8 +6541,10 @@ resource "aws_cloudwatch_log_group" "lambda_logs" {
6012
6541
  #checkov:skip=CKV_AWS_158:Using default CloudWatch log encryption
6013
6542
  #checkov:skip=CKV_AWS_338:Log retention set to forever
6014
6543
  #checkov:skip=CKV_AWS_66:Log retention set to forever
6015
- name = "/aws/lambda/TestApiHandler-\${random_string.suffix.result}"
6016
- tags = var.tags
6544
+ for_each = local.operations
6545
+
6546
+ name = "/aws/lambda/\${local.function_name[each.key]}"
6547
+ tags = var.tags
6017
6548
  }
6018
6549
 
6019
6550
 
@@ -6026,33 +6557,14 @@ resource "aws_api_gateway_authorizer" "cognito_authorizer" {
6026
6557
  identity_source = "method.request.header.Authorization"
6027
6558
  }
6028
6559
 
6029
- # Create proxy resource (captures all paths)
6030
- resource "aws_api_gateway_resource" "proxy_resource" {
6031
- rest_api_id = module.rest_api.api_id
6032
- parent_id = module.rest_api.api_root_resource_id
6033
- path_part = "{proxy+}"
6034
- }
6035
-
6036
- # Lambda integration for REST API
6037
- resource "aws_api_gateway_integration" "lambda_integration" {
6038
- rest_api_id = module.rest_api.api_id
6039
- resource_id = aws_api_gateway_resource.proxy_resource.id
6040
- http_method = aws_api_gateway_method.proxy_method.http_method
6041
-
6042
- integration_http_method = "POST"
6043
- type = "AWS_PROXY"
6044
- uri = aws_lambda_function.api_lambda.response_streaming_invoke_arn
6045
- response_transfer_mode = "STREAM"
6046
-
6047
- depends_on = [aws_lambda_function.api_lambda]
6048
- }
6049
-
6050
- # Method for proxy integration
6051
- resource "aws_api_gateway_method" "proxy_method" {
6560
+ # Method per operation
6561
+ resource "aws_api_gateway_method" "operation_methods" {
6052
6562
  #checkov:skip=CKV2_AWS_53:Request validation not required for proxy integration as Lambda handles validation
6563
+ for_each = local.operations
6564
+
6053
6565
  rest_api_id = module.rest_api.api_id
6054
- resource_id = aws_api_gateway_resource.proxy_resource.id
6055
- http_method = "ANY"
6566
+ resource_id = local.operation_resource_id[each.key]
6567
+ http_method = upper(each.value.method)
6056
6568
 
6057
6569
  authorization = "COGNITO_USER_POOLS"
6058
6570
  authorizer_id = aws_api_gateway_authorizer.cognito_authorizer.id
@@ -6060,28 +6572,51 @@ resource "aws_api_gateway_method" "proxy_method" {
6060
6572
  # and 'aws.cognito.signin.user.admin' (Cognito admin/SRP auth APIs).
6061
6573
  authorization_scopes = ["openid", "aws.cognito.signin.user.admin"]
6062
6574
 
6575
+ # Path parameters must be declared on the method
6063
6576
  request_parameters = {
6064
- "method.request.path.proxy" = true
6577
+ for segment in local.operation_segments[each.key] :
6578
+ "method.request.path.\${trimsuffix(trimprefix(segment, "{"), "}")}" => true
6579
+ if startswith(segment, "{")
6065
6580
  }
6066
6581
 
6067
6582
  depends_on = [aws_api_gateway_authorizer.cognito_authorizer]
6068
6583
  }
6069
6584
 
6070
- # OPTIONS method for CORS preflight
6585
+ # Lambda integration per operation
6586
+ resource "aws_api_gateway_integration" "lambda_integration" {
6587
+ for_each = local.operations
6588
+
6589
+ rest_api_id = module.rest_api.api_id
6590
+ resource_id = local.operation_resource_id[each.key]
6591
+ http_method = aws_api_gateway_method.operation_methods[each.key].http_method
6592
+
6593
+ integration_http_method = "POST"
6594
+ type = "AWS_PROXY"
6595
+ uri = aws_lambda_function.api_lambda[each.key].response_streaming_invoke_arn
6596
+ response_transfer_mode = "STREAM"
6597
+
6598
+ depends_on = [aws_lambda_function.api_lambda]
6599
+ }
6600
+
6601
+ # OPTIONS method for CORS preflight, on every resource an operation is served from
6071
6602
  resource "aws_api_gateway_method" "options_method" {
6072
6603
  #checkov:skip=CKV2_AWS_70:OPTIONS method must be unauthenticated for CORS preflight requests
6073
6604
  #checkov:skip=CKV2_AWS_53:Request validation not required for OPTIONS CORS preflight method
6605
+ for_each = local.resource_ids
6606
+
6074
6607
  rest_api_id = module.rest_api.api_id
6075
- resource_id = aws_api_gateway_resource.proxy_resource.id
6608
+ resource_id = each.value
6076
6609
  http_method = "OPTIONS"
6077
6610
  authorization = "NONE"
6078
6611
  }
6079
6612
 
6080
- # CORS integration for OPTIONS method
6613
+ # CORS integration for OPTIONS methods
6081
6614
  resource "aws_api_gateway_integration" "options_integration" {
6615
+ for_each = local.resource_ids
6616
+
6082
6617
  rest_api_id = module.rest_api.api_id
6083
- resource_id = aws_api_gateway_resource.proxy_resource.id
6084
- http_method = aws_api_gateway_method.options_method.http_method
6618
+ resource_id = each.value
6619
+ http_method = aws_api_gateway_method.options_method[each.key].http_method
6085
6620
 
6086
6621
  type = "MOCK"
6087
6622
  request_templates = {
@@ -6089,11 +6624,13 @@ resource "aws_api_gateway_integration" "options_integration" {
6089
6624
  }
6090
6625
  }
6091
6626
 
6092
- # OPTIONS method response
6627
+ # OPTIONS method responses
6093
6628
  resource "aws_api_gateway_method_response" "options_response" {
6629
+ for_each = local.resource_ids
6630
+
6094
6631
  rest_api_id = module.rest_api.api_id
6095
- resource_id = aws_api_gateway_resource.proxy_resource.id
6096
- http_method = aws_api_gateway_method.options_method.http_method
6632
+ resource_id = each.value
6633
+ http_method = aws_api_gateway_method.options_method[each.key].http_method
6097
6634
  status_code = "204"
6098
6635
 
6099
6636
  response_parameters = {
@@ -6103,18 +6640,22 @@ resource "aws_api_gateway_method_response" "options_response" {
6103
6640
  }
6104
6641
  }
6105
6642
 
6106
- # OPTIONS integration response
6643
+ # OPTIONS integration responses
6107
6644
  resource "aws_api_gateway_integration_response" "options_integration_response" {
6645
+ for_each = local.resource_ids
6646
+
6108
6647
  rest_api_id = module.rest_api.api_id
6109
- resource_id = aws_api_gateway_resource.proxy_resource.id
6110
- http_method = aws_api_gateway_method.options_method.http_method
6111
- status_code = aws_api_gateway_method_response.options_response.status_code
6648
+ resource_id = each.value
6649
+ http_method = aws_api_gateway_method.options_method[each.key].http_method
6650
+ status_code = aws_api_gateway_method_response.options_response[each.key].status_code
6112
6651
 
6113
6652
  response_parameters = {
6114
6653
  "method.response.header.Access-Control-Allow-Headers" = "'\${join(",", var.cors_allow_headers)}'"
6115
6654
  "method.response.header.Access-Control-Allow-Methods" = "'\${join(",", var.cors_allow_methods)}'"
6116
6655
  "method.response.header.Access-Control-Allow-Origin" = "'\${join(",", var.cors_allow_origins)}'"
6117
6656
  }
6657
+
6658
+ depends_on = [aws_api_gateway_integration.options_integration]
6118
6659
  }
6119
6660
 
6120
6661
  # API Gateway deployment
@@ -6122,13 +6663,28 @@ resource "aws_api_gateway_deployment" "api_deployment" {
6122
6663
  rest_api_id = module.rest_api.api_id
6123
6664
 
6124
6665
  triggers = {
6125
- redeployment = sha1(jsonencode([
6126
- aws_api_gateway_resource.proxy_resource.id,
6127
- aws_api_gateway_method.proxy_method.id,
6128
- aws_api_gateway_integration.lambda_integration.id,
6129
- aws_api_gateway_method.options_method.id,
6130
- aws_api_gateway_integration.options_integration.id,
6131
- ]))
6666
+ redeployment = sha1(jsonencode(concat(
6667
+ [for r in aws_api_gateway_resource.path_depth_0 : r.id],
6668
+ [for r in aws_api_gateway_resource.path_depth_1 : r.id],
6669
+ [for r in aws_api_gateway_resource.path_depth_2 : r.id],
6670
+ [for r in aws_api_gateway_resource.path_depth_3 : r.id],
6671
+ [for r in aws_api_gateway_resource.path_depth_4 : r.id],
6672
+ [for r in aws_api_gateway_resource.path_depth_5 : r.id],
6673
+ [for r in aws_api_gateway_resource.path_depth_6 : r.id],
6674
+ [for r in aws_api_gateway_resource.path_depth_7 : r.id],
6675
+ [for r in aws_api_gateway_resource.path_depth_8 : r.id],
6676
+ [for r in aws_api_gateway_resource.path_depth_9 : r.id],
6677
+ [for r in aws_api_gateway_resource.path_depth_10 : r.id],
6678
+ [for r in aws_api_gateway_resource.path_depth_11 : r.id],
6679
+ [for r in aws_api_gateway_resource.path_depth_12 : r.id],
6680
+ [for r in aws_api_gateway_resource.path_depth_13 : r.id],
6681
+ [for r in aws_api_gateway_resource.path_depth_14 : r.id],
6682
+ [for r in aws_api_gateway_resource.path_depth_15 : r.id],
6683
+ [for m in aws_api_gateway_method.operation_methods : m.id],
6684
+ [for i in aws_api_gateway_integration.lambda_integration : i.id],
6685
+ [for m in aws_api_gateway_method.options_method : m.id],
6686
+ [for i in aws_api_gateway_integration.options_integration : i.id],
6687
+ )))
6132
6688
  }
6133
6689
 
6134
6690
  lifecycle {
@@ -6136,7 +6692,7 @@ resource "aws_api_gateway_deployment" "api_deployment" {
6136
6692
  }
6137
6693
 
6138
6694
  depends_on = [
6139
- aws_api_gateway_method.proxy_method,
6695
+ aws_api_gateway_method.operation_methods,
6140
6696
  aws_api_gateway_integration.lambda_integration,
6141
6697
  aws_api_gateway_method.options_method,
6142
6698
  aws_api_gateway_integration.options_integration,
@@ -6264,11 +6820,13 @@ resource "aws_api_gateway_rest_api_policy" "api_policy" {
6264
6820
 
6265
6821
  # Lambda permission for API Gateway to invoke the function
6266
6822
  resource "aws_lambda_permission" "api_gateway_invoke" {
6823
+ for_each = local.operations
6824
+
6267
6825
  statement_id = "AllowExecutionFromAPIGateway"
6268
6826
  action = "lambda:InvokeFunction"
6269
- function_name = aws_lambda_function.api_lambda.function_name
6827
+ function_name = aws_lambda_function.api_lambda[each.key].function_name
6270
6828
  principal = "apigateway.amazonaws.com"
6271
- source_arn = "\${module.rest_api.api_execution_arn}/*/*"
6829
+ source_arn = "\${module.rest_api.api_execution_arn}/*/\${local.permission_source_suffix[each.key]}"
6272
6830
 
6273
6831
  depends_on = [module.rest_api, aws_lambda_function.api_lambda]
6274
6832
  }
@@ -6280,7 +6838,7 @@ module "add_url_to_runtime_config" {
6280
6838
 
6281
6839
  namespace = "connection"
6282
6840
  key = "apis"
6283
- value = { "TestApi" = aws_api_gateway_stage.api_stage.invoke_url }
6841
+ value = { "TestApi" = "\${aws_api_gateway_stage.api_stage.invoke_url}/" }
6284
6842
 
6285
6843
  depends_on = [aws_api_gateway_stage.api_stage]
6286
6844
  }
@@ -6333,83 +6891,83 @@ output "stage_id" {
6333
6891
  value = aws_api_gateway_stage.api_stage.id
6334
6892
  }
6335
6893
 
6336
- # Lambda Function Outputs
6337
- output "lambda_function_name" {
6338
- description = "Name of the Lambda function"
6339
- value = aws_lambda_function.api_lambda.function_name
6894
+ # Lambda Function Outputs, keyed by operation name
6895
+ output "operations" {
6896
+ description = "Names of the API operations, each with its own Lambda function"
6897
+ value = keys(local.operations)
6340
6898
  }
6341
6899
 
6342
- output "lambda_function_arn" {
6343
- description = "ARN of the Lambda function"
6344
- value = aws_lambda_function.api_lambda.arn
6900
+ output "lambda_function_names" {
6901
+ description = "Name of each operation's Lambda function, keyed by operation name"
6902
+ value = { for op, fn in aws_lambda_function.api_lambda : op => fn.function_name }
6345
6903
  }
6346
6904
 
6347
- output "lambda_invoke_arn" {
6348
- description = "Invoke ARN of the Lambda function"
6349
- value = aws_lambda_function.api_lambda.invoke_arn
6905
+ output "lambda_function_arns" {
6906
+ description = "ARN of each operation's Lambda function, keyed by operation name"
6907
+ value = { for op, fn in aws_lambda_function.api_lambda : op => fn.arn }
6350
6908
  }
6351
6909
 
6352
- output "lambda_qualified_arn" {
6353
- description = "Qualified ARN of the Lambda function"
6354
- value = aws_lambda_function.api_lambda.qualified_arn
6910
+ output "lambda_invoke_arns" {
6911
+ description = "Invoke ARN of each operation's Lambda function, keyed by operation name"
6912
+ value = { for op, fn in aws_lambda_function.api_lambda : op => fn.invoke_arn }
6355
6913
  }
6356
6914
 
6357
- output "lambda_version" {
6358
- description = "Version of the Lambda function"
6359
- value = aws_lambda_function.api_lambda.version
6915
+ output "lambda_qualified_arns" {
6916
+ description = "Qualified ARN of each operation's Lambda function, keyed by operation name"
6917
+ value = { for op, fn in aws_lambda_function.api_lambda : op => fn.qualified_arn }
6360
6918
  }
6361
6919
 
6362
- output "lambda_source_code_hash" {
6363
- description = "Base64-encoded SHA256 hash of the Lambda deployment package"
6364
- value = aws_lambda_function.api_lambda.source_code_hash
6920
+ output "lambda_versions" {
6921
+ description = "Version of each operation's Lambda function, keyed by operation name"
6922
+ value = { for op, fn in aws_lambda_function.api_lambda : op => fn.version }
6365
6923
  }
6366
6924
 
6367
- output "lambda_source_code_size" {
6368
- description = "Size of the Lambda deployment package in bytes"
6369
- value = aws_lambda_function.api_lambda.source_code_size
6925
+ output "lambda_source_code_hash" {
6926
+ description = "Base64-encoded SHA256 hash of the Lambda deployment package, shared by every operation"
6927
+ value = data.archive_file.lambda_zip.output_base64sha256
6370
6928
  }
6371
6929
 
6372
- # IAM Role Outputs
6373
- output "lambda_execution_role_arn" {
6374
- description = "ARN of the Lambda execution role"
6375
- value = aws_iam_role.lambda_execution_role.arn
6930
+ # IAM Role Outputs, keyed by operation name
6931
+ output "lambda_execution_role_arns" {
6932
+ description = "ARN of each operation's Lambda execution role, keyed by operation name"
6933
+ value = { for op, role in aws_iam_role.lambda_execution_role : op => role.arn }
6376
6934
  }
6377
6935
 
6378
- output "lambda_execution_role_name" {
6379
- description = "Name of the Lambda execution role"
6380
- value = aws_iam_role.lambda_execution_role.name
6936
+ output "lambda_execution_role_names" {
6937
+ description = "Name of each operation's Lambda execution role, keyed by operation name. Attach additional policies to a specific operation's role by name."
6938
+ value = { for op, role in aws_iam_role.lambda_execution_role : op => role.name }
6381
6939
  }
6382
6940
 
6383
6941
  output "security_group_id" {
6384
- 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."
6942
+ 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."
6385
6943
  value = var.enable_vpc ? aws_security_group.api_lambda[0].id : null
6386
6944
  }
6387
6945
 
6388
- # Integration Outputs
6389
- output "integration_id" {
6390
- description = "ID of the Lambda integration"
6391
- value = aws_api_gateway_integration.lambda_integration.id
6946
+ # Integration Outputs, keyed by operation name
6947
+ output "integration_ids" {
6948
+ description = "ID of each operation's Lambda integration, keyed by operation name"
6949
+ value = { for op, i in aws_api_gateway_integration.lambda_integration : op => i.id }
6392
6950
  }
6393
6951
 
6394
- output "proxy_resource_id" {
6395
- description = "ID of the proxy resource"
6396
- value = aws_api_gateway_resource.proxy_resource.id
6952
+ output "method_ids" {
6953
+ description = "ID of each operation's API Gateway method, keyed by operation name"
6954
+ value = { for op, m in aws_api_gateway_method.operation_methods : op => m.id }
6397
6955
  }
6398
6956
 
6399
- output "proxy_method_id" {
6400
- description = "ID of the proxy method"
6401
- value = aws_api_gateway_method.proxy_method.id
6957
+ output "resource_ids" {
6958
+ description = "ID of each API Gateway resource, keyed by its path"
6959
+ value = local.resource_ids
6402
6960
  }
6403
6961
 
6404
- # CloudWatch Log Groups
6405
- output "lambda_log_group_name" {
6406
- description = "Name of the Lambda CloudWatch log group"
6407
- value = aws_cloudwatch_log_group.lambda_logs.name
6962
+ # CloudWatch Log Groups, keyed by operation name
6963
+ output "lambda_log_group_names" {
6964
+ description = "Name of each operation's Lambda CloudWatch log group, keyed by operation name"
6965
+ value = { for op, lg in aws_cloudwatch_log_group.lambda_logs : op => lg.name }
6408
6966
  }
6409
6967
 
6410
- output "lambda_log_group_arn" {
6411
- description = "ARN of the Lambda CloudWatch log group"
6412
- value = aws_cloudwatch_log_group.lambda_logs.arn
6968
+ output "lambda_log_group_arns" {
6969
+ description = "ARN of each operation's Lambda CloudWatch log group, keyed by operation name"
6970
+ value = { for op, lg in aws_cloudwatch_log_group.lambda_logs : op => lg.arn }
6413
6971
  }
6414
6972
  ",
6415
6973
  }
@@ -6619,22 +7177,75 @@ resource "aws_wafv2_web_acl" "api_waf" {
6619
7177
  }
6620
7178
  }
6621
7179
 
7180
+ resource "random_id" "waf_log_suffix" {
7181
+ count = var.enable_waf ? 1 : 0
7182
+ byte_length = 4
7183
+ }
7184
+
7185
+ locals {
7186
+ waf_log_group_name = var.enable_waf ? "aws-waf-logs-\${var.api_name}-\${random_id.waf_log_suffix[0].hex}" : null
7187
+ }
7188
+
7189
+ # KMS key for encrypting WAF request logs at rest
7190
+ resource "aws_kms_key" "waf_logs" {
7191
+ count = var.enable_waf ? 1 : 0
7192
+
7193
+ description = "\${var.api_name} API WAF log encryption"
7194
+ deletion_window_in_days = 7
7195
+ enable_key_rotation = true
7196
+
7197
+ policy = jsonencode({
7198
+ Version = "2012-10-17"
7199
+ Statement = [
7200
+ {
7201
+ Sid = "Enable IAM User Permissions"
7202
+ Effect = "Allow"
7203
+ Principal = { AWS = "arn:aws:iam::\${data.aws_caller_identity.current.account_id}:root" }
7204
+ Action = "kms:*"
7205
+ Resource = "*"
7206
+ },
7207
+ {
7208
+ Sid = "Allow CloudWatch Logs"
7209
+ Effect = "Allow"
7210
+ Principal = { Service = "logs.\${data.aws_region.current.region}.amazonaws.com" }
7211
+ Action = [
7212
+ "kms:Encrypt",
7213
+ "kms:Decrypt",
7214
+ "kms:ReEncrypt*",
7215
+ "kms:GenerateDataKey*",
7216
+ "kms:DescribeKey"
7217
+ ]
7218
+ Resource = "*"
7219
+ Condition = {
7220
+ ArnEquals = {
7221
+ "kms:EncryptionContext:aws:logs:arn" = "arn:aws:logs:\${data.aws_region.current.region}:\${data.aws_caller_identity.current.account_id}:log-group:\${local.waf_log_group_name}"
7222
+ }
7223
+ }
7224
+ }
7225
+ ]
7226
+ })
7227
+
7228
+ tags = var.tags
7229
+ }
7230
+
7231
+ resource "aws_kms_alias" "waf_logs" {
7232
+ count = var.enable_waf ? 1 : 0
7233
+
7234
+ name = "alias/\${var.api_name}-\${random_id.waf_log_suffix[0].hex}-waf-logs"
7235
+ target_key_id = aws_kms_key.waf_logs[0].key_id
7236
+ }
7237
+
6622
7238
  # CloudWatch Log Group for WAF request logs. Name must start with \`aws-waf-logs-\`.
6623
7239
  resource "aws_cloudwatch_log_group" "api_waf_logs" {
6624
- #checkov:skip=CKV_AWS_158:Using default CloudWatch log encryption
6625
7240
  #checkov:skip=CKV_AWS_338:Log retention set to one month which is sufficient for WAF logs
6626
7241
  count = var.enable_waf ? 1 : 0
6627
7242
 
6628
- name = "aws-waf-logs-\${var.api_name}-\${random_id.waf_log_suffix[0].hex}"
7243
+ name = local.waf_log_group_name
6629
7244
  retention_in_days = 30
7245
+ kms_key_id = aws_kms_key.waf_logs[0].arn
6630
7246
  tags = var.tags
6631
7247
  }
6632
7248
 
6633
- resource "random_id" "waf_log_suffix" {
6634
- count = var.enable_waf ? 1 : 0
6635
- byte_length = 4
6636
- }
6637
-
6638
7249
  resource "aws_wafv2_web_acl_logging_configuration" "api_waf_logging" {
6639
7250
  count = var.enable_waf ? 1 : 0
6640
7251
 
@@ -6680,10 +7291,18 @@ output "waf_web_acl_arn" {
6680
7291
  required_version = ">= 1.0"
6681
7292
 
6682
7293
  required_providers {
7294
+ archive = {
7295
+ source = "hashicorp/archive"
7296
+ version = "2.8.0"
7297
+ }
6683
7298
  aws = {
6684
7299
  source = "hashicorp/aws"
6685
7300
  version = "6.61.0"
6686
7301
  }
7302
+ random = {
7303
+ source = "hashicorp/random"
7304
+ version = "3.9.0"
7305
+ }
6687
7306
  }
6688
7307
  }
6689
7308
 
@@ -6765,28 +7384,257 @@ variable "cors_allow_origins" {
6765
7384
  default = ["*"]
6766
7385
  }
6767
7386
 
6768
- # WAF Configuration
6769
- variable "enable_waf" {
6770
- description = "Whether to enable AWS WAFv2 with the default managed ruleset on the API stage"
6771
- type = bool
6772
- default = true
7387
+ # WAF Configuration
7388
+ variable "enable_waf" {
7389
+ description = "Whether to enable AWS WAFv2 with the default managed ruleset on the API stage"
7390
+ type = bool
7391
+ default = true
7392
+ }
7393
+
7394
+ # Tags
7395
+ variable "tags" {
7396
+ description = "Tags to apply to all resources"
7397
+ type = map(string)
7398
+ default = {}
7399
+ }
7400
+
7401
+ # Get current AWS region and account ID
7402
+ data "aws_region" "current" {}
7403
+ data "aws_caller_identity" "current" {}
7404
+
7405
+ resource "random_string" "suffix" {
7406
+ length = 8
7407
+ special = false
7408
+ upper = false
7409
+ }
7410
+
7411
+ locals {
7412
+ # Generated at build time from the API definition
7413
+ operations_file = "\${path.module}/../../../generated/test-api/operations.json"
7414
+ operations = fileexists(local.operations_file) ? jsondecode(file(local.operations_file)) : {}
7415
+
7416
+ operation_slug = { for op in keys(local.operations) : op => replace(op, "/[^a-zA-Z0-9-_]/", "-") }
7417
+ operation_hash = { for op in keys(local.operations) : op => substr(sha256(op), 0, 8) }
7418
+
7419
+ function_name = { for op in keys(local.operations) : op =>
7420
+ "\${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}"
7421
+ }
7422
+ role_name = { for op in keys(local.operations) : op =>
7423
+ "\${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}"
7424
+ }
7425
+
7426
+ # A REST API needs a resource per path segment, so each operation's path is
7427
+ # split into the distinct path prefixes the resource tree is built from below
7428
+ operation_segments = { for op, details in local.operations : op => compact(split("/", details.path)) }
7429
+
7430
+ path_nodes = merge([
7431
+ for op, segments in local.operation_segments : {
7432
+ for i, segment in segments : join("/", slice(segments, 0, i + 1)) => {
7433
+ depth = i
7434
+ parent = i == 0 ? "" : join("/", slice(segments, 0, i))
7435
+ part = segment
7436
+ }
7437
+ }
7438
+ ]...)
7439
+
7440
+ # Instances of a resource cannot reference one another, so each depth is a
7441
+ # separate resource. Deeper paths are reported by the precondition below.
7442
+ max_path_depth = 16
7443
+ path_nodes_by_depth = { for depth in range(local.max_path_depth) : depth => {
7444
+ for path, node in local.path_nodes : path => node if node.depth == depth
7445
+ } }
7446
+ paths_too_deep = [for path, node in local.path_nodes : path if node.depth >= local.max_path_depth]
7447
+
7448
+ resource_ids = merge(
7449
+ { for path, resource in aws_api_gateway_resource.path_depth_0 : path => resource.id },
7450
+ { for path, resource in aws_api_gateway_resource.path_depth_1 : path => resource.id },
7451
+ { for path, resource in aws_api_gateway_resource.path_depth_2 : path => resource.id },
7452
+ { for path, resource in aws_api_gateway_resource.path_depth_3 : path => resource.id },
7453
+ { for path, resource in aws_api_gateway_resource.path_depth_4 : path => resource.id },
7454
+ { for path, resource in aws_api_gateway_resource.path_depth_5 : path => resource.id },
7455
+ { for path, resource in aws_api_gateway_resource.path_depth_6 : path => resource.id },
7456
+ { for path, resource in aws_api_gateway_resource.path_depth_7 : path => resource.id },
7457
+ { for path, resource in aws_api_gateway_resource.path_depth_8 : path => resource.id },
7458
+ { for path, resource in aws_api_gateway_resource.path_depth_9 : path => resource.id },
7459
+ { for path, resource in aws_api_gateway_resource.path_depth_10 : path => resource.id },
7460
+ { for path, resource in aws_api_gateway_resource.path_depth_11 : path => resource.id },
7461
+ { for path, resource in aws_api_gateway_resource.path_depth_12 : path => resource.id },
7462
+ { for path, resource in aws_api_gateway_resource.path_depth_13 : path => resource.id },
7463
+ { for path, resource in aws_api_gateway_resource.path_depth_14 : path => resource.id },
7464
+ { for path, resource in aws_api_gateway_resource.path_depth_15 : path => resource.id },
7465
+ )
7466
+
7467
+ operation_resource_id = { for op, segments in local.operation_segments : op =>
7468
+ local.resource_ids[join("/", segments)]
7469
+ }
7470
+
7471
+ permission_source_suffix = { for op, details in local.operations : op =>
7472
+ "\${upper(details.method)}/\${replace(join("/", local.operation_segments[op]), "/{[^}]*}/", "*")}"
7473
+ }
7474
+ }
7475
+
7476
+ resource "terraform_data" "operations_metadata" {
7477
+ # Fails the plan when the operations metadata has not been generated
7478
+ input = local.operations_file
7479
+
7480
+ lifecycle {
7481
+ precondition {
7482
+ condition = fileexists(local.operations_file)
7483
+ error_message = "Operations metadata not found at \${local.operations_file}. Build the TestApi project to generate it."
7484
+ }
7485
+ precondition {
7486
+ condition = length(local.operations) > 0
7487
+ error_message = "No operations found in \${local.operations_file}. The TestApi API must define at least one operation."
7488
+ }
7489
+ precondition {
7490
+ condition = length(local.paths_too_deep) == 0
7491
+ 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."
7492
+ }
7493
+ }
7494
+ }
7495
+
7496
+ # API Gateway resources at path depth 0
7497
+ resource "aws_api_gateway_resource" "path_depth_0" {
7498
+ for_each = local.path_nodes_by_depth[0]
7499
+
7500
+ rest_api_id = module.rest_api.api_id
7501
+ parent_id = module.rest_api.api_root_resource_id
7502
+ path_part = each.value.part
7503
+ }
7504
+
7505
+ # API Gateway resources at path depth 1
7506
+ resource "aws_api_gateway_resource" "path_depth_1" {
7507
+ for_each = local.path_nodes_by_depth[1]
7508
+
7509
+ rest_api_id = module.rest_api.api_id
7510
+ parent_id = aws_api_gateway_resource.path_depth_0[each.value.parent].id
7511
+ path_part = each.value.part
7512
+ }
7513
+
7514
+ # API Gateway resources at path depth 2
7515
+ resource "aws_api_gateway_resource" "path_depth_2" {
7516
+ for_each = local.path_nodes_by_depth[2]
7517
+
7518
+ rest_api_id = module.rest_api.api_id
7519
+ parent_id = aws_api_gateway_resource.path_depth_1[each.value.parent].id
7520
+ path_part = each.value.part
7521
+ }
7522
+
7523
+ # API Gateway resources at path depth 3
7524
+ resource "aws_api_gateway_resource" "path_depth_3" {
7525
+ for_each = local.path_nodes_by_depth[3]
7526
+
7527
+ rest_api_id = module.rest_api.api_id
7528
+ parent_id = aws_api_gateway_resource.path_depth_2[each.value.parent].id
7529
+ path_part = each.value.part
7530
+ }
7531
+
7532
+ # API Gateway resources at path depth 4
7533
+ resource "aws_api_gateway_resource" "path_depth_4" {
7534
+ for_each = local.path_nodes_by_depth[4]
7535
+
7536
+ rest_api_id = module.rest_api.api_id
7537
+ parent_id = aws_api_gateway_resource.path_depth_3[each.value.parent].id
7538
+ path_part = each.value.part
7539
+ }
7540
+
7541
+ # API Gateway resources at path depth 5
7542
+ resource "aws_api_gateway_resource" "path_depth_5" {
7543
+ for_each = local.path_nodes_by_depth[5]
7544
+
7545
+ rest_api_id = module.rest_api.api_id
7546
+ parent_id = aws_api_gateway_resource.path_depth_4[each.value.parent].id
7547
+ path_part = each.value.part
7548
+ }
7549
+
7550
+ # API Gateway resources at path depth 6
7551
+ resource "aws_api_gateway_resource" "path_depth_6" {
7552
+ for_each = local.path_nodes_by_depth[6]
7553
+
7554
+ rest_api_id = module.rest_api.api_id
7555
+ parent_id = aws_api_gateway_resource.path_depth_5[each.value.parent].id
7556
+ path_part = each.value.part
7557
+ }
7558
+
7559
+ # API Gateway resources at path depth 7
7560
+ resource "aws_api_gateway_resource" "path_depth_7" {
7561
+ for_each = local.path_nodes_by_depth[7]
7562
+
7563
+ rest_api_id = module.rest_api.api_id
7564
+ parent_id = aws_api_gateway_resource.path_depth_6[each.value.parent].id
7565
+ path_part = each.value.part
7566
+ }
7567
+
7568
+ # API Gateway resources at path depth 8
7569
+ resource "aws_api_gateway_resource" "path_depth_8" {
7570
+ for_each = local.path_nodes_by_depth[8]
7571
+
7572
+ rest_api_id = module.rest_api.api_id
7573
+ parent_id = aws_api_gateway_resource.path_depth_7[each.value.parent].id
7574
+ path_part = each.value.part
7575
+ }
7576
+
7577
+ # API Gateway resources at path depth 9
7578
+ resource "aws_api_gateway_resource" "path_depth_9" {
7579
+ for_each = local.path_nodes_by_depth[9]
7580
+
7581
+ rest_api_id = module.rest_api.api_id
7582
+ parent_id = aws_api_gateway_resource.path_depth_8[each.value.parent].id
7583
+ path_part = each.value.part
7584
+ }
7585
+
7586
+ # API Gateway resources at path depth 10
7587
+ resource "aws_api_gateway_resource" "path_depth_10" {
7588
+ for_each = local.path_nodes_by_depth[10]
7589
+
7590
+ rest_api_id = module.rest_api.api_id
7591
+ parent_id = aws_api_gateway_resource.path_depth_9[each.value.parent].id
7592
+ path_part = each.value.part
7593
+ }
7594
+
7595
+ # API Gateway resources at path depth 11
7596
+ resource "aws_api_gateway_resource" "path_depth_11" {
7597
+ for_each = local.path_nodes_by_depth[11]
7598
+
7599
+ rest_api_id = module.rest_api.api_id
7600
+ parent_id = aws_api_gateway_resource.path_depth_10[each.value.parent].id
7601
+ path_part = each.value.part
7602
+ }
7603
+
7604
+ # API Gateway resources at path depth 12
7605
+ resource "aws_api_gateway_resource" "path_depth_12" {
7606
+ for_each = local.path_nodes_by_depth[12]
7607
+
7608
+ rest_api_id = module.rest_api.api_id
7609
+ parent_id = aws_api_gateway_resource.path_depth_11[each.value.parent].id
7610
+ path_part = each.value.part
7611
+ }
7612
+
7613
+ # API Gateway resources at path depth 13
7614
+ resource "aws_api_gateway_resource" "path_depth_13" {
7615
+ for_each = local.path_nodes_by_depth[13]
7616
+
7617
+ rest_api_id = module.rest_api.api_id
7618
+ parent_id = aws_api_gateway_resource.path_depth_12[each.value.parent].id
7619
+ path_part = each.value.part
6773
7620
  }
6774
7621
 
6775
- # Tags
6776
- variable "tags" {
6777
- description = "Tags to apply to all resources"
6778
- type = map(string)
6779
- default = {}
7622
+ # API Gateway resources at path depth 14
7623
+ resource "aws_api_gateway_resource" "path_depth_14" {
7624
+ for_each = local.path_nodes_by_depth[14]
7625
+
7626
+ rest_api_id = module.rest_api.api_id
7627
+ parent_id = aws_api_gateway_resource.path_depth_13[each.value.parent].id
7628
+ path_part = each.value.part
6780
7629
  }
6781
7630
 
6782
- # Get current AWS region and account ID
6783
- data "aws_region" "current" {}
6784
- data "aws_caller_identity" "current" {}
7631
+ # API Gateway resources at path depth 15
7632
+ resource "aws_api_gateway_resource" "path_depth_15" {
7633
+ for_each = local.path_nodes_by_depth[15]
6785
7634
 
6786
- resource "random_string" "suffix" {
6787
- length = 8
6788
- special = false
6789
- upper = false
7635
+ rest_api_id = module.rest_api.api_id
7636
+ parent_id = aws_api_gateway_resource.path_depth_14[each.value.parent].id
7637
+ path_part = each.value.part
6790
7638
  }
6791
7639
 
6792
7640
  # Resources
@@ -6863,8 +7711,10 @@ resource "aws_vpc_security_group_egress_rule" "api_lambda_https" {
6863
7711
  description = "Allow outbound HTTPS to AWS service endpoints"
6864
7712
  }
6865
7713
 
6866
- # Lambda function
6867
7714
  resource "aws_lambda_function" "api_lambda" {
7715
+ # One lambda function per operation, each serving just that operation
7716
+ for_each = local.operations
7717
+
6868
7718
  #checkov:skip=CKV_AWS_117:Lambda function is optionally deployed into a VPC via vpc_id/subnet_ids; not required for this use case
6869
7719
  #checkov:skip=CKV_AWS_116:Dead Letter Queue not required for this simple API use case
6870
7720
  #checkov:skip=CKV_AWS_272:Code signing not required for this use case
@@ -6873,8 +7723,8 @@ resource "aws_lambda_function" "api_lambda" {
6873
7723
  s3_bucket = aws_s3_object.lambda_zip.bucket
6874
7724
  s3_key = aws_s3_object.lambda_zip.key
6875
7725
  s3_object_version = aws_s3_object.lambda_zip.version_id
6876
- function_name = "TestApiHandler-\${random_string.suffix.result}"
6877
- role = aws_iam_role.lambda_execution_role.arn
7726
+ function_name = local.function_name[each.key]
7727
+ role = aws_iam_role.lambda_execution_role[each.key].arn
6878
7728
  handler = "index.handler"
6879
7729
  runtime = "nodejs22.x"
6880
7730
  timeout = 30
@@ -6910,7 +7760,9 @@ resource "aws_lambda_function" "api_lambda" {
6910
7760
 
6911
7761
  # IAM role for Lambda execution
6912
7762
  resource "aws_iam_role" "lambda_execution_role" {
6913
- name = "TestApiHandler-execution-role-\${random_string.suffix.result}"
7763
+ for_each = local.operations
7764
+
7765
+ name = local.role_name[each.key]
6914
7766
 
6915
7767
  assume_role_policy = jsonencode({
6916
7768
  Version = "2012-10-17"
@@ -6930,32 +7782,38 @@ resource "aws_iam_role" "lambda_execution_role" {
6930
7782
 
6931
7783
  # Attach basic execution policy to Lambda role
6932
7784
  resource "aws_iam_role_policy_attachment" "lambda_basic_execution" {
7785
+ for_each = local.operations
7786
+
6933
7787
  policy_arn = "arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole"
6934
- role = aws_iam_role.lambda_execution_role.name
7788
+ role = aws_iam_role.lambda_execution_role[each.key].name
6935
7789
  }
6936
7790
 
6937
7791
  # Attach X-Ray tracing policy to Lambda role
6938
7792
  resource "aws_iam_role_policy_attachment" "lambda_xray_execution" {
7793
+ for_each = local.operations
7794
+
6939
7795
  policy_arn = "arn:aws:iam::aws:policy/AWSXRayDaemonWriteAccess"
6940
- role = aws_iam_role.lambda_execution_role.name
7796
+ role = aws_iam_role.lambda_execution_role[each.key].name
6941
7797
  }
6942
7798
 
6943
7799
  # Attach VPC access policy to Lambda role when deployed into a VPC
6944
7800
  resource "aws_iam_role_policy_attachment" "lambda_vpc_access" {
6945
- count = var.enable_vpc ? 1 : 0
7801
+ for_each = var.enable_vpc ? local.operations : {}
7802
+
6946
7803
  policy_arn = "arn:aws:iam::aws:policy/service-role/AWSLambdaVPCAccessExecutionRole"
6947
- role = aws_iam_role.lambda_execution_role.name
7804
+ role = aws_iam_role.lambda_execution_role[each.key].name
6948
7805
  }
6949
7806
 
6950
7807
  # Additional IAM policies for Lambda (if provided)
6951
7808
  resource "aws_iam_role_policy" "lambda_additional_policies" {
6952
- count = length(local.lambda_policy_statements) > 0 ? 1 : 0
6953
- name = "TestApiHandler-additional-policies-\${random_string.suffix.result}"
6954
- role = aws_iam_role.lambda_execution_role.id
7809
+ for_each = local.operations
7810
+
7811
+ name = "\${substr(local.function_name[each.key], 0, 55)}-policies"
7812
+ role = aws_iam_role.lambda_execution_role[each.key].id
6955
7813
 
6956
7814
  policy = jsonencode({
6957
7815
  Version = "2012-10-17"
6958
- Statement = local.lambda_policy_statements
7816
+ Statement = local.lambda_policy_document_statements
6959
7817
  })
6960
7818
  }
6961
7819
 
@@ -6971,6 +7829,15 @@ locals {
6971
7829
  Resource = ["\${var.appconfig_application_arn}/*"]
6972
7830
  }
6973
7831
  ] : [], var.additional_iam_policy_statements)
7832
+
7833
+ # IAM rejects an empty statement list, so fall back to a no-op deny
7834
+ lambda_policy_document_statements = length(local.lambda_policy_statements) > 0 ? local.lambda_policy_statements : [
7835
+ {
7836
+ Effect = "Deny"
7837
+ Action = ["appconfig:GetLatestConfiguration"]
7838
+ Resource = ["arn:aws:appconfig:\${data.aws_region.current.region}:\${data.aws_caller_identity.current.account_id}:application/none"]
7839
+ }
7840
+ ]
6974
7841
  }
6975
7842
 
6976
7843
  # CloudWatch Log Group for Lambda
@@ -6978,64 +7845,70 @@ resource "aws_cloudwatch_log_group" "lambda_logs" {
6978
7845
  #checkov:skip=CKV_AWS_158:Using default CloudWatch log encryption
6979
7846
  #checkov:skip=CKV_AWS_338:Log retention set to forever
6980
7847
  #checkov:skip=CKV_AWS_66:Log retention set to forever
6981
- name = "/aws/lambda/TestApiHandler-\${random_string.suffix.result}"
6982
- tags = var.tags
6983
- }
6984
-
7848
+ for_each = local.operations
6985
7849
 
6986
-
6987
- # Create proxy resource (captures all paths)
6988
- resource "aws_api_gateway_resource" "proxy_resource" {
6989
- rest_api_id = module.rest_api.api_id
6990
- parent_id = module.rest_api.api_root_resource_id
6991
- path_part = "{proxy+}"
7850
+ name = "/aws/lambda/\${local.function_name[each.key]}"
7851
+ tags = var.tags
6992
7852
  }
6993
7853
 
6994
- # Lambda integration for REST API
6995
- resource "aws_api_gateway_integration" "lambda_integration" {
6996
- rest_api_id = module.rest_api.api_id
6997
- resource_id = aws_api_gateway_resource.proxy_resource.id
6998
- http_method = aws_api_gateway_method.proxy_method.http_method
6999
-
7000
- integration_http_method = "POST"
7001
- type = "AWS_PROXY"
7002
- uri = aws_lambda_function.api_lambda.response_streaming_invoke_arn
7003
- response_transfer_mode = "STREAM"
7004
7854
 
7005
- depends_on = [aws_lambda_function.api_lambda]
7006
- }
7007
7855
 
7008
- # Method for proxy integration
7009
- resource "aws_api_gateway_method" "proxy_method" {
7856
+ # Method per operation
7857
+ resource "aws_api_gateway_method" "operation_methods" {
7010
7858
  #checkov:skip=CKV2_AWS_53:Request validation not required for proxy integration as Lambda handles validation
7859
+ for_each = local.operations
7860
+
7011
7861
  rest_api_id = module.rest_api.api_id
7012
- resource_id = aws_api_gateway_resource.proxy_resource.id
7013
- http_method = "ANY"
7862
+ resource_id = local.operation_resource_id[each.key]
7863
+ http_method = upper(each.value.method)
7014
7864
 
7015
7865
  authorization = "AWS_IAM"
7016
7866
 
7867
+ # Path parameters must be declared on the method
7017
7868
  request_parameters = {
7018
- "method.request.path.proxy" = true
7869
+ for segment in local.operation_segments[each.key] :
7870
+ "method.request.path.\${trimsuffix(trimprefix(segment, "{"), "}")}" => true
7871
+ if startswith(segment, "{")
7019
7872
  }
7020
7873
 
7021
7874
  depends_on = []
7022
7875
  }
7023
7876
 
7024
- # OPTIONS method for CORS preflight
7877
+ # Lambda integration per operation
7878
+ resource "aws_api_gateway_integration" "lambda_integration" {
7879
+ for_each = local.operations
7880
+
7881
+ rest_api_id = module.rest_api.api_id
7882
+ resource_id = local.operation_resource_id[each.key]
7883
+ http_method = aws_api_gateway_method.operation_methods[each.key].http_method
7884
+
7885
+ integration_http_method = "POST"
7886
+ type = "AWS_PROXY"
7887
+ uri = aws_lambda_function.api_lambda[each.key].response_streaming_invoke_arn
7888
+ response_transfer_mode = "STREAM"
7889
+
7890
+ depends_on = [aws_lambda_function.api_lambda]
7891
+ }
7892
+
7893
+ # OPTIONS method for CORS preflight, on every resource an operation is served from
7025
7894
  resource "aws_api_gateway_method" "options_method" {
7026
7895
  #checkov:skip=CKV2_AWS_70:OPTIONS method must be unauthenticated for CORS preflight requests
7027
7896
  #checkov:skip=CKV2_AWS_53:Request validation not required for OPTIONS CORS preflight method
7897
+ for_each = local.resource_ids
7898
+
7028
7899
  rest_api_id = module.rest_api.api_id
7029
- resource_id = aws_api_gateway_resource.proxy_resource.id
7900
+ resource_id = each.value
7030
7901
  http_method = "OPTIONS"
7031
7902
  authorization = "NONE"
7032
7903
  }
7033
7904
 
7034
- # CORS integration for OPTIONS method
7905
+ # CORS integration for OPTIONS methods
7035
7906
  resource "aws_api_gateway_integration" "options_integration" {
7907
+ for_each = local.resource_ids
7908
+
7036
7909
  rest_api_id = module.rest_api.api_id
7037
- resource_id = aws_api_gateway_resource.proxy_resource.id
7038
- http_method = aws_api_gateway_method.options_method.http_method
7910
+ resource_id = each.value
7911
+ http_method = aws_api_gateway_method.options_method[each.key].http_method
7039
7912
 
7040
7913
  type = "MOCK"
7041
7914
  request_templates = {
@@ -7043,11 +7916,13 @@ resource "aws_api_gateway_integration" "options_integration" {
7043
7916
  }
7044
7917
  }
7045
7918
 
7046
- # OPTIONS method response
7919
+ # OPTIONS method responses
7047
7920
  resource "aws_api_gateway_method_response" "options_response" {
7921
+ for_each = local.resource_ids
7922
+
7048
7923
  rest_api_id = module.rest_api.api_id
7049
- resource_id = aws_api_gateway_resource.proxy_resource.id
7050
- http_method = aws_api_gateway_method.options_method.http_method
7924
+ resource_id = each.value
7925
+ http_method = aws_api_gateway_method.options_method[each.key].http_method
7051
7926
  status_code = "204"
7052
7927
 
7053
7928
  response_parameters = {
@@ -7057,18 +7932,22 @@ resource "aws_api_gateway_method_response" "options_response" {
7057
7932
  }
7058
7933
  }
7059
7934
 
7060
- # OPTIONS integration response
7935
+ # OPTIONS integration responses
7061
7936
  resource "aws_api_gateway_integration_response" "options_integration_response" {
7937
+ for_each = local.resource_ids
7938
+
7062
7939
  rest_api_id = module.rest_api.api_id
7063
- resource_id = aws_api_gateway_resource.proxy_resource.id
7064
- http_method = aws_api_gateway_method.options_method.http_method
7065
- status_code = aws_api_gateway_method_response.options_response.status_code
7940
+ resource_id = each.value
7941
+ http_method = aws_api_gateway_method.options_method[each.key].http_method
7942
+ status_code = aws_api_gateway_method_response.options_response[each.key].status_code
7066
7943
 
7067
7944
  response_parameters = {
7068
7945
  "method.response.header.Access-Control-Allow-Headers" = "'\${join(",", var.cors_allow_headers)}'"
7069
7946
  "method.response.header.Access-Control-Allow-Methods" = "'\${join(",", var.cors_allow_methods)}'"
7070
7947
  "method.response.header.Access-Control-Allow-Origin" = "'\${join(",", var.cors_allow_origins)}'"
7071
7948
  }
7949
+
7950
+ depends_on = [aws_api_gateway_integration.options_integration]
7072
7951
  }
7073
7952
 
7074
7953
  # API Gateway deployment
@@ -7076,13 +7955,28 @@ resource "aws_api_gateway_deployment" "api_deployment" {
7076
7955
  rest_api_id = module.rest_api.api_id
7077
7956
 
7078
7957
  triggers = {
7079
- redeployment = sha1(jsonencode([
7080
- aws_api_gateway_resource.proxy_resource.id,
7081
- aws_api_gateway_method.proxy_method.id,
7082
- aws_api_gateway_integration.lambda_integration.id,
7083
- aws_api_gateway_method.options_method.id,
7084
- aws_api_gateway_integration.options_integration.id,
7085
- ]))
7958
+ redeployment = sha1(jsonencode(concat(
7959
+ [for r in aws_api_gateway_resource.path_depth_0 : r.id],
7960
+ [for r in aws_api_gateway_resource.path_depth_1 : r.id],
7961
+ [for r in aws_api_gateway_resource.path_depth_2 : r.id],
7962
+ [for r in aws_api_gateway_resource.path_depth_3 : r.id],
7963
+ [for r in aws_api_gateway_resource.path_depth_4 : r.id],
7964
+ [for r in aws_api_gateway_resource.path_depth_5 : r.id],
7965
+ [for r in aws_api_gateway_resource.path_depth_6 : r.id],
7966
+ [for r in aws_api_gateway_resource.path_depth_7 : r.id],
7967
+ [for r in aws_api_gateway_resource.path_depth_8 : r.id],
7968
+ [for r in aws_api_gateway_resource.path_depth_9 : r.id],
7969
+ [for r in aws_api_gateway_resource.path_depth_10 : r.id],
7970
+ [for r in aws_api_gateway_resource.path_depth_11 : r.id],
7971
+ [for r in aws_api_gateway_resource.path_depth_12 : r.id],
7972
+ [for r in aws_api_gateway_resource.path_depth_13 : r.id],
7973
+ [for r in aws_api_gateway_resource.path_depth_14 : r.id],
7974
+ [for r in aws_api_gateway_resource.path_depth_15 : r.id],
7975
+ [for m in aws_api_gateway_method.operation_methods : m.id],
7976
+ [for i in aws_api_gateway_integration.lambda_integration : i.id],
7977
+ [for m in aws_api_gateway_method.options_method : m.id],
7978
+ [for i in aws_api_gateway_integration.options_integration : i.id],
7979
+ )))
7086
7980
  }
7087
7981
 
7088
7982
  lifecycle {
@@ -7090,7 +7984,7 @@ resource "aws_api_gateway_deployment" "api_deployment" {
7090
7984
  }
7091
7985
 
7092
7986
  depends_on = [
7093
- aws_api_gateway_method.proxy_method,
7987
+ aws_api_gateway_method.operation_methods,
7094
7988
  aws_api_gateway_integration.lambda_integration,
7095
7989
  aws_api_gateway_method.options_method,
7096
7990
  aws_api_gateway_integration.options_integration,
@@ -7229,11 +8123,13 @@ resource "aws_api_gateway_rest_api_policy" "api_policy" {
7229
8123
 
7230
8124
  # Lambda permission for API Gateway to invoke the function
7231
8125
  resource "aws_lambda_permission" "api_gateway_invoke" {
8126
+ for_each = local.operations
8127
+
7232
8128
  statement_id = "AllowExecutionFromAPIGateway"
7233
8129
  action = "lambda:InvokeFunction"
7234
- function_name = aws_lambda_function.api_lambda.function_name
8130
+ function_name = aws_lambda_function.api_lambda[each.key].function_name
7235
8131
  principal = "apigateway.amazonaws.com"
7236
- source_arn = "\${module.rest_api.api_execution_arn}/*/*"
8132
+ source_arn = "\${module.rest_api.api_execution_arn}/*/\${local.permission_source_suffix[each.key]}"
7237
8133
 
7238
8134
  depends_on = [module.rest_api, aws_lambda_function.api_lambda]
7239
8135
  }
@@ -7245,7 +8141,7 @@ module "add_url_to_runtime_config" {
7245
8141
 
7246
8142
  namespace = "connection"
7247
8143
  key = "apis"
7248
- value = { "TestApi" = aws_api_gateway_stage.api_stage.invoke_url }
8144
+ value = { "TestApi" = "\${aws_api_gateway_stage.api_stage.invoke_url}/" }
7249
8145
 
7250
8146
  depends_on = [aws_api_gateway_stage.api_stage]
7251
8147
  }
@@ -7298,83 +8194,83 @@ output "stage_id" {
7298
8194
  value = aws_api_gateway_stage.api_stage.id
7299
8195
  }
7300
8196
 
7301
- # Lambda Function Outputs
7302
- output "lambda_function_name" {
7303
- description = "Name of the Lambda function"
7304
- value = aws_lambda_function.api_lambda.function_name
8197
+ # Lambda Function Outputs, keyed by operation name
8198
+ output "operations" {
8199
+ description = "Names of the API operations, each with its own Lambda function"
8200
+ value = keys(local.operations)
7305
8201
  }
7306
8202
 
7307
- output "lambda_function_arn" {
7308
- description = "ARN of the Lambda function"
7309
- value = aws_lambda_function.api_lambda.arn
8203
+ output "lambda_function_names" {
8204
+ description = "Name of each operation's Lambda function, keyed by operation name"
8205
+ value = { for op, fn in aws_lambda_function.api_lambda : op => fn.function_name }
7310
8206
  }
7311
8207
 
7312
- output "lambda_invoke_arn" {
7313
- description = "Invoke ARN of the Lambda function"
7314
- value = aws_lambda_function.api_lambda.invoke_arn
8208
+ output "lambda_function_arns" {
8209
+ description = "ARN of each operation's Lambda function, keyed by operation name"
8210
+ value = { for op, fn in aws_lambda_function.api_lambda : op => fn.arn }
7315
8211
  }
7316
8212
 
7317
- output "lambda_qualified_arn" {
7318
- description = "Qualified ARN of the Lambda function"
7319
- value = aws_lambda_function.api_lambda.qualified_arn
8213
+ output "lambda_invoke_arns" {
8214
+ description = "Invoke ARN of each operation's Lambda function, keyed by operation name"
8215
+ value = { for op, fn in aws_lambda_function.api_lambda : op => fn.invoke_arn }
7320
8216
  }
7321
8217
 
7322
- output "lambda_version" {
7323
- description = "Version of the Lambda function"
7324
- value = aws_lambda_function.api_lambda.version
8218
+ output "lambda_qualified_arns" {
8219
+ description = "Qualified ARN of each operation's Lambda function, keyed by operation name"
8220
+ value = { for op, fn in aws_lambda_function.api_lambda : op => fn.qualified_arn }
7325
8221
  }
7326
8222
 
7327
- output "lambda_source_code_hash" {
7328
- description = "Base64-encoded SHA256 hash of the Lambda deployment package"
7329
- value = aws_lambda_function.api_lambda.source_code_hash
8223
+ output "lambda_versions" {
8224
+ description = "Version of each operation's Lambda function, keyed by operation name"
8225
+ value = { for op, fn in aws_lambda_function.api_lambda : op => fn.version }
7330
8226
  }
7331
8227
 
7332
- output "lambda_source_code_size" {
7333
- description = "Size of the Lambda deployment package in bytes"
7334
- value = aws_lambda_function.api_lambda.source_code_size
8228
+ output "lambda_source_code_hash" {
8229
+ description = "Base64-encoded SHA256 hash of the Lambda deployment package, shared by every operation"
8230
+ value = data.archive_file.lambda_zip.output_base64sha256
7335
8231
  }
7336
8232
 
7337
- # IAM Role Outputs
7338
- output "lambda_execution_role_arn" {
7339
- description = "ARN of the Lambda execution role"
7340
- value = aws_iam_role.lambda_execution_role.arn
8233
+ # IAM Role Outputs, keyed by operation name
8234
+ output "lambda_execution_role_arns" {
8235
+ description = "ARN of each operation's Lambda execution role, keyed by operation name"
8236
+ value = { for op, role in aws_iam_role.lambda_execution_role : op => role.arn }
7341
8237
  }
7342
8238
 
7343
- output "lambda_execution_role_name" {
7344
- description = "Name of the Lambda execution role"
7345
- value = aws_iam_role.lambda_execution_role.name
8239
+ output "lambda_execution_role_names" {
8240
+ description = "Name of each operation's Lambda execution role, keyed by operation name. Attach additional policies to a specific operation's role by name."
8241
+ value = { for op, role in aws_iam_role.lambda_execution_role : op => role.name }
7346
8242
  }
7347
8243
 
7348
8244
  output "security_group_id" {
7349
- 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."
8245
+ 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."
7350
8246
  value = var.enable_vpc ? aws_security_group.api_lambda[0].id : null
7351
8247
  }
7352
8248
 
7353
- # Integration Outputs
7354
- output "integration_id" {
7355
- description = "ID of the Lambda integration"
7356
- value = aws_api_gateway_integration.lambda_integration.id
8249
+ # Integration Outputs, keyed by operation name
8250
+ output "integration_ids" {
8251
+ description = "ID of each operation's Lambda integration, keyed by operation name"
8252
+ value = { for op, i in aws_api_gateway_integration.lambda_integration : op => i.id }
7357
8253
  }
7358
8254
 
7359
- output "proxy_resource_id" {
7360
- description = "ID of the proxy resource"
7361
- value = aws_api_gateway_resource.proxy_resource.id
8255
+ output "method_ids" {
8256
+ description = "ID of each operation's API Gateway method, keyed by operation name"
8257
+ value = { for op, m in aws_api_gateway_method.operation_methods : op => m.id }
7362
8258
  }
7363
8259
 
7364
- output "proxy_method_id" {
7365
- description = "ID of the proxy method"
7366
- value = aws_api_gateway_method.proxy_method.id
8260
+ output "resource_ids" {
8261
+ description = "ID of each API Gateway resource, keyed by its path"
8262
+ value = local.resource_ids
7367
8263
  }
7368
8264
 
7369
- # CloudWatch Log Groups
7370
- output "lambda_log_group_name" {
7371
- description = "Name of the Lambda CloudWatch log group"
7372
- value = aws_cloudwatch_log_group.lambda_logs.name
8265
+ # CloudWatch Log Groups, keyed by operation name
8266
+ output "lambda_log_group_names" {
8267
+ description = "Name of each operation's Lambda CloudWatch log group, keyed by operation name"
8268
+ value = { for op, lg in aws_cloudwatch_log_group.lambda_logs : op => lg.name }
7373
8269
  }
7374
8270
 
7375
- output "lambda_log_group_arn" {
7376
- description = "ARN of the Lambda CloudWatch log group"
7377
- value = aws_cloudwatch_log_group.lambda_logs.arn
8271
+ output "lambda_log_group_arns" {
8272
+ description = "ARN of each operation's Lambda CloudWatch log group, keyed by operation name"
8273
+ value = { for op, lg in aws_cloudwatch_log_group.lambda_logs : op => lg.arn }
7378
8274
  }
7379
8275
  ",
7380
8276
  }
@@ -7584,22 +8480,75 @@ resource "aws_wafv2_web_acl" "api_waf" {
7584
8480
  }
7585
8481
  }
7586
8482
 
8483
+ resource "random_id" "waf_log_suffix" {
8484
+ count = var.enable_waf ? 1 : 0
8485
+ byte_length = 4
8486
+ }
8487
+
8488
+ locals {
8489
+ waf_log_group_name = var.enable_waf ? "aws-waf-logs-\${var.api_name}-\${random_id.waf_log_suffix[0].hex}" : null
8490
+ }
8491
+
8492
+ # KMS key for encrypting WAF request logs at rest
8493
+ resource "aws_kms_key" "waf_logs" {
8494
+ count = var.enable_waf ? 1 : 0
8495
+
8496
+ description = "\${var.api_name} API WAF log encryption"
8497
+ deletion_window_in_days = 7
8498
+ enable_key_rotation = true
8499
+
8500
+ policy = jsonencode({
8501
+ Version = "2012-10-17"
8502
+ Statement = [
8503
+ {
8504
+ Sid = "Enable IAM User Permissions"
8505
+ Effect = "Allow"
8506
+ Principal = { AWS = "arn:aws:iam::\${data.aws_caller_identity.current.account_id}:root" }
8507
+ Action = "kms:*"
8508
+ Resource = "*"
8509
+ },
8510
+ {
8511
+ Sid = "Allow CloudWatch Logs"
8512
+ Effect = "Allow"
8513
+ Principal = { Service = "logs.\${data.aws_region.current.region}.amazonaws.com" }
8514
+ Action = [
8515
+ "kms:Encrypt",
8516
+ "kms:Decrypt",
8517
+ "kms:ReEncrypt*",
8518
+ "kms:GenerateDataKey*",
8519
+ "kms:DescribeKey"
8520
+ ]
8521
+ Resource = "*"
8522
+ Condition = {
8523
+ ArnEquals = {
8524
+ "kms:EncryptionContext:aws:logs:arn" = "arn:aws:logs:\${data.aws_region.current.region}:\${data.aws_caller_identity.current.account_id}:log-group:\${local.waf_log_group_name}"
8525
+ }
8526
+ }
8527
+ }
8528
+ ]
8529
+ })
8530
+
8531
+ tags = var.tags
8532
+ }
8533
+
8534
+ resource "aws_kms_alias" "waf_logs" {
8535
+ count = var.enable_waf ? 1 : 0
8536
+
8537
+ name = "alias/\${var.api_name}-\${random_id.waf_log_suffix[0].hex}-waf-logs"
8538
+ target_key_id = aws_kms_key.waf_logs[0].key_id
8539
+ }
8540
+
7587
8541
  # CloudWatch Log Group for WAF request logs. Name must start with \`aws-waf-logs-\`.
7588
8542
  resource "aws_cloudwatch_log_group" "api_waf_logs" {
7589
- #checkov:skip=CKV_AWS_158:Using default CloudWatch log encryption
7590
8543
  #checkov:skip=CKV_AWS_338:Log retention set to one month which is sufficient for WAF logs
7591
8544
  count = var.enable_waf ? 1 : 0
7592
8545
 
7593
- name = "aws-waf-logs-\${var.api_name}-\${random_id.waf_log_suffix[0].hex}"
8546
+ name = local.waf_log_group_name
7594
8547
  retention_in_days = 30
8548
+ kms_key_id = aws_kms_key.waf_logs[0].arn
7595
8549
  tags = var.tags
7596
8550
  }
7597
8551
 
7598
- resource "random_id" "waf_log_suffix" {
7599
- count = var.enable_waf ? 1 : 0
7600
- byte_length = 4
7601
- }
7602
-
7603
8552
  resource "aws_wafv2_web_acl_logging_configuration" "api_waf_logging" {
7604
8553
  count = var.enable_waf ? 1 : 0
7605
8554
 
@@ -7645,10 +8594,18 @@ output "waf_web_acl_arn" {
7645
8594
  required_version = ">= 1.0"
7646
8595
 
7647
8596
  required_providers {
8597
+ archive = {
8598
+ source = "hashicorp/archive"
8599
+ version = "2.8.0"
8600
+ }
7648
8601
  aws = {
7649
8602
  source = "hashicorp/aws"
7650
8603
  version = "6.61.0"
7651
8604
  }
8605
+ random = {
8606
+ source = "hashicorp/random"
8607
+ version = "3.9.0"
8608
+ }
7652
8609
  }
7653
8610
  }
7654
8611
 
@@ -7754,6 +8711,235 @@ resource "random_string" "suffix" {
7754
8711
  upper = false
7755
8712
  }
7756
8713
 
8714
+ locals {
8715
+ # Generated at build time from the API definition
8716
+ operations_file = "\${path.module}/../../../generated/test-api/operations.json"
8717
+ operations = fileexists(local.operations_file) ? jsondecode(file(local.operations_file)) : {}
8718
+
8719
+ operation_slug = { for op in keys(local.operations) : op => replace(op, "/[^a-zA-Z0-9-_]/", "-") }
8720
+ operation_hash = { for op in keys(local.operations) : op => substr(sha256(op), 0, 8) }
8721
+
8722
+ function_name = { for op in keys(local.operations) : op =>
8723
+ "\${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}"
8724
+ }
8725
+ role_name = { for op in keys(local.operations) : op =>
8726
+ "\${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}"
8727
+ }
8728
+
8729
+ # A REST API needs a resource per path segment, so each operation's path is
8730
+ # split into the distinct path prefixes the resource tree is built from below
8731
+ operation_segments = { for op, details in local.operations : op => compact(split("/", details.path)) }
8732
+
8733
+ path_nodes = merge([
8734
+ for op, segments in local.operation_segments : {
8735
+ for i, segment in segments : join("/", slice(segments, 0, i + 1)) => {
8736
+ depth = i
8737
+ parent = i == 0 ? "" : join("/", slice(segments, 0, i))
8738
+ part = segment
8739
+ }
8740
+ }
8741
+ ]...)
8742
+
8743
+ # Instances of a resource cannot reference one another, so each depth is a
8744
+ # separate resource. Deeper paths are reported by the precondition below.
8745
+ max_path_depth = 16
8746
+ path_nodes_by_depth = { for depth in range(local.max_path_depth) : depth => {
8747
+ for path, node in local.path_nodes : path => node if node.depth == depth
8748
+ } }
8749
+ paths_too_deep = [for path, node in local.path_nodes : path if node.depth >= local.max_path_depth]
8750
+
8751
+ resource_ids = merge(
8752
+ { for path, resource in aws_api_gateway_resource.path_depth_0 : path => resource.id },
8753
+ { for path, resource in aws_api_gateway_resource.path_depth_1 : path => resource.id },
8754
+ { for path, resource in aws_api_gateway_resource.path_depth_2 : path => resource.id },
8755
+ { for path, resource in aws_api_gateway_resource.path_depth_3 : path => resource.id },
8756
+ { for path, resource in aws_api_gateway_resource.path_depth_4 : path => resource.id },
8757
+ { for path, resource in aws_api_gateway_resource.path_depth_5 : path => resource.id },
8758
+ { for path, resource in aws_api_gateway_resource.path_depth_6 : path => resource.id },
8759
+ { for path, resource in aws_api_gateway_resource.path_depth_7 : path => resource.id },
8760
+ { for path, resource in aws_api_gateway_resource.path_depth_8 : path => resource.id },
8761
+ { for path, resource in aws_api_gateway_resource.path_depth_9 : path => resource.id },
8762
+ { for path, resource in aws_api_gateway_resource.path_depth_10 : path => resource.id },
8763
+ { for path, resource in aws_api_gateway_resource.path_depth_11 : path => resource.id },
8764
+ { for path, resource in aws_api_gateway_resource.path_depth_12 : path => resource.id },
8765
+ { for path, resource in aws_api_gateway_resource.path_depth_13 : path => resource.id },
8766
+ { for path, resource in aws_api_gateway_resource.path_depth_14 : path => resource.id },
8767
+ { for path, resource in aws_api_gateway_resource.path_depth_15 : path => resource.id },
8768
+ )
8769
+
8770
+ operation_resource_id = { for op, segments in local.operation_segments : op =>
8771
+ local.resource_ids[join("/", segments)]
8772
+ }
8773
+
8774
+ permission_source_suffix = { for op, details in local.operations : op =>
8775
+ "\${upper(details.method)}/\${replace(join("/", local.operation_segments[op]), "/{[^}]*}/", "*")}"
8776
+ }
8777
+ }
8778
+
8779
+ resource "terraform_data" "operations_metadata" {
8780
+ # Fails the plan when the operations metadata has not been generated
8781
+ input = local.operations_file
8782
+
8783
+ lifecycle {
8784
+ precondition {
8785
+ condition = fileexists(local.operations_file)
8786
+ error_message = "Operations metadata not found at \${local.operations_file}. Build the TestApi project to generate it."
8787
+ }
8788
+ precondition {
8789
+ condition = length(local.operations) > 0
8790
+ error_message = "No operations found in \${local.operations_file}. The TestApi API must define at least one operation."
8791
+ }
8792
+ precondition {
8793
+ condition = length(local.paths_too_deep) == 0
8794
+ 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."
8795
+ }
8796
+ }
8797
+ }
8798
+
8799
+ # API Gateway resources at path depth 0
8800
+ resource "aws_api_gateway_resource" "path_depth_0" {
8801
+ for_each = local.path_nodes_by_depth[0]
8802
+
8803
+ rest_api_id = module.rest_api.api_id
8804
+ parent_id = module.rest_api.api_root_resource_id
8805
+ path_part = each.value.part
8806
+ }
8807
+
8808
+ # API Gateway resources at path depth 1
8809
+ resource "aws_api_gateway_resource" "path_depth_1" {
8810
+ for_each = local.path_nodes_by_depth[1]
8811
+
8812
+ rest_api_id = module.rest_api.api_id
8813
+ parent_id = aws_api_gateway_resource.path_depth_0[each.value.parent].id
8814
+ path_part = each.value.part
8815
+ }
8816
+
8817
+ # API Gateway resources at path depth 2
8818
+ resource "aws_api_gateway_resource" "path_depth_2" {
8819
+ for_each = local.path_nodes_by_depth[2]
8820
+
8821
+ rest_api_id = module.rest_api.api_id
8822
+ parent_id = aws_api_gateway_resource.path_depth_1[each.value.parent].id
8823
+ path_part = each.value.part
8824
+ }
8825
+
8826
+ # API Gateway resources at path depth 3
8827
+ resource "aws_api_gateway_resource" "path_depth_3" {
8828
+ for_each = local.path_nodes_by_depth[3]
8829
+
8830
+ rest_api_id = module.rest_api.api_id
8831
+ parent_id = aws_api_gateway_resource.path_depth_2[each.value.parent].id
8832
+ path_part = each.value.part
8833
+ }
8834
+
8835
+ # API Gateway resources at path depth 4
8836
+ resource "aws_api_gateway_resource" "path_depth_4" {
8837
+ for_each = local.path_nodes_by_depth[4]
8838
+
8839
+ rest_api_id = module.rest_api.api_id
8840
+ parent_id = aws_api_gateway_resource.path_depth_3[each.value.parent].id
8841
+ path_part = each.value.part
8842
+ }
8843
+
8844
+ # API Gateway resources at path depth 5
8845
+ resource "aws_api_gateway_resource" "path_depth_5" {
8846
+ for_each = local.path_nodes_by_depth[5]
8847
+
8848
+ rest_api_id = module.rest_api.api_id
8849
+ parent_id = aws_api_gateway_resource.path_depth_4[each.value.parent].id
8850
+ path_part = each.value.part
8851
+ }
8852
+
8853
+ # API Gateway resources at path depth 6
8854
+ resource "aws_api_gateway_resource" "path_depth_6" {
8855
+ for_each = local.path_nodes_by_depth[6]
8856
+
8857
+ rest_api_id = module.rest_api.api_id
8858
+ parent_id = aws_api_gateway_resource.path_depth_5[each.value.parent].id
8859
+ path_part = each.value.part
8860
+ }
8861
+
8862
+ # API Gateway resources at path depth 7
8863
+ resource "aws_api_gateway_resource" "path_depth_7" {
8864
+ for_each = local.path_nodes_by_depth[7]
8865
+
8866
+ rest_api_id = module.rest_api.api_id
8867
+ parent_id = aws_api_gateway_resource.path_depth_6[each.value.parent].id
8868
+ path_part = each.value.part
8869
+ }
8870
+
8871
+ # API Gateway resources at path depth 8
8872
+ resource "aws_api_gateway_resource" "path_depth_8" {
8873
+ for_each = local.path_nodes_by_depth[8]
8874
+
8875
+ rest_api_id = module.rest_api.api_id
8876
+ parent_id = aws_api_gateway_resource.path_depth_7[each.value.parent].id
8877
+ path_part = each.value.part
8878
+ }
8879
+
8880
+ # API Gateway resources at path depth 9
8881
+ resource "aws_api_gateway_resource" "path_depth_9" {
8882
+ for_each = local.path_nodes_by_depth[9]
8883
+
8884
+ rest_api_id = module.rest_api.api_id
8885
+ parent_id = aws_api_gateway_resource.path_depth_8[each.value.parent].id
8886
+ path_part = each.value.part
8887
+ }
8888
+
8889
+ # API Gateway resources at path depth 10
8890
+ resource "aws_api_gateway_resource" "path_depth_10" {
8891
+ for_each = local.path_nodes_by_depth[10]
8892
+
8893
+ rest_api_id = module.rest_api.api_id
8894
+ parent_id = aws_api_gateway_resource.path_depth_9[each.value.parent].id
8895
+ path_part = each.value.part
8896
+ }
8897
+
8898
+ # API Gateway resources at path depth 11
8899
+ resource "aws_api_gateway_resource" "path_depth_11" {
8900
+ for_each = local.path_nodes_by_depth[11]
8901
+
8902
+ rest_api_id = module.rest_api.api_id
8903
+ parent_id = aws_api_gateway_resource.path_depth_10[each.value.parent].id
8904
+ path_part = each.value.part
8905
+ }
8906
+
8907
+ # API Gateway resources at path depth 12
8908
+ resource "aws_api_gateway_resource" "path_depth_12" {
8909
+ for_each = local.path_nodes_by_depth[12]
8910
+
8911
+ rest_api_id = module.rest_api.api_id
8912
+ parent_id = aws_api_gateway_resource.path_depth_11[each.value.parent].id
8913
+ path_part = each.value.part
8914
+ }
8915
+
8916
+ # API Gateway resources at path depth 13
8917
+ resource "aws_api_gateway_resource" "path_depth_13" {
8918
+ for_each = local.path_nodes_by_depth[13]
8919
+
8920
+ rest_api_id = module.rest_api.api_id
8921
+ parent_id = aws_api_gateway_resource.path_depth_12[each.value.parent].id
8922
+ path_part = each.value.part
8923
+ }
8924
+
8925
+ # API Gateway resources at path depth 14
8926
+ resource "aws_api_gateway_resource" "path_depth_14" {
8927
+ for_each = local.path_nodes_by_depth[14]
8928
+
8929
+ rest_api_id = module.rest_api.api_id
8930
+ parent_id = aws_api_gateway_resource.path_depth_13[each.value.parent].id
8931
+ path_part = each.value.part
8932
+ }
8933
+
8934
+ # API Gateway resources at path depth 15
8935
+ resource "aws_api_gateway_resource" "path_depth_15" {
8936
+ for_each = local.path_nodes_by_depth[15]
8937
+
8938
+ rest_api_id = module.rest_api.api_id
8939
+ parent_id = aws_api_gateway_resource.path_depth_14[each.value.parent].id
8940
+ path_part = each.value.part
8941
+ }
8942
+
7757
8943
  # Resources
7758
8944
 
7759
8945
  # Create Lambda ZIP file from the bundle directory
@@ -7828,8 +9014,10 @@ resource "aws_vpc_security_group_egress_rule" "api_lambda_https" {
7828
9014
  description = "Allow outbound HTTPS to AWS service endpoints"
7829
9015
  }
7830
9016
 
7831
- # Lambda function
7832
9017
  resource "aws_lambda_function" "api_lambda" {
9018
+ # One lambda function per operation, each serving just that operation
9019
+ for_each = local.operations
9020
+
7833
9021
  #checkov:skip=CKV_AWS_117:Lambda function is optionally deployed into a VPC via vpc_id/subnet_ids; not required for this use case
7834
9022
  #checkov:skip=CKV_AWS_116:Dead Letter Queue not required for this simple API use case
7835
9023
  #checkov:skip=CKV_AWS_272:Code signing not required for this use case
@@ -7838,8 +9026,8 @@ resource "aws_lambda_function" "api_lambda" {
7838
9026
  s3_bucket = aws_s3_object.lambda_zip.bucket
7839
9027
  s3_key = aws_s3_object.lambda_zip.key
7840
9028
  s3_object_version = aws_s3_object.lambda_zip.version_id
7841
- function_name = "TestApiHandler-\${random_string.suffix.result}"
7842
- role = aws_iam_role.lambda_execution_role.arn
9029
+ function_name = local.function_name[each.key]
9030
+ role = aws_iam_role.lambda_execution_role[each.key].arn
7843
9031
  handler = "index.handler"
7844
9032
  runtime = "nodejs22.x"
7845
9033
  timeout = 30
@@ -7875,7 +9063,9 @@ resource "aws_lambda_function" "api_lambda" {
7875
9063
 
7876
9064
  # IAM role for Lambda execution
7877
9065
  resource "aws_iam_role" "lambda_execution_role" {
7878
- name = "TestApiHandler-execution-role-\${random_string.suffix.result}"
9066
+ for_each = local.operations
9067
+
9068
+ name = local.role_name[each.key]
7879
9069
 
7880
9070
  assume_role_policy = jsonencode({
7881
9071
  Version = "2012-10-17"
@@ -7895,32 +9085,38 @@ resource "aws_iam_role" "lambda_execution_role" {
7895
9085
 
7896
9086
  # Attach basic execution policy to Lambda role
7897
9087
  resource "aws_iam_role_policy_attachment" "lambda_basic_execution" {
9088
+ for_each = local.operations
9089
+
7898
9090
  policy_arn = "arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole"
7899
- role = aws_iam_role.lambda_execution_role.name
9091
+ role = aws_iam_role.lambda_execution_role[each.key].name
7900
9092
  }
7901
9093
 
7902
9094
  # Attach X-Ray tracing policy to Lambda role
7903
9095
  resource "aws_iam_role_policy_attachment" "lambda_xray_execution" {
9096
+ for_each = local.operations
9097
+
7904
9098
  policy_arn = "arn:aws:iam::aws:policy/AWSXRayDaemonWriteAccess"
7905
- role = aws_iam_role.lambda_execution_role.name
9099
+ role = aws_iam_role.lambda_execution_role[each.key].name
7906
9100
  }
7907
9101
 
7908
9102
  # Attach VPC access policy to Lambda role when deployed into a VPC
7909
9103
  resource "aws_iam_role_policy_attachment" "lambda_vpc_access" {
7910
- count = var.enable_vpc ? 1 : 0
9104
+ for_each = var.enable_vpc ? local.operations : {}
9105
+
7911
9106
  policy_arn = "arn:aws:iam::aws:policy/service-role/AWSLambdaVPCAccessExecutionRole"
7912
- role = aws_iam_role.lambda_execution_role.name
9107
+ role = aws_iam_role.lambda_execution_role[each.key].name
7913
9108
  }
7914
9109
 
7915
9110
  # Additional IAM policies for Lambda (if provided)
7916
9111
  resource "aws_iam_role_policy" "lambda_additional_policies" {
7917
- count = length(local.lambda_policy_statements) > 0 ? 1 : 0
7918
- name = "TestApiHandler-additional-policies-\${random_string.suffix.result}"
7919
- role = aws_iam_role.lambda_execution_role.id
9112
+ for_each = local.operations
9113
+
9114
+ name = "\${substr(local.function_name[each.key], 0, 55)}-policies"
9115
+ role = aws_iam_role.lambda_execution_role[each.key].id
7920
9116
 
7921
9117
  policy = jsonencode({
7922
9118
  Version = "2012-10-17"
7923
- Statement = local.lambda_policy_statements
9119
+ Statement = local.lambda_policy_document_statements
7924
9120
  })
7925
9121
  }
7926
9122
 
@@ -7936,6 +9132,15 @@ locals {
7936
9132
  Resource = ["\${var.appconfig_application_arn}/*"]
7937
9133
  }
7938
9134
  ] : [], var.additional_iam_policy_statements)
9135
+
9136
+ # IAM rejects an empty statement list, so fall back to a no-op deny
9137
+ lambda_policy_document_statements = length(local.lambda_policy_statements) > 0 ? local.lambda_policy_statements : [
9138
+ {
9139
+ Effect = "Deny"
9140
+ Action = ["appconfig:GetLatestConfiguration"]
9141
+ Resource = ["arn:aws:appconfig:\${data.aws_region.current.region}:\${data.aws_caller_identity.current.account_id}:application/none"]
9142
+ }
9143
+ ]
7939
9144
  }
7940
9145
 
7941
9146
  # CloudWatch Log Group for Lambda
@@ -7943,8 +9148,10 @@ resource "aws_cloudwatch_log_group" "lambda_logs" {
7943
9148
  #checkov:skip=CKV_AWS_158:Using default CloudWatch log encryption
7944
9149
  #checkov:skip=CKV_AWS_338:Log retention set to forever
7945
9150
  #checkov:skip=CKV_AWS_66:Log retention set to forever
7946
- name = "/aws/lambda/TestApiHandler-\${random_string.suffix.result}"
7947
- tags = var.tags
9151
+ for_each = local.operations
9152
+
9153
+ name = "/aws/lambda/\${local.function_name[each.key]}"
9154
+ tags = var.tags
7948
9155
  }
7949
9156
 
7950
9157
 
@@ -8041,59 +9248,63 @@ resource "aws_lambda_permission" "authorizer_invoke" {
8041
9248
  source_arn = "\${module.rest_api.api_execution_arn}/authorizers/\${aws_api_gateway_authorizer.custom_authorizer.id}"
8042
9249
  }
8043
9250
 
8044
- # Create proxy resource (captures all paths)
8045
- resource "aws_api_gateway_resource" "proxy_resource" {
8046
- rest_api_id = module.rest_api.api_id
8047
- parent_id = module.rest_api.api_root_resource_id
8048
- path_part = "{proxy+}"
8049
- }
8050
-
8051
- # Lambda integration for REST API
8052
- resource "aws_api_gateway_integration" "lambda_integration" {
8053
- rest_api_id = module.rest_api.api_id
8054
- resource_id = aws_api_gateway_resource.proxy_resource.id
8055
- http_method = aws_api_gateway_method.proxy_method.http_method
8056
-
8057
- integration_http_method = "POST"
8058
- type = "AWS_PROXY"
8059
- uri = aws_lambda_function.api_lambda.response_streaming_invoke_arn
8060
- response_transfer_mode = "STREAM"
8061
-
8062
- depends_on = [aws_lambda_function.api_lambda]
8063
- }
8064
-
8065
- # Method for proxy integration
8066
- resource "aws_api_gateway_method" "proxy_method" {
9251
+ # Method per operation
9252
+ resource "aws_api_gateway_method" "operation_methods" {
8067
9253
  #checkov:skip=CKV2_AWS_53:Request validation not required for proxy integration as Lambda handles validation
9254
+ for_each = local.operations
9255
+
8068
9256
  rest_api_id = module.rest_api.api_id
8069
- resource_id = aws_api_gateway_resource.proxy_resource.id
8070
- http_method = "ANY"
9257
+ resource_id = local.operation_resource_id[each.key]
9258
+ http_method = upper(each.value.method)
8071
9259
 
8072
9260
  authorization = "CUSTOM"
8073
9261
  authorizer_id = aws_api_gateway_authorizer.custom_authorizer.id
8074
9262
 
9263
+ # Path parameters must be declared on the method
8075
9264
  request_parameters = {
8076
- "method.request.path.proxy" = true
9265
+ for segment in local.operation_segments[each.key] :
9266
+ "method.request.path.\${trimsuffix(trimprefix(segment, "{"), "}")}" => true
9267
+ if startswith(segment, "{")
8077
9268
  }
8078
9269
 
8079
9270
  depends_on = [aws_api_gateway_authorizer.custom_authorizer]
8080
9271
  }
8081
9272
 
8082
- # OPTIONS method for CORS preflight
9273
+ # Lambda integration per operation
9274
+ resource "aws_api_gateway_integration" "lambda_integration" {
9275
+ for_each = local.operations
9276
+
9277
+ rest_api_id = module.rest_api.api_id
9278
+ resource_id = local.operation_resource_id[each.key]
9279
+ http_method = aws_api_gateway_method.operation_methods[each.key].http_method
9280
+
9281
+ integration_http_method = "POST"
9282
+ type = "AWS_PROXY"
9283
+ uri = aws_lambda_function.api_lambda[each.key].response_streaming_invoke_arn
9284
+ response_transfer_mode = "STREAM"
9285
+
9286
+ depends_on = [aws_lambda_function.api_lambda]
9287
+ }
9288
+
9289
+ # OPTIONS method for CORS preflight, on every resource an operation is served from
8083
9290
  resource "aws_api_gateway_method" "options_method" {
8084
9291
  #checkov:skip=CKV2_AWS_70:OPTIONS method must be unauthenticated for CORS preflight requests
8085
9292
  #checkov:skip=CKV2_AWS_53:Request validation not required for OPTIONS CORS preflight method
9293
+ for_each = local.resource_ids
9294
+
8086
9295
  rest_api_id = module.rest_api.api_id
8087
- resource_id = aws_api_gateway_resource.proxy_resource.id
9296
+ resource_id = each.value
8088
9297
  http_method = "OPTIONS"
8089
9298
  authorization = "NONE"
8090
9299
  }
8091
9300
 
8092
- # CORS integration for OPTIONS method
9301
+ # CORS integration for OPTIONS methods
8093
9302
  resource "aws_api_gateway_integration" "options_integration" {
9303
+ for_each = local.resource_ids
9304
+
8094
9305
  rest_api_id = module.rest_api.api_id
8095
- resource_id = aws_api_gateway_resource.proxy_resource.id
8096
- http_method = aws_api_gateway_method.options_method.http_method
9306
+ resource_id = each.value
9307
+ http_method = aws_api_gateway_method.options_method[each.key].http_method
8097
9308
 
8098
9309
  type = "MOCK"
8099
9310
  request_templates = {
@@ -8101,11 +9312,13 @@ resource "aws_api_gateway_integration" "options_integration" {
8101
9312
  }
8102
9313
  }
8103
9314
 
8104
- # OPTIONS method response
9315
+ # OPTIONS method responses
8105
9316
  resource "aws_api_gateway_method_response" "options_response" {
9317
+ for_each = local.resource_ids
9318
+
8106
9319
  rest_api_id = module.rest_api.api_id
8107
- resource_id = aws_api_gateway_resource.proxy_resource.id
8108
- http_method = aws_api_gateway_method.options_method.http_method
9320
+ resource_id = each.value
9321
+ http_method = aws_api_gateway_method.options_method[each.key].http_method
8109
9322
  status_code = "204"
8110
9323
 
8111
9324
  response_parameters = {
@@ -8115,18 +9328,22 @@ resource "aws_api_gateway_method_response" "options_response" {
8115
9328
  }
8116
9329
  }
8117
9330
 
8118
- # OPTIONS integration response
9331
+ # OPTIONS integration responses
8119
9332
  resource "aws_api_gateway_integration_response" "options_integration_response" {
9333
+ for_each = local.resource_ids
9334
+
8120
9335
  rest_api_id = module.rest_api.api_id
8121
- resource_id = aws_api_gateway_resource.proxy_resource.id
8122
- http_method = aws_api_gateway_method.options_method.http_method
8123
- status_code = aws_api_gateway_method_response.options_response.status_code
9336
+ resource_id = each.value
9337
+ http_method = aws_api_gateway_method.options_method[each.key].http_method
9338
+ status_code = aws_api_gateway_method_response.options_response[each.key].status_code
8124
9339
 
8125
9340
  response_parameters = {
8126
9341
  "method.response.header.Access-Control-Allow-Headers" = "'\${join(",", var.cors_allow_headers)}'"
8127
9342
  "method.response.header.Access-Control-Allow-Methods" = "'\${join(",", var.cors_allow_methods)}'"
8128
9343
  "method.response.header.Access-Control-Allow-Origin" = "'\${join(",", var.cors_allow_origins)}'"
8129
9344
  }
9345
+
9346
+ depends_on = [aws_api_gateway_integration.options_integration]
8130
9347
  }
8131
9348
 
8132
9349
  # API Gateway deployment
@@ -8134,13 +9351,28 @@ resource "aws_api_gateway_deployment" "api_deployment" {
8134
9351
  rest_api_id = module.rest_api.api_id
8135
9352
 
8136
9353
  triggers = {
8137
- redeployment = sha1(jsonencode([
8138
- aws_api_gateway_resource.proxy_resource.id,
8139
- aws_api_gateway_method.proxy_method.id,
8140
- aws_api_gateway_integration.lambda_integration.id,
8141
- aws_api_gateway_method.options_method.id,
8142
- aws_api_gateway_integration.options_integration.id,
8143
- ]))
9354
+ redeployment = sha1(jsonencode(concat(
9355
+ [for r in aws_api_gateway_resource.path_depth_0 : r.id],
9356
+ [for r in aws_api_gateway_resource.path_depth_1 : r.id],
9357
+ [for r in aws_api_gateway_resource.path_depth_2 : r.id],
9358
+ [for r in aws_api_gateway_resource.path_depth_3 : r.id],
9359
+ [for r in aws_api_gateway_resource.path_depth_4 : r.id],
9360
+ [for r in aws_api_gateway_resource.path_depth_5 : r.id],
9361
+ [for r in aws_api_gateway_resource.path_depth_6 : r.id],
9362
+ [for r in aws_api_gateway_resource.path_depth_7 : r.id],
9363
+ [for r in aws_api_gateway_resource.path_depth_8 : r.id],
9364
+ [for r in aws_api_gateway_resource.path_depth_9 : r.id],
9365
+ [for r in aws_api_gateway_resource.path_depth_10 : r.id],
9366
+ [for r in aws_api_gateway_resource.path_depth_11 : r.id],
9367
+ [for r in aws_api_gateway_resource.path_depth_12 : r.id],
9368
+ [for r in aws_api_gateway_resource.path_depth_13 : r.id],
9369
+ [for r in aws_api_gateway_resource.path_depth_14 : r.id],
9370
+ [for r in aws_api_gateway_resource.path_depth_15 : r.id],
9371
+ [for m in aws_api_gateway_method.operation_methods : m.id],
9372
+ [for i in aws_api_gateway_integration.lambda_integration : i.id],
9373
+ [for m in aws_api_gateway_method.options_method : m.id],
9374
+ [for i in aws_api_gateway_integration.options_integration : i.id],
9375
+ )))
8144
9376
  }
8145
9377
 
8146
9378
  lifecycle {
@@ -8148,7 +9380,7 @@ resource "aws_api_gateway_deployment" "api_deployment" {
8148
9380
  }
8149
9381
 
8150
9382
  depends_on = [
8151
- aws_api_gateway_method.proxy_method,
9383
+ aws_api_gateway_method.operation_methods,
8152
9384
  aws_api_gateway_integration.lambda_integration,
8153
9385
  aws_api_gateway_method.options_method,
8154
9386
  aws_api_gateway_integration.options_integration,
@@ -8277,11 +9509,13 @@ resource "aws_api_gateway_rest_api_policy" "api_policy" {
8277
9509
 
8278
9510
  # Lambda permission for API Gateway to invoke the function
8279
9511
  resource "aws_lambda_permission" "api_gateway_invoke" {
9512
+ for_each = local.operations
9513
+
8280
9514
  statement_id = "AllowExecutionFromAPIGateway"
8281
9515
  action = "lambda:InvokeFunction"
8282
- function_name = aws_lambda_function.api_lambda.function_name
9516
+ function_name = aws_lambda_function.api_lambda[each.key].function_name
8283
9517
  principal = "apigateway.amazonaws.com"
8284
- source_arn = "\${module.rest_api.api_execution_arn}/*/*"
9518
+ source_arn = "\${module.rest_api.api_execution_arn}/*/\${local.permission_source_suffix[each.key]}"
8285
9519
 
8286
9520
  depends_on = [module.rest_api, aws_lambda_function.api_lambda]
8287
9521
  }
@@ -8293,7 +9527,7 @@ module "add_url_to_runtime_config" {
8293
9527
 
8294
9528
  namespace = "connection"
8295
9529
  key = "apis"
8296
- value = { "TestApi" = aws_api_gateway_stage.api_stage.invoke_url }
9530
+ value = { "TestApi" = "\${aws_api_gateway_stage.api_stage.invoke_url}/" }
8297
9531
 
8298
9532
  depends_on = [aws_api_gateway_stage.api_stage]
8299
9533
  }
@@ -8346,83 +9580,83 @@ output "stage_id" {
8346
9580
  value = aws_api_gateway_stage.api_stage.id
8347
9581
  }
8348
9582
 
8349
- # Lambda Function Outputs
8350
- output "lambda_function_name" {
8351
- description = "Name of the Lambda function"
8352
- value = aws_lambda_function.api_lambda.function_name
9583
+ # Lambda Function Outputs, keyed by operation name
9584
+ output "operations" {
9585
+ description = "Names of the API operations, each with its own Lambda function"
9586
+ value = keys(local.operations)
8353
9587
  }
8354
9588
 
8355
- output "lambda_function_arn" {
8356
- description = "ARN of the Lambda function"
8357
- value = aws_lambda_function.api_lambda.arn
9589
+ output "lambda_function_names" {
9590
+ description = "Name of each operation's Lambda function, keyed by operation name"
9591
+ value = { for op, fn in aws_lambda_function.api_lambda : op => fn.function_name }
8358
9592
  }
8359
9593
 
8360
- output "lambda_invoke_arn" {
8361
- description = "Invoke ARN of the Lambda function"
8362
- value = aws_lambda_function.api_lambda.invoke_arn
9594
+ output "lambda_function_arns" {
9595
+ description = "ARN of each operation's Lambda function, keyed by operation name"
9596
+ value = { for op, fn in aws_lambda_function.api_lambda : op => fn.arn }
8363
9597
  }
8364
9598
 
8365
- output "lambda_qualified_arn" {
8366
- description = "Qualified ARN of the Lambda function"
8367
- value = aws_lambda_function.api_lambda.qualified_arn
9599
+ output "lambda_invoke_arns" {
9600
+ description = "Invoke ARN of each operation's Lambda function, keyed by operation name"
9601
+ value = { for op, fn in aws_lambda_function.api_lambda : op => fn.invoke_arn }
8368
9602
  }
8369
9603
 
8370
- output "lambda_version" {
8371
- description = "Version of the Lambda function"
8372
- value = aws_lambda_function.api_lambda.version
9604
+ output "lambda_qualified_arns" {
9605
+ description = "Qualified ARN of each operation's Lambda function, keyed by operation name"
9606
+ value = { for op, fn in aws_lambda_function.api_lambda : op => fn.qualified_arn }
8373
9607
  }
8374
9608
 
8375
- output "lambda_source_code_hash" {
8376
- description = "Base64-encoded SHA256 hash of the Lambda deployment package"
8377
- value = aws_lambda_function.api_lambda.source_code_hash
9609
+ output "lambda_versions" {
9610
+ description = "Version of each operation's Lambda function, keyed by operation name"
9611
+ value = { for op, fn in aws_lambda_function.api_lambda : op => fn.version }
8378
9612
  }
8379
9613
 
8380
- output "lambda_source_code_size" {
8381
- description = "Size of the Lambda deployment package in bytes"
8382
- value = aws_lambda_function.api_lambda.source_code_size
9614
+ output "lambda_source_code_hash" {
9615
+ description = "Base64-encoded SHA256 hash of the Lambda deployment package, shared by every operation"
9616
+ value = data.archive_file.lambda_zip.output_base64sha256
8383
9617
  }
8384
9618
 
8385
- # IAM Role Outputs
8386
- output "lambda_execution_role_arn" {
8387
- description = "ARN of the Lambda execution role"
8388
- value = aws_iam_role.lambda_execution_role.arn
9619
+ # IAM Role Outputs, keyed by operation name
9620
+ output "lambda_execution_role_arns" {
9621
+ description = "ARN of each operation's Lambda execution role, keyed by operation name"
9622
+ value = { for op, role in aws_iam_role.lambda_execution_role : op => role.arn }
8389
9623
  }
8390
9624
 
8391
- output "lambda_execution_role_name" {
8392
- description = "Name of the Lambda execution role"
8393
- value = aws_iam_role.lambda_execution_role.name
9625
+ output "lambda_execution_role_names" {
9626
+ description = "Name of each operation's Lambda execution role, keyed by operation name. Attach additional policies to a specific operation's role by name."
9627
+ value = { for op, role in aws_iam_role.lambda_execution_role : op => role.name }
8394
9628
  }
8395
9629
 
8396
9630
  output "security_group_id" {
8397
- 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."
9631
+ 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."
8398
9632
  value = var.enable_vpc ? aws_security_group.api_lambda[0].id : null
8399
9633
  }
8400
9634
 
8401
- # Integration Outputs
8402
- output "integration_id" {
8403
- description = "ID of the Lambda integration"
8404
- value = aws_api_gateway_integration.lambda_integration.id
9635
+ # Integration Outputs, keyed by operation name
9636
+ output "integration_ids" {
9637
+ description = "ID of each operation's Lambda integration, keyed by operation name"
9638
+ value = { for op, i in aws_api_gateway_integration.lambda_integration : op => i.id }
8405
9639
  }
8406
9640
 
8407
- output "proxy_resource_id" {
8408
- description = "ID of the proxy resource"
8409
- value = aws_api_gateway_resource.proxy_resource.id
9641
+ output "method_ids" {
9642
+ description = "ID of each operation's API Gateway method, keyed by operation name"
9643
+ value = { for op, m in aws_api_gateway_method.operation_methods : op => m.id }
8410
9644
  }
8411
9645
 
8412
- output "proxy_method_id" {
8413
- description = "ID of the proxy method"
8414
- value = aws_api_gateway_method.proxy_method.id
9646
+ output "resource_ids" {
9647
+ description = "ID of each API Gateway resource, keyed by its path"
9648
+ value = local.resource_ids
8415
9649
  }
8416
9650
 
8417
- # CloudWatch Log Groups
8418
- output "lambda_log_group_name" {
8419
- description = "Name of the Lambda CloudWatch log group"
8420
- value = aws_cloudwatch_log_group.lambda_logs.name
9651
+ # CloudWatch Log Groups, keyed by operation name
9652
+ output "lambda_log_group_names" {
9653
+ description = "Name of each operation's Lambda CloudWatch log group, keyed by operation name"
9654
+ value = { for op, lg in aws_cloudwatch_log_group.lambda_logs : op => lg.name }
8421
9655
  }
8422
9656
 
8423
- output "lambda_log_group_arn" {
8424
- description = "ARN of the Lambda CloudWatch log group"
8425
- value = aws_cloudwatch_log_group.lambda_logs.arn
9657
+ output "lambda_log_group_arns" {
9658
+ description = "ARN of each operation's Lambda CloudWatch log group, keyed by operation name"
9659
+ value = { for op, lg in aws_cloudwatch_log_group.lambda_logs : op => lg.arn }
8426
9660
  }
8427
9661
  ",
8428
9662
  }