From 2a5705ab02e5638bd33e8d9034fb9063ad951354 Mon Sep 17 00:00:00 2001 From: upstream Date: Fri, 11 Sep 2026 23:12:57 +0200 Subject: [PATCH] Upstream versity/versitygw state of the touched files before b237f0cf7a --- s3api/controllers/bucket-put.go | 725 +++++++++++++++ s3api/controllers/bucket-put_test.go | 1278 ++++++++++++++++++++++++++ s3err/s3err.go | 1061 +++++++++++++++++++++ 3 files changed, 3064 insertions(+) create mode 100644 s3api/controllers/bucket-put.go create mode 100644 s3api/controllers/bucket-put_test.go create mode 100644 s3err/s3err.go diff --git a/s3api/controllers/bucket-put.go b/s3api/controllers/bucket-put.go new file mode 100644 index 0000000..9c1b101 --- /dev/null +++ b/s3api/controllers/bucket-put.go @@ -0,0 +1,725 @@ +// 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 controllers + +import ( + "encoding/xml" + "errors" + "net/http" + "strings" + + "github.com/aws/aws-sdk-go-v2/service/s3" + "github.com/aws/aws-sdk-go-v2/service/s3/types" + "github.com/gofiber/fiber/v3" + "github.com/versity/versitygw/auth" + "github.com/versity/versitygw/debuglogger" + "github.com/versity/versitygw/s3api/utils" + "github.com/versity/versitygw/s3err" + "github.com/versity/versitygw/s3response" +) + +func (c S3ApiController) PutBucketTagging(ctx fiber.Ctx) (*Response, error) { + bucket := ctx.Params("bucket") + parsedAcl := utils.ContextKeyParsedAcl.Get(ctx).(auth.ACL) + acct := utils.ContextKeyAccount.Get(ctx).(auth.Account) + isRoot := utils.ContextKeyIsRoot.Get(ctx).(bool) + isPublicBucket := utils.ContextKeyPublicBucket.IsSet(ctx) + + err := c.verifyAccess(ctx, auth.AccessOptions{ + Acl: parsedAcl, + AclPermission: auth.PermissionWrite, + IsRoot: isRoot, + Acc: acct, + Bucket: bucket, + Actions: []auth.Action{auth.PutBucketTaggingAction}, + IsPublicRequest: isPublicBucket, + }) + if err != nil { + return &Response{ + MetaOpts: &MetaOptions{ + BucketOwner: parsedAcl.Owner, + }, + }, err + } + + tagging, err := utils.ParseTagging(ctx.BodyRaw(), utils.TagLimitBucket) + if err != nil { + return &Response{ + MetaOpts: &MetaOptions{ + BucketOwner: parsedAcl.Owner, + }, + }, err + } + + err = c.be.PutBucketTagging(ctx.RequestCtx(), bucket, tagging) + return &Response{ + MetaOpts: &MetaOptions{ + BucketOwner: parsedAcl.Owner, + Status: http.StatusNoContent, + }, + }, err +} + +func (c S3ApiController) PutBucketOwnershipControls(ctx fiber.Ctx) (*Response, error) { + bucket := ctx.Params("bucket") + parsedAcl := utils.ContextKeyParsedAcl.Get(ctx).(auth.ACL) + acct := utils.ContextKeyAccount.Get(ctx).(auth.Account) + isRoot := utils.ContextKeyIsRoot.Get(ctx).(bool) + + if err := c.verifyAccess(ctx, auth.AccessOptions{ + Acl: parsedAcl, + AclPermission: auth.PermissionWrite, + IsRoot: isRoot, + Acc: acct, + Bucket: bucket, + Actions: []auth.Action{auth.PutBucketOwnershipControlsAction}, + }); err != nil { + return &Response{ + MetaOpts: &MetaOptions{ + BucketOwner: parsedAcl.Owner, + }, + }, err + } + + var ownershipControls s3response.OwnershipControls + if err := xml.Unmarshal(ctx.BodyRaw(), &ownershipControls); err != nil { + debuglogger.Logf("failed to unmarshal request body: %v", err) + return &Response{ + MetaOpts: &MetaOptions{ + BucketOwner: parsedAcl.Owner, + }, + }, s3err.GetAPIError(s3err.ErrMalformedXML) + } + + rulesCount := len(ownershipControls.Rules) + if rulesCount != 1 { + debuglogger.Logf("ownership control rules should be 1, got %v", rulesCount) + return &Response{ + MetaOpts: &MetaOptions{ + BucketOwner: parsedAcl.Owner, + }, + }, s3err.GetAPIError(s3err.ErrMalformedXML) + } + + if !utils.IsValidOwnership(ownershipControls.Rules[0].ObjectOwnership) { + return &Response{ + MetaOpts: &MetaOptions{ + BucketOwner: parsedAcl.Owner, + }, + }, s3err.GetAPIError(s3err.ErrMalformedXML) + } + + err := c.be.PutBucketOwnershipControls(ctx.RequestCtx(), bucket, ownershipControls.Rules[0].ObjectOwnership) + return &Response{ + MetaOpts: &MetaOptions{ + BucketOwner: parsedAcl.Owner, + }, + }, err +} + +func (c S3ApiController) PutBucketVersioning(ctx fiber.Ctx) (*Response, error) { + bucket := ctx.Params("bucket") + parsedAcl := utils.ContextKeyParsedAcl.Get(ctx).(auth.ACL) + acct := utils.ContextKeyAccount.Get(ctx).(auth.Account) + isRoot := utils.ContextKeyIsRoot.Get(ctx).(bool) + isPublicBucket := utils.ContextKeyPublicBucket.IsSet(ctx) + + err := c.verifyAccess(ctx, auth.AccessOptions{ + Acl: parsedAcl, + AclPermission: auth.PermissionWrite, + IsRoot: isRoot, + Acc: acct, + Bucket: bucket, + Actions: []auth.Action{auth.PutBucketVersioningAction}, + IsPublicRequest: isPublicBucket, + }) + if err != nil { + return &Response{ + MetaOpts: &MetaOptions{ + BucketOwner: parsedAcl.Owner, + }, + }, err + } + + var versioningConf types.VersioningConfiguration + err = xml.Unmarshal(ctx.BodyRaw(), &versioningConf) + if err != nil { + debuglogger.Logf("error unmarshalling versioning configuration: %v", err) + return &Response{ + MetaOpts: &MetaOptions{ + BucketOwner: parsedAcl.Owner, + }, + }, s3err.GetAPIError(s3err.ErrInvalidRequest) + } + + if versioningConf.Status != types.BucketVersioningStatusEnabled && + versioningConf.Status != types.BucketVersioningStatusSuspended { + debuglogger.Logf("invalid versioning configuration status: %v", versioningConf.Status) + return &Response{ + MetaOpts: &MetaOptions{ + BucketOwner: parsedAcl.Owner, + }, + }, s3err.GetAPIError(s3err.ErrMalformedXML) + } + + err = c.be.PutBucketVersioning(ctx.RequestCtx(), bucket, versioningConf.Status) + return &Response{ + MetaOpts: &MetaOptions{ + BucketOwner: parsedAcl.Owner, + }, + }, err +} + +func (c S3ApiController) PutObjectLockConfiguration(ctx fiber.Ctx) (*Response, error) { + bucket := ctx.Params("bucket") + parsedAcl := utils.ContextKeyParsedAcl.Get(ctx).(auth.ACL) + acct := utils.ContextKeyAccount.Get(ctx).(auth.Account) + isRoot := utils.ContextKeyIsRoot.Get(ctx).(bool) + isPublicBucket := utils.ContextKeyPublicBucket.IsSet(ctx) + + if err := c.verifyAccess(ctx, auth.AccessOptions{ + Acl: parsedAcl, + AclPermission: auth.PermissionWrite, + IsRoot: isRoot, + Acc: acct, + Bucket: bucket, + Actions: []auth.Action{auth.PutBucketObjectLockConfigurationAction}, + IsPublicRequest: isPublicBucket, + }); err != nil { + return &Response{ + MetaOpts: &MetaOptions{ + BucketOwner: parsedAcl.Owner, + }, + }, err + } + + config, err := auth.ParseBucketLockConfigurationInput(ctx.BodyRaw()) + if err != nil { + return &Response{ + MetaOpts: &MetaOptions{ + BucketOwner: parsedAcl.Owner, + }, + }, err + } + + err = c.be.PutObjectLockConfiguration(ctx.RequestCtx(), bucket, config) + return &Response{ + MetaOpts: &MetaOptions{ + BucketOwner: parsedAcl.Owner, + }, + }, err +} + +func (c S3ApiController) PutBucketCors(ctx fiber.Ctx) (*Response, error) { + bucket := ctx.Params("bucket") + parsedAcl := utils.ContextKeyParsedAcl.Get(ctx).(auth.ACL) + acct := utils.ContextKeyAccount.Get(ctx).(auth.Account) + isRoot := utils.ContextKeyIsRoot.Get(ctx).(bool) + isPublicBucket := utils.ContextKeyPublicBucket.IsSet(ctx) + + err := c.verifyAccess(ctx, auth.AccessOptions{ + Acl: parsedAcl, + AclPermission: auth.PermissionWrite, + IsRoot: isRoot, + Acc: acct, + Bucket: bucket, + Actions: []auth.Action{auth.PutBucketCorsAction}, + IsPublicRequest: isPublicBucket, + }) + if err != nil { + return &Response{ + MetaOpts: &MetaOptions{ + BucketOwner: parsedAcl.Owner, + }, + }, err + } + + body := ctx.BodyRaw() + + var corsConfig auth.CORSConfiguration + err = xml.Unmarshal(body, &corsConfig) + if err != nil { + debuglogger.Logf("invalid CORS request body: %v", err) + return &Response{ + MetaOpts: &MetaOptions{ + BucketOwner: parsedAcl.Owner, + }, + }, s3err.GetAPIError(s3err.ErrMalformedXML) + } + + // validate the CORS configuration rules + err = corsConfig.Validate() + if err != nil { + return &Response{ + MetaOpts: &MetaOptions{ + BucketOwner: parsedAcl.Owner, + }, + }, err + } + + err = c.be.PutBucketCors(ctx.RequestCtx(), bucket, body) + return &Response{ + MetaOpts: &MetaOptions{ + BucketOwner: parsedAcl.Owner, + }, + }, err +} + +func (c S3ApiController) PutBucketWebsite(ctx fiber.Ctx) (*Response, error) { + bucket := ctx.Params("bucket") + parsedAcl := utils.ContextKeyParsedAcl.Get(ctx).(auth.ACL) + acct := utils.ContextKeyAccount.Get(ctx).(auth.Account) + isRoot := utils.ContextKeyIsRoot.Get(ctx).(bool) + isPublicBucket := utils.ContextKeyPublicBucket.IsSet(ctx) + + err := c.verifyAccess(ctx, auth.AccessOptions{ + Acl: parsedAcl, + AclPermission: auth.PermissionWrite, + IsRoot: isRoot, + Acc: acct, + Bucket: bucket, + Actions: []auth.Action{auth.PutBucketWebsiteAction}, + IsPublicRequest: isPublicBucket, + }) + if err != nil { + return &Response{ + MetaOpts: &MetaOptions{ + BucketOwner: parsedAcl.Owner, + }, + }, err + } + + body := ctx.BodyRaw() + if len(body) > maxWebsiteConfigurationBytes { + debuglogger.Logf("the request size exceeded the 128KB limit: %d", len(body)) + return &Response{ + MetaOpts: &MetaOptions{ + BucketOwner: parsedAcl.Owner, + }, + }, s3err.GetMaxMessageLengthExceeded(maxWebsiteConfigurationBytes) + } + + var websiteConfig s3response.WebsiteConfiguration + err = xml.Unmarshal(body, &websiteConfig) + if err != nil { + debuglogger.Logf("invalid website config request body: %v", err) + return &Response{ + MetaOpts: &MetaOptions{ + BucketOwner: parsedAcl.Owner, + }, + }, s3err.GetAPIError(s3err.ErrMalformedXML) + } + + err = websiteConfig.Validate() + if err != nil { + return &Response{ + MetaOpts: &MetaOptions{ + BucketOwner: parsedAcl.Owner, + }, + }, err + } + + err = c.be.PutBucketWebsite(ctx.RequestCtx(), bucket, body) + return &Response{ + MetaOpts: &MetaOptions{ + BucketOwner: parsedAcl.Owner, + }, + }, err +} + +func (c S3ApiController) PutBucketPolicy(ctx fiber.Ctx) (*Response, error) { + bucket := ctx.Params("bucket") + parsedAcl := utils.ContextKeyParsedAcl.Get(ctx).(auth.ACL) + acct := utils.ContextKeyAccount.Get(ctx).(auth.Account) + isRoot := utils.ContextKeyIsRoot.Get(ctx).(bool) + + err := c.verifyAccess(ctx, auth.AccessOptions{ + Acl: parsedAcl, + AclPermission: auth.PermissionWrite, + IsRoot: isRoot, + Acc: acct, + Bucket: bucket, + Actions: []auth.Action{auth.PutBucketPolicyAction}, + }) + if err != nil { + return &Response{ + MetaOpts: &MetaOptions{ + BucketOwner: parsedAcl.Owner, + }, + }, err + } + + err = auth.ValidatePolicyDocument(ctx.BodyRaw(), bucket, c.iam) + if err != nil { + return &Response{ + MetaOpts: &MetaOptions{ + BucketOwner: parsedAcl.Owner, + }, + }, err + } + + err = c.be.PutBucketPolicy(ctx.RequestCtx(), bucket, ctx.BodyRaw()) + return &Response{ + MetaOpts: &MetaOptions{ + BucketOwner: parsedAcl.Owner, + Status: http.StatusNoContent, + }, + }, err +} + +func (c S3ApiController) PutBucketAcl(ctx fiber.Ctx) (*Response, error) { + bucket := ctx.Params("bucket") + acl := types.BucketCannedACL(ctx.Get("X-Amz-Acl")) + grantFullControl := ctx.Get("X-Amz-Grant-Full-Control") + grantRead := ctx.Get("X-Amz-Grant-Read") + grantReadACP := ctx.Get("X-Amz-Grant-Read-Acp") + grantWrite := ctx.Get("X-Amz-Grant-Write") + grantWriteACP := ctx.Get("X-Amz-Grant-Write-Acp") + // context locals + parsedAcl := utils.ContextKeyParsedAcl.Get(ctx).(auth.ACL) + acct := utils.ContextKeyAccount.Get(ctx).(auth.Account) + isRoot := utils.ContextKeyIsRoot.Get(ctx).(bool) + + grants := grantFullControl + grantRead + grantReadACP + grantWrite + grantWriteACP + var input *auth.PutBucketAclInput + + err := c.verifyAccess(ctx, + auth.AccessOptions{ + Acl: parsedAcl, + AclPermission: auth.PermissionWriteAcp, + IsRoot: isRoot, + Acc: acct, + Bucket: bucket, + Actions: []auth.Action{auth.PutBucketAclAction}, + }) + if err != nil { + return &Response{ + MetaOpts: &MetaOptions{ + BucketOwner: parsedAcl.Owner, + }, + }, err + } + + if c.disableACL { + debuglogger.Logf("PutBucketAcl is not available, as ACLs are disabled at gateway level") + return &Response{ + MetaOpts: &MetaOptions{ + BucketOwner: parsedAcl.Owner, + }, + }, s3err.GetAPIError(s3err.ErrACLsDisabled) + } + + err = auth.ValidateCannedACL(acl) + if err != nil { + return &Response{ + MetaOpts: &MetaOptions{ + BucketOwner: parsedAcl.Owner, + }, + }, err + } + + ownership, err := c.be.GetBucketOwnershipControls(ctx.RequestCtx(), bucket) + if err != nil && !errors.Is(err, s3err.GetAPIError(s3err.ErrOwnershipControlsNotFound)) { + return &Response{ + MetaOpts: &MetaOptions{ + BucketOwner: parsedAcl.Owner, + }, + }, err + } + if ownership == types.ObjectOwnershipBucketOwnerEnforced { + debuglogger.Logf("bucket acls are disabled") + return &Response{ + MetaOpts: &MetaOptions{ + BucketOwner: parsedAcl.Owner, + }, + }, s3err.GetAPIError(s3err.ErrAclNotSupported) + } + + if len(ctx.BodyRaw()) > 0 { + var accessControlPolicy auth.AccessControlPolicy + err := xml.Unmarshal(ctx.BodyRaw(), &accessControlPolicy) + if err != nil { + debuglogger.Logf("error unmarshalling access control policy: %v", err) + return &Response{ + MetaOpts: &MetaOptions{ + BucketOwner: parsedAcl.Owner, + }, + }, s3err.GetAPIError(s3err.ErrMalformedACL) + } + + err = accessControlPolicy.Validate() + if err != nil { + debuglogger.Logf("invalid access control policy: %v", err) + return &Response{ + MetaOpts: &MetaOptions{ + BucketOwner: parsedAcl.Owner, + }, + }, err + } + + if *accessControlPolicy.Owner.ID != parsedAcl.Owner { + debuglogger.Logf("invalid access control policy owner id: %v, expected %v", *accessControlPolicy.Owner.ID, parsedAcl.Owner) + return &Response{ + MetaOpts: &MetaOptions{ + BucketOwner: parsedAcl.Owner, + }, + }, s3err.APIError{ + Code: "InvalidArgument", + Description: "Invalid id", + HTTPStatusCode: http.StatusBadRequest, + } + } + + if grants+string(acl) != "" { + debuglogger.Logf("invalid request: %q (grants) %q (acl)", + grants, acl) + return &Response{ + MetaOpts: &MetaOptions{ + BucketOwner: parsedAcl.Owner, + }, + }, s3err.GetAPIError(s3err.ErrUnexpectedContent) + } + + input = &auth.PutBucketAclInput{ + Bucket: &bucket, + AccessControlPolicy: &accessControlPolicy, + } + } else if acl != "" { + if grants != "" { + debuglogger.Logf("invalid request: %q (grants) %q (acl)", + grants, acl) + return &Response{ + MetaOpts: &MetaOptions{ + BucketOwner: parsedAcl.Owner, + }, + }, s3err.GetAPIError(s3err.ErrBothCannedAndHeaderGrants) + } + + input = &auth.PutBucketAclInput{ + Bucket: &bucket, + ACL: types.BucketCannedACL(acl), + } + } else if grants != "" { + input = &auth.PutBucketAclInput{ + Bucket: &bucket, + GrantFullControl: &grantFullControl, + GrantRead: &grantRead, + GrantReadACP: &grantReadACP, + GrantWrite: &grantWrite, + GrantWriteACP: &grantWriteACP, + } + } else { + debuglogger.Logf("none of the bucket acl options has been specified: canned, req headers, req body") + return &Response{ + MetaOpts: &MetaOptions{ + BucketOwner: parsedAcl.Owner, + }, + }, s3err.GetAPIError(s3err.ErrMissingSecurityHeader) + } + + updAcl, err := auth.UpdateACL(input, parsedAcl, c.iam) + if err != nil { + return &Response{ + MetaOpts: &MetaOptions{ + BucketOwner: parsedAcl.Owner, + }, + }, err + } + + err = c.be.PutBucketAcl(ctx.RequestCtx(), bucket, updAcl) + return &Response{ + MetaOpts: &MetaOptions{ + BucketOwner: parsedAcl.Owner, + }, + }, err +} + +func (c S3ApiController) CreateBucket(ctx fiber.Ctx) (*Response, error) { + bucket := ctx.Params("bucket") + acl := types.BucketCannedACL(c.getAclHeaderValue(ctx, "X-Amz-Acl")) + grantFullControl := c.getAclHeaderValue(ctx, "X-Amz-Grant-Full-Control") + grantRead := c.getAclHeaderValue(ctx, "X-Amz-Grant-Read") + grantReadACP := c.getAclHeaderValue(ctx, "X-Amz-Grant-Read-Acp") + grantWrite := c.getAclHeaderValue(ctx, "X-Amz-Grant-Write") + grantWriteACP := c.getAclHeaderValue(ctx, "X-Amz-Grant-Write-Acp") + lockEnabled := strings.EqualFold(ctx.Get("X-Amz-Bucket-Object-Lock-Enabled"), "true") + grants := grantFullControl + grantRead + grantReadACP + grantWrite + grantWriteACP + objectOwnership := types.ObjectOwnership(ctx.Get("X-Amz-Object-Ownership")) + + if c.readonly { + return &Response{ + MetaOpts: &MetaOptions{}, + }, s3err.GetAPIError(s3err.ErrAccessDenied) + } + + creator := utils.ContextKeyAccount.Get(ctx).(auth.Account) + // A backend that fixes bucket ownership picks the owner itself; otherwise + // it is whoever the admin API named, defaulting to the creator. The + // context is set either way: the storage backend reads the owner back out + // of it to chown the new bucket. + bucketOwner, fixedOwner := auth.ResolveFixedBucketOwner(c.iam) + if !fixedOwner { + bucketOwner = creator + if utils.ContextKeyBucketOwner.IsSet(ctx) { + bucketOwner = utils.ContextKeyBucketOwner.Get(ctx).(auth.Account) + } + } + utils.ContextKeyBucketOwner.Set(ctx, bucketOwner) + isRoot, _ := utils.ContextKeyIsRoot.Get(ctx).(bool) + + if err := auth.VerifyCreateBucketAccess(ctx, c.iam, isRoot, creator, bucket); err != nil { + return &Response{ + MetaOpts: &MetaOptions{}, + }, err + } + + // validate the bucket name + if ok := utils.IsValidBucketName(bucket); !ok { + return &Response{ + MetaOpts: &MetaOptions{ + BucketOwner: bucketOwner.Access, + }, + }, s3err.GetBucketErr(s3err.ErrInvalidBucketName, bucket) + } + + // both bucket canned ACL and acl grants is not allowed + if acl != "" && grants != "" { + debuglogger.Logf("invalid request: %q (grants) %q (acl)", grants, acl) + return &Response{ + MetaOpts: &MetaOptions{ + BucketOwner: bucketOwner.Access, + }, + }, s3err.GetAPIError(s3err.ErrBothCannedAndHeaderGrants) + } + + // validate bucket canned acl + err := auth.ValidateCannedACL(acl) + if err != nil { + return &Response{ + MetaOpts: &MetaOptions{ + BucketOwner: bucketOwner.Access, + }, + }, err + } + + // if bucket acl is 'private', object ownership should default to 'BucketOwnerPreferred' + if acl == types.BucketCannedACLPrivate && objectOwnership == "" { + objectOwnership = types.ObjectOwnershipBucketOwnerPreferred + } + + // if object ownership is so far empty, it should default to BucketOwnerEnforced + if objectOwnership == "" { + objectOwnership = types.ObjectOwnershipBucketOwnerEnforced + } + + // validate the object ownership value + if ok := utils.IsValidOwnership(objectOwnership); !ok { + return &Response{ + MetaOpts: &MetaOptions{ + BucketOwner: bucketOwner.Access, + }, + }, s3err.GetInvalidArgObjectOwnership(string(objectOwnership)) + } + + // any bucket ACL(canned, grants) is not allowed with object ownership 'BucketOwnerEnforced' + // but there's an exception for 'private' bucket canned ACL, + // which is the effective default for all buckets. In this case + // the ACL(private canned) is allowed with 'BucketOwnerEnforced' + if acl != types.BucketCannedACLPrivate && string(acl)+grants != "" && objectOwnership == types.ObjectOwnershipBucketOwnerEnforced { + debuglogger.Logf("bucket acls are disabled for %v object ownership", objectOwnership) + return &Response{ + MetaOpts: &MetaOptions{ + BucketOwner: bucketOwner.Access, + }, + }, s3err.GetAPIError(s3err.ErrInvalidBucketAclWithObjectOwnership) + } + + var body s3response.CreateBucketConfiguration + if len(ctx.BodyRaw()) != 0 { + // request body is optional for CreateBucket + err := xml.Unmarshal(ctx.BodyRaw(), &body) + if err != nil { + debuglogger.Logf("failed to parse the request body: %v", err) + return &Response{ + MetaOpts: &MetaOptions{ + BucketOwner: bucketOwner.Access, + }, + }, s3err.GetAPIError(s3err.ErrMalformedXML) + } + + if body.LocationConstraint != nil { + region := utils.ContextKeyRegion.Get(ctx).(string) + if *body.LocationConstraint != region || *body.LocationConstraint == "us-east-1" { + debuglogger.Logf("invalid location constraint: %s", *body.LocationConstraint) + return &Response{ + MetaOpts: &MetaOptions{ + BucketOwner: bucketOwner.Access, + }, + }, s3err.GetInvalidLocationConstraintErr(*body.LocationConstraint) + } + } + } + + defACL := auth.ACL{ + Owner: bucketOwner.Access, + } + + updAcl, err := auth.UpdateACL(&auth.PutBucketAclInput{ + GrantFullControl: &grantFullControl, + GrantRead: &grantRead, + GrantReadACP: &grantReadACP, + GrantWrite: &grantWrite, + GrantWriteACP: &grantWriteACP, + AccessControlPolicy: &auth.AccessControlPolicy{ + Owner: &types.Owner{ + ID: &bucketOwner.Access, + }}, + ACL: types.BucketCannedACL(acl), + }, defACL, c.iam) + if err != nil { + debuglogger.Logf("failed to update bucket acl: %v", err) + return &Response{ + MetaOpts: &MetaOptions{ + BucketOwner: bucketOwner.Access, + }, + }, err + } + + err = c.be.CreateBucket(ctx.RequestCtx(), &s3.CreateBucketInput{ + Bucket: &bucket, + ObjectOwnership: objectOwnership, + ObjectLockEnabledForBucket: &lockEnabled, + CreateBucketConfiguration: &types.CreateBucketConfiguration{ + Tags: body.TagSet, + }, + }, updAcl) + if err != nil { + return &Response{ + MetaOpts: &MetaOptions{ + BucketOwner: bucketOwner.Access, + }, + }, err + } + + return &Response{ + MetaOpts: &MetaOptions{ + BucketOwner: bucketOwner.Access, + }, + Headers: map[string]*string{ + "Location": utils.GetStringPtr("/" + bucket), + "x-amz-bucket-arn": utils.GetStringPtr(auth.ResourceArnPrefix + bucket), + }, + }, nil +} diff --git a/s3api/controllers/bucket-put_test.go b/s3api/controllers/bucket-put_test.go new file mode 100644 index 0000000..b404588 --- /dev/null +++ b/s3api/controllers/bucket-put_test.go @@ -0,0 +1,1278 @@ +// 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 controllers + +import ( + "context" + "encoding/xml" + "errors" + "fmt" + "net/http" + "testing" + + "github.com/aws/aws-sdk-go-v2/service/s3" + "github.com/aws/aws-sdk-go-v2/service/s3/types" + "github.com/stretchr/testify/assert" + "github.com/versity/versitygw/auth" + "github.com/versity/versitygw/s3api/utils" + "github.com/versity/versitygw/s3err" + "github.com/versity/versitygw/s3response" +) + +func TestS3ApiController_PutBucketTagging(t *testing.T) { + validTaggingBody, err := xml.Marshal(s3response.Tagging{ + TagSet: s3response.TagSet{ + Tags: []s3response.Tag{ + { + Key: "key", + Value: "val", + }, + }, + }, + }) + assert.NoError(t, err) + + tests := []struct { + name string + input testInput + output testOutput + }{ + { + name: "verify access fails", + input: testInput{ + locals: accessDeniedLocals, + }, + output: testOutput{ + response: &Response{ + MetaOpts: &MetaOptions{ + BucketOwner: "root", + }, + }, + err: s3err.GetAPIError(s3err.ErrAccessDenied), + }, + }, + { + name: "invalid request body", + input: testInput{ + locals: defaultLocals, + body: []byte("invalid_body"), + }, + output: testOutput{ + response: &Response{ + MetaOpts: &MetaOptions{ + BucketOwner: "root", + }, + }, + err: s3err.GetAPIError(s3err.ErrMalformedXML), + }, + }, + { + name: "backend returns error", + input: testInput{ + locals: defaultLocals, + beErr: s3err.GetAPIError(s3err.ErrNoSuchBucket), + body: validTaggingBody, + }, + output: testOutput{ + response: &Response{ + MetaOpts: &MetaOptions{ + BucketOwner: "root", + Status: http.StatusNoContent, + }, + }, + err: s3err.GetAPIError(s3err.ErrNoSuchBucket), + }, + }, + { + name: "successful response", + input: testInput{ + locals: defaultLocals, + body: validTaggingBody, + }, + output: testOutput{ + response: &Response{ + MetaOpts: &MetaOptions{ + BucketOwner: "root", + Status: http.StatusNoContent, + }, + }, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + be := &BackendMock{ + PutBucketTaggingFunc: func(contextMoqParam context.Context, bucket string, tags map[string]string) error { + return tt.input.beErr + }, + GetBucketPolicyFunc: func(contextMoqParam context.Context, bucket string) ([]byte, error) { + return nil, s3err.GetAPIError(s3err.ErrAccessDenied) + }, + } + + ctrl := S3ApiController{ + be: be, + } + + testController( + t, + ctrl.PutBucketTagging, + tt.output.response, + tt.output.err, + ctxInputs{ + locals: tt.input.locals, + body: tt.input.body, + }) + }) + } +} + +func TestS3ApiController_PutBucketOwnershipControls(t *testing.T) { + validOwnershipBody, err := xml.Marshal( + s3response.OwnershipControls{ + Rules: []types.OwnershipControlsRule{ + {ObjectOwnership: types.ObjectOwnershipBucketOwnerEnforced}, + }, + }) + assert.NoError(t, err) + + invalidRuleCountBody, err := xml.Marshal( + s3response.OwnershipControls{ + Rules: []types.OwnershipControlsRule{ + {ObjectOwnership: types.ObjectOwnershipBucketOwnerEnforced}, + {ObjectOwnership: types.ObjectOwnershipBucketOwnerPreferred}, + }, + }, + ) + assert.NoError(t, err) + + emptyRulesBody, err := xml.Marshal( + s3response.OwnershipControls{}, + ) + assert.NoError(t, err) + + tests := []struct { + name string + input testInput + output testOutput + }{ + { + name: "verify access fails", + input: testInput{ + locals: accessDeniedLocals, + }, + output: testOutput{ + response: &Response{ + MetaOpts: &MetaOptions{ + BucketOwner: "root", + }, + }, + err: s3err.GetAPIError(s3err.ErrAccessDenied), + }, + }, + { + name: "invalid request body", + input: testInput{ + locals: defaultLocals, + body: []byte("invalid_body"), + }, + output: testOutput{ + response: &Response{ + MetaOpts: &MetaOptions{BucketOwner: "root"}, + }, + err: s3err.GetAPIError(s3err.ErrMalformedXML), + }, + }, + { + name: "invalid rules count", + input: testInput{ + locals: defaultLocals, + body: invalidRuleCountBody, + }, + output: testOutput{ + response: &Response{ + MetaOpts: &MetaOptions{BucketOwner: "root"}, + }, + err: s3err.GetAPIError(s3err.ErrMalformedXML), + }, + }, + { + name: "empty rules count", + input: testInput{ + locals: defaultLocals, + body: emptyRulesBody, + }, + output: testOutput{ + response: &Response{ + MetaOpts: &MetaOptions{BucketOwner: "root"}, + }, + err: s3err.GetAPIError(s3err.ErrMalformedXML), + }, + }, + { + name: "backend error", + input: testInput{ + locals: defaultLocals, + body: validOwnershipBody, + beErr: s3err.GetAPIError(s3err.ErrNoSuchBucket), + }, + output: testOutput{ + response: &Response{ + MetaOpts: &MetaOptions{BucketOwner: "root"}, + }, + err: s3err.GetAPIError(s3err.ErrNoSuchBucket), + }, + }, + { + name: "success", + input: testInput{ + locals: defaultLocals, + body: validOwnershipBody, + }, + output: testOutput{ + response: &Response{ + MetaOpts: &MetaOptions{ + BucketOwner: "root", + }, + }, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + be := &BackendMock{ + PutBucketOwnershipControlsFunc: func(contextMoqParam context.Context, bucket string, ownership types.ObjectOwnership) error { + return tt.input.beErr + }, + GetBucketPolicyFunc: func(contextMoqParam context.Context, bucket string) ([]byte, error) { + return nil, s3err.GetAPIError(s3err.ErrAccessDenied) + }, + } + + ctrl := S3ApiController{ + be: be, + } + + testController(t, ctrl.PutBucketOwnershipControls, tt.output.response, tt.output.err, ctxInputs{ + locals: tt.input.locals, + body: tt.input.body, + }) + }) + } +} + +func TestS3ApiController_PutBucketVersioning(t *testing.T) { + validVersioningBody, err := xml.Marshal( + types.VersioningConfiguration{ + Status: types.BucketVersioningStatusEnabled, + }, + ) + assert.NoError(t, err) + + invalidVersioningStatusBody, err := xml.Marshal( + types.VersioningConfiguration{ + Status: types.BucketVersioningStatus("invalid_status"), + }, + ) + assert.NoError(t, err) + + tests := []struct { + name string + input testInput + output testOutput + }{ + { + name: "verify access fails", + input: testInput{ + locals: accessDeniedLocals, + }, + output: testOutput{ + response: &Response{ + MetaOpts: &MetaOptions{ + BucketOwner: "root", + }, + }, + err: s3err.GetAPIError(s3err.ErrAccessDenied), + }, + }, + { + name: "invalid request body", + input: testInput{ + locals: defaultLocals, + body: []byte("invalid_body"), + }, + output: testOutput{ + response: &Response{ + MetaOpts: &MetaOptions{BucketOwner: "root"}, + }, + err: s3err.GetAPIError(s3err.ErrInvalidRequest), + }, + }, + { + name: "invalid rules count", + input: testInput{ + locals: defaultLocals, + body: invalidVersioningStatusBody, + }, + output: testOutput{ + response: &Response{ + MetaOpts: &MetaOptions{BucketOwner: "root"}, + }, + err: s3err.GetAPIError(s3err.ErrMalformedXML), + }, + }, + { + name: "backend error", + input: testInput{ + locals: defaultLocals, + body: validVersioningBody, + beErr: s3err.GetAPIError(s3err.ErrNoSuchBucket), + }, + output: testOutput{ + response: &Response{ + MetaOpts: &MetaOptions{BucketOwner: "root"}, + }, + err: s3err.GetAPIError(s3err.ErrNoSuchBucket), + }, + }, + { + name: "success", + input: testInput{ + locals: defaultLocals, + body: validVersioningBody, + }, + output: testOutput{ + response: &Response{ + MetaOpts: &MetaOptions{ + BucketOwner: "root", + }, + }, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + be := &BackendMock{ + PutBucketVersioningFunc: func(contextMoqParam context.Context, bucket string, status types.BucketVersioningStatus) error { + return tt.input.beErr + }, + GetBucketPolicyFunc: func(contextMoqParam context.Context, bucket string) ([]byte, error) { + return nil, s3err.GetAPIError(s3err.ErrAccessDenied) + }, + } + + ctrl := S3ApiController{ + be: be, + } + + testController(t, ctrl.PutBucketVersioning, tt.output.response, tt.output.err, ctxInputs{ + locals: tt.input.locals, + body: tt.input.body, + }) + }) + } +} + +func TestS3ApiController_PutObjectLockConfiguration(t *testing.T) { + validLockBody, err := xml.Marshal( + types.ObjectLockConfiguration{ + ObjectLockEnabled: types.ObjectLockEnabledEnabled, + }, + ) + assert.NoError(t, err) + + tests := []struct { + name string + input testInput + output testOutput + }{ + { + name: "verify access fails", + input: testInput{ + locals: accessDeniedLocals, + }, + output: testOutput{ + response: &Response{ + MetaOpts: &MetaOptions{ + BucketOwner: "root", + }, + }, + err: s3err.GetAPIError(s3err.ErrAccessDenied), + }, + }, + { + name: "invalid request body", + input: testInput{ + locals: defaultLocals, + body: []byte("invalid_body"), + }, + output: testOutput{ + response: &Response{ + MetaOpts: &MetaOptions{BucketOwner: "root"}, + }, + err: s3err.GetAPIError(s3err.ErrMalformedXML), + }, + }, + { + name: "backend error", + input: testInput{ + locals: defaultLocals, + body: validLockBody, + beErr: s3err.GetAPIError(s3err.ErrNoSuchBucket), + }, + output: testOutput{ + response: &Response{ + MetaOpts: &MetaOptions{BucketOwner: "root"}, + }, + err: s3err.GetAPIError(s3err.ErrNoSuchBucket), + }, + }, + { + name: "success", + input: testInput{ + locals: defaultLocals, + body: validLockBody, + }, + output: testOutput{ + response: &Response{ + MetaOpts: &MetaOptions{ + BucketOwner: "root", + }, + }, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + be := &BackendMock{ + PutObjectLockConfigurationFunc: func(contextMoqParam context.Context, bucket string, config []byte) error { + return tt.input.beErr + }, + GetBucketPolicyFunc: func(contextMoqParam context.Context, bucket string) ([]byte, error) { + return nil, s3err.GetAPIError(s3err.ErrAccessDenied) + }, + } + + ctrl := S3ApiController{ + be: be, + } + + testController(t, ctrl.PutObjectLockConfiguration, tt.output.response, tt.output.err, ctxInputs{ + locals: tt.input.locals, + body: tt.input.body, + }) + }) + } +} + +func TestS3ApiController_PutBucketCors(t *testing.T) { + validBody, err := xml.Marshal(auth.CORSConfiguration{ + Rules: []auth.CORSRule{ + { + AllowedOrigins: []auth.CORSOrigin{"*"}, + AllowedMethods: []auth.CORSHTTPMethod{http.MethodPost}, + }, + }, + }) + assert.NoError(t, err) + + invalidCors, err := xml.Marshal(auth.CORSConfiguration{ + Rules: []auth.CORSRule{ + { + AllowedOrigins: []auth.CORSOrigin{"origin"}, + AllowedMethods: []auth.CORSHTTPMethod{"invalid_method"}, + }, + }, + }) + assert.NoError(t, err) + + tests := []struct { + name string + input testInput + output testOutput + }{ + { + name: "verify access fails", + input: testInput{ + locals: accessDeniedLocals, + }, + output: testOutput{ + response: &Response{ + MetaOpts: &MetaOptions{ + BucketOwner: "root", + }, + }, + err: s3err.GetAPIError(s3err.ErrAccessDenied), + }, + }, + { + name: "invalid request body", + input: testInput{ + locals: defaultLocals, + body: []byte("invalid_body"), + }, + output: testOutput{ + response: &Response{ + MetaOpts: &MetaOptions{BucketOwner: "root"}, + }, + err: s3err.GetAPIError(s3err.ErrMalformedXML), + }, + }, + { + name: "invalid cors config", + input: testInput{ + locals: defaultLocals, + body: invalidCors, + }, + output: testOutput{ + response: &Response{ + MetaOpts: &MetaOptions{BucketOwner: "root"}, + }, + err: s3err.GetUnsopportedCORSMethodErr("invalid_method"), + }, + }, + { + name: "backend error", + input: testInput{ + locals: defaultLocals, + beErr: s3err.GetAPIError(s3err.ErrNotImplemented), + body: validBody, + }, + output: testOutput{ + response: &Response{ + MetaOpts: &MetaOptions{BucketOwner: "root"}, + }, + err: s3err.GetAPIError(s3err.ErrNotImplemented), + }, + }, + { + name: "success", + input: testInput{ + locals: defaultLocals, + body: validBody, + }, + output: testOutput{ + response: &Response{ + MetaOpts: &MetaOptions{ + BucketOwner: "root", + }, + }, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + be := &BackendMock{ + PutBucketCorsFunc: func(contextMoqParam context.Context, bucket string, cors []byte) error { + return tt.input.beErr + }, + GetBucketPolicyFunc: func(contextMoqParam context.Context, bucket string) ([]byte, error) { + return nil, s3err.GetAPIError(s3err.ErrAccessDenied) + }, + } + + ctrl := S3ApiController{ + be: be, + } + + testController(t, ctrl.PutBucketCors, tt.output.response, tt.output.err, ctxInputs{ + locals: tt.input.locals, + body: tt.input.body, + headers: tt.input.headers, + }) + }) + } +} + +func TestS3ApiController_PutBucketPolicy(t *testing.T) { + validPolicyDocument := + `{ + "Version": "2012-10-17", + "Statement": [ + { + "Sid": "PublicReadGetObject", + "Effect": "Allow", + "Principal": "*", + "Action": "s3:GetObject", + "Resource": "arn:aws:s3:::bucket/*" + } + ] + }` + tests := []struct { + name string + input testInput + output testOutput + }{ + { + name: "verify access fails", + input: testInput{ + locals: accessDeniedLocals, + }, + output: testOutput{ + response: &Response{ + MetaOpts: &MetaOptions{ + BucketOwner: "root", + }, + }, + err: s3err.GetAPIError(s3err.ErrAccessDenied), + }, + }, + { + name: "invalid policy document", + input: testInput{ + locals: defaultLocals, + body: []byte("invalid_body"), + }, + output: testOutput{ + response: &Response{ + MetaOpts: &MetaOptions{BucketOwner: "root"}, + }, + err: s3err.APIError{ + Code: "MalformedPolicy", + Description: "Policies must be valid JSON and the first byte must be '{'", + HTTPStatusCode: http.StatusBadRequest, + }, + }, + }, + { + name: "backend error", + input: testInput{ + locals: defaultLocals, + body: []byte(validPolicyDocument), + beErr: s3err.GetAPIError(s3err.ErrNoSuchBucket), + }, + output: testOutput{ + response: &Response{ + MetaOpts: &MetaOptions{ + BucketOwner: "root", + Status: http.StatusNoContent, + }, + }, + err: s3err.GetAPIError(s3err.ErrNoSuchBucket), + }, + }, + { + name: "success", + input: testInput{ + locals: defaultLocals, + body: []byte(validPolicyDocument), + }, + output: testOutput{ + response: &Response{ + MetaOpts: &MetaOptions{ + BucketOwner: "root", + Status: http.StatusNoContent, + }, + }, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + be := &BackendMock{ + PutBucketPolicyFunc: func(contextMoqParam context.Context, bucket string, policy []byte) error { + return tt.input.beErr + }, + GetBucketPolicyFunc: func(contextMoqParam context.Context, bucket string) ([]byte, error) { + return nil, s3err.GetAPIError(s3err.ErrAccessDenied) + }, + } + + ctrl := S3ApiController{ + be: be, + } + + testController(t, ctrl.PutBucketPolicy, tt.output.response, tt.output.err, ctxInputs{ + locals: tt.input.locals, + body: tt.input.body, + }) + }) + } +} + +func TestS3ApiController_CreateBucket(t *testing.T) { + adminAcc := auth.Account{ + Access: "root", + Role: auth.RoleAdmin, + } + userAcc := auth.Account{ + Access: "user", + Role: auth.RoleUser, + } + userPlusAcc := auth.Account{ + Access: "userplus", + Role: auth.RoleUserPlus, + } + + invLocConstBody, err := xml.Marshal(s3response.CreateBucketConfiguration{ + LocationConstraint: utils.GetStringPtr("us-west-1"), + }) + assert.NoError(t, err) + + tests := []struct { + name string + input testInput + output testOutput + }{ + { + name: "access denied", + input: testInput{ + locals: map[utils.ContextKey]any{ + utils.ContextKeyAccount: userAcc, + utils.ContextKeyIsRoot: false, + }, + }, + output: testOutput{ + response: &Response{ + MetaOpts: &MetaOptions{}, + }, + err: s3err.GetAPIError(s3err.ErrAccessDenied), + }, + }, + { + name: "invalid bucket name", + input: testInput{ + locals: map[utils.ContextKey]any{ + utils.ContextKeyAccount: adminAcc, + }, + bucket: "invalid_bucket_name", + }, + output: testOutput{ + response: &Response{ + MetaOpts: &MetaOptions{ + BucketOwner: adminAcc.Access, + }, + }, + err: s3err.GetBucketErr(s3err.ErrInvalidBucketName, "invalid_bucket_name"), + }, + }, + { + name: "malformed body", + input: testInput{ + locals: map[utils.ContextKey]any{ + utils.ContextKeyAccount: adminAcc, + }, + body: []byte("invalid_body"), + }, + output: testOutput{ + response: &Response{ + MetaOpts: &MetaOptions{BucketOwner: adminAcc.Access}, + }, + err: s3err.GetAPIError(s3err.ErrMalformedXML), + }, + }, + + { + name: "invalid canned acl", + input: testInput{ + locals: map[utils.ContextKey]any{ + utils.ContextKeyAccount: adminAcc, + }, + headers: map[string]string{ + "x-amz-acl": "invalid_acl", + }, + }, + output: testOutput{ + response: &Response{ + MetaOpts: &MetaOptions{BucketOwner: adminAcc.Access}, + }, + err: s3err.GetInvalidArgumentErr(s3err.InvalidArgCannedAcl, "invalid_acl"), + }, + }, + { + name: "invalid location constraint", + input: testInput{ + locals: map[utils.ContextKey]any{ + utils.ContextKeyAccount: adminAcc, + utils.ContextKeyRegion: "us-east-1", + }, + body: invLocConstBody, + }, + output: testOutput{ + response: &Response{ + MetaOpts: &MetaOptions{BucketOwner: adminAcc.Access}, + }, + err: s3err.GetInvalidLocationConstraintErr("us-west-1"), + }, + }, + { + name: "invalid ownership", + input: testInput{ + locals: map[utils.ContextKey]any{ + utils.ContextKeyAccount: adminAcc, + }, + headers: map[string]string{ + "X-Amz-Object-Ownership": "invalid_ownership", + }, + }, + output: testOutput{ + response: &Response{ + MetaOpts: &MetaOptions{ + BucketOwner: adminAcc.Access, + }, + }, + err: s3err.GetInvalidArgObjectOwnership("invalid_ownership"), + }, + }, + { + name: "invalid ownership + acl", + input: testInput{ + locals: map[utils.ContextKey]any{ + utils.ContextKeyAccount: adminAcc, + }, + headers: map[string]string{ + "X-Amz-Object-Ownership": string(types.ObjectOwnershipBucketOwnerEnforced), + "X-Amz-Acl": string(types.BucketCannedACLPublicRead), + }, + }, + output: testOutput{ + response: &Response{ + MetaOpts: &MetaOptions{BucketOwner: adminAcc.Access}, + }, + err: s3err.GetAPIError(s3err.ErrInvalidBucketAclWithObjectOwnership), + }, + }, + { + name: "both grants and canned acl", + input: testInput{ + locals: map[utils.ContextKey]any{ + utils.ContextKeyAccount: adminAcc, + }, + headers: map[string]string{ + "X-Amz-Acl": string(types.BucketCannedACLPublicRead), + "X-Amz-Grant-Read": userAcc.Access, + "X-Amz-Object-Ownership": string(types.ObjectOwnershipBucketOwnerPreferred), + }, + }, + output: testOutput{ + response: &Response{ + MetaOpts: &MetaOptions{BucketOwner: adminAcc.Access}, + }, + err: s3err.GetAPIError(s3err.ErrBothCannedAndHeaderGrants), + }, + }, + { + name: "fail to update the acl", + input: testInput{ + locals: map[utils.ContextKey]any{ + utils.ContextKeyAccount: adminAcc, + }, + headers: map[string]string{ + "X-Amz-Grant-Read": userAcc.Access, + "X-Amz-Object-Ownership": string(types.ObjectOwnershipBucketOwnerPreferred), + }, + }, + output: testOutput{ + response: &Response{ + MetaOpts: &MetaOptions{BucketOwner: adminAcc.Access}, + }, + err: fmt.Errorf("accounts does not exist: %s", userAcc.Access), + }, + }, + { + name: "backend error", + input: testInput{ + locals: map[utils.ContextKey]any{ + utils.ContextKeyAccount: adminAcc, + }, + beErr: s3err.GetAPIError(s3err.ErrNoSuchBucket), + }, + output: testOutput{ + response: &Response{ + MetaOpts: &MetaOptions{BucketOwner: adminAcc.Access}, + }, + err: s3err.GetAPIError(s3err.ErrNoSuchBucket), + }, + }, + { + name: "success", + input: testInput{ + locals: map[utils.ContextKey]any{ + utils.ContextKeyAccount: adminAcc, + }, + bucket: "my-bucket", + }, + output: testOutput{ + response: &Response{ + MetaOpts: &MetaOptions{ + BucketOwner: adminAcc.Access, + }, + Headers: map[string]*string{ + "Location": utils.GetStringPtr("/my-bucket"), + "x-amz-bucket-arn": utils.GetStringPtr("arn:aws:s3:::my-bucket"), + }, + }, + }, + }, + { + name: "userplus role can create bucket", + input: testInput{ + locals: map[utils.ContextKey]any{ + utils.ContextKeyAccount: userPlusAcc, + }, + bucket: "my-bucket", + }, + output: testOutput{ + response: &Response{ + MetaOpts: &MetaOptions{ + BucketOwner: userPlusAcc.Access, + }, + Headers: map[string]*string{ + "Location": utils.GetStringPtr("/my-bucket"), + "x-amz-bucket-arn": utils.GetStringPtr("arn:aws:s3:::my-bucket"), + }, + }, + }, + }, + { + name: "root bypasses role check", + input: testInput{ + locals: map[utils.ContextKey]any{ + utils.ContextKeyAccount: userAcc, + utils.ContextKeyIsRoot: true, + }, + bucket: "my-bucket", + }, + output: testOutput{ + response: &Response{ + MetaOpts: &MetaOptions{ + BucketOwner: userAcc.Access, + }, + Headers: map[string]*string{ + "Location": utils.GetStringPtr("/my-bucket"), + "x-amz-bucket-arn": utils.GetStringPtr("arn:aws:s3:::my-bucket"), + }, + }, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + be := &BackendMock{ + CreateBucketFunc: func(contextMoqParam context.Context, createBucketInput *s3.CreateBucketInput, defaultACL []byte) error { + return tt.input.beErr + }, + } + + ctrl := S3ApiController{ + be: be, + iam: auth.NewIAMServiceSingle(adminAcc), + } + + testController(t, ctrl.CreateBucket, tt.output.response, tt.output.err, + ctxInputs{ + locals: tt.input.locals, + body: tt.input.body, + bucket: tt.input.bucket, + headers: tt.input.headers, + }) + }) + } +} + +func TestS3ApiController_PutBucketAcl(t *testing.T) { + invalidBody, err := xml.Marshal(auth.AccessControlPolicy{ + Owner: &types.Owner{ + ID: utils.GetStringPtr("root"), + }, + AccessControlList: auth.AccessControlList{ + Grants: []auth.Grant{ + { + Permission: auth.Permission("invalid_permission"), + }, + }, + }, + }) + assert.NoError(t, err) + + incorrectOwnerBody, err := xml.Marshal(auth.AccessControlPolicy{ + Owner: &types.Owner{ + ID: utils.GetStringPtr("user"), + }, + AccessControlList: auth.AccessControlList{}, + }) + assert.NoError(t, err) + + validAccessControlPolicy, err := xml.Marshal(auth.AccessControlPolicy{ + Owner: &types.Owner{ + ID: utils.GetStringPtr("root"), + }, + AccessControlList: auth.AccessControlList{}, + }) + assert.NoError(t, err) + + tests := []struct { + name string + input testInput + output testOutput + }{ + { + name: "access denied", + input: testInput{ + locals: accessDeniedLocals, + }, + output: testOutput{ + response: &Response{ + MetaOpts: &MetaOptions{ + BucketOwner: "root", + }, + }, + err: s3err.GetAPIError(s3err.ErrAccessDenied), + }, + }, + { + name: "fails to get bucket ownership", + input: testInput{ + locals: defaultLocals, + extraMockErr: s3err.GetAPIError(s3err.ErrInternalError), + extraMockResp: types.ObjectOwnership(""), + }, + output: testOutput{ + response: &Response{ + MetaOpts: &MetaOptions{ + BucketOwner: "root", + }, + }, + err: s3err.GetAPIError(s3err.ErrInternalError), + }, + }, + { + name: "acl not supported", + input: testInput{ + locals: defaultLocals, + extraMockResp: types.ObjectOwnershipBucketOwnerEnforced, + }, + output: testOutput{ + response: &Response{ + MetaOpts: &MetaOptions{ + BucketOwner: "root", + }, + }, + err: s3err.GetAPIError(s3err.ErrAclNotSupported), + }, + }, + { + name: "invalid request body", + input: testInput{ + locals: defaultLocals, + extraMockResp: types.ObjectOwnershipBucketOwnerPreferred, + body: []byte("invalid_body"), + }, + output: testOutput{ + response: &Response{ + MetaOpts: &MetaOptions{ + BucketOwner: "root", + }, + }, + err: s3err.GetAPIError(s3err.ErrMalformedACL), + }, + }, + { + name: "invalid access control policy", + input: testInput{ + locals: defaultLocals, + extraMockResp: types.ObjectOwnershipBucketOwnerPreferred, + body: invalidBody, + }, + output: testOutput{ + response: &Response{ + MetaOpts: &MetaOptions{ + BucketOwner: "root", + }, + }, + err: s3err.GetAPIError(s3err.ErrMalformedACL), + }, + }, + { + name: "incorrect owner id", + input: testInput{ + locals: defaultLocals, + extraMockResp: types.ObjectOwnershipBucketOwnerPreferred, + body: incorrectOwnerBody, + }, + output: testOutput{ + response: &Response{ + MetaOpts: &MetaOptions{ + BucketOwner: "root", + }, + }, + err: s3err.APIError{ + Code: "InvalidArgument", + Description: "Invalid id", + HTTPStatusCode: http.StatusBadRequest, + }, + }, + }, + { + name: "both access control policy and grants", + input: testInput{ + body: validAccessControlPolicy, + extraMockResp: types.ObjectOwnershipBucketOwnerPreferred, + headers: map[string]string{ + "X-Amz-Acl": "public-read", + }, + locals: defaultLocals, + }, + output: testOutput{ + response: &Response{ + MetaOpts: &MetaOptions{ + BucketOwner: "root", + }, + }, + err: s3err.GetAPIError(s3err.ErrUnexpectedContent), + }, + }, + { + name: "access control policy success", + input: testInput{ + body: validAccessControlPolicy, + extraMockResp: types.ObjectOwnershipBucketOwnerPreferred, + locals: defaultLocals, + }, + output: testOutput{ + response: &Response{ + MetaOpts: &MetaOptions{ + BucketOwner: "root", + }, + }, + }, + }, + { + name: "invalid canned acl", + input: testInput{ + extraMockResp: types.ObjectOwnershipBucketOwnerPreferred, + headers: map[string]string{ + "X-Amz-Acl": "invalid_acl", + }, + locals: defaultLocals, + }, + output: testOutput{ + response: &Response{ + MetaOpts: &MetaOptions{ + BucketOwner: "root", + }, + }, + err: s3err.GetInvalidArgumentErr(s3err.InvalidArgCannedAcl, "invalid_acl"), + }, + }, + { + name: "both canned acl and grants", + input: testInput{ + extraMockResp: types.ObjectOwnershipBucketOwnerPreferred, + headers: map[string]string{ + "X-Amz-Acl": "public-read", + "X-Amz-Grant-Read": "grt1", + }, + locals: defaultLocals, + }, + output: testOutput{ + response: &Response{ + MetaOpts: &MetaOptions{ + BucketOwner: "root", + }, + }, + err: s3err.GetAPIError(s3err.ErrBothCannedAndHeaderGrants), + }, + }, + { + name: "canned acl success", + input: testInput{ + extraMockResp: types.ObjectOwnershipBucketOwnerPreferred, + headers: map[string]string{ + "X-Amz-Acl": "public-read", + }, + locals: defaultLocals, + }, + output: testOutput{ + response: &Response{ + MetaOpts: &MetaOptions{ + BucketOwner: "root", + }, + }, + }, + }, + { + name: "grants update acl fails", + input: testInput{ + extraMockResp: types.ObjectOwnershipBucketOwnerPreferred, + headers: map[string]string{ + "X-Amz-Grant-Read": "grt1", + }, + locals: defaultLocals, + }, + output: testOutput{ + response: &Response{ + MetaOpts: &MetaOptions{ + BucketOwner: "root", + }, + }, + err: errors.New("accounts does not exist: grt1"), + }, + }, + { + name: "no option provided", + input: testInput{ + extraMockResp: types.ObjectOwnershipBucketOwnerPreferred, + locals: defaultLocals, + }, + output: testOutput{ + response: &Response{ + MetaOpts: &MetaOptions{ + BucketOwner: "root", + }, + }, + err: s3err.GetAPIError(s3err.ErrMissingSecurityHeader), + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + be := &BackendMock{ + PutBucketAclFunc: func(contextMoqParam context.Context, bucket string, data []byte) error { + return tt.input.beErr + }, + GetBucketOwnershipControlsFunc: func(contextMoqParam context.Context, bucket string) (types.ObjectOwnership, error) { + return tt.input.extraMockResp.(types.ObjectOwnership), tt.input.extraMockErr + }, + GetBucketPolicyFunc: func(contextMoqParam context.Context, bucket string) ([]byte, error) { + return nil, s3err.GetAPIError(s3err.ErrAccessDenied) + }, + } + + ctrl := S3ApiController{ + be: be, + iam: auth.NewIAMServiceSingle( + auth.Account{ + Access: "root", + }), + } + + testController(t, ctrl.PutBucketAcl, tt.output.response, tt.output.err, + ctxInputs{ + locals: tt.input.locals, + body: tt.input.body, + bucket: tt.input.bucket, + headers: tt.input.headers, + }) + }) + } +} diff --git a/s3err/s3err.go b/s3err/s3err.go new file mode 100644 index 0000000..5fe1413 --- /dev/null +++ b/s3err/s3err.go @@ -0,0 +1,1061 @@ +// 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 s3err + +import ( + "bytes" + "encoding/xml" + "fmt" + "html" + "net/http" + "strings" + + "github.com/aws/aws-sdk-go-v2/service/s3/types" +) + +// S3Error is the interface implemented by all S3 error types. +// It allows centralized error handling while supporting per-error-type XML fields. +type S3Error interface { + error + StatusCode() int + BaseError() APIError + XMLBody(requestID, hostID string) []byte + HTMLBody(requestID, hostID string) []byte +} + +// APIError structure +type APIError struct { + Code string + Description string + HTTPStatusCode int +} + +func (e APIError) BaseError() APIError { + return e +} + +func (A APIError) Error() string { + var bytesBuffer bytes.Buffer + bytesBuffer.WriteString(xml.Header) + e := xml.NewEncoder(&bytesBuffer) + _ = e.Encode(A) + return bytesBuffer.String() +} + +func (e APIError) StatusCode() int { return e.HTTPStatusCode } + +func (e APIError) XMLBody(requestID, hostID string) []byte { + return encodeResponse(struct { + XMLName xml.Name `xml:"Error"` + Code string + Message string + RequestID string `xml:"RequestId,omitempty"` + HostID string `xml:"HostId,omitempty"` + }{ + Code: e.Code, + Message: e.Description, + RequestID: requestID, + HostID: hostID, + }) +} + +func (e APIError) HTMLBody(requestID, hostID string) []byte { + return e.encodeHTMLResponse(requestID, hostID) +} + +// ErrorCode type of error status. +type ErrorCode int + +// Error codes, see full list at http://docs.aws.amazon.com/AmazonS3/latest/API/ErrorResponses.html +const ( + ErrNone ErrorCode = iota + ErrAccessDenied + ErrAnonymousRequest + ErrAnonymousCreateMp + ErrAnonymousCopyObject + ErrAnonymousPutBucketOwnership + ErrAnonymousGetBucketOwnership + ErrAnonymousResponseHeaders + ErrMethodNotAllowed + ErrBucketNotEmpty + ErrVersionedBucketNotEmpty + ErrBucketAlreadyExists + ErrBucketAlreadyOwnedByYou + ErrNoSuchBucket + ErrNoSuchKey + ErrNoSuchUpload + ErrInvalidBucketName + ErrInvalidDigest + ErrBadDigest + ErrInvalidPart + ErrInvalidPartNumberRange + ErrRangeAndPartNumber + ErrInvalidPartOrder + ErrInternalError + ErrNonEmptyRequestBody + ErrIncompleteBody + ErrInvalidCopyDest + ErrInvalidTagKey + ErrInvalidTagValue + ErrDuplicateTagKey + ErrBucketTaggingLimited + ErrObjectTaggingLimited + ErrCannotParseHTTPRequest + ErrMalformedPOSTRequest + ErrEntityTooSmall + ErrEntityTooLarge + ErrMalformedXML + ErrMalformedCredentialDate + ErrExpiredPresignRequest + ErrSignatureDoesNotMatch + ErrContentSHA256Mismatch + ErrInvalidSHA256PayloadUsage + ErrUnsupportedAnonymousSignedStreaming + ErrMissingContentLength + ErrContentLengthMismatch + ErrInvalidAccessKeyID + ErrInvalidToken + ErrRequestNotReadyYet + ErrMissingDateHeader + ErrGetUploadsWithKey + ErrVersionsWithKey + ErrInvalidRequest + ErrAuthNotSetup + ErrNotImplemented + ErrPreconditionFailed + ErrInvalidObjectState + ErrInvalidRange + ErrInvalidURI + ErrObjectLockConfigurationNotFound + ErrNoSuchObjectLockConfiguration + ErrMissingObjectLockConfiguration + ErrMissingObjectLockConfigurationNoSpaces + ErrObjectLockConfigurationNotAllowed + ErrObjectLocked + ErrNoSuchBucketPolicy + ErrBucketTaggingNotFound + ErrObjectAttributesInvalidHeader + ErrRequestTimeTooSkewed + ErrInvalidBucketAclWithObjectOwnership + ErrBothCannedAndHeaderGrants + ErrOwnershipControlsNotFound + ErrAclNotSupported + ErrMalformedACL + ErrUnexpectedContent + ErrMissingSecurityHeader + ErrKeyTooLong + ErrNoSuchVersion + ErrSuspendedVersioningNotAllowed + ErrMissingRequestBody + ErrMultipleChecksumHeaders + ErrChecksumSDKAlgoMismatch + ErrChecksumRequired + ErrMissingContentSha256 + ErrInvalidChecksumAlgorithm + ErrChecksumTypeWithAlgo + ErrInvalidChecksumHeader + ErrTrailerHeaderNotSupported + ErrBadRequest + ErrNoSuchCORSConfiguration + ErrCORSForbidden + ErrMissingCORSOrigin + ErrCORSIsNotEnabled + ErrNoSuchWebsiteConfiguration + ErrInvalidWebsiteRedirectProtocol + ErrInvalidRedirectLocation + ErrBothReplaceKeyAndPrefix + ErrMaxMessageLengthExceeded + ErrNotModified + ErrInvalidLocationConstraint + ErrMalformedTrailer + ErrInvalidChunkSize + ErrSlowDown + ErrMetadataTooLarge + ErrRequestHeaderSectionTooLarge + ErrUnsupportedAuthorizationMechanism + ErrNoBucketInRequest + + // Non-AWS errors + ErrExistingObjectIsDirectory + ErrObjectParentIsFile + ErrDirectoryObjectContainsData + ErrDirectoryNotEmpty + ErrQuotaExceeded + ErrVersioningNotConfigured + ErrACLsDisabled + ErrNoSpaceLeftOnDevice + + // Admin api errors + ErrAdminAccessDenied + ErrAdminUserNotFound + ErrAdminUserExists + ErrAdminInvalidUserRole + ErrAdminMissingUserAcess + ErrAdminMethodNotSupported + ErrAdminEmptyBucketOwnerHeader +) + +var errorCodeResponse = map[ErrorCode]APIError{ + ErrAccessDenied: { + Code: "AccessDenied", + Description: "Access Denied.", + HTTPStatusCode: http.StatusForbidden, + }, + ErrAnonymousRequest: { + Code: "AccessDenied", + Description: "Anonymous users cannot invoke this API. Please authenticate.", + HTTPStatusCode: http.StatusForbidden, + }, + ErrAnonymousCreateMp: { + Code: "AccessDenied", + Description: "Anonymous users cannot initiate multipart uploads. Please authenticate.", + HTTPStatusCode: http.StatusForbidden, + }, + ErrAnonymousCopyObject: { + Code: "AccessDenied", + Description: "Anonymous users cannot copy objects. Please authenticate.", + HTTPStatusCode: http.StatusForbidden, + }, + ErrAnonymousPutBucketOwnership: { + Code: "AccessDenied", + Description: "s3:PutBucketOwnershipControls does not support Anonymous requests!", + HTTPStatusCode: http.StatusForbidden, + }, + ErrAnonymousGetBucketOwnership: { + Code: "AccessDenied", + Description: "s3:GetBucketOwnershipControls does not support Anonymous requests!", + HTTPStatusCode: http.StatusForbidden, + }, + ErrAnonymousResponseHeaders: { + Code: "InvalidRequest", + Description: "Request specific response headers cannot be used for anonymous GET requests.", + HTTPStatusCode: http.StatusBadRequest, + }, + ErrMethodNotAllowed: { + Code: "MethodNotAllowed", + Description: "The specified method is not allowed against this resource.", + HTTPStatusCode: http.StatusMethodNotAllowed, + }, + ErrBucketNotEmpty: { + Code: "BucketNotEmpty", + Description: "The bucket you tried to delete is not empty.", + HTTPStatusCode: http.StatusConflict, + }, + ErrVersionedBucketNotEmpty: { + Code: "BucketNotEmpty", + Description: "The bucket you tried to delete is not empty. You must delete all versions in the bucket.", + HTTPStatusCode: http.StatusConflict, + }, + ErrBucketAlreadyExists: { + Code: "BucketAlreadyExists", + Description: "The requested bucket name is not available. The bucket name can not be an existing collection, and the bucket namespace is shared by all users of the system. Please select a different name and try again.", + HTTPStatusCode: http.StatusConflict, + }, + ErrBucketAlreadyOwnedByYou: { + Code: "BucketAlreadyOwnedByYou", + Description: "Your previous request to create the named bucket succeeded and you already own it.", + HTTPStatusCode: http.StatusConflict, + }, + ErrInvalidBucketName: { + Code: "InvalidBucketName", + Description: "The specified bucket is not valid.", + HTTPStatusCode: http.StatusBadRequest, + }, + ErrInvalidDigest: { + Code: "InvalidDigest", + Description: "The Content-Md5 you specified is not valid.", + HTTPStatusCode: http.StatusBadRequest, + }, + ErrBadDigest: { + Code: "BadDigest", + Description: "The Content-MD5 you specified did not match what we received.", + HTTPStatusCode: http.StatusBadRequest, + }, + ErrNoSuchBucket: { + Code: "NoSuchBucket", + Description: "The specified bucket does not exist.", + HTTPStatusCode: http.StatusNotFound, + }, + ErrNoSuchKey: { + Code: "NoSuchKey", + Description: "The specified key does not exist.", + HTTPStatusCode: http.StatusNotFound, + }, + ErrNoSuchUpload: { + Code: "NoSuchUpload", + Description: "The specified multipart upload does not exist. The upload ID may be invalid, or the upload may have been aborted or completed.", + HTTPStatusCode: http.StatusNotFound, + }, + ErrInternalError: { + Code: "InternalError", + Description: "We encountered an internal error, please try again.", + HTTPStatusCode: http.StatusInternalServerError, + }, + ErrNonEmptyRequestBody: { + Code: "InvalidRequest", + Description: "The request included a body. Requests of this type must not include a non-empty body.", + HTTPStatusCode: http.StatusBadRequest, + }, + ErrIncompleteBody: { + Code: "IncompleteBody", + Description: "The request body terminated unexpectedly", + HTTPStatusCode: http.StatusBadRequest, + }, + ErrInvalidPart: { + Code: "InvalidPart", + Description: "One or more of the specified parts could not be found. The part may not have been uploaded, or the specified entity tag may not match the part's entity tag.", + HTTPStatusCode: http.StatusBadRequest, + }, + ErrInvalidPartNumberRange: { + Code: "InvalidPartNumber", + Description: "The requested partnumber is not satisfiable.", + HTTPStatusCode: http.StatusRequestedRangeNotSatisfiable, + }, + ErrRangeAndPartNumber: { + Code: "InvalidRequest", + Description: "Cannot specify both Range header and partNumber query parameter", + HTTPStatusCode: http.StatusBadRequest, + }, + ErrInvalidPartOrder: { + Code: "InvalidPartOrder", + Description: "The list of parts was not in ascending order. Parts must be ordered by part number.", + HTTPStatusCode: http.StatusBadRequest, + }, + ErrInvalidCopyDest: { + Code: "InvalidRequest", + Description: "This copy request is illegal because it is trying to copy an object to itself without changing the object's metadata, storage class, website redirect location or encryption attributes.", + HTTPStatusCode: http.StatusBadRequest, + }, + ErrInvalidTagKey: { + Code: "InvalidTag", + Description: "The TagKey you have provided is invalid", + HTTPStatusCode: http.StatusBadRequest, + }, + ErrInvalidTagValue: { + Code: "InvalidTag", + Description: "The TagValue you have provided is invalid", + HTTPStatusCode: http.StatusBadRequest, + }, + ErrDuplicateTagKey: { + Code: "InvalidTag", + Description: "Cannot provide multiple Tags with the same key", + HTTPStatusCode: http.StatusBadRequest, + }, + ErrBucketTaggingLimited: { + Code: "BadRequest", + Description: "Bucket tag count cannot be greater than 50", + HTTPStatusCode: http.StatusBadRequest, + }, + ErrObjectTaggingLimited: { + Code: "BadRequest", + Description: "Object tags cannot be greater than 10", + HTTPStatusCode: http.StatusBadRequest, + }, + ErrCannotParseHTTPRequest: { + Code: "BadRequest", + Description: "An error occurred when parsing the HTTP request.", + HTTPStatusCode: http.StatusBadRequest, + }, + ErrMalformedXML: { + Code: "MalformedXML", + Description: "The XML you provided was not well-formed or did not validate against our published schema.", + HTTPStatusCode: http.StatusBadRequest, + }, + ErrMalformedPOSTRequest: { + Code: "MalformedPOSTRequest", + Description: "The body of your POST request is not well-formed multipart/form-data.", + HTTPStatusCode: http.StatusBadRequest, + }, + ErrEntityTooSmall: { + Code: "EntityTooSmall", + Description: "Your proposed upload is smaller than the minimum allowed size", + HTTPStatusCode: http.StatusBadRequest, + }, + ErrEntityTooLarge: { + Code: "EntityTooLarge", + Description: "Your proposed upload exceeds the maximum allowed object size.", + HTTPStatusCode: http.StatusBadRequest, + }, + ErrExpiredPresignRequest: { + Code: "AccessDenied", + Description: "Request has expired.", + HTTPStatusCode: http.StatusForbidden, + }, + ErrInvalidAccessKeyID: { + Code: "InvalidAccessKeyId", + Description: "The AWS Access Key Id you provided does not exist in our records.", + HTTPStatusCode: http.StatusForbidden, + }, + ErrInvalidToken: { + Code: "InvalidToken", + Description: "The provided token is malformed or otherwise invalid.", + HTTPStatusCode: http.StatusBadRequest, + }, + ErrRequestNotReadyYet: { + Code: "AccessDenied", + Description: "Request is not valid yet.", + HTTPStatusCode: http.StatusForbidden, + }, + ErrSignatureDoesNotMatch: { + Code: "SignatureDoesNotMatch", + Description: "The request signature we calculated does not match the signature you provided. Check your key and signing method.", + HTTPStatusCode: http.StatusForbidden, + }, + ErrContentSHA256Mismatch: { + Code: "XAmzContentSHA256Mismatch", + Description: "The provided 'x-amz-content-sha256' header does not match what was computed.", + HTTPStatusCode: http.StatusBadRequest, + }, + ErrInvalidSHA256PayloadUsage: { + Code: "InvalidRequest", + Description: "The value of x-amz-content-sha256 header is invalid.", + HTTPStatusCode: http.StatusBadRequest, + }, + ErrUnsupportedAnonymousSignedStreaming: { + Code: "InvalidRequest", + Description: "Anonymous requests don't support this x-amz-content-sha256 value. Please use UNSIGNED-PAYLOAD or STREAMING-UNSIGNED-PAYLOAD-TRAILER.", + HTTPStatusCode: http.StatusBadRequest, + }, + ErrMissingContentLength: { + Code: "MissingContentLength", + Description: "You must provide the Content-Length HTTP header.", + HTTPStatusCode: http.StatusLengthRequired, + }, + ErrContentLengthMismatch: { + Code: "IncompleteBody", + Description: "You did not provide the number of bytes specified by the Content-Length HTTP header", + HTTPStatusCode: http.StatusBadRequest, + }, + ErrMissingDateHeader: { + Code: "AccessDenied", + Description: "AWS authentication requires a valid Date or x-amz-date header.", + HTTPStatusCode: http.StatusForbidden, + }, + ErrGetUploadsWithKey: { + Code: "InvalidRequest", + Description: "Key is not expected for the GET method ?uploads subresource", + HTTPStatusCode: http.StatusBadRequest, + }, + ErrVersionsWithKey: { + Code: "InvalidRequest", + Description: "There is no such thing as the ?versions sub-resource for a key", + HTTPStatusCode: http.StatusBadRequest, + }, + ErrInvalidRequest: { + Code: "InvalidRequest", + Description: "Invalid Request.", + HTTPStatusCode: http.StatusBadRequest, + }, + ErrAuthNotSetup: { + Code: "InvalidRequest", + Description: "Signed request requires setting up SeaweedFS S3 authentication.", + HTTPStatusCode: http.StatusBadRequest, + }, + ErrNotImplemented: { + Code: "NotImplemented", + Description: "A header you provided implies functionality that is not implemented.", + HTTPStatusCode: http.StatusNotImplemented, + }, + ErrPreconditionFailed: { + Code: "PreconditionFailed", + Description: "At least one of the pre-conditions you specified did not hold.", + HTTPStatusCode: http.StatusPreconditionFailed, + }, + ErrInvalidObjectState: { + Code: "InvalidObjectState", + Description: "The operation is not valid for the current state of the object.", + HTTPStatusCode: http.StatusForbidden, + }, + ErrInvalidRange: { + Code: "InvalidRange", + Description: "The requested range is not satisfiable", + HTTPStatusCode: http.StatusRequestedRangeNotSatisfiable, + }, + ErrInvalidURI: { + Code: "InvalidURI", + Description: "The specified URI couldn't be parsed.", + HTTPStatusCode: http.StatusBadRequest, + }, + ErrObjectLockConfigurationNotFound: { + Code: "ObjectLockConfigurationNotFoundError", + Description: "Object Lock configuration does not exist for this bucket.", + HTTPStatusCode: http.StatusNotFound, + }, + ErrNoSuchObjectLockConfiguration: { + Code: "NoSuchObjectLockConfiguration", + Description: "The specified object does not have a ObjectLock configuration.", + HTTPStatusCode: http.StatusBadRequest, + }, + ErrMissingObjectLockConfiguration: { + Code: "InvalidRequest", + Description: "Bucket is missing Object Lock Configuration", + HTTPStatusCode: http.StatusBadRequest, + }, + ErrMissingObjectLockConfigurationNoSpaces: { + Code: "InvalidRequest", + Description: "Bucket is missing ObjectLockConfiguration", + HTTPStatusCode: http.StatusBadRequest, + }, + ErrObjectLockConfigurationNotAllowed: { + Code: "InvalidBucketState", + Description: "Versioning must be 'Enabled' on the bucket to apply a Object Lock configuration", + HTTPStatusCode: http.StatusConflict, + }, + ErrObjectLocked: { + Code: "AccessDenied", + Description: "Access Denied because object protected by object lock.", + HTTPStatusCode: http.StatusForbidden, + }, + ErrNoSuchBucketPolicy: { + Code: "NoSuchBucketPolicy", + Description: "The bucket policy does not exist.", + HTTPStatusCode: http.StatusNotFound, + }, + ErrBucketTaggingNotFound: { + Code: "NoSuchTagSet", + Description: "The TagSet does not exist.", + HTTPStatusCode: http.StatusNotFound, + }, + ErrObjectAttributesInvalidHeader: { + Code: "InvalidRequest", + Description: "The x-amz-object-attributes header specifying the attributes to be retrieved is either missing or empty", + HTTPStatusCode: http.StatusBadRequest, + }, + ErrRequestTimeTooSkewed: { + Code: "RequestTimeTooSkewed", + Description: "The difference between the request time and the current time is too large.", + HTTPStatusCode: http.StatusForbidden, + }, + ErrInvalidBucketAclWithObjectOwnership: { + Code: "InvalidBucketAclWithObjectOwnership", + Description: "Bucket cannot have ACLs set with ObjectOwnership's BucketOwnerEnforced setting", + HTTPStatusCode: http.StatusBadRequest, + }, + ErrBothCannedAndHeaderGrants: { + Code: "InvalidRequest", + Description: "Specifying both Canned ACLs and Header Grants is not allowed.", + HTTPStatusCode: http.StatusBadRequest, + }, + ErrOwnershipControlsNotFound: { + Code: "OwnershipControlsNotFoundError", + Description: "The bucket ownership controls were not found.", + HTTPStatusCode: http.StatusNotFound, + }, + ErrAclNotSupported: { + Code: "AccessControlListNotSupported", + Description: "The bucket does not allow ACLs.", + HTTPStatusCode: http.StatusBadRequest, + }, + ErrMalformedACL: { + Code: "MalformedACLError", + Description: "The XML you provided was not well-formed or did not validate against our published schema.", + HTTPStatusCode: http.StatusBadRequest, + }, + ErrUnexpectedContent: { + Code: "UnexpectedContent", + Description: "This request does not support content.", + HTTPStatusCode: http.StatusBadRequest, + }, + ErrMissingSecurityHeader: { + Code: "MissingSecurityHeader", + Description: "Your request was missing a required header.", + HTTPStatusCode: http.StatusNotFound, + }, + ErrKeyTooLong: { + Code: "KeyTooLongError", + Description: "Your key is too long.", + HTTPStatusCode: http.StatusBadRequest, + }, + ErrNoSuchVersion: { + Code: "NoSuchVersion", + Description: "The specified version does not exist.", + HTTPStatusCode: http.StatusNotFound, + }, + ErrSuspendedVersioningNotAllowed: { + Code: "InvalidBucketState", + Description: "An Object Lock configuration is present on this bucket, so the versioning state cannot be changed.", + HTTPStatusCode: http.StatusBadRequest, + }, + ErrMissingRequestBody: { + Code: "MissingRequestBodyError", + Description: "Request Body is empty", + HTTPStatusCode: http.StatusBadRequest, + }, + ErrChecksumSDKAlgoMismatch: { + Code: "InvalidRequest", + Description: "x-amz-sdk-checksum-algorithm specified, but no corresponding x-amz-checksum-* or x-amz-trailer headers were found.", + HTTPStatusCode: http.StatusBadRequest, + }, + ErrChecksumRequired: { + Code: "InvalidRequest", + Description: "Missing required header for this request: Content-MD5 OR x-amz-checksum-*", + HTTPStatusCode: http.StatusBadRequest, + }, + ErrMissingContentSha256: { + Code: "InvalidRequest", + Description: "Missing required header for this request: x-amz-content-sha256", + HTTPStatusCode: http.StatusBadRequest, + }, + ErrMultipleChecksumHeaders: { + Code: "InvalidRequest", + Description: "Expecting a single x-amz-checksum- header. Multiple checksum Types are not allowed.", + HTTPStatusCode: http.StatusBadRequest, + }, + ErrInvalidChecksumAlgorithm: { + Code: "InvalidRequest", + Description: "Checksum algorithm provided is unsupported. Please try again with any of the valid types: [CRC32, CRC32C, CRC64NVME, MD5, SHA1, SHA256, SHA512, XXHASH128, XXHASH3, XXHASH64]", + HTTPStatusCode: http.StatusBadRequest, + }, + ErrChecksumTypeWithAlgo: { + Code: "InvalidRequest", + Description: "The x-amz-checksum-type header can only be used with the x-amz-checksum-algorithm header.", + HTTPStatusCode: http.StatusBadRequest, + }, + ErrInvalidChecksumHeader: { + Code: "InvalidRequest", + Description: "The algorithm type you specified in x-amz-checksum- header is invalid.", + HTTPStatusCode: http.StatusBadRequest, + }, + ErrTrailerHeaderNotSupported: { + Code: "InvalidRequest", + Description: "The value specified in the x-amz-trailer header is not supported", + HTTPStatusCode: http.StatusBadRequest, + }, + ErrBadRequest: { + Code: "400", + Description: "Bad Request", + HTTPStatusCode: http.StatusBadRequest, + }, + ErrNoSuchCORSConfiguration: { + Code: "NoSuchCORSConfiguration", + Description: "The CORS configuration does not exist", + HTTPStatusCode: http.StatusNotFound, + }, + ErrCORSForbidden: { + Code: "AccessForbidden", + Description: "CORSResponse: This CORS request is not allowed. This is usually because the evalution of Origin, request method / Access-Control-Request-Method or Access-Control-Request-Headers are not whitelisted by the resource's CORS spec.", + HTTPStatusCode: http.StatusForbidden, + }, + ErrMissingCORSOrigin: { + Code: "BadRequest", + Description: "Insufficient information. Origin request header needed.", + HTTPStatusCode: http.StatusBadRequest, + }, + ErrCORSIsNotEnabled: { + Code: "AccessForbidden", + Description: "CORSResponse: CORS is not enabled for this bucket.", + HTTPStatusCode: http.StatusForbidden, + }, + ErrNoSuchWebsiteConfiguration: { + Code: "NoSuchWebsiteConfiguration", + Description: "The specified bucket does not have a website configuration", + HTTPStatusCode: http.StatusNotFound, + }, + ErrInvalidWebsiteRedirectProtocol: { + Code: "InvalidRequest", + Description: "Invalid protocol, protocol can be http or https. If not defined the protocol will be selected automatically.", + HTTPStatusCode: http.StatusBadRequest, + }, + ErrInvalidRedirectLocation: { + Code: "InvalidRedirectLocation", + Description: "The website redirect location must have a prefix of 'http://' or 'https://' or '/'.", + HTTPStatusCode: http.StatusBadRequest, + }, + ErrBothReplaceKeyAndPrefix: { + Code: "InvalidRequest", + Description: "You can only define ReplaceKeyPrefix or ReplaceKey but not both.", + HTTPStatusCode: http.StatusBadRequest, + }, + ErrMaxMessageLengthExceeded: { + Code: "MaxMessageLengthExceeded", + Description: "Your request was too big.", + HTTPStatusCode: http.StatusBadRequest, + }, + ErrNotModified: { + Code: "NotModified", + Description: "Not Modified", + HTTPStatusCode: http.StatusNotModified, + }, + ErrInvalidLocationConstraint: { + Code: "InvalidLocationConstraint", + Description: "The specified location-constraint is not valid", + HTTPStatusCode: http.StatusBadRequest, + }, + ErrMalformedTrailer: { + Code: "MalformedTrailerError", + Description: "The request contained trailing data that was not well-formed or did not conform to our published schema.", + HTTPStatusCode: http.StatusBadRequest, + }, + ErrInvalidChunkSize: { + Code: "InvalidChunkSizeError", + Description: "Only the last chunk is allowed to have a size less than 8192 bytes", + HTTPStatusCode: http.StatusBadRequest, + }, + ErrSlowDown: { + Code: "SlowDown", + Description: "Please reduce your request rate.", + HTTPStatusCode: http.StatusServiceUnavailable, + }, + ErrMetadataTooLarge: { + Code: "MetadataTooLarge", + Description: "Your metadata headers exceed the maximum allowed metadata size", + HTTPStatusCode: http.StatusBadRequest, + }, + ErrRequestHeaderSectionTooLarge: { + Code: "RequestHeaderSectionTooLarge", + Description: "Your request header section exceeds the maximum allowed size.", + HTTPStatusCode: http.StatusBadRequest, + }, + ErrUnsupportedAuthorizationMechanism: { + Code: "InvalidRequest", + Description: "The authorization mechanism you have provided is not supported. Please use AWS4-HMAC-SHA256.", + HTTPStatusCode: http.StatusBadRequest, + }, + ErrNoBucketInRequest: { + Code: "WebsiteRedirect", + Description: "Request does not contain a bucket name.", + HTTPStatusCode: http.StatusMovedPermanently, + }, + + // non aws errors + ErrExistingObjectIsDirectory: { + Code: "ExistingObjectIsDirectory", + Description: "Existing Object is a directory.", + HTTPStatusCode: http.StatusConflict, + }, + ErrObjectParentIsFile: { + Code: "ObjectParentIsFile", + Description: "Object parent already exists as a file.", + HTTPStatusCode: http.StatusConflict, + }, + ErrDirectoryObjectContainsData: { + Code: "DirectoryObjectContainsData", + Description: "Directory object contains data payload.", + HTTPStatusCode: http.StatusBadRequest, + }, + ErrDirectoryNotEmpty: { + Code: "ErrDirectoryNotEmpty", + Description: "Directory object not empty.", + HTTPStatusCode: http.StatusBadRequest, + }, + ErrQuotaExceeded: { + Code: "QuotaExceeded", + Description: "Your request was denied due to quota exceeded.", + HTTPStatusCode: http.StatusForbidden, + }, + ErrVersioningNotConfigured: { + Code: "VersioningNotConfigured", + Description: "Versioning has not been configured for the gateway.", + HTTPStatusCode: http.StatusNotImplemented, + }, + ErrACLsDisabled: { + Code: "AccessControlListNotSupported", + Description: "Access control lists are disabled at the gateway level", + HTTPStatusCode: http.StatusBadRequest, + }, + ErrNoSpaceLeftOnDevice: { + Code: "InsufficientStorage", + Description: "No space left on device.", + HTTPStatusCode: http.StatusInsufficientStorage, + }, + + // Admin api errors + ErrAdminAccessDenied: { + Code: "XAdminAccessDenied", + Description: "Only admin users have access to this resource.", + HTTPStatusCode: http.StatusForbidden, + }, + ErrAdminUserNotFound: { + Code: "XAdminUserNotFound", + Description: "No user exists with the provided access key ID.", + HTTPStatusCode: http.StatusNotFound, + }, + ErrAdminUserExists: { + Code: "XAdminUserExists", + Description: "A user with the provided access key ID already exists.", + HTTPStatusCode: http.StatusConflict, + }, + ErrAdminInvalidUserRole: { + Code: "XAdminInvalidArgument", + Description: "User role has to be one of the following: 'user', 'admin', 'userplus'.", + HTTPStatusCode: http.StatusBadRequest, + }, + ErrAdminMissingUserAcess: { + Code: "XAdminInvalidArgument", + Description: "User access key ID is missing.", + HTTPStatusCode: http.StatusNotFound, + }, + ErrAdminMethodNotSupported: { + Code: "XAdminMethodNotSupported", + Description: "The method is not supported in single root user mode.", + HTTPStatusCode: http.StatusNotImplemented, + }, + ErrAdminEmptyBucketOwnerHeader: { + Code: "XAdminInvalidRequest", + Description: "The x-vgw-owner header specifying the new bucket owner access key id is either missing or empty", + HTTPStatusCode: http.StatusBadRequest, + }, +} + +// GetAPIError provides API Error for input API error code. +func GetAPIError(code ErrorCode) APIError { + return errorCodeResponse[code] +} + +// Encodes the response headers into XML format. +func encodeResponse(response any) []byte { + var bytesBuffer bytes.Buffer + bytesBuffer.WriteString(xml.Header) + e := xml.NewEncoder(&bytesBuffer) + e.Encode(response) + return bytesBuffer.Bytes() +} + +type ErrorField struct { + Name string + Value any +} + +func (e APIError) encodeHTMLResponse(requestID, hostID string, fields ...ErrorField) []byte { + status := fmt.Sprintf("%d %s", e.HTTPStatusCode, http.StatusText(e.HTTPStatusCode)) + + builder := &strings.Builder{} + builder.WriteString("\n") + builder.WriteString("") + builder.WriteString(html.EscapeString(status)) + builder.WriteString("\n\n

") + builder.WriteString(html.EscapeString(status)) + builder.WriteString("

\n\n
\n\n\n") + return []byte(builder.String()) +} + +func writeHTMLErrorField(builder *strings.Builder, name string, value any) { + builder.WriteString("
  • ") + builder.WriteString(html.EscapeString(name)) + builder.WriteString(": ") + builder.WriteString(html.EscapeString(fmt.Sprint(value))) + builder.WriteString("
  • \n") +} + +// Returns invalid checksum error with the provided header in the error description +func GetInvalidChecksumHeaderErr(header string) APIError { + return APIError{ + Code: "InvalidRequest", + Description: fmt.Sprintf("Value for %v header is invalid.", header), + HTTPStatusCode: http.StatusBadRequest, + } +} + +func GetInvalidTrailingChecksumHeaderErr(header string) APIError { + return APIError{ + Code: "InvalidRequest", + Description: fmt.Sprintf("Value for %v trailing header is invalid.", header), + HTTPStatusCode: http.StatusBadRequest, + } +} + +// Returns checksum type mismatch APIError +func GetChecksumTypeMismatchErr(expected, actual types.ChecksumAlgorithm) APIError { + return APIError{ + Code: "InvalidRequest", + Description: fmt.Sprintf("Checksum Type mismatch occurred, expected checksum Type: %v, actual checksum Type: %v", expected, actual), + HTTPStatusCode: http.StatusBadRequest, + } +} + +// Returns incorrect checksum APIError +func GetChecksumBadDigestErr(algo types.ChecksumAlgorithm) APIError { + return APIError{ + Code: "BadDigest", + Description: fmt.Sprintf("The %v you specified did not match the calculated checksum.", algo), + HTTPStatusCode: http.StatusBadRequest, + } +} + +// Returns checksum type mismatch error with checksum algorithm +func GetChecksumSchemaMismatchErr(algo types.ChecksumAlgorithm, t types.ChecksumType) APIError { + return APIError{ + Code: "InvalidRequest", + Description: fmt.Sprintf("The %v checksum type cannot be used with the %v checksum algorithm.", strings.ToUpper(string(t)), strings.ToLower(string(algo))), + HTTPStatusCode: http.StatusBadRequest, + } +} + +// Returns checksum type mismatch error for multipart uploads +func GetChecksumTypeMismatchOnMpErr(t types.ChecksumType) APIError { + return APIError{ + Code: "InvalidRequest", + Description: fmt.Sprintf("The upload was created using the %v checksum mode. The complete request must use the same checksum mode.", t), + HTTPStatusCode: http.StatusBadRequest, + } +} + +func GetIncorrectMpObjectSizeErr(expected, actual int64) APIError { + return APIError{ + Code: "InvalidRequest", + Description: fmt.Sprintf("The provided 'x-amz-mp-object-size' header value %v does not match what was computed: %v", expected, actual), + HTTPStatusCode: http.StatusBadRequest, + } +} + +func GetNegatvieMpObjectSizeErr(val int64) APIError { + return APIError{ + Code: "InvalidRequest", + Description: fmt.Sprintf("Value for x-amz-mp-object-size header is less than zero: '%v'", val), + HTTPStatusCode: http.StatusBadRequest, + } +} + +func GetInvalidMpObjectSizeErr(val string) APIError { + return APIError{ + Code: "InvalidRequest", + Description: fmt.Sprintf("Value for x-amz-mp-object-size header is invalid: '%s'", val), + HTTPStatusCode: http.StatusBadRequest, + } +} + +func GetInvalidCORSHeaderErr(header string) APIError { + return APIError{ + Code: "InvalidRequest", + Description: fmt.Sprintf(`AllowedHeader "%s" contains invalid character.`, header), + HTTPStatusCode: http.StatusBadRequest, + } +} + +func GetInvalidCORSRequestHeaderErr(header string) APIError { + return APIError{ + Code: "BadRequest", + Description: fmt.Sprintf(`Access-Control-Request-Headers "%s" contains invalid character.`, header), + HTTPStatusCode: http.StatusBadRequest, + } +} + +func GetMultipleWildcardCORSOriginErr(origin string) APIError { + return APIError{ + Code: "InvalidRequest", + Description: fmt.Sprintf(`AllowedOrigin "%s" can not have more than one wildcard.`, origin), + HTTPStatusCode: http.StatusBadRequest, + } +} + +func GetUnsopportedCORSMethodErr(method string) APIError { + return APIError{ + Code: "InvalidRequest", + Description: fmt.Sprintf("Found unsupported HTTP method in CORS config. Unsupported method is %s", method), + HTTPStatusCode: http.StatusBadRequest, + } +} + +func GetInvalidCORSMethodErr(method string) APIError { + return APIError{ + Code: "BadRequest", + Description: fmt.Sprintf("Invalid Access-Control-Request-Method: %s", method), + HTTPStatusCode: http.StatusBadRequest, + } +} + +func GetCopySourceObjectTooLargeErr(limit int64) APIError { + return APIError{ + Code: "InvalidRequest", + Description: fmt.Sprintf("The specified copy source is larger than the maximum allowable size for a copy source: %d", limit), + HTTPStatusCode: http.StatusBadRequest, + } +} + +func GetInvalidRedirectCodeErr(input int) APIError { + return APIError{ + Code: "InvalidRequest", + Description: fmt.Sprintf("The provided HTTP redirect code (%d) is not valid. Valid codes are 3XX except 300.", input), + HTTPStatusCode: http.StatusBadRequest, + } +} + +func GetInvalidHTTPErrorCodeErr(input int) APIError { + return APIError{ + Code: "InvalidRequest", + Description: fmt.Sprintf("The provided HTTP error code (%d) is not valid. Valid codes are 4XX or 5XX.", input), + HTTPStatusCode: http.StatusBadRequest, + } +} + +func GetWebsiteRoutingRulesLimitedErr(rules int) APIError { + return APIError{ + Code: "InvalidRequest", + Description: fmt.Sprintf("%d routing rules provided, the number of routing rules in a website configuration is limited to 50.", rules), + HTTPStatusCode: http.StatusBadRequest, + } +} + +func GetExplicitDenyAccessErr(principal, action, resourceArn, source string) APIError { + return APIError{ + Code: "AccessDenied", + Description: fmt.Sprintf( + "User: %s is not authorized to perform: %s on resource: %q with an explicit deny in %s", + principal, action, resourceArn, source, + ), + HTTPStatusCode: http.StatusForbidden, + } +} + +func GetImplicitDenyAccessErr(principal, action, resourceArn string) APIError { + return APIError{ + Code: "AccessDenied", + Description: fmt.Sprintf( + "User: %s is not authorized to perform: %s on resource: %q because no identity-based policy allows the %s action", + principal, action, resourceArn, action, + ), + HTTPStatusCode: http.StatusForbidden, + } +} + +type ResourceType string + +const ( + ResourceTypeBucket ResourceType = "BUCKET" + ResourceTypeObject ResourceType = "OBJECT" + ResourceTypeService ResourceType = "SERVICE" + ResourceTypeBucketPolicy ResourceType = "BUCKETPOLICY" + ResourceTypeUpload ResourceType = "UPLOAD" +) + +func ObjectDeleteError(key, versionId *string, err error) types.Error { + if serr, ok := err.(S3Error); ok { + base := serr.BaseError() + return types.Error{ + Key: key, + VersionId: versionId, + Code: &base.Code, + Message: &base.Description, + } + } + message := err.Error() + code := "InternalError" + return types.Error{ + Key: key, + VersionId: versionId, + Code: &code, + Message: &message, + } +}