fix(s3tables): route REST RenameTable and refuse unrouted S3 Tables requests (#12446)

* s3tables: route REST RenameTable and refuse unrouted S3 Tables requests

PUT /tables/<arn>/<ns>/<name>/rename had no route, so it fell through to the
/{bucket} routers and ran as a PutObject into a bucket named "tables",
answering 2xx while the table kept its name.

- Answer any s3tables-signed request no S3 Tables route takes with 404
  UnknownOperationException, before the health, domain and bucket routers.
- Route the AWS RenameTable REST call (newNamespaceName, newName, both
  optional; versionToken).
- Honor versionToken on RenameTable, checked against the snapshot the claim
  asserts so a commit between the two reads cannot slip a stale token through.

* s3tables: match the credential scope's service, not a region named s3tables

The unrouted-request fallback widens what a false match costs: an S3
client whose region is named s3tables had every request answered as an
unknown S3 Tables operation.

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

* s3tables: refuse an empty newName or newNamespaceName on REST rename

An empty field was read as omitted, so {"newNamespaceName":"other",
"newName":""} moved the table under its old name instead of failing
validation the way AWS does.

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

* s3tables: check the rename version token after the snapshot comparison

Once the separately read metadata is known to equal the claimed snapshot,
the token compares against the parsed metadata; no second decode of the
snapshot bytes is needed.

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

* s3tables test: cover policy and tag changes in the rename snapshot compare

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

* s3tables test: give each REST rename subtest its own source table

The token subtests renamed the table the first subtest produced, so run
alone they failed on a missing table instead of testing the token.

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

---------

Co-authored-by: Chris Lu <chrislusf@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
authored and GitHub committed 2026-11-06 09:32:13 -08:00
1 parent c84c43f3da
commit f71c0fe523
8 files changed
+1027 -6

No files matched your search

+38
View File
@@ -336,6 +336,44 @@ func (c *S3TablesClient) DeleteTable(bucketARN string, namespace []string, name
return c.doRestRequestAndDecode("DeleteTable", http.MethodDelete, path, nil, nil)
}
// RenameTable renames a table through the AWS REST shape: the path names the
// source and the body the destination, where an empty newNamespace or newName
// keeps the source's. Any 2xx is success, as for the SDK (AWS answers 204).
func (c *S3TablesClient) RenameTable(bucketARN string, namespace []string, name, newNamespace, newName, versionToken string) error {
nameSpace, err := getFirstNamespace(namespace)
if err != nil {
return fmt.Errorf("RenameTable requires namespace: %w", err)
}
body := map[string]string{}
if newNamespace != "" {
body["newNamespaceName"] = newNamespace
}
if newName != "" {
body["newName"] = newName
}
if versionToken != "" {
body["versionToken"] = versionToken
}
path := "/tables/" + url.PathEscape(bucketARN) + "/" + url.PathEscape(nameSpace) + "/" + url.PathEscape(name) + "/rename"
resp, err := c.doRestRequest(http.MethodPut, path, body)
if err != nil {
return err
}
defer resp.Body.Close()
bodyBytes, readErr := io.ReadAll(resp.Body)
if resp.StatusCode >= 200 && resp.StatusCode < 300 {
return nil
}
if readErr != nil {
return fmt.Errorf("RenameTable failed with status %d and could not read error response body: %v", resp.StatusCode, readErr)
}
var errResp s3tables.S3TablesError
if err := json.Unmarshal(bodyBytes, &errResp); err != nil {
return fmt.Errorf("RenameTable failed with status %d, could not decode error response: %v. Body: %s", resp.StatusCode, err, string(bodyBytes))
}
return fmt.Errorf("RenameTable failed: %s - %s", errResp.Type, errResp.Message)
}
// Policy operations
func (c *S3TablesClient) PutTableBucketPolicy(bucketARN, policy string) error {
@@ -0,0 +1,116 @@
package s3tables
import (
"context"
"testing"
"github.com/aws/aws-sdk-go-v2/aws"
"github.com/aws/aws-sdk-go-v2/service/s3"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/seaweedfs/seaweedfs/weed/s3api/s3tables"
)
// emptyObjectBucket deletes every object in bucket.
func emptyObjectBucket(t *testing.T, ctx context.Context, client *s3.Client, bucket string) {
t.Helper()
pages := s3.NewListObjectsV2Paginator(client, &s3.ListObjectsV2Input{Bucket: aws.String(bucket)})
for pages.HasMorePages() {
page, err := pages.NextPage(ctx)
require.NoError(t, err)
for _, o := range page.Contents {
_, err := client.DeleteObject(ctx, &s3.DeleteObjectInput{Bucket: aws.String(bucket), Key: o.Key})
require.NoError(t, err)
}
}
}
// The AWS SDK sends RenameTable as PUT /tables/<arn>/<ns>/<name>/rename. With
// no route for it the gateway ran it as a PutObject into an S3 bucket named
// "tables" and answered 2xx, so the rename reported success while the table
// kept its old name. The bucket is created up front so that failure shows as
// an object landing in it.
func TestRenameTableREST(t *testing.T) {
if testing.Short() {
t.Skip("Skipping integration test in short mode")
}
ctx := context.Background()
tables := NewS3TablesClient(sharedCluster.s3Endpoint, testRegion, testAccessKey, testSecretKey)
objects := newObjectClient(t)
const decoyBucket = "tables"
if _, err := objects.CreateBucket(ctx, &s3.CreateBucketInput{Bucket: aws.String(decoyBucket)}); err != nil {
require.Equal(t, "BucketAlreadyOwnedByYou", apiErrorCode(err), "create the %q bucket: %v", decoyBucket, err)
}
emptyObjectBucket(t, ctx, objects, decoyBucket)
defer func() {
emptyObjectBucket(t, ctx, objects, decoyBucket)
_, _ = objects.DeleteBucket(ctx, &s3.DeleteBucketInput{Bucket: aws.String(decoyBucket)})
}()
created, err := tables.CreateTableBucket("rename-rest-"+randomString(8), nil)
require.NoError(t, err)
defer tables.DeleteTableBucket(created.ARN)
bucketARN := created.ARN
const namespace, otherNamespace = "rename_ns", "rename_other"
for _, ns := range []string{namespace, otherNamespace} {
_, err := tables.CreateNamespace(bucketARN, []string{ns})
require.NoError(t, err)
defer tables.DeleteNamespace(bucketARN, []string{ns})
}
// Each subtest owns its source table, so any one of them runs alone.
createSource := func(t *testing.T, name string) string {
resp, err := tables.CreateTable(bucketARN, []string{namespace}, name, "ICEBERG", nil, nil)
require.NoError(t, err)
return resp.VersionToken
}
t.Run("newName only", func(t *testing.T) {
createSource(t, "src")
defer tables.DeleteTable(bucketARN, []string{namespace}, "src")
defer tables.DeleteTable(bucketARN, []string{namespace}, "dst")
require.NoError(t, tables.RenameTable(bucketARN, []string{namespace}, "src", "", "dst", ""))
got, err := tables.GetTable(bucketARN, []string{namespace}, "dst")
require.NoError(t, err, "the renamed table must resolve under its new name")
assert.Equal(t, "dst", got.Name)
_, err = tables.GetTable(bucketARN, []string{namespace}, "src")
require.Error(t, err)
assert.Contains(t, err.Error(), s3tables.ErrCodeNoSuchTable)
list, err := objects.ListObjectsV2(ctx, &s3.ListObjectsV2Input{Bucket: aws.String(decoyBucket)})
require.NoError(t, err)
assert.Empty(t, list.Contents, "the rename must not store anything in the %q bucket", decoyBucket)
})
t.Run("stale versionToken", func(t *testing.T) {
token := createSource(t, "stale_src")
defer tables.DeleteTable(bucketARN, []string{namespace}, "stale_src")
defer tables.DeleteTable(bucketARN, []string{otherNamespace}, "stale_moved")
err := tables.RenameTable(bucketARN, []string{namespace}, "stale_src", otherNamespace, "stale_moved", token+"-stale")
require.Error(t, err)
assert.Contains(t, err.Error(), s3tables.ErrCodeConflict)
_, err = tables.GetTable(bucketARN, []string{namespace}, "stale_src")
require.NoError(t, err, "a refused rename must leave the source in place")
})
t.Run("newNamespaceName and newName with the current versionToken", func(t *testing.T) {
token := createSource(t, "tok_src")
defer tables.DeleteTable(bucketARN, []string{namespace}, "tok_src")
defer tables.DeleteTable(bucketARN, []string{otherNamespace}, "moved")
require.NoError(t, tables.RenameTable(bucketARN, []string{namespace}, "tok_src", otherNamespace, "moved", token))
got, err := tables.GetTable(bucketARN, []string{otherNamespace}, "moved")
require.NoError(t, err)
assert.Equal(t, "moved", got.Name)
_, err = tables.GetTable(bucketARN, []string{namespace}, "tok_src")
require.Error(t, err)
assert.Contains(t, err.Error(), s3tables.ErrCodeNoSuchTable)
})
}