Repository: localstack/localstack

Issue:
bug: KMS create grant failed with 500
### Is there an existing issue for this?

- [X] I have searched the existing issues

### Current Behavior

```
An error occurred (500) when calling the CreateGrant operation (reached max retries: 4): <!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 3.2 Final//EN">
<title>500 Internal Server Error</title>
<h1>Internal Server Error</h1>
<p>The server encountered an internal error and was unable to complete your request. Either the server is overloaded or there is an error in the application.</p>
```

### Expected Behavior

_No response_

### How are you starting LocalStack?

Custom (please describe below)

### Steps To Reproduce

#### How are you starting localstack (e.g., `bin/localstack` command, arguments, or `docker-compose.yml`)

    helm install localstack/localstack

#### Client commands (e.g., AWS SDK code snippet, or sequence of "awslocal" commands)

    aws kms create-grant --key-id=738f46cb-444b-4fc5-9c6f-8c1165279dbb --grantee-principal=arn:aws:iam::000000000000:role/test --operations=Encrypt


### Environment

```markdown
- OS: 
- LocalStack: 0.12.17
```


### Anything else?

_No response_


Discussion:

Resolving patch:
diff --git a/localstack/services/kms/kms_listener.py b/localstack/services/kms/kms_listener.py
--- a/localstack/services/kms/kms_listener.py
+++ b/localstack/services/kms/kms_listener.py
@@ -1,25 +1,219 @@
 import json
 import logging
+import time
 
-from localstack.services.generic_proxy import ProxyListener
+from moto.kms.exceptions import ValidationException
+
+from localstack.services.generic_proxy import ProxyListener, RegionBackend
 from localstack.utils.analytics import event_publisher
-from localstack.utils.common import to_str
+from localstack.utils.aws import aws_stack
+from localstack.utils.common import long_uid, to_str
 
 LOG = logging.getLogger(__name__)
 
 # event types
 EVENT_KMS_CREATE_KEY = "kms.ck"
 
+# valid operations
+VALID_OPERATIONS = [
+    "Decrypt",
+    "Encrypt",
+    "GenerateDataKey",
+    "GenerateDataKeyWithoutPlaintext",
+    "ReEncryptFrom",
+    "ReEncryptTo",
+    "Sign",
+    "Verify",
+    "GetPublicKey",
+    "CreateGrant",
+    "RetireGrant",
+    "DescribeKey",
+    "GenerateDataKeyPair",
+    "GenerateDataKeyPairWithoutPlaintext",
+]
+
+# grant attributes
+KEY_ID = "KeyId"
+GRANTEE_PRINCIPAL = "GranteePrincipal"
+RETIRING_PRINCIPAL = "RetiringPrincipal"
+OPERATIONS = "Operations"
+GRANT_ID = "GrantId"
+GRANT_TOKENS = "GrantTokens"
+NAME = "Name"
+CONSTRAINTS = "Constraints"
+ISSUING_ACCOUNT = "IssuingAccount"
+CREATION_DATE = "CreationDate"
+
+
+def verify_key_exists(key_id):
+    try:
+        aws_stack.connect_to_service("kms").describe_key(KeyId=key_id)
+    # FIXME catch the proper exception
+    except Exception:
+        raise ValidationException(f"Invalid keyId {key_id}")
+
+
+def validate_grant(data):
+    if KEY_ID not in data or GRANTEE_PRINCIPAL not in data or OPERATIONS not in data:
+        raise ValidationException("Grant ID, key ID and grantee principal must be specified")
+
+    for operation in data[OPERATIONS]:
+        if operation not in VALID_OPERATIONS:
+            raise ValidationException(
+                f"Value {[OPERATIONS]} at 'operations' failed to satisfy constraint: Member must satisfy"
+                f" constraint: [Member must satisfy enum value set: {VALID_OPERATIONS}]"
+            )
+
+    verify_key_exists(data[KEY_ID])
+
+
+def handle_create_grant(data):
+    validate_grant(data)
+
+    grants = KMSBackend.get().grants
+
+    data[GRANT_ID] = long_uid()
+    data[GRANT_TOKENS] = [long_uid()]
+    if NAME not in data:
+        data[NAME] = ""
+    data[CREATION_DATE] = time.time()
+
+    grants[data[GRANT_ID]] = data
+    return {GRANT_ID: data[GRANT_ID], "GrantToken": data[GRANT_TOKENS][0]}
+
+
+def filter_if_present(grant, data, filter_key):
+    return filter_key not in data or grant[filter_key] == data[filter_key]
+
+
+def filter_grantee_principal(grant, data):
+    return filter_if_present(grant, data, GRANTEE_PRINCIPAL)
+
+
+def filter_grant_id(grant, data):
+    return filter_if_present(grant, data, GRANT_ID)
+
+
+def handle_list_grants(data):
+    if KEY_ID not in data:
+        raise ValidationException("KeyId must be specified")
+    verify_key_exists(data[KEY_ID])
+
+    grants = KMSBackend.get().grants
+
+    limit = data.get("Limit", 50)
+
+    if "Marker" in data:
+        markers = KMSBackend.get().markers
+        filtered = markers.get(data["Marker"], [])
+    else:
+        filtered = [
+            grant
+            for grant in grants.values()
+            if grant[KEY_ID] == data[KEY_ID]
+            and filter_grant_id(grant, data)
+            and filter_grantee_principal(grant, data)
+        ]
+    if len(filtered) <= limit:
+        return {"Grants": filtered, "Truncated": False}
+
+    markers = KMSBackend.get().markers
+
+    in_limit = filtered[:limit]
+    out_limit = filtered[limit:]
+
+    marker_id = long_uid()
+    markers[marker_id] = out_limit
+
+    return {"Grants": in_limit, "Truncated": True, "NextMarker": marker_id}
+
+
+def handle_retire_grant(data):
+    grants = KMSBackend.get().grants
+
+    if GRANT_ID in data and KEY_ID in data and grants[data[GRANT_ID]][KEY_ID] == data[KEY_ID]:
+        del grants[data[GRANT_ID]]
+    elif "GrantToken" in data:
+        KMSBackend.get().grants = {
+            grant_id: grant
+            for grant_id, grant in grants.items()
+            if data["GrantToken"] not in grant[GRANT_TOKENS]
+        }
+    else:
+        raise ValidationException("Grant token OR (grant ID, key ID) must be specified")
+    return {}
+
+
+def handle_revoke_grant(data):
+    grants = KMSBackend.get().grants
+
+    if GRANT_ID in data and KEY_ID in data and grants[data[GRANT_ID]][KEY_ID] == data[KEY_ID]:
+        del grants[data[GRANT_ID]]
+        return {}
+    else:
+        raise ValidationException("Grant ID, key ID must be specified")
+
+
+def handle_list_retirable_grants(data):
+    if RETIRING_PRINCIPAL not in data:
+        raise ValidationException("Retiring principal must be specified")
+
+    grants = KMSBackend.get().grants
+
+    limit = data.get("Limit", 50)
+
+    if "Marker" in data:
+        markers = KMSBackend.get().markers
+        filtered = markers.get(data["Marker"], [])
+    else:
+        filtered = [
+            grant
+            for grant in grants.values()
+            if RETIRING_PRINCIPAL in grant and grant[RETIRING_PRINCIPAL] == data[RETIRING_PRINCIPAL]
+        ]
+    if len(filtered) <= limit:
+        return {"Grants": filtered, "Truncated": False}
+
+    markers = KMSBackend.get().markers
+
+    in_limit = filtered[:limit]
+    out_limit = filtered[limit:]
+
+    marker_id = long_uid()
+    markers[marker_id] = out_limit
+
+    return {"Grants": in_limit, "Truncated": True, "NextMarker": marker_id}
+
 
 class ProxyListenerKMS(ProxyListener):
     def forward_request(self, method, path, data, headers):
         action = headers.get("X-Amz-Target") or ""
-        if action.endswith(".CreateKey"):
-            data1 = json.loads(to_str(data))
-            descr = data1.get("Description") or ""
-            event_publisher.fire_event(EVENT_KMS_CREATE_KEY, {"k": event_publisher.get_hash(descr)})
+        if method == "POST" and path == "/":
+            parsed_data = json.loads(to_str(data))
+
+            if action.endswith(".CreateKey"):
+                descr = parsed_data.get("Description") or ""
+                event_publisher.fire_event(
+                    EVENT_KMS_CREATE_KEY, {"k": event_publisher.get_hash(descr)}
+                )
+            elif action.endswith(".CreateGrant"):
+                return handle_create_grant(parsed_data)
+            elif action.endswith(".ListGrants"):
+                return handle_list_grants(parsed_data)
+            elif action.endswith(".RevokeGrant"):
+                return handle_revoke_grant(parsed_data)
+            elif action.endswith(".RetireGrant"):
+                return handle_retire_grant(parsed_data)
+            elif action.endswith(".ListRetirableGrants"):
+                return handle_list_retirable_grants(parsed_data)
         return True
 
 
+class KMSBackend(RegionBackend):
+    def __init__(self):
+        self.grants = {}
+        self.markers = {}
+
+
 # instantiate listener
 UPDATE_KMS = ProxyListenerKMS()


Test patch:
diff --git a/tests/integration/fixtures.py b/tests/integration/fixtures.py
--- a/tests/integration/fixtures.py
+++ b/tests/integration/fixtures.py
@@ -17,6 +17,7 @@
     from mypy_boto3_events import EventBridgeClient
     from mypy_boto3_iam import IAMClient
     from mypy_boto3_kinesis import KinesisClient
+    from mypy_boto3_kms import KMSClient
     from mypy_boto3_lambda import LambdaClient
     from mypy_boto3_logs import CloudWatchLogsClient
     from mypy_boto3_s3 import S3Client
@@ -93,6 +94,11 @@ def kinesis_client() -> "KinesisClient":
     return _client("kinesis")
 
 
+@pytest.fixture(scope="class")
+def kms_client() -> "KMSClient":
+    return _client("kms")
+
+
 @pytest.fixture(scope="class")
 def logs_client() -> "CloudWatchLogsClient":
     return _client("logs")
@@ -206,6 +212,25 @@ def sns_topic(sns_client):
     sns_client.delete_topic(TopicArn=topic_arn)
 
 
+@pytest.fixture
+def kms_key(kms_client):
+    return kms_client.create_key(
+        Policy="policy1", Description="test key 123", KeyUsage="ENCRYPT_DECRYPT"
+    )
+
+
+@pytest.fixture
+def kms_grant_and_key(kms_client, kms_key):
+    return [
+        kms_client.create_grant(
+            KeyId=kms_key["KeyMetadata"]["KeyId"],
+            GranteePrincipal="arn:aws:iam::000000000000:role/test",
+            Operations=["Decrypt", "Encrypt"],
+        ),
+        kms_key,
+    ]
+
+
 # Cleanup fixtures
 @pytest.fixture
 def cleanup_stacks(cfn_client):
diff --git a/tests/integration/test_kms.py b/tests/integration/test_kms.py
--- a/tests/integration/test_kms.py
+++ b/tests/integration/test_kms.py
@@ -1,28 +1,77 @@
-import unittest
+import botocore.exceptions
+import pytest
 
 from localstack import config
 from localstack.constants import TEST_AWS_ACCOUNT_ID
-from localstack.utils.aws import aws_stack
 
 
-class KMSTest(unittest.TestCase):
-    def test_create_key(self):
-        client = aws_stack.connect_to_service("kms")
+class TestKMS:
+    def test_create_key(self, kms_client):
 
-        response = client.list_keys()
-        self.assertEqual(200, response["ResponseMetadata"]["HTTPStatusCode"])
+        response = kms_client.list_keys()
+        assert response["ResponseMetadata"]["HTTPStatusCode"] == 200
         keys_before = response["Keys"]
 
-        response = client.create_key(
+        response = kms_client.create_key(
             Policy="policy1", Description="test key 123", KeyUsage="ENCRYPT_DECRYPT"
         )
-        self.assertEqual(200, response["ResponseMetadata"]["HTTPStatusCode"])
+        assert response["ResponseMetadata"]["HTTPStatusCode"] == 200
         key_id = response["KeyMetadata"]["KeyId"]
 
-        response = client.list_keys()
-        self.assertEqual(len(keys_before) + 1, len(response["Keys"]))
+        response = kms_client.list_keys()
+        assert len(response["Keys"]) == len(keys_before) + 1
 
-        response = client.describe_key(KeyId=key_id)["KeyMetadata"]
-        self.assertEqual(key_id, response["KeyId"])
-        self.assertIn(":%s:" % config.DEFAULT_REGION, response["Arn"])
-        self.assertIn(":%s:" % TEST_AWS_ACCOUNT_ID, response["Arn"])
+        response = kms_client.describe_key(KeyId=key_id)["KeyMetadata"]
+        assert response["KeyId"] == key_id
+        assert ":%s:" % config.DEFAULT_REGION in response["Arn"]
+        assert ":%s:" % TEST_AWS_ACCOUNT_ID in response["Arn"]
+
+    def test_create_grant_with_invalid_key(self, kms_client):
+        with pytest.raises(botocore.exceptions.ClientError):
+            kms_client.create_grant(
+                KeyId="invalid",
+                GranteePrincipal="arn:aws:iam::000000000000:role/test",
+                Operations=["Decrypt", "Encrypt"],
+            )
+
+    def test_list_grants_with_invalid_key(self, kms_client):
+        with pytest.raises(botocore.exceptions.ClientError):
+            kms_client.list_grants(
+                KeyId="invalid",
+            )
+
+    def test_create_grant_with_valid_key(self, kms_client, kms_key):
+        key_id = kms_key["KeyMetadata"]["KeyId"]
+
+        grants_before = kms_client.list_grants(KeyId=key_id)["Grants"]
+
+        grant = kms_client.create_grant(
+            KeyId=key_id,
+            GranteePrincipal="arn:aws:iam::000000000000:role/test",
+            Operations=["Decrypt", "Encrypt"],
+        )
+        assert "GrantId" in grant
+        assert "GrantToken" in grant
+
+        grants_after = kms_client.list_grants(KeyId=key_id)["Grants"]
+        assert len(grants_after) == len(grants_before) + 1
+
+    def test_revoke_grant(self, kms_client, kms_grant_and_key):
+        grant = kms_grant_and_key[0]
+        key_id = kms_grant_and_key[1]["KeyMetadata"]["KeyId"]
+        grants_before = kms_client.list_grants(KeyId=key_id)["Grants"]
+
+        kms_client.revoke_grant(KeyId=key_id, GrantId=grant["GrantId"])
+
+        grants_after = kms_client.list_grants(KeyId=key_id)["Grants"]
+        assert len(grants_after) == len(grants_before) - 1
+
+    def test_retire_grant(self, kms_client, kms_grant_and_key):
+        grant = kms_grant_and_key[0]
+        key_id = kms_grant_and_key[1]["KeyMetadata"]["KeyId"]
+        grants_before = kms_client.list_grants(KeyId=key_id)["Grants"]
+
+        kms_client.retire_grant(GrantToken=grant["GrantToken"])
+
+        grants_after = kms_client.list_grants(KeyId=key_id)["Grants"]
+        assert len(grants_after) == len(grants_before) - 1