feat: configuration option to disable ACLs

Closes #1847

This PR introduces a global optional gateway CLI flag `--disable-acl` (`VGW_DISABLE_ACL`) to disable ACL handling. When this flag is enabled, the gateway ignores all ACL-related headers, particularly in `CreateBucket`, `PutObject`, `CopyObject`, and `CreateMultipartUpload`.

`GetBucketAcl` behavior is unchanged simply returning the bucket ACL config.
There's no change in object ACL actions(`PutObjectACL`, `GetObjectACL`). They return a`NotImplemented` error as before.

A new custom error is added for PutBucketAcl calls when ACLs are disabled at the gateway level. Its HTTP status code and error code match AWS S3’s behavior, with only a slightly different error message.

In the access-control checker, ACL evaluation is fully bypassed. If ACLs are disabled only the bucket owner gets access to the bucket and all grantee checks are ignored.

The PR also includes minor refactoring of the S3 API server and router. The growing list of parameters passed to the router’s Init method has been consolidated into fields within the router struct, initialized during router construction. Parameters not needed by the S3 server are no longer stored in the server configuration and are instead forwarded directly to the router.
This commit is contained in:
niksis02
2026-02-27 20:04:13 +04:00
parent 17b42ac5d8
commit 5ae791b154
26 changed files with 1045 additions and 631 deletions
+2 -1
View File
@@ -79,6 +79,7 @@ type AccessOptions struct {
Action Action
Readonly bool
IsPublicRequest bool
DisableACL bool
}
func VerifyAccess(ctx context.Context, be backend.Backend, opts AccessOptions) error {
@@ -107,7 +108,7 @@ func VerifyAccess(ctx context.Context, be backend.Backend, opts AccessOptions) e
return VerifyBucketPolicy(policy, opts.Acc.Access, opts.Bucket, opts.Object, opts.Action)
}
if err := verifyACL(opts.Acl, opts.Acc.Access, opts.AclPermission); err != nil {
if err := verifyACL(opts.Acl, opts.Acc.Access, opts.AclPermission, opts.DisableACL); err != nil {
return err
}
+11 -1
View File
@@ -414,7 +414,17 @@ func splitUnique(s, divider string) []string {
return result
}
func verifyACL(acl ACL, access string, permission Permission) error {
func verifyACL(acl ACL, access string, permission Permission, disableACL bool) error {
if disableACL {
// only the bucket owner should have access to the bucket
// as bucket ACLs are disabled and no grantee check is necessary
if acl.Owner != access {
return s3err.GetAPIError(s3err.ErrAccessDenied)
}
return nil
}
grantee := Grantee{
Access: access,
Permission: permission,
+11
View File
@@ -100,6 +100,7 @@ var (
webuiNoTLS bool
webuiGateways []string
webuiAdminGateways []string
disableACLs bool
)
var (
@@ -341,6 +342,13 @@ func initFlags() []cli.Flag {
Destination: &virtualDomain,
Aliases: []string{"vd"},
},
&cli.BoolFlag{
Name: "disable-acl",
Usage: "disables gateway ACLs, by ignoring all ACL headers",
EnvVars: []string{"VGW_DISABLE_ACL"},
Destination: &disableACLs,
Aliases: []string{"noacl"},
},
&cli.StringFlag{
Name: "access-log",
Usage: "enable server access logging to specified file",
@@ -833,6 +841,9 @@ func runGateway(ctx context.Context, be backend.Backend) error {
if keepAlive {
opts = append(opts, s3api.WithKeepAlive())
}
if disableACLs {
opts = append(opts, s3api.WithDisableACL())
}
if debug {
debuglogger.SetDebugEnabled()
}
+5
View File
@@ -152,6 +152,11 @@ func initTestCommands() []*cli.Command {
Usage: "Tests gateway access control with bucket ACLs and Policies",
Action: getAction(integration.TestAccessControl),
},
{
Name: "noacl",
Usage: "Tests gateway in ACL-disabled mode",
Action: getAction(integration.TestNoAclMode),
},
{
Name: "bench",
Usage: "Runs download/upload performance test on the gateway",
+7
View File
@@ -111,6 +111,13 @@ ROOT_SECRET_ACCESS_KEY=
# operations will be allowed.
#VGW_READ_ONLY=false
# Disable ACL support at the gateway level. All ACL headers on requests are
# ignored, and no access control is enforced using bucket ACLs.
# Prefer using bucket policies instead of ACLs for access management.
# PutBucketAcl returns an immediate AccessControlListNotSupported error.
# GetBucketAcl returns a successful response containing the default bucket ACL.
#VGW_DISABLE_ACL=false
# The VGW_VIRTUAL_DOMAIN option enables the virtual host style bucket
# addressing. The path style addressing is the default, and remains enabled
# even when virtual host style is enabled. The VGW_VIRTUAL_DOMAIN option
+77 -6
View File
@@ -5,8 +5,15 @@ rm -rf /tmp/gw
mkdir /tmp/gw
rm -rf /tmp/covdata
mkdir /tmp/covdata
rm -rf /tmp/versioing.covdata
rm -rf /tmp/https.covdata
mkdir /tmp/https.covdata
rm -rf /tmp/versioning.covdata
mkdir /tmp/versioning.covdata
rm -rf /tmp/versioning.https.covdata
mkdir /tmp/versioning.https.covdata
rm -rf /tmp/noacl.covdata
mkdir /tmp/noacl.covdata
rm -rf /tmp/versioningdir
mkdir /tmp/versioningdir
@@ -153,10 +160,74 @@ fi
# kill off server
kill $GW_VS_HTTPS_PID
ECHO "Running No ACL integration tests"
# run server in background versioning-enabled
# port: 7073
GOCOVERDIR=/tmp/noacl.covdata ./versitygw -p :7074 -a user -s pass -noacl --iam-dir /tmp/gw posix /tmp/gw &
GW_NO_ACL_PID=$!
# wait a second for server to start up
sleep 1
# check if noacl gateway process is still running
if ! kill -0 $GW_NO_ACL_PID; then
echo "noacl server no longer running"
exit 1
fi
if ! ./versitygw test --allow-insecure -a user -s pass -e http://127.0.0.1:7074 noacl; then
echo "No ACL integration tests failed"
kill $GW_NO_ACL_PID
exit 1
fi
# kill off server
kill $GW_NO_ACL_PID
exit 0
# if the above binary was built with -cover enabled (make testbin),
# then the following can be used for code coverage reports:
# go tool covdata percent -i=/tmp/covdata
# go tool covdata textfmt -i=/tmp/covdata -o profile.txt
# go tool cover -html=profile.txt
# -----------------------------------------------------------------------------
# Coverage Reports (Go 1.20+ Runtime Coverage)
#
# The servers above were started with GOCOVERDIR=<dir>, which causes Go to write
# raw coverage artifacts into those directories (covmeta + covcounters.* files).
# These raw files must be processed with "go tool covdata" to generate
# human-readable coverage reports.
#
# You may generate *per-environment* coverage or a *merged full-suite* report.
#
# -----------------------------------------------------------------------------
# 1) INDIVIDUAL COVERAGE REPORTS
#
# Example for a single environment (e.g. /tmp/covdata):
#
# go tool covdata percent -i=/tmp/covdata
# go tool covdata textfmt -i=/tmp/covdata -o /tmp/profile.txt
# go tool cover -html=/tmp/profile.txt
#
# Repeat using:
# /tmp/covdata
# /tmp/https.covdata
# /tmp/versioning.covdata
# /tmp/versioning.https.covdata
# /tmp/noacl.covdata
#
# This gives you coverage metrics isolated per test suite / server mode.
#
# -----------------------------------------------------------------------------
# 2) MERGED COVERAGE REPORT (RECOMMENDED)
#
# If you want a unified report combining all environments:
#
# go tool covdata merge \
# -i=/tmp/covdata,/tmp/https.covdata,/tmp/versioning.covdata,/tmp/versioning.https.covdata,/tmp/noacl.covdata \
# -o /tmp/allcovdata
#
# go tool covdata percent -i=/tmp/allcovdata
# go tool covdata textfmt -i=/tmp/allcovdata -o /tmp/all_profile.txt
# go tool cover -html=/tmp/all_profile.txt
#
# This produces the full aggregate coverage across all HTTP/HTTPS,
# versioning-enabled, non-versioning, and no-ACL test runs.
#
# -----------------------------------------------------------------------------
+1 -1
View File
@@ -681,7 +681,7 @@ func TestAdminController_CreateBucket(t *testing.T) {
},
}
s3api := New(be, iam, nil, nil, nil, false, "")
s3api := New(be, iam, nil, nil, nil, false, false, "")
ctrl := AdminController{
iam: iam,
+11 -1
View File
@@ -39,6 +39,7 @@ type S3ApiController struct {
evSender s3event.S3EventSender
mm metrics.Manager
readonly bool
disableACL bool
virtualDomain string
}
@@ -59,7 +60,7 @@ var (
xmlhdr = []byte(`<?xml version="1.0" encoding="UTF-8"?>` + "\n")
)
func New(be backend.Backend, iam auth.IAMService, logger s3log.AuditLogger, evs s3event.S3EventSender, mm metrics.Manager, readonly bool, virtualDomain string) S3ApiController {
func New(be backend.Backend, iam auth.IAMService, logger s3log.AuditLogger, evs s3event.S3EventSender, mm metrics.Manager, readonly, disableACL bool, virtualDomain string) S3ApiController {
return S3ApiController{
be: be,
iam: iam,
@@ -67,10 +68,19 @@ func New(be backend.Backend, iam auth.IAMService, logger s3log.AuditLogger, evs
evSender: evs,
readonly: readonly,
mm: mm,
disableACL: disableACL,
virtualDomain: virtualDomain,
}
}
func (c S3ApiController) getAclHeaderValue(ctx *fiber.Ctx, key string, defaultValues ...string) string {
if c.disableACL {
return ""
}
return ctx.Get(key, defaultValues...)
}
// Returns MethodNotAllowed for unmatched routes
func (c S3ApiController) HandleErrorRoute(err error) Controller {
return func(ctx *fiber.Ctx) (*Response, error) {
+5
View File
@@ -39,6 +39,7 @@ func (c S3ApiController) DeleteBucketTagging(ctx *fiber.Ctx) (*Response, error)
Bucket: bucket,
Action: auth.PutBucketTaggingAction,
IsPublicRequest: IsBucketPublic,
DisableACL: c.disableACL,
})
if err != nil {
return &Response{
@@ -72,6 +73,7 @@ func (c S3ApiController) DeleteBucketOwnershipControls(ctx *fiber.Ctx) (*Respons
Acc: acct,
Bucket: bucket,
Action: auth.PutBucketOwnershipControlsAction,
DisableACL: c.disableACL,
})
if err != nil {
return &Response{
@@ -105,6 +107,7 @@ func (c S3ApiController) DeleteBucketPolicy(ctx *fiber.Ctx) (*Response, error) {
Acc: acct,
Bucket: bucket,
Action: auth.DeleteBucketPolicyAction,
DisableACL: c.disableACL,
})
if err != nil {
return &Response{
@@ -140,6 +143,7 @@ func (c S3ApiController) DeleteBucketCors(ctx *fiber.Ctx) (*Response, error) {
Bucket: bucket,
Action: auth.PutBucketCorsAction,
IsPublicRequest: IsBucketPublic,
DisableACL: c.disableACL,
})
if err != nil {
return &Response{
@@ -175,6 +179,7 @@ func (c S3ApiController) DeleteBucket(ctx *fiber.Ctx) (*Response, error) {
Bucket: bucket,
Action: auth.DeleteBucketAction,
IsPublicRequest: IsBucketPublic,
DisableACL: c.disableACL,
})
if err != nil {
return &Response{
+13
View File
@@ -41,6 +41,7 @@ func (c S3ApiController) GetBucketTagging(ctx *fiber.Ctx) (*Response, error) {
Bucket: bucket,
Action: auth.GetBucketTaggingAction,
IsPublicRequest: isPublicBucket,
DisableACL: c.disableACL,
})
if err != nil {
return &Response{
@@ -93,6 +94,7 @@ func (c S3ApiController) GetBucketOwnershipControls(ctx *fiber.Ctx) (*Response,
Bucket: bucket,
Action: auth.GetBucketOwnershipControlsAction,
IsPublicRequest: isPublicBucket,
DisableACL: c.disableACL,
})
if err != nil {
return &Response{
@@ -133,6 +135,7 @@ func (c S3ApiController) GetBucketVersioning(ctx *fiber.Ctx) (*Response, error)
Bucket: bucket,
Action: auth.GetBucketVersioningAction,
IsPublicRequest: isPublicBucket,
DisableACL: c.disableACL,
})
if err != nil {
return &Response{
@@ -175,6 +178,7 @@ func (c S3ApiController) GetBucketCors(ctx *fiber.Ctx) (*Response, error) {
Bucket: bucket,
Action: auth.GetBucketCorsAction,
IsPublicRequest: isPublicBucket,
DisableACL: c.disableACL,
})
if err != nil {
return &Response{
@@ -218,6 +222,7 @@ func (c S3ApiController) GetBucketPolicy(ctx *fiber.Ctx) (*Response, error) {
Bucket: bucket,
Action: auth.GetBucketPolicyAction,
IsPublicRequest: isPublicBucket,
DisableACL: c.disableACL,
})
if err != nil {
return &Response{
@@ -252,6 +257,7 @@ func (c S3ApiController) GetBucketPolicyStatus(ctx *fiber.Ctx) (*Response, error
Bucket: bucket,
Action: auth.GetBucketPolicyStatusAction,
IsPublicRequest: isPublicBucket,
DisableACL: c.disableACL,
})
if err != nil {
return &Response{
@@ -313,6 +319,7 @@ func (c S3ApiController) ListObjectVersions(ctx *fiber.Ctx) (*Response, error) {
Bucket: bucket,
Action: auth.ListBucketVersionsAction,
IsPublicRequest: isPublicBucket,
DisableACL: c.disableACL,
})
if err != nil {
return &Response{
@@ -366,6 +373,7 @@ func (c S3ApiController) GetObjectLockConfiguration(ctx *fiber.Ctx) (*Response,
Bucket: bucket,
Action: auth.GetBucketObjectLockConfigurationAction,
IsPublicRequest: isPublicBucket,
DisableACL: c.disableACL,
})
if err != nil {
return &Response{
@@ -411,6 +419,7 @@ func (c S3ApiController) GetBucketAcl(ctx *fiber.Ctx) (*Response, error) {
Bucket: bucket,
Action: auth.GetBucketAclAction,
IsPublicRequest: isPublicBucket,
DisableACL: c.disableACL,
})
if err != nil {
return &Response{
@@ -462,6 +471,7 @@ func (c S3ApiController) ListMultipartUploads(ctx *fiber.Ctx) (*Response, error)
Bucket: bucket,
Action: auth.ListBucketMultipartUploadsAction,
IsPublicRequest: isPublicBucket,
DisableACL: c.disableACL,
})
if err != nil {
return &Response{
@@ -519,6 +529,7 @@ func (c S3ApiController) ListObjectsV2(ctx *fiber.Ctx) (*Response, error) {
Bucket: bucket,
Action: auth.ListBucketAction,
IsPublicRequest: isPublicBucket,
DisableACL: c.disableACL,
})
if err != nil {
return &Response{
@@ -576,6 +587,7 @@ func (c S3ApiController) ListObjects(ctx *fiber.Ctx) (*Response, error) {
Bucket: bucket,
Action: auth.ListBucketAction,
IsPublicRequest: isPublicBucket,
DisableACL: c.disableACL,
})
if err != nil {
return &Response{
@@ -627,6 +639,7 @@ func (c S3ApiController) GetBucketLocation(ctx *fiber.Ctx) (*Response, error) {
Bucket: bucket,
Action: auth.GetBucketLocationAction,
IsPublicRequest: isPublicBucket,
DisableACL: c.disableACL,
})
if err != nil {
return &Response{
+1
View File
@@ -42,6 +42,7 @@ func (c S3ApiController) HeadBucket(ctx *fiber.Ctx) (*Response, error) {
Bucket: bucket,
Action: auth.ListBucketAction,
IsPublicRequest: isPublicBucket,
DisableACL: c.disableACL,
})
if err != nil {
return &Response{
+1
View File
@@ -47,6 +47,7 @@ func (c S3ApiController) DeleteObjects(ctx *fiber.Ctx) (*Response, error) {
Bucket: bucket,
Action: auth.DeleteObjectAction,
IsPublicRequest: IsBucketPublic,
DisableACL: c.disableACL,
})
if err != nil {
return &Response{
+22 -6
View File
@@ -47,6 +47,7 @@ func (c S3ApiController) PutBucketTagging(ctx *fiber.Ctx) (*Response, error) {
Bucket: bucket,
Action: auth.PutBucketTaggingAction,
IsPublicRequest: isPublicBucket,
DisableACL: c.disableACL,
})
if err != nil {
return &Response{
@@ -88,6 +89,7 @@ func (c S3ApiController) PutBucketOwnershipControls(ctx *fiber.Ctx) (*Response,
Acc: acct,
Bucket: bucket,
Action: auth.PutBucketOwnershipControlsAction,
DisableACL: c.disableACL,
}); err != nil {
return &Response{
MetaOpts: &MetaOptions{
@@ -143,6 +145,7 @@ func (c S3ApiController) PutBucketVersioning(ctx *fiber.Ctx) (*Response, error)
Bucket: bucket,
Action: auth.PutBucketVersioningAction,
IsPublicRequest: isPublicBucket,
DisableACL: c.disableACL,
})
if err != nil {
return &Response{
@@ -197,6 +200,7 @@ func (c S3ApiController) PutObjectLockConfiguration(ctx *fiber.Ctx) (*Response,
Bucket: bucket,
Action: auth.PutBucketObjectLockConfigurationAction,
IsPublicRequest: isPublicBucket,
DisableACL: c.disableACL,
}); err != nil {
return &Response{
MetaOpts: &MetaOptions{
@@ -238,6 +242,7 @@ func (c S3ApiController) PutBucketCors(ctx *fiber.Ctx) (*Response, error) {
Bucket: bucket,
Action: auth.PutBucketCorsAction,
IsPublicRequest: isPublicBucket,
DisableACL: c.disableACL,
})
if err != nil {
return &Response{
@@ -292,6 +297,7 @@ func (c S3ApiController) PutBucketPolicy(ctx *fiber.Ctx) (*Response, error) {
Acc: acct,
Bucket: bucket,
Action: auth.PutBucketPolicyAction,
DisableACL: c.disableACL,
})
if err != nil {
return &Response{
@@ -344,6 +350,7 @@ func (c S3ApiController) PutBucketAcl(ctx *fiber.Ctx) (*Response, error) {
Acc: acct,
Bucket: bucket,
Action: auth.PutBucketAclAction,
DisableACL: c.disableACL,
})
if err != nil {
return &Response{
@@ -353,6 +360,15 @@ func (c S3ApiController) PutBucketAcl(ctx *fiber.Ctx) (*Response, error) {
}, 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{
@@ -480,12 +496,12 @@ func (c S3ApiController) PutBucketAcl(ctx *fiber.Ctx) (*Response, error) {
func (c S3ApiController) CreateBucket(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")
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"))
+3
View File
@@ -52,6 +52,7 @@ func (c S3ApiController) DeleteObjectTagging(ctx *fiber.Ctx) (*Response, error)
Object: key,
Action: action,
IsPublicRequest: isBucketPublic,
DisableACL: c.disableACL,
})
if err != nil {
return &Response{
@@ -104,6 +105,7 @@ func (c S3ApiController) AbortMultipartUpload(ctx *fiber.Ctx) (*Response, error)
Object: key,
Action: auth.AbortMultipartUploadAction,
IsPublicRequest: isBucketPublic,
DisableACL: c.disableACL,
})
if err != nil {
return &Response{
@@ -158,6 +160,7 @@ func (c S3ApiController) DeleteObject(ctx *fiber.Ctx) (*Response, error) {
Object: key,
Action: action,
IsPublicRequest: isBucketPublic,
DisableACL: c.disableACL,
})
if err != nil {
return &Response{
+7
View File
@@ -55,6 +55,7 @@ func (c S3ApiController) GetObjectTagging(ctx *fiber.Ctx) (*Response, error) {
Object: key,
Action: action,
IsPublicRequest: isPublicBucket,
DisableACL: c.disableACL,
})
if err != nil {
return &Response{
@@ -121,6 +122,7 @@ func (c S3ApiController) GetObjectRetention(ctx *fiber.Ctx) (*Response, error) {
Object: key,
Action: auth.GetObjectRetentionAction,
IsPublicRequest: isPublicBucket,
DisableACL: c.disableACL,
})
if err != nil {
return &Response{
@@ -177,6 +179,7 @@ func (c S3ApiController) GetObjectLegalHold(ctx *fiber.Ctx) (*Response, error) {
Object: key,
Action: auth.GetObjectLegalHoldAction,
IsPublicRequest: isPublicBucket,
DisableACL: c.disableACL,
})
if err != nil {
return &Response{
@@ -223,6 +226,7 @@ func (c S3ApiController) GetObjectAcl(ctx *fiber.Ctx) (*Response, error) {
Object: key,
Action: auth.GetObjectAclAction,
IsPublicRequest: isPublicBucket,
DisableACL: c.disableACL,
})
if err != nil {
return &Response{
@@ -265,6 +269,7 @@ func (c S3ApiController) ListParts(ctx *fiber.Ctx) (*Response, error) {
Object: key,
Action: auth.ListMultipartUploadPartsAction,
IsPublicRequest: isPublicBucket,
DisableACL: c.disableACL,
})
if err != nil {
return &Response{
@@ -336,6 +341,7 @@ func (c S3ApiController) GetObjectAttributes(ctx *fiber.Ctx) (*Response, error)
Object: key,
Action: action,
IsPublicRequest: isPublicBucket,
DisableACL: c.disableACL,
})
if err != nil {
return &Response{
@@ -469,6 +475,7 @@ func (c S3ApiController) GetObject(ctx *fiber.Ctx) (*Response, error) {
Object: key,
Action: action,
IsPublicRequest: isPublicBucketRequest,
DisableACL: c.disableACL,
})
if err != nil {
return &Response{
+1
View File
@@ -57,6 +57,7 @@ func (c S3ApiController) HeadObject(ctx *fiber.Ctx) (*Response, error) {
Object: key,
Action: action,
IsPublicRequest: isPublicBucket,
DisableACL: c.disableACL,
})
if err != nil {
return &Response{
+5 -1
View File
@@ -50,6 +50,7 @@ func (c S3ApiController) RestoreObject(ctx *fiber.Ctx) (*Response, error) {
Object: key,
Action: auth.RestoreObjectAction,
IsPublicRequest: isBucketPublic,
DisableACL: c.disableACL,
})
if err != nil {
return &Response{
@@ -101,6 +102,7 @@ func (c S3ApiController) SelectObjectContent(ctx *fiber.Ctx) (*Response, error)
Object: key,
Action: auth.GetObjectAction,
IsPublicRequest: isBucketPublic,
DisableACL: c.disableACL,
})
if err != nil {
return &Response{
@@ -158,7 +160,7 @@ func (c S3ApiController) CreateMultipartUpload(ctx *fiber.Ctx) (*Response, error
isRoot := utils.ContextKeyIsRoot.Get(ctx).(bool)
parsedAcl := utils.ContextKeyParsedAcl.Get(ctx).(auth.ACL)
err := utils.ValidateNoACLHeaders(ctx)
err := utils.ValidateNoACLHeaders(ctx, c.disableACL)
if err != nil {
return &Response{
MetaOpts: &MetaOptions{
@@ -177,6 +179,7 @@ func (c S3ApiController) CreateMultipartUpload(ctx *fiber.Ctx) (*Response, error
Bucket: bucket,
Object: key,
Action: auth.PutObjectAction,
DisableACL: c.disableACL,
})
if err != nil {
return &Response{
@@ -261,6 +264,7 @@ func (c S3ApiController) CompleteMultipartUpload(ctx *fiber.Ctx) (*Response, err
Object: key,
Action: auth.PutObjectAction,
IsPublicRequest: isBucketPublic,
DisableACL: c.disableACL,
})
if err != nil {
return &Response{
+8 -2
View File
@@ -57,6 +57,7 @@ func (c S3ApiController) PutObjectTagging(ctx *fiber.Ctx) (*Response, error) {
Object: key,
Action: action,
IsPublicRequest: IsBucketPublic,
DisableACL: c.disableACL,
})
if err != nil {
return &Response{
@@ -116,6 +117,7 @@ func (c S3ApiController) PutObjectRetention(ctx *fiber.Ctx) (*Response, error) {
Object: key,
Action: auth.PutObjectRetentionAction,
IsPublicRequest: IsBucketPublic,
DisableACL: c.disableACL,
})
if err != nil {
return &Response{
@@ -191,6 +193,7 @@ func (c S3ApiController) PutObjectLegalHold(ctx *fiber.Ctx) (*Response, error) {
Object: key,
Action: auth.PutObjectLegalHoldAction,
IsPublicRequest: IsBucketPublic,
DisableACL: c.disableACL,
})
if err != nil {
return &Response{
@@ -269,6 +272,7 @@ func (c S3ApiController) UploadPart(ctx *fiber.Ctx) (*Response, error) {
Object: key,
Action: auth.PutObjectAction,
IsPublicRequest: IsBucketPublic,
DisableACL: c.disableACL,
})
if err != nil {
return &Response{
@@ -383,6 +387,7 @@ func (c S3ApiController) UploadPartCopy(ctx *fiber.Ctx) (*Response, error) {
Object: key,
Action: auth.PutObjectAction,
IsPublicRequest: IsBucketPublic,
DisableACL: c.disableACL,
})
if err != nil {
return &Response{
@@ -510,7 +515,7 @@ func (c S3ApiController) CopyObject(ctx *fiber.Ctx) (*Response, error) {
isRoot := utils.ContextKeyIsRoot.Get(ctx).(bool)
parsedAcl := utils.ContextKeyParsedAcl.Get(ctx).(auth.ACL)
err := utils.ValidateNoACLHeaders(ctx)
err := utils.ValidateNoACLHeaders(ctx, c.disableACL)
if err != nil {
return &Response{
MetaOpts: &MetaOptions{
@@ -668,7 +673,7 @@ func (c S3ApiController) PutObject(ctx *fiber.Ctx) (*Response, error) {
parsedAcl := utils.ContextKeyParsedAcl.Get(ctx).(auth.ACL)
IsBucketPublic := utils.ContextKeyPublicBucket.IsSet(ctx)
err := utils.ValidateNoACLHeaders(ctx)
err := utils.ValidateNoACLHeaders(ctx, c.disableACL)
if err != nil {
return &Response{
MetaOpts: &MetaOptions{
@@ -703,6 +708,7 @@ func (c S3ApiController) PutObject(ctx *fiber.Ctx) (*Response, error) {
Object: key,
Action: auth.PutObjectAction,
IsPublicRequest: IsBucketPublic,
DisableACL: c.disableACL,
})
if err != nil {
return &Response{
+498 -480
View File
File diff suppressed because it is too large Load Diff
+49 -99
View File
@@ -24,7 +24,6 @@ import (
"github.com/gofiber/fiber/v2"
"github.com/versity/versitygw/auth"
"github.com/versity/versitygw/backend"
"github.com/versity/versitygw/s3api/middlewares"
"github.com/versity/versitygw/s3err"
)
@@ -60,20 +59,13 @@ func TestS3ApiRouter_ListBuckets_DefaultCORSAllowOrigin(t *testing.T) {
origin := "https://example.com"
app := fiber.New()
(&S3ApiRouter{}).Init(
app,
backend.BackendUnsupported{},
&auth.IAMServiceInternal{},
nil,
nil,
nil,
nil,
false,
"us-east-1",
"",
middlewares.RootUserConfig{},
origin,
)
(&S3ApiRouter{
app: app,
be: backend.BackendUnsupported{},
iam: &auth.IAMServiceInternal{},
region: "us-east-1",
corsAllowOrigin: origin,
}).Init()
req, err := http.NewRequest(http.MethodGet, "/", nil)
if err != nil {
@@ -97,20 +89,13 @@ func TestS3ApiRouter_ListBuckets_OptionsPreflight_DefaultCORS(t *testing.T) {
origin := "https://example.com"
app := fiber.New()
(&S3ApiRouter{}).Init(
app,
backend.BackendUnsupported{},
&auth.IAMServiceInternal{},
nil,
nil,
nil,
nil,
false,
"us-east-1",
"",
middlewares.RootUserConfig{},
origin,
)
(&S3ApiRouter{
app: app,
be: backend.BackendUnsupported{},
iam: &auth.IAMServiceInternal{},
region: "us-east-1",
corsAllowOrigin: origin,
}).Init()
req, err := http.NewRequest(http.MethodOptions, "/", nil)
if err != nil {
@@ -137,20 +122,13 @@ func TestS3ApiRouter_PutBucketTagging_ErrorStillIncludesFallbackCORS(t *testing.
origin := "http://127.0.0.1:9090"
app := fiber.New()
(&S3ApiRouter{}).Init(
app,
backendWithCorsOnly{},
&auth.IAMServiceInternal{},
nil,
nil,
nil,
nil,
false,
"us-east-1",
"",
middlewares.RootUserConfig{},
origin,
)
(&S3ApiRouter{
app: app,
be: backendWithCorsOnly{},
iam: &auth.IAMServiceInternal{},
region: "us-east-1",
corsAllowOrigin: origin,
}).Init()
req, err := http.NewRequest(http.MethodPut, "/testing?tagging", nil)
if err != nil {
@@ -172,20 +150,13 @@ func TestS3ApiRouter_PutObjectTagging_ErrorStillIncludesFallbackCORS(t *testing.
origin := "http://127.0.0.1:9090"
app := fiber.New()
(&S3ApiRouter{}).Init(
app,
backendWithCorsOnly{},
&auth.IAMServiceInternal{},
nil,
nil,
nil,
nil,
false,
"us-east-1",
"",
middlewares.RootUserConfig{},
origin,
)
(&S3ApiRouter{
app: app,
be: backendWithCorsOnly{},
iam: &auth.IAMServiceInternal{},
region: "us-east-1",
corsAllowOrigin: origin,
}).Init()
req, err := http.NewRequest(http.MethodPut, "/testing/myobj?tagging", nil)
if err != nil {
@@ -207,20 +178,13 @@ func TestS3ApiRouter_CopyObject_ErrorStillIncludesFallbackCORS(t *testing.T) {
origin := "http://127.0.0.1:9090"
app := fiber.New()
(&S3ApiRouter{}).Init(
app,
backendWithCorsOnly{},
&auth.IAMServiceInternal{},
nil,
nil,
nil,
nil,
false,
"us-east-1",
"",
middlewares.RootUserConfig{},
origin,
)
(&S3ApiRouter{
app: app,
be: backendWithCorsOnly{},
iam: &auth.IAMServiceInternal{},
region: "us-east-1",
corsAllowOrigin: origin,
}).Init()
req, err := http.NewRequest(http.MethodPut, "/testing/myobj", nil)
if err != nil {
@@ -243,20 +207,13 @@ func TestS3ApiRouter_PutObject_ErrorStillIncludesFallbackCORS(t *testing.T) {
origin := "http://127.0.0.1:9090"
app := fiber.New()
(&S3ApiRouter{}).Init(
app,
backendWithCorsOnly{},
&auth.IAMServiceInternal{},
nil,
nil,
nil,
nil,
false,
"us-east-1",
"",
middlewares.RootUserConfig{},
origin,
)
(&S3ApiRouter{
app: app,
be: backendWithCorsOnly{},
iam: &auth.IAMServiceInternal{},
region: "us-east-1",
corsAllowOrigin: origin,
}).Init()
req, err := http.NewRequest(http.MethodPut, "/testing/myobj", nil)
if err != nil {
@@ -294,20 +251,13 @@ func TestS3ApiRouter_OptionsWithBucketCORS_NoDuplicateHeaders(t *testing.T) {
</CORSConfiguration>`)
app := fiber.New()
(&S3ApiRouter{}).Init(
app,
backendWithBucketCors{corsConfig: corsConfig},
&auth.IAMServiceInternal{},
nil,
nil,
nil,
nil,
false,
"us-east-1",
"",
middlewares.RootUserConfig{},
fallbackOrigin,
)
(&S3ApiRouter{
app: app,
be: backendWithBucketCors{corsConfig: corsConfig},
iam: &auth.IAMServiceInternal{},
region: "us-east-1",
corsAllowOrigin: fallbackOrigin,
}).Init()
req, err := http.NewRequest(http.MethodOptions, "/xyz/upload/test.txt", nil)
if err != nil {
+2 -10
View File
@@ -20,24 +20,16 @@ import (
"github.com/gofiber/fiber/v2"
"github.com/versity/versitygw/auth"
"github.com/versity/versitygw/backend"
"github.com/versity/versitygw/s3api/middlewares"
)
func TestS3ApiRouter_Init(t *testing.T) {
type args struct {
app *fiber.App
be backend.Backend
iam auth.IAMService
}
tests := []struct {
name string
sa *S3ApiRouter
args args
}{
{
name: "Initialize S3 api router",
sa: &S3ApiRouter{},
args: args{
sa: &S3ApiRouter{
app: fiber.New(),
be: backend.BackendUnsupported{},
iam: &auth.IAMServiceInternal{},
@@ -46,7 +38,7 @@ func TestS3ApiRouter_Init(t *testing.T) {
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
tt.sa.Init(tt.args.app, tt.args.be, tt.args.iam, nil, nil, nil, nil, false, "us-east-1", "", middlewares.RootUserConfig{}, "")
tt.sa.Init()
})
}
}
+32 -22
View File
@@ -42,18 +42,15 @@ const (
)
type S3ApiServer struct {
Router *S3ApiRouter
app *fiber.App
backend backend.Backend
CertStorage *utils.CertStorage
quiet bool
readonly bool
keepAlive bool
health string
virtualDomain string
corsAllowOrigin string
maxConnections int
maxRequests int
Router *S3ApiRouter
app *fiber.App
backend backend.Backend
CertStorage *utils.CertStorage
quiet bool
keepAlive bool
health string
maxConnections int
maxRequests int
}
func New(
@@ -69,7 +66,16 @@ func New(
) (*S3ApiServer, error) {
server := &S3ApiServer{
backend: be,
Router: new(S3ApiRouter),
Router: &S3ApiRouter{
be: be,
iam: iam,
logger: l,
aLogger: adminLogger,
evs: evs,
mm: mm,
root: root,
region: region,
},
}
for _, opt := range opts {
@@ -88,6 +94,7 @@ func New(
})
server.app = app
server.Router.app = app
// initialize the panic recovery middleware
app.Use(recover.New(
@@ -119,17 +126,12 @@ func New(
// path unescapes the url
app.Use(controllers.WrapMiddleware(middlewares.DecodeURL, l, mm))
// initialize host-style parser in virtual domain is specified
if server.virtualDomain != "" {
app.Use(middlewares.HostStyleParser(server.virtualDomain))
}
// initialize the debug logger in debug mode
if debuglogger.IsDebugEnabled() {
app.Use(middlewares.DebugLogger())
}
server.Router.Init(app, be, iam, l, adminLogger, evs, mm, server.readonly, region, server.virtualDomain, root, server.corsAllowOrigin)
server.Router.Init()
return server, nil
}
@@ -158,12 +160,14 @@ func WithHealth(health string) Option {
}
func WithReadOnly() Option {
return func(s *S3ApiServer) { s.readonly = true }
return func(s *S3ApiServer) { s.Router.readonly = true }
}
// WithHostStyle enabled host-style bucket addressing on the server
func WithHostStyle(virtualDomain string) Option {
return func(s *S3ApiServer) { s.virtualDomain = virtualDomain }
return func(s *S3ApiServer) {
s.Router.virtualDomain = virtualDomain
}
}
// WithKeepAlive enables the server keep alive
@@ -174,7 +178,7 @@ func WithKeepAlive() Option {
// WithCORSAllowOrigin sets the default CORS Access-Control-Allow-Origin value.
// This is applied when no bucket CORS configuration exists, and for admin APIs.
func WithCORSAllowOrigin(origin string) Option {
return func(s *S3ApiServer) { s.corsAllowOrigin = origin }
return func(s *S3ApiServer) { s.Router.corsAllowOrigin = origin }
}
// WithConcurrencyLimiter sets the server's maximum connection limit
@@ -186,6 +190,12 @@ func WithConcurrencyLimiter(maxConnections, maxRequests int) Option {
}
}
// WithDisableACL disables the s3 api server ACLs, by ignoring all
// bucket/object ACL headers
func WithDisableACL() Option {
return func(s *S3ApiServer) { s.Router.disableACL = true }
}
// ServeMultiPort creates listeners for multiple port specifications and serves
// on all of them simultaneously. This supports listening on multiple ports and/or
// addresses (e.g., [":7070", "localhost:8080", "0.0.0.0:9090"]).
+5 -1
View File
@@ -1011,7 +1011,11 @@ func NewTLSListener(network string, address string, getCertificateFunc func(*tls
// since ACL operations are not supported on objects, the presence of any ACL headers
// results in a NotImplemented error. It returns nil only when all ACL headers
// are absent.
func ValidateNoACLHeaders(ctx *fiber.Ctx) error {
func ValidateNoACLHeaders(ctx *fiber.Ctx, disableACL bool) error {
if disableACL {
return nil
}
for _, header := range []string{
"x-amz-acl",
"x-amz-grant-full-control",
+6
View File
@@ -193,6 +193,7 @@ const (
ErrDirectoryNotEmpty
ErrQuotaExceeded
ErrVersioningNotConfigured
ErrACLsDisabled
// Admin api errors
ErrAdminAccessDenied
@@ -867,6 +868,11 @@ var errorCodeResponse = map[ErrorCode]APIError{
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,
},
// Admin api errors
ErrAdminAccessDenied: {
+17
View File
@@ -1171,6 +1171,16 @@ func TestSignedStreaminPayloadTrailer(ts *TestState) {
}
}
func TestNoAclMode(ts *TestState) {
ts.Run(NoAclMode_CreateBucket_with_acl)
ts.Run(NoAclMode_PutObject_with_acl)
ts.Run(NoAclMode_CopyObject_with_acl)
ts.Run(NoAclMode_multipart_upload_with_acl)
ts.Run(NoAclMode_PutBucketAcl)
ts.Run(NoAclMode_PutObjectAcl_not_implemented)
ts.Run(NoAclMode_GetObjectAcl_not_implemented)
}
type IntTest func(s3 *S3Conf) error
type IntTests map[string]IntTest
@@ -1867,5 +1877,12 @@ func GetIntTests() IntTests {
"SignedStreamingPayloadTrailer_invalid_checksum": SignedStreamingPayloadTrailer_invalid_checksum,
"SignedStreamingPayloadTrailer_bad_digest": SignedStreamingPayloadTrailer_bad_digest,
"SignedStreamingPayloadTrailer_success": SignedStreamingPayloadTrailer_success,
"NoAclMode_CreateBucket_with_acl": NoAclMode_CreateBucket_with_acl,
"NoAclMode_PutObject_with_acl": NoAclMode_PutObject_with_acl,
"NoAclMode_CopyObject_with_acl": NoAclMode_CopyObject_with_acl,
"NoAclMode_multipart_upload_with_acl": NoAclMode_multipart_upload_with_acl,
"NoAclMode_PutBucketAcl": NoAclMode_PutBucketAcl,
"NoAclMode_PutObjectAcl_not_implemented": NoAclMode_PutObjectAcl_not_implemented,
"NoAclMode_GetObjectAcl_not_implemented": NoAclMode_GetObjectAcl_not_implemented,
}
}
+245
View File
@@ -0,0 +1,245 @@
// Copyright 2026 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"
"strings"
"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 NoAclMode_CreateBucket_with_acl(s *S3Conf) error {
testName := "NoAclMode_CreateBucket_with_acl"
return actionHandlerNoSetup(s, testName, func(s3client *s3.Client, bucket string) error {
u := getUser("user")
err := createUsers(s, []user{u})
if err != nil {
return err
}
ctx, cancel := context.WithTimeout(context.Background(), shortTimeout)
_, err = s3client.CreateBucket(ctx, &s3.CreateBucketInput{
Bucket: &bucket,
ACL: types.BucketCannedACLPublicReadWrite,
GrantFullControl: &u.access,
GrantRead: &u.access,
GrantReadACP: &u.access,
GrantWrite: &u.access,
GrantWriteACP: &u.access,
})
cancel()
if err != nil {
return err
}
ctx, cancel = context.WithTimeout(context.Background(), shortTimeout)
out, err := s3client.GetBucketAcl(ctx, &s3.GetBucketAclInput{
Bucket: &bucket,
})
cancel()
if err != nil {
return err
}
if getString(out.Owner.ID) != s.awsID {
return fmt.Errorf("expected bucket owner to be %v, instead got %v",
s.awsID, getString(out.Owner.ID))
}
if len(out.Grants) != 1 {
return fmt.Errorf("expected grants length to be 1, instead got %v",
len(out.Grants))
}
grt := out.Grants[0]
if grt.Permission != types.PermissionFullControl {
return fmt.Errorf("expected the grantee to have full-control permission, instead got %v",
grt.Permission)
}
if getString(grt.Grantee.ID) != s.awsID {
return fmt.Errorf("expected the grantee id to be %v, instead got %v",
s.awsID, getString(grt.Grantee.ID))
}
return teardown(s, bucket)
})
}
func NoAclMode_PutObject_with_acl(s *S3Conf) error {
testName := "NoAclMode_PutObject_with_acl"
return actionHandler(s, testName, func(s3client *s3.Client, bucket string) error {
obj := "my-object"
u := getUser("user")
err := createUsers(s, []user{u})
if err != nil {
return err
}
ctx, cancel := context.WithTimeout(context.Background(), shortTimeout)
_, err = s3client.PutObject(ctx, &s3.PutObjectInput{
Bucket: &bucket,
Key: &obj,
ACL: types.ObjectCannedACLBucketOwnerFullControl,
GrantFullControl: &u.access,
GrantRead: &u.access,
GrantReadACP: &u.access,
GrantWriteACP: &u.access,
Body: strings.NewReader("dummy data"),
})
cancel()
return err
})
}
func NoAclMode_CopyObject_with_acl(s *S3Conf) error {
testName := "NoAclMode_CopyObject_with_acl"
return actionHandler(s, testName, func(s3client *s3.Client, bucket string) error {
u := getUser("user")
err := createUsers(s, []user{u})
if err != nil {
return err
}
srcObj, dstObj := "source-object", "destination-object"
_, err = putObjectWithData(10, &s3.PutObjectInput{
Bucket: &bucket,
Key: &srcObj,
ACL: types.ObjectCannedACLAuthenticatedRead,
}, s3client)
if err != nil {
return err
}
ctx, cancel := context.WithTimeout(context.Background(), shortTimeout)
_, err = s3client.CopyObject(ctx, &s3.CopyObjectInput{
Bucket: &bucket,
Key: &dstObj,
CopySource: getPtr(fmt.Sprintf("%s/%s", bucket, srcObj)),
})
cancel()
return err
})
}
func NoAclMode_multipart_upload_with_acl(s *S3Conf) error {
testName := "NoAclMode_CreateMultipartUpload_with_acl"
return actionHandler(s, testName, func(s3client *s3.Client, bucket string) error {
obj := "my-object"
ctx, cancel := context.WithTimeout(context.Background(), shortTimeout)
mp, err := s3client.CreateMultipartUpload(ctx, &s3.CreateMultipartUploadInput{
Bucket: &bucket,
Key: &obj,
ACL: types.ObjectCannedACLAuthenticatedRead,
GrantFullControl: getPtr("non_existing_user_1"),
GrantRead: getPtr("non_existing_user_2"),
GrantReadACP: getPtr("non_existing_user_3"),
GrantWriteACP: getPtr("non_existing_user_4"),
})
cancel()
if err != nil {
return err
}
parts, _, err := uploadParts(s3client, 100, 1, bucket, obj, *mp.UploadId)
if err != nil {
return err
}
compParts := []types.CompletedPart{}
for _, el := range parts {
compParts = append(compParts, types.CompletedPart{
ETag: el.ETag,
PartNumber: el.PartNumber,
})
}
ctx, cancel = context.WithTimeout(context.Background(), shortTimeout)
_, err = s3client.CompleteMultipartUpload(ctx, &s3.CompleteMultipartUploadInput{
Bucket: &bucket,
Key: &obj,
UploadId: mp.UploadId,
MultipartUpload: &types.CompletedMultipartUpload{
Parts: compParts,
},
})
cancel()
return err
})
}
func NoAclMode_PutBucketAcl(s *S3Conf) error {
testName := "NoAclMode_PutBucketAcl"
return actionHandler(s, testName, func(s3client *s3.Client, bucket string) error {
ctx, cancel := context.WithTimeout(context.Background(), shortTimeout)
_, err := s3client.PutBucketAcl(ctx, &s3.PutBucketAclInput{
Bucket: &bucket,
ACL: types.BucketCannedACLPrivate,
})
cancel()
return checkApiErr(err, s3err.GetAPIError(s3err.ErrACLsDisabled))
})
}
func NoAclMode_PutObjectAcl_not_implemented(s *S3Conf) error {
testName := "NoAclMode_PutObjectAcl_not_implemented"
return actionHandler(s, testName, func(s3client *s3.Client, bucket string) error {
obj := "my-object"
_, err := putObjectWithData(10, &s3.PutObjectInput{
Bucket: &bucket,
Key: &obj,
}, s3client)
if err != nil {
return err
}
ctx, cancel := context.WithTimeout(context.Background(), shortTimeout)
_, err = s3client.PutObjectAcl(ctx, &s3.PutObjectAclInput{
Bucket: &bucket,
Key: &obj,
ACL: types.ObjectCannedACLAuthenticatedRead,
})
cancel()
return checkApiErr(err, s3err.GetAPIError(s3err.ErrNotImplemented))
})
}
func NoAclMode_GetObjectAcl_not_implemented(s *S3Conf) error {
testName := "NoAclMode_GetObjectAcl_not_implemented"
return actionHandler(s, testName, func(s3client *s3.Client, bucket string) error {
obj := "my-object"
_, err := putObjectWithData(10, &s3.PutObjectInput{
Bucket: &bucket,
Key: &obj,
}, s3client)
if err != nil {
return err
}
ctx, cancel := context.WithTimeout(context.Background(), shortTimeout)
_, err = s3client.GetObjectAcl(ctx, &s3.GetObjectAclInput{
Bucket: &bucket,
Key: &obj,
})
cancel()
return checkApiErr(err, s3err.GetAPIError(s3err.ErrNotImplemented))
})
}