Skip to content
This repository was archived by the owner on Mar 23, 2026. It is now read-only.

Commit 2046dff

Browse files
CFn: Handle Fn::Sub returning JSON strings for IAM policies (#13735)
1 parent 41e8292 commit 2046dff

File tree

5 files changed

+409
-24
lines changed

5 files changed

+409
-24
lines changed

localstack-core/localstack/services/iam/resource_providers/aws_iam_managedpolicy.py

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -61,7 +61,13 @@ def create(
6161
group_name = util.generate_default_name(request.stack_name, request.logical_resource_id)
6262
model["ManagedPolicyName"] = group_name
6363

64-
policy_doc = json.dumps(util.remove_none_values(model["PolicyDocument"]))
64+
# PolicyDocument can be either a dict or a JSON string (e.g., from Fn::Sub)
65+
policy_document = model["PolicyDocument"]
66+
if isinstance(policy_document, str):
67+
policy_doc = policy_document
68+
else:
69+
policy_doc = json.dumps(util.remove_none_values(policy_document))
70+
6571
policy = iam_client.create_policy(
6672
PolicyName=model["ManagedPolicyName"], PolicyDocument=policy_doc
6773
)

localstack-core/localstack/services/iam/resource_providers/aws_iam_policy.py

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -53,7 +53,13 @@ def create(
5353
model = request.desired_state
5454
iam_client = request.aws_client_factory.iam
5555

56-
policy_doc = json.dumps(util.remove_none_values(model["PolicyDocument"]))
56+
# PolicyDocument can be either a dict or a JSON string (e.g., from Fn::Sub)
57+
policy_document = model["PolicyDocument"]
58+
if isinstance(policy_document, str):
59+
policy_doc = policy_document
60+
else:
61+
policy_doc = json.dumps(util.remove_none_values(policy_document))
62+
5763
policy_name = model["PolicyName"]
5864

5965
if not any([model.get("Roles"), model.get("Users"), model.get("Groups")]):
@@ -122,7 +128,14 @@ def update(
122128
iam_client = request.aws_client_factory.iam
123129
model = request.desired_state
124130
# FIXME: this wasn't properly implemented before as well, still needs to be rewritten
125-
policy_doc = json.dumps(util.remove_none_values(model["PolicyDocument"]))
131+
132+
# PolicyDocument can be either a dict or a JSON string (e.g., from Fn::Sub)
133+
policy_document = model["PolicyDocument"]
134+
if isinstance(policy_document, str):
135+
policy_doc = policy_document
136+
else:
137+
policy_doc = json.dumps(util.remove_none_values(policy_document))
138+
126139
policy_name = model["PolicyName"]
127140

128141
for role in model.get("Roles", []):

tests/aws/services/cloudformation/resource_providers/iam/test_iam.py

Lines changed: 210 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -333,3 +333,213 @@ def test_updating_stack_with_iam_role(deploy_cfn_template, aws_client):
333333
"Role"
334334
)
335335
assert stack.outputs["TestStackRoleName"] == lambda_role_name_new
336+
337+
338+
@markers.aws.validated
339+
def test_managedpolicy_with_fn_sub_json_string(deploy_cfn_template, snapshot, aws_client):
340+
snapshot.add_transformer(snapshot.transform.iam_api())
341+
342+
policy_name = f"test-policy-{short_uid()}"
343+
344+
template_json = {
345+
"AWSTemplateFormatVersion": "2010-09-09",
346+
"Parameters": {"ClusterName": {"Type": "String", "Default": "test-cluster"}},
347+
"Resources": {
348+
"TestPolicy": {
349+
"Type": "AWS::IAM::ManagedPolicy",
350+
"Properties": {
351+
"ManagedPolicyName": policy_name,
352+
"PolicyDocument": {
353+
"Fn::Sub": json.dumps(
354+
{
355+
"Version": "2012-10-17",
356+
"Statement": [
357+
{
358+
"Effect": "Allow",
359+
"Action": "s3:GetObject",
360+
"Resource": "arn:${AWS::Partition}:s3:::bucket-${ClusterName}-${AWS::Region}/*",
361+
}
362+
],
363+
}
364+
)
365+
},
366+
},
367+
}
368+
},
369+
"Outputs": {"PolicyArn": {"Value": {"Ref": "TestPolicy"}}},
370+
}
371+
372+
stack = deploy_cfn_template(
373+
template=json.dumps(template_json), parameters={"ClusterName": "my-cluster"}
374+
)
375+
376+
policy_arn = stack.outputs["PolicyArn"]
377+
policy_version = aws_client.iam.get_policy_version(
378+
PolicyArn=policy_arn,
379+
VersionId=aws_client.iam.get_policy(PolicyArn=policy_arn)["Policy"]["DefaultVersionId"],
380+
)["PolicyVersion"]
381+
382+
snapshot.match("policy_document", policy_version["Document"])
383+
384+
385+
@markers.aws.validated
386+
def test_managedpolicy_with_fn_sub_multiline_yaml(deploy_cfn_template, snapshot, aws_client):
387+
snapshot.add_transformer(snapshot.transform.iam_api())
388+
389+
policy_name = f"test-policy-{short_uid()}"
390+
391+
template_yaml = f"""
392+
AWSTemplateFormatVersion: "2010-09-09"
393+
Parameters:
394+
ClusterName:
395+
Type: String
396+
Default: "test-cluster"
397+
Resources:
398+
TestPolicy:
399+
Type: AWS::IAM::ManagedPolicy
400+
Properties:
401+
ManagedPolicyName: {policy_name}
402+
PolicyDocument: !Sub |
403+
{{
404+
"Version": "2012-10-17",
405+
"Statement": [
406+
{{
407+
"Effect": "Allow",
408+
"Action": "eks:DescribeCluster",
409+
"Resource": "arn:${{AWS::Partition}}:eks:${{AWS::Region}}:${{AWS::AccountId}}:cluster/${{ClusterName}}"
410+
}}
411+
]
412+
}}
413+
Outputs:
414+
PolicyArn:
415+
Value: !Ref TestPolicy
416+
"""
417+
418+
stack = deploy_cfn_template(template=template_yaml, parameters={"ClusterName": "my-cluster"})
419+
420+
policy_arn = stack.outputs["PolicyArn"]
421+
policy_version = aws_client.iam.get_policy_version(
422+
PolicyArn=policy_arn,
423+
VersionId=aws_client.iam.get_policy(PolicyArn=policy_arn)["Policy"]["DefaultVersionId"],
424+
)["PolicyVersion"]
425+
426+
snapshot.match("policy_document_multiline", policy_version["Document"])
427+
428+
429+
@markers.aws.validated
430+
def test_managedpolicy_with_fn_sub_and_arrays(deploy_cfn_template, snapshot, aws_client):
431+
snapshot.add_transformer(snapshot.transform.iam_api())
432+
433+
policy_name = f"test-policy-{short_uid()}"
434+
435+
template = {
436+
"AWSTemplateFormatVersion": "2010-09-09",
437+
"Parameters": {"BucketName": {"Type": "String", "Default": "my-bucket"}},
438+
"Resources": {
439+
"TestPolicy": {
440+
"Type": "AWS::IAM::ManagedPolicy",
441+
"Properties": {
442+
"ManagedPolicyName": policy_name,
443+
"PolicyDocument": {
444+
"Fn::Sub": json.dumps(
445+
{
446+
"Version": "2012-10-17",
447+
"Statement": [
448+
{
449+
"Effect": "Allow",
450+
"Action": ["s3:GetObject", "s3:PutObject"],
451+
"Resource": [
452+
"arn:${AWS::Partition}:s3:::${BucketName}/*",
453+
"arn:${AWS::Partition}:s3:::${BucketName}-${AWS::Region}/*",
454+
],
455+
}
456+
],
457+
}
458+
)
459+
},
460+
},
461+
}
462+
},
463+
"Outputs": {"PolicyArn": {"Value": {"Ref": "TestPolicy"}}},
464+
}
465+
466+
stack = deploy_cfn_template(
467+
template=json.dumps(template), parameters={"BucketName": "test-bucket"}
468+
)
469+
470+
policy_arn = stack.outputs["PolicyArn"]
471+
policy_version = aws_client.iam.get_policy_version(
472+
PolicyArn=policy_arn,
473+
VersionId=aws_client.iam.get_policy(PolicyArn=policy_arn)["Policy"]["DefaultVersionId"],
474+
)["PolicyVersion"]
475+
476+
snapshot.match("policy_with_arrays", policy_version["Document"])
477+
478+
479+
@markers.aws.validated
480+
def test_inline_policy_with_fn_sub_json_string(deploy_cfn_template, snapshot, aws_client):
481+
"""
482+
Test inline IAM Policy (AWS::IAM::Policy) with Fn::Sub returning a JSON string.
483+
484+
Verifies that inline policies also handle JSON strings from Fn::Sub correctly.
485+
"""
486+
snapshot.add_transformer(snapshot.transform.iam_api())
487+
488+
role_name = f"test-role-{short_uid()}"
489+
policy_name = f"test-policy-{short_uid()}"
490+
491+
template = {
492+
"AWSTemplateFormatVersion": "2010-09-09",
493+
"Resources": {
494+
"TestRole": {
495+
"Type": "AWS::IAM::Role",
496+
"Properties": {
497+
"RoleName": role_name,
498+
"AssumeRolePolicyDocument": {
499+
"Version": "2012-10-17",
500+
"Statement": [
501+
{
502+
"Effect": "Allow",
503+
"Principal": {"Service": "lambda.amazonaws.com"},
504+
"Action": "sts:AssumeRole",
505+
}
506+
],
507+
},
508+
},
509+
},
510+
"TestPolicy": {
511+
"Type": "AWS::IAM::Policy",
512+
"Properties": {
513+
"PolicyName": policy_name,
514+
"Roles": [{"Ref": "TestRole"}],
515+
"PolicyDocument": {
516+
"Fn::Sub": json.dumps(
517+
{
518+
"Version": "2012-10-17",
519+
"Statement": [
520+
{
521+
"Effect": "Allow",
522+
"Action": "s3:GetObject",
523+
"Resource": "arn:${AWS::Partition}:s3:::my-bucket-${AWS::Region}/*",
524+
}
525+
],
526+
}
527+
)
528+
},
529+
},
530+
},
531+
},
532+
}
533+
534+
deploy_cfn_template(template=json.dumps(template))
535+
536+
# Verify the inline policy was attached to the role
537+
policies = aws_client.iam.list_role_policies(RoleName=role_name)
538+
assert policy_name in policies["PolicyNames"]
539+
540+
# Get the policy document
541+
policy_doc = aws_client.iam.get_role_policy(RoleName=role_name, PolicyName=policy_name)[
542+
"PolicyDocument"
543+
]
544+
545+
snapshot.match("inline_policy_document", policy_doc)

tests/aws/services/cloudformation/resource_providers/iam/test_iam.snapshot.json

Lines changed: 76 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,24 +1,24 @@
11
{
22
"tests/aws/services/cloudformation/resource_providers/iam/test_iam.py::test_iam_username_defaultname": {
3-
"recorded-date": "31-05-2022, 11:29:45",
3+
"recorded-date": "10-02-2026, 15:51:18",
44
"recorded-content": {
55
"get_iam_user": {
66
"User": {
7+
"Arn": "arn:<partition>:iam::111111111111:user/<user-name:1>",
8+
"CreateDate": "datetime",
79
"Path": "/",
8-
"UserName": "<user-name:1>",
910
"UserId": "<user-id:1>",
10-
"Arn": "arn:<partition>:iam::111111111111:user/<user-name:1>",
11-
"CreateDate": "datetime"
11+
"UserName": "<user-name:1>"
1212
},
1313
"ResponseMetadata": {
14-
"HTTPStatusCode": 200,
15-
"HTTPHeaders": {}
14+
"HTTPHeaders": {},
15+
"HTTPStatusCode": 200
1616
}
1717
}
1818
}
1919
},
2020
"tests/aws/services/cloudformation/resource_providers/iam/test_iam.py::test_managed_policy_with_empty_resource": {
21-
"recorded-date": "11-07-2023, 18:10:41",
21+
"recorded-date": "10-02-2026, 15:56:19",
2222
"recorded-content": {
2323
"outputs": {
2424
"PolicyArn": "arn:<partition>:iam::111111111111:policy/<policy-name:1>",
@@ -48,7 +48,7 @@
4848
}
4949
},
5050
"tests/aws/services/cloudformation/resource_providers/iam/test_iam.py::test_iam_user_access_key": {
51-
"recorded-date": "11-07-2023, 08:23:54",
51+
"recorded-date": "10-02-2026, 15:52:59",
5252
"recorded-content": {
5353
"key_outputs": {
5454
"AccessKeyId": "<key-id:1>",
@@ -69,7 +69,7 @@
6969
}
7070
},
7171
"tests/aws/services/cloudformation/resource_providers/iam/test_iam.py::test_update_inline_policy": {
72-
"recorded-date": "05-04-2023, 11:55:22",
72+
"recorded-date": "10-02-2026, 15:54:55",
7373
"recorded-content": {
7474
"user_inline_policy": {
7575
"PolicyDocument": {
@@ -156,7 +156,7 @@
156156
}
157157
},
158158
"tests/aws/services/cloudformation/resource_providers/iam/test_iam.py::test_server_certificate": {
159-
"recorded-date": "13-03-2024, 20:20:07",
159+
"recorded-date": "10-02-2026, 15:58:53",
160160
"recorded-content": {
161161
"outputs": {
162162
"Arn": "arn:<partition>:iam::111111111111:server-certificate/<server-certificate-name:1>",
@@ -192,5 +192,71 @@
192192
}
193193
}
194194
}
195+
},
196+
"tests/aws/services/cloudformation/resource_providers/iam/test_iam.py::test_managedpolicy_with_fn_sub_json_string": {
197+
"recorded-date": "10-02-2026, 16:02:06",
198+
"recorded-content": {
199+
"policy_document": {
200+
"Statement": [
201+
{
202+
"Action": "s3:GetObject",
203+
"Effect": "Allow",
204+
"Resource": "arn:<partition>:s3:::bucket-my-cluster-<region>/*"
205+
}
206+
],
207+
"Version": "2012-10-17"
208+
}
209+
}
210+
},
211+
"tests/aws/services/cloudformation/resource_providers/iam/test_iam.py::test_managedpolicy_with_fn_sub_multiline_yaml": {
212+
"recorded-date": "10-02-2026, 16:02:38",
213+
"recorded-content": {
214+
"policy_document_multiline": {
215+
"Statement": [
216+
{
217+
"Action": "eks:DescribeCluster",
218+
"Effect": "Allow",
219+
"Resource": "arn:<partition>:eks:<region>:111111111111:cluster/my-cluster"
220+
}
221+
],
222+
"Version": "2012-10-17"
223+
}
224+
}
225+
},
226+
"tests/aws/services/cloudformation/resource_providers/iam/test_iam.py::test_managedpolicy_with_fn_sub_and_arrays": {
227+
"recorded-date": "10-02-2026, 16:03:09",
228+
"recorded-content": {
229+
"policy_with_arrays": {
230+
"Statement": [
231+
{
232+
"Action": [
233+
"s3:GetObject",
234+
"s3:PutObject"
235+
],
236+
"Effect": "Allow",
237+
"Resource": [
238+
"arn:<partition>:s3:::test-bucket/*",
239+
"arn:<partition>:s3:::test-bucket-<region>/*"
240+
]
241+
}
242+
],
243+
"Version": "2012-10-17"
244+
}
245+
}
246+
},
247+
"tests/aws/services/cloudformation/resource_providers/iam/test_iam.py::test_inline_policy_with_fn_sub_json_string": {
248+
"recorded-date": "10-02-2026, 16:03:58",
249+
"recorded-content": {
250+
"inline_policy_document": {
251+
"Statement": [
252+
{
253+
"Action": "s3:GetObject",
254+
"Effect": "Allow",
255+
"Resource": "arn:<partition>:s3:::my-bucket-<region>/*"
256+
}
257+
],
258+
"Version": "2012-10-17"
259+
}
260+
}
195261
}
196262
}

0 commit comments

Comments
 (0)