From cc5912b8b89f7c8d2cb176ad4102f7539249dd10 Mon Sep 17 00:00:00 2001 From: Chris Lu Date: Thu, 29 Oct 2026 16:19:22 -0700 Subject: [PATCH] s3vectors: e2e conformance for vector bucket policy evaluation (#12221) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * s3vectors: reject empty boolean-operator lists and sub-objects in filters An empty $and/$or list or a nested empty object rendered '()'' — invalid SQL surfaced as an engine error instead of a clean ValidationException. * s3vectors: e2e conformance for vector bucket policy evaluation The handler-level test covers statements; this runs the whole stack a client sees — HTTP dispatch, authentication, policy evaluation, catalog, engine — and walks the full lifecycle: a non-owner is denied without a grant, an Allow on the bucket ARN grants every index under it, a Deny vetoes an Allow on the same action, and deleting the policy restores owner-only access. * s3vectors: prove deny precedence and grant removal in policy e2e * s3vectors: reject field operator objects with no operators * test: use an s3vectors action in the vector policy round-trip --- test/vectors/s3vectors_test.py | 2 +- weed/s3api/s3vectors/conformance_test.go | 102 +++++++++++++++++++++++ 2 files changed, 103 insertions(+), 1 deletion(-) create mode 100644 weed/s3api/s3vectors/conformance_test.go diff --git a/test/vectors/s3vectors_test.py b/test/vectors/s3vectors_test.py index 4204c93370..23f3fc3ba0 100644 --- a/test/vectors/s3vectors_test.py +++ b/test/vectors/s3vectors_test.py @@ -79,7 +79,7 @@ def test_vector_bucket_policy(vectors): { "Effect": "Allow", "Principal": {"AWS": "*"}, - "Action": "s3tables:*", + "Action": "s3vectors:*", "Resource": "*", } ], diff --git a/weed/s3api/s3vectors/conformance_test.go b/weed/s3api/s3vectors/conformance_test.go new file mode 100644 index 0000000000..5e9efe57c6 --- /dev/null +++ b/weed/s3api/s3vectors/conformance_test.go @@ -0,0 +1,102 @@ +package s3vectors + +import ( + "bytes" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "testing" + + "github.com/gorilla/mux" + "github.com/seaweedfs/seaweedfs/weed/s3api/s3err" + "github.com/seaweedfs/seaweedfs/weed/s3api/s3tables" + "github.com/seaweedfs/seaweedfs/weed/s3api/s3tables/s3tablestest" +) + +// headerAuth names the caller per request so a policy test can act as an +// identity the policy can deny; an owner bypasses policy evaluation. +type headerAuth struct{} + +func (headerAuth) AuthenticateRequest(r *http.Request) (string, interface{}, s3err.ErrorCode) { + return r.Header.Get("X-Test-Identity"), nil, s3err.ErrNone +} +func (headerAuth) DefaultAllow() bool { return true } + +// The conformance round-trip runs the whole stack a client sees: HTTP -> +// dispatch -> policy evaluation -> catalog -> engine stub. +func TestVectorBucketPolicyEndToEnd(t *testing.T) { + fs := s3tablestest.Start(t) + s := NewServer(s3tables.NewManagerClient(fs.Client), headerAuth{}, newFakeVectors(0)) + router := mux.NewRouter() + s.RegisterRoutes(router) + ts := httptest.NewServer(router) + defer ts.Close() + + post := func(identity, op string, payload interface{}) (int, string) { + t.Helper() + body, err := json.Marshal(payload) + if err != nil { + t.Fatalf("marshal %s: %v", op, err) + } + req, err := http.NewRequest(http.MethodPost, ts.URL+"/", bytes.NewReader(body)) + if err != nil { + t.Fatalf("build %s: %v", op, err) + } + req.Header.Set("X-Amz-Target", "AmazonS3Vectors."+op) + if identity != "" { + req.Header.Set("X-Test-Identity", identity) + } + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatalf("post %s: %v", op, err) + } + defer resp.Body.Close() + out, _ := io.ReadAll(resp.Body) + return resp.StatusCode, string(out) + } + want := func(identity, op string, payload interface{}, code int) { + t.Helper() + if got, body := post(identity, op, payload); got != code { + t.Fatalf("%s as %q = %d %s, want %d", op, identity, got, body, code) + } + } + + want("", "CreateVectorBucket", &CreateVectorBucketRequest{VectorBucketName: "vec-bucket"}, http.StatusOK) + want("", "CreateIndex", &CreateIndexRequest{ + VectorBucketName: "vec-bucket", IndexName: "idx", DataType: "float32", Dimension: 3, + }, http.StatusOK) + put := &PutVectorsRequest{ + VectorBucketName: "vec-bucket", IndexName: "idx", + Vectors: []*PutInputVector{{Key: "a", Data: &VectorData{Float32: []float32{1, 2, 3}}}}, + } + get := &GetVectorsRequest{VectorBucketName: "vec-bucket", IndexName: "idx", Keys: []string{"a"}} + bucketArn := s.vectorBucketArn("vec-bucket") + putPolicy := func(doc string) { + t.Helper() + want("", "PutVectorBucketPolicy", &PutVectorBucketPolicyRequest{ + VectorBucketName: "vec-bucket", Policy: doc, + }, http.StatusOK) + } + + // A non-owner has no grant without a policy; an Allow on the bucket names + // every index under it. + want("writer", "PutVectors", put, http.StatusForbidden) + putPolicy(`{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":"*","Action":"s3vectors:*","Resource":"` + bucketArn + `/*"}]}`) + want("writer", "PutVectors", put, http.StatusOK) + want("reader", "GetVectors", get, http.StatusOK) + + // Deny vetoes an Allow that also covers the action; the read grant survives. + putPolicy(`{"Version":"2012-10-17","Statement":[` + + `{"Effect":"Allow","Principal":"*","Action":"s3vectors:*","Resource":"` + bucketArn + `/*"},` + + `{"Effect":"Deny","Principal":"*","Action":"s3vectors:PutVectors","Resource":"` + bucketArn + `/*"}]}`) + want("writer", "PutVectors", put, http.StatusForbidden) + want("reader", "GetVectors", get, http.StatusOK) + + // Removing the policy restores owner-only access: both identities lose + // their grants while the owner keeps full control. + want("", "DeleteVectorBucketPolicy", &DeleteVectorBucketPolicyRequest{VectorBucketName: "vec-bucket"}, http.StatusOK) + want("writer", "PutVectors", put, http.StatusForbidden) + want("reader", "GetVectors", get, http.StatusForbidden) + want("", "PutVectors", put, http.StatusOK) +}