mirror of
https://github.com/versity/versitygw.git
synced 2026-09-22 07:54:14 +00:00
Closes #2294 Support AWS's conditional-write condition keys in both S3 bucket policies and IAM identity policies, so a policy can require that an upload or delete carry an `If-Match`/`If-None-Match` precondition. `requestConditionContext` now populates both keys from the request headers, which covers both policy types at once: the same map is evaluated in-process for bucket policies and shipped to the IAM service for identity policies. Only the three requests whose preconditions the gateway actually enforces contribute - PutObject, CompleteMultipartUpload and DeleteObject. Copies, form uploads, DeleteObjects batches, upload parts and the sub-resource writes all ignore the headers, and a policy must never grant on a precondition that won't be checked; reads are excluded for the same reason, since GET and HEAD take these headers as ordinary HTTP cache preconditions. The value is the ETag with its surrounding quotes stripped, matching what the gateway enforces against. Bucket policies validate condition keys against a fixed catalogue at `PutBucketPolicy` time, so both keys are added there with the action sets AWS accepts: `s3:if-match` on `s3:PutObject` and `s3:DeleteObject`, `s3:if-none-match` on `s3:PutObject` alone. Identity policies validate only the operator vocabulary, matching AWS, so they need no change.
305 lines
9.2 KiB
Go
305 lines
9.2 KiB
Go
// 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 auth
|
|
|
|
import (
|
|
"testing"
|
|
|
|
"github.com/gofiber/fiber/v3"
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/valyala/fasthttp"
|
|
)
|
|
|
|
func TestRequestConditionContextConditionalWriteKeys(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
method string
|
|
ifMatch string
|
|
ifNoneMatch string
|
|
copySource string
|
|
// uri defaults to an object path when empty.
|
|
uri string
|
|
query string
|
|
actions []Action
|
|
want map[string][]string
|
|
}{
|
|
{
|
|
name: "no conditional headers",
|
|
method: fiber.MethodPut,
|
|
actions: []Action{PutObjectAction},
|
|
want: map[string][]string{},
|
|
},
|
|
{
|
|
name: "If-Match quotes are stripped",
|
|
method: fiber.MethodPut,
|
|
ifMatch: `"abc123"`,
|
|
actions: []Action{PutObjectAction},
|
|
want: map[string][]string{"s3:if-match": {"abc123"}},
|
|
},
|
|
{
|
|
// Both wire forms land on the same context value, so a policy
|
|
// always compares against the bare ETag.
|
|
name: "unquoted If-Match is taken as-is",
|
|
method: fiber.MethodPut,
|
|
ifMatch: "abc123",
|
|
actions: []Action{PutObjectAction},
|
|
want: map[string][]string{"s3:if-match": {"abc123"}},
|
|
},
|
|
{
|
|
// A multi-ETag If-Match is one value, not a list: the gateway
|
|
// compares the whole header against the object's ETag, and the
|
|
// key carries exactly what that comparison sees.
|
|
name: "a multi-ETag If-Match is one value",
|
|
method: fiber.MethodPut,
|
|
ifMatch: `"abc123", "def456"`,
|
|
actions: []Action{PutObjectAction},
|
|
want: map[string][]string{"s3:if-match": {`abc123", "def456`}},
|
|
},
|
|
{
|
|
name: "If-None-Match wildcard",
|
|
method: fiber.MethodPut,
|
|
ifNoneMatch: "*",
|
|
actions: []Action{PutObjectAction},
|
|
want: map[string][]string{"s3:if-none-match": {"*"}},
|
|
},
|
|
{
|
|
name: "both headers on one request",
|
|
method: fiber.MethodPut,
|
|
ifMatch: `"abc123"`,
|
|
ifNoneMatch: "*",
|
|
actions: []Action{PutObjectAction},
|
|
want: map[string][]string{
|
|
"s3:if-match": {"abc123"},
|
|
"s3:if-none-match": {"*"},
|
|
},
|
|
},
|
|
{
|
|
// An upload carrying tagging or lock headers is authorized
|
|
// under those actions too; s3:PutObject is still among them.
|
|
name: "an upload authorized under several actions",
|
|
method: fiber.MethodPut,
|
|
ifMatch: `"abc123"`,
|
|
actions: []Action{PutObjectAction, PutObjectTaggingAction, PutObjectRetentionAction},
|
|
want: map[string][]string{"s3:if-match": {"abc123"}},
|
|
},
|
|
{
|
|
name: "CompleteMultipartUpload populates the keys",
|
|
method: fiber.MethodPost,
|
|
query: "uploadId=abc",
|
|
ifMatch: `"abc123"`,
|
|
actions: []Action{PutObjectAction},
|
|
want: map[string][]string{"s3:if-match": {"abc123"}},
|
|
},
|
|
{
|
|
name: "DeleteObject populates s3:if-match",
|
|
method: fiber.MethodDelete,
|
|
ifMatch: `"abc123"`,
|
|
actions: []Action{DeleteObjectAction},
|
|
want: map[string][]string{"s3:if-match": {"abc123"}},
|
|
},
|
|
{
|
|
// A delete is authorized as s3:DeleteObject, which
|
|
// s3:if-none-match doesn't apply to — DeleteObject reads no
|
|
// If-None-Match, so the header changes nothing.
|
|
name: "a delete populates no s3:if-none-match",
|
|
method: fiber.MethodDelete,
|
|
ifMatch: `"abc123"`,
|
|
ifNoneMatch: "*",
|
|
actions: []Action{DeleteObjectAction},
|
|
want: map[string][]string{"s3:if-match": {"abc123"}},
|
|
},
|
|
{
|
|
// A versioned delete is authorized as s3:DeleteObjectVersion,
|
|
// which neither key applies to, even though the gateway does
|
|
// check the precondition against the named version.
|
|
name: "a versioned delete populates neither key",
|
|
method: fiber.MethodDelete,
|
|
query: "versionId=v1",
|
|
ifMatch: `"abc123"`,
|
|
ifNoneMatch: "*",
|
|
actions: []Action{DeleteObjectVersionAction},
|
|
want: map[string][]string{},
|
|
},
|
|
{
|
|
// A bucket sub-resource write is a bare PUT carrying none of
|
|
// the object sub-resources, so only the action it authorizes
|
|
// under separates it from an upload.
|
|
name: "PutBucketVersioning populates neither key",
|
|
method: fiber.MethodPut,
|
|
uri: "/bucket",
|
|
query: "versioning=",
|
|
ifMatch: `"abc123"`,
|
|
actions: []Action{PutBucketVersioningAction},
|
|
want: map[string][]string{},
|
|
},
|
|
{
|
|
name: "PutBucketPolicy populates neither key",
|
|
method: fiber.MethodPut,
|
|
uri: "/bucket",
|
|
query: "policy=",
|
|
ifMatch: `"abc123"`,
|
|
ifNoneMatch: "*",
|
|
actions: []Action{PutBucketPolicyAction},
|
|
want: map[string][]string{},
|
|
},
|
|
{
|
|
name: "CreateBucket populates neither key",
|
|
method: fiber.MethodPut,
|
|
uri: "/bucket",
|
|
ifMatch: `"abc123"`,
|
|
ifNoneMatch: "*",
|
|
actions: []Action{CreateBucketAction},
|
|
want: map[string][]string{},
|
|
},
|
|
{
|
|
name: "DeleteBucket populates neither key",
|
|
method: fiber.MethodDelete,
|
|
uri: "/bucket",
|
|
ifMatch: `"abc123"`,
|
|
actions: []Action{DeleteBucketAction},
|
|
want: map[string][]string{},
|
|
},
|
|
{
|
|
// The object-lock paths authorize s3:BypassGovernanceRetention,
|
|
// an action neither key applies to, on the very requests that
|
|
// do carry a precondition.
|
|
name: "a governance bypass populates neither key",
|
|
method: fiber.MethodPut,
|
|
ifMatch: `"abc123"`,
|
|
ifNoneMatch: "*",
|
|
actions: []Action{BypassGovernanceRetentionAction},
|
|
want: map[string][]string{},
|
|
},
|
|
{
|
|
name: "no actions populates neither key",
|
|
method: fiber.MethodPut,
|
|
ifMatch: `"abc123"`,
|
|
ifNoneMatch: "*",
|
|
want: map[string][]string{},
|
|
},
|
|
{
|
|
// A form upload and a DeleteObjects batch are POSTs that never
|
|
// look at the headers.
|
|
name: "a form upload populates neither key",
|
|
method: fiber.MethodPost,
|
|
ifMatch: `"abc123"`,
|
|
ifNoneMatch: "*",
|
|
actions: []Action{PutObjectAction},
|
|
want: map[string][]string{},
|
|
},
|
|
{
|
|
name: "a DeleteObjects batch populates neither key",
|
|
method: fiber.MethodPost,
|
|
uri: "/bucket",
|
|
query: "delete=",
|
|
ifMatch: `"abc123"`,
|
|
actions: []Action{DeleteObjectAction},
|
|
want: map[string][]string{},
|
|
},
|
|
{
|
|
name: "an upload part populates neither key",
|
|
method: fiber.MethodPut,
|
|
query: "partNumber=1&uploadId=abc",
|
|
ifMatch: `"abc123"`,
|
|
actions: []Action{PutObjectAction},
|
|
want: map[string][]string{},
|
|
},
|
|
{
|
|
name: "a tagging write populates neither key",
|
|
method: fiber.MethodPut,
|
|
query: "tagging=",
|
|
ifMatch: `"abc123"`,
|
|
actions: []Action{PutObjectTaggingAction},
|
|
want: map[string][]string{},
|
|
},
|
|
{
|
|
name: "an ACL write populates neither key",
|
|
method: fiber.MethodPut,
|
|
query: "acl=",
|
|
ifMatch: `"abc123"`,
|
|
actions: []Action{PutObjectAclAction},
|
|
want: map[string][]string{},
|
|
},
|
|
{
|
|
// On a read the same headers are ordinary HTTP cache
|
|
// preconditions, not conditional writes.
|
|
name: "GET populates neither key",
|
|
method: fiber.MethodGet,
|
|
ifMatch: `"abc123"`,
|
|
ifNoneMatch: `"abc123"`,
|
|
actions: []Action{GetObjectAction},
|
|
want: map[string][]string{},
|
|
},
|
|
{
|
|
name: "HEAD populates neither key",
|
|
method: fiber.MethodHead,
|
|
ifMatch: `"abc123"`,
|
|
ifNoneMatch: `"abc123"`,
|
|
actions: []Action{GetObjectAction},
|
|
want: map[string][]string{},
|
|
},
|
|
{
|
|
// A copy takes its preconditions from the
|
|
// X-Amz-Copy-Source-If-* headers, so a plain one is ignored.
|
|
// It is authorized as s3:PutObject like any other upload, so
|
|
// only the copy-source header separates the two.
|
|
name: "a copy populates neither key",
|
|
method: fiber.MethodPut,
|
|
ifMatch: `"abc123"`,
|
|
ifNoneMatch: "*",
|
|
copySource: "/src-bucket/src-key",
|
|
actions: []Action{PutObjectAction},
|
|
want: map[string][]string{},
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
app := fiber.New()
|
|
fctx := &fasthttp.RequestCtx{}
|
|
fctx.Request.Header.SetMethod(tt.method)
|
|
uri := tt.uri
|
|
if uri == "" {
|
|
uri = "/bucket/object"
|
|
}
|
|
if tt.query != "" {
|
|
uri += "?" + tt.query
|
|
}
|
|
fctx.Request.SetRequestURI(uri)
|
|
if tt.ifMatch != "" {
|
|
fctx.Request.Header.Set("If-Match", tt.ifMatch)
|
|
}
|
|
if tt.ifNoneMatch != "" {
|
|
fctx.Request.Header.Set("If-None-Match", tt.ifNoneMatch)
|
|
}
|
|
if tt.copySource != "" {
|
|
fctx.Request.Header.Set("X-Amz-Copy-Source", tt.copySource)
|
|
}
|
|
|
|
ctx := app.AcquireCtx(fctx)
|
|
defer app.ReleaseCtx(ctx)
|
|
|
|
condCtx := requestConditionContext(ctx, tt.actions)
|
|
for _, key := range []string{"s3:if-match", "s3:if-none-match"} {
|
|
if want, ok := tt.want[key]; ok {
|
|
assert.Equal(t, want, condCtx[key])
|
|
continue
|
|
}
|
|
assert.NotContains(t, condCtx, key)
|
|
}
|
|
})
|
|
}
|
|
}
|