Files
seaweedfs/weed/s3api/s3tables/handler_bucket_format_test.go
T
Chris LuandClaude Opus 5.5 2eb219df61 s3vectors: encrypt the objects of a vector bucket declared AES256 (#12324)
* s3api: encrypt the objects of a vector bucket declared AES256

CreateVectorBucket recorded encryptionConfiguration sseType AES256 and
returned it, but nothing on the write path read it: the index's data
files, manifests and transactions were stored without SSE.

A LANCE table bucket's record now supplies the bucket's default
encryption whenever the bucket has none of its own, so PutObject,
CreateMultipartUpload and CopyObject apply SSE-S3 to every object the
engine writes. The S3 bucket metadata itself is unchanged.

ICEBERG table buckets keep recording the declaration only: their
catalog reads the manifests clients upload straight from the filer,
which would see ciphertext.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011UpvafvK8JpagF94MHWqwK

* s3tables: refuse an AES256 vector bucket on a gateway without an SSE-S3 key

A vector bucket's AES256 is now applied to every index write, and SSE-S3
fails each write when the gateway has no KEK. Creating the bucket there
succeeded and the first CreateIndex came back as InternalFailure.

CreateTableBucket now refuses a LANCE bucket declaring AES256 when the
gateway reports no SSE-S3 key, naming s3.sse.kek and s3.sse.key;
CreateVectorBucket returns it as ValidationException. ICEBERG buckets,
whose declaration is only recorded, and tooling that sets no checker
are unaffected.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011UpvafvK8JpagF94MHWqwK

* s3api: refuse DeleteBucketEncryption on a bucket's declared encryption

GetBucketEncryption reports a vector bucket's declared AES256, but
DeleteBucketEncryption answered NoSuchBucketEncryptionConfiguration and
the declaration kept applying. The declaration is fixed when the bucket
is created, so the delete now returns MethodNotAllowed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011UpvafvK8JpagF94MHWqwK

* test(s3vectors): keep an inherited WEED_S3_SSE_KEK out of the test cluster

The gateway refuses to start SSE-S3 with both s3.sse.kek and s3.sse.key
set, so a developer shell exporting WEED_S3_SSE_KEK made the AES256
bucket test fail.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011UpvafvK8JpagF94MHWqwK

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-01 23:57:40 -08:00

148 lines
5.6 KiB
Go

package s3tables
import (
"context"
"encoding/json"
"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/require"
)
const formatTestBucket = "formats"
// bucketWithFormat lays down a table bucket holding one namespace, declared as
// the given format. An empty format is a bucket from before the declaration
// existed.
func bucketWithFormat(t *testing.T, format string) (*s3tablestest.MemFiler, *Manager) {
t.Helper()
fs := s3tablestest.Start(t)
m := NewManager()
bucketMeta, _ := json.Marshal(tableBucketMetadata{
Name: formatTestBucket,
OwnerAccountID: DefaultAccountID,
Format: format,
})
fs.Put(TablesPath, formatTestBucket, map[string][]byte{
ExtendedKeyTableBucket: []byte("{}"),
ExtendedKeyMetadata: bucketMeta,
})
nsMeta, _ := json.Marshal(namespaceMetadata{Namespace: []string{"ns"}, OwnerAccountID: DefaultAccountID})
fs.Put(GetTableBucketPath(formatTestBucket), "ns", map[string][]byte{ExtendedKeyMetadata: nsMeta})
return fs, m
}
func createTableOfFormat(t *testing.T, m *Manager, fs *s3tablestest.MemFiler, name, format string) error {
t.Helper()
return m.Execute(context.Background(), NewManagerClient(fs.Client), "CreateTable", &CreateTableRequest{
TableBucketARN: "arn:aws:s3tables:::bucket/" + formatTestBucket,
Namespace: []string{"ns"},
Name: name,
Format: format,
}, nil, "")
}
// The declaration is the point: a bucket that says it holds Lance cannot be
// handed an Iceberg table, because the catalog serving it would never show one.
func TestCreateTableRefusesAForeignFormat(t *testing.T) {
fs, m := bucketWithFormat(t, FormatLance)
require.NoError(t, createTableOfFormat(t, m, fs, "vectors", FormatLance))
err := createTableOfFormat(t, m, fs, "events", FormatIceberg)
require.Error(t, err, "an Iceberg table in a Lance bucket should be refused")
require.Contains(t, err.Error(), "holds LANCE")
}
func TestCreateTableRefusesLanceInAnIcebergBucket(t *testing.T) {
fs, m := bucketWithFormat(t, FormatIceberg)
err := createTableOfFormat(t, m, fs, "vectors", FormatLance)
require.Error(t, err)
require.Contains(t, err.Error(), "holds ICEBERG")
}
// A bucket made before formats were declared keeps taking anything. Nothing is
// migrated, so nothing that worked stops working.
func TestUndeclaredBucketAcceptsEitherFormat(t *testing.T) {
fs, m := bucketWithFormat(t, "")
require.NoError(t, createTableOfFormat(t, m, fs, "events", FormatIceberg))
require.NoError(t, createTableOfFormat(t, m, fs, "vectors", FormatLance))
}
// A view is Iceberg metadata, so it has no meaning in a bucket of another format.
func TestCreateViewRefusedInALanceBucket(t *testing.T) {
fs, m := bucketWithFormat(t, FormatLance)
err := m.Execute(context.Background(), NewManagerClient(fs.Client), "CreateView", &CreateViewRequest{
TableBucketARN: "arn:aws:s3tables:::bucket/" + formatTestBucket,
Namespace: []string{"ns"},
Name: "v",
}, nil, "")
require.Error(t, err)
require.Contains(t, err.Error(), "cannot hold views")
}
func TestNormalizeFormat(t *testing.T) {
cases := []struct {
in string
want string
ok bool
}{
{"ICEBERG", FormatIceberg, true},
{"iceberg", FormatIceberg, true},
{" Lance ", FormatLance, true},
{"delta", "", false},
{"", "", false},
}
for _, c := range cases {
got, ok := NormalizeFormat(c.in)
if got != c.want || ok != c.ok {
t.Errorf("NormalizeFormat(%q) = %q,%v; want %q,%v", c.in, got, ok, c.want, c.ok)
}
}
}
func TestDeclaredBucketEncryption(t *testing.T) {
entry := func(format string, cfg map[string]interface{}) *filer_pb.Entry {
meta, _ := json.Marshal(tableBucketMetadata{Name: "b", Format: format, EncryptionConfiguration: cfg})
return &filer_pb.Entry{Extended: map[string][]byte{ExtendedKeyTableBucket: []byte("true"), ExtendedKeyMetadata: meta}}
}
aes := map[string]interface{}{"sseAlgorithm": "AES256"}
require.Equal(t, "AES256", DeclaredBucketEncryption(entry(FormatLance, aes)))
require.Empty(t, DeclaredBucketEncryption(entry(FormatLance, nil)))
require.Empty(t, DeclaredBucketEncryption(entry(FormatIceberg, aes)), "the Iceberg catalog reads client manifests raw")
require.Empty(t, DeclaredBucketEncryption(entry("", aes)), "a bucket without a format is Iceberg")
require.Empty(t, DeclaredBucketEncryption(&filer_pb.Entry{Extended: map[string][]byte{ExtendedKeyMetadata: entry(FormatLance, aes).Extended[ExtendedKeyMetadata]}}), "not a table bucket")
require.Empty(t, DeclaredBucketEncryption(nil))
}
// Only a format whose files the catalog leaves to clients gets its declared
// AES256 applied, so only that format needs the gateway's SSE-S3 key.
func TestCreateTableBucketNeedsSSES3ForADeclaredLanceBucket(t *testing.T) {
fs := s3tablestest.Start(t)
m := NewManager()
m.SetEncryptionChecker(func() bool { return false })
create := func(name, format string, cfg map[string]interface{}) error {
return m.Execute(context.Background(), NewManagerClient(fs.Client), "CreateTableBucket",
&CreateTableBucketRequest{Name: name, Format: format, EncryptionConfiguration: cfg}, nil, s3_constants.AccountAdminId)
}
aes := map[string]interface{}{"sseAlgorithm": "AES256"}
err := create("lance-sealed", FormatLance, aes)
var s3Err *S3TablesError
require.ErrorAs(t, err, &s3Err)
require.Equal(t, ErrCodeInvalidRequest, s3Err.Type)
require.Contains(t, s3Err.Message, "s3.sse.kek")
require.NoError(t, create("lance-plain", FormatLance, nil))
require.NoError(t, create("iceberg-sealed", FormatIceberg, aes), "an Iceberg bucket only records the declaration")
}