package policy // Tests for S3 bucket policy JSON round-trip idempotency. // These validate behavior that IaC tools (Terraform, Ansible) depend on: // PUT a policy, GET it back, and verify the JSON matches exactly. // See https://github.com/seaweedfs/seaweedfs/issues/8657 import ( "encoding/json" "testing" "github.com/aws/aws-sdk-go/aws" "github.com/aws/aws-sdk-go/aws/awserr" "github.com/aws/aws-sdk-go/aws/credentials" "github.com/aws/aws-sdk-go/aws/session" "github.com/aws/aws-sdk-go/service/s3" "github.com/stretchr/testify/require" ) func waitForClusterReady(t *testing.T, s3Client *s3.S3) { t.Helper() // ListBuckets is a lightweight call that confirms the S3 API is serving. _, err := s3Client.ListBuckets(&s3.ListBucketsInput{}) require.NoError(t, err, "S3 endpoint not ready") } func newS3ClientForCluster(t *testing.T, cluster *TestCluster) *s3.S3 { t.Helper() sess, err := session.NewSession(&aws.Config{ Region: aws.String("us-east-1"), Endpoint: aws.String(cluster.s3Endpoint), DisableSSL: aws.Bool(true), S3ForcePathStyle: aws.Bool(true), Credentials: credentials.NewStaticCredentials("admin", "admin", ""), }) require.NoError(t, err) return s3.New(sess) } // TestBucketPolicyRoundTrip verifies that GetBucketPolicy returns exactly what // was submitted via PutBucketPolicy, without adding spurious fields like // "NotResource": null. This is the core issue from #8657. func TestBucketPolicyRoundTrip(t *testing.T) { if testing.Short() { t.Skip("Skipping integration test in short mode") } cluster, err := startMiniCluster(t) require.NoError(t, err) defer cluster.Stop() s3Client := newS3ClientForCluster(t, cluster) waitForClusterReady(t, s3Client) bucket := uniqueName("policy-rt") _, err = s3Client.CreateBucket(&s3.CreateBucketInput{Bucket: aws.String(bucket)}) require.NoError(t, err) tests := []struct { name string policy map[string]interface{} }{ { name: "simple allow without NotResource", policy: map[string]interface{}{ "Version": "2012-10-17", "Statement": []interface{}{ map[string]interface{}{ "Sid": "AllowPublicRead", "Effect": "Allow", "Principal": "*", "Action": "s3:GetObject", "Resource": "arn:aws:s3:::" + bucket + "/*", }, }, }, }, { name: "multiple actions without NotResource", policy: map[string]interface{}{ "Version": "2012-10-17", "Statement": []interface{}{ map[string]interface{}{ "Sid": "ReadWrite", "Effect": "Allow", "Principal": "*", "Action": []interface{}{"s3:GetObject", "s3:PutObject"}, "Resource": "arn:aws:s3:::" + bucket + "/*", }, }, }, }, { name: "allow with NotResource", policy: map[string]interface{}{ "Version": "2012-10-17", "Statement": []interface{}{ map[string]interface{}{ "Sid": "AllowOutsidePublic", "Effect": "Allow", "Principal": "*", "Action": "s3:GetObject", "NotResource": "arn:aws:s3:::" + bucket + "/private/*", }, }, }, }, { name: "multiple statements with NotResource", policy: map[string]interface{}{ "Version": "2012-10-17", "Statement": []interface{}{ map[string]interface{}{ "Sid": "AllowRead", "Effect": "Allow", "Principal": "*", "Action": "s3:GetObject", "Resource": "arn:aws:s3:::" + bucket + "/*", }, map[string]interface{}{ "Sid": "DenyPrivateObjects", "Effect": "Deny", "Principal": "*", "Action": "s3:GetObject", "NotResource": "arn:aws:s3:::" + bucket + "/public/*", }, }, }, }, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { policyJSON, err := json.Marshal(tc.policy) require.NoError(t, err) _, err = s3Client.PutBucketPolicy(&s3.PutBucketPolicyInput{ Bucket: aws.String(bucket), Policy: aws.String(string(policyJSON)), }) require.NoError(t, err) getOut, err := s3Client.GetBucketPolicy(&s3.GetBucketPolicyInput{ Bucket: aws.String(bucket), }) require.NoError(t, err) require.NotNil(t, getOut.Policy) // The returned policy must not contain fields that were not submitted. // This is the exact issue from #8657: NotResource:null was being added. returnedJSON := *getOut.Policy var returnedPolicy map[string]interface{} require.NoError(t, json.Unmarshal([]byte(returnedJSON), &returnedPolicy)) if !hasKey(tc.policy, "NotResource") { require.False(t, hasKey(returnedPolicy, "NotResource"), "returned policy must not contain NotResource when it was not submitted") } // Semantic comparison of all submitted fields require.JSONEq(t, string(policyJSON), returnedJSON, "GET should return semantically identical policy to what was PUT") }) } } // TestBucketPolicyIdempotentPut verifies that putting the same policy twice // does not change the returned value — the behavior IaC tools rely on. func TestBucketPolicyIdempotentPut(t *testing.T) { if testing.Short() { t.Skip("Skipping integration test in short mode") } cluster, err := startMiniCluster(t) require.NoError(t, err) defer cluster.Stop() s3Client := newS3ClientForCluster(t, cluster) waitForClusterReady(t, s3Client) bucket := uniqueName("policy-idem") _, err = s3Client.CreateBucket(&s3.CreateBucketInput{Bucket: aws.String(bucket)}) require.NoError(t, err) policyJSON := `{ "Version": "2012-10-17", "Statement": [{ "Sid": "AllowRead", "Effect": "Allow", "Principal": "*", "Action": "s3:GetObject", "Resource": "arn:aws:s3:::` + bucket + `/*" }] }` // PUT, then GET, then PUT the returned value, then GET again. // Both GETs should return the same result. _, err = s3Client.PutBucketPolicy(&s3.PutBucketPolicyInput{ Bucket: aws.String(bucket), Policy: aws.String(policyJSON), }) require.NoError(t, err) getOut1, err := s3Client.GetBucketPolicy(&s3.GetBucketPolicyInput{ Bucket: aws.String(bucket), }) require.NoError(t, err) // Re-PUT the policy that was returned by GET (what Terraform does on update) _, err = s3Client.PutBucketPolicy(&s3.PutBucketPolicyInput{ Bucket: aws.String(bucket), Policy: getOut1.Policy, }) require.NoError(t, err) getOut2, err := s3Client.GetBucketPolicy(&s3.GetBucketPolicyInput{ Bucket: aws.String(bucket), }) require.NoError(t, err) require.JSONEq(t, *getOut1.Policy, *getOut2.Policy, "re-PUTting the GET result must produce identical output (idempotency)") } // TestBucketPolicyDeleteAndRecreate verifies clean lifecycle. func TestBucketPolicyDeleteAndRecreate(t *testing.T) { if testing.Short() { t.Skip("Skipping integration test in short mode") } cluster, err := startMiniCluster(t) require.NoError(t, err) defer cluster.Stop() s3Client := newS3ClientForCluster(t, cluster) waitForClusterReady(t, s3Client) bucket := uniqueName("policy-del") _, err = s3Client.CreateBucket(&s3.CreateBucketInput{Bucket: aws.String(bucket)}) require.NoError(t, err) policyJSON := `{ "Version": "2012-10-17", "Statement": [{ "Effect": "Allow", "Principal": "*", "Action": "s3:GetObject", "Resource": "arn:aws:s3:::` + bucket + `/*" }] }` // PUT _, err = s3Client.PutBucketPolicy(&s3.PutBucketPolicyInput{ Bucket: aws.String(bucket), Policy: aws.String(policyJSON), }) require.NoError(t, err) // DELETE _, err = s3Client.DeleteBucketPolicy(&s3.DeleteBucketPolicyInput{ Bucket: aws.String(bucket), }) require.NoError(t, err) // GET should fail with NoSuchBucketPolicy _, err = s3Client.GetBucketPolicy(&s3.GetBucketPolicyInput{ Bucket: aws.String(bucket), }) require.Error(t, err) awsErr, ok := err.(awserr.Error) require.True(t, ok, "expected AWS error, got %T: %v", err, err) require.Equal(t, "NoSuchBucketPolicy", awsErr.Code()) // Re-PUT same policy _, err = s3Client.PutBucketPolicy(&s3.PutBucketPolicyInput{ Bucket: aws.String(bucket), Policy: aws.String(policyJSON), }) require.NoError(t, err) // GET should succeed and be clean getOut, err := s3Client.GetBucketPolicy(&s3.GetBucketPolicyInput{ Bucket: aws.String(bucket), }) require.NoError(t, err) var recreatedPolicy map[string]interface{} require.NoError(t, json.Unmarshal([]byte(*getOut.Policy), &recreatedPolicy)) require.False(t, hasKey(recreatedPolicy, "NotResource"), "recreated policy must not contain spurious NotResource") } // hasKey checks whether any Statement in the policy map contains the given key. // Handles both single-statement objects and arrays of statements. func hasKey(policy map[string]interface{}, key string) bool { stmtsRaw, ok := policy["Statement"] if !ok { return false } // Single statement object if stmt, ok := stmtsRaw.(map[string]interface{}); ok { _, exists := stmt[key] return exists } // Array of statements if stmts, ok := stmtsRaw.([]interface{}); ok { for _, s := range stmts { stmt, ok := s.(map[string]interface{}) if !ok { continue } if _, exists := stmt[key]; exists { return true } } } return false }