Repository: getmoto/moto
Pull request #7182 (2024-01-03 19:00:06)

SSO-admin: Add basic inline policy crud operations
Implemented basic CRUD operations for the following sso-admin methods:

- [get_inline_policy_for_permission_set](https://boto3.amazonaws.com/v1/documentation/api/latest/reference/services/sso-admin/client/get_inline_policy_for_permission_set.html)
- [put_inline_policy_to_permission_set](https://boto3.amazonaws.com/v1/documentation/api/latest/reference/services/sso-admin/client/put_inline_policy_to_permission_set.html)
- [delete_inline_policy_from_permission_set](https://boto3.amazonaws.com/v1/documentation/api/latest/reference/services/sso-admin/client/delete_inline_policy_from_permission_set.html)

Additionally renamed the the exception `ResourceNotFound` to `ResourceNotFoundException` to comply with the proper name for the exception (`SSOAdmin.Client.exceptions.ResourceNotFoundException`)

codecov[bot]: ## [Codecov](https://app.codecov.io/gh/getmoto/moto/pull/7182?src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=getmoto) Report
All modified and coverable lines are covered by tests :white_check_mark:
> Comparison is base [(`ea47ba7`)](https://app.codecov.io/gh/getmoto/moto/commit/ea47ba7939e1a53a0d3e6146c6eaae29a1e54c1f?el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=getmoto) 95.90% compared to head [(`8b2c4a2`)](https://app.codecov.io/gh/getmoto/moto/pull/7182?src=pr&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=getmoto) 95.90%.


<details><summary>Additional details and impacted files</summary>


```diff
@@           Coverage Diff           @@
##           master    #7182   +/-   ##
=======================================
  Coverage   95.90%   95.90%           
=======================================
  Files         840      840           
  Lines       82512    82539   +27     
=======================================
+ Hits        79134    79161   +27     
  Misses       3378     3378           
```

| [Flag](https://app.codecov.io/gh/getmoto/moto/pull/7182/flags?src=pr&el=flags&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=getmoto) | Coverage Δ | |
|---|---|---|
| [servertests](https://app.codecov.io/gh/getmoto/moto/pull/7182/flags?src=pr&el=flag&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=getmoto) | `35.47% <27.27%> (-0.01%)` | :arrow_down: |
| [unittests](https://app.codecov.io/gh/getmoto/moto/pull/7182/flags?src=pr&el=flag&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=getmoto) | `95.85% <100.00%> (+<0.01%)` | :arrow_up: |

Flags with carried forward coverage won't be shown. [Click here](https://docs.codecov.io/docs/carryforward-flags?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=getmoto#carryforward-flags-in-the-pull-request-comment) to find out more.


</details>

[:umbrella: View full report in Codecov by Sentry](https://app.codecov.io/gh/getmoto/moto/pull/7182?src=pr&el=continue&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=getmoto).   
:loudspeaker: Have feedback on the report? [Share it here](https://about.codecov.io/codecov-pr-comment-feedback/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=getmoto).


Patch:
diff --git a/moto/ssoadmin/exceptions.py b/moto/ssoadmin/exceptions.py
index 990eaa5de..a5f5df6e2 100644
--- a/moto/ssoadmin/exceptions.py
+++ b/moto/ssoadmin/exceptions.py
@@ -2,6 +2,10 @@
 from moto.core.exceptions import JsonRESTError
 
 
-class ResourceNotFound(JsonRESTError):
-    def __init__(self) -> None:
-        super().__init__("ResourceNotFound", "Account not found")
+class ResourceNotFoundException(JsonRESTError):
+    def __init__(self, message: str = "Account not found") -> None:
+        super().__init__(
+            error_type="ResourceNotFoundException",
+            message=message,
+            code="ResourceNotFoundException",
+        )
diff --git a/moto/ssoadmin/models.py b/moto/ssoadmin/models.py
index 4a5097525..5cb410868 100644
--- a/moto/ssoadmin/models.py
+++ b/moto/ssoadmin/models.py
@@ -5,7 +5,7 @@ from moto.core.utils import unix_time
 from moto.moto_api._internal import mock_random as random
 from moto.utilities.paginator import paginate
 
-from .exceptions import ResourceNotFound
+from .exceptions import ResourceNotFoundException
 from .utils import PAGINATION_MODEL
 
 
@@ -59,6 +59,7 @@ class PermissionSet(BaseModel):
         self.relay_state = relay_state
         self.tags = tags
         self.created_date = unix_time()
+        self.inline_policy = ""
 
     def to_json(self, include_creation_date: bool = False) -> Dict[str, Any]:
         summary: Dict[str, Any] = {
@@ -155,14 +156,12 @@ class SSOAdminBackend(BaseBackend):
                 and principal_id_match
             ):
                 return account
-        raise ResourceNotFound
+        raise ResourceNotFoundException
 
+    @paginate(PAGINATION_MODEL)  # type: ignore[misc]
     def list_account_assignments(
         self, instance_arn: str, account_id: str, permission_set_arn: str
     ) -> List[Dict[str, Any]]:
-        """
-        Pagination has not yet been implemented
-        """
         account_assignments = []
         for assignment in self.account_assignments:
             if (
@@ -172,7 +171,7 @@ class SSOAdminBackend(BaseBackend):
             ):
                 account_assignments.append(
                     {
-                        "AccountId": account_id,
+                        "AccountId": assignment.target_id,
                         "PermissionSetArn": assignment.permission_set_arn,
                         "PrincipalType": assignment.principal_type,
                         "PrincipalId": assignment.principal_id,
@@ -275,7 +274,10 @@ class SSOAdminBackend(BaseBackend):
             )
             if instance_arn_match and permission_set_match:
                 return permission_set
-        raise ResourceNotFound
+        ps_id = permission_set_arn.split("/")[-1]
+        raise ResourceNotFoundException(
+            message=f"Could not find PermissionSet with id {ps_id}"
+        )
 
     @paginate(pagination_model=PAGINATION_MODEL)  # type: ignore[misc]
     def list_permission_sets(self, instance_arn: str) -> List[PermissionSet]:
@@ -285,5 +287,32 @@ class SSOAdminBackend(BaseBackend):
                 permission_sets.append(permission_set)
         return permission_sets
 
+    def put_inline_policy_to_permission_set(
+        self, instance_arn: str, permission_set_arn: str, inline_policy: str
+    ) -> None:
+        permission_set = self._find_permission_set(
+            instance_arn,
+            permission_set_arn,
+        )
+        permission_set.inline_policy = inline_policy
+
+    def get_inline_policy_for_permission_set(
+        self, instance_arn: str, permission_set_arn: str
+    ) -> str:
+        permission_set = self._find_permission_set(
+            instance_arn,
+            permission_set_arn,
+        )
+        return permission_set.inline_policy
+
+    def delete_inline_policy_from_permission_set(
+        self, instance_arn: str, permission_set_arn: str
+    ) -> None:
+        permission_set = self._find_permission_set(
+            instance_arn,
+            permission_set_arn,
+        )
+        permission_set.inline_policy = ""
+
 
 ssoadmin_backends = BackendDict(SSOAdminBackend, "sso")
diff --git a/moto/ssoadmin/responses.py b/moto/ssoadmin/responses.py
index e89ca0e89..dec1cb94c 100644
--- a/moto/ssoadmin/responses.py
+++ b/moto/ssoadmin/responses.py
@@ -62,12 +62,18 @@ class SSOAdminResponse(BaseResponse):
         instance_arn = params.get("InstanceArn")
         account_id = params.get("AccountId")
         permission_set_arn = params.get("PermissionSetArn")
-        assignments = self.ssoadmin_backend.list_account_assignments(
+        max_results = self._get_param("MaxResults")
+        next_token = self._get_param("NextToken")
+
+        assignments, next_token = self.ssoadmin_backend.list_account_assignments(
             instance_arn=instance_arn,
             account_id=account_id,
             permission_set_arn=permission_set_arn,
+            next_token=next_token,
+            max_results=max_results,
         )
-        return json.dumps({"AccountAssignments": assignments})
+
+        return json.dumps(dict(AccountAssignments=assignments, NextToken=next_token))
 
     def list_account_assignments_for_principal(self) -> str:
         filter_ = self._get_param("Filter", {})
@@ -160,3 +166,32 @@ class SSOAdminResponse(BaseResponse):
         if next_token:
             response["NextToken"] = next_token
         return json.dumps(response)
+
+    def put_inline_policy_to_permission_set(self) -> str:
+        instance_arn = self._get_param("InstanceArn")
+        permission_set_arn = self._get_param("PermissionSetArn")
+        inline_policy = self._get_param("InlinePolicy")
+        self.ssoadmin_backend.put_inline_policy_to_permission_set(
+            instance_arn=instance_arn,
+            permission_set_arn=permission_set_arn,
+            inline_policy=inline_policy,
+        )
+        return json.dumps({})
+
+    def get_inline_policy_for_permission_set(self) -> str:
+        instance_arn = self._get_param("InstanceArn")
+        permission_set_arn = self._get_param("PermissionSetArn")
+        inline_policy = self.ssoadmin_backend.get_inline_policy_for_permission_set(
+            instance_arn=instance_arn,
+            permission_set_arn=permission_set_arn,
+        )
+        return json.dumps({"InlinePolicy": inline_policy})
+
+    def delete_inline_policy_from_permission_set(self) -> str:
+        instance_arn = self._get_param("InstanceArn")
+        permission_set_arn = self._get_param("PermissionSetArn")
+        self.ssoadmin_backend.delete_inline_policy_from_permission_set(
+            instance_arn=instance_arn,
+            permission_set_arn=permission_set_arn,
+        )
+        return json.dumps({})
diff --git a/moto/ssoadmin/utils.py b/moto/ssoadmin/utils.py
index 45fc457f2..e7c3230ed 100644
--- a/moto/ssoadmin/utils.py
+++ b/moto/ssoadmin/utils.py
@@ -10,7 +10,19 @@ PAGINATION_MODEL = {
         "input_token": "next_token",
         "limit_key": "max_results",
         "limit_default": 100,
-        "result_key": "PermissionSets",
+        "result_key": "AccountAssignments",
+        "unique_attribute": [
+            "AccountId",
+            "PermissionSetArn",
+            "PrincipalId",
+            "PrincipalType",
+        ],
+    },
+    "list_account_assignments": {
+        "input_token": "next_token",
+        "limit_key": "max_results",
+        "limit_default": 100,
+        "result_key": "AccountAssignments",
         "unique_attribute": [
             "AccountId",
             "PermissionSetArn",


Test patch:
diff --git a/tests/test_ssoadmin/test_server.py b/tests/test_ssoadmin/test_server.py
index 51623d78a..5e76e1b69 100644
--- a/tests/test_ssoadmin/test_server.py
+++ b/tests/test_ssoadmin/test_server.py
@@ -23,4 +23,4 @@ def test_ssoadmin_list():
     resp = test_client.post("/", headers=headers, data=json.dumps(data))
 
     assert resp.status_code == 200
-    assert json.loads(resp.data) == {"AccountAssignments": []}
+    assert json.loads(resp.data) == {"AccountAssignments": [], "NextToken": None}
diff --git a/tests/test_ssoadmin/test_ssoadmin.py b/tests/test_ssoadmin/test_ssoadmin.py
index ab1e88a17..e3a134814 100644
--- a/tests/test_ssoadmin/test_ssoadmin.py
+++ b/tests/test_ssoadmin/test_ssoadmin.py
@@ -118,7 +118,7 @@ def test_delete_account_assignment_unknown():
             PrincipalId=principal_id,
         )
     err = exc.value.response["Error"]
-    assert err["Code"] == "ResourceNotFound"
+    assert err["Code"] == "ResourceNotFoundException"
 
 
 @mock_ssoadmin
@@ -190,6 +190,66 @@ def test_list_account_assignments():
     ]
 
 
+@mock_ssoadmin
+def test_list_account_assignments_pagination():
+    client = boto3.client("sso-admin", region_name="ap-southeast-1")
+    DUMMY_AWS_ACCOUNT_ID = "111111111111"
+
+    dummy_account_assignments = []
+    for _ in range(3):
+        dummy_account_assignments.append(
+            {
+                "InstanceArn": DUMMY_INSTANCE_ARN,
+                "TargetId": DUMMY_AWS_ACCOUNT_ID,
+                "TargetType": "AWS_ACCOUNT",
+                "PermissionSetArn": DUMMY_PERMISSIONSET_ID,
+                "PrincipalType": "USER",
+                "PrincipalId": str(uuid4()),
+            },
+        )
+
+    for dummy_account_assignment in dummy_account_assignments:
+        client.create_account_assignment(**dummy_account_assignment)
+
+    account_assignments = []
+
+    response = client.list_account_assignments(
+        InstanceArn=DUMMY_INSTANCE_ARN,
+        AccountId=DUMMY_AWS_ACCOUNT_ID,
+        PermissionSetArn=DUMMY_PERMISSIONSET_ID,
+        MaxResults=2,
+    )
+
+    assert len(response["AccountAssignments"]) == 2
+    account_assignments.extend(response["AccountAssignments"])
+
+    next_token = response["NextToken"]
+
+    response = client.list_account_assignments(
+        InstanceArn=DUMMY_INSTANCE_ARN,
+        AccountId=DUMMY_AWS_ACCOUNT_ID,
+        PermissionSetArn=DUMMY_PERMISSIONSET_ID,
+        MaxResults=2,
+        NextToken=next_token,
+    )
+
+    assert len(response["AccountAssignments"]) == 1
+    account_assignments.extend(response["AccountAssignments"])
+
+    # ensure 3 unique assignments returned
+    assert (
+        len(
+            set(
+                [
+                    account_assignment["PrincipalId"]
+                    for account_assignment in account_assignments
+                ]
+            )
+        )
+        == 3
+    )
+
+
 @mock_ssoadmin
 def test_list_account_assignments_for_principal():
     client = boto3.client("sso-admin", region_name="us-west-2")
@@ -391,7 +451,7 @@ def test_update_permission_set_unknown():
             RelayState="https://console.aws.amazon.com/s3",
         )
     err = exc.value.response["Error"]
-    assert err["Code"] == "ResourceNotFound"
+    assert err["Code"] == "ResourceNotFoundException"
 
 
 @mock_ssoadmin
@@ -428,7 +488,7 @@ def test_describe_permission_set_unknown():
             PermissionSetArn="arn:aws:sso:::permissionSet/ins-eeeeffffgggghhhh/ps-hhhhkkkkppppoooo",
         )
     err = exc.value.response["Error"]
-    assert err["Code"] == "ResourceNotFound"
+    assert err["Code"] == "ResourceNotFoundException"
 
 
 @mock_ssoadmin
@@ -451,7 +511,7 @@ def test_delete_permission_set():
             PermissionSetArn=permission_set["PermissionSetArn"],
         )
     err = exc.value.response["Error"]
-    assert err["Code"] == "ResourceNotFound"
+    assert err["Code"] == "ResourceNotFoundException"
 
 
 @mock_ssoadmin
@@ -464,7 +524,7 @@ def test_delete_permission_set_unknown():
             PermissionSetArn="arn:aws:sso:::permissionSet/ins-eeeeffffgggghhhh/ps-hhhhkkkkppppoooo",
         )
     err = exc.value.response["Error"]
-    assert err["Code"] == "ResourceNotFound"
+    assert err["Code"] == "ResourceNotFoundException"
 
 
 @mock_ssoadmin
diff --git a/tests/test_ssoadmin/test_ssoadmin_policies.py b/tests/test_ssoadmin/test_ssoadmin_policies.py
new file mode 100644
index 000000000..7eee8260c
--- /dev/null
+++ b/tests/test_ssoadmin/test_ssoadmin_policies.py
@@ -0,0 +1,132 @@
+import json
+
+import boto3
+import pytest
+from botocore.exceptions import ClientError
+
+from moto import mock_ssoadmin
+
+# See our Development Tips on writing tests for hints on how to write good tests:
+# http://docs.getmoto.org/en/latest/docs/contributing/development_tips/tests.html
+
+DUMMY_PERMISSIONSET_ID = (
+    "arn:aws:sso:::permissionSet/ins-eeeeffffgggghhhh/ps-hhhhkkkkppppoooo"
+)
+DUMMY_INSTANCE_ARN = "arn:aws:sso:::instance/ins-aaaabbbbccccdddd"
+
+
+def create_permissionset(client) -> str:
+    """Helper function to create a dummy permission set and returns the arn."""
+
+    response = client.create_permission_set(
+        Name="test-permission-set",
+        InstanceArn=DUMMY_INSTANCE_ARN,
+        Description="test permission set",
+    )
+
+    return response["PermissionSet"]["PermissionSetArn"]
+
+
+@mock_ssoadmin
+def test_put_inline_policy_to_permission_set():
+    """
+    Tests putting and getting an inline policy to a permission set.
+    """
+    client = boto3.client("sso-admin", region_name="us-east-1")
+
+    permission_set_arn = create_permissionset(client)
+    dummy_policy = {
+        "Version": "2012-10-17",
+        "Statement": [
+            {
+                "Effect": "Allow",
+                "Action": "s3:GetObject",
+                "Resource": "arn:aws:s3:::your-bucket-name/*",
+            }
+        ],
+    }
+
+    # Happy path
+    response = client.put_inline_policy_to_permission_set(
+        InstanceArn=DUMMY_INSTANCE_ARN,
+        PermissionSetArn=permission_set_arn,
+        InlinePolicy=json.dumps(dummy_policy),
+    )
+
+    response = client.get_inline_policy_for_permission_set(
+        InstanceArn=DUMMY_INSTANCE_ARN,
+        PermissionSetArn=permission_set_arn,
+    )
+
+    assert response["InlinePolicy"] == json.dumps(dummy_policy)
+
+    # Invalid permission set arn
+    not_create_ps_arn = (
+        "arn:aws:sso:::permissionSet/ins-eeeeffffgggghhhh/ps-hhhhkkkkppppoxyz"
+    )
+    with pytest.raises(ClientError) as e:
+        client.put_inline_policy_to_permission_set(
+            InstanceArn=DUMMY_INSTANCE_ARN,
+            PermissionSetArn=not_create_ps_arn,
+            InlinePolicy=json.dumps(dummy_policy),
+        )
+    err = e.value.response["Error"]
+    assert err["Code"] == "ResourceNotFoundException"
+    assert err["Message"] == "Could not find PermissionSet with id ps-hhhhkkkkppppoxyz"
+
+
+@mock_ssoadmin
+def test_get_inline_policy_to_permission_set_no_policy():
+    client = boto3.client("sso-admin", region_name="us-east-1")
+
+    permission_set_arn = create_permissionset(client)
+
+    response = client.get_inline_policy_for_permission_set(
+        InstanceArn=DUMMY_INSTANCE_ARN,
+        PermissionSetArn=permission_set_arn,
+    )
+
+    assert response["InlinePolicy"] == ""
+
+
+@mock_ssoadmin
+def test_delete_inline_policy_to_permissionset():
+    client = boto3.client("sso-admin", region_name="us-east-1")
+
+    permission_set_arn = create_permissionset(client)
+
+    dummy_policy = {
+        "Version": "2012-10-17",
+        "Statement": [
+            {
+                "Effect": "Allow",
+                "Action": "s3:GetObject",
+                "Resource": "arn:aws:s3:::your-bucket-name/*",
+            }
+        ],
+    }
+
+    client.put_inline_policy_to_permission_set(
+        InstanceArn=DUMMY_INSTANCE_ARN,
+        PermissionSetArn=permission_set_arn,
+        InlinePolicy=json.dumps(dummy_policy),
+    )
+
+    response = client.get_inline_policy_for_permission_set(
+        InstanceArn=DUMMY_INSTANCE_ARN,
+        PermissionSetArn=permission_set_arn,
+    )
+
+    assert response["InlinePolicy"] == json.dumps(dummy_policy)
+
+    client.delete_inline_policy_from_permission_set(
+        InstanceArn=DUMMY_INSTANCE_ARN,
+        PermissionSetArn=permission_set_arn,
+    )
+
+    response = client.get_inline_policy_for_permission_set(
+        InstanceArn=DUMMY_INSTANCE_ARN,
+        PermissionSetArn=permission_set_arn,
+    )
+
+    assert response["InlinePolicy"] == ""