feat(domain): introduce error slugs (#12030)

# Which Problems Are Solved

As part of https://github.com/zitadel/zitadel/issues/11917 we want to
introduce error slugs so (API) clients can rely on stable,
machine-readable errors and act accordingly.

# How the Problems Are Solved

- Added a `NewSlug` helper function in the domain package.
- Added `ErrorDetails` to the `ZitadelError`
- Added an `zitadel.error.v2.ErrorDetail` proto message
- Updated the connectRPC error interceptor to map new slug based errors
to the new `ErrorDetail`
- Defined some common slugs and error functions like internal errors
- Defined (session) specific slugs used in the `DeleteSession` and
`CheckUser` functions and replaced old implementations
- Updated integration tests to check specific errors if the relation
database feature is enabled
- Updated doc and guideline to reflect the latest changes and decisions
- Updated DeleteSession endpoint API to list possible slugs

# Additional Changes

None

# Additional Context

- closes #11957

---------

Co-authored-by: Wim Van Laer <[email protected]>
This commit is contained in:
Livio Spring
2026-04-13 09:47:04 +02:00
committed by GitHub
co-authored by Wim Van Laer
parent 5c9361f6c7
commit a2bf528ca0
18 changed files with 524 additions and 97 deletions
+58
View File
@@ -8,6 +8,64 @@ import (
"github.com/zitadel/zitadel/internal/zerrors"
)
func NewSlug(resource, cause string) zerrors.Slug {
return zerrors.Slug(fmt.Sprintf("%s.%s", resource, cause))
}
var (
// SlugInternalError is a general slug for any internal error, where the client doesn't have any influence on the error.
// It's returned when an unexpected condition in the server occurs, such as a database error, an external service failure, or any other error that is not caused by the client's request.
// The error details might contain additional information.
SlugInternalError = NewSlug("zitadel", "internal_error")
// SlugRequestInvalid is a general slug for any request containing missing, malformed or otherwise invalid parameters.
// The details typically contain additional information on how to resolve the problem.
SlugRequestInvalid = NewSlug("request", "invalid")
// SlugAuthMissingPermission is a general slug for any authorization error where the user is missing a required permission to perform an action.
// The details typically contain the necessary required permission.
SlugAuthMissingPermission = NewSlug("auth", "missing_permission")
)
var (
// ErrIDMissing is an error that can be returned on any resource if the necessary (resource) ID is missing
ErrIDMissing = func() error {
return zerrors.CreateZitadelError(zerrors.KindInvalidArgument, nil, string(SlugRequestInvalid), "validation failed: id is required", 1).
WithDetails(zerrors.ErrorDetailsMap{"id": "required"})
}
// ErrInstanceIDMissing is an error that can be returned on any resource if the necessary instance ID is missing
ErrInstanceIDMissing = func() error {
return zerrors.CreateZitadelError(zerrors.KindInvalidArgument, nil, string(SlugRequestInvalid), "validation failed: instance_id is required", 1).
WithDetails(zerrors.ErrorDetailsMap{"instance_id": "required"})
}
// ErrInvalidRequest is a general error for any invalid request type, like missing or wrong parameters.
ErrInvalidRequest = func(message string) error {
return zerrors.CreateZitadelError(zerrors.KindInvalidArgument, nil, string(SlugRequestInvalid), message, 1)
}
// ErrInternal is a general error for any internal error, where the client doesn't have any influence on the error.
// This error should be used for any error that is not caused by the client's request, but rather by an unexpected condition in the server.
ErrInternal = func(err error, message string) error {
return zerrors.CreateZitadelError(zerrors.KindInternal, err, string(SlugInternalError), message, 1)
}
// ErrMoreThanOneRowAffected is an error that can be returned when a database operation affects more rows than expected,
// which could indicate a potential issue with the query or the data integrity.
ErrMoreThanOneRowAffected = func(message string, rows int64) error {
return zerrors.CreateZitadelError(zerrors.KindInternal, nil, string(SlugInternalError), message, 1).
WithDetails(zerrors.ErrorDetailsMap{"rows": rows})
}
// ErrAuthMissingPermission is an error that can be returned when a user tries to perform an action that requires a specific permission,
// but the user does not have that permission.
ErrAuthMissingPermission = func(err error, message, permission string) error {
return zerrors.CreateZitadelError(zerrors.KindPermissionDenied, err, string(SlugAuthMissingPermission), message, 1).
WithDetails(zerrors.ErrorDetailsMap{"permission": permission})
}
)
type wrongIDPTypeError struct {
expected IDPType
got string
+30
View File
@@ -5,6 +5,7 @@ import (
"time"
"github.com/zitadel/zitadel/backend/v3/storage/database"
"github.com/zitadel/zitadel/internal/zerrors"
)
type Session struct {
@@ -123,3 +124,32 @@ type sessionChanges interface {
// SetMetadata adds or updates the metadata of the session.
SetMetadata(metadata []*SessionMetadata) database.Change
}
var (
// SlugSessionNotFound is a slug used whenever a session is not found by the requested id.
SlugSessionNotFound = NewSlug("session", "not_found")
// SlugSessionUserChange is a slug used when trying to change the user of an already authenticated session, which is not allowed.
SlugSessionUserChange = NewSlug("session", "user_change")
// SlugSessionTokenInvalid is a slug used when the provided session token is invalid, either because it is malformed, expired or does not match the session.
SlugSessionTokenInvalid = NewSlug("session", "token_invalid")
)
var (
// ErrSessionNotFound is an error that can be returned when a session with the requested id was not found.
ErrSessionNotFound = func(err error, id string) error {
return zerrors.CreateZitadelError(zerrors.KindNotFound, err, string(SlugSessionNotFound), "session was not found", 1).
WithDetails(zerrors.ErrorDetailsMap{"id": id})
}
// ErrSessionUserChange is an error that can be returned when trying to change the user of an already authenticated session, which is not allowed.
ErrSessionUserChange = func() error {
return zerrors.CreateZitadelError(zerrors.KindInvalidArgument, nil, string(SlugSessionUserChange), "session was already authenticated with another user, you cannot change it to a different one", 1)
}
// ErrSessionTokenInvalid is an error that can be returned whenever the provided (session) token is invalid for the intended session.
ErrSessionTokenInvalid = func(err error) error {
return zerrors.CreateZitadelError(zerrors.KindPermissionDenied, err, string(SlugSessionTokenInvalid), "The provided session token is invalid: either the token is malformed, expired or does not match the session", 1)
}
)
+16 -14
View File
@@ -82,13 +82,13 @@ func (u *UserCheckCommand) Execute(ctx context.Context, opts *InvokeOpts) (err e
session, err := sessionRepo.Get(ctx, opts.DB(), database.WithCondition(sessionRepo.IDCondition(u.SessionID)))
if err != nil {
if errors.Is(err, &database.NoRowFoundError{}) {
return zerrors.ThrowNotFound(err, "DOM-rbdCv3", "session not found")
return ErrSessionNotFound(err, u.SessionID) // TODO: not found vs precondition failed
}
return zerrors.ThrowInternal(err, "DOM-To1rLz", "failed fetching session")
return ErrInternal(err, "failed to fetch the session")
}
if session.UserID != "" && u.FetchedUser.ID != "" && session.UserID != u.FetchedUser.ID {
return zerrors.ThrowInvalidArgument(nil, "DOM-78g1TV", "user change not possible")
return ErrSessionUserChange()
}
userFactor := &SessionFactorUser{
UserID: u.FetchedUser.ID,
@@ -97,16 +97,14 @@ func (u *UserCheckCommand) Execute(ctx context.Context, opts *InvokeOpts) (err e
updateCount, err := sessionRepo.Update(ctx, opts.DB(), sessionRepo.IDCondition(session.ID), sessionRepo.SetFactor(userFactor))
if err != nil {
return zerrors.ThrowInternal(err, "DOM-netNam", "failed updating session")
return ErrInternal(err, "failed to update the session")
}
if updateCount == 0 {
err = zerrors.ThrowNotFound(nil, "DOM-FszyWS", "session not found")
return err
return ErrSessionNotFound(err, u.SessionID) // TODO: not found vs precondition failed vs internal
}
if updateCount > 1 {
err = zerrors.ThrowInternal(NewMultipleObjectsUpdatedError(1, updateCount), "DOM-SsIwDt", "unexpected number of rows updated")
return err
return ErrMoreThanOneRowAffected("unexpected number of rows updated", updateCount)
}
u.UserCheckedAt = userFactor.LastVerifiedAt
@@ -126,37 +124,41 @@ func (u *UserCheckCommand) Validate(ctx context.Context, opts *InvokeOpts) (err
}
if u.SessionID == "" {
return zerrors.ThrowPreconditionFailed(nil, "DOM-00o0ys", "Errors.Missing.SessionID")
return ErrIDMissing() // TODO: precondition failed vs invalid argument
}
if u.InstanceID == "" {
return zerrors.ThrowPreconditionFailed(nil, "DOM-Oe1dtz", "Errors.Missing.InstanceID")
return ErrInstanceIDMissing() // TODO: precondition failed vs invalid argument
}
if authZErr := opts.Permissions.CheckSessionPermission(ctx, SessionWritePermission, u.SessionID); authZErr != nil {
// TODO: return a more specific error once permissions are implemented?
return zerrors.ThrowPermissionDenied(authZErr, "DOM-4qz3mt", "Errors.PermissionDenied")
}
var usrQueryOpt database.QueryOption
var query string
userRepo := opts.userRepo
if loginName := u.InputCheckUser.LoginName; loginName != "" {
usrQueryOpt = database.WithCondition(userRepo.LoginNameCondition(database.TextOperationEqual, loginName))
query = loginName
} else if userID := u.InputCheckUser.UserID; userID != "" {
usrQueryOpt = database.WithCondition(userRepo.IDCondition(userID))
query = userID
} else {
return zerrors.ThrowInvalidArgument(nil, "DOM-7B2m0b", "no valid query option")
return ErrInvalidRequest("either login_name or user_id is required")
}
usr, err := userRepo.Get(ctx, opts.DB(), usrQueryOpt)
if err != nil {
if errors.Is(err, &database.NoRowFoundError{}) {
return zerrors.ThrowNotFound(err, "DOM-lcZeXI", "user not found")
return ErrUserNotFound(err, query)
}
return zerrors.ThrowInternal(err, "DOM-Y846I0", "failed fetching user")
return ErrInternal(err, "failed to fetch the user")
}
if usr.State != UserStateActive {
return zerrors.ThrowPreconditionFailed(nil, "DOM-vgDIu9", "Errors.User.NotActive")
return ErrUserNotActive(usr.ID, "user must be active for sign in")
}
u.FetchedUser = *usr
+12 -12
View File
@@ -43,7 +43,7 @@ func TestUserCheckCommand_Validate(t *testing.T) {
cmd: &domain.UserCheckCommand{
InputCheckUser: &domain.CheckUserType{UserID: "user-123"},
},
expectedError: zerrors.ThrowPreconditionFailed(nil, "DOM-00o0ys", "Errors.Missing.SessionID"),
expectedError: domain.ErrIDMissing(),
},
{
testName: "when instance ID is not set should return error",
@@ -51,7 +51,7 @@ func TestUserCheckCommand_Validate(t *testing.T) {
InputCheckUser: &domain.CheckUserType{UserID: "user-123"},
SessionID: "session-1",
},
expectedError: zerrors.ThrowPreconditionFailed(nil, "DOM-Oe1dtz", "Errors.Missing.InstanceID"),
expectedError: domain.ErrInstanceIDMissing(),
},
{
testName: "when permission check fails should return permission denied error",
@@ -140,7 +140,7 @@ func TestUserCheckCommand_Validate(t *testing.T) {
SessionID: "session-1",
InstanceID: "instance-1",
},
expectedError: zerrors.ThrowPreconditionFailed(nil, "DOM-vgDIu9", "Errors.User.NotActive"),
expectedError: domain.ErrUserNotActive("user-123", ""),
},
{
testName: "when user retrieval fails should return error",
@@ -171,7 +171,7 @@ func TestUserCheckCommand_Validate(t *testing.T) {
SessionID: "session-1",
InstanceID: "instance-1",
},
expectedError: zerrors.ThrowInternal(getErr, "DOM-Y846I0", "failed fetching user"),
expectedError: domain.ErrInternal(getErr, ""),
},
{
testName: "when user not found should return not found error",
@@ -202,7 +202,7 @@ func TestUserCheckCommand_Validate(t *testing.T) {
SessionID: "session-1",
InstanceID: "instance-1",
},
expectedError: zerrors.ThrowNotFound(notFoundErr, "DOM-lcZeXI", "user not found"),
expectedError: domain.ErrUserNotFound(notFoundErr, "user-123"),
},
{
testName: "when search type is LoginName should attempt to fetch by login name",
@@ -257,7 +257,7 @@ func TestUserCheckCommand_Validate(t *testing.T) {
SessionID: "session-1",
InstanceID: "instance-1",
},
expectedError: zerrors.ThrowInvalidArgumentf(nil, "DOM-7B2m0b", "no valid query option"),
expectedError: domain.ErrInvalidRequest(""),
},
}
@@ -330,7 +330,7 @@ func TestUserCheckCommand_Execute(t *testing.T) {
OrganizationID: "org-1",
},
},
expectedError: zerrors.ThrowInternal(getErr, "DOM-To1rLz", "failed fetching session"),
expectedError: domain.ErrInternal(getErr, ""),
},
{
testName: "when session not found should return not found error",
@@ -357,7 +357,7 @@ func TestUserCheckCommand_Execute(t *testing.T) {
},
},
},
expectedError: zerrors.ThrowNotFound(sessionNotFoundErr, "DOM-rbdCv3", "session not found"),
expectedError: domain.ErrSessionNotFound(sessionNotFoundErr, "session-1"),
},
{
testName: "when user change is attempted should return invalid argument error",
@@ -385,7 +385,7 @@ func TestUserCheckCommand_Execute(t *testing.T) {
OrganizationID: "org-1",
},
},
expectedError: zerrors.ThrowInvalidArgument(nil, "DOM-78g1TV", "user change not possible"),
expectedError: domain.ErrSessionUserChange(),
},
{
testName: "when session update fails should return error",
@@ -421,7 +421,7 @@ func TestUserCheckCommand_Execute(t *testing.T) {
OrganizationID: "org-1",
},
},
expectedError: zerrors.ThrowInternal(updateErr, "DOM-netNam", "failed updating session"),
expectedError: domain.ErrInternal(updateErr, "failed to update the session"),
},
{
testName: "when session update returns no rows should return not found error",
@@ -457,7 +457,7 @@ func TestUserCheckCommand_Execute(t *testing.T) {
OrganizationID: "org-1",
},
},
expectedError: zerrors.ThrowNotFound(nil, "DOM-FszyWS", "session not found"),
expectedError: domain.ErrSessionNotFound(nil, "session-1"),
},
{
testName: "when session update returns multiple rows should return internal error",
@@ -493,7 +493,7 @@ func TestUserCheckCommand_Execute(t *testing.T) {
OrganizationID: "org-1",
},
},
expectedError: zerrors.ThrowInternal(domain.NewMultipleObjectsUpdatedError(1, 2), "DOM-SsIwDt", "unexpected number of rows updated"),
expectedError: domain.ErrMoreThanOneRowAffected("unexpected number of rows updated", 2),
},
{
testName: "when session has no user and user is set should execute successfully",
+6 -15
View File
@@ -3,6 +3,7 @@ package domain
import (
"context"
"errors"
"fmt"
"strings"
"time"
@@ -10,7 +11,6 @@ import (
"github.com/zitadel/zitadel/internal/api/authz"
"github.com/zitadel/zitadel/internal/eventstore"
"github.com/zitadel/zitadel/internal/repository/session"
"github.com/zitadel/zitadel/internal/zerrors"
)
type DeleteSessionCommand struct {
@@ -49,14 +49,6 @@ func (cmd *DeleteSessionCommand) Events(ctx context.Context, opts *InvokeOpts) (
}, nil
}
const (
ErrSessionTokenInvalid = "ErrSessionTokenInvalid"
ErrMoreThanOneRowAffected = "ErrMoreThanOneRowAffected"
ErrIDMissing = "ErrIDMissing"
ErrMissingPermission = "ErrMissingPermission"
ErrInternal = "ErrInternal"
)
func (cmd *DeleteSessionCommand) sessionDeletePermissionCheckCondition(ctx context.Context, sessionRepo SessionRepository, decryptor SessionTokenDecryptor) (database.Condition, error) {
if !cmd.MustCheckPermission {
return nil, nil
@@ -64,7 +56,7 @@ func (cmd *DeleteSessionCommand) sessionDeletePermissionCheckCondition(ctx conte
if cmd.Token != "" {
sessionID, tokenID, err := decryptor(ctx, cmd.Token)
if err != nil || sessionID != cmd.ID {
return nil, zerrors.ThrowPermissionDenied(err, ErrSessionTokenInvalid, "Errors.Session.Token.Invalid")
return nil, ErrSessionTokenInvalid(err)
}
return database.Or(
sessionRepo.TokenIDCondition(tokenID),
@@ -93,14 +85,13 @@ func (cmd *DeleteSessionCommand) Execute(ctx context.Context, opts *InvokeOpts)
)
if err != nil {
if errors.Is(err, new(database.PermissionError)) {
return zerrors.ThrowPermissionDenied(err, ErrMissingPermission,
"insufficient permissions to delete session, require `session.delete` permission, ownership of the session or current session token")
return ErrAuthMissingPermission(err, "insufficient permissions to delete session, require `session.delete` permission, ownership of the session or current session token", SessionDeletePermission)
}
return zerrors.ThrowInternal(err, ErrInternal, "an unexpected error occurred while deleting the session")
return ErrInternal(err, "an unexpected error occurred while deleting the session")
}
if deletedRows > 1 {
return zerrors.ThrowInternalf(nil, ErrMoreThanOneRowAffected, "expected 1 session to be deleted, got %d", deletedRows)
return ErrMoreThanOneRowAffected(fmt.Sprintf("expected 1 session to be deleted, got %d", deletedRows), deletedRows)
}
cmd.DeletedAt = deletedAt
@@ -116,7 +107,7 @@ func (*DeleteSessionCommand) String() string {
// Validate implements [Commander].
func (cmd *DeleteSessionCommand) Validate(ctx context.Context, opts *InvokeOpts) (err error) {
if cmd.ID = strings.TrimSpace(cmd.ID); cmd.ID == "" {
return zerrors.ThrowInvalidArgument(nil, ErrIDMissing, "Errors.IDMissing")
return ErrIDMissing()
}
return nil
}
+4 -5
View File
@@ -16,7 +16,6 @@ import (
noopdb "github.com/zitadel/zitadel/backend/v3/storage/database/dialect/noop"
"github.com/zitadel/zitadel/internal/api/authz"
zdomain "github.com/zitadel/zitadel/internal/domain"
"github.com/zitadel/zitadel/internal/zerrors"
)
func TestDeleteSessionCommand_Validate(t *testing.T) {
@@ -44,7 +43,7 @@ func TestDeleteSessionCommand_Validate(t *testing.T) {
sessionID: "",
},
fields: fields{},
expectedError: zerrors.ThrowInvalidArgument(nil, domain.ErrIDMissing, "Errors.IDMissing"),
expectedError: domain.ErrIDMissing(),
},
{
name: "when sessionID is provided must validate successfully",
@@ -208,7 +207,7 @@ func TestDeleteSessionCommand_Execute(t *testing.T) {
mustCheckPermission: true,
},
res: res{
error: new(database.PermissionError),
error: domain.ErrAuthMissingPermission(nil, "", domain.SessionDeletePermission),
},
},
{
@@ -230,7 +229,7 @@ func TestDeleteSessionCommand_Execute(t *testing.T) {
sessionID: "session-1",
},
res: res{
error: zerrors.ThrowInternalf(nil, domain.ErrMoreThanOneRowAffected, "expected 1 session to be deleted, got %d", 2),
error: domain.ErrMoreThanOneRowAffected("expected 1 session to be deleted, got 2", 2),
},
},
{
@@ -339,7 +338,7 @@ func mockSessionTokenDecryptor(expectedSessionToken, returnSessionID, returnSess
return func(t *testing.T) domain.SessionTokenDecryptor {
return func(ctx context.Context, sessionToken string) (sessionID, tokenID string, err error) {
if sessionToken != expectedSessionToken {
return "", "", zerrors.ThrowInvalidArgumentf(nil, "SESS-S3gq1", "Errors.Session.TokenInvalid")
return "", "", domain.ErrSessionTokenInvalid(nil)
}
return returnSessionID, returnSessionToken, nil
}
+24
View File
@@ -6,6 +6,7 @@ import (
"golang.org/x/text/language"
"github.com/zitadel/zitadel/internal/crypto"
"github.com/zitadel/zitadel/internal/zerrors"
)
type User struct {
@@ -252,3 +253,26 @@ type HumanRecoveryCodes struct {
// It is reset to 0 on successful verification
FailedAttempts uint8 `json:"failedAttempts,omitempty" db:"-"`
}
var (
// SlugUserNotFound is the slug whenever a user is not found by the provided identifier.
SlugUserNotFound = NewSlug("user", "not_found")
// SlugUserNotActive is the slug whenever the user requires to be in state "active", but isn't.
SlugUserNotActive = NewSlug("user", "not_active")
)
var (
// ErrUserNotFound is an error that can be used when a user is not found by the provided identifier.
ErrUserNotFound = func(err error, identifier string) error {
return zerrors.CreateZitadelError(zerrors.KindNotFound, err, string(SlugUserNotFound), "user was not found", 1).
WithDetails(zerrors.ErrorDetailsMap{"identifier": identifier})
}
// ErrUserNotActive is an error that can be returned when a user needs to be active for the operation,
// e.g. sign in, reactivation, etc. but has a different state.
ErrUserNotActive = func(id, message string) error {
return zerrors.CreateZitadelError(zerrors.KindPreconditionFailed, nil, string(SlugUserNotActive), message, 1).
WithDetails(zerrors.ErrorDetailsMap{"id": id})
}
)