mirror of
https://github.com/seaweedfs/seaweedfs.git
synced 2026-09-14 10:30:45 +02:00
* s3/lifecycle: attach admin Bearer token on internal LifecycleDelete clients Export credential.WithS3InternalAdminAuth (renamed from withIamCacheAdminAuth) and use it in the worker and shell lifecycle RPC adapters so lifecycle calls carry the same admin token the IAM-cache propagation already attaches. No-op when jwt.filer_signing.key is unset, matching the server-side checkAdminAuth. Prepares the internal clients for the server-side auth gate that follows. * s3/lifecycle: gate LifecycleDelete behind admin Bearer auth Add checkAdminAuth to LifecycleDelete, matching the SeaweedS3IamCache handlers on the same internal gRPC listener (PR #11190). No-op when jwt.filer_signing.key is unset; rejects unauthenticated callers when it is. The internal worker/shell clients already attach the token in the previous commit.
366 lines
13 KiB
Go
366 lines
13 KiB
Go
package s3api
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"testing"
|
|
|
|
"github.com/prometheus/client_golang/prometheus/testutil"
|
|
"github.com/seaweedfs/seaweedfs/weed/pb/filer_pb"
|
|
"github.com/seaweedfs/seaweedfs/weed/pb/s3_lifecycle_pb"
|
|
"github.com/seaweedfs/seaweedfs/weed/s3api/s3lifecycle"
|
|
"github.com/seaweedfs/seaweedfs/weed/security"
|
|
stats_collect "github.com/seaweedfs/seaweedfs/weed/stats"
|
|
"google.golang.org/grpc/codes"
|
|
"google.golang.org/grpc/status"
|
|
)
|
|
|
|
func TestComputeEntryIdentity_BasicFields(t *testing.T) {
|
|
entry := &filer_pb.Entry{
|
|
Attributes: &filer_pb.FuseAttributes{Mtime: 1700000000, MtimeNs: 123, FileSize: 4096},
|
|
Chunks: []*filer_pb.FileChunk{
|
|
{FileId: "1,abc"},
|
|
{FileId: "1,def"},
|
|
},
|
|
}
|
|
id := computeEntryIdentity(entry)
|
|
want := int64(1700000000)*int64(1e9) + int64(123)
|
|
if id.MtimeNs != want {
|
|
t.Fatalf("MtimeNs want %d, got %d", want, id.MtimeNs)
|
|
}
|
|
if id.Size != 4096 {
|
|
t.Fatalf("Size want 4096, got %d", id.Size)
|
|
}
|
|
if id.HeadFid != "1,abc" {
|
|
t.Fatalf("HeadFid want 1,abc, got %s", id.HeadFid)
|
|
}
|
|
}
|
|
|
|
func TestComputeEntryIdentity_NilSafeMissingChunks(t *testing.T) {
|
|
if got := computeEntryIdentity(nil); got != nil {
|
|
t.Fatalf("nil entry should return nil, got %v", got)
|
|
}
|
|
id := computeEntryIdentity(&filer_pb.Entry{})
|
|
if id == nil {
|
|
t.Fatalf("entry with nil Attributes should still produce identity")
|
|
}
|
|
if id.HeadFid != "" {
|
|
t.Fatalf("missing chunks should yield empty HeadFid, got %s", id.HeadFid)
|
|
}
|
|
}
|
|
|
|
func TestHashExtended_OrderStable(t *testing.T) {
|
|
a := map[string][]byte{"k1": []byte("v1"), "k2": []byte("v2")}
|
|
b := map[string][]byte{"k2": []byte("v2"), "k1": []byte("v1")}
|
|
if !bytes.Equal(s3lifecycle.HashExtended(a), s3lifecycle.HashExtended(b)) {
|
|
t.Fatalf("hash should be insensitive to map iteration order")
|
|
}
|
|
}
|
|
|
|
func TestHashExtended_DelimiterCollisionResistant(t *testing.T) {
|
|
// Naively concatenated: "k1=v1k2v2" could collide with "k1=v1k" / "2v2".
|
|
// Length-prefix encoding must keep them apart.
|
|
a := map[string][]byte{"k1": []byte("v1"), "k2": []byte("v2")}
|
|
b := map[string][]byte{"k1": []byte("v1k2v2")}
|
|
if bytes.Equal(s3lifecycle.HashExtended(a), s3lifecycle.HashExtended(b)) {
|
|
t.Fatalf("delimiter-forged Extended payloads must not collide")
|
|
}
|
|
}
|
|
|
|
func TestHashExtended_NilEqualsEmpty(t *testing.T) {
|
|
if got := s3lifecycle.HashExtended(nil); len(got) != 0 {
|
|
t.Fatalf("nil should produce zero-length hash, got %d bytes", len(got))
|
|
}
|
|
if got := s3lifecycle.HashExtended(map[string][]byte{}); len(got) != 0 {
|
|
t.Fatalf("empty map should produce zero-length hash, got %d bytes", len(got))
|
|
}
|
|
}
|
|
|
|
func TestIdentityMatches_NilWantTreatedAsMatch(t *testing.T) {
|
|
// Bootstrap callers that don't yet have an identity to CAS against
|
|
// pass nil expected_identity; the server treats this as "no CAS".
|
|
live := &s3_lifecycle_pb.EntryIdentity{MtimeNs: 1, Size: 2}
|
|
if !identityMatches(live, nil) {
|
|
t.Fatalf("nil want should match")
|
|
}
|
|
}
|
|
|
|
func TestIdentityMatches_NilLiveDoesNotMatch(t *testing.T) {
|
|
if identityMatches(nil, &s3_lifecycle_pb.EntryIdentity{MtimeNs: 1}) {
|
|
t.Fatalf("nil live should not match a populated want")
|
|
}
|
|
}
|
|
|
|
func TestIdentityMatches_AllFieldsCompared(t *testing.T) {
|
|
base := &s3_lifecycle_pb.EntryIdentity{MtimeNs: 100, Size: 2048, HeadFid: "1,abc", ExtendedHash: []byte{0x01, 0x02}}
|
|
cases := []struct {
|
|
name string
|
|
live *s3_lifecycle_pb.EntryIdentity
|
|
want bool
|
|
}{
|
|
{"identical", &s3_lifecycle_pb.EntryIdentity{MtimeNs: 100, Size: 2048, HeadFid: "1,abc", ExtendedHash: []byte{0x01, 0x02}}, true},
|
|
{"mtime-drift", &s3_lifecycle_pb.EntryIdentity{MtimeNs: 101, Size: 2048, HeadFid: "1,abc", ExtendedHash: []byte{0x01, 0x02}}, false},
|
|
{"size-drift", &s3_lifecycle_pb.EntryIdentity{MtimeNs: 100, Size: 2049, HeadFid: "1,abc", ExtendedHash: []byte{0x01, 0x02}}, false},
|
|
{"fid-drift", &s3_lifecycle_pb.EntryIdentity{MtimeNs: 100, Size: 2048, HeadFid: "1,xyz", ExtendedHash: []byte{0x01, 0x02}}, false},
|
|
{"extended-drift", &s3_lifecycle_pb.EntryIdentity{MtimeNs: 100, Size: 2048, HeadFid: "1,abc", ExtendedHash: []byte{0x03, 0x04}}, false},
|
|
}
|
|
for _, c := range cases {
|
|
t.Run(c.name, func(t *testing.T) {
|
|
if got := identityMatches(c.live, base); got != c.want {
|
|
t.Fatalf("want %v, got %v", c.want, got)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestLifecycleDelete_RejectsEmptyRequest(t *testing.T) {
|
|
s := &S3ApiServer{}
|
|
resp, err := s.LifecycleDelete(nil, &s3_lifecycle_pb.LifecycleDeleteRequest{})
|
|
if err != nil {
|
|
t.Fatalf("unexpected gRPC error: %v", err)
|
|
}
|
|
if resp.Outcome != s3_lifecycle_pb.LifecycleDeleteOutcome_BLOCKED {
|
|
t.Fatalf("empty request should be BLOCKED, got %v", resp.Outcome)
|
|
}
|
|
}
|
|
|
|
func TestLifecycleAbortMPU_RejectsTraversalUploadIDs(t *testing.T) {
|
|
// "." and ".." pass the no-slash check but resolve to the bucket
|
|
// root via util.JoinPath; they must be rejected before any rm call.
|
|
s := &S3ApiServer{}
|
|
cases := []string{
|
|
"",
|
|
".uploads",
|
|
".uploads/",
|
|
".uploads/.",
|
|
".uploads/..",
|
|
".uploads/u1/extra",
|
|
}
|
|
for _, path := range cases {
|
|
t.Run(path, func(t *testing.T) {
|
|
resp, err := s.LifecycleDelete(nil, &s3_lifecycle_pb.LifecycleDeleteRequest{
|
|
Bucket: "bk",
|
|
ObjectPath: path,
|
|
ActionKind: s3_lifecycle_pb.ActionKind_ABORT_MPU,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("unexpected gRPC error: %v", err)
|
|
}
|
|
if resp.Outcome != s3_lifecycle_pb.LifecycleDeleteOutcome_BLOCKED {
|
|
t.Fatalf("path %q: outcome=%v reason=%q, want BLOCKED",
|
|
path, resp.Outcome, resp.Reason)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestLifecycleDispatch_AbortMPUAfterFetchIsBlocked(t *testing.T) {
|
|
// LifecycleDelete routes ABORT_MPU to lifecycleAbortMPU before
|
|
// getObjectEntry; reaching lifecycleDispatch with ABORT_MPU means
|
|
// some caller bypassed that route. Defensive BLOCKED so a
|
|
// regression there can't accidentally rm a real object via the
|
|
// expiration paths.
|
|
s := &S3ApiServer{}
|
|
resp, err := s.lifecycleDispatch(nil, &s3_lifecycle_pb.LifecycleDeleteRequest{
|
|
Bucket: "bk",
|
|
ObjectPath: "k",
|
|
ActionKind: s3_lifecycle_pb.ActionKind_ABORT_MPU,
|
|
}, &filer_pb.Entry{Attributes: &filer_pb.FuseAttributes{}})
|
|
if err != nil {
|
|
t.Fatalf("unexpected gRPC error: %v", err)
|
|
}
|
|
if resp.Outcome != s3_lifecycle_pb.LifecycleDeleteOutcome_BLOCKED {
|
|
t.Fatalf("ABORT_MPU at dispatch should be BLOCKED, got %v reason=%q", resp.Outcome, resp.Reason)
|
|
}
|
|
if !contains(resp.Reason, "ABORT_MPU dispatched after fetch") {
|
|
t.Fatalf("reason should name the route bypass, got %q", resp.Reason)
|
|
}
|
|
}
|
|
|
|
func TestLifecycleDispatch_UnknownActionKindIsBlocked(t *testing.T) {
|
|
// An ActionKind value the proto doesn't define yet must produce a
|
|
// FATAL outcome rather than fall through to a default delete path.
|
|
s := &S3ApiServer{}
|
|
const bogus = s3_lifecycle_pb.ActionKind(999)
|
|
resp, err := s.lifecycleDispatch(nil, &s3_lifecycle_pb.LifecycleDeleteRequest{
|
|
Bucket: "bk",
|
|
ObjectPath: "k",
|
|
ActionKind: bogus,
|
|
}, &filer_pb.Entry{Attributes: &filer_pb.FuseAttributes{}})
|
|
if err != nil {
|
|
t.Fatalf("unexpected gRPC error: %v", err)
|
|
}
|
|
if resp.Outcome != s3_lifecycle_pb.LifecycleDeleteOutcome_BLOCKED {
|
|
t.Fatalf("unknown action kind should be BLOCKED, got %v reason=%q", resp.Outcome, resp.Reason)
|
|
}
|
|
if !contains(resp.Reason, "unknown action_kind") {
|
|
t.Fatalf("reason should name the unknown kind, got %q", resp.Reason)
|
|
}
|
|
}
|
|
|
|
func TestLifecycleDispatch_NoncurrentRequiresVersionID(t *testing.T) {
|
|
// Noncurrent / EXPIRED_DELETE_MARKER target a specific version; an
|
|
// empty version_id is a writer-side bug and must be rejected before
|
|
// any filer call. This pinning keeps the early-return in place so
|
|
// a refactor doesn't accidentally let the empty-version_id path
|
|
// reach deleteSpecificObjectVersion.
|
|
s := &S3ApiServer{}
|
|
for _, kind := range []s3_lifecycle_pb.ActionKind{
|
|
s3_lifecycle_pb.ActionKind_NONCURRENT_DAYS,
|
|
s3_lifecycle_pb.ActionKind_NEWER_NONCURRENT,
|
|
s3_lifecycle_pb.ActionKind_EXPIRED_DELETE_MARKER,
|
|
} {
|
|
t.Run(kind.String(), func(t *testing.T) {
|
|
resp, err := s.lifecycleDispatch(nil, &s3_lifecycle_pb.LifecycleDeleteRequest{
|
|
Bucket: "bk",
|
|
ObjectPath: "k",
|
|
ActionKind: kind,
|
|
// VersionId intentionally empty
|
|
}, &filer_pb.Entry{Attributes: &filer_pb.FuseAttributes{}})
|
|
if err != nil {
|
|
t.Fatalf("unexpected gRPC error: %v", err)
|
|
}
|
|
if resp.Outcome != s3_lifecycle_pb.LifecycleDeleteOutcome_BLOCKED {
|
|
t.Fatalf("kind %v with empty version_id should be BLOCKED, got %v reason=%q",
|
|
kind, resp.Outcome, resp.Reason)
|
|
}
|
|
if !contains(resp.Reason, "version_id required") {
|
|
t.Fatalf("reason should name the missing version_id, got %q", resp.Reason)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// contains is a tiny helper so the tests above don't pull in strings
|
|
// just for a substring check.
|
|
func contains(haystack, needle string) bool {
|
|
if len(needle) == 0 {
|
|
return true
|
|
}
|
|
for i := 0; i+len(needle) <= len(haystack); i++ {
|
|
if haystack[i:i+len(needle)] == needle {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func TestEntryUsesMetadataOnlyDelete(t *testing.T) {
|
|
cases := []struct {
|
|
name string
|
|
entry *filer_pb.Entry
|
|
want bool
|
|
}{
|
|
{
|
|
name: "nil entry",
|
|
entry: nil,
|
|
want: false,
|
|
},
|
|
{
|
|
name: "nil attributes",
|
|
entry: &filer_pb.Entry{},
|
|
want: false,
|
|
},
|
|
{
|
|
name: "TtlSec=0 (no per-write stamp)",
|
|
entry: &filer_pb.Entry{Attributes: &filer_pb.FuseAttributes{TtlSec: 0}},
|
|
want: false,
|
|
},
|
|
{
|
|
name: "TtlSec>0 (PR 9377 stamped a fast-path TTL)",
|
|
entry: &filer_pb.Entry{Attributes: &filer_pb.FuseAttributes{TtlSec: 86400}},
|
|
want: true,
|
|
},
|
|
{
|
|
name: "TtlSec<0 should not happen but must not flip the path on",
|
|
entry: &filer_pb.Entry{Attributes: &filer_pb.FuseAttributes{TtlSec: -1}},
|
|
want: false,
|
|
},
|
|
}
|
|
for _, c := range cases {
|
|
t.Run(c.name, func(t *testing.T) {
|
|
if got := entryUsesMetadataOnlyDelete(c.entry); got != c.want {
|
|
t.Fatalf("want %v, got %v", c.want, got)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestRecordMetadataOnlyIf_OnlyFiresWhenOn(t *testing.T) {
|
|
// Counter must increment exactly once per (bucket, hex(rule_hash))
|
|
// when on=true, and not at all when on=false. Other lifecycle paths
|
|
// in the same suite share the global counter — use distinct bucket
|
|
// names per test so series don't bleed.
|
|
c := stats_collect.S3LifecycleMetadataOnlyCounter
|
|
bucket := "bk-counter-on"
|
|
hash := []byte{0xde, 0xad, 0xbe, 0xef, 0x01, 0x02, 0x03, 0x04}
|
|
hexHash := "deadbeef01020304"
|
|
|
|
before := testutil.ToFloat64(c.WithLabelValues(bucket, hexHash))
|
|
recordMetadataOnlyIf(true, &s3_lifecycle_pb.LifecycleDeleteRequest{
|
|
Bucket: bucket,
|
|
RuleHash: hash,
|
|
})
|
|
if got := testutil.ToFloat64(c.WithLabelValues(bucket, hexHash)); got != before+1 {
|
|
t.Fatalf("on=true should bump by 1; before=%v after=%v", before, got)
|
|
}
|
|
|
|
beforeOff := testutil.ToFloat64(c.WithLabelValues("bk-counter-off", hexHash))
|
|
recordMetadataOnlyIf(false, &s3_lifecycle_pb.LifecycleDeleteRequest{
|
|
Bucket: "bk-counter-off",
|
|
RuleHash: hash,
|
|
})
|
|
if got := testutil.ToFloat64(c.WithLabelValues("bk-counter-off", hexHash)); got != beforeOff {
|
|
t.Fatalf("on=false should not bump; before=%v after=%v", beforeOff, got)
|
|
}
|
|
}
|
|
|
|
func TestRecordMetadataOnlyIf_NilRequestSafe(t *testing.T) {
|
|
// A nil req is a defensive no-op; never panic on the prometheus
|
|
// label call which would otherwise dereference req.Bucket.
|
|
recordMetadataOnlyIf(true, nil)
|
|
}
|
|
|
|
func TestRecordMetadataOnlyIf_EmptyRuleHashCollapsesToEmptyLabel(t *testing.T) {
|
|
// Bootstrap or test paths may not stamp a rule hash; the label
|
|
// must end up as an empty string rather than panicking on
|
|
// hex.EncodeToString(nil).
|
|
c := stats_collect.S3LifecycleMetadataOnlyCounter
|
|
bucket := "bk-counter-emptyhash"
|
|
before := testutil.ToFloat64(c.WithLabelValues(bucket, ""))
|
|
recordMetadataOnlyIf(true, &s3_lifecycle_pb.LifecycleDeleteRequest{Bucket: bucket})
|
|
if got := testutil.ToFloat64(c.WithLabelValues(bucket, "")); got != before+1 {
|
|
t.Fatalf("nil rule_hash should produce empty-label series; before=%v after=%v", before, got)
|
|
}
|
|
}
|
|
|
|
func TestLifecycleDelete_RequiresAdminAuth(t *testing.T) {
|
|
s := newTestS3IamCacheServer(t, testS3IamCacheSigningKey)
|
|
_, err := s.LifecycleDelete(context.Background(), &s3_lifecycle_pb.LifecycleDeleteRequest{
|
|
Bucket: "victim", ObjectPath: "important.dat",
|
|
ActionKind: s3_lifecycle_pb.ActionKind_EXPIRATION_DAYS,
|
|
})
|
|
if got, want := status.Code(err), codes.Unauthenticated; got != want {
|
|
t.Fatalf("LifecycleDelete without token: got code %v, want %v (err=%v)", got, want, err)
|
|
}
|
|
bad := security.GenJwtForFilerAdmin(security.SigningKey("a-different-key"), 60)
|
|
_, err = s.LifecycleDelete(s3IamCacheBearerCtx(string(bad)), &s3_lifecycle_pb.LifecycleDeleteRequest{
|
|
Bucket: "victim", ObjectPath: "important.dat",
|
|
ActionKind: s3_lifecycle_pb.ActionKind_EXPIRATION_DAYS,
|
|
})
|
|
if got, want := status.Code(err), codes.Unauthenticated; got != want {
|
|
t.Fatalf("LifecycleDelete with mis-signed token: got code %v, want %v (err=%v)", got, want, err)
|
|
}
|
|
}
|
|
|
|
func TestLifecycleDelete_NoSigningKey_Allowed(t *testing.T) {
|
|
s := newTestS3IamCacheServer(t, "")
|
|
resp, err := s.LifecycleDelete(context.Background(), &s3_lifecycle_pb.LifecycleDeleteRequest{})
|
|
if err != nil {
|
|
t.Fatalf("LifecycleDelete without signing key: unexpected error %v", err)
|
|
}
|
|
if resp == nil || resp.Outcome != s3_lifecycle_pb.LifecycleDeleteOutcome_BLOCKED {
|
|
t.Fatalf("expected BLOCKED for empty request without signing key, got %v", resp)
|
|
}
|
|
}
|