mirror of
https://github.com/seaweedfs/seaweedfs.git
synced 2026-11-02 02:10:45 +01:00
s3tables: keep the fence on a declare of an existing index Deciding the existing-index exception before the handler ran left a window: an index deleted between that lookup and the handler's own check would be recreated, unfenced, by a caller without s3vectors:CreateIndex. Drop the up-front exception, so every CreateTable runs fenced and a fresh create is refused at its first write. The engine's declare still passes because the idempotent path writes nothing; for a build whose idempotent path re-applies state already in place, the fence answers an UpdateEntry that leaves the entry exactly as stored without writing. Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
197 lines
7.3 KiB
Go
197 lines
7.3 KiB
Go
package s3tables
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"io"
|
|
"net/http"
|
|
"strings"
|
|
"sync/atomic"
|
|
|
|
"github.com/seaweedfs/seaweedfs/weed/pb/filer_pb"
|
|
"google.golang.org/grpc"
|
|
"google.golang.org/protobuf/proto"
|
|
)
|
|
|
|
// VectorIndexNamespace holds a vector bucket's indexes. Only the S3 Vectors
|
|
// gateway, through a trusted Manager, may change it in a LANCE bucket.
|
|
const VectorIndexNamespace = "vector"
|
|
|
|
const vectorNamespaceReadOnly = "the vector namespace of a vector bucket is read-only; manage its indexes through S3 Vectors"
|
|
|
|
var errVectorNamespaceReadOnly = errors.New(vectorNamespaceReadOnly)
|
|
|
|
func isVectorIndexNamespace(namespace []string) bool {
|
|
name, err := validateNamespace(namespace)
|
|
return err == nil && name == VectorIndexNamespace
|
|
}
|
|
|
|
func (h *S3TablesHandler) checkVectorNamespaceWrite(ctx context.Context, filerClient FilerClient, bucketName string, namespace []string) error {
|
|
if h.trusted || !isVectorIndexNamespace(namespace) {
|
|
return nil
|
|
}
|
|
var metadata tableBucketMetadata
|
|
err := filerClient.WithFilerClient(false, func(client filer_pb.SeaweedFilerClient) error {
|
|
data, err := h.getExtendedAttribute(ctx, client, GetTableBucketPath(bucketName), ExtendedKeyMetadata)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return json.Unmarshal(data, &metadata)
|
|
})
|
|
if errors.Is(err, filer_pb.ErrNotFound) || errors.Is(err, ErrAttributeNotFound) {
|
|
return nil
|
|
}
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if metadata.Format == FormatLance {
|
|
return &S3TablesError{Type: ErrCodeAccessDenied, Message: vectorNamespaceReadOnly}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// fenceVectorNamespace returns a fence for an untrusted write naming the
|
|
// vector namespace of a LANCE bucket. The request still runs, so the
|
|
// operation's own checks answer everything short of a write: an ungranted
|
|
// caller gets its usual denial, and a CreateTable that finds the index already
|
|
// there (the vector engine's declare) succeeds.
|
|
func (h *S3TablesHandler) fenceVectorNamespace(w http.ResponseWriter, r *http.Request, filerClient FilerClient, operation string) *vectorNamespaceFence {
|
|
if h.trusted || strings.HasPrefix(operation, "Get") || strings.HasPrefix(operation, "List") {
|
|
return nil
|
|
}
|
|
body, _ := io.ReadAll(io.LimitReader(r.Body, MaxRequestBodySize+1))
|
|
r.Body = io.NopCloser(bytes.NewReader(body))
|
|
|
|
var req struct {
|
|
TableBucketARN string `json:"tableBucketARN"`
|
|
Namespace []string `json:"namespace"`
|
|
SourceNamespace []string `json:"sourceNamespace"`
|
|
DestNamespace []string `json:"destNamespace"`
|
|
ResourceARN string `json:"resourceArn"`
|
|
}
|
|
// Like the handler's own decode, this keeps going past a mistyped field.
|
|
_ = json.Unmarshal(body, &req)
|
|
type target struct {
|
|
bucket string
|
|
namespace []string
|
|
}
|
|
bucketName, _ := parseBucketNameFromARN(req.TableBucketARN)
|
|
targets := []target{{bucketName, req.Namespace}, {bucketName, req.SourceNamespace}, {bucketName, req.DestNamespace}}
|
|
if bucket, namespace, _, err := parseTableFromARN(req.ResourceARN); err == nil {
|
|
targets = append(targets, target{bucket, []string{namespace}})
|
|
}
|
|
for _, t := range targets {
|
|
if t.bucket != "" && h.checkVectorNamespaceWrite(r.Context(), filerClient, t.bucket, t.namespace) != nil {
|
|
return &vectorNamespaceFence{ResponseWriter: w, h: h, filer: filerClient}
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// vectorNamespaceFence passes reads, refuses filer writes, and answers the
|
|
// request as read-only once a write was refused. Handlers write only after
|
|
// their permission check, so an ungranted caller never sees that answer.
|
|
type vectorNamespaceFence struct {
|
|
http.ResponseWriter
|
|
h *S3TablesHandler
|
|
filer FilerClient
|
|
tripped atomic.Bool
|
|
decided bool
|
|
refusing bool
|
|
}
|
|
|
|
func (f *vectorNamespaceFence) WithFilerClient(streamingMode bool, fn func(client filer_pb.SeaweedFilerClient) error) error {
|
|
return f.filer.WithFilerClient(streamingMode, func(client filer_pb.SeaweedFilerClient) error {
|
|
return fn(readOnlyFilerClient{SeaweedFilerClient: client, tripped: &f.tripped})
|
|
})
|
|
}
|
|
|
|
func (f *vectorNamespaceFence) WriteHeader(status int) {
|
|
if !f.settle() {
|
|
f.ResponseWriter.WriteHeader(status)
|
|
}
|
|
}
|
|
|
|
func (f *vectorNamespaceFence) Write(b []byte) (int, error) {
|
|
if f.settle() {
|
|
return len(b), nil
|
|
}
|
|
return f.ResponseWriter.Write(b)
|
|
}
|
|
|
|
// settle decides, at the first response write, whether the handler's answer
|
|
// is replaced by the read-only refusal.
|
|
func (f *vectorNamespaceFence) settle() bool {
|
|
if !f.decided {
|
|
f.decided = true
|
|
f.refusing = f.tripped.Load()
|
|
if f.refusing {
|
|
f.h.writeError(f.ResponseWriter, http.StatusForbidden, ErrCodeAccessDenied, vectorNamespaceReadOnly)
|
|
}
|
|
}
|
|
return f.refusing
|
|
}
|
|
|
|
type readOnlyFilerClient struct {
|
|
filer_pb.SeaweedFilerClient
|
|
tripped *atomic.Bool
|
|
}
|
|
|
|
func (c readOnlyFilerClient) refuse() error {
|
|
c.tripped.Store(true)
|
|
return errVectorNamespaceReadOnly
|
|
}
|
|
|
|
func (c readOnlyFilerClient) CreateEntry(context.Context, *filer_pb.CreateEntryRequest, ...grpc.CallOption) (*filer_pb.CreateEntryResponse, error) {
|
|
return nil, c.refuse()
|
|
}
|
|
|
|
// UpdateEntry answers a rewrite that leaves the entry exactly as stored
|
|
// without writing, so an idempotent path that repairs state already in place
|
|
// still passes.
|
|
func (c readOnlyFilerClient) UpdateEntry(ctx context.Context, req *filer_pb.UpdateEntryRequest, opts ...grpc.CallOption) (*filer_pb.UpdateEntryResponse, error) {
|
|
if req.GetEntry() != nil {
|
|
resp, err := c.SeaweedFilerClient.LookupDirectoryEntry(ctx, &filer_pb.LookupDirectoryEntryRequest{Directory: req.Directory, Name: req.Entry.Name}, opts...)
|
|
if err == nil && proto.Equal(resp.GetEntry(), req.Entry) {
|
|
return &filer_pb.UpdateEntryResponse{}, nil
|
|
}
|
|
}
|
|
return nil, c.refuse()
|
|
}
|
|
|
|
func (c readOnlyFilerClient) AppendToEntry(context.Context, *filer_pb.AppendToEntryRequest, ...grpc.CallOption) (*filer_pb.AppendToEntryResponse, error) {
|
|
return nil, c.refuse()
|
|
}
|
|
|
|
func (c readOnlyFilerClient) DeleteEntry(context.Context, *filer_pb.DeleteEntryRequest, ...grpc.CallOption) (*filer_pb.DeleteEntryResponse, error) {
|
|
return nil, c.refuse()
|
|
}
|
|
|
|
func (c readOnlyFilerClient) AtomicRenameEntry(context.Context, *filer_pb.AtomicRenameEntryRequest, ...grpc.CallOption) (*filer_pb.AtomicRenameEntryResponse, error) {
|
|
return nil, c.refuse()
|
|
}
|
|
|
|
func (c readOnlyFilerClient) StreamRenameEntry(context.Context, *filer_pb.StreamRenameEntryRequest, ...grpc.CallOption) (grpc.ServerStreamingClient[filer_pb.StreamRenameEntryResponse], error) {
|
|
return nil, c.refuse()
|
|
}
|
|
|
|
func (c readOnlyFilerClient) StreamMutateEntry(context.Context, ...grpc.CallOption) (grpc.BidiStreamingClient[filer_pb.StreamMutateEntryRequest, filer_pb.StreamMutateEntryResponse], error) {
|
|
return nil, c.refuse()
|
|
}
|
|
|
|
func (c readOnlyFilerClient) ObjectTransaction(context.Context, *filer_pb.ObjectTransactionRequest, ...grpc.CallOption) (*filer_pb.ObjectTransactionResponse, error) {
|
|
return nil, c.refuse()
|
|
}
|
|
|
|
func (c readOnlyFilerClient) ObjectTransactionBatch(context.Context, *filer_pb.ObjectTransactionBatchRequest, ...grpc.CallOption) (*filer_pb.ObjectTransactionBatchResponse, error) {
|
|
return nil, c.refuse()
|
|
}
|
|
|
|
// CheckVectorNamespaceWrite refuses an untrusted write into the vector
|
|
// namespace of a LANCE bucket, for callers that write the filer directly.
|
|
func (m *Manager) CheckVectorNamespaceWrite(ctx context.Context, filerClient FilerClient, bucketName string, namespace []string) error {
|
|
return m.handler.checkVectorNamespaceWrite(ctx, filerClient, bucketName, namespace)
|
|
}
|