Files
seaweedfs/weed/s3api/s3vectors/auth_fix_test.go
T
Chris LuandClaude Opus 5.5 21fc46692c s3vectors: authorize CreateVectorBucket as a vector action, close DeleteIndex race, fold policy service case (#12287)
* s3vectors: authorize CreateVectorBucket as an s3vectors action

CreateVectorBucket went through the catalog's CreateTableBucket check, so
an identity granted s3vectors:* was denied the create while s3tables:*
was allowed it. Check s3vectors:CreateVectorBucket in the gateway, the
same way the catalog checks a bucket create, and create the record on
the internal manager under the caller's name so the caller stays the
owner.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* s3vectors: delete only the index record DeleteIndex checked

DeleteIndex read the record, checked it is a Lance index, then deleted
by name. A table that replaced the index in between was deleted too.
Send the checked record's version token, so a replaced record fails the
delete with a 409 ConflictException.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* s3tables: reject s3vectors rules in any letter case

validatePolicyDocument kept s3vectors actions and resources out of the
shared policy slot with case-sensitive checks, so "S3Vectors:GetIndex"
was stored as a rule that never takes effect. IAM service prefixes are
case-insensitive; fold case in both checks.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 15:07:23 -07:00

116 lines
4.5 KiB
Go

package s3vectors
import (
"bytes"
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/seaweedfs/seaweedfs/weed/s3api"
"github.com/seaweedfs/seaweedfs/weed/s3api/s3_constants"
"github.com/seaweedfs/seaweedfs/weed/s3api/s3tables"
"github.com/seaweedfs/seaweedfs/weed/s3api/s3tables/s3tablestest"
)
// postJSONAsIdentity runs the handler with the identity object in the context,
// the way Auth leaves it.
func postJSONAsIdentity(t *testing.T, id *s3api.Identity, handler http.HandlerFunc, payload interface{}) *httptest.ResponseRecorder {
t.Helper()
body, err := json.Marshal(payload)
if err != nil {
t.Fatalf("marshal request: %v", err)
}
w := httptest.NewRecorder()
r := httptest.NewRequest(http.MethodPost, "/", bytes.NewReader(body))
ctx := s3_constants.SetIdentityInContext(s3_constants.SetIdentityNameInContext(r.Context(), id.Name), id)
handler(w, r.WithContext(ctx))
return w
}
// CreateVectorBucket is authorized as an s3vectors action, and the creator
// still owns the bucket.
func TestCreateVectorBucketChecksVectorAction(t *testing.T) {
fs := s3tablestest.Start(t)
s := NewServer(s3tables.NewManagerClient(fs.Client), allowAllAuth{}, newFakeVectors(0))
create := &CreateVectorBucketRequest{VectorBucketName: "vec-bucket"}
tablesOnly := &s3api.Identity{Name: "bob", Actions: []s3api.Action{"s3tables:*"}}
if w := postJSONAsIdentity(t, tablesOnly, s.handleCreateVectorBucket, create); w.Code != http.StatusForbidden {
t.Fatalf("create with s3tables:* = %d %s, want 403", w.Code, w.Body.String())
}
vectors := &s3api.Identity{Name: "alice", Actions: []s3api.Action{"s3vectors:*"}}
if w := postJSONAsIdentity(t, vectors, s.handleCreateVectorBucket, create); w.Code != http.StatusOK {
t.Fatalf("create with s3vectors:* = %d %s, want 200", w.Code, w.Body.String())
}
record, err := s.vectorBucket(context.Background(), "vec-bucket")
if err != nil {
t.Fatalf("read bucket record: %v", err)
}
if record.OwnerAccountID != "alice" {
t.Fatalf("owner = %q, want alice", record.OwnerAccountID)
}
get := &GetVectorBucketRequest{VectorBucketName: "vec-bucket"}
if w := postJSONAsIdentity(t, &s3api.Identity{Name: "alice"}, s.handleGetVectorBucket, get); w.Code != http.StatusOK {
t.Fatalf("owner get without actions = %d %s, want 200", w.Code, w.Body.String())
}
if w := postJSONAsIdentity(t, &s3api.Identity{Name: "carol"}, s.handleGetVectorBucket, get); w.Code != http.StatusForbidden {
t.Fatalf("non-owner get without actions = %d %s, want 403", w.Code, w.Body.String())
}
}
// DeleteIndex deletes only the record its format check read: a table that
// replaced the index in between survives, and the caller gets a conflict.
func TestDeleteIndexKeepsReplacedRecord(t *testing.T) {
fs := s3tablestest.Start(t)
s := NewServer(s3tables.NewManagerClient(fs.Client), allowAllAuth{}, newFakeVectors(0))
if w := postJSON(t, s.handleCreateVectorBucket, &CreateVectorBucketRequest{VectorBucketName: "vec-bucket"}); w.Code != http.StatusOK {
t.Fatalf("create bucket: %d %s", w.Code, w.Body.String())
}
if w := postJSON(t, s.handleCreateIndex, &CreateIndexRequest{
VectorBucketName: "vec-bucket", IndexName: "idx", DataType: "float32", Dimension: 3, DistanceMetric: "euclidean",
}); w.Code != http.StatusOK {
t.Fatalf("create index: %d %s", w.Code, w.Body.String())
}
dir, name := splitEntryPath(s3tables.GetTablePath("vec-bucket", indexNamespace, "idx"))
lookups := 0
fs.OnLookup = func(d, n string) {
if d != dir || n != name {
return
}
// The first lookup is the handler's format check; swap the record
// for an Iceberg table right after it.
if lookups++; lookups != 2 {
return
}
entry := fs.Get(dir, name)
var meta map[string]interface{}
if err := json.Unmarshal(entry.Extended[s3tables.ExtendedKeyMetadata], &meta); err != nil {
t.Errorf("decode index metadata: %v", err)
return
}
meta["format"] = s3tables.FormatIceberg
meta["versionToken"] = "replaced"
data, _ := json.Marshal(meta)
ext := make(map[string][]byte, len(entry.Extended))
for k, v := range entry.Extended {
ext[k] = v
}
ext[s3tables.ExtendedKeyMetadata] = data
fs.Put(dir, name, ext)
}
w := postJSON(t, s.handleDeleteIndex, &DeleteIndexRequest{VectorBucketName: "vec-bucket", IndexName: "idx"})
fs.OnLookup = nil
if w.Code != http.StatusConflict || !strings.Contains(w.Body.String(), "ConflictException") {
t.Fatalf("delete over a replaced record = %d %s, want 409 ConflictException", w.Code, w.Body.String())
}
if fs.Get(dir, name) == nil {
t.Fatal("DeleteIndex removed the table that replaced the index")
}
}