diff --git a/internal/api/grpc/gerrors/zitadel_errors.go b/internal/api/grpc/gerrors/zitadel_errors.go index d0bddf17b..1b3eda5c5 100644 --- a/internal/api/grpc/gerrors/zitadel_errors.go +++ b/internal/api/grpc/gerrors/zitadel_errors.go @@ -23,6 +23,11 @@ func ZITADELToGRPCError(ctx context.Context, err error) error { if err == nil { return nil } + if grpcStatus, ok := status.FromError(err); ok { + // Native gRPC status errors are already transport-safe and may carry details. + logging.Log(ctx, grpcStatusLevel(grpcStatus.Code()), grpcStatus.Message(), "err", err, "code", grpcStatus.Code()) + return err + } code, key, id, lvl := extractError(err) msg := key @@ -78,6 +83,9 @@ func extractError(err error) (c codes.Code, msg, id string, lvl slog.Level) { if ok := errors.As(err, &connErr); ok { return codes.Internal, "db connection error", "", slog.LevelError } + if grpcStatus, ok := status.FromError(err); ok { + return grpcStatus.Code(), grpcStatus.Message(), "", grpcStatusLevel(grpcStatus.Code()) + } zitadelErr, ok := zerrors.AsZitadelError(err) if !ok { return codes.Unknown, err.Error(), "", slog.LevelError @@ -126,6 +134,24 @@ func extractError(err error) (c codes.Code, msg, id string, lvl slog.Level) { return c, msg, id, lvl } +func grpcStatusLevel(code codes.Code) slog.Level { + switch code { //nolint:exhaustive + case codes.AlreadyExists, + codes.Canceled, + codes.FailedPrecondition, + codes.InvalidArgument, + codes.NotFound, + codes.OutOfRange, + codes.PermissionDenied, + codes.Unauthenticated: + return slog.LevelWarn + case codes.Unimplemented: + return slog.LevelInfo + default: + return slog.LevelError + } +} + func getErrorInfo(id, key string, err error) protoadapt.MessageV1 { var errorInfo protoadapt.MessageV1 diff --git a/internal/api/grpc/gerrors/zitadel_errors_test.go b/internal/api/grpc/gerrors/zitadel_errors_test.go index 8b8a2c131..daee39e2d 100644 --- a/internal/api/grpc/gerrors/zitadel_errors_test.go +++ b/internal/api/grpc/gerrors/zitadel_errors_test.go @@ -7,7 +7,9 @@ import ( "testing" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" "google.golang.org/protobuf/protoadapt" commandErrors "github.com/zitadel/zitadel/internal/command/errors" @@ -49,6 +51,14 @@ func TestCaosToGRPCError(t *testing.T) { } } +func TestZITADELToGRPCError_PreservesGRPCStatus(t *testing.T) { + err := ZITADELToGRPCError(t.Context(), status.Error(codes.Unauthenticated, "auth header missing")) + grpcStatus, ok := status.FromError(err) + require.True(t, ok) + assert.Equal(t, codes.Unauthenticated, grpcStatus.Code()) + assert.Equal(t, "auth header missing", grpcStatus.Message()) +} + func Test_getErrorInfo(t *testing.T) { tests := []struct { name string @@ -205,6 +215,14 @@ func Test_extractError(t *testing.T) { "", slog.LevelError, }, + { + "grpc unauthenticated", + args{status.Error(codes.Unauthenticated, "auth header missing")}, + codes.Unauthenticated, + "auth header missing", + "", + slog.LevelWarn, + }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { diff --git a/internal/api/grpc/server/gateway_test.go b/internal/api/grpc/server/gateway_test.go new file mode 100644 index 000000000..cda52c9ff --- /dev/null +++ b/internal/api/grpc/server/gateway_test.go @@ -0,0 +1,24 @@ +package server + +import ( + "context" + "net/http" + "net/http/httptest" + "testing" + + "github.com/grpc-ecosystem/grpc-gateway/v2/runtime" + "github.com/stretchr/testify/assert" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" +) + +func TestErrorHandler_PreservesUnauthenticatedStatus(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, "/auth/v1/users/me", nil) + resp := httptest.NewRecorder() + + errorHandler(context.Background(), runtime.NewServeMux(), jsonMarshaler, resp, req, status.Error(codes.Unauthenticated, "auth header missing")) + + assert.Equal(t, http.StatusUnauthorized, resp.Code) + assert.Contains(t, resp.Body.String(), `"code":16`) + assert.Contains(t, resp.Body.String(), `"message":"auth header missing"`) +} diff --git a/internal/api/grpc/server/middleware/error_interceptor_test.go b/internal/api/grpc/server/middleware/error_interceptor_test.go index 04fc8e35f..788d251f5 100644 --- a/internal/api/grpc/server/middleware/error_interceptor_test.go +++ b/internal/api/grpc/server/middleware/error_interceptor_test.go @@ -43,6 +43,18 @@ func Test_toGRPCError(t *testing.T) { want: nil, wantCode: codes.FailedPrecondition, }, + { + name: "grpc status error", + args: args{ + ctx: context.Background(), + req: &mockReq{}, + handler: func(context.Context, any) (any, error) { + return nil, status.Error(codes.Unauthenticated, "auth header missing") + }, + }, + want: nil, + wantCode: codes.Unauthenticated, + }, { name: "panic with string", args: args{