diff --git a/.cfnlintrc.yaml b/.cfnlintrc.yaml index 23172d076..67cdeeac3 100644 --- a/.cfnlintrc.yaml +++ b/.cfnlintrc.yaml @@ -1,6 +1,9 @@ templates: - tests/translator/output/**/*.json ignore_templates: + - tests/translator/output/**/function_with_function_url_config.json + - tests/translator/output/**/function_with_function_url_config_and_autopublishalias.json + - tests/translator/output/**/function_with_function_url_config_without_cors_config.json - tests/translator/output/**/error_*.json # Fail by design - tests/translator/output/**/api_http_paths_with_if_condition.json - tests/translator/output/**/api_http_paths_with_if_condition_no_value_else_case.json diff --git a/integration/resources/expected/single/basic_function_with_function_url_dual_auth.json b/integration/resources/expected/single/basic_function_with_function_url_dual_auth.json new file mode 100644 index 000000000..e1132a952 --- /dev/null +++ b/integration/resources/expected/single/basic_function_with_function_url_dual_auth.json @@ -0,0 +1,22 @@ +[ + { + "LogicalResourceId": "MyLambdaFunction", + "ResourceType": "AWS::Lambda::Function" + }, + { + "LogicalResourceId": "MyLambdaFunctionUrl", + "ResourceType": "AWS::Lambda::Url" + }, + { + "LogicalResourceId": "MyLambdaFunctionUrlPublicPermissions", + "ResourceType": "AWS::Lambda::Permission" + }, + { + "LogicalResourceId": "MyLambdaFunctionURLInvokeAllowPublicAccess", + "ResourceType": "AWS::Lambda::Permission" + }, + { + "LogicalResourceId": "MyLambdaFunctionRole", + "ResourceType": "AWS::IAM::Role" + } +] diff --git a/integration/resources/expected/single/basic_function_with_function_url_with_autopuplishalias_dual_auth.json b/integration/resources/expected/single/basic_function_with_function_url_with_autopuplishalias_dual_auth.json new file mode 100644 index 000000000..fef523859 --- /dev/null +++ b/integration/resources/expected/single/basic_function_with_function_url_with_autopuplishalias_dual_auth.json @@ -0,0 +1,30 @@ +[ + { + "LogicalResourceId": "MyLambdaFunction", + "ResourceType": "AWS::Lambda::Function" + }, + { + "LogicalResourceId": "MyLambdaFunctionRole", + "ResourceType": "AWS::IAM::Role" + }, + { + "LogicalResourceId": "MyLambdaFunctionVersion", + "ResourceType": "AWS::Lambda::Version" + }, + { + "LogicalResourceId": "MyLambdaFunctionAliaslive", + "ResourceType": "AWS::Lambda::Alias" + }, + { + "LogicalResourceId": "MyLambdaFunctionUrlPublicPermissions", + "ResourceType": "AWS::Lambda::Permission" + }, + { + "LogicalResourceId": "MyLambdaFunctionURLInvokeAllowPublicAccess", + "ResourceType": "AWS::Lambda::Permission" + }, + { + "LogicalResourceId": "MyLambdaFunctionUrl", + "ResourceType": "AWS::Lambda::Url" + } +] diff --git a/integration/resources/templates/single/basic_function_with_function_url_dual_auth.yaml b/integration/resources/templates/single/basic_function_with_function_url_dual_auth.yaml new file mode 100644 index 000000000..4274a3947 --- /dev/null +++ b/integration/resources/templates/single/basic_function_with_function_url_dual_auth.yaml @@ -0,0 +1,27 @@ +Resources: + MyLambdaFunction: + Type: AWS::Serverless::Function + Properties: + Handler: index.handler + Runtime: nodejs18.x + CodeUri: ${codeuri} + MemorySize: 128 + FunctionUrlConfig: + AuthType: NONE + Cors: + AllowOrigins: + - https://foo.com + AllowMethods: + - POST + AllowCredentials: true + AllowHeaders: + - x-Custom-Header + ExposeHeaders: + - x-amzn-header + MaxAge: 10 +Outputs: + FunctionUrl: + Description: URL of the Lambda function + Value: !GetAtt MyLambdaFunctionUrl.FunctionUrl +Metadata: + SamTransformTest: true diff --git a/integration/resources/templates/single/basic_function_with_function_url_with_autopuplishalias_dual_auth.yaml b/integration/resources/templates/single/basic_function_with_function_url_with_autopuplishalias_dual_auth.yaml new file mode 100644 index 000000000..c78bbb3e3 --- /dev/null +++ b/integration/resources/templates/single/basic_function_with_function_url_with_autopuplishalias_dual_auth.yaml @@ -0,0 +1,28 @@ +Resources: + MyLambdaFunction: + Type: AWS::Serverless::Function + Properties: + Handler: index.handler + Runtime: nodejs18.x + CodeUri: ${codeuri} + MemorySize: 128 + AutoPublishAlias: live + FunctionUrlConfig: + AuthType: NONE + Cors: + AllowOrigins: + - https://foo.com + AllowMethods: + - POST + AllowCredentials: true + AllowHeaders: + - x-Custom-Header + ExposeHeaders: + - x-amzn-header + MaxAge: 10 +Outputs: + FunctionUrl: + Description: URL of the Lambda function alias + Value: !GetAtt MyLambdaFunctionUrl.FunctionUrl +Metadata: + SamTransformTest: true diff --git a/integration/single/test_basic_function.py b/integration/single/test_basic_function.py index 5678130c6..ee00524ab 100644 --- a/integration/single/test_basic_function.py +++ b/integration/single/test_basic_function.py @@ -130,6 +130,73 @@ def test_basic_function_with_url_config(self, file_name, qualifier): self.assertEqual(function_url_config["Cors"], cors_config) self._assert_invoke(lambda_client, function_name, qualifier, 200) + @parameterized.expand( + [ + ("single/basic_function_with_function_url_dual_auth", None), + ("single/basic_function_with_function_url_with_autopuplishalias_dual_auth", "live"), + ] + ) + @skipIf(current_region_does_not_support([LAMBDA_URL]), "Lambda Url is not supported in this testing region") + def test_basic_function_with_url_dual_auth(self, file_name, qualifier): + """ + Creates a basic lambda function with Function Url with authtype: None + Verifies that 2 AWS::Lambda::Permission resources are created: + - lambda:InvokeFunctionUrl + - lambda:InvokeFunction with InvokedViaFunctionUrl: True + """ + self.create_and_verify_stack(file_name) + + # Get Lambda permissions + lambda_permissions = self.get_stack_resources("AWS::Lambda::Permission") + + # Verify we have exactly 2 permissions + self.assertEqual(len(lambda_permissions), 2, "Expected exactly 2 Lambda permissions") + + # Check for the expected permission logical IDs + invoke_function_url_permission = None + invoke_permission = None + + for permission in lambda_permissions: + logical_id = permission["LogicalResourceId"] + if "MyLambdaFunctionUrlPublicPermissions" in logical_id: + invoke_function_url_permission = permission + elif "MyLambdaFunctionURLInvokeAllowPublicAccess" in logical_id: + invoke_permission = permission + + # Verify both permissions exist + self.assertIsNotNone(invoke_function_url_permission, "Expected MyLambdaFunctionUrlPublicPermissions to exist") + self.assertIsNotNone(invoke_permission, "Expected MyLambdaFunctionURLInvokeAllowPublicAccess to exist") + + # Get the function name and URL + function_name = self.get_physical_id_by_type("AWS::Lambda::Function") + lambda_client = self.client_provider.lambda_client + + # Get the function URL configuration to verify auth type + function_url_config = ( + lambda_client.get_function_url_config(FunctionName=function_name, Qualifier=qualifier) + if qualifier + else lambda_client.get_function_url_config(FunctionName=function_name) + ) + + # Verify the auth type is NONE + self.assertEqual(function_url_config["AuthType"], "NONE", "Expected AuthType to be NONE") + + # Get the template to check for InvokedViaFunctionUrl property + cfn_client = self.client_provider.cfn_client + template = cfn_client.get_template(StackName=self.stack_name, TemplateStage="Processed") + template_body = template["TemplateBody"] + + # Check if the InvokePermission has InvokedViaFunctionUrl: True + # This is a bit hacky but we don't have direct access to the resource properties + # We're checking if the string representation of the template contains this property + template_str = str(template_body) + self.assertIn("InvokedViaFunctionUrl", template_str, "Expected InvokedViaFunctionUrl property in the template") + + # Get the function URL from stack outputs + function_url = self.get_stack_output("FunctionUrl")["OutputValue"] + # Invoke the function URL and verify the response + self._verify_get_request(function_url, self.FUNCTION_OUTPUT) + @skipIf(current_region_does_not_support([CODE_DEPLOY]), "CodeDeploy is not supported in this testing region") def test_function_with_deployment_preference_alarms_intrinsic_if(self): self.create_and_verify_stack("single/function_with_deployment_preference_alarms_intrinsic_if") diff --git a/samtranslator/model/lambda_.py b/samtranslator/model/lambda_.py index 27e9636ed..c6fecd242 100644 --- a/samtranslator/model/lambda_.py +++ b/samtranslator/model/lambda_.py @@ -139,6 +139,7 @@ class LambdaPermission(Resource): "SourceArn": GeneratedProperty(), "EventSourceToken": GeneratedProperty(), "FunctionUrlAuthType": GeneratedProperty(), + "InvokedViaFunctionUrl": GeneratedProperty(), } diff --git a/samtranslator/model/sam_resources.py b/samtranslator/model/sam_resources.py index 2cdbc3c8b..70e7ef675 100644 --- a/samtranslator/model/sam_resources.py +++ b/samtranslator/model/sam_resources.py @@ -321,8 +321,12 @@ def to_cloudformation(self, **kwargs): # type: ignore[no-untyped-def] # noqa: P lambda_url = self._construct_function_url(lambda_function, lambda_alias, self.FunctionUrlConfig) resources.append(lambda_url) url_permission = self._construct_url_permission(lambda_function, lambda_alias, self.FunctionUrlConfig) - if url_permission: + invoke_dual_auth_permission = self._construct_invoke_permission( + lambda_function, lambda_alias, self.FunctionUrlConfig + ) + if url_permission and invoke_dual_auth_permission: resources.append(url_permission) + resources.append(invoke_dual_auth_permission) self._validate_deployment_preference_and_add_update_policy( kwargs.get("deployment_preference_collection"), @@ -332,7 +336,6 @@ def to_cloudformation(self, **kwargs): # type: ignore[no-untyped-def] # noqa: P self.get_passthrough_resource_attributes(), feature_toggle, ) - event_invoke_policies: List[Dict[str, Any]] = [] if self.EventInvokeConfig: function_name = lambda_function.logical_id @@ -1225,9 +1228,13 @@ def _construct_url_permission( lambda_function : LambdaUrl Lambda Function resource - llambda_alias : LambdaAlias + lambda_alias : LambdaAlias Lambda Alias resource + + function_url_config: Dict + Function url config used to create FURL + Returns ------- LambdaPermission @@ -1249,6 +1256,47 @@ def _construct_url_permission( lambda_permission.FunctionUrlAuthType = auth_type return lambda_permission + def _construct_invoke_permission( + self, lambda_function: LambdaFunction, lambda_alias: Optional[LambdaAlias], function_url_config: Dict[str, Any] + ) -> Optional[LambdaPermission]: + """ + Construct the lambda permission associated with the function invoke resource in a case + for public access when AuthType is NONE + + Parameters + ---------- + lambda_function : LambdaUrl + Lambda Function resource + + lambda_alias : LambdaAlias + Lambda Alias resource + + function_url_config: Dict + Function url config used to create FURL + + Returns + ------- + LambdaPermission + The lambda permission appended to a function that allow function invoke only from Function URL + """ + # create lambda:InvokeFunction with InvokedViaFunctionUrl=True + auth_type = function_url_config.get("AuthType") + + if auth_type not in ["NONE"] or is_intrinsic(function_url_config): + return None + + logical_id = f"{lambda_function.logical_id}URLInvokeAllowPublicAccess" + lambda_permission_attributes = self.get_passthrough_resource_attributes() + lambda_invoke_permission = LambdaPermission(logical_id=logical_id, attributes=lambda_permission_attributes) + lambda_invoke_permission.Action = "lambda:InvokeFunction" + lambda_invoke_permission.Principal = "*" + lambda_invoke_permission.FunctionName = ( + lambda_alias.get_runtime_attr("arn") if lambda_alias else lambda_function.get_runtime_attr("name") + ) + lambda_invoke_permission.InvokedViaFunctionUrl = True + + return lambda_invoke_permission + class SamApi(SamResourceMacro): """SAM rest API macro.""" diff --git a/tests/model/test_sam_resources.py b/tests/model/test_sam_resources.py index 9b6c28908..b53649f10 100644 --- a/tests/model/test_sam_resources.py +++ b/tests/model/test_sam_resources.py @@ -583,11 +583,29 @@ def test_with_valid_function_url_config_with_lambda_permission(self): cfnResources = function.to_cloudformation(**self.kwargs) generatedUrlList = [x for x in cfnResources if isinstance(x, LambdaPermission)] - self.assertEqual(generatedUrlList.__len__(), 1) - self.assertEqual(generatedUrlList[0].Action, "lambda:InvokeFunctionUrl") - self.assertEqual(generatedUrlList[0].FunctionName, {"Ref": "foo"}) - self.assertEqual(generatedUrlList[0].Principal, "*") - self.assertEqual(generatedUrlList[0].FunctionUrlAuthType, "NONE") + self.assertEqual(generatedUrlList.__len__(), 2) + for permission in generatedUrlList: + self.assertEqual(permission.FunctionName, {"Ref": "foo"}) + self.assertEqual(permission.Principal, "*") + self.assertTrue(permission.Action in ["lambda:InvokeFunctionUrl", "lambda:InvokeFunction"]) + if permission.Action == "lambda:InvokeFunctionUrl": + self.assertEqual(permission.FunctionUrlAuthType, "NONE") + if permission.Action == "lambda:InvokeFunction": + self.assertEqual(permission.InvokedViaFunctionUrl, True) + + @patch("boto3.session.Session.region_name", "ap-southeast-1") + def test_with_aws_iam_function_url_config_with_lambda_permission(self): + function = SamFunction("foo") + function.CodeUri = "s3://foobar/foo.zip" + function.Runtime = "foo" + function.Handler = "bar" + # When create FURL with AWS_IAM + function.FunctionUrlConfig = {"AuthType": "AWS_IAM"} + + cfnResources = function.to_cloudformation(**self.kwargs) + generatedUrlList = [x for x in cfnResources if isinstance(x, LambdaPermission)] + # Then no permisssion should be auto created + self.assertEqual(generatedUrlList.__len__(), 0) @patch("boto3.session.Session.region_name", "ap-southeast-1") def test_with_invalid_function_url_config_with_authorization_type_value_as_None(self): diff --git a/tests/translator/output/aws-cn/function_with_function_url_config.json b/tests/translator/output/aws-cn/function_with_function_url_config.json index 7404f943e..bd007a362 100644 --- a/tests/translator/output/aws-cn/function_with_function_url_config.json +++ b/tests/translator/output/aws-cn/function_with_function_url_config.json @@ -58,6 +58,17 @@ }, "Type": "AWS::IAM::Role" }, + "MyFunctionURLInvokeAllowPublicAccess": { + "Properties": { + "Action": "lambda:InvokeFunction", + "FunctionName": { + "Ref": "MyFunction" + }, + "InvokedViaFunctionUrl": true, + "Principal": "*" + }, + "Type": "AWS::Lambda::Permission" + }, "MyFunctionUrl": { "Properties": { "AuthType": "NONE", diff --git a/tests/translator/output/aws-cn/function_with_function_url_config_and_autopublishalias.json b/tests/translator/output/aws-cn/function_with_function_url_config_and_autopublishalias.json index b12e0271c..e648b9460 100644 --- a/tests/translator/output/aws-cn/function_with_function_url_config_and_autopublishalias.json +++ b/tests/translator/output/aws-cn/function_with_function_url_config_and_autopublishalias.json @@ -73,6 +73,17 @@ }, "Type": "AWS::IAM::Role" }, + "MyFunctionURLInvokeAllowPublicAccess": { + "Properties": { + "Action": "lambda:InvokeFunction", + "FunctionName": { + "Ref": "MyFunctionAliaslive" + }, + "InvokedViaFunctionUrl": true, + "Principal": "*" + }, + "Type": "AWS::Lambda::Permission" + }, "MyFunctionUrl": { "Properties": { "AuthType": "NONE", diff --git a/tests/translator/output/aws-cn/function_with_function_url_config_conditions.json b/tests/translator/output/aws-cn/function_with_function_url_config_conditions.json index 5cb30218d..3f2fcbda6 100644 --- a/tests/translator/output/aws-cn/function_with_function_url_config_conditions.json +++ b/tests/translator/output/aws-cn/function_with_function_url_config_conditions.json @@ -68,6 +68,18 @@ }, "Type": "AWS::IAM::Role" }, + "MyFunctionURLInvokeAllowPublicAccess": { + "Condition": "MyCondition", + "Properties": { + "Action": "lambda:InvokeFunction", + "FunctionName": { + "Ref": "MyFunction" + }, + "InvokedViaFunctionUrl": true, + "Principal": "*" + }, + "Type": "AWS::Lambda::Permission" + }, "MyFunctionUrl": { "Condition": "MyCondition", "Properties": { diff --git a/tests/translator/output/aws-cn/function_with_function_url_config_without_cors_config.json b/tests/translator/output/aws-cn/function_with_function_url_config_without_cors_config.json index b20af083f..11393799b 100644 --- a/tests/translator/output/aws-cn/function_with_function_url_config_without_cors_config.json +++ b/tests/translator/output/aws-cn/function_with_function_url_config_without_cors_config.json @@ -58,6 +58,17 @@ }, "Type": "AWS::IAM::Role" }, + "MyFunctionURLInvokeAllowPublicAccess": { + "Properties": { + "Action": "lambda:InvokeFunction", + "FunctionName": { + "Ref": "MyFunction" + }, + "InvokedViaFunctionUrl": true, + "Principal": "*" + }, + "Type": "AWS::Lambda::Permission" + }, "MyFunctionUrl": { "Properties": { "AuthType": "NONE", diff --git a/tests/translator/output/aws-us-gov/function_with_function_url_config.json b/tests/translator/output/aws-us-gov/function_with_function_url_config.json index 2d04a4c32..717276ba4 100644 --- a/tests/translator/output/aws-us-gov/function_with_function_url_config.json +++ b/tests/translator/output/aws-us-gov/function_with_function_url_config.json @@ -58,6 +58,17 @@ }, "Type": "AWS::IAM::Role" }, + "MyFunctionURLInvokeAllowPublicAccess": { + "Properties": { + "Action": "lambda:InvokeFunction", + "FunctionName": { + "Ref": "MyFunction" + }, + "InvokedViaFunctionUrl": true, + "Principal": "*" + }, + "Type": "AWS::Lambda::Permission" + }, "MyFunctionUrl": { "Properties": { "AuthType": "NONE", diff --git a/tests/translator/output/aws-us-gov/function_with_function_url_config_and_autopublishalias.json b/tests/translator/output/aws-us-gov/function_with_function_url_config_and_autopublishalias.json index 5d558e29e..ab22babe3 100644 --- a/tests/translator/output/aws-us-gov/function_with_function_url_config_and_autopublishalias.json +++ b/tests/translator/output/aws-us-gov/function_with_function_url_config_and_autopublishalias.json @@ -73,6 +73,17 @@ }, "Type": "AWS::IAM::Role" }, + "MyFunctionURLInvokeAllowPublicAccess": { + "Properties": { + "Action": "lambda:InvokeFunction", + "FunctionName": { + "Ref": "MyFunctionAliaslive" + }, + "InvokedViaFunctionUrl": true, + "Principal": "*" + }, + "Type": "AWS::Lambda::Permission" + }, "MyFunctionUrl": { "Properties": { "AuthType": "NONE", diff --git a/tests/translator/output/aws-us-gov/function_with_function_url_config_conditions.json b/tests/translator/output/aws-us-gov/function_with_function_url_config_conditions.json index e2763bcd9..98bd4aeda 100644 --- a/tests/translator/output/aws-us-gov/function_with_function_url_config_conditions.json +++ b/tests/translator/output/aws-us-gov/function_with_function_url_config_conditions.json @@ -68,6 +68,18 @@ }, "Type": "AWS::IAM::Role" }, + "MyFunctionURLInvokeAllowPublicAccess": { + "Condition": "MyCondition", + "Properties": { + "Action": "lambda:InvokeFunction", + "FunctionName": { + "Ref": "MyFunction" + }, + "InvokedViaFunctionUrl": true, + "Principal": "*" + }, + "Type": "AWS::Lambda::Permission" + }, "MyFunctionUrl": { "Condition": "MyCondition", "Properties": { diff --git a/tests/translator/output/aws-us-gov/function_with_function_url_config_without_cors_config.json b/tests/translator/output/aws-us-gov/function_with_function_url_config_without_cors_config.json index c274150d6..34e72dd45 100644 --- a/tests/translator/output/aws-us-gov/function_with_function_url_config_without_cors_config.json +++ b/tests/translator/output/aws-us-gov/function_with_function_url_config_without_cors_config.json @@ -58,6 +58,17 @@ }, "Type": "AWS::IAM::Role" }, + "MyFunctionURLInvokeAllowPublicAccess": { + "Properties": { + "Action": "lambda:InvokeFunction", + "FunctionName": { + "Ref": "MyFunction" + }, + "InvokedViaFunctionUrl": true, + "Principal": "*" + }, + "Type": "AWS::Lambda::Permission" + }, "MyFunctionUrl": { "Properties": { "AuthType": "NONE", diff --git a/tests/translator/output/function_with_function_url_config.json b/tests/translator/output/function_with_function_url_config.json index a185781cb..f8470b199 100644 --- a/tests/translator/output/function_with_function_url_config.json +++ b/tests/translator/output/function_with_function_url_config.json @@ -58,6 +58,17 @@ }, "Type": "AWS::IAM::Role" }, + "MyFunctionURLInvokeAllowPublicAccess": { + "Properties": { + "Action": "lambda:InvokeFunction", + "FunctionName": { + "Ref": "MyFunction" + }, + "InvokedViaFunctionUrl": true, + "Principal": "*" + }, + "Type": "AWS::Lambda::Permission" + }, "MyFunctionUrl": { "Properties": { "AuthType": "NONE", diff --git a/tests/translator/output/function_with_function_url_config_and_autopublishalias.json b/tests/translator/output/function_with_function_url_config_and_autopublishalias.json index d62e34565..29f8dd5b4 100644 --- a/tests/translator/output/function_with_function_url_config_and_autopublishalias.json +++ b/tests/translator/output/function_with_function_url_config_and_autopublishalias.json @@ -73,6 +73,17 @@ }, "Type": "AWS::IAM::Role" }, + "MyFunctionURLInvokeAllowPublicAccess": { + "Properties": { + "Action": "lambda:InvokeFunction", + "FunctionName": { + "Ref": "MyFunctionAliaslive" + }, + "InvokedViaFunctionUrl": true, + "Principal": "*" + }, + "Type": "AWS::Lambda::Permission" + }, "MyFunctionUrl": { "Properties": { "AuthType": "NONE", diff --git a/tests/translator/output/function_with_function_url_config_conditions.json b/tests/translator/output/function_with_function_url_config_conditions.json index 94cc22817..736511c8b 100644 --- a/tests/translator/output/function_with_function_url_config_conditions.json +++ b/tests/translator/output/function_with_function_url_config_conditions.json @@ -68,6 +68,18 @@ }, "Type": "AWS::IAM::Role" }, + "MyFunctionURLInvokeAllowPublicAccess": { + "Condition": "MyCondition", + "Properties": { + "Action": "lambda:InvokeFunction", + "FunctionName": { + "Ref": "MyFunction" + }, + "InvokedViaFunctionUrl": true, + "Principal": "*" + }, + "Type": "AWS::Lambda::Permission" + }, "MyFunctionUrl": { "Condition": "MyCondition", "Properties": { diff --git a/tests/translator/output/function_with_function_url_config_without_cors_config.json b/tests/translator/output/function_with_function_url_config_without_cors_config.json index 07520fa01..4f1a321b7 100644 --- a/tests/translator/output/function_with_function_url_config_without_cors_config.json +++ b/tests/translator/output/function_with_function_url_config_without_cors_config.json @@ -58,6 +58,17 @@ }, "Type": "AWS::IAM::Role" }, + "MyFunctionURLInvokeAllowPublicAccess": { + "Properties": { + "Action": "lambda:InvokeFunction", + "FunctionName": { + "Ref": "MyFunction" + }, + "InvokedViaFunctionUrl": true, + "Principal": "*" + }, + "Type": "AWS::Lambda::Permission" + }, "MyFunctionUrl": { "Properties": { "AuthType": "NONE",