Repository: localstack/localstack

Issue:
bug: key error causes internal server exception rather than failing gracefully
### Is there an existing issue for this?

- [X] I have searched the existing issues

### Current Behavior

First [reported here](https://stackoverflow.com/questions/73934496/localstack-creating-stack-from-cloudformation-file-which-refer-to-some-existin), I've reproduced it but using terraform: when you try to attach the following policy you get an internal server error:

> <ErrorResponse xmlns="https://iam.amazonaws.com/doc/2010-05-08/"><Error><Code>InternalError</Code><Message>exception while calling iam.AttachRolePolicy: Traceback (most recent call last):
  File "/opt/code/localstack/localstack/aws/chain.py", line 90, in handle
    handler(self, self.context, response)
  File "/opt/code/localstack/localstack/aws/handlers/service.py", line 122, in __call__
    handler(chain, context, response)
  File "/opt/code/localstack/localstack/aws/handlers/service.py", line 92, in __call__
    skeleton_response = self.skeleton.invoke(context)
  File "/opt/code/localstack/localstack/aws/skeleton.py", line 153, in invoke
    return self.dispatch_request(context, instance)
  File "/opt/code/localstack/localstack/aws/skeleton.py", line 165, in dispatch_request
    result = handler(context, instance) or {}
  File "/opt/code/localstack/localstack/aws/forwarder.py", line 67, in _call
    return fallthrough_handler(context, req)
  File "/opt/code/localstack/localstack/services/moto.py", line 83, in _proxy_moto
    return call_moto(context)
  File "/opt/code/localstack/localstack/services/moto.py", line 46, in call_moto
    return dispatch_to_backend(context, dispatch_to_moto, include_response_metadata)
  File "/opt/code/localstack/localstack/aws/forwarder.py", line 120, in dispatch_to_backend
    http_response = http_request_dispatcher(context)
  File "/opt/code/localstack/localstack/services/moto.py", line 111, in dispatch_to_moto
    response = dispatch(request, request.url, request.headers)
  File "/opt/code/localstack/.venv/lib/python3.10/site-packages/moto/core/responses.py", line 225, in dispatch
    return cls()._dispatch(*args, **kwargs)
  File "/opt/code/localstack/.venv/lib/python3.10/site-packages/moto/core/responses.py", line 366, in _dispatch
    return self.call_action()
  File "/opt/code/localstack/.venv/lib/python3.10/site-packages/moto/core/responses.py", line 455, in call_action
    response = method()
  File "/opt/code/localstack/.venv/lib/python3.10/site-packages/moto/iam/responses.py", line 17, in attach_role_policy
    self.backend.attach_role_policy(policy_arn, role_name)
  File "/opt/code/localstack/.venv/lib/python3.10/site-packages/moto/iam/models.py", line 1702, in attach_role_policy
    policy = arns[policy_arn]
KeyError: 'local-bucket-access-policy'
</Message></Error><RequestId>DQJQDJKNC93JXLHUJ4T593IIKH8KS0OR7FJGL3J7EWIZKPAHGTRU</RequestId></ErrorResponse>" http.response.header.access_control_allow_headers=authorization,cache-control,content-length,content-md5,content-type,etag,location,x-amz-acl,x-amz-content-sha256,x-amz-date,x-amz-request-id,x-amz-security-token,x-amz-tagging,x-amz-target,x-amz-user-agent,x-amz-version-id,x-amzn-requestid,x-localstack-target,amz-sdk-invocation-id,amz-sdk-request http.response.header.access_control_allow_methods=HEAD,GET,PUT,POST,DELETE,OPTIONS,PATCH http.response.header.date="Wed, 22 Mar 2023 22:42:30 GMT" http.response.header.server=hypercorn-h11 http.response_content_length=2481 tf_req_id=74a915fe-1b32-ebcf-e1a6-88a84e30c539 @caller=github.com/hashicorp/aws-sdk-go-base/v2/awsv1shim/v2@v2.0.0-beta.25/logger.go:138 @module=aws http.duration=9 http.response.header.access_control_allow_origin=* http.response.header.access_control_expose_headers=etag,x-amz-version-id tf_provider_addr=registry.terraform.io/hashicorp/aws tf_resource_type=aws_iam_role_policy_attachment tf_rpc=ApplyResourceChange timestamp=2023-03-22T22:42:30.954Z


terraform (tflocal):

```terraform
resource "aws_s3_bucket" "bucket" {
  bucket = "mybucket"
}

# this works
resource "aws_iam_role" "lambda_role" {
  name   = "lambda_role"
  assume_role_policy = <<EOF
  {
  "Version": "2012-10-17",
  "Statement": [
    {
      "Action": "sts:AssumeRole",
      "Principal": {
        "Service": "lambda.amazonaws.com"
      },
      "Effect": "Allow",
      "Sid": ""
    }
  ]
  }
  EOF
}

resource "aws_iam_policy" "s3_access" {
  name = "local-bucket-access-policy"
  policy = jsonencode({
    "Version" : "2012-10-17",
    "Statement" : [
      {
        "Sid" : "",
        "Effect" : "Allow",
        "Action" : ["s3:GetObject", "s3:PutObject", "s3:DeleteObject"],
        "Resource" : "${aws_s3_bucket.bucket.arn}"
      }
    ]
  })
}
  
  

resource "aws_iam_role_policy_attachment" "exe" {
  role        = aws_iam_role.lambda_role.name
  policy_arn  = "arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole"
}

resource "aws_iam_role_policy_attachment" "s3_access" {
  role        = aws_iam_role.lambda_role.name

  # this causes the error, should use "arn" not "name"
  policy_arn  = aws_iam_policy.s3_access.name
}
```

### Expected Behavior

should attach the policy or give a reason why not due to the key error. The above error was only visible in debug logs

### How are you starting LocalStack?

With a docker-compose file

### Steps To Reproduce

#### How are you starting localstack (e.g., `bin/localstack` command, arguments, or `docker-compose.yml`)

    docker-compose up

#### Client commands (e.g., AWS SDK code snippet, or sequence of "awslocal" commands)

    tflocal apply


#### Compose file
```yaml
version: "3.8"

services:
  localstack:
    container_name: localstack-main
    image: "localstack/localstack:${LOCALSTACK_VERSION}"
    environment:
      - SERVICES=s3,lambda,logs,iam
      - PROVIDER_OVERRIDE_LAMBDA=asf 
      - DOCKER_HOST=unix:///var/run/docker.sock
      - DEBUG=1
      - EDGE_PORT=4566
      - AWS_ACCESS_KEY_ID=local
      - AWS_SECRET_ACCESS_KEY=local
      - DEFAULT_REGION=us-west-1
    ports:
      - "127.0.0.1:4566:4566"        
      - "127.0.0.1:4510-4559:4510-4559" 
    volumes:
      - "${LOCALSTACK_VOLUME_DIR:-./volume}:/var/lib/localstack"
      - "/var/run/docker.sock:/var/run/docker.sock"  
```

### Environment

```markdown
- OS: `Linux 07109cc2728b 5.15.0-67-generic #74-Ubuntu SMP Wed Feb 22 14:14:39 UTC 2023 aarch64 aarch64 aarch64 GNU/Linux`
- LocalStack: `1.4.0-arm64`
```


### Anything else?

_No response_


Discussion:
Welcome to LocalStack! Thanks for reporting your first issue and our team will be working towards fixing the issue for you or reach out for more background information. We recommend joining our [Slack Community](https://localstack.cloud/contact/) for real-time help and drop a message to LocalStack Pro Support if you are a Pro user! If you are willing to contribute towards fixing this issue, please have a look at our [contributing guidelines](https://github.com/localstack/.github/blob/main/CONTRIBUTING.md) and our [contributing guide](https://docs.localstack.cloud/contributing/).
I am having the same issue with version 2.0.2.

Resolving patch:
diff --git a/localstack/services/iam/provider.py b/localstack/services/iam/provider.py
--- a/localstack/services/iam/provider.py
+++ b/localstack/services/iam/provider.py
@@ -1,4 +1,5 @@
 import json
+import re
 from datetime import datetime
 from typing import Dict, List, Optional
 from urllib.parse import quote
@@ -26,6 +27,7 @@
     GetServiceLinkedRoleDeletionStatusResponse,
     GetUserResponse,
     IamApi,
+    InvalidInputException,
     ListInstanceProfileTagsResponse,
     ListRolesResponse,
     MalformedPolicyDocumentException,
@@ -89,6 +91,8 @@
     }
 }
 
+POLICY_ARN_REGEX = re.compile(r"arn:[^:]+:iam::(?:\d{12}|aws):policy/.*")
+
 
 def get_iam_backend(context: RequestContext) -> IAMBackend:
     return iam_backends[context.account_id]["global"]
@@ -416,6 +420,20 @@ def get_user(
 
         return response
 
+    def attach_role_policy(
+        self, context: RequestContext, role_name: roleNameType, policy_arn: arnType
+    ) -> None:
+        if not POLICY_ARN_REGEX.match(policy_arn):
+            raise InvalidInputException(f"ARN {policy_arn} is not valid.")
+        return call_moto(context=context)
+
+    def attach_user_policy(
+        self, context: RequestContext, user_name: userNameType, policy_arn: arnType
+    ) -> None:
+        if not POLICY_ARN_REGEX.match(policy_arn):
+            raise InvalidInputException(f"ARN {policy_arn} is not valid.")
+        return call_moto(context=context)
+
     # def get_user(
     #     self, context: RequestContext, user_name: existingUserNameType = None
     # ) -> GetUserResponse:


Test patch:
diff --git a/localstack/testing/snapshots/transformer_utility.py b/localstack/testing/snapshots/transformer_utility.py
--- a/localstack/testing/snapshots/transformer_utility.py
+++ b/localstack/testing/snapshots/transformer_utility.py
@@ -256,6 +256,8 @@ def iam_api():
             TransformerUtility.key_value("UserId"),
             TransformerUtility.key_value("RoleId"),
             TransformerUtility.key_value("RoleName"),
+            TransformerUtility.key_value("PolicyName"),
+            TransformerUtility.key_value("PolicyId"),
         ]
 
     @staticmethod
diff --git a/tests/integration/cloudformation/resources/test_iam.snapshot.json b/tests/integration/cloudformation/resources/test_iam.snapshot.json
--- a/tests/integration/cloudformation/resources/test_iam.snapshot.json
+++ b/tests/integration/cloudformation/resources/test_iam.snapshot.json
@@ -18,17 +18,17 @@
     }
   },
   "tests/integration/cloudformation/resources/test_iam.py::test_managed_policy_with_empty_resource": {
-    "recorded-date": "05-09-2022, 10:01:53",
+    "recorded-date": "11-07-2023, 18:10:41",
     "recorded-content": {
       "outputs": {
-        "PolicyArn": "arn:aws:iam::111111111111:policy/<resource:1>",
-        "StreamARN": "arn:aws:dynamodb:<region>:111111111111:table/<resource:3>/stream/<resource:2>",
-        "TableARN": "arn:aws:dynamodb:<region>:111111111111:table/<resource:3>",
-        "TableName": "<resource:3>"
+        "PolicyArn": "arn:aws:iam::111111111111:policy/<policy-name:1>",
+        "StreamARN": "arn:aws:dynamodb:<region>:111111111111:table/<resource:2>/stream/<resource:1>",
+        "TableARN": "arn:aws:dynamodb:<region>:111111111111:table/<resource:2>",
+        "TableName": "<resource:2>"
       },
       "managed_policy": {
         "Policy": {
-          "Arn": "arn:aws:iam::111111111111:policy/<resource:1>",
+          "Arn": "arn:aws:iam::111111111111:policy/<policy-name:1>",
           "AttachmentCount": 0,
           "CreateDate": "datetime",
           "DefaultVersionId": "v1",
@@ -36,7 +36,7 @@
           "Path": "/",
           "PermissionsBoundaryUsageCount": 0,
           "PolicyId": "<policy-id:1>",
-          "PolicyName": "<resource:1>",
+          "PolicyName": "<policy-name:1>",
           "Tags": [],
           "UpdateDate": "datetime"
         },
diff --git a/tests/integration/cloudformation/resources/test_sam.snapshot.json b/tests/integration/cloudformation/resources/test_sam.snapshot.json
--- a/tests/integration/cloudformation/resources/test_sam.snapshot.json
+++ b/tests/integration/cloudformation/resources/test_sam.snapshot.json
@@ -1,22 +1,22 @@
 {
   "tests/integration/cloudformation/resources/test_sam.py::test_sam_policies": {
-    "recorded-date": "02-06-2022, 13:13:20",
+    "recorded-date": "11-07-2023, 18:08:53",
     "recorded-content": {
       "list_attached_role_policies": {
         "AttachedPolicies": [
           {
-            "PolicyName": "AWSLambdaBasicExecutionRole",
-            "PolicyArn": "arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole"
+            "PolicyArn": "arn:aws:iam::aws:policy/service-role/<policy-name:1>",
+            "PolicyName": "<policy-name:1>"
           },
           {
-            "PolicyName": "AmazonSNSFullAccess",
-            "PolicyArn": "arn:aws:iam::aws:policy/AmazonSNSFullAccess"
+            "PolicyArn": "arn:aws:iam::aws:policy/<policy-name:2>",
+            "PolicyName": "<policy-name:2>"
           }
         ],
         "IsTruncated": false,
         "ResponseMetadata": {
-          "HTTPStatusCode": 200,
-          "HTTPHeaders": {}
+          "HTTPHeaders": {},
+          "HTTPStatusCode": 200
         }
       }
     }
diff --git a/tests/integration/test_iam.py b/tests/integration/test_iam.py
--- a/tests/integration/test_iam.py
+++ b/tests/integration/test_iam.py
@@ -585,3 +585,105 @@ def test_list_roles_with_permission_boundary(
 
         list_roles_result = aws_client.iam.list_roles(PathPrefix=path_prefix)
         snapshot.match("list_roles_result", list_roles_result)
+
+    @pytest.mark.aws_validated
+    @pytest.mark.skip_snapshot_verify(
+        paths=[
+            "$..Policy.IsAttachable",
+            "$..Policy.PermissionsBoundaryUsageCount",
+            "$..Policy.Tags",
+        ]
+    )
+    def test_role_attach_policy(self, snapshot, aws_client, create_role, create_policy):
+        snapshot.add_transformer(snapshot.transform.iam_api())
+
+        trust_policy = {
+            "Version": "2012-10-17",
+            "Statement": [
+                {
+                    "Effect": "Allow",
+                    "Principal": {"Service": "ec2.amazonaws.com"},
+                    "Action": "sts:AssumeRole",
+                }
+            ],
+        }
+        policy_document = {
+            "Version": "2012-10-17",
+            "Statement": [
+                {"Effect": "Allow", "Action": ["lambda:ListFunctions"], "Resource": ["*"]}
+            ],
+        }
+
+        role_name = f"test-role-{short_uid()}"
+        policy_name = f"test-policy-{short_uid()}"
+        create_role(RoleName=role_name, AssumeRolePolicyDocument=json.dumps(trust_policy))
+        create_policy_response = create_policy(
+            PolicyName=policy_name, PolicyDocument=json.dumps(policy_document)
+        )
+        snapshot.match("create_policy_response", create_policy_response)
+        policy_arn = create_policy_response["Policy"]["Arn"]
+
+        with pytest.raises(ClientError) as e:
+            aws_client.iam.attach_role_policy(
+                RoleName=role_name, PolicyArn="longpolicynamebutnoarn"
+            )
+        snapshot.match("non_existent_malformed_policy_arn", e.value.response)
+
+        with pytest.raises(ClientError) as e:
+            aws_client.iam.attach_role_policy(RoleName=role_name, PolicyArn=policy_name)
+        snapshot.match("existing_policy_name_provided", e.value.response)
+
+        with pytest.raises(ClientError) as e:
+            aws_client.iam.attach_role_policy(RoleName=role_name, PolicyArn=f"{policy_arn}123")
+        snapshot.match("valid_arn_not_existent", e.value.response)
+
+        attach_policy_response = aws_client.iam.attach_role_policy(
+            RoleName=role_name, PolicyArn=policy_arn
+        )
+        snapshot.match("valid_policy_arn", attach_policy_response)
+
+    @pytest.mark.aws_validated
+    @pytest.mark.skip_snapshot_verify(
+        paths=[
+            "$..Policy.IsAttachable",
+            "$..Policy.PermissionsBoundaryUsageCount",
+            "$..Policy.Tags",
+        ]
+    )
+    def test_user_attach_policy(self, snapshot, aws_client, create_user, create_policy):
+        snapshot.add_transformer(snapshot.transform.iam_api())
+
+        policy_document = {
+            "Version": "2012-10-17",
+            "Statement": [
+                {"Effect": "Allow", "Action": ["lambda:ListFunctions"], "Resource": ["*"]}
+            ],
+        }
+
+        user_name = f"test-role-{short_uid()}"
+        policy_name = f"test-policy-{short_uid()}"
+        create_user(UserName=user_name)
+        create_policy_response = create_policy(
+            PolicyName=policy_name, PolicyDocument=json.dumps(policy_document)
+        )
+        snapshot.match("create_policy_response", create_policy_response)
+        policy_arn = create_policy_response["Policy"]["Arn"]
+
+        with pytest.raises(ClientError) as e:
+            aws_client.iam.attach_user_policy(
+                UserName=user_name, PolicyArn="longpolicynamebutnoarn"
+            )
+        snapshot.match("non_existent_malformed_policy_arn", e.value.response)
+
+        with pytest.raises(ClientError) as e:
+            aws_client.iam.attach_user_policy(UserName=user_name, PolicyArn=policy_name)
+        snapshot.match("existing_policy_name_provided", e.value.response)
+
+        with pytest.raises(ClientError) as e:
+            aws_client.iam.attach_user_policy(UserName=user_name, PolicyArn=f"{policy_arn}123")
+        snapshot.match("valid_arn_not_existent", e.value.response)
+
+        attach_policy_response = aws_client.iam.attach_user_policy(
+            UserName=user_name, PolicyArn=policy_arn
+        )
+        snapshot.match("valid_policy_arn", attach_policy_response)
diff --git a/tests/integration/test_iam.snapshot.json b/tests/integration/test_iam.snapshot.json
--- a/tests/integration/test_iam.snapshot.json
+++ b/tests/integration/test_iam.snapshot.json
@@ -199,5 +199,129 @@
         }
       }
     }
+  },
+  "tests/integration/test_iam.py::TestIAMIntegrations::test_role_attach_policy": {
+    "recorded-date": "04-07-2023, 17:04:08",
+    "recorded-content": {
+      "create_policy_response": {
+        "Policy": {
+          "Arn": "arn:aws:iam::111111111111:policy/<policy-name:1>",
+          "AttachmentCount": 0,
+          "CreateDate": "datetime",
+          "DefaultVersionId": "v1",
+          "IsAttachable": true,
+          "Path": "/",
+          "PermissionsBoundaryUsageCount": 0,
+          "PolicyId": "<policy-id:1>",
+          "PolicyName": "<policy-name:1>",
+          "UpdateDate": "datetime"
+        },
+        "ResponseMetadata": {
+          "HTTPHeaders": {},
+          "HTTPStatusCode": 200
+        }
+      },
+      "non_existent_malformed_policy_arn": {
+        "Error": {
+          "Code": "InvalidInput",
+          "Message": "ARN longpolicynamebutnoarn is not valid.",
+          "Type": "Sender"
+        },
+        "ResponseMetadata": {
+          "HTTPHeaders": {},
+          "HTTPStatusCode": 400
+        }
+      },
+      "existing_policy_name_provided": {
+        "Error": {
+          "Code": "InvalidInput",
+          "Message": "ARN <policy-name:1> is not valid.",
+          "Type": "Sender"
+        },
+        "ResponseMetadata": {
+          "HTTPHeaders": {},
+          "HTTPStatusCode": 400
+        }
+      },
+      "valid_arn_not_existent": {
+        "Error": {
+          "Code": "NoSuchEntity",
+          "Message": "Policy arn:aws:iam::111111111111:policy/<policy-name:1>123 does not exist or is not attachable.",
+          "Type": "Sender"
+        },
+        "ResponseMetadata": {
+          "HTTPHeaders": {},
+          "HTTPStatusCode": 404
+        }
+      },
+      "valid_policy_arn": {
+        "ResponseMetadata": {
+          "HTTPHeaders": {},
+          "HTTPStatusCode": 200
+        }
+      }
+    }
+  },
+  "tests/integration/test_iam.py::TestIAMIntegrations::test_user_attach_policy": {
+    "recorded-date": "04-07-2023, 17:16:43",
+    "recorded-content": {
+      "create_policy_response": {
+        "Policy": {
+          "Arn": "arn:aws:iam::111111111111:policy/<policy-name:1>",
+          "AttachmentCount": 0,
+          "CreateDate": "datetime",
+          "DefaultVersionId": "v1",
+          "IsAttachable": true,
+          "Path": "/",
+          "PermissionsBoundaryUsageCount": 0,
+          "PolicyId": "<policy-id:1>",
+          "PolicyName": "<policy-name:1>",
+          "UpdateDate": "datetime"
+        },
+        "ResponseMetadata": {
+          "HTTPHeaders": {},
+          "HTTPStatusCode": 200
+        }
+      },
+      "non_existent_malformed_policy_arn": {
+        "Error": {
+          "Code": "InvalidInput",
+          "Message": "ARN longpolicynamebutnoarn is not valid.",
+          "Type": "Sender"
+        },
+        "ResponseMetadata": {
+          "HTTPHeaders": {},
+          "HTTPStatusCode": 400
+        }
+      },
+      "existing_policy_name_provided": {
+        "Error": {
+          "Code": "InvalidInput",
+          "Message": "ARN <policy-name:1> is not valid.",
+          "Type": "Sender"
+        },
+        "ResponseMetadata": {
+          "HTTPHeaders": {},
+          "HTTPStatusCode": 400
+        }
+      },
+      "valid_arn_not_existent": {
+        "Error": {
+          "Code": "NoSuchEntity",
+          "Message": "Policy arn:aws:iam::111111111111:policy/<policy-name:1>123 does not exist or is not attachable.",
+          "Type": "Sender"
+        },
+        "ResponseMetadata": {
+          "HTTPHeaders": {},
+          "HTTPStatusCode": 404
+        }
+      },
+      "valid_policy_arn": {
+        "ResponseMetadata": {
+          "HTTPHeaders": {},
+          "HTTPStatusCode": 200
+        }
+      }
+    }
   }
 }