Files
seaweedfs/weed/s3api/s3tables/vector_namespace_test.go
T
Chris LuandClaude Opus 5.5 ab355b43b9 s3tables: keep the vector-namespace fence on a declare of an existing index (#12293)
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>
2026-10-31 16:07:31 -07:00

291 lines
14 KiB
Go

package s3tables
import (
"context"
"strings"
"testing"
"github.com/seaweedfs/seaweedfs/weed/pb/filer_pb"
"github.com/seaweedfs/seaweedfs/weed/s3api/s3_constants"
"github.com/seaweedfs/seaweedfs/weed/s3api/s3tables/s3tablestest"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
const vectorTestBucket = "vecns"
func vectorTestARN(t *testing.T) string {
t.Helper()
arn, err := BuildBucketARN(DefaultRegion, DefaultAccountID, vectorTestBucket)
require.NoError(t, err)
return arn
}
// seedVectorBucket lays down, through a trusted Manager, a bucket of the given
// format holding vector/idx (with a policy and a tag) and other/t, the shape
// the S3 Vectors gateway leaves behind.
func seedVectorBucket(t *testing.T, fs *s3tablestest.MemFiler, format string) *Manager {
t.Helper()
trusted := NewManager()
trusted.SetTrusted(true)
run := func(operation string, req interface{}) {
t.Helper()
require.NoError(t, trusted.Execute(context.Background(), NewManagerClient(fs.Client), operation, req, nil, ""))
}
arn := vectorTestARN(t)
run("CreateTableBucket", &CreateTableBucketRequest{Name: vectorTestBucket, Format: format})
for _, ns := range []string{VectorIndexNamespace, "other"} {
run("CreateNamespace", &CreateNamespaceRequest{TableBucketARN: arn, Namespace: []string{ns}})
}
run("CreateTable", &CreateTableRequest{TableBucketARN: arn, Namespace: []string{VectorIndexNamespace}, Name: "idx", Format: format})
run("CreateTable", &CreateTableRequest{TableBucketARN: arn, Namespace: []string{"other"}, Name: "t", Format: format})
run("PutTablePolicy", &PutTablePolicyRequest{TableBucketARN: arn, Namespace: []string{VectorIndexNamespace}, Name: "idx",
ResourcePolicy: `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":"*","Action":"s3tables:GetTable","Resource":"*"}]}`})
idx, err := getTestTable(t, trusted, fs, VectorIndexNamespace, "idx")
require.NoError(t, err)
run("TagResource", &TagResourceRequest{ResourceARN: idx.TableARN, Tags: map[string]string{"k": "v"}})
return trusted
}
func getTestTable(t *testing.T, m *Manager, fs *s3tablestest.MemFiler, namespace, name string) (*GetTableResponse, error) {
t.Helper()
var resp GetTableResponse
err := m.Execute(context.Background(), NewManagerClient(fs.Client), "GetTable", &GetTableRequest{
TableBucketARN: vectorTestARN(t), Namespace: []string{namespace}, Name: name}, &resp, "")
return &resp, err
}
// vectorWrite labels a case "Operation detail" when an operation appears twice.
type vectorWrite struct {
operation string
request interface{}
}
func (w vectorWrite) op() string {
op, _, _ := strings.Cut(w.operation, " ")
return op
}
// vectorNamespaceWrites lists the catalog writes that reach vector/idx or move
// a table across the namespace boundary.
func vectorNamespaceWrites(t *testing.T, indexARN, versionToken string) []vectorWrite {
arn := vectorTestARN(t)
vector := []string{VectorIndexNamespace}
return []vectorWrite{
{"UpdateNamespace", &UpdateNamespaceRequest{TableBucketARN: arn, Namespace: vector, Properties: map[string]string{"a": "b"}}},
{"CreateTable", &CreateTableRequest{TableBucketARN: arn, Namespace: vector, Name: "fresh", Format: FormatLance}},
{"RegisterTable", &RegisterTableRequest{TableBucketARN: arn, Namespace: vector, Name: "registered",
MetadataLocation: "s3://" + vectorTestBucket + "/vector/registered/metadata/v1.metadata.json"}},
{"UpdateTable", &UpdateTableRequest{TableBucketARN: arn, Namespace: vector, Name: "idx", VersionToken: versionToken,
MetadataLocation: "s3://" + vectorTestBucket + "/vector/elsewhere"}},
{"DeleteTable", &DeleteTableRequest{TableBucketARN: arn, Namespace: vector, Name: "idx"}},
{"RenameTable out", &RenameTableRequest{TableBucketARN: arn, SourceNamespace: vector, SourceName: "idx",
DestNamespace: []string{"other"}, DestName: "moved"}},
{"RenameTable in", &RenameTableRequest{TableBucketARN: arn, SourceNamespace: []string{"other"}, SourceName: "t",
DestNamespace: vector, DestName: "t"}},
{"PutTablePolicy", &PutTablePolicyRequest{TableBucketARN: arn, Namespace: vector, Name: "idx",
ResourcePolicy: `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":"*","Action":"s3tables:*","Resource":"*"}]}`}},
{"DeleteTablePolicy", &DeleteTablePolicyRequest{TableBucketARN: arn, Namespace: vector, Name: "idx"}},
{"TagResource", &TagResourceRequest{ResourceARN: indexARN, Tags: map[string]string{"x": "y"}}},
{"UntagResource", &UntagResourceRequest{ResourceARN: indexARN, TagKeys: []string{"k"}}},
{"PutTableMaintenanceConfiguration", &PutTableMaintenanceConfigurationRequest{TableBucketARN: arn, Namespace: vector, Name: "idx",
Type: MaintenanceTypeIcebergCompaction, Value: &MaintenanceConfigurationValue{Status: "enabled"}}},
}
}
func untrustedManager() *Manager {
m := NewManager()
m.SetDefaultAllow(false)
return m
}
// A caller holding s3tables:* still cannot change the index namespace of a
// vector bucket, and nothing it tried is left behind.
func TestVectorNamespaceRefusesUntrustedWrites(t *testing.T) {
fs := s3tablestest.Start(t)
trusted := seedVectorBucket(t, fs, FormatLance)
before, err := getTestTable(t, trusted, fs, VectorIndexNamespace, "idx")
require.NoError(t, err)
m := untrustedManager()
granted := []string{"s3tables:*"}
for _, tc := range vectorNamespaceWrites(t, before.TableARN, before.VersionToken) {
t.Run(tc.operation, func(t *testing.T) {
got := runRequestAs(t, m, fs, tc.op(), tc.request, granted)
assert.Equal(t, 403, got.status)
assert.Equal(t, ErrCodeAccessDenied, got.body.Type)
assert.Equal(t, vectorNamespaceReadOnly, got.body.Message)
})
}
after, err := getTestTable(t, trusted, fs, VectorIndexNamespace, "idx")
require.NoError(t, err)
assert.Equal(t, before.VersionToken, after.VersionToken)
assert.Equal(t, before.MetadataLocation, after.MetadataLocation)
_, err = getTestTable(t, trusted, fs, "other", "t")
assert.NoError(t, err)
for _, absent := range [][2]string{{VectorIndexNamespace, "fresh"}, {VectorIndexNamespace, "registered"}, {VectorIndexNamespace, "t"}, {"other", "moved"}} {
_, err := getTestTable(t, trusted, fs, absent[0], absent[1])
assert.Error(t, err, "%s/%s should not exist", absent[0], absent[1])
}
var tags ListTagsForResourceResponse
require.NoError(t, trusted.Execute(context.Background(), NewManagerClient(fs.Client), "ListTagsForResource",
&ListTagsForResourceRequest{ResourceARN: before.TableARN}, &tags, ""))
assert.Equal(t, map[string]string{"k": "v"}, tags.Tags)
}
func TestVectorNamespaceCannotBeCreatedOrDeleted(t *testing.T) {
fs := s3tablestest.Start(t)
trusted := NewManager()
trusted.SetTrusted(true)
m := untrustedManager()
granted := []string{"s3tables:*"}
arn := vectorTestARN(t)
ctx := context.Background()
client := NewManagerClient(fs.Client)
require.NoError(t, trusted.Execute(ctx, client, "CreateTableBucket", &CreateTableBucketRequest{Name: vectorTestBucket, Format: FormatLance}, nil, ""))
create := &CreateNamespaceRequest{TableBucketARN: arn, Namespace: []string{VectorIndexNamespace}}
got := runRequestAs(t, m, fs, "CreateNamespace", create, granted)
assert.Equal(t, 403, got.status)
assert.Equal(t, vectorNamespaceReadOnly, got.body.Message)
require.NoError(t, trusted.Execute(ctx, client, "CreateNamespace", create, nil, ""))
got = runRequestAs(t, m, fs, "DeleteNamespace", &DeleteNamespaceRequest{TableBucketARN: arn, Namespace: []string{VectorIndexNamespace}}, granted)
assert.Equal(t, 403, got.status)
assert.Equal(t, vectorNamespaceReadOnly, got.body.Message)
require.NoError(t, trusted.Execute(ctx, client, "GetNamespace", &GetNamespaceRequest{TableBucketARN: arn, Namespace: []string{VectorIndexNamespace}}, nil, ""))
other := &CreateNamespaceRequest{TableBucketARN: arn, Namespace: []string{"other"}}
assert.Equal(t, 200, runRequestAs(t, m, fs, "CreateNamespace", other, granted).status)
}
// The vector engine declares an index the gateway already created; that
// CreateTable writes nothing and keeps succeeding, as do reads.
func TestVectorNamespaceAllowsReadsAndExistingCreate(t *testing.T) {
fs := s3tablestest.Start(t)
trusted := seedVectorBucket(t, fs, FormatLance)
before, err := getTestTable(t, trusted, fs, VectorIndexNamespace, "idx")
require.NoError(t, err)
m := untrustedManager()
granted := []string{"s3tables:*"}
arn := vectorTestARN(t)
vector := []string{VectorIndexNamespace}
for _, tc := range []vectorWrite{
{"CreateTable", &CreateTableRequest{TableBucketARN: arn, Namespace: vector, Name: "idx", Format: FormatLance}},
{"GetTable", &GetTableRequest{TableBucketARN: arn, Namespace: vector, Name: "idx"}},
{"ListTables", &ListTablesRequest{TableBucketARN: arn, Namespace: vector}},
{"GetNamespace", &GetNamespaceRequest{TableBucketARN: arn, Namespace: vector}},
{"GetTablePolicy", &GetTablePolicyRequest{TableBucketARN: arn, Namespace: vector, Name: "idx"}},
{"ListTagsForResource", &ListTagsForResourceRequest{ResourceARN: before.TableARN}},
} {
t.Run(tc.operation, func(t *testing.T) {
assert.Equal(t, 200, runRequestAs(t, m, fs, tc.op(), tc.request, granted).status)
})
}
after, err := getTestTable(t, trusted, fs, VectorIndexNamespace, "idx")
require.NoError(t, err)
assert.Equal(t, before.VersionToken, after.VersionToken)
require.NoError(t, trusted.Execute(context.Background(), NewManagerClient(fs.Client), "DeleteTable",
&DeleteTableRequest{TableBucketARN: arn, Namespace: vector, Name: "idx"}, nil, ""), "the trusted path stays writable")
}
// An Iceberg bucket's namespace named vector is an ordinary namespace.
func TestVectorNamespaceOfIcebergBucketIsWritable(t *testing.T) {
fs := s3tablestest.Start(t)
trusted := seedVectorBucket(t, fs, FormatIceberg)
before, err := getTestTable(t, trusted, fs, VectorIndexNamespace, "idx")
require.NoError(t, err)
m := untrustedManager()
granted := []string{"s3tables:*"}
for _, tc := range vectorNamespaceWrites(t, before.TableARN, before.VersionToken) {
switch tc.operation {
case "CreateTable", "UpdateTable", "DeleteTable", "RenameTable out":
continue // covered below: they need an Iceberg request or would consume idx
}
t.Run(tc.operation, func(t *testing.T) {
got := runRequestAs(t, m, fs, tc.op(), tc.request, granted)
assert.Equal(t, 200, got.status, "%+v", got.body)
})
}
arn := vectorTestARN(t)
vector := []string{VectorIndexNamespace}
assert.Equal(t, 200, runRequestAs(t, m, fs, "CreateTable",
&CreateTableRequest{TableBucketARN: arn, Namespace: vector, Name: "fresh", Format: FormatIceberg}, granted).status)
assert.Equal(t, 200, runRequestAs(t, m, fs, "DeleteTable",
&DeleteTableRequest{TableBucketARN: arn, Namespace: vector, Name: "idx"}, granted).status)
}
// A caller without a grant gets the answer an Iceberg bucket of the same name
// and shape gives: the read-only rule never tells it the bucket exists or
// holds vectors.
func TestVectorNamespaceDenialHidesBucketFormat(t *testing.T) {
lanceFS := s3tablestest.Start(t)
lance := seedVectorBucket(t, lanceFS, FormatLance)
icebergFS := s3tablestest.Start(t)
seedVectorBucket(t, icebergFS, FormatIceberg)
idx, err := getTestTable(t, lance, lanceFS, VectorIndexNamespace, "idx")
require.NoError(t, err)
m := untrustedManager()
arn := vectorTestARN(t)
writes := append(vectorNamespaceWrites(t, idx.TableARN, idx.VersionToken),
vectorWrite{"CreateNamespace", &CreateNamespaceRequest{TableBucketARN: arn, Namespace: []string{VectorIndexNamespace}}},
vectorWrite{"DeleteNamespace", &DeleteNamespaceRequest{TableBucketARN: arn, Namespace: []string{VectorIndexNamespace}}})
for _, tc := range writes {
t.Run(tc.operation, func(t *testing.T) {
got := runUnauthorizedRequest(t, m, lanceFS, tc.op(), tc.request)
want := runUnauthorizedRequest(t, m, icebergFS, tc.op(), tc.request)
assert.Equal(t, want, got)
assert.NotEqual(t, vectorNamespaceReadOnly, got.body.Message)
})
}
}
// A deferred create writes into the bucket before it registers anything, so
// its gate refuses the vector namespace as the create itself would.
func TestAuthorizeCreateTableRefusesVectorNamespace(t *testing.T) {
identity := &testIdentity{Name: "reader", Account: &testIdentityAccount{Id: "reader"}, Actions: []string{"s3tables:*"}}
ctx := s3_constants.SetIdentityInContext(context.Background(), identity)
req := &CreateTableRequest{TableBucketARN: vectorTestARN(t), Namespace: []string{VectorIndexNamespace}, Name: "fresh"}
lanceFS := s3tablestest.Start(t)
seedVectorBucket(t, lanceFS, FormatLance)
err := untrustedManager().AuthorizeCreateTable(ctx, NewManagerClient(lanceFS.Client), req, "reader")
var apiErr *S3TablesError
require.ErrorAs(t, err, &apiErr)
assert.Equal(t, ErrCodeAccessDenied, apiErr.Type)
assert.Equal(t, vectorNamespaceReadOnly, apiErr.Message)
icebergFS := s3tablestest.Start(t)
seedVectorBucket(t, icebergFS, FormatIceberg)
assert.NoError(t, untrustedManager().AuthorizeCreateTable(ctx, NewManagerClient(icebergFS.Client), req, "reader"))
}
// Behind the fence a rewrite that leaves the entry as stored, as an
// idempotent repair does, passes without writing; any real change is refused.
func TestVectorNamespaceFencePassesOnlyNoOpUpdates(t *testing.T) {
fs := s3tablestest.Start(t)
seedVectorBucket(t, fs, FormatLance)
h := untrustedManager().handler
fence := &vectorNamespaceFence{h: h, filer: NewManagerClient(fs.Client)}
idxPath := GetTablePath(vectorTestBucket, VectorIndexNamespace, "idx")
stored := fs.Get(GetNamespacePath(vectorTestBucket, VectorIndexNamespace), "idx").Extended[ExtendedKeyMetadata]
err := fence.WithFilerClient(false, func(client filer_pb.SeaweedFilerClient) error {
return h.setExtendedAttribute(context.Background(), client, idxPath, ExtendedKeyMetadata, stored)
})
require.NoError(t, err)
assert.False(t, fence.tripped.Load())
err = fence.WithFilerClient(false, func(client filer_pb.SeaweedFilerClient) error {
return h.setExtendedAttribute(context.Background(), client, idxPath, ExtendedKeyMetadata, []byte(`{}`))
})
assert.ErrorIs(t, err, errVectorNamespaceReadOnly)
assert.True(t, fence.tripped.Load())
assert.Equal(t, stored, fs.Get(GetNamespacePath(vectorTestBucket, VectorIndexNamespace), "idx").Extended[ExtendedKeyMetadata])
}