| index 495f1c483..8e6e84e71 100644 |
| --- a/internal/objstore/contract_test.go |
| +++ b/internal/objstore/contract_test.go |
| @@ -218,6 +218,135 @@ func TestDeliveredContractMinio(t *testing.T) { |
| requireDenied(t, err, "PutObject with x-amz-tagging") |
| }) |
| }) |
| + |
| + // THE OTHER ROUTES. "By either route" was two routes, and the property has |
| + // more than two. |
| + // |
| + // Each of these is authorised as plain `s3:PutObject` and carries tags |
| + // somewhere other than the `x-amz-tagging` header of a simple PUT, so the |
| + // Deny added for that header is not obviously in the path. Whether a gateway |
| + // populates `s3:RequestObjectTagKeys` in these handlers is a gateway-specific |
| + // fact of exactly the kind that already bit this design once — which is why |
| + // they are asserted against a real server rather than reasoned about. |
| + t.Run("the app cannot tag by any other route", func(t *testing.T) { |
| + // Multipart: any upload over the SDK's threshold takes this path, so |
| + // an app does not even have to choose it deliberately. |
| + t.Run("not by CreateMultipartUpload", func(t *testing.T) { |
| + const key = "contract-test/multipart-tagged.bin" |
| + out, err := client.CreateMultipartUpload(ctx, &awss3.CreateMultipartUploadInput{ |
| + Bucket: aws.String(bucket), |
| + Key: aws.String(key), |
| + Tagging: aws.String("carlos-scan=clean"), |
| + }) |
| + if err == nil { |
| + // Nothing was completed, but the upload id exists; clean it up so |
| + // the bucket is not left holding a live multipart session. |
| + _, _ = client.AbortMultipartUpload(ctx, &awss3.AbortMultipartUploadInput{ |
| + Bucket: aws.String(bucket), Key: aws.String(key), UploadId: out.UploadId, |
| + }) |
| + } |
| + requireDenied(t, err, "CreateMultipartUpload with x-amz-tagging") |
| + }) |
| + |
| + // Copy with REPLACE: the app already has GetObject and PutObject, so it |
| + // can copy its own object over itself and name the tags on the way. |
| + t.Run("not by CopyObject with a REPLACE tagging directive", func(t *testing.T) { |
| + const src = "contract-test/copy-src.txt" |
| + if _, err := client.PutObject(ctx, &awss3.PutObjectInput{ |
| + Bucket: aws.String(bucket), |
| + Key: aws.String(src), |
| + Body: bytes.NewReader([]byte("payload")), |
| + }); err != nil { |
| + t.Fatalf("seed the copy source: %v", err) |
| + } |
| + defer client.DeleteObject(ctx, &awss3.DeleteObjectInput{ |
| + Bucket: aws.String(bucket), Key: aws.String(src), |
| + }) |
| + |
| + const dst = "contract-test/copy-tagged.txt" |
| + _, err := client.CopyObject(ctx, &awss3.CopyObjectInput{ |
| + Bucket: aws.String(bucket), |
| + Key: aws.String(dst), |
| + CopySource: aws.String(bucket + "/" + src), |
| + TaggingDirective: s3types.TaggingDirectiveReplace, |
| + Tagging: aws.String("carlos-scan=clean"), |
| + }) |
| + if err == nil { |
| + _, _ = client.DeleteObject(ctx, &awss3.DeleteObjectInput{ |
| + Bucket: aws.String(bucket), Key: aws.String(dst), |
| + }) |
| + } |
| + requireDenied(t, err, "CopyObject with a REPLACE tagging directive") |
| + }) |
| + |
| + // THE CONTROLS, WITHOUT WHICH THE TWO ABOVE PROVE NOTHING. |
| + // |
| + // Both assert AccessDenied. If these operations were denied anyway — |
| + // because the app policy never permitted multipart or copy in the |
| + // first place — the assertions would pass while saying nothing at all |
| + // about tagging, and a later policy that lost the tag-key Deny would |
| + // still look green. So each route is exercised again WITHOUT our tag |
| + // keys and must succeed: that is what makes the refusals above |
| + // attributable to the tag and not to the verb. |
| + t.Run("but multipart itself is allowed", func(t *testing.T) { |
| + const key = "contract-test/multipart-plain.bin" |
| + out, err := client.CreateMultipartUpload(ctx, &awss3.CreateMultipartUploadInput{ |
| + Bucket: aws.String(bucket), |
| + Key: aws.String(key), |
| + }) |
| + if err != nil { |
| + t.Fatalf("CreateMultipartUpload without tags = %v — the refusal above was about the VERB, not the tag, and proves nothing", err) |
| + } |
| + _, _ = client.AbortMultipartUpload(ctx, &awss3.AbortMultipartUploadInput{ |
| + Bucket: aws.String(bucket), Key: aws.String(key), UploadId: out.UploadId, |
| + }) |
| + }) |
| + |
| + t.Run("and copy itself is allowed", func(t *testing.T) { |
| + const src = "contract-test/copy-control-src.txt" |
| + const dst = "contract-test/copy-control-dst.txt" |
| + if _, err := client.PutObject(ctx, &awss3.PutObjectInput{ |
| + Bucket: aws.String(bucket), Key: aws.String(src), |
| + Body: bytes.NewReader([]byte("payload")), |
| + }); err != nil { |
| + t.Fatalf("seed: %v", err) |
| + } |
| + defer client.DeleteObject(ctx, &awss3.DeleteObjectInput{ |
| + Bucket: aws.String(bucket), Key: aws.String(src), |
| + }) |
| + _, err := client.CopyObject(ctx, &awss3.CopyObjectInput{ |
| + Bucket: aws.String(bucket), |
| + Key: aws.String(dst), |
| + CopySource: aws.String(bucket + "/" + src), |
| + }) |
| + if err != nil { |
| + t.Fatalf("CopyObject without tags = %v — the refusal above was about the VERB, not the tag, and proves nothing", err) |
| + } |
| + _, _ = client.DeleteObject(ctx, &awss3.DeleteObjectInput{ |
| + Bucket: aws.String(bucket), Key: aws.String(dst), |
| + }) |
| + }) |
| + |
| + // And the same control for the plain PUT route, which has the same |
| + // hazard: an ordinary untagged upload must still work, or the Deny is |
| + // firing on every write and the store is simply broken. |
| + t.Run("and an ordinary tagged-with-something-else upload is allowed", func(t *testing.T) { |
| + const key = "contract-test/foreign-tag.txt" |
| + _, err := client.PutObject(ctx, &awss3.PutObjectInput{ |
| + Bucket: aws.String(bucket), |
| + Key: aws.String(key), |
| + Body: bytes.NewReader([]byte("payload")), |
| + Tagging: aws.String("team=payments"), |
| + }) |
| + if err != nil { |
| + t.Fatalf("PutObject with a tag of the app's own = %v — the Deny is keyed on OUR tag names and must not touch a tenant's", err) |
| + } |
| + _, _ = client.DeleteObject(ctx, &awss3.DeleteObjectInput{ |
| + Bucket: aws.String(bucket), Key: aws.String(key), |
| + }) |
| + }) |
| + }) |
| + |
| t.Run("delete removes it", func(t *testing.T) { |
| _, err := client.DeleteObject(ctx, &awss3.DeleteObjectInput{ |
| Bucket: aws.String(bucket), |