fix: add information about target response into error message if inte… (#8281)

# Which Problems Are Solved

Execution responses with HTTP StatusCode not equal to 200 interrupt the
client request silently.

# How the Problems Are Solved

Adds information about the recieved StatusCode and Body into the error
if StatusCode not 200.

# Additional Context

Closes #8177

---------

Co-authored-by: Elio Bischof <elio@zitadel.com>
Co-authored-by: Livio Spring <livio.a@gmail.com>
This commit is contained in:
Stefan Benz 2024-08-16 11:26:15 +02:00 committed by GitHub
parent 11d01b9b35
commit 83c78a470c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
21 changed files with 400 additions and 6 deletions

View File

@ -169,4 +169,19 @@ For event there are 3 levels the condition can be defined:
- Group, handling a specific group of events
- All, handling any event in ZITADEL
The concept of events can be found under [Events](/concepts/architecture/software#events)
The concept of events can be found under [Events](/concepts/architecture/software#events)
### Error forwarding
If you want to forward a specific error from the Target through ZITADEL, you can provide a response from the Target with status code 200 and a JSON in the following format:
```json
{
"forwardedStatusCode": 403,
"forwardedErrorMessage": "Call is forbidden through the IP AllowList definition"
}
```
Only values from 400 to 499 will be forwarded through ZITADEL, other StatusCodes will end in a PreconditionFailed error.
If the Target returns any other status code than >= 200 and < 299, the execution is looked at as failed, and a PreconditionFailed error is logged.

View File

@ -263,7 +263,7 @@ func TestServer_RemovePhone(t *testing.T) {
req *user.RemovePhoneRequest
want *user.RemovePhoneResponse
wantErr bool
dep func(ctx context.Context, userID string) (*user.RemovePhoneResponse, error)
dep func(ctx context.Context, userID string) (*user.RemovePhoneResponse, error)
}{
{
name: "remove phone",
@ -303,7 +303,7 @@ func TestServer_RemovePhone(t *testing.T) {
dep: func(ctx context.Context, userID string) (*user.RemovePhoneResponse, error) {
return Client.RemovePhone(ctx, &user.RemovePhoneRequest{
UserId: doubleRemoveUser.GetUserId(),
});
})
},
},
{

View File

@ -45,3 +45,36 @@ func ZitadelErrorToHTTPStatusCode(err error) (statusCode int, ok bool) {
return http.StatusInternalServerError, false
}
}
func HTTPStatusCodeToZitadelError(parent error, statusCode int, id string, message string) error {
if statusCode == http.StatusOK {
return nil
}
var errorFunc func(parent error, id, message string) error
switch statusCode {
case http.StatusConflict:
errorFunc = zerrors.ThrowAlreadyExists
case http.StatusGatewayTimeout:
errorFunc = zerrors.ThrowDeadlineExceeded
case http.StatusInternalServerError:
errorFunc = zerrors.ThrowInternal
case http.StatusBadRequest:
errorFunc = zerrors.ThrowInvalidArgument
case http.StatusNotFound:
errorFunc = zerrors.ThrowNotFound
case http.StatusForbidden:
errorFunc = zerrors.ThrowPermissionDenied
case http.StatusUnauthorized:
errorFunc = zerrors.ThrowUnauthenticated
case http.StatusServiceUnavailable:
errorFunc = zerrors.ThrowUnavailable
case http.StatusNotImplemented:
errorFunc = zerrors.ThrowUnimplemented
case http.StatusTooManyRequests:
errorFunc = zerrors.ThrowResourceExhausted
default:
errorFunc = zerrors.ThrowUnknown
}
return errorFunc(parent, id, message)
}

View File

@ -6,6 +6,8 @@ import (
"net/http"
"testing"
"github.com/stretchr/testify/assert"
"github.com/zitadel/zitadel/internal/zerrors"
)
@ -136,3 +138,152 @@ func TestZitadelErrorToHTTPStatusCode(t *testing.T) {
})
}
}
func TestHTTPStatusCodeToZitadelError(t *testing.T) {
type args struct {
statusCode int
id string
message string
parent error
}
tests := []struct {
name string
args args
wantErr error
}{
{
name: "StatusOK",
args: args{
statusCode: http.StatusOK,
},
wantErr: nil,
},
{
name: "StatusConflict",
args: args{
statusCode: http.StatusConflict,
id: "id",
message: "message",
},
wantErr: zerrors.ThrowAlreadyExists(nil, "id", "message"),
},
{
name: "StatusGatewayTimeout",
args: args{
statusCode: http.StatusGatewayTimeout,
id: "id",
message: "message",
},
wantErr: zerrors.ThrowDeadlineExceeded(nil, "id", "message"),
},
{
name: "StatusInternalServerError",
args: args{
statusCode: http.StatusInternalServerError,
id: "id",
message: "message",
},
wantErr: zerrors.ThrowInternal(nil, "id", "message"),
},
{
name: "StatusBadRequest",
args: args{
statusCode: http.StatusBadRequest,
id: "id",
message: "message",
},
wantErr: zerrors.ThrowInvalidArgument(nil, "id", "message"),
},
{
name: "StatusNotFound",
args: args{
statusCode: http.StatusNotFound,
id: "id",
message: "message",
},
wantErr: zerrors.ThrowNotFound(nil, "id", "message"),
},
{
name: "StatusForbidden",
args: args{
statusCode: http.StatusForbidden,
id: "id",
message: "message",
},
wantErr: zerrors.ThrowPermissionDenied(nil, "id", "message"),
},
{
name: "StatusUnauthorized",
args: args{
statusCode: http.StatusUnauthorized,
id: "id",
message: "message",
},
wantErr: zerrors.ThrowUnauthenticated(nil, "id", "message"),
},
{
name: "StatusServiceUnavailable",
args: args{
statusCode: http.StatusServiceUnavailable,
id: "id",
message: "message",
},
wantErr: zerrors.ThrowUnavailable(nil, "id", "message"),
},
{
name: "StatusNotImplemented",
args: args{
statusCode: http.StatusNotImplemented,
id: "id",
message: "message",
},
wantErr: zerrors.ThrowUnimplemented(nil, "id", "message"),
},
{
name: "StatusTooManyRequests",
args: args{
statusCode: http.StatusTooManyRequests,
id: "id",
message: "message",
},
wantErr: zerrors.ThrowResourceExhausted(nil, "id", "message"),
},
{
name: "Unknown",
args: args{
statusCode: 1000,
id: "id",
message: "message",
},
wantErr: zerrors.ThrowUnknown(nil, "id", "message"),
},
{
name: "Unknown, test for statuscode",
args: args{
statusCode: 1000,
id: "id",
message: "message",
},
wantErr: zerrors.ThrowError(nil, "id", "message"),
},
{
name: "Unknown with parent",
args: args{
statusCode: 1000,
id: "id",
message: "message",
parent: errors.New("parent error"),
},
wantErr: zerrors.ThrowUnknown(nil, "id", "message"),
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := HTTPStatusCodeToZitadelError(tt.args.parent, tt.args.statusCode, tt.args.id, tt.args.message)
assert.ErrorIs(t, err, tt.wantErr)
if tt.args.parent != nil {
assert.ErrorIs(t, err, tt.args.parent)
}
})
}
}

View File

@ -3,12 +3,14 @@ package execution
import (
"bytes"
"context"
"encoding/json"
"io"
"net/http"
"time"
"github.com/zitadel/logging"
zhttp "github.com/zitadel/zitadel/internal/api/http"
"github.com/zitadel/zitadel/internal/domain"
"github.com/zitadel/zitadel/internal/telemetry/tracing"
"github.com/zitadel/zitadel/internal/zerrors"
@ -114,9 +116,35 @@ func Call(ctx context.Context, url string, timeout time.Duration, body []byte) (
}
defer resp.Body.Close()
return HandleResponse(resp)
}
func HandleResponse(resp *http.Response) ([]byte, error) {
data, err := io.ReadAll(resp.Body)
if err != nil {
return nil, err
}
// Check for success between 200 and 299, redirect 300 to 399 is handled by the client, return error with statusCode >= 400
if resp.StatusCode >= 200 && resp.StatusCode <= 299 {
return io.ReadAll(resp.Body)
var errorBody ErrorBody
if err := json.Unmarshal(data, &errorBody); err != nil {
// if json unmarshal fails, body has no ErrorBody information, so will be taken as successful response
return data, nil
}
if errorBody.ForwardedStatusCode != 0 || errorBody.ForwardedErrorMessage != "" {
if errorBody.ForwardedStatusCode >= 400 && errorBody.ForwardedStatusCode < 500 {
return nil, zhttp.HTTPStatusCodeToZitadelError(nil, errorBody.ForwardedStatusCode, "EXEC-reUaUZCzCp", errorBody.ForwardedErrorMessage)
}
return nil, zerrors.ThrowPreconditionFailed(nil, "EXEC-bmhNhpcqpF", errorBody.ForwardedErrorMessage)
}
// no ErrorBody filled in response, so will be taken as successful response
return data, nil
}
return nil, zerrors.ThrowUnknown(nil, "EXEC-dra6yamk98", "Errors.Execution.Failed")
return nil, zerrors.ThrowPreconditionFailed(nil, "EXEC-dra6yamk98", "Errors.Execution.Failed")
}
type ErrorBody struct {
ForwardedStatusCode int `json:"forwardedStatusCode,omitempty"`
ForwardedErrorMessage string `json:"forwardedErrorMessage,omitempty"`
}

View File

@ -1,7 +1,10 @@
package execution_test
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
@ -15,6 +18,7 @@ import (
"github.com/zitadel/zitadel/internal/api/grpc/server/middleware"
"github.com/zitadel/zitadel/internal/domain"
"github.com/zitadel/zitadel/internal/execution"
"github.com/zitadel/zitadel/internal/zerrors"
)
func Test_Call(t *testing.T) {
@ -513,3 +517,152 @@ var requestContextInfoBody2 = []byte("{\"request\":{\"request\":\"content2\"}}")
type request struct {
Request string `json:"request"`
}
func testErrorBody(code int, message string) []byte {
body := &execution.ErrorBody{ForwardedStatusCode: code, ForwardedErrorMessage: message}
data, _ := json.Marshal(body)
return data
}
func Test_handleResponse(t *testing.T) {
type args struct {
resp *http.Response
}
type res struct {
data []byte
wantErr func(error) bool
}
tests := []struct {
name string
args args
res res
}{
{
"response, statuscode unknown and body",
args{
resp: &http.Response{
StatusCode: 1000,
Body: io.NopCloser(bytes.NewReader([]byte(""))),
},
},
res{
wantErr: func(err error) bool {
return errors.Is(err, zerrors.ThrowPreconditionFailed(nil, "EXEC-dra6yamk98", "Errors.Execution.Failed"))
},
},
},
{
"response, statuscode >= 400 and no body",
args{
resp: &http.Response{
StatusCode: http.StatusForbidden,
Body: io.NopCloser(bytes.NewReader([]byte(""))),
},
},
res{
wantErr: func(err error) bool {
return errors.Is(err, zerrors.ThrowPreconditionFailed(nil, "EXEC-dra6yamk98", "Errors.Execution.Failed"))
},
},
},
{
"response, statuscode >= 400 and body",
args{
resp: &http.Response{
StatusCode: http.StatusForbidden,
Body: io.NopCloser(bytes.NewReader([]byte("body"))),
},
},
res{
wantErr: func(err error) bool {
return errors.Is(err, zerrors.ThrowPreconditionFailed(nil, "EXEC-dra6yamk98", "Errors.Execution.Failed"))
}},
},
{
"response, statuscode = 200 and body",
args{
resp: &http.Response{
StatusCode: http.StatusOK,
Body: io.NopCloser(bytes.NewReader([]byte("body"))),
},
},
res{
data: []byte("body"),
wantErr: nil,
},
},
{
"response, statuscode = 200 no body",
args{
resp: &http.Response{
StatusCode: http.StatusOK,
Body: io.NopCloser(bytes.NewReader([]byte(""))),
},
},
res{
data: []byte(""),
wantErr: nil,
},
},
{
"response, statuscode = 200, error body >= 400 < 500",
args{
resp: &http.Response{
StatusCode: http.StatusOK,
Body: io.NopCloser(bytes.NewReader(testErrorBody(http.StatusForbidden, "forbidden"))),
},
},
res{
wantErr: func(err error) bool {
return errors.Is(err, zerrors.ThrowPermissionDenied(nil, "EXEC-reUaUZCzCp", "forbidden"))
},
},
},
{
"response, statuscode = 200, error body >= 500",
args{
resp: &http.Response{
StatusCode: http.StatusOK,
Body: io.NopCloser(bytes.NewReader(testErrorBody(http.StatusInternalServerError, "internal"))),
},
},
res{
wantErr: func(err error) bool {
return errors.Is(err, zerrors.ThrowPreconditionFailed(nil, "EXEC-bmhNhpcqpF", "internal"))
},
},
},
{
"response, statuscode = 308, no body, should not happen",
args{
resp: &http.Response{
StatusCode: http.StatusOK,
Body: io.NopCloser(bytes.NewReader(testErrorBody(http.StatusPermanentRedirect, "redirect"))),
},
},
res{
wantErr: func(err error) bool {
return errors.Is(err, zerrors.ThrowPreconditionFailed(nil, "EXEC-bmhNhpcqpF", "redirect"))
},
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
respBody, err := execution.HandleResponse(
tt.args.resp,
)
if tt.res.wantErr == nil {
if !assert.NoError(t, err) {
t.FailNow()
}
} else if !tt.res.wantErr(err) {
t.Errorf("got wrong err: %v", err)
return
}
assert.Equal(t, tt.res.data, respBody)
})
}
}

View File

@ -581,6 +581,7 @@ Errors:
NotFound: Изпълнението не е намерено
IncludeNotFound: Включването не е намерено
NoTargets: Няма определени цели
Failed: неуспешно изпълнение
ResponseIsNotValidJSON: Отговорът не е валиден JSON
UserSchema:
NotEnabled: Функцията „Потребителска схема“ не е активирана

View File

@ -562,6 +562,7 @@ Errors:
NotFound: Provedení nenalezeno
IncludeNotFound: Zahrnout nenalezeno
NoTargets: Nejsou definovány žádné cíle
Failed: Provedení se nezdařilo
ResponseIsNotValidJSON: Odpověď není platný JSON
UserSchema:
NotEnabled: Funkce "Uživatelské schéma" není povolena

View File

@ -564,6 +564,7 @@ Errors:
NotFound: Ausführung nicht gefunden
IncludeNotFound: Einschließen nicht gefunden
NoTargets: Keine Ziele definiert
Failed: Ausführung fehlgeschlagen
ResponseIsNotValidJSON: Antwort ist kein gültiges JSON
UserSchema:
NotEnabled: Funktion Benutzerschema ist nicht aktiviert

View File

@ -564,6 +564,7 @@ Errors:
NotFound: Execution not found
IncludeNotFound: Include not found
NoTargets: No targets defined
Failed: Execution failed
ResponseIsNotValidJSON: Response is not valid JSON
UserSchema:
NotEnabled: Feature "User Schema" is not enabled
@ -594,7 +595,6 @@ Errors:
NoActive: No active web key found
NotFound: Web key not found
AggregateTypes:
action: Action
instance: Instance

View File

@ -564,6 +564,7 @@ Errors:
NotFound: Ejecución no encontrada
IncludeNotFound: Incluir no encontrado
NoTargets: No hay objetivos definidos
Failed: Ejecución fallida
ResponseIsNotValidJSON: La respuesta no es un JSON válido
UserSchema:
NotEnabled: La función "Esquema de usuario" no está habilitada

View File

@ -564,6 +564,7 @@ Errors:
NotFound: Exécution introuvable
IncludeNotFound: Inclure introuvable
NoTargets: Aucune cible définie
Failed: Exécution échouée
ResponseIsNotValidJSON: La réponse n'est pas un JSON valide
UserSchema:
NotEnabled: La fonctionnalité "Schéma utilisateur" n'est pas activée

View File

@ -564,6 +564,7 @@ Errors:
NotFound: Esecuzione non trovata
IncludeNotFound: Includi non trovato
NoTargets: Nessun obiettivo definito
Failed: Esecuzione fallita
ResponseIsNotValidJSON: La risposta non è un JSON valido
UserSchema:
NotEnabled: La funzionalità "Schema utente" non è abilitata

View File

@ -553,6 +553,7 @@ Errors:
NotFound: 実行が見つかりませんでした
IncludeNotFound: 見つからないものを含める
NoTargets: ターゲットが定義されていません
Failed: 実行に失敗しました
ResponseIsNotValidJSON: 応答は有効な JSON ではありません
UserSchema:
NotEnabled: 機能「ユーザースキーマ」が有効になっていません

View File

@ -563,6 +563,7 @@ Errors:
NotFound: Извршувањето не е пронајдено
IncludeNotFound: Вклучете не е пронајден
NoTargets: Не се дефинирани цели
Failed: Извршувањето не успеа
ResponseIsNotValidJSON: Одговорот не е валиден JSON
UserSchema:
NotEnabled: Функцијата „Корисничка шема“ не е овозможена

View File

@ -564,6 +564,7 @@ Errors:
NotFound: Uitvoering niet gevonden
IncludeNotFound: Inclusief niet gevonden
NoTargets: Geen doelstellingen gedefinieerd
Failed: Uitvoering mislukt
ResponseIsNotValidJSON: Reactie is geen geldige JSON
UserSchema:
NotEnabled: Functie "Gebruikersschema" is niet ingeschakeld

View File

@ -564,6 +564,7 @@ Errors:
NotFound: Nie znaleziono wykonania
IncludeNotFound: Nie znaleziono uwzględnienia
NoTargets: Nie zdefiniowano celów
Failed: Wykonanie nie powiodło się
ResponseIsNotValidJSON: Odpowiedź nie jest prawidłowym JSON-em
UserSchema:
NotEnabled: Funkcja „Schemat użytkownika” nie jest włączona

View File

@ -559,6 +559,7 @@ Errors:
NotFound: Execução não encontrada
IncludeNotFound: Incluir não encontrado
NoTargets: Nenhuma meta definida
Failed: Falha na execução
ResponseIsNotValidJSON: A resposta não é um JSON válido
UserSchema:
NotEnabled: O recurso "Esquema do usuário" não está habilitado

View File

@ -553,6 +553,7 @@ Errors:
NotFound: Исполнение не найдено
IncludeNotFound: Включить не найдено
NoTargets: Цели не определены
Failed: Выполнение не удалось
ResponseIsNotValidJSON: Ответ не является допустимым JSON
UserSchema:
NotEnabled: Функция «Пользовательская схема» не включена

View File

@ -564,6 +564,7 @@ Errors:
NotFound: Exekveringen hittades inte
IncludeNotFound: Inkluderingen hittades inte
NoTargets: Inga mål definierade
Failed: Utförande misslyckades
ResponseIsNotValidJSON: Svaret är inte giltigt JSON
UserSchema:
NotEnabled: Funktionen "Användarschema" är inte aktiverad

View File

@ -564,6 +564,7 @@ Errors:
NotFound: 未找到执行
IncludeNotFound: 包括未找到的内容
NoTargets: 没有定义目标
Failed: 执行失败
ResponseIsNotValidJSON: 响应不是有效的 JSON
UserSchema:
NotEnabled: 未启用“用户架构”功能