Files
seaweedfs/weed/s3api/s3tables/manager.go
T
Chris LuandClaude Opus 5.5 ec968aa016 s3vectors: support TagResource, UntagResource and ListTagsForResource (#12335)
* 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>
2026-11-02 10:33:26 -08:00

232 lines
8.0 KiB
Go

package s3tables
import (
"bytes"
"context"
"encoding/json"
"errors"
"io"
"net/http"
"net/http/httptest"
"time"
"github.com/seaweedfs/seaweedfs/weed/pb/filer_pb"
"github.com/seaweedfs/seaweedfs/weed/s3api/s3_constants"
)
// Manager provides reusable S3 Tables operations for shell/admin without HTTP routing.
type Manager struct {
handler *S3TablesHandler
}
// NewManager creates a new Manager.
func NewManager() *Manager {
m := &Manager{handler: NewS3TablesHandler()}
// Default to allowing access when IAM is not configured
m.handler.SetDefaultAllow(true)
return m
}
// SetRegion sets the AWS region for ARN generation.
func (m *Manager) SetRegion(region string) {
m.handler.SetRegion(region)
}
// SetAccountID sets the AWS account ID for ARN generation.
func (m *Manager) SetAccountID(accountID string) {
m.handler.SetAccountID(accountID)
}
// SetDefaultAllow sets whether to allow access by default.
func (m *Manager) SetDefaultAllow(allow bool) {
m.handler.SetDefaultAllow(allow)
}
// SetTrusted lets trusted local tooling (shell, admin console) bypass authorization.
func (m *Manager) SetTrusted(trusted bool) {
m.handler.SetTrusted(trusted)
}
// SetEncryptionChecker sets the handler's SSE-S3 availability predicate.
func (m *Manager) SetEncryptionChecker(fn func() bool) {
m.handler.SetEncryptionChecker(fn)
}
// PrincipalFor resolves the caller of r the way the catalog's handlers do,
// so it matches the owner CreateTableBucket records. A nil request is the
// handler's account ID.
func (m *Manager) PrincipalFor(r *http.Request) string {
if r == nil {
return m.handler.accountID
}
return m.handler.getAccountID(r)
}
// PolicyContext builds the evaluation context a Manager call's synthesized
// request produces: the configured local-account predicate, the caller's own
// identity actions so admin and action grants resolve on privileges rather
// than the forwarded name, plus the open-by-default fallback only for a
// trusted caller or no resolved identity. A non-empty bucket lets an identity
// action scoped to that bucket apply, as the handlers do.
func (m *Manager) PolicyContext(r *http.Request, bucket string) *PolicyContext {
ctx := &PolicyContext{IsLocalAccountID: m.handler.localAccountIDs, IdentityActions: getIdentityActions(r),
TrustedIssuers: m.handler.trustedIssuers(r), TableBucketName: bucket}
if m.handler.trusted || (m.handler.defaultAllow && s3_constants.GetIdentityNameFromContext(r) == "") {
ctx.DefaultAllow = true
}
return ctx
}
type tableBucketCreatedAtKey struct{}
// WithTableBucketCreatedAt binds a DeleteTableBucket, CreateNamespace,
// DeleteNamespace, CreateTable, DeleteTable or tag operation run through
// Execute to the bucket created at createdAt, so a caller that authorized the
// bucket itself never writes into one created again under its name.
// In-process only: no wire request can carry it.
func WithTableBucketCreatedAt(ctx context.Context, createdAt time.Time) context.Context {
return context.WithValue(ctx, tableBucketCreatedAtKey{}, createdAt)
}
// boundTableBucket returns the bucket creation time ctx binds the request to.
func boundTableBucket(ctx context.Context) (time.Time, bool) {
createdAt, ok := ctx.Value(tableBucketCreatedAtKey{}).(time.Time)
return createdAt, ok
}
// otherTableBucket reports whether ctx binds the request to a bucket other
// than the one created at createdAt.
func otherTableBucket(ctx context.Context, createdAt time.Time) bool {
want, ok := boundTableBucket(ctx)
return ok && !createdAt.Equal(want)
}
// Execute runs an S3 Tables operation and decodes the response into resp (if provided).
func (m *Manager) Execute(ctx context.Context, filerClient FilerClient, operation string, req interface{}, resp interface{}, identity string) error {
body, err := json.Marshal(req)
if err != nil {
return err
}
httpReq, err := newManagerRequest(ctx, operation, body, identity)
if err != nil {
return err
}
recorder := httptest.NewRecorder()
m.handler.HandleRequest(recorder, httpReq, filerClient)
return decodeS3TablesHTTPResponse(recorder, resp)
}
// AuthorizeCreateTable checks that identity may create the table the request
// describes, without creating anything. A deferred create (Iceberg
// stage-create) writes into the table bucket long before it registers the
// table, so it passes this gate first.
func (m *Manager) AuthorizeCreateTable(ctx context.Context, filerClient FilerClient, req *CreateTableRequest, identity string) error {
httpReq, err := newManagerRequest(ctx, "CreateTable", nil, identity)
if err != nil {
return err
}
recorder := httptest.NewRecorder()
target, authErr := m.handler.authorizeCreateTable(recorder, httpReq, filerClient, req.TableBucketARN, req.Namespace, req.Name, req.Tags, req.AllowMissingNamespace)
if authErr == nil {
return m.handler.checkVectorNamespaceWrite(ctx, filerClient, target.bucketName, req.Namespace)
}
if decoded := decodeS3TablesHTTPResponse(recorder, nil); decoded != nil {
return decoded
}
return authErr
}
// AuthorizeUpdateTable checks that identity may update the table the request
// describes, without updating anything. Credential vending gates on the same
// permission a commit needs.
func (m *Manager) AuthorizeUpdateTable(ctx context.Context, filerClient FilerClient, req *UpdateTableRequest, identity string) error {
httpReq, err := newManagerRequest(ctx, "UpdateTable", nil, identity)
if err != nil {
return err
}
recorder := httptest.NewRecorder()
_, authErr := m.handler.authorizeUpdateTable(recorder, httpReq, filerClient, req)
if authErr == nil {
return nil
}
if decoded := decodeS3TablesHTTPResponse(recorder, nil); decoded != nil {
return decoded
}
return authErr
}
// AuthorizeUpdateView checks that identity may update the view the request
// describes, without updating anything.
func (m *Manager) AuthorizeUpdateView(ctx context.Context, filerClient FilerClient, req *UpdateViewRequest, identity string) error {
httpReq, err := newManagerRequest(ctx, "UpdateView", nil, identity)
if err != nil {
return err
}
recorder := httptest.NewRecorder()
_, authErr := m.handler.authorizeUpdateView(recorder, httpReq, filerClient, req)
if authErr == nil {
return nil
}
if decoded := decodeS3TablesHTTPResponse(recorder, nil); decoded != nil {
return decoded
}
return authErr
}
func newManagerRequest(ctx context.Context, operation string, body []byte, identity string) (*http.Request, error) {
httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, "/", bytes.NewReader(body))
if err != nil {
return nil, err
}
httpReq.Header.Set("Content-Type", "application/x-amz-json-1.1")
httpReq.Header.Set("X-Amz-Target", "S3Tables."+operation)
if identity != "" {
httpReq.Header.Set(s3_constants.AmzAccountId, identity)
httpReq = httpReq.WithContext(s3_constants.SetIdentityNameInContext(httpReq.Context(), identity))
}
return httpReq, nil
}
func decodeS3TablesHTTPResponse(recorder *httptest.ResponseRecorder, resp interface{}) error {
result := recorder.Result()
defer result.Body.Close()
data, err := io.ReadAll(result.Body)
if err != nil {
return err
}
if result.StatusCode >= http.StatusBadRequest {
var errResp S3TablesError
if len(data) > 0 {
if jsonErr := json.Unmarshal(data, &errResp); jsonErr == nil && (errResp.Type != "" || errResp.Message != "") {
return &errResp
}
}
return &S3TablesError{Type: ErrCodeInternalError, Message: string(bytes.TrimSpace(data))}
}
if resp == nil || len(data) == 0 {
return nil
}
if err := json.Unmarshal(data, resp); err != nil {
return err
}
return nil
}
// ManagerClient adapts a SeaweedFilerClient to the FilerClient interface.
type ManagerClient struct {
client filer_pb.SeaweedFilerClient
}
// NewManagerClient wraps a filer client.
func NewManagerClient(client filer_pb.SeaweedFilerClient) *ManagerClient {
return &ManagerClient{client: client}
}
// WithFilerClient implements FilerClient.
func (m *ManagerClient) WithFilerClient(streamingMode bool, fn func(client filer_pb.SeaweedFilerClient) error) error {
if m.client == nil {
return errors.New("nil filer client")
}
return fn(m.client)
}