Skip to content

Commit 9ed1d64

Browse files
roger-zhanggjonife
andauthored
feat: refactor Function URL permissions (#3735)
Co-authored-by: jonathan Ifegunni <jonife@amazon.com>
1 parent e341c9f commit 9ed1d64

21 files changed

+387
-8
lines changed

.cfnlintrc.yaml

+3
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
11
templates:
22
- tests/translator/output/**/*.json
33
ignore_templates:
4+
- tests/translator/output/**/function_with_function_url_config.json
5+
- tests/translator/output/**/function_with_function_url_config_and_autopublishalias.json
6+
- tests/translator/output/**/function_with_function_url_config_without_cors_config.json
47
- tests/translator/output/**/error_*.json # Fail by design
58
- tests/translator/output/**/api_http_paths_with_if_condition.json
69
- tests/translator/output/**/api_http_paths_with_if_condition_no_value_else_case.json
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
[
2+
{
3+
"LogicalResourceId": "MyLambdaFunction",
4+
"ResourceType": "AWS::Lambda::Function"
5+
},
6+
{
7+
"LogicalResourceId": "MyLambdaFunctionUrl",
8+
"ResourceType": "AWS::Lambda::Url"
9+
},
10+
{
11+
"LogicalResourceId": "MyLambdaFunctionUrlPublicPermissions",
12+
"ResourceType": "AWS::Lambda::Permission"
13+
},
14+
{
15+
"LogicalResourceId": "MyLambdaFunctionURLInvokeAllowPublicAccess",
16+
"ResourceType": "AWS::Lambda::Permission"
17+
},
18+
{
19+
"LogicalResourceId": "MyLambdaFunctionRole",
20+
"ResourceType": "AWS::IAM::Role"
21+
}
22+
]
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
[
2+
{
3+
"LogicalResourceId": "MyLambdaFunction",
4+
"ResourceType": "AWS::Lambda::Function"
5+
},
6+
{
7+
"LogicalResourceId": "MyLambdaFunctionRole",
8+
"ResourceType": "AWS::IAM::Role"
9+
},
10+
{
11+
"LogicalResourceId": "MyLambdaFunctionVersion",
12+
"ResourceType": "AWS::Lambda::Version"
13+
},
14+
{
15+
"LogicalResourceId": "MyLambdaFunctionAliaslive",
16+
"ResourceType": "AWS::Lambda::Alias"
17+
},
18+
{
19+
"LogicalResourceId": "MyLambdaFunctionUrlPublicPermissions",
20+
"ResourceType": "AWS::Lambda::Permission"
21+
},
22+
{
23+
"LogicalResourceId": "MyLambdaFunctionURLInvokeAllowPublicAccess",
24+
"ResourceType": "AWS::Lambda::Permission"
25+
},
26+
{
27+
"LogicalResourceId": "MyLambdaFunctionUrl",
28+
"ResourceType": "AWS::Lambda::Url"
29+
}
30+
]
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
Resources:
2+
MyLambdaFunction:
3+
Type: AWS::Serverless::Function
4+
Properties:
5+
Handler: index.handler
6+
Runtime: nodejs18.x
7+
CodeUri: ${codeuri}
8+
MemorySize: 128
9+
FunctionUrlConfig:
10+
AuthType: NONE
11+
Cors:
12+
AllowOrigins:
13+
- https://foo.com
14+
AllowMethods:
15+
- POST
16+
AllowCredentials: true
17+
AllowHeaders:
18+
- x-Custom-Header
19+
ExposeHeaders:
20+
- x-amzn-header
21+
MaxAge: 10
22+
Outputs:
23+
FunctionUrl:
24+
Description: URL of the Lambda function
25+
Value: !GetAtt MyLambdaFunctionUrl.FunctionUrl
26+
Metadata:
27+
SamTransformTest: true
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
Resources:
2+
MyLambdaFunction:
3+
Type: AWS::Serverless::Function
4+
Properties:
5+
Handler: index.handler
6+
Runtime: nodejs18.x
7+
CodeUri: ${codeuri}
8+
MemorySize: 128
9+
AutoPublishAlias: live
10+
FunctionUrlConfig:
11+
AuthType: NONE
12+
Cors:
13+
AllowOrigins:
14+
- https://foo.com
15+
AllowMethods:
16+
- POST
17+
AllowCredentials: true
18+
AllowHeaders:
19+
- x-Custom-Header
20+
ExposeHeaders:
21+
- x-amzn-header
22+
MaxAge: 10
23+
Outputs:
24+
FunctionUrl:
25+
Description: URL of the Lambda function alias
26+
Value: !GetAtt MyLambdaFunctionUrl.FunctionUrl
27+
Metadata:
28+
SamTransformTest: true

integration/single/test_basic_function.py

+67
Original file line numberDiff line numberDiff line change
@@ -130,6 +130,73 @@ def test_basic_function_with_url_config(self, file_name, qualifier):
130130
self.assertEqual(function_url_config["Cors"], cors_config)
131131
self._assert_invoke(lambda_client, function_name, qualifier, 200)
132132

133+
@parameterized.expand(
134+
[
135+
("single/basic_function_with_function_url_dual_auth", None),
136+
("single/basic_function_with_function_url_with_autopuplishalias_dual_auth", "live"),
137+
]
138+
)
139+
@skipIf(current_region_does_not_support([LAMBDA_URL]), "Lambda Url is not supported in this testing region")
140+
def test_basic_function_with_url_dual_auth(self, file_name, qualifier):
141+
"""
142+
Creates a basic lambda function with Function Url with authtype: None
143+
Verifies that 2 AWS::Lambda::Permission resources are created:
144+
- lambda:InvokeFunctionUrl
145+
- lambda:InvokeFunction with InvokedViaFunctionUrl: True
146+
"""
147+
self.create_and_verify_stack(file_name)
148+
149+
# Get Lambda permissions
150+
lambda_permissions = self.get_stack_resources("AWS::Lambda::Permission")
151+
152+
# Verify we have exactly 2 permissions
153+
self.assertEqual(len(lambda_permissions), 2, "Expected exactly 2 Lambda permissions")
154+
155+
# Check for the expected permission logical IDs
156+
invoke_function_url_permission = None
157+
invoke_permission = None
158+
159+
for permission in lambda_permissions:
160+
logical_id = permission["LogicalResourceId"]
161+
if "MyLambdaFunctionUrlPublicPermissions" in logical_id:
162+
invoke_function_url_permission = permission
163+
elif "MyLambdaFunctionURLInvokeAllowPublicAccess" in logical_id:
164+
invoke_permission = permission
165+
166+
# Verify both permissions exist
167+
self.assertIsNotNone(invoke_function_url_permission, "Expected MyLambdaFunctionUrlPublicPermissions to exist")
168+
self.assertIsNotNone(invoke_permission, "Expected MyLambdaFunctionURLInvokeAllowPublicAccess to exist")
169+
170+
# Get the function name and URL
171+
function_name = self.get_physical_id_by_type("AWS::Lambda::Function")
172+
lambda_client = self.client_provider.lambda_client
173+
174+
# Get the function URL configuration to verify auth type
175+
function_url_config = (
176+
lambda_client.get_function_url_config(FunctionName=function_name, Qualifier=qualifier)
177+
if qualifier
178+
else lambda_client.get_function_url_config(FunctionName=function_name)
179+
)
180+
181+
# Verify the auth type is NONE
182+
self.assertEqual(function_url_config["AuthType"], "NONE", "Expected AuthType to be NONE")
183+
184+
# Get the template to check for InvokedViaFunctionUrl property
185+
cfn_client = self.client_provider.cfn_client
186+
template = cfn_client.get_template(StackName=self.stack_name, TemplateStage="Processed")
187+
template_body = template["TemplateBody"]
188+
189+
# Check if the InvokePermission has InvokedViaFunctionUrl: True
190+
# This is a bit hacky but we don't have direct access to the resource properties
191+
# We're checking if the string representation of the template contains this property
192+
template_str = str(template_body)
193+
self.assertIn("InvokedViaFunctionUrl", template_str, "Expected InvokedViaFunctionUrl property in the template")
194+
195+
# Get the function URL from stack outputs
196+
function_url = self.get_stack_output("FunctionUrl")["OutputValue"]
197+
# Invoke the function URL and verify the response
198+
self._verify_get_request(function_url, self.FUNCTION_OUTPUT)
199+
133200
@skipIf(current_region_does_not_support([CODE_DEPLOY]), "CodeDeploy is not supported in this testing region")
134201
def test_function_with_deployment_preference_alarms_intrinsic_if(self):
135202
self.create_and_verify_stack("single/function_with_deployment_preference_alarms_intrinsic_if")

samtranslator/model/lambda_.py

+1
Original file line numberDiff line numberDiff line change
@@ -139,6 +139,7 @@ class LambdaPermission(Resource):
139139
"SourceArn": GeneratedProperty(),
140140
"EventSourceToken": GeneratedProperty(),
141141
"FunctionUrlAuthType": GeneratedProperty(),
142+
"InvokedViaFunctionUrl": GeneratedProperty(),
142143
}
143144

144145

samtranslator/model/sam_resources.py

+51-3
Original file line numberDiff line numberDiff line change
@@ -321,8 +321,12 @@ def to_cloudformation(self, **kwargs): # type: ignore[no-untyped-def] # noqa: P
321321
lambda_url = self._construct_function_url(lambda_function, lambda_alias, self.FunctionUrlConfig)
322322
resources.append(lambda_url)
323323
url_permission = self._construct_url_permission(lambda_function, lambda_alias, self.FunctionUrlConfig)
324-
if url_permission:
324+
invoke_dual_auth_permission = self._construct_invoke_permission(
325+
lambda_function, lambda_alias, self.FunctionUrlConfig
326+
)
327+
if url_permission and invoke_dual_auth_permission:
325328
resources.append(url_permission)
329+
resources.append(invoke_dual_auth_permission)
326330

327331
self._validate_deployment_preference_and_add_update_policy(
328332
kwargs.get("deployment_preference_collection"),
@@ -332,7 +336,6 @@ def to_cloudformation(self, **kwargs): # type: ignore[no-untyped-def] # noqa: P
332336
self.get_passthrough_resource_attributes(),
333337
feature_toggle,
334338
)
335-
336339
event_invoke_policies: List[Dict[str, Any]] = []
337340
if self.EventInvokeConfig:
338341
function_name = lambda_function.logical_id
@@ -1225,9 +1228,13 @@ def _construct_url_permission(
12251228
lambda_function : LambdaUrl
12261229
Lambda Function resource
12271230
1228-
llambda_alias : LambdaAlias
1231+
lambda_alias : LambdaAlias
12291232
Lambda Alias resource
12301233
1234+
1235+
function_url_config: Dict
1236+
Function url config used to create FURL
1237+
12311238
Returns
12321239
-------
12331240
LambdaPermission
@@ -1249,6 +1256,47 @@ def _construct_url_permission(
12491256
lambda_permission.FunctionUrlAuthType = auth_type
12501257
return lambda_permission
12511258

1259+
def _construct_invoke_permission(
1260+
self, lambda_function: LambdaFunction, lambda_alias: Optional[LambdaAlias], function_url_config: Dict[str, Any]
1261+
) -> Optional[LambdaPermission]:
1262+
"""
1263+
Construct the lambda permission associated with the function invoke resource in a case
1264+
for public access when AuthType is NONE
1265+
1266+
Parameters
1267+
----------
1268+
lambda_function : LambdaUrl
1269+
Lambda Function resource
1270+
1271+
lambda_alias : LambdaAlias
1272+
Lambda Alias resource
1273+
1274+
function_url_config: Dict
1275+
Function url config used to create FURL
1276+
1277+
Returns
1278+
-------
1279+
LambdaPermission
1280+
The lambda permission appended to a function that allow function invoke only from Function URL
1281+
"""
1282+
# create lambda:InvokeFunction with InvokedViaFunctionUrl=True
1283+
auth_type = function_url_config.get("AuthType")
1284+
1285+
if auth_type not in ["NONE"] or is_intrinsic(function_url_config):
1286+
return None
1287+
1288+
logical_id = f"{lambda_function.logical_id}URLInvokeAllowPublicAccess"
1289+
lambda_permission_attributes = self.get_passthrough_resource_attributes()
1290+
lambda_invoke_permission = LambdaPermission(logical_id=logical_id, attributes=lambda_permission_attributes)
1291+
lambda_invoke_permission.Action = "lambda:InvokeFunction"
1292+
lambda_invoke_permission.Principal = "*"
1293+
lambda_invoke_permission.FunctionName = (
1294+
lambda_alias.get_runtime_attr("arn") if lambda_alias else lambda_function.get_runtime_attr("name")
1295+
)
1296+
lambda_invoke_permission.InvokedViaFunctionUrl = True
1297+
1298+
return lambda_invoke_permission
1299+
12521300

12531301
class SamApi(SamResourceMacro):
12541302
"""SAM rest API macro."""

tests/model/test_sam_resources.py

+23-5
Original file line numberDiff line numberDiff line change
@@ -583,11 +583,29 @@ def test_with_valid_function_url_config_with_lambda_permission(self):
583583

584584
cfnResources = function.to_cloudformation(**self.kwargs)
585585
generatedUrlList = [x for x in cfnResources if isinstance(x, LambdaPermission)]
586-
self.assertEqual(generatedUrlList.__len__(), 1)
587-
self.assertEqual(generatedUrlList[0].Action, "lambda:InvokeFunctionUrl")
588-
self.assertEqual(generatedUrlList[0].FunctionName, {"Ref": "foo"})
589-
self.assertEqual(generatedUrlList[0].Principal, "*")
590-
self.assertEqual(generatedUrlList[0].FunctionUrlAuthType, "NONE")
586+
self.assertEqual(generatedUrlList.__len__(), 2)
587+
for permission in generatedUrlList:
588+
self.assertEqual(permission.FunctionName, {"Ref": "foo"})
589+
self.assertEqual(permission.Principal, "*")
590+
self.assertTrue(permission.Action in ["lambda:InvokeFunctionUrl", "lambda:InvokeFunction"])
591+
if permission.Action == "lambda:InvokeFunctionUrl":
592+
self.assertEqual(permission.FunctionUrlAuthType, "NONE")
593+
if permission.Action == "lambda:InvokeFunction":
594+
self.assertEqual(permission.InvokedViaFunctionUrl, True)
595+
596+
@patch("boto3.session.Session.region_name", "ap-southeast-1")
597+
def test_with_aws_iam_function_url_config_with_lambda_permission(self):
598+
function = SamFunction("foo")
599+
function.CodeUri = "s3://foobar/foo.zip"
600+
function.Runtime = "foo"
601+
function.Handler = "bar"
602+
# When create FURL with AWS_IAM
603+
function.FunctionUrlConfig = {"AuthType": "AWS_IAM"}
604+
605+
cfnResources = function.to_cloudformation(**self.kwargs)
606+
generatedUrlList = [x for x in cfnResources if isinstance(x, LambdaPermission)]
607+
# Then no permisssion should be auto created
608+
self.assertEqual(generatedUrlList.__len__(), 0)
591609

592610
@patch("boto3.session.Session.region_name", "ap-southeast-1")
593611
def test_with_invalid_function_url_config_with_authorization_type_value_as_None(self):

tests/translator/output/aws-cn/function_with_function_url_config.json

+11
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,17 @@
5858
},
5959
"Type": "AWS::IAM::Role"
6060
},
61+
"MyFunctionURLInvokeAllowPublicAccess": {
62+
"Properties": {
63+
"Action": "lambda:InvokeFunction",
64+
"FunctionName": {
65+
"Ref": "MyFunction"
66+
},
67+
"InvokedViaFunctionUrl": true,
68+
"Principal": "*"
69+
},
70+
"Type": "AWS::Lambda::Permission"
71+
},
6172
"MyFunctionUrl": {
6273
"Properties": {
6374
"AuthType": "NONE",

tests/translator/output/aws-cn/function_with_function_url_config_and_autopublishalias.json

+11
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,17 @@
7373
},
7474
"Type": "AWS::IAM::Role"
7575
},
76+
"MyFunctionURLInvokeAllowPublicAccess": {
77+
"Properties": {
78+
"Action": "lambda:InvokeFunction",
79+
"FunctionName": {
80+
"Ref": "MyFunctionAliaslive"
81+
},
82+
"InvokedViaFunctionUrl": true,
83+
"Principal": "*"
84+
},
85+
"Type": "AWS::Lambda::Permission"
86+
},
7687
"MyFunctionUrl": {
7788
"Properties": {
7889
"AuthType": "NONE",

tests/translator/output/aws-cn/function_with_function_url_config_conditions.json

+12
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,18 @@
6868
},
6969
"Type": "AWS::IAM::Role"
7070
},
71+
"MyFunctionURLInvokeAllowPublicAccess": {
72+
"Condition": "MyCondition",
73+
"Properties": {
74+
"Action": "lambda:InvokeFunction",
75+
"FunctionName": {
76+
"Ref": "MyFunction"
77+
},
78+
"InvokedViaFunctionUrl": true,
79+
"Principal": "*"
80+
},
81+
"Type": "AWS::Lambda::Permission"
82+
},
7183
"MyFunctionUrl": {
7284
"Condition": "MyCondition",
7385
"Properties": {

tests/translator/output/aws-cn/function_with_function_url_config_without_cors_config.json

+11
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,17 @@
5858
},
5959
"Type": "AWS::IAM::Role"
6060
},
61+
"MyFunctionURLInvokeAllowPublicAccess": {
62+
"Properties": {
63+
"Action": "lambda:InvokeFunction",
64+
"FunctionName": {
65+
"Ref": "MyFunction"
66+
},
67+
"InvokedViaFunctionUrl": true,
68+
"Principal": "*"
69+
},
70+
"Type": "AWS::Lambda::Permission"
71+
},
6172
"MyFunctionUrl": {
6273
"Properties": {
6374
"AuthType": "NONE",

0 commit comments

Comments
 (0)