mirror of
https://github.com/versity/versitygw.git
synced 2026-09-23 00:14:15 +00:00
A posix gateway serving a preexisting dataset treats every directory under the root as a bucket. But the bucket checks only stat the path, so a regular file, FIFO or symlink at the root passed for a bucket. Only `ListBuckets` skipped these entries. With a root-level file as the bucket, `HeadBucket`, `ListObjects`, `ListObjectsV2`, `ListMultipartUploads`, `GetBucketAcl`, `GetBucketLocation`, `GetBucketVersioning` and the bucket config getters returned a successful response. `PutBucketAcl`, `PutBucketTagging`, `PutBucketPolicy`, `PutBucketCors`, ... and the admin `ChangeBucketOwner` also succeeded, storing gateway metadata on the file itself or in the sidecar directory. `DeleteObject` and `DeleteObjects` reported success. `CreateMultipartUpload`, `UploadPart`, `UploadPartCopy`, `ListParts`, `CompleteMultipartUpload`, `ListObjectVersions` and `DeleteBucket` returned `InternalError` with not a directory. `PutObject` and `CopyObject` returned `ObjectParentIsFile`. `GetObject`, `HeadObject`, `GetObjectAttributes` and the object tagging, legal hold and retention actions returned `NoSuchKey`, and `AbortMultipartUpload` returned `NoSuchUpload`. A symlink loop at the root returned `InternalError` for every action. Bucket existence is now checked in one place, `doesBucketExist`, and `ScoutFS` uses it too through the exported `DoesBucketExist`. A bucket is a directory under the root, or a symlink to a directory when `--bucketlinks` is enabled. Any other entry returns `NoSuchBucket` for all bucket and object actions. `CreateBucket` on such an entry returns `BucketAlreadyExists` without reading its metadata. This also enforces `--bucketlinks` for all actions. Before, it only affected `ListBuckets`: a symlinked directory was hidden from the listing, but every other action followed the link and used it as a bucket even with the option disabled. Without `--bucketlinks` such symlinks now return `NoSuchBucket`.
356 lines
12 KiB
Go
356 lines
12 KiB
Go
// Copyright 2023 Versity Software
|
|
// This file is licensed under the Apache License, Version 2.0
|
|
// (the "License"); you may not use this file except in compliance
|
|
// with the License. You may obtain a copy of the License at
|
|
//
|
|
// http://www.apache.org/licenses/LICENSE-2.0
|
|
//
|
|
// Unless required by applicable law or agreed to in writing,
|
|
// software distributed under the License is distributed on an
|
|
// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
|
// KIND, either express or implied. See the License for the
|
|
// specific language governing permissions and limitations
|
|
// under the License.
|
|
|
|
package integration
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
|
|
"github.com/aws/aws-sdk-go-v2/service/iam"
|
|
"github.com/aws/aws-sdk-go-v2/service/s3"
|
|
"github.com/aws/aws-sdk-go-v2/service/s3/types"
|
|
"github.com/versity/versitygw/s3err"
|
|
)
|
|
|
|
func DeleteObjects_non_existing_bucket(s *S3Conf) error {
|
|
testName := "DeleteObjects_non_existing_bucket"
|
|
return actionHandler(s, testName, func(s3client *s3.Client, bucket string) error {
|
|
bckt := getBucketName()
|
|
ctx, cancel := context.WithTimeout(context.Background(), shortTimeout)
|
|
_, err := s3client.DeleteObjects(ctx, &s3.DeleteObjectsInput{
|
|
Bucket: &bckt,
|
|
Delete: &types.Delete{
|
|
Objects: []types.ObjectIdentifier{{Key: getPtr("obj1")}, {Key: getPtr("obj2")}},
|
|
},
|
|
})
|
|
cancel()
|
|
return checkApiErr(err, s3err.GetAPIError(s3err.ErrNoSuchBucket))
|
|
})
|
|
}
|
|
|
|
func DeleteObjects_empty_input(s *S3Conf) error {
|
|
testName := "DeleteObjects_empty_input"
|
|
return actionHandler(s, testName, func(s3client *s3.Client, bucket string) error {
|
|
contents, err := putObjects(s3client, []string{"foo", "bar", "baz"}, bucket)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), shortTimeout)
|
|
out, err := s3client.DeleteObjects(ctx, &s3.DeleteObjectsInput{
|
|
Bucket: &bucket,
|
|
Delete: &types.Delete{
|
|
Objects: []types.ObjectIdentifier{},
|
|
},
|
|
})
|
|
cancel()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
if len(out.Deleted) != 0 {
|
|
return fmt.Errorf("expected deleted object count 0, instead got %v",
|
|
len(out.Deleted))
|
|
}
|
|
if len(out.Errors) != 0 {
|
|
return fmt.Errorf("expected 0 errors, instead got %v", len(out.Errors))
|
|
}
|
|
|
|
ctx, cancel = context.WithTimeout(context.Background(), shortTimeout)
|
|
res, err := s3client.ListObjects(ctx, &s3.ListObjectsInput{
|
|
Bucket: &bucket,
|
|
})
|
|
cancel()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
if !compareObjects(contents, res.Contents) {
|
|
return fmt.Errorf("expected the output to be %v, instead got %v",
|
|
contents, res.Contents)
|
|
}
|
|
|
|
return nil
|
|
})
|
|
}
|
|
|
|
func DeleteObjects_non_existing_objects(s *S3Conf) error {
|
|
testName := "DeleteObjects_empty_input"
|
|
return actionHandler(s, testName, func(s3client *s3.Client, bucket string) error {
|
|
delObjects := []types.ObjectIdentifier{{Key: getPtr("obj1")}, {Key: getPtr("obj2")}}
|
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), shortTimeout)
|
|
out, err := s3client.DeleteObjects(ctx, &s3.DeleteObjectsInput{
|
|
Bucket: &bucket,
|
|
Delete: &types.Delete{
|
|
Objects: delObjects,
|
|
},
|
|
})
|
|
cancel()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
if len(out.Deleted) != 2 {
|
|
return fmt.Errorf("expected deleted object count 2, instead got %v",
|
|
len(out.Deleted))
|
|
}
|
|
if len(out.Errors) != 0 {
|
|
return fmt.Errorf("expected 0 errors, instead got %v, %v",
|
|
len(out.Errors), out.Errors)
|
|
}
|
|
|
|
return nil
|
|
})
|
|
}
|
|
|
|
func DeleteObjects_success(s *S3Conf) error {
|
|
testName := "DeleteObjects_success"
|
|
return actionHandler(s, testName, func(s3client *s3.Client, bucket string) error {
|
|
objects, objToDel := []string{"obj1", "obj2", "obj3"}, []string{"foo", "bar", "baz"}
|
|
contents, err := putObjects(s3client, append(objToDel, objects...), bucket)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
delObjects := []types.ObjectIdentifier{}
|
|
delResult := []types.DeletedObject{}
|
|
for _, key := range objToDel {
|
|
k := key
|
|
delObjects = append(delObjects, types.ObjectIdentifier{Key: &k})
|
|
delResult = append(delResult, types.DeletedObject{Key: &k})
|
|
}
|
|
ctx, cancel := context.WithTimeout(context.Background(), shortTimeout)
|
|
out, err := s3client.DeleteObjects(ctx, &s3.DeleteObjectsInput{
|
|
Bucket: &bucket,
|
|
Delete: &types.Delete{
|
|
Objects: delObjects,
|
|
},
|
|
})
|
|
cancel()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
if len(out.Deleted) != 3 {
|
|
return fmt.Errorf("expected deleted object count 3, instead got %v",
|
|
len(out.Deleted))
|
|
}
|
|
if len(out.Errors) != 0 {
|
|
return fmt.Errorf("expected 2 errors, instead got %v",
|
|
len(out.Errors))
|
|
}
|
|
|
|
if !compareDelObjects(delResult, out.Deleted) {
|
|
return fmt.Errorf("unexpected deleted output")
|
|
}
|
|
|
|
ctx, cancel = context.WithTimeout(context.Background(), shortTimeout)
|
|
res, err := s3client.ListObjects(ctx, &s3.ListObjectsInput{
|
|
Bucket: &bucket,
|
|
})
|
|
cancel()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
if !compareObjects(contents[3:], res.Contents) {
|
|
return fmt.Errorf("expected the output to be %v, instead got %v",
|
|
contents[3:], res.Contents)
|
|
}
|
|
|
|
return nil
|
|
})
|
|
}
|
|
|
|
// DeleteObjects_iam_mixed_denials_and_success covers a single batch mixing
|
|
// every DeleteObjects outcome at once: a key the identity policy denies, a
|
|
// governance-locked key with no bypass, and keys the caller may freely
|
|
// delete. All three outcomes land in one response — no top-level error —
|
|
// with Deleted and Errors each preserving the order the keys were
|
|
// requested in.
|
|
func DeleteObjects_iam_mixed_denials_and_success(s *S3Conf) error {
|
|
testName := "DeleteObjects_iam_mixed_denials_and_success"
|
|
return s3IAMActionHandler(s, testName, func(root *iam.Client, bucket string) error {
|
|
for _, key := range []string{"allowed/one", "allowed/two", "denied/one"} {
|
|
ctx, cancel := context.WithTimeout(context.Background(), shortTimeout)
|
|
_, err := s.GetClient().PutObject(ctx, &s3.PutObjectInput{Bucket: &bucket, Key: &key})
|
|
cancel()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
}
|
|
if err := putGovernanceLockedObject(s, bucket, "locked/one"); err != nil {
|
|
return err
|
|
}
|
|
|
|
user, cleanup, err := newS3IAMUser(root, s, map[string]string{
|
|
"p": policyDoc(accessStatement{
|
|
Effect: "Allow", Action: actS3DeleteObject,
|
|
Resource: []string{objectArn(bucket, "allowed/*"), objectArn(bucket, "locked/*")},
|
|
}),
|
|
})
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer cleanup()
|
|
|
|
delObjects := []types.ObjectIdentifier{
|
|
{Key: getPtr("allowed/one")},
|
|
{Key: getPtr("denied/one")},
|
|
{Key: getPtr("locked/one")},
|
|
{Key: getPtr("allowed/two")},
|
|
}
|
|
ctx, cancel := context.WithTimeout(context.Background(), shortTimeout)
|
|
out, err := user.client.DeleteObjects(ctx, &s3.DeleteObjectsInput{
|
|
Bucket: &bucket,
|
|
Delete: &types.Delete{Objects: delObjects},
|
|
})
|
|
cancel()
|
|
if err != nil {
|
|
return fmt.Errorf("expected DeleteObjects to succeed with per-object denials, not fail outright: %w", err)
|
|
}
|
|
|
|
if err := checkDeletedKeysInOrder(out.Deleted, []string{"allowed/one", "allowed/two"}); err != nil {
|
|
return err
|
|
}
|
|
return checkDeleteObjectsErrsInOrder(out.Errors, []keyDenial{
|
|
{"denied/one", wantImplicitDeny(user.arn, actS3DeleteObject, objectArn(bucket, "denied/one"))},
|
|
{"locked/one", s3err.GetAPIError(s3err.ErrObjectLocked)},
|
|
})
|
|
}, withLock())
|
|
}
|
|
|
|
// DeleteObjects_iam_all_access_denied covers a batch where the identity
|
|
// policy grants nothing at all: the call itself still succeeds — no
|
|
// top-level error — with every object reported denied in Errors, in
|
|
// request order, and nothing in Deleted.
|
|
func DeleteObjects_iam_all_access_denied(s *S3Conf) error {
|
|
testName := "DeleteObjects_iam_all_access_denied"
|
|
return s3IAMActionHandler(s, testName, func(root *iam.Client, bucket string) error {
|
|
for _, key := range []string{"one", "two", "three"} {
|
|
ctx, cancel := context.WithTimeout(context.Background(), shortTimeout)
|
|
_, err := s.GetClient().PutObject(ctx, &s3.PutObjectInput{Bucket: &bucket, Key: &key})
|
|
cancel()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
}
|
|
|
|
user, cleanup, err := newS3IAMUser(root, s, nil)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer cleanup()
|
|
|
|
delObjects := []types.ObjectIdentifier{
|
|
{Key: getPtr("one")}, {Key: getPtr("two")}, {Key: getPtr("three")},
|
|
}
|
|
ctx, cancel := context.WithTimeout(context.Background(), shortTimeout)
|
|
out, err := user.client.DeleteObjects(ctx, &s3.DeleteObjectsInput{
|
|
Bucket: &bucket,
|
|
Delete: &types.Delete{Objects: delObjects},
|
|
})
|
|
cancel()
|
|
if err != nil {
|
|
return fmt.Errorf("expected DeleteObjects to succeed with per-object denials, not fail outright: %w", err)
|
|
}
|
|
|
|
if len(out.Deleted) != 0 {
|
|
return fmt.Errorf("expected nothing deleted, got %+v", out.Deleted)
|
|
}
|
|
return checkDeleteObjectsErrsInOrder(out.Errors, []keyDenial{
|
|
{"one", wantImplicitDeny(user.arn, actS3DeleteObject, objectArn(bucket, "one"))},
|
|
{"two", wantImplicitDeny(user.arn, actS3DeleteObject, objectArn(bucket, "two"))},
|
|
{"three", wantImplicitDeny(user.arn, actS3DeleteObject, objectArn(bucket, "three"))},
|
|
})
|
|
})
|
|
}
|
|
|
|
// DeleteObjects_iam_all_locked covers a batch where every object is
|
|
// governance-locked and none is deleted: the call still succeeds — no
|
|
// top-level error — with every object reported denied in Errors, in
|
|
// request order, and nothing in Deleted. Omitting the bypass header
|
|
// entirely reports the generic object-lock message; sending the header
|
|
// without s3:BypassGovernanceRetention reports the specific AccessDenied
|
|
// naming that action instead — the same distinction the single-object
|
|
// DELETE path makes, now confirmed for the batch path too.
|
|
func DeleteObjects_iam_all_locked(s *S3Conf) error {
|
|
testName := "DeleteObjects_iam_all_locked"
|
|
return s3IAMActionHandler(s, testName, func(root *iam.Client, bucket string) error {
|
|
for _, key := range []string{"locked/one", "locked/two"} {
|
|
if err := putGovernanceLockedObject(s, bucket, key); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
|
|
user, cleanup, err := newS3IAMUser(root, s, map[string]string{
|
|
"p": policyDoc(accessStatement{
|
|
Effect: "Allow", Action: actS3DeleteObject, Resource: objectsArn(bucket),
|
|
}),
|
|
})
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer cleanup()
|
|
|
|
delObjects := []types.ObjectIdentifier{{Key: getPtr("locked/one")}, {Key: getPtr("locked/two")}}
|
|
|
|
// No bypass header at all: the generic object-lock message.
|
|
ctx, cancel := context.WithTimeout(context.Background(), shortTimeout)
|
|
out, err := user.client.DeleteObjects(ctx, &s3.DeleteObjectsInput{
|
|
Bucket: &bucket,
|
|
Delete: &types.Delete{Objects: delObjects},
|
|
})
|
|
cancel()
|
|
if err != nil {
|
|
return fmt.Errorf("expected DeleteObjects to succeed with per-object denials, not fail outright: %w", err)
|
|
}
|
|
if len(out.Deleted) != 0 {
|
|
return fmt.Errorf("expected nothing deleted, got %+v", out.Deleted)
|
|
}
|
|
if err := checkDeleteObjectsErrsInOrder(out.Errors, []keyDenial{
|
|
{"locked/one", s3err.GetAPIError(s3err.ErrObjectLocked)},
|
|
{"locked/two", s3err.GetAPIError(s3err.ErrObjectLocked)},
|
|
}); err != nil {
|
|
return fmt.Errorf("without bypass header: %w", err)
|
|
}
|
|
|
|
// Bypass header sent, but the identity policy doesn't grant
|
|
// s3:BypassGovernanceRetention: a specific AccessDenied naming that
|
|
// action, not the generic object-lock message.
|
|
ctx, cancel = context.WithTimeout(context.Background(), shortTimeout)
|
|
out, err = user.client.DeleteObjects(ctx, &s3.DeleteObjectsInput{
|
|
Bucket: &bucket,
|
|
Delete: &types.Delete{Objects: delObjects},
|
|
BypassGovernanceRetention: getPtr(true),
|
|
})
|
|
cancel()
|
|
if err != nil {
|
|
return fmt.Errorf("expected DeleteObjects to succeed with per-object denials, not fail outright: %w", err)
|
|
}
|
|
if len(out.Deleted) != 0 {
|
|
return fmt.Errorf("expected nothing deleted, got %+v", out.Deleted)
|
|
}
|
|
if err := checkDeleteObjectsErrsInOrder(out.Errors, []keyDenial{
|
|
{"locked/one", wantImplicitDeny(user.arn, actS3BypassGovernance, objectArn(bucket, "locked/one"))},
|
|
{"locked/two", wantImplicitDeny(user.arn, actS3BypassGovernance, objectArn(bucket, "locked/two"))},
|
|
}); err != nil {
|
|
return fmt.Errorf("with bypass header, no permission: %w", err)
|
|
}
|
|
return nil
|
|
}, withLock())
|
|
}
|