Files
seaweedfs/weed/s3api/s3api_server_grpc_test.go
Chris Lu 70a26cb5d2 s3: gate IAM-cache gRPC RPCs behind admin Bearer auth (#11190)
* s3: gate IAM-cache gRPC RPCs behind admin Bearer auth

The SeaweedS3IamCacheServer registered on the S3 gateway's internal gRPC
port (default 0.0.0.0:18333) accepted PutIdentity/RemoveIdentity/PutPolicy/
DeletePolicy/GetPolicy/ListPolicies/PutGroup/RemoveGroup with no per-RPC
authentication. An unauthenticated network peer could call PutIdentity with
Actions:[Admin] and write straight into the live accessKeyIdent map that
the SigV4 path reads, bypassing S3 authentication entirely.

Mirror the filer's IamGrpcServer.checkAdminAuth: require a Bearer token
signed with jwt.filer_signing.key (read from the existing s3a.filerGuard)
at the top of every IAM-cache RPC. With no key configured the check is a
no-op, matching the rest of SeaweedFS's gRPC surface.

* credential: attach admin Bearer token to S3 IAM-cache propagation

The filer's PropagatingCredentialStore fans IAM mutations out to peer S3
servers over the SeaweedS3IamCache gRPC service. Now that the S3 handlers
require a Bearer token signed with jwt.filer_signing.key, attach one to the
outgoing propagation context (mirroring shell/iamAdminAuthContext). With no
key configured it is a no-op, so deployments that run without the signing
key keep working.

* credential: mint IAM-cache admin token after master discovery

propagateChange attached the admin Bearer token before ListClusterNodes,
so master-client retries could run down the (default 10s) token lifetime
before the peer S3 fan-out began, leaving peers to reject an expired token
and IAM caches stale. Move withIamCacheAdminAuth to after discovery
succeeds, immediately before the propagation timeout is derived.

* credential: cap IAM-cache propagation timeout below JWT lifetime

The propagation fan-out used a fixed 10s timeout. If an operator
configures jwt.filer_signing.expires_after_seconds below 10, the admin
token can expire while slower S3 peers are still being contacted, leaving
their IAM caches stale. Derive the propagation deadline as
min(10s, tokenTTL) so it never outlives the token. withIamCacheAdminAuth
now returns the token's lifetime (0 = no expiry) for this purpose.
2026-09-06 12:21:51 -07:00

207 lines
8.3 KiB
Go

package s3api
import (
"context"
"testing"
"time"
jwt "github.com/golang-jwt/jwt/v5"
"github.com/seaweedfs/seaweedfs/weed/pb/iam_pb"
"github.com/seaweedfs/seaweedfs/weed/security"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/metadata"
"google.golang.org/grpc/status"
)
const testS3IamCacheSigningKey = "s3-iam-cache-test-key-do-not-use-in-prod"
func newTestS3IamCacheServer(t *testing.T, signingKey string) *S3ApiServer {
t.Helper()
s3a := newTestS3ApiServerWithMemoryIAM(t, nil)
s3a.filerGuard = security.NewGuard(nil, signingKey, 10, "", 60)
return s3a
}
func s3IamCacheBearerCtx(token string) context.Context {
md := metadata.New(map[string]string{"authorization": "Bearer " + token})
return metadata.NewIncomingContext(context.Background(), md)
}
func TestS3IamCache_NoMetadata_Unauthenticated(t *testing.T) {
s := newTestS3IamCacheServer(t, testS3IamCacheSigningKey)
_, err := s.PutIdentity(context.Background(), &iam_pb.PutIdentityRequest{
Identity: &iam_pb.Identity{Name: "pwn", Actions: []string{"Admin"}},
})
if got, want := status.Code(err), codes.Unauthenticated; got != want {
t.Fatalf("PutIdentity without metadata: got code %v, want %v (err=%v)", got, want, err)
}
}
func TestS3IamCache_MissingAuthorizationHeader_Unauthenticated(t *testing.T) {
s := newTestS3IamCacheServer(t, testS3IamCacheSigningKey)
ctx := metadata.NewIncomingContext(context.Background(), metadata.New(map[string]string{"other": "value"}))
_, err := s.PutIdentity(ctx, &iam_pb.PutIdentityRequest{
Identity: &iam_pb.Identity{Name: "pwn", Actions: []string{"Admin"}},
})
if got, want := status.Code(err), codes.Unauthenticated; got != want {
t.Fatalf("PutIdentity with no authorization header: got code %v, want %v (err=%v)", got, want, err)
}
}
func TestS3IamCache_NonBearerAuthorization_Unauthenticated(t *testing.T) {
s := newTestS3IamCacheServer(t, testS3IamCacheSigningKey)
md := metadata.New(map[string]string{"authorization": "Basic dXNlcjpwYXNz"})
ctx := metadata.NewIncomingContext(context.Background(), md)
_, err := s.PutIdentity(ctx, &iam_pb.PutIdentityRequest{
Identity: &iam_pb.Identity{Name: "pwn", Actions: []string{"Admin"}},
})
if got, want := status.Code(err), codes.Unauthenticated; got != want {
t.Fatalf("PutIdentity with non-Bearer scheme: got code %v, want %v (err=%v)", got, want, err)
}
}
func TestS3IamCache_InvalidToken_Unauthenticated(t *testing.T) {
s := newTestS3IamCacheServer(t, testS3IamCacheSigningKey)
bad := security.GenJwtForFilerAdmin(security.SigningKey("a-different-key"), 60)
if bad == "" {
t.Fatal("GenJwtForFilerAdmin returned empty")
}
_, err := s.PutIdentity(s3IamCacheBearerCtx(string(bad)), &iam_pb.PutIdentityRequest{
Identity: &iam_pb.Identity{Name: "pwn", Actions: []string{"Admin"}},
})
if got, want := status.Code(err), codes.Unauthenticated; got != want {
t.Fatalf("PutIdentity with mis-signed token: got code %v, want %v (err=%v)", got, want, err)
}
}
func TestS3IamCache_GarbageToken_Unauthenticated(t *testing.T) {
s := newTestS3IamCacheServer(t, testS3IamCacheSigningKey)
_, err := s.PutIdentity(s3IamCacheBearerCtx("not.a.jwt"), &iam_pb.PutIdentityRequest{
Identity: &iam_pb.Identity{Name: "pwn", Actions: []string{"Admin"}},
})
if got, want := status.Code(err), codes.Unauthenticated; got != want {
t.Fatalf("PutIdentity with garbage token: got code %v, want %v (err=%v)", got, want, err)
}
}
func TestS3IamCache_ExpiredToken_Unauthenticated(t *testing.T) {
s := newTestS3IamCacheServer(t, testS3IamCacheSigningKey)
claims := security.SeaweedFilerAdminClaims{
RegisteredClaims: jwt.RegisteredClaims{
ExpiresAt: jwt.NewNumericDate(time.Now().Add(-time.Hour)),
},
}
tok := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
encoded, err := tok.SignedString([]byte(testS3IamCacheSigningKey))
if err != nil {
t.Fatalf("SignedString: %v", err)
}
_, err = s.PutIdentity(s3IamCacheBearerCtx(encoded), &iam_pb.PutIdentityRequest{
Identity: &iam_pb.Identity{Name: "pwn", Actions: []string{"Admin"}},
})
if got, want := status.Code(err), codes.Unauthenticated; got != want {
t.Fatalf("PutIdentity with expired token: got code %v, want %v (err=%v)", got, want, err)
}
}
func TestS3IamCache_ValidToken_WritesIdentity(t *testing.T) {
s := newTestS3IamCacheServer(t, testS3IamCacheSigningKey)
good := security.GenJwtForFilerAdmin(security.SigningKey(testS3IamCacheSigningKey), 60)
if good == "" {
t.Fatal("GenJwtForFilerAdmin returned empty")
}
_, err := s.PutIdentity(s3IamCacheBearerCtx(string(good)), &iam_pb.PutIdentityRequest{
Identity: &iam_pb.Identity{
Name: "pushed",
Actions: []string{"Admin"},
Credentials: []*iam_pb.Credential{
{AccessKey: "AKIAPUSHED", SecretKey: "s3cr3t"},
},
},
})
if err != nil {
t.Fatalf("PutIdentity with valid token: unexpected error %v", err)
}
s.iam.m.RLock()
id := s.iam.accessKeyIdent["AKIAPUSHED"]
s.iam.m.RUnlock()
if id == nil {
t.Fatalf("expected pushed identity to land in accessKeyIdent map")
}
if !id.isAdmin() {
t.Fatalf("expected pushed identity to be admin, actions=%v", id.Actions)
}
}
func TestS3IamCache_NoSigningKey_Unauthenticated_Allowed(t *testing.T) {
s := newTestS3IamCacheServer(t, "")
_, err := s.PutIdentity(context.Background(), &iam_pb.PutIdentityRequest{
Identity: &iam_pb.Identity{Name: "pushed", Actions: []string{"Read"}},
})
if err != nil {
t.Fatalf("PutIdentity without key: unexpected error %v", err)
}
good := security.GenJwtForFilerAdmin(security.SigningKey(testS3IamCacheSigningKey), 60)
if _, err := s.PutIdentity(s3IamCacheBearerCtx(string(good)), &iam_pb.PutIdentityRequest{
Identity: &iam_pb.Identity{Name: "pushed2", Actions: []string{"Read"}},
}); err != nil {
t.Fatalf("PutIdentity with stray token but no server key: unexpected error %v", err)
}
}
func TestS3IamCache_RemoveIdentity_RequiresAuth(t *testing.T) {
s := newTestS3IamCacheServer(t, testS3IamCacheSigningKey)
_, err := s.RemoveIdentity(context.Background(), &iam_pb.RemoveIdentityRequest{Username: "anyone"})
if got, want := status.Code(err), codes.Unauthenticated; got != want {
t.Fatalf("RemoveIdentity without token: got code %v, want %v (err=%v)", got, want, err)
}
}
func TestS3IamCache_PutPolicy_RequiresAuth(t *testing.T) {
s := newTestS3IamCacheServer(t, testS3IamCacheSigningKey)
_, err := s.PutPolicy(context.Background(), &iam_pb.PutPolicyRequest{Name: "p", Content: "{}"})
if got, want := status.Code(err), codes.Unauthenticated; got != want {
t.Fatalf("PutPolicy without token: got code %v, want %v (err=%v)", got, want, err)
}
}
func TestS3IamCache_DeletePolicy_RequiresAuth(t *testing.T) {
s := newTestS3IamCacheServer(t, testS3IamCacheSigningKey)
_, err := s.DeletePolicy(context.Background(), &iam_pb.DeletePolicyRequest{Name: "p"})
if got, want := status.Code(err), codes.Unauthenticated; got != want {
t.Fatalf("DeletePolicy without token: got code %v, want %v (err=%v)", got, want, err)
}
}
func TestS3IamCache_GetPolicy_RequiresAuth(t *testing.T) {
s := newTestS3IamCacheServer(t, testS3IamCacheSigningKey)
_, err := s.GetPolicy(context.Background(), &iam_pb.GetPolicyRequest{Name: "p"})
if got, want := status.Code(err), codes.Unauthenticated; got != want {
t.Fatalf("GetPolicy without token: got code %v, want %v (err=%v)", got, want, err)
}
}
func TestS3IamCache_ListPolicies_RequiresAuth(t *testing.T) {
s := newTestS3IamCacheServer(t, testS3IamCacheSigningKey)
_, err := s.ListPolicies(context.Background(), &iam_pb.ListPoliciesRequest{})
if got, want := status.Code(err), codes.Unauthenticated; got != want {
t.Fatalf("ListPolicies without token: got code %v, want %v (err=%v)", got, want, err)
}
}
func TestS3IamCache_PutGroup_RequiresAuth(t *testing.T) {
s := newTestS3IamCacheServer(t, testS3IamCacheSigningKey)
_, err := s.PutGroup(context.Background(), &iam_pb.PutGroupRequest{Group: &iam_pb.Group{Name: "g"}})
if got, want := status.Code(err), codes.Unauthenticated; got != want {
t.Fatalf("PutGroup without token: got code %v, want %v (err=%v)", got, want, err)
}
}
func TestS3IamCache_RemoveGroup_RequiresAuth(t *testing.T) {
s := newTestS3IamCacheServer(t, testS3IamCacheSigningKey)
_, err := s.RemoveGroup(context.Background(), &iam_pb.RemoveGroupRequest{GroupName: "g"})
if got, want := status.Code(err), codes.Unauthenticated; got != want {
t.Fatalf("RemoveGroup without token: got code %v, want %v (err=%v)", got, want, err)
}
}