Repository: localstack/localstack
Pull request: ExpectedBucketOwner for S3 bucket policy operations
<!-- Please refer to the contribution guidelines before raising a PR: https://github.com/localstack/localstack/blob/master/docs/CONTRIBUTING.md -->

<!-- Why am I raising this PR? Add context such as related issues, PRs, or documentation. -->
## Motivation

Closes #11826

<!-- What changes does this PR make? How does LocalStack behave differently now? -->
## Changes

Implement the ExpectedBucketOwner parameter and its behavior in 3 S3 operations: 
- PutBucketPolicy
- GetBucketPolicy 
- DeleteBucketPolicy. 

This parameter is currently ignored in LocalStack S3.

<!-- Optional section: How to test these changes? -->
<!--
## Testing

-->

<!-- Optional section: What's left to do before it can be merged? -->
<!--
## TODO

What's left to do:

- [ ] ...
- [ ] ...
-->


Problem statement:
feature request: S3 ExpectedBucketOwner for bucket policy operations
### Is there an existing issue for this?

- [X] I have searched the existing issues

### Feature description

Implement ExpectedBucketOwner parameter and its behavior in 3 S3 operations: 
- PutBucketPolicy
- GetBucketPolicy 
- DeleteBucketPolicy

### 🧑‍💻 Implementation

_No response_

### Anything else?

_No response_

Hints from issue 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/).

Commit message: Raise InvalidBucketOwnerAWSAccountID if needed

Patch to review:
diff --git a/localstack-core/localstack/aws/api/s3/__init__.py b/localstack-core/localstack/aws/api/s3/__init__.py
index 3a7cdafe351b4..fe68fb7ff8cf9 100644
--- a/localstack-core/localstack/aws/api/s3/__init__.py
+++ b/localstack-core/localstack/aws/api/s3/__init__.py
@@ -684,6 +684,12 @@ class InvalidBucketName(ServiceException):
     BucketName: Optional[BucketName]
 
 
+class InvalidBucketOwnerAWSAccountID(ServiceException):
+    code: str = "InvalidBucketOwnerAWSAccountID"
+    sender_fault: bool = False
+    status_code: int = 400
+
+
 class NoSuchVersion(ServiceException):
     code: str = "NoSuchVersion"
     sender_fault: bool = False
diff --git a/localstack-core/localstack/services/s3/provider.py b/localstack-core/localstack/services/s3/provider.py
index 816b36a4a2a6c..748bb7c038b46 100644
--- a/localstack-core/localstack/services/s3/provider.py
+++ b/localstack-core/localstack/services/s3/provider.py
@@ -3,6 +3,7 @@
 import datetime
 import json
 import logging
+import re
 from collections import defaultdict
 from io import BytesIO
 from operator import itemgetter
@@ -103,6 +104,7 @@
     IntelligentTieringId,
     InvalidArgument,
     InvalidBucketName,
+    InvalidBucketOwnerAWSAccountID,
     InvalidDigest,
     InvalidLocationConstraint,
     InvalidObjectState,
@@ -412,8 +414,17 @@ def _get_expiration_header(
             return expiration_header
 
     def _get_cross_account_bucket(
-        self, context: RequestContext, bucket_name: BucketName
+        self,
+        context: RequestContext,
+        bucket_name: BucketName,
+        *,
+        expected_bucket_owner: AccountId = None,
     ) -> tuple[S3Store, S3Bucket]:
+        if expected_bucket_owner and not re.fullmatch(r"\w{12}", expected_bucket_owner):
+            raise InvalidBucketOwnerAWSAccountID(
+                f"The value of the expected bucket owner parameter must be an AWS Account ID... [{expected_bucket_owner}]",
+            )
+
         store = self.get_store(context.account_id, context.region)
         if not (s3_bucket := store.buckets.get(bucket_name)):
             if not (account_id := store.global_bucket_map.get(bucket_name)):
@@ -423,6 +434,9 @@ def _get_cross_account_bucket(
             if not (s3_bucket := store.buckets.get(bucket_name)):
                 raise NoSuchBucket("The specified bucket does not exist", BucketName=bucket_name)
 
+        if expected_bucket_owner and s3_bucket.bucket_account_id != expected_bucket_owner:
+            raise AccessDenied("Access Denied")
+
         return store, s3_bucket
 
     @staticmethod
@@ -3646,7 +3660,9 @@ def get_bucket_policy(
         expected_bucket_owner: AccountId = None,
         **kwargs,
     ) -> GetBucketPolicyOutput:
-        store, s3_bucket = self._get_cross_account_bucket(context, bucket)
+        store, s3_bucket = self._get_cross_account_bucket(
+            context, bucket, expected_bucket_owner=expected_bucket_owner
+        )
         if not s3_bucket.policy:
             raise NoSuchBucketPolicy(
                 "The bucket policy does not exist",
@@ -3665,7 +3681,9 @@ def put_bucket_policy(
         expected_bucket_owner: AccountId = None,
         **kwargs,
     ) -> None:
-        store, s3_bucket = self._get_cross_account_bucket(context, bucket)
+        store, s3_bucket = self._get_cross_account_bucket(
+            context, bucket, expected_bucket_owner=expected_bucket_owner
+        )
 
         if not policy or policy[0] != "{":
             raise MalformedPolicy("Policies must be valid JSON and the first byte must be '{'")
@@ -3686,7 +3704,9 @@ def delete_bucket_policy(
         expected_bucket_owner: AccountId = None,
         **kwargs,
     ) -> None:
-        store, s3_bucket = self._get_cross_account_bucket(context, bucket)
+        store, s3_bucket = self._get_cross_account_bucket(
+            context, bucket, expected_bucket_owner=expected_bucket_owner
+        )
 
         s3_bucket.policy = None
 
diff --git a/tests/aws/services/s3/test_s3.py b/tests/aws/services/s3/test_s3.py
index edbc8a4baa2fc..fe6eb89b31e31 100644
--- a/tests/aws/services/s3/test_s3.py
+++ b/tests/aws/services/s3/test_s3.py
@@ -267,6 +267,20 @@ def _filter_header(param: dict) -> dict:
     return {k: v for k, v in param.items() if k.startswith("x-amz") or k in ["content-type"]}
 
 
+def _simple_bucket_policy(s3_bucket: str) -> dict:
+    return {
+        "Version": "2012-10-17",
+        "Statement": [
+            {
+                "Action": "s3:GetObject",
+                "Effect": "Allow",
+                "Resource": f"arn:aws:s3:::{s3_bucket}/*",
+                "Principal": {"AWS": "*"},
+            }
+        ],
+    }
+
+
 class TestS3:
     @pytest.mark.skipif(condition=TEST_S3_IMAGE, reason="KMS not enabled in S3 image")
     @markers.aws.validated
@@ -964,31 +978,132 @@ def test_create_bucket_via_host_name(self, s3_vhost_client, aws_client, region_n
             s3_vhost_client.delete_bucket(Bucket=bucket_name)
 
     @markers.aws.validated
-    def test_put_and_get_bucket_policy(self, s3_bucket, snapshot, aws_client, allow_bucket_acl):
+    def test_get_bucket_policy(self, s3_bucket, snapshot, aws_client, allow_bucket_acl, account_id):
+        snapshot.add_transformer(snapshot.transform.key_value("Resource"))
+        snapshot.add_transformer(snapshot.transform.key_value("BucketName"))
+
+        with pytest.raises(ClientError) as e:
+            aws_client.s3.get_bucket_policy(Bucket=s3_bucket)
+        snapshot.match("get-bucket-policy-no-such-bucket-policy", e.value.response)
+
+        policy = _simple_bucket_policy(s3_bucket)
+        aws_client.s3.put_bucket_policy(Bucket=s3_bucket, Policy=json.dumps(policy))
+
+        # retrieve and check policy config
+        response = aws_client.s3.get_bucket_policy(Bucket=s3_bucket)
+        snapshot.match("get-bucket-policy", response)
+        assert policy == json.loads(response["Policy"])
+
+        response = aws_client.s3.get_bucket_policy(Bucket=s3_bucket, ExpectedBucketOwner=account_id)
+        snapshot.match("get-bucket-policy-with-expected-bucket-owner", response)
+        assert policy == json.loads(response["Policy"])
+
+        with pytest.raises(ClientError) as e:
+            aws_client.s3.get_bucket_policy(Bucket=s3_bucket, ExpectedBucketOwner="000000000002")
+        snapshot.match("get-bucket-policy-with-expected-bucket-owner-error", e.value.response)
+
+    @pytest.mark.parametrize(
+        "invalid_account_id", ["0000", "0000000000020", "abcd", "aa000000000$"]
+    )
+    @markers.aws.validated
+    def test_get_bucket_policy_invalid_account_id(
+        self, s3_bucket, snapshot, aws_client, invalid_account_id
+    ):
+        with pytest.raises(ClientError) as e:
+            aws_client.s3.get_bucket_policy(
+                Bucket=s3_bucket, ExpectedBucketOwner=invalid_account_id
+            )
+
+        snapshot.match("get-bucket-policy-invalid-bucket-owner", e.value.response)
+
+    @markers.aws.validated
+    def test_put_bucket_policy(self, s3_bucket, snapshot, aws_client, allow_bucket_acl):
         # just for the joke: Response syntax HTTP/1.1 200
         # sample response: HTTP/1.1 204 No Content
         # https://docs.aws.amazon.com/AmazonS3/latest/API/API_PutBucketPolicy.html
         snapshot.add_transformer(snapshot.transform.key_value("Resource"))
         # put bucket policy
-        policy = {
-            "Version": "2012-10-17",
-            "Statement": [
-                {
-                    "Action": "s3:GetObject",
-                    "Effect": "Allow",
-                    "Resource": f"arn:aws:s3:::{s3_bucket}/*",
-                    "Principal": {"AWS": "*"},
-                }
-            ],
-        }
+        policy = _simple_bucket_policy(s3_bucket)
         response = aws_client.s3.put_bucket_policy(Bucket=s3_bucket, Policy=json.dumps(policy))
         snapshot.match("put-bucket-policy", response)
 
-        # retrieve and check policy config
         response = aws_client.s3.get_bucket_policy(Bucket=s3_bucket)
         snapshot.match("get-bucket-policy", response)
         assert policy == json.loads(response["Policy"])
 
+    @markers.aws.validated
+    def test_put_bucket_policy_expected_bucket_owner(
+        self, s3_bucket, snapshot, aws_client, allow_bucket_acl, account_id
+    ):
+        snapshot.add_transformer(snapshot.transform.key_value("Resource"))
+        policy = _simple_bucket_policy(s3_bucket)
+
+        with pytest.raises(ClientError) as e:
+            aws_client.s3.put_bucket_policy(
+                Bucket=s3_bucket, Policy=json.dumps(policy), ExpectedBucketOwner="000000000002"
+            )
+        snapshot.match("put-bucket-policy-with-expected-bucket-owner-error", e.value.response)
+
+        response = aws_client.s3.put_bucket_policy(
+            Bucket=s3_bucket, Policy=json.dumps(policy), ExpectedBucketOwner=account_id
+        )
+        snapshot.match("put-bucket-policy-with-expected-bucket-owner", response)
+
+    @pytest.mark.parametrize(
+        "invalid_account_id", ["0000", "0000000000020", "abcd", "aa000000000$"]
+    )
+    @markers.aws.validated
+    def test_put_bucket_policy_invalid_account_id(
+        self, s3_bucket, snapshot, aws_client, invalid_account_id
+    ):
+        snapshot.add_transformer(snapshot.transform.key_value("Resource"))
+        policy = _simple_bucket_policy(s3_bucket)
+
+        with pytest.raises(ClientError) as e:
+            aws_client.s3.put_bucket_policy(
+                Bucket=s3_bucket, Policy=json.dumps(policy), ExpectedBucketOwner=invalid_account_id
+            )
+
+        snapshot.match("put-bucket-policy-invalid-bucket-owner", e.value.response)
+
+    @markers.aws.validated
+    def test_delete_bucket_policy(self, s3_bucket, snapshot, aws_client, allow_bucket_acl):
+        snapshot.add_transformer(snapshot.transform.key_value("Resource"))
+        snapshot.add_transformer(snapshot.transform.key_value("BucketName"))
+
+        policy = _simple_bucket_policy(s3_bucket)
+        aws_client.s3.put_bucket_policy(Bucket=s3_bucket, Policy=json.dumps(policy))
+
+        response = aws_client.s3.delete_bucket_policy(Bucket=s3_bucket)
+        snapshot.match("delete-bucket-policy", response)
+
+        with pytest.raises(ClientError) as e:
+            aws_client.s3.get_bucket_policy(Bucket=s3_bucket)
+        snapshot.match("get-bucket-policy-no-such-bucket-policy", e.value.response)
+
+    @markers.aws.validated
+    def test_delete_bucket_policy_expected_bucket_owner(
+        self, s3_bucket, snapshot, aws_client, allow_bucket_acl, account_id
+    ):
+        snapshot.add_transformer(snapshot.transform.key_value("Resource"))
+        snapshot.add_transformer(snapshot.transform.key_value("BucketName"))
+
+        policy = _simple_bucket_policy(s3_bucket)
+        aws_client.s3.put_bucket_policy(Bucket=s3_bucket, Policy=json.dumps(policy))
+
+        with pytest.raises(ClientError) as e:
+            aws_client.s3.delete_bucket_policy(Bucket=s3_bucket, ExpectedBucketOwner="000000000002")
+        snapshot.match("delete-bucket-policy-with-expected-bucket-owner-error", e.value.response)
+
+        with pytest.raises(ClientError) as e:
+            aws_client.s3.delete_bucket_policy(Bucket=s3_bucket, ExpectedBucketOwner="invalid")
+        snapshot.match("delete-bucket-policy-invalid-bucket-owner", e.value.response)
+
+        response = aws_client.s3.delete_bucket_policy(
+            Bucket=s3_bucket, ExpectedBucketOwner=account_id
+        )
+        snapshot.match("delete-bucket-policy-with-expected-bucket-owner", response)
+
     @markers.aws.validated
     def test_put_object_tagging_empty_list(self, s3_bucket, snapshot, aws_client):
         key = "my-key"
diff --git a/tests/aws/services/s3/test_s3.snapshot.json b/tests/aws/services/s3/test_s3.snapshot.json
index 00e4a7fe8b902..e5e6bd0d36ae7 100644
--- a/tests/aws/services/s3/test_s3.snapshot.json
+++ b/tests/aws/services/s3/test_s3.snapshot.json
@@ -13295,5 +13295,314 @@
         }
       }
     }
+  },
+  "tests/aws/services/s3/test_s3.py::TestS3::test_get_bucket_policy": {
+    "recorded-date": "10-11-2024, 19:20:17",
+    "recorded-content": {
+      "get-bucket-policy-no-such-bucket-policy": {
+        "Error": {
+          "BucketName": "<bucket-name:1>",
+          "Code": "NoSuchBucketPolicy",
+          "Message": "The bucket policy does not exist"
+        },
+        "ResponseMetadata": {
+          "HTTPHeaders": {},
+          "HTTPStatusCode": 404
+        }
+      },
+      "get-bucket-policy": {
+        "Policy": {
+          "Version": "2012-10-17",
+          "Statement": [
+            {
+              "Effect": "Allow",
+              "Principal": {
+                "AWS": "*"
+              },
+              "Action": "s3:GetObject",
+              "Resource": "<resource:1>"
+            }
+          ]
+        },
+        "ResponseMetadata": {
+          "HTTPHeaders": {},
+          "HTTPStatusCode": 200
+        }
+      },
+      "get-bucket-policy-with-expected-bucket-owner": {
+        "Policy": {
+          "Version": "2012-10-17",
+          "Statement": [
+            {
+              "Effect": "Allow",
+              "Principal": {
+                "AWS": "*"
+              },
+              "Action": "s3:GetObject",
+              "Resource": "<resource:1>"
+            }
+          ]
+        },
+        "ResponseMetadata": {
+          "HTTPHeaders": {},
+          "HTTPStatusCode": 200
+        }
+      },
+      "get-bucket-policy-with-expected-bucket-owner-error": {
+        "Error": {
+          "Code": "AccessDenied",
+          "Message": "Access Denied"
+        },
+        "ResponseMetadata": {
+          "HTTPHeaders": {},
+          "HTTPStatusCode": 403
+        }
+      }
+    }
+  },
+  "tests/aws/services/s3/test_s3.py::TestS3::test_put_bucket_policy_with_expected_bucket_owner": {
+    "recorded-date": "10-11-2024, 19:06:26",
+    "recorded-content": {
+      "put-bucket-policy-with-expected-bucket-owner-error": {
+        "Error": {
+          "Code": "AccessDenied",
+          "Message": "Access Denied"
+        },
+        "ResponseMetadata": {
+          "HTTPHeaders": {},
+          "HTTPStatusCode": 403
+        }
+      },
+      "put-bucket-policy-with-expected-bucket-owner": {
+        "ResponseMetadata": {
+          "HTTPHeaders": {},
+          "HTTPStatusCode": 204
+        }
+      }
+    }
+  },
+  "tests/aws/services/s3/test_s3.py::TestS3::test_delete_bucket_policy": {
+    "recorded-date": "10-11-2024, 19:21:53",
+    "recorded-content": {
+      "delete-bucket-policy": {
+        "ResponseMetadata": {
+          "HTTPHeaders": {},
+          "HTTPStatusCode": 204
+        }
+      },
+      "get-bucket-policy-no-such-bucket-policy": {
+        "Error": {
+          "BucketName": "<bucket-name:1>",
+          "Code": "NoSuchBucketPolicy",
+          "Message": "The bucket policy does not exist"
+        },
+        "ResponseMetadata": {
+          "HTTPHeaders": {},
+          "HTTPStatusCode": 404
+        }
+      }
+    }
+  },
+  "tests/aws/services/s3/test_s3.py::TestS3::test_put_bucket_policy": {
+    "recorded-date": "10-11-2024, 19:20:47",
+    "recorded-content": {
+      "put-bucket-policy": {
+        "ResponseMetadata": {
+          "HTTPHeaders": {},
+          "HTTPStatusCode": 204
+        }
+      },
+      "get-bucket-policy": {
+        "Policy": {
+          "Version": "2012-10-17",
+          "Statement": [
+            {
+              "Effect": "Allow",
+              "Principal": {
+                "AWS": "*"
+              },
+              "Action": "s3:GetObject",
+              "Resource": "<resource:1>"
+            }
+          ]
+        },
+        "ResponseMetadata": {
+          "HTTPHeaders": {},
+          "HTTPStatusCode": 200
+        }
+      }
+    }
+  },
+  "tests/aws/services/s3/test_s3.py::TestS3::test_delete_bucket_policy_expected_bucket_owner": {
+    "recorded-date": "14-11-2024, 21:43:07",
+    "recorded-content": {
+      "delete-bucket-policy-with-expected-bucket-owner-error": {
+        "Error": {
+          "Code": "AccessDenied",
+          "Message": "Access Denied"
+        },
+        "ResponseMetadata": {
+          "HTTPHeaders": {},
+          "HTTPStatusCode": 403
+        }
+      },
+      "delete-bucket-policy-invalid-bucket-owner": {
+        "Error": {
+          "Code": "InvalidBucketOwnerAWSAccountID",
+          "Message": "The value of the expected bucket owner parameter must be an AWS Account ID... [invalid]"
+        },
+        "ResponseMetadata": {
+          "HTTPHeaders": {},
+          "HTTPStatusCode": 400
+        }
+      },
+      "delete-bucket-policy-with-expected-bucket-owner": {
+        "ResponseMetadata": {
+          "HTTPHeaders": {},
+          "HTTPStatusCode": 204
+        }
+      }
+    }
+  },
+  "tests/aws/services/s3/test_s3.py::TestS3::test_put_bucket_policy_expected_bucket_owner": {
+    "recorded-date": "10-11-2024, 19:32:05",
+    "recorded-content": {
+      "put-bucket-policy-with-expected-bucket-owner-error": {
+        "Error": {
+          "Code": "AccessDenied",
+          "Message": "Access Denied"
+        },
+        "ResponseMetadata": {
+          "HTTPHeaders": {},
+          "HTTPStatusCode": 403
+        }
+      },
+      "put-bucket-policy-with-expected-bucket-owner": {
+        "ResponseMetadata": {
+          "HTTPHeaders": {},
+          "HTTPStatusCode": 204
+        }
+      }
+    }
+  },
+  "tests/aws/services/s3/test_s3.py::TestS3::test_get_bucket_policy_invalid_account_id[0000]": {
+    "recorded-date": "14-11-2024, 21:34:49",
+    "recorded-content": {
+      "get-bucket-policy-invalid-bucket-owner": {
+        "Error": {
+          "Code": "InvalidBucketOwnerAWSAccountID",
+          "Message": "The value of the expected bucket owner parameter must be an AWS Account ID... [0000]"
+        },
+        "ResponseMetadata": {
+          "HTTPHeaders": {},
+          "HTTPStatusCode": 400
+        }
+      }
+    }
+  },
+  "tests/aws/services/s3/test_s3.py::TestS3::test_get_bucket_policy_invalid_account_id[0000000000020]": {
+    "recorded-date": "14-11-2024, 21:34:51",
+    "recorded-content": {
+      "get-bucket-policy-invalid-bucket-owner": {
+        "Error": {
+          "Code": "InvalidBucketOwnerAWSAccountID",
+          "Message": "The value of the expected bucket owner parameter must be an AWS Account ID... [0000000000020]"
+        },
+        "ResponseMetadata": {
+          "HTTPHeaders": {},
+          "HTTPStatusCode": 400
+        }
+      }
+    }
+  },
+  "tests/aws/services/s3/test_s3.py::TestS3::test_get_bucket_policy_invalid_account_id[abcd]": {
+    "recorded-date": "14-11-2024, 21:34:53",
+    "recorded-content": {
+      "get-bucket-policy-invalid-bucket-owner": {
+        "Error": {
+          "Code": "InvalidBucketOwnerAWSAccountID",
+          "Message": "The value of the expected bucket owner parameter must be an AWS Account ID... [abcd]"
+        },
+        "ResponseMetadata": {
+          "HTTPHeaders": {},
+          "HTTPStatusCode": 400
+        }
+      }
+    }
+  },
+  "tests/aws/services/s3/test_s3.py::TestS3::test_get_bucket_policy_invalid_account_id[aa000000000$]": {
+    "recorded-date": "14-11-2024, 21:34:56",
+    "recorded-content": {
+      "get-bucket-policy-invalid-bucket-owner": {
+        "Error": {
+          "Code": "InvalidBucketOwnerAWSAccountID",
+          "Message": "The value of the expected bucket owner parameter must be an AWS Account ID... [aa000000000$]"
+        },
+        "ResponseMetadata": {
+          "HTTPHeaders": {},
+          "HTTPStatusCode": 400
+        }
+      }
+    }
+  },
+  "tests/aws/services/s3/test_s3.py::TestS3::test_put_bucket_policy_invalid_account_id[0000]": {
+    "recorded-date": "14-11-2024, 21:38:42",
+    "recorded-content": {
+      "put-bucket-policy-invalid-bucket-owner": {
+        "Error": {
+          "Code": "InvalidBucketOwnerAWSAccountID",
+          "Message": "The value of the expected bucket owner parameter must be an AWS Account ID... [0000]"
+        },
+        "ResponseMetadata": {
+          "HTTPHeaders": {},
+          "HTTPStatusCode": 400
+        }
+      }
+    }
+  },
+  "tests/aws/services/s3/test_s3.py::TestS3::test_put_bucket_policy_invalid_account_id[0000000000020]": {
+    "recorded-date": "14-11-2024, 21:38:44",
+    "recorded-content": {
+      "put-bucket-policy-invalid-bucket-owner": {
+        "Error": {
+          "Code": "InvalidBucketOwnerAWSAccountID",
+          "Message": "The value of the expected bucket owner parameter must be an AWS Account ID... [0000000000020]"
+        },
+        "ResponseMetadata": {
+          "HTTPHeaders": {},
+          "HTTPStatusCode": 400
+        }
+      }
+    }
+  },
+  "tests/aws/services/s3/test_s3.py::TestS3::test_put_bucket_policy_invalid_account_id[abcd]": {
+    "recorded-date": "14-11-2024, 21:38:46",
+    "recorded-content": {
+      "put-bucket-policy-invalid-bucket-owner": {
+        "Error": {
+          "Code": "InvalidBucketOwnerAWSAccountID",
+          "Message": "The value of the expected bucket owner parameter must be an AWS Account ID... [abcd]"
+        },
+        "ResponseMetadata": {
+          "HTTPHeaders": {},
+          "HTTPStatusCode": 400
+        }
+      }
+    }
+  },
+  "tests/aws/services/s3/test_s3.py::TestS3::test_put_bucket_policy_invalid_account_id[aa000000000$]": {
+    "recorded-date": "14-11-2024, 21:38:49",
+    "recorded-content": {
+      "put-bucket-policy-invalid-bucket-owner": {
+        "Error": {
+          "Code": "InvalidBucketOwnerAWSAccountID",
+          "Message": "The value of the expected bucket owner parameter must be an AWS Account ID... [aa000000000$]"
+        },
+        "ResponseMetadata": {
+          "HTTPHeaders": {},
+          "HTTPStatusCode": 400
+        }
+      }
+    }
   }
 }
diff --git a/tests/aws/services/s3/test_s3.validation.json b/tests/aws/services/s3/test_s3.validation.json
index fae10256b38d4..a0c6e2f1ab036 100644
--- a/tests/aws/services/s3/test_s3.validation.json
+++ b/tests/aws/services/s3/test_s3.validation.json
@@ -44,6 +44,12 @@
   "tests/aws/services/s3/test_s3.py::TestS3::test_delete_bucket_no_such_bucket": {
     "last_validated_date": "2023-08-03T02:13:50+00:00"
   },
+  "tests/aws/services/s3/test_s3.py::TestS3::test_delete_bucket_policy": {
+    "last_validated_date": "2024-11-10T19:21:53+00:00"
+  },
+  "tests/aws/services/s3/test_s3.py::TestS3::test_delete_bucket_policy_expected_bucket_owner": {
+    "last_validated_date": "2024-11-14T21:43:07+00:00"
+  },
   "tests/aws/services/s3/test_s3.py::TestS3::test_delete_bucket_with_content": {
     "last_validated_date": "2023-08-03T02:13:20+00:00"
   },
@@ -77,6 +83,21 @@
   "tests/aws/services/s3/test_s3.py::TestS3::test_get_bucket_notification_configuration_no_such_bucket": {
     "last_validated_date": "2023-08-03T02:13:50+00:00"
   },
+  "tests/aws/services/s3/test_s3.py::TestS3::test_get_bucket_policy": {
+    "last_validated_date": "2024-11-10T19:20:17+00:00"
+  },
+  "tests/aws/services/s3/test_s3.py::TestS3::test_get_bucket_policy_invalid_account_id[0000000000020]": {
+    "last_validated_date": "2024-11-14T21:34:51+00:00"
+  },
+  "tests/aws/services/s3/test_s3.py::TestS3::test_get_bucket_policy_invalid_account_id[0000]": {
+    "last_validated_date": "2024-11-14T21:34:49+00:00"
+  },
+  "tests/aws/services/s3/test_s3.py::TestS3::test_get_bucket_policy_invalid_account_id[aa000000000$]": {
+    "last_validated_date": "2024-11-14T21:34:56+00:00"
+  },
+  "tests/aws/services/s3/test_s3.py::TestS3::test_get_bucket_policy_invalid_account_id[abcd]": {
+    "last_validated_date": "2024-11-14T21:34:53+00:00"
+  },
   "tests/aws/services/s3/test_s3.py::TestS3::test_get_bucket_versioning_order": {
     "last_validated_date": "2023-08-03T02:23:26+00:00"
   },
@@ -161,6 +182,27 @@
   "tests/aws/services/s3/test_s3.py::TestS3::test_put_bucket_inventory_config_order": {
     "last_validated_date": "2023-08-03T02:26:27+00:00"
   },
+  "tests/aws/services/s3/test_s3.py::TestS3::test_put_bucket_policy": {
+    "last_validated_date": "2024-11-10T19:20:47+00:00"
+  },
+  "tests/aws/services/s3/test_s3.py::TestS3::test_put_bucket_policy_expected_bucket_owner": {
+    "last_validated_date": "2024-11-10T19:32:05+00:00"
+  },
+  "tests/aws/services/s3/test_s3.py::TestS3::test_put_bucket_policy_invalid_account_id[0000000000020]": {
+    "last_validated_date": "2024-11-14T21:38:44+00:00"
+  },
+  "tests/aws/services/s3/test_s3.py::TestS3::test_put_bucket_policy_invalid_account_id[0000]": {
+    "last_validated_date": "2024-11-14T21:38:42+00:00"
+  },
+  "tests/aws/services/s3/test_s3.py::TestS3::test_put_bucket_policy_invalid_account_id[aa000000000$]": {
+    "last_validated_date": "2024-11-14T21:38:49+00:00"
+  },
+  "tests/aws/services/s3/test_s3.py::TestS3::test_put_bucket_policy_invalid_account_id[abcd]": {
+    "last_validated_date": "2024-11-14T21:38:46+00:00"
+  },
+  "tests/aws/services/s3/test_s3.py::TestS3::test_put_bucket_policy_with_expected_bucket_owner": {
+    "last_validated_date": "2024-11-10T19:06:26+00:00"
+  },
   "tests/aws/services/s3/test_s3.py::TestS3::test_put_get_object_special_character[a/%F0%9F%98%80/]": {
     "last_validated_date": "2023-12-12T12:46:44+00:00"
   },


Code review comments:
path: tests/aws/services/s3/test_s3.snapshot.json
text: @user1:
nit: I think as `test_put_and_get_bucket_policy` was renamed/deleted, its snapshot is still living in this file. It would be good to remove it (at `"tests/aws/services/s3/test_s3.py::TestS3::test_put_and_get_bucket_policy"`)

Same comment for the `tests/aws/services/s3/test_s3.validation.json` file. 👍

diff_hunk: @@ -161,6 +182,27 @@
   "tests/aws/services/s3/test_s3.py::TestS3::test_put_bucket_inventory_config_order": {
     "last_validated_date": "2023-08-03T02:26:27+00:00"
   },
+  "tests/aws/services/s3/test_s3.py::TestS3::test_put_bucket_policy": {
+    "last_validated_date": "2024-11-10T19:20:47+00:00"
+  },
+  "tests/aws/services/s3/test_s3.py::TestS3::test_put_bucket_policy_expected_bucket_owner": {
+    "last_validated_date": "2024-11-10T19:32:05+00:00"
+  },
+  "tests/aws/services/s3/test_s3.py::TestS3::test_put_bucket_policy_invalid_account_id[0000000000020]": {
+    "last_validated_date": "2024-11-14T21:38:44+00:00"
+  },
+  "tests/aws/services/s3/test_s3.py::TestS3::test_put_bucket_policy_invalid_account_id[0000]": {
+    "last_validated_date": "2024-11-14T21:38:42+00:00"
+  },
+  "tests/aws/services/s3/test_s3.py::TestS3::test_put_bucket_policy_invalid_account_id[aa000000000$]": {
+    "last_validated_date": "2024-11-14T21:38:49+00:00"
+  },
+  "tests/aws/services/s3/test_s3.py::TestS3::test_put_bucket_policy_invalid_account_id[abcd]": {
+    "last_validated_date": "2024-11-14T21:38:46+00:00"
+  },
+  "tests/aws/services/s3/test_s3.py::TestS3::test_put_bucket_policy_with_expected_bucket_owner": {
+    "last_validated_date": "2024-11-10T19:06:26+00:00"
+  },
path: tests/aws/services/s3/test_s3.validation.json
text: @user1:
nit: this looks like a leftover and the test has been renamed `test_put_bucket_policy_expected_bucket_owner` afterwards. Our util does not clean up after renaming a test yet.