Skip to content
This repository was archived by the owner on Mar 23, 2026. It is now read-only.
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions localstack-core/localstack/services/sns/constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,19 @@
"SubscriptionRoleArn",
]


VALID_POLICY_ACTIONS = [
"GetTopicAttributes",
"SetTopicAttributes",
"AddPermission",
"RemovePermission",
"DeleteTopic",
"Subscribe",
"ListSubscriptionsByTopic",
"Publish",
"Receive",
]

MSG_ATTR_NAME_REGEX = re.compile(r"^(?!\.)(?!.*\.$)(?!.*\.\.)[a-zA-Z0-9_\-.]+$")
ATTR_TYPE_REGEX = re.compile(r"^(String|Number|Binary)\..+$")
VALID_MSG_ATTR_NAME_CHARS = set(ascii_letters + digits + "." + "-" + "_")
Expand Down
59 changes: 59 additions & 0 deletions localstack-core/localstack/services/sns/v2/provider.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,13 +10,15 @@

from localstack.aws.api import CommonServiceException, RequestContext
from localstack.aws.api.sns import (
ActionsList,
AmazonResourceName,
BatchEntryIdsNotDistinctException,
CheckIfPhoneNumberIsOptedOutResponse,
ConfirmSubscriptionResponse,
CreateEndpointResponse,
CreatePlatformApplicationResponse,
CreateTopicResponse,
DelegatesList,
Endpoint,
EndpointDisabledException,
GetEndpointAttributesResponse,
Expand Down Expand Up @@ -60,6 +62,7 @@
attributeValue,
authenticateOnUnsubscribe,
endpoint,
label,
message,
messageStructure,
nextToken,
Expand Down Expand Up @@ -88,6 +91,7 @@
SUBSCRIPTION_TOKENS_ENDPOINT,
VALID_APPLICATION_PLATFORMS,
VALID_MSG_ATTR_NAME_CHARS,
VALID_POLICY_ACTIONS,
VALID_SUBSCRIPTION_ATTR_NAME,
)
from localstack.services.sns.filter import FilterPolicyValidator
Expand Down Expand Up @@ -1114,6 +1118,61 @@ def opt_in_phone_number(
store.PHONE_NUMBERS_OPTED_OUT.remove(phone_number)
return OptInPhoneNumberResponse()

#
# Permission operations
#

def add_permission(
self,
context: RequestContext,
topic_arn: topicARN,
label: label,
aws_account_id: DelegatesList,
action_name: ActionsList,
**kwargs,
) -> None:
topic: Topic = self._get_topic(topic_arn, context)
policy = json.loads(topic["attributes"]["Policy"])
statement = next(
(statement for statement in policy["Statement"] if statement["Sid"] == label),
None,
)

if statement:
raise InvalidParameterException("Invalid parameter: Statement already exists")

if any(action not in VALID_POLICY_ACTIONS for action in action_name):
raise InvalidParameterException(
"Invalid parameter: Policy statement action out of service scope!"
)

principals = [
f"arn:{get_partition(context.region)}:iam::{account_id}:root"
for account_id in aws_account_id
]
actions = [f"SNS:{action}" for action in action_name]

statement = {
"Sid": label,
"Effect": "Allow",
"Principal": {"AWS": principals[0] if len(principals) == 1 else principals},
"Action": actions[0] if len(actions) == 1 else actions,
"Resource": topic_arn,
}

policy["Statement"].append(statement)
topic["attributes"]["Policy"] = json.dumps(policy)

def remove_permission(
self, context: RequestContext, topic_arn: topicARN, label: label, **kwargs
) -> None:
topic = self._get_topic(topic_arn, context)
policy = json.loads(topic["attributes"]["Policy"])
statements = policy["Statement"]
statements = [statement for statement in statements if statement["Sid"] != label]
policy["Statement"] = statements
topic["attributes"]["Policy"] = json.dumps(policy)

def list_tags_for_resource(
self, context: RequestContext, resource_arn: AmazonResourceName, **kwargs
) -> ListTagsForResourceResponse:
Expand Down
101 changes: 101 additions & 0 deletions tests/aws/services/sns/test_sns.py
Original file line number Diff line number Diff line change
Expand Up @@ -517,6 +517,107 @@ def test_topic_get_attributes_with_fifo_false(self, sns_create_topic, aws_client
)
snapshot.match("set-fifo-false-after-creation", e.value.response)

@markers.aws.validated
def test_topic_add_permission(self, sns_create_topic, aws_client, snapshot, account_id):
topic_arn = sns_create_topic()["TopicArn"]
resp = aws_client.sns.add_permission(
TopicArn=topic_arn, Label="test", AWSAccountId=[account_id], ActionName=["Publish"]
)
snapshot.match("add-permission-response", resp)

attributes_resp = aws_client.sns.get_topic_attributes(TopicArn=topic_arn)
policy_str = attributes_resp["Attributes"]["Policy"]
policy_json = json.loads(policy_str)
snapshot.match("topic-policy-after-permission", policy_json)

@markers.aws.validated
def test_topic_add_multiple_permissions(
self, sns_create_topic, aws_client, snapshot, account_id
):
topic_arn = sns_create_topic()["TopicArn"]
resp = aws_client.sns.add_permission(
TopicArn=topic_arn,
Label="test",
AWSAccountId=[account_id],
ActionName=["Publish", "Subscribe"],
)
snapshot.match("add-permission-response", resp)

attributes_resp = aws_client.sns.get_topic_attributes(TopicArn=topic_arn)
policy_str = attributes_resp["Attributes"]["Policy"]
policy_json = json.loads(policy_str)
snapshot.match("topic-policy-after-permission", policy_json)

@markers.aws.validated
def test_topic_remove_permission(self, sns_create_topic, aws_client, snapshot, account_id):
topic_arn = sns_create_topic()["TopicArn"]
label = "test"
aws_client.sns.add_permission(
TopicArn=topic_arn, Label=label, AWSAccountId=[account_id], ActionName=["Publish"]
)

aws_client.sns.remove_permission(TopicArn=topic_arn, Label=label)
attributes_resp = aws_client.sns.get_topic_attributes(TopicArn=topic_arn)
policy_str = attributes_resp["Attributes"]["Policy"]
policy_json = json.loads(policy_str)
snapshot.match("topic-policy", policy_json)

@markers.snapshot.skip_snapshot_verify(paths=["$..Error.Message"], condition=is_sns_v1_provider)
@markers.aws.validated
def test_add_permission_errors(self, snapshot, aws_client, account_id):
topic_name = f"topic-{short_uid()}"
topic_arn = aws_client.sns.create_topic(Name=topic_name)["TopicArn"]

aws_client.sns.add_permission(
TopicArn=topic_arn,
Label="test",
AWSAccountId=[account_id],
ActionName=["Publish"],
)

with pytest.raises(ClientError) as e:
aws_client.sns.add_permission(
TopicArn=topic_arn,
Label="test",
AWSAccountId=[account_id],
ActionName=["AddPermission"],
)
snapshot.match("duplicate-label", e.value.response)

with pytest.raises(ClientError) as e:
aws_client.sns.add_permission(
TopicArn=f"{topic_arn}-not-existing",
Label="test-2",
AWSAccountId=[account_id],
ActionName=["AddPermission"],
)
snapshot.match("topic-not-found", e.value.response)

with pytest.raises(ClientError) as e:
aws_client.sns.add_permission(
TopicArn=topic_arn,
Label="test-2",
AWSAccountId=[account_id],
ActionName=["InvalidAction"],
)
snapshot.match("invalid-action", e.value.response)

@markers.aws.validated
def test_remove_permission_errors(self, snapshot, aws_client, account_id):
topic_name = f"topic-{short_uid()}"
topic_arn = aws_client.sns.create_topic(Name=topic_name)["TopicArn"]
aws_client.sns.add_permission(
TopicArn=topic_arn,
Label="test",
AWSAccountId=[account_id],
ActionName=["Publish"],
)

with pytest.raises(ClientError) as e:
aws_client.sns.remove_permission(TopicArn=f"{topic_arn}-not-existing", Label="test")

snapshot.match("topic-not-found", e.value.response)


class TestSNSPublishCrud:
"""
Expand Down
Loading
Loading