Skip to content

feat: refactor Function URL permissions #3735

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 13 commits into from
Apr 24, 2025
Merged
3 changes: 3 additions & 0 deletions .cfnlintrc.yaml
Original file line number Diff line number Diff line change
@@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -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"
}
]
Original file line number Diff line number Diff line change
@@ -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"
}
]
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
@@ -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
67 changes: 67 additions & 0 deletions integration/single/test_basic_function.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
1 change: 1 addition & 0 deletions samtranslator/model/lambda_.py
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,7 @@ class LambdaPermission(Resource):
"SourceArn": GeneratedProperty(),
"EventSourceToken": GeneratedProperty(),
"FunctionUrlAuthType": GeneratedProperty(),
"InvokedViaFunctionUrl": GeneratedProperty(),
}


Expand Down
54 changes: 51 additions & 3 deletions samtranslator/model/sam_resources.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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."""
Expand Down
28 changes: 23 additions & 5 deletions tests/model/test_sam_resources.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Loading