mirror of
https://github.com/seaweedfs/seaweedfs.git
synced 2026-11-01 09:50:39 +01:00
* s3tables: make the vector namespace of a vector bucket read-only S3 Vectors authorizes index operations against the vector bucket policy, but an index is also a catalog table in the bucket's "vector" namespace, and the S3 Tables API only checks s3tables grants. A caller the vector policy denies DeleteIndex could still delete the index, and its data, with DeleteTable. Untrusted writes that name the vector namespace of a LANCE bucket now run behind a fence that passes filer reads and refuses filer writes. The operation's own checks still answer everything short of a write, so a caller without the grant gets its usual denial and learns nothing about the bucket, and a CreateTable that finds the index already there, which is what the vector engine's declare does, still succeeds. A refused write answers 403 AccessDenied. Trusted callers (the gateway, the shell) are unaffected, and so is a namespace named vector in an Iceberg bucket. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * lance: refuse register and deregister in a vector bucket's vector namespace Every catalog write the Lance namespace makes goes through the S3 Tables handler and its vector namespace fence, but register over an existing table at the same location and deregister only add or remove a marker file in the dataset directory. Check the vector namespace before those writes, after the table read that already gates them. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * s3tables: refuse deferred creates in a vector bucket's vector namespace Manager.AuthorizeCreateTable gates the Iceberg create paths that write metadata into the table bucket before the catalog registers the table. Those writes never pass the handler's fence, so the gate applies the same read-only rule once the caller's own create permission is confirmed. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * s3tables: let a declare of an existing index past the vector namespace fence The vector engine declares every index the gateway has just created, and that CreateTable only reaches its idempotent path. Upstream that path writes nothing, so the fence never trips, but a build whose idempotent path repairs table state would refuse the engine. Decide the exception up front instead: a CreateTable naming a finished index in the vector namespace runs unfenced, while a new name or an unfinished create stays fenced. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
201 lines
6.8 KiB
Go
201 lines
6.8 KiB
Go
package s3tables
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"io"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
|
|
"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)
|
|
}
|
|
|
|
// PrincipalFor maps a forwarded identity name onto the account the catalog
|
|
// would charge the request to: a synthesized request resolves its identity
|
|
// name through getAccountID's name branch — normalized like any principal —
|
|
// and an empty name falls back to the handler's account ID.
|
|
func (m *Manager) PrincipalFor(identity string) string {
|
|
if principal := normalizePrincipalID(identity); principal != "" {
|
|
return principal
|
|
}
|
|
return m.handler.accountID
|
|
}
|
|
|
|
// 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.
|
|
func (m *Manager) PolicyContext(r *http.Request) *PolicyContext {
|
|
ctx := &PolicyContext{IsLocalAccountID: m.handler.localAccountIDs, IdentityActions: getIdentityActions(r)}
|
|
if m.handler.trusted || (m.handler.defaultAllow && s3_constants.GetIdentityNameFromContext(r) == "") {
|
|
ctx.DefaultAllow = true
|
|
}
|
|
return ctx
|
|
}
|
|
|
|
// 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)
|
|
}
|