From f45f52ea0d19c11413956f325387dc5d59472588 Mon Sep 17 00:00:00 2001 From: Titouan-joseph Cicorella Date: Tue, 12 Nov 2024 15:14:17 +0100 Subject: [PATCH 01/64] docs(v2): fix duplicate section of user, session, oidc and settings services (#8889) # Which Problems Are Solved Duplicate section in the doc ![image](https://github.com/user-attachments/assets/b9d31f87-9158-443f-8f76-1bae31fb7ee8) # How the Problems Are Solved Change the category link source to add a introduction section ![image](https://github.com/user-attachments/assets/562843e6-e8b9-4125-a3f7-8e4d2a24522d) --- docs/docusaurus.config.js | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/docusaurus.config.js b/docs/docusaurus.config.js index c0c7d5a45c..f5f349d951 100644 --- a/docs/docusaurus.config.js +++ b/docs/docusaurus.config.js @@ -289,7 +289,7 @@ module.exports = { outputDir: "docs/apis/resources/user_service_v2", sidebarOptions: { groupPathsBy: "tag", - categoryLinkSource: "tag", + categoryLinkSource: "auto", }, }, session_v2: { @@ -297,7 +297,7 @@ module.exports = { outputDir: "docs/apis/resources/session_service_v2", sidebarOptions: { groupPathsBy: "tag", - categoryLinkSource: "tag", + categoryLinkSource: "auto", }, }, oidc_v2: { @@ -305,7 +305,7 @@ module.exports = { outputDir: "docs/apis/resources/oidc_service_v2", sidebarOptions: { groupPathsBy: "tag", - categoryLinkSource: "tag", + categoryLinkSource: "auto", }, }, settings_v2: { @@ -313,7 +313,7 @@ module.exports = { outputDir: "docs/apis/resources/settings_service_v2", sidebarOptions: { groupPathsBy: "tag", - categoryLinkSource: "tag", + categoryLinkSource: "auto", }, }, user_schema_v3: { From 69e9926bcc9f766aef5cb34babd94ef627337835 Mon Sep 17 00:00:00 2001 From: chuangjinglu Date: Tue, 12 Nov 2024 22:41:18 +0800 Subject: [PATCH 02/64] fix: fix slice init length (#8707) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit # Which Problems Are Solved The intention here should be to initialize a slice with a capacity of len(queriedOrgs.Orgs) rather than initializing the length of this slice. the online demo: https://go.dev/play/p/vNUPNjdb2gJ # How the Problems Are Solved use `processedOrgs := make([]string, 0, len(queriedOrgs.Orgs))` # Additional Changes None # Additional Context None Co-authored-by: Tim Möhlmann --- internal/api/grpc/admin/export.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/api/grpc/admin/export.go b/internal/api/grpc/admin/export.go index 408a1a59fe..68b6053c2c 100644 --- a/internal/api/grpc/admin/export.go +++ b/internal/api/grpc/admin/export.go @@ -42,7 +42,7 @@ func (s *Server) ExportData(ctx context.Context, req *admin_pb.ExportDataRequest } orgs := make([]*admin_pb.DataOrg, len(queriedOrgs.Orgs)) - processedOrgs := make([]string, len(queriedOrgs.Orgs)) + processedOrgs := make([]string, 0, len(queriedOrgs.Orgs)) processedProjects := make([]string, 0) processedGrants := make([]string, 0) processedUsers := make([]string, 0) From 778b4041ca6f0cdb2957081a0cb95cd92426c8e1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tim=20M=C3=B6hlmann?= Date: Tue, 12 Nov 2024 17:20:48 +0200 Subject: [PATCH 03/64] fix(oidc): do not return access token for response type id_token (#8777) # Which Problems Are Solved Do not return an access token for implicit flow from v1 login, if the `response_type` is `id_token` # How the Problems Are Solved Do not create the access token event if if the `response_type` is `id_token`. # Additional Changes Token endpoint calls without auth request, such as machine users, token exchange and refresh token, do not have a `response_type`. For such calls the `OIDCResponseTypeUnspecified` enum is added at a `-1` offset, in order not to break existing client configs. # Additional Context - https://discord.com/channels/927474939156643850/1294001717725237298 - Fixes https://github.com/zitadel/zitadel/issues/8776 --- internal/api/oidc/auth_request.go | 1 + internal/api/oidc/token_client_credentials.go | 1 + internal/api/oidc/token_code.go | 1 + internal/api/oidc/token_exchange.go | 2 + internal/api/oidc/token_jwt_profile.go | 1 + internal/api/oidc/token_refresh.go | 1 + internal/command/oidc_session.go | 7 +- internal/command/oidc_session_test.go | 91 +++++++++++++++++++ internal/domain/application_oidc.go | 3 +- 9 files changed, 105 insertions(+), 3 deletions(-) diff --git a/internal/api/oidc/auth_request.go b/internal/api/oidc/auth_request.go index 173585ff13..138035af58 100644 --- a/internal/api/oidc/auth_request.go +++ b/internal/api/oidc/auth_request.go @@ -600,6 +600,7 @@ func (s *Server) authResponseToken(authReq *AuthRequest, authorizer op.Authorize nil, slices.Contains(scope, oidc.ScopeOfflineAccess), authReq.SessionID, + authReq.oidc().ResponseType, ) if err != nil { op.AuthRequestError(w, r, authReq, err, authorizer) diff --git a/internal/api/oidc/token_client_credentials.go b/internal/api/oidc/token_client_credentials.go index 0b836a03cc..5871e2f130 100644 --- a/internal/api/oidc/token_client_credentials.go +++ b/internal/api/oidc/token_client_credentials.go @@ -47,6 +47,7 @@ func (s *Server) ClientCredentialsExchange(ctx context.Context, r *op.ClientRequ nil, false, "", + domain.OIDCResponseTypeUnspecified, ) if err != nil { return nil, err diff --git a/internal/api/oidc/token_code.go b/internal/api/oidc/token_code.go index 3aa53e629e..ee3585be69 100644 --- a/internal/api/oidc/token_code.go +++ b/internal/api/oidc/token_code.go @@ -87,6 +87,7 @@ func (s *Server) codeExchangeV1(ctx context.Context, client *Client, req *oidc.A nil, slices.Contains(scope, oidc.ScopeOfflineAccess), authReq.SessionID, + authReq.oidc().ResponseType, ) if err != nil { return nil, err diff --git a/internal/api/oidc/token_exchange.go b/internal/api/oidc/token_exchange.go index 63a594b940..3887ff7c51 100644 --- a/internal/api/oidc/token_exchange.go +++ b/internal/api/oidc/token_exchange.go @@ -300,6 +300,7 @@ func (s *Server) createExchangeAccessToken( actor, slices.Contains(scope, oidc.ScopeOfflineAccess), "", + domain.OIDCResponseTypeUnspecified, ) if err != nil { return "", "", "", 0, err @@ -346,6 +347,7 @@ func (s *Server) createExchangeJWT( actor, slices.Contains(scope, oidc.ScopeOfflineAccess), "", + domain.OIDCResponseTypeUnspecified, ) accessToken, err = s.createJWT(ctx, client, session, getUserInfo, roleAssertion, getSigner) if err != nil { diff --git a/internal/api/oidc/token_jwt_profile.go b/internal/api/oidc/token_jwt_profile.go index 4717d29f9c..d60e6a283e 100644 --- a/internal/api/oidc/token_jwt_profile.go +++ b/internal/api/oidc/token_jwt_profile.go @@ -57,6 +57,7 @@ func (s *Server) JWTProfile(ctx context.Context, r *op.Request[oidc.JWTProfileGr nil, false, "", + domain.OIDCResponseTypeUnspecified, ) if err != nil { return nil, err diff --git a/internal/api/oidc/token_refresh.go b/internal/api/oidc/token_refresh.go index f0d92fa521..76e85a4888 100644 --- a/internal/api/oidc/token_refresh.go +++ b/internal/api/oidc/token_refresh.go @@ -69,6 +69,7 @@ func (s *Server) refreshTokenV1(ctx context.Context, client *Client, r *op.Clien refreshToken.Actor, true, "", + domain.OIDCResponseTypeUnspecified, ) if err != nil { return nil, err diff --git a/internal/command/oidc_session.go b/internal/command/oidc_session.go index c2922f5194..bea17986ea 100644 --- a/internal/command/oidc_session.go +++ b/internal/command/oidc_session.go @@ -147,6 +147,7 @@ func (c *Commands) CreateOIDCSession(ctx context.Context, actor *domain.TokenActor, needRefreshToken bool, sessionID string, + responseType domain.OIDCResponseType, ) (session *OIDCSession, err error) { ctx, span := tracing.NewSpan(ctx) defer func() { span.EndWithError(err) }() @@ -164,8 +165,10 @@ func (c *Commands) CreateOIDCSession(ctx context.Context, cmd.AddSession(ctx, userID, resourceOwner, sessionID, clientID, audience, scope, authMethods, authTime, nonce, preferredLanguage, userAgent) cmd.RegisterLogout(ctx, sessionID, userID, clientID, backChannelLogoutURI) - if err = cmd.AddAccessToken(ctx, scope, userID, resourceOwner, reason, actor); err != nil { - return nil, err + if responseType != domain.OIDCResponseTypeIDToken { + if err = cmd.AddAccessToken(ctx, scope, userID, resourceOwner, reason, actor); err != nil { + return nil, err + } } if needRefreshToken { if err = cmd.AddRefreshToken(ctx, userID); err != nil { diff --git a/internal/command/oidc_session_test.go b/internal/command/oidc_session_test.go index 43ca622a29..18a115eb00 100644 --- a/internal/command/oidc_session_test.go +++ b/internal/command/oidc_session_test.go @@ -749,6 +749,7 @@ func TestCommands_CreateOIDCSession(t *testing.T) { actor *domain.TokenActor needRefreshToken bool sessionID string + responseType domain.OIDCResponseType } tests := []struct { name string @@ -788,6 +789,7 @@ func TestCommands_CreateOIDCSession(t *testing.T) { Issuer: "foo.com", }, needRefreshToken: false, + responseType: domain.OIDCResponseTypeUnspecified, }, wantErr: io.ErrClosedPipe, }, @@ -844,6 +846,7 @@ func TestCommands_CreateOIDCSession(t *testing.T) { Issuer: "foo.com", }, needRefreshToken: false, + responseType: domain.OIDCResponseTypeUnspecified, }, wantErr: zerrors.ThrowPreconditionFailed(nil, "OIDCS-kj3g2", "Errors.User.NotActive"), }, @@ -918,6 +921,7 @@ func TestCommands_CreateOIDCSession(t *testing.T) { Issuer: "foo.com", }, needRefreshToken: false, + responseType: domain.OIDCResponseTypeUnspecified, }, want: &OIDCSession{ TokenID: "V2_oidcSessionID-at_accessTokenID", @@ -943,6 +947,87 @@ func TestCommands_CreateOIDCSession(t *testing.T) { }, }, }, + { + name: "ID token only", + fields: fields{ + eventstore: expectEventstore( + expectFilter( + user.NewHumanAddedEvent( + context.Background(), + &user.NewAggregate("userID", "org1").Aggregate, + "username", + "firstname", + "lastname", + "nickname", + "displayname", + language.Afrikaans, + domain.GenderUnspecified, + "email", + false, + ), + ), + expectFilter(), // token lifetime + expectPush( + oidcsession.NewAddedEvent(context.Background(), &oidcsession.NewAggregate("V2_oidcSessionID", "org1").Aggregate, + "userID", "org1", "", "clientID", []string{"audience"}, []string{"openid", "offline_access"}, + []domain.UserAuthMethodType{domain.UserAuthMethodTypePassword}, testNow, "nonce", &language.Afrikaans, + &domain.UserAgent{ + FingerprintID: gu.Ptr("fp1"), + IP: net.ParseIP("1.2.3.4"), + Description: gu.Ptr("firefox"), + Header: http.Header{"foo": []string{"bar"}}, + }, + ), + ), + ), + idGenerator: mock.NewIDGeneratorExpectIDs(t, "oidcSessionID"), + defaultAccessTokenLifetime: time.Hour, + defaultRefreshTokenLifetime: 7 * 24 * time.Hour, + defaultRefreshTokenIdleLifetime: 24 * time.Hour, + keyAlgorithm: crypto.CreateMockEncryptionAlg(gomock.NewController(t)), + }, + args: args{ + ctx: authz.WithInstanceID(context.Background(), "instanceID"), + userID: "userID", + resourceOwner: "org1", + clientID: "clientID", + audience: []string{"audience"}, + scope: []string{"openid", "offline_access"}, + authMethods: []domain.UserAuthMethodType{domain.UserAuthMethodTypePassword}, + authTime: testNow, + nonce: "nonce", + preferredLanguage: &language.Afrikaans, + userAgent: &domain.UserAgent{ + FingerprintID: gu.Ptr("fp1"), + IP: net.ParseIP("1.2.3.4"), + Description: gu.Ptr("firefox"), + Header: http.Header{"foo": []string{"bar"}}, + }, + reason: domain.TokenReasonAuthRequest, + actor: &domain.TokenActor{ + UserID: "user2", + Issuer: "foo.com", + }, + needRefreshToken: false, + responseType: domain.OIDCResponseTypeIDToken, + }, + want: &OIDCSession{ + ClientID: "clientID", + UserID: "userID", + Audience: []string{"audience"}, + Scope: []string{"openid", "offline_access"}, + AuthMethods: []domain.UserAuthMethodType{domain.UserAuthMethodTypePassword}, + AuthTime: testNow, + Nonce: "nonce", + PreferredLanguage: &language.Afrikaans, + UserAgent: &domain.UserAgent{ + FingerprintID: gu.Ptr("fp1"), + IP: net.ParseIP("1.2.3.4"), + Description: gu.Ptr("firefox"), + Header: http.Header{"foo": []string{"bar"}}, + }, + }, + }, { name: "disable user token event", fields: fields{ @@ -1018,6 +1103,7 @@ func TestCommands_CreateOIDCSession(t *testing.T) { Issuer: "foo.com", }, needRefreshToken: false, + responseType: domain.OIDCResponseTypeUnspecified, }, want: &OIDCSession{ TokenID: "V2_oidcSessionID-at_accessTokenID", @@ -1115,6 +1201,7 @@ func TestCommands_CreateOIDCSession(t *testing.T) { Issuer: "foo.com", }, needRefreshToken: true, + responseType: domain.OIDCResponseTypeUnspecified, }, want: &OIDCSession{ TokenID: "V2_oidcSessionID-at_accessTokenID", @@ -1213,6 +1300,7 @@ func TestCommands_CreateOIDCSession(t *testing.T) { }, needRefreshToken: false, sessionID: "sessionID", + responseType: domain.OIDCResponseTypeUnspecified, }, want: &OIDCSession{ TokenID: "V2_oidcSessionID-at_accessTokenID", @@ -1594,6 +1682,7 @@ func TestCommands_CreateOIDCSession(t *testing.T) { Issuer: "foo.com", }, needRefreshToken: false, + responseType: domain.OIDCResponseTypeUnspecified, }, wantErr: zerrors.ThrowPermissionDenied(nil, "test", "test"), }, @@ -1675,6 +1764,7 @@ func TestCommands_CreateOIDCSession(t *testing.T) { Issuer: "foo.com", }, needRefreshToken: false, + responseType: domain.OIDCResponseTypeUnspecified, }, want: &OIDCSession{ TokenID: "V2_oidcSessionID-at_accessTokenID", @@ -1729,6 +1819,7 @@ func TestCommands_CreateOIDCSession(t *testing.T) { tt.args.actor, tt.args.needRefreshToken, tt.args.sessionID, + tt.args.responseType, ) require.ErrorIs(t, err, tt.wantErr) if got != nil { diff --git a/internal/domain/application_oidc.go b/internal/domain/application_oidc.go index 1ffb61f538..617b889561 100644 --- a/internal/domain/application_oidc.go +++ b/internal/domain/application_oidc.go @@ -79,7 +79,8 @@ const ( type OIDCResponseType int32 const ( - OIDCResponseTypeCode OIDCResponseType = iota + OIDCResponseTypeUnspecified OIDCResponseType = iota - 1 // Negative offset not to break existing configs. + OIDCResponseTypeCode OIDCResponseTypeIDToken OIDCResponseTypeIDTokenToken ) From a09c772b034c5490249d8d61dd7ed4ea73c72f50 Mon Sep 17 00:00:00 2001 From: chris-1o Date: Tue, 12 Nov 2024 17:03:41 +0100 Subject: [PATCH 04/64] fix(mirror): Fix instance_id check for tables without (#8852) # Which Problems Are Solved Fixes 'column "instance_id" does not exist' errors from #8558. # How the Problems Are Solved The instanceClause / WHERE clause in the query for the respective tables is excluded. I have successfully created a mirror with this change. --- cmd/mirror/verify.go | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/cmd/mirror/verify.go b/cmd/mirror/verify.go index 7b90ad89aa..68c927d091 100644 --- a/cmd/mirror/verify.go +++ b/cmd/mirror/verify.go @@ -5,13 +5,16 @@ import ( "database/sql" _ "embed" "fmt" + "slices" "github.com/spf13/cobra" "github.com/spf13/viper" "github.com/zitadel/logging" + cryptoDatabase "github.com/zitadel/zitadel/internal/crypto/database" "github.com/zitadel/zitadel/internal/database" "github.com/zitadel/zitadel/internal/database/dialect" + "github.com/zitadel/zitadel/internal/query/projection" ) func verifyCmd() *cobra.Command { @@ -98,12 +101,22 @@ func getViews(ctx context.Context, dest *database.DB, schema string) (tables []s } func countEntries(ctx context.Context, client *database.DB, table string) (count int) { + instanceClause := instanceClause() + noInstanceIDColumn := []string{ + projection.InstanceProjectionTable, + projection.SystemFeatureTable, + cryptoDatabase.EncryptionKeysTable, + } + if slices.Contains(noInstanceIDColumn, table) { + instanceClause = "" + } + err := client.QueryRowContext( ctx, func(r *sql.Row) error { return r.Scan(&count) }, - fmt.Sprintf("SELECT COUNT(*) FROM %s %s", table, instanceClause()), + fmt.Sprintf("SELECT COUNT(*) FROM %s %s", table, instanceClause), ) logging.WithFields("table", table, "db", client.DatabaseName()).OnError(err).Error("unable to count") From ecbf0db15b6bd09587bdaebc2874ec7c207864ea Mon Sep 17 00:00:00 2001 From: Livio Spring Date: Wed, 13 Nov 2024 08:50:23 +0100 Subject: [PATCH 05/64] fix(setup): improve search query to use index (#8898) # Which Problems Are Solved The setup filter for previous steps and kept getting slower. This is due to the filter, which did not provide any instanceID and thus resulting in a full table scan. # How the Problems Are Solved - Added an empty instanceID filter (since it's on system level) # Additional Changes None # Additional Context Noticed internally and during migrations on some regions --- internal/migration/step.go | 1 + 1 file changed, 1 insertion(+) diff --git a/internal/migration/step.go b/internal/migration/step.go index 036a0a1055..04cae07e32 100644 --- a/internal/migration/step.go +++ b/internal/migration/step.go @@ -18,6 +18,7 @@ type StepStates struct { // Query implements eventstore.QueryReducer. func (*StepStates) Query() *eventstore.SearchQueryBuilder { return eventstore.NewSearchQueryBuilder(eventstore.ColumnsEvent). + InstanceID(""). // to make sure we can use an appropriate index AddQuery(). AggregateTypes(SystemAggregate). AggregateIDs(SystemAggregateID). From 3b7b0c69e6d064f9b34939f36c6878ae40df056e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tim=20M=C3=B6hlmann?= Date: Wed, 13 Nov 2024 20:11:48 +0200 Subject: [PATCH 06/64] feat(cache): redis circuit breaker (#8890) # Which Problems Are Solved If a redis cache has connection issues or any other type of permament error, it tanks the responsiveness of ZITADEL. We currently do not support things like Redis cluster or sentinel. So adding a simple redis cache improves performance but introduces a single point of failure. # How the Problems Are Solved Implement a [circuit breaker](https://learn.microsoft.com/en-us/previous-versions/msp-n-p/dn589784(v=pandp.10)?redirectedfrom=MSDN) as [`redis.Limiter`](https://pkg.go.dev/github.com/redis/go-redis/v9#Limiter) by wrapping sony's [gobreaker](https://github.com/sony/gobreaker) package. This package is picked as it seems well maintained and we already use their `sonyflake` package # Additional Changes - The unit tests constructed an unused `redis.Client` and didn't cleanup the connector. This is now fixed. # Additional Context Closes #8864 --- cmd/defaults.yaml | 13 ++ go.mod | 1 + go.sum | 2 + .../cache/connector/redis/circuit_breaker.go | 90 ++++++++++ .../connector/redis/circuit_breaker_test.go | 168 ++++++++++++++++++ internal/cache/connector/redis/connector.go | 3 + internal/cache/connector/redis/redis_test.go | 31 ++-- 7 files changed, 296 insertions(+), 12 deletions(-) create mode 100644 internal/cache/connector/redis/circuit_breaker.go create mode 100644 internal/cache/connector/redis/circuit_breaker_test.go diff --git a/cmd/defaults.yaml b/cmd/defaults.yaml index 18f814a6d2..a188d1446b 100644 --- a/cmd/defaults.yaml +++ b/cmd/defaults.yaml @@ -291,6 +291,19 @@ Caches: DisableIndentity: false # Add suffix to client name. Default is empty. IdentitySuffix: "" + # Implementation of [Circuit Breaker Pattern](https://learn.microsoft.com/en-us/previous-versions/msp-n-p/dn589784(v=pandp.10)?redirectedfrom=MSDN) + CircuitBreaker: + # Interval when the counters are reset to 0. + # 0 interval never resets the counters until the CB is opened. + Interval: 0 + # Amount of consecutive failures permitted + MaxConsecutiveFailures: 5 + # The ratio of failed requests out of total requests + MaxFailureRatio: 0.1 + # Timeout after opening of the CB, until the state is set to half-open. + Timeout: 60s + # The allowed amount of requests that are allowed to pass when the CB is half-open. + MaxRetryRequests: 1 # Instance caches auth middleware instances, gettable by domain or ID. Instance: diff --git a/go.mod b/go.mod index cf4e755605..2928d4dbfb 100644 --- a/go.mod +++ b/go.mod @@ -56,6 +56,7 @@ require ( github.com/redis/go-redis/v9 v9.7.0 github.com/rs/cors v1.11.1 github.com/santhosh-tekuri/jsonschema/v5 v5.3.1 + github.com/sony/gobreaker/v2 v2.0.0 github.com/sony/sonyflake v1.2.0 github.com/spf13/cobra v1.8.1 github.com/spf13/viper v1.19.0 diff --git a/go.sum b/go.sum index 015fea1b80..ad9e8914cf 100644 --- a/go.sum +++ b/go.sum @@ -670,6 +670,8 @@ github.com/smartystreets/goconvey v1.6.4 h1:fv0U8FUIMPNf1L9lnHLvLhgicrIVChEkdzIK github.com/smartystreets/goconvey v1.6.4/go.mod h1:syvi0/a8iFYH4r/RixwvyeAJjdLS9QV7WQ/tjFTllLA= github.com/soheilhy/cmux v0.1.4/go.mod h1:IM3LyeVVIOuxMH7sFAkER9+bJ4dT7Ms6E4xg4kGIyLM= github.com/sony/gobreaker v0.4.1/go.mod h1:ZKptC7FHNvhBz7dN2LGjPVBz2sZJmc0/PkyDJOjmxWY= +github.com/sony/gobreaker/v2 v2.0.0 h1:23AaR4JQ65y4rz8JWMzgXw2gKOykZ/qfqYunll4OwJ4= +github.com/sony/gobreaker/v2 v2.0.0/go.mod h1:8JnRUz80DJ1/ne8M8v7nmTs2713i58nIt4s7XcGe/DI= github.com/sony/sonyflake v1.2.0 h1:Pfr3A+ejSg+0SPqpoAmQgEtNDAhc2G1SUYk205qVMLQ= github.com/sony/sonyflake v1.2.0/go.mod h1:LORtCywH/cq10ZbyfhKrHYgAUGH7mOBa76enV9txy/Y= github.com/sourcegraph/conc v0.3.0 h1:OQTbbt6P72L20UqAkXXuLOj79LfEanQ+YQFNpLA9ySo= diff --git a/internal/cache/connector/redis/circuit_breaker.go b/internal/cache/connector/redis/circuit_breaker.go new file mode 100644 index 0000000000..1e06b7387e --- /dev/null +++ b/internal/cache/connector/redis/circuit_breaker.go @@ -0,0 +1,90 @@ +package redis + +import ( + "context" + "errors" + "time" + + "github.com/redis/go-redis/v9" + "github.com/sony/gobreaker/v2" + "github.com/zitadel/logging" +) + +const defaultInflightSize = 100000 + +type CBConfig struct { + // Interval when the counters are reset to 0. + // 0 interval never resets the counters until the CB is opened. + Interval time.Duration + // Amount of consecutive failures permitted + MaxConsecutiveFailures uint32 + // The ratio of failed requests out of total requests + MaxFailureRatio float64 + // Timeout after opening of the CB, until the state is set to half-open. + Timeout time.Duration + // The allowed amount of requests that are allowed to pass when the CB is half-open. + MaxRetryRequests uint32 +} + +func (config *CBConfig) readyToTrip(counts gobreaker.Counts) bool { + if config.MaxConsecutiveFailures > 0 && counts.ConsecutiveFailures > config.MaxConsecutiveFailures { + return true + } + if config.MaxFailureRatio > 0 && counts.Requests > 0 { + failureRatio := float64(counts.TotalFailures) / float64(counts.Requests) + return failureRatio > config.MaxFailureRatio + } + return false +} + +// limiter implements [redis.Limiter] as a circuit breaker. +type limiter struct { + inflight chan func(success bool) + cb *gobreaker.TwoStepCircuitBreaker[struct{}] +} + +func newLimiter(config *CBConfig, maxActiveConns int) redis.Limiter { + if config == nil { + return nil + } + // The size of the inflight channel needs to be big enough for maxActiveConns to prevent blocking. + // When that is 0 (no limit), we must set a sane default. + if maxActiveConns <= 0 { + maxActiveConns = defaultInflightSize + } + return &limiter{ + inflight: make(chan func(success bool), maxActiveConns), + cb: gobreaker.NewTwoStepCircuitBreaker[struct{}](gobreaker.Settings{ + Name: "redis cache", + MaxRequests: config.MaxRetryRequests, + Interval: config.Interval, + Timeout: config.Timeout, + ReadyToTrip: config.readyToTrip, + OnStateChange: func(name string, from, to gobreaker.State) { + logging.WithFields("name", name, "from", from, "to", to).Warn("circuit breaker state change") + }, + }), + } +} + +// Allow implements [redis.Limiter]. +func (l *limiter) Allow() error { + done, err := l.cb.Allow() + if err != nil { + return err + } + l.inflight <- done + return nil +} + +// ReportResult implements [redis.Limiter]. +// +// ReportResult checks the error returned by the Redis client. +// `nil`, [redis.Nil] and [context.Canceled] are not considered failures. +// Any other error, like connection or [context.DeadlineExceeded] is counted as a failure. +func (l *limiter) ReportResult(err error) { + done := <-l.inflight + done(err == nil || + errors.Is(err, redis.Nil) || + errors.Is(err, context.Canceled)) +} diff --git a/internal/cache/connector/redis/circuit_breaker_test.go b/internal/cache/connector/redis/circuit_breaker_test.go new file mode 100644 index 0000000000..ba61d18071 --- /dev/null +++ b/internal/cache/connector/redis/circuit_breaker_test.go @@ -0,0 +1,168 @@ +package redis + +import ( + "context" + "testing" + "time" + + "github.com/sony/gobreaker/v2" + "github.com/stretchr/testify/require" + + "github.com/zitadel/zitadel/internal/cache" +) + +func TestCBConfig_readyToTrip(t *testing.T) { + type fields struct { + MaxConsecutiveFailures uint32 + MaxFailureRatio float64 + } + type args struct { + counts gobreaker.Counts + } + tests := []struct { + name string + fields fields + args args + want bool + }{ + { + name: "disabled", + fields: fields{}, + args: args{ + counts: gobreaker.Counts{ + Requests: 100, + ConsecutiveFailures: 5, + TotalFailures: 10, + }, + }, + want: false, + }, + { + name: "no failures", + fields: fields{ + MaxConsecutiveFailures: 5, + MaxFailureRatio: 0.1, + }, + args: args{ + counts: gobreaker.Counts{ + Requests: 100, + ConsecutiveFailures: 0, + TotalFailures: 0, + }, + }, + want: false, + }, + { + name: "some failures", + fields: fields{ + MaxConsecutiveFailures: 5, + MaxFailureRatio: 0.1, + }, + args: args{ + counts: gobreaker.Counts{ + Requests: 100, + ConsecutiveFailures: 5, + TotalFailures: 10, + }, + }, + want: false, + }, + { + name: "consecutive exceeded", + fields: fields{ + MaxConsecutiveFailures: 5, + MaxFailureRatio: 0.1, + }, + args: args{ + counts: gobreaker.Counts{ + Requests: 100, + ConsecutiveFailures: 6, + TotalFailures: 0, + }, + }, + want: true, + }, + { + name: "ratio exceeded", + fields: fields{ + MaxConsecutiveFailures: 5, + MaxFailureRatio: 0.1, + }, + args: args{ + counts: gobreaker.Counts{ + Requests: 100, + ConsecutiveFailures: 1, + TotalFailures: 11, + }, + }, + want: true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + config := &CBConfig{ + MaxConsecutiveFailures: tt.fields.MaxConsecutiveFailures, + MaxFailureRatio: tt.fields.MaxFailureRatio, + } + if got := config.readyToTrip(tt.args.counts); got != tt.want { + t.Errorf("CBConfig.readyToTrip() = %v, want %v", got, tt.want) + } + }) + } +} + +func Test_redisCache_limiter(t *testing.T) { + c, _ := prepareCache(t, cache.Config{}, withCircuitBreakerOption( + &CBConfig{ + MaxConsecutiveFailures: 2, + MaxFailureRatio: 0.4, + Timeout: 100 * time.Millisecond, + MaxRetryRequests: 1, + }, + )) + + ctx := context.Background() + canceledCtx, cancel := context.WithCancel(ctx) + cancel() + timedOutCtx, cancel := context.WithTimeout(ctx, -1) + defer cancel() + + // CB is and should remain closed + for i := 0; i < 10; i++ { + err := c.Truncate(ctx) + require.NoError(t, err) + } + for i := 0; i < 10; i++ { + err := c.Truncate(canceledCtx) + require.ErrorIs(t, err, context.Canceled) + } + + // Timeout err should open the CB after more than 2 failures + for i := 0; i < 3; i++ { + err := c.Truncate(timedOutCtx) + if i > 2 { + require.ErrorIs(t, err, gobreaker.ErrOpenState) + } else { + require.ErrorIs(t, err, context.DeadlineExceeded) + } + } + + time.Sleep(200 * time.Millisecond) + + // CB should be half-open. If the first command fails, the CB will be Open again + err := c.Truncate(timedOutCtx) + require.ErrorIs(t, err, context.DeadlineExceeded) + err = c.Truncate(timedOutCtx) + require.ErrorIs(t, err, gobreaker.ErrOpenState) + + // Reset the DB to closed + time.Sleep(200 * time.Millisecond) + err = c.Truncate(ctx) + require.NoError(t, err) + + // Exceed the ratio + err = c.Truncate(timedOutCtx) + require.ErrorIs(t, err, context.DeadlineExceeded) + err = c.Truncate(ctx) + require.ErrorIs(t, err, gobreaker.ErrOpenState) +} diff --git a/internal/cache/connector/redis/connector.go b/internal/cache/connector/redis/connector.go index 2d0498dfa0..a10a0c25d0 100644 --- a/internal/cache/connector/redis/connector.go +++ b/internal/cache/connector/redis/connector.go @@ -105,6 +105,8 @@ type Config struct { // Add suffix to client name. Default is empty. IdentitySuffix string + + CircuitBreaker *CBConfig } type Connector struct { @@ -146,6 +148,7 @@ func optionsFromConfig(c Config) *redis.Options { ConnMaxLifetime: c.ConnMaxLifetime, DisableIndentity: c.DisableIndentity, IdentitySuffix: c.IdentitySuffix, + Limiter: newLimiter(c.CircuitBreaker, c.MaxActiveConns), } if c.EnableTLS { opts.TLSConfig = new(tls.Config) diff --git a/internal/cache/connector/redis/redis_test.go b/internal/cache/connector/redis/redis_test.go index 3f45be1502..1909f55e44 100644 --- a/internal/cache/connector/redis/redis_test.go +++ b/internal/cache/connector/redis/redis_test.go @@ -6,7 +6,6 @@ import ( "time" "github.com/alicebob/miniredis/v2" - "github.com/redis/go-redis/v9" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "github.com/zitadel/logging" @@ -689,26 +688,34 @@ func Test_redisCache_Truncate(t *testing.T) { } } -func prepareCache(t *testing.T, conf cache.Config) (cache.Cache[testIndex, string, *testObject], *miniredis.Miniredis) { +func prepareCache(t *testing.T, conf cache.Config, options ...func(*Config)) (cache.Cache[testIndex, string, *testObject], *miniredis.Miniredis) { conf.Log = &logging.Config{ Level: "debug", AddSource: true, } server := miniredis.RunT(t) server.Select(testDB) - client := redis.NewClient(&redis.Options{ - Network: "tcp", - Addr: server.Addr(), - }) + + connConfig := Config{ + Enabled: true, + Network: "tcp", + Addr: server.Addr(), + DisableIndentity: true, + } + for _, option := range options { + option(&connConfig) + } + connector := NewConnector(connConfig) t.Cleanup(func() { - client.Close() + connector.Close() server.Close() }) - connector := NewConnector(Config{ - Enabled: true, - Network: "tcp", - Addr: server.Addr(), - }) c := NewCache[testIndex, string, *testObject](conf, connector, testDB, testIndices) return c, server } + +func withCircuitBreakerOption(cb *CBConfig) func(*Config) { + return func(c *Config) { + c.CircuitBreaker = cb + } +} From b77901cb4b08e1e620228f2fb8e1a74fd8d3222c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tim=20M=C3=B6hlmann?= Date: Wed, 13 Nov 2024 23:18:47 +0200 Subject: [PATCH 07/64] fix(cache): unset client and user names in defaults (#8901) # Which Problems Are Solved By having default entries in the `Username` and `ClientName` fields, it was not possible to unset there parameters. Unsetting them is required for GCP connections # How the Problems Are Solved Set the fields to empty strings. # Additional Changes - none # Additional Context - none --- cmd/defaults.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/cmd/defaults.yaml b/cmd/defaults.yaml index a188d1446b..4854356455 100644 --- a/cmd/defaults.yaml +++ b/cmd/defaults.yaml @@ -211,11 +211,11 @@ Caches: # host:port address. Addr: localhost:6379 # ClientName will execute the `CLIENT SETNAME ClientName` command for each conn. - ClientName: ZITADEL_cache + ClientName: "" # Use the specified Username to authenticate the current connection # with one of the connections defined in the ACL list when connecting # to a Redis 6.0 instance, or greater, that is using the Redis ACL system. - Username: zitadel + Username: "" # Optional password. Must match the password specified in the # requirepass server configuration option (if connecting to a Redis 5.0 instance, or lower), # or the User Password when connecting to a Redis 6.0 instance, or greater, From 85bdf015054185cc411e2f6ee31faa7792d64d1a Mon Sep 17 00:00:00 2001 From: Livio Spring Date: Thu, 14 Nov 2024 15:04:39 +0100 Subject: [PATCH 08/64] fix(actions): preserve order of execution (#8895) # Which Problems Are Solved The order of actions on a trigger was not respected on the execution and not correctly returned when retrieving the flow, for example in Console. The supposed correction of the order (e.g. in the UI) would then return a "no changes" error since the order was already as desired. # How the Problems Are Solved - Correctly order the actions of a trigger based on their configuration (`trigger_sequence`). # Additional Changes - replaced a `reflect.DeepEqual` with `slices.Equal` for checking the action list # Additional Context - reported by a customer - requires backports --- internal/command/org_flow.go | 4 ++-- internal/query/action_flow.go | 2 ++ internal/query/action_flow_test.go | 10 ++++++---- 3 files changed, 10 insertions(+), 6 deletions(-) diff --git a/internal/command/org_flow.go b/internal/command/org_flow.go index eff7a90286..24143aa96b 100644 --- a/internal/command/org_flow.go +++ b/internal/command/org_flow.go @@ -2,7 +2,7 @@ package command import ( "context" - "reflect" + "slices" "github.com/zitadel/zitadel/internal/domain" "github.com/zitadel/zitadel/internal/repository/org" @@ -47,7 +47,7 @@ func (c *Commands) SetTriggerActions(ctx context.Context, flowType domain.FlowTy if err != nil { return nil, err } - if reflect.DeepEqual(existingFlow.Triggers[triggerType], actionIDs) { + if slices.Equal(existingFlow.Triggers[triggerType], actionIDs) { return nil, zerrors.ThrowPreconditionFailed(nil, "COMMAND-Nfh52", "Errors.Flow.NoChanges") } if len(actionIDs) > 0 { diff --git a/internal/query/action_flow.go b/internal/query/action_flow.go index d6d4243d94..c5263d6c43 100644 --- a/internal/query/action_flow.go +++ b/internal/query/action_flow.go @@ -168,6 +168,7 @@ func prepareTriggerActionsQuery(ctx context.Context, db prepareDatabase) (sq.Sel ). From(flowsTriggersTable.name). LeftJoin(join(ActionColumnID, FlowsTriggersColumnActionID) + db.Timetravel(call.Took(ctx))). + OrderBy(FlowsTriggersColumnTriggerSequence.identifier()). PlaceholderFormat(sq.Dollar), func(rows *sql.Rows) ([]*Action, error) { actions := make([]*Action, 0) @@ -220,6 +221,7 @@ func prepareFlowQuery(ctx context.Context, db prepareDatabase, flowType domain.F ). From(flowsTriggersTable.name). LeftJoin(join(ActionColumnID, FlowsTriggersColumnActionID) + db.Timetravel(call.Took(ctx))). + OrderBy(FlowsTriggersColumnTriggerSequence.identifier()). PlaceholderFormat(sq.Dollar), func(rows *sql.Rows) (*Flow, error) { flow := &Flow{ diff --git a/internal/query/action_flow_test.go b/internal/query/action_flow_test.go index 75f1ed22b4..af0db27278 100644 --- a/internal/query/action_flow_test.go +++ b/internal/query/action_flow_test.go @@ -33,8 +33,9 @@ var ( ` projections.flow_triggers3.sequence,` + ` projections.flow_triggers3.resource_owner` + ` FROM projections.flow_triggers3` + - ` LEFT JOIN projections.actions3 ON projections.flow_triggers3.action_id = projections.actions3.id AND projections.flow_triggers3.instance_id = projections.actions3.instance_id` - // ` AS OF SYSTEM TIME '-1 ms'` + ` LEFT JOIN projections.actions3 ON projections.flow_triggers3.action_id = projections.actions3.id AND projections.flow_triggers3.instance_id = projections.actions3.instance_id` + + ` AS OF SYSTEM TIME '-1 ms'` + + ` ORDER BY projections.flow_triggers3.trigger_sequence` prepareFlowCols = []string{ "id", "creation_date", @@ -66,8 +67,9 @@ var ( ` projections.actions3.allowed_to_fail,` + ` projections.actions3.timeout` + ` FROM projections.flow_triggers3` + - ` LEFT JOIN projections.actions3 ON projections.flow_triggers3.action_id = projections.actions3.id AND projections.flow_triggers3.instance_id = projections.actions3.instance_id` - // ` AS OF SYSTEM TIME '-1 ms'` + ` LEFT JOIN projections.actions3 ON projections.flow_triggers3.action_id = projections.actions3.id AND projections.flow_triggers3.instance_id = projections.actions3.instance_id` + + ` AS OF SYSTEM TIME '-1 ms'` + + ` ORDER BY projections.flow_triggers3.trigger_sequence` prepareTriggerActionCols = []string{ "id", From 374b9a7f66046253da14dcc936fe8a3842385352 Mon Sep 17 00:00:00 2001 From: Livio Spring Date: Fri, 15 Nov 2024 07:19:43 +0100 Subject: [PATCH 09/64] fix(saml): provide option to get internal as default ACS (#8888) # Which Problems Are Solved Some SAML IdPs including Google only allow to configure a single AssertionConsumerService URL. Since the current metadata provides multiple and the hosted login UI is not published as neither the first nor with `isDefault=true`, those IdPs take another and then return an error on sign in. # How the Problems Are Solved Allow to reorder the ACS URLs using a query parameter (`internalUI=true`) when retrieving the metadata endpoint. This will list the `ui/login/login/externalidp/saml/acs` first and also set the `isDefault=true`. # Additional Changes None # Additional Context Reported by a customer --- internal/api/idp/idp.go | 62 ++++++--- internal/api/idp/integration_test/idp_test.go | 122 +++++++++++++++++- 2 files changed, 162 insertions(+), 22 deletions(-) diff --git a/internal/api/idp/idp.go b/internal/api/idp/idp.go index 3d46f029da..01594c43ba 100644 --- a/internal/api/idp/idp.go +++ b/internal/api/idp/idp.go @@ -8,9 +8,11 @@ import ( "fmt" "io" "net/http" + "strconv" "github.com/crewjam/saml" "github.com/gorilla/mux" + "github.com/muhlemmer/gu" "github.com/zitadel/logging" http_utils "github.com/zitadel/zitadel/internal/api/http" @@ -49,6 +51,7 @@ const ( paramError = "error" paramErrorDescription = "error_description" varIDPID = "idpid" + paramInternalUI = "internalUI" ) type Handler struct { @@ -187,21 +190,8 @@ func (h *Handler) handleMetadata(w http.ResponseWriter, r *http.Request) { } metadata := sp.ServiceProvider.Metadata() - for i, spDesc := range metadata.SPSSODescriptors { - spDesc.AssertionConsumerServices = append( - spDesc.AssertionConsumerServices, - saml.IndexedEndpoint{ - Binding: saml.HTTPPostBinding, - Location: h.loginSAMLRootURL(ctx), - Index: len(spDesc.AssertionConsumerServices) + 1, - }, saml.IndexedEndpoint{ - Binding: saml.HTTPArtifactBinding, - Location: h.loginSAMLRootURL(ctx), - Index: len(spDesc.AssertionConsumerServices) + 2, - }, - ) - metadata.SPSSODescriptors[i] = spDesc - } + internalUI, _ := strconv.ParseBool(r.URL.Query().Get(paramInternalUI)) + h.assertionConsumerServices(ctx, metadata, internalUI) buf, _ := xml.MarshalIndent(metadata, "", " ") w.Header().Set("Content-Type", "application/samlmetadata+xml") @@ -212,6 +202,48 @@ func (h *Handler) handleMetadata(w http.ResponseWriter, r *http.Request) { } } +func (h *Handler) assertionConsumerServices(ctx context.Context, metadata *saml.EntityDescriptor, internalUI bool) { + if !internalUI { + for i, spDesc := range metadata.SPSSODescriptors { + spDesc.AssertionConsumerServices = append( + spDesc.AssertionConsumerServices, + saml.IndexedEndpoint{ + Binding: saml.HTTPPostBinding, + Location: h.loginSAMLRootURL(ctx), + Index: len(spDesc.AssertionConsumerServices) + 1, + }, saml.IndexedEndpoint{ + Binding: saml.HTTPArtifactBinding, + Location: h.loginSAMLRootURL(ctx), + Index: len(spDesc.AssertionConsumerServices) + 2, + }, + ) + metadata.SPSSODescriptors[i] = spDesc + } + return + } + for i, spDesc := range metadata.SPSSODescriptors { + acs := make([]saml.IndexedEndpoint, 0, len(spDesc.AssertionConsumerServices)+2) + acs = append(acs, + saml.IndexedEndpoint{ + Binding: saml.HTTPPostBinding, + Location: h.loginSAMLRootURL(ctx), + Index: 0, + IsDefault: gu.Ptr(true), + }, + saml.IndexedEndpoint{ + Binding: saml.HTTPArtifactBinding, + Location: h.loginSAMLRootURL(ctx), + Index: 1, + }) + for i := 0; i < len(spDesc.AssertionConsumerServices); i++ { + spDesc.AssertionConsumerServices[i].Index = 2 + i + acs = append(acs, spDesc.AssertionConsumerServices[i]) + } + spDesc.AssertionConsumerServices = acs + metadata.SPSSODescriptors[i] = spDesc + } +} + func (h *Handler) handleACS(w http.ResponseWriter, r *http.Request) { ctx := r.Context() data := parseSAMLRequest(r) diff --git a/internal/api/idp/integration_test/idp_test.go b/internal/api/idp/integration_test/idp_test.go index 8e7141271a..d7616f8f53 100644 --- a/internal/api/idp/integration_test/idp_test.go +++ b/internal/api/idp/integration_test/idp_test.go @@ -23,6 +23,7 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" saml_xml "github.com/zitadel/saml/pkg/provider/xml" + "github.com/zitadel/saml/pkg/provider/xml/md" "golang.org/x/crypto/bcrypt" http_util "github.com/zitadel/zitadel/internal/api/http" @@ -111,13 +112,15 @@ func TestServer_SAMLMetadata(t *testing.T) { oauthIdpResp := Instance.AddGenericOAuthProvider(CTX, Instance.DefaultOrg.Id) type args struct { - ctx context.Context - idpID string + ctx context.Context + idpID string + internalUI bool } tests := []struct { - name string - args args - want int + name string + args args + want int + wantACS []md.IndexedEndpointType }{ { name: "saml metadata, invalid idp", @@ -142,11 +145,115 @@ func TestServer_SAMLMetadata(t *testing.T) { idpID: samlRedirectIdpID, }, want: http.StatusOK, + wantACS: []md.IndexedEndpointType{ + { + XMLName: xml.Name{ + Space: "urn:oasis:names:tc:SAML:2.0:metadata", + Local: "AssertionConsumerService", + }, + Index: "1", + IsDefault: "", + Binding: saml.HTTPPostBinding, + Location: http_util.BuildOrigin(Instance.Host(), Instance.Config.Secure) + "/idps/" + samlRedirectIdpID + "/saml/acs", + ResponseLocation: "", + }, + { + XMLName: xml.Name{ + Space: "urn:oasis:names:tc:SAML:2.0:metadata", + Local: "AssertionConsumerService", + }, + Index: "2", + IsDefault: "", + Binding: saml.HTTPArtifactBinding, + Location: http_util.BuildOrigin(Instance.Host(), Instance.Config.Secure) + "/idps/" + samlRedirectIdpID + "/saml/acs", + ResponseLocation: "", + }, + { + XMLName: xml.Name{ + Space: "urn:oasis:names:tc:SAML:2.0:metadata", + Local: "AssertionConsumerService", + }, + Index: "3", + IsDefault: "", + Binding: saml.HTTPPostBinding, + Location: http_util.BuildOrigin(Instance.Host(), Instance.Config.Secure) + "/ui/login/login/externalidp/saml/acs", + ResponseLocation: "", + }, + { + XMLName: xml.Name{ + Space: "urn:oasis:names:tc:SAML:2.0:metadata", + Local: "AssertionConsumerService", + }, + Index: "4", + IsDefault: "", + Binding: saml.HTTPArtifactBinding, + Location: http_util.BuildOrigin(Instance.Host(), Instance.Config.Secure) + "/ui/login/login/externalidp/saml/acs", + ResponseLocation: "", + }, + }, + }, + { + name: "saml metadata, ok (internalUI)", + args: args{ + ctx: CTX, + idpID: samlRedirectIdpID, + internalUI: true, + }, + want: http.StatusOK, + wantACS: []md.IndexedEndpointType{ + { + XMLName: xml.Name{ + Space: "urn:oasis:names:tc:SAML:2.0:metadata", + Local: "AssertionConsumerService", + }, + Index: "0", + IsDefault: "true", + Binding: saml.HTTPPostBinding, + Location: http_util.BuildOrigin(Instance.Host(), Instance.Config.Secure) + "/ui/login/login/externalidp/saml/acs", + ResponseLocation: "", + }, + { + XMLName: xml.Name{ + Space: "urn:oasis:names:tc:SAML:2.0:metadata", + Local: "AssertionConsumerService", + }, + Index: "1", + IsDefault: "", + Binding: saml.HTTPArtifactBinding, + Location: http_util.BuildOrigin(Instance.Host(), Instance.Config.Secure) + "/ui/login/login/externalidp/saml/acs", + ResponseLocation: "", + }, + { + XMLName: xml.Name{ + Space: "urn:oasis:names:tc:SAML:2.0:metadata", + Local: "AssertionConsumerService", + }, + Index: "2", + IsDefault: "", + Binding: saml.HTTPPostBinding, + Location: http_util.BuildOrigin(Instance.Host(), Instance.Config.Secure) + "/idps/" + samlRedirectIdpID + "/saml/acs", + ResponseLocation: "", + }, + { + XMLName: xml.Name{ + Space: "urn:oasis:names:tc:SAML:2.0:metadata", + Local: "AssertionConsumerService", + }, + Index: "3", + IsDefault: "", + Binding: saml.HTTPArtifactBinding, + Location: http_util.BuildOrigin(Instance.Host(), Instance.Config.Secure) + "/idps/" + samlRedirectIdpID + "/saml/acs", + ResponseLocation: "", + }, + }, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { metadataURL := http_util.BuildOrigin(Instance.Host(), Instance.Config.Secure) + "/idps/" + tt.args.idpID + "/saml/metadata" + if tt.args.internalUI { + metadataURL = metadataURL + "?internalUI=true" + } resp, err := http.Get(metadataURL) assert.NoError(t, err) assert.Equal(t, tt.want, resp.StatusCode) @@ -155,10 +262,11 @@ func TestServer_SAMLMetadata(t *testing.T) { defer resp.Body.Close() assert.NoError(t, err) - _, err = saml_xml.ParseMetadataXmlIntoStruct(b) + metadata, err := saml_xml.ParseMetadataXmlIntoStruct(b) assert.NoError(t, err) - } + assert.Equal(t, metadata.SPSSODescriptor.AssertionConsumerService, tt.wantACS) + } }) } } From 7ba797b8724b5dfa7201e9e03097aa10a81a3511 Mon Sep 17 00:00:00 2001 From: Zach Hirschtritt Date: Fri, 15 Nov 2024 01:46:33 -0500 Subject: [PATCH 10/64] fix: use correct check for user existing on import (#8907) # Which Problems Are Solved - ImportHuman was not checking for a `UserStateDeleted` state on import, resulting in "already existing" errors when attempting to delete and re-import a user with the same id # How the Problems Are Solved Use the `Exists` helper method to check for both `UserStateUnspecified` and `UserStateDeleted` states on import # Additional Changes N/A # Additional Context N/A Co-authored-by: Livio Spring --- internal/command/user_human.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/command/user_human.go b/internal/command/user_human.go index 91739e0d6d..ab2617c276 100644 --- a/internal/command/user_human.go +++ b/internal/command/user_human.go @@ -448,7 +448,7 @@ func (c *Commands) ImportHuman(ctx context.Context, orgID string, human *domain. return nil, nil, err } - if existing.UserState != domain.UserStateUnspecified { + if existing.UserState.Exists() { return nil, nil, zerrors.ThrowPreconditionFailed(nil, "COMMAND-ziuna", "Errors.User.AlreadyExisting") } } From 45cf38e08f15d2dd445fd45050b7b6677f9dea7f Mon Sep 17 00:00:00 2001 From: Florian Forster Date: Fri, 15 Nov 2024 16:48:11 +0100 Subject: [PATCH 11/64] chore: adding an adopters file for our community (#8909) # Which Problems Are Solved We want to give adopters a platform to show that they are using ZITADEL # How the Problems Are Solved Addding an ADOPTERS.md file # Additional Changes none # Additional Context none --- ADOPTERS.md | 9 +++++++++ README.md | 4 ++++ 2 files changed, 13 insertions(+) create mode 100644 ADOPTERS.md diff --git a/ADOPTERS.md b/ADOPTERS.md new file mode 100644 index 0000000000..3bb7e1a707 --- /dev/null +++ b/ADOPTERS.md @@ -0,0 +1,9 @@ +## Adopters + +We are grateful to the organizations and individuals who are using ZITADEL. If you are using ZITADEL, please consider adding your name to this list by submitting a pull request. + +| Organization/Individual | Contact Information | Description of Usage | +| ----------------------- | -------------------------------------------------------- | ----------------------------------------------- | +| ZITADEL | [@fforootd](https://github.com/fforootd) (and many more) | ZITADEL Cloud makes heavy use of of ZITADEL ;-) | +| Organization Name | contact@example.com | Description of how they use ZITADEL | +| Individual Name | contact@example.com | Description of how they use ZITADEL | diff --git a/README.md b/README.md index 27c66dbf81..17306129c4 100644 --- a/README.md +++ b/README.md @@ -89,6 +89,10 @@ Available data regions are: ZITADEL Cloud comes with a free tier, providing you with all the same features as the open-source version. Learn more about the [pay-as-you-go pricing](https://zitadel.com/pricing). +## Adopters + +We are grateful to the organizations and individuals who are using ZITADEL. If you are using ZITADEL, please consider adding your name to our [Adopters list](./ADOPTERS.md) by submitting a pull request. + ### Example applications Clone one of our [example applications](https://zitadel.com/docs/sdk-examples/introduction) or deploy them directly to Vercel. From fbebe0f183389489019c2e699d75be2b0dfb4407 Mon Sep 17 00:00:00 2001 From: Silvan Date: Fri, 15 Nov 2024 22:44:22 +0100 Subject: [PATCH 12/64] docs: init benchmarks (#8894) # Which Problems Are Solved Adds initial benchmarks. # How the Problems Are Solved Added section `apis/benchmarks` # Additional Changes Update Makefile dependencies # Additional Context - Part of https://github.com/zitadel/zitadel/issues/8023 - Part of https://github.com/zitadel/zitadel/issues/8352 --- docs/docs/apis/benchmarks/_template.mdx | 77 + docs/docs/apis/benchmarks/index.mdx | 111 + .../machine_jwt_profile_grant/index.mdx | 75 + .../machine_jwt_profile_grant/output.json | 1803 +++++++++++++++++ docs/package.json | 1 + docs/sidebars.js | 24 + docs/src/components/benchmark_chart.jsx | 45 + docs/static/img/benchmark/Flowchart.svg | 1 + docs/yarn.lock | 5 + 9 files changed, 2142 insertions(+) create mode 100644 docs/docs/apis/benchmarks/_template.mdx create mode 100644 docs/docs/apis/benchmarks/index.mdx create mode 100644 docs/docs/apis/benchmarks/v2.65.0/machine_jwt_profile_grant/index.mdx create mode 100644 docs/docs/apis/benchmarks/v2.65.0/machine_jwt_profile_grant/output.json create mode 100644 docs/src/components/benchmark_chart.jsx create mode 100644 docs/static/img/benchmark/Flowchart.svg diff --git a/docs/docs/apis/benchmarks/_template.mdx b/docs/docs/apis/benchmarks/_template.mdx new file mode 100644 index 0000000000..a6a9109309 --- /dev/null +++ b/docs/docs/apis/benchmarks/_template.mdx @@ -0,0 +1,77 @@ + + +## Summary + +TODO: describe the outcome of the test? + +## Performance test results + +| Metric | Value | +|-:-------------------------------------|-:-----| +| Baseline | none | +| Purpose | | +| Test start | UTC | +| Test duration | 30min | +| Executed test | | +| k6 version | | +| VUs | | +| Client location | | +| Client machine specification | vCPU:
memory: Gb | +| ZITADEL location | | +| ZITADEL container specification | vCPU:
Memory: Gb
Container count: | +| ZITADEL Version | | +| ZITADEL Configuration | | +| ZITADEL feature flags | | +| Database | type: crdb / psql
version: | +| Database location | | +| Database specification | vCPU:
memory: Gb | +| ZITADEL metrics during test | | +| Observed errors | | +| Top 3 most expensive database queries | | +| Database metrics during test | | +| k6 Iterations per second | | +| k6 overview | | +| k6 output | | +| flowchart outcome | | + + +## Endpoint latencies + +import OutputSource from "!!raw-loader!./output.json"; + +import { BenchmarkChart } from '/src/components/benchmark_chart'; + + + +## k6 output {#k6-output} + +```bash +TODO: add summary of k6 +``` + diff --git a/docs/docs/apis/benchmarks/index.mdx b/docs/docs/apis/benchmarks/index.mdx new file mode 100644 index 0000000000..e5d89dbae8 --- /dev/null +++ b/docs/docs/apis/benchmarks/index.mdx @@ -0,0 +1,111 @@ +--- +title: Benchmarks +sidebar_label: Benchmarks +--- + +import DocCardList from '@theme/DocCardList'; + +Benchmarks are crucial to understand if ZITADEL fulfills your expected workload and what resources it needs to do so. + +This document explains the process and goals of load-testing zitadel in a cloud environment. + +The results can be found on sub pages. + +## Goals + +The primary goal is to assess if ZITADEL can scale to required proportion. The goals might change over time and maturity of ZITADEL. At the moment the goal is to assess how the application’s performance scales. There are some concrete goals we have to meet: + +1. [https://github.com/zitadel/zitadel/issues/8352](https://github.com/zitadel/zitadel/issues/8352) defines 1000 JWT profile auth/sec +2. [https://github.com/zitadel/zitadel/issues/4424](https://github.com/zitadel/zitadel/issues/4424) defines 1200 logins / sec. + +## Procedure + +First we determine the “target” of our load-test. The target is expressed as a make recipe in the load-test [Makefile](https://github.com/zitadel/zitadel/blob/main/load-test/Makefile). See also the load-test [readme](https://github.com/zitadel/zitadel/blob/main/load-test/README.md) on how to configure and run load-tests. +A target should be tested for longer periods of time, as it might take time for certain metrics to show up. For example, cloud SQL samples query insights. A runtime of at least **30 minutes** is advised at the moment. + +After each iteration of load-test, we should consult the [After test procedure](#after-test-procedure) to conclude an outcome: + +1. Scale +2. Log potential issuer and scale +3. Terminate testing and resolve issues + + +## Methodology + +### Benchmark definition + +Tests are implemented in the ecosystem of [k6](https://k6.io). The tests are publicly available in the [zitadel repository](https://github.com/zitadel/zitadel/tree/main/load-test). Custom extensions of k6 are implemented in the [xk6-modules repository](https://github.com/zitadel/xk6-modules). +The tests must at least measure the request duration for each API call. This gives an indication on how zitadel behaves over the duration of the load test. + +### Metrics + +The following metrics must be collected for each test iteration. The metrics are used to follow the decision path of the [After test procedure](https://drive.google.com/open?id=1WVr7aA8dGgV1zd2jUg1y1h_o37mkZF2O6M5Mhafn_NM): + +| Metric | Type | Description | Unit | +| :---- | :---- | :---- | :---- | +| Baseline | Comparison | Defines the baseline the test is compared against. If not specified the baseline defined in this document is used. | Link to test result | +| Purpose | Description | Description what should been proved with this test run | text +| Test start | Setup | Timestamp when the test started. This is useful for gathering additional data like metrics or logs later | Date | +| Test duration | Setup | Duration of the test | Duration | +| Executed test | Setup | Name of the make recipe executed. Further information about specific test cases can be found [here](?tab=t.0#heading=h.xav4f3s5r2f3). | Name of the make recipe | +| k6 version | Setup | Version of the test client (k6) used | semantic version | +| VUs | Setup | Virtual Users which execute the test scenario in parallel | Number | +| Client location | Setup | Region or location of the machine which executed the test client. If not further specified the hoster is Google Cloud | Location / Region | +| Client machine specification | Setup | Definition of the client machine the test client ran on. The resources of the machine could be maxed out during tests therefore we collect this metric as well. The description must at least clarify the following metrics: vCPU Memory egress bandwidth | **vCPU**: Amount of threads ([additional info](https://cloud.google.com/compute/docs/cpu-platforms)) **memory**: GB **egress bandwidth**:Gbps | +| ZITADEL location | Setup | Region or location of the deployment of zitadel. If not further specified the hoster is Google Cloud | Location / Region | +| ZITADEL container specification | Setup | As ZITADEL is mainly run in cloud environments it should also be run as a container during the load tests. The description must at least clarify the following metrics: vCPU Memory egress bandwidth Scale | **vCPU**: Amount of threads ([additional info](https://cloud.google.com/compute/docs/cpu-platforms)) **memory**: GB **egress bandwidth**:Gbps **scale**: The amount of containers running during the test. The amount must not vary during the tests | +| ZITADEL Version | Setup | The version of zitadel deployed | Semantic version or commit | +| ZITADEL Configuration | Setup | Configuration of zitadel which deviates from the defaults and is not secret | yaml | +| ZITADEL feature flags | Setup | Changed feature flags | yaml | +| Database | Setup | Database type and version | **type**: crdb / psql **version**: semantic version | +| Database location | Setup | Region or location of the deployment of the database. If not further specified the hoster is Google Cloud SQL | Location / Region | +| Database specification | Setup | The description must at least clarify the following metrics: vCPU, Memory and egress bandwidth (Scale) | **vCPU**: Amount of threads ([additional info](https://cloud.google.com/compute/docs/cpu-platforms)) **memory**: GB **egress bandwidth**:Gbps **scale**: Amount of crdb nodes if crdb is used | +| ZITADEL metrics during test | Result | This metric helps understanding the bottlenecks of the executed test. At least the following metrics must be provided: CPU usage Memory usage | **CPU usage** in percent **Memory usage** in percent | +| Observed errors | Result | Errors worth mentioning, mostly unexpected errors | description | +| Top 3 most expensive database queries | Result | The execution plan of the top 3 most expensive database queries during the test execution | database execution plan | +| Database metrics during test | Result | This metric helps understanding the bottlenecks of the executed test. At least the following metrics must be provided: CPU usage Memory usage | **CPU usage** in percent **Memory usage** in percent | +| k6 Iterations per second | Result | How many test iterations were done per second | Number | +| k6 overview | Result | Shows some basic metrics aggregated over the test run At least the following metrics must be included: duration per request (min, max, avg, p50, p95, p99) VUS For simplicity just add the whole test result printed to the terminal | terminal output | +| k6 output | Result | Trends and metrics generated during the test, this contains detailed information for each step executed during each iteration | csv | + +### Test setup + +#### Make recipes + +Details about the tests implemented can be found in [this readme](https://github.com/zitadel/zitadel/blob/main/load-test/README.md#test). + +### Test conclusion + +After each iteration of load-test, we should consult the [Flowchart](#after-test-procedure) to conclude an outcome: + +1. [Scale](#scale) +2. [Log potential issue and scale](#potential-issues) +3. [Terminate testing](#termination) and resolve issues + +#### Scale {#scale} + +An outcome of scale means that the service hit some kind of resource limit, like CPU or RAM which can be increased. In such cases we increase the suggested parameter and rerun the load-test for the same target. On the next test we should analyse if the increase in scale resulted in a performance improvement proportional to the scale parameter. For example if we scale from 1 to 2 containers, it might be reasonable to expect a doubling of iterations / sec. If such an increase is not noticed, there might be another bottleneck or unlying issue, such as locking. + +#### Potential issues {#potential-issues} + +A potential issue has an impact on performance, but does not prevent us to scale. Such issues must be logged in GH issues and load-testing can continue. The issue can be resolved at a later time and the load-tests repeated when it is. This is primarily for issues which require big changes to ZITADEL. + +#### Termination {#termination} + +Scaling no longer improves iterations / second, or some kind of critical error or bug is experienced. The root cause of the issue must be resolved before we can continue with increasing scale. + +### After test procedure + +This flowchart shows the procedure after running a test. + +![Flowchart](/img/benchmark/Flowchart.svg) + +## Baseline + +Will be established as soon as the goal described above is reached. + +## Test results + +This chapter provides a table linking to the detailed test results. + + diff --git a/docs/docs/apis/benchmarks/v2.65.0/machine_jwt_profile_grant/index.mdx b/docs/docs/apis/benchmarks/v2.65.0/machine_jwt_profile_grant/index.mdx new file mode 100644 index 0000000000..f2e198c510 --- /dev/null +++ b/docs/docs/apis/benchmarks/v2.65.0/machine_jwt_profile_grant/index.mdx @@ -0,0 +1,75 @@ +--- +title: machine jwt profile grant benchmark of zitadel v2.65.0 +sidebar_label: machine jwt profile grant +--- + +## Summary + +Tests are halted after this test run because of too many [client read events](https://docs.aws.amazon.com/AmazonRDS/latest/UserGuide/wait-event.clientread.html) on the database. + +## Performance test results + +| Metric | Value | +| :---- | :---- | +| Baseline | none | +| Test start | 22-10-2024 16:20 UTC | +| Test duration | 30min | +| Executed test | machine\_jwt\_profile\_grant | +| k6 version | v0.54.0 | +| VUs | 50 | +| Client location | US1 | +| Client machine specification | e2-high-cpu-4 | +| Zitadel location | US1 | +| Zitadel container specification | vCPUs: 2
Memory: 512 MiB
Container count: 2 | +| Zitadel feature flags | none | +| Database | postgres v15 | +| Database location | US1 | +| Database specification | vCPUs: 4
Memory: 16 GiB | +| Zitadel metrics during test | | +| Observed errors | Many client read events during push | +| Top 3 most expensive database queries | 1: Query events `instance_id = $1 AND aggregate_type = $2 AND aggregate_id = $3 AND event_type = ANY($4)`
2: latest sequence query during push events
3: writing events during push (caused lock wait events) | +| k6 iterations per second | 193 | +| k6 overview | [output](#k6-output) | +| flowchart outcome | Halt tests, must resolve an issue | + +## /token endpoint latencies + +import OutputSource from "!!raw-loader!./output.json"; + +import { BenchmarkChart } from '/src/components/benchmark_chart'; + + + +## k6 output {#k6-output} + +```bash +checks...............................: 100.00% ✓ 695739 ✗ 0 +data_received........................: 479 MB 265 kB/s +data_sent............................: 276 MB 153 kB/s +http_req_blocked.....................: min=178ns avg=5µs max=119.8ms p(50)=460ns p(95)=702ns p(99)=921ns +http_req_connecting..................: min=0s avg=1.24µs max=43.45ms p(50)=0s p(95)=0s p(99)=0s +http_req_duration....................: min=18ms avg=255.3ms max=1.22s p(50)=241.56ms p(95)=479.19ms p(99)=600.92ms +{ expected_response:true }.........: min=18ms avg=255.3ms max=1.22s p(50)=241.56ms p(95)=479.19ms p(99)=600.92ms +http_req_failed......................: 0.00% ✓ 0 ✗ 347998 +http_req_receiving...................: min=25.92µs avg=536.96µs max=401.94ms p(50)=89.44µs p(95)=2.39ms p(99)=11.12ms +http_req_sending.....................: min=24.01µs avg=63.86µs max=4.48ms p(50)=60.97µs p(95)=88.69µs p(99)=141.74µs +http_req_tls_handshaking.............: min=0s avg=2.8µs max=51.05ms p(50)=0s p(95)=0s p(99)=0s +http_req_waiting.....................: min=17.65ms avg=254.7ms max=1.22s p(50)=240.88ms p(95)=478.6ms p(99)=600.6ms +http_reqs............................: 347998 192.80552/s +iteration_duration...................: min=33.86ms avg=258.77ms max=1.22s p(50)=245ms p(95)=482.61ms p(99)=604.32ms +iterations...........................: 347788 192.689171/s +login_ui_enter_login_name_duration...: min=218.61ms avg=218.61ms max=218.61ms p(50)=218.61ms p(95)=218.61ms p(99)=218.61ms +login_ui_enter_password_duration.....: min=18ms avg=18ms max=18ms p(50)=18ms p(95)=18ms p(99)=18ms +login_ui_init_login_duration.........: min=90.96ms avg=90.96ms max=90.96ms p(50)=90.96ms p(95)=90.96ms p(99)=90.96ms +login_ui_token_duration..............: min=140.02ms avg=140.02ms max=140.02ms p(50)=140.02ms p(95)=140.02ms p(99)=140.02ms +oidc_token_duration..................: min=29.85ms avg=255.38ms max=1.22s p(50)=241.61ms p(95)=479.23ms p(99)=600.95ms +org_create_org_duration..............: min=64.51ms avg=64.51ms max=64.51ms p(50)=64.51ms p(95)=64.51ms p(99)=64.51ms +user_add_machine_key_duration........: min=44.93ms avg=87.89ms max=159.52ms p(50)=84.43ms p(95)=144.59ms p(99)=155.54ms +user_create_machine_duration.........: min=65.75ms avg=266.53ms max=421.58ms p(50)=276.59ms p(95)=380.84ms p(99)=414.43ms +vus..................................: 0 min=0 max=50 +vus_max..............................: 50 min=50 max=50 + +running (30m04.9s), 00/50 VUs, 347788 complete and 0 interrupted iterations +default ✓ [======================================] 50 VUs 30m0s +``` + diff --git a/docs/docs/apis/benchmarks/v2.65.0/machine_jwt_profile_grant/output.json b/docs/docs/apis/benchmarks/v2.65.0/machine_jwt_profile_grant/output.json new file mode 100644 index 0000000000..c2316eae29 --- /dev/null +++ b/docs/docs/apis/benchmarks/v2.65.0/machine_jwt_profile_grant/output.json @@ -0,0 +1,1803 @@ +[ + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:20:35+02","p50":152.742548,"p95":189.4438704479804,"p99":214.3874318037758}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:20:36+02","p50":134.31271792,"p95":176.85990823356335,"p99":189.17410793315742}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:20:37+02","p50":149.12034540000002,"p95":234.05779399949907,"p99":246.90676967010307}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:20:38+02","p50":141.79488025,"p95":298.87885997760134,"p99":314.37352053326987}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:20:39+02","p50":183.23340781250002,"p95":283.2726692992518,"p99":292.49417304653673}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:20:40+02","p50":213.86190366666668,"p95":284.20557151433445,"p99":390.4803307008381}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:20:41+02","p50":78.26700088888889,"p95":445.6252991799176,"p99":464.8756637391315}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:20:42+02","p50":93.90013371875,"p95":393.90886492717925,"p99":404.07198386581877}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:20:43+02","p50":99.26241149999998,"p95":456.46543169375514,"p99":482.1890742862892}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:20:44+02","p50":259.33973561111117,"p95":387.0035960871835,"p99":402.3678526092014}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:20:45+02","p50":163.7966285,"p95":280.9855007966218,"p99":297.6403551814313}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:20:46+02","p50":74.6244951875,"p95":394.0813611938515,"p99":412.85163931081155}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:20:47+02","p50":180.3818921111111,"p95":391.04031958131907,"p99":409.423791903923}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:20:48+02","p50":196.83148822222222,"p95":390.84742817174293,"p99":422.014462269928}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:20:49+02","p50":192.46919175,"p95":261.7432952727178,"p99":297.98025934050463}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:20:50+02","p50":163.3955145,"p95":373.8762231012586,"p99":402.81260525012493}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:20:51+02","p50":169.287491,"p95":252.19139975148394,"p99":260.0374195735345}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:20:52+02","p50":172.3803739375,"p95":254.90392808387185,"p99":298.5229499636707}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:20:53+02","p50":159.63037021875002,"p95":325.96280276943094,"p99":339.4366488568575}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:20:54+02","p50":151.7369948125,"p95":232.77033866258023,"p99":245.4879663243437}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:20:55+02","p50":146.12840375000002,"p95":276.01754821507706,"p99":286.9390759050336}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:20:56+02","p50":166.82458387499997,"p95":244.8316436244864,"p99":255.96774487884497}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:20:57+02","p50":171.2386103125,"p95":231.91869474922763,"p99":255.3471724571259}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:20:58+02","p50":132.43773015625,"p95":305.5849403249092,"p99":317.7783982236719}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:20:59+02","p50":98.29408981249999,"p95":345.2765417731901,"p99":359.39837518970154}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:21:00+02","p50":174.8437813125,"p95":279.72543384122116,"p99":295.9230939623306}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:21:01+02","p50":156.1206249375,"p95":257.0086383694033,"p99":290.1174500658493}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:21:02+02","p50":125.861364375,"p95":335.8766849864509,"p99":344.69110596878573}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:21:03+02","p50":116.19827,"p95":289.9331628960681,"p99":308.2670122635899}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:21:04+02","p50":88.807123,"p95":309.88822512970404,"p99":329.24833508430766}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:21:05+02","p50":125.42367399999999,"p95":313.7408337804247,"p99":330.610476359817}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:21:06+02","p50":148.7421011875,"p95":288.69967697102777,"p99":307.14991885160015}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:21:07+02","p50":123.452076,"p95":324.4732774677882,"p99":338.0723913875208}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:21:08+02","p50":94.517341,"p95":310.1070211770424,"p99":322.64246295973874}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:21:09+02","p50":199.0995741875,"p95":266.4028019143426,"p99":283.33593921839906}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:21:10+02","p50":159.412726375,"p95":231.16968194039453,"p99":236.0824995757258}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:21:11+02","p50":162.84928418750002,"p95":252.365319447649,"p99":276.632285485939}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:21:12+02","p50":145.54211509375,"p95":264.97463031152597,"p99":277.07558885515954}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:21:13+02","p50":137.4853945625,"p95":383.14112161791684,"p99":401.6204909024353}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:21:14+02","p50":110.63827400000001,"p95":324.8113530617447,"p99":348.8752330086517}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:21:15+02","p50":180.9934134375,"p95":294.84876206776056,"p99":304.4406698862913}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:21:16+02","p50":211.72708266666666,"p95":253.80525846297198,"p99":269.59085696040177}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:21:17+02","p50":209.98856577777778,"p95":253.06196131151407,"p99":264.4179178528454}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:21:18+02","p50":156.35097040625,"p95":293.5385406069375,"p99":401.6490299732654}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:21:19+02","p50":133.221681875,"p95":399.4656152824501,"p99":418.854191442893}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:21:20+02","p50":118.86549344444445,"p95":405.91692504971184,"p99":417.90231864442757}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:21:21+02","p50":191.00690390625,"p95":305.106348396054,"p99":334.6109268334396}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:21:22+02","p50":131.788673875,"p95":318.6432634163847,"p99":340.73336806457513}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:21:23+02","p50":119.2033654375,"p95":278.4747170609107,"p99":290.79545231027504}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:21:24+02","p50":103.891219625,"p95":354.52017043143763,"p99":365.99442424978156}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:21:25+02","p50":110.90601099999999,"p95":409.53373378248904,"p99":439.4092664350481}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:21:26+02","p50":110.04638775000001,"p95":353.0635395443648,"p99":390.9164269659906}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:21:27+02","p50":139.166329,"p95":330.2447540706727,"p99":343.60334007957596}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:21:28+02","p50":173.25523311111112,"p95":353.7907797741324,"p99":362.92556865444755}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:21:29+02","p50":268.14744616666667,"p95":350.00666472811616,"p99":364.6646985822191}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:21:30+02","p50":215.313930875,"p95":273.1942162963142,"p99":288.4021928619313}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:21:31+02","p50":151.94144256250001,"p95":302.8211043623749,"p99":316.1713350709291}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:21:32+02","p50":204.56433033333334,"p95":350.7035384562023,"p99":367.32154349208736}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:21:33+02","p50":184.935939625,"p95":244.05362925004005,"p99":265.5505863892994}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:21:34+02","p50":181.39206394444446,"p95":263.64057324885795,"p99":279.00201451316264}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:21:35+02","p50":205.52729209375002,"p95":266.2276509014458,"p99":275.20543244661326}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:21:36+02","p50":184.00616190625001,"p95":337.11831113507805,"p99":366.7406586168554}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:21:37+02","p50":175.3103945,"p95":395.91543717141724,"p99":416.1321127693481}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:21:38+02","p50":170.106749,"p95":233.25833817163755,"p99":255.1431567000685}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:21:39+02","p50":175.60114431250003,"p95":251.45913770202392,"p99":271.3119037283981}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:21:40+02","p50":177.2062990625,"p95":294.1022916899325,"p99":307.97861875463434}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:21:41+02","p50":177.1961231875,"p95":320.58236328688787,"p99":336.7532011867485}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:21:42+02","p50":162.4275845,"p95":361.9094602270508,"p99":385.3591560391846}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:21:43+02","p50":189.32301237500002,"p95":356.5451459530969,"p99":376.63635141138747}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:21:44+02","p50":145.7136858125,"p95":344.11261977926944,"p99":359.2462192147102}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:21:45+02","p50":172.38470781249998,"p95":353.6295055632823,"p99":372.84916331113646}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:21:46+02","p50":206.59438666666668,"p95":441.1500573161624,"p99":474.9812698063977}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:21:47+02","p50":172.873287375,"p95":320.8506584619575,"p99":358.5100670831585}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:21:48+02","p50":169.6534998125,"p95":303.4536858748769,"p99":330.31731258393904}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:21:49+02","p50":173.02729125000002,"p95":298.78449271407396,"p99":339.62662085937404}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:21:50+02","p50":163.46804384375002,"p95":296.1723000890368,"p99":315.01419666908794}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:21:51+02","p50":171.338443,"p95":248.84376248058607,"p99":259.60469639209316}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:21:52+02","p50":163.3185848125,"p95":270.9658209938422,"p99":290.1653841856108}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:21:53+02","p50":147.25774171875003,"p95":263.3940735966012,"p99":284.50598333436756}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:21:54+02","p50":176.44091971875,"p95":266.5990478841503,"p99":300.03993930488224}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:21:55+02","p50":142.82466225,"p95":289.0258727321505,"p99":300.2080874461651}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:21:56+02","p50":175.36170518749998,"p95":330.08911763715554,"p99":375.1193799273758}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:21:57+02","p50":162.222671625,"p95":281.79289915946663,"p99":294.4279530910194}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:21:58+02","p50":143.71196425,"p95":362.0538523995184,"p99":381.575093799727}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:21:59+02","p50":149.01801776000002,"p95":195.78059584427396,"p99":209.09926001296807}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:22:00+02","p50":168.380417625,"p95":267.1608095025909,"p99":282.49195416929626}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:22:01+02","p50":128.7791685,"p95":262.1669432662675,"p99":270.3051170505457}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:22:02+02","p50":127.9593894375,"p95":252.83218249204972,"p99":274.0372604026485}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:22:03+02","p50":127.1617695,"p95":267.06822044613585,"p99":283.4020022676983}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:22:04+02","p50":106.68187865625,"p95":307.734150013091,"p99":316.84643377242423}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:22:05+02","p50":179.382695,"p95":249.17453328077286,"p99":266.2734021554391}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:22:06+02","p50":172.73768656250002,"p95":228.5491930327513,"p99":264.98035601136826}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:22:07+02","p50":128.65640771875002,"p95":337.6269146065091,"p99":371.7743257245612}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:22:08+02","p50":100.0326530625,"p95":303.43871650302395,"p99":334.7614539394679}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:22:09+02","p50":119.12006821875,"p95":335.46215280524103,"p99":350.0083065727637}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:22:10+02","p50":125.806114,"p95":288.15592741759167,"p99":301.6352730747447}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:22:11+02","p50":133.42285409375,"p95":316.6540532220981,"p99":329.05199164620757}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:22:12+02","p50":95.71434059375,"p95":308.4402244789036,"p99":320.67318234980775}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:22:13+02","p50":155.3043468125,"p95":394.8728883021291,"p99":414.4798793780084}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:22:14+02","p50":164.24609787499998,"p95":295.26223958864114,"p99":312.8365331274672}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:22:15+02","p50":140.62968171875002,"p95":297.0755794801937,"p99":311.255825160466}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:22:16+02","p50":167.9410644375,"p95":338.7029358086513,"p99":354.43729884373784}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:22:17+02","p50":155.62452675,"p95":282.73366318783974,"p99":320.7858698425536}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:22:18+02","p50":162.254539375,"p95":324.6161681515989,"p99":340.7320104777489}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:22:19+02","p50":145.28746665625,"p95":309.1353151952988,"p99":321.34194833209347}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:22:20+02","p50":155.42283681249998,"p95":227.0559183476073,"p99":285.1077931350944}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:22:21+02","p50":147.71090025,"p95":330.71194199860406,"p99":343.6829263263979}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:22:22+02","p50":161.43343950000002,"p95":224.56334413866048,"p99":235.36415136311962}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:22:23+02","p50":179.38427725,"p95":249.76000027644014,"p99":260.70670063443185}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:22:24+02","p50":225.33285412500004,"p95":281.72253456857135,"p99":299.9099734186788}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:22:25+02","p50":166.78473862500002,"p95":232.1957648318806,"p99":299.35293979125305}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:22:26+02","p50":172.81051031249999,"p95":338.5984408025247,"p99":365.33059938493534}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:22:27+02","p50":180.41219675,"p95":254.84395884091364,"p99":280.9361037687831}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:22:28+02","p50":170.17445437499998,"p95":244.8935166378977,"p99":282.9037624571953}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:22:29+02","p50":196.90349796875,"p95":264.5450147089112,"p99":284.2996830777185}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:22:30+02","p50":149.61717309375,"p95":258.4944096041763,"p99":280.0767955062025}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:22:31+02","p50":115.37371856249999,"p95":342.0609913932303,"p99":369.31108017582085}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:22:32+02","p50":198.97898015625,"p95":275.73095247318406,"p99":330.507157946228}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:22:33+02","p50":147.3347025,"p95":324.7720196622293,"p99":338.60460008617355}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:22:34+02","p50":118.407053,"p95":281.2860384632016,"p99":326.8983674016011}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:22:35+02","p50":152.8172223125,"p95":271.18395431658706,"p99":314.1567188235145}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:22:36+02","p50":133.17060325,"p95":331.59060301692466,"p99":343.9984966597519}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:22:37+02","p50":139.62863121875,"p95":284.8950458756842,"p99":294.2825206762035}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:22:38+02","p50":180.022578625,"p95":258.0588529323397,"p99":289.3623059911065}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:22:39+02","p50":157.79121866666665,"p95":299.27330007239976,"p99":317.4350921771674}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:22:40+02","p50":208.92391650000002,"p95":344.031906960927,"p99":363.73983160268403}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:22:41+02","p50":202.68174344444444,"p95":305.52328825860576,"p99":337.10060789617324}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:22:42+02","p50":84.37220403124999,"p95":361.3133458600537,"p99":371.2432742504656}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:22:43+02","p50":231.49854371875,"p95":349.888320585701,"p99":384.09872475423884}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:22:44+02","p50":183.9890441875,"p95":271.10930583288854,"p99":298.76474763219386}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:22:45+02","p50":182.8464445,"p95":246.2624996970563,"p99":271.49484056651687}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:22:46+02","p50":93.68166966666666,"p95":463.97366856742735,"p99":481.7509814693787}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:22:47+02","p50":115.90802022222222,"p95":457.24317004265833,"p99":481.81383054922867}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:22:48+02","p50":127.41821911111111,"p95":444.18740387771396,"p99":455.7524692107775}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:22:49+02","p50":139.04014443749998,"p95":294.4670809434862,"p99":305.7424495887248}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:22:50+02","p50":98.5424918125,"p95":347.32745104648734,"p99":363.6402334598913}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:22:51+02","p50":119.86771771875,"p95":261.4503544709988,"p99":271.10387682557536}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:22:52+02","p50":130.5921068125,"p95":335.50228157148223,"p99":345.1897153513317}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:22:53+02","p50":147.82731846875,"p95":305.32917860643096,"p99":327.81091039268375}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:22:54+02","p50":184.37881875,"p95":414.9498528464594,"p99":436.0652476570034}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:22:55+02","p50":134.9334113888889,"p95":394.22851781011155,"p99":423.1439984321022}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:22:56+02","p50":94.6194291875,"p95":369.1436656720502,"p99":429.596956807251}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:22:57+02","p50":103.10241853124998,"p95":358.68138499470405,"p99":369.60892119685246}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:22:58+02","p50":96.860279125,"p95":298.3693310251786,"p99":321.95436359483097}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:22:59+02","p50":126.16320309375,"p95":358.2172364309369,"p99":380.3571782561917}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:23:00+02","p50":163.9286190625,"p95":287.8749754242624,"p99":304.07535824718906}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:23:01+02","p50":110.296562,"p95":356.67980462961077,"p99":369.1336770410352}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:23:02+02","p50":121.2899105625,"p95":376.36945194076355,"p99":393.2726901395225}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:23:03+02","p50":118.94529322222222,"p95":364.647289347859,"p99":379.42877625152425}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:23:04+02","p50":168.66469700000002,"p95":335.5127914306146,"p99":348.81079129140187}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:23:05+02","p50":109.39908218750001,"p95":343.64168488864436,"p99":356.2030261326771}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:23:06+02","p50":90.71489159375001,"p95":395.67789852777827,"p99":414.08672484933857}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:23:07+02","p50":102.102573,"p95":479.7651860054539,"p99":509.84110983474443}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:23:08+02","p50":86.0721990625,"p95":506.7270854694948,"p99":533.6720435621214}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:23:09+02","p50":156.2318160625,"p95":342.99648317675934,"p99":372.053304515549}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:23:10+02","p50":188.14600168750002,"p95":306.32164278457384,"p99":363.1293493959436}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:23:11+02","p50":192.2799678888889,"p95":300.28397298561293,"p99":314.459901629174}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:23:12+02","p50":153.60912234375002,"p95":411.5785920044621,"p99":428.19369271948545}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:23:13+02","p50":111.96040766666665,"p95":435.55265119979356,"p99":452.5854287691793}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:23:14+02","p50":139.76533733333335,"p95":455.35024341441203,"p99":475.24830211597254}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:23:15+02","p50":198.77428865625,"p95":325.2470845571967,"p99":354.2798636108484}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:23:16+02","p50":127.02826,"p95":397.94563417990844,"p99":418.64960824809697}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:23:17+02","p50":106.55520544444444,"p95":452.29131266254143,"p99":470.03969402793314}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:23:18+02","p50":113.88863299999998,"p95":525.0521919118939,"p99":529.8129106062248}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:23:19+02","p50":111.85406478125,"p95":389.74024184425025,"p99":402.0315347526336}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:23:20+02","p50":175.7656145,"p95":347.27385680249597,"p99":358.86244784611324}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:23:21+02","p50":157.78282825,"p95":317.69338495123293,"p99":341.9171689902344}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:23:22+02","p50":131.46160666666665,"p95":369.19526349180256,"p99":387.8415062052717}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:23:23+02","p50":204.50269084374997,"p95":354.0385939407066,"p99":375.9711954611883}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:23:24+02","p50":169.301663,"p95":321.4563997478036,"p99":339.69673819850203}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:23:25+02","p50":263.8962338333334,"p95":347.43064110933443,"p99":372.9916210012765}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:23:26+02","p50":239.87338566666668,"p95":347.0042002027512,"p99":389.0801205401683}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:23:27+02","p50":187.87346983333336,"p95":378.2293020718398,"p99":392.0665997422705}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:23:28+02","p50":278.3228516111111,"p95":413.9842850428133,"p99":420.58175994584656}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:23:29+02","p50":230.29268366666668,"p95":388.3813619979001,"p99":414.3347986147597}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:23:30+02","p50":205.597269,"p95":342.4151837143591,"p99":372.75280997233176}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:23:31+02","p50":185.66734133333333,"p95":366.79302571517263,"p99":386.82752764575815}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:23:32+02","p50":113.17833633333333,"p95":382.2272483782675,"p99":399.6901822997966}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:23:33+02","p50":224.427364,"p95":318.78927568640904,"p99":379.3479040931482}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:23:34+02","p50":171.93366183333333,"p95":337.67122743761195,"p99":347.5229087776327}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:23:35+02","p50":160.31750171875,"p95":296.4358263359214,"p99":305.3764371393015}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:23:36+02","p50":190.399403125,"p95":329.76192083518026,"p99":358.4830148763714}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:23:37+02","p50":189.664540625,"p95":291.7815892821846,"p99":306.7206485249286}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:23:38+02","p50":112.06204344444444,"p95":369.52459361610045,"p99":409.1660215970068}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:23:39+02","p50":276.3946736111111,"p95":403.4417701618221,"p99":483.7071277961846}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:23:40+02","p50":214.50907466666663,"p95":368.17170445707075,"p99":387.5157735438919}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:23:41+02","p50":91.36098566666665,"p95":401.8118318129068,"p99":417.86146135524916}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:23:42+02","p50":237.6363456875,"p95":333.55795506752133,"p99":345.2556038274617}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:23:43+02","p50":208.2464379375,"p95":285.59666313684176,"p99":306.4541200015583}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:23:44+02","p50":108.37741083333333,"p95":381.99226537324716,"p99":399.8603670226402}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:23:45+02","p50":224.89928093749998,"p95":356.48579109293877,"p99":370.34997563481807}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:23:46+02","p50":187.40087555555556,"p95":365.9536206192869,"p99":395.0626114183414}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:23:47+02","p50":219.2488168333333,"p95":352.2197022321227,"p99":360.55958806999826}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:23:48+02","p50":128.14031988888888,"p95":370.29248196576475,"p99":381.02069397623825}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:23:49+02","p50":175.8623413125,"p95":328.54612543744935,"p99":351.48333055523966}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:23:50+02","p50":170.20777471875,"p95":248.07310912902372,"p99":259.2663498275757}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:23:51+02","p50":189.34810149999998,"p95":254.63217625896917,"p99":271.4447753241811}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:23:52+02","p50":155.339734625,"p95":247.59586218575285,"p99":297.08098081845475}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:23:53+02","p50":162.452478,"p95":235.18443832070545,"p99":251.61101743764354}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:23:54+02","p50":197.10812044444447,"p95":340.2054231095977,"p99":372.61731303294516}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:23:55+02","p50":148.3748315625,"p95":304.8272034887056,"p99":328.65983394512176}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:23:56+02","p50":112.314109625,"p95":371.49208539474535,"p99":389.60865558733843}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:23:57+02","p50":84.32444275,"p95":344.43789664394535,"p99":355.5426731411834}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:23:58+02","p50":125.430037875,"p95":363.9490895496088,"p99":386.1311214982054}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:23:59+02","p50":113.5846795,"p95":339.92753503626835,"p99":357.93413792771963}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:24:00+02","p50":112.61793766666666,"p95":352.45506782568015,"p99":357.97922475105383}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:24:01+02","p50":161.57308468749997,"p95":330.8013208029374,"p99":348.19938232203555}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:24:02+02","p50":88.23626255555557,"p95":411.7930310908556,"p99":436.56527862182617}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:24:03+02","p50":155.04360022222224,"p95":363.3401143622393,"p99":378.03465003131817}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:24:04+02","p50":134.3302317777778,"p95":442.9010524626141,"p99":476.7326678590927}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:24:05+02","p50":132.83108988888887,"p95":407.68930056825303,"p99":427.3419876943593}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:24:06+02","p50":235.7166078333333,"p95":427.09760607859795,"p99":450.61696512708517}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:24:07+02","p50":185.73770733333333,"p95":345.20340229425943,"p99":369.64291598097344}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:24:08+02","p50":127.86131288888889,"p95":346.50813979244816,"p99":381.3701055143173}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:24:09+02","p50":204.58561616666668,"p95":385.83124888707266,"p99":404.65335307042693}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:24:10+02","p50":134.93217372222225,"p95":335.8876011389651,"p99":344.50895744095425}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:24:11+02","p50":145.890436125,"p95":371.3015613146542,"p99":380.6078602409513}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:24:12+02","p50":137.35259468749996,"p95":320.9258905171859,"p99":341.68218627207045}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:24:13+02","p50":109.34771977777778,"p95":501.9964330002911,"p99":515.4457118845553}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:24:14+02","p50":100.66297100000001,"p95":463.74451614195823,"p99":513.6433440599309}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:24:15+02","p50":109.57291790625,"p95":371.5828618090343,"p99":388.3091798479202}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:24:16+02","p50":150.32008,"p95":468.928458806547,"p99":488.85305357821943}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:24:17+02","p50":124.15826366666666,"p95":427.9907656928685,"p99":449.04125851229384}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:24:18+02","p50":114.9532918888889,"p95":448.636051890614,"p99":485.4835679474907}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:24:19+02","p50":176.6278854375,"p95":323.2559806472752,"p99":347.9221029315009}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:24:20+02","p50":211.88934416666666,"p95":262.22510196252955,"p99":281.2612729744716}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:24:21+02","p50":180.81894740625,"p95":259.6539730155451,"p99":269.67856666175607}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:24:22+02","p50":196.672637,"p95":266.94895766008,"p99":298.00997561267087}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:24:23+02","p50":164.28795509375,"p95":272.20733088255565,"p99":286.64984232324144}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:24:24+02","p50":239.44598994444445,"p95":422.5159794253372,"p99":439.2937172332916}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:24:25+02","p50":187.291075,"p95":354.77484739822967,"p99":374.8504402552736}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:24:26+02","p50":194.9652128333333,"p95":275.41904993178474,"p99":288.84490828162814}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:24:27+02","p50":203.21512822222223,"p95":355.13161239707694,"p99":395.83519843569616}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:24:28+02","p50":262.27167633333335,"p95":334.4411825664316,"p99":350.01384678881715}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:24:29+02","p50":194.2414280625,"p95":304.2947880739841,"p99":343.91379995135236}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:24:30+02","p50":172.834217,"p95":286.87646965032957,"p99":336.19378734832765}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:24:31+02","p50":207.0231061875,"p95":302.47231828407706,"p99":316.54491024100827}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:24:32+02","p50":181.0753525,"p95":321.49112572849896,"p99":351.5424148507652}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:24:33+02","p50":153.663604,"p95":276.54249616299137,"p99":293.4143785937378}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:24:34+02","p50":160.192512,"p95":392.5024005158302,"p99":406.7674115495229}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:24:35+02","p50":115.11713325,"p95":374.7461023176923,"p99":403.76463397014857}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:24:36+02","p50":195.5520289375,"p95":330.24640575101205,"p99":378.25412397826955}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:24:37+02","p50":153.22983206249998,"p95":302.1021002301149,"p99":320.31823634316964}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:24:38+02","p50":190.33183956249997,"p95":264.897034113108,"p99":280.9633527328908}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:24:39+02","p50":161.7138188125,"p95":297.7743485606594,"p99":329.2594311006222}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:24:40+02","p50":198.20748,"p95":377.3354283850393,"p99":406.5434730048401}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:24:41+02","p50":139.463968625,"p95":266.8608783056278,"p99":289.8984755721698}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:24:42+02","p50":114.82625178125,"p95":349.68077743478915,"p99":361.65627729169654}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:24:43+02","p50":232.9297346875,"p95":309.02823247616396,"p99":327.71930822933507}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:24:44+02","p50":146.7611834375,"p95":314.5849987406578,"p99":368.70549214709615}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:24:45+02","p50":133.7292851875,"p95":297.54178871003234,"p99":310.04929223846005}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:24:46+02","p50":140.45567866666667,"p95":412.6909459159718,"p99":451.2378967052231}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:24:47+02","p50":195.74258483333332,"p95":391.1413024729508,"p99":426.36363475451185}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:24:48+02","p50":141.246246,"p95":379.41185379152716,"p99":406.632193284086}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:24:49+02","p50":162.00057353125,"p95":288.8276731247837,"p99":313.6953214802961}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:24:50+02","p50":150.4389035,"p95":353.7256544370002,"p99":383.2435627905927}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:24:51+02","p50":175.6269825,"p95":248.15324239547587,"p99":264.5156661502189}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:24:52+02","p50":159.8990134375,"p95":300.3999955336074,"p99":325.6686536526949}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:24:53+02","p50":162.34384893749998,"p95":297.086694576544,"p99":332.67165895619155}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:24:54+02","p50":146.514717375,"p95":265.59541198054677,"p99":281.9044932644291}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:24:55+02","p50":175.333063,"p95":334.77658803869053,"p99":373.9112342957733}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:24:56+02","p50":131.2167323125,"p95":288.0913442904415,"p99":310.9476716580658}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:24:57+02","p50":146.7495225625,"p95":297.6914536560938,"p99":339.77493364830207}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:24:58+02","p50":142.8694296875,"p95":285.2685503283086,"p99":313.73204227613735}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:24:59+02","p50":206.041962,"p95":277.91586266952214,"p99":301.9713539372003}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:25:00+02","p50":187.0775275625,"p95":301.2439236612787,"p99":318.2949744250431}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:25:01+02","p50":116.769160625,"p95":297.3274914935353,"p99":314.41500567552447}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:25:02+02","p50":121.01627225,"p95":302.45192923908996,"p99":323.91664271936037}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:25:03+02","p50":115.9357485,"p95":291.0901811554011,"p99":310.4947344401798}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:25:04+02","p50":128.8123610625,"p95":293.8868525166878,"p99":309.0480242172456}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:25:05+02","p50":160.981729375,"p95":307.9032117912121,"p99":320.6469388115158}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:25:06+02","p50":147.9102275,"p95":278.95437096888554,"p99":299.85087856600524}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:25:07+02","p50":177.696945125,"p95":255.77304940730096,"p99":274.12012098093413}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:25:08+02","p50":145.12405118750002,"p95":309.2880586250639,"p99":335.62102451202105}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:25:09+02","p50":188.25798759375,"p95":291.11708460701226,"p99":316.63812602788687}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:25:10+02","p50":167.07433734375,"p95":276.3570106623106,"p99":298.2238179203718}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:25:11+02","p50":157.453457625,"p95":249.26331657150982,"p99":261.95071847950743}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:25:12+02","p50":170.7052875,"p95":272.21180737058626,"p99":291.06299620788957}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:25:13+02","p50":166.31834390625,"p95":255.44433066577483,"p99":272.64818770659184}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:25:14+02","p50":179.1684726875,"p95":257.67500678455974,"p99":283.6161977368152}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:25:15+02","p50":190.28375031250002,"p95":244.14229401287508,"p99":265.4086650401878}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:25:16+02","p50":194.85328783333333,"p95":268.6921016668321,"p99":336.8306007708764}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:25:17+02","p50":217.8591368888889,"p95":270.79396199034244,"p99":293.95661484732244}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:25:18+02","p50":194.134548,"p95":285.16345218963994,"p99":309.2768642459817}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:25:19+02","p50":178.6079338125,"p95":268.24652942969857,"p99":300.60436891284706}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:25:20+02","p50":161.261008,"p95":276.3407417475745,"p99":293.17805059296325}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:25:21+02","p50":172.40981234375,"p95":233.20667978189297,"p99":247.1107588331442}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:25:22+02","p50":188.83555416666664,"p95":297.4500083579718,"p99":307.55552874326133}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:25:23+02","p50":170.39420646874999,"p95":211.03686184797803,"p99":224.53489826566695}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:25:24+02","p50":164.35997515625002,"p95":276.49485311147856,"p99":292.59989314810065}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:25:25+02","p50":143.64633949999998,"p95":272.80621859268007,"p99":281.2128650680747}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:25:26+02","p50":149.58510009375,"p95":307.63088744441603,"p99":335.33292383728195}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:25:27+02","p50":168.15508775,"p95":285.7666212016733,"p99":313.92114463868904}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:25:28+02","p50":188.28659266666668,"p95":295.94769378776715,"p99":315.91148610047435}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:25:29+02","p50":178.75765806249998,"p95":295.6567288852167,"p99":317.2789837458754}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:25:30+02","p50":152.27584396875,"p95":341.79098242099843,"p99":352.3200402101381}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:25:31+02","p50":163.94469566666666,"p95":323.94278911006387,"p99":347.9313984693055}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:25:32+02","p50":190.45950209375002,"p95":295.96613964145297,"p99":314.3702196151154}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:25:33+02","p50":180.63572125000002,"p95":284.8222163975665,"p99":318.613081566169}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:25:34+02","p50":188.64411037500003,"p95":266.3229787653223,"p99":286.69827406677865}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:25:35+02","p50":184.52384700000002,"p95":263.67790348002853,"p99":303.49705895792914}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:25:36+02","p50":179.47607453125,"p95":355.8448701697182,"p99":382.9379842353823}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:25:37+02","p50":181.09762290625002,"p95":251.98523698942577,"p99":268.9887313744817}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:25:38+02","p50":129.08299715625,"p95":305.38149777157025,"p99":339.56128032651185}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:25:39+02","p50":173.939551375,"p95":295.13943168028476,"p99":306.4496784014864}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:25:40+02","p50":184.15993678125,"p95":219.87376809752035,"p99":242.36668579811266}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:25:41+02","p50":176.92126165624998,"p95":255.3895744386714,"p99":273.5941948558238}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:25:42+02","p50":184.09578978125,"p95":242.15070153322625,"p99":255.75301165470577}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:25:43+02","p50":188.93190390625,"p95":270.17291396094987,"p99":288.7374878324158}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:25:44+02","p50":170.27386246875,"p95":302.0877696634417,"p99":322.2277050531044}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:25:45+02","p50":171.28439425,"p95":294.0131721756791,"p99":312.53864885021994}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:25:46+02","p50":216.75257355555553,"p95":322.79357449730196,"p99":356.2870012329533}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:25:47+02","p50":195.4499258333333,"p95":390.5324061168661,"p99":428.31820336249876}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:25:48+02","p50":237.23479605555553,"p95":356.1376857394992,"p99":380.66792645569654}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:25:49+02","p50":198.9062845,"p95":303.59157814383747,"p99":335.63319875815586}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:25:50+02","p50":197.55178744444444,"p95":246.66201525187805,"p99":286.0360022681031}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:25:51+02","p50":203.1253768125,"p95":298.65952319879835,"p99":323.86093111381956}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:25:52+02","p50":157.29233521875,"p95":317.64778697795776,"p99":350.21748591659593}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:25:53+02","p50":162.37203615625,"p95":305.0249680160875,"p99":344.69489125115444}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:25:54+02","p50":174.49744640625,"p95":279.51049966483635,"p99":299.0728399718125}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:25:55+02","p50":155.20962281250002,"p95":267.78474178160235,"p99":295.7095052904222}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:25:56+02","p50":157.371921875,"p95":293.56901468093776,"p99":319.46319449705885}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:25:57+02","p50":157.63671618749999,"p95":280.69837198581314,"p99":305.63588144813065}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:25:58+02","p50":169.2675446875,"p95":288.29848496026744,"p99":320.18566677379846}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:25:59+02","p50":173.090842,"p95":295.79925527414963,"p99":309.57774177467587}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:26:00+02","p50":206.27317477777777,"p95":332.6504074051008,"p99":364.18589949073026}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:26:01+02","p50":182.27632059375,"p95":221.51328541887975,"p99":266.13306615993787}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:26:02+02","p50":168.33315818749998,"p95":233.01725007892423,"p99":262.8184382386236}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:26:03+02","p50":172.41379237499999,"p95":247.44395826688336,"p99":268.36499833924194}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:26:04+02","p50":174.61597949999998,"p95":271.83778823314736,"p99":291.0804720828}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:26:05+02","p50":187.59158225,"p95":282.9914468398229,"p99":309.3119564544549}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:26:06+02","p50":181.2318166875,"p95":251.55728365982807,"p99":268.7432143299179}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:26:07+02","p50":172.82143659375,"p95":270.6421756053556,"p99":301.884456831074}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:26:08+02","p50":172.9038281875,"p95":251.8655380025793,"p99":298.0483943289444}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:26:09+02","p50":176.55819175,"p95":243.4837930472659,"p99":266.464914060235}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:26:10+02","p50":176.31888506250002,"p95":281.58976408667183,"p99":308.106734991333}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:26:11+02","p50":174.0874495,"p95":221.15128212171365,"p99":235.78570532621}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:26:12+02","p50":176.74896453125,"p95":249.94871128860984,"p99":268.7346848409734}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:26:13+02","p50":179.19973940625,"p95":322.88291914086074,"p99":346.68211576098633}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:26:14+02","p50":167.114223,"p95":279.35395499266207,"p99":304.0353890021672}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:26:15+02","p50":189.64666166666666,"p95":296.94451521529624,"p99":339.07841855854224}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:26:16+02","p50":182.53102394444443,"p95":292.5580229132622,"p99":311.0710275786543}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:26:17+02","p50":215.63680366666665,"p95":353.435264173954,"p99":391.11029739471434}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:26:18+02","p50":199.57578966666668,"p95":283.7855816993978,"p99":325.47797826733233}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:26:19+02","p50":180.462869625,"p95":294.70932847846296,"p99":353.2521015840626}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:26:20+02","p50":171.41940599999998,"p95":277.59080925117195,"p99":308.29028416486597}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:26:21+02","p50":175.43739625,"p95":255.71843944816638,"p99":288.13309434937094}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:26:22+02","p50":155.1737825625,"p95":319.33560243206887,"p99":352.25093038751055}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:26:23+02","p50":145.570873375,"p95":296.3754123819015,"p99":317.5755562688322}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:26:24+02","p50":160.4218194375,"p95":340.7114007824508,"p99":364.4656010268531}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:26:25+02","p50":162.16974831250002,"p95":279.9444344233162,"p99":296.29224127451107}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:26:26+02","p50":145.263541375,"p95":303.46550683899375,"p99":317.1922718414955}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:26:27+02","p50":171.87917687499998,"p95":297.13505856224157,"p99":326.78417222842313}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:26:28+02","p50":131.22916377777779,"p95":359.898864760616,"p99":376.2556717880466}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:26:29+02","p50":119.6028695,"p95":364.6834245433574,"p99":392.0918980344329}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:26:30+02","p50":152.96088740625,"p95":328.00408136766305,"p99":351.2401286689439}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:26:31+02","p50":102.28407511111112,"p95":460.5637817656824,"p99":478.0751268420224}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:26:32+02","p50":126.57270088888889,"p95":360.61222046122094,"p99":371.3287526978929}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:26:33+02","p50":132.26185315625,"p95":393.72701131120203,"p99":408.43086990150164}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:26:34+02","p50":165.71685333333335,"p95":356.6648353605751,"p99":384.9428702928367}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:26:35+02","p50":144.32326233333333,"p95":325.38959274268797,"p99":355.6178618845525}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:26:36+02","p50":141.32919166666667,"p95":354.349809254661,"p99":378.2323980121696}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:26:37+02","p50":129.520349,"p95":365.91650590921705,"p99":388.41206783638785}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:26:38+02","p50":107.45927116666667,"p95":388.48086245010336,"p99":410.1962572701168}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:26:39+02","p50":99.57383877777777,"p95":416.093772003908,"p99":424.19737575689317}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:26:40+02","p50":103.85079288888888,"p95":426.45169877547954,"p99":433.7994967553639}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:26:41+02","p50":106.83163511111111,"p95":423.48536041654,"p99":458.9282701152256}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:26:42+02","p50":139.0369598888889,"p95":367.1590390966934,"p99":378.2780998659148}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:26:43+02","p50":150.3051636666667,"p95":352.1733739595711,"p99":370.4108028213062}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:26:44+02","p50":183.41215333333332,"p95":366.32920334733535,"p99":389.2896098950312}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:26:45+02","p50":119.20249233333334,"p95":342.0096206443362,"p99":361.2770049820531}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:26:46+02","p50":142.06372866666666,"p95":431.9168797226615,"p99":453.44524299833773}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:26:47+02","p50":166.04477699999998,"p95":494.36011340903895,"p99":528.4672684806739}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:26:48+02","p50":243.59032388888886,"p95":367.2906143898193,"p99":396.595394825233}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:26:49+02","p50":172.526195,"p95":356.46564384029017,"p99":398.40816489110944}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:26:50+02","p50":156.869940375,"p95":270.63714209433107,"p99":298.84650663945007}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:26:51+02","p50":184.6008940625,"p95":302.04423156519533,"p99":330.38419290871815}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:26:52+02","p50":136.397303,"p95":348.60078693502504,"p99":379.10041040987636}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:26:53+02","p50":208.5958850625,"p95":297.4139880978174,"p99":342.4072156456852}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:26:54+02","p50":167.1972085,"p95":339.2144632359161,"p99":357.6255409016114}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:26:55+02","p50":200.5229682222222,"p95":284.90606338766986,"p99":323.40569906101587}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:26:56+02","p50":210.01563066666668,"p95":338.4560215353322,"p99":359.1871781713526}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:26:57+02","p50":179.1637526875,"p95":335.4905377814721,"p99":400.75122673833704}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:26:58+02","p50":189.334459,"p95":289.19623811589145,"p99":322.82845078143214}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:26:59+02","p50":197.29853,"p95":254.67813269819308,"p99":286.1629846492476}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:27:00+02","p50":190.4065411875,"p95":284.5076546309163,"p99":331.9035186802502}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:27:01+02","p50":181.47052406249998,"p95":279.69881130609434,"p99":313.719389493555}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:27:02+02","p50":151.94579166666668,"p95":354.61666319084213,"p99":371.7136036657352}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:27:03+02","p50":166.281235,"p95":386.73531692902384,"p99":420.649655327538}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:27:04+02","p50":148.7445223125,"p95":359.46318257351254,"p99":376.27688912959866}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:27:05+02","p50":184.21798096875,"p95":277.70900109177575,"p99":296.8218056136756}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:27:06+02","p50":162.87833112500002,"p95":314.527402146051,"p99":339.50987744527913}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:27:07+02","p50":168.53892825,"p95":306.9428177077141,"p99":334.97422145559887}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:27:08+02","p50":192.90900859375,"p95":285.82773883297716,"p99":341.94940182323097}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:27:09+02","p50":194.55982674999998,"p95":246.99063782974244,"p99":280.4351090385742}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:27:10+02","p50":174.28953725,"p95":247.9744066636477,"p99":279.17424074895763}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:27:11+02","p50":193.928404125,"p95":253.76260802380764,"p99":284.1286567328668}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:27:12+02","p50":189.29576181250002,"p95":263.7371795551295,"p99":278.0667329535737}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:27:13+02","p50":182.957293,"p95":294.6086662118673,"p99":317.8614731482558}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:27:14+02","p50":203.753277875,"p95":281.0476899822991,"p99":316.78528554933285}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:27:15+02","p50":180.89417,"p95":234.47657934764052,"p99":263.55548825027086}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:27:16+02","p50":203.712888,"p95":323.5563643740608,"p99":364.48719365641284}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:27:17+02","p50":260.81837955555557,"p95":380.940437108066,"p99":410.5779094341323}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:27:18+02","p50":198.35689022222223,"p95":365.16697414600145,"p99":396.2802318050656}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:27:19+02","p50":174.5708140625,"p95":285.0906010951182,"p99":328.2365876712575}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:27:20+02","p50":181.09996243749998,"p95":276.39923109533675,"p99":315.6490332285991}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:27:21+02","p50":186.5207919375,"p95":270.1056047462885,"p99":321.6402500349741}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:27:22+02","p50":184.4209691875,"p95":246.49267304373691,"p99":269.07445565216636}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:27:23+02","p50":193.4719618888889,"p95":273.3790767838836,"p99":314.5124505561542}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:27:24+02","p50":186.08233903125,"p95":266.5522896144972,"p99":304.5487637141478}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:27:25+02","p50":172.57913712500002,"p95":253.0352081657167,"p99":262.7493767693462}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:27:26+02","p50":178.66033675,"p95":310.59731244291976,"p99":344.55369092844967}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:27:27+02","p50":188.10271955555552,"p95":270.31915788613077,"p99":356.1369984561987}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:27:28+02","p50":159.21614743749998,"p95":287.4595513562077,"p99":316.61637117335323}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:27:29+02","p50":182.15573256250002,"p95":246.1517650496707,"p99":281.4713636001492}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:27:30+02","p50":173.9616719375,"p95":238.7210853960729,"p99":269.03982139480013}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:27:31+02","p50":176.48987084375,"p95":269.9758206322698,"p99":310.2472167075813}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:27:32+02","p50":169.6217251875,"p95":334.4223009461551,"p99":381.64384746658703}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:27:33+02","p50":173.198981,"p95":295.5843631533242,"p99":322.93146719270993}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:27:34+02","p50":162.326174875,"p95":309.13122795573594,"p99":349.75819565876577}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:27:35+02","p50":195.27382344444445,"p95":309.3739377559875,"p99":349.0708461076501}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:27:36+02","p50":181.84496800000002,"p95":338.0018862877129,"p99":369.45319263241146}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:27:37+02","p50":179.28821343749996,"p95":307.6492964786487,"p99":347.9981545432668}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:27:38+02","p50":183.731094,"p95":314.62089908723397,"p99":346.75015494358547}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:27:39+02","p50":194.126655625,"p95":260.54428832613536,"p99":295.5964730170975}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:27:40+02","p50":177.77776921875,"p95":288.4279057673874,"p99":329.3559257648399}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:27:41+02","p50":177.5621809375,"p95":305.15425345334114,"p99":376.9097950054536}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:27:42+02","p50":211.76522111111112,"p95":357.18577438551364,"p99":405.6006579650128}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:27:43+02","p50":184.48686756249998,"p95":255.88449723447326,"p99":300.79711599596027}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:27:44+02","p50":184.867052125,"p95":257.1018845733412,"p99":351.9285792595954}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:27:45+02","p50":177.895700875,"p95":254.4358383973141,"p99":303.02956470006563}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:27:46+02","p50":172.86840555555557,"p95":338.72827478282875,"p99":394.7819297608509}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:27:47+02","p50":254.53267066666663,"p95":380.7524738069238,"p99":407.6565277409205}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:27:48+02","p50":234.29611466666668,"p95":359.137765516373,"p99":411.1764193704636}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:27:49+02","p50":225.3586666111111,"p95":343.9276526965337,"p99":428.88149968406293}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:27:50+02","p50":172.047994125,"p95":265.0060092756381,"p99":308.71778692960356}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:27:51+02","p50":176.81159381249998,"p95":266.21200436137303,"p99":303.04123896559526}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:27:52+02","p50":181.7798923125,"p95":285.306894141206,"p99":359.1930122079897}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:27:53+02","p50":178.96946090625002,"p95":271.8641297672259,"p99":308.8865002192125}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:27:54+02","p50":187.8267409375,"p95":294.422145442864,"p99":313.28276309497954}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:27:55+02","p50":181.83900615624998,"p95":282.85299389284353,"p99":414.0699751253739}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:27:56+02","p50":175.685083625,"p95":305.89563840037823,"p99":392.00051498221444}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:27:57+02","p50":181.27693875,"p95":283.32694764093947,"p99":371.5354144100413}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:27:58+02","p50":189.2878153125,"p95":303.2108947008197,"p99":338.6312464663994}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:27:59+02","p50":184.22582406249998,"p95":264.0269759026263,"p99":287.74213224001693}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:28:00+02","p50":174.34488290624998,"p95":277.9093237921628,"p99":306.53292767350075}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:28:01+02","p50":172.891099125,"p95":313.25551782171726,"p99":370.5475053258922}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:28:02+02","p50":173.47537109375,"p95":267.0225890905224,"p99":326.83412361638716}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:28:03+02","p50":180.10718253125,"p95":256.32498419858223,"p99":370.94424462468885}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:28:04+02","p50":185.4720615625,"p95":273.6668380397305,"p99":301.26450008935115}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:28:05+02","p50":183.60832521875,"p95":295.63168707886484,"p99":313.20009102869676}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:28:06+02","p50":188.33244009375,"p95":292.2807335878806,"p99":330.34957164277387}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:28:07+02","p50":175.0624861111111,"p95":282.2448508753879,"p99":371.1998730442781}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:28:08+02","p50":193.189431375,"p95":289.6453383929767,"p99":385.4908967383757}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:28:09+02","p50":177.4467085,"p95":270.8755065380138,"p99":305.5201443798356}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:28:10+02","p50":183.44905834374998,"p95":281.39212976917014,"p99":356.07378722806266}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:28:11+02","p50":180.18716506250001,"p95":297.3179076449388,"p99":381.84803981396294}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:28:12+02","p50":179.81955490625,"p95":273.71195399226775,"p99":395.0524973836839}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:28:13+02","p50":197.876847,"p95":304.39508602396387,"p99":410.64733257506373}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:28:14+02","p50":204.00739866666666,"p95":345.92283645437016,"p99":428.4246764596453}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:28:15+02","p50":186.27291156249998,"p95":317.68894601801037,"p99":372.11997879711726}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:28:16+02","p50":195.23936694444447,"p95":313.743992693481,"p99":384.4459505873584}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:28:17+02","p50":231.78869322222224,"p95":395.5796082287531,"p99":437.190137809721}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:28:18+02","p50":209.29811977777777,"p95":387.20049555944814,"p99":530.0324769997549}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:28:19+02","p50":178.382816375,"p95":283.33926716237164,"p99":372.69932674834064}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:28:20+02","p50":175.56871912500003,"p95":285.42811584741463,"p99":339.69615951487253}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:28:21+02","p50":182.872967125,"p95":284.1898924847056,"p99":361.01803691475106}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:28:22+02","p50":179.582868125,"p95":293.5345978478228,"p99":361.35547253136446}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:28:23+02","p50":178.9781014375,"p95":288.1660969617573,"p99":321.85255615265464}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:28:24+02","p50":179.80794075,"p95":274.03609601131484,"p99":309.22765644909475}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:28:25+02","p50":167.9727905,"p95":282.9057814949338,"p99":353.0839556728606}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:28:26+02","p50":168.2010754375,"p95":290.81359906178733,"p99":353.23707309792997}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:28:27+02","p50":178.9828831875,"p95":303.51690530413777,"p99":345.44209892959077}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:28:28+02","p50":179.24066525,"p95":283.17939501104735,"p99":322.1220969011841}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:28:29+02","p50":182.18125053125,"p95":278.39569861031396,"p99":328.2225490846484}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:28:30+02","p50":168.5792554375,"p95":280.10370993942666,"p99":339.67714086049267}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:28:31+02","p50":185.55199322222222,"p95":290.30181307020194,"p99":325.63648723553825}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:28:32+02","p50":183.94873153124996,"p95":291.04865962262016,"p99":364.07572688195205}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:28:33+02","p50":175.4366629375,"p95":319.27942885836194,"p99":451.61856201362133}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:28:34+02","p50":183.21118928125,"p95":304.4462418187314,"p99":392.6879494052341}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:28:35+02","p50":181.02044783333335,"p95":277.3016030049224,"p99":327.82674934820557}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:28:36+02","p50":191.68535487499997,"p95":309.53458136322985,"p99":385.6664749968862}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:28:37+02","p50":181.94860699999998,"p95":285.48172275946047,"p99":345.92694605340574}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:28:38+02","p50":177.33195643750003,"p95":280.75634856703374,"p99":320.6769525189438}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:28:39+02","p50":184.9492619375,"p95":285.2504567660826,"p99":358.27477145018486}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:28:40+02","p50":183.04699634375,"p95":273.8091363463696,"p99":336.4280685615406}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:28:41+02","p50":173.40073978125002,"p95":299.81940583663135,"p99":362.2512695265634}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:28:42+02","p50":192.40045434374997,"p95":288.41179595461534,"p99":392.5507122601442}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:28:43+02","p50":178.7790755,"p95":292.0326294655233,"p99":371.9958734281445}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:28:44+02","p50":191.67839765624998,"p95":300.24229647855213,"p99":381.395401676014}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:28:45+02","p50":187.67865516666666,"p95":275.18243718778587,"p99":315.0424383924927}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:28:46+02","p50":197.05481855555558,"p95":371.42625160446977,"p99":467.6787794722595}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:28:47+02","p50":240.87116833333334,"p95":391.572233295926,"p99":519.9037980597662}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:28:48+02","p50":235.87759300000002,"p95":409.31382142708264,"p99":456.40398974086264}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:28:49+02","p50":224.59980644444443,"p95":355.4837907680993,"p99":423.4581333588619}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:28:50+02","p50":203.3833103333333,"p95":359.28066545660334,"p99":411.95622871698185}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:28:51+02","p50":173.208011,"p95":307.059043303128,"p99":387.6606016126637}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:28:52+02","p50":192.56502483333335,"p95":339.2739105763652,"p99":392.4409275000506}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:28:53+02","p50":199.82310011111113,"p95":297.42499997561225,"p99":345.9870140928783}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:28:54+02","p50":188.8862,"p95":322.0846949810638,"p99":378.0526086578979}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:28:55+02","p50":180.85722199999998,"p95":288.82187167933654,"p99":343.70991487231447}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:28:56+02","p50":185.458497125,"p95":284.31283382909396,"p99":335.0110088045254}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:28:57+02","p50":173.795028,"p95":279.8780628272852,"p99":339.887694766423}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:28:58+02","p50":174.3704303125,"p95":284.08356637899925,"p99":340.7857195103054}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:28:59+02","p50":188.94265933333335,"p95":296.63605886391923,"p99":361.7627259493022}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:29:00+02","p50":180.5411283888889,"p95":361.76556212719953,"p99":524.4916457930836}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:29:01+02","p50":186.18883196875,"p95":274.26070087586845,"p99":342.69958166062355}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:29:02+02","p50":187.39715016666665,"p95":338.78737315855227,"p99":428.3477456998158}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:29:03+02","p50":180.85751015625,"p95":310.2928600412392,"p99":383.3905066506598}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:29:04+02","p50":191.18199811111108,"p95":319.8406756083638,"p99":389.0285095677936}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:29:05+02","p50":175.32893340625003,"p95":286.3750313720981,"p99":349.6345958194578}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:29:06+02","p50":183.2386705,"p95":286.19398265717626,"p99":345.68618192171385}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:29:07+02","p50":192.226055,"p95":297.8554963492075,"p99":387.10925219785975}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:29:08+02","p50":182.3250955,"p95":294.3484677958275,"p99":363.44833844165186}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:29:09+02","p50":183.173839125,"p95":283.8246264684485,"p99":396.3395074730677}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:29:10+02","p50":187.69375688888888,"p95":291.33825403262443,"p99":346.2634405867634}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:29:11+02","p50":190.76420799999997,"p95":298.0675107736571,"p99":369.94269045167687}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:29:12+02","p50":185.63644483333334,"p95":292.96604209052634,"p99":348.131268917387}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:29:13+02","p50":190.84302672222222,"p95":304.11442938740254,"p99":348.86566499752047}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:29:14+02","p50":199.46363427777774,"p95":310.30567044360345,"p99":378.27760563399363}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:29:15+02","p50":195.37079677777777,"p95":342.68533850806176,"p99":435.84184006633376}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:29:16+02","p50":227.98423083333333,"p95":371.98305139658953,"p99":452.75762399102973}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:29:17+02","p50":206.52422533333333,"p95":348.3823036163349,"p99":436.44131284798}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:29:18+02","p50":222.68072266666664,"p95":441.58051954525274,"p99":477.3179562999191}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:29:19+02","p50":209.60902766666666,"p95":427.4816595137386,"p99":490.64022842250347}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:29:20+02","p50":195.28943727777778,"p95":386.7631437744287,"p99":528.5339059680829}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:29:21+02","p50":177.19292612499999,"p95":296.9966029884962,"p99":351.0876559623608}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:29:22+02","p50":187.82212983333332,"p95":320.7605695487688,"p99":356.4046808374877}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:29:23+02","p50":200.91790177777776,"p95":325.3274707046466,"p99":382.9400430357437}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:29:24+02","p50":192.095791,"p95":305.1662079537307,"p99":368.2749778338752}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:29:25+02","p50":196.35827400000002,"p95":353.0490556321643,"p99":452.9795076071234}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:29:26+02","p50":201.5129793333333,"p95":313.126704331702,"p99":380.70473433486416}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:29:27+02","p50":191.16391925,"p95":322.38616867259213,"p99":383.38376561450195}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:29:28+02","p50":189.79404555555558,"p95":318.48205436716233,"p99":403.98749458813404}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:29:29+02","p50":202.74254166666665,"p95":294.48037518920864,"p99":337.5341222375772}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:29:30+02","p50":200.895257,"p95":328.09237700937075,"p99":385.4590321867692}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:29:31+02","p50":195.1853305,"p95":317.0557495971711,"p99":341.8695390660572}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:29:32+02","p50":185.53336349999998,"p95":313.77426831526185,"p99":349.27972168170163}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:29:33+02","p50":185.72398405555555,"p95":311.7927280365346,"p99":385.93252687193103}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:29:34+02","p50":196.32199388888887,"p95":313.3746737635519,"p99":397.15976756807805}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:29:35+02","p50":195.77611466666667,"p95":318.6987151699731,"p99":391.9965806837673}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:29:36+02","p50":205.0182351666667,"p95":339.42000078268126,"p99":413.6267370639348}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:29:37+02","p50":194.602442,"p95":290.1181957805015,"p99":318.45405940259076}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:29:38+02","p50":191.95687622222223,"p95":297.9188392002153,"p99":600.743555807945}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:29:39+02","p50":205.7433178888889,"p95":306.8241684115159,"p99":417.0625454678657}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:29:40+02","p50":203.63462788888887,"p95":308.77570929680644,"p99":403.33060484419275}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:29:41+02","p50":188.15050315625,"p95":341.6003651119405,"p99":389.12308656003614}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:29:42+02","p50":193.08033874999998,"p95":293.74547187106015,"p99":362.63748073884443}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:29:43+02","p50":205.56052744444446,"p95":347.56138723170784,"p99":410.31080260947607}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:29:44+02","p50":229.83359483333334,"p95":389.4037414396386,"p99":503.9098959869328}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:29:45+02","p50":196.40565455555557,"p95":340.76758979425955,"p99":415.5161074771428}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:29:46+02","p50":204.35345344444445,"p95":377.3570702471015,"p99":426.65224602999325}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:29:47+02","p50":225.82986166666666,"p95":396.9132368823718,"p99":479.39892228121164}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:29:48+02","p50":236.2227238888889,"p95":420.8084648244519,"p99":585.2791829253792}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:29:49+02","p50":240.21362611111113,"p95":414.5261726602379,"p99":504.4608610409298}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:29:50+02","p50":179.1758516875,"p95":311.6895966241861,"p99":416.06294661549447}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:29:51+02","p50":258.72182475,"p95":449.94842317300106,"p99":494.3432882395859}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:29:52+02","p50":237.80108577777779,"p95":442.9910982815889,"p99":543.2034437793208}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:29:53+02","p50":195.665854,"p95":310.5751608634274,"p99":357.82289057198335}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:29:54+02","p50":196.82895044444444,"p95":319.5017947959468,"p99":373.76577025522687}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:29:55+02","p50":200.800851,"p95":342.9027650422167,"p99":400.36503775353503}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:29:56+02","p50":182.81177111111114,"p95":330.3610955594633,"p99":375.26893745562074}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:29:57+02","p50":196.06131533333334,"p95":315.32270442228736,"p99":392.0299460193381}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:29:58+02","p50":194.06245544444445,"p95":364.67851196668533,"p99":415.71667057423065}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:29:59+02","p50":191.181879,"p95":322.84256489709725,"p99":388.43254446045876}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:30:00+02","p50":215.1314085,"p95":322.17242880714775,"p99":391.6174805017881}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:30:01+02","p50":195.36236833333336,"p95":320.0050312237211,"p99":356.24980559045576}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:30:02+02","p50":191.07694611111114,"p95":294.1568991743737,"p99":355.2651012445986}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:30:03+02","p50":194.71243922222223,"p95":320.18561128697405,"p99":380.92638734178587}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:30:04+02","p50":202.7954959444444,"p95":359.1465304480229,"p99":422.0879062050114}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:30:05+02","p50":204.89305344444446,"p95":328.98988321948696,"p99":443.6920679938033}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:30:06+02","p50":189.607352,"p95":308.7473520167389,"p99":362.31522194940186}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:30:07+02","p50":197.15711483333334,"p95":305.333828006937,"p99":402.04787657486725}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:30:08+02","p50":205.9379448888889,"p95":321.8561829285126,"p99":390.4513909561615}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:30:09+02","p50":200.68760466666666,"p95":297.40365169285434,"p99":377.10381864853736}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:30:10+02","p50":205.77781333333334,"p95":329.51096899003556,"p99":362.6896775728693}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:30:11+02","p50":194.04743865624997,"p95":308.2045224999106,"p99":370.28523751307915}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:30:12+02","p50":187.54421494444443,"p95":304.8126183170655,"p99":374.488265570702}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:30:13+02","p50":207.95467661111113,"p95":353.32314698984675,"p99":425.3301876491327}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:30:14+02","p50":204.24703350000001,"p95":335.6742798255293,"p99":385.04407642636494}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:30:15+02","p50":199.99838666666665,"p95":343.40152375740155,"p99":414.83322244828344}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:30:16+02","p50":215.66121344444446,"p95":389.6706946554674,"p99":483.6929230183053}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:30:17+02","p50":252.786444,"p95":392.7790993085299,"p99":468.75099819384195}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:30:18+02","p50":250.53043150000002,"p95":484.7992541378441,"p99":597.2657474953403}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:30:19+02","p50":211.7113725,"p95":363.0630051821494,"p99":439.0834621645174}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:30:20+02","p50":196.90273866666666,"p95":327.408693076395,"p99":409.49469937997054}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:30:21+02","p50":195.202466,"p95":332.31202677364854,"p99":392.09884465544843}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:30:22+02","p50":207.1027911111111,"p95":318.4890294689935,"p99":383.3717451876423}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:30:23+02","p50":194.8952988888889,"p95":336.54343823294545,"p99":379.69921173835564}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:30:24+02","p50":203.22173966666665,"p95":325.858825276369,"p99":375.65041018047714}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:30:25+02","p50":208.34684211111116,"p95":321.4584466321313,"p99":375.1542131777668}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:30:26+02","p50":208.5398351111111,"p95":333.55307064500647,"p99":414.4640012401466}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:30:27+02","p50":207.8972022222222,"p95":313.9749510282942,"p99":365.0789934250443}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:30:28+02","p50":207.131656,"p95":325.7565959555035,"p99":381.7642503274655}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:30:29+02","p50":199.86096222222224,"p95":352.385276092414,"p99":457.95726721027086}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:30:30+02","p50":203.449391,"p95":311.8331262542561,"p99":368.2060998664541}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:30:31+02","p50":201.434107,"p95":323.3725637632765,"p99":412.7679559767577}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:30:32+02","p50":195.92857949999998,"p95":331.18196339855,"p99":350.3222537354736}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:30:33+02","p50":209.50369350000003,"p95":340.38541028134443,"p99":403.06620992008783}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:30:34+02","p50":204.23807227777777,"p95":332.1988236080222,"p99":374.7636895254688}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:30:35+02","p50":208.2409052222222,"p95":314.55598781393724,"p99":361.87513171565223}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:30:36+02","p50":199.80649922222221,"p95":346.9903820318022,"p99":420.8994297507582}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:30:37+02","p50":202.18685877777776,"p95":344.1347232513341,"p99":385.2447149118266}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:30:38+02","p50":199.01452222222224,"p95":314.9894906094337,"p99":382.90295159783483}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:30:39+02","p50":197.9540558888889,"p95":298.5944473896455,"p99":361.7452332461603}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:30:40+02","p50":193.04107544444446,"p95":336.9445636221,"p99":439.2198271483893}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:30:41+02","p50":204.85928644444445,"p95":329.6950419662266,"p99":376.36683230102875}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:30:42+02","p50":202.08633,"p95":335.5709988721693,"p99":449.73176842874045}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:30:43+02","p50":204.33184033333336,"p95":341.681939853343,"p99":398.2243132224665}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:30:44+02","p50":216.2977343888889,"p95":338.53055366873934,"p99":403.5138837327957}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:30:45+02","p50":210.7147877222222,"p95":349.652755861713,"p99":411.895023365963}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:30:46+02","p50":234.20458283333335,"p95":420.9659186840737,"p99":465.7731906653318}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:30:47+02","p50":293.1767015,"p95":474.4596434355699,"p99":539.9835101593533}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:30:48+02","p50":264.5489315,"p95":488.1557553457098,"p99":638.3500447652283}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:30:49+02","p50":249.61188066666668,"p95":454.0098626703217,"p99":509.3023160842629}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:30:50+02","p50":210.4459478888889,"p95":310.4518044317118,"p99":393.9216711000547}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:30:51+02","p50":207.11965961111113,"p95":291.8754875246973,"p99":377.99808943608855}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:30:52+02","p50":203.21053294444445,"p95":319.5771978321209,"p99":370.6410599894028}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:30:53+02","p50":201.05646166666668,"p95":326.7955876665544,"p99":370.4877728239765}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:30:54+02","p50":211.28015066666663,"p95":344.0611608850927,"p99":390.8962635882895}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:30:55+02","p50":203.21056233333334,"p95":322.7953190818432,"p99":399.29807289031123}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:30:56+02","p50":194.33037533333334,"p95":326.5859764551394,"p99":410.8137740690634}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:30:57+02","p50":196.94781300000002,"p95":349.0178860734527,"p99":383.11062646083474}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:30:58+02","p50":204.45741644444445,"p95":327.84456876539326,"p99":381.39637339495084}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:30:59+02","p50":202.78864411111113,"p95":343.9740485821655,"p99":432.9572875947094}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:31:00+02","p50":205.95223866666666,"p95":335.41241242368073,"p99":387.34601652950363}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:31:01+02","p50":211.46082533333333,"p95":360.6423262300987,"p99":440.72140459379574}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:31:02+02","p50":197.3168747777778,"p95":329.8497866886737,"p99":374.03593301870967}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:31:03+02","p50":201.06334833333335,"p95":332.5075342838344,"p99":389.4220417752116}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:31:04+02","p50":202.27969394444446,"p95":353.7058692879639,"p99":457.29652491950606}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:31:05+02","p50":213.40677316666665,"p95":353.859651326473,"p99":433.6958857299137}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:31:06+02","p50":202.95112300000002,"p95":353.25017717877125,"p99":402.259600662328}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:31:07+02","p50":202.82333622222222,"p95":378.725918430907,"p99":453.3172039213264}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:31:08+02","p50":205.31515066666665,"p95":331.4173666829703,"p99":356.3674236977348}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:31:09+02","p50":199.81312433333335,"p95":332.1951719743659,"p99":372.910739085881}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:31:10+02","p50":212.85129116666667,"p95":329.4328011459733,"p99":419.0517945991316}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:31:11+02","p50":208.479479,"p95":336.37588835243065,"p99":443.67065954572126}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:31:12+02","p50":201.95191027777778,"p95":356.8024198754394,"p99":470.981126758841}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:31:13+02","p50":214.89258866666668,"p95":362.81409435534954,"p99":465.5712323945394}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:31:14+02","p50":214.901326,"p95":352.42506585925935,"p99":463.7408573168888}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:31:15+02","p50":211.19516933333333,"p95":349.87133545118365,"p99":430.80135017974516}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:31:16+02","p50":216.68483155555555,"p95":427.9566579194666,"p99":503.48659728901697}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:31:17+02","p50":271.41614725,"p95":441.15044961888987,"p99":512.1111401653092}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:31:18+02","p50":306.50639475,"p95":569.0558931402464,"p99":688.2556815319238}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:31:19+02","p50":254.88855933333332,"p95":476.27378646646827,"p99":597.141738387}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:31:20+02","p50":198.59727344444445,"p95":320.7389186073849,"p99":421.7967471746555}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:31:21+02","p50":206.5006558888889,"p95":342.536433827621,"p99":431.1285347640934}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:31:22+02","p50":217.01676633333332,"p95":314.94728825959515,"p99":373.5243463510456}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:31:23+02","p50":219.68843355555555,"p95":323.98048004122876,"p99":431.36936606665995}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:31:24+02","p50":208.99038966666663,"p95":384.34630963680394,"p99":467.5369988175614}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:31:25+02","p50":236.496023,"p95":384.22419352286676,"p99":427.69464637662}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:31:26+02","p50":206.78477172222222,"p95":338.1532447306398,"p99":391.5990644091611}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:31:27+02","p50":215.75523866666668,"p95":347.49228730711417,"p99":382.6851442212737}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:31:28+02","p50":225.08358466666667,"p95":360.465705259843,"p99":460.8789688364668}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:31:29+02","p50":218.99032866666667,"p95":374.5318866430343,"p99":449.60486558010365}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:31:30+02","p50":217.46841788888887,"p95":353.05215357354984,"p99":441.3271211384466}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:31:31+02","p50":209.3592971666667,"p95":333.3041163535206,"p99":430.5565161539979}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:31:32+02","p50":218.3212752222222,"p95":373.2606719534786,"p99":451.42541734322}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:31:33+02","p50":222.234025,"p95":397.0059874037057,"p99":793.392067381136}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:31:34+02","p50":217.33779122222222,"p95":373.3611613553316,"p99":464.0301309402456}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:31:35+02","p50":216.2161926111111,"p95":368.94490332340814,"p99":417.49581946537967}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:31:36+02","p50":213.15242594444445,"p95":375.2710341566048,"p99":455.5329708254852}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:31:37+02","p50":218.75487983333332,"p95":375.00843727908887,"p99":437.8240498166828}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:31:38+02","p50":202.4453447777778,"p95":343.02197042780926,"p99":403.13196793447014}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:31:39+02","p50":204.0129807222222,"p95":328.8427881609154,"p99":386.41570954950714}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:31:40+02","p50":211.9851253333333,"p95":334.1692656585097,"p99":380.46787664077857}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:31:41+02","p50":211.3932218888889,"p95":332.68919332244724,"p99":368.890662632473}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:31:42+02","p50":213.8660166666667,"p95":362.8466214451796,"p99":434.8911987468617}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:31:43+02","p50":217.3899536111111,"p95":346.0583209492775,"p99":458.01952588379385}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:31:44+02","p50":212.36583444444446,"p95":436.4938474673834,"p99":497.11531839578294}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:31:45+02","p50":223.41937505555555,"p95":369.2475102060952,"p99":395.1562643494711}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:31:46+02","p50":233.03533766666663,"p95":476.76958996716024,"p99":545.7290004457728}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:31:47+02","p50":247.136315,"p95":426.3290815725408,"p99":556.8003014434757}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:31:48+02","p50":267.027059375,"p95":441.90775357704644,"p99":500.54315025215794}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:31:49+02","p50":278.28536125000005,"p95":540.1597657444663,"p99":589.0782165439439}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:31:50+02","p50":255.21872077777778,"p95":459.0029476219091,"p99":563.1064010536222}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:31:51+02","p50":210.07645122222223,"p95":348.043192212614,"p99":433.9214216951654}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:31:52+02","p50":218.8208072777778,"p95":369.6221431672909,"p99":449.7027166413131}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:31:53+02","p50":227.08810566666668,"p95":367.7262492031312,"p99":440.15627429259945}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:31:54+02","p50":220.03400722222224,"p95":389.7109632064009,"p99":471.74365632854847}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:31:55+02","p50":217.39400366666666,"p95":369.9641647675552,"p99":412.9882192422233}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:31:56+02","p50":223.94330666666667,"p95":353.05924872795214,"p99":410.5398413230288}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:31:57+02","p50":203.63400333333334,"p95":345.79593741989925,"p99":389.4707619506445}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:31:58+02","p50":216.1525446666667,"p95":362.81613961797296,"p99":499.07007394086384}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:31:59+02","p50":224.3493111111111,"p95":356.99095574862196,"p99":454.9698276131661}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:32:00+02","p50":213.83111416666668,"p95":347.0250929388242,"p99":403.7637512958574}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:32:01+02","p50":222.49984299999997,"p95":339.04559366448564,"p99":396.24504114500377}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:32:02+02","p50":223.620714,"p95":335.90819822589737,"p99":410.91454987573576}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:32:03+02","p50":226.24937166666666,"p95":335.32358695026153,"p99":367.93180820174575}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:32:04+02","p50":218.08913655555557,"p95":435.8915802509346,"p99":489.28708586331175}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:32:05+02","p50":217.93237133333332,"p95":328.9693759232018,"p99":392.5351731268709}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:32:06+02","p50":222.4663835,"p95":406.6362453829376,"p99":500.8691196273427}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:32:07+02","p50":226.61218944444445,"p95":354.61878946562285,"p99":416.5205221994543}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:32:08+02","p50":222.77214600000002,"p95":374.46663888822206,"p99":450.8094589098823}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:32:09+02","p50":219.8126467777778,"p95":392.00014755658765,"p99":513.9537291904709}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:32:10+02","p50":224.03142144444442,"p95":352.97279644524684,"p99":435.4032528243065}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:32:11+02","p50":208.24536694444444,"p95":378.0713102377484,"p99":431.6813703477011}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:32:12+02","p50":209.8761542222222,"p95":369.5424052136536,"p99":451.2388182447891}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:32:13+02","p50":219.23786166666665,"p95":363.25913980668,"p99":451.12399298057295}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:32:14+02","p50":223.97180244444448,"p95":360.2883645230989,"p99":435.269975978215}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:32:15+02","p50":216.71007194444442,"p95":337.97084148830606,"p99":425.6973328247461}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:32:16+02","p50":233.9444117222222,"p95":425.433444166523,"p99":475.6684020970764}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:32:17+02","p50":231.26625750000002,"p95":434.71429186028485,"p99":520.2974088405571}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:32:18+02","p50":266.73170337500005,"p95":490.28090702285914,"p99":608.0986997067176}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:32:19+02","p50":324.23335299999997,"p95":515.6776790467342,"p99":609.0101567112661}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:32:20+02","p50":235.59441133333334,"p95":449.6935749005503,"p99":553.0957678274817}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:32:21+02","p50":218.5602545555556,"p95":372.14105988772064,"p99":430.6909131883452}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:32:22+02","p50":225.17245699999998,"p95":363.09618414827094,"p99":452.7774334426458}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:32:23+02","p50":210.15913133333333,"p95":373.7111297360679,"p99":461.5869807403679}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:32:24+02","p50":238.57727033333333,"p95":381.973308155411,"p99":477.66297981008245}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:32:25+02","p50":216.27082833333336,"p95":378.01305271718786,"p99":466.8391950271168}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:32:26+02","p50":242.8518598333333,"p95":359.6791281525676,"p99":428.9229971366134}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:32:27+02","p50":231.63725011111111,"p95":349.4298509186148,"p99":397.71019343576194}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:32:28+02","p50":226.732339,"p95":405.62277149829583,"p99":484.44164217784237}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:32:29+02","p50":228.32962716666668,"p95":361.53411524699186,"p99":414.4297816233115}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:32:30+02","p50":233.88495633333332,"p95":326.2271343763365,"p99":369.9302257789562}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:32:31+02","p50":226.84978122222222,"p95":360.97489976813193,"p99":463.8371062346909}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:32:32+02","p50":236.76296433333334,"p95":354.7024958877034,"p99":413.6180070585396}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:32:33+02","p50":224.9032802222222,"p95":375.59095100642446,"p99":419.86932278964713}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:32:34+02","p50":240.32421677777776,"p95":392.4488996499612,"p99":431.8389848001766}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:32:35+02","p50":230.42220444444447,"p95":378.56464276099706,"p99":505.89891194351765}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:32:36+02","p50":234.5692048888889,"p95":401.51542342983174,"p99":461.6675951622534}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:32:37+02","p50":227.309022,"p95":355.8756864912835,"p99":455.98563471131206}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:32:38+02","p50":224.007426,"p95":376.11202465679236,"p99":464.66399719672154}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:32:39+02","p50":228.86314066666668,"p95":356.3182932732327,"p99":403.7778434523003}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:32:40+02","p50":230.80453366666669,"p95":404.52124239434767,"p99":535.0275503524186}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:32:41+02","p50":218.62569861111112,"p95":360.09291733054164,"p99":439.7718299076786}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:32:42+02","p50":217.58812883333334,"p95":371.6389735550856,"p99":437.86308375483134}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:32:43+02","p50":233.06853166666664,"p95":399.19068332490605,"p99":479.7929705137174}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:32:44+02","p50":234.47938266666665,"p95":392.6643144576529,"p99":449.9907589968376}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:32:45+02","p50":232.08557983333333,"p95":382.68468427319885,"p99":488.2678124033661}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:32:46+02","p50":244.62408449999998,"p95":446.9696500811621,"p99":515.7216993169636}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:32:47+02","p50":255.19688,"p95":399.67054350503884,"p99":525.5660708170998}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:32:48+02","p50":285.83534387500004,"p95":487.26039093950254,"p99":544.4698768061297}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:32:49+02","p50":301.3556585,"p95":510.964604578166,"p99":635.2784504037246}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:32:50+02","p50":264.18729766666667,"p95":472.6264136536513,"p99":636.7521938510426}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:32:51+02","p50":227.35487550000002,"p95":377.1048645129325,"p99":458.53888587810513}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:32:52+02","p50":251.922576,"p95":447.350989489799,"p99":554.7686835216598}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:32:53+02","p50":263.13434474999997,"p95":530.8507723858329,"p99":721.0481155224113}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:32:54+02","p50":225.58388261111114,"p95":380.161137656028,"p99":468.34563998138475}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:32:55+02","p50":242.1133463333333,"p95":384.5402654924929,"p99":505.5946372716129}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:32:56+02","p50":232.08675422222223,"p95":384.213887055137,"p99":429.20222261100054}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:32:57+02","p50":223.09726055555555,"p95":404.80337730472684,"p99":511.3158119601204}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:32:58+02","p50":234.7839282777778,"p95":358.6693596630655,"p99":451.8204360131163}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:32:59+02","p50":232.87882766666667,"p95":425.7467114883852,"p99":487.68536225521467}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:33:00+02","p50":237.06443000000002,"p95":399.4266287533381,"p99":476.90989017555665}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:33:01+02","p50":248.4058461666667,"p95":396.54308326479105,"p99":474.22467991028736}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:33:02+02","p50":230.37859744444447,"p95":393.55309706906684,"p99":453.449538335906}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:33:03+02","p50":232.3341578333333,"p95":374.4735733048958,"p99":465.26900734739206}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:33:04+02","p50":264.482689875,"p95":410.27116344049404,"p99":469.7166974274752}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:33:05+02","p50":246.8850505,"p95":395.45494734583315,"p99":442.84180295915985}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:33:06+02","p50":230.89117733333333,"p95":353.4638644950161,"p99":389.47989380728535}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:33:07+02","p50":232.73668866666668,"p95":377.9326010976375,"p99":437.5844729626205}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:33:08+02","p50":235.79598088888892,"p95":416.665821860076,"p99":505.7929715148871}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:33:09+02","p50":233.68560366666665,"p95":373.1392250865016,"p99":418.8801051078911}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:33:10+02","p50":230.8417433333333,"p95":420.2855908858211,"p99":473.8649640798705}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:33:11+02","p50":223.24582850000002,"p95":393.8979325222867,"p99":476.9909705780468}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:33:12+02","p50":244.14954133333333,"p95":403.7375241260774,"p99":477.99835210016084}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:33:13+02","p50":246.41228644444445,"p95":361.8135735635612,"p99":402.8533168321066}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:33:14+02","p50":229.61298766666667,"p95":363.6033308717476,"p99":455.7843266998546}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:33:15+02","p50":244.89581355555552,"p95":400.5891745289134,"p99":456.673000659389}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:33:16+02","p50":253.74574712499998,"p95":398.5470624757168,"p99":436.57160895948317}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:33:17+02","p50":290.77609774999996,"p95":534.8039849119766,"p99":591.1491747143622}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:33:18+02","p50":287.630905,"p95":555.7053628811187,"p99":759.3057628708649}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:33:19+02","p50":260.139722,"p95":434.3915614445654,"p99":583.3201923909776}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:33:20+02","p50":243.41405466666666,"p95":369.17734439572047,"p99":455.30727302378466}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:33:21+02","p50":231.53368149999997,"p95":365.0432653110473,"p99":407.24337827021407}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:33:22+02","p50":242.505883,"p95":412.4999347425413,"p99":524.601658194849}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:33:23+02","p50":232.54534833333332,"p95":374.5556629026492,"p99":442.560841053041}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:33:24+02","p50":239.58688183333334,"p95":398.5046884272634,"p99":480.59723467273955}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:33:25+02","p50":247.6962111111111,"p95":387.1799167187638,"p99":464.32631229422833}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:33:26+02","p50":241.012872,"p95":368.4025947332088,"p99":447.3605011210189}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:33:27+02","p50":233.9766694444444,"p95":386.96228628693285,"p99":477.81864650684935}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:33:28+02","p50":235.542109,"p95":389.2316282820778,"p99":448.98848502568865}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:33:29+02","p50":245.02206544444445,"p95":393.23018982993966,"p99":459.86451168006994}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:33:30+02","p50":223.270794,"p95":355.406840181513,"p99":387.4610252394195}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:33:31+02","p50":239.80010344444443,"p95":394.09504108615874,"p99":494.7818513401794}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:33:32+02","p50":246.17028566666667,"p95":386.3242740246104,"p99":417.96801886121796}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:33:33+02","p50":247.03520083333333,"p95":390.1639436671072,"p99":459.0386687274175}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:33:34+02","p50":241.73285116666668,"p95":375.95783428324125,"p99":601.1601099873352}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:33:35+02","p50":255.778945125,"p95":419.4284088787367,"p99":545.8958604787023}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:33:36+02","p50":243.52722133333333,"p95":460.7516336206549,"p99":532.6451249426284}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:33:37+02","p50":244.04432766666665,"p95":388.6908727975695,"p99":422.97644730704116}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:33:38+02","p50":220.39450349999998,"p95":568.1284529653406,"p99":796.0735677623483}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:33:39+02","p50":246.928389,"p95":472.18584955523835,"p99":812.0410928928576}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:33:40+02","p50":227.95230333333333,"p95":370.18308553036735,"p99":465.310982619061}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:33:41+02","p50":227.30706838888887,"p95":399.8503266134108,"p99":454.9765421770229}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:33:42+02","p50":225.83264633333334,"p95":403.40213752225685,"p99":480.22409354110385}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:33:43+02","p50":243.92712533333335,"p95":377.5723761829224,"p99":432.4551040263641}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:33:44+02","p50":242.24754425,"p95":426.6340734641848,"p99":490.11104219123314}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:33:45+02","p50":257.8771213333333,"p95":407.0849706850665,"p99":560.2219665034747}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:33:46+02","p50":246.939657375,"p95":450.8215187908593,"p99":489.9558820302191}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:33:47+02","p50":290.98755325,"p95":497.5922189429208,"p99":551.4503608069765}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:33:48+02","p50":306.20941975,"p95":527.852640478988,"p99":641.807728294407}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:33:49+02","p50":292.03312174999996,"p95":518.6509610362588,"p99":583.629724640347}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:33:50+02","p50":252.55858233333333,"p95":437.9912643892018,"p99":561.7242892578134}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:33:51+02","p50":236.48447816666666,"p95":417.9918249314029,"p99":464.1891630546756}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:33:52+02","p50":248.784396,"p95":393.80324938535057,"p99":452.50154753660036}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:33:53+02","p50":239.0274007222222,"p95":373.2718030940702,"p99":433.1383861347618}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:33:54+02","p50":233.55698716666666,"p95":394.4068900888228,"p99":459.51528932271003}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:33:55+02","p50":248.1247286111111,"p95":422.1556352405434,"p99":461.31605151419063}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:33:56+02","p50":232.1230298888889,"p95":382.47190553403607,"p99":453.6062800068715}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:33:57+02","p50":245.4926118888889,"p95":439.4423141718396,"p99":481.5430297317166}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:33:58+02","p50":232.844899875,"p95":436.8947481744818,"p99":541.0581313336654}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:33:59+02","p50":266.1794130555556,"p95":384.41047095709104,"p99":434.79699793671415}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:34:00+02","p50":236.73512316666665,"p95":393.8240202940875,"p99":502.21442247265816}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:34:01+02","p50":231.73938011111113,"p95":375.5038520823298,"p99":490.72883558253477}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:34:02+02","p50":234.91059538888888,"p95":386.9306789033506,"p99":435.4778516190729}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:34:03+02","p50":250.90772750000002,"p95":418.5410255227275,"p99":484.13693815435596}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:34:04+02","p50":253.2914976666667,"p95":438.1993889250488,"p99":485.156488437088}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:34:05+02","p50":237.86275966666665,"p95":412.84416064722546,"p99":531.4925584635935}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:34:06+02","p50":241.4130301111111,"p95":384.6835320394778,"p99":466.4485572138905}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:34:07+02","p50":246.9962435,"p95":430.61991138997416,"p99":548.2566847912502}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:34:08+02","p50":252.04718633333334,"p95":415.8835463977967,"p99":506.81570539813833}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:34:09+02","p50":242.890341,"p95":415.68252231719333,"p99":459.3355598804331}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:34:10+02","p50":257.612865375,"p95":396.18150317915325,"p99":547.5985127782313}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:34:11+02","p50":241.42066133333336,"p95":384.0826483941463,"p99":475.19276299889685}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:34:12+02","p50":230.87129155555556,"p95":414.3070638313751,"p99":516.0454287644143}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:34:13+02","p50":257.038431375,"p95":395.0868904290263,"p99":520.8248982555652}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:34:14+02","p50":257.551038,"p95":392.02358257368945,"p99":446.8837309689145}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:34:15+02","p50":242.0045942222222,"p95":398.8031564911429,"p99":463.7348498225236}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:34:16+02","p50":263.97582162500004,"p95":470.7002272789613,"p99":596.6145870656634}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:34:17+02","p50":262.332346,"p95":475.3494265709779,"p99":658.0977595134699}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:34:18+02","p50":293.60957375,"p95":560.0902498358479,"p99":778.0352557814922}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:34:19+02","p50":269.52456137499996,"p95":487.2904865060484,"p99":606.1188282251981}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:34:20+02","p50":244.15401133333333,"p95":387.7079944607147,"p99":461.90501719173835}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:34:21+02","p50":242.50962461111112,"p95":407.23767418207547,"p99":450.81608595880124}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:34:22+02","p50":253.49955575,"p95":405.8076309284006,"p99":479.3382726868348}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:34:23+02","p50":274.9960685,"p95":428.6451952173658,"p99":535.5117178568265}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:34:24+02","p50":247.22204250000001,"p95":379.9838824606048,"p99":469.887887423934}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:34:25+02","p50":243.24941633333333,"p95":387.08692911201007,"p99":522.0354548741606}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:34:26+02","p50":256.83797849999996,"p95":435.508455010896,"p99":550.692052602832}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:34:27+02","p50":304.496019875,"p95":446.85923004462387,"p99":534.6948307420452}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:34:28+02","p50":257.481522125,"p95":445.0183366163345,"p99":538.8034372824739}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:34:29+02","p50":238.31051549999998,"p95":427.5492712721037,"p99":482.23812962444117}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:34:30+02","p50":247.265616125,"p95":450.8236542639635,"p99":507.2346711872134}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:34:31+02","p50":244.04404427777777,"p95":415.78895576956944,"p99":455.62288074609944}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:34:32+02","p50":244.34603562499998,"p95":433.7406606232805,"p99":522.8088149285817}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:34:33+02","p50":237.54384644444443,"p95":387.55320204594807,"p99":480.8667602889709}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:34:34+02","p50":258.3482255,"p95":397.8261588699484,"p99":489.93941125999265}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:34:35+02","p50":246.02377483333336,"p95":371.4415451996841,"p99":480.1354843534546}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:34:36+02","p50":246.23659855555556,"p95":398.50261767405425,"p99":550.2927599834298}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:34:37+02","p50":253.42273262499998,"p95":427.3329114535793,"p99":506.6480114395923}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:34:38+02","p50":246.60848294444443,"p95":395.13165166255476,"p99":462.32764865563485}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:34:39+02","p50":245.709733,"p95":422.92360313488217,"p99":558.3094882057648}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:34:40+02","p50":247.76838394444442,"p95":399.6298373327687,"p99":524.8320113544054}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:34:41+02","p50":231.99763283333334,"p95":396.4600884229193,"p99":468.1577781916847}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:34:42+02","p50":257.294788,"p95":412.975906559,"p99":477.2232233898587}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:34:43+02","p50":247.16260511111113,"p95":437.994821644352,"p99":616.0052474575958}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:34:44+02","p50":254.80792699999998,"p95":423.8831210833641,"p99":480.05263252854013}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:34:45+02","p50":250.00091700000002,"p95":444.52197677016926,"p99":514.3937235638236}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:34:46+02","p50":251.906231,"p95":532.076560612113,"p99":591.2071615058823}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:34:47+02","p50":262.25566625,"p95":464.3827456603379,"p99":609.9958891009597}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:34:48+02","p50":284.721244375,"p95":531.728444953583,"p99":640.5216567479637}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:34:49+02","p50":338.290922125,"p95":589.1906216921661,"p99":691.8721883108179}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:34:50+02","p50":320.7673855,"p95":515.5176785436153,"p99":632.0176443304506}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:34:51+02","p50":270.6670875,"p95":439.75460668313167,"p99":534.7910984565306}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:34:52+02","p50":247.18855844444445,"p95":414.9266299242001,"p99":449.263458878932}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:34:53+02","p50":252.72339575,"p95":461.86140911139967,"p99":506.16770534266567}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:34:54+02","p50":244.2179183333333,"p95":403.0196703677668,"p99":476.57731770493507}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:34:55+02","p50":254.95947675,"p95":428.0794374766185,"p99":480.5137230691939}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:34:56+02","p50":239.356133,"p95":417.97537687333147,"p99":575.2845023377519}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:34:57+02","p50":252.05772174999998,"p95":445.2623466346369,"p99":681.62964024632}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:34:58+02","p50":251.509722,"p95":406.68917443704447,"p99":488.2903484970379}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:34:59+02","p50":269.968730125,"p95":462.955597932823,"p99":517.51872375743}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:35:00+02","p50":271.291179625,"p95":408.15900895877274,"p99":468.0575583917348}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:35:01+02","p50":260.6519265,"p95":408.2398278800601,"p99":486.9506066071282}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:35:02+02","p50":249.08638399999998,"p95":402.8122317941038,"p99":425.2730846728821}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:35:03+02","p50":240.812377,"p95":387.9569571473711,"p99":487.22251631128}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:35:04+02","p50":255.25976799999998,"p95":389.6793000755879,"p99":468.7659897720661}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:35:05+02","p50":250.62307099999998,"p95":410.2871182714511,"p99":497.14616014005856}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:35:06+02","p50":246.606369,"p95":439.569528999143,"p99":540.7300664410473}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:35:07+02","p50":250.63701862499997,"p95":439.88441669453726,"p99":495.77547170241456}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:35:08+02","p50":250.99789633333333,"p95":384.7336377220992,"p99":460.1935018582964}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:35:09+02","p50":255.797218625,"p95":382.21282533441325,"p99":437.8775799206567}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:35:10+02","p50":260.1340413333333,"p95":462.7808593478576,"p99":544.0607919301765}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:35:11+02","p50":255.126604,"p95":437.71638282977256,"p99":509.5122343222742}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:35:12+02","p50":254.16688375,"p95":469.7708445514171,"p99":596.4816492280378}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:35:13+02","p50":255.737143,"p95":470.07591599670377,"p99":555.2960708614478}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:35:14+02","p50":251.8899373333333,"p95":401.569131154243,"p99":439.75819112880185}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:35:15+02","p50":252.79897012500004,"p95":382.81354998726715,"p99":466.82819427431633}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:35:16+02","p50":258.368427625,"p95":478.7574591130687,"p99":550.6379207093775}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:35:17+02","p50":269.95823225000004,"p95":508.84886587641716,"p99":581.3479067453766}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:35:18+02","p50":319.929041875,"p95":623.0270191611027,"p99":744.8653670339498}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:35:19+02","p50":320.10733,"p95":578.515384161166,"p99":802.1934291480673}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:35:20+02","p50":254.131393125,"p95":429.48825539788913,"p99":520.7591032995374}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:35:21+02","p50":263.7090465,"p95":464.01810903141245,"p99":543.333945996479}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:35:22+02","p50":265.382925,"p95":444.47017766590363,"p99":499.2965828000684}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:35:23+02","p50":262.59565216666664,"p95":445.50939130510335,"p99":505.75773296498255}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:35:24+02","p50":255.2947625,"p95":447.27120964095207,"p99":522.845152996975}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:35:25+02","p50":266.405042,"p95":430.21109693399,"p99":527.1653022898302}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:35:26+02","p50":257.5719805,"p95":425.71357152069425,"p99":475.99853154133797}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:35:27+02","p50":258.42057625,"p95":451.2807406978629,"p99":519.8596373823572}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:35:28+02","p50":263.750858875,"p95":413.1412213547309,"p99":501.4988705335603}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:35:29+02","p50":261.3700325,"p95":459.44244356627036,"p99":545.8286856570244}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:35:30+02","p50":259.16923124999994,"p95":394.999657350305,"p99":474.09946806015637}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:35:31+02","p50":260.73462974999995,"p95":402.0080005847919,"p99":480.7240314587393}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:35:32+02","p50":247.95554925,"p95":443.7761597778764,"p99":568.4041524165335}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:35:33+02","p50":273.68144574999997,"p95":418.8592395719484,"p99":445.7310229781904}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:35:34+02","p50":271.24839637499997,"p95":415.6710178238098,"p99":474.1082294845698}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:35:35+02","p50":271.00155233333334,"p95":464.5032853302579,"p99":564.5255068388929}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:35:36+02","p50":270.057148,"p95":429.41141660477575,"p99":489.42654765290973}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:35:37+02","p50":279.260726875,"p95":473.10020290691523,"p99":517.9944379044347}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:35:38+02","p50":259.893681,"p95":475.40265398043715,"p99":516.7147075678382}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:35:39+02","p50":241.16473766666664,"p95":401.106435010652,"p99":486.8981021822858}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:35:40+02","p50":264.733921,"p95":438.0484842146253,"p99":464.77467181394763}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:35:41+02","p50":253.783912,"p95":390.63252882195974,"p99":489.4342463915157}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:35:42+02","p50":248.65580075000003,"p95":394.7830699308772,"p99":504.30505801576044}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:35:43+02","p50":295.8947515,"p95":528.4741479112611,"p99":617.1819563232746}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:35:44+02","p50":284.473048125,"p95":454.27704807440466,"p99":527.7279918741376}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:35:45+02","p50":249.64267566666663,"p95":436.2224412011648,"p99":497.9041390465624}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:35:46+02","p50":270.368884,"p95":517.5866726913666,"p99":571.0812230935578}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:35:47+02","p50":314.154541625,"p95":554.5483804525378,"p99":697.1422834208388}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:35:48+02","p50":330.89126825,"p95":566.703627649873,"p99":684.9721087983279}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:35:49+02","p50":327.57335762499997,"p95":602.7722687312624,"p99":716.9294886656778}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:35:50+02","p50":267.41302675,"p95":488.1631517109785,"p99":536.3552451085968}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:35:51+02","p50":268.711671625,"p95":393.6989624800772,"p99":445.9623439904365}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:35:52+02","p50":258.78581877777776,"p95":406.33913889978095,"p99":484.40464599093536}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:35:53+02","p50":255.63745699999998,"p95":416.6679169158888,"p99":486.7021321201973}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:35:54+02","p50":253.28816762499997,"p95":424.1734274351335,"p99":505.7028296532907}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:35:55+02","p50":261.75738533333333,"p95":439.2747612949972,"p99":470.0899060178227}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:35:56+02","p50":254.1992995,"p95":438.37555251568114,"p99":504.4832310588999}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:35:57+02","p50":271.47276312500003,"p95":398.8732578309153,"p99":530.2756969538971}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:35:58+02","p50":265.66792,"p95":443.20559678939367,"p99":533.555235527616}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:35:59+02","p50":266.24755,"p95":441.10460870551316,"p99":464.42411755782365}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:36:00+02","p50":247.36104799999998,"p95":424.21022066528747,"p99":525.1935427021211}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:36:01+02","p50":308.055564,"p95":547.7484162400189,"p99":754.7858044313889}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:36:02+02","p50":297.69547950000003,"p95":470.56979023146846,"p99":550.9621809520797}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:36:03+02","p50":273.73311424999997,"p95":391.54654427341166,"p99":467.90297496202754}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:36:04+02","p50":279.3649785,"p95":440.68600435456733,"p99":601.7719794049216}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:36:05+02","p50":260.66567775,"p95":415.4385318268583,"p99":498.1817095879083}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:36:06+02","p50":244.37345525,"p95":419.7056218058458,"p99":501.14800422493624}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:36:07+02","p50":265.3689635,"p95":454.1364713933226,"p99":499.5201940366664}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:36:08+02","p50":282.50187800000003,"p95":476.86135509599245,"p99":544.7850116231317}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:36:09+02","p50":258.74034725,"p95":421.0682923532711,"p99":549.8008959157944}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:36:10+02","p50":250.69572349999999,"p95":479.76608016887957,"p99":563.4040498106232}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:36:11+02","p50":265.02781425,"p95":418.6808485449755,"p99":479.3354043331985}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:36:12+02","p50":287.13989349999997,"p95":417.95321612878786,"p99":493.66395250505826}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:36:13+02","p50":265.90458675,"p95":429.3374977181099,"p99":540.6860666412163}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:36:14+02","p50":267.35199725,"p95":462.12737333626444,"p99":518.3628795830326}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:36:15+02","p50":270.26036425,"p95":425.64670490455245,"p99":492.6650219917927}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:36:16+02","p50":267.952325,"p95":457.5532252522965,"p99":663.9122299074402}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:36:17+02","p50":274.56824300000005,"p95":495.6762548673733,"p99":628.0609482288104}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:36:18+02","p50":342.886177375,"p95":563.050607191615,"p99":773.9684411976912}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:36:19+02","p50":311.35315875000003,"p95":618.8045532641,"p99":676.2139193190757}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:36:20+02","p50":258.68767425,"p95":434.68928235208654,"p99":527.718808758278}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:36:21+02","p50":264.1264005,"p95":432.4782100336876,"p99":496.36295273950196}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:36:22+02","p50":284.1780665,"p95":443.2347498551246,"p99":518.5824665314648}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:36:23+02","p50":281.483592875,"p95":419.242792908064,"p99":490.7284401088722}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:36:24+02","p50":272.64804025,"p95":416.24347988170456,"p99":472.5741446776733}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:36:25+02","p50":280.950089125,"p95":426.0589155050207,"p99":566.034674371351}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:36:26+02","p50":251.6446375,"p95":396.3501515190627,"p99":495.75921183264967}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:36:27+02","p50":271.6921563333333,"p95":474.90097030203987,"p99":519.697609997715}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:36:28+02","p50":266.936282875,"p95":442.30553009358806,"p99":509.80180702896024}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:36:29+02","p50":260.72009649999995,"p95":455.6411531602584,"p99":524.1748620250402}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:36:30+02","p50":264.44158949999996,"p95":404.5745870633924,"p99":491.91618528585434}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:36:31+02","p50":269.81570650000003,"p95":419.10434405671907,"p99":488.8180724874473}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:36:32+02","p50":260.94705250000004,"p95":454.2397197066002,"p99":510.50326549041364}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:36:33+02","p50":274.13322,"p95":439.01972856016585,"p99":482.5345382981339}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:36:34+02","p50":254.45976655555557,"p95":408.23470826867293,"p99":513.4013226754913}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:36:35+02","p50":267.26025725,"p95":464.78726307099674,"p99":540.4649847713737}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:36:36+02","p50":263.451475,"p95":439.8489624030719,"p99":528.4659436606503}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:36:37+02","p50":273.681362,"p95":413.1530599547871,"p99":475.3887482464139}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:36:38+02","p50":262.42256325,"p95":455.4396345786124,"p99":580.7996150937603}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:36:39+02","p50":257.9202376666667,"p95":437.1068716493566,"p99":486.99432259580396}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:36:40+02","p50":258.56056875,"p95":433.37247478405715,"p99":555.8528159423342}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:36:41+02","p50":264.389997,"p95":438.2981008643098,"p99":547.3491188609486}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:36:42+02","p50":276.49165312499997,"p95":430.88875801244956,"p99":485.78888851100425}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:36:43+02","p50":280.752932125,"p95":446.3170856669779,"p99":531.9637418814247}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:36:44+02","p50":282.32115875,"p95":484.47938888746734,"p99":602.8593631017479}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:36:45+02","p50":263.9226543333333,"p95":445.83545400655476,"p99":601.6209245441456}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:36:46+02","p50":291.68244425,"p95":497.7035354248284,"p99":679.3175245265692}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:36:47+02","p50":312.990089125,"p95":486.8317967950743,"p99":505.312604825201}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:36:48+02","p50":329.2914065,"p95":551.4049445607922,"p99":680.2606640784159}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:36:49+02","p50":339.16039,"p95":593.5391209677692,"p99":646.3333434984534}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:36:50+02","p50":276.62490599999995,"p95":558.3999952588991,"p99":600.6332137365399}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:36:51+02","p50":250.670752,"p95":409.05569041803824,"p99":533.5402634348641}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:36:52+02","p50":272.426645375,"p95":571.8618228908813,"p99":675.4606616498987}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:36:53+02","p50":267.51233625,"p95":422.6446393228599,"p99":531.6386685849448}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:36:54+02","p50":291.44980050000004,"p95":454.23875224000966,"p99":493.3651159520278}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:36:55+02","p50":274.67588487499995,"p95":441.77680315136587,"p99":540.8149981984445}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:36:56+02","p50":267.484300125,"p95":458.66542662605326,"p99":599.1730176369266}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:36:57+02","p50":262.621663,"p95":493.3482629882154,"p99":551.2015649434089}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:36:58+02","p50":281.55920000000003,"p95":445.7603713362245,"p99":496.409015278841}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:36:59+02","p50":278.549184,"p95":460.764101054255,"p99":532.8517307298717}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:37:00+02","p50":257.17045125,"p95":455.6271916242397,"p99":546.5039346143418}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:37:01+02","p50":271.48472825,"p95":427.6455074257917,"p99":533.3504298355958}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:37:02+02","p50":277.759975125,"p95":447.2331365456114,"p99":500.83704259983995}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:37:03+02","p50":275.49270187499997,"p95":405.2120361834234,"p99":479.98993033915207}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:37:04+02","p50":281.118100625,"p95":510.59994351032475,"p99":613.3954287719366}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:37:05+02","p50":271.03451125000004,"p95":475.3531457091773,"p99":520.8336449481296}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:37:06+02","p50":263.57687075,"p95":455.16643932775304,"p99":563.6589416264572}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:37:07+02","p50":290.43707325,"p95":472.7174846760864,"p99":560.3053150923405}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:37:08+02","p50":286.53179824999995,"p95":453.03868992775585,"p99":570.84568331039}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:37:09+02","p50":272.77136099999996,"p95":423.93573187629914,"p99":505.7369940903995}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:37:10+02","p50":280.7256185,"p95":463.9208990961666,"p99":520.664117339571}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:37:11+02","p50":275.613451,"p95":467.25170113098903,"p99":517.4252537377548}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:37:12+02","p50":248.20316125,"p95":454.2489062066117,"p99":547.0151625803135}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:37:13+02","p50":271.80180625,"p95":494.06151770305723,"p99":552.4011694815388}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:37:14+02","p50":290.31772524999997,"p95":521.2814904891643,"p99":599.5541574068633}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:37:15+02","p50":278.0536915,"p95":448.11469075231,"p99":561.162434063208}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:37:16+02","p50":313.3462895,"p95":619.5034486344389,"p99":702.6555097997456}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:37:17+02","p50":324.854590625,"p95":579.6841767499525,"p99":750.3554944991853}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:37:18+02","p50":343.512835,"p95":627.1252977442357,"p99":678.921302243248}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:37:19+02","p50":347.22449875,"p95":641.9308238686408,"p99":726.5546061571674}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:37:20+02","p50":272.753773,"p95":456.93943756108297,"p99":545.0433867799397}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:37:21+02","p50":287.305234,"p95":482.93960357461015,"p99":527.3300204129798}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:37:22+02","p50":291.84331899999995,"p95":459.0205791523464,"p99":636.5230674784857}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:37:23+02","p50":272.12157075000005,"p95":421.2066189373023,"p99":493.5883063361807}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:37:24+02","p50":275.994239875,"p95":472.16574953047,"p99":525.6466085327122}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:37:25+02","p50":282.369423,"p95":400.93488896156776,"p99":504.1080421592221}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:37:26+02","p50":279.14357125000004,"p95":435.26581782687947,"p99":490.03172875637534}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:37:27+02","p50":278.045139,"p95":437.25978724796465,"p99":525.4959172818178}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:37:28+02","p50":280.65772525,"p95":491.2533090767055,"p99":595.1768315263453}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:37:29+02","p50":294.69866025,"p95":483.799874165232,"p99":563.3968928920141}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:37:30+02","p50":280.822649,"p95":428.03382758572747,"p99":464.46219796906854}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:37:31+02","p50":275.64132825,"p95":446.15891757751916,"p99":507.87054447163007}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:37:32+02","p50":269.5587385,"p95":430.96664200211677,"p99":669.9642561105194}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:37:33+02","p50":279.663449125,"p95":438.6697980329419,"p99":520.7928192715287}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:37:34+02","p50":289.255497,"p95":499.0736842099783,"p99":528.0643940131398}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:37:35+02","p50":322.683688,"p95":558.1222331505617,"p99":758.9454577473765}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:37:36+02","p50":295.59765225,"p95":624.6576934951707,"p99":714.7788967299953}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:37:37+02","p50":261.1673405,"p95":435.0841642586198,"p99":480.58191710951616}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:37:38+02","p50":276.519091,"p95":478.10282919305,"p99":546.4376535161714}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:37:39+02","p50":267.70598937499994,"p95":442.08557746862556,"p99":526.6979442846423}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:37:40+02","p50":278.05879112499997,"p95":459.12646396856746,"p99":562.7794775632623}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:37:41+02","p50":268.1261875,"p95":480.67277178579377,"p99":526.5582824514285}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:37:42+02","p50":265.645195625,"p95":445.7156446246986,"p99":535.8363589735332}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:37:43+02","p50":281.2477945,"p95":449.5835557982304,"p99":515.9120453508282}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:37:44+02","p50":275.003384375,"p95":514.9206479877232,"p99":594.6198214315237}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:37:45+02","p50":270.0799885,"p95":454.97443280930185,"p99":524.0589443352184}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:37:46+02","p50":295.65093625000003,"p95":492.2391704338405,"p99":534.6472352812043}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:37:47+02","p50":274.08601450000003,"p95":442.37287061170724,"p99":586.8032892054558}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:37:48+02","p50":374.62705025,"p95":614.2161898959117,"p99":718.6417095456095}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:37:49+02","p50":384.14167225,"p95":646.9470666795946,"p99":709.5936829303309}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:37:50+02","p50":307.04816675,"p95":580.9987341745286,"p99":657.1635294630051}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:37:51+02","p50":264.51252750000003,"p95":405.9214381572137,"p99":485.2863852560253}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:37:52+02","p50":288.733014625,"p95":436.28569338081195,"p99":499.4273233914039}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:37:53+02","p50":292.59008775,"p95":444.8126474846289,"p99":559.6404062197221}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:37:54+02","p50":265.45254250000005,"p95":444.6517892553885,"p99":521.8394724585886}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:37:55+02","p50":276.110035,"p95":439.42550232806536,"p99":510.11889809915357}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:37:56+02","p50":269.87857225,"p95":408.3670248879873,"p99":486.81305099183794}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:37:57+02","p50":274.41081124999994,"p95":439.4784172497368,"p99":487.9560559297927}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:37:58+02","p50":272.0299305,"p95":483.57158045087084,"p99":564.1303695331484}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:37:59+02","p50":294.5038185,"p95":440.11316648074296,"p99":542.4094273271429}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:38:00+02","p50":294.01674149999997,"p95":473.3424680057596,"p99":583.2569641643033}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:38:01+02","p50":295.58635225,"p95":455.02840676387115,"p99":521.1404888461552}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:38:02+02","p50":303.802941125,"p95":451.2165169351802,"p99":511.0916807555754}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:38:03+02","p50":286.47689275000005,"p95":479.99778429102633,"p99":567.6860556975946}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:38:04+02","p50":283.83911750000004,"p95":466.3207577074134,"p99":560.1058221574355}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:38:05+02","p50":296.90665624999997,"p95":433.7798157127762,"p99":575.7135354705439}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:38:06+02","p50":270.504869,"p95":483.0571026430154,"p99":556.9352219606143}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:38:07+02","p50":272.069074625,"p95":457.8241872004946,"p99":521.6077715802451}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:38:08+02","p50":286.7748935,"p95":514.7952817698724,"p99":680.5006881987887}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:38:09+02","p50":279.39346625,"p95":460.0734857394414,"p99":528.4559263084283}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:38:10+02","p50":292.86708899999996,"p95":448.0299446216389,"p99":584.2344832331862}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:38:11+02","p50":302.73789650000003,"p95":513.5038546738189,"p99":615.2702772098422}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:38:12+02","p50":278.865654875,"p95":441.64487278980135,"p99":511.978653663738}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:38:13+02","p50":284.784116125,"p95":457.41361447205577,"p99":533.5180968186232}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:38:14+02","p50":275.32788000000005,"p95":458.3162684359131,"p99":556.7599309027099}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:38:15+02","p50":280.3231195,"p95":445.0551637334065,"p99":582.3993081457844}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:38:16+02","p50":301.43420525,"p95":530.2744790843103,"p99":640.1278627152329}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:38:17+02","p50":364.4796525,"p95":578.1403585420021,"p99":603.9809204001413}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:38:18+02","p50":372.29258325,"p95":704.1429628307343,"p99":774.5320789924278}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:38:19+02","p50":360.78676,"p95":610.7048109200895,"p99":736.5407505721814}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:38:20+02","p50":314.049614,"p95":613.2237474310322,"p99":762.3890784030613}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:38:21+02","p50":282.7069275,"p95":475.41358644634107,"p99":535.6855813607083}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:38:22+02","p50":278.298527,"p95":424.1571578855939,"p99":457.30417616728636}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:38:23+02","p50":284.983961125,"p95":454.2553327711692,"p99":562.3987605130988}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:38:24+02","p50":323.141547375,"p95":521.9283128823034,"p99":546.8692202282961}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:38:25+02","p50":304.54330775,"p95":471.48780923213565,"p99":526.0347048071785}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:38:26+02","p50":292.379567,"p95":471.84134074121897,"p99":520.0252552257032}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:38:27+02","p50":299.916064,"p95":462.18045082281066,"p99":526.3080132471199}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:38:28+02","p50":294.7068685,"p95":454.14565936854245,"p99":562.630079958622}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:38:29+02","p50":282.64017725,"p95":462.15185026630354,"p99":524.8979255207462}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:38:30+02","p50":298.0146975,"p95":453.1234867757131,"p99":501.8353833705611}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:38:31+02","p50":285.517019375,"p95":474.32733556678096,"p99":607.0127939875014}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:38:32+02","p50":316.60584700000004,"p95":436.0858531417457,"p99":564.3585470786509}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:38:33+02","p50":276.259842625,"p95":432.824868558483,"p99":535.3429050789955}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:38:34+02","p50":288.039445875,"p95":457.41460762805696,"p99":526.8870811959748}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:38:35+02","p50":304.45541275,"p95":433.01749780563546,"p99":529.5591907882152}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:38:36+02","p50":290.926061375,"p95":469.41253246797226,"p99":567.7128335475919}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:38:37+02","p50":289.969403,"p95":460.42590394879903,"p99":514.1239983952655}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:38:38+02","p50":291.094792375,"p95":470.95182639752295,"p99":565.8061726239405}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:38:39+02","p50":293.434761,"p95":447.9659659214381,"p99":530.5451185746626}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:38:40+02","p50":282.9847665,"p95":504.0198940813208,"p99":627.297626658765}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:38:41+02","p50":281.394672,"p95":508.38208074886796,"p99":705.631788158936}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:38:42+02","p50":283.62036,"p95":456.1519186505919,"p99":527.400940347641}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:38:43+02","p50":296.907472,"p95":470.9682533727894,"p99":577.844560855461}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:38:44+02","p50":309.17395650000003,"p95":489.76920722176743,"p99":538.4678099386673}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:38:45+02","p50":284.83044674999996,"p95":511.9471709640339,"p99":638.2307501275484}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:38:46+02","p50":324.24727399999995,"p95":514.4866818407793,"p99":653.1810312161302}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:38:47+02","p50":292.47771575,"p95":460.39423205013384,"p99":495.5933168066232}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:38:48+02","p50":361.477539,"p95":702.9946877955523,"p99":863.3035797861188}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:38:49+02","p50":347.163134,"p95":722.0058020472488,"p99":865.4673349531555}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:38:50+02","p50":330.57022037499996,"p95":703.469716732043,"p99":835.1083508007011}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:38:51+02","p50":284.812362125,"p95":496.3238719336624,"p99":557.5309844423194}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:38:52+02","p50":299.3001195,"p95":471.77067894943366,"p99":533.9160366149049}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:38:53+02","p50":292.06249325,"p95":457.5616723272429,"p99":534.5011652540618}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:38:54+02","p50":294.1137175,"p95":482.43980061224505,"p99":584.8751213080201}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:38:55+02","p50":305.75706875,"p95":467.83652219410607,"p99":503.4050448995853}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:38:56+02","p50":283.83761525,"p95":447.8575097564939,"p99":507.14210297411324}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:38:57+02","p50":282.3411345,"p95":459.26619966412596,"p99":536.5603360367336}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:38:58+02","p50":295.537558625,"p95":458.08335727020454,"p99":522.9812536097942}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:38:59+02","p50":294.9872435,"p95":437.5866677396869,"p99":520.258768646837}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:39:00+02","p50":303.54293925,"p95":446.6625401886058,"p99":533.2071036481738}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:39:01+02","p50":289.70852399999995,"p95":507.7120067326195,"p99":537.6489259911264}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:39:02+02","p50":297.58836175,"p95":448.46979712468783,"p99":483.5936854827015}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:39:03+02","p50":286.61284487499995,"p95":467.224118861816,"p99":645.2514354724825}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:39:04+02","p50":296.334842125,"p95":473.1896239246984,"p99":604.3109553382325}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:39:05+02","p50":282.262784625,"p95":500.17499748450706,"p99":592.0214839451652}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:39:06+02","p50":299.263343125,"p95":450.785239057786,"p99":504.1242538318785}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:39:07+02","p50":306.72614887500004,"p95":503.68155193797935,"p99":639.3609644677372}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:39:08+02","p50":388.798586375,"p95":595.2196786744255,"p99":670.3795597631452}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:39:09+02","p50":313.7536325,"p95":499.0897153087473,"p99":622.1571526460896}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:39:10+02","p50":292.8736505,"p95":475.12504706742715,"p99":615.6898882612362}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:39:11+02","p50":287.72190224999997,"p95":470.03727405909814,"p99":521.5958924355562}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:39:12+02","p50":294.019459,"p95":421.21563264722636,"p99":481.86109114819715}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:39:13+02","p50":291.92281475,"p95":495.63964192564924,"p99":578.7918773636441}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:39:14+02","p50":278.930134125,"p95":523.824413685155,"p99":609.3752381564026}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:39:15+02","p50":279.509395625,"p95":470.93278338554813,"p99":641.5617679089451}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:39:16+02","p50":335.45656575,"p95":523.4278485714962,"p99":637.7313868931882}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:39:17+02","p50":313.2136555,"p95":500.6458104606156,"p99":643.7488418568447}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:39:18+02","p50":411.448944,"p95":659.0515835216574,"p99":692.7601942719373}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:39:19+02","p50":323.019279,"p95":633.9363027837603,"p99":831.3469673552698}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:39:20+02","p50":291.513243,"p95":434.4102281724081,"p99":498.8238963223419}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:39:21+02","p50":278.19095050000004,"p95":499.5846070849953,"p99":589.5221163984547}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:39:22+02","p50":275.92579249999994,"p95":478.9669818728042,"p99":539.2421971128979}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:39:23+02","p50":281.12221275,"p95":489.3655113104573,"p99":532.2930449638558}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:39:24+02","p50":287.6263035,"p95":473.5327284865749,"p99":527.5643380773906}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:39:25+02","p50":285.844445,"p95":476.5885602038138,"p99":596.8086480897759}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:39:26+02","p50":276.659429,"p95":483.23488431402643,"p99":553.1055383279042}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:39:27+02","p50":292.8026555,"p95":437.6286556834668,"p99":562.5607714173016}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:39:28+02","p50":289.2635385,"p95":468.5254924632532,"p99":495.9771608658907}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:39:29+02","p50":289.365346125,"p95":486.97665285133246,"p99":566.1052013866753}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:39:30+02","p50":269.47354875,"p95":444.0457907483142,"p99":522.7294281529155}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:39:31+02","p50":316.68122225,"p95":492.2670159791336,"p99":599.7145179515344}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:39:32+02","p50":278.94372112499997,"p95":494.4792191545054,"p99":543.801619090188}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:39:33+02","p50":290.986736875,"p95":449.1992817243483,"p99":516.4929860083512}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:39:34+02","p50":301.91486,"p95":445.82400281291024,"p99":522.3193553207207}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:39:35+02","p50":299.5034515,"p95":476.16217412559655,"p99":523.689367272183}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:39:36+02","p50":308.474279625,"p95":499.1238591518811,"p99":589.2124832457032}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:39:37+02","p50":288.27218625,"p95":442.9043426567468,"p99":485.648556105432}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:39:38+02","p50":302.109373,"p95":486.71924880357255,"p99":530.5639405975213}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:39:39+02","p50":282.0861065,"p95":455.0125066332538,"p99":529.4192212293186}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:39:40+02","p50":299.5464095,"p95":492.77070150722494,"p99":573.5600539197319}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:39:41+02","p50":292.971954,"p95":484.06476146641495,"p99":570.5580891688219}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:39:42+02","p50":311.76950550000004,"p95":439.17795159897804,"p99":511.91554700526433}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:39:43+02","p50":297.67031762500005,"p95":458.6554031959731,"p99":538.8457759646666}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:39:44+02","p50":296.6030455,"p95":496.20232048572876,"p99":567.816771563633}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:39:45+02","p50":310.283842875,"p95":463.5535547799375,"p99":530.6328237687378}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:39:46+02","p50":341.0196415,"p95":526.4154583080785,"p99":612.0404280131067}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:39:47+02","p50":309.946251625,"p95":471.0679451358792,"p99":506.83550367945577}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:39:48+02","p50":408.892771,"p95":612.1856057077842,"p99":753.6998635432913}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:39:49+02","p50":375.8138045,"p95":658.1641567728309,"p99":878.4842786047897}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:39:50+02","p50":324.0173475,"p95":670.7768270410317,"p99":862.9408644143576}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:39:51+02","p50":297.91106625,"p95":495.0000450699778,"p99":574.8004294732361}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:39:52+02","p50":305.74394675,"p95":439.0423736705117,"p99":519.7990007475805}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:39:53+02","p50":312.20286575,"p95":449.5555775160666,"p99":504.76247497174694}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:39:54+02","p50":294.8860905,"p95":501.68926737833505,"p99":564.6064657294828}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:39:55+02","p50":310.47490425,"p95":492.3080736637569,"p99":545.570634742797}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:39:56+02","p50":300.711862,"p95":448.523817298154,"p99":547.4589189224587}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:39:57+02","p50":285.361698875,"p95":426.74876815992815,"p99":544.319046544888}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:39:58+02","p50":284.739319,"p95":455.2454605485891,"p99":505.4754467727804}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:39:59+02","p50":303.65188,"p95":428.5325339329714,"p99":492.497304639669}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:40:00+02","p50":313.89264749999995,"p95":465.8342206242869,"p99":525.137867824834}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:40:01+02","p50":305.00714975,"p95":486.31293282384075,"p99":553.5618117954156}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:40:02+02","p50":302.10090625,"p95":441.6590150125473,"p99":504.86669759222843}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:40:03+02","p50":314.53987037499996,"p95":522.940527460797,"p99":617.7475047280867}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:40:04+02","p50":327.82806875,"p95":459.01161000943864,"p99":554.916915678565}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:40:05+02","p50":295.581257625,"p95":509.83931894021174,"p99":571.2683574212518}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:40:06+02","p50":298.40544324999996,"p95":446.2547257418549,"p99":511.4758402779052}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:40:07+02","p50":288.66548800000004,"p95":471.302635162027,"p99":517.955740443758}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:40:08+02","p50":306.576096,"p95":462.53459915288596,"p99":579.3859896210632}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:40:09+02","p50":303.828008,"p95":488.7604992657844,"p99":614.1007459973416}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:40:10+02","p50":297.930899875,"p95":437.89464585127587,"p99":598.5329666812044}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:40:11+02","p50":296.85393650000003,"p95":460.11099566975935,"p99":553.600011615623}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:40:12+02","p50":292.716156125,"p95":487.6084974831651,"p99":533.3088350333521}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:40:13+02","p50":309.138551,"p95":521.042852219364,"p99":577.1700352138172}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:40:14+02","p50":323.65315875,"p95":458.5195512760284,"p99":495.41461625261974}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:40:15+02","p50":299.84042124999996,"p95":474.80214611007483,"p99":560.7688814104268}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:40:16+02","p50":323.49443275,"p95":526.8861012911833,"p99":609.8603744477457}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:40:17+02","p50":353.7937945,"p95":606.0710592379974,"p99":737.9176218182773}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:40:18+02","p50":399.14815600000003,"p95":635.1591814926941,"p99":802.3005647703493}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:40:19+02","p50":356.54469274999997,"p95":634.793854917179,"p99":699.0339292064401}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:40:20+02","p50":297.6499905,"p95":486.16026861651227,"p99":669.111151363678}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:40:21+02","p50":300.78904025,"p95":472.0395010926522,"p99":587.9472115621968}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:40:22+02","p50":299.28269774999995,"p95":535.6570898374453,"p99":647.6625711216443}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:40:23+02","p50":306.28313349999996,"p95":491.04700728000256,"p99":572.0145518629989}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:40:24+02","p50":297.6418995,"p95":444.35196201995467,"p99":560.9877170154724}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:40:25+02","p50":306.4591745,"p95":448.0956320624989,"p99":503.77743094976614}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:40:26+02","p50":307.7786835,"p95":502.2503078251931,"p99":584.5106716337091}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:40:27+02","p50":307.887486875,"p95":489.97355275501536,"p99":561.8933482998109}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:40:28+02","p50":296.552464,"p95":492.39513455147284,"p99":575.1306444471833}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:40:29+02","p50":304.83733962499997,"p95":469.97202904058065,"p99":540.6657381220723}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:40:30+02","p50":297.06478787500004,"p95":508.65188531755604,"p99":660.0527787129575}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:40:31+02","p50":332.04694025000003,"p95":486.4280021698817,"p99":596.3989477609406}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:40:32+02","p50":294.55854,"p95":488.3112866523909,"p99":522.8180566459126}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:40:33+02","p50":297.38822175,"p95":530.4617622704887,"p99":577.5223368259461}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:40:34+02","p50":307.84212249999996,"p95":478.93599936241674,"p99":492.83389687346266}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:40:35+02","p50":303.214311375,"p95":500.39238821960964,"p99":573.1897440080343}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:40:36+02","p50":308.248709625,"p95":512.7063705251439,"p99":549.0270344249051}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:40:37+02","p50":319.3844325,"p95":454.44361620906085,"p99":479.01550769645667}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:40:38+02","p50":309.10320075,"p95":460.1251080360451,"p99":538.551745776207}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:40:39+02","p50":309.55519400000003,"p95":524.1800892570419,"p99":595.7085964175634}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:40:40+02","p50":308.913221125,"p95":482.71049663483933,"p99":512.1885086505567}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:40:41+02","p50":304.82266549999997,"p95":523.9982732200986,"p99":658.6942209644012}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:40:42+02","p50":309.01759749999997,"p95":482.5976166110712,"p99":539.3997563117584}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:40:43+02","p50":306.00587162499994,"p95":503.6285551948016,"p99":590.7161858025829}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:40:44+02","p50":373.21234125,"p95":607.9167637566718,"p99":724.1480606345654}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:40:45+02","p50":315.50000550000004,"p95":580.1693204951665,"p99":664.2680299818707}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:40:46+02","p50":345.46472274999996,"p95":572.9140044497159,"p99":629.8515626681366}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:40:47+02","p50":335.88026549999995,"p95":645.0842873623425,"p99":818.6681102975144}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:40:48+02","p50":356.88882087499996,"p95":596.6027891325829,"p99":768.5156516738011}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:40:49+02","p50":380.8114975,"p95":560.6301455998201,"p99":623.0124684732971}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:40:50+02","p50":391.33717,"p95":709.434419001908,"p99":791.0037296776726}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:40:51+02","p50":300.99483925000004,"p95":548.6453005997798,"p99":809.444665581554}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:40:52+02","p50":311.69538,"p95":529.7192219903793,"p99":655.2737100052185}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:40:53+02","p50":321.52313200000003,"p95":519.3159208392374,"p99":664.8758497415256}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:40:54+02","p50":297.32266925,"p95":513.4077142465279,"p99":594.4405908971854}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:40:55+02","p50":299.3202725,"p95":474.77076873040846,"p99":558.6933262662697}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:40:56+02","p50":316.05039324999996,"p95":507.46733855047296,"p99":651.8103151627821}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:40:57+02","p50":291.718621875,"p95":484.4998487995039,"p99":567.7552356438165}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:40:58+02","p50":305.58951912500004,"p95":524.6972196583592,"p99":584.3777284589825}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:40:59+02","p50":312.85441375000005,"p95":455.7878425775516,"p99":557.08559745153}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:41:00+02","p50":313.15196349999997,"p95":526.8877192496118,"p99":620.5821026369466}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:41:01+02","p50":329.83175575,"p95":512.0655578011895,"p99":615.1726061007461}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:41:02+02","p50":308.7025895,"p95":486.3438130353107,"p99":553.8112277463046}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:41:03+02","p50":311.64479700000004,"p95":505.6599191714747,"p99":638.9092862032937}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:41:04+02","p50":344.98547987499995,"p95":524.8518197825521,"p99":625.855102418458}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:41:05+02","p50":338.65577825,"p95":511.973442047192,"p99":619.0625848790436}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:41:06+02","p50":327.2365685,"p95":532.8625153432456,"p99":624.5471610542664}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:41:07+02","p50":348.76077075,"p95":551.1159969202657,"p99":711.7168278118286}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:41:08+02","p50":323.111841,"p95":469.02636642978285,"p99":531.8206015098572}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:41:09+02","p50":304.785499125,"p95":466.42241931136516,"p99":538.9242017375652}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:41:10+02","p50":283.58626275,"p95":555.9782627755885,"p99":685.2704095387935}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:41:11+02","p50":295.619051,"p95":472.8689771328274,"p99":590.1278786215696}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:41:12+02","p50":298.2463655,"p95":493.30942877872025,"p99":594.2155398430829}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:41:13+02","p50":297.318209375,"p95":486.9838799174398,"p99":563.6399148381553}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:41:14+02","p50":333.85226675,"p95":484.20084607643696,"p99":519.5828539244461}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:41:15+02","p50":303.30325650000003,"p95":487.6196518884309,"p99":643.6907188242235}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:41:16+02","p50":347.79974225,"p95":577.1969705619294,"p99":631.6691500440741}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:41:17+02","p50":318.02333775,"p95":456.20864617872104,"p99":541.6072872126236}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:41:18+02","p50":378.190816,"p95":619.9570623480663,"p99":685.133048977197}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:41:19+02","p50":419.6789895,"p95":645.2635026278042,"p99":811.0415010838723}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:41:20+02","p50":330.65390875,"p95":600.125367850236,"p99":667.8689065744419}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:41:21+02","p50":295.71968325,"p95":535.2554408393985,"p99":638.9240327161928}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:41:22+02","p50":310.5602875,"p95":490.85670221864615,"p99":594.962364588532}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:41:23+02","p50":307.4521145,"p95":511.08938277472186,"p99":572.715615489068}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:41:24+02","p50":293.3413045,"p95":534.2967215712881,"p99":651.0559156541538}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:41:25+02","p50":318.8742605,"p95":499.16811679777,"p99":624.0814159430123}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:41:26+02","p50":315.435583125,"p95":494.4907585545782,"p99":583.2955762381969}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:41:27+02","p50":297.67247999999995,"p95":477.88036936586894,"p99":556.6162288245912}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:41:28+02","p50":324.94858925,"p95":517.539524587432,"p99":583.698400930685}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:41:29+02","p50":323.78296075,"p95":517.4314450198067,"p99":612.6719742975177}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:41:30+02","p50":345.84636675,"p95":566.0622260694211,"p99":694.4989783399391}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:41:31+02","p50":338.43930275,"p95":540.3704727656424,"p99":647.513178981524}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:41:32+02","p50":315.49332525,"p95":528.8310196370462,"p99":596.4071484533229}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:41:33+02","p50":320.25945975,"p95":500.6513166889908,"p99":557.4933477221203}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:41:34+02","p50":324.80278575,"p95":486.9775078169079,"p99":574.1470536415329}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:41:35+02","p50":293.403228375,"p95":515.0906835788616,"p99":597.309944086947}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:41:36+02","p50":285.350331375,"p95":487.1931897997964,"p99":553.8792018729384}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:41:37+02","p50":309.449604,"p95":515.0356002773511,"p99":549.5819831853958}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:41:38+02","p50":306.8011365,"p95":445.0728730842158,"p99":482.2868831689527}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:41:39+02","p50":318.655461,"p95":470.20219925142516,"p99":511.0270397748909}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:41:40+02","p50":310.432168,"p95":514.7370236046787,"p99":551.264536050488}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:41:41+02","p50":301.745571,"p95":506.9501718176842,"p99":616.5461299818114}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:41:42+02","p50":318.40706175,"p95":529.5567735206054,"p99":590.7033028432536}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:41:43+02","p50":312.969415375,"p95":503.445947123498,"p99":631.2859423757071}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:41:44+02","p50":322.49209924999997,"p95":522.9672847355642,"p99":647.7381676844205}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:41:45+02","p50":315.16366874999994,"p95":537.4620491556883,"p99":719.7507715904802}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:41:46+02","p50":323.68091525,"p95":532.9382123968081,"p99":562.3216370969019}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:41:47+02","p50":346.68994337500004,"p95":584.8454665628483,"p99":744.5853204668167}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:41:48+02","p50":386.425078,"p95":784.7564577795508,"p99":888.3893254928813}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:41:49+02","p50":403.349631,"p95":608.9356836375546,"p99":743.9230100532303}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:41:50+02","p50":351.079016,"p95":642.5210489758571,"p99":770.4706950490704}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:41:51+02","p50":302.89721,"p95":496.89994052726433,"p99":525.067742970924}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:41:52+02","p50":322.574773625,"p95":514.2510070523305,"p99":652.4840814783556}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:41:53+02","p50":337.6034145,"p95":467.2438498147283,"p99":528.269330598588}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:41:54+02","p50":318.559557625,"p95":457.28120237560074,"p99":574.6904906653624}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:41:55+02","p50":303.70893375,"p95":549.8322872879548,"p99":628.297075645979}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:41:56+02","p50":311.8553755,"p95":454.46989390990416,"p99":569.136391301688}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:41:57+02","p50":320.513333,"p95":515.0829108275394,"p99":575.8809617809062}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:41:58+02","p50":326.8126315,"p95":470.78010936192743,"p99":505.53433785863115}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:41:59+02","p50":312.40408525,"p95":509.3496198810025,"p99":633.0293030510302}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:42:00+02","p50":323.30809425,"p95":484.1047928735289,"p99":616.0170981187821}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:42:01+02","p50":336.66561175000004,"p95":509.280181792151,"p99":561.3578553932957}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:42:02+02","p50":316.45768325,"p95":528.5621460147039,"p99":586.6443262122751}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:42:03+02","p50":322.49223674999996,"p95":514.7743987051591,"p99":579.0984374669387}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:42:04+02","p50":323.482571375,"p95":548.3249511110104,"p99":605.0175722270608}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:42:05+02","p50":299.96462725000004,"p95":490.0013817073374,"p99":580.3914451528213}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:42:06+02","p50":334.218077,"p95":503.7678927114556,"p99":565.7659849495211}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:42:07+02","p50":302.65574200000003,"p95":534.2894679439306,"p99":573.4125380651896}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:42:08+02","p50":329.6782675,"p95":507.9696080238378,"p99":586.1021280679006}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:42:09+02","p50":297.7699725,"p95":510.34910517110586,"p99":579.692875771944}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:42:10+02","p50":316.15952975,"p95":487.4539040628368,"p99":557.6102038947726}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:42:11+02","p50":341.07417675,"p95":541.9830433137706,"p99":603.3066621361351}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:42:12+02","p50":330.58212175,"p95":474.92338477380133,"p99":572.1673446765309}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:42:13+02","p50":331.43706525,"p95":542.5854363821318,"p99":600.7207056061845}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:42:14+02","p50":308.8410665,"p95":517.7612954149139,"p99":618.871893006094}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:42:15+02","p50":309.9929055,"p95":541.2307780276724,"p99":671.8582049587717}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:42:16+02","p50":338.404977625,"p95":545.8150527256497,"p99":614.7325576396663}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:42:17+02","p50":348.29992825,"p95":507.2583596070805,"p99":565.1056478395903}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:42:18+02","p50":420.027892,"p95":625.1432876739595,"p99":776.8548635069598}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:42:19+02","p50":416.82770400000004,"p95":654.5306135843845,"p99":740.6236414797163}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:42:20+02","p50":330.53776112500003,"p95":581.1028939177265,"p99":653.1910018763497}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:42:21+02","p50":369.1526625,"p95":596.8604122721432,"p99":700.4589788419195}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:42:22+02","p50":345.38576450000005,"p95":535.6493107181265,"p99":606.224765239501}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:42:23+02","p50":309.920321,"p95":487.89770756828415,"p99":603.1206214935105}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:42:24+02","p50":324.47928575000003,"p95":508.20170975121056,"p99":666.8904040487128}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:42:25+02","p50":349.18265337500003,"p95":479.5071976475417,"p99":505.8866622045445}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:42:26+02","p50":330.54887562499994,"p95":450.7554980978613,"p99":489.7120110605159}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:42:27+02","p50":343.730371875,"p95":493.7631183877071,"p99":555.04478314539}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:42:28+02","p50":337.99702012500006,"p95":510.4028049346196,"p99":629.3045883811124}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:42:29+02","p50":339.243583,"p95":537.2696932108249,"p99":587.8088385430145}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:42:30+02","p50":315.843077,"p95":470.7670652149177,"p99":571.4889124666214}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:42:31+02","p50":350.03122237499997,"p95":522.2099784214005,"p99":589.0143701041894}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:42:32+02","p50":326.924258375,"p95":509.3178959582295,"p99":564.8230784894492}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:42:33+02","p50":316.035223,"p95":522.067869312798,"p99":592.4449681284857}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:42:34+02","p50":321.66661899999997,"p95":694.2694242244008,"p99":804.5431043952805}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:42:35+02","p50":308.22360349999997,"p95":514.3069604655512,"p99":595.8185673666338}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:42:36+02","p50":330.61765,"p95":533.8932072991197,"p99":647.332133349102}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:42:37+02","p50":350.51526249999995,"p95":511.5237501097942,"p99":582.5538779358175}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:42:38+02","p50":305.87990575000003,"p95":473.1666702928462,"p99":582.0423681405124}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:42:39+02","p50":325.0968145,"p95":524.3519463490534,"p99":548.2684647625961}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:42:40+02","p50":313.85357999999997,"p95":508.5812824218935,"p99":600.2153679181232}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:42:41+02","p50":309.533420125,"p95":488.89638473662484,"p99":630.9097560657021}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:42:42+02","p50":336.22685449999994,"p95":468.71740999862425,"p99":548.4321008022217}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:42:43+02","p50":323.4750845,"p95":510.9212389961422,"p99":587.6782060044155}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:42:44+02","p50":316.91954875,"p95":528.9628326702832,"p99":679.8281462052851}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:42:45+02","p50":308.04483700000003,"p95":482.90176352845396,"p99":564.8573764919057}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:42:46+02","p50":356.40417025,"p95":491.9380949132031,"p99":581.6491912525258}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:42:47+02","p50":330.98668475,"p95":563.7884276829878,"p99":661.9602661078023}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:42:48+02","p50":418.76185,"p95":715.4987217449041,"p99":841.5310871147861}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:42:49+02","p50":445.64414,"p95":770.0418201068027,"p99":906.1397841380792}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:42:50+02","p50":379.91148699999997,"p95":650.2271047559034,"p99":757.348003292974}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:42:51+02","p50":334.6581065,"p95":549.8081047190972,"p99":651.5491413281812}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:42:52+02","p50":332.70984875,"p95":521.9127705617464,"p99":598.6372660789403}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:42:53+02","p50":333.0945265,"p95":529.5789459835682,"p99":618.296226774334}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:42:54+02","p50":324.59780025,"p95":556.8821676770366,"p99":688.2358825259287}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:42:55+02","p50":340.29491575000003,"p95":509.0787693624846,"p99":617.5421699590264}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:42:56+02","p50":327.57181325,"p95":545.5759211806184,"p99":595.4499519324455}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:42:57+02","p50":387.08001774999997,"p95":589.778122813007,"p99":710.7826134849253}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:42:58+02","p50":349.606655875,"p95":564.0891520012915,"p99":664.5532803822157}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:42:59+02","p50":331.03212662500005,"p95":489.86048063179726,"p99":558.7947575490754}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:43:00+02","p50":321.2447595,"p95":468.42394510229684,"p99":534.6001989460735}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:43:01+02","p50":328.57371725,"p95":505.8325801870539,"p99":588.3147489771061}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:43:02+02","p50":324.208074,"p95":539.6714646978564,"p99":633.4924001450978}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:43:03+02","p50":328.3624995,"p95":504.8007488905276,"p99":577.6911164909186}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:43:04+02","p50":342.49406200000004,"p95":556.0203131826987,"p99":658.8486427469214}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:43:05+02","p50":326.3104575,"p95":550.9121454604501,"p99":649.8969540989366}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:43:06+02","p50":344.91071875,"p95":509.65463865328184,"p99":568.3216354299843}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:43:07+02","p50":318.521784875,"p95":511.2065145025327,"p99":591.3988925365489}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:43:08+02","p50":312.596453375,"p95":507.95963390923663,"p99":590.5658738199346}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:43:09+02","p50":338.92585825000003,"p95":533.7577879749393,"p99":576.3004978027649}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:43:10+02","p50":336.47683625,"p95":562.383729663859,"p99":599.8609079838258}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:43:11+02","p50":327.113832625,"p95":517.527400115967,"p99":578.5028320652666}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:43:12+02","p50":327.3078845,"p95":481.74701875358863,"p99":522.8601290657116}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:43:13+02","p50":354.90136375,"p95":572.4460333586337,"p99":640.893094044488}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:43:14+02","p50":330.89827925,"p95":564.9274914541887,"p99":652.2808648848171}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:43:15+02","p50":347.57848225,"p95":540.4815923843818,"p99":651.1613865393839}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:43:16+02","p50":340.834904,"p95":569.4828697374805,"p99":692.0377972832339}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:43:17+02","p50":358.112802,"p95":626.5573374826258,"p99":648.2736702828009}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:43:18+02","p50":440.42444950000004,"p95":650.9863843936222,"p99":782.4971167358399}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:43:19+02","p50":413.64021149999996,"p95":747.1482057006587,"p99":917.8008568120785}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:43:20+02","p50":318.55529125,"p95":587.0976869933598,"p99":650.5547205419783}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:43:21+02","p50":327.19383750000003,"p95":467.3512144772246,"p99":513.6789322461367}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:43:22+02","p50":331.60111799999993,"p95":483.6494313948022,"p99":542.9437926127504}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:43:23+02","p50":340.103423625,"p95":547.2012210528068,"p99":583.4993008570337}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:43:24+02","p50":349.34509975,"p95":508.2810611659913,"p99":542.3164453763083}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:43:25+02","p50":342.29770725000003,"p95":541.0706412453068,"p99":578.5239365249439}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:43:26+02","p50":327.26271862500005,"p95":519.156284634458,"p99":591.2775145309606}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:43:27+02","p50":321.31289200000003,"p95":550.4422337083914,"p99":727.8195606816721}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:43:28+02","p50":335.58940375,"p95":526.3931017083185,"p99":624.7924379633237}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:43:29+02","p50":346.36725175000004,"p95":539.4061676581839,"p99":625.2804323605232}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:43:30+02","p50":336.19097550000004,"p95":551.3631965409066,"p99":634.8267021399197}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:43:31+02","p50":342.493862125,"p95":542.1946994635236,"p99":608.1275651678474}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:43:32+02","p50":343.356515,"p95":543.9354308191412,"p99":585.5432913191397}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:43:33+02","p50":322.77636037499997,"p95":546.2677592612876,"p99":604.0025447862323}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:43:34+02","p50":346.44739775,"p95":573.6535181222276,"p99":644.8819079760972}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:43:35+02","p50":334.605818,"p95":558.9967375222055,"p99":641.1136848389373}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:43:36+02","p50":314.65849875000004,"p95":486.85783002175623,"p99":506.13328598721125}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:43:37+02","p50":313.059019375,"p95":513.1926294755925,"p99":574.490563398947}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:43:38+02","p50":356.152302,"p95":629.7512799691596,"p99":712.5001832770805}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:43:39+02","p50":350.729707625,"p95":490.0353317711048,"p99":541.0986881269837}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:43:40+02","p50":321.2167895,"p95":564.3305929822646,"p99":632.0842256974883}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:43:41+02","p50":315.42502625,"p95":463.52723249894916,"p99":598.9443610534997}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:43:42+02","p50":322.43123075,"p95":522.2043013386689,"p99":626.181152649395}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:43:43+02","p50":327.319726,"p95":511.11810669396976,"p99":564.4221545269623}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:43:44+02","p50":350.81368125,"p95":546.9990960221184,"p99":657.1244605480948}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:43:45+02","p50":337.696921,"p95":529.0149504819892,"p99":613.8735210176029}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:43:46+02","p50":357.2893755,"p95":563.1612284092183,"p99":657.4573452784075}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:43:47+02","p50":339.6019,"p95":582.7254937191329,"p99":629.5898235055847}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:43:48+02","p50":402.135537,"p95":711.9188947588846,"p99":892.3447114332228}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:43:49+02","p50":441.45477600000004,"p95":733.9246420231929,"p99":935.1766653473816}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:43:50+02","p50":406.062491,"p95":760.0104188199185,"p99":834.7430915743317}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:43:51+02","p50":320.406151875,"p95":543.5071282592572,"p99":576.6741897093175}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:43:52+02","p50":349.87627725,"p95":514.9634185391819,"p99":609.1754714589429}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:43:53+02","p50":377.359371625,"p95":598.4808368906163,"p99":675.7592210577669}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:43:54+02","p50":336.052020625,"p95":569.2151762421723,"p99":683.7743174141632}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:43:55+02","p50":330.08588075,"p95":529.6835019443193,"p99":641.9929328891768}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:43:56+02","p50":303.15323575,"p95":510.21732344072774,"p99":681.9766942057275}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:43:57+02","p50":339.1372965,"p95":509.0658947488823,"p99":591.6627659989495}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:43:58+02","p50":319.83823225000003,"p95":544.7309274728975,"p99":664.1933552479275}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:43:59+02","p50":351.64510112500005,"p95":528.0506835276024,"p99":668.6147146735203}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:44:00+02","p50":348.252881625,"p95":543.181292921331,"p99":633.5930048063681}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:44:01+02","p50":331.607667,"p95":539.2300831935438,"p99":631.455975505971}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:44:02+02","p50":324.649678625,"p95":544.1680615725603,"p99":677.5085549666525}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:44:03+02","p50":315.25527325,"p95":555.5250743470641,"p99":609.622951829411}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:44:04+02","p50":365.672411,"p95":543.0288468875787,"p99":652.3411585622215}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:44:05+02","p50":342.568414,"p95":512.793715325577,"p99":618.4076284013751}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:44:06+02","p50":333.78929875,"p95":509.0509641225709,"p99":583.3571742093377}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:44:07+02","p50":309.444478,"p95":504.94748119617464,"p99":584.7733703681555}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:44:08+02","p50":358.30726975,"p95":555.707903055953,"p99":639.9491676502342}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:44:09+02","p50":343.423042125,"p95":528.4406846797634,"p99":650.2524399178006}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:44:10+02","p50":344.6283565,"p95":517.3996959602098,"p99":584.3293029321632}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:44:11+02","p50":360.36645450000003,"p95":554.0392444659653,"p99":625.9818996899108}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:44:12+02","p50":366.55893175,"p95":567.4357889148151,"p99":626.7209425291443}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:44:13+02","p50":346.86440675,"p95":543.6496711809788,"p99":642.096952304966}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:44:14+02","p50":353.39149075,"p95":577.5596644226237,"p99":643.319340748392}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:44:15+02","p50":341.09941225,"p95":488.57378077602004,"p99":554.1846986540642}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:44:16+02","p50":363.713310375,"p95":573.9259659905728,"p99":694.3959354154669}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:44:17+02","p50":428.348118,"p95":620.7357997010351,"p99":719.3941255517722}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:44:18+02","p50":400.901007,"p95":755.4515822323385,"p99":820.3866738953035}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:44:19+02","p50":371.872225,"p95":672.2152944671152,"p99":771.9139738783016}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:44:20+02","p50":336.51824462499997,"p95":537.1944035475506,"p99":594.6638948443408}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:44:21+02","p50":322.795362625,"p95":507.3882789071787,"p99":549.3647344492305}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:44:22+02","p50":325.145735,"p95":503.24908884989725,"p99":611.0339718718205}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:44:23+02","p50":342.52409925,"p95":605.2133816231802,"p99":743.5402453059545}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:44:24+02","p50":326.113772375,"p95":546.3615107799446,"p99":633.9234585743316}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:44:25+02","p50":358.885657625,"p95":605.5025837244076,"p99":672.8392904294071}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:44:26+02","p50":335.03479175,"p95":507.94215605371664,"p99":539.8503027720566}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:44:27+02","p50":320.792308,"p95":502.848236345376,"p99":565.9427686338806}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:44:28+02","p50":340.089294,"p95":567.740797113977,"p99":625.8843573938685}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:44:29+02","p50":330.62131000000005,"p95":503.91396347586146,"p99":651.4610301186677}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:44:30+02","p50":313.853461875,"p95":532.662647136313,"p99":635.7935862359376}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:44:31+02","p50":352.82864575,"p95":583.297518264915,"p99":672.5121789563389}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:44:32+02","p50":364.65018524999994,"p95":585.8284566888414,"p99":642.1417981324215}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:44:33+02","p50":366.73125949999996,"p95":608.3559323920485,"p99":725.2928327889746}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:44:34+02","p50":346.7205105,"p95":564.2498423319068,"p99":631.0187985758477}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:44:35+02","p50":357.026326625,"p95":643.3134047584376,"p99":691.3110414690401}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:44:36+02","p50":338.037225625,"p95":587.7517571396664,"p99":666.2727045889056}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:44:37+02","p50":332.331228875,"p95":557.5391620648119,"p99":733.9624281525951}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:44:38+02","p50":348.61139025,"p95":538.1252050438933,"p99":623.5037710384449}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:44:39+02","p50":350.319776875,"p95":548.902283918745,"p99":582.9400503304043}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:44:40+02","p50":324.78724625,"p95":468.23032480021186,"p99":590.6577618388824}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:44:41+02","p50":338.604237625,"p95":496.69199885883165,"p99":580.3399563173156}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:44:42+02","p50":338.38548525,"p95":514.1438362161766,"p99":585.1993780636466}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:44:43+02","p50":332.84213387499994,"p95":596.9369607276868,"p99":693.003808062556}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:44:44+02","p50":328.30492449999997,"p95":553.5345307069192,"p99":613.0522008717575}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:44:45+02","p50":355.51230275,"p95":536.2873064674928,"p99":629.3372348191824}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:44:46+02","p50":362.88431024999994,"p95":662.7800251113944,"p99":740.9045094218789}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:44:47+02","p50":363.03878275,"p95":624.5005102746338,"p99":702.4394397960845}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:44:48+02","p50":452.140451,"p95":641.6064560464545,"p99":808.9536519214856}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:44:49+02","p50":460.25323149999997,"p95":684.3763280765312,"p99":787.8054245690737}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:44:50+02","p50":360.07355974999996,"p95":576.2068855519274,"p99":673.0585240240574}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:44:51+02","p50":339.56782,"p95":556.4122995985499,"p99":584.4511258181686}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:44:52+02","p50":340.96431025,"p95":542.1747362165277,"p99":694.0009480606399}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:44:53+02","p50":346.59112725,"p95":545.775912490787,"p99":592.8181228979234}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:44:54+02","p50":345.29219962499997,"p95":574.982256025082,"p99":638.1739965908649}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:44:55+02","p50":339.69288850000004,"p95":536.0492649755479,"p99":587.7214661975629}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:44:56+02","p50":354.87745574999997,"p95":589.7209300949339,"p99":648.512332062253}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:44:57+02","p50":399.225588,"p95":606.0997370246735,"p99":762.3074757153626}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:44:58+02","p50":361.609880375,"p95":587.3312141333265,"p99":649.0144829675403}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:44:59+02","p50":375.4963745,"p95":597.3958477720232,"p99":658.8760655571076}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:45:00+02","p50":324.150401375,"p95":560.6063027899701,"p99":635.3311170758727}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:45:01+02","p50":353.793433125,"p95":585.4320197072103,"p99":625.3968822394033}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:45:02+02","p50":337.484696,"p95":510.8684445387955,"p99":545.4295805129166}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:45:03+02","p50":347.23752025,"p95":543.4740464520517,"p99":596.8581490632382}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:45:04+02","p50":346.26664337500006,"p95":558.1099372979429,"p99":737.0618150952544}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:45:05+02","p50":328.94622749999996,"p95":497.8070499913093,"p99":551.2574102764773}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:45:06+02","p50":339.4385945,"p95":549.4102414865409,"p99":590.4435919302292}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:45:07+02","p50":363.29308737499997,"p95":549.8564751976868,"p99":594.4757302067794}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:45:08+02","p50":333.527347,"p95":602.2613198320062,"p99":668.4757034237766}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:45:09+02","p50":350.487534,"p95":553.9185693652901,"p99":647.0859333514304}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:45:10+02","p50":357.864382,"p95":541.1163411903921,"p99":593.630792498826}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:45:11+02","p50":342.684916,"p95":535.7652182280488,"p99":579.419762448008}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:45:12+02","p50":366.39387325,"p95":537.5041689847051,"p99":643.3912362304791}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:45:13+02","p50":350.523786,"p95":523.1297766173515,"p99":606.7601464649315}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:45:14+02","p50":359.49665175,"p95":552.57607071309,"p99":626.9760799493027}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:45:15+02","p50":349.344872,"p95":546.5477102665989,"p99":590.8390068376393}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:45:16+02","p50":376.904857,"p95":676.5819791843779,"p99":819.2726504091942}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:45:17+02","p50":364.3164845,"p95":633.4672356310823,"p99":772.715747096447}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:45:18+02","p50":450.038067,"p95":702.4808938677854,"p99":871.8367253081093}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:45:19+02","p50":410.343016,"p95":643.1498567238561,"p99":759.7305919120329}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:45:20+02","p50":346.97637737499997,"p95":501.28509446006524,"p99":640.3220917080525}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:45:21+02","p50":332.378516,"p95":536.7870575605347,"p99":622.6890286930113}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:45:22+02","p50":335.417158375,"p95":499.1350125774557,"p99":676.754268993287}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:45:23+02","p50":359.990161625,"p95":595.0249133781737,"p99":706.7902989884295}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:45:24+02","p50":349.976341375,"p95":517.5214534847408,"p99":602.1449828580975}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:45:25+02","p50":345.627530375,"p95":537.8296353127979,"p99":614.846616106573}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:45:26+02","p50":399.3272875,"p95":596.5065870531112,"p99":734.8957623056144}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:45:27+02","p50":364.2523795,"p95":553.1759960812092,"p99":644.5089362873192}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:45:28+02","p50":336.62618837499997,"p95":514.3392470972312,"p99":564.6238786615079}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:45:29+02","p50":358.803955,"p95":551.1398884214469,"p99":598.8946582802253}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:45:30+02","p50":334.55181775,"p95":510.92113687125885,"p99":557.5052702214638}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:45:31+02","p50":353.88141775,"p95":520.9150071872731,"p99":587.7909470786}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:45:32+02","p50":342.56084062499997,"p95":515.0521171364501,"p99":601.0929402814107}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:45:33+02","p50":324.0906635,"p95":523.2055296571111,"p99":657.5371172251472}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:45:34+02","p50":347.01409925,"p95":540.2876733650646,"p99":585.7058553339463}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:45:35+02","p50":327.12255100000004,"p95":521.5998255642677,"p99":610.0326364807672}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:45:36+02","p50":371.173282625,"p95":523.8652652994544,"p99":600.0573835936073}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:45:37+02","p50":363.48669525,"p95":580.3229243209925,"p99":640.8220176608982}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:45:38+02","p50":348.821429625,"p95":503.57228163743963,"p99":551.6865038716606}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:45:39+02","p50":327.1297095,"p95":533.3160169962772,"p99":603.5995423386149}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:45:40+02","p50":347.57051150000007,"p95":515.7508554867925,"p99":624.7439295672657}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:45:41+02","p50":348.17471850000004,"p95":503.79439140520384,"p99":526.4679940250745}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:45:42+02","p50":342.02924125,"p95":540.9323713551387,"p99":663.7699225116005}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:45:43+02","p50":346.85442975,"p95":573.970615412692,"p99":671.3220911510086}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:45:44+02","p50":348.94339,"p95":553.8250227548432,"p99":608.9172629492889}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:45:45+02","p50":362.167644,"p95":572.6568605889731,"p99":642.5339761294265}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:45:46+02","p50":449.165789,"p95":649.6537892811198,"p99":806.4412916986909}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:45:47+02","p50":412.744774,"p95":722.7844726881544,"p99":813.37351721904}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:45:48+02","p50":442.513619,"p95":695.6580571621427,"p99":764.0540100782699}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:45:49+02","p50":413.373996,"p95":742.3946704845505,"p99":898.4970946870388}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:45:50+02","p50":401.431331,"p95":806.9983492125108,"p99":1089.5320209498882}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:45:51+02","p50":327.89469187500003,"p95":578.4287207270136,"p99":657.2523128917635}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:45:52+02","p50":337.102387,"p95":576.2936623412663,"p99":660.1718600363254}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:45:53+02","p50":361.21950712499995,"p95":569.2638940045007,"p99":699.6975276886852}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:45:54+02","p50":331.06800899999996,"p95":524.4758514649495,"p99":558.5047467185478}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:45:55+02","p50":364.307729,"p95":546.9215105486043,"p99":609.24503898455}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:45:56+02","p50":338.17483325,"p95":537.529060036429,"p99":596.3516635225019}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:45:57+02","p50":335.948761,"p95":524.5735766707137,"p99":649.8923334592514}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:45:58+02","p50":345.1245805,"p95":551.4527573691249,"p99":689.5070633250904}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:45:59+02","p50":340.35369575,"p95":574.0492728977716,"p99":734.3485056186771}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:46:00+02","p50":326.01020625,"p95":534.3457177984557,"p99":642.8531181339529}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:46:01+02","p50":349.556566,"p95":518.3864366541636,"p99":694.0963477342606}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:46:02+02","p50":360.87075749999997,"p95":579.7477344035956,"p99":680.0530456225418}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:46:03+02","p50":357.371667,"p95":533.109212540358,"p99":578.6317545747489}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:46:04+02","p50":341.749813625,"p95":573.3093022196327,"p99":644.7616532399826}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:46:05+02","p50":320.6170245,"p95":535.0045539217391,"p99":574.1511910854016}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:46:06+02","p50":349.35370550000005,"p95":615.5754129626853,"p99":664.3451944788022}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:46:07+02","p50":342.0677505,"p95":500.23475022103133,"p99":546.5653979372277}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:46:08+02","p50":342.98735500000004,"p95":570.6678997224845,"p99":616.7477293901939}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:46:09+02","p50":347.29162375,"p95":541.7223988069749,"p99":712.2159962368186}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:46:10+02","p50":365.035491125,"p95":540.6129649454722,"p99":637.7218718838925}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:46:11+02","p50":350.03384875,"p95":575.908056891989,"p99":631.3995757880421}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:46:12+02","p50":338.81277525,"p95":560.0984794637244,"p99":775.6743723776826}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:46:13+02","p50":347.71150775,"p95":551.2569839859333,"p99":604.849165405223}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:46:14+02","p50":374.16590175,"p95":548.4767495970355,"p99":572.9626778943658}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:46:15+02","p50":337.250061625,"p95":506.517735715367,"p99":544.5336736389237}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:46:16+02","p50":374.160351,"p95":599.6476712737378,"p99":651.4095189924625}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:46:17+02","p50":391.4443225,"p95":574.6293131787165,"p99":675.6484147643184}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:46:18+02","p50":422.722661,"p95":801.8904405198309,"p99":863.7027500875104}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:46:19+02","p50":426.4683285,"p95":723.6471293044243,"p99":884.0684621323076}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:46:20+02","p50":345.12625475,"p95":583.8862624139952,"p99":661.0541674827142}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:46:21+02","p50":345.53076375,"p95":536.2967791234223,"p99":637.1758599501028}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:46:22+02","p50":379.48715925,"p95":564.6169836967816,"p99":652.4221536137562}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:46:23+02","p50":376.84858725000004,"p95":520.5663124032963,"p99":668.127548019948}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:46:24+02","p50":351.26797224999996,"p95":544.0758982710404,"p99":647.1024968332835}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:46:25+02","p50":360.2830105,"p95":575.9003097748199,"p99":664.8352036892582}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:46:26+02","p50":340.13485075,"p95":559.0044568046784,"p99":618.4696541508598}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:46:27+02","p50":356.90814687500006,"p95":539.6683896089304,"p99":588.990574314398}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:46:28+02","p50":364.363302,"p95":557.9573939390931,"p99":714.4486014077387}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:46:29+02","p50":369.2119467499999,"p95":559.8195483858964,"p99":605.4333882320802}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:46:30+02","p50":353.59453824999997,"p95":540.943501826767,"p99":604.0178129430085}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:46:31+02","p50":341.64630150000005,"p95":598.2149039603522,"p99":688.8407177724839}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:46:32+02","p50":363.850076875,"p95":548.4968659399699,"p99":624.3300117749095}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:46:33+02","p50":320.93730625,"p95":565.3146409970316,"p99":671.0347354925132}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:46:34+02","p50":370.12893325,"p95":585.4740319925847,"p99":650.0584546206627}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:46:35+02","p50":361.22903125,"p95":537.0443487406942,"p99":618.8847951454678}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:46:36+02","p50":382.79561574999997,"p95":522.8193759887354,"p99":591.3114227743597}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:46:37+02","p50":345.733393125,"p95":641.1866238535613,"p99":699.9755659561661}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:46:38+02","p50":353.763625875,"p95":547.664618671233,"p99":623.113616392451}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:46:39+02","p50":367.84983475,"p95":538.0287704812815,"p99":603.2834892632761}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:46:40+02","p50":329.19345899999996,"p95":517.658193310068,"p99":596.2428623315711}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:46:41+02","p50":378.69003875,"p95":557.4398456603016,"p99":633.9591832523568}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:46:42+02","p50":348.52142287500004,"p95":519.0831945842875,"p99":561.6199110145434}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:46:43+02","p50":354.59012375,"p95":525.9576285781751,"p99":665.4675615167932}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:46:44+02","p50":381.149179,"p95":570.1894354293403,"p99":674.8120213161407}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:46:45+02","p50":376.36328249999997,"p95":534.1634561133704,"p99":607.3743401134548}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:46:46+02","p50":375.87968712500003,"p95":607.4298005219961,"p99":754.2779052279494}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:46:47+02","p50":367.839692,"p95":614.6653641036158,"p99":684.1563988919105}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:46:48+02","p50":470.60785,"p95":705.6385812547643,"p99":841.9683077408989}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:46:49+02","p50":461.235327,"p95":805.8623452664671,"p99":896.7389074969918}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:46:50+02","p50":410.10131075000004,"p95":685.3478531655312,"p99":824.0622178336725}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:46:51+02","p50":327.795443,"p95":547.0594844745756,"p99":619.2649680240106}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:46:52+02","p50":354.61930975,"p95":558.4728557765573,"p99":636.97769057809}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:46:53+02","p50":345.214233375,"p95":527.2921589568558,"p99":558.6288495968028}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:46:54+02","p50":350.352511375,"p95":503.8315707582582,"p99":609.9543784524154}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:46:55+02","p50":363.90394137500004,"p95":572.6521485627101,"p99":665.6983828493146}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:46:56+02","p50":365.492157,"p95":523.3605533984289,"p99":571.8051118889975}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:46:57+02","p50":371.169569,"p95":523.2952542524438,"p99":571.2109034612259}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:46:58+02","p50":436.911162,"p95":638.304762745309,"p99":709.4670206928821}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:46:59+02","p50":411.18073,"p95":612.3270736110799,"p99":715.6366150032653}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:47:00+02","p50":352.9105875,"p95":540.8583767316579,"p99":621.0581579829368}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:47:01+02","p50":376.91366800000003,"p95":608.9526297512717,"p99":633.7185279041666}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:47:02+02","p50":374.34576412499996,"p95":595.1244913522681,"p99":627.2598151529543}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:47:03+02","p50":356.11296812500007,"p95":534.0915329685939,"p99":612.1283232763257}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:47:04+02","p50":373.62495075000004,"p95":571.1913684215087,"p99":599.2966416379137}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:47:05+02","p50":349.42042949999995,"p95":629.4699964729137,"p99":722.7621205956013}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:47:06+02","p50":376.271132,"p95":591.6972141875145,"p99":688.8064591543424}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:47:07+02","p50":363.15761062499996,"p95":580.7751883201435,"p99":687.49893791833}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:47:08+02","p50":359.12589075000005,"p95":627.3962238782893,"p99":771.416657282438}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:47:09+02","p50":331.537497875,"p95":552.8859099029235,"p99":667.0232644884268}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:47:10+02","p50":344.60761149999996,"p95":670.7708161270356,"p99":732.9215999619885}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:47:11+02","p50":329.5246875,"p95":582.709590824389,"p99":722.9900399842286}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:47:12+02","p50":365.487416,"p95":560.7896041474411,"p99":621.9079438566717}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:47:13+02","p50":366.85857175,"p95":565.3370858114414,"p99":654.078266817527}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:47:14+02","p50":376.624474125,"p95":600.2568704768521,"p99":654.9918930514531}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:47:15+02","p50":385.75254862500003,"p95":578.4678990606368,"p99":641.6020224810624}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:47:16+02","p50":366.97081049999997,"p95":647.33773638466,"p99":743.7291821785717}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:47:17+02","p50":393.7892745,"p95":618.3646556576285,"p99":698.0266578596397}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:47:18+02","p50":431.89287,"p95":756.3606272760418,"p99":857.3252262805039}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:47:19+02","p50":502.68915549999997,"p95":795.9049426001947,"p99":934.9490622697764}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:47:20+02","p50":420.67546100000004,"p95":674.8818829643311,"p99":793.2769937691173}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:47:21+02","p50":355.45911025,"p95":563.6235681177752,"p99":665.7891341768741}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:47:22+02","p50":351.042864,"p95":550.1976598919637,"p99":624.438729203361}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:47:23+02","p50":336.83872825,"p95":535.7430034556264,"p99":700.4418950208094}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:47:24+02","p50":382.423903,"p95":595.7934666937183,"p99":652.1804639763667}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:47:25+02","p50":356.95381125,"p95":588.686524896492,"p99":675.3769710497913}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:47:26+02","p50":342.142293375,"p95":527.0026947642408,"p99":658.6139797398522}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:47:27+02","p50":351.54080849999997,"p95":598.2845428583211,"p99":640.1780923918863}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:47:28+02","p50":351.10330275,"p95":568.0761515172472,"p99":618.3176152456906}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:47:29+02","p50":382.7587185,"p95":602.253188061808,"p99":655.8339036481781}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:47:30+02","p50":356.3262155,"p95":576.6441496147552,"p99":662.9474969482193}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:47:31+02","p50":377.2490755,"p95":546.042730181945,"p99":592.7406988252216}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:47:32+02","p50":366.4939325,"p95":575.1407336788749,"p99":658.0646213520932}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:47:33+02","p50":334.68827450000003,"p95":550.26795746339,"p99":610.1698392888622}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:47:34+02","p50":368.598075,"p95":605.5791265990532,"p99":672.2396189265404}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:47:35+02","p50":362.02651275,"p95":519.6200709826431,"p99":580.9427359890051}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:47:36+02","p50":367.76984849999997,"p95":587.425418991338,"p99":638.4740805902625}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:47:37+02","p50":360.245454375,"p95":608.7418731459536,"p99":653.1512138291268}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:47:38+02","p50":406.948706,"p95":607.1203332223666,"p99":640.8209670412433}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:47:39+02","p50":391.03764775,"p95":548.5080014495067,"p99":656.0194364837594}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:47:40+02","p50":407.506433,"p95":641.3765128161691,"p99":693.6325246361919}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:47:41+02","p50":392.0926725,"p95":606.018242698949,"p99":677.4727272443628}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:47:42+02","p50":380.680381,"p95":580.4023302434218,"p99":685.8592293568843}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:47:43+02","p50":371.282735,"p95":577.8334548774365,"p99":667.6755771671204}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:47:44+02","p50":381.125060125,"p95":600.6462395270736,"p99":728.9498561144115}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:47:45+02","p50":364.24978,"p95":546.2749676986276,"p99":615.2926799143088}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:47:46+02","p50":394.563987875,"p95":589.5063062651146,"p99":652.27759246698}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:47:47+02","p50":398.128657,"p95":620.3325964756989,"p99":708.7442627269784}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:47:48+02","p50":489.1820475,"p95":720.4108372897692,"p99":886.7874097963199}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:47:49+02","p50":445.4518175,"p95":728.9021142257258,"p99":949.7441621377039}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:47:50+02","p50":480.368007,"p95":793.0226181554071,"p99":971.231016473387}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:47:51+02","p50":370.2256745,"p95":524.9930269471047,"p99":632.8595963865823}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:47:52+02","p50":382.141342,"p95":577.2822490433972,"p99":682.8809792750768}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:47:53+02","p50":351.329848,"p95":582.114161490897,"p99":671.9360361640039}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:47:54+02","p50":366.29761499999995,"p95":565.6847946252662,"p99":673.3247704165912}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:47:55+02","p50":360.08347075,"p95":608.0463271750035,"p99":709.0654027889042}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:47:56+02","p50":387.609815,"p95":597.6089246492459,"p99":687.7932023599882}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:47:57+02","p50":378.806582,"p95":563.3652127297133,"p99":627.0161513699355}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:47:58+02","p50":367.4258025,"p95":553.874677023038,"p99":702.2397284850064}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:47:59+02","p50":385.534227,"p95":567.5638756617304,"p99":658.5302650030661}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:48:00+02","p50":358.108514375,"p95":592.2173371127257,"p99":647.7260890681663}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:48:01+02","p50":372.657969,"p95":650.653177755713,"p99":722.1162193479786}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:48:02+02","p50":374.977187,"p95":561.7995290611792,"p99":638.3017917092704}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:48:03+02","p50":384.11512424999995,"p95":554.6637274162941,"p99":596.0777641940341}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:48:04+02","p50":345.30697775000004,"p95":647.6057250044437,"p99":733.7630055309344}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:48:05+02","p50":371.919690375,"p95":538.4030222033645,"p99":666.8601673203542}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:48:06+02","p50":370.85242025,"p95":566.8579537161854,"p99":596.3000648483257}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:48:07+02","p50":371.3460725,"p95":600.864875038826,"p99":696.5931488108387}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:48:08+02","p50":377.0203545,"p95":593.0985303793224,"p99":717.2182938407545}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:48:09+02","p50":350.117952625,"p95":551.1117659721687,"p99":629.2865839315465}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:48:10+02","p50":384.89380324999996,"p95":557.8008546511566,"p99":636.580461088732}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:48:11+02","p50":366.029803,"p95":564.3884609931798,"p99":661.2605684219055}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:48:12+02","p50":359.28891,"p95":561.3114050814762,"p99":670.2915985139274}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:48:13+02","p50":368.3550525,"p95":528.7123712326683,"p99":660.9452924902478}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:48:14+02","p50":403.413705,"p95":647.2213528704553,"p99":787.9417279004001}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:48:15+02","p50":316.89532575,"p95":585.3375307634708,"p99":746.9125202290841}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:48:16+02","p50":366.46199775,"p95":564.5855855665229,"p99":720.3569641328735}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:48:17+02","p50":395.0455565,"p95":630.3617309191131,"p99":712.5628947219238}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:48:18+02","p50":452.543411,"p95":666.0552077854462,"p99":818.2982870411587}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:48:19+02","p50":475.983971,"p95":815.1069785751895,"p99":992.4595084467697}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:48:20+02","p50":396.29265350000003,"p95":760.2994612084045,"p99":881.2400988808289}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:48:21+02","p50":350.0927785,"p95":534.3606522786283,"p99":685.6315452478809}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:48:22+02","p50":355.531666,"p95":562.436802332802,"p99":655.665564886801}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:48:23+02","p50":379.008702,"p95":526.9116277446686,"p99":579.3579618329161}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:48:24+02","p50":369.6668505,"p95":562.9086056966726,"p99":642.9324887840419}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:48:25+02","p50":374.783023125,"p95":556.1949239988519,"p99":648.6149735306576}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:48:26+02","p50":373.97536675,"p95":545.8640974620236,"p99":603.0847192951051}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:48:27+02","p50":357.790776875,"p95":592.9894363913226,"p99":698.608809728214}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:48:28+02","p50":380.39239150000003,"p95":560.935014845125,"p99":713.1072916162286}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:48:29+02","p50":375.62008249999997,"p95":549.8600039423752,"p99":614.5286269966126}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:48:30+02","p50":354.73620475,"p95":518.6924974618819,"p99":653.8115801267328}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:48:31+02","p50":408.81667400000003,"p95":580.7742718071449,"p99":743.639358516593}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:48:32+02","p50":373.717473,"p95":591.7884088895145,"p99":653.9233233542363}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:48:33+02","p50":457.6231675,"p95":713.9853752811575,"p99":801.5314229417592}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:48:34+02","p50":398.69369099999994,"p95":576.9453266908774,"p99":638.878944097023}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:48:35+02","p50":380.10456575,"p95":575.6161159108634,"p99":609.1460214958057}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:48:36+02","p50":390.482404,"p95":564.0768451614668,"p99":716.9423898276623}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:48:37+02","p50":376.32667000000004,"p95":600.187044783256,"p99":781.7169870735951}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:48:38+02","p50":387.6777615,"p95":560.6641300469131,"p99":595.6967540945435}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:48:39+02","p50":402.231564125,"p95":530.5324569812227,"p99":632.398123102675}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:48:40+02","p50":387.8637995,"p95":594.2622456500368,"p99":640.2609036061725}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:48:41+02","p50":372.434851,"p95":524.2710856375901,"p99":578.9491680272847}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:48:42+02","p50":387.522010625,"p95":580.4659249738294,"p99":711.8193150877477}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:48:43+02","p50":387.458622,"p95":620.741373132667,"p99":658.9881203480729}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:48:44+02","p50":392.4298125,"p95":629.7071045733506,"p99":705.2516679337415}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:48:45+02","p50":376.85462225,"p95":533.5347648917531,"p99":639.7289377307749}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:48:46+02","p50":395.706921,"p95":641.9824354647773,"p99":702.8896531357329}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:48:47+02","p50":376.69207400000005,"p95":657.763545992053,"p99":805.5041914577865}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:48:48+02","p50":509.502717,"p95":755.5429311040151,"p99":935.4421637327413}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:48:49+02","p50":474.339925,"p95":745.6588229550018,"p99":864.3030752003326}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:48:50+02","p50":416.080606,"p95":740.9167848159949,"p99":896.6391244707446}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:48:51+02","p50":374.4881395,"p95":568.7187625705914,"p99":640.6493812109198}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:48:52+02","p50":377.6801165,"p95":627.671924471273,"p99":727.8040352599335}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:48:53+02","p50":407.80580174999994,"p95":637.1368828413749,"p99":749.3144091134742}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:48:54+02","p50":390.149391,"p95":532.2912166657791,"p99":621.3468670521851}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:48:55+02","p50":353.1494325,"p95":578.9051062667124,"p99":622.0327491599936}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:48:56+02","p50":380.552572,"p95":573.046774797695,"p99":643.197298432375}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:48:57+02","p50":397.34807575,"p95":601.8214968569139,"p99":737.5489854612472}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:48:58+02","p50":379.87111775,"p95":601.284545349459,"p99":641.9105274041752}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:48:59+02","p50":372.430882,"p95":574.5653418701997,"p99":656.5257328923083}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:49:00+02","p50":391.869787,"p95":594.046199838078,"p99":661.4024955055589}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:49:01+02","p50":380.62370150000004,"p95":601.2354013430615,"p99":699.7857347286258}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:49:02+02","p50":381.049654,"p95":606.5920663715998,"p99":664.5980062494796}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:49:03+02","p50":383.42459199999996,"p95":620.6149392064591,"p99":717.3536460669555}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:49:04+02","p50":394.31672,"p95":608.5648653262712,"p99":752.5454078678036}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:49:05+02","p50":393.24571000000003,"p95":628.0916439125399,"p99":697.0547843593827}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:49:06+02","p50":403.32300975,"p95":600.9828152580207,"p99":705.8764485959186}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:49:07+02","p50":381.233005,"p95":593.6824948727258,"p99":646.7162360751807}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:49:08+02","p50":378.452192125,"p95":585.3596847937857,"p99":658.7346346348761}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:49:09+02","p50":406.263443,"p95":601.8851923236006,"p99":657.2011940190091}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:49:10+02","p50":379.06424675,"p95":536.3294893311079,"p99":657.037147603148}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:49:11+02","p50":385.3275725,"p95":601.5528484018837,"p99":655.0453609955755}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:49:12+02","p50":364.032396875,"p95":653.4345941447176,"p99":756.3619943769318}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:49:13+02","p50":375.1136185,"p95":563.4905159659334,"p99":609.4856272265067}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:49:14+02","p50":374.0972925,"p95":603.8206581368601,"p99":665.2039553373108}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:49:15+02","p50":387.637838,"p95":577.552652499797,"p99":693.657785178182}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:49:16+02","p50":395.9279765,"p95":573.2520221783011,"p99":623.4036501542654}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:49:17+02","p50":385.011343,"p95":578.0024207123759,"p99":739.5986926926594}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:49:18+02","p50":501.070556,"p95":739.1737046452372,"p99":817.5306489707167}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:49:19+02","p50":487.48740399999997,"p95":714.5395390926317,"p99":788.6833549983197}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:49:20+02","p50":398.6928585,"p95":624.9289234712396,"p99":735.1492829041281}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:49:21+02","p50":381.2014705,"p95":552.4190413578395,"p99":607.1950033001709}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:49:22+02","p50":378.4648505,"p95":572.1401214373743,"p99":672.7073571081388}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:49:23+02","p50":381.756316,"p95":546.8975953816202,"p99":656.8522043579336}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:49:24+02","p50":398.69236900000004,"p95":564.599991420596,"p99":666.2908761377087}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:49:25+02","p50":369.738289,"p95":591.8191478315905,"p99":667.6489562640949}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:49:26+02","p50":374.1937285,"p95":561.258875386296,"p99":635.1006114370289}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:49:27+02","p50":364.389940375,"p95":607.656058991329,"p99":638.7479768448577}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:49:28+02","p50":392.7941255,"p95":582.934002763613,"p99":668.4763312391167}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:49:29+02","p50":395.1537375,"p95":581.2645812752047,"p99":629.3970892682247}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:49:30+02","p50":378.461375,"p95":560.356492213997,"p99":644.3948260291138}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:49:31+02","p50":382.706153,"p95":554.023332663457,"p99":595.6850969616621}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:49:32+02","p50":378.17704625,"p95":596.8606748886185,"p99":652.8597620553479}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:49:33+02","p50":361.20687,"p95":554.0451086429039,"p99":665.8597491082382}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:49:34+02","p50":375.875681,"p95":573.009036371263,"p99":677.5763840911723}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:49:35+02","p50":398.95089375,"p95":551.1305156222186,"p99":625.1852731621457}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:49:36+02","p50":403.060358,"p95":578.952155218065,"p99":617.1697987665615}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:49:37+02","p50":358.56926925,"p95":602.9619980526259,"p99":706.9007030513372}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:49:38+02","p50":369.853585875,"p95":558.05031432408,"p99":656.9718285308442}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:49:39+02","p50":376.7368085,"p95":541.9252990983949,"p99":597.4204704358468}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:49:40+02","p50":374.443547,"p95":610.7528968129975,"p99":718.2325440708454}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:49:41+02","p50":390.63388,"p95":566.5547698392392,"p99":751.4906429859648}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:49:42+02","p50":386.25493274999997,"p95":558.2470035515831,"p99":651.1849266849058}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:49:43+02","p50":386.3001798749999,"p95":568.7246794361353,"p99":680.0371702720599}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:49:44+02","p50":414.451416,"p95":582.8826341209705,"p99":688.0970412610598}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:49:45+02","p50":396.874899,"p95":573.0194268884987,"p99":673.6045943448377}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:49:46+02","p50":442.434258,"p95":598.4165902697932,"p99":720.8006863922512}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:49:47+02","p50":403.353726,"p95":548.8214058317883,"p99":825.8187105583257}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:49:48+02","p50":512.466077,"p95":732.5617006177514,"p99":875.0522002655697}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:49:49+02","p50":498.12606500000004,"p95":711.3046155963399,"p99":893.5885908343587}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:49:50+02","p50":419.23831099999995,"p95":653.8646418435065,"p99":740.4648088355135}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:49:51+02","p50":376.37620575,"p95":552.512115076958,"p99":619.9807175853116}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:49:52+02","p50":390.14741749999996,"p95":588.6646891362872,"p99":655.2341834101849}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:49:53+02","p50":400.861338,"p95":577.2982991460228,"p99":600.915501021375}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:49:54+02","p50":379.451339125,"p95":575.3869842699014,"p99":657.3521219556268}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:49:55+02","p50":407.52232649999996,"p95":604.6002093730469,"p99":653.4454181223144}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:49:56+02","p50":363.082460375,"p95":540.8862117627307,"p99":679.5614941885009}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:49:57+02","p50":365.796288125,"p95":567.4904118875198,"p99":652.1854918458216}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:49:58+02","p50":372.912214,"p95":562.0312605474297,"p99":632.4050374625965}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:49:59+02","p50":369.301701,"p95":593.6903148726128,"p99":677.3486074447236}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:50:00+02","p50":421.84070199999996,"p95":580.7411737502654,"p99":621.4996571411447}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:50:01+02","p50":392.687742,"p95":617.2486047475122,"p99":686.2983731112414}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:50:02+02","p50":383.580548,"p95":617.7467801069415,"p99":707.103503131856}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:50:03+02","p50":386.919284,"p95":593.5768893762719,"p99":679.0635569386003}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:50:04+02","p50":451.1858505,"p95":710.2390074153294,"p99":824.9659940393234}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:50:05+02","p50":386.025572,"p95":683.426417292915,"p99":896.3165627046494}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:50:06+02","p50":384.505966,"p95":589.1070330830125,"p99":694.5420661562305}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:50:07+02","p50":399.1031545,"p95":592.4421404059601,"p99":675.8345912961425}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:50:08+02","p50":394.995577,"p95":604.3202049969374,"p99":674.6714073621386}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:50:09+02","p50":367.20676249999997,"p95":585.9666742111297,"p99":659.7609447760358}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:50:10+02","p50":385.432987,"p95":552.5380288014563,"p99":639.7719383094694}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:50:11+02","p50":404.909684,"p95":600.2965685052718,"p99":678.1246169718006}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:50:12+02","p50":400.76854000000003,"p95":609.4308743893686,"p99":640.4930314384975}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:50:13+02","p50":380.7047025,"p95":576.3048701383278,"p99":672.3878423597536}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:50:14+02","p50":382.82686674999997,"p95":582.7837581255008,"p99":661.5016473044512}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:50:15+02","p50":378.88070849999997,"p95":584.1112044746536,"p99":627.313500304875}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:50:16+02","p50":403.2571525,"p95":632.6679983820713,"p99":682.0945478748445}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:50:17+02","p50":413.427646,"p95":606.5683102387396,"p99":738.8084224646201}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:50:18+02","p50":488.05521,"p95":703.4761239822246,"p99":768.0045405116186}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:50:19+02","p50":526.1706945,"p95":757.2231546335336,"p99":822.1608808752136}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:50:20+02","p50":428.938374,"p95":735.4151300372282,"p99":845.5614114277008}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:50:21+02","p50":389.45504625,"p95":642.9555771926074,"p99":747.944182983573}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:50:22+02","p50":421.3969125,"p95":613.8132536197404,"p99":715.3682359889335}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:50:23+02","p50":407.713257,"p95":575.4587298318987,"p99":654.8682521849261}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:50:24+02","p50":406.7370325,"p95":612.7913894636231,"p99":682.8240717960119}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:50:25+02","p50":414.999551,"p95":679.9491651280817,"p99":712.1398508435125}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:50:26+02","p50":400.381467,"p95":629.3210713000857,"p99":757.5066780656276}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:50:27+02","p50":403.815541,"p95":601.0086458383829,"p99":731.782436562891}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:50:28+02","p50":436.904756,"p95":585.4005056937589,"p99":624.0598723059244}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:50:29+02","p50":433.925698,"p95":586.8056523985105,"p99":627.1120268679717}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:50:30+02","p50":416.318149,"p95":604.0334259981772,"p99":670.8958782901761}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:50:31+02","p50":404.17097,"p95":609.5917478201899,"p99":678.6095126874237}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:50:32+02","p50":406.893869,"p95":564.5321166061897,"p99":685.2356767854099}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:50:33+02","p50":390.560496,"p95":565.1381103080156,"p99":639.992465843625}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:50:34+02","p50":405.088062,"p95":584.9710610011844,"p99":642.5561481885514}, + {"metric_name":"oidc_token_duration","timestamp":"2024-10-22 18:50:35+02","p50":379.604884,"p95":610.3624684022362,"p99":696.5676583588892} +] diff --git a/docs/package.json b/docs/package.json index 3d7b2fe036..e322206563 100644 --- a/docs/package.json +++ b/docs/package.json @@ -44,6 +44,7 @@ "react": "^18.2.0", "react-copy-to-clipboard": "^5.1.0", "react-dom": "^18.2.0", + "react-google-charts": "^5.2.1", "react-player": "^2.15.1", "sitemap": "7.1.1", "swc-loader": "^0.2.3", diff --git a/docs/sidebars.js b/docs/sidebars.js index 8c83f9bf1d..be96b61706 100644 --- a/docs/sidebars.js +++ b/docs/sidebars.js @@ -841,6 +841,30 @@ module.exports = { label: "Rate Limits (Cloud)", // The link label href: "/legal/policies/rate-limit-policy", // The internal path }, + { + type: "category", + label: "Benchmarks", + collapsed: false, + link: { + type: "doc", + id: "apis/benchmarks/index", + }, + items: [ + { + type: "category", + label: "v2.65.0", + link: { + title: "v2.65.0", + slug: "/apis/benchmarks/v2.65.0", + description: + "Benchmark results of Zitadel v2.65.0\n" + }, + items: [ + "apis/benchmarks/v2.65.0/machine_jwt_profile_grant/index", + ], + }, + ], + }, ], selfHosting: [ { diff --git a/docs/src/components/benchmark_chart.jsx b/docs/src/components/benchmark_chart.jsx new file mode 100644 index 0000000000..f9ac920cad --- /dev/null +++ b/docs/src/components/benchmark_chart.jsx @@ -0,0 +1,45 @@ +import React from "react"; +import Chart from "react-google-charts"; + +export function BenchmarkChart(testResults=[], height='500px') { + + const options = { + legend: { position: 'bottom' }, + focusTarget: 'category', + hAxis: { + title: 'timestamp', + }, + vAxis: { + title: 'latency (ms)', + }, + }; + + const data = [ + [ + {type:"datetime", label: "timestamp"}, + {type:"number", label: "p50"}, + {type:"number", label: "p95"}, + {type:"number", label: "p99"}, + ], + ] + + JSON.parse(testResults.testResults).forEach((result) => { + data.push([ + new Date(result.timestamp), + result.p50, + result.p95, + result.p99, + ]) + }); + + return ( + + ); +} \ No newline at end of file diff --git a/docs/static/img/benchmark/Flowchart.svg b/docs/static/img/benchmark/Flowchart.svg new file mode 100644 index 0000000000..e2a078ab96 --- /dev/null +++ b/docs/static/img/benchmark/Flowchart.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/docs/yarn.lock b/docs/yarn.lock index 538212d391..b5d92b98b4 100644 --- a/docs/yarn.lock +++ b/docs/yarn.lock @@ -9479,6 +9479,11 @@ react-fast-compare@^3.0.1, react-fast-compare@^3.2.0, react-fast-compare@^3.2.2: resolved "https://registry.yarnpkg.com/react-fast-compare/-/react-fast-compare-3.2.2.tgz#929a97a532304ce9fee4bcae44234f1ce2c21d49" integrity sha512-nsO+KSNgo1SbJqJEYRE9ERzo7YtYbou/OqjSQKxV7jcKox7+usiUVZOAC+XnDOABXggQTno0Y1CpVnuWEc1boQ== +react-google-charts@^5.2.1: + version "5.2.1" + resolved "https://registry.yarnpkg.com/react-google-charts/-/react-google-charts-5.2.1.tgz#d9cbe8ed45d7c0fafefea5c7c3361bee76648454" + integrity sha512-mCbPiObP8yWM5A9ogej7Qp3/HX4EzOwuEzUYvcfHtL98Xt4V/brD14KgfDzSNNtyD48MNXCpq5oVaYKt0ykQUQ== + react-helmet-async@*: version "2.0.5" resolved "https://registry.yarnpkg.com/react-helmet-async/-/react-helmet-async-2.0.5.tgz#cfc70cd7bb32df7883a8ed55502a1513747223ec" From 5a85c3eda854282346ebf087d70d2a7f7c468441 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 18 Nov 2024 10:20:03 +0000 Subject: [PATCH 13/64] chore(deps): bump http-proxy-middleware from 2.0.6 to 2.0.7 in /console (#8823) Bumps [http-proxy-middleware](https://github.com/chimurai/http-proxy-middleware) from 2.0.6 to 2.0.7.
Release notes

Sourced from http-proxy-middleware's releases.

v2.0.7

Full Changelog: https://github.com/chimurai/http-proxy-middleware/compare/v2.0.6...v2.0.7

v2.0.7-beta.1

Full Changelog: https://github.com/chimurai/http-proxy-middleware/compare/v2.0.7-beta.0...v2.0.7-beta.1

v2.0.7-beta.0

Full Changelog: https://github.com/chimurai/http-proxy-middleware/compare/v2.0.6...v2.0.7-beta.0

Changelog

Sourced from http-proxy-middleware's changelog.

v2.0.7

  • ci(github actions): add publish.yml
  • fix(filter): handle errors
Commits

[![Dependabot compatibility score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=http-proxy-middleware&package-manager=npm_and_yarn&previous-version=2.0.6&new-version=2.0.7)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores) Dependabot will resolve any conflicts with this PR as long as you don't alter it yourself. You can also trigger a rebase manually by commenting `@dependabot rebase`. [//]: # (dependabot-automerge-start) [//]: # (dependabot-automerge-end) ---
Dependabot commands and options
You can trigger Dependabot actions by commenting on this PR: - `@dependabot rebase` will rebase this PR - `@dependabot recreate` will recreate this PR, overwriting any edits that have been made to it - `@dependabot merge` will merge this PR after your CI passes on it - `@dependabot squash and merge` will squash and merge this PR after your CI passes on it - `@dependabot cancel merge` will cancel a previously requested merge and block automerging - `@dependabot reopen` will reopen this PR if it is closed - `@dependabot close` will close this PR and stop Dependabot recreating it. You can achieve the same result by closing it manually - `@dependabot show ignore conditions` will show all of the ignore conditions of the specified dependency - `@dependabot ignore this major version` will close this PR and stop Dependabot creating any more for this major version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this minor version` will close this PR and stop Dependabot creating any more for this minor version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this dependency` will close this PR and stop Dependabot creating any more for this dependency (unless you reopen the PR or upgrade to it yourself) You can disable automated security fix PRs for this repo from the [Security Alerts page](https://github.com/zitadel/zitadel/network/alerts).
Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Max Peintner --- console/yarn.lock | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/console/yarn.lock b/console/yarn.lock index a820c97d73..74e8599a93 100644 --- a/console/yarn.lock +++ b/console/yarn.lock @@ -6042,9 +6042,9 @@ http-proxy-agent@^5.0.0: debug "4" http-proxy-middleware@^2.0.3: - version "2.0.6" - resolved "https://registry.yarnpkg.com/http-proxy-middleware/-/http-proxy-middleware-2.0.6.tgz#e1a4dd6979572c7ab5a4e4b55095d1f32a74963f" - integrity sha512-ya/UeJ6HVBYxrgYotAZo1KvPWlgB48kUJLDePFeneHsVujFaW5WNj2NgWCAE//B1Dl02BIfYlpNgBy8Kf8Rjmw== + version "2.0.7" + resolved "https://registry.yarnpkg.com/http-proxy-middleware/-/http-proxy-middleware-2.0.7.tgz#915f236d92ae98ef48278a95dedf17e991936ec6" + integrity sha512-fgVY8AV7qU7z/MmXJ/rxwbrtQH4jBQ9m7kp3llF0liB7glmFeVZFBepQb32T3y8n8k2+AEYuMPCpinYW+/CuRA== dependencies: "@types/http-proxy" "^1.17.8" http-proxy "^1.18.1" From 522c82876f4360135bdf4b772ac03669fd9c8e26 Mon Sep 17 00:00:00 2001 From: Silvan Date: Mon, 18 Nov 2024 16:30:12 +0100 Subject: [PATCH 14/64] fix(eventstore): set application name during push to instance id (#8918) # Which Problems Are Solved Noisy neighbours can introduce projection latencies because the projections only query events older than the start timestamp of the oldest push transaction. # How the Problems Are Solved During push we set the application name to `zitadel_es_pusher_` instead of `zitadel_es_pusher` which is used to query events by projections. --- internal/eventstore/repository/sql/crdb.go | 9 ++-- internal/eventstore/repository/sql/query.go | 12 +++++ .../eventstore/repository/sql/query_test.go | 44 +++++++++---------- internal/eventstore/v3/push.go | 18 ++++++++ 4 files changed, 56 insertions(+), 27 deletions(-) diff --git a/internal/eventstore/repository/sql/crdb.go b/internal/eventstore/repository/sql/crdb.go index 1b3e17377c..68610676c3 100644 --- a/internal/eventstore/repository/sql/crdb.go +++ b/internal/eventstore/repository/sql/crdb.go @@ -15,7 +15,6 @@ import ( "github.com/zitadel/zitadel/internal/api/authz" "github.com/zitadel/zitadel/internal/database" - "github.com/zitadel/zitadel/internal/database/dialect" "github.com/zitadel/zitadel/internal/eventstore" "github.com/zitadel/zitadel/internal/eventstore/repository" "github.com/zitadel/zitadel/internal/telemetry/tracing" @@ -125,11 +124,11 @@ type CRDB struct { func NewCRDB(client *database.DB) *CRDB { switch client.Type() { case "cockroach": - awaitOpenTransactionsV1 = " AND creation_date::TIMESTAMP < (SELECT COALESCE(MIN(start), NOW())::TIMESTAMP FROM crdb_internal.cluster_transactions where application_name = '" + dialect.EventstorePusherAppName + "')" - awaitOpenTransactionsV2 = ` AND hlc_to_timestamp("position") < (SELECT COALESCE(MIN(start), NOW())::TIMESTAMP FROM crdb_internal.cluster_transactions where application_name = '` + dialect.EventstorePusherAppName + `')` + awaitOpenTransactionsV1 = " AND creation_date::TIMESTAMP < (SELECT COALESCE(MIN(start), NOW())::TIMESTAMP FROM crdb_internal.cluster_transactions where application_name = ANY(?))" + awaitOpenTransactionsV2 = ` AND hlc_to_timestamp("position") < (SELECT COALESCE(MIN(start), NOW())::TIMESTAMP FROM crdb_internal.cluster_transactions where application_name = ANY(?))` case "postgres": - awaitOpenTransactionsV1 = ` AND EXTRACT(EPOCH FROM created_at) < (SELECT COALESCE(EXTRACT(EPOCH FROM min(xact_start)), EXTRACT(EPOCH FROM now())) FROM pg_stat_activity WHERE datname = current_database() AND application_name = '` + dialect.EventstorePusherAppName + `' AND state <> 'idle')` - awaitOpenTransactionsV2 = ` AND "position" < (SELECT COALESCE(EXTRACT(EPOCH FROM min(xact_start)), EXTRACT(EPOCH FROM now())) FROM pg_stat_activity WHERE datname = current_database() AND application_name = '` + dialect.EventstorePusherAppName + `' AND state <> 'idle')` + awaitOpenTransactionsV1 = ` AND EXTRACT(EPOCH FROM created_at) < (SELECT COALESCE(EXTRACT(EPOCH FROM min(xact_start)), EXTRACT(EPOCH FROM now())) FROM pg_stat_activity WHERE datname = current_database() AND application_name = ANY(?) AND state <> 'idle')` + awaitOpenTransactionsV2 = ` AND "position" < (SELECT COALESCE(EXTRACT(EPOCH FROM min(xact_start)), EXTRACT(EPOCH FROM now())) FROM pg_stat_activity WHERE datname = current_database() AND application_name = ANY(?) AND state <> 'idle')` } return &CRDB{client} diff --git a/internal/eventstore/repository/sql/query.go b/internal/eventstore/repository/sql/query.go index bbc9513864..8ecea6e3d6 100644 --- a/internal/eventstore/repository/sql/query.go +++ b/internal/eventstore/repository/sql/query.go @@ -272,7 +272,19 @@ func prepareConditions(criteria querier, query *repository.SearchQuery, useV1 bo } if query.AwaitOpenTransactions { + instanceIDs := make(database.TextArray[string], 0, 3) + if query.InstanceID != nil { + instanceIDs = append(instanceIDs, query.InstanceID.Value.(string)) + } else if query.InstanceIDs != nil { + instanceIDs = append(instanceIDs, query.InstanceIDs.Value.(database.TextArray[string])...) + } + + for i := range instanceIDs { + instanceIDs[i] = dialect.DBPurposeEventPusher.AppName() + "_" + instanceIDs[i] + } + clauses += awaitOpenTransactions(useV1) + args = append(args, instanceIDs) } if clauses == "" { diff --git a/internal/eventstore/repository/sql/query_test.go b/internal/eventstore/repository/sql/query_test.go index 906c153deb..2956c39bd5 100644 --- a/internal/eventstore/repository/sql/query_test.go +++ b/internal/eventstore/repository/sql/query_test.go @@ -402,8 +402,8 @@ func Test_prepareCondition(t *testing.T) { useV1: true, }, res: res{ - clause: " WHERE aggregate_type = ANY(?) AND creation_date::TIMESTAMP < (SELECT COALESCE(MIN(start), NOW())::TIMESTAMP FROM crdb_internal.cluster_transactions where application_name = 'zitadel_es_pusher')", - values: []interface{}{[]eventstore.AggregateType{"user", "org"}}, + clause: " WHERE aggregate_type = ANY(?) AND creation_date::TIMESTAMP < (SELECT COALESCE(MIN(start), NOW())::TIMESTAMP FROM crdb_internal.cluster_transactions where application_name = ANY(?))", + values: []interface{}{[]eventstore.AggregateType{"user", "org"}, database.TextArray[string]{}}, }, }, { @@ -419,8 +419,8 @@ func Test_prepareCondition(t *testing.T) { }, }, res: res{ - clause: ` WHERE aggregate_type = ANY(?) AND hlc_to_timestamp("position") < (SELECT COALESCE(MIN(start), NOW())::TIMESTAMP FROM crdb_internal.cluster_transactions where application_name = 'zitadel_es_pusher')`, - values: []interface{}{[]eventstore.AggregateType{"user", "org"}}, + clause: ` WHERE aggregate_type = ANY(?) AND hlc_to_timestamp("position") < (SELECT COALESCE(MIN(start), NOW())::TIMESTAMP FROM crdb_internal.cluster_transactions where application_name = ANY(?))`, + values: []interface{}{[]eventstore.AggregateType{"user", "org"}, database.TextArray[string]{}}, }, }, { @@ -439,8 +439,8 @@ func Test_prepareCondition(t *testing.T) { useV1: true, }, res: res{ - clause: " WHERE aggregate_type = ANY(?) AND aggregate_id = ? AND event_type = ANY(?) AND creation_date::TIMESTAMP < (SELECT COALESCE(MIN(start), NOW())::TIMESTAMP FROM crdb_internal.cluster_transactions where application_name = 'zitadel_es_pusher')", - values: []interface{}{[]eventstore.AggregateType{"user", "org"}, "1234", []eventstore.EventType{"user.created", "org.created"}}, + clause: " WHERE aggregate_type = ANY(?) AND aggregate_id = ? AND event_type = ANY(?) AND creation_date::TIMESTAMP < (SELECT COALESCE(MIN(start), NOW())::TIMESTAMP FROM crdb_internal.cluster_transactions where application_name = ANY(?))", + values: []interface{}{[]eventstore.AggregateType{"user", "org"}, "1234", []eventstore.EventType{"user.created", "org.created"}, database.TextArray[string]{}}, }, }, { @@ -458,8 +458,8 @@ func Test_prepareCondition(t *testing.T) { }, }, res: res{ - clause: ` WHERE aggregate_type = ANY(?) AND aggregate_id = ? AND event_type = ANY(?) AND hlc_to_timestamp("position") < (SELECT COALESCE(MIN(start), NOW())::TIMESTAMP FROM crdb_internal.cluster_transactions where application_name = 'zitadel_es_pusher')`, - values: []interface{}{[]eventstore.AggregateType{"user", "org"}, "1234", []eventstore.EventType{"user.created", "org.created"}}, + clause: ` WHERE aggregate_type = ANY(?) AND aggregate_id = ? AND event_type = ANY(?) AND hlc_to_timestamp("position") < (SELECT COALESCE(MIN(start), NOW())::TIMESTAMP FROM crdb_internal.cluster_transactions where application_name = ANY(?))`, + values: []interface{}{[]eventstore.AggregateType{"user", "org"}, "1234", []eventstore.EventType{"user.created", "org.created"}, database.TextArray[string]{}}, }, }, } @@ -687,8 +687,8 @@ func Test_query_events_mocked(t *testing.T) { }, fields: fields{ mock: newMockClient(t).expectQuery(t, - `SELECT creation_date, event_type, event_sequence, event_data, editor_user, resource_owner, instance_id, aggregate_type, aggregate_id, aggregate_version FROM eventstore.events WHERE aggregate_type = \$1 AND creation_date::TIMESTAMP < \(SELECT COALESCE\(MIN\(start\), NOW\(\)\)::TIMESTAMP FROM crdb_internal\.cluster_transactions where application_name = 'zitadel_es_pusher'\) ORDER BY event_sequence DESC`, - []driver.Value{eventstore.AggregateType("user")}, + `SELECT creation_date, event_type, event_sequence, event_data, editor_user, resource_owner, instance_id, aggregate_type, aggregate_id, aggregate_version FROM eventstore.events WHERE aggregate_type = \$1 AND creation_date::TIMESTAMP < \(SELECT COALESCE\(MIN\(start\), NOW\(\)\)::TIMESTAMP FROM crdb_internal\.cluster_transactions where application_name = ANY\(\$2\)\) ORDER BY event_sequence DESC`, + []driver.Value{eventstore.AggregateType("user"), database.TextArray[string]{}}, ), }, res: res{ @@ -709,8 +709,8 @@ func Test_query_events_mocked(t *testing.T) { }, fields: fields{ mock: newMockClient(t).expectQuery(t, - `SELECT creation_date, event_type, event_sequence, event_data, editor_user, resource_owner, instance_id, aggregate_type, aggregate_id, aggregate_version FROM eventstore.events WHERE aggregate_type = \$1 AND creation_date::TIMESTAMP < \(SELECT COALESCE\(MIN\(start\), NOW\(\)\)::TIMESTAMP FROM crdb_internal\.cluster_transactions where application_name = 'zitadel_es_pusher'\) ORDER BY event_sequence LIMIT \$2`, - []driver.Value{eventstore.AggregateType("user"), uint64(5)}, + `SELECT creation_date, event_type, event_sequence, event_data, editor_user, resource_owner, instance_id, aggregate_type, aggregate_id, aggregate_version FROM eventstore.events WHERE aggregate_type = \$1 AND creation_date::TIMESTAMP < \(SELECT COALESCE\(MIN\(start\), NOW\(\)\)::TIMESTAMP FROM crdb_internal\.cluster_transactions where application_name = ANY\(\$2\)\) ORDER BY event_sequence LIMIT \$3`, + []driver.Value{eventstore.AggregateType("user"), database.TextArray[string]{}, uint64(5)}, ), }, res: res{ @@ -731,8 +731,8 @@ func Test_query_events_mocked(t *testing.T) { }, fields: fields{ mock: newMockClient(t).expectQuery(t, - `SELECT creation_date, event_type, event_sequence, event_data, editor_user, resource_owner, instance_id, aggregate_type, aggregate_id, aggregate_version FROM eventstore.events WHERE aggregate_type = \$1 AND creation_date::TIMESTAMP < \(SELECT COALESCE\(MIN\(start\), NOW\(\)\)::TIMESTAMP FROM crdb_internal\.cluster_transactions where application_name = 'zitadel_es_pusher'\) ORDER BY event_sequence DESC LIMIT \$2`, - []driver.Value{eventstore.AggregateType("user"), uint64(5)}, + `SELECT creation_date, event_type, event_sequence, event_data, editor_user, resource_owner, instance_id, aggregate_type, aggregate_id, aggregate_version FROM eventstore.events WHERE aggregate_type = \$1 AND creation_date::TIMESTAMP < \(SELECT COALESCE\(MIN\(start\), NOW\(\)\)::TIMESTAMP FROM crdb_internal\.cluster_transactions where application_name = ANY\(\$2\)\) ORDER BY event_sequence DESC LIMIT \$3`, + []driver.Value{eventstore.AggregateType("user"), database.TextArray[string]{}, uint64(5)}, ), }, res: res{ @@ -754,8 +754,8 @@ func Test_query_events_mocked(t *testing.T) { }, fields: fields{ mock: newMockClient(t).expectQuery(t, - `SELECT creation_date, event_type, event_sequence, event_data, editor_user, resource_owner, instance_id, aggregate_type, aggregate_id, aggregate_version FROM eventstore.events AS OF SYSTEM TIME '-1 ms' WHERE aggregate_type = \$1 AND creation_date::TIMESTAMP < \(SELECT COALESCE\(MIN\(start\), NOW\(\)\)::TIMESTAMP FROM crdb_internal\.cluster_transactions where application_name = 'zitadel_es_pusher'\) ORDER BY event_sequence DESC LIMIT \$2`, - []driver.Value{eventstore.AggregateType("user"), uint64(5)}, + `SELECT creation_date, event_type, event_sequence, event_data, editor_user, resource_owner, instance_id, aggregate_type, aggregate_id, aggregate_version FROM eventstore.events AS OF SYSTEM TIME '-1 ms' WHERE aggregate_type = \$1 AND creation_date::TIMESTAMP < \(SELECT COALESCE\(MIN\(start\), NOW\(\)\)::TIMESTAMP FROM crdb_internal\.cluster_transactions where application_name = ANY\(\$2\)\) ORDER BY event_sequence DESC LIMIT \$3`, + []driver.Value{eventstore.AggregateType("user"), database.TextArray[string]{}, uint64(5)}, ), }, res: res{ @@ -776,8 +776,8 @@ func Test_query_events_mocked(t *testing.T) { }, fields: fields{ mock: newMockClient(t).expectQueryErr(t, - `SELECT creation_date, event_type, event_sequence, event_data, editor_user, resource_owner, instance_id, aggregate_type, aggregate_id, aggregate_version FROM eventstore.events WHERE aggregate_type = \$1 AND creation_date::TIMESTAMP < \(SELECT COALESCE\(MIN\(start\), NOW\(\)\)::TIMESTAMP FROM crdb_internal\.cluster_transactions where application_name = 'zitadel_es_pusher'\) ORDER BY event_sequence DESC`, - []driver.Value{eventstore.AggregateType("user")}, + `SELECT creation_date, event_type, event_sequence, event_data, editor_user, resource_owner, instance_id, aggregate_type, aggregate_id, aggregate_version FROM eventstore.events WHERE aggregate_type = \$1 AND creation_date::TIMESTAMP < \(SELECT COALESCE\(MIN\(start\), NOW\(\)\)::TIMESTAMP FROM crdb_internal\.cluster_transactions where application_name = ANY\(\$2\)\) ORDER BY event_sequence DESC`, + []driver.Value{eventstore.AggregateType("user"), database.TextArray[string]{}}, sql.ErrConnDone), }, res: res{ @@ -798,8 +798,8 @@ func Test_query_events_mocked(t *testing.T) { }, fields: fields{ mock: newMockClient(t).expectQueryScanErr(t, - `SELECT creation_date, event_type, event_sequence, event_data, editor_user, resource_owner, instance_id, aggregate_type, aggregate_id, aggregate_version FROM eventstore.events WHERE aggregate_type = \$1 AND creation_date::TIMESTAMP < \(SELECT COALESCE\(MIN\(start\), NOW\(\)\)::TIMESTAMP FROM crdb_internal\.cluster_transactions where application_name = 'zitadel_es_pusher'\) ORDER BY event_sequence DESC`, - []driver.Value{eventstore.AggregateType("user")}, + `SELECT creation_date, event_type, event_sequence, event_data, editor_user, resource_owner, instance_id, aggregate_type, aggregate_id, aggregate_version FROM eventstore.events WHERE aggregate_type = \$1 AND creation_date::TIMESTAMP < \(SELECT COALESCE\(MIN\(start\), NOW\(\)\)::TIMESTAMP FROM crdb_internal\.cluster_transactions where application_name = ANY\(\$2\)\) ORDER BY event_sequence DESC`, + []driver.Value{eventstore.AggregateType("user"), database.TextArray[string]{}}, &repository.Event{Seq: 100}), }, res: res{ @@ -832,8 +832,8 @@ func Test_query_events_mocked(t *testing.T) { }, fields: fields{ mock: newMockClient(t).expectQuery(t, - `SELECT creation_date, event_type, event_sequence, event_data, editor_user, resource_owner, instance_id, aggregate_type, aggregate_id, aggregate_version FROM eventstore.events WHERE \(aggregate_type = \$1 OR \(aggregate_type = \$2 AND aggregate_id = \$3\)\) AND creation_date::TIMESTAMP < \(SELECT COALESCE\(MIN\(start\), NOW\(\)\)::TIMESTAMP FROM crdb_internal\.cluster_transactions where application_name = 'zitadel_es_pusher'\) ORDER BY event_sequence DESC LIMIT \$4`, - []driver.Value{eventstore.AggregateType("user"), eventstore.AggregateType("org"), "asdf42", uint64(5)}, + `SELECT creation_date, event_type, event_sequence, event_data, editor_user, resource_owner, instance_id, aggregate_type, aggregate_id, aggregate_version FROM eventstore.events WHERE \(aggregate_type = \$1 OR \(aggregate_type = \$2 AND aggregate_id = \$3\)\) AND creation_date::TIMESTAMP < \(SELECT COALESCE\(MIN\(start\), NOW\(\)\)::TIMESTAMP FROM crdb_internal\.cluster_transactions where application_name = ANY\(\$4\)\) ORDER BY event_sequence DESC LIMIT \$5`, + []driver.Value{eventstore.AggregateType("user"), eventstore.AggregateType("org"), "asdf42", database.TextArray[string]{}, uint64(5)}, ), }, res: res{ diff --git a/internal/eventstore/v3/push.go b/internal/eventstore/v3/push.go index f09c3de515..47a4c96dca 100644 --- a/internal/eventstore/v3/push.go +++ b/internal/eventstore/v3/push.go @@ -13,11 +13,15 @@ import ( "github.com/jackc/pgx/v5/pgconn" "github.com/zitadel/logging" + "github.com/zitadel/zitadel/internal/api/authz" + "github.com/zitadel/zitadel/internal/database/dialect" "github.com/zitadel/zitadel/internal/eventstore" "github.com/zitadel/zitadel/internal/telemetry/tracing" "github.com/zitadel/zitadel/internal/zerrors" ) +var appNamePrefix = dialect.DBPurposeEventPusher.AppName() + "_" + func (es *Eventstore) Push(ctx context.Context, commands ...eventstore.Command) (events []eventstore.Event, err error) { ctx, span := tracing.NewSpan(ctx) defer func() { span.EndWithError(err) }() @@ -31,6 +35,20 @@ func (es *Eventstore) Push(ctx context.Context, commands ...eventstore.Command) sequences []*latestSequence ) + // needs to be set like this because psql complains about parameters in the SET statement + _, err = tx.ExecContext(ctx, "SET application_name = '"+appNamePrefix+authz.GetInstance(ctx).InstanceID()+"'") + if err != nil { + logging.WithError(err).Warn("failed to set application name") + return nil, err + } + + // needs to be set like this because psql complains about parameters in the SET statement + _, err = tx.ExecContext(ctx, "SET application_name = '"+appNamePrefix+authz.GetInstance(ctx).InstanceID()+"'") + if err != nil { + logging.WithError(err).Warn("failed to set application name") + return nil, err + } + err = crdb.ExecuteInTx(ctx, &transaction{tx}, func() (err error) { inTxCtx, span := tracing.NewSpan(ctx) defer func() { span.EndWithError(err) }() From 65e24b67dae6d9ab6eae600f8fd6fa0e794f0364 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tim=20M=C3=B6hlmann?= Date: Tue, 19 Nov 2024 10:53:07 +0200 Subject: [PATCH 15/64] chore(load-test): disable userinfo after JWT profile (#8927) # Which Problems Are Solved Load-test requires single endpoint to be used for each test type. # How the Problems Are Solved Remove userinfo call from machine tests. # Additional Changes - Add load-test/.env to gitignore. # Additional Context - Related to #4424 --- load-test/.gitignore | 2 ++ load-test/src/use_cases/machine_jwt_profile_grant.ts | 3 --- .../src/use_cases/machine_jwt_profile_grant_single_user.ts | 3 --- 3 files changed, 2 insertions(+), 6 deletions(-) create mode 100644 load-test/.gitignore diff --git a/load-test/.gitignore b/load-test/.gitignore new file mode 100644 index 0000000000..30bd623c23 --- /dev/null +++ b/load-test/.gitignore @@ -0,0 +1,2 @@ +.env + diff --git a/load-test/src/use_cases/machine_jwt_profile_grant.ts b/load-test/src/use_cases/machine_jwt_profile_grant.ts index 084ac4f684..2511f9e2a5 100644 --- a/load-test/src/use_cases/machine_jwt_profile_grant.ts +++ b/load-test/src/use_cases/machine_jwt_profile_grant.ts @@ -46,9 +46,6 @@ export async function setup() { export default function (data: any) { token(new JWTProfileRequest(data.machines[__VU - 1].userId, data.machines[__VU - 1].keyId)) - .then((token) => { - userinfo(token.accessToken!) - }) } export function teardown(data: any) { diff --git a/load-test/src/use_cases/machine_jwt_profile_grant_single_user.ts b/load-test/src/use_cases/machine_jwt_profile_grant_single_user.ts index c654fb9492..95437b0c97 100644 --- a/load-test/src/use_cases/machine_jwt_profile_grant_single_user.ts +++ b/load-test/src/use_cases/machine_jwt_profile_grant_single_user.ts @@ -24,9 +24,6 @@ export async function setup() { export default function (data: any) { token(new JWTProfileRequest(data.machine.userId, data.machine.keyId)) - .then((token) => { - userinfo(token.accessToken!) - }) } export function teardown(data: any) { From c31b5df73b5c7e4161750360f2bc1996eccf075f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tim=20M=C3=B6hlmann?= Date: Tue, 19 Nov 2024 11:56:10 +0200 Subject: [PATCH 16/64] docs: add caches documentation (#8902) # Which Problems Are Solved Explain the usage of the new cache mechanisms. # How the Problems Are Solved Provide a dedicated page on caches with reference to `defaults.yaml`. # Additional Changes - Fix a broken link tag in token exchange docs. # Additional Context - Closes #8855 --- docs/docs/guides/integrate/token-exchange.mdx | 2 +- docs/docs/self-hosting/manage/cache.md | 233 ++++++++++++++++++ docs/sidebars.js | 1 + 3 files changed, 235 insertions(+), 1 deletion(-) create mode 100644 docs/docs/self-hosting/manage/cache.md diff --git a/docs/docs/guides/integrate/token-exchange.mdx b/docs/docs/guides/integrate/token-exchange.mdx index ab3ee26f48..00fda4f17b 100644 --- a/docs/docs/guides/integrate/token-exchange.mdx +++ b/docs/docs/guides/integrate/token-exchange.mdx @@ -10,7 +10,7 @@ import TokenExchangeResponse from "../../apis/openidoauth/_token_exchange_respon The Token Exchange grant implements [RFC 8693, OAuth 2.0 Token Exchange](https://www.rfc-editor.org/rfc/rfc8693) and can be used to exchange tokens to a different scope, audience or subject. Changing the subject of an authenticated token is called impersonation or delegation. This guide will explain how token exchange is implemented inside ZITADEL and gives some usage examples. :::info -Token Exchange is currently an experimental beta](/docs/support/software-release-cycles-support#beta) feature. Be sure to enable it on the [feature API](#feature-api) before using it. +Token Exchange is currently an [experimental beta](/docs/support/software-release-cycles-support#beta) feature. Be sure to enable it on the [feature API](#feature-api) before using it. ::: In this guide we assume that the application performing the token exchange is already in possession of tokens. You should already have a good understanding on the following topics before starting with this guide: diff --git a/docs/docs/self-hosting/manage/cache.md b/docs/docs/self-hosting/manage/cache.md new file mode 100644 index 0000000000..2de0b43faa --- /dev/null +++ b/docs/docs/self-hosting/manage/cache.md @@ -0,0 +1,233 @@ +--- +title: Caches +sidebar_label: Caches +--- + +ZITADEL supports the use of a caches to speed up the lookup of frequently needed objects. As opposed to HTTP caches which might reside between ZITADEL and end-user applications, the cache build into ZITADEL uses active invalidation when an object gets updated. Another difference is that HTTP caches only cache the result of a complete request and the built-in cache stores objects needed for the internal business logic. For example, each request made to ZITADEL needs to retrieve and set [instance](/docs/concepts/structure/instance) information in middleware. + +:::info +Caches is currently an [experimental beta](/docs/support/software-release-cycles-support#beta) feature. +::: + +## Configuration + +The `Caches` configuration entry defines *connectors* which can be used by several objects. It is possible to mix *connectors* with different objects based on operational needs. + +```yaml +Caches: + Connectors: + SomeConnector: + Enabled: true + SomeOption: foo + SomeObject: + # Connector must be enabled above. + # When connector is empty, this cache will be disabled. + Connector: "SomeConnector" + MaxAge: 1h + LastUsage: 10m + # Log enables cache-specific logging. Default to error log to stderr when omitted. + Log: + Level: error +``` + +For a full configuration reference, please see the [runtime configuration file](/docs/self-hosting/manage/configure#runtime-configuration-file) section's `defaults.yaml`. + +## Connectors + +ZITADEL supports a number of *connectors*. Connectors integrate a cache with a storage backend. Users can combine connectors with the type of object cache depending on their operational and performance requirements. +When no connector is specified for an object cache, then no caching is performed. This is the current default. + +### Auto prune + +Some connectors take an `AutoPrune` option. This is provided for caches which don't have built-in expiry and cleanup routines. The auto pruner is a routine launched by ZITADEL and scans and removes outdated objects in the cache. Pruning can take a cost as they typically involve some kind of scan. However, using a long interval can cause higher storage utilization. + +```yaml +Caches: + Connectors: + Memory: + Enabled: true + # AutoPrune removes invalidated or expired object from the cache. + AutoPrune: + Interval: 1m + TimeOut: 5s +``` + +### Redis cache + +Redis is supported in simple mode. Cluster and Sentinel are not yet supported. There is also a circuit-breaker provided which prevents a single point of failure, should the single Redis instance become unavailable. + +Benefits: + +- Centralized cache with single source of truth +- Consistent invalidation +- Very fast when network latency is kept to a minimum +- Built-in object expiry, no pruner required + +Drawbacks: + +- Increased operational overhead: need to run a Redis instance as part of your infrastructure. +- When running multiple servers of ZITADEL in different regions, network roundtrip time might impact performance, neutralizing the benefit of a cache. + +#### Circuit breaker + +A [circuit breaker](https://learn.microsoft.com/en-us/previous-versions/msp-n-p/dn589784(v=pandp.10)?redirectedfrom=MSDN) is provided for the Redis connector, to prevent a single point of failure in the case persistent errors. When the circuit breaker opens, the cache is temporary disabled and ignored. ZITADEL will continue to operate using queries to the database. + +```yaml +Caches: + Connectors: + Redis: + Enabled: true + Addr: localhost:6379 + # Many other options... + CircuitBreaker: + # Interval when the counters are reset to 0. + # 0 interval never resets the counters until the CB is opened. + Interval: 0 + # Amount of consecutive failures permitted + MaxConsecutiveFailures: 5 + # The ratio of failed requests out of total requests + MaxFailureRatio: 0.1 + # Timeout after opening of the CB, until the state is set to half-open. + Timeout: 60s + # The allowed amount of requests that are allowed to pass when the CB is half-open. + MaxRetryRequests: 1 +``` + +### PostgreSQL cache + +PostgreSQL can be used to store objects in unlogged tables. [Unlogged tables](https://www.postgresql.org/docs/current/sql-createtable.html#SQL-CREATETABLE-UNLOGGED) do not write to the WAL log and are therefore faster than regular tables. If the PostgreSQL server crashes, the data from those tables are lost. ZITADEL always creates the cache schema in the `zitadel` database during [setup](./updating_scaling#the-setup-phase). This connector requires a [pruner](#auto-prune) routine. + +Benefits: + +- Centralized cache with single source of truth +- No operational overhead. Reuses the query connection pool and the existing `zitadel` database. +- Consistent invalidation +- Faster than regular queries which often contain `JOIN` clauses. + +Drawbacks: + +- Slowest of the available caching options +- Might put additional strain on the database server, limiting horizontal scalability +- CockroachDB does not support unlogged tables. When this connector is enabled against CockroachDB, it does work but little to no performance benefit is to be expected. + +### Local memory cache + +ZITADEL is capable of caching object in local application memory, using hash-maps. Each ZITADEL server manages its own copy of the cache. This connector requires a [pruner](#auto-prune) routine. + +Benefits: + +- Fastest of the available caching options +- No operational overhead + +Drawbacks: + +- Inconsistent invalidation. An object validated in one ZITADEL server will not get invalidated in other servers. +- There's no single source of truth. Different servers may operate on a different version of an object +- Data is duplicated in each server, consuming more total memory inside a deployment. + +The drawbacks restricts its usefulness in distributed deployments. However simple installations running a single server can benefit greatly from this type of cache. For example test, development or home deployments. +If inconsistency is acceptable for short periods of time, one can choose to use this type of cache in distributed deployments with short max age configuration. + +**For example**: A ZITADEL deployment with 2 servers is serving 1000 req/sec total. The installation only has one instance[^1]. There is only a small amount of data cached (a few kB) so duplication is not a problem in this case. It is acceptable for [instance level setting](/docs/guides/manage/console/default-settings) to be out-dated for a short amount of time. When the memory cache is enabled for the instance objects, with a max age of 1 second, the instance only needs to be obtained from the database 2 times per second (once for each server). Saving 998 of redundant queries. Once an instance level setting is changed, it takes up to 1 second for all the servers to get the new state. + +## Objects + +The following section describes the type of objects ZITADEL can currently cache. Objects are actively invalidated at the cache backend when one of their properties is changed. Each object cache defines: + +- `Connector`: Selects the used [connector](#connectors) back-end. Must be activated first. +- `MaxAge`: the amount of time that an object is considered valid. When this age is passed the object is ignored (cache miss) and possibly cleaned up by the [pruner](#auto-prune) or other built-in garbage collection. +- `LastUsage`: defines usage based lifetime. Each time an object is used, its usage timestamp is updated. Popular objects remain cached, while unused objects are cleaned up. This option can be used to indirectly limit the size of the cache. +- `Log`: allows specific log settings for the cache. This can be used to debug a certain cache without having to change the global log level. + +```yaml +Caches: + SomeObject: + # Connector must be enabled above. + # When connector is empty, this cache will be disabled. + Connector: "" + MaxAge: 1h + LastUsage: 10m + # Log enables cache-specific logging. Default to error log to stderr when omitted. + Log: + Level: error + AddSource: true + Formatter: + Format: text +``` + +### Instance + +All HTTP and gRPC requests sent to ZITADEL receive an instance context. The instance is usually resolved by the domain from the request. In some cases, like the [system service](/docs/apis/resources/system/system-service), the instance can be resolved by its ID. An instance object contains many of the [default settings](/docs/guides/manage/console/default-settings): + +- Instance [features](/docs/guides/manage/console/default-settings#features) +- Instance domains: generated and [custom](/docs/guides/manage/cloud/instances#add-custom-domain) +- [Trusted domains](/docs/apis/resources/admin/admin-service-add-instance-trusted-domain) +- Security settings ([IFrame policy](/docs/guides/solution-scenarios/configurations#embedding-zitadel-in-an-iframe)) +- Limits[^2] +- [Allowed languages](/docs/guides/manage/console/default-settings#languages) + +These settings typically change infrequently in production. ***Every*** request made to ZITADEL needs to query for the instance. This is a typical case of set once, get many times where a cache can provide a significant optimization. + +### Milestones + +Milestones are used to track the administrator's progress in setting up their instance. Milestones are used to render *your next steps* in the [console](/docs/guides/manage/console/overview) landing page. +Milestones are reached upon the first time a certain action is performed. For example the first application created or the first human login. In order to push a "reached" event only once, ZITADEL must keep track of the current state of milestones by an eventstore query every time an eligible action is performed. This can cause an unwanted overhead on production servers, therefore they are cached. + +As an extra optimization, once all milestones are reached by the instance, an in-memory flag is set and the milestone state is never queried again from the database nor cache. +For single instance setups which fulfilled all milestone (*your next steps* in console) it is not needed to enable this cache. We mainly use it for ZITADEL cloud where there are many instances with *incomplete* milestones. + +## Examples + +Currently caches are in beta and disabled by default. However, if you want to give caching a try, the following sections contains some suggested configurations for different setups. + +The following configuration is recommended for single instance setups with a single ZITADEL server: + +```yaml +Caches: + Memory: + Enabled: true + Instance: + Connector: "memory" + MaxAge: 1h +``` + +The following configuration is recommended for single instance setups with high traffic on multiple servers, where Redis is not available: + +```yaml +Caches: + Memory: + Enabled: true + Postgres: + Enabled: true + Instance: + Connector: "memory" + MaxAge: 1s + Milestones: + Connector: "postgres" + MaxAge: 1h + LastUsage: 10m +``` + +When running many instances on multiple servers: + +```yaml +Caches: + Connectors: + Redis: + Enabled: true + # Other connection options + + Instance: + Connector: "redis" + MaxAge: 1h + LastUsage: 10m + Milestones: + Connector: "redis" + MaxAge: 1h + LastUsage: 10m +``` +---- + +[^1]: Many deployments of ZITADEL have only one or few [instances](/docs/concepts/structure/instance). Multiple instances are mostly used for ZITADEL cloud, where each customer gets at least one instance. + +[^2]: Limits are imposed by the system API, usually when customers exceed their subscription in ZITADEL cloud. \ No newline at end of file diff --git a/docs/sidebars.js b/docs/sidebars.js index be96b61706..ed75894399 100644 --- a/docs/sidebars.js +++ b/docs/sidebars.js @@ -913,6 +913,7 @@ module.exports = { "self-hosting/manage/http2", "self-hosting/manage/tls_modes", "self-hosting/manage/database/database", + "self-hosting/manage/cache", "self-hosting/manage/updating_scaling", "self-hosting/manage/usage_control", { From 041c3d9b9e41594c2c05a55739ba50decf06e72f Mon Sep 17 00:00:00 2001 From: zitadelraccine Date: Tue, 19 Nov 2024 07:00:58 -0500 Subject: [PATCH 17/64] docs: add next office hours (#8922) # Which Problems Are Solved Updating the meeting schedule with the latest community event. # How the Problems Are Solved A new event invite with associated details are added to direct community members on Github to register for our Discord event. # Additional Changes N/A # Additional Context N/A Co-authored-by: Silvan --- MEETING_SCHEDULE.md | 29 +++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/MEETING_SCHEDULE.md b/MEETING_SCHEDULE.md index 695a19855f..8d59970611 100644 --- a/MEETING_SCHEDULE.md +++ b/MEETING_SCHEDULE.md @@ -3,6 +3,35 @@ Dear community! We're excited to announce bi-weekly office hours. +## #6 Q&A + +Hey folks! + +We’re inviting you to our next open office hours session! C: From leveraging ZITADEL actions to exploring your use cases, join our hosts Silvan & Stefan on Wednesday, November 20, 2024 at 11:00 AM (EST) as they answer your questions about ZITADEL! + +🦒 **What to expect** + +An open Q&A session - Share your questions and support others with their inquiries. +A space to share your thoughts / feedback on the ZITADEL platform + +🗒️ **Details** + +Target audience: All ZITADEL platform users & community members +Topic: Q&A Session +Date & time: Wednesday, November 20, 2024 at 11:00 AM (EST) +Duration: ~1 hour +Platform: ZITADEL’s Discord stage channel + +Register for this event here ➡️ https://discord.gg/bnuAe2RX?event=1307010383713927230 + +🗓️ **Add this to your calendar** ➡️ [Google Calendar](https://calendar.google.com/calendar/u/0/r/eventedit?dates=20241120T110000/20241120T110000&details=We%E2%80%99re+inviting+you+to+our+next+open+office+hours+session!+C:+From+leveraging+ZITADEL+actions+to+exploring+your+use+cases,+join+our+hosts+Silvan+%26+Stefan+as+they+answer+your+questions+about+ZITADEL!+%0A%0A**What+to+expect**%0A%0A-+An+open+Q%26A+session+-+Share+your+questions+and+support+others+with+their+inquiries.+%0A-+A+space+to+share+your+thoughts+/+feedback+on+the+ZITADEL+platform+++%0A%0A**Details**+%0A%0A**Target+audience:**+All+ZITADEL+platform+users+%26+community+members%0A**Topic**:+Q%26A+Session+%0A**Date+%26+time**:+Wednesday,+November+20,+2024+11:00+AM%0A**Duration**:+~1+hour+%0A**Platform**:+ZITADEL%E2%80%99s+Discord+stage+channel&location=Discord:+ZITADEL+server,+office+hours&text=Open+Office+Hours) + + +If you have any questions prior to the live session, be sure to share them in the office hours stage chat + +Looking forward to seeing you there! Share this with other ZITADEL users & people who might be interested in ZITADEL! It’s appreciated 🫶 + + ## #5 Q&A Dear community, From c165ed07f41e64fdfa6360c7aa937eadf22a5fa3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tim=20M=C3=B6hlmann?= Date: Thu, 21 Nov 2024 08:05:03 +0200 Subject: [PATCH 18/64] feat(cache): organization (#8903) # Which Problems Are Solved Organizations are ofter searched for by ID or primary domain. This results in many redundant queries, resulting in a performance impact. # How the Problems Are Solved Cache Organizaion objects by ID and primary domain. # Additional Changes - Adjust integration test config to use all types of cache. - Adjust integration test lifetimes so the pruner has something to do while the tests run. # Additional Context - Closes #8865 - After #8902 --- cmd/defaults.yaml | 10 +++ docs/docs/self-hosting/manage/cache.md | 20 ++++++ internal/cache/cache.go | 1 + internal/cache/connector/connector.go | 5 +- internal/cache/purpose_enumer.go | 12 ++-- internal/integration/config/zitadel.yaml | 24 +++---- internal/query/cache.go | 7 ++ internal/query/instance.go | 4 +- internal/query/org.go | 46 ++++++++++++- internal/query/orgindex_enumer.go | 82 ++++++++++++++++++++++++ 10 files changed, 191 insertions(+), 20 deletions(-) create mode 100644 internal/query/orgindex_enumer.go diff --git a/cmd/defaults.yaml b/cmd/defaults.yaml index 4854356455..f0c8bedbeb 100644 --- a/cmd/defaults.yaml +++ b/cmd/defaults.yaml @@ -328,6 +328,16 @@ Caches: AddSource: true Formatter: Format: text + # Organization cache, gettable by primary domain or ID. + Organization: + Connector: "" + MaxAge: 1h + LastUsage: 10m + Log: + Level: error + AddSource: true + Formatter: + Format: text Machine: # Cloud-hosted VMs need to specify their metadata endpoint so that the machine can be uniquely identified. diff --git a/docs/docs/self-hosting/manage/cache.md b/docs/docs/self-hosting/manage/cache.md index 2de0b43faa..def2ece633 100644 --- a/docs/docs/self-hosting/manage/cache.md +++ b/docs/docs/self-hosting/manage/cache.md @@ -176,6 +176,16 @@ Milestones are reached upon the first time a certain action is performed. For ex As an extra optimization, once all milestones are reached by the instance, an in-memory flag is set and the milestone state is never queried again from the database nor cache. For single instance setups which fulfilled all milestone (*your next steps* in console) it is not needed to enable this cache. We mainly use it for ZITADEL cloud where there are many instances with *incomplete* milestones. +### Organization + +Most resources like users, project and applications are part of an [organization](/docs/concepts/structure/organizations). Therefore many parts of the ZITADEL logic search for an organization by ID or by their primary domain. +Organization objects are quite small and receive infrequent updates after they are created: + +- Change of organization name +- Deactivation / Reactivation +- Change of primary domain +- Removal + ## Examples Currently caches are in beta and disabled by default. However, if you want to give caching a try, the following sections contains some suggested configurations for different setups. @@ -189,6 +199,9 @@ Caches: Instance: Connector: "memory" MaxAge: 1h + Organization: + Connector: "memory" + MaxAge: 1h ``` The following configuration is recommended for single instance setups with high traffic on multiple servers, where Redis is not available: @@ -206,6 +219,9 @@ Caches: Connector: "postgres" MaxAge: 1h LastUsage: 10m + Organization: + Connector: "memory" + MaxAge: 1s ``` When running many instances on multiple servers: @@ -225,6 +241,10 @@ Caches: Connector: "redis" MaxAge: 1h LastUsage: 10m + Organization: + Connector: "redis" + MaxAge: 1h + LastUsage: 10m ``` ---- diff --git a/internal/cache/cache.go b/internal/cache/cache.go index 9e92f50988..c7dbad6f2c 100644 --- a/internal/cache/cache.go +++ b/internal/cache/cache.go @@ -16,6 +16,7 @@ const ( PurposeUnspecified Purpose = iota PurposeAuthzInstance PurposeMilestones + PurposeOrganization ) // Cache stores objects with a value of type `V`. diff --git a/internal/cache/connector/connector.go b/internal/cache/connector/connector.go index 0c4fb9ccc6..09298fa688 100644 --- a/internal/cache/connector/connector.go +++ b/internal/cache/connector/connector.go @@ -19,8 +19,9 @@ type CachesConfig struct { Postgres pg.Config Redis redis.Config } - Instance *cache.Config - Milestones *cache.Config + Instance *cache.Config + Milestones *cache.Config + Organization *cache.Config } type Connectors struct { diff --git a/internal/cache/purpose_enumer.go b/internal/cache/purpose_enumer.go index bae47476ff..47ad167d70 100644 --- a/internal/cache/purpose_enumer.go +++ b/internal/cache/purpose_enumer.go @@ -7,11 +7,11 @@ import ( "strings" ) -const _PurposeName = "unspecifiedauthz_instancemilestones" +const _PurposeName = "unspecifiedauthz_instancemilestonesorganization" -var _PurposeIndex = [...]uint8{0, 11, 25, 35} +var _PurposeIndex = [...]uint8{0, 11, 25, 35, 47} -const _PurposeLowerName = "unspecifiedauthz_instancemilestones" +const _PurposeLowerName = "unspecifiedauthz_instancemilestonesorganization" func (i Purpose) String() string { if i < 0 || i >= Purpose(len(_PurposeIndex)-1) { @@ -27,9 +27,10 @@ func _PurposeNoOp() { _ = x[PurposeUnspecified-(0)] _ = x[PurposeAuthzInstance-(1)] _ = x[PurposeMilestones-(2)] + _ = x[PurposeOrganization-(3)] } -var _PurposeValues = []Purpose{PurposeUnspecified, PurposeAuthzInstance, PurposeMilestones} +var _PurposeValues = []Purpose{PurposeUnspecified, PurposeAuthzInstance, PurposeMilestones, PurposeOrganization} var _PurposeNameToValueMap = map[string]Purpose{ _PurposeName[0:11]: PurposeUnspecified, @@ -38,12 +39,15 @@ var _PurposeNameToValueMap = map[string]Purpose{ _PurposeLowerName[11:25]: PurposeAuthzInstance, _PurposeName[25:35]: PurposeMilestones, _PurposeLowerName[25:35]: PurposeMilestones, + _PurposeName[35:47]: PurposeOrganization, + _PurposeLowerName[35:47]: PurposeOrganization, } var _PurposeNames = []string{ _PurposeName[0:11], _PurposeName[11:25], _PurposeName[25:35], + _PurposeName[35:47], } // PurposeString retrieves an enum value from the enum constants string name. diff --git a/internal/integration/config/zitadel.yaml b/internal/integration/config/zitadel.yaml index 378dc2f09b..e2642d9b8f 100644 --- a/internal/integration/config/zitadel.yaml +++ b/internal/integration/config/zitadel.yaml @@ -8,28 +8,30 @@ TLS: Caches: Connectors: + Memory: + Enabled: true Postgres: Enabled: true Redis: Enabled: true Instance: - Connector: "redis" - MaxAge: 1h - LastUsage: 10m + Connector: "memory" + MaxAge: 5m + LastUsage: 1m Log: Level: info - AddSource: true - Formatter: - Format: text Milestones: Connector: "postgres" - MaxAge: 1h - LastUsage: 10m + MaxAge: 5m + LastUsage: 1m + Log: + Level: info + Organization: + Connector: "redis" + MaxAge: 5m + LastUsage: 1m Log: Level: info - AddSource: true - Formatter: - Format: text Quotas: Access: diff --git a/internal/query/cache.go b/internal/query/cache.go index 55f7bb3db6..949e121c1f 100644 --- a/internal/query/cache.go +++ b/internal/query/cache.go @@ -12,6 +12,7 @@ import ( type Caches struct { instance cache.Cache[instanceIndex, string, *authzInstance] + org cache.Cache[orgIndex, string, *Org] } func startCaches(background context.Context, connectors connector.Connectors) (_ *Caches, err error) { @@ -20,7 +21,13 @@ func startCaches(background context.Context, connectors connector.Connectors) (_ if err != nil { return nil, err } + caches.org, err = connector.StartCache[orgIndex, string, *Org](background, orgIndexValues(), cache.PurposeOrganization, connectors.Config.Organization, connectors) + if err != nil { + return nil, err + } + caches.registerInstanceInvalidation() + caches.registerOrgInvalidation() return caches, nil } diff --git a/internal/query/instance.go b/internal/query/instance.go index 549c05a233..8dd0db7d89 100644 --- a/internal/query/instance.go +++ b/internal/query/instance.go @@ -522,9 +522,9 @@ func (i *authzInstance) Keys(index instanceIndex) []string { return []string{i.ID} case instanceIndexByHost: return i.ExternalDomains - default: - return nil + case instanceIndexUnspecified: } + return nil } func scanAuthzInstance() (*authzInstance, func(row *sql.Row) error) { diff --git a/internal/query/org.go b/internal/query/org.go index 1c20255171..a57867d92b 100644 --- a/internal/query/org.go +++ b/internal/query/org.go @@ -107,10 +107,19 @@ func (q *OrgSearchQueries) toQuery(query sq.SelectBuilder) sq.SelectBuilder { return query } -func (q *Queries) OrgByID(ctx context.Context, shouldTriggerBulk bool, id string) (_ *Org, err error) { +func (q *Queries) OrgByID(ctx context.Context, shouldTriggerBulk bool, id string) (org *Org, err error) { ctx, span := tracing.NewSpan(ctx) defer func() { span.EndWithError(err) }() + if org, ok := q.caches.org.Get(ctx, orgIndexByID, id); ok { + return org, nil + } + defer func() { + if err == nil && org != nil { + q.caches.org.Set(ctx, org) + } + }() + if !authz.GetInstance(ctx).Features().ShouldUseImprovedPerformance(feature.ImprovedPerformanceTypeOrgByID) { return q.oldOrgByID(ctx, shouldTriggerBulk, id) } @@ -175,6 +184,11 @@ func (q *Queries) OrgByPrimaryDomain(ctx context.Context, domain string) (org *O ctx, span := tracing.NewSpan(ctx) defer func() { span.EndWithError(err) }() + org, ok := q.caches.org.Get(ctx, orgIndexByPrimaryDomain, domain) + if ok { + return org, nil + } + stmt, scan := prepareOrgQuery(ctx, q.client) query, args, err := stmt.Where(sq.Eq{ OrgColumnDomain.identifier(): domain, @@ -189,6 +203,9 @@ func (q *Queries) OrgByPrimaryDomain(ctx context.Context, domain string) (org *O org, err = scan(row) return err }, query, args...) + if err == nil { + q.caches.org.Set(ctx, org) + } return org, err } @@ -476,3 +493,30 @@ func prepareOrgUniqueQuery(ctx context.Context, db prepareDatabase) (sq.SelectBu return isUnique, err } } + +type orgIndex int + +//go:generate enumer -type orgIndex -linecomment +const ( + // Empty line comment ensures empty string for unspecified value + orgIndexUnspecified orgIndex = iota // + orgIndexByID + orgIndexByPrimaryDomain +) + +// Keys implements [cache.Entry] +func (o *Org) Keys(index orgIndex) []string { + switch index { + case orgIndexByID: + return []string{o.ID} + case orgIndexByPrimaryDomain: + return []string{o.Domain} + case orgIndexUnspecified: + } + return nil +} + +func (c *Caches) registerOrgInvalidation() { + invalidate := cacheInvalidationFunc(c.instance, instanceIndexByID, getAggregateID) + projection.OrgProjection.RegisterCacheInvalidation(invalidate) +} diff --git a/internal/query/orgindex_enumer.go b/internal/query/orgindex_enumer.go new file mode 100644 index 0000000000..74f7c985c9 --- /dev/null +++ b/internal/query/orgindex_enumer.go @@ -0,0 +1,82 @@ +// Code generated by "enumer -type orgIndex -linecomment"; DO NOT EDIT. + +package query + +import ( + "fmt" + "strings" +) + +const _orgIndexName = "orgIndexByIDorgIndexByPrimaryDomain" + +var _orgIndexIndex = [...]uint8{0, 0, 12, 35} + +const _orgIndexLowerName = "orgindexbyidorgindexbyprimarydomain" + +func (i orgIndex) String() string { + if i < 0 || i >= orgIndex(len(_orgIndexIndex)-1) { + return fmt.Sprintf("orgIndex(%d)", i) + } + return _orgIndexName[_orgIndexIndex[i]:_orgIndexIndex[i+1]] +} + +// An "invalid array index" compiler error signifies that the constant values have changed. +// Re-run the stringer command to generate them again. +func _orgIndexNoOp() { + var x [1]struct{} + _ = x[orgIndexUnspecified-(0)] + _ = x[orgIndexByID-(1)] + _ = x[orgIndexByPrimaryDomain-(2)] +} + +var _orgIndexValues = []orgIndex{orgIndexUnspecified, orgIndexByID, orgIndexByPrimaryDomain} + +var _orgIndexNameToValueMap = map[string]orgIndex{ + _orgIndexName[0:0]: orgIndexUnspecified, + _orgIndexLowerName[0:0]: orgIndexUnspecified, + _orgIndexName[0:12]: orgIndexByID, + _orgIndexLowerName[0:12]: orgIndexByID, + _orgIndexName[12:35]: orgIndexByPrimaryDomain, + _orgIndexLowerName[12:35]: orgIndexByPrimaryDomain, +} + +var _orgIndexNames = []string{ + _orgIndexName[0:0], + _orgIndexName[0:12], + _orgIndexName[12:35], +} + +// orgIndexString retrieves an enum value from the enum constants string name. +// Throws an error if the param is not part of the enum. +func orgIndexString(s string) (orgIndex, error) { + if val, ok := _orgIndexNameToValueMap[s]; ok { + return val, nil + } + + if val, ok := _orgIndexNameToValueMap[strings.ToLower(s)]; ok { + return val, nil + } + return 0, fmt.Errorf("%s does not belong to orgIndex values", s) +} + +// orgIndexValues returns all values of the enum +func orgIndexValues() []orgIndex { + return _orgIndexValues +} + +// orgIndexStrings returns a slice of all String values of the enum +func orgIndexStrings() []string { + strs := make([]string, len(_orgIndexNames)) + copy(strs, _orgIndexNames) + return strs +} + +// IsAorgIndex returns "true" if the value is listed in the enum definition. "false" otherwise +func (i orgIndex) IsAorgIndex() bool { + for _, v := range _orgIndexValues { + if i == v { + return true + } + } + return false +} From b65266907c0102beb10fb49ceee6999d7128023d Mon Sep 17 00:00:00 2001 From: Titouan-joseph Cicorella Date: Thu, 21 Nov 2024 14:26:39 +0100 Subject: [PATCH 19/64] docs(user): change some deprecated links (#8913) # Which Problems Are Solved Some links are pointing to the deprecated API v1 # How the Problems Are Solved Change the link to the API V2 # Additional Changes For the moment, I don't have the time to add more links in the API v1 pages. Maybe later, when I will have time, I will add more links --------- Co-authored-by: Fabi --- docs/docs/guides/manage/user/reg-create-user.md | 2 +- proto/zitadel/management.proto | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/docs/guides/manage/user/reg-create-user.md b/docs/docs/guides/manage/user/reg-create-user.md index 0ac85e3b33..9c523cba24 100644 --- a/docs/docs/guides/manage/user/reg-create-user.md +++ b/docs/docs/guides/manage/user/reg-create-user.md @@ -6,7 +6,7 @@ The ZITADEL API has different possibilities to create users. This can be used, if you are building your own registration page. Use the following API call to create your users: -[Create User (Human)](/apis/resources/mgmt/management-service-import-human-user.api.mdx) +[Create User (Human)](apis/resources/user_service_v2/user-service-add-human-user.api.mdx) ## With Username and Password diff --git a/proto/zitadel/management.proto b/proto/zitadel/management.proto index 0df07ffd4c..0ff2ad7b75 100644 --- a/proto/zitadel/management.proto +++ b/proto/zitadel/management.proto @@ -472,7 +472,7 @@ service ManagementService { option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { summary: "Create/Import User (Human)"; - description: "Create/import a new user with the type human. The newly created user will get an initialization email if either the email address is not marked as verified or no password is set. If a password is set the user will not be requested to set a new one on the first login.\n\nDeprecated: please use user service v2 AddHumanUser" + description: "Create/import a new user with the type human. The newly created user will get an initialization email if either the email address is not marked as verified or no password is set. If a password is set the user will not be requested to set a new one on the first login.\n\nDeprecated: please use user service v2 [AddHumanUser](apis/resources/user_service_v2/user-service-add-human-user.api.mdx)" tags: "Users"; tags: "User Human" deprecated: true; From d4389ab359e76f745185be7c36ddde616a35472a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tim=20M=C3=B6hlmann?= Date: Thu, 21 Nov 2024 16:46:30 +0200 Subject: [PATCH 20/64] feat(eventstore): add row locking option (#8939) # Which Problems Are Solved We need a reliable way to lock events that are being processed as part of a job queue. For example in the notification handlers. # How the Problems Are Solved Allow setting `FOR UPDATE [ NOWAIT | SKIP LOCKED ]` to the eventstore query builder using an open transaction. - NOWAIT returns an errors if the lock cannot be obtained - SKIP LOCKED only returns row which are not locked. - Default is to wait for the lock to be released. # Additional Changes - none # Additional Context - [Locking docs](https://www.postgresql.org/docs/17/sql-select.html#SQL-FOR-UPDATE-SHARE) - Related to https://github.com/zitadel/zitadel/issues/8931 --- .../eventstore/repository/search_query.go | 3 + internal/eventstore/repository/sql/query.go | 12 ++ .../eventstore/repository/sql/query_test.go | 146 ++++++++++++++++++ internal/eventstore/search_query.go | 27 ++++ 4 files changed, 188 insertions(+) diff --git a/internal/eventstore/repository/search_query.go b/internal/eventstore/repository/search_query.go index e67ed6206c..32a353049d 100644 --- a/internal/eventstore/repository/search_query.go +++ b/internal/eventstore/repository/search_query.go @@ -14,6 +14,8 @@ type SearchQuery struct { SubQueries [][]*Filter Tx *sql.Tx + LockRows bool + LockOption eventstore.LockOption AllowTimeTravel bool AwaitOpenTransactions bool Limit uint64 @@ -130,6 +132,7 @@ func QueryFromBuilder(builder *eventstore.SearchQueryBuilder) (*SearchQuery, err AwaitOpenTransactions: builder.GetAwaitOpenTransactions(), SubQueries: make([][]*Filter, len(builder.GetQueries())), } + query.LockRows, query.LockOption = builder.GetLockRows() for _, f := range []func(builder *eventstore.SearchQueryBuilder, query *SearchQuery) *Filter{ instanceIDFilter, diff --git a/internal/eventstore/repository/sql/query.go b/internal/eventstore/repository/sql/query.go index 8ecea6e3d6..dda12187f9 100644 --- a/internal/eventstore/repository/sql/query.go +++ b/internal/eventstore/repository/sql/query.go @@ -105,6 +105,18 @@ func query(ctx context.Context, criteria querier, searchQuery *eventstore.Search query += " OFFSET ?" } + if q.LockRows { + query += " FOR UPDATE" + switch q.LockOption { + case eventstore.LockOptionWait: // default behavior + case eventstore.LockOptionNoWait: + query += " NOWAIT" + case eventstore.LockOptionSkipLocked: + query += " SKIP LOCKED" + + } + } + query = criteria.placeholder(query) var contextQuerier interface { diff --git a/internal/eventstore/repository/sql/query_test.go b/internal/eventstore/repository/sql/query_test.go index 2956c39bd5..a3d47b838d 100644 --- a/internal/eventstore/repository/sql/query_test.go +++ b/internal/eventstore/repository/sql/query_test.go @@ -657,6 +657,89 @@ func Test_query_events_with_crdb(t *testing.T) { } } +/* Cockroach test DB doesn't seem to lock +func Test_query_events_with_crdb_locking(t *testing.T) { + type args struct { + searchQuery *eventstore.SearchQueryBuilder + } + type fields struct { + existingEvents []eventstore.Command + client *sql.DB + } + tests := []struct { + name string + fields fields + args args + lockOption eventstore.LockOption + wantErr bool + }{ + { + name: "skip locked", + fields: fields{ + client: testCRDBClient, + existingEvents: []eventstore.Command{ + generateEvent(t, "306", func(e *repository.Event) { e.ResourceOwner = sql.NullString{String: "caos", Valid: true} }), + generateEvent(t, "307", func(e *repository.Event) { e.ResourceOwner = sql.NullString{String: "caos", Valid: true} }), + generateEvent(t, "308", func(e *repository.Event) { e.ResourceOwner = sql.NullString{String: "caos", Valid: true} }), + }, + }, + args: args{ + searchQuery: eventstore.NewSearchQueryBuilder(eventstore.ColumnsEvent). + ResourceOwner("caos"), + }, + lockOption: eventstore.LockOptionNoWait, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + db := &CRDB{ + DB: &database.DB{ + DB: tt.fields.client, + Database: new(testDB), + }, + } + // setup initial data for query + if _, err := db.Push(context.Background(), tt.fields.existingEvents...); err != nil { + t.Errorf("error in setup = %v", err) + return + } + // first TX should lock and return all events + tx1, err := db.DB.Begin() + require.NoError(t, err) + defer func() { + require.NoError(t, tx1.Rollback()) + }() + searchQuery1 := tt.args.searchQuery.LockRowsDuringTx(tx1, tt.lockOption) + gotEvents1 := []eventstore.Event{} + err = query(context.Background(), db, searchQuery1, eventstore.Reducer(func(event eventstore.Event) error { + gotEvents1 = append(gotEvents1, event) + return nil + }), true) + require.NoError(t, err) + assert.Len(t, gotEvents1, len(tt.fields.existingEvents)) + + // second TX should not return the events, and might return an error + tx2, err := db.DB.Begin() + require.NoError(t, err) + defer func() { + require.NoError(t, tx2.Rollback()) + }() + searchQuery2 := tt.args.searchQuery.LockRowsDuringTx(tx1, tt.lockOption) + gotEvents2 := []eventstore.Event{} + err = query(context.Background(), db, searchQuery2, eventstore.Reducer(func(event eventstore.Event) error { + gotEvents2 = append(gotEvents2, event) + return nil + }), true) + if tt.wantErr { + require.Error(t, err) + } + require.NoError(t, err) + assert.Len(t, gotEvents2, 0) + }) + } +} +*/ + func Test_query_events_mocked(t *testing.T) { type args struct { query *eventstore.SearchQueryBuilder @@ -762,6 +845,69 @@ func Test_query_events_mocked(t *testing.T) { wantErr: false, }, }, + { + name: "lock, wait", + args: args{ + dest: &[]*repository.Event{}, + query: eventstore.NewSearchQueryBuilder(eventstore.ColumnsEvent). + OrderDesc(). + Limit(5). + AddQuery(). + AggregateTypes("user"). + Builder().LockRowsDuringTx(nil, eventstore.LockOptionWait), + }, + fields: fields{ + mock: newMockClient(t).expectQuery(t, + `SELECT creation_date, event_type, event_sequence, event_data, editor_user, resource_owner, instance_id, aggregate_type, aggregate_id, aggregate_version FROM eventstore.events WHERE aggregate_type = \$1 ORDER BY event_sequence DESC LIMIT \$2 FOR UPDATE`, + []driver.Value{eventstore.AggregateType("user"), uint64(5)}, + ), + }, + res: res{ + wantErr: false, + }, + }, + { + name: "lock, no wait", + args: args{ + dest: &[]*repository.Event{}, + query: eventstore.NewSearchQueryBuilder(eventstore.ColumnsEvent). + OrderDesc(). + Limit(5). + AddQuery(). + AggregateTypes("user"). + Builder().LockRowsDuringTx(nil, eventstore.LockOptionNoWait), + }, + fields: fields{ + mock: newMockClient(t).expectQuery(t, + `SELECT creation_date, event_type, event_sequence, event_data, editor_user, resource_owner, instance_id, aggregate_type, aggregate_id, aggregate_version FROM eventstore.events WHERE aggregate_type = \$1 ORDER BY event_sequence DESC LIMIT \$2 FOR UPDATE NOWAIT`, + []driver.Value{eventstore.AggregateType("user"), uint64(5)}, + ), + }, + res: res{ + wantErr: false, + }, + }, + { + name: "lock, skip locked", + args: args{ + dest: &[]*repository.Event{}, + query: eventstore.NewSearchQueryBuilder(eventstore.ColumnsEvent). + OrderDesc(). + Limit(5). + AddQuery(). + AggregateTypes("user"). + Builder().LockRowsDuringTx(nil, eventstore.LockOptionSkipLocked), + }, + fields: fields{ + mock: newMockClient(t).expectQuery(t, + `SELECT creation_date, event_type, event_sequence, event_data, editor_user, resource_owner, instance_id, aggregate_type, aggregate_id, aggregate_version FROM eventstore.events WHERE aggregate_type = \$1 ORDER BY event_sequence DESC LIMIT \$2 FOR UPDATE SKIP LOCKED`, + []driver.Value{eventstore.AggregateType("user"), uint64(5)}, + ), + }, + res: res{ + wantErr: false, + }, + }, { name: "error sql conn closed", args: args{ diff --git a/internal/eventstore/search_query.go b/internal/eventstore/search_query.go index e80ed8295c..58e25f6567 100644 --- a/internal/eventstore/search_query.go +++ b/internal/eventstore/search_query.go @@ -22,6 +22,8 @@ type SearchQueryBuilder struct { editorUser string queries []*SearchQuery tx *sql.Tx + lockRows bool + lockOption LockOption allowTimeTravel bool positionAfter float64 awaitOpenTransactions bool @@ -94,6 +96,10 @@ func (q SearchQueryBuilder) GetCreationDateBefore() time.Time { return q.creationDateBefore } +func (q SearchQueryBuilder) GetLockRows() (bool, LockOption) { + return q.lockRows, q.lockOption +} + // ensureInstanceID makes sure that the instance id is always set func (b *SearchQueryBuilder) ensureInstanceID(ctx context.Context) { if b.instanceID == nil && len(b.instanceIDs) == 0 && authz.GetInstance(ctx).InstanceID() != "" { @@ -307,6 +313,27 @@ func (builder *SearchQueryBuilder) CreationDateBefore(creationDate time.Time) *S return builder } +type LockOption int + +const ( + // Wait until the previous lock on all of the selected rows is released (default) + LockOptionWait LockOption = iota + // With NOWAIT, the statement reports an error, rather than waiting, if a selected row cannot be locked immediately. + LockOptionNoWait + // With SKIP LOCKED, any selected rows that cannot be immediately locked are skipped. + LockOptionSkipLocked +) + +// LockRowsDuringTx locks the found rows for the duration of the transaction, +// using the [`FOR UPDATE`](https://www.postgresql.org/docs/17/sql-select.html#SQL-FOR-UPDATE-SHARE) lock strength. +// The lock is removed on transaction commit or rollback. +func (builder *SearchQueryBuilder) LockRowsDuringTx(tx *sql.Tx, option LockOption) *SearchQueryBuilder { + builder.tx = tx + builder.lockRows = true + builder.lockOption = option + return builder +} + // AddQuery creates a new sub query. // All fields in the sub query are AND-connected in the storage request. // Multiple sub queries are OR-connected in the storage request. From 48ffc902cc90237d693e7104fc742ee927478da7 Mon Sep 17 00:00:00 2001 From: Dominic Bachmann Date: Fri, 22 Nov 2024 10:25:25 +0100 Subject: [PATCH 21/64] fix: typo in defaults.yaml where ExternalPort should be ExternalDomain (#8923) # Which Problems Are Solved Fixed a typo in cmd/defaults.yaml where ExternalPort should be ExternalDomain Co-authored-by: Fabi --- cmd/defaults.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cmd/defaults.yaml b/cmd/defaults.yaml index f0c8bedbeb..e15d491a8b 100644 --- a/cmd/defaults.yaml +++ b/cmd/defaults.yaml @@ -68,7 +68,7 @@ Port: 8080 # ZITADEL_PORT # It can differ from Port e.g. if a reverse proxy forwards the traffic to ZITADEL # Read more about external access: https://zitadel.com/docs/self-hosting/manage/custom-domain ExternalPort: 8080 # ZITADEL_EXTERNALPORT -# ExternalPort is the domain on which end users access ZITADEL. +# ExternalDomain is the domain on which end users access ZITADEL. # Read more about external access: https://zitadel.com/docs/self-hosting/manage/custom-domain ExternalDomain: localhost # ZITADEL_EXTERNALDOMAIN # ExternalSecure specifies if ZITADEL is exposed externally using HTTPS or HTTP. From 1ee7a1ab7ca30438529ad33f4186c78b886aec0f Mon Sep 17 00:00:00 2001 From: Silvan Date: Fri, 22 Nov 2024 17:25:28 +0100 Subject: [PATCH 22/64] feat(eventstore): accept transaction in push (#8945) # Which Problems Are Solved Push is not capable of external transactions. # How the Problems Are Solved A new function `PushWithClient` is added to the eventstore framework which allows to pass a client which can either be a `*sql.Client` or `*sql.Tx` and is used during push. # Additional Changes Added interfaces to database package. # Additional Context - part of https://github.com/zitadel/zitadel/issues/8931 --------- Co-authored-by: Livio Spring Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .../instance_debug_notification_log_test.go | 4 +- internal/database/database.go | 36 ++++++++ internal/eventstore/eventstore.go | 30 +++++-- internal/eventstore/eventstore_bench_test.go | 4 +- internal/eventstore/eventstore_pusher_test.go | 4 +- internal/eventstore/eventstore_test.go | 8 +- .../repository/mock/repository.mock.go | 24 +++-- .../repository/mock/repository.mock.impl.go | 17 ++-- internal/eventstore/v3/eventstore.go | 8 ++ internal/eventstore/v3/field.go | 15 ++-- internal/eventstore/v3/push.go | 90 +++++++++++-------- internal/eventstore/v3/sequence.go | 3 +- internal/eventstore/v3/unique_constraints.go | 4 +- 13 files changed, 177 insertions(+), 70 deletions(-) diff --git a/internal/command/instance_debug_notification_log_test.go b/internal/command/instance_debug_notification_log_test.go index 9190064f60..32fdd06618 100644 --- a/internal/command/instance_debug_notification_log_test.go +++ b/internal/command/instance_debug_notification_log_test.go @@ -199,7 +199,7 @@ func TestCommandSide_ChangeDebugNotificationProviderLog(t *testing.T) { }, }, { - name: "change, ok", + name: "change, ok 1", fields: fields{ eventstore: eventstoreExpect( t, @@ -232,7 +232,7 @@ func TestCommandSide_ChangeDebugNotificationProviderLog(t *testing.T) { }, }, { - name: "change, ok", + name: "change, ok 2", fields: fields{ eventstore: eventstoreExpect( t, diff --git a/internal/database/database.go b/internal/database/database.go index d6ccf2873c..0191f34b6d 100644 --- a/internal/database/database.go +++ b/internal/database/database.go @@ -18,6 +18,42 @@ import ( "github.com/zitadel/zitadel/internal/zerrors" ) +type QueryExecuter interface { + Query(query string, args ...any) (*sql.Rows, error) + QueryContext(ctx context.Context, query string, args ...any) (*sql.Rows, error) + Exec(query string, args ...any) (sql.Result, error) + ExecContext(ctx context.Context, query string, args ...any) (sql.Result, error) +} + +type Client interface { + QueryExecuter + BeginTx(ctx context.Context, opts *sql.TxOptions) (*sql.Tx, error) + Begin() (*sql.Tx, error) +} + +type Tx interface { + QueryExecuter + Commit() error + Rollback() error +} + +var ( + _ Client = (*sql.DB)(nil) + _ Tx = (*sql.Tx)(nil) +) + +func CloseTransaction(tx Tx, err error) error { + if err != nil { + rollbackErr := tx.Rollback() + logging.OnError(rollbackErr).Error("failed to rollback transaction") + return err + } + + commitErr := tx.Commit() + logging.OnError(commitErr).Error("failed to commit transaction") + return commitErr +} + type Config struct { Dialects map[string]interface{} `mapstructure:",remain"` EventPushConnRatio float64 diff --git a/internal/eventstore/eventstore.go b/internal/eventstore/eventstore.go index a8c8e923b5..6246978739 100644 --- a/internal/eventstore/eventstore.go +++ b/internal/eventstore/eventstore.go @@ -85,6 +85,12 @@ func (es *Eventstore) Health(ctx context.Context) error { // Push pushes the events in a single transaction // an event needs at least an aggregate func (es *Eventstore) Push(ctx context.Context, cmds ...Command) ([]Event, error) { + return es.PushWithClient(ctx, nil, cmds...) +} + +// PushWithClient pushes the events in a single transaction using the provided database client +// an event needs at least an aggregate +func (es *Eventstore) PushWithClient(ctx context.Context, client database.Client, cmds ...Command) ([]Event, error) { if es.PushTimeout > 0 { var cancel func() ctx, cancel = context.WithTimeout(ctx, es.PushTimeout) @@ -100,12 +106,24 @@ func (es *Eventstore) Push(ctx context.Context, cmds ...Command) ([]Event, error // https://github.com/zitadel/zitadel/issues/7202 retry: for i := 0; i <= es.maxRetries; i++ { - events, err = es.pusher.Push(ctx, cmds...) - var pgErr *pgconn.PgError - if !errors.As(err, &pgErr) || pgErr.ConstraintName != "events2_pkey" || pgErr.SQLState() != "23505" { + events, err = es.pusher.Push(ctx, client, cmds...) + // if there is a transaction passed the calling function needs to retry + if _, ok := client.(database.Tx); ok { break retry } - logging.WithError(err).Info("eventstore push retry") + var pgErr *pgconn.PgError + if !errors.As(err, &pgErr) { + break retry + } + if pgErr.ConstraintName == "events2_pkey" && pgErr.SQLState() == "23505" { + logging.WithError(err).Info("eventstore push retry") + continue + } + if pgErr.SQLState() == "CR000" || pgErr.SQLState() == "40001" { + logging.WithError(err).Info("eventstore push retry") + continue + } + break retry } if err != nil { return nil, err @@ -283,7 +301,9 @@ type Pusher interface { // Health checks if the connection to the storage is available Health(ctx context.Context) error // Push stores the actions - Push(ctx context.Context, commands ...Command) (_ []Event, err error) + Push(ctx context.Context, client database.QueryExecuter, commands ...Command) (_ []Event, err error) + // Client returns the underlying database connection + Client() *database.DB } type FillFieldsEvent interface { diff --git a/internal/eventstore/eventstore_bench_test.go b/internal/eventstore/eventstore_bench_test.go index 69b958abd8..582391e09f 100644 --- a/internal/eventstore/eventstore_bench_test.go +++ b/internal/eventstore/eventstore_bench_test.go @@ -69,7 +69,7 @@ func Benchmark_Push_SameAggregate(b *testing.B) { b.StartTimer() for n := 0; n < b.N; n++ { - _, err := store.Push(ctx, cmds...) + _, err := store.Push(ctx, store.Client().DB, cmds...) if err != nil { b.Error(err) } @@ -149,7 +149,7 @@ func Benchmark_Push_MultipleAggregate_Parallel(b *testing.B) { b.RunParallel(func(p *testing.PB) { for p.Next() { i++ - _, err := store.Push(ctx, commandCreator(strconv.Itoa(i))...) + _, err := store.Push(ctx, store.Client().DB, commandCreator(strconv.Itoa(i))...) if err != nil { b.Error(err) } diff --git a/internal/eventstore/eventstore_pusher_test.go b/internal/eventstore/eventstore_pusher_test.go index bd97b2e1e6..4e8e663667 100644 --- a/internal/eventstore/eventstore_pusher_test.go +++ b/internal/eventstore/eventstore_pusher_test.go @@ -607,7 +607,7 @@ func TestCRDB_Push_ResourceOwner(t *testing.T) { } } -func pushAggregates(pusher eventstore.Pusher, aggregateCommands [][]eventstore.Command) []error { +func pushAggregates(es *eventstore.Eventstore, aggregateCommands [][]eventstore.Command) []error { wg := sync.WaitGroup{} errs := make([]error, 0) errsMu := sync.Mutex{} @@ -619,7 +619,7 @@ func pushAggregates(pusher eventstore.Pusher, aggregateCommands [][]eventstore.C go func(events []eventstore.Command) { <-ctx.Done() - _, err := pusher.Push(context.Background(), events...) //nolint:contextcheck + _, err := es.Push(context.Background(), events...) //nolint:contextcheck if err != nil { errsMu.Lock() errs = append(errs, err) diff --git a/internal/eventstore/eventstore_test.go b/internal/eventstore/eventstore_test.go index 33e80892c5..ec28b9c551 100644 --- a/internal/eventstore/eventstore_test.go +++ b/internal/eventstore/eventstore_test.go @@ -330,6 +330,12 @@ func Test_eventData(t *testing.T) { } } +var _ Pusher = (*testPusher)(nil) + +func (repo *testPusher) Client() *database.DB { + return nil +} + type testPusher struct { events []Event errs []error @@ -341,7 +347,7 @@ func (repo *testPusher) Health(ctx context.Context) error { return nil } -func (repo *testPusher) Push(ctx context.Context, commands ...Command) (events []Event, err error) { +func (repo *testPusher) Push(_ context.Context, _ database.QueryExecuter, commands ...Command) (events []Event, err error) { if len(repo.errs) != 0 { err, repo.errs = repo.errs[0], repo.errs[1:] return nil, err diff --git a/internal/eventstore/repository/mock/repository.mock.go b/internal/eventstore/repository/mock/repository.mock.go index 58a6c8f86f..de04fef8c9 100644 --- a/internal/eventstore/repository/mock/repository.mock.go +++ b/internal/eventstore/repository/mock/repository.mock.go @@ -136,6 +136,20 @@ func (m *MockPusher) EXPECT() *MockPusherMockRecorder { return m.recorder } +// Client mocks base method. +func (m *MockPusher) Client() *database.DB { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Client") + ret0, _ := ret[0].(*database.DB) + return ret0 +} + +// Client indicates an expected call of Client. +func (mr *MockPusherMockRecorder) Client() *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Client", reflect.TypeOf((*MockPusher)(nil).Client)) +} + // Health mocks base method. func (m *MockPusher) Health(arg0 context.Context) error { m.ctrl.T.Helper() @@ -151,10 +165,10 @@ func (mr *MockPusherMockRecorder) Health(arg0 any) *gomock.Call { } // Push mocks base method. -func (m *MockPusher) Push(arg0 context.Context, arg1 ...eventstore.Command) ([]eventstore.Event, error) { +func (m *MockPusher) Push(arg0 context.Context, arg1 database.QueryExecuter, arg2 ...eventstore.Command) ([]eventstore.Event, error) { m.ctrl.T.Helper() - varargs := []any{arg0} - for _, a := range arg1 { + varargs := []any{arg0, arg1} + for _, a := range arg2 { varargs = append(varargs, a) } ret := m.ctrl.Call(m, "Push", varargs...) @@ -164,8 +178,8 @@ func (m *MockPusher) Push(arg0 context.Context, arg1 ...eventstore.Command) ([]e } // Push indicates an expected call of Push. -func (mr *MockPusherMockRecorder) Push(arg0 any, arg1 ...any) *gomock.Call { +func (mr *MockPusherMockRecorder) Push(arg0, arg1 any, arg2 ...any) *gomock.Call { mr.mock.ctrl.T.Helper() - varargs := append([]any{arg0}, arg1...) + varargs := append([]any{arg0, arg1}, arg2...) return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Push", reflect.TypeOf((*MockPusher)(nil).Push), varargs...) } diff --git a/internal/eventstore/repository/mock/repository.mock.impl.go b/internal/eventstore/repository/mock/repository.mock.impl.go index d41521ad8f..365da7afe2 100644 --- a/internal/eventstore/repository/mock/repository.mock.impl.go +++ b/internal/eventstore/repository/mock/repository.mock.impl.go @@ -10,6 +10,7 @@ import ( "github.com/stretchr/testify/assert" "go.uber.org/mock/gomock" + "github.com/zitadel/zitadel/internal/database" "github.com/zitadel/zitadel/internal/eventstore" "github.com/zitadel/zitadel/internal/eventstore/repository" ) @@ -78,8 +79,8 @@ func (m *MockRepository) ExpectInstanceIDsError(err error) *MockRepository { // ExpectPush checks if the expectedCommands are send to the Push method. // The call will sleep at least the amount of passed duration. func (m *MockRepository) ExpectPush(expectedCommands []eventstore.Command, sleep time.Duration) *MockRepository { - m.MockPusher.EXPECT().Push(gomock.Any(), gomock.Any()).DoAndReturn( - func(ctx context.Context, commands ...eventstore.Command) ([]eventstore.Event, error) { + m.MockPusher.EXPECT().Push(gomock.Any(), gomock.Any(), gomock.Any()).DoAndReturn( + func(ctx context.Context, _ database.QueryExecuter, commands ...eventstore.Command) ([]eventstore.Event, error) { m.MockPusher.ctrl.T.Helper() time.Sleep(sleep) @@ -133,8 +134,8 @@ func (m *MockRepository) ExpectPush(expectedCommands []eventstore.Command, sleep func (m *MockRepository) ExpectPushFailed(err error, expectedCommands []eventstore.Command) *MockRepository { m.MockPusher.ctrl.T.Helper() - m.MockPusher.EXPECT().Push(gomock.Any(), gomock.Any()).DoAndReturn( - func(ctx context.Context, commands ...eventstore.Command) ([]eventstore.Event, error) { + m.MockPusher.EXPECT().Push(gomock.Any(), gomock.Any(), gomock.Any()).DoAndReturn( + func(ctx context.Context, _ database.QueryExecuter, commands ...eventstore.Command) ([]eventstore.Event, error) { if len(expectedCommands) != len(commands) { return nil, fmt.Errorf("unexpected amount of commands: want %d, got %d", len(expectedCommands), len(commands)) } @@ -195,8 +196,8 @@ func (e *mockEvent) CreatedAt() time.Time { } func (m *MockRepository) ExpectRandomPush(expectedCommands []eventstore.Command) *MockRepository { - m.MockPusher.EXPECT().Push(gomock.Any(), gomock.Any()).DoAndReturn( - func(ctx context.Context, commands ...eventstore.Command) ([]eventstore.Event, error) { + m.MockPusher.EXPECT().Push(gomock.Any(), gomock.Any(), gomock.Any()).DoAndReturn( + func(ctx context.Context, _ database.QueryExecuter, commands ...eventstore.Command) ([]eventstore.Event, error) { assert.Len(m.MockPusher.ctrl.T, commands, len(expectedCommands)) events := make([]eventstore.Event, len(commands)) @@ -213,8 +214,8 @@ func (m *MockRepository) ExpectRandomPush(expectedCommands []eventstore.Command) } func (m *MockRepository) ExpectRandomPushFailed(err error, expectedEvents []eventstore.Command) *MockRepository { - m.MockPusher.EXPECT().Push(gomock.Any(), gomock.Any()).DoAndReturn( - func(ctx context.Context, events ...eventstore.Command) ([]eventstore.Event, error) { + m.MockPusher.EXPECT().Push(gomock.Any(), gomock.Any(), gomock.Any()).DoAndReturn( + func(ctx context.Context, _ database.QueryExecuter, events ...eventstore.Command) ([]eventstore.Event, error) { assert.Len(m.MockPusher.ctrl.T, events, len(expectedEvents)) return nil, err }, diff --git a/internal/eventstore/v3/eventstore.go b/internal/eventstore/v3/eventstore.go index 4ecaf6bad2..7c58f53f29 100644 --- a/internal/eventstore/v3/eventstore.go +++ b/internal/eventstore/v3/eventstore.go @@ -4,6 +4,7 @@ import ( "context" "github.com/zitadel/zitadel/internal/database" + "github.com/zitadel/zitadel/internal/eventstore" ) var ( @@ -11,12 +12,19 @@ var ( pushPlaceholderFmt string // uniqueConstraintPlaceholderFmt defines the format of the unique constraint error returned from the database uniqueConstraintPlaceholderFmt string + + _ eventstore.Pusher = (*Eventstore)(nil) ) type Eventstore struct { client *database.DB } +// Client implements the [eventstore.Pusher] +func (es *Eventstore) Client() *database.DB { + return es.client +} + func NewEventstore(client *database.DB) *Eventstore { switch client.Type() { case "cockroach": diff --git a/internal/eventstore/v3/field.go b/internal/eventstore/v3/field.go index 17037f8bcc..cfa9c08bba 100644 --- a/internal/eventstore/v3/field.go +++ b/internal/eventstore/v3/field.go @@ -11,6 +11,7 @@ import ( "strings" "github.com/zitadel/zitadel/internal/api/authz" + "github.com/zitadel/zitadel/internal/database" "github.com/zitadel/zitadel/internal/eventstore" "github.com/zitadel/zitadel/internal/telemetry/tracing" "github.com/zitadel/zitadel/internal/zerrors" @@ -142,7 +143,7 @@ func buildSearchCondition(builder *strings.Builder, index int, conditions map[ev return args } -func handleFieldCommands(ctx context.Context, tx *sql.Tx, commands []eventstore.Command) error { +func handleFieldCommands(ctx context.Context, tx database.Tx, commands []eventstore.Command) error { for _, command := range commands { if len(command.Fields()) > 0 { if err := handleFieldOperations(ctx, tx, command.Fields()); err != nil { @@ -153,7 +154,7 @@ func handleFieldCommands(ctx context.Context, tx *sql.Tx, commands []eventstore. return nil } -func handleFieldFillEvents(ctx context.Context, tx *sql.Tx, events []eventstore.FillFieldsEvent) error { +func handleFieldFillEvents(ctx context.Context, tx database.Tx, events []eventstore.FillFieldsEvent) error { for _, event := range events { if len(event.Fields()) > 0 { if err := handleFieldOperations(ctx, tx, event.Fields()); err != nil { @@ -164,7 +165,7 @@ func handleFieldFillEvents(ctx context.Context, tx *sql.Tx, events []eventstore. return nil } -func handleFieldOperations(ctx context.Context, tx *sql.Tx, operations []*eventstore.FieldOperation) error { +func handleFieldOperations(ctx context.Context, tx database.Tx, operations []*eventstore.FieldOperation) error { for _, operation := range operations { if operation.Set != nil { if err := handleFieldSet(ctx, tx, operation.Set); err != nil { @@ -182,7 +183,7 @@ func handleFieldOperations(ctx context.Context, tx *sql.Tx, operations []*events return nil } -func handleFieldSet(ctx context.Context, tx *sql.Tx, field *eventstore.Field) error { +func handleFieldSet(ctx context.Context, tx database.Tx, field *eventstore.Field) error { if len(field.UpsertConflictFields) == 0 { return handleSearchInsert(ctx, tx, field) } @@ -193,7 +194,7 @@ const ( insertField = `INSERT INTO eventstore.fields (instance_id, resource_owner, aggregate_type, aggregate_id, object_type, object_id, object_revision, field_name, value, value_must_be_unique, should_index) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)` ) -func handleSearchInsert(ctx context.Context, tx *sql.Tx, field *eventstore.Field) error { +func handleSearchInsert(ctx context.Context, tx database.Tx, field *eventstore.Field) error { value, err := json.Marshal(field.Value.Value) if err != nil { return zerrors.ThrowInvalidArgument(err, "V3-fcrW1", "unable to marshal field value") @@ -222,7 +223,7 @@ const ( fieldsUpsertSuffix = ` RETURNING * ) INSERT INTO eventstore.fields (instance_id, resource_owner, aggregate_type, aggregate_id, object_type, object_id, object_revision, field_name, value, value_must_be_unique, should_index) SELECT $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11 WHERE NOT EXISTS (SELECT 1 FROM upsert)` ) -func handleSearchUpsert(ctx context.Context, tx *sql.Tx, field *eventstore.Field) error { +func handleSearchUpsert(ctx context.Context, tx database.Tx, field *eventstore.Field) error { value, err := json.Marshal(field.Value.Value) if err != nil { return zerrors.ThrowInvalidArgument(err, "V3-fcrW1", "unable to marshal field value") @@ -268,7 +269,7 @@ func writeUpsertField(fields []eventstore.FieldType) string { const removeSearch = `DELETE FROM eventstore.fields WHERE ` -func handleSearchDelete(ctx context.Context, tx *sql.Tx, clauses map[eventstore.FieldType]any) error { +func handleSearchDelete(ctx context.Context, tx database.Tx, clauses map[eventstore.FieldType]any) error { if len(clauses) == 0 { return zerrors.ThrowInvalidArgument(nil, "V3-oqlBZ", "no conditions") } diff --git a/internal/eventstore/v3/push.go b/internal/eventstore/v3/push.go index 47a4c96dca..c0f66209c3 100644 --- a/internal/eventstore/v3/push.go +++ b/internal/eventstore/v3/push.go @@ -14,6 +14,7 @@ import ( "github.com/zitadel/logging" "github.com/zitadel/zitadel/internal/api/authz" + "github.com/zitadel/zitadel/internal/database" "github.com/zitadel/zitadel/internal/database/dialect" "github.com/zitadel/zitadel/internal/eventstore" "github.com/zitadel/zitadel/internal/telemetry/tracing" @@ -22,13 +23,45 @@ import ( var appNamePrefix = dialect.DBPurposeEventPusher.AppName() + "_" -func (es *Eventstore) Push(ctx context.Context, commands ...eventstore.Command) (events []eventstore.Event, err error) { +var pushTxOpts = &sql.TxOptions{ + Isolation: sql.LevelReadCommitted, + ReadOnly: false, +} + +func (es *Eventstore) Push(ctx context.Context, client database.QueryExecuter, commands ...eventstore.Command) (events []eventstore.Event, err error) { ctx, span := tracing.NewSpan(ctx) defer func() { span.EndWithError(err) }() - tx, err := es.client.BeginTx(ctx, nil) - if err != nil { - return nil, err + var tx database.Tx + switch c := client.(type) { + case database.Tx: + tx = c + case database.Client: + // We cannot use READ COMMITTED on CockroachDB because we use cluster_logical_timestamp() which is not supported in this isolation level + var opts *sql.TxOptions + if es.client.Database.Type() == "postgres" { + opts = pushTxOpts + } + tx, err = c.BeginTx(ctx, opts) + if err != nil { + return nil, err + } + defer func() { + err = database.CloseTransaction(tx, err) + }() + default: + // We cannot use READ COMMITTED on CockroachDB because we use cluster_logical_timestamp() which is not supported in this isolation level + var opts *sql.TxOptions + if es.client.Database.Type() == "postgres" { + opts = pushTxOpts + } + tx, err = es.client.BeginTx(ctx, opts) + if err != nil { + return nil, err + } + defer func() { + err = database.CloseTransaction(tx, err) + }() } // tx is not closed because [crdb.ExecuteInTx] takes care of that var ( @@ -42,43 +75,30 @@ func (es *Eventstore) Push(ctx context.Context, commands ...eventstore.Command) return nil, err } - // needs to be set like this because psql complains about parameters in the SET statement - _, err = tx.ExecContext(ctx, "SET application_name = '"+appNamePrefix+authz.GetInstance(ctx).InstanceID()+"'") + sequences, err = latestSequences(ctx, tx, commands) if err != nil { - logging.WithError(err).Warn("failed to set application name") return nil, err } - err = crdb.ExecuteInTx(ctx, &transaction{tx}, func() (err error) { - inTxCtx, span := tracing.NewSpan(ctx) - defer func() { span.EndWithError(err) }() + events, err = insertEvents(ctx, tx, sequences, commands) + if err != nil { + return nil, err + } - sequences, err = latestSequences(inTxCtx, tx, commands) + if err = handleUniqueConstraints(ctx, tx, commands); err != nil { + return nil, err + } + + // CockroachDB by default does not allow multiple modifications of the same table using ON CONFLICT + // Thats why we enable it manually + if es.client.Type() == "cockroach" { + _, err = tx.Exec("SET enable_multiple_modifications_of_table = on") if err != nil { - return err + return nil, err } + } - events, err = insertEvents(inTxCtx, tx, sequences, commands) - if err != nil { - return err - } - - if err = handleUniqueConstraints(inTxCtx, tx, commands); err != nil { - return err - } - - // CockroachDB by default does not allow multiple modifications of the same table using ON CONFLICT - // Thats why we enable it manually - if es.client.Type() == "cockroach" { - _, err = tx.Exec("SET enable_multiple_modifications_of_table = on") - if err != nil { - return err - } - } - - return handleFieldCommands(inTxCtx, tx, commands) - }) - + err = handleFieldCommands(ctx, tx, commands) if err != nil { return nil, err } @@ -89,7 +109,7 @@ func (es *Eventstore) Push(ctx context.Context, commands ...eventstore.Command) //go:embed push.sql var pushStmt string -func insertEvents(ctx context.Context, tx *sql.Tx, sequences []*latestSequence, commands []eventstore.Command) ([]eventstore.Event, error) { +func insertEvents(ctx context.Context, tx database.Tx, sequences []*latestSequence, commands []eventstore.Command) ([]eventstore.Event, error) { events, placeholders, args, err := mapCommands(commands, sequences) if err != nil { return nil, err @@ -186,7 +206,7 @@ func mapCommands(commands []eventstore.Command, sequences []*latestSequence) (ev } type transaction struct { - *sql.Tx + database.Tx } var _ crdb.Tx = (*transaction)(nil) diff --git a/internal/eventstore/v3/sequence.go b/internal/eventstore/v3/sequence.go index 8d84ef4755..7d97e1080d 100644 --- a/internal/eventstore/v3/sequence.go +++ b/internal/eventstore/v3/sequence.go @@ -10,6 +10,7 @@ import ( "github.com/zitadel/logging" "github.com/zitadel/zitadel/internal/api/authz" + "github.com/zitadel/zitadel/internal/database" "github.com/zitadel/zitadel/internal/eventstore" "github.com/zitadel/zitadel/internal/zerrors" ) @@ -22,7 +23,7 @@ type latestSequence struct { //go:embed sequences_query.sql var latestSequencesStmt string -func latestSequences(ctx context.Context, tx *sql.Tx, commands []eventstore.Command) ([]*latestSequence, error) { +func latestSequences(ctx context.Context, tx database.Tx, commands []eventstore.Command) ([]*latestSequence, error) { sequences := commandsToSequences(ctx, commands) conditions, args := sequencesToSql(sequences) diff --git a/internal/eventstore/v3/unique_constraints.go b/internal/eventstore/v3/unique_constraints.go index e3bae89805..9c4d1831c4 100644 --- a/internal/eventstore/v3/unique_constraints.go +++ b/internal/eventstore/v3/unique_constraints.go @@ -2,7 +2,6 @@ package eventstore import ( "context" - "database/sql" _ "embed" "errors" "fmt" @@ -11,6 +10,7 @@ import ( "github.com/jackc/pgx/v5/pgconn" "github.com/zitadel/logging" + "github.com/zitadel/zitadel/internal/database" "github.com/zitadel/zitadel/internal/eventstore" "github.com/zitadel/zitadel/internal/zerrors" ) @@ -24,7 +24,7 @@ var ( addConstraintStmt string ) -func handleUniqueConstraints(ctx context.Context, tx *sql.Tx, commands []eventstore.Command) error { +func handleUniqueConstraints(ctx context.Context, tx database.Tx, commands []eventstore.Command) error { deletePlaceholders := make([]string, 0) deleteArgs := make([]any, 0) From 7714af6f5b871ccb2a61e1da4ea64dd7dadb8763 Mon Sep 17 00:00:00 2001 From: Silvan Date: Mon, 25 Nov 2024 07:02:59 +0100 Subject: [PATCH 23/64] fix(eventstore): correct database type in `PushWithClient` (#8949) # Which Problems Are Solved `eventstore.PushWithClient` required the wrong type of for the client parameter. # How the Problems Are Solved Changed type of client from `database.Client` to `database.QueryExecutor` --- internal/eventstore/eventstore.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/eventstore/eventstore.go b/internal/eventstore/eventstore.go index 6246978739..22dfde3f4f 100644 --- a/internal/eventstore/eventstore.go +++ b/internal/eventstore/eventstore.go @@ -90,7 +90,7 @@ func (es *Eventstore) Push(ctx context.Context, cmds ...Command) ([]Event, error // PushWithClient pushes the events in a single transaction using the provided database client // an event needs at least an aggregate -func (es *Eventstore) PushWithClient(ctx context.Context, client database.Client, cmds ...Command) ([]Event, error) { +func (es *Eventstore) PushWithClient(ctx context.Context, client database.QueryExecuter, cmds ...Command) ([]Event, error) { if es.PushTimeout > 0 { var cancel func() ctx, cancel = context.WithTimeout(ctx, es.PushTimeout) From ae49b390a22eb777741be49b599ccc23a8dc8f69 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 25 Nov 2024 09:04:30 +0100 Subject: [PATCH 24/64] chore(deps): bump cross-spawn from 7.0.3 to 7.0.6 in /console (#8941) Bumps [cross-spawn](https://github.com/moxystudio/node-cross-spawn) from 7.0.3 to 7.0.6.
Changelog

Sourced from cross-spawn's changelog.

7.0.6 (2024-11-18)

Bug Fixes

  • update cross-spawn version to 7.0.5 in package-lock.json (f700743)

7.0.5 (2024-11-07)

Bug Fixes

  • fix escaping bug introduced by backtracking (640d391)

7.0.4 (2024-11-07)

Bug Fixes

Commits
  • 77cd97f chore(release): 7.0.6
  • 6717de4 chore: upgrade standard-version
  • f700743 fix: update cross-spawn version to 7.0.5 in package-lock.json
  • 9a7e3b2 chore: fix build status badge
  • 0852683 chore(release): 7.0.5
  • 640d391 fix: fix escaping bug introduced by backtracking
  • bff0c87 chore: remove codecov
  • a7c6abc chore: replace travis with github workflows
  • 9b9246e chore(release): 7.0.4
  • 5ff3a07 fix: disable regexp backtracking (#160)
  • Additional commits viewable in compare view

[![Dependabot compatibility score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=cross-spawn&package-manager=npm_and_yarn&previous-version=7.0.3&new-version=7.0.6)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores) Dependabot will resolve any conflicts with this PR as long as you don't alter it yourself. You can also trigger a rebase manually by commenting `@dependabot rebase`. [//]: # (dependabot-automerge-start) [//]: # (dependabot-automerge-end) ---
Dependabot commands and options
You can trigger Dependabot actions by commenting on this PR: - `@dependabot rebase` will rebase this PR - `@dependabot recreate` will recreate this PR, overwriting any edits that have been made to it - `@dependabot merge` will merge this PR after your CI passes on it - `@dependabot squash and merge` will squash and merge this PR after your CI passes on it - `@dependabot cancel merge` will cancel a previously requested merge and block automerging - `@dependabot reopen` will reopen this PR if it is closed - `@dependabot close` will close this PR and stop Dependabot recreating it. You can achieve the same result by closing it manually - `@dependabot show ignore conditions` will show all of the ignore conditions of the specified dependency - `@dependabot ignore this major version` will close this PR and stop Dependabot creating any more for this major version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this minor version` will close this PR and stop Dependabot creating any more for this minor version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this dependency` will close this PR and stop Dependabot creating any more for this dependency (unless you reopen the PR or upgrade to it yourself) You can disable automated security fix PRs for this repo from the [Security Alerts page](https://github.com/zitadel/zitadel/network/alerts).
Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Max Peintner --- console/yarn.lock | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/console/yarn.lock b/console/yarn.lock index 74e8599a93..c54f09c395 100644 --- a/console/yarn.lock +++ b/console/yarn.lock @@ -4537,9 +4537,9 @@ critters@0.0.20: pretty-bytes "^5.3.0" cross-spawn@^7.0.0, cross-spawn@^7.0.2, cross-spawn@^7.0.3: - version "7.0.3" - resolved "https://registry.yarnpkg.com/cross-spawn/-/cross-spawn-7.0.3.tgz#f73a85b9d5d41d045551c177e2882d4ac85728a6" - integrity sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w== + version "7.0.6" + resolved "https://registry.yarnpkg.com/cross-spawn/-/cross-spawn-7.0.6.tgz#8a58fe78f00dcd70c370451759dfbfaf03e8ee9f" + integrity sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA== dependencies: path-key "^3.1.0" shebang-command "^2.0.0" From 91290d6195cb7e91c5780cc51c3215d218e50a5e Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 25 Nov 2024 08:30:09 +0000 Subject: [PATCH 25/64] chore(deps): bump cross-spawn from 7.0.3 to 7.0.6 in /docs (#8925) Bumps [cross-spawn](https://github.com/moxystudio/node-cross-spawn) from 7.0.3 to 7.0.6.
Changelog

Sourced from cross-spawn's changelog.

7.0.6 (2024-11-18)

Bug Fixes

  • update cross-spawn version to 7.0.5 in package-lock.json (f700743)

7.0.5 (2024-11-07)

Bug Fixes

  • fix escaping bug introduced by backtracking (640d391)

7.0.4 (2024-11-07)

Bug Fixes

Commits
  • 77cd97f chore(release): 7.0.6
  • 6717de4 chore: upgrade standard-version
  • f700743 fix: update cross-spawn version to 7.0.5 in package-lock.json
  • 9a7e3b2 chore: fix build status badge
  • 0852683 chore(release): 7.0.5
  • 640d391 fix: fix escaping bug introduced by backtracking
  • bff0c87 chore: remove codecov
  • a7c6abc chore: replace travis with github workflows
  • 9b9246e chore(release): 7.0.4
  • 5ff3a07 fix: disable regexp backtracking (#160)
  • Additional commits viewable in compare view

[![Dependabot compatibility score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=cross-spawn&package-manager=npm_and_yarn&previous-version=7.0.3&new-version=7.0.6)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores) Dependabot will resolve any conflicts with this PR as long as you don't alter it yourself. You can also trigger a rebase manually by commenting `@dependabot rebase`. [//]: # (dependabot-automerge-start) [//]: # (dependabot-automerge-end) ---
Dependabot commands and options
You can trigger Dependabot actions by commenting on this PR: - `@dependabot rebase` will rebase this PR - `@dependabot recreate` will recreate this PR, overwriting any edits that have been made to it - `@dependabot merge` will merge this PR after your CI passes on it - `@dependabot squash and merge` will squash and merge this PR after your CI passes on it - `@dependabot cancel merge` will cancel a previously requested merge and block automerging - `@dependabot reopen` will reopen this PR if it is closed - `@dependabot close` will close this PR and stop Dependabot recreating it. You can achieve the same result by closing it manually - `@dependabot show ignore conditions` will show all of the ignore conditions of the specified dependency - `@dependabot ignore this major version` will close this PR and stop Dependabot creating any more for this major version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this minor version` will close this PR and stop Dependabot creating any more for this minor version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this dependency` will close this PR and stop Dependabot creating any more for this dependency (unless you reopen the PR or upgrade to it yourself) You can disable automated security fix PRs for this repo from the [Security Alerts page](https://github.com/zitadel/zitadel/network/alerts).
Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Max Peintner --- docs/yarn.lock | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/yarn.lock b/docs/yarn.lock index b5d92b98b4..94698e4821 100644 --- a/docs/yarn.lock +++ b/docs/yarn.lock @@ -3909,9 +3909,9 @@ cross-fetch@3.1.5: node-fetch "2.6.7" cross-spawn@^7.0.0, cross-spawn@^7.0.3: - version "7.0.3" - resolved "https://registry.yarnpkg.com/cross-spawn/-/cross-spawn-7.0.3.tgz#f73a85b9d5d41d045551c177e2882d4ac85728a6" - integrity sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w== + version "7.0.6" + resolved "https://registry.yarnpkg.com/cross-spawn/-/cross-spawn-7.0.6.tgz#8a58fe78f00dcd70c370451759dfbfaf03e8ee9f" + integrity sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA== dependencies: path-key "^3.1.0" shebang-command "^2.0.0" From ff70ede7c78fc3ffa36aa2639a896597e89a786b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tim=20M=C3=B6hlmann?= Date: Mon, 25 Nov 2024 17:25:11 +0200 Subject: [PATCH 26/64] feat(eventstore): exclude aggregate IDs when event_type occurred (#8940) # Which Problems Are Solved For truly event-based notification handler, we need to be able to filter out events of aggregates which are already handled. For example when an event like `notify.success` or `notify.failed` was created on an aggregate, we no longer require events from that aggregate ID. # How the Problems Are Solved Extend the query builder to use a `NOT IN` clause which excludes aggregate IDs when they have certain events for a certain aggregate type. For optimization and proper index usages, certain filters are inherited from the parent query, such as: - Instance ID - Instance IDs - Position offset This is a prettified query as used by the unit tests: ```sql SELECT created_at, event_type, "sequence", "position", payload, creator, "owner", instance_id, aggregate_type, aggregate_id, revision FROM eventstore.events2 WHERE instance_id = $1 AND aggregate_type = $2 AND event_type = $3 AND "position" > $4 AND aggregate_id NOT IN ( SELECT aggregate_id FROM eventstore.events2 WHERE aggregate_type = $5 AND event_type = ANY($6) AND instance_id = $7 AND "position" > $8 ) ORDER BY "position" DESC, in_tx_order DESC LIMIT $9 ``` I used this query to run it against the `oidc_session` aggregate looking for added events, excluding aggregates where a token was revoked, against a recent position. It fully used index scans:
```json [ { "Plan": { "Node Type": "Index Scan", "Parallel Aware": false, "Async Capable": false, "Scan Direction": "Forward", "Index Name": "es_projection", "Relation Name": "events2", "Alias": "events2", "Actual Rows": 2, "Actual Loops": 1, "Index Cond": "((instance_id = '286399006995644420'::text) AND (aggregate_type = 'oidc_session'::text) AND (event_type = 'oidc_session.added'::text) AND (\"position\" > 1731582100.784168))", "Rows Removed by Index Recheck": 0, "Filter": "(NOT (hashed SubPlan 1))", "Rows Removed by Filter": 1, "Plans": [ { "Node Type": "Index Scan", "Parent Relationship": "SubPlan", "Subplan Name": "SubPlan 1", "Parallel Aware": false, "Async Capable": false, "Scan Direction": "Forward", "Index Name": "es_projection", "Relation Name": "events2", "Alias": "events2_1", "Actual Rows": 1, "Actual Loops": 1, "Index Cond": "((instance_id = '286399006995644420'::text) AND (aggregate_type = 'oidc_session'::text) AND (event_type = 'oidc_session.access_token.revoked'::text) AND (\"position\" > 1731582100.784168))", "Rows Removed by Index Recheck": 0 } ] }, "Triggers": [ ] } ] ```
# Additional Changes - None # Additional Context - Related to https://github.com/zitadel/zitadel/issues/8931 --------- Co-authored-by: adlerhurst --- .../eventstore/eventstore_querier_test.go | 33 ++++++++ .../eventstore/repository/search_query.go | 54 ++++++++++--- internal/eventstore/repository/sql/query.go | 17 +++++ .../eventstore/repository/sql/query_test.go | 76 ++++++++++++++++++- internal/eventstore/search_query.go | 46 +++++++++++ 5 files changed, 216 insertions(+), 10 deletions(-) diff --git a/internal/eventstore/eventstore_querier_test.go b/internal/eventstore/eventstore_querier_test.go index 856bb4a20e..4b7ad78b25 100644 --- a/internal/eventstore/eventstore_querier_test.go +++ b/internal/eventstore/eventstore_querier_test.go @@ -66,6 +66,39 @@ func TestCRDB_Filter(t *testing.T) { }, wantErr: false, }, + { + name: "exclude aggregate type and event type", + args: args{ + searchQuery: eventstore.NewSearchQueryBuilder(eventstore.ColumnsEvent). + AddQuery(). + AggregateTypes(eventstore.AggregateType(t.Name())). + Builder(). + ExcludeAggregateIDs(). + EventTypes("test.updated"). + AggregateTypes(eventstore.AggregateType(t.Name())). + Builder(), + }, + fields: fields{ + existingEvents: []eventstore.Command{ + generateCommand(eventstore.AggregateType(t.Name()), "306"), + generateCommand( + eventstore.AggregateType(t.Name()), + "306", + func(te *testEvent) { + te.EventType = "test.updated" + }, + ), + generateCommand( + eventstore.AggregateType(t.Name()), + "308", + ), + }, + }, + res: res{ + eventCount: 1, + }, + wantErr: false, + }, } for _, tt := range tests { for querierName, querier := range queriers { diff --git a/internal/eventstore/repository/search_query.go b/internal/eventstore/repository/search_query.go index 32a353049d..f84c7f1201 100644 --- a/internal/eventstore/repository/search_query.go +++ b/internal/eventstore/repository/search_query.go @@ -22,15 +22,16 @@ type SearchQuery struct { Offset uint32 Desc bool - InstanceID *Filter - InstanceIDs *Filter - ExcludedInstances *Filter - Creator *Filter - Owner *Filter - Position *Filter - Sequence *Filter - CreatedAfter *Filter - CreatedBefore *Filter + InstanceID *Filter + InstanceIDs *Filter + ExcludedInstances *Filter + Creator *Filter + Owner *Filter + Position *Filter + Sequence *Filter + CreatedAfter *Filter + CreatedBefore *Filter + ExcludeAggregateIDs []*Filter } // Filter represents all fields needed to compare a field of an event with a value @@ -171,6 +172,21 @@ func QueryFromBuilder(builder *eventstore.SearchQueryBuilder) (*SearchQuery, err query.SubQueries[i] = append(query.SubQueries[i], filter) } } + if excludeAggregateIDs := builder.GetExcludeAggregateIDs(); excludeAggregateIDs != nil { + for _, f := range []func(query *eventstore.ExclusionQuery) *Filter{ + excludeAggregateTypeFilter, + excludeEventTypeFilter, + } { + filter := f(excludeAggregateIDs) + if filter == nil { + continue + } + if err := filter.Validate(); err != nil { + return nil, err + } + query.ExcludeAggregateIDs = append(query.ExcludeAggregateIDs, filter) + } + } return query, nil } @@ -286,3 +302,23 @@ func eventPositionAfterFilter(query *eventstore.SearchQuery) *Filter { } return nil } + +func excludeEventTypeFilter(query *eventstore.ExclusionQuery) *Filter { + if len(query.GetEventTypes()) < 1 { + return nil + } + if len(query.GetEventTypes()) == 1 { + return NewFilter(FieldEventType, query.GetEventTypes()[0], OperationEquals) + } + return NewFilter(FieldEventType, database.TextArray[eventstore.EventType](query.GetEventTypes()), OperationIn) +} + +func excludeAggregateTypeFilter(query *eventstore.ExclusionQuery) *Filter { + if len(query.GetAggregateTypes()) < 1 { + return nil + } + if len(query.GetAggregateTypes()) == 1 { + return NewFilter(FieldAggregateType, query.GetAggregateTypes()[0], OperationEquals) + } + return NewFilter(FieldAggregateType, database.TextArray[eventstore.AggregateType](query.GetAggregateTypes()), OperationIn) +} diff --git a/internal/eventstore/repository/sql/query.go b/internal/eventstore/repository/sql/query.go index dda12187f9..01f0c080e3 100644 --- a/internal/eventstore/repository/sql/query.go +++ b/internal/eventstore/repository/sql/query.go @@ -283,6 +283,23 @@ func prepareConditions(criteria querier, query *repository.SearchQuery, useV1 bo args = append(args, additionalArgs...) } + excludeAggregateIDs := query.ExcludeAggregateIDs + if len(excludeAggregateIDs) > 0 { + excludeAggregateIDs = append(excludeAggregateIDs, query.InstanceID, query.InstanceIDs, query.Position) + } + excludeAggregateIDsClauses, excludeAggregateIDsArgs := prepareQuery(criteria, useV1, excludeAggregateIDs...) + if excludeAggregateIDsClauses != "" { + if clauses != "" { + clauses += " AND " + } + if useV1 { + clauses += "aggregate_id NOT IN (SELECT aggregate_id FROM eventstore.events WHERE " + excludeAggregateIDsClauses + ")" + } else { + clauses += "aggregate_id NOT IN (SELECT aggregate_id FROM eventstore.events2 WHERE " + excludeAggregateIDsClauses + ")" + } + args = append(args, excludeAggregateIDsArgs...) + } + if query.AwaitOpenTransactions { instanceIDs := make(database.TextArray[string], 0, 3) if query.InstanceID != nil { diff --git a/internal/eventstore/repository/sql/query_test.go b/internal/eventstore/repository/sql/query_test.go index a3d47b838d..2caa9da72d 100644 --- a/internal/eventstore/repository/sql/query_test.go +++ b/internal/eventstore/repository/sql/query_test.go @@ -5,6 +5,7 @@ import ( "database/sql" "database/sql/driver" "reflect" + "regexp" "strconv" "testing" "time" @@ -744,6 +745,7 @@ func Test_query_events_mocked(t *testing.T) { type args struct { query *eventstore.SearchQueryBuilder dest interface{} + useV1 bool } type res struct { wantErr bool @@ -767,6 +769,7 @@ func Test_query_events_mocked(t *testing.T) { AddQuery(). AggregateTypes("user"). Builder(), + useV1: true, }, fields: fields{ mock: newMockClient(t).expectQuery(t, @@ -789,6 +792,7 @@ func Test_query_events_mocked(t *testing.T) { AddQuery(). AggregateTypes("user"). Builder(), + useV1: true, }, fields: fields{ mock: newMockClient(t).expectQuery(t, @@ -811,6 +815,7 @@ func Test_query_events_mocked(t *testing.T) { AddQuery(). AggregateTypes("user"). Builder(), + useV1: true, }, fields: fields{ mock: newMockClient(t).expectQuery(t, @@ -834,6 +839,7 @@ func Test_query_events_mocked(t *testing.T) { AddQuery(). AggregateTypes("user"). Builder(), + useV1: true, }, fields: fields{ mock: newMockClient(t).expectQuery(t, @@ -855,6 +861,7 @@ func Test_query_events_mocked(t *testing.T) { AddQuery(). AggregateTypes("user"). Builder().LockRowsDuringTx(nil, eventstore.LockOptionWait), + useV1: true, }, fields: fields{ mock: newMockClient(t).expectQuery(t, @@ -876,6 +883,7 @@ func Test_query_events_mocked(t *testing.T) { AddQuery(). AggregateTypes("user"). Builder().LockRowsDuringTx(nil, eventstore.LockOptionNoWait), + useV1: true, }, fields: fields{ mock: newMockClient(t).expectQuery(t, @@ -897,6 +905,7 @@ func Test_query_events_mocked(t *testing.T) { AddQuery(). AggregateTypes("user"). Builder().LockRowsDuringTx(nil, eventstore.LockOptionSkipLocked), + useV1: true, }, fields: fields{ mock: newMockClient(t).expectQuery(t, @@ -919,6 +928,7 @@ func Test_query_events_mocked(t *testing.T) { AddQuery(). AggregateTypes("user"). Builder(), + useV1: true, }, fields: fields{ mock: newMockClient(t).expectQueryErr(t, @@ -941,6 +951,7 @@ func Test_query_events_mocked(t *testing.T) { AddQuery(). AggregateTypes("user"). Builder(), + useV1: true, }, fields: fields{ mock: newMockClient(t).expectQueryScanErr(t, @@ -975,6 +986,7 @@ func Test_query_events_mocked(t *testing.T) { AggregateTypes("org"). AggregateIDs("asdf42"). Builder(), + useV1: true, }, fields: fields{ mock: newMockClient(t).expectQuery(t, @@ -986,6 +998,68 @@ func Test_query_events_mocked(t *testing.T) { wantErr: false, }, }, + { + name: "aggregate / event type, position and exclusion, v1", + args: args{ + dest: &[]*repository.Event{}, + query: eventstore.NewSearchQueryBuilder(eventstore.ColumnsEvent). + InstanceID("instanceID"). + OrderDesc(). + Limit(5). + PositionAfter(123.456). + AddQuery(). + AggregateTypes("notify"). + EventTypes("notify.foo.bar"). + Builder(). + ExcludeAggregateIDs(). + AggregateTypes("notify"). + EventTypes("notification.failed", "notification.success"). + Builder(), + useV1: true, + }, + fields: fields{ + mock: newMockClient(t).expectQuery(t, + regexp.QuoteMeta( + `SELECT creation_date, event_type, event_sequence, event_data, editor_user, resource_owner, instance_id, aggregate_type, aggregate_id, aggregate_version FROM eventstore.events WHERE instance_id = $1 AND aggregate_type = $2 AND event_type = $3 AND "position" > $4 AND aggregate_id NOT IN (SELECT aggregate_id FROM eventstore.events WHERE aggregate_type = $5 AND event_type = ANY($6) AND instance_id = $7 AND "position" > $8) ORDER BY event_sequence DESC LIMIT $9`, + ), + []driver.Value{"instanceID", eventstore.AggregateType("notify"), eventstore.EventType("notify.foo.bar"), 123.456, eventstore.AggregateType("notify"), []eventstore.EventType{"notification.failed", "notification.success"}, "instanceID", 123.456, uint64(5)}, + ), + }, + res: res{ + wantErr: false, + }, + }, + { + name: "aggregate / event type, position and exclusion, v2", + args: args{ + dest: &[]*repository.Event{}, + query: eventstore.NewSearchQueryBuilder(eventstore.ColumnsEvent). + InstanceID("instanceID"). + OrderDesc(). + Limit(5). + PositionAfter(123.456). + AddQuery(). + AggregateTypes("notify"). + EventTypes("notify.foo.bar"). + Builder(). + ExcludeAggregateIDs(). + AggregateTypes("notify"). + EventTypes("notification.failed", "notification.success"). + Builder(), + useV1: false, + }, + fields: fields{ + mock: newMockClient(t).expectQuery(t, + regexp.QuoteMeta( + `SELECT created_at, event_type, "sequence", "position", payload, creator, "owner", instance_id, aggregate_type, aggregate_id, revision FROM eventstore.events2 WHERE instance_id = $1 AND aggregate_type = $2 AND event_type = $3 AND "position" > $4 AND aggregate_id NOT IN (SELECT aggregate_id FROM eventstore.events2 WHERE aggregate_type = $5 AND event_type = ANY($6) AND instance_id = $7 AND "position" > $8) ORDER BY "position" DESC, in_tx_order DESC LIMIT $9`, + ), + []driver.Value{"instanceID", eventstore.AggregateType("notify"), eventstore.EventType("notify.foo.bar"), 123.456, eventstore.AggregateType("notify"), []eventstore.EventType{"notification.failed", "notification.success"}, "instanceID", 123.456, uint64(5)}, + ), + }, + res: res{ + wantErr: false, + }, + }, } crdb := NewCRDB(&database.DB{Database: new(testDB)}) for _, tt := range tests { @@ -994,7 +1068,7 @@ func Test_query_events_mocked(t *testing.T) { crdb.DB.DB = tt.fields.mock.client } - err := query(context.Background(), crdb, tt.args.query, tt.args.dest, true) + err := query(context.Background(), crdb, tt.args.query, tt.args.dest, tt.args.useV1) if (err != nil) != tt.res.wantErr { t.Errorf("query() error = %v, wantErr %v", err, tt.res.wantErr) } diff --git a/internal/eventstore/search_query.go b/internal/eventstore/search_query.go index 58e25f6567..df38d15def 100644 --- a/internal/eventstore/search_query.go +++ b/internal/eventstore/search_query.go @@ -21,6 +21,7 @@ type SearchQueryBuilder struct { instanceIDs []string editorUser string queries []*SearchQuery + excludeAggregateIDs *ExclusionQuery tx *sql.Tx lockRows bool lockOption LockOption @@ -68,6 +69,10 @@ func (b *SearchQueryBuilder) GetQueries() []*SearchQuery { return b.queries } +func (b *SearchQueryBuilder) GetExcludeAggregateIDs() *ExclusionQuery { + return b.excludeAggregateIDs +} + func (b *SearchQueryBuilder) GetTx() *sql.Tx { return b.tx } @@ -136,6 +141,20 @@ func (q SearchQuery) GetPositionAfter() float64 { return q.positionAfter } +type ExclusionQuery struct { + builder *SearchQueryBuilder + aggregateTypes []AggregateType + eventTypes []EventType +} + +func (q ExclusionQuery) GetAggregateTypes() []AggregateType { + return q.aggregateTypes +} + +func (q ExclusionQuery) GetEventTypes() []EventType { + return q.eventTypes +} + // Columns defines which fields of the event are needed for the query type Columns int8 @@ -346,6 +365,16 @@ func (builder *SearchQueryBuilder) AddQuery() *SearchQuery { return query } +// ExcludeAggregateIDs excludes events from the aggregate IDs returned by the [ExclusionQuery]. +// There can be only 1 exclusion query. Subsequent calls overwrite previous definitions. +func (builder *SearchQueryBuilder) ExcludeAggregateIDs() *ExclusionQuery { + query := &ExclusionQuery{ + builder: builder, + } + builder.excludeAggregateIDs = query + return query +} + // Or creates a new sub query on the search query builder func (query SearchQuery) Or() *SearchQuery { return query.builder.AddQuery() @@ -398,3 +427,20 @@ func (query *SearchQuery) matches(command Command) bool { } return true } + +// AggregateTypes filters for events with the given aggregate types +func (query *ExclusionQuery) AggregateTypes(types ...AggregateType) *ExclusionQuery { + query.aggregateTypes = types + return query +} + +// EventTypes filters for events with the given event types +func (query *ExclusionQuery) EventTypes(types ...EventType) *ExclusionQuery { + query.eventTypes = types + return query +} + +// Builder returns the SearchQueryBuilder of the sub query +func (query *ExclusionQuery) Builder() *SearchQueryBuilder { + return query.builder +} From 33bff5a4b0883b0b8e97be1818f5c8bd348100e6 Mon Sep 17 00:00:00 2001 From: Luka Waymouth <155040393+lukawaay@users.noreply.github.com> Date: Tue, 26 Nov 2024 04:00:21 -0500 Subject: [PATCH 27/64] fix(console): bug fixes for ListProjectRoles and general pagination (#8938) # Which Problems Are Solved A number of small problems are fixed relating to the project roles listed in various places in the UI: - Fixes issue #8460 - Fixes an issue where the "Master checkbox" that's supposed to check and uncheck all list items breaks when there's multiple pages of results. Demonstration images are attached at the end of the PR. - Fixes an issue where the "Edit Role" dialog opened by clicking on a role in the list will not save any changes if the role's group is empty even though empty groups are allowed during creation. - Fixes issues where the list does not properly update after the user modifies or deletes some of its entries. - Fixes an issue for all paginated lists where the page number information (like "0-25" specifying that items 0 through 25 are shown on screen) was inaccurate, as described in #8460. # How the Problems Are Solved - Fixes buggy handling of pre-selected roles while editing a grant so that all selected roles are saved instead of only the ones on the current page. - Triggers the entire page to be reloaded when a user modifies or deletes a role to easily ensure the information on the screen is accurate. - Revises checkbox logic so that the "Master checkbox" will apply only to rows on the current page. I think this is the correct behavior but tell me if it should be changed. - Other fixes to faulty logic. # Additional Changes - I made clicking on a group name toggle all the rows in that group on the screen, instead of just turning them on. Tell me if this should be changed back to what it was before. # Additional Context - Closes #8460 ## An example of the broken checkboxes: ![2024-11-20_03-11-1732091377](https://github.com/user-attachments/assets/9f01f529-aac9-4669-92df-2abbe67e4983) ![2024-11-20_03-11-1732091365](https://github.com/user-attachments/assets/e7b8bed6-5cef-4c9f-9ecf-45ed41640dc6) ![2024-11-20_03-11-1732091357](https://github.com/user-attachments/assets/d404bc78-68fd-472d-b450-6578658f48ab) ![2024-11-20_03-11-1732091348](https://github.com/user-attachments/assets/a5976816-802b-4eab-bc61-58babc0b68f7) --------- Co-authored-by: Max Peintner --- .../paginator/paginator.component.html | 6 +- .../modules/paginator/paginator.component.ts | 15 ++++ .../project-role-detail-dialog.component.ts | 4 +- .../project-roles-table.component.html | 8 +- .../project-roles-table.component.ts | 83 ++++++++++--------- 5 files changed, 65 insertions(+), 51 deletions(-) diff --git a/console/src/app/modules/paginator/paginator.component.html b/console/src/app/modules/paginator/paginator.component.html index 9138038437..e74a3e3881 100644 --- a/console/src/app/modules/paginator/paginator.component.html +++ b/console/src/app/modules/paginator/paginator.component.html @@ -8,12 +8,10 @@

- {{ pageIndex * pageSize }} - {{ pageIndex * pageSize + pageSize }} - + {{ startIndex }} - {{ endIndex }}
- + {{ sizeOption }} diff --git a/console/src/app/modules/paginator/paginator.component.ts b/console/src/app/modules/paginator/paginator.component.ts index b4b406acda..4fd62fb568 100644 --- a/console/src/app/modules/paginator/paginator.component.ts +++ b/console/src/app/modules/paginator/paginator.component.ts @@ -50,6 +50,15 @@ export class PaginatorComponent { return temp <= this.length / this.pageSize; } + get startIndex(): number { + return this.pageIndex * this.pageSize; + } + + get endIndex(): number { + const max = this.startIndex + this.pageSize; + return this.length < max ? this.length : max; + } + public emitChange(): void { this.page.emit({ length: this.length, @@ -58,4 +67,10 @@ export class PaginatorComponent { pageSizeOptions: this.pageSizeOptions, }); } + + public updatePageSize(newSize: number): void { + this.pageSize = newSize; + this.pageIndex = 0; + this.emitChange(); + } } diff --git a/console/src/app/modules/project-role-detail-dialog/project-role-detail-dialog.component.ts b/console/src/app/modules/project-role-detail-dialog/project-role-detail-dialog.component.ts index 24c7002778..f62ccc54a1 100644 --- a/console/src/app/modules/project-role-detail-dialog/project-role-detail-dialog.component.ts +++ b/console/src/app/modules/project-role-detail-dialog/project-role-detail-dialog.component.ts @@ -31,9 +31,9 @@ export class ProjectRoleDetailDialogComponent { } submitForm(): void { - if (this.formGroup.valid && this.key?.value && this.group?.value && this.displayName?.value) { + if (this.formGroup.valid && this.key?.value && this.displayName?.value) { this.mgmtService - .updateProjectRole(this.projectId, this.key.value, this.displayName.value, this.group.value) + .updateProjectRole(this.projectId, this.key.value, this.displayName.value, this.group?.value) .then(() => { this.toast.showInfo('PROJECT.TOAST.ROLECHANGED', true); this.dialogRef.close(true); diff --git a/console/src/app/modules/project-roles-table/project-roles-table.component.html b/console/src/app/modules/project-roles-table/project-roles-table.component.html index 851e6848aa..48ad2851bb 100644 --- a/console/src/app/modules/project-roles-table/project-roles-table.component.html +++ b/console/src/app/modules/project-roles-table/project-roles-table.component.html @@ -35,8 +35,8 @@ [disabled]="disabled" color="primary" (change)="$event ? masterToggle() : null" - [checked]="selection.hasValue() && isAllSelected()" - [indeterminate]="selection.hasValue() && !isAllSelected()" + [checked]="isAnySelected() && isAllSelected()" + [indeterminate]="isAnySelected() && !isAllSelected()" >
@@ -76,7 +76,7 @@ class="role state" [ngClass]="{ 'no-selection': !selectionAllowed }" *ngIf="role.group" - (click)="selectionAllowed ? selectAllOfGroup(role.group) : openDetailDialog(role)" + (click)="selectionAllowed ? groupMasterToggle(role.group) : openDetailDialog(role)" [matTooltip]="selectionAllowed ? ('PROJECT.ROLE.SELECTGROUPTOOLTIP' | translate: role) : null" >{{ role.group }} @@ -135,7 +135,7 @@ #paginator [timestamp]="dataSource.viewTimestamp" [length]="dataSource.totalResult" - [pageSize]="50" + [pageSize]="INITIAL_PAGE_SIZE" (page)="changePage()" [pageSizeOptions]="[25, 50, 100, 250]" > diff --git a/console/src/app/modules/project-roles-table/project-roles-table.component.ts b/console/src/app/modules/project-roles-table/project-roles-table.component.ts index ae8633d15d..2159782411 100644 --- a/console/src/app/modules/project-roles-table/project-roles-table.component.ts +++ b/console/src/app/modules/project-roles-table/project-roles-table.component.ts @@ -18,6 +18,7 @@ import { ProjectRolesDataSource } from './project-roles-table-datasource'; styleUrls: ['./project-roles-table.component.scss'], }) export class ProjectRolesTableComponent implements OnInit { + public INITIAL_PAGE_SIZE: number = 50; @Input() public projectId: string = ''; @Input() public grantId: string = ''; @Input() public disabled: boolean = false; @@ -43,41 +44,58 @@ export class ProjectRolesTableComponent implements OnInit { } public ngOnInit(): void { - this.dataSource.loadRoles(this.projectId, this.grantId, 0, 25, 'asc'); - - this.dataSource.rolesSubject.subscribe((roles) => { - const selectedRoles: Role.AsObject[] = roles.filter((role) => this.selectedKeys.includes(role.key)); - this.selection.select(...selectedRoles.map((r) => r.key)); - }); + this.loadRolesPage(); + this.selection.select(...this.selectedKeys); this.selection.changed.subscribe(() => { this.changedSelection.emit(this.selection.selected); }); } - public selectAllOfGroup(group: string): void { - const groupRoles: Role.AsObject[] = this.dataSource.rolesSubject.getValue().filter((role) => role.group === group); - this.selection.select(...groupRoles.map((r) => r.key)); - } - private loadRolesPage(): void { - this.dataSource.loadRoles(this.projectId, this.grantId, this.paginator?.pageIndex ?? 0, this.paginator?.pageSize ?? 25); + this.dataSource.loadRoles( + this.projectId, + this.grantId, + this.paginator?.pageIndex ?? 0, + this.paginator?.pageSize ?? this.INITIAL_PAGE_SIZE, + ); } public changePage(): void { this.loadRolesPage(); } - public isAllSelected(): boolean { - const numSelected = this.selection.selected.length; - const numRows = this.dataSource.totalResult; - return numSelected === numRows; + private listIsAllSelected(list: string[]): boolean { + return list.findIndex((key) => !this.selection.isSelected(key)) == -1; + } + + private listIsAnySelected(list: string[]): boolean { + return list.findIndex((key) => this.selection.isSelected(key)) != -1; + } + + private listMasterToggle(list: string[]): void { + if (this.listIsAllSelected(list)) this.selection.deselect(...list); + else this.selection.select(...list); + } + + private compilePageKeys(): string[] { + return this.dataSource.rolesSubject.value.map((role) => role.key); } public masterToggle(): void { - this.isAllSelected() - ? this.selection.clear() - : this.dataSource.rolesSubject.value.forEach((row: Role.AsObject) => this.selection.select(row.key)); + this.listMasterToggle(this.compilePageKeys()); + } + + public isAllSelected(): boolean { + return this.listIsAllSelected(this.compilePageKeys()); + } + + public isAnySelected(): boolean { + return this.listIsAnySelected(this.compilePageKeys()); + } + + public groupMasterToggle(group: string): void { + this.listMasterToggle(this.dataSource.rolesSubject.value.filter((role) => role.group == group).map((role) => role.key)); } public deleteRole(role: Role.AsObject): void { @@ -93,45 +111,28 @@ export class ProjectRolesTableComponent implements OnInit { dialogRef.afterClosed().subscribe((resp) => { if (resp) { - const index = this.dataSource.rolesSubject.value.findIndex((iter) => iter.key === role.key); - this.mgmtService.removeProjectRole(this.projectId, role.key).then(() => { this.toast.showInfo('PROJECT.TOAST.ROLEREMOVED', true); - - if (index > -1) { - this.dataSource.rolesSubject.value.splice(index, 1); - this.dataSource.rolesSubject.next(this.dataSource.rolesSubject.value); - } + this.loadRolesPage(); }); } }); } - public removeRole(role: Role.AsObject, index: number): void { - this.mgmtService - .removeProjectRole(this.projectId, role.key) - .then(() => { - this.toast.showInfo('PROJECT.TOAST.ROLEREMOVED', true); - this.dataSource.rolesSubject.value.splice(index, 1); - this.dataSource.rolesSubject.next(this.dataSource.rolesSubject.value); - }) - .catch((error) => { - this.toast.showError(error); - }); - } - public openDetailDialog(role: Role.AsObject): void { - this.dialog.open(ProjectRoleDetailDialogComponent, { + const dialogRef = this.dialog.open(ProjectRoleDetailDialogComponent, { data: { role, projectId: this.projectId, }, width: '400px', }); + + dialogRef.afterClosed().subscribe(() => this.loadRolesPage()); } public refreshPage(): void { - this.dataSource.loadRoles(this.projectId, this.grantId, this.paginator?.pageIndex ?? 0, this.paginator?.pageSize ?? 25); + this.loadRolesPage(); } public get selectionAllowed(): boolean { From 1e0996371edf48125ac8d6b64427abbcf2202522 Mon Sep 17 00:00:00 2001 From: Martin Mayer <38588055+damattl@users.noreply.github.com> Date: Tue, 26 Nov 2024 10:40:35 +0100 Subject: [PATCH 28/64] docs: update flutter dependencies (#8862) The dependencies in the [docs](https://zitadel.com/docs/examples/login/flutter) did not match the ones used in the code [example](https://github.com/zitadel/zitadel_flutter/blob/main/lib/main.dart#L14-L28) Co-authored-by: Max Peintner --- docs/docs/examples/login/flutter.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/docs/examples/login/flutter.md b/docs/docs/examples/login/flutter.md index 87822a01d6..f6e8352831 100644 --- a/docs/docs/examples/login/flutter.md +++ b/docs/docs/examples/login/flutter.md @@ -75,8 +75,8 @@ To install run: ```bash flutter pub add http -flutter pub add flutter_web_auth_2 -flutter pub add flutter_secure_storage +flutter pub add oidc +flutter pub add oidc_default_store ``` #### Setup for Android From cdd4f37ffadfb4f06b7a07e24e5b5a52969c09b4 Mon Sep 17 00:00:00 2001 From: Florian Forster Date: Tue, 26 Nov 2024 12:55:17 +0100 Subject: [PATCH 29/64] chore: improve adopters file (#8966) # Which Problems Are Solved This improves the `ADOPTERS.md` file to better understand its purpose. # How the Problems Are Solved Adding additional instructions to the `ADOPTERS.md` file --- ADOPTERS.md | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/ADOPTERS.md b/ADOPTERS.md index 3bb7e1a707..377a4f6461 100644 --- a/ADOPTERS.md +++ b/ADOPTERS.md @@ -1,9 +1,15 @@ ## Adopters -We are grateful to the organizations and individuals who are using ZITADEL. If you are using ZITADEL, please consider adding your name to this list by submitting a pull request. +Sharing experiences and learning from other users is essential. We are frequently asked who is using a particular feature of Zitadel so people can get in touch with other users to share experiences and best practices. People also often want to know if a specific product or platform has integrated Zitadel. While the Zitadel Discord Community allows users to get in touch, it can be challenging to find this information quickly. + +The following is a directory of adopters to help identify users of individual features. The users themselves directly maintain the list. + +### Adding yourself as a user + +If you are using Zitadel, please consider adding yourself as a user with a quick description of your use case by opening a pull request to this file and adding a section describing your usage of Zitadel. | Organization/Individual | Contact Information | Description of Usage | | ----------------------- | -------------------------------------------------------- | ----------------------------------------------- | -| ZITADEL | [@fforootd](https://github.com/fforootd) (and many more) | ZITADEL Cloud makes heavy use of of ZITADEL ;-) | -| Organization Name | contact@example.com | Description of how they use ZITADEL | -| Individual Name | contact@example.com | Description of how they use ZITADEL | +| Zitadel | [@fforootd](https://github.com/fforootd) (and many more) | Zitadel Cloud makes heavy use of of Zitadel ;-) | +| Organization Name | contact@example.com | Description of how they use Zitadel | +| Individual Name | contact@example.com | Description of how they use Zitadel | From ccef67cefafe0bf239a3046558735e718b8d56b2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tim=20M=C3=B6hlmann?= Date: Tue, 26 Nov 2024 17:26:41 +0200 Subject: [PATCH 30/64] fix(eventstore): cleanup org fields on remove (#8946) # Which Problems Are Solved When an org is removed, the corresponding fields are not deleted. This creates issues, such as recreating a new org with the same verified domain. # How the Problems Are Solved Remove the search fields by the org aggregate, instead of just setting the removed state. # Additional Changes - Cleanup migration script that removed current stale fields. # Additional Context - Closes https://github.com/zitadel/zitadel/issues/8943 - Related to https://github.com/zitadel/zitadel/pull/8790 --------- Co-authored-by: Silvan --- cmd/setup/39.go | 27 +++++++++++++++++++++++++++ cmd/setup/39.sql | 6 ++++++ cmd/setup/config.go | 1 + cmd/setup/setup.go | 2 ++ internal/repository/org/org.go | 18 +----------------- 5 files changed, 37 insertions(+), 17 deletions(-) create mode 100644 cmd/setup/39.go create mode 100644 cmd/setup/39.sql diff --git a/cmd/setup/39.go b/cmd/setup/39.go new file mode 100644 index 0000000000..01b0ddfcbf --- /dev/null +++ b/cmd/setup/39.go @@ -0,0 +1,27 @@ +package setup + +import ( + "context" + _ "embed" + + "github.com/zitadel/zitadel/internal/database" + "github.com/zitadel/zitadel/internal/eventstore" +) + +var ( + //go:embed 39.sql + deleteStaleOrgFields string +) + +type DeleteStaleOrgFields struct { + dbClient *database.DB +} + +func (mig *DeleteStaleOrgFields) Execute(ctx context.Context, _ eventstore.Event) error { + _, err := mig.dbClient.ExecContext(ctx, deleteStaleOrgFields) + return err +} + +func (mig *DeleteStaleOrgFields) String() string { + return "39_delete_stale_org_fields" +} diff --git a/cmd/setup/39.sql b/cmd/setup/39.sql new file mode 100644 index 0000000000..4abc815c25 --- /dev/null +++ b/cmd/setup/39.sql @@ -0,0 +1,6 @@ +DELETE FROM eventstore.fields +WHERE aggregate_type = 'org' +AND aggregate_id NOT IN ( + SELECT id + FROM projections.orgs1 +); diff --git a/cmd/setup/config.go b/cmd/setup/config.go index f688a2a3a4..b0a143b698 100644 --- a/cmd/setup/config.go +++ b/cmd/setup/config.go @@ -125,6 +125,7 @@ type Steps struct { s36FillV2Milestones *FillV3Milestones s37Apps7OIDConfigsBackChannelLogoutURI *Apps7OIDConfigsBackChannelLogoutURI s38BackChannelLogoutNotificationStart *BackChannelLogoutNotificationStart + s39DeleteStaleOrgFields *DeleteStaleOrgFields } func MustNewSteps(v *viper.Viper) *Steps { diff --git a/cmd/setup/setup.go b/cmd/setup/setup.go index aeed1523b1..b8ea708cbf 100644 --- a/cmd/setup/setup.go +++ b/cmd/setup/setup.go @@ -169,6 +169,7 @@ func Setup(ctx context.Context, config *Config, steps *Steps, masterKey string) steps.s36FillV2Milestones = &FillV3Milestones{dbClient: queryDBClient, eventstore: eventstoreClient} steps.s37Apps7OIDConfigsBackChannelLogoutURI = &Apps7OIDConfigsBackChannelLogoutURI{dbClient: esPusherDBClient} steps.s38BackChannelLogoutNotificationStart = &BackChannelLogoutNotificationStart{dbClient: esPusherDBClient, esClient: eventstoreClient} + steps.s39DeleteStaleOrgFields = &DeleteStaleOrgFields{dbClient: esPusherDBClient} err = projection.Create(ctx, projectionDBClient, eventstoreClient, config.Projections, nil, nil, nil) logging.OnError(err).Fatal("unable to start projections") @@ -232,6 +233,7 @@ func Setup(ctx context.Context, config *Config, steps *Steps, masterKey string) steps.s32AddAuthSessionID, steps.s33SMSConfigs3TwilioAddVerifyServiceSid, steps.s37Apps7OIDConfigsBackChannelLogoutURI, + steps.s39DeleteStaleOrgFields, } { mustExecuteMigration(ctx, eventstoreClient, step, "migration failed") } diff --git a/internal/repository/org/org.go b/internal/repository/org/org.go index 95c9c2f3ed..cf8a3ce114 100644 --- a/internal/repository/org/org.go +++ b/internal/repository/org/org.go @@ -310,23 +310,7 @@ func (e *OrgRemovedEvent) UniqueConstraints() []*eventstore.UniqueConstraint { func (e *OrgRemovedEvent) Fields() []*eventstore.FieldOperation { // TODO: project grants are currently not removed because we don't have the relationship between the granted org and the grant return []*eventstore.FieldOperation{ - eventstore.SetField( - e.Aggregate(), - orgSearchObject(e.Aggregate().ID), - OrgStateSearchField, - &eventstore.Value{ - Value: domain.OrgStateRemoved, - ShouldIndex: true, - }, - - eventstore.FieldTypeInstanceID, - eventstore.FieldTypeResourceOwner, - eventstore.FieldTypeAggregateType, - eventstore.FieldTypeAggregateID, - eventstore.FieldTypeObjectType, - eventstore.FieldTypeObjectID, - eventstore.FieldTypeFieldName, - ), + eventstore.RemoveSearchFieldsByAggregate(e.Aggregate()), } } From 4413efd82c6392955474d1a4d4526ad30f73c0e7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tim=20M=C3=B6hlmann?= Date: Wed, 27 Nov 2024 16:32:13 +0200 Subject: [PATCH 31/64] chore: remove parallel running in integration tests (#8904) # Which Problems Are Solved Integration tests are flaky due to eventual consistency. # How the Problems Are Solved Remove t.Parallel so that less concurrent requests on multiple instance happen. This allows the projections to catch up more easily. # Additional Changes - none # Additional Context - none --- .../integration_test/iam_settings_test.go | 4 --- ...ons_allow_public_org_registrations_test.go | 2 -- .../restrictions_allowed_languages_test.go | 2 -- .../grpc/org/v2/integration_test/org_test.go | 2 -- .../org/v2/integration_test/query_test.go | 2 -- .../integration_test/execution_target_test.go | 1 - .../integration_test/execution_test.go | 5 --- .../v3alpha/integration_test/query_test.go | 3 -- .../v3alpha/integration_test/target_test.go | 1 - .../v3alpha/integration_test/email_test.go | 3 -- .../v3alpha/integration_test/phone_test.go | 3 -- .../v3alpha/integration_test/user_test.go | 7 ---- .../v3alpha/integration_test/query_test.go | 2 -- .../integration_test/userschema_test.go | 5 --- .../system/integration_test/instance_test.go | 2 -- .../limits_auditlogretention_test.go | 2 -- .../integration_test/limits_block_test.go | 2 -- .../user/v2/integration_test/email_test.go | 6 ---- .../user/v2/integration_test/idp_link_test.go | 6 ---- .../grpc/user/v2/integration_test/otp_test.go | 8 ----- .../user/v2/integration_test/passkey_test.go | 10 ------ .../user/v2/integration_test/password_test.go | 4 --- .../user/v2/integration_test/phone_test.go | 8 ----- .../user/v2/integration_test/query_test.go | 6 ---- .../user/v2/integration_test/totp_test.go | 6 ---- .../grpc/user/v2/integration_test/u2f_test.go | 6 ---- .../user/v2/integration_test/user_test.go | 32 +------------------ .../v2beta/integration_test/email_test.go | 6 ---- .../user/v2beta/integration_test/otp_test.go | 8 ----- .../v2beta/integration_test/passkey_test.go | 6 ---- .../v2beta/integration_test/password_test.go | 4 --- .../v2beta/integration_test/phone_test.go | 8 ----- .../v2beta/integration_test/query_test.go | 6 ---- .../user/v2beta/integration_test/totp_test.go | 6 ---- .../user/v2beta/integration_test/u2f_test.go | 4 --- .../user/v2beta/integration_test/user_test.go | 28 +--------------- .../integration_test/auth_request_test.go | 26 --------------- .../api/oidc/integration_test/client_test.go | 6 ---- .../api/oidc/integration_test/keys_test.go | 2 -- .../api/oidc/integration_test/server_test.go | 2 -- .../token_client_credentials_test.go | 2 -- .../integration_test/token_exchange_test.go | 6 ---- .../token_jwt_profile_test.go | 2 -- .../integration_test/telemetry_pusher_test.go | 2 -- 44 files changed, 2 insertions(+), 262 deletions(-) diff --git a/internal/api/grpc/admin/integration_test/iam_settings_test.go b/internal/api/grpc/admin/integration_test/iam_settings_test.go index 9eca09c06c..603d4a5d32 100644 --- a/internal/api/grpc/admin/integration_test/iam_settings_test.go +++ b/internal/api/grpc/admin/integration_test/iam_settings_test.go @@ -18,8 +18,6 @@ import ( ) func TestServer_GetSecurityPolicy(t *testing.T) { - t.Parallel() - instance := integration.NewInstance(CTX) adminCtx := instance.WithAuthorization(CTX, integration.UserTypeIAMOwner) @@ -72,8 +70,6 @@ func TestServer_GetSecurityPolicy(t *testing.T) { } func TestServer_SetSecurityPolicy(t *testing.T) { - t.Parallel() - instance := integration.NewInstance(CTX) adminCtx := instance.WithAuthorization(CTX, integration.UserTypeIAMOwner) diff --git a/internal/api/grpc/admin/integration_test/restrictions_allow_public_org_registrations_test.go b/internal/api/grpc/admin/integration_test/restrictions_allow_public_org_registrations_test.go index 3cbcf8abd0..9aed3f5924 100644 --- a/internal/api/grpc/admin/integration_test/restrictions_allow_public_org_registrations_test.go +++ b/internal/api/grpc/admin/integration_test/restrictions_allow_public_org_registrations_test.go @@ -21,8 +21,6 @@ import ( ) func TestServer_Restrictions_DisallowPublicOrgRegistration(t *testing.T) { - t.Parallel() - instance := integration.NewInstance(CTX) regOrgUrl, err := url.Parse("http://" + instance.Domain + ":8080/ui/login/register/org") require.NoError(t, err) diff --git a/internal/api/grpc/admin/integration_test/restrictions_allowed_languages_test.go b/internal/api/grpc/admin/integration_test/restrictions_allowed_languages_test.go index e00b7f221b..52a1607bba 100644 --- a/internal/api/grpc/admin/integration_test/restrictions_allowed_languages_test.go +++ b/internal/api/grpc/admin/integration_test/restrictions_allowed_languages_test.go @@ -24,8 +24,6 @@ import ( ) func TestServer_Restrictions_AllowedLanguages(t *testing.T) { - t.Parallel() - ctx, cancel := context.WithTimeout(context.Background(), 15*time.Minute) defer cancel() diff --git a/internal/api/grpc/org/v2/integration_test/org_test.go b/internal/api/grpc/org/v2/integration_test/org_test.go index 165eb1471f..8de5e40bb5 100644 --- a/internal/api/grpc/org/v2/integration_test/org_test.go +++ b/internal/api/grpc/org/v2/integration_test/org_test.go @@ -42,8 +42,6 @@ func TestMain(m *testing.M) { } func TestServer_AddOrganization(t *testing.T) { - t.Parallel() - idpResp := Instance.AddGenericOAuthProvider(CTX, Instance.DefaultOrg.Id) tests := []struct { diff --git a/internal/api/grpc/org/v2/integration_test/query_test.go b/internal/api/grpc/org/v2/integration_test/query_test.go index bd0352ed75..188aeddf9f 100644 --- a/internal/api/grpc/org/v2/integration_test/query_test.go +++ b/internal/api/grpc/org/v2/integration_test/query_test.go @@ -27,8 +27,6 @@ type orgAttr struct { } func TestServer_ListOrganizations(t *testing.T) { - t.Parallel() - type args struct { ctx context.Context req *org.ListOrganizationsRequest diff --git a/internal/api/grpc/resources/action/v3alpha/integration_test/execution_target_test.go b/internal/api/grpc/resources/action/v3alpha/integration_test/execution_target_test.go index c70ed227c1..7aff6afb3f 100644 --- a/internal/api/grpc/resources/action/v3alpha/integration_test/execution_target_test.go +++ b/internal/api/grpc/resources/action/v3alpha/integration_test/execution_target_test.go @@ -26,7 +26,6 @@ import ( ) func TestServer_ExecutionTarget(t *testing.T) { - t.Parallel() instance := integration.NewInstance(CTX) ensureFeatureEnabled(t, instance) isolatedIAMOwnerCTX := instance.WithAuthorization(CTX, integration.UserTypeIAMOwner) diff --git a/internal/api/grpc/resources/action/v3alpha/integration_test/execution_test.go b/internal/api/grpc/resources/action/v3alpha/integration_test/execution_test.go index 19d97c3857..b56efd6b99 100644 --- a/internal/api/grpc/resources/action/v3alpha/integration_test/execution_test.go +++ b/internal/api/grpc/resources/action/v3alpha/integration_test/execution_test.go @@ -25,7 +25,6 @@ func executionTargetsSingleInclude(include *action.Condition) []*action.Executio } func TestServer_SetExecution_Request(t *testing.T) { - t.Parallel() instance := integration.NewInstance(CTX) ensureFeatureEnabled(t, instance) isolatedIAMOwnerCTX := instance.WithAuthorization(CTX, integration.UserTypeIAMOwner) @@ -207,7 +206,6 @@ func TestServer_SetExecution_Request(t *testing.T) { } func TestServer_SetExecution_Request_Include(t *testing.T) { - t.Parallel() instance := integration.NewInstance(CTX) ensureFeatureEnabled(t, instance) isolatedIAMOwnerCTX := instance.WithAuthorization(CTX, integration.UserTypeIAMOwner) @@ -346,7 +344,6 @@ func TestServer_SetExecution_Request_Include(t *testing.T) { } func TestServer_SetExecution_Response(t *testing.T) { - t.Parallel() instance := integration.NewInstance(CTX) ensureFeatureEnabled(t, instance) isolatedIAMOwnerCTX := instance.WithAuthorization(CTX, integration.UserTypeIAMOwner) @@ -528,7 +525,6 @@ func TestServer_SetExecution_Response(t *testing.T) { } func TestServer_SetExecution_Event(t *testing.T) { - t.Parallel() instance := integration.NewInstance(CTX) ensureFeatureEnabled(t, instance) isolatedIAMOwnerCTX := instance.WithAuthorization(CTX, integration.UserTypeIAMOwner) @@ -716,7 +712,6 @@ func TestServer_SetExecution_Event(t *testing.T) { } func TestServer_SetExecution_Function(t *testing.T) { - t.Parallel() instance := integration.NewInstance(CTX) ensureFeatureEnabled(t, instance) isolatedIAMOwnerCTX := instance.WithAuthorization(CTX, integration.UserTypeIAMOwner) diff --git a/internal/api/grpc/resources/action/v3alpha/integration_test/query_test.go b/internal/api/grpc/resources/action/v3alpha/integration_test/query_test.go index d6f584b35e..23fb860cd3 100644 --- a/internal/api/grpc/resources/action/v3alpha/integration_test/query_test.go +++ b/internal/api/grpc/resources/action/v3alpha/integration_test/query_test.go @@ -22,7 +22,6 @@ import ( ) func TestServer_GetTarget(t *testing.T) { - t.Parallel() instance := integration.NewInstance(CTX) ensureFeatureEnabled(t, instance) isolatedIAMOwnerCTX := instance.WithAuthorization(CTX, integration.UserTypeIAMOwner) @@ -237,7 +236,6 @@ func TestServer_GetTarget(t *testing.T) { } func TestServer_ListTargets(t *testing.T) { - t.Parallel() instance := integration.NewInstance(CTX) ensureFeatureEnabled(t, instance) isolatedIAMOwnerCTX := instance.WithAuthorization(CTX, integration.UserTypeIAMOwner) @@ -503,7 +501,6 @@ func TestServer_ListTargets(t *testing.T) { } func TestServer_SearchExecutions(t *testing.T) { - t.Parallel() instance := integration.NewInstance(CTX) ensureFeatureEnabled(t, instance) isolatedIAMOwnerCTX := instance.WithAuthorization(CTX, integration.UserTypeIAMOwner) diff --git a/internal/api/grpc/resources/action/v3alpha/integration_test/target_test.go b/internal/api/grpc/resources/action/v3alpha/integration_test/target_test.go index 60c4ac35a7..04fa60982d 100644 --- a/internal/api/grpc/resources/action/v3alpha/integration_test/target_test.go +++ b/internal/api/grpc/resources/action/v3alpha/integration_test/target_test.go @@ -21,7 +21,6 @@ import ( ) func TestServer_CreateTarget(t *testing.T) { - t.Parallel() instance := integration.NewInstance(CTX) ensureFeatureEnabled(t, instance) isolatedIAMOwnerCTX := instance.WithAuthorization(CTX, integration.UserTypeIAMOwner) diff --git a/internal/api/grpc/resources/user/v3alpha/integration_test/email_test.go b/internal/api/grpc/resources/user/v3alpha/integration_test/email_test.go index 4b5a342905..f64bcebe38 100644 --- a/internal/api/grpc/resources/user/v3alpha/integration_test/email_test.go +++ b/internal/api/grpc/resources/user/v3alpha/integration_test/email_test.go @@ -19,7 +19,6 @@ import ( ) func TestServer_SetContactEmail(t *testing.T) { - t.Parallel() instance := integration.NewInstance(CTX) ensureFeatureEnabled(t, instance) isolatedIAMOwnerCTX := instance.WithAuthorization(CTX, integration.UserTypeIAMOwner) @@ -365,7 +364,6 @@ func TestServer_SetContactEmail(t *testing.T) { } func TestServer_VerifyContactEmail(t *testing.T) { - t.Parallel() instance := integration.NewInstance(CTX) ensureFeatureEnabled(t, instance) isolatedIAMOwnerCTX := instance.WithAuthorization(CTX, integration.UserTypeIAMOwner) @@ -555,7 +553,6 @@ func TestServer_VerifyContactEmail(t *testing.T) { } func TestServer_ResendContactEmailCode(t *testing.T) { - t.Parallel() instance := integration.NewInstance(CTX) ensureFeatureEnabled(t, instance) isolatedIAMOwnerCTX := instance.WithAuthorization(CTX, integration.UserTypeIAMOwner) diff --git a/internal/api/grpc/resources/user/v3alpha/integration_test/phone_test.go b/internal/api/grpc/resources/user/v3alpha/integration_test/phone_test.go index 9fcacd7457..fbd5805f16 100644 --- a/internal/api/grpc/resources/user/v3alpha/integration_test/phone_test.go +++ b/internal/api/grpc/resources/user/v3alpha/integration_test/phone_test.go @@ -18,7 +18,6 @@ import ( ) func TestServer_SetContactPhone(t *testing.T) { - t.Parallel() instance := integration.NewInstance(CTX) ensureFeatureEnabled(t, instance) isolatedIAMOwnerCTX := instance.WithAuthorization(CTX, integration.UserTypeIAMOwner) @@ -292,7 +291,6 @@ func TestServer_SetContactPhone(t *testing.T) { } func TestServer_VerifyContactPhone(t *testing.T) { - t.Parallel() instance := integration.NewInstance(CTX) ensureFeatureEnabled(t, instance) isolatedIAMOwnerCTX := instance.WithAuthorization(CTX, integration.UserTypeIAMOwner) @@ -484,7 +482,6 @@ func TestServer_VerifyContactPhone(t *testing.T) { } func TestServer_ResendContactPhoneCode(t *testing.T) { - t.Parallel() instance := integration.NewInstance(CTX) ensureFeatureEnabled(t, instance) isolatedIAMOwnerCTX := instance.WithAuthorization(CTX, integration.UserTypeIAMOwner) diff --git a/internal/api/grpc/resources/user/v3alpha/integration_test/user_test.go b/internal/api/grpc/resources/user/v3alpha/integration_test/user_test.go index 34d12446c5..1bc35a5390 100644 --- a/internal/api/grpc/resources/user/v3alpha/integration_test/user_test.go +++ b/internal/api/grpc/resources/user/v3alpha/integration_test/user_test.go @@ -21,7 +21,6 @@ import ( ) func TestServer_CreateUser(t *testing.T) { - t.Parallel() instance := integration.NewInstance(CTX) ensureFeatureEnabled(t, instance) isolatedIAMOwnerCTX := instance.WithAuthorization(CTX, integration.UserTypeIAMOwner) @@ -230,7 +229,6 @@ func TestServer_CreateUser(t *testing.T) { } func TestServer_PatchUser(t *testing.T) { - t.Parallel() instance := integration.NewInstance(CTX) ensureFeatureEnabled(t, instance) isolatedIAMOwnerCTX := instance.WithAuthorization(CTX, integration.UserTypeIAMOwner) @@ -650,7 +648,6 @@ func TestServer_PatchUser(t *testing.T) { } func TestServer_DeleteUser(t *testing.T) { - t.Parallel() instance := integration.NewInstance(CTX) ensureFeatureEnabled(t, instance) isolatedIAMOwnerCTX := instance.WithAuthorization(CTX, integration.UserTypeIAMOwner) @@ -868,7 +865,6 @@ func unmarshalJSON(data string) *structpb.Struct { } func TestServer_LockUser(t *testing.T) { - t.Parallel() instance := integration.NewInstance(CTX) ensureFeatureEnabled(t, instance) isolatedIAMOwnerCTX := instance.WithAuthorization(CTX, integration.UserTypeIAMOwner) @@ -1070,7 +1066,6 @@ func TestServer_LockUser(t *testing.T) { } func TestServer_UnlockUser(t *testing.T) { - t.Parallel() instance := integration.NewInstance(CTX) ensureFeatureEnabled(t, instance) isolatedIAMOwnerCTX := instance.WithAuthorization(CTX, integration.UserTypeIAMOwner) @@ -1253,7 +1248,6 @@ func TestServer_UnlockUser(t *testing.T) { } func TestServer_DeactivateUser(t *testing.T) { - t.Parallel() instance := integration.NewInstance(CTX) ensureFeatureEnabled(t, instance) isolatedIAMOwnerCTX := instance.WithAuthorization(CTX, integration.UserTypeIAMOwner) @@ -1455,7 +1449,6 @@ func TestServer_DeactivateUser(t *testing.T) { } func TestServer_ActivateUser(t *testing.T) { - t.Parallel() instance := integration.NewInstance(CTX) ensureFeatureEnabled(t, instance) isolatedIAMOwnerCTX := instance.WithAuthorization(CTX, integration.UserTypeIAMOwner) diff --git a/internal/api/grpc/resources/userschema/v3alpha/integration_test/query_test.go b/internal/api/grpc/resources/userschema/v3alpha/integration_test/query_test.go index ef7fe02807..31ee68ed8d 100644 --- a/internal/api/grpc/resources/userschema/v3alpha/integration_test/query_test.go +++ b/internal/api/grpc/resources/userschema/v3alpha/integration_test/query_test.go @@ -20,7 +20,6 @@ import ( ) func TestServer_ListUserSchemas(t *testing.T) { - t.Parallel() instance := integration.NewInstance(CTX) ensureFeatureEnabled(t, instance) isolatedIAMOwnerCTX := instance.WithAuthorization(CTX, integration.UserTypeIAMOwner) @@ -214,7 +213,6 @@ func TestServer_ListUserSchemas(t *testing.T) { } func TestServer_GetUserSchema(t *testing.T) { - t.Parallel() instance := integration.NewInstance(CTX) ensureFeatureEnabled(t, instance) isolatedIAMOwnerCTX := instance.WithAuthorization(CTX, integration.UserTypeIAMOwner) diff --git a/internal/api/grpc/resources/userschema/v3alpha/integration_test/userschema_test.go b/internal/api/grpc/resources/userschema/v3alpha/integration_test/userschema_test.go index a264b163eb..33b4565861 100644 --- a/internal/api/grpc/resources/userschema/v3alpha/integration_test/userschema_test.go +++ b/internal/api/grpc/resources/userschema/v3alpha/integration_test/userschema_test.go @@ -19,7 +19,6 @@ import ( ) func TestServer_CreateUserSchema(t *testing.T) { - t.Parallel() instance := integration.NewInstance(CTX) ensureFeatureEnabled(t, instance) isolatedIAMOwnerCTX := instance.WithAuthorization(CTX, integration.UserTypeIAMOwner) @@ -304,7 +303,6 @@ func TestServer_CreateUserSchema(t *testing.T) { } func TestServer_UpdateUserSchema(t *testing.T) { - t.Parallel() instance := integration.NewInstance(CTX) ensureFeatureEnabled(t, instance) isolatedIAMOwnerCTX := instance.WithAuthorization(CTX, integration.UserTypeIAMOwner) @@ -596,7 +594,6 @@ func TestServer_UpdateUserSchema(t *testing.T) { } func TestServer_DeactivateUserSchema(t *testing.T) { - t.Parallel() instance := integration.NewInstance(CTX) ensureFeatureEnabled(t, instance) isolatedIAMOwnerCTX := instance.WithAuthorization(CTX, integration.UserTypeIAMOwner) @@ -678,7 +675,6 @@ func TestServer_DeactivateUserSchema(t *testing.T) { } func TestServer_ReactivateUserSchema(t *testing.T) { - t.Parallel() instance := integration.NewInstance(CTX) ensureFeatureEnabled(t, instance) isolatedIAMOwnerCTX := instance.WithAuthorization(CTX, integration.UserTypeIAMOwner) @@ -760,7 +756,6 @@ func TestServer_ReactivateUserSchema(t *testing.T) { } func TestServer_DeleteUserSchema(t *testing.T) { - t.Parallel() instance := integration.NewInstance(CTX) ensureFeatureEnabled(t, instance) isolatedIAMOwnerCTX := instance.WithAuthorization(CTX, integration.UserTypeIAMOwner) diff --git a/internal/api/grpc/system/integration_test/instance_test.go b/internal/api/grpc/system/integration_test/instance_test.go index 18d2f68bb9..2c8cc8cf89 100644 --- a/internal/api/grpc/system/integration_test/instance_test.go +++ b/internal/api/grpc/system/integration_test/instance_test.go @@ -15,8 +15,6 @@ import ( ) func TestServer_ListInstances(t *testing.T) { - t.Parallel() - isoInstance := integration.NewInstance(CTX) tests := []struct { diff --git a/internal/api/grpc/system/integration_test/limits_auditlogretention_test.go b/internal/api/grpc/system/integration_test/limits_auditlogretention_test.go index 077054eb33..24c224b0fe 100644 --- a/internal/api/grpc/system/integration_test/limits_auditlogretention_test.go +++ b/internal/api/grpc/system/integration_test/limits_auditlogretention_test.go @@ -22,8 +22,6 @@ import ( ) func TestServer_Limits_AuditLogRetention(t *testing.T) { - t.Parallel() - isoInstance := integration.NewInstance(CTX) iamOwnerCtx := isoInstance.WithAuthorization(CTX, integration.UserTypeIAMOwner) userID, projectID, appID, projectGrantID := seedObjects(iamOwnerCtx, t, isoInstance.Client) diff --git a/internal/api/grpc/system/integration_test/limits_block_test.go b/internal/api/grpc/system/integration_test/limits_block_test.go index 46b213f603..d3b9fffbd3 100644 --- a/internal/api/grpc/system/integration_test/limits_block_test.go +++ b/internal/api/grpc/system/integration_test/limits_block_test.go @@ -26,8 +26,6 @@ import ( ) func TestServer_Limits_Block(t *testing.T) { - t.Parallel() - isoInstance := integration.NewInstance(CTX) iamOwnerCtx := isoInstance.WithAuthorization(CTX, integration.UserTypeIAMOwner) tests := []*test{ diff --git a/internal/api/grpc/user/v2/integration_test/email_test.go b/internal/api/grpc/user/v2/integration_test/email_test.go index 5092dbf40d..37d575016b 100644 --- a/internal/api/grpc/user/v2/integration_test/email_test.go +++ b/internal/api/grpc/user/v2/integration_test/email_test.go @@ -17,8 +17,6 @@ import ( ) func TestServer_SetEmail(t *testing.T) { - t.Parallel() - userID := Instance.CreateHumanUser(CTX).GetUserId() tests := []struct { @@ -148,8 +146,6 @@ func TestServer_SetEmail(t *testing.T) { } func TestServer_ResendEmailCode(t *testing.T) { - t.Parallel() - userID := Instance.CreateHumanUser(CTX).GetUserId() verifiedUserID := Instance.CreateHumanUserVerified(CTX, Instance.DefaultOrg.Id, gofakeit.Email()).GetUserId() @@ -254,8 +250,6 @@ func TestServer_ResendEmailCode(t *testing.T) { } func TestServer_VerifyEmail(t *testing.T) { - t.Parallel() - userResp := Instance.CreateHumanUser(CTX) tests := []struct { name string diff --git a/internal/api/grpc/user/v2/integration_test/idp_link_test.go b/internal/api/grpc/user/v2/integration_test/idp_link_test.go index ab398c7233..116a095216 100644 --- a/internal/api/grpc/user/v2/integration_test/idp_link_test.go +++ b/internal/api/grpc/user/v2/integration_test/idp_link_test.go @@ -20,8 +20,6 @@ import ( ) func TestServer_AddIDPLink(t *testing.T) { - t.Parallel() - idpResp := Instance.AddGenericOAuthProvider(IamCTX, Instance.DefaultOrg.Id) type args struct { ctx context.Context @@ -101,8 +99,6 @@ func TestServer_AddIDPLink(t *testing.T) { } func TestServer_ListIDPLinks(t *testing.T) { - t.Parallel() - orgResp := Instance.CreateOrganization(IamCTX, fmt.Sprintf("ListIDPLinks-%s", gofakeit.AppName()), gofakeit.Email()) instanceIdpResp := Instance.AddGenericOAuthProvider(IamCTX, Instance.DefaultOrg.Id) @@ -257,8 +253,6 @@ func TestServer_ListIDPLinks(t *testing.T) { } func TestServer_RemoveIDPLink(t *testing.T) { - t.Parallel() - orgResp := Instance.CreateOrganization(IamCTX, fmt.Sprintf("ListIDPLinks-%s", gofakeit.AppName()), gofakeit.Email()) instanceIdpResp := Instance.AddGenericOAuthProvider(IamCTX, Instance.DefaultOrg.Id) diff --git a/internal/api/grpc/user/v2/integration_test/otp_test.go b/internal/api/grpc/user/v2/integration_test/otp_test.go index 3070a1d584..ae7c040427 100644 --- a/internal/api/grpc/user/v2/integration_test/otp_test.go +++ b/internal/api/grpc/user/v2/integration_test/otp_test.go @@ -15,8 +15,6 @@ import ( ) func TestServer_AddOTPSMS(t *testing.T) { - t.Parallel() - userID := Instance.CreateHumanUser(CTX).GetUserId() Instance.RegisterUserPasskey(CTX, userID) _, sessionToken, _, _ := Instance.CreateVerifiedWebAuthNSession(t, CTX, userID) @@ -123,8 +121,6 @@ func TestServer_AddOTPSMS(t *testing.T) { } func TestServer_RemoveOTPSMS(t *testing.T) { - t.Parallel() - userID := Instance.CreateHumanUser(CTX).GetUserId() Instance.RegisterUserPasskey(CTX, userID) _, sessionToken, _, _ := Instance.CreateVerifiedWebAuthNSession(t, CTX, userID) @@ -191,8 +187,6 @@ func TestServer_RemoveOTPSMS(t *testing.T) { } func TestServer_AddOTPEmail(t *testing.T) { - t.Parallel() - userID := Instance.CreateHumanUser(CTX).GetUserId() Instance.RegisterUserPasskey(CTX, userID) _, sessionToken, _, _ := Instance.CreateVerifiedWebAuthNSession(t, CTX, userID) @@ -301,8 +295,6 @@ func TestServer_AddOTPEmail(t *testing.T) { } func TestServer_RemoveOTPEmail(t *testing.T) { - t.Parallel() - userID := Instance.CreateHumanUser(CTX).GetUserId() Instance.RegisterUserPasskey(CTX, userID) _, sessionToken, _, _ := Instance.CreateVerifiedWebAuthNSession(t, CTX, userID) diff --git a/internal/api/grpc/user/v2/integration_test/passkey_test.go b/internal/api/grpc/user/v2/integration_test/passkey_test.go index 9d9cb8a047..881ab17c09 100644 --- a/internal/api/grpc/user/v2/integration_test/passkey_test.go +++ b/internal/api/grpc/user/v2/integration_test/passkey_test.go @@ -19,8 +19,6 @@ import ( ) func TestServer_RegisterPasskey(t *testing.T) { - t.Parallel() - userID := Instance.CreateHumanUser(CTX).GetUserId() reg, err := Client.CreatePasskeyRegistrationLink(CTX, &user.CreatePasskeyRegistrationLinkRequest{ UserId: userID, @@ -141,8 +139,6 @@ func TestServer_RegisterPasskey(t *testing.T) { } func TestServer_VerifyPasskeyRegistration(t *testing.T) { - t.Parallel() - userID, pkr := userWithPasskeyRegistered(t) attestationResponse, err := Instance.WebAuthN.CreateAttestationResponse(pkr.GetPublicKeyCredentialCreationOptions()) @@ -219,8 +215,6 @@ func TestServer_VerifyPasskeyRegistration(t *testing.T) { } func TestServer_CreatePasskeyRegistrationLink(t *testing.T) { - t.Parallel() - userID := Instance.CreateHumanUser(CTX).GetUserId() type args struct { @@ -354,8 +348,6 @@ func passkeyVerify(t *testing.T, userID string, pkr *user.RegisterPasskeyRespons } func TestServer_RemovePasskey(t *testing.T) { - t.Parallel() - userIDWithout := Instance.CreateHumanUser(CTX).GetUserId() userIDRegistered, pkrRegistered := userWithPasskeyRegistered(t) userIDVerified, passkeyIDVerified := userWithPasskeyVerified(t) @@ -461,8 +453,6 @@ func TestServer_RemovePasskey(t *testing.T) { } func TestServer_ListPasskeys(t *testing.T) { - t.Parallel() - userIDWithout := Instance.CreateHumanUser(CTX).GetUserId() userIDRegistered, _ := userWithPasskeyRegistered(t) userIDVerified, passkeyIDVerified := userWithPasskeyVerified(t) diff --git a/internal/api/grpc/user/v2/integration_test/password_test.go b/internal/api/grpc/user/v2/integration_test/password_test.go index 7707537653..0cd0da7454 100644 --- a/internal/api/grpc/user/v2/integration_test/password_test.go +++ b/internal/api/grpc/user/v2/integration_test/password_test.go @@ -17,8 +17,6 @@ import ( ) func TestServer_RequestPasswordReset(t *testing.T) { - t.Parallel() - userID := Instance.CreateHumanUser(CTX).GetUserId() tests := []struct { @@ -107,8 +105,6 @@ func TestServer_RequestPasswordReset(t *testing.T) { } func TestServer_SetPassword(t *testing.T) { - t.Parallel() - type args struct { ctx context.Context req *user.SetPasswordRequest diff --git a/internal/api/grpc/user/v2/integration_test/phone_test.go b/internal/api/grpc/user/v2/integration_test/phone_test.go index 47590b6d67..1c1f75854d 100644 --- a/internal/api/grpc/user/v2/integration_test/phone_test.go +++ b/internal/api/grpc/user/v2/integration_test/phone_test.go @@ -18,8 +18,6 @@ import ( ) func TestServer_SetPhone(t *testing.T) { - t.Parallel() - userID := Instance.CreateHumanUser(CTX).GetUserId() tests := []struct { @@ -124,8 +122,6 @@ func TestServer_SetPhone(t *testing.T) { } func TestServer_ResendPhoneCode(t *testing.T) { - t.Parallel() - userID := Instance.CreateHumanUser(CTX).GetUserId() verifiedUserID := Instance.CreateHumanUserVerified(CTX, Instance.DefaultOrg.Id, gofakeit.Email()).GetUserId() @@ -201,8 +197,6 @@ func TestServer_ResendPhoneCode(t *testing.T) { } func TestServer_VerifyPhone(t *testing.T) { - t.Parallel() - userResp := Instance.CreateHumanUser(CTX) tests := []struct { name string @@ -256,8 +250,6 @@ func TestServer_VerifyPhone(t *testing.T) { } func TestServer_RemovePhone(t *testing.T) { - t.Parallel() - userResp := Instance.CreateHumanUser(CTX) failResp := Instance.CreateHumanUserNoPhone(CTX) otherUser := Instance.CreateHumanUser(CTX).GetUserId() diff --git a/internal/api/grpc/user/v2/integration_test/query_test.go b/internal/api/grpc/user/v2/integration_test/query_test.go index 3d5b2d9416..a00d1b1a48 100644 --- a/internal/api/grpc/user/v2/integration_test/query_test.go +++ b/internal/api/grpc/user/v2/integration_test/query_test.go @@ -20,8 +20,6 @@ import ( ) func TestServer_GetUserByID(t *testing.T) { - t.Parallel() - orgResp := Instance.CreateOrganization(IamCTX, fmt.Sprintf("GetUserByIDOrg-%s", gofakeit.AppName()), gofakeit.Email()) type args struct { ctx context.Context @@ -188,8 +186,6 @@ func TestServer_GetUserByID(t *testing.T) { } func TestServer_GetUserByID_Permission(t *testing.T) { - t.Parallel() - newOrgOwnerEmail := gofakeit.Email() newOrg := Instance.CreateOrganization(IamCTX, fmt.Sprintf("GetHuman-%s", gofakeit.AppName()), newOrgOwnerEmail) newUserID := newOrg.CreatedAdmins[0].GetUserId() @@ -337,8 +333,6 @@ type userAttr struct { } func TestServer_ListUsers(t *testing.T) { - t.Parallel() - orgResp := Instance.CreateOrganization(IamCTX, fmt.Sprintf("ListUsersOrg-%s", gofakeit.AppName()), gofakeit.Email()) userResp := Instance.CreateHumanUserVerified(IamCTX, orgResp.OrganizationId, gofakeit.Email()) type args struct { diff --git a/internal/api/grpc/user/v2/integration_test/totp_test.go b/internal/api/grpc/user/v2/integration_test/totp_test.go index add67876ff..e65756c1c1 100644 --- a/internal/api/grpc/user/v2/integration_test/totp_test.go +++ b/internal/api/grpc/user/v2/integration_test/totp_test.go @@ -18,8 +18,6 @@ import ( ) func TestServer_RegisterTOTP(t *testing.T) { - t.Parallel() - userID := Instance.CreateHumanUser(CTX).GetUserId() Instance.RegisterUserPasskey(CTX, userID) _, sessionToken, _, _ := Instance.CreateVerifiedWebAuthNSession(t, CTX, userID) @@ -106,8 +104,6 @@ func TestServer_RegisterTOTP(t *testing.T) { } func TestServer_VerifyTOTPRegistration(t *testing.T) { - t.Parallel() - userID := Instance.CreateHumanUser(CTX).GetUserId() Instance.RegisterUserPasskey(CTX, userID) _, sessionToken, _, _ := Instance.CreateVerifiedWebAuthNSession(t, CTX, userID) @@ -211,8 +207,6 @@ func TestServer_VerifyTOTPRegistration(t *testing.T) { } func TestServer_RemoveTOTP(t *testing.T) { - t.Parallel() - userID := Instance.CreateHumanUser(CTX).GetUserId() Instance.RegisterUserPasskey(CTX, userID) _, sessionToken, _, _ := Instance.CreateVerifiedWebAuthNSession(t, CTX, userID) diff --git a/internal/api/grpc/user/v2/integration_test/u2f_test.go b/internal/api/grpc/user/v2/integration_test/u2f_test.go index 9ec0011249..b8af753f85 100644 --- a/internal/api/grpc/user/v2/integration_test/u2f_test.go +++ b/internal/api/grpc/user/v2/integration_test/u2f_test.go @@ -17,8 +17,6 @@ import ( ) func TestServer_RegisterU2F(t *testing.T) { - t.Parallel() - userID := Instance.CreateHumanUser(CTX).GetUserId() otherUser := Instance.CreateHumanUser(CTX).GetUserId() @@ -108,8 +106,6 @@ func TestServer_RegisterU2F(t *testing.T) { } func TestServer_VerifyU2FRegistration(t *testing.T) { - t.Parallel() - ctx, userID, pkr := ctxFromNewUserWithRegisteredU2F(t) attestationResponse, err := Instance.WebAuthN.CreateAttestationResponse(pkr.GetPublicKeyCredentialCreationOptions()) @@ -215,8 +211,6 @@ func ctxFromNewUserWithVerifiedU2F(t *testing.T) (context.Context, string, strin } func TestServer_RemoveU2F(t *testing.T) { - t.Parallel() - userIDWithout := Instance.CreateHumanUser(CTX).GetUserId() ctxRegistered, userIDRegistered, pkrRegistered := ctxFromNewUserWithRegisteredU2F(t) _, userIDVerified, u2fVerified := ctxFromNewUserWithVerifiedU2F(t) diff --git a/internal/api/grpc/user/v2/integration_test/user_test.go b/internal/api/grpc/user/v2/integration_test/user_test.go index e4196c1a30..cf42e9291f 100644 --- a/internal/api/grpc/user/v2/integration_test/user_test.go +++ b/internal/api/grpc/user/v2/integration_test/user_test.go @@ -53,8 +53,6 @@ func TestMain(m *testing.M) { } func TestServer_AddHumanUser(t *testing.T) { - t.Parallel() - idpResp := Instance.AddGenericOAuthProvider(IamCTX, Instance.DefaultOrg.Id) type args struct { ctx context.Context @@ -681,8 +679,6 @@ func TestServer_AddHumanUser(t *testing.T) { } func TestServer_AddHumanUser_Permission(t *testing.T) { - t.Parallel() - newOrgOwnerEmail := gofakeit.Email() newOrg := Instance.CreateOrganization(IamCTX, fmt.Sprintf("AddHuman-%s", gofakeit.AppName()), newOrgOwnerEmail) type args struct { @@ -876,8 +872,6 @@ func TestServer_AddHumanUser_Permission(t *testing.T) { } func TestServer_UpdateHumanUser(t *testing.T) { - t.Parallel() - type args struct { ctx context.Context req *user.UpdateHumanUserRequest @@ -1239,8 +1233,6 @@ func TestServer_UpdateHumanUser(t *testing.T) { } func TestServer_UpdateHumanUser_Permission(t *testing.T) { - t.Parallel() - newOrgOwnerEmail := gofakeit.Email() newOrg := Instance.CreateOrganization(IamCTX, fmt.Sprintf("UpdateHuman-%s", gofakeit.AppName()), newOrgOwnerEmail) newUserID := newOrg.CreatedAdmins[0].GetUserId() @@ -1324,8 +1316,6 @@ func TestServer_UpdateHumanUser_Permission(t *testing.T) { } func TestServer_LockUser(t *testing.T) { - t.Parallel() - type args struct { ctx context.Context req *user.LockUserRequest @@ -1434,8 +1424,6 @@ func TestServer_LockUser(t *testing.T) { } func TestServer_UnLockUser(t *testing.T) { - t.Parallel() - type args struct { ctx context.Context req *user.UnlockUserRequest @@ -1544,8 +1532,6 @@ func TestServer_UnLockUser(t *testing.T) { } func TestServer_DeactivateUser(t *testing.T) { - t.Parallel() - type args struct { ctx context.Context req *user.DeactivateUserRequest @@ -1655,8 +1641,6 @@ func TestServer_DeactivateUser(t *testing.T) { } func TestServer_ReactivateUser(t *testing.T) { - t.Parallel() - type args struct { ctx context.Context req *user.ReactivateUserRequest @@ -1765,8 +1749,6 @@ func TestServer_ReactivateUser(t *testing.T) { } func TestServer_DeleteUser(t *testing.T) { - t.Parallel() - projectResp, err := Instance.CreateProject(CTX) require.NoError(t, err) type args struct { @@ -1866,8 +1848,6 @@ func TestServer_DeleteUser(t *testing.T) { } func TestServer_StartIdentityProviderIntent(t *testing.T) { - t.Parallel() - idpResp := Instance.AddGenericOAuthProvider(IamCTX, Instance.DefaultOrg.Id) orgIdpResp := Instance.AddOrgGenericOAuthProvider(CTX, Instance.DefaultOrg.Id) orgResp := Instance.CreateOrganization(IamCTX, fmt.Sprintf("NotDefaultOrg-%s", gofakeit.AppName()), gofakeit.Email()) @@ -2131,9 +2111,7 @@ func TestServer_StartIdentityProviderIntent(t *testing.T) { /* func TestServer_RetrieveIdentityProviderIntent(t *testing.T) { - t.Parallel() - - idpID := Instance.AddGenericOAuthProvider(t, CTX) + idpID := Instance.AddGenericOAuthProvider(t, CTX) intentID := Instance.CreateIntent(t, CTX, idpID) successfulID, token, changeDate, sequence := Instance.CreateSuccessfulOAuthIntent(t, CTX, idpID, "", "id") successfulWithUserID, withUsertoken, withUserchangeDate, withUsersequence := Instance.CreateSuccessfulOAuthIntent(t, CTX, idpID, "user", "id") @@ -2421,8 +2399,6 @@ func ctxFromNewUserWithVerifiedPasswordlessLegacy(t *testing.T) (context.Context } func TestServer_ListAuthenticationMethodTypes(t *testing.T) { - t.Parallel() - userIDWithoutAuth := Instance.CreateHumanUser(CTX).GetUserId() userIDWithPasskey := Instance.CreateHumanUser(CTX).GetUserId() @@ -2654,8 +2630,6 @@ func TestServer_ListAuthenticationMethodTypes(t *testing.T) { } func TestServer_CreateInviteCode(t *testing.T) { - t.Parallel() - type args struct { ctx context.Context req *user.CreateInviteCodeRequest @@ -2787,8 +2761,6 @@ func TestServer_CreateInviteCode(t *testing.T) { } func TestServer_ResendInviteCode(t *testing.T) { - t.Parallel() - type args struct { ctx context.Context req *user.ResendInviteCodeRequest @@ -2878,8 +2850,6 @@ func TestServer_ResendInviteCode(t *testing.T) { } func TestServer_VerifyInviteCode(t *testing.T) { - t.Parallel() - type args struct { ctx context.Context req *user.VerifyInviteCodeRequest diff --git a/internal/api/grpc/user/v2beta/integration_test/email_test.go b/internal/api/grpc/user/v2beta/integration_test/email_test.go index ebace6daa2..d22355978a 100644 --- a/internal/api/grpc/user/v2beta/integration_test/email_test.go +++ b/internal/api/grpc/user/v2beta/integration_test/email_test.go @@ -17,8 +17,6 @@ import ( ) func TestServer_SetEmail(t *testing.T) { - t.Parallel() - userID := Instance.CreateHumanUser(CTX).GetUserId() tests := []struct { @@ -148,8 +146,6 @@ func TestServer_SetEmail(t *testing.T) { } func TestServer_ResendEmailCode(t *testing.T) { - t.Parallel() - userID := Instance.CreateHumanUser(CTX).GetUserId() verifiedUserID := Instance.CreateHumanUserVerified(CTX, Instance.DefaultOrg.Id, gofakeit.Email()).GetUserId() @@ -254,8 +250,6 @@ func TestServer_ResendEmailCode(t *testing.T) { } func TestServer_VerifyEmail(t *testing.T) { - t.Parallel() - userResp := Instance.CreateHumanUser(CTX) tests := []struct { name string diff --git a/internal/api/grpc/user/v2beta/integration_test/otp_test.go b/internal/api/grpc/user/v2beta/integration_test/otp_test.go index 3ffec36ef0..6d6e2eff3e 100644 --- a/internal/api/grpc/user/v2beta/integration_test/otp_test.go +++ b/internal/api/grpc/user/v2beta/integration_test/otp_test.go @@ -15,8 +15,6 @@ import ( ) func TestServer_AddOTPSMS(t *testing.T) { - t.Parallel() - userID := Instance.CreateHumanUser(CTX).GetUserId() Instance.RegisterUserPasskey(CTX, userID) _, sessionToken, _, _ := Instance.CreateVerifiedWebAuthNSession(t, CTX, userID) @@ -123,8 +121,6 @@ func TestServer_AddOTPSMS(t *testing.T) { } func TestServer_RemoveOTPSMS(t *testing.T) { - t.Parallel() - userID := Instance.CreateHumanUser(CTX).GetUserId() Instance.RegisterUserPasskey(CTX, userID) _, sessionToken, _, _ := Instance.CreateVerifiedWebAuthNSession(t, CTX, userID) @@ -191,8 +187,6 @@ func TestServer_RemoveOTPSMS(t *testing.T) { } func TestServer_AddOTPEmail(t *testing.T) { - t.Parallel() - userID := Instance.CreateHumanUser(CTX).GetUserId() Instance.RegisterUserPasskey(CTX, userID) _, sessionToken, _, _ := Instance.CreateVerifiedWebAuthNSession(t, CTX, userID) @@ -301,8 +295,6 @@ func TestServer_AddOTPEmail(t *testing.T) { } func TestServer_RemoveOTPEmail(t *testing.T) { - t.Parallel() - userID := Instance.CreateHumanUser(CTX).GetUserId() Instance.RegisterUserPasskey(CTX, userID) _, sessionToken, _, _ := Instance.CreateVerifiedWebAuthNSession(t, CTX, userID) diff --git a/internal/api/grpc/user/v2beta/integration_test/passkey_test.go b/internal/api/grpc/user/v2beta/integration_test/passkey_test.go index e5a0ec193b..acca01885c 100644 --- a/internal/api/grpc/user/v2beta/integration_test/passkey_test.go +++ b/internal/api/grpc/user/v2beta/integration_test/passkey_test.go @@ -18,8 +18,6 @@ import ( ) func TestServer_RegisterPasskey(t *testing.T) { - t.Parallel() - userID := Instance.CreateHumanUser(CTX).GetUserId() reg, err := Client.CreatePasskeyRegistrationLink(CTX, &user.CreatePasskeyRegistrationLinkRequest{ UserId: userID, @@ -140,8 +138,6 @@ func TestServer_RegisterPasskey(t *testing.T) { } func TestServer_VerifyPasskeyRegistration(t *testing.T) { - t.Parallel() - userID := Instance.CreateHumanUser(CTX).GetUserId() reg, err := Client.CreatePasskeyRegistrationLink(CTX, &user.CreatePasskeyRegistrationLinkRequest{ UserId: userID, @@ -230,8 +226,6 @@ func TestServer_VerifyPasskeyRegistration(t *testing.T) { } func TestServer_CreatePasskeyRegistrationLink(t *testing.T) { - t.Parallel() - userID := Instance.CreateHumanUser(CTX).GetUserId() type args struct { diff --git a/internal/api/grpc/user/v2beta/integration_test/password_test.go b/internal/api/grpc/user/v2beta/integration_test/password_test.go index 5995f87c7f..fa6bc66104 100644 --- a/internal/api/grpc/user/v2beta/integration_test/password_test.go +++ b/internal/api/grpc/user/v2beta/integration_test/password_test.go @@ -17,8 +17,6 @@ import ( ) func TestServer_RequestPasswordReset(t *testing.T) { - t.Parallel() - userID := Instance.CreateHumanUser(CTX).GetUserId() tests := []struct { @@ -109,8 +107,6 @@ func TestServer_RequestPasswordReset(t *testing.T) { } func TestServer_SetPassword(t *testing.T) { - t.Parallel() - type args struct { ctx context.Context req *user.SetPasswordRequest diff --git a/internal/api/grpc/user/v2beta/integration_test/phone_test.go b/internal/api/grpc/user/v2beta/integration_test/phone_test.go index 03567f4023..cd7199dcea 100644 --- a/internal/api/grpc/user/v2beta/integration_test/phone_test.go +++ b/internal/api/grpc/user/v2beta/integration_test/phone_test.go @@ -18,8 +18,6 @@ import ( ) func TestServer_SetPhone(t *testing.T) { - t.Parallel() - userID := Instance.CreateHumanUser(CTX).GetUserId() tests := []struct { @@ -126,8 +124,6 @@ func TestServer_SetPhone(t *testing.T) { } func TestServer_ResendPhoneCode(t *testing.T) { - t.Parallel() - userID := Instance.CreateHumanUser(CTX).GetUserId() verifiedUserID := Instance.CreateHumanUserVerified(CTX, Instance.DefaultOrg.Id, gofakeit.Email()).GetUserId() @@ -204,8 +200,6 @@ func TestServer_ResendPhoneCode(t *testing.T) { } func TestServer_VerifyPhone(t *testing.T) { - t.Parallel() - userResp := Instance.CreateHumanUser(CTX) tests := []struct { name string @@ -258,8 +252,6 @@ func TestServer_VerifyPhone(t *testing.T) { } func TestServer_RemovePhone(t *testing.T) { - t.Parallel() - userResp := Instance.CreateHumanUser(CTX) failResp := Instance.CreateHumanUserNoPhone(CTX) otherUser := Instance.CreateHumanUser(CTX).GetUserId() diff --git a/internal/api/grpc/user/v2beta/integration_test/query_test.go b/internal/api/grpc/user/v2beta/integration_test/query_test.go index 654a84a5d4..fc1d71926e 100644 --- a/internal/api/grpc/user/v2beta/integration_test/query_test.go +++ b/internal/api/grpc/user/v2beta/integration_test/query_test.go @@ -29,8 +29,6 @@ func detailsV2ToV2beta(obj *object.Details) *object_v2beta.Details { } func TestServer_GetUserByID(t *testing.T) { - t.Parallel() - orgResp := Instance.CreateOrganization(IamCTX, fmt.Sprintf("GetUserByIDOrg-%s", gofakeit.AppName()), gofakeit.Email()) type args struct { ctx context.Context @@ -197,8 +195,6 @@ func TestServer_GetUserByID(t *testing.T) { } func TestServer_GetUserByID_Permission(t *testing.T) { - t.Parallel() - timeNow := time.Now().UTC() newOrgOwnerEmail := gofakeit.Email() newOrg := Instance.CreateOrganization(IamCTX, fmt.Sprintf("GetHuman-%s", gofakeit.AppName()), newOrgOwnerEmail) @@ -347,8 +343,6 @@ type userAttr struct { } func TestServer_ListUsers(t *testing.T) { - t.Parallel() - orgResp := Instance.CreateOrganization(IamCTX, fmt.Sprintf("ListUsersOrg-%s", gofakeit.AppName()), gofakeit.Email()) userResp := Instance.CreateHumanUserVerified(IamCTX, orgResp.OrganizationId, gofakeit.Email()) type args struct { diff --git a/internal/api/grpc/user/v2beta/integration_test/totp_test.go b/internal/api/grpc/user/v2beta/integration_test/totp_test.go index 9fcce47321..4afe5e1f31 100644 --- a/internal/api/grpc/user/v2beta/integration_test/totp_test.go +++ b/internal/api/grpc/user/v2beta/integration_test/totp_test.go @@ -18,8 +18,6 @@ import ( ) func TestServer_RegisterTOTP(t *testing.T) { - t.Parallel() - userID := Instance.CreateHumanUser(CTX).GetUserId() Instance.RegisterUserPasskey(CTX, userID) _, sessionToken, _, _ := Instance.CreateVerifiedWebAuthNSession(t, CTX, userID) @@ -106,8 +104,6 @@ func TestServer_RegisterTOTP(t *testing.T) { } func TestServer_VerifyTOTPRegistration(t *testing.T) { - t.Parallel() - userID := Instance.CreateHumanUser(CTX).GetUserId() Instance.RegisterUserPasskey(CTX, userID) _, sessionToken, _, _ := Instance.CreateVerifiedWebAuthNSession(t, CTX, userID) @@ -216,8 +212,6 @@ func TestServer_VerifyTOTPRegistration(t *testing.T) { } func TestServer_RemoveTOTP(t *testing.T) { - t.Parallel() - userID := Instance.CreateHumanUser(CTX).GetUserId() Instance.RegisterUserPasskey(CTX, userID) _, sessionToken, _, _ := Instance.CreateVerifiedWebAuthNSession(t, CTX, userID) diff --git a/internal/api/grpc/user/v2beta/integration_test/u2f_test.go b/internal/api/grpc/user/v2beta/integration_test/u2f_test.go index c35231eefc..6e47cbbb99 100644 --- a/internal/api/grpc/user/v2beta/integration_test/u2f_test.go +++ b/internal/api/grpc/user/v2beta/integration_test/u2f_test.go @@ -17,8 +17,6 @@ import ( ) func TestServer_RegisterU2F(t *testing.T) { - t.Parallel() - userID := Instance.CreateHumanUser(CTX).GetUserId() otherUser := Instance.CreateHumanUser(CTX).GetUserId() @@ -108,8 +106,6 @@ func TestServer_RegisterU2F(t *testing.T) { } func TestServer_VerifyU2FRegistration(t *testing.T) { - t.Parallel() - userID := Instance.CreateHumanUser(CTX).GetUserId() Instance.RegisterUserPasskey(CTX, userID) _, sessionToken, _, _ := Instance.CreateVerifiedWebAuthNSession(t, CTX, userID) diff --git a/internal/api/grpc/user/v2beta/integration_test/user_test.go b/internal/api/grpc/user/v2beta/integration_test/user_test.go index b5f0b16d20..2e0abbb6b9 100644 --- a/internal/api/grpc/user/v2beta/integration_test/user_test.go +++ b/internal/api/grpc/user/v2beta/integration_test/user_test.go @@ -51,8 +51,6 @@ func TestMain(m *testing.M) { } func TestServer_AddHumanUser(t *testing.T) { - t.Parallel() - idpResp := Instance.AddGenericOAuthProvider(IamCTX, Instance.DefaultOrg.Id) type args struct { ctx context.Context @@ -638,8 +636,6 @@ func TestServer_AddHumanUser(t *testing.T) { } func TestServer_AddHumanUser_Permission(t *testing.T) { - t.Parallel() - newOrg := Instance.CreateOrganization(IamCTX, fmt.Sprintf("AddHuman-%s", gofakeit.AppName()), gofakeit.Email()) type args struct { ctx context.Context @@ -832,8 +828,6 @@ func TestServer_AddHumanUser_Permission(t *testing.T) { } func TestServer_UpdateHumanUser(t *testing.T) { - t.Parallel() - type args struct { ctx context.Context req *user.UpdateHumanUserRequest @@ -1195,8 +1189,6 @@ func TestServer_UpdateHumanUser(t *testing.T) { } func TestServer_UpdateHumanUser_Permission(t *testing.T) { - t.Parallel() - newOrg := Instance.CreateOrganization(IamCTX, fmt.Sprintf("UpdateHuman-%s", gofakeit.AppName()), gofakeit.Email()) newUserID := newOrg.CreatedAdmins[0].GetUserId() type args struct { @@ -1279,8 +1271,6 @@ func TestServer_UpdateHumanUser_Permission(t *testing.T) { } func TestServer_LockUser(t *testing.T) { - t.Parallel() - type args struct { ctx context.Context req *user.LockUserRequest @@ -1389,8 +1379,6 @@ func TestServer_LockUser(t *testing.T) { } func TestServer_UnLockUser(t *testing.T) { - t.Parallel() - type args struct { ctx context.Context req *user.UnlockUserRequest @@ -1499,8 +1487,6 @@ func TestServer_UnLockUser(t *testing.T) { } func TestServer_DeactivateUser(t *testing.T) { - t.Parallel() - type args struct { ctx context.Context req *user.DeactivateUserRequest @@ -1609,8 +1595,6 @@ func TestServer_DeactivateUser(t *testing.T) { } func TestServer_ReactivateUser(t *testing.T) { - t.Parallel() - type args struct { ctx context.Context req *user.ReactivateUserRequest @@ -1719,8 +1703,6 @@ func TestServer_ReactivateUser(t *testing.T) { } func TestServer_DeleteUser(t *testing.T) { - t.Parallel() - projectResp, err := Instance.CreateProject(CTX) require.NoError(t, err) type args struct { @@ -1820,8 +1802,6 @@ func TestServer_DeleteUser(t *testing.T) { } func TestServer_AddIDPLink(t *testing.T) { - t.Parallel() - idpResp := Instance.AddGenericOAuthProvider(IamCTX, Instance.DefaultOrg.Id) type args struct { ctx context.Context @@ -1901,8 +1881,6 @@ func TestServer_AddIDPLink(t *testing.T) { } func TestServer_StartIdentityProviderIntent(t *testing.T) { - t.Parallel() - idpResp := Instance.AddGenericOAuthProvider(IamCTX, Instance.DefaultOrg.Id) orgIdpID := Instance.AddOrgGenericOAuthProvider(CTX, Instance.DefaultOrg.Id) orgResp := Instance.CreateOrganization(IamCTX, fmt.Sprintf("NotDefaultOrg-%s", gofakeit.AppName()), gofakeit.Email()) @@ -2166,9 +2144,7 @@ func TestServer_StartIdentityProviderIntent(t *testing.T) { /* func TestServer_RetrieveIdentityProviderIntent(t *testing.T) { - t.Parallel() - - idpID := Instance.AddGenericOAuthProvider(t, CTX) + idpID := Instance.AddGenericOAuthProvider(t, CTX) intentID := Instance.CreateIntent(t, CTX, idpID) successfulID, token, changeDate, sequence := Instance.CreateSuccessfulOAuthIntent(t, CTX, idpID.Id, "", "id") successfulWithUserID, withUsertoken, withUserchangeDate, withUsersequence := Instance.CreateSuccessfulOAuthIntent(t, CTX, idpID.Id, "user", "id") @@ -2428,8 +2404,6 @@ func TestServer_RetrieveIdentityProviderIntent(t *testing.T) { */ func TestServer_ListAuthenticationMethodTypes(t *testing.T) { - t.Parallel() - userIDWithoutAuth := Instance.CreateHumanUser(CTX).GetUserId() userIDWithPasskey := Instance.CreateHumanUser(CTX).GetUserId() diff --git a/internal/api/oidc/integration_test/auth_request_test.go b/internal/api/oidc/integration_test/auth_request_test.go index bd9142a3f6..7ac0e24694 100644 --- a/internal/api/oidc/integration_test/auth_request_test.go +++ b/internal/api/oidc/integration_test/auth_request_test.go @@ -28,8 +28,6 @@ var ( ) func TestOPStorage_CreateAuthRequest(t *testing.T) { - t.Parallel() - clientID, _ := createClient(t, Instance) id := createAuthRequest(t, Instance, clientID, redirectURI) @@ -37,8 +35,6 @@ func TestOPStorage_CreateAuthRequest(t *testing.T) { } func TestOPStorage_CreateAccessToken_code(t *testing.T) { - t.Parallel() - clientID, _ := createClient(t, Instance) authRequestID := createAuthRequest(t, Instance, clientID, redirectURI) sessionID, sessionToken, startTime, changeTime := Instance.CreateVerifiedWebAuthNSession(t, CTXLOGIN, User.GetUserId()) @@ -78,8 +74,6 @@ func TestOPStorage_CreateAccessToken_code(t *testing.T) { } func TestOPStorage_CreateAccessToken_implicit(t *testing.T) { - t.Parallel() - clientID := createImplicitClient(t) authRequestID := createAuthRequestImplicit(t, clientID, redirectURIImplicit) sessionID, sessionToken, startTime, changeTime := Instance.CreateVerifiedWebAuthNSession(t, CTXLOGIN, User.GetUserId()) @@ -130,8 +124,6 @@ func TestOPStorage_CreateAccessToken_implicit(t *testing.T) { } func TestOPStorage_CreateAccessAndRefreshTokens_code(t *testing.T) { - t.Parallel() - clientID, _ := createClient(t, Instance) authRequestID := createAuthRequest(t, Instance, clientID, redirectURI, oidc.ScopeOpenID, oidc.ScopeOfflineAccess) sessionID, sessionToken, startTime, changeTime := Instance.CreateVerifiedWebAuthNSession(t, CTXLOGIN, User.GetUserId()) @@ -155,8 +147,6 @@ func TestOPStorage_CreateAccessAndRefreshTokens_code(t *testing.T) { } func TestOPStorage_CreateAccessAndRefreshTokens_refresh(t *testing.T) { - t.Parallel() - clientID, _ := createClient(t, Instance) provider, err := Instance.CreateRelyingParty(CTX, clientID, redirectURI) require.NoError(t, err) @@ -193,8 +183,6 @@ func TestOPStorage_CreateAccessAndRefreshTokens_refresh(t *testing.T) { } func TestOPStorage_RevokeToken_access_token(t *testing.T) { - t.Parallel() - clientID, _ := createClient(t, Instance) provider, err := Instance.CreateRelyingParty(CTX, clientID, redirectURI) require.NoError(t, err) @@ -238,8 +226,6 @@ func TestOPStorage_RevokeToken_access_token(t *testing.T) { } func TestOPStorage_RevokeToken_access_token_invalid_token_hint_type(t *testing.T) { - t.Parallel() - clientID, _ := createClient(t, Instance) provider, err := Instance.CreateRelyingParty(CTX, clientID, redirectURI) require.NoError(t, err) @@ -277,8 +263,6 @@ func TestOPStorage_RevokeToken_access_token_invalid_token_hint_type(t *testing.T } func TestOPStorage_RevokeToken_refresh_token(t *testing.T) { - t.Parallel() - clientID, _ := createClient(t, Instance) provider, err := Instance.CreateRelyingParty(CTX, clientID, redirectURI) require.NoError(t, err) @@ -322,8 +306,6 @@ func TestOPStorage_RevokeToken_refresh_token(t *testing.T) { } func TestOPStorage_RevokeToken_refresh_token_invalid_token_type_hint(t *testing.T) { - t.Parallel() - clientID, _ := createClient(t, Instance) provider, err := Instance.CreateRelyingParty(CTX, clientID, redirectURI) require.NoError(t, err) @@ -361,8 +343,6 @@ func TestOPStorage_RevokeToken_refresh_token_invalid_token_type_hint(t *testing. } func TestOPStorage_RevokeToken_invalid_client(t *testing.T) { - t.Parallel() - clientID, _ := createClient(t, Instance) authRequestID := createAuthRequest(t, Instance, clientID, redirectURI, oidc.ScopeOpenID, oidc.ScopeOfflineAccess) sessionID, sessionToken, startTime, changeTime := Instance.CreateVerifiedWebAuthNSession(t, CTXLOGIN, User.GetUserId()) @@ -393,8 +373,6 @@ func TestOPStorage_RevokeToken_invalid_client(t *testing.T) { } func TestOPStorage_TerminateSession(t *testing.T) { - t.Parallel() - clientID, _ := createClient(t, Instance) provider, err := Instance.CreateRelyingParty(CTX, clientID, redirectURI) require.NoError(t, err) @@ -432,8 +410,6 @@ func TestOPStorage_TerminateSession(t *testing.T) { } func TestOPStorage_TerminateSession_refresh_grant(t *testing.T) { - t.Parallel() - clientID, _ := createClient(t, Instance) provider, err := Instance.CreateRelyingParty(CTX, clientID, redirectURI) require.NoError(t, err) @@ -478,8 +454,6 @@ func TestOPStorage_TerminateSession_refresh_grant(t *testing.T) { } func TestOPStorage_TerminateSession_empty_id_token_hint(t *testing.T) { - t.Parallel() - clientID, _ := createClient(t, Instance) provider, err := Instance.CreateRelyingParty(CTX, clientID, redirectURI) require.NoError(t, err) diff --git a/internal/api/oidc/integration_test/client_test.go b/internal/api/oidc/integration_test/client_test.go index 1b9ccd5cb3..6d61e84437 100644 --- a/internal/api/oidc/integration_test/client_test.go +++ b/internal/api/oidc/integration_test/client_test.go @@ -24,8 +24,6 @@ import ( ) func TestServer_Introspect(t *testing.T) { - t.Parallel() - project, err := Instance.CreateProject(CTX) require.NoError(t, err) app, err := Instance.CreateOIDCNativeClient(CTX, redirectURI, logoutRedirectURI, project.GetId(), false) @@ -143,8 +141,6 @@ func TestServer_Introspect(t *testing.T) { } func TestServer_Introspect_invalid_auth_invalid_token(t *testing.T) { - t.Parallel() - // ensure that when an invalid authentication and token is sent, the authentication error is returned // https://github.com/zitadel/zitadel/pull/8133 resourceServer, err := Instance.CreateResourceServerClientCredentials(CTX, "xxxxx", "xxxxx") @@ -191,8 +187,6 @@ func assertIntrospection( // TestServer_VerifyClient tests verification by running code flow tests // with clients that have different authentication methods. func TestServer_VerifyClient(t *testing.T) { - t.Parallel() - sessionID, sessionToken, startTime, changeTime := Instance.CreateVerifiedWebAuthNSession(t, CTXLOGIN, User.GetUserId()) project, err := Instance.CreateProject(CTX) require.NoError(t, err) diff --git a/internal/api/oidc/integration_test/keys_test.go b/internal/api/oidc/integration_test/keys_test.go index e8160017a5..8b66e980d0 100644 --- a/internal/api/oidc/integration_test/keys_test.go +++ b/internal/api/oidc/integration_test/keys_test.go @@ -24,8 +24,6 @@ import ( ) func TestServer_Keys(t *testing.T) { - t.Parallel() - instance := integration.NewInstance(CTX) ctxLogin := instance.WithAuthorization(CTX, integration.UserTypeLogin) diff --git a/internal/api/oidc/integration_test/server_test.go b/internal/api/oidc/integration_test/server_test.go index fcf9bfb65e..ea2fa0e2ee 100644 --- a/internal/api/oidc/integration_test/server_test.go +++ b/internal/api/oidc/integration_test/server_test.go @@ -18,8 +18,6 @@ import ( ) func TestServer_RefreshToken_Status(t *testing.T) { - t.Parallel() - clientID, _ := createClient(t, Instance) provider, err := Instance.CreateRelyingParty(CTX, clientID, redirectURI) require.NoError(t, err) diff --git a/internal/api/oidc/integration_test/token_client_credentials_test.go b/internal/api/oidc/integration_test/token_client_credentials_test.go index d43f40e53e..0567b4ce78 100644 --- a/internal/api/oidc/integration_test/token_client_credentials_test.go +++ b/internal/api/oidc/integration_test/token_client_credentials_test.go @@ -22,8 +22,6 @@ import ( ) func TestServer_ClientCredentialsExchange(t *testing.T) { - t.Parallel() - machine, name, clientID, clientSecret, err := Instance.CreateOIDCCredentialsClient(CTX) require.NoError(t, err) diff --git a/internal/api/oidc/integration_test/token_exchange_test.go b/internal/api/oidc/integration_test/token_exchange_test.go index 5b0b86f0ec..1319eea19a 100644 --- a/internal/api/oidc/integration_test/token_exchange_test.go +++ b/internal/api/oidc/integration_test/token_exchange_test.go @@ -143,8 +143,6 @@ func refreshTokenVerifier(ctx context.Context, provider rp.RelyingParty, subject } func TestServer_TokenExchange(t *testing.T) { - t.Parallel() - instance := integration.NewInstance(CTX) ctx := instance.WithAuthorization(CTX, integration.UserTypeIAMOwner) userResp := instance.CreateHumanUser(ctx) @@ -365,8 +363,6 @@ func TestServer_TokenExchange(t *testing.T) { } func TestServer_TokenExchangeImpersonation(t *testing.T) { - t.Parallel() - instance := integration.NewInstance(CTX) ctx := instance.WithAuthorization(CTX, integration.UserTypeIAMOwner) userResp := instance.CreateHumanUser(ctx) @@ -581,8 +577,6 @@ func TestServer_TokenExchangeImpersonation(t *testing.T) { // This test tries to call the zitadel API with an impersonated token, // which should fail. func TestImpersonation_API_Call(t *testing.T) { - t.Parallel() - instance := integration.NewInstance(CTX) ctx := instance.WithAuthorization(CTX, integration.UserTypeIAMOwner) diff --git a/internal/api/oidc/integration_test/token_jwt_profile_test.go b/internal/api/oidc/integration_test/token_jwt_profile_test.go index ac483cf620..5845713317 100644 --- a/internal/api/oidc/integration_test/token_jwt_profile_test.go +++ b/internal/api/oidc/integration_test/token_jwt_profile_test.go @@ -21,8 +21,6 @@ import ( ) func TestServer_JWTProfile(t *testing.T) { - t.Parallel() - user, name, keyData, err := Instance.CreateOIDCJWTProfileClient(CTX) require.NoError(t, err) diff --git a/internal/notification/handlers/integration_test/telemetry_pusher_test.go b/internal/notification/handlers/integration_test/telemetry_pusher_test.go index 6b4ac10258..0779df7b34 100644 --- a/internal/notification/handlers/integration_test/telemetry_pusher_test.go +++ b/internal/notification/handlers/integration_test/telemetry_pusher_test.go @@ -26,8 +26,6 @@ import ( ) func TestServer_TelemetryPushMilestones(t *testing.T) { - t.Parallel() - sub := sink.Subscribe(CTX, sink.ChannelMilestone) defer sub.Close() From 8537805ea548452c9a88165d9882b0e67c1e9b93 Mon Sep 17 00:00:00 2001 From: Livio Spring Date: Wed, 27 Nov 2024 16:01:17 +0100 Subject: [PATCH 32/64] feat(notification): use event worker pool (#8962) # Which Problems Are Solved The current handling of notification follows the same pattern as all other projections: Created events are handled sequentially (based on "position") by a handler. During the process, a lot of information is aggregated (user, texts, templates, ...). This leads to back pressure on the projection since the handling of events might take longer than the time before a new event (to be handled) is created. # How the Problems Are Solved - The current user notification handler creates separate notification events based on the user / session events. - These events contain all the present and required information including the userID. - These notification events get processed by notification workers, which gather the necessary information (recipient address, texts, templates) to send out these notifications. - If a notification fails, a retry event is created based on the current notification request including the current state of the user (this prevents race conditions, where a user is changed in the meantime and the notification already gets the new state). - The retry event will be handled after a backoff delay. This delay increases with every attempt. - If the configured amount of attempts is reached or the message expired (based on config), a cancel event is created, letting the workers know, the notification must no longer be handled. - In case of successful send, a sent event is created for the notification aggregate and the existing "sent" events for the user / session object is stored. - The following is added to the defaults.yaml to allow configuration of the notification workers: ```yaml Notifications: # The amount of workers processing the notification request events. # If set to 0, no notification request events will be handled. This can be useful when running in # multi binary / pod setup and allowing only certain executables to process the events. Workers: 1 # ZITADEL_NOTIFIACATIONS_WORKERS # The amount of events a single worker will process in a run. BulkLimit: 10 # ZITADEL_NOTIFIACATIONS_BULKLIMIT # Time interval between scheduled notifications for request events RequeueEvery: 2s # ZITADEL_NOTIFIACATIONS_REQUEUEEVERY # The amount of workers processing the notification retry events. # If set to 0, no notification retry events will be handled. This can be useful when running in # multi binary / pod setup and allowing only certain executables to process the events. RetryWorkers: 1 # ZITADEL_NOTIFIACATIONS_RETRYWORKERS # Time interval between scheduled notifications for retry events RetryRequeueEvery: 2s # ZITADEL_NOTIFIACATIONS_RETRYREQUEUEEVERY # Only instances are projected, for which at least a projection-relevant event exists within the timeframe # from HandleActiveInstances duration in the past until the projection's current time # If set to 0 (default), every instance is always considered active HandleActiveInstances: 0s # ZITADEL_NOTIFIACATIONS_HANDLEACTIVEINSTANCES # The maximum duration a transaction remains open # before it spots left folding additional events # and updates the table. TransactionDuration: 1m # ZITADEL_NOTIFIACATIONS_TRANSACTIONDURATION # Automatically cancel the notification after the amount of failed attempts MaxAttempts: 3 # ZITADEL_NOTIFIACATIONS_MAXATTEMPTS # Automatically cancel the notification if it cannot be handled within a specific time MaxTtl: 5m # ZITADEL_NOTIFIACATIONS_MAXTTL # Failed attempts are retried after a confogired delay (with exponential backoff). # Set a minimum and maximum delay and a factor for the backoff MinRetryDelay: 1s # ZITADEL_NOTIFIACATIONS_MINRETRYDELAY MaxRetryDelay: 20s # ZITADEL_NOTIFIACATIONS_MAXRETRYDELAY # Any factor below 1 will be set to 1 RetryDelayFactor: 1.5 # ZITADEL_NOTIFIACATIONS_RETRYDELAYFACTOR ``` # Additional Changes None # Additional Context - closes #8931 --- .github/workflows/build.yml | 2 +- cmd/defaults.yaml | 34 + cmd/mirror/projections.go | 3 + cmd/setup/config.go | 1 + cmd/setup/setup.go | 2 + cmd/start/config.go | 1 + cmd/start/start.go | 2 + .../api/ui/login/init_password_handler.go | 16 +- internal/api/ui/login/init_user_handler.go | 20 +- internal/api/ui/login/invite_user_handler.go | 18 +- internal/api/ui/login/mail_verify_handler.go | 16 +- .../api/ui/login/mfa_verify_otp_handler.go | 4 +- internal/command/notification.go | 162 + internal/domain/human_web_auth_n.go | 4 + internal/domain/notification.go | 33 + internal/domain/url_template.go | 4 + internal/notification/channels/error.go | 25 + .../notification/channels/twilio/channel.go | 18 +- internal/notification/handlers/commands.go | 6 + .../notification/handlers/config_email.go | 3 + internal/notification/handlers/config_sms.go | 3 + internal/notification/handlers/ctx.go | 4 + .../handlers/mock/commands.mock.go | 58 + .../handlers/notification_worker.go | 515 ++++ .../handlers/notification_worker_test.go | 963 ++++++ .../notification/handlers/user_notifier.go | 806 +++-- .../handlers/user_notifier_test.go | 2709 +++++++++-------- internal/notification/projections.go | 12 +- internal/notification/types/domain_claimed.go | 20 - .../types/email_verification_code.go | 28 - .../types/email_verification_code_test.go | 92 - internal/notification/types/init_code.go | 17 - internal/notification/types/invite_code.go | 31 - internal/notification/types/notification.go | 22 +- internal/notification/types/otp.go | 29 - .../notification/types/password_change.go | 16 - internal/notification/types/password_code.go | 27 - .../types/passwordless_registration_link.go | 25 - .../passwordless_registration_link_test.go | 90 - .../types/phone_verification_code.go | 15 - internal/notification/types/types_test.go | 23 - internal/notification/types/user_email.go | 3 + internal/repository/notification/aggregate.go | 25 + .../repository/notification/eventstore.go | 12 + .../repository/notification/notification.go | 244 ++ 45 files changed, 4005 insertions(+), 2158 deletions(-) create mode 100644 internal/command/notification.go create mode 100644 internal/notification/channels/error.go create mode 100644 internal/notification/handlers/notification_worker.go create mode 100644 internal/notification/handlers/notification_worker_test.go delete mode 100644 internal/notification/types/domain_claimed.go delete mode 100644 internal/notification/types/email_verification_code.go delete mode 100644 internal/notification/types/email_verification_code_test.go delete mode 100644 internal/notification/types/init_code.go delete mode 100644 internal/notification/types/invite_code.go delete mode 100644 internal/notification/types/otp.go delete mode 100644 internal/notification/types/password_change.go delete mode 100644 internal/notification/types/password_code.go delete mode 100644 internal/notification/types/passwordless_registration_link.go delete mode 100644 internal/notification/types/passwordless_registration_link_test.go delete mode 100644 internal/notification/types/phone_verification_code.go delete mode 100644 internal/notification/types/types_test.go create mode 100644 internal/repository/notification/aggregate.go create mode 100644 internal/repository/notification/eventstore.go create mode 100644 internal/repository/notification/notification.go diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index ac7d909589..181ec838fe 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -77,7 +77,7 @@ jobs: go_version: "1.22" node_version: "18" buf_version: "latest" - go_lint_version: "v1.55.2" + go_lint_version: "v1.62.2" core_cache_key: ${{ needs.core.outputs.cache_key }} core_cache_path: ${{ needs.core.outputs.cache_path }} diff --git a/cmd/defaults.yaml b/cmd/defaults.yaml index e15d491a8b..16c321251a 100644 --- a/cmd/defaults.yaml +++ b/cmd/defaults.yaml @@ -448,6 +448,40 @@ Projections: # Telemetry data synchronization is not time critical. Setting RequeueEvery to 55 minutes doesn't annoy the database too much. RequeueEvery: 3300s # ZITADEL_PROJECTIONS_CUSTOMIZATIONS_TELEMETRY_REQUEUEEVERY +Notifications: + # The amount of workers processing the notification request events. + # If set to 0, no notification request events will be handled. This can be useful when running in + # multi binary / pod setup and allowing only certain executables to process the events. + Workers: 1 # ZITADEL_NOTIFIACATIONS_WORKERS + # The amount of events a single worker will process in a run. + BulkLimit: 10 # ZITADEL_NOTIFIACATIONS_BULKLIMIT + # Time interval between scheduled notifications for request events + RequeueEvery: 2s # ZITADEL_NOTIFIACATIONS_REQUEUEEVERY + # The amount of workers processing the notification retry events. + # If set to 0, no notification retry events will be handled. This can be useful when running in + # multi binary / pod setup and allowing only certain executables to process the events. + RetryWorkers: 1 # ZITADEL_NOTIFIACATIONS_RETRYWORKERS + # Time interval between scheduled notifications for retry events + RetryRequeueEvery: 2s # ZITADEL_NOTIFIACATIONS_RETRYREQUEUEEVERY + # Only instances are projected, for which at least a projection-relevant event exists within the timeframe + # from HandleActiveInstances duration in the past until the projection's current time + # If set to 0 (default), every instance is always considered active + HandleActiveInstances: 0s # ZITADEL_NOTIFIACATIONS_HANDLEACTIVEINSTANCES + # The maximum duration a transaction remains open + # before it spots left folding additional events + # and updates the table. + TransactionDuration: 1m # ZITADEL_NOTIFIACATIONS_TRANSACTIONDURATION + # Automatically cancel the notification after the amount of failed attempts + MaxAttempts: 3 # ZITADEL_NOTIFIACATIONS_MAXATTEMPTS + # Automatically cancel the notification if it cannot be handled within a specific time + MaxTtl: 5m # ZITADEL_NOTIFIACATIONS_MAXTTL + # Failed attempts are retried after a confogired delay (with exponential backoff). + # Set a minimum and maximum delay and a factor for the backoff + MinRetryDelay: 1s # ZITADEL_NOTIFIACATIONS_MINRETRYDELAY + MaxRetryDelay: 20s # ZITADEL_NOTIFIACATIONS_MAXRETRYDELAY + # Any factor below 1 will be set to 1 + RetryDelayFactor: 1.5 # ZITADEL_NOTIFIACATIONS_RETRYDELAYFACTOR + Auth: # See Projections.BulkLimit SearchLimit: 1000 # ZITADEL_AUTH_SEARCHLIMIT diff --git a/cmd/mirror/projections.go b/cmd/mirror/projections.go index cffc4921ca..f849d01217 100644 --- a/cmd/mirror/projections.go +++ b/cmd/mirror/projections.go @@ -69,6 +69,7 @@ func projectionsCmd() *cobra.Command { type ProjectionsConfig struct { Destination database.Config Projections projection.Config + Notifications handlers.WorkerConfig EncryptionKeys *encryption.EncryptionKeyConfig SystemAPIUsers map[string]*internal_authz.SystemAPIUser Eventstore *eventstore.Config @@ -205,6 +206,7 @@ func projections( config.Projections.Customizations["notificationsquotas"], config.Projections.Customizations["backchannel"], config.Projections.Customizations["telemetry"], + config.Notifications, *config.Telemetry, config.ExternalDomain, config.ExternalPort, @@ -219,6 +221,7 @@ func projections( keys.SMS, keys.OIDC, config.OIDC.DefaultBackChannelLogoutLifetime, + client, ) config.Auth.Spooler.Client = client diff --git a/cmd/setup/config.go b/cmd/setup/config.go index b0a143b698..bd5444f2de 100644 --- a/cmd/setup/config.go +++ b/cmd/setup/config.go @@ -42,6 +42,7 @@ type Config struct { DefaultInstance command.InstanceSetup Machine *id.Config Projections projection.Config + Notifications handlers.WorkerConfig Eventstore *eventstore.Config InitProjections InitProjections diff --git a/cmd/setup/setup.go b/cmd/setup/setup.go index b8ea708cbf..1ad3037009 100644 --- a/cmd/setup/setup.go +++ b/cmd/setup/setup.go @@ -437,6 +437,7 @@ func initProjections( config.Projections.Customizations["notificationsquotas"], config.Projections.Customizations["backchannel"], config.Projections.Customizations["telemetry"], + config.Notifications, *config.Telemetry, config.ExternalDomain, config.ExternalPort, @@ -451,6 +452,7 @@ func initProjections( keys.SMS, keys.OIDC, config.OIDC.DefaultBackChannelLogoutLifetime, + queryDBClient, ) for _, p := range notify_handler.Projections() { err := migration.Migrate(ctx, eventstoreClient, p) diff --git a/cmd/start/config.go b/cmd/start/config.go index 26c4b84b50..6182342592 100644 --- a/cmd/start/config.go +++ b/cmd/start/config.go @@ -54,6 +54,7 @@ type Config struct { Metrics metrics.Config Profiler profiler.Config Projections projection.Config + Notifications handlers.WorkerConfig Auth auth_es.Config Admin admin_es.Config UserAgentCookie *middleware.UserAgentCookieConfig diff --git a/cmd/start/start.go b/cmd/start/start.go index e816b5bb52..c9147fe653 100644 --- a/cmd/start/start.go +++ b/cmd/start/start.go @@ -277,6 +277,7 @@ func startZitadel(ctx context.Context, config *Config, masterKey string, server config.Projections.Customizations["notificationsquotas"], config.Projections.Customizations["backchannel"], config.Projections.Customizations["telemetry"], + config.Notifications, *config.Telemetry, config.ExternalDomain, config.ExternalPort, @@ -291,6 +292,7 @@ func startZitadel(ctx context.Context, config *Config, masterKey string, server keys.SMS, keys.OIDC, config.OIDC.DefaultBackChannelLogoutLifetime, + queryDBClient, ) notification.Start(ctx) diff --git a/internal/api/ui/login/init_password_handler.go b/internal/api/ui/login/init_password_handler.go index 4b9c173a2f..f7faab778e 100644 --- a/internal/api/ui/login/init_password_handler.go +++ b/internal/api/ui/login/init_password_handler.go @@ -1,8 +1,8 @@ package login import ( + "fmt" "net/http" - "net/url" http_mw "github.com/zitadel/zitadel/internal/api/http/middleware" "github.com/zitadel/zitadel/internal/domain" @@ -38,13 +38,13 @@ type initPasswordData struct { HasSymbol string } -func InitPasswordLink(origin, userID, code, orgID, authRequestID string) string { - v := url.Values{} - v.Set(queryInitPWUserID, userID) - v.Set(queryInitPWCode, code) - v.Set(queryOrgID, orgID) - v.Set(QueryAuthRequestID, authRequestID) - return externalLink(origin) + EndpointInitPassword + "?" + v.Encode() +func InitPasswordLinkTemplate(origin, userID, orgID, authRequestID string) string { + return fmt.Sprintf("%s%s?%s=%s&%s=%s&%s=%s&%s=%s", + externalLink(origin), EndpointInitPassword, + queryInitPWUserID, userID, + queryInitPWCode, "{{.Code}}", + queryOrgID, orgID, + QueryAuthRequestID, authRequestID) } func (l *Login) handleInitPassword(w http.ResponseWriter, r *http.Request) { diff --git a/internal/api/ui/login/init_user_handler.go b/internal/api/ui/login/init_user_handler.go index d60c4c3cbd..ad00aa0258 100644 --- a/internal/api/ui/login/init_user_handler.go +++ b/internal/api/ui/login/init_user_handler.go @@ -1,8 +1,8 @@ package login import ( + "fmt" "net/http" - "net/url" "strconv" http_mw "github.com/zitadel/zitadel/internal/api/http/middleware" @@ -44,15 +44,15 @@ type initUserData struct { HasSymbol string } -func InitUserLink(origin, userID, loginName, code, orgID string, passwordSet bool, authRequestID string) string { - v := url.Values{} - v.Set(queryInitUserUserID, userID) - v.Set(queryInitUserLoginName, loginName) - v.Set(queryInitUserCode, code) - v.Set(queryOrgID, orgID) - v.Set(queryInitUserPassword, strconv.FormatBool(passwordSet)) - v.Set(QueryAuthRequestID, authRequestID) - return externalLink(origin) + EndpointInitUser + "?" + v.Encode() +func InitUserLinkTemplate(origin, userID, orgID, authRequestID string) string { + return fmt.Sprintf("%s%s?%s=%s&%s=%s&%s=%s&%s=%s&%s=%s&%s=%s", + externalLink(origin), EndpointInitUser, + queryInitUserUserID, userID, + queryInitUserLoginName, "{{.LoginName}}", + queryInitUserCode, "{{.Code}}", + queryOrgID, orgID, + queryInitUserPassword, "{{.PasswordSet}}", + QueryAuthRequestID, authRequestID) } func (l *Login) handleInitUser(w http.ResponseWriter, r *http.Request) { diff --git a/internal/api/ui/login/invite_user_handler.go b/internal/api/ui/login/invite_user_handler.go index 3141af2d78..9f9ffb5ad3 100644 --- a/internal/api/ui/login/invite_user_handler.go +++ b/internal/api/ui/login/invite_user_handler.go @@ -1,8 +1,8 @@ package login import ( + "fmt" "net/http" - "net/url" http_mw "github.com/zitadel/zitadel/internal/api/http/middleware" "github.com/zitadel/zitadel/internal/domain" @@ -40,14 +40,14 @@ type inviteUserData struct { HasSymbol string } -func InviteUserLink(origin, userID, loginName, code, orgID string, authRequestID string) string { - v := url.Values{} - v.Set(queryInviteUserUserID, userID) - v.Set(queryInviteUserLoginName, loginName) - v.Set(queryInviteUserCode, code) - v.Set(queryOrgID, orgID) - v.Set(QueryAuthRequestID, authRequestID) - return externalLink(origin) + EndpointInviteUser + "?" + v.Encode() +func InviteUserLinkTemplate(origin, userID, orgID string, authRequestID string) string { + return fmt.Sprintf("%s%s?%s=%s&%s=%s&%s=%s&%s=%s&%s=%s", + externalLink(origin), EndpointInviteUser, + queryInviteUserUserID, userID, + queryInviteUserLoginName, "{{.LoginName}}", + queryInviteUserCode, "{{.Code}}", + queryOrgID, orgID, + QueryAuthRequestID, authRequestID) } func (l *Login) handleInviteUser(w http.ResponseWriter, r *http.Request) { diff --git a/internal/api/ui/login/mail_verify_handler.go b/internal/api/ui/login/mail_verify_handler.go index be13663529..5be22c6741 100644 --- a/internal/api/ui/login/mail_verify_handler.go +++ b/internal/api/ui/login/mail_verify_handler.go @@ -2,8 +2,8 @@ package login import ( "context" + "fmt" "net/http" - "net/url" "slices" "github.com/zitadel/logging" @@ -43,13 +43,13 @@ type mailVerificationData struct { HasSymbol string } -func MailVerificationLink(origin, userID, code, orgID, authRequestID string) string { - v := url.Values{} - v.Set(queryUserID, userID) - v.Set(queryCode, code) - v.Set(queryOrgID, orgID) - v.Set(QueryAuthRequestID, authRequestID) - return externalLink(origin) + EndpointMailVerification + "?" + v.Encode() +func MailVerificationLinkTemplate(origin, userID, orgID, authRequestID string) string { + return fmt.Sprintf("%s%s?%s=%s&%s=%s&%s=%s&%s=%s", + externalLink(origin), EndpointMailVerification, + queryUserID, userID, + queryCode, "{{.Code}}", + queryOrgID, orgID, + QueryAuthRequestID, authRequestID) } func (l *Login) handleMailVerification(w http.ResponseWriter, r *http.Request) { diff --git a/internal/api/ui/login/mfa_verify_otp_handler.go b/internal/api/ui/login/mfa_verify_otp_handler.go index b39605e667..09352f9443 100644 --- a/internal/api/ui/login/mfa_verify_otp_handler.go +++ b/internal/api/ui/login/mfa_verify_otp_handler.go @@ -27,8 +27,8 @@ type mfaOTPFormData struct { Provider domain.MFAType `schema:"provider"` } -func OTPLink(origin, authRequestID, code string, provider domain.MFAType) string { - return fmt.Sprintf("%s%s?%s=%s&%s=%s&%s=%d", externalLink(origin), EndpointMFAOTPVerify, QueryAuthRequestID, authRequestID, queryCode, code, querySelectedProvider, provider) +func OTPLinkTemplate(origin, authRequestID string, provider domain.MFAType) string { + return fmt.Sprintf("%s%s?%s=%s&%s=%s&%s=%d", externalLink(origin), EndpointMFAOTPVerify, QueryAuthRequestID, authRequestID, queryCode, "{{.Code}}", querySelectedProvider, provider) } // renderOTPVerification renders the OTP verification for SMS and Email based on the passed MFAType. diff --git a/internal/command/notification.go b/internal/command/notification.go new file mode 100644 index 0000000000..b0524afa89 --- /dev/null +++ b/internal/command/notification.go @@ -0,0 +1,162 @@ +package command + +import ( + "context" + "database/sql" + "time" + + "github.com/zitadel/zitadel/internal/crypto" + "github.com/zitadel/zitadel/internal/domain" + "github.com/zitadel/zitadel/internal/eventstore" + "github.com/zitadel/zitadel/internal/query" + "github.com/zitadel/zitadel/internal/repository/notification" +) + +type NotificationRequest struct { + UserID string + UserResourceOwner string + TriggerOrigin string + URLTemplate string + Code *crypto.CryptoValue + CodeExpiry time.Duration + EventType eventstore.EventType + NotificationType domain.NotificationType + MessageType string + UnverifiedNotificationChannel bool + Args *domain.NotificationArguments + AggregateID string + AggregateResourceOwner string + IsOTP bool + RequiresPreviousDomain bool +} + +type NotificationRetryRequest struct { + NotificationRequest + BackOff time.Duration + NotifyUser *query.NotifyUser +} + +func NewNotificationRequest( + userID, resourceOwner, triggerOrigin string, + eventType eventstore.EventType, + notificationType domain.NotificationType, + messageType string, +) *NotificationRequest { + return &NotificationRequest{ + UserID: userID, + UserResourceOwner: resourceOwner, + TriggerOrigin: triggerOrigin, + EventType: eventType, + NotificationType: notificationType, + MessageType: messageType, + } +} + +func (r *NotificationRequest) WithCode(code *crypto.CryptoValue, expiry time.Duration) *NotificationRequest { + r.Code = code + r.CodeExpiry = expiry + return r +} + +func (r *NotificationRequest) WithURLTemplate(urlTemplate string) *NotificationRequest { + r.URLTemplate = urlTemplate + return r +} + +func (r *NotificationRequest) WithUnverifiedChannel() *NotificationRequest { + r.UnverifiedNotificationChannel = true + return r +} + +func (r *NotificationRequest) WithArgs(args *domain.NotificationArguments) *NotificationRequest { + r.Args = args + return r +} + +func (r *NotificationRequest) WithAggregate(id, resourceOwner string) *NotificationRequest { + r.AggregateID = id + r.AggregateResourceOwner = resourceOwner + return r +} + +func (r *NotificationRequest) WithOTP() *NotificationRequest { + r.IsOTP = true + return r +} + +func (r *NotificationRequest) WithPreviousDomain() *NotificationRequest { + r.RequiresPreviousDomain = true + return r +} + +// RequestNotification writes a new notification.RequestEvent with the notification.Aggregate to the eventstore +func (c *Commands) RequestNotification( + ctx context.Context, + resourceOwner string, + request *NotificationRequest, +) error { + id, err := c.idGenerator.Next() + if err != nil { + return err + } + _, err = c.eventstore.Push(ctx, notification.NewRequestedEvent(ctx, ¬ification.NewAggregate(id, resourceOwner).Aggregate, + request.UserID, + request.UserResourceOwner, + request.AggregateID, + request.AggregateResourceOwner, + request.TriggerOrigin, + request.URLTemplate, + request.Code, + request.CodeExpiry, + request.EventType, + request.NotificationType, + request.MessageType, + request.UnverifiedNotificationChannel, + request.IsOTP, + request.RequiresPreviousDomain, + request.Args)) + return err +} + +// NotificationCanceled writes a new notification.CanceledEvent with the notification.Aggregate to the eventstore +func (c *Commands) NotificationCanceled(ctx context.Context, tx *sql.Tx, id, resourceOwner string, requestError error) error { + var errorMessage string + if requestError != nil { + errorMessage = requestError.Error() + } + _, err := c.eventstore.PushWithClient(ctx, tx, notification.NewCanceledEvent(ctx, ¬ification.NewAggregate(id, resourceOwner).Aggregate, errorMessage)) + return err +} + +// NotificationSent writes a new notification.SentEvent with the notification.Aggregate to the eventstore +func (c *Commands) NotificationSent(ctx context.Context, tx *sql.Tx, id, resourceOwner string) error { + _, err := c.eventstore.PushWithClient(ctx, tx, notification.NewSentEvent(ctx, ¬ification.NewAggregate(id, resourceOwner).Aggregate)) + return err +} + +// NotificationRetryRequested writes a new notification.RetryRequestEvent with the notification.Aggregate to the eventstore +func (c *Commands) NotificationRetryRequested(ctx context.Context, tx *sql.Tx, id, resourceOwner string, request *NotificationRetryRequest, requestError error) error { + var errorMessage string + if requestError != nil { + errorMessage = requestError.Error() + } + _, err := c.eventstore.PushWithClient(ctx, tx, notification.NewRetryRequestedEvent(ctx, ¬ification.NewAggregate(id, resourceOwner).Aggregate, + request.UserID, + request.UserResourceOwner, + request.AggregateID, + request.AggregateResourceOwner, + request.TriggerOrigin, + request.URLTemplate, + request.Code, + request.CodeExpiry, + request.EventType, + request.NotificationType, + request.MessageType, + request.UnverifiedNotificationChannel, + request.IsOTP, + request.Args, + request.NotifyUser, + request.BackOff, + errorMessage)) + return err +} diff --git a/internal/domain/human_web_auth_n.go b/internal/domain/human_web_auth_n.go index 16590d43ca..62c3424914 100644 --- a/internal/domain/human_web_auth_n.go +++ b/internal/domain/human_web_auth_n.go @@ -96,3 +96,7 @@ func (p *PasswordlessInitCode) Link(baseURL string) string { func PasswordlessInitCodeLink(baseURL, userID, resourceOwner, codeID, code string) string { return fmt.Sprintf("%s?userID=%s&orgID=%s&codeID=%s&code=%s", baseURL, userID, resourceOwner, codeID, code) } + +func PasswordlessInitCodeLinkTemplate(baseURL, userID, resourceOwner, codeID string) string { + return PasswordlessInitCodeLink(baseURL, userID, resourceOwner, codeID, "{{.Code}}") +} diff --git a/internal/domain/notification.go b/internal/domain/notification.go index 756c400c66..81bf6413dd 100644 --- a/internal/domain/notification.go +++ b/internal/domain/notification.go @@ -1,5 +1,9 @@ package domain +import ( + "time" +) + type NotificationType int32 const ( @@ -31,3 +35,32 @@ const ( notificationProviderTypeCount ) + +type NotificationArguments struct { + Origin string `json:"origin,omitempty"` + Domain string `json:"domain,omitempty"` + Expiry time.Duration `json:"expiry,omitempty"` + TempUsername string `json:"tempUsername,omitempty"` + ApplicationName string `json:"applicationName,omitempty"` + CodeID string `json:"codeID,omitempty"` + SessionID string `json:"sessionID,omitempty"` + AuthRequestID string `json:"authRequestID,omitempty"` +} + +// ToMap creates a type safe map of the notification arguments. +// Since these arguments are used in text template, all keys must be PascalCase and types must remain the same (e.g. Duration). +func (n *NotificationArguments) ToMap() map[string]interface{} { + m := make(map[string]interface{}) + if n == nil { + return m + } + m["Origin"] = n.Origin + m["Domain"] = n.Domain + m["Expiry"] = n.Expiry + m["TempUsername"] = n.TempUsername + m["ApplicationName"] = n.ApplicationName + m["CodeID"] = n.CodeID + m["SessionID"] = n.SessionID + m["AuthRequestID"] = n.AuthRequestID + return m +} diff --git a/internal/domain/url_template.go b/internal/domain/url_template.go index ed39a8257e..063d701d0a 100644 --- a/internal/domain/url_template.go +++ b/internal/domain/url_template.go @@ -7,6 +7,10 @@ import ( "github.com/zitadel/zitadel/internal/zerrors" ) +func RenderURLTemplate(w io.Writer, tmpl string, data any) error { + return renderURLTemplate(w, tmpl, data) +} + func renderURLTemplate(w io.Writer, tmpl string, data any) error { parsed, err := template.New("").Parse(tmpl) if err != nil { diff --git a/internal/notification/channels/error.go b/internal/notification/channels/error.go new file mode 100644 index 0000000000..e3c3fa3c49 --- /dev/null +++ b/internal/notification/channels/error.go @@ -0,0 +1,25 @@ +package channels + +import "errors" + +type CancelError struct { + Err error +} + +func (e *CancelError) Error() string { + return e.Err.Error() +} + +func NewCancelError(err error) error { + return &CancelError{ + Err: err, + } +} + +func (e *CancelError) Is(target error) bool { + return errors.As(target, &e) +} + +func (e *CancelError) Unwrap() error { + return e.Err +} diff --git a/internal/notification/channels/twilio/channel.go b/internal/notification/channels/twilio/channel.go index e3e2767a0e..8b7f0e24f2 100644 --- a/internal/notification/channels/twilio/channel.go +++ b/internal/notification/channels/twilio/channel.go @@ -1,7 +1,10 @@ package twilio import ( - newTwilio "github.com/twilio/twilio-go" + "errors" + + "github.com/twilio/twilio-go" + twilioClient "github.com/twilio/twilio-go/client" openapi "github.com/twilio/twilio-go/rest/api/v2010" verify "github.com/twilio/twilio-go/rest/verify/v2" "github.com/zitadel/logging" @@ -12,7 +15,7 @@ import ( ) func InitChannel(config Config) channels.NotificationChannel { - client := newTwilio.NewRestClientWithParams(newTwilio.ClientParams{Username: config.SID, Password: config.Token}) + client := twilio.NewRestClientWithParams(twilio.ClientParams{Username: config.SID, Password: config.Token}) logging.Debug("successfully initialized twilio sms channel") return channels.HandleMessageFunc(func(message channels.Message) error { @@ -26,6 +29,17 @@ func InitChannel(config Config) channels.NotificationChannel { params.SetChannel("sms") resp, err := client.VerifyV2.CreateVerification(config.VerifyServiceSID, params) + + var twilioErr *twilioClient.TwilioRestError + if errors.As(err, &twilioErr) && twilioErr.Code == 60203 { + // If there were too many attempts to send a verification code (more than 5 times) + // without a verification check, even retries with backoff might not solve the problem. + // Instead, let the user initiate the verification again (e.g. using "resend code") + // https://www.twilio.com/docs/api/errors/60203 + logging.WithFields("error", twilioErr.Message, "code", twilioErr.Code).Warn("twilio create verification error") + return channels.NewCancelError(twilioErr) + } + if err != nil { return zerrors.ThrowInternal(err, "TWILI-0s9f2", "could not send verification") } diff --git a/internal/notification/handlers/commands.go b/internal/notification/handlers/commands.go index 07969a6bba..90b66bdf48 100644 --- a/internal/notification/handlers/commands.go +++ b/internal/notification/handlers/commands.go @@ -2,13 +2,19 @@ package handlers import ( "context" + "database/sql" + "github.com/zitadel/zitadel/internal/command" "github.com/zitadel/zitadel/internal/notification/senders" "github.com/zitadel/zitadel/internal/repository/milestone" "github.com/zitadel/zitadel/internal/repository/quota" ) type Commands interface { + RequestNotification(ctx context.Context, instanceID string, request *command.NotificationRequest) error + NotificationCanceled(ctx context.Context, tx *sql.Tx, id, resourceOwner string, err error) error + NotificationRetryRequested(ctx context.Context, tx *sql.Tx, id, resourceOwner string, request *command.NotificationRetryRequest, err error) error + NotificationSent(ctx context.Context, tx *sql.Tx, id, instanceID string) error HumanInitCodeSent(ctx context.Context, orgID, userID string) error HumanEmailVerificationCodeSent(ctx context.Context, orgID, userID string) error PasswordCodeSent(ctx context.Context, orgID, userID string, generatorInfo *senders.CodeGeneratorInfo) error diff --git a/internal/notification/handlers/config_email.go b/internal/notification/handlers/config_email.go index b78540a423..3e6eaa27a1 100644 --- a/internal/notification/handlers/config_email.go +++ b/internal/notification/handlers/config_email.go @@ -23,6 +23,9 @@ func (n *NotificationQueries) GetActiveEmailConfig(ctx context.Context) (*email. Description: config.Description, } if config.SMTPConfig != nil { + if config.SMTPConfig.Password == nil { + return nil, zerrors.ThrowNotFound(err, "QUERY-Wrs3gw", "Errors.SMTPConfig.NotFound") + } password, err := crypto.DecryptString(config.SMTPConfig.Password, n.SMTPPasswordCrypto) if err != nil { return nil, err diff --git a/internal/notification/handlers/config_sms.go b/internal/notification/handlers/config_sms.go index 1962824c9a..fd733b3731 100644 --- a/internal/notification/handlers/config_sms.go +++ b/internal/notification/handlers/config_sms.go @@ -24,6 +24,9 @@ func (n *NotificationQueries) GetActiveSMSConfig(ctx context.Context) (*sms.Conf Description: config.Description, } if config.TwilioConfig != nil { + if config.TwilioConfig.Token == nil { + return nil, zerrors.ThrowNotFound(err, "QUERY-SFefsd", "Errors.SMS.Twilio.NotFound") + } token, err := crypto.DecryptString(config.TwilioConfig.Token, n.SMSTokenCrypto) if err != nil { return nil, err diff --git a/internal/notification/handlers/ctx.go b/internal/notification/handlers/ctx.go index b8fc45da68..8f499814aa 100644 --- a/internal/notification/handlers/ctx.go +++ b/internal/notification/handlers/ctx.go @@ -14,6 +14,10 @@ func HandlerContext(event *eventstore.Aggregate) context.Context { return authz.SetCtxData(ctx, authz.CtxData{UserID: NotifyUserID, OrgID: event.ResourceOwner}) } +func ContextWithNotifier(ctx context.Context, aggregate *eventstore.Aggregate) context.Context { + return authz.SetCtxData(ctx, authz.CtxData{UserID: NotifyUserID, OrgID: aggregate.ResourceOwner}) +} + func (n *NotificationQueries) HandlerContext(event *eventstore.Aggregate) (context.Context, error) { ctx := context.Background() instance, err := n.InstanceByID(ctx, event.InstanceID) diff --git a/internal/notification/handlers/mock/commands.mock.go b/internal/notification/handlers/mock/commands.mock.go index ec327de8e8..ee6eb3c6b1 100644 --- a/internal/notification/handlers/mock/commands.mock.go +++ b/internal/notification/handlers/mock/commands.mock.go @@ -11,8 +11,10 @@ package mock import ( context "context" + sql "database/sql" reflect "reflect" + command "github.com/zitadel/zitadel/internal/command" senders "github.com/zitadel/zitadel/internal/notification/senders" milestone "github.com/zitadel/zitadel/internal/repository/milestone" quota "github.com/zitadel/zitadel/internal/repository/quota" @@ -155,6 +157,48 @@ func (mr *MockCommandsMockRecorder) MilestonePushed(ctx, instanceID, msType, end return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "MilestonePushed", reflect.TypeOf((*MockCommands)(nil).MilestonePushed), ctx, instanceID, msType, endpoints) } +// NotificationCanceled mocks base method. +func (m *MockCommands) NotificationCanceled(ctx context.Context, tx *sql.Tx, id, resourceOwner string, err error) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "NotificationCanceled", ctx, tx, id, resourceOwner, err) + ret0, _ := ret[0].(error) + return ret0 +} + +// NotificationCanceled indicates an expected call of NotificationCanceled. +func (mr *MockCommandsMockRecorder) NotificationCanceled(ctx, tx, id, resourceOwner, err any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "NotificationCanceled", reflect.TypeOf((*MockCommands)(nil).NotificationCanceled), ctx, tx, id, resourceOwner, err) +} + +// NotificationRetryRequested mocks base method. +func (m *MockCommands) NotificationRetryRequested(ctx context.Context, tx *sql.Tx, id, resourceOwner string, request *command.NotificationRetryRequest, err error) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "NotificationRetryRequested", ctx, tx, id, resourceOwner, request, err) + ret0, _ := ret[0].(error) + return ret0 +} + +// NotificationRetryRequested indicates an expected call of NotificationRetryRequested. +func (mr *MockCommandsMockRecorder) NotificationRetryRequested(ctx, tx, id, resourceOwner, request, err any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "NotificationRetryRequested", reflect.TypeOf((*MockCommands)(nil).NotificationRetryRequested), ctx, tx, id, resourceOwner, request, err) +} + +// NotificationSent mocks base method. +func (m *MockCommands) NotificationSent(ctx context.Context, tx *sql.Tx, id, instanceID string) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "NotificationSent", ctx, tx, id, instanceID) + ret0, _ := ret[0].(error) + return ret0 +} + +// NotificationSent indicates an expected call of NotificationSent. +func (mr *MockCommandsMockRecorder) NotificationSent(ctx, tx, id, instanceID any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "NotificationSent", reflect.TypeOf((*MockCommands)(nil).NotificationSent), ctx, tx, id, instanceID) +} + // OTPEmailSent mocks base method. func (m *MockCommands) OTPEmailSent(ctx context.Context, sessionID, resourceOwner string) error { m.ctrl.T.Helper() @@ -211,6 +255,20 @@ func (mr *MockCommandsMockRecorder) PasswordCodeSent(ctx, orgID, userID, generat return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "PasswordCodeSent", reflect.TypeOf((*MockCommands)(nil).PasswordCodeSent), ctx, orgID, userID, generatorInfo) } +// RequestNotification mocks base method. +func (m *MockCommands) RequestNotification(ctx context.Context, instanceID string, request *command.NotificationRequest) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "RequestNotification", ctx, instanceID, request) + ret0, _ := ret[0].(error) + return ret0 +} + +// RequestNotification indicates an expected call of RequestNotification. +func (mr *MockCommandsMockRecorder) RequestNotification(ctx, instanceID, request any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "RequestNotification", reflect.TypeOf((*MockCommands)(nil).RequestNotification), ctx, instanceID, request) +} + // UsageNotificationSent mocks base method. func (m *MockCommands) UsageNotificationSent(ctx context.Context, dueEvent *quota.NotificationDueEvent) error { m.ctrl.T.Helper() diff --git a/internal/notification/handlers/notification_worker.go b/internal/notification/handlers/notification_worker.go new file mode 100644 index 0000000000..96ecd755dd --- /dev/null +++ b/internal/notification/handlers/notification_worker.go @@ -0,0 +1,515 @@ +package handlers + +import ( + "context" + "database/sql" + "errors" + "math/rand/v2" + "slices" + "strings" + "time" + + "github.com/zitadel/logging" + + "github.com/zitadel/zitadel/internal/api/authz" + "github.com/zitadel/zitadel/internal/api/call" + "github.com/zitadel/zitadel/internal/command" + "github.com/zitadel/zitadel/internal/crypto" + "github.com/zitadel/zitadel/internal/database" + "github.com/zitadel/zitadel/internal/domain" + "github.com/zitadel/zitadel/internal/eventstore" + "github.com/zitadel/zitadel/internal/notification/channels" + "github.com/zitadel/zitadel/internal/notification/senders" + "github.com/zitadel/zitadel/internal/notification/types" + "github.com/zitadel/zitadel/internal/query" + "github.com/zitadel/zitadel/internal/repository/instance" + "github.com/zitadel/zitadel/internal/repository/notification" +) + +const ( + Domain = "Domain" + Code = "Code" + OTP = "OTP" +) + +type NotificationWorker struct { + commands Commands + queries *NotificationQueries + es *eventstore.Eventstore + client *database.DB + channels types.ChannelChains + config WorkerConfig + now nowFunc + backOff func(current time.Duration) time.Duration +} + +type WorkerConfig struct { + Workers uint8 + BulkLimit uint16 + RequeueEvery time.Duration + RetryWorkers uint8 + RetryRequeueEvery time.Duration + HandleActiveInstances time.Duration + TransactionDuration time.Duration + MaxAttempts uint8 + MaxTtl time.Duration + MinRetryDelay time.Duration + MaxRetryDelay time.Duration + RetryDelayFactor float32 +} + +// nowFunc makes [time.Now] mockable +type nowFunc func() time.Time + +type Sent func(ctx context.Context, commands Commands, id, orgID string, generatorInfo *senders.CodeGeneratorInfo, args map[string]any) error + +var sentHandlers map[eventstore.EventType]Sent + +func RegisterSentHandler(eventType eventstore.EventType, sent Sent) { + if sentHandlers == nil { + sentHandlers = make(map[eventstore.EventType]Sent) + } + sentHandlers[eventType] = sent +} + +func NewNotificationWorker( + config WorkerConfig, + commands Commands, + queries *NotificationQueries, + es *eventstore.Eventstore, + client *database.DB, + channels types.ChannelChains, +) *NotificationWorker { + // make sure the delay does not get less + if config.RetryDelayFactor < 1 { + config.RetryDelayFactor = 1 + } + w := &NotificationWorker{ + config: config, + commands: commands, + queries: queries, + es: es, + client: client, + channels: channels, + now: time.Now, + } + w.backOff = w.exponentialBackOff + return w +} + +func (w *NotificationWorker) Start(ctx context.Context) { + for i := 0; i < int(w.config.Workers); i++ { + go w.schedule(ctx, i, false) + } + for i := 0; i < int(w.config.RetryWorkers); i++ { + go w.schedule(ctx, i, true) + } +} + +func (w *NotificationWorker) reduceNotificationRequested(ctx context.Context, tx *sql.Tx, event *notification.RequestedEvent) (err error) { + ctx = ContextWithNotifier(ctx, event.Aggregate()) + + // if the notification is too old, we can directly cancel + if event.CreatedAt().Add(w.config.MaxTtl).Before(w.now()) { + return w.commands.NotificationCanceled(ctx, tx, event.Aggregate().ID, event.Aggregate().ResourceOwner, nil) + } + + // Get the notify user first, so if anything fails afterward we have the current state of the user + // and can pass that to the retry request. + notifyUser, err := w.queries.GetNotifyUserByID(ctx, true, event.UserID) + if err != nil { + return err + } + + // The domain claimed event requires the domain as argument, but lacks the user when creating the request event. + // Since we set it into the request arguments, it will be passed into a potential retry event. + if event.RequiresPreviousDomain && event.Request.Args != nil && event.Request.Args.Domain == "" { + index := strings.LastIndex(notifyUser.LastEmail, "@") + event.Request.Args.Domain = notifyUser.LastEmail[index+1:] + } + + err = w.sendNotification(ctx, tx, event.Request, notifyUser, event) + if err == nil { + return nil + } + // if retries are disabled or if the error explicitly specifies, we cancel the notification + if w.config.MaxAttempts <= 1 || errors.Is(err, &channels.CancelError{}) { + return w.commands.NotificationCanceled(ctx, tx, event.Aggregate().ID, event.Aggregate().ResourceOwner, err) + } + // otherwise we retry after a backoff delay + return w.commands.NotificationRetryRequested( + ctx, + tx, + event.Aggregate().ID, + event.Aggregate().ResourceOwner, + notificationEventToRequest(event.Request, notifyUser, w.backOff(0)), + err, + ) +} + +func (w *NotificationWorker) reduceNotificationRetry(ctx context.Context, tx *sql.Tx, event *notification.RetryRequestedEvent) (err error) { + ctx = ContextWithNotifier(ctx, event.Aggregate()) + + // if the notification is too old, we can directly cancel + if event.CreatedAt().Add(w.config.MaxTtl).Before(w.now()) { + return w.commands.NotificationCanceled(ctx, tx, event.Aggregate().ID, event.Aggregate().ResourceOwner, err) + } + + if event.CreatedAt().Add(event.BackOff).After(w.now()) { + return nil + } + err = w.sendNotification(ctx, tx, event.Request, event.NotifyUser, event) + if err == nil { + return nil + } + // if the max attempts are reached or if the error explicitly specifies, we cancel the notification + if event.Sequence() >= uint64(w.config.MaxAttempts) || errors.Is(err, &channels.CancelError{}) { + return w.commands.NotificationCanceled(ctx, tx, event.Aggregate().ID, event.Aggregate().ResourceOwner, err) + } + // otherwise we retry after a backoff delay + return w.commands.NotificationRetryRequested(ctx, tx, event.Aggregate().ID, event.Aggregate().ResourceOwner, notificationEventToRequest( + event.Request, + event.NotifyUser, + w.backOff(event.BackOff), + ), err) +} + +func (w *NotificationWorker) sendNotification(ctx context.Context, tx *sql.Tx, request notification.Request, notifyUser *query.NotifyUser, e eventstore.Event) error { + ctx, err := enrichCtx(ctx, request.TriggeredAtOrigin) + if err != nil { + err := w.commands.NotificationCanceled(ctx, tx, e.Aggregate().ID, e.Aggregate().ResourceOwner, err) + logging.WithFields("instanceID", authz.GetInstance(ctx).InstanceID(), "notification", e.Aggregate().ID). + OnError(err).Error("could not cancel notification") + return nil + } + + // check early that a "sent" handler exists, otherwise we can cancel early + sentHandler, ok := sentHandlers[request.EventType] + if !ok { + err := w.commands.NotificationCanceled(ctx, tx, e.Aggregate().ID, e.Aggregate().ResourceOwner, err) + logging.WithFields("instanceID", authz.GetInstance(ctx).InstanceID(), "notification", e.Aggregate().ID). + OnError(err).Errorf(`no "sent" handler registered for %s`, request.EventType) + return nil + } + + var code string + if request.Code != nil { + code, err = crypto.DecryptString(request.Code, w.queries.UserDataCrypto) + if err != nil { + return err + } + } + + colors, err := w.queries.ActiveLabelPolicyByOrg(ctx, request.UserResourceOwner, false) + if err != nil { + return err + } + + translator, err := w.queries.GetTranslatorWithOrgTexts(ctx, request.UserResourceOwner, request.MessageType) + if err != nil { + return err + } + + generatorInfo := new(senders.CodeGeneratorInfo) + var notify types.Notify + switch request.NotificationType { + case domain.NotificationTypeEmail: + template, err := w.queries.MailTemplateByOrg(ctx, notifyUser.ResourceOwner, false) + if err != nil { + return err + } + notify = types.SendEmail(ctx, w.channels, string(template.Template), translator, notifyUser, colors, e) + case domain.NotificationTypeSms: + notify = types.SendSMS(ctx, w.channels, translator, notifyUser, colors, e, generatorInfo) + } + + args := request.Args.ToMap() + args[Code] = code + // existing notifications use `OTP` as argument for the code + if request.IsOTP { + args[OTP] = code + } + + if err := notify(request.URLTemplate, args, request.MessageType, request.UnverifiedNotificationChannel); err != nil { + return err + } + err = w.commands.NotificationSent(ctx, tx, e.Aggregate().ID, e.Aggregate().ResourceOwner) + if err != nil { + // In case the notification event cannot be pushed, we most likely cannot create a retry or cancel event. + // Therefore, we'll only log the error and also do not need to try to push to the user / session. + logging.WithFields("instanceID", authz.GetInstance(ctx).InstanceID(), "notification", e.Aggregate().ID). + OnError(err).Error("could not set sent notification event") + return nil + } + err = sentHandler(ctx, w.commands, request.NotificationAggregateID(), request.NotificationAggregateResourceOwner(), generatorInfo, args) + logging.WithFields("instanceID", authz.GetInstance(ctx).InstanceID(), "notification", e.Aggregate().ID). + OnError(err).Error("could not set notification event on aggregate") + return nil +} + +func (w *NotificationWorker) exponentialBackOff(current time.Duration) time.Duration { + if current >= w.config.MaxRetryDelay { + return w.config.MaxRetryDelay + } + if current < w.config.MinRetryDelay { + current = w.config.MinRetryDelay + } + t := time.Duration(rand.Int64N(int64(w.config.RetryDelayFactor*float32(current.Nanoseconds()))-current.Nanoseconds()) + current.Nanoseconds()) + if t > w.config.MaxRetryDelay { + return w.config.MaxRetryDelay + } + return t +} + +func notificationEventToRequest(e notification.Request, notifyUser *query.NotifyUser, backoff time.Duration) *command.NotificationRetryRequest { + return &command.NotificationRetryRequest{ + NotificationRequest: command.NotificationRequest{ + UserID: e.UserID, + UserResourceOwner: e.UserResourceOwner, + TriggerOrigin: e.TriggeredAtOrigin, + URLTemplate: e.URLTemplate, + Code: e.Code, + CodeExpiry: e.CodeExpiry, + EventType: e.EventType, + NotificationType: e.NotificationType, + MessageType: e.MessageType, + UnverifiedNotificationChannel: e.UnverifiedNotificationChannel, + Args: e.Args, + AggregateID: e.AggregateID, + AggregateResourceOwner: e.AggregateResourceOwner, + IsOTP: e.IsOTP, + }, + BackOff: backoff, + NotifyUser: notifyUser, + } +} + +func (w *NotificationWorker) schedule(ctx context.Context, workerID int, retry bool) { + t := time.NewTimer(0) + + for { + select { + case <-ctx.Done(): + t.Stop() + w.log(workerID, retry).Info("scheduler stopped") + return + case <-t.C: + instances, err := w.queryInstances(ctx, retry) + w.log(workerID, retry).OnError(err).Error("unable to query instances") + + w.triggerInstances(call.WithTimestamp(ctx), instances, workerID, retry) + if retry { + t.Reset(w.config.RetryRequeueEvery) + continue + } + t.Reset(w.config.RequeueEvery) + } + } +} + +func (w *NotificationWorker) log(workerID int, retry bool) *logging.Entry { + return logging.WithFields("notification worker", workerID, "retries", retry) +} + +func (w *NotificationWorker) queryInstances(ctx context.Context, retry bool) ([]string, error) { + if w.config.HandleActiveInstances == 0 { + return w.existingInstances(ctx) + } + + query := eventstore.NewSearchQueryBuilder(eventstore.ColumnsInstanceIDs). + AwaitOpenTransactions(). + AllowTimeTravel(). + CreationDateAfter(w.now().Add(-1 * w.config.HandleActiveInstances)) + + maxAge := w.config.RequeueEvery + if retry { + maxAge = w.config.RetryRequeueEvery + } + return w.es.InstanceIDs(ctx, maxAge, false, query) +} + +func (w *NotificationWorker) existingInstances(ctx context.Context) ([]string, error) { + ai := existingInstances{} + if err := w.es.FilterToQueryReducer(ctx, &ai); err != nil { + return nil, err + } + + return ai, nil +} + +func (w *NotificationWorker) triggerInstances(ctx context.Context, instances []string, workerID int, retry bool) { + for _, instance := range instances { + instanceCtx := authz.WithInstanceID(ctx, instance) + + err := w.trigger(instanceCtx, workerID, retry) + w.log(workerID, retry).WithField("instance", instance).OnError(err).Info("trigger failed") + } +} + +func (w *NotificationWorker) trigger(ctx context.Context, workerID int, retry bool) (err error) { + txCtx := ctx + if w.config.TransactionDuration > 0 { + var cancel, cancelTx func() + txCtx, cancelTx = context.WithCancel(ctx) + defer cancelTx() + ctx, cancel = context.WithTimeout(ctx, w.config.TransactionDuration) + defer cancel() + } + tx, err := w.client.BeginTx(txCtx, nil) + if err != nil { + return err + } + defer func() { + err = database.CloseTransaction(tx, err) + }() + + events, err := w.searchEvents(ctx, tx, retry) + if err != nil { + return err + } + + // If there aren't any events or no unlocked event terminate early and start a new run. + if len(events) == 0 { + return nil + } + + w.log(workerID, retry). + WithField("instanceID", authz.GetInstance(ctx).InstanceID()). + WithField("events", len(events)). + Info("handling notification events") + + for _, event := range events { + var err error + switch e := event.(type) { + case *notification.RequestedEvent: + w.createSavepoint(ctx, tx, event, workerID, retry) + err = w.reduceNotificationRequested(ctx, tx, e) + case *notification.RetryRequestedEvent: + w.createSavepoint(ctx, tx, event, workerID, retry) + err = w.reduceNotificationRetry(ctx, tx, e) + } + if err != nil { + w.log(workerID, retry).OnError(err). + WithField("instanceID", authz.GetInstance(ctx).InstanceID()). + WithField("notificationID", event.Aggregate().ID). + WithField("sequence", event.Sequence()). + WithField("type", event.Type()). + Error("could not push notification event") + w.rollbackToSavepoint(ctx, tx, event, workerID, retry) + } + } + return nil +} + +func (w *NotificationWorker) latestRetries(events []eventstore.Event) { + for i := len(events) - 1; i > 0; i-- { + // since we delete during the iteration, we need to make sure we don't panic + if len(events) <= i { + continue + } + // delete all the previous retries of the same notification + events = slices.DeleteFunc(events, func(e eventstore.Event) bool { + return e.Aggregate().ID == events[i].Aggregate().ID && + e.Sequence() < events[i].Sequence() + }) + } +} + +func (w *NotificationWorker) createSavepoint(ctx context.Context, tx *sql.Tx, event eventstore.Event, workerID int, retry bool) { + _, err := tx.ExecContext(ctx, "SAVEPOINT notification_send") + w.log(workerID, retry).OnError(err). + WithField("instanceID", authz.GetInstance(ctx).InstanceID()). + WithField("notificationID", event.Aggregate().ID). + WithField("sequence", event.Sequence()). + WithField("type", event.Type()). + Error("could not create savepoint for notification event") +} + +func (w *NotificationWorker) rollbackToSavepoint(ctx context.Context, tx *sql.Tx, event eventstore.Event, workerID int, retry bool) { + _, err := tx.ExecContext(ctx, "ROLLBACK TO SAVEPOINT notification_send") + w.log(workerID, retry).OnError(err). + WithField("instanceID", authz.GetInstance(ctx).InstanceID()). + WithField("notificationID", event.Aggregate().ID). + WithField("sequence", event.Sequence()). + WithField("type", event.Type()). + Error("could not rollback to savepoint for notification event") +} + +func (w *NotificationWorker) searchEvents(ctx context.Context, tx *sql.Tx, retry bool) ([]eventstore.Event, error) { + if retry { + return w.searchRetryEvents(ctx, tx) + } + // query events and lock them for update (with skip locked) + searchQuery := eventstore.NewSearchQueryBuilder(eventstore.ColumnsEvent). + LockRowsDuringTx(tx, eventstore.LockOptionSkipLocked). + // Messages older than the MaxTTL, we can be ignored. + // The first attempt of a retry might still be older than the TTL and needs to be filtered out later on. + CreationDateAfter(w.now().Add(-1*w.config.MaxTtl)). + Limit(uint64(w.config.BulkLimit)). + AddQuery(). + AggregateTypes(notification.AggregateType). + EventTypes(notification.RequestedType). + Builder(). + ExcludeAggregateIDs(). + EventTypes(notification.RetryRequestedType, notification.CanceledType, notification.SentType). + Builder() + //nolint:staticcheck + return w.es.Filter(ctx, searchQuery) +} + +func (w *NotificationWorker) searchRetryEvents(ctx context.Context, tx *sql.Tx) ([]eventstore.Event, error) { + // query events and lock them for update (with skip locked) + searchQuery := eventstore.NewSearchQueryBuilder(eventstore.ColumnsEvent). + LockRowsDuringTx(tx, eventstore.LockOptionSkipLocked). + // Messages older than the MaxTTL, we can be ignored. + // The first attempt of a retry might still be older than the TTL and needs to be filtered out later on. + CreationDateAfter(w.now().Add(-1*w.config.MaxTtl)). + AddQuery(). + AggregateTypes(notification.AggregateType). + EventTypes(notification.RetryRequestedType). + Builder(). + ExcludeAggregateIDs(). + EventTypes(notification.CanceledType, notification.SentType). + Builder() + //nolint:staticcheck + events, err := w.es.Filter(ctx, searchQuery) + if err != nil { + return nil, err + } + w.latestRetries(events) + return events, nil +} + +type existingInstances []string + +// AppendEvents implements eventstore.QueryReducer. +func (ai *existingInstances) AppendEvents(events ...eventstore.Event) { + for _, event := range events { + switch event.Type() { + case instance.InstanceAddedEventType: + *ai = append(*ai, event.Aggregate().InstanceID) + case instance.InstanceRemovedEventType: + *ai = slices.DeleteFunc(*ai, func(s string) bool { + return s == event.Aggregate().InstanceID + }) + } + } +} + +// Query implements eventstore.QueryReducer. +func (*existingInstances) Query() *eventstore.SearchQueryBuilder { + return eventstore.NewSearchQueryBuilder(eventstore.ColumnsEvent). + AddQuery(). + AggregateTypes(instance.AggregateType). + EventTypes( + instance.InstanceAddedEventType, + instance.InstanceRemovedEventType, + ). + Builder() +} + +// Reduce implements eventstore.QueryReducer. +// reduce is not used as events are reduced during AppendEvents +func (*existingInstances) Reduce() error { + return nil +} diff --git a/internal/notification/handlers/notification_worker_test.go b/internal/notification/handlers/notification_worker_test.go new file mode 100644 index 0000000000..03de5201fc --- /dev/null +++ b/internal/notification/handlers/notification_worker_test.go @@ -0,0 +1,963 @@ +package handlers + +import ( + "context" + "database/sql" + "errors" + "fmt" + "testing" + "time" + + "github.com/muhlemmer/gu" + "github.com/stretchr/testify/assert" + "go.uber.org/mock/gomock" + + "github.com/zitadel/zitadel/internal/api/authz" + "github.com/zitadel/zitadel/internal/api/ui/login" + "github.com/zitadel/zitadel/internal/command" + "github.com/zitadel/zitadel/internal/domain" + "github.com/zitadel/zitadel/internal/eventstore" + "github.com/zitadel/zitadel/internal/eventstore/repository" + es_repo_mock "github.com/zitadel/zitadel/internal/eventstore/repository/mock" + "github.com/zitadel/zitadel/internal/notification/channels/email" + channel_mock "github.com/zitadel/zitadel/internal/notification/channels/mock" + "github.com/zitadel/zitadel/internal/notification/channels/sms" + "github.com/zitadel/zitadel/internal/notification/channels/smtp" + "github.com/zitadel/zitadel/internal/notification/channels/twilio" + "github.com/zitadel/zitadel/internal/notification/handlers/mock" + "github.com/zitadel/zitadel/internal/notification/messages" + "github.com/zitadel/zitadel/internal/notification/senders" + "github.com/zitadel/zitadel/internal/query" + "github.com/zitadel/zitadel/internal/repository/notification" + "github.com/zitadel/zitadel/internal/repository/session" + "github.com/zitadel/zitadel/internal/repository/user" +) + +const ( + notificationID = "notificationID" +) + +func Test_userNotifier_reduceNotificationRequested(t *testing.T) { + testNow := time.Now + testBackOff := func(current time.Duration) time.Duration { + return time.Second + } + sendError := errors.New("send error") + tests := []struct { + name string + test func(*gomock.Controller, *mock.MockQueries, *mock.MockCommands) (fieldsWorker, argsWorker, wantWorker) + }{ + { + name: "too old", + test: func(ctrl *gomock.Controller, queries *mock.MockQueries, commands *mock.MockCommands) (f fieldsWorker, a argsWorker, w wantWorker) { + codeAlg, code := cryptoValue(t, ctrl, "testcode") + commands.EXPECT().NotificationCanceled(gomock.Any(), gomock.Any(), notificationID, instanceID, nil).Return(nil) + return fieldsWorker{ + queries: queries, + commands: commands, + es: eventstore.NewEventstore(&eventstore.Config{ + Querier: es_repo_mock.NewRepo(t).MockQuerier, + }), + userDataCrypto: codeAlg, + now: testNow, + }, + argsWorker{ + event: ¬ification.RequestedEvent{ + BaseEvent: *eventstore.BaseEventFromRepo(&repository.Event{ + InstanceID: instanceID, + AggregateID: notificationID, + ResourceOwner: sql.NullString{String: instanceID}, + CreationDate: time.Now().Add(-1 * time.Hour), + Typ: notification.RequestedType, + }), + Request: notification.Request{ + UserID: userID, + UserResourceOwner: orgID, + AggregateID: "", + AggregateResourceOwner: "", + TriggeredAtOrigin: eventOrigin, + EventType: user.HumanInviteCodeAddedType, + MessageType: domain.InviteUserMessageType, + NotificationType: domain.NotificationTypeEmail, + URLTemplate: fmt.Sprintf("%s/ui/login/user/invite?userID=%s&loginname={{.LoginName}}&code={{.Code}}&orgID=%s&authRequestID=%s", eventOrigin, userID, orgID, authRequestID), + CodeExpiry: 1 * time.Hour, + Code: code, + UnverifiedNotificationChannel: true, + IsOTP: false, + RequiresPreviousDomain: false, + Args: &domain.NotificationArguments{ + ApplicationName: "APP", + }, + }, + }, + }, w + }, + }, + { + name: "send ok (email)", + test: func(ctrl *gomock.Controller, queries *mock.MockQueries, commands *mock.MockCommands) (f fieldsWorker, a argsWorker, w wantWorker) { + givenTemplate := "{{.LogoURL}}" + expectContent := fmt.Sprintf("%s%s/%s/%s", eventOrigin, assetsPath, policyID, logoURL) + w.message = &messages.Email{ + Recipients: []string{lastEmail}, + Subject: "Invitation to APP", + Content: expectContent, + } + codeAlg, code := cryptoValue(t, ctrl, "testcode") + expectTemplateWithNotifyUserQueries(queries, givenTemplate) + commands.EXPECT().NotificationSent(gomock.Any(), gomock.Any(), notificationID, instanceID).Return(nil) + commands.EXPECT().InviteCodeSent(gomock.Any(), orgID, userID).Return(nil) + return fieldsWorker{ + queries: queries, + commands: commands, + es: eventstore.NewEventstore(&eventstore.Config{ + Querier: es_repo_mock.NewRepo(t).MockQuerier, + }), + userDataCrypto: codeAlg, + now: testNow, + }, + argsWorker{ + event: ¬ification.RequestedEvent{ + BaseEvent: *eventstore.BaseEventFromRepo(&repository.Event{ + InstanceID: instanceID, + AggregateID: notificationID, + ResourceOwner: sql.NullString{String: instanceID}, + CreationDate: time.Now().UTC(), + Typ: notification.RequestedType, + }), + Request: notification.Request{ + UserID: userID, + UserResourceOwner: orgID, + AggregateID: "", + AggregateResourceOwner: "", + TriggeredAtOrigin: eventOrigin, + EventType: user.HumanInviteCodeAddedType, + MessageType: domain.InviteUserMessageType, + NotificationType: domain.NotificationTypeEmail, + URLTemplate: fmt.Sprintf("%s/ui/login/user/invite?userID=%s&loginname={{.LoginName}}&code={{.Code}}&orgID=%s&authRequestID=%s", eventOrigin, userID, orgID, authRequestID), + CodeExpiry: 1 * time.Hour, + Code: code, + UnverifiedNotificationChannel: true, + IsOTP: false, + RequiresPreviousDomain: false, + Args: &domain.NotificationArguments{ + ApplicationName: "APP", + }, + }, + }, + }, w + }, + }, + { + name: "send ok (sms with external provider)", + test: func(ctrl *gomock.Controller, queries *mock.MockQueries, commands *mock.MockCommands) (f fieldsWorker, a argsWorker, w wantWorker) { + expiry := 0 * time.Hour + testCode := "" + expectContent := fmt.Sprintf(`%[1]s is your one-time password for %[2]s. Use it within the next %[3]s. +@%[2]s #%[1]s`, testCode, eventOriginDomain, expiry) + w.messageSMS = &messages.SMS{ + SenderPhoneNumber: "senderNumber", + RecipientPhoneNumber: verifiedPhone, + Content: expectContent, + } + codeAlg, code := cryptoValue(t, ctrl, testCode) + expectTemplateWithNotifyUserQueriesSMS(queries) + commands.EXPECT().NotificationSent(gomock.Any(), gomock.Any(), notificationID, instanceID).Return(nil) + commands.EXPECT().OTPSMSSent(gomock.Any(), sessionID, instanceID, &senders.CodeGeneratorInfo{ + ID: smsProviderID, + VerificationID: verificationID, + }).Return(nil) + return fieldsWorker{ + queries: queries, + commands: commands, + es: eventstore.NewEventstore(&eventstore.Config{ + Querier: es_repo_mock.NewRepo(t).MockQuerier, + }), + userDataCrypto: codeAlg, + now: testNow, + }, + argsWorker{ + event: ¬ification.RequestedEvent{ + BaseEvent: *eventstore.BaseEventFromRepo(&repository.Event{ + InstanceID: instanceID, + AggregateID: notificationID, + ResourceOwner: sql.NullString{String: instanceID}, + CreationDate: time.Now().UTC(), + Typ: notification.RequestedType, + }), + Request: notification.Request{ + UserID: userID, + UserResourceOwner: orgID, + AggregateID: sessionID, + AggregateResourceOwner: instanceID, + TriggeredAtOrigin: eventOrigin, + EventType: session.OTPSMSChallengedType, + MessageType: domain.VerifySMSOTPMessageType, + NotificationType: domain.NotificationTypeSms, + URLTemplate: "", + CodeExpiry: expiry, + Code: code, + UnverifiedNotificationChannel: false, + IsOTP: true, + RequiresPreviousDomain: false, + Args: &domain.NotificationArguments{ + Origin: eventOrigin, + Domain: eventOriginDomain, + Expiry: expiry, + }, + }, + }, + }, w + }, + }, + { + name: "previous domain", + test: func(ctrl *gomock.Controller, queries *mock.MockQueries, commands *mock.MockCommands) (f fieldsWorker, a argsWorker, w wantWorker) { + givenTemplate := "{{.LogoURL}}" + expectContent := fmt.Sprintf("%s%s/%s/%s", eventOrigin, assetsPath, policyID, logoURL) + w.message = &messages.Email{ + Recipients: []string{verifiedEmail}, + Subject: "Domain has been claimed", + Content: expectContent, + } + expectTemplateWithNotifyUserQueries(queries, givenTemplate) + commands.EXPECT().NotificationSent(gomock.Any(), gomock.Any(), notificationID, instanceID).Return(nil) + commands.EXPECT().UserDomainClaimedSent(gomock.Any(), orgID, userID).Return(nil) + return fieldsWorker{ + queries: queries, + commands: commands, + es: eventstore.NewEventstore(&eventstore.Config{ + Querier: es_repo_mock.NewRepo(t).MockQuerier, + }), + userDataCrypto: nil, + now: testNow, + }, + argsWorker{ + event: ¬ification.RequestedEvent{ + BaseEvent: *eventstore.BaseEventFromRepo(&repository.Event{ + InstanceID: instanceID, + AggregateID: notificationID, + ResourceOwner: sql.NullString{String: instanceID}, + CreationDate: time.Now().UTC(), + Typ: notification.RequestedType, + }), + Request: notification.Request{ + UserID: userID, + UserResourceOwner: orgID, + AggregateID: "", + AggregateResourceOwner: "", + TriggeredAtOrigin: eventOrigin, + EventType: user.UserDomainClaimedType, + MessageType: domain.DomainClaimedMessageType, + NotificationType: domain.NotificationTypeEmail, + URLTemplate: login.LoginLink(eventOrigin, orgID), + CodeExpiry: 0, + Code: nil, + UnverifiedNotificationChannel: false, + IsOTP: false, + RequiresPreviousDomain: true, + Args: &domain.NotificationArguments{ + TempUsername: "tempUsername", + }, + }, + }, + }, w + }, + }, + { + name: "send failed, retry", + test: func(ctrl *gomock.Controller, queries *mock.MockQueries, commands *mock.MockCommands) (f fieldsWorker, a argsWorker, w wantWorker) { + givenTemplate := "{{.LogoURL}}" + expectContent := fmt.Sprintf("%s%s/%s/%s", eventOrigin, assetsPath, policyID, logoURL) + w.message = &messages.Email{ + Recipients: []string{lastEmail}, + Subject: "Invitation to APP", + Content: expectContent, + } + w.sendError = sendError + codeAlg, code := cryptoValue(t, ctrl, "testcode") + expectTemplateWithNotifyUserQueries(queries, givenTemplate) + commands.EXPECT().NotificationRetryRequested(gomock.Any(), gomock.Any(), notificationID, instanceID, + &command.NotificationRetryRequest{ + NotificationRequest: command.NotificationRequest{ + UserID: userID, + UserResourceOwner: orgID, + AggregateID: "", + AggregateResourceOwner: "", + TriggerOrigin: eventOrigin, + EventType: user.HumanInviteCodeAddedType, + MessageType: domain.InviteUserMessageType, + NotificationType: domain.NotificationTypeEmail, + URLTemplate: fmt.Sprintf("%s/ui/login/user/invite?userID=%s&loginname={{.LoginName}}&code={{.Code}}&orgID=%s&authRequestID=%s", eventOrigin, userID, orgID, authRequestID), + CodeExpiry: 1 * time.Hour, + Code: code, + UnverifiedNotificationChannel: true, + IsOTP: false, + RequiresPreviousDomain: false, + Args: &domain.NotificationArguments{ + ApplicationName: "APP", + }, + }, + BackOff: 1 * time.Second, + NotifyUser: &query.NotifyUser{ + ID: userID, + ResourceOwner: orgID, + LastEmail: lastEmail, + VerifiedEmail: verifiedEmail, + PreferredLoginName: preferredLoginName, + LastPhone: lastPhone, + VerifiedPhone: verifiedPhone, + }, + }, + sendError, + ).Return(nil) + return fieldsWorker{ + queries: queries, + commands: commands, + es: eventstore.NewEventstore(&eventstore.Config{ + Querier: es_repo_mock.NewRepo(t).MockQuerier, + }), + userDataCrypto: codeAlg, + now: testNow, + backOff: testBackOff, + maxAttempts: 2, + }, + argsWorker{ + event: ¬ification.RequestedEvent{ + BaseEvent: *eventstore.BaseEventFromRepo(&repository.Event{ + InstanceID: instanceID, + AggregateID: notificationID, + ResourceOwner: sql.NullString{String: instanceID}, + CreationDate: time.Now().UTC(), + Typ: notification.RequestedType, + }), + Request: notification.Request{ + UserID: userID, + UserResourceOwner: orgID, + AggregateID: "", + AggregateResourceOwner: "", + TriggeredAtOrigin: eventOrigin, + EventType: user.HumanInviteCodeAddedType, + MessageType: domain.InviteUserMessageType, + NotificationType: domain.NotificationTypeEmail, + URLTemplate: fmt.Sprintf("%s/ui/login/user/invite?userID=%s&loginname={{.LoginName}}&code={{.Code}}&orgID=%s&authRequestID=%s", eventOrigin, userID, orgID, authRequestID), + CodeExpiry: 1 * time.Hour, + Code: code, + UnverifiedNotificationChannel: true, + IsOTP: false, + RequiresPreviousDomain: false, + Args: &domain.NotificationArguments{ + ApplicationName: "APP", + }, + }, + }, + }, w + }, + }, + { + name: "send failed (max attempts), cancel", + test: func(ctrl *gomock.Controller, queries *mock.MockQueries, commands *mock.MockCommands) (f fieldsWorker, a argsWorker, w wantWorker) { + givenTemplate := "{{.LogoURL}}" + expectContent := fmt.Sprintf("%s%s/%s/%s", eventOrigin, assetsPath, policyID, logoURL) + w.message = &messages.Email{ + Recipients: []string{lastEmail}, + Subject: "Invitation to APP", + Content: expectContent, + } + w.sendError = sendError + codeAlg, code := cryptoValue(t, ctrl, "testcode") + expectTemplateWithNotifyUserQueries(queries, givenTemplate) + commands.EXPECT().NotificationCanceled(gomock.Any(), gomock.Any(), notificationID, instanceID, sendError).Return(nil) + return fieldsWorker{ + queries: queries, + commands: commands, + es: eventstore.NewEventstore(&eventstore.Config{ + Querier: es_repo_mock.NewRepo(t).MockQuerier, + }), + userDataCrypto: codeAlg, + now: testNow, + backOff: testBackOff, + maxAttempts: 1, + }, + argsWorker{ + event: ¬ification.RequestedEvent{ + BaseEvent: *eventstore.BaseEventFromRepo(&repository.Event{ + InstanceID: instanceID, + AggregateID: notificationID, + ResourceOwner: sql.NullString{String: instanceID}, + CreationDate: time.Now().UTC(), + Seq: 1, + Typ: notification.RequestedType, + }), + Request: notification.Request{ + UserID: userID, + UserResourceOwner: orgID, + AggregateID: "", + AggregateResourceOwner: "", + TriggeredAtOrigin: eventOrigin, + EventType: user.HumanInviteCodeAddedType, + MessageType: domain.InviteUserMessageType, + NotificationType: domain.NotificationTypeEmail, + URLTemplate: fmt.Sprintf("%s/ui/login/user/invite?userID=%s&loginname={{.LoginName}}&code={{.Code}}&orgID=%s&authRequestID=%s", eventOrigin, userID, orgID, authRequestID), + CodeExpiry: 1 * time.Hour, + Code: code, + UnverifiedNotificationChannel: true, + IsOTP: false, + RequiresPreviousDomain: false, + Args: &domain.NotificationArguments{ + ApplicationName: "APP", + }, + }, + }, + }, w + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ctrl := gomock.NewController(t) + queries := mock.NewMockQueries(ctrl) + commands := mock.NewMockCommands(ctrl) + f, a, w := tt.test(ctrl, queries, commands) + err := newNotificationWorker(t, ctrl, queries, f, a, w).reduceNotificationRequested( + authz.WithInstanceID(context.Background(), instanceID), + &sql.Tx{}, + a.event.(*notification.RequestedEvent)) + if w.err != nil { + w.err(t, err) + } else { + assert.NoError(t, err) + } + }) + } +} + +func Test_userNotifier_reduceNotificationRetry(t *testing.T) { + testNow := time.Now + testBackOff := func(current time.Duration) time.Duration { + return time.Second + } + sendError := errors.New("send error") + tests := []struct { + name string + test func(*gomock.Controller, *mock.MockQueries, *mock.MockCommands) (fieldsWorker, argsWorker, wantWorker) + }{ + { + name: "too old", + test: func(ctrl *gomock.Controller, queries *mock.MockQueries, commands *mock.MockCommands) (f fieldsWorker, a argsWorker, w wantWorker) { + codeAlg, code := cryptoValue(t, ctrl, "testcode") + commands.EXPECT().NotificationCanceled(gomock.Any(), gomock.Any(), notificationID, instanceID, nil).Return(nil) + return fieldsWorker{ + queries: queries, + commands: commands, + es: eventstore.NewEventstore(&eventstore.Config{ + Querier: es_repo_mock.NewRepo(t).MockQuerier, + }), + userDataCrypto: codeAlg, + now: testNow, + }, + argsWorker{ + event: ¬ification.RetryRequestedEvent{ + BaseEvent: *eventstore.BaseEventFromRepo(&repository.Event{ + InstanceID: instanceID, + AggregateID: notificationID, + ResourceOwner: sql.NullString{String: instanceID}, + CreationDate: time.Now().Add(-1 * time.Hour), + Typ: notification.RequestedType, + }), + Request: notification.Request{ + UserID: userID, + UserResourceOwner: orgID, + AggregateID: "", + AggregateResourceOwner: "", + TriggeredAtOrigin: eventOrigin, + EventType: user.HumanInviteCodeAddedType, + MessageType: domain.InviteUserMessageType, + NotificationType: domain.NotificationTypeEmail, + URLTemplate: fmt.Sprintf("%s/ui/login/user/invite?userID=%s&loginname={{.LoginName}}&code={{.Code}}&orgID=%s&authRequestID=%s", eventOrigin, userID, orgID, authRequestID), + CodeExpiry: 1 * time.Hour, + Code: code, + UnverifiedNotificationChannel: true, + IsOTP: false, + RequiresPreviousDomain: false, + Args: &domain.NotificationArguments{ + ApplicationName: "APP", + }, + }, + BackOff: 1 * time.Second, + NotifyUser: &query.NotifyUser{ + ID: userID, + ResourceOwner: orgID, + LastEmail: lastEmail, + VerifiedEmail: verifiedEmail, + PreferredLoginName: preferredLoginName, + LastPhone: lastPhone, + VerifiedPhone: verifiedPhone, + }, + }, + }, w + }, + }, + { + name: "backoff not done", + test: func(ctrl *gomock.Controller, queries *mock.MockQueries, commands *mock.MockCommands) (f fieldsWorker, a argsWorker, w wantWorker) { + codeAlg, code := cryptoValue(t, ctrl, "testcode") + return fieldsWorker{ + queries: queries, + commands: commands, + es: eventstore.NewEventstore(&eventstore.Config{ + Querier: es_repo_mock.NewRepo(t).MockQuerier, + }), + userDataCrypto: codeAlg, + now: testNow, + }, + argsWorker{ + event: ¬ification.RetryRequestedEvent{ + BaseEvent: *eventstore.BaseEventFromRepo(&repository.Event{ + InstanceID: instanceID, + AggregateID: notificationID, + ResourceOwner: sql.NullString{String: instanceID}, + CreationDate: time.Now(), + Typ: notification.RequestedType, + Seq: 2, + }), + Request: notification.Request{ + UserID: userID, + UserResourceOwner: orgID, + AggregateID: "", + AggregateResourceOwner: "", + TriggeredAtOrigin: eventOrigin, + EventType: user.HumanInviteCodeAddedType, + MessageType: domain.InviteUserMessageType, + NotificationType: domain.NotificationTypeEmail, + URLTemplate: fmt.Sprintf("%s/ui/login/user/invite?userID=%s&loginname={{.LoginName}}&code={{.Code}}&orgID=%s&authRequestID=%s", eventOrigin, userID, orgID, authRequestID), + CodeExpiry: 1 * time.Hour, + Code: code, + UnverifiedNotificationChannel: true, + IsOTP: false, + RequiresPreviousDomain: false, + Args: &domain.NotificationArguments{ + ApplicationName: "APP", + }, + }, + BackOff: 10 * time.Second, + NotifyUser: &query.NotifyUser{ + ID: userID, + ResourceOwner: orgID, + LastEmail: lastEmail, + VerifiedEmail: verifiedEmail, + PreferredLoginName: preferredLoginName, + LastPhone: lastPhone, + VerifiedPhone: verifiedPhone, + }, + }, + }, w + }, + }, + { + name: "send ok", + test: func(ctrl *gomock.Controller, queries *mock.MockQueries, commands *mock.MockCommands) (f fieldsWorker, a argsWorker, w wantWorker) { + givenTemplate := "{{.LogoURL}}" + expectContent := fmt.Sprintf("%s%s/%s/%s", eventOrigin, assetsPath, policyID, logoURL) + w.message = &messages.Email{ + Recipients: []string{lastEmail}, + Subject: "Invitation to APP", + Content: expectContent, + } + codeAlg, code := cryptoValue(t, ctrl, "testcode") + expectTemplateQueries(queries, givenTemplate) + commands.EXPECT().NotificationSent(gomock.Any(), gomock.Any(), notificationID, instanceID).Return(nil) + commands.EXPECT().InviteCodeSent(gomock.Any(), orgID, userID).Return(nil) + return fieldsWorker{ + queries: queries, + commands: commands, + es: eventstore.NewEventstore(&eventstore.Config{ + Querier: es_repo_mock.NewRepo(t).MockQuerier, + }), + userDataCrypto: codeAlg, + now: testNow, + maxAttempts: 3, + }, + argsWorker{ + event: ¬ification.RetryRequestedEvent{ + BaseEvent: *eventstore.BaseEventFromRepo(&repository.Event{ + InstanceID: instanceID, + AggregateID: notificationID, + ResourceOwner: sql.NullString{String: instanceID}, + CreationDate: time.Now().Add(-2 * time.Second), + Typ: notification.RequestedType, + Seq: 2, + }), + Request: notification.Request{ + UserID: userID, + UserResourceOwner: orgID, + AggregateID: "", + AggregateResourceOwner: "", + TriggeredAtOrigin: eventOrigin, + EventType: user.HumanInviteCodeAddedType, + MessageType: domain.InviteUserMessageType, + NotificationType: domain.NotificationTypeEmail, + URLTemplate: fmt.Sprintf("%s/ui/login/user/invite?userID=%s&loginname={{.LoginName}}&code={{.Code}}&orgID=%s&authRequestID=%s", eventOrigin, userID, orgID, authRequestID), + CodeExpiry: 1 * time.Hour, + Code: code, + UnverifiedNotificationChannel: true, + IsOTP: false, + RequiresPreviousDomain: false, + Args: &domain.NotificationArguments{ + ApplicationName: "APP", + }, + }, + BackOff: 1 * time.Second, + NotifyUser: &query.NotifyUser{ + ID: userID, + ResourceOwner: orgID, + LastEmail: lastEmail, + VerifiedEmail: verifiedEmail, + PreferredLoginName: preferredLoginName, + LastPhone: lastPhone, + VerifiedPhone: verifiedPhone, + }, + }, + }, w + }, + }, + { + name: "send failed, retry", + test: func(ctrl *gomock.Controller, queries *mock.MockQueries, commands *mock.MockCommands) (f fieldsWorker, a argsWorker, w wantWorker) { + givenTemplate := "{{.LogoURL}}" + expectContent := fmt.Sprintf("%s%s/%s/%s", eventOrigin, assetsPath, policyID, logoURL) + w.message = &messages.Email{ + Recipients: []string{lastEmail}, + Subject: "Invitation to APP", + Content: expectContent, + } + w.sendError = sendError + codeAlg, code := cryptoValue(t, ctrl, "testcode") + expectTemplateQueries(queries, givenTemplate) + commands.EXPECT().NotificationRetryRequested(gomock.Any(), gomock.Any(), notificationID, instanceID, + &command.NotificationRetryRequest{ + NotificationRequest: command.NotificationRequest{ + UserID: userID, + UserResourceOwner: orgID, + AggregateID: "", + AggregateResourceOwner: "", + TriggerOrigin: eventOrigin, + EventType: user.HumanInviteCodeAddedType, + MessageType: domain.InviteUserMessageType, + NotificationType: domain.NotificationTypeEmail, + URLTemplate: fmt.Sprintf("%s/ui/login/user/invite?userID=%s&loginname={{.LoginName}}&code={{.Code}}&orgID=%s&authRequestID=%s", eventOrigin, userID, orgID, authRequestID), + CodeExpiry: 1 * time.Hour, + Code: code, + UnverifiedNotificationChannel: true, + IsOTP: false, + RequiresPreviousDomain: false, + Args: &domain.NotificationArguments{ + ApplicationName: "APP", + }, + }, + BackOff: 1 * time.Second, + NotifyUser: &query.NotifyUser{ + ID: userID, + ResourceOwner: orgID, + LastEmail: lastEmail, + VerifiedEmail: verifiedEmail, + PreferredLoginName: preferredLoginName, + LastPhone: lastPhone, + VerifiedPhone: verifiedPhone, + }, + }, + sendError, + ).Return(nil) + return fieldsWorker{ + queries: queries, + commands: commands, + es: eventstore.NewEventstore(&eventstore.Config{ + Querier: es_repo_mock.NewRepo(t).MockQuerier, + }), + userDataCrypto: codeAlg, + now: testNow, + backOff: testBackOff, + maxAttempts: 3, + }, + argsWorker{ + event: ¬ification.RetryRequestedEvent{ + BaseEvent: *eventstore.BaseEventFromRepo(&repository.Event{ + InstanceID: instanceID, + AggregateID: notificationID, + ResourceOwner: sql.NullString{String: instanceID}, + CreationDate: time.Now().Add(-2 * time.Second), + Typ: notification.RequestedType, + Seq: 2, + }), + Request: notification.Request{ + UserID: userID, + UserResourceOwner: orgID, + AggregateID: "", + AggregateResourceOwner: "", + TriggeredAtOrigin: eventOrigin, + EventType: user.HumanInviteCodeAddedType, + MessageType: domain.InviteUserMessageType, + NotificationType: domain.NotificationTypeEmail, + URLTemplate: fmt.Sprintf("%s/ui/login/user/invite?userID=%s&loginname={{.LoginName}}&code={{.Code}}&orgID=%s&authRequestID=%s", eventOrigin, userID, orgID, authRequestID), + CodeExpiry: 1 * time.Hour, + Code: code, + UnverifiedNotificationChannel: true, + IsOTP: false, + RequiresPreviousDomain: false, + Args: &domain.NotificationArguments{ + ApplicationName: "APP", + }, + }, + BackOff: 1 * time.Second, + NotifyUser: &query.NotifyUser{ + ID: userID, + ResourceOwner: orgID, + LastEmail: lastEmail, + VerifiedEmail: verifiedEmail, + PreferredLoginName: preferredLoginName, + LastPhone: lastPhone, + VerifiedPhone: verifiedPhone, + }, + }, + }, w + }, + }, + { + name: "send failed (max attempts), cancel", + test: func(ctrl *gomock.Controller, queries *mock.MockQueries, commands *mock.MockCommands) (f fieldsWorker, a argsWorker, w wantWorker) { + givenTemplate := "{{.LogoURL}}" + expectContent := fmt.Sprintf("%s%s/%s/%s", eventOrigin, assetsPath, policyID, logoURL) + w.message = &messages.Email{ + Recipients: []string{lastEmail}, + Subject: "Invitation to APP", + Content: expectContent, + } + w.sendError = sendError + codeAlg, code := cryptoValue(t, ctrl, "testcode") + expectTemplateQueries(queries, givenTemplate) + commands.EXPECT().NotificationCanceled(gomock.Any(), gomock.Any(), notificationID, instanceID, sendError).Return(nil) + return fieldsWorker{ + queries: queries, + commands: commands, + es: eventstore.NewEventstore(&eventstore.Config{ + Querier: es_repo_mock.NewRepo(t).MockQuerier, + }), + userDataCrypto: codeAlg, + now: testNow, + backOff: testBackOff, + maxAttempts: 2, + }, + argsWorker{ + event: ¬ification.RetryRequestedEvent{ + BaseEvent: *eventstore.BaseEventFromRepo(&repository.Event{ + InstanceID: instanceID, + AggregateID: notificationID, + ResourceOwner: sql.NullString{String: instanceID}, + CreationDate: time.Now().Add(-2 * time.Second), + Seq: 2, + Typ: notification.RequestedType, + }), + Request: notification.Request{ + UserID: userID, + UserResourceOwner: orgID, + AggregateID: "", + AggregateResourceOwner: "", + TriggeredAtOrigin: eventOrigin, + EventType: user.HumanInviteCodeAddedType, + MessageType: domain.InviteUserMessageType, + NotificationType: domain.NotificationTypeEmail, + URLTemplate: fmt.Sprintf("%s/ui/login/user/invite?userID=%s&loginname={{.LoginName}}&code={{.Code}}&orgID=%s&authRequestID=%s", eventOrigin, userID, orgID, authRequestID), + CodeExpiry: 1 * time.Hour, + Code: code, + UnverifiedNotificationChannel: true, + IsOTP: false, + RequiresPreviousDomain: false, + Args: &domain.NotificationArguments{ + ApplicationName: "APP", + }, + }, + BackOff: 1 * time.Second, + NotifyUser: &query.NotifyUser{ + ID: userID, + ResourceOwner: orgID, + LastEmail: lastEmail, + VerifiedEmail: verifiedEmail, + PreferredLoginName: preferredLoginName, + LastPhone: lastPhone, + VerifiedPhone: verifiedPhone, + }, + }, + }, w + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ctrl := gomock.NewController(t) + queries := mock.NewMockQueries(ctrl) + commands := mock.NewMockCommands(ctrl) + f, a, w := tt.test(ctrl, queries, commands) + err := newNotificationWorker(t, ctrl, queries, f, a, w).reduceNotificationRetry( + authz.WithInstanceID(context.Background(), instanceID), + &sql.Tx{}, + a.event.(*notification.RetryRequestedEvent)) + if w.err != nil { + w.err(t, err) + } else { + assert.NoError(t, err) + } + }) + } +} + +func newNotificationWorker(t *testing.T, ctrl *gomock.Controller, queries *mock.MockQueries, f fieldsWorker, a argsWorker, w wantWorker) *NotificationWorker { + queries.EXPECT().NotificationProviderByIDAndType(gomock.Any(), gomock.Any(), gomock.Any()).AnyTimes().Return(&query.DebugNotificationProvider{}, nil) + smtpAlg, _ := cryptoValue(t, ctrl, "smtppw") + channel := channel_mock.NewMockNotificationChannel(ctrl) + if w.err == nil { + if w.message != nil { + w.message.TriggeringEvent = a.event + channel.EXPECT().HandleMessage(w.message).Return(w.sendError) + } + if w.messageSMS != nil { + w.messageSMS.TriggeringEvent = a.event + channel.EXPECT().HandleMessage(w.messageSMS).DoAndReturn(func(message *messages.SMS) error { + message.VerificationID = gu.Ptr(verificationID) + return w.sendError + }) + } + } + return &NotificationWorker{ + commands: f.commands, + queries: NewNotificationQueries( + f.queries, + f.es, + externalDomain, + externalPort, + externalSecure, + "", + f.userDataCrypto, + smtpAlg, + f.SMSTokenCrypto, + ), + channels: ¬ificationChannels{ + Chain: *senders.ChainChannels(channel), + EmailConfig: &email.Config{ + ProviderConfig: &email.Provider{ + ID: "emailProviderID", + Description: "description", + }, + SMTPConfig: &smtp.Config{ + SMTP: smtp.SMTP{ + Host: "host", + User: "user", + Password: "password", + }, + Tls: true, + From: "from", + FromName: "fromName", + ReplyToAddress: "replyToAddress", + }, + WebhookConfig: nil, + }, + SMSConfig: &sms.Config{ + ProviderConfig: &sms.Provider{ + ID: "smsProviderID", + Description: "description", + }, + TwilioConfig: &twilio.Config{ + SID: "sid", + Token: "token", + SenderNumber: "senderNumber", + VerifyServiceSID: "verifyServiceSID", + }, + }, + }, + config: WorkerConfig{ + Workers: 1, + BulkLimit: 10, + RequeueEvery: 2 * time.Second, + HandleActiveInstances: 0, + TransactionDuration: 5 * time.Second, + MaxAttempts: f.maxAttempts, + MaxTtl: 5 * time.Minute, + MinRetryDelay: 1 * time.Second, + MaxRetryDelay: 10 * time.Second, + RetryDelayFactor: 2, + }, + now: f.now, + backOff: f.backOff, + } +} + +func TestNotificationWorker_exponentialBackOff(t *testing.T) { + type fields struct { + config WorkerConfig + } + type args struct { + current time.Duration + } + tests := []struct { + name string + fields fields + args args + wantMin time.Duration + wantMax time.Duration + }{ + { + name: "less than min, min - 1.5*min", + fields: fields{ + config: WorkerConfig{ + MinRetryDelay: 1 * time.Second, + MaxRetryDelay: 5 * time.Second, + RetryDelayFactor: 1.5, + }, + }, + args: args{ + current: 0, + }, + wantMin: 1000 * time.Millisecond, + wantMax: 1500 * time.Millisecond, + }, + { + name: "current, 1.5*current - max", + fields: fields{ + config: WorkerConfig{ + MinRetryDelay: 1 * time.Second, + MaxRetryDelay: 5 * time.Second, + RetryDelayFactor: 1.5, + }, + }, + args: args{ + current: 4 * time.Second, + }, + wantMin: 4000 * time.Millisecond, + wantMax: 5000 * time.Millisecond, + }, + { + name: "max, max", + fields: fields{ + config: WorkerConfig{ + MinRetryDelay: 1 * time.Second, + MaxRetryDelay: 5 * time.Second, + RetryDelayFactor: 1.5, + }, + }, + args: args{ + current: 5 * time.Second, + }, + wantMin: 5000 * time.Millisecond, + wantMax: 5000 * time.Millisecond, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + w := &NotificationWorker{ + config: tt.fields.config, + } + b := w.exponentialBackOff(tt.args.current) + assert.GreaterOrEqual(t, b, tt.wantMin) + assert.LessOrEqual(t, b, tt.wantMax) + }) + } +} diff --git a/internal/notification/handlers/user_notifier.go b/internal/notification/handlers/user_notifier.go index 41d2f4dc8f..684c7b630d 100644 --- a/internal/notification/handlers/user_notifier.go +++ b/internal/notification/handlers/user_notifier.go @@ -2,23 +2,84 @@ package handlers import ( "context" - "strings" "time" http_util "github.com/zitadel/zitadel/internal/api/http" + "github.com/zitadel/zitadel/internal/api/ui/console" "github.com/zitadel/zitadel/internal/api/ui/login" - "github.com/zitadel/zitadel/internal/crypto" + "github.com/zitadel/zitadel/internal/command" "github.com/zitadel/zitadel/internal/domain" "github.com/zitadel/zitadel/internal/eventstore" "github.com/zitadel/zitadel/internal/eventstore/handler/v2" "github.com/zitadel/zitadel/internal/notification/senders" - "github.com/zitadel/zitadel/internal/notification/types" - "github.com/zitadel/zitadel/internal/query" "github.com/zitadel/zitadel/internal/repository/session" "github.com/zitadel/zitadel/internal/repository/user" "github.com/zitadel/zitadel/internal/zerrors" ) +func init() { + RegisterSentHandler(user.HumanInitialCodeAddedType, + func(ctx context.Context, commands Commands, id, orgID string, _ *senders.CodeGeneratorInfo, args map[string]any) error { + return commands.HumanInitCodeSent(ctx, orgID, id) + }, + ) + RegisterSentHandler(user.HumanEmailCodeAddedType, + func(ctx context.Context, commands Commands, id, orgID string, generatorInfo *senders.CodeGeneratorInfo, args map[string]any) error { + return commands.HumanEmailVerificationCodeSent(ctx, orgID, id) + }, + ) + RegisterSentHandler(user.HumanPasswordCodeAddedType, + func(ctx context.Context, commands Commands, id, orgID string, generatorInfo *senders.CodeGeneratorInfo, args map[string]any) error { + return commands.PasswordCodeSent(ctx, orgID, id, generatorInfo) + }, + ) + RegisterSentHandler(user.HumanOTPSMSCodeAddedType, + func(ctx context.Context, commands Commands, id, orgID string, generatorInfo *senders.CodeGeneratorInfo, args map[string]any) error { + return commands.HumanOTPSMSCodeSent(ctx, id, orgID, generatorInfo) + }, + ) + RegisterSentHandler(session.OTPSMSChallengedType, + func(ctx context.Context, commands Commands, id, orgID string, generatorInfo *senders.CodeGeneratorInfo, args map[string]any) error { + return commands.OTPSMSSent(ctx, id, orgID, generatorInfo) + }, + ) + RegisterSentHandler(user.HumanOTPEmailCodeAddedType, + func(ctx context.Context, commands Commands, id, orgID string, generatorInfo *senders.CodeGeneratorInfo, args map[string]any) error { + return commands.HumanOTPEmailCodeSent(ctx, id, orgID) + }, + ) + RegisterSentHandler(session.OTPEmailChallengedType, + func(ctx context.Context, commands Commands, id, orgID string, generatorInfo *senders.CodeGeneratorInfo, args map[string]any) error { + return commands.OTPEmailSent(ctx, id, orgID) + }, + ) + RegisterSentHandler(user.UserDomainClaimedType, + func(ctx context.Context, commands Commands, id, orgID string, generatorInfo *senders.CodeGeneratorInfo, args map[string]any) error { + return commands.UserDomainClaimedSent(ctx, orgID, id) + }, + ) + RegisterSentHandler(user.HumanPasswordlessInitCodeRequestedType, + func(ctx context.Context, commands Commands, id, orgID string, generatorInfo *senders.CodeGeneratorInfo, args map[string]any) error { + return commands.HumanPasswordlessInitCodeSent(ctx, id, orgID, args["CodeID"].(string)) + }, + ) + RegisterSentHandler(user.HumanPasswordChangedType, + func(ctx context.Context, commands Commands, id, orgID string, generatorInfo *senders.CodeGeneratorInfo, args map[string]any) error { + return commands.PasswordChangeSent(ctx, orgID, id) + }, + ) + RegisterSentHandler(user.HumanPhoneCodeAddedType, + func(ctx context.Context, commands Commands, id, orgID string, generatorInfo *senders.CodeGeneratorInfo, args map[string]any) error { + return commands.HumanPhoneVerificationCodeSent(ctx, orgID, id, generatorInfo) + }, + ) + RegisterSentHandler(user.HumanInviteCodeAddedType, + func(ctx context.Context, commands Commands, id, orgID string, _ *senders.CodeGeneratorInfo, args map[string]any) error { + return commands.InviteCodeSent(ctx, orgID, id) + }, + ) +} + const ( UserNotificationsProjectionTable = "projections.notifications" ) @@ -26,7 +87,6 @@ const ( type userNotifier struct { commands Commands queries *NotificationQueries - channels types.ChannelChains otpEmailTmpl string } @@ -35,14 +95,12 @@ func NewUserNotifier( config handler.Config, commands Commands, queries *NotificationQueries, - channels types.ChannelChains, otpEmailTmpl string, ) *handler.Handler { return handler.NewHandler(ctx, &config, &userNotifier{ commands: commands, queries: queries, otpEmailTmpl: otpEmailTmpl, - channels: channels, }) } @@ -146,39 +204,29 @@ func (u *userNotifier) reduceInitCodeAdded(event eventstore.Event) (*handler.Sta if alreadyHandled { return nil } - code, err := crypto.DecryptString(e.Code, u.queries.UserDataCrypto) - if err != nil { - return err - } - colors, err := u.queries.ActiveLabelPolicyByOrg(ctx, e.Aggregate().ResourceOwner, false) - if err != nil { - return err - } - - template, err := u.queries.MailTemplateByOrg(ctx, e.Aggregate().ResourceOwner, false) - if err != nil { - return err - } - - notifyUser, err := u.queries.GetNotifyUserByID(ctx, true, e.Aggregate().ID) - if err != nil { - return err - } - translator, err := u.queries.GetTranslatorWithOrgTexts(ctx, notifyUser.ResourceOwner, domain.InitCodeMessageType) - if err != nil { - return err - } - ctx, err = u.queries.Origin(ctx, e) if err != nil { return err } - err = types.SendEmail(ctx, u.channels, string(template.Template), translator, notifyUser, colors, e). - SendUserInitCode(ctx, notifyUser, code, e.AuthRequestID) - if err != nil { - return err - } - return u.commands.HumanInitCodeSent(ctx, e.Aggregate().ResourceOwner, e.Aggregate().ID) + origin := http_util.DomainContext(ctx).Origin() + return u.commands.RequestNotification( + ctx, + e.Aggregate().ResourceOwner, + command.NewNotificationRequest( + e.Aggregate().ID, + e.Aggregate().ResourceOwner, + origin, + e.EventType, + domain.NotificationTypeEmail, + domain.InitCodeMessageType, + ). + WithURLTemplate(login.InitUserLinkTemplate(origin, e.Aggregate().ID, e.Aggregate().ResourceOwner, e.AuthRequestID)). + WithCode(e.Code, e.Expiry). + WithArgs(&domain.NotificationArguments{ + AuthRequestID: e.AuthRequestID, + }). + WithUnverifiedChannel(), + ) }), nil } @@ -203,42 +251,39 @@ func (u *userNotifier) reduceEmailCodeAdded(event eventstore.Event) (*handler.St if alreadyHandled { return nil } - code, err := crypto.DecryptString(e.Code, u.queries.UserDataCrypto) - if err != nil { - return err - } - colors, err := u.queries.ActiveLabelPolicyByOrg(ctx, e.Aggregate().ResourceOwner, false) - if err != nil { - return err - } - - template, err := u.queries.MailTemplateByOrg(ctx, e.Aggregate().ResourceOwner, false) - if err != nil { - return err - } - - notifyUser, err := u.queries.GetNotifyUserByID(ctx, true, e.Aggregate().ID) - if err != nil { - return err - } - translator, err := u.queries.GetTranslatorWithOrgTexts(ctx, notifyUser.ResourceOwner, domain.VerifyEmailMessageType) - if err != nil { - return err - } - ctx, err = u.queries.Origin(ctx, e) if err != nil { return err } - err = types.SendEmail(ctx, u.channels, string(template.Template), translator, notifyUser, colors, e). - SendEmailVerificationCode(ctx, notifyUser, code, e.URLTemplate, e.AuthRequestID) - if err != nil { - return err - } - return u.commands.HumanEmailVerificationCodeSent(ctx, e.Aggregate().ResourceOwner, e.Aggregate().ID) + origin := http_util.DomainContext(ctx).Origin() + + return u.commands.RequestNotification(ctx, + e.Aggregate().ResourceOwner, + command.NewNotificationRequest( + e.Aggregate().ID, + e.Aggregate().ResourceOwner, + origin, + e.EventType, + domain.NotificationTypeEmail, + domain.VerifyEmailMessageType, + ). + WithURLTemplate(u.emailCodeTemplate(origin, e)). + WithCode(e.Code, e.Expiry). + WithArgs(&domain.NotificationArguments{ + AuthRequestID: e.AuthRequestID, + }). + WithUnverifiedChannel(), + ) }), nil } +func (u *userNotifier) emailCodeTemplate(origin string, e *user.HumanEmailCodeAddedEvent) string { + if e.URLTemplate != "" { + return e.URLTemplate + } + return login.MailVerificationLinkTemplate(origin, e.Aggregate().ID, e.Aggregate().ResourceOwner, e.AuthRequestID) +} + func (u *userNotifier) reducePasswordCodeAdded(event eventstore.Event) (*handler.Statement, error) { e, ok := event.(*user.HumanPasswordCodeAddedEvent) if !ok { @@ -259,64 +304,74 @@ func (u *userNotifier) reducePasswordCodeAdded(event eventstore.Event) (*handler if alreadyHandled { return nil } - var code string - if e.Code != nil { - code, err = crypto.DecryptString(e.Code, u.queries.UserDataCrypto) - if err != nil { - return err - } - } - colors, err := u.queries.ActiveLabelPolicyByOrg(ctx, e.Aggregate().ResourceOwner, false) - if err != nil { - return err - } - - template, err := u.queries.MailTemplateByOrg(ctx, e.Aggregate().ResourceOwner, false) - if err != nil { - return err - } - - notifyUser, err := u.queries.GetNotifyUserByID(ctx, true, e.Aggregate().ID) - if err != nil { - return err - } - translator, err := u.queries.GetTranslatorWithOrgTexts(ctx, notifyUser.ResourceOwner, domain.PasswordResetMessageType) - if err != nil { - return err - } - ctx, err = u.queries.Origin(ctx, e) if err != nil { return err } - generatorInfo := new(senders.CodeGeneratorInfo) - notify := types.SendEmail(ctx, u.channels, string(template.Template), translator, notifyUser, colors, e) - if e.NotificationType == domain.NotificationTypeSms { - notify = types.SendSMS(ctx, u.channels, translator, notifyUser, colors, e, generatorInfo) - } - err = notify.SendPasswordCode(ctx, notifyUser, code, e.URLTemplate, e.AuthRequestID) - if err != nil { - return err - } - return u.commands.PasswordCodeSent(ctx, e.Aggregate().ResourceOwner, e.Aggregate().ID, generatorInfo) + origin := http_util.DomainContext(ctx).Origin() + return u.commands.RequestNotification(ctx, + e.Aggregate().ResourceOwner, + command.NewNotificationRequest( + e.Aggregate().ID, + e.Aggregate().ResourceOwner, + origin, + e.EventType, + e.NotificationType, + domain.PasswordResetMessageType, + ). + WithURLTemplate(u.passwordCodeTemplate(origin, e)). + WithCode(e.Code, e.Expiry). + WithArgs(&domain.NotificationArguments{ + AuthRequestID: e.AuthRequestID, + }). + WithUnverifiedChannel(), + ) }), nil } +func (u *userNotifier) passwordCodeTemplate(origin string, e *user.HumanPasswordCodeAddedEvent) string { + if e.URLTemplate != "" { + return e.URLTemplate + } + return login.InitPasswordLinkTemplate(origin, e.Aggregate().ID, e.Aggregate().ResourceOwner, e.AuthRequestID) +} + func (u *userNotifier) reduceOTPSMSCodeAdded(event eventstore.Event) (*handler.Statement, error) { e, ok := event.(*user.HumanOTPSMSCodeAddedEvent) if !ok { return nil, zerrors.ThrowInvalidArgumentf(nil, "HANDL-ASF3g", "reduce.wrong.event.type %s", user.HumanOTPSMSCodeAddedType) } - return u.reduceOTPSMS( - e, - e.Code, - e.Expiry, - e.Aggregate().ID, - e.Aggregate().ResourceOwner, - u.commands.HumanOTPSMSCodeSent, - user.HumanOTPSMSCodeAddedType, - user.HumanOTPSMSCodeSentType, - ) + + return handler.NewStatement(event, func(ex handler.Executer, projectionName string) error { + ctx := HandlerContext(event.Aggregate()) + alreadyHandled, err := u.checkIfCodeAlreadyHandledOrExpired(ctx, event, e.Expiry, nil, + user.HumanOTPSMSCodeAddedType, + user.HumanOTPSMSCodeSentType) + if err != nil { + return err + } + if alreadyHandled { + return nil + } + ctx, err = u.queries.Origin(ctx, e) + if err != nil { + return err + } + return u.commands.RequestNotification(ctx, + e.Aggregate().ResourceOwner, + command.NewNotificationRequest( + e.Aggregate().ID, + e.Aggregate().ResourceOwner, + http_util.DomainContext(ctx).Origin(), + e.EventType, + domain.NotificationTypeSms, + domain.VerifySMSOTPMessageType, + ). + WithCode(e.Code, e.Expiry). + WithArgs(otpArgs(ctx, e.Expiry)). + WithOTP(), + ) + }), nil } func (u *userNotifier) reduceSessionOTPSMSChallenged(event eventstore.Event) (*handler.Statement, error) { @@ -327,75 +382,46 @@ func (u *userNotifier) reduceSessionOTPSMSChallenged(event eventstore.Event) (*h if e.CodeReturned { return handler.NewNoOpStatement(e), nil } - ctx := HandlerContext(event.Aggregate()) - s, err := u.queries.SessionByID(ctx, true, e.Aggregate().ID, "") - if err != nil { - return nil, err - } - return u.reduceOTPSMS( - e, - e.Code, - e.Expiry, - s.UserFactor.UserID, - s.UserFactor.ResourceOwner, - u.commands.OTPSMSSent, - session.OTPSMSChallengedType, - session.OTPSMSSentType, - ) -} -func (u *userNotifier) reduceOTPSMS( - event eventstore.Event, - code *crypto.CryptoValue, - expiry time.Duration, - userID, - resourceOwner string, - sentCommand func(ctx context.Context, userID, resourceOwner string, generatorInfo *senders.CodeGeneratorInfo) (err error), - eventTypes ...eventstore.EventType, -) (*handler.Statement, error) { - ctx := HandlerContext(event.Aggregate()) - alreadyHandled, err := u.checkIfCodeAlreadyHandledOrExpired(ctx, event, expiry, nil, eventTypes...) - if err != nil { - return nil, err - } - if alreadyHandled { - return handler.NewNoOpStatement(event), nil - } - var plainCode string - if code != nil { - plainCode, err = crypto.DecryptString(code, u.queries.UserDataCrypto) + return handler.NewStatement(event, func(ex handler.Executer, projectionName string) error { + ctx := HandlerContext(event.Aggregate()) + alreadyHandled, err := u.checkIfCodeAlreadyHandledOrExpired(ctx, event, e.Expiry, nil, + session.OTPSMSChallengedType, + session.OTPSMSSentType) if err != nil { - return nil, err + return err + } + if alreadyHandled { + return nil + } + s, err := u.queries.SessionByID(ctx, true, e.Aggregate().ID, "") + if err != nil { + return err } - } - colors, err := u.queries.ActiveLabelPolicyByOrg(ctx, resourceOwner, false) - if err != nil { - return nil, err - } - notifyUser, err := u.queries.GetNotifyUserByID(ctx, true, userID) - if err != nil { - return nil, err - } - translator, err := u.queries.GetTranslatorWithOrgTexts(ctx, notifyUser.ResourceOwner, domain.VerifySMSOTPMessageType) - if err != nil { - return nil, err - } - ctx, err = u.queries.Origin(ctx, event) - if err != nil { - return nil, err - } - generatorInfo := new(senders.CodeGeneratorInfo) - notify := types.SendSMS(ctx, u.channels, translator, notifyUser, colors, event, generatorInfo) - err = notify.SendOTPSMSCode(ctx, plainCode, expiry) - if err != nil { - return nil, err - } - err = sentCommand(ctx, event.Aggregate().ID, event.Aggregate().ResourceOwner, generatorInfo) - if err != nil { - return nil, err - } - return handler.NewNoOpStatement(event), nil + ctx, err = u.queries.Origin(ctx, e) + if err != nil { + return err + } + + args := otpArgs(ctx, e.Expiry) + args.SessionID = e.Aggregate().ID + return u.commands.RequestNotification(ctx, + s.UserFactor.ResourceOwner, + command.NewNotificationRequest( + s.UserFactor.UserID, + s.UserFactor.ResourceOwner, + http_util.DomainContext(ctx).Origin(), + e.EventType, + domain.NotificationTypeSms, + domain.VerifySMSOTPMessageType, + ). + WithAggregate(e.Aggregate().ID, e.Aggregate().ResourceOwner). + WithCode(e.Code, e.Expiry). + WithOTP(). + WithArgs(args), + ) + }), nil } func (u *userNotifier) reduceOTPEmailCodeAdded(event eventstore.Event) (*handler.Statement, error) { @@ -403,24 +429,46 @@ func (u *userNotifier) reduceOTPEmailCodeAdded(event eventstore.Event) (*handler if !ok { return nil, zerrors.ThrowInvalidArgumentf(nil, "HANDL-JL3hw", "reduce.wrong.event.type %s", user.HumanOTPEmailCodeAddedType) } - var authRequestID string - if e.AuthRequestInfo != nil { - authRequestID = e.AuthRequestInfo.ID - } - url := func(code, origin string, _ *query.NotifyUser) (string, error) { - return login.OTPLink(origin, authRequestID, code, domain.MFATypeOTPEmail), nil - } - return u.reduceOTPEmail( - e, - e.Code, - e.Expiry, - e.Aggregate().ID, - e.Aggregate().ResourceOwner, - url, - u.commands.HumanOTPEmailCodeSent, - user.HumanOTPEmailCodeAddedType, - user.HumanOTPEmailCodeSentType, - ) + + return handler.NewStatement(event, func(ex handler.Executer, projectionName string) error { + ctx := HandlerContext(event.Aggregate()) + alreadyHandled, err := u.checkIfCodeAlreadyHandledOrExpired(ctx, event, e.Expiry, nil, + user.HumanOTPEmailCodeAddedType, + user.HumanOTPEmailCodeSentType) + if err != nil { + return err + } + if alreadyHandled { + return nil + } + + ctx, err = u.queries.Origin(ctx, e) + if err != nil { + return err + } + origin := http_util.DomainContext(ctx).Origin() + var authRequestID string + if e.AuthRequestInfo != nil { + authRequestID = e.AuthRequestInfo.ID + } + args := otpArgs(ctx, e.Expiry) + args.AuthRequestID = authRequestID + return u.commands.RequestNotification(ctx, + e.Aggregate().ResourceOwner, + command.NewNotificationRequest( + e.Aggregate().ID, + e.Aggregate().ResourceOwner, + origin, + e.EventType, + domain.NotificationTypeEmail, + domain.VerifyEmailOTPMessageType, + ). + WithURLTemplate(login.OTPLinkTemplate(origin, authRequestID, domain.MFATypeOTPEmail)). + WithCode(e.Code, e.Expiry). + WithOTP(). + WithArgs(args), + ) + }), nil } func (u *userNotifier) reduceSessionOTPEmailChallenged(event eventstore.Event) (*handler.Statement, error) { @@ -431,93 +479,63 @@ func (u *userNotifier) reduceSessionOTPEmailChallenged(event eventstore.Event) ( if e.ReturnCode { return handler.NewNoOpStatement(e), nil } - ctx := HandlerContext(event.Aggregate()) - s, err := u.queries.SessionByID(ctx, true, e.Aggregate().ID, "") - if err != nil { - return nil, err - } - url := func(code, origin string, user *query.NotifyUser) (string, error) { - var buf strings.Builder - urlTmpl := origin + u.otpEmailTmpl - if e.URLTmpl != "" { - urlTmpl = e.URLTmpl + return handler.NewStatement(event, func(ex handler.Executer, projectionName string) error { + ctx := HandlerContext(event.Aggregate()) + alreadyHandled, err := u.checkIfCodeAlreadyHandledOrExpired(ctx, event, e.Expiry, nil, + session.OTPEmailChallengedType, + session.OTPEmailSentType) + if err != nil { + return err } - if err := domain.RenderOTPEmailURLTemplate(&buf, urlTmpl, code, user.ID, user.PreferredLoginName, user.DisplayName, e.Aggregate().ID, user.PreferredLanguage); err != nil { - return "", err + if alreadyHandled { + return nil } - return buf.String(), nil - } - return u.reduceOTPEmail( - e, - e.Code, - e.Expiry, - s.UserFactor.UserID, - s.UserFactor.ResourceOwner, - url, - u.commands.OTPEmailSent, - user.HumanOTPEmailCodeAddedType, - user.HumanOTPEmailCodeSentType, - ) + s, err := u.queries.SessionByID(ctx, true, e.Aggregate().ID, "") + if err != nil { + return err + } + + ctx, err = u.queries.Origin(ctx, e) + if err != nil { + return err + } + origin := http_util.DomainContext(ctx).Origin() + + args := otpArgs(ctx, e.Expiry) + args.SessionID = e.Aggregate().ID + return u.commands.RequestNotification(ctx, + s.UserFactor.ResourceOwner, + command.NewNotificationRequest( + s.UserFactor.UserID, + s.UserFactor.ResourceOwner, + origin, + e.EventType, + domain.NotificationTypeEmail, + domain.VerifyEmailOTPMessageType, + ). + WithAggregate(e.Aggregate().ID, e.Aggregate().ResourceOwner). + WithURLTemplate(u.otpEmailTemplate(origin, e)). + WithCode(e.Code, e.Expiry). + WithOTP(). + WithArgs(args), + ) + }), nil } -func (u *userNotifier) reduceOTPEmail( - event eventstore.Event, - code *crypto.CryptoValue, - expiry time.Duration, - userID, - resourceOwner string, - urlTmpl func(code, origin string, user *query.NotifyUser) (string, error), - sentCommand func(ctx context.Context, userID string, resourceOwner string) (err error), - eventTypes ...eventstore.EventType, -) (*handler.Statement, error) { - ctx := HandlerContext(event.Aggregate()) - alreadyHandled, err := u.checkIfCodeAlreadyHandledOrExpired(ctx, event, expiry, nil, eventTypes...) - if err != nil { - return nil, err - } - if alreadyHandled { - return handler.NewNoOpStatement(event), nil - } - plainCode, err := crypto.DecryptString(code, u.queries.UserDataCrypto) - if err != nil { - return nil, err - } - colors, err := u.queries.ActiveLabelPolicyByOrg(ctx, resourceOwner, false) - if err != nil { - return nil, err +func (u *userNotifier) otpEmailTemplate(origin string, e *session.OTPEmailChallengedEvent) string { + if e.URLTmpl != "" { + return e.URLTmpl } + return origin + u.otpEmailTmpl +} - template, err := u.queries.MailTemplateByOrg(ctx, resourceOwner, false) - if err != nil { - return nil, err +func otpArgs(ctx context.Context, expiry time.Duration) *domain.NotificationArguments { + domainCtx := http_util.DomainContext(ctx) + return &domain.NotificationArguments{ + Origin: domainCtx.Origin(), + Domain: domainCtx.RequestedDomain(), + Expiry: expiry, } - - notifyUser, err := u.queries.GetNotifyUserByID(ctx, true, userID) - if err != nil { - return nil, err - } - translator, err := u.queries.GetTranslatorWithOrgTexts(ctx, resourceOwner, domain.VerifyEmailOTPMessageType) - if err != nil { - return nil, err - } - ctx, err = u.queries.Origin(ctx, event) - if err != nil { - return nil, err - } - url, err := urlTmpl(plainCode, http_util.DomainContext(ctx).Origin(), notifyUser) - if err != nil { - return nil, err - } - notify := types.SendEmail(ctx, u.channels, string(template.Template), translator, notifyUser, colors, event) - err = notify.SendOTPEmailCode(ctx, url, plainCode, expiry) - if err != nil { - return nil, err - } - err = sentCommand(ctx, event.Aggregate().ID, event.Aggregate().ResourceOwner) - if err != nil { - return nil, err - } - return handler.NewNoOpStatement(event), nil } func (u *userNotifier) reduceDomainClaimed(event eventstore.Event) (*handler.Statement, error) { @@ -535,35 +553,28 @@ func (u *userNotifier) reduceDomainClaimed(event eventstore.Event) (*handler.Sta if alreadyHandled { return nil } - colors, err := u.queries.ActiveLabelPolicyByOrg(ctx, e.Aggregate().ResourceOwner, false) - if err != nil { - return err - } - - template, err := u.queries.MailTemplateByOrg(ctx, e.Aggregate().ResourceOwner, false) - if err != nil { - return err - } - - notifyUser, err := u.queries.GetNotifyUserByID(ctx, true, e.Aggregate().ID) - if err != nil { - return err - } - translator, err := u.queries.GetTranslatorWithOrgTexts(ctx, notifyUser.ResourceOwner, domain.DomainClaimedMessageType) - if err != nil { - return err - } - ctx, err = u.queries.Origin(ctx, e) if err != nil { return err } - err = types.SendEmail(ctx, u.channels, string(template.Template), translator, notifyUser, colors, e). - SendDomainClaimed(ctx, notifyUser, e.UserName) - if err != nil { - return err - } - return u.commands.UserDomainClaimedSent(ctx, e.Aggregate().ResourceOwner, e.Aggregate().ID) + origin := http_util.DomainContext(ctx).Origin() + return u.commands.RequestNotification(ctx, + e.Aggregate().ResourceOwner, + command.NewNotificationRequest( + e.Aggregate().ID, + e.Aggregate().ResourceOwner, + origin, + e.EventType, + domain.NotificationTypeEmail, + domain.DomainClaimedMessageType, + ). + WithURLTemplate(login.LoginLink(origin, e.Aggregate().ResourceOwner)). + WithUnverifiedChannel(). + WithPreviousDomain(). + WithArgs(&domain.NotificationArguments{ + TempUsername: e.UserName, + }), + ) }), nil } @@ -585,42 +596,37 @@ func (u *userNotifier) reducePasswordlessCodeRequested(event eventstore.Event) ( if alreadyHandled { return nil } - code, err := crypto.DecryptString(e.Code, u.queries.UserDataCrypto) - if err != nil { - return err - } - colors, err := u.queries.ActiveLabelPolicyByOrg(ctx, e.Aggregate().ResourceOwner, false) - if err != nil { - return err - } - - template, err := u.queries.MailTemplateByOrg(ctx, e.Aggregate().ResourceOwner, false) - if err != nil { - return err - } - - notifyUser, err := u.queries.GetNotifyUserByID(ctx, true, e.Aggregate().ID) - if err != nil { - return err - } - translator, err := u.queries.GetTranslatorWithOrgTexts(ctx, notifyUser.ResourceOwner, domain.PasswordlessRegistrationMessageType) - if err != nil { - return err - } - ctx, err = u.queries.Origin(ctx, e) if err != nil { return err } - err = types.SendEmail(ctx, u.channels, string(template.Template), translator, notifyUser, colors, e). - SendPasswordlessRegistrationLink(ctx, notifyUser, code, e.ID, e.URLTemplate) - if err != nil { - return err - } - return u.commands.HumanPasswordlessInitCodeSent(ctx, e.Aggregate().ID, e.Aggregate().ResourceOwner, e.ID) + origin := http_util.DomainContext(ctx).Origin() + return u.commands.RequestNotification(ctx, + e.Aggregate().ResourceOwner, + command.NewNotificationRequest( + e.Aggregate().ID, + e.Aggregate().ResourceOwner, + origin, + e.EventType, + domain.NotificationTypeEmail, + domain.PasswordlessRegistrationMessageType, + ). + WithURLTemplate(u.passwordlessCodeTemplate(origin, e)). + WithCode(e.Code, e.Expiry). + WithArgs(&domain.NotificationArguments{ + CodeID: e.ID, + }), + ) }), nil } +func (u *userNotifier) passwordlessCodeTemplate(origin string, e *user.HumanPasswordlessInitCodeRequestedEvent) string { + if e.URLTemplate != "" { + return e.URLTemplate + } + return domain.PasswordlessInitCodeLinkTemplate(origin+login.HandlerPrefix+login.EndpointPasswordlessRegistration, e.Aggregate().ID, e.Aggregate().ResourceOwner, e.ID) +} + func (u *userNotifier) reducePasswordChanged(event eventstore.Event) (*handler.Statement, error) { e, ok := event.(*user.HumanPasswordChangedEvent) if !ok { @@ -638,10 +644,7 @@ func (u *userNotifier) reducePasswordChanged(event eventstore.Event) (*handler.S } notificationPolicy, err := u.queries.NotificationPolicyByOrg(ctx, true, e.Aggregate().ResourceOwner, false) - if zerrors.IsNotFound(err) { - return nil - } - if err != nil { + if err != nil && !zerrors.IsNotFound(err) { return err } @@ -649,34 +652,25 @@ func (u *userNotifier) reducePasswordChanged(event eventstore.Event) (*handler.S return nil } - colors, err := u.queries.ActiveLabelPolicyByOrg(ctx, e.Aggregate().ResourceOwner, false) - if err != nil { - return err - } - - template, err := u.queries.MailTemplateByOrg(ctx, e.Aggregate().ResourceOwner, false) - if err != nil { - return err - } - - notifyUser, err := u.queries.GetNotifyUserByID(ctx, true, e.Aggregate().ID) - if err != nil { - return err - } - translator, err := u.queries.GetTranslatorWithOrgTexts(ctx, notifyUser.ResourceOwner, domain.PasswordChangeMessageType) - if err != nil { - return err - } ctx, err = u.queries.Origin(ctx, e) if err != nil { return err } - err = types.SendEmail(ctx, u.channels, string(template.Template), translator, notifyUser, colors, e). - SendPasswordChange(ctx, notifyUser) - if err != nil { - return err - } - return u.commands.PasswordChangeSent(ctx, e.Aggregate().ResourceOwner, e.Aggregate().ID) + origin := http_util.DomainContext(ctx).Origin() + + return u.commands.RequestNotification(ctx, + e.Aggregate().ResourceOwner, + command.NewNotificationRequest( + e.Aggregate().ID, + e.Aggregate().ResourceOwner, + origin, + e.EventType, + domain.NotificationTypeEmail, + domain.PasswordChangeMessageType, + ). + WithURLTemplate(console.LoginHintLink(origin, "{{.PreferredLoginName}}")). + WithUnverifiedChannel(), + ) }), nil } @@ -700,37 +694,28 @@ func (u *userNotifier) reducePhoneCodeAdded(event eventstore.Event) (*handler.St if alreadyHandled { return nil } - var code string - if e.Code != nil { - code, err = crypto.DecryptString(e.Code, u.queries.UserDataCrypto) - if err != nil { - return err - } - } - colors, err := u.queries.ActiveLabelPolicyByOrg(ctx, e.Aggregate().ResourceOwner, false) - if err != nil { - return err - } - - notifyUser, err := u.queries.GetNotifyUserByID(ctx, true, e.Aggregate().ID) - if err != nil { - return err - } - translator, err := u.queries.GetTranslatorWithOrgTexts(ctx, notifyUser.ResourceOwner, domain.VerifyPhoneMessageType) - if err != nil { - return err - } ctx, err = u.queries.Origin(ctx, e) if err != nil { return err } - generatorInfo := new(senders.CodeGeneratorInfo) - if err = types.SendSMS(ctx, u.channels, translator, notifyUser, colors, e, generatorInfo). - SendPhoneVerificationCode(ctx, code); err != nil { - return err - } - return u.commands.HumanPhoneVerificationCodeSent(ctx, e.Aggregate().ResourceOwner, e.Aggregate().ID, generatorInfo) + + return u.commands.RequestNotification(ctx, + e.Aggregate().ResourceOwner, + command.NewNotificationRequest( + e.Aggregate().ID, + e.Aggregate().ResourceOwner, + http_util.DomainContext(ctx).Origin(), + e.EventType, + domain.NotificationTypeSms, + domain.VerifyPhoneMessageType, + ). + WithCode(e.Code, e.Expiry). + WithUnverifiedChannel(). + WithArgs(&domain.NotificationArguments{ + Domain: http_util.DomainContext(ctx).RequestedDomain(), + }), + ) }), nil } @@ -753,42 +738,45 @@ func (u *userNotifier) reduceInviteCodeAdded(event eventstore.Event) (*handler.S if alreadyHandled { return nil } - code, err := crypto.DecryptString(e.Code, u.queries.UserDataCrypto) - if err != nil { - return err - } - colors, err := u.queries.ActiveLabelPolicyByOrg(ctx, e.Aggregate().ResourceOwner, false) - if err != nil { - return err - } - - template, err := u.queries.MailTemplateByOrg(ctx, e.Aggregate().ResourceOwner, false) - if err != nil { - return err - } - - notifyUser, err := u.queries.GetNotifyUserByID(ctx, true, e.Aggregate().ID) - if err != nil { - return err - } - translator, err := u.queries.GetTranslatorWithOrgTexts(ctx, notifyUser.ResourceOwner, domain.InviteUserMessageType) - if err != nil { - return err - } ctx, err = u.queries.Origin(ctx, e) if err != nil { return err } - notify := types.SendEmail(ctx, u.channels, string(template.Template), translator, notifyUser, colors, e) - err = notify.SendInviteCode(ctx, notifyUser, code, e.ApplicationName, e.URLTemplate, e.AuthRequestID) - if err != nil { - return err + origin := http_util.DomainContext(ctx).Origin() + + applicationName := e.ApplicationName + if applicationName == "" { + applicationName = "ZITADEL" } - return u.commands.InviteCodeSent(ctx, e.Aggregate().ID, e.Aggregate().ResourceOwner) + return u.commands.RequestNotification(ctx, + e.Aggregate().ResourceOwner, + command.NewNotificationRequest( + e.Aggregate().ID, + e.Aggregate().ResourceOwner, + origin, + e.EventType, + domain.NotificationTypeEmail, + domain.InviteUserMessageType, + ). + WithURLTemplate(u.inviteCodeTemplate(origin, e)). + WithCode(e.Code, e.Expiry). + WithUnverifiedChannel(). + WithArgs(&domain.NotificationArguments{ + AuthRequestID: e.AuthRequestID, + ApplicationName: applicationName, + }), + ) }), nil } +func (u *userNotifier) inviteCodeTemplate(origin string, e *user.HumanInviteCodeAddedEvent) string { + if e.URLTemplate != "" { + return e.URLTemplate + } + return login.InviteUserLinkTemplate(origin, e.Aggregate().ID, e.Aggregate().ResourceOwner, e.AuthRequestID) +} + func (u *userNotifier) checkIfCodeAlreadyHandledOrExpired(ctx context.Context, event eventstore.Event, expiry time.Duration, data map[string]interface{}, eventTypes ...eventstore.EventType) (bool, error) { if expiry > 0 && event.CreatedAt().Add(expiry).Before(time.Now().UTC()) { return true, nil diff --git a/internal/notification/handlers/user_notifier_test.go b/internal/notification/handlers/user_notifier_test.go index 9692832787..b57edcc57c 100644 --- a/internal/notification/handlers/user_notifier_test.go +++ b/internal/notification/handlers/user_notifier_test.go @@ -7,22 +7,19 @@ import ( "testing" "time" - "github.com/muhlemmer/gu" "github.com/stretchr/testify/assert" "go.uber.org/mock/gomock" "golang.org/x/text/language" + "github.com/zitadel/zitadel/internal/command" "github.com/zitadel/zitadel/internal/crypto" "github.com/zitadel/zitadel/internal/domain" "github.com/zitadel/zitadel/internal/eventstore" "github.com/zitadel/zitadel/internal/eventstore/repository" es_repo_mock "github.com/zitadel/zitadel/internal/eventstore/repository/mock" "github.com/zitadel/zitadel/internal/notification/channels/email" - channel_mock "github.com/zitadel/zitadel/internal/notification/channels/mock" "github.com/zitadel/zitadel/internal/notification/channels/set" "github.com/zitadel/zitadel/internal/notification/channels/sms" - "github.com/zitadel/zitadel/internal/notification/channels/smtp" - "github.com/zitadel/zitadel/internal/notification/channels/twilio" "github.com/zitadel/zitadel/internal/notification/channels/webhook" "github.com/zitadel/zitadel/internal/notification/handlers/mock" "github.com/zitadel/zitadel/internal/notification/messages" @@ -39,6 +36,8 @@ const ( userID = "user1" codeID = "event1" logoURL = "logo.png" + instanceID = "instanceID" + sessionID = "sessionID" eventOrigin = "https://triggered.here" eventOriginDomain = "triggered.here" assetsPath = "/assets/v1" @@ -60,196 +59,106 @@ const ( ) func Test_userNotifier_reduceInitCodeAdded(t *testing.T) { - expectMailSubject := "Initialize User" tests := []struct { name string test func(*gomock.Controller, *mock.MockQueries, *mock.MockCommands) (fields, args, want) - }{{ - name: "asset url with event trigger url", - test: func(ctrl *gomock.Controller, queries *mock.MockQueries, commands *mock.MockCommands) (f fields, a args, w want) { - givenTemplate := "{{.LogoURL}}" - expectContent := fmt.Sprintf("%s%s/%s/%s", eventOrigin, assetsPath, policyID, logoURL) - w.message = &messages.Email{ - Recipients: []string{lastEmail}, - Subject: expectMailSubject, - Content: expectContent, - } - codeAlg, code := cryptoValue(t, ctrl, "testcode") - expectTemplateQueries(queries, givenTemplate) - commands.EXPECT().HumanInitCodeSent(gomock.Any(), orgID, userID).Return(nil) - return fields{ - queries: queries, - commands: commands, - es: eventstore.NewEventstore(&eventstore.Config{ - Querier: es_repo_mock.NewRepo(t).ExpectFilterEvents().MockQuerier, - }), - userDataCrypto: codeAlg, - }, args{ - event: &user.HumanInitialCodeAddedEvent{ - BaseEvent: *eventstore.BaseEventFromRepo(&repository.Event{ - AggregateID: userID, - ResourceOwner: sql.NullString{String: orgID}, - CreationDate: time.Now().UTC(), + }{ + { + name: "with event trigger", + test: func(ctrl *gomock.Controller, queries *mock.MockQueries, commands *mock.MockCommands) (f fields, a args, w want) { + _, code := cryptoValue(t, ctrl, "testcode") + commands.EXPECT().RequestNotification(gomock.Any(), orgID, &command.NotificationRequest{ + UserID: userID, + UserResourceOwner: orgID, + TriggerOrigin: eventOrigin, + URLTemplate: fmt.Sprintf("%s/ui/login/user/init?userID=%s&loginname={{.LoginName}}&code={{.Code}}&orgID=%s&passwordset={{.PasswordSet}}&authRequestID=%s", + eventOrigin, userID, orgID, authRequestID), + Code: code, + CodeExpiry: time.Hour, + EventType: user.HumanInitialCodeAddedType, + NotificationType: domain.NotificationTypeEmail, + MessageType: domain.InitCodeMessageType, + UnverifiedNotificationChannel: true, + Args: &domain.NotificationArguments{AuthRequestID: authRequestID}, + AggregateID: "", + AggregateResourceOwner: "", + IsOTP: false, + RequiresPreviousDomain: false, + }).Return(nil) + return fields{ + queries: queries, + commands: commands, + es: eventstore.NewEventstore(&eventstore.Config{ + Querier: es_repo_mock.NewRepo(t).ExpectFilterEvents().MockQuerier, }), - Code: code, - Expiry: time.Hour, - TriggeredAtOrigin: eventOrigin, - }, - }, w + }, args{ + event: &user.HumanInitialCodeAddedEvent{ + BaseEvent: *eventstore.BaseEventFromRepo(&repository.Event{ + InstanceID: instanceID, + AggregateID: userID, + ResourceOwner: sql.NullString{String: orgID}, + CreationDate: time.Now().UTC(), + Typ: user.HumanInitialCodeAddedType, + }), + Code: code, + Expiry: time.Hour, + TriggeredAtOrigin: eventOrigin, + AuthRequestID: authRequestID, + }, + }, w + }, }, - }, { - name: "asset url without event trigger url", - test: func(ctrl *gomock.Controller, queries *mock.MockQueries, commands *mock.MockCommands) (f fields, a args, w want) { - givenTemplate := "{{.LogoURL}}" - expectContent := fmt.Sprintf("%s://%s:%d%s/%s/%s", externalProtocol, instancePrimaryDomain, externalPort, assetsPath, policyID, logoURL) - w.message = &messages.Email{ - Recipients: []string{lastEmail}, - Subject: expectMailSubject, - Content: expectContent, - } - codeAlg, code := cryptoValue(t, ctrl, "testcode") - queries.EXPECT().SearchInstanceDomains(gomock.Any(), gomock.Any()).Return(&query.InstanceDomains{ - Domains: []*query.InstanceDomain{{ - Domain: instancePrimaryDomain, - IsPrimary: true, - }}, - }, nil) - expectTemplateQueries(queries, givenTemplate) - commands.EXPECT().HumanInitCodeSent(gomock.Any(), orgID, userID).Return(nil) - return fields{ - queries: queries, - commands: commands, - es: eventstore.NewEventstore(&eventstore.Config{ - Querier: es_repo_mock.NewRepo(t).ExpectFilterEvents().MockQuerier, - }), - userDataCrypto: codeAlg, - }, args{ - event: &user.HumanInitialCodeAddedEvent{ - BaseEvent: *eventstore.BaseEventFromRepo(&repository.Event{ - AggregateID: userID, - ResourceOwner: sql.NullString{String: orgID}, - CreationDate: time.Now().UTC(), + { + name: "without event trigger", + test: func(ctrl *gomock.Controller, queries *mock.MockQueries, commands *mock.MockCommands) (f fields, a args, w want) { + _, code := cryptoValue(t, ctrl, "testcode") + queries.EXPECT().SearchInstanceDomains(gomock.Any(), gomock.Any()).Return(&query.InstanceDomains{ + Domains: []*query.InstanceDomain{{ + Domain: instancePrimaryDomain, + IsPrimary: true, + }}, + }, nil) + commands.EXPECT().RequestNotification(gomock.Any(), orgID, &command.NotificationRequest{ + UserID: userID, + UserResourceOwner: orgID, + TriggerOrigin: fmt.Sprintf("%s://%s:%d", externalProtocol, instancePrimaryDomain, externalPort), + URLTemplate: fmt.Sprintf("%s://%s:%d/ui/login/user/init?userID=%s&loginname={{.LoginName}}&code={{.Code}}&orgID=%s&passwordset={{.PasswordSet}}&authRequestID=%s", + externalProtocol, instancePrimaryDomain, externalPort, userID, orgID, authRequestID), + Code: code, + CodeExpiry: time.Hour, + EventType: user.HumanInitialCodeAddedType, + NotificationType: domain.NotificationTypeEmail, + MessageType: domain.InitCodeMessageType, + UnverifiedNotificationChannel: true, + Args: &domain.NotificationArguments{AuthRequestID: authRequestID}, + AggregateID: "", + AggregateResourceOwner: "", + IsOTP: false, + RequiresPreviousDomain: false, + }).Return(nil) + return fields{ + queries: queries, + commands: commands, + es: eventstore.NewEventstore(&eventstore.Config{ + Querier: es_repo_mock.NewRepo(t).ExpectFilterEvents().MockQuerier, }), - Code: code, - Expiry: time.Hour, - }, - }, w + }, args{ + event: &user.HumanInitialCodeAddedEvent{ + BaseEvent: *eventstore.BaseEventFromRepo(&repository.Event{ + InstanceID: instanceID, + AggregateID: userID, + ResourceOwner: sql.NullString{String: orgID}, + CreationDate: time.Now().UTC(), + Typ: user.HumanInitialCodeAddedType, + }), + Code: code, + Expiry: time.Hour, + AuthRequestID: authRequestID, + }, + }, w + }, }, - }, { - name: "button url with event trigger url", - test: func(ctrl *gomock.Controller, queries *mock.MockQueries, commands *mock.MockCommands) (f fields, a args, w want) { - givenTemplate := "{{.URL}}" - testCode := "testcode" - expectContent := fmt.Sprintf("%s/ui/login/user/init?authRequestID=%s&code=%s&loginname=%s&orgID=%s&passwordset=%t&userID=%s", eventOrigin, "", testCode, preferredLoginName, orgID, false, userID) - w.message = &messages.Email{ - Recipients: []string{lastEmail}, - Subject: expectMailSubject, - Content: expectContent, - } - codeAlg, code := cryptoValue(t, ctrl, testCode) - expectTemplateQueries(queries, givenTemplate) - commands.EXPECT().HumanInitCodeSent(gomock.Any(), orgID, userID).Return(nil) - return fields{ - queries: queries, - commands: commands, - es: eventstore.NewEventstore(&eventstore.Config{ - Querier: es_repo_mock.NewRepo(t).ExpectFilterEvents().MockQuerier, - }), - userDataCrypto: codeAlg, - }, args{ - event: &user.HumanInitialCodeAddedEvent{ - BaseEvent: *eventstore.BaseEventFromRepo(&repository.Event{ - AggregateID: userID, - ResourceOwner: sql.NullString{String: orgID}, - CreationDate: time.Now().UTC(), - }), - Code: code, - Expiry: time.Hour, - TriggeredAtOrigin: eventOrigin, - }, - }, w - }, - }, { - name: "button url without event trigger url", - test: func(ctrl *gomock.Controller, queries *mock.MockQueries, commands *mock.MockCommands) (f fields, a args, w want) { - givenTemplate := "{{.URL}}" - testCode := "testcode" - expectContent := fmt.Sprintf("%s://%s:%d/ui/login/user/init?authRequestID=%s&code=%s&loginname=%s&orgID=%s&passwordset=%t&userID=%s", externalProtocol, instancePrimaryDomain, externalPort, "", testCode, preferredLoginName, orgID, false, userID) - w.message = &messages.Email{ - Recipients: []string{lastEmail}, - Subject: expectMailSubject, - Content: expectContent, - } - codeAlg, code := cryptoValue(t, ctrl, testCode) - queries.EXPECT().SearchInstanceDomains(gomock.Any(), gomock.Any()).Return(&query.InstanceDomains{ - Domains: []*query.InstanceDomain{{ - Domain: instancePrimaryDomain, - IsPrimary: true, - }}, - }, nil) - expectTemplateQueries(queries, givenTemplate) - commands.EXPECT().HumanInitCodeSent(gomock.Any(), orgID, userID).Return(nil) - return fields{ - queries: queries, - commands: commands, - es: eventstore.NewEventstore(&eventstore.Config{ - Querier: es_repo_mock.NewRepo(t).ExpectFilterEvents().MockQuerier, - }), - userDataCrypto: codeAlg, - }, args{ - event: &user.HumanInitialCodeAddedEvent{ - BaseEvent: *eventstore.BaseEventFromRepo(&repository.Event{ - AggregateID: userID, - ResourceOwner: sql.NullString{String: orgID}, - CreationDate: time.Now().UTC(), - }), - Code: code, - Expiry: time.Hour, - }, - }, w - }, - }, { - name: "button url without event trigger url with authRequestID", - test: func(ctrl *gomock.Controller, queries *mock.MockQueries, commands *mock.MockCommands) (f fields, a args, w want) { - givenTemplate := "{{.URL}}" - testCode := "testcode" - expectContent := fmt.Sprintf("%s://%s:%d/ui/login/user/init?authRequestID=%s&code=%s&loginname=%s&orgID=%s&passwordset=%t&userID=%s", externalProtocol, instancePrimaryDomain, externalPort, authRequestID, testCode, preferredLoginName, orgID, false, userID) - w.message = &messages.Email{ - Recipients: []string{lastEmail}, - Subject: expectMailSubject, - Content: expectContent, - } - codeAlg, code := cryptoValue(t, ctrl, testCode) - queries.EXPECT().SearchInstanceDomains(gomock.Any(), gomock.Any()).Return(&query.InstanceDomains{ - Domains: []*query.InstanceDomain{{ - Domain: instancePrimaryDomain, - IsPrimary: true, - }}, - }, nil) - expectTemplateQueries(queries, givenTemplate) - commands.EXPECT().HumanInitCodeSent(gomock.Any(), orgID, userID).Return(nil) - return fields{ - queries: queries, - commands: commands, - es: eventstore.NewEventstore(&eventstore.Config{ - Querier: es_repo_mock.NewRepo(t).ExpectFilterEvents().MockQuerier, - }), - userDataCrypto: codeAlg, - }, args{ - event: &user.HumanInitialCodeAddedEvent{ - BaseEvent: *eventstore.BaseEventFromRepo(&repository.Event{ - AggregateID: userID, - ResourceOwner: sql.NullString{String: orgID}, - CreationDate: time.Now().UTC(), - }), - Code: code, - Expiry: time.Hour, - AuthRequestID: authRequestID, - }, - }, w - }, - }} - // TODO: Why don't we have an url template on user.HumanInitialCodeAddedEvent? + } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { ctrl := gomock.NewController(t) @@ -273,244 +182,141 @@ func Test_userNotifier_reduceInitCodeAdded(t *testing.T) { } func Test_userNotifier_reduceEmailCodeAdded(t *testing.T) { - expectMailSubject := "Verify email" tests := []struct { name string test func(*gomock.Controller, *mock.MockQueries, *mock.MockCommands) (fields, args, want) - }{{ - name: "asset url with event trigger url", - test: func(ctrl *gomock.Controller, queries *mock.MockQueries, commands *mock.MockCommands) (f fields, a args, w want) { - givenTemplate := "{{.LogoURL}}" - expectContent := fmt.Sprintf("%s%s/%s/%s", eventOrigin, assetsPath, policyID, logoURL) - w.message = &messages.Email{ - Recipients: []string{lastEmail}, - Subject: expectMailSubject, - Content: expectContent, - } - codeAlg, code := cryptoValue(t, ctrl, "testcode") - expectTemplateQueries(queries, givenTemplate) - commands.EXPECT().HumanEmailVerificationCodeSent(gomock.Any(), orgID, userID).Return(nil) - return fields{ - queries: queries, - commands: commands, - es: eventstore.NewEventstore(&eventstore.Config{ - Querier: es_repo_mock.NewRepo(t).ExpectFilterEvents().MockQuerier, - }), - userDataCrypto: codeAlg, - }, args{ - event: &user.HumanEmailCodeAddedEvent{ - BaseEvent: *eventstore.BaseEventFromRepo(&repository.Event{ - AggregateID: userID, - ResourceOwner: sql.NullString{String: orgID}, - CreationDate: time.Now().UTC(), + }{ + { + name: "with event trigger", + test: func(ctrl *gomock.Controller, queries *mock.MockQueries, commands *mock.MockCommands) (f fields, a args, w want) { + _, code := cryptoValue(t, ctrl, "testcode") + commands.EXPECT().RequestNotification(gomock.Any(), orgID, &command.NotificationRequest{ + UserID: userID, + UserResourceOwner: orgID, + TriggerOrigin: eventOrigin, + URLTemplate: fmt.Sprintf("%s/ui/login/mail/verification?userID=%s&code={{.Code}}&orgID=%s&authRequestID=%s", + eventOrigin, userID, orgID, authRequestID), + Code: code, + CodeExpiry: time.Hour, + EventType: user.HumanEmailCodeAddedType, + NotificationType: domain.NotificationTypeEmail, + MessageType: domain.VerifyEmailMessageType, + UnverifiedNotificationChannel: true, + Args: &domain.NotificationArguments{AuthRequestID: authRequestID}, + AggregateID: "", + AggregateResourceOwner: "", + IsOTP: false, + RequiresPreviousDomain: false, + }).Return(nil) + return fields{ + queries: queries, + commands: commands, + es: eventstore.NewEventstore(&eventstore.Config{ + Querier: es_repo_mock.NewRepo(t).ExpectFilterEvents().MockQuerier, }), - Code: code, - Expiry: time.Hour, - URLTemplate: "", - CodeReturned: false, - TriggeredAtOrigin: eventOrigin, - }, - }, w + }, args{ + event: &user.HumanEmailCodeAddedEvent{ + BaseEvent: *eventstore.BaseEventFromRepo(&repository.Event{ + InstanceID: instanceID, + AggregateID: userID, + ResourceOwner: sql.NullString{String: orgID}, + CreationDate: time.Now().UTC(), + Typ: user.HumanEmailCodeAddedType, + }), + Code: code, + Expiry: time.Hour, + URLTemplate: "", + CodeReturned: false, + TriggeredAtOrigin: eventOrigin, + AuthRequestID: authRequestID, + }, + }, w + }, }, - }, { - name: "asset url without event trigger url", - test: func(ctrl *gomock.Controller, queries *mock.MockQueries, commands *mock.MockCommands) (f fields, a args, w want) { - givenTemplate := "{{.LogoURL}}" - expectContent := fmt.Sprintf("%s://%s:%d%s/%s/%s", externalProtocol, instancePrimaryDomain, externalPort, assetsPath, policyID, logoURL) - w.message = &messages.Email{ - Recipients: []string{lastEmail}, - Subject: expectMailSubject, - Content: expectContent, - } - codeAlg, code := cryptoValue(t, ctrl, "testcode") - queries.EXPECT().SearchInstanceDomains(gomock.Any(), gomock.Any()).Return(&query.InstanceDomains{ - Domains: []*query.InstanceDomain{{ - Domain: instancePrimaryDomain, - IsPrimary: true, - }}, - }, nil) - expectTemplateQueries(queries, givenTemplate) - commands.EXPECT().HumanEmailVerificationCodeSent(gomock.Any(), orgID, userID).Return(nil) - return fields{ - queries: queries, - commands: commands, - es: eventstore.NewEventstore(&eventstore.Config{ - Querier: es_repo_mock.NewRepo(t).ExpectFilterEvents().MockQuerier, - }), - userDataCrypto: codeAlg, - }, args{ - event: &user.HumanEmailCodeAddedEvent{ - BaseEvent: *eventstore.BaseEventFromRepo(&repository.Event{ - AggregateID: userID, - ResourceOwner: sql.NullString{String: orgID}, - CreationDate: time.Now().UTC(), + { + name: "without event trigger", + test: func(ctrl *gomock.Controller, queries *mock.MockQueries, commands *mock.MockCommands) (f fields, a args, w want) { + _, code := cryptoValue(t, ctrl, "testcode") + queries.EXPECT().SearchInstanceDomains(gomock.Any(), gomock.Any()).Return(&query.InstanceDomains{ + Domains: []*query.InstanceDomain{{ + Domain: instancePrimaryDomain, + IsPrimary: true, + }}, + }, nil) + + commands.EXPECT().RequestNotification(gomock.Any(), orgID, &command.NotificationRequest{ + UserID: userID, + UserResourceOwner: orgID, + TriggerOrigin: fmt.Sprintf("%s://%s:%d", externalProtocol, instancePrimaryDomain, externalPort), + URLTemplate: fmt.Sprintf("%s://%s:%d/ui/login/mail/verification?userID=%s&code={{.Code}}&orgID=%s&authRequestID=%s", + externalProtocol, instancePrimaryDomain, externalPort, userID, orgID, authRequestID), + Code: code, + CodeExpiry: time.Hour, + EventType: user.HumanEmailCodeAddedType, + NotificationType: domain.NotificationTypeEmail, + MessageType: domain.VerifyEmailMessageType, + UnverifiedNotificationChannel: true, + Args: &domain.NotificationArguments{AuthRequestID: authRequestID}, + AggregateID: "", + AggregateResourceOwner: "", + IsOTP: false, + RequiresPreviousDomain: false, + }).Return(nil) + return fields{ + queries: queries, + commands: commands, + es: eventstore.NewEventstore(&eventstore.Config{ + Querier: es_repo_mock.NewRepo(t).ExpectFilterEvents().MockQuerier, }), - Code: code, - Expiry: time.Hour, - URLTemplate: "", - CodeReturned: false, - }, - }, w + }, args{ + event: &user.HumanEmailCodeAddedEvent{ + BaseEvent: *eventstore.BaseEventFromRepo(&repository.Event{ + InstanceID: instanceID, + AggregateID: userID, + ResourceOwner: sql.NullString{String: orgID}, + CreationDate: time.Now().UTC(), + Typ: user.HumanEmailCodeAddedType, + }), + Code: code, + Expiry: time.Hour, + URLTemplate: "", + CodeReturned: false, + AuthRequestID: authRequestID, + }, + }, w + }, }, - }, { - name: "button url with event trigger url", - test: func(ctrl *gomock.Controller, queries *mock.MockQueries, commands *mock.MockCommands) (f fields, a args, w want) { - givenTemplate := "{{.URL}}" - testCode := "testcode" - expectContent := fmt.Sprintf("%s/ui/login/mail/verification?authRequestID=%s&code=%s&orgID=%s&userID=%s", eventOrigin, "", testCode, orgID, userID) - w.message = &messages.Email{ - Recipients: []string{lastEmail}, - Subject: expectMailSubject, - Content: expectContent, - } - codeAlg, code := cryptoValue(t, ctrl, testCode) - expectTemplateQueries(queries, givenTemplate) - commands.EXPECT().HumanEmailVerificationCodeSent(gomock.Any(), orgID, userID).Return(nil) - return fields{ - queries: queries, - commands: commands, - es: eventstore.NewEventstore(&eventstore.Config{ - Querier: es_repo_mock.NewRepo(t).ExpectFilterEvents().MockQuerier, - }), - userDataCrypto: codeAlg, - SMSTokenCrypto: nil, - }, args{ - event: &user.HumanEmailCodeAddedEvent{ - BaseEvent: *eventstore.BaseEventFromRepo(&repository.Event{ - AggregateID: userID, - ResourceOwner: sql.NullString{String: orgID}, - CreationDate: time.Now().UTC(), + { + name: "return code", + test: func(ctrl *gomock.Controller, queries *mock.MockQueries, commands *mock.MockCommands) (f fields, a args, w want) { + w.noOperation = true + _, code := cryptoValue(t, ctrl, "testcode") + return fields{ + queries: queries, + commands: commands, + es: eventstore.NewEventstore(&eventstore.Config{ + Querier: es_repo_mock.NewRepo(t).MockQuerier, }), - Code: code, - Expiry: time.Hour, - URLTemplate: "", - CodeReturned: false, - TriggeredAtOrigin: eventOrigin, - }, - }, w + }, args{ + event: &user.HumanEmailCodeAddedEvent{ + BaseEvent: *eventstore.BaseEventFromRepo(&repository.Event{ + InstanceID: instanceID, + AggregateID: userID, + ResourceOwner: sql.NullString{String: orgID}, + CreationDate: time.Now().UTC(), + Typ: user.HumanEmailCodeAddedType, + }), + Code: code, + Expiry: time.Hour, + URLTemplate: "", + CodeReturned: true, + TriggeredAtOrigin: eventOrigin, + AuthRequestID: authRequestID, + }, + }, w + }, }, - }, { - name: "button url without event trigger url", - test: func(ctrl *gomock.Controller, queries *mock.MockQueries, commands *mock.MockCommands) (f fields, a args, w want) { - givenTemplate := "{{.URL}}" - testCode := "testcode" - expectContent := fmt.Sprintf("%s://%s:%d/ui/login/mail/verification?authRequestID=%s&code=%s&orgID=%s&userID=%s", externalProtocol, instancePrimaryDomain, externalPort, "", testCode, orgID, userID) - w.message = &messages.Email{ - Recipients: []string{lastEmail}, - Subject: expectMailSubject, - Content: expectContent, - } - codeAlg, code := cryptoValue(t, ctrl, testCode) - queries.EXPECT().SearchInstanceDomains(gomock.Any(), gomock.Any()).Return(&query.InstanceDomains{ - Domains: []*query.InstanceDomain{{ - Domain: instancePrimaryDomain, - IsPrimary: true, - }}, - }, nil) - expectTemplateQueries(queries, givenTemplate) - commands.EXPECT().HumanEmailVerificationCodeSent(gomock.Any(), orgID, userID).Return(nil) - return fields{ - queries: queries, - commands: commands, - es: eventstore.NewEventstore(&eventstore.Config{ - Querier: es_repo_mock.NewRepo(t).ExpectFilterEvents().MockQuerier, - }), - userDataCrypto: codeAlg, - }, args{ - event: &user.HumanEmailCodeAddedEvent{ - BaseEvent: *eventstore.BaseEventFromRepo(&repository.Event{ - AggregateID: userID, - ResourceOwner: sql.NullString{String: orgID}, - CreationDate: time.Now().UTC(), - }), - Code: code, - Expiry: time.Hour, - URLTemplate: "", - CodeReturned: false, - }, - }, w - }, - }, { - name: "button url without event trigger url with authRequestID", - test: func(ctrl *gomock.Controller, queries *mock.MockQueries, commands *mock.MockCommands) (f fields, a args, w want) { - givenTemplate := "{{.URL}}" - testCode := "testcode" - expectContent := fmt.Sprintf("%s://%s:%d/ui/login/mail/verification?authRequestID=%s&code=%s&orgID=%s&userID=%s", externalProtocol, instancePrimaryDomain, externalPort, authRequestID, testCode, orgID, userID) - w.message = &messages.Email{ - Recipients: []string{lastEmail}, - Subject: expectMailSubject, - Content: expectContent, - } - codeAlg, code := cryptoValue(t, ctrl, testCode) - queries.EXPECT().SearchInstanceDomains(gomock.Any(), gomock.Any()).Return(&query.InstanceDomains{ - Domains: []*query.InstanceDomain{{ - Domain: instancePrimaryDomain, - IsPrimary: true, - }}, - }, nil) - expectTemplateQueries(queries, givenTemplate) - commands.EXPECT().HumanEmailVerificationCodeSent(gomock.Any(), orgID, userID).Return(nil) - return fields{ - queries: queries, - commands: commands, - es: eventstore.NewEventstore(&eventstore.Config{ - Querier: es_repo_mock.NewRepo(t).ExpectFilterEvents().MockQuerier, - }), - userDataCrypto: codeAlg, - }, args{ - event: &user.HumanEmailCodeAddedEvent{ - BaseEvent: *eventstore.BaseEventFromRepo(&repository.Event{ - AggregateID: userID, - ResourceOwner: sql.NullString{String: orgID}, - CreationDate: time.Now().UTC(), - }), - Code: code, - Expiry: time.Hour, - URLTemplate: "", - CodeReturned: false, - AuthRequestID: authRequestID, - }, - }, w - }, - }, { - name: "button url with url template and event trigger url", - test: func(ctrl *gomock.Controller, queries *mock.MockQueries, commands *mock.MockCommands) (f fields, a args, w want) { - givenTemplate := "{{.URL}}" - urlTemplate := "https://my.custom.url/org/{{.OrgID}}/user/{{.UserID}}/verify/{{.Code}}" - testCode := "testcode" - expectContent := fmt.Sprintf("https://my.custom.url/org/%s/user/%s/verify/%s", orgID, userID, testCode) - w.message = &messages.Email{ - Recipients: []string{lastEmail}, - Subject: expectMailSubject, - Content: expectContent, - } - codeAlg, code := cryptoValue(t, ctrl, testCode) - expectTemplateQueries(queries, givenTemplate) - commands.EXPECT().HumanEmailVerificationCodeSent(gomock.Any(), orgID, userID).Return(nil) - return fields{ - queries: queries, - commands: commands, - es: eventstore.NewEventstore(&eventstore.Config{ - Querier: es_repo_mock.NewRepo(t).ExpectFilterEvents().MockQuerier, - }), - userDataCrypto: codeAlg, - SMSTokenCrypto: nil, - }, args{ - event: &user.HumanEmailCodeAddedEvent{ - BaseEvent: *eventstore.BaseEventFromRepo(&repository.Event{ - AggregateID: userID, - ResourceOwner: sql.NullString{String: orgID}, - CreationDate: time.Now().UTC(), - }), - Code: code, - Expiry: time.Hour, - URLTemplate: urlTemplate, - CodeReturned: false, - TriggeredAtOrigin: eventOrigin, - }, - }, w - }, - }} + } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { ctrl := gomock.NewController(t) @@ -523,6 +329,10 @@ func Test_userNotifier_reduceEmailCodeAdded(t *testing.T) { } else { assert.NoError(t, err) } + if w.noOperation { + assert.Nil(t, stmt.Execute) + return + } err = stmt.Execute(nil, "") if w.err != nil { w.err(t, err) @@ -534,280 +344,189 @@ func Test_userNotifier_reduceEmailCodeAdded(t *testing.T) { } func Test_userNotifier_reducePasswordCodeAdded(t *testing.T) { - expectMailSubject := "Reset password" tests := []struct { name string test func(*gomock.Controller, *mock.MockQueries, *mock.MockCommands) (fields, args, want) - }{{ - name: "asset url with event trigger url", - test: func(ctrl *gomock.Controller, queries *mock.MockQueries, commands *mock.MockCommands) (f fields, a args, w want) { - givenTemplate := "{{.LogoURL}}" - expectContent := fmt.Sprintf("%s%s/%s/%s", eventOrigin, assetsPath, policyID, logoURL) - w.message = &messages.Email{ - Recipients: []string{lastEmail}, - Subject: expectMailSubject, - Content: expectContent, - } - codeAlg, code := cryptoValue(t, ctrl, "testcode") - expectTemplateQueries(queries, givenTemplate) - commands.EXPECT().PasswordCodeSent(gomock.Any(), orgID, userID, &senders.CodeGeneratorInfo{}).Return(nil) - return fields{ - queries: queries, - commands: commands, - es: eventstore.NewEventstore(&eventstore.Config{ - Querier: es_repo_mock.NewRepo(t).ExpectFilterEvents().MockQuerier, - }), - userDataCrypto: codeAlg, - }, args{ - event: &user.HumanPasswordCodeAddedEvent{ - BaseEvent: *eventstore.BaseEventFromRepo(&repository.Event{ - AggregateID: userID, - ResourceOwner: sql.NullString{String: orgID}, - CreationDate: time.Now().UTC(), + }{ + { + name: "with event trigger", + test: func(ctrl *gomock.Controller, queries *mock.MockQueries, commands *mock.MockCommands) (f fields, a args, w want) { + _, code := cryptoValue(t, ctrl, "testcode") + commands.EXPECT().RequestNotification(gomock.Any(), orgID, &command.NotificationRequest{ + UserID: userID, + UserResourceOwner: orgID, + TriggerOrigin: eventOrigin, + URLTemplate: fmt.Sprintf("%s/ui/login/password/init?userID=%s&code={{.Code}}&orgID=%s&authRequestID=%s", + eventOrigin, userID, orgID, authRequestID), + Code: code, + CodeExpiry: time.Hour, + EventType: user.HumanPasswordCodeAddedType, + NotificationType: domain.NotificationTypeEmail, + MessageType: domain.PasswordResetMessageType, + UnverifiedNotificationChannel: true, + Args: &domain.NotificationArguments{AuthRequestID: authRequestID}, + AggregateID: "", + AggregateResourceOwner: "", + IsOTP: false, + RequiresPreviousDomain: false, + }).Return(nil) + return fields{ + queries: queries, + commands: commands, + es: eventstore.NewEventstore(&eventstore.Config{ + Querier: es_repo_mock.NewRepo(t).ExpectFilterEvents().MockQuerier, }), - Code: code, - Expiry: time.Hour, - URLTemplate: "", - CodeReturned: false, - TriggeredAtOrigin: eventOrigin, - }, - }, w + }, args{ + event: &user.HumanPasswordCodeAddedEvent{ + BaseEvent: *eventstore.BaseEventFromRepo(&repository.Event{ + InstanceID: instanceID, + AggregateID: userID, + ResourceOwner: sql.NullString{String: orgID}, + CreationDate: time.Now().UTC(), + Typ: user.HumanPasswordCodeAddedType, + }), + Code: code, + Expiry: time.Hour, + URLTemplate: "", + CodeReturned: false, + TriggeredAtOrigin: eventOrigin, + AuthRequestID: authRequestID, + }, + }, w + }, }, - }, { - name: "asset url without event trigger url", - test: func(ctrl *gomock.Controller, queries *mock.MockQueries, commands *mock.MockCommands) (f fields, a args, w want) { - givenTemplate := "{{.LogoURL}}" - expectContent := fmt.Sprintf("%s://%s:%d%s/%s/%s", externalProtocol, instancePrimaryDomain, externalPort, assetsPath, policyID, logoURL) - w.message = &messages.Email{ - Recipients: []string{lastEmail}, - Subject: expectMailSubject, - Content: expectContent, - } - codeAlg, code := cryptoValue(t, ctrl, "testcode") - queries.EXPECT().SearchInstanceDomains(gomock.Any(), gomock.Any()).Return(&query.InstanceDomains{ - Domains: []*query.InstanceDomain{{ - Domain: instancePrimaryDomain, - IsPrimary: true, - }}, - }, nil) - expectTemplateQueries(queries, givenTemplate) - commands.EXPECT().PasswordCodeSent(gomock.Any(), orgID, userID, &senders.CodeGeneratorInfo{}).Return(nil) - return fields{ - queries: queries, - commands: commands, - es: eventstore.NewEventstore(&eventstore.Config{ - Querier: es_repo_mock.NewRepo(t).ExpectFilterEvents().MockQuerier, - }), - userDataCrypto: codeAlg, - }, args{ - event: &user.HumanPasswordCodeAddedEvent{ - BaseEvent: *eventstore.BaseEventFromRepo(&repository.Event{ - AggregateID: userID, - ResourceOwner: sql.NullString{String: orgID}, - CreationDate: time.Now().UTC(), + { + name: "asset url without event trigger url", + test: func(ctrl *gomock.Controller, queries *mock.MockQueries, commands *mock.MockCommands) (f fields, a args, w want) { + _, code := cryptoValue(t, ctrl, "testcode") + queries.EXPECT().SearchInstanceDomains(gomock.Any(), gomock.Any()).Return(&query.InstanceDomains{ + Domains: []*query.InstanceDomain{{ + Domain: instancePrimaryDomain, + IsPrimary: true, + }}, + }, nil) + commands.EXPECT().RequestNotification(gomock.Any(), orgID, &command.NotificationRequest{ + UserID: userID, + UserResourceOwner: orgID, + TriggerOrigin: fmt.Sprintf("%s://%s:%d", externalProtocol, instancePrimaryDomain, externalPort), + URLTemplate: fmt.Sprintf("%s://%s:%d/ui/login/password/init?userID=%s&code={{.Code}}&orgID=%s&authRequestID=%s", + externalProtocol, instancePrimaryDomain, externalPort, userID, orgID, authRequestID), + Code: code, + CodeExpiry: time.Hour, + EventType: user.HumanPasswordCodeAddedType, + NotificationType: domain.NotificationTypeEmail, + MessageType: domain.PasswordResetMessageType, + UnverifiedNotificationChannel: true, + Args: &domain.NotificationArguments{AuthRequestID: authRequestID}, + AggregateID: "", + AggregateResourceOwner: "", + IsOTP: false, + RequiresPreviousDomain: false, + }).Return(nil) + return fields{ + queries: queries, + commands: commands, + es: eventstore.NewEventstore(&eventstore.Config{ + Querier: es_repo_mock.NewRepo(t).ExpectFilterEvents().MockQuerier, }), - Code: code, - Expiry: time.Hour, - URLTemplate: "", - CodeReturned: false, - }, - }, w + }, args{ + event: &user.HumanPasswordCodeAddedEvent{ + BaseEvent: *eventstore.BaseEventFromRepo(&repository.Event{ + InstanceID: instanceID, + AggregateID: userID, + ResourceOwner: sql.NullString{String: orgID}, + CreationDate: time.Now().UTC(), + Typ: user.HumanPasswordCodeAddedType, + }), + Code: code, + Expiry: time.Hour, + URLTemplate: "", + CodeReturned: false, + AuthRequestID: authRequestID, + }, + }, w + }, }, - }, { - name: "button url with event trigger url", - test: func(ctrl *gomock.Controller, queries *mock.MockQueries, commands *mock.MockCommands) (f fields, a args, w want) { - givenTemplate := "{{.URL}}" - testCode := "testcode" - expectContent := fmt.Sprintf("%s/ui/login/password/init?authRequestID=%s&code=%s&orgID=%s&userID=%s", eventOrigin, "", testCode, orgID, userID) - w.message = &messages.Email{ - Recipients: []string{lastEmail}, - Subject: expectMailSubject, - Content: expectContent, - } - codeAlg, code := cryptoValue(t, ctrl, testCode) - expectTemplateQueries(queries, givenTemplate) - commands.EXPECT().PasswordCodeSent(gomock.Any(), orgID, userID, &senders.CodeGeneratorInfo{}).Return(nil) - return fields{ - queries: queries, - commands: commands, - es: eventstore.NewEventstore(&eventstore.Config{ - Querier: es_repo_mock.NewRepo(t).ExpectFilterEvents().MockQuerier, - }), - userDataCrypto: codeAlg, - SMSTokenCrypto: nil, - }, args{ - event: &user.HumanPasswordCodeAddedEvent{ - BaseEvent: *eventstore.BaseEventFromRepo(&repository.Event{ - AggregateID: userID, - ResourceOwner: sql.NullString{String: orgID}, - CreationDate: time.Now().UTC(), + { + name: "external code", + test: func(ctrl *gomock.Controller, queries *mock.MockQueries, commands *mock.MockCommands) (f fields, a args, w want) { + commands.EXPECT().RequestNotification(gomock.Any(), orgID, &command.NotificationRequest{ + UserID: userID, + UserResourceOwner: orgID, + TriggerOrigin: eventOrigin, + URLTemplate: fmt.Sprintf("%s/ui/login/password/init?userID=%s&code={{.Code}}&orgID=%s&authRequestID=%s", + eventOrigin, userID, orgID, authRequestID), + Code: nil, + CodeExpiry: 0, + EventType: user.HumanPasswordCodeAddedType, + NotificationType: domain.NotificationTypeSms, + MessageType: domain.PasswordResetMessageType, + UnverifiedNotificationChannel: true, + Args: &domain.NotificationArguments{AuthRequestID: authRequestID}, + AggregateID: "", + AggregateResourceOwner: "", + IsOTP: false, + RequiresPreviousDomain: false, + }).Return(nil) + return fields{ + queries: queries, + commands: commands, + es: eventstore.NewEventstore(&eventstore.Config{ + Querier: es_repo_mock.NewRepo(t).ExpectFilterEvents().MockQuerier, }), - Code: code, - Expiry: time.Hour, - URLTemplate: "", - CodeReturned: false, - TriggeredAtOrigin: eventOrigin, - }, - }, w + }, args{ + event: &user.HumanPasswordCodeAddedEvent{ + BaseEvent: *eventstore.BaseEventFromRepo(&repository.Event{ + InstanceID: instanceID, + AggregateID: userID, + ResourceOwner: sql.NullString{String: orgID}, + CreationDate: time.Now().UTC(), + Typ: user.HumanPasswordCodeAddedType, + }), + Code: nil, + Expiry: 0, + URLTemplate: "", + CodeReturned: false, + NotificationType: domain.NotificationTypeSms, + GeneratorID: smsProviderID, + TriggeredAtOrigin: eventOrigin, + AuthRequestID: authRequestID, + }, + }, w + }, }, - }, { - name: "button url without event trigger url", - test: func(ctrl *gomock.Controller, queries *mock.MockQueries, commands *mock.MockCommands) (f fields, a args, w want) { - givenTemplate := "{{.URL}}" - testCode := "testcode" - expectContent := fmt.Sprintf("%s://%s:%d/ui/login/password/init?authRequestID=%s&code=%s&orgID=%s&userID=%s", externalProtocol, instancePrimaryDomain, externalPort, "", testCode, orgID, userID) - w.message = &messages.Email{ - Recipients: []string{lastEmail}, - Subject: expectMailSubject, - Content: expectContent, - } - codeAlg, code := cryptoValue(t, ctrl, testCode) - queries.EXPECT().SearchInstanceDomains(gomock.Any(), gomock.Any()).Return(&query.InstanceDomains{ - Domains: []*query.InstanceDomain{{ - Domain: instancePrimaryDomain, - IsPrimary: true, - }}, - }, nil) - expectTemplateQueries(queries, givenTemplate) - commands.EXPECT().PasswordCodeSent(gomock.Any(), orgID, userID, &senders.CodeGeneratorInfo{}).Return(nil) - return fields{ - queries: queries, - commands: commands, - es: eventstore.NewEventstore(&eventstore.Config{ - Querier: es_repo_mock.NewRepo(t).ExpectFilterEvents().MockQuerier, - }), - userDataCrypto: codeAlg, - }, args{ - event: &user.HumanPasswordCodeAddedEvent{ - BaseEvent: *eventstore.BaseEventFromRepo(&repository.Event{ - AggregateID: userID, - ResourceOwner: sql.NullString{String: orgID}, - CreationDate: time.Now().UTC(), + { + name: "return code", + test: func(ctrl *gomock.Controller, queries *mock.MockQueries, commands *mock.MockCommands) (f fields, a args, w want) { + w.noOperation = true + _, code := cryptoValue(t, ctrl, "testcode") + return fields{ + queries: queries, + commands: commands, + es: eventstore.NewEventstore(&eventstore.Config{ + Querier: es_repo_mock.NewRepo(t).MockQuerier, }), - Code: code, - Expiry: time.Hour, - URLTemplate: "", - CodeReturned: false, - }, - }, w + }, args{ + event: &user.HumanPasswordCodeAddedEvent{ + BaseEvent: *eventstore.BaseEventFromRepo(&repository.Event{ + InstanceID: instanceID, + AggregateID: userID, + ResourceOwner: sql.NullString{String: orgID}, + CreationDate: time.Now().UTC(), + Typ: user.HumanPasswordCodeAddedType, + }), + Code: code, + Expiry: 1 * time.Hour, + URLTemplate: "", + CodeReturned: true, + NotificationType: domain.NotificationTypeSms, + GeneratorID: smsProviderID, + TriggeredAtOrigin: eventOrigin, + AuthRequestID: authRequestID, + }, + }, w + }, }, - }, { - name: "button url without event trigger url with authRequestID", - test: func(ctrl *gomock.Controller, queries *mock.MockQueries, commands *mock.MockCommands) (f fields, a args, w want) { - givenTemplate := "{{.URL}}" - testCode := "testcode" - expectContent := fmt.Sprintf("%s://%s:%d/ui/login/password/init?authRequestID=%s&code=%s&orgID=%s&userID=%s", externalProtocol, instancePrimaryDomain, externalPort, authRequestID, testCode, orgID, userID) - w.message = &messages.Email{ - Recipients: []string{lastEmail}, - Subject: expectMailSubject, - Content: expectContent, - } - codeAlg, code := cryptoValue(t, ctrl, testCode) - queries.EXPECT().SearchInstanceDomains(gomock.Any(), gomock.Any()).Return(&query.InstanceDomains{ - Domains: []*query.InstanceDomain{{ - Domain: instancePrimaryDomain, - IsPrimary: true, - }}, - }, nil) - expectTemplateQueries(queries, givenTemplate) - commands.EXPECT().PasswordCodeSent(gomock.Any(), orgID, userID, &senders.CodeGeneratorInfo{}).Return(nil) - return fields{ - queries: queries, - commands: commands, - es: eventstore.NewEventstore(&eventstore.Config{ - Querier: es_repo_mock.NewRepo(t).ExpectFilterEvents().MockQuerier, - }), - userDataCrypto: codeAlg, - }, args{ - event: &user.HumanPasswordCodeAddedEvent{ - BaseEvent: *eventstore.BaseEventFromRepo(&repository.Event{ - AggregateID: userID, - ResourceOwner: sql.NullString{String: orgID}, - CreationDate: time.Now().UTC(), - }), - Code: code, - Expiry: time.Hour, - URLTemplate: "", - CodeReturned: false, - AuthRequestID: authRequestID, - }, - }, w - }, - }, { - name: "button url with url template and event trigger url", - test: func(ctrl *gomock.Controller, queries *mock.MockQueries, commands *mock.MockCommands) (f fields, a args, w want) { - givenTemplate := "{{.URL}}" - urlTemplate := "https://my.custom.url/org/{{.OrgID}}/user/{{.UserID}}/verify/{{.Code}}" - testCode := "testcode" - expectContent := fmt.Sprintf("https://my.custom.url/org/%s/user/%s/verify/%s", orgID, userID, testCode) - w.message = &messages.Email{ - Recipients: []string{lastEmail}, - Subject: expectMailSubject, - Content: expectContent, - } - codeAlg, code := cryptoValue(t, ctrl, testCode) - expectTemplateQueries(queries, givenTemplate) - commands.EXPECT().PasswordCodeSent(gomock.Any(), orgID, userID, &senders.CodeGeneratorInfo{}).Return(nil) - return fields{ - queries: queries, - commands: commands, - es: eventstore.NewEventstore(&eventstore.Config{ - Querier: es_repo_mock.NewRepo(t).ExpectFilterEvents().MockQuerier, - }), - userDataCrypto: codeAlg, - SMSTokenCrypto: nil, - }, args{ - event: &user.HumanPasswordCodeAddedEvent{ - BaseEvent: *eventstore.BaseEventFromRepo(&repository.Event{ - AggregateID: userID, - ResourceOwner: sql.NullString{String: orgID}, - CreationDate: time.Now().UTC(), - }), - Code: code, - Expiry: time.Hour, - URLTemplate: urlTemplate, - CodeReturned: false, - TriggeredAtOrigin: eventOrigin, - }, - }, w - }, - }, { - name: "external code", - test: func(ctrl *gomock.Controller, queries *mock.MockQueries, commands *mock.MockCommands) (f fields, a args, w want) { - givenTemplate := "{{.URL}}" - expectContent := "We received a password reset request. Please use the button below to reset your password. (Code ) If you didn't ask for this mail, please ignore it." - w.messageSMS = &messages.SMS{ - SenderPhoneNumber: "senderNumber", - RecipientPhoneNumber: lastPhone, - Content: expectContent, - } - expectTemplateQueries(queries, givenTemplate) - commands.EXPECT().PasswordCodeSent(gomock.Any(), orgID, userID, &senders.CodeGeneratorInfo{ID: smsProviderID, VerificationID: verificationID}).Return(nil) - return fields{ - queries: queries, - commands: commands, - es: eventstore.NewEventstore(&eventstore.Config{ - Querier: es_repo_mock.NewRepo(t).ExpectFilterEvents().MockQuerier, - }), - SMSTokenCrypto: nil, - }, args{ - event: &user.HumanPasswordCodeAddedEvent{ - BaseEvent: *eventstore.BaseEventFromRepo(&repository.Event{ - AggregateID: userID, - ResourceOwner: sql.NullString{String: orgID}, - CreationDate: time.Now().UTC(), - }), - Code: nil, - Expiry: 0, - URLTemplate: "", - CodeReturned: false, - NotificationType: domain.NotificationTypeSms, - GeneratorID: smsProviderID, - TriggeredAtOrigin: eventOrigin, - }, - }, w - }, - }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { @@ -821,6 +540,10 @@ func Test_userNotifier_reducePasswordCodeAdded(t *testing.T) { } else { assert.NoError(t, err) } + if w.noOperation { + assert.Nil(t, stmt.Execute) + return + } err = stmt.Execute(nil, "") if w.err != nil { w.err(t, err) @@ -832,22 +555,30 @@ func Test_userNotifier_reducePasswordCodeAdded(t *testing.T) { } func Test_userNotifier_reduceDomainClaimed(t *testing.T) { - expectMailSubject := "Domain has been claimed" tests := []struct { name string test func(*gomock.Controller, *mock.MockQueries, *mock.MockCommands) (fields, args, want) }{{ - name: "asset url with event trigger url", + name: "with event trigger", test: func(ctrl *gomock.Controller, queries *mock.MockQueries, commands *mock.MockCommands) (f fields, a args, w want) { - givenTemplate := "{{.LogoURL}}" - expectContent := fmt.Sprintf("%s%s/%s/%s", eventOrigin, assetsPath, policyID, logoURL) - w.message = &messages.Email{ - Recipients: []string{lastEmail}, - Subject: expectMailSubject, - Content: expectContent, - } - expectTemplateQueries(queries, givenTemplate) - commands.EXPECT().UserDomainClaimedSent(gomock.Any(), orgID, userID).Return(nil) + commands.EXPECT().RequestNotification(gomock.Any(), orgID, &command.NotificationRequest{ + UserID: userID, + UserResourceOwner: orgID, + TriggerOrigin: eventOrigin, + URLTemplate: fmt.Sprintf("%s/ui/login/login?orgID=%s", + eventOrigin, orgID), + Code: nil, + CodeExpiry: 0, + EventType: user.UserDomainClaimedType, + NotificationType: domain.NotificationTypeEmail, + MessageType: domain.DomainClaimedMessageType, + UnverifiedNotificationChannel: true, + Args: &domain.NotificationArguments{TempUsername: "newUsername"}, + AggregateID: "", + AggregateResourceOwner: "", + IsOTP: false, + RequiresPreviousDomain: true, + }).Return(nil) return fields{ queries: queries, commands: commands, @@ -857,32 +588,44 @@ func Test_userNotifier_reduceDomainClaimed(t *testing.T) { }, args{ event: &user.DomainClaimedEvent{ BaseEvent: *eventstore.BaseEventFromRepo(&repository.Event{ + InstanceID: instanceID, AggregateID: userID, ResourceOwner: sql.NullString{String: orgID}, CreationDate: time.Now().UTC(), + Typ: user.UserDomainClaimedType, }), TriggeredAtOrigin: eventOrigin, + UserName: "newUsername", }, }, w }, }, { - name: "asset url without event trigger url", + name: "without event trigger", test: func(ctrl *gomock.Controller, queries *mock.MockQueries, commands *mock.MockCommands) (f fields, a args, w want) { - givenTemplate := "{{.LogoURL}}" - expectContent := fmt.Sprintf("%s://%s:%d%s/%s/%s", externalProtocol, instancePrimaryDomain, externalPort, assetsPath, policyID, logoURL) - w.message = &messages.Email{ - Recipients: []string{lastEmail}, - Subject: expectMailSubject, - Content: expectContent, - } queries.EXPECT().SearchInstanceDomains(gomock.Any(), gomock.Any()).Return(&query.InstanceDomains{ Domains: []*query.InstanceDomain{{ Domain: instancePrimaryDomain, IsPrimary: true, }}, }, nil) - expectTemplateQueries(queries, givenTemplate) - commands.EXPECT().UserDomainClaimedSent(gomock.Any(), orgID, userID).Return(nil) + commands.EXPECT().RequestNotification(gomock.Any(), orgID, &command.NotificationRequest{ + UserID: userID, + UserResourceOwner: orgID, + TriggerOrigin: fmt.Sprintf("%s://%s:%d", externalProtocol, instancePrimaryDomain, externalPort), + URLTemplate: fmt.Sprintf("%s://%s:%d/ui/login/login?orgID=%s", + externalProtocol, instancePrimaryDomain, externalPort, orgID), + Code: nil, + CodeExpiry: 0, + EventType: user.UserDomainClaimedType, + NotificationType: domain.NotificationTypeEmail, + MessageType: domain.DomainClaimedMessageType, + UnverifiedNotificationChannel: true, + Args: &domain.NotificationArguments{TempUsername: "newUsername"}, + AggregateID: "", + AggregateResourceOwner: "", + IsOTP: false, + RequiresPreviousDomain: true, + }).Return(nil) return fields{ queries: queries, commands: commands, @@ -892,10 +635,13 @@ func Test_userNotifier_reduceDomainClaimed(t *testing.T) { }, args{ event: &user.DomainClaimedEvent{ BaseEvent: *eventstore.BaseEventFromRepo(&repository.Event{ + InstanceID: instanceID, AggregateID: userID, ResourceOwner: sql.NullString{String: orgID}, CreationDate: time.Now().UTC(), + Typ: user.UserDomainClaimedType, }), + UserName: "newUsername", }, }, w }, @@ -923,207 +669,138 @@ func Test_userNotifier_reduceDomainClaimed(t *testing.T) { } func Test_userNotifier_reducePasswordlessCodeRequested(t *testing.T) { - expectMailSubject := "Add Passwordless Login" tests := []struct { name string test func(*gomock.Controller, *mock.MockQueries, *mock.MockCommands) (fields, args, want) - }{{ - name: "asset url with event trigger url", - test: func(ctrl *gomock.Controller, queries *mock.MockQueries, commands *mock.MockCommands) (f fields, a args, w want) { - givenTemplate := "{{.LogoURL}}" - expectContent := fmt.Sprintf("%s%s/%s/%s", eventOrigin, assetsPath, policyID, logoURL) - w.message = &messages.Email{ - Recipients: []string{lastEmail}, - Subject: expectMailSubject, - Content: expectContent, - } - codeAlg, code := cryptoValue(t, ctrl, "testcode") - expectTemplateQueries(queries, givenTemplate) - commands.EXPECT().HumanPasswordlessInitCodeSent(gomock.Any(), userID, orgID, codeID).Return(nil) - return fields{ - queries: queries, - commands: commands, - es: eventstore.NewEventstore(&eventstore.Config{ - Querier: es_repo_mock.NewRepo(t).ExpectFilterEvents().MockQuerier, - }), - userDataCrypto: codeAlg, - }, args{ - event: &user.HumanPasswordlessInitCodeRequestedEvent{ - BaseEvent: *eventstore.BaseEventFromRepo(&repository.Event{ - AggregateID: userID, - ResourceOwner: sql.NullString{String: orgID}, - CreationDate: time.Now().UTC(), + }{ + { + name: "with event trigger", + test: func(ctrl *gomock.Controller, queries *mock.MockQueries, commands *mock.MockCommands) (f fields, a args, w want) { + _, code := cryptoValue(t, ctrl, "testcode") + commands.EXPECT().RequestNotification(gomock.Any(), orgID, &command.NotificationRequest{ + UserID: userID, + UserResourceOwner: orgID, + TriggerOrigin: eventOrigin, + URLTemplate: fmt.Sprintf("%s/ui/login/login/passwordless/init?userID=%s&orgID=%s&codeID=%s&code={{.Code}}", eventOrigin, userID, orgID, codeID), + Code: code, + CodeExpiry: time.Hour, + EventType: user.HumanPasswordlessInitCodeAddedType, + NotificationType: domain.NotificationTypeEmail, + MessageType: domain.PasswordlessRegistrationMessageType, + UnverifiedNotificationChannel: false, + Args: &domain.NotificationArguments{CodeID: codeID}, + AggregateID: "", + AggregateResourceOwner: "", + IsOTP: false, + RequiresPreviousDomain: false, + }).Return(nil) + return fields{ + queries: queries, + commands: commands, + es: eventstore.NewEventstore(&eventstore.Config{ + Querier: es_repo_mock.NewRepo(t).ExpectFilterEvents().MockQuerier, }), - ID: codeID, - Code: code, - Expiry: time.Hour, - URLTemplate: "", - CodeReturned: false, - TriggeredAtOrigin: eventOrigin, - }, - }, w + }, args{ + event: &user.HumanPasswordlessInitCodeRequestedEvent{ + BaseEvent: *eventstore.BaseEventFromRepo(&repository.Event{ + InstanceID: instanceID, + AggregateID: userID, + ResourceOwner: sql.NullString{String: orgID}, + CreationDate: time.Now().UTC(), + Typ: user.HumanPasswordlessInitCodeAddedType, + }), + ID: codeID, + Code: code, + Expiry: time.Hour, + URLTemplate: "", + CodeReturned: false, + TriggeredAtOrigin: eventOrigin, + }, + }, w + }, }, - }, { - name: "asset url without event trigger url", - test: func(ctrl *gomock.Controller, queries *mock.MockQueries, commands *mock.MockCommands) (f fields, a args, w want) { - givenTemplate := "{{.LogoURL}}" - expectContent := fmt.Sprintf("%s://%s:%d%s/%s/%s", externalProtocol, instancePrimaryDomain, externalPort, assetsPath, policyID, logoURL) - w.message = &messages.Email{ - Recipients: []string{lastEmail}, - Subject: expectMailSubject, - Content: expectContent, - } - codeAlg, code := cryptoValue(t, ctrl, "testcode") - queries.EXPECT().SearchInstanceDomains(gomock.Any(), gomock.Any()).Return(&query.InstanceDomains{ - Domains: []*query.InstanceDomain{{ - Domain: instancePrimaryDomain, - IsPrimary: true, - }}, - }, nil) - expectTemplateQueries(queries, givenTemplate) - commands.EXPECT().HumanPasswordlessInitCodeSent(gomock.Any(), userID, orgID, codeID).Return(nil) - return fields{ - queries: queries, - commands: commands, - es: eventstore.NewEventstore(&eventstore.Config{ - Querier: es_repo_mock.NewRepo(t).ExpectFilterEvents().MockQuerier, - }), - userDataCrypto: codeAlg, - }, args{ - event: &user.HumanPasswordlessInitCodeRequestedEvent{ - BaseEvent: *eventstore.BaseEventFromRepo(&repository.Event{ - AggregateID: userID, - ResourceOwner: sql.NullString{String: orgID}, - CreationDate: time.Now().UTC(), + { + name: "without event trigger", + test: func(ctrl *gomock.Controller, queries *mock.MockQueries, commands *mock.MockCommands) (f fields, a args, w want) { + _, code := cryptoValue(t, ctrl, "testCode") + queries.EXPECT().SearchInstanceDomains(gomock.Any(), gomock.Any()).Return(&query.InstanceDomains{ + Domains: []*query.InstanceDomain{{ + Domain: instancePrimaryDomain, + IsPrimary: true, + }}, + }, nil) + commands.EXPECT().RequestNotification(gomock.Any(), orgID, &command.NotificationRequest{ + UserID: userID, + UserResourceOwner: orgID, + TriggerOrigin: fmt.Sprintf("%s://%s:%d", externalProtocol, instancePrimaryDomain, externalPort), + URLTemplate: fmt.Sprintf("%s://%s:%d/ui/login/login/passwordless/init?userID=%s&orgID=%s&codeID=%s&code={{.Code}}", externalProtocol, instancePrimaryDomain, externalPort, userID, orgID, codeID), + Code: code, + CodeExpiry: time.Hour, + EventType: user.HumanPasswordlessInitCodeAddedType, + NotificationType: domain.NotificationTypeEmail, + MessageType: domain.PasswordlessRegistrationMessageType, + UnverifiedNotificationChannel: false, + Args: &domain.NotificationArguments{CodeID: codeID}, + AggregateID: "", + AggregateResourceOwner: "", + IsOTP: false, + RequiresPreviousDomain: false, + }).Return(nil) + return fields{ + queries: queries, + commands: commands, + es: eventstore.NewEventstore(&eventstore.Config{ + Querier: es_repo_mock.NewRepo(t).ExpectFilterEvents().MockQuerier, }), - ID: codeID, - Code: code, - Expiry: time.Hour, - URLTemplate: "", - CodeReturned: false, - }, - }, w + }, args{ + event: &user.HumanPasswordlessInitCodeRequestedEvent{ + BaseEvent: *eventstore.BaseEventFromRepo(&repository.Event{ + InstanceID: instanceID, + AggregateID: userID, + ResourceOwner: sql.NullString{String: orgID}, + CreationDate: time.Now().UTC(), + Typ: user.HumanPasswordlessInitCodeAddedType, + }), + ID: codeID, + Code: code, + Expiry: time.Hour, + URLTemplate: "", + CodeReturned: false, + }, + }, w + }, }, - }, { - name: "button url with event trigger url", - test: func(ctrl *gomock.Controller, queries *mock.MockQueries, commands *mock.MockCommands) (f fields, a args, w want) { - givenTemplate := "{{.URL}}" - testCode := "testcode" - codeAlg, code := cryptoValue(t, ctrl, testCode) - expectContent := fmt.Sprintf("%s/ui/login/login/passwordless/init?userID=%s&orgID=%s&codeID=%s&code=%s", eventOrigin, userID, orgID, codeID, testCode) - w.message = &messages.Email{ - Recipients: []string{lastEmail}, - Subject: expectMailSubject, - Content: expectContent, - } - expectTemplateQueries(queries, givenTemplate) - commands.EXPECT().HumanPasswordlessInitCodeSent(gomock.Any(), userID, orgID, codeID).Return(nil) - return fields{ - queries: queries, - commands: commands, - es: eventstore.NewEventstore(&eventstore.Config{ - Querier: es_repo_mock.NewRepo(t).ExpectFilterEvents().MockQuerier, - }), - userDataCrypto: codeAlg, - SMSTokenCrypto: nil, - }, args{ - event: &user.HumanPasswordlessInitCodeRequestedEvent{ - BaseEvent: *eventstore.BaseEventFromRepo(&repository.Event{ - AggregateID: userID, - ResourceOwner: sql.NullString{String: orgID}, - CreationDate: time.Now().UTC(), + { + name: "return code", + test: func(ctrl *gomock.Controller, queries *mock.MockQueries, commands *mock.MockCommands) (f fields, a args, w want) { + w.noOperation = true + _, code := cryptoValue(t, ctrl, "testcode") + return fields{ + queries: queries, + commands: commands, + es: eventstore.NewEventstore(&eventstore.Config{ + Querier: es_repo_mock.NewRepo(t).MockQuerier, }), - ID: codeID, - Code: code, - Expiry: time.Hour, - URLTemplate: "", - CodeReturned: false, - TriggeredAtOrigin: eventOrigin, - }, - }, w + }, args{ + event: &user.HumanPasswordlessInitCodeRequestedEvent{ + BaseEvent: *eventstore.BaseEventFromRepo(&repository.Event{ + InstanceID: instanceID, + AggregateID: userID, + ResourceOwner: sql.NullString{String: orgID}, + CreationDate: time.Now().UTC(), + Typ: user.HumanPasswordlessInitCodeAddedType, + }), + ID: codeID, + Code: code, + Expiry: time.Hour, + URLTemplate: "", + CodeReturned: true, + TriggeredAtOrigin: eventOrigin, + }, + }, w + }, }, - }, { - name: "button url without event trigger url", - test: func(ctrl *gomock.Controller, queries *mock.MockQueries, commands *mock.MockCommands) (f fields, a args, w want) { - givenTemplate := "{{.URL}}" - testCode := "testcode" - codeAlg, code := cryptoValue(t, ctrl, testCode) - expectContent := fmt.Sprintf("%s://%s:%d/ui/login/login/passwordless/init?userID=%s&orgID=%s&codeID=%s&code=%s", externalProtocol, instancePrimaryDomain, externalPort, userID, orgID, codeID, testCode) - w.message = &messages.Email{ - Recipients: []string{lastEmail}, - Subject: expectMailSubject, - Content: expectContent, - } - queries.EXPECT().SearchInstanceDomains(gomock.Any(), gomock.Any()).Return(&query.InstanceDomains{ - Domains: []*query.InstanceDomain{{ - Domain: instancePrimaryDomain, - IsPrimary: true, - }}, - }, nil) - expectTemplateQueries(queries, givenTemplate) - commands.EXPECT().HumanPasswordlessInitCodeSent(gomock.Any(), userID, orgID, codeID).Return(nil) - return fields{ - queries: queries, - commands: commands, - es: eventstore.NewEventstore(&eventstore.Config{ - Querier: es_repo_mock.NewRepo(t).ExpectFilterEvents().MockQuerier, - }), - userDataCrypto: codeAlg, - }, args{ - event: &user.HumanPasswordlessInitCodeRequestedEvent{ - BaseEvent: *eventstore.BaseEventFromRepo(&repository.Event{ - AggregateID: userID, - ResourceOwner: sql.NullString{String: orgID}, - CreationDate: time.Now().UTC(), - }), - ID: codeID, - Code: code, - Expiry: time.Hour, - URLTemplate: "", - CodeReturned: false, - }, - }, w - }, - }, { - name: "button url with url template and event trigger url", - test: func(ctrl *gomock.Controller, queries *mock.MockQueries, commands *mock.MockCommands) (f fields, a args, w want) { - givenTemplate := "{{.URL}}" - urlTemplate := "https://my.custom.url/org/{{.OrgID}}/user/{{.UserID}}/verify/{{.Code}}" - testCode := "testcode" - expectContent := fmt.Sprintf("https://my.custom.url/org/%s/user/%s/verify/%s", orgID, userID, testCode) - w.message = &messages.Email{ - Recipients: []string{lastEmail}, - Subject: expectMailSubject, - Content: expectContent, - } - codeAlg, code := cryptoValue(t, ctrl, testCode) - expectTemplateQueries(queries, givenTemplate) - commands.EXPECT().HumanPasswordlessInitCodeSent(gomock.Any(), userID, orgID, codeID).Return(nil) - return fields{ - queries: queries, - commands: commands, - es: eventstore.NewEventstore(&eventstore.Config{ - Querier: es_repo_mock.NewRepo(t).ExpectFilterEvents().MockQuerier, - }), - userDataCrypto: codeAlg, - SMSTokenCrypto: nil, - }, args{ - event: &user.HumanPasswordlessInitCodeRequestedEvent{ - BaseEvent: *eventstore.BaseEventFromRepo(&repository.Event{ - AggregateID: userID, - ResourceOwner: sql.NullString{String: orgID}, - CreationDate: time.Now().UTC(), - }), - ID: codeID, - Code: code, - Expiry: time.Hour, - URLTemplate: urlTemplate, - CodeReturned: false, - TriggeredAtOrigin: eventOrigin, - }, - }, w - }, - }} + } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { ctrl := gomock.NewController(t) @@ -1136,6 +813,10 @@ func Test_userNotifier_reducePasswordlessCodeRequested(t *testing.T) { } else { assert.NoError(t, err) } + if w.noOperation { + assert.Nil(t, stmt.Execute) + return + } err = stmt.Execute(nil, "") if w.err != nil { w.err(t, err) @@ -1147,80 +828,127 @@ func Test_userNotifier_reducePasswordlessCodeRequested(t *testing.T) { } func Test_userNotifier_reducePasswordChanged(t *testing.T) { - expectMailSubject := "Password of user has changed" tests := []struct { name string test func(*gomock.Controller, *mock.MockQueries, *mock.MockCommands) (fields, args, want) - }{{ - name: "asset url with event trigger url", - test: func(ctrl *gomock.Controller, queries *mock.MockQueries, commands *mock.MockCommands) (f fields, a args, w want) { - givenTemplate := "{{.LogoURL}}" - expectContent := fmt.Sprintf("%s%s/%s/%s", eventOrigin, assetsPath, policyID, logoURL) - w.message = &messages.Email{ - Recipients: []string{lastEmail}, - Subject: expectMailSubject, - Content: expectContent, - } - queries.EXPECT().NotificationPolicyByOrg(gomock.Any(), gomock.Any(), orgID, gomock.Any()).Return(&query.NotificationPolicy{ - PasswordChange: true, - }, nil) - expectTemplateQueries(queries, givenTemplate) - commands.EXPECT().PasswordChangeSent(gomock.Any(), orgID, userID).Return(nil) - return fields{ - queries: queries, - commands: commands, - es: eventstore.NewEventstore(&eventstore.Config{ - Querier: es_repo_mock.NewRepo(t).ExpectFilterEvents().MockQuerier, - }), - }, args{ - event: &user.HumanPasswordChangedEvent{ - BaseEvent: *eventstore.BaseEventFromRepo(&repository.Event{ - AggregateID: userID, - ResourceOwner: sql.NullString{String: orgID}, - CreationDate: time.Now().UTC(), + }{ + { + name: "with event trigger", + test: func(ctrl *gomock.Controller, queries *mock.MockQueries, commands *mock.MockCommands) (f fields, a args, w want) { + queries.EXPECT().NotificationPolicyByOrg(gomock.Any(), gomock.Any(), orgID, gomock.Any()).Return(&query.NotificationPolicy{ + PasswordChange: true, + }, nil) + commands.EXPECT().RequestNotification(gomock.Any(), orgID, &command.NotificationRequest{ + UserID: userID, + UserResourceOwner: orgID, + TriggerOrigin: eventOrigin, + URLTemplate: fmt.Sprintf("%s/ui/console?login_hint={{.PreferredLoginName}}", eventOrigin), + Code: nil, + CodeExpiry: 0, + EventType: user.HumanPasswordChangedType, + NotificationType: domain.NotificationTypeEmail, + MessageType: domain.PasswordChangeMessageType, + UnverifiedNotificationChannel: true, + Args: nil, + AggregateID: "", + AggregateResourceOwner: "", + IsOTP: false, + RequiresPreviousDomain: false, + }).Return(nil) + return fields{ + queries: queries, + commands: commands, + es: eventstore.NewEventstore(&eventstore.Config{ + Querier: es_repo_mock.NewRepo(t).ExpectFilterEvents().MockQuerier, }), - TriggeredAtOrigin: eventOrigin, - }, - }, w + }, args{ + event: &user.HumanPasswordChangedEvent{ + BaseEvent: *eventstore.BaseEventFromRepo(&repository.Event{ + InstanceID: instanceID, + AggregateID: userID, + ResourceOwner: sql.NullString{String: orgID}, + CreationDate: time.Now().UTC(), + Typ: user.HumanPasswordChangedType, + }), + TriggeredAtOrigin: eventOrigin, + }, + }, w + }, }, - }, { - name: "asset url without event trigger url", - test: func(ctrl *gomock.Controller, queries *mock.MockQueries, commands *mock.MockCommands) (f fields, a args, w want) { - givenTemplate := "{{.LogoURL}}" - expectContent := fmt.Sprintf("%s://%s:%d%s/%s/%s", externalProtocol, instancePrimaryDomain, externalPort, assetsPath, policyID, logoURL) - w.message = &messages.Email{ - Recipients: []string{lastEmail}, - Subject: expectMailSubject, - Content: expectContent, - } - queries.EXPECT().NotificationPolicyByOrg(gomock.Any(), gomock.Any(), orgID, gomock.Any()).Return(&query.NotificationPolicy{ - PasswordChange: true, - }, nil) - queries.EXPECT().SearchInstanceDomains(gomock.Any(), gomock.Any()).Return(&query.InstanceDomains{ - Domains: []*query.InstanceDomain{{ - Domain: instancePrimaryDomain, - IsPrimary: true, - }}, - }, nil) - expectTemplateQueries(queries, givenTemplate) - commands.EXPECT().PasswordChangeSent(gomock.Any(), orgID, userID).Return(nil) - return fields{ - queries: queries, - commands: commands, - es: eventstore.NewEventstore(&eventstore.Config{ - Querier: es_repo_mock.NewRepo(t).ExpectFilterEvents().MockQuerier, - }), - }, args{ - event: &user.HumanPasswordChangedEvent{ - BaseEvent: *eventstore.BaseEventFromRepo(&repository.Event{ - AggregateID: userID, - ResourceOwner: sql.NullString{String: orgID}, - CreationDate: time.Now().UTC(), + { + name: "without event trigger", + test: func(ctrl *gomock.Controller, queries *mock.MockQueries, commands *mock.MockCommands) (f fields, a args, w want) { + queries.EXPECT().NotificationPolicyByOrg(gomock.Any(), gomock.Any(), orgID, gomock.Any()).Return(&query.NotificationPolicy{ + PasswordChange: true, + }, nil) + queries.EXPECT().SearchInstanceDomains(gomock.Any(), gomock.Any()).Return(&query.InstanceDomains{ + Domains: []*query.InstanceDomain{{ + Domain: instancePrimaryDomain, + IsPrimary: true, + }}, + }, nil) + commands.EXPECT().RequestNotification(gomock.Any(), orgID, &command.NotificationRequest{ + UserID: userID, + UserResourceOwner: orgID, + TriggerOrigin: fmt.Sprintf("%s://%s:%d", externalProtocol, instancePrimaryDomain, externalPort), + URLTemplate: fmt.Sprintf("%s://%s:%d/ui/console?login_hint={{.PreferredLoginName}}", + externalProtocol, instancePrimaryDomain, externalPort), + Code: nil, + CodeExpiry: 0, + EventType: user.HumanPasswordChangedType, + NotificationType: domain.NotificationTypeEmail, + MessageType: domain.PasswordChangeMessageType, + UnverifiedNotificationChannel: true, + Args: nil, + AggregateID: "", + AggregateResourceOwner: "", + IsOTP: false, + RequiresPreviousDomain: false, + }).Return(nil) + return fields{ + queries: queries, + commands: commands, + es: eventstore.NewEventstore(&eventstore.Config{ + Querier: es_repo_mock.NewRepo(t).ExpectFilterEvents().MockQuerier, }), - }, - }, w + }, args{ + event: &user.HumanPasswordChangedEvent{ + BaseEvent: *eventstore.BaseEventFromRepo(&repository.Event{ + InstanceID: instanceID, + AggregateID: userID, + ResourceOwner: sql.NullString{String: orgID}, + CreationDate: time.Now().UTC(), + Typ: user.HumanPasswordChangedType, + }), + }, + }, w + }, + }, { + name: "no notification", + test: func(ctrl *gomock.Controller, queries *mock.MockQueries, commands *mock.MockCommands) (f fields, a args, w want) { + queries.EXPECT().NotificationPolicyByOrg(gomock.Any(), gomock.Any(), orgID, gomock.Any()).Return(&query.NotificationPolicy{ + PasswordChange: false, + }, nil) + return fields{ + queries: queries, + commands: commands, + es: eventstore.NewEventstore(&eventstore.Config{ + Querier: es_repo_mock.NewRepo(t).ExpectFilterEvents().MockQuerier, + }), + }, args{ + event: &user.HumanPasswordChangedEvent{ + BaseEvent: *eventstore.BaseEventFromRepo(&repository.Event{ + InstanceID: instanceID, + AggregateID: userID, + ResourceOwner: sql.NullString{String: orgID}, + CreationDate: time.Now().UTC(), + Typ: user.HumanPasswordChangedType, + }), + }, + }, w + }, }, - }} + } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { ctrl := gomock.NewController(t) @@ -1244,213 +972,235 @@ func Test_userNotifier_reducePasswordChanged(t *testing.T) { } func Test_userNotifier_reduceOTPEmailChallenged(t *testing.T) { - expectMailSubject := "Verify One-Time Password" tests := []struct { name string test func(*gomock.Controller, *mock.MockQueries, *mock.MockCommands) (fields, args, want) - }{{ - name: "asset url with event trigger url", - test: func(ctrl *gomock.Controller, queries *mock.MockQueries, commands *mock.MockCommands) (f fields, a args, w want) { - givenTemplate := "{{.LogoURL}}" - expectContent := fmt.Sprintf("%s%s/%s/%s", eventOrigin, assetsPath, policyID, logoURL) - w.message = &messages.Email{ - Recipients: []string{verifiedEmail}, - Subject: expectMailSubject, - Content: expectContent, - } - codeAlg, code := cryptoValue(t, ctrl, "testcode") - expectTemplateQueries(queries, givenTemplate) - queries.EXPECT().SessionByID(gomock.Any(), gomock.Any(), userID, gomock.Any()).Return(&query.Session{}, nil) - commands.EXPECT().OTPEmailSent(gomock.Any(), userID, orgID).Return(nil) - return fields{ - queries: queries, - commands: commands, - es: eventstore.NewEventstore(&eventstore.Config{ - Querier: es_repo_mock.NewRepo(t).ExpectFilterEvents().MockQuerier, - }), - userDataCrypto: codeAlg, - }, args{ - event: &session.OTPEmailChallengedEvent{ - BaseEvent: *eventstore.BaseEventFromRepo(&repository.Event{ - AggregateID: userID, - ResourceOwner: sql.NullString{String: orgID}, - CreationDate: time.Now().UTC(), - }), - Code: code, - Expiry: time.Hour, - URLTmpl: "", - ReturnCode: false, - TriggeredAtOrigin: eventOrigin, + }{ + { + name: "url with event trigger", + test: func(ctrl *gomock.Controller, queries *mock.MockQueries, commands *mock.MockCommands) (f fields, a args, w want) { + _, code := cryptoValue(t, ctrl, "testCode") + queries.EXPECT().SessionByID(gomock.Any(), gomock.Any(), sessionID, gomock.Any()).Return(&query.Session{ + ID: sessionID, + ResourceOwner: instanceID, + UserFactor: query.SessionUserFactor{ + UserID: userID, + ResourceOwner: orgID, }, - }, w - }, - }, { - name: "asset url without event trigger url", - test: func(ctrl *gomock.Controller, queries *mock.MockQueries, commands *mock.MockCommands) (f fields, a args, w want) { - givenTemplate := "{{.LogoURL}}" - expectContent := fmt.Sprintf("%s://%s:%d%s/%s/%s", externalProtocol, instancePrimaryDomain, externalPort, assetsPath, policyID, logoURL) - w.message = &messages.Email{ - Recipients: []string{verifiedEmail}, - Subject: expectMailSubject, - Content: expectContent, - } - codeAlg, code := cryptoValue(t, ctrl, "testcode") - expectTemplateQueries(queries, givenTemplate) - queries.EXPECT().SessionByID(gomock.Any(), gomock.Any(), userID, gomock.Any()).Return(&query.Session{}, nil) - queries.EXPECT().SearchInstanceDomains(gomock.Any(), gomock.Any()).Return(&query.InstanceDomains{ - Domains: []*query.InstanceDomain{{ - Domain: instancePrimaryDomain, - IsPrimary: true, - }}, - }, nil) - commands.EXPECT().OTPEmailSent(gomock.Any(), userID, orgID).Return(nil) - return fields{ - queries: queries, - commands: commands, - es: eventstore.NewEventstore(&eventstore.Config{ - Querier: es_repo_mock.NewRepo(t).ExpectFilterEvents().MockQuerier, - }), - userDataCrypto: codeAlg, - }, args{ - event: &session.OTPEmailChallengedEvent{ - BaseEvent: *eventstore.BaseEventFromRepo(&repository.Event{ - AggregateID: userID, - ResourceOwner: sql.NullString{String: orgID}, - CreationDate: time.Now().UTC(), - }), - Code: code, - Expiry: time.Hour, - URLTmpl: "", - ReturnCode: false, + }, nil) + commands.EXPECT().RequestNotification(gomock.Any(), orgID, &command.NotificationRequest{ + UserID: userID, + UserResourceOwner: orgID, + TriggerOrigin: eventOrigin, + URLTemplate: fmt.Sprintf("%s/otp/verify?loginName={{.LoginName}}&code={{.Code}}", eventOrigin), + Code: code, + CodeExpiry: time.Hour, + EventType: session.OTPEmailChallengedType, + NotificationType: domain.NotificationTypeEmail, + MessageType: domain.VerifyEmailOTPMessageType, + UnverifiedNotificationChannel: false, + Args: &domain.NotificationArguments{ + Domain: eventOriginDomain, + Expiry: 1 * time.Hour, + Origin: eventOrigin, + SessionID: sessionID, }, - }, w - }, - }, { - name: "button url with event trigger url", - test: func(ctrl *gomock.Controller, queries *mock.MockQueries, commands *mock.MockCommands) (f fields, a args, w want) { - givenTemplate := "{{.URL}}" - testCode := "testcode" - expectContent := fmt.Sprintf("%s/otp/verify?loginName=%s&code=%s", eventOrigin, preferredLoginName, testCode) - w.message = &messages.Email{ - Recipients: []string{verifiedEmail}, - Subject: expectMailSubject, - Content: expectContent, - } - codeAlg, code := cryptoValue(t, ctrl, testCode) - expectTemplateQueries(queries, givenTemplate) - queries.EXPECT().SessionByID(gomock.Any(), gomock.Any(), userID, gomock.Any()).Return(&query.Session{}, nil) - commands.EXPECT().OTPEmailSent(gomock.Any(), userID, orgID).Return(nil) - return fields{ - queries: queries, - commands: commands, - es: eventstore.NewEventstore(&eventstore.Config{ - Querier: es_repo_mock.NewRepo(t).ExpectFilterEvents().MockQuerier, - }), - userDataCrypto: codeAlg, - SMSTokenCrypto: nil, - }, args{ - event: &session.OTPEmailChallengedEvent{ - BaseEvent: *eventstore.BaseEventFromRepo(&repository.Event{ - AggregateID: userID, - ResourceOwner: sql.NullString{String: orgID}, - CreationDate: time.Now().UTC(), + AggregateID: sessionID, + AggregateResourceOwner: instanceID, + IsOTP: true, + RequiresPreviousDomain: false, + }).Return(nil) + return fields{ + queries: queries, + commands: commands, + es: eventstore.NewEventstore(&eventstore.Config{ + Querier: es_repo_mock.NewRepo(t).ExpectFilterEvents().MockQuerier, }), - Code: code, - Expiry: time.Hour, - URLTmpl: "", - ReturnCode: false, - TriggeredAtOrigin: eventOrigin, - }, - }, w + }, args{ + event: &session.OTPEmailChallengedEvent{ + BaseEvent: *eventstore.BaseEventFromRepo(&repository.Event{ + InstanceID: instanceID, + AggregateID: sessionID, + ResourceOwner: sql.NullString{String: instanceID}, + CreationDate: time.Now().UTC(), + Typ: session.OTPEmailChallengedType, + }), + Code: code, + Expiry: time.Hour, + URLTmpl: "", + ReturnCode: false, + TriggeredAtOrigin: eventOrigin, + }, + }, w + }, }, - }, { - name: "button url without event trigger url", - test: func(ctrl *gomock.Controller, queries *mock.MockQueries, commands *mock.MockCommands) (f fields, a args, w want) { - givenTemplate := "{{.URL}}" - testCode := "testcode" - expectContent := fmt.Sprintf("%s://%s:%d/otp/verify?loginName=%s&code=%s", externalProtocol, instancePrimaryDomain, externalPort, preferredLoginName, testCode) - w.message = &messages.Email{ - Recipients: []string{verifiedEmail}, - Subject: expectMailSubject, - Content: expectContent, - } - codeAlg, code := cryptoValue(t, ctrl, testCode) - queries.EXPECT().SearchInstanceDomains(gomock.Any(), gomock.Any()).Return(&query.InstanceDomains{ - Domains: []*query.InstanceDomain{{ - Domain: instancePrimaryDomain, - IsPrimary: true, - }}, - }, nil) - expectTemplateQueries(queries, givenTemplate) - queries.EXPECT().SessionByID(gomock.Any(), gomock.Any(), userID, gomock.Any()).Return(&query.Session{}, nil) - commands.EXPECT().OTPEmailSent(gomock.Any(), userID, orgID).Return(nil) - return fields{ - queries: queries, - commands: commands, - es: eventstore.NewEventstore(&eventstore.Config{ - Querier: es_repo_mock.NewRepo(t).ExpectFilterEvents().MockQuerier, - }), - userDataCrypto: codeAlg, - }, args{ - event: &session.OTPEmailChallengedEvent{ - BaseEvent: *eventstore.BaseEventFromRepo(&repository.Event{ - AggregateID: userID, - ResourceOwner: sql.NullString{String: orgID}, - CreationDate: time.Now().UTC(), + { + name: "without event trigger", + test: func(ctrl *gomock.Controller, queries *mock.MockQueries, commands *mock.MockCommands) (f fields, a args, w want) { + _, code := cryptoValue(t, ctrl, "testCode") + queries.EXPECT().SearchInstanceDomains(gomock.Any(), gomock.Any()).Return(&query.InstanceDomains{ + Domains: []*query.InstanceDomain{{ + Domain: instancePrimaryDomain, + IsPrimary: true, + }}, + }, nil) + queries.EXPECT().SessionByID(gomock.Any(), gomock.Any(), sessionID, gomock.Any()).Return(&query.Session{ + ID: sessionID, + ResourceOwner: instanceID, + UserFactor: query.SessionUserFactor{ + UserID: userID, + ResourceOwner: orgID, + }, + }, nil) + commands.EXPECT().RequestNotification(gomock.Any(), orgID, &command.NotificationRequest{ + UserID: userID, + UserResourceOwner: orgID, + TriggerOrigin: fmt.Sprintf("%s://%s:%d", externalProtocol, instancePrimaryDomain, externalPort), + URLTemplate: fmt.Sprintf("%s://%s:%d/otp/verify?loginName={{.LoginName}}&code={{.Code}}", externalProtocol, instancePrimaryDomain, externalPort), + Code: code, + CodeExpiry: time.Hour, + EventType: session.OTPEmailChallengedType, + NotificationType: domain.NotificationTypeEmail, + MessageType: domain.VerifyEmailOTPMessageType, + UnverifiedNotificationChannel: false, + Args: &domain.NotificationArguments{ + Domain: instancePrimaryDomain, + Expiry: 1 * time.Hour, + Origin: fmt.Sprintf("%s://%s:%d", externalProtocol, instancePrimaryDomain, externalPort), + SessionID: sessionID, + }, + AggregateID: sessionID, + AggregateResourceOwner: instanceID, + IsOTP: true, + RequiresPreviousDomain: false, + }).Return(nil) + return fields{ + queries: queries, + commands: commands, + es: eventstore.NewEventstore(&eventstore.Config{ + Querier: es_repo_mock.NewRepo(t).ExpectFilterEvents().MockQuerier, }), - Code: code, - Expiry: time.Hour, - ReturnCode: false, - }, - }, w + }, args{ + event: &session.OTPEmailChallengedEvent{ + BaseEvent: *eventstore.BaseEventFromRepo(&repository.Event{ + InstanceID: instanceID, + AggregateID: sessionID, + ResourceOwner: sql.NullString{String: instanceID}, + CreationDate: time.Now().UTC(), + Typ: session.OTPEmailChallengedType, + }), + Code: code, + Expiry: time.Hour, + ReturnCode: false, + }, + }, w + }, }, - }, { - name: "button url with url template and event trigger url", - test: func(ctrl *gomock.Controller, queries *mock.MockQueries, commands *mock.MockCommands) (f fields, a args, w want) { - givenTemplate := "{{.URL}}" - urlTemplate := "https://my.custom.url/user/{{.LoginName}}/verify" - testCode := "testcode" - expectContent := fmt.Sprintf("https://my.custom.url/user/%s/verify", preferredLoginName) - w.message = &messages.Email{ - Recipients: []string{verifiedEmail}, - Subject: expectMailSubject, - Content: expectContent, - } - codeAlg, code := cryptoValue(t, ctrl, testCode) - expectTemplateQueries(queries, givenTemplate) - queries.EXPECT().SessionByID(gomock.Any(), gomock.Any(), userID, gomock.Any()).Return(&query.Session{}, nil) - commands.EXPECT().OTPEmailSent(gomock.Any(), userID, orgID).Return(nil) - return fields{ - queries: queries, - commands: commands, - es: eventstore.NewEventstore(&eventstore.Config{ - Querier: es_repo_mock.NewRepo(t).ExpectFilterEvents().MockQuerier, - }), - userDataCrypto: codeAlg, - SMSTokenCrypto: nil, - }, args{ - event: &session.OTPEmailChallengedEvent{ - BaseEvent: *eventstore.BaseEventFromRepo(&repository.Event{ - AggregateID: userID, - ResourceOwner: sql.NullString{String: orgID}, - CreationDate: time.Now().UTC(), + { + name: "return code", + test: func(ctrl *gomock.Controller, queries *mock.MockQueries, commands *mock.MockCommands) (f fields, a args, w want) { + w.noOperation = true + _, code := cryptoValue(t, ctrl, "testCode") + return fields{ + queries: queries, + commands: commands, + es: eventstore.NewEventstore(&eventstore.Config{ + Querier: es_repo_mock.NewRepo(t).MockQuerier, }), - Code: code, - Expiry: time.Hour, - ReturnCode: false, - URLTmpl: urlTemplate, - TriggeredAtOrigin: eventOrigin, - }, - }, w + }, args{ + event: &session.OTPEmailChallengedEvent{ + BaseEvent: *eventstore.BaseEventFromRepo(&repository.Event{ + InstanceID: instanceID, + AggregateID: sessionID, + ResourceOwner: sql.NullString{String: instanceID}, + CreationDate: time.Now().UTC(), + Typ: session.OTPEmailChallengedType, + }), + Code: code, + Expiry: time.Hour, + URLTmpl: "", + ReturnCode: true, + TriggeredAtOrigin: eventOrigin, + }, + }, w + }, }, - }} + { + name: "url template", + test: func(ctrl *gomock.Controller, queries *mock.MockQueries, commands *mock.MockCommands) (f fields, a args, w want) { + _, code := cryptoValue(t, ctrl, "testCode") + queries.EXPECT().SessionByID(gomock.Any(), gomock.Any(), sessionID, gomock.Any()).Return(&query.Session{ + ID: sessionID, + ResourceOwner: instanceID, + UserFactor: query.SessionUserFactor{ + UserID: userID, + ResourceOwner: orgID, + }, + }, nil) + commands.EXPECT().RequestNotification(gomock.Any(), orgID, &command.NotificationRequest{ + UserID: userID, + UserResourceOwner: orgID, + TriggerOrigin: eventOrigin, + URLTemplate: "/verify-otp?sessionID={{.SessionID}}", + Code: code, + CodeExpiry: time.Hour, + EventType: session.OTPEmailChallengedType, + NotificationType: domain.NotificationTypeEmail, + MessageType: domain.VerifyEmailOTPMessageType, + UnverifiedNotificationChannel: false, + Args: &domain.NotificationArguments{ + Domain: eventOriginDomain, + Expiry: 1 * time.Hour, + Origin: eventOrigin, + SessionID: sessionID, + }, + AggregateID: sessionID, + AggregateResourceOwner: instanceID, + IsOTP: true, + RequiresPreviousDomain: false, + }).Return(nil) + return fields{ + queries: queries, + commands: commands, + es: eventstore.NewEventstore(&eventstore.Config{ + Querier: es_repo_mock.NewRepo(t).ExpectFilterEvents().MockQuerier, + }), + }, args{ + event: &session.OTPEmailChallengedEvent{ + BaseEvent: *eventstore.BaseEventFromRepo(&repository.Event{ + InstanceID: instanceID, + AggregateID: sessionID, + ResourceOwner: sql.NullString{String: instanceID}, + CreationDate: time.Now().UTC(), + Typ: session.OTPEmailChallengedType, + }), + Code: code, + Expiry: time.Hour, + URLTmpl: "/verify-otp?sessionID={{.SessionID}}", + ReturnCode: false, + TriggeredAtOrigin: eventOrigin, + }, + }, w + }, + }, + } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { ctrl := gomock.NewController(t) queries := mock.NewMockQueries(ctrl) commands := mock.NewMockCommands(ctrl) f, a, w := tt.test(ctrl, queries, commands) - _, err := newUserNotifier(t, ctrl, queries, f, a, w).reduceSessionOTPEmailChallenged(a.event) + stmt, err := newUserNotifier(t, ctrl, queries, f, a, w).reduceSessionOTPEmailChallenged(a.event) + if w.err != nil { + w.err(t, err) + } else { + assert.NoError(t, err) + } + if w.noOperation { + assert.Nil(t, stmt.Execute) + return + } + err = stmt.Execute(nil, "") if w.err != nil { w.err(t, err) } else { @@ -1464,86 +1214,212 @@ func Test_userNotifier_reduceOTPSMSChallenged(t *testing.T) { tests := []struct { name string test func(*gomock.Controller, *mock.MockQueries, *mock.MockCommands) (fields, args, want) - }{{ - name: "asset url with event trigger url", - test: func(ctrl *gomock.Controller, queries *mock.MockQueries, commands *mock.MockCommands) (f fields, a args, w want) { - givenTemplate := "{{.LogoURL}}" - testCode := "" - expiry := 0 * time.Hour - expectContent := fmt.Sprintf(`%[1]s is your one-time password for %[2]s. Use it within the next %[3]s. -@%[2]s #%[1]s`, testCode, eventOriginDomain, expiry) - w.messageSMS = &messages.SMS{ - SenderPhoneNumber: "senderNumber", - RecipientPhoneNumber: verifiedPhone, - Content: expectContent, - } - expectTemplateQueriesSMS(queries, givenTemplate) - queries.EXPECT().SessionByID(gomock.Any(), gomock.Any(), userID, gomock.Any()).Return(&query.Session{}, nil) - commands.EXPECT().OTPSMSSent(gomock.Any(), userID, orgID, &senders.CodeGeneratorInfo{ID: smsProviderID, VerificationID: verificationID}).Return(nil) - return fields{ - queries: queries, - commands: commands, - es: eventstore.NewEventstore(&eventstore.Config{ - Querier: es_repo_mock.NewRepo(t).ExpectFilterEvents().MockQuerier, - }), - }, args{ - event: &session.OTPSMSChallengedEvent{ - BaseEvent: *eventstore.BaseEventFromRepo(&repository.Event{ - AggregateID: userID, - ResourceOwner: sql.NullString{String: orgID}, - CreationDate: time.Now().UTC(), - }), - Code: nil, - Expiry: expiry, - CodeReturned: false, - GeneratorID: smsProviderID, - TriggeredAtOrigin: eventOrigin, + }{ + { + name: "with event trigger", + test: func(ctrl *gomock.Controller, queries *mock.MockQueries, commands *mock.MockCommands) (f fields, a args, w want) { + testCode := "testcode" + _, code := cryptoValue(t, ctrl, testCode) + queries.EXPECT().SessionByID(gomock.Any(), gomock.Any(), sessionID, gomock.Any()).Return(&query.Session{ + ID: sessionID, + ResourceOwner: instanceID, + UserFactor: query.SessionUserFactor{ + UserID: userID, + ResourceOwner: orgID, }, - }, w - }, - }, { - name: "asset url without event trigger url", - test: func(ctrl *gomock.Controller, queries *mock.MockQueries, commands *mock.MockCommands) (f fields, a args, w want) { - givenTemplate := "{{.LogoURL}}" - testCode := "" - expiry := 0 * time.Hour - expectContent := fmt.Sprintf(`%[1]s is your one-time password for %[2]s. Use it within the next %[3]s. -@%[2]s #%[1]s`, testCode, instancePrimaryDomain, expiry) - w.messageSMS = &messages.SMS{ - SenderPhoneNumber: "senderNumber", - RecipientPhoneNumber: verifiedPhone, - Content: expectContent, - } - expectTemplateQueriesSMS(queries, givenTemplate) - queries.EXPECT().SessionByID(gomock.Any(), gomock.Any(), userID, gomock.Any()).Return(&query.Session{}, nil) - queries.EXPECT().SearchInstanceDomains(gomock.Any(), gomock.Any()).Return(&query.InstanceDomains{ - Domains: []*query.InstanceDomain{{ - Domain: instancePrimaryDomain, - IsPrimary: true, - }}, - }, nil) - commands.EXPECT().OTPSMSSent(gomock.Any(), userID, orgID, &senders.CodeGeneratorInfo{ID: smsProviderID, VerificationID: verificationID}).Return(nil) - return fields{ - queries: queries, - commands: commands, - es: eventstore.NewEventstore(&eventstore.Config{ - Querier: es_repo_mock.NewRepo(t).ExpectFilterEvents().MockQuerier, - }), - }, args{ - event: &session.OTPSMSChallengedEvent{ - BaseEvent: *eventstore.BaseEventFromRepo(&repository.Event{ - AggregateID: userID, - ResourceOwner: sql.NullString{String: orgID}, - CreationDate: time.Now().UTC(), - }), - Code: nil, - Expiry: expiry, - CodeReturned: false, - GeneratorID: smsProviderID, + }, nil) + commands.EXPECT().RequestNotification(gomock.Any(), orgID, &command.NotificationRequest{ + UserID: userID, + UserResourceOwner: orgID, + TriggerOrigin: eventOrigin, + URLTemplate: "", + Code: code, + CodeExpiry: time.Hour, + EventType: session.OTPSMSChallengedType, + NotificationType: domain.NotificationTypeSms, + MessageType: domain.VerifySMSOTPMessageType, + UnverifiedNotificationChannel: false, + Args: &domain.NotificationArguments{ + Domain: eventOriginDomain, + Expiry: 1 * time.Hour, + Origin: eventOrigin, + SessionID: sessionID, }, - }, w + AggregateID: sessionID, + AggregateResourceOwner: instanceID, + IsOTP: true, + RequiresPreviousDomain: false, + }).Return(nil) + return fields{ + queries: queries, + commands: commands, + es: eventstore.NewEventstore(&eventstore.Config{ + Querier: es_repo_mock.NewRepo(t).ExpectFilterEvents().MockQuerier, + }), + }, args{ + event: &session.OTPSMSChallengedEvent{ + BaseEvent: *eventstore.BaseEventFromRepo(&repository.Event{ + InstanceID: instanceID, + AggregateID: sessionID, + ResourceOwner: sql.NullString{String: instanceID}, + CreationDate: time.Now().UTC(), + Typ: session.OTPSMSChallengedType, + }), + Code: code, + Expiry: 1 * time.Hour, + CodeReturned: false, + TriggeredAtOrigin: eventOrigin, + }, + }, w + }, + }, + { + name: "without event trigger", + test: func(ctrl *gomock.Controller, queries *mock.MockQueries, commands *mock.MockCommands) (f fields, a args, w want) { + testCode := "testcode" + _, code := cryptoValue(t, ctrl, testCode) + queries.EXPECT().SearchInstanceDomains(gomock.Any(), gomock.Any()).Return(&query.InstanceDomains{ + Domains: []*query.InstanceDomain{{ + Domain: instancePrimaryDomain, + IsPrimary: true, + }}, + }, nil) + queries.EXPECT().SessionByID(gomock.Any(), gomock.Any(), sessionID, gomock.Any()).Return(&query.Session{ + ID: sessionID, + ResourceOwner: instanceID, + UserFactor: query.SessionUserFactor{ + UserID: userID, + ResourceOwner: orgID, + }, + }, nil) + commands.EXPECT().RequestNotification(gomock.Any(), orgID, &command.NotificationRequest{ + UserID: userID, + UserResourceOwner: orgID, + TriggerOrigin: fmt.Sprintf("%s://%s:%d", externalProtocol, instancePrimaryDomain, externalPort), + URLTemplate: "", + Code: code, + CodeExpiry: time.Hour, + EventType: session.OTPSMSChallengedType, + NotificationType: domain.NotificationTypeSms, + MessageType: domain.VerifySMSOTPMessageType, + UnverifiedNotificationChannel: false, + Args: &domain.NotificationArguments{ + Domain: instancePrimaryDomain, + Expiry: 1 * time.Hour, + Origin: fmt.Sprintf("%s://%s:%d", externalProtocol, instancePrimaryDomain, externalPort), + SessionID: sessionID, + }, + AggregateID: sessionID, + AggregateResourceOwner: instanceID, + IsOTP: true, + RequiresPreviousDomain: false, + }).Return(nil) + return fields{ + queries: queries, + commands: commands, + es: eventstore.NewEventstore(&eventstore.Config{ + Querier: es_repo_mock.NewRepo(t).ExpectFilterEvents().MockQuerier, + }), + }, args{ + event: &session.OTPSMSChallengedEvent{ + BaseEvent: *eventstore.BaseEventFromRepo(&repository.Event{ + InstanceID: instanceID, + AggregateID: sessionID, + ResourceOwner: sql.NullString{String: instanceID}, + CreationDate: time.Now().UTC(), + Typ: session.OTPSMSChallengedType, + }), + Code: code, + Expiry: 1 * time.Hour, + CodeReturned: false, + }, + }, w + }, + }, + { + name: "external code", + test: func(ctrl *gomock.Controller, queries *mock.MockQueries, commands *mock.MockCommands) (f fields, a args, w want) { + queries.EXPECT().SessionByID(gomock.Any(), gomock.Any(), sessionID, gomock.Any()).Return(&query.Session{ + ID: sessionID, + ResourceOwner: instanceID, + UserFactor: query.SessionUserFactor{ + UserID: userID, + ResourceOwner: orgID, + }, + }, nil) + commands.EXPECT().RequestNotification(gomock.Any(), orgID, &command.NotificationRequest{ + UserID: userID, + UserResourceOwner: orgID, + TriggerOrigin: eventOrigin, + URLTemplate: "", + Code: nil, + CodeExpiry: 0, + EventType: session.OTPSMSChallengedType, + NotificationType: domain.NotificationTypeSms, + MessageType: domain.VerifySMSOTPMessageType, + UnverifiedNotificationChannel: false, + Args: &domain.NotificationArguments{ + Domain: eventOriginDomain, + Expiry: 0 * time.Hour, + Origin: eventOrigin, + SessionID: sessionID, + }, + AggregateID: sessionID, + AggregateResourceOwner: instanceID, + IsOTP: true, + RequiresPreviousDomain: false, + }).Return(nil) + return fields{ + queries: queries, + commands: commands, + es: eventstore.NewEventstore(&eventstore.Config{ + Querier: es_repo_mock.NewRepo(t).ExpectFilterEvents().MockQuerier, + }), + }, args{ + event: &session.OTPSMSChallengedEvent{ + BaseEvent: *eventstore.BaseEventFromRepo(&repository.Event{ + InstanceID: instanceID, + AggregateID: sessionID, + ResourceOwner: sql.NullString{String: instanceID}, + CreationDate: time.Now().UTC(), + Typ: session.OTPSMSChallengedType, + }), + Code: nil, + Expiry: 0, + CodeReturned: false, + TriggeredAtOrigin: eventOrigin, + }, + }, w + }, + }, + { + name: "return code", + test: func(ctrl *gomock.Controller, queries *mock.MockQueries, commands *mock.MockCommands) (f fields, a args, w want) { + w.noOperation = true + _, code := cryptoValue(t, ctrl, "testCode") + return fields{ + queries: queries, + commands: commands, + es: eventstore.NewEventstore(&eventstore.Config{ + Querier: es_repo_mock.NewRepo(t).MockQuerier, + }), + }, args{ + event: &session.OTPSMSChallengedEvent{ + BaseEvent: *eventstore.BaseEventFromRepo(&repository.Event{ + InstanceID: instanceID, + AggregateID: sessionID, + ResourceOwner: sql.NullString{String: instanceID}, + CreationDate: time.Now().UTC(), + Typ: session.OTPSMSChallengedType, + }), + Code: code, + Expiry: 1 * time.Hour, + CodeReturned: true, + TriggeredAtOrigin: eventOrigin, + }, + }, w + }, }, - }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { @@ -1551,7 +1427,281 @@ func Test_userNotifier_reduceOTPSMSChallenged(t *testing.T) { queries := mock.NewMockQueries(ctrl) commands := mock.NewMockCommands(ctrl) f, a, w := tt.test(ctrl, queries, commands) - _, err := newUserNotifier(t, ctrl, queries, f, a, w).reduceSessionOTPSMSChallenged(a.event) + stmt, err := newUserNotifier(t, ctrl, queries, f, a, w).reduceSessionOTPSMSChallenged(a.event) + if w.err != nil { + w.err(t, err) + } else { + assert.NoError(t, err) + } + if w.noOperation { + assert.Nil(t, stmt.Execute) + return + } + err = stmt.Execute(nil, "") + if w.err != nil { + w.err(t, err) + } else { + assert.NoError(t, err) + } + }) + } +} + +func Test_userNotifier_reduceInviteCodeAdded(t *testing.T) { + tests := []struct { + name string + test func(*gomock.Controller, *mock.MockQueries, *mock.MockCommands) (fields, args, want) + }{ + { + name: "with event trigger", + test: func(ctrl *gomock.Controller, queries *mock.MockQueries, commands *mock.MockCommands) (f fields, a args, w want) { + _, code := cryptoValue(t, ctrl, "testcode") + commands.EXPECT().RequestNotification(gomock.Any(), orgID, &command.NotificationRequest{ + UserID: userID, + UserResourceOwner: orgID, + TriggerOrigin: eventOrigin, + URLTemplate: fmt.Sprintf("%s/ui/login/user/invite?userID=%s&loginname={{.LoginName}}&code={{.Code}}&orgID=%s&authRequestID=%s", eventOrigin, userID, orgID, authRequestID), + Code: code, + CodeExpiry: time.Hour, + EventType: user.HumanInviteCodeAddedType, + NotificationType: domain.NotificationTypeEmail, + MessageType: domain.InviteUserMessageType, + UnverifiedNotificationChannel: true, + Args: &domain.NotificationArguments{ + ApplicationName: "ZITADEL", + AuthRequestID: authRequestID, + }, + AggregateID: "", + AggregateResourceOwner: "", + IsOTP: false, + RequiresPreviousDomain: false, + }).Return(nil) + return fields{ + queries: queries, + commands: commands, + es: eventstore.NewEventstore(&eventstore.Config{ + Querier: es_repo_mock.NewRepo(t).ExpectFilterEvents().MockQuerier, + }), + }, args{ + event: &user.HumanInviteCodeAddedEvent{ + BaseEvent: eventstore.BaseEventFromRepo(&repository.Event{ + InstanceID: instanceID, + AggregateID: userID, + ResourceOwner: sql.NullString{String: orgID}, + CreationDate: time.Now().UTC(), + Typ: user.HumanInviteCodeAddedType, + }), + Code: code, + Expiry: time.Hour, + URLTemplate: "", + CodeReturned: false, + TriggeredAtOrigin: eventOrigin, + AuthRequestID: authRequestID, + }, + }, w + }, + }, + { + name: "without event trigger", + test: func(ctrl *gomock.Controller, queries *mock.MockQueries, commands *mock.MockCommands) (f fields, a args, w want) { + _, code := cryptoValue(t, ctrl, "testCode") + queries.EXPECT().SearchInstanceDomains(gomock.Any(), gomock.Any()).Return(&query.InstanceDomains{ + Domains: []*query.InstanceDomain{{ + Domain: instancePrimaryDomain, + IsPrimary: true, + }}, + }, nil) + commands.EXPECT().RequestNotification(gomock.Any(), orgID, &command.NotificationRequest{ + UserID: userID, + UserResourceOwner: orgID, + TriggerOrigin: fmt.Sprintf("%s://%s:%d", externalProtocol, instancePrimaryDomain, externalPort), + URLTemplate: fmt.Sprintf("%s://%s:%d/ui/login/user/invite?userID=%s&loginname={{.LoginName}}&code={{.Code}}&orgID=%s&authRequestID=%s", externalProtocol, instancePrimaryDomain, externalPort, userID, orgID, authRequestID), + Code: code, + CodeExpiry: time.Hour, + EventType: user.HumanInviteCodeAddedType, + NotificationType: domain.NotificationTypeEmail, + MessageType: domain.InviteUserMessageType, + UnverifiedNotificationChannel: true, + Args: &domain.NotificationArguments{ + ApplicationName: "ZITADEL", + AuthRequestID: authRequestID, + }, + AggregateID: "", + AggregateResourceOwner: "", + IsOTP: false, + RequiresPreviousDomain: false, + }).Return(nil) + return fields{ + queries: queries, + commands: commands, + es: eventstore.NewEventstore(&eventstore.Config{ + Querier: es_repo_mock.NewRepo(t).ExpectFilterEvents().MockQuerier, + }), + }, args{ + event: &user.HumanInviteCodeAddedEvent{ + BaseEvent: eventstore.BaseEventFromRepo(&repository.Event{ + InstanceID: instanceID, + AggregateID: userID, + ResourceOwner: sql.NullString{String: orgID}, + CreationDate: time.Now().UTC(), + Typ: user.HumanInviteCodeAddedType, + }), + Code: code, + Expiry: time.Hour, + URLTemplate: "", + CodeReturned: false, + AuthRequestID: authRequestID, + }, + }, w + }, + }, + { + name: "return code", + test: func(ctrl *gomock.Controller, queries *mock.MockQueries, commands *mock.MockCommands) (f fields, a args, w want) { + w.noOperation = true + _, code := cryptoValue(t, ctrl, "testcode") + return fields{ + queries: queries, + commands: commands, + es: eventstore.NewEventstore(&eventstore.Config{ + Querier: es_repo_mock.NewRepo(t).MockQuerier, + }), + }, args{ + event: &user.HumanInviteCodeAddedEvent{ + BaseEvent: eventstore.BaseEventFromRepo(&repository.Event{ + InstanceID: instanceID, + AggregateID: userID, + ResourceOwner: sql.NullString{String: orgID}, + CreationDate: time.Now().UTC(), + Typ: user.HumanInviteCodeAddedType, + }), + Code: code, + Expiry: time.Hour, + URLTemplate: "", + CodeReturned: true, + TriggeredAtOrigin: eventOrigin, + AuthRequestID: authRequestID, + }, + }, w + }, + }, + { + name: "url template", + test: func(ctrl *gomock.Controller, queries *mock.MockQueries, commands *mock.MockCommands) (f fields, a args, w want) { + _, code := cryptoValue(t, ctrl, "testcode") + commands.EXPECT().RequestNotification(gomock.Any(), orgID, &command.NotificationRequest{ + UserID: userID, + UserResourceOwner: orgID, + TriggerOrigin: eventOrigin, + URLTemplate: "/passwordless-init?userID={{.UserID}}", + Code: code, + CodeExpiry: time.Hour, + EventType: user.HumanInviteCodeAddedType, + NotificationType: domain.NotificationTypeEmail, + MessageType: domain.InviteUserMessageType, + UnverifiedNotificationChannel: true, + Args: &domain.NotificationArguments{ + ApplicationName: "ZITADEL", + AuthRequestID: authRequestID, + }, + AggregateID: "", + AggregateResourceOwner: "", + IsOTP: false, + RequiresPreviousDomain: false, + }).Return(nil) + return fields{ + queries: queries, + commands: commands, + es: eventstore.NewEventstore(&eventstore.Config{ + Querier: es_repo_mock.NewRepo(t).ExpectFilterEvents().MockQuerier, + }), + }, args{ + event: &user.HumanInviteCodeAddedEvent{ + BaseEvent: eventstore.BaseEventFromRepo(&repository.Event{ + InstanceID: instanceID, + AggregateID: userID, + ResourceOwner: sql.NullString{String: orgID}, + CreationDate: time.Now().UTC(), + Typ: user.HumanInviteCodeAddedType, + }), + Code: code, + Expiry: time.Hour, + URLTemplate: "/passwordless-init?userID={{.UserID}}", + CodeReturned: false, + TriggeredAtOrigin: eventOrigin, + AuthRequestID: authRequestID, + }, + }, w + }, + }, + { + name: "application name", + test: func(ctrl *gomock.Controller, queries *mock.MockQueries, commands *mock.MockCommands) (f fields, a args, w want) { + _, code := cryptoValue(t, ctrl, "testcode") + commands.EXPECT().RequestNotification(gomock.Any(), orgID, &command.NotificationRequest{ + UserID: userID, + UserResourceOwner: orgID, + TriggerOrigin: eventOrigin, + URLTemplate: fmt.Sprintf("%s/ui/login/user/invite?userID=%s&loginname={{.LoginName}}&code={{.Code}}&orgID=%s&authRequestID=%s", eventOrigin, userID, orgID, authRequestID), + Code: code, + CodeExpiry: time.Hour, + EventType: user.HumanInviteCodeAddedType, + NotificationType: domain.NotificationTypeEmail, + MessageType: domain.InviteUserMessageType, + UnverifiedNotificationChannel: true, + Args: &domain.NotificationArguments{ + ApplicationName: "APP", + AuthRequestID: authRequestID, + }, + AggregateID: "", + AggregateResourceOwner: "", + IsOTP: false, + RequiresPreviousDomain: false, + }).Return(nil) + return fields{ + queries: queries, + commands: commands, + es: eventstore.NewEventstore(&eventstore.Config{ + Querier: es_repo_mock.NewRepo(t).ExpectFilterEvents().MockQuerier, + }), + }, args{ + event: &user.HumanInviteCodeAddedEvent{ + BaseEvent: eventstore.BaseEventFromRepo(&repository.Event{ + InstanceID: instanceID, + AggregateID: userID, + ResourceOwner: sql.NullString{String: orgID}, + CreationDate: time.Now().UTC(), + Typ: user.HumanInviteCodeAddedType, + }), + Code: code, + Expiry: time.Hour, + URLTemplate: "", + CodeReturned: false, + TriggeredAtOrigin: eventOrigin, + AuthRequestID: authRequestID, + ApplicationName: "APP", + }, + }, w + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ctrl := gomock.NewController(t) + queries := mock.NewMockQueries(ctrl) + commands := mock.NewMockCommands(ctrl) + f, a, w := tt.test(ctrl, queries, commands) + stmt, err := newUserNotifier(t, ctrl, queries, f, a, w).reduceInviteCodeAdded(a.event) + if w.err != nil { + w.err(t, err) + } else { + assert.NoError(t, err) + } + if w.noOperation { + assert.Nil(t, stmt.Execute) + return + } + err = stmt.Execute(nil, "") if w.err != nil { w.err(t, err) } else { @@ -1568,32 +1718,36 @@ type fields struct { userDataCrypto crypto.EncryptionAlgorithm SMSTokenCrypto crypto.EncryptionAlgorithm } +type fieldsWorker struct { + queries *mock.MockQueries + commands *mock.MockCommands + es *eventstore.Eventstore + userDataCrypto crypto.EncryptionAlgorithm + SMSTokenCrypto crypto.EncryptionAlgorithm + now nowFunc + backOff func(current time.Duration) time.Duration + maxAttempts uint8 +} type args struct { event eventstore.Event } +type argsWorker struct { + event eventstore.Event +} type want struct { + noOperation bool + err assert.ErrorAssertionFunc +} +type wantWorker struct { message *messages.Email messageSMS *messages.SMS + sendError error err assert.ErrorAssertionFunc } func newUserNotifier(t *testing.T, ctrl *gomock.Controller, queries *mock.MockQueries, f fields, a args, w want) *userNotifier { queries.EXPECT().NotificationProviderByIDAndType(gomock.Any(), gomock.Any(), gomock.Any()).AnyTimes().Return(&query.DebugNotificationProvider{}, nil) smtpAlg, _ := cryptoValue(t, ctrl, "smtppw") - channel := channel_mock.NewMockNotificationChannel(ctrl) - if w.err == nil { - if w.message != nil { - w.message.TriggeringEvent = a.event - channel.EXPECT().HandleMessage(w.message).Return(nil) - } - if w.messageSMS != nil { - w.messageSMS.TriggeringEvent = a.event - channel.EXPECT().HandleMessage(w.messageSMS).DoAndReturn(func(message *messages.SMS) error { - message.VerificationID = gu.Ptr(verificationID) - return nil - }) - } - } return &userNotifier{ commands: f.commands, queries: NewNotificationQueries( @@ -1608,63 +1762,30 @@ func newUserNotifier(t *testing.T, ctrl *gomock.Controller, queries *mock.MockQu f.SMSTokenCrypto, ), otpEmailTmpl: defaultOTPEmailTemplate, - channels: &channels{ - Chain: *senders.ChainChannels(channel), - EmailConfig: &email.Config{ - ProviderConfig: &email.Provider{ - ID: "emailProviderID", - Description: "description", - }, - SMTPConfig: &smtp.Config{ - SMTP: smtp.SMTP{ - Host: "host", - User: "user", - Password: "password", - }, - Tls: true, - From: "from", - FromName: "fromName", - ReplyToAddress: "replyToAddress", - }, - WebhookConfig: nil, - }, - SMSConfig: &sms.Config{ - ProviderConfig: &sms.Provider{ - ID: "smsProviderID", - Description: "description", - }, - TwilioConfig: &twilio.Config{ - SID: "sid", - Token: "token", - SenderNumber: "senderNumber", - VerifyServiceSID: "verifyServiceSID", - }, - }, - }, } } -var _ types.ChannelChains = (*channels)(nil) +var _ types.ChannelChains = (*notificationChannels)(nil) -type channels struct { +type notificationChannels struct { senders.Chain EmailConfig *email.Config SMSConfig *sms.Config } -func (c *channels) Email(context.Context) (*senders.Chain, *email.Config, error) { +func (c *notificationChannels) Email(context.Context) (*senders.Chain, *email.Config, error) { return &c.Chain, c.EmailConfig, nil } -func (c *channels) SMS(context.Context) (*senders.Chain, *sms.Config, error) { +func (c *notificationChannels) SMS(context.Context) (*senders.Chain, *sms.Config, error) { return &c.Chain, c.SMSConfig, nil } -func (c *channels) Webhook(context.Context, webhook.Config) (*senders.Chain, error) { +func (c *notificationChannels) Webhook(context.Context, webhook.Config) (*senders.Chain, error) { return &c.Chain, nil } -func (c *channels) SecurityTokenEvent(context.Context, set.Config) (*senders.Chain, error) { +func (c *notificationChannels) SecurityTokenEvent(context.Context, set.Config) (*senders.Chain, error) { return &c.Chain, nil } @@ -1679,6 +1800,11 @@ func expectTemplateQueries(queries *mock.MockQueries, template string) { }, }, nil) queries.EXPECT().MailTemplateByOrg(gomock.Any(), gomock.Any(), gomock.Any()).Return(&query.MailTemplate{Template: []byte(template)}, nil) + queries.EXPECT().GetDefaultLanguage(gomock.Any()).Return(language.English) + queries.EXPECT().CustomTextListByTemplate(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Times(2).Return(&query.CustomTexts{}, nil) +} + +func expectTemplateWithNotifyUserQueries(queries *mock.MockQueries, template string) { queries.EXPECT().GetNotifyUserByID(gomock.Any(), gomock.Any(), gomock.Any()).Return(&query.NotifyUser{ ID: userID, ResourceOwner: orgID, @@ -1688,11 +1814,19 @@ func expectTemplateQueries(queries *mock.MockQueries, template string) { LastPhone: lastPhone, VerifiedPhone: verifiedPhone, }, nil) - queries.EXPECT().GetDefaultLanguage(gomock.Any()).Return(language.English) - queries.EXPECT().CustomTextListByTemplate(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Times(2).Return(&query.CustomTexts{}, nil) + expectTemplateQueries(queries, template) } -func expectTemplateQueriesSMS(queries *mock.MockQueries, template string) { +func expectTemplateWithNotifyUserQueriesSMS(queries *mock.MockQueries) { + queries.EXPECT().GetNotifyUserByID(gomock.Any(), gomock.Any(), gomock.Any()).Return(&query.NotifyUser{ + ID: userID, + ResourceOwner: orgID, + LastEmail: lastEmail, + VerifiedEmail: verifiedEmail, + PreferredLoginName: preferredLoginName, + LastPhone: lastPhone, + VerifiedPhone: verifiedPhone, + }, nil) queries.EXPECT().GetInstanceRestrictions(gomock.Any()).Return(query.Restrictions{ AllowedLanguages: []language.Tag{language.English}, }, nil) @@ -1702,15 +1836,6 @@ func expectTemplateQueriesSMS(queries *mock.MockQueries, template string) { LogoURL: logoURL, }, }, nil) - queries.EXPECT().GetNotifyUserByID(gomock.Any(), gomock.Any(), gomock.Any()).Return(&query.NotifyUser{ - ID: userID, - ResourceOwner: orgID, - LastEmail: lastEmail, - VerifiedEmail: verifiedEmail, - PreferredLoginName: preferredLoginName, - LastPhone: lastPhone, - VerifiedPhone: verifiedPhone, - }, nil) queries.EXPECT().GetDefaultLanguage(gomock.Any()).Return(language.English) queries.EXPECT().CustomTextListByTemplate(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Times(2).Return(&query.CustomTexts{}, nil) } diff --git a/internal/notification/projections.go b/internal/notification/projections.go index 2be95f1490..1a8c70cd40 100644 --- a/internal/notification/projections.go +++ b/internal/notification/projections.go @@ -6,6 +6,7 @@ import ( "github.com/zitadel/zitadel/internal/command" "github.com/zitadel/zitadel/internal/crypto" + "github.com/zitadel/zitadel/internal/database" "github.com/zitadel/zitadel/internal/eventstore" "github.com/zitadel/zitadel/internal/eventstore/handler/v2" "github.com/zitadel/zitadel/internal/notification/handlers" @@ -14,11 +15,15 @@ import ( "github.com/zitadel/zitadel/internal/query/projection" ) -var projections []*handler.Handler +var ( + projections []*handler.Handler + worker *handlers.NotificationWorker +) func Register( ctx context.Context, userHandlerCustomConfig, quotaHandlerCustomConfig, telemetryHandlerCustomConfig, backChannelLogoutHandlerCustomConfig projection.CustomConfig, + notificationWorkerConfig handlers.WorkerConfig, telemetryCfg handlers.TelemetryPusherConfig, externalDomain string, externalPort uint16, @@ -29,10 +34,11 @@ func Register( otpEmailTmpl, fileSystemPath string, userEncryption, smtpEncryption, smsEncryption, keysEncryptionAlg crypto.EncryptionAlgorithm, tokenLifetime time.Duration, + client *database.DB, ) { q := handlers.NewNotificationQueries(queries, es, externalDomain, externalPort, externalSecure, fileSystemPath, userEncryption, smtpEncryption, smsEncryption) c := newChannels(q) - projections = append(projections, handlers.NewUserNotifier(ctx, projection.ApplyCustomConfig(userHandlerCustomConfig), commands, q, c, otpEmailTmpl)) + projections = append(projections, handlers.NewUserNotifier(ctx, projection.ApplyCustomConfig(userHandlerCustomConfig), commands, q, otpEmailTmpl)) projections = append(projections, handlers.NewQuotaNotifier(ctx, projection.ApplyCustomConfig(quotaHandlerCustomConfig), commands, q, c)) projections = append(projections, handlers.NewBackChannelLogoutNotifier( ctx, @@ -47,12 +53,14 @@ func Register( if telemetryCfg.Enabled { projections = append(projections, handlers.NewTelemetryPusher(ctx, telemetryCfg, projection.ApplyCustomConfig(telemetryHandlerCustomConfig), commands, q, c)) } + worker = handlers.NewNotificationWorker(notificationWorkerConfig, commands, q, es, client, c) } func Start(ctx context.Context) { for _, projection := range projections { projection.Start(ctx) } + worker.Start(ctx) } func ProjectInstance(ctx context.Context) error { diff --git a/internal/notification/types/domain_claimed.go b/internal/notification/types/domain_claimed.go deleted file mode 100644 index 433728392b..0000000000 --- a/internal/notification/types/domain_claimed.go +++ /dev/null @@ -1,20 +0,0 @@ -package types - -import ( - "context" - "strings" - - http_utils "github.com/zitadel/zitadel/internal/api/http" - "github.com/zitadel/zitadel/internal/api/ui/login" - "github.com/zitadel/zitadel/internal/domain" - "github.com/zitadel/zitadel/internal/query" -) - -func (notify Notify) SendDomainClaimed(ctx context.Context, user *query.NotifyUser, username string) error { - url := login.LoginLink(http_utils.DomainContext(ctx).Origin(), user.ResourceOwner) - index := strings.LastIndex(user.LastEmail, "@") - args := make(map[string]interface{}) - args["TempUsername"] = username - args["Domain"] = user.LastEmail[index+1:] - return notify(url, args, domain.DomainClaimedMessageType, true) -} diff --git a/internal/notification/types/email_verification_code.go b/internal/notification/types/email_verification_code.go deleted file mode 100644 index 4ff59137b1..0000000000 --- a/internal/notification/types/email_verification_code.go +++ /dev/null @@ -1,28 +0,0 @@ -package types - -import ( - "context" - "strings" - - http_utils "github.com/zitadel/zitadel/internal/api/http" - "github.com/zitadel/zitadel/internal/api/ui/login" - "github.com/zitadel/zitadel/internal/domain" - "github.com/zitadel/zitadel/internal/query" -) - -func (notify Notify) SendEmailVerificationCode(ctx context.Context, user *query.NotifyUser, code string, urlTmpl, authRequestID string) error { - var url string - if urlTmpl == "" { - url = login.MailVerificationLink(http_utils.DomainContext(ctx).Origin(), user.ID, code, user.ResourceOwner, authRequestID) - } else { - var buf strings.Builder - if err := domain.RenderConfirmURLTemplate(&buf, urlTmpl, user.ID, code, user.ResourceOwner); err != nil { - return err - } - url = buf.String() - } - - args := make(map[string]interface{}) - args["Code"] = code - return notify(url, args, domain.VerifyEmailMessageType, true) -} diff --git a/internal/notification/types/email_verification_code_test.go b/internal/notification/types/email_verification_code_test.go deleted file mode 100644 index 2196e25b0c..0000000000 --- a/internal/notification/types/email_verification_code_test.go +++ /dev/null @@ -1,92 +0,0 @@ -package types - -import ( - "context" - "testing" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - - http_utils "github.com/zitadel/zitadel/internal/api/http" - "github.com/zitadel/zitadel/internal/domain" - "github.com/zitadel/zitadel/internal/query" - "github.com/zitadel/zitadel/internal/zerrors" -) - -func TestNotify_SendEmailVerificationCode(t *testing.T) { - type args struct { - user *query.NotifyUser - origin *http_utils.DomainCtx - code string - urlTmpl string - authRequestID string - } - tests := []struct { - name string - args args - want *notifyResult - wantErr error - }{ - { - name: "default URL", - args: args{ - user: &query.NotifyUser{ - ID: "user1", - ResourceOwner: "org1", - }, - origin: &http_utils.DomainCtx{InstanceHost: "example.com", Protocol: "https"}, - code: "123", - urlTmpl: "", - authRequestID: "authRequestID", - }, - want: ¬ifyResult{ - url: "https://example.com/ui/login/mail/verification?authRequestID=authRequestID&code=123&orgID=org1&userID=user1", - args: map[string]interface{}{"Code": "123"}, - messageType: domain.VerifyEmailMessageType, - allowUnverifiedNotificationChannel: true, - }, - }, - { - name: "template error", - args: args{ - user: &query.NotifyUser{ - ID: "user1", - ResourceOwner: "org1", - }, - origin: &http_utils.DomainCtx{InstanceHost: "example.com", Protocol: "https"}, - code: "123", - urlTmpl: "{{", - authRequestID: "authRequestID", - }, - want: ¬ifyResult{}, - wantErr: zerrors.ThrowInvalidArgument(nil, "DOMAIN-oGh5e", "Errors.User.InvalidURLTemplate"), - }, - { - name: "template success", - args: args{ - user: &query.NotifyUser{ - ID: "user1", - ResourceOwner: "org1", - }, - origin: &http_utils.DomainCtx{InstanceHost: "example.com", Protocol: "https"}, - code: "123", - urlTmpl: "https://example.com/email/verify?userID={{.UserID}}&code={{.Code}}&orgID={{.OrgID}}", - authRequestID: "authRequestID", - }, - want: ¬ifyResult{ - url: "https://example.com/email/verify?userID=user1&code=123&orgID=org1", - args: map[string]interface{}{"Code": "123"}, - messageType: domain.VerifyEmailMessageType, - allowUnverifiedNotificationChannel: true, - }, - }, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - got, notify := mockNotify() - err := notify.SendEmailVerificationCode(http_utils.WithDomainContext(context.Background(), tt.args.origin), tt.args.user, tt.args.code, tt.args.urlTmpl, tt.args.authRequestID) - require.ErrorIs(t, err, tt.wantErr) - assert.Equal(t, tt.want, got) - }) - } -} diff --git a/internal/notification/types/init_code.go b/internal/notification/types/init_code.go deleted file mode 100644 index 3e38cc284b..0000000000 --- a/internal/notification/types/init_code.go +++ /dev/null @@ -1,17 +0,0 @@ -package types - -import ( - "context" - - http_utils "github.com/zitadel/zitadel/internal/api/http" - "github.com/zitadel/zitadel/internal/api/ui/login" - "github.com/zitadel/zitadel/internal/domain" - "github.com/zitadel/zitadel/internal/query" -) - -func (notify Notify) SendUserInitCode(ctx context.Context, user *query.NotifyUser, code, authRequestID string) error { - url := login.InitUserLink(http_utils.DomainContext(ctx).Origin(), user.ID, user.PreferredLoginName, code, user.ResourceOwner, user.PasswordSet, authRequestID) - args := make(map[string]interface{}) - args["Code"] = code - return notify(url, args, domain.InitCodeMessageType, true) -} diff --git a/internal/notification/types/invite_code.go b/internal/notification/types/invite_code.go deleted file mode 100644 index 953124a553..0000000000 --- a/internal/notification/types/invite_code.go +++ /dev/null @@ -1,31 +0,0 @@ -package types - -import ( - "context" - "strings" - - http_utils "github.com/zitadel/zitadel/internal/api/http" - "github.com/zitadel/zitadel/internal/api/ui/login" - "github.com/zitadel/zitadel/internal/domain" - "github.com/zitadel/zitadel/internal/query" -) - -func (notify Notify) SendInviteCode(ctx context.Context, user *query.NotifyUser, code, applicationName, urlTmpl, authRequestID string) error { - var url string - if applicationName == "" { - applicationName = "ZITADEL" - } - if urlTmpl == "" { - url = login.InviteUserLink(http_utils.DomainContext(ctx).Origin(), user.ID, user.PreferredLoginName, code, user.ResourceOwner, authRequestID) - } else { - var buf strings.Builder - if err := domain.RenderConfirmURLTemplate(&buf, urlTmpl, user.ID, code, user.ResourceOwner); err != nil { - return err - } - url = buf.String() - } - args := make(map[string]interface{}) - args["Code"] = code - args["ApplicationName"] = applicationName - return notify(url, args, domain.InviteUserMessageType, true) -} diff --git a/internal/notification/types/notification.go b/internal/notification/types/notification.go index 61c4cf70de..db791851bc 100644 --- a/internal/notification/types/notification.go +++ b/internal/notification/types/notification.go @@ -3,8 +3,10 @@ package types import ( "context" "html" + "strings" "github.com/zitadel/zitadel/internal/database" + "github.com/zitadel/zitadel/internal/domain" "github.com/zitadel/zitadel/internal/eventstore" "github.com/zitadel/zitadel/internal/i18n" "github.com/zitadel/zitadel/internal/notification/channels/email" @@ -40,13 +42,17 @@ func SendEmail( triggeringEvent eventstore.Event, ) Notify { return func( - url string, + urlTmpl string, args map[string]interface{}, messageType string, allowUnverifiedNotificationChannel bool, ) error { args = mapNotifyUserToArgs(user, args) sanitizeArgsForHTML(args) + url, err := urlFromTemplate(urlTmpl, args) + if err != nil { + return err + } data := GetTemplateData(ctx, translator, args, url, messageType, user.PreferredLanguage.String(), colors) template, err := templates.GetParsedTemplate(mailhtml, data) if err != nil { @@ -82,6 +88,14 @@ func sanitizeArgsForHTML(args map[string]any) { } } +func urlFromTemplate(urlTmpl string, args map[string]interface{}) (string, error) { + var buf strings.Builder + if err := domain.RenderURLTemplate(&buf, urlTmpl, args); err != nil { + return "", err + } + return buf.String(), nil +} + func SendSMS( ctx context.Context, channels ChannelChains, @@ -92,12 +106,16 @@ func SendSMS( generatorInfo *senders.CodeGeneratorInfo, ) Notify { return func( - url string, + urlTmpl string, args map[string]interface{}, messageType string, allowUnverifiedNotificationChannel bool, ) error { args = mapNotifyUserToArgs(user, args) + url, err := urlFromTemplate(urlTmpl, args) + if err != nil { + return err + } data := GetTemplateData(ctx, translator, args, url, messageType, user.PreferredLanguage.String(), colors) return generateSms( ctx, diff --git a/internal/notification/types/otp.go b/internal/notification/types/otp.go deleted file mode 100644 index 3242b2da3d..0000000000 --- a/internal/notification/types/otp.go +++ /dev/null @@ -1,29 +0,0 @@ -package types - -import ( - "context" - "time" - - http_utils "github.com/zitadel/zitadel/internal/api/http" - "github.com/zitadel/zitadel/internal/domain" -) - -func (notify Notify) SendOTPSMSCode(ctx context.Context, code string, expiry time.Duration) error { - args := otpArgs(ctx, code, expiry) - return notify("", args, domain.VerifySMSOTPMessageType, false) -} - -func (notify Notify) SendOTPEmailCode(ctx context.Context, url, code string, expiry time.Duration) error { - args := otpArgs(ctx, code, expiry) - return notify(url, args, domain.VerifyEmailOTPMessageType, false) -} - -func otpArgs(ctx context.Context, code string, expiry time.Duration) map[string]interface{} { - domainCtx := http_utils.DomainContext(ctx) - args := make(map[string]interface{}) - args["OTP"] = code - args["Origin"] = domainCtx.Origin() - args["Domain"] = domainCtx.RequestedDomain() - args["Expiry"] = expiry - return args -} diff --git a/internal/notification/types/password_change.go b/internal/notification/types/password_change.go deleted file mode 100644 index 8536ac4c04..0000000000 --- a/internal/notification/types/password_change.go +++ /dev/null @@ -1,16 +0,0 @@ -package types - -import ( - "context" - - http_utils "github.com/zitadel/zitadel/internal/api/http" - "github.com/zitadel/zitadel/internal/api/ui/console" - "github.com/zitadel/zitadel/internal/domain" - "github.com/zitadel/zitadel/internal/query" -) - -func (notify Notify) SendPasswordChange(ctx context.Context, user *query.NotifyUser) error { - url := console.LoginHintLink(http_utils.DomainContext(ctx).Origin(), user.PreferredLoginName) - args := make(map[string]interface{}) - return notify(url, args, domain.PasswordChangeMessageType, true) -} diff --git a/internal/notification/types/password_code.go b/internal/notification/types/password_code.go deleted file mode 100644 index 40ffee3e6d..0000000000 --- a/internal/notification/types/password_code.go +++ /dev/null @@ -1,27 +0,0 @@ -package types - -import ( - "context" - "strings" - - http_utils "github.com/zitadel/zitadel/internal/api/http" - "github.com/zitadel/zitadel/internal/api/ui/login" - "github.com/zitadel/zitadel/internal/domain" - "github.com/zitadel/zitadel/internal/query" -) - -func (notify Notify) SendPasswordCode(ctx context.Context, user *query.NotifyUser, code, urlTmpl, authRequestID string) error { - var url string - if urlTmpl == "" { - url = login.InitPasswordLink(http_utils.DomainContext(ctx).Origin(), user.ID, code, user.ResourceOwner, authRequestID) - } else { - var buf strings.Builder - if err := domain.RenderConfirmURLTemplate(&buf, urlTmpl, user.ID, code, user.ResourceOwner); err != nil { - return err - } - url = buf.String() - } - args := make(map[string]interface{}) - args["Code"] = code - return notify(url, args, domain.PasswordResetMessageType, true) -} diff --git a/internal/notification/types/passwordless_registration_link.go b/internal/notification/types/passwordless_registration_link.go deleted file mode 100644 index 64af1a9797..0000000000 --- a/internal/notification/types/passwordless_registration_link.go +++ /dev/null @@ -1,25 +0,0 @@ -package types - -import ( - "context" - "strings" - - http_utils "github.com/zitadel/zitadel/internal/api/http" - "github.com/zitadel/zitadel/internal/api/ui/login" - "github.com/zitadel/zitadel/internal/domain" - "github.com/zitadel/zitadel/internal/query" -) - -func (notify Notify) SendPasswordlessRegistrationLink(ctx context.Context, user *query.NotifyUser, code, codeID, urlTmpl string) error { - var url string - if urlTmpl == "" { - url = domain.PasswordlessInitCodeLink(http_utils.DomainContext(ctx).Origin()+login.HandlerPrefix+login.EndpointPasswordlessRegistration, user.ID, user.ResourceOwner, codeID, code) - } else { - var buf strings.Builder - if err := domain.RenderPasskeyURLTemplate(&buf, urlTmpl, user.ID, user.ResourceOwner, codeID, code); err != nil { - return err - } - url = buf.String() - } - return notify(url, nil, domain.PasswordlessRegistrationMessageType, true) -} diff --git a/internal/notification/types/passwordless_registration_link_test.go b/internal/notification/types/passwordless_registration_link_test.go deleted file mode 100644 index 0a04b7a0fe..0000000000 --- a/internal/notification/types/passwordless_registration_link_test.go +++ /dev/null @@ -1,90 +0,0 @@ -package types - -import ( - "context" - "testing" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - - http_utils "github.com/zitadel/zitadel/internal/api/http" - "github.com/zitadel/zitadel/internal/domain" - "github.com/zitadel/zitadel/internal/query" - "github.com/zitadel/zitadel/internal/zerrors" -) - -func TestNotify_SendPasswordlessRegistrationLink(t *testing.T) { - type args struct { - user *query.NotifyUser - origin *http_utils.DomainCtx - code string - codeID string - urlTmpl string - } - tests := []struct { - name string - args args - want *notifyResult - wantErr error - }{ - { - name: "default URL", - args: args{ - user: &query.NotifyUser{ - ID: "user1", - ResourceOwner: "org1", - }, - origin: &http_utils.DomainCtx{InstanceHost: "example.com", Protocol: "https"}, - code: "123", - codeID: "456", - urlTmpl: "", - }, - want: ¬ifyResult{ - url: "https://example.com/ui/login/login/passwordless/init?userID=user1&orgID=org1&codeID=456&code=123", - messageType: domain.PasswordlessRegistrationMessageType, - allowUnverifiedNotificationChannel: true, - }, - }, - { - name: "template error", - args: args{ - user: &query.NotifyUser{ - ID: "user1", - ResourceOwner: "org1", - }, - origin: &http_utils.DomainCtx{InstanceHost: "example.com", Protocol: "https"}, - code: "123", - codeID: "456", - urlTmpl: "{{", - }, - want: ¬ifyResult{}, - wantErr: zerrors.ThrowInvalidArgument(nil, "DOMAIN-oGh5e", "Errors.User.InvalidURLTemplate"), - }, - { - name: "template success", - args: args{ - user: &query.NotifyUser{ - ID: "user1", - ResourceOwner: "org1", - }, - origin: &http_utils.DomainCtx{InstanceHost: "example.com", Protocol: "https"}, - code: "123", - codeID: "456", - urlTmpl: "https://example.com/passkey/register?userID={{.UserID}}&orgID={{.OrgID}}&codeID={{.CodeID}}&code={{.Code}}", - }, - want: ¬ifyResult{ - url: "https://example.com/passkey/register?userID=user1&orgID=org1&codeID=456&code=123", - messageType: domain.PasswordlessRegistrationMessageType, - allowUnverifiedNotificationChannel: true, - }, - }, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - got, notify := mockNotify() - err := notify.SendPasswordlessRegistrationLink(http_utils.WithDomainContext(context.Background(), tt.args.origin), tt.args.user, tt.args.code, tt.args.codeID, tt.args.urlTmpl) - require.ErrorIs(t, err, tt.wantErr) - assert.Equal(t, tt.want, got) - }) - } -} diff --git a/internal/notification/types/phone_verification_code.go b/internal/notification/types/phone_verification_code.go deleted file mode 100644 index 461b85749c..0000000000 --- a/internal/notification/types/phone_verification_code.go +++ /dev/null @@ -1,15 +0,0 @@ -package types - -import ( - "context" - - http_util "github.com/zitadel/zitadel/internal/api/http" - "github.com/zitadel/zitadel/internal/domain" -) - -func (notify Notify) SendPhoneVerificationCode(ctx context.Context, code string) error { - args := make(map[string]interface{}) - args["Code"] = code - args["Domain"] = http_util.DomainContext(ctx).RequestedDomain() - return notify("", args, domain.VerifyPhoneMessageType, true) -} diff --git a/internal/notification/types/types_test.go b/internal/notification/types/types_test.go deleted file mode 100644 index 1b5066d195..0000000000 --- a/internal/notification/types/types_test.go +++ /dev/null @@ -1,23 +0,0 @@ -package types - -type notifyResult struct { - url string - args map[string]interface{} - messageType string - allowUnverifiedNotificationChannel bool -} - -// mockNotify returns a notifyResult and Notify function for easy mocking. -// The notifyResult will only be populated after Notify is called. -func mockNotify() (*notifyResult, Notify) { - dst := new(notifyResult) - return dst, func(url string, args map[string]interface{}, messageType string, allowUnverifiedNotificationChannel bool) error { - *dst = notifyResult{ - url: url, - args: args, - messageType: messageType, - allowUnverifiedNotificationChannel: allowUnverifiedNotificationChannel, - } - return nil - } -} diff --git a/internal/notification/types/user_email.go b/internal/notification/types/user_email.go index 210ca14cf8..985fe81391 100644 --- a/internal/notification/types/user_email.go +++ b/internal/notification/types/user_email.go @@ -74,6 +74,8 @@ func mapNotifyUserToArgs(user *query.NotifyUser, args map[string]interface{}) ma if args == nil { args = make(map[string]interface{}) } + args["UserID"] = user.ID + args["OrgID"] = user.ResourceOwner args["UserName"] = user.Username args["FirstName"] = user.FirstName args["LastName"] = user.LastName @@ -84,6 +86,7 @@ func mapNotifyUserToArgs(user *query.NotifyUser, args map[string]interface{}) ma args["LastPhone"] = user.LastPhone args["VerifiedPhone"] = user.VerifiedPhone args["PreferredLoginName"] = user.PreferredLoginName + args["LoginName"] = user.PreferredLoginName // some endpoint promoted LoginName instead of PreferredLoginName args["LoginNames"] = user.LoginNames args["ChangeDate"] = user.ChangeDate args["CreationDate"] = user.CreationDate diff --git a/internal/repository/notification/aggregate.go b/internal/repository/notification/aggregate.go new file mode 100644 index 0000000000..8370337d40 --- /dev/null +++ b/internal/repository/notification/aggregate.go @@ -0,0 +1,25 @@ +package notification + +import ( + "github.com/zitadel/zitadel/internal/eventstore" +) + +const ( + AggregateType = "notification" + AggregateVersion = "v1" +) + +type Aggregate struct { + eventstore.Aggregate +} + +func NewAggregate(id, resourceOwner string) *Aggregate { + return &Aggregate{ + Aggregate: eventstore.Aggregate{ + Type: AggregateType, + Version: AggregateVersion, + ID: id, + ResourceOwner: resourceOwner, + }, + } +} diff --git a/internal/repository/notification/eventstore.go b/internal/repository/notification/eventstore.go new file mode 100644 index 0000000000..3ef1c9c7db --- /dev/null +++ b/internal/repository/notification/eventstore.go @@ -0,0 +1,12 @@ +package notification + +import ( + "github.com/zitadel/zitadel/internal/eventstore" +) + +func init() { + eventstore.RegisterFilterEventMapper(AggregateType, RequestedType, eventstore.GenericEventMapper[RequestedEvent]) + eventstore.RegisterFilterEventMapper(AggregateType, SentType, eventstore.GenericEventMapper[SentEvent]) + eventstore.RegisterFilterEventMapper(AggregateType, RetryRequestedType, eventstore.GenericEventMapper[RetryRequestedEvent]) + eventstore.RegisterFilterEventMapper(AggregateType, CanceledType, eventstore.GenericEventMapper[CanceledEvent]) +} diff --git a/internal/repository/notification/notification.go b/internal/repository/notification/notification.go new file mode 100644 index 0000000000..cf7090525f --- /dev/null +++ b/internal/repository/notification/notification.go @@ -0,0 +1,244 @@ +package notification + +import ( + "context" + "time" + + "github.com/zitadel/zitadel/internal/crypto" + "github.com/zitadel/zitadel/internal/domain" + "github.com/zitadel/zitadel/internal/eventstore" + "github.com/zitadel/zitadel/internal/query" +) + +const ( + notificationEventPrefix = "notification." + RequestedType = notificationEventPrefix + "requested" + RetryRequestedType = notificationEventPrefix + "retry.requested" + SentType = notificationEventPrefix + "sent" + CanceledType = notificationEventPrefix + "canceled" +) + +type Request struct { + UserID string `json:"userID"` + UserResourceOwner string `json:"userResourceOwner"` + AggregateID string `json:"notificationAggregateID"` + AggregateResourceOwner string `json:"notificationAggregateResourceOwner"` + TriggeredAtOrigin string `json:"triggeredAtOrigin"` + EventType eventstore.EventType `json:"eventType"` + MessageType string `json:"messageType"` + NotificationType domain.NotificationType `json:"notificationType"` + URLTemplate string `json:"urlTemplate,omitempty"` + CodeExpiry time.Duration `json:"codeExpiry,omitempty"` + Code *crypto.CryptoValue `json:"code,omitempty"` + UnverifiedNotificationChannel bool `json:"unverifiedNotificationChannel,omitempty"` + IsOTP bool `json:"isOTP,omitempty"` + RequiresPreviousDomain bool `json:"RequiresPreviousDomain,omitempty"` + Args *domain.NotificationArguments `json:"args,omitempty"` +} + +func (e *Request) NotificationAggregateID() string { + if e.AggregateID == "" { + return e.UserID + } + return e.AggregateID +} + +func (e *Request) NotificationAggregateResourceOwner() string { + if e.AggregateResourceOwner == "" { + return e.UserResourceOwner + } + return e.AggregateResourceOwner +} + +type RequestedEvent struct { + eventstore.BaseEvent `json:"-"` + + Request `json:"request"` +} + +func (e *RequestedEvent) TriggerOrigin() string { + return e.TriggeredAtOrigin +} + +func (e *RequestedEvent) Payload() interface{} { + return e +} + +func (e *RequestedEvent) UniqueConstraints() []*eventstore.UniqueConstraint { + return nil +} + +func (e *RequestedEvent) SetBaseEvent(event *eventstore.BaseEvent) { + e.BaseEvent = *event +} + +func NewRequestedEvent(ctx context.Context, + aggregate *eventstore.Aggregate, + userID, + userResourceOwner, + aggregateID, + aggregateResourceOwner, + triggerOrigin, + urlTemplate string, + code *crypto.CryptoValue, + codeExpiry time.Duration, + eventType eventstore.EventType, + notificationType domain.NotificationType, + messageType string, + unverifiedNotificationChannel, + isOTP, + requiresPreviousDomain bool, + args *domain.NotificationArguments, +) *RequestedEvent { + return &RequestedEvent{ + BaseEvent: *eventstore.NewBaseEventForPush( + ctx, + aggregate, + RequestedType, + ), + Request: Request{ + UserID: userID, + UserResourceOwner: userResourceOwner, + AggregateID: aggregateID, + AggregateResourceOwner: aggregateResourceOwner, + TriggeredAtOrigin: triggerOrigin, + EventType: eventType, + MessageType: messageType, + NotificationType: notificationType, + URLTemplate: urlTemplate, + CodeExpiry: codeExpiry, + Code: code, + UnverifiedNotificationChannel: unverifiedNotificationChannel, + IsOTP: isOTP, + RequiresPreviousDomain: requiresPreviousDomain, + Args: args, + }, + } +} + +type SentEvent struct { + eventstore.BaseEvent `json:"-"` +} + +func (e *SentEvent) Payload() interface{} { + return e +} + +func (e *SentEvent) UniqueConstraints() []*eventstore.UniqueConstraint { + return nil +} + +func (e *SentEvent) SetBaseEvent(event *eventstore.BaseEvent) { + e.BaseEvent = *event +} + +func NewSentEvent(ctx context.Context, + aggregate *eventstore.Aggregate, +) *SentEvent { + return &SentEvent{ + BaseEvent: *eventstore.NewBaseEventForPush( + ctx, + aggregate, + SentType, + ), + } +} + +type CanceledEvent struct { + eventstore.BaseEvent `json:"-"` + + Error string `json:"error"` +} + +func (e *CanceledEvent) Payload() interface{} { + return e +} + +func (e *CanceledEvent) UniqueConstraints() []*eventstore.UniqueConstraint { + return nil +} + +func (e *CanceledEvent) SetBaseEvent(event *eventstore.BaseEvent) { + e.BaseEvent = *event +} + +func NewCanceledEvent(ctx context.Context, aggregate *eventstore.Aggregate, errorMessage string) *CanceledEvent { + return &CanceledEvent{ + BaseEvent: *eventstore.NewBaseEventForPush( + ctx, + aggregate, + CanceledType, + ), + Error: errorMessage, + } +} + +type RetryRequestedEvent struct { + eventstore.BaseEvent `json:"-"` + + Request `json:"request"` + Error string `json:"error"` + NotifyUser *query.NotifyUser `json:"notifyUser"` + BackOff time.Duration `json:"backOff"` +} + +func (e *RetryRequestedEvent) Payload() interface{} { + return e +} + +func (e *RetryRequestedEvent) UniqueConstraints() []*eventstore.UniqueConstraint { + return nil +} + +func (e *RetryRequestedEvent) SetBaseEvent(event *eventstore.BaseEvent) { + e.BaseEvent = *event +} + +func NewRetryRequestedEvent( + ctx context.Context, + aggregate *eventstore.Aggregate, + userID, + userResourceOwner, + aggregateID, + aggregateResourceOwner, + triggerOrigin, + urlTemplate string, + code *crypto.CryptoValue, + codeExpiry time.Duration, + eventType eventstore.EventType, + notificationType domain.NotificationType, + messageType string, + unverifiedNotificationChannel, + isOTP bool, + args *domain.NotificationArguments, + notifyUser *query.NotifyUser, + backoff time.Duration, + errorMessage string, +) *RetryRequestedEvent { + return &RetryRequestedEvent{ + BaseEvent: *eventstore.NewBaseEventForPush( + ctx, + aggregate, + RetryRequestedType, + ), + Request: Request{ + UserID: userID, + UserResourceOwner: userResourceOwner, + AggregateID: aggregateID, + AggregateResourceOwner: aggregateResourceOwner, + TriggeredAtOrigin: triggerOrigin, + EventType: eventType, + MessageType: messageType, + NotificationType: notificationType, + URLTemplate: urlTemplate, + CodeExpiry: codeExpiry, + Code: code, + UnverifiedNotificationChannel: unverifiedNotificationChannel, + IsOTP: isOTP, + Args: args, + }, + NotifyUser: notifyUser, + BackOff: backoff, + Error: errorMessage, + } +} From 7caa43ab2398356e63f5722594eac88d4ee99044 Mon Sep 17 00:00:00 2001 From: Stefan Benz <46600784+stebenz@users.noreply.github.com> Date: Thu, 28 Nov 2024 11:06:52 +0100 Subject: [PATCH 33/64] feat: action v2 signing (#8779) # Which Problems Are Solved The action v2 messages were didn't contain anything providing security for the sent content. # How the Problems Are Solved Each Target now has a SigningKey, which can also be newly generated through the API and returned at creation and through the Get-Endpoints. There is now a HTTP header "Zitadel-Signature", which is generated with the SigningKey and Payload, and also contains a timestamp to check with a tolerance if the message took to long to sent. # Additional Changes The functionality to create and check the signature is provided in the pkg/actions package, and can be reused in the SDK. # Additional Context Closes #7924 --------- Co-authored-by: Livio Spring --- cmd/defaults.yaml | 9 ++ cmd/encryption/encryption_keys.go | 7 + cmd/mirror/projections.go | 2 + cmd/setup/03.go | 1 + cmd/setup/config_change.go | 1 + cmd/setup/setup.go | 2 + cmd/start/start.go | 2 + docs/docs/apis/actions/v3/testing-locally.md | 14 ++ docs/docs/apis/actions/v3/usage.md | 7 + docs/package.json | 2 +- .../v3alpha/integration_test/query_test.go | 7 + .../v3alpha/integration_test/target_test.go | 120 ++++++++++++------ .../grpc/resources/action/v3alpha/query.go | 1 + .../grpc/resources/action/v3alpha/target.go | 20 ++- .../middleware/execution_interceptor_test.go | 22 ++++ internal/command/action_v2_execution_test.go | 31 +++++ internal/command/action_v2_target.go | 33 ++++- internal/command/action_v2_target_model.go | 11 ++ .../command/action_v2_target_model_test.go | 7 + internal/command/action_v2_target_test.go | 58 +++++++-- internal/command/command.go | 4 +- internal/command/instance.go | 1 + internal/domain/secret_generator.go | 1 + internal/execution/execution.go | 17 ++- internal/execution/execution_test.go | 82 +++++++++++- internal/query/execution.go | 31 +++++ internal/query/projection/target.go | 8 +- internal/query/projection/target_test.go | 14 +- internal/query/query.go | 12 +- internal/query/target.go | 47 ++++++- internal/query/target_test.go | 105 ++++++++++++--- internal/query/targets_by_execution_id.sql | 4 +- internal/query/targets_by_execution_ids.sql | 4 +- internal/repository/target/target.go | 32 +++-- pkg/actions/signing.go | 115 +++++++++++++++++ .../action/v3alpha/action_service.proto | 12 ++ .../resources/action/v3alpha/target.proto | 21 +++ 37 files changed, 745 insertions(+), 122 deletions(-) create mode 100644 pkg/actions/signing.go diff --git a/cmd/defaults.yaml b/cmd/defaults.yaml index 16c321251a..21ed1a5e53 100644 --- a/cmd/defaults.yaml +++ b/cmd/defaults.yaml @@ -633,6 +633,9 @@ EncryptionKeys: User: EncryptionKeyID: "userKey" # ZITADEL_ENCRYPTIONKEYS_USER_ENCRYPTIONKEYID DecryptionKeyIDs: # ZITADEL_ENCRYPTIONKEYS_USER_DECRYPTIONKEYIDS (comma separated list) + Target: + EncryptionKeyID: "targetKey" # ZITADEL_ENCRYPTIONKEYS_TARGET_ENCRYPTIONKEYID + DecryptionKeyIDs: # ZITADEL_ENCRYPTIONKEYS_TARGET_DECRYPTIONKEYIDS (comma separated list) CSRFCookieKeyID: "csrfCookieKey" # ZITADEL_ENCRYPTIONKEYS_CSRFCOOKIEKEYID UserAgentCookieKeyID: "userAgentCookieKey" # ZITADEL_ENCRYPTIONKEYS_USERAGENTCOOKIEKEYID @@ -910,6 +913,12 @@ DefaultInstance: IncludeUpperLetters: true # ZITADEL_DEFAULTINSTANCE_SECRETGENERATORS_INITIALIZEUSERCODE_INCLUDEUPPERLETTERS IncludeDigits: true # ZITADEL_DEFAULTINSTANCE_SECRETGENERATORS_INITIALIZEUSERCODE_INCLUDEDIGITS IncludeSymbols: false # ZITADEL_DEFAULTINSTANCE_SECRETGENERATORS_INITIALIZEUSERCODE_INCLUDESYMBOLS + SigningKey: + Length: 36 # ZITADEL_DEFAULTINSTANCE_SECRETGENERATORS_SIGNINGKEY_LENGTH + IncludeLowerLetters: true # ZITADEL_DEFAULTINSTANCE_SECRETGENERATORS_SIGNINGKEY_INCLUDELOWERLETTERS + IncludeUpperLetters: true # ZITADEL_DEFAULTINSTANCE_SECRETGENERATORS_SIGNINGKEY_INCLUDEUPPERLETTERS + IncludeDigits: true # ZITADEL_DEFAULTINSTANCE_SECRETGENERATORS_SIGNINGKEY_INCLUDEDIGITS + IncludeSymbols: false # ZITADEL_DEFAULTINSTANCE_SECRETGENERATORS_SIGNINGKEY_INCLUDESYMBOLS PasswordComplexityPolicy: MinLength: 8 # ZITADEL_DEFAULTINSTANCE_PASSWORDCOMPLEXITYPOLICY_MINLENGTH HasLowercase: true # ZITADEL_DEFAULTINSTANCE_PASSWORDCOMPLEXITYPOLICY_HASLOWERCASE diff --git a/cmd/encryption/encryption_keys.go b/cmd/encryption/encryption_keys.go index b4772e7957..9a26d572c0 100644 --- a/cmd/encryption/encryption_keys.go +++ b/cmd/encryption/encryption_keys.go @@ -17,6 +17,7 @@ var ( "smsKey", "smtpKey", "userKey", + "targetKey", "csrfCookieKey", "userAgentCookieKey", } @@ -31,6 +32,7 @@ type EncryptionKeyConfig struct { SMS *crypto.KeyConfig SMTP *crypto.KeyConfig User *crypto.KeyConfig + Target *crypto.KeyConfig CSRFCookieKeyID string UserAgentCookieKeyID string } @@ -44,6 +46,7 @@ type EncryptionKeys struct { SMS crypto.EncryptionAlgorithm SMTP crypto.EncryptionAlgorithm User crypto.EncryptionAlgorithm + Target crypto.EncryptionAlgorithm CSRFCookieKey []byte UserAgentCookieKey []byte OIDCKey []byte @@ -91,6 +94,10 @@ func EnsureEncryptionKeys(ctx context.Context, keyConfig *EncryptionKeyConfig, k if err != nil { return nil, err } + keys.Target, err = crypto.NewAESCrypto(keyConfig.Target, keyStorage) + if err != nil { + return nil, err + } key, err = crypto.LoadKey(keyConfig.CSRFCookieKeyID, keyStorage) if err != nil { return nil, err diff --git a/cmd/mirror/projections.go b/cmd/mirror/projections.go index f849d01217..ae903d90c5 100644 --- a/cmd/mirror/projections.go +++ b/cmd/mirror/projections.go @@ -145,6 +145,7 @@ func projections( keys.OTP, keys.OIDC, keys.SAML, + keys.Target, config.InternalAuthZ.RolePermissionMappings, sessionTokenVerifier, func(q *query.Queries) domain.PermissionCheck { @@ -183,6 +184,7 @@ func projections( keys.DomainVerification, keys.OIDC, keys.SAML, + keys.Target, &http.Client{}, func(ctx context.Context, permission, orgID, resourceID string) (err error) { return internal_authz.CheckPermission(ctx, authZRepo, config.InternalAuthZ.RolePermissionMappings, permission, orgID, resourceID) diff --git a/cmd/setup/03.go b/cmd/setup/03.go index 4d4231ea9c..588ac71610 100644 --- a/cmd/setup/03.go +++ b/cmd/setup/03.go @@ -86,6 +86,7 @@ func (mig *FirstInstance) Execute(ctx context.Context, _ eventstore.Event) error nil, nil, nil, + nil, 0, 0, 0, diff --git a/cmd/setup/config_change.go b/cmd/setup/config_change.go index f38508af2c..fb3ae08d52 100644 --- a/cmd/setup/config_change.go +++ b/cmd/setup/config_change.go @@ -53,6 +53,7 @@ func (mig *externalConfigChange) Execute(ctx context.Context, _ eventstore.Event nil, nil, nil, + nil, 0, 0, 0, diff --git a/cmd/setup/setup.go b/cmd/setup/setup.go index 1ad3037009..e9721c6b39 100644 --- a/cmd/setup/setup.go +++ b/cmd/setup/setup.go @@ -366,6 +366,7 @@ func initProjections( keys.OTP, keys.OIDC, keys.SAML, + keys.Target, config.InternalAuthZ.RolePermissionMappings, sessionTokenVerifier, func(q *query.Queries) domain.PermissionCheck { @@ -422,6 +423,7 @@ func initProjections( keys.DomainVerification, keys.OIDC, keys.SAML, + keys.Target, &http.Client{}, permissionCheck, sessionTokenVerifier, diff --git a/cmd/start/start.go b/cmd/start/start.go index c9147fe653..38a8450b46 100644 --- a/cmd/start/start.go +++ b/cmd/start/start.go @@ -196,6 +196,7 @@ func startZitadel(ctx context.Context, config *Config, masterKey string, server keys.OTP, keys.OIDC, keys.SAML, + keys.Target, config.InternalAuthZ.RolePermissionMappings, sessionTokenVerifier, func(q *query.Queries) domain.PermissionCheck { @@ -245,6 +246,7 @@ func startZitadel(ctx context.Context, config *Config, masterKey string, server keys.DomainVerification, keys.OIDC, keys.SAML, + keys.Target, &http.Client{}, permissionCheck, sessionTokenVerifier, diff --git a/docs/docs/apis/actions/v3/testing-locally.md b/docs/docs/apis/actions/v3/testing-locally.md index 7662c2bfe0..b5b3cb389f 100644 --- a/docs/docs/apis/actions/v3/testing-locally.md +++ b/docs/docs/apis/actions/v3/testing-locally.md @@ -48,6 +48,20 @@ func main() { What happens here is only a target which prints out the received request, which could also be handled with a different logic. +### Check Signature + +To additionally check the signature header you can add the following to the example: +```go + // validate signature + if err := actions.ValidatePayload(sentBody, req.Header.Get(actions.SigningHeader), signingKey); err != nil { + // if the signed content is not equal the sent content return an error + http.Error(w, "error", http.StatusInternalServerError) + return + } +``` + +Where you can replace 'signingKey' with the key received in the next step 'Create target'. + ## Create target As you see in the example above the target is created with HTTP and port '8090' and if we want to use it as webhook, the target can be created as follows: diff --git a/docs/docs/apis/actions/v3/usage.md b/docs/docs/apis/actions/v3/usage.md index 686c9d5445..2e89f3ce36 100644 --- a/docs/docs/apis/actions/v3/usage.md +++ b/docs/docs/apis/actions/v3/usage.md @@ -64,6 +64,13 @@ There are different types of Targets: The API documentation to create a target can be found [here](/apis/resources/action_service_v3/zitadel-actions-create-target) +### Content Signing + +To ensure the integrity of request content, each call includes a 'ZITADEL-Signature' in the headers. This header contains an HMAC value computed from the request content and a timestamp, which can be used to time out requests. The logic for this process is provided in 'pkg/actions/signing.go'. The goal is to verify that the HMAC value in the header matches the HMAC value computed by the Target, ensuring that the sent and received requests are identical. + +Each Target resource now contains also a Signing Key, which gets generated and returned when a Target is [created](/apis/resources/action_service_v3/zitadel-actions-create-target), +and can also be newly generated when a Target is [patched](/apis/resources/action_service_v3/zitadel-actions-patch-target). + ## Execution ZITADEL decides on specific conditions if one or more Targets have to be called. diff --git a/docs/package.json b/docs/package.json index e322206563..f9636418dd 100644 --- a/docs/package.json +++ b/docs/package.json @@ -17,7 +17,7 @@ "generate:grpc": "buf generate ../proto", "generate:apidocs": "docusaurus gen-api-docs all", "generate:configdocs": "cp -r ../cmd/defaults.yaml ./docs/self-hosting/manage/configure/ && cp -r ../cmd/setup/steps.yaml ./docs/self-hosting/manage/configure/", - "generate:re-gen": "yarn clean-all && yarn gen-all", + "generate:re-gen": "yarn generate:clean-all && yarn generate", "generate:clean-all": "docusaurus clean-api-docs all" }, "dependencies": { diff --git a/internal/api/grpc/resources/action/v3alpha/integration_test/query_test.go b/internal/api/grpc/resources/action/v3alpha/integration_test/query_test.go index 23fb860cd3..aa748ac4d8 100644 --- a/internal/api/grpc/resources/action/v3alpha/integration_test/query_test.go +++ b/internal/api/grpc/resources/action/v3alpha/integration_test/query_test.go @@ -62,6 +62,7 @@ func TestServer_GetTarget(t *testing.T) { request.Id = resp.GetDetails().GetId() response.Target.Config.Name = name response.Target.Details = resp.GetDetails() + response.Target.SigningKey = resp.GetSigningKey() return nil }, req: &action.GetTargetRequest{}, @@ -92,6 +93,7 @@ func TestServer_GetTarget(t *testing.T) { request.Id = resp.GetDetails().GetId() response.Target.Config.Name = name response.Target.Details = resp.GetDetails() + response.Target.SigningKey = resp.GetSigningKey() return nil }, req: &action.GetTargetRequest{}, @@ -122,6 +124,7 @@ func TestServer_GetTarget(t *testing.T) { request.Id = resp.GetDetails().GetId() response.Target.Config.Name = name response.Target.Details = resp.GetDetails() + response.Target.SigningKey = resp.GetSigningKey() return nil }, req: &action.GetTargetRequest{}, @@ -154,6 +157,7 @@ func TestServer_GetTarget(t *testing.T) { request.Id = resp.GetDetails().GetId() response.Target.Config.Name = name response.Target.Details = resp.GetDetails() + response.Target.SigningKey = resp.GetSigningKey() return nil }, req: &action.GetTargetRequest{}, @@ -186,6 +190,7 @@ func TestServer_GetTarget(t *testing.T) { request.Id = resp.GetDetails().GetId() response.Target.Config.Name = name response.Target.Details = resp.GetDetails() + response.Target.SigningKey = resp.GetSigningKey() return nil }, req: &action.GetTargetRequest{}, @@ -230,6 +235,7 @@ func TestServer_GetTarget(t *testing.T) { gotTarget := got.GetTarget() integration.AssertResourceDetails(ttt, wantTarget.GetDetails(), gotTarget.GetDetails()) assert.EqualExportedValues(ttt, wantTarget.GetConfig(), gotTarget.GetConfig()) + assert.Equal(ttt, wantTarget.GetSigningKey(), gotTarget.GetSigningKey()) }, retryDuration, tick, "timeout waiting for expected target result") }) } @@ -492,6 +498,7 @@ func TestServer_ListTargets(t *testing.T) { for i := range tt.want.Result { integration.AssertResourceDetails(ttt, tt.want.Result[i].GetDetails(), got.Result[i].GetDetails()) assert.EqualExportedValues(ttt, tt.want.Result[i].GetConfig(), got.Result[i].GetConfig()) + assert.NotEmpty(ttt, got.Result[i].GetSigningKey()) } } integration.AssertResourceListDetails(ttt, tt.want, got) diff --git a/internal/api/grpc/resources/action/v3alpha/integration_test/target_test.go b/internal/api/grpc/resources/action/v3alpha/integration_test/target_test.go index 04fa60982d..b5d1903ca6 100644 --- a/internal/api/grpc/resources/action/v3alpha/integration_test/target_test.go +++ b/internal/api/grpc/resources/action/v3alpha/integration_test/target_test.go @@ -9,6 +9,7 @@ import ( "github.com/brianvoe/gofakeit/v6" "github.com/muhlemmer/gu" + "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "google.golang.org/protobuf/types/known/durationpb" "google.golang.org/protobuf/types/known/timestamppb" @@ -200,11 +201,12 @@ func TestServer_CreateTarget(t *testing.T) { t.Run(tt.name, func(t *testing.T) { got, err := instance.Client.ActionV3Alpha.CreateTarget(tt.ctx, &action.CreateTargetRequest{Target: tt.req}) if tt.wantErr { - require.Error(t, err) - return + assert.Error(t, err) + } else { + assert.NoError(t, err) + integration.AssertResourceDetails(t, tt.want, got.Details) + assert.NotEmpty(t, got.GetSigningKey()) } - require.NoError(t, err) - integration.AssertResourceDetails(t, tt.want, got.Details) }) } } @@ -217,11 +219,15 @@ func TestServer_PatchTarget(t *testing.T) { ctx context.Context req *action.PatchTargetRequest } + type want struct { + details *resource_object.Details + signingKey bool + } tests := []struct { name string prepare func(request *action.PatchTargetRequest) error args args - want *resource_object.Details + want want wantErr bool }{ { @@ -272,14 +278,42 @@ func TestServer_PatchTarget(t *testing.T) { }, }, }, - want: &resource_object.Details{ - Changed: timestamppb.Now(), - Owner: &object.Owner{ - Type: object.OwnerType_OWNER_TYPE_INSTANCE, - Id: instance.ID(), + want: want{ + details: &resource_object.Details{ + Changed: timestamppb.Now(), + Owner: &object.Owner{ + Type: object.OwnerType_OWNER_TYPE_INSTANCE, + Id: instance.ID(), + }, }, }, }, + { + name: "regenerate signingkey, ok", + prepare: func(request *action.PatchTargetRequest) error { + targetID := instance.CreateTarget(isolatedIAMOwnerCTX, t, "", "https://example.com", domain.TargetTypeWebhook, false).GetDetails().GetId() + request.Id = targetID + return nil + }, + args: args{ + ctx: isolatedIAMOwnerCTX, + req: &action.PatchTargetRequest{ + Target: &action.PatchTarget{ + ExpirationSigningKey: durationpb.New(0 * time.Second), + }, + }, + }, + want: want{ + details: &resource_object.Details{ + Changed: timestamppb.Now(), + Owner: &object.Owner{ + Type: object.OwnerType_OWNER_TYPE_INSTANCE, + Id: instance.ID(), + }, + }, + signingKey: true, + }, + }, { name: "change type, ok", prepare: func(request *action.PatchTargetRequest) error { @@ -299,11 +333,13 @@ func TestServer_PatchTarget(t *testing.T) { }, }, }, - want: &resource_object.Details{ - Changed: timestamppb.Now(), - Owner: &object.Owner{ - Type: object.OwnerType_OWNER_TYPE_INSTANCE, - Id: instance.ID(), + want: want{ + details: &resource_object.Details{ + Changed: timestamppb.Now(), + Owner: &object.Owner{ + Type: object.OwnerType_OWNER_TYPE_INSTANCE, + Id: instance.ID(), + }, }, }, }, @@ -322,11 +358,13 @@ func TestServer_PatchTarget(t *testing.T) { }, }, }, - want: &resource_object.Details{ - Changed: timestamppb.Now(), - Owner: &object.Owner{ - Type: object.OwnerType_OWNER_TYPE_INSTANCE, - Id: instance.ID(), + want: want{ + details: &resource_object.Details{ + Changed: timestamppb.Now(), + Owner: &object.Owner{ + Type: object.OwnerType_OWNER_TYPE_INSTANCE, + Id: instance.ID(), + }, }, }, }, @@ -345,11 +383,13 @@ func TestServer_PatchTarget(t *testing.T) { }, }, }, - want: &resource_object.Details{ - Changed: timestamppb.Now(), - Owner: &object.Owner{ - Type: object.OwnerType_OWNER_TYPE_INSTANCE, - Id: instance.ID(), + want: want{ + details: &resource_object.Details{ + Changed: timestamppb.Now(), + Owner: &object.Owner{ + Type: object.OwnerType_OWNER_TYPE_INSTANCE, + Id: instance.ID(), + }, }, }, }, @@ -370,11 +410,13 @@ func TestServer_PatchTarget(t *testing.T) { }, }, }, - want: &resource_object.Details{ - Changed: timestamppb.Now(), - Owner: &object.Owner{ - Type: object.OwnerType_OWNER_TYPE_INSTANCE, - Id: instance.ID(), + want: want{ + details: &resource_object.Details{ + Changed: timestamppb.Now(), + Owner: &object.Owner{ + Type: object.OwnerType_OWNER_TYPE_INSTANCE, + Id: instance.ID(), + }, }, }, }, @@ -387,11 +429,14 @@ func TestServer_PatchTarget(t *testing.T) { instance.Client.ActionV3Alpha.PatchTarget(tt.args.ctx, tt.args.req) got, err := instance.Client.ActionV3Alpha.PatchTarget(tt.args.ctx, tt.args.req) if tt.wantErr { - require.Error(t, err) - return + assert.Error(t, err) + } else { + assert.NoError(t, err) + integration.AssertResourceDetails(t, tt.want.details, got.Details) + if tt.want.signingKey { + assert.NotEmpty(t, got.SigningKey) + } } - require.NoError(t, err) - integration.AssertResourceDetails(t, tt.want, got.Details) }) } } @@ -443,11 +488,12 @@ func TestServer_DeleteTarget(t *testing.T) { t.Run(tt.name, func(t *testing.T) { got, err := instance.Client.ActionV3Alpha.DeleteTarget(tt.ctx, tt.req) if tt.wantErr { - require.Error(t, err) + assert.Error(t, err) return + } else { + assert.NoError(t, err) + integration.AssertResourceDetails(t, tt.want, got.Details) } - require.NoError(t, err) - integration.AssertResourceDetails(t, tt.want, got.Details) }) } } diff --git a/internal/api/grpc/resources/action/v3alpha/query.go b/internal/api/grpc/resources/action/v3alpha/query.go index ec7ed8b9c8..7cdedd8134 100644 --- a/internal/api/grpc/resources/action/v3alpha/query.go +++ b/internal/api/grpc/resources/action/v3alpha/query.go @@ -97,6 +97,7 @@ func targetToPb(t *query.Target) *action.GetTarget { Timeout: durationpb.New(t.Timeout), Endpoint: t.Endpoint, }, + SigningKey: t.SigningKey, } switch t.TargetType { case domain.TargetTypeWebhook: diff --git a/internal/api/grpc/resources/action/v3alpha/target.go b/internal/api/grpc/resources/action/v3alpha/target.go index 031cd99477..621b6677b7 100644 --- a/internal/api/grpc/resources/action/v3alpha/target.go +++ b/internal/api/grpc/resources/action/v3alpha/target.go @@ -25,7 +25,8 @@ func (s *Server) CreateTarget(ctx context.Context, req *action.CreateTargetReque return nil, err } return &action.CreateTargetResponse{ - Details: resource_object.DomainToDetailsPb(details, object.OwnerType_OWNER_TYPE_INSTANCE, instanceID), + Details: resource_object.DomainToDetailsPb(details, object.OwnerType_OWNER_TYPE_INSTANCE, instanceID), + SigningKey: add.SigningKey, }, nil } @@ -34,12 +35,14 @@ func (s *Server) PatchTarget(ctx context.Context, req *action.PatchTargetRequest return nil, err } instanceID := authz.GetInstance(ctx).InstanceID() - details, err := s.command.ChangeTarget(ctx, patchTargetToCommand(req), instanceID) + patch := patchTargetToCommand(req) + details, err := s.command.ChangeTarget(ctx, patch, instanceID) if err != nil { return nil, err } return &action.PatchTargetResponse{ - Details: resource_object.DomainToDetailsPb(details, object.OwnerType_OWNER_TYPE_INSTANCE, instanceID), + Details: resource_object.DomainToDetailsPb(details, object.OwnerType_OWNER_TYPE_INSTANCE, instanceID), + SigningKey: patch.SigningKey, }, nil } @@ -83,6 +86,12 @@ func createTargetToCommand(req *action.CreateTargetRequest) *command.AddTarget { } func patchTargetToCommand(req *action.PatchTargetRequest) *command.ChangeTarget { + expirationSigningKey := false + // TODO handle expiration, currently only immediate expiration is supported + if req.GetTarget().GetExpirationSigningKey() != nil { + expirationSigningKey = true + } + reqTarget := req.GetTarget() if reqTarget == nil { return nil @@ -91,8 +100,9 @@ func patchTargetToCommand(req *action.PatchTargetRequest) *command.ChangeTarget ObjectRoot: models.ObjectRoot{ AggregateID: req.GetId(), }, - Name: reqTarget.Name, - Endpoint: reqTarget.Endpoint, + Name: reqTarget.Name, + Endpoint: reqTarget.Endpoint, + ExpirationSigningKey: expirationSigningKey, } if reqTarget.TargetType != nil { switch t := reqTarget.GetTargetType().(type) { diff --git a/internal/api/grpc/server/middleware/execution_interceptor_test.go b/internal/api/grpc/server/middleware/execution_interceptor_test.go index f59fd00441..6a5b74c5e4 100644 --- a/internal/api/grpc/server/middleware/execution_interceptor_test.go +++ b/internal/api/grpc/server/middleware/execution_interceptor_test.go @@ -26,6 +26,7 @@ type mockExecutionTarget struct { Endpoint string Timeout time.Duration InterruptOnError bool + SigningKey string } func (e *mockExecutionTarget) SetEndpoint(endpoint string) { @@ -49,6 +50,9 @@ func (e *mockExecutionTarget) GetTargetID() string { func (e *mockExecutionTarget) GetExecutionID() string { return e.ExecutionID } +func (e *mockExecutionTarget) GetSigningKey() string { + return e.SigningKey +} type mockContentRequest struct { Content string @@ -157,6 +161,7 @@ func Test_executeTargetsForGRPCFullMethod_request(t *testing.T) { TargetID: "target", TargetType: domain.TargetTypeCall, Timeout: time.Minute, + SigningKey: "signingkey", }, }, targets: []target{ @@ -186,6 +191,7 @@ func Test_executeTargetsForGRPCFullMethod_request(t *testing.T) { TargetType: domain.TargetTypeCall, Timeout: time.Minute, InterruptOnError: true, + SigningKey: "signingkey", }, }, @@ -216,6 +222,7 @@ func Test_executeTargetsForGRPCFullMethod_request(t *testing.T) { TargetType: domain.TargetTypeCall, Timeout: time.Second, InterruptOnError: true, + SigningKey: "signingkey", }, }, targets: []target{ @@ -245,6 +252,7 @@ func Test_executeTargetsForGRPCFullMethod_request(t *testing.T) { TargetType: domain.TargetTypeCall, Timeout: time.Second, InterruptOnError: true, + SigningKey: "signingkey", }, }, targets: []target{ @@ -269,6 +277,7 @@ func Test_executeTargetsForGRPCFullMethod_request(t *testing.T) { TargetType: domain.TargetTypeCall, Timeout: time.Minute, InterruptOnError: true, + SigningKey: "signingkey", }, }, targets: []target{ @@ -297,6 +306,7 @@ func Test_executeTargetsForGRPCFullMethod_request(t *testing.T) { TargetID: "target", TargetType: domain.TargetTypeAsync, Timeout: time.Second, + SigningKey: "signingkey", }, }, targets: []target{ @@ -325,6 +335,7 @@ func Test_executeTargetsForGRPCFullMethod_request(t *testing.T) { TargetID: "target", TargetType: domain.TargetTypeAsync, Timeout: time.Minute, + SigningKey: "signingkey", }, }, targets: []target{ @@ -354,6 +365,7 @@ func Test_executeTargetsForGRPCFullMethod_request(t *testing.T) { TargetType: domain.TargetTypeWebhook, Timeout: time.Minute, InterruptOnError: true, + SigningKey: "signingkey", }, }, targets: []target{ @@ -382,6 +394,7 @@ func Test_executeTargetsForGRPCFullMethod_request(t *testing.T) { TargetType: domain.TargetTypeWebhook, Timeout: time.Second, InterruptOnError: true, + SigningKey: "signingkey", }, }, targets: []target{ @@ -411,6 +424,7 @@ func Test_executeTargetsForGRPCFullMethod_request(t *testing.T) { TargetType: domain.TargetTypeWebhook, Timeout: time.Minute, InterruptOnError: true, + SigningKey: "signingkey", }, }, targets: []target{ @@ -440,6 +454,7 @@ func Test_executeTargetsForGRPCFullMethod_request(t *testing.T) { TargetType: domain.TargetTypeCall, Timeout: time.Minute, InterruptOnError: true, + SigningKey: "signingkey", }, &mockExecutionTarget{ InstanceID: "instance", @@ -448,6 +463,7 @@ func Test_executeTargetsForGRPCFullMethod_request(t *testing.T) { TargetType: domain.TargetTypeCall, Timeout: time.Minute, InterruptOnError: true, + SigningKey: "signingkey", }, &mockExecutionTarget{ InstanceID: "instance", @@ -456,6 +472,7 @@ func Test_executeTargetsForGRPCFullMethod_request(t *testing.T) { TargetType: domain.TargetTypeCall, Timeout: time.Minute, InterruptOnError: true, + SigningKey: "signingkey", }, }, @@ -498,6 +515,7 @@ func Test_executeTargetsForGRPCFullMethod_request(t *testing.T) { TargetType: domain.TargetTypeCall, Timeout: time.Minute, InterruptOnError: true, + SigningKey: "signingkey", }, &mockExecutionTarget{ InstanceID: "instance", @@ -506,6 +524,7 @@ func Test_executeTargetsForGRPCFullMethod_request(t *testing.T) { TargetType: domain.TargetTypeCall, Timeout: time.Second, InterruptOnError: true, + SigningKey: "signingkey", }, &mockExecutionTarget{ InstanceID: "instance", @@ -514,6 +533,7 @@ func Test_executeTargetsForGRPCFullMethod_request(t *testing.T) { TargetType: domain.TargetTypeCall, Timeout: time.Second, InterruptOnError: true, + SigningKey: "signingkey", }, }, targets: []target{ @@ -692,6 +712,7 @@ func Test_executeTargetsForGRPCFullMethod_response(t *testing.T) { TargetType: domain.TargetTypeCall, Timeout: time.Minute, InterruptOnError: true, + SigningKey: "signingkey", }, }, targets: []target{ @@ -721,6 +742,7 @@ func Test_executeTargetsForGRPCFullMethod_response(t *testing.T) { TargetType: domain.TargetTypeCall, Timeout: time.Minute, InterruptOnError: true, + SigningKey: "signingkey", }, }, targets: []target{ diff --git a/internal/command/action_v2_execution_test.go b/internal/command/action_v2_execution_test.go index be05929695..6833125a0a 100644 --- a/internal/command/action_v2_execution_test.go +++ b/internal/command/action_v2_execution_test.go @@ -7,6 +7,7 @@ import ( "github.com/stretchr/testify/assert" + "github.com/zitadel/zitadel/internal/crypto" "github.com/zitadel/zitadel/internal/domain" "github.com/zitadel/zitadel/internal/eventstore" "github.com/zitadel/zitadel/internal/repository/execution" @@ -172,6 +173,12 @@ func TestCommands_SetExecutionRequest(t *testing.T) { "https://example.com", time.Second, true, + &crypto.CryptoValue{ + CryptoType: crypto.TypeEncryption, + Algorithm: "enc", + KeyID: "id", + Crypted: []byte("12345678"), + }, ), ), ), @@ -221,6 +228,12 @@ func TestCommands_SetExecutionRequest(t *testing.T) { "https://example.com", time.Second, true, + &crypto.CryptoValue{ + CryptoType: crypto.TypeEncryption, + Algorithm: "enc", + KeyID: "id", + Crypted: []byte("12345678"), + }, ), ), ), @@ -270,6 +283,12 @@ func TestCommands_SetExecutionRequest(t *testing.T) { "https://example.com", time.Second, true, + &crypto.CryptoValue{ + CryptoType: crypto.TypeEncryption, + Algorithm: "enc", + KeyID: "id", + Crypted: []byte("12345678"), + }, ), ), ), @@ -836,6 +855,12 @@ func TestCommands_SetExecutionResponse(t *testing.T) { "https://example.com", time.Second, true, + &crypto.CryptoValue{ + CryptoType: crypto.TypeEncryption, + Algorithm: "enc", + KeyID: "id", + Crypted: []byte("12345678"), + }, ), ), expectPushFailed( @@ -930,6 +955,12 @@ func TestCommands_SetExecutionResponse(t *testing.T) { "https://example.com", time.Second, true, + &crypto.CryptoValue{ + CryptoType: crypto.TypeEncryption, + Algorithm: "enc", + KeyID: "id", + Crypted: []byte("12345678"), + }, ), ), ), diff --git a/internal/command/action_v2_target.go b/internal/command/action_v2_target.go index d1f06b79b2..95dd097ed0 100644 --- a/internal/command/action_v2_target.go +++ b/internal/command/action_v2_target.go @@ -5,6 +5,8 @@ import ( "net/url" "time" + "github.com/zitadel/zitadel/internal/command/preparation" + "github.com/zitadel/zitadel/internal/crypto" "github.com/zitadel/zitadel/internal/domain" "github.com/zitadel/zitadel/internal/eventstore/v1/models" "github.com/zitadel/zitadel/internal/repository/target" @@ -19,6 +21,8 @@ type AddTarget struct { Endpoint string Timeout time.Duration InterruptOnError bool + + SigningKey string } func (a *AddTarget) IsValid() error { @@ -58,7 +62,11 @@ func (c *Commands) AddTarget(ctx context.Context, add *AddTarget, resourceOwner if wm.State.Exists() { return nil, zerrors.ThrowAlreadyExists(nil, "INSTANCE-9axkz0jvzm", "Errors.Target.AlreadyExists") } - + code, err := c.newSigningKey(ctx, c.eventstore.Filter, c.targetEncryption) //nolint + if err != nil { + return nil, err + } + add.SigningKey = code.PlainCode() pushedEvents, err := c.eventstore.Push(ctx, target.NewAddedEvent( ctx, TargetAggregateFromWriteModel(&wm.WriteModel), @@ -67,6 +75,7 @@ func (c *Commands) AddTarget(ctx context.Context, add *AddTarget, resourceOwner add.Endpoint, add.Timeout, add.InterruptOnError, + code.Crypted, )) if err != nil { return nil, err @@ -85,6 +94,9 @@ type ChangeTarget struct { Endpoint *string Timeout *time.Duration InterruptOnError *bool + + ExpirationSigningKey bool + SigningKey *string } func (a *ChangeTarget) IsValid() error { @@ -120,6 +132,17 @@ func (c *Commands) ChangeTarget(ctx context.Context, change *ChangeTarget, resou if !existing.State.Exists() { return nil, zerrors.ThrowNotFound(nil, "COMMAND-xj14f2cccn", "Errors.Target.NotFound") } + + var changedSigningKey *crypto.CryptoValue + if change.ExpirationSigningKey { + code, err := c.newSigningKey(ctx, c.eventstore.Filter, c.targetEncryption) //nolint + if err != nil { + return nil, err + } + changedSigningKey = code.Crypted + change.SigningKey = &code.Plain + } + changedEvent := existing.NewChangedEvent( ctx, TargetAggregateFromWriteModel(&existing.WriteModel), @@ -127,7 +150,9 @@ func (c *Commands) ChangeTarget(ctx context.Context, change *ChangeTarget, resou change.TargetType, change.Endpoint, change.Timeout, - change.InterruptOnError) + change.InterruptOnError, + changedSigningKey, + ) if changedEvent == nil { return writeModelToObjectDetails(&existing.WriteModel), nil } @@ -184,3 +209,7 @@ func (c *Commands) getTargetWriteModelByID(ctx context.Context, id string, resou } return wm, nil } + +func (c *Commands) newSigningKey(ctx context.Context, filter preparation.FilterToQueryReducer, alg crypto.EncryptionAlgorithm) (*EncryptedCode, error) { + return c.newEncryptedCodeWithDefault(ctx, filter, domain.SecretGeneratorTypeSigningKey, alg, c.defaultSecretGenerators.SigningKey) +} diff --git a/internal/command/action_v2_target_model.go b/internal/command/action_v2_target_model.go index 24dd76c80a..cf20c9923d 100644 --- a/internal/command/action_v2_target_model.go +++ b/internal/command/action_v2_target_model.go @@ -5,6 +5,7 @@ import ( "slices" "time" + "github.com/zitadel/zitadel/internal/crypto" "github.com/zitadel/zitadel/internal/domain" "github.com/zitadel/zitadel/internal/eventstore" "github.com/zitadel/zitadel/internal/repository/target" @@ -18,6 +19,7 @@ type TargetWriteModel struct { Endpoint string Timeout time.Duration InterruptOnError bool + SigningKey *crypto.CryptoValue State domain.TargetState } @@ -41,6 +43,7 @@ func (wm *TargetWriteModel) Reduce() error { wm.Endpoint = e.Endpoint wm.Timeout = e.Timeout wm.State = domain.TargetActive + wm.SigningKey = e.SigningKey case *target.ChangedEvent: if e.Name != nil { wm.Name = *e.Name @@ -57,6 +60,9 @@ func (wm *TargetWriteModel) Reduce() error { if e.InterruptOnError != nil { wm.InterruptOnError = *e.InterruptOnError } + if e.SigningKey != nil { + wm.SigningKey = e.SigningKey + } case *target.RemovedEvent: wm.State = domain.TargetRemoved } @@ -84,6 +90,7 @@ func (wm *TargetWriteModel) NewChangedEvent( endpoint *string, timeout *time.Duration, interruptOnError *bool, + signingKey *crypto.CryptoValue, ) *target.ChangedEvent { changes := make([]target.Changes, 0) if name != nil && wm.Name != *name { @@ -101,6 +108,10 @@ func (wm *TargetWriteModel) NewChangedEvent( if interruptOnError != nil && wm.InterruptOnError != *interruptOnError { changes = append(changes, target.ChangeInterruptOnError(*interruptOnError)) } + // if signingkey is set, update it as it is encrypted + if signingKey != nil { + changes = append(changes, target.ChangeSigningKey(signingKey)) + } if len(changes) == 0 { return nil } diff --git a/internal/command/action_v2_target_model_test.go b/internal/command/action_v2_target_model_test.go index 8042da23b1..e8c40c04c8 100644 --- a/internal/command/action_v2_target_model_test.go +++ b/internal/command/action_v2_target_model_test.go @@ -7,6 +7,7 @@ import ( "github.com/stretchr/testify/assert" + "github.com/zitadel/zitadel/internal/crypto" "github.com/zitadel/zitadel/internal/domain" "github.com/zitadel/zitadel/internal/eventstore" "github.com/zitadel/zitadel/internal/repository/target" @@ -20,6 +21,12 @@ func targetAddEvent(aggID, resourceOwner string) *target.AddedEvent { "https://example.com", time.Second, false, + &crypto.CryptoValue{ + CryptoType: crypto.TypeEncryption, + Algorithm: "enc", + KeyID: "id", + Crypted: []byte("12345678"), + }, ) } diff --git a/internal/command/action_v2_target_test.go b/internal/command/action_v2_target_test.go index 12f76c4629..ed7d6163a0 100644 --- a/internal/command/action_v2_target_test.go +++ b/internal/command/action_v2_target_test.go @@ -8,6 +8,7 @@ import ( "github.com/muhlemmer/gu" "github.com/stretchr/testify/assert" + "github.com/zitadel/zitadel/internal/crypto" "github.com/zitadel/zitadel/internal/domain" "github.com/zitadel/zitadel/internal/eventstore" "github.com/zitadel/zitadel/internal/eventstore/v1/models" @@ -19,8 +20,10 @@ import ( func TestCommands_AddTarget(t *testing.T) { type fields struct { - eventstore func(t *testing.T) *eventstore.Eventstore - idGenerator id.Generator + eventstore func(t *testing.T) *eventstore.Eventstore + idGenerator id.Generator + newEncryptedCodeWithDefault encryptedCodeWithDefaultFunc + defaultSecretGenerators *SecretGenerators } type args struct { ctx context.Context @@ -132,10 +135,18 @@ func TestCommands_AddTarget(t *testing.T) { "https://example.com", time.Second, false, + &crypto.CryptoValue{ + CryptoType: crypto.TypeEncryption, + Algorithm: "enc", + KeyID: "id", + Crypted: []byte("12345678"), + }, ), ), ), - idGenerator: mock.ExpectID(t, "id1"), + idGenerator: mock.ExpectID(t, "id1"), + newEncryptedCodeWithDefault: mockEncryptedCodeWithDefault("12345678", time.Hour), + defaultSecretGenerators: &SecretGenerators{}, }, args{ ctx: context.Background(), @@ -186,7 +197,9 @@ func TestCommands_AddTarget(t *testing.T) { targetAddEvent("id1", "instance"), ), ), - idGenerator: mock.ExpectID(t, "id1"), + idGenerator: mock.ExpectID(t, "id1"), + newEncryptedCodeWithDefault: mockEncryptedCodeWithDefault("12345678", time.Hour), + defaultSecretGenerators: &SecretGenerators{}, }, args{ ctx: context.Background(), @@ -219,7 +232,9 @@ func TestCommands_AddTarget(t *testing.T) { }(), ), ), - idGenerator: mock.ExpectID(t, "id1"), + idGenerator: mock.ExpectID(t, "id1"), + newEncryptedCodeWithDefault: mockEncryptedCodeWithDefault("12345678", time.Hour), + defaultSecretGenerators: &SecretGenerators{}, }, args{ ctx: context.Background(), @@ -244,8 +259,10 @@ func TestCommands_AddTarget(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { c := &Commands{ - eventstore: tt.fields.eventstore(t), - idGenerator: tt.fields.idGenerator, + eventstore: tt.fields.eventstore(t), + idGenerator: tt.fields.idGenerator, + newEncryptedCodeWithDefault: tt.fields.newEncryptedCodeWithDefault, + defaultSecretGenerators: tt.fields.defaultSecretGenerators, } details, err := c.AddTarget(tt.args.ctx, tt.args.add, tt.args.resourceOwner) if tt.res.err == nil { @@ -264,7 +281,9 @@ func TestCommands_AddTarget(t *testing.T) { func TestCommands_ChangeTarget(t *testing.T) { type fields struct { - eventstore func(t *testing.T) *eventstore.Eventstore + eventstore func(t *testing.T) *eventstore.Eventstore + newEncryptedCodeWithDefault encryptedCodeWithDefaultFunc + defaultSecretGenerators *SecretGenerators } type args struct { ctx context.Context @@ -510,10 +529,18 @@ func TestCommands_ChangeTarget(t *testing.T) { target.ChangeTargetType(domain.TargetTypeCall), target.ChangeTimeout(10 * time.Second), target.ChangeInterruptOnError(true), + target.ChangeSigningKey(&crypto.CryptoValue{ + CryptoType: crypto.TypeEncryption, + Algorithm: "enc", + KeyID: "id", + Crypted: []byte("12345678"), + }), }, ), ), ), + newEncryptedCodeWithDefault: mockEncryptedCodeWithDefault("12345678", time.Hour), + defaultSecretGenerators: &SecretGenerators{}, }, args{ ctx: context.Background(), @@ -521,11 +548,12 @@ func TestCommands_ChangeTarget(t *testing.T) { ObjectRoot: models.ObjectRoot{ AggregateID: "id1", }, - Name: gu.Ptr("name2"), - Endpoint: gu.Ptr("https://example2.com"), - TargetType: gu.Ptr(domain.TargetTypeCall), - Timeout: gu.Ptr(10 * time.Second), - InterruptOnError: gu.Ptr(true), + Name: gu.Ptr("name2"), + Endpoint: gu.Ptr("https://example2.com"), + TargetType: gu.Ptr(domain.TargetTypeCall), + Timeout: gu.Ptr(10 * time.Second), + InterruptOnError: gu.Ptr(true), + ExpirationSigningKey: true, }, resourceOwner: "instance", }, @@ -540,7 +568,9 @@ func TestCommands_ChangeTarget(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { c := &Commands{ - eventstore: tt.fields.eventstore(t), + eventstore: tt.fields.eventstore(t), + newEncryptedCodeWithDefault: tt.fields.newEncryptedCodeWithDefault, + defaultSecretGenerators: tt.fields.defaultSecretGenerators, } details, err := c.ChangeTarget(tt.args.ctx, tt.args.change, tt.args.resourceOwner) if tt.res.err == nil { diff --git a/internal/command/command.go b/internal/command/command.go index bc3f189a4a..ab047fccdb 100644 --- a/internal/command/command.go +++ b/internal/command/command.go @@ -54,6 +54,7 @@ type Commands struct { smtpEncryption crypto.EncryptionAlgorithm smsEncryption crypto.EncryptionAlgorithm userEncryption crypto.EncryptionAlgorithm + targetEncryption crypto.EncryptionAlgorithm userPasswordHasher *crypto.Hasher secretHasher *crypto.Hasher machineKeySize int @@ -108,7 +109,7 @@ func StartCommands( externalDomain string, externalSecure bool, externalPort uint16, - idpConfigEncryption, otpEncryption, smtpEncryption, smsEncryption, userEncryption, domainVerificationEncryption, oidcEncryption, samlEncryption crypto.EncryptionAlgorithm, + idpConfigEncryption, otpEncryption, smtpEncryption, smsEncryption, userEncryption, domainVerificationEncryption, oidcEncryption, samlEncryption, targetEncryption crypto.EncryptionAlgorithm, httpClient *http.Client, permissionCheck domain.PermissionCheck, sessionTokenVerifier func(ctx context.Context, sessionToken string, sessionID string, tokenID string) (err error), @@ -153,6 +154,7 @@ func StartCommands( smtpEncryption: smtpEncryption, smsEncryption: smsEncryption, userEncryption: userEncryption, + targetEncryption: targetEncryption, userPasswordHasher: userPasswordHasher, secretHasher: secretHasher, machineKeySize: int(defaults.SecretGenerators.MachineKeySize), diff --git a/internal/command/instance.go b/internal/command/instance.go index 3491aaf4a2..c5ac4d8472 100644 --- a/internal/command/instance.go +++ b/internal/command/instance.go @@ -157,6 +157,7 @@ type SecretGenerators struct { OTPSMS *crypto.GeneratorConfig OTPEmail *crypto.GeneratorConfig InviteCode *crypto.GeneratorConfig + SigningKey *crypto.GeneratorConfig } type ZitadelConfig struct { diff --git a/internal/domain/secret_generator.go b/internal/domain/secret_generator.go index 855e3447c1..25998bd205 100644 --- a/internal/domain/secret_generator.go +++ b/internal/domain/secret_generator.go @@ -15,6 +15,7 @@ const ( SecretGeneratorTypeOTPSMS SecretGeneratorTypeOTPEmail SecretGeneratorTypeInviteCode + SecretGeneratorTypeSigningKey secretGeneratorTypeCount ) diff --git a/internal/execution/execution.go b/internal/execution/execution.go index c4756b86a2..99d7f6182f 100644 --- a/internal/execution/execution.go +++ b/internal/execution/execution.go @@ -14,6 +14,7 @@ import ( "github.com/zitadel/zitadel/internal/domain" "github.com/zitadel/zitadel/internal/telemetry/tracing" "github.com/zitadel/zitadel/internal/zerrors" + "github.com/zitadel/zitadel/pkg/actions" ) type ContextInfo interface { @@ -28,6 +29,7 @@ type Target interface { GetEndpoint() string GetTargetType() domain.TargetType GetTimeout() time.Duration + GetSigningKey() string } // CallTargets call a list of targets in order with handling of error and responses @@ -72,13 +74,13 @@ func CallTarget( switch target.GetTargetType() { // get request, ignore response and return request and error for handling in list of targets case domain.TargetTypeWebhook: - return nil, webhook(ctx, target.GetEndpoint(), target.GetTimeout(), info.GetHTTPRequestBody()) + return nil, webhook(ctx, target.GetEndpoint(), target.GetTimeout(), info.GetHTTPRequestBody(), target.GetSigningKey()) // get request, return response and error case domain.TargetTypeCall: - return Call(ctx, target.GetEndpoint(), target.GetTimeout(), info.GetHTTPRequestBody()) + return Call(ctx, target.GetEndpoint(), target.GetTimeout(), info.GetHTTPRequestBody(), target.GetSigningKey()) case domain.TargetTypeAsync: go func(target Target, info ContextInfoRequest) { - if _, err := Call(ctx, target.GetEndpoint(), target.GetTimeout(), info.GetHTTPRequestBody()); err != nil { + if _, err := Call(ctx, target.GetEndpoint(), target.GetTimeout(), info.GetHTTPRequestBody(), target.GetSigningKey()); err != nil { logging.WithFields("target", target.GetTargetID()).OnError(err).Info(err) } }(target, info) @@ -89,13 +91,13 @@ func CallTarget( } // webhook call a webhook, ignore the response but return the errror -func webhook(ctx context.Context, url string, timeout time.Duration, body []byte) error { - _, err := Call(ctx, url, timeout, body) +func webhook(ctx context.Context, url string, timeout time.Duration, body []byte, signingKey string) error { + _, err := Call(ctx, url, timeout, body, signingKey) return err } // Call function to do a post HTTP request to a desired url with timeout -func Call(ctx context.Context, url string, timeout time.Duration, body []byte) (_ []byte, err error) { +func Call(ctx context.Context, url string, timeout time.Duration, body []byte, signingKey string) (_ []byte, err error) { ctx, cancel := context.WithTimeout(ctx, timeout) ctx, span := tracing.NewSpan(ctx) defer func() { @@ -108,6 +110,9 @@ func Call(ctx context.Context, url string, timeout time.Duration, body []byte) ( return nil, err } req.Header.Set("Content-Type", "application/json") + if signingKey != "" { + req.Header.Set(actions.SigningHeader, actions.ComputeSignatureHeader(time.Now(), body, signingKey)) + } client := http.DefaultClient resp, err := client.Do(req) diff --git a/internal/execution/execution_test.go b/internal/execution/execution_test.go index 184823f9b2..5a45d96625 100644 --- a/internal/execution/execution_test.go +++ b/internal/execution/execution_test.go @@ -18,6 +18,7 @@ import ( "github.com/zitadel/zitadel/internal/domain" "github.com/zitadel/zitadel/internal/execution" "github.com/zitadel/zitadel/internal/zerrors" + "github.com/zitadel/zitadel/pkg/actions" ) func Test_Call(t *testing.T) { @@ -29,6 +30,7 @@ func Test_Call(t *testing.T) { body []byte respBody []byte statusCode int + signingKey string } type res struct { body []byte @@ -84,6 +86,22 @@ func Test_Call(t *testing.T) { body: []byte("{\"response\": \"values\"}"), }, }, + { + "ok, signed", + args{ + ctx: context.Background(), + timeout: time.Minute, + sleep: time.Second, + method: http.MethodPost, + body: []byte("{\"request\": \"values\"}"), + respBody: []byte("{\"response\": \"values\"}"), + statusCode: http.StatusOK, + signingKey: "signingkey", + }, + res{ + body: []byte("{\"response\": \"values\"}"), + }, + }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { @@ -95,7 +113,7 @@ func Test_Call(t *testing.T) { statusCode: tt.args.statusCode, respondBody: tt.args.respBody, }, - testCall(tt.args.ctx, tt.args.timeout, tt.args.body), + testCall(tt.args.ctx, tt.args.timeout, tt.args.body, tt.args.signingKey), ) if tt.res.wantErr { assert.Error(t, err) @@ -186,6 +204,29 @@ func Test_CallTarget(t *testing.T) { body: nil, }, }, + { + "webhook, signed, ok", + args{ + ctx: context.Background(), + info: requestContextInfo1, + server: &callTestServer{ + timeout: time.Second, + method: http.MethodPost, + expectBody: []byte("{\"request\":{\"request\":\"content1\"}}"), + respondBody: []byte("{\"request\":\"content2\"}"), + statusCode: http.StatusOK, + signingKey: "signingkey", + }, + target: &mockTarget{ + TargetType: domain.TargetTypeWebhook, + Timeout: time.Minute, + SigningKey: "signingkey", + }, + }, + res{ + body: nil, + }, + }, { "request response, error", args{ @@ -228,6 +269,29 @@ func Test_CallTarget(t *testing.T) { body: []byte("{\"request\":\"content2\"}"), }, }, + { + "request response, signed, ok", + args{ + ctx: context.Background(), + info: requestContextInfo1, + server: &callTestServer{ + timeout: time.Second, + method: http.MethodPost, + expectBody: []byte("{\"request\":{\"request\":\"content1\"}}"), + respondBody: []byte("{\"request\":\"content2\"}"), + statusCode: http.StatusOK, + signingKey: "signingkey", + }, + target: &mockTarget{ + TargetType: domain.TargetTypeCall, + Timeout: time.Minute, + SigningKey: "signingkey", + }, + }, + res{ + body: []byte("{\"request\":\"content2\"}"), + }, + }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { @@ -392,6 +456,7 @@ type mockTarget struct { Endpoint string Timeout time.Duration InterruptOnError bool + SigningKey string } func (e *mockTarget) GetTargetID() string { @@ -409,6 +474,9 @@ func (e *mockTarget) GetTargetType() domain.TargetType { func (e *mockTarget) GetTimeout() time.Duration { return e.Timeout } +func (e *mockTarget) GetSigningKey() string { + return e.SigningKey +} type callTestServer struct { method string @@ -416,6 +484,7 @@ type callTestServer struct { timeout time.Duration statusCode int respondBody []byte + signingKey string } func testServers( @@ -447,7 +516,7 @@ func listen( c *callTestServer, ) (url string, close func()) { handler := func(w http.ResponseWriter, r *http.Request) { - checkRequest(t, r, c.method, c.expectBody) + checkRequest(t, r, c.method, c.expectBody, c.signingKey) if c.statusCode != http.StatusOK { http.Error(w, "error", c.statusCode) @@ -466,16 +535,19 @@ func listen( return server.URL, server.Close } -func checkRequest(t *testing.T, sent *http.Request, method string, expectedBody []byte) { +func checkRequest(t *testing.T, sent *http.Request, method string, expectedBody []byte, signingKey string) { sentBody, err := io.ReadAll(sent.Body) require.NoError(t, err) require.Equal(t, expectedBody, sentBody) require.Equal(t, method, sent.Method) + if signingKey != "" { + require.NoError(t, actions.ValidatePayload(sentBody, sent.Header.Get(actions.SigningHeader), signingKey)) + } } -func testCall(ctx context.Context, timeout time.Duration, body []byte) func(string) ([]byte, error) { +func testCall(ctx context.Context, timeout time.Duration, body []byte, signingKey string) func(string) ([]byte, error) { return func(url string) ([]byte, error) { - return execution.Call(ctx, url, timeout, body) + return execution.Call(ctx, url, timeout, body, signingKey) } } diff --git a/internal/query/execution.go b/internal/query/execution.go index 5ce5e36a94..b98c680f57 100644 --- a/internal/query/execution.go +++ b/internal/query/execution.go @@ -11,6 +11,7 @@ import ( sq "github.com/Masterminds/squirrel" "github.com/zitadel/zitadel/internal/api/authz" + "github.com/zitadel/zitadel/internal/crypto" "github.com/zitadel/zitadel/internal/database" "github.com/zitadel/zitadel/internal/domain" "github.com/zitadel/zitadel/internal/query/projection" @@ -175,6 +176,11 @@ func (q *Queries) TargetsByExecutionID(ctx context.Context, ids []string) (execu instanceID, database.TextArray[string](ids), ) + for i := range execution { + if err := execution[i].decryptSigningKey(q.targetEncryptionAlgorithm); err != nil { + return nil, err + } + } return execution, err } @@ -205,6 +211,11 @@ func (q *Queries) TargetsByExecutionIDs(ctx context.Context, ids1, ids2 []string database.TextArray[string](ids1), database.TextArray[string](ids2), ) + for i := range execution { + if err := execution[i].decryptSigningKey(q.targetEncryptionAlgorithm); err != nil { + return nil, err + } + } return execution, err } @@ -352,6 +363,8 @@ type ExecutionTarget struct { Endpoint string Timeout time.Duration InterruptOnError bool + signingKey *crypto.CryptoValue + SigningKey string } func (e *ExecutionTarget) GetExecutionID() string { @@ -372,6 +385,21 @@ func (e *ExecutionTarget) GetTargetType() domain.TargetType { func (e *ExecutionTarget) GetTimeout() time.Duration { return e.Timeout } +func (e *ExecutionTarget) GetSigningKey() string { + return e.SigningKey +} + +func (t *ExecutionTarget) decryptSigningKey(alg crypto.EncryptionAlgorithm) error { + if t.signingKey == nil { + return nil + } + keyValue, err := crypto.DecryptString(t.signingKey, alg) + if err != nil { + return zerrors.ThrowInternal(err, "QUERY-bxevy3YXwy", "Errors.Internal") + } + t.SigningKey = keyValue + return nil +} func scanExecutionTargets(rows *sql.Rows) ([]*ExecutionTarget, error) { targets := make([]*ExecutionTarget, 0) @@ -386,6 +414,7 @@ func scanExecutionTargets(rows *sql.Rows) ([]*ExecutionTarget, error) { endpoint = &sql.NullString{} timeout = &sql.NullInt64{} interruptOnError = &sql.NullBool{} + signingKey = &crypto.CryptoValue{} ) err := rows.Scan( @@ -396,6 +425,7 @@ func scanExecutionTargets(rows *sql.Rows) ([]*ExecutionTarget, error) { endpoint, timeout, interruptOnError, + signingKey, ) if err != nil { @@ -409,6 +439,7 @@ func scanExecutionTargets(rows *sql.Rows) ([]*ExecutionTarget, error) { target.Endpoint = endpoint.String target.Timeout = time.Duration(timeout.Int64) target.InterruptOnError = interruptOnError.Bool + target.signingKey = signingKey targets = append(targets, target) } diff --git a/internal/query/projection/target.go b/internal/query/projection/target.go index d39a75b6dc..acc42b9604 100644 --- a/internal/query/projection/target.go +++ b/internal/query/projection/target.go @@ -11,7 +11,7 @@ import ( ) const ( - TargetTable = "projections.targets1" + TargetTable = "projections.targets2" TargetIDCol = "id" TargetCreationDateCol = "creation_date" TargetChangeDateCol = "change_date" @@ -23,6 +23,7 @@ const ( TargetEndpointCol = "endpoint" TargetTimeoutCol = "timeout" TargetInterruptOnErrorCol = "interrupt_on_error" + TargetSigningKey = "signing_key" ) type targetProjection struct{} @@ -49,6 +50,7 @@ func (*targetProjection) Init() *old_handler.Check { handler.NewColumn(TargetEndpointCol, handler.ColumnTypeText), handler.NewColumn(TargetTimeoutCol, handler.ColumnTypeInt64), handler.NewColumn(TargetInterruptOnErrorCol, handler.ColumnTypeBool), + handler.NewColumn(TargetSigningKey, handler.ColumnTypeJSONB, handler.Nullable()), }, handler.NewPrimaryKey(TargetInstanceIDCol, TargetIDCol), ), @@ -105,6 +107,7 @@ func (p *targetProjection) reduceTargetAdded(event eventstore.Event) (*handler.S handler.NewCol(TargetTargetType, e.TargetType), handler.NewCol(TargetTimeoutCol, e.Timeout), handler.NewCol(TargetInterruptOnErrorCol, e.InterruptOnError), + handler.NewCol(TargetSigningKey, e.SigningKey), }, ), nil } @@ -134,6 +137,9 @@ func (p *targetProjection) reduceTargetChanged(event eventstore.Event) (*handler if e.InterruptOnError != nil { values = append(values, handler.NewCol(TargetInterruptOnErrorCol, *e.InterruptOnError)) } + if e.SigningKey != nil { + values = append(values, handler.NewCol(TargetSigningKey, e.SigningKey)) + } return handler.NewUpdateStatement( e, values, diff --git a/internal/query/projection/target_test.go b/internal/query/projection/target_test.go index 30067c6640..6517e78f04 100644 --- a/internal/query/projection/target_test.go +++ b/internal/query/projection/target_test.go @@ -29,7 +29,7 @@ func TestTargetProjection_reduces(t *testing.T) { testEvent( target.AddedEventType, target.AggregateType, - []byte(`{"name": "name", "targetType":0, "endpoint":"https://example.com", "timeout": 3000000000, "async": true, "interruptOnError": true}`), + []byte(`{"name": "name", "targetType":0, "endpoint":"https://example.com", "timeout": 3000000000, "async": true, "interruptOnError": true, "signingKey": { "cryptoType": 0, "algorithm": "RSA-265", "keyId": "key-id" }}`), ), eventstore.GenericEventMapper[target.AddedEvent], ), @@ -41,7 +41,7 @@ func TestTargetProjection_reduces(t *testing.T) { executer: &testExecuter{ executions: []execution{ { - expectedStmt: "INSERT INTO projections.targets1 (instance_id, resource_owner, id, creation_date, change_date, sequence, name, endpoint, target_type, timeout, interrupt_on_error) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)", + expectedStmt: "INSERT INTO projections.targets2 (instance_id, resource_owner, id, creation_date, change_date, sequence, name, endpoint, target_type, timeout, interrupt_on_error, signing_key) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)", expectedArgs: []interface{}{ "instance-id", "ro-id", @@ -54,6 +54,7 @@ func TestTargetProjection_reduces(t *testing.T) { domain.TargetTypeWebhook, 3 * time.Second, true, + anyArg{}, }, }, }, @@ -67,7 +68,7 @@ func TestTargetProjection_reduces(t *testing.T) { testEvent( target.ChangedEventType, target.AggregateType, - []byte(`{"name": "name2", "targetType":0, "endpoint":"https://example.com", "timeout": 3000000000, "async": true, "interruptOnError": true}`), + []byte(`{"name": "name2", "targetType":0, "endpoint":"https://example.com", "timeout": 3000000000, "async": true, "interruptOnError": true, "signingKey": { "cryptoType": 0, "algorithm": "RSA-265", "keyId": "key-id" }}`), ), eventstore.GenericEventMapper[target.ChangedEvent], ), @@ -79,7 +80,7 @@ func TestTargetProjection_reduces(t *testing.T) { executer: &testExecuter{ executions: []execution{ { - expectedStmt: "UPDATE projections.targets1 SET (change_date, sequence, resource_owner, name, target_type, endpoint, timeout, interrupt_on_error) = ($1, $2, $3, $4, $5, $6, $7, $8) WHERE (instance_id = $9) AND (id = $10)", + expectedStmt: "UPDATE projections.targets2 SET (change_date, sequence, resource_owner, name, target_type, endpoint, timeout, interrupt_on_error, signing_key) = ($1, $2, $3, $4, $5, $6, $7, $8, $9) WHERE (instance_id = $10) AND (id = $11)", expectedArgs: []interface{}{ anyArg{}, uint64(15), @@ -89,6 +90,7 @@ func TestTargetProjection_reduces(t *testing.T) { "https://example.com", 3 * time.Second, true, + anyArg{}, "instance-id", "agg-id", }, @@ -116,7 +118,7 @@ func TestTargetProjection_reduces(t *testing.T) { executer: &testExecuter{ executions: []execution{ { - expectedStmt: "DELETE FROM projections.targets1 WHERE (instance_id = $1) AND (id = $2)", + expectedStmt: "DELETE FROM projections.targets2 WHERE (instance_id = $1) AND (id = $2)", expectedArgs: []interface{}{ "instance-id", "agg-id", @@ -145,7 +147,7 @@ func TestTargetProjection_reduces(t *testing.T) { executer: &testExecuter{ executions: []execution{ { - expectedStmt: "DELETE FROM projections.targets1 WHERE (instance_id = $1)", + expectedStmt: "DELETE FROM projections.targets2 WHERE (instance_id = $1)", expectedArgs: []interface{}{ "agg-id", }, diff --git a/internal/query/query.go b/internal/query/query.go index b39dbe9ca1..5fd06d5643 100644 --- a/internal/query/query.go +++ b/internal/query/query.go @@ -29,10 +29,11 @@ type Queries struct { client *database.DB caches *Caches - keyEncryptionAlgorithm crypto.EncryptionAlgorithm - idpConfigEncryption crypto.EncryptionAlgorithm - sessionTokenVerifier func(ctx context.Context, sessionToken string, sessionID string, tokenID string) (err error) - checkPermission domain.PermissionCheck + keyEncryptionAlgorithm crypto.EncryptionAlgorithm + idpConfigEncryption crypto.EncryptionAlgorithm + targetEncryptionAlgorithm crypto.EncryptionAlgorithm + sessionTokenVerifier func(ctx context.Context, sessionToken string, sessionID string, tokenID string) (err error) + checkPermission domain.PermissionCheck DefaultLanguage language.Tag mutex sync.Mutex @@ -52,7 +53,7 @@ func StartQueries( cacheConnectors connector.Connectors, projections projection.Config, defaults sd.SystemDefaults, - idpConfigEncryption, otpEncryption, keyEncryptionAlgorithm, certEncryptionAlgorithm crypto.EncryptionAlgorithm, + idpConfigEncryption, otpEncryption, keyEncryptionAlgorithm, certEncryptionAlgorithm, targetEncryptionAlgorithm crypto.EncryptionAlgorithm, zitadelRoles []authz.RoleMapping, sessionTokenVerifier func(ctx context.Context, sessionToken string, sessionID string, tokenID string) (err error), permissionCheck func(q *Queries) domain.PermissionCheck, @@ -70,6 +71,7 @@ func StartQueries( zitadelRoles: zitadelRoles, keyEncryptionAlgorithm: keyEncryptionAlgorithm, idpConfigEncryption: idpConfigEncryption, + targetEncryptionAlgorithm: targetEncryptionAlgorithm, sessionTokenVerifier: sessionTokenVerifier, multifactors: domain.MultifactorConfigs{ OTP: domain.OTPConfig{ diff --git a/internal/query/target.go b/internal/query/target.go index 8d926a699b..03db85236c 100644 --- a/internal/query/target.go +++ b/internal/query/target.go @@ -9,6 +9,7 @@ import ( sq "github.com/Masterminds/squirrel" "github.com/zitadel/zitadel/internal/api/authz" + "github.com/zitadel/zitadel/internal/crypto" "github.com/zitadel/zitadel/internal/domain" "github.com/zitadel/zitadel/internal/query/projection" "github.com/zitadel/zitadel/internal/zerrors" @@ -59,6 +60,10 @@ var ( name: projection.TargetInterruptOnErrorCol, table: targetTable, } + TargetColumnSigningKey = Column{ + name: projection.TargetSigningKey, + table: targetTable, + } ) type Targets struct { @@ -78,6 +83,20 @@ type Target struct { Endpoint string Timeout time.Duration InterruptOnError bool + signingKey *crypto.CryptoValue + SigningKey string +} + +func (t *Target) decryptSigningKey(alg crypto.EncryptionAlgorithm) error { + if t.signingKey == nil { + return nil + } + keyValue, err := crypto.DecryptString(t.signingKey, alg) + if err != nil { + return zerrors.ThrowInternal(err, "QUERY-bxevy3YXwy", "Errors.Internal") + } + t.SigningKey = keyValue + return nil } type TargetSearchQueries struct { @@ -93,21 +112,37 @@ func (q *TargetSearchQueries) toQuery(query sq.SelectBuilder) sq.SelectBuilder { return query } -func (q *Queries) SearchTargets(ctx context.Context, queries *TargetSearchQueries) (targets *Targets, err error) { +func (q *Queries) SearchTargets(ctx context.Context, queries *TargetSearchQueries) (*Targets, error) { eq := sq.Eq{ TargetColumnInstanceID.identifier(): authz.GetInstance(ctx).InstanceID(), } query, scan := prepareTargetsQuery(ctx, q.client) - return genericRowsQueryWithState[*Targets](ctx, q.client, targetTable, combineToWhereStmt(query, queries.toQuery, eq), scan) + targets, err := genericRowsQueryWithState[*Targets](ctx, q.client, targetTable, combineToWhereStmt(query, queries.toQuery, eq), scan) + if err != nil { + return nil, err + } + for i := range targets.Targets { + if err := targets.Targets[i].decryptSigningKey(q.targetEncryptionAlgorithm); err != nil { + return nil, err + } + } + return targets, nil } -func (q *Queries) GetTargetByID(ctx context.Context, id string) (target *Target, err error) { +func (q *Queries) GetTargetByID(ctx context.Context, id string) (*Target, error) { eq := sq.Eq{ TargetColumnID.identifier(): id, TargetColumnInstanceID.identifier(): authz.GetInstance(ctx).InstanceID(), } query, scan := prepareTargetQuery(ctx, q.client) - return genericRowQuery[*Target](ctx, q.client, query.Where(eq), scan) + target, err := genericRowQuery[*Target](ctx, q.client, query.Where(eq), scan) + if err != nil { + return nil, err + } + if err := target.decryptSigningKey(q.targetEncryptionAlgorithm); err != nil { + return nil, err + } + return target, nil } func NewTargetNameSearchQuery(method TextComparison, value string) (SearchQuery, error) { @@ -129,6 +164,7 @@ func prepareTargetsQuery(context.Context, prepareDatabase) (sq.SelectBuilder, fu TargetColumnTimeout.identifier(), TargetColumnURL.identifier(), TargetColumnInterruptOnError.identifier(), + TargetColumnSigningKey.identifier(), countColumn.identifier(), ).From(targetTable.identifier()). PlaceholderFormat(sq.Dollar), @@ -147,6 +183,7 @@ func prepareTargetsQuery(context.Context, prepareDatabase) (sq.SelectBuilder, fu &target.Timeout, &target.Endpoint, &target.InterruptOnError, + &target.signingKey, &count, ) if err != nil { @@ -179,6 +216,7 @@ func prepareTargetQuery(context.Context, prepareDatabase) (sq.SelectBuilder, fun TargetColumnTimeout.identifier(), TargetColumnURL.identifier(), TargetColumnInterruptOnError.identifier(), + TargetColumnSigningKey.identifier(), ).From(targetTable.identifier()). PlaceholderFormat(sq.Dollar), func(row *sql.Row) (*Target, error) { @@ -193,6 +231,7 @@ func prepareTargetQuery(context.Context, prepareDatabase) (sq.SelectBuilder, fun &target.Timeout, &target.Endpoint, &target.InterruptOnError, + &target.signingKey, ) if err != nil { if errors.Is(err, sql.ErrNoRows) { diff --git a/internal/query/target_test.go b/internal/query/target_test.go index 1b6edd1ad7..aa1ad517b7 100644 --- a/internal/query/target_test.go +++ b/internal/query/target_test.go @@ -9,22 +9,24 @@ import ( "testing" "time" + "github.com/zitadel/zitadel/internal/crypto" "github.com/zitadel/zitadel/internal/domain" "github.com/zitadel/zitadel/internal/zerrors" ) var ( - prepareTargetsStmt = `SELECT projections.targets1.id,` + - ` projections.targets1.creation_date,` + - ` projections.targets1.change_date,` + - ` projections.targets1.resource_owner,` + - ` projections.targets1.name,` + - ` projections.targets1.target_type,` + - ` projections.targets1.timeout,` + - ` projections.targets1.endpoint,` + - ` projections.targets1.interrupt_on_error,` + + prepareTargetsStmt = `SELECT projections.targets2.id,` + + ` projections.targets2.creation_date,` + + ` projections.targets2.change_date,` + + ` projections.targets2.resource_owner,` + + ` projections.targets2.name,` + + ` projections.targets2.target_type,` + + ` projections.targets2.timeout,` + + ` projections.targets2.endpoint,` + + ` projections.targets2.interrupt_on_error,` + + ` projections.targets2.signing_key,` + ` COUNT(*) OVER ()` + - ` FROM projections.targets1` + ` FROM projections.targets2` prepareTargetsCols = []string{ "id", "creation_date", @@ -35,19 +37,21 @@ var ( "timeout", "endpoint", "interrupt_on_error", + "signing_key", "count", } - prepareTargetStmt = `SELECT projections.targets1.id,` + - ` projections.targets1.creation_date,` + - ` projections.targets1.change_date,` + - ` projections.targets1.resource_owner,` + - ` projections.targets1.name,` + - ` projections.targets1.target_type,` + - ` projections.targets1.timeout,` + - ` projections.targets1.endpoint,` + - ` projections.targets1.interrupt_on_error` + - ` FROM projections.targets1` + prepareTargetStmt = `SELECT projections.targets2.id,` + + ` projections.targets2.creation_date,` + + ` projections.targets2.change_date,` + + ` projections.targets2.resource_owner,` + + ` projections.targets2.name,` + + ` projections.targets2.target_type,` + + ` projections.targets2.timeout,` + + ` projections.targets2.endpoint,` + + ` projections.targets2.interrupt_on_error,` + + ` projections.targets2.signing_key` + + ` FROM projections.targets2` prepareTargetCols = []string{ "id", "creation_date", @@ -58,6 +62,7 @@ var ( "timeout", "endpoint", "interrupt_on_error", + "signing_key", } ) @@ -102,6 +107,12 @@ func Test_TargetPrepares(t *testing.T) { 1 * time.Second, "https://example.com", true, + &crypto.CryptoValue{ + CryptoType: crypto.TypeEncryption, + Algorithm: "alg", + KeyID: "encKey", + Crypted: []byte("crypted"), + }, }, }, ), @@ -123,6 +134,12 @@ func Test_TargetPrepares(t *testing.T) { Timeout: 1 * time.Second, Endpoint: "https://example.com", InterruptOnError: true, + signingKey: &crypto.CryptoValue{ + CryptoType: crypto.TypeEncryption, + Algorithm: "alg", + KeyID: "encKey", + Crypted: []byte("crypted"), + }, }, }, }, @@ -145,6 +162,12 @@ func Test_TargetPrepares(t *testing.T) { 1 * time.Second, "https://example.com", true, + &crypto.CryptoValue{ + CryptoType: crypto.TypeEncryption, + Algorithm: "alg", + KeyID: "encKey", + Crypted: []byte("crypted"), + }, }, { "id-2", @@ -156,6 +179,12 @@ func Test_TargetPrepares(t *testing.T) { 1 * time.Second, "https://example.com", false, + &crypto.CryptoValue{ + CryptoType: crypto.TypeEncryption, + Algorithm: "alg", + KeyID: "encKey", + Crypted: []byte("crypted"), + }, }, { "id-3", @@ -167,6 +196,12 @@ func Test_TargetPrepares(t *testing.T) { 1 * time.Second, "https://example.com", false, + &crypto.CryptoValue{ + CryptoType: crypto.TypeEncryption, + Algorithm: "alg", + KeyID: "encKey", + Crypted: []byte("crypted"), + }, }, }, ), @@ -188,6 +223,12 @@ func Test_TargetPrepares(t *testing.T) { Timeout: 1 * time.Second, Endpoint: "https://example.com", InterruptOnError: true, + signingKey: &crypto.CryptoValue{ + CryptoType: crypto.TypeEncryption, + Algorithm: "alg", + KeyID: "encKey", + Crypted: []byte("crypted"), + }, }, { ObjectDetails: domain.ObjectDetails{ @@ -201,6 +242,12 @@ func Test_TargetPrepares(t *testing.T) { Timeout: 1 * time.Second, Endpoint: "https://example.com", InterruptOnError: false, + signingKey: &crypto.CryptoValue{ + CryptoType: crypto.TypeEncryption, + Algorithm: "alg", + KeyID: "encKey", + Crypted: []byte("crypted"), + }, }, { ObjectDetails: domain.ObjectDetails{ @@ -214,6 +261,12 @@ func Test_TargetPrepares(t *testing.T) { Timeout: 1 * time.Second, Endpoint: "https://example.com", InterruptOnError: false, + signingKey: &crypto.CryptoValue{ + CryptoType: crypto.TypeEncryption, + Algorithm: "alg", + KeyID: "encKey", + Crypted: []byte("crypted"), + }, }, }, }, @@ -270,6 +323,12 @@ func Test_TargetPrepares(t *testing.T) { 1 * time.Second, "https://example.com", true, + &crypto.CryptoValue{ + CryptoType: crypto.TypeEncryption, + Algorithm: "alg", + KeyID: "encKey", + Crypted: []byte("crypted"), + }, }, ), }, @@ -285,6 +344,12 @@ func Test_TargetPrepares(t *testing.T) { Timeout: 1 * time.Second, Endpoint: "https://example.com", InterruptOnError: true, + signingKey: &crypto.CryptoValue{ + CryptoType: crypto.TypeEncryption, + Algorithm: "alg", + KeyID: "encKey", + Crypted: []byte("crypted"), + }, }, }, { diff --git a/internal/query/targets_by_execution_id.sql b/internal/query/targets_by_execution_id.sql index f8248479b0..f3ee25d675 100644 --- a/internal/query/targets_by_execution_id.sql +++ b/internal/query/targets_by_execution_id.sql @@ -31,9 +31,9 @@ WITH RECURSIVE ON e.instance_id = p.instance_id AND e.include IS NOT NULL AND e.include = p.execution_id) -select e.execution_id, e.instance_id, e.target_id, t.target_type, t.endpoint, t.timeout, t.interrupt_on_error +select e.execution_id, e.instance_id, e.target_id, t.target_type, t.endpoint, t.timeout, t.interrupt_on_error, t.signing_key FROM dissolved_execution_targets e - JOIN projections.targets1 t + JOIN projections.targets2 t ON e.instance_id = t.instance_id AND e.target_id = t.id WHERE "include" = '' diff --git a/internal/query/targets_by_execution_ids.sql b/internal/query/targets_by_execution_ids.sql index 749d9387b2..277826a81b 100644 --- a/internal/query/targets_by_execution_ids.sql +++ b/internal/query/targets_by_execution_ids.sql @@ -38,9 +38,9 @@ WITH RECURSIVE ON e.instance_id = p.instance_id AND e.include IS NOT NULL AND e.include = p.execution_id) -select e.execution_id, e.instance_id, e.target_id, t.target_type, t.endpoint, t.timeout, t.interrupt_on_error +select e.execution_id, e.instance_id, e.target_id, t.target_type, t.endpoint, t.timeout, t.interrupt_on_error, t.signing_key FROM dissolved_execution_targets e - JOIN projections.targets1 t + JOIN projections.targets2 t ON e.instance_id = t.instance_id AND e.target_id = t.id WHERE "include" = '' diff --git a/internal/repository/target/target.go b/internal/repository/target/target.go index 85e3ae7023..3df1b31480 100644 --- a/internal/repository/target/target.go +++ b/internal/repository/target/target.go @@ -4,6 +4,7 @@ import ( "context" "time" + "github.com/zitadel/zitadel/internal/crypto" "github.com/zitadel/zitadel/internal/domain" "github.com/zitadel/zitadel/internal/eventstore" ) @@ -18,11 +19,12 @@ const ( type AddedEvent struct { eventstore.BaseEvent `json:"-"` - Name string `json:"name"` - TargetType domain.TargetType `json:"targetType"` - Endpoint string `json:"endpoint"` - Timeout time.Duration `json:"timeout"` - InterruptOnError bool `json:"interruptOnError"` + Name string `json:"name"` + TargetType domain.TargetType `json:"targetType"` + Endpoint string `json:"endpoint"` + Timeout time.Duration `json:"timeout"` + InterruptOnError bool `json:"interruptOnError"` + SigningKey *crypto.CryptoValue `json:"signingKey"` } func (e *AddedEvent) SetBaseEvent(b *eventstore.BaseEvent) { @@ -45,22 +47,24 @@ func NewAddedEvent( endpoint string, timeout time.Duration, interruptOnError bool, + signingKey *crypto.CryptoValue, ) *AddedEvent { return &AddedEvent{ *eventstore.NewBaseEventForPush( ctx, aggregate, AddedEventType, ), - name, targetType, endpoint, timeout, interruptOnError} + name, targetType, endpoint, timeout, interruptOnError, signingKey} } type ChangedEvent struct { eventstore.BaseEvent `json:"-"` - Name *string `json:"name,omitempty"` - TargetType *domain.TargetType `json:"targetType,omitempty"` - Endpoint *string `json:"endpoint,omitempty"` - Timeout *time.Duration `json:"timeout,omitempty"` - InterruptOnError *bool `json:"interruptOnError,omitempty"` + Name *string `json:"name,omitempty"` + TargetType *domain.TargetType `json:"targetType,omitempty"` + Endpoint *string `json:"endpoint,omitempty"` + Timeout *time.Duration `json:"timeout,omitempty"` + InterruptOnError *bool `json:"interruptOnError,omitempty"` + SigningKey *crypto.CryptoValue `json:"signingKey,omitempty"` oldName string } @@ -134,6 +138,12 @@ func ChangeInterruptOnError(interruptOnError bool) func(event *ChangedEvent) { } } +func ChangeSigningKey(signingKey *crypto.CryptoValue) func(event *ChangedEvent) { + return func(e *ChangedEvent) { + e.SigningKey = signingKey + } +} + type RemovedEvent struct { eventstore.BaseEvent `json:"-"` diff --git a/pkg/actions/signing.go b/pkg/actions/signing.go new file mode 100644 index 0000000000..0b39327450 --- /dev/null +++ b/pkg/actions/signing.go @@ -0,0 +1,115 @@ +package actions + +import ( + "crypto/hmac" + "crypto/sha256" + "encoding/hex" + "errors" + "fmt" + "strconv" + "strings" + "time" +) + +var ( + ErrNoValidSignature = errors.New("no valid signature") + ErrInvalidHeader = errors.New("webhook has invalid Zitadel-Signature header") + ErrNotSigned = errors.New("webhook has no Zitadel-Signature header") + ErrTooOld = errors.New("timestamp wasn't within tolerance") +) + +const ( + SigningHeader = "ZITADEL-Signature" + signingTimestamp = "t" + signingVersion string = "v1" + DefaultTolerance = 300 * time.Second + partSeparator = "," +) + +func ComputeSignatureHeader(t time.Time, payload []byte, signingKey ...string) string { + parts := []string{ + fmt.Sprintf("%s=%d", signingTimestamp, t.Unix()), + } + for _, k := range signingKey { + parts = append(parts, fmt.Sprintf("%s=%s", signingVersion, hex.EncodeToString(computeSignature(t, payload, k)))) + } + return strings.Join(parts, partSeparator) +} + +func computeSignature(t time.Time, payload []byte, signingKey string) []byte { + mac := hmac.New(sha256.New, []byte(signingKey)) + mac.Write([]byte(fmt.Sprintf("%d", t.Unix()))) + mac.Write([]byte(".")) + mac.Write(payload) + return mac.Sum(nil) +} + +func ValidatePayload(payload []byte, header string, signingKey string) error { + return ValidatePayloadWithTolerance(payload, header, signingKey, DefaultTolerance) +} + +func ValidatePayloadWithTolerance(payload []byte, header string, signingKey string, tolerance time.Duration) error { + return validatePayload(payload, header, signingKey, tolerance, true) +} + +func validatePayload(payload []byte, sigHeader string, signingKey string, tolerance time.Duration, enforceTolerance bool) error { + header, err := parseSignatureHeader(sigHeader) + if err != nil { + return err + } + + expectedSignature := computeSignature(header.timestamp, payload, signingKey) + expiredTimestamp := time.Since(header.timestamp) > tolerance + if enforceTolerance && expiredTimestamp { + return ErrTooOld + } + + for _, sig := range header.signatures { + if hmac.Equal(expectedSignature, sig) { + return nil + } + } + return ErrNoValidSignature +} + +type signedHeader struct { + timestamp time.Time + signatures [][]byte +} + +func parseSignatureHeader(header string) (*signedHeader, error) { + sh := &signedHeader{} + if header == "" { + return sh, ErrNotSigned + } + + pairs := strings.Split(header, ",") + for _, pair := range pairs { + parts := strings.Split(pair, "=") + if len(parts) != 2 { + return sh, ErrInvalidHeader + } + switch parts[0] { + case signingTimestamp: + timestamp, err := strconv.ParseInt(parts[1], 10, 64) + if err != nil { + return sh, ErrInvalidHeader + } + sh.timestamp = time.Unix(timestamp, 0) + + case signingVersion: + sig, err := hex.DecodeString(parts[1]) + if err != nil { + continue + } + sh.signatures = append(sh.signatures, sig) + default: + continue + } + } + + if len(sh.signatures) == 0 { + return sh, ErrNoValidSignature + } + return sh, nil +} diff --git a/proto/zitadel/resources/action/v3alpha/action_service.proto b/proto/zitadel/resources/action/v3alpha/action_service.proto index fa07a9f854..bc3739861d 100644 --- a/proto/zitadel/resources/action/v3alpha/action_service.proto +++ b/proto/zitadel/resources/action/v3alpha/action_service.proto @@ -408,6 +408,12 @@ message CreateTargetRequest { message CreateTargetResponse { zitadel.resources.object.v3alpha.Details details = 1; + // Key used to sign and check payload sent to the target. + string signing_key = 2 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "\"98KmsU67\"" + } + ]; } message PatchTargetRequest { @@ -433,6 +439,12 @@ message PatchTargetRequest { message PatchTargetResponse { zitadel.resources.object.v3alpha.Details details = 1; + // Key used to sign and check payload sent to the target. + optional string signing_key = 2 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "\"98KmsU67\"" + } + ]; } message DeleteTargetRequest { diff --git a/proto/zitadel/resources/action/v3alpha/target.proto b/proto/zitadel/resources/action/v3alpha/target.proto index cb1ff85883..8524ab3639 100644 --- a/proto/zitadel/resources/action/v3alpha/target.proto +++ b/proto/zitadel/resources/action/v3alpha/target.proto @@ -9,6 +9,7 @@ import "google/protobuf/struct.proto"; import "protoc-gen-openapiv2/options/annotations.proto"; import "validate/validate.proto"; import "zitadel/protoc_gen_zitadel/v2/options.proto"; +import "google/protobuf/timestamp.proto"; import "zitadel/resources/object/v3alpha/object.proto"; @@ -51,6 +52,11 @@ message Target { message GetTarget { zitadel.resources.object.v3alpha.Details details = 1; Target config = 2; + string signing_key = 3 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "\"98KmsU67\"" + } + ]; } message PatchTarget { @@ -84,6 +90,21 @@ message PatchTarget { max_length: 1000 } ]; + // Regenerate the key used for signing and checking the payload sent to the target. + // Set the graceful period for the existing key. During that time, the previous + // signing key and the new one will be used to sign the request to allow you a smooth + // transition onf your API. + // + // Note that we currently only allow an immediate rotation ("0s") and will support + // longer expirations in the future. + optional google.protobuf.Duration expiration_signing_key = 7 [ + (validate.rules).duration = {const: {seconds: 0, nanos: 0}}, + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "\"0s\"" + minimum: 0 + maximum: 0 + } + ]; } From de7e0f840898f5e2c93ca09afbd334d1ef13263b Mon Sep 17 00:00:00 2001 From: David Flanagan Date: Thu, 28 Nov 2024 15:49:14 +0000 Subject: [PATCH 34/64] docs(adopter): RawkodeAcademy (#8978) N/A --- ADOPTERS.md | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/ADOPTERS.md b/ADOPTERS.md index 377a4f6461..2f903664be 100644 --- a/ADOPTERS.md +++ b/ADOPTERS.md @@ -8,8 +8,9 @@ The following is a directory of adopters to help identify users of individual fe If you are using Zitadel, please consider adding yourself as a user with a quick description of your use case by opening a pull request to this file and adding a section describing your usage of Zitadel. -| Organization/Individual | Contact Information | Description of Usage | -| ----------------------- | -------------------------------------------------------- | ----------------------------------------------- | -| Zitadel | [@fforootd](https://github.com/fforootd) (and many more) | Zitadel Cloud makes heavy use of of Zitadel ;-) | +| Organization/Individual | Contact Information | Description of Usage | +| ----------------------- | -------------------------------------------------------------------- | ----------------------------------------------- | +| Zitadel | [@fforootd](https://github.com/fforootd) (and many more) | Zitadel Cloud makes heavy use of of Zitadel ;-) | +| Rawkode Academy | [@RawkodeAcademy](https://github.com/RawkodeAcademy) | Rawkode Academy Platform & Zulip use Zitadel for all user and M2M authentication | | Organization Name | contact@example.com | Description of how they use Zitadel | | Individual Name | contact@example.com | Description of how they use Zitadel | From ed42dde4639e2bb5ddcc4efae8e79ab4c7d0c9cc Mon Sep 17 00:00:00 2001 From: Stefan Benz <46600784+stebenz@users.noreply.github.com> Date: Thu, 28 Nov 2024 18:09:00 +0100 Subject: [PATCH 35/64] fix: process org remove event in domain verified writemodel (#8790) # Which Problems Are Solved Domains are processed as still verified in the domain verified writemodel even if the org is removed. # How the Problems Are Solved Handle the org removed event in the writemodel. # Additional Changes None # Additional Context Closes #8514 --------- Co-authored-by: Livio Spring --- internal/command/org_domain_model.go | 10 +- internal/command/user_v2_human_test.go | 317 +++++++++++++++++++++++++ 2 files changed, 326 insertions(+), 1 deletion(-) diff --git a/internal/command/org_domain_model.go b/internal/command/org_domain_model.go index 43ab9e52dd..b387e6f4b7 100644 --- a/internal/command/org_domain_model.go +++ b/internal/command/org_domain_model.go @@ -202,6 +202,8 @@ func (wm *OrgDomainVerifiedWriteModel) AppendEvents(events ...eventstore.Event) continue } wm.WriteModel.AppendEvents(e) + case *org.OrgRemovedEvent: + wm.WriteModel.AppendEvents(e) } } } @@ -214,6 +216,11 @@ func (wm *OrgDomainVerifiedWriteModel) Reduce() error { wm.ResourceOwner = e.Aggregate().ResourceOwner case *org.DomainRemovedEvent: wm.Verified = false + case *org.OrgRemovedEvent: + if wm.ResourceOwner != e.Aggregate().ID { + continue + } + wm.Verified = false } } return wm.WriteModel.Reduce() @@ -225,6 +232,7 @@ func (wm *OrgDomainVerifiedWriteModel) Query() *eventstore.SearchQueryBuilder { AggregateTypes(org.AggregateType). EventTypes( org.OrgDomainVerifiedEventType, - org.OrgDomainRemovedEventType). + org.OrgDomainRemovedEventType, + org.OrgRemovedEventType). Builder() } diff --git a/internal/command/user_v2_human_test.go b/internal/command/user_v2_human_test.go index a50061c010..2b4399fb2a 100644 --- a/internal/command/user_v2_human_test.go +++ b/internal/command/user_v2_human_test.go @@ -1725,6 +1725,323 @@ func TestCommandSide_AddUserHuman(t *testing.T) { wantID: "user1", }, }, + { + name: "register human (validate domain), already verified", + fields: fields{ + eventstore: expectEventstore( + expectFilter(), + expectFilter( + eventFromEventPusher( + org.NewDomainPolicyAddedEvent(context.Background(), + &userAgg.Aggregate, + false, + true, + true, + ), + ), + ), + expectFilter( + eventFromEventPusher( + org.NewDomainVerifiedEvent(context.Background(), + &org.NewAggregate("existing").Aggregate, + "example.com", + ), + ), + ), + ), + checkPermission: newMockPermissionCheckAllowed(), + idGenerator: id_mock.NewIDGeneratorExpectIDs(t, "user1"), + newCode: mockEncryptedCode("userinit", time.Hour), + }, + args: args{ + ctx: context.Background(), + orgID: "org1", + human: &AddHuman{ + Username: "username@example.com", + FirstName: "firstname", + LastName: "lastname", + Email: Email{ + Address: "email@example.com", + }, + PreferredLanguage: language.English, + Register: true, + UserAgentID: "userAgentID", + AuthRequestID: "authRequestID", + }, + secretGenerator: GetMockSecretGenerator(t), + allowInitMail: true, + codeAlg: crypto.CreateMockEncryptionAlg(gomock.NewController(t)), + }, + res: res{ + err: func(err error) bool { + return errors.Is(err, zerrors.ThrowInvalidArgument(nil, "COMMAND-SFd21", "Errors.User.DomainNotAllowedAsUsername")) + }, + }, + }, + { + name: "register human (validate domain), ok", + fields: fields{ + eventstore: expectEventstore( + expectFilter(), + expectFilter( + eventFromEventPusher( + org.NewDomainPolicyAddedEvent(context.Background(), + &userAgg.Aggregate, + false, + true, + true, + ), + ), + ), + expectFilter( + eventFromEventPusher( + org.NewDomainVerifiedEvent(context.Background(), + &org.NewAggregate(userAgg.ResourceOwner).Aggregate, + "example.com", + ), + ), + ), + expectPush( + user.NewHumanRegisteredEvent(context.Background(), + &userAgg.Aggregate, + "username@example.com", + "firstname", + "lastname", + "", + "firstname lastname", + language.English, + domain.GenderUnspecified, + "email@example.com", + false, + "userAgentID", + ), + user.NewHumanInitialCodeAddedEvent(context.Background(), + &userAgg.Aggregate, + &crypto.CryptoValue{ + CryptoType: crypto.TypeEncryption, + Algorithm: "enc", + KeyID: "id", + Crypted: []byte("userinit"), + }, + time.Hour*1, + "authRequestID", + ), + ), + ), + checkPermission: newMockPermissionCheckAllowed(), + idGenerator: id_mock.NewIDGeneratorExpectIDs(t, "user1"), + newCode: mockEncryptedCode("userinit", time.Hour), + }, + args: args{ + ctx: context.Background(), + orgID: "org1", + human: &AddHuman{ + Username: "username@example.com", + FirstName: "firstname", + LastName: "lastname", + Email: Email{ + Address: "email@example.com", + }, + PreferredLanguage: language.English, + Register: true, + UserAgentID: "userAgentID", + AuthRequestID: "authRequestID", + }, + secretGenerator: GetMockSecretGenerator(t), + allowInitMail: true, + codeAlg: crypto.CreateMockEncryptionAlg(gomock.NewController(t)), + }, + res: res{ + want: &domain.ObjectDetails{ + Sequence: 0, + EventDate: time.Time{}, + ResourceOwner: "org1", + }, + wantID: "user1", + }, + }, + { + name: "register human (validate domain, domain removed), ok", + fields: fields{ + eventstore: expectEventstore( + expectFilter(), + expectFilter( + eventFromEventPusher( + org.NewDomainPolicyAddedEvent(context.Background(), + &userAgg.Aggregate, + false, + true, + true, + ), + ), + ), + expectFilter( + eventFromEventPusher( + org.NewDomainVerifiedEvent(context.Background(), + &org.NewAggregate("existing").Aggregate, + "example.com", + ), + ), + eventFromEventPusher( + org.NewDomainRemovedEvent(context.Background(), + &org.NewAggregate("existing").Aggregate, + "example.com", + true, + ), + ), + ), + expectPush( + user.NewHumanRegisteredEvent(context.Background(), + &userAgg.Aggregate, + "username@example.com", + "firstname", + "lastname", + "", + "firstname lastname", + language.English, + domain.GenderUnspecified, + "email@example.com", + false, + "userAgentID", + ), + user.NewHumanInitialCodeAddedEvent(context.Background(), + &userAgg.Aggregate, + &crypto.CryptoValue{ + CryptoType: crypto.TypeEncryption, + Algorithm: "enc", + KeyID: "id", + Crypted: []byte("userinit"), + }, + time.Hour*1, + "authRequestID", + ), + ), + ), + checkPermission: newMockPermissionCheckAllowed(), + idGenerator: id_mock.NewIDGeneratorExpectIDs(t, "user1"), + newCode: mockEncryptedCode("userinit", time.Hour), + }, + args: args{ + ctx: context.Background(), + orgID: "org1", + human: &AddHuman{ + Username: "username@example.com", + FirstName: "firstname", + LastName: "lastname", + Email: Email{ + Address: "email@example.com", + }, + PreferredLanguage: language.English, + Register: true, + UserAgentID: "userAgentID", + AuthRequestID: "authRequestID", + }, + secretGenerator: GetMockSecretGenerator(t), + allowInitMail: true, + codeAlg: crypto.CreateMockEncryptionAlg(gomock.NewController(t)), + }, + res: res{ + want: &domain.ObjectDetails{ + Sequence: 0, + EventDate: time.Time{}, + ResourceOwner: "org1", + }, + wantID: "user1", + }, + }, + { + name: "register human (validate domain, org removed), ok", + fields: fields{ + eventstore: expectEventstore( + expectFilter(), + expectFilter( + eventFromEventPusher( + org.NewDomainPolicyAddedEvent(context.Background(), + &userAgg.Aggregate, + false, + true, + true, + ), + ), + ), + expectFilter( + eventFromEventPusher( + org.NewDomainVerifiedEvent(context.Background(), + &org.NewAggregate("existing").Aggregate, + "example.com", + ), + ), + eventFromEventPusher( + org.NewOrgRemovedEvent(context.Background(), + &org.NewAggregate("existing").Aggregate, + "org", + []string{}, + false, + []string{}, + []*domain.UserIDPLink{}, + []string{}, + ), + ), + ), + expectPush( + user.NewHumanRegisteredEvent(context.Background(), + &userAgg.Aggregate, + "username@example.com", + "firstname", + "lastname", + "", + "firstname lastname", + language.English, + domain.GenderUnspecified, + "email@example.com", + false, + "userAgentID", + ), + user.NewHumanInitialCodeAddedEvent(context.Background(), + &userAgg.Aggregate, + &crypto.CryptoValue{ + CryptoType: crypto.TypeEncryption, + Algorithm: "enc", + KeyID: "id", + Crypted: []byte("userinit"), + }, + time.Hour*1, + "authRequestID", + ), + ), + ), + checkPermission: newMockPermissionCheckAllowed(), + idGenerator: id_mock.NewIDGeneratorExpectIDs(t, "user1"), + newCode: mockEncryptedCode("userinit", time.Hour), + }, + args: args{ + ctx: context.Background(), + orgID: "org1", + human: &AddHuman{ + Username: "username@example.com", + FirstName: "firstname", + LastName: "lastname", + Email: Email{ + Address: "email@example.com", + }, + PreferredLanguage: language.English, + Register: true, + UserAgentID: "userAgentID", + AuthRequestID: "authRequestID", + }, + secretGenerator: GetMockSecretGenerator(t), + allowInitMail: true, + codeAlg: crypto.CreateMockEncryptionAlg(gomock.NewController(t)), + }, + res: res{ + want: &domain.ObjectDetails{ + Sequence: 0, + EventDate: time.Time{}, + ResourceOwner: "org1", + }, + wantID: "user1", + }, + }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { From 001fb9761bda8d3463a84f6f6a800651a3f21549 Mon Sep 17 00:00:00 2001 From: Ivan <88590094+Nexfader@users.noreply.github.com> Date: Mon, 2 Dec 2024 10:34:54 +0300 Subject: [PATCH 36/64] fix(i18n): Improve Russian locale in the auth module (#8988) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit # Which Problems Are Solved - The quality of the Russian locale in the auth module is currently low, likely due to automatic translation. # How the Problems Are Solved - Corrected grammatical errors and awkward phrasing from auto-translation (e.g., "footer" → ~"нижний колонтитул"~ "примечание"). - Enhanced alignment with the English (reference) locale, including improvements to casing and semantics. - Ensured consistency in terminology (e.g., the "next"/"cancel" buttons are now consistently translated as "продолжить"/"отмена"). - Improved clarity and readability (e.g., "подтверждение пароля" → "повторите пароль"). # Additional Changes N/A # Additional Context - Follow-up for PR #6864 Co-authored-by: Fabi --- internal/api/ui/login/static/i18n/ru.yaml | 423 +++++++++++----------- 1 file changed, 212 insertions(+), 211 deletions(-) diff --git a/internal/api/ui/login/static/i18n/ru.yaml b/internal/api/ui/login/static/i18n/ru.yaml index 9c1796c59a..e6edc967fb 100644 --- a/internal/api/ui/login/static/i18n/ru.yaml +++ b/internal/api/ui/login/static/i18n/ru.yaml @@ -1,246 +1,247 @@ Login: Title: Добро пожаловать! - Description: Введите ваши данные. + Description: Введите свои данные дял входа. TitleLinking: Вход для привязки пользователей - DescriptionLinking: Введите данные для входа, чтобы привязать внешнего пользователя к пользователю ZITADEL. + DescriptionLinking: Введите данные для входа, чтобы привязать внешнего пользователя к учётной записи ZITADEL. LoginNameLabel: Логин UsernamePlaceHolder: логин LoginnamePlaceHolder: username@domain - ExternalUserDescription: Войти под внешним пользователем. - MustBeMemberOfOrg: Пользователь должен быть участником организации {{.OrgName}}. - RegisterButtonText: зарегистрироваться - NextButtonText: далее + ExternalUserDescription: Войти как внешний пользователь. + MustBeMemberOfOrg: Пользователь должен быть членом организации {{.OrgName}}. + RegisterButtonText: Зарегистрироваться + NextButtonText: Продолжить LDAP: Title: Войти - Description: Введите ваши данные дял входа. - LoginNameLabel: Имя пользователя + Description: Введите свои данные для входа. + LoginNameLabel: Логин PasswordLabel: Пароль - NextButtonText: следующий + NextButtonText: Продолжить + SelectAccount: Title: Выбор учётной записи - Description: Выберите вашу учётную запись. + Description: Выберите учётную запись. TitleLinking: Выберите учётную запись для привязки пользователя DescriptionLinking: Выберите свою учётную запись для связи с внешним пользователем. - OtherUser: Другой пользователь + OtherUser: Другая учётная запись SessionState0: активный SessionState1: неактивный - MustBeMemberOfOrg: Пользователь должен быть участником организации {{.OrgName}}. + MustBeMemberOfOrg: Пользователь должен быть членом организации {{.OrgName}}. Password: Title: Пароль - Description: Введите свои данные для входа. + Description: Введите данные для входа. PasswordLabel: Пароль - MinLength: Должно быть не менее + MinLength: Минимум MinLengthp2: символов. - MaxLength: Должно быть меньше 70 символов. - HasUppercase: Должно содержать заглавную букву. - HasLowercase: Должно содержать строчную букву. - HasNumber: Должно содержать число. - HasSymbol: Должно содержать символ. - Confirmation: Подтверждение пароля совпадает. + MaxLength: Не более 70 символов. + HasUppercase: Должен содержать заглавную букву. + HasLowercase: Должен содержать строчную букву. + HasNumber: Должен содержать цифру. + HasSymbol: Должен содержить специальный символ. + Confirmation: Пароли должны совпадать. ResetLinkText: Сбросить пароль BackButtonText: Назад - NextButtonText: Вперед + NextButtonText: Продолжить UsernameChange: Title: Изменение логина Description: Установите новый логин. UsernameLabel: Логин - CancelButtonText: отмена - NextButtonText: далее + CancelButtonText: Отмена + NextButtonText: Продолжить UsernameChangeDone: Title: Логин изменён Description: Ваш логин был успешно изменён. - NextButtonText: далее + NextButtonText: Продолжить InitPassword: Title: Установка пароля Description: Введите код из письма, отправленного на вашу электронную почту, чтобы установить пароль. - CodeLabel: Код + CodeLabel: Код из письма NewPasswordLabel: Новый пароль - NewPasswordConfirmLabel: Подтверждение пароля - ResendButtonText: повторно отправить код - NextButtonText: далее + NewPasswordConfirmLabel: Повторите пароль + ResendButtonText: Отправить код ещё раз + NextButtonText: Продолжить InitPasswordDone: Title: Пароль установлен Description: Пароль успешно установлен. - NextButtonText: далее - CancelButtonText: отмена + NextButtonText: Продолжить + CancelButtonText: Отмена InitUser: - Title: Активация пользователя - Description: Подтвердите вашу электронную почту кодом из письма и установите пароль. - CodeLabel: Код + Title: Активация учётной записи + Description: Введите код из письма для подтверждения электронной почты и установите новый пароль. + CodeLabel: Код из письма NewPasswordLabel: Новый пароль - NewPasswordConfirm: Подтверждение пароля - NextButtonText: далее - ResendButtonText: повторно отправить код + NewPasswordConfirm: Повторите пароль + NextButtonText: Продолжить + ResendButtonText: Отправить код ещё раз InitUserDone: - Title: Пользователь активирован + Title: Учётная запись активирована Description: Электронная почта подтверждена и пароль успешно установлен. - NextButtonText: далее - CancelButtonText: отмена + NextButtonText: Продолжить + CancelButtonText: Отмена InviteUser: - Title: Активировать пользователя - Description: Проверьте свой адрес электронной почты с помощью кода ниже и установите свой пароль. - CodeLabel: Код + Title: Активация учётной записи + Description: Введите код из письма для подтверждения электронной почты и установите новый пароль. + CodeLabel: Код из письма NewPasswordLabel: Новый пароль - NewPasswordConfirm: Подтвердить пароль - NextButtonText: Далее - ResendButtonText: Отправить код повторно + NewPasswordConfirm: Повторите пароль + NextButtonText: Продолжить + ResendButtonText: Отправить код ещё раз InitMFAPrompt: Title: Установка двухфакторной аутентификации - Description: Двухфакторная аутентификация обеспечивает дополнительную защиту вашей учётной записи. - Provider0: Через приложение (например, Google/Microsoft Authenticator, Authy) - Provider1: Через устройство (например, FaceID, Windows Hello, Fingerprint) - Provider3: OTP SMS - Provider4: Электронная почта OTP - NextButtonText: следующий - SkipButtonText: скип + Description: Установите двухфакторную аутентификацию для дополнительной защиты вашей учётной записи. + Provider0: Приложение для кодов (например, Google/Microsoft Authenticator или Authy) + Provider1: С помощью устройства (Face ID, Windows Hello, отпечаток пальца) + Provider3: Получать код по СМС + Provider4: Получать код по электронной почте + NextButtonText: Продолжить + SkipButtonText: Пропустить InitMFAOTP: - Title: Подтверждение двухфакторной аутентификации - Description: Создайте двухфакторную аутентификацию. Загрузите приложение для проверки подлинности, если у вас его ещё нет. - OTPDescription: Отсканируйте код с помощью приложения для проверки подлинности (например, Google/Microsoft Authenticator, Authy) или сгенерируйте код указанного ключа и введите его в поле ниже. - SecretLabel: Ключ - CodeLabel: Код - NextButtonText: далее - CancelButtonText: отмена + Title: Настройка двухфакторной аутентификации + Description: Настройте двухфакторную аутентификацию. Загрузите приложение для генерации кодов, если оно у вас отсутствует. + OTPDescription: Отсканируйте QR-код с помощью приложения (например, Google Authenticator, Microsoft Authenticator или Authy), либо скопируйте секретный ключ и введите код ниже. + SecretLabel: Секретный ключ + CodeLabel: Код подтверждения + NextButtonText: Продолжить + CancelButtonText: Отмена InitMFAOTPSMS: - Title: 2-факторная верификация - DescriptionPhone: Создайте свой 2-фактор. Введите свой номер телефона, чтобы подтвердить его. - DescriptionCode: Создайте свой 2-фактор. Введите полученный код, чтобы подтвердить свой номер телефона. - PhoneLabel: Телефон - CodeLabel: Код - EditButtonText: редактировать - ResendButtonText: Повторная отправка кода - NextButtonText: следующий + Title: Настройка двухфакторной аутентификации + DescriptionPhone: Введите номер телефона для его подтверждения. + DescriptionCode: Введите код из СМС для подтверждения номера телефона. + PhoneLabel: Номер телефона + CodeLabel: Код из СМС + EditButtonText: Изменить + ResendButtonText: Отправить код ещё раз + NextButtonText: Продолжить InitMFAU2F: Title: Добавление ключа безопасности - Description: Ключ безопасности — это метод проверки, который можно встроить в телефон, используя Bluetooth, или подключить непосредственно к USB-порту компьютера. + Description: Ключ безопасности — это метод проверки, который может быть встроен в телефон, использовать Bluetooth или подключаться напрямую к USB-порту вашего компьютера. TokenNameLabel: Название ключа безопасности / устройства - NotSupported: WebAuthN не поддерживается вашим браузером. Пожалуйста, убедитесь, что он обновлён или используйте другой (например, Chrome, Safari, Firefox) + NotSupported: Ваш браузер не поддерживает WebAuthN. Пожалуйста, обновите его или используйте другой (например, Chrome, Safari, Firefox). RegisterTokenButtonText: Добавить ключ безопасности - ErrorRetry: Повторите попытку или выберите другой метод. + ErrorRetry: Повторите попытку, создайте новый запрос или выберите другой метод. InitMFADone: Title: Ключ безопасности подтверждён - Description: Поздравляю! Вы только что успешно настроили двухфакторную аутентификацию и сделали свою учётную запись более безопасной. Фактор необходимо вводить при каждом входе в систему. - NextButtonText: далее - CancelButtonText: отмена + Description: Отлично! Вы успешно настроили двухфакторную аутентификацию и сделали свою учётную запись более безопасной. Фактор необходимо вводить при каждом входе в систему. + NextButtonText: Продолжить + CancelButtonText: Отмена MFAProvider: - Provider0: Через приложение (например, Google/Microsoft Authenticator, Authy) - Provider1: Через устройство (например, FaceID, Windows Hello, Fingerprint) - Provider3: OTP SMS - Provider4: Электронная почта OTP + Provider0: Приложение для кодов (например, Google/Microsoft Authenticator или Authy) + Provider1: С помощью устройства (Face ID, Windows Hello, отпечаток пальца) + Provider3: Получать код по СМС + Provider4: Получать код по электронной почте ChooseOther: или выберите другой вариант VerifyMFAOTP: Title: Подтверждение двухфакторной аутентификации - Description: Подтвердите двухфакторную аутентификацию. + Description: Введите код для проверки второго фактора CodeLabel: Код - NextButtonText: далее + NextButtonText: Продолжить VerifyOTP: - Title: Проверка 2-фактора - Description: Проверьте свой второй фактор + Title: Подтверждение двухфакторной аутентификации + Description: Введите код для проверки второго фактора CodeLabel: Код - ResendButtonText: Повторная отправка кода - NextButtonText: следующий + ResendButtonText: Отправить код ещё раз + NextButtonText: Продолжить VerifyMFAU2F: Title: Подтверждение двухфакторной аутентификации - Description: Подтвердите двухфакторную аутентификацию с помощью зарегистрированного устройства (например, FaceID, Windows Hello, Fingerprint). - NotSupported: WebAuthN не поддерживается вашим браузером. Убедитесь, что вы используете самую новую версию, или измените браузер на поддерживаемый (Chrome, Safari, Firefox) + Description: Подтвердите двухфакторную аутентификацию с помощью зарегистрированного устройства (например, FaceID, Windows Hello или отпечатка пальца). + NotSupported: Ваш браузер не поддерживает WebAuthN. Пожалуйста, обновите его или используйте другой (например, Chrome, Safari, Firefox). ErrorRetry: Повторите попытку или выберите другой метод. - ValidateTokenButtonText: Подтвердить двухфакторную аутентификацию + ValidateTokenButtonText: Подтвердить Passwordless: Title: Вход без пароля - Description: Войдите в систему с помощью методов аутентификации, предоставляемых вашим устройством, таких как FaceID, Windows Hello или Fingerprint. - NotSupported: WebAuthN не поддерживается вашим браузером. Пожалуйста, убедитесь, что он обновлён или используйте другой (например, Chrome, Safari, Firefox) + Description: Войдите в систему с помощью методов аутентификации, доступных на вашем устройстве, таких как FaceID, Windows Hello или отпечаток пальца. + NotSupported: Ваш браузер не поддерживает WebAuthN. Пожалуйста, обновите его или используйте другой (например, Chrome, Safari, Firefox). ErrorRetry: Повторите попытку или выберите другой метод. - LoginWithPwButtonText: Войти по паролю + LoginWithPwButtonText: Войти с паролем ValidateTokenButtonText: Войти без пароля PasswordlessPrompt: - Title: Установка входа без пароля - Description: Хотите настроить вход без пароля? (Например, используя методы аутентификации вашего устройства, такие как FaceID, Windows Hello или Fingerprint). - DescriptionInit: Вам необходимо настроить вход без пароля. Воспользуйтесь ссылкой, которую вы получили, чтобы зарегистрировать своё устройство. - PasswordlessButtonText: Перейти к входу без пароля - NextButtonText: далее - SkipButtonText: пропустить + Title: Настройка входа без пароля + Description: Хотите настроить вход без пароля? Вы сможете использовать методы аутентификации вашего устройства, такие как FaceID, Windows Hello или отпечаток пальца. + DescriptionInit: Для начала настройки входа без пароля перейдите по полученной ссылке и зарегистрируйте своё устройство. + PasswordlessButtonText: Настроить вход без пароля + NextButtonText: Продолжить + SkipButtonText: Пропустить PasswordlessRegistration: - Title: Установка входа без пароля - Description: Добавьте свою аутентификацию, указав имя (например, MyMobilePhone, MacBook и так далее), а затем нажмите кнопку «Зарегистрировать вход без пароля» ниже. - TokenNameLabel: Название устройства - NotSupported: WebAuthN не поддерживается вашим браузером. Пожалуйста, убедитесь, что он обновлён или используйте другой (например, Chrome, Safari, Firefox) + Title: Настройка входа без пароля + Description: Укажите имя устройства (например, MyMobilePhone, MacBook и так далее), а затем нажмите кнопку «Зарегистрировать вход без пароля» ниже. + TokenNameLabel: Имя устройства + NotSupported: Ваш браузер не поддерживает WebAuthN. Пожалуйста, обновите его или используйте другой (например, Chrome, Safari, Firefox). RegisterTokenButtonText: Зарегистрировать вход без пароля ErrorRetry: Повторите попытку или выберите другой метод. PasswordlessRegistrationDone: - Title: Установка входа без пароля - Description: Устройство для входа без пароля успешно добавлено. - DescriptionClose: Теперь вы можете закрыть данное окно. - NextButtonText: далее - CancelButtonText: отмена + Title: Вход без пароля настроен + Description: Устройство для входа без пароля успешно зарегистрировано. + DescriptionClose: Теперь вы можете закрыть это окно. + NextButtonText: Продолжить + CancelButtonText: Отмена PasswordChange: Title: Изменение пароля - Description: Измените ваш пароль. Введите старый и новый пароли. - ExpiredDescription: Срок действия вашего пароля истек, и его необходимо изменить. Введите старый и новый пароль. - OldPasswordLabel: Старый пароль + Description: Введите текущий пароль и задайте новый. + ExpiredDescription: Срок действия вашего пароля истёк. Пожалуйста, введите текущий пароль и задайте новый. + OldPasswordLabel: Текущий пароль NewPasswordLabel: Новый пароль - NewPasswordConfirmLabel: Подтверждение пароля - CancelButtonText: отмена - NextButtonText: далее - Footer: Нижний колонтитул + NewPasswordConfirmLabel: Повторите новый пароль + CancelButtonText: Отмена + NextButtonText: Продолжить + Footer: Примечание PasswordChangeDone: Title: Изменение пароля Description: Ваш пароль был успешно изменён. - NextButtonText: далее + NextButtonText: Продолжить PasswordResetDone: Title: Ссылка для сброса пароля отправлена Description: Проверьте вашу электронную почту, чтобы сбросить пароль. - NextButtonText: далее + NextButtonText: Продолжить EmailVerification: Title: Подтверждение электронной почты - Description: Мы отправили вам письмо для подтверждения вашей электронной почты. Пожалуйста, введите полученный код в поле ниже. + Description: Мы отправили письмо с кодом для подтверждения вашей электронной почты. Введите код ниже. CodeLabel: Код - NextButtonText: далее - ResendButtonText: повторно отправить код + NextButtonText: Продолжить + ResendButtonText: Отправить код ещё раз EmailVerificationDone: Title: Подтверждение электронной почты - Description: Ваша электронная почта была успешно подтверждена. - NextButtonText: далее - CancelButtonText: отмена - LoginButtonText: вход + Description: Ваша электронная почта успешно подтверждена. + NextButtonText: Продолжить + CancelButtonText: Отмена + LoginButtonText: Войти RegisterOption: Title: Способы регистрации Description: Выберите способ регистрации. - RegisterUsernamePasswordButtonText: С паролем логина - ExternalLoginDescription: или зарегистрируйтесь внешним пользователем - LoginButtonText: вход + RegisterUsernamePasswordButtonText: С логином и паролем + ExternalLoginDescription: или зарегистрируйтесь с помощью внешнего пользователя + LoginButtonText: Войти RegistrationUser: Title: Регистрация - Description: Введите ваши данные. Электронная почта будет использоваться в качестве логина. - DescriptionOrgRegister: Введите ваши данные. + Description: Введите свои данные. Электронная почта будет использоваться как логин. + DescriptionOrgRegister: Введите свои данные. EmailLabel: Электронная почта UsernameLabel: Логин FirstnameLabel: Имя @@ -268,19 +269,19 @@ RegistrationUser: Male: Мужской Diverse: Другой / X PasswordLabel: Пароль - PasswordConfirmLabel: Подтверждение пароля + PasswordConfirmLabel: Повторите пароль TosAndPrivacyLabel: Условия использования TosConfirm: Я согласен с TosLinkText: Пользовательским соглашением PrivacyConfirm: Я согласен с PrivacyLinkText: Политикой конфиденциальности - ExternalLogin: или зарегистрируйтесь внешним пользователем - BackButtonText: вход - NextButtonText: далее + ExternalLogin: или зарегистрируйтесь с помощью внешнего пользователя + BackButtonText: Войти + NextButtonText: Продолжить ExternalRegistrationUserOverview: Title: Регистрация внешнего пользователя - Description: Мы получили ваши данные пользователя у выбранного провайдера. Теперь вы можете изменить или дополнить их. + Description: Мы получили ваши данные от провайдера. Вы можете изменить или дополнить их. EmailLabel: Электронная почта UsernameLabel: Логин FirstnameLabel: Имя @@ -310,9 +311,9 @@ ExternalRegistrationUserOverview: TosLinkText: Пользовательским соглашением PrivacyConfirm: Я согласен с PrivacyLinkText: Политикой конфиденциальности - ExternalLogin: или зарегистрируйтесь внешним пользователем - BackButtonText: назад - NextButtonText: сохранить + ExternalLogin: или зарегистрируйтесь с помощью внешнего пользователя + BackButtonText: Назад + NextButtonText: Сохранить RegistrationOrg: Title: Регистрация организации @@ -323,7 +324,7 @@ RegistrationOrg: FirstnameLabel: Имя LastnameLabel: Фамилия PasswordLabel: Пароль - PasswordConfirmLabel: Подтверждение пароля + PasswordConfirmLabel: Повторите пароль TosAndPrivacyLabel: Условия использования TosConfirm: Я согласен с TosLinkText: Пользовательским соглашением @@ -333,32 +334,32 @@ RegistrationOrg: LoginSuccess: Title: Успешный вход - AutoRedirectDescription: Вы будете автоматически перенаправлены в своё приложение. Если этого не произошло, нажмите кнопку ниже. После этого вы можете закрыть окно. - RedirectedDescription: Теперь вы можете закрыть данное окно. - NextButtonText: далее + AutoRedirectDescription: Вы будете автоматически перенаправлены в приложение. Если этого не произошло, нажмите кнопку ниже. + RedirectedDescription: Вы можете закрыть это окно. + NextButtonText: Продолжить LogoutDone: Title: Выход из системы Description: Вы успешно вышли из системы. - LoginButtonText: вход + LoginButtonText: Войти LinkingUserPrompt: - Title: Существующий пользователь найден - Description: "Хотите ли вы связать существующую учетную запись:" - LinkButtonText: Связь + Title: Найден существующий пользователь + Description: "Хотите связать эту учётную запись с существующей?" + LinkButtonText: Связать OtherButtonText: Другие варианты LinkingUsersDone: Title: Привязка пользователя - Description: Привязка пользователя выполнена. - CancelButtonText: отмена - NextButtonText: далее + Description: Привязка учётной записи выполнена успешно. + CancelButtonText: Отмена + NextButtonText: Продолжить ExternalNotFound: Title: Внешний пользователь не найден - Description: Внешний пользователь не найден. Вы можете привязать своего пользователя или автоматически зарегистрировать нового. + Description: Мы не смогли найти указанного внешнего пользователя. Вы можете привязать существующую учетную запись или зарегистрировать нового пользователя. LinkButtonText: Привязать - AutoRegisterButtonText: зарегистрировать + AutoRegisterButtonText: Зарегистрировать TosAndPrivacyLabel: Условия использования TosConfirm: Я согласен с TosLinkText: Пользовательским соглашением @@ -386,132 +387,132 @@ DeviceAuth: Title: Авторизация устройства UserCode: Label: Код пользователя - Description: Введите код пользователя, представленный на устройстве. - ButtonNext: следующий + Description: Введите код, отображаемый на вашем устройстве. + ButtonNext: Продолжить Action: - Description: Предоставьте доступ к устройству. - GrantDevice: Вы собираетесь предоставить устройство - AccessToScopes: Доступ к следующим областям + Description: Предоставьте устройству доступ к системе. + GrantDevice: вы собираетесь предоставить устройству + AccessToScopes: доступ к следующим областям Button: - Allow: разрешать - Deny: отрицать + Allow: Разрешать + Deny: Отклонить Done: - Description: Договорились. - Approved: Авторизация устройства одобрена. Теперь вы можете вернуться к устройству. - Denied: Отказано в авторизации устройства. Теперь вы можете вернуться к устройству. + Description: Операция завершена. + Approved: Устройство успешно авторизовано. Теперь вы можете вернуться к устройству. + Denied: Авторизация устройства отклонена. Теперь вы можете вернуться к устройству. Footer: - PoweredBy: На базе + PoweredBy: Работает на основе Tos: Пользовательское соглашение PrivacyPolicy: Политика конфиденциальности Help: Помощь SupportEmail: Электронная почта службы поддержки -SignIn: Вход с помощью {{.Provider}} +SignIn: Войти с помощью {{.Provider}} Errors: Internal: Произошла внутренняя ошибка AuthRequest: - NotFound: Не удалось обнаружить запрос авторизации - UserAgentNotCorresponding: User Agent не соответствует - UserAgentNotFound: ID User Agent не найден + NotFound: authrequest не найден + UserAgentNotCorresponding: User Agent не совпадает + UserAgentNotFound: User Agent ID не найден TokenNotFound: Токен не найден RequestTypeNotSupported: Тип запроса не поддерживается MissingParameters: Отсутствуют обязательные параметры User: - NotFound: Пользователь не может быть найден + NotFound: Пользователь не найден AlreadyExists: Пользователь уже существует Inactive: Пользователь неактивен NotFoundOnOrg: Не удалось найти пользователя в выбранной организации - NotAllowedOrg: Пользователь не является участником требуемой организации - NotMatchingUserID: Пользователь не совпадает с пользователем в запросе авторизации - UserIDMissing: UserID пустой + NotAllowedOrg: Пользователь не является членом требуемой организации + NotMatchingUserID: Указанный пользователь не совпадает с пользователем в authrequest + UserIDMissing: Не указан UserID Invalid: Неверные данные пользователя - DomainNotAllowedAsUsername: Домен уже зарезервирован и не может быть использован - NotAllowedToLink: Пользователя не разрешено привязывать к внешнему провайдеру входа + DomainNotAllowedAsUsername: Домен уже зарезервирован и не может быть использован в качестве логина + NotAllowedToLink: Привязка пользователя к внешнему провайдеру запрещена Profile: NotFound: Профиль не найден NotChanged: Профиль не изменен Empty: Профиль пуст - FirstNameEmpty: Имя в профиле пусто - LastNameEmpty: Фамилия в профиле пуста + FirstNameEmpty: Поле имени пусто + LastNameEmpty: Поле фамилии пусто IDMissing: Отсутствует идентификатор профиля Email: NotFound: Электронная почта не найдена - Invalid: Адрес электронной почты недействителен + Invalid: Некорректный адрес электронной почты AlreadyVerified: Электронная почта уже подтверждена NotChanged: Адрес электронной почты не изменился - Empty: Электронная почта пуста + Empty: Электронная почта не указана IDMissing: Отсутствует идентификатор электронной почты Phone: - NotFound: Телефон не найден - Invalid: Телефон недействителен - AlreadyVerified: Телефон уже проверен - Empty: Телефон пуст - NotChanged: Телефон не менялся + NotFound: Номер телефона не найден + Invalid: Неверный номер телефона + AlreadyVerified: Номер телефона уже подтвержден + Empty: Номер телефона не указан + NotChanged: Номер телефона не изменился Address: NotFound: Адрес не найден NotChanged: Адрес не изменился Username: - AlreadyExists: Имя пользователя уже занято - Reserved: Имя пользователя уже занято - Empty: Имя пользователя пусто + AlreadyExists: Логин уже занят + Reserved: Логин уже занят + Empty: Логин не указан Password: - ConfirmationWrong: Неверное подтверждение пароля - Empty: Пароль пустой + ConfirmationWrong: Указанные пароли не совпадают + Empty: Пароль не указан Invalid: Неверный пароль InvalidAndLocked: Неверный пароль, пользователь заблокирован. Обратитесь к администратору. - NotChanged: Пароль не изменен + NotChanged: Пароль не изменился UsernameOrPassword: - Invalid: Логин или пароль недействительны + Invalid: Неверный логин или пароль PasswordComplexityPolicy: NotFound: Политика паролей не найдена MinLength: Пароль слишком короткий - HasLower: Пароль должен содержать строчную букву - HasUpper: Пароль должен содержать заглавную букву - HasNumber: Пароль должен содержать цифру - HasSymbol: Пароль должен содержать символ + HasLower: Пароль должен содержать хотя бы одну строчную букву + HasUpper: Пароль должен содержать хотя бы одну заглавную букву + HasNumber: Пароль должен содержать хотя бы одну цифру + HasSymbol: Пароль должен содержать хотя бы один специальный символ Code: Expired: Код истёк Invalid: Неверный код - Empty: Код пустой - CryptoCodeNil: Криптокод равен нулю - NotFound: Не удалось найти код - GeneratorAlgNotSupported: Неподдерживаемый алгоритм генератора + Empty: Код не указан + CryptoCodeNil: Пустой криптокод + NotFound: Код не найден + GeneratorAlgNotSupported: Алгоритм генерации кода не поддерживается EmailVerify: - UserIDEmpty: UserID пустой + UserIDEmpty: UserID не указан ExternalData: - CouldNotRead: Внешние данные не могут быть обработаны корректно + CouldNotRead: Не удалось обработать внешние данные MFA: - NoProviders: Нет доступных многофакторных поставщиков + NoProviders: Нет доступных методов многофакторной аутентификации OTP: - AlreadyReady: Мультифактор OTP (OneTimePassword) уже настроен - NotExisting: Мультифактор OTP (OneTimePassword) не существует + AlreadyReady: OTP (OneTimePassword) уже настроен + NotExisting: OTP (OneTimePassword) не существует InvalidCode: Неверный код - NotReady: Мультифактор OTP (OneTimePassword) не готов + NotReady: OTP (OneTimePassword) не готов Locked: Пользователь заблокирован SomethingWentWrong: Что-то пошло не так NotActive: Пользователь неактивен ExternalIDP: - IDPTypeNotImplemented: IDP тип не реализован - NotAllowed: Внешний провайдер входа запрещён - IDPConfigIDEmpty: IDP ID пустой - ExternalUserIDEmpty: External User ID пустой - UserDisplayNameEmpty: Отображаемое имя пользователя пустое - NoExternalUserData: Данные внешнего пользователя не получены - CreationNotAllowed: Создание нового пользователя для данного провайдера не разрешено - LinkingNotAllowed: Привязка пользователя с данным провайдером запрещена - NoOptionAllowed: Ни создание, ни связывание не разрешены для этого провайдера. Пожалуйста, обратитесь к администратору. - GrantRequired: Вход невозможен. Пользователь должен иметь хотя бы один допуск в приложении. Пожалуйста, свяжитесь с вашим администратором. - ProjectRequired: Вход невозможен. Организация пользователя должна иметь допуск к проекту. Пожалуйста, свяжитесь с вашим администратором. + IDPTypeNotImplemented: Тип внешнего провайдера не поддерживается + NotAllowed: Доступ к внешнему провайдеру запрещен + IDPConfigIDEmpty: Не указан идентификатор конфигурации провайдера + ExternalUserIDEmpty: Не указан внешний идентификатор пользователя + UserDisplayNameEmpty: Отображаемое имя пользователя не указано + NoExternalUserData: Внешние данные пользователя отсутствуют + CreationNotAllowed: Создание нового пользователя для этого провайдера запрещено + LinkingNotAllowed: Привязка к этому провайдеру запрещена + NoOptionAllowed: Ни создание, ни привязка пользователя к этому провайдеру невозможны. Обратитесь к администратору. + GrantRequired: Вход невозможен. Пользователь должен иметь хотя бы один допуск к приложению. Обратитесь к администратору. + ProjectRequired: Вход невозможен. Организация пользователя должна иметь доступ к проекту. Обратитесь к администратору. IdentityProvider: - InvalidConfig: Недопустимая конфигурация поставщика идентификационных данных + InvalidConfig: Некорректная конфигурация провайдера идентификации IAM: LockoutPolicy: - NotExisting: Политика блокировки не существует + NotExisting: Политика блокировки не найдена Org: LoginPolicy: - RegistrationNotAllowed: Регистрация не допускается + RegistrationNotAllowed: Регистрация запрещена DeviceAuth: NotExisting: Код пользователя не существует From 8fcf8e9ac8333aa439eef2af2fce9deb4201f080 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Robin=20Br=C3=A4mer?= Date: Mon, 2 Dec 2024 09:57:33 +0100 Subject: [PATCH 37/64] docs: Add adopters (#8987) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit I love Zitadel, and we have been using it for a while. It's the most complete solution out there. ❤️ Co-authored-by: Fabi --- ADOPTERS.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/ADOPTERS.md b/ADOPTERS.md index 2f903664be..ba740212b2 100644 --- a/ADOPTERS.md +++ b/ADOPTERS.md @@ -12,5 +12,7 @@ If you are using Zitadel, please consider adding yourself as a user with a quick | ----------------------- | -------------------------------------------------------------------- | ----------------------------------------------- | | Zitadel | [@fforootd](https://github.com/fforootd) (and many more) | Zitadel Cloud makes heavy use of of Zitadel ;-) | | Rawkode Academy | [@RawkodeAcademy](https://github.com/RawkodeAcademy) | Rawkode Academy Platform & Zulip use Zitadel for all user and M2M authentication | +| CNAP.tech | [@cnap-tech](https://github.com/cnap-tech) | Using Zitadel for authentication and authorization in cloud-native applications | +| Minekube | [@minekube](https://github.com/minekube) | Leveraging Zitadel for secure user authentication in gaming infrastructure | | Organization Name | contact@example.com | Description of how they use Zitadel | | Individual Name | contact@example.com | Description of how they use Zitadel | From c0a93944c30f1ed4f10b265bd0255d7ebe7f999a Mon Sep 17 00:00:00 2001 From: Kim JeongHyeon <38876544+KimTibber@users.noreply.github.com> Date: Mon, 2 Dec 2024 22:11:31 +0900 Subject: [PATCH 38/64] feat(i18n): add korean language support (#8879) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Hello everyone, To support Korean-speaking users who may experience challenges in using this excellent tool due to language barriers, I have added Korean language support with the help of ChatGPT. I hope that this contribution allows ZITADEL to be more useful and accessible to Korean-speaking users. Thank you. --- 안녕하세요 여러분, 언어의 어려움으로 이 훌륭한 도구를 활용하는데 곤란함을 겪는 한국어 사용자들을 위하여 ChatGPT의 도움을 받아 한국어 지원을 추가하였습니다. 이 기여를 통해 ZITADEL이 한국어 사용자들에게 유용하게 활용되었으면 좋겠습니다. 감사합니다. Co-authored-by: Max Peintner --- console/src/app/app.module.ts | 3 + console/src/app/utils/language.ts | 3 +- console/src/assets/i18n/bg.json | 9 +- console/src/assets/i18n/cs.json | 9 +- console/src/assets/i18n/de.json | 9 +- console/src/assets/i18n/en.json | 9 +- console/src/assets/i18n/es.json | 9 +- console/src/assets/i18n/fr.json | 9 +- console/src/assets/i18n/hu.json | 6 +- console/src/assets/i18n/id.json | 9 +- console/src/assets/i18n/it.json | 9 +- console/src/assets/i18n/ja.json | 9 +- console/src/assets/i18n/ko.json | 2671 +++++++++++++++++ console/src/assets/i18n/mk.json | 9 +- console/src/assets/i18n/nl.json | 6 +- console/src/assets/i18n/pl.json | 9 +- console/src/assets/i18n/pt.json | 9 +- console/src/assets/i18n/ru.json | 9 +- console/src/assets/i18n/sv.json | 9 +- console/src/assets/i18n/zh.json | 9 +- docs/docs/guides/manage/customize/texts.md | 1 + internal/api/ui/login/static/i18n/bg.yaml | 3 + internal/api/ui/login/static/i18n/cs.yaml | 3 + internal/api/ui/login/static/i18n/de.yaml | 3 + internal/api/ui/login/static/i18n/en.yaml | 3 + internal/api/ui/login/static/i18n/es.yaml | 3 + internal/api/ui/login/static/i18n/fr.yaml | 3 + internal/api/ui/login/static/i18n/hu.yaml | 3 + internal/api/ui/login/static/i18n/id.yaml | 3 + internal/api/ui/login/static/i18n/it.yaml | 3 + internal/api/ui/login/static/i18n/ja.yaml | 3 + internal/api/ui/login/static/i18n/ko.yaml | 521 ++++ internal/api/ui/login/static/i18n/mk.yaml | 3 + internal/api/ui/login/static/i18n/nl.yaml | 3 + internal/api/ui/login/static/i18n/pl.yaml | 3 + internal/api/ui/login/static/i18n/pt.yaml | 3 + internal/api/ui/login/static/i18n/ru.yaml | 3 + internal/api/ui/login/static/i18n/sv.yaml | 3 + internal/api/ui/login/static/i18n/zh.yaml | 3 + .../templates/external_not_found_option.html | 2 + internal/notification/static/i18n/ko.yaml | 68 + internal/static/i18n/ko.yaml | 1406 +++++++++ 42 files changed, 4823 insertions(+), 50 deletions(-) create mode 100644 console/src/assets/i18n/ko.json create mode 100644 internal/api/ui/login/static/i18n/ko.yaml create mode 100644 internal/notification/static/i18n/ko.yaml create mode 100644 internal/static/i18n/ko.yaml diff --git a/console/src/app/app.module.ts b/console/src/app/app.module.ts index b995c69b88..7fad84a4c6 100644 --- a/console/src/app/app.module.ts +++ b/console/src/app/app.module.ts @@ -17,6 +17,7 @@ import localeRu from '@angular/common/locales/ru'; import localeNl from '@angular/common/locales/nl'; import localeSv from '@angular/common/locales/sv'; import localeHu from '@angular/common/locales/hu'; +import localeKo from '@angular/common/locales/ko'; import { APP_INITIALIZER, NgModule } from '@angular/core'; import { MatNativeDateModule } from '@angular/material/core'; import { MatDialogModule } from '@angular/material/dialog'; @@ -108,6 +109,8 @@ registerLocaleData(localeSv); i18nIsoCountries.registerLocale(require('i18n-iso-countries/langs/sv.json')); registerLocaleData(localeHu); i18nIsoCountries.registerLocale(require('i18n-iso-countries/langs/hu.json')); +registerLocaleData(localeKo); +i18nIsoCountries.registerLocale(require('i18n-iso-countries/langs/ko.json')); export class WebpackTranslateLoader implements TranslateLoader { getTranslation(lang: string): Observable { diff --git a/console/src/app/utils/language.ts b/console/src/app/utils/language.ts index f17893372c..4ef63dcb28 100644 --- a/console/src/app/utils/language.ts +++ b/console/src/app/utils/language.ts @@ -16,6 +16,7 @@ export const supportedLanguages = [ 'nl', 'sv', 'hu', + 'ko', ]; -export const supportedLanguagesRegexp: RegExp = /de|en|es|fr|id|it|ja|pl|zh|bg|pt|mk|cs|ru|nl|sv|hu/; +export const supportedLanguagesRegexp: RegExp = /de|en|es|fr|id|it|ja|pl|zh|bg|pt|mk|cs|ru|nl|sv|hu|ko/; export const fallbackLanguage: string = 'en'; diff --git a/console/src/assets/i18n/bg.json b/console/src/assets/i18n/bg.json index d47d411e07..c196e230a1 100644 --- a/console/src/assets/i18n/bg.json +++ b/console/src/assets/i18n/bg.json @@ -1383,7 +1383,8 @@ "nl": "Nederlands", "sv": "Svenska", "id": "Bahasa Indonesia", - "hu": "Magyar" + "hu": "Magyar", + "ko": "한국어" } }, "SMTP": { @@ -1620,7 +1621,8 @@ "ru": "Русский", "nl": "Nederlands", "sv": "Svenska", - "id": "Bahasa Indonesia" + "id": "Bahasa Indonesia", + "ko": "한국어" }, "KEYS": { "emailVerificationDoneText": "Проверката на имейл е извършена", @@ -2559,7 +2561,8 @@ "ru": "Русский", "nl": "Nederlands", "sv": "Svenska", - "id": "Bahasa Indonesia" + "id": "Bahasa Indonesia", + "ko": "한국어" }, "MEMBER": { "ADD": "Добавяне на мениджър", diff --git a/console/src/assets/i18n/cs.json b/console/src/assets/i18n/cs.json index f251da7ab5..817587574f 100644 --- a/console/src/assets/i18n/cs.json +++ b/console/src/assets/i18n/cs.json @@ -1384,7 +1384,8 @@ "nl": "Nederlands", "sv": "Svenska", "id": "Bahasa Indonesia", - "hu": "Magyar" + "hu": "Magyar", + "ko": "한국어" } }, "SMTP": { @@ -1621,7 +1622,8 @@ "ru": "Русский", "nl": "Nederlands", "sv": "Svenska", - "id": "Bahasa Indonesia" + "id": "Bahasa Indonesia", + "ko": "한국어" }, "KEYS": { "emailVerificationDoneText": "Ověření e-mailu dokončeno", @@ -2572,7 +2574,8 @@ "ru": "Русский", "nl": "Nederlands", "sv": "Svenska", - "id": "Bahasa Indonesia" + "id": "Bahasa Indonesia", + "ko": "한국어" }, "MEMBER": { "ADD": "Přidat manažera", diff --git a/console/src/assets/i18n/de.json b/console/src/assets/i18n/de.json index 3659848bc7..b8f0e3285b 100644 --- a/console/src/assets/i18n/de.json +++ b/console/src/assets/i18n/de.json @@ -1384,7 +1384,8 @@ "nl": "Nederlands", "sv": "Svenska", "id": "Bahasa Indonesia", - "hu": "Magyar" + "hu": "Magyar", + "ko": "한국어" } }, "SMTP": { @@ -1621,7 +1622,8 @@ "ru": "Русский", "nl": "Nederlands", "sv": "Svenska", - "id": "Bahasa Indonesia" + "id": "Bahasa Indonesia", + "ko": "한국어" }, "KEYS": { "emailVerificationDoneText": "Email Verification erfolgreich", @@ -2563,7 +2565,8 @@ "ru": "Русский", "nl": "Nederlands", "sv": "Svenska", - "id": "Bahasa Indonesia" + "id": "Bahasa Indonesia", + "ko": "한국어" }, "MEMBER": { "ADD": "Manager hinzufügen", diff --git a/console/src/assets/i18n/en.json b/console/src/assets/i18n/en.json index c6fe05499d..d18a7114fe 100644 --- a/console/src/assets/i18n/en.json +++ b/console/src/assets/i18n/en.json @@ -1384,7 +1384,8 @@ "nl": "Nederlands", "sv": "Svenska", "id": "Bahasa Indonesia", - "hu": "Magyar" + "hu": "Magyar", + "ko": "한국어" } }, "SMTP": { @@ -1621,7 +1622,8 @@ "ru": "Русский", "nl": "Nederlands", "sv": "Svenska", - "id": "Bahasa Indonesia" + "id": "Bahasa Indonesia", + "ko": "한국어" }, "KEYS": { "emailVerificationDoneText": "Email verification done", @@ -2588,7 +2590,8 @@ "ru": "Русский", "nl": "Nederlands", "sv": "Svenska", - "id": "Bahasa Indonesia" + "id": "Bahasa Indonesia", + "ko": "한국어" }, "MEMBER": { "ADD": "Add a Manager", diff --git a/console/src/assets/i18n/es.json b/console/src/assets/i18n/es.json index 9565c034b7..2367e12471 100644 --- a/console/src/assets/i18n/es.json +++ b/console/src/assets/i18n/es.json @@ -1385,7 +1385,8 @@ "nl": "Nederlands", "sv": "Svenska", "id": "Bahasa Indonesia", - "hu": "Magyar" + "hu": "Magyar", + "ko": "한국어" } }, "SMTP": { @@ -1622,7 +1623,8 @@ "ru": "Русский", "nl": "Nederlands", "sv": "Svenska", - "id": "Bahasa Indonesia" + "id": "Bahasa Indonesia", + "ko": "한국어" }, "KEYS": { "emailVerificationDoneText": "Verificación de email realizada", @@ -2560,7 +2562,8 @@ "ru": "Русский", "nl": "Nederlands", "sv": "Svenska", - "id": "Bahasa Indonesia" + "id": "Bahasa Indonesia", + "ko": "한국어" }, "MEMBER": { "ADD": "Añadir un Mánager", diff --git a/console/src/assets/i18n/fr.json b/console/src/assets/i18n/fr.json index 5a41984c7e..72423c79ec 100644 --- a/console/src/assets/i18n/fr.json +++ b/console/src/assets/i18n/fr.json @@ -1384,7 +1384,8 @@ "nl": "Nederlands", "sv": "Svenska", "id": "Bahasa Indonesia", - "hu": "Magyar" + "hu": "Magyar", + "ko": "한국어" } }, "SMTP": { @@ -1621,7 +1622,8 @@ "ru": "Русский", "nl": "Nederlands", "sv": "Svenska", - "id": "Bahasa Indonesia" + "id": "Bahasa Indonesia", + "ko": "한국어" }, "KEYS": { "emailVerificationDoneText": "Vérification de l'e-mail effectuée", @@ -2564,7 +2566,8 @@ "ru": "Русский", "nl": "Nederlands", "sv": "Svenska", - "id": "Bahasa Indonesia" + "id": "Bahasa Indonesia", + "ko": "한국어" }, "MEMBER": { "ADD": "Ajouter un responsable", diff --git a/console/src/assets/i18n/hu.json b/console/src/assets/i18n/hu.json index 614af5520e..064dd8ea5f 100644 --- a/console/src/assets/i18n/hu.json +++ b/console/src/assets/i18n/hu.json @@ -1384,7 +1384,8 @@ "nl": "Holland", "sv": "Svéd", "id": "Indonéz", - "hu": "Magyar" + "hu": "Magyar", + "ko": "한국어" } }, "SMTP": { @@ -1619,7 +1620,8 @@ "ru": "Orosz", "nl": "Holland", "sv": "Svéd", - "id": "Indonéz" + "id": "Indonéz", + "ko": "한국어" }, "KEYS": { "emailVerificationDoneText": "E-mail ellenőrzés kész", diff --git a/console/src/assets/i18n/id.json b/console/src/assets/i18n/id.json index 76fe69b507..9dd80d7902 100644 --- a/console/src/assets/i18n/id.json +++ b/console/src/assets/i18n/id.json @@ -1262,7 +1262,8 @@ "nl": "Nederlands", "sv": "Svenska", "id": "Bahasa Indonesia", - "hu": "Magyar" + "hu": "Magyar", + "ko": "한국어" } }, "SMTP": { @@ -1486,7 +1487,8 @@ "ru": "Русский", "nl": "Nederlands", "sv": "Svenska", - "id": "Bahasa Indonesia" + "id": "Bahasa Indonesia", + "ko": "한국어" }, "KEYS": { "emailVerificationDoneText": "Verifikasi email selesai", @@ -2272,7 +2274,8 @@ "ru": "Русский", "nl": "Nederlands", "sv": "Svenska", - "id": "Bahasa Indonesia" + "id": "Bahasa Indonesia", + "ko": "한국어" }, "MEMBER": { "ADD": "Tambahkan Manajer", diff --git a/console/src/assets/i18n/it.json b/console/src/assets/i18n/it.json index a87cb2fc75..f69e97bea7 100644 --- a/console/src/assets/i18n/it.json +++ b/console/src/assets/i18n/it.json @@ -1384,7 +1384,8 @@ "nl": "Nederlands", "sv": "Svenska", "id": "Bahasa Indonesia", - "hu": "Magyar" + "hu": "Magyar", + "ko": "한국어" } }, "SMTP": { @@ -1621,7 +1622,8 @@ "ru": "Русский", "nl": "Nederlands", "sv": "Svenska", - "id": "Bahasa Indonesia" + "id": "Bahasa Indonesia", + "ko": "한국어" }, "KEYS": { "emailVerificationDoneText": "Verifica dell'e-mail terminata con successo.", @@ -2564,7 +2566,8 @@ "ru": "Русский", "nl": "Nederlands", "sv": "Svenska", - "id": "Bahasa Indonesia" + "id": "Bahasa Indonesia", + "ko": "한국어" }, "MEMBER": { "ADD": "Aggiungi un manager", diff --git a/console/src/assets/i18n/ja.json b/console/src/assets/i18n/ja.json index 4e7e401c7a..7e9f102ecf 100644 --- a/console/src/assets/i18n/ja.json +++ b/console/src/assets/i18n/ja.json @@ -1384,7 +1384,8 @@ "nl": "Nederlands", "sv": "Svenska", "id": "Bahasa Indonesia", - "hu": "Magyar" + "hu": "Magyar", + "ko": "한국어" } }, "SMTP": { @@ -1617,7 +1618,8 @@ "ru": "Русский", "nl": "Nederlands", "sv": "Svenska", - "id": "Bahasa Indonesia" + "id": "Bahasa Indonesia", + "ko": "한국어" }, "KEYS": { "emailVerificationDoneText": "メール認証が完了しました", @@ -2554,7 +2556,8 @@ "ru": "Русский", "nl": "Nederlands", "sv": "Svenska", - "id": "Bahasa Indonesia" + "id": "Bahasa Indonesia", + "ko": "한국어" }, "MEMBER": { "ADD": "マネージャーを追加する", diff --git a/console/src/assets/i18n/ko.json b/console/src/assets/i18n/ko.json new file mode 100644 index 0000000000..79fe95324a --- /dev/null +++ b/console/src/assets/i18n/ko.json @@ -0,0 +1,2671 @@ +{ + "APP_NAME": "ZITADEL", + "DESCRIPTIONS": { + "METADATA_TITLE": "메타데이터", + "HOME": { + "TITLE": "ZITADEL 시작하기", + "NEXT": { + "TITLE": "다음 단계", + "DESCRIPTION": "애플리케이션 보안을 위해 다음 단계를 완료하세요.", + "CREATE_PROJECT": { + "TITLE": "프로젝트 생성", + "DESCRIPTION": "프로젝트를 추가하고 역할과 권한을 정의하세요." + } + }, + "MORE_SHORTCUTS": { + "GET_STARTED": { + "TITLE": "시작하기", + "DESCRIPTION": "빠른 시작 가이드를 단계별로 따라하여 바로 시작하세요." + }, + "DOCS": { + "TITLE": "문서", + "DESCRIPTION": "ZITADEL의 지식 베이스를 탐색하여 핵심 개념과 아이디어에 대해 배우세요. ZITADEL의 작동 방식과 사용 방법을 학습하세요." + }, + "EXAMPLES": { + "TITLE": "예제 및 소프트웨어 개발 키트", + "DESCRIPTION": "예제와 SDK를 통해 ZITADEL을 선호하는 프로그래밍 언어 및 도구와 함께 사용하는 방법을 확인하세요." + } + } + }, + "ORG": { + "TITLE": "조직", + "DESCRIPTION": "조직은 사용자, 앱이 포함된 프로젝트, ID 제공자, 회사 브랜딩과 같은 설정을 관리합니다. 여러 조직 간에 설정을 공유하고 싶으신가요? 기본 설정을 구성하세요.", + "METADATA": "조직에 위치나 다른 시스템의 식별자와 같은 맞춤 속성을 추가하세요. 이 정보를 작업에 활용할 수 있습니다." + }, + "PROJECTS": { + "TITLE": "프로젝트", + "DESCRIPTION": "프로젝트는 하나 이상의 애플리케이션을 호스팅하여 사용자를 인증할 수 있습니다. 또한 프로젝트를 통해 사용자에게 권한을 부여할 수 있습니다. 다른 조직의 사용자가 애플리케이션에 로그인할 수 있도록 프로젝트에 대한 액세스를 부여하세요.

프로젝트를 찾을 수 없는 경우 프로젝트 소유자 또는 관련 권한이 있는 사람에게 문의하여 액세스를 얻으세요.", + "OWNED": { + "TITLE": "소유한 프로젝트", + "DESCRIPTION": "이 프로젝트는 사용자가 소유한 프로젝트입니다. 프로젝트 설정, 권한 및 애플리케이션을 관리할 수 있습니다." + }, + "GRANTED": { + "TITLE": "부여된 프로젝트", + "DESCRIPTION": "다른 조직에서 사용자가 부여받은 프로젝트입니다. 부여된 프로젝트를 통해 사용자가 다른 조직의 애플리케이션에 액세스할 수 있습니다." + } + }, + "USERS": { + "TITLE": "사용자", + "DESCRIPTION": "사용자는 애플리케이션에 액세스할 수 있는 사람 또는 장치입니다.", + "HUMANS": { + "TITLE": "사용자", + "DESCRIPTION": "사용자는 로그인 프롬프트로 브라우저 세션에서 상호 인증을 수행합니다.", + "METADATA": "부서와 같은 사용자 정의 속성을 사용자에게 추가하세요. 이 정보를 작업에 사용할 수 있습니다." + }, + "MACHINES": { + "TITLE": "서비스 사용자", + "DESCRIPTION": "서비스 사용자는 개인 키로 서명된 JWT bearer 토큰을 사용하여 비대화형 인증을 수행합니다. 또한 개인 액세스 토큰을 사용할 수 있습니다.", + "METADATA": "인증 시스템과 같은 사용자 정의 속성을 사용자에게 추가하세요. 이 정보를 작업에 사용할 수 있습니다." + }, + "SELF": { + "METADATA": "부서와 같은 사용자 정의 속성을 사용자에게 추가하세요. 이 정보를 조직의 작업에 사용할 수 있습니다." + } + }, + "AUTHORIZATIONS": { + "TITLE": "권한", + "DESCRIPTION": "권한은 사용자의 프로젝트 접근 권한을 정의합니다. 사용자가 프로젝트에 액세스할 수 있도록 하고, 프로젝트 내의 역할을 정의할 수 있습니다." + }, + "ACTIONS": { + "TITLE": "작업", + "DESCRIPTION": "사용자가 ZITADEL에 인증할 때 발생하는 이벤트에 맞춰 사용자 정의 코드를 실행하세요. 프로세스를 자동화하고, 사용자 메타데이터 및 토큰을 강화하거나 외부 시스템에 알림을 보냅니다.", + "SCRIPTS": { + "TITLE": "스크립트", + "DESCRIPTION": "하나의 자바스크립트 코드로 여러 작업 플로우에서 손쉽게 활용하세요." + }, + "FLOWS": { + "TITLE": "플로우", + "DESCRIPTION": "인증 플로우를 선택하고 이 플로우 내의 특정 이벤트에서 작업을 트리거하세요." + } + }, + "SETTINGS": { + "INSTANCE": { + "TITLE": "기본 설정", + "DESCRIPTION": "모든 조직에 대한 기본 설정입니다. 권한이 있으면 일부 설정을 조직 설정에서 재정의할 수 있습니다." + }, + "ORG": { + "TITLE": "조직 설정", + "DESCRIPTION": "조직의 설정을 사용자 정의하세요." + }, + "FEATURES": { + "TITLE": "기능 설정", + "DESCRIPTION": "인스턴스의 기능을 잠금 해제하세요." + }, + "IDPS": { + "TITLE": "ID 제공자", + "DESCRIPTION": "외부 ID 제공자를 생성하고 활성화하세요. 알려진 제공자를 선택하거나 다른 OIDC, OAuth 또는 SAML 호환 제공자를 구성하세요. 기존 JWT 토큰을 사용하여 연합된 ID로 구성할 수도 있습니다.", + "NEXT": "다음 단계는?", + "SAML": { + "TITLE": "SAML ID 제공자 구성", + "DESCRIPTION": "ZITADEL이 구성되었습니다. 이제 SAML ID 제공자에 대한 일부 설정이 필요합니다. 대부분의 제공자는 ZITADEL 메타데이터 XML 전체를 업로드하는 기능을 제공합니다. 일부 제공자는 메타데이터 URL, Assertion Consumer Service (ACS) URL, 또는 Single Logout URL과 같은 특정 URL만 제공합니다." + }, + "CALLBACK": { + "TITLE": "{{ provider }} ID 제공자 구성", + "DESCRIPTION": "ZITADEL을 구성하기 전에, 인증 후 ZITADEL로 브라우저 리디렉션을 가능하게 하기 위해 이 URL을 ID 제공자에게 전달하세요." + }, + "JWT": { + "TITLE": "JWT를 연합된 ID로 사용", + "DESCRIPTION": "JWT ID 제공자를 통해 기존 JWT 토큰을 연합된 ID로 사용할 수 있습니다. 이미 JWT 발급자가 있는 경우 이 기능이 유용합니다. JWT IdP를 사용하여 ZITADEL에서 사용자 생성 및 업데이트를 실시간으로 수행할 수 있습니다." + }, + "LDAP": { + "TITLE": "LDAP ID 제공자에 ZITADEL 연결 구성", + "DESCRIPTION": "LDAP 서버의 연결 세부 정보를 제공하고, LDAP 속성을 ZITADEL 속성에 매핑하세요." + }, + "AUTOFILL": { + "TITLE": "사용자 데이터 자동 입력", + "DESCRIPTION": "사용자 경험을 개선하기 위해 작업을 사용하세요. ID 제공자에서 값을 가져와 ZITADEL의 등록 양식을 미리 채울 수 있습니다." + }, + "ACTIVATE": { + "TITLE": "ID 제공자 활성화", + "DESCRIPTION": "ID 제공자가 아직 활성화되지 않았습니다. 사용자가 로그인할 수 있도록 활성화하세요." + } + }, + "PW_COMPLEXITY": { + "TITLE": "비밀번호 복잡성", + "DESCRIPTION": "사용자가 복잡한 비밀번호를 사용하도록 규칙을 정의하여 보안을 강화하세요." + }, + "BRANDING": { + "TITLE": "브랜딩", + "DESCRIPTION": "로그인 폼의 외관을 사용자 정의하세요. 설정을 완료한 후 구성 적용을 기억하세요." + }, + "PRIVACY_POLICY": { + "TITLE": "외부 링크", + "DESCRIPTION": "사용자가 로그인 페이지에서 커스텀 외부 리소스로 안내됩니다. 사용자는 가입 전에 이용 약관과 개인정보처리방침을 수락해야 합니다. 문서 링크를 변경하거나, 콘솔에서 문서 버튼을 숨기려면 빈 문자열로 설정하세요. 사용자 정의 외부 링크와 그 링크에 대한 설명을 콘솔에 추가하거나, 버튼을 숨기려면 빈 값으로 설정하세요." + }, + "SMTP_PROVIDER": { + "TITLE": "SMTP 설정", + "DESCRIPTION": "사용자가 신뢰할 수 있는 도메인을 발신자 주소로 사용하는 SMTP 서버를 구성하세요." + }, + "SMS_PROVIDER": { + "TITLE": "SMS 설정", + "DESCRIPTION": "ZITADEL의 모든 기능을 사용하려면 Twilio를 설정하여 사용자에게 SMS 메시지를 보내세요." + }, + "IAM_EVENTS": { + "TITLE": "이벤트", + "DESCRIPTION": "인스턴스에서 상태 변경을 보여주는 페이지입니다. 디버깅을 위해 시간 범위별로 필터링하거나 감사 목적으로 특정 집계를 필터링하세요." + }, + "IAM_FAILED_EVENTS": { + "TITLE": "실패한 이벤트", + "DESCRIPTION": "인스턴스에서 실패한 모든 이벤트가 표시됩니다. ZITADEL이 예상대로 작동하지 않는 경우, 이 목록을 먼저 확인하세요." + }, + "IAM_VIEWS": { + "TITLE": "뷰", + "DESCRIPTION": "데이터베이스 뷰와 최근 이벤트가 처리된 시점을 보여주는 페이지입니다. 데이터가 누락된 경우, 뷰가 최신인지 확인하세요." + }, + "LANGUAGES": { + "TITLE": "언어", + "DESCRIPTION": "로그인 폼과 알림 메시지가 번역되는 언어를 제한하세요. 일부 언어를 비활성화하려면 '허용되지 않은 언어' 섹션으로 드래그하세요. 기본 언어로 허용된 언어를 지정할 수 있습니다. 사용자의 선호 언어가 허용되지 않으면 기본 언어가 사용됩니다." + }, + "SECRET_GENERATORS": { + "TITLE": "시크릿 생성기", + "DESCRIPTION": "시크릿의 복잡성과 수명을 정의하세요. 높은 복잡성과 긴 수명은 보안을 강화하고, 낮은 복잡성과 짧은 수명은 암호 해독 성능을 향상시킵니다." + }, + "SECURITY": { + "TITLE": "보안 설정", + "DESCRIPTION": "보안에 영향을 미칠 수 있는 ZITADEL 기능을 활성화하세요. 설정을 변경하기 전에 주의 깊게 검토하세요." + }, + "OIDC": { + "TITLE": "OpenID Connect 설정", + "DESCRIPTION": "OIDC 토큰 수명을 설정하세요. 짧은 수명은 사용자의 보안을 강화하고, 긴 수명은 사용자의 편의를 증가시킵니다.", + "LABEL_HOURS": "최대 수명 (시간)", + "LABEL_DAYS": "최대 수명 (일)", + "ACCESS_TOKEN": { + "TITLE": "액세스 토큰", + "DESCRIPTION": "액세스 토큰은 사용자를 인증하는 데 사용되는 단기 토큰입니다. 사용자의 데이터를 접근하는 데 사용됩니다. 불법적인 접근 위험을 줄이기 위해 짧은 수명을 사용하세요. 액세스 토큰은 갱신 토큰을 사용하여 자동으로 갱신할 수 있습니다." + }, + "ID_TOKEN": { + "TITLE": "ID 토큰", + "DESCRIPTION": "ID 토큰은 사용자에 대한 정보를 포함하는 JSON 웹 토큰(JWT)입니다. ID 토큰 수명은 액세스 토큰 수명을 초과해서는 안 됩니다." + }, + "REFRESH_TOKEN": { + "TITLE": "갱신 토큰", + "DESCRIPTION": "갱신 토큰은 새로운 액세스 토큰을 얻는 데 사용되는 장기 토큰입니다. 갱신 토큰이 만료되면 사용자가 수동으로 재인증해야 합니다." + }, + "REFRESH_TOKEN_IDLE": { + "TITLE": "유휴 갱신 토큰", + "DESCRIPTION": "유휴 갱신 토큰 수명은 갱신 토큰이 사용되지 않는 최대 기간을 의미합니다." + } + }, + "MESSAGE_TEXTS": { + "TITLE": "메시지 텍스트", + "DESCRIPTION": "알림 이메일 또는 SMS 메시지의 텍스트를 사용자 정의하세요. 언어를 비활성화하려면 인스턴스의 언어 설정에서 제한하세요.", + "TYPE_DESCRIPTIONS": { + "DC": "조직의 도메인을 주장할 때, 해당 도메인을 로그인 이름에 사용하지 않는 사용자는 로그인 이름을 도메인에 맞게 변경하도록 요청받습니다.", + "INIT": "사용자가 생성되면 비밀번호를 설정할 수 있는 링크가 포함된 이메일을 받게 됩니다.", + "PC": "사용자가 비밀번호를 변경하면 알림 설정을 통해 변경 사항에 대한 알림을 받게 됩니다.", + "PL": "사용자가 비밀번호 없는 인증 방식을 추가하면 이메일의 링크를 클릭하여 활성화해야 합니다.", + "PR": "사용자가 비밀번호를 재설정할 때 새 비밀번호를 설정할 수 있는 링크가 포함된 이메일을 받습니다.", + "VE": "사용자가 이메일 주소를 변경할 때, 새 주소를 확인할 수 있는 링크가 포함된 이메일을 받습니다.", + "VP": "사용자가 전화번호를 변경할 때, 새 번호를 확인할 수 있는 코드가 포함된 SMS를 받습니다.", + "VEO": "사용자가 이메일을 통해 일회성 비밀번호를 추가할 때, 이메일 주소로 전송된 코드를 입력하여 활성화해야 합니다.", + "VSO": "사용자가 SMS를 통해 일회성 비밀번호를 추가할 때, 전화번호로 전송된 코드를 입력하여 활성화해야 합니다.", + "IU": "사용자 초대 코드가 생성되면 인증 방법을 설정할 수 있는 링크가 포함된 이메일을 받습니다." + } + }, + "LOGIN_TEXTS": { + "TITLE": "로그인 인터페이스 텍스트", + "DESCRIPTION": "로그인 양식의 텍스트를 사용자 정의하세요. 텍스트가 비어 있으면 기본 값이 표시됩니다. 언어를 비활성화하려면 인스턴스의 언어 설정에서 제한하세요." + }, + "DOMAINS": { + "TITLE": "도메인 설정", + "DESCRIPTION": "도메인에 대한 제한 사항을 정의하고 로그인 이름 패턴을 구성하세요.", + "REQUIRE_VERIFICATION": { + "TITLE": "맞춤 도메인 확인 필요", + "DESCRIPTION": "활성화된 경우, 조직 도메인은 도메인 검색이나 사용자 이름 접미사로 사용되기 전에 확인되어야 합니다." + }, + "LOGIN_NAME_PATTERN": { + "TITLE": "로그인 이름 패턴", + "DESCRIPTION": "사용자의 로그인 이름 패턴을 제어하세요. 사용자가 로그인 이름을 입력하는 즉시 ZITADEL은 사용자의 조직을 선택합니다. 따라서 로그인 이름은 모든 조직에서 고유해야 합니다. 여러 도메인에 계정이 있는 사용자의 경우, 로그인 이름에 조직 도메인을 접미사로 붙여 고유성을 보장할 수 있습니다." + }, + "DOMAIN_VERIFICATION": { + "TITLE": "도메인 확인", + "DESCRIPTION": "조직이 실제로 제어하는 도메인만 사용할 수 있도록 합니다. 활성화되면 조직 도메인은 도메인 하이재킹 방지를 위해 DNS 또는 HTTP 검증을 통해 주기적으로 확인됩니다." + }, + "SMTP_SENDER_ADDRESS": { + "TITLE": "SMTP 발신 주소", + "DESCRIPTION": "인스턴스 도메인 중 하나와 일치하는 경우에만 SMTP 발신 주소를 허용합니다." + } + }, + "LOGIN": { + "LIFETIMES": { + "TITLE": "로그인 수명", + "DESCRIPTION": "로그인 관련 최대 수명을 줄여 보안을 강화하세요.", + "LABEL": "최대 수명 (시간)", + "PW_CHECK": { + "TITLE": "비밀번호 확인", + "DESCRIPTION": "지정된 기간 후 사용자는 비밀번호로 재인증이 필요합니다." + }, + "EXT_LOGIN_CHECK": { + "TITLE": "외부 로그인 확인", + "DESCRIPTION": "지정된 기간 후 사용자는 외부 ID 제공자에게 리디렉션됩니다." + }, + "MULTI_FACTOR_INIT": { + "TITLE": "다중 인증 초기화 확인", + "DESCRIPTION": "사용자가 아직 설정하지 않은 경우, 지정된 기간 후 두 번째 인증 요소나 다중 인증을 설정하도록 안내합니다. 수명을 0으로 설정하면 이 안내가 비활성화됩니다." + }, + "SECOND_FACTOR_CHECK": { + "TITLE": "두 번째 인증 요소 확인", + "DESCRIPTION": "사용자는 지정된 기간 동안 두 번째 인증 요소를 재확인해야 합니다." + }, + "MULTI_FACTOR_CHECK": { + "TITLE": "다중 인증 확인", + "DESCRIPTION": "사용자는 지정된 기간 동안 다중 인증을 재확인해야 합니다." + } + }, + "FORM": { + "TITLE": "로그인 폼", + "DESCRIPTION": "로그인 폼을 사용자 정의하세요.", + "USERNAME_PASSWORD_ALLOWED": { + "TITLE": "사용자 이름과 비밀번호 허용", + "DESCRIPTION": "사용자가 사용자 이름과 비밀번호로 로그인할 수 있도록 허용하세요. 비활성화하면 비밀번호 없는 인증 또는 외부 ID 제공자로만 로그인할 수 있습니다." + }, + "USER_REGISTRATION_ALLOWED": { + "TITLE": "사용자 등록 허용", + "DESCRIPTION": "익명 사용자가 계정을 생성할 수 있도록 허용하세요." + }, + "ORG_REGISTRATION_ALLOWED": { + "TITLE": "조직 등록 허용", + "DESCRIPTION": "익명 사용자가 조직을 생성할 수 있도록 허용하세요." + }, + "EXTERNAL_LOGIN_ALLOWED": { + "TITLE": "외부 로그인 허용", + "DESCRIPTION": "사용자가 ZITADEL 사용자 대신 외부 ID 제공자를 통해 로그인할 수 있도록 허용하세요." + }, + "HIDE_PASSWORD_RESET": { + "TITLE": "비밀번호 재설정 숨기기", + "DESCRIPTION": "사용자가 비밀번호를 재설정할 수 없도록 허용하지 않습니다." + }, + "DOMAIN_DISCOVERY_ALLOWED": { + "TITLE": "도메인 검색 허용", + "DESCRIPTION": "예를 들어 이메일 주소와 같은 로그인 이름의 도메인에 따라 사용자의 조직을 찾습니다." + }, + "IGNORE_UNKNOWN_USERNAMES": { + "TITLE": "알 수 없는 사용자 이름 무시", + "DESCRIPTION": "활성화된 경우, 로그인 폼은 사용자 이름이 알 수 없는 경우에도 오류 메시지를 표시하지 않습니다. 이는 사용자 이름 추측을 방지하는 데 도움이 됩니다." + }, + "DISABLE_EMAIL_LOGIN": { + "TITLE": "이메일 로그인 비활성화", + "DESCRIPTION": "활성화된 경우, 사용자는 이메일 주소를 사용하여 로그인할 수 없습니다. 이 설정을 비활성화하면 모든 조직에서 사용자의 이메일 주소가 고유해야 합니다." + }, + "DISABLE_PHONE_LOGIN": { + "TITLE": "전화번호 로그인 비활성화", + "DESCRIPTION": "활성화된 경우, 사용자는 전화번호를 사용하여 로그인할 수 없습니다. 이 설정을 비활성화하면 모든 조직에서 사용자의 전화번호가 고유해야 합니다." + } + } + } + } + }, + "PAGINATOR": { + "PREVIOUS": "이전", + "NEXT": "다음", + "COUNT": "개의 결과를 찾았습니다", + "MORE": "더보기" + }, + "FOOTER": { + "LINKS": { + "CONTACT": "문의하기", + "TOS": "이용 약관", + "PP": "개인정보처리방침" + }, + "THEME": { + "DARK": "다크", + "LIGHT": "라이트" + } + }, + "HOME": { + "WELCOME": "ZITADEL 시작하기", + "DISCLAIMER": "ZITADEL은 귀하의 데이터를 기밀하고 안전하게 처리합니다.", + "DISCLAIMERLINK": "추가 정보", + "DOCUMENTATION": { + "DESCRIPTION": "ZITADEL 시작을 빠르게 진행하세요." + }, + "GETSTARTED": { + "DESCRIPTION": "ZITADEL 시작을 빠르게 진행하세요." + }, + "QUICKSTARTS": { + "LABEL": "첫 단계", + "DESCRIPTION": "ZITADEL 시작을 빠르게 진행하세요." + }, + "SHORTCUTS": { + "SHORTCUTS": "바로 가기", + "SETTINGS": "사용 가능한 바로 가기", + "PROJECTS": "프로젝트", + "REORDER": "타일을 클릭 후 드래그하여 이동하세요", + "ADD": "타일을 클릭 후 드래그하여 추가하세요" + } + }, + "ONBOARDING": { + "DESCRIPTION": "다음 단계", + "MOREDESCRIPTION": "더 많은 바로 가기", + "COMPLETED": "완료됨", + "DISMISS": "괜찮습니다. 전문가입니다.", + "CARD": { + "TITLE": "ZITADEL 설정", + "DESCRIPTION": "이 체크리스트는 인스턴스를 설정하는 데 도움이 되며, 가장 중요한 단계를 안내합니다." + }, + "MILESTONES": { + "instance.policy.label.added": { + "title": "브랜드 설정", + "description": "로그인의 색상과 모양을 정의하고 로고와 아이콘을 업로드하세요.", + "action": "브랜딩 설정" + }, + "instance.smtp.config.added": { + "title": "SMTP 설정", + "description": "자신의 메일 서버 설정을 구성하세요.", + "action": "SMTP 설정" + }, + "PROJECT_CREATED": { + "title": "프로젝트 생성", + "description": "프로젝트를 추가하고 역할과 권한을 정의하세요.", + "action": "프로젝트 생성" + }, + "APPLICATION_CREATED": { + "title": "앱 등록", + "description": "웹, 네이티브, API 또는 SAML 애플리케이션을 등록하고 인증 플로우를 설정하세요.", + "action": "앱 등록" + }, + "AUTHENTICATION_SUCCEEDED_ON_APPLICATION": { + "title": "앱에 로그인", + "description": "ZITADEL로 애플리케이션을 통합하고 관리자 사용자로 로그인하여 테스트하세요.", + "action": "로그인" + }, + "user.human.added": { + "title": "사용자 추가", + "description": "애플리케이션 사용자 추가", + "action": "사용자 추가" + }, + "user.grant.added": { + "title": "사용자 권한 부여", + "description": "사용자가 애플리케이션에 접근할 수 있도록 하고 역할을 설정하세요.", + "action": "사용자 권한 부여" + } + } + }, + "MENU": { + "INSTANCE": "기본 설정", + "DASHBOARD": "홈", + "PERSONAL_INFO": "개인 정보", + "DOCUMENTATION": "문서", + "INSTANCEOVERVIEW": "인스턴스", + "ORGS": "조직", + "VIEWS": "뷰", + "EVENTS": "이벤트", + "FAILEDEVENTS": "실패한 이벤트", + "ORGANIZATION": "조직", + "PROJECT": "프로젝트", + "PROJECTOVERVIEW": "개요", + "PROJECTGRANTS": "권한 부여", + "ROLES": "역할", + "GRANTEDPROJECT": "부여된 프로젝트", + "HUMANUSERS": "사용자", + "MACHINEUSERS": "서비스 사용자", + "LOGOUT": "모든 사용자 로그아웃", + "NEWORG": "새 조직", + "IAMADMIN": "IAM 관리자입니다. 확장된 권한이 부여되었습니다.", + "SHOWORGS": "모든 조직 보기", + "GRANTS": "권한", + "ACTIONS": "작업", + "PRIVACY": "개인정보처리방침", + "TOS": "이용 약관", + "OPENSHORTCUTSTOOLTIP": "키보드 단축키 표시하려면 ? 입력", + "SETTINGS": "설정", + "CUSTOMERPORTAL": "고객 포털" + }, + "QUICKSTART": { + "TITLE": "애플리케이션에 ZITADEL 통합", + "DESCRIPTION": "애플리케이션에 ZITADEL을 통합하거나 샘플 중 하나를 사용하여 몇 분 안에 시작하세요.", + "BTN_START": "애플리케이션 생성", + "BTN_LEARNMORE": "자세히 알아보기", + "CREATEPROJECTFORAPP": "프로젝트 생성 {{value}}", + "SELECT_FRAMEWORK": "프레임워크 선택", + "FRAMEWORK": "프레임워크", + "FRAMEWORK_OTHER": "기타 (OIDC, SAML, API)", + "ALMOSTDONE": "거의 완료되었습니다.", + "REVIEWCONFIGURATION": "구성 검토", + "REVIEWCONFIGURATION_DESCRIPTION": "{{value}} 애플리케이션에 대한 기본 구성을 생성했습니다. 생성 후 이 구성을 필요에 맞게 조정할 수 있습니다.", + "REDIRECTS": "리디렉션 구성", + "DEVMODEWARN": "개발 모드가 기본으로 활성화되어 있습니다. 운영 환경에서는 나중에 설정 값을 변경할 수 있습니다.", + "GUIDE": "가이드", + "BROWSEEXAMPLES": "예제 및 SDK 둘러보기", + "DUPLICATEAPPRENAME": "같은 이름의 앱이 이미 존재합니다. 다른 이름을 선택하세요.", + "DIALOG": { + "CHANGE": { + "TITLE": "프레임워크 변경", + "DESCRIPTION": "애플리케이션의 빠른 설정을 위해 사용할 수 있는 프레임워크 중 하나를 선택하세요." + } + } + }, + "ACTIONS": { + "ACTIONS": "작업", + "FILTER": "필터", + "RENAME": "이름 변경", + "SET": "설정", + "COPY": "클립보드에 복사", + "COPIED": "클립보드에 복사되었습니다.", + "RESET": "재설정", + "RESETDEFAULT": "기본값으로 재설정", + "RESETTO": "다음으로 재설정: ", + "RESETCURRENT": "현재 값으로 재설정", + "SHOW": "표시", + "HIDE": "숨기기", + "SAVE": "저장", + "SAVENOW": "지금 저장", + "NEW": "새로 만들기", + "ADD": "추가", + "CREATE": "생성", + "CONTINUE": "계속", + "CONTINUEWITH": "{{value}}으로 계속", + "BACK": "뒤로", + "CLOSE": "닫기", + "CLEAR": "비우기", + "CANCEL": "취소", + "INFO": "정보", + "OK": "확인", + "SELECT": "선택", + "VIEW": "보기", + "SELECTIONDELETE": "선택 항목 삭제", + "DELETE": "삭제", + "REMOVE": "제거", + "VERIFY": "확인", + "FINISH": "완료", + "FINISHED": "닫기", + "CHANGE": "변경", + "REACTIVATE": "재활성화", + "ACTIVATE": "활성화", + "DEACTIVATE": "비활성화", + "REFRESH": "새로 고침", + "LOGIN": "로그인", + "EDIT": "편집", + "PIN": "고정 / 고정 해제", + "CONFIGURE": "구성", + "SEND": "보내기", + "NEWVALUE": "새로운 값", + "RESTORE": "복원", + "CONTINUEWITHOUTSAVE": "저장하지 않고 계속", + "OF": "의", + "PREVIOUS": "이전", + "NEXT": "다음", + "MORE": "더 보기", + "STEP": "단계", + "SETUP": "설정", + "TEST": "테스트", + "UNSAVEDCHANGES": "저장되지 않은 변경 사항", + "UNSAVED": { + "DIALOG": { + "DESCRIPTION": "이 새 작업을 삭제하시겠습니까? 작업이 손실됩니다.", + "CANCEL": "취소", + "DISCARD": "삭제" + } + }, + "TABLE": { + "SHOWUSER": "사용자 {{value}} 표시" + }, + "DOWNLOAD": "다운로드", + "APPLY": "적용" + }, + "MEMBERROLES": { + "IAM_OWNER": "인스턴스와 모든 조직에 대한 제어 권한이 있습니다", + "IAM_OWNER_VIEWER": "인스턴스와 모든 조직을 검토할 수 있는 권한이 있습니다", + "IAM_ORG_MANAGER": "조직을 생성하고 관리할 수 있는 권한이 있습니다", + "IAM_USER_MANAGER": "사용자를 생성하고 관리할 수 있는 권한이 있습니다", + "IAM_ADMIN_IMPERSONATOR": "모든 조직의 관리자와 최종 사용자를 대리할 수 있는 권한이 있습니다", + "IAM_END_USER_IMPERSONATOR": "모든 조직의 최종 사용자를 대리할 수 있는 권한이 있습니다", + "ORG_OWNER": "조직에 대한 전체 권한이 있습니다", + "ORG_USER_MANAGER": "조직의 사용자를 생성하고 관리할 수 있는 권한이 있습니다", + "ORG_OWNER_VIEWER": "조직 전체를 검토할 수 있는 권한이 있습니다", + "ORG_USER_PERMISSION_EDITOR": "사용자 권한을 관리할 수 있는 권한이 있습니다", + "ORG_PROJECT_PERMISSION_EDITOR": "프로젝트 권한을 관리할 수 있는 권한이 있습니다", + "ORG_PROJECT_CREATOR": "자신의 프로젝트와 하위 설정을 생성할 수 있는 권한이 있습니다", + "ORG_ADMIN_IMPERSONATOR": "조직의 관리자 및 최종 사용자를 대리할 수 있는 권한이 있습니다", + "ORG_END_USER_IMPERSONATOR": "조직의 최종 사용자를 대리할 수 있는 권한이 있습니다", + "PROJECT_OWNER": "프로젝트에 대한 전체 권한이 있습니다", + "PROJECT_OWNER_VIEWER": "프로젝트 전체를 검토할 수 있는 권한이 있습니다", + "PROJECT_OWNER_GLOBAL": "프로젝트에 대한 전체 권한이 있습니다", + "PROJECT_OWNER_VIEWER_GLOBAL": "프로젝트 전체를 검토할 수 있는 권한이 있습니다", + "PROJECT_GRANT_OWNER": "프로젝트 권한 부여를 관리할 수 있는 권한이 있습니다", + "PROJECT_GRANT_OWNER_VIEWER": "프로젝트 권한 부여를 검토할 수 있는 권한이 있습니다" + }, + "OVERLAYS": { + "ORGSWITCHER": { + "TEXT": "콘솔의 모든 조직 설정 및 테이블은 선택된 조직을 기준으로 합니다. 이 버튼을 클릭하여 조직을 변경하거나 새 조직을 생성하세요." + }, + "INSTANCE": { + "TEXT": "기본 설정으로 이동하려면 여기를 클릭하세요. 이 버튼은 향상된 권한이 있는 경우에만 접근할 수 있습니다." + }, + "PROFILE": { + "TEXT": "여기에서 사용자 계정을 전환하고 세션 및 프로필을 관리할 수 있습니다." + }, + "NAV": { + "TEXT": "이 네비게이션은 위에 선택한 조직 또는 인스턴스에 따라 변경됩니다." + }, + "CONTEXTCHANGED": { + "TEXT": "조직 컨텍스트가 변경되었습니다." + }, + "SWITCHEDTOINSTANCE": { + "TEXT": "뷰가 인스턴스로 변경되었습니다!" + } + }, + "FILTER": { + "TITLE": "필터", + "STATE": "상태", + "DISPLAYNAME": "사용자 표시 이름", + "EMAIL": "이메일", + "USERNAME": "사용자 이름", + "ORGNAME": "조직 이름", + "PRIMARYDOMAIN": "기본 도메인", + "PROJECTNAME": "프로젝트 이름", + "RESOURCEOWNER": "리소스 소유자", + "METHODS": { + "5": "포함", + "7": "로 끝남", + "1": "와 일치" + } + }, + "KEYBOARDSHORTCUTS": { + "TITLE": "키보드 단축키", + "UNDERORGCONTEXT": "조직 페이지 내", + "SIDEWIDE": "사이트 전체 단축키", + "SHORTCUTS": { + "HOME": "홈으로 이동 (GH)", + "INSTANCE": "인스턴스로 이동 (GI)", + "ORG": "조직으로 이동 (GO)", + "ORGSETTINGS": "조직 설정으로 이동 (GS)", + "ORGSWITCHER": "조직 변경", + "ME": "내 프로필로 이동", + "PROJECTS": "프로젝트로 이동 (GP)", + "USERS": "사용자로 이동 (GU)", + "USERGRANTS": "인증으로 이동 (GA)", + "ACTIONS": "액션 플로우로 이동 (GF)", + "DOMAINS": "도메인으로 이동 (GD)" + } + }, + "RESOURCEID": "리소스 ID", + "NAME": "Name", + "VERSION": "버전", + "TABLE": { + "NOROWS": "데이터가 없습니다" + }, + "ERRORS": { + "REQUIRED": "이 필드를 입력하세요.", + "ATLEASTONE": "하나 이상의 값을 제공하세요.", + "TOKENINVALID": { + "TITLE": "인증 토큰이 만료되었습니다.", + "DESCRIPTION": "다시 로그인하려면 아래 버튼을 클릭하세요." + }, + "EXHAUSTED": { + "TITLE": "인스턴스가 차단되었습니다.", + "DESCRIPTION": "ZITADEL 인스턴스 관리자에게 구독을 업데이트하도록 요청하세요." + }, + "INVALID_FORMAT": "형식이 유효하지 않습니다.", + "NOTANEMAIL": "입력된 값이 이메일 주소가 아닙니다.", + "MINLENGTH": "{{requiredLength}}자 이상이어야 합니다.", + "MAXLENGTH": "{{requiredLength}}자 이하이어야 합니다.", + "UPPERCASEMISSING": "대문자가 포함되어야 합니다.", + "LOWERCASEMISSING": "소문자가 포함되어야 합니다.", + "SYMBOLERROR": "기호나 구두점이 포함되어야 합니다.", + "NUMBERERROR": "숫자가 포함되어야 합니다.", + "PWNOTEQUAL": "입력된 비밀번호가 일치하지 않습니다.", + "PHONE": "전화번호는 +로 시작해야 합니다." + }, + "USER": { + "SETTINGS": { + "TITLE": "설정", + "GENERAL": "일반", + "IDP": "ID 제공자", + "SECURITY": "비밀번호 및 보안", + "KEYS": "키", + "PAT": "개인 접근 토큰", + "USERGRANTS": "권한 부여", + "MEMBERSHIPS": "멤버십", + "METADATA": "메타데이터" + }, + "TITLE": "개인 정보", + "DESCRIPTION": "정보와 보안 설정을 관리하세요.", + "PAGES": { + "TITLE": "사용자", + "DETAIL": "세부 정보", + "CREATE": "생성", + "MY": "내 정보", + "LOGINNAMES": "로그인 이름", + "LOGINMETHODS": "로그인 방법", + "LOGINNAMESDESC": "다음은 사용자의 로그인 이름입니다:", + "NOUSER": "연관된 사용자가 없습니다.", + "REACTIVATE": "재활성화", + "DEACTIVATE": "비활성화", + "FILTER": "필터", + "STATE": "상태", + "DELETE": "사용자 삭제", + "UNLOCK": "사용자 잠금 해제", + "GENERATESECRET": "클라이언트 시크릿 생성", + "REMOVESECRET": "클라이언트 시크릿 삭제", + "LOCKEDDESCRIPTION": "로그인 시도 횟수가 초과되어 사용자가 잠금되었습니다. 사용하려면 잠금을 해제해야 합니다.", + "DELETEACCOUNT": "계정 삭제", + "DELETEACCOUNT_DESC": "이 작업을 수행하면 로그아웃되며 계정에 다시 접근할 수 없습니다. 이 작업은 되돌릴 수 없으므로 신중히 진행하세요.", + "DELETEACCOUNT_BTN": "계정 삭제", + "DELETEACCOUNT_SUCCESS": "계정이 성공적으로 삭제되었습니다!" + }, + "DETAILS": { + "DATECREATED": "생성일", + "DATECHANGED": "수정일" + }, + "DIALOG": { + "DELETE_TITLE": "사용자 삭제", + "DELETE_SELF_TITLE": "계정 삭제", + "DELETE_DESCRIPTION": "사용자를 영구 삭제하려고 합니다. 정말로 진행하시겠습니까?", + "DELETE_SELF_DESCRIPTION": "개인 계정을 영구 삭제하려고 합니다. 이 작업은 사용자를 로그아웃하고 계정을 삭제합니다. 이 작업은 되돌릴 수 없습니다!", + "DELETE_AUTH_DESCRIPTION": "개인 계정을 영구 삭제하려고 합니다. 정말로 진행하시겠습니까?", + "TYPEUSERNAME": "'{{value}}'을(를) 입력하여 사용자를 삭제하세요.", + "USERNAME": "로그인 이름", + "DELETE_BTN": "영구 삭제" + }, + "SENDEMAILDIALOG": { + "TITLE": "이메일 알림 보내기", + "DESCRIPTION": "현재 이메일 주소로 알림을 보내려면 아래 버튼을 클릭하거나 필드에서 이메일 주소를 변경하세요.", + "NEWEMAIL": "새 이메일 주소" + }, + "SECRETDIALOG": { + "CLIENTSECRET": "클라이언트 시크릿", + "CLIENTSECRET_DESCRIPTION": "클라이언트 시크릿은 안전한 장소에 보관하세요. 이 대화 상자를 닫으면 다시 볼 수 없습니다." + }, + "TABLE": { + "DEACTIVATE": "비활성화", + "ACTIVATE": "활성화", + "CHANGEDATE": "마지막 수정", + "CREATIONDATE": "생성일", + "FILTER": { + "0": "표시 이름으로 필터링", + "1": "사용자 이름으로 필터링", + "2": "표시 이름으로 필터링", + "3": "사용자 이름으로 필터링", + "4": "이메일로 필터링", + "5": "표시 이름으로 필터링", + "10": "조직 이름으로 필터링", + "12": "프로젝트 이름으로 필터링" + }, + "EMPTY": "항목 없음" + }, + "PASSWORDLESS": { + "SEND": "등록 링크 보내기", + "TABLETYPE": "유형", + "TABLESTATE": "상태", + "NAME": "이름", + "EMPTY": "설정된 장치 없음", + "TITLE": "비밀번호 없는 인증", + "DESCRIPTION": "ZITADEL에 비밀번호 없이 로그인할 수 있도록 WebAuthn 기반 인증 방법을 추가하세요.", + "MANAGE_DESCRIPTION": "사용자의 두 번째 인증 요소를 관리하세요.", + "U2F": "방법 추가", + "U2F_DIALOG_TITLE": "인증기 확인", + "U2F_DIALOG_DESCRIPTION": "비밀번호 없는 로그인에 사용할 이름을 입력하세요.", + "U2F_SUCCESS": "비밀번호 없는 인증이 성공적으로 생성되었습니다!", + "U2F_ERROR": "설정 중 오류가 발생했습니다!", + "U2F_NAME": "인증기 이름", + "TYPE": { + "0": "다중 인증 미정의", + "1": "일회용 비밀번호 (OTP)", + "2": "지문, 보안 키, Face ID 및 기타" + }, + "STATE": { + "0": "상태 없음", + "1": "준비되지 않음", + "2": "준비됨", + "3": "삭제됨" + }, + "DIALOG": { + "DELETE_TITLE": "비밀번호 없는 인증 방법 제거", + "DELETE_DESCRIPTION": "비밀번호 없는 인증 방법을 삭제하려고 합니다. 진행하시겠습니까?", + "ADD_TITLE": "비밀번호 없는 인증", + "ADD_DESCRIPTION": "비밀번호 없는 인증 방법을 만들기 위해 사용할 수 있는 옵션 중 하나를 선택하세요.", + "SEND_DESCRIPTION": "이메일 주소로 등록 링크를 보내세요.", + "SEND": "등록 링크 보내기", + "SENT": "이메일이 성공적으로 발송되었습니다. 메일함을 확인하여 설정을 계속 진행하세요.", + "QRCODE_DESCRIPTION": "다른 장치로 스캔할 QR 코드를 생성하세요.", + "QRCODE": "QR 코드 생성", + "QRCODE_SCAN": "설정을 계속하려면 이 QR 코드를 스캔하세요.", + "NEW_DESCRIPTION": "이 장치를 사용하여 비밀번호 없는 인증을 설정하세요.", + "NEW": "새로 추가" + } + }, + "MFA": { + "TABLETYPE": "유형", + "TABLESTATE": "상태", + "NAME": "이름", + "EMPTY": "추가 인증 요소 없음", + "TITLE": "다중 인증", + "DESCRIPTION": "계정의 보안을 위해 두 번째 인증 요소를 추가하세요.", + "MANAGE_DESCRIPTION": "사용자의 두 번째 인증 방법을 관리하세요.", + "ADD": "인증 요소 추가", + "OTP": "TOTP (시간 기반 일회용 비밀번호)용 인증 앱", + "OTP_DIALOG_TITLE": "OTP 추가", + "OTP_DIALOG_DESCRIPTION": "인증 앱으로 QR 코드를 스캔하고, 아래에 코드를 입력하여 OTP 방법을 검증하고 활성화하세요.", + "U2F": "지문, 보안 키, Face ID 및 기타", + "U2F_DIALOG_TITLE": "인증 요소 확인", + "U2F_DIALOG_DESCRIPTION": "사용할 다중 인증의 이름을 입력하세요.", + "U2F_SUCCESS": "인증 요소가 성공적으로 추가되었습니다!", + "U2F_ERROR": "설정 중 오류가 발생했습니다!", + "U2F_NAME": "인증기 이름", + "OTPSMS": "SMS를 통한 OTP (일회용 비밀번호)", + "OTPEMAIL": "이메일을 통한 OTP (일회용 비밀번호)", + "SETUPOTPSMSDESCRIPTION": "이 전화번호를 OTP (일회용 비밀번호) 두 번째 인증 요소로 설정하시겠습니까?", + "OTPSMSSUCCESS": "OTP 인증 요소가 성공적으로 설정되었습니다.", + "OTPSMSPHONEMUSTBEVERIFIED": "이 방법을 사용하려면 전화번호를 확인해야 합니다.", + "OTPEMAILSUCCESS": "OTP 인증 요소가 성공적으로 설정되었습니다.", + "TYPE": { + "0": "다중 인증 미정의", + "1": "일회용 비밀번호 (OTP)", + "2": "지문, 보안 키, Face ID 및 기타" + }, + "STATE": { + "0": "상태 없음", + "1": "준비되지 않음", + "2": "준비됨", + "3": "삭제됨" + }, + "DIALOG": { + "MFA_DELETE_TITLE": "두 번째 인증 요소 제거", + "MFA_DELETE_DESCRIPTION": "두 번째 인증 요소를 삭제하려고 합니다. 정말로 진행하시겠습니까?", + "ADD_MFA_TITLE": "두 번째 인증 요소 추가", + "ADD_MFA_DESCRIPTION": "다음 옵션 중 하나를 선택하세요." + } + }, + "EXTERNALIDP": { + "TITLE": "외부 ID 제공자", + "DESC": "", + "IDPCONFIGID": "ID 제공자 구성 ID", + "IDPNAME": "ID 제공자 이름", + "USERDISPLAYNAME": "외부 이름", + "EXTERNALUSERID": "외부 사용자 ID", + "EMPTY": "외부 ID 제공자를 찾을 수 없습니다", + "DIALOG": { + "DELETE_TITLE": "ID 제공자 제거", + "DELETE_DESCRIPTION": "사용자로부터 ID 제공자를 삭제하려고 합니다. 계속하시겠습니까?" + } + }, + "CREATE": { + "TITLE": "새 사용자 생성", + "DESCRIPTION": "필요한 정보를 입력하세요.", + "NAMEANDEMAILSECTION": "이름과 이메일", + "GENDERLANGSECTION": "성별과 언어", + "PHONESECTION": "전화번호", + "PASSWORDSECTION": "초기 비밀번호", + "ADDRESSANDPHONESECTION": "전화번호", + "INITMAILDESCRIPTION": "두 옵션이 모두 선택된 경우 초기화 이메일이 전송되지 않습니다. 하나의 옵션만 선택된 경우 데이터 제공/확인을 위한 이메일이 전송됩니다." + }, + "CODEDIALOG": { + "TITLE": "전화번호 확인", + "DESCRIPTION": "전화번호를 확인하려면 문자 메시지로 받은 코드를 입력하세요.", + "CODE": "코드" + }, + "DATA": { + "STATE": "상태", + "STATE0": "알 수 없음", + "STATE1": "활성", + "STATE2": "비활성", + "STATE3": "삭제됨", + "STATE4": "잠김", + "STATE5": "일시 중단됨", + "STATE6": "초기" + }, + "PROFILE": { + "TITLE": "프로필", + "EMAIL": "이메일", + "PHONE": "전화번호", + "PHONE_HINT": "+ 기호 다음에 국가 코드를 입력하거나 드롭다운에서 국가를 선택한 후 전화번호를 입력하세요.", + "USERNAME": "사용자 이름", + "CHANGEUSERNAME": "수정", + "CHANGEUSERNAME_TITLE": "사용자 이름 변경", + "CHANGEUSERNAME_DESC": "아래 필드에 새 이름을 입력하세요.", + "FIRSTNAME": "이름", + "LASTNAME": "성", + "NICKNAME": "별명", + "DISPLAYNAME": "표시 이름", + "PREFERREDLOGINNAME": "선호 로그인 이름", + "PREFERRED_LANGUAGE": "언어", + "GENDER": "성별", + "PASSWORD": "비밀번호", + "AVATAR": { + "UPLOADTITLE": "프로필 사진 업로드", + "UPLOADBTN": "파일 선택", + "UPLOAD": "업로드", + "CURRENT": "현재 사진", + "PREVIEW": "미리보기", + "DELETESUCCESS": "성공적으로 삭제되었습니다!", + "CROPPERERROR": "파일 업로드 중 오류가 발생했습니다. 필요 시 다른 형식과 크기를 시도하세요." + }, + "COUNTRY": "국가" + }, + "MACHINE": { + "TITLE": "서비스 사용자 세부 정보", + "USERNAME": "사용자 이름", + "NAME": "이름", + "DESCRIPTION": "설명", + "KEYSTITLE": "키", + "KEYSDESC": "키를 정의하고 만료 날짜를 선택적으로 추가하세요.", + "TOKENSTITLE": "개인 접근 토큰", + "TOKENSDESC": "개인 접근 토큰은 일반적인 OAuth 접근 토큰과 유사하게 작동합니다.", + "ID": "키 ID", + "TYPE": "유형", + "EXPIRATIONDATE": "만료 날짜", + "CHOOSEDATEAFTER": "유효한 만료 날짜를 선택하세요", + "CHOOSEEXPIRY": "만료 날짜 선택", + "CREATIONDATE": "생성일", + "KEYDETAILS": "키 세부 정보", + "ACCESSTOKENTYPE": "접근 토큰 유형", + "ACCESSTOKENTYPES": { + "0": "Bearer", + "1": "JWT" + }, + "ADD": { + "TITLE": "키 추가", + "DESCRIPTION": "키 유형을 선택하고 만료 날짜를 선택하세요." + }, + "ADDED": { + "TITLE": "키가 생성되었습니다", + "DESCRIPTION": "키를 다운로드하세요. 이 대화 상자를 닫으면 다시 볼 수 없습니다!" + }, + "KEYTYPES": { + "1": "JSON" + }, + "DIALOG": { + "DELETE_KEY": { + "TITLE": "키 삭제", + "DESCRIPTION": "선택한 키를 삭제하시겠습니까? 이 작업은 되돌릴 수 없습니다." + } + } + }, + "PASSWORD": { + "TITLE": "비밀번호", + "LABEL": "안전한 비밀번호는 계정 보호에 도움이 됩니다", + "DESCRIPTION": "아래 정책에 따라 새 비밀번호를 입력하세요.", + "OLD": "현재 비밀번호", + "NEW": "새 비밀번호", + "CONFIRM": "새 비밀번호 확인", + "NEWINITIAL": "비밀번호", + "CONFIRMINITIAL": "비밀번호 확인", + "RESET": "현재 비밀번호 재설정", + "SET": "새 비밀번호 설정", + "RESENDNOTIFICATION": "비밀번호 재설정 링크 보내기", + "REQUIRED": "필수 필드가 누락되었습니다.", + "MINLENGTHERROR": "{{value}}자 이상이어야 합니다.", + "MAXLENGTHERROR": "{{value}}자 이하이어야 합니다." + }, + "ID": "ID", + "EMAIL": "이메일", + "PHONE": "전화번호", + "PHONEEMPTY": "정의된 전화번호가 없습니다", + "PHONEVERIFIED": "전화번호가 확인되었습니다.", + "EMAILVERIFIED": "이메일이 확인되었습니다", + "NOTVERIFIED": "확인되지 않음", + "PREFERRED_LOGINNAME": "선호 로그인 이름", + "ISINITIAL": "사용자가 아직 활성화되지 않았습니다.", + "LOGINMETHODS": { + "TITLE": "연락처 정보", + "DESCRIPTION": "제공된 정보는 비밀번호 재설정 이메일 등의 중요한 정보를 전송하는 데 사용됩니다.", + "EMAIL": { + "TITLE": "이메일", + "VALID": "확인됨", + "ISVERIFIED": "이메일 확인됨", + "ISVERIFIEDDESC": "이메일이 확인된 것으로 표시되면 이메일 확인 요청이 보내지지 않습니다.", + "RESEND": "이메일 확인 요청 다시 보내기", + "EDITTITLE": "이메일 변경", + "EDITDESC": "새 이메일을 아래 필드에 입력하세요." + }, + "PHONE": { + "TITLE": "전화", + "VALID": "확인됨", + "RESEND": "인증 문자 다시 보내기", + "EDITTITLE": "번호 변경", + "EDITVALUE": "전화번호", + "EDITDESC": "새 전화번호를 아래 필드에 입력하세요.", + "DELETETITLE": "전화번호 삭제", + "DELETEDESC": "전화번호를 정말로 삭제하시겠습니까?", + "OTPSMSREMOVALWARNING": "이 계정은 이 전화번호를 두 번째 인증 요소로 사용 중입니다. 삭제 후에는 사용할 수 없습니다." + }, + "RESENDCODE": "코드 다시 보내기", + "ENTERCODE": "확인", + "ENTERCODE_DESC": "코드 확인" + }, + "GRANTS": { + "TITLE": "사용자 권한", + "DESCRIPTION": "특정 프로젝트에 대한 이 사용자의 접근을 부여하세요", + "CREATE": { + "TITLE": "사용자 권한 생성", + "DESCRIPTION": "조직, 프로젝트 및 관련 프로젝트 역할을 검색하세요." + }, + "PROJECTNAME": "프로젝트 이름", + "PROJECT-OWNED": "프로젝트", + "PROJECT-GRANTED": "부여된 프로젝트", + "FILTER": { + "0": "사용자로 필터링", + "1": "도메인으로 필터링", + "2": "프로젝트 이름으로 필터링", + "3": "역할 이름으로 필터링" + } + }, + "STATE": { + "0": "알 수 없음", + "1": "활성", + "2": "비활성", + "3": "삭제됨", + "4": "잠김", + "5": "일시 중단됨", + "6": "초기" + }, + "SEARCH": { + "ADDITIONAL": "로그인 이름 (현재 조직)", + "ADDITIONAL-EXTERNAL": "로그인 이름 (외부 조직)" + }, + "TARGET": { + "SELF": "다른 조직의 사용자에게 권한을 부여하려면", + "EXTERNAL": "조직의 사용자에게 권한을 부여하려면", + "CLICKHERE": "여기를 클릭하세요" + }, + "SIGNEDOUT": "로그아웃되었습니다. 다시 로그인하려면 '로그인' 버튼을 클릭하세요.", + "SIGNEDOUT_BTN": "로그인", + "EDITACCOUNT": "계정 편집", + "ADDACCOUNT": "다른 계정으로 로그인", + "RESENDINITIALEMAIL": "활성화 이메일 다시 보내기", + "RESENDEMAILNOTIFICATION": "이메일 알림 다시 보내기", + "TOAST": { + "CREATED": "사용자가 성공적으로 생성되었습니다.", + "SAVED": "프로필이 성공적으로 저장되었습니다.", + "USERNAMECHANGED": "사용자 이름이 변경되었습니다.", + "EMAILSAVED": "이메일이 성공적으로 저장되었습니다.", + "INITEMAILSENT": "초기화 이메일이 전송되었습니다.", + "PHONESAVED": "전화번호가 성공적으로 저장되었습니다.", + "PHONEREMOVED": "전화번호가 제거되었습니다.", + "PHONEVERIFIED": "전화번호가 성공적으로 확인되었습니다.", + "PHONEVERIFICATIONSENT": "전화번호 확인 코드가 전송되었습니다.", + "EMAILVERIFICATIONSENT": "이메일 확인 코드가 전송되었습니다.", + "OTPREMOVED": "OTP가 제거되었습니다.", + "U2FREMOVED": "인증 요소가 제거되었습니다.", + "PASSWORDLESSREMOVED": "비밀번호 없는 인증이 제거되었습니다.", + "INITIALPASSWORDSET": "초기 비밀번호가 설정되었습니다.", + "PASSWORDNOTIFICATIONSENT": "비밀번호 변경 알림이 전송되었습니다.", + "PASSWORDCHANGED": "비밀번호가 성공적으로 변경되었습니다.", + "REACTIVATED": "사용자가 재활성화되었습니다.", + "DEACTIVATED": "사용자가 비활성화되었습니다.", + "SELECTEDREACTIVATED": "선택된 사용자가 재활성화되었습니다.", + "SELECTEDDEACTIVATED": "선택된 사용자가 비활성화되었습니다.", + "SELECTEDKEYSDELETED": "선택된 키가 삭제되었습니다.", + "KEYADDED": "키가 추가되었습니다!", + "MACHINEADDED": "서비스 사용자가 생성되었습니다!", + "DELETED": "사용자가 성공적으로 삭제되었습니다!", + "UNLOCKED": "사용자가 성공적으로 잠금 해제되었습니다!", + "PASSWORDLESSREGISTRATIONSENT": "등록 링크가 성공적으로 전송되었습니다.", + "SECRETGENERATED": "시크릿이 성공적으로 생성되었습니다!", + "SECRETREMOVED": "시크릿이 성공적으로 제거되었습니다!" + }, + "MEMBERSHIPS": { + "TITLE": "ZITADEL 관리자 역할", + "DESCRIPTION": "이 사용자의 모든 멤버 권한입니다. 조직, 프로젝트 또는 IAM 세부 페이지에서 수정할 수도 있습니다.", + "ORGCONTEXT": "현재 선택된 조직과 관련된 모든 조직과 프로젝트를 볼 수 있습니다.", + "USERCONTEXT": "사용 권한이 있는 모든 조직과 프로젝트를 볼 수 있습니다. 다른 조직도 포함됩니다.", + "CREATIONDATE": "생성 날짜", + "CHANGEDATE": "마지막 수정", + "DISPLAYNAME": "표시 이름", + "REMOVE": "제거", + "TYPE": "유형", + "ORGID": "조직 ID", + "UPDATED": "멤버십이 업데이트되었습니다.", + "NOPERMISSIONTOEDIT": "역할을 편집할 권한이 없습니다!", + "TYPES": { + "UNKNOWN": "알 수 없음", + "ORG": "조직", + "PROJECT": "프로젝트", + "GRANTEDPROJECT": "부여된 프로젝트" + } + }, + "PERSONALACCESSTOKEN": { + "ID": "ID", + "TOKEN": "토큰", + "ADD": { + "TITLE": "새로운 개인 액세스 토큰 생성", + "DESCRIPTION": "토큰의 사용자 정의 만료일을 설정하세요.", + "CHOOSEEXPIRY": "만료 날짜 선택", + "CHOOSEDATEAFTER": "유효한 만료일 입력" + }, + "ADDED": { + "TITLE": "개인 액세스 토큰", + "DESCRIPTION": "개인 액세스 토큰을 복사하세요. 다시 볼 수 없습니다!" + }, + "DELETE": { + "TITLE": "토큰 삭제", + "DESCRIPTION": "개인 액세스 토큰을 삭제하려고 합니다. 확실합니까?" + }, + "DELETED": "토큰이 성공적으로 삭제되었습니다." + } + }, + "METADATA": { + "TITLE": "메타데이터", + "KEY": "키", + "VALUE": "값", + "ADD": "새 항목", + "SAVE": "저장", + "EMPTY": "메타데이터 없음", + "SETSUCCESS": "항목이 성공적으로 저장되었습니다", + "REMOVESUCCESS": "항목이 성공적으로 삭제되었습니다" + }, + "FLOWS": { + "ID": "ID", + "NAME": "이름", + "STATE": "상태", + "STATES": { + "0": "상태 없음", + "1": "비활성", + "2": "활성" + }, + "ADDTRIGGER": "트리거 추가", + "FLOWCHANGED": "플로우가 성공적으로 변경되었습니다", + "FLOWCLEARED": "플로우가 성공적으로 초기화되었습니다", + "TIMEOUT": "시간 초과", + "TIMEOUTINSEC": "초 단위 시간 초과", + "ALLOWEDTOFAIL": "실패 허용", + "ALLOWEDTOFAILWARN": { + "TITLE": "경고", + "DESCRIPTION": "이 설정을 비활성화하면 조직 내 사용자가 로그인할 수 없게 될 수 있습니다. 또한, 콘솔에 다시 접근하여 작업을 비활성화할 수 없게 됩니다. 별도의 조직에서 관리자 계정을 만들거나 개발 환경 또는 개발 조직에서 스크립트를 먼저 테스트할 것을 권장합니다." + }, + "SCRIPT": "스크립트", + "FLOWTYPE": "플로우 유형", + "TRIGGERTYPE": "트리거 유형", + "ACTIONS": "작업", + "ACTIONSMAX": "사용 가능한 작업의 수는 티어에 따라 제한됩니다 ({{value}}). 필요하지 않은 작업은 비활성화하거나 티어 업그레이드를 고려하세요.", + "DIALOG": { + "ADD": { + "TITLE": "작업 생성" + }, + "UPDATE": { + "TITLE": "작업 업데이트" + }, + "DELETEACTION": { + "TITLE": "작업 삭제?", + "DESCRIPTION": "작업을 삭제하려고 합니다. 이 작업은 되돌릴 수 없습니다. 확실합니까?", + "DELETE_SUCCESS": "작업이 성공적으로 삭제되었습니다." + }, + "CLEAR": { + "TITLE": "플로우 초기화?", + "DESCRIPTION": "트리거 및 작업과 함께 플로우를 초기화하려고 합니다. 이 변경 사항은 복구할 수 없습니다. 확실합니까?" + }, + "REMOVEACTIONSLIST": { + "TITLE": "선택한 작업 삭제?", + "DESCRIPTION": "선택한 작업을 플로우에서 삭제하시겠습니까?" + }, + "ABOUTNAME": "작업 이름과 자바스크립트 함수 이름은 동일해야 합니다" + }, + "TOAST": { + "ACTIONSSET": "작업이 설정되었습니다", + "ACTIONREACTIVATED": "작업이 성공적으로 재활성화되었습니다", + "ACTIONDEACTIVATED": "작업이 성공적으로 비활성화되었습니다" + } + }, + "IAM": { + "POLICIES": { + "TITLE": "시스템 정책 및 액세스 설정", + "DESCRIPTION": "전역 정책 및 관리 액세스 설정을 관리합니다." + }, + "EVENTSTORE": { + "TITLE": "IAM 스토리지 관리", + "DESCRIPTION": "ZITADEL 뷰 및 실패한 이벤트를 관리합니다." + }, + "MEMBER": { + "TITLE": "관리자", + "DESCRIPTION": "이 관리자들은 인스턴스 내에서 변경할 권한이 있습니다." + }, + "PAGES": { + "STATE": "상태", + "DOMAINLIST": "사용자 정의 도메인" + }, + "STATE": { + "0": "미지정", + "1": "생성 중", + "2": "실행 중", + "3": "중지 중", + "4": "중지됨" + }, + "VIEWS": { + "VIEWNAME": "이름", + "DATABASE": "데이터베이스", + "SEQUENCE": "순서", + "EVENTTIMESTAMP": "타임스탬프", + "LASTSPOOL": "성공적 스풀", + "ACTIONS": "작업", + "CLEAR": "지우기", + "CLEARED": "뷰가 성공적으로 지워졌습니다!", + "DIALOG": { + "VIEW_CLEAR_TITLE": "뷰 지우기", + "VIEW_CLEAR_DESCRIPTION": "뷰를 지우려 하고 있습니다. 뷰를 지우면 데이터가 최종 사용자에게 사용 불가한 상태가 될 수 있습니다. 정말 확실합니까?" + } + }, + "FAILEDEVENTS": { + "VIEWNAME": "이름", + "DATABASE": "데이터베이스", + "FAILEDSEQUENCE": "실패한 순서", + "FAILURECOUNT": "실패 횟수", + "LASTFAILED": "마지막 실패 시점", + "ERRORMESSAGE": "오류 메시지", + "ACTIONS": "작업", + "DELETE": "삭제", + "DELETESUCCESS": "실패한 이벤트가 삭제되었습니다." + }, + "EVENTS": { + "EDITOR": "편집자", + "EDITORID": "편집자 ID", + "AGGREGATE": "집합", + "AGGREGATEID": "집합 ID", + "AGGREGATETYPE": "집합 유형", + "RESOURCEOWNER": "리소스 소유자", + "SEQUENCE": "순서", + "CREATIONDATE": "생성일", + "TYPE": "유형", + "PAYLOAD": "페이로드", + "FILTERS": { + "BTN": "필터", + "USER": { + "IDLABEL": "ID", + "CHECKBOX": "편집자로 필터" + }, + "AGGREGATE": { + "TYPELABEL": "집합 유형", + "IDLABEL": "ID", + "CHECKBOX": "집합으로 필터" + }, + "TYPE": { + "TYPELABEL": "유형", + "CHECKBOX": "유형으로 필터" + }, + "RESOURCEOWNER": { + "LABEL": "ID", + "CHECKBOX": "리소스 소유자로 필터" + }, + "SEQUENCE": { + "LABEL": "순서", + "CHECKBOX": "순서로 필터" + }, + "SORT": "정렬", + "ASC": "오름차순", + "DESC": "내림차순", + "CREATIONDATE": { + "RADIO_FROM": "부터", + "RADIO_RANGE": "범위", + "LABEL_SINCE": "이후", + "LABEL_UNTIL": "까지" + }, + "OTHER": "기타", + "OTHERS": "기타들" + }, + "DIALOG": { + "TITLE": "이벤트 상세 정보" + } + }, + "TOAST": { + "MEMBERREMOVED": "관리자가 제거되었습니다.", + "MEMBERSADDED": "관리자가 추가되었습니다.", + "MEMBERADDED": "관리자가 추가되었습니다.", + "MEMBERCHANGED": "관리자가 변경되었습니다.", + "ROLEREMOVED": "역할이 제거되었습니다.", + "ROLECHANGED": "역할이 변경되었습니다.", + "REACTIVATED": "재활성화됨", + "DEACTIVATED": "비활성화됨" + } + }, + "ORG": { + "PAGES": { + "NAME": "이름", + "ID": "ID", + "CREATIONDATE": "생성일", + "DATECHANGED": "변경일", + "FILTER": "필터", + "FILTERPLACEHOLDER": "이름으로 필터링", + "LIST": "조직", + "LISTDESCRIPTION": "조직을 선택하세요.", + "ACTIVE": "활성", + "CREATE": "조직 생성", + "DEACTIVATE": "조직 비활성화", + "REACTIVATE": "조직 재활성화", + "NOPERMISSION": "조직 설정에 접근할 권한이 없습니다.", + "USERSELFACCOUNT": "개인 계정을 조직 소유자로 사용", + "ORGDETAIL_TITLE": "새 조직의 이름과 도메인을 입력하세요.", + "ORGDETAIL_TITLE_WITHOUT_DOMAIN": "새 조직의 이름을 입력하세요.", + "ORGDETAILUSER_TITLE": "조직 소유자 설정", + "DELETE": "조직 삭제", + "DEFAULTLABEL": "기본", + "SETASDEFAULT": "기본 조직으로 설정", + "DEFAULTORGSET": "기본 조직이 성공적으로 변경되었습니다", + "RENAME": { + "ACTION": "이름 변경", + "TITLE": "조직 이름 변경", + "DESCRIPTION": "새 조직 이름을 입력하세요", + "BTN": "이름 변경" + }, + "ORGDOMAIN": { + "TITLE": "{{value}} 소유권 확인", + "VERIFICATION": "도메인을 수동으로 검증할 수 있는 두 가지 방법을 제공합니다:", + "VERIFICATION_HTML": "- HTTP. 웹사이트에 임시 검증 파일을 호스팅하세요", + "VERIFICATION_DNS": "- DNS. TXT 레코드 DNS 항목을 생성하세요", + "VERIFICATION_DNS_DESC": "{{value}}을 관리하고 DNS 기록에 접근할 수 있다면, 다음 값을 사용하여 새 TXT 레코드를 생성할 수 있습니다:", + "VERIFICATION_DNS_HOST_LABEL": "호스트:", + "VERIFICATION_DNS_CHALLENGE_LABEL": "TXT 레코드 값으로 이 코드를 사용하세요:", + "VERIFICATION_HTTP_DESC": "웹사이트 호스팅에 접근할 수 있다면, 검증 파일을 다운로드하고 제공된 URL에 업로드하세요", + "VERIFICATION_HTTP_URL_LABEL": "예상 URL:", + "VERIFICATION_HTTP_FILE_LABEL": "검증 파일:", + "VERIFICATION_SKIP": "지금은 검증을 건너뛰고 조직 생성을 계속할 수 있지만, 도메인을 사용하려면 이 단계를 완료해야 합니다!", + "VERIFICATION_VALIDATION_DESC": "검증 코드를 삭제하지 마세요. ZITADEL은 주기적으로 도메인 소유권을 다시 확인할 것입니다.", + "VERIFICATION_NEWTOKEN_TITLE": "새 토큰 요청", + "VERIFICATION_VALIDATION_ONGOING": "{{value}} 방법이 도메인 검증을 위해 선택되었습니다. 검증 검사를 실행하거나 검증 프로세스를 재설정하려면 버튼을 클릭하세요.", + "VERIFICATION_SUCCESSFUL": "도메인이 성공적으로 검증되었습니다!", + "RESETMETHOD": "검증 방법 재설정" + }, + "DOWNLOAD_FILE": "파일 다운로드", + "SELECTORGTOOLTIP": "이 조직을 선택하세요.", + "PRIMARYDOMAIN": "기본 도메인", + "STATE": "상태", + "USEPASSWORD": "초기 비밀번호 설정", + "USEPASSWORDDESC": "초기화 중에 사용자가 비밀번호를 설정할 필요가 없습니다." + }, + "LIST": { + "TITLE": "조직", + "DESCRIPTION": "이 인스턴스의 조직들" + }, + "DOMAINS": { + "NEW": "도메인 추가", + "TITLE": "검증된 도메인", + "DESCRIPTION": "조직 도메인을 구성하세요. 이 도메인은 도메인 검색 및 사용자 이름 접미사에 사용할 수 있습니다.", + "SETPRIMARY": "기본으로 설정", + "DELETE": { + "TITLE": "도메인 삭제", + "DESCRIPTION": "도메인 중 하나를 삭제하려고 합니다." + }, + "ADD": { + "TITLE": "도메인 추가", + "DESCRIPTION": "조직에 도메인을 추가하려고 합니다. 프로세스가 성공하면, 이 도메인은 도메인 검색 및 사용자 접미사로 사용할 수 있습니다." + } + }, + "STATE": { + "0": "미정", + "1": "활성", + "2": "비활성화됨" + }, + "MEMBER": { + "TITLE": "조직 관리자", + "DESCRIPTION": "조직 설정을 변경할 수 있는 사용자를 정의하세요." + }, + "TOAST": { + "UPDATED": "조직이 성공적으로 업데이트되었습니다.", + "DEACTIVATED": "조직이 비활성화되었습니다.", + "REACTIVATED": "조직이 재활성화되었습니다.", + "DOMAINADDED": "도메인이 추가되었습니다.", + "DOMAINREMOVED": "도메인이 삭제되었습니다.", + "MEMBERADDED": "관리자가 추가되었습니다.", + "MEMBERREMOVED": "관리자가 제거되었습니다.", + "MEMBERCHANGED": "관리자가 변경되었습니다.", + "SETPRIMARY": "기본 도메인이 설정되었습니다.", + "DELETED": "조직이 성공적으로 삭제되었습니다", + "DEFAULTORGNOTFOUND": "기본 조직을 찾을 수 없습니다", + "ORG_WAS_DELETED": "조직이 삭제되었습니다." + }, + "DIALOG": { + "DEACTIVATE": { + "TITLE": "조직 비활성화", + "DESCRIPTION": "조직을 비활성화하려고 합니다. 이후에는 사용자가 로그인할 수 없습니다. 계속 진행하시겠습니까?" + }, + "REACTIVATE": { + "TITLE": "조직 재활성화", + "DESCRIPTION": "조직을 재활성화하려고 합니다. 사용자가 다시 로그인할 수 있게 됩니다. 계속 진행하시겠습니까?" + }, + "DELETE": { + "TITLE": "조직 삭제", + "DESCRIPTION": "조직을 삭제하려고 합니다. 이 작업은 조직과 관련된 모든 데이터를 삭제하는 과정을 시작합니다. 현재로서는 이 작업을 되돌릴 수 없습니다.", + "TYPENAME": "조직을 삭제하려면 '{{value}}'을(를) 입력하세요.", + "ORGNAME": "이름", + "BTN": "삭제" + } + } + }, + "SETTINGS": { + "LIST": { + "ORGS": "조직", + "FEATURESETTINGS": "기능 설정", + "LANGUAGES": "언어", + "LOGIN": "로그인 동작 및 보안", + "LOCKOUT": "잠금", + "AGE": "비밀번호 만료", + "COMPLEXITY": "비밀번호 복잡성", + "NOTIFICATIONS": "알림", + "SMTP_PROVIDER": "SMTP 제공자", + "SMS_PROVIDER": "SMS/전화 제공자", + "NOTIFICATIONS_DESC": "SMTP 및 SMS 설정", + "MESSAGETEXTS": "메시지 텍스트", + "IDP": "ID 제공자", + "VERIFIED_DOMAINS": "검증된 도메인", + "DOMAIN": "도메인 설정", + "LOGINTEXTS": "로그인 인터페이스 텍스트", + "BRANDING": "브랜딩", + "PRIVACYPOLICY": "외부 링크", + "OIDC": "OIDC 토큰 수명 및 만료", + "SECRETS": "시크릿 생성기", + "SECURITY": "보안 설정", + "EVENTS": "이벤트", + "FAILEDEVENTS": "실패한 이벤트", + "VIEWS": "뷰" + }, + "GROUPS": { + "GENERAL": "일반 정보", + "NOTIFICATIONS": "알림", + "LOGIN": "로그인 및 접근", + "DOMAIN": "도메인", + "TEXTS": "텍스트 및 언어", + "APPEARANCE": "외형", + "OTHER": "기타", + "STORAGE": "저장소" + } + }, + "SETTING": { + "LANGUAGES": { + "DEFAULT": "기본 언어", + "ALLOWED": "허용하는 언어", + "NOT_ALLOWED": "허용하지 않는 언어", + "ALLOW_ALL": "모두 허용", + "DISALLOW_ALL": "모두 비허용", + "SETASDEFAULT": "기본 언어로 설정", + "DEFAULT_SAVED": "기본 언어가 저장되었습니다.", + "ALLOWED_SAVED": "허용하는 언어가 저장되었습니다.", + "OPTIONS": { + "de": "Deutsch", + "en": "English", + "es": "Español", + "fr": "Français", + "it": "Italiano", + "ja": "日本語", + "pl": "Polski", + "zh": "简体中文", + "bg": "Български", + "pt": "Portuguese", + "mk": "Македонски", + "cs": "Čeština", + "ru": "Русский", + "nl": "Nederlands", + "sv": "Svenska", + "id": "Bahasa Indonesia", + "hu": "Magyar", + "ko": "한국어" + } + }, + "SMTP": { + "TITLE": "SMTP 제공자", + "DESCRIPTION": "설명", + "SENDERADDRESS": "발신 이메일 주소", + "SENDERNAME": "발신자 이름", + "REPLYTOADDRESS": "회신 주소", + "HOSTANDPORT": "호스트 및 포트", + "USER": "사용자", + "PASSWORD": "비밀번호", + "SETPASSWORD": "SMTP 비밀번호 설정", + "PASSWORDSET": "SMTP 비밀번호가 성공적으로 설정되었습니다.", + "TLS": "전송 계층 보안 (TLS)", + "SAVED": "성공적으로 저장되었습니다!", + "NOCHANGES": "변경 사항이 없습니다!", + "REQUIREDWARN": "도메인에서 알림을 보내려면 SMTP 데이터를 입력해야 합니다." + }, + "SMS": { + "PROVIDERS": "제공자", + "PROVIDER": "SMS 제공자", + "ADDPROVIDER": "SMS 제공자 추가", + "ADDPROVIDERDESCRIPTION": "사용 가능한 제공자 중 하나를 선택하고 필요한 데이터를 입력하세요.", + "REMOVEPROVIDER": "제공자 제거", + "REMOVEPROVIDER_DESC": "제공자 구성을 삭제하려고 합니다. 계속하시겠습니까?", + "SMSPROVIDERSTATE": { + "0": "미지정", + "1": "활성", + "2": "비활성" + }, + "ACTIVATED": "제공자가 활성화되었습니다.", + "DEACTIVATED": "제공자가 비활성화되었습니다.", + "TWILIO": { + "SID": "SID", + "TOKEN": "토큰", + "SENDERNUMBER": "발신 번호", + "VERIFYSERVICESID": "검증 서비스 SID", + "VERIFYSERVICESID_DESCRIPTION": "검증 서비스 SID를 설정하면 전화번호 검증 및 OTP SMS에 Twilio 검증 서비스를 사용할 수 있습니다.", + "ADDED": "Twilio가 성공적으로 추가되었습니다.", + "UPDATED": "Twilio가 성공적으로 업데이트되었습니다.", + "REMOVED": "Twilio가 제거되었습니다.", + "CHANGETOKEN": "토큰 변경", + "SETTOKEN": "토큰 설정", + "TOKENSET": "토큰이 성공적으로 설정되었습니다." + } + }, + "SECRETS": { + "TYPES": "시크릿 유형", + "TYPE": { + "1": "초기화 메일", + "2": "이메일 검증", + "3": "전화 검증", + "4": "비밀번호 재설정", + "5": "비밀번호 없는 초기화", + "6": "앱 시크릿", + "7": "일회성 비밀번호 (OTP) - SMS", + "8": "일회성 비밀번호 (OTP) - 이메일" + }, + "EXPIRY": "만료 시간 (분)", + "INCLUDEDIGITS": "숫자 포함", + "INCLUDESYMBOLS": "기호 포함", + "INCLUDELOWERLETTERS": "소문자 포함", + "INCLUDEUPPERLETTERS": "대문자 포함", + "LENGTH": "길이", + "UPDATED": "설정이 업데이트되었습니다." + }, + "SECURITY": { + "IFRAMETITLE": "iFrame", + "IFRAMEDESCRIPTION": "이 설정은 CSP를 통해 허용된 도메인에서 프레이밍을 허용합니다. iFrame 사용을 허용하면 클릭재킹 위험이 있습니다.", + "IFRAMEENABLED": "iFrame 허용", + "ALLOWEDORIGINS": "허용된 URL", + "IMPERSONATIONTITLE": "신원 가장", + "IMPERSONATIONENABLED": "신원 가장 허용", + "IMPERSONATIONDESCRIPTION": "이 설정은 기본적으로 신원 가장을 사용할 수 있도록 합니다. 신원 가장하는 계정에는 적절한 `*_IMPERSONATOR` 역할이 할당되어야 합니다." + }, + "FEATURES": { + "LOGINDEFAULTORG": "로그인 기본 조직", + "LOGINDEFAULTORG_DESCRIPTION": "조직 컨텍스트가 설정되지 않은 경우 로그인 UI가 기본 조직의 설정을 사용합니다 (인스턴스에서 설정되지 않음).", + "OIDCLEGACYINTROSPECTION": "OIDC 레거시 내부 조사", + "OIDCLEGACYINTROSPECTION_DESCRIPTION": "최근 내부 조사 엔드포인트를 성능을 위해 리팩토링했습니다. 예상치 못한 버그가 발생하면 이 기능을 사용하여 레거시 구현으로 롤백할 수 있습니다.", + "OIDCTOKENEXCHANGE": "OIDC 토큰 교환", + "OIDCTOKENEXCHANGE_DESCRIPTION": "OIDC 토큰 엔드포인트의 실험적 urn:ietf:params:oauth:grant-type:token-exchange 허용을 활성화합니다. 토큰 교환을 통해 범위가 좁은 토큰을 요청하거나 다른 사용자를 가장할 수 있습니다. 인스턴스에서 가장을 허용하는 보안 정책을 확인하세요.", + "OIDCTRIGGERINTROSPECTIONPROJECTIONS": "OIDC 트리거 내부 조사 프로젝션", + "OIDCTRIGGERINTROSPECTIONPROJECTIONS_DESCRIPTION": "내부 조사 요청 중 프로젝션 트리거를 활성화합니다. 이는 내부 조사 응답에서 일관성 문제가 있는 경우 임시 해결책으로 작동할 수 있으나 성능에 영향을 미칠 수 있습니다. 향후 내부 조사 요청에 대한 트리거 제거를 계획 중입니다.", + "USERSCHEMA": "사용자 스키마", + "USERSCHEMA_DESCRIPTION": "사용자 스키마를 통해 사용자의 데이터 스키마를 관리할 수 있습니다. 플래그가 활성화되면 새 API 및 기능을 사용할 수 있습니다.", + "ACTIONS": "액션", + "ACTIONS_DESCRIPTION": "액션 v2는 데이터 실행 및 대상을 관리할 수 있습니다. 플래그가 활성화되면 새 API 및 기능을 사용할 수 있습니다.", + "OIDCSINGLEV1SESSIONTERMINATION": "OIDC 단일 V1 세션 종료", + "OIDCSINGLEV1SESSIONTERMINATION_DESCRIPTION": "플래그가 활성화되면, `sid` 클레임이 있는 id_token을 사용하여 end_session 엔드포인트에서 로그인 UI의 단일 세션을 종료할 수 있습니다. 현재 동일한 사용자 에이전트(브라우저)에서 모든 세션이 로그인 UI에서 종료됩니다. Session API를 통해 관리된 세션은 이미 단일 세션 종료를 허용합니다.", + "STATES": { + "INHERITED": "상속", + "ENABLED": "활성화됨", + "DISABLED": "비활성화됨" + }, + "INHERITED_DESCRIPTION": "시스템의 기본값으로 값을 설정합니다.", + "INHERITEDINDICATOR_DESCRIPTION": { + "ENABLED": "\"활성화됨\"은 상속되었습니다.", + "DISABLED": "\"비활성화됨\"은 상속되었습니다." + }, + "RESET": "모두 상속으로 설정" + }, + "DIALOG": { + "RESET": { + "DEFAULTTITLE": "설정 재설정", + "DEFAULTDESCRIPTION": "설정을 인스턴스의 기본 구성으로 재설정하려고 합니다. 계속하시겠습니까?", + "LOGINPOLICY_DESCRIPTION": "경고: 계속하면 ID 제공자 설정도 인스턴스 설정으로 재설정됩니다." + } + } + }, + "POLICY": { + "APPLIEDTO": "적용 대상", + "PWD_COMPLEXITY": { + "TITLE": "비밀번호 복잡성", + "DESCRIPTION": "모든 설정된 비밀번호가 특정 패턴에 맞는지 확인합니다", + "SYMBOLANDNUMBERERROR": "숫자와 기호/구두점이 포함되어야 합니다.", + "SYMBOLERROR": "기호/구두점이 포함되어야 합니다.", + "NUMBERERROR": "숫자가 포함되어야 합니다.", + "PATTERNERROR": "비밀번호가 요구되는 패턴에 맞지 않습니다." + }, + "NOTIFICATION": { + "TITLE": "알림", + "DESCRIPTION": "어떤 변경 사항에 대해 알림을 보낼지 결정합니다.", + "PASSWORDCHANGE": "비밀번호 변경" + }, + "PRIVATELABELING": { + "DESCRIPTION": "로그인을 맞춤형 스타일로 제공하고 동작을 수정하세요.", + "PREVIEW_DESCRIPTION": "정책의 변경 사항이 미리보기 환경에 자동으로 배포됩니다.", + "BTN": "파일 선택", + "ACTIVATEPREVIEW": "구성 적용", + "DARK": "다크 모드", + "LIGHT": "라이트 모드", + "CHANGEVIEW": "보기 변경", + "ACTIVATED": "정책 변경이 이제 실시간으로 반영됩니다", + "THEME": "테마", + "COLORS": "색상", + "FONT": "폰트", + "ADVANCEDBEHAVIOR": "고급 동작", + "DROP": "이미지를 여기로 드롭하거나", + "RELEASE": "릴리스", + "DROPFONT": "폰트 파일을 여기로 드롭하세요", + "RELEASEFONT": "릴리스", + "USEOFLOGO": "로그인이나 이메일에 로고가 사용됩니다. 작은 UI 요소에는 아이콘이 사용됩니다.", + "MAXSIZE": "최대 크기는 524kB로 제한됩니다", + "EMAILNOSVG": "이메일에서는 SVG 파일 형식이 지원되지 않습니다. PNG 또는 지원되는 다른 형식의 로고를 업로드하세요.", + "MAXSIZEEXCEEDED": "최대 크기인 524kB를 초과했습니다.", + "NOSVGSUPPORTED": "SVG는 지원되지 않습니다!", + "FONTINLOGINONLY": "현재 폰트는 로그인 인터페이스에서만 표시됩니다.", + "BACKGROUNDCOLOR": "배경 색상", + "PRIMARYCOLOR": "기본 색상", + "WARNCOLOR": "경고 색상", + "FONTCOLOR": "폰트 색상", + "VIEWS": { + "PREVIEW": "미리보기", + "CURRENT": "현재 구성" + }, + "PREVIEW": { + "TITLE": "로그인", + "SECOND": "ZITADEL 계정으로 로그인하세요.", + "ERROR": "사용자를 찾을 수 없습니다!", + "PRIMARYBUTTON": "다음", + "SECONDARYBUTTON": "등록" + }, + "THEMEMODE": { + "THEME_MODE_AUTO": "자동 모드", + "THEME_MODE_LIGHT": "라이트 모드만", + "THEME_MODE_DARK": "다크 모드만" + } + }, + "PWD_AGE": { + "TITLE": "비밀번호 만료", + "DESCRIPTION": "비밀번호 만료 정책을 설정할 수 있습니다. 이 정책은 만료 후 다음 로그인 시 비밀번호 변경을 강제합니다. 자동 경고 및 알림은 없습니다." + }, + "PWD_LOCKOUT": { + "TITLE": "잠금 정책", + "DESCRIPTION": "계정이 차단되기 전 최대 비밀번호 시도 횟수를 설정합니다." + }, + "PRIVATELABELING_POLICY": { + "TITLE": "브랜딩", + "BTN": "파일 선택", + "DESCRIPTION": "로그인의 외관을 맞춤화하세요", + "ACTIVATEPREVIEW": "구성 활성화" + }, + "LOGIN_POLICY": { + "TITLE": "로그인 설정", + "DESCRIPTION": "사용자 인증 방식을 정의하고 ID 제공자를 구성합니다", + "DESCRIPTIONCREATEADMIN": "사용자는 아래에서 사용할 수 있는 ID 제공자 중에서 선택할 수 있습니다.", + "DESCRIPTIONCREATEMGMT": "사용자는 아래에서 사용할 수 있는 ID 제공자 중에서 선택할 수 있습니다. 참고: 시스템 설정 제공자와 조직만을 위한 제공자를 사용할 수 있습니다.", + "LIFETIME_INVALID": "양식에 잘못된 값이 포함되어 있습니다.", + "SAVED": "성공적으로 저장되었습니다!", + "PROVIDER_ADDED": "ID 제공자가 활성화되었습니다." + }, + "PRIVACY_POLICY": { + "DESCRIPTION": "개인정보처리방침 및 이용 약관 링크 설정", + "TOSLINK": "이용 약관 링크", + "POLICYLINK": "개인정보처리방침 링크", + "HELPLINK": "도움말 링크", + "SUPPORTEMAIL": "지원 이메일", + "DOCSLINK": "문서 링크 (콘솔)", + "CUSTOMLINK": "사용자 정의 링크 (콘솔)", + "CUSTOMLINKTEXT": "사용자 정의 링크 텍스트 (콘솔)", + "SAVED": "성공적으로 저장되었습니다!", + "RESET_TITLE": "기본값 복원", + "RESET_DESCRIPTION": "이용 약관 및 개인정보처리방침의 기본 링크를 복원하려고 합니다. 계속하시겠습니까?" + }, + "LOGIN_TEXTS": { + "TITLE": "로그인 인터페이스 텍스트", + "DESCRIPTION": "로그인 인터페이스의 텍스트를 정의하세요. 텍스트가 비어 있으면 기본값이 플레이스홀더로 표시됩니다.", + "DESCRIPTION_SHORT": "로그인 인터페이스의 텍스트를 정의하세요.", + "NEWERVERSIONEXISTS": "새 버전이 존재합니다", + "CURRENTDATE": "현재 구성", + "CHANGEDATE": "새 버전 업데이트", + "KEYNAME": "로그인 화면 / 인터페이스", + "RESET_TITLE": "기본값 복원", + "RESET_DESCRIPTION": "모든 기본값을 복원하려고 합니다. 모든 변경 사항이 영구적으로 삭제됩니다. 계속하시겠습니까?", + "UNSAVED_TITLE": "저장하지 않고 계속하시겠습니까?", + "UNSAVED_DESCRIPTION": "저장하지 않고 변경했습니다. 지금 저장하시겠습니까?", + "ACTIVE_LANGUAGE_NOT_ALLOWED": "허용되지 않은 언어를 선택했습니다. 텍스트를 수정할 수는 있지만, 사용자가 이 언어를 실제로 사용할 수 있도록 하려면 인스턴스 제한을 변경하세요.", + "LANGUAGES_NOT_ALLOWED": "허용되지 않은 언어:", + "LANGUAGE": "언어", + "LANGUAGES": { + "de": "Deutsch", + "en": "English", + "es": "Español", + "fr": "Français", + "it": "Italiano", + "ja": "日本語", + "pl": "Polski", + "zh": "简体中文", + "bg": "Български", + "pt": "Portuguese", + "mk": "Македонски", + "cs": "Čeština", + "ru": "Русский", + "nl": "Nederlands", + "sv": "Svenska", + "id": "Bahasa Indonesia", + "ko": "한국어" + }, + "KEYS": { + "emailVerificationDoneText": "이메일 인증 완료", + "emailVerificationText": "이메일 인증", + "externalUserNotFoundText": "외부 사용자를 찾을 수 없습니다", + "footerText": "푸터", + "initMfaDoneText": "MFA 초기화 완료", + "initMfaOtpText": "MFA 초기화", + "initMfaPromptText": "MFA 초기화 프롬프트", + "initMfaU2fText": "U2F 초기화", + "initPasswordDoneText": "비밀번호 초기화 완료", + "initPasswordText": "비밀번호 초기화", + "initializeDoneText": "사용자 초기화 완료", + "initializeUserText": "사용자 초기화", + "linkingUserDoneText": "사용자 연결 완료", + "loginText": "로그인", + "logoutText": "로그아웃", + "mfaProvidersText": "MFA 제공자", + "passwordChangeDoneText": "비밀번호 변경 완료", + "passwordChangeText": "비밀번호 변경", + "passwordResetDoneText": "비밀번호 재설정 완료", + "passwordText": "비밀번호", + "registrationOptionText": "등록 옵션", + "registrationOrgText": "조직 등록", + "registrationUserText": "사용자 등록", + "selectAccountText": "계정 선택", + "successLoginText": "성공적으로 로그인", + "usernameChangeDoneText": "사용자 이름 변경 완료", + "usernameChangeText": "사용자 이름 변경", + "verifyMfaOtpText": "OTP 확인", + "verifyMfaU2fText": "U2F 확인", + "passwordlessPromptText": "비밀번호 없는 프롬프트", + "passwordlessRegistrationDoneText": "비밀번호 없는 등록 완료", + "passwordlessRegistrationText": "비밀번호 없는 등록", + "passwordlessText": "비밀번호 없음", + "externalRegistrationUserOverviewText": "외부 등록 사용자 개요" + } + }, + "MESSAGE_TEXTS": { + "TYPE": "알림", + "TYPES": { + "INIT": "초기화", + "VE": "이메일 확인", + "VP": "전화 확인", + "VSO": "SMS OTP 확인", + "VEO": "이메일 OTP 확인", + "PR": "비밀번호 재설정", + "DC": "도메인 클레임", + "PL": "비밀번호 없음", + "PC": "비밀번호 변경", + "IU": "사용자 초대" + }, + "CHIPS": { + "firstname": "이름", + "lastname": "성", + "code": "코드", + "preferredLoginName": "선호 로그인 이름", + "displayName": "표시 이름", + "nickName": "닉네임", + "loginnames": "로그인 이름", + "domain": "도메인", + "lastEmail": "마지막 이메일", + "lastPhone": "마지막 전화번호", + "verifiedEmail": "확인된 이메일", + "verifiedPhone": "확인된 전화번호", + "changedate": "변경 날짜", + "username": "사용자 이름", + "tempUsername": "임시 사용자 이름", + "otp": "일회용 비밀번호", + "verifyUrl": "일회용 비밀번호 확인 URL", + "expiry": "만료", + "applicationName": "애플리케이션 이름" + }, + "TOAST": { + "UPDATED": "사용자 정의 텍스트가 저장되었습니다." + } + }, + "DEFAULTLABEL": "현재 설정이 인스턴스의 표준과 일치합니다.", + "BTN_INSTALL": "설치", + "BTN_EDIT": "수정", + "DATA": { + "DESCRIPTION": "설명", + "MINLENGTH": "최소 길이여야 함", + "HASNUMBER": "숫자를 포함해야 함", + "HASSYMBOL": "기호를 포함해야 함", + "HASLOWERCASE": "소문자를 포함해야 함", + "HASUPPERCASE": "대문자를 포함해야 함", + "SHOWLOCKOUTFAILURES": "잠금 실패 표시", + "MAXPASSWORDATTEMPTS": "비밀번호 최대 시도 횟수", + "MAXOTPATTEMPTS": "OTP 최대 시도 횟수", + "EXPIREWARNDAYS": "만료 경고 (일)", + "MAXAGEDAYS": "최대 유효 기간 (일)", + "USERLOGINMUSTBEDOMAIN": "로그인 이름에 조직 도메인 추가", + "USERLOGINMUSTBEDOMAIN_DESCRIPTION": "이 설정을 활성화하면 모든 로그인 이름에 조직 도메인이 추가됩니다. 이 설정이 비활성화된 경우, 모든 조직에서 사용자 이름이 고유하도록 해야 합니다.", + "VALIDATEORGDOMAINS": "조직 도메인 확인 필요 (DNS 또는 HTTP 검증)", + "SMTPSENDERADDRESSMATCHESINSTANCEDOMAIN": "SMTP 발신 주소가 인스턴스 도메인과 일치해야 함", + "ALLOWUSERNAMEPASSWORD_DESC": "사용자 이름과 비밀번호로의 일반적인 로그인이 허용됩니다.", + "ALLOWEXTERNALIDP_DESC": "기본 ID 제공자에 대한 로그인이 허용됩니다.", + "ALLOWREGISTER_DESC": "옵션이 선택되면, 로그인 중 사용자 등록을 위한 추가 단계가 표시됩니다.", + "FORCEMFA": "MFA 강제 적용", + "FORCEMFALOCALONLY": "로컬 인증 사용자에 대해 MFA 강제 적용", + "FORCEMFALOCALONLY_DESC": "옵션이 선택되면, 로컬 인증 사용자는 로그인 시 두 번째 인증 요소를 구성해야 합니다.", + "HIDEPASSWORDRESET_DESC": "옵션이 선택되면, 사용자가 로그인 과정에서 비밀번호를 재설정할 수 없습니다.", + "HIDELOGINNAMESUFFIX": "로그인 이름 접미사 숨기기", + "HIDELOGINNAMESUFFIX_DESC": "로그인 인터페이스에서 로그인 이름 접미사를 숨깁니다.", + "IGNOREUNKNOWNUSERNAMES_DESC": "옵션이 선택되면, 사용자를 찾을 수 없는 경우에도 로그인 과정에서 비밀번호 화면이 표시됩니다. 비밀번호 확인 오류가 사용자 이름 또는 비밀번호의 오류를 공개하지 않습니다.", + "ALLOWDOMAINDISCOVERY_DESC": "옵션이 선택되면, 로그인 화면에서 알 수 없는 사용자 이름의 접미사 (@domain.com)가 조직 도메인과 일치하고, 일치할 경우 해당 조직의 등록 화면으로 리디렉션됩니다.", + "DEFAULTREDIRECTURI": "기본 리디렉션 URI", + "DEFAULTREDIRECTURI_DESC": "앱 컨텍스트 없이 로그인 시작 시 (예: 이메일에서) 사용자가 리디렉션될 위치를 정의합니다.", + "ERRORMSGPOPUP": "팝업에 오류 표시", + "DISABLEWATERMARK": "워터마크 숨기기", + "DISABLEWATERMARK_DESC": "로그인 인터페이스에서 'Powered by ZITADEL' 워터마크를 숨깁니다." + }, + "RESET": "인스턴스 기본값으로 재설정", + "CREATECUSTOM": "사용자 정의 정책 생성", + "TOAST": { + "SET": "정책이 성공적으로 설정되었습니다!", + "RESETSUCCESS": "정책이 성공적으로 재설정되었습니다!", + "UPLOADSUCCESS": "업로드가 성공적으로 완료되었습니다!", + "DELETESUCCESS": "성공적으로 삭제되었습니다!", + "UPLOADFAILED": "업로드 실패!" + } + }, + "ORG_DETAIL": { + "TITLE": "조직", + "DESCRIPTION": "여기에서 조직의 설정을 편집하고 멤버를 관리할 수 있습니다.", + "DETAIL": { + "TITLE": "세부 정보", + "NAME": "이름", + "DOMAIN": "도메인", + "STATE": { + "0": "정의되지 않음", + "1": "활성화", + "2": "비활성화" + } + }, + "MEMBER": { + "TITLE": "멤버", + "USERNAME": "사용자 이름", + "DISPLAYNAME": "표시 이름", + "LOGINNAME": "로그인 이름", + "EMAIL": "이메일", + "ROLES": "역할", + "ADD": "멤버 추가", + "ADDDESCRIPTION": "추가할 사용자 이름을 입력하세요." + }, + "TABLE": { + "TOTAL": "총 항목", + "SELECTION": "선택한 요소", + "DEACTIVATE": "사용자 비활성화", + "ACTIVATE": "사용자 활성화", + "DELETE": "사용자 삭제", + "CLEAR": "선택 지우기" + } + }, + "PROJECT": { + "PAGES": { + "TITLE": "프로젝트", + "DESCRIPTION": "여기에서 애플리케이션을 정의하고, 역할을 관리하며 다른 조직이 프로젝트를 사용할 수 있도록 권한을 부여할 수 있습니다.", + "DELETE": "프로젝트 삭제", + "DETAIL": "세부 정보", + "CREATE": "프로젝트 생성", + "CREATE_DESC": "프로젝트의 이름을 입력하세요.", + "ROLE": "역할", + "NOITEMS": "프로젝트가 없습니다", + "ZITADELPROJECT": "이 프로젝트는 ZITADEL 프로젝트에 속해 있습니다. 변경 시 ZITADEL이 의도한 대로 작동하지 않을 수 있습니다.", + "TYPE": { + "OWNED": "소유한 프로젝트", + "OWNED_SINGULAR": "소유한 프로젝트", + "GRANTED_SINGULAR": "{{name}}의 허가된 프로젝트" + }, + "PRIVATELABEL": { + "TITLE": "브랜딩 설정", + "0": { + "TITLE": "정의되지 않음", + "DESC": "사용자가 식별되면 식별된 사용자의 조직 브랜딩이 표시되며, 그렇지 않으면 시스템 기본값이 표시됩니다." + }, + "1": { + "TITLE": "프로젝트 설정 사용", + "DESC": "프로젝트를 소유한 조직의 브랜딩이 표시됩니다" + }, + "2": { + "TITLE": "사용자 조직 설정 사용", + "DESC": "프로젝트의 조직 브랜딩이 표시되지만 사용자가 식별되면 식별된 사용자의 조직 설정이 표시됩니다." + }, + "DIALOG": { + "TITLE": "브랜딩 설정", + "DESCRIPTION": "프로젝트 사용 시 로그인 동작을 선택하세요." + } + }, + "PINNED": "고정됨", + "ALL": "모두", + "CREATEDON": "생성일", + "LASTMODIFIED": "마지막 수정일", + "ADDNEW": "새 프로젝트 생성", + "DIALOG": { + "REACTIVATE": { + "TITLE": "프로젝트 재활성화", + "DESCRIPTION": "프로젝트를 정말로 재활성화하시겠습니까?" + }, + "DEACTIVATE": { + "TITLE": "프로젝트 비활성화", + "DESCRIPTION": "프로젝트를 정말로 비활성화하시겠습니까?" + }, + "DELETE": { + "TITLE": "프로젝트 삭제", + "DESCRIPTION": "프로젝트를 정말로 삭제하시겠습니까?", + "TYPENAME": "영구적으로 삭제하려면 프로젝트 이름을 입력하세요." + } + } + }, + "SETTINGS": { + "TITLE": "설정", + "DESCRIPTION": "" + }, + "STATE": { + "TITLE": "상태", + "0": "정의되지 않음", + "1": "활성화", + "2": "비활성화" + }, + "TYPE": { + "TITLE": "유형", + "0": "알 수 없는 유형", + "1": "소유한 프로젝트", + "2": "허가된 프로젝트" + }, + "NAME": "이름", + "NAMEDIALOG": { + "TITLE": "프로젝트 이름 변경", + "DESCRIPTION": "프로젝트의 새 이름을 입력하세요", + "NAME": "새 이름" + }, + "MEMBER": { + "TITLE": "관리자", + "TITLEDESC": "관리자는 역할에 따라 이 프로젝트를 변경할 수 있습니다.", + "DESCRIPTION": "이 관리자는 프로젝트를 편집할 수 있습니다.", + "USERNAME": "사용자 이름", + "DISPLAYNAME": "표시 이름", + "LOGINNAME": "로그인 이름", + "EMAIL": "이메일", + "ROLES": "역할", + "USERID": "사용자 ID" + }, + "GRANT": { + "EMPTY": "허가된 조직이 없습니다.", + "TITLE": "프로젝트 권한", + "DESCRIPTION": "다른 조직이 프로젝트를 사용할 수 있도록 허용합니다.", + "EDITTITLE": "역할 수정", + "CREATE": { + "TITLE": "조직 권한 생성", + "SEL_USERS": "액세스를 허가할 사용자를 선택하세요", + "SEL_PROJECT": "프로젝트를 검색하세요", + "SEL_ROLES": "권한에 추가할 역할을 선택하세요", + "SEL_USER": "사용자 선택", + "SEL_ORG": "조직 검색", + "SEL_ORG_DESC": "권한을 부여할 조직을 검색하세요.", + "ORG_DESCRIPTION": "{{name}} 조직의 사용자에게 권한을 부여하려고 합니다.", + "ORG_DESCRIPTION_DESC": "헤더의 컨텍스트를 전환하여 다른 조직에 대한 사용자 권한을 부여할 수 있습니다.", + "SEL_ORG_FORMFIELD": "조직", + "FOR_ORG": "권한이 생성된 대상:" + }, + "DETAIL": { + "TITLE": "프로젝트 권한", + "DESC": "특정 조직이 사용할 수 있는 역할을 선택하고 관리자를 지정할 수 있습니다.", + "MEMBERTITLE": "관리자", + "MEMBERDESC": "권한이 부여된 조직의 관리자입니다. 프로젝트 데이터 편집 권한을 부여할 사용자를 추가하세요.", + "PROJECTNAME": "프로젝트 이름", + "GRANTEDORG": "권한 부여된 조직", + "RESOURCEOWNER": "리소스 소유자" + }, + "STATE": "상태", + "STATES": { + "1": "활성화", + "2": "비활성화" + }, + "ALL": "모두", + "SHOWDETAIL": "세부 정보 보기", + "USER": "사용자", + "MEMBERS": "관리자", + "ORG": "조직", + "PROJECTNAME": "프로젝트 이름", + "GRANTEDORG": "권한 부여된 조직", + "GRANTEDORGDOMAIN": "도메인", + "RESOURCEOWNER": "리소스 소유자", + "GRANTEDORGNAME": "조직 이름", + "GRANTID": "권한 ID", + "CREATIONDATE": "생성일", + "CHANGEDATE": "마지막 수정일", + "DATES": "날짜", + "ROLENAMESLIST": "역할", + "NOROLES": "역할 없음", + "TYPE": "유형", + "TOAST": { + "PROJECTGRANTUSERGRANTADDED": "프로젝트 권한이 생성되었습니다.", + "PROJECTGRANTADDED": "프로젝트 권한이 생성되었습니다.", + "PROJECTGRANTCHANGED": "프로젝트 권한이 변경되었습니다.", + "PROJECTGRANTMEMBERADDED": "권한 관리자가 추가되었습니다.", + "PROJECTGRANTMEMBERCHANGED": "권한 관리자가 변경되었습니다.", + "PROJECTGRANTMEMBERREMOVED": "권한 관리자가 제거되었습니다.", + "PROJECTGRANTUPDATED": "프로젝트 권한이 업데이트되었습니다." + }, + "DIALOG": { + "DELETE_TITLE": "프로젝트 권한 삭제", + "DELETE_DESCRIPTION": "프로젝트 권한을 삭제하려고 합니다. 정말 삭제하시겠습니까?" + }, + "ROLES": "프로젝트 역할" + }, + "APP": { + "TITLE": "애플리케이션", + "NAME": "이름", + "NAMEREQUIRED": "이름이 필요합니다." + }, + "ROLE": { + "EMPTY": "아직 생성된 역할이 없습니다.", + "ADDNEWLINE": "추가 역할 추가", + "KEY": "키", + "TITLE": "역할", + "DESCRIPTION": "프로젝트 권한을 생성하는 데 사용할 수 있는 역할을 정의하세요.", + "NAME": "이름", + "DISPLAY_NAME": "표시 이름", + "GROUP": "그룹", + "ACTIONS": "작업", + "ADDTITLE": "역할 생성", + "ADDDESCRIPTION": "새 역할에 대한 데이터를 입력하세요.", + "EDITTITLE": "역할 수정", + "EDITDESCRIPTION": "역할의 새 데이터를 입력하세요.", + "DELETE": "역할 삭제", + "CREATIONDATE": "생성일", + "CHANGEDATE": "마지막 수정일", + "SELECTGROUPTOOLTIP": "{{group}} 그룹의 모든 역할을 선택하세요.", + "OPTIONS": "옵션", + "ASSERTION": "인증 시 역할 검증", + "ASSERTION_DESCRIPTION": "역할 정보는 Userinfo 엔드포인트에서 전송되며, 애플리케이션 설정에 따라 토큰 및 기타 형식으로 전송될 수 있습니다.", + "CHECK": "인증 시 권한 확인", + "CHECK_DESCRIPTION": "설정 시, 계정에 역할이 할당된 사용자만 인증할 수 있습니다.", + "DIALOG": { + "DELETE_TITLE": "역할 삭제", + "DELETE_DESCRIPTION": "프로젝트 역할을 삭제하려고 합니다. 정말 삭제하시겠습니까?" + } + }, + "HAS_PROJECT": "인증 시 프로젝트 확인", + "HAS_PROJECT_DESCRIPTION": "사용자의 조직에 이 프로젝트가 있는지 확인합니다. 없으면 인증할 수 없습니다.", + "TABLE": { + "TOTAL": "총 항목 수:", + "SELECTION": "선택한 요소", + "DEACTIVATE": "프로젝트 비활성화", + "ACTIVATE": "프로젝트 활성화", + "DELETE": "프로젝트 삭제", + "ORGNAME": "조직 이름", + "ORGDOMAIN": "조직 도메인", + "STATE": "상태", + "TYPE": "유형", + "CREATIONDATE": "생성일", + "CHANGEDATE": "마지막 수정일", + "RESOURCEOWNER": "소유자", + "SHOWTABLE": "표 보기", + "SHOWGRID": "그리드 보기", + "EMPTY": "찾은 프로젝트가 없습니다" + }, + "TOAST": { + "MEMBERREMOVED": "관리자가 제거되었습니다.", + "MEMBERSADDED": "관리자가 추가되었습니다.", + "MEMBERADDED": "관리자가 추가되었습니다.", + "MEMBERCHANGED": "관리자가 변경되었습니다.", + "ROLESCREATED": "역할이 생성되었습니다.", + "ROLEREMOVED": "역할이 제거되었습니다.", + "ROLECHANGED": "역할이 변경되었습니다.", + "REACTIVATED": "재활성화되었습니다.", + "DEACTIVATED": "비활성화되었습니다.", + "CREATED": "프로젝트가 생성되었습니다.", + "UPDATED": "프로젝트가 변경되었습니다.", + "GRANTUPDATED": "권한이 변경되었습니다.", + "DELETED": "프로젝트가 삭제되었습니다." + } + }, + "ROLES": { + "DIALOG": { + "DELETE_TITLE": "역할 삭제", + "DELETE_DESCRIPTION": "역할을 삭제하려고 합니다. 정말로 삭제하시겠습니까?" + } + }, + "NEXTSTEPS": { + "TITLE": "다음 단계" + }, + "IDP": { + "LIST": { + "ACTIVETITLE": "활성화된 ID 제공자" + }, + "CREATE": { + "TITLE": "제공자 추가", + "DESCRIPTION": "다음 제공자 중 하나 이상을 선택하세요.", + "STEPPERTITLE": "제공자 생성", + "OIDC": { + "TITLE": "OIDC 제공자", + "DESCRIPTION": "OIDC 제공자에 필요한 데이터를 입력하세요." + }, + "OAUTH": { + "TITLE": "OAuth 제공자", + "DESCRIPTION": "OAuth 제공자에 필요한 데이터를 입력하세요." + }, + "JWT": { + "TITLE": "JWT 제공자", + "DESCRIPTION": "JWT 제공자에 필요한 데이터를 입력하세요." + }, + "GOOGLE": { + "TITLE": "Google 제공자", + "DESCRIPTION": "Google ID 제공자의 자격 증명을 입력하세요." + }, + "GITLAB": { + "TITLE": "Gitlab 제공자", + "DESCRIPTION": "Gitlab ID 제공자의 자격 증명을 입력하세요." + }, + "GITLABSELFHOSTED": { + "TITLE": "자체 호스팅된 Gitlab 제공자", + "DESCRIPTION": "자체 호스팅된 Gitlab ID 제공자의 자격 증명을 입력하세요." + }, + "GITHUBES": { + "TITLE": "GitHub 엔터프라이즈 서버 제공자", + "DESCRIPTION": "GitHub 엔터프라이즈 서버 ID 제공자의 자격 증명을 입력하세요." + }, + "GITHUB": { + "TITLE": "Github 제공자", + "DESCRIPTION": "Github ID 제공자의 자격 증명을 입력하세요." + }, + "AZUREAD": { + "TITLE": "Microsoft 제공자", + "DESCRIPTION": "Microsoft ID 제공자의 자격 증명을 입력하세요." + }, + "LDAP": { + "TITLE": "Active Directory / LDAP", + "DESCRIPTION": "LDAP 제공자의 자격 증명을 입력하세요." + }, + "APPLE": { + "TITLE": "Apple로 로그인", + "DESCRIPTION": "Apple 제공자의 자격 증명을 입력하세요." + }, + "SAML": { + "TITLE": "SAML로 로그인", + "DESCRIPTION": "SAML 제공자의 자격 증명을 입력하세요." + } + }, + "DETAIL": { + "TITLE": "ID 제공자", + "DESCRIPTION": "제공자 설정을 업데이트하세요.", + "DATECREATED": "생성됨", + "DATECHANGED": "변경됨" + }, + "OPTIONS": { + "ISAUTOCREATION": "자동 생성", + "ISAUTOCREATION_DESC": "선택 시, 계정이 존재하지 않으면 생성됩니다.", + "ISAUTOUPDATE": "자동 업데이트", + "ISAUTOUPDATE_DESC": "선택 시, 재인증 시 계정이 업데이트됩니다.", + "ISCREATIONALLOWED": "계정 생성 허용 (수동)", + "ISCREATIONALLOWED_DESC": "외부 계정을 사용하여 계정을 생성할 수 있는지 결정합니다. 자동 생성이 활성화된 경우 사용자가 계정 정보를 수정하지 못하도록 설정할 수 있습니다.", + "ISLINKINGALLOWED": "계정 연결 허용 (수동)", + "ISLINKINGALLOWED_DESC": "ID를 기존 계정에 수동으로 연결할 수 있는지 결정합니다. 자동 연결이 활성화된 경우 사용자가 제안된 계정만 연결하도록 설정할 수 있습니다.", + "AUTOLINKING_DESC": "ID가 기존 계정에 연결되도록 요청할지 여부를 결정합니다.", + "AUTOLINKINGTYPE": { + "0": "비활성화됨", + "1": "기존 사용자 이름 확인", + "2": "기존 이메일 확인" + } + }, + "OWNERTYPES": { + "0": "알 수 없음", + "1": "인스턴스", + "2": "조직" + }, + "STATES": { + "1": "활성화", + "2": "비활성화" + }, + "AZUREADTENANTTYPES": { + "3": "테넌트 ID", + "0": "공통", + "1": "조직", + "2": "소비자" + }, + "AZUREADTENANTTYPE": "테넌트 유형", + "AZUREADTENANTID": "테넌트 ID", + "EMAILVERIFIED": "이메일 인증됨", + "NAMEHINT": "지정하면 로그인 인터페이스에 표시됩니다.", + "OPTIONAL": "선택 사항", + "LDAPATTRIBUTES": "LDAP 속성", + "UPDATEBINDPASSWORD": "바인드 비밀번호 업데이트", + "UPDATECLIENTSECRET": "클라이언트 시크릿 업데이트", + "ADD": "ID 제공자 추가", + "TYPE": "유형", + "OWNER": "소유자", + "ID": "ID", + "NAME": "이름", + "AUTHORIZATIONENDPOINT": "인증 엔드포인트", + "TOKENENDPOINT": "토큰 엔드포인트", + "USERENDPOINT": "사용자 엔드포인트", + "IDATTRIBUTE": "ID 속성", + "AVAILABILITY": "가용성", + "AVAILABLE": "사용 가능", + "AVAILABLEBUTINACTIVE": "사용 가능하지만 비활성화됨", + "SETAVAILABLE": "사용 가능으로 설정", + "SETUNAVAILABLE": "사용 불가로 설정", + "CONFIG": "구성", + "STATE": "상태", + "ISSUER": "발급자", + "SCOPESLIST": "스코프 목록", + "CLIENTID": "클라이언트 ID", + "CLIENTSECRET": "클라이언트 시크릿", + "LDAPCONNECTION": "연결", + "LDAPUSERBINDING": "사용자 바인딩", + "BASEDN": "기준 DN", + "BINDDN": "바인드 DN", + "BINDPASSWORD": "바인드 비밀번호", + "SERVERS": "서버", + "STARTTLS": "TLS 시작", + "TIMEOUT": "타임아웃 (초)", + "USERBASE": "사용자 베이스", + "USERFILTERS": "사용자 필터", + "USEROBJECTCLASSES": "사용자 객체 클래스", + "REQUIRED": "필수", + "LDAPIDATTRIBUTE": "ID 속성", + "AVATARURLATTRIBUTE": "아바타 URL 속성", + "DISPLAYNAMEATTRIBUTE": "표시 이름 속성", + "EMAILATTRIBUTEATTRIBUTE": "이메일 속성", + "EMAILVERIFIEDATTRIBUTE": "이메일 인증 속성", + "FIRSTNAMEATTRIBUTE": "이름 속성", + "LASTNAMEATTRIBUTE": "성 속성", + "NICKNAMEATTRIBUTE": "닉네임 속성", + "PHONEATTRIBUTE": "전화번호 속성", + "PHONEVERIFIEDATTRIBUTE": "전화 인증 속성", + "PREFERREDLANGUAGEATTRIBUTE": "선호 언어 속성", + "PREFERREDUSERNAMEATTRIBUTE": "선호 사용자 이름 속성", + "PROFILEATTRIBUTE": "프로필 속성", + "IDPDISPLAYNAMMAPPING": "ID 제공자 표시 이름 매핑", + "USERNAMEMAPPING": "사용자 이름 매핑", + "DATES": "날짜", + "CREATIONDATE": "생성 일자", + "CHANGEDATE": "마지막 수정 일자", + "DEACTIVATE": "비활성화", + "ACTIVATE": "활성화", + "DELETE": "삭제", + "DELETE_TITLE": "ID 제공자 삭제", + "DELETE_DESCRIPTION": "ID 제공자를 삭제하려고 합니다. 이 변경 사항은 되돌릴 수 없습니다. 정말로 삭제하시겠습니까?", + "REMOVE_WARN_TITLE": "ID 제공자 제거", + "REMOVE_WARN_DESCRIPTION": "ID 제공자를 제거하려고 합니다. 사용자가 선택할 수 있는 ID 제공자가 제거되며, 이미 등록된 사용자는 다시 로그인할 수 없습니다. 계속하시겠습니까?", + "DELETE_SELECTION_TITLE": "ID 제공자 삭제", + "DELETE_SELECTION_DESCRIPTION": "ID 제공자를 삭제하려고 합니다. 이 변경 사항은 되돌릴 수 없습니다. 정말로 삭제하시겠습니까?", + "EMPTY": "사용 가능한 ID 제공자가 없습니다.", + "OIDC": { + "GENERAL": "일반 정보", + "TITLE": "OIDC 구성", + "DESCRIPTION": "OIDC ID 제공자에 필요한 데이터를 입력하세요." + }, + "JWT": { + "TITLE": "JWT 구성", + "DESCRIPTION": "JWT ID 제공자에 필요한 데이터를 입력하세요.", + "HEADERNAME": "헤더 이름", + "JWTENDPOINT": "JWT 엔드포인트", + "JWTKEYSENDPOINT": "JWT 키 엔드포인트" + }, + "APPLE": { + "TEAMID": "팀 ID", + "KEYID": "키 ID", + "PRIVATEKEY": "개인 키", + "UPDATEPRIVATEKEY": "개인 키 업데이트", + "UPLOADPRIVATEKEY": "개인 키 업로드", + "KEYMAXSIZEEXCEEDED": "최대 크기 5kB 초과" + }, + "SAML": { + "METADATAXML": "메타데이터 XML", + "METADATAURL": "메타데이터 URL", + "BINDING": "바인딩", + "SIGNEDREQUEST": "서명된 요청", + "NAMEIDFORMAT": "NameID 형식", + "TRANSIENTMAPPINGATTRIBUTENAME": "사용자 매핑 속성 이름", + "TRANSIENTMAPPINGATTRIBUTENAME_DESC": "`nameid-format`이 `transient`인 경우 사용자 매핑에 사용할 대체 속성 이름, 예: `http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress`" + }, + "TOAST": { + "SAVED": "성공적으로 저장되었습니다.", + "REACTIVATED": "ID 제공자가 재활성화되었습니다.", + "DEACTIVATED": "ID 제공자가 비활성화되었습니다.", + "SELECTEDREACTIVATED": "선택된 ID 제공자가 재활성화되었습니다.", + "SELECTEDDEACTIVATED": "선택된 ID 제공자가 비활성화되었습니다.", + "SELECTEDKEYSDELETED": "선택된 ID 제공자가 삭제되었습니다.", + "DELETED": "ID 제공자가 성공적으로 제거되었습니다!", + "ADDED": "성공적으로 추가되었습니다.", + "REMOVED": "성공적으로 제거되었습니다." + }, + "ISIDTOKENMAPPING": "ID 토큰에서 매핑", + "ISIDTOKENMAPPING_DESC": "선택 시, 사용자 정보 엔드포인트가 아닌 ID 토큰에서 제공자 정보를 매핑합니다." + }, + "MFA": { + "LIST": { + "MULTIFACTORTITLE": "비밀번호 없는 인증", + "MULTIFACTORDESCRIPTION": "여기서 비밀번호 없는 인증을 위한 다중 인증 요소를 정의하세요.", + "SECONDFACTORTITLE": "다중 인증", + "SECONDFACTORDESCRIPTION": "비밀번호 인증을 강화할 추가 인증 요소를 정의하세요." + }, + "CREATE": { + "TITLE": "새로운 인증 요소", + "DESCRIPTION": "새로운 인증 요소 유형을 선택하세요." + }, + "DELETE": { + "TITLE": "인증 요소 삭제", + "DESCRIPTION": "로그인 설정에서 인증 요소를 삭제하려고 합니다. 정말로 삭제하시겠습니까?" + }, + "TOAST": { + "ADDED": "성공적으로 추가되었습니다.", + "SAVED": "성공적으로 저장되었습니다.", + "DELETED": "성공적으로 삭제되었습니다." + }, + "TYPE": "유형", + "MULTIFACTORTYPES": { + "0": "알 수 없음", + "1": "지문, 보안 키, Face ID 및 기타" + }, + "SECONDFACTORTYPES": { + "0": "알 수 없음", + "1": "인증 앱을 통한 일회성 비밀번호(TOTP)", + "2": "지문, 보안 키, Face ID 및 기타", + "3": "이메일을 통한 일회성 비밀번호(이메일 OTP)", + "4": "SMS를 통한 일회성 비밀번호(SMS OTP)" + } + }, + "LOGINPOLICY": { + "CREATE": { + "TITLE": "로그인 설정", + "DESCRIPTION": "조직 내 사용자의 인증 방법을 정의하세요." + }, + "IDPS": "아이덴티티 제공자", + "ADDIDP": { + "TITLE": "아이덴티티 제공자 추가", + "DESCRIPTION": "인증을 위해 사전 정의된 제공자 또는 사용자가 생성한 제공자를 선택할 수 있습니다.", + "SELECTIDPS": "아이덴티티 제공자" + }, + "PASSWORDLESS": "비밀번호 없는 로그인", + "PASSWORDLESSTYPE": { + "0": "허용되지 않음", + "1": "허용됨" + } + }, + "SMTP": { + "LIST": { + "TITLE": "SMTP 제공자", + "DESCRIPTION": "이것은 ZITADEL 인스턴스에 대한 SMTP 제공자 목록입니다. 사용자에게 알림을 전송할 제공자를 활성화하세요.", + "EMPTY": "사용 가능한 SMTP 제공자가 없습니다.", + "ACTIVATED": "활성화됨", + "ACTIVATE": "제공자 활성화", + "DEACTIVATE": "제공자 비활성화", + "TEST": "제공자 테스트", + "TYPE": "유형", + "DIALOG": { + "ACTIVATED": "SMTP 설정이 활성화되었습니다.", + "ACTIVATE_WARN_TITLE": "SMTP 설정 활성화", + "ACTIVATE_WARN_DESCRIPTION": "SMTP 설정을 활성화하려고 합니다. 현재 활성화된 제공자는 비활성화되며 새 설정이 활성화됩니다. 진행하시겠습니까?", + "DEACTIVATE_WARN_TITLE": "SMTP 설정 비활성화", + "DEACTIVATE_WARN_DESCRIPTION": "SMTP 설정을 비활성화하려고 합니다. 진행하시겠습니까?", + "DEACTIVATED": "SMTP 설정이 비활성화되었습니다.", + "DELETE_TITLE": "SMTP 설정 삭제", + "DELETE_DESCRIPTION": "구성을 삭제하려고 합니다. 발신자 이름을 입력하여 이 작업을 확인하세요.", + "DELETED": "SMTP 설정이 삭제되었습니다.", + "SENDER": "이 SMTP 설정을 삭제하려면 {{value}}을 입력하세요.", + "TEST_TITLE": "SMTP 설정 테스트", + "TEST_DESCRIPTION": "이 제공자의 SMTP 설정을 테스트할 이메일 주소를 지정하세요.", + "TEST_EMAIL": "이메일 주소", + "TEST_RESULT": "테스트 결과" + } + }, + "CREATE": { + "TITLE": "SMTP 제공자 추가", + "DESCRIPTION": "다음 제공자 중 하나 이상을 선택하세요.", + "STEPS": { + "TITLE": "{{ value }} SMTP 제공자 추가", + "CREATE_DESC_TITLE": "단계별로 {{ value }} SMTP 설정 입력", + "CURRENT_DESC_TITLE": "현재 SMTP 설정입니다.", + "PROVIDER_SETTINGS": "SMTP 제공자 설정", + "SENDER_SETTINGS": "발신자 설정", + "NEXT_STEPS": "다음 단계", + "ACTIVATE": { + "TITLE": "SMTP 제공자 활성화", + "DESCRIPTION": "SMTP 제공자를 활성화하지 않으면 ZITADEL이 알림을 전송할 수 없습니다. 이 제공자를 활성화하면 현재 활성화된 다른 제공자는 비활성화됩니다." + }, + "DEACTIVATE": { + "TITLE": "SMTP 제공자 비활성화", + "DESCRIPTION": "SMTP 제공자를 비활성화하면 다시 활성화할 때까지 ZITADEL이 이를 통해 알림을 전송할 수 없습니다." + }, + "SAVE_SETTINGS": "설정 저장", + "TEST": { + "TITLE": "설정 테스트", + "DESCRIPTION": "SMTP 제공자 설정을 테스트하고 저장하기 전에 테스트 결과를 확인하세요.", + "RESULT": "이메일이 성공적으로 전송되었습니다." + } + } + }, + "DETAIL": { + "TITLE": "SMTP 제공자 설정" + }, + "EMPTY": "사용 가능한 SMTP 제공자가 없습니다.", + "STEPS": { + "SENDGRID": {} + } + }, + "APP": { + "LIST": "애플리케이션", + "COMPLIANCE": "OIDC 준수", + "URLS": "URL", + "CONFIGURATION": "구성", + "TOKEN": "토큰 설정", + "PAGES": { + "TITLE": "애플리케이션", + "ID": "ID", + "DESCRIPTION": "여기서 애플리케이션 데이터와 구성을 편집할 수 있습니다.", + "CREATE": "애플리케이션 생성", + "CREATE_SELECT_PROJECT": "먼저 프로젝트를 선택하세요", + "CREATE_NEW_PROJECT": "또는 새 프로젝트 이름을 입력하세요", + "CREATE_DESC_TITLE": "단계별로 애플리케이션 세부사항 입력", + "CREATE_DESC_SUB": "권장 구성 설정이 자동으로 생성됩니다.", + "STATE": "상태", + "DATECREATED": "생성됨", + "DATECHANGED": "변경됨", + "URLS": "URL", + "DELETE": "앱 삭제", + "JUMPTOPROJECT": "역할, 권한 등을 구성하려면 프로젝트로 이동하세요.", + "DETAIL": { + "TITLE": "세부사항", + "STATE": { + "0": "정의되지 않음", + "1": "활성", + "2": "비활성" + } + }, + "DIALOG": { + "CONFIG": { + "TITLE": "OIDC 구성 변경" + }, + "DELETE": { + "TITLE": "앱 삭제", + "DESCRIPTION": "이 애플리케이션을 정말로 삭제하시겠습니까?" + } + }, + "NEXTSTEPS": { + "TITLE": "다음 단계", + "0": { + "TITLE": "역할 추가", + "DESC": "프로젝트 역할을 입력하세요" + }, + "1": { + "TITLE": "사용자 추가", + "DESC": "조직의 새 사용자를 추가하세요" + }, + "2": { + "TITLE": "도움말 및 지원", + "DESC": "애플리케이션 생성에 대한 문서를 읽거나 지원팀에 문의하세요" + } + } + }, + "NAMEDIALOG": { + "TITLE": "앱 이름 변경", + "DESCRIPTION": "앱의 새 이름을 입력하세요", + "NAME": "새 이름" + }, + "NAME": "이름", + "TYPE": "애플리케이션 유형", + "AUTHMETHOD": "인증 방법", + "AUTHMETHODSECTION": "인증 방법", + "GRANT": "권한 부여 유형", + "ADDITIONALORIGINS": "추가 출처", + "ADDITIONALORIGINSDESC": "리디렉션에 사용되지 않는 추가 출처를 앱에 추가하려면 여기에서 설정할 수 있습니다.", + "ORIGINS": "출처", + "NOTANORIGIN": "입력된 값이 유효한 출처가 아닙니다", + "PROSWITCH": "전문가 모드로 진행하기", + "NAMEANDTYPESECTION": "이름과 유형", + "TITLEFIRST": "애플리케이션 이름", + "TYPETITLE": "애플리케이션 유형", + "OIDC": { + "WELLKNOWN": "추가 링크는 탐색 엔드포인트에서 확인할 수 있습니다.", + "INFO": { + "ISSUER": "발급자", + "CLIENTID": "클라이언트 ID" + }, + "CURRENT": "현재 구성", + "TOKENSECTIONTITLE": "인증 토큰 옵션", + "REDIRECTSECTIONTITLE": "리디렉션 설정", + "REDIRECTTITLE": "로그인 후 리디렉션될 URI를 지정하세요.", + "POSTREDIRECTTITLE": "로그아웃 후 리디렉션 URI입니다.", + "REDIRECTDESCRIPTIONWEB": "리디렉션 URI는 https://로 시작해야 합니다. 개발 모드에서만 http://가 유효합니다.", + "REDIRECTDESCRIPTIONNATIVE": "리디렉션 URI는 http://127.0.0.1, http://[::1] 또는 http://localhost와 같은 자체 프로토콜로 시작해야 합니다.", + "REDIRECTNOTVALID": "유효하지 않은 리디렉션 URI입니다.", + "COMMAORENTERSEPERATION": "↵로 구분", + "TYPEREQUIRED": "유형은 필수 항목입니다.", + "TITLE": "OIDC 구성", + "CLIENTID": "클라이언트 ID", + "CLIENTSECRET": "클라이언트 시크릿", + "CLIENTSECRET_NOSECRET": "선택한 인증 플로우에서는 시크릿이 필요하지 않으므로 사용할 수 없습니다.", + "CLIENTSECRET_DESCRIPTION": "클라이언트 시크릿을 안전한 곳에 보관하세요. 대화 상자가 닫히면 시크릿이 사라집니다.", + "REGENERATESECRET": "클라이언트 시크릿 재생성", + "DEVMODE": "개발 모드", + "DEVMODE_ENABLED": "활성화됨", + "DEVMODE_DISABLED": "비활성화됨", + "DEVMODEDESC": "주의: 개발 모드가 활성화된 경우 리디렉션 URI가 검증되지 않습니다.", + "SKIPNATIVEAPPSUCCESSPAGE": "로그인 성공 페이지 건너뛰기", + "SKIPNATIVEAPPSUCCESSPAGE_DESCRIPTION": "네이티브 앱 로그인 후 성공 페이지를 건너뜁니다.", + "REDIRECT": "리디렉션 URI", + "REDIRECTSECTION": "리디렉션 URI", + "POSTLOGOUTREDIRECT": "로그아웃 후 리디렉션 URI", + "RESPONSESECTION": "응답 유형", + "GRANTSECTION": "권한 부여 유형", + "GRANTTITLE": "권한 부여 유형을 선택하세요. 참고: Implicit은 브라우저 기반 애플리케이션에서만 사용 가능합니다.", + "APPTYPE": { + "0": "웹", + "1": "사용자 에이전트", + "2": "네이티브" + }, + "RESPONSETYPE": "응답 유형", + "RESPONSE": { + "0": "코드", + "1": "ID 토큰", + "2": "토큰-ID 토큰" + }, + "REFRESHTOKEN": "새로 고침 토큰", + "GRANTTYPE": "권한 부여 유형", + "GRANT": { + "0": "Authorization Code", + "1": "Implicit", + "2": "Refresh Token", + "3": "Device Code", + "4": "Token Exchange" + }, + "AUTHMETHOD": { + "0": "기본", + "1": "POST", + "2": "없음", + "3": "프라이빗 키 JWT" + }, + "TOKENTYPE": "인증 토큰 유형", + "TOKENTYPE0": "Bearer Token", + "TOKENTYPE1": "JWT", + "UNSECUREREDIRECT": "정말로 이 설정을 알고 계신가요?", + "OVERVIEWSECTION": "개요", + "OVERVIEWTITLE": "구성이 완료되었습니다. 설정을 검토하세요.", + "ACCESSTOKENROLEASSERTION": "액세스 토큰에 사용자 역할 추가", + "ACCESSTOKENROLEASSERTION_DESCRIPTION": "선택 시 인증된 사용자의 요청된 역할이 액세스 토큰에 추가됩니다.", + "IDTOKENROLEASSERTION": "ID 토큰에 사용자 역할 추가", + "IDTOKENROLEASSERTION_DESCRIPTION": "선택 시 인증된 사용자의 요청된 역할이 ID 토큰에 추가됩니다.", + "IDTOKENUSERINFOASSERTION": "ID 토큰 내 사용자 정보", + "IDTOKENUSERINFOASSERTION_DESCRIPTION": "클라이언트가 ID 토큰에서 프로필, 이메일, 전화번호 및 주소 클레임을 검색할 수 있습니다.", + "CLOCKSKEW": "OP와 클라이언트 간의 시간 오차를 처리할 수 있도록 허용합니다. (0-5초) 동안 exp 클레임에 추가되고 iats, auth_time 및 nbf에서 감소됩니다.", + "RECOMMENDED": "권장", + "NOTRECOMMENDED": "권장하지 않음", + "SELECTION": { + "APPTYPE": { + "WEB": { + "TITLE": "웹", + "DESCRIPTION": ".net, PHP, Node.js, Java 등과 같은 일반 웹 애플리케이션" + }, + "NATIVE": { + "TITLE": "네이티브", + "DESCRIPTION": "모바일 앱, 데스크톱, 스마트 기기 등" + }, + "USERAGENT": { + "TITLE": "사용자 에이전트", + "DESCRIPTION": "단일 페이지 애플리케이션(SPA) 및 브라우저에서 실행되는 모든 JS 프레임워크" + } + } + } + }, + "API": { + "INFO": { + "CLIENTID": "클라이언트 ID" + }, + "REGENERATESECRET": "클라이언트 시크릿 재생성", + "SELECTION": { + "TITLE": "API", + "DESCRIPTION": "일반적인 API" + }, + "AUTHMETHOD": { + "0": "기본", + "1": "프라이빗 키 JWT" + } + }, + "SAML": { + "SELECTION": { + "TITLE": "SAML", + "DESCRIPTION": "SAML 애플리케이션" + }, + "CONFIGSECTION": "SAML 구성", + "CHOOSEMETADATASOURCE": "SAML 구성을 다음 옵션 중 하나로 제공하세요:", + "METADATAOPT1": "옵션 1. 메타데이터 파일이 위치한 URL을 지정하세요", + "METADATAOPT2": "옵션 2. 메타데이터 XML이 포함된 파일을 업로드하세요", + "METADATAOPT3": "옵션 3. ENTITYID 및 ACS URL을 제공하여 최소한의 메타데이터 파일을 실시간으로 생성", + "UPLOAD": "XML 파일 업로드", + "METADATA": "메타데이터", + "METADATAFROMFILE": "파일에서 가져온 메타데이터", + "CERTIFICATE": "SAML 인증서", + "DOWNLOADCERT": "SAML 인증서 다운로드", + "CREATEMETADATA": "메타데이터 생성", + "ENTITYID": "엔터티 ID", + "ACSURL": "ACS 엔드포인트 URL" + }, + "AUTHMETHODS": { + "CODE": { + "TITLE": "코드", + "DESCRIPTION": "토큰과 인증 코드를 교환합니다" + }, + "PKCE": { + "TITLE": "PKCE", + "DESCRIPTION": "더 높은 보안을 위해 정적 클라이언트 시크릿 대신 임의 해시 사용" + }, + "POST": { + "TITLE": "POST", + "DESCRIPTION": "폼의 일부로 client_id 및 client_secret 전송" + }, + "PK_JWT": { + "TITLE": "프라이빗 키 JWT", + "DESCRIPTION": "애플리케이션 인증을 위해 프라이빗 키 사용" + }, + "BASIC": { + "TITLE": "기본", + "DESCRIPTION": "사용자 이름과 비밀번호로 인증" + }, + "IMPLICIT": { + "TITLE": "암묵적", + "DESCRIPTION": "인증 엔드포인트에서 직접 토큰 수신" + }, + "DEVICECODE": { + "TITLE": "디바이스 코드", + "DESCRIPTION": "컴퓨터 또는 스마트폰에서 장치 인증" + }, + "CUSTOM": { + "TITLE": "사용자 정의", + "DESCRIPTION": "설정이 다른 옵션과 일치하지 않습니다." + } + }, + "TOAST": { + "REACTIVATED": "애플리케이션이 다시 활성화되었습니다.", + "DEACTIVATED": "애플리케이션이 비활성화되었습니다.", + "OIDCUPDATED": "애플리케이션이 업데이트되었습니다.", + "APIUPDATED": "애플리케이션이 업데이트되었습니다.", + "UPDATED": "애플리케이션이 업데이트되었습니다.", + "CREATED": "애플리케이션이 생성되었습니다.", + "CLIENTSECRETREGENERATED": "클라이언트 시크릿이 생성되었습니다.", + "DELETED": "애플리케이션이 삭제되었습니다.", + "CONFIGCHANGED": "변경 사항이 감지되었습니다!" + } + }, + "GENDERS": { + "0": "알 수 없음", + "1": "여성", + "2": "남성", + "3": "기타" + }, + "LANGUAGES": { + "de": "Deutsch", + "en": "English", + "es": "Español", + "fr": "Français", + "it": "Italiano", + "ja": "日本語", + "pl": "Polski", + "zh": "简体中文", + "bg": "Български", + "pt": "Portuguese", + "mk": "Македонски", + "cs": "Čeština", + "ru": "Русский", + "nl": "Nederlands", + "sv": "Svenska", + "id": "Bahasa Indonesia", + "ko": "한국어" + }, + "MEMBER": { + "ADD": "매니저 추가", + "CREATIONTYPE": "생성 유형", + "CREATIONTYPES": { + "3": "IAM", + "2": "조직", + "0": "소유 프로젝트", + "1": "부여된 프로젝트", + "4": "프로젝트" + }, + "EDITROLE": "역할 편집", + "EDITFOR": "사용자의 역할 편집: {{value}}", + "DIALOG": { + "DELETE_TITLE": "매니저 제거", + "DELETE_DESCRIPTION": "매니저를 제거하려고 합니다. 계속하시겠습니까?" + }, + "SHOWDETAILS": "세부 정보 보려면 클릭하세요." + }, + "ROLESLABEL": "역할", + "GRANTS": { + "TITLE": "권한", + "DESC": "조직의 모든 권한입니다.", + "DELETE": "권한 삭제", + "EMPTY": "권한이 없습니다", + "ADD": "권한 생성", + "ADD_BTN": "새로 추가", + "PROJECT": { + "TITLE": "권한", + "DESCRIPTION": "지정된 프로젝트에 대한 권한을 정의합니다. 권한이 있는 프로젝트와 사용자만 볼 수 있습니다." + }, + "USER": { + "TITLE": "권한", + "DESCRIPTION": "지정된 사용자에 대한 권한을 정의합니다. 권한이 있는 프로젝트와 사용자만 볼 수 있습니다." + }, + "CREATE": { + "TITLE": "권한 생성", + "DESCRIPTION": "조직, 프로젝트 및 해당 역할을 검색하세요." + }, + "EDIT": { + "TITLE": "권한 변경" + }, + "DETAIL": { + "TITLE": "권한 세부 정보", + "DESCRIPTION": "여기에서 권한의 모든 세부 정보를 볼 수 있습니다." + }, + "TOAST": { + "UPDATED": "권한이 업데이트되었습니다.", + "REMOVED": "권한이 제거되었습니다.", + "BULKREMOVED": "권한이 삭제되었습니다.", + "CANTSHOWINFO": "이 사용자가 속한 조직의 구성원이 아니기 때문에 사용자의 프로필을 볼 수 없습니다." + }, + "DIALOG": { + "DELETE_TITLE": "권한 삭제", + "DELETE_DESCRIPTION": "권한을 삭제하려고 합니다. 계속하시겠습니까?", + "BULK_DELETE_TITLE": "권한 삭제", + "BULK_DELETE_DESCRIPTION": "여러 권한을 삭제하려고 합니다. 계속하시겠습니까?" + } + }, + "CHANGES": { + "LISTTITLE": "최근 변경 사항", + "BOTTOM": "목록의 끝에 도달했습니다.", + "LOADMORE": "더 불러오기", + "ORG": { + "TITLE": "활동", + "DESCRIPTION": "조직 변경을 유발한 최신 이벤트를 볼 수 있습니다." + }, + "PROJECT": { + "TITLE": "활동", + "DESCRIPTION": "프로젝트 변경을 유발한 최신 이벤트를 볼 수 있습니다." + }, + "USER": { + "TITLE": "활동", + "DESCRIPTION": "사용자 변경을 유발한 최신 이벤트를 볼 수 있습니다." + } + } +} diff --git a/console/src/assets/i18n/mk.json b/console/src/assets/i18n/mk.json index 5625374d1d..43b08abc9d 100644 --- a/console/src/assets/i18n/mk.json +++ b/console/src/assets/i18n/mk.json @@ -1385,7 +1385,8 @@ "nl": "Nederlands", "sv": "Svenska", "id": "Bahasa Indonesia", - "hu": "Magyar" + "hu": "Magyar", + "ko": "한국어" } }, "SMTP": { @@ -1622,7 +1623,8 @@ "ru": "Русский", "nl": "Nederlands", "sv": "Svenska", - "id": "Bahasa Indonesia" + "id": "Bahasa Indonesia", + "ko": "한국어" }, "KEYS": { "emailVerificationDoneText": "Е-поштата е верифицирана", @@ -2560,7 +2562,8 @@ "ru": "Русский", "nl": "Nederlands", "sv": "Svenska", - "id": "Bahasa Indonesia" + "id": "Bahasa Indonesia", + "ko": "한국어" }, "MEMBER": { "ADD": "Додај Менаџер", diff --git a/console/src/assets/i18n/nl.json b/console/src/assets/i18n/nl.json index 3e67d36f29..aa7008d57c 100644 --- a/console/src/assets/i18n/nl.json +++ b/console/src/assets/i18n/nl.json @@ -1620,7 +1620,8 @@ "ru": "Русский", "nl": "Nederlands", "sv": "Svenska", - "id": "Bahasa Indonesia" + "id": "Bahasa Indonesia", + "ko": "한국어" }, "KEYS": { "emailVerificationDoneText": "E-mail verificatie voltooid", @@ -2580,7 +2581,8 @@ "ru": "Русский", "nl": "Nederlands", "sv": "Svenska", - "id": "Bahasa Indonesia" + "id": "Bahasa Indonesia", + "ko": "한국어" }, "MEMBER": { "ADD": "Voeg een Manager toe", diff --git a/console/src/assets/i18n/pl.json b/console/src/assets/i18n/pl.json index e9874d286c..0b4fd0daba 100644 --- a/console/src/assets/i18n/pl.json +++ b/console/src/assets/i18n/pl.json @@ -1383,7 +1383,8 @@ "nl": "Nederlands", "sv": "Svenska", "id": "Bahasa Indonesia", - "hu": "Magyar" + "hu": "Magyar", + "ko": "한국어" } }, "SMTP": { @@ -1620,7 +1621,8 @@ "ru": "Русский", "nl": "Nederlands", "sv": "Svenska", - "id": "Bahasa Indonesia" + "id": "Bahasa Indonesia", + "ko": "한국어" }, "KEYS": { "emailVerificationDoneText": "Weryfikacja adresu e-mail zakończona", @@ -2563,7 +2565,8 @@ "ru": "Русский", "nl": "Nederlands", "sv": "Svenska", - "id": "Bahasa Indonesia" + "id": "Bahasa Indonesia", + "ko": "한국어" }, "MEMBER": { "ADD": "Dodaj managera", diff --git a/console/src/assets/i18n/pt.json b/console/src/assets/i18n/pt.json index 1885358bc1..74ede27301 100644 --- a/console/src/assets/i18n/pt.json +++ b/console/src/assets/i18n/pt.json @@ -1385,7 +1385,8 @@ "nl": "Nederlands", "sv": "Svenska", "id": "Bahasa Indonesia", - "hu": "Magyar" + "hu": "Magyar", + "ko": "한국어" } }, "SMTP": { @@ -1622,7 +1623,8 @@ "ru": "Русский", "nl": "Nederlands", "sv": "Svenska", - "id": "Bahasa Indonesia" + "id": "Bahasa Indonesia", + "ko": "한국어" }, "KEYS": { "emailVerificationDoneText": "Verificação de email concluída", @@ -2558,7 +2560,8 @@ "ru": "Русский", "nl": "Nederlands", "sv": "Svenska", - "id": "Bahasa Indonesia" + "id": "Bahasa Indonesia", + "ko": "한국어" }, "MEMBER": { "ADD": "Adicionar um Gerente", diff --git a/console/src/assets/i18n/ru.json b/console/src/assets/i18n/ru.json index 56ef096d73..775428ebcd 100644 --- a/console/src/assets/i18n/ru.json +++ b/console/src/assets/i18n/ru.json @@ -1428,7 +1428,8 @@ "nl": "Nederlands", "sv": "Svenska", "id": "Bahasa Indonesia", - "hu": "Magyar" + "hu": "Magyar", + "ko": "한국어" } }, "SMTP": { @@ -1677,7 +1678,8 @@ "ru": "Русский", "nl": "Nederlands", "sv": "Svenska", - "id": "Bahasa Indonesia" + "id": "Bahasa Indonesia", + "ko": "한국어" }, "LOCALE": "Код языка", "LOCALES": { @@ -2670,7 +2672,8 @@ "ru": "Русский", "nl": "Nederlands", "sv": "Svenska", - "id": "Bahasa Indonesia" + "id": "Bahasa Indonesia", + "ko": "한국어" }, "MEMBER": { "ADD": "Добавить менеджера", diff --git a/console/src/assets/i18n/sv.json b/console/src/assets/i18n/sv.json index d56384e419..d8be2e7f0f 100644 --- a/console/src/assets/i18n/sv.json +++ b/console/src/assets/i18n/sv.json @@ -1388,7 +1388,8 @@ "nl": "Nederlands", "sv": "Svenska", "id": "Bahasa Indonesia", - "hu": "Magyar" + "hu": "Magyar", + "ko": "한국어" } }, "SMTP": { @@ -1625,7 +1626,8 @@ "ru": "Русский", "nl": "Nederlands", "sv": "Svenska", - "id": "Bahasa Indonesia" + "id": "Bahasa Indonesia", + "ko": "한국어" }, "KEYS": { "emailVerificationDoneText": "E-postverifiering klar", @@ -2592,7 +2594,8 @@ "ru": "Русский", "nl": "Nederlands", "sv": "Svenska", - "id": "Bahasa Indonesia" + "id": "Bahasa Indonesia", + "ko": "한국어" }, "MEMBER": { "ADD": "Lägg till en administratör", diff --git a/console/src/assets/i18n/zh.json b/console/src/assets/i18n/zh.json index bb602d4e65..50f108533d 100644 --- a/console/src/assets/i18n/zh.json +++ b/console/src/assets/i18n/zh.json @@ -1384,7 +1384,8 @@ "nl": "Nederlands", "sv": "Svenska", "id": "Bahasa Indonesia", - "hu": "Magyar" + "hu": "Magyar", + "ko": "한국어" } }, "SMTP": { @@ -1620,7 +1621,8 @@ "ru": "Русский", "nl": "Nederlands", "sv": "Svenska", - "id": "Bahasa Indonesia" + "id": "Bahasa Indonesia", + "ko": "한국어" }, "KEYS": { "emailVerificationDoneText": "电子邮件验证完成", @@ -2563,7 +2565,8 @@ "ru": "Русский", "nl": "Nederlands", "sv": "Svenska", - "id": "Bahasa Indonesia" + "id": "Bahasa Indonesia", + "ko": "한국어" }, "MEMBER": { "ADD": "添加管理者", diff --git a/docs/docs/guides/manage/customize/texts.md b/docs/docs/guides/manage/customize/texts.md index d3a3fd5299..0d4bfdd21e 100644 --- a/docs/docs/guides/manage/customize/texts.md +++ b/docs/docs/guides/manage/customize/texts.md @@ -51,6 +51,7 @@ ZITADEL is available in the following languages - Dutch (nl) - Swedish (sv) - Hungarian (hu) +- 한국어 (ko) A language is displayed based on your agent's language header. If a users language header doesn't match any of the supported or [restricted](#restrict-languages) languages, the instances default language will be used. diff --git a/internal/api/ui/login/static/i18n/bg.yaml b/internal/api/ui/login/static/i18n/bg.yaml index f89a672b97..ad308b859d 100644 --- a/internal/api/ui/login/static/i18n/bg.yaml +++ b/internal/api/ui/login/static/i18n/bg.yaml @@ -260,6 +260,7 @@ RegistrationUser: Swedish: Svenska Indonesian: Bahasa Indonesia Hungarian: Magyar + Korean: 한국어 GenderLabel: Пол Female: Женски пол Male: Мъжки @@ -301,6 +302,7 @@ ExternalRegistrationUserOverview: Swedish: Svenska Indonesian: Bahasa Indonesia Hungarian: Magyar + Korean: 한국어 TosAndPrivacyLabel: Правила и условия TosConfirm: Приемам TosLinkText: TOS @@ -371,6 +373,7 @@ ExternalNotFound: Swedish: Svenska Indonesian: Bahasa Indonesia Hungarian: Magyar + Korean: 한국어 DeviceAuth: Title: Упълномощаване на устройството UserCode: diff --git a/internal/api/ui/login/static/i18n/cs.yaml b/internal/api/ui/login/static/i18n/cs.yaml index 65d70719f6..032302e3b8 100644 --- a/internal/api/ui/login/static/i18n/cs.yaml +++ b/internal/api/ui/login/static/i18n/cs.yaml @@ -264,6 +264,7 @@ RegistrationUser: Swedish: Svenska Indonesian: Bahasa Indonesia Hungarian: Magyar + Korean: 한국어 GenderLabel: Pohlaví Female: Žena Male: Muž @@ -306,6 +307,7 @@ ExternalRegistrationUserOverview: Swedish: Svenska Indonesian: Bahasa Indonesia Hungarian: Magyar + Korean: 한국어 TosAndPrivacyLabel: Obchodní podmínky TosConfirm: Souhlasím s TosLinkText: obchodními podmínkami @@ -382,6 +384,7 @@ ExternalNotFound: Swedish: Svenska Indonesian: Bahasa Indonesia Hungarian: Magyar + Korean: 한국어 DeviceAuth: Title: Autorizace zařízení UserCode: diff --git a/internal/api/ui/login/static/i18n/de.yaml b/internal/api/ui/login/static/i18n/de.yaml index d6b1d86d5e..edbeb652ce 100644 --- a/internal/api/ui/login/static/i18n/de.yaml +++ b/internal/api/ui/login/static/i18n/de.yaml @@ -263,6 +263,7 @@ RegistrationUser: Swedish: Svenska Indonesian: Bahasa Indonesia Hungarian: Magyar + Korean: 한국어 GenderLabel: Geschlecht Female: weiblich Male: männlich @@ -305,6 +306,7 @@ ExternalRegistrationUserOverview: Swedish: Svenska Indonesian: Bahasa Indonesia Hungarian: Magyar + Korean: 한국어 TosAndPrivacyLabel: Allgemeine Geschäftsbedingungen und Datenschutz TosConfirm: Ich akzeptiere die TosLinkText: AGB @@ -381,6 +383,7 @@ ExternalNotFound: Swedish: Svenska Indonesian: Bahasa Indonesia Hungarian: Magyar + Korean: 한국어 DeviceAuth: Title: Gerät verbinden UserCode: diff --git a/internal/api/ui/login/static/i18n/en.yaml b/internal/api/ui/login/static/i18n/en.yaml index 098a42f4ca..6c58b11257 100644 --- a/internal/api/ui/login/static/i18n/en.yaml +++ b/internal/api/ui/login/static/i18n/en.yaml @@ -264,6 +264,7 @@ RegistrationUser: Swedish: Svenska Indonesian: Bahasa Indonesia Hungarian: Magyar + Korean: 한국어 GenderLabel: Gender Female: Female Male: Male @@ -306,6 +307,7 @@ ExternalRegistrationUserOverview: Swedish: Svenska Indonesian: Bahasa Indonesia Hungarian: Magyar + Korean: 한국어 TosAndPrivacyLabel: Terms and conditions TosConfirm: I accept the TosLinkText: TOS @@ -382,6 +384,7 @@ ExternalNotFound: Swedish: Svenska Indonesian: Bahasa Indonesia Hungarian: Magyar + Korean: 한국어 DeviceAuth: Title: Device Authorization UserCode: diff --git a/internal/api/ui/login/static/i18n/es.yaml b/internal/api/ui/login/static/i18n/es.yaml index 504f2b944b..de57fdcd85 100644 --- a/internal/api/ui/login/static/i18n/es.yaml +++ b/internal/api/ui/login/static/i18n/es.yaml @@ -264,6 +264,7 @@ RegistrationUser: Swedish: Svenska Indonesian: Bahasa Indonesia Hungarian: Magyar + Korean: 한국어 GenderLabel: Género Female: Mujer Male: Hombre @@ -306,6 +307,7 @@ ExternalRegistrationUserOverview: Swedish: Svenska Indonesian: Bahasa Indonesia Hungarian: Magyar + Korean: 한국어 TosAndPrivacyLabel: Términos y condiciones TosConfirm: Acepto los TosLinkText: TDS @@ -382,6 +384,7 @@ ExternalNotFound: Swedish: Svenska Indonesian: Bahasa Indonesia Hungarian: Magyar + Korean: 한국어 Footer: PoweredBy: Powered By diff --git a/internal/api/ui/login/static/i18n/fr.yaml b/internal/api/ui/login/static/i18n/fr.yaml index e79a95ccc6..8534085ae9 100644 --- a/internal/api/ui/login/static/i18n/fr.yaml +++ b/internal/api/ui/login/static/i18n/fr.yaml @@ -264,6 +264,7 @@ RegistrationUser: Swedish: Svenska Indonesian: Bahasa Indonesia Hungarian: Magyar + Korean: 한국어 GenderLabel: Genre Female: Femme Male: Homme @@ -306,6 +307,7 @@ ExternalRegistrationUserOverview: Swedish: Svenska Indonesian: Bahasa Indonesia Hungarian: Magyar + Korean: 한국어 TosAndPrivacyLabel: Termes et conditions TosConfirm: J'accepte les TosLinkText: TOS @@ -382,6 +384,7 @@ ExternalNotFound: Swedish: Svenska Indonesian: Bahasa Indonesia Hungarian: Magyar + Korean: 한국어 DeviceAuth: Title: Autorisation de l'appareil diff --git a/internal/api/ui/login/static/i18n/hu.yaml b/internal/api/ui/login/static/i18n/hu.yaml index f10829b493..80ed98945c 100644 --- a/internal/api/ui/login/static/i18n/hu.yaml +++ b/internal/api/ui/login/static/i18n/hu.yaml @@ -234,6 +234,7 @@ RegistrationUser: Swedish: Svéd Indonesian: Indonéz Hungarian: Magyar + Korean: 한국어 GenderLabel: Nem Female: Nő Male: Férfi @@ -275,6 +276,7 @@ ExternalRegistrationUserOverview: Swedish: Svéd Indonesian: Indonéz Hungarian: Magyar + Korean: 한국어 TosAndPrivacyLabel: Felhasználási feltételek TosConfirm: Elfogadom a TosLinkText: TOS @@ -345,6 +347,7 @@ ExternalNotFound: Swedish: Svéd Indonesian: Indonéz Hungarian: Magyar + Korean: 한국어 DeviceAuth: Title: Eszköz engedélyezése UserCode: diff --git a/internal/api/ui/login/static/i18n/id.yaml b/internal/api/ui/login/static/i18n/id.yaml index a8bf57f467..63deb41229 100644 --- a/internal/api/ui/login/static/i18n/id.yaml +++ b/internal/api/ui/login/static/i18n/id.yaml @@ -234,6 +234,7 @@ RegistrationUser: Swedish: Svenska Indonesian: Bahasa Indonesia Hungarian: Magyar + Korean: 한국어 GenderLabel: Jenis kelamin Female: Perempuan Male: Pria @@ -274,6 +275,7 @@ ExternalRegistrationUserOverview: Dutch: Nederlands Swedish: Svenska Indonesian: Bahasa Indonesia + Korean: 한국어 TosAndPrivacyLabel: Syarat dan Ketentuan TosConfirm: Saya menerima itu TosLinkText: KL @@ -344,6 +346,7 @@ ExternalNotFound: Swedish: Svenska Indonesian: Bahasa Indonesia Hungarian: Magyar + Korean: 한국어 DeviceAuth: Title: Otorisasi Perangkat UserCode: diff --git a/internal/api/ui/login/static/i18n/it.yaml b/internal/api/ui/login/static/i18n/it.yaml index 62b49e8bca..46e74d3b13 100644 --- a/internal/api/ui/login/static/i18n/it.yaml +++ b/internal/api/ui/login/static/i18n/it.yaml @@ -264,6 +264,7 @@ RegistrationUser: Swedish: Svenska Indonesian: Bahasa Indonesia Hungarian: Magyar + Korean: 한국어 GenderLabel: Genere Female: Femminile Male: Maschile @@ -305,6 +306,7 @@ ExternalRegistrationUserOverview: Dutch: Nederlands Swedish: Svenska Indonesian: Bahasa Indonesia + Korean: 한국어 TosAndPrivacyLabel: Termini di servizio TosConfirm: Accetto i TosLinkText: Termini di servizio @@ -381,6 +383,7 @@ ExternalNotFound: Swedish: Svenska Indonesian: Bahasa Indonesia Hungarian: Magyar + Korean: 한국어 DeviceAuth: Title: Autorizzazione del dispositivo diff --git a/internal/api/ui/login/static/i18n/ja.yaml b/internal/api/ui/login/static/i18n/ja.yaml index 68899b5c65..9ec99eb912 100644 --- a/internal/api/ui/login/static/i18n/ja.yaml +++ b/internal/api/ui/login/static/i18n/ja.yaml @@ -256,6 +256,7 @@ RegistrationUser: Swedish: Svenska Indonesian: Bahasa Indonesia Hungarian: Magyar + Korean: 한국어 GenderLabel: 性別 Female: 女性 Male: 男性 @@ -298,6 +299,7 @@ ExternalRegistrationUserOverview: Swedish: Svenska Indonesian: Bahasa Indonesia Hungarian: Magyar + Korean: 한국어 TosAndPrivacyLabel: 利用規約 TosConfirm: 私は利用規約を承諾します。 TosLinkText: TOS @@ -374,6 +376,7 @@ ExternalNotFound: Swedish: Svenska Indonesian: Bahasa Indonesia Hungarian: Magyar + Korean: 한국어 DeviceAuth: Title: デバイス認証 diff --git a/internal/api/ui/login/static/i18n/ko.yaml b/internal/api/ui/login/static/i18n/ko.yaml new file mode 100644 index 0000000000..e62cfcb8b5 --- /dev/null +++ b/internal/api/ui/login/static/i18n/ko.yaml @@ -0,0 +1,521 @@ +Login: + Title: 다시 오신 것을 환영합니다! + Description: 로그인 정보를 입력하세요. + TitleLinking: 사용자 연결을 위한 로그인 + DescriptionLinking: 외부 사용자를 연결하려면 로그인 정보를 입력하세요. + LoginNameLabel: 로그인 이름 + UsernamePlaceHolder: 사용자 이름 + LoginnamePlaceHolder: username@domain + ExternalUserDescription: 외부 사용자로 로그인하세요. + MustBeMemberOfOrg: 사용자는 {{.OrgName}} 조직의 멤버여야 합니다. + RegisterButtonText: 등록 + NextButtonText: 다음 + +LDAP: + Title: 로그인 + Description: 로그인 정보를 입력하세요. + LoginNameLabel: 로그인 이름 + PasswordLabel: 비밀번호 + NextButtonText: 다음 + +SelectAccount: + Title: 계정 선택 + Description: 계정을 사용하세요 + TitleLinking: 사용자 연결을 위한 계정 선택 + DescriptionLinking: 외부 사용자와 연결할 계정을 선택하세요. + OtherUser: 다른 사용자 + SessionState0: 활성 + SessionState1: 로그아웃됨 + MustBeMemberOfOrg: 사용자는 {{.OrgName}} 조직의 멤버여야 합니다. + +Password: + Title: 비밀번호 + Description: 로그인 정보를 입력하세요. + PasswordLabel: 비밀번호 + MinLength: 최소 길이는 + MinLengthp2: 자 이상이어야 합니다. + MaxLength: 70자 미만이어야 합니다. + HasUppercase: 대문자가 포함되어야 합니다. + HasLowercase: 소문자가 포함되어야 합니다. + HasNumber: 숫자가 포함되어야 합니다. + HasSymbol: 기호가 포함되어야 합니다. + Confirmation: 비밀번호가 일치합니다. + ResetLinkText: 비밀번호 재설정 + BackButtonText: 뒤로 + NextButtonText: 다음 + +UsernameChange: + Title: 사용자 이름 변경 + Description: 새 사용자 이름을 설정하세요 + UsernameLabel: 사용자 이름 + CancelButtonText: 취소 + NextButtonText: 다음 + +UsernameChangeDone: + Title: 사용자 이름 변경 완료 + Description: 사용자 이름이 성공적으로 변경되었습니다. + NextButtonText: 다음 + +InitPassword: + Title: 비밀번호 설정 + Description: 아래 양식에 새 비밀번호를 설정하기 위해 받은 코드를 입력하세요. + CodeLabel: 코드 + NewPasswordLabel: 새 비밀번호 + NewPasswordConfirmLabel: 비밀번호 확인 + ResendButtonText: 코드 재전송 + NextButtonText: 다음 + +InitPasswordDone: + Title: 비밀번호 설정 완료 + Description: 비밀번호가 성공적으로 설정되었습니다. + NextButtonText: 다음 + CancelButtonText: 취소 + +InitUser: + Title: 사용자 활성화 + Description: 아래 코드를 통해 이메일을 인증하고 비밀번호를 설정하세요. + CodeLabel: 코드 + NewPasswordLabel: 새 비밀번호 + NewPasswordConfirm: 비밀번호 확인 + NextButtonText: 다음 + ResendButtonText: 코드 재전송 + +InitUserDone: + Title: 사용자 활성화 완료 + Description: 이메일이 인증되었으며 비밀번호가 성공적으로 설정되었습니다. + NextButtonText: 다음 + CancelButtonText: 취소 + +InviteUser: + Title: 사용자 활성화 + Description: 아래 코드를 통해 이메일을 인증하고 비밀번호를 설정하세요. + CodeLabel: 코드 + NewPasswordLabel: 새 비밀번호 + NewPasswordConfirm: 비밀번호 확인 + NextButtonText: 다음 + ResendButtonText: 코드 재전송 + +InitMFAPrompt: + Title: 2단계 인증 설정 + Description: 2단계 인증은 사용자 계정에 추가 보안을 제공합니다. 이를 통해 계정 접근이 본인에게만 허용됩니다. + Provider0: "인증 앱 (예: Google/Microsoft Authenticator, Authy)" + Provider1: "장치 종속 (예: FaceID, Windows Hello, 지문)" + Provider3: OTP SMS + Provider4: OTP 이메일 + NextButtonText: 다음 + SkipButtonText: 건너뛰기 + +InitMFAOTP: + Title: 2단계 인증 + Description: 2단계 인증을 설정하세요. 인증 앱이 없으면 다운로드하세요. + OTPDescription: "인증 앱으로 코드를 스캔하거나 비밀을 복사하여 아래에 생성된 코드를 입력하세요 (예: Google/Microsoft Authenticator, Authy)." + SecretLabel: 비밀 + CodeLabel: 코드 + NextButtonText: 다음 + CancelButtonText: 취소 + +InitMFAOTPSMS: + Title: 2단계 인증 + DescriptionPhone: 2단계 인증을 설정하세요. 전화번호를 입력하여 인증하세요. + DescriptionCode: 2단계 인증을 설정하세요. 받은 코드를 입력하여 전화번호를 인증하세요. + PhoneLabel: 전화번호 + CodeLabel: 코드 + EditButtonText: 수정 + ResendButtonText: 코드 재전송 + NextButtonText: 다음 + +InitMFAU2F: + Title: 보안 키 추가 + Description: 보안 키는 휴대폰에 내장되거나, 블루투스 또는 컴퓨터 USB 포트에 직접 연결할 수 있는 인증 방법입니다. + TokenNameLabel: 보안 키/장치 이름 + NotSupported: "WebAuthN이 브라우저에서 지원되지 않습니다. 최신 상태인지 확인하거나 다른 브라우저를 사용하세요 (예: Chrome, Safari, Firefox)." + RegisterTokenButtonText: 보안 키 추가 + ErrorRetry: 다시 시도, 새 챌린지 생성 또는 다른 방법 선택. + +InitMFADone: + Title: 2단계 인증 완료 + Description: 축하합니다! 2단계 인증을 성공적으로 설정하여 계정을 더욱 안전하게 보호했습니다. 로그인 시마다 이 인증이 필요합니다. + NextButtonText: 다음 + CancelButtonText: 취소 + +MFAProvider: + Provider0: "인증 앱 (예: Google/Microsoft Authenticator, Authy)" + Provider1: "장치 종속 (예: FaceID, Windows Hello, 지문)" + Provider3: OTP SMS + Provider4: OTP 이메일 + ChooseOther: 다른 옵션 선택 + +VerifyMFAOTP: + Title: 2단계 인증 확인 + Description: 2단계 인증을 확인하세요 + CodeLabel: 코드 + NextButtonText: 다음 + +VerifyOTP: + Title: 2단계 인증 확인 + Description: 2단계 인증을 확인하세요 + CodeLabel: 코드 + ResendButtonText: 코드 재전송 + NextButtonText: 다음 + +VerifyMFAU2F: + Title: 2단계 인증 + Description: "등록된 장치로 2단계 인증을 진행하세요 (예: FaceID, Windows Hello, 지문)" + NotSupported: "WebAuthN이 브라우저에서 지원되지 않습니다. 최신 버전을 사용하거나 지원되는 다른 브라우저로 변경하세요 (예: Chrome, Safari, Firefox)." + ErrorRetry: 다시 시도, 새 요청 생성 또는 다른 방법 선택. + ValidateTokenButtonText: 2단계 인증 + +Passwordless: + Title: 비밀번호 없이 로그인 + Description: "FaceID, Windows Hello, 지문과 같은 장치에서 제공하는 인증 방법으로 로그인하세요." + NotSupported: "WebAuthN이 브라우저에서 지원되지 않습니다. 최신 상태인지 확인하거나 다른 브라우저를 사용하세요 (예: Chrome, Safari, Firefox)." + ErrorRetry: 다시 시도, 새 챌린지 생성 또는 다른 방법 선택. + LoginWithPwButtonText: 비밀번호로 로그인 + ValidateTokenButtonText: 비밀번호 없이 로그인 + +PasswordlessPrompt: + Title: 비밀번호 없는 로그인 설정 + Description: "비밀번호 없는 로그인을 설정하시겠습니까? (FaceID, Windows Hello, 지문과 같은 장치 인증 방법)" + DescriptionInit: 비밀번호 없는 로그인을 설정해야 합니다. 기기 등록을 위해 제공된 링크를 사용하세요. + PasswordlessButtonText: 비밀번호 없이 사용 + NextButtonText: 다음 + SkipButtonText: 건너뛰기 + +PasswordlessRegistration: + Title: 비밀번호 없는 로그인 설정 + Description: "장치 이름을 입력한 후 아래의 '비밀번호 없이 등록' 버튼을 클릭하여 인증을 추가하세요 (예: 내 휴대폰, MacBook 등)." + TokenNameLabel: 장치 이름 + NotSupported: "WebAuthN이 브라우저에서 지원되지 않습니다. 최신 상태인지 확인하거나 다른 브라우저를 사용하세요 (예: Chrome, Safari, Firefox)." + RegisterTokenButtonText: 비밀번호 없이 등록 + ErrorRetry: 다시 시도, 새 챌린지 생성 또는 다른 방법 선택. + +PasswordlessRegistrationDone: + Title: 비밀번호 없는 로그인 설정 완료 + Description: 비밀번호 없는 장치가 성공적으로 추가되었습니다. + DescriptionClose: 이제 이 창을 닫을 수 있습니다. + NextButtonText: 다음 + CancelButtonText: 취소 + +PasswordChange: + Title: 비밀번호 변경 + Description: 비밀번호를 변경하세요. 기존 비밀번호와 새 비밀번호를 입력하세요. + ExpiredDescription: 비밀번호가 만료되어 변경이 필요합니다. 기존 비밀번호와 새 비밀번호를 입력하세요. + OldPasswordLabel: 기존 비밀번호 + NewPasswordLabel: 새 비밀번호 + NewPasswordConfirmLabel: 비밀번호 확인 + CancelButtonText: 취소 + NextButtonText: 다음 + Footer: 푸터 + +PasswordChangeDone: + Title: 비밀번호 변경 완료 + Description: 비밀번호가 성공적으로 변경되었습니다. + NextButtonText: 다음 + +PasswordResetDone: + Title: 비밀번호 재설정 링크 발송됨 + Description: 비밀번호를 재설정하려면 이메일을 확인하세요. + NextButtonText: 다음 + +EmailVerification: + Title: 이메일 인증 + Description: 이메일 인증을 위해 전송된 코드를 아래 양식에 입력하세요. + CodeLabel: 코드 + NextButtonText: 다음 + ResendButtonText: 코드 재전송 + +EmailVerificationDone: + Title: 이메일 인증 완료 + Description: 이메일 주소가 성공적으로 인증되었습니다. + NextButtonText: 다음 + CancelButtonText: 취소 + LoginButtonText: 로그인 + +RegisterOption: + Title: 등록 옵션 + Description: 등록 방법을 선택하세요 + RegisterUsernamePasswordButtonText: 사용자 이름과 비밀번호로 등록 + ExternalLoginDescription: 또는 외부 사용자로 등록 + LoginButtonText: 로그인 + +RegistrationUser: + Title: 등록 + Description: 사용자 정보를 입력하세요. 이메일 주소는 로그인 이름으로 사용됩니다. + DescriptionOrgRegister: 사용자 정보를 입력하세요. + EmailLabel: 이메일 + UsernameLabel: 사용자 이름 + FirstnameLabel: 이름 + LastnameLabel: 성 + LanguageLabel: 언어 + German: Deutsch + English: English + Italian: Italiano + French: Français + Chinese: 简体中文 + Polish: Polski + Japanese: 日本語 + Spanish: Español + Bulgarian: Български + Portuguese: Português + Macedonian: Македонски + Czech: Čeština + Russian: Русский + Dutch: Nederlands + Swedish: Svenska + Indonesian: Bahasa Indonesia + Hungarian: Magyar + Korean: 한국어 + GenderLabel: 성별 + Female: 여성 + Male: 남성 + Diverse: 기타 / X + PasswordLabel: 비밀번호 + PasswordConfirmLabel: 비밀번호 확인 + TosAndPrivacyLabel: 동의사항 + TosConfirm: 이용 약관에 동의합니다. + TosLinkText: 이용 약관 + PrivacyConfirm: 개인정보 수집 및 이용에 동의합니다. + PrivacyLinkText: 개인정보처리방침 + ExternalLogin: 또는 외부 사용자로 등록 + BackButtonText: 로그인 + NextButtonText: 다음 + +ExternalRegistrationUserOverview: + Title: 외부 사용자 등록 + Description: 선택한 제공자에서 사용자 정보를 가져왔습니다. 이제 정보를 수정하거나 완성할 수 있습니다. + EmailLabel: 이메일 + UsernameLabel: 사용자 이름 + FirstnameLabel: 이름 + LastnameLabel: 성 + NicknameLabel: 닉네임 + PhoneLabel: 전화번호 + LanguageLabel: 언어 + German: Deutsch + English: English + Italian: Italiano + French: Français + Chinese: 简体中文 + Polish: Polski + Japanese: 日本語 + Spanish: Español + Bulgarian: Български + Portuguese: Português + Macedonian: Македонски + Czech: Čeština + Russian: Русский + Dutch: Nederlands + Swedish: Svenska + Indonesian: Bahasa Indonesia + Hungarian: Magyar + Korean: 한국어 + TosAndPrivacyLabel: 동의사항 + TosConfirm: 이용 약관에 동의합니다. + TosLinkText: 이용 약관 + PrivacyConfirm: 개인정보 수집 및 이용에 동의합니다. + PrivacyLinkText: 개인정보처리방침 + ExternalLogin: 또는 외부 사용자로 등록 + BackButtonText: 뒤로 + NextButtonText: 저장 + +RegistrationOrg: + Title: 조직 등록 + Description: 조직 이름과 사용자 정보를 입력하세요. + OrgNameLabel: 조직 이름 + EmailLabel: 이메일 + UsernameLabel: 사용자 이름 + FirstnameLabel: 이름 + LastnameLabel: 성 + PasswordLabel: 비밀번호 + PasswordConfirmLabel: 비밀번호 확인 + TosAndPrivacyLabel: 동의사항 + TosConfirm: 이용 약관에 동의합니다. + TosLinkText: 이용 약관 + PrivacyConfirm: 개인정보 수집 및 이용에 동의합니다. + PrivacyLinkText: 개인정보처리방침 + SaveButtonText: 조직 생성 + +LoginSuccess: + Title: 로그인 성공 + AutoRedirectDescription: 자동으로 애플리케이션으로 리디렉션됩니다. 그렇지 않으면 아래 버튼을 클릭하세요. 이후 창을 닫아도 됩니다. + RedirectedDescription: 이제 이 창을 닫을 수 있습니다. + NextButtonText: 다음 + +LogoutDone: + Title: 로그아웃 완료 + Description: 성공적으로 로그아웃되었습니다. + LoginButtonText: 로그인 + +LinkingUserPrompt: + Title: 기존 사용자 발견 + Description: "기존 계정을 연결하시겠습니까:" + LinkButtonText: 연결 + OtherButtonText: 다른 옵션 + +LinkingUsersDone: + Title: 사용자 연결 + Description: 사용자가 연결되었습니다. + CancelButtonText: 취소 + NextButtonText: 다음 + +ExternalNotFound: + Title: 외부 사용자 찾을 수 없음 + Description: 외부 사용자를 찾을 수 없습니다. 사용자 계정을 연결하거나 새 계정을 자동 등록하시겠습니까? + LinkButtonText: 연결 + AutoRegisterButtonText: 등록 + TosAndPrivacyLabel: 동의사항 + TosConfirm: 이용 약관에 동의합니다. + TosLinkText: 이용 약관 + PrivacyConfirm: 개인정보 수집 및 이용에 동의합니다. + PrivacyLinkText: 개인정보처리방침 + German: Deutsch + English: English + Italian: Italiano + French: Français + Chinese: 简体中文 + Polish: Polski + Japanese: 日本語 + Spanish: Español + Bulgarian: Български + Portuguese: Português + Macedonian: Македонски + Czech: Čeština + Russian: Русский + Dutch: Nederlands + Swedish: Svenska + Indonesian: Bahasa Indonesia + Hungarian: Magyar + Korean: 한국어 +DeviceAuth: + Title: 기기 인증 + UserCode: + Label: 사용자 코드 + Description: 기기에서 표시된 사용자 코드를 입력하세요. + ButtonNext: 다음 + Action: + Description: 기기 접근 권한을 부여하세요. + GrantDevice: 기기에게 권한을 부여하려고 합니다 + AccessToScopes: 다음 범위에 접근 권한이 있습니다 + Button: + Allow: 허용 + Deny: 거부 + Done: + Description: 완료. + Approved: 기기 인증이 승인되었습니다. 이제 기기로 돌아가세요. + Denied: 기기 인증이 거부되었습니다. 이제 기기로 돌아가세요. + +Footer: + PoweredBy: 제공자 + Tos: 이용 약관 + PrivacyPolicy: 개인정보처리방침 + Help: 도움말 + SupportEmail: 지원 이메일 + +SignIn: "{{.Provider}}로 로그인" + +Errors: + Internal: 내부 오류가 발생했습니다 + AuthRequest: + NotFound: 인증 요청을 찾을 수 없습니다 + UserAgentNotCorresponding: 사용자 에이전트가 일치하지 않습니다 + UserAgentNotFound: 사용자 에이전트 ID를 찾을 수 없습니다 + TokenNotFound: 토큰을 찾을 수 없습니다 + RequestTypeNotSupported: 요청 유형이 지원되지 않습니다 + MissingParameters: 필수 매개변수가 없습니다 + User: + NotFound: 사용자를 찾을 수 없습니다 + AlreadyExists: 사용자가 이미 존재합니다 + Inactive: 사용자가 비활성화되었습니다 + NotFoundOnOrg: 선택된 조직에서 사용자를 찾을 수 없습니다 + NotAllowedOrg: 사용자는 필수 조직의 멤버가 아닙니다 + NotMatchingUserID: 사용자와 인증 요청의 사용자가 일치하지 않습니다 + UserIDMissing: 사용자 ID가 비어 있습니다 + Invalid: 잘못된 사용자 데이터입니다 + DomainNotAllowedAsUsername: 도메인이 이미 예약되어 사용할 수 없습니다 + NotAllowedToLink: 외부 로그인 제공자와 연결할 수 없습니다 + Profile: + NotFound: 프로필을 찾을 수 없습니다 + NotChanged: 프로필이 변경되지 않았습니다 + Empty: 프로필이 비어 있습니다 + FirstNameEmpty: 프로필에 이름이 비어 있습니다 + LastNameEmpty: 프로필에 성이 비어 있습니다 + IDMissing: 프로필 ID가 없습니다 + Email: + NotFound: 이메일을 찾을 수 없습니다 + Invalid: 잘못된 이메일입니다 + AlreadyVerified: 이메일이 이미 인증되었습니다 + NotChanged: 이메일이 변경되지 않았습니다 + Empty: 이메일이 비어 있습니다 + IDMissing: 이메일 ID가 없습니다 + Phone: + NotFound: 전화번호를 찾을 수 없습니다 + Invalid: 잘못된 전화번호입니다 + AlreadyVerified: 전화번호가 이미 인증되었습니다 + Empty: 전화번호가 비어 있습니다 + NotChanged: 전화번호가 변경되지 않았습니다 + Address: + NotFound: 주소를 찾을 수 없습니다 + NotChanged: 주소가 변경되지 않았습니다 + Username: + AlreadyExists: 사용자 이름이 이미 사용 중입니다 + Reserved: 사용자 이름이 이미 예약되었습니다 + Empty: 사용자 이름이 비어 있습니다 + Password: + ConfirmationWrong: 비밀번호 확인이 일치하지 않습니다 + Empty: 비밀번호가 비어 있습니다 + Invalid: 잘못된 비밀번호입니다 + InvalidAndLocked: 비밀번호가 잘못되었고 사용자가 잠겼습니다. 관리자에게 문의하세요. + NotChanged: 새 비밀번호는 현재 비밀번호와 다르게 설정해야 합니다 + UsernameOrPassword: + Invalid: 사용자 이름 또는 비밀번호가 잘못되었습니다 + PasswordComplexityPolicy: + NotFound: 비밀번호 정책을 찾을 수 없습니다 + MinLength: 비밀번호가 너무 짧습니다 + HasLower: 비밀번호에 소문자가 포함되어야 합니다 + HasUpper: 비밀번호에 대문자가 포함되어야 합니다 + HasNumber: 비밀번호에 숫자가 포함되어야 합니다 + HasSymbol: 비밀번호에 기호가 포함되어야 합니다 + Code: + Expired: 코드가 만료되었습니다 + Invalid: 잘못된 코드입니다 + Empty: 코드가 비어 있습니다 + CryptoCodeNil: 암호화 코드가 없습니다 + NotFound: 코드를 찾을 수 없습니다 + GeneratorAlgNotSupported: 지원되지 않는 생성 알고리즘입니다 + EmailVerify: + UserIDEmpty: 사용자 ID가 비어 있습니다 + ExternalData: + CouldNotRead: 외부 데이터를 올바르게 읽을 수 없습니다 + MFA: + NoProviders: 사용 가능한 다중 인증 제공자가 없습니다 + OTP: + AlreadyReady: 다중 인증 OTP(일회용 비밀번호)가 이미 설정되었습니다 + NotExisting: 다중 인증 OTP(일회용 비밀번호)가 존재하지 않습니다 + InvalidCode: 잘못된 코드입니다 + NotReady: 다중 인증 OTP(일회용 비밀번호)가 준비되지 않았습니다 + Locked: 사용자가 잠겼습니다 + SomethingWentWrong: 문제가 발생했습니다 + NotActive: 사용자가 활성 상태가 아닙니다 + ExternalIDP: + IDPTypeNotImplemented: IDP 유형이 구현되지 않았습니다 + NotAllowed: 외부 로그인 제공자가 허용되지 않습니다 + IDPConfigIDEmpty: ID 제공자 ID가 비어 있습니다 + ExternalUserIDEmpty: 외부 사용자 ID가 비어 있습니다 + UserDisplayNameEmpty: 사용자 표시 이름이 비어 있습니다 + NoExternalUserData: 외부 사용자 데이터를 받을 수 없습니다 + CreationNotAllowed: 이 제공자에서는 새 사용자 생성을 허용하지 않습니다 + LinkingNotAllowed: 이 제공자에서는 사용자를 연결할 수 없습니다 + NoOptionAllowed: 이 제공자에서는 생성과 연결이 모두 허용되지 않습니다. 관리자에게 문의하세요. + GrantRequired: 로그인 불가. 사용자는 애플리케이션에서 최소한 하나의 권한이 필요합니다. 관리자에게 문의하세요. + ProjectRequired: 로그인 불가. 사용자의 조직이 프로젝트에 허가되어야 합니다. 관리자에게 문의하세요. + IdentityProvider: + InvalidConfig: ID 제공자 설정이 잘못되었습니다 + IAM: + LockoutPolicy: + NotExisting: 잠금 정책이 존재하지 않습니다 + Org: + LoginPolicy: + RegistrationNotAllowed: 등록이 허용되지 않습니다 + DeviceAuth: + NotExisting: 사용자 코드가 존재하지 않습니다 + +optional: (선택 사항) diff --git a/internal/api/ui/login/static/i18n/mk.yaml b/internal/api/ui/login/static/i18n/mk.yaml index 2424701077..dbb988a0a6 100644 --- a/internal/api/ui/login/static/i18n/mk.yaml +++ b/internal/api/ui/login/static/i18n/mk.yaml @@ -264,6 +264,7 @@ RegistrationUser: Swedish: Svenska Indonesian: Bahasa Indonesia Hungarian: Magyar + Korean: 한국어 GenderLabel: Пол Female: Женски Male: Машки @@ -306,6 +307,7 @@ ExternalRegistrationUserOverview: Swedish: Svenska Indonesian: Bahasa Indonesia Hungarian: Magyar + Korean: 한국어 TosAndPrivacyLabel: Правила и услови TosConfirm: Се согласувам со TosLinkText: правилата за користење @@ -382,6 +384,7 @@ ExternalNotFound: Swedish: Svenska Indonesian: Bahasa Indonesia Hungarian: Magyar + Korean: 한국어 DeviceAuth: Title: Овластување преку уред diff --git a/internal/api/ui/login/static/i18n/nl.yaml b/internal/api/ui/login/static/i18n/nl.yaml index 017e1a52d8..3bbcee94b6 100644 --- a/internal/api/ui/login/static/i18n/nl.yaml +++ b/internal/api/ui/login/static/i18n/nl.yaml @@ -264,6 +264,7 @@ RegistrationUser: Swedish: Svenska Indonesian: Bahasa Indonesia Hungarian: Magyar + Korean: 한국어 GenderLabel: Geslacht Female: Vrouw Male: Man @@ -306,6 +307,7 @@ ExternalRegistrationUserOverview: Swedish: Svenska Indonesian: Bahasa Indonesia Hungarian: Magyar + Korean: 한국어 TosAndPrivacyLabel: Algemene voorwaarden TosConfirm: Ik accepteer de TosLinkText: AV @@ -382,6 +384,7 @@ ExternalNotFound: Swedish: Svenska Indonesian: Bahasa Indonesia Hungarian: Magyar + Korean: 한국어 DeviceAuth: Title: Apparaat Autorisatie UserCode: diff --git a/internal/api/ui/login/static/i18n/pl.yaml b/internal/api/ui/login/static/i18n/pl.yaml index 367da1a2cd..2c8b4fddf0 100644 --- a/internal/api/ui/login/static/i18n/pl.yaml +++ b/internal/api/ui/login/static/i18n/pl.yaml @@ -264,6 +264,7 @@ RegistrationUser: Swedish: Svenska Indonesian: Bahasa Indonesia Hungarian: Magyar + Korean: 한국어 GenderLabel: Płeć Female: Kobieta Male: Mężczyzna @@ -306,6 +307,7 @@ ExternalRegistrationUserOverview: Swedish: Svenska Indonesian: Bahasa Indonesia Hungarian: Magyar + Korean: 한국어 TosAndPrivacyLabel: Warunki i zasady TosConfirm: Akceptuję TosLinkText: Warunki korzystania @@ -382,6 +384,7 @@ ExternalNotFound: Swedish: Svenska Indonesian: Bahasa Indonesia Hungarian: Magyar + Korean: 한국어 DeviceAuth: Title: Autoryzacja urządzenia diff --git a/internal/api/ui/login/static/i18n/pt.yaml b/internal/api/ui/login/static/i18n/pt.yaml index b0c6cb6c8e..f03f120ed8 100644 --- a/internal/api/ui/login/static/i18n/pt.yaml +++ b/internal/api/ui/login/static/i18n/pt.yaml @@ -260,6 +260,7 @@ RegistrationUser: Swedish: Svenska Indonesian: Bahasa Indonesia Hungarian: Magyar + Korean: 한국어 GenderLabel: Gênero Female: Feminino Male: Masculino @@ -302,6 +303,7 @@ ExternalRegistrationUserOverview: Swedish: Svenska Indonesian: Bahasa Indonesia Hungarian: Magyar + Korean: 한국어 TosAndPrivacyLabel: Termos e condições TosConfirm: Eu aceito os TosLinkText: termos de serviço @@ -378,6 +380,7 @@ ExternalNotFound: Swedish: Svenska Indonesian: Bahasa Indonesia Hungarian: Magyar + Korean: 한국어 DeviceAuth: Title: Autorização de dispositivo diff --git a/internal/api/ui/login/static/i18n/ru.yaml b/internal/api/ui/login/static/i18n/ru.yaml index e6edc967fb..03239e0612 100644 --- a/internal/api/ui/login/static/i18n/ru.yaml +++ b/internal/api/ui/login/static/i18n/ru.yaml @@ -264,6 +264,7 @@ RegistrationUser: Swedish: Svenska Indonesian: Bahasa Indonesia Hungarian: Magyar + Korean: 한국어 GenderLabel: Пол Female: Женский Male: Мужской @@ -306,6 +307,7 @@ ExternalRegistrationUserOverview: Swedish: Svenska Indonesian: Bahasa Indonesia Hungarian: Magyar + Korean: 한국어 TosAndPrivacyLabel: Условия использования TosConfirm: Я согласен с TosLinkText: Пользовательским соглашением @@ -382,6 +384,7 @@ ExternalNotFound: Swedish: Svenska Indonesian: Bahasa Indonesia Hungarian: Magyar + Korean: 한국어 DeviceAuth: Title: Авторизация устройства diff --git a/internal/api/ui/login/static/i18n/sv.yaml b/internal/api/ui/login/static/i18n/sv.yaml index 6d1d5ef8ac..26fee23551 100644 --- a/internal/api/ui/login/static/i18n/sv.yaml +++ b/internal/api/ui/login/static/i18n/sv.yaml @@ -264,6 +264,7 @@ RegistrationUser: Swedish: Svenska Indonesian: Bahasa Indonesia Hungarian: Magyar + Korean: 한국어 GenderLabel: Kön Female: Man Male: Kvinna @@ -306,6 +307,7 @@ ExternalRegistrationUserOverview: Swedish: Svenska Indonesian: Bahasa Indonesia Hungarian: Magyar + Korean: 한국어 TosAndPrivacyLabel: Användarvillkor TosConfirm: Jag accepterar TosLinkText: Användarvillkoren @@ -382,6 +384,7 @@ ExternalNotFound: Swedish: Svenska Indonesian: Bahasa Indonesia Hungarian: Magyar + Korean: 한국어 DeviceAuth: Title: Tillgång från hårdvaruenhet UserCode: diff --git a/internal/api/ui/login/static/i18n/zh.yaml b/internal/api/ui/login/static/i18n/zh.yaml index dbbe969b6e..79db3c020e 100644 --- a/internal/api/ui/login/static/i18n/zh.yaml +++ b/internal/api/ui/login/static/i18n/zh.yaml @@ -264,6 +264,7 @@ RegistrationUser: Swedish: Svenska Indonesian: Bahasa Indonesia Hungarian: Magyar + Korean: 한국어 GenderLabel: 性别 Female: 女性 Male: 男性 @@ -306,6 +307,7 @@ ExternalRegistrationUserOverview: Swedish: Svenska Indonesian: Bahasa Indonesia Hungarian: Magyar + Korean: 한국어 TosAndPrivacyLabel: 条款和条款 TosConfirm: 我接受 TosLinkText: 服务条款 @@ -382,6 +384,7 @@ ExternalNotFound: Swedish: Svenska Indonesian: Bahasa Indonesia Hungarian: Magyar + Korean: 한국어 DeviceAuth: Title: 设备授权 UserCode: diff --git a/internal/api/ui/login/static/templates/external_not_found_option.html b/internal/api/ui/login/static/templates/external_not_found_option.html index 9173671b16..33bcaeb4e0 100644 --- a/internal/api/ui/login/static/templates/external_not_found_option.html +++ b/internal/api/ui/login/static/templates/external_not_found_option.html @@ -98,6 +98,8 @@ + diff --git a/internal/notification/static/i18n/ko.yaml b/internal/notification/static/i18n/ko.yaml new file mode 100644 index 0000000000..6bc160e3ec --- /dev/null +++ b/internal/notification/static/i18n/ko.yaml @@ -0,0 +1,68 @@ +InitCode: + Title: 사용자 초기화 + PreHeader: 사용자 초기화 + Subject: 사용자 초기화 + Greeting: 안녕하세요, {{.DisplayName}}님, + Text: 사용자가 생성되었습니다. 로그인하려면 사용자 이름 {{.PreferredLoginName}}을 사용하세요. 초기화 프로세스를 완료하려면 아래 버튼을 클릭하세요. (코드 {{.Code}}) 이 메일을 요청하지 않으셨다면 무시하셔도 됩니다. + ButtonText: 초기화 완료 +PasswordReset: + Title: 비밀번호 재설정 + PreHeader: 비밀번호 재설정 + Subject: 비밀번호 재설정 + Greeting: 안녕하세요, {{.DisplayName}}님, + Text: 비밀번호 재설정 요청을 받았습니다. 비밀번호를 재설정하려면 아래 버튼을 사용하세요. (코드 {{.Code}}) 이 메일을 요청하지 않으셨다면 무시하셔도 됩니다. + ButtonText: 비밀번호 재설정 +VerifyEmail: + Title: 이메일 인증 + PreHeader: 이메일 인증 + Subject: 이메일 인증 + Greeting: 안녕하세요, {{.DisplayName}}님, + Text: 새 이메일이 추가되었습니다. 이메일을 인증하려면 아래 버튼을 사용하세요. (코드 {{.Code}}) 새로운 이메일을 추가하지 않으셨다면 이 메일을 무시하세요. + ButtonText: 이메일 인증 +VerifyPhone: + Title: 전화번호 인증 + PreHeader: 전화번호 인증 + Subject: 전화번호 인증 + Greeting: 안녕하세요, {{.DisplayName}}님, + Text: 새 전화번호가 추가되었습니다. 다음 코드를 사용하여 인증하세요 {{.Code}} + ButtonText: 전화번호 인증 +VerifyEmailOTP: + Title: 일회용 비밀번호 인증 + PreHeader: 일회용 비밀번호 인증 + Subject: 일회용 비밀번호 인증 + Greeting: 안녕하세요, {{.DisplayName}}님, + Text: 인증하려면 다음 일회용 비밀번호 {{.OTP}}를 5분 이내에 사용하거나 "인증" 버튼을 클릭하세요. + ButtonText: 인증 +VerifySMSOTP: + Text: >- + {{.OTP}}는 {{ .Domain }}의 일회용 비밀번호입니다. 다음 {{.Expiry}} 이내에 사용하세요. + + @{{.Domain}} #{{.OTP}} +DomainClaimed: + Title: 조직에서 도메인을 소유하게 되었습니다 + PreHeader: 이메일 / 사용자 이름 변경 + Subject: 조직에서 도메인을 소유하게 되었습니다 + Greeting: 안녕하세요, {{.DisplayName}}님, + Text: 도메인 {{.Domain}} 은(는) 조직에서 소유하게 되었습니다. 현재 사용자인 {{.Username}}은 이 조직의 구성원이 아닙니다. 따라서 로그인할 때 이메일을 변경해야 합니다. 이 로그인용으로 임시 사용자 이름 ({{.TempUsername}})을 생성했습니다. + ButtonText: 로그인 +PasswordlessRegistration: + Title: 비밀번호 없는 로그인 추가 + PreHeader: 비밀번호 없는 로그인 추가 + Subject: 비밀번호 없는 로그인 추가 + Greeting: 안녕하세요, {{.DisplayName}}님, + Text: 비밀번호 없는 로그인을 위한 토큰 추가 요청을 받았습니다. 비밀번호 없는 로그인을 위해 토큰 또는 장치를 추가하려면 아래 버튼을 클릭하세요. + ButtonText: 비밀번호 없는 로그인 추가 +PasswordChange: + Title: 사용자 비밀번호 변경됨 + PreHeader: 비밀번호 변경 + Subject: 사용자 비밀번호 변경됨 + Greeting: 안녕하세요, {{.DisplayName}}님, + Text: 사용자의 비밀번호가 변경되었습니다. 본인이 직접 변경하지 않으셨다면 즉시 비밀번호를 재설정하시기 바랍니다. + ButtonText: 로그인 +InviteUser: + Title: "{{.ApplicationName}} 초대" + PreHeader: "{{.ApplicationName}} 초대" + Subject: "{{.ApplicationName}} 초대" + Greeting: 안녕하세요, {{.DisplayName}}님, + Text: "{{.ApplicationName}}에 초대되었습니다. 초대 프로세스를 완료하려면 아래 버튼을 클릭하세요. 이 메일을 요청하지 않으셨다면 무시하셔도 됩니다." + ButtonText: 초대 수락 diff --git a/internal/static/i18n/ko.yaml b/internal/static/i18n/ko.yaml new file mode 100644 index 0000000000..fda5171de2 --- /dev/null +++ b/internal/static/i18n/ko.yaml @@ -0,0 +1,1406 @@ +Errors: + Internal: 내부 오류가 발생했습니다 + NoChangesFound: 변경 사항 없음 + OriginNotAllowed: 이 "Origin"은 허용되지 않습니다 + IDMissing: ID가 누락되었습니다 + ResourceOwnerMissing: 리소스 소유 조직이 누락되었습니다 + RemoveFailed: 제거할 수 없습니다 + ProjectionName: + Invalid: 잘못된 투영 이름입니다 + Assets: + EmptyKey: 자산 키가 비어 있습니다 + Store: + NotInitialized: 자산 저장소가 초기화되지 않았습니다 + NotConfigured: 자산 저장소가 구성되지 않았습니다 + Bucket: + Internal: 버킷 생성 중 내부 오류 발생 + AlreadyExists: 버킷이 이미 존재합니다 + CreateFailed: 버킷이 생성되지 않았습니다 + ListFailed: 버킷을 읽을 수 없습니다 + RemoveFailed: 버킷이 삭제되지 않았습니다 + SetPublicFailed: 버킷을 공개로 설정할 수 없습니다 + Object: + PutFailed: 객체가 생성되지 않았습니다 + GetFailed: 객체를 읽을 수 없습니다 + NotFound: 객체를 찾을 수 없습니다 + PresignedTokenFailed: 서명된 토큰을 생성할 수 없습니다 + ListFailed: 객체 목록을 읽을 수 없습니다 + RemoveFailed: 객체를 삭제할 수 없습니다 + Limit: + ExceedsDefault: 제한이 기본 제한을 초과합니다 + Limits: + NotFound: 제한을 찾을 수 없습니다 + NoneSpecified: 지정된 제한이 없습니다 + Instance: + Blocked: 인스턴스가 차단되었습니다 + Restrictions: + NoneSpecified: 지정된 제한이 없습니다 + DefaultLanguageMustBeAllowed: 기본 언어는 허용되어야 합니다 + Language: + NotParsed: 언어를 구문 분석할 수 없습니다 + NotSupported: 지원되지 않는 언어입니다 + NotAllowed: 허용되지 않는 언어입니다 + Undefined: 언어가 정의되지 않았습니다 + Duplicate: 중복된 언어가 있습니다 + OIDCSettings: + NotFound: OIDC 구성을 찾을 수 없습니다 + AlreadyExists: OIDC 구성이 이미 존재합니다 + SecretGenerator: + AlreadyExists: 시크릿 생성기가 이미 존재합니다 + TypeMissing: 시크릿 생성기 유형이 누락되었습니다 + NotFound: 시크릿 생성기를 찾을 수 없습니다 + SMSConfig: + NotFound: SMS 구성을 찾을 수 없습니다 + AlreadyActive: SMS 구성이 이미 활성화되었습니다 + AlreadyDeactivated: SMS 구성이 이미 비활성화되었습니다 + NotExternalVerification: SMS 구성은 코드 검증을 지원하지 않습니다 + SMTP: + NotEmailMessage: 메시지가 이메일 메시지가 아닙니다 + RequiredAttributes: subject, recipients 및 content가 설정되어야 하지만 일부 또는 모두 비어 있습니다 + CouldNotSplit: smtp 연결을 위해 호스트와 포트를 분할할 수 없습니다 + CouldNotDial: SMTP 서버에 연결할 수 없습니다. 포트 또는 방화벽 문제를 확인하십시오... + CouldNotDialTLS: TLS를 사용하여 SMTP 서버에 연결할 수 없습니다. 포트 또는 방화벽 문제를 확인하십시오... + CouldNotCreateClient: smtp 클라이언트를 생성할 수 없습니다 + CouldNotStartTLS: TLS를 시작할 수 없습니다 + CouldNotAuth: smtp 인증을 추가할 수 없습니다. 사용자 이름과 비밀번호가 정확한지 확인하십시오. 정확하다면 제공자가 ZITADEL에서 지원하지 않는 인증 방법을 요구할 수 있습니다 + CouldNotSetSender: 발신자를 설정할 수 없습니다 + CouldNotSetRecipient: 수신자를 설정할 수 없습니다 + SMTPConfig: + TestPassword: 테스트할 비밀번호가 없습니다 + NotFound: SMTP 구성을 찾을 수 없습니다 + AlreadyExists: SMTP 구성이 이미 존재합니다 + AlreadyDeactivated: SMTP 구성이 이미 비활성화되었습니다 + SenderAdressNotCustomDomain: 발신자 주소는 인스턴스에서 사용자 정의 도메인으로 구성되어야 합니다 + TestEmailNotFound: 테스트할 이메일 주소가 없습니다 + Notification: + NoDomain: 메시지에 대한 도메인을 찾을 수 없습니다 + User: + NotFound: 사용자를 찾을 수 없습니다 + AlreadyExists: 사용자가 이미 존재합니다 + NotFoundOnOrg: 선택한 조직에서 사용자를 찾을 수 없습니다 + NotAllowedOrg: 사용자가 필수 조직의 구성원이 아닙니다 + UserIDMissing: 사용자 ID가 누락되었습니다 + UserIDWrong: "요청한 사용자와 인증된 사용자가 일치하지 않습니다" + DomainPolicyNil: 조직 정책이 비어 있습니다 + EmailAsUsernameNotAllowed: 이메일을 사용자 이름으로 사용할 수 없습니다 + Invalid: 사용자 데이터가 잘못되었습니다 + DomainNotAllowedAsUsername: 도메인이 이미 예약되어 사용할 수 없습니다 + AlreadyInactive: 사용자가 이미 비활성 상태입니다 + NotInactive: 사용자가 비활성 상태가 아닙니다 + CantDeactivateInitial: 초기 상태의 사용자는 비활성화할 수 없으며 삭제만 가능합니다 + ShouldBeActiveOrInitial: 사용자가 활성 상태이거나 초기 상태가 아닙니다 + AlreadyInitialised: 사용자가 이미 초기화되었습니다 + NotInitialised: 사용자가 아직 초기화되지 않았습니다 + NotLocked: 사용자가 잠겨 있지 않습니다 + NoChanges: 변경 사항이 없습니다 + InitCodeNotFound: 초기화 코드를 찾을 수 없습니다 + UsernameNotChanged: 사용자 이름이 변경되지 않았습니다 + InvalidURLTemplate: URL 템플릿이 잘못되었습니다 + Profile: + NotFound: 프로필을 찾을 수 없습니다 + NotChanged: 프로필이 변경되지 않았습니다 + Empty: 프로필이 비어 있습니다 + FirstNameEmpty: 프로필의 이름이 비어 있습니다 + LastNameEmpty: 프로필의 성이 비어 있습니다 + IDMissing: 프로필 ID가 누락되었습니다 + Email: + NotFound: 이메일을 찾을 수 없습니다 + Invalid: 이메일이 잘못되었습니다 + AlreadyVerified: 이메일이 이미 인증되었습니다 + NotChanged: 이메일이 변경되지 않았습니다 + Empty: 이메일이 비어 있습니다 + IDMissing: 이메일 ID가 누락되었습니다 + Phone: + NotFound: 전화번호를 찾을 수 없습니다 + Invalid: 전화번호가 잘못되었습니다 + AlreadyVerified: 전화번호가 이미 인증되었습니다 + Empty: 전화번호가 비어 있습니다 + NotChanged: 전화번호가 변경되지 않았습니다 + Address: + NotFound: 주소를 찾을 수 없습니다 + NotChanged: 주소가 변경되지 않았습니다 + Machine: + Key: + NotFound: 머신 키를 찾을 수 없습니다 + AlreadyExisting: 머신 키가 이미 존재합니다 + Invalid: 공개 키가 PKIX 형식의 PEM 인코딩을 따르는 유효한 RSA 공개 키가 아닙니다 + Secret: + NotExisting: 시크릿이 존재하지 않습니다 + Invalid: 시크릿이 잘못되었습니다 + CouldNotGenerate: 시크릿을 생성할 수 없습니다 + PAT: + NotFound: 개인 액세스 토큰을 찾을 수 없습니다 + NotHuman: 사용자는 개인이어야 합니다 + NotMachine: 사용자는 기술적이어야 합니다 + WrongType: 이 사용자 유형에는 허용되지 않습니다 + NotAllowedToLink: 사용자는 외부 로그인 제공자와 링크할 수 없습니다 + Username: + AlreadyExists: 사용자 이름이 이미 사용 중입니다 + Reserved: 사용자 이름이 이미 사용 중입니다 + Empty: 사용자 이름이 비어 있습니다 + Code: + Empty: 코드가 비어 있습니다 + NotFound: 코드를 찾을 수 없습니다 + Expired: 코드가 만료되었습니다 + GeneratorAlgNotSupported: 지원되지 않는 생성 알고리즘 + Invalid: 코드가 잘못되었습니다 + Password: + NotFound: 비밀번호를 찾을 수 없습니다 + Empty: 비밀번호가 비어 있습니다 + Invalid: 비밀번호가 잘못되었습니다 + NotSet: 사용자가 비밀번호를 설정하지 않았습니다 + NotChanged: 새 비밀번호는 현재 비밀번호와 다르지 않아야 합니다 + NotSupported: 비밀번호 해시 인코딩이 지원되지 않습니다. 자세한 내용은 https://zitadel.com/docs/concepts/architecture/secrets#hashed-secrets를 참조하세요 + PasswordComplexityPolicy: + NotFound: 비밀번호 정책을 찾을 수 없습니다 + MinLength: 비밀번호가 너무 짧습니다 + MinLengthNotAllowed: 지정된 최소 길이는 허용되지 않습니다 + HasLower: 비밀번호에는 소문자가 포함되어야 합니다 + HasUpper: 비밀번호에는 대문자가 포함되어야 합니다 + HasNumber: 비밀번호에는 숫자가 포함되어야 합니다 + HasSymbol: 비밀번호에는 기호가 포함되어야 합니다 + ExternalIDP: + Invalid: 외부 IDP가 잘못되었습니다 + IDPConfigNotExisting: 이 조직에 대해 유효하지 않은 IDP 제공자입니다 + NotAllowed: 외부 IDP가 허용되지 않습니다 + MinimumExternalIDPNeeded: 최소 하나의 IDP가 추가되어야 합니다 + AlreadyExists: 외부 IDP가 이미 사용 중입니다 + NotFound: 외부 IDP를 찾을 수 없습니다 + LoginFailed: 외부 IDP에서 로그인에 실패했습니다 + MFA: + OTP: + AlreadyReady: 다중 요소 OTP(일회용 비밀번호)가 이미 설정되었습니다 + NotExisting: 다중 요소 OTP(일회용 비밀번호)가 존재하지 않습니다 + NotReady: 다중 요소 OTP(일회용 비밀번호)가 준비되지 않았습니다 + InvalidCode: 코드가 잘못되었습니다 + U2F: + NotExisting: U2F가 존재하지 않습니다 + Passwordless: + NotExisting: 패스워드리스가 존재하지 않습니다 + WebAuthN: + NotFound: WebAuthN 토큰을 찾을 수 없습니다 + BeginRegisterFailed: WebAuthN 등록 시작에 실패했습니다 + MarshalError: 데이터 직렬화 오류 발생 + ErrorOnParseCredential: 자격 증명 데이터 구문 분석 오류 + CreateCredentialFailed: 자격 증명 생성 오류 + BeginLoginFailed: WebAuthN 로그인 시작에 실패했습니다 + ValidateLoginFailed: 로그인 자격 증명 확인 오류 + CloneWarning: 자격 증명이 복제될 수 있습니다 + RefreshToken: + Invalid: 리프레시 토큰이 잘못되었습니다 + NotFound: 리프레시 토큰을 찾을 수 없습니다 + Instance: + NotFound: 인스턴스를 찾을 수 없습니다 + AlreadyExists: 인스턴스가 이미 존재합니다 + NotChanged: 인스턴스가 변경되지 않았습니다 + Org: + AlreadyExists: 조직 이름이 이미 사용 중입니다 + Invalid: 조직이 유효하지 않습니다 + AlreadyDeactivated: 조직이 이미 비활성화되었습니다 + AlreadyActive: 조직이 이미 활성화되었습니다 + Empty: 조직이 비어 있습니다 + NotFound: 조직을 찾을 수 없습니다 + NotChanged: 조직이 변경되지 않았습니다 + DefaultOrgNotDeletable: 기본 조직은 삭제할 수 없습니다 + ZitadelOrgNotDeletable: ZITADEL 프로젝트가 포함된 조직은 삭제할 수 없습니다 + InvalidDomain: 유효하지 않은 도메인입니다 + DomainMissing: 도메인이 누락되었습니다 + DomainNotOnOrg: 조직에 도메인이 존재하지 않습니다 + DomainNotVerified: 도메인이 인증되지 않았습니다 + DomainAlreadyVerified: 도메인이 이미 인증되었습니다 + DomainVerificationTypeInvalid: 도메인 인증 유형이 유효하지 않습니다 + DomainVerificationMissing: 도메인 인증이 아직 시작되지 않았습니다 + DomainVerificationFailed: 도메인 인증에 실패했습니다 + DomainVerificationTXTNotFound: 도메인에서 _zitadel-challenge TXT 레코드를 찾을 수 없습니다. DNS 서버에 추가했는지 확인하거나 새로운 레코드가 전파될 때까지 기다리세요 + DomainVerificationTXTNoMatch: 도메인에서 _zitadel-challenge TXT 레코드를 찾았으나 올바른 토큰 텍스트가 포함되어 있지 않습니다. DNS 서버에 올바른 토큰을 추가했는지 확인하거나 새로운 레코드가 전파될 때까지 기다리세요 + DomainVerificationHTTPNotFound: 예상되는 URL에 챌린지를 포함하는 파일을 찾을 수 없습니다. 파일이 올바른 위치에 읽기 권한으로 업로드되었는지 확인하세요 + DomainVerificationHTTPNoMatch: 예상되는 URL에 챌린지 파일이 있지만 올바른 토큰 텍스트가 포함되어 있지 않습니다. 파일의 내용을 확인하세요 + DomainVerificationTimeout: DNS 서버에 대한 쿼리에서 시간 초과가 발생했습니다 + PrimaryDomainNotDeletable: 기본 도메인은 삭제할 수 없습니다 + DomainNotFound: 도메인을 찾을 수 없습니다 + MemberIDMissing: 구성원 ID가 누락되었습니다 + MemberNotFound: 조직 구성원을 찾을 수 없습니다 + InvalidMember: 조직 구성원이 유효하지 않습니다 + UserIDMissing: 사용자 ID가 누락되었습니다 + PolicyAlreadyExists: 정책이 이미 존재합니다 + PolicyNotExisting: 정책이 존재하지 않습니다 + IdpInvalid: IDP 설정이 유효하지 않습니다 + IdpNotExisting: IDP 설정이 존재하지 않습니다 + OIDCConfigInvalid: OIDC IDP 설정이 유효하지 않습니다 + IdpIsNotOIDC: IDP 설정이 OIDC 유형이 아닙니다 + Domain: + AlreadyExists: 도메인이 이미 존재합니다 + InvalidCharacter: 도메인에는 영숫자, ., -만 허용됩니다 + EmptyString: 유효하지 않은 문자들이 비어 있는 문자열로 대체되었고 결과 도메인이 비어 있습니다 + IDP: + InvalidSearchQuery: 잘못된 검색 쿼리입니다 + ClientIDMissing: ClientID가 누락되었습니다 + TeamIDMissing: TeamID가 누락되었습니다 + KeyIDMissing: KeyID가 누락되었습니다 + PrivateKeyMissing: 개인 키가 누락되었습니다 + LoginPolicy: + NotFound: 로그인 정책을 찾을 수 없습니다 + Invalid: 로그인 정책이 유효하지 않습니다 + RedirectURIInvalid: 기본 리디렉트 URI가 유효하지 않습니다 + NotExisting: 로그인 정책이 존재하지 않습니다 + AlreadyExists: 로그인 정책이 이미 존재합니다 + IdpProviderAlreadyExisting: IDP 제공자가 이미 존재합니다 + IdpProviderNotExisting: IDP 제공자가 존재하지 않습니다 + RegistrationNotAllowed: 등록이 허용되지 않습니다 + UsernamePasswordNotAllowed: 사용자 이름/비밀번호로 로그인할 수 없습니다 + MFA: + AlreadyExists: 다중 인증이 이미 존재합니다 + NotExisting: 다중 인증이 존재하지 않습니다 + Unspecified: 다중 인증이 유효하지 않습니다 + MailTemplate: + NotFound: 기본 메일 템플릿을 찾을 수 없습니다 + NotChanged: 기본 메일 템플릿이 변경되지 않았습니다 + AlreadyExists: 기본 메일 템플릿이 이미 존재합니다 + Invalid: 기본 메일 템플릿이 유효하지 않습니다 + CustomMessageText: + NotFound: 기본 메시지 텍스트를 찾을 수 없습니다 + NotChanged: 기본 메시지 텍스트가 변경되지 않았습니다 + AlreadyExists: 기본 메시지 텍스트가 이미 존재합니다 + Invalid: 기본 메시지 텍스트가 유효하지 않습니다 + PasswordComplexityPolicy: + NotFound: 비밀번호 복잡성 정책을 찾을 수 없습니다 + Empty: 비밀번호 복잡성 정책이 비어 있습니다 + NotExisting: 비밀번호 복잡성 정책이 존재하지 않습니다 + AlreadyExists: 비밀번호 복잡성 정책이 이미 존재합니다 + PasswordLockoutPolicy: + NotFound: 비밀번호 잠금 정책을 찾을 수 없습니다 + Empty: 비밀번호 잠금 정책이 비어 있습니다 + NotExisting: 비밀번호 잠금 정책이 존재하지 않습니다 + AlreadyExists: 비밀번호 잠금 정책이 이미 존재합니다 + PasswordAgePolicy: + NotFound: 비밀번호 만료 정책을 찾을 수 없습니다 + Empty: 비밀번호 만료 정책이 비어 있습니다 + NotExisting: 비밀번호 만료 정책이 존재하지 않습니다 + AlreadyExists: 비밀번호 만료 정책이 이미 존재합니다 + OrgIAMPolicy: + Empty: 조직 IAM 정책이 비어 있습니다 + NotExisting: 조직 IAM 정책이 존재하지 않습니다 + AlreadyExists: 조직 IAM 정책이 이미 존재합니다 + NotificationPolicy: + NotFound: 알림 정책을 찾을 수 없습니다 + NotChanged: 알림 정책이 변경되지 않았습니다 + AlreadyExists: 알림 정책이 이미 존재합니다 + LabelPolicy: + NotFound: 개인 라벨 정책을 찾을 수 없습니다 + NotChanged: 개인 라벨 정책이 변경되지 않았습니다 + Project: + ProjectIDMissing: 프로젝트 ID가 누락되었습니다 + AlreadyExists: 조직에 프로젝트가 이미 존재합니다 + OrgNotExisting: 조직이 존재하지 않습니다 + UserNotExisting: 사용자가 존재하지 않습니다 + CouldNotGenerateClientSecret: 클라이언트 시크릿을 생성할 수 없습니다 + Invalid: 프로젝트가 유효하지 않습니다 + NotActive: 프로젝트가 활성 상태가 아닙니다 + NotInactive: 프로젝트가 비활성화 상태가 아닙니다 + NotFound: 프로젝트를 찾을 수 없습니다 + UserIDMissing: 사용자 ID가 누락되었습니다 + Member: + NotFound: 프로젝트 구성원을 찾을 수 없습니다 + Invalid: 프로젝트 구성원이 유효하지 않습니다 + AlreadyExists: 프로젝트 구성원이 이미 존재합니다 + NotExisting: 프로젝트 구성원이 존재하지 않습니다 + MinimumOneRoleNeeded: 최소 하나의 역할이 추가되어야 합니다 + Role: + AlreadyExists: 역할이 이미 존재합니다 + Invalid: 역할이 유효하지 않습니다 + NotExisting: 역할이 존재하지 않습니다 + IDMissing: ID가 누락되었습니다 + App: + AlreadyExists: 애플리케이션이 이미 존재합니다 + NotFound: 애플리케이션을 찾을 수 없습니다 + Invalid: 애플리케이션이 유효하지 않습니다 + NotExisting: 애플리케이션이 존재하지 않습니다 + NotActive: 애플리케이션이 활성 상태가 아닙니다 + NotInactive: 애플리케이션이 비활성 상태가 아닙니다 + OIDCConfigInvalid: OIDC 설정이 유효하지 않습니다 + APIConfigInvalid: API 설정이 유효하지 않습니다 + SAMLConfigInvalid: SAML 설정이 유효하지 않습니다 + IsNotOIDC: 애플리케이션이 OIDC 유형이 아닙니다 + IsNotAPI: 애플리케이션이 API 유형이 아닙니다 + IsNotSAML: 애플리케이션이 SAML 유형이 아닙니다 + SAMLMetadataMissing: SAML 메타데이터가 누락되었습니다 + SAMLMetadataFormat: SAML 메타데이터 형식 오류 + SAMLEntityIDAlreadyExisting: SAML EntityID가 이미 존재합니다 + OIDCAuthMethodNoSecret: 선택한 OIDC 인증 방법에는 시크릿이 필요하지 않습니다 + APIAuthMethodNoSecret: 선택한 API 인증 방법에는 시크릿이 필요하지 않습니다 + AuthMethodNoPrivateKeyJWT: 선택한 인증 방법에는 키가 필요하지 않습니다 + ClientSecretInvalid: 클라이언트 시크릿이 유효하지 않습니다 + Key: + AlreadyExisting: 애플리케이션 키가 이미 존재합니다 + NotFound: 애플리케이션 키를 찾을 수 없습니다 + RequiredFieldsMissing: 필요한 필드가 일부 누락되었습니다 + Grant: + AlreadyExists: 프로젝트 권한이 이미 존재합니다 + NotFound: 권한을 찾을 수 없습니다 + Invalid: 프로젝트 권한이 유효하지 않습니다 + NotExisting: 프로젝트 권한이 존재하지 않습니다 + HasNotExistingRole: 프로젝트에 존재하지 않는 역할이 있습니다 + NotActive: 프로젝트 권한이 활성 상태가 아닙니다 + NotInactive: 프로젝트 권한이 비활성 상태가 아닙니다 + IAM: + NotFound: 인스턴스를 찾을 수 없습니다. 도메인이 올바른지 확인하십시오. https://zitadel.com/docs/apis/introduction#domains 를 참조하세요 + Member: + RolesNotChanged: 역할이 변경되지 않았습니다 + MemberInvalid: 구성원이 유효하지 않습니다 + MemberAlreadyExisting: 구성원이 이미 존재합니다 + MemberNotExisting: 구성원이 존재하지 않습니다 + IDMissing: ID가 누락되었습니다 + IAMProjectIDMissing: IAM 프로젝트 ID가 누락되었습니다 + IamProjectAlreadySet: IAM 프로젝트 ID가 이미 설정되었습니다 + IdpInvalid: IDP 설정이 유효하지 않습니다 + IdpNotExisting: IDP 설정이 존재하지 않습니다 + OIDCConfigInvalid: OIDC IDP 설정이 유효하지 않습니다 + IdpIsNotOIDC: IDP 설정이 OIDC 유형이 아닙니다 + LoginPolicyInvalid: 로그인 정책이 유효하지 않습니다 + LoginPolicyNotExisting: 로그인 정책이 존재하지 않습니다 + IdpProviderInvalid: IDP 제공자가 유효하지 않습니다 + LoginPolicy: + NotFound: 기본 로그인 정책을 찾을 수 없습니다 + NotChanged: 기본 로그인 정책이 변경되지 않았습니다 + NotExisting: 기본 로그인 정책이 존재하지 않습니다 + AlreadyExists: 기본 로그인 정책이 이미 존재합니다 + RedirectURIInvalid: 기본 리디렉트 URI가 유효하지 않습니다 + MFA: + AlreadyExists: 다중 인증이 이미 존재합니다 + NotExisting: 다중 인증이 존재하지 않습니다 + Unspecified: 다중 인증이 유효하지 않습니다 + IDP: + AlreadyExists: IDP 제공자가 이미 존재합니다 + NotExisting: IDP 제공자가 존재하지 않습니다 + Invalid: IDP 제공자가 유효하지 않습니다 + IDPConfig: + AlreadyExists: IDP 설정이 이미 존재합니다 + NotInactive: IDP 설정이 비활성화 상태가 아닙니다 + NotActive: IDP 설정이 활성 상태가 아닙니다 + LabelPolicy: + NotFound: 기본 개인 라벨 정책을 찾을 수 없습니다 + NotChanged: 기본 개인 라벨 정책이 변경되지 않았습니다 + MailTemplate: + NotFound: 기본 메일 템플릿을 찾을 수 없습니다 + NotChanged: 기본 메일 템플릿이 변경되지 않았습니다 + AlreadyExists: 기본 메일 템플릿이 이미 존재합니다 + Invalid: 기본 메일 템플릿이 유효하지 않습니다 + CustomMessageText: + NotFound: 기본 메시지 텍스트를 찾을 수 없습니다 + NotChanged: 기본 메시지 텍스트가 변경되지 않았습니다 + AlreadyExists: 기본 메시지 텍스트가 이미 존재합니다 + Invalid: 기본 메시지 텍스트가 유효하지 않습니다 + PasswordComplexityPolicy: + NotFound: 기본 비밀번호 복잡성 정책을 찾을 수 없습니다 + NotExisting: 기본 비밀번호 복잡성 정책이 존재하지 않습니다 + AlreadyExists: 기본 비밀번호 복잡성 정책이 이미 존재합니다 + Empty: 기본 비밀번호 복잡성 정책이 비어 있습니다 + NotChanged: 기본 비밀번호 복잡성 정책이 변경되지 않았습니다 + PasswordAgePolicy: + NotFound: 기본 비밀번호 만료 정책을 찾을 수 없습니다 + NotExisting: 기본 비밀번호 만료 정책이 존재하지 않습니다 + AlreadyExists: 기본 비밀번호 만료 정책이 이미 존재합니다 + Empty: 기본 비밀번호 만료 정책이 비어 있습니다 + NotChanged: 기본 비밀번호 만료 정책이 변경되지 않았습니다 + PasswordLockoutPolicy: + NotFound: 기본 비밀번호 잠금 정책을 찾을 수 없습니다 + NotExisting: 기본 비밀번호 잠금 정책이 존재하지 않습니다 + AlreadyExists: 기본 비밀번호 잠금 정책이 이미 존재합니다 + Empty: 기본 비밀번호 잠금 정책이 비어 있습니다 + NotChanged: 기본 비밀번호 잠금 정책이 변경되지 않았습니다 + DomainPolicy: + NotFound: 조직 IAM 정책을 찾을 수 없습니다 + Empty: 조직 IAM 정책이 비어 있습니다 + NotExisting: 조직 IAM 정책이 존재하지 않습니다 + AlreadyExists: 조직 IAM 정책이 이미 존재합니다 + NotChanged: 조직 IAM 정책이 변경되지 않았습니다 + NotificationPolicy: + NotFound: 기본 알림 정책을 찾을 수 없습니다 + NotChanged: 기본 알림 정책이 변경되지 않았습니다 + AlreadyExists: 기본 알림 정책이 이미 존재합니다 + Policy: + AlreadyExists: 정책이 이미 존재합니다 + Label: + Invalid: + PrimaryColor: 기본 색상이 유효한 16진수 색상 값이 아닙니다 + BackgroundColor: 배경 색상이 유효한 16진수 색상 값이 아닙니다 + WarnColor: 경고 색상이 유효한 16진수 색상 값이 아닙니다 + FontColor: 글꼴 색상이 유효한 16진수 색상 값이 아닙니다 + PrimaryColorDark: 기본 색상(다크 모드)이 유효한 16진수 색상 값이 아닙니다 + BackgroundColorDark: 배경 색상(다크 모드)이 유효한 16진수 색상 값이 아닙니다 + WarnColorDark: 경고 색상(다크 모드)이 유효한 16진수 색상 값이 아닙니다 + FontColorDark: 글꼴 색상(다크 모드)이 유효한 16진수 색상 값이 아닙니다 + UserGrant: + AlreadyExists: 사용자 권한이 이미 존재합니다 + NotFound: 사용자 권한을 찾을 수 없습니다 + Invalid: 사용자 권한이 유효하지 않습니다 + NotChanged: 사용자 권한이 변경되지 않았습니다 + IDMissing: ID가 누락되었습니다 + NotActive: 사용자 권한이 활성 상태가 아닙니다 + NotInactive: 사용자 권한이 비활성 상태가 아닙니다 + NoPermissionForProject: 사용자가 이 프로젝트에 대한 권한이 없습니다 + RoleKeyNotFound: 역할을 찾을 수 없습니다 + Member: + AlreadyExists: 구성원이 이미 존재합니다 + IDPConfig: + AlreadyExists: 동일한 이름의 IDP 설정이 이미 존재합니다 + NotExisting: IDP 설정이 존재하지 않습니다 + Changes: + NotFound: 기록을 찾을 수 없습니다 + AuditRetention: 기록이 감사 로그 보존 기간을 초과했습니다 + Token: + NotFound: 토큰을 찾을 수 없습니다 + Invalid: 토큰이 유효하지 않습니다 + UserSession: + NotFound: 사용자 세션을 찾을 수 없습니다 + Key: + NotFound: 키를 찾을 수 없습니다 + ExpireBeforeNow: 만료일이 이미 경과했습니다 + Login: + LoginPolicy: + MFA: + ForceAndNotConfigured: 다중 인증이 필수로 설정되었지만 가능한 제공자가 구성되지 않았습니다. 시스템 관리자에게 문의하십시오. + Step: + Started: + AlreadyExists: 이미 시작된 단계가 존재합니다 + Done: + AlreadyExists: 이미 완료된 단계가 존재합니다 + CustomText: + AlreadyExists: 사용자 정의 텍스트가 이미 존재합니다 + Invalid: 사용자 정의 텍스트가 유효하지 않습니다 + NotFound: 사용자 정의 텍스트를 찾을 수 없습니다 + TranslationFile: + ReadError: 번역 파일을 읽는 중 오류 발생 + MergeError: 번역 파일을 사용자 정의 번역과 병합할 수 없습니다 + NotFound: 번역 파일이 존재하지 않습니다 + Metadata: + NotFound: 메타데이터를 찾을 수 없습니다 + NoData: 메타데이터 목록이 비어 있습니다 + Invalid: 메타데이터가 유효하지 않습니다 + KeyNotExisting: 하나 이상의 키가 존재하지 않습니다 + Action: + Invalid: 작업이 유효하지 않습니다 + NotFound: 작업을 찾을 수 없습니다 + NotActive: 작업이 활성화되지 않았습니다 + NotInactive: 작업이 비활성화되지 않았습니다 + MaxAllowed: 추가 활성 작업이 허용되지 않습니다 + NotEnabled: "\"작업\" 기능이 활성화되지 않았습니다" + Flow: + FlowTypeMissing: FlowType이 누락되었습니다 + Empty: 플로우가 이미 비어 있습니다 + WrongTriggerType: TriggerType이 유효하지 않습니다 + NoChanges: 변경 사항이 없습니다 + ActionIDsNotExist: ActionIDs가 존재하지 않습니다 + Query: + CloseRows: SQL 문을 완료할 수 없습니다 + SQLStatement: SQL 문을 생성할 수 없습니다 + InvalidRequest: 요청이 유효하지 않습니다 + TooManyNestingLevels: 쿼리 중첩 수준이 너무 많습니다 (최대 20) + LimitExceeded: 제한을 초과했습니다 + Quota: + AlreadyExists: 이 단위에 대한 할당량이 이미 존재합니다 + NotFound: 이 단위에 대한 할당량을 찾을 수 없습니다 + Invalid: + CallURL: 할당량 호출 URL이 유효하지 않습니다 + Percent: 할당량 백분율이 1 미만입니다 + Unimplemented: 이 단위에 대해 할당량이 구현되지 않았습니다 + Amount: 할당량 금액이 1 미만입니다 + ResetInterval: 할당량 재설정 간격이 1분보다 짧습니다 + Noop: 알림 없는 무제한 할당량은 효과가 없습니다 + Access: + Exhausted: 인증된 요청에 대한 할당량이 소진되었습니다 + Execution: + Exhausted: 실행 시간에 대한 할당량이 소진되었습니다 + LogStore: + Access: + StorageFailed: 액세스 로그를 데이터베이스에 저장하지 못했습니다 + ScanFailed: 인증된 요청 사용량 조회 실패 + Execution: + StorageFailed: 작업 실행 로그를 데이터베이스에 저장하지 못했습니다 + ScanFailed: 작업 실행 시간 사용량 조회 실패 + Session: + NotExisting: 세션이 존재하지 않습니다 + Terminated: 세션이 이미 종료되었습니다 + Expired: 세션이 만료되었습니다 + PositiveLifetime: 세션 수명은 0보다 작아서는 안 됩니다 + Token: + Invalid: 세션 토큰이 유효하지 않습니다 + WebAuthN: + NoChallenge: WebAuthN 챌린지가 없는 세션 + Intent: + IDPMissing: 요청에서 IDP ID가 누락되었습니다 + IDPInvalid: 요청에 대한 IDP가 유효하지 않습니다 + ResponseInvalid: IDP 응답이 유효하지 않습니다 + MissingSingleMappingAttribute: IDP 응답에 매핑 속성이 포함되어 있지 않거나 값이 하나 이상 있습니다 + SuccessURLMissing: 요청에 성공 URL이 누락되었습니다 + FailureURLMissing: 요청에 실패 URL이 누락되었습니다 + StateMissing: 요청에 상태 매개변수가 누락되었습니다 + NotStarted: 의도가 시작되지 않았거나 이미 종료되었습니다 + NotSucceeded: 의도가 성공하지 않았습니다 + TokenCreationFailed: 토큰 생성 실패 + InvalidToken: 의도 토큰이 유효하지 않습니다 + OtherUser: 다른 사용자를 위한 의도입니다 + AuthRequest: + AlreadyExists: 인증 요청이 이미 존재합니다 + NotExisting: 인증 요청이 존재하지 않습니다 + WrongLoginClient: 다른 로그인 클라이언트에 의해 생성된 인증 요청 + OIDCSession: + RefreshTokenInvalid: 새로 고침 토큰이 유효하지 않습니다 + Token: + Invalid: 토큰이 유효하지 않습니다 + Expired: 토큰이 만료되었습니다 + InvalidClient: 토큰이 이 클라이언트에 대해 발행되지 않았습니다 + Feature: + NotExisting: 기능이 존재하지 않습니다 + TypeNotSupported: 기능 유형이 지원되지 않습니다 + InvalidValue: 이 기능에 대해 유효하지 않은 값 + Target: + Invalid: 대상이 유효하지 않습니다 + NoTimeout: 대상에 타임아웃이 없습니다 + InvalidURL: 대상 URL이 유효하지 않습니다 + NotFound: 대상을 찾을 수 없습니다 + Execution: + ConditionInvalid: 실행 조건이 유효하지 않습니다 + Invalid: 실행이 유효하지 않습니다 + NotFound: 실행을 찾을 수 없습니다 + IncludeNotFound: 포함을 찾을 수 없습니다 + NoTargets: 정의된 대상이 없습니다 + Failed: 실행 실패 + ResponseIsNotValidJSON: 응답이 유효한 JSON이 아닙니다 + UserSchema: + NotEnabled: "\"사용자 스키마\" 기능이 활성화되지 않았습니다" + Type: + Missing: 사용자 스키마 유형이 누락되었습니다 + AlreadyExists: 사용자 스키마 유형이 이미 존재합니다 + Authenticator: + Invalid: 인증기 유형이 유효하지 않습니다 + NotActive: 사용자 스키마가 활성 상태가 아닙니다 + NotInactive: 사용자 스키마가 비활성 상태가 아닙니다 + NotExists: 사용자 스키마가 존재하지 않습니다 + ID: + Missing: 사용자 스키마 ID가 누락되었습니다 + Invalid: 사용자 스키마가 유효하지 않습니다 + Data: + Invalid: 사용자 스키마에 대한 데이터가 유효하지 않습니다 + TokenExchange: + FeatureDisabled: 토큰 교환 기능이 인스턴스에서 비활성화되어 있습니다. https://zitadel.com/docs/apis/resources/feature_service_v2/feature-service-set-instance-features + Token: + Missing: 토큰이 누락되었습니다 + Invalid: 토큰이 유효하지 않습니다 + TypeMissing: 토큰 유형이 누락되었습니다 + TypeNotAllowed: 허용되지 않는 토큰 유형입니다 + TypeNotSupported: 지원되지 않는 토큰 유형입니다 + NotForAPI: API에 대해 대리 인증된 토큰을 허용하지 않습니다 + Impersonation: + PolicyDisabled: 인스턴스 보안 정책에서 대리 인증이 비활성화되었습니다 + WebKey: + ActiveDelete: 활성 웹 키를 삭제할 수 없습니다 + Config: 웹 키 설정이 유효하지 않습니다 + Duplicate: 웹 키 ID가 고유하지 않습니다 + FeatureDisabled: 웹 키 기능이 비활성화되었습니다 + NoActive: 활성 웹 키가 없습니다 + NotFound: 웹 키를 찾을 수 없습니다 + +AggregateTypes: + action: 작업 + instance: 인스턴스 + key_pair: 키 쌍 + org: 조직 + project: 프로젝트 + user: 사용자 + usergrant: 사용자 권한 + quota: 할당량 + feature: 기능 + target: 대상 + execution: 실행 + user_schema: 사용자 스키마 + auth_request: 인증 요청 + device_auth: 디바이스 인증 + idpintent: IDP 의도 + limits: 제한 + milestone: 마일스톤 + oidc_session: OIDC 세션 + restrictions: 제한 사항 + system: 시스템 + session: 세션 + web_key: 웹 키 + +EventTypes: + execution: + set: 실행 설정됨 + removed: 실행 삭제됨 + target: + added: 대상 생성됨 + changed: 대상 변경됨 + removed: 대상 삭제됨 + user: + added: 사용자 추가됨 + selfregistered: 사용자가 자체 등록함 + initialization: + code: + added: 초기화 코드 생성됨 + sent: 초기화 코드 전송됨 + check: + succeeded: 초기화 확인 성공 + failed: 초기화 확인 실패 + token: + added: 액세스 토큰 생성됨 + v2.added: 액세스 토큰 생성됨 + removed: 액세스 토큰 삭제됨 + impersonated: 사용자 대리 로그인됨 + username: + reserved: 사용자 이름 예약됨 + released: 사용자 이름 해제됨 + changed: 사용자 이름 변경됨 + email: + reserved: 이메일 주소 예약됨 + released: 이메일 주소 해제됨 + changed: 이메일 주소 변경됨 + verified: 이메일 주소 인증됨 + verification: + failed: 이메일 주소 인증 실패 + code: + added: 이메일 인증 코드 생성됨 + sent: 이메일 인증 코드 전송됨 + machine: + added: 기술 사용자 추가됨 + changed: 기술 사용자 변경됨 + key: + added: 키 추가됨 + removed: 키 삭제됨 + secret: + set: 시크릿 설정됨 + updated: 시크릿 해시 업데이트됨 + removed: 시크릿 삭제됨 + check: + succeeded: 시크릿 확인 성공 + failed: 시크릿 확인 실패 + human: + added: 사용자 추가됨 + selfregistered: 사용자가 자체 등록함 + avatar: + added: 아바타 추가됨 + removed: 아바타 삭제됨 + initialization: + code: + added: 초기화 코드 생성됨 + sent: 초기화 코드 전송됨 + check: + succeeded: 초기화 확인 성공 + failed: 초기화 확인 실패 + invite: + code: + added: 초대 코드 생성됨 + sent: 초대 코드 전송됨 + check: + succeeded: 초대 확인 성공 + failed: 초대 확인 실패 + username: + reserved: 사용자 이름 예약됨 + released: 사용자 이름 해제됨 + email: + changed: 이메일 주소 변경됨 + verified: 이메일 주소 인증됨 + verification: + failed: 이메일 주소 인증 실패 + code: + added: 이메일 인증 코드 생성됨 + sent: 이메일 인증 코드 전송됨 + password: + changed: 비밀번호 변경됨 + code: + added: 비밀번호 코드 생성됨 + sent: 비밀번호 코드 전송됨 + check: + succeeded: 비밀번호 확인 성공 + failed: 비밀번호 확인 실패 + change: + sent: 비밀번호 변경 전송됨 + hash: + updated: 비밀번호 해시 업데이트됨 + externallogin: + check: + succeeded: 외부 로그인 성공 + externalidp: + added: 외부 IDP 추가됨 + removed: 외부 IDP 삭제됨 + cascade: + removed: 외부 IDP 연쇄 삭제됨 + id: + migrated: 외부 IDP의 사용자 ID가 마이그레이션됨 + phone: + changed: 전화번호 변경됨 + verified: 전화번호 인증됨 + verification: + failed: 전화번호 인증 실패 + code: + added: 전화번호 코드 생성됨 + sent: 전화번호 코드 전송됨 + removed: 전화번호 삭제됨 + profile: + changed: 사용자 프로필 변경됨 + address: + changed: 사용자 주소 변경됨 + mfa: + otp: + added: 다중인증 OTP 추가됨 + verified: 다중인증 OTP 인증됨 + removed: 다중인증 OTP 삭제됨 + check: + succeeded: 다중인증 OTP 확인 성공 + failed: 다중인증 OTP 확인 실패 + sms: + added: 다중인증 OTP SMS 추가됨 + removed: 다중인증 OTP SMS 삭제됨 + code: + added: 다중인증 OTP SMS 코드 추가됨 + sent: 다중인증 OTP SMS 코드 전송됨 + check: + succeeded: 다중인증 OTP SMS 확인 성공 + failed: 다중인증 OTP SMS 확인 실패 + email: + added: 다중인증 OTP 이메일 추가됨 + removed: 다중인증 OTP 이메일 삭제됨 + code: + added: 다중인증 OTP 이메일 코드 추가됨 + sent: 다중인증 OTP 이메일 코드 전송됨 + check: + succeeded: 다중인증 OTP 이메일 확인 성공 + failed: 다중인증 OTP 이메일 확인 실패 + u2f: + token: + added: 다중인증 U2F 토큰 추가됨 + verified: 다중인증 U2F 토큰 인증됨 + removed: 다중인증 U2F 토큰 삭제됨 + begin: + login: 다중인증 U2F 확인 시작됨 + check: + succeeded: 다중인증 U2F 확인 성공 + failed: 다중인증 U2F 확인 실패 + signcount: + changed: 다중인증 U2F 토큰의 체크섬이 변경됨 + init: + skipped: 다중인증 초기화 건너뜀 + passwordless: + token: + added: 비밀번호 없는 로그인 토큰 추가됨 + verified: 비밀번호 없는 로그인 토큰 인증됨 + removed: 비밀번호 없는 로그인 토큰 삭제됨 + begin: + login: 비밀번호 없는 로그인 확인 시작됨 + check: + succeeded: 비밀번호 없는 로그인 확인 성공 + failed: 비밀번호 없는 로그인 확인 실패 + signcount: + changed: 비밀번호 없는 로그인 토큰의 체크섬이 변경됨 + initialization: + code: + added: 비밀번호 없는 초기화 코드 추가됨 + sent: 비밀번호 없는 초기화 코드 전송됨 + requested: 비밀번호 없는 초기화 코드 요청됨 + check: + succeeded: 비밀번호 없는 초기화 코드 확인 성공 + failed: 비밀번호 없는 초기화 코드 확인 실패 + signed: + out: 사용자 로그아웃됨 + refresh: + token: + added: 리프레시 토큰 생성됨 + renewed: 리프레시 토큰 갱신됨 + removed: 리프레시 토큰 삭제됨 + locked: 사용자 잠금됨 + unlocked: 사용자 잠금 해제됨 + deactivated: 사용자 비활성화됨 + reactivated: 사용자 재활성화됨 + removed: 사용자 삭제됨 + password: + changed: 비밀번호 변경됨 + code: + added: 비밀번호 코드 생성됨 + sent: 비밀번호 코드 전송됨 + check: + succeeded: 비밀번호 확인 성공 + failed: 비밀번호 확인 실패 + phone: + changed: 전화번호 변경됨 + verified: 전화번호 인증됨 + verification: + failed: 전화번호 인증 실패 + code: + added: 전화번호 코드 생성됨 + sent: 전화번호 코드 전송됨 + + profile: + changed: 사용자 프로필 변경됨 + address: + changed: 사용자 주소 변경됨 + mfa: + otp: + added: 다중인증 OTP 추가됨 + verified: 다중인증 OTP 인증됨 + removed: 다중인증 OTP 삭제됨 + check: + succeeded: 다중인증 OTP 확인 성공 + failed: 다중인증 OTP 확인 실패 + init: + skipped: 다중인증 OTP 초기화 건너뜀 + init: + skipped: 다중인증 초기화 건너뜀 + signed: + out: 사용자 로그아웃됨 + grant: + added: 권한 추가됨 + changed: 권한 변경됨 + removed: 권한 삭제됨 + deactivated: 권한 비활성화됨 + reactivated: 권한 재활성화됨 + reserved: 권한 예약됨 + released: 권한 해제됨 + cascade: + removed: 권한 연쇄 삭제됨 + changed: 권한 변경됨 + metadata: + set: 사용자 메타데이터 설정됨 + removed: 사용자 메타데이터 삭제됨 + removed.all: 모든 사용자 메타데이터 삭제됨 + domain: + claimed: 도메인 클레임됨 + claimed.sent: 도메인 클레임 알림 전송됨 + pat: + added: 개인 액세스 토큰 추가됨 + removed: 개인 액세스 토큰 삭제됨 + org: + added: 조직 추가됨 + changed: 조직 변경됨 + deactivated: 조직 비활성화됨 + reactivated: 조직 재활성화됨 + removed: 조직 삭제됨 + domain: + added: 도메인 추가됨 + verification: + added: 도메인 인증 추가됨 + failed: 도메인 인증 실패 + verified: 도메인 인증됨 + removed: 도메인 삭제됨 + primary: + set: 기본 도메인 설정됨 + reserved: 도메인 예약됨 + released: 도메인 해제됨 + name: + reserved: 조직 이름 예약됨 + released: 조직 이름 해제됨 + member: + added: 조직 멤버 추가됨 + changed: 조직 멤버 변경됨 + removed: 조직 멤버 삭제됨 + cascade: + removed: 조직 멤버 연쇄 삭제됨 + iam: + policy: + added: 시스템 정책 추가됨 + changed: 시스템 정책 변경됨 + removed: 시스템 정책 삭제됨 + idp: + config: + added: IDP 설정 추가됨 + changed: IDP 설정 변경됨 + removed: IDP 설정 삭제됨 + deactivated: IDP 설정 비활성화됨 + reactivated: IDP 설정 재활성화됨 + oidc: + config: + added: OIDC IDP 설정 추가됨 + changed: OIDC IDP 설정 변경됨 + saml: + config: + added: SAML IDP 설정 추가됨 + changed: SAML IDP 설정 변경됨 + jwt: + config: + added: JWT IDP 설정 추가됨 + changed: JWT IDP 설정 변경됨 + customtext: + set: 사용자 지정 텍스트 설정됨 + removed: 사용자 지정 텍스트 삭제됨 + template: + removed: 사용자 지정 템플릿 삭제됨 + policy: + login: + added: 로그인 정책 추가됨 + changed: 로그인 정책 변경됨 + removed: 로그인 정책 삭제됨 + idpprovider: + added: 로그인 정책에 IDP 추가됨 + removed: 로그인 정책에서 IDP 삭제됨 + cascade: + removed: 로그인 정책에서 연쇄 삭제됨 + secondfactor: + added: 로그인 정책에 2차 인증 추가됨 + removed: 로그인 정책에서 2차 인증 삭제됨 + multifactor: + added: 로그인 정책에 다중인증 추가됨 + removed: 로그인 정책에서 다중인증 삭제됨 + password: + complexity: + added: 비밀번호 복잡도 정책 추가됨 + changed: 비밀번호 복잡도 정책 변경됨 + removed: 비밀번호 복잡도 정책 삭제됨 + age: + added: 비밀번호 만료 정책 추가됨 + changed: 비밀번호 만료 정책 변경됨 + removed: 비밀번호 만료 정책 삭제됨 + lockout: + added: 비밀번호 잠금 정책 추가됨 + changed: 비밀번호 잠금 정책 변경됨 + removed: 비밀번호 잠금 정책 삭제됨 + label: + added: 레이블 정책 추가됨 + changed: 레이블 정책 변경됨 + activated: 레이블 정책 활성화됨 + removed: 레이블 정책 삭제됨 + logo: + added: 레이블 정책에 로고 추가됨 + removed: 레이블 정책에서 로고 삭제됨 + dark: + added: 레이블 정책에 다크 모드 로고 추가됨 + removed: 레이블 정책에서 다크 모드 로고 삭제됨 + icon: + added: 레이블 정책에 아이콘 추가됨 + removed: 레이블 정책에서 아이콘 삭제됨 + dark: + added: 레이블 정책에 다크 모드 아이콘 추가됨 + removed: 레이블 정책에서 다크 모드 아이콘 삭제됨 + font: + added: 레이블 정책에 폰트 추가됨 + removed: 레이블 정책에서 폰트 삭제됨 + assets: + removed: 레이블 정책에서 자산 삭제됨 + privacy: + added: 개인정보처리방침 및 이용 약관 추가됨 + changed: 개인정보처리방침 및 이용 약관 변경됨 + removed: 개인정보처리방침 및 이용 약관 삭제됨 + domain: + added: 도메인 정책 추가됨 + changed: 도메인 정책 변경됨 + removed: 도메인 정책 삭제됨 + lockout: + added: 잠금 정책 추가됨 + changed: 잠금 정책 변경됨 + removed: 잠금 정책 삭제됨 + notification: + added: 알림 정책 추가됨 + changed: 알림 정책 변경됨 + removed: 알림 정책 삭제됨 + flow: + trigger_actions: + set: 작업 설정됨 + cascade: + removed: 연쇄 작업 삭제됨 + removed: 작업 삭제됨 + cleared: 플로우 초기화됨 + mail: + template: + added: 이메일 템플릿 추가됨 + changed: 이메일 템플릿 변경됨 + removed: 이메일 템플릿 삭제됨 + text: + added: 이메일 텍스트 추가됨 + changed: 이메일 텍스트 변경됨 + removed: 이메일 텍스트 삭제됨 + metadata: + removed: 메타데이터 삭제됨 + removed.all: 모든 메타데이터 삭제됨 + set: 메타데이터 설정됨 + project: + added: 프로젝트 추가됨 + changed: 프로젝트 변경됨 + deactivated: 프로젝트 비활성화됨 + reactivated: 프로젝트 재활성화됨 + removed: 프로젝트 삭제됨 + member: + added: 프로젝트 멤버 추가됨 + changed: 프로젝트 멤버 변경됨 + removed: 프로젝트 멤버 삭제됨 + cascade: + removed: 프로젝트 멤버 연쇄 삭제됨 + role: + added: 프로젝트 역할 추가됨 + changed: 프로젝트 역할 변경됨 + removed: 프로젝트 역할 삭제됨 + grant: + added: 관리 액세스 추가됨 + changed: 관리 액세스 변경됨 + removed: 관리 액세스 삭제됨 + deactivated: 관리 액세스 비활성화됨 + reactivated: 관리 액세스 재활성화됨 + cascade: + changed: 관리 액세스 변경됨 + member: + added: 관리 액세스 멤버 추가됨 + changed: 관리 액세스 멤버 변경됨 + removed: 관리 액세스 멤버 삭제됨 + cascade: + removed: 관리 액세스 연쇄 삭제됨 + application: + added: 애플리케이션 추가됨 + changed: 애플리케이션 변경됨 + removed: 애플리케이션 삭제됨 + deactivated: 애플리케이션 비활성화됨 + reactivated: 애플리케이션 재활성화됨 + oidc: + secret: + check: + succeeded: OIDC 클라이언트 시크릿 확인 성공 + failed: OIDC 클라이언트 시크릿 확인 실패 + key: + added: OIDC 앱 키 추가됨 + removed: OIDC 앱 키 삭제됨 + api: + secret: + check: + succeeded: API 시크릿 확인 성공 + failed: API 시크릿 확인 실패 + key: + added: 애플리케이션 키 추가됨 + removed: 애플리케이션 키 삭제됨 + config: + saml: + added: SAML 설정 추가됨 + changed: SAML 설정 변경됨 + oidc: + added: OIDC 설정 추가됨 + changed: OIDC 설정 변경됨 + secret: + changed: OIDC 시크릿 변경됨 + updated: OIDC 시크릿 해시 갱신됨 + api: + added: API 설정 추가됨 + changed: API 설정 변경됨 + secret: + changed: API 시크릿 변경됨 + updated: API 시크릿 해시 갱신됨 + policy: + password: + complexity: + added: 비밀번호 복잡도 정책 추가됨 + changed: 비밀번호 복잡도 정책 변경됨 + age: + added: 비밀번호 만료 정책 추가됨 + changed: 비밀번호 만료 정책 변경됨 + lockout: + added: 비밀번호 잠금 정책 추가됨 + changed: 비밀번호 잠금 정책 변경됨 + iam: + setup: + started: ZITADEL 설정 시작됨 + done: ZITADEL 설정 완료됨 + global: + org: + set: 글로벌 조직 설정됨 + project: + iam: + set: ZITADEL 프로젝트 설정됨 + member: + added: ZITADEL 멤버 추가됨 + changed: ZITADEL 멤버 변경됨 + removed: ZITADEL 멤버 삭제됨 + cascade: + removed: ZITADEL 멤버 연쇄 삭제됨 + idp: + config: + added: IDP 설정 추가됨 + changed: IDP 설정 변경됨 + removed: IDP 설정 삭제됨 + deactivated: IDP 설정 비활성화됨 + reactivated: IDP 설정 재활성화됨 + oidc: + config: + added: OIDC IDP 설정 추가됨 + changed: OIDC IDP 설정 변경됨 + saml: + config: + added: SAML IDP 설정 추가됨 + changed: SAML IDP 설정 변경됨 + jwt: + config: + added: IDP에 JWT 설정 추가됨 + changed: IDP로부터 JWT 설정 삭제됨 + customtext: + set: 텍스트 설정됨 + removed: 텍스트 삭제됨 + policy: + login: + added: 기본 로그인 정책 추가됨 + changed: 기본 로그인 정책 변경됨 + idpprovider: + added: 기본 로그인 정책에 IDP 추가됨 + removed: 기본 로그인 정책에서 IDP 삭제됨 + label: + added: 레이블 정책 추가됨 + changed: 레이블 정책 변경됨 + activated: 레이블 정책 활성화됨 + logo: + added: 로고가 레이블 정책에 추가됨 + removed: 레이블 정책에서 로고가 삭제됨 + dark: + added: 다크 모드 로고가 레이블 정책에 추가됨 + removed: 다크 모드 로고가 레이블 정책에서 삭제됨 + icon: + added: 아이콘이 레이블 정책에 추가됨 + removed: 레이블 정책에서 아이콘이 삭제됨 + dark: + added: 다크 모드 아이콘이 레이블 정책에 추가됨 + removed: 다크 모드 아이콘이 레이블 정책에서 삭제됨 + font: + added: 폰트가 레이블 정책에 추가됨 + removed: 레이블 정책에서 폰트가 삭제됨 + assets: + removed: 레이블 정책에서 자산 삭제됨 + default: + language: + set: 기본 언어 설정됨 + oidc: + settings: + added: OIDC 설정 추가됨 + changed: OIDC 설정 변경됨 + removed: OIDC 설정 삭제됨 + secret: + generator: + added: 시크릿 생성기 추가됨 + changed: 시크릿 생성기 변경됨 + removed: 시크릿 생성기 삭제됨 + smtp: + config: + added: SMTP 설정 추가됨 + changed: SMTP 설정 변경됨 + activated: SMTP 설정 활성화됨 + deactivated: SMTP 설정 비활성화됨 + removed: SMTP 설정 삭제됨 + password: + changed: SMTP 설정 비밀번호 변경됨 + sms: + config: + twilio: + added: Twilio SMS 제공자 추가됨 + changed: Twilio SMS 제공자 변경됨 + token: + changed: Twilio SMS 제공자 토큰 변경됨 + removed: Twilio SMS 제공자 삭제됨 + activated: Twilio SMS 제공자 활성화됨 + deactivated: Twilio SMS 제공자 비활성화됨 + key_pair: + added: 키 페어 추가됨 + certificate: + added: 인증서 추가됨 + action: + added: 작업 추가됨 + changed: 작업 변경됨 + deactivated: 작업 비활성화됨 + reactivated: 작업 재활성화됨 + removed: 작업 삭제됨 + instance: + added: 인스턴스 추가됨 + changed: 인스턴스 변경됨 + customtext: + removed: 사용자 정의 텍스트 삭제됨 + set: 사용자 정의 텍스트 설정됨 + template: + removed: 사용자 정의 템플릿 삭제됨 + default: + language: + set: 기본 언어 설정됨 + org: + set: 기본 조직 설정됨 + domain: + added: 도메인 추가됨 + primary: + set: 기본 도메인 설정됨 + removed: 도메인 삭제됨 + iam: + console: + set: ZITADEL 콘솔 애플리케이션 설정됨 + project: + set: ZITADEL 프로젝트 설정됨 + mail: + template: + added: 이메일 템플릿 추가됨 + changed: 이메일 템플릿 변경됨 + text: + added: 이메일 텍스트 추가됨 + changed: 이메일 텍스트 변경됨 + member: + added: 인스턴스 멤버 추가됨 + changed: 인스턴스 멤버 변경됨 + removed: 인스턴스 멤버 삭제됨 + cascade: + removed: 인스턴스 멤버 연쇄 삭제됨 + notification: + provider: + debug: + fileadded: 파일 디버그 알림 제공자 추가됨 + filechanged: 파일 디버그 알림 제공자 변경됨 + fileremoved: 파일 디버그 알림 제공자 삭제됨 + logadded: 로그 디버그 알림 제공자 추가됨 + logchanged: 로그 디버그 알림 제공자 변경됨 + logremoved: 로그 디버그 알림 제공자 삭제됨 + oidc: + settings: + added: OIDC 설정 추가됨 + changed: OIDC 설정 변경됨 + policy: + domain: + added: 도메인 정책 추가됨 + changed: 도메인 정책 변경됨 + label: + activated: 레이블 정책 활성화됨 + added: 레이블 정책 추가됨 + assets: + removed: 레이블 정책에서 자산 삭제됨 + changed: 레이블 정책 변경됨 + font: + added: 레이블 정책에 폰트 추가됨 + removed: 레이블 정책에서 폰트 삭제됨 + icon: + added: 레이블 정책에 아이콘 추가됨 + removed: 레이블 정책에서 아이콘 삭제됨 + dark: + added: 레이블 정책에 다크 모드 아이콘 추가됨 + removed: 레이블 정책에서 다크 모드 아이콘 삭제됨 + logo: + added: 레이블 정책에 로고 추가됨 + removed: 레이블 정책에서 로고 삭제됨 + dark: + added: 레이블 정책에 다크 모드 로고 추가됨 + removed: 레이블 정책에서 다크 모드 로고 삭제됨 + lockout: + added: 잠금 정책 추가됨 + changed: 잠금 정책 변경됨 + login: + added: 로그인 정책 추가됨 + changed: 로그인 정책 변경됨 + idpprovider: + added: 로그인 정책에 IDP 추가됨 + cascade: + removed: 로그인 정책에서 연쇄 IDP 삭제됨 + removed: 로그인 정책에서 IDP 삭제됨 + multifactor: + added: 다중 인증 추가됨 + removed: 다중 인증 삭제됨 + secondfactor: + added: 2차 인증 추가됨 + removed: 2차 인증 삭제됨 + password: + age: + added: 비밀번호 만료 정책 추가됨 + changed: 비밀번호 만료 정책 변경됨 + complexity: + added: 비밀번호 복잡도 정책 추가됨 + changed: 비밀번호 복잡도 정책 변경됨 + privacy: + added: 개인정보 보호 정책 추가됨 + changed: 개인정보 보호 정책 변경됨 + security: + set: 보안 정책 설정됨 + + removed: 인스턴스 삭제됨 + secret: + generator: + added: 시크릿 생성기 추가됨 + changed: 시크릿 생성기 변경됨 + removed: 시크릿 생성기 삭제됨 + sms: + configtwilio: + activated: Twilio SMS 설정 활성화됨 + added: Twilio SMS 설정 추가됨 + changed: Twilio SMS 설정 변경됨 + deactivated: Twilio SMS 설정 비활성화됨 + removed: Twilio SMS 설정 삭제됨 + token: + changed: Twilio SMS 설정 토큰 변경됨 + smtp: + config: + added: SMTP 설정 추가됨 + changed: SMTP 설정 변경됨 + activated: SMTP 설정 활성화됨 + deactivated: SMTP 설정 비활성화됨 + password: + changed: SMTP 설정 비밀번호 변경됨 + removed: SMTP 설정 삭제됨 + user_schema: + created: 사용자 스키마 생성됨 + updated: 사용자 스키마 업데이트됨 + deactivated: 사용자 스키마 비활성화됨 + reactivated: 사용자 스키마 재활성화됨 + deleted: 사용자 스키마 삭제됨 + user: + created: 사용자 생성됨 + updated: 사용자 업데이트됨 + deleted: 사용자 삭제됨 + email: + updated: 이메일 주소 변경됨 + verified: 이메일 주소 인증됨 + verification: + failed: 이메일 주소 인증 실패 + code: + added: 이메일 주소 인증 코드 생성됨 + sent: 이메일 주소 인증 코드 전송됨 + phone: + updated: 전화번호 변경됨 + verified: 전화번호 인증됨 + verification: + failed: 전화번호 인증 실패 + code: + added: 전화번호 인증 코드 생성됨 + sent: 전화번호 인증 코드 전송됨 + + + web_key: + added: 웹 키 추가됨 + activated: 웹 키 활성화됨 + deactivated: 웹 키 비활성화됨 + removed: 웹 키 삭제됨 + +Application: + OIDC: + UnsupportedVersion: 지원되지 않는 OIDC 버전입니다 + V1: + NotCompliant: 설정이 OIDC 1.0 표준과 일치하지 않습니다. + NoRedirectUris: 적어도 하나의 리디렉션 URI가 등록되어야 합니다. + NotAllCombinationsAreAllowed: 설정은 준수하지만 모든 가능한 조합이 허용되는 것은 아닙니다. + Code: + RedirectUris: + HttpOnlyForWeb: 코드 인증 방식에서는 웹 앱 유형에 대해서만 HTTP 리디렉션 URI가 허용됩니다. + CustomOnlyForNative: 코드 인증 방식에서는 네이티브 앱 유형에 대해서만 사용자 정의 리디렉션 URI를 허용합니다 (e.g appname:// ). + Implicit: + RedirectUris: + CustomNotAllowed: 암시적 인증 방식에서는 사용자 정의 리디렉션 URI를 허용하지 않습니다. + HttpNotAllowed: 암시적 인증 방식에서는 HTTP 리디렉션 URI를 허용하지 않습니다. + HttpLocalhostOnlyForNative: http://localhost 리디렉션 URI는 네이티브 애플리케이션에서만 허용됩니다. + Native: + AuthMethodType: + NotNone: 네이티브 애플리케이션의 authmethodtype은 none이어야 합니다. + RedirectUris: + MustBeHttpLocalhost: 리디렉션 URI는 http://127.0.0.1, http://[::1], http://localhost와 같은 자체 프로토콜로 시작해야 합니다. + UserAgent: + AuthMethodType: + NotNone: 사용자 에이전트 앱의 authmethodtype은 none이어야 합니다. + GrantType: + Refresh: + NoAuthCode: 리프레시 토큰은 인증 코드와 함께 사용할 때만 허용됩니다. + +Action: + Flow: + Type: + Unspecified: 미지정 + ExternalAuthentication: 외부 인증 + CustomiseToken: 토큰 커스터마이징 + InternalAuthentication: 내부 인증 + CustomizeSAMLResponse: SAML 응답 커스터마이징 + TriggerType: + Unspecified: 미지정 + PostAuthentication: 인증 후 + PreCreation: 생성 전 + PostCreation: 생성 후 + PreUserinfoCreation: 사용자 정보 생성 전 + PreAccessTokenCreation: 액세스 토큰 생성 전 + PreSAMLResponseCreation: SAML 응답 생성 전 From 36c197590f99ef080a78525303565e0919c2afd1 Mon Sep 17 00:00:00 2001 From: asoji <99072163+asoji@users.noreply.github.com> Date: Mon, 2 Dec 2024 07:51:06 -0800 Subject: [PATCH 39/64] docs(adopter): devOS: Sanity Edition org (#8986) N/A --- ADOPTERS.md | 1 + 1 file changed, 1 insertion(+) diff --git a/ADOPTERS.md b/ADOPTERS.md index ba740212b2..10e8fc28b4 100644 --- a/ADOPTERS.md +++ b/ADOPTERS.md @@ -12,6 +12,7 @@ If you are using Zitadel, please consider adding yourself as a user with a quick | ----------------------- | -------------------------------------------------------------------- | ----------------------------------------------- | | Zitadel | [@fforootd](https://github.com/fforootd) (and many more) | Zitadel Cloud makes heavy use of of Zitadel ;-) | | Rawkode Academy | [@RawkodeAcademy](https://github.com/RawkodeAcademy) | Rawkode Academy Platform & Zulip use Zitadel for all user and M2M authentication | +| devOS: Sanity Edition | [@devOS-Sanity-Edition](https://github.com/devOS-Sanity-Edition) | Uses SSO Auth for every piece of our internal and external infrastructure | | CNAP.tech | [@cnap-tech](https://github.com/cnap-tech) | Using Zitadel for authentication and authorization in cloud-native applications | | Minekube | [@minekube](https://github.com/minekube) | Leveraging Zitadel for secure user authentication in gaming infrastructure | | Organization Name | contact@example.com | Description of how they use Zitadel | From 26e936aec3ad49c27f7c1db86d0c0d7772c187ea Mon Sep 17 00:00:00 2001 From: Fabi Date: Mon, 2 Dec 2024 17:52:55 +0100 Subject: [PATCH 40/64] fix: miss-leading labels in the console (#8972) # Which Problems Are Solved On the login settings we do have the settings "Force MFA" and "Force MFA for local authenticated users" this gives the impression, that i can enable both and then all users should be forced to use an mfa. But when both settings are enabled, only local users are forced to add mfa. # How the Problems Are Solved The label was wrong, the second one should be "Force MFA for local authneticated users only", I changed both labels to make it easier to understand. --- console/src/assets/i18n/bg.json | 4 ++-- console/src/assets/i18n/cs.json | 4 ++-- console/src/assets/i18n/de.json | 4 ++-- console/src/assets/i18n/en.json | 4 ++-- console/src/assets/i18n/es.json | 4 ++-- console/src/assets/i18n/fr.json | 4 ++-- console/src/assets/i18n/hu.json | 4 ++-- console/src/assets/i18n/id.json | 4 ++-- console/src/assets/i18n/it.json | 4 ++-- console/src/assets/i18n/ja.json | 4 ++-- console/src/assets/i18n/mk.json | 4 ++-- console/src/assets/i18n/nl.json | 4 ++-- console/src/assets/i18n/pl.json | 4 ++-- console/src/assets/i18n/pt.json | 3 ++- console/src/assets/i18n/ru.json | 3 ++- console/src/assets/i18n/sv.json | 4 ++-- console/src/assets/i18n/zh.json | 4 ++-- 17 files changed, 34 insertions(+), 32 deletions(-) diff --git a/console/src/assets/i18n/bg.json b/console/src/assets/i18n/bg.json index c196e230a1..9402ae5bb2 100644 --- a/console/src/assets/i18n/bg.json +++ b/console/src/assets/i18n/bg.json @@ -1722,8 +1722,8 @@ "ALLOWUSERNAMEPASSWORD_DESC": "Разрешено е конвенционалното влизане с потребителско име и парола.", "ALLOWEXTERNALIDP_DESC": "Входът е разрешен за основните доставчици на самоличност", "ALLOWREGISTER_DESC": "Ако опцията е избрана, в входа се появява допълнителна стъпка за регистрация на потребител.", - "FORCEMFA": "Сила MFA", - "FORCEMFALOCALONLY": "Принудително MFA за локални потребители", + "FORCEMFA": "Наложи MFA за всички потребители", + "FORCEMFALOCALONLY": "Наложи MFA само за локално автентифицирани потребители", "FORCEMFALOCALONLY_DESC": "Ако е избрана опцията, локалните удостоверени потребители трябва да конфигурират втори фактор за влизане.", "HIDEPASSWORDRESET_DESC": "Ако опцията е избрана, потребителят не може да нулира паролата си в процеса на влизане.", "HIDELOGINNAMESUFFIX": "Скриване на суфикса на името за влизане", diff --git a/console/src/assets/i18n/cs.json b/console/src/assets/i18n/cs.json index 817587574f..2f34468cd2 100644 --- a/console/src/assets/i18n/cs.json +++ b/console/src/assets/i18n/cs.json @@ -1724,8 +1724,8 @@ "ALLOWUSERNAMEPASSWORD_DESC": "Je povoleno klasické přihlášení s uživatelským jménem a heslem.", "ALLOWEXTERNALIDP_DESC": "Přihlášení je povoleno pro níže uvedené poskytovatele identity.", "ALLOWREGISTER_DESC": "Pokud je možnost vybrána, objeví se při přihlášení další krok pro registraci uživatele.", - "FORCEMFA": "Vynutit MFA", - "FORCEMFALOCALONLY": "Vynutit MFA pouze pro lokálně ověřené uživatele", + "FORCEMFA": "Vynuti MFA pro všechny uživatele", + "FORCEMFALOCALONLY": "Vynutit MFA pouze pro místně ověřené uživatele", "FORCEMFALOCALONLY_DESC": "Pokud je možnost vybrána, lokálně ověření uživatelé musí pro přihlášení nastavit druhý faktor.", "HIDEPASSWORDRESET_DESC": "Pokud je možnost vybrána, uživatel nemůže během přihlašovacího procesu resetovat své heslo.", "HIDELOGINNAMESUFFIX": "Skrýt příponu přihlašovacího jména", diff --git a/console/src/assets/i18n/de.json b/console/src/assets/i18n/de.json index b8f0e3285b..4adf55be3e 100644 --- a/console/src/assets/i18n/de.json +++ b/console/src/assets/i18n/de.json @@ -1723,8 +1723,8 @@ "ALLOWUSERNAMEPASSWORD_DESC": "Der konventionelle Login mit Benutzername und Passwort wird erlaubt.", "ALLOWEXTERNALIDP_DESC": "Der Login wird für die darunter liegenden Identitätsanbieter erlaubt.", "ALLOWREGISTER_DESC": "Ist die Option gewählt, erscheint im Login ein zusätzlicher Schritt zum Registrieren eines Benutzers.", - "FORCEMFA": "MFA erzwingen", - "FORCEMFALOCALONLY": "MFA für lokale Benutzer erzwingen", + "FORCEMFA": "MFA für alle Benutzer erzwingen", + "FORCEMFALOCALONLY": "MFA nur für lokal authentifizierte Benutzer erzwingen", "FORCEMFALOCALONLY_DESC": "Ist die Option gewählt, müssen lokal authentifizierte Benutzer einen zweiten Faktor für den Login verwenden.", "HIDEPASSWORDRESET_DESC": "Ist die Option gewählt, ist es nicht möglich im Login das Passwort zurück zusetzen via Passwort vergessen Link.", "HIDELOGINNAMESUFFIX": "Loginname Suffix ausblenden", diff --git a/console/src/assets/i18n/en.json b/console/src/assets/i18n/en.json index d18a7114fe..4e7d2e13d9 100644 --- a/console/src/assets/i18n/en.json +++ b/console/src/assets/i18n/en.json @@ -1723,8 +1723,8 @@ "ALLOWUSERNAMEPASSWORD_DESC": "The conventional login with user name and password is allowed.", "ALLOWEXTERNALIDP_DESC": "The login is allowed for the underlying identity providers", "ALLOWREGISTER_DESC": "If the option is selected, an additional step for registering a user appears in the login.", - "FORCEMFA": "Force MFA", - "FORCEMFALOCALONLY": "Force MFA for local authenticated users", + "FORCEMFA": "Force MFA for all users", + "FORCEMFALOCALONLY": "Force MFA for local authenticated users only", "FORCEMFALOCALONLY_DESC": "If the option is selected, local authenticated users have to configure a second factor for login.", "HIDEPASSWORDRESET_DESC": "If the option is selected, the user can't reset his password in the login process.", "HIDELOGINNAMESUFFIX": "Hide Loginname suffix", diff --git a/console/src/assets/i18n/es.json b/console/src/assets/i18n/es.json index 2367e12471..06532cc849 100644 --- a/console/src/assets/i18n/es.json +++ b/console/src/assets/i18n/es.json @@ -1724,8 +1724,8 @@ "ALLOWUSERNAMEPASSWORD_DESC": "El inicio de sesión convencional con nombre de usuario y contraseña está permitido.", "ALLOWEXTERNALIDP_DESC": "El inicio de sesión está permitido para los proveedores de identidad subyacentes", "ALLOWREGISTER_DESC": "Si esta opción es seleccionada, aparece un paso adicional durante el inicio de sesión para registrar un usuario.", - "FORCEMFA": "Forzar MFA", - "FORCEMFALOCALONLY": "Forzar MFA para usuarios locales", + "FORCEMFA": "Forzar MFA para todos los usuarios", + "FORCEMFALOCALONLY": "Forzar MFA solo para usuarios autenticados localmente", "FORCEMFALOCALONLY_DESC": "Si esta opción es seleccionada, los usuarios autenticados localmente tendrán que configurar un doble factor para iniciar sesión", "HIDEPASSWORDRESET_DESC": "Si esta opción es seleccionada, el usuario no podrá restablecer su contraseña en el proceso de inicio de sesión.", "HIDELOGINNAMESUFFIX": "Ocultar sufijo del nombre de inicio de sesión", diff --git a/console/src/assets/i18n/fr.json b/console/src/assets/i18n/fr.json index 72423c79ec..2814abdc97 100644 --- a/console/src/assets/i18n/fr.json +++ b/console/src/assets/i18n/fr.json @@ -1723,8 +1723,8 @@ "ALLOWUSERNAMEPASSWORD_DESC": "La connexion classique avec nom d'utilisateur et mot de passe est autorisée.", "ALLOWEXTERNALIDP_DESC": "La connexion est autorisée pour les fournisseurs d'identité sous-jacents", "ALLOWREGISTER_DESC": "Si l'option est sélectionnée, une étape supplémentaire pour l'enregistrement d'un utilisateur apparaît dans la connexion.", - "FORCEMFA": "Forcer MFA", - "FORCEMFALOCALONLY": "Forcer MFA pour les utilisateurs locaux", + "FORCEMFA": "Forcer MFA pour tous les utilisateurs", + "FORCEMFALOCALONLY": "Forcer MFA uniquement pour les utilisateurs authentifiés localement", "FORCEMFALOCALONLY_DESC": "Si l'option est sélectionnée, les utilisateurs locaux authentifiés doivent configurer un deuxième facteur pour la connexion.", "HIDEPASSWORDRESET_DESC": "Si l'option est sélectionnée, l'utilisateur ne peut pas réinitialiser son mot de passe lors du processus de connexion.", "HIDELOGINNAMESUFFIX": "Masquer le suffixe du nom de connexion", diff --git a/console/src/assets/i18n/hu.json b/console/src/assets/i18n/hu.json index 064dd8ea5f..0ffa6b92b6 100644 --- a/console/src/assets/i18n/hu.json +++ b/console/src/assets/i18n/hu.json @@ -1721,8 +1721,8 @@ "ALLOWUSERNAMEPASSWORD_DESC": "A hagyományos bejelentkezés felhasználónévvel és jelszóval engedélyezett.", "ALLOWEXTERNALIDP_DESC": "A bejelentkezés engedélyezett az alapul szolgáló identitásszolgáltatóknál", "ALLOWREGISTER_DESC": "Ha ezt az opciót választod, egy további lépés jelenik meg a bejelentkezés során a felhasználói regisztrációhoz.", - "FORCEMFA": "MFA kényszerítése", - "FORCEMFALOCALONLY": "Kényszerítsd az MFA-t a helyileg hitelesített felhasználókra", + "FORCEMFA": "MFA kikényszerítése minden felhasználó számára", + "FORCEMFALOCALONLY": "MFA kikényszerítése csak helyi hitelesített felhasználók számára", "FORCEMFALOCALONLY_DESC": "Ha ezt az opciót választod, a helyileg hitelesített felhasználóknak be kell állítaniuk egy második faktor a bejelentkezéshez.", "HIDEPASSWORDRESET_DESC": "Ha ezt az opciót választod, a felhasználó nem tudja visszaállítani a jelszavát a bejelentkezési folyamat során.", "HIDELOGINNAMESUFFIX": "Bejelentkezési név utótag elrejtése", diff --git a/console/src/assets/i18n/id.json b/console/src/assets/i18n/id.json index 9dd80d7902..c12fbbe555 100644 --- a/console/src/assets/i18n/id.json +++ b/console/src/assets/i18n/id.json @@ -1587,8 +1587,8 @@ "ALLOWUSERNAMEPASSWORD_DESC": "Login konvensional dengan nama pengguna dan kata sandi diperbolehkan.", "ALLOWEXTERNALIDP_DESC": "Login diperbolehkan untuk penyedia identitas yang mendasarinya", "ALLOWREGISTER_DESC": "Jika opsi ini dipilih, langkah tambahan untuk mendaftarkan pengguna akan muncul di login.", - "FORCEMFA": "Paksa MFA", - "FORCEMFALOCALONLY": "Paksa MFA untuk pengguna lokal yang diautentikasi", + "FORCEMFA": "Memaksa MFA untuk semua pengguna", + "FORCEMFALOCALONLY": "Memaksa MFA hanya untuk pengguna yang diautentikasi lokal", "FORCEMFALOCALONLY_DESC": "Jika opsi ini dipilih, pengguna yang diautentikasi lokal harus mengonfigurasi faktor kedua untuk login.", "HIDEPASSWORDRESET_DESC": "Jika opsi ini dipilih, pengguna tidak dapat mengatur ulang kata sandinya dalam proses login.", "HIDELOGINNAMESUFFIX": "Sembunyikan akhiran Nama Login", diff --git a/console/src/assets/i18n/it.json b/console/src/assets/i18n/it.json index f69e97bea7..d21396991c 100644 --- a/console/src/assets/i18n/it.json +++ b/console/src/assets/i18n/it.json @@ -1723,8 +1723,8 @@ "ALLOWUSERNAMEPASSWORD_DESC": "Autenticazione classica con nome utente e password è permessa.", "ALLOWEXTERNALIDP_DESC": "Il login è permesso per gli IDP sottostanti", "ALLOWREGISTER_DESC": "Se l'opzione è selezionata, nel login apparirà un passo aggiuntivo per la registrazione di un utente.", - "FORCEMFA": "Forza MFA", - "FORCEMFALOCALONLY": "Forza MFA per gli utenti locali", + "FORCEMFA": "Forzare MFA per tutti gli utenti", + "FORCEMFALOCALONLY": "Forzare MFA solo per gli utenti autenticati localmente", "FORCEMFALOCALONLY_DESC": "Se l'opzione è selezionata, gli utenti locali autenticati devono configurare un secondo fattore per l'accesso.", "HIDEPASSWORDRESET_DESC": "Se l'opzione è selezionata, l'utente non può resettare la sua password nel interfaccia login.", "HIDELOGINNAMESUFFIX": "Nascondi il suffisso del nome utente", diff --git a/console/src/assets/i18n/ja.json b/console/src/assets/i18n/ja.json index 7e9f102ecf..936262d132 100644 --- a/console/src/assets/i18n/ja.json +++ b/console/src/assets/i18n/ja.json @@ -1718,8 +1718,8 @@ "ALLOWUSERNAMEPASSWORD_DESC": "ユーザー名とパスワードを使用した従来のログインを許可します。", "ALLOWEXTERNALIDP_DESC": "基礎となるIDプロバイダーにログインを許可します。", "ALLOWREGISTER_DESC": "このオプションが選択されている場合、ユーザーを登録するための追加のステップがログインに表示されます。", - "FORCEMFA": "MFAを強制する", - "FORCEMFALOCALONLY": "ローカル ユーザーに MFA を強制する", + "FORCEMFA": "すべてのユーザーに MFA を強制する", + "FORCEMFALOCALONLY": "ローカル認証ユーザーのみに MFA を強制する", "FORCEMFALOCALONLY_DESC": "オプションが選択されている場合、ローカル認証されたユーザーはログインの 2 番目の要素を構成する必要があります。", "HIDEPASSWORDRESET_DESC": "このオプションが選択されている場合、ユーザーはログイン過程ででパスワードをリセットできません。", "HIDELOGINNAMESUFFIX": "ログイン名の接尾辞を非表示にする", diff --git a/console/src/assets/i18n/mk.json b/console/src/assets/i18n/mk.json index 43b08abc9d..ab0481b6bf 100644 --- a/console/src/assets/i18n/mk.json +++ b/console/src/assets/i18n/mk.json @@ -1724,8 +1724,8 @@ "ALLOWUSERNAMEPASSWORD_DESC": "Дозволена е конвенционална најава со корисничко име и лозинка.", "ALLOWEXTERNALIDP_DESC": "Најавата е дозволена за поддржуваните IDPs", "ALLOWREGISTER_DESC": "Доколку е избрана опцијата, се прикажува дополнителен чекор за регистрирање на корисник во најавата.", - "FORCEMFA": "Задолжителна MFA", - "FORCEMFALOCALONLY": "Force MFA за локални корисници", + "FORCEMFA": "Наметнете MFA за сите корисници", + "FORCEMFALOCALONLY": "Наметнете MFA само за локално автентифицирани корисници", "FORCEMFALOCALONLY_DESC": "Ако е избрана опцијата, локалните автентицирани корисници треба да конфигурираат втор фактор за најавување.", "HIDEPASSWORDRESET_DESC": "Доколку е избрана опцијата, корисникот нема да може да ја ресетира својата лозинка во процесот на најава.", "HIDELOGINNAMESUFFIX": "Сокриј го суфиксот на корисничкото име", diff --git a/console/src/assets/i18n/nl.json b/console/src/assets/i18n/nl.json index aa7008d57c..efc5513e68 100644 --- a/console/src/assets/i18n/nl.json +++ b/console/src/assets/i18n/nl.json @@ -1721,8 +1721,8 @@ "ALLOWUSERNAMEPASSWORD_DESC": "De conventionele login met gebruikersnaam en wachtwoord is toegestaan.", "ALLOWEXTERNALIDP_DESC": "De login is toegestaan voor de onderliggende identiteitsproviders", "ALLOWREGISTER_DESC": "Als de optie is geselecteerd, verschijnt er een extra stap voor het registreren van een gebruiker in het login proces.", - "FORCEMFA": "Forceer MFA", - "FORCEMFALOCALONLY": "Forceer MFA voor lokaal geauthenticeerde gebruikers", + "FORCEMFA": "MFA afdwingen voor alle gebruikers", + "FORCEMFALOCALONLY": "MFA alleen afdwingen voor lokaal geverifieerde gebruikers", "FORCEMFALOCALONLY_DESC": "Als de optie is geselecteerd, moeten lokaal geauthenticeerde gebruikers een tweede factor configureren voor login.", "HIDEPASSWORDRESET_DESC": "Als de optie is geselecteerd, kan de gebruiker zijn wachtwoord niet resetten in het login proces.", "HIDELOGINNAMESUFFIX": "Verberg Inlognaam achtervoegsel", diff --git a/console/src/assets/i18n/pl.json b/console/src/assets/i18n/pl.json index 0b4fd0daba..0443b89a89 100644 --- a/console/src/assets/i18n/pl.json +++ b/console/src/assets/i18n/pl.json @@ -1722,8 +1722,8 @@ "ALLOWUSERNAMEPASSWORD_DESC": "Zwykłe logowanie za pomocą nazwy użytkownika i hasła jest dozwolone.", "ALLOWEXTERNALIDP_DESC": "Logowanie jest dozwolone dla dostawców tożsamości podstawowych", "ALLOWREGISTER_DESC": "Jeśli ta opcja jest zaznaczona, pojawi się dodatkowy krok rejestracji użytkownika w procesie logowania.", - "FORCEMFA": "Wymuś MFA", - "FORCEMFALOCALONLY": "Wymuś MFA dla lokalnych użytkowników", + "FORCEMFA": "Wymuś MFA dla wszystkich użytkowników", + "FORCEMFALOCALONLY": "Wymuś MFA tylko dla lokalnie uwierzytelnionych użytkowników", "FORCEMFALOCALONLY_DESC": "Jeśli ta opcja jest zaznaczona, lokalni uwierzytelnieni użytkownicy muszą skonfigurować drugi czynnik logowania.", "HIDEPASSWORDRESET_DESC": "Jeśli ta opcja jest zaznaczona, użytkownik nie może zresetować swojego hasła w procesie logowania.", "HIDELOGINNAMESUFFIX": "Ukryj sufiks nazwy użytkownika", diff --git a/console/src/assets/i18n/pt.json b/console/src/assets/i18n/pt.json index 74ede27301..3bbb4e9c9b 100644 --- a/console/src/assets/i18n/pt.json +++ b/console/src/assets/i18n/pt.json @@ -1724,7 +1724,8 @@ "ALLOWUSERNAMEPASSWORD_DESC": "O login convencional com nome de usuário e senha é permitido.", "ALLOWEXTERNALIDP_DESC": "O login é permitido para os provedores de identidade subjacentes", "ALLOWREGISTER_DESC": "Se a opção estiver selecionada, uma etapa adicional para registrar um usuário aparecerá no login.", - "FORCEMFA": "Forçar MFA", + "FORCEMFA": "Forçar MFA para todos os utilizadores", + "FORCEMFALOCALONLY": "Forçar MFA apenas para utilizadores autenticados localmente", "HIDEPASSWORDRESET_DESC": "Se a opção estiver selecionada, o usuário não poderá redefinir sua senha no processo de login.", "HIDELOGINNAMESUFFIX": "Ocultar sufixo do nome de login", "HIDELOGINNAMESUFFIX_DESC": "Oculta o sufixo do nome de login na interface de login", diff --git a/console/src/assets/i18n/ru.json b/console/src/assets/i18n/ru.json index 775428ebcd..cdbb49d708 100644 --- a/console/src/assets/i18n/ru.json +++ b/console/src/assets/i18n/ru.json @@ -1794,7 +1794,8 @@ "ALLOWUSERNAMEPASSWORD_DESC": "Допускается стандартный вход с именем пользователя и паролем.", "ALLOWEXTERNALIDP_DESC": "Вход разрешён для основных поставщиков идентификационных данных.", "ALLOWREGISTER_DESC": "Если данный параметр выбран, при входе в систему появляется дополнительный шаг для регистрации пользователя.", - "FORCEMFA": "Принудительная многофакторная аутентификация (MFA)", + "FORCEMFA": "Принудительная многофакторная аутентификация для всех пользователей", + "FORCEMFALOCALONLY": "Принудительная многофакторная аутентификация только для локально аутентифицированных пользователей", "FORCEMFA_DESC": "Если данный параметр выбран, пользователи должны настроить двухфакторную аутентификацию для входа в систему.", "HIDEPASSWORDRESET": "Скрыть сброс пароля", "HIDEPASSWORDRESET_DESC": "Если данный параметр выбран, пользователь не может сбросить свой пароль в процессе входа в систему.", diff --git a/console/src/assets/i18n/sv.json b/console/src/assets/i18n/sv.json index d8be2e7f0f..93ed8ac72b 100644 --- a/console/src/assets/i18n/sv.json +++ b/console/src/assets/i18n/sv.json @@ -1727,8 +1727,8 @@ "ALLOWUSERNAMEPASSWORD_DESC": "Den konventionella inloggningen med användarnamn och lösenord är tillåten.", "ALLOWEXTERNALIDP_DESC": "Inloggning är tillåten för de underliggande identitetsleverantörerna", "ALLOWREGISTER_DESC": "Om alternativet är valt visas ett ytterligare steg för att registrera en användare i inloggningen.", - "FORCEMFA": "Tvinga MFA", - "FORCEMFALOCALONLY": "Tvinga MFA för lokalt autentiserade användare", + "FORCEMFA": "Tvinga MFA för alla användare", + "FORCEMFALOCALONLY": "Tvinga MFA endast för lokalt autentiserade användare", "FORCEMFALOCALONLY_DESC": "Om alternativet är valt måste lokalt autentiserade användare konfigurera en andra faktor för inloggning.", "HIDEPASSWORDRESET_DESC": "Om alternativet är valt kan användaren inte återställa sitt lösenord i inloggningsprocessen.", "HIDELOGINNAMESUFFIX": "Dölj inloggningsnamn suffix", diff --git a/console/src/assets/i18n/zh.json b/console/src/assets/i18n/zh.json index 50f108533d..4f1b1d1d46 100644 --- a/console/src/assets/i18n/zh.json +++ b/console/src/assets/i18n/zh.json @@ -1722,8 +1722,8 @@ "ALLOWUSERNAMEPASSWORD_DESC": "允许使用用户名和密码进行登录。", "ALLOWEXTERNALIDP_DESC": "允许外部身份提供者进行登录", "ALLOWREGISTER_DESC": "如果选择了该选项,登录中会出现一个用于注册用户的附加步骤。", - "FORCEMFA": "强制使用 MFA", - "FORCEMFALOCALONLY": "对本地用户强制执行 MFA", + "FORCEMFA": "强制所有用户使用 MFA", + "FORCEMFALOCALONLY": "仅强制本地认证用户使用 MFA", "FORCEMFALOCALONLY_DESC": "如果选择该选项,本地经过身份验证的用户必须配置第二个登录因素。", "HIDEPASSWORDRESET_DESC": "如果选择该选项,则用户无法在登录过程中重置其密码。", "HIDELOGINNAMESUFFIX": "隐藏登录名后缀", From c07a5f4277277284d6ddd31a1b0f4c86da39c005 Mon Sep 17 00:00:00 2001 From: Stefan Benz <46600784+stebenz@users.noreply.github.com> Date: Tue, 3 Dec 2024 11:14:04 +0100 Subject: [PATCH 41/64] fix: consistent permission check on user v2 (#8807) # Which Problems Are Solved Some user v2 API calls checked for permission only on the user itself. # How the Problems Are Solved Consistent check for permissions on user v2 API. # Additional Changes None # Additional Context Closes #7944 --------- Co-authored-by: Livio Spring --- .../grpc/user/v2/integration_test/otp_test.go | 73 ++++++++++++++++--- .../user/v2/integration_test/passkey_test.go | 19 ++++- internal/api/grpc/user/v2/otp.go | 1 - .../user/v2beta/integration_test/otp_test.go | 71 +++++++++++++++--- .../v2beta/integration_test/passkey_test.go | 19 ++++- internal/command/user_human_otp.go | 43 ++++------- internal/command/user_human_password.go | 2 +- internal/command/user_human_webauthn.go | 14 ++-- internal/command/user_v2.go | 10 +++ internal/command/user_v2_email.go | 13 +--- internal/command/user_v2_invite.go | 7 +- internal/command/user_v2_passkey.go | 3 +- internal/command/user_v2_passkey_test.go | 24 +++--- internal/command/user_v2_password.go | 7 +- internal/command/user_v2_phone.go | 12 +-- 15 files changed, 213 insertions(+), 105 deletions(-) diff --git a/internal/api/grpc/user/v2/integration_test/otp_test.go b/internal/api/grpc/user/v2/integration_test/otp_test.go index ae7c040427..01e6c07a40 100644 --- a/internal/api/grpc/user/v2/integration_test/otp_test.go +++ b/internal/api/grpc/user/v2/integration_test/otp_test.go @@ -58,7 +58,7 @@ func TestServer_AddOTPSMS(t *testing.T) { wantErr: true, }, { - name: "user mismatch", + name: "no permission", args: args{ ctx: integration.WithAuthorizationToken(context.Background(), sessionTokenOtherUser), req: &user.AddOTPSMSRequest{ @@ -127,14 +127,24 @@ func TestServer_RemoveOTPSMS(t *testing.T) { userVerified := Instance.CreateHumanUser(CTX) Instance.RegisterUserPasskey(CTX, userVerified.GetUserId()) - _, sessionTokenVerified, _, _ := Instance.CreateVerifiedWebAuthNSession(t, CTX, userVerified.GetUserId()) - userVerifiedCtx := integration.WithAuthorizationToken(context.Background(), sessionTokenVerified) - _, err := Instance.Client.UserV2.VerifyPhone(userVerifiedCtx, &user.VerifyPhoneRequest{ + _, err := Instance.Client.UserV2.VerifyPhone(CTX, &user.VerifyPhoneRequest{ UserId: userVerified.GetUserId(), VerificationCode: userVerified.GetPhoneCode(), }) require.NoError(t, err) - _, err = Instance.Client.UserV2.AddOTPSMS(userVerifiedCtx, &user.AddOTPSMSRequest{UserId: userVerified.GetUserId()}) + _, err = Instance.Client.UserV2.AddOTPSMS(CTX, &user.AddOTPSMSRequest{UserId: userVerified.GetUserId()}) + require.NoError(t, err) + + userSelf := Instance.CreateHumanUser(CTX) + Instance.RegisterUserPasskey(CTX, userSelf.GetUserId()) + _, sessionTokenSelf, _, _ := Instance.CreateVerifiedWebAuthNSession(t, CTX, userSelf.GetUserId()) + userSelfCtx := integration.WithAuthorizationToken(context.Background(), sessionTokenSelf) + _, err = Instance.Client.UserV2.VerifyPhone(CTX, &user.VerifyPhoneRequest{ + UserId: userSelf.GetUserId(), + VerificationCode: userSelf.GetPhoneCode(), + }) + require.NoError(t, err) + _, err = Instance.Client.UserV2.AddOTPSMS(CTX, &user.AddOTPSMSRequest{UserId: userSelf.GetUserId()}) require.NoError(t, err) type args struct { @@ -157,10 +167,24 @@ func TestServer_RemoveOTPSMS(t *testing.T) { }, wantErr: true, }, + { + name: "success, self", + args: args{ + ctx: userSelfCtx, + req: &user.RemoveOTPSMSRequest{ + UserId: userSelf.GetUserId(), + }, + }, + want: &user.RemoveOTPSMSResponse{ + Details: &object.Details{ + ResourceOwner: Instance.DefaultOrg.Details.ResourceOwner, + }, + }, + }, { name: "success", args: args{ - ctx: userVerifiedCtx, + ctx: CTX, req: &user.RemoveOTPSMSRequest{ UserId: userVerified.GetUserId(), }, @@ -230,7 +254,7 @@ func TestServer_AddOTPEmail(t *testing.T) { wantErr: true, }, { - name: "user mismatch", + name: "no permission", args: args{ ctx: integration.WithAuthorizationToken(context.Background(), sessionTokenOtherUser), req: &user.AddOTPEmailRequest{ @@ -301,14 +325,24 @@ func TestServer_RemoveOTPEmail(t *testing.T) { userVerified := Instance.CreateHumanUser(CTX) Instance.RegisterUserPasskey(CTX, userVerified.GetUserId()) - _, sessionTokenVerified, _, _ := Instance.CreateVerifiedWebAuthNSession(t, CTX, userVerified.GetUserId()) - userVerifiedCtx := integration.WithAuthorizationToken(context.Background(), sessionTokenVerified) - _, err := Instance.Client.UserV2.VerifyEmail(userVerifiedCtx, &user.VerifyEmailRequest{ + _, err := Instance.Client.UserV2.VerifyEmail(CTX, &user.VerifyEmailRequest{ UserId: userVerified.GetUserId(), VerificationCode: userVerified.GetEmailCode(), }) require.NoError(t, err) - _, err = Instance.Client.UserV2.AddOTPEmail(userVerifiedCtx, &user.AddOTPEmailRequest{UserId: userVerified.GetUserId()}) + _, err = Instance.Client.UserV2.AddOTPEmail(CTX, &user.AddOTPEmailRequest{UserId: userVerified.GetUserId()}) + require.NoError(t, err) + + userSelf := Instance.CreateHumanUser(CTX) + Instance.RegisterUserPasskey(CTX, userSelf.GetUserId()) + _, sessionTokenSelf, _, _ := Instance.CreateVerifiedWebAuthNSession(t, CTX, userSelf.GetUserId()) + userSelfCtx := integration.WithAuthorizationToken(context.Background(), sessionTokenSelf) + _, err = Instance.Client.UserV2.VerifyEmail(CTX, &user.VerifyEmailRequest{ + UserId: userSelf.GetUserId(), + VerificationCode: userSelf.GetEmailCode(), + }) + require.NoError(t, err) + _, err = Instance.Client.UserV2.AddOTPEmail(CTX, &user.AddOTPEmailRequest{UserId: userSelf.GetUserId()}) require.NoError(t, err) type args struct { @@ -331,10 +365,25 @@ func TestServer_RemoveOTPEmail(t *testing.T) { }, wantErr: true, }, + { + name: "success, self", + args: args{ + ctx: userSelfCtx, + req: &user.RemoveOTPEmailRequest{ + UserId: userSelf.GetUserId(), + }, + }, + want: &user.RemoveOTPEmailResponse{ + Details: &object.Details{ + ChangeDate: timestamppb.Now(), + ResourceOwner: Instance.DefaultOrg.Details.ResourceOwner, + }, + }, + }, { name: "success", args: args{ - ctx: userVerifiedCtx, + ctx: CTX, req: &user.RemoveOTPEmailRequest{ UserId: userVerified.GetUserId(), }, diff --git a/internal/api/grpc/user/v2/integration_test/passkey_test.go b/internal/api/grpc/user/v2/integration_test/passkey_test.go index 881ab17c09..055a47ec46 100644 --- a/internal/api/grpc/user/v2/integration_test/passkey_test.go +++ b/internal/api/grpc/user/v2/integration_test/passkey_test.go @@ -93,15 +93,30 @@ func TestServer_RegisterPasskey(t *testing.T) { wantErr: true, }, { - name: "user mismatch", + name: "user no permission", args: args{ - ctx: CTX, + ctx: UserCTX, req: &user.RegisterPasskeyRequest{ UserId: userID, }, }, wantErr: true, }, + { + name: "user permission", + args: args{ + ctx: IamCTX, + req: &user.RegisterPasskeyRequest{ + UserId: userID, + }, + }, + want: &user.RegisterPasskeyResponse{ + Details: &object.Details{ + ChangeDate: timestamppb.Now(), + ResourceOwner: Instance.DefaultOrg.Id, + }, + }, + }, { name: "user setting its own passkey", args: args{ diff --git a/internal/api/grpc/user/v2/otp.go b/internal/api/grpc/user/v2/otp.go index e2fe6b794d..fd76cf2b93 100644 --- a/internal/api/grpc/user/v2/otp.go +++ b/internal/api/grpc/user/v2/otp.go @@ -13,7 +13,6 @@ func (s *Server) AddOTPSMS(ctx context.Context, req *user.AddOTPSMSRequest) (*us return nil, err } return &user.AddOTPSMSResponse{Details: object.DomainToDetailsPb(details)}, nil - } func (s *Server) RemoveOTPSMS(ctx context.Context, req *user.RemoveOTPSMSRequest) (*user.RemoveOTPSMSResponse, error) { diff --git a/internal/api/grpc/user/v2beta/integration_test/otp_test.go b/internal/api/grpc/user/v2beta/integration_test/otp_test.go index 6d6e2eff3e..fae6c069a4 100644 --- a/internal/api/grpc/user/v2beta/integration_test/otp_test.go +++ b/internal/api/grpc/user/v2beta/integration_test/otp_test.go @@ -58,7 +58,7 @@ func TestServer_AddOTPSMS(t *testing.T) { wantErr: true, }, { - name: "user mismatch", + name: "no permission", args: args{ ctx: integration.WithAuthorizationToken(context.Background(), sessionTokenOtherUser), req: &user.AddOTPSMSRequest{ @@ -127,14 +127,24 @@ func TestServer_RemoveOTPSMS(t *testing.T) { userVerified := Instance.CreateHumanUser(CTX) Instance.RegisterUserPasskey(CTX, userVerified.GetUserId()) - _, sessionTokenVerified, _, _ := Instance.CreateVerifiedWebAuthNSession(t, CTX, userVerified.GetUserId()) - userVerifiedCtx := integration.WithAuthorizationToken(context.Background(), sessionTokenVerified) - _, err := Client.VerifyPhone(userVerifiedCtx, &user.VerifyPhoneRequest{ + _, err := Instance.Client.UserV2beta.VerifyPhone(CTX, &user.VerifyPhoneRequest{ UserId: userVerified.GetUserId(), VerificationCode: userVerified.GetPhoneCode(), }) require.NoError(t, err) - _, err = Client.AddOTPSMS(userVerifiedCtx, &user.AddOTPSMSRequest{UserId: userVerified.GetUserId()}) + _, err = Instance.Client.UserV2beta.AddOTPSMS(CTX, &user.AddOTPSMSRequest{UserId: userVerified.GetUserId()}) + require.NoError(t, err) + + userSelf := Instance.CreateHumanUser(CTX) + Instance.RegisterUserPasskey(CTX, userSelf.GetUserId()) + _, sessionTokenSelf, _, _ := Instance.CreateVerifiedWebAuthNSession(t, CTX, userSelf.GetUserId()) + userSelfCtx := integration.WithAuthorizationToken(context.Background(), sessionTokenSelf) + _, err = Instance.Client.UserV2beta.VerifyPhone(CTX, &user.VerifyPhoneRequest{ + UserId: userSelf.GetUserId(), + VerificationCode: userSelf.GetPhoneCode(), + }) + require.NoError(t, err) + _, err = Instance.Client.UserV2beta.AddOTPSMS(CTX, &user.AddOTPSMSRequest{UserId: userSelf.GetUserId()}) require.NoError(t, err) type args struct { @@ -157,10 +167,24 @@ func TestServer_RemoveOTPSMS(t *testing.T) { }, wantErr: true, }, + { + name: "success, self", + args: args{ + ctx: userSelfCtx, + req: &user.RemoveOTPSMSRequest{ + UserId: userSelf.GetUserId(), + }, + }, + want: &user.RemoveOTPSMSResponse{ + Details: &object.Details{ + ResourceOwner: Instance.DefaultOrg.Details.ResourceOwner, + }, + }, + }, { name: "success", args: args{ - ctx: userVerifiedCtx, + ctx: CTX, req: &user.RemoveOTPSMSRequest{ UserId: userVerified.GetUserId(), }, @@ -301,14 +325,24 @@ func TestServer_RemoveOTPEmail(t *testing.T) { userVerified := Instance.CreateHumanUser(CTX) Instance.RegisterUserPasskey(CTX, userVerified.GetUserId()) - _, sessionTokenVerified, _, _ := Instance.CreateVerifiedWebAuthNSession(t, CTX, userVerified.GetUserId()) - userVerifiedCtx := integration.WithAuthorizationToken(context.Background(), sessionTokenVerified) - _, err := Client.VerifyEmail(userVerifiedCtx, &user.VerifyEmailRequest{ + _, err := Client.VerifyEmail(CTX, &user.VerifyEmailRequest{ UserId: userVerified.GetUserId(), VerificationCode: userVerified.GetEmailCode(), }) require.NoError(t, err) - _, err = Client.AddOTPEmail(userVerifiedCtx, &user.AddOTPEmailRequest{UserId: userVerified.GetUserId()}) + _, err = Client.AddOTPEmail(CTX, &user.AddOTPEmailRequest{UserId: userVerified.GetUserId()}) + require.NoError(t, err) + + userSelf := Instance.CreateHumanUser(CTX) + Instance.RegisterUserPasskey(CTX, userSelf.GetUserId()) + _, sessionTokenSelf, _, _ := Instance.CreateVerifiedWebAuthNSession(t, IamCTX, userSelf.GetUserId()) + userSelfCtx := integration.WithAuthorizationToken(context.Background(), sessionTokenSelf) + _, err = Client.VerifyEmail(CTX, &user.VerifyEmailRequest{ + UserId: userSelf.GetUserId(), + VerificationCode: userSelf.GetEmailCode(), + }) + require.NoError(t, err) + _, err = Client.AddOTPEmail(CTX, &user.AddOTPEmailRequest{UserId: userSelf.GetUserId()}) require.NoError(t, err) type args struct { @@ -331,10 +365,25 @@ func TestServer_RemoveOTPEmail(t *testing.T) { }, wantErr: true, }, + { + name: "success, self", + args: args{ + ctx: userSelfCtx, + req: &user.RemoveOTPEmailRequest{ + UserId: userSelf.GetUserId(), + }, + }, + want: &user.RemoveOTPEmailResponse{ + Details: &object.Details{ + ChangeDate: timestamppb.Now(), + ResourceOwner: Instance.DefaultOrg.Details.ResourceOwner, + }, + }, + }, { name: "success", args: args{ - ctx: userVerifiedCtx, + ctx: CTX, req: &user.RemoveOTPEmailRequest{ UserId: userVerified.GetUserId(), }, diff --git a/internal/api/grpc/user/v2beta/integration_test/passkey_test.go b/internal/api/grpc/user/v2beta/integration_test/passkey_test.go index acca01885c..7bc0465956 100644 --- a/internal/api/grpc/user/v2beta/integration_test/passkey_test.go +++ b/internal/api/grpc/user/v2beta/integration_test/passkey_test.go @@ -92,15 +92,30 @@ func TestServer_RegisterPasskey(t *testing.T) { wantErr: true, }, { - name: "user mismatch", + name: "user no permission", args: args{ - ctx: CTX, + ctx: UserCTX, req: &user.RegisterPasskeyRequest{ UserId: userID, }, }, wantErr: true, }, + { + name: "user permission", + args: args{ + ctx: IamCTX, + req: &user.RegisterPasskeyRequest{ + UserId: userID, + }, + }, + want: &user.RegisterPasskeyResponse{ + Details: &object.Details{ + ChangeDate: timestamppb.Now(), + ResourceOwner: Instance.DefaultOrg.Id, + }, + }, + }, { name: "user setting its own passkey", args: args{ diff --git a/internal/command/user_human_otp.go b/internal/command/user_human_otp.go index e505288cbd..97596aabd8 100644 --- a/internal/command/user_human_otp.go +++ b/internal/command/user_human_otp.go @@ -7,7 +7,6 @@ import ( "github.com/pquerna/otp" "github.com/zitadel/logging" - "github.com/zitadel/zitadel/internal/api/authz" http_util "github.com/zitadel/zitadel/internal/api/http" "github.com/zitadel/zitadel/internal/command/preparation" "github.com/zitadel/zitadel/internal/crypto" @@ -79,10 +78,8 @@ func (c *Commands) createHumanTOTP(ctx context.Context, userID, resourceOwner st logging.WithError(err).WithField("traceID", tracing.TraceIDFromCtx(ctx)).Debug("unable to get human for loginname") return nil, zerrors.ThrowPreconditionFailed(err, "COMMAND-SqyJz", "Errors.User.NotFound") } - if authz.GetCtxData(ctx).UserID != userID { - if err := c.checkPermission(ctx, domain.PermissionUserCredentialWrite, human.ResourceOwner, userID); err != nil { - return nil, err - } + if err := c.checkPermissionUpdateUserCredentials(ctx, human.ResourceOwner, userID); err != nil { + return nil, err } org, err := c.getOrg(ctx, human.ResourceOwner) if err != nil { @@ -139,10 +136,8 @@ func (c *Commands) HumanCheckMFATOTPSetup(ctx context.Context, userID, code, use if err != nil { return nil, err } - if authz.GetCtxData(ctx).UserID != userID { - if err := c.checkPermission(ctx, domain.PermissionUserCredentialWrite, existingOTP.ResourceOwner, userID); err != nil { - return nil, err - } + if err := c.checkPermissionUpdateUserCredentials(ctx, existingOTP.ResourceOwner, userID); err != nil { + return nil, err } if existingOTP.State == domain.MFAStateUnspecified || existingOTP.State == domain.MFAStateRemoved { return nil, zerrors.ThrowNotFound(nil, "COMMAND-3Mif9s", "Errors.User.MFA.OTP.NotExisting") @@ -242,10 +237,8 @@ func (c *Commands) HumanRemoveTOTP(ctx context.Context, userID, resourceOwner st if existingOTP.State == domain.MFAStateUnspecified || existingOTP.State == domain.MFAStateRemoved { return nil, zerrors.ThrowNotFound(nil, "COMMAND-Hd9sd", "Errors.User.MFA.OTP.NotExisting") } - if userID != authz.GetCtxData(ctx).UserID { - if err := c.checkPermission(ctx, domain.PermissionUserWrite, existingOTP.ResourceOwner, userID); err != nil { - return nil, err - } + if err := c.checkPermissionUpdateUser(ctx, existingOTP.ResourceOwner, userID); err != nil { + return nil, err } userAgg := UserAggregateFromWriteModel(&existingOTP.WriteModel) pushedEvents, err := c.eventstore.Push(ctx, user.NewHumanOTPRemovedEvent(ctx, userAgg)) @@ -286,10 +279,8 @@ func (c *Commands) addHumanOTPSMS(ctx context.Context, userID, resourceOwner str if err != nil { return nil, err } - if authz.GetCtxData(ctx).UserID != userID { - if err := c.checkPermission(ctx, domain.PermissionUserCredentialWrite, otpWriteModel.ResourceOwner(), userID); err != nil { - return nil, err - } + if err := c.checkPermissionUpdateUserCredentials(ctx, otpWriteModel.ResourceOwner(), userID); err != nil { + return nil, err } if otpWriteModel.otpAdded { return nil, zerrors.ThrowAlreadyExists(nil, "COMMAND-Ad3g2", "Errors.User.MFA.OTP.AlreadyReady") @@ -318,10 +309,8 @@ func (c *Commands) RemoveHumanOTPSMS(ctx context.Context, userID, resourceOwner if err != nil { return nil, err } - if userID != authz.GetCtxData(ctx).UserID { - if err := c.checkPermission(ctx, domain.PermissionUserWrite, existingOTP.WriteModel.ResourceOwner, userID); err != nil { - return nil, err - } + if err := c.checkPermissionUpdateUser(ctx, existingOTP.WriteModel.ResourceOwner, userID); err != nil { + return nil, err } if !existingOTP.otpAdded { return nil, zerrors.ThrowNotFound(nil, "COMMAND-Sr3h3", "Errors.User.MFA.OTP.NotExisting") @@ -420,10 +409,8 @@ func (c *Commands) addHumanOTPEmail(ctx context.Context, userID, resourceOwner s if err != nil { return nil, err } - if authz.GetCtxData(ctx).UserID != userID { - if err := c.checkPermission(ctx, domain.PermissionUserCredentialWrite, otpWriteModel.ResourceOwner(), userID); err != nil { - return nil, err - } + if err := c.checkPermissionUpdateUserCredentials(ctx, otpWriteModel.ResourceOwner(), userID); err != nil { + return nil, err } if otpWriteModel.otpAdded { return nil, zerrors.ThrowAlreadyExists(nil, "COMMAND-MKL2s", "Errors.User.MFA.OTP.AlreadyReady") @@ -452,10 +439,8 @@ func (c *Commands) RemoveHumanOTPEmail(ctx context.Context, userID, resourceOwne if err != nil { return nil, err } - if userID != authz.GetCtxData(ctx).UserID { - if err := c.checkPermission(ctx, domain.PermissionUserWrite, existingOTP.WriteModel.ResourceOwner, userID); err != nil { - return nil, err - } + if err := c.checkPermissionUpdateUser(ctx, existingOTP.WriteModel.ResourceOwner, userID); err != nil { + return nil, err } if !existingOTP.otpAdded { return nil, zerrors.ThrowNotFound(nil, "COMMAND-b312D", "Errors.User.MFA.OTP.NotExisting") diff --git a/internal/command/user_human_password.go b/internal/command/user_human_password.go index 9b686f88b7..a67a4b91da 100644 --- a/internal/command/user_human_password.go +++ b/internal/command/user_human_password.go @@ -110,7 +110,7 @@ type setPasswordVerification func(ctx context.Context) (newEncodedPassword strin // setPasswordWithPermission returns a permission check as [setPasswordVerification] implementation func (c *Commands) setPasswordWithPermission(userID, orgID string) setPasswordVerification { return func(ctx context.Context) (_ string, err error) { - return "", c.checkPermission(ctx, domain.PermissionUserWrite, orgID, userID) + return "", c.checkPermissionUpdateUser(ctx, orgID, userID) } } diff --git a/internal/command/user_human_webauthn.go b/internal/command/user_human_webauthn.go index 3555466359..3b8a66e0d5 100644 --- a/internal/command/user_human_webauthn.go +++ b/internal/command/user_human_webauthn.go @@ -6,7 +6,6 @@ import ( "github.com/zitadel/logging" - "github.com/zitadel/zitadel/internal/api/authz" "github.com/zitadel/zitadel/internal/crypto" "github.com/zitadel/zitadel/internal/domain" "github.com/zitadel/zitadel/internal/eventstore" @@ -146,10 +145,8 @@ func (c *Commands) addHumanWebAuthN(ctx context.Context, userID, resourceowner, if err != nil { return nil, nil, nil, err } - if authz.GetCtxData(ctx).UserID != userID { - if err = c.checkPermission(ctx, domain.PermissionUserCredentialWrite, user.ResourceOwner, userID); err != nil { - return nil, nil, nil, err - } + if err := c.checkPermissionUpdateUserCredentials(ctx, user.ResourceOwner, userID); err != nil { + return nil, nil, nil, err } org, err := c.getOrg(ctx, user.ResourceOwner) if err != nil { @@ -603,10 +600,9 @@ func (c *Commands) removeHumanWebAuthN(ctx context.Context, userID, webAuthNID, if existingWebAuthN.State == domain.MFAStateUnspecified || existingWebAuthN.State == domain.MFAStateRemoved { return nil, zerrors.ThrowNotFound(nil, "COMMAND-DAfb2", "Errors.User.WebAuthN.NotFound") } - if userID != authz.GetCtxData(ctx).UserID { - if err := c.checkPermission(ctx, domain.PermissionUserWrite, existingWebAuthN.ResourceOwner, existingWebAuthN.AggregateID); err != nil { - return nil, err - } + + if err := c.checkPermissionUpdateUser(ctx, existingWebAuthN.ResourceOwner, existingWebAuthN.AggregateID); err != nil { + return nil, err } userAgg := UserAggregateFromWriteModel(&existingWebAuthN.WriteModel) diff --git a/internal/command/user_v2.go b/internal/command/user_v2.go index 032ac0b8f7..033a16eb9a 100644 --- a/internal/command/user_v2.go +++ b/internal/command/user_v2.go @@ -127,6 +127,16 @@ func (c *Commands) checkPermissionUpdateUser(ctx context.Context, resourceOwner, return nil } +func (c *Commands) checkPermissionUpdateUserCredentials(ctx context.Context, resourceOwner, userID string) error { + if userID != "" && userID == authz.GetCtxData(ctx).UserID { + return nil + } + if err := c.checkPermission(ctx, domain.PermissionUserCredentialWrite, resourceOwner, userID); err != nil { + return err + } + return nil +} + func (c *Commands) checkPermissionDeleteUser(ctx context.Context, resourceOwner, userID string) error { if userID != "" && userID == authz.GetCtxData(ctx).UserID { return nil diff --git a/internal/command/user_v2_email.go b/internal/command/user_v2_email.go index cc81f7399c..1618e2cd48 100644 --- a/internal/command/user_v2_email.go +++ b/internal/command/user_v2_email.go @@ -6,7 +6,6 @@ import ( "github.com/zitadel/logging" - "github.com/zitadel/zitadel/internal/api/authz" "github.com/zitadel/zitadel/internal/crypto" "github.com/zitadel/zitadel/internal/domain" "github.com/zitadel/zitadel/internal/eventstore" @@ -118,10 +117,8 @@ func (c *Commands) changeUserEmailWithGeneratorEvents(ctx context.Context, userI if err != nil { return nil, err } - if authz.GetCtxData(ctx).UserID != userID { - if err = c.checkPermission(ctx, domain.PermissionUserWrite, cmd.aggregate.ResourceOwner, userID); err != nil { - return nil, err - } + if err = c.checkPermissionUpdateUser(ctx, cmd.aggregate.ResourceOwner, userID); err != nil { + return nil, err } if err = cmd.Change(ctx, domain.EmailAddress(email)); err != nil { return nil, err @@ -137,10 +134,8 @@ func (c *Commands) resendUserEmailCodeWithGeneratorEvents(ctx context.Context, u if err != nil { return nil, err } - if authz.GetCtxData(ctx).UserID != userID { - if err = c.checkPermission(ctx, domain.PermissionUserWrite, cmd.aggregate.ResourceOwner, userID); err != nil { - return nil, err - } + if err = c.checkPermissionUpdateUser(ctx, cmd.aggregate.ResourceOwner, userID); err != nil { + return nil, err } if cmd.model.Code == nil { return nil, zerrors.ThrowPreconditionFailed(err, "EMAIL-5w5ilin4yt", "Errors.User.Code.Empty") diff --git a/internal/command/user_v2_invite.go b/internal/command/user_v2_invite.go index 78b46a530e..1325d2e0c9 100644 --- a/internal/command/user_v2_invite.go +++ b/internal/command/user_v2_invite.go @@ -6,7 +6,6 @@ import ( "github.com/zitadel/logging" - "github.com/zitadel/zitadel/internal/api/authz" "github.com/zitadel/zitadel/internal/crypto" "github.com/zitadel/zitadel/internal/domain" "github.com/zitadel/zitadel/internal/eventstore" @@ -74,10 +73,8 @@ func (c *Commands) ResendInviteCode(ctx context.Context, userID, resourceOwner, if err != nil { return nil, err } - if authz.GetCtxData(ctx).UserID != userID { - if err := c.checkPermission(ctx, domain.PermissionUserWrite, existingCode.ResourceOwner, userID); err != nil { - return nil, err - } + if err := c.checkPermissionUpdateUser(ctx, existingCode.ResourceOwner, userID); err != nil { + return nil, err } if !existingCode.UserState.Exists() { return nil, zerrors.ThrowPreconditionFailed(nil, "COMMAND-H3b2a", "Errors.User.NotFound") diff --git a/internal/command/user_v2_passkey.go b/internal/command/user_v2_passkey.go index 897a1ab41d..a386049744 100644 --- a/internal/command/user_v2_passkey.go +++ b/internal/command/user_v2_passkey.go @@ -6,7 +6,6 @@ import ( "github.com/zitadel/logging" - "github.com/zitadel/zitadel/internal/api/authz" "github.com/zitadel/zitadel/internal/command/preparation" "github.com/zitadel/zitadel/internal/crypto" "github.com/zitadel/zitadel/internal/domain" @@ -18,7 +17,7 @@ import ( // RegisterUserPasskey creates a passkey registration for the current authenticated user. // UserID, usually taken from the request is compared against the user ID in the context. func (c *Commands) RegisterUserPasskey(ctx context.Context, userID, resourceOwner, rpID string, authenticator domain.AuthenticatorAttachment) (*domain.WebAuthNRegistrationDetails, error) { - if err := authz.UserIDInCTX(ctx, userID); err != nil { + if err := c.checkPermissionUpdateUserCredentials(ctx, resourceOwner, userID); err != nil { return nil, err } return c.registerUserPasskey(ctx, userID, resourceOwner, rpID, authenticator) diff --git a/internal/command/user_v2_passkey_test.go b/internal/command/user_v2_passkey_test.go index a6ba470d2b..0d1009862c 100644 --- a/internal/command/user_v2_passkey_test.go +++ b/internal/command/user_v2_passkey_test.go @@ -34,8 +34,9 @@ func TestCommands_RegisterUserPasskey(t *testing.T) { } userAgg := &user.NewAggregate("user1", "org1").Aggregate type fields struct { - eventstore *eventstore.Eventstore - idGenerator id.Generator + eventstore func(t *testing.T) *eventstore.Eventstore + idGenerator id.Generator + checkPermission domain.PermissionCheck } type args struct { userID string @@ -51,18 +52,22 @@ func TestCommands_RegisterUserPasskey(t *testing.T) { wantErr error }{ { - name: "wrong user", + name: "no permission", + fields: fields{ + eventstore: expectEventstore(), + checkPermission: newMockPermissionCheckNotAllowed(), + }, args: args{ userID: "foo", resourceOwner: "org1", authenticator: domain.AuthenticatorAttachmentCrossPlattform, }, - wantErr: zerrors.ThrowPermissionDenied(nil, "AUTH-Bohd2", "Errors.User.UserIDWrong"), + wantErr: zerrors.ThrowPermissionDenied(nil, "AUTHZ-HKJD33", "Errors.PermissionDenied"), }, { name: "get human passwordless error", fields: fields{ - eventstore: eventstoreExpect(t, + eventstore: expectEventstore( expectFilterError(io.ErrClosedPipe), ), }, @@ -76,7 +81,7 @@ func TestCommands_RegisterUserPasskey(t *testing.T) { { name: "id generator error", fields: fields{ - eventstore: eventstoreExpect(t, + eventstore: expectEventstore( expectFilter(), // getHumanPasswordlessTokens expectFilter(eventFromEventPusher( user.NewHumanAddedEvent(ctx, @@ -118,9 +123,10 @@ func TestCommands_RegisterUserPasskey(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { c := &Commands{ - eventstore: tt.fields.eventstore, - idGenerator: tt.fields.idGenerator, - webauthnConfig: webauthnConfig, + eventstore: tt.fields.eventstore(t), + idGenerator: tt.fields.idGenerator, + webauthnConfig: webauthnConfig, + checkPermission: tt.fields.checkPermission, } _, err := c.RegisterUserPasskey(ctx, tt.args.userID, tt.args.resourceOwner, tt.args.rpID, tt.args.authenticator) require.ErrorIs(t, err, tt.wantErr) diff --git a/internal/command/user_v2_password.go b/internal/command/user_v2_password.go index 67bee2c28f..faa1fe14a6 100644 --- a/internal/command/user_v2_password.go +++ b/internal/command/user_v2_password.go @@ -4,7 +4,6 @@ import ( "context" "io" - "github.com/zitadel/zitadel/internal/api/authz" "github.com/zitadel/zitadel/internal/domain" "github.com/zitadel/zitadel/internal/repository/user" "github.com/zitadel/zitadel/internal/zerrors" @@ -50,10 +49,8 @@ func (c *Commands) requestPasswordReset(ctx context.Context, userID string, retu if model.UserState == domain.UserStateInitial { return nil, nil, zerrors.ThrowPreconditionFailed(nil, "COMMAND-Sfe4g", "Errors.User.NotInitialised") } - if authz.GetCtxData(ctx).UserID != userID { - if err = c.checkPermission(ctx, domain.PermissionUserWrite, model.ResourceOwner, userID); err != nil { - return nil, nil, err - } + if err = c.checkPermissionUpdateUser(ctx, model.ResourceOwner, userID); err != nil { + return nil, nil, err } var passwordCode *EncryptedCode var generatorID string diff --git a/internal/command/user_v2_phone.go b/internal/command/user_v2_phone.go index 8b754b36f3..8648f9a564 100644 --- a/internal/command/user_v2_phone.go +++ b/internal/command/user_v2_phone.go @@ -82,10 +82,8 @@ func (c *Commands) changeUserPhoneWithGenerator(ctx context.Context, userID, pho if err != nil { return nil, err } - if authz.GetCtxData(ctx).UserID != userID { - if err = c.checkPermission(ctx, domain.PermissionUserWrite, cmd.aggregate.ResourceOwner, userID); err != nil { - return nil, err - } + if err = c.checkPermissionUpdateUser(ctx, cmd.aggregate.ResourceOwner, userID); err != nil { + return nil, err } if err = cmd.Change(ctx, domain.PhoneNumber(phone)); err != nil { return nil, err @@ -104,10 +102,8 @@ func (c *Commands) resendUserPhoneCodeWithGenerator(ctx context.Context, userID if err != nil { return nil, err } - if authz.GetCtxData(ctx).UserID != userID { - if err = c.checkPermission(ctx, domain.PermissionUserWrite, cmd.aggregate.ResourceOwner, userID); err != nil { - return nil, err - } + if err = c.checkPermissionUpdateUser(ctx, cmd.aggregate.ResourceOwner, userID); err != nil { + return nil, err } if cmd.model.Code == nil && cmd.model.GeneratorID == "" { return nil, zerrors.ThrowPreconditionFailed(err, "PHONE-5xrra88eq8", "Errors.User.Code.Empty") From ffe95707769abde4ffffa7fde62fe957adf24ab1 Mon Sep 17 00:00:00 2001 From: Livio Spring Date: Tue, 3 Dec 2024 11:38:28 +0100 Subject: [PATCH 42/64] fix(saml): improve error handling (#8928) # Which Problems Are Solved There are multiple issues with the metadata and error handling of SAML: - When providing a SAML metadata for an IdP, which cannot be processed, the error will only be noticed once a user tries to use the IdP. - Parsing for metadata with any other encoding than UTF-8 fails. - Metadata containing an enclosing EntitiesDescriptor around EntityDescriptor cannot be parsed. - Metadata's `validUntil` value is always set to 48 hours, which causes issues on external providers, if processed from a manual down/upload. - If a SAML response cannot be parsed, only a generic "Authentication failed" error is returned, the cause is hidden to the user and also to actions. # How the Problems Are Solved - Return parsing errors after create / update and retrieval of an IdP in the API. - Prevent the creation and update of an IdP in case of a parsing failure. - Added decoders for encodings other than UTF-8 (including ASCII, windows and ISO, [currently supported](https://github.com/golang/text/blob/efd25daf282ae4d20d3625f1ccb4452fe40967ae/encoding/ianaindex/ianaindex.go#L156)) - Updated parsing to handle both `EntitiesDescriptor` and `EntityDescriptor` as root element - `validUntil` will automatically set to the certificate's expiration time - Unwrapped the hidden error to be returned. The Login UI will still only provide a mostly generic error, but action can now access the underlying error. # Additional Changes None # Additional Context reported by a customer --- internal/api/grpc/admin/idp_converter.go | 8 +- internal/api/grpc/management/idp_converter.go | 8 +- internal/command/instance_idp.go | 17 +- internal/command/instance_idp_test.go | 151 +++++++++++++++--- internal/command/org_idp.go | 17 +- internal/command/org_idp_test.go | 86 +++++++--- internal/idp/providers/saml/saml.go | 44 ++++- internal/idp/providers/saml/saml_test.go | 110 +++++++++++++ internal/idp/providers/saml/session.go | 3 +- internal/idp/providers/saml/session_test.go | 2 +- 10 files changed, 377 insertions(+), 69 deletions(-) diff --git a/internal/api/grpc/admin/idp_converter.go b/internal/api/grpc/admin/idp_converter.go index 2914f94b30..67e40a44ab 100644 --- a/internal/api/grpc/admin/idp_converter.go +++ b/internal/api/grpc/admin/idp_converter.go @@ -469,12 +469,12 @@ func updateAppleProviderToCommand(req *admin_pb.UpdateAppleProviderRequest) comm } } -func addSAMLProviderToCommand(req *admin_pb.AddSAMLProviderRequest) command.SAMLProvider { +func addSAMLProviderToCommand(req *admin_pb.AddSAMLProviderRequest) *command.SAMLProvider { var nameIDFormat *domain.SAMLNameIDFormat if req.NameIdFormat != nil { nameIDFormat = gu.Ptr(idp_grpc.SAMLNameIDFormatToDomain(req.GetNameIdFormat())) } - return command.SAMLProvider{ + return &command.SAMLProvider{ Name: req.Name, Metadata: req.GetMetadataXml(), MetadataURL: req.GetMetadataUrl(), @@ -486,12 +486,12 @@ func addSAMLProviderToCommand(req *admin_pb.AddSAMLProviderRequest) command.SAML } } -func updateSAMLProviderToCommand(req *admin_pb.UpdateSAMLProviderRequest) command.SAMLProvider { +func updateSAMLProviderToCommand(req *admin_pb.UpdateSAMLProviderRequest) *command.SAMLProvider { var nameIDFormat *domain.SAMLNameIDFormat if req.NameIdFormat != nil { nameIDFormat = gu.Ptr(idp_grpc.SAMLNameIDFormatToDomain(req.GetNameIdFormat())) } - return command.SAMLProvider{ + return &command.SAMLProvider{ Name: req.Name, Metadata: req.GetMetadataXml(), MetadataURL: req.GetMetadataUrl(), diff --git a/internal/api/grpc/management/idp_converter.go b/internal/api/grpc/management/idp_converter.go index 48d5a85a99..ef3914cc96 100644 --- a/internal/api/grpc/management/idp_converter.go +++ b/internal/api/grpc/management/idp_converter.go @@ -462,12 +462,12 @@ func updateAppleProviderToCommand(req *mgmt_pb.UpdateAppleProviderRequest) comma } } -func addSAMLProviderToCommand(req *mgmt_pb.AddSAMLProviderRequest) command.SAMLProvider { +func addSAMLProviderToCommand(req *mgmt_pb.AddSAMLProviderRequest) *command.SAMLProvider { var nameIDFormat *domain.SAMLNameIDFormat if req.NameIdFormat != nil { nameIDFormat = gu.Ptr(idp_grpc.SAMLNameIDFormatToDomain(req.GetNameIdFormat())) } - return command.SAMLProvider{ + return &command.SAMLProvider{ Name: req.Name, Metadata: req.GetMetadataXml(), MetadataURL: req.GetMetadataUrl(), @@ -479,12 +479,12 @@ func addSAMLProviderToCommand(req *mgmt_pb.AddSAMLProviderRequest) command.SAMLP } } -func updateSAMLProviderToCommand(req *mgmt_pb.UpdateSAMLProviderRequest) command.SAMLProvider { +func updateSAMLProviderToCommand(req *mgmt_pb.UpdateSAMLProviderRequest) *command.SAMLProvider { var nameIDFormat *domain.SAMLNameIDFormat if req.NameIdFormat != nil { nameIDFormat = gu.Ptr(idp_grpc.SAMLNameIDFormatToDomain(req.GetNameIdFormat())) } - return command.SAMLProvider{ + return &command.SAMLProvider{ Name: req.Name, Metadata: req.GetMetadataXml(), MetadataURL: req.GetMetadataUrl(), diff --git a/internal/command/instance_idp.go b/internal/command/instance_idp.go index c3940c007a..99ab506424 100644 --- a/internal/command/instance_idp.go +++ b/internal/command/instance_idp.go @@ -11,6 +11,7 @@ import ( "github.com/zitadel/zitadel/internal/crypto" "github.com/zitadel/zitadel/internal/domain" "github.com/zitadel/zitadel/internal/eventstore" + "github.com/zitadel/zitadel/internal/idp/providers/saml" "github.com/zitadel/zitadel/internal/repository/instance" "github.com/zitadel/zitadel/internal/zerrors" ) @@ -511,10 +512,10 @@ func (c *Commands) UpdateInstanceAppleProvider(ctx context.Context, id string, p return pushedEventsToObjectDetails(pushedEvents), nil } -func (c *Commands) AddInstanceSAMLProvider(ctx context.Context, provider SAMLProvider) (string, *domain.ObjectDetails, error) { +func (c *Commands) AddInstanceSAMLProvider(ctx context.Context, provider *SAMLProvider) (id string, details *domain.ObjectDetails, err error) { instanceID := authz.GetInstance(ctx).InstanceID() instanceAgg := instance.NewAggregate(instanceID) - id, err := c.idGenerator.Next() + id, err = c.idGenerator.Next() if err != nil { return "", nil, err } @@ -530,7 +531,7 @@ func (c *Commands) AddInstanceSAMLProvider(ctx context.Context, provider SAMLPro return id, pushedEventsToObjectDetails(pushedEvents), nil } -func (c *Commands) UpdateInstanceSAMLProvider(ctx context.Context, id string, provider SAMLProvider) (*domain.ObjectDetails, error) { +func (c *Commands) UpdateInstanceSAMLProvider(ctx context.Context, id string, provider *SAMLProvider) (details *domain.ObjectDetails, err error) { instanceID := authz.GetInstance(ctx).InstanceID() instanceAgg := instance.NewAggregate(instanceID) writeModel := NewSAMLInstanceIDPWriteModel(instanceID, id) @@ -1719,7 +1720,7 @@ func (c *Commands) prepareUpdateInstanceAppleProvider(a *instance.Aggregate, wri } } -func (c *Commands) prepareAddInstanceSAMLProvider(a *instance.Aggregate, writeModel *InstanceSAMLIDPWriteModel, provider SAMLProvider) preparation.Validation { +func (c *Commands) prepareAddInstanceSAMLProvider(a *instance.Aggregate, writeModel *InstanceSAMLIDPWriteModel, provider *SAMLProvider) preparation.Validation { return func() (preparation.CreateCommands, error) { if provider.Name = strings.TrimSpace(provider.Name); provider.Name == "" { return nil, zerrors.ThrowInvalidArgument(nil, "INST-o07zjotgnd", "Errors.Invalid.Argument") @@ -1734,6 +1735,9 @@ func (c *Commands) prepareAddInstanceSAMLProvider(a *instance.Aggregate, writeMo if len(provider.Metadata) == 0 { return nil, zerrors.ThrowInvalidArgument(nil, "INST-3bi3esi16t", "Errors.Invalid.Argument") } + if _, err := saml.ParseMetadata(provider.Metadata); err != nil { + return nil, zerrors.ThrowInvalidArgument(err, "INST-SF3rwhgh", "Errors.Project.App.SAMLMetadataFormat") + } return func(ctx context.Context, filter preparation.FilterToQueryReducer) ([]eventstore.Command, error) { events, err := filter(ctx, writeModel.Query()) if err != nil { @@ -1772,7 +1776,7 @@ func (c *Commands) prepareAddInstanceSAMLProvider(a *instance.Aggregate, writeMo } } -func (c *Commands) prepareUpdateInstanceSAMLProvider(a *instance.Aggregate, writeModel *InstanceSAMLIDPWriteModel, provider SAMLProvider) preparation.Validation { +func (c *Commands) prepareUpdateInstanceSAMLProvider(a *instance.Aggregate, writeModel *InstanceSAMLIDPWriteModel, provider *SAMLProvider) preparation.Validation { return func() (preparation.CreateCommands, error) { if writeModel.ID = strings.TrimSpace(writeModel.ID); writeModel.ID == "" { return nil, zerrors.ThrowInvalidArgument(nil, "INST-7o3rq1owpm", "Errors.Invalid.Argument") @@ -1790,6 +1794,9 @@ func (c *Commands) prepareUpdateInstanceSAMLProvider(a *instance.Aggregate, writ } provider.Metadata = data } + if _, err := saml.ParseMetadata(provider.Metadata); err != nil { + return nil, zerrors.ThrowInvalidArgument(err, "INST-dsfj3kl2", "Errors.Project.App.SAMLMetadataFormat") + } return func(ctx context.Context, filter preparation.FilterToQueryReducer) ([]eventstore.Command, error) { events, err := filter(ctx, writeModel.Query()) if err != nil { diff --git a/internal/command/instance_idp_test.go b/internal/command/instance_idp_test.go index c6181af9f1..22defda532 100644 --- a/internal/command/instance_idp_test.go +++ b/internal/command/instance_idp_test.go @@ -22,6 +22,73 @@ import ( "github.com/zitadel/zitadel/internal/zerrors" ) +var ( + validSAMLMetadata = []byte(` + + + + + + + + + + + + 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 + + + + + + + urn:oasis:names:tc:SAML:2.0:profiles:attribute:basic + + + + + + + + + urn:oasis:names:tc:SAML:2.0:nameid-format:persistent + + + http://localhost:8080/saml/v2/metadata IDP signing + + MIIFIjCCAwqgAwIBAgICA7QwDQYJKoZIhvcNAQELBQAwLDEQMA4GA1UEChMHWklUQURFTDEYMBYGA1UEAxMPWklUQURFTCBTQU1MIENBMB4XDTI0MTEyNzEwMjUwMloXDTI1MTEyNzE2MjUwMlowMjEQMA4GA1UEChMHWklUQURFTDEeMBwGA1UEAxMVWklUQURFTCBTQU1MIHJlc3BvbnNlMIICIjANBgkqhkiG9w0BAQEFAAOCAg8AMIICCgKCAgEA2lUgaI6AS/9xvM9DNSWK6Ho64LpK8UIioM26QfvAfeQ/I2pgX6SwWxEbd7qv+PkJzaFTjrXSlwOmWsJYma+UsdyFClaGFRyCgY8SWxPceandC8a+hQIDS/irLd9XF33RWp0b/09HjQl+n0HZ4teUFDUd2U1mUf3XCpn0+Ho316bmi6xSW6zaMy5RsbUl01hgWj2fgapAsGAHSBphwCE3Dz/9I/UfHWQw1k2/UTgjc9uIujcza6WgOxfsKluXYIOxwNKTfmzzOJMUwXz6GRgB2jhQI29MuKOZOITA7pXq5kZKf0lSRU8zKFTMJaK4zAHQ6f877Drr8XdAHemuXGZ2JdH/Dbdwarzy3YBMCWsAYlpeEvaVAdiSpyR7fAZktNuHd39Zg00Vlj2wdc44Vk5yVssW7pv5qnVZ7JTrXX2uBYFecLAXmplQ2ph1VdSXZLEDGgjiNA2T/fBj7G4/VjsuCBZFm1I0KCJp3HWEJx5dwwhSVc5wOJEzl7fMuPYMKWH/RM6P/7LnO1ulpdmiKPa4gHzdg3hDZn42NKcVt3UYf0phtxpWMrZp/DUEeizhckrC4ed6cfGtS3CUtJEqoycrCROJ5Hy+ONHl5Aqxt+JoPU+t/XATuctfPxQVcDr0itHzo2cjh/AVTU+IC7C0oQHSS9CC8Fp58UqbtYwFtSAd7ecCAwEAAaNIMEYwDgYDVR0PAQH/BAQDAgWgMBMGA1UdJQQMMAoGCCsGAQUFBwMCMB8GA1UdIwQYMBaAFIzl7uckcPWldirXeOFL3rH6K8FLMA0GCSqGSIb3DQEBCwUAA4ICAQAp+IGZScVIbRdCq5HPjlYBPOY7UbL8ZXnlMW/HLELV9GndnULuFhnuQTIdA5dquCsk8RI1fKsScEV1rqWvHZeSo5nVbvUaPJctoD/4GACqE6F8axs1AgSOvpJMyuycjSzSh6gDM1z37Fdqc/2IRqgi7SKdDsfJpi8XW8LtErpp4kyE1rEXopsXG2fe1UH25bZpXraUqYvp61rwVUCazAtV/U7ARG5AnT0mPqzUriIPrfL+v/+2ntV/BSc8/uCqYnHbwpIwjPURCaxo1Pmm6EEkm+V/Ss4ieNwwkD2bLLLST1LoVMim7Ebfy53PEKpsznKsGlVSu0YYKUsStWQVpwhKQw0bQLCJHdpvZtZSDgS9RbSMZz+aY/fpoNx6wDvmMgtdrb3pVXZ8vPKdq9YDrGfFqP60QdZ3CuSHXCM/zX4742GgImJ4KYAcTuF1+BkGf5JLAJOUZBkfCQ/kBT5wr8+EotLxASOC6717whLBYMEG6N8osEk+LDqoJRTLqkzirJsyOHWChKK47yGkdS3HBIZfo91QrJwKpfATYziBjEnqipkTu+6jFylBIkxKTPye4b3vgcodZP8LSNVXAsMGTPNPJxzPWQ37ba4zMnYZ5iUerlaox/SNsn68DT6RajIb1A1JDq+HNFc3hQP2bzk2y5pCax8zo5swjdklnm4clfB2Lw== + + + + + + + urn:oasis:names:tc:SAML:2.0:nameid-format:persistent + urn:oasis:names:tc:SAML:2.0:profiles:attribute:basic + + + + + + + + + http://localhost:8080/saml/v2/metadata IDP signing + + MIIFIjCCAwqgAwIBAgICA7QwDQYJKoZIhvcNAQELBQAwLDEQMA4GA1UEChMHWklUQURFTDEYMBYGA1UEAxMPWklUQURFTCBTQU1MIENBMB4XDTI0MTEyNzEwMjUwMloXDTI1MTEyNzE2MjUwMlowMjEQMA4GA1UEChMHWklUQURFTDEeMBwGA1UEAxMVWklUQURFTCBTQU1MIHJlc3BvbnNlMIICIjANBgkqhkiG9w0BAQEFAAOCAg8AMIICCgKCAgEA2lUgaI6AS/9xvM9DNSWK6Ho64LpK8UIioM26QfvAfeQ/I2pgX6SwWxEbd7qv+PkJzaFTjrXSlwOmWsJYma+UsdyFClaGFRyCgY8SWxPceandC8a+hQIDS/irLd9XF33RWp0b/09HjQl+n0HZ4teUFDUd2U1mUf3XCpn0+Ho316bmi6xSW6zaMy5RsbUl01hgWj2fgapAsGAHSBphwCE3Dz/9I/UfHWQw1k2/UTgjc9uIujcza6WgOxfsKluXYIOxwNKTfmzzOJMUwXz6GRgB2jhQI29MuKOZOITA7pXq5kZKf0lSRU8zKFTMJaK4zAHQ6f877Drr8XdAHemuXGZ2JdH/Dbdwarzy3YBMCWsAYlpeEvaVAdiSpyR7fAZktNuHd39Zg00Vlj2wdc44Vk5yVssW7pv5qnVZ7JTrXX2uBYFecLAXmplQ2ph1VdSXZLEDGgjiNA2T/fBj7G4/VjsuCBZFm1I0KCJp3HWEJx5dwwhSVc5wOJEzl7fMuPYMKWH/RM6P/7LnO1ulpdmiKPa4gHzdg3hDZn42NKcVt3UYf0phtxpWMrZp/DUEeizhckrC4ed6cfGtS3CUtJEqoycrCROJ5Hy+ONHl5Aqxt+JoPU+t/XATuctfPxQVcDr0itHzo2cjh/AVTU+IC7C0oQHSS9CC8Fp58UqbtYwFtSAd7ecCAwEAAaNIMEYwDgYDVR0PAQH/BAQDAgWgMBMGA1UdJQQMMAoGCCsGAQUFBwMCMB8GA1UdIwQYMBaAFIzl7uckcPWldirXeOFL3rH6K8FLMA0GCSqGSIb3DQEBCwUAA4ICAQAp+IGZScVIbRdCq5HPjlYBPOY7UbL8ZXnlMW/HLELV9GndnULuFhnuQTIdA5dquCsk8RI1fKsScEV1rqWvHZeSo5nVbvUaPJctoD/4GACqE6F8axs1AgSOvpJMyuycjSzSh6gDM1z37Fdqc/2IRqgi7SKdDsfJpi8XW8LtErpp4kyE1rEXopsXG2fe1UH25bZpXraUqYvp61rwVUCazAtV/U7ARG5AnT0mPqzUriIPrfL+v/+2ntV/BSc8/uCqYnHbwpIwjPURCaxo1Pmm6EEkm+V/Ss4ieNwwkD2bLLLST1LoVMim7Ebfy53PEKpsznKsGlVSu0YYKUsStWQVpwhKQw0bQLCJHdpvZtZSDgS9RbSMZz+aY/fpoNx6wDvmMgtdrb3pVXZ8vPKdq9YDrGfFqP60QdZ3CuSHXCM/zX4742GgImJ4KYAcTuF1+BkGf5JLAJOUZBkfCQ/kBT5wr8+EotLxASOC6717whLBYMEG6N8osEk+LDqoJRTLqkzirJsyOHWChKK47yGkdS3HBIZfo91QrJwKpfATYziBjEnqipkTu+6jFylBIkxKTPye4b3vgcodZP8LSNVXAsMGTPNPJxzPWQ37ba4zMnYZ5iUerlaox/SNsn68DT6RajIb1A1JDq+HNFc3hQP2bzk2y5pCax8zo5swjdklnm4clfB2Lw== + + + + +`) +) + func TestCommandSide_AddInstanceGenericOAuthIDP(t *testing.T) { type fields struct { eventstore func(*testing.T) *eventstore.Eventstore @@ -5180,7 +5247,7 @@ func TestCommandSide_AddInstanceSAMLIDP(t *testing.T) { } type args struct { ctx context.Context - provider SAMLProvider + provider *SAMLProvider } type res struct { id string @@ -5201,7 +5268,7 @@ func TestCommandSide_AddInstanceSAMLIDP(t *testing.T) { }, args{ ctx: authz.WithInstanceID(context.Background(), "instance1"), - provider: SAMLProvider{}, + provider: &SAMLProvider{}, }, res{ err: func(err error) bool { @@ -5210,14 +5277,14 @@ func TestCommandSide_AddInstanceSAMLIDP(t *testing.T) { }, }, { - "invalid metadata", + "no metadata", fields{ eventstore: expectEventstore(), idGenerator: id_mock.NewIDGeneratorExpectIDs(t, "id1"), }, args{ ctx: authz.WithInstanceID(context.Background(), "instance1"), - provider: SAMLProvider{ + provider: &SAMLProvider{ Name: "name", }, }, @@ -5227,6 +5294,25 @@ func TestCommandSide_AddInstanceSAMLIDP(t *testing.T) { }, }, }, + { + "invalid metadata, error", + fields{ + eventstore: expectEventstore(), + idGenerator: id_mock.NewIDGeneratorExpectIDs(t, "id1"), + }, + args{ + ctx: authz.WithInstanceID(context.Background(), "instance1"), + provider: &SAMLProvider{ + Name: "name", + Metadata: []byte("metadata"), + }, + }, + res{ + err: func(err error) bool { + return errors.Is(err, zerrors.ThrowInvalidArgument(nil, "INST-SF3rwhgh", "Errors.Project.App.SAMLMetadataFormat")) + }, + }, + }, { name: "ok", fields: fields{ @@ -5236,7 +5322,7 @@ func TestCommandSide_AddInstanceSAMLIDP(t *testing.T) { instance.NewSAMLIDPAddedEvent(context.Background(), &instance.NewAggregate("instance1").Aggregate, "id1", "name", - []byte("metadata"), + validSAMLMetadata, &crypto.CryptoValue{ CryptoType: crypto.TypeEncryption, Algorithm: "enc", @@ -5258,9 +5344,9 @@ func TestCommandSide_AddInstanceSAMLIDP(t *testing.T) { }, args: args{ ctx: authz.WithInstanceID(context.Background(), "instance1"), - provider: SAMLProvider{ + provider: &SAMLProvider{ Name: "name", - Metadata: []byte("metadata"), + Metadata: validSAMLMetadata, }, }, res: res{ @@ -5277,7 +5363,7 @@ func TestCommandSide_AddInstanceSAMLIDP(t *testing.T) { instance.NewSAMLIDPAddedEvent(context.Background(), &instance.NewAggregate("instance1").Aggregate, "id1", "name", - []byte("metadata"), + validSAMLMetadata, &crypto.CryptoValue{ CryptoType: crypto.TypeEncryption, Algorithm: "enc", @@ -5304,9 +5390,9 @@ func TestCommandSide_AddInstanceSAMLIDP(t *testing.T) { }, args: args{ ctx: authz.WithInstanceID(context.Background(), "instance1"), - provider: SAMLProvider{ + provider: &SAMLProvider{ Name: "name", - Metadata: []byte("metadata"), + Metadata: validSAMLMetadata, Binding: "binding", WithSignedRequest: true, NameIDFormat: gu.Ptr(domain.SAMLNameIDFormatTransient), @@ -5356,7 +5442,7 @@ func TestCommandSide_UpdateInstanceGenericSAMLIDP(t *testing.T) { type args struct { ctx context.Context id string - provider SAMLProvider + provider *SAMLProvider } type res struct { want *domain.ObjectDetails @@ -5375,7 +5461,7 @@ func TestCommandSide_UpdateInstanceGenericSAMLIDP(t *testing.T) { }, args{ ctx: authz.WithInstanceID(context.Background(), "instance1"), - provider: SAMLProvider{}, + provider: &SAMLProvider{}, }, res{ err: func(err error) bool { @@ -5391,7 +5477,7 @@ func TestCommandSide_UpdateInstanceGenericSAMLIDP(t *testing.T) { args{ ctx: authz.WithInstanceID(context.Background(), "instance1"), id: "id1", - provider: SAMLProvider{}, + provider: &SAMLProvider{}, }, res{ err: func(err error) bool { @@ -5400,14 +5486,14 @@ func TestCommandSide_UpdateInstanceGenericSAMLIDP(t *testing.T) { }, }, { - "invalid metadata", + "no metadata", fields{ eventstore: expectEventstore(), }, args{ ctx: authz.WithInstanceID(context.Background(), "instance1"), id: "id1", - provider: SAMLProvider{ + provider: &SAMLProvider{ Name: "name", }, }, @@ -5417,6 +5503,25 @@ func TestCommandSide_UpdateInstanceGenericSAMLIDP(t *testing.T) { }, }, }, + { + "invalid metadata, error", + fields{ + eventstore: expectEventstore(), + }, + args{ + ctx: authz.WithInstanceID(context.Background(), "instance1"), + id: "id1", + provider: &SAMLProvider{ + Name: "name", + Metadata: []byte("metadata"), + }, + }, + res{ + err: func(err error) bool { + return errors.Is(err, zerrors.ThrowInvalidArgument(nil, "INST-dsfj3kl2", "Errors.Project.App.SAMLMetadataFormat")) + }, + }, + }, { name: "not found", fields: fields{ @@ -5427,9 +5532,9 @@ func TestCommandSide_UpdateInstanceGenericSAMLIDP(t *testing.T) { args: args{ ctx: authz.WithInstanceID(context.Background(), "instance1"), id: "id1", - provider: SAMLProvider{ + provider: &SAMLProvider{ Name: "name", - Metadata: []byte("metadata"), + Metadata: validSAMLMetadata, }, }, res: res{ @@ -5445,7 +5550,7 @@ func TestCommandSide_UpdateInstanceGenericSAMLIDP(t *testing.T) { instance.NewSAMLIDPAddedEvent(context.Background(), &instance.NewAggregate("instance1").Aggregate, "id1", "name", - []byte("metadata"), + validSAMLMetadata, &crypto.CryptoValue{ CryptoType: crypto.TypeEncryption, Algorithm: "enc", @@ -5465,9 +5570,9 @@ func TestCommandSide_UpdateInstanceGenericSAMLIDP(t *testing.T) { args: args{ ctx: authz.WithInstanceID(context.Background(), "instance1"), id: "id1", - provider: SAMLProvider{ + provider: &SAMLProvider{ Name: "name", - Metadata: []byte("metadata"), + Metadata: validSAMLMetadata, }, }, res: res{ @@ -5505,7 +5610,7 @@ func TestCommandSide_UpdateInstanceGenericSAMLIDP(t *testing.T) { "id1", []idp.SAMLIDPChanges{ idp.ChangeSAMLName("new name"), - idp.ChangeSAMLMetadata([]byte("new metadata")), + idp.ChangeSAMLMetadata(validSAMLMetadata), idp.ChangeSAMLBinding("new binding"), idp.ChangeSAMLWithSignedRequest(true), idp.ChangeSAMLNameIDFormat(gu.Ptr(domain.SAMLNameIDFormatTransient)), @@ -5527,9 +5632,9 @@ func TestCommandSide_UpdateInstanceGenericSAMLIDP(t *testing.T) { args: args{ ctx: authz.WithInstanceID(context.Background(), "instance1"), id: "id1", - provider: SAMLProvider{ + provider: &SAMLProvider{ Name: "new name", - Metadata: []byte("new metadata"), + Metadata: validSAMLMetadata, Binding: "new binding", WithSignedRequest: true, NameIDFormat: gu.Ptr(domain.SAMLNameIDFormatTransient), diff --git a/internal/command/org_idp.go b/internal/command/org_idp.go index 597783c78f..26690b3a66 100644 --- a/internal/command/org_idp.go +++ b/internal/command/org_idp.go @@ -10,6 +10,7 @@ import ( "github.com/zitadel/zitadel/internal/crypto" "github.com/zitadel/zitadel/internal/domain" "github.com/zitadel/zitadel/internal/eventstore" + "github.com/zitadel/zitadel/internal/idp/providers/saml" "github.com/zitadel/zitadel/internal/repository/org" "github.com/zitadel/zitadel/internal/zerrors" ) @@ -446,9 +447,9 @@ func (c *Commands) UpdateOrgLDAPProvider(ctx context.Context, resourceOwner, id return pushedEventsToObjectDetails(pushedEvents), nil } -func (c *Commands) AddOrgSAMLProvider(ctx context.Context, resourceOwner string, provider SAMLProvider) (string, *domain.ObjectDetails, error) { +func (c *Commands) AddOrgSAMLProvider(ctx context.Context, resourceOwner string, provider *SAMLProvider) (id string, details *domain.ObjectDetails, err error) { orgAgg := org.NewAggregate(resourceOwner) - id, err := c.idGenerator.Next() + id, err = c.idGenerator.Next() if err != nil { return "", nil, err } @@ -464,7 +465,7 @@ func (c *Commands) AddOrgSAMLProvider(ctx context.Context, resourceOwner string, return id, pushedEventsToObjectDetails(pushedEvents), nil } -func (c *Commands) UpdateOrgSAMLProvider(ctx context.Context, resourceOwner, id string, provider SAMLProvider) (*domain.ObjectDetails, error) { +func (c *Commands) UpdateOrgSAMLProvider(ctx context.Context, resourceOwner, id string, provider *SAMLProvider) (details *domain.ObjectDetails, err error) { orgAgg := org.NewAggregate(resourceOwner) writeModel := NewSAMLOrgIDPWriteModel(resourceOwner, id) cmds, err := preparation.PrepareCommands(ctx, c.eventstore.Filter, c.prepareUpdateOrgSAMLProvider(orgAgg, writeModel, provider)) @@ -1703,7 +1704,7 @@ func (c *Commands) prepareUpdateOrgAppleProvider(a *org.Aggregate, writeModel *O } } -func (c *Commands) prepareAddOrgSAMLProvider(a *org.Aggregate, writeModel *OrgSAMLIDPWriteModel, provider SAMLProvider) preparation.Validation { +func (c *Commands) prepareAddOrgSAMLProvider(a *org.Aggregate, writeModel *OrgSAMLIDPWriteModel, provider *SAMLProvider) preparation.Validation { return func() (preparation.CreateCommands, error) { if provider.Name = strings.TrimSpace(provider.Name); provider.Name == "" { return nil, zerrors.ThrowInvalidArgument(nil, "ORG-957lr0f8u3", "Errors.Invalid.Argument") @@ -1718,6 +1719,9 @@ func (c *Commands) prepareAddOrgSAMLProvider(a *org.Aggregate, writeModel *OrgSA } provider.Metadata = data } + if _, err := saml.ParseMetadata(provider.Metadata); err != nil { + return nil, zerrors.ThrowInvalidArgument(err, "ORG-SF3rwhgh", "Errors.Project.App.SAMLMetadataFormat") + } return func(ctx context.Context, filter preparation.FilterToQueryReducer) ([]eventstore.Command, error) { events, err := filter(ctx, writeModel.Query()) if err != nil { @@ -1755,7 +1759,7 @@ func (c *Commands) prepareAddOrgSAMLProvider(a *org.Aggregate, writeModel *OrgSA } } -func (c *Commands) prepareUpdateOrgSAMLProvider(a *org.Aggregate, writeModel *OrgSAMLIDPWriteModel, provider SAMLProvider) preparation.Validation { +func (c *Commands) prepareUpdateOrgSAMLProvider(a *org.Aggregate, writeModel *OrgSAMLIDPWriteModel, provider *SAMLProvider) preparation.Validation { return func() (preparation.CreateCommands, error) { if writeModel.ID = strings.TrimSpace(writeModel.ID); writeModel.ID == "" { return nil, zerrors.ThrowInvalidArgument(nil, "ORG-wwdwdlaya0", "Errors.Invalid.Argument") @@ -1773,6 +1777,9 @@ func (c *Commands) prepareUpdateOrgSAMLProvider(a *org.Aggregate, writeModel *Or if provider.Metadata == nil { return nil, zerrors.ThrowInvalidArgument(nil, "ORG-j6spncd74m", "Errors.Invalid.Argument") } + if _, err := saml.ParseMetadata(provider.Metadata); err != nil { + return nil, zerrors.ThrowInvalidArgument(err, "ORG-SFqqh42", "Errors.Project.App.SAMLMetadataFormat") + } return func(ctx context.Context, filter preparation.FilterToQueryReducer) ([]eventstore.Command, error) { events, err := filter(ctx, writeModel.Query()) if err != nil { diff --git a/internal/command/org_idp_test.go b/internal/command/org_idp_test.go index b2b4632cf6..75321bb603 100644 --- a/internal/command/org_idp_test.go +++ b/internal/command/org_idp_test.go @@ -5348,7 +5348,7 @@ func TestCommandSide_AddOrgSAMLIDP(t *testing.T) { type args struct { ctx context.Context resourceOwner string - provider SAMLProvider + provider *SAMLProvider } type res struct { id string @@ -5370,7 +5370,7 @@ func TestCommandSide_AddOrgSAMLIDP(t *testing.T) { args{ ctx: context.Background(), resourceOwner: "org1", - provider: SAMLProvider{}, + provider: &SAMLProvider{}, }, res{ err: func(err error) bool { @@ -5379,7 +5379,7 @@ func TestCommandSide_AddOrgSAMLIDP(t *testing.T) { }, }, { - "invalid metadata", + "no metadata", fields{ eventstore: expectEventstore(), idGenerator: id_mock.NewIDGeneratorExpectIDs(t, "id1"), @@ -5387,7 +5387,7 @@ func TestCommandSide_AddOrgSAMLIDP(t *testing.T) { args{ ctx: context.Background(), resourceOwner: "org1", - provider: SAMLProvider{ + provider: &SAMLProvider{ Name: "name", }, }, @@ -5397,6 +5397,26 @@ func TestCommandSide_AddOrgSAMLIDP(t *testing.T) { }, }, }, + { + "invalid metadata, fail on error", + fields{ + eventstore: expectEventstore(), + idGenerator: id_mock.NewIDGeneratorExpectIDs(t, "id1"), + }, + args{ + ctx: context.Background(), + resourceOwner: "org1", + provider: &SAMLProvider{ + Name: "name", + Metadata: []byte("metadata"), + }, + }, + res{ + err: func(err error) bool { + return errors.Is(err, zerrors.ThrowInvalidArgument(nil, "ORG-SF3rwhgh", "Errors.Project.App.SAMLMetadataFormat")) + }, + }, + }, { name: "ok", fields: fields{ @@ -5406,7 +5426,7 @@ func TestCommandSide_AddOrgSAMLIDP(t *testing.T) { org.NewSAMLIDPAddedEvent(context.Background(), &org.NewAggregate("org1").Aggregate, "id1", "name", - []byte("metadata"), + validSAMLMetadata, &crypto.CryptoValue{ CryptoType: crypto.TypeEncryption, Algorithm: "enc", @@ -5428,9 +5448,9 @@ func TestCommandSide_AddOrgSAMLIDP(t *testing.T) { args: args{ ctx: context.Background(), resourceOwner: "org1", - provider: SAMLProvider{ + provider: &SAMLProvider{ Name: "name", - Metadata: []byte("metadata"), + Metadata: validSAMLMetadata, }, }, res: res{ @@ -5447,7 +5467,7 @@ func TestCommandSide_AddOrgSAMLIDP(t *testing.T) { org.NewSAMLIDPAddedEvent(context.Background(), &org.NewAggregate("org1").Aggregate, "id1", "name", - []byte("metadata"), + validSAMLMetadata, &crypto.CryptoValue{ CryptoType: crypto.TypeEncryption, Algorithm: "enc", @@ -5475,9 +5495,9 @@ func TestCommandSide_AddOrgSAMLIDP(t *testing.T) { args: args{ ctx: context.Background(), resourceOwner: "org1", - provider: SAMLProvider{ + provider: &SAMLProvider{ Name: "name", - Metadata: []byte("metadata"), + Metadata: validSAMLMetadata, Binding: "binding", WithSignedRequest: true, NameIDFormat: gu.Ptr(domain.SAMLNameIDFormatTransient), @@ -5528,7 +5548,7 @@ func TestCommandSide_UpdateOrgSAMLIDP(t *testing.T) { ctx context.Context resourceOwner string id string - provider SAMLProvider + provider *SAMLProvider } type res struct { want *domain.ObjectDetails @@ -5548,7 +5568,7 @@ func TestCommandSide_UpdateOrgSAMLIDP(t *testing.T) { args{ ctx: context.Background(), resourceOwner: "org1", - provider: SAMLProvider{}, + provider: &SAMLProvider{}, }, res{ err: func(err error) bool { @@ -5565,7 +5585,7 @@ func TestCommandSide_UpdateOrgSAMLIDP(t *testing.T) { ctx: context.Background(), resourceOwner: "org1", id: "id1", - provider: SAMLProvider{}, + provider: &SAMLProvider{}, }, res{ err: func(err error) bool { @@ -5574,7 +5594,7 @@ func TestCommandSide_UpdateOrgSAMLIDP(t *testing.T) { }, }, { - "invalid metadata", + "no metadata", fields{ eventstore: expectEventstore(), }, @@ -5582,7 +5602,7 @@ func TestCommandSide_UpdateOrgSAMLIDP(t *testing.T) { ctx: context.Background(), resourceOwner: "org1", id: "id1", - provider: SAMLProvider{ + provider: &SAMLProvider{ Name: "name", }, }, @@ -5592,6 +5612,26 @@ func TestCommandSide_UpdateOrgSAMLIDP(t *testing.T) { }, }, }, + { + "invalid metadata, error", + fields{ + eventstore: expectEventstore(), + }, + args{ + ctx: context.Background(), + resourceOwner: "org1", + id: "id1", + provider: &SAMLProvider{ + Name: "name", + Metadata: []byte("metadata"), + }, + }, + res{ + err: func(err error) bool { + return errors.Is(err, zerrors.ThrowInvalidArgument(nil, "ORG-SFqqh42", "Errors.Project.App.SAMLMetadataFormat")) + }, + }, + }, { name: "not found", fields: fields{ @@ -5603,9 +5643,9 @@ func TestCommandSide_UpdateOrgSAMLIDP(t *testing.T) { ctx: context.Background(), resourceOwner: "org1", id: "id1", - provider: SAMLProvider{ + provider: &SAMLProvider{ Name: "name", - Metadata: []byte("metadata"), + Metadata: validSAMLMetadata, }, }, res: res{ @@ -5623,7 +5663,7 @@ func TestCommandSide_UpdateOrgSAMLIDP(t *testing.T) { org.NewSAMLIDPAddedEvent(context.Background(), &org.NewAggregate("org1").Aggregate, "id1", "name", - []byte("metadata"), + validSAMLMetadata, &crypto.CryptoValue{ CryptoType: crypto.TypeEncryption, Algorithm: "enc", @@ -5644,9 +5684,9 @@ func TestCommandSide_UpdateOrgSAMLIDP(t *testing.T) { ctx: context.Background(), resourceOwner: "org1", id: "id1", - provider: SAMLProvider{ + provider: &SAMLProvider{ Name: "name", - Metadata: []byte("metadata"), + Metadata: validSAMLMetadata, }, }, res: res{ @@ -5684,7 +5724,7 @@ func TestCommandSide_UpdateOrgSAMLIDP(t *testing.T) { "id1", []idp.SAMLIDPChanges{ idp.ChangeSAMLName("new name"), - idp.ChangeSAMLMetadata([]byte("new metadata")), + idp.ChangeSAMLMetadata(validSAMLMetadata), idp.ChangeSAMLBinding("new binding"), idp.ChangeSAMLWithSignedRequest(true), idp.ChangeSAMLNameIDFormat(gu.Ptr(domain.SAMLNameIDFormatTransient)), @@ -5707,9 +5747,9 @@ func TestCommandSide_UpdateOrgSAMLIDP(t *testing.T) { ctx: context.Background(), resourceOwner: "org1", id: "id1", - provider: SAMLProvider{ + provider: &SAMLProvider{ Name: "new name", - Metadata: []byte("new metadata"), + Metadata: validSAMLMetadata, Binding: "new binding", WithSignedRequest: true, NameIDFormat: gu.Ptr(domain.SAMLNameIDFormatTransient), diff --git a/internal/idp/providers/saml/saml.go b/internal/idp/providers/saml/saml.go index 702e1481e3..e0391bc099 100644 --- a/internal/idp/providers/saml/saml.go +++ b/internal/idp/providers/saml/saml.go @@ -1,15 +1,19 @@ package saml import ( + "bytes" "context" "crypto/rsa" "crypto/tls" "crypto/x509" "encoding/xml" + "io" "net/url" + "time" "github.com/crewjam/saml" "github.com/crewjam/saml/samlsp" + "golang.org/x/text/encoding/ianaindex" "github.com/zitadel/zitadel/internal/domain" "github.com/zitadel/zitadel/internal/idp" @@ -104,6 +108,41 @@ func WithEntityID(entityID string) ProviderOpts { } } +// ParseMetadata parses the metadata with the provided XML encoding and returns the EntityDescriptor +func ParseMetadata(metadata []byte) (*saml.EntityDescriptor, error) { + entityDescriptor := new(saml.EntityDescriptor) + reader := bytes.NewReader(metadata) + decoder := xml.NewDecoder(reader) + decoder.CharsetReader = func(charset string, reader io.Reader) (io.Reader, error) { + enc, err := ianaindex.IANA.Encoding(charset) + if err != nil { + return nil, err + } + return enc.NewDecoder().Reader(reader), nil + } + if err := decoder.Decode(entityDescriptor); err != nil { + if err.Error() == "expected element type but have " { + // reset reader to start of metadata so we can try to parse it as an EntitiesDescriptor + if _, err := reader.Seek(0, io.SeekStart); err != nil { + return nil, err + } + entities := &saml.EntitiesDescriptor{} + if err := decoder.Decode(entities); err != nil { + return nil, err + } + + for i, e := range entities.EntityDescriptors { + if len(e.IDPSSODescriptors) > 0 { + return &entities.EntityDescriptors[i], nil + } + } + return nil, zerrors.ThrowInternal(nil, "SAML-Ejoi3r2", "no entity found with IDPSSODescriptor") + } + return nil, err + } + return entityDescriptor, nil +} + func New( name string, rootURLStr string, @@ -112,8 +151,8 @@ func New( key []byte, options ...ProviderOpts, ) (*Provider, error) { - entityDescriptor := new(saml.EntityDescriptor) - if err := xml.Unmarshal(metadata, entityDescriptor); err != nil { + entityDescriptor, err := ParseMetadata(metadata) + if err != nil { return nil, err } keyPair, err := tls.X509KeyPair(certificate, key) @@ -180,6 +219,7 @@ func (p *Provider) GetSP() (*samlsp.Middleware, error) { if p.binding != "" { sp.Binding = p.binding } + sp.ServiceProvider.MetadataValidDuration = time.Until(sp.ServiceProvider.Certificate.NotAfter) return sp, nil } diff --git a/internal/idp/providers/saml/saml_test.go b/internal/idp/providers/saml/saml_test.go index dc8e8e2115..801ddd36fc 100644 --- a/internal/idp/providers/saml/saml_test.go +++ b/internal/idp/providers/saml/saml_test.go @@ -1,6 +1,7 @@ package saml import ( + "encoding/xml" "testing" "github.com/crewjam/saml" @@ -10,6 +11,7 @@ import ( "github.com/zitadel/zitadel/internal/domain" "github.com/zitadel/zitadel/internal/idp/providers/saml/requesttracker" + "github.com/zitadel/zitadel/internal/zerrors" ) func TestProvider_Options(t *testing.T) { @@ -170,3 +172,111 @@ func TestProvider_Options(t *testing.T) { }) } } + +func TestParseMetadata(t *testing.T) { + type args struct { + metadata []byte + } + tests := []struct { + name string + args args + want *saml.EntityDescriptor + wantErr error + }{ + { + "invalid", + args{ + metadata: []byte(``), + }, + nil, + xml.UnmarshalError("expected element type but have "), + }, + { + "valid entity descriptor", + args{ + metadata: []byte(``), + }, + &saml.EntityDescriptor{ + EntityID: "http://localhost:8000/metadata", + IDPSSODescriptors: []saml.IDPSSODescriptor{ + { + XMLName: xml.Name{ + Space: "urn:oasis:names:tc:SAML:2.0:metadata", + Local: "IDPSSODescriptor", + }, + SingleSignOnServices: []saml.Endpoint{ + { + Binding: saml.HTTPRedirectBinding, + Location: "http://localhost:8000/sso", + }, + }, + }, + }, + }, + nil, + }, + { + "valid entity descriptor, non utf-8", + args{ + metadata: []byte(``), + }, + &saml.EntityDescriptor{ + EntityID: "http://localhost:8000/metadata", + IDPSSODescriptors: []saml.IDPSSODescriptor{ + { + XMLName: xml.Name{ + Space: "urn:oasis:names:tc:SAML:2.0:metadata", + Local: "IDPSSODescriptor", + }, + SingleSignOnServices: []saml.Endpoint{ + { + Binding: saml.HTTPRedirectBinding, + Location: "http://localhost:8000/sso", + }, + }, + }, + }, + }, + nil, + }, + { + "entities descriptor without IDPSSODescriptor", + args{ + metadata: []byte(``), + }, + nil, + zerrors.ThrowInternal(nil, "SAML-Ejoi3r2", "no entity found with IDPSSODescriptor"), + }, + { + "valid entities descriptor", + args{ + metadata: []byte(``), + }, + &saml.EntityDescriptor{ + EntityID: "http://localhost:8000/metadata", + IDPSSODescriptors: []saml.IDPSSODescriptor{ + { + XMLName: xml.Name{ + Space: "urn:oasis:names:tc:SAML:2.0:metadata", + Local: "IDPSSODescriptor", + }, + SingleSignOnServices: []saml.Endpoint{ + { + Binding: saml.HTTPRedirectBinding, + Location: "http://localhost:8000/sso", + }, + }, + }, + }, + }, + nil, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := ParseMetadata(tt.args.metadata) + assert.ErrorIs(t, err, tt.wantErr) + assert.Equal(t, tt.want, got) + }) + } +} diff --git a/internal/idp/providers/saml/session.go b/internal/idp/providers/saml/session.go index 09de7163bb..49a04e49cb 100644 --- a/internal/idp/providers/saml/session.go +++ b/internal/idp/providers/saml/session.go @@ -9,7 +9,6 @@ import ( "github.com/crewjam/saml" "github.com/crewjam/saml/samlsp" - "github.com/zitadel/logging" "github.com/zitadel/zitadel/internal/idp" "github.com/zitadel/zitadel/internal/zerrors" @@ -71,7 +70,7 @@ func (s *Session) FetchUser(ctx context.Context) (user idp.User, err error) { if err != nil { invalidRespErr := new(saml.InvalidResponseError) if errors.As(err, &invalidRespErr) { - logging.WithError(invalidRespErr.PrivateErr).Info("invalid SAML response details") + return nil, zerrors.ThrowInvalidArgument(invalidRespErr.PrivateErr, "SAML-ajl3irfs", "Errors.Intent.ResponseInvalid") } return nil, zerrors.ThrowInvalidArgument(err, "SAML-nuo0vphhh9", "Errors.Intent.ResponseInvalid") } diff --git a/internal/idp/providers/saml/session_test.go b/internal/idp/providers/saml/session_test.go index 5ab8c7eaec..ea3e510d60 100644 --- a/internal/idp/providers/saml/session_test.go +++ b/internal/idp/providers/saml/session_test.go @@ -134,7 +134,7 @@ func TestSession_FetchUser(t *testing.T) { requestID: "id-b22c90db88bf01d82ffb0a7b6fe25ac9fcb2c679", }, want: want{ - err: zerrors.ThrowInvalidArgument(nil, "SAML-nuo0vphhh9", "Errors.Intent.ResponseInvalid"), + err: zerrors.ThrowInvalidArgument(nil, "SAML-ajl3irfs", "Errors.Intent.ResponseInvalid"), }, }, { From 14db62885679092f6f04d16333f6837f3d9cc4ab Mon Sep 17 00:00:00 2001 From: Stefan Benz <46600784+stebenz@users.noreply.github.com> Date: Tue, 3 Dec 2024 15:38:25 +0100 Subject: [PATCH 43/64] fix: project existing check removed from project grant remove (#9004) # Which Problems Are Solved Wrongly created project grants with a unexpected resourceowner can't be removed as there is a check if the project is existing, the project is never existing as the wrong resourceowner is used. # How the Problems Are Solved There is already a fix related to the resourceowner of the project grant, which should remove the possibility that this situation can happen anymore. This PR removes the check for the project existing, as when the projectgrant is existing and the project is not already removed, this check is not needed anymore. # Additional Changes None # Additional Context Closes #8900 --- internal/command/project_grant.go | 6 --- internal/command/project_grant_test.go | 56 ++++++++------------------ 2 files changed, 17 insertions(+), 45 deletions(-) diff --git a/internal/command/project_grant.go b/internal/command/project_grant.go index feb8a29e4b..82d5dcab38 100644 --- a/internal/command/project_grant.go +++ b/internal/command/project_grant.go @@ -205,12 +205,6 @@ func (c *Commands) RemoveProjectGrant(ctx context.Context, projectID, grantID, r if grantID == "" || projectID == "" { return details, zerrors.ThrowInvalidArgument(nil, "PROJECT-1m9fJ", "Errors.IDMissing") } - - err = c.checkProjectExists(ctx, projectID, resourceOwner) - if err != nil { - return nil, err - } - existingGrant, err := c.projectGrantWriteModelByID(ctx, grantID, projectID, resourceOwner) if err != nil { return details, err diff --git a/internal/command/project_grant_test.go b/internal/command/project_grant_test.go index b6f44c27dd..e39835e2f4 100644 --- a/internal/command/project_grant_test.go +++ b/internal/command/project_grant_test.go @@ -1273,11 +1273,25 @@ func TestCommandSide_RemoveProjectGrant(t *testing.T) { }, }, { - name: "project not existing, precondition failed error", + name: "project already removed, precondition failed error", fields: fields{ eventstore: eventstoreExpect( t, - expectFilter(), + expectFilter( + eventFromEventPusher(project.NewGrantAddedEvent(context.Background(), + &project.NewAggregate("project1", "org1").Aggregate, + "projectgrant1", + "grantedorg1", + []string{"key1"}, + )), + eventFromEventPusher( + project.NewProjectRemovedEvent(context.Background(), + &project.NewAggregate("project1", "org1").Aggregate, + "projectname1", + nil, + ), + ), + ), ), }, args: args{ @@ -1287,7 +1301,7 @@ func TestCommandSide_RemoveProjectGrant(t *testing.T) { resourceOwner: "org1", }, res: res{ - err: zerrors.IsPreconditionFailed, + err: zerrors.IsNotFound, }, }, { @@ -1295,15 +1309,6 @@ func TestCommandSide_RemoveProjectGrant(t *testing.T) { fields: fields{ eventstore: eventstoreExpect( t, - expectFilter( - eventFromEventPusher( - project.NewProjectAddedEvent(context.Background(), - &project.NewAggregate("project1", "org1").Aggregate, - "projectname1", true, true, true, - domain.PrivateLabelingSettingUnspecified, - ), - ), - ), expectFilter(), ), }, @@ -1322,15 +1327,6 @@ func TestCommandSide_RemoveProjectGrant(t *testing.T) { fields: fields{ eventstore: eventstoreExpect( t, - expectFilter( - eventFromEventPusher( - project.NewProjectAddedEvent(context.Background(), - &project.NewAggregate("project1", "org1").Aggregate, - "projectname1", true, true, true, - domain.PrivateLabelingSettingUnspecified, - ), - ), - ), expectFilter( eventFromEventPusher(project.NewGrantAddedEvent(context.Background(), &project.NewAggregate("project1", "org1").Aggregate, @@ -1365,15 +1361,6 @@ func TestCommandSide_RemoveProjectGrant(t *testing.T) { fields: fields{ eventstore: eventstoreExpect( t, - expectFilter( - eventFromEventPusher( - project.NewProjectAddedEvent(context.Background(), - &project.NewAggregate("project1", "org1").Aggregate, - "projectname1", true, true, true, - domain.PrivateLabelingSettingUnspecified, - ), - ), - ), expectFilter( eventFromEventPusher(project.NewGrantAddedEvent(context.Background(), &project.NewAggregate("project1", "org1").Aggregate, @@ -1410,15 +1397,6 @@ func TestCommandSide_RemoveProjectGrant(t *testing.T) { fields: fields{ eventstore: eventstoreExpect( t, - expectFilter( - eventFromEventPusher( - project.NewProjectAddedEvent(context.Background(), - &project.NewAggregate("project1", "org1").Aggregate, - "projectname1", true, true, true, - domain.PrivateLabelingSettingUnspecified, - ), - ), - ), expectFilter( eventFromEventPusher(project.NewGrantAddedEvent(context.Background(), &project.NewAggregate("project1", "org1").Aggregate, From dab5d9e7565c5ccdf2ddab5c4b3f2a635e72c562 Mon Sep 17 00:00:00 2001 From: Silvan Date: Wed, 4 Dec 2024 14:51:40 +0100 Subject: [PATCH 44/64] refactor(eventstore): move push logic to sql (#8816) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit # Which Problems Are Solved If many events are written to the same aggregate id it can happen that zitadel [starts to retry the push transaction](https://github.com/zitadel/zitadel/blob/48ffc902cc90237d693e7104fc742ee927478da7/internal/eventstore/eventstore.go#L101) because [the locking behaviour](https://github.com/zitadel/zitadel/blob/48ffc902cc90237d693e7104fc742ee927478da7/internal/eventstore/v3/sequence.go#L25) during push does compute the wrong sequence because newly committed events are not visible to the transaction. These events impact the current sequence. In cases with high command traffic on a single aggregate id this can have severe impact on general performance of zitadel. Because many connections of the `eventstore pusher` database pool are blocked by each other. # How the Problems Are Solved To improve the performance this locking mechanism was removed and the business logic of push is moved to sql functions which reduce network traffic and can be analyzed by the database before the actual push. For clients of the eventstore framework nothing changed. # Additional Changes - after a connection is established prefetches the newly added database types - `eventstore.BaseEvent` now returns the correct revision of the event # Additional Context - part of https://github.com/zitadel/zitadel/issues/8931 --------- Co-authored-by: Tim Möhlmann Co-authored-by: Livio Spring Co-authored-by: Max Peintner Co-authored-by: Elio Bischof Co-authored-by: Stefan Benz <46600784+stebenz@users.noreply.github.com> Co-authored-by: Miguel Cabrerizo <30386061+doncicuto@users.noreply.github.com> Co-authored-by: Joakim Lodén Co-authored-by: Yxnt Co-authored-by: Stefan Benz Co-authored-by: Harsha Reddy Co-authored-by: Zach H --- cmd/initialise/helper.go | 5 +- cmd/initialise/init.go | 10 +- cmd/initialise/sql/cockroach/02_database.sql | 2 +- .../sql/cockroach/08_events_table.sql | 95 +++++ .../sql/postgres/08_events_table.sql | 101 ++++- cmd/initialise/verify_database.go | 9 +- cmd/initialise/verify_database_test.go | 3 +- cmd/initialise/verify_grant.go | 9 +- cmd/initialise/verify_grant_test.go | 3 +- cmd/initialise/verify_settings.go | 9 +- cmd/initialise/verify_user.go | 9 +- cmd/initialise/verify_user_test.go | 3 +- cmd/initialise/verify_zitadel.go | 33 +- cmd/initialise/verify_zitadel_test.go | 7 +- cmd/setup/40.go | 52 +++ cmd/setup/40/cockroach/40_init_push_func.sql | 107 ++++++ cmd/setup/40/postgres/01_type.sql | 15 + cmd/setup/40/postgres/02_func.sql | 82 ++++ cmd/setup/config.go | 1 + cmd/setup/setup.go | 2 + .../webkey_integration_test.go | 1 + internal/database/cockroach/crdb.go | 40 ++ internal/database/database.go | 22 +- internal/database/dialect/connections.go | 39 ++ internal/database/postgres/pg.go | 32 ++ internal/eventstore/event_base.go | 10 +- internal/eventstore/eventstore.go | 4 +- internal/eventstore/eventstore_test.go | 12 +- internal/eventstore/local_crdb_test.go | 23 +- internal/eventstore/repository/event.go | 8 +- .../repository/mock/repository.mock.go | 2 +- .../repository/mock/repository.mock.impl.go | 8 +- .../repository/sql/local_crdb_test.go | 21 +- internal/eventstore/v3/event.go | 111 ++++-- internal/eventstore/v3/event_test.go | 356 +++++++++++++++++- internal/eventstore/v3/eventstore.go | 174 +++++++++ internal/eventstore/v3/field.go | 2 +- internal/eventstore/v3/mock_test.go | 27 +- internal/eventstore/v3/push.go | 200 ++-------- internal/eventstore/v3/push_test.go | 30 +- internal/eventstore/v3/push_without_func.go | 183 +++++++++ internal/eventstore/v3/unique_constraints.go | 6 +- 42 files changed, 1591 insertions(+), 277 deletions(-) create mode 100644 cmd/setup/40.go create mode 100644 cmd/setup/40/cockroach/40_init_push_func.sql create mode 100644 cmd/setup/40/postgres/01_type.sql create mode 100644 cmd/setup/40/postgres/02_func.sql create mode 100644 internal/eventstore/v3/push_without_func.go diff --git a/cmd/initialise/helper.go b/cmd/initialise/helper.go index ac212e8e6d..94d5aef7eb 100644 --- a/cmd/initialise/helper.go +++ b/cmd/initialise/helper.go @@ -1,6 +1,7 @@ package initialise import ( + "context" "errors" "github.com/jackc/pgx/v5/pgconn" @@ -8,8 +9,8 @@ import ( "github.com/zitadel/zitadel/internal/database" ) -func exec(db *database.DB, stmt string, possibleErrCodes []string, args ...interface{}) error { - _, err := db.Exec(stmt, args...) +func exec(ctx context.Context, db database.ContextExecuter, stmt string, possibleErrCodes []string, args ...interface{}) error { + _, err := db.ExecContext(ctx, stmt, args...) pgErr := new(pgconn.PgError) if errors.As(err, &pgErr) { for _, possibleCode := range possibleErrCodes { diff --git a/cmd/initialise/init.go b/cmd/initialise/init.go index 917e6a2d93..fba5098fa2 100644 --- a/cmd/initialise/init.go +++ b/cmd/initialise/init.go @@ -59,7 +59,7 @@ The user provided by flags needs privileges to } func InitAll(ctx context.Context, config *Config) { - err := initialise(config.Database, + err := initialise(ctx, config.Database, VerifyUser(config.Database.Username(), config.Database.Password()), VerifyDatabase(config.Database.DatabaseName()), VerifyGrant(config.Database.DatabaseName(), config.Database.Username()), @@ -71,7 +71,7 @@ func InitAll(ctx context.Context, config *Config) { logging.OnError(err).Fatal("unable to initialize ZITADEL") } -func initialise(config database.Config, steps ...func(*database.DB) error) error { +func initialise(ctx context.Context, config database.Config, steps ...func(context.Context, *database.DB) error) error { logging.Info("initialization started") err := ReadStmts(config.Type()) @@ -85,12 +85,12 @@ func initialise(config database.Config, steps ...func(*database.DB) error) error } defer db.Close() - return Init(db, steps...) + return Init(ctx, db, steps...) } -func Init(db *database.DB, steps ...func(*database.DB) error) error { +func Init(ctx context.Context, db *database.DB, steps ...func(context.Context, *database.DB) error) error { for _, step := range steps { - if err := step(db); err != nil { + if err := step(ctx, db); err != nil { return err } } diff --git a/cmd/initialise/sql/cockroach/02_database.sql b/cmd/initialise/sql/cockroach/02_database.sql index a0e3c3350f..6103b95b31 100644 --- a/cmd/initialise/sql/cockroach/02_database.sql +++ b/cmd/initialise/sql/cockroach/02_database.sql @@ -1,2 +1,2 @@ -- replace %[1]s with the name of the database -CREATE DATABASE IF NOT EXISTS "%[1]s" \ No newline at end of file +CREATE DATABASE IF NOT EXISTS "%[1]s"; diff --git a/cmd/initialise/sql/cockroach/08_events_table.sql b/cmd/initialise/sql/cockroach/08_events_table.sql index 00eba689f3..ebaf18ce2a 100644 --- a/cmd/initialise/sql/cockroach/08_events_table.sql +++ b/cmd/initialise/sql/cockroach/08_events_table.sql @@ -19,3 +19,98 @@ CREATE TABLE IF NOT EXISTS eventstore.events2 ( , INDEX es_wm (aggregate_id, instance_id, aggregate_type, event_type) , INDEX es_projection (instance_id, aggregate_type, event_type, "position" DESC) ); + +-- represents an event to be created. +CREATE TYPE IF NOT EXISTS eventstore.command AS ( + instance_id TEXT + , aggregate_type TEXT + , aggregate_id TEXT + , command_type TEXT + , revision INT2 + , payload JSONB + , creator TEXT + , owner TEXT +); + +CREATE OR REPLACE FUNCTION eventstore.commands_to_events(commands eventstore.command[]) RETURNS SETOF eventstore.events2 VOLATILE AS $$ +SELECT + ("c").instance_id + , ("c").aggregate_type + , ("c").aggregate_id + , ("c").command_type AS event_type + , cs.sequence + ROW_NUMBER() OVER (PARTITION BY ("c").instance_id, ("c").aggregate_type, ("c").aggregate_id ORDER BY ("c").in_tx_order) AS sequence + , ("c").revision + , hlc_to_timestamp(cluster_logical_timestamp()) AS created_at + , ("c").payload + , ("c").creator + , cs.owner + , cluster_logical_timestamp() AS position + , ("c").in_tx_order +FROM ( + SELECT + ("c").instance_id + , ("c").aggregate_type + , ("c").aggregate_id + , ("c").command_type + , ("c").revision + , ("c").payload + , ("c").creator + , ("c").owner + , ROW_NUMBER() OVER () AS in_tx_order + FROM + UNNEST(commands) AS "c" +) AS "c" +JOIN ( + SELECT + cmds.instance_id + , cmds.aggregate_type + , cmds.aggregate_id + , CASE WHEN (e.owner IS NOT NULL OR e.owner <> '') THEN e.owner ELSE command_owners.owner END AS owner + , COALESCE(MAX(e.sequence), 0) AS sequence + FROM ( + SELECT DISTINCT + ("cmds").instance_id + , ("cmds").aggregate_type + , ("cmds").aggregate_id + , ("cmds").owner + FROM UNNEST(commands) AS "cmds" + ) AS cmds + LEFT JOIN eventstore.events2 AS e + ON cmds.instance_id = e.instance_id + AND cmds.aggregate_type = e.aggregate_type + AND cmds.aggregate_id = e.aggregate_id + JOIN ( + SELECT + DISTINCT ON ( + ("c").instance_id + , ("c").aggregate_type + , ("c").aggregate_id + ) + ("c").instance_id + , ("c").aggregate_type + , ("c").aggregate_id + , ("c").owner + FROM + UNNEST(commands) AS "c" + ) AS command_owners ON + cmds.instance_id = command_owners.instance_id + AND cmds.aggregate_type = command_owners.aggregate_type + AND cmds.aggregate_id = command_owners.aggregate_id + GROUP BY + cmds.instance_id + , cmds.aggregate_type + , cmds.aggregate_id + , 4 -- owner +) AS cs + ON ("c").instance_id = cs.instance_id + AND ("c").aggregate_type = cs.aggregate_type + AND ("c").aggregate_id = cs.aggregate_id +ORDER BY + in_tx_order +$$ LANGUAGE SQL; + +CREATE OR REPLACE FUNCTION eventstore.push(commands eventstore.command[]) RETURNS SETOF eventstore.events2 AS $$ + INSERT INTO eventstore.events2 + SELECT * FROM eventstore.commands_to_events(commands) + RETURNING * +$$ LANGUAGE SQL; \ No newline at end of file diff --git a/cmd/initialise/sql/postgres/08_events_table.sql b/cmd/initialise/sql/postgres/08_events_table.sql index d978df5d9f..c4b7351e65 100644 --- a/cmd/initialise/sql/postgres/08_events_table.sql +++ b/cmd/initialise/sql/postgres/08_events_table.sql @@ -19,4 +19,103 @@ CREATE TABLE IF NOT EXISTS eventstore.events2 ( CREATE INDEX IF NOT EXISTS es_active_instances ON eventstore.events2 (created_at DESC, instance_id); CREATE INDEX IF NOT EXISTS es_wm ON eventstore.events2 (aggregate_id, instance_id, aggregate_type, event_type); -CREATE INDEX IF NOT EXISTS es_projection ON eventstore.events2 (instance_id, aggregate_type, event_type, "position"); \ No newline at end of file +CREATE INDEX IF NOT EXISTS es_projection ON eventstore.events2 (instance_id, aggregate_type, event_type, "position"); + +-- represents an event to be created. +DO $$ BEGIN + CREATE TYPE eventstore.command AS ( + instance_id TEXT + , aggregate_type TEXT + , aggregate_id TEXT + , command_type TEXT + , revision INT2 + , payload JSONB + , creator TEXT + , owner TEXT + ); +EXCEPTION + WHEN duplicate_object THEN null; +END $$; + +CREATE OR REPLACE FUNCTION eventstore.commands_to_events(commands eventstore.command[]) RETURNS SETOF eventstore.events2 VOLATILE AS $$ +SELECT + c.instance_id + , c.aggregate_type + , c.aggregate_id + , c.command_type AS event_type + , cs.sequence + ROW_NUMBER() OVER (PARTITION BY c.instance_id, c.aggregate_type, c.aggregate_id ORDER BY c.in_tx_order) AS sequence + , c.revision + , NOW() AS created_at + , c.payload + , c.creator + , cs.owner + , EXTRACT(EPOCH FROM NOW()) AS position + , c.in_tx_order +FROM ( + SELECT + c.instance_id + , c.aggregate_type + , c.aggregate_id + , c.command_type + , c.revision + , c.payload + , c.creator + , c.owner + , ROW_NUMBER() OVER () AS in_tx_order + FROM + UNNEST(commands) AS c +) AS c +JOIN ( + SELECT + cmds.instance_id + , cmds.aggregate_type + , cmds.aggregate_id + , CASE WHEN (e.owner IS NOT NULL OR e.owner <> '') THEN e.owner ELSE command_owners.owner END AS owner + , COALESCE(MAX(e.sequence), 0) AS sequence + FROM ( + SELECT DISTINCT + instance_id + , aggregate_type + , aggregate_id + , owner + FROM UNNEST(commands) + ) AS cmds + LEFT JOIN eventstore.events2 AS e + ON cmds.instance_id = e.instance_id + AND cmds.aggregate_type = e.aggregate_type + AND cmds.aggregate_id = e.aggregate_id + JOIN ( + SELECT + DISTINCT ON ( + instance_id + , aggregate_type + , aggregate_id + ) + instance_id + , aggregate_type + , aggregate_id + , owner + FROM + UNNEST(commands) + ) AS command_owners ON + cmds.instance_id = command_owners.instance_id + AND cmds.aggregate_type = command_owners.aggregate_type + AND cmds.aggregate_id = command_owners.aggregate_id + GROUP BY + cmds.instance_id + , cmds.aggregate_type + , cmds.aggregate_id + , 4 -- owner +) AS cs + ON c.instance_id = cs.instance_id + AND c.aggregate_type = cs.aggregate_type + AND c.aggregate_id = cs.aggregate_id +ORDER BY + in_tx_order; +$$ LANGUAGE SQL; + +CREATE OR REPLACE FUNCTION eventstore.push(commands eventstore.command[]) RETURNS SETOF eventstore.events2 VOLATILE AS $$ +INSERT INTO eventstore.events2 +SELECT * FROM eventstore.commands_to_events(commands) +RETURNING * +$$ LANGUAGE SQL; diff --git a/cmd/initialise/verify_database.go b/cmd/initialise/verify_database.go index be3ec19bab..6e04e489f5 100644 --- a/cmd/initialise/verify_database.go +++ b/cmd/initialise/verify_database.go @@ -1,6 +1,7 @@ package initialise import ( + "context" _ "embed" "fmt" @@ -28,16 +29,16 @@ The user provided by flags needs privileges to Run: func(cmd *cobra.Command, args []string) { config := MustNewConfig(viper.GetViper()) - err := initialise(config.Database, VerifyDatabase(config.Database.DatabaseName())) + err := initialise(cmd.Context(), config.Database, VerifyDatabase(config.Database.DatabaseName())) logging.OnError(err).Fatal("unable to initialize the database") }, } } -func VerifyDatabase(databaseName string) func(*database.DB) error { - return func(db *database.DB) error { +func VerifyDatabase(databaseName string) func(context.Context, *database.DB) error { + return func(ctx context.Context, db *database.DB) error { logging.WithFields("database", databaseName).Info("verify database") - return exec(db, fmt.Sprintf(databaseStmt, databaseName), []string{dbAlreadyExistsCode}) + return exec(ctx, db, fmt.Sprintf(databaseStmt, databaseName), []string{dbAlreadyExistsCode}) } } diff --git a/cmd/initialise/verify_database_test.go b/cmd/initialise/verify_database_test.go index ebdf0473b6..d7da97847f 100644 --- a/cmd/initialise/verify_database_test.go +++ b/cmd/initialise/verify_database_test.go @@ -1,6 +1,7 @@ package initialise import ( + "context" "database/sql" "errors" "testing" @@ -55,7 +56,7 @@ func Test_verifyDB(t *testing.T) { } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - if err := VerifyDatabase(tt.args.database)(tt.args.db.db); !errors.Is(err, tt.targetErr) { + if err := VerifyDatabase(tt.args.database)(context.Background(), tt.args.db.db); !errors.Is(err, tt.targetErr) { t.Errorf("verifyDB() error = %v, want: %v", err, tt.targetErr) } if err := tt.args.db.mock.ExpectationsWereMet(); err != nil { diff --git a/cmd/initialise/verify_grant.go b/cmd/initialise/verify_grant.go index ed8ecb7256..a14a495bff 100644 --- a/cmd/initialise/verify_grant.go +++ b/cmd/initialise/verify_grant.go @@ -1,6 +1,7 @@ package initialise import ( + "context" _ "embed" "fmt" @@ -23,16 +24,16 @@ Prerequisites: Run: func(cmd *cobra.Command, args []string) { config := MustNewConfig(viper.GetViper()) - err := initialise(config.Database, VerifyGrant(config.Database.DatabaseName(), config.Database.Username())) + err := initialise(cmd.Context(), config.Database, VerifyGrant(config.Database.DatabaseName(), config.Database.Username())) logging.OnError(err).Fatal("unable to set grant") }, } } -func VerifyGrant(databaseName, username string) func(*database.DB) error { - return func(db *database.DB) error { +func VerifyGrant(databaseName, username string) func(context.Context, *database.DB) error { + return func(ctx context.Context, db *database.DB) error { logging.WithFields("user", username, "database", databaseName).Info("verify grant") - return exec(db, fmt.Sprintf(grantStmt, databaseName, username), nil) + return exec(ctx, db, fmt.Sprintf(grantStmt, databaseName, username), nil) } } diff --git a/cmd/initialise/verify_grant_test.go b/cmd/initialise/verify_grant_test.go index a6bfa818ad..3cab9099f7 100644 --- a/cmd/initialise/verify_grant_test.go +++ b/cmd/initialise/verify_grant_test.go @@ -1,6 +1,7 @@ package initialise import ( + "context" "database/sql" "errors" "testing" @@ -53,7 +54,7 @@ func Test_verifyGrant(t *testing.T) { } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - if err := VerifyGrant(tt.args.database, tt.args.username)(tt.args.db.db); !errors.Is(err, tt.targetErr) { + if err := VerifyGrant(tt.args.database, tt.args.username)(context.Background(), tt.args.db.db); !errors.Is(err, tt.targetErr) { t.Errorf("VerifyGrant() error = %v, want: %v", err, tt.targetErr) } if err := tt.args.db.mock.ExpectationsWereMet(); err != nil { diff --git a/cmd/initialise/verify_settings.go b/cmd/initialise/verify_settings.go index 75e811663f..6f4ba7c074 100644 --- a/cmd/initialise/verify_settings.go +++ b/cmd/initialise/verify_settings.go @@ -1,6 +1,7 @@ package initialise import ( + "context" _ "embed" "fmt" @@ -26,19 +27,19 @@ Cockroach Run: func(cmd *cobra.Command, args []string) { config := MustNewConfig(viper.GetViper()) - err := initialise(config.Database, VerifySettings(config.Database.DatabaseName(), config.Database.Username())) + err := initialise(cmd.Context(), config.Database, VerifySettings(config.Database.DatabaseName(), config.Database.Username())) logging.OnError(err).Fatal("unable to set settings") }, } } -func VerifySettings(databaseName, username string) func(*database.DB) error { - return func(db *database.DB) error { +func VerifySettings(databaseName, username string) func(context.Context, *database.DB) error { + return func(ctx context.Context, db *database.DB) error { if db.Type() == "postgres" { return nil } logging.WithFields("user", username, "database", databaseName).Info("verify settings") - return exec(db, fmt.Sprintf(settingsStmt, databaseName, username), nil) + return exec(ctx, db, fmt.Sprintf(settingsStmt, databaseName, username), nil) } } diff --git a/cmd/initialise/verify_user.go b/cmd/initialise/verify_user.go index 86e6c25ba0..43bdb91420 100644 --- a/cmd/initialise/verify_user.go +++ b/cmd/initialise/verify_user.go @@ -1,6 +1,7 @@ package initialise import ( + "context" _ "embed" "fmt" @@ -28,20 +29,20 @@ The user provided by flags needs privileges to Run: func(cmd *cobra.Command, args []string) { config := MustNewConfig(viper.GetViper()) - err := initialise(config.Database, VerifyUser(config.Database.Username(), config.Database.Password())) + err := initialise(cmd.Context(), config.Database, VerifyUser(config.Database.Username(), config.Database.Password())) logging.OnError(err).Fatal("unable to init user") }, } } -func VerifyUser(username, password string) func(*database.DB) error { - return func(db *database.DB) error { +func VerifyUser(username, password string) func(context.Context, *database.DB) error { + return func(ctx context.Context, db *database.DB) error { logging.WithFields("username", username).Info("verify user") if password != "" { createUserStmt += " WITH PASSWORD '" + password + "'" } - return exec(db, fmt.Sprintf(createUserStmt, username), []string{roleAlreadyExistsCode}) + return exec(ctx, db, fmt.Sprintf(createUserStmt, username), []string{roleAlreadyExistsCode}) } } diff --git a/cmd/initialise/verify_user_test.go b/cmd/initialise/verify_user_test.go index da7afc1765..53b35e67db 100644 --- a/cmd/initialise/verify_user_test.go +++ b/cmd/initialise/verify_user_test.go @@ -1,6 +1,7 @@ package initialise import ( + "context" "database/sql" "errors" "testing" @@ -70,7 +71,7 @@ func Test_verifyUser(t *testing.T) { } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - if err := VerifyUser(tt.args.username, tt.args.password)(tt.args.db.db); !errors.Is(err, tt.targetErr) { + if err := VerifyUser(tt.args.username, tt.args.password)(context.Background(), tt.args.db.db); !errors.Is(err, tt.targetErr) { t.Errorf("VerifyGrant() error = %v, want: %v", err, tt.targetErr) } if err := tt.args.db.mock.ExpectationsWereMet(); err != nil { diff --git a/cmd/initialise/verify_zitadel.go b/cmd/initialise/verify_zitadel.go index 7c16fdaadf..a5ce1fd57c 100644 --- a/cmd/initialise/verify_zitadel.go +++ b/cmd/initialise/verify_zitadel.go @@ -2,6 +2,7 @@ package initialise import ( "context" + "database/sql" _ "embed" "fmt" @@ -11,6 +12,7 @@ import ( "github.com/zitadel/zitadel/internal/database" "github.com/zitadel/zitadel/internal/database/dialect" + es_v3 "github.com/zitadel/zitadel/internal/eventstore/v3" ) func newZitadel() *cobra.Command { @@ -36,38 +38,44 @@ func VerifyZitadel(ctx context.Context, db *database.DB, config database.Config) return err } + conn, err := db.Conn(ctx) + if err != nil { + return err + } + defer conn.Close() + logging.WithFields().Info("verify system") - if err := exec(db, fmt.Sprintf(createSystemStmt, config.Username()), nil); err != nil { + if err := exec(ctx, conn, fmt.Sprintf(createSystemStmt, config.Username()), nil); err != nil { return err } logging.WithFields().Info("verify encryption keys") - if err := createEncryptionKeys(ctx, db); err != nil { + if err := createEncryptionKeys(ctx, conn); err != nil { return err } logging.WithFields().Info("verify projections") - if err := exec(db, fmt.Sprintf(createProjectionsStmt, config.Username()), nil); err != nil { + if err := exec(ctx, conn, fmt.Sprintf(createProjectionsStmt, config.Username()), nil); err != nil { return err } logging.WithFields().Info("verify eventstore") - if err := exec(db, fmt.Sprintf(createEventstoreStmt, config.Username()), nil); err != nil { + if err := exec(ctx, conn, fmt.Sprintf(createEventstoreStmt, config.Username()), nil); err != nil { return err } logging.WithFields().Info("verify events tables") - if err := createEvents(ctx, db); err != nil { + if err := createEvents(ctx, conn); err != nil { return err } logging.WithFields().Info("verify system sequence") - if err := exec(db, createSystemSequenceStmt, nil); err != nil { + if err := exec(ctx, conn, createSystemSequenceStmt, nil); err != nil { return err } logging.WithFields().Info("verify unique constraints") - if err := exec(db, createUniqueConstraints, nil); err != nil { + if err := exec(ctx, conn, createUniqueConstraints, nil); err != nil { return err } @@ -89,7 +97,7 @@ func verifyZitadel(ctx context.Context, config database.Config) error { return db.Close() } -func createEncryptionKeys(ctx context.Context, db *database.DB) error { +func createEncryptionKeys(ctx context.Context, db database.Beginner) error { tx, err := db.BeginTx(ctx, nil) if err != nil { return err @@ -103,8 +111,8 @@ func createEncryptionKeys(ctx context.Context, db *database.DB) error { return tx.Commit() } -func createEvents(ctx context.Context, db *database.DB) (err error) { - tx, err := db.BeginTx(ctx, nil) +func createEvents(ctx context.Context, conn *sql.Conn) (err error) { + tx, err := conn.BeginTx(ctx, nil) if err != nil { return err } @@ -127,5 +135,8 @@ func createEvents(ctx context.Context, db *database.DB) (err error) { return row.Err() } _, err = tx.Exec(createEventsStmt) - return err + if err != nil { + return err + } + return es_v3.CheckExecutionPlan(ctx, conn) } diff --git a/cmd/initialise/verify_zitadel_test.go b/cmd/initialise/verify_zitadel_test.go index 64df01bdb1..194911a179 100644 --- a/cmd/initialise/verify_zitadel_test.go +++ b/cmd/initialise/verify_zitadel_test.go @@ -108,7 +108,12 @@ func Test_verifyEvents(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - if err := createEvents(context.Background(), tt.args.db.db); !errors.Is(err, tt.targetErr) { + conn, err := tt.args.db.db.Conn(context.Background()) + if err != nil { + t.Error(err) + return + } + if err := createEvents(context.Background(), conn); !errors.Is(err, tt.targetErr) { t.Errorf("createEvents() error = %v, want: %v", err, tt.targetErr) } if err := tt.args.db.mock.ExpectationsWereMet(); err != nil { diff --git a/cmd/setup/40.go b/cmd/setup/40.go new file mode 100644 index 0000000000..a0d1afcf54 --- /dev/null +++ b/cmd/setup/40.go @@ -0,0 +1,52 @@ +package setup + +import ( + "context" + "embed" + "fmt" + + "github.com/zitadel/logging" + + "github.com/zitadel/zitadel/internal/database" + "github.com/zitadel/zitadel/internal/eventstore" +) + +var ( + //go:embed 40/cockroach/*.sql + //go:embed 40/postgres/*.sql + initPushFunc embed.FS +) + +type InitPushFunc struct { + dbClient *database.DB +} + +func (mig *InitPushFunc) Execute(ctx context.Context, _ eventstore.Event) (err error) { + statements, err := readStatements(initPushFunc, "40", mig.dbClient.Type()) + if err != nil { + return err + } + conn, err := mig.dbClient.Conn(ctx) + if err != nil { + return err + } + defer func() { + closeErr := conn.Close() + logging.OnError(closeErr).Debug("failed to release connection") + // Force the pool to reopen connections to apply the new types + mig.dbClient.Pool.Reset() + }() + + for _, stmt := range statements { + logging.WithFields("file", stmt.file, "migration", mig.String()).Info("execute statement") + if _, err := conn.ExecContext(ctx, stmt.query); err != nil { + return fmt.Errorf("%s %s: %w", mig.String(), stmt.file, err) + } + } + + return nil +} + +func (mig *InitPushFunc) String() string { + return "40_init_push_func" +} diff --git a/cmd/setup/40/cockroach/40_init_push_func.sql b/cmd/setup/40/cockroach/40_init_push_func.sql new file mode 100644 index 0000000000..c2e2e92b07 --- /dev/null +++ b/cmd/setup/40/cockroach/40_init_push_func.sql @@ -0,0 +1,107 @@ +-- represents an event to be created. +CREATE TYPE IF NOT EXISTS eventstore.command AS ( + instance_id TEXT + , aggregate_type TEXT + , aggregate_id TEXT + , command_type TEXT + , revision INT2 + , payload JSONB + , creator TEXT + , owner TEXT +); + +/* +select * from eventstore.commands_to_events( +ARRAY[ + ROW('', 'system', 'SYSTEM', 'ct1', 1, '{"key": "value"}', 'c1', 'SYSTEM') + , ROW('', 'system', 'SYSTEM', 'ct2', 1, '{"key": "value"}', 'c1', 'SYSTEM') + , ROW('289525561255060732', 'org', '289575074711790844', 'ct3', 1, '{"key": "value"}', 'c1', '289575074711790844') + , ROW('289525561255060732', 'user', '289575075164906748', 'ct3', 1, '{"key": "value"}', 'c1', '289575074711790844') + , ROW('289525561255060732', 'oidc_session', 'V2_289575178579535100', 'ct3', 1, '{"key": "value"}', 'c1', '289575074711790844') + , ROW('', 'system', 'SYSTEM', 'ct3', 1, '{"key": "value"}', 'c1', 'SYSTEM') +]::eventstore.command[] +); +*/ + +CREATE OR REPLACE FUNCTION eventstore.commands_to_events(commands eventstore.command[]) RETURNS SETOF eventstore.events2 VOLATILE AS $$ +SELECT + ("c").instance_id + , ("c").aggregate_type + , ("c").aggregate_id + , ("c").command_type AS event_type + , cs.sequence + ROW_NUMBER() OVER (PARTITION BY ("c").instance_id, ("c").aggregate_type, ("c").aggregate_id ORDER BY ("c").in_tx_order) AS sequence + , ("c").revision + , hlc_to_timestamp(cluster_logical_timestamp()) AS created_at + , ("c").payload + , ("c").creator + , cs.owner + , cluster_logical_timestamp() AS position + , ("c").in_tx_order +FROM ( + SELECT + ("c").instance_id + , ("c").aggregate_type + , ("c").aggregate_id + , ("c").command_type + , ("c").revision + , ("c").payload + , ("c").creator + , ("c").owner + , ROW_NUMBER() OVER () AS in_tx_order + FROM + UNNEST(commands) AS "c" +) AS "c" +JOIN ( + SELECT + cmds.instance_id + , cmds.aggregate_type + , cmds.aggregate_id + , CASE WHEN (e.owner <> '') THEN e.owner ELSE command_owners.owner END AS owner + , COALESCE(MAX(e.sequence), 0) AS sequence + FROM ( + SELECT DISTINCT + ("cmds").instance_id + , ("cmds").aggregate_type + , ("cmds").aggregate_id + , ("cmds").owner + FROM UNNEST(commands) AS "cmds" + ) AS cmds + LEFT JOIN eventstore.events2 AS e + ON cmds.instance_id = e.instance_id + AND cmds.aggregate_type = e.aggregate_type + AND cmds.aggregate_id = e.aggregate_id + JOIN ( + SELECT + DISTINCT ON ( + ("c").instance_id + , ("c").aggregate_type + , ("c").aggregate_id + ) + ("c").instance_id + , ("c").aggregate_type + , ("c").aggregate_id + , ("c").owner + FROM + UNNEST(commands) AS "c" + ) AS command_owners ON + cmds.instance_id = command_owners.instance_id + AND cmds.aggregate_type = command_owners.aggregate_type + AND cmds.aggregate_id = command_owners.aggregate_id + GROUP BY + cmds.instance_id + , cmds.aggregate_type + , cmds.aggregate_id + , 4 -- owner +) AS cs + ON ("c").instance_id = cs.instance_id + AND ("c").aggregate_type = cs.aggregate_type + AND ("c").aggregate_id = cs.aggregate_id +ORDER BY + in_tx_order +$$ LANGUAGE SQL; + +CREATE OR REPLACE FUNCTION eventstore.push(commands eventstore.command[]) RETURNS SETOF eventstore.events2 AS $$ + INSERT INTO eventstore.events2 + SELECT * FROM eventstore.commands_to_events(commands) + RETURNING * +$$ LANGUAGE SQL; \ No newline at end of file diff --git a/cmd/setup/40/postgres/01_type.sql b/cmd/setup/40/postgres/01_type.sql new file mode 100644 index 0000000000..ace6d8fe1a --- /dev/null +++ b/cmd/setup/40/postgres/01_type.sql @@ -0,0 +1,15 @@ +-- represents an event to be created. +DO $$ BEGIN + CREATE TYPE eventstore.command AS ( + instance_id TEXT + , aggregate_type TEXT + , aggregate_id TEXT + , command_type TEXT + , revision INT2 + , payload JSONB + , creator TEXT + , owner TEXT + ); +EXCEPTION + WHEN duplicate_object THEN null; +END $$; diff --git a/cmd/setup/40/postgres/02_func.sql b/cmd/setup/40/postgres/02_func.sql new file mode 100644 index 0000000000..5f84f3908c --- /dev/null +++ b/cmd/setup/40/postgres/02_func.sql @@ -0,0 +1,82 @@ +CREATE OR REPLACE FUNCTION eventstore.commands_to_events(commands eventstore.command[]) RETURNS SETOF eventstore.events2 VOLATILE AS $$ +SELECT + c.instance_id + , c.aggregate_type + , c.aggregate_id + , c.command_type AS event_type + , cs.sequence + ROW_NUMBER() OVER (PARTITION BY c.instance_id, c.aggregate_type, c.aggregate_id ORDER BY c.in_tx_order) AS sequence + , c.revision + , NOW() AS created_at + , c.payload + , c.creator + , cs.owner + , EXTRACT(EPOCH FROM NOW()) AS position + , c.in_tx_order +FROM ( + SELECT + c.instance_id + , c.aggregate_type + , c.aggregate_id + , c.command_type + , c.revision + , c.payload + , c.creator + , c.owner + , ROW_NUMBER() OVER () AS in_tx_order + FROM + UNNEST(commands) AS c +) AS c +JOIN ( + SELECT + cmds.instance_id + , cmds.aggregate_type + , cmds.aggregate_id + , CASE WHEN (e.owner IS NOT NULL OR e.owner <> '') THEN e.owner ELSE command_owners.owner END AS owner + , COALESCE(MAX(e.sequence), 0) AS sequence + FROM ( + SELECT DISTINCT + instance_id + , aggregate_type + , aggregate_id + , owner + FROM UNNEST(commands) + ) AS cmds + LEFT JOIN eventstore.events2 AS e + ON cmds.instance_id = e.instance_id + AND cmds.aggregate_type = e.aggregate_type + AND cmds.aggregate_id = e.aggregate_id + JOIN ( + SELECT + DISTINCT ON ( + instance_id + , aggregate_type + , aggregate_id + ) + instance_id + , aggregate_type + , aggregate_id + , owner + FROM + UNNEST(commands) + ) AS command_owners ON + cmds.instance_id = command_owners.instance_id + AND cmds.aggregate_type = command_owners.aggregate_type + AND cmds.aggregate_id = command_owners.aggregate_id + GROUP BY + cmds.instance_id + , cmds.aggregate_type + , cmds.aggregate_id + , 4 -- owner +) AS cs + ON c.instance_id = cs.instance_id + AND c.aggregate_type = cs.aggregate_type + AND c.aggregate_id = cs.aggregate_id +ORDER BY + in_tx_order; +$$ LANGUAGE SQL; + +CREATE OR REPLACE FUNCTION eventstore.push(commands eventstore.command[]) RETURNS SETOF eventstore.events2 VOLATILE AS $$ +INSERT INTO eventstore.events2 +SELECT * FROM eventstore.commands_to_events(commands) +RETURNING * +$$ LANGUAGE SQL; diff --git a/cmd/setup/config.go b/cmd/setup/config.go index bd5444f2de..34bc80d4a2 100644 --- a/cmd/setup/config.go +++ b/cmd/setup/config.go @@ -126,6 +126,7 @@ type Steps struct { s36FillV2Milestones *FillV3Milestones s37Apps7OIDConfigsBackChannelLogoutURI *Apps7OIDConfigsBackChannelLogoutURI s38BackChannelLogoutNotificationStart *BackChannelLogoutNotificationStart + s40InitPushFunc *InitPushFunc s39DeleteStaleOrgFields *DeleteStaleOrgFields } diff --git a/cmd/setup/setup.go b/cmd/setup/setup.go index e9721c6b39..8322e081ec 100644 --- a/cmd/setup/setup.go +++ b/cmd/setup/setup.go @@ -170,6 +170,7 @@ func Setup(ctx context.Context, config *Config, steps *Steps, masterKey string) steps.s37Apps7OIDConfigsBackChannelLogoutURI = &Apps7OIDConfigsBackChannelLogoutURI{dbClient: esPusherDBClient} steps.s38BackChannelLogoutNotificationStart = &BackChannelLogoutNotificationStart{dbClient: esPusherDBClient, esClient: eventstoreClient} steps.s39DeleteStaleOrgFields = &DeleteStaleOrgFields{dbClient: esPusherDBClient} + steps.s40InitPushFunc = &InitPushFunc{dbClient: esPusherDBClient} err = projection.Create(ctx, projectionDBClient, eventstoreClient, config.Projections, nil, nil, nil) logging.OnError(err).Fatal("unable to start projections") @@ -190,6 +191,7 @@ func Setup(ctx context.Context, config *Config, steps *Steps, masterKey string) for _, step := range []migration.Migration{ steps.s14NewEventsTable, + steps.s40InitPushFunc, steps.s1ProjectionTable, steps.s2AssetsTable, steps.s28AddFieldTable, diff --git a/internal/api/grpc/resources/webkey/v3/integration_test/webkey_integration_test.go b/internal/api/grpc/resources/webkey/v3/integration_test/webkey_integration_test.go index 19d02dcea3..eafa733fd1 100644 --- a/internal/api/grpc/resources/webkey/v3/integration_test/webkey_integration_test.go +++ b/internal/api/grpc/resources/webkey/v3/integration_test/webkey_integration_test.go @@ -216,6 +216,7 @@ func assertFeatureDisabledError(t *testing.T, err error) { } func checkWebKeyListState(ctx context.Context, t *testing.T, instance *integration.Instance, nKeys int, expectActiveKeyID string, config any, creationDate *timestamppb.Timestamp) { + t.Helper() retryDuration, tick := integration.WaitForAndTickWithMaxDuration(ctx, time.Minute) assert.EventuallyWithT(t, func(collect *assert.CollectT) { diff --git a/internal/database/cockroach/crdb.go b/internal/database/cockroach/crdb.go index 527becf7b5..cc89be8687 100644 --- a/internal/database/cockroach/crdb.go +++ b/internal/database/cockroach/crdb.go @@ -3,15 +3,18 @@ package cockroach import ( "context" "database/sql" + "fmt" "strconv" "strings" "time" + "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgxpool" "github.com/jackc/pgx/v5/stdlib" "github.com/mitchellh/mapstructure" "github.com/zitadel/logging" + "github.com/zitadel/zitadel/internal/api/authz" "github.com/zitadel/zitadel/internal/database/dialect" ) @@ -72,6 +75,12 @@ func (_ *Config) Decode(configs []interface{}) (dialect.Connector, error) { } func (c *Config) Connect(useAdmin bool, pusherRatio, spoolerRatio float64, purpose dialect.DBPurpose) (*sql.DB, *pgxpool.Pool, error) { + dialect.RegisterAfterConnect(func(ctx context.Context, c *pgx.Conn) error { + // CockroachDB by default does not allow multiple modifications of the same table using ON CONFLICT + // This is needed to fill the fields table of the eventstore during eventstore.Push. + _, err := c.Exec(ctx, "SET enable_multiple_modifications_of_table = on") + return err + }) connConfig, err := dialect.NewConnectionConfig(c.MaxOpenConns, c.MaxIdleConns, pusherRatio, spoolerRatio, purpose) if err != nil { return nil, nil, err @@ -82,6 +91,29 @@ func (c *Config) Connect(useAdmin bool, pusherRatio, spoolerRatio float64, purpo return nil, nil, err } + if len(connConfig.AfterConnect) > 0 { + config.AfterConnect = func(ctx context.Context, conn *pgx.Conn) error { + for _, f := range connConfig.AfterConnect { + if err := f(ctx, conn); err != nil { + return err + } + } + return nil + } + } + + // For the pusher we set the app name with the instance ID + if purpose == dialect.DBPurposeEventPusher { + config.BeforeAcquire = func(ctx context.Context, conn *pgx.Conn) bool { + return setAppNameWithID(ctx, conn, purpose, authz.GetInstance(ctx).InstanceID()) + } + config.AfterRelease = func(conn *pgx.Conn) bool { + ctx, cancel := context.WithTimeout(context.Background(), time.Second) + defer cancel() + return setAppNameWithID(ctx, conn, purpose, "IDLE") + } + } + if connConfig.MaxOpenConns != 0 { config.MaxConns = int32(connConfig.MaxOpenConns) } @@ -200,3 +232,11 @@ func (c Config) String(useAdmin bool, appName string) string { return strings.Join(fields, " ") } + +func setAppNameWithID(ctx context.Context, conn *pgx.Conn, purpose dialect.DBPurpose, id string) bool { + // needs to be set like this because psql complains about parameters in the SET statement + query := fmt.Sprintf("SET application_name = '%s_%s'", purpose.AppName(), id) + _, err := conn.Exec(ctx, query) + logging.OnError(err).Warn("failed to set application name") + return err == nil +} diff --git a/internal/database/database.go b/internal/database/database.go index 0191f34b6d..b86a9f247c 100644 --- a/internal/database/database.go +++ b/internal/database/database.go @@ -18,21 +18,31 @@ import ( "github.com/zitadel/zitadel/internal/zerrors" ) -type QueryExecuter interface { - Query(query string, args ...any) (*sql.Rows, error) +type ContextQuerier interface { QueryContext(ctx context.Context, query string, args ...any) (*sql.Rows, error) - Exec(query string, args ...any) (sql.Result, error) +} + +type ContextExecuter interface { ExecContext(ctx context.Context, query string, args ...any) (sql.Result, error) } +type ContextQueryExecuter interface { + ContextQuerier + ContextExecuter +} + type Client interface { - QueryExecuter + ContextQueryExecuter + Beginner + Conn(ctx context.Context) (*sql.Conn, error) +} + +type Beginner interface { BeginTx(ctx context.Context, opts *sql.TxOptions) (*sql.Tx, error) - Begin() (*sql.Tx, error) } type Tx interface { - QueryExecuter + ContextQueryExecuter Commit() error Rollback() error } diff --git a/internal/database/dialect/connections.go b/internal/database/dialect/connections.go index 48f8d6e223..f957870df0 100644 --- a/internal/database/dialect/connections.go +++ b/internal/database/dialect/connections.go @@ -1,8 +1,13 @@ package dialect import ( + "context" "errors" "fmt" + "reflect" + + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgtype" ) var ( @@ -17,6 +22,7 @@ var ( type ConnectionConfig struct { MaxOpenConns, MaxIdleConns uint32 + AfterConnect []func(ctx context.Context, c *pgx.Conn) error } // takeRatio of MaxOpenConns and MaxIdleConns from config and returns @@ -29,6 +35,7 @@ func (c *ConnectionConfig) takeRatio(ratio float64) (*ConnectionConfig, error) { out := &ConnectionConfig{ MaxOpenConns: uint32(ratio * float64(c.MaxOpenConns)), MaxIdleConns: uint32(ratio * float64(c.MaxIdleConns)), + AfterConnect: c.AfterConnect, } if c.MaxOpenConns != 0 && out.MaxOpenConns < 1 && ratio > 0 { out.MaxOpenConns = 1 @@ -40,6 +47,36 @@ func (c *ConnectionConfig) takeRatio(ratio float64) (*ConnectionConfig, error) { return out, nil } +var afterConnectFuncs []func(ctx context.Context, c *pgx.Conn) error + +func RegisterAfterConnect(f func(ctx context.Context, c *pgx.Conn) error) { + afterConnectFuncs = append(afterConnectFuncs, f) +} + +func RegisterDefaultPgTypeVariants[T any](m *pgtype.Map, name, arrayName string) { + // T + var value T + m.RegisterDefaultPgType(value, name) + + // *T + valueType := reflect.TypeOf(value) + m.RegisterDefaultPgType(reflect.New(valueType).Interface(), name) + + // []T + sliceType := reflect.SliceOf(valueType) + m.RegisterDefaultPgType(reflect.MakeSlice(sliceType, 0, 0).Interface(), arrayName) + + // *[]T + m.RegisterDefaultPgType(reflect.New(sliceType).Interface(), arrayName) + + // []*T + sliceOfPointerType := reflect.SliceOf(reflect.TypeOf(reflect.New(valueType).Interface())) + m.RegisterDefaultPgType(reflect.MakeSlice(sliceOfPointerType, 0, 0).Interface(), arrayName) + + // *[]*T + m.RegisterDefaultPgType(reflect.New(sliceOfPointerType).Interface(), arrayName) +} + // NewConnectionConfig calculates [ConnectionConfig] values from the passed ratios // and returns the config applicable for the requested purpose. // @@ -59,11 +96,13 @@ func NewConnectionConfig(openConns, idleConns uint32, pusherRatio, projectionRat queryConfig := &ConnectionConfig{ MaxOpenConns: openConns, MaxIdleConns: idleConns, + AfterConnect: afterConnectFuncs, } pusherConfig, err := queryConfig.takeRatio(pusherRatio) if err != nil { return nil, fmt.Errorf("event pusher: %w", err) } + spoolerConfig, err := queryConfig.takeRatio(projectionRatio) if err != nil { return nil, fmt.Errorf("projection spooler: %w", err) diff --git a/internal/database/postgres/pg.go b/internal/database/postgres/pg.go index 539aedf0a4..c12e122437 100644 --- a/internal/database/postgres/pg.go +++ b/internal/database/postgres/pg.go @@ -3,15 +3,18 @@ package postgres import ( "context" "database/sql" + "fmt" "strconv" "strings" "time" + "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgxpool" "github.com/jackc/pgx/v5/stdlib" "github.com/mitchellh/mapstructure" "github.com/zitadel/logging" + "github.com/zitadel/zitadel/internal/api/authz" "github.com/zitadel/zitadel/internal/database/dialect" ) @@ -83,6 +86,27 @@ func (c *Config) Connect(useAdmin bool, pusherRatio, spoolerRatio float64, purpo return nil, nil, err } + config.AfterConnect = func(ctx context.Context, conn *pgx.Conn) error { + for _, f := range connConfig.AfterConnect { + if err := f(ctx, conn); err != nil { + return err + } + } + return nil + } + + // For the pusher we set the app name with the instance ID + if purpose == dialect.DBPurposeEventPusher { + config.BeforeAcquire = func(ctx context.Context, conn *pgx.Conn) bool { + return setAppNameWithID(ctx, conn, purpose, authz.GetInstance(ctx).InstanceID()) + } + config.AfterRelease = func(conn *pgx.Conn) bool { + ctx, cancel := context.WithTimeout(context.Background(), time.Second) + defer cancel() + return setAppNameWithID(ctx, conn, purpose, "IDLE") + } + } + if connConfig.MaxOpenConns != 0 { config.MaxConns = int32(connConfig.MaxOpenConns) } @@ -209,3 +233,11 @@ func (c Config) String(useAdmin bool, appName string) string { return strings.Join(fields, " ") } + +func setAppNameWithID(ctx context.Context, conn *pgx.Conn, purpose dialect.DBPurpose, id string) bool { + // needs to be set like this because psql complains about parameters in the SET statement + query := fmt.Sprintf("SET application_name = '%s_%s'", purpose.AppName(), id) + _, err := conn.Exec(ctx, query) + logging.OnError(err).Warn("failed to set application name") + return err == nil +} diff --git a/internal/eventstore/event_base.go b/internal/eventstore/event_base.go index c2b56128a8..45706641d8 100644 --- a/internal/eventstore/event_base.go +++ b/internal/eventstore/event_base.go @@ -3,8 +3,12 @@ package eventstore import ( "context" "encoding/json" + "strconv" + "strings" "time" + "github.com/zitadel/logging" + "github.com/zitadel/zitadel/internal/api/authz" "github.com/zitadel/zitadel/internal/api/service" ) @@ -84,8 +88,10 @@ func (e *BaseEvent) DataAsBytes() []byte { } // Revision implements action -func (*BaseEvent) Revision() uint16 { - return 0 +func (e *BaseEvent) Revision() uint16 { + revision, err := strconv.ParseUint(strings.TrimPrefix(string(e.Agg.Version), "v"), 10, 16) + logging.OnError(err).Debug("failed to parse event revision") + return uint16(revision) } // Unmarshal implements Event diff --git a/internal/eventstore/eventstore.go b/internal/eventstore/eventstore.go index 22dfde3f4f..4f331c1852 100644 --- a/internal/eventstore/eventstore.go +++ b/internal/eventstore/eventstore.go @@ -90,7 +90,7 @@ func (es *Eventstore) Push(ctx context.Context, cmds ...Command) ([]Event, error // PushWithClient pushes the events in a single transaction using the provided database client // an event needs at least an aggregate -func (es *Eventstore) PushWithClient(ctx context.Context, client database.QueryExecuter, cmds ...Command) ([]Event, error) { +func (es *Eventstore) PushWithClient(ctx context.Context, client database.ContextQueryExecuter, cmds ...Command) ([]Event, error) { if es.PushTimeout > 0 { var cancel func() ctx, cancel = context.WithTimeout(ctx, es.PushTimeout) @@ -301,7 +301,7 @@ type Pusher interface { // Health checks if the connection to the storage is available Health(ctx context.Context) error // Push stores the actions - Push(ctx context.Context, client database.QueryExecuter, commands ...Command) (_ []Event, err error) + Push(ctx context.Context, client database.ContextQueryExecuter, commands ...Command) (_ []Event, err error) // Client returns the underlying database connection Client() *database.DB } diff --git a/internal/eventstore/eventstore_test.go b/internal/eventstore/eventstore_test.go index ec28b9c551..9e1aa77db1 100644 --- a/internal/eventstore/eventstore_test.go +++ b/internal/eventstore/eventstore_test.go @@ -347,7 +347,7 @@ func (repo *testPusher) Health(ctx context.Context) error { return nil } -func (repo *testPusher) Push(_ context.Context, _ database.QueryExecuter, commands ...Command) (events []Event, err error) { +func (repo *testPusher) Push(_ context.Context, _ database.ContextQueryExecuter, commands ...Command) (events []Event, err error) { if len(repo.errs) != 0 { err, repo.errs = repo.errs[0], repo.errs[1:] return nil, err @@ -490,6 +490,7 @@ func TestEventstore_Push(t *testing.T) { Type: "test.aggregate", ResourceOwner: "caos", InstanceID: "zitadel", + Version: "v1", }, Data: []byte(nil), User: "editorUser", @@ -534,6 +535,7 @@ func TestEventstore_Push(t *testing.T) { Type: "test.aggregate", ResourceOwner: "caos", InstanceID: "zitadel", + Version: "v1", }, Data: []byte(nil), User: "editorUser", @@ -585,6 +587,7 @@ func TestEventstore_Push(t *testing.T) { Type: "test.aggregate", ResourceOwner: "caos", InstanceID: "zitadel", + Version: "v1", }, Data: []byte(nil), User: "editorUser", @@ -596,6 +599,7 @@ func TestEventstore_Push(t *testing.T) { Type: "test.aggregate", ResourceOwner: "caos", InstanceID: "zitadel", + Version: "v1", }, Data: []byte(nil), User: "editorUser", @@ -658,6 +662,7 @@ func TestEventstore_Push(t *testing.T) { Type: "test.aggregate", ResourceOwner: "caos", InstanceID: "zitadel", + Version: "v1", }, Data: []byte(nil), User: "editorUser", @@ -669,6 +674,7 @@ func TestEventstore_Push(t *testing.T) { Type: "test.aggregate", ResourceOwner: "caos", InstanceID: "zitadel", + Version: "v1", }, Data: []byte(nil), User: "editorUser", @@ -682,6 +688,7 @@ func TestEventstore_Push(t *testing.T) { Type: "test.aggregate", ResourceOwner: "caos", InstanceID: "zitadel", + Version: "v1", }, Data: []byte(nil), User: "editorUser", @@ -778,6 +785,7 @@ func TestEventstore_Push(t *testing.T) { Type: "test.aggregate", ResourceOwner: "caos", InstanceID: "zitadel", + Version: "v1", }, Data: []byte(nil), User: "editorUser", @@ -828,6 +836,7 @@ func TestEventstore_Push(t *testing.T) { Type: "test.aggregate", ResourceOwner: "caos", InstanceID: "zitadel", + Version: "v1", }, Data: []byte(nil), User: "editorUser", @@ -883,6 +892,7 @@ func TestEventstore_Push(t *testing.T) { Type: "test.aggregate", ResourceOwner: "caos", InstanceID: "zitadel", + Version: "v1", }, Data: []byte(nil), User: "editorUser", diff --git a/internal/eventstore/local_crdb_test.go b/internal/eventstore/local_crdb_test.go index 6df9e9fd29..87c5084fe7 100644 --- a/internal/eventstore/local_crdb_test.go +++ b/internal/eventstore/local_crdb_test.go @@ -9,6 +9,8 @@ import ( "time" "github.com/cockroachdb/cockroach-go/v2/testserver" + "github.com/jackc/pgx/v5/pgxpool" + "github.com/jackc/pgx/v5/stdlib" "github.com/zitadel/logging" "github.com/zitadel/zitadel/cmd/initialise" @@ -39,10 +41,17 @@ func TestMain(m *testing.M) { testCRDBClient = &database.DB{ Database: new(testDB), } - testCRDBClient.DB, err = sql.Open("postgres", ts.PGURL().String()) + + connConfig, err := pgxpool.ParseConfig(ts.PGURL().String()) if err != nil { - logging.WithFields("error", err).Fatal("unable to connect to db") + logging.WithFields("error", err).Fatal("unable to parse db url") } + connConfig.AfterConnect = new_es.RegisterEventstoreTypes + pool, err := pgxpool.NewWithConfig(context.Background(), connConfig) + if err != nil { + logging.WithFields("error", err).Fatal("unable to create db pool") + } + testCRDBClient.DB = stdlib.OpenDBFromPool(pool) if err = testCRDBClient.Ping(); err != nil { logging.WithFields("error", err).Fatal("unable to ping db") } @@ -55,7 +64,7 @@ func TestMain(m *testing.M) { clients["v3(inmemory)"] = testCRDBClient if localDB, err := connectLocalhost(); err == nil { - if err = initDB(localDB); err != nil { + if err = initDB(context.Background(), localDB); err != nil { logging.WithFields("error", err).Fatal("migrations failed") } pushers["v3(singlenode)"] = new_es.NewEventstore(localDB) @@ -69,14 +78,14 @@ func TestMain(m *testing.M) { ts.Stop() }() - if err = initDB(testCRDBClient); err != nil { + if err = initDB(context.Background(), testCRDBClient); err != nil { logging.WithFields("error", err).Fatal("migrations failed") } os.Exit(m.Run()) } -func initDB(db *database.DB) error { +func initDB(ctx context.Context, db *database.DB) error { initialise.ReadStmts("cockroach") config := new(database.Config) config.SetConnector(&cockroach.Config{ @@ -85,7 +94,7 @@ func initDB(db *database.DB) error { }, Database: "zitadel", }) - err := initialise.Init(db, + err := initialise.Init(ctx, db, initialise.VerifyUser(config.Username(), ""), initialise.VerifyDatabase(config.DatabaseName()), initialise.VerifyGrant(config.DatabaseName(), config.Username()), @@ -93,7 +102,7 @@ func initDB(db *database.DB) error { if err != nil { return err } - err = initialise.VerifyZitadel(context.Background(), db, *config) + err = initialise.VerifyZitadel(ctx, db, *config) if err != nil { return err } diff --git a/internal/eventstore/repository/event.go b/internal/eventstore/repository/event.go index 57b85f15ba..d0d2660d79 100644 --- a/internal/eventstore/repository/event.go +++ b/internal/eventstore/repository/event.go @@ -3,8 +3,12 @@ package repository import ( "database/sql" "encoding/json" + "strconv" + "strings" "time" + "github.com/zitadel/logging" + "github.com/zitadel/zitadel/internal/eventstore" ) @@ -82,7 +86,9 @@ func (e *Event) Type() eventstore.EventType { // Revision implements [eventstore.Event] func (e *Event) Revision() uint16 { - return 0 + revision, err := strconv.ParseUint(strings.TrimPrefix(string(e.Version), "v"), 10, 16) + logging.OnError(err).Debug("failed to parse event revision") + return uint16(revision) } // Sequence implements [eventstore.Event] diff --git a/internal/eventstore/repository/mock/repository.mock.go b/internal/eventstore/repository/mock/repository.mock.go index de04fef8c9..8d5c0430ad 100644 --- a/internal/eventstore/repository/mock/repository.mock.go +++ b/internal/eventstore/repository/mock/repository.mock.go @@ -165,7 +165,7 @@ func (mr *MockPusherMockRecorder) Health(arg0 any) *gomock.Call { } // Push mocks base method. -func (m *MockPusher) Push(arg0 context.Context, arg1 database.QueryExecuter, arg2 ...eventstore.Command) ([]eventstore.Event, error) { +func (m *MockPusher) Push(arg0 context.Context, arg1 database.ContextQueryExecuter, arg2 ...eventstore.Command) ([]eventstore.Event, error) { m.ctrl.T.Helper() varargs := []any{arg0, arg1} for _, a := range arg2 { diff --git a/internal/eventstore/repository/mock/repository.mock.impl.go b/internal/eventstore/repository/mock/repository.mock.impl.go index 365da7afe2..9ae0b6b1ea 100644 --- a/internal/eventstore/repository/mock/repository.mock.impl.go +++ b/internal/eventstore/repository/mock/repository.mock.impl.go @@ -80,7 +80,7 @@ func (m *MockRepository) ExpectInstanceIDsError(err error) *MockRepository { // The call will sleep at least the amount of passed duration. func (m *MockRepository) ExpectPush(expectedCommands []eventstore.Command, sleep time.Duration) *MockRepository { m.MockPusher.EXPECT().Push(gomock.Any(), gomock.Any(), gomock.Any()).DoAndReturn( - func(ctx context.Context, _ database.QueryExecuter, commands ...eventstore.Command) ([]eventstore.Event, error) { + func(ctx context.Context, _ database.ContextQueryExecuter, commands ...eventstore.Command) ([]eventstore.Event, error) { m.MockPusher.ctrl.T.Helper() time.Sleep(sleep) @@ -135,7 +135,7 @@ func (m *MockRepository) ExpectPushFailed(err error, expectedCommands []eventsto m.MockPusher.ctrl.T.Helper() m.MockPusher.EXPECT().Push(gomock.Any(), gomock.Any(), gomock.Any()).DoAndReturn( - func(ctx context.Context, _ database.QueryExecuter, commands ...eventstore.Command) ([]eventstore.Event, error) { + func(ctx context.Context, _ database.ContextQueryExecuter, commands ...eventstore.Command) ([]eventstore.Event, error) { if len(expectedCommands) != len(commands) { return nil, fmt.Errorf("unexpected amount of commands: want %d, got %d", len(expectedCommands), len(commands)) } @@ -197,7 +197,7 @@ func (e *mockEvent) CreatedAt() time.Time { func (m *MockRepository) ExpectRandomPush(expectedCommands []eventstore.Command) *MockRepository { m.MockPusher.EXPECT().Push(gomock.Any(), gomock.Any(), gomock.Any()).DoAndReturn( - func(ctx context.Context, _ database.QueryExecuter, commands ...eventstore.Command) ([]eventstore.Event, error) { + func(ctx context.Context, _ database.ContextQueryExecuter, commands ...eventstore.Command) ([]eventstore.Event, error) { assert.Len(m.MockPusher.ctrl.T, commands, len(expectedCommands)) events := make([]eventstore.Event, len(commands)) @@ -215,7 +215,7 @@ func (m *MockRepository) ExpectRandomPush(expectedCommands []eventstore.Command) func (m *MockRepository) ExpectRandomPushFailed(err error, expectedEvents []eventstore.Command) *MockRepository { m.MockPusher.EXPECT().Push(gomock.Any(), gomock.Any(), gomock.Any()).DoAndReturn( - func(ctx context.Context, _ database.QueryExecuter, events ...eventstore.Command) ([]eventstore.Event, error) { + func(ctx context.Context, _ database.ContextQueryExecuter, events ...eventstore.Command) ([]eventstore.Event, error) { assert.Len(m.MockPusher.ctrl.T, events, len(expectedEvents)) return nil, err }, diff --git a/internal/eventstore/repository/sql/local_crdb_test.go b/internal/eventstore/repository/sql/local_crdb_test.go index fccb169341..0f8c934b47 100644 --- a/internal/eventstore/repository/sql/local_crdb_test.go +++ b/internal/eventstore/repository/sql/local_crdb_test.go @@ -8,11 +8,14 @@ import ( "time" "github.com/cockroachdb/cockroach-go/v2/testserver" + "github.com/jackc/pgx/v5/pgxpool" + "github.com/jackc/pgx/v5/stdlib" "github.com/zitadel/logging" "github.com/zitadel/zitadel/cmd/initialise" "github.com/zitadel/zitadel/internal/database" "github.com/zitadel/zitadel/internal/database/cockroach" + new_es "github.com/zitadel/zitadel/internal/eventstore/v3" ) var ( @@ -29,10 +32,18 @@ func TestMain(m *testing.M) { logging.WithFields("error", err).Fatal("unable to start db") } - testCRDBClient, err = sql.Open("postgres", ts.PGURL().String()) + connConfig, err := pgxpool.ParseConfig(ts.PGURL().String()) if err != nil { - logging.WithFields("error", err).Fatal("unable to connect to db") + logging.WithFields("error", err).Fatal("unable to parse db url") } + connConfig.AfterConnect = new_es.RegisterEventstoreTypes + pool, err := pgxpool.NewWithConfig(context.Background(), connConfig) + if err != nil { + logging.WithFields("error", err).Fatal("unable to create db pool") + } + + testCRDBClient = stdlib.OpenDBFromPool(pool) + if err = testCRDBClient.Ping(); err != nil { logging.WithFields("error", err).Fatal("unable to ping db") } @@ -42,14 +53,14 @@ func TestMain(m *testing.M) { ts.Stop() }() - if err = initDB(&database.DB{DB: testCRDBClient, Database: &cockroach.Config{Database: "zitadel"}}); err != nil { + if err = initDB(context.Background(), &database.DB{DB: testCRDBClient, Database: &cockroach.Config{Database: "zitadel"}}); err != nil { logging.WithFields("error", err).Fatal("migrations failed") } os.Exit(m.Run()) } -func initDB(db *database.DB) error { +func initDB(ctx context.Context, db *database.DB) error { config := new(database.Config) config.SetConnector(&cockroach.Config{User: cockroach.User{Username: "zitadel"}, Database: "zitadel"}) @@ -57,7 +68,7 @@ func initDB(db *database.DB) error { return err } - err := initialise.Init(db, + err := initialise.Init(ctx, db, initialise.VerifyUser(config.Username(), ""), initialise.VerifyDatabase(config.DatabaseName()), initialise.VerifyGrant(config.DatabaseName(), config.Username()), diff --git a/internal/eventstore/v3/event.go b/internal/eventstore/v3/event.go index e1c95f13ff..da4e7a0383 100644 --- a/internal/eventstore/v3/event.go +++ b/internal/eventstore/v3/event.go @@ -1,11 +1,13 @@ package eventstore import ( + "context" "encoding/json" + "strconv" "time" "github.com/zitadel/logging" - + "github.com/zitadel/zitadel/internal/api/authz" "github.com/zitadel/zitadel/internal/eventstore" "github.com/zitadel/zitadel/internal/zerrors" ) @@ -14,33 +16,98 @@ var ( _ eventstore.Event = (*event)(nil) ) +type command struct { + InstanceID string + AggregateType string + AggregateID string + CommandType string + Revision uint16 + Payload Payload + Creator string + Owner string +} + +func (c *command) Aggregate() *eventstore.Aggregate { + return &eventstore.Aggregate{ + ID: c.AggregateID, + Type: eventstore.AggregateType(c.AggregateType), + ResourceOwner: c.Owner, + InstanceID: c.InstanceID, + Version: eventstore.Version("v" + strconv.Itoa(int(c.Revision))), + } +} + type event struct { - aggregate *eventstore.Aggregate - creator string - revision uint16 - typ eventstore.EventType + command *command createdAt time.Time sequence uint64 position float64 - payload Payload } -func commandToEvent(sequence *latestSequence, command eventstore.Command) (_ *event, err error) { +// TODO: remove on v3 +func commandToEventOld(sequence *latestSequence, cmd eventstore.Command) (_ *event, err error) { var payload Payload - if command.Payload() != nil { - payload, err = json.Marshal(command.Payload()) + if cmd.Payload() != nil { + payload, err = json.Marshal(cmd.Payload()) if err != nil { logging.WithError(err).Warn("marshal payload failed") return nil, zerrors.ThrowInternal(err, "V3-MInPK", "Errors.Internal") } } return &event{ - aggregate: sequence.aggregate, - creator: command.Creator(), - revision: command.Revision(), - typ: command.Type(), - payload: payload, - sequence: sequence.sequence, + command: &command{ + InstanceID: sequence.aggregate.InstanceID, + AggregateType: string(sequence.aggregate.Type), + AggregateID: sequence.aggregate.ID, + CommandType: string(cmd.Type()), + Revision: cmd.Revision(), + Payload: payload, + Creator: cmd.Creator(), + Owner: sequence.aggregate.ResourceOwner, + }, + sequence: sequence.sequence, + }, nil +} + +func commandsToEvents(ctx context.Context, cmds []eventstore.Command) (_ []eventstore.Event, _ []*command, err error) { + events := make([]eventstore.Event, len(cmds)) + commands := make([]*command, len(cmds)) + for i, cmd := range cmds { + if cmd.Aggregate().InstanceID == "" { + cmd.Aggregate().InstanceID = authz.GetInstance(ctx).InstanceID() + } + events[i], err = commandToEvent(cmd) + if err != nil { + return nil, nil, err + } + commands[i] = events[i].(*event).command + } + return events, commands, nil +} + +func commandToEvent(cmd eventstore.Command) (_ eventstore.Event, err error) { + var payload Payload + if cmd.Payload() != nil { + payload, err = json.Marshal(cmd.Payload()) + if err != nil { + logging.WithError(err).Warn("marshal payload failed") + return nil, zerrors.ThrowInternal(err, "V3-MInPK", "Errors.Internal") + } + } + + command := &command{ + InstanceID: cmd.Aggregate().InstanceID, + AggregateType: string(cmd.Aggregate().Type), + AggregateID: cmd.Aggregate().ID, + CommandType: string(cmd.Type()), + Revision: cmd.Revision(), + Payload: payload, + Creator: cmd.Creator(), + Owner: cmd.Aggregate().ResourceOwner, + } + + return &event{ + command: command, }, nil } @@ -56,22 +123,22 @@ func (e *event) EditorUser() string { // Aggregate implements [eventstore.Event] func (e *event) Aggregate() *eventstore.Aggregate { - return e.aggregate + return e.command.Aggregate() } // Creator implements [eventstore.Event] func (e *event) Creator() string { - return e.creator + return e.command.Creator } // Revision implements [eventstore.Event] func (e *event) Revision() uint16 { - return e.revision + return e.command.Revision } // Type implements [eventstore.Event] func (e *event) Type() eventstore.EventType { - return e.typ + return eventstore.EventType(e.command.CommandType) } // CreatedAt implements [eventstore.Event] @@ -91,10 +158,10 @@ func (e *event) Position() float64 { // Unmarshal implements [eventstore.Event] func (e *event) Unmarshal(ptr any) error { - if len(e.payload) == 0 { + if len(e.command.Payload) == 0 { return nil } - if err := json.Unmarshal(e.payload, ptr); err != nil { + if err := json.Unmarshal(e.command.Payload, ptr); err != nil { return zerrors.ThrowInternal(err, "V3-u8qVo", "Errors.Internal") } @@ -103,5 +170,5 @@ func (e *event) Unmarshal(ptr any) error { // DataAsBytes implements [eventstore.Event] func (e *event) DataAsBytes() []byte { - return e.payload + return e.command.Payload } diff --git a/internal/eventstore/v3/event_test.go b/internal/eventstore/v3/event_test.go index 82a3aa1c9b..bd813c6ed2 100644 --- a/internal/eventstore/v3/event_test.go +++ b/internal/eventstore/v3/event_test.go @@ -1,16 +1,122 @@ package eventstore import ( + "context" "encoding/json" "testing" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "github.com/zitadel/zitadel/internal/api/authz" "github.com/zitadel/zitadel/internal/eventstore" ) func Test_commandToEvent(t *testing.T) { + payload := struct { + ID string + }{ + ID: "test", + } + payloadMarshalled, err := json.Marshal(payload) + if err != nil { + t.Fatalf("marshal of payload failed: %v", err) + } + type args struct { + command eventstore.Command + } + type want struct { + event *event + err func(t *testing.T, err error) + } + tests := []struct { + name string + args args + want want + }{ + { + name: "no payload", + args: args{ + command: &mockCommand{ + aggregate: mockAggregate("V3-Red9I"), + payload: nil, + }, + }, + want: want{ + event: mockEvent( + mockAggregate("V3-Red9I"), + 0, + nil, + ).(*event), + }, + }, + { + name: "struct payload", + args: args{ + command: &mockCommand{ + aggregate: mockAggregate("V3-Red9I"), + payload: payload, + }, + }, + want: want{ + event: mockEvent( + mockAggregate("V3-Red9I"), + 0, + payloadMarshalled, + ).(*event), + }, + }, + { + name: "pointer payload", + args: args{ + command: &mockCommand{ + aggregate: mockAggregate("V3-Red9I"), + payload: &payload, + }, + }, + want: want{ + event: mockEvent( + mockAggregate("V3-Red9I"), + 0, + payloadMarshalled, + ).(*event), + }, + }, + { + name: "invalid payload", + args: args{ + command: &mockCommand{ + aggregate: mockAggregate("V3-Red9I"), + payload: func() {}, + }, + }, + want: want{ + err: func(t *testing.T, err error) { + assert.Error(t, err) + }, + }, + }, + } + for _, tt := range tests { + if tt.want.err == nil { + tt.want.err = func(t *testing.T, err error) { + require.NoError(t, err) + } + } + t.Run(tt.name, func(t *testing.T) { + got, err := commandToEvent(tt.args.command) + + tt.want.err(t, err) + if tt.want.event == nil { + assert.Nil(t, got) + return + } + assert.Equal(t, tt.want.event, got) + }) + } +} + +func Test_commandToEventOld(t *testing.T) { payload := struct { ID string }{ @@ -119,10 +225,258 @@ func Test_commandToEvent(t *testing.T) { } } t.Run(tt.name, func(t *testing.T) { - got, err := commandToEvent(tt.args.sequence, tt.args.command) + got, err := commandToEventOld(tt.args.sequence, tt.args.command) tt.want.err(t, err) assert.Equal(t, tt.want.event, got) }) } } + +func Test_commandsToEvents(t *testing.T) { + ctx := context.Background() + payload := struct { + ID string + }{ + ID: "test", + } + payloadMarshalled, err := json.Marshal(payload) + if err != nil { + t.Fatalf("marshal of payload failed: %v", err) + } + type args struct { + ctx context.Context + cmds []eventstore.Command + } + type want struct { + events []eventstore.Event + commands []*command + err func(t *testing.T, err error) + } + tests := []struct { + name string + args args + want want + }{ + { + name: "no commands", + args: args{ + ctx: ctx, + cmds: nil, + }, + want: want{ + events: []eventstore.Event{}, + commands: []*command{}, + err: func(t *testing.T, err error) { + require.NoError(t, err) + }, + }, + }, + { + name: "single command no payload", + args: args{ + ctx: ctx, + cmds: []eventstore.Command{ + &mockCommand{ + aggregate: mockAggregate("V3-Red9I"), + payload: nil, + }, + }, + }, + want: want{ + events: []eventstore.Event{ + mockEvent( + mockAggregate("V3-Red9I"), + 0, + nil, + ), + }, + commands: []*command{ + { + InstanceID: "instance", + AggregateType: "type", + AggregateID: "V3-Red9I", + Owner: "ro", + CommandType: "event.type", + Revision: 1, + Payload: nil, + Creator: "creator", + }, + }, + err: func(t *testing.T, err error) { + require.NoError(t, err) + }, + }, + }, + { + name: "single command no instance id", + args: args{ + ctx: authz.WithInstanceID(ctx, "instance from ctx"), + cmds: []eventstore.Command{ + &mockCommand{ + aggregate: mockAggregateWithInstance("V3-Red9I", ""), + payload: nil, + }, + }, + }, + want: want{ + events: []eventstore.Event{ + mockEvent( + mockAggregateWithInstance("V3-Red9I", "instance from ctx"), + 0, + nil, + ), + }, + commands: []*command{ + { + InstanceID: "instance from ctx", + AggregateType: "type", + AggregateID: "V3-Red9I", + Owner: "ro", + CommandType: "event.type", + Revision: 1, + Payload: nil, + Creator: "creator", + }, + }, + err: func(t *testing.T, err error) { + require.NoError(t, err) + }, + }, + }, + { + name: "single command with payload", + args: args{ + ctx: ctx, + cmds: []eventstore.Command{ + &mockCommand{ + aggregate: mockAggregate("V3-Red9I"), + payload: payload, + }, + }, + }, + want: want{ + events: []eventstore.Event{ + mockEvent( + mockAggregate("V3-Red9I"), + 0, + payloadMarshalled, + ), + }, + commands: []*command{ + { + InstanceID: "instance", + AggregateType: "type", + AggregateID: "V3-Red9I", + Owner: "ro", + CommandType: "event.type", + Revision: 1, + Payload: payloadMarshalled, + Creator: "creator", + }, + }, + err: func(t *testing.T, err error) { + require.NoError(t, err) + }, + }, + }, + { + name: "multiple commands", + args: args{ + ctx: ctx, + cmds: []eventstore.Command{ + &mockCommand{ + aggregate: mockAggregate("V3-Red9I"), + payload: payload, + }, + &mockCommand{ + aggregate: mockAggregate("V3-Red9I"), + payload: nil, + }, + }, + }, + want: want{ + events: []eventstore.Event{ + mockEvent( + mockAggregate("V3-Red9I"), + 0, + payloadMarshalled, + ), + mockEvent( + mockAggregate("V3-Red9I"), + 0, + nil, + ), + }, + commands: []*command{ + { + InstanceID: "instance", + AggregateType: "type", + AggregateID: "V3-Red9I", + CommandType: "event.type", + Revision: 1, + Payload: payloadMarshalled, + Creator: "creator", + Owner: "ro", + }, + { + InstanceID: "instance", + AggregateType: "type", + AggregateID: "V3-Red9I", + CommandType: "event.type", + Revision: 1, + Payload: nil, + Creator: "creator", + Owner: "ro", + }, + }, + err: func(t *testing.T, err error) { + require.NoError(t, err) + }, + }, + }, + { + name: "invalid command", + args: args{ + ctx: ctx, + cmds: []eventstore.Command{ + &mockCommand{ + aggregate: mockAggregate("V3-Red9I"), + payload: func() {}, + }, + }, + }, + want: want{ + events: nil, + commands: nil, + err: func(t *testing.T, err error) { + assert.Error(t, err) + }, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + gotEvents, gotCommands, err := commandsToEvents(tt.args.ctx, tt.args.cmds) + + tt.want.err(t, err) + assert.Equal(t, tt.want.events, gotEvents) + require.Len(t, gotCommands, len(tt.want.commands)) + for i, wantCommand := range tt.want.commands { + assertCommand(t, wantCommand, gotCommands[i]) + } + }) + } +} + +func assertCommand(t *testing.T, want, got *command) { + t.Helper() + assert.Equal(t, want.CommandType, got.CommandType) + assert.Equal(t, want.Payload, got.Payload) + assert.Equal(t, want.Creator, got.Creator) + assert.Equal(t, want.Owner, got.Owner) + assert.Equal(t, want.AggregateID, got.AggregateID) + assert.Equal(t, want.AggregateType, got.AggregateType) + assert.Equal(t, want.InstanceID, got.InstanceID) + assert.Equal(t, want.Revision, got.Revision) +} diff --git a/internal/eventstore/v3/eventstore.go b/internal/eventstore/v3/eventstore.go index 7c58f53f29..1bb515527c 100644 --- a/internal/eventstore/v3/eventstore.go +++ b/internal/eventstore/v3/eventstore.go @@ -2,11 +2,26 @@ package eventstore import ( "context" + "database/sql" + "encoding/json" + "errors" + "sync" + + "github.com/DATA-DOG/go-sqlmock" + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgtype" + "github.com/jackc/pgx/v5/stdlib" + "github.com/zitadel/logging" "github.com/zitadel/zitadel/internal/database" + "github.com/zitadel/zitadel/internal/database/dialect" "github.com/zitadel/zitadel/internal/eventstore" ) +func init() { + dialect.RegisterAfterConnect(RegisterEventstoreTypes) +} + var ( // pushPlaceholderFmt defines how data are inserted into the events table pushPlaceholderFmt string @@ -20,6 +35,123 @@ type Eventstore struct { client *database.DB } +var ( + textType = &pgtype.Type{ + Name: "text", + OID: pgtype.TextOID, + Codec: pgtype.TextCodec{}, + } + commandType = &pgtype.Type{ + Codec: &pgtype.CompositeCodec{ + Fields: []pgtype.CompositeCodecField{ + { + Name: "instance_id", + Type: textType, + }, + { + Name: "aggregate_type", + Type: textType, + }, + { + Name: "aggregate_id", + Type: textType, + }, + { + Name: "command_type", + Type: textType, + }, + { + Name: "revision", + Type: &pgtype.Type{ + Name: "int2", + OID: pgtype.Int2OID, + Codec: pgtype.Int2Codec{}, + }, + }, + { + Name: "payload", + Type: &pgtype.Type{ + Name: "jsonb", + OID: pgtype.JSONBOID, + Codec: &pgtype.JSONBCodec{ + Marshal: json.Marshal, + Unmarshal: json.Unmarshal, + }, + }, + }, + { + Name: "creator", + Type: textType, + }, + { + Name: "owner", + Type: textType, + }, + }, + }, + } + commandArrayCodec = &pgtype.Type{ + Codec: &pgtype.ArrayCodec{ + ElementType: commandType, + }, + } +) + +var typeMu sync.Mutex + +func RegisterEventstoreTypes(ctx context.Context, conn *pgx.Conn) error { + // conn.TypeMap is not thread safe + typeMu.Lock() + defer typeMu.Unlock() + + m := conn.TypeMap() + + var cmd *command + if _, ok := m.TypeForValue(cmd); ok { + return nil + } + + if commandType.OID == 0 || commandArrayCodec.OID == 0 { + err := conn.QueryRow(ctx, "select oid, typarray from pg_type where typname = $1 and typnamespace = (select oid from pg_namespace where nspname = $2)", "command", "eventstore"). + Scan(&commandType.OID, &commandArrayCodec.OID) + if err != nil { + logging.WithError(err).Debug("failed to get oid for command type") + return nil + } + if commandType.OID == 0 || commandArrayCodec.OID == 0 { + logging.Debug("oid for command type not found") + return nil + } + } + + m.RegisterTypes([]*pgtype.Type{ + { + Name: "eventstore.command", + Codec: commandType.Codec, + OID: commandType.OID, + }, + { + Name: "command", + Codec: commandType.Codec, + OID: commandType.OID, + }, + { + Name: "eventstore._command", + Codec: commandArrayCodec.Codec, + OID: commandArrayCodec.OID, + }, + { + Name: "_command", + Codec: commandArrayCodec.Codec, + OID: commandArrayCodec.OID, + }, + }) + dialect.RegisterDefaultPgTypeVariants[command](m, "eventstore.command", "eventstore._command") + dialect.RegisterDefaultPgTypeVariants[command](m, "command", "_command") + + return nil +} + // Client implements the [eventstore.Pusher] func (es *Eventstore) Client() *database.DB { return es.client @@ -41,3 +173,45 @@ func NewEventstore(client *database.DB) *Eventstore { func (es *Eventstore) Health(ctx context.Context) error { return es.client.PingContext(ctx) } + +var errTypesNotFound = errors.New("types not found") + +func CheckExecutionPlan(ctx context.Context, conn *sql.Conn) error { + return conn.Raw(func(driverConn any) error { + if _, ok := driverConn.(sqlmock.SqlmockCommon); ok { + return nil + } + conn, ok := driverConn.(*stdlib.Conn) + if !ok { + return errTypesNotFound + } + + return RegisterEventstoreTypes(ctx, conn.Conn()) + }) +} + +func (es *Eventstore) pushTx(ctx context.Context, client database.ContextQueryExecuter) (tx database.Tx, deferrable func(err error) error, err error) { + tx, ok := client.(database.Tx) + if ok { + return tx, nil, nil + } + beginner, ok := client.(database.Beginner) + if !ok { + beginner = es.client + } + + isolationLevel := sql.LevelReadCommitted + // cockroach requires serializable to execute the push function + // because we use [cluster_logical_timestamp()](https://www.cockroachlabs.com/docs/stable/functions-and-operators#system-info-functions) + if es.client.Type() == "cockroach" { + isolationLevel = sql.LevelSerializable + } + tx, err = beginner.BeginTx(ctx, &sql.TxOptions{ + Isolation: isolationLevel, + ReadOnly: false, + }) + if err != nil { + return nil, nil, err + } + return tx, func(err error) error { return database.CloseTransaction(tx, err) }, nil +} diff --git a/internal/eventstore/v3/field.go b/internal/eventstore/v3/field.go index cfa9c08bba..b399e7f5e8 100644 --- a/internal/eventstore/v3/field.go +++ b/internal/eventstore/v3/field.go @@ -143,7 +143,7 @@ func buildSearchCondition(builder *strings.Builder, index int, conditions map[ev return args } -func handleFieldCommands(ctx context.Context, tx database.Tx, commands []eventstore.Command) error { +func (es *Eventstore) handleFieldCommands(ctx context.Context, tx database.Tx, commands []eventstore.Command) error { for _, command := range commands { if len(command.Fields()) > 0 { if err := handleFieldOperations(ctx, tx, command.Fields()); err != nil { diff --git a/internal/eventstore/v3/mock_test.go b/internal/eventstore/v3/mock_test.go index 8de5ae8e2c..89a50f8fd4 100644 --- a/internal/eventstore/v3/mock_test.go +++ b/internal/eventstore/v3/mock_test.go @@ -48,12 +48,17 @@ func (e *mockCommand) Fields() []*eventstore.FieldOperation { func mockEvent(aggregate *eventstore.Aggregate, sequence uint64, payload Payload) eventstore.Event { return &event{ - aggregate: aggregate, - creator: "creator", - revision: 1, - typ: "event.type", - sequence: sequence, - payload: payload, + command: &command{ + InstanceID: aggregate.InstanceID, + AggregateType: string(aggregate.Type), + AggregateID: aggregate.ID, + Owner: aggregate.ResourceOwner, + Creator: "creator", + Revision: 1, + CommandType: "event.type", + Payload: payload, + }, + sequence: sequence, } } @@ -66,3 +71,13 @@ func mockAggregate(id string) *eventstore.Aggregate { Version: "v1", } } + +func mockAggregateWithInstance(id, instance string) *eventstore.Aggregate { + return &eventstore.Aggregate{ + ID: id, + InstanceID: instance, + Type: "type", + ResourceOwner: "ro", + Version: "v1", + } +} diff --git a/internal/eventstore/v3/push.go b/internal/eventstore/v3/push.go index c0f66209c3..fb597021e2 100644 --- a/internal/eventstore/v3/push.go +++ b/internal/eventstore/v3/push.go @@ -4,83 +4,58 @@ import ( "context" "database/sql" _ "embed" - "errors" - "fmt" - "strconv" - "strings" - "github.com/cockroachdb/cockroach-go/v2/crdb" - "github.com/jackc/pgx/v5/pgconn" "github.com/zitadel/logging" - "github.com/zitadel/zitadel/internal/api/authz" "github.com/zitadel/zitadel/internal/database" - "github.com/zitadel/zitadel/internal/database/dialect" "github.com/zitadel/zitadel/internal/eventstore" "github.com/zitadel/zitadel/internal/telemetry/tracing" - "github.com/zitadel/zitadel/internal/zerrors" ) -var appNamePrefix = dialect.DBPurposeEventPusher.AppName() + "_" - var pushTxOpts = &sql.TxOptions{ Isolation: sql.LevelReadCommitted, ReadOnly: false, } -func (es *Eventstore) Push(ctx context.Context, client database.QueryExecuter, commands ...eventstore.Command) (events []eventstore.Event, err error) { +func (es *Eventstore) Push(ctx context.Context, client database.ContextQueryExecuter, commands ...eventstore.Command) (events []eventstore.Event, err error) { ctx, span := tracing.NewSpan(ctx) defer func() { span.EndWithError(err) }() - var tx database.Tx + events, err = es.writeCommands(ctx, client, commands) + if isSetupNotExecutedError(err) { + return es.pushWithoutFunc(ctx, client, commands...) + } + + return events, err +} + +func (es *Eventstore) writeCommands(ctx context.Context, client database.ContextQueryExecuter, commands []eventstore.Command) (_ []eventstore.Event, err error) { + var conn *sql.Conn switch c := client.(type) { - case database.Tx: - tx = c case database.Client: - // We cannot use READ COMMITTED on CockroachDB because we use cluster_logical_timestamp() which is not supported in this isolation level - var opts *sql.TxOptions - if es.client.Database.Type() == "postgres" { - opts = pushTxOpts - } - tx, err = c.BeginTx(ctx, opts) - if err != nil { - return nil, err - } - defer func() { - err = database.CloseTransaction(tx, err) - }() - default: - // We cannot use READ COMMITTED on CockroachDB because we use cluster_logical_timestamp() which is not supported in this isolation level - var opts *sql.TxOptions - if es.client.Database.Type() == "postgres" { - opts = pushTxOpts - } - tx, err = es.client.BeginTx(ctx, opts) - if err != nil { - return nil, err - } - defer func() { - err = database.CloseTransaction(tx, err) - }() + conn, err = c.Conn(ctx) + case nil: + conn, err = es.client.Conn(ctx) + client = conn } - // tx is not closed because [crdb.ExecuteInTx] takes care of that - var ( - sequences []*latestSequence - ) - - // needs to be set like this because psql complains about parameters in the SET statement - _, err = tx.ExecContext(ctx, "SET application_name = '"+appNamePrefix+authz.GetInstance(ctx).InstanceID()+"'") - if err != nil { - logging.WithError(err).Warn("failed to set application name") - return nil, err - } - - sequences, err = latestSequences(ctx, tx, commands) if err != nil { return nil, err } + if conn != nil { + defer conn.Close() + } - events, err = insertEvents(ctx, tx, sequences, commands) + tx, close, err := es.pushTx(ctx, client) + if err != nil { + return nil, err + } + if close != nil { + defer func() { + err = close(err) + }() + } + + events, err := writeEvents(ctx, tx, commands) if err != nil { return nil, err } @@ -89,16 +64,7 @@ func (es *Eventstore) Push(ctx context.Context, client database.QueryExecuter, c return nil, err } - // CockroachDB by default does not allow multiple modifications of the same table using ON CONFLICT - // Thats why we enable it manually - if es.client.Type() == "cockroach" { - _, err = tx.Exec("SET enable_multiple_modifications_of_table = on") - if err != nil { - return nil, err - } - } - - err = handleFieldCommands(ctx, tx, commands) + err = es.handleFieldCommands(ctx, tx, commands) if err != nil { return nil, err } @@ -106,120 +72,30 @@ func (es *Eventstore) Push(ctx context.Context, client database.QueryExecuter, c return events, nil } -//go:embed push.sql -var pushStmt string +func writeEvents(ctx context.Context, tx database.Tx, commands []eventstore.Command) (_ []eventstore.Event, err error) { + ctx, span := tracing.NewSpan(ctx) + defer func() { span.EndWithError(err) }() -func insertEvents(ctx context.Context, tx database.Tx, sequences []*latestSequence, commands []eventstore.Command) ([]eventstore.Event, error) { - events, placeholders, args, err := mapCommands(commands, sequences) + events, cmds, err := commandsToEvents(ctx, commands) if err != nil { return nil, err } - rows, err := tx.QueryContext(ctx, fmt.Sprintf(pushStmt, strings.Join(placeholders, ", ")), args...) + rows, err := tx.QueryContext(ctx, `select owner, created_at, "sequence", position from eventstore.push($1::eventstore.command[])`, cmds) if err != nil { return nil, err } defer rows.Close() for i := 0; rows.Next(); i++ { - err = rows.Scan(&events[i].(*event).createdAt, &events[i].(*event).position) + err = rows.Scan(&events[i].(*event).command.Owner, &events[i].(*event).createdAt, &events[i].(*event).sequence, &events[i].(*event).position) if err != nil { logging.WithError(err).Warn("failed to scan events") return nil, err } } - - if err := rows.Err(); err != nil { - pgErr := new(pgconn.PgError) - if errors.As(err, &pgErr) { - // Check if push tries to write an event just written - // by another transaction - if pgErr.Code == "40001" { - // TODO: @livio-a should we return the parent or not? - return nil, zerrors.ThrowInvalidArgument(err, "V3-p5xAn", "Errors.AlreadyExists") - } - } - logging.WithError(rows.Err()).Warn("failed to push events") - return nil, zerrors.ThrowInternal(err, "V3-VGnZY", "Errors.Internal") + if err = rows.Err(); err != nil { + return nil, err } - return events, nil } - -const argsPerCommand = 10 - -func mapCommands(commands []eventstore.Command, sequences []*latestSequence) (events []eventstore.Event, placeholders []string, args []any, err error) { - events = make([]eventstore.Event, len(commands)) - args = make([]any, 0, len(commands)*argsPerCommand) - placeholders = make([]string, len(commands)) - - for i, command := range commands { - sequence := searchSequenceByCommand(sequences, command) - if sequence == nil { - logging.WithFields( - "aggType", command.Aggregate().Type, - "aggID", command.Aggregate().ID, - "instance", command.Aggregate().InstanceID, - ).Panic("no sequence found") - // added return for linting - return nil, nil, nil, nil - } - sequence.sequence++ - - events[i], err = commandToEvent(sequence, command) - if err != nil { - return nil, nil, nil, err - } - - placeholders[i] = fmt.Sprintf(pushPlaceholderFmt, - i*argsPerCommand+1, - i*argsPerCommand+2, - i*argsPerCommand+3, - i*argsPerCommand+4, - i*argsPerCommand+5, - i*argsPerCommand+6, - i*argsPerCommand+7, - i*argsPerCommand+8, - i*argsPerCommand+9, - i*argsPerCommand+10, - ) - - revision, err := strconv.Atoi(strings.TrimPrefix(string(events[i].(*event).aggregate.Version), "v")) - if err != nil { - return nil, nil, nil, zerrors.ThrowInternal(err, "V3-JoZEp", "Errors.Internal") - } - args = append(args, - events[i].(*event).aggregate.InstanceID, - events[i].(*event).aggregate.ResourceOwner, - events[i].(*event).aggregate.Type, - events[i].(*event).aggregate.ID, - revision, - events[i].(*event).creator, - events[i].(*event).typ, - events[i].(*event).payload, - events[i].(*event).sequence, - i, - ) - } - - return events, placeholders, args, nil -} - -type transaction struct { - database.Tx -} - -var _ crdb.Tx = (*transaction)(nil) - -func (t *transaction) Exec(ctx context.Context, query string, args ...interface{}) error { - _, err := t.Tx.ExecContext(ctx, query, args...) - return err -} - -func (t *transaction) Commit(ctx context.Context) error { - return t.Tx.Commit() -} - -func (t *transaction) Rollback(ctx context.Context) error { - return t.Tx.Rollback() -} diff --git a/internal/eventstore/v3/push_test.go b/internal/eventstore/v3/push_test.go index 0f16a2ac75..a6c4f515fd 100644 --- a/internal/eventstore/v3/push_test.go +++ b/internal/eventstore/v3/push_test.go @@ -70,11 +70,11 @@ func Test_mapCommands(t *testing.T) { args: []any{ "instance", "ro", - eventstore.AggregateType("type"), + "type", "V3-VEIvq", - 1, + uint16(1), "creator", - eventstore.EventType("event.type"), + "event.type", Payload(nil), uint64(1), 0, @@ -121,22 +121,22 @@ func Test_mapCommands(t *testing.T) { // first event "instance", "ro", - eventstore.AggregateType("type"), + "type", "V3-VEIvq", - 1, + uint16(1), "creator", - eventstore.EventType("event.type"), + "event.type", Payload(nil), uint64(6), 0, // second event "instance", "ro", - eventstore.AggregateType("type"), + "type", "V3-VEIvq", - 1, + uint16(1), "creator", - eventstore.EventType("event.type"), + "event.type", Payload(nil), uint64(7), 1, @@ -187,22 +187,22 @@ func Test_mapCommands(t *testing.T) { // first event "instance", "ro", - eventstore.AggregateType("type"), + "type", "V3-VEIvq", - 1, + uint16(1), "creator", - eventstore.EventType("event.type"), + "event.type", Payload(nil), uint64(6), 0, // second event "instance", "ro", - eventstore.AggregateType("type"), + "type", "V3-IT6VN", - 1, + uint16(1), "creator", - eventstore.EventType("event.type"), + "event.type", Payload(nil), uint64(1), 1, diff --git a/internal/eventstore/v3/push_without_func.go b/internal/eventstore/v3/push_without_func.go new file mode 100644 index 0000000000..914b880204 --- /dev/null +++ b/internal/eventstore/v3/push_without_func.go @@ -0,0 +1,183 @@ +package eventstore + +import ( + "context" + _ "embed" + "errors" + "fmt" + "strings" + + "github.com/cockroachdb/cockroach-go/v2/crdb" + "github.com/jackc/pgx/v5/pgconn" + "github.com/zitadel/logging" + + "github.com/zitadel/zitadel/internal/database" + "github.com/zitadel/zitadel/internal/eventstore" + "github.com/zitadel/zitadel/internal/zerrors" +) + +type transaction struct { + database.Tx +} + +var _ crdb.Tx = (*transaction)(nil) + +func (t *transaction) Exec(ctx context.Context, query string, args ...interface{}) error { + _, err := t.Tx.ExecContext(ctx, query, args...) + return err +} + +func (t *transaction) Commit(ctx context.Context) error { + return t.Tx.Commit() +} + +func (t *transaction) Rollback(ctx context.Context) error { + return t.Tx.Rollback() +} + +// checks whether the error is caused because setup step 39 was not executed +func isSetupNotExecutedError(err error) bool { + if err == nil { + return false + } + var pgErr *pgconn.PgError + if errors.As(err, &pgErr) { + return (pgErr.Code == "42704" && strings.Contains(pgErr.Message, "eventstore.command")) || + (pgErr.Code == "42883" && strings.Contains(pgErr.Message, "eventstore.push")) + } + return errors.Is(err, errTypesNotFound) +} + +var ( + //go:embed push.sql + pushStmt string +) + +// pushWithoutFunc implements pushing events before setup step 39 was introduced. +// TODO: remove with v3 +func (es *Eventstore) pushWithoutFunc(ctx context.Context, client database.ContextQueryExecuter, commands ...eventstore.Command) (events []eventstore.Event, err error) { + tx, closeTx, err := es.pushTx(ctx, client) + if err != nil { + return nil, err + } + defer func() { + err = closeTx(err) + }() + + // tx is not closed because [crdb.ExecuteInTx] takes care of that + var ( + sequences []*latestSequence + ) + sequences, err = latestSequences(ctx, tx, commands) + if err != nil { + return nil, err + } + + events, err = es.writeEventsOld(ctx, tx, sequences, commands) + if err != nil { + return nil, err + } + + if err = handleUniqueConstraints(ctx, tx, commands); err != nil { + return nil, err + } + + err = es.handleFieldCommands(ctx, tx, commands) + if err != nil { + return nil, err + } + + return events, nil +} + +func (es *Eventstore) writeEventsOld(ctx context.Context, tx database.Tx, sequences []*latestSequence, commands []eventstore.Command) ([]eventstore.Event, error) { + events, placeholders, args, err := mapCommands(commands, sequences) + if err != nil { + return nil, err + } + + rows, err := tx.QueryContext(ctx, fmt.Sprintf(pushStmt, strings.Join(placeholders, ", ")), args...) + if err != nil { + return nil, err + } + defer rows.Close() + + for i := 0; rows.Next(); i++ { + err = rows.Scan(&events[i].(*event).createdAt, &events[i].(*event).position) + if err != nil { + logging.WithError(err).Warn("failed to scan events") + return nil, err + } + } + + if err := rows.Err(); err != nil { + pgErr := new(pgconn.PgError) + if errors.As(err, &pgErr) { + // Check if push tries to write an event just written + // by another transaction + if pgErr.Code == "40001" { + // TODO: @livio-a should we return the parent or not? + return nil, zerrors.ThrowInvalidArgument(err, "V3-p5xAn", "Errors.AlreadyExists") + } + } + logging.WithError(rows.Err()).Warn("failed to push events") + return nil, zerrors.ThrowInternal(err, "V3-VGnZY", "Errors.Internal") + } + + return events, nil +} + +const argsPerCommand = 10 + +func mapCommands(commands []eventstore.Command, sequences []*latestSequence) (events []eventstore.Event, placeholders []string, args []any, err error) { + events = make([]eventstore.Event, len(commands)) + args = make([]any, 0, len(commands)*argsPerCommand) + placeholders = make([]string, len(commands)) + + for i, command := range commands { + sequence := searchSequenceByCommand(sequences, command) + if sequence == nil { + logging.WithFields( + "aggType", command.Aggregate().Type, + "aggID", command.Aggregate().ID, + "instance", command.Aggregate().InstanceID, + ).Panic("no sequence found") + // added return for linting + return nil, nil, nil, nil + } + sequence.sequence++ + + events[i], err = commandToEventOld(sequence, command) + if err != nil { + return nil, nil, nil, err + } + + placeholders[i] = fmt.Sprintf(pushPlaceholderFmt, + i*argsPerCommand+1, + i*argsPerCommand+2, + i*argsPerCommand+3, + i*argsPerCommand+4, + i*argsPerCommand+5, + i*argsPerCommand+6, + i*argsPerCommand+7, + i*argsPerCommand+8, + i*argsPerCommand+9, + i*argsPerCommand+10, + ) + + args = append(args, + events[i].(*event).command.InstanceID, + events[i].(*event).command.Owner, + events[i].(*event).command.AggregateType, + events[i].(*event).command.AggregateID, + events[i].(*event).command.Revision, + events[i].(*event).command.Creator, + events[i].(*event).command.CommandType, + events[i].(*event).command.Payload, + events[i].(*event).sequence, + i, + ) + } + + return events, placeholders, args, nil +} diff --git a/internal/eventstore/v3/unique_constraints.go b/internal/eventstore/v3/unique_constraints.go index 9c4d1831c4..a491ae4f5c 100644 --- a/internal/eventstore/v3/unique_constraints.go +++ b/internal/eventstore/v3/unique_constraints.go @@ -12,6 +12,7 @@ import ( "github.com/zitadel/zitadel/internal/database" "github.com/zitadel/zitadel/internal/eventstore" + "github.com/zitadel/zitadel/internal/telemetry/tracing" "github.com/zitadel/zitadel/internal/zerrors" ) @@ -24,7 +25,10 @@ var ( addConstraintStmt string ) -func handleUniqueConstraints(ctx context.Context, tx database.Tx, commands []eventstore.Command) error { +func handleUniqueConstraints(ctx context.Context, tx database.Tx, commands []eventstore.Command) (err error) { + ctx, span := tracing.NewSpan(ctx) + defer func() { span.EndWithError(err) }() + deletePlaceholders := make([]string, 0) deleteArgs := make([]any, 0) From e6fae1b3526d45f383ac6ea4e28312eb7102a97c Mon Sep 17 00:00:00 2001 From: zitadelraccine Date: Wed, 4 Dec 2024 10:09:41 -0500 Subject: [PATCH 45/64] docs: add office hours #7 (#8947) # Which Problems Are Solved - Ensuring that the community meeting schedule is updated with the latest upcoming event. # How the Problems Are Solved - By adding a new entry to the meeting schedule # Additional Changes N/A # Additional Context N/A Co-authored-by: Silvan --- MEETING_SCHEDULE.md | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/MEETING_SCHEDULE.md b/MEETING_SCHEDULE.md index 8d59970611..40e528d489 100644 --- a/MEETING_SCHEDULE.md +++ b/MEETING_SCHEDULE.md @@ -3,6 +3,31 @@ Dear community! We're excited to announce bi-weekly office hours. +## #7 Feature demo - Back-channel Logout + +Our dev team has been hard at work developing new features for you to explore. What's been cooking? 🧑🏾‍🍳 Back-channel logout! We're inviting to you join our swiss army knife dev Livio on Wednesday, December 11th, 2024 at 11:00 AM (EST) as he walks you through back-channel logout on the ZITADEL platform & answers your questions! + +🦒 **What to expect** + +A demo session - You'll get the chance to learn more about an upcoming feature through a comprehensive walkthrough led by Livio. +Brief Q&A - Post-demo, there will be space to share your questions & feedback on the back-channel logout feature. + +🗒️ **Details** + +Topic: Feature demo - Back-channel logout +Date & time: Wednesday, December 11th, 2024 at 11:00 AM (EST) +Duration: ~1 hour +Platform: ZITADEL’s Discord stage channel + +**Register for this event here** ➡️ https://discord.gg/bnuAe2RX?event=1308899625041924127 + +🗓️ **Add this to your calendar** ➡️ [Google Calendar](https://calendar.google.com/calendar/u/0/r/eventedit?dates=20241211T110000/20241211T110000&details=Our+dev+team+has+been+hard+at+work+developing+new+features+for+you+to+explore.+What%27s+been+cooking?+%F0%9F%A7%91%F0%9F%8F%BD%E2%80%8D%F0%9F%8D%B3+Back-channel+logout!+We%27re+inviting+to+you+join+our+swiss+army+knife+dev+Livio+on+Wednesday,+December+11,+2024+at+11:00+AM+as+he+walks+you+through+implementing+back-channel+logout+on+the+ZITADEL+platform+%26+answers+your+questions!&location=Discord:+ZITADEL+server,+office+hours&text=Feature+Demo+-+Back-Channel+Logout) + +If you have any questions prior to the live session, be sure to share them in the (office hours stage chat)[https://discord.com/channels/927474939156643850/1243281463554605058] + +Looking forward to seeing you there! Share this with other ZITADEL users & people who might be interested in ZITADEL! It’s appreciated 🫶 + + ## #6 Q&A Hey folks! From 6614aacf786fa7d73c891539025d2c01f1c2015a Mon Sep 17 00:00:00 2001 From: Silvan Date: Wed, 4 Dec 2024 19:10:10 +0100 Subject: [PATCH 46/64] feat(fields): add instance domain (#9000) # Which Problems Are Solved Instance domains are only computed on read side. This can cause missing domains if calls are executed shortly after a instance domain (or instance) was added. # How the Problems Are Solved The instance domain is added to the fields table which is filled on command side. # Additional Changes - added setup step to compute instance domains - instance by host uses fields table instead of instance_domains table # Additional Context - part of https://github.com/zitadel/zitadel/issues/8999 --- cmd/setup/41.go | 42 +++++++++++++ cmd/setup/config.go | 1 + cmd/setup/setup.go | 2 + internal/query/instance_by_domain.sql | 6 +- internal/query/projection/eventstore_field.go | 16 +++++ internal/query/projection/projection.go | 2 + internal/repository/instance/domain.go | 59 +++++++++++++++++++ internal/repository/instance/instance.go | 8 +++ 8 files changed, 134 insertions(+), 2 deletions(-) create mode 100644 cmd/setup/41.go diff --git a/cmd/setup/41.go b/cmd/setup/41.go new file mode 100644 index 0000000000..6fa958bce7 --- /dev/null +++ b/cmd/setup/41.go @@ -0,0 +1,42 @@ +package setup + +import ( + "context" + + "github.com/zitadel/zitadel/internal/api/authz" + "github.com/zitadel/zitadel/internal/eventstore" + "github.com/zitadel/zitadel/internal/query/projection" + "github.com/zitadel/zitadel/internal/repository/instance" +) + +type FillFieldsForInstanceDomains struct { + eventstore *eventstore.Eventstore +} + +func (mig *FillFieldsForInstanceDomains) Execute(ctx context.Context, _ eventstore.Event) error { + instances, err := mig.eventstore.InstanceIDs( + ctx, + 0, + true, + eventstore.NewSearchQueryBuilder(eventstore.ColumnsInstanceIDs). + OrderDesc(). + AddQuery(). + AggregateTypes("instance"). + EventTypes(instance.InstanceAddedEventType). + Builder(), + ) + if err != nil { + return err + } + for _, instance := range instances { + ctx := authz.WithInstanceID(ctx, instance) + if err := projection.InstanceDomainFields.Trigger(ctx); err != nil { + return err + } + } + return nil +} + +func (mig *FillFieldsForInstanceDomains) String() string { + return "41_fill_fields_for_instance_domains" +} diff --git a/cmd/setup/config.go b/cmd/setup/config.go index 34bc80d4a2..6c0a355b94 100644 --- a/cmd/setup/config.go +++ b/cmd/setup/config.go @@ -128,6 +128,7 @@ type Steps struct { s38BackChannelLogoutNotificationStart *BackChannelLogoutNotificationStart s40InitPushFunc *InitPushFunc s39DeleteStaleOrgFields *DeleteStaleOrgFields + s41FillFieldsForInstanceDomains *FillFieldsForInstanceDomains } func MustNewSteps(v *viper.Viper) *Steps { diff --git a/cmd/setup/setup.go b/cmd/setup/setup.go index 8322e081ec..b21ba31bce 100644 --- a/cmd/setup/setup.go +++ b/cmd/setup/setup.go @@ -171,6 +171,7 @@ func Setup(ctx context.Context, config *Config, steps *Steps, masterKey string) steps.s38BackChannelLogoutNotificationStart = &BackChannelLogoutNotificationStart{dbClient: esPusherDBClient, esClient: eventstoreClient} steps.s39DeleteStaleOrgFields = &DeleteStaleOrgFields{dbClient: esPusherDBClient} steps.s40InitPushFunc = &InitPushFunc{dbClient: esPusherDBClient} + steps.s41FillFieldsForInstanceDomains = &FillFieldsForInstanceDomains{eventstore: eventstoreClient} err = projection.Create(ctx, projectionDBClient, eventstoreClient, config.Projections, nil, nil, nil) logging.OnError(err).Fatal("unable to start projections") @@ -218,6 +219,7 @@ func Setup(ctx context.Context, config *Config, steps *Steps, masterKey string) steps.s35AddPositionToIndexEsWm, steps.s36FillV2Milestones, steps.s38BackChannelLogoutNotificationStart, + steps.s41FillFieldsForInstanceDomains, } { mustExecuteMigration(ctx, eventstoreClient, step, "migration failed") } diff --git a/internal/query/instance_by_domain.sql b/internal/query/instance_by_domain.sql index 2f3fcb3518..60896027c4 100644 --- a/internal/query/instance_by_domain.sql +++ b/internal/query/instance_by_domain.sql @@ -1,6 +1,8 @@ with domain as ( - select instance_id from projections.instance_domains - where domain = $1 + SELECT instance_id FROM eventstore.fields + WHERE object_type = 'instance_domain' + AND object_id = $1 + AND field_name = 'domain' ), instance_features as ( select i.* from domain d diff --git a/internal/query/projection/eventstore_field.go b/internal/query/projection/eventstore_field.go index 647c2af83b..59dde7507d 100644 --- a/internal/query/projection/eventstore_field.go +++ b/internal/query/projection/eventstore_field.go @@ -3,6 +3,7 @@ package projection import ( "github.com/zitadel/zitadel/internal/eventstore" "github.com/zitadel/zitadel/internal/eventstore/handler/v2" + "github.com/zitadel/zitadel/internal/repository/instance" "github.com/zitadel/zitadel/internal/repository/org" "github.com/zitadel/zitadel/internal/repository/project" ) @@ -10,6 +11,7 @@ import ( const ( fieldsProjectGrant = "project_grant_fields" fieldsOrgDomainVerified = "org_domain_verified_fields" + fieldsInstanceDomain = "instance_domain_fields" ) func newFillProjectGrantFields(config handler.Config) *handler.FieldHandler { @@ -36,3 +38,17 @@ func newFillOrgDomainVerifiedFields(config handler.Config) *handler.FieldHandler }, ) } + +func newFillInstanceDomainFields(config handler.Config) *handler.FieldHandler { + return handler.NewFieldHandler( + &config, + fieldsInstanceDomain, + map[eventstore.AggregateType][]eventstore.EventType{ + instance.AggregateType: { + instance.InstanceDomainAddedEventType, + instance.InstanceDomainRemovedEventType, + instance.InstanceRemovedEventType, + }, + }, + ) +} diff --git a/internal/query/projection/projection.go b/internal/query/projection/projection.go index a23ae72330..78ca59bc3a 100644 --- a/internal/query/projection/projection.go +++ b/internal/query/projection/projection.go @@ -83,6 +83,7 @@ var ( ProjectGrantFields *handler.FieldHandler OrgDomainVerifiedFields *handler.FieldHandler + InstanceDomainFields *handler.FieldHandler ) type projection interface { @@ -170,6 +171,7 @@ func Create(ctx context.Context, sqlClient *database.DB, es handler.EventStore, ProjectGrantFields = newFillProjectGrantFields(applyCustomConfig(projectionConfig, config.Customizations[fieldsProjectGrant])) OrgDomainVerifiedFields = newFillOrgDomainVerifiedFields(applyCustomConfig(projectionConfig, config.Customizations[fieldsOrgDomainVerified])) + InstanceDomainFields = newFillInstanceDomainFields(applyCustomConfig(projectionConfig, config.Customizations[fieldsInstanceDomain])) newProjectionsList() return nil diff --git a/internal/repository/instance/domain.go b/internal/repository/instance/domain.go index faeb45a71f..9e9b241ad2 100644 --- a/internal/repository/instance/domain.go +++ b/internal/repository/instance/domain.go @@ -13,6 +13,10 @@ const ( InstanceDomainAddedEventType = domainEventPrefix + "added" InstanceDomainPrimarySetEventType = domainEventPrefix + "primary.set" InstanceDomainRemovedEventType = domainEventPrefix + "removed" + + InstanceDomainSearchType = "instance_domain" + InstanceDomainSearchField = "domain" + InstanceDomainObjectRevision = uint8(1) ) func NewAddInstanceDomainUniqueConstraint(domain string) *eventstore.UniqueConstraint { @@ -43,6 +47,30 @@ func (e *DomainAddedEvent) UniqueConstraints() []*eventstore.UniqueConstraint { return []*eventstore.UniqueConstraint{NewAddInstanceDomainUniqueConstraint(e.Domain)} } +func (e *DomainAddedEvent) Fields() []*eventstore.FieldOperation { + return []*eventstore.FieldOperation{ + eventstore.SetField( + e.Aggregate(), + domainSearchObject(e.Domain), + InstanceDomainSearchField, + &eventstore.Value{ + Value: e.Domain, + // TODO: (adlerhurst) ensure uniqueness if we go with fields table: https://github.com/zitadel/zitadel/issues/9009 + MustBeUnique: false, + ShouldIndex: true, + }, + + eventstore.FieldTypeInstanceID, + eventstore.FieldTypeResourceOwner, + eventstore.FieldTypeAggregateType, + eventstore.FieldTypeAggregateID, + eventstore.FieldTypeObjectType, + eventstore.FieldTypeObjectID, + eventstore.FieldTypeFieldName, + ), + } +} + func NewDomainAddedEvent(ctx context.Context, aggregate *eventstore.Aggregate, domain string, generated bool) *DomainAddedEvent { return &DomainAddedEvent{ BaseEvent: *eventstore.NewBaseEventForPush( @@ -118,6 +146,29 @@ func (e *DomainRemovedEvent) UniqueConstraints() []*eventstore.UniqueConstraint return []*eventstore.UniqueConstraint{NewRemoveInstanceDomainUniqueConstraint(e.Domain)} } +func (e *DomainRemovedEvent) Fields() []*eventstore.FieldOperation { + return []*eventstore.FieldOperation{ + eventstore.SetField( + e.Aggregate(), + domainSearchObject(e.Domain), + InstanceDomainSearchField, + &eventstore.Value{ + Value: e.Domain, + MustBeUnique: true, + ShouldIndex: true, + }, + + eventstore.FieldTypeInstanceID, + eventstore.FieldTypeResourceOwner, + eventstore.FieldTypeAggregateType, + eventstore.FieldTypeAggregateID, + eventstore.FieldTypeObjectType, + eventstore.FieldTypeObjectID, + eventstore.FieldTypeFieldName, + ), + } +} + func NewDomainRemovedEvent(ctx context.Context, aggregate *eventstore.Aggregate, domain string) *DomainRemovedEvent { return &DomainRemovedEvent{ BaseEvent: *eventstore.NewBaseEventForPush( @@ -140,3 +191,11 @@ func DomainRemovedEventMapper(event eventstore.Event) (eventstore.Event, error) return domainRemoved, nil } + +func domainSearchObject(domain string) eventstore.Object { + return eventstore.Object{ + Type: InstanceDomainSearchType, + ID: domain, + Revision: InstanceDomainObjectRevision, + } +} diff --git a/internal/repository/instance/instance.go b/internal/repository/instance/instance.go index bd0214075c..761ec4d576 100644 --- a/internal/repository/instance/instance.go +++ b/internal/repository/instance/instance.go @@ -106,6 +106,14 @@ func (e *InstanceRemovedEvent) UniqueConstraints() []*eventstore.UniqueConstrain return constraints } +func (e *InstanceRemovedEvent) Fields() []*eventstore.FieldOperation { + return []*eventstore.FieldOperation{ + eventstore.RemoveSearchFields(map[eventstore.FieldType]any{ + eventstore.FieldTypeInstanceID: e.Aggregate().ID, + }), + } +} + func NewInstanceRemovedEvent(ctx context.Context, aggregate *eventstore.Aggregate, name string, domains []string) *InstanceRemovedEvent { return &InstanceRemovedEvent{ BaseEvent: *eventstore.NewBaseEventForPush( From 7f0378636bd48d134bee814ffb19a66ab570f86c Mon Sep 17 00:00:00 2001 From: Livio Spring Date: Wed, 4 Dec 2024 21:17:49 +0100 Subject: [PATCH 47/64] fix(notifications): improve error handling (#8994) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit # Which Problems Are Solved While running the latest RC / main, we noticed some errors including context timeouts and rollback issues. # How the Problems Are Solved - The transaction context is passed and used for any event being written and for handling savepoints to be able to handle context timeouts. - The user projection is not triggered anymore. This will reduce unnecessary load and potential timeouts if lot of workers are running. In case a user would not be projected yet, the request event will log an error and then be skipped / retried on the next run. - Additionally, the context is checked if being closed after each event process. - `latestRetries` now correctly only returns the latest retry events to be processed - Default values for notifications have been changed to run workers less often, more retry delay, but less transaction duration. # Additional Changes None # Additional Context relates to #8931 --------- Co-authored-by: Tim Möhlmann --- cmd/defaults.yaml | 10 +-- .../handlers/notification_worker.go | 72 ++++++++++--------- .../handlers/notification_worker_test.go | 5 +- 3 files changed, 46 insertions(+), 41 deletions(-) diff --git a/cmd/defaults.yaml b/cmd/defaults.yaml index 21ed1a5e53..a983c7125a 100644 --- a/cmd/defaults.yaml +++ b/cmd/defaults.yaml @@ -456,13 +456,13 @@ Notifications: # The amount of events a single worker will process in a run. BulkLimit: 10 # ZITADEL_NOTIFIACATIONS_BULKLIMIT # Time interval between scheduled notifications for request events - RequeueEvery: 2s # ZITADEL_NOTIFIACATIONS_REQUEUEEVERY + RequeueEvery: 5s # ZITADEL_NOTIFIACATIONS_REQUEUEEVERY # The amount of workers processing the notification retry events. # If set to 0, no notification retry events will be handled. This can be useful when running in # multi binary / pod setup and allowing only certain executables to process the events. RetryWorkers: 1 # ZITADEL_NOTIFIACATIONS_RETRYWORKERS # Time interval between scheduled notifications for retry events - RetryRequeueEvery: 2s # ZITADEL_NOTIFIACATIONS_RETRYREQUEUEEVERY + RetryRequeueEvery: 5s # ZITADEL_NOTIFIACATIONS_RETRYREQUEUEEVERY # Only instances are projected, for which at least a projection-relevant event exists within the timeframe # from HandleActiveInstances duration in the past until the projection's current time # If set to 0 (default), every instance is always considered active @@ -470,15 +470,15 @@ Notifications: # The maximum duration a transaction remains open # before it spots left folding additional events # and updates the table. - TransactionDuration: 1m # ZITADEL_NOTIFIACATIONS_TRANSACTIONDURATION + TransactionDuration: 10s # ZITADEL_NOTIFIACATIONS_TRANSACTIONDURATION # Automatically cancel the notification after the amount of failed attempts MaxAttempts: 3 # ZITADEL_NOTIFIACATIONS_MAXATTEMPTS # Automatically cancel the notification if it cannot be handled within a specific time MaxTtl: 5m # ZITADEL_NOTIFIACATIONS_MAXTTL # Failed attempts are retried after a confogired delay (with exponential backoff). # Set a minimum and maximum delay and a factor for the backoff - MinRetryDelay: 1s # ZITADEL_NOTIFIACATIONS_MINRETRYDELAY - MaxRetryDelay: 20s # ZITADEL_NOTIFIACATIONS_MAXRETRYDELAY + MinRetryDelay: 5s # ZITADEL_NOTIFIACATIONS_MINRETRYDELAY + MaxRetryDelay: 1m # ZITADEL_NOTIFIACATIONS_MAXRETRYDELAY # Any factor below 1 will be set to 1 RetryDelayFactor: 1.5 # ZITADEL_NOTIFIACATIONS_RETRYDELAYFACTOR diff --git a/internal/notification/handlers/notification_worker.go b/internal/notification/handlers/notification_worker.go index 96ecd755dd..6d90b2acb4 100644 --- a/internal/notification/handlers/notification_worker.go +++ b/internal/notification/handlers/notification_worker.go @@ -27,9 +27,8 @@ import ( ) const ( - Domain = "Domain" - Code = "Code" - OTP = "OTP" + Code = "Code" + OTP = "OTP" ) type NotificationWorker struct { @@ -106,17 +105,19 @@ func (w *NotificationWorker) Start(ctx context.Context) { } } -func (w *NotificationWorker) reduceNotificationRequested(ctx context.Context, tx *sql.Tx, event *notification.RequestedEvent) (err error) { +func (w *NotificationWorker) reduceNotificationRequested(ctx, txCtx context.Context, tx *sql.Tx, event *notification.RequestedEvent) (err error) { ctx = ContextWithNotifier(ctx, event.Aggregate()) // if the notification is too old, we can directly cancel if event.CreatedAt().Add(w.config.MaxTtl).Before(w.now()) { - return w.commands.NotificationCanceled(ctx, tx, event.Aggregate().ID, event.Aggregate().ResourceOwner, nil) + return w.commands.NotificationCanceled(txCtx, tx, event.Aggregate().ID, event.Aggregate().ResourceOwner, nil) } // Get the notify user first, so if anything fails afterward we have the current state of the user // and can pass that to the retry request. - notifyUser, err := w.queries.GetNotifyUserByID(ctx, true, event.UserID) + // We do not trigger the projection to reduce load on the database. By the time the notification is processed, + // the user should be projected anyway. If not, it will just wait for the next run. + notifyUser, err := w.queries.GetNotifyUserByID(ctx, false, event.UserID) if err != nil { return err } @@ -128,17 +129,17 @@ func (w *NotificationWorker) reduceNotificationRequested(ctx context.Context, tx event.Request.Args.Domain = notifyUser.LastEmail[index+1:] } - err = w.sendNotification(ctx, tx, event.Request, notifyUser, event) + err = w.sendNotification(ctx, txCtx, tx, event.Request, notifyUser, event) if err == nil { return nil } // if retries are disabled or if the error explicitly specifies, we cancel the notification if w.config.MaxAttempts <= 1 || errors.Is(err, &channels.CancelError{}) { - return w.commands.NotificationCanceled(ctx, tx, event.Aggregate().ID, event.Aggregate().ResourceOwner, err) + return w.commands.NotificationCanceled(txCtx, tx, event.Aggregate().ID, event.Aggregate().ResourceOwner, err) } // otherwise we retry after a backoff delay return w.commands.NotificationRetryRequested( - ctx, + txCtx, tx, event.Aggregate().ID, event.Aggregate().ResourceOwner, @@ -147,49 +148,44 @@ func (w *NotificationWorker) reduceNotificationRequested(ctx context.Context, tx ) } -func (w *NotificationWorker) reduceNotificationRetry(ctx context.Context, tx *sql.Tx, event *notification.RetryRequestedEvent) (err error) { +func (w *NotificationWorker) reduceNotificationRetry(ctx, txCtx context.Context, tx *sql.Tx, event *notification.RetryRequestedEvent) (err error) { ctx = ContextWithNotifier(ctx, event.Aggregate()) // if the notification is too old, we can directly cancel if event.CreatedAt().Add(w.config.MaxTtl).Before(w.now()) { - return w.commands.NotificationCanceled(ctx, tx, event.Aggregate().ID, event.Aggregate().ResourceOwner, err) + return w.commands.NotificationCanceled(txCtx, tx, event.Aggregate().ID, event.Aggregate().ResourceOwner, err) } if event.CreatedAt().Add(event.BackOff).After(w.now()) { return nil } - err = w.sendNotification(ctx, tx, event.Request, event.NotifyUser, event) + err = w.sendNotification(ctx, txCtx, tx, event.Request, event.NotifyUser, event) if err == nil { return nil } // if the max attempts are reached or if the error explicitly specifies, we cancel the notification if event.Sequence() >= uint64(w.config.MaxAttempts) || errors.Is(err, &channels.CancelError{}) { - return w.commands.NotificationCanceled(ctx, tx, event.Aggregate().ID, event.Aggregate().ResourceOwner, err) + return w.commands.NotificationCanceled(txCtx, tx, event.Aggregate().ID, event.Aggregate().ResourceOwner, err) } // otherwise we retry after a backoff delay - return w.commands.NotificationRetryRequested(ctx, tx, event.Aggregate().ID, event.Aggregate().ResourceOwner, notificationEventToRequest( + return w.commands.NotificationRetryRequested(txCtx, tx, event.Aggregate().ID, event.Aggregate().ResourceOwner, notificationEventToRequest( event.Request, event.NotifyUser, w.backOff(event.BackOff), ), err) } -func (w *NotificationWorker) sendNotification(ctx context.Context, tx *sql.Tx, request notification.Request, notifyUser *query.NotifyUser, e eventstore.Event) error { +func (w *NotificationWorker) sendNotification(ctx, txCtx context.Context, tx *sql.Tx, request notification.Request, notifyUser *query.NotifyUser, e eventstore.Event) error { ctx, err := enrichCtx(ctx, request.TriggeredAtOrigin) if err != nil { - err := w.commands.NotificationCanceled(ctx, tx, e.Aggregate().ID, e.Aggregate().ResourceOwner, err) - logging.WithFields("instanceID", authz.GetInstance(ctx).InstanceID(), "notification", e.Aggregate().ID). - OnError(err).Error("could not cancel notification") - return nil + return channels.NewCancelError(err) } // check early that a "sent" handler exists, otherwise we can cancel early sentHandler, ok := sentHandlers[request.EventType] if !ok { - err := w.commands.NotificationCanceled(ctx, tx, e.Aggregate().ID, e.Aggregate().ResourceOwner, err) - logging.WithFields("instanceID", authz.GetInstance(ctx).InstanceID(), "notification", e.Aggregate().ID). - OnError(err).Errorf(`no "sent" handler registered for %s`, request.EventType) - return nil + logging.Errorf(`no "sent" handler registered for %s`, request.EventType) + return channels.NewCancelError(err) } var code string @@ -233,7 +229,7 @@ func (w *NotificationWorker) sendNotification(ctx context.Context, tx *sql.Tx, r if err := notify(request.URLTemplate, args, request.MessageType, request.UnverifiedNotificationChannel); err != nil { return err } - err = w.commands.NotificationSent(ctx, tx, e.Aggregate().ID, e.Aggregate().ResourceOwner) + err = w.commands.NotificationSent(txCtx, tx, e.Aggregate().ID, e.Aggregate().ResourceOwner) if err != nil { // In case the notification event cannot be pushed, we most likely cannot create a retry or cancel event. // Therefore, we'll only log the error and also do not need to try to push to the user / session. @@ -241,7 +237,7 @@ func (w *NotificationWorker) sendNotification(ctx context.Context, tx *sql.Tx, r OnError(err).Error("could not set sent notification event") return nil } - err = sentHandler(ctx, w.commands, request.NotificationAggregateID(), request.NotificationAggregateResourceOwner(), generatorInfo, args) + err = sentHandler(txCtx, w.commands, request.NotificationAggregateID(), request.NotificationAggregateResourceOwner(), generatorInfo, args) logging.WithFields("instanceID", authz.GetInstance(ctx).InstanceID(), "notification", e.Aggregate().ID). OnError(err).Error("could not set notification event on aggregate") return nil @@ -363,7 +359,7 @@ func (w *NotificationWorker) trigger(ctx context.Context, workerID int, retry bo err = database.CloseTransaction(tx, err) }() - events, err := w.searchEvents(ctx, tx, retry) + events, err := w.searchEvents(txCtx, tx, retry) if err != nil { return err } @@ -382,11 +378,11 @@ func (w *NotificationWorker) trigger(ctx context.Context, workerID int, retry bo var err error switch e := event.(type) { case *notification.RequestedEvent: - w.createSavepoint(ctx, tx, event, workerID, retry) - err = w.reduceNotificationRequested(ctx, tx, e) + w.createSavepoint(txCtx, tx, event, workerID, retry) + err = w.reduceNotificationRequested(ctx, txCtx, tx, e) case *notification.RetryRequestedEvent: - w.createSavepoint(ctx, tx, event, workerID, retry) - err = w.reduceNotificationRetry(ctx, tx, e) + w.createSavepoint(txCtx, tx, event, workerID, retry) + err = w.reduceNotificationRetry(ctx, txCtx, tx, e) } if err != nil { w.log(workerID, retry).OnError(err). @@ -394,14 +390,20 @@ func (w *NotificationWorker) trigger(ctx context.Context, workerID int, retry bo WithField("notificationID", event.Aggregate().ID). WithField("sequence", event.Sequence()). WithField("type", event.Type()). - Error("could not push notification event") - w.rollbackToSavepoint(ctx, tx, event, workerID, retry) + Error("could not handle notification event") + // if we have an error, we rollback to the savepoint and continue with the next event + // we use the txCtx to make sure we can rollback the transaction in case the ctx is canceled + w.rollbackToSavepoint(txCtx, tx, event, workerID, retry) + } + // if the context is canceled, we stop the processing + if ctx.Err() != nil { + return nil } } return nil } -func (w *NotificationWorker) latestRetries(events []eventstore.Event) { +func (w *NotificationWorker) latestRetries(events []eventstore.Event) []eventstore.Event { for i := len(events) - 1; i > 0; i-- { // since we delete during the iteration, we need to make sure we don't panic if len(events) <= i { @@ -413,6 +415,7 @@ func (w *NotificationWorker) latestRetries(events []eventstore.Event) { e.Sequence() < events[i].Sequence() }) } + return events } func (w *NotificationWorker) createSavepoint(ctx context.Context, tx *sql.Tx, event eventstore.Event, workerID int, retry bool) { @@ -476,8 +479,7 @@ func (w *NotificationWorker) searchRetryEvents(ctx context.Context, tx *sql.Tx) if err != nil { return nil, err } - w.latestRetries(events) - return events, nil + return w.latestRetries(events), nil } type existingInstances []string diff --git a/internal/notification/handlers/notification_worker_test.go b/internal/notification/handlers/notification_worker_test.go index 03de5201fc..4ffd33005b 100644 --- a/internal/notification/handlers/notification_worker_test.go +++ b/internal/notification/handlers/notification_worker_test.go @@ -420,6 +420,7 @@ func Test_userNotifier_reduceNotificationRequested(t *testing.T) { commands := mock.NewMockCommands(ctrl) f, a, w := tt.test(ctrl, queries, commands) err := newNotificationWorker(t, ctrl, queries, f, a, w).reduceNotificationRequested( + authz.WithInstanceID(context.Background(), instanceID), authz.WithInstanceID(context.Background(), instanceID), &sql.Tx{}, a.event.(*notification.RequestedEvent)) @@ -798,9 +799,11 @@ func Test_userNotifier_reduceNotificationRetry(t *testing.T) { commands := mock.NewMockCommands(ctrl) f, a, w := tt.test(ctrl, queries, commands) err := newNotificationWorker(t, ctrl, queries, f, a, w).reduceNotificationRetry( + authz.WithInstanceID(context.Background(), instanceID), authz.WithInstanceID(context.Background(), instanceID), &sql.Tx{}, - a.event.(*notification.RetryRequestedEvent)) + a.event.(*notification.RetryRequestedEvent), + ) if w.err != nil { w.err(t, err) } else { From d0c23546ec34722b98fb1ade26b56f34d55d8c99 Mon Sep 17 00:00:00 2001 From: Roman Kolokhanin Date: Wed, 4 Dec 2024 23:56:36 +0300 Subject: [PATCH 48/64] fix(oidc): prompts slice conversion function returns slice which contains unexpected empty strings (#8997) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit # Which Problems Are Solved Slice initialized with a fixed length instead of capacity, this leads to unexpected results when calling the append function. # How the Problems Are Solved fixed slice initialization, slice is initialized with zero length and with capacity of function's argument # Additional Changes test case added # Additional Context none Co-authored-by: Kolokhanin Roman Co-authored-by: Tim Möhlmann --- internal/api/oidc/auth_request_converter.go | 2 +- .../api/oidc/auth_request_converter_test.go | 33 +++++++++++++++++++ 2 files changed, 34 insertions(+), 1 deletion(-) diff --git a/internal/api/oidc/auth_request_converter.go b/internal/api/oidc/auth_request_converter.go index cd52cdbe58..2144ca8ba1 100644 --- a/internal/api/oidc/auth_request_converter.go +++ b/internal/api/oidc/auth_request_converter.go @@ -158,7 +158,7 @@ func IpFromContext(ctx context.Context) net.IP { } func PromptToBusiness(oidcPrompt []string) []domain.Prompt { - prompts := make([]domain.Prompt, len(oidcPrompt)) + prompts := make([]domain.Prompt, 0, len(oidcPrompt)) for _, oidcPrompt := range oidcPrompt { switch oidcPrompt { case oidc.PromptNone: diff --git a/internal/api/oidc/auth_request_converter_test.go b/internal/api/oidc/auth_request_converter_test.go index b35d519661..06750aad3c 100644 --- a/internal/api/oidc/auth_request_converter_test.go +++ b/internal/api/oidc/auth_request_converter_test.go @@ -94,3 +94,36 @@ func TestResponseModeToOIDC(t *testing.T) { }) } } + +func TestPromptToBusiness(t *testing.T) { + type args struct { + oidcPrompt []string + } + tests := []struct { + name string + args args + want []domain.Prompt + }{ + { + name: "unspecified", + args: args{nil}, + want: []domain.Prompt{}, + }, + { + name: "invalid", + args: args{[]string{"non_existing_prompt"}}, + want: []domain.Prompt{}, + }, + { + name: "prompt_none", + args: args{[]string{oidc.PromptNone}}, + want: []domain.Prompt{domain.PromptNone}, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := PromptToBusiness(tt.args.oidcPrompt) + assert.Equal(t, tt.want, got) + }) + } +} From 0017e4daa6091c7337929be258beb4e14c1de5bf Mon Sep 17 00:00:00 2001 From: Livio Spring Date: Thu, 5 Dec 2024 07:23:59 +0100 Subject: [PATCH 49/64] docs: remove autoplay from videos (#9005) # Which Problems Are Solved Some videos in the guides start playing automatically. This prevents a great user / developer experience. # How the Problems Are Solved Stop autoplay. # Additional Changes None # Additional Context Discussed internally --- docs/docs/guides/integrate/identity-providers/google.mdx | 2 +- docs/docs/guides/start/quickstart.mdx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/docs/guides/integrate/identity-providers/google.mdx b/docs/docs/guides/integrate/identity-providers/google.mdx index ef191054a6..182a2dc748 100644 --- a/docs/docs/guides/integrate/identity-providers/google.mdx +++ b/docs/docs/guides/integrate/identity-providers/google.mdx @@ -13,7 +13,7 @@ import { ResponsivePlayer } from "../../../../src/components/player"; - + ## Open the Google Identity Provider Template diff --git a/docs/docs/guides/start/quickstart.mdx b/docs/docs/guides/start/quickstart.mdx index 99f193ddd5..b5c3ff5d6d 100644 --- a/docs/docs/guides/start/quickstart.mdx +++ b/docs/docs/guides/start/quickstart.mdx @@ -7,7 +7,7 @@ import { ResponsivePlayer } from "../../../src/components/player"; In this quick start guide, we will be learning some fundamentals on how to set up ZITADEL for user management and application security. Thereafter, we will secure a React-based Single Page Application (SPA) using ZITADEL. - + The sample application allows users to securely log in to ZITADEL using the OIDC Proof Key for Code Exchange (PKCE) flow. This flow ensures that the authentication process is secure by using a code verifier and a code challenge, which are sent to ZITADEL to obtain an access token. The access token is then used by the app to access the userinfo endpoint to retrieve and display information about the logged-in user. The app also has a logout feature that allows users to end their session and clear their access token. Overall, the app provides a simple and secure way for users to authenticate and access protected resources within ZITADEL. From 71d381b5e7ca316676d614a0d524164c2ed5391e Mon Sep 17 00:00:00 2001 From: mffap Date: Thu, 5 Dec 2024 15:04:41 +0200 Subject: [PATCH 50/64] docs(legal): link subprocessors to trust center (#9013) Link list of subprocessors to our trust center --- docs/docs/legal/subprocessors.md | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/docs/docs/legal/subprocessors.md b/docs/docs/legal/subprocessors.md index b5fa71ee03..bc10deae95 100644 --- a/docs/docs/legal/subprocessors.md +++ b/docs/docs/legal/subprocessors.md @@ -4,7 +4,7 @@ sidebar_label: Third Party Sub-Processors custom_edit_url: null --- -Last updated on November 15, 2023 +Last updated on December 5, 2025. In order to achieve the best possible transparency we publish which sub-processors and services we use to provide ZITADEL and related services. The table shows what activity each entity performs. @@ -13,9 +13,5 @@ This explains the limited processing of customer data the entity is authorized t We regularly audit all data processing agreements that we have with our sub-processors to guarantee that they adhere to the same level of privacy as ours to protect your personal data. -The following table indicates which sub-processors have access to end-user data. We try to minimize the number of sub-processors that handle end-user data on our behalf to reduce any vendor related risks. +You can find [the full list of sub-processors in our trust center](https://trust.zitadel.com/subprocessors). We try to minimize the number of sub-processors that handle end-user data on our behalf to reduce any vendor related risks. Some providers are used by default, but you can opt-out of the default provide and replace the sub-processor by a provider of your choice. - -import { SubProcessorTable } from "../../src/components/subprocessors"; - - From 7a3ae8f4990bc913dd1e8a555da1a72cb493152d Mon Sep 17 00:00:00 2001 From: Livio Spring Date: Fri, 6 Dec 2024 10:56:19 +0100 Subject: [PATCH 51/64] fix(notifications): bring back legacy notification handling (#9015) # Which Problems Are Solved There are some problems related to the use of CockroachDB with the new notification handling (#8931). See #9002 for details. # How the Problems Are Solved - Brought back the previous notification handler as legacy mode. - Added a configuration to choose between legacy mode and new parallel workers. - Enabled legacy mode by default to prevent issues. # Additional Changes None # Additional Context - closes https://github.com/zitadel/zitadel/issues/9002 - relates to #8931 --- cmd/defaults.yaml | 5 + .../api/ui/login/init_password_handler.go | 10 + internal/api/ui/login/init_user_handler.go | 12 + internal/api/ui/login/invite_user_handler.go | 11 + internal/api/ui/login/mail_verify_handler.go | 10 + .../api/ui/login/mfa_verify_otp_handler.go | 4 + .../handlers/notification_worker.go | 4 + .../notification/handlers/user_notifier.go | 6 + .../handlers/user_notifier_legacy.go | 793 ++++++++ .../handlers/user_notifier_legacy_test.go | 1601 +++++++++++++++++ internal/notification/projections.go | 2 +- internal/notification/types/domain_claimed.go | 20 + .../types/email_verification_code.go | 28 + .../types/email_verification_code_test.go | 92 + internal/notification/types/init_code.go | 17 + internal/notification/types/invite_code.go | 31 + internal/notification/types/otp.go | 29 + .../notification/types/password_change.go | 16 + internal/notification/types/password_code.go | 27 + .../types/passwordless_registration_link.go | 25 + .../passwordless_registration_link_test.go | 90 + .../types/phone_verification_code.go | 15 + internal/notification/types/types_test.go | 23 + 23 files changed, 2870 insertions(+), 1 deletion(-) create mode 100644 internal/notification/handlers/user_notifier_legacy.go create mode 100644 internal/notification/handlers/user_notifier_legacy_test.go create mode 100644 internal/notification/types/domain_claimed.go create mode 100644 internal/notification/types/email_verification_code.go create mode 100644 internal/notification/types/email_verification_code_test.go create mode 100644 internal/notification/types/init_code.go create mode 100644 internal/notification/types/invite_code.go create mode 100644 internal/notification/types/otp.go create mode 100644 internal/notification/types/password_change.go create mode 100644 internal/notification/types/password_code.go create mode 100644 internal/notification/types/passwordless_registration_link.go create mode 100644 internal/notification/types/passwordless_registration_link_test.go create mode 100644 internal/notification/types/phone_verification_code.go create mode 100644 internal/notification/types/types_test.go diff --git a/cmd/defaults.yaml b/cmd/defaults.yaml index a983c7125a..09899593ab 100644 --- a/cmd/defaults.yaml +++ b/cmd/defaults.yaml @@ -449,6 +449,11 @@ Projections: RequeueEvery: 3300s # ZITADEL_PROJECTIONS_CUSTOMIZATIONS_TELEMETRY_REQUEUEEVERY Notifications: + # Notifications can be processed by either a sequential mode (legacy) or a new parallel mode. + # The parallel mode is currently only recommended for Postgres databases. + # For CockroachDB, the sequential mode is recommended, see: https://github.com/zitadel/zitadel/issues/9002 + # If legacy mode is enabled, the worker config below is ignored. + LegacyEnabled: true # ZITADEL_NOTIFICATIONS_LEGACYENABLED # The amount of workers processing the notification request events. # If set to 0, no notification request events will be handled. This can be useful when running in # multi binary / pod setup and allowing only certain executables to process the events. diff --git a/internal/api/ui/login/init_password_handler.go b/internal/api/ui/login/init_password_handler.go index f7faab778e..b8c6d401c5 100644 --- a/internal/api/ui/login/init_password_handler.go +++ b/internal/api/ui/login/init_password_handler.go @@ -3,6 +3,7 @@ package login import ( "fmt" "net/http" + "net/url" http_mw "github.com/zitadel/zitadel/internal/api/http/middleware" "github.com/zitadel/zitadel/internal/domain" @@ -38,6 +39,15 @@ type initPasswordData struct { HasSymbol string } +func InitPasswordLink(origin, userID, code, orgID, authRequestID string) string { + v := url.Values{} + v.Set(queryInitPWUserID, userID) + v.Set(queryInitPWCode, code) + v.Set(queryOrgID, orgID) + v.Set(QueryAuthRequestID, authRequestID) + return externalLink(origin) + EndpointInitPassword + "?" + v.Encode() +} + func InitPasswordLinkTemplate(origin, userID, orgID, authRequestID string) string { return fmt.Sprintf("%s%s?%s=%s&%s=%s&%s=%s&%s=%s", externalLink(origin), EndpointInitPassword, diff --git a/internal/api/ui/login/init_user_handler.go b/internal/api/ui/login/init_user_handler.go index ad00aa0258..9a6d052dcd 100644 --- a/internal/api/ui/login/init_user_handler.go +++ b/internal/api/ui/login/init_user_handler.go @@ -3,6 +3,7 @@ package login import ( "fmt" "net/http" + "net/url" "strconv" http_mw "github.com/zitadel/zitadel/internal/api/http/middleware" @@ -44,6 +45,17 @@ type initUserData struct { HasSymbol string } +func InitUserLink(origin, userID, loginName, code, orgID string, passwordSet bool, authRequestID string) string { + v := url.Values{} + v.Set(queryInitUserUserID, userID) + v.Set(queryInitUserLoginName, loginName) + v.Set(queryInitUserCode, code) + v.Set(queryOrgID, orgID) + v.Set(queryInitUserPassword, strconv.FormatBool(passwordSet)) + v.Set(QueryAuthRequestID, authRequestID) + return externalLink(origin) + EndpointInitUser + "?" + v.Encode() +} + func InitUserLinkTemplate(origin, userID, orgID, authRequestID string) string { return fmt.Sprintf("%s%s?%s=%s&%s=%s&%s=%s&%s=%s&%s=%s&%s=%s", externalLink(origin), EndpointInitUser, diff --git a/internal/api/ui/login/invite_user_handler.go b/internal/api/ui/login/invite_user_handler.go index 9f9ffb5ad3..e083277c93 100644 --- a/internal/api/ui/login/invite_user_handler.go +++ b/internal/api/ui/login/invite_user_handler.go @@ -3,6 +3,7 @@ package login import ( "fmt" "net/http" + "net/url" http_mw "github.com/zitadel/zitadel/internal/api/http/middleware" "github.com/zitadel/zitadel/internal/domain" @@ -40,6 +41,16 @@ type inviteUserData struct { HasSymbol string } +func InviteUserLink(origin, userID, loginName, code, orgID string, authRequestID string) string { + v := url.Values{} + v.Set(queryInviteUserUserID, userID) + v.Set(queryInviteUserLoginName, loginName) + v.Set(queryInviteUserCode, code) + v.Set(queryOrgID, orgID) + v.Set(QueryAuthRequestID, authRequestID) + return externalLink(origin) + EndpointInviteUser + "?" + v.Encode() +} + func InviteUserLinkTemplate(origin, userID, orgID string, authRequestID string) string { return fmt.Sprintf("%s%s?%s=%s&%s=%s&%s=%s&%s=%s&%s=%s", externalLink(origin), EndpointInviteUser, diff --git a/internal/api/ui/login/mail_verify_handler.go b/internal/api/ui/login/mail_verify_handler.go index 5be22c6741..864ff76dd2 100644 --- a/internal/api/ui/login/mail_verify_handler.go +++ b/internal/api/ui/login/mail_verify_handler.go @@ -4,6 +4,7 @@ import ( "context" "fmt" "net/http" + "net/url" "slices" "github.com/zitadel/logging" @@ -43,6 +44,15 @@ type mailVerificationData struct { HasSymbol string } +func MailVerificationLink(origin, userID, code, orgID, authRequestID string) string { + v := url.Values{} + v.Set(queryUserID, userID) + v.Set(queryCode, code) + v.Set(queryOrgID, orgID) + v.Set(QueryAuthRequestID, authRequestID) + return externalLink(origin) + EndpointMailVerification + "?" + v.Encode() +} + func MailVerificationLinkTemplate(origin, userID, orgID, authRequestID string) string { return fmt.Sprintf("%s%s?%s=%s&%s=%s&%s=%s&%s=%s", externalLink(origin), EndpointMailVerification, diff --git a/internal/api/ui/login/mfa_verify_otp_handler.go b/internal/api/ui/login/mfa_verify_otp_handler.go index 09352f9443..fb77bbcba9 100644 --- a/internal/api/ui/login/mfa_verify_otp_handler.go +++ b/internal/api/ui/login/mfa_verify_otp_handler.go @@ -27,6 +27,10 @@ type mfaOTPFormData struct { Provider domain.MFAType `schema:"provider"` } +func OTPLink(origin, authRequestID, code string, provider domain.MFAType) string { + return fmt.Sprintf("%s%s?%s=%s&%s=%s&%s=%d", externalLink(origin), EndpointMFAOTPVerify, QueryAuthRequestID, authRequestID, queryCode, code, querySelectedProvider, provider) +} + func OTPLinkTemplate(origin, authRequestID string, provider domain.MFAType) string { return fmt.Sprintf("%s%s?%s=%s&%s=%s&%s=%d", externalLink(origin), EndpointMFAOTPVerify, QueryAuthRequestID, authRequestID, queryCode, "{{.Code}}", querySelectedProvider, provider) } diff --git a/internal/notification/handlers/notification_worker.go b/internal/notification/handlers/notification_worker.go index 6d90b2acb4..8ee32c7080 100644 --- a/internal/notification/handlers/notification_worker.go +++ b/internal/notification/handlers/notification_worker.go @@ -43,6 +43,7 @@ type NotificationWorker struct { } type WorkerConfig struct { + LegacyEnabled bool Workers uint8 BulkLimit uint16 RequeueEvery time.Duration @@ -97,6 +98,9 @@ func NewNotificationWorker( } func (w *NotificationWorker) Start(ctx context.Context) { + if w.config.LegacyEnabled { + return + } for i := 0; i < int(w.config.Workers); i++ { go w.schedule(ctx, i, false) } diff --git a/internal/notification/handlers/user_notifier.go b/internal/notification/handlers/user_notifier.go index 684c7b630d..ec30ab476f 100644 --- a/internal/notification/handlers/user_notifier.go +++ b/internal/notification/handlers/user_notifier.go @@ -12,6 +12,7 @@ import ( "github.com/zitadel/zitadel/internal/eventstore" "github.com/zitadel/zitadel/internal/eventstore/handler/v2" "github.com/zitadel/zitadel/internal/notification/senders" + "github.com/zitadel/zitadel/internal/notification/types" "github.com/zitadel/zitadel/internal/repository/session" "github.com/zitadel/zitadel/internal/repository/user" "github.com/zitadel/zitadel/internal/zerrors" @@ -95,8 +96,13 @@ func NewUserNotifier( config handler.Config, commands Commands, queries *NotificationQueries, + channels types.ChannelChains, otpEmailTmpl string, + legacyMode bool, ) *handler.Handler { + if legacyMode { + return NewUserNotifierLegacy(ctx, config, commands, queries, channels, otpEmailTmpl) + } return handler.NewHandler(ctx, &config, &userNotifier{ commands: commands, queries: queries, diff --git a/internal/notification/handlers/user_notifier_legacy.go b/internal/notification/handlers/user_notifier_legacy.go new file mode 100644 index 0000000000..7df31cdf91 --- /dev/null +++ b/internal/notification/handlers/user_notifier_legacy.go @@ -0,0 +1,793 @@ +package handlers + +import ( + "context" + "strings" + "time" + + http_util "github.com/zitadel/zitadel/internal/api/http" + "github.com/zitadel/zitadel/internal/api/ui/login" + "github.com/zitadel/zitadel/internal/crypto" + "github.com/zitadel/zitadel/internal/domain" + "github.com/zitadel/zitadel/internal/eventstore" + "github.com/zitadel/zitadel/internal/eventstore/handler/v2" + "github.com/zitadel/zitadel/internal/notification/senders" + "github.com/zitadel/zitadel/internal/notification/types" + "github.com/zitadel/zitadel/internal/query" + "github.com/zitadel/zitadel/internal/repository/session" + "github.com/zitadel/zitadel/internal/repository/user" + "github.com/zitadel/zitadel/internal/zerrors" +) + +type userNotifierLegacy struct { + commands Commands + queries *NotificationQueries + channels types.ChannelChains + otpEmailTmpl string +} + +func NewUserNotifierLegacy( + ctx context.Context, + config handler.Config, + commands Commands, + queries *NotificationQueries, + channels types.ChannelChains, + otpEmailTmpl string, +) *handler.Handler { + return handler.NewHandler(ctx, &config, &userNotifierLegacy{ + commands: commands, + queries: queries, + otpEmailTmpl: otpEmailTmpl, + channels: channels, + }) +} + +func (u *userNotifierLegacy) Name() string { + return UserNotificationsProjectionTable +} + +func (u *userNotifierLegacy) Reducers() []handler.AggregateReducer { + return []handler.AggregateReducer{ + { + Aggregate: user.AggregateType, + EventReducers: []handler.EventReducer{ + { + Event: user.UserV1InitialCodeAddedType, + Reduce: u.reduceInitCodeAdded, + }, + { + Event: user.HumanInitialCodeAddedType, + Reduce: u.reduceInitCodeAdded, + }, + { + Event: user.UserV1EmailCodeAddedType, + Reduce: u.reduceEmailCodeAdded, + }, + { + Event: user.HumanEmailCodeAddedType, + Reduce: u.reduceEmailCodeAdded, + }, + { + Event: user.UserV1PasswordCodeAddedType, + Reduce: u.reducePasswordCodeAdded, + }, + { + Event: user.HumanPasswordCodeAddedType, + Reduce: u.reducePasswordCodeAdded, + }, + { + Event: user.UserDomainClaimedType, + Reduce: u.reduceDomainClaimed, + }, + { + Event: user.HumanPasswordlessInitCodeRequestedType, + Reduce: u.reducePasswordlessCodeRequested, + }, + { + Event: user.UserV1PhoneCodeAddedType, + Reduce: u.reducePhoneCodeAdded, + }, + { + Event: user.HumanPhoneCodeAddedType, + Reduce: u.reducePhoneCodeAdded, + }, + { + Event: user.HumanPasswordChangedType, + Reduce: u.reducePasswordChanged, + }, + { + Event: user.HumanOTPSMSCodeAddedType, + Reduce: u.reduceOTPSMSCodeAdded, + }, + { + Event: user.HumanOTPEmailCodeAddedType, + Reduce: u.reduceOTPEmailCodeAdded, + }, + { + Event: user.HumanInviteCodeAddedType, + Reduce: u.reduceInviteCodeAdded, + }, + }, + }, + { + Aggregate: session.AggregateType, + EventReducers: []handler.EventReducer{ + { + Event: session.OTPSMSChallengedType, + Reduce: u.reduceSessionOTPSMSChallenged, + }, + { + Event: session.OTPEmailChallengedType, + Reduce: u.reduceSessionOTPEmailChallenged, + }, + }, + }, + } +} + +func (u *userNotifierLegacy) reduceInitCodeAdded(event eventstore.Event) (*handler.Statement, error) { + e, ok := event.(*user.HumanInitialCodeAddedEvent) + if !ok { + return nil, zerrors.ThrowInvalidArgumentf(nil, "HANDL-EFe2f", "reduce.wrong.event.type %s", user.HumanInitialCodeAddedType) + } + + return handler.NewStatement(event, func(ex handler.Executer, projectionName string) error { + ctx := HandlerContext(event.Aggregate()) + alreadyHandled, err := u.checkIfCodeAlreadyHandledOrExpired(ctx, event, e.Expiry, nil, + user.UserV1InitialCodeAddedType, user.UserV1InitialCodeSentType, + user.HumanInitialCodeAddedType, user.HumanInitialCodeSentType) + if err != nil { + return err + } + if alreadyHandled { + return nil + } + code, err := crypto.DecryptString(e.Code, u.queries.UserDataCrypto) + if err != nil { + return err + } + colors, err := u.queries.ActiveLabelPolicyByOrg(ctx, e.Aggregate().ResourceOwner, false) + if err != nil { + return err + } + + template, err := u.queries.MailTemplateByOrg(ctx, e.Aggregate().ResourceOwner, false) + if err != nil { + return err + } + + notifyUser, err := u.queries.GetNotifyUserByID(ctx, true, e.Aggregate().ID) + if err != nil { + return err + } + translator, err := u.queries.GetTranslatorWithOrgTexts(ctx, notifyUser.ResourceOwner, domain.InitCodeMessageType) + if err != nil { + return err + } + + ctx, err = u.queries.Origin(ctx, e) + if err != nil { + return err + } + err = types.SendEmail(ctx, u.channels, string(template.Template), translator, notifyUser, colors, e). + SendUserInitCode(ctx, notifyUser, code, e.AuthRequestID) + if err != nil { + return err + } + return u.commands.HumanInitCodeSent(ctx, e.Aggregate().ResourceOwner, e.Aggregate().ID) + }), nil +} + +func (u *userNotifierLegacy) reduceEmailCodeAdded(event eventstore.Event) (*handler.Statement, error) { + e, ok := event.(*user.HumanEmailCodeAddedEvent) + if !ok { + return nil, zerrors.ThrowInvalidArgumentf(nil, "HANDL-SWf3g", "reduce.wrong.event.type %s", user.HumanEmailCodeAddedType) + } + + if e.CodeReturned { + return handler.NewNoOpStatement(e), nil + } + + return handler.NewStatement(event, func(ex handler.Executer, projectionName string) error { + ctx := HandlerContext(event.Aggregate()) + alreadyHandled, err := u.checkIfCodeAlreadyHandledOrExpired(ctx, event, e.Expiry, nil, + user.UserV1EmailCodeAddedType, user.UserV1EmailCodeSentType, + user.HumanEmailCodeAddedType, user.HumanEmailCodeSentType) + if err != nil { + return err + } + if alreadyHandled { + return nil + } + code, err := crypto.DecryptString(e.Code, u.queries.UserDataCrypto) + if err != nil { + return err + } + colors, err := u.queries.ActiveLabelPolicyByOrg(ctx, e.Aggregate().ResourceOwner, false) + if err != nil { + return err + } + + template, err := u.queries.MailTemplateByOrg(ctx, e.Aggregate().ResourceOwner, false) + if err != nil { + return err + } + + notifyUser, err := u.queries.GetNotifyUserByID(ctx, true, e.Aggregate().ID) + if err != nil { + return err + } + translator, err := u.queries.GetTranslatorWithOrgTexts(ctx, notifyUser.ResourceOwner, domain.VerifyEmailMessageType) + if err != nil { + return err + } + + ctx, err = u.queries.Origin(ctx, e) + if err != nil { + return err + } + err = types.SendEmail(ctx, u.channels, string(template.Template), translator, notifyUser, colors, e). + SendEmailVerificationCode(ctx, notifyUser, code, e.URLTemplate, e.AuthRequestID) + if err != nil { + return err + } + return u.commands.HumanEmailVerificationCodeSent(ctx, e.Aggregate().ResourceOwner, e.Aggregate().ID) + }), nil +} + +func (u *userNotifierLegacy) reducePasswordCodeAdded(event eventstore.Event) (*handler.Statement, error) { + e, ok := event.(*user.HumanPasswordCodeAddedEvent) + if !ok { + return nil, zerrors.ThrowInvalidArgumentf(nil, "HANDL-Eeg3s", "reduce.wrong.event.type %s", user.HumanPasswordCodeAddedType) + } + if e.CodeReturned { + return handler.NewNoOpStatement(e), nil + } + + return handler.NewStatement(event, func(ex handler.Executer, projectionName string) error { + ctx := HandlerContext(event.Aggregate()) + alreadyHandled, err := u.checkIfCodeAlreadyHandledOrExpired(ctx, event, e.Expiry, nil, + user.UserV1PasswordCodeAddedType, user.UserV1PasswordCodeSentType, + user.HumanPasswordCodeAddedType, user.HumanPasswordCodeSentType) + if err != nil { + return err + } + if alreadyHandled { + return nil + } + var code string + if e.Code != nil { + code, err = crypto.DecryptString(e.Code, u.queries.UserDataCrypto) + if err != nil { + return err + } + } + colors, err := u.queries.ActiveLabelPolicyByOrg(ctx, e.Aggregate().ResourceOwner, false) + if err != nil { + return err + } + + template, err := u.queries.MailTemplateByOrg(ctx, e.Aggregate().ResourceOwner, false) + if err != nil { + return err + } + + notifyUser, err := u.queries.GetNotifyUserByID(ctx, true, e.Aggregate().ID) + if err != nil { + return err + } + translator, err := u.queries.GetTranslatorWithOrgTexts(ctx, notifyUser.ResourceOwner, domain.PasswordResetMessageType) + if err != nil { + return err + } + + ctx, err = u.queries.Origin(ctx, e) + if err != nil { + return err + } + generatorInfo := new(senders.CodeGeneratorInfo) + notify := types.SendEmail(ctx, u.channels, string(template.Template), translator, notifyUser, colors, e) + if e.NotificationType == domain.NotificationTypeSms { + notify = types.SendSMS(ctx, u.channels, translator, notifyUser, colors, e, generatorInfo) + } + err = notify.SendPasswordCode(ctx, notifyUser, code, e.URLTemplate, e.AuthRequestID) + if err != nil { + return err + } + return u.commands.PasswordCodeSent(ctx, e.Aggregate().ResourceOwner, e.Aggregate().ID, generatorInfo) + }), nil +} + +func (u *userNotifierLegacy) reduceOTPSMSCodeAdded(event eventstore.Event) (*handler.Statement, error) { + e, ok := event.(*user.HumanOTPSMSCodeAddedEvent) + if !ok { + return nil, zerrors.ThrowInvalidArgumentf(nil, "HANDL-ASF3g", "reduce.wrong.event.type %s", user.HumanOTPSMSCodeAddedType) + } + return u.reduceOTPSMS( + e, + e.Code, + e.Expiry, + e.Aggregate().ID, + e.Aggregate().ResourceOwner, + u.commands.HumanOTPSMSCodeSent, + user.HumanOTPSMSCodeAddedType, + user.HumanOTPSMSCodeSentType, + ) +} + +func (u *userNotifierLegacy) reduceSessionOTPSMSChallenged(event eventstore.Event) (*handler.Statement, error) { + e, ok := event.(*session.OTPSMSChallengedEvent) + if !ok { + return nil, zerrors.ThrowInvalidArgumentf(nil, "HANDL-Sk32L", "reduce.wrong.event.type %s", session.OTPSMSChallengedType) + } + if e.CodeReturned { + return handler.NewNoOpStatement(e), nil + } + ctx := HandlerContext(event.Aggregate()) + s, err := u.queries.SessionByID(ctx, true, e.Aggregate().ID, "") + if err != nil { + return nil, err + } + return u.reduceOTPSMS( + e, + e.Code, + e.Expiry, + s.UserFactor.UserID, + s.UserFactor.ResourceOwner, + u.commands.OTPSMSSent, + session.OTPSMSChallengedType, + session.OTPSMSSentType, + ) +} + +func (u *userNotifierLegacy) reduceOTPSMS( + event eventstore.Event, + code *crypto.CryptoValue, + expiry time.Duration, + userID, + resourceOwner string, + sentCommand func(ctx context.Context, userID, resourceOwner string, generatorInfo *senders.CodeGeneratorInfo) (err error), + eventTypes ...eventstore.EventType, +) (*handler.Statement, error) { + ctx := HandlerContext(event.Aggregate()) + alreadyHandled, err := u.checkIfCodeAlreadyHandledOrExpired(ctx, event, expiry, nil, eventTypes...) + if err != nil { + return nil, err + } + if alreadyHandled { + return handler.NewNoOpStatement(event), nil + } + var plainCode string + if code != nil { + plainCode, err = crypto.DecryptString(code, u.queries.UserDataCrypto) + if err != nil { + return nil, err + } + } + colors, err := u.queries.ActiveLabelPolicyByOrg(ctx, resourceOwner, false) + if err != nil { + return nil, err + } + + notifyUser, err := u.queries.GetNotifyUserByID(ctx, true, userID) + if err != nil { + return nil, err + } + translator, err := u.queries.GetTranslatorWithOrgTexts(ctx, notifyUser.ResourceOwner, domain.VerifySMSOTPMessageType) + if err != nil { + return nil, err + } + ctx, err = u.queries.Origin(ctx, event) + if err != nil { + return nil, err + } + generatorInfo := new(senders.CodeGeneratorInfo) + notify := types.SendSMS(ctx, u.channels, translator, notifyUser, colors, event, generatorInfo) + err = notify.SendOTPSMSCode(ctx, plainCode, expiry) + if err != nil { + return nil, err + } + err = sentCommand(ctx, event.Aggregate().ID, event.Aggregate().ResourceOwner, generatorInfo) + if err != nil { + return nil, err + } + return handler.NewNoOpStatement(event), nil +} + +func (u *userNotifierLegacy) reduceOTPEmailCodeAdded(event eventstore.Event) (*handler.Statement, error) { + e, ok := event.(*user.HumanOTPEmailCodeAddedEvent) + if !ok { + return nil, zerrors.ThrowInvalidArgumentf(nil, "HANDL-JL3hw", "reduce.wrong.event.type %s", user.HumanOTPEmailCodeAddedType) + } + var authRequestID string + if e.AuthRequestInfo != nil { + authRequestID = e.AuthRequestInfo.ID + } + url := func(code, origin string, _ *query.NotifyUser) (string, error) { + return login.OTPLink(origin, authRequestID, code, domain.MFATypeOTPEmail), nil + } + return u.reduceOTPEmail( + e, + e.Code, + e.Expiry, + e.Aggregate().ID, + e.Aggregate().ResourceOwner, + url, + u.commands.HumanOTPEmailCodeSent, + user.HumanOTPEmailCodeAddedType, + user.HumanOTPEmailCodeSentType, + ) +} + +func (u *userNotifierLegacy) reduceSessionOTPEmailChallenged(event eventstore.Event) (*handler.Statement, error) { + e, ok := event.(*session.OTPEmailChallengedEvent) + if !ok { + return nil, zerrors.ThrowInvalidArgumentf(nil, "HANDL-zbsgt", "reduce.wrong.event.type %s", session.OTPEmailChallengedType) + } + if e.ReturnCode { + return handler.NewNoOpStatement(e), nil + } + ctx := HandlerContext(event.Aggregate()) + s, err := u.queries.SessionByID(ctx, true, e.Aggregate().ID, "") + if err != nil { + return nil, err + } + url := func(code, origin string, user *query.NotifyUser) (string, error) { + var buf strings.Builder + urlTmpl := origin + u.otpEmailTmpl + if e.URLTmpl != "" { + urlTmpl = e.URLTmpl + } + if err := domain.RenderOTPEmailURLTemplate(&buf, urlTmpl, code, user.ID, user.PreferredLoginName, user.DisplayName, e.Aggregate().ID, user.PreferredLanguage); err != nil { + return "", err + } + return buf.String(), nil + } + return u.reduceOTPEmail( + e, + e.Code, + e.Expiry, + s.UserFactor.UserID, + s.UserFactor.ResourceOwner, + url, + u.commands.OTPEmailSent, + user.HumanOTPEmailCodeAddedType, + user.HumanOTPEmailCodeSentType, + ) +} + +func (u *userNotifierLegacy) reduceOTPEmail( + event eventstore.Event, + code *crypto.CryptoValue, + expiry time.Duration, + userID, + resourceOwner string, + urlTmpl func(code, origin string, user *query.NotifyUser) (string, error), + sentCommand func(ctx context.Context, userID string, resourceOwner string) (err error), + eventTypes ...eventstore.EventType, +) (*handler.Statement, error) { + ctx := HandlerContext(event.Aggregate()) + alreadyHandled, err := u.checkIfCodeAlreadyHandledOrExpired(ctx, event, expiry, nil, eventTypes...) + if err != nil { + return nil, err + } + if alreadyHandled { + return handler.NewNoOpStatement(event), nil + } + plainCode, err := crypto.DecryptString(code, u.queries.UserDataCrypto) + if err != nil { + return nil, err + } + colors, err := u.queries.ActiveLabelPolicyByOrg(ctx, resourceOwner, false) + if err != nil { + return nil, err + } + + template, err := u.queries.MailTemplateByOrg(ctx, resourceOwner, false) + if err != nil { + return nil, err + } + + notifyUser, err := u.queries.GetNotifyUserByID(ctx, true, userID) + if err != nil { + return nil, err + } + translator, err := u.queries.GetTranslatorWithOrgTexts(ctx, resourceOwner, domain.VerifyEmailOTPMessageType) + if err != nil { + return nil, err + } + ctx, err = u.queries.Origin(ctx, event) + if err != nil { + return nil, err + } + url, err := urlTmpl(plainCode, http_util.DomainContext(ctx).Origin(), notifyUser) + if err != nil { + return nil, err + } + notify := types.SendEmail(ctx, u.channels, string(template.Template), translator, notifyUser, colors, event) + err = notify.SendOTPEmailCode(ctx, url, plainCode, expiry) + if err != nil { + return nil, err + } + err = sentCommand(ctx, event.Aggregate().ID, event.Aggregate().ResourceOwner) + if err != nil { + return nil, err + } + return handler.NewNoOpStatement(event), nil +} + +func (u *userNotifierLegacy) reduceDomainClaimed(event eventstore.Event) (*handler.Statement, error) { + e, ok := event.(*user.DomainClaimedEvent) + if !ok { + return nil, zerrors.ThrowInvalidArgumentf(nil, "HANDL-Drh5w", "reduce.wrong.event.type %s", user.UserDomainClaimedType) + } + return handler.NewStatement(event, func(ex handler.Executer, projectionName string) error { + ctx := HandlerContext(event.Aggregate()) + alreadyHandled, err := u.queries.IsAlreadyHandled(ctx, event, nil, + user.UserDomainClaimedType, user.UserDomainClaimedSentType) + if err != nil { + return err + } + if alreadyHandled { + return nil + } + colors, err := u.queries.ActiveLabelPolicyByOrg(ctx, e.Aggregate().ResourceOwner, false) + if err != nil { + return err + } + + template, err := u.queries.MailTemplateByOrg(ctx, e.Aggregate().ResourceOwner, false) + if err != nil { + return err + } + + notifyUser, err := u.queries.GetNotifyUserByID(ctx, true, e.Aggregate().ID) + if err != nil { + return err + } + translator, err := u.queries.GetTranslatorWithOrgTexts(ctx, notifyUser.ResourceOwner, domain.DomainClaimedMessageType) + if err != nil { + return err + } + + ctx, err = u.queries.Origin(ctx, e) + if err != nil { + return err + } + err = types.SendEmail(ctx, u.channels, string(template.Template), translator, notifyUser, colors, e). + SendDomainClaimed(ctx, notifyUser, e.UserName) + if err != nil { + return err + } + return u.commands.UserDomainClaimedSent(ctx, e.Aggregate().ResourceOwner, e.Aggregate().ID) + }), nil +} + +func (u *userNotifierLegacy) reducePasswordlessCodeRequested(event eventstore.Event) (*handler.Statement, error) { + e, ok := event.(*user.HumanPasswordlessInitCodeRequestedEvent) + if !ok { + return nil, zerrors.ThrowInvalidArgumentf(nil, "HANDL-EDtjd", "reduce.wrong.event.type %s", user.HumanPasswordlessInitCodeAddedType) + } + if e.CodeReturned { + return handler.NewNoOpStatement(e), nil + } + + return handler.NewStatement(event, func(ex handler.Executer, projectionName string) error { + ctx := HandlerContext(event.Aggregate()) + alreadyHandled, err := u.checkIfCodeAlreadyHandledOrExpired(ctx, event, e.Expiry, map[string]interface{}{"id": e.ID}, user.HumanPasswordlessInitCodeSentType) + if err != nil { + return err + } + if alreadyHandled { + return nil + } + code, err := crypto.DecryptString(e.Code, u.queries.UserDataCrypto) + if err != nil { + return err + } + colors, err := u.queries.ActiveLabelPolicyByOrg(ctx, e.Aggregate().ResourceOwner, false) + if err != nil { + return err + } + + template, err := u.queries.MailTemplateByOrg(ctx, e.Aggregate().ResourceOwner, false) + if err != nil { + return err + } + + notifyUser, err := u.queries.GetNotifyUserByID(ctx, true, e.Aggregate().ID) + if err != nil { + return err + } + translator, err := u.queries.GetTranslatorWithOrgTexts(ctx, notifyUser.ResourceOwner, domain.PasswordlessRegistrationMessageType) + if err != nil { + return err + } + + ctx, err = u.queries.Origin(ctx, e) + if err != nil { + return err + } + err = types.SendEmail(ctx, u.channels, string(template.Template), translator, notifyUser, colors, e). + SendPasswordlessRegistrationLink(ctx, notifyUser, code, e.ID, e.URLTemplate) + if err != nil { + return err + } + return u.commands.HumanPasswordlessInitCodeSent(ctx, e.Aggregate().ID, e.Aggregate().ResourceOwner, e.ID) + }), nil +} + +func (u *userNotifierLegacy) reducePasswordChanged(event eventstore.Event) (*handler.Statement, error) { + e, ok := event.(*user.HumanPasswordChangedEvent) + if !ok { + return nil, zerrors.ThrowInvalidArgumentf(nil, "HANDL-Yko2z8", "reduce.wrong.event.type %s", user.HumanPasswordChangedType) + } + + return handler.NewStatement(event, func(ex handler.Executer, projectionName string) error { + ctx := HandlerContext(event.Aggregate()) + alreadyHandled, err := u.queries.IsAlreadyHandled(ctx, event, nil, user.HumanPasswordChangeSentType) + if err != nil { + return err + } + if alreadyHandled { + return nil + } + + notificationPolicy, err := u.queries.NotificationPolicyByOrg(ctx, true, e.Aggregate().ResourceOwner, false) + if zerrors.IsNotFound(err) { + return nil + } + if err != nil { + return err + } + + if !notificationPolicy.PasswordChange { + return nil + } + + colors, err := u.queries.ActiveLabelPolicyByOrg(ctx, e.Aggregate().ResourceOwner, false) + if err != nil { + return err + } + + template, err := u.queries.MailTemplateByOrg(ctx, e.Aggregate().ResourceOwner, false) + if err != nil { + return err + } + + notifyUser, err := u.queries.GetNotifyUserByID(ctx, true, e.Aggregate().ID) + if err != nil { + return err + } + translator, err := u.queries.GetTranslatorWithOrgTexts(ctx, notifyUser.ResourceOwner, domain.PasswordChangeMessageType) + if err != nil { + return err + } + ctx, err = u.queries.Origin(ctx, e) + if err != nil { + return err + } + err = types.SendEmail(ctx, u.channels, string(template.Template), translator, notifyUser, colors, e). + SendPasswordChange(ctx, notifyUser) + if err != nil { + return err + } + return u.commands.PasswordChangeSent(ctx, e.Aggregate().ResourceOwner, e.Aggregate().ID) + }), nil +} + +func (u *userNotifierLegacy) reducePhoneCodeAdded(event eventstore.Event) (*handler.Statement, error) { + e, ok := event.(*user.HumanPhoneCodeAddedEvent) + if !ok { + return nil, zerrors.ThrowInvalidArgumentf(nil, "HANDL-He83g", "reduce.wrong.event.type %s", user.HumanPhoneCodeAddedType) + } + if e.CodeReturned { + return handler.NewNoOpStatement(e), nil + } + + return handler.NewStatement(event, func(ex handler.Executer, projectionName string) error { + ctx := HandlerContext(event.Aggregate()) + alreadyHandled, err := u.checkIfCodeAlreadyHandledOrExpired(ctx, event, e.Expiry, nil, + user.UserV1PhoneCodeAddedType, user.UserV1PhoneCodeSentType, + user.HumanPhoneCodeAddedType, user.HumanPhoneCodeSentType) + if err != nil { + return err + } + if alreadyHandled { + return nil + } + var code string + if e.Code != nil { + code, err = crypto.DecryptString(e.Code, u.queries.UserDataCrypto) + if err != nil { + return err + } + } + colors, err := u.queries.ActiveLabelPolicyByOrg(ctx, e.Aggregate().ResourceOwner, false) + if err != nil { + return err + } + + notifyUser, err := u.queries.GetNotifyUserByID(ctx, true, e.Aggregate().ID) + if err != nil { + return err + } + translator, err := u.queries.GetTranslatorWithOrgTexts(ctx, notifyUser.ResourceOwner, domain.VerifyPhoneMessageType) + if err != nil { + return err + } + + ctx, err = u.queries.Origin(ctx, e) + if err != nil { + return err + } + generatorInfo := new(senders.CodeGeneratorInfo) + if err = types.SendSMS(ctx, u.channels, translator, notifyUser, colors, e, generatorInfo). + SendPhoneVerificationCode(ctx, code); err != nil { + return err + } + return u.commands.HumanPhoneVerificationCodeSent(ctx, e.Aggregate().ResourceOwner, e.Aggregate().ID, generatorInfo) + }), nil +} + +func (u *userNotifierLegacy) reduceInviteCodeAdded(event eventstore.Event) (*handler.Statement, error) { + e, ok := event.(*user.HumanInviteCodeAddedEvent) + if !ok { + return nil, zerrors.ThrowInvalidArgumentf(nil, "HANDL-Eeg3s", "reduce.wrong.event.type %s", user.HumanInviteCodeAddedType) + } + if e.CodeReturned { + return handler.NewNoOpStatement(e), nil + } + + return handler.NewStatement(event, func(ex handler.Executer, projectionName string) error { + ctx := HandlerContext(event.Aggregate()) + alreadyHandled, err := u.checkIfCodeAlreadyHandledOrExpired(ctx, event, e.Expiry, nil, + user.HumanInviteCodeAddedType, user.HumanInviteCodeSentType) + if err != nil { + return err + } + if alreadyHandled { + return nil + } + code, err := crypto.DecryptString(e.Code, u.queries.UserDataCrypto) + if err != nil { + return err + } + colors, err := u.queries.ActiveLabelPolicyByOrg(ctx, e.Aggregate().ResourceOwner, false) + if err != nil { + return err + } + + template, err := u.queries.MailTemplateByOrg(ctx, e.Aggregate().ResourceOwner, false) + if err != nil { + return err + } + + notifyUser, err := u.queries.GetNotifyUserByID(ctx, true, e.Aggregate().ID) + if err != nil { + return err + } + translator, err := u.queries.GetTranslatorWithOrgTexts(ctx, notifyUser.ResourceOwner, domain.InviteUserMessageType) + if err != nil { + return err + } + + ctx, err = u.queries.Origin(ctx, e) + if err != nil { + return err + } + notify := types.SendEmail(ctx, u.channels, string(template.Template), translator, notifyUser, colors, e) + err = notify.SendInviteCode(ctx, notifyUser, code, e.ApplicationName, e.URLTemplate, e.AuthRequestID) + if err != nil { + return err + } + return u.commands.InviteCodeSent(ctx, e.Aggregate().ID, e.Aggregate().ResourceOwner) + }), nil +} + +func (u *userNotifierLegacy) checkIfCodeAlreadyHandledOrExpired(ctx context.Context, event eventstore.Event, expiry time.Duration, data map[string]interface{}, eventTypes ...eventstore.EventType) (bool, error) { + if expiry > 0 && event.CreatedAt().Add(expiry).Before(time.Now().UTC()) { + return true, nil + } + return u.queries.IsAlreadyHandled(ctx, event, data, eventTypes...) +} diff --git a/internal/notification/handlers/user_notifier_legacy_test.go b/internal/notification/handlers/user_notifier_legacy_test.go new file mode 100644 index 0000000000..fe99eaa572 --- /dev/null +++ b/internal/notification/handlers/user_notifier_legacy_test.go @@ -0,0 +1,1601 @@ +package handlers + +import ( + "database/sql" + "fmt" + "testing" + "time" + + "github.com/muhlemmer/gu" + "github.com/stretchr/testify/assert" + "go.uber.org/mock/gomock" + + "github.com/zitadel/zitadel/internal/domain" + "github.com/zitadel/zitadel/internal/eventstore" + "github.com/zitadel/zitadel/internal/eventstore/repository" + es_repo_mock "github.com/zitadel/zitadel/internal/eventstore/repository/mock" + "github.com/zitadel/zitadel/internal/notification/channels/email" + channel_mock "github.com/zitadel/zitadel/internal/notification/channels/mock" + "github.com/zitadel/zitadel/internal/notification/channels/sms" + "github.com/zitadel/zitadel/internal/notification/channels/smtp" + "github.com/zitadel/zitadel/internal/notification/channels/twilio" + "github.com/zitadel/zitadel/internal/notification/handlers/mock" + "github.com/zitadel/zitadel/internal/notification/messages" + "github.com/zitadel/zitadel/internal/notification/senders" + "github.com/zitadel/zitadel/internal/query" + "github.com/zitadel/zitadel/internal/repository/session" + "github.com/zitadel/zitadel/internal/repository/user" +) + +func Test_userNotifierLegacy_reduceInitCodeAdded(t *testing.T) { + expectMailSubject := "Initialize User" + tests := []struct { + name string + test func(*gomock.Controller, *mock.MockQueries, *mock.MockCommands) (fields, args, wantLegacy) + }{{ + name: "asset url with event trigger url", + test: func(ctrl *gomock.Controller, queries *mock.MockQueries, commands *mock.MockCommands) (f fields, a args, w wantLegacy) { + givenTemplate := "{{.LogoURL}}" + expectContent := fmt.Sprintf("%s%s/%s/%s", eventOrigin, assetsPath, policyID, logoURL) + w.message = &messages.Email{ + Recipients: []string{lastEmail}, + Subject: expectMailSubject, + Content: expectContent, + } + codeAlg, code := cryptoValue(t, ctrl, "testcode") + expectTemplateWithNotifyUserQueries(queries, givenTemplate) + commands.EXPECT().HumanInitCodeSent(gomock.Any(), orgID, userID).Return(nil) + return fields{ + queries: queries, + commands: commands, + es: eventstore.NewEventstore(&eventstore.Config{ + Querier: es_repo_mock.NewRepo(t).ExpectFilterEvents().MockQuerier, + }), + userDataCrypto: codeAlg, + }, args{ + event: &user.HumanInitialCodeAddedEvent{ + BaseEvent: *eventstore.BaseEventFromRepo(&repository.Event{ + AggregateID: userID, + ResourceOwner: sql.NullString{String: orgID}, + CreationDate: time.Now().UTC(), + }), + Code: code, + Expiry: time.Hour, + TriggeredAtOrigin: eventOrigin, + }, + }, w + }, + }, { + name: "asset url without event trigger url", + test: func(ctrl *gomock.Controller, queries *mock.MockQueries, commands *mock.MockCommands) (f fields, a args, w wantLegacy) { + givenTemplate := "{{.LogoURL}}" + expectContent := fmt.Sprintf("%s://%s:%d%s/%s/%s", externalProtocol, instancePrimaryDomain, externalPort, assetsPath, policyID, logoURL) + w.message = &messages.Email{ + Recipients: []string{lastEmail}, + Subject: expectMailSubject, + Content: expectContent, + } + codeAlg, code := cryptoValue(t, ctrl, "testcode") + queries.EXPECT().SearchInstanceDomains(gomock.Any(), gomock.Any()).Return(&query.InstanceDomains{ + Domains: []*query.InstanceDomain{{ + Domain: instancePrimaryDomain, + IsPrimary: true, + }}, + }, nil) + expectTemplateWithNotifyUserQueries(queries, givenTemplate) + commands.EXPECT().HumanInitCodeSent(gomock.Any(), orgID, userID).Return(nil) + return fields{ + queries: queries, + commands: commands, + es: eventstore.NewEventstore(&eventstore.Config{ + Querier: es_repo_mock.NewRepo(t).ExpectFilterEvents().MockQuerier, + }), + userDataCrypto: codeAlg, + }, args{ + event: &user.HumanInitialCodeAddedEvent{ + BaseEvent: *eventstore.BaseEventFromRepo(&repository.Event{ + AggregateID: userID, + ResourceOwner: sql.NullString{String: orgID}, + CreationDate: time.Now().UTC(), + }), + Code: code, + Expiry: time.Hour, + }, + }, w + }, + }, { + name: "button url with event trigger url", + test: func(ctrl *gomock.Controller, queries *mock.MockQueries, commands *mock.MockCommands) (f fields, a args, w wantLegacy) { + givenTemplate := "{{.URL}}" + testCode := "testcode" + expectContent := fmt.Sprintf("%s/ui/login/user/init?authRequestID=%s&code=%s&loginname=%s&orgID=%s&passwordset=%t&userID=%s", eventOrigin, "", testCode, preferredLoginName, orgID, false, userID) + w.message = &messages.Email{ + Recipients: []string{lastEmail}, + Subject: expectMailSubject, + Content: expectContent, + } + codeAlg, code := cryptoValue(t, ctrl, testCode) + expectTemplateWithNotifyUserQueries(queries, givenTemplate) + commands.EXPECT().HumanInitCodeSent(gomock.Any(), orgID, userID).Return(nil) + return fields{ + queries: queries, + commands: commands, + es: eventstore.NewEventstore(&eventstore.Config{ + Querier: es_repo_mock.NewRepo(t).ExpectFilterEvents().MockQuerier, + }), + userDataCrypto: codeAlg, + }, args{ + event: &user.HumanInitialCodeAddedEvent{ + BaseEvent: *eventstore.BaseEventFromRepo(&repository.Event{ + AggregateID: userID, + ResourceOwner: sql.NullString{String: orgID}, + CreationDate: time.Now().UTC(), + }), + Code: code, + Expiry: time.Hour, + TriggeredAtOrigin: eventOrigin, + }, + }, w + }, + }, { + name: "button url without event trigger url", + test: func(ctrl *gomock.Controller, queries *mock.MockQueries, commands *mock.MockCommands) (f fields, a args, w wantLegacy) { + givenTemplate := "{{.URL}}" + testCode := "testcode" + expectContent := fmt.Sprintf("%s://%s:%d/ui/login/user/init?authRequestID=%s&code=%s&loginname=%s&orgID=%s&passwordset=%t&userID=%s", externalProtocol, instancePrimaryDomain, externalPort, "", testCode, preferredLoginName, orgID, false, userID) + w.message = &messages.Email{ + Recipients: []string{lastEmail}, + Subject: expectMailSubject, + Content: expectContent, + } + codeAlg, code := cryptoValue(t, ctrl, testCode) + queries.EXPECT().SearchInstanceDomains(gomock.Any(), gomock.Any()).Return(&query.InstanceDomains{ + Domains: []*query.InstanceDomain{{ + Domain: instancePrimaryDomain, + IsPrimary: true, + }}, + }, nil) + expectTemplateWithNotifyUserQueries(queries, givenTemplate) + commands.EXPECT().HumanInitCodeSent(gomock.Any(), orgID, userID).Return(nil) + return fields{ + queries: queries, + commands: commands, + es: eventstore.NewEventstore(&eventstore.Config{ + Querier: es_repo_mock.NewRepo(t).ExpectFilterEvents().MockQuerier, + }), + userDataCrypto: codeAlg, + }, args{ + event: &user.HumanInitialCodeAddedEvent{ + BaseEvent: *eventstore.BaseEventFromRepo(&repository.Event{ + AggregateID: userID, + ResourceOwner: sql.NullString{String: orgID}, + CreationDate: time.Now().UTC(), + }), + Code: code, + Expiry: time.Hour, + }, + }, w + }, + }, { + name: "button url without event trigger url with authRequestID", + test: func(ctrl *gomock.Controller, queries *mock.MockQueries, commands *mock.MockCommands) (f fields, a args, w wantLegacy) { + givenTemplate := "{{.URL}}" + testCode := "testcode" + expectContent := fmt.Sprintf("%s://%s:%d/ui/login/user/init?authRequestID=%s&code=%s&loginname=%s&orgID=%s&passwordset=%t&userID=%s", externalProtocol, instancePrimaryDomain, externalPort, authRequestID, testCode, preferredLoginName, orgID, false, userID) + w.message = &messages.Email{ + Recipients: []string{lastEmail}, + Subject: expectMailSubject, + Content: expectContent, + } + codeAlg, code := cryptoValue(t, ctrl, testCode) + queries.EXPECT().SearchInstanceDomains(gomock.Any(), gomock.Any()).Return(&query.InstanceDomains{ + Domains: []*query.InstanceDomain{{ + Domain: instancePrimaryDomain, + IsPrimary: true, + }}, + }, nil) + expectTemplateWithNotifyUserQueries(queries, givenTemplate) + commands.EXPECT().HumanInitCodeSent(gomock.Any(), orgID, userID).Return(nil) + return fields{ + queries: queries, + commands: commands, + es: eventstore.NewEventstore(&eventstore.Config{ + Querier: es_repo_mock.NewRepo(t).ExpectFilterEvents().MockQuerier, + }), + userDataCrypto: codeAlg, + }, args{ + event: &user.HumanInitialCodeAddedEvent{ + BaseEvent: *eventstore.BaseEventFromRepo(&repository.Event{ + AggregateID: userID, + ResourceOwner: sql.NullString{String: orgID}, + CreationDate: time.Now().UTC(), + }), + Code: code, + Expiry: time.Hour, + AuthRequestID: authRequestID, + }, + }, w + }, + }} + // TODO: Why don't we have an url template on user.HumanInitialCodeAddedEvent? + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ctrl := gomock.NewController(t) + queries := mock.NewMockQueries(ctrl) + commands := mock.NewMockCommands(ctrl) + f, a, w := tt.test(ctrl, queries, commands) + stmt, err := newUserNotifierLegacy(t, ctrl, queries, f, a, w).reduceInitCodeAdded(a.event) + if w.err != nil { + w.err(t, err) + } else { + assert.NoError(t, err) + } + err = stmt.Execute(nil, "") + if w.err != nil { + w.err(t, err) + } else { + assert.NoError(t, err) + } + }) + } +} + +func Test_userNotifierLegacy_reduceEmailCodeAdded(t *testing.T) { + expectMailSubject := "Verify email" + tests := []struct { + name string + test func(*gomock.Controller, *mock.MockQueries, *mock.MockCommands) (fields, args, wantLegacy) + }{{ + name: "asset url with event trigger url", + test: func(ctrl *gomock.Controller, queries *mock.MockQueries, commands *mock.MockCommands) (f fields, a args, w wantLegacy) { + givenTemplate := "{{.LogoURL}}" + expectContent := fmt.Sprintf("%s%s/%s/%s", eventOrigin, assetsPath, policyID, logoURL) + w.message = &messages.Email{ + Recipients: []string{lastEmail}, + Subject: expectMailSubject, + Content: expectContent, + } + codeAlg, code := cryptoValue(t, ctrl, "testcode") + expectTemplateWithNotifyUserQueries(queries, givenTemplate) + commands.EXPECT().HumanEmailVerificationCodeSent(gomock.Any(), orgID, userID).Return(nil) + return fields{ + queries: queries, + commands: commands, + es: eventstore.NewEventstore(&eventstore.Config{ + Querier: es_repo_mock.NewRepo(t).ExpectFilterEvents().MockQuerier, + }), + userDataCrypto: codeAlg, + }, args{ + event: &user.HumanEmailCodeAddedEvent{ + BaseEvent: *eventstore.BaseEventFromRepo(&repository.Event{ + AggregateID: userID, + ResourceOwner: sql.NullString{String: orgID}, + CreationDate: time.Now().UTC(), + }), + Code: code, + Expiry: time.Hour, + URLTemplate: "", + CodeReturned: false, + TriggeredAtOrigin: eventOrigin, + }, + }, w + }, + }, { + name: "asset url without event trigger url", + test: func(ctrl *gomock.Controller, queries *mock.MockQueries, commands *mock.MockCommands) (f fields, a args, w wantLegacy) { + givenTemplate := "{{.LogoURL}}" + expectContent := fmt.Sprintf("%s://%s:%d%s/%s/%s", externalProtocol, instancePrimaryDomain, externalPort, assetsPath, policyID, logoURL) + w.message = &messages.Email{ + Recipients: []string{lastEmail}, + Subject: expectMailSubject, + Content: expectContent, + } + codeAlg, code := cryptoValue(t, ctrl, "testcode") + queries.EXPECT().SearchInstanceDomains(gomock.Any(), gomock.Any()).Return(&query.InstanceDomains{ + Domains: []*query.InstanceDomain{{ + Domain: instancePrimaryDomain, + IsPrimary: true, + }}, + }, nil) + expectTemplateWithNotifyUserQueries(queries, givenTemplate) + commands.EXPECT().HumanEmailVerificationCodeSent(gomock.Any(), orgID, userID).Return(nil) + return fields{ + queries: queries, + commands: commands, + es: eventstore.NewEventstore(&eventstore.Config{ + Querier: es_repo_mock.NewRepo(t).ExpectFilterEvents().MockQuerier, + }), + userDataCrypto: codeAlg, + }, args{ + event: &user.HumanEmailCodeAddedEvent{ + BaseEvent: *eventstore.BaseEventFromRepo(&repository.Event{ + AggregateID: userID, + ResourceOwner: sql.NullString{String: orgID}, + CreationDate: time.Now().UTC(), + }), + Code: code, + Expiry: time.Hour, + URLTemplate: "", + CodeReturned: false, + }, + }, w + }, + }, { + name: "button url with event trigger url", + test: func(ctrl *gomock.Controller, queries *mock.MockQueries, commands *mock.MockCommands) (f fields, a args, w wantLegacy) { + givenTemplate := "{{.URL}}" + testCode := "testcode" + expectContent := fmt.Sprintf("%s/ui/login/mail/verification?authRequestID=%s&code=%s&orgID=%s&userID=%s", eventOrigin, "", testCode, orgID, userID) + w.message = &messages.Email{ + Recipients: []string{lastEmail}, + Subject: expectMailSubject, + Content: expectContent, + } + codeAlg, code := cryptoValue(t, ctrl, testCode) + expectTemplateWithNotifyUserQueries(queries, givenTemplate) + commands.EXPECT().HumanEmailVerificationCodeSent(gomock.Any(), orgID, userID).Return(nil) + return fields{ + queries: queries, + commands: commands, + es: eventstore.NewEventstore(&eventstore.Config{ + Querier: es_repo_mock.NewRepo(t).ExpectFilterEvents().MockQuerier, + }), + userDataCrypto: codeAlg, + SMSTokenCrypto: nil, + }, args{ + event: &user.HumanEmailCodeAddedEvent{ + BaseEvent: *eventstore.BaseEventFromRepo(&repository.Event{ + AggregateID: userID, + ResourceOwner: sql.NullString{String: orgID}, + CreationDate: time.Now().UTC(), + }), + Code: code, + Expiry: time.Hour, + URLTemplate: "", + CodeReturned: false, + TriggeredAtOrigin: eventOrigin, + }, + }, w + }, + }, { + name: "button url without event trigger url", + test: func(ctrl *gomock.Controller, queries *mock.MockQueries, commands *mock.MockCommands) (f fields, a args, w wantLegacy) { + givenTemplate := "{{.URL}}" + testCode := "testcode" + expectContent := fmt.Sprintf("%s://%s:%d/ui/login/mail/verification?authRequestID=%s&code=%s&orgID=%s&userID=%s", externalProtocol, instancePrimaryDomain, externalPort, "", testCode, orgID, userID) + w.message = &messages.Email{ + Recipients: []string{lastEmail}, + Subject: expectMailSubject, + Content: expectContent, + } + codeAlg, code := cryptoValue(t, ctrl, testCode) + queries.EXPECT().SearchInstanceDomains(gomock.Any(), gomock.Any()).Return(&query.InstanceDomains{ + Domains: []*query.InstanceDomain{{ + Domain: instancePrimaryDomain, + IsPrimary: true, + }}, + }, nil) + expectTemplateWithNotifyUserQueries(queries, givenTemplate) + commands.EXPECT().HumanEmailVerificationCodeSent(gomock.Any(), orgID, userID).Return(nil) + return fields{ + queries: queries, + commands: commands, + es: eventstore.NewEventstore(&eventstore.Config{ + Querier: es_repo_mock.NewRepo(t).ExpectFilterEvents().MockQuerier, + }), + userDataCrypto: codeAlg, + }, args{ + event: &user.HumanEmailCodeAddedEvent{ + BaseEvent: *eventstore.BaseEventFromRepo(&repository.Event{ + AggregateID: userID, + ResourceOwner: sql.NullString{String: orgID}, + CreationDate: time.Now().UTC(), + }), + Code: code, + Expiry: time.Hour, + URLTemplate: "", + CodeReturned: false, + }, + }, w + }, + }, { + name: "button url without event trigger url with authRequestID", + test: func(ctrl *gomock.Controller, queries *mock.MockQueries, commands *mock.MockCommands) (f fields, a args, w wantLegacy) { + givenTemplate := "{{.URL}}" + testCode := "testcode" + expectContent := fmt.Sprintf("%s://%s:%d/ui/login/mail/verification?authRequestID=%s&code=%s&orgID=%s&userID=%s", externalProtocol, instancePrimaryDomain, externalPort, authRequestID, testCode, orgID, userID) + w.message = &messages.Email{ + Recipients: []string{lastEmail}, + Subject: expectMailSubject, + Content: expectContent, + } + codeAlg, code := cryptoValue(t, ctrl, testCode) + queries.EXPECT().SearchInstanceDomains(gomock.Any(), gomock.Any()).Return(&query.InstanceDomains{ + Domains: []*query.InstanceDomain{{ + Domain: instancePrimaryDomain, + IsPrimary: true, + }}, + }, nil) + expectTemplateWithNotifyUserQueries(queries, givenTemplate) + commands.EXPECT().HumanEmailVerificationCodeSent(gomock.Any(), orgID, userID).Return(nil) + return fields{ + queries: queries, + commands: commands, + es: eventstore.NewEventstore(&eventstore.Config{ + Querier: es_repo_mock.NewRepo(t).ExpectFilterEvents().MockQuerier, + }), + userDataCrypto: codeAlg, + }, args{ + event: &user.HumanEmailCodeAddedEvent{ + BaseEvent: *eventstore.BaseEventFromRepo(&repository.Event{ + AggregateID: userID, + ResourceOwner: sql.NullString{String: orgID}, + CreationDate: time.Now().UTC(), + }), + Code: code, + Expiry: time.Hour, + URLTemplate: "", + CodeReturned: false, + AuthRequestID: authRequestID, + }, + }, w + }, + }, { + name: "button url with url template and event trigger url", + test: func(ctrl *gomock.Controller, queries *mock.MockQueries, commands *mock.MockCommands) (f fields, a args, w wantLegacy) { + givenTemplate := "{{.URL}}" + urlTemplate := "https://my.custom.url/org/{{.OrgID}}/user/{{.UserID}}/verify/{{.Code}}" + testCode := "testcode" + expectContent := fmt.Sprintf("https://my.custom.url/org/%s/user/%s/verify/%s", orgID, userID, testCode) + w.message = &messages.Email{ + Recipients: []string{lastEmail}, + Subject: expectMailSubject, + Content: expectContent, + } + codeAlg, code := cryptoValue(t, ctrl, testCode) + expectTemplateWithNotifyUserQueries(queries, givenTemplate) + commands.EXPECT().HumanEmailVerificationCodeSent(gomock.Any(), orgID, userID).Return(nil) + return fields{ + queries: queries, + commands: commands, + es: eventstore.NewEventstore(&eventstore.Config{ + Querier: es_repo_mock.NewRepo(t).ExpectFilterEvents().MockQuerier, + }), + userDataCrypto: codeAlg, + SMSTokenCrypto: nil, + }, args{ + event: &user.HumanEmailCodeAddedEvent{ + BaseEvent: *eventstore.BaseEventFromRepo(&repository.Event{ + AggregateID: userID, + ResourceOwner: sql.NullString{String: orgID}, + CreationDate: time.Now().UTC(), + }), + Code: code, + Expiry: time.Hour, + URLTemplate: urlTemplate, + CodeReturned: false, + TriggeredAtOrigin: eventOrigin, + }, + }, w + }, + }} + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ctrl := gomock.NewController(t) + queries := mock.NewMockQueries(ctrl) + commands := mock.NewMockCommands(ctrl) + f, a, w := tt.test(ctrl, queries, commands) + stmt, err := newUserNotifierLegacy(t, ctrl, queries, f, a, w).reduceEmailCodeAdded(a.event) + if w.err != nil { + w.err(t, err) + } else { + assert.NoError(t, err) + } + err = stmt.Execute(nil, "") + if w.err != nil { + w.err(t, err) + } else { + assert.NoError(t, err) + } + }) + } +} + +func Test_userNotifierLegacy_reducePasswordCodeAdded(t *testing.T) { + expectMailSubject := "Reset password" + tests := []struct { + name string + test func(*gomock.Controller, *mock.MockQueries, *mock.MockCommands) (fields, args, wantLegacy) + }{{ + name: "asset url with event trigger url", + test: func(ctrl *gomock.Controller, queries *mock.MockQueries, commands *mock.MockCommands) (f fields, a args, w wantLegacy) { + givenTemplate := "{{.LogoURL}}" + expectContent := fmt.Sprintf("%s%s/%s/%s", eventOrigin, assetsPath, policyID, logoURL) + w.message = &messages.Email{ + Recipients: []string{lastEmail}, + Subject: expectMailSubject, + Content: expectContent, + } + codeAlg, code := cryptoValue(t, ctrl, "testcode") + expectTemplateWithNotifyUserQueries(queries, givenTemplate) + commands.EXPECT().PasswordCodeSent(gomock.Any(), orgID, userID, &senders.CodeGeneratorInfo{}).Return(nil) + return fields{ + queries: queries, + commands: commands, + es: eventstore.NewEventstore(&eventstore.Config{ + Querier: es_repo_mock.NewRepo(t).ExpectFilterEvents().MockQuerier, + }), + userDataCrypto: codeAlg, + }, args{ + event: &user.HumanPasswordCodeAddedEvent{ + BaseEvent: *eventstore.BaseEventFromRepo(&repository.Event{ + AggregateID: userID, + ResourceOwner: sql.NullString{String: orgID}, + CreationDate: time.Now().UTC(), + }), + Code: code, + Expiry: time.Hour, + URLTemplate: "", + CodeReturned: false, + TriggeredAtOrigin: eventOrigin, + }, + }, w + }, + }, { + name: "asset url without event trigger url", + test: func(ctrl *gomock.Controller, queries *mock.MockQueries, commands *mock.MockCommands) (f fields, a args, w wantLegacy) { + givenTemplate := "{{.LogoURL}}" + expectContent := fmt.Sprintf("%s://%s:%d%s/%s/%s", externalProtocol, instancePrimaryDomain, externalPort, assetsPath, policyID, logoURL) + w.message = &messages.Email{ + Recipients: []string{lastEmail}, + Subject: expectMailSubject, + Content: expectContent, + } + codeAlg, code := cryptoValue(t, ctrl, "testcode") + queries.EXPECT().SearchInstanceDomains(gomock.Any(), gomock.Any()).Return(&query.InstanceDomains{ + Domains: []*query.InstanceDomain{{ + Domain: instancePrimaryDomain, + IsPrimary: true, + }}, + }, nil) + expectTemplateWithNotifyUserQueries(queries, givenTemplate) + commands.EXPECT().PasswordCodeSent(gomock.Any(), orgID, userID, &senders.CodeGeneratorInfo{}).Return(nil) + return fields{ + queries: queries, + commands: commands, + es: eventstore.NewEventstore(&eventstore.Config{ + Querier: es_repo_mock.NewRepo(t).ExpectFilterEvents().MockQuerier, + }), + userDataCrypto: codeAlg, + }, args{ + event: &user.HumanPasswordCodeAddedEvent{ + BaseEvent: *eventstore.BaseEventFromRepo(&repository.Event{ + AggregateID: userID, + ResourceOwner: sql.NullString{String: orgID}, + CreationDate: time.Now().UTC(), + }), + Code: code, + Expiry: time.Hour, + URLTemplate: "", + CodeReturned: false, + }, + }, w + }, + }, { + name: "button url with event trigger url", + test: func(ctrl *gomock.Controller, queries *mock.MockQueries, commands *mock.MockCommands) (f fields, a args, w wantLegacy) { + givenTemplate := "{{.URL}}" + testCode := "testcode" + expectContent := fmt.Sprintf("%s/ui/login/password/init?authRequestID=%s&code=%s&orgID=%s&userID=%s", eventOrigin, "", testCode, orgID, userID) + w.message = &messages.Email{ + Recipients: []string{lastEmail}, + Subject: expectMailSubject, + Content: expectContent, + } + codeAlg, code := cryptoValue(t, ctrl, testCode) + expectTemplateWithNotifyUserQueries(queries, givenTemplate) + commands.EXPECT().PasswordCodeSent(gomock.Any(), orgID, userID, &senders.CodeGeneratorInfo{}).Return(nil) + return fields{ + queries: queries, + commands: commands, + es: eventstore.NewEventstore(&eventstore.Config{ + Querier: es_repo_mock.NewRepo(t).ExpectFilterEvents().MockQuerier, + }), + userDataCrypto: codeAlg, + SMSTokenCrypto: nil, + }, args{ + event: &user.HumanPasswordCodeAddedEvent{ + BaseEvent: *eventstore.BaseEventFromRepo(&repository.Event{ + AggregateID: userID, + ResourceOwner: sql.NullString{String: orgID}, + CreationDate: time.Now().UTC(), + }), + Code: code, + Expiry: time.Hour, + URLTemplate: "", + CodeReturned: false, + TriggeredAtOrigin: eventOrigin, + }, + }, w + }, + }, { + name: "button url without event trigger url", + test: func(ctrl *gomock.Controller, queries *mock.MockQueries, commands *mock.MockCommands) (f fields, a args, w wantLegacy) { + givenTemplate := "{{.URL}}" + testCode := "testcode" + expectContent := fmt.Sprintf("%s://%s:%d/ui/login/password/init?authRequestID=%s&code=%s&orgID=%s&userID=%s", externalProtocol, instancePrimaryDomain, externalPort, "", testCode, orgID, userID) + w.message = &messages.Email{ + Recipients: []string{lastEmail}, + Subject: expectMailSubject, + Content: expectContent, + } + codeAlg, code := cryptoValue(t, ctrl, testCode) + queries.EXPECT().SearchInstanceDomains(gomock.Any(), gomock.Any()).Return(&query.InstanceDomains{ + Domains: []*query.InstanceDomain{{ + Domain: instancePrimaryDomain, + IsPrimary: true, + }}, + }, nil) + expectTemplateWithNotifyUserQueries(queries, givenTemplate) + commands.EXPECT().PasswordCodeSent(gomock.Any(), orgID, userID, &senders.CodeGeneratorInfo{}).Return(nil) + return fields{ + queries: queries, + commands: commands, + es: eventstore.NewEventstore(&eventstore.Config{ + Querier: es_repo_mock.NewRepo(t).ExpectFilterEvents().MockQuerier, + }), + userDataCrypto: codeAlg, + }, args{ + event: &user.HumanPasswordCodeAddedEvent{ + BaseEvent: *eventstore.BaseEventFromRepo(&repository.Event{ + AggregateID: userID, + ResourceOwner: sql.NullString{String: orgID}, + CreationDate: time.Now().UTC(), + }), + Code: code, + Expiry: time.Hour, + URLTemplate: "", + CodeReturned: false, + }, + }, w + }, + }, { + name: "button url without event trigger url with authRequestID", + test: func(ctrl *gomock.Controller, queries *mock.MockQueries, commands *mock.MockCommands) (f fields, a args, w wantLegacy) { + givenTemplate := "{{.URL}}" + testCode := "testcode" + expectContent := fmt.Sprintf("%s://%s:%d/ui/login/password/init?authRequestID=%s&code=%s&orgID=%s&userID=%s", externalProtocol, instancePrimaryDomain, externalPort, authRequestID, testCode, orgID, userID) + w.message = &messages.Email{ + Recipients: []string{lastEmail}, + Subject: expectMailSubject, + Content: expectContent, + } + codeAlg, code := cryptoValue(t, ctrl, testCode) + queries.EXPECT().SearchInstanceDomains(gomock.Any(), gomock.Any()).Return(&query.InstanceDomains{ + Domains: []*query.InstanceDomain{{ + Domain: instancePrimaryDomain, + IsPrimary: true, + }}, + }, nil) + expectTemplateWithNotifyUserQueries(queries, givenTemplate) + commands.EXPECT().PasswordCodeSent(gomock.Any(), orgID, userID, &senders.CodeGeneratorInfo{}).Return(nil) + return fields{ + queries: queries, + commands: commands, + es: eventstore.NewEventstore(&eventstore.Config{ + Querier: es_repo_mock.NewRepo(t).ExpectFilterEvents().MockQuerier, + }), + userDataCrypto: codeAlg, + }, args{ + event: &user.HumanPasswordCodeAddedEvent{ + BaseEvent: *eventstore.BaseEventFromRepo(&repository.Event{ + AggregateID: userID, + ResourceOwner: sql.NullString{String: orgID}, + CreationDate: time.Now().UTC(), + }), + Code: code, + Expiry: time.Hour, + URLTemplate: "", + CodeReturned: false, + AuthRequestID: authRequestID, + }, + }, w + }, + }, { + name: "button url with url template and event trigger url", + test: func(ctrl *gomock.Controller, queries *mock.MockQueries, commands *mock.MockCommands) (f fields, a args, w wantLegacy) { + givenTemplate := "{{.URL}}" + urlTemplate := "https://my.custom.url/org/{{.OrgID}}/user/{{.UserID}}/verify/{{.Code}}" + testCode := "testcode" + expectContent := fmt.Sprintf("https://my.custom.url/org/%s/user/%s/verify/%s", orgID, userID, testCode) + w.message = &messages.Email{ + Recipients: []string{lastEmail}, + Subject: expectMailSubject, + Content: expectContent, + } + codeAlg, code := cryptoValue(t, ctrl, testCode) + expectTemplateWithNotifyUserQueries(queries, givenTemplate) + commands.EXPECT().PasswordCodeSent(gomock.Any(), orgID, userID, &senders.CodeGeneratorInfo{}).Return(nil) + return fields{ + queries: queries, + commands: commands, + es: eventstore.NewEventstore(&eventstore.Config{ + Querier: es_repo_mock.NewRepo(t).ExpectFilterEvents().MockQuerier, + }), + userDataCrypto: codeAlg, + SMSTokenCrypto: nil, + }, args{ + event: &user.HumanPasswordCodeAddedEvent{ + BaseEvent: *eventstore.BaseEventFromRepo(&repository.Event{ + AggregateID: userID, + ResourceOwner: sql.NullString{String: orgID}, + CreationDate: time.Now().UTC(), + }), + Code: code, + Expiry: time.Hour, + URLTemplate: urlTemplate, + CodeReturned: false, + TriggeredAtOrigin: eventOrigin, + }, + }, w + }, + }, { + name: "external code", + test: func(ctrl *gomock.Controller, queries *mock.MockQueries, commands *mock.MockCommands) (f fields, a args, w wantLegacy) { + givenTemplate := "{{.URL}}" + expectContent := "We received a password reset request. Please use the button below to reset your password. (Code ) If you didn't ask for this mail, please ignore it." + w.messageSMS = &messages.SMS{ + SenderPhoneNumber: "senderNumber", + RecipientPhoneNumber: lastPhone, + Content: expectContent, + } + expectTemplateWithNotifyUserQueries(queries, givenTemplate) + commands.EXPECT().PasswordCodeSent(gomock.Any(), orgID, userID, &senders.CodeGeneratorInfo{ID: smsProviderID, VerificationID: verificationID}).Return(nil) + return fields{ + queries: queries, + commands: commands, + es: eventstore.NewEventstore(&eventstore.Config{ + Querier: es_repo_mock.NewRepo(t).ExpectFilterEvents().MockQuerier, + }), + SMSTokenCrypto: nil, + }, args{ + event: &user.HumanPasswordCodeAddedEvent{ + BaseEvent: *eventstore.BaseEventFromRepo(&repository.Event{ + AggregateID: userID, + ResourceOwner: sql.NullString{String: orgID}, + CreationDate: time.Now().UTC(), + }), + Code: nil, + Expiry: 0, + URLTemplate: "", + CodeReturned: false, + NotificationType: domain.NotificationTypeSms, + GeneratorID: smsProviderID, + TriggeredAtOrigin: eventOrigin, + }, + }, w + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ctrl := gomock.NewController(t) + queries := mock.NewMockQueries(ctrl) + commands := mock.NewMockCommands(ctrl) + f, a, w := tt.test(ctrl, queries, commands) + stmt, err := newUserNotifierLegacy(t, ctrl, queries, f, a, w).reducePasswordCodeAdded(a.event) + if w.err != nil { + w.err(t, err) + } else { + assert.NoError(t, err) + } + err = stmt.Execute(nil, "") + if w.err != nil { + w.err(t, err) + } else { + assert.NoError(t, err) + } + }) + } +} + +func Test_userNotifierLegacy_reduceDomainClaimed(t *testing.T) { + expectMailSubject := "Domain has been claimed" + tests := []struct { + name string + test func(*gomock.Controller, *mock.MockQueries, *mock.MockCommands) (fields, args, wantLegacy) + }{{ + name: "asset url with event trigger url", + test: func(ctrl *gomock.Controller, queries *mock.MockQueries, commands *mock.MockCommands) (f fields, a args, w wantLegacy) { + givenTemplate := "{{.LogoURL}}" + expectContent := fmt.Sprintf("%s%s/%s/%s", eventOrigin, assetsPath, policyID, logoURL) + w.message = &messages.Email{ + Recipients: []string{lastEmail}, + Subject: expectMailSubject, + Content: expectContent, + } + expectTemplateWithNotifyUserQueries(queries, givenTemplate) + commands.EXPECT().UserDomainClaimedSent(gomock.Any(), orgID, userID).Return(nil) + return fields{ + queries: queries, + commands: commands, + es: eventstore.NewEventstore(&eventstore.Config{ + Querier: es_repo_mock.NewRepo(t).ExpectFilterEvents().MockQuerier, + }), + }, args{ + event: &user.DomainClaimedEvent{ + BaseEvent: *eventstore.BaseEventFromRepo(&repository.Event{ + AggregateID: userID, + ResourceOwner: sql.NullString{String: orgID}, + CreationDate: time.Now().UTC(), + }), + TriggeredAtOrigin: eventOrigin, + }, + }, w + }, + }, { + name: "asset url without event trigger url", + test: func(ctrl *gomock.Controller, queries *mock.MockQueries, commands *mock.MockCommands) (f fields, a args, w wantLegacy) { + givenTemplate := "{{.LogoURL}}" + expectContent := fmt.Sprintf("%s://%s:%d%s/%s/%s", externalProtocol, instancePrimaryDomain, externalPort, assetsPath, policyID, logoURL) + w.message = &messages.Email{ + Recipients: []string{lastEmail}, + Subject: expectMailSubject, + Content: expectContent, + } + queries.EXPECT().SearchInstanceDomains(gomock.Any(), gomock.Any()).Return(&query.InstanceDomains{ + Domains: []*query.InstanceDomain{{ + Domain: instancePrimaryDomain, + IsPrimary: true, + }}, + }, nil) + expectTemplateWithNotifyUserQueries(queries, givenTemplate) + commands.EXPECT().UserDomainClaimedSent(gomock.Any(), orgID, userID).Return(nil) + return fields{ + queries: queries, + commands: commands, + es: eventstore.NewEventstore(&eventstore.Config{ + Querier: es_repo_mock.NewRepo(t).ExpectFilterEvents().MockQuerier, + }), + }, args{ + event: &user.DomainClaimedEvent{ + BaseEvent: *eventstore.BaseEventFromRepo(&repository.Event{ + AggregateID: userID, + ResourceOwner: sql.NullString{String: orgID}, + CreationDate: time.Now().UTC(), + }), + }, + }, w + }, + }} + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ctrl := gomock.NewController(t) + queries := mock.NewMockQueries(ctrl) + commands := mock.NewMockCommands(ctrl) + f, a, w := tt.test(ctrl, queries, commands) + stmt, err := newUserNotifierLegacy(t, ctrl, queries, f, a, w).reduceDomainClaimed(a.event) + if w.err != nil { + w.err(t, err) + } else { + assert.NoError(t, err) + } + err = stmt.Execute(nil, "") + if w.err != nil { + w.err(t, err) + } else { + assert.NoError(t, err) + } + }) + } +} + +func Test_userNotifierLegacy_reducePasswordlessCodeRequested(t *testing.T) { + expectMailSubject := "Add Passwordless Login" + tests := []struct { + name string + test func(*gomock.Controller, *mock.MockQueries, *mock.MockCommands) (fields, args, wantLegacy) + }{{ + name: "asset url with event trigger url", + test: func(ctrl *gomock.Controller, queries *mock.MockQueries, commands *mock.MockCommands) (f fields, a args, w wantLegacy) { + givenTemplate := "{{.LogoURL}}" + expectContent := fmt.Sprintf("%s%s/%s/%s", eventOrigin, assetsPath, policyID, logoURL) + w.message = &messages.Email{ + Recipients: []string{lastEmail}, + Subject: expectMailSubject, + Content: expectContent, + } + codeAlg, code := cryptoValue(t, ctrl, "testcode") + expectTemplateWithNotifyUserQueries(queries, givenTemplate) + commands.EXPECT().HumanPasswordlessInitCodeSent(gomock.Any(), userID, orgID, codeID).Return(nil) + return fields{ + queries: queries, + commands: commands, + es: eventstore.NewEventstore(&eventstore.Config{ + Querier: es_repo_mock.NewRepo(t).ExpectFilterEvents().MockQuerier, + }), + userDataCrypto: codeAlg, + }, args{ + event: &user.HumanPasswordlessInitCodeRequestedEvent{ + BaseEvent: *eventstore.BaseEventFromRepo(&repository.Event{ + AggregateID: userID, + ResourceOwner: sql.NullString{String: orgID}, + CreationDate: time.Now().UTC(), + }), + ID: codeID, + Code: code, + Expiry: time.Hour, + URLTemplate: "", + CodeReturned: false, + TriggeredAtOrigin: eventOrigin, + }, + }, w + }, + }, { + name: "asset url without event trigger url", + test: func(ctrl *gomock.Controller, queries *mock.MockQueries, commands *mock.MockCommands) (f fields, a args, w wantLegacy) { + givenTemplate := "{{.LogoURL}}" + expectContent := fmt.Sprintf("%s://%s:%d%s/%s/%s", externalProtocol, instancePrimaryDomain, externalPort, assetsPath, policyID, logoURL) + w.message = &messages.Email{ + Recipients: []string{lastEmail}, + Subject: expectMailSubject, + Content: expectContent, + } + codeAlg, code := cryptoValue(t, ctrl, "testcode") + queries.EXPECT().SearchInstanceDomains(gomock.Any(), gomock.Any()).Return(&query.InstanceDomains{ + Domains: []*query.InstanceDomain{{ + Domain: instancePrimaryDomain, + IsPrimary: true, + }}, + }, nil) + expectTemplateWithNotifyUserQueries(queries, givenTemplate) + commands.EXPECT().HumanPasswordlessInitCodeSent(gomock.Any(), userID, orgID, codeID).Return(nil) + return fields{ + queries: queries, + commands: commands, + es: eventstore.NewEventstore(&eventstore.Config{ + Querier: es_repo_mock.NewRepo(t).ExpectFilterEvents().MockQuerier, + }), + userDataCrypto: codeAlg, + }, args{ + event: &user.HumanPasswordlessInitCodeRequestedEvent{ + BaseEvent: *eventstore.BaseEventFromRepo(&repository.Event{ + AggregateID: userID, + ResourceOwner: sql.NullString{String: orgID}, + CreationDate: time.Now().UTC(), + }), + ID: codeID, + Code: code, + Expiry: time.Hour, + URLTemplate: "", + CodeReturned: false, + }, + }, w + }, + }, { + name: "button url with event trigger url", + test: func(ctrl *gomock.Controller, queries *mock.MockQueries, commands *mock.MockCommands) (f fields, a args, w wantLegacy) { + givenTemplate := "{{.URL}}" + testCode := "testcode" + codeAlg, code := cryptoValue(t, ctrl, testCode) + expectContent := fmt.Sprintf("%s/ui/login/login/passwordless/init?userID=%s&orgID=%s&codeID=%s&code=%s", eventOrigin, userID, orgID, codeID, testCode) + w.message = &messages.Email{ + Recipients: []string{lastEmail}, + Subject: expectMailSubject, + Content: expectContent, + } + expectTemplateWithNotifyUserQueries(queries, givenTemplate) + commands.EXPECT().HumanPasswordlessInitCodeSent(gomock.Any(), userID, orgID, codeID).Return(nil) + return fields{ + queries: queries, + commands: commands, + es: eventstore.NewEventstore(&eventstore.Config{ + Querier: es_repo_mock.NewRepo(t).ExpectFilterEvents().MockQuerier, + }), + userDataCrypto: codeAlg, + SMSTokenCrypto: nil, + }, args{ + event: &user.HumanPasswordlessInitCodeRequestedEvent{ + BaseEvent: *eventstore.BaseEventFromRepo(&repository.Event{ + AggregateID: userID, + ResourceOwner: sql.NullString{String: orgID}, + CreationDate: time.Now().UTC(), + }), + ID: codeID, + Code: code, + Expiry: time.Hour, + URLTemplate: "", + CodeReturned: false, + TriggeredAtOrigin: eventOrigin, + }, + }, w + }, + }, { + name: "button url without event trigger url", + test: func(ctrl *gomock.Controller, queries *mock.MockQueries, commands *mock.MockCommands) (f fields, a args, w wantLegacy) { + givenTemplate := "{{.URL}}" + testCode := "testcode" + codeAlg, code := cryptoValue(t, ctrl, testCode) + expectContent := fmt.Sprintf("%s://%s:%d/ui/login/login/passwordless/init?userID=%s&orgID=%s&codeID=%s&code=%s", externalProtocol, instancePrimaryDomain, externalPort, userID, orgID, codeID, testCode) + w.message = &messages.Email{ + Recipients: []string{lastEmail}, + Subject: expectMailSubject, + Content: expectContent, + } + queries.EXPECT().SearchInstanceDomains(gomock.Any(), gomock.Any()).Return(&query.InstanceDomains{ + Domains: []*query.InstanceDomain{{ + Domain: instancePrimaryDomain, + IsPrimary: true, + }}, + }, nil) + expectTemplateWithNotifyUserQueries(queries, givenTemplate) + commands.EXPECT().HumanPasswordlessInitCodeSent(gomock.Any(), userID, orgID, codeID).Return(nil) + return fields{ + queries: queries, + commands: commands, + es: eventstore.NewEventstore(&eventstore.Config{ + Querier: es_repo_mock.NewRepo(t).ExpectFilterEvents().MockQuerier, + }), + userDataCrypto: codeAlg, + }, args{ + event: &user.HumanPasswordlessInitCodeRequestedEvent{ + BaseEvent: *eventstore.BaseEventFromRepo(&repository.Event{ + AggregateID: userID, + ResourceOwner: sql.NullString{String: orgID}, + CreationDate: time.Now().UTC(), + }), + ID: codeID, + Code: code, + Expiry: time.Hour, + URLTemplate: "", + CodeReturned: false, + }, + }, w + }, + }, { + name: "button url with url template and event trigger url", + test: func(ctrl *gomock.Controller, queries *mock.MockQueries, commands *mock.MockCommands) (f fields, a args, w wantLegacy) { + givenTemplate := "{{.URL}}" + urlTemplate := "https://my.custom.url/org/{{.OrgID}}/user/{{.UserID}}/verify/{{.Code}}" + testCode := "testcode" + expectContent := fmt.Sprintf("https://my.custom.url/org/%s/user/%s/verify/%s", orgID, userID, testCode) + w.message = &messages.Email{ + Recipients: []string{lastEmail}, + Subject: expectMailSubject, + Content: expectContent, + } + codeAlg, code := cryptoValue(t, ctrl, testCode) + expectTemplateWithNotifyUserQueries(queries, givenTemplate) + commands.EXPECT().HumanPasswordlessInitCodeSent(gomock.Any(), userID, orgID, codeID).Return(nil) + return fields{ + queries: queries, + commands: commands, + es: eventstore.NewEventstore(&eventstore.Config{ + Querier: es_repo_mock.NewRepo(t).ExpectFilterEvents().MockQuerier, + }), + userDataCrypto: codeAlg, + SMSTokenCrypto: nil, + }, args{ + event: &user.HumanPasswordlessInitCodeRequestedEvent{ + BaseEvent: *eventstore.BaseEventFromRepo(&repository.Event{ + AggregateID: userID, + ResourceOwner: sql.NullString{String: orgID}, + CreationDate: time.Now().UTC(), + }), + ID: codeID, + Code: code, + Expiry: time.Hour, + URLTemplate: urlTemplate, + CodeReturned: false, + TriggeredAtOrigin: eventOrigin, + }, + }, w + }, + }} + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ctrl := gomock.NewController(t) + queries := mock.NewMockQueries(ctrl) + commands := mock.NewMockCommands(ctrl) + f, a, w := tt.test(ctrl, queries, commands) + stmt, err := newUserNotifierLegacy(t, ctrl, queries, f, a, w).reducePasswordlessCodeRequested(a.event) + if w.err != nil { + w.err(t, err) + } else { + assert.NoError(t, err) + } + err = stmt.Execute(nil, "") + if w.err != nil { + w.err(t, err) + } else { + assert.NoError(t, err) + } + }) + } +} + +func Test_userNotifierLegacy_reducePasswordChanged(t *testing.T) { + expectMailSubject := "Password of user has changed" + tests := []struct { + name string + test func(*gomock.Controller, *mock.MockQueries, *mock.MockCommands) (fields, args, wantLegacy) + }{{ + name: "asset url with event trigger url", + test: func(ctrl *gomock.Controller, queries *mock.MockQueries, commands *mock.MockCommands) (f fields, a args, w wantLegacy) { + givenTemplate := "{{.LogoURL}}" + expectContent := fmt.Sprintf("%s%s/%s/%s", eventOrigin, assetsPath, policyID, logoURL) + w.message = &messages.Email{ + Recipients: []string{lastEmail}, + Subject: expectMailSubject, + Content: expectContent, + } + queries.EXPECT().NotificationPolicyByOrg(gomock.Any(), gomock.Any(), orgID, gomock.Any()).Return(&query.NotificationPolicy{ + PasswordChange: true, + }, nil) + expectTemplateWithNotifyUserQueries(queries, givenTemplate) + commands.EXPECT().PasswordChangeSent(gomock.Any(), orgID, userID).Return(nil) + return fields{ + queries: queries, + commands: commands, + es: eventstore.NewEventstore(&eventstore.Config{ + Querier: es_repo_mock.NewRepo(t).ExpectFilterEvents().MockQuerier, + }), + }, args{ + event: &user.HumanPasswordChangedEvent{ + BaseEvent: *eventstore.BaseEventFromRepo(&repository.Event{ + AggregateID: userID, + ResourceOwner: sql.NullString{String: orgID}, + CreationDate: time.Now().UTC(), + }), + TriggeredAtOrigin: eventOrigin, + }, + }, w + }, + }, { + name: "asset url without event trigger url", + test: func(ctrl *gomock.Controller, queries *mock.MockQueries, commands *mock.MockCommands) (f fields, a args, w wantLegacy) { + givenTemplate := "{{.LogoURL}}" + expectContent := fmt.Sprintf("%s://%s:%d%s/%s/%s", externalProtocol, instancePrimaryDomain, externalPort, assetsPath, policyID, logoURL) + w.message = &messages.Email{ + Recipients: []string{lastEmail}, + Subject: expectMailSubject, + Content: expectContent, + } + queries.EXPECT().NotificationPolicyByOrg(gomock.Any(), gomock.Any(), orgID, gomock.Any()).Return(&query.NotificationPolicy{ + PasswordChange: true, + }, nil) + queries.EXPECT().SearchInstanceDomains(gomock.Any(), gomock.Any()).Return(&query.InstanceDomains{ + Domains: []*query.InstanceDomain{{ + Domain: instancePrimaryDomain, + IsPrimary: true, + }}, + }, nil) + expectTemplateWithNotifyUserQueries(queries, givenTemplate) + commands.EXPECT().PasswordChangeSent(gomock.Any(), orgID, userID).Return(nil) + return fields{ + queries: queries, + commands: commands, + es: eventstore.NewEventstore(&eventstore.Config{ + Querier: es_repo_mock.NewRepo(t).ExpectFilterEvents().MockQuerier, + }), + }, args{ + event: &user.HumanPasswordChangedEvent{ + BaseEvent: *eventstore.BaseEventFromRepo(&repository.Event{ + AggregateID: userID, + ResourceOwner: sql.NullString{String: orgID}, + CreationDate: time.Now().UTC(), + }), + }, + }, w + }, + }} + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ctrl := gomock.NewController(t) + queries := mock.NewMockQueries(ctrl) + commands := mock.NewMockCommands(ctrl) + f, a, w := tt.test(ctrl, queries, commands) + stmt, err := newUserNotifierLegacy(t, ctrl, queries, f, a, w).reducePasswordChanged(a.event) + if w.err != nil { + w.err(t, err) + } else { + assert.NoError(t, err) + } + err = stmt.Execute(nil, "") + if w.err != nil { + w.err(t, err) + } else { + assert.NoError(t, err) + } + }) + } +} + +func Test_userNotifierLegacy_reduceOTPEmailChallenged(t *testing.T) { + expectMailSubject := "Verify One-Time Password" + tests := []struct { + name string + test func(*gomock.Controller, *mock.MockQueries, *mock.MockCommands) (fields, args, wantLegacy) + }{{ + name: "asset url with event trigger url", + test: func(ctrl *gomock.Controller, queries *mock.MockQueries, commands *mock.MockCommands) (f fields, a args, w wantLegacy) { + givenTemplate := "{{.LogoURL}}" + expectContent := fmt.Sprintf("%s%s/%s/%s", eventOrigin, assetsPath, policyID, logoURL) + w.message = &messages.Email{ + Recipients: []string{verifiedEmail}, + Subject: expectMailSubject, + Content: expectContent, + } + codeAlg, code := cryptoValue(t, ctrl, "testcode") + expectTemplateWithNotifyUserQueries(queries, givenTemplate) + queries.EXPECT().SessionByID(gomock.Any(), gomock.Any(), userID, gomock.Any()).Return(&query.Session{}, nil) + commands.EXPECT().OTPEmailSent(gomock.Any(), userID, orgID).Return(nil) + return fields{ + queries: queries, + commands: commands, + es: eventstore.NewEventstore(&eventstore.Config{ + Querier: es_repo_mock.NewRepo(t).ExpectFilterEvents().MockQuerier, + }), + userDataCrypto: codeAlg, + }, args{ + event: &session.OTPEmailChallengedEvent{ + BaseEvent: *eventstore.BaseEventFromRepo(&repository.Event{ + AggregateID: userID, + ResourceOwner: sql.NullString{String: orgID}, + CreationDate: time.Now().UTC(), + }), + Code: code, + Expiry: time.Hour, + URLTmpl: "", + ReturnCode: false, + TriggeredAtOrigin: eventOrigin, + }, + }, w + }, + }, { + name: "asset url without event trigger url", + test: func(ctrl *gomock.Controller, queries *mock.MockQueries, commands *mock.MockCommands) (f fields, a args, w wantLegacy) { + givenTemplate := "{{.LogoURL}}" + expectContent := fmt.Sprintf("%s://%s:%d%s/%s/%s", externalProtocol, instancePrimaryDomain, externalPort, assetsPath, policyID, logoURL) + w.message = &messages.Email{ + Recipients: []string{verifiedEmail}, + Subject: expectMailSubject, + Content: expectContent, + } + codeAlg, code := cryptoValue(t, ctrl, "testcode") + expectTemplateWithNotifyUserQueries(queries, givenTemplate) + queries.EXPECT().SessionByID(gomock.Any(), gomock.Any(), userID, gomock.Any()).Return(&query.Session{}, nil) + queries.EXPECT().SearchInstanceDomains(gomock.Any(), gomock.Any()).Return(&query.InstanceDomains{ + Domains: []*query.InstanceDomain{{ + Domain: instancePrimaryDomain, + IsPrimary: true, + }}, + }, nil) + commands.EXPECT().OTPEmailSent(gomock.Any(), userID, orgID).Return(nil) + return fields{ + queries: queries, + commands: commands, + es: eventstore.NewEventstore(&eventstore.Config{ + Querier: es_repo_mock.NewRepo(t).ExpectFilterEvents().MockQuerier, + }), + userDataCrypto: codeAlg, + }, args{ + event: &session.OTPEmailChallengedEvent{ + BaseEvent: *eventstore.BaseEventFromRepo(&repository.Event{ + AggregateID: userID, + ResourceOwner: sql.NullString{String: orgID}, + CreationDate: time.Now().UTC(), + }), + Code: code, + Expiry: time.Hour, + URLTmpl: "", + ReturnCode: false, + }, + }, w + }, + }, { + name: "button url with event trigger url", + test: func(ctrl *gomock.Controller, queries *mock.MockQueries, commands *mock.MockCommands) (f fields, a args, w wantLegacy) { + givenTemplate := "{{.URL}}" + testCode := "testcode" + expectContent := fmt.Sprintf("%s/otp/verify?loginName=%s&code=%s", eventOrigin, preferredLoginName, testCode) + w.message = &messages.Email{ + Recipients: []string{verifiedEmail}, + Subject: expectMailSubject, + Content: expectContent, + } + codeAlg, code := cryptoValue(t, ctrl, testCode) + expectTemplateWithNotifyUserQueries(queries, givenTemplate) + queries.EXPECT().SessionByID(gomock.Any(), gomock.Any(), userID, gomock.Any()).Return(&query.Session{}, nil) + commands.EXPECT().OTPEmailSent(gomock.Any(), userID, orgID).Return(nil) + return fields{ + queries: queries, + commands: commands, + es: eventstore.NewEventstore(&eventstore.Config{ + Querier: es_repo_mock.NewRepo(t).ExpectFilterEvents().MockQuerier, + }), + userDataCrypto: codeAlg, + SMSTokenCrypto: nil, + }, args{ + event: &session.OTPEmailChallengedEvent{ + BaseEvent: *eventstore.BaseEventFromRepo(&repository.Event{ + AggregateID: userID, + ResourceOwner: sql.NullString{String: orgID}, + CreationDate: time.Now().UTC(), + }), + Code: code, + Expiry: time.Hour, + URLTmpl: "", + ReturnCode: false, + TriggeredAtOrigin: eventOrigin, + }, + }, w + }, + }, { + name: "button url without event trigger url", + test: func(ctrl *gomock.Controller, queries *mock.MockQueries, commands *mock.MockCommands) (f fields, a args, w wantLegacy) { + givenTemplate := "{{.URL}}" + testCode := "testcode" + expectContent := fmt.Sprintf("%s://%s:%d/otp/verify?loginName=%s&code=%s", externalProtocol, instancePrimaryDomain, externalPort, preferredLoginName, testCode) + w.message = &messages.Email{ + Recipients: []string{verifiedEmail}, + Subject: expectMailSubject, + Content: expectContent, + } + codeAlg, code := cryptoValue(t, ctrl, testCode) + queries.EXPECT().SearchInstanceDomains(gomock.Any(), gomock.Any()).Return(&query.InstanceDomains{ + Domains: []*query.InstanceDomain{{ + Domain: instancePrimaryDomain, + IsPrimary: true, + }}, + }, nil) + expectTemplateWithNotifyUserQueries(queries, givenTemplate) + queries.EXPECT().SessionByID(gomock.Any(), gomock.Any(), userID, gomock.Any()).Return(&query.Session{}, nil) + commands.EXPECT().OTPEmailSent(gomock.Any(), userID, orgID).Return(nil) + return fields{ + queries: queries, + commands: commands, + es: eventstore.NewEventstore(&eventstore.Config{ + Querier: es_repo_mock.NewRepo(t).ExpectFilterEvents().MockQuerier, + }), + userDataCrypto: codeAlg, + }, args{ + event: &session.OTPEmailChallengedEvent{ + BaseEvent: *eventstore.BaseEventFromRepo(&repository.Event{ + AggregateID: userID, + ResourceOwner: sql.NullString{String: orgID}, + CreationDate: time.Now().UTC(), + }), + Code: code, + Expiry: time.Hour, + ReturnCode: false, + }, + }, w + }, + }, { + name: "button url with url template and event trigger url", + test: func(ctrl *gomock.Controller, queries *mock.MockQueries, commands *mock.MockCommands) (f fields, a args, w wantLegacy) { + givenTemplate := "{{.URL}}" + urlTemplate := "https://my.custom.url/user/{{.LoginName}}/verify" + testCode := "testcode" + expectContent := fmt.Sprintf("https://my.custom.url/user/%s/verify", preferredLoginName) + w.message = &messages.Email{ + Recipients: []string{verifiedEmail}, + Subject: expectMailSubject, + Content: expectContent, + } + codeAlg, code := cryptoValue(t, ctrl, testCode) + expectTemplateWithNotifyUserQueries(queries, givenTemplate) + queries.EXPECT().SessionByID(gomock.Any(), gomock.Any(), userID, gomock.Any()).Return(&query.Session{}, nil) + commands.EXPECT().OTPEmailSent(gomock.Any(), userID, orgID).Return(nil) + return fields{ + queries: queries, + commands: commands, + es: eventstore.NewEventstore(&eventstore.Config{ + Querier: es_repo_mock.NewRepo(t).ExpectFilterEvents().MockQuerier, + }), + userDataCrypto: codeAlg, + SMSTokenCrypto: nil, + }, args{ + event: &session.OTPEmailChallengedEvent{ + BaseEvent: *eventstore.BaseEventFromRepo(&repository.Event{ + AggregateID: userID, + ResourceOwner: sql.NullString{String: orgID}, + CreationDate: time.Now().UTC(), + }), + Code: code, + Expiry: time.Hour, + ReturnCode: false, + URLTmpl: urlTemplate, + TriggeredAtOrigin: eventOrigin, + }, + }, w + }, + }} + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ctrl := gomock.NewController(t) + queries := mock.NewMockQueries(ctrl) + commands := mock.NewMockCommands(ctrl) + f, a, w := tt.test(ctrl, queries, commands) + _, err := newUserNotifierLegacy(t, ctrl, queries, f, a, w).reduceSessionOTPEmailChallenged(a.event) + if w.err != nil { + w.err(t, err) + } else { + assert.NoError(t, err) + } + }) + } +} + +func Test_userNotifierLegacy_reduceOTPSMSChallenged(t *testing.T) { + tests := []struct { + name string + test func(*gomock.Controller, *mock.MockQueries, *mock.MockCommands) (fields, args, wantLegacy) + }{{ + name: "asset url with event trigger url", + test: func(ctrl *gomock.Controller, queries *mock.MockQueries, commands *mock.MockCommands) (f fields, a args, w wantLegacy) { + testCode := "" + expiry := 0 * time.Hour + expectContent := fmt.Sprintf(`%[1]s is your one-time password for %[2]s. Use it within the next %[3]s. +@%[2]s #%[1]s`, testCode, eventOriginDomain, expiry) + w.messageSMS = &messages.SMS{ + SenderPhoneNumber: "senderNumber", + RecipientPhoneNumber: verifiedPhone, + Content: expectContent, + } + expectTemplateWithNotifyUserQueriesSMS(queries) + queries.EXPECT().SessionByID(gomock.Any(), gomock.Any(), userID, gomock.Any()).Return(&query.Session{}, nil) + commands.EXPECT().OTPSMSSent(gomock.Any(), userID, orgID, &senders.CodeGeneratorInfo{ID: smsProviderID, VerificationID: verificationID}).Return(nil) + return fields{ + queries: queries, + commands: commands, + es: eventstore.NewEventstore(&eventstore.Config{ + Querier: es_repo_mock.NewRepo(t).ExpectFilterEvents().MockQuerier, + }), + }, args{ + event: &session.OTPSMSChallengedEvent{ + BaseEvent: *eventstore.BaseEventFromRepo(&repository.Event{ + AggregateID: userID, + ResourceOwner: sql.NullString{String: orgID}, + CreationDate: time.Now().UTC(), + }), + Code: nil, + Expiry: expiry, + CodeReturned: false, + GeneratorID: smsProviderID, + TriggeredAtOrigin: eventOrigin, + }, + }, w + }, + }, { + name: "asset url without event trigger url", + test: func(ctrl *gomock.Controller, queries *mock.MockQueries, commands *mock.MockCommands) (f fields, a args, w wantLegacy) { + testCode := "" + expiry := 0 * time.Hour + expectContent := fmt.Sprintf(`%[1]s is your one-time password for %[2]s. Use it within the next %[3]s. +@%[2]s #%[1]s`, testCode, instancePrimaryDomain, expiry) + w.messageSMS = &messages.SMS{ + SenderPhoneNumber: "senderNumber", + RecipientPhoneNumber: verifiedPhone, + Content: expectContent, + } + expectTemplateWithNotifyUserQueriesSMS(queries) + queries.EXPECT().SessionByID(gomock.Any(), gomock.Any(), userID, gomock.Any()).Return(&query.Session{}, nil) + queries.EXPECT().SearchInstanceDomains(gomock.Any(), gomock.Any()).Return(&query.InstanceDomains{ + Domains: []*query.InstanceDomain{{ + Domain: instancePrimaryDomain, + IsPrimary: true, + }}, + }, nil) + commands.EXPECT().OTPSMSSent(gomock.Any(), userID, orgID, &senders.CodeGeneratorInfo{ID: smsProviderID, VerificationID: verificationID}).Return(nil) + return fields{ + queries: queries, + commands: commands, + es: eventstore.NewEventstore(&eventstore.Config{ + Querier: es_repo_mock.NewRepo(t).ExpectFilterEvents().MockQuerier, + }), + }, args{ + event: &session.OTPSMSChallengedEvent{ + BaseEvent: *eventstore.BaseEventFromRepo(&repository.Event{ + AggregateID: userID, + ResourceOwner: sql.NullString{String: orgID}, + CreationDate: time.Now().UTC(), + }), + Code: nil, + Expiry: expiry, + CodeReturned: false, + GeneratorID: smsProviderID, + }, + }, w + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ctrl := gomock.NewController(t) + queries := mock.NewMockQueries(ctrl) + commands := mock.NewMockCommands(ctrl) + f, a, w := tt.test(ctrl, queries, commands) + _, err := newUserNotifierLegacy(t, ctrl, queries, f, a, w).reduceSessionOTPSMSChallenged(a.event) + if w.err != nil { + w.err(t, err) + } else { + assert.NoError(t, err) + } + }) + } +} + +type wantLegacy struct { + message *messages.Email + messageSMS *messages.SMS + err assert.ErrorAssertionFunc +} + +func newUserNotifierLegacy(t *testing.T, ctrl *gomock.Controller, queries *mock.MockQueries, f fields, a args, w wantLegacy) *userNotifierLegacy { + queries.EXPECT().NotificationProviderByIDAndType(gomock.Any(), gomock.Any(), gomock.Any()).AnyTimes().Return(&query.DebugNotificationProvider{}, nil) + smtpAlg, _ := cryptoValue(t, ctrl, "smtppw") + channel := channel_mock.NewMockNotificationChannel(ctrl) + if w.err == nil { + if w.message != nil { + w.message.TriggeringEvent = a.event + channel.EXPECT().HandleMessage(w.message).Return(nil) + } + if w.messageSMS != nil { + w.messageSMS.TriggeringEvent = a.event + channel.EXPECT().HandleMessage(w.messageSMS).DoAndReturn(func(message *messages.SMS) error { + message.VerificationID = gu.Ptr(verificationID) + return nil + }) + } + } + return &userNotifierLegacy{ + commands: f.commands, + queries: NewNotificationQueries( + f.queries, + f.es, + externalDomain, + externalPort, + externalSecure, + "", + f.userDataCrypto, + smtpAlg, + f.SMSTokenCrypto, + ), + otpEmailTmpl: defaultOTPEmailTemplate, + channels: ¬ificationChannels{ + Chain: *senders.ChainChannels(channel), + EmailConfig: &email.Config{ + ProviderConfig: &email.Provider{ + ID: "emailProviderID", + Description: "description", + }, + SMTPConfig: &smtp.Config{ + SMTP: smtp.SMTP{ + Host: "host", + User: "user", + Password: "password", + }, + Tls: true, + From: "from", + FromName: "fromName", + ReplyToAddress: "replyToAddress", + }, + WebhookConfig: nil, + }, + SMSConfig: &sms.Config{ + ProviderConfig: &sms.Provider{ + ID: "smsProviderID", + Description: "description", + }, + TwilioConfig: &twilio.Config{ + SID: "sid", + Token: "token", + SenderNumber: "senderNumber", + VerifyServiceSID: "verifyServiceSID", + }, + }, + }, + } +} diff --git a/internal/notification/projections.go b/internal/notification/projections.go index 1a8c70cd40..38e1f1c347 100644 --- a/internal/notification/projections.go +++ b/internal/notification/projections.go @@ -38,7 +38,7 @@ func Register( ) { q := handlers.NewNotificationQueries(queries, es, externalDomain, externalPort, externalSecure, fileSystemPath, userEncryption, smtpEncryption, smsEncryption) c := newChannels(q) - projections = append(projections, handlers.NewUserNotifier(ctx, projection.ApplyCustomConfig(userHandlerCustomConfig), commands, q, otpEmailTmpl)) + projections = append(projections, handlers.NewUserNotifier(ctx, projection.ApplyCustomConfig(userHandlerCustomConfig), commands, q, c, otpEmailTmpl, notificationWorkerConfig.LegacyEnabled)) projections = append(projections, handlers.NewQuotaNotifier(ctx, projection.ApplyCustomConfig(quotaHandlerCustomConfig), commands, q, c)) projections = append(projections, handlers.NewBackChannelLogoutNotifier( ctx, diff --git a/internal/notification/types/domain_claimed.go b/internal/notification/types/domain_claimed.go new file mode 100644 index 0000000000..433728392b --- /dev/null +++ b/internal/notification/types/domain_claimed.go @@ -0,0 +1,20 @@ +package types + +import ( + "context" + "strings" + + http_utils "github.com/zitadel/zitadel/internal/api/http" + "github.com/zitadel/zitadel/internal/api/ui/login" + "github.com/zitadel/zitadel/internal/domain" + "github.com/zitadel/zitadel/internal/query" +) + +func (notify Notify) SendDomainClaimed(ctx context.Context, user *query.NotifyUser, username string) error { + url := login.LoginLink(http_utils.DomainContext(ctx).Origin(), user.ResourceOwner) + index := strings.LastIndex(user.LastEmail, "@") + args := make(map[string]interface{}) + args["TempUsername"] = username + args["Domain"] = user.LastEmail[index+1:] + return notify(url, args, domain.DomainClaimedMessageType, true) +} diff --git a/internal/notification/types/email_verification_code.go b/internal/notification/types/email_verification_code.go new file mode 100644 index 0000000000..4ff59137b1 --- /dev/null +++ b/internal/notification/types/email_verification_code.go @@ -0,0 +1,28 @@ +package types + +import ( + "context" + "strings" + + http_utils "github.com/zitadel/zitadel/internal/api/http" + "github.com/zitadel/zitadel/internal/api/ui/login" + "github.com/zitadel/zitadel/internal/domain" + "github.com/zitadel/zitadel/internal/query" +) + +func (notify Notify) SendEmailVerificationCode(ctx context.Context, user *query.NotifyUser, code string, urlTmpl, authRequestID string) error { + var url string + if urlTmpl == "" { + url = login.MailVerificationLink(http_utils.DomainContext(ctx).Origin(), user.ID, code, user.ResourceOwner, authRequestID) + } else { + var buf strings.Builder + if err := domain.RenderConfirmURLTemplate(&buf, urlTmpl, user.ID, code, user.ResourceOwner); err != nil { + return err + } + url = buf.String() + } + + args := make(map[string]interface{}) + args["Code"] = code + return notify(url, args, domain.VerifyEmailMessageType, true) +} diff --git a/internal/notification/types/email_verification_code_test.go b/internal/notification/types/email_verification_code_test.go new file mode 100644 index 0000000000..2196e25b0c --- /dev/null +++ b/internal/notification/types/email_verification_code_test.go @@ -0,0 +1,92 @@ +package types + +import ( + "context" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + http_utils "github.com/zitadel/zitadel/internal/api/http" + "github.com/zitadel/zitadel/internal/domain" + "github.com/zitadel/zitadel/internal/query" + "github.com/zitadel/zitadel/internal/zerrors" +) + +func TestNotify_SendEmailVerificationCode(t *testing.T) { + type args struct { + user *query.NotifyUser + origin *http_utils.DomainCtx + code string + urlTmpl string + authRequestID string + } + tests := []struct { + name string + args args + want *notifyResult + wantErr error + }{ + { + name: "default URL", + args: args{ + user: &query.NotifyUser{ + ID: "user1", + ResourceOwner: "org1", + }, + origin: &http_utils.DomainCtx{InstanceHost: "example.com", Protocol: "https"}, + code: "123", + urlTmpl: "", + authRequestID: "authRequestID", + }, + want: ¬ifyResult{ + url: "https://example.com/ui/login/mail/verification?authRequestID=authRequestID&code=123&orgID=org1&userID=user1", + args: map[string]interface{}{"Code": "123"}, + messageType: domain.VerifyEmailMessageType, + allowUnverifiedNotificationChannel: true, + }, + }, + { + name: "template error", + args: args{ + user: &query.NotifyUser{ + ID: "user1", + ResourceOwner: "org1", + }, + origin: &http_utils.DomainCtx{InstanceHost: "example.com", Protocol: "https"}, + code: "123", + urlTmpl: "{{", + authRequestID: "authRequestID", + }, + want: ¬ifyResult{}, + wantErr: zerrors.ThrowInvalidArgument(nil, "DOMAIN-oGh5e", "Errors.User.InvalidURLTemplate"), + }, + { + name: "template success", + args: args{ + user: &query.NotifyUser{ + ID: "user1", + ResourceOwner: "org1", + }, + origin: &http_utils.DomainCtx{InstanceHost: "example.com", Protocol: "https"}, + code: "123", + urlTmpl: "https://example.com/email/verify?userID={{.UserID}}&code={{.Code}}&orgID={{.OrgID}}", + authRequestID: "authRequestID", + }, + want: ¬ifyResult{ + url: "https://example.com/email/verify?userID=user1&code=123&orgID=org1", + args: map[string]interface{}{"Code": "123"}, + messageType: domain.VerifyEmailMessageType, + allowUnverifiedNotificationChannel: true, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, notify := mockNotify() + err := notify.SendEmailVerificationCode(http_utils.WithDomainContext(context.Background(), tt.args.origin), tt.args.user, tt.args.code, tt.args.urlTmpl, tt.args.authRequestID) + require.ErrorIs(t, err, tt.wantErr) + assert.Equal(t, tt.want, got) + }) + } +} diff --git a/internal/notification/types/init_code.go b/internal/notification/types/init_code.go new file mode 100644 index 0000000000..3e38cc284b --- /dev/null +++ b/internal/notification/types/init_code.go @@ -0,0 +1,17 @@ +package types + +import ( + "context" + + http_utils "github.com/zitadel/zitadel/internal/api/http" + "github.com/zitadel/zitadel/internal/api/ui/login" + "github.com/zitadel/zitadel/internal/domain" + "github.com/zitadel/zitadel/internal/query" +) + +func (notify Notify) SendUserInitCode(ctx context.Context, user *query.NotifyUser, code, authRequestID string) error { + url := login.InitUserLink(http_utils.DomainContext(ctx).Origin(), user.ID, user.PreferredLoginName, code, user.ResourceOwner, user.PasswordSet, authRequestID) + args := make(map[string]interface{}) + args["Code"] = code + return notify(url, args, domain.InitCodeMessageType, true) +} diff --git a/internal/notification/types/invite_code.go b/internal/notification/types/invite_code.go new file mode 100644 index 0000000000..953124a553 --- /dev/null +++ b/internal/notification/types/invite_code.go @@ -0,0 +1,31 @@ +package types + +import ( + "context" + "strings" + + http_utils "github.com/zitadel/zitadel/internal/api/http" + "github.com/zitadel/zitadel/internal/api/ui/login" + "github.com/zitadel/zitadel/internal/domain" + "github.com/zitadel/zitadel/internal/query" +) + +func (notify Notify) SendInviteCode(ctx context.Context, user *query.NotifyUser, code, applicationName, urlTmpl, authRequestID string) error { + var url string + if applicationName == "" { + applicationName = "ZITADEL" + } + if urlTmpl == "" { + url = login.InviteUserLink(http_utils.DomainContext(ctx).Origin(), user.ID, user.PreferredLoginName, code, user.ResourceOwner, authRequestID) + } else { + var buf strings.Builder + if err := domain.RenderConfirmURLTemplate(&buf, urlTmpl, user.ID, code, user.ResourceOwner); err != nil { + return err + } + url = buf.String() + } + args := make(map[string]interface{}) + args["Code"] = code + args["ApplicationName"] = applicationName + return notify(url, args, domain.InviteUserMessageType, true) +} diff --git a/internal/notification/types/otp.go b/internal/notification/types/otp.go new file mode 100644 index 0000000000..3242b2da3d --- /dev/null +++ b/internal/notification/types/otp.go @@ -0,0 +1,29 @@ +package types + +import ( + "context" + "time" + + http_utils "github.com/zitadel/zitadel/internal/api/http" + "github.com/zitadel/zitadel/internal/domain" +) + +func (notify Notify) SendOTPSMSCode(ctx context.Context, code string, expiry time.Duration) error { + args := otpArgs(ctx, code, expiry) + return notify("", args, domain.VerifySMSOTPMessageType, false) +} + +func (notify Notify) SendOTPEmailCode(ctx context.Context, url, code string, expiry time.Duration) error { + args := otpArgs(ctx, code, expiry) + return notify(url, args, domain.VerifyEmailOTPMessageType, false) +} + +func otpArgs(ctx context.Context, code string, expiry time.Duration) map[string]interface{} { + domainCtx := http_utils.DomainContext(ctx) + args := make(map[string]interface{}) + args["OTP"] = code + args["Origin"] = domainCtx.Origin() + args["Domain"] = domainCtx.RequestedDomain() + args["Expiry"] = expiry + return args +} diff --git a/internal/notification/types/password_change.go b/internal/notification/types/password_change.go new file mode 100644 index 0000000000..8536ac4c04 --- /dev/null +++ b/internal/notification/types/password_change.go @@ -0,0 +1,16 @@ +package types + +import ( + "context" + + http_utils "github.com/zitadel/zitadel/internal/api/http" + "github.com/zitadel/zitadel/internal/api/ui/console" + "github.com/zitadel/zitadel/internal/domain" + "github.com/zitadel/zitadel/internal/query" +) + +func (notify Notify) SendPasswordChange(ctx context.Context, user *query.NotifyUser) error { + url := console.LoginHintLink(http_utils.DomainContext(ctx).Origin(), user.PreferredLoginName) + args := make(map[string]interface{}) + return notify(url, args, domain.PasswordChangeMessageType, true) +} diff --git a/internal/notification/types/password_code.go b/internal/notification/types/password_code.go new file mode 100644 index 0000000000..40ffee3e6d --- /dev/null +++ b/internal/notification/types/password_code.go @@ -0,0 +1,27 @@ +package types + +import ( + "context" + "strings" + + http_utils "github.com/zitadel/zitadel/internal/api/http" + "github.com/zitadel/zitadel/internal/api/ui/login" + "github.com/zitadel/zitadel/internal/domain" + "github.com/zitadel/zitadel/internal/query" +) + +func (notify Notify) SendPasswordCode(ctx context.Context, user *query.NotifyUser, code, urlTmpl, authRequestID string) error { + var url string + if urlTmpl == "" { + url = login.InitPasswordLink(http_utils.DomainContext(ctx).Origin(), user.ID, code, user.ResourceOwner, authRequestID) + } else { + var buf strings.Builder + if err := domain.RenderConfirmURLTemplate(&buf, urlTmpl, user.ID, code, user.ResourceOwner); err != nil { + return err + } + url = buf.String() + } + args := make(map[string]interface{}) + args["Code"] = code + return notify(url, args, domain.PasswordResetMessageType, true) +} diff --git a/internal/notification/types/passwordless_registration_link.go b/internal/notification/types/passwordless_registration_link.go new file mode 100644 index 0000000000..64af1a9797 --- /dev/null +++ b/internal/notification/types/passwordless_registration_link.go @@ -0,0 +1,25 @@ +package types + +import ( + "context" + "strings" + + http_utils "github.com/zitadel/zitadel/internal/api/http" + "github.com/zitadel/zitadel/internal/api/ui/login" + "github.com/zitadel/zitadel/internal/domain" + "github.com/zitadel/zitadel/internal/query" +) + +func (notify Notify) SendPasswordlessRegistrationLink(ctx context.Context, user *query.NotifyUser, code, codeID, urlTmpl string) error { + var url string + if urlTmpl == "" { + url = domain.PasswordlessInitCodeLink(http_utils.DomainContext(ctx).Origin()+login.HandlerPrefix+login.EndpointPasswordlessRegistration, user.ID, user.ResourceOwner, codeID, code) + } else { + var buf strings.Builder + if err := domain.RenderPasskeyURLTemplate(&buf, urlTmpl, user.ID, user.ResourceOwner, codeID, code); err != nil { + return err + } + url = buf.String() + } + return notify(url, nil, domain.PasswordlessRegistrationMessageType, true) +} diff --git a/internal/notification/types/passwordless_registration_link_test.go b/internal/notification/types/passwordless_registration_link_test.go new file mode 100644 index 0000000000..0a04b7a0fe --- /dev/null +++ b/internal/notification/types/passwordless_registration_link_test.go @@ -0,0 +1,90 @@ +package types + +import ( + "context" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + http_utils "github.com/zitadel/zitadel/internal/api/http" + "github.com/zitadel/zitadel/internal/domain" + "github.com/zitadel/zitadel/internal/query" + "github.com/zitadel/zitadel/internal/zerrors" +) + +func TestNotify_SendPasswordlessRegistrationLink(t *testing.T) { + type args struct { + user *query.NotifyUser + origin *http_utils.DomainCtx + code string + codeID string + urlTmpl string + } + tests := []struct { + name string + args args + want *notifyResult + wantErr error + }{ + { + name: "default URL", + args: args{ + user: &query.NotifyUser{ + ID: "user1", + ResourceOwner: "org1", + }, + origin: &http_utils.DomainCtx{InstanceHost: "example.com", Protocol: "https"}, + code: "123", + codeID: "456", + urlTmpl: "", + }, + want: ¬ifyResult{ + url: "https://example.com/ui/login/login/passwordless/init?userID=user1&orgID=org1&codeID=456&code=123", + messageType: domain.PasswordlessRegistrationMessageType, + allowUnverifiedNotificationChannel: true, + }, + }, + { + name: "template error", + args: args{ + user: &query.NotifyUser{ + ID: "user1", + ResourceOwner: "org1", + }, + origin: &http_utils.DomainCtx{InstanceHost: "example.com", Protocol: "https"}, + code: "123", + codeID: "456", + urlTmpl: "{{", + }, + want: ¬ifyResult{}, + wantErr: zerrors.ThrowInvalidArgument(nil, "DOMAIN-oGh5e", "Errors.User.InvalidURLTemplate"), + }, + { + name: "template success", + args: args{ + user: &query.NotifyUser{ + ID: "user1", + ResourceOwner: "org1", + }, + origin: &http_utils.DomainCtx{InstanceHost: "example.com", Protocol: "https"}, + code: "123", + codeID: "456", + urlTmpl: "https://example.com/passkey/register?userID={{.UserID}}&orgID={{.OrgID}}&codeID={{.CodeID}}&code={{.Code}}", + }, + want: ¬ifyResult{ + url: "https://example.com/passkey/register?userID=user1&orgID=org1&codeID=456&code=123", + messageType: domain.PasswordlessRegistrationMessageType, + allowUnverifiedNotificationChannel: true, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, notify := mockNotify() + err := notify.SendPasswordlessRegistrationLink(http_utils.WithDomainContext(context.Background(), tt.args.origin), tt.args.user, tt.args.code, tt.args.codeID, tt.args.urlTmpl) + require.ErrorIs(t, err, tt.wantErr) + assert.Equal(t, tt.want, got) + }) + } +} diff --git a/internal/notification/types/phone_verification_code.go b/internal/notification/types/phone_verification_code.go new file mode 100644 index 0000000000..461b85749c --- /dev/null +++ b/internal/notification/types/phone_verification_code.go @@ -0,0 +1,15 @@ +package types + +import ( + "context" + + http_util "github.com/zitadel/zitadel/internal/api/http" + "github.com/zitadel/zitadel/internal/domain" +) + +func (notify Notify) SendPhoneVerificationCode(ctx context.Context, code string) error { + args := make(map[string]interface{}) + args["Code"] = code + args["Domain"] = http_util.DomainContext(ctx).RequestedDomain() + return notify("", args, domain.VerifyPhoneMessageType, true) +} diff --git a/internal/notification/types/types_test.go b/internal/notification/types/types_test.go new file mode 100644 index 0000000000..1b5066d195 --- /dev/null +++ b/internal/notification/types/types_test.go @@ -0,0 +1,23 @@ +package types + +type notifyResult struct { + url string + args map[string]interface{} + messageType string + allowUnverifiedNotificationChannel bool +} + +// mockNotify returns a notifyResult and Notify function for easy mocking. +// The notifyResult will only be populated after Notify is called. +func mockNotify() (*notifyResult, Notify) { + dst := new(notifyResult) + return dst, func(url string, args map[string]interface{}, messageType string, allowUnverifiedNotificationChannel bool) error { + *dst = notifyResult{ + url: url, + args: args, + messageType: messageType, + allowUnverifiedNotificationChannel: allowUnverifiedNotificationChannel, + } + return nil + } +} From a81d42a61a9a4cbd88ae047498eb411078e2e163 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tim=20M=C3=B6hlmann?= Date: Fri, 6 Dec 2024 12:20:10 +0200 Subject: [PATCH 52/64] fix(eventstore): set created filters to exclusion sub-query (#9019) # Which Problems Are Solved In eventstore queries with aggregate ID exclusion filters, filters on events creation date where not passed to the sub-query. This results in a high amount of returned rows from the sub-query and high overall query cost. # How the Problems Are Solved When CreatedAfter and CreatedBefore are used on the global search query, copy those filters to the sub-query. We already did this for the position column filter. # Additional Changes - none # Additional Context - Introduced in https://github.com/zitadel/zitadel/pull/8940 Co-authored-by: Livio Spring --- internal/eventstore/repository/sql/query.go | 2 +- .../eventstore/repository/sql/query_test.go | 31 +++++++++++++++++++ 2 files changed, 32 insertions(+), 1 deletion(-) diff --git a/internal/eventstore/repository/sql/query.go b/internal/eventstore/repository/sql/query.go index 01f0c080e3..b93e663b17 100644 --- a/internal/eventstore/repository/sql/query.go +++ b/internal/eventstore/repository/sql/query.go @@ -285,7 +285,7 @@ func prepareConditions(criteria querier, query *repository.SearchQuery, useV1 bo excludeAggregateIDs := query.ExcludeAggregateIDs if len(excludeAggregateIDs) > 0 { - excludeAggregateIDs = append(excludeAggregateIDs, query.InstanceID, query.InstanceIDs, query.Position) + excludeAggregateIDs = append(excludeAggregateIDs, query.InstanceID, query.InstanceIDs, query.Position, query.CreatedAfter, query.CreatedBefore) } excludeAggregateIDsClauses, excludeAggregateIDsArgs := prepareQuery(criteria, useV1, excludeAggregateIDs...) if excludeAggregateIDsClauses != "" { diff --git a/internal/eventstore/repository/sql/query_test.go b/internal/eventstore/repository/sql/query_test.go index 2caa9da72d..abac19ead0 100644 --- a/internal/eventstore/repository/sql/query_test.go +++ b/internal/eventstore/repository/sql/query_test.go @@ -1060,6 +1060,37 @@ func Test_query_events_mocked(t *testing.T) { wantErr: false, }, }, + { + name: "aggregate / event type, created after and exclusion, v2", + args: args{ + dest: &[]*repository.Event{}, + query: eventstore.NewSearchQueryBuilder(eventstore.ColumnsEvent). + InstanceID("instanceID"). + OrderDesc(). + Limit(5). + CreationDateAfter(time.Unix(123, 456)). + AddQuery(). + AggregateTypes("notify"). + EventTypes("notify.foo.bar"). + Builder(). + ExcludeAggregateIDs(). + AggregateTypes("notify"). + EventTypes("notification.failed", "notification.success"). + Builder(), + useV1: false, + }, + fields: fields{ + mock: newMockClient(t).expectQuery(t, + regexp.QuoteMeta( + `SELECT created_at, event_type, "sequence", "position", payload, creator, "owner", instance_id, aggregate_type, aggregate_id, revision FROM eventstore.events2 WHERE instance_id = $1 AND aggregate_type = $2 AND event_type = $3 AND created_at > $4 AND aggregate_id NOT IN (SELECT aggregate_id FROM eventstore.events2 WHERE aggregate_type = $5 AND event_type = ANY($6) AND instance_id = $7 AND created_at > $8) ORDER BY "position" DESC, in_tx_order DESC LIMIT $9`, + ), + []driver.Value{"instanceID", eventstore.AggregateType("notify"), eventstore.EventType("notify.foo.bar"), time.Unix(123, 456), eventstore.AggregateType("notify"), []eventstore.EventType{"notification.failed", "notification.success"}, "instanceID", time.Unix(123, 456), uint64(5)}, + ), + }, + res: res{ + wantErr: false, + }, + }, } crdb := NewCRDB(&database.DB{Database: new(testDB)}) for _, tt := range tests { From 77cd430b3a67cd95ad9deec1e5b44a17638def06 Mon Sep 17 00:00:00 2001 From: Silvan Date: Fri, 6 Dec 2024 12:32:53 +0100 Subject: [PATCH 53/64] refactor(handler): cache active instances (#9008) # Which Problems Are Solved Scheduled handlers use `eventstore.InstanceIDs` to get the all active instances within a given timeframe. This function scrapes through all events written within that time frame which can cause heavy load on the database. # How the Problems Are Solved A new query cache `activeInstances` is introduced which caches the ids of all instances queried by id or host within the configured timeframe. # Additional Changes - Changed `default.yaml` - Removed `HandleActiveInstances` from custom handler configs - Added `MaxActiveInstances` to define the maximal amount of cached instance ids - fixed start-from-init and start-from-setup to start auth and admin projections twice - fixed org cache invalidation to use correct index # Additional Context - part of #8999 --- cmd/defaults.yaml | 21 ++------ cmd/setup/29.go | 2 - cmd/setup/30.go | 2 - cmd/setup/41.go | 2 - cmd/start/start.go | 4 +- go.mod | 12 ++--- go.sum | 24 ++++----- .../eventsourcing/handler/handler.go | 21 +++++--- .../repository/eventsourcing/repository.go | 3 +- .../eventsourcing/handler/handler.go | 22 +++++--- internal/eventstore/eventstore.go | 32 ++---------- .../eventstore/handler/v2/field_handler.go | 1 - internal/eventstore/handler/v2/handler.go | 52 +++++++++---------- internal/eventstore/handler/v2/mock_test.go | 9 +++- .../handlers/mock/queries.mock.go | 14 +++++ .../handlers/notification_worker.go | 49 +++++------------ .../handlers/notification_worker_test.go | 19 ++++--- internal/notification/handlers/queries.go | 2 + internal/query/cache.go | 13 ++++- internal/query/instance.go | 13 +++++ internal/query/org.go | 2 +- internal/query/projection/config.go | 17 +++--- .../query/projection/eventstore_mock_test.go | 3 +- internal/query/projection/projection.go | 19 +++---- internal/query/query.go | 11 +++- 25 files changed, 181 insertions(+), 188 deletions(-) diff --git a/cmd/defaults.yaml b/cmd/defaults.yaml index 09899593ab..08973cee64 100644 --- a/cmd/defaults.yaml +++ b/cmd/defaults.yaml @@ -400,6 +400,9 @@ Projections: # from HandleActiveInstances duration in the past until the projection's current time # If set to 0 (default), every instance is always considered active HandleActiveInstances: 0s # ZITADEL_PROJECTIONS_HANDLEACTIVEINSTANCES + # Maximum amount of instances cached as active + # If set to 0, every instance is always considered active + MaxActiveInstances: 0 # ZITADEL_PROJECTIONS_MAXACTIVEINSTANCES # In the Customizations section, all settings from above can be overwritten for each specific projection Customizations: custom_texts: @@ -423,11 +426,6 @@ Projections: TransactionDuration: 2s # ZITADEL_PROJECTIONS_CUSTOMIZATIONS_LOCKOUT_POLICY_TRANSACTIONDURATION # The NotificationsQuotas projection is used for calling quota webhooks NotificationsQuotas: - # In case of failed deliveries, ZITADEL retries to send the data points to the configured endpoints, but only for active instances. - # An instance is active, as long as there are projected events on the instance, that are not older than the HandleActiveInstances duration. - # Delivery guarantee requirements are higher for quota webhooks - # If set to 0 (default), every instance is always considered active - HandleActiveInstances: 0s # ZITADEL_PROJECTIONS_CUSTOMIZATIONS_NOTIFICATIONSQUOTAS_HANDLEACTIVEINSTANCES # As quota notification projections don't result in database statements, retries don't have an effect MaxFailureCount: 10 # ZITADEL_PROJECTIONS_CUSTOMIZATIONS_NOTIFICATIONSQUOTAS_MAXFAILURECOUNT # Quota notifications are not so time critical. Setting RequeueEvery every five minutes doesn't annoy the db too much. @@ -438,11 +436,6 @@ Projections: BulkLimit: 50 # The Telemetry projection is used for calling telemetry webhooks Telemetry: - # In case of failed deliveries, ZITADEL retries to send the data points to the configured endpoints, but only for active instances. - # An instance is active, as long as there are projected events on the instance, that are not older than the HandleActiveInstances duration. - # Telemetry delivery guarantee requirements are a bit higher than normal data projections, as they are not interactively retryable. - # If set to 0 (default), every instance is always considered active - HandleActiveInstances: 0s # ZITADEL_PROJECTIONS_CUSTOMIZATIONS_TELEMETRY_HANDLEACTIVEINSTANCES # As sending telemetry data doesn't result in database statements, retries don't have any effects MaxFailureCount: 0 # ZITADEL_PROJECTIONS_CUSTOMIZATIONS_TELEMETRY_MAXFAILURECOUNT # Telemetry data synchronization is not time critical. Setting RequeueEvery to 55 minutes doesn't annoy the database too much. @@ -497,10 +490,6 @@ Auth: BulkLimit: 100 #ZITADEL_AUTH_SPOOLER_BULKLIMIT # See Projections.MaxFailureCount FailureCountUntilSkip: 5 #ZITADEL_AUTH_SPOOLER_FAILURECOUNTUNTILSKIP - # Only instance are projected, for which at least a projection relevant event exists withing the timeframe - # from HandleActiveInstances duration in the past until the projections current time - # If set to 0 (default), every instance is always considered active - HandleActiveInstances: 0s #ZITADEL_AUTH_SPOOLER_HANDLEACTIVEINSTANCES # Defines the amount of auth requests stored in the LRU caches. # There are two caches implemented one for id and one for code AmountOfCachedAuthRequests: 0 #ZITADEL_AUTH_AMOUNTOFCACHEDAUTHREQUESTS @@ -515,10 +504,6 @@ Admin: BulkLimit: 200 # See Projections.MaxFailureCount FailureCountUntilSkip: 5 - # Only instance are projected, for which at least a projection relevant event exists withing the timeframe - # from HandleActiveInstances duration in the past until the projections current time - # If set to 0 (default), every instance is always considered active - HandleActiveInstances: 0s UserAgentCookie: Name: zitadel.useragent # ZITADEL_USERAGENTCOOKIE_NAME diff --git a/cmd/setup/29.go b/cmd/setup/29.go index 1d3bfe992d..8df1047ec9 100644 --- a/cmd/setup/29.go +++ b/cmd/setup/29.go @@ -16,8 +16,6 @@ type FillFieldsForProjectGrant struct { func (mig *FillFieldsForProjectGrant) Execute(ctx context.Context, _ eventstore.Event) error { instances, err := mig.eventstore.InstanceIDs( ctx, - 0, - true, eventstore.NewSearchQueryBuilder(eventstore.ColumnsInstanceIDs). OrderDesc(). AddQuery(). diff --git a/cmd/setup/30.go b/cmd/setup/30.go index f2b6d466f1..c2037a7f23 100644 --- a/cmd/setup/30.go +++ b/cmd/setup/30.go @@ -16,8 +16,6 @@ type FillFieldsForOrgDomainVerified struct { func (mig *FillFieldsForOrgDomainVerified) Execute(ctx context.Context, _ eventstore.Event) error { instances, err := mig.eventstore.InstanceIDs( ctx, - 0, - true, eventstore.NewSearchQueryBuilder(eventstore.ColumnsInstanceIDs). OrderDesc(). AddQuery(). diff --git a/cmd/setup/41.go b/cmd/setup/41.go index 6fa958bce7..57c446e2d1 100644 --- a/cmd/setup/41.go +++ b/cmd/setup/41.go @@ -16,8 +16,6 @@ type FillFieldsForInstanceDomains struct { func (mig *FillFieldsForInstanceDomains) Execute(ctx context.Context, _ eventstore.Event) error { instances, err := mig.eventstore.InstanceIDs( ctx, - 0, - true, eventstore.NewSearchQueryBuilder(eventstore.ColumnsInstanceIDs). OrderDesc(). AddQuery(). diff --git a/cmd/start/start.go b/cmd/start/start.go index 38a8450b46..61e9c35e34 100644 --- a/cmd/start/start.go +++ b/cmd/start/start.go @@ -405,6 +405,7 @@ func startAPIs( config.Auth.Spooler.Client = dbClient config.Auth.Spooler.Eventstore = eventstore + config.Auth.Spooler.ActiveInstancer = queries authRepo, err := auth_es.Start(ctx, config.Auth, config.SystemDefaults, commands, queries, dbClient, eventstore, keys.OIDC, keys.User) if err != nil { return nil, fmt.Errorf("error starting auth repo: %w", err) @@ -412,7 +413,8 @@ func startAPIs( config.Admin.Spooler.Client = dbClient config.Admin.Spooler.Eventstore = eventstore - err = admin_es.Start(ctx, config.Admin, store, dbClient) + config.Admin.Spooler.ActiveInstancer = queries + err = admin_es.Start(ctx, config.Admin, store, dbClient, queries) if err != nil { return nil, fmt.Errorf("error starting admin repo: %w", err) } diff --git a/go.mod b/go.mod index 2928d4dbfb..cf5cbf919d 100644 --- a/go.mod +++ b/go.mod @@ -79,13 +79,13 @@ require ( go.opentelemetry.io/otel/sdk v1.29.0 go.opentelemetry.io/otel/sdk/metric v1.29.0 go.opentelemetry.io/otel/trace v1.29.0 - go.uber.org/mock v0.4.0 - golang.org/x/crypto v0.27.0 + go.uber.org/mock v0.5.0 + golang.org/x/crypto v0.29.0 golang.org/x/exp v0.0.0-20240613232115-7f521ea00fb8 - golang.org/x/net v0.28.0 + golang.org/x/net v0.31.0 golang.org/x/oauth2 v0.23.0 - golang.org/x/sync v0.8.0 - golang.org/x/text v0.19.0 + golang.org/x/sync v0.9.0 + golang.org/x/text v0.20.0 google.golang.org/api v0.187.0 google.golang.org/genproto/googleapis/api v0.0.0-20240822170219-fc7c04adadcd google.golang.org/grpc v1.65.0 @@ -205,7 +205,7 @@ require ( go.opencensus.io v0.24.0 // indirect go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.29.0 // indirect go.opentelemetry.io/proto/otlp v1.3.1 // indirect - golang.org/x/sys v0.25.0 + golang.org/x/sys v0.27.0 gopkg.in/ini.v1 v1.67.0 // indirect gopkg.in/yaml.v2 v2.4.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect diff --git a/go.sum b/go.sum index ad9e8914cf..bacef90c1a 100644 --- a/go.sum +++ b/go.sum @@ -785,8 +785,8 @@ go.uber.org/atomic v1.7.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc= go.uber.org/goleak v1.1.10/go.mod h1:8a7PlsEVH3e/a/GLqe5IIrQx6GzcnRmZEufDUTk4A7A= go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= -go.uber.org/mock v0.4.0 h1:VcM4ZOtdbR4f6VXfiOpwpVJDL6lCReaZ6mw31wqh7KU= -go.uber.org/mock v0.4.0/go.mod h1:a6FSlNadKUHUa9IP5Vyt1zh4fC7uAwxMutEAscFbkZc= +go.uber.org/mock v0.5.0 h1:KAMbZvZPyBPWgD14IrIQ38QCyjwpvVVV6K/bHl1IwQU= +go.uber.org/mock v0.5.0/go.mod h1:ge71pBPLYDk7QIi1LupWxdAykm7KIEFchiOqd6z7qMM= go.uber.org/multierr v1.1.0/go.mod h1:wR5kodmAFQ0UK8QlbwjlSNy0Z68gJhDJUG5sjR94q/0= go.uber.org/multierr v1.3.0/go.mod h1:VgVr7evmIr6uPjLBxg28wmKNXyqE9akIJ5XnfpiKl+4= go.uber.org/multierr v1.6.0/go.mod h1:cdWPpRnG4AhwMwsgIHip0KRBQjJy5kYEpYjJxpXp9iU= @@ -809,8 +809,8 @@ golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5y golang.org/x/crypto v0.6.0/go.mod h1:OFC/31mSvZgRz0V1QTNCzfAI1aIRzbiufJtkMIlEp58= golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU= golang.org/x/crypto v0.21.0/go.mod h1:0BP7YvVV9gBbVKyeTG0Gyn+gZm94bibOW5BjDEYAOMs= -golang.org/x/crypto v0.27.0 h1:GXm2NjJrPaiv/h1tb2UH8QfgC/hOf/+z0p6PT8o1w7A= -golang.org/x/crypto v0.27.0/go.mod h1:1Xngt8kV6Dvbssa53Ziq6Eqn0HqbZi5Z6R0ZpwQzt70= +golang.org/x/crypto v0.29.0 h1:L5SG1JTTXupVV3n6sUqMTeWbjAyfPwoda2DLX8J8FrQ= +golang.org/x/crypto v0.29.0/go.mod h1:+F4F4N5hv6v38hfeYwTdx20oUvLLc+QfrE9Ax9HtgRg= golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/exp v0.0.0-20200331195152-e8c3332aa8e5/go.mod h1:4M0jN8W1tt0AVLNr8HDosyJCDCDuyL9N9+3m7wDWgKw= @@ -873,8 +873,8 @@ golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44= golang.org/x/net v0.22.0/go.mod h1:JKghWKKOSdJwpW2GEx0Ja7fmaKnMsbu+MWVZTokSYmg= -golang.org/x/net v0.28.0 h1:a9JDOJc5GMUJ0+UDqmLT86WiEy7iWyIhz8gz8E4e5hE= -golang.org/x/net v0.28.0/go.mod h1:yqtgsTWOOnlGLG9GFRrK3++bGOUEkNBoHZc8MEDWPNg= +golang.org/x/net v0.31.0 h1:68CPQngjLL0r2AlUKiSxtQFKvzRVbnzLwMUn5SzcLHo= +golang.org/x/net v0.31.0/go.mod h1:P4fl1q7dY2hnZFxEk4pPSkDHF+QqjitcnDjUQyMM+pM= golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= golang.org/x/oauth2 v0.0.0-20200107190931-bf48bf16ab8d/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= @@ -890,8 +890,8 @@ golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJ golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.8.0 h1:3NFvSEYkUoMifnESzZl15y791HH1qU2xm6eCJU5ZPXQ= -golang.org/x/sync v0.8.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/sync v0.9.0 h1:fEo0HyrW1GIgZdpbhCRO0PkJajUS5H9IFUztCgEo2jQ= +golang.org/x/sync v0.9.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/sys v0.0.0-20180823144017-11551d06cbcc/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= @@ -934,8 +934,8 @@ golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.18.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= -golang.org/x/sys v0.25.0 h1:r+8e+loiHxRqhXVl6ML1nO3l1+oFoWbnlu2Ehimmi34= -golang.org/x/sys v0.25.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.27.0 h1:wBqf8DvsY9Y/2P8gAfPDEYNuS30J4lPHJxXSb/nJZ+s= +golang.org/x/sys v0.27.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= @@ -950,8 +950,8 @@ golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= -golang.org/x/text v0.19.0 h1:kTxAhCbGbxhK0IwgSKiMO5awPoDQ0RpfiVYBfK860YM= -golang.org/x/text v0.19.0/go.mod h1:BuEKDfySbSR4drPmRPG/7iBdf8hvFMuRexcpahXilzY= +golang.org/x/text v0.20.0 h1:gK/Kv2otX8gz+wn7Rmb3vT96ZwuoxnQlY+HlJVj7Qug= +golang.org/x/text v0.20.0/go.mod h1:D4IsuqiFMhST5bX19pQ9ikHC2GsaKyk/oF+pn3ducp4= golang.org/x/time v0.0.0-20180412165947-fbb02b2291d2/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/time v0.0.0-20191024005414-555d28b269f0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/time v0.5.0 h1:o7cqy6amK/52YcAKIPlM3a+Fpj35zvRj2TP+e1xFSfk= diff --git a/internal/admin/repository/eventsourcing/handler/handler.go b/internal/admin/repository/eventsourcing/handler/handler.go index 06720144e1..ec268c25a1 100644 --- a/internal/admin/repository/eventsourcing/handler/handler.go +++ b/internal/admin/repository/eventsourcing/handler/handler.go @@ -18,9 +18,11 @@ type Config struct { BulkLimit uint64 FailureCountUntilSkip uint64 - HandleActiveInstances time.Duration TransactionDuration time.Duration Handlers map[string]*ConfigOverwrites + ActiveInstancer interface { + ActiveInstances() []string + } } type ConfigOverwrites struct { @@ -34,6 +36,9 @@ func Register(ctx context.Context, config Config, view *view.View, static static return } + // make sure the slice does not contain old values + projections = nil + projections = append(projections, newStyling(ctx, config.overwrite("Styling"), static, @@ -63,13 +68,13 @@ func ProjectInstance(ctx context.Context) error { func (config Config) overwrite(viewModel string) handler2.Config { c := handler2.Config{ - Client: config.Client, - Eventstore: config.Eventstore, - BulkLimit: uint16(config.BulkLimit), - RequeueEvery: 3 * time.Minute, - HandleActiveInstances: config.HandleActiveInstances, - MaxFailureCount: uint8(config.FailureCountUntilSkip), - TransactionDuration: config.TransactionDuration, + Client: config.Client, + Eventstore: config.Eventstore, + BulkLimit: uint16(config.BulkLimit), + RequeueEvery: 3 * time.Minute, + MaxFailureCount: uint8(config.FailureCountUntilSkip), + TransactionDuration: config.TransactionDuration, + ActiveInstancer: config.ActiveInstancer, } overwrite, ok := config.Handlers[viewModel] if !ok { diff --git a/internal/admin/repository/eventsourcing/repository.go b/internal/admin/repository/eventsourcing/repository.go index f9ba285a82..f6209391c6 100644 --- a/internal/admin/repository/eventsourcing/repository.go +++ b/internal/admin/repository/eventsourcing/repository.go @@ -6,6 +6,7 @@ import ( admin_handler "github.com/zitadel/zitadel/internal/admin/repository/eventsourcing/handler" admin_view "github.com/zitadel/zitadel/internal/admin/repository/eventsourcing/view" "github.com/zitadel/zitadel/internal/database" + "github.com/zitadel/zitadel/internal/query" "github.com/zitadel/zitadel/internal/static" ) @@ -13,7 +14,7 @@ type Config struct { Spooler admin_handler.Config } -func Start(ctx context.Context, conf Config, static static.Storage, dbClient *database.DB) error { +func Start(ctx context.Context, conf Config, static static.Storage, dbClient *database.DB, queries *query.Queries) error { view, err := admin_view.StartView(dbClient) if err != nil { return err diff --git a/internal/auth/repository/eventsourcing/handler/handler.go b/internal/auth/repository/eventsourcing/handler/handler.go index 557890265f..0d87ab06bb 100644 --- a/internal/auth/repository/eventsourcing/handler/handler.go +++ b/internal/auth/repository/eventsourcing/handler/handler.go @@ -19,9 +19,12 @@ type Config struct { BulkLimit uint64 FailureCountUntilSkip uint64 - HandleActiveInstances time.Duration TransactionDuration time.Duration Handlers map[string]*ConfigOverwrites + + ActiveInstancer interface { + ActiveInstances() []string + } } type ConfigOverwrites struct { @@ -31,6 +34,9 @@ type ConfigOverwrites struct { var projections []*handler.Handler func Register(ctx context.Context, configs Config, view *view.View, queries *query2.Queries) { + // make sure the slice does not contain old values + projections = nil + projections = append(projections, newUser(ctx, configs.overwrite("User"), view, @@ -77,13 +83,13 @@ func ProjectInstance(ctx context.Context) error { func (config Config) overwrite(viewModel string) handler2.Config { c := handler2.Config{ - Client: config.Client, - Eventstore: config.Eventstore, - BulkLimit: uint16(config.BulkLimit), - RequeueEvery: 3 * time.Minute, - HandleActiveInstances: config.HandleActiveInstances, - MaxFailureCount: uint8(config.FailureCountUntilSkip), - TransactionDuration: config.TransactionDuration, + Client: config.Client, + Eventstore: config.Eventstore, + BulkLimit: uint16(config.BulkLimit), + RequeueEvery: 3 * time.Minute, + MaxFailureCount: uint8(config.FailureCountUntilSkip), + TransactionDuration: config.TransactionDuration, + ActiveInstancer: config.ActiveInstancer, } overwrite, ok := config.Handlers[viewModel] if !ok { diff --git a/internal/eventstore/eventstore.go b/internal/eventstore/eventstore.go index 4f331c1852..4954df86c8 100644 --- a/internal/eventstore/eventstore.go +++ b/internal/eventstore/eventstore.go @@ -4,7 +4,6 @@ import ( "context" "errors" "sort" - "sync" "time" "github.com/jackc/pgx/v5/pgconn" @@ -24,10 +23,6 @@ type Eventstore struct { pusher Pusher querier Querier searcher Searcher - - instances []string - lastInstanceQuery time.Time - instancesMu sync.Mutex } var ( @@ -68,8 +63,6 @@ func NewEventstore(config *Config) *Eventstore { pusher: config.Pusher, querier: config.Querier, searcher: config.Searcher, - - instancesMu: sync.Mutex{}, } } @@ -243,27 +236,10 @@ func (es *Eventstore) LatestSequence(ctx context.Context, queryFactory *SearchQu return es.querier.LatestSequence(ctx, queryFactory) } -// InstanceIDs returns the instance ids found by the search query -// forceDBCall forces to query the database, the instance ids are not cached -func (es *Eventstore) InstanceIDs(ctx context.Context, maxAge time.Duration, forceDBCall bool, queryFactory *SearchQueryBuilder) ([]string, error) { - es.instancesMu.Lock() - defer es.instancesMu.Unlock() - - if !forceDBCall && time.Since(es.lastInstanceQuery) <= maxAge { - return es.instances, nil - } - - instances, err := es.querier.InstanceIDs(ctx, queryFactory) - if err != nil { - return nil, err - } - - if !forceDBCall { - es.instances = instances - es.lastInstanceQuery = time.Now() - } - - return instances, nil +// InstanceIDs returns the distinct instance ids found by the search query +// Warning: this function can have high impact on performance, only use this function during setup +func (es *Eventstore) InstanceIDs(ctx context.Context, queryFactory *SearchQueryBuilder) ([]string, error) { + return es.querier.InstanceIDs(ctx, queryFactory) } func (es *Eventstore) Client() *database.DB { diff --git a/internal/eventstore/handler/v2/field_handler.go b/internal/eventstore/handler/v2/field_handler.go index 8b71f32519..bbe40ed465 100644 --- a/internal/eventstore/handler/v2/field_handler.go +++ b/internal/eventstore/handler/v2/field_handler.go @@ -41,7 +41,6 @@ func NewFieldHandler(config *Config, name string, eventTypes map[eventstore.Aggr bulkLimit: config.BulkLimit, eventTypes: eventTypes, requeueEvery: config.RequeueEvery, - handleActiveInstances: config.HandleActiveInstances, now: time.Now, maxFailureCount: config.MaxFailureCount, retryFailedAfter: config.RetryFailedAfter, diff --git a/internal/eventstore/handler/v2/handler.go b/internal/eventstore/handler/v2/handler.go index c2e2b2a355..2c2f88f8a0 100644 --- a/internal/eventstore/handler/v2/handler.go +++ b/internal/eventstore/handler/v2/handler.go @@ -23,7 +23,7 @@ import ( ) type EventStore interface { - InstanceIDs(ctx context.Context, maxAge time.Duration, forceLoad bool, query *eventstore.SearchQueryBuilder) ([]string, error) + InstanceIDs(ctx context.Context, query *eventstore.SearchQueryBuilder) ([]string, error) FilterToQueryReducer(ctx context.Context, reducer eventstore.QueryReducer) error Filter(ctx context.Context, queryFactory *eventstore.SearchQueryBuilder) ([]eventstore.Event, error) Push(ctx context.Context, cmds ...eventstore.Command) ([]eventstore.Event, error) @@ -34,14 +34,17 @@ type Config struct { Client *database.DB Eventstore EventStore - BulkLimit uint16 - RequeueEvery time.Duration - RetryFailedAfter time.Duration - HandleActiveInstances time.Duration - TransactionDuration time.Duration - MaxFailureCount uint8 + BulkLimit uint16 + RequeueEvery time.Duration + RetryFailedAfter time.Duration + TransactionDuration time.Duration + MaxFailureCount uint8 TriggerWithoutEvents Reduce + + ActiveInstancer interface { + ActiveInstances() []string + } } type Handler struct { @@ -52,17 +55,18 @@ type Handler struct { bulkLimit uint16 eventTypes map[eventstore.AggregateType][]eventstore.EventType - maxFailureCount uint8 - retryFailedAfter time.Duration - requeueEvery time.Duration - handleActiveInstances time.Duration - txDuration time.Duration - now nowFunc + maxFailureCount uint8 + retryFailedAfter time.Duration + requeueEvery time.Duration + txDuration time.Duration + now nowFunc triggeredInstancesSync sync.Map triggerWithoutEvents Reduce cacheInvalidations []func(ctx context.Context, aggregates []*eventstore.Aggregate) + + queryInstances func() ([]string, error) } var _ migration.Migration = (*Handler)(nil) @@ -162,13 +166,18 @@ func NewHandler( bulkLimit: config.BulkLimit, eventTypes: aggregates, requeueEvery: config.RequeueEvery, - handleActiveInstances: config.HandleActiveInstances, now: time.Now, maxFailureCount: config.MaxFailureCount, retryFailedAfter: config.RetryFailedAfter, triggeredInstancesSync: sync.Map{}, triggerWithoutEvents: config.TriggerWithoutEvents, txDuration: config.TransactionDuration, + queryInstances: func() ([]string, error) { + if config.ActiveInstancer != nil { + return config.ActiveInstancer.ActiveInstances(), nil + } + return nil, nil + }, } return handler @@ -239,7 +248,7 @@ func (h *Handler) schedule(ctx context.Context) { t.Stop() return case <-t.C: - instances, err := h.queryInstances(ctx) + instances, err := h.queryInstances() h.log().OnError(err).Debug("unable to query instances") h.triggerInstances(call.WithTimestamp(ctx), instances) @@ -356,19 +365,6 @@ func (*existingInstances) Reduce() error { var _ eventstore.QueryReducer = (*existingInstances)(nil) -func (h *Handler) queryInstances(ctx context.Context) ([]string, error) { - if h.handleActiveInstances == 0 { - return h.existingInstances(ctx) - } - - query := eventstore.NewSearchQueryBuilder(eventstore.ColumnsInstanceIDs). - AwaitOpenTransactions(). - AllowTimeTravel(). - CreationDateAfter(h.now().Add(-1 * h.handleActiveInstances)) - - return h.es.InstanceIDs(ctx, h.requeueEvery, false, query) -} - func (h *Handler) existingInstances(ctx context.Context) ([]string, error) { ai := existingInstances{} if err := h.es.FilterToQueryReducer(ctx, &ai); err != nil { diff --git a/internal/eventstore/handler/v2/mock_test.go b/internal/eventstore/handler/v2/mock_test.go index ebd49659f1..4dd0d68861 100644 --- a/internal/eventstore/handler/v2/mock_test.go +++ b/internal/eventstore/handler/v2/mock_test.go @@ -7,12 +7,17 @@ type projection struct { reducers []AggregateReducer } -// Name implements Projection +// ActiveInstances implements [Projection] +func (p *projection) ActiveInstances() []string { + return nil +} + +// Name implements [Projection] func (p *projection) Name() string { return p.name } -// Reducers implements Projection +// Reducers implements [Projection] func (p *projection) Reducers() []AggregateReducer { return p.reducers } diff --git a/internal/notification/handlers/mock/queries.mock.go b/internal/notification/handlers/mock/queries.mock.go index 5669444d4f..5ead216437 100644 --- a/internal/notification/handlers/mock/queries.mock.go +++ b/internal/notification/handlers/mock/queries.mock.go @@ -46,6 +46,20 @@ func (m *MockQueries) EXPECT() *MockQueriesMockRecorder { return m.recorder } +// ActiveInstances mocks base method. +func (m *MockQueries) ActiveInstances() []string { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "ActiveInstances") + ret0, _ := ret[0].([]string) + return ret0 +} + +// ActiveInstances indicates an expected call of ActiveInstances. +func (mr *MockQueriesMockRecorder) ActiveInstances() *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ActiveInstances", reflect.TypeOf((*MockQueries)(nil).ActiveInstances)) +} + // ActiveLabelPolicyByOrg mocks base method. func (m *MockQueries) ActiveLabelPolicyByOrg(ctx context.Context, orgID string, withOwnerRemoved bool) (*query.LabelPolicy, error) { m.ctrl.T.Helper() diff --git a/internal/notification/handlers/notification_worker.go b/internal/notification/handlers/notification_worker.go index 8ee32c7080..dbf11a72fd 100644 --- a/internal/notification/handlers/notification_worker.go +++ b/internal/notification/handlers/notification_worker.go @@ -43,19 +43,18 @@ type NotificationWorker struct { } type WorkerConfig struct { - LegacyEnabled bool - Workers uint8 - BulkLimit uint16 - RequeueEvery time.Duration - RetryWorkers uint8 - RetryRequeueEvery time.Duration - HandleActiveInstances time.Duration - TransactionDuration time.Duration - MaxAttempts uint8 - MaxTtl time.Duration - MinRetryDelay time.Duration - MaxRetryDelay time.Duration - RetryDelayFactor float32 + LegacyEnabled bool + Workers uint8 + BulkLimit uint16 + RequeueEvery time.Duration + RetryWorkers uint8 + RetryRequeueEvery time.Duration + TransactionDuration time.Duration + MaxAttempts uint8 + MaxTtl time.Duration + MinRetryDelay time.Duration + MaxRetryDelay time.Duration + RetryDelayFactor float32 } // nowFunc makes [time.Now] mockable @@ -312,29 +311,7 @@ func (w *NotificationWorker) log(workerID int, retry bool) *logging.Entry { } func (w *NotificationWorker) queryInstances(ctx context.Context, retry bool) ([]string, error) { - if w.config.HandleActiveInstances == 0 { - return w.existingInstances(ctx) - } - - query := eventstore.NewSearchQueryBuilder(eventstore.ColumnsInstanceIDs). - AwaitOpenTransactions(). - AllowTimeTravel(). - CreationDateAfter(w.now().Add(-1 * w.config.HandleActiveInstances)) - - maxAge := w.config.RequeueEvery - if retry { - maxAge = w.config.RetryRequeueEvery - } - return w.es.InstanceIDs(ctx, maxAge, false, query) -} - -func (w *NotificationWorker) existingInstances(ctx context.Context) ([]string, error) { - ai := existingInstances{} - if err := w.es.FilterToQueryReducer(ctx, &ai); err != nil { - return nil, err - } - - return ai, nil + return w.queries.ActiveInstances(), nil } func (w *NotificationWorker) triggerInstances(ctx context.Context, instances []string, workerID int, retry bool) { diff --git a/internal/notification/handlers/notification_worker_test.go b/internal/notification/handlers/notification_worker_test.go index 4ffd33005b..40b3197d37 100644 --- a/internal/notification/handlers/notification_worker_test.go +++ b/internal/notification/handlers/notification_worker_test.go @@ -877,16 +877,15 @@ func newNotificationWorker(t *testing.T, ctrl *gomock.Controller, queries *mock. }, }, config: WorkerConfig{ - Workers: 1, - BulkLimit: 10, - RequeueEvery: 2 * time.Second, - HandleActiveInstances: 0, - TransactionDuration: 5 * time.Second, - MaxAttempts: f.maxAttempts, - MaxTtl: 5 * time.Minute, - MinRetryDelay: 1 * time.Second, - MaxRetryDelay: 10 * time.Second, - RetryDelayFactor: 2, + Workers: 1, + BulkLimit: 10, + RequeueEvery: 2 * time.Second, + TransactionDuration: 5 * time.Second, + MaxAttempts: f.maxAttempts, + MaxTtl: 5 * time.Minute, + MinRetryDelay: 1 * time.Second, + MaxRetryDelay: 10 * time.Second, + RetryDelayFactor: 2, }, now: f.now, backOff: f.backOff, diff --git a/internal/notification/handlers/queries.go b/internal/notification/handlers/queries.go index 1c00460531..1c8d37598e 100644 --- a/internal/notification/handlers/queries.go +++ b/internal/notification/handlers/queries.go @@ -31,6 +31,8 @@ type Queries interface { InstanceByID(ctx context.Context, id string) (instance authz.Instance, err error) GetActiveSigningWebKey(ctx context.Context) (*jose.JSONWebKey, error) ActivePrivateSigningKey(ctx context.Context, t time.Time) (keys *query.PrivateKeys, err error) + + ActiveInstances() []string } type NotificationQueries struct { diff --git a/internal/query/cache.go b/internal/query/cache.go index 949e121c1f..7c60eca702 100644 --- a/internal/query/cache.go +++ b/internal/query/cache.go @@ -2,7 +2,9 @@ package query import ( "context" + "time" + "github.com/hashicorp/golang-lru/v2/expirable" "github.com/zitadel/logging" "github.com/zitadel/zitadel/internal/cache" @@ -13,9 +15,16 @@ import ( type Caches struct { instance cache.Cache[instanceIndex, string, *authzInstance] org cache.Cache[orgIndex, string, *Org] + + activeInstances *expirable.LRU[string, bool] } -func startCaches(background context.Context, connectors connector.Connectors) (_ *Caches, err error) { +type ActiveInstanceConfig struct { + MaxEntries int + TTL time.Duration +} + +func startCaches(background context.Context, connectors connector.Connectors, instanceConfig ActiveInstanceConfig) (_ *Caches, err error) { caches := new(Caches) caches.instance, err = connector.StartCache[instanceIndex, string, *authzInstance](background, instanceIndexValues(), cache.PurposeAuthzInstance, connectors.Config.Instance, connectors) if err != nil { @@ -26,6 +35,8 @@ func startCaches(background context.Context, connectors connector.Connectors) (_ return nil, err } + caches.activeInstances = expirable.NewLRU[string, bool](instanceConfig.MaxEntries, nil, instanceConfig.TTL) + caches.registerInstanceInvalidation() caches.registerOrgInvalidation() return caches, nil diff --git a/internal/query/instance.go b/internal/query/instance.go index 8dd0db7d89..d7d66b1607 100644 --- a/internal/query/instance.go +++ b/internal/query/instance.go @@ -143,6 +143,10 @@ func (q *InstanceSearchQueries) toQuery(query sq.SelectBuilder) sq.SelectBuilder return query } +func (q *Queries) ActiveInstances() []string { + return q.caches.activeInstances.Keys() +} + func (q *Queries) SearchInstances(ctx context.Context, queries *InstanceSearchQueries) (instances *Instances, err error) { ctx, span := tracing.NewSpan(ctx) defer func() { span.EndWithError(err) }() @@ -198,10 +202,13 @@ var ( ) func (q *Queries) InstanceByHost(ctx context.Context, instanceHost, publicHost string) (_ authz.Instance, err error) { + var instance *authzInstance ctx, span := tracing.NewSpan(ctx) defer func() { if err != nil { err = fmt.Errorf("unable to get instance by host: instanceHost %s, publicHost %s: %w", instanceHost, publicHost, err) + } else { + q.caches.activeInstances.Add(instance.ID, true) } span.EndWithError(err) }() @@ -225,6 +232,12 @@ func (q *Queries) InstanceByHost(ctx context.Context, instanceHost, publicHost s func (q *Queries) InstanceByID(ctx context.Context, id string) (_ authz.Instance, err error) { ctx, span := tracing.NewSpan(ctx) defer func() { span.EndWithError(err) }() + defer func() { + if err != nil { + return + } + q.caches.activeInstances.Add(id, true) + }() instance, ok := q.caches.instance.Get(ctx, instanceIndexByID, id) if ok { diff --git a/internal/query/org.go b/internal/query/org.go index a57867d92b..e5bfc5140f 100644 --- a/internal/query/org.go +++ b/internal/query/org.go @@ -517,6 +517,6 @@ func (o *Org) Keys(index orgIndex) []string { } func (c *Caches) registerOrgInvalidation() { - invalidate := cacheInvalidationFunc(c.instance, instanceIndexByID, getAggregateID) + invalidate := cacheInvalidationFunc(c.org, orgIndexByID, getAggregateID) projection.OrgProjection.RegisterCacheInvalidation(invalidate) } diff --git a/internal/query/projection/config.go b/internal/query/projection/config.go index d1a79f1c20..085f5a60cf 100644 --- a/internal/query/projection/config.go +++ b/internal/query/projection/config.go @@ -12,15 +12,18 @@ type Config struct { BulkLimit uint64 Customizations map[string]CustomConfig HandleActiveInstances time.Duration + MaxActiveInstances uint32 TransactionDuration time.Duration + ActiveInstancer interface { + ActiveInstances() []string + } } type CustomConfig struct { - RequeueEvery *time.Duration - RetryFailedAfter *time.Duration - MaxFailureCount *uint8 - ConcurrentInstances *uint - BulkLimit *uint16 - HandleActiveInstances *time.Duration - TransactionDuration *time.Duration + RequeueEvery *time.Duration + RetryFailedAfter *time.Duration + MaxFailureCount *uint8 + ConcurrentInstances *uint + BulkLimit *uint16 + TransactionDuration *time.Duration } diff --git a/internal/query/projection/eventstore_mock_test.go b/internal/query/projection/eventstore_mock_test.go index 202664d517..4e280dce21 100644 --- a/internal/query/projection/eventstore_mock_test.go +++ b/internal/query/projection/eventstore_mock_test.go @@ -2,7 +2,6 @@ package projection import ( "context" - "time" "github.com/zitadel/zitadel/internal/eventstore" "github.com/zitadel/zitadel/internal/eventstore/handler/v2" @@ -28,7 +27,7 @@ func (m *mockEventStore) appendFilterResponse(events []eventstore.Event) *mockEv return m } -func (m *mockEventStore) InstanceIDs(ctx context.Context, _ time.Duration, _ bool, query *eventstore.SearchQueryBuilder) ([]string, error) { +func (m *mockEventStore) InstanceIDs(ctx context.Context, query *eventstore.SearchQueryBuilder) ([]string, error) { m.instanceIDCounter++ return m.instanceIDsResponse[m.instanceIDCounter-1], nil } diff --git a/internal/query/projection/projection.go b/internal/query/projection/projection.go index 78ca59bc3a..30dd46a3c6 100644 --- a/internal/query/projection/projection.go +++ b/internal/query/projection/projection.go @@ -99,14 +99,14 @@ var ( func Create(ctx context.Context, sqlClient *database.DB, es handler.EventStore, config Config, keyEncryptionAlgorithm crypto.EncryptionAlgorithm, certEncryptionAlgorithm crypto.EncryptionAlgorithm, systemUsers map[string]*internal_authz.SystemAPIUser) error { projectionConfig = handler.Config{ - Client: sqlClient, - Eventstore: es, - BulkLimit: uint16(config.BulkLimit), - RequeueEvery: config.RequeueEvery, - HandleActiveInstances: config.HandleActiveInstances, - MaxFailureCount: config.MaxFailureCount, - RetryFailedAfter: config.RetryFailedAfter, - TransactionDuration: config.TransactionDuration, + Client: sqlClient, + Eventstore: es, + BulkLimit: uint16(config.BulkLimit), + RequeueEvery: config.RequeueEvery, + MaxFailureCount: config.MaxFailureCount, + RetryFailedAfter: config.RetryFailedAfter, + TransactionDuration: config.TransactionDuration, + ActiveInstancer: config.ActiveInstancer, } OrgProjection = newOrgProjection(ctx, applyCustomConfig(projectionConfig, config.Customizations["orgs"])) @@ -223,9 +223,6 @@ func applyCustomConfig(config handler.Config, customConfig CustomConfig) handler if customConfig.RetryFailedAfter != nil { config.RetryFailedAfter = *customConfig.RetryFailedAfter } - if customConfig.HandleActiveInstances != nil { - config.HandleActiveInstances = *customConfig.HandleActiveInstances - } if customConfig.TransactionDuration != nil { config.TransactionDuration = *customConfig.TransactionDuration } diff --git a/internal/query/query.go b/internal/query/query.go index 5fd06d5643..0a90e9e4f9 100644 --- a/internal/query/query.go +++ b/internal/query/query.go @@ -84,6 +84,7 @@ func StartQueries( repo.checkPermission = permissionCheck(repo) + projections.ActiveInstancer = repo err = projection.Create(ctx, projectionSqlClient, es, projections, keyEncryptionAlgorithm, certEncryptionAlgorithm, systemAPIUsers) if err != nil { return nil, err @@ -91,7 +92,15 @@ func StartQueries( if startProjections { projection.Start(ctx) } - repo.caches, err = startCaches(ctx, cacheConnectors) + + repo.caches, err = startCaches( + ctx, + cacheConnectors, + ActiveInstanceConfig{ + MaxEntries: int(projections.MaxActiveInstances), + TTL: projections.HandleActiveInstances, + }, + ) if err != nil { return nil, err } From 5c3e9172480e6bc2e062eb9a969e6bad5213962a Mon Sep 17 00:00:00 2001 From: Livio Spring Date: Mon, 9 Dec 2024 08:29:13 +0100 Subject: [PATCH 54/64] chore: remove stable release tag (#8885) # Which Problems Are Solved The current "stable" release tag was no longer maintained. # How the Problems Are Solved Remove the tag from the docs. # Additional Changes Update the docs to reflect that test run with Ubuntu 22.04 instead of 20.04. # Additional Context - relates to https://github.com/zitadel/zitadel/issues/8884 --- .github/workflows/release-channels.yml | 47 ------------------- README.md | 2 - docs/docs/self-hosting/deploy/knative.mdx | 2 +- docs/docs/self-hosting/deploy/linux.mdx | 2 +- .../loadbalancing-example/docker-compose.yaml | 2 +- docs/docs/self-hosting/deploy/macos.mdx | 2 +- .../manage/configure/docker-compose.yaml | 2 +- docs/docs/support/advisory/a10013.md | 25 ++++++++++ .../software-release-cycles-support.md | 15 +----- docs/docs/support/technical_advisory.mdx | 24 ++++++++++ release-channels.yaml | 1 - 11 files changed, 55 insertions(+), 69 deletions(-) delete mode 100644 .github/workflows/release-channels.yml create mode 100644 docs/docs/support/advisory/a10013.md delete mode 100644 release-channels.yaml diff --git a/.github/workflows/release-channels.yml b/.github/workflows/release-channels.yml deleted file mode 100644 index bc5a184bf2..0000000000 --- a/.github/workflows/release-channels.yml +++ /dev/null @@ -1,47 +0,0 @@ -name: ZITADEL Release tags - -on: - push: - branches: - - "main" - paths: - - 'release-channels.yaml' - workflow_dispatch: - -permissions: - contents: write - packages: write - -jobs: - Build: - runs-on: ubuntu-20.04 - env: - DOCKER_BUILDKIT: 1 - steps: - - name: Source checkout - uses: actions/checkout@v4 - with: - fetch-depth: 0 - - name: get stable tag - run: echo STABLE_RELEASE=$(yq eval '.stable' release-channels.yaml) >> $GITHUB_ENV - - name: checkout stable tag - uses: actions/checkout@v4 - with: - fetch-depth: 0 - ref: ${{ env.STABLE_RELEASE }} - - name: GitHub Container Registry Login - uses: docker/login-action@v3 - with: - registry: ghcr.io - username: ${{ github.actor }} - password: ${{ secrets.GITHUB_TOKEN }} - - name: Google Artifact Registry Login - uses: docker/login-action@v3 - with: - registry: europe-docker.pkg.dev - username: _json_key_base64 - password: ${{ secrets.GCR_JSON_KEY_BASE64 }} - - name: copy release to stable - run: | - skopeo --version - skopeo copy --all docker://ghcr.io/zitadel/zitadel:$STABLE_RELEASE docker://ghcr.io/zitadel/zitadel:stable diff --git a/README.md b/README.md index 17306129c4..f2f4d9a772 100644 --- a/README.md +++ b/README.md @@ -13,8 +13,6 @@ GitHub Workflow Status (with event) - - Dynamic YAML Badge diff --git a/docs/docs/self-hosting/deploy/knative.mdx b/docs/docs/self-hosting/deploy/knative.mdx index 0613e7f7e2..b26c7189bd 100644 --- a/docs/docs/self-hosting/deploy/knative.mdx +++ b/docs/docs/self-hosting/deploy/knative.mdx @@ -27,7 +27,7 @@ kubectl apply -f https://raw.githubusercontent.com/zitadel/zitadel/main/deploy/k ```bash kn service create zitadel \ ---image ghcr.io/zitadel/zitadel:stable \ +--image ghcr.io/zitadel/zitadel:latest \ --port 8080 \ --env ZITADEL_DATABASE_COCKROACH_HOST=cockroachdb \ --env ZITADEL_EXTERNALSECURE=false \ diff --git a/docs/docs/self-hosting/deploy/linux.mdx b/docs/docs/self-hosting/deploy/linux.mdx index 359bf26c69..eb7f4dc90d 100644 --- a/docs/docs/self-hosting/deploy/linux.mdx +++ b/docs/docs/self-hosting/deploy/linux.mdx @@ -11,7 +11,7 @@ import NoteInstanceNotFound from "./troubleshooting/_note_instance_not_found.mdx ## Install PostgreSQL Download a `postgresql` binary as described [in the PostgreSQL docs](https://www.postgresql.org/download/linux/). -ZITADEL is tested against PostgreSQL and CockroachDB latest stable tag and Ubuntu 20.04. +ZITADEL is tested against PostgreSQL and CockroachDB latest stable tag and Ubuntu 22.04. ## Run PostgreSQL diff --git a/docs/docs/self-hosting/deploy/loadbalancing-example/docker-compose.yaml b/docs/docs/self-hosting/deploy/loadbalancing-example/docker-compose.yaml index 4c455621fb..2b9266c798 100644 --- a/docs/docs/self-hosting/deploy/loadbalancing-example/docker-compose.yaml +++ b/docs/docs/self-hosting/deploy/loadbalancing-example/docker-compose.yaml @@ -15,7 +15,7 @@ services: restart: 'always' networks: - 'zitadel' - image: 'ghcr.io/zitadel/zitadel:stable' + image: 'ghcr.io/zitadel/zitadel:latest' command: 'start-from-init --config /example-zitadel-config.yaml --config /example-zitadel-secrets.yaml --steps /example-zitadel-init-steps.yaml --masterkey "${ZITADEL_MASTERKEY}" --tlsMode external' depends_on: db: diff --git a/docs/docs/self-hosting/deploy/macos.mdx b/docs/docs/self-hosting/deploy/macos.mdx index a9673ab8b3..f736255478 100644 --- a/docs/docs/self-hosting/deploy/macos.mdx +++ b/docs/docs/self-hosting/deploy/macos.mdx @@ -11,7 +11,7 @@ import NoteInstanceNotFound from './troubleshooting/_note_instance_not_found.mdx ## Install PostgreSQL Download a `postgresql` binary as described [in the PostgreSQL docs](https://www.postgresql.org/download/macosx/). -ZITADEL is tested against PostgreSQL and CockroachDB latest stable tag and Ubuntu 20.04. +ZITADEL is tested against PostgreSQL and CockroachDB latest stable tag and Ubuntu 22.04. ## Run PostgreSQL diff --git a/docs/docs/self-hosting/manage/configure/docker-compose.yaml b/docs/docs/self-hosting/manage/configure/docker-compose.yaml index 69ebdd9093..8e5c9fbc05 100644 --- a/docs/docs/self-hosting/manage/configure/docker-compose.yaml +++ b/docs/docs/self-hosting/manage/configure/docker-compose.yaml @@ -5,7 +5,7 @@ services: restart: "always" networks: - "zitadel" - image: "ghcr.io/zitadel/zitadel:stable" + image: "ghcr.io/zitadel/zitadel:latest" command: 'start-from-init --config /example-zitadel-config.yaml --config /example-zitadel-secrets.yaml --steps /example-zitadel-init-steps.yaml --masterkey "${ZITADEL_MASTERKEY}" --tlsMode disabled' ports: - "8080:8080" diff --git a/docs/docs/support/advisory/a10013.md b/docs/docs/support/advisory/a10013.md new file mode 100644 index 0000000000..f95d1d62fc --- /dev/null +++ b/docs/docs/support/advisory/a10013.md @@ -0,0 +1,25 @@ +--- +title: Technical Advisory 10013 +--- + +## Date + +Date: 2024-12-09 + +## Description + +ZITADEL currently provides a "latest" and "stable" release tags as well as maintenance branches, where bug fixes are backported. +We also publish release candidates regularly. + +The "stable" release channel was introduced for users seeking a more reliable and production-ready version of the software. +However, most customers have their own deployment policies and cycles. +Backports and security fixes are currently done as needed or required by customers. +zitadel.cloud follows a similar approach, where the latest release is deployed a few days after its creation. + +## Mitigation + +If you used the "stable" Docker release, please consider switching to a specific version tag and follow the [release notes on GitHub](https://github.com/zitadel/zitadel/releases) for latest changes. + +## Impact + +The "stable" version will no longer be published or updated, and the corresponding Docker image tag will not be maintained anymore. diff --git a/docs/docs/support/software-release-cycles-support.md b/docs/docs/support/software-release-cycles-support.md index 6e6e094c10..4a9484dd27 100644 --- a/docs/docs/support/software-release-cycles-support.md +++ b/docs/docs/support/software-release-cycles-support.md @@ -82,7 +82,7 @@ Features in General Availability are not marked explicitly. All release channels receive regular updates and bug fixes. However, the timing and frequency of updates may differ between the channels. -The choice between the "release candidate", "latest" and "stable" release channels depends on the specific requirements, preferences, and risk tolerance of the users. +The choice between the "release candidate", "latest" and stable release channels depends on the specific requirements, preferences, and risk tolerance of the users. [List of all releases](https://github.com/zitadel/zitadel/releases) @@ -100,19 +100,6 @@ The "latest" release channel is designed for users who prefer to access the most It provides early access to new functionalities and improvements but may involve a higher degree of risk as it is the most actively developed version. Users opting for the latest release channel should be aware that occasional bugs or issues may arise due to the ongoing development process. -### Stable - -The "stable" release channel is intended for users seeking a more reliable and production-ready version of the software. -It offers a well-tested and validated release with fewer known issues and a higher level of stability. -The stable release channel undergoes rigorous quality assurance and testing processes to ensure that it meets the highest standards of reliability and performance. -It is recommended for users who prioritize stability over immediate access to the latest features. - -Current Stable Version: - -```yaml reference -https://github.com/zitadel/zitadel/blob/main/release-channels.yaml -``` - ## Maintenance ZITADEL Cloud follows a regular deployment cycle to ensure our product remains up-to-date, secure, and provides new features. diff --git a/docs/docs/support/technical_advisory.mdx b/docs/docs/support/technical_advisory.mdx index 6e5c6ac519..7562ff3870 100644 --- a/docs/docs/support/technical_advisory.mdx +++ b/docs/docs/support/technical_advisory.mdx @@ -190,6 +190,30 @@ We understand that these advisories may include breaking changes, and we aim to 2.59.0 2024-08-19 + + + A-10012 + + Tncreased transaction duration for projections + Breaking Behavior Change + + In version 2.63.0 we've increased the transaction duration for projections to resolve outdated projections or dead-locks. + + 2.63.0 + 2024-09-26 + + + + A-10013 + + Deprecation of "stable" version + Breaking Behavior Change + + The "stable" version will no longer be published or updated, and the corresponding Docker image tag will not be maintained anymore. + + - + 2024-12-09 + ## Subscribe to our Mailing List diff --git a/release-channels.yaml b/release-channels.yaml deleted file mode 100644 index 4d2b5bf892..0000000000 --- a/release-channels.yaml +++ /dev/null @@ -1 +0,0 @@ -stable: "v2.54.8" From ee7beca61f6d46210158565a7281ac29819994c7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tim=20M=C3=B6hlmann?= Date: Mon, 9 Dec 2024 10:20:21 +0200 Subject: [PATCH 55/64] fix(cache): ignore NOSCRIPT errors in redis circuit breaker (#9022) # Which Problems Are Solved When Zitadel starts the first time with a configured Redis cache, the circuit break would open on the first requests, with no explanatory error and only log-lines explaining the state of the Circuit breaker. Using a debugger, `NOSCRIPT No matching script. Please use EVAL.` was found the be passed to `Limiter.ReportResult`. This error is actually retried by go-redis after a [`Script.Run`](https://pkg.go.dev/github.com/redis/go-redis/v9@v9.7.0#Script.Run): > Run optimistically uses EVALSHA to run the script. If script does not exist it is retried using EVAL. # How the Problems Are Solved Add the `NOSCRIPT` error prefix to the whitelist. # Additional Changes - none # Additional Context - Introduced in: https://github.com/zitadel/zitadel/pull/8890 - Workaround for: https://github.com/redis/go-redis/issues/3203 --- internal/cache/connector/redis/circuit_breaker.go | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/internal/cache/connector/redis/circuit_breaker.go b/internal/cache/connector/redis/circuit_breaker.go index 1e06b7387e..fd556b52b0 100644 --- a/internal/cache/connector/redis/circuit_breaker.go +++ b/internal/cache/connector/redis/circuit_breaker.go @@ -86,5 +86,6 @@ func (l *limiter) ReportResult(err error) { done := <-l.inflight done(err == nil || errors.Is(err, redis.Nil) || - errors.Is(err, context.Canceled)) + errors.Is(err, context.Canceled) || + redis.HasErrorPrefix(err, "NOSCRIPT")) } From 83bdaf43c35b7c4c5dfaf3e822a917e95d9f5fae Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tim=20M=C3=B6hlmann?= Date: Tue, 10 Dec 2024 12:54:07 +0200 Subject: [PATCH 56/64] docs(events-api): user auth example using OIDC session events (#9020) # Which Problems Are Solved Integration guide with event API examples used outdated `user.token.added` events which are no longer produced by ZITADEL. # How the Problems Are Solved Modify the example to use events from the `oidc_session` aggregate. # Additional Changes - Add a TODO for related SAML events. # Additional Context - Related to https://github.com/zitadel/zitadel/issues/8983 --- .../integrate/zitadel-apis/event-api.md | 29 ++++++++++++++----- 1 file changed, 22 insertions(+), 7 deletions(-) diff --git a/docs/docs/guides/integrate/zitadel-apis/event-api.md b/docs/docs/guides/integrate/zitadel-apis/event-api.md index ed35aa1c8e..c79cb27e8e 100644 --- a/docs/docs/guides/integrate/zitadel-apis/event-api.md +++ b/docs/docs/guides/integrate/zitadel-apis/event-api.md @@ -114,10 +114,13 @@ curl --request POST \ }' ``` -## Example: Find out when user have been authenticated +## Example: Find out which users have authenticated -The following example shows you how you could use the events search to get all events where a token has been created. -Also we include the refresh tokens in this example to know when the user has become a new token. +### OIDC session + +The following example shows you how you could use the events search to get all events where a user has authenticated using OIDC. +Also we include the refresh tokens in this example to know when the user has received a new token. +Sessions without tokens events may by created during implicit flow with ID Token only, which do not create an access token. ```bash curl --request POST \ @@ -127,13 +130,25 @@ curl --request POST \ --data '{ "asc": true, "limit": 1000, - "event_types": [ - "user.token.added", - "user.refresh.token.added" - ] + "eventTypes": [ + "oidc_session.added", + "oidc_session.access_token.added", + "oidc_session.refresh_token.added", + "oidc_session.refresh_token.renewed" + ], + "aggregateTypes": [ + "oidc_session" + ] }' ``` + + ## Example: Get failed login attempt From e130467b7a58658b49af88a306041f895089c987 Mon Sep 17 00:00:00 2001 From: Silvan <27845747+adlerhurst@users.noreply.github.com> Date: Wed, 11 Dec 2024 11:14:33 +0100 Subject: [PATCH 57/64] docs(benchmarks): add v2.66.0 (#9038) # Which Problems Are Solved Add benchmarks for v2.66.0 --- .../machine_jwt_profile_grant/index.mdx | 104 + .../machine_jwt_profile_grant/output.json | 1803 +++++++++++++++++ docs/sidebars.js | 13 + 3 files changed, 1920 insertions(+) create mode 100644 docs/docs/apis/benchmarks/v2.66.0/machine_jwt_profile_grant/index.mdx create mode 100644 docs/docs/apis/benchmarks/v2.66.0/machine_jwt_profile_grant/output.json diff --git a/docs/docs/apis/benchmarks/v2.66.0/machine_jwt_profile_grant/index.mdx b/docs/docs/apis/benchmarks/v2.66.0/machine_jwt_profile_grant/index.mdx new file mode 100644 index 0000000000..50dd7dc7ec --- /dev/null +++ b/docs/docs/apis/benchmarks/v2.66.0/machine_jwt_profile_grant/index.mdx @@ -0,0 +1,104 @@ +--- +title: machine jwt profile grant benchmark of zitadel v2.66.0 +sidebar_label: machine jwt profile grant +--- + +## Summary + +The tests showed heavy database load by time by the first two database queries. These queries need to be analyzed further. + +## Performance test results + +| Metric | Value | +|:--------------------------------------|:------| +| Baseline | none | +| Purpose | Test current performance | +| Test start | 15:39 UTC | +| Test duration | 30min | +| Executed test | machine\_jwt\_profile\_grant | +| k6 version | v0.54.0 | +| VUs | 150 | +| Client location | US1 | +| ZITADEL location | US1 | +| ZITADEL container specification | vCPU: 2
Memory: 512 Mib
Container count: 5 | +| ZITADEL Version | v2.66.0 | +| ZITADEL feature flags | webKey: true, improvedPerformance: \[\"IMPROVED\_PERFORMANCE\_ORG\_BY\_ID\", \"IMPROVED\_PERFORMANCE\_PROJECT\", \"IMPROVED\_PERFORMANCE\_USER\_GRANT\", \"IMPROVED\_PERFORMANCE\_ORG\_DOMAIN\_VERIFIED\", \"IMPROVED\_PERFORMANCE\_PROJECT\_GRANT\"\] | +| Database | type: psql
version: v15.8 | +| Database location | US1 | +| Database specification | vCPU: 8
memory: 32Gib | +| ZITADEL metrics during test | | +| Observed errors | | +| Top 3 most expensive database queries | 1: Write events using the newly added eventstore.push function
2: Query events by instance\_id, aggregate\_type, aggregate\_id, event\_types
3: Query user
| +| k6 Iterations per second | 439 | +| k6 output | [output](#k6-output) | +| flowchart outcome | Scale out | + +## Endpoint latencies + +import OutputSource from "!!raw-loader!./output.json"; + +import { BenchmarkChart } from '/src/components/benchmark_chart'; + + + +## k6 output {#k6-output} + +```bash + ✓ openid configuration + ✗ token status ok + ↳ 99% — ✓ 790655 / ✗ 5 + ✗ access token returned + ↳ 99% — ✓ 790655 / ✗ 5 + + █ setup + + ✓ user defined + ✓ authorize status ok + ✓ login name status ok + ✓ login shows password page + ✓ password status ok + ✓ password callback + ✓ code set + ✓ token status ok + ✓ access token created + ✓ id token created + ✓ info created + ✓ org created + ✓ create user is status ok + ✓ generate machine key status ok + + █ teardown + + ✓ org removed + + checks...............................: 99.99% ✓ 1581773 ✗ 10 + data_received........................: 1.1 GB 623 kB/s + data_sent............................: 628 MB 347 kB/s + http_req_blocked.....................: min=167ns avg=20.68µs max=493.59ms p(50)=468ns p(95)=717ns p(99)=928ns + http_req_connecting..................: min=0s avg=10.06µs max=388.27ms p(50)=0s p(95)=0s p(99)=0s + http_req_duration....................: min=17.71ms avg=337.34ms max=16.27s p(50)=249.03ms p(95)=888.75ms p(99)=1.4s + { expected_response:true }.........: min=17.71ms avg=337.29ms max=3.56s p(50)=249.03ms p(95)=888.7ms p(99)=1.4s + http_req_failed......................: 0.00% ✓ 5 ✗ 791265 + http_req_receiving...................: min=25.49µs avg=1.58ms max=539.43ms p(50)=89.69µs p(95)=7.55ms p(99)=23.46ms + http_req_sending.....................: min=22.7µs avg=69.14µs max=480.23ms p(50)=59.16µs p(95)=85.15µs p(99)=129.88µs + http_req_tls_handshaking.............: min=0s avg=9.38µs max=98.15ms p(50)=0s p(95)=0s p(99)=0s + http_req_waiting.....................: min=15.11ms avg=335.69ms max=16.26s p(50)=246.91ms p(95)=888.27ms p(99)=1.4s + http_reqs............................: 791270 437.256468/s + iteration_duration...................: min=32.28ms avg=341.46ms max=16.27s p(50)=253ms p(95)=892.49ms p(99)=1.41s + iterations...........................: 790660 436.919382/s + login_ui_enter_login_name_duration...: min=179.27ms avg=179.27ms max=179.27ms p(50)=179.27ms p(95)=179.27ms p(99)=179.27ms + login_ui_enter_password_duration.....: min=17.71ms avg=17.71ms max=17.71ms p(50)=17.71ms p(95)=17.71ms p(99)=17.71ms + login_ui_init_login_duration.........: min=77.66ms avg=77.66ms max=77.66ms p(50)=77.66ms p(95)=77.66ms p(99)=77.66ms + login_ui_token_duration..............: min=86.79ms avg=86.79ms max=86.79ms p(50)=86.79ms p(95)=86.79ms p(99)=86.79ms + oidc_token_duration..................: min=28.38ms avg=337.54ms max=16.27s p(50)=249.17ms p(95)=889.01ms p(99)=1.4s + org_create_org_duration..............: min=44.94ms avg=44.94ms max=44.94ms p(50)=44.94ms p(95)=44.94ms p(99)=44.94ms + user_add_machine_key_duration........: min=38.11ms avg=66.64ms max=160.59ms p(50)=60.28ms p(95)=104.99ms p(99)=112.5ms + user_create_machine_duration.........: min=37.12ms avg=122.76ms max=1.03s p(50)=78.25ms p(95)=266.95ms p(99)=306.94ms + vus..................................: 150 min=0 max=150 + vus_max..............................: 150 min=150 max=150 + + +running (30m09.6s), 000/150 VUs, 790660 complete and 0 interrupted iterations +default ✓ [======================================] 150 VUs 30m0s +``` + diff --git a/docs/docs/apis/benchmarks/v2.66.0/machine_jwt_profile_grant/output.json b/docs/docs/apis/benchmarks/v2.66.0/machine_jwt_profile_grant/output.json new file mode 100644 index 0000000000..e2c43e09e2 --- /dev/null +++ b/docs/docs/apis/benchmarks/v2.66.0/machine_jwt_profile_grant/output.json @@ -0,0 +1,1803 @@ +[ + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:38:44+01","p50":101.95622399999999,"p95":137.6454028957974,"p99":157.2097258345785}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:38:45+01","p50":160.71148648611108,"p95":242.88793605301868,"p99":283.76071553355786}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:38:46+01","p50":121.72930775694444,"p95":417.7653542187909,"p99":436.3731336767896}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:38:47+01","p50":113.88456833538461,"p95":441.91780702667364,"p99":474.0527494362228}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:38:48+01","p50":138.66216244378697,"p95":392.44944815358485,"p99":413.12171234236575}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:38:49+01","p50":188.4568091775148,"p95":285.6134255112239,"p99":301.9917086591737}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:38:50+01","p50":193.87928416319446,"p95":400.48394514500757,"p99":431.03960871716765}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:38:51+01","p50":100.58784778472221,"p95":388.16808085544255,"p99":452.6248447790737}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:38:52+01","p50":84.70682090495868,"p95":459.5434618697324,"p99":496.90298018895936}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:38:53+01","p50":84.85543177272727,"p95":405.27415783347095,"p99":429.8696062663708}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:38:54+01","p50":78.19703018181819,"p95":412.3267073848172,"p99":434.65174111990393}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:38:55+01","p50":98.09821723611111,"p95":541.8270667530961,"p99":569.7796809969452}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:38:56+01","p50":88.92674323958335,"p95":604.1480300615494,"p99":629.4101199430855}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:38:57+01","p50":94.25996172189349,"p95":552.0454750890208,"p99":578.8507372053331}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:38:58+01","p50":69.09293158333334,"p95":651.3896680271364,"p99":677.6142473733806}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:38:59+01","p50":59.70957779338842,"p95":754.4046575486248,"p99":800.369793227911}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:39:00+01","p50":83.38675619834713,"p95":679.7530003826953,"p99":698.8895711397541}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:39:01+01","p50":86.97336683333333,"p95":634.8256747769915,"p99":672.9910167700718}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:39:02+01","p50":116.58535063194445,"p95":563.9482791114166,"p99":590.9092625559061}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:39:03+01","p50":137.22131244444446,"p95":389.24244922502425,"p99":417.7380193225627}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:39:04+01","p50":98.48047405555555,"p95":478.40393591816127,"p99":568.753388686688}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:39:05+01","p50":65.81398447569445,"p95":662.2519370419724,"p99":697.4593990805466}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:39:06+01","p50":76.7836114752066,"p95":693.045640942108,"p99":712.0740948468391}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:39:07+01","p50":87.3815950697436,"p95":533.8020075679781,"p99":555.6812275544797}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:39:08+01","p50":108.40741219230769,"p95":508.4610614833938,"p99":523.5754284794996}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:39:09+01","p50":123.75995270512819,"p95":427.4367694853075,"p99":453.9057932773251}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:39:10+01","p50":143.06203653472224,"p95":436.019242594773,"p99":500.96013612772214}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:39:11+01","p50":163.82245444444445,"p95":352.2676896285294,"p99":395.2562410976543}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:39:12+01","p50":189.35444572948717,"p95":280.58916577935,"p99":329.13701846991734}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:39:13+01","p50":158.01874584375,"p95":305.0929308849436,"p99":344.95560191032104}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:39:14+01","p50":98.86212096428572,"p95":343.1827672970643,"p99":359.07650118063657}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:39:15+01","p50":148.2732390625,"p95":343.3010464739998,"p99":366.40679934058517}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:39:16+01","p50":144.74630972307693,"p95":337.1911591209745,"p99":364.8760598073751}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:39:17+01","p50":158.87796496875004,"p95":379.51867407425647,"p99":401.08808145494316}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:39:18+01","p50":197.64607608333336,"p95":356.5588904414338,"p99":392.4581539708023}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:39:19+01","p50":129.4125514201183,"p95":390.3371094746815,"p99":420.35230749515904}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:39:20+01","p50":97.61605846564103,"p95":490.68944690923774,"p99":527.4489383970988}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:39:21+01","p50":107.76460658680556,"p95":477.3659518223023,"p99":496.1928347163977}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:39:22+01","p50":117.44868988717948,"p95":454.1808149556858,"p99":515.2328602515254}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:39:23+01","p50":97.55113141124261,"p95":452.9805033099765,"p99":480.48952179725205}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:39:24+01","p50":131.61317731250003,"p95":370.7860591967902,"p99":400.50686612839235}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:39:25+01","p50":127.78259752366861,"p95":389.51509347240165,"p99":412.85549195026647}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:39:26+01","p50":124.32231885487181,"p95":377.5170314677562,"p99":417.6425894308979}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:39:27+01","p50":90.28449679166665,"p95":387.41615753598063,"p99":453.32418886782455}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:39:28+01","p50":69.83177338,"p95":726.3041636570783,"p99":760.3623750899031}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:39:29+01","p50":82.90674688461537,"p95":748.2743932268411,"p99":791.7971064829702}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:39:30+01","p50":94.60348115972222,"p95":569.5002533690139,"p99":617.3139484939122}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:39:31+01","p50":126.65370924305556,"p95":451.9422106836249,"p99":489.58885982269874}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:39:32+01","p50":103.59756552071006,"p95":463.21392981184965,"p99":490.88010029968535}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:39:33+01","p50":107.23317684615382,"p95":463.4600011584177,"p99":524.1793939104941}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:39:34+01","p50":122.73644040236687,"p95":426.10495775129675,"p99":457.26206028565736}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:39:35+01","p50":180.13561702564104,"p95":353.7763569813716,"p99":371.91369185396013}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:39:36+01","p50":127.1379665739645,"p95":295.7125128164935,"p99":327.2167265282798}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:39:37+01","p50":163.18437498214286,"p95":309.76309655697787,"p99":358.1931256399682}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:39:38+01","p50":152.10465000000002,"p95":291.44487577096913,"p99":355.2782387550554}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:39:39+01","p50":171.80184869444443,"p95":291.25106553931664,"p99":321.8108586276755}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:39:40+01","p50":131.7923757485207,"p95":358.1267161681937,"p99":386.3548208418736}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:39:41+01","p50":109.11131225694443,"p95":500.85207023846687,"p99":541.9965055489822}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:39:42+01","p50":94.31880577777777,"p95":514.1331498675038,"p99":538.6291467694282}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:39:43+01","p50":88.45482849999998,"p95":506.87047220237343,"p99":533.217965195303}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:39:44+01","p50":75.99285691666667,"p95":563.1341731490003,"p99":586.8454878953421}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:39:45+01","p50":78.04575791319444,"p95":560.6020778561364,"p99":584.0347969075983}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:39:46+01","p50":69.93837122222222,"p95":583.1436883813226,"p99":609.789652659574}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:39:47+01","p50":75.74691906944444,"p95":595.6732498260205,"p99":629.4469651198204}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:39:48+01","p50":90.56894379166665,"p95":547.4183809439039,"p99":567.6402171650184}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:39:49+01","p50":121.98702320710059,"p95":437.4328312784021,"p99":464.1821426945799}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:39:50+01","p50":143.58755762500002,"p95":419.3740978008817,"p99":447.02481925501246}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:39:51+01","p50":135.25529236692307,"p95":417.0512811258649,"p99":437.9065658626807}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:39:52+01","p50":112.64030973611109,"p95":452.6488015395233,"p99":486.3007172425156}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:39:53+01","p50":81.60573402366865,"p95":494.22594630013674,"p99":512.9955964094256}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:39:54+01","p50":92.42321304166666,"p95":538.7354114677075,"p99":561.8000694492002}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:39:55+01","p50":87.73330956615384,"p95":560.3798702778885,"p99":582.9945306706032}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:39:56+01","p50":91.72019077083336,"p95":535.0953102886127,"p99":585.5578139556216}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:39:57+01","p50":85.15941284615384,"p95":540.8847860170158,"p99":560.1400723319731}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:39:58+01","p50":90.26350492361111,"p95":556.9696732176005,"p99":573.1325806344051}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:39:59+01","p50":77.84642229166667,"p95":533.2522054634483,"p99":558.3325973766355}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:40:00+01","p50":132.17229913194444,"p95":509.04760358599725,"p99":549.6209992342126}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:40:01+01","p50":167.21761793750002,"p95":365.123159374226,"p99":407.55215734951634}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:40:02+01","p50":142.67088705208334,"p95":375.7471040833458,"p99":401.52165325263525}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:40:03+01","p50":105.18881233333333,"p95":349.77825306078137,"p99":366.9560180681512}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:40:04+01","p50":101.856276,"p95":395.8278248530486,"p99":427.90799470867177}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:40:05+01","p50":74.82229978512396,"p95":616.9319200625013,"p99":644.8957025972545}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:40:06+01","p50":91.40010851041666,"p95":590.8001965581633,"p99":630.5051493763192}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:40:07+01","p50":86.183248875,"p95":574.3462907923283,"p99":593.9599358535249}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:40:08+01","p50":82.26859711805555,"p95":614.6232196523804,"p99":632.3237096065822}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:40:09+01","p50":78.90578863223142,"p95":613.5982234619646,"p99":644.100604879699}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:40:10+01","p50":95.57105683471073,"p95":568.8889026592448,"p99":598.6244983987975}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:40:11+01","p50":95.46569968749999,"p95":545.6172264823972,"p99":575.6657091616918}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:40:12+01","p50":93.12112121900827,"p95":569.0027889956261,"p99":595.4926556786131}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:40:13+01","p50":100.53065516666666,"p95":536.042215766721,"p99":572.2321731558561}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:40:14+01","p50":97.57324775,"p95":562.0529697343982,"p99":591.0343833802948}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:40:15+01","p50":133.57539063888888,"p95":509.0541015674988,"p99":556.303328832901}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:40:16+01","p50":124.22675591666666,"p95":464.2705362198908,"p99":493.2546924270477}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:40:17+01","p50":128.24074092708335,"p95":498.61606921289257,"p99":537.9222896753763}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:40:18+01","p50":137.99689933333335,"p95":466.84183049648993,"p99":495.4667485496285}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:40:19+01","p50":111.77674313888889,"p95":475.4774850918696,"p99":503.3384257174796}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:40:20+01","p50":113.31951005621303,"p95":458.1750315689811,"p99":500.49116268753056}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:40:21+01","p50":119.84649527083336,"p95":424.25072243071565,"p99":456.11141327825175}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:40:22+01","p50":157.37732627810652,"p95":414.97423303714146,"p99":435.9454448856339}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:40:23+01","p50":130.9790169375,"p95":413.1754348629612,"p99":448.3356487720349}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:40:24+01","p50":87.58673572222222,"p95":414.52946198751005,"p99":455.28479627477213}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:40:25+01","p50":97.83618783333334,"p95":336.86894100654496,"p99":355.1840208984475}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:40:26+01","p50":98.32786904166666,"p95":378.06166936114215,"p99":409.0661119619925}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:40:27+01","p50":120.63735804166667,"p95":365.3308771819533,"p99":387.5634260185882}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:40:28+01","p50":256.3922228515625,"p95":507.4061893079345,"p99":588.9136951626673}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:40:29+01","p50":405.2968411,"p95":741.7480717913759,"p99":865.2794983107214}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:40:30+01","p50":300.7969605510204,"p95":431.0513314097269,"p99":554.9928161084022}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:40:31+01","p50":318.7500045833333,"p95":509.25965059560923,"p99":636.8053048485546}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:40:32+01","p50":293.3239689591837,"p95":439.3350883331949,"p99":604.5661200978217}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:40:33+01","p50":273.23968116326535,"p95":491.07233060415945,"p99":539.8570070911121}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:40:34+01","p50":325.1737032,"p95":571.2120406992194,"p99":660.3595275364341}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:40:35+01","p50":266.80053713265306,"p95":436.41712692247944,"p99":613.138877821022}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:40:36+01","p50":317.9744242,"p95":540.1798305671141,"p99":661.0712341334892}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:40:37+01","p50":335.40736559722217,"p95":570.3400851910666,"p99":662.043744033508}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:40:38+01","p50":167.68907577083328,"p95":308.11974913143024,"p99":583.1417500005509}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:40:39+01","p50":143.81799477384618,"p95":292.8504029544521,"p99":319.7505007721857}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:40:40+01","p50":165.2808483372781,"p95":326.9120415372973,"p99":369.96195431708315}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:40:41+01","p50":180.46724924999998,"p95":329.4187870243736,"p99":355.25900759258434}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:40:42+01","p50":133.77021155555556,"p95":437.1314825049485,"p99":475.67759025469496}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:40:43+01","p50":107.67179333333333,"p95":520.2970250481079,"p99":556.6558343640891}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:40:44+01","p50":99.09475161805555,"p95":566.443634520363,"p99":606.1468685731234}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:40:45+01","p50":121.84509769097222,"p95":482.78248615905056,"p99":513.6306669925133}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:40:46+01","p50":181.11651979166666,"p95":383.50846679154193,"p99":419.782887811665}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:40:47+01","p50":149.23981741715977,"p95":353.0961334702595,"p99":370.79685625247606}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:40:48+01","p50":145.85921094538463,"p95":362.8218351790662,"p99":391.8143269314747}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:40:49+01","p50":163.54859145454543,"p95":392.8813798870608,"p99":418.67251844226706}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:40:50+01","p50":138.59491245867767,"p95":408.39317442940467,"p99":426.51578888154745}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:40:51+01","p50":166.57350719526627,"p95":379.2455483057585,"p99":427.8573923744141}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:40:52+01","p50":148.1943132692308,"p95":411.96511940726975,"p99":462.58051847259236}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:40:53+01","p50":164.11855828819444,"p95":385.8763284527696,"p99":409.9494071513559}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:40:54+01","p50":148.42442637499997,"p95":334.94317389465846,"p99":399.9033468358762}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:40:55+01","p50":134.6974950473373,"p95":407.5446963280021,"p99":446.61813724848315}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:40:56+01","p50":119.78755362847222,"p95":505.06636059252895,"p99":539.0253774909463}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:40:57+01","p50":105.84350979166668,"p95":532.024426948585,"p99":553.3202703303175}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:40:58+01","p50":106.51078458333332,"p95":472.1730293651093,"p99":490.6947052263731}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:40:59+01","p50":96.89142987499999,"p95":495.9329837888282,"p99":535.8807128890589}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:41:00+01","p50":95.8949944876033,"p95":524.2076775969031,"p99":543.3839198861698}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:41:01+01","p50":79.37144154,"p95":615.0193961395058,"p99":635.3428000241315}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:41:02+01","p50":73.42690711570248,"p95":602.7664669140363,"p99":626.0170225410861}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:41:03+01","p50":76.64229597222221,"p95":585.5528533539062,"p99":623.9457972382203}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:41:04+01","p50":69.70040529166666,"p95":537.4732614117437,"p99":562.4937842162401}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:41:05+01","p50":64.1410094628099,"p95":629.9419693858313,"p99":658.1186802511063}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:41:06+01","p50":76.2555816198347,"p95":607.0660525936493,"p99":630.9929336713673}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:41:07+01","p50":66.50165761157024,"p95":603.3572839567105,"p99":624.2669383155626}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:41:08+01","p50":61.79705429752066,"p95":640.9749931221494,"p99":662.1256739697008}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:41:09+01","p50":74.08435375,"p95":665.7778572553271,"p99":692.6510113831741}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:41:10+01","p50":69.47923140909091,"p95":592.2587113220148,"p99":610.4134084533755}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:41:11+01","p50":62.77434199173554,"p95":581.7786063104679,"p99":596.8846012632675}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:41:12+01","p50":71.80177740495868,"p95":561.2790312832076,"p99":584.8997773869073}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:41:13+01","p50":62.85025523140496,"p95":604.31724675006,"p99":624.7182492899176}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:41:14+01","p50":60.00107763636363,"p95":668.0291777059201,"p99":683.9823378800266}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:41:15+01","p50":59.28935307438016,"p95":631.9506173526725,"p99":649.3750163239507}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:41:16+01","p50":58.90572745454546,"p95":634.084268904297,"p99":649.510729109484}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:41:17+01","p50":61.80572913636363,"p95":609.7891813478544,"p99":620.0572333747921}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:41:18+01","p50":71.60840007024792,"p95":559.8874666964492,"p99":592.5992380026972}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:41:19+01","p50":58.13061114,"p95":650.3538829152382,"p99":663.5863870429329}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:41:20+01","p50":61.86582962,"p95":620.9669647622484,"p99":651.462879988739}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:41:21+01","p50":66.77992954545455,"p95":631.1164914842583,"p99":639.9338938968889}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:41:22+01","p50":60.17779063636363,"p95":634.6677554071666,"p99":661.1144794389195}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:41:23+01","p50":65.02052834000001,"p95":653.0454981229543,"p99":673.1218770871357}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:41:24+01","p50":57.13980746,"p95":675.790308628906,"p99":698.9799746068121}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:41:25+01","p50":58.24718095867768,"p95":662.7384424419728,"p99":688.2042338089752}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:41:26+01","p50":70.69783547,"p95":595.248382066493,"p99":611.8900123906598}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:41:27+01","p50":61.548106250000004,"p95":659.5202249164303,"p99":686.0183081571658}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:41:28+01","p50":63.888166275,"p95":620.474516779485,"p99":636.3685913138737}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:41:29+01","p50":62.02698227,"p95":666.8902032875698,"p99":696.1166366655381}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:41:30+01","p50":65.35355684999999,"p95":620.7848496363005,"p99":635.5683915472309}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:41:31+01","p50":70.44304855,"p95":691.8590752161153,"p99":715.0191105740284}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:41:32+01","p50":67.37997759500001,"p95":626.2066631117002,"p99":649.2957250652213}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:41:33+01","p50":66.06885879338843,"p95":597.9353302482874,"p99":625.2392518280727}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:41:34+01","p50":64.74006789999999,"p95":670.8292389837939,"p99":699.4323136105946}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:41:35+01","p50":61.849484842975215,"p95":596.5549910826903,"p99":617.1200690922201}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:41:36+01","p50":64.5200382231405,"p95":617.9165390848217,"p99":640.6491890718996}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:41:37+01","p50":60.77042837190083,"p95":643.824495813177,"p99":666.3431619080753}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:41:38+01","p50":61.46832467,"p95":644.0652491371304,"p99":657.3184414108292}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:41:39+01","p50":62.77397028,"p95":641.0440278738972,"p99":668.1244157608188}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:41:40+01","p50":62.267536326446276,"p95":622.08602280511,"p99":641.5338435452259}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:41:41+01","p50":61.03319342975206,"p95":622.916360216743,"p99":641.9112676301595}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:41:42+01","p50":61.3407558,"p95":638.0191288815209,"p99":656.8496002549076}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:41:43+01","p50":63.57414913,"p95":662.8738484911922,"p99":688.1531705990941}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:41:44+01","p50":66.8209046,"p95":628.9202321552625,"p99":664.8463409218803}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:41:45+01","p50":66.85840900000001,"p95":674.493846461769,"p99":690.2472327520633}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:41:46+01","p50":60.28434102066115,"p95":639.4882704575268,"p99":662.7946079308878}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:41:47+01","p50":65.43626285123968,"p95":632.8991563900032,"p99":664.3373402649083}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:41:48+01","p50":62.16148514049587,"p95":640.6230485705134,"p99":664.84912550807}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:41:49+01","p50":61.4274126,"p95":615.9519005600997,"p99":628.6935576113124}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:41:50+01","p50":67.42087760330577,"p95":651.1394828499429,"p99":666.3791948174235}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:41:51+01","p50":66.33640635,"p95":616.8089937642235,"p99":665.8788403719442}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:41:52+01","p50":67.29680968181817,"p95":656.7313668107452,"p99":685.2968477682801}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:41:53+01","p50":66.51175005785124,"p95":687.0938422258783,"p99":707.1657349871975}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:41:54+01","p50":72.6408527,"p95":618.6133521127764,"p99":656.3228593347724}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:41:55+01","p50":74.88716230991734,"p95":641.9809766175623,"p99":666.4717551134484}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:41:56+01","p50":75.43533901652891,"p95":639.7904861585171,"p99":658.5598886644584}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:41:57+01","p50":82.90082569,"p95":632.488243639808,"p99":664.2611276988092}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:41:58+01","p50":83.19439482644628,"p95":581.8008534366982,"p99":612.3138335722941}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:41:59+01","p50":92.55629436363638,"p95":611.8925145952585,"p99":626.219947496443}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:42:00+01","p50":95.513820605,"p95":580.0704659946305,"p99":626.8086981582727}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:42:01+01","p50":108.95095749,"p95":648.124213288216,"p99":680.864462389479}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:42:02+01","p50":95.03935828099175,"p95":622.914999664873,"p99":656.4921832904374}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:42:03+01","p50":104.651274825,"p95":570.7769805655242,"p99":594.8087301179568}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:42:04+01","p50":110.46424965000001,"p95":601.150437160345,"p99":637.7155409400348}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:42:05+01","p50":107.70065435000001,"p95":603.7190336415115,"p99":623.1267803379897}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:42:06+01","p50":113.75183295000002,"p95":598.3224279675898,"p99":625.4230125962575}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:42:07+01","p50":113.91106854,"p95":544.487881893275,"p99":564.2903968583831}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:42:08+01","p50":113.7979202479339,"p95":564.4837890360361,"p99":590.7293302601231}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:42:09+01","p50":119.65436459090907,"p95":533.9626266754418,"p99":551.6154114423613}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:42:10+01","p50":113.0136901570248,"p95":523.9769248315413,"p99":545.518070093442}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:42:11+01","p50":123.92614024380164,"p95":506.42123658007125,"p99":532.7641218319454}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:42:12+01","p50":145.4265089338843,"p95":468.43344217495695,"p99":497.37859887193326}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:42:13+01","p50":145.93830121000002,"p95":473.22702027860896,"p99":492.7592998734981}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:42:14+01","p50":135.92363158677682,"p95":445.3027095028191,"p99":475.7678405123181}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:42:15+01","p50":165.54328979999997,"p95":434.6364409680323,"p99":454.4874806157722}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:42:16+01","p50":167.34084095000003,"p95":406.3332601374499,"p99":428.0369057653067}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:42:17+01","p50":166.32390059999997,"p95":443.7535240054734,"p99":466.6222055482558}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:42:18+01","p50":170.08523334,"p95":422.39782791789116,"p99":438.3282328818248}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:42:19+01","p50":169.13981641322314,"p95":421.4605632742555,"p99":439.93749897164884}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:42:20+01","p50":174.71706541999998,"p95":398.21559156475405,"p99":416.4561073500684}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:42:21+01","p50":184.40369287603306,"p95":400.77875542481297,"p99":422.971553433959}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:42:22+01","p50":209.63455621999998,"p95":406.91382347536154,"p99":426.2201664470338}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:42:23+01","p50":204.59003566,"p95":381.18141161440485,"p99":396.25733442948984}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:42:24+01","p50":194.13508244628102,"p95":325.05685187326657,"p99":387.45018498952106}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:42:25+01","p50":205.38787896999997,"p95":351.5181279746652,"p99":373.1731277970198}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:42:26+01","p50":200.13284370000002,"p95":303.5068036878929,"p99":323.23277671187566}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:42:27+01","p50":199.30461376033057,"p95":296.33566031043307,"p99":316.0184037624205}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:42:28+01","p50":205.86356354999998,"p95":322.3229898696147,"p99":354.09381297954513}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:42:29+01","p50":208.87985079999999,"p95":277.64585103760453,"p99":294.5102807418717}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:42:30+01","p50":225.88604066666667,"p95":325.21909291452874,"p99":362.11784659269614}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:42:31+01","p50":252.2089251234568,"p95":337.82816208494785,"p99":383.4915778469143}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:42:32+01","p50":214.8128714,"p95":282.13472025581854,"p99":307.7542446805807}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:42:33+01","p50":210.5768091198347,"p95":276.48536003305173,"p99":304.67822830494305}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:42:34+01","p50":205.83594004958678,"p95":291.3119604168843,"p99":316.2611438917699}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:42:35+01","p50":210.74379012,"p95":274.73700556582423,"p99":311.2203219175575}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:42:36+01","p50":211.37787944214878,"p95":263.3916352598567,"p99":287.3125816832063}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:42:37+01","p50":206.339958,"p95":255.74564694823394,"p99":265.7220561802488}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:42:38+01","p50":208.36953597933882,"p95":273.11675931813215,"p99":313.24789656309736}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:42:39+01","p50":211.89849715999998,"p95":275.84168402106525,"p99":314.7386794059194}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:42:40+01","p50":205.1854362479339,"p95":268.2655841678027,"p99":293.3185302644855}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:42:41+01","p50":207.35127854545456,"p95":270.85956489751214,"p99":291.65609142568263}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:42:42+01","p50":205.92993213636365,"p95":283.1462784024512,"p99":309.22032307148646}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:42:43+01","p50":206.59181983471072,"p95":277.4724594397631,"p99":302.24686135942727}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:42:44+01","p50":209.84103933,"p95":279.8546277080067,"p99":326.286694177766}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:42:45+01","p50":203.9019317644628,"p95":276.30867342578046,"p99":306.31588786837807}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:42:46+01","p50":209.10944416,"p95":278.39238641104095,"p99":307.1239100316689}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:42:47+01","p50":205.3218318,"p95":308.2991569233706,"p99":352.2819971887245}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:42:48+01","p50":217.29072868000003,"p95":318.3889288404758,"p99":377.52654887112936}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:42:49+01","p50":211.9904,"p95":307.6337409904953,"p99":351.1546811078254}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:42:50+01","p50":208.13657170000002,"p95":287.2357787762207,"p99":319.7593246253825}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:42:51+01","p50":222.21164414,"p95":304.9733275307495,"p99":333.9077577947789}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:42:52+01","p50":218.760900975,"p95":297.4669348291186,"p99":331.7858540176414}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:42:53+01","p50":208.4477652,"p95":302.56381131755626,"p99":331.0214724889977}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:42:54+01","p50":208.05792780000002,"p95":306.67040230477045,"p99":336.8534546874857}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:42:55+01","p50":212.81048582000003,"p95":280.7631852382431,"p99":307.2718171869035}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:42:56+01","p50":215.96929854000004,"p95":296.1545302726932,"p99":334.6363757980954}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:42:57+01","p50":223.24622268000002,"p95":280.3165774794676,"p99":299.39099125476474}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:42:58+01","p50":223.96217385,"p95":286.4173673343284,"p99":308.1830709748355}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:42:59+01","p50":218.43836751999999,"p95":302.39487687001923,"p99":406.682880411872}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:43:00+01","p50":234.8941296111111,"p95":303.56291413841745,"p99":345.7983497641659}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:43:01+01","p50":245.7841760740741,"p95":360.66756654836814,"p99":389.5592164468436}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:43:02+01","p50":215.50010050999998,"p95":305.9556130113415,"p99":332.6421174126827}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:43:03+01","p50":218.16031095000002,"p95":310.2951306234005,"p99":346.2025961755987}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:43:04+01","p50":218.085952945,"p95":298.8350058431988,"p99":337.89815800755997}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:43:05+01","p50":218.96447296000002,"p95":267.5901645456911,"p99":289.3522355091627}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:43:06+01","p50":220.39892079999998,"p95":283.95188726599815,"p99":310.7981320721116}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:43:07+01","p50":213.57276013999999,"p95":313.78784598577283,"p99":349.422183201581}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:43:08+01","p50":215.91361588,"p95":308.82812132209614,"p99":355.2259709296504}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:43:09+01","p50":224.995786825,"p95":273.11564905940844,"p99":289.4293419120888}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:43:10+01","p50":213.89981111000003,"p95":342.9151325472899,"p99":384.3140006659305}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:43:11+01","p50":223.008151295,"p95":289.6920305527179,"p99":305.6966092911037}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:43:12+01","p50":221.8847448,"p95":307.178971093112,"p99":385.76419502867776}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:43:13+01","p50":234.74603814000002,"p95":292.51374163980984,"p99":315.0928189088354}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:43:14+01","p50":230.63315959000002,"p95":287.3956519807023,"p99":301.51953251960276}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:43:15+01","p50":223.60981683999998,"p95":307.95663767034637,"p99":349.638760208001}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:43:16+01","p50":232.97873244444443,"p95":308.72932054506146,"p99":344.55854463161046}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:43:17+01","p50":229.12226423,"p95":318.7243294689348,"p99":347.6248296594744}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:43:18+01","p50":224.33404088000003,"p95":314.6390919687667,"p99":357.6844157447042}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:43:19+01","p50":239.99917183000002,"p95":304.0081723412635,"p99":326.3215513153874}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:43:20+01","p50":225.27772783999998,"p95":303.8724074122683,"p99":345.05536394420034}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:43:21+01","p50":218.40284165499997,"p95":299.9323947706719,"p99":347.0350404891139}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:43:22+01","p50":226.55160652499998,"p95":311.46804004498324,"p99":372.86824873449433}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:43:23+01","p50":224.2333895,"p95":313.11968587788846,"p99":356.92745905419355}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:43:24+01","p50":221.58133381999997,"p95":286.5379765821935,"p99":302.86770657890827}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:43:25+01","p50":231.01130158,"p95":302.9296034892931,"p99":334.1885577605097}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:43:26+01","p50":227.965134425,"p95":281.4081944148631,"p99":298.91211110043923}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:43:27+01","p50":228.18345068,"p95":289.1175699341779,"p99":320.84109184553336}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:43:28+01","p50":231.28718193,"p95":301.1543272443212,"p99":334.93013820060577}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:43:29+01","p50":230.96784336419756,"p95":295.7985523423554,"p99":326.63685934051324}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:43:30+01","p50":236.41569886419757,"p95":323.15221895455,"p99":355.84738286540124}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:43:31+01","p50":261.18275559374996,"p95":326.2367171339679,"p99":374.44677768891717}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:43:32+01","p50":238.72037855555558,"p95":310.91794728413095,"p99":333.3537962823639}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:43:33+01","p50":223.91749006,"p95":296.0411621026263,"p99":327.2011292525082}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:43:34+01","p50":228.110582575,"p95":288.3759004796407,"p99":327.18031116862744}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:43:35+01","p50":219.99303003,"p95":266.16024120951624,"p99":297.2480038146499}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:43:36+01","p50":221.60760616,"p95":294.2332467053376,"p99":331.77470583390715}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:43:37+01","p50":226.73102002000002,"p95":276.6364447585766,"p99":299.84920572380304}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:43:38+01","p50":215.39194108,"p95":278.7312414047483,"p99":306.65246902191797}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:43:39+01","p50":221.04404290500003,"p95":289.14730379333156,"p99":316.7282016582161}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:43:40+01","p50":236.00444958024693,"p95":310.8881672806127,"p99":338.07933216078237}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:43:41+01","p50":226.170667525,"p95":303.86026826403423,"p99":335.0505934994669}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:43:42+01","p50":226.994670365,"p95":286.2058999214015,"p99":307.1751533944094}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:43:43+01","p50":227.91483996296301,"p95":313.03218783865435,"p99":366.6153134771373}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:43:44+01","p50":222.98006688,"p95":288.77783355749546,"p99":330.51672576852735}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:43:45+01","p50":213.16388796500001,"p95":319.39473805074215,"p99":351.18802659971954}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:43:46+01","p50":229.6286215,"p95":298.96879081458866,"p99":340.49306620084207}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:43:47+01","p50":227.3207768,"p95":287.0410479432773,"p99":311.70544844752754}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:43:48+01","p50":224.02806297,"p95":280.3114930597334,"p99":367.70523267018575}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:43:49+01","p50":229.5843895555556,"p95":287.2554511472848,"p99":311.97252648708655}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:43:50+01","p50":226.86120169999998,"p95":297.8654988220625,"p99":324.8528261715774}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:43:51+01","p50":227.73033625,"p95":304.7452693077776,"p99":341.4966641090006}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:43:52+01","p50":229.43261528395064,"p95":329.4608420761883,"p99":360.45441631729886}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:43:53+01","p50":233.44179237037037,"p95":312.98086624497876,"p99":345.14207424462825}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:43:54+01","p50":224.76305555,"p95":340.9300107624405,"p99":375.940175119682}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:43:55+01","p50":226.80129230499998,"p95":289.6117079601,"p99":313.86055017679143}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:43:56+01","p50":231.6949609691358,"p95":287.8531604420075,"p99":306.5860878605385}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:43:57+01","p50":228.59216644499998,"p95":287.5490156433385,"p99":314.3855962102075}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:43:58+01","p50":229.8766922901235,"p95":300.1019888481416,"p99":317.0127521601634}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:43:59+01","p50":232.12182987654322,"p95":307.11689395967244,"p99":335.2247744974556}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:44:00+01","p50":242.03274538888886,"p95":359.7712343747422,"p99":401.0317873695488}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:44:01+01","p50":266.9841349765625,"p95":335.79608987190034,"p99":367.0953763685167}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:44:02+01","p50":227.54199040740738,"p95":352.7645655675264,"p99":384.78265775848007}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:44:03+01","p50":222.60332568500002,"p95":295.3146603694484,"p99":343.1357868478847}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:44:04+01","p50":227.53948897000004,"p95":286.3642131627198,"p99":307.7116735806589}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:44:05+01","p50":227.25509795999997,"p95":284.17668873254644,"p99":309.2026960913931}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:44:06+01","p50":229.002703175,"p95":288.51951185059767,"p99":307.3055339416721}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:44:07+01","p50":227.90352341999997,"p95":288.9389762859282,"p99":329.1693938114319}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:44:08+01","p50":226.700259525,"p95":294.5930806485263,"p99":320.7139166845181}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:44:09+01","p50":228.866691705,"p95":276.82862095413003,"p99":293.4586540926369}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:44:10+01","p50":230.24321654,"p95":291.5135872513003,"p99":321.6061616105754}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:44:11+01","p50":236.0547376296296,"p95":288.9985622980626,"p99":311.3583808458735}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:44:12+01","p50":232.31932432098768,"p95":297.6697089763983,"p99":328.42913460863093}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:44:13+01","p50":212.32758394444446,"p95":382.07793913507794,"p99":548.9407757282638}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:44:14+01","p50":222.2969005185185,"p95":457.4753442707809,"p99":517.0199566288567}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:44:15+01","p50":230.35156744444444,"p95":323.1959750449393,"p99":382.71152511653355}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:44:16+01","p50":237.35684742592593,"p95":322.60051178565215,"p99":349.8818183007172}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:44:17+01","p50":231.4525040246914,"p95":309.3719507983617,"p99":341.557965587876}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:44:18+01","p50":241.18758633333334,"p95":311.7570389046515,"p99":339.5561322990873}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:44:19+01","p50":232.93229305555556,"p95":362.1249660573662,"p99":388.89702842814336}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:44:20+01","p50":229.90953520987654,"p95":303.6292440166583,"p99":330.8865856812748}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:44:21+01","p50":236.90551922222224,"p95":300.9845318302042,"p99":334.66252012507255}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:44:22+01","p50":232.92155444444444,"p95":316.20003558752245,"p99":361.23624300495186}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:44:23+01","p50":231.0479861975308,"p95":312.2876871735196,"p99":354.6363389151595}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:44:24+01","p50":233.12522743209877,"p95":313.8506659652578,"p99":346.43192357389285}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:44:25+01","p50":248.16989191358022,"p95":318.2944778836649,"p99":343.74699294701884}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:44:26+01","p50":229.23957338271606,"p95":322.247106772999,"p99":397.41408053560554}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:44:27+01","p50":235.00642464814817,"p95":303.18924745042443,"p99":364.5679387146967}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:44:28+01","p50":237.69411672839507,"p95":328.83617695734245,"p99":367.39538193397186}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:44:29+01","p50":231.2979863333333,"p95":302.8665096443494,"p99":343.48030228696393}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:44:30+01","p50":246.83790505555555,"p95":323.3037096058812,"p99":354.97172799137496}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:44:31+01","p50":266.25567065625,"p95":381.7247247812427,"p99":408.5804087836418}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:44:32+01","p50":250.92026525000003,"p95":457.4742196225,"p99":518.8273736379653}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:44:33+01","p50":227.916567,"p95":335.5662265405199,"p99":373.54549423814433}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:44:34+01","p50":233.71457569135802,"p95":299.3744070049061,"p99":329.22239441504337}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:44:35+01","p50":229.9242444197531,"p95":310.27277688896584,"p99":343.97856698501363}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:44:36+01","p50":236.080865691358,"p95":296.2819347079152,"p99":324.3035311676905}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:44:37+01","p50":237.9940661111111,"p95":296.22591404141497,"p99":313.67635408998837}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:44:38+01","p50":234.7515991728395,"p95":295.0866239729916,"p99":328.48329296940375}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:44:39+01","p50":245.54870330864196,"p95":310.58100590344674,"p99":347.35950749358636}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:44:40+01","p50":236.0710164197531,"p95":303.0492662037728,"p99":339.2475535310731}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:44:41+01","p50":233.89671609259256,"p95":300.5567627187972,"p99":332.46072604693126}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:44:42+01","p50":233.92950116049386,"p95":304.69936422163414,"p99":351.7700806455592}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:44:43+01","p50":239.86293933333334,"p95":302.0943083402211,"p99":336.6915734134861}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:44:44+01","p50":237.01349629629627,"p95":293.9614255010273,"p99":316.271315578567}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:44:45+01","p50":241.03661314814815,"p95":293.95325496818117,"p99":311.90670126999265}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:44:46+01","p50":248.6397497222222,"p95":321.1083276743935,"p99":355.53217551825617}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:44:47+01","p50":236.83440436419752,"p95":307.77608052907715,"p99":336.16516838472984}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:44:48+01","p50":237.78182008641974,"p95":296.75326864755885,"p99":334.11718776960356}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:44:49+01","p50":247.34182591358024,"p95":316.32987245415734,"p99":345.93814732074355}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:44:50+01","p50":236.2530198888889,"p95":320.1980873117887,"p99":352.30335366892115}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:44:51+01","p50":242.4144787222222,"p95":294.36881323788333,"p99":321.2963556315209}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:44:52+01","p50":249.93633536419753,"p95":315.82175934213325,"p99":339.7380642858667}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:44:53+01","p50":249.5573993950617,"p95":320.7931308942644,"p99":345.27954962262606}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:44:54+01","p50":240.79447177777777,"p95":308.69344150712243,"p99":327.8232104120652}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:44:55+01","p50":243.64542290123458,"p95":306.03520016397994,"p99":327.64600322032805}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:44:56+01","p50":241.75838503703704,"p95":290.2335840942161,"p99":318.6701166426756}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:44:57+01","p50":244.1633235925926,"p95":306.87419694930196,"p99":333.6326824178183}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:44:58+01","p50":245.86742325925928,"p95":327.6107458238275,"p99":357.74392194234366}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:44:59+01","p50":241.5525650123457,"p95":314.85224848993306,"p99":336.1264216632122}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:45:00+01","p50":253.033641765625,"p95":360.22433500278765,"p99":394.2293334993372}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:45:01+01","p50":282.9873596530612,"p95":379.22299309784813,"p99":420.5163859353969}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:45:02+01","p50":261.2101283888889,"p95":349.5979372506568,"p99":380.0417791396627}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:45:03+01","p50":242.76836553086417,"p95":317.6506345034635,"p99":352.6504838312211}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:45:04+01","p50":242.29955274074075,"p95":315.85430882457143,"p99":379.5246074787372}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:45:05+01","p50":244.17885608641973,"p95":301.2072668961855,"p99":319.67482698173524}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:45:06+01","p50":246.99386586419755,"p95":294.79096765591675,"p99":323.8580152355954}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:45:07+01","p50":244.983715808642,"p95":320.0715005697858,"p99":359.3221083824115}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:45:08+01","p50":252.29098775925928,"p95":329.8536805369474,"p99":380.0450008629608}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:45:09+01","p50":248.63151905555554,"p95":312.04804377199986,"p99":339.19033159178736}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:45:10+01","p50":244.60815769135803,"p95":319.6249185810434,"p99":341.8980779112456}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:45:11+01","p50":243.62099285185184,"p95":316.3863579225111,"p99":359.2064474374562}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:45:12+01","p50":242.5257604197531,"p95":319.937880594814,"p99":345.90183122712205}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:45:13+01","p50":251.4814667160494,"p95":347.12832185550633,"p99":387.00833247104526}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:45:14+01","p50":244.03338811111112,"p95":329.5356001124202,"p99":353.70902560328153}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:45:15+01","p50":252.42461846875,"p95":352.93799996342045,"p99":396.57703143454955}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:45:16+01","p50":241.96543055555554,"p95":350.0999032539423,"p99":381.9900515419493}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:45:17+01","p50":237.49905385185184,"p95":348.28513285813546,"p99":378.2041651995187}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:45:18+01","p50":233.28525844444442,"p95":360.3378021336139,"p99":385.50966882857773}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:45:19+01","p50":235.27731401234567,"p95":392.25916967316795,"p99":415.1317684099419}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:45:20+01","p50":228.74149416666666,"p95":382.436929919615,"p99":417.29958860276696}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:45:21+01","p50":223.8554472222222,"p95":397.19822636716356,"p99":419.3491243445099}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:45:22+01","p50":228.93348565686276,"p95":390.45854274879633,"p99":418.0448409027219}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:45:23+01","p50":229.05229897530864,"p95":435.94131868004234,"p99":475.9577755427845}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:45:24+01","p50":224.6719653765432,"p95":404.15345074513885,"p99":431.1063728551382}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:45:25+01","p50":223.1738973271605,"p95":427.83862540364817,"p99":447.6870681274729}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:45:26+01","p50":219.4280582222222,"p95":424.4125297381542,"p99":452.2128250626185}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:45:27+01","p50":221.28637337499998,"p95":479.25856039177324,"p99":505.7553833616252}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:45:28+01","p50":220.13827125,"p95":497.69187694629755,"p99":521.2643295716357}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:45:29+01","p50":207.90608811111113,"p95":489.3627019916342,"p99":515.6024039426687}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:45:30+01","p50":220.88996113281254,"p95":557.7241497582178,"p99":605.0783330651728}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:45:31+01","p50":239.9994074387755,"p95":633.4437467143093,"p99":663.949303313808}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:45:32+01","p50":227.19496610937503,"p95":597.1201047446923,"p99":642.2356207996032}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:45:33+01","p50":208.9667705185185,"p95":534.2685504683992,"p99":565.457320744766}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:45:34+01","p50":197.3172998271605,"p95":577.5774809833255,"p99":615.3160591011061}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:45:35+01","p50":196.6841504814815,"p95":590.6343385262705,"p99":621.236526317459}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:45:36+01","p50":192.16300379012347,"p95":594.937989638396,"p99":616.6474479613671}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:45:37+01","p50":191.98542124074072,"p95":606.6746978951647,"p99":637.0841462819167}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:45:38+01","p50":199.094159140625,"p95":608.8222806475949,"p99":643.7600327943096}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:45:39+01","p50":198.6228311358025,"p95":603.4780945745752,"p99":633.6055038901814}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:45:40+01","p50":197.91195058024692,"p95":568.9742578396002,"p99":597.6751088185949}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:45:41+01","p50":187.92167739506175,"p95":587.6157216882028,"p99":630.434425031493}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:45:42+01","p50":193.8545081328125,"p95":576.7168153036739,"p99":609.471843039989}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:45:43+01","p50":182.7041825,"p95":626.5635759521475,"p99":656.6105761140519}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:45:44+01","p50":182.002420296875,"p95":595.2455152799928,"p99":666.524966823246}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:45:45+01","p50":191.43340067901235,"p95":541.5282873718245,"p99":577.426050821677}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:45:46+01","p50":199.72619957812498,"p95":643.5090654767054,"p99":675.4957638449421}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:45:47+01","p50":207.5420738271605,"p95":633.8917496826473,"p99":678.5703506387939}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:45:48+01","p50":164.81341753124997,"p95":634.4198897592404,"p99":662.86963345962}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:45:49+01","p50":174.15371123437498,"p95":654.5268345700912,"p99":689.1564842725492}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:45:50+01","p50":180.313614845679,"p95":666.8810886539575,"p99":726.1272797738256}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:45:51+01","p50":191.733401859375,"p95":630.6399569315726,"p99":646.1715571890819}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:45:52+01","p50":208.895401984375,"p95":606.6619703857724,"p99":640.2750225670086}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:45:53+01","p50":210.49941197656247,"p95":541.696203820779,"p99":577.569265250039}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:45:54+01","p50":254.5180668125,"p95":478.10806471849406,"p99":546.5435760246972}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:45:55+01","p50":229.85918274999997,"p95":546.262029470349,"p99":591.1771795484085}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:45:56+01","p50":174.65561250000002,"p95":607.8544517046315,"p99":634.0928913900585}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:45:57+01","p50":183.8232034921875,"p95":667.0396639921627,"p99":685.5928076427662}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:45:58+01","p50":195.130553140625,"p95":609.2753202277082,"p99":648.4411754986186}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:45:59+01","p50":183.1262176484375,"p95":641.7196272421642,"p99":675.980125018514}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:46:00+01","p50":175.864330359375,"p95":718.4392911164153,"p99":750.7176252789826}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:46:01+01","p50":259.4954200714286,"p95":777.5164146899575,"p99":813.5769222000613}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:46:02+01","p50":188.16883884375,"p95":730.0940563236533,"p99":775.5735355237389}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:46:03+01","p50":191.58450478125,"p95":620.0481701912838,"p99":668.8960170888228}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:46:04+01","p50":175.841472296875,"p95":632.8241188411656,"p99":665.6329794227838}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:46:05+01","p50":175.40988267187498,"p95":642.8582759503653,"p99":664.626595705243}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:46:06+01","p50":162.648231890625,"p95":711.5610653251928,"p99":735.6088418108253}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:46:07+01","p50":183.725167734375,"p95":713.8705531019855,"p99":738.3491766403465}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:46:08+01","p50":205.39213659876543,"p95":674.3890294992779,"p99":713.7598256706095}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:46:09+01","p50":232.22102699999996,"p95":515.8753497536136,"p99":546.1046437778377}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:46:10+01","p50":221.6818236796875,"p95":557.9633368008311,"p99":593.6550660394516}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:46:11+01","p50":201.258418984375,"p95":530.9680855909236,"p99":573.2599308174435}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:46:12+01","p50":211.8697521875,"p95":529.6859483082727,"p99":561.3951712317014}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:46:13+01","p50":206.766411484375,"p95":567.5206615242497,"p99":614.1787438853047}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:46:14+01","p50":304.241553859375,"p95":512.3258756998524,"p99":536.6138555834252}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:46:15+01","p50":303.19803565625006,"p95":489.98481228281395,"p99":522.9720132191463}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:46:16+01","p50":308.9834941796875,"p95":460.19994999820847,"p99":496.7734299254275}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:46:17+01","p50":287.605237484375,"p95":394.30359972952937,"p99":444.0751214773295}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:46:18+01","p50":275.86983460937495,"p95":404.98791292437505,"p99":449.0815109612393}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:46:19+01","p50":292.66104935937506,"p95":422.1997539417769,"p99":456.06440989265013}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:46:20+01","p50":256.19148906249995,"p95":455.1225759756719,"p99":478.6134592895532}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:46:21+01","p50":203.071612828125,"p95":599.7232979431302,"p99":652.3018377973488}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:46:22+01","p50":184.572660578125,"p95":708.3567861859196,"p99":749.9978142965715}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:46:23+01","p50":179.27201665625,"p95":742.0952314548227,"p99":801.2938597553085}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:46:24+01","p50":189.0404942421875,"p95":714.11674297969,"p99":738.5481027947288}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:46:25+01","p50":190.13964307812503,"p95":648.5751613377107,"p99":695.8864858107758}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:46:26+01","p50":185.01441,"p95":744.4800640991624,"p99":772.8078027810068}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:46:27+01","p50":176.379387765625,"p95":731.3401126864051,"p99":763.1530903995427}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:46:28+01","p50":183.20303623437502,"p95":694.7755455998175,"p99":726.9407615041866}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:46:29+01","p50":172.45682875000003,"p95":762.5253784376276,"p99":795.9366136895187}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:46:30+01","p50":154.4886441122449,"p95":742.4563853324298,"p99":778.4863342859178}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:46:31+01","p50":218.32736989795916,"p95":818.4413222946499,"p99":857.634896244293}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:46:32+01","p50":199.55808895918366,"p95":805.7552451962433,"p99":836.1199240491286}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:46:33+01","p50":184.13282901562502,"p95":724.3228182066305,"p99":747.5561410408754}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:46:34+01","p50":228.3469134453125,"p95":710.6347505156864,"p99":763.6017112995186}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:46:35+01","p50":219.93913431250002,"p95":624.2612106298724,"p99":691.6263712410774}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:46:36+01","p50":190.55343200000002,"p95":623.4027338032672,"p99":661.6895393242999}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:46:37+01","p50":212.14271306249998,"p95":588.2951044012299,"p99":620.9212367747192}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:46:38+01","p50":234.58604350000002,"p95":577.7735167885902,"p99":615.3833853898707}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:46:39+01","p50":239.61629159375,"p95":464.1651513131973,"p99":546.9423849048862}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:46:40+01","p50":292.46323740625,"p95":409.63920697818253,"p99":469.5404719516118}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:46:41+01","p50":227.675191125,"p95":503.85976609547976,"p99":553.924288017417}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:46:42+01","p50":226.64132925,"p95":603.5267217380252,"p99":644.8777912905831}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:46:43+01","p50":199.1105529375,"p95":643.8826120289554,"p99":672.7115565878887}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:46:44+01","p50":174.5728428828125,"p95":694.4032371676702,"p99":736.6072573001676}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:46:45+01","p50":172.085016125,"p95":743.4974839411611,"p99":795.3485996004028}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:46:46+01","p50":183.005939609375,"p95":754.6950008615736,"p99":786.6471891985135}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:46:47+01","p50":208.466747328125,"p95":706.2681708744914,"p99":737.65150653682}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:46:48+01","p50":246.9391240390625,"p95":590.9976223694864,"p99":665.6865845471425}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:46:49+01","p50":216.42823199999998,"p95":597.9715750012991,"p99":636.8980246520805}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:46:50+01","p50":251.113149375,"p95":581.0220640798034,"p99":619.7855946713543}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:46:51+01","p50":233.2108146328125,"p95":570.2510566929836,"p99":624.6462220877168}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:46:52+01","p50":223.53510228125,"p95":576.7560597538044,"p99":601.9914905856793}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:46:53+01","p50":256.2180309140625,"p95":519.3446504099135,"p99":552.7567635974259}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:46:54+01","p50":244.90737917187496,"p95":551.1302996945136,"p99":583.3979395160695}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:46:55+01","p50":244.2594149296875,"p95":578.3673924578494,"p99":611.230872182735}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:46:56+01","p50":271.960776625,"p95":506.1509154173652,"p99":543.6596564271631}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:46:57+01","p50":251.7292739921875,"p95":514.4367051248188,"p99":548.8995316722279}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:46:58+01","p50":257.4327385390625,"p95":551.2531465787594,"p99":600.7844830728993}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:46:59+01","p50":226.29275295918367,"p95":573.46389100471,"p99":607.488826888814}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:47:00+01","p50":158.01631525510203,"p95":688.4556226623076,"p99":726.1140858411645}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:47:01+01","p50":263.582781,"p95":785.0761925368176,"p99":834.3156718260684}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:47:02+01","p50":271.7865777346939,"p95":618.5424746651571,"p99":675.8486690594968}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:47:03+01","p50":251.528862140625,"p95":479.0324366316913,"p99":508.13196977759077}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:47:04+01","p50":272.764998,"p95":463.1456154859989,"p99":492.5051863097601}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:47:05+01","p50":251.34881190625,"p95":515.0898139056978,"p99":551.6839977448759}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:47:06+01","p50":178.59173807812502,"p95":607.2977458906694,"p99":650.6791628030777}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:47:07+01","p50":228.528214484375,"p95":612.7287301368407,"p99":662.3054589351979}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:47:08+01","p50":269.133687015625,"p95":487.560907427792,"p99":521.3191350775146}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:47:09+01","p50":289.095168,"p95":459.2765764709546,"p99":500.31729044287636}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:47:10+01","p50":237.16472143750002,"p95":495.3849000834253,"p99":519.7289208531666}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:47:11+01","p50":290.9932819375,"p95":463.7811181815878,"p99":519.7174620174709}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:47:12+01","p50":258.854821328125,"p95":480.86759390169664,"p99":517.8000236548042}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:47:13+01","p50":240.463201625,"p95":585.2571056350987,"p99":641.181980439687}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:47:14+01","p50":217.14943262500003,"p95":644.3636885138629,"p99":682.302154629669}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:47:15+01","p50":238.20522790625,"p95":592.661404156106,"p99":620.7804603361654}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:47:16+01","p50":253.40245743877554,"p95":538.3297126158285,"p99":593.0840711909748}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:47:17+01","p50":297.05636955102045,"p95":499.91498118751207,"p99":521.7519758634403}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:47:18+01","p50":288.1675955918367,"p95":491.402148217257,"p99":544.5610512041274}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:47:19+01","p50":260.479531578125,"p95":510.95569657420856,"p99":609.1119799084473}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:47:20+01","p50":284.88819455102043,"p95":546.3360550552809,"p99":593.226342864191}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:47:21+01","p50":316.47126771874997,"p95":397.17570840094487,"p99":545.4049952443041}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:47:22+01","p50":300.7380733673469,"p95":390.06157530521165,"p99":434.4400018280599}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:47:23+01","p50":317.3342994296875,"p95":471.1538393901341,"p99":498.7268114635818}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:47:24+01","p50":312.3114796953125,"p95":479.9899206529178,"p99":508.73059234328105}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:47:25+01","p50":303.44096385714283,"p95":432.24111573472936,"p99":481.15480448994924}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:47:26+01","p50":295.2548508469387,"p95":414.8377016582926,"p99":454.60888461715126}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:47:27+01","p50":282.79908851562504,"p95":442.81139118377405,"p99":472.3974882147641}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:47:28+01","p50":265.6355371020408,"p95":542.0216534247596,"p99":598.2860635018455}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:47:29+01","p50":306.47830520408166,"p95":520.2475861884038,"p99":598.9080517879603}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:47:30+01","p50":336.10097310204077,"p95":521.6890196125762,"p99":590.9175295823351}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:47:31+01","p50":327.7472398333333,"p95":683.1995407896691,"p99":729.6082376169705}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:47:32+01","p50":269.0071397142857,"p95":631.085476807135,"p99":666.320322275085}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:47:33+01","p50":295.03508512244895,"p95":589.338263032539,"p99":631.7643346847112}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:47:34+01","p50":249.8829897142857,"p95":583.8902808736984,"p99":619.9919637827294}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:47:35+01","p50":252.86402424489796,"p95":573.0543828894796,"p99":598.4470002061379}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:47:36+01","p50":219.82085685714287,"p95":579.4600081886587,"p99":621.7511878869092}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:47:37+01","p50":245.50718857142854,"p95":616.2387932650793,"p99":678.351689813037}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:47:38+01","p50":250.49263643877552,"p95":602.5751674895109,"p99":641.185069785058}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:47:39+01","p50":233.51451746938775,"p95":612.6201817991691,"p99":648.9237323123665}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:47:40+01","p50":216.0034506122449,"p95":569.0684257077188,"p99":620.8073523999672}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:47:41+01","p50":250.4272383671875,"p95":600.218748778727,"p99":656.0170346708195}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:47:42+01","p50":206.4858886122449,"p95":629.3186483907494,"p99":678.8911320336118}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:47:43+01","p50":277.00825892857137,"p95":535.3907336961008,"p99":592.3082711132507}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:47:44+01","p50":252.9347802857143,"p95":498.12035919245324,"p99":617.8742110423693}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:47:45+01","p50":254.9217653061224,"p95":517.4190094289047,"p99":564.0159873910244}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:47:46+01","p50":267.98508674489796,"p95":537.5984335198178,"p99":584.562653411211}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:47:47+01","p50":311.4633385816326,"p95":519.1129243049223,"p99":541.8988053559627}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:47:48+01","p50":340.391139744898,"p95":471.9430775032337,"p99":497.39183832360317}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:47:49+01","p50":367.09360151020417,"p95":520.4733203573115,"p99":564.6715372804565}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:47:50+01","p50":337.7105508775511,"p95":530.2094405821729,"p99":578.1108267085857}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:47:51+01","p50":300.3466880612245,"p95":571.241293134362,"p99":611.3414432796807}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:47:52+01","p50":247.19063304081632,"p95":647.1589957449572,"p99":674.28939230445}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:47:53+01","p50":255.582212125,"p95":637.857276136227,"p99":677.6535077579799}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:47:54+01","p50":296.7022103367347,"p95":451.5140622661691,"p99":546.5301015580468}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:47:55+01","p50":283.5862878367347,"p95":440.25651179571787,"p99":468.89946693176364}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:47:56+01","p50":314.56377830612246,"p95":438.53360951214563,"p99":480.9842112853651}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:47:57+01","p50":302.2940052857143,"p95":455.12424920122913,"p99":495.88523729636097}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:47:58+01","p50":268.82244810204077,"p95":569.849262972912,"p99":620.3263257291067}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:47:59+01","p50":216.31429573469387,"p95":611.0886964712064,"p99":647.7019792534868}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:48:00+01","p50":217.94831810204082,"p95":594.550644939399,"p99":650.7976690020821}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:48:01+01","p50":313.5328812222222,"p95":723.875334869905,"p99":775.6773649439392}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:48:02+01","p50":250.93725970408164,"p95":685.7735414509722,"p99":720.7377627211465}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:48:03+01","p50":202.44430571428572,"p95":647.4959573656823,"p99":680.689731691963}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:48:04+01","p50":183.31401714285715,"p95":671.2769395130892,"p99":710.5193306389084}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:48:05+01","p50":182.30028423469386,"p95":587.8527808406545,"p99":629.5860477890368}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:48:06+01","p50":208.96191375510207,"p95":595.1338501026898,"p99":635.7649387988863}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:48:07+01","p50":151.1706621836735,"p95":615.8021337093631,"p99":654.5062712858038}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:48:08+01","p50":202.9434863877551,"p95":610.7181859192923,"p99":644.4745153171597}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:48:09+01","p50":216.3411271122449,"p95":554.995367901815,"p99":612.3734393997192}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:48:10+01","p50":251.48215348979588,"p95":604.7923876059812,"p99":638.4972198188}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:48:11+01","p50":267.63262057142856,"p95":561.875279334098,"p99":599.8694049838954}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:48:12+01","p50":270.4322100612245,"p95":605.7305240160875,"p99":641.9719404369918}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:48:13+01","p50":307.4224391836735,"p95":466.3995110011762,"p99":523.641878718961}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:48:14+01","p50":291.61121361224497,"p95":441.45558275731696,"p99":480.8666306603563}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:48:15+01","p50":262.130187377551,"p95":575.5714525922123,"p99":611.5036096487136}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:48:16+01","p50":264.17186257142856,"p95":607.0769702199005,"p99":645.565693578789}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:48:17+01","p50":228.8468317142857,"p95":719.1735706277701,"p99":754.8677493859416}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:48:18+01","p50":219.38354603061225,"p95":748.7627153832609,"p99":772.63493998806}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:48:19+01","p50":166.42149718367344,"p95":757.3193736554908,"p99":788.6931652842703}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:48:20+01","p50":199.4642451632653,"p95":691.3631589838145,"p99":728.2780502629342}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:48:21+01","p50":197.2340604489796,"p95":638.0026640036712,"p99":673.4952658540275}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:48:22+01","p50":180.60037740816327,"p95":630.5889597556105,"p99":689.0832717456519}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:48:23+01","p50":173.7753414387755,"p95":670.8670350198843,"p99":732.9260544131212}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:48:24+01","p50":213.12469955102043,"p95":750.9354503928142,"p99":805.5751668207803}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:48:25+01","p50":247.2843455714286,"p95":736.4944946133068,"p99":780.7774727300904}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:48:26+01","p50":218.10012491836736,"p95":730.837990191177,"p99":778.9385891134154}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:48:27+01","p50":262.002551122449,"p95":700.5616359582401,"p99":756.9048626470887}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:48:28+01","p50":289.7629767040816,"p95":672.3096414120706,"p99":728.4566563747978}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:48:29+01","p50":264.45821714285717,"p95":726.7055618058066,"p99":774.2369000614326}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:48:30+01","p50":262.5185723265306,"p95":704.4912015363733,"p99":758.7186197180715}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:48:31+01","p50":275.39993044444446,"p95":820.9562307042065,"p99":866.4028677042666}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:48:32+01","p50":263.83988056944446,"p95":791.1161073841017,"p99":823.3289688891775}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:48:33+01","p50":293.15725185714285,"p95":714.45539924708,"p99":746.5249241090489}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:48:34+01","p50":277.87784152040814,"p95":606.5874947062987,"p99":643.4790099747429}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:48:35+01","p50":315.46635521428567,"p95":585.6415155324551,"p99":638.0046302508625}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:48:36+01","p50":293.73228835714286,"p95":568.4201803959612,"p99":601.677044758728}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:48:37+01","p50":304.021280755102,"p95":534.1499460419994,"p99":566.0640603125194}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:48:38+01","p50":291.27754679591834,"p95":596.8311867759784,"p99":637.7817745441773}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:48:39+01","p50":291.7688119591836,"p95":570.5321142449335,"p99":606.8888851653304}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:48:40+01","p50":220.57998365306125,"p95":648.0817195437627,"p99":690.4200619598942}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:48:41+01","p50":181.35881422448978,"p95":630.6161193099124,"p99":663.1168061252422}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:48:42+01","p50":233.47329135714284,"p95":629.8588389740045,"p99":681.69014375104}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:48:43+01","p50":206.12514671428568,"p95":634.7490289664078,"p99":668.1127304456902}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:48:44+01","p50":230.05858548979592,"p95":610.4103756934267,"p99":647.3481937841301}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:48:45+01","p50":227.6803363163265,"p95":652.7560365684249,"p99":674.2915624855079}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:48:46+01","p50":219.16174310204082,"p95":654.8532907665656,"p99":681.5681750076035}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:48:47+01","p50":261.55030512244895,"p95":701.8015864900239,"p99":745.0727206702061}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:48:48+01","p50":199.50218967346942,"p95":720.286582982032,"p99":761.7266217137771}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:48:49+01","p50":207.7118072361111,"p95":697.3990260208933,"p99":737.2076939909625}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:48:50+01","p50":229.83912271428568,"p95":642.5168307631476,"p99":718.6087997955627}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:48:51+01","p50":199.4962662244898,"p95":725.2457697092244,"p99":767.3374947650689}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:48:52+01","p50":225.12055385714282,"p95":792.3813827129654,"p99":834.9856485649822}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:48:53+01","p50":275.20692365306127,"p95":767.3419334572144,"p99":812.4921307580223}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:48:54+01","p50":287.75460132653063,"p95":615.8124095386316,"p99":644.4055491448177}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:48:55+01","p50":324.6991015612245,"p95":450.5179323133089,"p99":481.17750134109593}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:48:56+01","p50":309.69928067346933,"p95":462.22181212931645,"p99":494.2742319524162}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:48:57+01","p50":336.73044108163265,"p95":432.12092161979996,"p99":494.46356324406554}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:48:58+01","p50":298.5005638163266,"p95":536.7536071167829,"p99":577.6714997420926}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:48:59+01","p50":256.16331506122447,"p95":630.5773388793722,"p99":674.6860054113004}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:49:00+01","p50":315.01835883333337,"p95":628.3399353781933,"p99":659.8670093930912}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:49:01+01","p50":326.41320616666667,"p95":664.2803946569802,"p99":705.5644797870874}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:49:02+01","p50":333.11842710204076,"p95":658.0510888573646,"p99":738.0648201247335}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:49:03+01","p50":267.6654635714286,"p95":630.5173188595422,"p99":666.5418691842566}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:49:04+01","p50":250.1251854489796,"p95":668.4904877046738,"p99":715.8688544002647}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:49:05+01","p50":239.87387402040818,"p95":700.7402548585661,"p99":732.7372357321857}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:49:06+01","p50":308.1462176836735,"p95":672.9652978880952,"p99":732.0085463307366}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:49:07+01","p50":271.5102277142857,"p95":635.6537464520687,"p99":680.2661475241514}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:49:08+01","p50":194.82290154081633,"p95":678.943645395757,"p99":708.2607905562362}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:49:09+01","p50":195.82785436734693,"p95":664.6283447619234,"p99":716.5776645683796}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:49:10+01","p50":179.55097002040816,"p95":652.1014714476751,"p99":708.4647517406023}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:49:11+01","p50":267.15907522448975,"p95":662.8770245201224,"p99":697.7052784995032}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:49:12+01","p50":252.65635636734692,"p95":699.9834343847629,"p99":756.2344805371079}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:49:13+01","p50":168.83482230612245,"p95":775.6032698063703,"p99":802.3864349583652}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:49:14+01","p50":177.04911975510205,"p95":759.9058693882338,"p99":794.8441235651238}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:49:15+01","p50":215.46989947959182,"p95":827.9849969941054,"p99":863.5576883822783}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:49:16+01","p50":244.72863320408163,"p95":819.8267221073417,"p99":861.9256245875945}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:49:17+01","p50":247.65149255102043,"p95":816.893547750237,"p99":861.397996304507}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:49:18+01","p50":190.79785398979593,"p95":755.5367149874246,"p99":798.9995327060509}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:49:19+01","p50":177.90122285714284,"p95":842.5017985169943,"p99":901.0984668560262}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:49:20+01","p50":181.24320881632656,"p95":723.0778248226597,"p99":753.7043911433326}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:49:21+01","p50":232.86854135714287,"p95":807.4350707396437,"p99":849.8820889607906}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:49:22+01","p50":226.3353195408163,"p95":763.0796598611014,"p99":802.684185327797}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:49:23+01","p50":285.10776713265307,"p95":755.7566123523562,"p99":806.283001315978}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:49:24+01","p50":278.96483992857145,"p95":564.1073512942185,"p99":601.303322523529}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:49:25+01","p50":230.11129585714284,"p95":572.2232770836935,"p99":621.8223184013094}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:49:26+01","p50":260.4715514081633,"p95":589.653434636575,"p99":646.3135151688205}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:49:27+01","p50":297.49965203061225,"p95":605.7346359881351,"p99":653.595959161232}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:49:28+01","p50":324.20771035714284,"p95":612.1225503336275,"p99":664.0239953370972}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:49:29+01","p50":242.86036108163265,"p95":637.8542562071713,"p99":691.5349705285105}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:49:30+01","p50":326.9510569444444,"p95":659.4176353137568,"p99":689.3172762019271}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:49:31+01","p50":422.1142157777778,"p95":660.2246952363407,"p99":701.9573469096808}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:49:32+01","p50":407.29931266666665,"p95":686.1880085401222,"p99":768.2805382751241}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:49:33+01","p50":370.35678734693875,"p95":631.171413237976,"p99":691.6925847327595}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:49:34+01","p50":385.4349486527778,"p95":620.3432873032896,"p99":664.9538954432413}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:49:35+01","p50":349.7823824591837,"p95":528.6318580349683,"p99":570.5756554172879}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:49:36+01","p50":293.44755961224485,"p95":581.4513809351383,"p99":631.3032772461875}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:49:37+01","p50":158.1090405,"p95":771.046252974354,"p99":833.8409945352173}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:49:38+01","p50":210.12150216326532,"p95":810.136747740759,"p99":850.43340344699}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:49:39+01","p50":233.41591395918365,"p95":787.104556139013,"p99":821.9807377053685}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:49:40+01","p50":211.39938287755103,"p95":649.5231221465376,"p99":681.7740350028846}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:49:41+01","p50":187.79516838775507,"p95":694.2393694875407,"p99":737.8125144993144}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:49:42+01","p50":178.11608097959183,"p95":784.4959204703014,"p99":810.8484535742016}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:49:43+01","p50":212.56817719444442,"p95":807.9591018700502,"p99":859.8467408750333}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:49:44+01","p50":245.03757230612246,"p95":809.037358257567,"p99":874.7658788003873}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:49:45+01","p50":196.24992130612245,"p95":898.4895167553177,"p99":953.3507689584064}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:49:46+01","p50":190.3318681388889,"p95":914.2285259831106,"p99":959.8850585649524}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:49:47+01","p50":195.5173472346939,"p95":959.3202006818742,"p99":1000.9494398815613}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:49:48+01","p50":184.50272018367346,"p95":975.0540629023902,"p99":1028.0474435122735}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:49:49+01","p50":172.43706444444445,"p95":1000.5504190296685,"p99":1039.3101055874263}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:49:50+01","p50":176.12599834693876,"p95":991.955907383474,"p99":1056.651332457127}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:49:51+01","p50":192.83361381632656,"p95":929.4040478826789,"p99":961.6774082491893}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:49:52+01","p50":171.06279916666668,"p95":1050.2466231297776,"p99":1086.2698165653587}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:49:53+01","p50":187.69819093877553,"p95":966.6000803524362,"p99":1038.5667927939362}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:49:54+01","p50":262.93424997222223,"p95":805.2355285315774,"p99":835.6190509706734}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:49:55+01","p50":263.7011396666667,"p95":749.0425741900706,"p99":785.7489275768019}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:49:56+01","p50":254.5929839897959,"p95":689.0688472197627,"p99":735.3318426415015}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:49:57+01","p50":229.01028008163266,"p95":736.0207365457726,"p99":767.4870870355835}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:49:58+01","p50":177.41243418055558,"p95":821.9965485097788,"p99":868.7488204838979}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:49:59+01","p50":177.5423672142857,"p95":854.8054950166754,"p99":891.4001902251564}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:50:00+01","p50":232.751821125,"p95":792.7245134323476,"p99":841.6830461617395}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:50:01+01","p50":357.09595363999995,"p95":807.6742122680157,"p99":868.5508825909972}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:50:02+01","p50":384.88291988888886,"p95":693.7077141471891,"p99":748.7991827527824}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:50:03+01","p50":341.3901864583333,"p95":618.2006362886523,"p99":671.7348371837898}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:50:04+01","p50":290.0262582777778,"p95":551.2350136023898,"p99":590.4993630156627}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:50:05+01","p50":247.53281379591837,"p95":585.1334404714663,"p99":671.7946695821286}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:50:06+01","p50":245.1405831734694,"p95":627.3119392067134,"p99":672.2021893241043}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:50:07+01","p50":263.0313428333333,"p95":710.7437825144805,"p99":757.3655499176946}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:50:08+01","p50":313.2946479489796,"p95":632.3995838705497,"p99":690.6386440958757}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:50:09+01","p50":309.71640130612246,"p95":646.5312788463018,"p99":692.3444851869435}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:50:10+01","p50":291.5116583333333,"p95":678.9456039414844,"p99":719.4741016060143}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:50:11+01","p50":247.4525357638889,"p95":715.3150937066807,"p99":770.202491234325}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:50:12+01","p50":256.91562170833333,"p95":822.8229415507406,"p99":857.3877650207448}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:50:13+01","p50":350.2451974583333,"p95":694.180970263835,"p99":734.4712791127859}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:50:14+01","p50":295.8837635,"p95":643.1692278796513,"p99":676.5734446503601}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:50:15+01","p50":249.25259957142856,"p95":731.039217044241,"p99":783.4135469863903}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:50:16+01","p50":282.1010397222222,"p95":621.2189683105596,"p99":686.1081747415816}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:50:17+01","p50":317.7719535714286,"p95":620.9165889371661,"p99":662.0153017419891}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:50:18+01","p50":304.9711680833334,"p95":711.6286877260729,"p99":765.1888006898537}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:50:19+01","p50":245.3608416122449,"p95":753.3537800879025,"p99":788.3542437851373}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:50:20+01","p50":267.3894616527778,"p95":765.120830193491,"p99":806.7844042345974}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:50:21+01","p50":252.00112095833333,"p95":795.4309959551565,"p99":846.250153396117}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:50:22+01","p50":257.9827359166666,"p95":848.151917783076,"p99":896.2650979179803}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:50:23+01","p50":274.145184,"p95":821.2434141199059,"p99":860.4601457637034}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:50:24+01","p50":217.65360108333334,"p95":830.1614667523102,"p99":874.2310621651154}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:50:25+01","p50":233.60031431944446,"p95":907.1971434224346,"p99":955.9839069642389}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:50:26+01","p50":240.85364033333335,"p95":878.2735575402669,"p99":921.9643030966624}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:50:27+01","p50":224.50892091666665,"p95":972.9795623815575,"p99":1024.9794388472706}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:50:28+01","p50":219.21432555555555,"p95":932.9332859249272,"p99":965.5029334533549}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:50:29+01","p50":245.21078283333335,"p95":898.8331366967443,"p99":935.8155560190335}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:50:30+01","p50":251.73489925,"p95":924.6551552393952,"p99":963.7606569456129}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:50:31+01","p50":299.15889634,"p95":1009.3684660938048,"p99":1067.5733165765564}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:50:32+01","p50":283.00178158333335,"p95":892.9365963166667,"p99":972.7566402801285}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:50:33+01","p50":288.59570141666666,"p95":642.9699988920811,"p99":704.2038800256644}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:50:34+01","p50":233.91520419444444,"p95":640.5056968653475,"p99":692.4353522680283}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:50:35+01","p50":243.48975591666667,"p95":629.7289437817052,"p99":662.980682823232}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:50:36+01","p50":367.4264822777778,"p95":570.6297589094617,"p99":621.7960802736907}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:50:37+01","p50":412.21100561111115,"p95":531.0147252686044,"p99":560.8520791238547}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:50:38+01","p50":302.31776891666664,"p95":538.7371326112262,"p99":570.3056439926338}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:50:39+01","p50":350.01690983333333,"p95":557.9754440309506,"p99":598.8796463162976}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:50:40+01","p50":369.10604508333336,"p95":548.6413507899823,"p99":588.074490153387}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:50:41+01","p50":352.52418083333333,"p95":509.1970042143006,"p99":545.1269255462641}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:50:42+01","p50":275.8046955,"p95":610.1278769276375,"p99":653.1770788058777}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:50:43+01","p50":337.0602440555556,"p95":528.0543318146509,"p99":562.3203251728974}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:50:44+01","p50":333.31347808333334,"p95":520.0800473239761,"p99":550.8248432725978}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:50:45+01","p50":369.3045985833333,"p95":542.7586470700342,"p99":585.1772218148546}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:50:46+01","p50":385.1074580972222,"p95":594.2531054522882,"p99":637.8252573396504}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:50:47+01","p50":359.75081897222225,"p95":616.818020960564,"p99":658.4227053491068}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:50:48+01","p50":318.0803700833333,"p95":592.3211290232764,"p99":636.3279250522747}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:50:49+01","p50":352.8745123888889,"p95":575.7061632431127,"p99":620.2092781273814}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:50:50+01","p50":294.6118072777777,"p95":732.6763470138973,"p99":795.7476610492917}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:50:51+01","p50":267.6389947777778,"p95":853.6423991551874,"p99":893.7280611513605}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:50:52+01","p50":298.8672672083333,"p95":720.270760875473,"p99":760.1027405553074}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:50:53+01","p50":363.66741581944444,"p95":602.2334419589254,"p99":672.6714655328321}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:50:54+01","p50":360.3935595138889,"p95":563.8654476013079,"p99":635.6237797320375}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:50:55+01","p50":353.06049720833335,"p95":673.2495430821555,"p99":709.3764554137927}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:50:56+01","p50":345.24859388888893,"p95":716.6402988058396,"p99":749.9069588414832}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:50:57+01","p50":316.7553612777778,"p95":629.8471193551975,"p99":736.9077736421423}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:50:58+01","p50":350.3084880833333,"p95":540.2617259051588,"p99":566.9858631039102}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:50:59+01","p50":329.7124712222222,"p95":654.1963415827814,"p99":692.2894115461464}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:51:00+01","p50":363.11332699999997,"p95":638.1175898229219,"p99":677.778917627408}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:51:01+01","p50":361.9246164,"p95":728.8194141236572,"p99":772.4014526322086}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:51:02+01","p50":344.6417695,"p95":710.514338173093,"p99":767.9764669562712}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:51:03+01","p50":315.6137375555555,"p95":651.1352718685192,"p99":716.6396755126839}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:51:04+01","p50":359.2164488333333,"p95":593.1473668153159,"p99":667.4548380854272}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:51:05+01","p50":341.7379495833333,"p95":622.148440790928,"p99":653.823010676464}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:51:06+01","p50":327.4376976666667,"p95":559.2251195744841,"p99":582.0864893680587}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:51:07+01","p50":295.15239225,"p95":638.0812943355162,"p99":685.7684850193697}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:51:08+01","p50":258.798005,"p95":675.9539531302865,"p99":723.2838926055374}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:51:09+01","p50":206.4661363888889,"p95":772.3226981780624,"p99":790.5112328309054}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:51:10+01","p50":172.57769662500002,"p95":866.0717009368169,"p99":914.9698191842862}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:51:11+01","p50":145.25547579166667,"p95":946.3163659245613,"p99":977.525876203806}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:51:12+01","p50":193.2539854722222,"p95":818.383654799432,"p99":913.586492557867}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:51:13+01","p50":225.6351079722222,"p95":768.7019840160299,"p99":820.8716055379806}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:51:14+01","p50":229.01689916666666,"p95":747.215407369273,"p99":790.2111607016062}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:51:15+01","p50":221.292599,"p95":750.4961695073712,"p99":808.9807229809342}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:51:16+01","p50":196.37402872222222,"p95":682.0777072098721,"p99":726.7288219186311}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:51:17+01","p50":190.16513420833334,"p95":812.5413360454965,"p99":849.8157338033662}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:51:18+01","p50":232.73869458333334,"p95":847.4000609675222,"p99":896.2574517781492}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:51:19+01","p50":360.4496757499999,"p95":759.564010738389,"p99":802.210356711471}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:51:20+01","p50":382.0703809583333,"p95":584.8737398498881,"p99":641.557236670868}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:51:21+01","p50":378.28542605555555,"p95":537.2307462181835,"p99":572.3123693292084}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:51:22+01","p50":373.43254791666664,"p95":508.34406448673883,"p99":556.2622691921134}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:51:23+01","p50":313.0650181666667,"p95":555.1118203865398,"p99":608.1270624399718}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:51:24+01","p50":222.93760040277778,"p95":658.7465430108713,"p99":704.0114360467727}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:51:25+01","p50":207.34105824999997,"p95":702.0649725402367,"p99":753.6413390549736}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:51:26+01","p50":227.73295469444443,"p95":747.5707576870867,"p99":793.3291291221905}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:51:27+01","p50":272.50558872222217,"p95":764.6816371163563,"p99":813.2427938974666}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:51:28+01","p50":309.5283793055555,"p95":798.9674634760178,"p99":857.6943701513884}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:51:29+01","p50":252.50616216666666,"p95":770.9116246683192,"p99":829.7381835070153}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:51:30+01","p50":271.7295911666667,"p95":674.7639156325763,"p99":747.8882395540962}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:51:31+01","p50":321.60635128,"p95":747.4322600361223,"p99":829.7056477928247}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:51:32+01","p50":318.55685791999997,"p95":728.6779540911377,"p99":796.0917552800045}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:51:33+01","p50":253.72161077777778,"p95":744.3839663405748,"p99":805.5960536532798}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:51:34+01","p50":221.1902518333333,"p95":801.5295453612677,"p99":841.4301221009894}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:51:35+01","p50":235.79668816666666,"p95":947.6681222798079,"p99":1002.7705592868974}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:51:36+01","p50":237.13540481944446,"p95":1035.0199011551333,"p99":1078.6594361096372}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:51:37+01","p50":237.27923127777777,"p95":1005.3791963613697,"p99":1034.6381518837509}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:51:38+01","p50":232.37556958333332,"p95":964.200127590515,"p99":999.270264032548}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:51:39+01","p50":172.6535795,"p95":883.3304856135723,"p99":922.2330521395636}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:51:40+01","p50":235.3118431111111,"p95":1007.030670971171,"p99":1059.2463906274756}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:51:41+01","p50":219.22015383333334,"p95":966.1499451770582,"p99":1013.814959957652}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:51:42+01","p50":225.4246216111111,"p95":937.9560786580615,"p99":990.525491020605}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:51:43+01","p50":266.998449,"p95":768.472677443443,"p99":790.68812258214}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:51:44+01","p50":296.4195334583334,"p95":759.7153912791351,"p99":807.9325334881524}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:51:45+01","p50":329.8890652777778,"p95":739.2803737327665,"p99":776.3784747708817}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:51:46+01","p50":369.5049073333334,"p95":765.0827141163503,"p99":812.9511989784386}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:51:47+01","p50":405.6622713611111,"p95":658.6494135970574,"p99":725.5720878641205}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:51:48+01","p50":376.35138683333327,"p95":636.9394394852822,"p99":678.2554176954127}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:51:49+01","p50":288.6572325833333,"p95":680.9975577590938,"p99":741.0568015681433}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:51:50+01","p50":337.7684687222222,"p95":668.4837382185967,"p99":730.8973774902496}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:51:51+01","p50":354.4068409166666,"p95":687.3171094744396,"p99":747.503416478281}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:51:52+01","p50":284.7650793333333,"p95":756.0798964911882,"p99":803.8046861301827}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:51:53+01","p50":243.71827522222222,"p95":855.6706006548725,"p99":886.6307743973313}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:51:54+01","p50":239.34775395833333,"p95":829.8078331581468,"p99":864.7351892999089}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:51:55+01","p50":289.6232888888889,"p95":805.6281692743639,"p99":855.7631043456125}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:51:56+01","p50":213.705781625,"p95":680.8015810177518,"p99":716.3165355049147}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:51:57+01","p50":218.7783870833333,"p95":792.4774368050212,"p99":826.3914821593476}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:51:58+01","p50":223.99196520833334,"p95":809.4883584725319,"p99":849.7334008862052}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:51:59+01","p50":225.57330788888888,"p95":837.6030075170193,"p99":910.6171174994888}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:52:00+01","p50":261.6736129444444,"p95":834.8122725692295,"p99":892.5535712757959}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:52:01+01","p50":336.57737352,"p95":887.7374767717981,"p99":954.9117732258312}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:52:02+01","p50":297.7958395416667,"p95":937.9005997186459,"p99":974.8765939926308}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:52:03+01","p50":278.77338683333335,"p95":908.478637977017,"p99":951.2951484017616}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:52:04+01","p50":220.33508416666666,"p95":873.6952795659374,"p99":945.6623612358437}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:52:05+01","p50":218.15404141666667,"p95":804.3374894404935,"p99":887.1643425245933}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:52:06+01","p50":285.3974315555555,"p95":869.5817736027927,"p99":915.3567266545468}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:52:07+01","p50":334.5556079305555,"p95":816.1295129409742,"p99":924.1645395825865}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:52:08+01","p50":306.11594533333334,"p95":791.2671940574018,"p99":823.2272334731022}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:52:09+01","p50":332.39367563999997,"p95":804.1929120134095,"p99":896.089106693659}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:52:10+01","p50":283.97461400000003,"p95":520.8344369048348,"p99":568.1455581599979}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:52:11+01","p50":349.30411050000004,"p95":616.9979405553923,"p99":655.9401847056141}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:52:12+01","p50":375.1463212,"p95":621.2467017191784,"p99":664.0508424550491}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:52:13+01","p50":311.39326124999997,"p95":583.0275376605474,"p99":678.9757754074459}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:52:14+01","p50":365.36966268055556,"p95":517.3713077985053,"p99":556.6780555533378}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:52:15+01","p50":410.59621538,"p95":585.4724612005226,"p99":638.4622551732244}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:52:16+01","p50":322.9337403611111,"p95":538.5112158947547,"p99":617.1411458983166}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:52:17+01","p50":362.5340684166667,"p95":481.27532426773587,"p99":534.3989468561473}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:52:18+01","p50":406.0639786666666,"p95":528.6984088255444,"p99":556.4567750179897}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:52:19+01","p50":378.12681788888887,"p95":549.6776664390405,"p99":590.5081452322896}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:52:20+01","p50":348.86642324999997,"p95":592.8788972859822,"p99":648.0182775590066}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:52:21+01","p50":374.99033488888887,"p95":595.3042290766754,"p99":660.1157346963976}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:52:22+01","p50":394.17485813888896,"p95":602.4777883754703,"p99":666.7406430166192}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:52:23+01","p50":384.54747966666656,"p95":767.4054292080198,"p99":844.0674283322068}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:52:24+01","p50":419.57192394444445,"p95":639.1985472911435,"p99":666.9092054162006}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:52:25+01","p50":385.01371131944444,"p95":593.4732597692307,"p99":671.9583502351868}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:52:26+01","p50":368.3308778611111,"p95":517.6831989334784,"p99":581.6018405815163}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:52:27+01","p50":382.4727758888889,"p95":547.0931887992382,"p99":593.3894839185056}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:52:28+01","p50":370.07349355555556,"p95":620.2916959594695,"p99":706.923731942607}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:52:29+01","p50":364.36369145833334,"p95":590.376672124801,"p99":655.1386691270626}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:52:30+01","p50":326.19569012,"p95":739.1644861075238,"p99":847.881542571065}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:52:31+01","p50":457.39104491999996,"p95":889.4244497556896,"p99":930.6864593652197}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:52:32+01","p50":385.83988927999997,"p95":745.2166101923433,"p99":821.6973727743919}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:52:33+01","p50":447.7296030416667,"p95":686.4642273047496,"p99":744.4630491057422}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:52:34+01","p50":364.9335878333334,"p95":643.830875092489,"p99":731.5411683412381}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:52:35+01","p50":239.17875716666666,"p95":789.9590224970556,"p99":837.0256470445022}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:52:36+01","p50":232.99302149999997,"p95":773.1262954996162,"p99":809.0378805053864}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:52:37+01","p50":196.97383750000003,"p95":767.206744473455,"p99":798.0031691178723}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:52:38+01","p50":224.09097855555555,"p95":830.6913760480581,"p99":861.5883874630814}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:52:39+01","p50":248.64919499999996,"p95":770.9210702745752,"p99":814.2055792747278}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:52:40+01","p50":288.8832440555555,"p95":791.8469247168061,"p99":821.6319206646576}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:52:41+01","p50":307.5551613333333,"p95":812.6314984557199,"p99":875.3220388074997}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:52:42+01","p50":263.26160687500004,"p95":693.3723035327848,"p99":784.438192099159}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:52:43+01","p50":275.0909941388889,"p95":675.7917381694069,"p99":720.7460705019286}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:52:44+01","p50":268.8368551666667,"p95":694.8647338612408,"p99":747.4254714066393}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:52:45+01","p50":310.0996642222222,"p95":644.2566701408876,"p99":712.6995620103797}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:52:46+01","p50":326.9766276388889,"p95":685.6952607063548,"p99":738.8868778545265}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:52:47+01","p50":409.75493,"p95":690.136426862182,"p99":732.7927522789984}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:52:48+01","p50":436.1781789583334,"p95":602.6775699672609,"p99":662.0720164259795}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:52:49+01","p50":397.2522094722222,"p95":712.6670809711751,"p99":752.8393592412498}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:52:50+01","p50":385.7045836944444,"p95":679.4701803967641,"p99":727.6730696094652}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:52:51+01","p50":416.7350911527778,"p95":567.1270516653473,"p99":629.982962326778}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:52:52+01","p50":395.3485313333333,"p95":657.659761369291,"p99":707.075718515871}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:52:53+01","p50":324.32118744444443,"p95":821.3542146693491,"p99":859.0360721342458}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:52:54+01","p50":304.3428443055555,"p95":813.4875807056729,"p99":866.0953501121521}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:52:55+01","p50":297.1517840277778,"p95":792.7633554876207,"p99":844.8658474071922}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:52:56+01","p50":332.60000537499997,"p95":826.6443667570861,"p99":890.5930670700983}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:52:57+01","p50":372.30711272222226,"p95":758.0525675261204,"p99":809.7245128334522}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:52:58+01","p50":304.41271751388894,"p95":858.1220593646757,"p99":902.3199152902644}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:52:59+01","p50":341.358296125,"p95":791.3732858433074,"p99":831.5479848800275}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:53:00+01","p50":368.5528496,"p95":741.1958927923525,"p99":772.8089488782324}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:53:01+01","p50":394.45223963999996,"p95":850.2547978410255,"p99":902.0851749574022}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:53:02+01","p50":344.71889369999997,"p95":839.9161523393681,"p99":907.6541181421223}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:53:03+01","p50":377.2381041666666,"p95":701.9813667115608,"p99":762.6677455976487}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:53:04+01","p50":332.411377625,"p95":767.6817025133928,"p99":811.0842237062594}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:53:05+01","p50":394.9147620555555,"p95":703.4400714141362,"p99":798.9047652727122}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:53:06+01","p50":343.6006621666667,"p95":597.6747144737091,"p99":644.5087135134887}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:53:07+01","p50":378.1326162222222,"p95":581.184018396926,"p99":621.3988198252964}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:53:08+01","p50":340.17102,"p95":725.7103397322296,"p99":767.4708243471863}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:53:09+01","p50":386.2321766666667,"p95":690.4641615081554,"p99":750.2768240456156}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:53:10+01","p50":341.84729439999995,"p95":713.5109754160393,"p99":779.9890986686635}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:53:11+01","p50":318.49557716666664,"p95":696.5810967809877,"p99":732.9240224781194}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:53:12+01","p50":375.0555484722222,"p95":643.7833107005183,"p99":709.2490573056906}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:53:13+01","p50":340.18869461111115,"p95":637.37439026828,"p99":686.0178707066663}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:53:14+01","p50":396.09816787499994,"p95":704.4209652609845,"p99":765.3058866658316}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:53:15+01","p50":389.4643341944445,"p95":633.8318001342496,"p99":731.0643432822428}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:53:16+01","p50":284.7880068333333,"p95":678.5356099516601,"p99":713.6480968486939}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:53:17+01","p50":376.0422746944444,"p95":739.9962334918392,"p99":775.5075578829145}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:53:18+01","p50":409.1034925000001,"p95":634.43206756217,"p99":702.0922816836243}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:53:19+01","p50":382.5472612777778,"p95":624.4558370640053,"p99":685.2042066444998}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:53:20+01","p50":377.47892422222225,"p95":641.320727382314,"p99":685.4550028454837}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:53:21+01","p50":385.94666960000006,"p95":697.0560304781274,"p99":755.487760128266}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:53:22+01","p50":438.45296852777784,"p95":642.9136879666701,"p99":671.3878626511779}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:53:23+01","p50":437.10219468055556,"p95":574.6737047722418,"p99":619.8709956932539}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:53:24+01","p50":379.16992286111116,"p95":551.4591229632356,"p99":581.4295108524966}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:53:25+01","p50":375.40319275,"p95":587.6738454205366,"p99":636.0937416565845}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:53:26+01","p50":401.31185567999995,"p95":656.4257006806112,"p99":687.5971995276983}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:53:27+01","p50":456.27446533333335,"p95":585.4698940101434,"p99":622.9307585471022}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:53:28+01","p50":396.20219716,"p95":634.0018777546911,"p99":678.2594843880145}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:53:29+01","p50":434.50417411111107,"p95":623.0007670908152,"p99":650.724139146936}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:53:30+01","p50":424.5066547,"p95":672.9092010085528,"p99":717.2891449701591}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:53:31+01","p50":434.75345056,"p95":665.5107148074112,"p99":708.7747189676713}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:53:32+01","p50":483.16504688000003,"p95":681.7897638719528,"p99":745.655702756788}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:53:33+01","p50":413.39353927777773,"p95":590.0480181090198,"p99":655.9893154499312}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:53:34+01","p50":415.0310074166666,"p95":611.5296181354103,"p99":662.7930506260986}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:53:35+01","p50":371.86373125,"p95":633.9961798270682,"p99":693.6297971340542}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:53:36+01","p50":351.80695497222223,"p95":674.0125164805722,"p99":726.6331989368584}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:53:37+01","p50":296.48255159999997,"p95":888.5716013743852,"p99":954.5629962548713}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:53:38+01","p50":292.04048416666666,"p95":879.1548737936603,"p99":949.9540517755048}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:53:39+01","p50":246.0174892777778,"p95":824.965561712392,"p99":897.8978984506731}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:53:40+01","p50":237.7705973888889,"p95":740.0725950936458,"p99":803.3794989738702}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:53:41+01","p50":231.15823144444445,"p95":803.5425579858188,"p99":844.9466545300922}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:53:42+01","p50":216.43426149999996,"p95":824.2761332189064,"p99":879.2194844873964}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:53:43+01","p50":249.48521912,"p95":894.38316735234,"p99":929.0837991929143}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:53:44+01","p50":283.45253104166665,"p95":846.5877458305217,"p99":898.3621037102973}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:53:45+01","p50":316.94064724,"p95":849.6438059263613,"p99":886.5365854898353}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:53:46+01","p50":346.119021,"p95":746.7282455432893,"p99":784.5593017268067}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:53:47+01","p50":363.697700875,"p95":758.7561317782719,"p99":811.5567042350829}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:53:48+01","p50":275.36590816666666,"p95":778.4693146747056,"p99":855.5455380702457}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:53:49+01","p50":290.8124626,"p95":847.7370841571593,"p99":907.3133227836933}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:53:50+01","p50":361.7866794444445,"p95":879.7080822376165,"p99":922.6077304433136}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:53:51+01","p50":357.9138285833333,"p95":845.2916342662621,"p99":888.427658538208}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:53:52+01","p50":330.14235826,"p95":820.7346740064464,"p99":877.160341989295}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:53:53+01","p50":381.0325011111111,"p95":804.7511741871082,"p99":858.7214423855572}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:53:54+01","p50":387.5051856,"p95":822.2259824908244,"p99":885.8470550928803}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:53:55+01","p50":384.6572393,"p95":861.7827497379739,"p99":923.4825342802438}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:53:56+01","p50":328.92843077777775,"p95":773.6982395651048,"p99":827.2388824170542}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:53:57+01","p50":382.76444324000005,"p95":808.9609603459417,"p99":876.9974567397275}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:53:58+01","p50":412.96602607999995,"p95":739.0029966562278,"p99":798.0999166121173}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:53:59+01","p50":342.20051233333334,"p95":749.5185807205829,"p99":782.6177797616576}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:54:00+01","p50":394.701882,"p95":780.0519333322796,"p99":829.0074917296662}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:54:01+01","p50":370.50946704,"p95":956.6801471348592,"p99":1015.9650052171261}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:54:02+01","p50":406.35339740000006,"p95":965.7036588731289,"p99":1032.9974992184282}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:54:03+01","p50":411.4556474,"p95":662.7960667226583,"p99":728.7094533433857}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:54:04+01","p50":406.37992088,"p95":655.2238341901138,"p99":693.1924472899337}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:54:05+01","p50":403.11374272222224,"p95":619.272154066654,"p99":678.9464765433988}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:54:06+01","p50":421.90354444,"p95":543.8354732568521,"p99":612.5916831497136}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:54:07+01","p50":384.632138,"p95":656.6191064848235,"p99":696.8630291268482}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:54:08+01","p50":338.61795136,"p95":673.909299900646,"p99":722.8354763130308}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:54:09+01","p50":429.83440252,"p95":631.3158834182082,"p99":668.301307234765}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:54:10+01","p50":397.8500336666666,"p95":528.9896323749066,"p99":579.3771011491699}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:54:11+01","p50":366.43875280000003,"p95":576.0389320557467,"p99":601.0840738320446}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:54:12+01","p50":377.24842808000005,"p95":626.6542265569894,"p99":679.381295528687}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:54:13+01","p50":432.33425314,"p95":541.1568412028666,"p99":583.7253049821547}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:54:14+01","p50":381.2746884,"p95":614.9666958594009,"p99":691.9696509354982}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:54:15+01","p50":330.803917,"p95":677.1682500547681,"p99":742.1522610069972}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:54:16+01","p50":309.9333351,"p95":758.9540969489733,"p99":813.463337098773}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:54:17+01","p50":358.95499368000003,"p95":737.9214126075019,"p99":778.5644817269149}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:54:18+01","p50":397.663931,"p95":615.086217850904,"p99":668.0484956535645}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:54:19+01","p50":429.00513308,"p95":669.0946374348083,"p99":728.85036035331}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:54:20+01","p50":413.0990724,"p95":622.9957779690224,"p99":664.8194154293737}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:54:21+01","p50":290.07376312,"p95":685.5403460886738,"p99":721.9188822499709}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:54:22+01","p50":311.77640024000004,"p95":728.9294482312265,"p99":801.7153805051167}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:54:23+01","p50":355.1588239166667,"p95":646.052827836689,"p99":695.4105803787842}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:54:24+01","p50":353.45010192,"p95":669.9484256486783,"p99":710.8693406583424}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:54:25+01","p50":335.30985410000005,"p95":691.7627321485306,"p99":726.9044959324378}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:54:26+01","p50":361.13428883999995,"p95":756.4065765432156,"p99":815.5474131759453}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:54:27+01","p50":361.29307375,"p95":696.2274641508275,"p99":765.0978776066285}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:54:28+01","p50":416.3067763,"p95":685.738203201137,"p99":739.8430238757658}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:54:29+01","p50":330.0524271,"p95":744.6750916592404,"p99":783.2408023358279}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:54:30+01","p50":361.60821496,"p95":816.0494606199702,"p99":880.8009484371357}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:54:31+01","p50":433.30588981249997,"p95":908.1685612947623,"p99":984.3471195292307}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:54:32+01","p50":448.93784936000003,"p95":783.5184148589387,"p99":845.8501551917205}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:54:33+01","p50":335.99682996,"p95":731.324016796836,"p99":776.238572359648}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:54:34+01","p50":326.83725564,"p95":767.1644797290053,"p99":810.4942556510016}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:54:35+01","p50":310.5724983,"p95":840.6513940942654,"p99":863.0169384689345}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:54:36+01","p50":373.3023782,"p95":692.7452524695855,"p99":754.0653348087969}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:54:37+01","p50":460.20648517999996,"p95":646.6387354728828,"p99":683.9232266571579}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:54:38+01","p50":332.05750980000005,"p95":674.3638019451939,"p99":729.6529745516267}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:54:39+01","p50":477.50348312000006,"p95":650.8963044132549,"p99":706.2729387592335}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:54:40+01","p50":480.75029911111113,"p95":602.0398662610263,"p99":638.5837775093694}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:54:41+01","p50":408.25061588,"p95":584.343231112489,"p99":633.2007006967955}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:54:42+01","p50":414.2472136,"p95":538.9985778976721,"p99":568.296154559248}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:54:43+01","p50":402.67941962,"p95":677.268038578432,"p99":739.2684555479947}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:54:44+01","p50":421.0484436,"p95":621.4037148245721,"p99":684.0350968217099}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:54:45+01","p50":385.34943350000003,"p95":663.1105020731571,"p99":713.5566042757835}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:54:46+01","p50":363.171233,"p95":670.1359688457547,"p99":713.3349683781001}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:54:47+01","p50":404.91409696,"p95":646.1998698443601,"p99":754.4473631028923}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:54:48+01","p50":397.43670232,"p95":629.2294473823131,"p99":697.1562948713538}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:54:49+01","p50":378.22623488000005,"p95":704.0032898766115,"p99":749.4917683284268}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:54:50+01","p50":323.4843978,"p95":747.7976574681868,"p99":790.1089498177142}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:54:51+01","p50":316.09317272000004,"p95":806.04750913099,"p99":854.3686610661658}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:54:52+01","p50":318.64449682,"p95":841.7025510754512,"p99":896.8443007886262}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:54:53+01","p50":331.94399924000004,"p95":754.7488961179165,"p99":785.4900998050819}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:54:54+01","p50":286.56402147999995,"p95":901.4052410586368,"p99":961.3330722875982}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:54:55+01","p50":289.12937582000006,"p95":1011.6713439832068,"p99":1045.5264822261424}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:54:56+01","p50":248.80357315999998,"p95":1006.6597335838775,"p99":1028.6829051979237}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:54:57+01","p50":187.0497954,"p95":1267.3960736373888,"p99":1334.6425493148404}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:54:58+01","p50":199.72879476,"p95":1354.489945877821,"p99":1400.629926331944}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:54:59+01","p50":216.1498943,"p95":1308.5300745160987,"p99":1331.8017563382855}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:55:00+01","p50":233.1045344,"p95":1284.5872392383894,"p99":1352.0405371455438}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:55:01+01","p50":241.49801943750003,"p95":1287.4417678376371,"p99":1383.3185035379445}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:55:02+01","p50":211.95823230000002,"p95":1361.2079540751129,"p99":1416.453834296713}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:55:03+01","p50":236.40451296,"p95":1329.779486412736,"p99":1381.085196059685}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:55:04+01","p50":259.50443052,"p95":1067.6386801126544,"p99":1145.2690279007836}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:55:05+01","p50":242.27871055999998,"p95":1015.4942670567623,"p99":1074.7681892114329}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:55:06+01","p50":251.12089559999998,"p95":992.9381983987165,"p99":1036.4950490827541}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:55:07+01","p50":285.9962614,"p95":1136.0268257834432,"p99":1214.2336846307574}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:55:08+01","p50":298.24962196,"p95":1135.6397944598243,"p99":1189.5013802177325}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:55:09+01","p50":286.32753518,"p95":957.562279237845,"p99":1006.6251888044386}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:55:10+01","p50":323.96486949999996,"p95":944.4131883608749,"p99":981.8378903168444}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:55:11+01","p50":299.30596732,"p95":909.5205953234912,"p99":953.0456752045827}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:55:12+01","p50":344.56753968,"p95":917.0416467262091,"p99":1006.7959972350883}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:55:13+01","p50":330.03559808,"p95":901.7160891404394,"p99":944.0302666649898}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:55:14+01","p50":293.2251769999999,"p95":926.8758345855962,"p99":962.5475740588192}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:55:15+01","p50":271.38507772,"p95":939.0260261340137,"p99":981.4483618870797}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:55:16+01","p50":309.44405906000003,"p95":784.318831007983,"p99":844.3583583810993}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:55:17+01","p50":432.62145244000004,"p95":710.6471413851959,"p99":739.9856701339851}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:55:18+01","p50":350.60745327999996,"p95":719.0148563938918,"p99":756.085466977768}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:55:19+01","p50":330.54460592000004,"p95":775.002291225549,"p99":819.0590204817838}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:55:20+01","p50":334.48834784,"p95":759.2517875605816,"p99":828.1892589598165}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:55:21+01","p50":397.36779564,"p95":882.8732611015357,"p99":922.7172349714148}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:55:22+01","p50":416.47482736,"p95":865.7065040765826,"p99":903.262753823052}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:55:23+01","p50":346.9420717,"p95":802.9601666098233,"p99":869.4614825801162}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:55:24+01","p50":393.87548252,"p95":770.0980893793998,"p99":806.603586890467}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:55:25+01","p50":458.96309936000006,"p95":703.7090115036873,"p99":775.9134350690108}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:55:26+01","p50":461.0146954,"p95":669.759415087366,"p99":709.5994916988902}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:55:27+01","p50":425.45649543999997,"p95":742.147750426179,"p99":787.2429684761734}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:55:28+01","p50":434.48275373999996,"p95":628.3262293999,"p99":717.4064534631467}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:55:29+01","p50":351.32969132,"p95":749.9548065761353,"p99":806.0872950974605}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:55:30+01","p50":297.78048636000005,"p95":826.1648954241546,"p99":895.0294472455206}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:55:31+01","p50":384.2033020625,"p95":1070.5739817144542,"p99":1112.1202494332254}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:55:32+01","p50":353.64675193999994,"p95":1189.604973768994,"p99":1247.2020603480319}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:55:33+01","p50":239.14545788,"p95":1166.6974851019536,"p99":1219.1489738891078}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:55:34+01","p50":216.83242976000002,"p95":1170.4503308862318,"p99":1227.5429726298332}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:55:35+01","p50":229.08312379999998,"p95":1155.2040464554627,"p99":1204.177650796592}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:55:36+01","p50":254.2850476,"p95":1185.5965109716474,"p99":1248.9033482931156}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:55:37+01","p50":270.36888932,"p95":1159.9119408946651,"p99":1209.0197779631992}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:55:38+01","p50":253.41361988,"p95":1139.6311330202384,"p99":1162.285131187622}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:55:39+01","p50":281.60743564,"p95":1103.8471568950902,"p99":1142.455159528547}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:55:40+01","p50":229.25446229999997,"p95":933.9261076707617,"p99":996.3348238082093}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:55:41+01","p50":267.50354166,"p95":1088.2970881620877,"p99":1125.5946009694267}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:55:42+01","p50":265.96746326,"p95":1007.9630611809492,"p99":1066.388558937748}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:55:43+01","p50":244.5367071,"p95":958.2433373874197,"p99":1067.626232143448}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:55:44+01","p50":272.4118856,"p95":1036.7074471437304,"p99":1078.4103036432953}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:55:45+01","p50":287.90826804000005,"p95":1019.7026013280017,"p99":1092.7689554055778}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:55:46+01","p50":262.67606642,"p95":1081.5903545411152,"p99":1127.3948215575942}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:55:47+01","p50":231.43718866,"p95":1114.029116508869,"p99":1183.3765995373917}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:55:48+01","p50":253.85626417999998,"p95":1252.0888013438864,"p99":1276.2174379807989}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:55:49+01","p50":239.131615,"p95":1209.7755027840565,"p99":1265.36922884206}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:55:50+01","p50":212.59336468,"p95":1276.3480767885167,"p99":1316.5846206872138}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:55:51+01","p50":214.89168692000004,"p95":1265.238003397526,"p99":1320.3595291856782}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:55:52+01","p50":212.62718678000002,"p95":1283.3832648901775,"p99":1319.0670669120966}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:55:53+01","p50":233.84688764000003,"p95":1267.240974392183,"p99":1306.3195936194436}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:55:54+01","p50":224.05557508,"p95":1234.3282995541706,"p99":1293.8881523143507}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:55:55+01","p50":247.94847004,"p95":1292.078441751325,"p99":1356.6597680590241}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:55:56+01","p50":277.4196574,"p95":1186.2048899884826,"p99":1254.8225845562017}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:55:57+01","p50":262.6753578,"p95":1122.7872047415071,"p99":1182.028884562132}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:55:58+01","p50":264.60421183999995,"p95":1117.4284230075123,"p99":1160.0261269237694}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:55:59+01","p50":270.93567306,"p95":1126.4508775776849,"p99":1156.1352791619677}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:56:00+01","p50":237.0544943,"p95":1213.3388356086052,"p99":1285.231468485841}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:56:01+01","p50":340.224278625,"p95":1386.6449382029614,"p99":1468.9306646416908}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:56:02+01","p50":391.75232736,"p95":1381.1741532211217,"p99":1438.7461658934014}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:56:03+01","p50":409.5893499,"p95":908.9975410322901,"p99":943.8937028508496}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:56:04+01","p50":502.98675156,"p95":735.6130979813775,"p99":825.0827324411514}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:56:05+01","p50":508.62081959999995,"p95":647.7398416454902,"p99":697.9107535818921}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:56:06+01","p50":479.00760568000004,"p95":631.7469312590968,"p99":681.7494679837584}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:56:07+01","p50":435.13712391999996,"p95":600.3594325278127,"p99":647.4719589252825}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:56:08+01","p50":388.3946441,"p95":604.7476320897559,"p99":647.6033462540167}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:56:09+01","p50":340.48776962,"p95":703.1193632544762,"p99":783.3057744908529}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:56:10+01","p50":333.56228219999997,"p95":722.3168222679459,"p99":766.4055108214656}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:56:11+01","p50":350.37652607999996,"p95":765.7146108421124,"p99":824.5666915061942}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:56:12+01","p50":382.86146764,"p95":826.5064959743003,"p99":872.9591447135977}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:56:13+01","p50":353.6670913,"p95":873.8096013045111,"p99":907.5215508249341}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:56:14+01","p50":352.35307961999996,"p95":910.1929521207294,"p99":977.2790004754486}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:56:15+01","p50":315.63083922,"p95":878.2206149153399,"p99":930.7301026867217}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:56:16+01","p50":416.16674439999997,"p95":808.4342742193945,"p99":887.5345496570649}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:56:17+01","p50":385.26746048000007,"p95":796.1051157837645,"p99":855.0251652078518}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:56:18+01","p50":458.79918055999997,"p95":793.6403815422891,"p99":884.1727401358299}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:56:19+01","p50":458.4300303,"p95":702.8565842657522,"p99":748.811872859414}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:56:20+01","p50":461.01460324,"p95":635.757179931597,"p99":683.6643829145517}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:56:21+01","p50":429.90323279999996,"p95":743.9629846954317,"p99":798.2602812370186}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:56:22+01","p50":402.54342540000005,"p95":786.6608894061912,"p99":837.5974862978171}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:56:23+01","p50":405.03802892000004,"p95":722.1222634662902,"p99":778.7402504746435}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:56:24+01","p50":368.5930135,"p95":711.4263894876838,"p99":759.9696588325396}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:56:25+01","p50":430.68463762,"p95":736.6715483106835,"p99":805.8435429784103}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:56:26+01","p50":481.2318462,"p95":703.3188934801223,"p99":765.5434543798857}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:56:27+01","p50":365.186744,"p95":693.326585856878,"p99":749.5361973901668}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:56:28+01","p50":363.4449766000001,"p95":690.1448051502995,"p99":731.2481323553895}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:56:29+01","p50":281.57706012000006,"p95":816.1336498138564,"p99":877.6273295394625}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:56:30+01","p50":377.18776829999996,"p95":847.2516219483769,"p99":900.22625928404}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:56:31+01","p50":497.5834539375,"p95":891.7020322620158,"p99":953.9438775516348}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:56:32+01","p50":346.8785468125,"p95":1045.9009802910373,"p99":1090.9405255936204}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:56:33+01","p50":273.31321859999997,"p95":1004.8453879768746,"p99":1081.6911939864942}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:56:34+01","p50":332.16112519999996,"p95":1095.0452094078066,"p99":1167.7051165375794}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:56:35+01","p50":337.61471372,"p95":1035.2542212101482,"p99":1084.1257866699298}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:56:36+01","p50":311.04855354,"p95":1059.9076032960595,"p99":1124.9549142783128}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:56:37+01","p50":270.09264676000004,"p95":1135.8208467005065,"p99":1185.8908946884296}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:56:38+01","p50":259.252566,"p95":1120.8785571228354,"p99":1183.2332453106371}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:56:39+01","p50":315.0637054,"p95":944.4408038219177,"p99":986.8414174087259}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:56:40+01","p50":314.37342288,"p95":954.8589981008489,"p99":980.7044269218632}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:56:41+01","p50":284.30682627999994,"p95":901.7362358870563,"p99":986.3828686475931}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:56:42+01","p50":315.17893456,"p95":930.3146580690732,"p99":1014.3349843842475}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:56:43+01","p50":290.040257,"p95":1119.0786337004793,"p99":1199.1782786195888}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:56:44+01","p50":299.33398334,"p95":1062.2987158529656,"p99":1140.803423950081}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:56:45+01","p50":233.46090107999999,"p95":1151.6376701296144,"p99":1200.0341685499686}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:56:46+01","p50":247.38120411999998,"p95":1279.937013403983,"p99":1324.121274298983}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:56:47+01","p50":227.3517311,"p95":1303.1708704527339,"p99":1360.12331886654}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:56:48+01","p50":256.2809042,"p95":1119.040681131122,"p99":1199.965282231458}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:56:49+01","p50":230.85126449999998,"p95":1133.1275066933395,"p99":1197.757758316496}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:56:50+01","p50":260.99369866,"p95":1134.7345127580911,"p99":1183.3118349570263}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:56:51+01","p50":227.1546781,"p95":1072.272919942504,"p99":1118.9456006087723}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:56:52+01","p50":259.33545352,"p95":986.9217335892054,"p99":1047.464165256409}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:56:53+01","p50":301.8407956,"p95":972.5192322878748,"p99":1032.1156223417897}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:56:54+01","p50":326.76326792,"p95":862.810722775288,"p99":909.5803762079476}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:56:55+01","p50":300.38906490000005,"p95":839.3019853332406,"p99":893.4715725576935}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:56:56+01","p50":359.06145146,"p95":844.8173108700956,"p99":883.135729778678}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:56:57+01","p50":299.48482640000003,"p95":970.1896724877304,"p99":1034.5983656418257}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:56:58+01","p50":397.57804764,"p95":856.8922947259601,"p99":903.2902919370122}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:56:59+01","p50":392.54401054,"p95":875.0276883630997,"p99":943.955445407579}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:57:00+01","p50":349.1070972,"p95":943.5794664721861,"p99":1005.3166373994055}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:57:01+01","p50":339.6679836875,"p95":1177.2743886517549,"p99":1226.2079772180114}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:57:02+01","p50":343.13432793749996,"p95":1235.222528610471,"p99":1317.8757675563809}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:57:03+01","p50":366.30110812,"p95":986.8015307402529,"p99":1023.7419323131263}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:57:04+01","p50":399.51522624,"p95":713.1408000887947,"p99":747.2741061325378}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:57:05+01","p50":434.72579620000005,"p95":662.2130725297279,"p99":726.5785288847532}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:57:06+01","p50":397.67283878,"p95":647.7514479274649,"p99":689.2638160795187}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:57:07+01","p50":385.50906384,"p95":738.7249611085691,"p99":767.9808155936778}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:57:08+01","p50":383.41557939999996,"p95":856.4393977894537,"p99":925.3709467741288}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:57:09+01","p50":483.68991476,"p95":729.586450087727,"p99":800.4132629093657}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:57:10+01","p50":406.90425,"p95":737.1791104025131,"p99":791.2790262248509}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:57:11+01","p50":389.58412928,"p95":729.2618919992746,"p99":800.4653494611495}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:57:12+01","p50":415.36276990000005,"p95":679.061726705846,"p99":722.4347528260846}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:57:13+01","p50":406.98933339999996,"p95":652.6531125879239,"p99":682.2326805354953}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:57:14+01","p50":467.97655999999995,"p95":740.2319270467935,"p99":786.0745924850826}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:57:15+01","p50":355.94396952,"p95":860.0352539588087,"p99":897.2271763138933}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:57:16+01","p50":289.25486692,"p95":942.2256321551854,"p99":997.7677184795206}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:57:17+01","p50":319.76045672,"p95":877.0618981598789,"p99":932.0780652918746}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:57:18+01","p50":239.80404577999997,"p95":1024.6173090271666,"p99":1077.2964688776822}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:57:19+01","p50":193.12364966,"p95":1030.8926519002496,"p99":1067.1008136204987}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:57:20+01","p50":236.5812899,"p95":1009.4241185782694,"p99":1035.776187787364}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:57:21+01","p50":277.49069776,"p95":876.9815180302694,"p99":929.6678462571867}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:57:22+01","p50":275.66744987999994,"p95":1056.1014057409034,"p99":1105.0969600038793}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:57:23+01","p50":248.80205772,"p95":1207.202113449867,"p99":1269.7558420112814}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:57:24+01","p50":272.8927695,"p95":1188.182763308424,"p99":1249.3211266131764}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:57:25+01","p50":294.4491807200001,"p95":1125.4400339690906,"p99":1171.4966970980677}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:57:26+01","p50":389.73766844,"p95":1047.001018540144,"p99":1108.892007179311}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:57:27+01","p50":429.3190364,"p95":836.2903029179487,"p99":881.7743468848786}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:57:28+01","p50":468.54090252,"p95":900.7679829712415,"p99":940.1121784611819}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:57:29+01","p50":411.47991916,"p95":905.4550502238745,"p99":943.3211215307244}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:57:30+01","p50":362.02141049999994,"p95":906.8196147758464,"p99":983.4989074641609}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:57:31+01","p50":322.90900075,"p95":995.1203460261723,"p99":1036.8146382049924}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:57:32+01","p50":367.79457290625,"p95":981.7886828799245,"p99":1028.8226607578586}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:57:33+01","p50":269.2401516,"p95":926.0037455867906,"p99":980.0305938557015}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:57:34+01","p50":305.7290486,"p95":1011.01607576981,"p99":1069.7811194305343}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:57:35+01","p50":214.19705972000003,"p95":1070.000221884475,"p99":1113.7809740610257}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:57:36+01","p50":258.64008864,"p95":1091.5989622175243,"p99":1149.0595232886817}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:57:37+01","p50":291.27162884000006,"p95":1107.9505514600635,"p99":1150.0936595297223}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:57:38+01","p50":320.56000464,"p95":1159.3710620537454,"p99":1203.6356690551675}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:57:39+01","p50":356.8938183,"p95":1079.5747307954462,"p99":1163.176085797266}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:57:40+01","p50":405.11472952,"p95":897.5538978986646,"p99":956.6281736585164}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:57:41+01","p50":432.35478720000003,"p95":779.5281100456482,"p99":814.4864391696188}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:57:42+01","p50":387.58351419999997,"p95":829.0659139947232,"p99":888.3248607584267}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:57:43+01","p50":314.01460787999997,"p95":800.7819089373688,"p99":856.6462762104177}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:57:44+01","p50":356.01624095999995,"p95":812.1045285450529,"p99":864.358327268134}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:57:45+01","p50":504.3944483,"p95":742.8679540730515,"p99":824.1621478287697}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:57:46+01","p50":549.9859488,"p95":739.7809005018709,"p99":787.2670317863198}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:57:47+01","p50":469.2477424,"p95":728.6841429041207,"p99":795.5359488160102}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:57:48+01","p50":508.6552557000001,"p95":670.3795027329365,"p99":709.8069249460946}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:57:49+01","p50":445.6615128,"p95":745.1682351088618,"p99":809.0169110135179}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:57:50+01","p50":374.5514552,"p95":737.247035157303,"p99":794.3447574525082}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:57:51+01","p50":295.62798232,"p95":793.9530607237452,"p99":845.5056677283487}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:57:52+01","p50":259.0301415,"p95":924.8302857645881,"p99":969.3365612119379}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:57:53+01","p50":290.14288554,"p95":911.5893949432145,"p99":945.2653490212193}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:57:54+01","p50":239.15335439999998,"p95":1030.444572739606,"p99":1069.8451879358477}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:57:55+01","p50":300.46250799999996,"p95":1017.4782258791332,"p99":1070.154116551463}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:57:56+01","p50":266.01037540000004,"p95":1039.0952350391083,"p99":1092.2099289953956}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:57:57+01","p50":336.5110432,"p95":1141.4368568790583,"p99":1201.3220397920322}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:57:58+01","p50":366.72726339999997,"p95":1065.8914943852085,"p99":1111.7190018627057}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:57:59+01","p50":360.09214504,"p95":996.0890198895962,"p99":1095.8170484538296}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:58:00+01","p50":364.69347832000005,"p95":877.163821657981,"p99":915.0921963467737}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:58:01+01","p50":435.21682175,"p95":1075.719895153781,"p99":1148.560603641164}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:58:02+01","p50":479.05649071875,"p95":1101.1120018213037,"p99":1191.1826187366066}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:58:03+01","p50":370.72535492,"p95":889.8102047709954,"p99":946.7171505680153}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:58:04+01","p50":334.21516304,"p95":1044.505268094341,"p99":1139.0388111009938}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:58:05+01","p50":375.86099730000007,"p95":1133.0229097328704,"p99":1173.9939707185335}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:58:06+01","p50":419.54386940000006,"p95":980.3225509994079,"p99":1096.0016857619055}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:58:07+01","p50":336.733586,"p95":994.9971102801377,"p99":1045.5975751770723}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:58:08+01","p50":316.00438790000004,"p95":1052.7515636397752,"p99":1107.8133244757555}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:58:09+01","p50":351.2290178,"p95":965.8748839099898,"p99":1047.39176631782}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:58:10+01","p50":346.80602652000005,"p95":1037.3550456325868,"p99":1076.1083508352124}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:58:11+01","p50":452.24222088,"p95":834.8852481205188,"p99":903.6534870733628}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:58:12+01","p50":464.18115020000005,"p95":642.0411864112673,"p99":710.4150309925842}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:58:13+01","p50":375.479682,"p95":758.1107541409783,"p99":802.7778872081605}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:58:14+01","p50":431.81022466,"p95":858.300869132487,"p99":933.592865034019}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:58:15+01","p50":489.45943449999993,"p95":775.6314540364419,"p99":847.962337112061}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:58:16+01","p50":469.27894860000004,"p95":750.888097725657,"p99":819.4368593248557}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:58:17+01","p50":466.82771539999993,"p95":843.7135111978948,"p99":938.9659155833066}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:58:18+01","p50":519.77498144,"p95":714.7735984568184,"p99":746.0568035888793}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:58:19+01","p50":520.3147824,"p95":709.6108389318147,"p99":790.4423354204082}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:58:20+01","p50":454.69084088,"p95":802.3370231192147,"p99":836.5269660078272}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:58:21+01","p50":431.32321250000007,"p95":909.5782643657427,"p99":964.2061518528128}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:58:22+01","p50":381.73367096,"p95":924.9803347393224,"p99":982.8420958155704}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:58:23+01","p50":331.68117876,"p95":907.8721970337026,"p99":942.3215469965783}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:58:24+01","p50":326.01035024000004,"p95":1016.4654596040541,"p99":1075.19524213198}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:58:25+01","p50":355.54321664,"p95":1039.717569216236,"p99":1093.8185934755784}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:58:26+01","p50":278.84134770000003,"p95":1146.205299194525,"p99":1218.7177286993829}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:58:27+01","p50":292.4578058,"p95":1211.9907382275708,"p99":1262.1156826691547}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:58:28+01","p50":283.08576955999996,"p95":1211.8622107159185,"p99":1297.7322183675662}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:58:29+01","p50":289.05377608000003,"p95":1284.6774725367175,"p99":1351.480960181459}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:58:30+01","p50":345.92260221875,"p95":1279.7275864510298,"p99":1367.5057884460196}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:58:31+01","p50":398.19659884375,"p95":1192.7272584847365,"p99":1290.1957617024964}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:58:32+01","p50":313.882550375,"p95":1306.5175193080547,"p99":1374.3497744433437}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:58:33+01","p50":275.54068558000006,"p95":1223.05735029875,"p99":1319.7532198173103}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:58:34+01","p50":267.74866144,"p95":1219.9975267540933,"p99":1264.5567571637346}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:58:35+01","p50":315.7367923,"p95":1151.3250680019476,"p99":1202.8329975055103}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:58:36+01","p50":320.5500758,"p95":1065.2840770677215,"p99":1132.0997277355489}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:58:37+01","p50":396.04671274000003,"p95":1068.8524999815636,"p99":1119.202173020711}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:58:38+01","p50":338.08247484000003,"p95":1007.4665267653726,"p99":1072.954123484378}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:58:39+01","p50":364.3668196,"p95":754.791949644097,"p99":803.2388631714265}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:58:40+01","p50":360.61949568,"p95":856.8443697463345,"p99":913.9182722375676}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:58:41+01","p50":349.72353869999995,"p95":1061.3458826271446,"p99":1118.3944011214542}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:58:42+01","p50":239.64374082,"p95":1182.507627784883,"p99":1227.9732746189527}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:58:43+01","p50":235.7197858,"p95":1355.4293380407967,"p99":1408.8571447679087}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:58:44+01","p50":296.78245810000004,"p95":1408.1687905492415,"p99":1453.917711219625}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:58:45+01","p50":300.50966776000007,"p95":1246.7138667026784,"p99":1341.7172133704016}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:58:46+01","p50":333.28718572,"p95":1152.5320147501513,"p99":1192.4113165128663}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:58:47+01","p50":377.97702024,"p95":1115.7254581891364,"p99":1178.832249058434}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:58:48+01","p50":327.9865061875,"p95":949.9168810982022,"p99":1018.5666551298614}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:58:49+01","p50":329.2999094,"p95":977.4308532567962,"p99":1016.857414300109}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:58:50+01","p50":458.51724915999995,"p95":831.740744931358,"p99":877.7489285528956}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:58:51+01","p50":467.44700520000004,"p95":824.0415255390159,"p99":870.1199001065771}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:58:52+01","p50":461.57772850000003,"p95":855.2744091000475,"p99":883.0852859742498}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:58:53+01","p50":484.25451164,"p95":813.6726527877815,"p99":903.8292531857317}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:58:54+01","p50":562.74375368,"p95":714.645227059034,"p99":769.4900637607064}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:58:55+01","p50":558.3744868399999,"p95":714.603362480608,"p99":787.0594298704743}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:58:56+01","p50":528.065768,"p95":726.3140412589262,"p99":768.4639606287608}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:58:57+01","p50":468.44308806,"p95":730.607578660367,"p99":777.5005196939392}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:58:58+01","p50":480.1072402,"p95":771.4684741980727,"p99":811.3600906097755}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:58:59+01","p50":423.52134676,"p95":806.102781696187,"p99":849.4244295275721}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:59:00+01","p50":447.6473225,"p95":767.0564484497323,"p99":814.7158534860421}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:59:01+01","p50":670.1639395,"p95":818.3660700869951,"p99":894.3097760871372}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:59:02+01","p50":579.968928,"p95":840.0522082325334,"p99":924.7306056415267}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:59:03+01","p50":409.52701139999994,"p95":791.400620911422,"p99":833.6115882444172}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:59:04+01","p50":480.74249048,"p95":858.446063036429,"p99":924.3532730880061}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:59:05+01","p50":493.86745932,"p95":793.8484347521294,"p99":891.6514025846448}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:59:06+01","p50":503.00869536,"p95":707.5608842001768,"p99":766.4481488458815}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:59:07+01","p50":446.7047443199999,"p95":732.8975775305025,"p99":784.2090599086849}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:59:08+01","p50":443.6299222400001,"p95":675.8365122516063,"p99":787.8422447429314}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:59:09+01","p50":446.2239348,"p95":676.0366937052796,"p99":730.2456811592422}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:59:10+01","p50":484.41588424,"p95":691.552294421825,"p99":771.7678810416132}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:59:11+01","p50":409.1241148,"p95":731.5684430451552,"p99":778.1050226085949}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:59:12+01","p50":412.8476838,"p95":685.186796900581,"p99":722.8749597384816}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:59:13+01","p50":426.4423246,"p95":703.8644782377653,"p99":750.7845951079803}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:59:14+01","p50":463.79773248000004,"p95":702.6447670155354,"p99":775.6442146433227}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:59:15+01","p50":509.17797410000003,"p95":741.9910991929672,"p99":771.4171255720368}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:59:16+01","p50":432.76381884,"p95":793.5088406433524,"p99":924.4603668276673}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:59:17+01","p50":520.2229302399999,"p95":752.0814903606627,"p99":799.5684956319533}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:59:18+01","p50":448.36303052000005,"p95":796.3597455593388,"p99":852.1273552687354}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:59:19+01","p50":344.08083324999996,"p95":862.0750866456473,"p99":920.0804788825207}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:59:20+01","p50":464.6192106799999,"p95":874.1757213886256,"p99":956.2818377526527}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:59:21+01","p50":512.07610176,"p95":718.4169571842101,"p99":767.4090250798824}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:59:22+01","p50":461.65116020000005,"p95":781.0651170408299,"p99":834.0496501097717}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:59:23+01","p50":485.5306144,"p95":720.7938410514453,"p99":777.1701240678788}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:59:24+01","p50":414.37206572,"p95":718.1310419619126,"p99":793.5687120217998}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:59:25+01","p50":401.1198763599999,"p95":755.876786938769,"p99":805.0451092795033}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:59:26+01","p50":324.98312148,"p95":848.4122304455406,"p99":911.9963356094869}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:59:27+01","p50":389.81453120000003,"p95":843.9371717415853,"p99":890.7586888226085}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:59:28+01","p50":443.795063625,"p95":857.4720472290392,"p99":912.1474033337047}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:59:29+01","p50":433.94968632,"p95":841.851378178957,"p99":915.3437029142351}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:59:30+01","p50":503.95550575000004,"p95":779.5877722546229,"p99":817.272524877471}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:59:31+01","p50":633.5656961875,"p95":892.9160582861742,"p99":936.8784041967201}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:59:32+01","p50":689.6775119687501,"p95":846.2514725405498,"p99":938.2470059975965}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:59:33+01","p50":420.84068206,"p95":818.1779144031025,"p99":882.2821742704887}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:59:34+01","p50":452.93968408,"p95":748.3596820722591,"p99":807.5742626219387}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:59:35+01","p50":416.29938439999995,"p95":864.6454801909074,"p99":916.3731302860942}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:59:36+01","p50":463.5475261,"p95":872.4921711269667,"p99":937.6462802473488}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:59:37+01","p50":413.74672451999993,"p95":905.2554121473956,"p99":983.9038533615537}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:59:38+01","p50":409.48736468000004,"p95":856.9544039638855,"p99":909.0892436706927}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:59:39+01","p50":389.8248627,"p95":868.6098857488134,"p99":921.7148423558159}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:59:40+01","p50":344.2292738,"p95":839.1935802877866,"p99":881.2609500367889}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:59:41+01","p50":346.7925172,"p95":950.6889530208272,"p99":985.2517418997936}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:59:42+01","p50":326.62641546,"p95":934.5463450636968,"p99":1013.3277539604215}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:59:43+01","p50":312.60239315999996,"p95":857.6209930594213,"p99":899.546291250639}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:59:44+01","p50":376.5497767187499,"p95":895.9849932721825,"p99":943.8155160021622}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:59:45+01","p50":440.746051375,"p95":919.6412343554778,"p99":986.1681137916092}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:59:46+01","p50":447.9712063200001,"p95":958.5427756260987,"p99":1060.3157812348136}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:59:47+01","p50":456.21867093749995,"p95":852.6917267819111,"p99":909.7960023076332}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:59:48+01","p50":459.19242316,"p95":748.2600818025804,"p99":797.439148998914}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:59:49+01","p50":505.65841575,"p95":652.7568129464436,"p99":733.1735807014727}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:59:50+01","p50":404.79807312,"p95":738.5028303807358,"p99":801.9166470379581}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:59:51+01","p50":290.85314350000004,"p95":955.0576914630678,"p99":993.0295452962375}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:59:52+01","p50":273.92152675,"p95":1066.472334990564,"p99":1120.8082584798276}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:59:53+01","p50":305.02749951999994,"p95":1148.5708324001473,"p99":1204.5413314804805}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:59:54+01","p50":296.8829594,"p95":1199.567254405099,"p99":1260.5372572301483}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:59:55+01","p50":282.68535284,"p95":1326.8499839542087,"p99":1357.909935063963}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:59:56+01","p50":247.85930779999998,"p95":1233.971583642542,"p99":1277.8010873724697}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:59:57+01","p50":242.06895225,"p95":1291.3411411178147,"p99":1342.3507907432374}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:59:58+01","p50":294.41228925,"p95":1326.6353642993754,"p99":1371.361604827898}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 16:59:59+01","p50":264.6782276,"p95":1378.67481306825,"p99":1450.5745288366754}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 17:00:00+01","p50":256.8066340625,"p95":1373.8662746155958,"p99":1437.6407693063645}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 17:00:01+01","p50":277.07714859375,"p95":1430.6590500299822,"p99":1498.896697427592}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 17:00:02+01","p50":255.91042134375,"p95":1598.1019676371614,"p99":1734.3704218267055}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 17:00:03+01","p50":229.57550356000002,"p95":1696.6231724577617,"p99":1741.7754104958733}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 17:00:04+01","p50":215.94945053125002,"p95":1448.8173705870752,"p99":1597.171465832263}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 17:00:05+01","p50":250.81661130000003,"p95":1474.1969914765775,"p99":1532.0604853143025}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 17:00:06+01","p50":318.30047725,"p95":1379.4660429889284,"p99":1457.6106646230917}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 17:00:07+01","p50":296.10575748,"p95":1371.5720435444232,"p99":1438.398997665289}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 17:00:08+01","p50":310.30135900000005,"p95":1276.8541217251852,"p99":1375.7508471897297}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 17:00:09+01","p50":324.61803356,"p95":1268.3503677327963,"p99":1334.0207465151766}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 17:00:10+01","p50":310.61837412499995,"p95":1118.6992901947972,"p99":1169.858637197795}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 17:00:11+01","p50":264.44422427999996,"p95":1253.4007735552764,"p99":1337.7334010542859}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 17:00:12+01","p50":282.95834049999996,"p95":1370.0375084611505,"p99":1409.8450066190928}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 17:00:13+01","p50":251.71732762000002,"p95":1387.0912283932494,"p99":1448.4463730911255}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 17:00:14+01","p50":239.77846034375,"p95":1485.8565785667743,"p99":1531.5640100604624}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 17:00:15+01","p50":265.63580804,"p95":1461.055881513004,"p99":1516.169582163746}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 17:00:16+01","p50":305.06471128,"p95":1418.0504749809015,"p99":1490.2805457760708}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 17:00:17+01","p50":365.92930575,"p95":1278.0673796078856,"p99":1332.1449607623836}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 17:00:18+01","p50":338.07147384,"p95":1207.8018534585383,"p99":1283.180626170243}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 17:00:19+01","p50":389.1698988125,"p95":1105.7264406951017,"p99":1158.5958294484985}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 17:00:20+01","p50":336.83239459999993,"p95":1123.0797544735824,"p99":1180.287071246669}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 17:00:21+01","p50":351.27304000000004,"p95":1183.2277076278476,"p99":1229.6496526538115}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 17:00:22+01","p50":337.25127134375003,"p95":1222.0596721526138,"p99":1265.6357135058497}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 17:00:23+01","p50":411.67429782,"p95":1121.4313489646242,"p99":1184.0013063645783}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 17:00:24+01","p50":372.12722075,"p95":1037.5583362300986,"p99":1121.4334752823468}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 17:00:25+01","p50":358.94511409374996,"p95":1113.4368757003874,"p99":1168.703088913448}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 17:00:26+01","p50":335.399527,"p95":1245.4090109917734,"p99":1337.7595733927058}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 17:00:27+01","p50":217.0584515,"p95":1299.7565216301439,"p99":1362.3630580998797}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 17:00:28+01","p50":290.05654131250003,"p95":1347.6446803194558,"p99":1395.1166595507148}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 17:00:29+01","p50":318.8217935625,"p95":1333.1252860993443,"p99":1379.3142076579184}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 17:00:30+01","p50":412.7397759375,"p95":1091.5723159084755,"p99":1204.0126030216459}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 17:00:31+01","p50":471.34652300000005,"p95":1165.9845495583122,"p99":1230.9619462373223}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 17:00:32+01","p50":489.24911299999997,"p95":1149.3562186741772,"p99":1209.2903007229222}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 17:00:33+01","p50":409.55080269999996,"p95":1000.9215262222518,"p99":1102.028283573578}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 17:00:34+01","p50":356.77803240624996,"p95":1045.8965672853271,"p99":1120.8221252000324}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 17:00:35+01","p50":299.6639951,"p95":1245.4198075950894,"p99":1303.1494872729797}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 17:00:36+01","p50":308.27954093750003,"p95":1318.6333646767896,"p99":1371.4519142280053}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 17:00:37+01","p50":296.95125137499997,"p95":1258.0613694350657,"p99":1319.8753401443994}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 17:00:38+01","p50":242.22241176000003,"p95":1226.9435109000488,"p99":1281.4951884865056}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 17:00:39+01","p50":351.40504819999995,"p95":1327.5645072608443,"p99":1385.9433720104546}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 17:00:40+01","p50":331.6019381875,"p95":1146.2044852120741,"p99":1225.8783657254414}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 17:00:41+01","p50":274.50845423999993,"p95":1412.633810252578,"p99":1458.1476701360189}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 17:00:42+01","p50":240.99243412500002,"p95":1362.5005430577285,"p99":1427.3493709728339}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 17:00:43+01","p50":271.41480644,"p95":1421.7638064450173,"p99":1497.0160478736734}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 17:00:44+01","p50":272.73868490625,"p95":1483.1230503132479,"p99":1546.2702801880585}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 17:00:45+01","p50":342.97998931250004,"p95":1316.0247621134738,"p99":1402.1832111210408}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 17:00:46+01","p50":367.92398596,"p95":1224.4723878160569,"p99":1316.3653151012775}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 17:00:47+01","p50":403.04360275,"p95":1131.497387748942,"p99":1211.780453446203}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 17:00:48+01","p50":462.5544555,"p95":969.3734170711749,"p99":1014.1721077419987}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 17:00:49+01","p50":383.88462375000006,"p95":999.0967503558016,"p99":1049.0881787377261}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 17:00:50+01","p50":377.50671904,"p95":1065.8351270002802,"p99":1124.8977055295038}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 17:00:51+01","p50":374.0603104375,"p95":1010.9933955756006,"p99":1087.2457707759208}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 17:00:52+01","p50":304.4090130625,"p95":1099.8624152295101,"p99":1186.8683443010832}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 17:00:53+01","p50":326.94160424999995,"p95":1087.458853347962,"p99":1134.1467520068607}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 17:00:54+01","p50":373.28528659375,"p95":1004.7598703866058,"p99":1078.0421877515412}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 17:00:55+01","p50":442.55948025,"p95":1027.5687645615303,"p99":1082.9457439019307}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 17:00:56+01","p50":455.06786600000004,"p95":906.6042555620081,"p99":972.4418643908546}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 17:00:57+01","p50":398.3586268125,"p95":982.2136200221285,"p99":1063.2275540859828}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 17:00:58+01","p50":427.8044215,"p95":993.419917844108,"p99":1087.530292932532}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 17:00:59+01","p50":419.6879015,"p95":928.2230856002031,"p99":1005.494435786173}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 17:01:00+01","p50":428.03856021875004,"p95":943.3408889327696,"p99":1010.6441153174541}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 17:01:01+01","p50":443.80421331249994,"p95":1120.3839794815858,"p99":1224.3089030061353}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 17:01:02+01","p50":330.72416975000004,"p95":1319.9551871595768,"p99":1374.513199573954}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 17:01:03+01","p50":308.868786375,"p95":1181.2607610089533,"p99":1258.5231168961257}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 17:01:04+01","p50":278.05828125,"p95":1105.7181125820864,"p99":1152.538185560535}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 17:01:05+01","p50":226.0628300625,"p95":1233.523458154641,"p99":1312.4420734525825}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 17:01:06+01","p50":221.04589365624997,"p95":1342.2640396102813,"p99":1392.4634917685332}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 17:01:07+01","p50":331.088105625,"p95":1472.6465262075792,"p99":1539.74797023108}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 17:01:08+01","p50":364.40633,"p95":1378.2779767054153,"p99":1455.6673160619164}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 17:01:09+01","p50":355.4938865,"p95":1232.6525205629393,"p99":1286.3614757544817}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 17:01:10+01","p50":444.01465028125,"p95":1176.1312283163688,"p99":1267.5995221146356}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 17:01:11+01","p50":460.36731987499996,"p95":963.1437316647844,"p99":1010.7553366085858}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 17:01:12+01","p50":348.6620261875,"p95":1157.098749246374,"p99":1209.0690921518678}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 17:01:13+01","p50":330.929518625,"p95":1123.3466312844735,"p99":1161.8901714263807}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 17:01:14+01","p50":280.80906319999997,"p95":1211.2271769580402,"p99":1276.1489093388725}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 17:01:15+01","p50":202.5970185,"p95":1054.039386469398,"p99":1095.7894092189892}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 17:01:16+01","p50":206.204974,"p95":960.2604881633181,"p99":1017.7444118097401}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 17:01:17+01","p50":204.76002453125,"p95":935.8978527156486,"p99":990.6852471083901}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 17:01:18+01","p50":286.80225265624995,"p95":998.7506113811944,"p99":1053.0257077525328}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 17:01:19+01","p50":348.41833125,"p95":914.3245040351516,"p99":967.1049710458984}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 17:01:20+01","p50":406.33185225,"p95":887.03263948242,"p99":961.8514902111516}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 17:01:21+01","p50":373.62391175000005,"p95":873.7941156592511,"p99":917.9207695006152}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 17:01:22+01","p50":384.1419971875,"p95":957.6718643504519,"p99":1008.0354960233902}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 17:01:23+01","p50":358.4550986874999,"p95":930.9010295038123,"p99":985.9724332767548}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 17:01:24+01","p50":388.71930775,"p95":900.4278588649084,"p99":958.6752755243512}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 17:01:25+01","p50":399.74073675,"p95":995.0417358608591,"p99":1043.4076431941376}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 17:01:26+01","p50":421.72374343749993,"p95":947.8117726786147,"p99":1011.8275186556208}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 17:01:27+01","p50":400.90323693749997,"p95":945.879280691314,"p99":985.9757093771556}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 17:01:28+01","p50":576.28487346875,"p95":861.9257649916646,"p99":935.0216043097272}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 17:01:29+01","p50":491.4461351875,"p95":854.9085999441995,"p99":920.5622834571533}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 17:01:30+01","p50":333.9964570625,"p95":860.0384215524153,"p99":908.1505602787666}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 17:01:31+01","p50":369.61234721874996,"p95":1098.8220424147707,"p99":1179.9086379717135}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 17:01:32+01","p50":406.55642393749997,"p95":1310.4675732788626,"p99":1372.3805498009688}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 17:01:33+01","p50":401.1109846875,"p95":1096.4234767759017,"p99":1261.31174702314}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 17:01:34+01","p50":371.8543209375,"p95":1081.5963490005306,"p99":1130.7102780844837}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 17:01:35+01","p50":353.534412125,"p95":1189.1395890160136,"p99":1236.5630415562048}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 17:01:36+01","p50":440.9433736875,"p95":972.5048970138604,"p99":1020.4256151303157}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 17:01:37+01","p50":576.10298625,"p95":868.6593772067789,"p99":909.6946102949876}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 17:01:38+01","p50":471.6856993125,"p95":862.0346265736099,"p99":941.6552965172004}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 17:01:39+01","p50":407.7286164375,"p95":804.3631566765524,"p99":898.4146348053677}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 17:01:40+01","p50":495.472425875,"p95":778.8919335706856,"p99":821.4438198893938}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 17:01:41+01","p50":390.015073875,"p95":915.6852682304248,"p99":968.8527980200281}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 17:01:42+01","p50":436.70397121875,"p95":1035.5519280481237,"p99":1079.741326754084}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 17:01:43+01","p50":399.66618312500003,"p95":1015.8571091822237,"p99":1061.5754131000485}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 17:01:44+01","p50":530.4277997500001,"p95":985.067257898768,"p99":1020.2450028939083}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 17:01:45+01","p50":488.66430675000004,"p95":798.7751331608897,"p99":839.0750598332825}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 17:01:46+01","p50":560.9766404375,"p95":878.9209963021202,"p99":954.149067567142}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 17:01:47+01","p50":505.62762825000004,"p95":929.5634865387963,"p99":977.3061333858404}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 17:01:48+01","p50":511.5911427500001,"p95":933.4198547144293,"p99":1005.8549665717044}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 17:01:49+01","p50":489.37948306249996,"p95":743.3766657749824,"p99":791.7762660572671}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 17:01:50+01","p50":523.76476725,"p95":682.6771869110567,"p99":740.3636572551417}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 17:01:51+01","p50":527.6462293750001,"p95":728.8734756788447,"p99":770.5431688815513}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 17:01:52+01","p50":557.694719125,"p95":715.0237951346518,"p99":786.6559587488437}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 17:01:53+01","p50":508.480639125,"p95":667.0940064109975,"p99":727.979009902976}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 17:01:54+01","p50":525.19123475,"p95":672.500880473401,"p99":728.8034774426936}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 17:01:55+01","p50":500.75535475,"p95":745.9783345827702,"p99":820.9949372550859}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 17:01:56+01","p50":484.0476434375,"p95":745.8610955601308,"p99":798.0840848942604}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 17:01:57+01","p50":434.06750350000004,"p95":781.8392150984273,"p99":813.8131414826131}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 17:01:58+01","p50":495.87167309375,"p95":689.0007507138381,"p99":728.9923556229758}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 17:01:59+01","p50":399.89193224999997,"p95":828.1196123810889,"p99":865.8575651366497}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 17:02:00+01","p50":307.79317031249997,"p95":907.5518388220042,"p99":963.6233573454666}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 17:02:01+01","p50":450.2785736875,"p95":1018.4391522637959,"p99":1093.8440466487082}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 17:02:02+01","p50":596.3215379999999,"p95":1091.8929729112756,"p99":1162.5406727544032}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 17:02:03+01","p50":539.162731875,"p95":848.0737903566577,"p99":906.5794161626329}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 17:02:04+01","p50":490.9393101875,"p95":795.3625589787633,"p99":848.4238285406649}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 17:02:05+01","p50":531.8070108125,"p95":775.7002985962014,"p99":827.0155542848335}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 17:02:06+01","p50":437.9073600625,"p95":848.3419694726712,"p99":905.993471892788}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 17:02:07+01","p50":431.15010034375,"p95":835.2641611670256,"p99":884.7950432175136}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 17:02:08+01","p50":351.8099315,"p95":879.648664891788,"p99":938.5206180565004}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 17:02:09+01","p50":304.160279375,"p95":946.47605494226,"p99":1035.3025574571952}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 17:02:10+01","p50":289.87681231249996,"p95":930.3402899532545,"p99":985.5760806319204}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 17:02:11+01","p50":297.51204471875,"p95":1011.708443413406,"p99":1069.4752120934954}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 17:02:12+01","p50":325.833794,"p95":1082.4082505675233,"p99":1140.0157160715016}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 17:02:13+01","p50":489.27371640625,"p95":872.0172264079048,"p99":960.0499716747796}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 17:02:14+01","p50":517.49301075,"p95":764.5488196049198,"p99":868.9921633781624}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 17:02:15+01","p50":502.704573125,"p95":718.4235492219174,"p99":745.5714971204739}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 17:02:16+01","p50":516.57124675,"p95":706.5996530187166,"p99":774.4198485369101}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 17:02:17+01","p50":525.2879698749999,"p95":725.9181978473864,"p99":799.3484394643592}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 17:02:18+01","p50":487.47193625,"p95":829.2221960716347,"p99":864.4230949769935}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 17:02:19+01","p50":490.115499,"p95":729.2113911087985,"p99":806.4557772201262}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 17:02:20+01","p50":512.13397978125,"p95":728.420884377361,"p99":802.8961748299433}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 17:02:21+01","p50":465.733527875,"p95":826.0496244955046,"p99":871.1046259509058}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 17:02:22+01","p50":447.53847562500005,"p95":917.1888370360566,"p99":986.5875071976982}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 17:02:23+01","p50":389.37695,"p95":1147.2464825701666,"p99":1215.0142711447982}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 17:02:24+01","p50":368.36683644,"p95":1115.1854948421724,"p99":1296.7192834727678}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 17:02:25+01","p50":431.46925762500007,"p95":849.5357697776268,"p99":912.5563385431905}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 17:02:26+01","p50":437.3141676875,"p95":832.877900316882,"p99":873.9508624078612}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 17:02:27+01","p50":481.08365634375,"p95":845.2556975099542,"p99":898.9850942347562}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 17:02:28+01","p50":534.8528012187501,"p95":814.1404909721426,"p99":909.5114925533416}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 17:02:29+01","p50":477.38707828125,"p95":838.2755947543105,"p99":918.7837006917653}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 17:02:30+01","p50":437.2799144375,"p95":886.9269572216167,"p99":964.1211330747919}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 17:02:31+01","p50":506.8406602222222,"p95":1130.3126061921832,"p99":1199.9750577549203}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 17:02:32+01","p50":519.0099894375,"p95":1195.5841332815523,"p99":1258.7531618418063}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 17:02:33+01","p50":550.3733055625,"p95":920.8027147532234,"p99":993.9141517019248}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 17:02:34+01","p50":510.54620706249995,"p95":905.9136580712307,"p99":953.6132146731028}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 17:02:35+01","p50":489.36180653125,"p95":964.7543322763221,"p99":998.1965240270135}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 17:02:36+01","p50":456.76863718749996,"p95":1048.6677480940914,"p99":1147.1578452513922}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 17:02:37+01","p50":405.79225190625,"p95":1101.61412981618,"p99":1167.5982818806763}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 17:02:38+01","p50":316.966581,"p95":1224.6288159488392,"p99":1266.172592892285}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 17:02:39+01","p50":249.5340845,"p95":1407.0580122595059,"p99":1494.7236534193687}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 17:02:40+01","p50":260.53212384375,"p95":1571.2938356809989,"p99":1639.5003233703987}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 17:02:41+01","p50":225.52530562500002,"p95":1502.4722683780058,"p99":1564.3753142137614}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 17:02:42+01","p50":257.60909884375,"p95":1300.2464042245335,"p99":1373.4228285685876}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 17:02:43+01","p50":331.811686,"p95":1411.7601681428653,"p99":1454.1124919311462}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 17:02:44+01","p50":284.41342540625,"p95":1373.598490453252,"p99":1436.9030629483366}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 17:02:45+01","p50":246.68098774999999,"p95":1394.307158559348,"p99":1498.149565495338}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 17:02:46+01","p50":245.33991400000002,"p95":1555.503754686955,"p99":1595.8455725054039}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 17:02:47+01","p50":217.0294381875,"p95":1632.1419017092649,"p99":1664.720494045454}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 17:02:48+01","p50":244.815718125,"p95":1658.5602877277875,"p99":1708.044500925355}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 17:02:49+01","p50":274.428511625,"p95":1571.856909066107,"p99":1673.5466836566543}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 17:02:50+01","p50":279.71968509375,"p95":1550.5602458935891,"p99":1611.063363525159}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 17:02:51+01","p50":323.95805925,"p95":1435.5251841731526,"p99":1513.0679296905519}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 17:02:52+01","p50":338.77594350000004,"p95":1393.3739504209116,"p99":1467.8625657480736}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 17:02:53+01","p50":320.25516303125,"p95":1418.3375261919466,"p99":1476.9829054384304}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 17:02:54+01","p50":278.661301125,"p95":1423.9185551519054,"p99":1495.3326177168342}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 17:02:55+01","p50":296.930953125,"p95":1555.8541939877027,"p99":1620.862156959202}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 17:02:56+01","p50":287.72675490625,"p95":1551.6493149217442,"p99":1630.5869621121428}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 17:02:57+01","p50":273.16591412500003,"p95":1429.361953071834,"p99":1491.3193079872308}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 17:02:58+01","p50":223.601614375,"p95":1481.4620352691375,"p99":1530.1255760190202}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 17:02:59+01","p50":243.47778575,"p95":1583.5778080413677,"p99":1627.8361638798458}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 17:03:00+01","p50":241.70434378125,"p95":1562.3451970964811,"p99":1618.923581356467}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 17:03:01+01","p50":247.93508266666666,"p95":1780.2658506151527,"p99":1852.7282519598946}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 17:03:02+01","p50":284.545861375,"p95":1909.5568453008889,"p99":1950.6261838792636}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 17:03:03+01","p50":223.1869029375,"p95":1665.3045776966958,"p99":1741.0172296839123}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 17:03:04+01","p50":217.67565925,"p95":1669.6400619169488,"p99":1712.5491345015582}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 17:03:05+01","p50":216.4439175,"p95":1676.0199154431627,"p99":1735.4953434306804}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 17:03:06+01","p50":232.8211455,"p95":1668.4443933128014,"p99":1710.3306143587417}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 17:03:07+01","p50":238.81680631249998,"p95":1668.784569885818,"p99":1726.8824277667966}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 17:03:08+01","p50":224.18347271874998,"p95":1669.7859471795648,"p99":1751.8882853493544}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 17:03:09+01","p50":255.56739228125002,"p95":1632.1900846751348,"p99":1689.733404448866}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 17:03:10+01","p50":282.37240340625004,"p95":1645.857232627638,"p99":1704.6216824828723}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 17:03:11+01","p50":287.11778799999996,"p95":1587.3114926286707,"p99":1629.9237721143281}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 17:03:12+01","p50":266.06492771875,"p95":1468.2654557670596,"p99":1519.8887532449721}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 17:03:13+01","p50":313.8869051875,"p95":1507.8693824706697,"p99":1556.9385347526907}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 17:03:14+01","p50":300.05446115625,"p95":1504.55750885628,"p99":1557.498578343833}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 17:03:15+01","p50":336.539755,"p95":1410.9941225333614,"p99":1534.357548577939}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 17:03:16+01","p50":361.6662794375,"p95":1343.7055491422527,"p99":1420.223939673569}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 17:03:17+01","p50":304.1360496875,"p95":1317.252028974455,"p99":1389.6991550341975}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 17:03:18+01","p50":257.2506144375,"p95":1437.9188404859888,"p99":1517.8337303722278}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 17:03:19+01","p50":342.03214075,"p95":1498.4250639910283,"p99":1552.08769069655}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 17:03:20+01","p50":359.33994087499997,"p95":1403.7187220484773,"p99":1525.2660110698346}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 17:03:21+01","p50":330.47825075000003,"p95":1263.4691199619258,"p99":1332.9154464997362}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 17:03:22+01","p50":404.3325505625,"p95":1151.6122958333142,"p99":1286.7977221252313}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 17:03:23+01","p50":477.983473,"p95":1047.358963936724,"p99":1106.5748304423305}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 17:03:24+01","p50":605.150412,"p95":829.8513527875087,"p99":938.4385975928965}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 17:03:25+01","p50":657.6632271249999,"p95":799.4701946358708,"p99":835.6046202065172}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 17:03:26+01","p50":566.38617075,"p95":789.7231718794752,"p99":823.8667890042235}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 17:03:27+01","p50":551.2718941875,"p95":862.8547551825673,"p99":952.7182545217929}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 17:03:28+01","p50":496.386323,"p95":979.390549759819,"p99":1043.5024977365665}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 17:03:29+01","p50":492.17342531249994,"p95":1085.2769396604529,"p99":1129.4739239835453}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 17:03:30+01","p50":391.99678325,"p95":1166.320532032022,"p99":1223.5079453269605}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 17:03:31+01","p50":461.4230819375,"p95":1172.3456338252888,"p99":1246.8573567444182}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 17:03:32+01","p50":434.40661846875,"p95":1354.620486797615,"p99":1416.5760314428085}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 17:03:33+01","p50":396.9980335,"p95":1341.456705223662,"p99":1418.2780499455514}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 17:03:34+01","p50":408.0605675625,"p95":1087.9780024853078,"p99":1164.9712159393998}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 17:03:35+01","p50":495.60596609375,"p95":1009.2177950838983,"p99":1068.8434326190877}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 17:03:36+01","p50":398.364537,"p95":1004.6770212253564,"p99":1078.0422638852644}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 17:03:37+01","p50":514.8410815312501,"p95":1069.5893657980494,"p99":1119.1435913276966}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 17:03:38+01","p50":492.5351595625,"p95":957.1923070940961,"p99":1109.8519915556749}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 17:03:39+01","p50":486.5265169375,"p95":1061.5015782010819,"p99":1123.5239192186427}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 17:03:40+01","p50":570.277847625,"p95":878.7198873932593,"p99":958.2418932165126}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 17:03:41+01","p50":433.7697501875,"p95":871.9915896014445,"p99":925.0943730779752}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 17:03:42+01","p50":462.87368190625,"p95":861.8533969640756,"p99":925.1568213114422}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 17:03:43+01","p50":350.05137484375,"p95":956.456481930902,"p99":1048.7539046323716}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 17:03:44+01","p50":272.7799403125,"p95":1141.7027832465083,"p99":1194.6874864128397}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 17:03:45+01","p50":272.07913425000004,"p95":1179.6071727594324,"p99":1235.9118927836637}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 17:03:46+01","p50":286.6193708125,"p95":1184.6878270029315,"p99":1229.8974995134963}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 17:03:47+01","p50":444.4442155,"p95":1272.525276680043,"p99":1342.056948450781}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 17:03:48+01","p50":410.54002521875,"p95":1217.3226085600884,"p99":1253.3684290439667}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 17:03:49+01","p50":359.11055053125,"p95":1144.3413137052314,"p99":1193.586138161663}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 17:03:50+01","p50":381.86686078125,"p95":1261.5027272343673,"p99":1366.7869136822524}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 17:03:51+01","p50":396.060829875,"p95":1205.9486232195206,"p99":1283.2509160344687}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 17:03:52+01","p50":440.9412880625,"p95":1130.164366385832,"p99":1220.508437647624}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 17:03:53+01","p50":393.6379438125,"p95":1087.457335263016,"p99":1156.0123133859759}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 17:03:54+01","p50":360.08196309375,"p95":1162.3183009985373,"p99":1213.8491897790823}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 17:03:55+01","p50":270.7513948125,"p95":1289.2412074169342,"p99":1391.3035269296663}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 17:03:56+01","p50":261.17554528125,"p95":1344.1463458929707,"p99":1436.9730327770244}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 17:03:57+01","p50":359.90874153125003,"p95":1226.1483832053516,"p99":1289.6511622184084}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 17:03:58+01","p50":419.63465284375,"p95":1089.1000972351355,"p99":1133.8074047620232}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 17:03:59+01","p50":426.1286608125,"p95":1181.8483155161216,"p99":1232.4081276638142}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 17:04:00+01","p50":407.65264456250003,"p95":1201.6717852207219,"p99":1258.5439710263524}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 17:04:01+01","p50":350.46005983333333,"p95":1424.7195962940973,"p99":1471.7136663441659}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 17:04:02+01","p50":305.26081462499997,"p95":1462.7733437964034,"p99":1505.9757252541328}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 17:04:03+01","p50":311.1174865625,"p95":1393.9437645516275,"p99":1489.3854107943516}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 17:04:04+01","p50":294.04617465625,"p95":1095.249260658051,"p99":1138.6390054021836}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 17:04:05+01","p50":331.30367034375,"p95":1194.6772804601114,"p99":1242.3397683720568}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 17:04:06+01","p50":365.3450695,"p95":1198.4173186879182,"p99":1268.3778623635249}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 17:04:07+01","p50":393.644743625,"p95":1278.556557193039,"p99":1369.765332407082}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 17:04:08+01","p50":370.1451470625,"p95":1353.0526307275213,"p99":1416.0828640852294}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 17:04:09+01","p50":341.91079534375,"p95":1342.7974576645238,"p99":1390.5403857883468}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 17:04:10+01","p50":290.30416125,"p95":1460.2677428478567,"p99":1505.5529785807075}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 17:04:11+01","p50":241.18618571874998,"p95":1434.7513577073012,"p99":1498.2506420944753}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 17:04:12+01","p50":266.130397,"p95":1561.626805089447,"p99":1609.1036210942812}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 17:04:13+01","p50":245.38688790624997,"p95":1482.3675191450511,"p99":1538.6322598553265}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 17:04:14+01","p50":272.4391511875,"p95":1596.9340431644964,"p99":1647.3903891978791}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 17:04:15+01","p50":349.7718371875,"p95":1561.57747434123,"p99":1631.509725051961}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 17:04:16+01","p50":372.99533303125,"p95":1371.7184807966048,"p99":1472.0763482383318}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 17:04:17+01","p50":381.5341501875,"p95":1402.192141518408,"p99":1473.7827869945459}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 17:04:18+01","p50":443.9494804375,"p95":1259.8984596167154,"p99":1314.1753718595664}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 17:04:19+01","p50":407.50983299999996,"p95":1053.7803540517082,"p99":1114.8658188149518}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 17:04:20+01","p50":330.038518375,"p95":1024.3757621961452,"p99":1105.0813751103217}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 17:04:21+01","p50":317.2547094375,"p95":1158.5443819353698,"p99":1224.6838087607991}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 17:04:22+01","p50":368.7601756875,"p95":1011.4479282594612,"p99":1100.1364356769827}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 17:04:23+01","p50":336.21394940625,"p95":1019.0957898091343,"p99":1078.404404797233}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 17:04:24+01","p50":412.995593,"p95":1047.0999994891702,"p99":1109.344957522729}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 17:04:25+01","p50":378.12793931249996,"p95":1025.9672355873738,"p99":1079.9647466002807}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 17:04:26+01","p50":410.930432625,"p95":1095.576843986663,"p99":1152.6132804493534}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 17:04:27+01","p50":426.31201799999997,"p95":970.0312945294149,"p99":1025.8238758906475}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 17:04:28+01","p50":461.8975015,"p95":1076.4309229496637,"p99":1172.6907522789058}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 17:04:29+01","p50":392.19302175,"p95":1211.9792057994634,"p99":1302.3766266167374}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 17:04:30+01","p50":253.30220624999998,"p95":1234.4706469999267,"p99":1271.1972375555115}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 17:04:31+01","p50":257.86143555555554,"p95":1328.8974781038303,"p99":1388.9802430381335}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 17:04:32+01","p50":325.16261872222225,"p95":1367.208211870984,"p99":1480.5690954539682}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 17:04:33+01","p50":358.778842,"p95":1268.7960678874576,"p99":1323.257177013688}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 17:04:34+01","p50":403.53463403125,"p95":1067.1791485678382,"p99":1145.3592779546284}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 17:04:35+01","p50":352.18259375,"p95":1106.6601172881947,"p99":1175.3228713645174}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 17:04:36+01","p50":351.20903043749996,"p95":1116.1011381038857,"p99":1164.5974901468549}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 17:04:37+01","p50":454.15392265625,"p95":1098.1344513710008,"p99":1141.4094042726092}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 17:04:38+01","p50":471.66136890625,"p95":1051.8374283678675,"p99":1110.409138757636}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 17:04:39+01","p50":399.3553395,"p95":1099.8626506148682,"p99":1182.1713657134671}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 17:04:40+01","p50":477.3820301875,"p95":1146.330773358639,"p99":1204.5507817873208}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 17:04:41+01","p50":542.93573425,"p95":1017.227366558991,"p99":1124.3543623450003}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 17:04:42+01","p50":539.945251,"p95":972.4583595878144,"p99":1045.5935240660401}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 17:04:43+01","p50":406.12722703124996,"p95":947.0021226884148,"p99":999.317351181486}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 17:04:44+01","p50":320.7812405,"p95":1033.871573568444,"p99":1114.8959639402199}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 17:04:45+01","p50":247.5284226875,"p95":1032.3039571599331,"p99":1071.4264279664496}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 17:04:46+01","p50":316.16519590625,"p95":1074.0998399624527,"p99":1123.1176419550802}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 17:04:47+01","p50":375.48857428125,"p95":1122.62939359618,"p99":1197.1433623743415}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 17:04:48+01","p50":436.10489325000003,"p95":1145.5356950049988,"p99":1201.1084639476662}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 17:04:49+01","p50":401.0414736875,"p95":1162.8687949156524,"p99":1238.7793130857183}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 17:04:50+01","p50":397.1332230625,"p95":1053.4150216872583,"p99":1142.1139045798407}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 17:04:51+01","p50":258.55396665625005,"p95":1093.0868789985632,"p99":1152.9485494992423}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 17:04:52+01","p50":280.93637606249996,"p95":1122.8647814105198,"p99":1207.9476703703308}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 17:04:53+01","p50":346.85615846875,"p95":1096.3395703804645,"p99":1142.5716697106998}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 17:04:54+01","p50":319.37706393750005,"p95":1142.7605045631972,"p99":1198.4144942098667}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 17:04:55+01","p50":303.94073568749997,"p95":1119.3090427493532,"p99":1187.227187754051}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 17:04:56+01","p50":285.59444699999995,"p95":1167.8035451491626,"p99":1239.726972310678}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 17:04:57+01","p50":338.44454925,"p95":1239.3030068431465,"p99":1275.7018183915854}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 17:04:58+01","p50":255.96545290625002,"p95":1188.5508220265638,"p99":1283.7131456380082}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 17:04:59+01","p50":283.17503700000003,"p95":1341.1839532549097,"p99":1410.4147991878556}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 17:05:00+01","p50":284.00554146875,"p95":1463.3712290228382,"p99":1527.9328021911845}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 17:05:01+01","p50":401.3173766666667,"p95":1434.7140358634138,"p99":1479.0404789858585}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 17:05:02+01","p50":428.57984375,"p95":1525.601126599747,"p99":1600.4857368781127}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 17:05:03+01","p50":369.58383271875,"p95":1367.651479377807,"p99":1453.967504780501}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 17:05:04+01","p50":402.96431625,"p95":1232.3071255755783,"p99":1309.3488060609598}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 17:05:05+01","p50":337.75996200000003,"p95":1222.3190906596108,"p99":1270.1436745046153}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 17:05:06+01","p50":319.54442675,"p95":1350.822235532204,"p99":1435.4977420017408}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 17:05:07+01","p50":343.82702553125,"p95":1398.4806488460997,"p99":1487.7265993565375}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 17:05:08+01","p50":326.29797587499996,"p95":1440.5712667033724,"p99":1512.919273649264}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 17:05:09+01","p50":268.5333929375,"p95":1418.7705229785752,"p99":1496.0151162976238}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 17:05:10+01","p50":250.10513674999999,"p95":1563.7743063357082,"p99":1615.682293749618}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 17:05:11+01","p50":273.68944112500003,"p95":1513.3243355312181,"p99":1572.243980261366}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 17:05:12+01","p50":291.73424593749996,"p95":1558.30763056493,"p99":1620.272713257736}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 17:05:13+01","p50":322.39391746875,"p95":1409.3866068146513,"p99":1493.5897174023821}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 17:05:14+01","p50":319.72705946875004,"p95":1503.656238943843,"p99":1563.3758630933817}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 17:05:15+01","p50":288.27486096875003,"p95":1497.9882414428384,"p99":1564.8138617559234}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 17:05:16+01","p50":245.58372740624998,"p95":1538.2397306973498,"p99":1635.782074877011}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 17:05:17+01","p50":304.04231050000004,"p95":1591.626314179972,"p99":1677.8650240455627}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 17:05:18+01","p50":296.23218118750003,"p95":1616.7703769208717,"p99":1710.3877224281246}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 17:05:19+01","p50":314.88241421875,"p95":1551.932980202591,"p99":1629.9841333596282}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 17:05:20+01","p50":390.4989616875,"p95":1368.751019992241,"p99":1408.7492995817558}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 17:05:21+01","p50":320.59373662499996,"p95":1450.5147917726617,"p99":1519.071425807252}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 17:05:22+01","p50":284.02978840625,"p95":1461.451512131657,"p99":1510.643271689516}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 17:05:23+01","p50":279.0636414375,"p95":1519.0169156925285,"p99":1562.062272963212}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 17:05:24+01","p50":274.325259,"p95":1646.7579885684427,"p99":1702.917154563032}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 17:05:25+01","p50":257.03439790625004,"p95":1567.1555420799948,"p99":1621.754768337983}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 17:05:26+01","p50":257.08461931249997,"p95":1697.574380484249,"p99":1740.107734550926}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 17:05:27+01","p50":228.5068239375,"p95":1654.8879698416893,"p99":1727.171493318413}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 17:05:28+01","p50":249.21318131249998,"p95":1772.693056329228,"p99":1817.8091819361266}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 17:05:29+01","p50":224.58208925,"p95":1755.6047212561896,"p99":1807.262657387853}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 17:05:30+01","p50":252.79144934375,"p95":1817.8145306279002,"p99":1866.379781304015}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 17:05:31+01","p50":306.76991533333336,"p95":1966.176637948251,"p99":2066.870963740402}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 17:05:32+01","p50":282.58361733333334,"p95":2137.2746187834755,"p99":2186.32995451353}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 17:05:33+01","p50":278.552697375,"p95":1767.445499423131,"p99":1946.8632338337784}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 17:05:34+01","p50":303.181731625,"p95":1704.4198928533342,"p99":1773.0230208545208}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 17:05:35+01","p50":308.0156649375,"p95":1602.614735204647,"p99":1667.3689897998422}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 17:05:36+01","p50":356.32229625,"p95":1489.0229413817644,"p99":1613.2725549303614}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 17:05:37+01","p50":343.90240562500003,"p95":1428.807654526174,"p99":1494.710956605878}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 17:05:38+01","p50":293.08430518750004,"p95":1505.4802454013316,"p99":1596.0966140438013}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 17:05:39+01","p50":261.127584875,"p95":1593.3398968456452,"p99":1663.6901066066991}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 17:05:40+01","p50":301.9970823125,"p95":1679.7013472583224,"p99":1738.2871955395688}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 17:05:41+01","p50":278.73169943749997,"p95":1680.875020447156,"p99":1750.0490670013378}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 17:05:42+01","p50":283.98032659374996,"p95":1438.112722768378,"p99":1510.3263956796686}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 17:05:43+01","p50":381.2891103125,"p95":1373.8334974724226,"p99":1440.4676265293465}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 17:05:44+01","p50":424.36096699999996,"p95":1429.2412467060515,"p99":1480.247798462422}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 17:05:45+01","p50":404.5327584375,"p95":1350.9321980407344,"p99":1400.7133538258781}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 17:05:46+01","p50":395.37590518750005,"p95":1406.0518784615974,"p99":1474.5047530692518}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 17:05:47+01","p50":424.16779134374997,"p95":1270.118065203299,"p99":1417.2027806771769}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 17:05:48+01","p50":483.5946184375,"p95":1125.7023971981844,"p99":1173.6769715508397}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 17:05:49+01","p50":523.2461419375,"p95":1111.8885390386563,"p99":1175.1148986376197}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 17:05:50+01","p50":493.3145861875,"p95":1126.3749809660417,"p99":1187.2050726930008}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 17:05:51+01","p50":485.62401793749996,"p95":1170.671746817452,"p99":1249.869949970225}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 17:05:52+01","p50":375.33573915625,"p95":1172.6301490253572,"p99":1226.4049537688268}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 17:05:53+01","p50":432.89421381249997,"p95":1242.0035552350375,"p99":1298.170266649762}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 17:05:54+01","p50":434.7152865,"p95":1237.8399370978884,"p99":1301.9258377521535}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 17:05:55+01","p50":394.81720975,"p95":1223.1887834027032,"p99":1287.8939013727388}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 17:05:56+01","p50":390.3632971875,"p95":1092.8065360958503,"p99":1142.433870135969}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 17:05:57+01","p50":322.04309396875,"p95":1012.6762337363058,"p99":1082.2933943403984}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 17:05:58+01","p50":312.6388766250001,"p95":1030.0413480029465,"p99":1087.6335890640287}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 17:05:59+01","p50":247.4165023125,"p95":1035.632261511802,"p99":1081.3702653294831}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 17:06:00+01","p50":289.830662,"p95":1155.0678122287432,"p99":1253.4184490132654}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 17:06:01+01","p50":483.65295688888887,"p95":1304.148789603818,"p99":1365.4294759013871}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 17:06:02+01","p50":408.23393653125,"p95":1368.2785093369134,"p99":1431.8431825724313}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 17:06:03+01","p50":284.457265,"p95":1355.6433049089214,"p99":1426.1600297281984}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 17:06:04+01","p50":360.542467,"p95":1323.938563444254,"p99":1421.6217052107336}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 17:06:05+01","p50":365.69909009375004,"p95":1257.539513912955,"p99":1311.932770733215}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 17:06:06+01","p50":361.56167962499995,"p95":1097.7989467907848,"p99":1173.9210891124194}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 17:06:07+01","p50":371.25324540625,"p95":1149.6112820575554,"p99":1193.330575341949}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 17:06:08+01","p50":371.0302539375,"p95":1031.5072860223147,"p99":1108.2099965042057}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 17:06:09+01","p50":311.54698109375,"p95":1023.3589308805781,"p99":1112.666629273579}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 17:06:10+01","p50":316.07706109375005,"p95":1047.2560342413622,"p99":1089.9983291922667}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 17:06:11+01","p50":285.5257350625,"p95":1067.1254121504555,"p99":1149.229493040175}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 17:06:12+01","p50":341.00916678125,"p95":1033.6519712134707,"p99":1105.2822447265753}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 17:06:13+01","p50":385.0462053125,"p95":969.5855393743718,"p99":1021.6151338609174}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 17:06:14+01","p50":475.7539206875,"p95":973.5409310039764,"p99":1014.4331232804456}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 17:06:15+01","p50":361.74082325,"p95":992.4800208172222,"p99":1052.0541741479883}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 17:06:16+01","p50":297.86539990625005,"p95":1051.8186292995497,"p99":1121.5936544029787}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 17:06:17+01","p50":316.77178159375,"p95":1344.2454284926334,"p99":1432.1328669541592}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 17:06:18+01","p50":345.15273449999995,"p95":1362.9327780658111,"p99":1456.314712411845}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 17:06:19+01","p50":346.60168350000004,"p95":1203.2250740975664,"p99":1264.011091016971}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 17:06:20+01","p50":329.854547625,"p95":1329.56260461685,"p99":1411.8671522504392}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 17:06:21+01","p50":331.64297671875,"p95":1369.5252941519325,"p99":1439.3392633589267}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 17:06:22+01","p50":322.91674475,"p95":1454.6938690824288,"p99":1528.5498516439804}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 17:06:23+01","p50":329.52848537500006,"p95":1421.3845545543995,"p99":1480.390018925598}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 17:06:24+01","p50":364.1062195,"p95":1422.5455446569476,"p99":1497.7550356817073}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 17:06:25+01","p50":336.866436875,"p95":1344.3262204260957,"p99":1458.2943538082216}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 17:06:26+01","p50":332.0278978125,"p95":1458.210987771324,"p99":1515.5878125292525}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 17:06:27+01","p50":408.47199493749997,"p95":1441.0163304239159,"p99":1491.0600777861328}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 17:06:28+01","p50":364.47389675,"p95":1301.7809773102908,"p99":1382.024266349303}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 17:06:29+01","p50":289.61514,"p95":1437.8915892150503,"p99":1487.8665462129688}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 17:06:30+01","p50":261.2820050625,"p95":1574.3773639595154,"p99":1715.295600204656}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 17:06:31+01","p50":306.48532166666666,"p95":1900.4856953233914,"p99":1959.3899135003596}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 17:06:32+01","p50":347.07674394444444,"p95":1987.0920070010422,"p99":2046.1503789964656}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 17:06:33+01","p50":300.21185675,"p95":1786.3974738490601,"p99":1915.3588858500366}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 17:06:34+01","p50":293.3286683125,"p95":1591.0667253357801,"p99":1689.430731814074}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 17:06:35+01","p50":274.99551725000003,"p95":1624.037876941772,"p99":1686.7823469788561}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 17:06:36+01","p50":333.399291,"p95":1641.735723714206,"p99":1723.8424876724278}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 17:06:37+01","p50":295.24999812500005,"p95":1570.3558324015498,"p99":1659.7156597555115}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 17:06:38+01","p50":298.74792981250005,"p95":1639.9654198898286,"p99":1670.3889338263195}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 17:06:39+01","p50":305.118326875,"p95":1582.3132393376777,"p99":1620.8188857369055}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 17:06:40+01","p50":308.32676787500003,"p95":1577.2948544976623,"p99":1658.4143919724352}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 17:06:41+01","p50":324.82144384374993,"p95":1377.0318782547195,"p99":1450.6158322236463}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 17:06:42+01","p50":407.526544,"p95":1398.358110193944,"p99":1451.3371241151028}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 17:06:43+01","p50":422.76700881249997,"p95":1251.4498667376015,"p99":1356.538421428661}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 17:06:44+01","p50":387.5582893125,"p95":1228.1671347072893,"p99":1282.79574028743}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 17:06:45+01","p50":428.45975753125003,"p95":1206.508172558035,"p99":1251.9194606676383}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 17:06:46+01","p50":424.32365725,"p95":1234.4082672546745,"p99":1300.568644231573}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 17:06:47+01","p50":353.2118588125,"p95":1348.6170292041893,"p99":1429.658215864481}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 17:06:48+01","p50":264.49746424999995,"p95":1548.300570024333,"p99":1633.0994539534195}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 17:06:49+01","p50":288.97203225,"p95":1727.1941183661468,"p99":1794.3136480413234}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 17:06:50+01","p50":297.10481690625,"p95":1705.7105864712246,"p99":1800.8056620704351}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 17:06:51+01","p50":274.99192153125,"p95":1723.0523103513785,"p99":1771.664264527322}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 17:06:52+01","p50":284.01368212499995,"p95":1688.5993043329759,"p99":1749.2266185395813}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 17:06:53+01","p50":258.03191350000003,"p95":1707.2516698695897,"p99":1739.192990437422}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 17:06:54+01","p50":227.24005699999998,"p95":1735.6724076769008,"p99":1791.190239160469}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 17:06:55+01","p50":221.98793968750002,"p95":1700.9969694957285,"p99":1766.105690881879}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 17:06:56+01","p50":268.84264296875,"p95":1759.0433248118882,"p99":1806.5413080155663}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 17:06:57+01","p50":280.11956,"p95":1718.042489642283,"p99":1787.760332321291}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 17:06:58+01","p50":217.2884128125,"p95":1757.4904433020538,"p99":1819.8057201981865}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 17:06:59+01","p50":241.4069425,"p95":1835.210164831108,"p99":1892.3429971231203}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 17:07:00+01","p50":248.387096,"p95":1846.6099793137623,"p99":1912.5780353766986}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 17:07:01+01","p50":255.74650155555557,"p95":1809.3214380860227,"p99":1896.8878216613693}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 17:07:02+01","p50":252.743748875,"p95":1966.5199657566636,"p99":2016.8234461833163}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 17:07:03+01","p50":247.6069545,"p95":2041.2048859617253,"p99":2081.4418472215825}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 17:07:04+01","p50":257.18020931250004,"p95":1904.2600373306616,"p99":1943.1625608557847}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 17:07:05+01","p50":260.81548150000003,"p95":1823.685767876216,"p99":1891.6286333270893}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 17:07:06+01","p50":242.24287184375,"p95":1752.6306567508327,"p99":1853.960570859661}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 17:07:07+01","p50":296.8724628125,"p95":1609.301349962394,"p99":1661.3783602785338}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 17:07:08+01","p50":278.1744650625,"p95":1672.696581013998,"p99":1740.9258568679563}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 17:07:09+01","p50":291.3791745625,"p95":1624.916695769646,"p99":1694.4589007127427}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 17:07:10+01","p50":347.1897555,"p95":1515.5909914722167,"p99":1678.6294267623798}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 17:07:11+01","p50":341.071074,"p95":1415.7749070213001,"p99":1534.860402762062}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 17:07:12+01","p50":467.80584,"p95":1227.727381418774,"p99":1324.1613154856905}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 17:07:13+01","p50":507.54137709375004,"p95":1068.085906363531,"p99":1123.388905908791}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 17:07:14+01","p50":484.67312025,"p95":1019.9437474933646,"p99":1114.2225871840149}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 17:07:15+01","p50":477.65720799999997,"p95":987.1540037817998,"p99":1046.0016645031992}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 17:07:16+01","p50":456.17110275,"p95":978.3969959360065,"p99":1036.2624035804695}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 17:07:17+01","p50":442.512968625,"p95":1010.6646197001905,"p99":1096.6705168904227}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 17:07:18+01","p50":506.61885674999996,"p95":1107.3383585927638,"p99":1170.0028905870734}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 17:07:19+01","p50":486.68116381249996,"p95":1029.9143351451428,"p99":1122.7667519185438}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 17:07:20+01","p50":521.12841134375,"p95":957.4677914988818,"p99":991.3602230051482}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 17:07:21+01","p50":526.2432866874999,"p95":988.5824761467683,"p99":1055.888588842828}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 17:07:22+01","p50":438.86447515625,"p95":905.620798816803,"p99":966.1974065131759}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 17:07:23+01","p50":458.27564928125,"p95":986.1479512966905,"p99":1043.1246015947606}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 17:07:24+01","p50":525.754246375,"p95":969.580744779557,"p99":1018.5835064626189}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 17:07:25+01","p50":510.90560928125007,"p95":1010.0074454929586,"p99":1047.8161540518001}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 17:07:26+01","p50":522.57500925,"p95":988.2640079508752,"p99":1088.793849333809}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 17:07:27+01","p50":534.49082275,"p95":809.6402136078312,"p99":907.502464096427}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 17:07:28+01","p50":526.8640839062499,"p95":929.2991993946384,"p99":980.5723535757481}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 17:07:29+01","p50":530.96312471875,"p95":933.9860093570272,"p99":993.4890586453822}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 17:07:30+01","p50":571.1106760555555,"p95":875.5911535023666,"p99":944.159775608982}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 17:07:31+01","p50":697.6506065,"p95":1234.25593943824,"p99":1317.8892574024583}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 17:07:32+01","p50":714.376971611111,"p95":1174.2999372771146,"p99":1263.8422928783473}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 17:07:33+01","p50":573.3186310000001,"p95":1173.3807107685222,"p99":1252.903421315887}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 17:07:34+01","p50":515.4901324375,"p95":1069.216212916209,"p99":1159.8631627630261}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 17:07:35+01","p50":511.6343217499999,"p95":1154.9382942379966,"p99":1197.6823566329765}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 17:07:36+01","p50":474.62153118749995,"p95":1164.3838092998692,"p99":1215.7137372707175}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 17:07:37+01","p50":464.73654625,"p95":1189.1063654051695,"p99":1244.2673679435577}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 17:07:38+01","p50":442.74434175,"p95":1183.1499836750208,"p99":1268.7039497214894}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 17:07:39+01","p50":439.8443479375,"p95":1135.7784411096636,"p99":1224.470696467803}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 17:07:40+01","p50":481.74800412499997,"p95":1215.7399186814737,"p99":1301.2503561900405}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 17:07:41+01","p50":424.57774221874996,"p95":1295.9946804074864,"p99":1360.2668308326015}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 17:07:42+01","p50":437.0834298125,"p95":1327.1855433752337,"p99":1399.2812585717315}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 17:07:43+01","p50":336.3055981875,"p95":1404.5267862641706,"p99":1466.9410361389862}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 17:07:44+01","p50":326.76183121875,"p95":1626.0442089630806,"p99":1695.9256774298287}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 17:07:45+01","p50":359.8491203125,"p95":1601.0866901695294,"p99":1642.1718319782706}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 17:07:46+01","p50":394.23339603125,"p95":1482.5219496927775,"p99":1568.083420501815}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 17:07:47+01","p50":300.0798480625,"p95":1418.873605437893,"p99":1478.706121210202}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 17:07:48+01","p50":313.0404033125,"p95":1431.8802600432023,"p99":1522.568714345523}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 17:07:49+01","p50":442.11343387500006,"p95":1416.567270455724,"p99":1496.4330829792348}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 17:07:50+01","p50":392.8795325,"p95":1373.4451170509722,"p99":1411.4217423685309}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 17:07:51+01","p50":474.7321375,"p95":1182.8752136171909,"p99":1299.5592740875898}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 17:07:52+01","p50":522.590415625,"p95":1134.0824906493535,"p99":1201.2878057499809}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 17:07:53+01","p50":476.317135375,"p95":1113.0289169222544,"p99":1173.4862054520684}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 17:07:54+01","p50":565.2378813749999,"p95":1095.8516640520784,"p99":1157.3129277538146}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 17:07:55+01","p50":498.52211384375005,"p95":1066.15780215531,"p99":1102.7254026161752}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 17:07:56+01","p50":434.183253625,"p95":1140.7481515431027,"p99":1258.4825391730192}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 17:07:57+01","p50":432.682649,"p95":1203.0053655482461,"p99":1253.016329662368}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 17:07:58+01","p50":437.493586875,"p95":1240.4738252545808,"p99":1302.7382019095692}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 17:07:59+01","p50":444.197144375,"p95":1078.6903479039972,"p99":1160.5056427697152}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 17:08:00+01","p50":496.92812299999997,"p95":1199.162786436264,"p99":1261.3355736722413}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 17:08:01+01","p50":556.9536431666667,"p95":1230.4517246924556,"p99":1307.9512818466796}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 17:08:02+01","p50":484.902241,"p95":1267.2466029647492,"p99":1349.804507144013}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 17:08:03+01","p50":439.7490656875,"p95":1296.222106880402,"p99":1334.8310805175925}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 17:08:04+01","p50":453.805850125,"p95":1234.3673040904464,"p99":1350.2085841899598}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 17:08:05+01","p50":429.40374893750004,"p95":1240.6345841702407,"p99":1314.5706062136353}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 17:08:06+01","p50":376.37309803125,"p95":1179.9916788505102,"p99":1230.6770133426262}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 17:08:07+01","p50":375.6287781875,"p95":1149.499974572711,"p99":1199.4068368444564}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 17:08:08+01","p50":297.382116125,"p95":1111.5522314791258,"p99":1178.3547829037132}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 17:08:09+01","p50":328.76074221875,"p95":1182.5503406513822,"p99":1246.9218950070574}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 17:08:10+01","p50":272.8324859375,"p95":1082.1353934854137,"p99":1211.1838941586648}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 17:08:11+01","p50":308.39708578125,"p95":1108.0701601463068,"p99":1146.8861241574677}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 17:08:12+01","p50":254.17744134375,"p95":1097.265012777626,"p99":1158.932061552124}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 17:08:13+01","p50":253.31748953125,"p95":1090.814105026771,"p99":1143.7979327423957}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 17:08:14+01","p50":261.551596,"p95":1101.2164490263704,"p99":1155.999651232667}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 17:08:15+01","p50":304.22117965625,"p95":1204.4729781773642,"p99":1254.1068851973923}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 17:08:16+01","p50":317.9041055,"p95":1216.307649752094,"p99":1273.1865497752237}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 17:08:17+01","p50":331.36072628125,"p95":1367.5685705664266,"p99":1440.9281961454667}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 17:08:18+01","p50":459.33826925,"p95":1418.9548532522651,"p99":1482.1253930152245}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 17:08:19+01","p50":456.25150718750007,"p95":1357.048695054238,"p99":1432.1037448811894}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 17:08:20+01","p50":417.207693375,"p95":1350.4690518827524,"p99":1418.6895526505455}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 17:08:21+01","p50":278.4812806875,"p95":1412.3298143224913,"p99":1468.1486892532869}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 17:08:22+01","p50":249.4043701875,"p95":1505.0483397557373,"p99":1546.8839660699996}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 17:08:23+01","p50":256.42888290625,"p95":1579.5369205821244,"p99":1628.8257072227289}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 17:08:24+01","p50":244.01007312500002,"p95":1718.6295320909496,"p99":1786.4807587733653}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 17:08:25+01","p50":225.79654825,"p95":1832.4185597102642,"p99":1894.4642495406495}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 17:08:26+01","p50":211.6351926875,"p95":1850.837247518722,"p99":1912.5559504938735}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 17:08:27+01","p50":225.43305625,"p95":1963.2912985002831,"p99":2014.0140711015663}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 17:08:28+01","p50":230.6408721875,"p95":1846.7780771972734,"p99":1924.3864902263829}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 17:08:29+01","p50":246.57681887500001,"p95":1823.4961229449777,"p99":1895.3639102247278}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 17:08:30+01","p50":248.30398050000002,"p95":1877.0947759549574,"p99":1939.204811906602}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 17:08:31+01","p50":304.1475393333333,"p95":2050.9967223914227,"p99":2117.9385163153925}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 17:08:32+01","p50":315.2603467777778,"p95":2150.9935741085155,"p99":2220.453660322976}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 17:08:33+01","p50":282.24254056250004,"p95":2002.541657610548,"p99":2055.9744421269215}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 17:08:34+01","p50":298.65164681249996,"p95":1810.476411665589,"p99":1855.2673514229255}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 17:08:35+01","p50":324.621955,"p95":1660.8495221106373,"p99":1744.1787307954621}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 17:08:36+01","p50":288.27276175,"p95":1371.766223288791,"p99":1491.9048104506364}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 17:08:37+01","p50":333.7823635625,"p95":1522.6434561626093,"p99":1626.3904841728056}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 17:08:38+01","p50":307.28389278125,"p95":1619.3232651317285,"p99":1669.649876016037}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 17:08:39+01","p50":266.0406035,"p95":1687.9496892222526,"p99":1722.2587455763069}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 17:08:40+01","p50":274.0707415,"p95":1803.0503029937695,"p99":1888.338850841073}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 17:08:41+01","p50":282.5498419375,"p95":1870.4509298657517,"p99":1932.3210351522412}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 17:08:42+01","p50":389.8601725625,"p95":1740.8414846840099,"p99":1780.7737843000582}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 17:08:43+01","p50":420.11246374999996,"p95":1559.6230469744103,"p99":1689.856260700873}, + {"metric_name":"oidc_token_duration","timestamp":"2024-12-09 17:08:44+01","p50":410.55254825000003,"p95":1466.747852620311,"p99":1526.2728165811207} +] diff --git a/docs/sidebars.js b/docs/sidebars.js index ed75894399..45a43a7c28 100644 --- a/docs/sidebars.js +++ b/docs/sidebars.js @@ -863,6 +863,19 @@ module.exports = { "apis/benchmarks/v2.65.0/machine_jwt_profile_grant/index", ], }, + { + type: "category", + label: "v2.66.0", + link: { + title: "v2.66.0", + slug: "/apis/benchmarks/v2.66.0", + description: + "Benchmark results of Zitadel v2.66.0\n" + }, + items: [ + "apis/benchmarks/v2.66.0/machine_jwt_profile_grant/index", + ], + }, ], }, ], From 25b013bf1444a66d0d73e810139d9a65592524ff Mon Sep 17 00:00:00 2001 From: Lucas Verdiell Date: Wed, 11 Dec 2024 18:16:22 +0000 Subject: [PATCH 58/64] docs(adopters): add smat.io (#9010) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit # Which Problems Are Solved Letting the 🌏 know we use Zitadel at [smat.io](https://smat.io) # Additional Changes - Updated `ADOPTERS.md`. Co-authored-by: Tim Möhlmann --- ADOPTERS.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/ADOPTERS.md b/ADOPTERS.md index 10e8fc28b4..db4cd382a9 100644 --- a/ADOPTERS.md +++ b/ADOPTERS.md @@ -15,5 +15,7 @@ If you are using Zitadel, please consider adding yourself as a user with a quick | devOS: Sanity Edition | [@devOS-Sanity-Edition](https://github.com/devOS-Sanity-Edition) | Uses SSO Auth for every piece of our internal and external infrastructure | | CNAP.tech | [@cnap-tech](https://github.com/cnap-tech) | Using Zitadel for authentication and authorization in cloud-native applications | | Minekube | [@minekube](https://github.com/minekube) | Leveraging Zitadel for secure user authentication in gaming infrastructure | +| Smat.io | [@smatio](https://github.com/smatio) - [@lukasver](https://github.com/lukasver) | Zitadel for authentication in cloud applications while offering B2B portfolio management solutions for professional investors | | Organization Name | contact@example.com | Description of how they use Zitadel | | Individual Name | contact@example.com | Description of how they use Zitadel | + From 6f6e2234ebf9645390d40f8f01f36957ef0493c9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tim=20M=C3=B6hlmann?= Date: Thu, 12 Dec 2024 18:37:18 +0200 Subject: [PATCH 59/64] fix(migrations): clean stale org fields using events (#9051) # Which Problems Are Solved Migration step 39 is supposed to cleanup stale organization entries in the eventstore.fields table. In order to do this it used the projection to check which orgs still exist. During initial setup of ZITADEL the first instance with the organization is created. Howevet, the projections are filled after all migrations are done. With the organization projection empty, the fields of the first org would be deleted. This was discovered during development of a new field type. The accosiated events did not yet have any projection based filled assigned. It seems fields with a pre-fill projection are somehow restored. Therefore a restoration migration isn't required IMO. # How the Problems Are Solved Query the event store for `org.removed` events instead. This has the drawback of using a sequential scan on the eventstore, making the migration more expensive. # Additional Changes - none # Additional Context - Introduced in https://github.com/zitadel/zitadel/pull/8946 --- cmd/setup/39.sql | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/cmd/setup/39.sql b/cmd/setup/39.sql index 4abc815c25..2abb701c9f 100644 --- a/cmd/setup/39.sql +++ b/cmd/setup/39.sql @@ -1,6 +1,8 @@ DELETE FROM eventstore.fields WHERE aggregate_type = 'org' -AND aggregate_id NOT IN ( - SELECT id - FROM projections.orgs1 +AND aggregate_id IN ( + SELECT aggregate_id + FROM eventstore.events2 + WHERE aggregate_type = 'org' + AND event_type = 'org.removed' ); From a077771bff4527589d2f80eb451fe1f1f3057bbe Mon Sep 17 00:00:00 2001 From: Stephan Besser Date: Thu, 12 Dec 2024 20:21:48 +0100 Subject: [PATCH 60/64] docs (adopters): add OpenAIP (#9045) # Which Problems Are Solved Replace this example text with a concise list of problems that this PR solves. For example: - If the property XY is not given, the system crashes with a nil pointer exception. # How the Problems Are Solved Replace this example text with a concise list of changes that this PR introduces. For example: - Validates if property XY is given and throws an error if not # Additional Changes Replace this example text with a concise list of additional changes that this PR introduces, that are not directly solving the initial problem but are related. For example: - The docs explicitly describe that the property XY is mandatory - Adds missing translations for validations. # Additional Context Replace this example with links to related issues, discussions, discord threads, or other sources with more context. Use the Closing #issue syntax for issues that are resolved with this PR. - Closes #xxx - Discussion #xxx - Follow-up for PR #xxx - https://discord.com/channels/xxx/xxx --- ADOPTERS.md | 1 + 1 file changed, 1 insertion(+) diff --git a/ADOPTERS.md b/ADOPTERS.md index db4cd382a9..bc7e8d69f5 100644 --- a/ADOPTERS.md +++ b/ADOPTERS.md @@ -15,6 +15,7 @@ If you are using Zitadel, please consider adding yourself as a user with a quick | devOS: Sanity Edition | [@devOS-Sanity-Edition](https://github.com/devOS-Sanity-Edition) | Uses SSO Auth for every piece of our internal and external infrastructure | | CNAP.tech | [@cnap-tech](https://github.com/cnap-tech) | Using Zitadel for authentication and authorization in cloud-native applications | | Minekube | [@minekube](https://github.com/minekube) | Leveraging Zitadel for secure user authentication in gaming infrastructure | +| OpenAIP | [@openaip](https://github.com/openAIP) | Using Zitadel Cloud for everything related to user authentication. | | Smat.io | [@smatio](https://github.com/smatio) - [@lukasver](https://github.com/lukasver) | Zitadel for authentication in cloud applications while offering B2B portfolio management solutions for professional investors | | Organization Name | contact@example.com | Description of how they use Zitadel | | Individual Name | contact@example.com | Description of how they use Zitadel | From 0a859fe41677ba11ad1b36d8d9a780f07564c2d4 Mon Sep 17 00:00:00 2001 From: Stefan Benz <46600784+stebenz@users.noreply.github.com> Date: Fri, 13 Dec 2024 10:33:20 +0100 Subject: [PATCH 61/64] docs: correct list users endpoint description (#9050) # Which Problems Are Solved There is a wrong description on the ListUsers endpoint on the users v2 API. # How the Problems Are Solved Correctly rewrote it with mention of instance instead of organization. # Additional Changes None # Additional Context Closes #8961 --- proto/zitadel/user/v2/user_service.proto | 2 +- proto/zitadel/user/v2beta/user_service.proto | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/proto/zitadel/user/v2/user_service.proto b/proto/zitadel/user/v2/user_service.proto index 47707fef4f..f1b79a9524 100644 --- a/proto/zitadel/user/v2/user_service.proto +++ b/proto/zitadel/user/v2/user_service.proto @@ -168,7 +168,7 @@ service UserService { // Search Users // - // Search for users. By default, we will return users of your organization. Make sure to include a limit and sorting for pagination.. + // Search for users. By default, we will return all users of your instance that you have permission to read. Make sure to include a limit and sorting for pagination. rpc ListUsers(ListUsersRequest) returns (ListUsersResponse) { option (google.api.http) = { post: "/v2/users" diff --git a/proto/zitadel/user/v2beta/user_service.proto b/proto/zitadel/user/v2beta/user_service.proto index 156f961c59..9ad0a7e6eb 100644 --- a/proto/zitadel/user/v2beta/user_service.proto +++ b/proto/zitadel/user/v2beta/user_service.proto @@ -174,7 +174,7 @@ service UserService { // Search Users // - // Search for users. By default, we will return users of your organization. Make sure to include a limit and sorting for pagination. + // Search for users. By default, we will return all users of your instance that you have permission to read. Make sure to include a limit and sorting for pagination. // // Deprecated: please move to the corresponding endpoint under user service v2 (GA). rpc ListUsers(ListUsersRequest) returns (ListUsersResponse) { From 40fedace3cc7eaec80a41a322eb88e9f3f452994 Mon Sep 17 00:00:00 2001 From: Livio Spring Date: Fri, 13 Dec 2024 10:47:04 +0100 Subject: [PATCH 62/64] docs(oidc): add back-channel logout (#9034) # Which Problems Are Solved OIDC Back-Channel Logout released with [V2.65.0](https://github.com/zitadel/zitadel/releases/tag/v2.65.0) were not yet documented # How the Problems Are Solved - Added small guide and description - Updated claims (added `sid` and `events`) # Additional Changes None # Additional Context relates to https://github.com/zitadel/zitadel/issues/8467 --- docs/docs/apis/openidoauth/claims.md | 131 ++++++------- .../guides/integrate/back-channel-logout.mdx | 177 ++++++++++++++++++ docs/sidebars.js | 1 + .../back-channel-logout-flow.png | Bin 0 -> 279084 bytes 4 files changed, 246 insertions(+), 63 deletions(-) create mode 100644 docs/docs/guides/integrate/back-channel-logout.mdx create mode 100644 docs/static/img/guides/back-channel-logout/back-channel-logout-flow.png diff --git a/docs/docs/apis/openidoauth/claims.md b/docs/docs/apis/openidoauth/claims.md index c06fb2c1e3..e82f0b4059 100644 --- a/docs/docs/apis/openidoauth/claims.md +++ b/docs/docs/apis/openidoauth/claims.md @@ -6,67 +6,72 @@ sidebar_label: Claims ZITADEL asserts claims on different places according to the corresponding specifications or project and clients settings. Please check below the matrix for an overview where which scope is asserted. -| Claims | Userinfo | Introspection | ID Token | Access Token | -| :------------------------------------------------ | :------------- | --------------------------------------- | ------------------------------------------- | ---------------------------------------------------- | -| acr | No | No | Yes | No | -| act | No | After Token Exchange with `actor_token` | After Token Exchange with `actor_token` | When JWT and after Token Exchange with `actor_token` | -| address | When requested | When requested | When requested and response_type `id_token` | No | -| amr | No | No | Yes | No | -| aud | No | Yes | Yes | When JWT | -| auth_time | No | No | Yes | No | -| azp (client_id when Introspect) | No | Yes | Yes | When JWT | -| email | When requested | When requested | When requested and response_type `id_token` | No | -| email_verified | When requested | When requested | When requested and response_type `id_token` | No | -| exp | No | Yes | Yes | When JWT | -| family_name | When requested | When requested | When requested and response_type `id_token` | No | -| gender | When requested | When requested | When requested and response_type `id_token` | No | -| given_name | When requested | When requested | When requested and response_type `id_token` | No | -| iat | No | Yes | Yes | When JWT | -| iss | No | Yes | Yes | When JWT | -| jti | No | Yes | No | When JWT | -| locale | When requested | When requested | When requested and response_type `id_token` | No | -| name | When requested | When requested | When requested and response_type `id_token` | No | -| nbf | No | Yes | Yes | When JWT | -| nonce | No | No | Yes | No | -| phone | When requested | When requested | When requested and response_type `id_token` | No | -| phone_verified | When requested | When requested | When requested and response_type `id_token` | No | -| preferred_username (username when Introspect) | When requested | When requested | Yes | No | -| sub | Yes | Yes | Yes | When JWT | -| urn:zitadel:iam:org:domain:primary:\{domainname} | When requested | When requested | When requested | When JWT and requested | -| urn:zitadel:iam:org:project:roles | When requested | When requested | When requested or configured | When JWT and requested or configured | -| urn:zitadel:iam:user:metadata | When requested | When requested | When requested | When JWT and requested | -| urn:zitadel:iam:user:resourceowner:id | When requested | When requested | When requested | When JWT and requested | -| urn:zitadel:iam:user:resourceowner:name | When requested | When requested | When requested | When JWT and requested | -| urn:zitadel:iam:user:resourceowner:primary_domain | When requested | When requested | When requested | When JWT and requested | +| Claims | Userinfo | Introspection | ID Token | Access Token | +|:--------------------------------------------------|:---------------|-----------------------------------------|-------------------------------------------------|------------------------------------------------------| +| acr | No | No | Yes | No | +| act | No | After Token Exchange with `actor_token` | After Token Exchange with `actor_token` | When JWT and after Token Exchange with `actor_token` | +| address | When requested | When requested | When requested and response_type `id_token` | No | +| amr | No | No | Yes | No | +| aud | No | Yes | Yes | When JWT | +| auth_time | No | No | Yes | No | +| azp (client_id when Introspect) | No | Yes | Yes | When JWT | +| email | When requested | When requested | When requested and response_type `id_token` | No | +| email_verified | When requested | When requested | When requested and response_type `id_token` | No | +| exp | No | Yes | Yes | When JWT | +| family_name | When requested | When requested | When requested and response_type `id_token` | No | +| gender | When requested | When requested | When requested and response_type `id_token` | No | +| given_name | When requested | When requested | When requested and response_type `id_token` | No | +| iat | No | Yes | Yes | When JWT | +| iss | No | Yes | Yes | When JWT | +| jti | No | Yes | No | When JWT | +| locale | When requested | When requested | When requested and response_type `id_token` | No | +| name | When requested | When requested | When requested and response_type `id_token` | No | +| nbf | No | Yes | Yes | When JWT | +| nonce | No | No | When provided in the authorization request [^1] | No | +| phone | When requested | When requested | When requested and response_type `id_token` | No | +| phone_verified | When requested | When requested | When requested and response_type `id_token` | No | +| preferred_username (username when Introspect) | When requested | When requested | Yes | No | +| sid | No | No | Yes | No | +| sub | Yes | Yes | Yes | When JWT | +| urn:zitadel:iam:org:domain:primary:\{domainname} | When requested | When requested | When requested | When JWT and requested | +| urn:zitadel:iam:org:project:roles | When requested | When requested | When requested or configured | When JWT and requested or configured | +| urn:zitadel:iam:user:metadata | When requested | When requested | When requested | When JWT and requested | +| urn:zitadel:iam:user:resourceowner:id | When requested | When requested | When requested | When JWT and requested | +| urn:zitadel:iam:user:resourceowner:name | When requested | When requested | When requested | When JWT and requested | +| urn:zitadel:iam:user:resourceowner:primary_domain | When requested | When requested | When requested | When JWT and requested | + +[^1]: The nonce can also be used to distinguish between an id_token and a logout_token as latter must never include a nonce. ## Standard Claims -| Claims | Example | Description | -| :----------------- | :------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------ | -| acr | TBA | TBA | -| act | `{"iss": "$CUSTOM-DOMAIN","sub": "259241944654282754"}` | JSON object describing the actor from the `actor_token` after [token exchange](/docs/guides/integrate/token-exchange#actor-token) | -| address | `Lerchenfeldstrasse 3, 9014 St. Gallen` | TBA | -| amr | `pwd mfa` | Authentication Method References as defined in [RFC8176](https://tools.ietf.org/html/rfc8176)
`password` value is deprecated, please check `pwd` | -| aud | `69234237810729019` | The audience of the token, by default all client id's and the project id are included | -| auth_time | `1311280969` | Unix time of the authentication | -| azp | `69234237810729234` | Client id of the client who requested the token | -| email | `road.runner@acme.ch` | Email Address of the subject | -| email_verified | `true` | Boolean if the email was verified by ZITADEL | -| exp | `1311281970` | Time the token expires (as unix time) | -| family_name | `Runner` | The subjects family name | -| gender | `other` | Gender of the subject | -| given_name | `Road` | Given name of the subject | -| iat | `1311280970` | Time of the token was issued at (as unix time) | -| iss | `$CUSTOM-DOMAIN` | Issuing domain of a token | -| jti | `69234237813329048` | Unique id of the token | -| locale | `en` | Language from the subject | -| name | `Road Runner` | The subjects full name | -| nbf | `1311280970` | Time the token must not be used before (as unix time) | -| nonce | `blQtVEJHNTF0WHhFQmhqZ0RqeHJsdzdkd2d...` | The nonce provided by the client | -| phone | `+41 79 XXX XX XX` | Phone number provided by the user | -| phone_verified | `true` | Boolean if the phone was verified by ZITADEL | -| preferred_username | `road.runner@acme.caos.ch` | ZITADEL's login name of the user. Consist of `username@primarydomain` | -| sub | `77776025198584418` | Subject ID of the user | +| Claims | Example | Description | +|:-------------------|:---------------------------------------------------------------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| acr | TBA | TBA | +| act | `{"iss": "$CUSTOM-DOMAIN","sub": "259241944654282754"}` | JSON object describing the actor from the `actor_token` after [token exchange](/docs/guides/integrate/token-exchange#actor-token) | +| address | `Lerchenfeldstrasse 3, 9014 St. Gallen` | TBA | +| amr | `pwd mfa` | Authentication Method References as defined in [RFC8176](https://tools.ietf.org/html/rfc8176)
`password` value is deprecated, please check `pwd` | +| aud | `69234237810729019` | The audience of the token, by default all client id's and the project id are included | +| auth_time | `1311280969` | Unix time of the authentication | +| azp | `69234237810729234` | Client id of the client who requested the token | +| email | `road.runner@acme.ch` | Email Address of the subject | +| email_verified | `true` | Boolean if the email was verified by ZITADEL | +| events | `{ "http://schemas.openid.net/event/backchannel-logout": {} }` | Security Events such as Back-Channel Logout | +| exp | `1311281970` | Time the token expires (as unix time) | +| family_name | `Runner` | The subjects family name | +| gender | `other` | Gender of the subject | +| given_name | `Road` | Given name of the subject | +| iat | `1311280970` | Time of the token was issued at (as unix time) | +| iss | `$CUSTOM-DOMAIN` | Issuing domain of a token | +| jti | `69234237813329048` | Unique id of the token | +| locale | `en` | Language from the subject | +| name | `Road Runner` | The subjects full name | +| nbf | `1311280970` | Time the token must not be used before (as unix time) | +| nonce | `blQtVEJHNTF0WHhFQmhqZ0RqeHJsdzdkd2d...` | The nonce provided by the client | +| phone | `+41 79 XXX XX XX` | Phone number provided by the user | +| phone_verified | `true` | Boolean if the phone was verified by ZITADEL | +| preferred_username | `road.runner@acme.caos.ch` | ZITADEL's login name of the user. Consist of `username@primarydomain` | +| sid | `291693710356251044` | String identifier for a session. This represents a session of a user agent for a logged-in end-User. Different sid values are used to identify distinct sessions at an OP. | +| sub | `77776025198584418` | Subject ID of the user | ## Custom Claims @@ -100,12 +105,12 @@ https://github.com/zitadel/actions/blob/main/examples/custom_roles.js#L20-L33 ZITADEL reserves some claims to assert certain data. Please check out the [reserved scopes](scopes#reserved-scopes). | Claims | Example | Description | -| :------------------------------------------------ | :------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| urn:zitadel:iam:action:\{actionname}:log | `{"urn:zitadel:iam:action:appendCustomClaims:log": ["test log", "another test log"]}` | This claim is set during Actions as a log, e.g. if two custom claims with the same keys are set. | -| urn:zitadel:iam:org:domain:primary:\{domainname} | `{"urn:zitadel:iam:org:domain:primary": "acme.ch"}` | This claim represents the primary domain of the organization the user belongs to. | +|:--------------------------------------------------|:---------------------------------------------------------------------------------------------------------|------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| urn:zitadel:iam:action:\{actionname}:log | `{"urn:zitadel:iam:action:appendCustomClaims:log": ["test log", "another test log"]}` | This claim is set during Actions as a log, e.g. if two custom claims with the same keys are set. | +| urn:zitadel:iam:org:domain:primary:\{domainname} | `{"urn:zitadel:iam:org:domain:primary": "acme.ch"}` | This claim represents the primary domain of the organization the user belongs to. | | urn:zitadel:iam:org:project:roles | `{"urn:zitadel:iam:org:project:roles": [ {"user": {"id1": "acme.zitade.ch", "id2": "caos.ch"} } ] }` | When roles are asserted, ZITADEL does this by providing the `id` and `primaryDomain` below the role. This gives you the option to check in which organization a user has the role on the current project (where your client belongs to). | -| urn:zitadel:iam:org:project:\{projectid}:roles | `{"urn:zitadel:iam:org:project:id3:roles": [ {"user": {"id1": "acme.zitade.ch", "id2": "caos.ch"} } ] }` | When roles are asserted, ZITADEL does this by providing the `id` and `primaryDomain` below the role. This gives you the option to check in which organization a user has the role on a specific project. | -| urn:zitadel:iam:roles:\{rolename} | TBA | TBA | +| urn:zitadel:iam:org:project:\{projectid}:roles | `{"urn:zitadel:iam:org:project:id3:roles": [ {"user": {"id1": "acme.zitade.ch", "id2": "caos.ch"} } ] }` | When roles are asserted, ZITADEL does this by providing the `id` and `primaryDomain` below the role. This gives you the option to check in which organization a user has the role on a specific project. | +| urn:zitadel:iam:roles:\{rolename} | TBA | TBA | | urn:zitadel:iam:user:metadata | `{"urn:zitadel:iam:user:metadata": [ {"key": "VmFsdWU=" } ] }` | The metadata claim will include all metadata of a user. The values are base64 encoded. | | urn:zitadel:iam:user:resourceowner:id | `{"urn:zitadel:iam:user:resourceowner:id": "orgid"}` | This claim represents the id of the resource owner organisation of the user. | | urn:zitadel:iam:user:resourceowner:name | `{"urn:zitadel:iam:user:resourceowner:name": "ACME"}` | This claim represents the name of the resource owner organisation of the user. | diff --git a/docs/docs/guides/integrate/back-channel-logout.mdx b/docs/docs/guides/integrate/back-channel-logout.mdx new file mode 100644 index 0000000000..6f635320bb --- /dev/null +++ b/docs/docs/guides/integrate/back-channel-logout.mdx @@ -0,0 +1,177 @@ +--- +title: OIDC Back-Channel Logout +sidebar_label: Back-Channel Logout +--- + +The Back-Channel Logout implements [OpenID Connect Back-Channel Logout 1.0](https://openid.net/specs/openid-connect-backchannel-1_0.html) +and can be used to notify clients about session termination at the OpenID Provider. This guide will explain how +back-channel logout is implemented inside ZITADEL and gives some usage examples. + +:::info +Back-Channel Logout is currently an experimental [beta](/docs/support/software-release-cycles-support#beta) feature. +Be sure to enable it on the [feature API](#feature-api) before using it. +We highly recommend also enabling the `webkey` feature on your instance. This will prevent issues with automatic key rotation. +::: + +In this guide we assume your already familiar with getting and validation token. You should already have a good +understanding on the following topics before starting with this guide: + +- Integrate your app with the [OIDC flow](/docs/guides/integrate/login/oidc/login-users) to obtain tokens +- [Claims](/docs/apis/openidoauth/claims) +- [Scope](/docs/apis/openidoauth/scopes) +- Audience + +## Concept + +ZITADEL provides the possibility for OpenID Connect clients to be notified about the session termination, for example +if a user signs out from another client using the same SSO session. +This allows the client to also invalidate the user's session without the need for an active browser session. + +![Authentication and Back-Channel Logout Flow](/img/guides/back-channel-logout/back-channel-logout-flow.png) + +1. When an unauthenticated user visits your application, +2. it will create an authorization request to the authorization endpoint. +3. The Authorization Server (ZITADEL) will send an HTTP 302 to the user's browser, which will redirect them to the login UI. +4. The user will have to authenticate using the demanded auth mechanics. +5. Your application will be called on the registered callback path (redirect_uri) for the authorization code exchange. +See [OIDC Flow](/docs/guides/integrate/login/oidc/login-users) for more details. +On successful exchange, an SSO session will be created. +6. If the user opens another application, +7. the application will also create an authorization request to the authorization endpoint. +8. ZITADEL can then reuse the existing SSO session and will not ask the user to authenticate again and directly return the code for exchange. +See [OIDC Flow](/docs/guides/integrate/login/oidc/login-users) again for details. +9. At a later point, the user signs out from one of the applications, in this case the second one. +10. The application will redirect the user to the end_session endpoint. +11. ZITADEL will terminate the SSO session and redirect the user back to the application's post_logout_redirect_uri. +The application can delete the local session. +12. ZITADEL will also send a back-channel logout request to every registered application with previously opened sessions. +The application can then invalidate the user's session without the need for an active browser session. + +### Indicating Support + +As required by the [specification](https://openid.net/specs/openid-connect-backchannel-1_0.html#BCSupport), ZITADEL +will advertise the `backchannel_logout_supported` and `backchannel_logout_session_supported` +on the discovery endpoint once the feature flag is enabled. +The latter boolean indicates, that ZITADEL will provide a session ID (`sid`) claim as part of the logout token. This +provides the possibility to match the exact SSO session, which was terminated. + +### Client + +To enable the back-channel logout on a client, they simply need to register a `backchannel_logout_uri` as part of their +configuration, e.g. [creating an OIDC application](https://zitadel.com/docs/apis/resources/mgmt/management-service-add-oidc-app). + +As soon as the URI is set, every new authorization request will register a back-channel notification to be sent once +the session is terminated by a user sign out. + +## Back-Channel Request + +When the session is terminated, ZITADEL will send back-channel logout requests asynchronously to every registered +client of the corresponding session. +The request is an `application/x-www-form-urlencoded` POST request to the registered URI including a `logout_token` +parameter in the body. + +Please be aware that body *may* contain other values in addition to logout_token. Values that are not understood by the +implementation *must* be ignored. + +### Logout Token + +The `logout_token` sent in the request is a JWT similar to an ID Token. + +:::info +Note however, that a Logout Token must never contain a `nonce` claim, to make sure it cannot be used as an ID Token. +::: + +The following Claims are used within the Logout Token: + +| Claim | Example | Description | +|--------|----------------------------------------------------------------|--------------------------------------------------------------------------------------------------------------------------------------| +| iss | `$CUSTOM-DOMAIN` | Issuer Identifier | +| sub | `77776025198584418` | Subject Identifier (the user who signed out) | +| aud | `69234237810729019` | Audience(s), will always contain your client_id | +| iat | `1311280970` | Issued at time | +| exp | `1311281970` | Expiration time (by default 15min after the issued at time) | +| jti | `69234237813329048` | Unique identifier for the token | +| events | `{ "http://schemas.openid.net/event/backchannel-logout": {} }` | JSON object, which always contains http://schemas.openid.net/event/backchannel-logout. This declares that the JWT is a Logout Token. | +| sid | `291693710356251044` | Session ID - String identifier for a Session. | + +#### Validation + +Verify the Logout Token the same way you verify an ID Token including signature validation, issuer, audience, expiration and issued at time claim checks. +Make sure that either a subject (`sub`) and / or a session ID (`sid`) is present in the token to identify the user or its session. +Also, check that the `events` claim contains the `http://schemas.openid.net/event/backchannel-logout` value. +Optionally, you can also verify that the TokenID (`jti`) has not been used before. + +For details on how to validate the logout token, please refer to the [OpenID Connect Back-Channel Logout specification](https://openid.net/specs/openid-connect-backchannel-1_0.html#Validation). + +## Example + +This example assumes that your application is registered with client_id `243864426485212395@example` and the back-channel logout +URI is set to `https://example.com/logout`. + +### Authentication + +When the user signed in to your application, ZITADEL issued the following id_token: +``` +eyJhbGciOiJSUzI1NiIsImtpZCI6IjI5NjkzMzA1NjAxNzY0Nzg3NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJodHRwOi8vbG9jYWxob3N0OjgwODAiLCJzdWIiOiIxNjQ4NDk0OTQyOTc0NzczNzciLCJhdWQiOlsiMjQzODY0NDI2NDg1MjEyMzk1QGV4YW1wbGUiLCIyNDM4NjQzODExNjk4ODY0NDMiXSwiZXhwIjoxNzMzNzgyNDQ0LCJpYXQiOjE3MzM3MzkyNDQsImF1dGhfdGltZSI6MTczMzczOTI0NCwiYW1yIjpbInVzZXIiLCJtZmEiXSwiYXpwIjoiMjQzODY0NDI2NDg1MjEyMzk1QGV4YW1wbGUiLCJjbGllbnRfaWQiOiIyNDM4NjQ0MjY0ODUyMTIzOTVAZXhhbXBsZSIsImF0X2hhc2giOiJSWVFPSkJuT01LS0hrN1VnLWY1eFJnIiwic2lkIjoiVjFfMjk3MzY0ODE4OTgwMDM0MDA0In0K.lZxHE_Z4tiaDQE-DPtYjnvb0H9rz4wMoGfBMeEm4EG837DGJb7RTq7PuMHWc4Z2e_6lilwfVBWDEOhmrnjmkQwDVxInbbJfN0NiWgeqoW-C1SZ_G00UVIbJdaxPy2-haRihDNNpy0Gjmi7q3FkGXGqkJx9S7ZtC5ISbXLnqfbRbuapoMs7hHNf-Iltf8v7dMs3K8dcAPSHJm0X0x6Cu1ZMeAS2a6H05xKXGM0bRK830AZlL8xmxTNj_q_WZKzxz304XrRNHvYRcHKmJqURSHvRNUR38QeNaiKzINlV2sVvPEY6Dru_PHSPNFu7YLWiUi34VUla6VTxy9ctI_BtI4nw +``` + +This represents the following claims: +```json +{ + "iss": "http://localhost:8080", + "sub": "164849494297477377", + "aud": [ + "243864426485212395@example", + "243864381169886443" + ], + "exp": 1733782444, + "iat": 1733739244, + "auth_time": 1733739244, + "amr": [ + "user", + "mfa" + ], + "azp": "243864426485212395@example", + "client_id": "243864426485212395@example", + "at_hash": "RYQOJBnOMKKHk7Ug-f5xRg", + "sid": "V1_297364818980034004" +} +``` + +As you can see, the `sid` claim is present in the token. This represents the application's specific session ID of the +user session. + + +### Sign Out and Back-Channel Logout + +When the user signs out from another application, ZITADEL will send a POST request to `https://example.com/logout` with +the following body: + +```http +POST /logout HTTP/1.1 +Host: example.com +Content-Type: application/x-www-form-urlencoded + +logout_token=eyJhbGciOiJSUzI1NiIsImtpZCI6IjI5NjkzMzA1NjAxNzY0Nzg3NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJodHRwOi8vbG9jYWxob3N0OjgwODAiLCJzdWIiOiIxNjQ4NDk0OTQyOTc0NzczNzciLCJhdWQiOlsiMjQzODY0NDI2NDg1MjEyMzk1QGV4YW1wbGUiXSwiaWF0IjoxNzMzNzM5MjUyLCJleHAiOjE3MzM3NDAxNTMsImp0aSI6IjI5NzM2NDgzNDQ5ODkyMTEzNyIsImV2ZW50cyI6eyJodHRwOi8vc2NoZW1hcy5vcGVuaWQubmV0L2V2ZW50L2JhY2tjaGFubmVsLWxvZ291dCI6e319LCJzaWQiOiJWMV8yOTczNjQ4MTg5ODAwMzQwMDQifQo.ELOPuS61fy8GgCKEtru5df4-9GI4-KQlNf_DMp6b5mtJZIrfykA_M7lYOxOskYhTDicBoQ2jjOjzsDqktI4r6ptD068c5LEOx-k2OVk7ybADsK7tht5omYy4tsbHmkDCZN065WMH0SQH7NKGroVW-MACi6Peuiz3nlQsfho0EnLECqhZT60qxu6qtofvBVhHe15Zlkzffy0vxjKEIeJoTmX_cNVsHlrC_n1vTqZStBrkqu3_rwZxZuynX47vf7_kj_kKhJ3TffRF561n1AP5xnhZ9i--rnaucbGtGGImlKi2sdqC4GzjtdlaKJaRuVF-91x758SLBxqJXPucroJoWw +``` + +```json +{ + "iss": "http://localhost:8080", + "sub": "164849494297477377", + "aud": [ + "243864426485212395@example" + ], + "iat": 1733739252, + "exp": 1733740153, + "jti": "297364834498921137", + "events": { + "http://schemas.openid.net/event/backchannel-logout": {} + }, + "sid": "V1_297364818980034004" +} +``` + +After validating the token, the application can now invalidate the user's local session based on the `sid` claim. + +Some applications might also want to delete all user sessions. In this case, the `sub` claim can be used to identify the user. diff --git a/docs/sidebars.js b/docs/sidebars.js index 45a43a7c28..05a2c42342 100644 --- a/docs/sidebars.js +++ b/docs/sidebars.js @@ -258,6 +258,7 @@ module.exports = { ], }, "guides/integrate/token-exchange", + "guides/integrate/back-channel-logout", { type: "category", label: "Service Users", diff --git a/docs/static/img/guides/back-channel-logout/back-channel-logout-flow.png b/docs/static/img/guides/back-channel-logout/back-channel-logout-flow.png new file mode 100644 index 0000000000000000000000000000000000000000..1ee48ed7f78bd1ec9df29e19f22188b9251c169e GIT binary patch literal 279084 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 z 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

_juQ zxvKWD)Yx*Bs^Vvlk)~$BUl;u+z29|G0MZCl7sgGuw~z@s-cGge-QO|1#@?;ae0X{) zm>mCxxl(s*#@c+MVlFwYMYoS&3@3iZi^G-#>m1G2(hg&91!5LaA@)L}zu`l5mV_!8 zr46WETc!L-F#JpN3B~`Mhfgdpn$<t>bNTNf+ip(_cE9D$b6Z96yY;bzHNdm$9*jr?dkU}cF zRu-&e$urfOW<>*@rJfp`ebe4L?TNO)Z{}OVL7Y_A{UYi2Fv=xl(CT#Q$DM}MP3|c& zV9FH|pxT;Ln%z*FD~HQ(bjNiK0g%d* z$+^mv=%2OGmXfmgYtng{PY%x-TI@zxieA z8(*2RBT!9hB^7}&F-n0AQT(4<_Y>u(!GKQb8j7yuM^9%y!A{t8ZaH0Tdy;PM9b+B6 ztazjYCGAE^-k3zRjsh3pr<{#j4qWUv-Wu4*4Rw#|Xx637XkZt6oq1S;+gp+>F->Fl zi3iy?`b1GqusfqG+xoy_z+0sn^8TkZcEGmnH>MuwP}U@gg}JFAw7p}45Quin1@vWM zn0>KqW*0CvO7F$rQYnghKYsfh_vtYp-Ku)=Y#FuPu*gm4=!cV5I^)7E*14!=Rt%lU zlK5GZI1f#$Xkf_qHJ60hv*5EuS_|SrekYDhBCeFY4?&|u9zpNFJNU!euO3aBlt3JG zkQoUR9z@P*jbCg?0qrq1tw;$Vk*z;>rwodz)a3JsE~t&iuygomRYa3AdlW|3>Nu)N z`q}^6YWTxNGbVb~OF(LAdZp7V;MfrH17BR{5l?fAsP{Uq@;Qxgrbh9rkeb97uCW{U$+TN`C!mi8?e!8}9Z`pU zn44l-{_-$@y>F)KjhquKah*3)&MC$oTPNCyyRl7(F8fbW;3^S$i%A|njN`#lRAs1W#OK~*$2@qFmqLm`&K^C z9Kq9h9iLfz$e>A$LY4s$p6Lv0A#*0J{Mn4E2=i9jX9jY zP8tO&Na68UAP;gLhzi||^lzGi$1e^n>wTIO0fJc%gD%mOHW4Z;FJR#lQZNa^_N0)> z9-6ku7j}K`-$f*ZaM9}WrY#p~=iR$KVV-+mktIdbdV;oq>+1>z z>$Z(rU_&=VmQrJ4p=u%DY1kzL|72AC6Ud*@0ndD`T7UvRy;NtgXM(Po4B^f?^q|F!V+E#be{&q9m`Dy3vXX9ShT6vC*@EiP5v-GQUoTr zZ=U55D~{rOv~naU9bop4?1L%ccKrrQN$wsEn{hLl8gzNFyVP^2a;KtA!hQ99$QTB` zTOm}Ul3zENtXyoZSgb&jNbTjXXaC;aA1?GB5=fIGDqOX$IDgjf#L~vO+~=Hgtktat z|D4W$HxoEhVfge4+wple9KUIaDTVMX{P1nETQLp4R%q*ZTy(EawN=_R)GgfpapS?n zY!rQbfKXt1v}HsV5z;h_dRq2M1|qxlzW$MQ)JmZ71(qKD4qJtvIySVu^NkNYYr?E2 znbPzKKz$&OVET$?_;1tu4;Q{1gb80a;z|keXqIWnXcTUHM6}uGgh8K{E1z_RyBz1y zo$(6MAUkX1N!?mcH*YI1aXl*{zpivV64K^4Vf2l!?0Ws?-sZ=zMc*f?(?9EbQ!y$K zy`E6cR_nJQJ?~bb)x#f?YvK}W5JK4s<~3u0(~)D1mUMo5_L(L@KNjmD4D^@#m!|ud z%x(&hO3^$vAv1SNcfo@7gvgk!M-m8`C=_BbCIjO=d+pP zW@TDM`j_P11f!D-K7LH=x(VS=k?@K?dB#A{9rDh^LcOpet$bc8HFiL}=JS_UuLW99 zFtXD-+?6@auvF8;X2`^j^kkm&@$kr-cr#ph<>@zFu4$F{%y1_VMEYB9xHNmIL^U;p(mf%?hSBy`FLiW|2VZ%{>a$EhI9e#}x)qd7{+U^cT zJo9H6=3atyMtE-p@f#>rLLtGBR%AERj1AA7zs>c?DL!G%x4v zlBvg@3^ayay3E^`x-?kT`PMA3c`A&_lmw~D`coeWZKqfv69Che7INB18Q_#$b#ViB=FD>NdeeC;P{_ZgG zK}%b6PVQbN%_kPCA>*Z))rt*(Z)1RWACb(u25aZSM^j1F3}7nS=e@6)X^>Ov{s&zh zn8+*}SUD5(7SrW09X`-4&{kM-MhT+TZTJIx!9ko6w?kcbLW3-Z3gAhSZsYFZ=wd=E z(~>G4*D(Tl*b8?*&EF8Yn}T0)c>nDdQ4z*L%bk*vZT3QJ4>;3sjB0Z7cq_T(cx60W zsrWm{%Vv3=uurHG0)6HZdEGQV>VoO^ftB5W?e_$n4-;4FWwOZw_LWb#x9(RLogo(RH6BBvt)lbwSEkH#!n>9fk8<(wL@ok6Z z4YwPfG;XBfa?$Y4Z`mlNjZ(>s2t|Ri=PM1 zY4bqd3st3;+VB|&l6jVtBx)BFSLpU0K z`z@bm`P6`JBz6xqsK$2Fb$1-++5`=TSJ z4`Rex@XkbkZBSL{)Z1u{FX!S75Etd*CxFO#_pH$;t2xLWjFhS0F}@_8ZMXd5*y4Si zj}IAA+KkJQIK`~;(MD4CX=@N4g(or7J~Z7Qa0k9E{i>^fY-v$~wtlFxqQl4)dh-W% z`VU3jmyFI%%u&f%caQP}6CdZUto%K=(eq5h)7}pJzA>jxDYHv(;-0MCllRw!Em#Ec za(b*8iJwT>t#^xIECv_cIt^XGw~E2~Iec-?gP*l+i)w}Ruy&^<8QdLqAhw7Ssphw= z)fgb9r+^(viKH6~$1UP;g{`8A#Vy;J$wDP(+p`y)h7l8eluAi26F~fjhB7DHCv?GA z=c~PAr{3&jv(IEduzYwr{k&4Gd*Uq5=c}e}2B|RGmzgZG7_zWsAy-n{5Qf%PD~b7^d96c=ofyM6sp&NFyYQsy10Gr;>XHgx$?xx+}MR3ybIy;vdxkZ)pGUv__0T5m7)1w)H2`G2K^bUtV8?Renr zm8dv52w>No8HIgO7U&;c2iLck#me&|73SU9BG~7P=C-=Srj@|Nit$44;p*YK|MWQhvFo zfzn09DCmIrAmr3-4URUsxP}Q+P%{QMHcPd}cNUI!vIFU0MMK_`%v0Mj{zg_s(VXTW z)?^(SczYW(!!7njWUnpEYtA*Q&kwH67CO;8F5Sl0$3mWKmm2(`lXlB!k7{Y3+N3ns za1c|(tR-^F(hLvk64?257lYe$jbJy^yJu2UhnYgPo}T5f+9ne+30Y4%0dxpmV)?o@ zGVBA+HvE;%4YLnZuI;QE_?U#%CBqC>M<~LvE&mvm`G8g&nbE}T&pf2n=;WbXwc zJy_4h+&0J~s%VYEbfy*!a2OGzL^)5c3OHOlh_y*ZGCS3nt(QbPCMm@@x-_u@-K@FK7QRp%Zf9gbE3%hiR&o zu@-OgHNO!#4IvlUx)Ff5#vHgx{6dn*=FDQ!>1*oMRCG)aG{h699J|fM113N=YvA>(wKnCB0pdoCr5|3ih(?^ zsYcbg5nHMLM$_Y)CL`ia34@xZGIwXkm`m0WLJ-xR#>{o{sq+UWb`p*kpd6nfNBGdB z^2kJQwxZUh?iuCD9zjszT06Ybh@QFjN57|AXE5o_7M&Nm(B8tR&U`Eim*ILC|Fi}6nW!1YH;<<;ydF=P!2-4V;*Lrabk_G~`# zt>#8(U&QW1MLQ0X!H(Hw5sbm6?>m<2RL-cYb&PI{lu_QozKzku#{GnLe`GICX*b_) z2_=j3@Gh8BiepchRwSoZW$lOHCf_WHrtF(ETWe&gF!P>epQgHpj3GNWyTL1jl!=G7 zVX8;f*L8oo)9ORv#-BB+H+ep#XD}?D4$84-FI!)@LC5$ZsB{MF6URhBsx8EtTjV7e zYyfWbFtRSbq)6NgYq_Smi0YQg9%#RzFp);wjBazT2dwfRe)!Da{cD{R;3J~Fwap)Y zo%k}Hc7$?mCWCVj|B{%KGCgJxn%*Rc!5`EM|MdzpwLbcYRCq0Jb z4|cBum6VdF_S-p~2)Ueb>vQ+;ykW4-q)n7RAx18yEYk$)>}eC7z7@a8<)(i5Sl5wG z>u~x$ho`0seWD~vw%~4A#r&<|2jIV-(&TC5T3G)coh9|lFP1>^U^|cRL&^ykX-AkV9<5<6Ns>!Aah-xX6&67I!ka_ zsTC*-#t8f*HM%Sz4^wuVRo%|B9i+;K(bKH?U=m_}d^EQHf%Dg1XmbFey+{sP6e$pt zHCR%WUQ{F?!RN*AfY`yOdE)caxeT+YSH-2~(yt1O`mB8<@@OqmMJd)>mlx0GDJnT1 z@?<}0N`l7C#nJU7;Pz5KWiO%|eR5@zv?H`G!3Ohd=o_LR_tDPrayxdM@MVv#Sbu}&LRFja% z$a(LV_3uQ{+bw_k)JF;kgZr8HcNrXaKy+-F>!7W3?yNKS7PqSk_+PGJaA_Jn)p5`jCrLG99m+NBTY0w;GWdYs#IFC_HBo z>UWbp7TZ#ogQWu$C5Z7lWp8QyiOIAUZ>Y9h97M5x>CycJDYyA|9-B1P<1}8P-+f}B zB5Acw-9yCZ(0WmYBIFUQ_3f;>Du}_EnRv@_dLX@0eOHid#-idj^?!DOwA(Wl~>R`cX!T^5d!tB%}In|yrr z!G@1P%hn~Wx;gBxjF?KotJhIHD<8aI^WUjLlvgMMX}AAYVBf>)!}L|4S-Ht5lJ^`w zjLit%!+(#7MhEaQXtzwnD%6Y8W%cVmpy_z$yg4F%<|*^_j>k36!+(rRedb0(e7TaY z;|P`b!w^==TUGk>!q*M={l$fxdhko4BBSg4qWpKN=sZOg*XOg;TW zM(Ko$rg~#+2<~ITL2v?qn|HQ*?PUO_#5{?;4ue(B1slG#65+w76+UhMv9tS^smuBa z6X&JFab0{spoI%ByRdkp96;wo*FbUvQN~u-QBXcFmWU%a=d~zNVC~Ix-IeFexTnNM zcuuSM55Y=jpOcP(lZpxf4Xpgm^#&Tnruy6h!Y_?(ivhFF4^`V)KYpD`=}|h`7+F ziOKm+XKhVTqL1RSl!O2H&a*7XwTOK{tpSez+o~5PtH`6WrG>S(_)O&^CvzyXK`y@Z zF|pyv*(0qpRUbI)y^cxW-dQMen3LgF?_$JvYB$V4B>oIH;BY=Fw%hfo_s5LDgGTWp zAyxDPURfWX3Es8O!FQm|xnkI;fLQ9-)KA1%BQ;TLJ|{a{Eaa}jvHE>&DU3N2ITs$` z0dy2`MjQ$iP<%YzqT1@O`|!@^HsxsA-O*-~ZMC2OLf*}Ag=dPGJDBtMT!5#p6?q4V+<=F?@|CGJpJFT;b4XSk zk-8UG@JKHcc^b5|Zb7UbtN)P!swchhkVaRpcgfNCq0U>dig;I0ch3hap?CcO&J_&Z zp+HoSFb$$>(hPW8WD@ZhYDS6$ByUm+ z5DnJWaHpUHMgJ-oR}aIpcnqDvSUytX6dojeFC-^R;9fb#Yke!$@zl@b)5189#^$9% zqIx9J@KYepHoE=^x;scua7Udr=%KFl5jp-V&~%-oh==-E>vpNpDicC=kE9&IK*Xu4 z-YdI$=4z{K8+AeUv)1_2Li>v#=}iy)!ez4-msUL2bUKlh=Goy)mL0?-G@aKZouC}Z zP*m3WlwC3U;8Wyi~Ri zzb>;n0)H+-zoiIaOgfD(j#iTNY^5Sbx3?+1aNNC!;}5cii%^#*psHQM)oRn@sZ8`N zZMll$VU3F3EDk;2LNvEZZ@rSCB4rZ%x{E|@!~=tuMD(fE2&bJPxLYK$4uZMtoCWBH zMayNu_Fvd?q!|{f_rGg{Y1qDQlsIBJ%f{3{cGVR=DVMl=t1|7AoICso@;@?5-_WA( zOKf0^^R*vPiuEX?8FKh&%Ue9P0P{F%r;OA*ZRK3$HpjWq9vsiEuW#=lY_E#s+G zv2!$aC5;h@f)?P>$)CHHVqOZ3`8-|rXj7T)Bv&6X3hTWz`nu<%%zqZ8+l&T>TmKJP zQ}hOv*Hx1nEL>t8I6SA+!_-vM6W4AVtCow$S&(4&<<-b5e36qnC>}dIi9J4zj!Fwz zqsB1SRXWZ9&cx|_c*?_qqcGiawDw6Ho(o+|kCXPB=(?6vAWpTUVCMi*MMF;STKxeeT z|L*4Mofp*?duFDx?=yfmXn&#w?C`*OKjO0FfAzoCUw+9*R^`jI!Z@ZBKRphNed6=zKq1>Fhkgz6AlXk#G z-nSICop!je*-MDN)hNMQx5;9dqg#2s*5pYIvtl6yThrZ-IgtWe9MZP5ON3^pGFhfH z$gqThjG=d8Y(8B?3#>W7cx$%Kvsbi~xB08$@%~JSXLccEkQU8z$;$fWUiV7)0BVlh zIdvO?#@EwPw}iYgT;2cq^h_~sJ4KPrnDfwpt9Ww<{~T5Pt&~p{n(u90Mf(wRU`*nh zobzAJa>lWaT8BQ*@Y<41sQjWbr8D?~*2i*k_4PVL67VIT+1xwukmTM200Y1;c~c0B zbJLekDksNv-nK*tVl%F$R{2Dk1+EB-MwFUKomL+ zTar@kRE<95%c?4%DUBG0?$Ur#2 z@67~J?vOPsI&Onh!?8=2SGl-8L*VYVqPaLNhvrK6KMc>iMwBX7U~Y>uFXtiow`3>0)?^^| zy0r>51R1ssmot6nZjHnH?;MrfCe{8O%g zFH$RA+8qsTOW$0(;_#ZbhjQZIocrc4@DEJhW=KPV9))d$S?tnn5`xEx)}HFxj=T!g zO+;?dt7kLGls9g8bxhoWRCWylTmME#{r?@sHZgtqmRBH<5%yX8AafB4Yw=3+q+ZWV zK7A0|P3)S;DpZQ=gLWJN2M#?wlsP*Oki`@f-3rqt?X4DE)K*D@6-kJu4!m*0F%@P9 zR=9_vLx>PAG>z^CPS|JSxEZgTP7F0A_`0Au;cqBp3@3Kr9gCCgGhLDno9FttjUWNj zX!2}oHKmr)hSsaQW5HuuC}0xPTNTCD-)yxN!oeRufo?zw@tl(yFWC_tq^wT0=~uZ`HuD>$~QE;D$Wob^_B zkf-kOwOjVhCdK*AH$!D7;&;V6JpsvAU7N*&n0mLf!9pDbrppX-Hget1_^j<*-s$2E zz1thOt}wayRg(%AG}J2L9)D~*VoioxVxNKRNS}e^?l9{&B5RM)lUL@kjlZ>N#Q#?` z<)c2*muj(Et>uBBGK6j*r9$;&jFgl>TsFN7SG15pFW-o-};ja2q=IK z?Oj+mr(AHSDffj`*PHaFSnxX4fgSRLv+>M6_cE?@1MX}eS11N$r1jk-cSOz_&z*lb z6${To<#Z^Q*M#3=Kdn6~-B&w%zlC`0HWd+*4K}c8$@mN?V8I*AZ%fU~IFIk1ELT?U z9(Pd%hM;H4E&fEG9Oqt@dcwna@$2~RDQzo{Xwfsx$ffqo#JdtwziS`a>QoDaiFg(a z-IKCAJx2D&+3|FMmD+|hl0dFFIJ8dEyzkJSFu;=xjIpRHm8&wd4*l;>{yRIWE)%08 z<)r9tPfE)iRCbVYFO-5XEzv!%B;n`wkT5~kcmh{2+p(QfAfNiBZ7gzn3gq%l3a|lP zBXe&q3qCwMG|W4Hirt+xN&GOhgH5aV(N?zTKr$RZG)J1MvNPXSx;2 zI8QC+jGKVdbf>H4+>%?F$fu#EHjDRdn424+Y}S`{S%f~hBCnx(J307ldo3I~L9Z29 z7~X}AWo=`W;bpPUb|!?&K5%+I7{1-^I9bd(nL^txI4IM_TT8w*M`+wTe{B+fU2RhK zM64`f^k&Cswfj+dOv4Jj&blzPz^QsxLB7tbk}aH$d0#9}NNl~U9$!4rJB9zFm-@Hf z`%O5n^>f@-rS-KO(xzfz!e~Ij&@XJ^!;CvC+-V)(X`kbjV9pOmohEsLjpfsr9+BTN zzcznw2`qWb0!%fwes~8v_VZ`0d49Hj6DwZtl^eG9$kJ*R%1sXy^FHm1yfs}qTG}s# zCqoAjNh-YJ;&l>fnq+1Bk11%0pP2b(j=$)KF0#~)PCxxJb>)V zyo)4#Htq2^)wVVHpx9BFh9%M;M_fL=G{<@2mLq=g>Ay3uKEL(J5!Y}}D(Sh2E9oDI zb?M0uyl0`p9Xy`UbNxUeAcs1d7(9)(MBG7u2f6bnzBy|6&^+PCvoV&y5~rGr!x@Lrw#}2vh12+Z`9q0)U#aDZ9l}3+(<>PTrKTC&tXw`Rgoy`v9387l0r)I5fj+A#;&wr@3$J|qCZe3TMPrxS!B~ARvId49$ckwz&xjUJZ)-g0F z@nl|cQa!y-Mz3epz6;khagD5YRj}5}(_NTTPp4!wIVdMok$b^&N&=#IZM2`x^CCD< z6*)V;l(q1O3F&4{V#bDj0D?|@Mj1BM<+(rs&@%e8!1A;<=~?@A?qKb9I@QNRPEt^&tgZ1#7aKZzV3CSwRhvMdivo`~+KLRD`U-L6?NMA@E5)efu&i zz1z<>_x?e)JPW6d@T!6ItKhVQE6Dk??eHqGvx8hA)J#_AeWj`M-Rz19X)(G%Nw4)$ zlZ>Qu`QT@adlj4<--cyZJ?FT4&ui})4Bws_hyzOS$5Z83d!3O=rUJ9oEAN zJu!}7k;X%ioIRTG8s+M=xBle?vG%yhIF~CZzG@_ph|>>+6R&xq$1!4O!ueQ5anSNcZB#Cn$j&ul>5HFVPLW zf7IfseQj92fNZGbujegSeQ}g8TX+Fiz&#S=`N5}{{pl%!5O@8)#3`N+>hSdnU$fRD z{o|fT5R+3=f6Z7Ew=DU-Y5~`|^l5Yf&W4@0*0&wj^Zgid9PQWOkm{f0G>q;GOv?gS zUj4@sA4qTNmnGQOS>wo+(x?t_(f5#)_Lj=y%Uw9(+4MQB-%zVT?PAw>GE>i&1Pely zK@TL0YW|H-^+a9mcO&#}Tdi5K$`zb%wG1(!^j z-KWu0xAt)5RWeWeoOBt7rG-r-l$@&wA{ickCarIbl8Ts-)wu|*o`BdOiu|y>=6dcn z|3**LcgCG|>FS#pjRmQfdi8aWcX)f+IU{+TwEyHO4C^H+1Mg0abXPrYCNX53n>ePi z0+1m&O7a1tYvmHdpzA>Fp+6@CQxa-zX0m?tdfJd+k4_T?)#{$M2(40B0Yx3X6-PFdrsQe-S#t(D&meEc3qVq>M>BF{hr#JqPlb;F z{grw>#c-*R9*_8y>ZjuNgqQQ|JuHC%TLMT%QA53&!(jRgVkn4`IW#% z0AU4%{9PG3~Y zaQX|pyw-FA4;zl&^!ST9<0Bm=HA!*we=S#Eg%8moB7Srm@ z&6vDV8&l(?mLKL9@>?+94ahttUdX?(&!uF{grRw-#nzSQ$Q-)Cas>Z7_2@4mNSw&` z`e`Mxm1J!ijIYVBO5RW-T;qQh6=r6iu?a|1owFLJUKR8-4DN_uIM7z8(^h%RzUb3#qzE5^~cc zd{{?A{JO5h#pGxFZa3{IsLG=Wm~c4b6bLq(1S*q??@pkw3?cI(JJaSDy(5A{wCer`ont+I&iUH2;yFe&5`EtWhIM4~ipf zc8le$BCBxCArSMw0Vq+QD43||U{y7jfn7Z%Du9D`{|WLgbVXb0RCQ| z^{mp5p5r>ZL0dZ&vcA$0k|T1^OIg1U1+msv)8@T$9~a{x1qD#P4#NYpayy5RL>KI*QTCld(ZS$}`uP*@6{P(o%Ga>U}xsXss=V{e+l5_3i z%QNta9IR?T$HrM=^Rm(UXBd%^EX~iU_vecAoLog59zaP{xBz>h1%H%|bApCrC%|JsKY^LSh zQBsZWtZ6-*TH<1eF&08ovhSsipqq%^VW2u+b{0L*br!wXb(Tb|`zl&m*bXa(J|^i4 zF?nIdcl~CSq|&f*wVXf%egD`adE({f$;*SMaNdJgGn#u+6UzZA5zMK;x6QEs`6){b zQ4|f-bH2BMr4Roh*SkdTlrS0()aD#ifv70siy{ay>glKs=$t=!mGViJp6%ays(wD4 zc!iwJswz7mQipr!l8DN6mkx)Ja4v(gCp}-2KSYn#Xm83A^#Q($+d!&~Q>^Y*Fy$X;4)cMm}ptqZr7`ME@5vfB*ASW>RUVbv1e&|NBd9g?sWiKm4_>%( zV(9)RFMW4Mr66XP3lXWRCnB^vnclt_Xzzb}mE>HL$zC=ytAOY3{GzRXh!&zV$t?v_5{J9;9xXefiI}HTrbu ztGF)Cfo~2i@tS|PL`2TeKQEHqCw=n=)I1rXW_sF~^AR$!Vy4&0E#&Z%teoZ7tnk8} zAT(8QQJp4YVL@@o^2?=1BYiGmr@8-!=RxC< zqR16t--9Z^UP_A-G_L=stgKN(f;xT~+Cg0Us(V>Dlb34< zt&sj4WCak?tsC8MDXM^)>5SnyZ_ul#@6kF&)j{zKc7qikB&jZuzdJ?W5Y+2?tuYfx~puPSbI&CMsbl@EZq4w{MaV{ zU2I_<_Q=?l%BS8w)qKD7^Rq}^n({A4HCl#tj^saXPi#&wp^a!KH*k)|x~FQ0=PEOY z66y}xV)!qa@8y{z~3oo_}7sM_NxHBD0=@wEs{ zF^!m1N?SUOUrD#+6YIN}*Wk^q>^Vs zU8(C4!*USI$;;EM`Y9T638W0?8LD103(nG+?;XUk&gnIJmz8*%dp8nAuI-z8j;dSp zI&6p#cS#rCB6*9rQQW>nTF;pA12+%fI!ctxDC?8WnUo8X$ub|70Vq*eA*NlJy2z%x zu?bnZb?Ds5(96kW<(B2r(GJ2<- zj8V?u^54)%eQ^}&?CGGgZZ)qov6X4x*VQXjE5wPuE4)U}`S*q1ed`qauo=-b!AsOP z*{!!{r>Fq78P;ql?;c;f(XCZOUGNGJJJ?E3b-J!pb=CI@*TRXu+ubJwR3_G`iZ>ke zB@9GH@d$ynsaRLbjdcefn-)^&tjgB)jLlcH@xwgN2nPV}{7~i;o7~1@gV2d)tRy*t zZChE;(~&5GaX~lMFluDLdZK#u$MU35z^BSzr}~G~IMD?C#`AYbJmOz4kTUgyEFufz zcHsAlJ-t+AfO!2Z1DHN`bl}$H%pTVb9N)oiaKZ4};`8gdWHmR(0e5MHOQtlZFS~hK zsIzzIdj6Y?i-i(8`!D3_Hm^7|Q)=8z{Ni6LV@!iX;HhA@p)rrOCiA7nN}_pQXK;R=0yZMH>zwz&63eh@ z^=-mUGm5byF80~-uME;@w6%7f&)c&B+}5NZb}QJy$!w~wh?AU7*H2hBFDqbd?DU}h zA##_P>&hn?1-*dST(&Ww{WsMD?68yBtzG1sYmxgV87g% za7w7Ue4iHQX+<=(+Wc!K|zb2T6KjgJP>;(f|5F zU9xTW+@s9L2Q(#{19UuwvFqwgdO3GBm4WWb=upMVt-7^-xWW*5$z7gP>XrBLL4~6- zg!r~)+Gej|>5Ug@U0xH-VXQ4a(oF&lG`f%CQ%9S<%w#vb8~O70352J6m3$pYa7TXN zsswH;e`{N*D&KMVD`b9(p_Vc!TXp_w!1b?%{-;DIs;!sGepWvoqpaMq?JW6bFl7lz^^x|J)^?GQP^4r~Z6HC_y}{Z52=0SV!x0ubT(tA(?QHg_wYJk*vw- zE1oo$Xq^)P z_BXd5yAHyog2K{U5-S&^n5Py*aTp;s3h!8T9PzduDM5obGw0k@^+Su(`>=3ZXb()n zRA+w6D+c&w&HpVR{OhY#WTT55{=@>iN80Rag)BDN*~pX8GXe$^nX{9Br!D;i3>7uD zLlml-7089<<#8_R4Y4w+Zj9>|79(uxr-U)5TVbeS`SUA$dUP z44OJVh`ERx7{^52WE~*SpQQo_%H0h(`Q5Y)ys+93AHM2a*WFIVk}4!(zLmVKJ6RC_ zmBx-1YZzA$+EUsS0Hw(w9SVL-g4}=-HhV)9PkexW=*^`BNS};8eYA3D_f`X$=C4pE zw+&;E8m&Rvx@A>@I-L4)lCA*pmCBBfTI-)%>fc`=?bmROk49m7^Bce#?Qy&Bh z?-N8AeBjQde=~nZup0K`TIJD#qrQuhRqU+JH!wYmPBx@xTb9G;b?1Wj_FOSn$biSj za~0%J;>uZGe;s-D^IoilI_D|-#3bm@S9kO0N-gS6T_zS!cEpgtJ|4upYo-a{(FZ3S z_#l90TcJb<6JYIa=13;ASJfgYS+x0;Fz~D)V1XfrTak>xeqM`XV{#`vN7Of;36&xR=TUjZ+(V zSgyxHD%_fO9y42kP%TnKx==KA1Is?ACU`IDRQhsrK3h=uk-GZ!QT0ZTTEE^7+y=cF zBs$(_#s@6CH3^KB;+lMZ`;l)DoHSb&?>So9CkIp6CBNVLV{=Yl0v~C)b&|0ln+23m zz;cN|HBI>{7guB*`2B0YN?SZFFTRfi+AxJVvwu84NeqlMuXHsS3`4S|AH&|IcG!wo zxE4747S3fITiuNKftog`2NQM)xe8CJnQLb_X^ z*d@tbFq9^rDg=}TsocY0Wv>+xb^7wfM{QRx{o`AaPExHtI`)Z#a$3As3FY2AwW?Fn zWh5*U?*g5iJ0xYZJnThP#2$mJpH6qzJ3Kty5Zb~gm; zFG(l6as#|KEUe{sc>K>F{m1B=6Hu)~{|CD=FWhyElaOJTjR8eVJW0hPuO`QDawY`|A*Zdgh2~V`7hB~a ztNpTyreMZ_;Hb-tNuTZJeZlSCJNZOS!r7F>!NKT?Y!o#7_(x@oT?YL;#oTPEP>(J)JTExPZyep zcpqsWv?GvEQ5>PC4Z)k!*+Mp?2wr~ zPlx9SEI8>J#)3RkWrV=U18rPhEAAqGNm4%mHNEbLmia)~cxh2}6JR`0V)J(HhH`s+ zQpghfL$h!F^>z5!Hta;ISjV_;4j(oZMpH*gbwYqax02K9JB5hTru`&Rm!?gxXHoBO zu5-H;0A@Yp67T`84=u(kzUn4vRs^~3pL-NDv)d&cK_kxqw)QHq$ScOrCty&qt3#I6DsSB@Jmv>hH`;ybtd&>U2F!+Qfg2 z_iR@vnSTtGB4aY`hi)maK_q9;2;HIBv|tX<>AB+6EBTApx$ULVIkLW1h6ehCEu?LU zvU1z4rAT&$3lQv{9@NaLHx1asF|bKZ<7tdU*W^aPwpY?&6W!wIwvjYP0n~4fbJymX zoPTI^C_t{Z2uHMLtj3M24ycgYNz$}-(zExnLz|p`6=Ss4c>?Q;@7B%jL_wa5H$B8o zPq`3IRVGd!sNRM?kkBPHS^sB=^6$woN<72cougAY%%t>m~4q$2=H&h7t8O zR%AxcI`GE-bs8lqT0YdXiQMh!Za;tFgZwPb_4$IAotA7y=NQvJmFLSH*To%48%s~Y z^9p)o)PPqz%qg5ykC6%+vSj}<>R?_~*Z#^Ov+c4qG z^R#xM>z*_r&R913{F@og7^DQtQ&F`uPPGBsJ=j#eA78ljeg8=Q=cp6q^FR$jwS?J@ zPzh>r%={8z_qgu;ZxqxA$ES)m4r%pqG1>fdW43!S6(qpddddnOz@ZfC=X==XTI``( zDvcjbfO5caCfBw#Gz2 zDlyKxG^Q=J9EkL1divWmh}p?w!81C{b`PT&TGrUKfz_Pj(Z8QXE&Lwoq8q-e+GQII zY)i8&CWC9iM;1z7xOEE|TmNUY`Rko!)S5+7R)fF%onFv?PWZ=80pW zAZO?@vjUjTy-^tz&a8~%I^hMa9H|xHay0!j`;NnO;HIQrh_U5tcl6hhr63m8lgv7YwEXK2Mns z_qFVLF^3*jZw0CrZlIH*9H`A?BgZ~XTDUy2sWYKv9bAup78B?#iRoO@DWkReb8_HP zJ|g0YXf*fWH{SoXh_6n{b{b7I9(X|*-hkHO$`T-IUp7cLO^nDdVX~ckS(W}}h|p!d*-`#{VD8nk|Dmfz?%WWZ zHy=Jq6PCy1h+QucFY`KJ&px(F{NmN~uwA)Xp65xzl1hF*-nl?ofnt3mBi)rw7HWW9 z3oHkBuc_h)JV#9Hi<8^9g9(8SQr{2b23%buqyHP0WU=jbp-Mfqa?vhUOg3w!IXo zmG0U9wH=YAX-+STt!O`)OY?whM3akF@BtQp_qyP6nOD;_7|_ZXcbzv0At z(!M7w)0S4fRzqe(d1DRXep6}ezw_y&h>0%8*|BEU0aT{D<)Xbdi@%--d~BD)06QBz z=lgU@!suGpi>YfpQ9`oWqP8|0k8I`-@Uvb=Mm9P^nuV6M;Q^MXJ5{~)fayK!q3<2@ zrV@WH(*E`w3x#~%w^NipnLz`$Dc8#h_&a&_v9}C%PwmY{mOdkk)k`qWVs_J+L7h8s zo2`CyqS1OB;Zv6AGOz=@IiD#qt9vT-jBY8H!QW|5LDWWvv8QtCO-AVJ{4+-rjAk!f zSAOJeRF%;6T{axaDNF=<{>b!L)-S6uZd&gO5)+ny&uwb{<)ZoDVrG{Dg&f<*&^G3v z8LKV{OaQ7DQ)T?jE!{?nSa~0w&DEYipmdn~ylgmG@8v^;=kuYE(U(lSA6uoWbg{T< zvJ1SE(;3Iq+bd%%lk$*am*Rn00l`O#_fK_^Af+Xa3*x~MbDzx0+l)S}EMQy%a+T*- z2J!ya151)dK`t>o{UWh{5Vzs~(GIM{CPESdej&IQDHRO)7zH(3oImUwY)e zOP2z8w?Q#143t@b;GPkdsV%3Gy=~!!jh&rxz6nK&S4NryAR~w^J6MD5kmTw2-GNE;6yJb%?Hib4-}k#rHoq zrX-4V)km#rw{LGf`1#cPXj~`VKs!tHK=D~W7}epEBMRQEiNa?Y)snHh&>JN{^IBci zs9@uUlWO<85S2-2{kfX2_N{y}6D`0Bf+!s1*mLd_CGT%qRT>*@W>xDuW!v`Bp*u8$ zwq&EiJsPVkeWzaWmn{FZgu6{dk%uRp4A!LV3T7kAlrvE2;oFt%kCU+`-IM=q$$!89 zJqA_s@``r|ICbt4g=HWfO6^0if6B;M-+ZolJ8HXz-=+;2+Bi0gZz?rRqui9#`za8v zE87|?g?QoGhMr45CgYvI$!IBcu=iSyu}K1HlHog4;d+_tNO4%EX@E>YxURt7VvWqB z+>a6P=OFPK8Z&ftW?Sp2xffY==18BnxK}%WE`R^VP7qN==Rtet@iqSjPlJN{j)2E; z?_6}Crs?!*x?ZVZQGNr}p?y&8wZ^gS?|N6~6ns+&tcxFf_jYxA<1{6fEtpMNGlGh%?Q=(vl-^btbF9|=GVsKb)5vE_fY0kDTI2!lbiVT@?S^@*~IB88tsU%A?B|#fsNfK za0;k+Kg>7G&+cJt#yH%b=4nqhHC<{y^mZ?Fo8+`2ij))sgk+7V`5n+!lFB8grX!r`-e%xas%u2%H7^}F&yZ1wr34pRTa86xqZlC>GA0_oPlg_a( zdNuuj_)bLtKopFjvE*pG2tb5;NN5m z$Ls%elDET(xY{n02h3BJdo=0Pj_)u8h27oPsMeMTIZF>#&QmeEQbt6u)2VJ20a?o5 zU>uRZm*Z~aRd8XUn{uQ*_|hFw8rxP(WB+#_oUHhG9QK8&Mx;B$2H_cYdaC-fR^R~H zcaVbk-U<>!7v7pD-9{)@6(a;YR>K+|O$7I;2ligD@%iAVcod(wBMPO0rmq9TpKb5V zH%S3yu>TN=^eNfMNM!3QL0-br!JVi+8vQ)|RkD=_3YV~_H%EUt#hYkTsln88Rn{u^`ZhH;h>z_1U=8Cj>5^gv$QqA^Mm z_i#e!to@*hYL%eR;_!)>3PBDC7HD)dZ#%Jy)9Jkq6M#$DWDBp>6tzq-)AJl-m(J-K z%(qpg^8`j}gVvr%#cKBCKbBo29@$JUp3}2P_3f!ag?p-uBvNeeR>`)g^;SHW(hBH~ z-~1sMIbZGa;`#o0hi(Du{^M$aSDKGmj8fc5w4Mj1ocx+t`NDm-@s&i+K@Vh=&pHnh zU`2A=2Ea_a1S55EpIyIG&YEc0(?d5p9L&x9aRI99}=fEIdYQ z@X|oN2Ksi~Bd%l-D*jD7wGF)T!>(xmr!Y>E4DAC4uh1L6^|XCLXK>o(&DkaOxTqt` z@r1ym29?1tlK<)+{(TF4ZynBjjB$ds_6r@Ulk8VJLAR63K~?L2>GMUCYTl6Diz_dzxbuB- zMma=n&Tz-Ii)^m}_QFB7hT!()frd!uJ3Tb5&@GW!y@z@PMR+?(`AFYY31K<5>TFgI z4n@Y38Qn(>8Syt#L$$d(Y&b=MXaY9Lo;a!pJI= zg>yFDcz!1A$6;JKaJ*l&*A_im$9c?{i{x5hOo*nI5j0B4So1^zl}qzn2%>qkhya8K zaoA?lS*!)McE`j5<;WhSq!V{7ve`60t*3TivruxUX9pO z7Vi2ZxIYDRW&CI4K&dD~iwy0Yn`LW97S=SoR@5we738s%6C<={H-KMXbWzO-SS{+N&+@LC>qMX7nfeAfp3N=Tga$-gdmSsRdEV3O7qL{0y7b6CoA@0; zO>0oA9zMjI(q$^>`^7m@TJXpbelULW>7cjATnh-eKP;UUiR zCA_r{4Rzl8#rlB-Sz%c`GqR<(yvo4dC5%s35ZdY;<$3dnGG#pvPNH6Ki&EsnB*RZ-n=2hZ%3GMVeo^0{al?K zzYFq>=N&>JQR5)y9Y5457X601m#~k_IVDVZU2Ejq32aXyTu0Do)zC--(+ng;o^5hg(vh%PlSdGrh=ZWF9ZC4Ch`G` zkOHK;2v13YU&k$Byhe}z5LHXeQ4}9i-Q9MD?B9jCATy43F413G7EF1#P0)Gu@*%$a zskIpINZ_UiwU*HAi7Rv?d?1ZOlgiKQ)SpXE_=uP-uwOl!w<9P3&nto}YiH9c_tgv; z$px8UP=$?iV96o$L+5m40jAhexAZS zj5ntqNbwJRRAT$}B|%8yD*iT}rj)C#9r)@1DK44{st9zF7(m6v>ex9+Ec-0{vIHV= zsbC_wolM0dEZf-}8CvghgH)qzT2C@NVn^d_O;D+kvr^3{C$bFeu0O>+Fvcx$_d|zj zOeCogW%Hm)d49UT8>Joe+NHz+NepWI44IEjEXOGA7f`oR&5Tn`&5XM9Yp+gViTrs5 zsIvSbj9ZyW=LwN!{7BR@??%-NQohL;gJsK`?-sqTJ2AAApzpnxk{>)%8%7fNtL$BK z!hl*GGP=`HfOucdIuSwFJ%CEcbSCrOkBUt!7c@Q?YOLZ{Ti2T4oTV2XU#5rWbN!PY zyW)8I(*gcNVn&Ph1HP4;natqC`#5)vv!_Rj1&Zseo;T#UN12Sv>(zCPNW29%EVOA0d+q?cY z272r2?WK8zZK|=A5hqXRLwJAT?%Qd4=l5`Kq(GtX`jQ@R!;Q*36+_tNYbc=k@*iFR z_0O|Ng&T*rq}YLnEuP9&!sRzWm%DO$>mA3x=$!w=aQ}K?nkdpz{>7{=GtAQ&|05xw z`-J7ACBEU-MQl^_uNplNb~lGoPNxMs3j?2nvLB2FSY}G?+}!<9x27(GaV*oVd}kXR zw{_x~Xu|onsuJ@B*mJ|UW98FAzIJ6rfspH?@n$>vT0i?8bL7yKNq7}KVK>2c#jT9% zc1RnH&;<)aJAXt0d0pL{S_9npbj4BzHHUVv*XLp&>6^GHcDDNkEet4XNL!bd3b`GC zN&FTgAoayy9w)w997r2tPlnknxl~G(WB>+OKeVBvje#Y*8Ihe zOWPbS3%n-+_#G6eBn0H>g#sjLuc_YnQJ~{tP{mTb5fEJz{VXDZf&BvY^hq281;vW8 zh+Gugw{JKxQBkWgm+*bqyxFd|mq#XFlgnN))GD(`Vd3sAFBGg+T5CB`x042gwpJHc zobB7ywhGg@I^WC1Tn6vLu%*?u!A-)i@R}RRVKUS?_?j76YdT^YLRudkJp0gB7fnJp zBy(9IzUKti;Be-^AtDqQjccutaSscqsKhT_ao>E-kIBS%=T3KH_C-5*&;F-N&-%g3 zl@h&+A1R<1i9@jnlo(RIFT*6Wl`0Z|k4CBM8`5dhAuyJt1-l+1;)+t2nd29f8CIEGDW>5cT9TsG53R2|gD^yS;&5QF@w&BG9i&^eNGt@#&Q z?gD!Ajyfw@0Y<5B*&vBAr&5iZF+$cmCDoqTy>2Z$3Ph~Nm#^c9s=OWGc6z!FWp=L= zSGBCEebr9^j{B?uZ!=tTYx2Aw1V-RM*D_x!*v;1Osk*UfpKI&JG>!I z$qF17aDx?QJtp8wgUTA@8UQU&1s1r|wqbh6y-x@-oHpo=tgHxJ{N9n$y*jE$=w%>jAO< zw~&9W>f)i;1LQQQ_u_E9$-t?8?5UZrVaJJc@~xU8k#^cqhOTnuomSIpdXa*`M<)er zEM=GQcXfu(h)`0Q9x-+G6*fL%Ov<&0bXOWJH5^shv5J&T3VwstcxoXl$Tdm$aRGt@ z3H)}blRz{$4b_1FTXf3$pk-P_qeQuEAN8OTPGJ2VLyuR2a5d=iq|0iNgI$?9yuG1M z@wp4nGL2i^_FLn*KmrsqEa}jv0|Hf;_>Y&qamZxZ5#q=f{FfY;|^~tKO`~9$II-z7E9+js~`?7pm_Xrb4!hci!hUDG^*t5?J$R@OrCOdr=MOwqS$4F*Z_GHgJ1>U7lU z)kj?~zA(`L>lpp(OyPull`apM4a-inr!TyGZA!0I_U_n2hwy~@1UKpgf618YS7yOM zOC&c}*J(IEhT{Y|nOd&5s=U!|+Q^N8d_}i#%TxA_J^x^k98W5R`g1Vx=f*<eA|SUPQ`F|O;L{$ zb+Qhg3_uehJ3n&guxC?o-B3}F(-4t2{bQP%;dflc4SbX^KDBO>zaRKoW^K1 z{CbgZ#N&Is>UFu8c}p%xtY)Gb%FJ4YTFHEPnQo<2#|A7guJ&^zvX8KZwLCKy&U%|!%MN(Aj%^GJh+2Nh(P^9E z{5}BuFn2Fyj?}YlAE&><`Db%{#Esu+*M9i+$$&=>ZzccgUAKyG)sF>c)K!V90ib!i z+E0q=5B4KM-8!wa3dxLvO1d#T>U1{)ejJ1;+Xjvi@AeVc3Ht!z(J|wlQ$=arn~^ZR z?5}P!+4mxa9bcO6=+p?`Z{z<*K7sI}T%5c+WgTX3G%+~4Cm}^#T%Sl{rfX(ITAbTt z3NM}nO&$_x-07}{gEMpD5L(93ty%?~`^_ci9X!@(ZFvirSKD8w-}g!Ja6t~B7MYIa z-3l170aGlB(OTK@<4Ty++%>JIX|*qGkI-ISlH3NRIPmyE2tSN52{jZ|lkK>&!xCsR z?>f3Z4w)7BYszJ~O??YAFCg*-^29Xjnoo*i^h7v($o=12ZmZ#RD;N(dMcT!Y?rzJ*{bcvoIXp_JR zN&w&in8^jsUY|z&{6D(BDj?1+**ZZIf&@!&*93QW4el;YaB1A#f(3VX*T&rmZoz39 z3GPng@^j|ibLY;T`5$)~;H$sw$j*gt+;c9+$Ms0O@G*Lrv6WnIxD4?+~%e zxMg+nj(oH=kPRd%iI%%+7wur4b zZ=Ws)XB;{WvP;rvptiu0^58TmP8Q~v+~fTjQl;nAnQ9a?B$yx|vq}*oFV&*Kv`O1+ zR>!^f7PI9@_{K}ikPu<1vFBFxsk({AWs`vNgPAsFkNP;Q=$0>Lwi84D|d6zgUj|7F7>8BTJ-F}8NBq!*!B>(D$XT} zzsoKt7rGt}*`9pYS-y<_$m)Ik2e`^|*^N80<5?R)?;Q3|&T#m&wmP> zY>sQUFAlfxfIo1Tb(Q5G$SRcU(Ek&AAsS8v^BPR-Md7QCH}iB;?i zzJK?G9Zt5R7pEN3)Q61ZV@O=YR<#RFUCQ++8gE~)XRwuwGe3yq=4FI;Z6X~*8d^m= zG4UbG#)iXvkG=>8Cxo>QTr6IQ8Yg_pisN?kC3O<~Zr$~ulDMM8Vfm#eT=-dh;uF6f z!e&UQwZ6Ob@!l7mGeM6JVa}DmwBu26jc@Nq^ufGDJeS&M(M!~Dfn;Os=NhJ?n^Kdw z%%2h^)o&!shV&`7Iv9u#R*HNIJ&gB(3zJu;s$HtQB7~&7+#w0}3${Zz44E=N47@9h zt9GA6gD-7_eL@ld7!@wVt^L4%} zpH18Z?TmlR^}){t7(2V1=01ItqHXxxkrffYI|^>vPGNUkoxrvJnwxFj&&O$2HKT{& z@l<3x<1`o%OYnY5YBel@v0Ld=3Vtn{pNi%FZ)Ik)0hr377n!1X+juVKL7c)Yw~K{1 zO~vrHave7u=tjP?mur~TU;?qi2^)syKIe>4{M)2x$L=Br<(Ba!DzQX9nr)@T^lXDRV%Jera(7?djjYgVB1CtmSwvc@*U5#=T)iZ+U`jlprT-sInkZ-QDKd2V%khU&^ zaB7}`YXao`Ot(HmNsbN3X|DX$xJ-Pq!m!I`i?E*-x$a);!X`1VcrbNAb z2CSw}V`lL%GFosN(;o7yk6`-$xy9ka3*9$#hUEq75p6o0*YQ*@7;F9drOXNl1>cHP z4L(pNVklIvrM?3r{v|rQudBdESTRYuZ*R3l%Otwt1)>%{UDOWxO4i_9Y*!8?E3+t0*S}BHQ;` z{9xSM*-1S_e}+GNsLB8MilA>1{P@~5dSv2-h|z2J0^?F|Qg>G7K#1Q9q<$B*=bkc( zZHbS!m3m(c__*)hj}5KGUb?*wmQWGN+c}s!br-b6q9=EKw5R8uQg4>+Ct`ef3rQd z(S-(l&hgnei!jXbJGLWz89!h?;6Anr2?eTq2^RdINEQGFGJVtE!be8H9(<UEc&YnY!K;NBoC0wviLYty-_s9E-|_ zAgkVxXwYWxx#1K2tkPIlP6ORZ>e!^kt*qp`0Hv;J{`>~rAKT_xre@pG;P$SWutD}~ zwy=3OtP^MUC;Q^SbuN9RQ>L8S^?+EP?{;-3mZDgS_4vA?(jv#f=7T&tIJuhzg1cii zKwFFG)`fbe!!VaTZZE+%mts9kj);*y(WwxvkrO-f`q$>@80%OwZVlkv@|YY%I`3;& zm$Bg}-mBHYBY0_tR_9&p&3`^HkV9oP!?#S^4ed1t4XSt6C zLhZtwf|}VdxXyJX#nX6fG|0;%=+~u!2umv>LVuFin@;J_!G%z3ucWi!drs0I-!|`t zUiZ=kD1>3gyc4wlsMsN5D3Y}Q5q$Za-PTl29AU>%>gb0dg60GO2!0yhv=}$akDh3K z9CZsZxcrtw`*2)6mSi{*96eVdFAdefn@?W@TWjg$BO-=W$KFaHbl~B&)^2=T3pym z^L%1zT|-E)@|~#rDZjWE876M(xtx16LBh?B?>8?&+Ql`BQ^x-cad>ah#%dmb9z>yIJa?rCv|kQ#k$hWWnFf%OTV&NWi;7Kp7Zcxh_Z+i} zRBe5HCnqxzcwZyHMGk~Nr~CHO3~yEbaQOlOpwkwyl+MW|>;pw#O?0_kMfu!G>zL(` zV{@@=ZvURM;GvRt{T#HE#P7~;;|G^BlC6%WsyRD?XIET}IE*}#nmCV+_=lvI2Z~mo zf}_r>uzoK3rRDu2CaFkADKw-%TJPfM;Tu>S9k+hpuxX}aA_=qL!l(7( z)19f*FU!%H+xA}BRX(RhNjMJlw_aF3Vv3^Cl=nj3YI!dfVQ&$9;{m*#^3apY!|Ifv zoMVzSPMc7{b!Mo`fMuz+WxTb!=?|<+)Q}*l9yN^$t^xou@`q}iFVc)JCX9MKPw)M1 zN@-nU&dVKn7&Y1x(Ely{3+>+f>m)Ct1LDumV%EfiDe<21UdK#EzgCv52D7ddtji(i zbM$hDDV3q!-F8Laht7)P(GT+tc{c2(_?e2i%d(}G?wC`a)5o#EZLQ%HvN3W^}g7O9VF+;XX8kdt#lC%CgQQ&Nz!FGtT(T zl%q_eoSF$Qmp>KhXdko&TeVi80&taAW2Vw6-8pS1xRSBK?Z*Ln5>V#wbnLH^R4`%& zba2(sL!<2@Rpa|R#73Y`DbVbpW|a_;f*9$4v!?$@8VoQH{wyN@HyWfc!VOU72(c9m zzjO3yU1(2xDoOXYQLrgJ9-QJp=~0($@zZrekJv=z4g52R>af=n-E+0Oj_0||E|8&V z)2|B*+b9@#=OA!(aUb_>ENwErbh9MnBU_$lL`et}DC(PZ7+Y?*)nm4WJ^@gZZi%z% z+0!J7&xRl0oo;w!kkI1txcCOlv$X6HXE2l6N0EN3yZZRKTXuEog!}cVmr49CRf*NX zCoJjz;k677$)wj$iq6K-wgFYEJoIS)4>?JKhCZNuR#ny-W-Ir3e?>`Fsl(oK-h9rJ zDRcx82hk8KRUUiana=tXV3peo1D&dBVJ!*hXW3j?nRX=L+*4;FJ<;!>qw`=SmZqbp zd~J}A0^}0hV`Tdc7~cLxk^*MI{Yz**X_$9I=xVO~_%aAq`rX0*K_NvH}$ zqf}DurnGo2N(rRRrFEQuI$7m~KorOMlKA9I>T9d~vc$_Y@P&;MMl1&zCi!Mz>q*1s zaPRXS{JTchN5Z19`jY=eL-`}a08SXo0XE(m-UaSa9`?s#LDw2v;=P=(pW**;zRIr- z)oV}8rqCPmaS7^bY3{|KbIiGQ;Po58)lAf&IE5P^$H0?#g*GvPMrwG;%S?V_6%@gm zCNJ>L)G7b&M(b|Cxe7KrzGiDi6cUoAaFQL|C{)3tpZ*T2v)M-}ewKD(V7d&qlDKKT z_&R~mJV8b3L1qUxy};9&^R6Szr9z`ZuOjx=X-@arL!wc@X~tVC%$k+anvM{y4eCMf z!H$ojW;;tBfGuYGBe143uX%es^6vVbOxB;OYYGM)WVf8V$T(+%1gcnlJ zWy$gTp5`Z6Zw$X8sZc4mvHRcW#U8SXh{80g-&!Qd5y~@$$=fq*tlF8Z9Y^fYPS)@k zs#Bx=3FUHT-mtGVPdKCf0kx0R$Bs*|@uZnn`0Ky3ln=np6@R+@em^k)l!1|K8fUTY zBKD2mr;wHLtqQgXu}D?gYaL~YET8`-W8`_&vbEIK$pbYg$+YUS3 z2p=!9WEhd?i)-PfaJeKH#V3wc`_)MWlBmmdIayRBBz4oNOwtC}j;hIzC(5o_Bvnz2 zeJG@(0KI6ASwx*)plG*~o>vUB7xmU@Q@GcqD2l)}^1+a#_CDh-UZ`yU8{?S*3n!bW z5dxcs9rObv@k8S;VoAsyu6^=ArgfSkHCl{z--hadXJ^cSVaG^WXxIQ}Aw1sdNY;TFq!{zOM( zF0t0rph^r>?YKQI%i!slKkUX9NI;aMV}dd)D`Yj5M5Z<``YS4gf@M#9Qpf3myZl{? z&*CN?!8W>AUQRlI8HcfM$;%e{dZ(oYc=t;V;jt6lp9uOfZ84GB1}!8ye4Lc9&7`Bk{cKa#dZCk$r+{k2T}!5Qqwhlw zpU8piGm3s$*?#d~jzrKG-KiCIRr)YBC2(bTU$zY{pSSaD@?6oj0M(!jO&MB4e3w)j zO%AA`ZUqH|Ao%*a{@IbL)QV>>>9Sh*%dC~QStZF6l{S}?&_;8jkUtG(9{^Boda{^k zKFjQ-Hdj}tDo`b7SR)XTe)l3kI&#e@{*1c{wE_dUpc_wL?cKk&S(lUXAJVFS9Mng+ zi%|W_s$u<%O_^Se{9f9>9zugg1oK+gYccI}zFlUjT~^=msZR=Db^FHO!9yUV`;Euy z@onFutPh#W(dYpL>5cE$O4Iw!w{q7H9EU}!!<-GQJ%kJz$2P~=0;Qu;Po%#{M^F0W z``S+DdsAVw6~SIv9S~JocveG-eD-wfBacUI`m*n`R{*mU#MD{eA6buwlfcLIjdL`G zKrj{?`9*`i28sU7F@)dVVSz|LzJtU)I*u7XZku->#sNFEoy2puRFxvSi3;4_2^l_4 z+ugTe#$tr=+=3vYmK`bI=q~0)>6es_K3E-eaefrn0HO2#2O}}qK`_t5rm)dpzw2<{ ztB(GMVMquGI$ZN_$yp^^S%{2FHYZ+kv^GW=8KdG+92+PbfPK~lyPHZk9CG_2GI8;k z*wVuE-Np%YbWlwCex5LcT2^x2r{Mv=UE%_viEPQZm&+edo!W@=0rjkZ-oDepfrNbW-AEgwut zAhEKmuVvRW;+OaPM?1T{HG8`HrWdV>`M@ePH(lsAX!f{gY4Xs#elZ=}R?L2Pa1+KgXiCuWm$Da@BdQg_hm-|yNDzvEd?A`fy)SOhFY`meJP7R`;|nxet6T?~F< zP14bLP>{@bo>8`f2{g?sx3cN5FVs4FOwqi)C)>&XHfl; zjt`fm!>gaE!7Kmdv#Sj)UCk_Qme(>bYhs^JeEH3%^JIAVQ_eHKFu*YIKe!ti9NUym z!jx4Xd@nZq0;h|us_ovgd3m+mU&pyPkFfWC{A92r-k%Cj6m`pPpM0O740mb=-91(D z0{;5(@=cR7{p8Xoji$1Bzg(R@2dyfL)=AAB7t{K|&$sgvM+^PNc3E^DMNM3tZ0LtV zYizhql4K#eI&E#s%GO4J%Z71%%w4gpS>WVJBVcW1Gb#9%!6oC?_jzY z-0UpHRq>N()pjej(jpn-dDS8goSb2>(WAf z79NVcPaE!#I%nHf{3&Z4pCD-T?suJYq>aYss;kkt(}cT$g%S5Bckiq*rel|B4eXxs zg{gu;nrqHCrawG2k32u2TuP9BLHkxzu}EEiNf+nSmDELzy9Rn~i{%vOL%K=<{nBYP zJ=piqMpFLdEvIes)V%gs3ay<=)t2I)n#>NVOm?$d$jMG!57%jeA}Dd!$I5SQks&{%qX< zAQB5oYULBTCuU*vy2EQT_Xadpw&@Z9y(@jcRgS`fDwVS|`K2%Dv1T7UE}z=}5CvR5 z(X=U7y97aRV>6IlXAv;S71qsYD5{1G$Dj7c1s4fiWwMMx^uA z436+bN12kUtTk&nR_D;mXDufz7o{`Gd*M^EaGcAA1q`WG3?XIbMuZGn-w1s=Um>hS+t7*dLuwvu zw^;gn5TMmI6wmqZJ1B`1ykjiXcMMZrsnY(_MXZbdw>e9b{!Ws_sIAfYil1EjI(ov1 z!%=1z03s~($@M5`mF=7(RyLKo*iia?a%->h%{+Dj_6P8>?%v{{Bq*&_b>~Wy6qi^Z zT&_<6F(Xb0(cT@k(Rl#_K3q3x{VYc0e!_P6g*noHNP8enTDIH3CvxW9a%6m6sDD9u zQ^rTaCr`ASS^qoc_$_uGW8ciphlBe6_v$$p?v?$HGR8kr;)Qx)HpiS6N>+sDb^^ElRQc*u@((-X3DOc!z<8d`K zG|@T{o71u^>^Sj#SDC*3&Z@q&ImKCxOOVMipkF~k|PiU@oND_nu{mo z22rdV<_qf&j~n`pbU*3;&hh<8;COB&a-P*mUY-zoBc%Vns-jRoxmz17uxFuhi$IZc z|0gpZ`~|aN`)@DH?d2ZH#`s-Tj-oVHO<#R^1?yZY?qjY5ot{{n$1~zhhy0#2Pd~a7 z6g9&2G__7?C;)vK9rMpM&t4@Eb23)crGspzMMP8gQLS<=>_#6%|F~KXmZIL+Xe?ZR z^o?5(6BAq=xc@$d@Fo!*4Yx-unrmc^;%xxJ+87CsK?h<^jtTB|D#A|MpUk(oTkmmi z#Mh|3*){x3_M0O=PW}D@LY4jt>-ydXCu;J}QmHaVG5M?oLy!Y6#wJgP*Wzypnu3|H zGKm%zj*ch;>(yUl$q%sP;Py*xG{^*Dc-e{#5}5aa@z?rgYe@2=z(QQ6CKkmEHiR}X z#Ts$)bLKIvv)5b90^14Rl@H8U_EdIqk4s(faa+zJ66<^ow<&saGpI|#oe;gN?(r*& zH`|<={pK8% z*(ur3uq@Y&(nutcL^sinlG^aOXDltWl7X3QUS3jua0HJzNtFyz+%8b;T zR%9Zepc1J+9C>YkWCpvy_Hpg^vW_s3ElSsdCg0+t^66@6wNV?%8S%yYu}Sr7aopBU z?#=XHy9?3Dj{fRfl`Q@W^lL&1|E|dh?M@PUvwO3Vp{uLai{72zmi=jL6=3IctWM(A zXV&4&cKXTiDRU)#PL#&8QU13h+P;8$!UzUAnxIouX<+3u+;~>_%JteM?j%7)A5KN6 z&lPB8s|``ICwg+rqY=3WA099G%=q_|QL0r9K~Fh<^Tu#hY`sgP;B{Vu*ORtPywaVA zHn09OY3qGt+#%roeoFI+babK4yLoL2fncjoESJZdXM$-%IAeBylJF1%%K(#WuqsB4 zTE*NQHoU_Um7~{v4j)hX%+3$O$Ogo@vzw?=2QO7O=+-*5ll9D#O{go)n_kr4dgm#_A5D(?o*Ap8$Ggrr> zQ64zY8FVw;g!Qa@U!6It!?R%H(T@DLLh@fCS=6`2XPq^UMhU~|VpHGBDz?e&DhT|4 zu*OSrzx%qt%u5~Iyk@&AEvQ9d#+L?ZCzSI;=TiK$J=(Dg8(| zkYk9D1{O^t$^GE~?g8yMad@X(grNCZtOID|qjT4Y{9S%ge$}$32JzkshwC{j$^Xp~ zUSG--2AH(P{x2oA4FRVcU7bE<9xcG=h~t<^O>>D|kbr9K>%Ncoe95DnPcJo|>Gzfs z%UIh=lbBYL!)rrrvmIDTv(6&VPp&U99sps9s>r!W!w^Od!EcXVaz~P1T|soEgQ`)A zH%VP!1uQZQU(jY}yF%x$V+h+RPU^1X*0Sm%0VQfxCnHDl(Cukl#a!e08=1kt-M3w8 z{2V;1A5dF5sd2m|8d;y4GrFvjU)2_2L_bJj=hdQgOG*LdM4$PMn%fLQ%#hdOB7XX! z1J+`Yku=<6fghgHxi_ISVVKu~D&TS=?Ul-tuaZ)F^tF>CE(T z>hNp8vApn&ZL8o#i(2uBhmXb6FLUqo#-%s6{K6ak9fFc)UBUw$swYpp9-L;Kdmw9( zd890SLh3`y>{~$*{cj(eK?Zq96Ku~t8QEVejWV~{-E|`*hc8venn%RgmGU@!I`vI& zE2`eHn~NxZRF;i2!mu|TYSo|5C#*(K_Q{ zchVFE;X*!?^~HvJwDZ`t`ntaiE2?tB(#5zi9HRUpivqdZh$mIQ`L7F3#1)mJt>f z=C<&GxsrC4Q9U~1`CAV$?LycQ{)Nno;4rZou|-yX*6&sp=y*`=(39@rMJ?6rf2lqF zt$%jsu062Nxu5aRO$QOHj8A%7r{{HB{EfRfC~q?;J*r~3}KONOtLo+Cz|!{((kb=3wX7g0tNpi%;DH)&~M26rGLsKkr*sS4%VA zM_3P?Z@XgOdH+N!!{(Ss==2dgFhn_y1HTlE?QXs*Ef$<@L$_W#Z&wdcu9#lSUX)S zkdO0x`EnQ6ot$=~I%x^0WWdS;)8!s+6WrDI9R5g=0Dr~d)9-{kXT|pnsS<#+sdR|% z^leCX7rkL~UvEkQM)N;!wNH9bMGf`c5-}qy!6~0`rz-0$Xz z;Xqj_xT@b*GD`M^M!i$4^!8eumK~1SF_Y%Lv#T7O2sC{Pe$M)3jZlfZL4A(1fRSF1 zgdY<;hu!27AK*nI3y4!h&JMF<@|!DODZWC)TcSEfoUSkE9#3c;{Y8>msKzboGBLT) z;14s+CD^Q9Zu22-A&S^Krt?-Apwamlymn7>0gww_DSFfoLSB=;dYhpcw`Q;F%(-z28>cV_Z~q}pp*ey;Vw-f}^odpGdK z=ZBC4;os}~hASE5KPQG#E#?ubA5TbSJi*geLe?T`$g=bTMa3bgr! zdIb>x9+L$<)R8J@tqEAD*(!f{eKa*5 zdgtajACB&6t*FZtkltEhwritmjlJj?`shEo1$kN}@A>G!f#!va=)cXap*;@JyAgP$ z8Se#9_)0#}K@mv{g()E0eBcv2z#F%Y45?!eFRw_nQ`^sEPioM3l_GH~1Q$l&3yBG9 z*g$zuh2%h!)LEL3|Jvt(&C{WFr>|}L^@N}q@i&Lo7@&O#+!Ox3%jQ+ZB;G{|mpeE{ zS#s~Yw=iElEO2=OrMHCHpzK5DmeQhRvJ!7fs{ktaOtZ{?KG5JvK9of^5LkCCjkhN4 z_Zv3V`LySzilU5pwf|BEQjdE>8ypr2WTv~VeXurxE!lD&w*0n(k)x-{+g5&2emwVC!y zMZrUeAdA5`TTAFj?~53?HFn?pIzNh4YZPmd1pi8|*876o#L5tuiLCq){L7azwZj&@ z<*w;=)0kJkKGyk&Hl%>~=E7g(;$Wru@}z?@EcE*;1yTHtF03UK?TGK0XE2Efb1q#5pTLkk-g0Rusvt}Est+s4S9 zIqt{z8M{R!Siho^(UZ9gl?>%nZi_gLVuotK*wG1zpClD0sODY3&Bh*A1&pQp0=lHm zH!0eA@ygQd(?45Hgt5Sf4@J%aRr@v5^nGu6#+dnk?z?yUSr7+HxhKGC6Ql_^u;=~ZNXyu6{9Ht4*Nb7JtAE6W%hhW2_tVhREt*73rC^_1w=T1G<5iUV9w%MgRaTGUbzvtB`y#(ewFZr?HN)LCH9PyvAY0dB<|^6LAOjQh;;F z_O)4T2 zPl5eT#TH`1efn?)$LsvYV8C`%<2VnSR|p` zt*+?PCz`pGwMU32cY%4Q0Uw&6zu$EZ2{7m!J$yD?!%X@`aok0Q;}_chdLM_2 zeA$avuJZ=;d=lT*^8#2sA?PysTkR|0DpEp-p0Tb0ajm|9wMj^_7NZBBepvS+LJDzb za9zruIpnx0=F&2=<)J6P(i*h@1Oglj@j9D<(QPPVbI-lRdaA#9qhCQv6I zw}a;D>G$tXG(}w!6y9g(8==(6tnDQnAK{g*t3;j~mZ;L`IFy_6Tm4dq)5!0kMNh$m zvum6p-;TxtmKMBxNsa%F^X{*T>i?k|kkP)7xk(*Gb*WyL+8>iTak{F4x%Z@$?BUUF@~C^q;xtb^a2`*jy-7NwIod=93{{>NgoX)yfzb+B z7`zn{pgM$coZ%0shr~!R_lr)eg&4TpAJpKx4i!btvsPV-T$%RP!@GEnL5zfAdWju8 zzo@usx_)gj7KN?DplzMiW3f(Mz=l8WH8x0S7?-}{x@Om%X(Y%!PJ>-`3$Xj?EpTLK zKu~4{k#FW7W)h0AF^T(BW7~iELsAPP{U-h3%ha?fgv`&j=r!>uYZZFFZL2VlDYQny zpqBr)n#iItoEMy}*pZ8EL?hck$}_~#@U%=tvEJ6!R04>%&i6HZ(wUm? zA_lIiaeQ+TD3VJB%-EKyYysC|0xBb!yT)m{w=Is#XFH5_HZ^7y9D;KPVZ%w2c}`i zV&6)wa<}3l_eZ{&$a8P=vVxX(m_XIJ!~c@%{9EyN2lXv>4-yM@0fWA64C8U!a^{$~ zXb~;q#CE3hTj6{Dz}t51Mmdg2c|uMif~l*=>xNo22N`~C7UyupMr)s+Xt}ozw=Id8 z;s@v!;YZ7xFbEf{LG8gW<8)ktH{j#)IY;C0BS0BZ>W6K=KS7llljmp~xPkr#%j`es zU$-i_Oq(Fhk4E)?yG^Y1?oAfLN8g)>b%*)I6WAeU?$j^|RDM<`1rbPM?oTtrabL=* zu}a=sa^ahz;@4`adgn&5H@>r?nOKQU#2=|VPNF(wtXkipOhf0kU7IX`{I1_@jNI@^ z!P{w^0xt2q;T5a5PNmI5BU}tRXcU~|4aRVLskppv(+?f^t7i!1CM%Kf;Msxbl$xT?t!@u~gd|w{ac~*p;^}^L;#Yg6($Gu^b)q4|Rq$1gVAotisf@F9d zMVOSTbtC&je-Xq6wY6#Qpz&j}<;yx&Fja&zZuJ)uS5(@7*Y&CZc7s>R%ciaz0qkSJ z+@`wr4z)Ta|E%52yK9FqODg<#!pnp>YI(XNY~7xn(kxZDJ|k30Qemtpo=+ZxrgX&= z2xIf~>>gU#d5pFPwXe%{(;91+2Yq~JYzC~;QGBi(F z!`tQ=ky%9q;2)8x=dY-h)}-EH=%OQ25%|q(*{6hxrxwfN$8{{`YqK3vnRGv% zZMV@D7AcQyu!IR}gS{YPo1^&D(p22@spXG9(6%%cTzWR=HAe1wy0myDR&+w006p3~ z!$Yc!uNej)=EeO``S4u&b@b|)_w%C5@6iRqY^UEwThT_6N!HOoTf8X2OrU{325W>+ z{BX(lKlbm42VJ&s4HTE@jO-p(arOH9C`L*sD-B02%okN~qt=neteEO$HF9hri^ui- zvf8EycYyk{x9f(CMNi9u854nS9uV&Y_`!Y1^SIR(-U%l4c6=hEg&5W@k6V0Sdpz!JG z&wAGCeW!<}bM(E}CDDO>Eq(PwFP!kTBI$(yjqx*M_#+=Tr>h)G*!ac#gxtZylQUGj z##_}ndx@8X^!)dS)M5}QyG#Jwp3YNV%f;5GiOu+&a{u|<0%LSh;;wf<>!2%07rwM~ zH2i?NL}6H0_&IG`H}!OlZ31oMIz)*B1l}$86{~*8G4Aq_czh~D+fIXni$#9HmZ~68 z1~3?J@g-iG;9JGa`l<-3kjWu7S_yz08$2wIDWFBK#akdL$XRe6z7yH+;l2%PA5b4G zg!hsau}`xW8=<>M-gfIdwCm^N6>-yZ$1?Hy31!8NRSv0TzVXfvyS1i%mvxk|xW+XM ziva}J0PEKew+Iwsc)ji}TZHqMf`h54mhl*V#i89~_X*BdYX~PF@+hJs9ePHulxB#S4yy=IKKvs65(s5|IQ zlVOz7+t|C)@Ea%LgjiCDJBB!sUMY2bXR$t+F&+z*0+4MsQ{?(pnK1QK0nB|Id)pJv zsw0Xx0ruXy-#&ofIK>q?>1c8A5bGp^y^^dYyfAxsKRfe_8bQ3x;Bc*Yq@{Ui!s}Ib z>m2pRU-SBdFft_Ecyu$Qks8}H&vZB?wHm#0^ zIzYL^f?(ni#Hx%QYD)UqRmPa}=PUCQSDdR)?fpXW!^pg+?su|tl?ot+n;(F1jzm*j zDjI+}WC0ZQiv4p#b$0<16`fXGOXfb?_4U{OU=RVk(g#_my9l%m)I$K)_pqaTJ(gtW z;ocso;15-A?;8oIl+~fd8XnBM7nMrT9RbbG5pU z=seZ$%gQb!iY(t`>yp^F&+lt@c;c*Q5`aflx`Na<+vmr$pBv}r3y(aeTtMakYTUiz z5*h5%%6W=T(ON_OKcrO+C?E)?zkMloafeLGz2+3+*Y!otoOX=}V$6<-W36>Gni_3_ zHDl2z?|09$mq@CvWr^oXTx!P^ib^85dmLNoTypLWTf8dP?ohtQr3OcgU?u?LN;*#< z=SpE8D_{DY72IWV43hnW)ZRzx_hjrf~WVrR#9N+`GzR@!hE1P9K zot+a-!~PfY18L!s7~QHYpB@8et28=P=zaFWA(kf@bpQP@_`ize2Mk45T!wk`e@6@4 zSX_K!k2qWY=_t-o#y5dDVM?E7BuBKL`Bmr58jZf*Ytub{=z2UQ`K5gB^NC$dZzDa6 z`(#XwP`CdPJ%7%jBOJatz8v7t=eCWXDz&uOe|@2-?<51}Fh*7npE= zV8{SlR)QV0In0MmdNO|G8mrDlXE)~w+;7>Im%nziM7!;_e6=lFB`M)<`DXlXVuEYD zg*QK{GNV!6yNiiel}ySq%$rQ63l@{R$gDBqCM$Hj^R@Ar5%;xMCMb{@i{`WQtkT15 z6rs$#w)EFYlYB-1PTOV_Ec$!4JkPg{siU(JvXttNTYr6-=o4adVf%#E7;gzd?Xj$TJXPn5)DblH}-s`fTn zj}gaqwgp^M+kt{MRLzSkjQ8Wbb*59OFy%QIsGC-c`%@fD;euhLA~^B8U#P#eniaOX zH{C=fBo9O`!LbkS&JMF-qEIK%vxhNsZL02&skL=fSzOKRovj!)#kC@P59Xcbn(&M3 ziPy#z!YnV?Auw+gL5cY&-Sl2#MYeN`Ii>EzP9j{s9Ypmc|C?wfkb3&6jg8PS58hp$ zc9LPm)veLvqHw~c?eSDt=QB!NyAE`M+2(Iz{$G*upV?yyKa2>Sh0QR#n|`*;8vUgk z^Dq0Pn&13^mtsa6lC%XDBzsh!;^uqUD|aI(B`JRg&Qz*|)&PsOci8NZpvWs7`7&}{ z+|^qj&&ar!-k4qZ6nEt@RMM4~arMi{>npb1BOSyTL#q%mfo9jerQDt6C|bHX7UJgY za&mfW4h!b;*SeSq6s4>*qG6S3sZ7X4!dIs6(jvOsi-R0xg4xC~_UA$0^)p>GoZCW9 zPx!C(dZTFQ{(t|>{|XrV{JUdtSd2@k!1end_a1=LY4O0}(5Y^GM6K}QTPe9{tX=4m zLmj<9y~7`88e^p`RUZ-@UFmmf%KUakS`A8r?)e!q41<|vt|fJI{Z9{NX4V;K+#y|Q zOdOsE(uDL7?$cP=wP52rL!Div!4V1ixG)~aC?;IedH>Ty+_&-5AXzgJ*FJ!aRQFd^ z=wib^|R^B7L3el`hA3mm6#@qiC zfd2QZm%;_(VFd*CypHtwr~&u*DYSTy{A0hF>}7`QW_O*g;tXy?Rde3+&`h`Kx9tQ^ zQ1Xn6xZ!Z{$M>IG5=h;kKst~k7;Z}Ev&hX|BIc|u(s1|!FP6zuX^N^^*MUaU>7VAmdVt@cS){in?$ z%B5(XGS7Ic5`Dw(^B9Lez3Os)!nXz?=GnTomd#m??gChn>Dhu}MJR`PF@XQO9pm3} zmk>@EjUn(LseuC& zi6{+UymWNFttPcEiQEQfLaZdPTZj%NS6A@*?VsYHDCh3dUNu1)VZ-u~QeC*XczwD?7UXVO+#N zU5^9?^3nfCHLm*sH$%JL^g3k8eTxfOl;0*8g1aZCRXx3b5K<}gaYi*;!GcMmCEO(m zugBqSrkg2`4o6EF_bYcIMlmf63GTBkU`q;#!w&Lm)wtpuydf z5ZtA4cPCgA+}%Bd;2PWsE=|+8OCUJGoyOf8cX@rzJ?Gv1-pSo}@M{egjP=>9S@l)T zs$PmJrTaKkts#GG)ylEjY~X8asI9N6ucFX@aHz>W*@W3j;nRG2nv67bQ!3mG5u$kTCh4#NwJ{dv;d8@)hF+Z{&=BD$wJ6|87~y6F|qrXQYNVGXeOOq#k@x zHnxN9xjwb;s!?K-DGyxv@^Pt_b1u+?*mf#1T|UnJyg*Uz&(QNT;OFiug^R%{>Z$bR zle$3k#K;}>N>5?aB0epk5Ilbwa-lU@e+!c3(i_}S75*$VLm1W(`?eKV9~m?A1@5h@O`hKWQ@TiH`NU4fAglem&3^GlRso zcASW+Ab-Z#vaucs`<}!z74a>MxP6NN(PF}bpynPi>Ckm||KvX2@hZ}4`*JJJXlYX_ zV9KbPGTYE^K`+m?@O9VG-d>tjJ%2i3PhliHmq6|F_K!cp7rGMC5d+QP9kDrx8sAEU z1|lG;iqD~_)=rgwJaZSaQCsSlTO|j8xV7l0`Ce>HYwR@>TZhnp%E0{>W%=;@J(}^X zgUyt{fn3NwHbxj$Md|mi)3Wi5I%${h7^@MPMitZNhjL!oT9rsW9LLjo)jIonzTSw$dnN!`~f&zCoMJ3(W^Mb0`VjdB> zZ%aR!+#Z55#t_UGIJ5cWN8CL5Fv&E=3mk?mlE1jVoL8_y-M*AGfl0PRRWn;CeKr#) zcf`?dFf+pWbkxu^n&Z2=xe*I@P(tp6mKgu#6&0VvbDjW-MW$m>e z~`{`b1nLYR!ncWmCimI~$%vDB`H1}1jIhV9 z+o8mb&`w2`-3{VAz36zi4TGV+qH=KxWl*0X^8B1ucZoA-*;>QwBV%f5sZxiT{G#>+ z+Sa&T?Nk6FL+MOB_c+%_I7Y21Y$yB?qvVMK{#mk!r+WF~ki6K`vy4FWwC#u6*ZKEI zFyncx;y~hEWOg2-qZqy!snhn->>R(AD+Ssj8wEa_+yCNV0lx?WUXSdh@5f#dYGNyd zyjy(FA@)-^44%?pG>2UjsDqBn?trB)K2#7USfs}{PS`3q6wunvPZz-Mh>U_saG%@o zHPU-c5L{$R3o%ZU%CjW*%xUTzcPkfEo5t<67X3ZV@M^C!$RStj?%7zx>BmOkr#T2y z$wv^RuM*!Dv+dypt&hFa2v&kQ73Q7sFl*O}X}IzIrv&lbtjh{)#A? zjOOYt`@gwBuMo&tH{zgG4S-m56ripYGIAqCX@8~kV$C|kl>mzSNv>Y$7d?0Hlrgj_ zM<83qDV30o;rTV%vH7Mr@#BC7cx;f6zeegLWOv?;3&~<#Z}DiQ*@nVvnU%tu)Nmu5 zDpxBLyrj>o+p63qUteE7sVH*+>StE_R4Qo%IRnmC(gw}}ETC*)@ZC$T*L)tfeVjMU zmzdibRt8D79KlJJPrIO_sTmjhrp*!isEd_f`u7042l2hC8dEc989i`K2bc}kt<(R` zOn9(RnNRdan?v`9OS8VKGRjhI?Zt-NYh8*XnGoqX_wmqi@;`B$0NZar)xH%!TWj}dBbUI=e4maIW@Y~C%(6@TES#m| zx&B#ekCd$^^~LXDyz08N;0HfW=ghtmUya1PQqpH}|25pZQA z{i4~o^DhX_?x3Fm4@6E)to7Cw`Mws-HIvKZw_BW`sHGM*FaPfa0IOoZEFdr&kY$%# zi{*Rs^5>e7+975fz6q=)83=*=Lrq~y1GB|+n;%xqfKOg;%>=)H`{SX+L_iFa`waY=ZyR zveQLC;5J-M(m8W{$19$svhaG!nJ9+x^=-1kjE0?`I$`P|JT_(?OAJI>!eicdsJE+)_{s|$ae+B~grPB`rvP@yI3k79dn`r|RjgH^URI~IO9pgB}e>~{{3Y% zDes>v@S0xj1T{4o3NPWkiDpc(tpPW0UJ~s0ur?l6 zsm8}D=oac07PR+HekeD#-qII6K3b?0bF(3P6Sk&8R}=szbW@yDcJ6fBE)m{U?w^;} zCJe9t@+E)qwSKlxJ5KMx%_B2mU{*CjtGG$6tc<_wENy_U3zHKp|6Z zptK0i=b&PP05NOVJ0c69EF&4B%X_P%wf&R+Ra$7s^57K@MR;4apbFf4 zUVjnd{CO#|Vr?bSYHZlvBwkK@$}kVGMhy$wP?X7%_fQy;q5Ht9Of{n)FFs7dH)#*_ zD<8>Lie{bgwaA%$)-|4+J4uR&po>O%=<9X-`3dr zJn+5MB|mh?#a}uTXl&`FZa7@C@MXPgY1|BD=v5P`KUDMX1lVyATaA!WdhEyNpsY48 z?}Id(8O0BpGLoTMJp1)35GiBB478VIdFOWzecW|6W-^#5ze(lqD<%JM`Sg!r1?VC9 z)r_gwdRn-~Vb5IR&go(fE5-JZ^NA?VHT@!%j2>bN{b~-^*GKzWPbm(uZ>!6Myy>ik9+rqX zDZ*h1q_Rk3$hy=iN=xNc$#@Qj^Sa>jNDii+U6-adD6%91rza7aX-WmZi}RDCU5w_N&{Xu5v*4tqkj4bBGJs5w$TT^R zf*WSgIX_g!xG?P37YkXlrin{wnUIzit-tJmcgn$`7d=j*bsK720j>aQKS7n>$gJDG z>W9YtUW%5NW(r!)ewA6zyY; z83C`e2b*r&M}N%`X`-@rH{DbgZ%f-KpYFb5;NI}S+o{!IoTKuRIb3R0<0N*~*4m`n z8HAl2cgIP7p|K~zsQU{r3;*|~3LvC#Q1I7(_+%(=%!6?Nt4b^t+nsU6gA_1Q)o#{e3!~0$ z>JXjloF?r$`(cKWGE$J>GC!v2 z)7Ea~KTFQxRj$m>DfI%<=?193yu=I6QHlD=WaSMDC6JUUb-={p4mwZ}_g<4q+Hr_2 zTeI7l#}QX)@BK{~ZJeDU1nBx)<y-TluY7uYV1#ESR&O z{$(ViEskZ1x+C(VfSCxBHL_IFdxe3aA00b4z7{S1r@M52?nd1Qv1315Ly1yz4;)ZnRWR%j0nOa&I}O4Qik!h1z)cV}R*`9v(n2h3D^PTynB!VTJt5^96tT zLieev^47bmNl@nCmmItqo#_L6vg-!QFUs&kHd0KU)C_TiA&HmiI>PZaIlWG9Em<_4vBHz1x@NQrnjw zrqX%lA}WL_daYR=Q_*V|e3^^DQeUS&%B7wNA0N$9!Ziv`G)gd==61GZky*gd8XX2V1$O(^aG2DKJeI@zo&74#)v&#}+I4j`vX#k=fW=MG z%eEr#h=0_7bocVSCz$)^f3@*O()*@Jl;F@=IIFehSVRS$UB4*KRHFkDpi5RZfPR zo>y-dyjML#OzfplmNbRvd{vOOAQ384RNJ_Mi!aD%jCpQ%r$YUL{z)sD1|8UVb=L!k)5Ax-7?;&7_RBF$ocShvA z#Uyp77f~fmo(*{3!rk3^==<3Ja5t?czEWwNl^g+1@dx{wo?Gml2Nlk@Q)2i?A1aXEb`4)y7DLM98_lb4ProoPu{nL#PyE7gYSs7k;B~ck0Tc8I&(Jv9 zbk7Wy^E7)-rU44qy${M->*ZO2j2=X=`0IDlmR_imw5U zymZ=ImJ>ugW=C(ftXYg`ZIZ;5uZo}Ru7V;4SE=1t0O}2Hh1?#sDcKpnEj5SQ!lMNd z#?Mo+M?TDtH_Rz}UmcRcVQ7nKc=^zUPmS8+;;loET|E>xu0BU%$y%V@D?wX;acz$O zI>Rk?BXffISH+*1&cE(=D4y$L1vsMAeBVkZVwnnEFr}rAiX#a7C6Mju8y26xH9a%V zk%wrh6L)ERdE;8p`#p7g#vP|FXs%^#boiZO?i*0|q{clvz?o6*-h@^rTR)HqZHq*w zMgbm&$fEP`?JBp7aMTqIcJq2w`XN#=HAO)vU0-U!gR;7wX}3SZN&e$j?f8B&o%i6v z{!o_t*!5Nu5q2XYbv2|f=4WY|=rNh&2C(0aH0l=7G5q&D6vp$z3vgrB@mkhkT3C1Z z!B+Y}!kYLYN>WT?WAHiPChl289J5~qd;$N$>i+aVfD@35d~V#Nl&<8(5+bB#GCdLS z2^@EzR$*XFF#C3#7%@m-u|@DT{ssQ)Zwl$$XeETy*I1>f`Pf)}mPmMs`Mu0zfFtYa zK!cG*HQ?>afZc`ZT=31{;id)u7AD3@^)&5fE#+A+USEh9R+b_o>KwF8ow~|hqwdId zkmQa{Y9To{uFc(%fiVI=Q$a} z6rem}SJg0qWTBl($r`R|zCAJG#&5yDK*yJNPThX&ofP%-U8 zI3YtVy zN`Qcm!HUcC75RfQM^n_Ex+uKBoxC^SsiuAdt)1zOE^ty&K!=m{b)`a`ZQTL=Y?r`y zP7?euE5wdnw*_nTB5zE8i!rX8TTid}^Mc-8*(B>%b1hXvj?eH5hw8d;uTY(J63E|p zy27brNfbWHGK)YCE+mfa%J&jJvt|UzC3e#}6gV$B;CNO3E}okP$8E=58KI+@YU5}i zM}yKM$fg$8Y_w;3)VH6e94$asoufZuzpO5SDcq9%4&H=;?v^C|4{57^InfmoSOgK$ zgo7Rt&$$LG0_@3jjn9dv-ibD68hW*!^?m9|BdeT=DngtH0Ihbxwbi6W@Xu0b#e;Qk zLynuR^sDsTrRkW5YXN$%*t|u~r#EZW5ym3GrIm?i!OveF#nsO);+;`kNHUUsT9R?J zcuwEHAOZIxtjNMva8<8D>h_H&lG{~^2Zzaa{W9H9E`d4d9B#jD!_$_c(L zjjL5Fb`mp#)FwhH!sh3T>K-M*%Ph)6u%A1r-)e7IW->U@g2Y-Bl?_l=YOq z+FOMe(^y3H>lFcB-b2v(8BklHgI0T*$~ZbTZ~c@I zUTC6mGniiQ?=^)VmQE0Q3SJsU0sb9Qpvx2_+Xa5S>?^NIrhN^IO~PBUggo zIVey(h}?&L#ne3WG-rYfB+cbH<;g#LDKL`5S{ngtTiF_{N^Y_wDb6*54BOoVbYMbF@h0@PRPN}Ue7G3jSO-> z6+LQ)p=DA>O=bfp*GqXLD~$Mv3twje4xf3OzA9%Xe^t(950LBn_Hl_6E-*-?y$Efz zfz8=(^N%$CFZO^g>I^#oj4MR) z39@%Uwj}lMk51hz29NiV^b1}_sr*uEd1>*5bpt=jw<3{81gi=DS7Yr6S6*e!T3h8_+O%b4qp|59flZk zygUVa4u7F{TUW^)W*n&t9KlNj*kI)ggQk=oTBhCxIWLf#jxM&`ITp!Cr^F?(*s9?e zvPA6$+A?ycfuA&2g+<5SfSr#^lqS(DbVT*P{IiOgX_$egvS3X$es?C@eH(aZF;mUg zuK$ar6Q_vyvh|5W?gtc60hC&sog$8`yuqS8QiRv_moue;Z< zf4u#n+U3`MMcv3C1LhIcow39BPKkQ?IXq{alX@K61ZI;m$dk#YAzljx>GJd4lzmxELJpycAd*t_L?PB&7%zIJN5piz~=%;0Ugw zNw23E&gnk?AcNFoCT|iE-ah5D2_3WHn83ViDwy)uw_v6*o|*7ZaO_Lx@^4)%DFEMuf7vMJ%ZLtF)}ii zme$nEmDnbSIc5F(>;J+n{%xNBiF_=ENb;8*#l%yn7zBIh`7$@}sx-C3qAB(WAHTd^ zzqzQ*l<5z$z&QLtBDyW%CC1<{fl>a8%mQBUU3MSs1E`}P!x{S;NlcVG;!N1cJKgDE z(iQymv-}&ZA#b`L==!3EvOA210OG_Ho!&_F`rMHfiI=b)BnZNM0z#FhTI1MDL#wZF z6-4{~3j(Yb29TBKP3)d4?UZ-dnwU-5&1M%HjpvEqM_%&=u;z!co0cIneK4ecYJNV= zs+P~ut>S@fB+MA(`h8>3%02Sre_6pTR0M$wZUTh7rhXNj0Ny-Sm&j60((qv1MPof= zUmjAst6_1S2ijXty6VUvDf1IhXP zw^5XD6MYDHgy=ABJ&~?`_;sDwht4xHGgE4+%4E|BGn3=_dI_cJi>z^9!x`;oMOr{b zapmvIRu@h4tx9YjdF&p8-P7?l+nbxXwNy0rLRSk$6ZXP3lCw`j-uBPUF`)X%w@)_028`QBZVa4SoxsFNhCDC3D_eCNi{7WYA91CcjRN-~2gmuMHLu*zmBbc4Ar|~6l^(P=dFcPMB=Y+5+B~Z%_9+|% z8sB}`0v1Ld-JI977~DGPkJ!tn}DDw+3t_N*U@5gqy*>B}?B zXU~P7p*%yNc!u=&9MEnX{6T&Zc%sI0~k4| z`c6Ci`mWG_9sLm2Wb3aaBqq+!d;5z>z^b7f*nFvc@q+&;Ur_SGVSO%JS?mI^4)2hX zX`pPMqvW~{urcZyHH>fHYUCgVU@Vn;h9osaOGQj%Mh;bR2s-m99tUT2=ANRF65hoH zrsrI3n9zQBybqKwk*D)eGSJBAfYw>jdEDGDM#vvyzh5eMtYeyzYUey0hm*=jSwoI| z+a&L6;K|lp~}75F6_KSbQ{uJcgkU(c zsdHV1GRKb$#PXkF$`_QL5PlUMw){e~gi1KT0z#{>x!eciG%fKASX)F}?fjB24Oz7s z6q--VafI9&GsFj=%GopK$4PIC5r_#xlPc(tHkeKMW-xy*PCcobIjX86yxv{c*dPZry zbcwVz@nQ*BnOHqbHp3vjwC=U7%>DP1{sZxOW%5Zo*Tc8ln_be{&?~y!qmUjgc#|LR z(A)lg+r5Hns5RF|3px2&5!E)X_ot@6CiXgh;bck!Ge^+P@K?rbqYDWtj7wz)2iR(x z0#XvC2b|bKm1i{Pn3mQ*(lwx7K~SK`W8r#qp1!aAZ88SX_dN@axM99Oi%Z{>Ug7ro z(S83Jqwkw?JJSqPb^l;(+Hk_NF#MD+^me6@jrDMez9ZPYn;h~&JgXbwbwob*C^@e{ zR9gfD+6*dUE$sCpB2Y?_-~ z-`VEjm@b%XL2N$nOi`!vL=mz3{<5No2LbYl!K<`YXoS&C94wmL)tuh=$2lotMC~a7!EngrzE#vQZQ{1jD~-S0 zW85}~nf$mq?_tVpzv{D@Sme4?*T8MgEAR>)`3vD>LqPZgIR5t>Rbj>^Z&X@;l>VKy z8Bv#=y_h4|(4N;8HVo6RaRqOqQBgJD)|K)~Bo(;$VOLE>blH#}-I1T*G)7{ac#R_6 z67sFzB%6_IJsEjet!m$}oHW2RH)8GIN+joB*241LTAr=C&M(Ez-B7(w5KK?DT0f08 zuCBLW>a*%tO0{sZavh}O*dyylLN!$@jAel6{bN{)<+W5Ey!a4dfcI8LlQ>8Pn%zNM zN+)Hu9B+2e#fO`!b#kgXAb(C~W-OYjg<;^?{a)ePiL|$pbfh$fdc2tM&lcg|gT@pV z27ZIie3|Q{3R~vH#Cc_3RP9MFqI=6U-Vj}WIN(GJ%XLQGhFB0Sf`Z8$CDEp97uVG8 z-ll>_6NNkZY>XPlV>#M0*qXJFaCVRvmjKI}@^l6Y+vk^K9%%Qg+a2kOwGNN1mI`O(iXk{HZzRSO^i4!BZN_YHPY<*- z0jU}>n(^2ro_hm~Ivvn}m4Y*qkt|*alm0yA*O};f`^}-TQVg#IzFuU(Ak0uU z;A~O#CBje6Pzla!k#wbv|lrJ1dmqL-&R)ob}{tsaxEofmFZ ze557_-4$SEpp2j~n zVV&3y)$^U-A-Y)D1@AGH1`%gB>V0l56_jzl`P~tmjD=`_Yw^S|LdJ^$EjngQ0xVowcU zvAya=C$Serf;CoePhDQxt!r12i7UQ&Ku3C*johzKL0?qa-Yq$ z__Up@f+Kc*>uFxiT6OlQjzDHKh&Wre1r}} zrCE5t`JN2P_LUvm4Np6`MWq^RbMs5|y4^4n`xM5U&3tak^dx|aP5t{GfRscg+zv_?qv=nW@H#diRnPVt1zq;#ZZs1>Rn zDA>p~1tPm*FPB-{lD)XF^4b;7yr{AK$#eL~aEM07i>6b-Tz7Y>P)y9*@6w zUUoYF0CKm>JqT`Y2*z)ZkOGU^yc`m}E)S?*Drt8NcD;)aXqQ%ZD$|$oqKh%$xh~GS zPYL&%8+h}gPOQ_-zx;?7$a*9%>#y`y*Eng>kS$y5>?k!|xsfgPHhhYhb9E^Hh#6;0 z={)9EfXdFG{gUP4>#o!kBu>vR+vD`?3IxI!Kh+u$Eb;SwM6Hzp1w9eoJP`H!r)Gga zc`xk9UI4wa^yAzOe0O&^0Le8vPAqEnK<@@G_C-Xx^VH9#RGLc72e+SqKiKsePtdv{%ifTq zr^`LD^Twb5;2JTTA+gA8z$wAreVF&#bXol?L7Px$0T>OEa>T`QqR_j$F zDD>f3n-I^ixZ{ri+H@4@!F&?n@LWJ-2XZKskP8{Z#Gn!tZDKdeE!)^iudhm78UYL|Xfz`*0^yn&bO#T{PD0t54u0>!iZxJttlcXi3%PkrkxsvysjRy8!vymUT zEzGHlf!0$H$jb&7f0+)tUfR&SXsjts5gPZ9IA-sM z6`b4PB0+rmyG1QKA4u$H4g_YWgs(~|dGQcWe=w#aSH^*mq-N8xSnxyVTE)qMz8`C6 zWSzFOEJ(kz7khG}peH}yW=!KwT>;EJzFulw1hh&xSAI<<@9jfOO{o;_o+-~3_fVoG z6ky#h#I2^Ev4lF#mwL_y@0N$`>33>C%FA}Z=~8l4oeh?86rSmrU&;P{fj?7HKp;2s za@B~k%6=j3eIc)2b=Ph#VANIxYj`1y(9l54eD~+6r&=x2XJNS-AuU4{2{(YeyJD?T zt0@A?K{xr=?MZcfk9Jh22X6RSNi7e~%L-P`>o29hV=sS^Z1y9y-5ewid*s9NZF=!q zy9?wh6frFQU~?PVe^Ensg_q{;sAIos_q0*1nOf1xtdg+M6;uCUYQz&v4_)F~R)Z9Q zecPQq?8)kEX!#6d$1C&uzTkqmLL2VmYo$^~bazr4Z@TiP>_?OOTc$3x4qA0vn-=Kd><>n z&**#XTSq@_{p9U88rnvC!Uc0|9p)AI4()ts^}0#6DDX~ISKlg?8xs;&f1}C=Z}8$L z@A-60Ya@T0#efPdPXv8zXxs7%#G`rLFIE-!C2$+lDyZd}Hy1ZIUCYLe&f_1f*Ui$j zq-+5utHd0RdN)YVvE5XbcQ$bo^>1ATwgx`gltcqxtnPEe6lu z1o`>jp#Mq=#pvMvl5#A8Tm=e~pZxItc9NszT}I;FSYz%?N?Cd!WoBO!#4ZiC$C&0H zB;6p>NXkmAF9ITfNq{swUo941x>c<*+8*|p7qorR^f;Btm4D1+tT(T-oxe1dH54LG z7!U78=taircwpj#xQ1w{Ss}80L|=04Jk#!!|8&ON+7iWH?|x)3zFmYahYt;yu*IhI z!UfNn4(QEW<|e>oTz2>e2&ml459Fh4+st?3wt@<8wbSB$?D0OR+E_EdE?Y?l&dQvK zZGnpq1UiimNb_;m{pTSFk;ZV=;VlV95s_^zU%xdq6 znMzqr;a-hPr#E=o`PkffPL%iPCUzCIHL-fqwvk^E-_q{hN80<{#j-+*4gZy7jVYlb zi}~lN-GQL^v{5||=oYSOGoD6t-VSPqE`MNh`0D%>C+p!NYu2-{xU+Y*(MF2987?nj zDRp#hR&wA|ydg_tslj*0Xh3-A>H`upJae{x9|>ki;e2wWN#W$`oWh}RAS44az2WXU zt)o12ZL6)#sjGLNG-1m^FX!>*U7cEIc$=fac3hQpi>3C?&_67>ln&3$}30Ebvq_}Af-`zW4SQrY__YM%NLg4bFjj!`)2CK>^DE)vp*3sOsh z+F5Psk{k$9|5i@rW zcX>I+amn6a3me6dbR(3eC^wfhSc|ABPTOB|ES4}I2v1RCROXN!I;D7_*Bx4KA*f(J z;zr{!lG&v?@ci(7nWhE9#v3Znm~J#}9p52_=#ead5|X!|NYA-6Sy?deOr z>P?EZxXjWLa?7T(d8<%;rPVXJ?z7;48yMXqpprn$ngFftd7e2BH0$Eoy2K6)0$tQC?7b-mYcXlpd@C|}r zDns_L^BLkX)c?joc?>!eI?J`2H?o^~uY(!u$X1>T8P8|d`aW?Xwka!4$CN%_TO*rT zZ!C!OX{i7GvLuM)Y2qC3g4g@$2T-6@0buXre{nqq$f#iYzFGV=P`F=&pL#>I?2=L^ zYET!^`AnUIF07l+Ku-K9#4@UiqfSWdY;!7&SlmX)*Cpx}-r)p9PNWY$`k_$K?sCd> zVL1O1ibRLJbeRg}+~jT5=?yrDsZ$O=%cONWdu@xOZ_noJRzrj#p%D&vHOSLW98L-C zKP^3^R}lO`CSlGjWbV`cc{Clx+^#Baj{8)WYY#f0F?v;ySo4ZI{+l^YnhMV^1(y*c z*BJpGsnn2m{>LjxkUZ&{KI+1@5K}UbK!okB@wYuB+c3|R8DE7CgM6d9UFtrf@y0Ys zXBFN$Cg~wo96VL)PMT79pm^y5d|_vPj9uw{BrkG2-)@}SpCsK;|IRHvWx;fuqtGDN zGDXz`bcoY}*4(>2fCMWEOl$!lgt7dM4SR+{LXPC+Ic9@=LWrQN)rpNsiuTzJ_&(lr z+&vuAxRGzbh@uo4W@3yDlbo-*gjoifwug}pdhQ$3DTodE`42Nt`-cs0Z=T7uCuz1k za)D0|+xveXqD6fC4Yhjz@cB8+IXHdyeB< z=yX9O}gzZuE0xx}Cr-^^4BlY-K6->@t>P>7eXhjT3|B+4zED>~CzjP{ek{ z_YC%9zmXuo_7RDMPtLVd6V{1FHV5IGFSXqmo84-X%0plspOT#X7Pm-O~>=3PwzfK3BX|R-dR|3!-0;a*lirR{M0vpqB{B9?=!9aVV%e` zq`$$tpPQl2+C7q#if+QHA6}*}8=dt|C*FQ3R+|^h%%_XCylUQ9v3%+{h`hZVYmGl@ z(cZ<8yvjdwgUQa%$#i?P;7&d}z;WJSknQA7Uaf z3f={c&||~6tX(0fB)bmx+(RR!_65#91(lIuEO$aJc8T>qkBFVs#U&}5LkSEurIV8i zncfK?V3a(zYfH~M%4tydlaNpmP*~UDDS2vt>Q@N8^_*Ww-#v_UxoFj|(JncZ%je2R z_rV~KK|hGoi0VAp&~dsFeb`jSGKsX3hbHE|tyuk2$a&;KI?KbrsAp%bgT|Ur&fV{; zy@(ycv2j@qx?-fJkKDE@rPS=!#-#5dYnxLoCwChuS9~cdH4!!X9QI=*x(a2W@X_0I5}RTO3D8i(o?7UKkJbd; zP|Mm4PJsf}D%geVBgE=Kbbxv&dI%Kf@8bh5&R~FMPRweW1fMYSn;m#eBX=L{pJu1} z$v?~;lzC0<1y14ye!$ReGx6dFivrb@hnaCyt8()$enYPEOd@!yF z;6F!oF4UR0#{^n$OmJ&Fm2MP9KpjjVp zBXQGYl&|-viY{W>H&gSW`KsM|;;)P}>t*od#<|oL_+0=_%u3Yeo_i89ag_a>1&2C= zi{y!;P2O0j^wK7H@#)Z!gjI6rZ&C6YLg2G@^`scAy4_HAgB!+XAD&?FdK>)*n%<`( zd9^M4^fAU7xV{}dxhAuIqX@v+=S?QNPOR11`IU=r444G&!SrkvL1rYUe#Wx za}O+Y26Al+!6qjZX=h3?J(MmI-mmwo>r5@Rwu6C6P_cFsm)3F(4j=M1cMpRL z_3$hGc#QB_Jz8#itcL=!I_CW9@1Fd7ZSxr8)m2i?H;swJnZ@q#%I0MyUB0u{r}s_S zdetVI+ks&VLi*m{ZvA2^9su*VH;}M}$GqAQQ12Ky(T;f-U6=VYp{U1(6@H7v8=R{> zXSZ!?l!ozzvB5BlM~d%t{!O5E-e`jN89FvV4J( zk2@bX1}f+@4wE5vF9$#hwl_zlA59S{4o`kQim%_7(L@K>J1Dycg9-NywFtM9E zY2;1m%@p%`(8w0N)om-3g`*DH-*4_+-5Jldb$431GxY3rm9Y%IHq&p*EZUKo?!@_+bx z%b>UxrfW11+=2uM?ry=|-Q6w0o!}0^-Q5EO9T?mP2<{Mca0t%e?#Z3=eDa?9?m5rT zT}2gpdUvn3)!l}*%*_F9e_|U2->23&lf%YMzqOJ$8I&4u6D%>xcSki@X5rW<{4+_m`i=wkh8Ejz2cmr?{v7lwPKUXOq=lFmwzHn%2{qy z+^fvh>-)(`=X=VOXF&9pCF~cDb=}&M58Ka#0{!>rbodL2@j&fOfxtFGoQybzoyV5f zYsh%{xkj96#p}gQKSb7M@1i!2?7Ob2=~^K`a6!ua=`{77jX{>~NyG}%5wpe6orjd% znFhCg+I4x9jkN>3=qej)YWZO6hr@-b&F*KI+o$HpO6!_6RpFqk8Q6~;0h~2guPKj$ z3qfVp+xLs4RXoT$##KR~Kle87%wgpXKmm7$Ojq2DopM)vqz~3*P z2F8KSPc9zlHArMt;L}2NvLd&{Q!5+|e|%bZHoB%h|8#6N9z#LJE;hJY{!m>dJ~mvl z0wmTxw56!%4M-x%KusXJwkfKPm!KFj4(PaiabZOuz&uQUdSrNx4ar0x)GPC>FjQ7Z z3`4=?+)5eg^CnZ{d|y25mFIBiXB7+4xtYYr3=a9pdrt!ShhE|H>uH1a#FEJOxNm~b zkc`}JyB0QkUgIGzkFk&;MChxFUBJQqg`UBkG_VIj5YdYDVSnI?{~6Ol^ycGrCk;Xt z*Z)@X;GM7^A~j_XyPMq_nf}w$51yq~Mfjj$TbT-6XeL1vJXIUJoV*$J1as2*y_@xu z6S%=fG|8zb=}rp|Wqu}G7s$c>fp)R#8xL6@9DeOOD0_sT_AH065Iny6ydlLzAKhu^ zKtTImk~AU26$Re%D#!knKG{yAJm`=2OU8n1m2*M2>GUwz%_zAmYecx<{@u?pBirIs z_F56EhHHngJY2!L1K zW;5QvHpVr^T@x;6+8j}pUHU&TUu|i`0+nBcE;B|oH3p(iP<(J|+=km#7r$QaSYNaD z{Z{(A1uo0znjc{LTKmZ?i+&lBh{q`T{M?szh@Q5i=89kvju7Rzp^$U=@@er7WWK#NG=aqJvmA zcw(M`t$zS0L?zxERbATYL61rd=Zz4de5TA+p)n5u7j|k9t7L{u(V&^r!yPC)5R2j5G?Xz=C6 zL;c0Sf1u5$n;o?>=2qI2Sz1$7kbcCNy_$%*D{R#EUa(DwhWB-5=t?U_;om~cF9ehq z@clL8Uv#ptF%RFt1hO#~o>XjO`c+Oim+ZwiSs$Y}Y7U{Dde^&}b;pj;+!woG4E(9< zJ>)|6k(-_B#;uhRkp=KQ66;^*xga@DC6YQ+un`x`8gfT+EG611k);q3z+c$o7z2f@ z=v+?hKOZa4@+5U5+X)wKTbY)!1^(9C)z|gRP|c8Blj|Gd=5)%Z-hQL)b_)bHH24-o zADb?*&oDYFc${Hj*>B+whUj*MWSXtQ)ndp;XC-k>LI|_xY`MNvuZtHr2b_6C6Y&a} zg?L?~P^+o5;mCC{+j<7R#6&y4Dl@Y?+Sv9n=|>I5B@HY@j85`ZtbV za5vT`*Lwo@2=YSRN4%$RP?E|PWs>jYiGe|h5#96@kpWDn$8!R>CT2_#awKD|<&x1n zd`)%6IAxPkCBA+hYy~oH0Xxw&}Ho+rv%xSIo`^-}}M2)p=DFr(-6e?Ki>ZiC;mCPTa{V zD$)_~M)}x&iK!?yUCgZ=D#G=_VUEZ6v0iJtLVwoU8;yfAz4Z>+OPKqSkX{glOhNZ5 zx8G;F7N%?0D$W4Y3>&)sCQOATPY2hHknyr}jY5Yt6TTF3nzEEqyG>@rDH)-juP$QW zH3xR3eT!Gnho|_v zTciVf$h{+_F0M-YR+nc9=YE8KLNdmfEJbk8DzbH01GWl-ijgjef*T(k7y>gc{i8_;W35Gz)P0g;Xx9nz>dXOponJ)$?qt#(VvY0 z59#xqA-gr^u*m<0ZN8E}=samYBAJN8f;*{T^G%t&+)obiy14Uq(i8Gu+bPGFAvp%z z)<4CFqfA4yqkZ^2yw8-c@%-0(czxOzg`Vh>+oy9kstU@T7RHphHSiIe+F2qSQuK@+ z!HF`RSVw z$Qa0l0)VRDXK}(B;LUwbY3-u}cP@50=3y(1L2&EtNYvCTaw}EpUXq?{0A}(A&;RV= zAB`SE*+8{Ef;PD-f4d&#BNDUS`)wx>_bcy7Z;Eo@E=zD!PGga#e zYSZUNeKFfdLt}8!V8v0OTY?&3)ONdGk9$>3j0y*C&+h&FXMLxpq#hE$W}qptmas|y zsYk9ID@A_7R`_MB#XgdFw$A%YI;rzBtlvsEzQ0gHBMOx+Oum0m!dr!NzUX!h8L9Eq zd4LXlRPsA?{jTHMJs)H2snXR)la*X4_K_^?UCr6rum=39_YBFXk@Xbr~5SD?=qc=TlAgwRl{&&7HGJ@`$HWy@ROP$ZPz zPK=DMw>-m_71yJBR#yivG*31@Dc@(AM;geH#^N@lXEEe2&DT=6Lv$i3lFt?RbUOw5 z>mlr}r!hGSe+;s6$c5mkSWdFD)|bhuv<N{dh;~FsSN9gN2i(5I;oK?9-vwyGlzbyS%!g36j1})4edj z`AI+>zo_??kjzMNTFicqR@=6eB(>`ZvRp?lpG|LE#2KGxxKmSBE~RnZvO2z$Kvp~+ zMQH~M`;YaAGaaNR3QzbCn4l0iU?vl6`+0=@_4x@s+r2%OA!C`%9JN=X40 zRC-v-RO(Vr8O4?8pdk3uyYON2j(Uiv_EZvZcEzPnV^!iQywraB-YIir9(P4T$l9?K z6B;wIMaHbX_*K+DrT5-KIOpYW6JAe+Qou1Ds#ie^4+yu}p&qw)N2O7sf^t}cN44-Y z_T!`99VpZ5jm%wVpt&=V;jwS56~1sik}3b$#uTaJjK8?@A({y(5r_lU_W8>pvn`*r z9wW^0biPeaEjGXQ{cr!mDXM~sW*mFPXFpLc&4RhKk)xxu=%+mga-YoW?r)5LbASIq zr~egVeHz6~YV3R-g%j+{z(#R|<=ATMtTbUZaQm&Ztqoz(yk@cb<_1|jpZQNrF{stpxdT zP6@<`ci_fMb&docS+kLK+u3o@j30+zNQeyw3k7ffOPnR?y-d zof_7Q_|+AeaCew=c?QaKd3$#Hg2zf2*#wg_G)Oi?dWiudx}MD6wQEtBgiUz~-$98z zX0Ks{E#%bvQ)hR;jpF6%tv%}MEtyieR?Lf~%&4;0W|2ScaX)r6_`WM#dE{wfrzUiE zM{D&m`P|duc$hhAODP8B&+zv_PolUNd~wi`6@^a%$p*bsgZbxpqRs+|S>a^NUF-q{ zyN%QuZuiv@!0+x_#ld`N0$*%$tXC>&*u!(@=#W3E7dp>moj^8s9s4?7aHtIP>9e=H zakn74s`v?lOpHqQusgdCbvTySEhh{{1rAM(Qa8dxym1H4^c+v1rD>x6Q4VRkMYBL0 z?l{K=9eeG5{*3j9$M|0B3n(uENBV`xL!nE_81OsxDeQ^=)}^^pMkizc%p9S7VO;iJ z_s{tY#;xg5WBLx6H0Zp z)K>GngGP%2;T0BuN0*^|U$eBTG6c3I^OJw6i%(_tbo9sd?zWmm9bJQ(1R%VC6in^* zI!%gKHj&(hWw&4j`ae5PJ}$VhrM<8Q?rX|X@e9@vQvXCk7$57^%7la|lJyW88S`b7 zXG@+@N9~UlbPnd5LyL+yBrEKvl~HexC0!Rey^<+?X@*=t0@t1hk)lLHg{o*Ch7>ht z82Iga*POdjZvJ`V+WIPaEj$66DORLTF8-DYM@E-IpL^Q6M(Ie-31fJXkcE3zlFpvf zUa}6+YxeU~h2$;_rpH6)b9~#OVY6m{)+=evc8%~^^LOA@FC^?~dFJi>oONpj_#|jd zTSis~rXYUt=R17k47`-81OC}Be1ugsOgy-?TaIt(hId(V*z&q6rnmM+nCB+lnX?ZK zjMoYFI%QrY%m6xc(9_H}l=yVk2v;Q22{^8u+ax?CD+Vd$N+wzpUl&!j3G3e=YBZ+c=PeXLyqh*Gz|Zw$Jp=EZQCit zD9H8UWqn%xT-|HNck4|J{(F}%(Kms`$)bKwtXG8%ZAIxO+j(_XIQXZ#pJ86_IE_5*btYHJ1btsPZ$-aKsg?$H`~Z)|#HciD_=sM} zlX*Q(*8u&V!&&ack;7_iQ%>e3DaK=^;na}F`Pqq`hA%%;brtNW;pbq|e>T4_yy9Xy zrzoSV_f6;AADDjsl{~(I`(HBK;-jboFqrmirvTm9Rw~H7QT+<%uS>Lc7?YGQ7Ck?F zgi$U>!Gh>pt=;;JhIqP1Z^Zo7W$;K;Bg}Al3EAqB14r|49(6^3Xm2qyHPv7&#&7Ym z$H87mUx9(-Y~8&4EHzy3jHv-wzYQY92BbaHqK!}8C5U9|)@A`EShbx6@+;jA*jQf~ zO%TxkIGb0JlDz+y2^He^u!zbhq+07ysQ>%r|Ek0FOMIH9zzM)(QT6aGb4R!mi`a%5LLo$cUmf_gK=b`-+y_S>t z&;h0d`@HYOvz)gP3GHZc=pJ_0jpiDKj?KzPLR7HYwfn{d0Nf{L!`x}x-2K>0=(zB@ zB@>I@^c64?BLkJvfzI1lH3z;Y7hNl zbeAw}MYdxt;51a{ZCcpdDGzrpFA&G&%qpYN)%mnUry^OjYaxtI`GlnrY$3wajx4fr$Z6>db$8_XIeY*yDx{CkCu= zNgG0zHWezy^_9pQi_kgW5dx5AtB5mvEvq`V z-|bVsRbxl^#9%ybMhDMgMXAp(1{G~(=4G&G^-?vfazMHXi{I}Q!uUhx-ANDj#=Lhu z<9lak%JV)eWj-9E{8YY|gos?%?2d_34-?~}R$J#G{?3eHJMCguL-oN3|BR_^6PVuP zFQDydXjMRS+K3^_1hYGHS$W9)|H22f=DReJve4S$zeZujXFBK|zM)n@0t~UFzBW)ANP}cU8@i)hudpzWY zu}pS;ZoVQO1i+q`P2q?Mt^a{ti>R(<$Cb5=fimozSG>pI)4{ea?C2_r>xZ}h{24P* zVU=!N1(Evh;~P8Yi4H>%;4(;I0{fR5JibI*9dhcVOKmU?o7^>;7-);nFl|c3u7K`Z z0`&AB3FfYF9od1$v6DQ59YFl}pj*!8r>!N|hK`kaGQ?NK&qS+wc`w;Rl*~Os+{0-= zAFJ(2FNr6h?wqjQZ0F3{3}v^|_Yx9#dEh7c&vBt;Eg#2goU}5v#N(&j8I9wzceR6y z%1S?LkE|tgi@tSPNonnF&gE=Ch(>Dh^E8!+ESrEkAJVCpcKhw7o-_bI6Gc6A8n;r6DEB}lb})BPu})uT)meAhOU4q1O*%@I2z zd>bhC4IOr`^M_ET&d|QFxycC{z`)m#+?msnVDKg)m>FokZgO*?VDP&(N~s(TaG$r^ z{UOb@V_yFK>d@p$`g~18yRcv?fI%iJKTSd!=LKZ7n|u7jAph^`ZC~f296=mxcv?ZRpM_x?C=T;ZMoaGa)QbSYwWeS-8b;s zNSex+oBmY)hX}CHejkD|JXT!QiFQO+se~G;#^-Vi+$MDU+%=>B7671nGG|jhmjp(; zv6|0$u;{IYoMqRw=}!bCc$eQKOraY}Q?;AZ5w42Zs1Xth&LtxudZOc$6}i98rxhmJ z+oYxjE3a|}TYfhie0rq%#B{ zP>7c?4(Jgey&RM&_XE|M6|zvk>L)+Okv~N>y0$$wOQ|s3BC-qT=)b%;_#>JCDZNf* zpy4ZfZPqnsrT^tNPXH_QKQsxfvQ>G^ zJ7J{0X6MY3>0Cf>77)s9uvYXU{)^(DU>)wfm|%8gzM8vQ;3)N^HII$)F#8+i7)Dn) zvB)T^Ew6ONkXs^djs`qK>@0Uzz97xK7glwq1HYOzYbg_BY1Ny%oWlC&&UT_`OmBCl z;hkWsx3f2(8@HBxxb)XhBbHX?=YQ9-|K+C4uW(kd4K^9sarXdqTQp_jamnWdEl2J_z)f}&_)=_)r64t6Mhyq$09|xx-W@v`j)eVD2ii{ zqT!oD0pq-0LE`=CTq^cnzuyz^0VV*}UN_!lrD2vP%*gc549}~CotwXbK6`kB@ZnDD zJ`u!nkR5yO{qrcvoS2-qsX&>V6lmeF%l%i%N(rY6FR!hlBjxVn#_hWIEo)(?->(jx&Ve*c8dzt4P}!nnBF&~Eik5igelBoqHIVW%I~W? z+-(gVooWEh+ya2iGjmNEY%2FXp_lb6l`_ancf?I6Z(eyHwy^ zAXU4M_|-loyx>2G_zf(y;DiY)v54|3*Da7O%0x&YfIR6ogmb*2qAc9D>BRue^oO&W zuzvp1-bG9D^UA_vTey}dMMHnHhwNn*kR&msMZ8oy6NlBtf!V(%p|6iIuYONOVsELr zxrSd7Q|d>Lsd$OTNbASVjpl^4-v%9N#tH|BcIK;AI)MdiF01Ev;tTWL3Ht;bMYsv$ zO{(}E5|>WVY>dK7Qcbbq=A)q*w5QUExvE? zhr@8xD8kQf-qn|xWDB%+c?(OIZiT844Kl_Jy&|NAwi<|{%>x5IyGofw)N1JH8Oo=6 zB3l}3XD+$1a6~vT65I5e%rHh(NhHcADrVXer>6KZH6DXj*UoralyP=^b9X%QwKeY~ zHupLMl2r9teNWmwpAkN=&AFHl>0Gbq)OWA#2rQ427T0uSe8)>EjG$DxG>TI|EvuAW zerZRd-sU{iN7GVbx#8z3Fp8`ns~#g7J%R!OEl7*vn8wu z$CexiGo?Q}50rL1Lb+~&Pq5lflc#R}j>u?}n(k=nsa;s4`dLr^35S8}`R2z7=C7$$ zrt7Mc%Q3w|u%KB(W{WH7&%0Gg6%E>LBRAKaIlKwCmEYN;>%s=n!h71CQV*{28l{Do z^uwIKEsqWxm*0dZQB&^e&xu|CE!=~LZ9ljVIqYI~x-U`*F?HkNS&iB>S=pK8C$~eh zC*u3o|L{;elj=h6l$LAD*5*JsFjA9< z?nI|h8xmsN9pu|mmWrRG%ZR}d!#f>$)OJZ_HhgFc5rHuD`w4Q8zD!-n$lmomo2X*P#Ud=LGQTKC#M~QtSw<# z>x-*}KLJEBnp<;m8r{;D4*2%%v2iEk8toAfdW*gL5LQbzC*Mzml^Tq!-I5qHOthBh z>7}Mx{HU&0nMdwrqJr)!9HOgFqEc6Qudrl+XY22VEzYY3ueEt1*LOrsw^(vjtI`)M z#jjc-&@le7JPl16qNvCLewKpPE$>~yXR*dY*j!#-aAV+Hmk$yMF#2Ne6K!qn0raO5uS%R?2K)C36B_rp9y)b)m({>uAJPl__<4Yl^W?g^tHTwiUL9jTU%0jT*G%pW*7*kM{Y|r9P!5n+ zJCn12nx%}VkI`oy;y$X1Nhi*C)gw%% zvc-e@k>zF@UdQthD~HfQBf5~xsV$ZRtE_BvpVrk? za0!gsy0f1WCz;&1hUaUbm{Gu?0%`#%Mla^@_wgPs>T|>0=}aYYHjpGd5!lZ#Dzi-4`_P0 z^CPeuY25c(e|{HN2eZca)p|W@#DZ$f`f3}?>>nH-!{Y1$5|*W$KS{W3_O&?|x8n9H z&ao7fKy0ggjZC?#d`s7G-={{uK&yB@@aMj|f9Z@e4k&p5*(LDNK|hF=+TDl>Z?QSO ze$VTNF)JVAS;m*RHO@XHI6-U#;C+8+w6W03cgZ!6YTtU+diL&L8`59J zP~je4OlUpNn_pX5K%ad7(hx}*BBoS?c*v(iM&>SCOIKZ_jg?ri+1xS#+ zxX#m@5R0Z42e6(|U`R)xV1qO!+Q`v3vwHy9)JI$6@(BC+>_F2GQ&5upKJ=}e==}=6 z?QC+RV&gNVTP?rb?$Us@>E3wsj;=%ZTz4@_?qR(q-LTZeS(TZzo!6fx>ksSRD&rdv z9e(JHm)&~^J3A4Twd5UzKBG+XCR0d6`1h1fj|^9HI(;U;2&Q4#mwovV-S5fpNRg-3R3yxpk){oiK$Iw}4>lrg4 zcs&LMF|R8q0VBB~cet(6saY5T4PBPH< zfK+*Gn2s>S`ta25T6LnJue=2suOY_i5{vrg5qtl%BFgLZQJ>+24xwlMoJyl=mS&M# zrHiA&x9Bm*I!2f63I$L819 z6kV>(83DXJ&b-SMa~O#bZ?MFva1j?Xb-~&LR8H3KrH5S` zJFw?J*oSzzrx3M^0vAtbxCv9ZqbD3e!=?ll#dnf=V3>cF6voxT=KETuys6r zcQK!+wv&YFmkw14p-jQoQ=w!As#2ViCS_y8Ou6shW_|Nest#Ub$6pJj^=*GsINub*acC5d{(p7> z2+-zhcuEt$&Uu0pTPG%TwD>X%I_6IqKN}xNX-ZOh@V4?qK}UO|x1g(FK(xMqj;e>F zwRGc7QrVg_k03a}UpWH&$pNMqp**#Ul z7|WMzqpc^XBM*hO$qf_@6O4!6AzX)flJGZspZVGhBOOtUOMd&^wD(wA^HMP$*jgd- zN)92jEVRfoH=3 zb#{V6z%fBTjO9y5-TXKZ^IA7AI8a6Hl+ISAhfWG^?TbH-Era+;S)nLRbxWG$Epu?C z0>vyIbS9tGZ}5hHcVhn$#Q${te{#Yan=-dta<(MbWXFrJw|^HnX-eHhvUmb<|q58rgj+2eeECu5mO^^Gw5=sTIl9hIib zxlYAy3IJbvAVq4ZK)>~KZEr_<9kmHh@Dix66?Ca{dSZIdbQ0Hmq?d4Wv+J<#;Ma6I zjI_asfv>|w6xkMcqL?|RS$$-O97#p#t?F1cIT`;(BqI^Lhw0~s@1y% zVZZOPMh7ESu{6~OKclooBNK*EeBs)>(dwJbPSFabYv%M;&5qflxA7fx-{!TQwKe!L zf#fE2RZ7aYl=R`tnY_W=(v9oI)3%u5qy3IChuCi4gIXF`ER<>}5Z}VGRIoWQIW>$=C63B8c-IbuXx6MSf%w8HQQi5qmgsV86T05gZ7(8y|~`#n%fGn zs`(e3kr#|`Pkn$NHNIma>)Y1A&@=LgPiAt;s{?vx3-kw3Z9)@)ibd0)h6oOjCQK(gSg;A|5)^IC@6rTjbaN6TIJ zpW+<`N3amIA!?b_nt@M!UL4g=_RocpH?UCg-9*3X`5f02Hg@^ZGZqs>D@Tws)IB0^ zho0hD%3*6hnBrvUcbR%z{2RNx)*hqR-W7f$0#RznzX;>)g4+d9D7D?XOmk|2ER~PE zs#h&nXM%yYRvHbtgCA^{`V%_=9Zqg_X|eHLqNML4%Nq4^U+~!-Oqg|=Aq2MAxdp4< zaUEF4o>6AL=3Y+D6^&qcv50%?n(~E-i#?!G$ckcF2Ff(X0X?CaKn{2k#+m*a$S?Qw zdO+LIE0Zt*mwg2o9xN{8=Ci6oe_@oy+haxS>}!PyCBdWT@2ptv1{3k|5OJd0WFFp# zrOUk1X=x+`=2VgKRbD~6ZZ91btD}KBX7^1M{Nsbne_*bbc~A(^C3vrTE}8E;ncIGNiET0e%zz1+`2?5GtT;1S$5eh1gN zLJv#yys(FzrX%*KpaTrHEOc%XHN>m6XVW@3TWp>*cs>;?b|}$vj4kpoeh@?bhw|fg zJs$XECS&qie4`yi?t?!}g!TV^`2SeM8<@YmkQftO@%gWknEi{PX82yiM>YRIKLeCF zp_G;~YjM;dWW!jhm;r7GjXe^IXp}M5tdTX-edIR>Y}S#hmlE| zypl9HfyO?)(`tG21Y`T-=niD%{$qn?#M9u3qw+iXd`v`wRe3E83_;V12+PdU0-qkw z%@%^qk2B*gjGI~LLF0@}JsEa+ogQvE-Zk6WU3T5#T5W&NKdJ z4)r16sr*pQt`y6R-Hq41Y{|v-OxZ$by$nhA9@=7F5+D$I2^&$i*n7dEhcC00fl6+J zHtgGf+fxx4{H90QsL6xIo1bmZnXw4^+aFbXyGvA-2i>`S zl+~1YC|i<{V8%=+eB`Vm%>vwH#!D6#2(WFjs|>0G;o*x~mQKtLiTA59ICXoxwzf)G ze7#;#XiNy*XsM2SWS4nQ4=n7t*jk*d$V7FeBNVz+cJ0)vVihM^QgmJTrIbv<4+{80 zGh1xVp#6-sc2qk*>r77@;FXIJX|q_WaYiG_CAE`A`+_WJfpGGbV?nJu>LQHkouvSVavg96poV|qr5FGEz2qtO=s38{4=v@s z?hNJXe<}X|L|jlS_&*Q9A7D2WC$jYANZpjjgiCq3Z0%eXKY7G=t=mFNplhvWDCHK*HDiLmeiQ zOjl)0-~qVvY|exE1e4y?UG*c$>m#C5M#Dm3ASj@ep%xP_Kxd6X=No;2hnPd(0cO__ zU&&k=8hml|QM z#H>7YjSk{2^SSqN@rGO&nZA#!lv4SNcPWOK-v&q2Bryi11b!p|`^0vu0M7$cscPsi`7IeD36$o!uR`_{pG~y7iAW zC32}Mtve@@A%>aHiDROlmlV>0RAup_hNV+H3D)MyUP{|8jf$wdY{jJ1K5JzimUX9| zkrJDQB34u`#};g0Xq98B+i6vnCUzK!g6UdOmQBdKhZ)Zdt2+#+oPGYsJUyU59eA0`~aUF_U#-}b)|^y znjEirshT+h6Q33d400dI3NeKLQSbkJ?*F6fe?4Hl37(JbXgN}NQi#)vhERp;YMx>e<4mQ7(GjWaYO$YVadSry|)s*e{ei5N`fADU+#9R3P zf_;1faIPCg`#R<3`Y<&>oW7BE^R9dz*I|L%tDL-12GHQQ*ZtNQqgxPlq;S)C?5-2f zuVpPA?Zme!Q@DrBFv%I$iD@dwR0z+)GpMf4eDOE@&Y5MeRN^T0^Z@y2YC+SHB9w!0 zWCA~0RP=YWfe)R{YDL38`F%A@+@i0(swa+X_fA`)z7T6#*I|m`_I9cma+Ob$HsiR! zL+QdepiB+8+S9>+&b!b@rw9&D=u~R4iuj8ihKmE(zJJ3FSwt!Dfws7Hw1;#$AjUtS z(0rr8M?&gc&Uu+Q0WF0$4)yzgOB}vn)|rO2OlH-`fZv@b(v&8PVee_@Vt`; zqgis7k4m=s*wOJYH(I!mlL`;1)_$~6dh*`6M|}K|Hv*T^_n$*23OveI(p$a-LOp$g zuXFM1rfcqtB?~QumM5JqY1dmhRh!9(YSGU&QrJ;grmH)I$T^t!{4dYF2WNQD5sd|# zfrEs7?gX8@UPVJLI^7GNLDWg%)|j#-U;$@y{K?Dp86UofASLhmOuQLRb1Zsq_oqSP zz~nlYKXtUs#$T{u?)fL}R43<=g$1V!oXLHTv&8;wdg?D5$si2A0CY=@qGLeM=yWK6 z6AebXc-y&%FNkb+s7dad?J1f)fE&{u6+2Q4$myEJ_OjRR@IyS5JicdK%MW4Er)yFd zD=m`&?WQfIBVz!xWhoS1H0k;T1;15Kowlf@hAmdZ^j;D~Kk`vw@Y-j_w8Pyoc7760 z&nK&;n6)s+(>{JFyJ-`Pyxxq~N$;k+qcL5ZUk50{)~k)%Lfi9R+cpQuV?eUvG?=5R zPJOy4fM}Nul13FkFI;TMw))!a!0;09cWy4x6WWIQDlQ0`vG1SoL(xVz=>BEQQs!}C zDdSb94<=1$U7Xt^O%#FT3o}{_el$8KB>|q-x}H> zxH%`4?D^XA;I2n8<$XlGDHmiC-#2+HsBf>AQdp(t`B@P^ZsCFq4V5mOW?$`@SG0bp z4q9-~gxrH@z0IvJR#a63-BX8-mu94zy|tp>gESt>f;Qf@g?!RyMk|7nn|!S?ij(^G z7+nQNu2M5lH}!X5d);l}?DkH7xFq9i{TT}{Kh zjb;QLX5=q$%Ou1hzR;#^Yzw%WpL#OpUL4Q$8B{OPTG09OdgiLr+kMpj9-;);JxmS)oaqpq)dhdP{bFvQ`HN#N_7T+N8>~_C8;?y_R;LmF!js zZ<=gJ_}KM1SZi)V5@!fw7X-lIVKCVBx%D7BH zFy0PgdNMLWR5pf@+mP(QlKU5i=bQi7DPHgsG|4U0gWIk> ze=1iTo?do1s9wQL1S!UhnEOPBF{;ZX7RKR-Gy!Q4iPp#9Brc)lr$B*i%lmq;g%g()_pX}7S|zZT z;{J!ihYJ$46FF~jSnUR=x1EHP1PEdivEbEYMD*oGmF*Zx<97d&Fb^u5*;I{Y#_rcT zLDi#Zkje|xilqiTFVehf*~G3oU zW7_=f!Xh}SxwuswJwRL6v;y1pg#ai2^Sj1Ns(DV&^Z_cttjAvgyad+^o@w=mGzoD5 zhBMdP7Mc)8-qpRs=Tk<`pm{aCDb4+BFV&ipV_eV`@uf$6mPueiK;cc^>OyM5pg{s1 zZ`pE3gUwa+5lj0*&9-aeZ*qz%Ha*5<+n6BB z?P!$Tz-w0Q`02g;p9SzQg%pk}C%j1@5z2q#NdDzjgFA6xuUymj9X!xsWoSuW&3Jx? zq>&8H!^yjMER{9Vr7Ty8C4C+7o_FUC1_|-7Hmm$7ZMaO^sB38SJ!Me$q#a=S*`AvU zxY<=)9wQg{iYsmwD~J74TMN6l2hZNZu@bJ{Qo4shXT9VqRu;DwI5W7}1@V9Be|MZX zsEpz;8ynz*%d6@i)_;dP7`1{+>8gs;>le~ua~G|+7=Or6u=wV#yg*mA8bL6}RI(`b zp`OTEcH~IcWz9bvG#g>oOyx}zk}Lb&eb0hJSTS$T5;l+`i8c0Vsig6H?m z>_Y_Q5=X)!1#CO2JOL4Iyhp0&6u1JX972LZFd2qg1L0Ng(^V`$=8K z0Y_;%NR!fMnB1qITpZNzpo4kv+7QqSU1nkfS6IbP*d;ZV9bG=)8_bz z;*L%?@!dOC%$+>N@uNDKx>o$-<4@QP`SLJPfD5JU8pD#Q(Ht1}_0 zwcOz3)J@c?$ZdQYX-O4=hBfbfWDXKRZ{CpB7)&&SO2Zq3&?1vAKfBl|=#2pBYYPEQ z>!__RciTt~p3QMQOUvvhf@evZ{0;s@u>9EDo+|h176TJ*1G@6n-6qHU?RZUZI_bMI4U`zSQs_kFybP*|Cd&A3KRUUtl{+O>`P9~YYqZdKI8?j44^G?x(f8&S zyt8Htd{@f8pAyk;27g{zqrC1lri8|KmG#qoumDj-Mks8$%)bv1jU*i4qD%2+fwZm`bQ02Bulkzp*&~ zLx)q7C9kLCd@@;yNijFi&Rlmg@gzPFZsv^Y>A#yG?uTf1I%?0`DxMIYWbUaG4fo0) z*>Xg5s`mtA&B0Ng^*0KiCswx8Rs}8F}ujw=d7!%I1>k~Te#b4x2^0t)m z#-rapJc-1?*RDXW;}@t-S`V*ejsZyD9rw|)z^ z6o(coP@p)4;>C+Yaf)m4;>C+YAkadK6f5pfpt!qRao6AkD;6YJ2qXmFocG@UxW6;* zJ?BHl{*VuQjJ2M<)_i2n$zfMx3`p?b&NV`p#Ugan20>NL9Wz;6J2~N@tLOY&ftOip zqQYDAEb8LLjjq$eO;z^blDDb86cVZEe4j3?=09*F|Nkq%R}OQux#|Ktu^QM=@is;K z@2mPGP~&gqCBWOUngipIi&tITrC6_%RWfJUhl(hqU)P)wKomZ#>}EvKnAMwc$(5TZ z*1Zx8aQft3Suv~bG%y-#KaB5T3vK#qfdJbqSmQg*;ITZ5FvdJ~T;Xvt2|Ot?Ko@Te z*&Jy0USfF5Ut9A(Pw-PuVlqIe7j0v?Z%$8L_llsOspXXqml{=|M za@+1QT?{?ajV$iy1rf^c!@&LiG&5aSb%gu@v$i_i-ix32A;)tr^so1M5AJ_y8a(=% zg6ju;844a8^a`;{nFhedq)M9`Q&Klhw)bw5o+uS1>XDTmtpP#-<}9Q zD*V-9Cm4n0t-eIA&8N-;U+c^dr_s?%jnAU-$C#94I!lImzYV#TM%lf&@B9wK@Rv)h ze$Z%F175giY?{3aNsU;QU8q;`4lgQdM_ncC*Kx^eC=Roxp)Sf|+h zu%-s^^my{03Fh{qTLuInsV@)|F=%2GnjvLI8q!SHt&Pa9(#o$XjLIiDV>c z;anqP&rE*@pA#Q9!IwmD>mNLte<81b;0km}f$H(Yx;;M-(djrV^!smWnpp(*| z70WmkS^X4(Zf8n1s@O9x;;SeBDiUDwLkpqSg@`zGJy%7NI0>vKxqvyqLB;gzMd@=% zi4~|zCrzn>EMUfx49?bay+H6EV8p8WMapZeqCZWG=w!bdS{j1O5LI z+8gVD1h=c15TCNTQGcn#{gx>OHvbOYDSH}92D!vG`JM>#3^&&VYgDtlZEWZ^mzh!1 zI&+TkBz!liS2fI z2It>fJ|rNHZv7iie*G~Y*c+%e_m2Q@9$N6Xum2XL<#wOz0 zbMuY`zdMH`=7U}WR>1Qbcv{hKpZLrODY6tX_={+eAz^im0)Q^}_x_y^V(ne`6RAdN z?<;hplJt|jMF=0T_ZscfR`4lWd&lsN)`xmcKXuPh&EZvieOOA1xX@Ig4 z)DG6mMY0OQ3pqh&Mvl6jAN@iRQKhZC^0)gF$aaXC z{IM%^3KSa7HV=OaEeD$OwacN#udILIxhIAK)Ud0Kd3~}m1((f3C&5?BAIR^|Jl*NIZ(H;WpDXH|#M3V3XqH-~S(T)uv0_MO7;v-y9qh-vh0 z*8sN7uY6tLf-_D=I@}0Deh@#)^x~rCwD82yN%N;7behO(sz=FdrgM2K`!Eux|B&Z1 zMx!KJZr+SR0VA@Hk+*d}e*ZiXiPtgBGjl{^Vtp}o9~!BbZ7{6R7XFi|UBY~z{k>o{ z@Lv$h`Q{_;zn|Oxc8I4uc^oukT(LG@qC{iAgr$aW$oYt@Qp~;QEdye589D#WT=n9; z&id%H?4^<}+s|$=Q)A%mFBBQ%_R^$(`(u^F_c|jp60GVfGi^V{o!nh{F6MHl?Z{(G zw0xs2r$~v@A?R43NaSAv_{6tDp2umJL4@&+Q&bk^boGGmh_+66sX$5$-|4Ko5i?ou z8fCiXE8L};5<%Q0Dx7uqr#~8ps}%fLf3+>TdeR5jF@4FJn{N zCu7`9|+WB)2yd zkMAi619_F7quM#ry?WFa84xnEM2$>inMj7~3wTQLxelReV;4;PXW_iM)8xId~ zaPU^xayjH!cS|q(^9FFMtGnDEu6ME$uvq0i*=pgmZnpa&_FgqWL(zqy?Ky4%f~&a? zXp;9(?kIv1j0s*KzmujUJ+|V4j=VC)zPKYPUr};SXx$UnZKNBwBt;{a_MqU zpZjI>@&}8zSAG}?Be)ud3=IdEU-`Wd(2|a=a~;zCQERH$54~Kh(nm9XY#+?wfsLtQ z+wHYEd|OXeg?u~d^UrR2F=?JQ(5+s4JwBq6V@MP=Lw=?%p0k7Ud88mS%y+s%WZ5Gm zLYt3rh>azwaly=Jk^XnDdTFAKP5^U4H;1Z3<5RJkMSMO1@I(zZqyrFw#B~5fv;eLrjc` z`S}0+=#l-{5fyizV3iD=4cbU1b6?PQJT6fx!sDqic$Nc4T0d?TzFBDN9Qmfk+A?%of>) z``sz&D2YUdm*Bpx+H5n|6UcB+Ya0Ch3m=b#0_v#Rr)sgJ)h2J`Z#|C@5e}!i3Kx=S zMtxsZ{o#PZQ{s*QAE=Jm=w{47I<@PY7iCy0`OCx|nE}B%d@db<;uB=Gn?$}YN86*3 z;EYYrdGmhrzPDDL8Ln(PF|WXW zmGtlqY=tGjGhGqCCwwC-Yx_g^xFEaO=Rbr$m6nH(K_o_f)T=}mRoO4X-;mt$H|Tg6 z&MopOzo3#bv)R5kMcVsl=)^fgwKa`VO;YLeCu5=KW^H-%C8<F3iSFnb9;GeLl+s1oHSj3GqBCRN0}Ir^ zUW8jMEt0y~c`M>Vdd*&+%FVYqc(gnr9F2N7Wvbal`xhR8JdsAC!)ect)9$9*Vg=F{ zK{`Xt8Hv!GDCdYPT2Je##!NH}*4mww9VXaL>JmWrH&eEMBge z;C{!VY~BOGn__i|Tu*fDr_-AZ%WYQ{Zs=gkTe1f1rtV!QM2(tyMB45112)ZSCiKCX zbq>->J9n37pJk;f8HRsJG<6#0CL{O2+;#-JYHaz&zvwMyMdg6s>O8uIO&fsDX+2^u zq*f%p;hhw@MvbY*5-0OX$x4JQT(t1;rG@9W&{Q~__55{ zF?jA6C#!N&XGV4>$!(rrUE@Jy1?jVz18H{kw~m*)^G?FQI+Sg;1`122#^tr1u)QRj z^SqttO+bY9u0!^=99^4K;dVcp)pl#ItiKL5UC&-h6kj*2Ky;{~M>a@%uPMWmxnvW` zA5Ah3^x0(dyp`)|V^+x<`D&NajPw>%TnDE+pIq5yF1>?)xW#@Ry4+52o4G5yoQYA0 z5IQ~8TP5y5Yw^7Wjn3XkK3sHOKJlV}CmJK?fw-Q|b3w0AXS-7e zs2kR057F4YARQ;wbBIyMeHBxrq(1dk*CEHQ*d(jkGa(KtTQ^J7^_NkOehyg zN4w8V;>^!YTH@aJhK7oIKP2kxkSA71g;c^bQCqbsIwu-0T4)sQChe~@Nc}Ogo*W?( zAhUrSE}%?k#(aH9`X3MHe+9lU#$!23%$$OElaKMe>h1opRGC^OB4xj;rcF5gKDAGq zh_XE$e?e%L|98g*_3&L;!g&#)qJC4GeLhFk1e$8rWYal*4jT$>zoMVK7`W~1UKY)b zs1=`I#TKDd6w8h%@b%%*3UIi+wC-yMS&-F+Ub@N-cIJ7;BH&vg0RIPo%Mb}=)+gEb zCHKv+}_~FG4BJv!&z-iU*I3)g<`G+Q<1z}6Cj_Kaj ziTF(E#L*w?PeIKL&)hAT;=t>on(G>Z(`=ESF?svf0w)jAWqra>yer z_B(eb2YR(8x9O&U`6;ocW6Yh-Jntfa;QIasEx1%lKti4cC6k!x=9*jBr3QzRdu?!P za_xQeIP?DoGwV*=22fhw(#!kYOOp7~GlX1wF>T1O26Y7FA+qmJ49M_kMjI=&i9p+mH=^3^2}Bf}f>CUt!H4%DvpWxrWt|I?Q{Z}J-4s;`}eIi^tJ3aLNG9?R~$_i99|D6f=>fqmT^Y3 zJSEseCG`43NPb`TE}TFvPeQLeL%+7s$9HzCT?8{Hz$Z><1ZTEZCW#;41H5wpdcL`c zP;fS+vd;v>v;~W}c7-jJdd{~4d`tF8?VvBg8lJ-a!pHDtyLsI4{R9#E4(h^_uC z#KzTlM`8}#C_ySIr*Ok1w_NJ2#4oU%$x1;-3os)HXYx1Ot~|okUkE7DwuMd#3);ky zwTe88$sL8Lzx2jx;{4O~?;fHkMBwwU&|;?wo{1sZ^eM6Yy)<2|xGdzdiQ24sBJKf$ z(wTr4i*wY5UcZdii=9n_e7kb#fXx*%%@*NZ&~Ja&Z{a3(um2-d{lZW)M}k}D-*;%W zC2TT>e*4Mmy}JHxr(fbsF&D7yF<2ZQT-56^K ze^qyQ-syJe84weq^Pp-oBJx_``bl<_U}QkHoU7&KpE$46;x3KWfT>dprB;_DKD#h| z3r6vu18}z7nS?|4jrxd3K>vL%7_*Zh7&G}=I9qhw1 zl_mSe3u@fKhqd*}oJOCRS1ZG(xe+o7tk4bhbxnKPho>Y3rZPpHrJhXy2t2WQ z(L9Xm(-#?=g@j;cB?|p&CF$mo)9`ZL=4V!clGoltRNdxe{gCKq#F;l0nf1o@$LB|a z*%NP>UJx-I`K2w2;vK_k6gM&ju4p5JcMAVg68FCoyq<4NKY0cA$j8{*bVu8@M`)@P zStk)z9C_mwpLu!2g6ZP}HRpqeW?-1G`304vc%KviFG)kU@?t8iTA%*eFn{cUZjuY) z-RK+1=Zs(OBb5g@BXJ*-9H;DSEkJpg6wOC5tLGw@;%AWROcCXynm!u(yo?veRGpms zA`mQ_WQV z$(MBuz_CMTIRPf`AFAmdDC1qO+VA*wt%~L^udtj~ksZA-x?X|}29qjh?sCn!b$`}% z!wHcd#s_T}i*aBO_zaL}Hhggm_-_CH-EgTyNYi2vA;~f?9r$@T7syLRrdde+kESx!DFsj@&&encXF-Iy|T5VsRXqIUTuwf zB{(_+?-c=7_on0Mcjs2w$T}^p|7^aUy3$+0zvf`-=gF^F31*%pU#$`5ewzt5U0Ivw zmR6@Kg%%R3id;^eRF^T&S}-u}DZM@|*7l*DjC%@Akj`vS%F1n(G2sAsT3dJ7?TM!U z?Mr1I+7ABhVnrliz~%L>C5iuCF3HahHEMEt;hjy6KkgY~nguQ&-3r$jQY8?iE-``q zjK9#rJrd)XJcV44hrGyZt2Y+;&S>IaBpGPz;GTJI@xv?|3d$BijWS_6tF3I{K3ifoGhKp0%cHL7iv&y>W z+fsu}wOAaD_Ujh%!Bt)lpEb->y`TRavdDAEszqITwHF3s6YE6z`Mje-zJwv>ymF-$ zrJujr-XeoO%>n6STrdG*f40Vi#QddKmd2(s>@9Gj3pOi z^9+H%q60^=dtQ__2%T2mF0|Z7$FC!rPW@Ziho1DB0GdlU`QM!yqh-hFZM(Kib+hU- zI2Ljq{Z+&qOOhP3^kB2}peFgD|4JGwKKhwm(jwmTeV53$+^u6d>R*=r+|GZDOqUxH zq7R?Az|*N%S%@R=KiMFSK{4dZ(}LDk^ulb~?Uc=#0_OnJMX!GU)UHeEhr-rJ<* zE30WCa(V2`-^;y4U2sSfdS#WZnBeOJnpc9x=VF6mWZH5+#A z-69KfYT<5q=qp64oJnp$rW?@Qu{ZlTt(vQOv8=YzuJ(HJ-UU*1T9;9X?k?tMty0ch zk9W_sssP(;o4I(5*&B0DAEcX}K`wo*QC#BF#&c~;3{<-qbx4f(zLpcSQMyx7zGVxx zf~ecf{aht(bKd-uTz?FPY_zxwZP>_Ku7$u zwvZIlEbDc_3!CI`&gW$l_L>BTg3)tqv zPSU|(!bHY)$gh4y?+{?d72`B-(i+Ep8PN&PIGsJexCt(xQ#R2tmKGgP6TkI6+4N<5Jl z(&LtC#nIzob9UR}nj)37PKu%8{}8JrsrH8`{Ik>m_BOrAXQlV7gS)JNAi8DnZYeN_ zKgmKF?MPk<_Ksb9fwEu8r=EgRo@c$qs-y81Ze@}DPbj+$ zC@zgNFBNs{>XPp4aF#H4rBz8fLX+z<>LehVvw9gB9sHVP(%$DdPD6rsnfb;A@mTqO zE#Xh|f#{$Cq`KIpr+Y2v3mCLH*8sdMJ~%!UKUkek8ej5kA$f|#I1}F};=S_05vZ{E z0vkmO(SP`$SMGEq9=aIqxKl(X5+ty|Q(PIgOr&Jp!84r? zp}&HThd(l?^&q#s`17R69+mo~BQ$7K19{>4B}FgLh7XYk5Z+L@kxFk;>iXSjNcOdP z`r^%JuJsk_d(yA@QQ-t5K(~(u7vB`Rd+^ii=&9+nav2fe=hSO=yiVbiJ~_Cv`a^Q2 z1{jpGy1B5Iz$ShLLwZ2c!$HjTSSJlAyp6JiX!`Q|Npdw)2+{IfTmka@@Wb(VtyjIc z%BSK@Mp~SF%$p2+qxUE>9~weiC=!}aX~`x3-xd1*UA_NUkJeeR?(?2r`z>>2RV!8J zJc5mOw=+GRol}(lgSUO|#UCh`rz6L!ySOSCy4Fr)+rd9V&`tDPZ5^(Y0=37+0gYiV zmO9mse`pfVTQ7{(%oOznE(8n5zry+Cw8o0{dksXL5xVw2S^zc=iJeaK;l(_9P^fuH z&U&9iBM8&P@nzNk64V<4FxY2-DTTIM9TcmT?>_Ewe(&pcsi~r$d`rlL|YL5d}VXfw;ql5$%dF}8l~@Rr8IHR7e6SCE#rw8iv+%@ zd}78a5Ca>qd7FD%(L_Vj$~ut7!(TYzKlyU_XHFrb%Wh{yGxR=kazq|70pT8U2cMEH zRt?t;8Ep`Qho>c@a`wwY#_hF6?Z!XI56ZEJ>4(lA=N7pn_-f7t$XWKgj;)G|lthJH zLby=`pee7e%1G2SY@*WEo7Hv~GQ@VQ)^JYi@$+?DVmK?oOKawoXZqL<%B0|o$gjv& zp;O)TMW^8*HzKGW^w-32cRAXxyBt`R;noa_3Kzk#^Ruve^(yYL$VNVCYLY`Ntl9l> zzf)3cz|9$n`q{P`*g7xz3G5}C%);~*K-;_;NEcAItx*F0#N4wPlk<>c--o1=5Hee4w^LH}TU9dCh zjN<2!{aIDqQRJHlcfD5_-JC&whF3d_?J1%Rs2)(4Oad6APfPPVCk z7ewEK556F_EORwVT#oEN3{ewNlv%$XynP;DhK$(E&T}gJ5{=rO1T{2XL?a4&TeTZ$ zB1xr#zrk-}7s|k+>u+6V(mPbuK{hha=Gv$H6hb*jw(m}&4wN`*XKz{y_wRDU2q4J~ zeUJGA&euN}IhN?%%4A*kpPqta1pJ&r_THYrOhYJXCqf*f69um24d<#y7|~0 zy0o~xdi37x(+A4GRg8W{I#P;d&8IEw$cZHQr{eHEw<9~n*VcIdp-K9`^6Q~T7$XV; z@=EWHnUjm_Z>*Y=kt3Y0)usDpB_BzIC_k)64Eta=YQfvt`=0Nb^bMF^ZZGh7^T#^` z^S%8!2GP6!I$0O!gG>@DQ^{tQzm^C%Q61&V(eDOJij*1_KUtM|Pzf{<8PL2~`FzH6|zji7`eGZ31wAcZUW}Jq> zpSmfU<}C<`%Gk<*qVW*XSijx-vve!}JL>WIkdh%A-ppd+{p0r|mZ!TIK&#;Z`Aek0xNi3GzNm0OHbakLYw&)3aLnvcSyIUzz!ekT+YBgCp=I`PSA z$MaS5l+)PbzSd#eY!j`;8bY_ktb6-GW4|E_NkSFB9V)34TNF)Kysq{TOC=V{;Juc} z@BgJi{)OR<4`xEtw~<&c+}6nVSETLF?Joa>f`no^PFkjHOQu6y%<>J7b_W+2qpr!~ ze|UbDe$%46zLhoQ|3=&s{u|Tbf|zM0XI`pTY~0a}X03uw75I=ndG?f`T3};pq{Z5( zB!Gyda|&WsDkIY!f@|FTP1tf3q<~s{txIMf7ZikUs;kIj>L=3|R&4O9OKYJ!JI*mF zf4*RS6`s>UJW45^UasbA`}6}h+ps|+>e&u{3~wMLV>NlPpcBhdcGLgP`6vQ`on3YZ zSvYjR;G?c;&Jh!)6}6S+n^%{USbS>_TCiG*O+eIi-u8AN}y5vpdMlxM!KZ!$_-yA9O3CtkQNn`6q2hFOK{69I^$A6{AQTxNeI5)d>{d|pU|0OF*$KLg zO@*t+t-Oco@QB9JHzwwK1&C3V;Y?fp5IoOn;lJv2G^G`GDevyeQ-^m8wGwlJM^^5D zs%O&^bB|0by7(pX!OAB5?YnlPWk~FJn`+lwb(6mOd&xSAeeJK%0Z>*wIQ)dxAHL3Sr%FkY`5-qaVe zV?cdFo6!45E=YuArDP6-IfC%vvr{)BtB&f|2%#DmIQ9AZ1i>{Q=p8*lwC>_xTL^`B z@JboHppsy|Z(hRE1`rRrG4tA&nik6)?)8kopaD8$=IV}`=uwh^urIh_;p3)e!v|zu zQddI=c*&I7x}jtxutQ8%T1gIwZlWMn66iI*Ap?>ipE{lQdLa+$7HaT&Lf*0Gn8#By^^Zv5L`9Q^@M8Nk2g43S-rxHda=LKs zYV&p@GvT+6+v8G^9=dKv>t*IklKPTyIUZ^&rqZ>*+~~ zNK>^gv=UL+*OO{7qm%J z&FnW_PUq^`GwBU=`8#@FUM(m~$Oh2$fsh{&KV~!uf9{;420e;eDK0a7>ZUNqx$&D` zHcL&jmuOb=iPjnF+yhb6?ly3}FhIo#I`Kl!#JtSz6Cb6R2)&o#=HV_aP&&N2=snm^ z4Ir!dq!&o8|G`aPQ>7xGq=dqm(fvpb8vkh6$#;MTh>-&5Eb04BY4 zc2xxAj-L|Ma(P=d3`SNfKB`?VP_`3|9+}G89mdH2GFRGnl`05sMQ~(GQ)OOGT~{ys zK7Kfnk~dSKa%mTMcM{qs4k9Rl&Lr6e1hZ}~iJCp64O=*)?(mr+ayKMnUdKz#(9?<) zG$^e;M&E7_v!b6*4b6-pVxwVqBus#ZmVi7l<@6YOsx>oTp`|tDNAkAcCnx%DJ8}!7 zyXjf^;cRWi2PePQFNM2h)4v2OuxuldzM$x-n<~VeiC8$?+pBB8U3|Z6m7VvDc;d-l z+9vv-kuy7QW^MBg=UdFYBLRi7zZcH2Rq773BH~tkR?fP}ClG2F|4PGcvx&VE#<6*u z1%qC<`qX&tT*_py{OS&AWzvg5ztQkv&Z;(&?b};Zc#x}7UaZ=vr@;d=(QPs+6GVwS zXTgwgUAgW#&$Jck66nTI_Z$vwMl_d$7z#+1+uYdJ4RxBhKNArQ?+p=?+^{kMH4l71 z?<3y)#X!9`;MJ|pWCWP*LzTu=J#VBKyuUpB?rD=etXD@a5SXHgmzJlZk~LM+RINB zORzXHEe5O0pJpQAjI#)jwz)m<+LE!s)S+pS`P;WqleX&fkH0$i*Pc)v*q`vXo#WSu@)7lIAklfVSZ7a`8;dZzTp@zQ^;y}yBI ziYG_B!oMw&{5dlI<1~@keY8)FsOpImJ!6jX^`6pQ@<1^F_p_9LKiFQOV0-{!sp)o; z$_D3-sROeD^LJA-LxAE%=cKCPq(v$;v~F zVs8%E_Ol*5C0&OEw-HcPih1n*6g09*>}3`Ap!DX1Ne&3@%(fN8DOG#Vmc}hTbC0$c z-c}$kk=cx%)6}+jSg|-1z?kelb0=S!<^G=p<_7oWZA`8qr)FT%3aSlWx*P}Wg(ju^ zA5i2hju}=z2kGhV)Ex4jmCGsC>wZ>Re^SKwm^Kvm3}NR5iJbiQ);AvRlrQ2&9%24s z$$ai?C4I;=MgPKQA?|%C*DMu@gWK?+-1XMFe9hXS^Wia}e#u1$n}_67zQmXUlj|v? z$@}ULWD7#&8xi?wCs{{5Tuy%coMDRQTwAhCJ7Q2d3Mf`mZ&bYpl!;$Cw>aZb@7Q1N z1FdQ{fv2eS5zDyB^eJX8Y{9Q45nP@lteE6u+d@+OZ2o_zy{W%*h{<{3tA714!}}rr zqsTxIDM^5@&UjQHPGdgkSq9(@ukU1;UA!&E11a9D+5n$Oh#Hx|07XwrFhh1HGW&4z z!X&Bp_TgZNa4D#@LL$4)&O&`PRN4kcgC%qw!*wMo&#{p)+{69C4x>YENrQSHe<(`& z{X*}{c&uPDlDJ{Ca9_*AQa%+`$2oU~rM}L#oC0A5z0GmX+vBo!6Q`@bn5E2@8O;Yn zv<>9vH3d}`Cc5z1hV-Zo%hN5$t8$T&xRf=bP(zccRVk=&&Yw^Isim7r>}+JcQBW>V zgSvPVi?ffmZ+)rueKurXuhPHy25karTDInb?SHm+ZU#t3Pq-WfhzJ$>Kr;;2+2QuA z21nauL}k)`e<(rT;qVgsKtzCxlEEA%^iP5@Ljt9VV5a@tnqH8`EmpJ3i+a~yTiaZx zEu>dKQ<3Ybm80_&1`X6iqUz;a$Im7^_ZSW*i##=BnPOcG!yTTX93#IHX@!5{C;xCg zVnZ7Ck^BWAq&yO94OtR>kAMD<`|qU`FU*=xX{L(mUabB8#QwoQbHr1k&8ZcTL?M$y z;{}_AuEL+iwwWqFe^E|&f}YgXk-=(B562~OZygv7!F#J}FhsIY8WyHOLlrT6);B?i z7?0bhVpWf7!Xu3D^CzECDw=0_`Rm1vashwowL$1;u|4 z`c@a7Ns%RD!ZjfJU2M9?ztL1sC4vyl(mR&1)5$Z_k!n1#_q6Wnwe6%2ySekL2^P2P z;cY)qt)Ljeb124oaf0Ehr%%Kck+$$>GvFGfO=rRUt0$|Nf3B_?CO1!XSfQ`6Frl-v zGg+H4-4+FY;6ve?Wt9o&I^hqA0!o*_?wAi{8yEOPw@dzoe|~j{U)7!7==53sinxq; zy~}vYFz32gY!l(_vo++b^Y5d4KDZchMciFyYzqH&+kUJgd2J@4hTa8H^e&k5nSJnk z7FGN%=I5u7uHsOi*AWMPKGQ_bOiXFc8Y7lH?*fU_Xk?30%k6=dG*;X@&wL_<$Lcsm z1PpIqy_zCdte9s^NU)|9d-u56eYacwS4D=mfP9zFufM)4;^ZRN4W7xzmJ_QvDGo9N z*Q*)jW!}Fip_BUEimTxdxY?mcQ<#c(#aB&TZ;`is4X^ynx^ExFaQdwA>&Aw9H=&J* z&Xrm*E_r~PZu2_c!I?LeSG$t$&6!1`XE+v5yjC1jCc*2CP8u<4XQc0gyd2V-9HdmI zKHd6_d@P{dxgw|iNbo%^6L_O)Pko3v6D7T0p1)wa`QyQnN{?e%RIf}7zXtyy%hk*q z+1ItUbQJMu7dAvQarUW*mfqd-_qz+%XOsLt{?A#7i$N3iNO@gze9drO2%bA%ARaKE zdf36h{_jaaVw}v0t(wwIsp8{dP>FUU=gkDEvTe9qI@za`|?mrdi~Y6x;!1!bSqWlkP{nATil@rB@#vj)u2meq`Rw(%_L zFY>B)ir^UE((7NvMS93C7?BW`8`Wmv-*G9q%Y}Xj)4Ym5$q>OeI_LbKdkQ`N z(1XqN$O>MlYwu^&{x3`pZu2?9j^(Jr`nMr!VVcKWELJo1T5GW~y(b(?@eI%@PQ`MM zBfFCL81TE&MR^}01Xm_=1` zAw7=KFWhlsd^)Sw1mceL4*9vj!fVF2F@~cAVieo)7r-AR=q8xkp=iFUbcz!P&yJqO zrhaWInQXGp$;_VFhc+GI z-bj1o$1$ycMsNkrV{n)#i|>{$E*QqZ+4RrZXRGZ^wsZzB+FTA6>E5mwkDE6NM)(rW8ka ztD3ysViO|y`+hKTKFv-F?Chb8ZVc%fb2rR+Qk@6OB+>4YocsjTe9(X)JH;ETMa6=B zlQAGT+CxvuIblO;MndV~jDBc0V7ym;RA{B`;e&zJj*aFE7YdOMjE#vP%A>eLN2~XH zt*u5v0s`tbZUJW6)s>Zp0)7FVbn$;*yyDa#CvA?>KNiwzq8*ZnfkxkI@IOp0WHk}2 z+DQR~21oTTRGl?SC!MX%xlR$BYgiHrkux4Aocisp|1oyp?hQMk*lX7p?2?8!!a2d!U(I!` z?pvoi>vA{=j$U+^*f+WO?7DZ3n=BZWkr7sSpT|>H$G`XWc@^?O{x_fU2+gCv;3*D) zawjKeU5$^m6jdp3|w_+^SZlx>V7uVD4BM?3B-DKX(u4!;L@SV;I)6i z<|n~Bc=-{93d+NM7JxW`yLr@vS@53Qm|s%j&mX$a|K)0^ujb+Q?#Ekq<;KQ@JC zhyuQg76yEw#&@t$&8b>^lToVgefga7=_qb#U6->U-MpJld6!FX!#k}kx$%#ZJDM}+ zmB=~)zuRa1gG;CUgCCjqzwnL28BgEeD(kl?5GmkUPxiYO&l$|k>1J%w;jmW@eFJPaLV9cmJu>Nn3C?0Vz7pFsjv0zjx$PYBn_Jz;2l5uM~@wT zn2eJH8>|voSU5P7{M(wAtCK9v9xUzBuNwpd-dLMDZm{{VivT)B~5 zI8cDUZ?uC$#_)B>?Au>jk^F7Vf9?ngNy+=;x&TH$D!kTza+W?HGxRtmf2U#-AJ1@D z#;|oAO?D;$k#;sQD_&XI3S3Q5yewuFn*RJ;F57g`kTd&H;4d4-%rIp7al~?3M1xAC znPrEXh#=nTVU3P*3&lAiLQuBXh(n;}SBvZ8joO}gHlLrDf4{0vJO85o5T?FCma`7g z(JQkzJXv0R9lHb1TmTFW(tZr_4H|uLS90z+;d){viFo@?Vt4fBHaSdJNl<2yLXMD3 zR^EzO?MBYB^&Vs1j;ncfFeDHoPw+dT+~`|VXw$ebhc3-@Xu~`E^Ze&I>_6}<&x@;O zhg8bS?@I!@0H2)9fyv4%CJwq6Wf#NfVc(QfQ3z%osymb!ZG>BflZblA64PIohoxS- z{`M@VEL&5Pk=_Vid#`J+va_iqT=8N=!cOJYIxQ#?KQZVBZB4O?ZK_qUB;Ln!RnlUa zMDw>D@9UFl|5Y~+e)F|5U5PslyU8=>RUM^tmkjIOOksiJ;ekgAIeoM=M8*a^VDxL}+pOr_^9ZCLkx;rR?fec+2aDjjCG zaH(}+q_0hh!Sdd+WS-R~o0D)$4`awSDP~%v9QVSr>OAu$AJ`bew3EAhDDR_d$RAbq zXo}hZN>d^OS0~rthMZCW#r)YytmW69QG|}iP*T}~lO%AXt-}^rd^IS^nln~9sYNTY zWq-zR?xk16>PH#*cfH@5-`@{7AUWD%vc9k!!tlwx$-X%=8@kMzu)ydqK!m{C%v96( zNWf3omNEZ*oW=$9RLa*^`AeQ_7;i8|o@bDgBCy4?t4+1CKQyAHr9`Ow-V5>$W-eBa z5CJsD7us&a%VGgz^P-Tr;?n}UW6dL+?vq2unV`&*TE=v%f!;S+(a2{{3bHtpMY~m;g`cgm3SL*2M@IloMHPJx6HL8Q2Jjt06{pl!<)3!?W&zHQvf(V(K`%Rbvx?WP1(5tMC*V!a4nnkcol`tI1~tIw%8FnK*9Nxg6_c3i@=P^B;gW>h5Bb$Dep~oVa=n`@#sJ|KM{mB=a1NEX z?u}!nqSfw0Y2$lfO}hnZVDlHi=|R~0c0M%QQqE-+7F;x36dZ?W1aljtfu5Cj0cKWU z5KjTPVKpSD@O~fSjg>v}{1MTbN>^Wzix&Uv;H6b+%u~708?ps?YQW3VY}r0s@<0}G z{0zm-L(kBl*Rs-0OX`15Pt7h7N|aeDxo34ewX6Y=e+PUY)djdO2z5IhxpT2V0n=nA z|NLC28EWXPQo~6?QsaM?|8uKCWDYZ2^92|ud*!9^Q)FO>uBu;-97Y0jZ=TD5}*LR=&?R{O(p9g-;dRE-4?zQu{lFuj!kWGJZuu$QbcjJ&`gjUZT2{^->(`8CEbDL2Vte2p8{pIJy zrBWh(%=cyfGIuP!)Mz_2?~5>tFbPjK(GA1_Nt~=l=#CL>-@!tOF;+ad{ak8J)+=&%>9P8&I)XBJJc<>M!Xj%3 zAS93J42bW!`!<{It_0ur6Tnt8tNm*wXlysVJ=e;k=lQ`Z$E-0nHvXeWSctt%`G=Yz zt-;HLGtx6G%NFTk?(5at&hsyo{;N3Hg<8r}esZW5#y>}Xr`5PzAHft&;G+k_uBA{r z9?9_Y8nHJUhldhDkwr7A?t?AAQX@EC+HgZ9fqzC7X^gj3*bFPFjWNhFniwE-oZ>%Y z3oV8zirkzdQ3hsJkSRD$U7>#U9d?;?uaG48(~Qn^z$^DpdL6IwclI}%G+)go0q_zU zb?hJuP$?-PiTkg2eg&CCV`EnF`d2K}0CF^kE#(9wLDrsv)HG*|5gSw~@ zEYF#sX*nh=xmsuy=!C{`bPR}WhItl{&RCX*vuVXJzvlx0uB`OZ`c$na3R}vfSXoO} z99KUn(3x(MlxSXB)4S>}w|%(3S)(>c;&O zTqK3%EtC!*%?#XhsU+zMRcnGL|5#!L0T4X8*|uc&FiKdE9)3vsn~&wR|S^lgH* znBF>m`|Xx={IU&*GY>_PP~R;f-?%t|#G$J}BW&wy+E%fJ%6)v6GN>t;8iyRHaAm03 z6&HX^yl5Q#IgJ%pgtEd>8?v3A+@{Af8Mm{)IP z?aSxXIOX?q77MLCg3DE1&15=e`kvw2NcoU8tq%C}(EeA8q~hvqK}Bm)PIC z_Ob7~2E32oODPtA@5*F!)vT)lhbP}@SA|y`hwBKj%%%Pcc9fBvt+Zo1W;-2|oCX~9 zwl)hPp zZ7y}TXYuQLK#@W`Z<=1ANEvzAaIS&yF}{tqT0 z%e~)UZaeNimVqR2!?dI@qnaV=3V2l!YDl)yc7&MUS|X{{1*1P1Zm49aHbD>xLao0}L~M?!qhaqm+)I3rF<5^R_aJlhgmQ!N z3yVSVQ|WhL$?Vp78`a)w>j3gNv$ws|7x9ZVCiVN1&Ld0Z-@ZvZpYAXIHEfwnRdmp% z0L+Sl!p7rW|HObrzU?Y%VC6@__n#$HzWn*0f3CMD3!<>j#Ltwqr{G|w_2)H3HkaMz z9`EgomGfWvK@KSE9BV>)^|cjW@)Rz1=jUO=k9B8JgHzjoI1!;?a-vtQup%xOgIV;; zf{B3K@Rb&MMMbMssCsvU#hm8`X~3q=+!RCa->I@KE5pew1UNGw^Qh>1;sxZeHy)|N zi5%AXBSQS6Zp4V^i^QFR`CFcsgHU?Qso+`H@Oxz+XT(5VO@_)$%^diX%d7)_G@vrF_>`ph z*w_fK0Eg?{F3z9hW9>{Bo+(`Jr|6T{Mk*$zBpgSn2TTv*XQkJJ*f5zT&M#Uh=FL(!j#Z;Y zNk??uiIrVX?@=*sU5(iuV-?%yEO|?F+19>am?1j97R@HbCSaZX))`&7wZX-QBD>;> z8dbK;(<=`J-Z#0)s|7Aat-`@;^Xz?8irs2y|v6#Pd^>WMGiz4>E%;Ywl0 z`#ribl@bn-)JeVmKF$zP%VHjM`iT_$9pj=+fg(NBG;kvhmJ;SxQ~3bn^zfNW5%Rfq zqB>>DMpkJgd04eY(`)66ROnkcW{tF^7=J<5&>7a){N*B65pn%k`IKO;%7DLEmf56< z7${zhIe{~P8@W_YjN$qF^Pq}sGP{o#D&ow}#yJz0&&pa7fGIQq94=j)b$j0GbJqhx zmsSh$Kxvo*dGilPqwAL#O7ixP1@-c+KCA1yWmwors-d~URm7P z--9cxbpsMpGg8$0j+P1w_Ml^~wFeXyd1a>$l>F62#KcjPVY^dMJiOIm90MA2UDBg4 zR5%OqJMUnO72QGyS0Beq^UIls+`WoQ%#4QXoE)=VbBV>T3f9mAnrNuOle=hX9rG8* z9Z4zy-al!gC+SLz{kax<0u`X1`JXh5Zn>4z0w_gy{cjwf! zn~r~(=Jwf5QJ7EBYO&Xp5E|HX&xZ&trlO!B}x-njs+;pR4v@%}bZaf&>1l$8a2CYpf%r;Ic> zkZ4Wpyg5dXsV@Jzs|=;OaNi*C_!|x(oT#q>-c!(7#3=Mf%@6tJ!Mjfbvs#}q`I{34!aK5wCWour7`GA_RXiNpEnFpB$ClMQASRsxNb6?$vZsYa`J4-XmB+` z=r)<<#7&CIZTgsV28XZJ71O2reYj}n&<0dArhvx1fFhZcicr9Q6%4&Z&e)i?QOqdy zPRCgBDcGnyr!mwpz2P@3mCt7F3XeQlHR}oPCLrPD@z7e#q+u55z z{w1c-yT(|Z#(|9u@$vkGDtU8&vM;(C0$ z&H1~OYAJ!j3_3OgNZbbmto7&|O%aa-nTBnn4z9s)T^bd4PDV#ZrWy6?mK^vVOSNLJZW_>Z@c|K@7gAb!jW_a7n@zT5VCsMKVLT{S^7oZJ9Hi# zh+|Z)T=IekW+tBA2apo!O@8-sk+zT-xpA}+8avQDm8q2y^>?bNfP+M9e%eRYs_Xoa zl~VmdR@#P4VDk0fNE{JTlmkQ?G!FNY91|VQNd79_u<*g`#8a#(dU;j29l;YDTbdFo zS|TsUEXNJ74su`Yo4na*9gpUupN{zP_Q#Z2sbG+wv!rO#hfBjC9?UQAynTmp5Dh zX?T!R6-6xh8@sO0d(y5vE-Y7YOEaeFkaH`sWF@VRsKQDW(F~eT9~j|3U^*e{e@A`i zmq^ye)*0s>uku`EgweQ||I{MhXHQwX7)cuC1>~C?4+Fe0in#}WNi zi96oY)aV|PNlcw`{INr)t`LH%V{xalRmjJgIOpd{xNJu6rQI?-EJa~u!Rv9z9UjZW zq9aawdwt*Eb)b?#WlTM`%U5z20`SMqhlk@CqqT2dPTvf?11e0_>(3++udwqDGLpVi z4MVZo(R~iySRB|SH5QF5vy#7`LrlQkP2J6;?P-*HeEBF;-Le!k{>I`aq07k0R>-jP$J~piyn%LL!+>oD$s%N;%X?t{`t5+FqmvHJ;erF< zi{3rqk35LMkTE;W@7s}Xd4?oYYhzzTHa@tMdG;bbBa9ha)&X&nzb%r#st!0c^K7nW zynIOj5w6$7NZ!<-T%0oNgTQHScBr7mIV0U>dp%3-0SbWV;M5{5q8RX?LidN2;YTe4n=nWWG+r9noPE; ztFbIsYn{W?>^k3Y58VC=W3^UY0e0Q-f3q_bW;kD$C3MO>T^BQ7Hq_ zmuNXE9xrgIg;^5~99Wi8Vzn7=_!H?WznjnYbXebzTkwc4Lp;1k;{!6^g^9}Gh|03D8J1FRvb8&yxw2^OC2oaNCS_whjP>Y^K2Mk_>u1 zq!1H?*cYcDgoLQX-{_en&9D zA)s|04Zd?_u+9oa6k=Rn<`Kl?68};&$BAf*w5Vl)%GM@D$g_RF2U4-?2M+3?l z4BJ4CCu@Isyz68yO2hR(tu-9s@tUQRB;15}CsS$%a^GBZp<_Y>Pe}H2BM6OC1Kr1& z`(^Qm$OGBs#8!jn9?6D7sEnt!x92MYBO~yve=LOCi4CZ-5kiy4us5Y+LO=%D@BkB= zT6>+Vtgv%m`wwpUYqa3ZVofVx7=;G&cAo>}vkJ?BgVVu&`CKoAqZH^jm;OPspgH5t zmc-+GqVRW@s-ScIdXw5ImH3{6Hl`)+aoI1wrPl>`gz~qyO{Uj7zi|~FC02fzcao`| zqhZVKHIQlQuY=CHP}}gFKRI%Jd9pIBE|~ctdYuY5mZxo<)$8)bGS%xkwe0d!-=aOh z9phk`VqgHIVWp(#*#(c%w&%U);HQ8GmeEj; zUs}^ev6ZjDTlrdXJcIX^6)V54uixuaj@{G9Ag(uTgU{PK;_&U<*)QE+#?@0R<^IDm z>ZIF8j$yODa<+de3I@^=^;6bRMv}h2N~U~s$&sM&f{xBp@xQ#cTmj{Nb}21`n5MMN zG+O5~CXBQ~Wn0}z%}G_;JXBQt@KmxT=WS2Dz`_|WBt|UPs%6~XOfCuYZT7R!8EW7N zPAP1mq`Yy%_7GeKjcng&^=xi(x7<45P8``V@VUYoWA7MhE$=m)xkY)5;mIcsS(EYt zBwhJi&rs@zniK(MRGVE!N;~wj)e_aiY{aHKCX{J7cL}SwbF~@DlP|m3!A^!4ef~Tf zT3aoVX;z_QI!W^pSzLr2gdSvGR;?v=tP)|D2KhQ%nA{ z#qvbgJ;g5ViWWo)dQ&QUc<94>lIKCHzUzLu6dBX zafj;CUIVwd8neC*PEE<| z23?p-Z7bUy6UYSt$W8O}i5b7G^|YjIItX{Y#{9U0APi}?fm09eW^lqzNrd0A)%E?B zqvyRI!_kO;jvOMdr>Qdt8T2S^`PeG`n=$IPKip5|^1855J{VI3lst_T>q7ey{$oWr z0aKk6d?ia7IHDC_c#nhIYFr<_YBF#n3%aY5D44E6m5x@dv0@yI39!wq6p&o%WNX;Hk*aLT<~^Bsi6)vDeY?+RGqDDot2$P!AiJoM`hHdaxpU!I{1%^yS{$=LR;cM_l+h)62HM*0zYFD zc@4Ic+)}uVWbqG{+>w|Qp4`~y{Jct#B5S~>FpkNC%;o0-zmu@BU(|8NS6HP01D#dv ztUO@?v#nB7YCLWha6<@tl(iLYu}$CA_C5jBdrlI9@PHR~aC3P!!VU_bD@JoUA}ZF* zQQVA)W}8)NhzZ>DzVReXYgL~$H8|-pT%t{tSC)IEq3~9C{VMJyTWi|JJudq#4UK?> zg^=P^Y?sapKFUHD%B27BvxNiuNJuDtk%=H-kF2t$^PT_%DC5t?SI9GB50#-YHC^v z0yXaL2Vh8bSR=eb@ip8vTIDdaQnJNC{*(ovC!}QAMpk46^})dcK}PNo^e@GxpQpax z@O)e=1-CIa(S3Gm1Mqy(;>)&;X0zOw(PE=3x^e4+GzZuX{?C+=h)u7j7$;iZ_F@!R zyWI%~-U(Vg>qU!7gos-+@70P9AT?<0OyKgupiu>8UbH4y=YzJUk=M&Sxe5j*TUvJV zO-8y%rv{6Shg|pNkkY7cUTL;ihYayiE4Y0&$Cw&CAw#u`UF6aE#Rj3_d2#O%dW52ZwC|l|Xaiy!A?X`=&nTj#ouJ1^7h^5rr$W5xe96WJV zC27m&Cbj?MowSeV$KUlYg`ONR44aoXpm}zC>??S4IiB2iuc|WY56?@DWyvgDr&Kgn zm4hlOPUccF%M>H*5>MW=xXjz9%41p4xslQR57p#<8Hgw|QvOiWatO=Zz4>%~p84H} zqOFb+V}v|10##;&yLho3a`{U1x)x%1hEu|V94K*2$`>>bT{50$^jK-wK-FE!?A)xy zS7zr>=cSfEUqvtBfU=s%=LGqt@J82!#L1x8L?2EjGxC0U#D6VhY2XJy6{a*>V8!6n zv^2Wd`ohGJLmj#^%Q> z`rhEQ*=~Bm?y|lv^LErR4@onW=JgssIJL#1e#Qx^J8w>&|Hhimt^U?};Mae#fqydP zwxiG~H{lFtG_2zVs4;0;qV$Y?X={_UI;5=ET3c{`=94}i86o5r(gL?S08+YWzLXB) zkxvZ$&=dRgq@TBle-S~1!SjTI+TycJ* z?x#&Cf|UpGZ7u($vKJcMpAhP4qc%6ogzQ!%;6(j}H+7Ar&eqzcxkTQlriNScguMEC zce0u>ciFP5#B6lYBwYZK5E2kUhKKlU4}gwJ24 zr;P?9E-pv*gVOnhFj3(|Tst23-fWHmn;HJiZ#e(i82zspwV;A#iQlh7RVn@(i z@G_(+l}*p17WME#vZrN5@3gyakuBo|zf?+0v-3#l%#7;bvCa%|2#Ey$3*8=evwnzt zm^x-^`=@sN$D0v`o*7@G-e88ZsE|d?`z?}t zrX1atO3FRk`$i{fJjP5)q&J)?i7G*$cY#Q9q`@)0A`56O)m{SdQWTo;+bwo*0g>~~_7{9u!>2h%tb4$%ShfQ#(YC?DXRx_C1I>t$vQw82HJqHl zNNC7bk+V~8Q7f|G-28RzsNn0#FNkw8BQcaEFZWzt-jc z*+>(`XQEYZDjUjx9%n!_wQaXC^aZqt_iUw#@^)V-7yjHQeO6-&=SWDU2EE*tGGKG^ ztZH%DdQc#JCp2@(r~e69LBYvu;P;7@{ikeCgEnAww%5XZrd2#_-+8yj)VI-}Q;Ti5 z@0MxKh{ZzXW1A^@2qL9usxS^&%Lsoa?MB^GDu05@nl9CY8B0oUta_B1&8EkP1rj`E zbDv*wv_@27Gcqz%y%vp$cS7hr2zN;GA`c}qQZ!C++I7p~)2b5kt$*eX7nsJ()-_f2 zi)(&rX2P^$ERBFGc(%R3HXuD>Et!k09Mc*EY5#_SSJf9ENP!V!!5&hVg@(So&zc5IWC3Gq> zcmy?h-xxAg{c`i2KQKTxAigilA%w_d8a$Xve94OpLq_XMKie8O4+&t{q{t7 zcJ4=%p(>@${SoI%T>4wU6U3GMb;x#x$LUKRmb1Rdbq?0`+?_7g($9ix*N@8Q)I8jQ zCWbeDu3gPvNSc!~y|~G2X1RpC!<>GzIF>n26n#(rrG5_*HO^*?e68mr5Zy` zw86TOc|LQcjxAp)cz&&tbcb55 zem5ljGw1*R1gcj^|IKJ|lk4SwNN}A?;j=&$>bCNng~_vz+c@d55~yBG12-ww_XACl z{iTd|aa5UZv$9ODyE*Z+(C!7|Z{7&|+1p20D? zIbLa&kAuGNBk>U$45!8?D68?L@GkBUA$bG_c^s;ZdW!&4eA*OrFR?YsJG_s6fnUBr zK2R7;Shacd@X5>L(AP4NOegUAb%J!in{6?=Q*KcEL12FL_jnG&HY-SoajZ(<37uOh zC*iaIQF}!3i+rDOXv-wMo(RGHos-FoS4=hY`USh?pjE5BFTb`nu2YNi?1|xFI5;}* zOOHWqs#mqmTnTs>a(W<0bX6PK@1SzE6&df+)M=VQaz8<~LJ?MZ9I;qaxr}EKu@m|p zb{Y!e^r1;Ir*N%uHoATP-39I#RcAViv7-Nki0-L)ikx&DxBsA6c2&0y9-E>m#;fXBn$Ph1{fek?{Rc{-LBAPqE+oxg0mfmjlUb6t7$j zBBF)&25@fAz`vW@QVDr&_*n1l$||b01bex{j$nOo_fvq+vK_bm$-!)w-MR}Z*{>ix znooCEM>*Gtb=J5eb=j~y?y@LK(6q@ZFIlDmf+QK!JonG_jQ=h|CSd5fC6Lex8G&4+ zUeDE-e_7>Jy6IlGZ>Xf)cRwog#g?&5id zVmS(5rZmmMDA(Ph(xIT$u0gU981J$fdq1$t%G(sZVnoQNAbd!I=tXm)D1g3=3|*UK&1=6wI1RT>@fKYjb^*VcKl^3 zI=7*!SkV8XaimXuCyn%vyLo_f%i-Z51qqIOV}sm|9DfzZFP&g%t1BY%{LLItM&0R) z^IdjFijSOZ)OqF+3xs9hq;=9m4rt)R#)DqMRmv4Xf zsh-vUHX2HIip`a*JMer9JF7=#5*ln|$kA5P%X=JPa&S=n&Y8_T(IfJvD6w&sduEX$ z@6&yYX>U0R%LcDpS!?-CKc8N+W55X2`W;zO+VvDJx{_%qQT zGcW%YGyK&C<)r`Cb2{B=I0`=72k7vn?)FSWldI8!@WUWw6Gx7;ZbegUG>N>D%8~Erl04$?~B+?s40Qh3AfM1gEPR zPOL+NU&SP^?X4}a4I$Wu{l|Mg!^?P1?K$MB`SSBpZ8_e6Qqa|2^ z2@>p6&Bq}}xUBIw@BdAK$eyxly(VF5j8m`M(@NTv63x+NCALVJ>X2w4Ix$r0DsI|u ziWFv#^dNRqSD!oRCs)1aOq(#|owSP@z&;NiVjpeJo3AR>H_@HN16H=Vx6Vh=5Z#gQ zyg0SB?6o1XmqL(o!iSs(K$4gu4@(w5j5J#;gDhs;TEbQAa5S3p zNk4c2=c0%QzJ@YDy2T5r(re!4{c=Sq>8=~(I;bZ>x z;`S>Nm#dw$t!A+Uy8kAdgz`Ve+y5ItrU%1_XD4aSD4Nox$|VtNETH_6h)N}<(PnxE% z;=&SNq%V4Qoq9NIgR9#7E+=(S4EXLkSQ1bxhr(fWdMo=@x^MJ_Kf&mYe>w*dZl>?!#Ic-^De`q z4)U5gM=p(c^$Z`q&rn}-n6fQqehjfFaFmC$vT)pvIHA{TIQ`ZDA1V{PrXz_4H}r4$ z`AHvJ4Znp})dilZ4zqSST%|LLYxmM%C~*GSAFHJ;P*R@@!OJ^GEZ!uuI8d+g(C5wu zqX0EQBOhS6)!sa(@<663N-d=}D~Y%P|EX)E@j*RsjNtGa@eA@Mq2Fh)FIX|Rsg|?}oD|gao4o%)bX?xufTP;Vp@N z|2@;qF#x3X;$E)@%uE>CB+2Tf=U^kfANxfmFM65r#Kq8%eu$)M_*0@ymOlDG7>-G_ z)os*ba$E|{9iK~i_+da`K%Y$&iT0EbSWSCM^6RCz?$;&r-us*TrJ4Y^(&HVq9nv0t zgzIl9U>=G0Ym#ziYi!I^Kov^R_jVs-#NX~-eG|@I?Z`RhO(5Yx{%BstplP|Ryppfgx~(&^?vWV zN+X{Jy77fLpA3zyeDTP>8bR-dzk!dHCxo-7p0*Yea*$H~5lPUxx1){Iw+O0S%#$mBnAmOV%PfaPatlp<~R6=33k z;2iRBnuXUcC^eSXcn!R6!N44wp^M1Q>C5Hhxl!<^>UUW~`Aq$T(%B%6Z8l)#O=wg)e z>^kL+oC2m7wSzRql^L?Nwe?#DyZC!Z15S%k?jlwYdn))a`4ec1a@ z$^S+6yrjOHXPhp{mufY}N>0Q{0SSMJ{i`_r@sYV&kRr zt@$6Dk%P%4$FkG{5x>vZn{;SQ+Ah6%kBwRi8K~d%`GdN*>Nbd(nYY`|IR#FFxLpzD zsind_DlJanJ9#vleg3!h_)`64s1wbej#6Gm}^KhAyeEcCGy=B3+pM;*-80V?-l(% z?ykd7KJ{s~a-%HU&RI&UU0-Upwm(m4Z6|A(9opa0?6yI1-w?9SF6&XRx2ZV@PTZ+o zFyN!WyRKF81>FkSarQ+$Igv>@sC_2DwV7l}aiP<^y=&wBV;B@3M)RVZ+Ry#xot7yk zqFMtEi~L8Q^Zy6&;Z*+;?1{U@tg)RYEi+Y%EW9FW`=HEMV@N_1e26o;g9e}nO3}V} zRX`Zmv;1XG^>RlzM9ZnFu9sS5e|edx5+iy~6BCnnV*YG;=QS1-Ykqf6&PX+DPU>YB znrUPCLYLXFM_O+RjF)=l>T5V;srZCMC6iLhV?zXWK-#lF=I@)R3)qt`phzk5xY59Ap_ilt9{{0Kgiex&ZJiuo(2z&dQSg{7XWp3z3g_5jZqekEEl;79FV zDH%mQV-gkbj1WG>IhRVfC2;3km=kc)L9wl=)CC z6$)JbZ_Cb<3C|n^BiCy27_HI-3<>Az&rflkY%7v>he6}NJ+AR5PQo>LlSX|9Ts#sY?K#O|MALEs_sNz6`^ZCas5-s9Q zR9^_wUbS42*hGeM%GZK4yBDolWvZ>wqoc5=dDd9s)Kj+=@KRIk>&qkTj&QNJFtQRM z$&=9|1lX-&Elw!=_jRnX)q+c ztXX41=6TiP@_vu=bynYaq}baCEojLlZRHfmEKEA&??}Pff~Tnoihi*_>A;>OF|;uW z1v?U(H_LOyO3+;l7)0EK+FdwLSvjX8r0}^l+j*FO6*y)0Jo_|@@3BD)v5|qw;@)k z^2Xf3JdArALOZeI@pnfvro9&dn-HcCivNp?t$XqmL?6kfw{Poeb^TcH!oC=>A(xoX zDsL}bVy%8%mco(p_?UMLP)#uda(txxCI%IY&NWmMDiE2DFePD z@p7NiZo{P0E#Tl9&(X)Zuat1u%N9~if& zwNecop`@>|_^s{JMnXI&S)`T>K0?YOmI;ThUAD9bq*(@Q*7c!=)yeR^@S_Z)%fq3L z(6W}@(oT3DMw^9Y5{t<1i~7~r8%K(`ESx&f2199;kyM?InN@wRF!iQTxAZRv{JaP2 zQ0c)SKsBpTf_cls01qPF>~5MiYmoKoKt4Q&^BvsXy8+MI$;u*yHs}{$kvPZ0X-Afi zC-?PNHndyY3gJN!?fxE?yIIXon{06gc$m>o{>{_=40G+&=G3mhx%0_ZD|jUToyC0t zhru^S${y_>AFaZFqUtWZUyugK=Ch25zgD&@am+vEV!K@fJko;lEa+37q!hekTo3P) zy?^JL)}}Sa&(N&A*7A_-aF)K$prv-bb*i|+vYe8TFGEm@k%&yj-tP{4}gCu z$!Te^xvVF=8Yzx={JfZ#Xt0oJ@8ca_*1m+!SxEYQdg6i+ejy}r@q*xm57xzfqj5k5 zMhk^oA;-#*#i~;}%^i7p{{r|dfvrI5aOsFv+2HbWp{%{PZgd_*0TeutJ6!e=!hfCV zF0?w2d>Q4qYq=@{H}kXTCuJEswLUB?w5ngS?iv+;2G8tO6m&YjBV-PRqK;cKJ|(36 zH&27mQ^8v{q2VuI=SY)ZKh8GnGcZEDr_|qya!2Y5*Lhsk_u?U4L@cznzb(fRmJqZ2 zZNK?>FO;>f)X4WmtH9)|mrDk;NnacTA75=Gkohd0t z5mOUA?KNU;HvofU&c;VWYzxacsLx6o#q%YAffHP*PGv>dYS?+K!7dMf?=3bY+3szo zCoQjyr00szQIn00?kjc87*-Zf-Ax@Z2Zu)-U-z0|@~po#PtB|+*dXcAgF13XpU9n6 zy-VoVMwTw@N@NQL{UxEvKi>R;lhpa@Ry|xXDMCF=F2*IC?(V}k6xo0C90xtg4BH5K zEzJKjJGwY^n>sO7_=d}i?0c%s*9P;NUit9b$j?5-LLpP*m4>W7@AH7N5)Yd7k*U6i z=zBvq8o`{$x>r&sc8|gV6GZf4YdrwhtP|W7X1*=ci@SHq?iyRoiATjFfMCAr7I4{F z&;AODn-7QDU9PpHZlj_57TiIaMueq>aV~RU(@ro*(mEln^LK`q=bAiMD0Fc<8hoez?_=PN(+bbj6~ zVzua%)FU&jDrLAZ~`&of#)rU zQQY)l+zHj4S&!sf%EZ(U2MWCuMf!ISTvYFZ6HLC#t-)_GrP0I#m14XGHfrw*Tc`Rsei+#=3W}}? zIqTA%fS-F0Y&g_y$gSTB_W!1#X*y1`gyZ;LGv&^dxqqx!RY-NU8p_JZ zto2^F%Pn|I+6%wx8Peh8Twx-2uhs^v^Zw4W&8Kg6w3=MA&Y^dQX`MqYF!u;&Lj5s% z)-TuQO=XoU_bu?VYFacuc%Di@uQz=Lb#DLW2C`(`tA4)jy&Oyhb{XQ74y3oimRkI` z4()W*E5Y%C}p*|49*wHPr{7B2@}T_r0#Mo33)=`kphPVzdG@_gD^Q zn&N_KWtjEkO%N(a=EQKTM7cpdGIe+0u2~l`bJc8KGdN}x3a@mYb(zdM!S^QXn8Bgb zz2)0jZX3I1V)0UM+oWL-H4GSMDP=yT*0DSd7g#(E>ilRYdxV7=&J7(?SEKT|xiwV3 zk;_C6ZmDiL|M}!+J&6}*jz5{7HeDkoUBWpEiGx+=9VrpZ$Cr{jV68suj^b(9jjq9ziy=M1D*7M^F0vkOj_v# zKF5c%Bct*ZQ+Jvk<~xZ1V>DF%0E*bupP(U9FG$&juu5N2R**^jgHDU6Hjjf+%}37u zL5;?_N*=B{r(=-euy;a*rcT&VMm_!QAo>y@Z%m$_O0R=B+@Q`W(XJ{3er#o+cAB}V z)2cBOc720e>XrZCEDWp_UPV1OdzSB`4m|Q|zOZRr1+tK^yN={fx(rbMuG|&~c^{>^ z2tl4BXK}NOq8jTn66yenFt$_YQGVnDIt z@nO$tD8KyFtUl5EIYx%SxDC_}1lbJor84DInRF~X=%Yb<8;p;VLTu@tGZdDag2d6U z$RFQK;Dx%m{Zmm=w>g!hlSA!SYw#6X2g#NPXsgtLEX!xfE2Y=$bZEJLKhR-0;ueC- zorz108*3Foo@Y)kG@YpRV5>f*bJ9CJ&q2BFt9 zk-*Pj$8}wAbbW@GHRS6StbXt$^;y4$pY;GdKGGlS2fz=!ZPX&E4Tp4V%FhFQ7M9>9 zeDe2zL#=`&{H_Qqb~%G+s1<1i=d`%73BjhFkhA?*IE-bx1zH_Sx#Xa0zxNhbY9)Z3 zw(EbeT|@x^-T z0V#e5dhH>5kJuY>c}3=kc<%h#^B;N$E@5$XSx&~qZe zdw7o~c$G_J;9fZiWY;D%=+sqWhjE@nc^ShVqM-Zl1^1LtBU$vO%337aW<1HRbK-2* zHMBfJVE3t3%Wm05Jql;tesd-Z}-nJ@27b$Wok!mm+u=B z%HK<^C8!UbH9UQ48$^n9e@Fm7Pr!CwnOAMa_rjyU)$f6ev6GE9WHz|VQ)P)StCD`( zquTOGCM0tg&exwsj_B&KCEj}jB5iz_Bvo1BS&#N3X)l-sy3#7Y0|=CIbH$<6On$az zU)FUEDp}B1uX7Hp#Ev1+&mi!&OH{2xxa1`^4+Bt5u;a*`r;&E}<3(+P&l5U0G;yY1 zL(@I_MJtm*+783YPFTnVNnh|1zifbighdWK*0|fLjql~A4hn~@X;5TS`HnyiDUw-c z4XGc8aKq~}?%k>A0pUUC5sRBHHDb3Rhx6AGF>{3-T>mENKqMpWFd|Z~i()j_!IG`Y z_X08vKZ}HmAdF#N2gz(tBdiYb=YUACkO*N}JemuJ#yS@KTV%=REDDvc{ts_XgzZo( zkeY55v=wi-;O7Vf+bU!ufmt50CLJ*ORp=a9sNd^fR;&=I#3{njBgUOS7GJhxCgcy` z)!$AA1u=i!yG#hA;`5aPuD_B;nB(elUu>_~?#!k_T75nkEk5nQ-(_=9ZlMJlG!tA_ zojVuWs`d*uXub+5yX`Sor25zeI-jfr@|TmW=XU78c{{3VlQe=({B! z?gR<^95%48WeK-LSv^zSjs+F@Q{Jn#+BBEDnUoPQ81*pwugoRQ9ji*No|Bb&a@xub z)p5jsaUk9FCHsMXhRK;|r2^NI7r0J^tfG(A{C+BMC?)v#1~?jod0S8rZq<6^;TG^~ z`0G#U7(THfw102YzEPs0Sz+5W$U``u$ZSIkmIKT%skY(G8L!Zfj0)pFIKTJCgr0^_ zZMZ)-_yMT)e%#N;JCX4DKZLz?Skv+MHm-;?5|SdVB2psKC9R}@bb~ab8wMgNDUGDG zG^1;yb99Z|Xi#GGfDz9=-|z4F@Au*Pce{37d%bqv=iKMM?{hXBa9tWIkz zZ)<=a)=mdM*9#o9O zyaOD{f#rAGVYPbZ7sBF{{oLT5ZcYL(XhNaY?zfsb`ea%Q+>y)tIUDT^j|&p2P?)U> z959TGKVzpwsQX=2GxFK8FH7zq4I228h2qsHluLdPSc8o|>I5Dq4?X z;0p-Q9pqC%U_{(tqhuS&%_2~cWcy%j8HDcg zI)sdcn)pr|zwgen(7qbNYOT6?gJ8urxm*E46q@A@9ZlG-RMmQZXhNLO;=6v$HFx=q z6@x-0KiqOY4?!?HdgA)WJ!9em04LCeKsM_F8r9y0g8fTLkg3x(TKJjd)CxZIizCS;6c?%vWSux`lm_>WOVL6M>##Sou}G}27O0eX17O}{?LM)(?fLk zquQ8t%XV5FsG4oWca&2jL4(NLzl@}5Q7N)BX`d9v$I(OVvEKnzo%r9Ii?wm#`kfyA z^;BHq#0B!K){$+j-{w^QYybP=kQZ;otjy=Lk)lpc-<8@5xM9iN8q_ z7Z5cHQkuSDF3tTMmE@s4!y!Jb!Z>F@_5fljdFtR7l*-|a8=b!#}OT*z_ZFIvw}5pPEz1tK67^Z4zZ-Iw+9s)1=#NMxy6-0K&y`9 z+oSSr%5`2vn5~!y%cjn07-W{)AyGe9Z{M&HI-a!Q3m!Z^cH=3i%myVN2b0}K&k?RR z-mTDA>Uu=!HVVBRRdk&n!WI6IDS*M!idyrdw9;GZ61eP($q+}!)UEJEp}WN)h1o@k z(_blZeFJeTjV(=;8&`-8l0GT83z2b?as4BfThG@0Y|9LXx$U^Clo1M_lj%9dd#>&w z{tB<{4XBjnLjKB7YqaR~X#TC>T3*RZf~VxqMVyj`MV|y)BmUO9KCo%m;Fh0vYHi@~ zWzS^F8~^U1Q?k>rOLRc@8Dcv+Jt>+iEP{x0QJbiY0ypFRJr;uZA4R8x?3 zL$RnET_Z4BgG?AFy^5_NpRPP!Mj^*2C#X3j-S?6uDsEcyR+`ZL|;iHjxK`1M_6#B|^8D_AZGp7I{#oBZRmqq(i#(BBjv zZ%a)c^DYJ%=KXtEq-N5vTxAlsY^?1uCj9z%G*_PSo;uO`a{N?CmvMZp8I;1y%D&r; zK2jKh$(U*!pG@Yi`PYk!Y#XvBl4$il@<3N3)aYs*+7-Fw|6v+??{=rSn$nZ^%y~J4 z(43%e;EZa^3SbV;0YwC)$&2aGTxpxy2e>tsf5(6j&G0!CWcl+k&ogY55&ZZVR!tbv z@*<=tXxHRm)t+%ohi*UwkXD_D+Mq9Ok)&26Uq_l;I+JYH=1^zPH}SwkrWKy5=HemOuatPJ&%*A1XA4^8Us{kP#1 zk6~;4B4kcOBd*q0i}mzcmjPcr_A3{M>`cn*LLU%Lc>S}{e0KuiebU#hXi_fHr-(oz z_FBV1rCN_omS>yW_(6;_Da#0l=`-I#PZ+=jr2uszFY|VZfwwI0@IPqfG09QB3cS1N zZo8X1bdgDiOb)Xj8>5E!MDD@DqGaRN1^FZ`XMfp=X7GhkmXv-I@1GIM^F0aFr5D6| zx=rPNE0KH{bl2w7O5?lgQUc=Qksw*-Q9rCscv|MyxGQ*)ZHx!mVwEP0wO__Pv68Ja zAn&M0M2<&Mf;*7{isqHoyWxYqHe|n)iNK#~KiL&aE&0duViCHdubm+6 z-rH&-7Yd$7a243=TuGz@irNEw{;Af4o~XmOTT>|qNdwgT`YTU8loMEAzfvFQaZ`H>V(~HoglXg2No57dHy$6 z6!SV`!UKkhScxaI+f8R~^z4Y?Tfnh*R{Iw!;@&bA%LhbDR6ztLbMYn~tkz^CYRn)DN2q+%;to zV^phq8|cWey?qGvg2UQGZmu3fsW>pUj^tAsxG-p6iENjp4A&=Q%(m$?nQWq8edm$= z904Z%1Nt|U&1{$Lr_QI$LE$^GeULHkI+)%nT0XwOKxbg1CTt3(Gz&P{&c|CbiSCF( zJ*VR}qzNL?mZp1RY#Q0dKty#PU}Jj-{33ca$8Y@y@L@d}oEIwatj#(@=zRb7lSTcU zD&8*1kvjBmcfc_AZ;FMB;Vv3Wletwc@UerD_T`Yk9|b*1wypzxiMW~;>YNI^cG7SA z=EAMu6K|ZlivjFmMW^QFFB>Df!uu$TEV<|X=zJ!ln|3}LX>YJh20N4TG_ArD`l4x4 zZ>0I%JwBIik@wHx{$H?e0@k7S%JPR(LH?l_4819R- zqz4R90^;6hIVYvTh?I;w-N4a)rzw{@fOH#v(X$(w9GQ6SVk`n)*?pxkvuGESxo!fD zu}|9Q14}{GXdD+<1cc@MV57D-(gZ{3US-n6$Bw1MDRnq*FMIeck#5dz?o>s$VrLl z9tLZ_+y;{g@6YynqSsCZu@6$eW*aXaApNX?4&q*Y*R%md+D@RQw|-{6dxU!Qh2? zf#w--C1@s7`!pHF&QLvxDP_F`h(t0VsGd*zj-`f zvNOC+);;(vKvHvcg=S*%FmEwoi@NXu4)x>L zW3rB~WoNzCB_tQMAQ>b24enH(Iw7+PdcO#c$dQ+!c&$B@>}(sp!$R-i3lWfnsEzzZ z>UAK*+nTGvLxN#U{!|>=Fn0b~+t-uVzVO(oY-{tXn*0ils#=EesA-pb8bzgaJT42p zyM|#PNK}I5+xr4d;Xz!GwPamv4l@j^q$1lop=BJn^@nkgIK+;cC5w>Zzlh%p< z9AxH&nR15B@)$0LL-I9JeVn@SAd4|s0-<93`beg`8Bo0u!xiOpg|G?Q|4ueL+HSuk zo$z4`97|m;^5NwA50IU^56BvjImbC7Ef&(pfM4CV^@yqqK5I-6Xnxyenv=p$+sO&z zC!4aemGUlMOx98H>^~{>!>Gcxor)%uz{VS@G0$YD*9n%vHAh9*Oq*guXI*-NKkD&A zhUTXiUpJ`9vB$)rm7bKOr$82R987ivv4&kSOf+_`RJCUak3wHF87}dugh%J8bvA$)ot~XN7lsXZ`6L$kt(7-qN9<* zpZ6uH{wHqj5URhL$3oD!ZU65(9OhLK5cCbaTs3SZs(Dz{Uy|eS_6(M@Mc=$d-*@tT z^gM4sl#ZVMKD5C15SZjw^4l4ob!0VxCTG4e0FY$7TsnZu@}azmG8$SmkShTh)PPiX zQFHuC)vCou<3=TxKw6Y8lrZqGv1v3^N!eLJoC@A z+}rl8d+(T=a*#PXd}j1{NcGL)4ly|@-#AUv;h&r>mo#TDE{7%aRi*Zr`~{|#EIV4m zRiY@{IWFTl-x}KMdvac`u4K*Y>P7iyL-6n~bF;tF*|kA2yimPgd?vGQBR+&zVLnf9 z?e>h3<#3I@ZDgLy{$sqCUUsznJ$=w1-cJ6qfke*?uO#izqKfraFBWQCt4ek*J!9ad zw9|7E(A#(LZ4AJ?6Q08ele=z>iE+!bdYBq#t$yCe7p-7+LykpiGJ;MjbWYkWz+HeU z)bqJc`Hk`0?|s=L2JkA*u@D#WE?VIJac?F%ei{AP%d3EEN6CQsv`g?T3^*5=avel- zyNd1wLb5o*warHKdS6;^D?(J3uBbQ;KYoe1u3;=F*&AybG&5rr#g8bgv6;my=HWpf zAln1eDx!0;zlI{GMw6IZ;8#YFG)_Dz2La>>s|4_gvLq{EIRFs-us~ad&^s0+R68 z^3tLzbczEunTh_8QmeY)Q3wdmlEMJUwu9_Adp)T)$m3)!h=JZ_R4wK6{gg4xRvd-( zZ}06{zG=11)%fttZV^~Jk-&Bo*H<;?8Cd6Jsw$r5y|0-&mD~9tl$rWvTo7+kO6@D& z*zrR(dXU2m-wz^J$R$6RyD$ZGZI<`z(g-*|#!~mhev}`P?c`w`s^KV&XIU9{_KQ|8 zbB0gwV~$#3S)+~6U!l=2NCm$>v=d^OYS23e8OPZE)nGf^kt6SQg9kif{HjSWqau4m zpx;%KCm}_tSohuffVaI5=-d~)>&fyVN3Oaz^6N%qGURnTSQrF@#LYR5MRS!j9(T@; zHx4zwr;TiT`Obmuzl+ICL0CZ-N9WVHU{IhLjeriGvZXOTrTgjvHYgYe>>4Du!}ID* z*JFBZ9HI=X>7Nf}DFTyu;M*L&p%Gx!!(K%U?8H2#E@{zFTLabrHI@gN24>fs1`fsH z06O0=X}e$cw8NYF>*=knle5Z%(P6g(MaU_kU>$_xI&sA(%88|iqA?r#HyUIo?!(M#+R~ja^>5aQmW0rUVuf8d5`ziJaEEIK&*Hpu!B2Y0f+86@{!{ zM?Ta@*3~5la4l9=7o7{G`bBGr;5W`K&|KWU?tjuOu%31ox!g+-D8ntTZ=t*T0t=oNRuQdus)UdaH$blhGdsiJc`xu<<`gl{&-;=M5cIO z-$&xSdm8%FP?l2q+0&-N@v^@xy^R5)@G09Fdfy;?mKP0PegEM5Er>O}e>P5;u}?Z{ z)i9nQ%k~?x2M;8$KgmjcA+U(}%htU-`5vM4eMZ+c$cXGE?6-6^ylA!tiKO$3%Y~}S zvF2^9%}d3_$L%9lbWw$o<;eb!TYvX!==wig} z$C8eWs13j0f#umz@eRpVMJY$G7vMSlOROgbUp(r|jW#)WPLnxq-Oj`!AfoeBw2bAa z7=_O%ej4`~hFQmL(N#|VukJF8>nuPAEA{WwR3F^o-260W8RngH4kYs_&N_;mR{NK9 zh*+kmPYzGd^4Xz;Nd$yW9S^izTPb1uGbLxVnA1XZuj0YtBjdA_>MzMI#9;=MKfb|p zspZqviL}oa!XQS#EBT5c9~)W%W9z_n8zqsf6sp}U{gk5(hnwf0E_HS?+bSp{*FLV)LvmK{TNb# zVYD_$t7!QNK$PI2D@&z(q}vC-=8?0e1o<~P{Q!HaqXN5z&F2RtxEw&WSHIWpUv@ydzFBl z)-r_a*e~;zuXY>hMv{+No6@)=?cLy&Tzb)Q~kc@b~{hdG}AOU zydLN&y-~o{%uE@}^*Hsm-`?J)J{dKRXqy}L;lOx;GZ`5-SvdlASgr(?sVnf8a_QyZ zqHV90f7~17URbG;nW=bB&m#0J%%gD>EK=%61mG*jthUknPo|kJB4-!(H;D+_pw!aH z{5>V23FHQ2p<)s^q(A&8vWj>^83fp zQ0%nJL7naXCEApvuIbsrp2%|bNCRf|JJcC>O{MYN`F7#ECu|`@jtwyjhs}2P$qCm- zNK=&M7V|^vbJ>jX4Qn`_Sc&L^?Y`vE@K5=^n}Ppjap5;~E2gLB@K@WaMKHFQaIjrT z?<`ASBqu|3&!V&-9B7_b>HQaYVH7@+U0UX`_vQRUIWWKE!E7M8nPLx)8 z#Fq~o;yLJ!rN*umxd$I2DqLuurdR8O+~=BTSZjXpSQrXEEL3+r`p!s>mprydBYCSm2i>|6~z{6Hpcvlz>=A&w1W0A_43$R(ovgO~r-O?0!HW*W{lQ z{k26KF~zh+_q!vqE&Yo!f3;!@)Kv@hgcoJ`DTcpq*>|*nBcl+xt#MniSGNHaK8U+# zZy){l#utMVg0ef_k-(YdWwAaNv~lsp2F|-Hw{vmPm6fwUbK%VS6=QG}E5sZu@z|tC z%9Hf%=1lGKMB7;7ZouJzq!KSM8dSs)+5w;@6{GnZi&acAB7 zfVwY{l=6;M%@-wc``v=_9x0U@{%Z|QjyHhw2A+E7i8T$tta@g;?ep`e(`SuAi1ADR zgV=rx)ba7c&BYNk0dvWB`mjZqwJbVTahw?cAkgBd1Z*UVJ-1*fyg! zHop`w!gA(=Ou0gYqDHwTC1gz;kk{A#9!r*5Nhy{}w)n_j4!_b{pB&HCTZ!Hgq`ZHgw z)$SwK{g8=y1Aa)Ih|7EEvY#DnuRf16S&ew>^5P+x-eO1oHq*X-Rc!W}Qf_Gd*;L*< z=^xuU%jlnFuQpg32*HP-+Y1Q{Q9#~FjV$Fy9#bA6)hUcI!vwZ-1I}-*x@wgPz$hS- zm&1H{IX9)(&AeT?H=(ttw(p38(t@KoRr<}*ch_KWV1I|d5Do~|K;#uhQEueK#U>ec zg$t?!N%o)fFAmgymXnoKR9UEJUVA>S@2KSWpAY9Z`j=JZcttS+{IOcPFuwuwg@j}~ zO)hG&k%#5l@q23*sx|zb9Ne#oU^wXBq%VV|^zo^HJOz7c*xCs32iyh1H=m?s$Yqe9 zX<7Z)PaYG9bz zpusIYz4ZW_IS{4Q_AvU-cdrWwdvsFyAF1yXKskR~ng%Jht(8kI;-+=3KJ7$uyeNE}I|74W+9veB-mR z05DicY)I;dqAtiz86r6eN8+_)W*EyluM^Wg>;Zjz_>qSNPtfQ+Ku;5lzQGk zD~%$~J?>$Q+slU8c`*NYT1UU;jfT=I!RjD+^8{_si>!w=c*Eq0xhNcbv#vJp1Z<|iCJ zedVt***pvu_$6ppIFqtD)5c8vl-0qt-q5;o#K-W91{ICDP#qcZ&0Ke;Uv~$@D_Uo9 z%OQ-H>di$-AM**&B?ScI|7ZM)^7W&2LJeJ(zQ^5&Vai1z3-ng|-R*>UC2ZNV5!~7? zO5x+=DeBC0e>qda3t5meQNo6g|4TS>+$a3i_f?YOny4lQ(wRIoF!1zefxxSU7THXv z);N0NJY*RW`b{1(F4$o`IvGsv$d<>`;1!VcPWx8+;_>@TIJ$#s%Bt0tEWG@SJ>R?L z=BoJj`^rqNsXcX>Z=-H}WsFGa=j7vW(iS!s zt2XK!HmOrD&S(k zTMLbH=zY(zglLd(+<**Y39XqyW{9G}&LmIvr`^VXX`A!84Vs2^s2}zS5L4Iv z5%2N`dcTFOzm8D|^664oxiFlOz|$xRIsao;(9#s;oXe=6B9!-sOf(QXa5Mm&bWF}$ z6zxUQpgZA5nu{Wqw7Z5Th>E;EhEMdGzPT5<{lXvi5j*g|n*5Cul(bvnVqZ(ZDm(dU zPPjPin+%Io;d3tgqF=C-#Z82%j|v zigvCNHCqoMhSsUe=?YL`2R%K74|2!+E#Ot%i$0#nDdez`#+Kn zW&59e5&@VP(?hdz@C&OxSCf-4%D|;7W{+XH4Ft9bj$4Wu0Dj1=KiWRdf#qM>|9c>G zu zm)1>UIyPmru}7TzGF7(^oP|6WN46?yK5u}_pq*duOV2BAK^6=SffPHgE5 zmq+7(@v{%DC3RnhcDld5=u4Ngc#Am8d)GGwD#mezkQ$~^#r&i+Pp8Zvztd{)G_I&N zLT2c#wmyCA84k$WB7*j4vi+c%Peq4_t_wRZwU|u=G zXkU(K1X~G9j|)wMzar8Ol1C2XunryJRVM982eTa3KS|W$1;;`w?u+IRScGJs%`AO| zjvPq6xBGLl$;1@%)ZE_hu*pnHy~NMefPOPO)SPN**XQz6#s5&(%gg=xFZPB?tH$%j zMi*-!D@4fLemfBlqJ3)gE4jgd%nze{N_e+d?HxKC2PtsMsM6Ba`XzaDC@beGqpWuZ?1B=*4U?%LlzGM>&0i;gP0aQf|5l)Lvz?s?EpNyPLb!C>4A6(xt z9G|^Ds-E;>+pJw8BaOglBHB9?Sq<DnkaEUC8BojutVQC zy%2MUzJ4=s1(;ZonwcAc|IdNaS>^ISQp#JSrd-keNn2 zXvMjv2btu-7ZcXsmk%eyOvxjhFvmw^{qOMw{+2%ifkaBr!o_0^erVA2Z@hjVry)@0 z|1unxETCe?UT0nUu(#AO)5Fg?!}f-5YCZx{R?Y<3Bs;0qc?Fi(O8?|m$u>x>NkbGa zZEp2UzU4&qa#rZehe;%nhy!Q)q5~{CEZQjGYrEDh^S6stwTWN~XQw@E99g5DUWLad z;xb>dT`y*9H8_c=5QL9wrE7DvIyVFzB%tpOOvZSY9UFJGFM^$M`~L;r{{5y);c+gWky^>OX1szHwj-8!y7eildBMvmP z!y@};S;o2-nk2R@1nuSN!Jb-G7CV^XT z%APpa_*;~fh4!W@VX8E~FynN{DU$=zEd)5N7khp*DJ^ezz&#ERFXmk!4t7#%xdOP+}c0l~?VU z=iw8UbI5?G;nC4!#Iq3)#K!kip!K8ao2#=WfZU$Za%Za##sTLp{8#zj z_T8C1h<6q1sNbRQDs>3-s_VjnDM#C29e3T7{8KO^9CLWRS_g}jz$`_PUC)Wx%P!ph z=bef`K!|0#)Qkg?lgBo62R#)5`A<`cq59%Q;Db>l+BaRdHS22+Qi&zk05xu@qq?wn z+-GO0{$Jl$EfOYC`lG+SO*Hb%N2E|&ud;+04XW5w-L;@=^u4Uf{Nq?IAVb7ASDx<- z#8}w^?X8_eKFKfWd@XR(>VDCc6!vx?6S0ysA(76dJtisH?ulvL51{mI8L2Eth1TFS zp8sz6!`M9(vGTqUDIH4-vzCV@R-J{WR8pZsYQk!v$2ej8NINTQ?9R>2Rh|NOvNz$O)wsFTYn6+C^ePPA zK^JwOnVGa{)$Whz^@-2E0`1X#KhI4t)|91h3I9%SKPo7V-FTbClwHen!Gdj`jyhKT z(U?{NEaquI8Ar{vQko+WvsPM2XIcAt^QGIBDmkO9seLmik)gOcRJg)x$Hn#0} zaxo`vE{BZi8zGj77gPuZ++Qwh@s)jc0Dw)jB9@a9H%KMQI8UOYfy)y|h=|`C!ra6i zeEIpX)nl6J_ka303{2Q3eN*1>Lw(fbhO}^NckzsMVTu^CpP{3}0+;%1=;8P4DpJIgKpVa2o55i;pE(pnCe5_*R4rSf=YS@GyR*n90G}Kj zajm^^dXHlMaRrP=)YU5+^ZVwt~%C65Gd zyboxN+CZExSAWQts4(}pxR^=C1MY4;ZL_s5BZ<%i(820jS(o;lty@*nhsph{EE`$B(IL7f8K{%3hlCF}Fv?RH)mOvUn ziGBd)uS%xMK}LXe6WjPOqr1%Z(!t16=uCV(--wa?(ER1|N)bmp@cnNO00j6876QEBraD z^y2x-zcrP^_9IzcCg@KP@?&|=1jem#aSV|?A1;X2*TC@-0^!#JN&(JjAGIwYU>pxtga2#vhE z^+Lf+?*2hO>~P&3frmmcIrcF}aKe9gm|+l05}pu9PLXzX@~joFS>SYQaUybG4@ydTGgkd0$8!87#yNJXmG0%SY39~aG zWwd>ijV!=Gu=G~hy;90w?3MQEZF~FO-`HYJ)Y)#TV{PYRN|j&cn3Mcr`@!pI)q88{ z2Q2Z2=oJG6vF2S9}b?`2|={63Zg`KW>LuXu!s#_@YcC5wTa^WG5 z{womd-e4C*uqLtwI^ve^+-&FMEC&ScK!9lTMkh#DsquBnp~T9z2@ggFLUHxZ-^&66 z@RSqOaDu#+IIJh8sp!r;+#=D~6HL;?k8MM;bmB^bJ6}pyVK-kroOs1^YHp+a_R&VE zp5;fyDe)*JC5>ap604#7GH*U9`Y~l|$(q_vn(ThP4L(r+vJ<&Gt^=`X)+h!izO)Q@ zYtq}194Cbnh;dSXoIu%;P~C%|lglM=TiZaZFUj(Wqje8_*>kai;WUR}WIKezLn5oy z`p4aMG;b`0&-Qu&+kIf*X9oqA_V}mA5iK<`RZ&((b@A7F~bl*LzJKJa~y12=C0k7@x4G0hbojGA42GMA^ zb*Aur=i`(%&Bo&!<50y6(QFcGYCyhXuXC8lpLQgAKDXI}8c(>-ANUvuZkrQJ*+16$ zIs|op zp)Z;a8fr?t*vygn5nz&C+)KY`wKVo?_}9OepWsNppMYPW8$RuQ^JM31)HfEVfmr_g zYa<6~^%!W=Z?1Hnm|T2afSwBw+)>sCj?C8*?7B`MqhF1k1Nu|WyjA5aUh?(1GLB9DBs0 zWTL2tSBPKSLpI$d*d)F|JwVXxa(B8 z%vQdW54R8t(VD%Fs=}0t-&r3nkLsdU9sED_owq_VfpDwXjwqLF5*D;ayMuyCdkite z+1oe%YT*f5d}Bv2Hi`cwkrp4#mRL|C2Ur+?iE!kQ_;5k_yT3a8=4!u-|ppDD?XS$0Uvf!9%-&<)~~ zUn|FMQy~4P+_ew7uW1Atc$WGWh0wryjp&VQw8&J|U1O-#>boq!DQ zDwGQKGJoQU6dRu#sF|*LduD7U`Y<}#9z2zqynaNsq6jB(2c^BaFF7nd4Wa@USZVG; zNBDH<3Ac^w273qVH_c}kNb@PIZBQZkxg&%N3q{<*e*5+kBz9w;X*|F@Aa1pX(snA! z^jRAkIQZJ-a{$68>ae>y@ATjGPk_;n4Yw z1snuzs79NV^J(^Ks>8aO!M=I>R!gd8nVNB32H78vj#8svKO@19?i4fIRD?ZWPPe+x zCqpLqf0hDV_74`C8{V$MSr?6V3G@AXo>_%^9WS7c1A1r+ge_FPagikRMYIO$Ccd1c zYm*c^h`Uk?(t7*u{d5cc^2B?wUNm{yi$vNWulHYjI}EzZ9zpe@AQqJ>XnUZYW`feR z;6ZokqM-L_XKQFVM(r= z%K^KpIqW_>&+f9BU{-0EIqO9+igh)>yPLhGYGaJssbpH;LKc>gwDWYG{w0K`v6{Vx z=f@22_Ob9E$W>r}LEu#Gk!Z=XtHq7QTvLKqH0gb-!Z1N^MON*rlktzzpt*(Z&{wZ1 z0G4Ji!?OUOFqlkPqV>Ea+ZSr)btO5H5sjg3Tu3|evY|w&mWMAGCb}7SU@qUbj*693 zFG&&lo0cvvNXI>Fe^Ed*r|#>eHO|jhi|K#)%&y=XULw=u3GG`8^f={(85`5 z`G)wQ(KpIA^vOKB|HX+xcvsI)UC3V2&7)p7-}?nWd;vY!d$NE z>!vu-Te4RK=@XSni+`ecorn1{UBt_^NQYyRkB;`TvXF2E&gQEkpfDqwg?G3LsV|%I z*%Rw;T*ZhM6P82svaL9Y)K={4C!a#wOOCZdjcgV@Fs72#?=Bzj`@$D{*yR+kR|CD; zm6aT(GWwN>hJKIwO8j~|P0_`Po1FmR)8VraKtvesg^N9nf_-jsgR%1{J9ceXvRs7T zs+DliD2249L;us#r`2E(jb_w8ZeoAFiNcb&XCW>YLX0q+DLB*v_qH!@(mEzfibfRQ z14f+It6(x1CEPY7Fd{itObGAZtz_e#Q-Bl=+*!~w`$?h>MUwVc9vRdZ5A14w{dFJU z|F}YcojI_j$kpvl((2bgCt1n*X2Erzi*?H(Q#ab*VdF)>@*9}*p%{G)yWWoCbwNuZ5Ae{41)5#?Tx7cb142cWF(S$JwJxK=LrqIY# zxAK@a#Lh1lXQ&+7V8boE-tPL;zH;yv#M(q??gC+U#hM{E`P$z1>=OJBryj(MCxOU!m4=o91Z{rJgZ_Rcd#X`@z_Xn4*TpJQpme5>+{tfKPOYpL)U+V_+k z{EPY~&z&OydwwJy4$}^BQ^McPP#2O<+OBUG-$SWJcvwzx0^Q$8*kacFrHX(oA7WkH z%msd)^nLWNz7$bzIM~|JYle4U^e{bCVDjhOx2xQ6fEL~|3ua!a?J>g0pbdrMHWMU! zYcgmJHS51ut}j7}Rm zxb}&9j|hwqj;`<-!cc9S8UY7_e1cUayzIxLFI6P(JHo{X9?^m+w2dy>BC+~l=H1vR z7|u+$m~vjg6|B_o)-mVPYr2wYAqnZhlS$7+p9D-e(c@srzLD;z8K8w%tgO@uITj;1DF)N%BZ8Oi8hab~2zCdE82%jwSkF_(b z4sZ82)4gZpChHe#!}I`|S?3)RNSSyY+N1vmcgp*a!(@$)q;NaxSKkyNAJyP_v9_(! zB85_b<8$j(Mb9BAalR$pz&^`5cAkeD%h6wd3cVoq**4!TyajaGyIh3Dy11a8=@c>Z z^8Y1U(2ObGReH=!$B8(&b|1KIp5aCUt13KSBEeRvP(lM`uVnQS1X?JW*-HTDuz{oHyQOS=#Pq@MM4KTmRXA2!Od zp91+gS?AGr3ZD_Sge#UUuQ>NVX7WkIkNMqPM*Csqh8+tT-1Np{kvvS zC%pMML`<&uE9--e+a*k9L|%XA6TdGons70hykh2}dsn|LxymNOY-AQ>TkCqRl(ZEq zJZk-8Pv3k)F2rSQ+G)u;Hk6?5w)y4gd$cXgCEY`&;b#AH3x{jD8-S){+OaoRzkN$@ zNE$79aLEczq}Ey0YOoeSu5y`7_eCA~RN1nAqf3ve&Gr?Bo;zWj6W*4>6!&2$e>*Jg zYYlwmu?iMI2+qO!7&Hp2noc}Y6qFuiu~^4eULUS==NXq^`KTzN%r-oNlmH(x~q>5V*)>k_{A{aH|=1j6gNcX_A-Q^IRv=+xgIiJW9Y`) zqukVUb}>gF@?160D>bC?=Os!TnhHF~njej5@#lDTlDC&e;7_ z70t~oph9DeGBIbxf0HZ<8yHI-zrd`1@{{-pFT-oo?Il#dI=1*hlWKi5&svabmx+GP z=p2%BrLl3d=S8)BFMG)t_Ti@U0vPGw^?-Wr-_EO-f1~hbL0nA)B&((X$l)M5d}3?H zd3yuN5ZJoy@35Qd%|`5lG`ElIop5=15yEL`GG`nu0qX9}*}2Fa=2a?>0mB}_&R6oi8Aw!ZQVRBEJv#+^L$~i{P!pym` zVZVLmetD6XYBJs=S^iL4askur5Nt*j1)egK^R+F&FXE_g?M8Jf487QNVo@ z^Cq(^2-9;AyyZ{k=?+cvkTfPf${*Fn=X4h>4cY3#n5u}-2?H)m)~uMLvhiH`9p^lg z5UCFEP~8D*M%lfx=cJD(BSx)l{(jy!PETZ_z{YA`$HuNCaf#=8k? z%uUYx%2kvu(r{Q2j{6F}uR?RQ6&lF;SGWAv(JJZhe(edEZvUT|7D)Z>P-}OgIOrlZ z7Jb7z;K9zvko|z^;WIOCgV9h6k`CG`$jl|DbfE#e0YN#3TSyX4YNx}~C5ioGgtI%H z-ZN(&B4KYd9t1DOT@QiH%w55OB8utxIZIRuVovzT`t+5qkgd7 zjceDXsk>!#TZgLj{T6HsR{v33IHJ5WqV{G$U!8J=Zo+ZGM$TUuev--r!1ruo$81oU z0_X$aOmRLWEH!k)G@R7Oxf5dGMGqubbR*)BHKz3fP&H+$ z(7?&FvNL{jcP?&-Q{OMcNW775%NGluRc+{U6PtS)2WhRvy_|J78S?_n%!jx86JCG5 z@U0UnU-Ck%W;t8k^rwTliSWqdRBK z#lhBW&a!Ro7j?v?*7V#(L8w3|dE!uB@p@@QH`O8zMONFz=CY`WNjtNlXliWe{jU`~ z__1W=0=qq(e!6waj%MPQSM8H)562G?>0(GLH6qg24g1clL?s>=K5^S}VWxFdI)oYo-i-Yrv-WQNrK2~B?`Jd?tCw#)LG zR85ASZj13Q-J`ZZ_iE5-w(hMSg?XTLBATw3oE#VK0`@D=%kcm4hM+^)z#Xn)RWC}h z44MAf0sSN9xRX@ckav&c@Kk9y9Lb$Hq~+ved#Q=&5QX`@S8b@xff+__~xPIYl0Pub-!6l4_6lZV8gEZy-t@8LWfiE%Ppo} ziOROsQ$>fLs^;km)#`=9n5~0Jz>xrYGAMG-VxZ(NDefhu9QvK;cvRajL>~TL;&7Gm zGwNUukPW+2u+ZXt^fiP1$iK~_5Savyq>ZekNf)Tr{*f%I_vf9gga%yfIX0k*|Ehv> zR7Cm+m{MGAE3bX1>dTCO^)Nk8OgX4gt^ag(^unDgpgt2s&S;!`gTA)=ooZ<0TGhX_ zUCTZn+aGv(%tMK=KLSyxqqO(^3z#hTnmy*JDd|prBQW-J<#E zfWsRL)h1%vQkvBF-qA^_a;+*}ZDwRe*}T56ADTf|7Dg8ZdiZLHjT!bRKg?bHkz0!O zcwoB_Glh&{L)V3o-FD96hqT$z?gn%@+9uZugZ<)L%#d5|_fmw-wykNk2R77I`0a6? zx!)-n(9t&yk28((K?9)54e@RaotIRoc*Vz>2QqmZK4FFHzM)?lw7xm^9cYzp}LCI5!y}hWZim$%oBMEnM zdbI`=H9;5kst2%Hl+QBULH!=~Cobrs)Ll_vr_JNqZTUsPpnvNoE3KY7ub$7zS6yEf zc5>m0(%@Y#lwC!v#KzugHEmp zp!?&AOLpgOGNvMD^5f-t1bS5Z#F|=vIdR!pja_D|8MuT@u>Q6j)^X2PVG!%z-#;4zz*Tq#4`bk#UXe>TkDFTBgCDAx zrL4<)E=vgg=^O;+>TK5RsW^$($^%Z?(7oEwxU1~wwV1v^Ua5Ure2#;ME%u%LrcF5F z$7kaFv00NZzUs32b02_9Z<1o95Obd@!?0`fUXXtN8-_L<@Z@Qi=x8p__OBjV1sh+a z5@KA~6V0F>EO2{)%3X+68PU?Vn%VKLrl^g4_p8M6>Kc7^p+?wgN1_|lec4?5v@DKl~NEQ@Q+5$&aC$;4W#Upy22m>S{2!T_wVMC{ literal 0 HcmV?d00001 From fd70a7de5fdb0c0f315847f0ed0be7bb283ce9f6 Mon Sep 17 00:00:00 2001 From: Livio Spring Date: Fri, 13 Dec 2024 11:24:14 +0100 Subject: [PATCH 63/64] fix(api): map REST request body in user invite requests (#9054) # Which Problems Are Solved The `CreateInviteCode` and `VerifyInviteCode` methods missed the body mapping. # How the Problems Are Solved Added the mapping. # Additional Changes None # Additional Context Noticed during internal login UI tests using REST --- proto/zitadel/user/v2/user_service.proto | 2 ++ 1 file changed, 2 insertions(+) diff --git a/proto/zitadel/user/v2/user_service.proto b/proto/zitadel/user/v2/user_service.proto index f1b79a9524..83b025bf0a 100644 --- a/proto/zitadel/user/v2/user_service.proto +++ b/proto/zitadel/user/v2/user_service.proto @@ -1091,6 +1091,7 @@ service UserService { rpc CreateInviteCode (CreateInviteCodeRequest) returns (CreateInviteCodeResponse) { option (google.api.http) = { post: "/v2/users/{user_id}/invite_code" + body: "*" }; option (zitadel.protoc_gen_zitadel.v2.options) = { @@ -1140,6 +1141,7 @@ service UserService { rpc VerifyInviteCode (VerifyInviteCodeRequest) returns (VerifyInviteCodeResponse) { option (google.api.http) = { post: "/v2/users/{user_id}/invite_code/verify" + body: "*" }; option (zitadel.protoc_gen_zitadel.v2.options) = { From f20539ef8f4784ff59beea922a7b95ba1d6e0926 Mon Sep 17 00:00:00 2001 From: Livio Spring Date: Fri, 13 Dec 2024 12:37:20 +0100 Subject: [PATCH 64/64] fix(login): make sure first email verification is done before MFA check (#9039) # Which Problems Are Solved During authentication in the login UI, there is a check if the user's MFA is already checked or needs to be setup. In cases where the user was just set up or especially, if the user was just federated without a verified email address, this can lead to the problem, where OTP Email cannot be setup as there's no verified email address. # How the Problems Are Solved - Added a check if there's no verified email address on the user and require a mail verification check before checking for MFA. Note: that if the user had a verified email address, but changed it and has not verified it, they will still be prompted with an MFA check before the email verification. This is make sure, we don't break the existing behavior and the user's authentication is properly checked. # Additional Changes None # Additional Context - closes https://github.com/zitadel/zitadel/issues/9035 --- .../eventsourcing/eventstore/auth_request.go | 9 +++ .../eventstore/auth_request_test.go | 56 +++++++++++++++++-- internal/user/model/user_view.go | 1 + internal/user/repository/view/model/user.go | 2 + internal/user/repository/view/user_by_id.sql | 4 ++ 5 files changed, 66 insertions(+), 6 deletions(-) diff --git a/internal/auth/repository/eventsourcing/eventstore/auth_request.go b/internal/auth/repository/eventsourcing/eventstore/auth_request.go index e098350c07..e35e7b5143 100644 --- a/internal/auth/repository/eventsourcing/eventstore/auth_request.go +++ b/internal/auth/repository/eventsourcing/eventstore/auth_request.go @@ -1081,6 +1081,15 @@ func (repo *AuthRequestRepo) nextSteps(ctx context.Context, request *domain.Auth } } + // If the user never had a verified email, we need to verify it. + // This prevents situations, where OTP email is the only MFA method and no verified email is set. + // If the user had a verified email, but change it and has not yet verified the new one, we'll verify it after we checked the MFA methods. + if user.VerifiedEmail == "" && !user.IsEmailVerified { + return append(steps, &domain.VerifyEMailStep{ + InitPassword: !user.PasswordSet && len(idps.Links) == 0, + }), nil + } + step, ok, err := repo.mfaChecked(userSession, request, user, isInternalLogin && len(request.LinkingUsers) == 0) if err != nil { return nil, err diff --git a/internal/auth/repository/eventsourcing/eventstore/auth_request_test.go b/internal/auth/repository/eventsourcing/eventstore/auth_request_test.go index ccd53e06a1..dda8c54872 100644 --- a/internal/auth/repository/eventsourcing/eventstore/auth_request_test.go +++ b/internal/auth/repository/eventsourcing/eventstore/auth_request_test.go @@ -156,6 +156,7 @@ type mockViewUser struct { PasswordChanged time.Time PasswordChangeRequired bool IsEmailVerified bool + VerifiedEmail string OTPState int32 MFAMaxSetUp int32 MFAInitSkipped time.Time @@ -222,6 +223,7 @@ func (m *mockViewUser) UserByID(context.Context, string, string) (*user_view_mod PasswordSet: m.PasswordSet, PasswordChangeRequired: m.PasswordChangeRequired, IsEmailVerified: m.IsEmailVerified, + VerifiedEmail: m.VerifiedEmail, OTPState: m.OTPState, MFAMaxSetUp: m.MFAMaxSetUp, MFAInitSkipped: m.MFAInitSkipped, @@ -1403,6 +1405,7 @@ func TestAuthRequestRepo_nextSteps(t *testing.T) { PasswordVerification: testNow.Add(-5 * time.Minute), }, userViewProvider: &mockViewUser{ + VerifiedEmail: "verified", PasswordSet: true, PasswordlessTokens: user_view_model.WebAuthNTokens{&user_view_model.WebAuthNView{ID: "id", State: int32(user_model.MFAStateReady)}}, OTPState: int32(user_model.MFAStateReady), @@ -1439,9 +1442,10 @@ func TestAuthRequestRepo_nextSteps(t *testing.T) { PasswordVerification: testNow.Add(-5 * time.Minute), }, userViewProvider: &mockViewUser{ - PasswordSet: true, - OTPState: int32(user_model.MFAStateReady), - MFAMaxSetUp: int32(domain.MFALevelSecondFactor), + VerifiedEmail: "verified", + PasswordSet: true, + OTPState: int32(user_model.MFAStateReady), + MFAMaxSetUp: int32(domain.MFALevelSecondFactor), }, userEventProvider: &mockEventUser{}, orgViewProvider: &mockViewOrg{State: domain.OrgStateActive}, @@ -1469,6 +1473,45 @@ func TestAuthRequestRepo_nextSteps(t *testing.T) { }, { "external user, mfa not verified, mfa check step", + fields{ + userSessionViewProvider: &mockViewUserSession{ + PasswordVerification: testNow.Add(-5 * time.Minute), + ExternalLoginVerification: testNow.Add(-5 * time.Minute), + }, + userViewProvider: &mockViewUser{ + VerifiedEmail: "verified", + PasswordSet: true, + OTPState: int32(user_model.MFAStateReady), + MFAMaxSetUp: int32(domain.MFALevelSecondFactor), + }, + userEventProvider: &mockEventUser{}, + orgViewProvider: &mockViewOrg{State: domain.OrgStateActive}, + lockoutPolicyProvider: &mockLockoutPolicy{ + policy: &query.LockoutPolicy{ + ShowFailures: true, + }, + }, + idpUserLinksProvider: &mockIDPUserLinks{}, + }, + args{ + &domain.AuthRequest{ + UserID: "UserID", + SelectedIDPConfigID: "IDPConfigID", + LoginPolicy: &domain.LoginPolicy{ + AllowUsernamePassword: true, + SecondFactors: []domain.SecondFactorType{domain.SecondFactorTypeTOTP}, + PasswordCheckLifetime: 10 * 24 * time.Hour, + ExternalLoginCheckLifetime: 10 * 24 * time.Hour, + SecondFactorCheckLifetime: 18 * time.Hour, + }, + }, false}, + []domain.NextStep{&domain.MFAVerificationStep{ + MFAProviders: []domain.MFAType{domain.MFATypeTOTP}, + }}, + nil, + }, + { + "external user, mfa not verified, email never verified, email verification step", fields{ userSessionViewProvider: &mockViewUserSession{ PasswordVerification: testNow.Add(-5 * time.Minute), @@ -1500,8 +1543,8 @@ func TestAuthRequestRepo_nextSteps(t *testing.T) { SecondFactorCheckLifetime: 18 * time.Hour, }, }, false}, - []domain.NextStep{&domain.MFAVerificationStep{ - MFAProviders: []domain.MFAType{domain.MFATypeTOTP}, + []domain.NextStep{&domain.VerifyEMailStep{ + InitPassword: false, }}, nil, }, @@ -1573,13 +1616,14 @@ func TestAuthRequestRepo_nextSteps(t *testing.T) { nil, }, { - "email not verified and password change required, mail verification step", + "email not verified (but had before) and password change required, mail verification step", fields{ userSessionViewProvider: &mockViewUserSession{ PasswordVerification: testNow.Add(-5 * time.Minute), SecondFactorVerification: testNow.Add(-5 * time.Minute), }, userViewProvider: &mockViewUser{ + VerifiedEmail: "verified", PasswordSet: true, PasswordChangeRequired: true, MFAMaxSetUp: int32(domain.MFALevelSecondFactor), diff --git a/internal/user/model/user_view.go b/internal/user/model/user_view.go index 6806d78ebd..c5f6c0da2c 100644 --- a/internal/user/model/user_view.go +++ b/internal/user/model/user_view.go @@ -40,6 +40,7 @@ type HumanView struct { Gender Gender Email string IsEmailVerified bool + VerifiedEmail string Phone string IsPhoneVerified bool Country string diff --git a/internal/user/repository/view/model/user.go b/internal/user/repository/view/model/user.go index 62c64edc0c..c7c0248924 100644 --- a/internal/user/repository/view/model/user.go +++ b/internal/user/repository/view/model/user.go @@ -79,6 +79,7 @@ type HumanView struct { AvatarKey string `json:"storeKey" gorm:"column:avatar_key"` Email string `json:"email" gorm:"column:email"` IsEmailVerified bool `json:"-" gorm:"column:is_email_verified"` + VerifiedEmail string `json:"-" gorm:"column:verified_email"` Phone string `json:"phone" gorm:"column:phone"` IsPhoneVerified bool `json:"-" gorm:"column:is_phone_verified"` Country string `json:"country" gorm:"column:country"` @@ -170,6 +171,7 @@ func UserToModel(user *UserView) *model.UserView { Gender: model.Gender(user.Gender), Email: user.Email, IsEmailVerified: user.IsEmailVerified, + VerifiedEmail: user.VerifiedEmail, Phone: user.Phone, IsPhoneVerified: user.IsPhoneVerified, Country: user.Country, diff --git a/internal/user/repository/view/user_by_id.sql b/internal/user/repository/view/user_by_id.sql index 1720ad7998..bd34f77d80 100644 --- a/internal/user/repository/view/user_by_id.sql +++ b/internal/user/repository/view/user_by_id.sql @@ -42,6 +42,7 @@ SELECT , h.gender , h.email , h.is_email_verified + , n.verified_email , h.phone , h.is_phone_verified , (SELECT COALESCE((SELECT state FROM auth_methods WHERE method_type = 1), 0)) AS otp_state @@ -77,6 +78,9 @@ FROM projections.users13 u LEFT JOIN projections.users13_humans h ON u.instance_id = h.instance_id AND u.id = h.user_id + LEFT JOIN projections.users13_notifications n + ON u.instance_id = n.instance_id + AND u.id = n.user_id LEFT JOIN projections.login_names3 l ON u.instance_id = l.instance_id AND u.id = l.user_id