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
+160 -40
View File
@@ -4,6 +4,7 @@ This document describes the design principles and conventions for the ZITADEL AP
endpoints of the proprietary ZITADEL API and does not cover any standardized APIs like OAuth 2, OpenID Connect or SCIM.
## The Basics
ZITADEL follows an API first approach. This means all features can not only be accessed via the UI but also via the API.
The API is designed using the Protobuf specification. The Protobuf specification is then used to generate the API client
and server code in different programming languages.
@@ -270,7 +271,12 @@ Additionally, state changes, specific actions or operations that do not fit into
- `Send` for sending a resource.
- etc.
#### REST Path
#### REST Path (Deprecated)
> [!NOTE]
> REST path annotation is deprecated and MUST only be used for existing services still using REST.
> All new services MUST use connectRPC, which allows clients to call the API using connectRPC, gRPC and also HTTP/1.1
> without the need for additional REST annotations.
Paths of operations MUST be structured as follows:
@@ -310,6 +316,8 @@ Permissions can be annotated on the endpoint in the proto file. This allows the
The permissions are checked by the middleware and an error is returned if the user does not have the required permissions.
The following example requires the user to have the `iam.web_key.write` permission to call the `CreateWebKey` method.
This is an instance wide permission and can therefore be checked by the API itself. If the user does not have the required permission,
an error is returned by the middleware before the API method is called.
```protobuf
option (zitadel.protoc_gen_zitadel.v2.options) = {
auth_option: {
@@ -319,6 +327,10 @@ The following example requires the user to have the `iam.web_key.write` permissi
```
In case the permission cannot be checked by the API itself, but all requests need to be from an authenticated user, the `auth_option` can be set to `authenticated`.
This is the case for a lot of resources that are organization specific, such as users, projects or authorizations.
The API will not check the permissions for the user, but it will check if the user is authenticated. If the user is not authenticated,
an error is returned by the middleware before the API method is called. The user's permissions need to be checked by the functions
that are called by the API method, based on the queried resource and the user's context.
```protobuf
option (zitadel.protoc_gen_zitadel.v2.options) = {
auth_option: {
@@ -377,8 +389,14 @@ message TargetNameFilter {
## Error Handling
The API returns machine-readable errors in the response body. This includes a status code, an error code and possibly
some details about the error. See the following sections for more information about the status codes, error codes and error messages.
The API returns machine-readable errors in the response body. This includes a status code, an error slug, a human-readable message
and possibly some details about the error. See the following sections for more information about the status codes, error slug and error messages.
> [!IMPORTANT]
> For stable `v2` service protos, always import `zitadel/error/v2/error.proto` in each `*_service.proto` file.
> This ensures reflection-aware clients can resolve `zitadel.error.v2.ErrorDetail` and read the `slug` field reliably.
> Scope note: slug-based handling is currently relevant for backend/domain paths that run with relational-storage-backed logic.
> Do not apply this rule to `v1`, `v2beta`, or `v3alpha` APIs.
### Status Codes
@@ -386,13 +404,13 @@ The API uses status codes to indicate the status of a request. Depending on the
the status code is returned as an HTTP status code or as a gRPC / connectRPC status code.
Check the possible status codes https://zitadel.com/docs/apis/statuscodes
### Error Codes
### Error Slugs
Additionally to the status code, the API returns unique error codes for each type of error.
The error codes are used to identify a specific error and can be used to handle the error programmatically.
Additionally to the status code, the API returns unique error slug for each type of error.
The error slugs are used to identify a specific error and can be used to handle the error programmatically.
> [!NOTE]
> Currently, ZITADEL might already return some error codes. However, they do not follow a specific pattern yet
> Currently, ZITADEL might already return some error codes in the error message. However, they do not follow a specific pattern yet
> and are not documented. We will update the error codes and document them in the future.
### Error Message and Details
@@ -400,33 +418,84 @@ The error codes are used to identify a specific error and can be used to handle
The API returns additional details about the error in the response body.
This includes a human-readable error message and additional information that can help the client to understand the error
as well as machine-readable details that can be used to handle the error programmatically.
Error details use the Google RPC error details format: https://github.com/googleapis/googleapis/blob/master/google/rpc/error_details.proto
Clients are encouraged not to present the error message to their end-users, but rather use their own translation logic based on the slug.
Error details use a custom ErrorDetail message containing the slug, message and additional details.
### Example
### Examples
HTTP/1.1 example:
HTTP/1.1 examples:
```
HTTP/1.1 400 Bad Request
Content-Type: application/json
{
"code": "user_invalid_information",
"message": "invalid or missing information provided for the creation of the user",
"code": "invalid_argument",
"message": "invalid or missing information provided for the creation of the user (user.invalid_information)",
"details": [
{
"@type": "type.googleapis.com/google.rpc.BadRequest",
"fieldViolations": [
{
"field": "given_name",
"description": "given name is required",
"reason": "MISSING_VALUE"
},
{
"field": "family_name",
"description": "family name must not exceed 200 characters",
"reason": "INVALID_LENGTH"
"type": "zitadel.error.v2.ErrorDetail",
"value": "aW52YWxpZCBvciBtaXNzaW5nIGluZm9ybWF0aW9uIHByb3ZpZGVkIGZvciB0aGUgY3JlYXRpb24gb2YgdGhlIHVzZXIgKHVzZXIuaW52YWxpZF9pbmZvcm1hdGlvbik...",
"debug": {
"slug": "user.invalid_information",
"message": "invalid or missing information provided for the creation of the user",
"details": {
"fieldViolations": [
{
"field": "given_name",
"description": "given name is required",
"reason": "MISSING_VALUE"
},
{
"field": "family_name",
"description": "family name must not exceed 200 characters",
"reason": "INVALID_LENGTH"
}
]
}
]
}
}
]
}
```
```
HTTP/1.1 403 Forbidden
Content-Type: application/json
{
"code": "permission_denied",
"message": "The provided session token is invalid: either the token is malformed, expired or does not match the session (session.token_invalid)",
"details": [
{
"type": "zitadel.error.v2.ErrorDetail",
"value": "ChVzZXNzaW9uLnRva2VuX2ludmFsaWQSa1RoZSBwcm92aWRlZCBzZXNzaW9uIHRva2VuIGlzIGludmFsaWQ6IGVpdGhlciB0aGUgdG9rZW4gaXMgbWFsZm9ybWVkLCBleHBpcmVkIG9yIGRvZXMgbm90IG1hdGNoIHRoZSBzZXNzaW9u",
"debug": {
"slug": "session.token_invalid",
"message": "The provided session token is invalid: either the token is malformed, expired or does not match the session"
}
}
]
}
```
```
HTTP/1.1 409 Conflict
Content-Type: application/json
{
"code": "already_exists",
"message": "the username is already taken (user.already_exists)",
"details": [
{
"type": "zitadel.error.v2.ErrorDetail",
"value": "ChN1c2VyLmFscmVhZHlfZXhpc3RzEh10aGUgdXNlcm5hbWUgaXMgYWxyZWFkeSB0YWtlbhoUChIKCHVzZXJuYW1lEgYaBHRlc3Q",
"debug": {
"slug": "user.already_exists",
"message": "the username is already taken",
"details": {
"username": "test"
}
}
}
]
}
@@ -436,32 +505,83 @@ gRPC / connectRPC example:
```
HTTP/2.0 200 OK
Content-Type: application/grpc
Grpc-Message: invalid information provided for the creation of the user
Grpc-Message: invalid information provided for the creation of the user (user.invalid_information)
Grpc-Status: 3
Grpc-Status-Details-Bin: aW52YWxpZCBpbmZvcm1hdGlvbiBwcm92aWRlZCBmb3IgdGhlIGNyZWF0aW9uIG9mIHRoZSB1c2VyICh1c2VyLmludmFsaWRfaW5mb3JtYXRpb24p...
{
"code": "user_invalid_information",
"message": "invalid or missing information provided for the creation of the user",
"code": 3,
"message": "invalid or missing information provided for the creation of the user (user.invalid_information)",
"details": [
{
"@type": "type.googleapis.com/google.rpc.BadRequest",
"fieldViolations": [
{
"field": "given_name",
"description": "given name is required",
"reason": "MISSING_VALUE"
},
{
"field": "family_name",
"description": "family name must not exceed 200 characters",
"reason": "INVALID_LENGTH"
}
]
"@type": "type.googleapis.com/zitadel.error.v2.ErrorDetail",
"slug": "user.invalid_information",
"message": "invalid or missing information provided for the creation of the user",
"details": {
"fieldViolations": [
{
"field": "given_name",
"description": "given name is required",
"reason": "MISSING_VALUE"
},
{
"field": "family_name",
"description": "family name must not exceed 200 characters",
"reason": "INVALID_LENGTH"
}
]
}
}
]
}
```
```
HTTP/2.0 200 OK
Content-Type: application/grpc
Grpc-Message: The provided session token is invalid: either the token is malformed, expired or does not match the session (session.token_invalid)
Grpc-Status: 7
Grpc-Status-Details-Bin: CAcSgwFUaGUgcHJvdmlkZWQgc2Vzc2lvbiB0b2tlbiBpcyBpbnZhbGlkOiBlaXRoZXIgdGhlIHRva2VuIGlzIG1hbGZvcm1lZCwgZXhwaXJlZCBvciBkb2VzIG5vdCBtYXRjaCB0aGUgc2Vzc2lvbiAoc2Vzc2lvbi50b2tlbl9pbnZhbGlkKRq5AQowdHlwZS5nb29nbGVhcGlzLmNvbS96aXRhZGVsLmVycm9yLnYyLkVycm9yRGV0YWlsEoQBChVzZXNzaW9uLnRva2VuX2ludmFsaWQSa1RoZSBwcm92aWRlZCBzZXNzaW9uIHRva2VuIGlzIGludmFsaWQ6IGVpdGhlciB0aGUgdG9rZW4gaXMgbWFsZm9ybWVkLCBleHBpcmVkIG9yIGRvZXMgbm90IG1hdGNoIHRoZSBzZXNzaW9u
{
"code": 7,
"message": "The provided session token is invalid: either the token is malformed, expired or does not match the session (session.token_invalid)",
"details": [
{
"@type": "type.googleapis.com/zitadel.error.v2.ErrorDetail",
"slug": "session.token_invalid",
"message": "The provided session token is invalid: either the token is malformed, expired or does not match the session"
}
]
}
```
```
HTTP/2.0 200 OK
Content-Type: application/grpc
Grpc-Message: the username is already taken (user.already_exists)
Grpc-Status: 6
Grpc-Status-Details-Bin: dGhlIHVzZXJuYW1lIGlzIGFscmVhZHkgdGFrZW4gKHVzZXIuYWxyZWFkeV9leGlzdHMp...
{
"code": 6,
"message": "the username is already taken (user.already_exists)",
"details": [
{
"@type": "type.googleapis.com/zitadel.error.v2.ErrorDetail",
"slug": "user.already_exists",
"message": "the username is already taken",
"details": {
"fields": {
"username": { "string_value": "test" }
}
}
}
]
}
```
### Documentation
- Document the purpose of the API, the services, the endpoints, the request and response messages, the error codes and the status codes.
+6
View File
@@ -226,6 +226,12 @@ Please check out the dedicated [API guidelines](./API_DESIGN.md) page when contr
To start developing, make sure you followed the [quick start](#quick-start) steps.
> [!IMPORTANT]
> For stable `v2` API services, add `import "zitadel/error/v2/error.proto";` to each `*_service.proto`.
> This enables reflection-aware clients to resolve `zitadel.error.v2.ErrorDetail` and use the `slug` field for programmatic handling.
> Scope note: this is relevant for backend/domain flows that run with relational-storage-backed logic.
> Do not apply this to `v1`, `v2beta`, or `v3alpha` APIs.
> [!IMPORTANT]
> The relational database migrations are work in progress on main. Therefore they are objectives to change. To simplify the development process you can add the following configuration to your setup step config (`--steps /path/to/config.yaml`). This re-initiates the relational tables on each restart.
+11
View File
@@ -18,3 +18,14 @@ sidebar_label: GRPC Status Codes
| 13 | INTERNAL | 500 | Internal | Internal errors. This means that some invariants expected by the underlying system have been broken. This error code is reserved for serious errors. |
| 14 | UNAVAILABLE | 503 | Service Unavailable | The service is currently unavailable. |
| 16 | UNAUTHENTICATED | 401 | Unauthorized | The request does not have valid authentication credentials for the operation. |
## Error details and slugs
For stable `v2` APIs, services import `zitadel/error/v2/error.proto` so reflection-aware clients can resolve `zitadel.error.v2.ErrorDetail`.
When present, inspect `ErrorDetail.slug` first for programmatic handling. Slugs are stable machine-readable identifiers such as `user.already_exists`.
Use `ErrorDetail.message` for developer diagnostics, not end-user text. Prefer mapping slugs to your own localized messages in clients.
This slug-based handling is currently relevant for backend/domain paths that run with relational-storage-backed logic.
Do not assume it for `v1`, `v2beta`, or `v3alpha` APIs or when using a v2 endpoint without having the relational storage feature enabled.
+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})
}
)
+28 -8
View File
@@ -4,6 +4,7 @@ import (
"context"
"errors"
"log/slog"
"strings"
"connectrpc.com/connect"
"github.com/jackc/pgx/v5/pgconn"
@@ -12,10 +13,12 @@ import (
"google.golang.org/grpc/status"
"google.golang.org/protobuf/proto"
"google.golang.org/protobuf/protoadapt"
"google.golang.org/protobuf/types/known/structpb"
"github.com/zitadel/zitadel/backend/v3/instrumentation/logging"
commandErrors "github.com/zitadel/zitadel/internal/command/errors"
"github.com/zitadel/zitadel/internal/zerrors"
"github.com/zitadel/zitadel/pkg/grpc/error/v2"
"github.com/zitadel/zitadel/pkg/grpc/message"
)
@@ -34,7 +37,7 @@ func ZITADELToGRPCError(ctx context.Context, err error) error {
msg += " (" + id + ")"
logging.Log(ctx, lvl, msg, "err", err, "code", code)
errorInfo := getErrorInfo(id, key, err)
errorInfo := getErrorInfo(ctx, id, key, err)
s, err := status.New(code, msg).WithDetails(errorInfo)
if err != nil {
@@ -61,7 +64,7 @@ func ZITADELToConnectError(ctx context.Context, err error) error {
msg += " (" + id + ")"
logging.Log(ctx, lvl, msg, "err", err)
errorInfo := getErrorInfo(id, key, err)
errorInfo := getErrorInfo(ctx, id, key, err)
cErr := connect.NewError(connect.Code(code), errors.New(msg))
if detail, detailErr := connect.NewErrorDetail(errorInfo.(proto.Message)); detailErr == nil {
@@ -152,15 +155,32 @@ func grpcStatusLevel(code codes.Code) slog.Level {
}
}
func getErrorInfo(id, key string, err error) protoadapt.MessageV1 {
var errorInfo protoadapt.MessageV1
func getErrorInfo(ctx context.Context, id, key string, err error) protoadapt.MessageV1 {
var wpe *commandErrors.WrongPasswordError
var zerr *zerrors.ZitadelError
if err != nil && errors.As(err, &wpe) {
errorInfo = &message.CredentialsCheckError{Id: id, Message: key, FailedAttempts: wpe.FailedAttempts}
} else {
errorInfo = &message.ErrorDetail{Id: id, Message: key}
return &message.CredentialsCheckError{Id: id, Message: key, FailedAttempts: wpe.FailedAttempts}
}
// Let's check the error is a zitadel error and does contain a slug, which should be the case for almost any error
// in the future, but not yet. In case it's not, we'll fall back to the old error details to prevent a breaking change.
if err != nil && errors.As(err, &zerr) && strings.Contains(zerr.GetID(), ".") {
if zerr.Details == nil {
return &errorpb.ErrorDetail{Slug: id, Message: key}
}
errDetails, err := zerr.GetDetails().MarshalJSON()
if err != nil {
logging.WithError(ctx, err).Error("unable to marshal error details")
return &errorpb.ErrorDetail{Slug: id, Message: key}
}
return errorInfo
details := new(structpb.Struct)
err = details.UnmarshalJSON(errDetails)
if err != nil {
logging.WithError(ctx, err).Error("unable to unmarshal error details")
return &errorpb.ErrorDetail{Slug: id, Message: key}
}
return &errorpb.ErrorDetail{Slug: id, Message: key, Details: details}
}
return &message.ErrorDetail{Id: id, Message: key}
}
@@ -10,10 +10,13 @@ import (
"github.com/stretchr/testify/require"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
"google.golang.org/protobuf/proto"
"google.golang.org/protobuf/protoadapt"
"google.golang.org/protobuf/types/known/structpb"
commandErrors "github.com/zitadel/zitadel/internal/command/errors"
"github.com/zitadel/zitadel/internal/zerrors"
errorpb "github.com/zitadel/zitadel/pkg/grpc/error/v2"
"github.com/zitadel/zitadel/pkg/grpc/message"
)
@@ -98,11 +101,35 @@ func Test_getErrorInfo(t *testing.T) {
}(),
result: &message.CredentialsCheckError{Id: "id", Message: "key", FailedAttempts: 26},
},
{
name: "parent error not nil wrapped zerrors.ZitadelError{} with id, message errorpb.ErrorDetail{}",
id: "id",
key: "key",
err: func() error {
err := fmt.Errorf("normal error")
zerr := &zerrors.ZitadelError{ID: "id", Message: "key"}
return fmt.Errorf("%w: %w", err, zerr)
}(),
result: &message.ErrorDetail{Id: "id", Message: "key"},
},
{
name: "parent error not nil wrapped zerrors.ZitadelError{} with slug, return errorpb.ErrorDetail{}",
id: "slug.reason",
key: "message",
err: func() error {
err := fmt.Errorf("normal error")
zerr := &zerrors.ZitadelError{ID: "slug.reason", Message: "message", Details: zerrors.ErrorDetailsMap{"some": "details"}}
return fmt.Errorf("%w: %w", err, zerr)
}(),
result: &errorpb.ErrorDetail{Slug: "slug.reason", Message: "message", Details: &structpb.Struct{Fields: map[string]*structpb.Value{"some": {Kind: &structpb.Value_StringValue{StringValue: "details"}}}}},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
errorInfo := getErrorInfo(tt.id, tt.key, tt.err)
assert.Equal(t, tt.result, errorInfo)
errorInfo := getErrorInfo(t.Context(), tt.id, tt.key, tt.err)
if !proto.Equal(tt.result.(proto.Message), errorInfo.(proto.Message)) {
t.Errorf("getErrorInfo() = %v, want %v", errorInfo, tt.result)
}
})
}
}
@@ -19,6 +19,7 @@ import (
"google.golang.org/protobuf/types/known/durationpb"
"google.golang.org/protobuf/types/known/timestamppb"
"github.com/zitadel/zitadel/backend/v3/domain"
"github.com/zitadel/zitadel/internal/integration"
"github.com/zitadel/zitadel/internal/integration/sink"
"github.com/zitadel/zitadel/pkg/grpc/feature/v2"
@@ -917,6 +918,9 @@ func TestServer_DeleteSession_token(t *testing.T) {
SessionToken: gu.Ptr("invalid"),
})
require.Error(t, err)
if stateCase.Enabled {
integration.RequireStatusError(t, err, domain.ErrSessionTokenInvalid(nil))
}
_, err = stateCase.Inst.Client.SessionV2.DeleteSession(stateCase.InstOwner, &session.DeleteSessionRequest{
SessionId: createResp.GetSessionId(),
@@ -965,6 +969,9 @@ func TestServer_DeleteSession_own_session(t *testing.T) {
SessionId: createResp.GetSessionId(),
})
require.Error(t, err)
if stateCase.Enabled {
integration.RequireStatusError(t, err, domain.ErrAuthMissingPermission(nil, "", domain.SessionDeletePermission))
}
// delete the new (user1) session by themselves
_, err = stateCase.Inst.Client.SessionV2.DeleteSession(integration.WithAuthorizationToken(t.Context(), token1), &session.DeleteSessionRequest{
@@ -1039,6 +1046,7 @@ func TestServer_DeleteSession_expired(t *testing.T) {
sessionResp, err := stateCase.Inst.Client.SessionV2.GetSession(stateCase.Inst.WithAuthorizationToken(t.Context(), integration.UserTypeOrgOwner),
&session.GetSessionRequest{SessionId: createResp.GetSessionId()})
require.Error(t, err)
// TODO: implement error check once the query side is relational
require.Nil(t, sessionResp)
})
}
+58
View File
@@ -12,6 +12,8 @@ import (
"google.golang.org/protobuf/proto"
"google.golang.org/protobuf/types/known/timestamppb"
"github.com/zitadel/zitadel/internal/api/grpc/gerrors"
errorpb "github.com/zitadel/zitadel/pkg/grpc/error/v2"
metadata "github.com/zitadel/zitadel/pkg/grpc/metadata/v2"
resources_object "github.com/zitadel/zitadel/pkg/grpc/resources/object/v3alpha"
)
@@ -191,3 +193,59 @@ func getMetadataMap(metadataEntries []*metadata.Metadata) map[string][]byte {
}
return metadataByKey
}
func RequireStatusError(t *testing.T, err, targetErr error) {
t.Helper()
if AssertStatusError(t, err, targetErr) {
return
}
t.FailNow()
}
func AssertStatusError(t *testing.T, err, targetErr error) bool {
t.Helper()
cErr, ok := status.FromError(err)
if !ok {
return assert.Fail(t, "expected status error")
}
tErr, ok := status.FromError(gerrors.ZITADELToGRPCError(t.Context(), targetErr))
if !ok {
return assert.Fail(t, "expected status error")
}
if !assert.Equal(t, tErr.Code(), cErr.Code(), "expected different status code") {
return false
}
if len(tErr.Details()) == 1 {
if len(cErr.Details()) != 1 {
return assert.Fail(t, "missing details")
}
targetDetails, ok := tErr.Details()[0].(*errorpb.ErrorDetail)
if !ok {
return assert.Failf(t, "expected error detail in target error", "got %#v", tErr.Details()[0])
}
details, ok := cErr.Details()[0].(*errorpb.ErrorDetail)
if !ok {
return assert.Failf(t, "expected error detail in actual error", "got %#v", cErr.Details()[0])
}
return AssertErrorDetail(t, details, targetDetails)
}
return true
}
func AssertErrorDetail(t *testing.T, detail, targetDetail *errorpb.ErrorDetail) bool {
t.Helper()
if !assert.Equal(t, targetDetail.GetSlug(), detail.GetSlug(), "expected different slug") {
return false
}
if targetDetail.GetMessage() != "" && !assert.Equal(t, targetDetail.GetMessage(), detail.GetMessage(), "expected different message") {
return false
}
if targetDetail.GetDetails() != nil && !assert.Equal(t, targetDetail.GetDetails().AsMap(), detail.GetDetails().AsMap(), "expected different details") {
return false
}
return true
}
+3 -1
View File
@@ -1,6 +1,8 @@
package zerrors
import "fmt"
import (
"fmt"
)
func ThrowPermissionDenied(parent error, id, message string) error {
return CreateZitadelError(KindPermissionDenied, parent, id, message, 1)
+36
View File
@@ -1,6 +1,8 @@
package zerrors
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"log/slog"
@@ -111,6 +113,7 @@ type ZitadelError struct {
Parent error
Message string
ID string
Details ErrorDetails
// location where the error was created
reportLocation *sloggcp.ReportLocation
@@ -119,6 +122,16 @@ type ZitadelError struct {
hasStackTrace bool
}
type Slug string
type ErrorDetails json.Marshaler
type ErrorDetailsMap map[string]any
func (m ErrorDetailsMap) MarshalJSON() ([]byte, error) {
return json.Marshal(map[string]any(m))
}
func ThrowError(parent error, id, message string) error {
return CreateZitadelError(KindUnknown, parent, id, message, 1)
}
@@ -183,6 +196,15 @@ func (err *ZitadelError) GetID() string {
return err.ID
}
func (err *ZitadelError) GetDetails() ErrorDetails {
return err.Details
}
func (err *ZitadelError) WithDetails(details ErrorDetails) *ZitadelError {
err.Details = details
return err
}
func (err *ZitadelError) Is(target error) bool {
t, ok := target.(*ZitadelError)
if !ok {
@@ -200,6 +222,20 @@ func (err *ZitadelError) Is(target error) bool {
if t.Parent != nil && !errors.Is(err.Parent, t.Parent) {
return false
}
if t.Details != nil {
if err.Details == nil {
return false
}
targetData, detailsErr := t.Details.MarshalJSON()
if detailsErr != nil {
return false
}
errData, detailsErr := err.Details.MarshalJSON()
if detailsErr != nil {
return false
}
return bytes.Equal(targetData, errData)
}
return true
}
+29
View File
@@ -0,0 +1,29 @@
syntax = "proto3";
package zitadel.error.v2;
import "google/protobuf/struct.proto";
option go_package = "github.com/zitadel/zitadel/pkg/grpc/error/v2;errorpb";
// ErrorDetail is the ZITADEL standard for rich error responses.
message ErrorDetail {
// The slug is a stable, machine-readable, two-part identifier (e.g., "user.already_exists").
// It is intended for programmatic use and does not change across versions, making it suitable for clients to use in their error handling logic.
// It is built from a namespace / resource (e.g., "user") and a specific error condition (e.g., "already_exists"), separated by a dot.
string slug = 1;
// The message is a human-readable message in English.
// This message is intended for developers and should provide a clear explanation of the error.
// It is not meant for end-users and should not be localized.
// Clients are encouraged not to present this to their end-users, but rather use their own translation logic based on the slug.
string message = 2;
// The details field is a structured object that can contain additional information about the error.
// It is intended to provide more context for developers and can include any relevant data that may help in diagnosing the issue
// or clients can use it to provide additional context in their error handling logic.
// The content of the details field is not standardized and can vary depending on the error type.
// While object details should remain consistent across versions, there's no guarantee that the structure will remain unchanged,
// so clients should not rely on a specific structure within the details field.
google.protobuf.Struct details = 3;
}
@@ -7,6 +7,7 @@ import "zitadel/object/v2/object.proto";
import "zitadel/protoc_gen_zitadel/v2/options.proto";
import "zitadel/session/v2/challenge.proto";
import "zitadel/session/v2/session.proto";
import "zitadel/error/v2/error.proto";
import "google/api/annotations.proto";
import "google/api/field_behavior.proto";
import "google/protobuf/struct.proto";
@@ -257,6 +258,11 @@ service SessionService {
// Required permissions:
// - `session.delete`
// - no permission required for own sessions or when providing the current session token
//
// Possible error slugs include (non-exhaustive):
// - "request.invalid" when required fields such as the session ID are missing or invalid
// - "auth.missing_permission" when the caller does not have the required permissions to delete the session
// - "session.token_invalid" when the provided session token is invalid
rpc DeleteSession (DeleteSessionRequest) returns (DeleteSessionResponse) {
option (google.api.http) = {
delete: "/v2/sessions/{session_id}"