diff --git a/weed/command/filer.go b/weed/command/filer.go index 736ac9898..c1ec5a424 100644 --- a/weed/command/filer.go +++ b/weed/command/filer.go @@ -431,11 +431,22 @@ func (fo *FilerOptions) startFiler() { grpcS := pb.NewGrpcServer(security.LoadServerTLS(util.GetViper(), "grpc.filer")) filer_pb.RegisterSeaweedFilerServer(grpcS, fs) - // Register IAM gRPC service if credential manager is available + // Register IAM gRPC service only when both a credential manager and an + // admin signing key are configured. The IAM RPCs can create users and + // mint access keys; mounting them on an unauthenticated listener would + // hand any caller that can reach the gRPC port S3-admin equivalent power. + // Operators who relied on the unauthenticated path must now set + // jwt.filer_signing.key in security.toml and attach a Bearer token signed + // with that key on every IAM call. if credentialManager != nil { - iamGrpcServer := weed_server.NewIamGrpcServer(credentialManager) - iam_pb.RegisterSeaweedIdentityAccessManagementServer(grpcS, iamGrpcServer) - glog.V(0).Info("Registered IAM gRPC service on filer") + adminSigningKey := security.SigningKey(util.GetViper().GetString("jwt.filer_signing.key")) + if len(adminSigningKey) == 0 { + glog.Warningf("IAM gRPC service NOT registered on filer: jwt.filer_signing.key is empty in security.toml; configure it to enable IAM administration") + } else { + iamGrpcServer := weed_server.NewIamGrpcServer(credentialManager, adminSigningKey) + iam_pb.RegisterSeaweedIdentityAccessManagementServer(grpcS, iamGrpcServer) + glog.V(0).Info("Registered IAM gRPC service on filer (admin Bearer token required)") + } } reflection.Register(grpcS) diff --git a/weed/command/scaffold/security.toml b/weed/command/scaffold/security.toml index ca797ca5c..b99c2fd8c 100644 --- a/weed/command/scaffold/security.toml +++ b/weed/command/scaffold/security.toml @@ -44,6 +44,11 @@ expires_after_seconds = 10 # seconds # - f.e. the S3 API Shim generates the JWT # - the Filer server validates the JWT on writing # NOTE: This key is ALSO used as a fallback signing key for S3 STS if s3.iam.config does not specify a signingKey. +# NOTE: This key is ALSO required to mount the IAM gRPC service (CreateUser, +# PutPolicy, CreateAccessKey, ...) on the filer. The filer refuses to +# register that service when the key is empty, and every IAM RPC must +# carry a Bearer token signed with this key in its "authorization" +# gRPC metadata. Mint such a token with security.GenJwtForFilerAdmin. # the jwt defaults to expire after 10 seconds. [jwt.filer_signing] key = "" diff --git a/weed/security/jwt.go b/weed/security/jwt.go index abea0198d..85b5163f9 100644 --- a/weed/security/jwt.go +++ b/weed/security/jwt.go @@ -29,6 +29,41 @@ type SeaweedFilerClaims struct { jwt.RegisteredClaims } +// SeaweedFilerAdminClaims is presented by callers of the filer's IAM gRPC +// service to prove they are authorised to administer users, access keys, and +// policies. The token is signed with the filer write-signing key +// (jwt.filer_signing.key in security.toml). +// +// Validation is delegated to DecodeJwt below: it enforces HS256 via the +// SigningMethodHMAC type check, and jwt/v5 validates exp/nbf on the embedded +// RegisteredClaims. Extra JSON fields in the payload are silently ignored by +// encoding/json, which is the desired behaviour here (forward-compat). +type SeaweedFilerAdminClaims struct { + jwt.RegisteredClaims +} + +// GenJwtForFilerAdmin mints a Bearer token for the filer IAM gRPC service. +// Returns an empty string if the signing key is not configured. +func GenJwtForFilerAdmin(signingKey SigningKey, expiresAfterSec int) EncodedJwt { + if len(signingKey) == 0 { + return "" + } + + claims := SeaweedFilerAdminClaims{ + RegisteredClaims: jwt.RegisteredClaims{}, + } + if expiresAfterSec > 0 { + claims.ExpiresAt = jwt.NewNumericDate(time.Now().Add(time.Second * time.Duration(expiresAfterSec))) + } + t := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) + encoded, e := t.SignedString([]byte(signingKey)) + if e != nil { + glog.V(0).Infof("Failed to sign claims %+v: %v", t.Claims, e) + return "" + } + return EncodedJwt(encoded) +} + func GenJwtForVolumeServer(signingKey SigningKey, expiresAfterSec int, fileId string) EncodedJwt { if len(signingKey) == 0 { return "" diff --git a/weed/server/filer_server_handlers_iam_grpc.go b/weed/server/filer_server_handlers_iam_grpc.go index 2a97cfd68..5ee7230c3 100644 --- a/weed/server/filer_server_handlers_iam_grpc.go +++ b/weed/server/filer_server_handlers_iam_grpc.go @@ -3,32 +3,78 @@ package weed_server import ( "context" "encoding/json" + "strings" "github.com/seaweedfs/seaweedfs/weed/credential" "github.com/seaweedfs/seaweedfs/weed/glog" "github.com/seaweedfs/seaweedfs/weed/pb/iam_pb" "github.com/seaweedfs/seaweedfs/weed/s3api/policy_engine" + "github.com/seaweedfs/seaweedfs/weed/security" "google.golang.org/grpc/codes" + "google.golang.org/grpc/metadata" "google.golang.org/grpc/status" ) -// IamGrpcServer implements the IAM gRPC service on the filer +// IamGrpcServer implements the IAM gRPC service on the filer. +// Every RPC requires a Bearer token in the "authorization" metadata, signed +// with the filer write-signing key (jwt.filer_signing.key in security.toml). +// If no signing key is configured the service refuses to register at all; the +// adminSigningKey check below is defensive in case that wiring is bypassed. type IamGrpcServer struct { iam_pb.UnimplementedSeaweedIdentityAccessManagementServer credentialManager *credential.CredentialManager + adminSigningKey security.SigningKey } -// NewIamGrpcServer creates a new IAM gRPC server -func NewIamGrpcServer(credentialManager *credential.CredentialManager) *IamGrpcServer { +// NewIamGrpcServer creates a new IAM gRPC server. adminSigningKey is required: +// callers without a Bearer token signed by this key are rejected. +func NewIamGrpcServer(credentialManager *credential.CredentialManager, adminSigningKey security.SigningKey) *IamGrpcServer { return &IamGrpcServer{ credentialManager: credentialManager, + adminSigningKey: adminSigningKey, } } +// checkAdminAuth verifies the caller presented a Bearer token signed by the +// filer's write-signing key. It is invoked at the top of every IAM RPC. +func (s *IamGrpcServer) checkAdminAuth(ctx context.Context) error { + if len(s.adminSigningKey) == 0 { + // Service should not be registered without a key; fail closed. + return status.Error(codes.PermissionDenied, "iam admin auth not configured") + } + md, ok := metadata.FromIncomingContext(ctx) + if !ok { + return status.Error(codes.Unauthenticated, "missing metadata") + } + authHeaders := md.Get("authorization") + if len(authHeaders) == 0 { + return status.Error(codes.Unauthenticated, "missing authorization metadata") + } + // RFC 6750 §2.1: the "Bearer" auth-scheme name is case-insensitive. + // Tolerate surrounding whitespace and any spacing between scheme and token. + raw := strings.TrimSpace(authHeaders[0]) + parts := strings.Fields(raw) + if len(parts) != 2 || !strings.EqualFold(parts[0], "Bearer") || parts[1] == "" { + return status.Error(codes.Unauthenticated, "authorization header must use Bearer scheme") + } + token := parts[1] + parsed, err := security.DecodeJwt(s.adminSigningKey, security.EncodedJwt(token), &security.SeaweedFilerAdminClaims{}) + if err != nil || parsed == nil || !parsed.Valid { + return status.Error(codes.Unauthenticated, "invalid admin token") + } + return nil +} + ////////////////////////////////////////////////// // Configuration Management func (s *IamGrpcServer) GetConfiguration(ctx context.Context, req *iam_pb.GetConfigurationRequest) (*iam_pb.GetConfigurationResponse, error) { + if err := s.checkAdminAuth(ctx); err != nil { + return nil, err + } + if req == nil { + return nil, status.Errorf(codes.InvalidArgument, "request is required") + } glog.V(4).Infof("GetConfiguration") if s.credentialManager == nil { @@ -47,6 +93,12 @@ func (s *IamGrpcServer) GetConfiguration(ctx context.Context, req *iam_pb.GetCon } func (s *IamGrpcServer) PutConfiguration(ctx context.Context, req *iam_pb.PutConfigurationRequest) (*iam_pb.PutConfigurationResponse, error) { + if err := s.checkAdminAuth(ctx); err != nil { + return nil, err + } + if req == nil { + return nil, status.Errorf(codes.InvalidArgument, "request is required") + } glog.V(4).Infof("PutConfiguration") if s.credentialManager == nil { @@ -70,6 +122,9 @@ func (s *IamGrpcServer) PutConfiguration(ctx context.Context, req *iam_pb.PutCon // User Management func (s *IamGrpcServer) CreateUser(ctx context.Context, req *iam_pb.CreateUserRequest) (*iam_pb.CreateUserResponse, error) { + if err := s.checkAdminAuth(ctx); err != nil { + return nil, err + } if req == nil || req.Identity == nil { return nil, status.Errorf(codes.InvalidArgument, "identity is required") } @@ -92,6 +147,12 @@ func (s *IamGrpcServer) CreateUser(ctx context.Context, req *iam_pb.CreateUserRe } func (s *IamGrpcServer) GetUser(ctx context.Context, req *iam_pb.GetUserRequest) (*iam_pb.GetUserResponse, error) { + if err := s.checkAdminAuth(ctx); err != nil { + return nil, err + } + if req == nil { + return nil, status.Errorf(codes.InvalidArgument, "request is required") + } glog.V(4).Infof("GetUser: %s", req.Username) if s.credentialManager == nil { @@ -117,6 +178,9 @@ func (s *IamGrpcServer) GetUser(ctx context.Context, req *iam_pb.GetUserRequest) } func (s *IamGrpcServer) UpdateUser(ctx context.Context, req *iam_pb.UpdateUserRequest) (*iam_pb.UpdateUserResponse, error) { + if err := s.checkAdminAuth(ctx); err != nil { + return nil, err + } if req == nil || req.Identity == nil { return nil, status.Errorf(codes.InvalidArgument, "identity is required") } @@ -139,6 +203,12 @@ func (s *IamGrpcServer) UpdateUser(ctx context.Context, req *iam_pb.UpdateUserRe } func (s *IamGrpcServer) DeleteUser(ctx context.Context, req *iam_pb.DeleteUserRequest) (*iam_pb.DeleteUserResponse, error) { + if err := s.checkAdminAuth(ctx); err != nil { + return nil, err + } + if req == nil { + return nil, status.Errorf(codes.InvalidArgument, "request is required") + } glog.V(4).Infof("IAM: Filer.DeleteUser %s", req.Username) if s.credentialManager == nil { @@ -158,6 +228,12 @@ func (s *IamGrpcServer) DeleteUser(ctx context.Context, req *iam_pb.DeleteUserRe } func (s *IamGrpcServer) ListUsers(ctx context.Context, req *iam_pb.ListUsersRequest) (*iam_pb.ListUsersResponse, error) { + if err := s.checkAdminAuth(ctx); err != nil { + return nil, err + } + if req == nil { + return nil, status.Errorf(codes.InvalidArgument, "request is required") + } glog.V(4).Infof("ListUsers") if s.credentialManager == nil { @@ -193,6 +269,9 @@ func (s *IamGrpcServer) ListUsers(ctx context.Context, req *iam_pb.ListUsersRequ // Access Key Management func (s *IamGrpcServer) CreateAccessKey(ctx context.Context, req *iam_pb.CreateAccessKeyRequest) (*iam_pb.CreateAccessKeyResponse, error) { + if err := s.checkAdminAuth(ctx); err != nil { + return nil, err + } if req == nil || req.Credential == nil { return nil, status.Errorf(codes.InvalidArgument, "credential is required") } @@ -215,6 +294,12 @@ func (s *IamGrpcServer) CreateAccessKey(ctx context.Context, req *iam_pb.CreateA } func (s *IamGrpcServer) DeleteAccessKey(ctx context.Context, req *iam_pb.DeleteAccessKeyRequest) (*iam_pb.DeleteAccessKeyResponse, error) { + if err := s.checkAdminAuth(ctx); err != nil { + return nil, err + } + if req == nil { + return nil, status.Errorf(codes.InvalidArgument, "request is required") + } glog.V(4).Infof("DeleteAccessKey: %s for user: %s", req.AccessKey, req.Username) if s.credentialManager == nil { @@ -237,6 +322,12 @@ func (s *IamGrpcServer) DeleteAccessKey(ctx context.Context, req *iam_pb.DeleteA } func (s *IamGrpcServer) GetUserByAccessKey(ctx context.Context, req *iam_pb.GetUserByAccessKeyRequest) (*iam_pb.GetUserByAccessKeyResponse, error) { + if err := s.checkAdminAuth(ctx); err != nil { + return nil, err + } + if req == nil { + return nil, status.Errorf(codes.InvalidArgument, "request is required") + } glog.V(4).Infof("GetUserByAccessKey: %s", req.AccessKey) if s.credentialManager == nil { @@ -261,6 +352,12 @@ func (s *IamGrpcServer) GetUserByAccessKey(ctx context.Context, req *iam_pb.GetU // Policy Management func (s *IamGrpcServer) PutPolicy(ctx context.Context, req *iam_pb.PutPolicyRequest) (*iam_pb.PutPolicyResponse, error) { + if err := s.checkAdminAuth(ctx); err != nil { + return nil, err + } + if req == nil { + return nil, status.Errorf(codes.InvalidArgument, "request is required") + } glog.V(4).Infof("IAM: Filer.PutPolicy %s", req.Name) if s.credentialManager == nil { @@ -293,6 +390,12 @@ func (s *IamGrpcServer) PutPolicy(ctx context.Context, req *iam_pb.PutPolicyRequ } func (s *IamGrpcServer) GetPolicy(ctx context.Context, req *iam_pb.GetPolicyRequest) (*iam_pb.GetPolicyResponse, error) { + if err := s.checkAdminAuth(ctx); err != nil { + return nil, err + } + if req == nil { + return nil, status.Errorf(codes.InvalidArgument, "request is required") + } glog.V(4).Infof("GetPolicy: %s", req.Name) if s.credentialManager == nil { @@ -322,6 +425,12 @@ func (s *IamGrpcServer) GetPolicy(ctx context.Context, req *iam_pb.GetPolicyRequ } func (s *IamGrpcServer) ListPolicies(ctx context.Context, req *iam_pb.ListPoliciesRequest) (*iam_pb.ListPoliciesResponse, error) { + if err := s.checkAdminAuth(ctx); err != nil { + return nil, err + } + if req == nil { + return nil, status.Errorf(codes.InvalidArgument, "request is required") + } glog.V(4).Infof("ListPolicies") if s.credentialManager == nil { @@ -352,6 +461,12 @@ func (s *IamGrpcServer) ListPolicies(ctx context.Context, req *iam_pb.ListPolici } func (s *IamGrpcServer) DeletePolicy(ctx context.Context, req *iam_pb.DeletePolicyRequest) (*iam_pb.DeletePolicyResponse, error) { + if err := s.checkAdminAuth(ctx); err != nil { + return nil, err + } + if req == nil { + return nil, status.Errorf(codes.InvalidArgument, "request is required") + } glog.V(4).Infof("DeletePolicy: %s", req.Name) if s.credentialManager == nil { @@ -371,6 +486,9 @@ func (s *IamGrpcServer) DeletePolicy(ctx context.Context, req *iam_pb.DeletePoli // Service Account Management func (s *IamGrpcServer) CreateServiceAccount(ctx context.Context, req *iam_pb.CreateServiceAccountRequest) (*iam_pb.CreateServiceAccountResponse, error) { + if err := s.checkAdminAuth(ctx); err != nil { + return nil, err + } if req == nil || req.ServiceAccount == nil { return nil, status.Errorf(codes.InvalidArgument, "service account is required") } @@ -393,6 +511,9 @@ func (s *IamGrpcServer) CreateServiceAccount(ctx context.Context, req *iam_pb.Cr } func (s *IamGrpcServer) UpdateServiceAccount(ctx context.Context, req *iam_pb.UpdateServiceAccountRequest) (*iam_pb.UpdateServiceAccountResponse, error) { + if err := s.checkAdminAuth(ctx); err != nil { + return nil, err + } if req == nil || req.ServiceAccount == nil { return nil, status.Errorf(codes.InvalidArgument, "service account is required") } @@ -412,6 +533,12 @@ func (s *IamGrpcServer) UpdateServiceAccount(ctx context.Context, req *iam_pb.Up } func (s *IamGrpcServer) DeleteServiceAccount(ctx context.Context, req *iam_pb.DeleteServiceAccountRequest) (*iam_pb.DeleteServiceAccountResponse, error) { + if err := s.checkAdminAuth(ctx); err != nil { + return nil, err + } + if req == nil { + return nil, status.Errorf(codes.InvalidArgument, "request is required") + } glog.V(4).Infof("DeleteServiceAccount: %s", req.Id) if s.credentialManager == nil { @@ -431,6 +558,12 @@ func (s *IamGrpcServer) DeleteServiceAccount(ctx context.Context, req *iam_pb.De } func (s *IamGrpcServer) GetServiceAccount(ctx context.Context, req *iam_pb.GetServiceAccountRequest) (*iam_pb.GetServiceAccountResponse, error) { + if err := s.checkAdminAuth(ctx); err != nil { + return nil, err + } + if req == nil { + return nil, status.Errorf(codes.InvalidArgument, "request is required") + } glog.V(4).Infof("GetServiceAccount: %s", req.Id) if s.credentialManager == nil { @@ -453,6 +586,12 @@ func (s *IamGrpcServer) GetServiceAccount(ctx context.Context, req *iam_pb.GetSe } func (s *IamGrpcServer) ListServiceAccounts(ctx context.Context, req *iam_pb.ListServiceAccountsRequest) (*iam_pb.ListServiceAccountsResponse, error) { + if err := s.checkAdminAuth(ctx); err != nil { + return nil, err + } + if req == nil { + return nil, status.Errorf(codes.InvalidArgument, "request is required") + } glog.V(4).Infof("ListServiceAccounts") if s.credentialManager == nil { @@ -471,6 +610,9 @@ func (s *IamGrpcServer) ListServiceAccounts(ctx context.Context, req *iam_pb.Lis } func (s *IamGrpcServer) GetServiceAccountByAccessKey(ctx context.Context, req *iam_pb.GetServiceAccountByAccessKeyRequest) (*iam_pb.GetServiceAccountByAccessKeyResponse, error) { + if err := s.checkAdminAuth(ctx); err != nil { + return nil, err + } if req == nil { return nil, status.Errorf(codes.InvalidArgument, "request is required") } diff --git a/weed/server/filer_server_handlers_iam_grpc_test.go b/weed/server/filer_server_handlers_iam_grpc_test.go new file mode 100644 index 000000000..ff1f0f5aa --- /dev/null +++ b/weed/server/filer_server_handlers_iam_grpc_test.go @@ -0,0 +1,144 @@ +package weed_server + +import ( + "context" + "testing" + "time" + + jwt "github.com/golang-jwt/jwt/v5" + "github.com/seaweedfs/seaweedfs/weed/credential" + _ "github.com/seaweedfs/seaweedfs/weed/credential/memory" + "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 testIamSigningKey = "iam-admin-test-key-do-not-use-in-prod" + +func newTestIamGrpcServer(t *testing.T) *IamGrpcServer { + t.Helper() + cm, err := credential.NewCredentialManager(credential.StoreTypeMemory, nil, "") + if err != nil { + t.Fatalf("NewCredentialManager: %v", err) + } + return NewIamGrpcServer(cm, security.SigningKey(testIamSigningKey)) +} + +func ctxWithBearer(token string) context.Context { + md := metadata.New(map[string]string{"authorization": "Bearer " + token}) + return metadata.NewIncomingContext(context.Background(), md) +} + +func TestIamGrpc_NoMetadata_Unauthenticated(t *testing.T) { + s := newTestIamGrpcServer(t) + _, err := s.ListUsers(context.Background(), &iam_pb.ListUsersRequest{}) + if got, want := status.Code(err), codes.Unauthenticated; got != want { + t.Fatalf("ListUsers without metadata: got code %v, want %v (err=%v)", got, want, err) + } +} + +func TestIamGrpc_MissingAuthorizationHeader_Unauthenticated(t *testing.T) { + s := newTestIamGrpcServer(t) + ctx := metadata.NewIncomingContext(context.Background(), metadata.New(map[string]string{"other": "value"})) + _, err := s.ListUsers(ctx, &iam_pb.ListUsersRequest{}) + if got, want := status.Code(err), codes.Unauthenticated; got != want { + t.Fatalf("ListUsers with no authorization header: got code %v, want %v (err=%v)", got, want, err) + } +} + +func TestIamGrpc_NonBearerAuthorization_Unauthenticated(t *testing.T) { + s := newTestIamGrpcServer(t) + md := metadata.New(map[string]string{"authorization": "Basic dXNlcjpwYXNz"}) + ctx := metadata.NewIncomingContext(context.Background(), md) + _, err := s.ListUsers(ctx, &iam_pb.ListUsersRequest{}) + if got, want := status.Code(err), codes.Unauthenticated; got != want { + t.Fatalf("ListUsers with non-Bearer scheme: got code %v, want %v (err=%v)", got, want, err) + } +} + +func TestIamGrpc_InvalidToken_Unauthenticated(t *testing.T) { + s := newTestIamGrpcServer(t) + // Token signed with the wrong key. + bad := security.GenJwtForFilerAdmin(security.SigningKey("a-different-key"), 60) + if bad == "" { + t.Fatal("GenJwtForFilerAdmin returned empty") + } + _, err := s.ListUsers(ctxWithBearer(string(bad)), &iam_pb.ListUsersRequest{}) + if got, want := status.Code(err), codes.Unauthenticated; got != want { + t.Fatalf("ListUsers with mis-signed token: got code %v, want %v (err=%v)", got, want, err) + } +} + +func TestIamGrpc_GarbageToken_Unauthenticated(t *testing.T) { + s := newTestIamGrpcServer(t) + _, err := s.ListUsers(ctxWithBearer("not.a.jwt"), &iam_pb.ListUsersRequest{}) + if got, want := status.Code(err), codes.Unauthenticated; got != want { + t.Fatalf("ListUsers with garbage token: got code %v, want %v (err=%v)", got, want, err) + } +} + +func TestIamGrpc_ExpiredToken_Unauthenticated(t *testing.T) { + s := newTestIamGrpcServer(t) + // Mint a token that's already expired (exp in the past). + 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(testIamSigningKey)) + if err != nil { + t.Fatalf("SignedString: %v", err) + } + _, err = s.ListUsers(ctxWithBearer(encoded), &iam_pb.ListUsersRequest{}) + if got, want := status.Code(err), codes.Unauthenticated; got != want { + t.Fatalf("ListUsers with expired token: got code %v, want %v (err=%v)", got, want, err) + } +} + +func TestIamGrpc_ValidToken_ReachesHandler(t *testing.T) { + s := newTestIamGrpcServer(t) + good := security.GenJwtForFilerAdmin(security.SigningKey(testIamSigningKey), 60) + if good == "" { + t.Fatal("GenJwtForFilerAdmin returned empty") + } + resp, err := s.ListUsers(ctxWithBearer(string(good)), &iam_pb.ListUsersRequest{}) + if err != nil { + t.Fatalf("ListUsers with valid token: unexpected error %v", err) + } + if resp == nil { + t.Fatal("ListUsers with valid token: nil response") + } + // Memory store starts empty; the handler ran past the auth gate. + if len(resp.Usernames) != 0 { + t.Fatalf("ListUsers: expected empty user list from fresh memory store, got %v", resp.Usernames) + } +} + +func TestIamGrpc_NoSigningKey_PermissionDenied(t *testing.T) { + // Defensive path: even if the service is somehow registered without a + // key, every RPC must refuse. + cm, err := credential.NewCredentialManager(credential.StoreTypeMemory, nil, "") + if err != nil { + t.Fatalf("NewCredentialManager: %v", err) + } + s := NewIamGrpcServer(cm, nil) + good := security.GenJwtForFilerAdmin(security.SigningKey(testIamSigningKey), 60) + _, err = s.ListUsers(ctxWithBearer(string(good)), &iam_pb.ListUsersRequest{}) + if got, want := status.Code(err), codes.PermissionDenied; got != want { + t.Fatalf("ListUsers with no signing key: got code %v, want %v (err=%v)", got, want, err) + } +} + +func TestIamGrpc_CreateUser_RequiresAuth(t *testing.T) { + // Spot-check a write RPC too — auth must run before any work. + s := newTestIamGrpcServer(t) + _, err := s.CreateUser(context.Background(), &iam_pb.CreateUserRequest{ + Identity: &iam_pb.Identity{Name: "admin"}, + }) + if got, want := status.Code(err), codes.Unauthenticated; got != want { + t.Fatalf("CreateUser without token: got code %v, want %v (err=%v)", got, want, err) + } +}