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) }