Files
versitygw/auth/condition_context.go
T
niksis02 23d8384ec0 feat: add s3:if-match and s3:if-none-match policy condition keys
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.
2026-09-09 17:18:42 +04:00

185 lines
7.5 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 (
"slices"
"strconv"
"time"
"github.com/gofiber/fiber/v3"
)
// requestConditionContext builds the IAM policy-condition keys describing
// this request — aws:SourceIp, aws:SecureTransport, aws:CurrentTime and
// friends — for identity-policy and bucket-policy Condition blocks to
// evaluate against. The identity-derived keys (aws:PrincipalArn,
// aws:username, aws:PrincipalTag/*, …) are deliberately absent: the S3
// gateway has no way to know them, so the IAM service fills them in itself
// when it evaluates an identity policy.
//
// actions is the action set this request is being authorized under, used by
// the keys whose applicability AWS defines per action. Pass nil where no
// such key can apply.
func requestConditionContext(ctx fiber.Ctx, actions []Action) map[string][]string {
now := time.Now().UTC()
condCtx := map[string][]string{
"aws:CurrentTime": {now.Format(time.RFC3339)},
"aws:EpochTime": {strconv.FormatInt(now.Unix(), 10)},
"aws:SecureTransport": {strconv.FormatBool(ctx.Secure())},
}
// ctx.IP() is the real peer address: the gateway's fiber app configures
// neither ProxyHeader nor TrustProxy, so no client-supplied header can
// influence it. Adding either for logging would make aws:SourceIp
// client-controlled — revisit this if that ever changes.
if ip := ctx.IP(); ip != "" {
condCtx["aws:SourceIp"] = []string{ip}
}
if ua := ctx.Get("User-Agent"); ua != "" {
condCtx["aws:UserAgent"] = []string{ua}
}
if ref := ctx.Get("Referer"); ref != "" {
condCtx["aws:Referer"] = []string{ref}
}
if prefix := ctx.Query("prefix"); prefix != "" {
condCtx["s3:prefix"] = []string{prefix}
}
if delim := ctx.Query("delimiter"); delim != "" {
condCtx["s3:delimiter"] = []string{delim}
}
if maxKeys := ctx.Query("max-keys"); maxKeys != "" {
condCtx["s3:max-keys"] = []string{maxKeys}
}
if acl := ctx.Get("X-Amz-Acl"); acl != "" {
condCtx["s3:x-amz-acl"] = []string{acl}
}
if versionID := ctx.Query("versionId"); versionID != "" {
condCtx["s3:VersionId"] = []string{versionID}
}
addConditionalWriteKeys(ctx, actions, condCtx)
return condCtx
}
// addConditionalWriteKeys populates s3:if-match and s3:if-none-match from
// the request's If-Match/If-None-Match headers, each only on a request
// whose action the key applies to. The applicability rules are the ones
// PutBucketPolicy validates a Condition against, so a key can never reach
// the request context on an action a policy isn't allowed to name it on.
// The value is the header with its surrounding ETag quotes removed, so a
// policy compares against the bare ETag whichever form the client sent.
func addConditionalWriteKeys(ctx fiber.Ctx, actions []Action, condCtx map[string][]string) {
action := conditionalWriteAction(ctx, actions)
if isConditionalWriteAction(action) {
if ifMatch := trimETagQuotes(ctx.Get("If-Match")); ifMatch != "" {
condCtx["s3:if-match"] = []string{ifMatch}
}
}
if isConditionalCreateAction(action) {
if ifNoneMatch := trimETagQuotes(ctx.Get("If-None-Match")); ifNoneMatch != "" {
condCtx["s3:if-none-match"] = []string{ifNoneMatch}
}
}
}
// nonConditionalWriteSubresources names the query parameters that route an
// object PUT or DELETE to a handler other than PutObject/DeleteObject:
// tagging, retention, legal-hold and ACL writes, plus UploadPart and
// AbortMultipartUpload. UploadPart and UploadPartCopy are authorized as
// s3:PutObject just like PutObject itself, so only the route tells them
// apart.
var nonConditionalWriteSubresources = []string{"acl", "tagging", "retention", "legal-hold", "uploadId"}
// conditionalWriteAction reports the action ctx is authorized under, for
// the requests whose If-Match/If-None-Match the gateway enforces: PutObject
// and CompleteMultipartUpload, both authorized as s3:PutObject, and
// DeleteObject, which a versionId turns into s3:DeleteObjectVersion exactly
// as the handler does. It returns the empty action for everything else.
//
// Everything else ignores those headers, and a policy must never grant on a
// precondition that won't be checked — otherwise a form upload, a
// DeleteObjects batch, a copy or an upload part could satisfy a statement
// demanding a conditional write by sending a header that changes nothing.
// Reads are the same case: GET and HEAD take these headers as ordinary HTTP
// cache preconditions. Excluding a request leaves both keys absent, which
// denies it under such a policy rather than letting it through.
//
// The request shape alone doesn't identify an object write: a bucket
// sub-resource write is a PUT or DELETE carrying none of the object
// sub-resources, so it has to be recognized by what it is authorized as.
// actions is that set, and the shape's action must be in it — the two
// disagree exactly when the request routes somewhere else, as
// PutBucketVersioning, CreateBucket and DeleteBucket all do.
func conditionalWriteAction(ctx fiber.Ctx, actions []Action) Action {
action := conditionalWriteRouteAction(ctx)
if action == "" || !slices.Contains(actions, action) {
return ""
}
return action
}
// conditionalWriteRouteAction is conditionalWriteAction's request-shape
// half: the action this method, query and copy-source header would route
// to, before checking what the request is actually authorized as.
func conditionalWriteRouteAction(ctx fiber.Ctx) Action {
// A copy carries its preconditions in the X-Amz-Copy-Source-If-*
// headers, which name the source object and populate neither key. Both
// a copy and a plain upload are authorized as s3:PutObject, so only
// this header tells them apart.
if ctx.Get("X-Amz-Copy-Source") != "" {
return ""
}
query := ctx.Request().URI().QueryArgs()
switch string(ctx.Request().Header.Method()) {
case fiber.MethodPut:
if slices.ContainsFunc(nonConditionalWriteSubresources, query.Has) {
return ""
}
return PutObjectAction
case fiber.MethodDelete:
if slices.ContainsFunc(nonConditionalWriteSubresources, query.Has) {
return ""
}
// A delete naming a version removes that version rather than
// overwriting the current one, so it is authorized as
// s3:DeleteObjectVersion — an action neither key applies to. The
// gateway still enforces the precondition against the named
// version; a policy simply has no vocabulary to require it there,
// and the key stays absent so such a statement denies instead.
if query.Has("versionId") {
return DeleteObjectVersionAction
}
return DeleteObjectAction
case fiber.MethodPost:
// CompleteMultipartUpload is the only POST that enforces them.
if query.Has("uploadId") {
return PutObjectAction
}
return ""
default:
return ""
}
}
// trimETagQuotes strips one leading and one trailing double quote from an
// ETag-valued header, leaving any other value (notably If-None-Match's "*")
// untouched.
func trimETagQuotes(s string) string {
if len(s) >= 2 && s[0] == '"' && s[len(s)-1] == '"' {
return s[1 : len(s)-1]
}
return s
}