Skip to content

Commit 62cccee

Browse files
authored
feat: support conditional AWS::NoValue in SAM Function's IAM Role (#3842)
1 parent cc9118c commit 62cccee

14 files changed

+799
-8
lines changed

samtranslator/model/sam_resources.py

Lines changed: 65 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -293,7 +293,7 @@ def resources_to_link(self, resources: Dict[str, Any]) -> Dict[str, Any]:
293293
raise InvalidResourceException(self.logical_id, e.message) from e
294294

295295
@cw_timer
296-
def to_cloudformation(self, **kwargs): # type: ignore[no-untyped-def] # noqa: PLR0915
296+
def to_cloudformation(self, **kwargs): # type: ignore[no-untyped-def] # noqa: PLR0915, PLR0912
297297
"""Returns the Lambda function, role, and event resources to which this SAM Function corresponds.
298298
299299
:param dict kwargs: already-converted resources that may need to be modified when converting this \
@@ -388,16 +388,25 @@ def to_cloudformation(self, **kwargs): # type: ignore[no-untyped-def] # noqa: P
388388
managed_policy_map = kwargs.get("managed_policy_map", {})
389389
get_managed_policy_map = kwargs.get("get_managed_policy_map")
390390

391-
execution_role = None
391+
execution_role = self._construct_role(
392+
managed_policy_map,
393+
event_invoke_policies,
394+
intrinsics_resolver,
395+
get_managed_policy_map,
396+
)
397+
392398
if lambda_function.Role is None:
393-
execution_role = self._construct_role(
394-
managed_policy_map,
395-
event_invoke_policies,
396-
intrinsics_resolver,
397-
get_managed_policy_map,
398-
)
399399
lambda_function.Role = execution_role.get_runtime_attr("arn")
400400
resources.append(execution_role)
401+
elif is_intrinsic_if(lambda_function.Role):
402+
role_changes = self._make_lambda_role(lambda_function, intrinsics_resolver, execution_role)
403+
404+
if role_changes["lambda_role_value"] is not None:
405+
lambda_function.Role = role_changes["lambda_role_value"]
406+
resources.append(role_changes["iam_role_resource"])
407+
408+
if role_changes["new_condition"] is not None:
409+
conditions.update(role_changes["new_condition"])
401410

402411
try:
403412
resources += self._generate_event_resources(
@@ -415,6 +424,54 @@ def to_cloudformation(self, **kwargs): # type: ignore[no-untyped-def] # noqa: P
415424

416425
return resources
417426

427+
def _make_lambda_role(
428+
self,
429+
lambda_function: LambdaFunction,
430+
intrinsics_resolver: IntrinsicsResolver,
431+
execution_role: IAMRole,
432+
) -> Dict[str, Any]:
433+
"""
434+
Analyzes lambda role requirements and returns the changes needed.
435+
436+
Returns:
437+
Dict containing:
438+
- 'lambda_role_value': Any - value to set for lambda_function.Role
439+
- 'new_condition': Dict|None - new condition to add to conditions dict
440+
- 'iam_role_resource' : IAMRole - IAM Role used for Lambda execution
441+
"""
442+
lambda_role = lambda_function.Role
443+
execution_role_arn = execution_role.get_runtime_attr("arn")
444+
445+
lambda_role_value = None
446+
new_condition = None
447+
448+
# We need to create and if else condition here
449+
role_resolved_value = intrinsics_resolver.resolve_parameter_refs(lambda_role)
450+
role_condition, role_if, role_else = role_resolved_value.get("Fn::If")
451+
452+
if is_intrinsic_no_value(role_if) and is_intrinsic_no_value(role_else):
453+
lambda_role_value = execution_role_arn
454+
455+
# first value is none so we should create condition ? create : [2]
456+
# create a condition for IAM role to only create on if case
457+
elif is_intrinsic_no_value(role_if):
458+
lambda_role_value = make_conditional(role_condition, execution_role_arn, role_else)
459+
execution_role.set_resource_attribute("Condition", role_condition)
460+
461+
# second value is none so we should create condition ? [1] : create
462+
# create a condition for IAM role to only create on else case
463+
# with top level condition that negates the condition passed
464+
elif is_intrinsic_no_value(role_else):
465+
lambda_role_value = make_conditional(role_condition, role_if, execution_role_arn)
466+
execution_role.set_resource_attribute("Condition", f"NOT{role_condition}")
467+
new_condition = {f"NOT{role_condition}": make_not_conditional(role_condition)}
468+
469+
return {
470+
"lambda_role_value": lambda_role_value,
471+
"new_condition": new_condition,
472+
"iam_role_resource": execution_role,
473+
}
474+
418475
def _construct_event_invoke_config( # noqa: PLR0913
419476
self,
420477
function_name: str,

tests/model/test_sam_resources.py

Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -741,6 +741,113 @@ def test_function_datasource_set_with_none():
741741
assert none_datasource
742742

743743

744+
class TestSamFunctionRoleResolver(TestCase):
745+
"""
746+
Tests for resolving IAM role property values in SamFunction
747+
"""
748+
749+
def setUp(self):
750+
self.function = SamFunction("foo")
751+
self.function.CodeUri = "s3://foobar/foo.zip"
752+
self.function.Runtime = "foo"
753+
self.function.Handler = "bar"
754+
self.kwargs = {
755+
"intrinsics_resolver": IntrinsicsResolver({}),
756+
"event_resources": [],
757+
"managed_policy_map": {},
758+
"resource_resolver": ResourceResolver({}),
759+
"conditions": {"Conditions": {}},
760+
}
761+
762+
def test_role_none_creates_execution_role(self):
763+
self.function.Role = None
764+
cfn_resources = self.function.to_cloudformation(**self.kwargs)
765+
generated_roles = [x for x in cfn_resources if isinstance(x, IAMRole)]
766+
767+
self.assertEqual(len(generated_roles), 1) # Should create execution role
768+
769+
def test_role_explicit_arn_no_execution_role(self):
770+
test_role = "arn:aws:iam::123456789012:role/existing-role"
771+
self.function.Role = test_role
772+
773+
cfn_resources = self.function.to_cloudformation(**self.kwargs)
774+
generated_roles = [x for x in cfn_resources if isinstance(x, IAMRole)]
775+
lambda_function = next(r for r in cfn_resources if r.resource_type == "AWS::Lambda::Function")
776+
777+
self.assertEqual(len(generated_roles), 0) # Should not create execution role
778+
self.assertEqual(lambda_function.Role, test_role)
779+
780+
def test_role_fn_if_no_aws_no_value_keeps_original(self):
781+
role_conditional = {
782+
"Fn::If": ["Condition", "arn:aws:iam::123456789012:role/existing-role", {"Ref": "iamRoleArn"}]
783+
}
784+
self.function.Role = role_conditional
785+
786+
kwargs = dict(self.kwargs)
787+
kwargs["conditions"] = {"Condition": True}
788+
789+
cfn_resources = self.function.to_cloudformation(**self.kwargs)
790+
generated_roles = [x for x in cfn_resources if isinstance(x, IAMRole)]
791+
lambda_function = next(r for r in cfn_resources if r.resource_type == "AWS::Lambda::Function")
792+
793+
# Should not create a role if a role is passed in for both cases
794+
self.assertEqual(len(generated_roles), 0)
795+
self.assertEqual(lambda_function.Role, role_conditional)
796+
797+
def test_role_fn_if_both_no_value_creates_execution_role(self):
798+
role_conditional = {"Fn::If": ["Condition", {"Ref": "AWS::NoValue"}, {"Ref": "AWS::NoValue"}]}
799+
self.function.Role = role_conditional
800+
801+
kwargs = dict(self.kwargs)
802+
kwargs["conditions"] = {"Condition": True}
803+
804+
cfn_resources = self.function.to_cloudformation(**self.kwargs)
805+
generated_roles = [x for x in cfn_resources if isinstance(x, IAMRole)]
806+
807+
self.assertEqual(len(generated_roles), 1)
808+
809+
def test_role_fn_if_first_no_value_creates_conditional_role(self):
810+
role_conditional = {"Fn::If": ["Condition", {"Ref": "AWS::NoValue"}, {"Ref": "iamRoleArn"}]}
811+
self.function.Role = role_conditional
812+
813+
kwargs = dict(self.kwargs)
814+
kwargs["conditions"] = {"Condition": True}
815+
816+
cfn_resources = self.function.to_cloudformation(**self.kwargs)
817+
generated_roles = [x for x in cfn_resources if isinstance(x, IAMRole)]
818+
lambda_function = next(r for r in cfn_resources if r.resource_type == "AWS::Lambda::Function")
819+
820+
self.assertEqual(len(generated_roles), 1)
821+
self.assertEqual(
822+
lambda_function.Role, {"Fn::If": ["Condition", {"Fn::GetAtt": ["fooRole", "Arn"]}, {"Ref": "iamRoleArn"}]}
823+
)
824+
825+
def test_role_fn_if_second_no_value_creates_conditional_role(self):
826+
role_conditional = {"Fn::If": ["Condition", {"Ref": "iamRoleArn"}, {"Ref": "AWS::NoValue"}]}
827+
self.function.Role = role_conditional
828+
829+
kwargs = dict(self.kwargs)
830+
kwargs["conditions"] = {"Condition": True}
831+
832+
cfn_resources = self.function.to_cloudformation(**self.kwargs)
833+
generated_roles = [x for x in cfn_resources if isinstance(x, IAMRole)]
834+
lambda_function = next(r for r in cfn_resources if r.resource_type == "AWS::Lambda::Function")
835+
836+
self.assertEqual(len(generated_roles), 1)
837+
self.assertEqual(
838+
lambda_function.Role, {"Fn::If": ["Condition", {"Ref": "iamRoleArn"}, {"Fn::GetAtt": ["fooRole", "Arn"]}]}
839+
)
840+
841+
def test_role_get_att_no_execution_role(self):
842+
role_get_att = {"Fn::GetAtt": ["MyCustomRole", "Arn"]}
843+
self.function.Role = role_get_att
844+
845+
cfn_resources = self.function.to_cloudformation(**self.kwargs)
846+
lambda_function = next(r for r in cfn_resources if r.resource_type == "AWS::Lambda::Function")
847+
848+
self.assertEqual(lambda_function.Role, role_get_att)
849+
850+
744851
class TestSamCapacityProvider(TestCase):
745852
"""Tests for SamCapacityProvider"""
746853

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
Resources:
2+
MinimalFunction:
3+
Type: AWS::Serverless::Function
4+
Properties:
5+
CodeUri: s3://sam-demo-bucket/hello.zip
6+
Handler: hello.handler
7+
Runtime: python3.10
8+
Role: 2
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
Parameters:
2+
iamRoleArn:
3+
Type: String
4+
Description: The ARN of an IAM role to use as this function's execution role.
5+
If a role isn't specified, one is created for you with a logical ID of <function-logical-id>Role.
6+
7+
Conditions:
8+
CreateRole: !Not [!Equals ['', !Ref iamRoleArn]]
9+
10+
Resources:
11+
MinimalFunction:
12+
Type: AWS::Serverless::Function
13+
Properties:
14+
CodeUri: s3://sam-demo-bucket/hello.zip
15+
Handler: hello.handler
16+
Runtime: python3.10
17+
Role: !If
18+
- CreateRole
19+
- !Ref "AWS::NoValue"
20+
- !Ref "iamRoleArn"
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
Parameters:
2+
iamRoleArn:
3+
Type: String
4+
Description: The ARN of an IAM role to use as this function's execution role.
5+
If a role isn't specified, one is created for you with a logical ID of <function-logical-id>Role.
6+
7+
Conditions:
8+
RoleExists: !Not [!Equals ['', !Ref iamRoleArn]]
9+
10+
Resources:
11+
MinimalFunction:
12+
Type: AWS::Serverless::Function
13+
Properties:
14+
CodeUri: s3://sam-demo-bucket/hello.zip
15+
Handler: hello.handler
16+
Runtime: python3.10
17+
Role: !If
18+
- RoleExists
19+
- !Ref "iamRoleArn"
20+
- !Ref "AWS::NoValue"
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
{
2+
"_autoGeneratedBreakdownErrorMessage": [
3+
"Invalid Serverless Application Specification document. ",
4+
"Number of errors found: 1. ",
5+
"Resource with id [MinimalFunction] is invalid. ",
6+
"Property 'Role' should be a string."
7+
],
8+
"errorMessage": "Invalid Serverless Application Specification document. Number of errors found: 1. Resource with id [MinimalFunction] is invalid. Property 'Role' should be a string.",
9+
"errors": [
10+
{
11+
"errorMessage": "Resource with id [MinimalFunction] is invalid. Property 'Role' should be a string."
12+
}
13+
]
14+
}
Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
{
2+
"Conditions": {
3+
"CreateRole": {
4+
"Fn::Not": [
5+
{
6+
"Fn::Equals": [
7+
"",
8+
{
9+
"Ref": "iamRoleArn"
10+
}
11+
]
12+
}
13+
]
14+
}
15+
},
16+
"Parameters": {
17+
"iamRoleArn": {
18+
"Description": "The ARN of an IAM role to use as this function's execution role. If a role isn't specified, one is created for you with a logical ID of <function-logical-id>Role.",
19+
"Type": "String"
20+
}
21+
},
22+
"Resources": {
23+
"MinimalFunction": {
24+
"Properties": {
25+
"Code": {
26+
"S3Bucket": "sam-demo-bucket",
27+
"S3Key": "hello.zip"
28+
},
29+
"Handler": "hello.handler",
30+
"Role": {
31+
"Fn::If": [
32+
"CreateRole",
33+
{
34+
"Fn::GetAtt": [
35+
"MinimalFunctionRole",
36+
"Arn"
37+
]
38+
},
39+
{
40+
"Ref": "iamRoleArn"
41+
}
42+
]
43+
},
44+
"Runtime": "python3.10",
45+
"Tags": [
46+
{
47+
"Key": "lambda:createdBy",
48+
"Value": "SAM"
49+
}
50+
]
51+
},
52+
"Type": "AWS::Lambda::Function"
53+
},
54+
"MinimalFunctionRole": {
55+
"Condition": "CreateRole",
56+
"Properties": {
57+
"AssumeRolePolicyDocument": {
58+
"Statement": [
59+
{
60+
"Action": [
61+
"sts:AssumeRole"
62+
],
63+
"Effect": "Allow",
64+
"Principal": {
65+
"Service": [
66+
"lambda.amazonaws.com"
67+
]
68+
}
69+
}
70+
],
71+
"Version": "2012-10-17"
72+
},
73+
"ManagedPolicyArns": [
74+
"arn:aws-cn:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole"
75+
],
76+
"Tags": [
77+
{
78+
"Key": "lambda:createdBy",
79+
"Value": "SAM"
80+
}
81+
]
82+
},
83+
"Type": "AWS::IAM::Role"
84+
}
85+
}
86+
}

0 commit comments

Comments
 (0)