Repository: localstack/localstack

Issue:
PutBucketLogging giving MalformedXML error for payload working with real AWS
Hi,

I am using below payload for PutBucketLogging api and getting MalformedXML error in response. I have checked it with real AWS and it is working perfectly fine. Am I missing something or this needs a fix in localstack?

`"LoggingEnabled": {
            "TargetBucket": "test_bucket_name",
            "TargetPrefix": "log",
            "TargetGrants": [
                {
                    "Grantee": {
                        "URI": "http://acs.amazonaws.com/groups/s3/LogDelivery",
                        "Type": "Group",
                    },
                    "Permission": "WRITE",
                },
                {
                    "Grantee": {
                        "URI": "http://acs.amazonaws.com/groups/s3/LogDelivery",
                        "Type": "Group",
                    },
                    "Permission": "READ_ACP",
                },
            ],
        }
 `


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 [developer guide](https://docs.localstack.cloud/developer-guide/).
Hi @ashish4710 and thanks for your report,

I'm reproducing the issue, and indeed AWS is not raising an exception. However, granting those permissions to the `LogDelivery` group does not actually allow it to write to the bucket. You would need to do it with Bucket ACL, see here: https://docs.aws.amazon.com/AmazonS3/latest/userguide/ServerLogs.html

The `TargetGrants` allows you to give access to the logs once those are published to the `TargetBucket`, but the `LogDelivery` group will need access before that. You can read more about it in the examples here as well: https://docs.aws.amazon.com/cli/latest/reference/s3api/put-bucket-logging.html

In the meantime, I think LocalStack should not raise an exception if AWS does not, so I'll keep this issue open and work on the validation of the payload. 

Resolving patch:
diff --git a/localstack/aws/api/s3/__init__.py b/localstack/aws/api/s3/__init__.py
--- a/localstack/aws/api/s3/__init__.py
+++ b/localstack/aws/api/s3/__init__.py
@@ -771,6 +771,20 @@ class MethodNotAllowed(ServiceException):
     ResourceType: Optional[ResourceType]
 
 
+class CrossLocationLoggingProhibitted(ServiceException):
+    code: str = "CrossLocationLoggingProhibitted"
+    sender_fault: bool = False
+    status_code: int = 403
+    TargetBucketLocation: Optional[BucketRegion]
+
+
+class InvalidTargetBucketForLogging(ServiceException):
+    code: str = "InvalidTargetBucketForLogging"
+    sender_fault: bool = False
+    status_code: int = 400
+    TargetBucket: Optional[BucketName]
+
+
 AbortDate = datetime
 
 
diff --git a/localstack/services/s3/provider.py b/localstack/services/s3/provider.py
--- a/localstack/services/s3/provider.py
+++ b/localstack/services/s3/provider.py
@@ -17,6 +17,7 @@
     AnalyticsConfigurationList,
     AnalyticsId,
     Body,
+    BucketLoggingStatus,
     BucketName,
     BypassGovernanceRetention,
     ChecksumAlgorithm,
@@ -30,6 +31,7 @@
     CreateBucketRequest,
     CreateMultipartUploadOutput,
     CreateMultipartUploadRequest,
+    CrossLocationLoggingProhibitted,
     Delete,
     DeleteObjectOutput,
     DeleteObjectRequest,
@@ -44,6 +46,7 @@
     GetBucketLifecycleConfigurationOutput,
     GetBucketLifecycleOutput,
     GetBucketLocationOutput,
+    GetBucketLoggingOutput,
     GetBucketReplicationOutput,
     GetBucketRequestPaymentOutput,
     GetBucketRequestPaymentRequest,
@@ -65,6 +68,7 @@
     InvalidBucketName,
     InvalidPartOrder,
     InvalidStorageClass,
+    InvalidTargetBucketForLogging,
     ListBucketAnalyticsConfigurationsOutput,
     ListBucketIntelligentTieringConfigurationsOutput,
     ListMultipartUploadsOutput,
@@ -1375,6 +1379,54 @@ def list_bucket_intelligent_tiering_configurations(
             IntelligentTieringConfigurationList=bucket_intelligent_tiering_configurations,
         )
 
+    def put_bucket_logging(
+        self,
+        context: RequestContext,
+        bucket: BucketName,
+        bucket_logging_status: BucketLoggingStatus,
+        content_md5: ContentMD5 = None,
+        checksum_algorithm: ChecksumAlgorithm = None,
+        expected_bucket_owner: AccountId = None,
+    ) -> None:
+        moto_backend = get_moto_s3_backend(context)
+        moto_bucket = get_bucket_from_moto(moto_backend, bucket)
+
+        if not (logging_config := bucket_logging_status.get("LoggingEnabled")):
+            moto_bucket.logging = {}
+
+        # the target bucket must be in the same account
+        if not (target_bucket_name := logging_config.get("TargetBucket")):
+            raise MalformedXML()
+
+        if not logging_config.get("TargetPrefix"):
+            logging_config["TargetPrefix"] = ""
+
+        # TODO: validate Grants
+
+        if not (target_bucket := moto_backend.buckets.get(target_bucket_name)):
+            raise InvalidTargetBucketForLogging(
+                "The target bucket for logging does not exist",
+                TargetBucket=target_bucket_name,
+            )
+
+        if target_bucket.region_name != moto_bucket.region_name:
+            raise CrossLocationLoggingProhibitted(
+                "Cross S3 location logging not allowed. ",
+                TargetBucketLocation=target_bucket.region_name,
+            )
+
+        moto_bucket.logging = logging_config
+
+    def get_bucket_logging(
+        self, context: RequestContext, bucket: BucketName, expected_bucket_owner: AccountId = None
+    ) -> GetBucketLoggingOutput:
+        moto_backend = get_moto_s3_backend(context)
+        moto_bucket = get_bucket_from_moto(moto_backend, bucket)
+        if not moto_bucket.logging:
+            return GetBucketLoggingOutput()
+
+        return GetBucketLoggingOutput(LoggingEnabled=moto_bucket.logging)
+
 
 def validate_bucket_analytics_configuration(
     id: AnalyticsId, analytics_configuration: AnalyticsConfiguration


Test patch:
diff --git a/tests/integration/s3/test_s3.py b/tests/integration/s3/test_s3.py
--- a/tests/integration/s3/test_s3.py
+++ b/tests/integration/s3/test_s3.py
@@ -4858,6 +4858,116 @@ def test_s3_get_object_headers(self, aws_client, s3_create_bucket, snapshot):
             aws_client.s3.get_object(Bucket=bucket, Key=key, IfMatch="etag")
         snapshot.match("if_match_err_1", e.value.response["Error"])
 
+    @pytest.mark.aws_validated
+    def test_put_bucket_logging(self, aws_client, s3_create_bucket, snapshot):
+        snapshot.add_transformer(
+            [
+                snapshot.transform.key_value("TargetBucket"),
+                snapshot.transform.key_value("DisplayName", reference_replacement=False),
+                snapshot.transform.key_value(
+                    "ID", value_replacement="owner-id", reference_replacement=False
+                ),
+            ]
+        )
+
+        bucket_name = s3_create_bucket()
+        target_bucket = s3_create_bucket()
+        bucket_logging_status = {
+            "LoggingEnabled": {
+                "TargetBucket": target_bucket,
+                "TargetPrefix": "log",
+            },
+        }
+        resp = aws_client.s3.get_bucket_acl(Bucket=target_bucket)
+        snapshot.match("get-bucket-default-acl", resp)
+
+        # this might have been failing in the past, as the target bucket does not give access to LogDelivery to
+        # write/read_acp. however, AWS accepts it, because you can also set it with Permissions
+        resp = aws_client.s3.put_bucket_logging(
+            Bucket=bucket_name, BucketLoggingStatus=bucket_logging_status
+        )
+        snapshot.match("put-bucket-logging", resp)
+
+        resp = aws_client.s3.get_bucket_logging(Bucket=bucket_name)
+        snapshot.match("get-bucket-logging", resp)
+
+    @pytest.mark.aws_validated
+    def test_put_bucket_logging_accept_wrong_grants(self, aws_client, s3_create_bucket, snapshot):
+        snapshot.add_transformer(snapshot.transform.key_value("TargetBucket"))
+
+        bucket_name = s3_create_bucket()
+
+        target_bucket = s3_create_bucket()
+        # We need to delete the ObjectOwnership from the bucket, because you otherwise can't set TargetGrants on it
+        # TODO: have the same default as AWS and have ObjectOwnership set
+        aws_client.s3.delete_bucket_ownership_controls(Bucket=target_bucket)
+
+        bucket_logging_status = {
+            "LoggingEnabled": {
+                "TargetBucket": target_bucket,
+                "TargetPrefix": "log",
+                "TargetGrants": [
+                    {
+                        "Grantee": {
+                            "URI": "http://acs.amazonaws.com/groups/s3/LogDelivery",
+                            "Type": "Group",
+                        },
+                        "Permission": "WRITE",
+                    },
+                    {
+                        "Grantee": {
+                            "URI": "http://acs.amazonaws.com/groups/s3/LogDelivery",
+                            "Type": "Group",
+                        },
+                        "Permission": "READ_ACP",
+                    },
+                ],
+            },
+        }
+
+        # from the documentation, only WRITE | READ | FULL_CONTROL are allowed, but AWS let READ_ACP pass
+        resp = aws_client.s3.put_bucket_logging(
+            Bucket=bucket_name, BucketLoggingStatus=bucket_logging_status
+        )
+        snapshot.match("put-bucket-logging", resp)
+
+        resp = aws_client.s3.get_bucket_logging(Bucket=bucket_name)
+        snapshot.match("get-bucket-logging", resp)
+
+    @pytest.mark.aws_validated
+    def test_put_bucket_logging_wrong_target(self, aws_client, s3_create_bucket, snapshot):
+        snapshot.add_transformer(snapshot.transform.key_value("TargetBucket"))
+        bucket_name = s3_create_bucket()
+        target_bucket = s3_create_bucket(
+            CreateBucketConfiguration={"LocationConstraint": "us-west-2"}
+        )
+
+        with pytest.raises(ClientError) as e:
+            bucket_logging_status = {
+                "LoggingEnabled": {
+                    "TargetBucket": target_bucket,
+                    "TargetPrefix": "log",
+                },
+            }
+            aws_client.s3.put_bucket_logging(
+                Bucket=bucket_name, BucketLoggingStatus=bucket_logging_status
+            )
+        snapshot.match("put-bucket-logging-different-regions", e.value.response)
+
+        nonexistent_target_bucket = f"target-bucket-{short_uid()}-{short_uid()}"
+        with pytest.raises(ClientError) as e:
+            bucket_logging_status = {
+                "LoggingEnabled": {
+                    "TargetBucket": nonexistent_target_bucket,
+                    "TargetPrefix": "log",
+                },
+            }
+            aws_client.s3.put_bucket_logging(
+                Bucket=bucket_name, BucketLoggingStatus=bucket_logging_status
+            )
+        snapshot.match("put-bucket-logging-non-existent-bucket", e.value.response)
+        assert e.value.response["Error"]["TargetBucket"] == nonexistent_target_bucket
+
 
 class TestS3MultiAccounts:
     @pytest.fixture
diff --git a/tests/integration/s3/test_s3.snapshot.json b/tests/integration/s3/test_s3.snapshot.json
--- a/tests/integration/s3/test_s3.snapshot.json
+++ b/tests/integration/s3/test_s3.snapshot.json
@@ -7561,5 +7561,110 @@
         "Message": "At least one of the pre-conditions you specified did not hold"
       }
     }
+  },
+  "tests/integration/s3/test_s3.py::TestS3::test_put_bucket_logging": {
+    "recorded-date": "08-07-2023, 02:00:56",
+    "recorded-content": {
+      "get-bucket-default-acl": {
+        "Grants": [
+          {
+            "Grantee": {
+              "DisplayName": "display-name",
+              "ID": "owner-id",
+              "Type": "CanonicalUser"
+            },
+            "Permission": "FULL_CONTROL"
+          }
+        ],
+        "Owner": {
+          "DisplayName": "display-name",
+          "ID": "owner-id"
+        },
+        "ResponseMetadata": {
+          "HTTPHeaders": {},
+          "HTTPStatusCode": 200
+        }
+      },
+      "put-bucket-logging": {
+        "ResponseMetadata": {
+          "HTTPHeaders": {},
+          "HTTPStatusCode": 200
+        }
+      },
+      "get-bucket-logging": {
+        "LoggingEnabled": {
+          "TargetBucket": "<target-bucket:1>",
+          "TargetPrefix": "log"
+        },
+        "ResponseMetadata": {
+          "HTTPHeaders": {},
+          "HTTPStatusCode": 200
+        }
+      }
+    }
+  },
+  "tests/integration/s3/test_s3.py::TestS3::test_put_bucket_logging_accept_wrong_grants": {
+    "recorded-date": "08-07-2023, 01:15:17",
+    "recorded-content": {
+      "put-bucket-logging": {
+        "ResponseMetadata": {
+          "HTTPHeaders": {},
+          "HTTPStatusCode": 200
+        }
+      },
+      "get-bucket-logging": {
+        "LoggingEnabled": {
+          "TargetBucket": "<target-bucket:1>",
+          "TargetGrants": [
+            {
+              "Grantee": {
+                "Type": "Group",
+                "URI": "http://acs.amazonaws.com/groups/s3/LogDelivery"
+              },
+              "Permission": "WRITE"
+            },
+            {
+              "Grantee": {
+                "Type": "Group",
+                "URI": "http://acs.amazonaws.com/groups/s3/LogDelivery"
+              },
+              "Permission": "READ_ACP"
+            }
+          ],
+          "TargetPrefix": "log"
+        },
+        "ResponseMetadata": {
+          "HTTPHeaders": {},
+          "HTTPStatusCode": 200
+        }
+      }
+    }
+  },
+  "tests/integration/s3/test_s3.py::TestS3::test_put_bucket_logging_wrong_target": {
+    "recorded-date": "08-07-2023, 01:27:18",
+    "recorded-content": {
+      "put-bucket-logging-different-regions": {
+        "Error": {
+          "Code": "CrossLocationLoggingProhibitted",
+          "Message": "Cross S3 location logging not allowed. ",
+          "TargetBucketLocation": "us-west-2"
+        },
+        "ResponseMetadata": {
+          "HTTPHeaders": {},
+          "HTTPStatusCode": 403
+        }
+      },
+      "put-bucket-logging-non-existent-bucket": {
+        "Error": {
+          "Code": "InvalidTargetBucketForLogging",
+          "Message": "The target bucket for logging does not exist",
+          "TargetBucket": "<target-bucket:1>"
+        },
+        "ResponseMetadata": {
+          "HTTPHeaders": {},
+          "HTTPStatusCode": 400
+        }
+      }
+    }
   }
 }