feat(s3api): add bucket quota S3 extension via ?seaweedfs-quota (#11279)

* feat(s3api): add bucket quota S3 extension via ?seaweedfs-quota

Add a SeaweedFS-specific S3 subresource for bucket quota management:

  PUT /{bucket}?seaweedfs-quota   — set bucket quota (s3:PutBucketQuota)
  GET /{bucket}?seaweedfs-quota   — get bucket quota (s3:GetBucketQuota)

The request/response body is JSON:
  {"quota_size": 100, "quota_unit": "GB", "quota_enabled": true}

Quota is stored on the bucket's filer entry (positive = enabled,
negative = disabled but retained, zero = no quota), matching the
existing admin REST API behavior. When quota is cleared, the bucket's
read-only flag is also lifted.

Authentication uses the existing S3 SigV4 flow — no new global secret
is needed. Authorization uses two new dedicated IAM permissions:
  s3:PutBucketQuota
  s3:GetBucketQuota

This allows integrations like Apache CloudStack to manage per-bucket
quotas through the S3 endpoint with a scoped credential, without
exposing the broad admin REST API or requiring a separate admin token.
The credential can be limited to s3:PutBucketQuota/s3:GetBucketQuota
only, preventing bucket deletion, user management, or cluster topology
changes.

The coarse-grained ACTION_PUT_BUCKET_QUOTA/ACTION_GET_BUCKET_QUOTA
constants are added to s3_constants, and the action resolver maps the
seaweedfs-quota query parameter to the fine-grained s3: actions for
policy evaluation.

* docs: update design for S3 ?seaweedfs-quota extension approach

Replace the broad admin REST API + bearer-token design with the narrow,
scoped S3 ?seaweedfs-quota extension. Update quota, usage reporting, and
SeaweedFS-side changes sections to reflect PR #11279.

* fix(s3api): address review comments on quota handler

Fix four issues identified by Devin, Greptile, and CodeRabbit reviews:

1. Integer overflow in convertQuotaToBytes: large quota_size values
   (e.g. 8388608 TB) could overflow int64, wrapping to negative and
   being silently treated as zero quota. Now returns an error when
   size * multiplier would exceed math.MaxInt64.

2. Disabled quotas returned negative sizes in GET: the GET handler
   returned entry.Quota directly, which is negative for disabled-but-
   retained quotas. Now returns the absolute magnitude as quota_size
   and derives quota_enabled from the sign, making the response
   round-trippable.

3. Missing buckets returned 500 instead of NoSuchBucket: the PUT
   handler treated all lookup failures as internal errors. Now
   distinguishes filer_pb.ErrNotFound and returns ErrNoSuchBucket.

4. Trailing JSON was silently accepted: the decoder read only the
   first JSON object without checking for trailing data. Now
   requires EOF after the object, rejecting malformed payloads.

Also add tests for overflow detection and trailing data rejection.

* fix(s3api): cast math.MaxInt64 to int64 for 32-bit vet

On 32-bit platforms, math.MaxInt64 is an untyped int constant that
overflows int (32-bit) when used directly in fmt.Errorf with %d.
Cast to int64 explicitly to fix Go Vet 32-bit.

* docs: reconcile design doc with implementation and add AWS tools note

- Resolve open question about IAM endpoint path: driver accepts optional
  iamUrl and defaults to <s3Url>/iam
- Add note explaining ?seaweedfs-quota is not callable by standard AWS tools
  (aws s3api, s3cmd, rclone), and how this compares to MinIO and Ceph quota
  APIs which also live outside the standard S3 API

* docs: fix IAM endpoint default — SeaweedFS IAM is at POST / on S3 endpoint

SeaweedFS registers its embedded IAM API at POST / on the same S3
endpoint (UnifiedPostHandler), not under /iam. The design doc
previously said the driver defaults iamUrl to <s3Url>/iam, which would
send IAM operations to an unregistered path. Correct the default to
s3Url.

Found by Greptile review on PR #11279.

* docs: fix credential model, signer, and GET response shape in design doc

Three issues found by CodeRabbit review on PR #11279:

1. Credential-scope contradiction: the doc claimed the service credential
   is scoped to only s3:PutBucketQuota/s3:GetBucketQuota, but the
   implementation uses it as the admin credential for all operations
   (bucket CRUD, IAM user provisioning, quota). Document the actual
   model.

2. S3Signer -> AWSS3V4Signer: the doc said 'S3Signer for SigV4 signing'
   but S3Signer is legacy SigV2. Correct to AWSS3V4Signer.

3. GET response shape: the doc showed a single JSON example with 'GB'
   for both PUT and GET, but GET always returns quota_unit 'B' and the
   absolute byte count. Document PUT input and GET response separately.
This commit is contained in:
Chris Lu authored and GitHub committed 2026-09-11 22:17:30 -07:00
1 parent 79994b69af
commit 9f6feef299
7 files changed
+834

No files matched your search

+9
View File
@@ -116,6 +116,11 @@ var bucketQueryActions = map[string]map[string]string{
http.MethodPut: s3_constants.S3_ACTION_PUT_BUCKET_OWNERSHIP_CONTROLS,
http.MethodDelete: s3_constants.S3_ACTION_PUT_BUCKET_OWNERSHIP_CONTROLS, // DELETE uses same permission as PUT
},
// SeaweedFS extension: bucket quota subresource
"seaweedfs-quota": {
http.MethodGet: s3_constants.S3_ACTION_GET_BUCKET_QUOTA,
http.MethodPut: s3_constants.S3_ACTION_PUT_BUCKET_QUOTA,
},
}
// resolveFromQueryParameters checks query parameters to determine specific S3 actions
@@ -366,6 +371,10 @@ func mapBaseActionToS3Format(baseAction string) string {
return s3_constants.S3_ACTION_PUT_BUCKET_POLICY
case s3_constants.ACTION_DELETE_BUCKET_POLICY:
return s3_constants.S3_ACTION_DELETE_BUCKET_POLICY
case s3_constants.ACTION_PUT_BUCKET_QUOTA:
return s3_constants.S3_ACTION_PUT_BUCKET_QUOTA
case s3_constants.ACTION_GET_BUCKET_QUOTA:
return s3_constants.S3_ACTION_GET_BUCKET_QUOTA
default:
// For unknown actions, prefix with s3: to maintain format consistency
return "s3:" + baseAction
@@ -100,6 +100,12 @@ const (
S3_ACTION_GET_BUCKET_OWNERSHIP_CONTROLS = "s3:GetBucketOwnershipControls"
S3_ACTION_PUT_BUCKET_OWNERSHIP_CONTROLS = "s3:PutBucketOwnershipControls"
// SeaweedFS extension: bucket quota operations
// PUT /{bucket}?seaweedfs-quota and GET /{bucket}?seaweedfs-quota
// These are SeaweedFS-specific subresources, not part of the AWS S3 API.
S3_ACTION_PUT_BUCKET_QUOTA = "s3:PutBucketQuota"
S3_ACTION_GET_BUCKET_QUOTA = "s3:GetBucketQuota"
// Wildcard for all S3 actions
S3_ACTION_ALL = "s3:*"
)
+2
View File
@@ -18,6 +18,8 @@ const (
ACTION_PUT_BUCKET_OBJECT_LOCK_CONFIG = "PutBucketObjectLockConfiguration"
ACTION_PUT_BUCKET_POLICY = "PutBucketPolicy"
ACTION_DELETE_BUCKET_POLICY = "DeleteBucketPolicy"
ACTION_PUT_BUCKET_QUOTA = "PutBucketQuota"
ACTION_GET_BUCKET_QUOTA = "GetBucketQuota"
SeaweedStorageDestinationHeader = "x-seaweedfs-destination"
MultipartUploadsFolder = ".uploads"
+236
View File
@@ -0,0 +1,236 @@
package s3api
import (
"encoding/json"
"errors"
"fmt"
"io"
"math"
"net/http"
"github.com/seaweedfs/seaweedfs/weed/filer"
"github.com/seaweedfs/seaweedfs/weed/glog"
"github.com/seaweedfs/seaweedfs/weed/pb/filer_pb"
"github.com/seaweedfs/seaweedfs/weed/s3api/s3_constants"
"github.com/seaweedfs/seaweedfs/weed/s3api/s3err"
)
// putBucketQuotaMaxBodyBytes caps the request body to prevent DoS via large
// payloads. The valid payload is a few hundred bytes.
const putBucketQuotaMaxBodyBytes = 64 * 1024
// bucketQuotaRequest is the JSON body for PUT /{bucket}?seaweedfs-quota.
//
// SeaweedFS stores quota on the bucket's filer entry:
// - positive value: quota enabled, enforced server-side
// - negative value: quota disabled but size retained
// - zero: no quota
//
// The quota_unit field accepts B, KB, MB, GB, TB (case-insensitive).
// quota_size is the numeric size in the given unit.
// quota_enabled false with a positive size stores a negative (disabled) quota.
type bucketQuotaRequest struct {
QuotaSize int64 `json:"quota_size"`
QuotaUnit string `json:"quota_unit"`
QuotaEnabled bool `json:"quota_enabled"`
}
// bucketQuotaResponse is the JSON body for GET /{bucket}?seaweedfs-quota.
type bucketQuotaResponse struct {
QuotaSize int64 `json:"quota_size"`
QuotaUnit string `json:"quota_unit"`
QuotaEnabled bool `json:"quota_enabled"`
}
// PutBucketQuotaHandler handles PUT /{bucket}?seaweedfs-quota.
//
// This is a SeaweedFS-specific S3 extension that allows setting a bucket's
// storage quota through the S3 API, authenticated via SigV4 and authorized via
// the s3:PutBucketQuota IAM permission. It avoids the need for a separate
// admin API credential for integrations like Apache CloudStack.
func (s3a *S3ApiServer) PutBucketQuotaHandler(w http.ResponseWriter, r *http.Request) {
bucket, _ := s3_constants.GetBucketAndObject(r)
glog.V(3).Infof("PutBucketQuotaHandler %s", bucket)
if bucket == "" {
s3err.WriteErrorResponse(w, r, s3err.ErrInvalidBucketName)
return
}
r.Body = http.MaxBytesReader(w, r.Body, putBucketQuotaMaxBodyBytes)
defer r.Body.Close()
var req bucketQuotaRequest
dec := json.NewDecoder(r.Body)
if err := dec.Decode(&req); err != nil {
s3err.WriteErrorResponse(w, r, s3err.ErrMalformedXML)
return
}
// Reject trailing data after the JSON object to prevent malformed payloads
// from being silently accepted.
if err := dec.Decode(&struct{}{}); err != io.EOF {
writeQuotaError(w, r, http.StatusBadRequest, "unexpected trailing data after JSON object")
return
}
if req.QuotaEnabled && req.QuotaSize <= 0 {
writeQuotaError(w, r, http.StatusBadRequest, "quota_size must be > 0 when quota_enabled is true")
return
}
normalizedUnit, err := normalizeQuotaUnit(req.QuotaUnit)
if err != nil {
writeQuotaError(w, r, http.StatusBadRequest, err.Error())
return
}
req.QuotaUnit = normalizedUnit
quotaBytes, err := convertQuotaToBytes(req.QuotaSize, normalizedUnit)
if err != nil {
writeQuotaError(w, r, http.StatusBadRequest, err.Error())
return
}
var quota int64
switch {
case req.QuotaEnabled && quotaBytes > 0:
quota = quotaBytes
case !req.QuotaEnabled && quotaBytes > 0:
quota = -quotaBytes
default:
quota = 0
}
err = s3a.WithFilerClient(false, func(client filer_pb.SeaweedFilerClient) error {
lookupResp, err := client.LookupDirectoryEntry(r.Context(), &filer_pb.LookupDirectoryEntryRequest{
Directory: s3a.option.BucketsPath,
Name: bucket,
})
if err != nil {
if errors.Is(err, filer_pb.ErrNotFound) {
return filer_pb.ErrNotFound
}
return fmt.Errorf("failed to look up bucket: %w", err)
}
bucketEntry := lookupResp.Entry
bucketEntry.Quota = quota
_, err = client.UpdateEntry(r.Context(), &filer_pb.UpdateEntryRequest{
Directory: s3a.option.BucketsPath,
Entry: bucketEntry,
})
if err != nil {
return fmt.Errorf("failed to update bucket quota: %w", err)
}
if quota <= 0 {
if _, err := filer.ClearBucketReadOnly(r.Context(), client, s3a.option.BucketsPath, bucket); err != nil {
return fmt.Errorf("failed to clear bucket read-only flag: %w", err)
}
}
return nil
})
if err != nil {
if errors.Is(err, filer_pb.ErrNotFound) {
s3err.WriteErrorResponse(w, r, s3err.ErrNoSuchBucket)
return
}
glog.Errorf("PutBucketQuotaHandler %s: %v", bucket, err)
s3err.WriteErrorResponse(w, r, s3err.ErrInternalError)
return
}
w.WriteHeader(http.StatusNoContent)
}
// GetBucketQuotaHandler handles GET /{bucket}?seaweedfs-quota.
//
// Returns the current quota configuration for the bucket as JSON.
func (s3a *S3ApiServer) GetBucketQuotaHandler(w http.ResponseWriter, r *http.Request) {
bucket, _ := s3_constants.GetBucketAndObject(r)
glog.V(3).Infof("GetBucketQuotaHandler %s", bucket)
if bucket == "" {
s3err.WriteErrorResponse(w, r, s3err.ErrInvalidBucketName)
return
}
entry, err := s3a.getBucketEntry(bucket)
if err != nil {
s3err.WriteErrorResponse(w, r, s3err.ErrNoSuchBucket)
return
}
// Return the absolute quota magnitude as quota_size; the sign only
// encodes enabled/disabled state internally. This makes the response
// round-trippable: a client can send the same JSON back via PUT without
// the negative sentinel being interpreted as zero.
quotaSize := entry.Quota
if quotaSize < 0 {
quotaSize = -quotaSize
}
resp := bucketQuotaResponse{
QuotaSize: quotaSize,
QuotaUnit: "B",
QuotaEnabled: entry.Quota > 0,
}
w.Header().Set("Content-Type", "application/json")
if err := json.NewEncoder(w).Encode(resp); err != nil {
glog.Errorf("GetBucketQuotaHandler %s: failed to encode response: %v", bucket, err)
}
}
// writeQuotaError writes a JSON error response for quota operations.
func writeQuotaError(w http.ResponseWriter, r *http.Request, statusCode int, message string) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(statusCode)
if err := json.NewEncoder(w).Encode(map[string]string{"error": message}); err != nil {
glog.V(1).Infof("failed to write quota error response: %v", err)
}
}
// normalizeQuotaUnit normalizes the quota unit string to a canonical uppercase form.
func normalizeQuotaUnit(unit string) (string, error) {
switch unit {
case "", "B", "b":
return "B", nil
case "KB", "kb":
return "KB", nil
case "MB", "mb":
return "MB", nil
case "GB", "gb":
return "GB", nil
case "TB", "tb":
return "TB", nil
default:
return "", fmt.Errorf("unsupported quota_unit %q (supported: B, KB, MB, GB, TB)", unit)
}
}
// convertQuotaToBytes converts a quota size + unit to bytes.
// Returns an error if the result would overflow int64.
func convertQuotaToBytes(size int64, unit string) (int64, error) {
if size <= 0 {
return 0, nil
}
var multiplier int64
switch unit {
case "TB":
multiplier = 1024 * 1024 * 1024 * 1024
case "GB":
multiplier = 1024 * 1024 * 1024
case "MB":
multiplier = 1024 * 1024
case "KB":
multiplier = 1024
case "B":
multiplier = 1
default:
return 0, fmt.Errorf("unsupported quota_unit %q", unit)
}
if multiplier > 0 && size > math.MaxInt64/multiplier {
return 0, fmt.Errorf("quota_size %d %s overflows maximum bytes (%d)", size, unit, int64(math.MaxInt64))
}
return size * multiplier, nil
}
@@ -0,0 +1,156 @@
package s3api
import (
"math"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/gorilla/mux"
)
func TestNormalizeQuotaUnit(t *testing.T) {
tests := []struct {
input string
want string
err bool
}{
{"", "B", false},
{"B", "B", false},
{"b", "B", false},
{"KB", "KB", false},
{"kb", "KB", false},
{"MB", "MB", false},
{"mb", "MB", false},
{"GB", "GB", false},
{"gb", "GB", false},
{"TB", "TB", false},
{"tb", "TB", false},
{"PB", "", true},
{"invalid", "", true},
}
for _, tc := range tests {
t.Run(tc.input, func(t *testing.T) {
got, err := normalizeQuotaUnit(tc.input)
if tc.err && err == nil {
t.Errorf("expected error for %q, got nil", tc.input)
}
if !tc.err && err != nil {
t.Errorf("unexpected error for %q: %v", tc.input, err)
}
if got != tc.want {
t.Errorf("normalizeQuotaUnit(%q) = %q, want %q", tc.input, got, tc.want)
}
})
}
}
func TestConvertQuotaToBytes(t *testing.T) {
tests := []struct {
size int64
unit string
want int64
wantErr bool
}{
{0, "B", 0, false},
{0, "GB", 0, false},
{1024, "B", 1024, false},
{1, "KB", 1024, false},
{1, "MB", 1024 * 1024, false},
{1, "GB", 1024 * 1024 * 1024, false},
{1, "TB", 1024 * 1024 * 1024 * 1024, false},
{2, "GB", 2 * 1024 * 1024 * 1024, false},
{-1, "GB", 0, false},
// Overflow: 8388608 TB = 2^23 * 2^40 = 2^63 which overflows int64
{8388608, "TB", 0, true},
// MaxInt64 KB overflows
{math.MaxInt64, "KB", 0, true},
// MaxInt64 B does not overflow
{math.MaxInt64, "B", math.MaxInt64, false},
}
for _, tc := range tests {
t.Run(tc.unit, func(t *testing.T) {
got, err := convertQuotaToBytes(tc.size, tc.unit)
if tc.wantErr && err == nil {
t.Errorf("expected error for %d %s, got %d", tc.size, tc.unit, got)
}
if !tc.wantErr && err != nil {
t.Errorf("unexpected error for %d %s: %v", tc.size, tc.unit, err)
}
if got != tc.want {
t.Errorf("convertQuotaToBytes(%d, %q) = %d, want %d", tc.size, tc.unit, got, tc.want)
}
})
}
}
func TestPutBucketQuotaHandler_InvalidBody(t *testing.T) {
s3a := &S3ApiServer{}
req := httptest.NewRequest(http.MethodPut, "/test-bucket?seaweedfs-quota", strings.NewReader("not json"))
req = mux.SetURLVars(req, map[string]string{"bucket": "test-bucket"})
rr := httptest.NewRecorder()
s3a.PutBucketQuotaHandler(rr, req)
if rr.Code != http.StatusBadRequest {
t.Errorf("expected 400 for malformed body, got %d", rr.Code)
}
}
func TestPutBucketQuotaHandler_TrailingData(t *testing.T) {
s3a := &S3ApiServer{}
body := `{"quota_size":100,"quota_unit":"GB","quota_enabled":true} garbage`
req := httptest.NewRequest(http.MethodPut, "/test-bucket?seaweedfs-quota", strings.NewReader(body))
req = mux.SetURLVars(req, map[string]string{"bucket": "test-bucket"})
rr := httptest.NewRecorder()
s3a.PutBucketQuotaHandler(rr, req)
if rr.Code != http.StatusBadRequest {
t.Errorf("expected 400 for trailing data, got %d: %s", rr.Code, rr.Body.String())
}
}
func TestPutBucketQuotaHandler_EnabledWithZeroSize(t *testing.T) {
s3a := &S3ApiServer{}
body := `{"quota_size":0,"quota_unit":"GB","quota_enabled":true}`
req := httptest.NewRequest(http.MethodPut, "/test-bucket?seaweedfs-quota", strings.NewReader(body))
req = mux.SetURLVars(req, map[string]string{"bucket": "test-bucket"})
rr := httptest.NewRecorder()
s3a.PutBucketQuotaHandler(rr, req)
if rr.Code != http.StatusBadRequest {
t.Errorf("expected 400 for enabled quota with size=0, got %d", rr.Code)
}
}
func TestPutBucketQuotaHandler_InvalidUnit(t *testing.T) {
s3a := &S3ApiServer{}
body := `{"quota_size":100,"quota_unit":"PB","quota_enabled":true}`
req := httptest.NewRequest(http.MethodPut, "/test-bucket?seaweedfs-quota", strings.NewReader(body))
req = mux.SetURLVars(req, map[string]string{"bucket": "test-bucket"})
rr := httptest.NewRecorder()
s3a.PutBucketQuotaHandler(rr, req)
if rr.Code != http.StatusBadRequest {
t.Errorf("expected 400 for invalid unit, got %d", rr.Code)
}
}
func TestPutBucketQuotaHandler_NoBucket(t *testing.T) {
s3a := &S3ApiServer{}
body := `{"quota_size":100,"quota_unit":"GB","quota_enabled":true}`
req := httptest.NewRequest(http.MethodPut, "/?seaweedfs-quota", strings.NewReader(body))
req = mux.SetURLVars(req, map[string]string{"bucket": ""})
rr := httptest.NewRecorder()
s3a.PutBucketQuotaHandler(rr, req)
if rr.Code != http.StatusBadRequest {
t.Errorf("expected 400 for missing bucket, got %d", rr.Code)
}
}
func TestGetBucketQuotaHandler_NoBucket(t *testing.T) {
s3a := &S3ApiServer{}
req := httptest.NewRequest(http.MethodGet, "/?seaweedfs-quota", nil)
req = mux.SetURLVars(req, map[string]string{"bucket": ""})
rr := httptest.NewRecorder()
s3a.GetBucketQuotaHandler(rr, req)
if rr.Code != http.StatusBadRequest {
t.Errorf("expected 400 for missing bucket, got %d", rr.Code)
}
}
+7
View File
@@ -994,6 +994,13 @@ func (s3a *S3ApiServer) registerRouter(router *mux.Router) {
//DeleteBucketOwnershipControls
bucket.Methods(http.MethodDelete).HandlerFunc(track(s3a.iam.Auth(s3a.DeleteBucketOwnershipControls, ACTION_ADMIN), "DELETE")).Queries("ownershipControls", "")
// SeaweedFS extension: bucket quota subresource
// PUT /{bucket}?seaweedfs-quota — set bucket quota (s3:PutBucketQuota)
// GET /{bucket}?seaweedfs-quota — get bucket quota (s3:GetBucketQuota)
// Authenticated via SigV4, authorized via dedicated IAM permissions.
bucket.Methods(http.MethodPut).HandlerFunc(track(s3a.iam.Auth(s3a.cb.Limit(s3a.PutBucketQuotaHandler, ACTION_PUT_BUCKET_QUOTA)), "PUT")).Queries("seaweedfs-quota", "")
bucket.Methods(http.MethodGet).HandlerFunc(track(s3a.iam.Auth(s3a.cb.Limit(s3a.GetBucketQuotaHandler, ACTION_GET_BUCKET_QUOTA)), "GET")).Queries("seaweedfs-quota", "")
// raw buckets
// PostPolicy