mirror of
https://github.com/seaweedfs/seaweedfs.git
synced 2026-11-03 18:47:41 +01:00
* s3vectors: tag vector buckets and indexes
TagResource, UntagResource and ListTagsForResource answer on
/tags/{resourceArn} (POST, DELETE, GET) and by name. Each authorizes its
own s3vectors action on the bucket or index ARN, then reads or writes the
catalog record's tags. A resource holds at most 50 tags.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* s3vectors: apply the tags passed to CreateVectorBucket and CreateIndex
The tags field was accepted and dropped. It is now stored on the new
bucket or index, and creating with tags needs s3vectors:TagResource in
addition to the create action.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* test/s3vectors: cover resource tagging end to end
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* s3tables: keep tag operations to a bound table bucket
TagResource, UntagResource and ListTagsForResource run through Execute
with WithTableBucketCreatedAt now answer not found when the bucket name
holds another bucket, as the bound creates and deletes already do.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* s3vectors: bind tagging to the authorized bucket and to vector indexes
The catalog tag calls carry the creation time of the bucket authorizeVector
approved, so a bucket created again under the name is not touched. An index
ARN must name a Lance table: another table in the vector namespace of a
format-less bucket is refused, as DeleteIndex does.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* s3vectors: match a TagResource grant scoped to the bucket being created
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* s3tables: merge tag writes into the tags the record holds at the write
TagResource and UntagResource merged into the tags they read before the
compare-and-swap and wrote that map on every attempt, so a tag write that
landed in between was undone. The merge now runs inside the mutation.
WithMaxTags lets an in-process caller cap the merged set there.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* s3vectors: check the 50-tag limit inside the catalog's tag write
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* s3tables: check a tag operation's bound bucket after its reads
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* s3vectors: validate each tag key UntagResource removes
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* s3vectors: validate untag keys one at a time
UntagResource sized a map by the number of submitted tag keys before
validating them, so a large body of repeated keys allocated far more than
the request itself, before authorization ran.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* s3vectors: authorize UntagResource before validating its keys
Validating a near-32 MiB body of tag keys costs about 13x its JSON
decode, so an unauthorized caller could make the gateway spend that
work on a request it then refuses.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
159 lines
5.7 KiB
Go
159 lines
5.7 KiB
Go
// Package s3vectors serves the AWS S3 Vectors API on top of the shared
|
|
// VectorService gRPC boundary and the S3 Tables catalog.
|
|
package s3vectors
|
|
|
|
import (
|
|
"net/http"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/gorilla/mux"
|
|
"github.com/seaweedfs/seaweedfs/weed/pb/filer_pb"
|
|
"github.com/seaweedfs/seaweedfs/weed/pb/vector_pb"
|
|
"github.com/seaweedfs/seaweedfs/weed/s3api/s3_constants"
|
|
"github.com/seaweedfs/seaweedfs/weed/s3api/s3err"
|
|
"github.com/seaweedfs/seaweedfs/weed/s3api/s3tables"
|
|
stats_collect "github.com/seaweedfs/seaweedfs/weed/stats"
|
|
)
|
|
|
|
// FilerClient provides access to the filer for storage operations.
|
|
type FilerClient interface {
|
|
WithFilerClient(streamingMode bool, fn func(client filer_pb.SeaweedFilerClient) error) error
|
|
}
|
|
|
|
// S3Authenticator authenticates a request, returning the caller identity.
|
|
type S3Authenticator interface {
|
|
AuthenticateRequest(r *http.Request) (string, interface{}, s3err.ErrorCode)
|
|
DefaultAllow() bool
|
|
}
|
|
|
|
// Server implements the AWS S3 Vectors API.
|
|
type Server struct {
|
|
filerClient FilerClient
|
|
tables *s3tables.Manager
|
|
internal *s3tables.Manager
|
|
vectors vector_pb.VectorServiceClient
|
|
authenticator S3Authenticator
|
|
region string
|
|
accountID string
|
|
}
|
|
|
|
// NewServer creates an S3 Vectors server over the given filer and catalog.
|
|
func NewServer(filerClient FilerClient, authenticator S3Authenticator, vectors vector_pb.VectorServiceClient) *Server {
|
|
manager := s3tables.NewManager()
|
|
if authenticator != nil {
|
|
manager.SetDefaultAllow(authenticator.DefaultAllow())
|
|
}
|
|
// Bookkeeping calls that ride along an operation the catalog already
|
|
// authorizes — format lookups, the implicit index namespace — must not
|
|
// demand read grants the API does not require.
|
|
internal := s3tables.NewManager()
|
|
internal.SetTrusted(true)
|
|
return &Server{
|
|
filerClient: filerClient,
|
|
tables: manager,
|
|
internal: internal,
|
|
vectors: vectors,
|
|
authenticator: authenticator,
|
|
}
|
|
}
|
|
|
|
// SetRegion configures the region embedded in the ARNs this server mints.
|
|
func (s *Server) SetRegion(region string) { s.region = region }
|
|
|
|
// SetAccountID configures the account id embedded in the ARNs this server mints.
|
|
func (s *Server) SetAccountID(id string) { s.accountID = id }
|
|
|
|
// SetEncryptionChecker tells CreateVectorBucket whether the gateway can
|
|
// apply the AES256 a bucket declares.
|
|
func (s *Server) SetEncryptionChecker(fn func() bool) { s.internal.SetEncryptionChecker(fn) }
|
|
|
|
// RegisterRoutes mounts POST / dispatching on the X-Amz-Target header,
|
|
// POST /{Operation} for clients that route per-path, and the tagging calls on
|
|
// /tags/{resourceArn}. Metrics wrap Auth like the S3 track middleware so
|
|
// rejected requests still count toward the operation.
|
|
func (s *Server) RegisterRoutes(router *mux.Router) {
|
|
handler := s.instrument(s.Auth(s.dispatch))
|
|
router.HandleFunc("/", handler).Methods(http.MethodPost)
|
|
router.HandleFunc(tagsPathPrefix+"{resourceArn:.+}", handler).Methods(http.MethodGet, http.MethodPost, http.MethodDelete)
|
|
router.HandleFunc("/{op}", handler).Methods(http.MethodPost)
|
|
}
|
|
|
|
// operationFromRequest resolves the operation the way dispatch does: the
|
|
// method on a /tags/ path, else the last path segment, or the X-Amz-Target
|
|
// suffix when the path is bare.
|
|
func operationFromRequest(r *http.Request) string {
|
|
if strings.HasPrefix(r.URL.Path, tagsPathPrefix) {
|
|
return tagOperation(r.Method)
|
|
}
|
|
op := strings.Trim(r.URL.Path, "/")
|
|
if i := strings.LastIndex(op, "/"); i >= 0 {
|
|
op = op[i+1:]
|
|
}
|
|
if op == "" {
|
|
target := r.Header.Get("X-Amz-Target")
|
|
op = target[strings.LastIndex(target, ".")+1:]
|
|
}
|
|
return op
|
|
}
|
|
|
|
// instrument records in-flight, latency, and status for a known operation;
|
|
// unknown operations stay out of the metric labels.
|
|
func (s *Server) instrument(next http.HandlerFunc) http.HandlerFunc {
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
op := operationFromRequest(r)
|
|
if _, ok := handlers[op]; !ok {
|
|
next(w, r)
|
|
return
|
|
}
|
|
label := "s3vectors." + op
|
|
inFlight := stats_collect.S3InFlightRequestsGauge.WithLabelValues(label)
|
|
inFlight.Inc()
|
|
defer inFlight.Dec()
|
|
recorder := stats_collect.NewStatusResponseWriter(w)
|
|
start := time.Now()
|
|
next(recorder, r)
|
|
elapsed := time.Since(start)
|
|
stats_collect.S3RequestHistogram.WithLabelValues(label, "").Observe(elapsed.Seconds())
|
|
stats_collect.S3RequestCounter.WithLabelValues(label, strconv.Itoa(recorder.Status), "").Inc()
|
|
stats_collect.RecordBucketActiveTime("")
|
|
}
|
|
}
|
|
|
|
func (s *Server) dispatch(w http.ResponseWriter, r *http.Request) {
|
|
// Clients carry the operation in the X-Amz-Target header or in the path —
|
|
// POST /QueryVectors and POST / with a target mean the same call here.
|
|
op := operationFromRequest(r)
|
|
handler, ok := handlers[op]
|
|
if !ok {
|
|
writeError(w, http.StatusBadRequest, "ValidationException", "unknown operation "+op)
|
|
return
|
|
}
|
|
handler(s, w, r)
|
|
}
|
|
|
|
// Auth authenticates the caller and puts the identity in the request context.
|
|
func (s *Server) Auth(handler http.HandlerFunc) http.HandlerFunc {
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
if r.ContentLength > maxRequestBodyBytes {
|
|
writeError(w, http.StatusBadRequest, "ValidationException", errRequestBodyTooLarge.Error())
|
|
return
|
|
}
|
|
r = r.WithContext(s3_constants.SetRequestBodyLimitInContext(r.Context(), maxRequestBodyBytes))
|
|
identity, identityObj, errCode := s.authenticator.AuthenticateRequest(r)
|
|
switch errCode {
|
|
case s3err.ErrNone:
|
|
case s3err.ErrEntityTooLarge:
|
|
writeError(w, http.StatusBadRequest, "ValidationException", errRequestBodyTooLarge.Error())
|
|
return
|
|
default:
|
|
writeError(w, http.StatusForbidden, "AccessDenied", "authentication failed")
|
|
return
|
|
}
|
|
r = r.WithContext(s3_constants.SetIdentityInContext(
|
|
s3_constants.SetIdentityNameInContext(r.Context(), identity), identityObj))
|
|
handler(w, r)
|
|
}
|
|
}
|