From 10c0857476d248a396920f6bbcf17f693cd1dc29 Mon Sep 17 00:00:00 2001 From: Chris Lu Date: Sun, 13 Sep 2026 13:07:10 -0700 Subject: [PATCH] s3: gate internal LifecycleDelete gRPC behind admin Bearer auth (#11291) * 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. --- weed/credential/propagating_store.go | 14 ++++---- .../credential/propagating_store_auth_test.go | 12 +++---- weed/s3api/s3api_internal_lifecycle.go | 3 ++ weed/s3api/s3api_internal_lifecycle_test.go | 34 +++++++++++++++++++ weed/shell/command_s3_lifecycle_run_shard.go | 2 ++ weed/worker/tasks/s3_lifecycle/handler.go | 2 ++ 6 files changed, 54 insertions(+), 13 deletions(-) diff --git a/weed/credential/propagating_store.go b/weed/credential/propagating_store.go index f7ce1a7be..9e2699bd1 100644 --- a/weed/credential/propagating_store.go +++ b/weed/credential/propagating_store.go @@ -51,12 +51,12 @@ func (s *PropagatingCredentialStore) SetFilerAddressFunc(getFiler func() pb.Serv } } -// withIamCacheAdminAuth attaches a Bearer token signed with jwt.filer_signing.key -// to the outgoing context so the S3 gateway's IAM-cache gRPC handlers accept the -// propagation. With no key configured it is a no-op, matching the S3 handler's -// checkAdminAuth. Returns the token's lifetime (0 = no expiry) so callers can -// cap any downstream timeout below it. -func withIamCacheAdminAuth(ctx context.Context) (context.Context, time.Duration) { +// WithS3InternalAdminAuth attaches a Bearer token signed with +// jwt.filer_signing.key to the outgoing context so the S3 gateway's internal +// gRPC handlers (IAM cache, lifecycle) accept the call. With no key configured +// it is a no-op, matching the S3 handler's checkAdminAuth. Returns the token's +// lifetime (0 = no expiry) so callers can cap any downstream timeout below it. +func WithS3InternalAdminAuth(ctx context.Context) (context.Context, time.Duration) { signingKey := util.GetViper().GetString("jwt.filer_signing.key") if signingKey == "" { return ctx, 0 @@ -102,7 +102,7 @@ func (s *PropagatingCredentialStore) propagateChange(ctx context.Context, fn fun // through the token lifetime before the peer fan-out begins. Cap the // propagation deadline below the token's expiry so slower peers don't see // an expired token. - authedCtx, tokenTTL := withIamCacheAdminAuth(ctx) + authedCtx, tokenTTL := WithS3InternalAdminAuth(ctx) propagateTimeout := 10 * time.Second if tokenTTL > 0 && tokenTTL < propagateTimeout { propagateTimeout = tokenTTL diff --git a/weed/credential/propagating_store_auth_test.go b/weed/credential/propagating_store_auth_test.go index f69e5a618..a200d1fa6 100644 --- a/weed/credential/propagating_store_auth_test.go +++ b/weed/credential/propagating_store_auth_test.go @@ -11,9 +11,9 @@ import ( "google.golang.org/grpc/metadata" ) -func TestWithIamCacheAdminAuth_NoKey_NoOp(t *testing.T) { +func TestWithS3InternalAdminAuth_NoKey_NoOp(t *testing.T) { util.GetViper().Set("jwt.filer_signing.key", "") - ctx, ttl := withIamCacheAdminAuth(context.Background()) + ctx, ttl := WithS3InternalAdminAuth(context.Background()) if ttl != 0 { t.Fatalf("expected zero TTL without key, got %v", ttl) } @@ -23,13 +23,13 @@ func TestWithIamCacheAdminAuth_NoKey_NoOp(t *testing.T) { } } -func TestWithIamCacheAdminAuth_WithKey_AttachesBearer(t *testing.T) { +func TestWithS3InternalAdminAuth_WithKey_AttachesBearer(t *testing.T) { const k = "propagation-test-signing-key" util.GetViper().Set("jwt.filer_signing.key", k) defer util.GetViper().Set("jwt.filer_signing.key", "") util.GetViper().Set("jwt.filer_signing.expires_after_seconds", 60) - ctx, ttl := withIamCacheAdminAuth(context.Background()) + ctx, ttl := WithS3InternalAdminAuth(context.Background()) if ttl != 60*time.Second { t.Fatalf("expected 60s TTL, got %v", ttl) } @@ -51,13 +51,13 @@ func TestWithIamCacheAdminAuth_WithKey_AttachesBearer(t *testing.T) { } } -func TestWithIamCacheAdminAuth_ZeroExpiry_ZeroTTL(t *testing.T) { +func TestWithS3InternalAdminAuth_ZeroExpiry_ZeroTTL(t *testing.T) { const k = "propagation-test-signing-key" util.GetViper().Set("jwt.filer_signing.key", k) defer util.GetViper().Set("jwt.filer_signing.key", "") util.GetViper().Set("jwt.filer_signing.expires_after_seconds", 0) - _, ttl := withIamCacheAdminAuth(context.Background()) + _, ttl := WithS3InternalAdminAuth(context.Background()) if ttl != 0 { t.Fatalf("expected zero TTL for no-expiry token, got %v", ttl) } diff --git a/weed/s3api/s3api_internal_lifecycle.go b/weed/s3api/s3api_internal_lifecycle.go index f32c478c7..79950dced 100644 --- a/weed/s3api/s3api_internal_lifecycle.go +++ b/weed/s3api/s3api_internal_lifecycle.go @@ -20,6 +20,9 @@ import ( // CAS, object-lock check, dispatch by kind. Errors surface as outcomes; // reader cursors and pending state are the worker's concern. func (s3a *S3ApiServer) LifecycleDelete(ctx context.Context, req *s3_lifecycle_pb.LifecycleDeleteRequest) (*s3_lifecycle_pb.LifecycleDeleteResponse, error) { + if err := s3a.checkAdminAuth(ctx); err != nil { + return nil, err + } if req == nil || req.Bucket == "" || req.ObjectPath == "" { return blocked("FATAL_EVENT_ERROR: empty bucket or object_path"), nil } diff --git a/weed/s3api/s3api_internal_lifecycle_test.go b/weed/s3api/s3api_internal_lifecycle_test.go index 6a8cbeb7c..b2a518c22 100644 --- a/weed/s3api/s3api_internal_lifecycle_test.go +++ b/weed/s3api/s3api_internal_lifecycle_test.go @@ -2,13 +2,17 @@ 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) { @@ -329,3 +333,33 @@ func TestRecordMetadataOnlyIf_EmptyRuleHashCollapsesToEmptyLabel(t *testing.T) { 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) + } +} diff --git a/weed/shell/command_s3_lifecycle_run_shard.go b/weed/shell/command_s3_lifecycle_run_shard.go index 9ec168298..1b2f45bab 100644 --- a/weed/shell/command_s3_lifecycle_run_shard.go +++ b/weed/shell/command_s3_lifecycle_run_shard.go @@ -11,6 +11,7 @@ import ( "strings" "time" + "github.com/seaweedfs/seaweedfs/weed/credential" "github.com/seaweedfs/seaweedfs/weed/pb" "github.com/seaweedfs/seaweedfs/weed/pb/filer_pb" "github.com/seaweedfs/seaweedfs/weed/pb/s3_lifecycle_pb" @@ -314,6 +315,7 @@ type lifecycleClientCallable struct { } func (l *lifecycleClientCallable) LifecycleDelete(ctx context.Context, req *s3_lifecycle_pb.LifecycleDeleteRequest) (*s3_lifecycle_pb.LifecycleDeleteResponse, error) { + ctx, _ = credential.WithS3InternalAdminAuth(ctx) return l.c.LifecycleDelete(ctx, req) } diff --git a/weed/worker/tasks/s3_lifecycle/handler.go b/weed/worker/tasks/s3_lifecycle/handler.go index 499520376..173725009 100644 --- a/weed/worker/tasks/s3_lifecycle/handler.go +++ b/weed/worker/tasks/s3_lifecycle/handler.go @@ -7,6 +7,7 @@ import ( "strconv" "time" + "github.com/seaweedfs/seaweedfs/weed/credential" "github.com/seaweedfs/seaweedfs/weed/glog" "github.com/seaweedfs/seaweedfs/weed/pb" "github.com/seaweedfs/seaweedfs/weed/pb/filer_pb" @@ -423,6 +424,7 @@ type lifecycleRPCAdapter struct { } func (a lifecycleRPCAdapter) LifecycleDelete(ctx context.Context, req *s3_lifecycle_pb.LifecycleDeleteRequest) (*s3_lifecycle_pb.LifecycleDeleteResponse, error) { + ctx, _ = credential.WithS3InternalAdminAuth(ctx) return a.c.LifecycleDelete(ctx, req) }