mirror of
https://github.com/seaweedfs/seaweedfs.git
synced 2026-10-11 08:47:46 +02:00
* s3tables: answer a denied write with 403 when the caller can read the entry Update/Delete/Rename answered every authorization refusal as not-found so the denial leaked no existence signal. For a caller allowed to GetTable (or GetView) the same entry, the veil hides nothing it could not load — yet a refused write was still answered 404, so clients saw a table they just loaded reported as missing. When the write check fails, re-check read permission on the same entry: readable entries get 403 AccessDenied; invisible ones keep the not-found answer. Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> * iceberg: map UpdateTable errors through writeManagerError on commit paths CommitTable and CommitTransaction answered any non-conflict UpdateTable failure, including AccessDenied and NoSuchTable, with a bare 500. A 500 reads as outcome-unknown to clients (PyIceberg raises CommitStateUnknownException) where a refused commit is a plain ForbiddenException, matching what the drop and rename handlers already emit through the same mapper. Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> * s3tables: keep the not-found veil over views and match the real read check on renames UpdateTable and DeleteTable read shared metadata without checking the entry kind, so a denied write on a view reported 403 to a GetTable-only caller where a missing name reports 404. The rename visibility check also fed resource tags into GetView evaluation that the real GetView path never supplies. * s3tables: gofmt handler_table.go Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> --------- Co-authored-by: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
214 lines
8.1 KiB
Go
214 lines
8.1 KiB
Go
package s3tables
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"net/http/httptest"
|
|
"testing"
|
|
|
|
"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"
|
|
)
|
|
|
|
type s3TablesHTTPError struct {
|
|
status int
|
|
body S3TablesError
|
|
}
|
|
|
|
func runUnauthorizedRequest(t *testing.T, m *Manager, fs *s3tablestest.MemFiler, operation string, input interface{}) s3TablesHTTPError {
|
|
t.Helper()
|
|
|
|
body, err := json.Marshal(input)
|
|
require.NoError(t, err)
|
|
identity := &testIdentity{Name: "attacker", Account: &testIdentityAccount{Id: "attacker"}, Actions: []string{"Read"}}
|
|
ctx := s3_constants.SetIdentityInContext(context.Background(), identity)
|
|
req, err := newManagerRequest(ctx, operation, body, "attacker")
|
|
require.NoError(t, err)
|
|
|
|
recorder := httptest.NewRecorder()
|
|
m.handler.HandleRequest(recorder, req, NewManagerClient(fs.Client))
|
|
result := recorder.Result()
|
|
defer result.Body.Close()
|
|
|
|
var response S3TablesError
|
|
require.NoError(t, json.NewDecoder(result.Body).Decode(&response))
|
|
return s3TablesHTTPError{status: result.StatusCode, body: response}
|
|
}
|
|
|
|
func TestTableBucketAuthorizationDenialMatchesMissing(t *testing.T) {
|
|
existing, manager := startRenameManager(t)
|
|
missing := s3tablestest.Start(t)
|
|
manager.SetTrusted(false)
|
|
manager.SetDefaultAllow(false)
|
|
|
|
for _, operation := range []string{"GetTableBucket", "DeleteTableBucket"} {
|
|
t.Run(operation, func(t *testing.T) {
|
|
var request interface{} = &GetTableBucketRequest{TableBucketARN: mustBucketARN(t)}
|
|
if operation == "DeleteTableBucket" {
|
|
request = &DeleteTableBucketRequest{TableBucketARN: mustBucketARN(t)}
|
|
}
|
|
|
|
want := runUnauthorizedRequest(t, manager, missing, operation, request)
|
|
got := runUnauthorizedRequest(t, manager, existing, operation, request)
|
|
assert.Equal(t, want, got)
|
|
assert.Equal(t, 404, got.status)
|
|
assert.Equal(t, ErrCodeNoSuchBucket, got.body.Type)
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestNamespaceAuthorizationDenialMatchesMissing(t *testing.T) {
|
|
existing, manager := startRenameManager(t)
|
|
missing := s3tablestest.Start(t)
|
|
manager.SetTrusted(false)
|
|
manager.SetDefaultAllow(false)
|
|
|
|
for _, operation := range []string{"GetNamespace", "UpdateNamespace", "DeleteNamespace"} {
|
|
t.Run(operation, func(t *testing.T) {
|
|
var request interface{} = &GetNamespaceRequest{TableBucketARN: mustBucketARN(t), Namespace: []string{"ns"}}
|
|
switch operation {
|
|
case "UpdateNamespace":
|
|
request = &UpdateNamespaceRequest{TableBucketARN: mustBucketARN(t), Namespace: []string{"ns"}}
|
|
case "DeleteNamespace":
|
|
request = &DeleteNamespaceRequest{TableBucketARN: mustBucketARN(t), Namespace: []string{"ns"}}
|
|
}
|
|
|
|
want := runUnauthorizedRequest(t, manager, missing, operation, request)
|
|
got := runUnauthorizedRequest(t, manager, existing, operation, request)
|
|
assert.Equal(t, want, got)
|
|
assert.Equal(t, 404, got.status)
|
|
assert.Equal(t, ErrCodeNoSuchNamespace, got.body.Type)
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestTableAuthorizationDenialMatchesMissing(t *testing.T) {
|
|
existing, manager := startRenameManager(t)
|
|
missing := s3tablestest.Start(t)
|
|
manager.SetTrusted(false)
|
|
manager.SetDefaultAllow(false)
|
|
|
|
for _, operation := range []string{"GetTable", "UpdateTable", "DeleteTable", "RenameTable"} {
|
|
t.Run(operation, func(t *testing.T) {
|
|
var request interface{} = &GetTableRequest{TableBucketARN: mustBucketARN(t), Namespace: []string{"ns"}, Name: "t"}
|
|
switch operation {
|
|
case "UpdateTable":
|
|
request = &UpdateTableRequest{TableBucketARN: mustBucketARN(t), Namespace: []string{"ns"}, Name: "t", VersionToken: "wrong"}
|
|
case "DeleteTable":
|
|
request = &DeleteTableRequest{TableBucketARN: mustBucketARN(t), Namespace: []string{"ns"}, Name: "t", VersionToken: "wrong"}
|
|
case "RenameTable":
|
|
request = &RenameTableRequest{TableBucketARN: mustBucketARN(t), SourceNamespace: []string{"ns"}, SourceName: "t", DestNamespace: []string{"ns"}, DestName: "t2"}
|
|
}
|
|
|
|
want := runUnauthorizedRequest(t, manager, missing, operation, request)
|
|
got := runUnauthorizedRequest(t, manager, existing, operation, request)
|
|
assert.Equal(t, want, got)
|
|
assert.Equal(t, 404, got.status)
|
|
assert.Equal(t, ErrCodeNoSuchTable, got.body.Type)
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestDeleteTableAuthorizedVersionMismatchStillConflicts(t *testing.T) {
|
|
fs, manager := startRenameManager(t)
|
|
err := manager.Execute(context.Background(), NewManagerClient(fs.Client), "DeleteTable", &DeleteTableRequest{
|
|
TableBucketARN: mustBucketARN(t),
|
|
Namespace: []string{"ns"},
|
|
Name: "t",
|
|
VersionToken: "wrong",
|
|
}, nil, "")
|
|
|
|
require.Error(t, err)
|
|
var s3Err *S3TablesError
|
|
require.ErrorAs(t, err, &s3Err)
|
|
assert.Equal(t, ErrCodeConflict, s3Err.Type)
|
|
assert.NotNil(t, fs.Get(GetNamespacePath(renameTestBucket, "ns"), "t"))
|
|
}
|
|
|
|
func runRequestAs(t *testing.T, m *Manager, fs *s3tablestest.MemFiler, operation string, input interface{}, actions []string) s3TablesHTTPError {
|
|
t.Helper()
|
|
|
|
body, err := json.Marshal(input)
|
|
require.NoError(t, err)
|
|
identity := &testIdentity{Name: "reader", Account: &testIdentityAccount{Id: "reader"}, Actions: actions}
|
|
ctx := s3_constants.SetIdentityInContext(context.Background(), identity)
|
|
req, err := newManagerRequest(ctx, operation, body, "reader")
|
|
require.NoError(t, err)
|
|
|
|
recorder := httptest.NewRecorder()
|
|
m.handler.HandleRequest(recorder, req, NewManagerClient(fs.Client))
|
|
result := recorder.Result()
|
|
defer result.Body.Close()
|
|
|
|
var response S3TablesError
|
|
if result.StatusCode >= 400 {
|
|
require.NoError(t, json.NewDecoder(result.Body).Decode(&response))
|
|
}
|
|
return s3TablesHTTPError{status: result.StatusCode, body: response}
|
|
}
|
|
|
|
// A principal that may read the table is told the write was refused; the
|
|
// not-found veil only hides the entry from callers that cannot see it.
|
|
func TestDeniedWritesReportForbiddenToReaders(t *testing.T) {
|
|
fs, manager := startRenameManager(t)
|
|
manager.SetTrusted(false)
|
|
manager.SetDefaultAllow(false)
|
|
readActions := []string{"s3tables:GetTable"}
|
|
|
|
got := runRequestAs(t, manager, fs, "GetTable", &GetTableRequest{
|
|
TableBucketARN: mustBucketARN(t), Namespace: []string{"ns"}, Name: "t"}, readActions)
|
|
assert.Equal(t, 200, got.status)
|
|
|
|
for _, tc := range []struct {
|
|
operation string
|
|
request interface{}
|
|
}{
|
|
{"UpdateTable", &UpdateTableRequest{TableBucketARN: mustBucketARN(t), Namespace: []string{"ns"}, Name: "t", VersionToken: "any"}},
|
|
{"DeleteTable", &DeleteTableRequest{TableBucketARN: mustBucketARN(t), Namespace: []string{"ns"}, Name: "t"}},
|
|
{"RenameTable", &RenameTableRequest{TableBucketARN: mustBucketARN(t), SourceNamespace: []string{"ns"}, SourceName: "t", DestNamespace: []string{"ns"}, DestName: "t2"}},
|
|
} {
|
|
t.Run(tc.operation, func(t *testing.T) {
|
|
got := runRequestAs(t, manager, fs, tc.operation, tc.request, readActions)
|
|
assert.Equal(t, 403, got.status)
|
|
assert.Equal(t, ErrCodeAccessDenied, got.body.Type)
|
|
})
|
|
}
|
|
}
|
|
|
|
// A view stored at a table name is hidden from table operations: a principal
|
|
// with only s3tables:GetTable must get the same not-found it would see for a
|
|
// genuinely missing name, never a 403 that reveals the entry exists.
|
|
func TestDeniedTableWritesDoNotRevealViews(t *testing.T) {
|
|
fs, manager := startRenameManager(t)
|
|
manager.SetTrusted(false)
|
|
manager.SetDefaultAllow(false)
|
|
|
|
viewMeta, _ := json.Marshal(tableMetadataInternal{
|
|
Name: "v",
|
|
Namespace: "ns",
|
|
OwnerAccountID: DefaultAccountID,
|
|
})
|
|
fs.Put(GetNamespacePath(renameTestBucket, "ns"), "v", map[string][]byte{
|
|
ExtendedKeyMetadata: viewMeta,
|
|
ExtendedKeyEntryType: []byte(EntryTypeView),
|
|
})
|
|
|
|
readActions := []string{"s3tables:GetTable"}
|
|
for _, tc := range []struct {
|
|
operation string
|
|
request interface{}
|
|
}{
|
|
{"GetTable", &GetTableRequest{TableBucketARN: mustBucketARN(t), Namespace: []string{"ns"}, Name: "v"}},
|
|
{"UpdateTable", &UpdateTableRequest{TableBucketARN: mustBucketARN(t), Namespace: []string{"ns"}, Name: "v", VersionToken: "any"}},
|
|
{"DeleteTable", &DeleteTableRequest{TableBucketARN: mustBucketARN(t), Namespace: []string{"ns"}, Name: "v"}},
|
|
} {
|
|
t.Run(tc.operation, func(t *testing.T) {
|
|
got := runRequestAs(t, manager, fs, tc.operation, tc.request, readActions)
|
|
assert.Equal(t, 404, got.status)
|
|
assert.Equal(t, ErrCodeNoSuchTable, got.body.Type)
|
|
})
|
|
}
|
|
}
|