mirror of
https://github.com/seaweedfs/seaweedfs.git
synced 2026-09-08 23:50:43 +02:00
* 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.
207 lines
8.3 KiB
Go
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)
|
|
}
|
|
}
|