From 6ef0fcb4d6124a576f89b7cc8be16de7092f6b71 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fabienne=20B=C3=BChler?= Date: Tue, 11 Feb 2025 18:55:03 +0100 Subject: [PATCH 001/169] docs: Mark beta features (#9337) # Which Problems Are Solved Currently it is not always obvious if a feature is in beta state, also I don't know where I can add my feedback if I test the feature. # How the Problems Are Solved - Mark beta features with [beta] in sidenav - Add note on feature description where to add feedback --- docs/docs/guides/integrate/login/oidc/webkeys.md | 4 +++- docs/docs/guides/integrate/token-exchange.mdx | 4 +++- docs/docs/self-hosting/manage/cache.md | 4 +++- 3 files changed, 9 insertions(+), 3 deletions(-) diff --git a/docs/docs/guides/integrate/login/oidc/webkeys.md b/docs/docs/guides/integrate/login/oidc/webkeys.md index b849012458..2b414ae7e9 100644 --- a/docs/docs/guides/integrate/login/oidc/webkeys.md +++ b/docs/docs/guides/integrate/login/oidc/webkeys.md @@ -1,6 +1,6 @@ --- title: OpenID Connect and Oauth2 web keys -sidebar_label: Web keys +sidebar_label: Web keys [Beta] --- Web Keys in ZITADEL are used to sign and verify JSON Web Tokens (JWT). @@ -22,6 +22,8 @@ endpoints are called with a JWT access token. :::info Web keys are an [experimental](/docs/support/software-release-cycles-support#beta) feature. Be sure to enable the `web_key` [feature](/docs/apis/resources/feature_service_v2/feature-service-set-instance-features) before using it. + +Test the feature and add improvement or bug reports directly to the [github repository](https://github.com/zitadel/zitadel) or let us know your general feedback in the [discord thread](https://discord.com/channels/927474939156643850/1329100936127320175/threads/1332344892629717075)! ::: ### JSON Web Key diff --git a/docs/docs/guides/integrate/token-exchange.mdx b/docs/docs/guides/integrate/token-exchange.mdx index 00fda4f17b..5cd589cf03 100644 --- a/docs/docs/guides/integrate/token-exchange.mdx +++ b/docs/docs/guides/integrate/token-exchange.mdx @@ -1,6 +1,6 @@ --- title: Impersonation and delegation using Token Exchange -sidebar_label: Token Exchange +sidebar_label: Token Exchange [Beta] --- import TokenExchangeTypes from "../../apis/openidoauth/_token_exchange_types.mdx"; @@ -11,6 +11,8 @@ The Token Exchange grant implements [RFC 8693, OAuth 2.0 Token Exchange](https:/ :::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. + +Test the feature and add improvement or bug reports directly to the [github repository](https://github.com/zitadel/zitadel) or let us know your general feedback in the [discord thread](https://discord.com/channels/927474939156643850/1333448892083208262)! ::: 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 index def2ece633..30619ad283 100644 --- a/docs/docs/self-hosting/manage/cache.md +++ b/docs/docs/self-hosting/manage/cache.md @@ -1,12 +1,14 @@ --- title: Caches -sidebar_label: Caches +sidebar_label: Caches [Beta] --- 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. + +Test the feature and add improvement or bug reports directly to the [github repository](https://github.com/zitadel/zitadel) or let us know your general feedback in the [discord thread](https://discord.com/channels/927474939156643850/1332343909900222506)! ::: ## Configuration From 13f9d2d142a482720591d0af73061029d44e85e6 Mon Sep 17 00:00:00 2001 From: Vlad Zagvozdkin <40493412+sirewix@users.noreply.github.com> Date: Tue, 11 Feb 2025 23:09:50 +0500 Subject: [PATCH 002/169] Add uid to few events (#9332) # Which Problems Are Solved When implementing simple stateless event processor, `the user.grant.changed` bears too little information: just grant id and list of role keys. This makes it impossible to change a users permissions solely based on available role keys and requires to either: - Store a mapping grant id -> user id, making a service stateful - Make an extra call to zitadel to resolve user id by grant id (And it doesn't seem that such an endpoint exists) Same with `user.grant.removed` events. # How the Problems Are Solved Added `userId` field to `user.grant.changed` and `user.grant.removed` events # Additional Changes `user.grant.removed` now has `projectId` and `grantId` as well # Additional Context - Closes #9113 --- internal/command/user_grant.go | 4 ++-- internal/command/user_grant_test.go | 2 ++ internal/repository/usergrant/user_grant.go | 19 +++++++++++-------- 3 files changed, 15 insertions(+), 10 deletions(-) diff --git a/internal/command/user_grant.go b/internal/command/user_grant.go index e17fece115..6bb4a20b0a 100644 --- a/internal/command/user_grant.go +++ b/internal/command/user_grant.go @@ -108,7 +108,7 @@ func (c *Commands) changeUserGrant(ctx context.Context, userGrant *domain.UserGr if cascade { return usergrant.NewUserGrantCascadeChangedEvent(ctx, userGrantAgg, userGrant.RoleKeys), existingUserGrant, nil } - return usergrant.NewUserGrantChangedEvent(ctx, userGrantAgg, userGrant.RoleKeys), existingUserGrant, nil + return usergrant.NewUserGrantChangedEvent(ctx, userGrantAgg, existingUserGrant.UserID, userGrant.RoleKeys), existingUserGrant, nil } func (c *Commands) removeRoleFromUserGrant(ctx context.Context, userGrantID string, roleKeys []string, cascade bool) (_ eventstore.Command, err error) { @@ -141,7 +141,7 @@ func (c *Commands) removeRoleFromUserGrant(ctx context.Context, userGrantID stri return usergrant.NewUserGrantCascadeChangedEvent(ctx, userGrantAgg, existingUserGrant.RoleKeys), nil } - return usergrant.NewUserGrantChangedEvent(ctx, userGrantAgg, existingUserGrant.RoleKeys), nil + return usergrant.NewUserGrantChangedEvent(ctx, userGrantAgg, existingUserGrant.UserID, existingUserGrant.RoleKeys), nil } func (c *Commands) DeactivateUserGrant(ctx context.Context, grantID, resourceOwner string) (objectDetails *domain.ObjectDetails, err error) { diff --git a/internal/command/user_grant_test.go b/internal/command/user_grant_test.go index a5fafde836..dec5903fe8 100644 --- a/internal/command/user_grant_test.go +++ b/internal/command/user_grant_test.go @@ -1073,6 +1073,7 @@ func TestCommandSide_ChangeUserGrant(t *testing.T) { expectPush( usergrant.NewUserGrantChangedEvent(context.Background(), &usergrant.NewAggregate("usergrant1", "org1").Aggregate, + "user1", []string{"rolekey1", "rolekey2"}, ), ), @@ -1167,6 +1168,7 @@ func TestCommandSide_ChangeUserGrant(t *testing.T) { expectPush( usergrant.NewUserGrantChangedEvent(context.Background(), &usergrant.NewAggregate("usergrant1", "org1").Aggregate, + "user1", []string{"rolekey1", "rolekey2"}, ), ), diff --git a/internal/repository/usergrant/user_grant.go b/internal/repository/usergrant/user_grant.go index 4f4b27572f..ee5c8fb8bc 100644 --- a/internal/repository/usergrant/user_grant.go +++ b/internal/repository/usergrant/user_grant.go @@ -85,6 +85,7 @@ func UserGrantAddedEventMapper(event eventstore.Event) (eventstore.Event, error) type UserGrantChangedEvent struct { eventstore.BaseEvent `json:"-"` + UserID string `json:"userId"` RoleKeys []string `json:"roleKeys"` } @@ -99,6 +100,7 @@ func (e *UserGrantChangedEvent) UniqueConstraints() []*eventstore.UniqueConstrai func NewUserGrantChangedEvent( ctx context.Context, aggregate *eventstore.Aggregate, + userID string, roleKeys []string) *UserGrantChangedEvent { return &UserGrantChangedEvent{ BaseEvent: *eventstore.NewBaseEventForPush( @@ -106,6 +108,7 @@ func NewUserGrantChangedEvent( aggregate, UserGrantChangedType, ), + UserID: userID, RoleKeys: roleKeys, } } @@ -165,17 +168,17 @@ func UserGrantCascadeChangedEventMapper(event eventstore.Event) (eventstore.Even type UserGrantRemovedEvent struct { eventstore.BaseEvent `json:"-"` - userID string `json:"-"` - projectID string `json:"-"` - projectGrantID string `json:"-"` + UserID string `json:"userId,omitempty"` + ProjectID string `json:"projectId,omitempty"` + ProjectGrantID string `json:"grantId,omitempty"` } func (e *UserGrantRemovedEvent) Payload() interface{} { - return nil + return e } func (e *UserGrantRemovedEvent) UniqueConstraints() []*eventstore.UniqueConstraint { - return []*eventstore.UniqueConstraint{NewRemoveUserGrantUniqueConstraint(e.Aggregate().ResourceOwner, e.userID, e.projectID, e.projectGrantID)} + return []*eventstore.UniqueConstraint{NewRemoveUserGrantUniqueConstraint(e.Aggregate().ResourceOwner, e.UserID, e.ProjectID, e.ProjectGrantID)} } func NewUserGrantRemovedEvent( @@ -191,9 +194,9 @@ func NewUserGrantRemovedEvent( aggregate, UserGrantRemovedType, ), - userID: userID, - projectID: projectID, - projectGrantID: projectGrantID, + UserID: userID, + ProjectID: projectID, + ProjectGrantID: projectGrantID, } } From 840da5be2d880f1a780878d3bd6920ba54c58dfa Mon Sep 17 00:00:00 2001 From: Stefan Benz <46600784+stebenz@users.noreply.github.com> Date: Tue, 11 Feb 2025 19:45:09 +0100 Subject: [PATCH 003/169] feat: permission check on OIDC and SAML service session API (#9304) # Which Problems Are Solved Through configuration on projects, there can be additional permission checks enabled through an OIDC or SAML flow, which were not included in the OIDC and SAML services. # How the Problems Are Solved Add permission check through the query-side of Zitadel in a singular SQL query, when an OIDC or SAML flow should be linked to a SSO session. That way it is eventual consistent, but will not impact the performance on the eventstore. The permission check is defined in the API, which provides the necessary function to the command side. # Additional Changes Added integration tests for the permission check on OIDC and SAML service for every combination. Corrected session list integration test, to content checks without ordering. Corrected get auth and saml request integration tests, to check for timestamp of creation, not start of test. # Additional Context Closes #9265 --------- Co-authored-by: Livio Spring --- .../oidc/v2/integration_test/oidc_test.go | 430 +++++++++++--- .../oidc/v2/integration_test/server_test.go | 44 ++ internal/api/grpc/oidc/v2/oidc.go | 16 +- .../oidc/v2beta/integration_test/oidc_test.go | 528 ++++++++++++++++-- .../v2beta/integration_test/server_test.go | 44 ++ internal/api/grpc/oidc/v2beta/oidc.go | 16 +- .../api/grpc/saml/v2/integration/saml_test.go | 432 +++++++++++--- .../grpc/saml/v2/integration/server_test.go | 34 ++ internal/api/grpc/saml/v2/saml.go | 18 +- .../session/v2/integration_test/query_test.go | 17 +- .../api/oidc/integration_test/client_test.go | 2 +- .../api/oidc/integration_test/oidc_test.go | 4 +- internal/command/auth_request.go | 9 +- internal/command/auth_request_test.go | 160 +++++- internal/command/main_test.go | 18 + internal/command/saml_request.go | 8 +- internal/command/saml_request_test.go | 141 ++++- internal/domain/permission.go | 4 + internal/integration/client.go | 19 + internal/integration/oidc.go | 27 +- internal/integration/saml.go | 16 +- .../integration_test/telemetry_pusher_test.go | 2 +- internal/query/app.go | 72 +++ .../query/app_oidc_project_permission.sql | 74 +++ .../query/app_saml_project_permission.sql | 74 +++ 25 files changed, 1977 insertions(+), 232 deletions(-) create mode 100644 internal/api/grpc/oidc/v2/integration_test/server_test.go create mode 100644 internal/api/grpc/oidc/v2beta/integration_test/server_test.go create mode 100644 internal/api/grpc/saml/v2/integration/server_test.go create mode 100644 internal/query/app_oidc_project_permission.sql create mode 100644 internal/query/app_saml_project_permission.sql diff --git a/internal/api/grpc/oidc/v2/integration_test/oidc_test.go b/internal/api/grpc/oidc/v2/integration_test/oidc_test.go index b81abc9fd6..d6b5c7b8cf 100644 --- a/internal/api/grpc/oidc/v2/integration_test/oidc_test.go +++ b/internal/api/grpc/oidc/v2/integration_test/oidc_test.go @@ -5,11 +5,11 @@ package oidc_test import ( "context" "net/url" - "os" "regexp" "testing" "time" + "github.com/brianvoe/gofakeit/v6" "github.com/muhlemmer/gu" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -22,92 +22,62 @@ import ( "github.com/zitadel/zitadel/pkg/grpc/session/v2" ) -var ( - CTX context.Context - CTXLoginClient context.Context - Instance *integration.Instance - Client oidc_pb.OIDCServiceClient - loginV2 = &app.LoginVersion{Version: &app.LoginVersion_LoginV2{LoginV2: &app.LoginV2{BaseUri: nil}}} -) - -const ( - redirectURI = "oidcintegrationtest://callback" - redirectURIImplicit = "http://localhost:9999/callback" - logoutRedirectURI = "oidcintegrationtest://logged-out" -) - -func TestMain(m *testing.M) { - os.Exit(func() int { - ctx, cancel := context.WithTimeout(context.Background(), 15*time.Minute) - defer cancel() - - Instance = integration.NewInstance(ctx) - Client = Instance.Client.OIDCv2 - - CTX = Instance.WithAuthorization(ctx, integration.UserTypeOrgOwner) - CTXLoginClient = Instance.WithAuthorization(ctx, integration.UserTypeLogin) - return m.Run() - }()) -} - func TestServer_GetAuthRequest(t *testing.T) { project, err := Instance.CreateProject(CTX) require.NoError(t, err) client, err := Instance.CreateOIDCNativeClient(CTX, redirectURI, logoutRedirectURI, project.GetId(), false) require.NoError(t, err) - now := time.Now() - tests := []struct { - name string - AuthRequestID string - ctx context.Context - want *oidc_pb.GetAuthRequestResponse - wantErr bool + name string + dep func() (time.Time, string, error) + ctx context.Context + want *oidc_pb.GetAuthRequestResponse + wantErr bool }{ { - name: "Not found", - AuthRequestID: "123", - ctx: CTX, - wantErr: true, + name: "Not found", + dep: func() (time.Time, string, error) { + return time.Now(), "123", nil + }, + ctx: CTX, + wantErr: true, }, { name: "success", - AuthRequestID: func() string { - authRequestID, err := Instance.CreateOIDCAuthRequest(CTX, client.GetClientId(), Instance.Users[integration.UserTypeOrgOwner].ID, redirectURI) - require.NoError(t, err) - return authRequestID - }(), + dep: func() (time.Time, string, error) { + return Instance.CreateOIDCAuthRequest(CTX, client.GetClientId(), Instance.Users[integration.UserTypeOrgOwner].ID, redirectURI) + }, ctx: CTX, }, { name: "without login client, no permission", - AuthRequestID: func() string { + dep: func() (time.Time, string, error) { client, err := Instance.CreateOIDCClientLoginVersion(CTX, redirectURI, logoutRedirectURI, project.GetId(), app.OIDCAppType_OIDC_APP_TYPE_NATIVE, app.OIDCAuthMethodType_OIDC_AUTH_METHOD_TYPE_NONE, false, loginV2) require.NoError(t, err) - authRequestID, err := Instance.CreateOIDCAuthRequestWithoutLoginClientHeader(CTX, client.GetClientId(), redirectURI, "") - require.NoError(t, err) - return authRequestID - }(), + return Instance.CreateOIDCAuthRequestWithoutLoginClientHeader(CTX, client.GetClientId(), redirectURI, "") + }, ctx: CTX, wantErr: true, }, { name: "without login client, with permission", - AuthRequestID: func() string { + dep: func() (time.Time, string, error) { client, err := Instance.CreateOIDCClientLoginVersion(CTX, redirectURI, logoutRedirectURI, project.GetId(), app.OIDCAppType_OIDC_APP_TYPE_NATIVE, app.OIDCAuthMethodType_OIDC_AUTH_METHOD_TYPE_NONE, false, loginV2) require.NoError(t, err) - authRequestID, err := Instance.CreateOIDCAuthRequestWithoutLoginClientHeader(CTX, client.GetClientId(), redirectURI, "") - require.NoError(t, err) - return authRequestID - }(), + return Instance.CreateOIDCAuthRequestWithoutLoginClientHeader(CTX, client.GetClientId(), redirectURI, "") + + }, ctx: CTXLoginClient, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { + now, authRequestID, err := tt.dep() + require.NoError(t, err) + got, err := Client.GetAuthRequest(tt.ctx, &oidc_pb.GetAuthRequestRequest{ - AuthRequestId: tt.AuthRequestID, + AuthRequestId: authRequestID, }) if tt.wantErr { require.Error(t, err) @@ -116,7 +86,7 @@ func TestServer_GetAuthRequest(t *testing.T) { require.NoError(t, err) authRequest := got.GetAuthRequest() assert.NotNil(t, authRequest) - assert.Equal(t, tt.AuthRequestID, authRequest.GetId()) + assert.Equal(t, authRequestID, authRequest.GetId()) assert.WithinRange(t, authRequest.GetCreationDate().AsTime(), now.Add(-time.Second), now.Add(time.Second)) assert.Contains(t, authRequest.GetScope(), "openid") }) @@ -130,16 +100,7 @@ func TestServer_CreateCallback(t *testing.T) { require.NoError(t, err) clientV2, err := Instance.CreateOIDCClientLoginVersion(CTX, redirectURI, logoutRedirectURI, project.GetId(), app.OIDCAppType_OIDC_APP_TYPE_NATIVE, app.OIDCAuthMethodType_OIDC_AUTH_METHOD_TYPE_NONE, false, loginV2) require.NoError(t, err) - sessionResp, err := Instance.Client.SessionV2.CreateSession(CTX, &session.CreateSessionRequest{ - Checks: &session.Checks{ - User: &session.CheckUser{ - Search: &session.CheckUser_UserId{ - UserId: Instance.Users[integration.UserTypeOrgOwner].ID, - }, - }, - }, - }) - require.NoError(t, err) + sessionResp := createSession(t, CTX, Instance.Users[integration.UserTypeOrgOwner].ID) tests := []struct { name string @@ -169,7 +130,7 @@ func TestServer_CreateCallback(t *testing.T) { ctx: CTX, req: &oidc_pb.CreateCallbackRequest{ AuthRequestId: func() string { - authRequestID, err := Instance.CreateOIDCAuthRequest(CTX, client.GetClientId(), Instance.Users[integration.UserTypeOrgOwner].ID, redirectURI) + _, authRequestID, err := Instance.CreateOIDCAuthRequest(CTX, client.GetClientId(), Instance.Users[integration.UserTypeOrgOwner].ID, redirectURI) require.NoError(t, err) return authRequestID }(), @@ -187,7 +148,7 @@ func TestServer_CreateCallback(t *testing.T) { ctx: CTX, req: &oidc_pb.CreateCallbackRequest{ AuthRequestId: func() string { - authRequestID, err := Instance.CreateOIDCAuthRequest(CTX, client.GetClientId(), Instance.Users.Get(integration.UserTypeOrgOwner).ID, redirectURI) + _, authRequestID, err := Instance.CreateOIDCAuthRequest(CTX, client.GetClientId(), Instance.Users.Get(integration.UserTypeOrgOwner).ID, redirectURI) require.NoError(t, err) return authRequestID }(), @@ -205,7 +166,7 @@ func TestServer_CreateCallback(t *testing.T) { ctx: CTX, req: &oidc_pb.CreateCallbackRequest{ AuthRequestId: func() string { - authRequestID, err := Instance.CreateOIDCAuthRequest(CTX, client.GetClientId(), Instance.Users.Get(integration.UserTypeOrgOwner).ID, redirectURI) + _, authRequestID, err := Instance.CreateOIDCAuthRequest(CTX, client.GetClientId(), Instance.Users.Get(integration.UserTypeOrgOwner).ID, redirectURI) require.NoError(t, err) return authRequestID }(), @@ -231,7 +192,7 @@ func TestServer_CreateCallback(t *testing.T) { ctx: CTXLoginClient, req: &oidc_pb.CreateCallbackRequest{ AuthRequestId: func() string { - authRequestID, err := Instance.CreateOIDCAuthRequestWithoutLoginClientHeader(CTX, clientV2.GetClientId(), redirectURI, "") + _, authRequestID, err := Instance.CreateOIDCAuthRequestWithoutLoginClientHeader(CTX, clientV2.GetClientId(), redirectURI, "") require.NoError(t, err) return authRequestID }(), @@ -257,7 +218,7 @@ func TestServer_CreateCallback(t *testing.T) { ctx: CTX, req: &oidc_pb.CreateCallbackRequest{ AuthRequestId: func() string { - authRequestID, err := Instance.CreateOIDCAuthRequest(CTX, client.GetClientId(), Instance.Users.Get(integration.UserTypeOrgOwner).ID, redirectURI) + _, authRequestID, err := Instance.CreateOIDCAuthRequest(CTX, client.GetClientId(), Instance.Users.Get(integration.UserTypeOrgOwner).ID, redirectURI) require.NoError(t, err) return authRequestID }(), @@ -282,7 +243,7 @@ func TestServer_CreateCallback(t *testing.T) { ctx: CTX, req: &oidc_pb.CreateCallbackRequest{ AuthRequestId: func() string { - authRequestID, err := Instance.CreateOIDCAuthRequestWithoutLoginClientHeader(CTX, clientV2.GetClientId(), redirectURI, "") + _, authRequestID, err := Instance.CreateOIDCAuthRequestWithoutLoginClientHeader(CTX, clientV2.GetClientId(), redirectURI, "") require.NoError(t, err) return authRequestID }(), @@ -300,7 +261,7 @@ func TestServer_CreateCallback(t *testing.T) { ctx: CTXLoginClient, req: &oidc_pb.CreateCallbackRequest{ AuthRequestId: func() string { - authRequestID, err := Instance.CreateOIDCAuthRequestWithoutLoginClientHeader(CTX, clientV2.GetClientId(), redirectURI, "") + _, authRequestID, err := Instance.CreateOIDCAuthRequestWithoutLoginClientHeader(CTX, clientV2.GetClientId(), redirectURI, "") require.NoError(t, err) return authRequestID }(), @@ -390,3 +351,324 @@ func TestServer_CreateCallback(t *testing.T) { }) } } + +func TestServer_CreateCallback_Permission(t *testing.T) { + tests := []struct { + name string + ctx context.Context + dep func(ctx context.Context, t *testing.T) *oidc_pb.CreateCallbackRequest + want *oidc_pb.CreateCallbackResponse + wantURL *url.URL + wantErr bool + }{ + { + name: "usergrant to project and different resourceowner with different project grant", + ctx: CTX, + dep: func(ctx context.Context, t *testing.T) *oidc_pb.CreateCallbackRequest { + projectID, clientID := createOIDCApplication(ctx, t, true, true) + projectID2, _ := createOIDCApplication(ctx, t, true, true) + + orgResp := Instance.CreateOrganization(ctx, "oidc-permission-"+gofakeit.AppName(), gofakeit.Email()) + Instance.CreateProjectGrant(ctx, projectID2, orgResp.GetOrganizationId()) + user := Instance.CreateHumanUserVerified(ctx, orgResp.GetOrganizationId(), gofakeit.Email(), gofakeit.Phone()) + Instance.CreateProjectUserGrant(t, ctx, projectID, user.GetUserId()) + + return createSessionAndAuthRequestForCallback(ctx, t, clientID, Instance.Users.Get(integration.UserTypeOrgOwner).ID, user.GetUserId()) + }, + wantErr: true, + }, + { + name: "usergrant to project and different resourceowner with project grant", + ctx: CTX, + dep: func(ctx context.Context, t *testing.T) *oidc_pb.CreateCallbackRequest { + projectID, clientID := createOIDCApplication(ctx, t, true, true) + + orgResp := Instance.CreateOrganization(ctx, "oidc-permission-"+gofakeit.AppName(), gofakeit.Email()) + Instance.CreateProjectGrant(ctx, projectID, orgResp.GetOrganizationId()) + user := Instance.CreateHumanUserVerified(ctx, orgResp.GetOrganizationId(), gofakeit.Email(), gofakeit.Phone()) + Instance.CreateProjectUserGrant(t, ctx, projectID, user.GetUserId()) + + return createSessionAndAuthRequestForCallback(ctx, t, clientID, Instance.Users.Get(integration.UserTypeOrgOwner).ID, user.GetUserId()) + }, + want: &oidc_pb.CreateCallbackResponse{ + CallbackUrl: `oidcintegrationtest:\/\/callback\?code=(.*)&state=state`, + Details: &object.Details{ + ChangeDate: timestamppb.Now(), + ResourceOwner: Instance.ID(), + }, + }, + }, + { + name: "usergrant to project grant and different resourceowner with project grant", + ctx: CTX, + dep: func(ctx context.Context, t *testing.T) *oidc_pb.CreateCallbackRequest { + projectID, clientID := createOIDCApplication(ctx, t, true, true) + + orgResp := Instance.CreateOrganization(ctx, "oidc-permission-"+gofakeit.AppName(), gofakeit.Email()) + projectGrantResp := Instance.CreateProjectGrant(ctx, projectID, orgResp.GetOrganizationId()) + user := Instance.CreateHumanUserVerified(ctx, orgResp.GetOrganizationId(), gofakeit.Email(), gofakeit.Phone()) + Instance.CreateProjectGrantUserGrant(ctx, orgResp.GetOrganizationId(), projectID, projectGrantResp.GetGrantId(), user.GetUserId()) + + return createSessionAndAuthRequestForCallback(ctx, t, clientID, Instance.Users.Get(integration.UserTypeOrgOwner).ID, user.GetUserId()) + }, + want: &oidc_pb.CreateCallbackResponse{ + CallbackUrl: `oidcintegrationtest:\/\/callback\?code=(.*)&state=state`, + Details: &object.Details{ + ChangeDate: timestamppb.Now(), + ResourceOwner: Instance.ID(), + }, + }, + }, + { + name: "no usergrant and different resourceowner", + ctx: CTX, + dep: func(ctx context.Context, t *testing.T) *oidc_pb.CreateCallbackRequest { + _, clientID := createOIDCApplication(ctx, t, true, true) + + orgResp := Instance.CreateOrganization(ctx, "oidc-permission-"+gofakeit.AppName(), gofakeit.Email()) + user := Instance.CreateHumanUserVerified(ctx, orgResp.GetOrganizationId(), gofakeit.Email(), gofakeit.Phone()) + + return createSessionAndAuthRequestForCallback(ctx, t, clientID, Instance.Users.Get(integration.UserTypeOrgOwner).ID, user.GetUserId()) + }, + wantErr: true, + }, + { + name: "no usergrant and same resourceowner", + ctx: CTX, + dep: func(ctx context.Context, t *testing.T) *oidc_pb.CreateCallbackRequest { + _, clientID := createOIDCApplication(ctx, t, true, true) + user := Instance.CreateHumanUser(ctx) + + return createSessionAndAuthRequestForCallback(ctx, t, clientID, Instance.Users.Get(integration.UserTypeOrgOwner).ID, user.GetUserId()) + }, + wantErr: true, + }, + { + name: "usergrant and different resourceowner", + ctx: CTX, + dep: func(ctx context.Context, t *testing.T) *oidc_pb.CreateCallbackRequest { + projectID, clientID := createOIDCApplication(ctx, t, true, true) + + orgResp := Instance.CreateOrganization(ctx, "oidc-permission-"+gofakeit.AppName(), gofakeit.Email()) + user := Instance.CreateHumanUserVerified(ctx, orgResp.GetOrganizationId(), gofakeit.Email(), gofakeit.Phone()) + Instance.CreateProjectUserGrant(t, ctx, projectID, user.GetUserId()) + + return createSessionAndAuthRequestForCallback(ctx, t, clientID, Instance.Users.Get(integration.UserTypeOrgOwner).ID, user.GetUserId()) + }, + wantErr: true, + }, + { + name: "usergrant and same resourceowner", + ctx: CTX, + dep: func(ctx context.Context, t *testing.T) *oidc_pb.CreateCallbackRequest { + projectID, clientID := createOIDCApplication(ctx, t, true, true) + user := Instance.CreateHumanUser(ctx) + Instance.CreateProjectUserGrant(t, ctx, projectID, user.GetUserId()) + + return createSessionAndAuthRequestForCallback(ctx, t, clientID, Instance.Users.Get(integration.UserTypeOrgOwner).ID, user.GetUserId()) + }, + want: &oidc_pb.CreateCallbackResponse{ + CallbackUrl: `oidcintegrationtest:\/\/callback\?code=(.*)&state=state`, + Details: &object.Details{ + ChangeDate: timestamppb.Now(), + ResourceOwner: Instance.ID(), + }, + }, + }, + { + name: "projectRoleCheck, usergrant and same resourceowner", + ctx: CTX, + dep: func(ctx context.Context, t *testing.T) *oidc_pb.CreateCallbackRequest { + projectID, clientID := createOIDCApplication(ctx, t, true, false) + user := Instance.CreateHumanUser(ctx) + Instance.CreateProjectUserGrant(t, ctx, projectID, user.GetUserId()) + + return createSessionAndAuthRequestForCallback(ctx, t, clientID, Instance.Users.Get(integration.UserTypeOrgOwner).ID, user.GetUserId()) + }, + want: &oidc_pb.CreateCallbackResponse{ + CallbackUrl: `oidcintegrationtest:\/\/callback\?code=(.*)&state=state`, + Details: &object.Details{ + ChangeDate: timestamppb.Now(), + ResourceOwner: Instance.ID(), + }, + }, + }, + { + name: "projectRoleCheck, no usergrant and same resourceowner", + ctx: CTX, + dep: func(ctx context.Context, t *testing.T) *oidc_pb.CreateCallbackRequest { + _, clientID := createOIDCApplication(ctx, t, true, false) + user := Instance.CreateHumanUser(ctx) + + return createSessionAndAuthRequestForCallback(ctx, t, clientID, Instance.Users.Get(integration.UserTypeOrgOwner).ID, user.GetUserId()) + }, + wantErr: true, + }, + { + name: "projectRoleCheck, usergrant and different resourceowner", + ctx: CTX, + dep: func(ctx context.Context, t *testing.T) *oidc_pb.CreateCallbackRequest { + projectID, clientID := createOIDCApplication(ctx, t, true, false) + orgResp := Instance.CreateOrganization(ctx, "oidc-permission-"+gofakeit.AppName(), gofakeit.Email()) + user := Instance.CreateHumanUserVerified(ctx, orgResp.GetOrganizationId(), gofakeit.Email(), gofakeit.Phone()) + Instance.CreateProjectUserGrant(t, ctx, projectID, user.GetUserId()) + + return createSessionAndAuthRequestForCallback(ctx, t, clientID, Instance.Users.Get(integration.UserTypeOrgOwner).ID, user.GetUserId()) + }, + want: &oidc_pb.CreateCallbackResponse{ + CallbackUrl: `oidcintegrationtest:\/\/callback\?code=(.*)&state=state`, + Details: &object.Details{ + ChangeDate: timestamppb.Now(), + ResourceOwner: Instance.ID(), + }, + }, + }, + { + name: "projectRoleCheck, no usergrant and different resourceowner", + ctx: CTX, + dep: func(ctx context.Context, t *testing.T) *oidc_pb.CreateCallbackRequest { + _, clientID := createOIDCApplication(ctx, t, true, false) + orgResp := Instance.CreateOrganization(ctx, "oidc-permission-"+gofakeit.AppName(), gofakeit.Email()) + user := Instance.CreateHumanUserVerified(ctx, orgResp.GetOrganizationId(), gofakeit.Email(), gofakeit.Phone()) + + return createSessionAndAuthRequestForCallback(ctx, t, clientID, Instance.Users.Get(integration.UserTypeOrgOwner).ID, user.GetUserId()) + }, + wantErr: true, + }, + { + name: "projectRoleCheck, usergrant on project grant and different resourceowner", + ctx: CTX, + dep: func(ctx context.Context, t *testing.T) *oidc_pb.CreateCallbackRequest { + projectID, clientID := createOIDCApplication(ctx, t, true, false) + + orgResp := Instance.CreateOrganization(ctx, "oidc-permission-"+gofakeit.AppName(), gofakeit.Email()) + projectGrantResp := Instance.CreateProjectGrant(ctx, projectID, orgResp.GetOrganizationId()) + user := Instance.CreateHumanUserVerified(ctx, orgResp.GetOrganizationId(), gofakeit.Email(), gofakeit.Phone()) + Instance.CreateProjectGrantUserGrant(ctx, orgResp.GetOrganizationId(), projectID, projectGrantResp.GetGrantId(), user.GetUserId()) + return createSessionAndAuthRequestForCallback(ctx, t, clientID, Instance.Users.Get(integration.UserTypeOrgOwner).ID, user.GetUserId()) + }, + want: &oidc_pb.CreateCallbackResponse{ + CallbackUrl: `oidcintegrationtest:\/\/callback\?code=(.*)&state=state`, + Details: &object.Details{ + ChangeDate: timestamppb.Now(), + ResourceOwner: Instance.ID(), + }, + }, + }, + { + name: "projectRoleCheck, no usergrant on project grant and different resourceowner", + ctx: CTX, + dep: func(ctx context.Context, t *testing.T) *oidc_pb.CreateCallbackRequest { + projectID, clientID := createOIDCApplication(ctx, t, true, false) + + orgResp := Instance.CreateOrganization(ctx, "oidc-permission-"+gofakeit.AppName(), gofakeit.Email()) + Instance.CreateProjectGrant(ctx, projectID, orgResp.GetOrganizationId()) + user := Instance.CreateHumanUserVerified(ctx, orgResp.GetOrganizationId(), gofakeit.Email(), gofakeit.Phone()) + return createSessionAndAuthRequestForCallback(ctx, t, clientID, Instance.Users.Get(integration.UserTypeOrgOwner).ID, user.GetUserId()) + }, + wantErr: true, + }, + { + name: "hasProjectCheck, same resourceowner", + ctx: CTX, + dep: func(ctx context.Context, t *testing.T) *oidc_pb.CreateCallbackRequest { + user := Instance.CreateHumanUser(ctx) + _, clientID := createOIDCApplication(ctx, t, false, true) + + return createSessionAndAuthRequestForCallback(ctx, t, clientID, Instance.Users.Get(integration.UserTypeOrgOwner).ID, user.GetUserId()) + }, + want: &oidc_pb.CreateCallbackResponse{ + CallbackUrl: `oidcintegrationtest:\/\/callback\?code=(.*)&state=state`, + Details: &object.Details{ + ChangeDate: timestamppb.Now(), + ResourceOwner: Instance.ID(), + }, + }, + }, + { + name: "hasProjectCheck, different resourceowner", + ctx: CTX, + dep: func(ctx context.Context, t *testing.T) *oidc_pb.CreateCallbackRequest { + _, clientID := createOIDCApplication(ctx, t, false, true) + orgResp := Instance.CreateOrganization(ctx, "oidc-permission-"+gofakeit.AppName(), gofakeit.Email()) + user := Instance.CreateHumanUserVerified(ctx, orgResp.GetOrganizationId(), gofakeit.Email(), gofakeit.Phone()) + + return createSessionAndAuthRequestForCallback(ctx, t, clientID, Instance.Users.Get(integration.UserTypeOrgOwner).ID, user.GetUserId()) + }, + wantErr: true, + }, + { + name: "hasProjectCheck, different resourceowner with project grant", + ctx: CTX, + dep: func(ctx context.Context, t *testing.T) *oidc_pb.CreateCallbackRequest { + projectID, clientID := createOIDCApplication(ctx, t, false, true) + + orgResp := Instance.CreateOrganization(ctx, "oidc-permission-"+gofakeit.AppName(), gofakeit.Email()) + Instance.CreateProjectGrant(ctx, projectID, orgResp.GetOrganizationId()) + user := Instance.CreateHumanUserVerified(ctx, orgResp.GetOrganizationId(), gofakeit.Email(), gofakeit.Phone()) + + return createSessionAndAuthRequestForCallback(ctx, t, clientID, Instance.Users.Get(integration.UserTypeOrgOwner).ID, user.GetUserId()) + }, + want: &oidc_pb.CreateCallbackResponse{ + CallbackUrl: `oidcintegrationtest:\/\/callback\?code=(.*)&state=state`, + Details: &object.Details{ + ChangeDate: timestamppb.Now(), + ResourceOwner: Instance.ID(), + }, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + req := tt.dep(IAMCTX, t) + + got, err := Client.CreateCallback(tt.ctx, req) + if tt.wantErr { + require.Error(t, err) + return + } + require.NoError(t, err) + integration.AssertDetails(t, tt.want, got) + if tt.want != nil { + assert.Regexp(t, regexp.MustCompile(tt.want.CallbackUrl), got.GetCallbackUrl()) + } + }) + } +} + +func createSession(t *testing.T, ctx context.Context, userID string) *session.CreateSessionResponse { + sessionResp, err := Instance.Client.SessionV2.CreateSession(ctx, &session.CreateSessionRequest{ + Checks: &session.Checks{ + User: &session.CheckUser{ + Search: &session.CheckUser_UserId{ + UserId: userID, + }, + }, + }, + }) + require.NoError(t, err) + return sessionResp +} + +func createSessionAndAuthRequestForCallback(ctx context.Context, t *testing.T, clientID, loginClient, userID string) *oidc_pb.CreateCallbackRequest { + _, authRequestID, err := Instance.CreateOIDCAuthRequest(ctx, clientID, loginClient, redirectURI) + require.NoError(t, err) + sessionResp := createSession(t, ctx, userID) + return &oidc_pb.CreateCallbackRequest{ + AuthRequestId: authRequestID, + CallbackKind: &oidc_pb.CreateCallbackRequest_Session{ + Session: &oidc_pb.Session{ + SessionId: sessionResp.GetSessionId(), + SessionToken: sessionResp.GetSessionToken(), + }, + }, + } +} + +func createOIDCApplication(ctx context.Context, t *testing.T, projectRoleCheck, hasProjectCheck bool) (string, string) { + project, err := Instance.CreateProjectWithPermissionCheck(ctx, projectRoleCheck, hasProjectCheck) + require.NoError(t, err) + clientV2, err := Instance.CreateOIDCClientLoginVersion(ctx, redirectURI, logoutRedirectURI, project.GetId(), app.OIDCAppType_OIDC_APP_TYPE_NATIVE, app.OIDCAuthMethodType_OIDC_AUTH_METHOD_TYPE_NONE, false, loginV2) + require.NoError(t, err) + return project.GetId(), clientV2.GetClientId() +} diff --git a/internal/api/grpc/oidc/v2/integration_test/server_test.go b/internal/api/grpc/oidc/v2/integration_test/server_test.go new file mode 100644 index 0000000000..ccc37e37e5 --- /dev/null +++ b/internal/api/grpc/oidc/v2/integration_test/server_test.go @@ -0,0 +1,44 @@ +//go:build integration + +package oidc_test + +import ( + "context" + "os" + "testing" + "time" + + "github.com/zitadel/zitadel/internal/integration" + "github.com/zitadel/zitadel/pkg/grpc/app" + oidc_pb "github.com/zitadel/zitadel/pkg/grpc/oidc/v2" +) + +var ( + CTX context.Context + CTXLoginClient context.Context + IAMCTX context.Context + Instance *integration.Instance + Client oidc_pb.OIDCServiceClient + loginV2 = &app.LoginVersion{Version: &app.LoginVersion_LoginV2{LoginV2: &app.LoginV2{BaseUri: nil}}} +) + +const ( + redirectURI = "oidcintegrationtest://callback" + redirectURIImplicit = "http://localhost:9999/callback" + logoutRedirectURI = "oidcintegrationtest://logged-out" +) + +func TestMain(m *testing.M) { + os.Exit(func() int { + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Minute) + defer cancel() + + Instance = integration.NewInstance(ctx) + Client = Instance.Client.OIDCv2 + + IAMCTX = Instance.WithAuthorization(ctx, integration.UserTypeIAMOwner) + CTX = Instance.WithAuthorization(ctx, integration.UserTypeOrgOwner) + CTXLoginClient = Instance.WithAuthorization(ctx, integration.UserTypeLogin) + return m.Run() + }()) +} diff --git a/internal/api/grpc/oidc/v2/oidc.go b/internal/api/grpc/oidc/v2/oidc.go index 826c198fad..d1ddc35cc0 100644 --- a/internal/api/grpc/oidc/v2/oidc.go +++ b/internal/api/grpc/oidc/v2/oidc.go @@ -73,6 +73,20 @@ func promptToPb(p domain.Prompt) oidc_pb.Prompt { } } +func (s *Server) checkPermission(ctx context.Context, clientID string, userID string) error { + permission, err := s.query.CheckProjectPermissionByClientID(ctx, clientID, userID) + if err != nil { + return err + } + if !permission.HasProjectChecked { + return zerrors.ThrowPermissionDenied(nil, "OIDC-foSyH49RvL", "Errors.User.ProjectRequired") + } + if !permission.ProjectRoleChecked { + return zerrors.ThrowPermissionDenied(nil, "OIDC-foSyH49RvL", "Errors.User.GrantRequired") + } + return nil +} + func (s *Server) CreateCallback(ctx context.Context, req *oidc_pb.CreateCallbackRequest) (*oidc_pb.CreateCallbackResponse, error) { switch v := req.GetCallbackKind().(type) { case *oidc_pb.CreateCallbackRequest_Error: @@ -101,7 +115,7 @@ func (s *Server) failAuthRequest(ctx context.Context, authRequestID string, ae * } func (s *Server) linkSessionToAuthRequest(ctx context.Context, authRequestID string, session *oidc_pb.Session) (*oidc_pb.CreateCallbackResponse, error) { - details, aar, err := s.command.LinkSessionToAuthRequest(ctx, authRequestID, session.GetSessionId(), session.GetSessionToken(), true) + details, aar, err := s.command.LinkSessionToAuthRequest(ctx, authRequestID, session.GetSessionId(), session.GetSessionToken(), true, s.checkPermission) if err != nil { return nil, err } diff --git a/internal/api/grpc/oidc/v2beta/integration_test/oidc_test.go b/internal/api/grpc/oidc/v2beta/integration_test/oidc_test.go index 1c83b504dd..1d2a6d2671 100644 --- a/internal/api/grpc/oidc/v2beta/integration_test/oidc_test.go +++ b/internal/api/grpc/oidc/v2beta/integration_test/oidc_test.go @@ -5,76 +5,79 @@ package oidc_test import ( "context" "net/url" - "os" "regexp" "testing" "time" + "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/timestamppb" "github.com/zitadel/zitadel/internal/integration" + "github.com/zitadel/zitadel/pkg/grpc/app" object "github.com/zitadel/zitadel/pkg/grpc/object/v2beta" oidc_pb "github.com/zitadel/zitadel/pkg/grpc/oidc/v2beta" session "github.com/zitadel/zitadel/pkg/grpc/session/v2beta" ) -var ( - CTX context.Context - Instance *integration.Instance - Client oidc_pb.OIDCServiceClient -) - -const ( - redirectURI = "oidcintegrationtest://callback" - redirectURIImplicit = "http://localhost:9999/callback" - logoutRedirectURI = "oidcintegrationtest://logged-out" -) - -func TestMain(m *testing.M) { - os.Exit(func() int { - ctx, cancel := context.WithTimeout(context.Background(), 15*time.Minute) - defer cancel() - - Instance = integration.NewInstance(ctx) - Client = Instance.Client.OIDCv2beta - - CTX = Instance.WithAuthorization(ctx, integration.UserTypeOrgOwner) - return m.Run() - }()) -} - func TestServer_GetAuthRequest(t *testing.T) { project, err := Instance.CreateProject(CTX) require.NoError(t, err) client, err := Instance.CreateOIDCNativeClient(CTX, redirectURI, logoutRedirectURI, project.GetId(), false) require.NoError(t, err) - authRequestID, err := Instance.CreateOIDCAuthRequest(CTX, client.GetClientId(), Instance.Users.Get(integration.UserTypeOrgOwner).ID, redirectURI) - require.NoError(t, err) - now := time.Now() tests := []struct { - name string - AuthRequestID string - want *oidc_pb.GetAuthRequestResponse - wantErr bool + name string + dep func() (time.Time, string, error) + ctx context.Context + want *oidc_pb.GetAuthRequestResponse + wantErr bool }{ { - name: "Not found", - AuthRequestID: "123", - wantErr: true, + name: "Not found", + dep: func() (time.Time, string, error) { + return time.Now(), "123", nil + }, + ctx: CTX, + wantErr: true, }, { - name: "success", - AuthRequestID: authRequestID, + name: "success", + dep: func() (time.Time, string, error) { + return Instance.CreateOIDCAuthRequest(CTX, client.GetClientId(), Instance.Users[integration.UserTypeOrgOwner].ID, redirectURI) + }, + ctx: CTX, + }, + { + name: "without login client, no permission", + dep: func() (time.Time, string, error) { + client, err := Instance.CreateOIDCClientLoginVersion(CTX, redirectURI, logoutRedirectURI, project.GetId(), app.OIDCAppType_OIDC_APP_TYPE_NATIVE, app.OIDCAuthMethodType_OIDC_AUTH_METHOD_TYPE_NONE, false, loginV2) + require.NoError(t, err) + return Instance.CreateOIDCAuthRequestWithoutLoginClientHeader(CTX, client.GetClientId(), redirectURI, "") + }, + ctx: CTX, + wantErr: true, + }, + { + name: "without login client, with permission", + dep: func() (time.Time, string, error) { + client, err := Instance.CreateOIDCClientLoginVersion(CTX, redirectURI, logoutRedirectURI, project.GetId(), app.OIDCAppType_OIDC_APP_TYPE_NATIVE, app.OIDCAuthMethodType_OIDC_AUTH_METHOD_TYPE_NONE, false, loginV2) + require.NoError(t, err) + return Instance.CreateOIDCAuthRequestWithoutLoginClientHeader(CTX, client.GetClientId(), redirectURI, "") + + }, + ctx: CTXLoginClient, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - got, err := Client.GetAuthRequest(CTX, &oidc_pb.GetAuthRequestRequest{ - AuthRequestId: tt.AuthRequestID, + now, authRequestID, err := tt.dep() + require.NoError(t, err) + + got, err := Client.GetAuthRequest(tt.ctx, &oidc_pb.GetAuthRequestRequest{ + AuthRequestId: authRequestID, }) if tt.wantErr { require.Error(t, err) @@ -95,19 +98,13 @@ func TestServer_CreateCallback(t *testing.T) { require.NoError(t, err) client, err := Instance.CreateOIDCNativeClient(CTX, redirectURI, logoutRedirectURI, project.GetId(), false) require.NoError(t, err) - sessionResp, err := Instance.Client.SessionV2beta.CreateSession(CTX, &session.CreateSessionRequest{ - Checks: &session.Checks{ - User: &session.CheckUser{ - Search: &session.CheckUser_UserId{ - UserId: Instance.Users.Get(integration.UserTypeOrgOwner).ID, - }, - }, - }, - }) + clientV2, err := Instance.CreateOIDCClientLoginVersion(CTX, redirectURI, logoutRedirectURI, project.GetId(), app.OIDCAppType_OIDC_APP_TYPE_NATIVE, app.OIDCAuthMethodType_OIDC_AUTH_METHOD_TYPE_NONE, false, loginV2) require.NoError(t, err) + sessionResp := createSession(t, CTX, Instance.Users[integration.UserTypeOrgOwner].ID) tests := []struct { name string + ctx context.Context req *oidc_pb.CreateCallbackRequest AuthError string want *oidc_pb.CreateCallbackResponse @@ -116,6 +113,7 @@ func TestServer_CreateCallback(t *testing.T) { }{ { name: "Not found", + ctx: CTX, req: &oidc_pb.CreateCallbackRequest{ AuthRequestId: "123", CallbackKind: &oidc_pb.CreateCallbackRequest_Session{ @@ -129,9 +127,10 @@ func TestServer_CreateCallback(t *testing.T) { }, { name: "session not found", + ctx: CTX, req: &oidc_pb.CreateCallbackRequest{ AuthRequestId: func() string { - authRequestID, err := Instance.CreateOIDCAuthRequest(CTX, client.GetClientId(), Instance.Users.Get(integration.UserTypeOrgOwner).ID, redirectURI) + _, authRequestID, err := Instance.CreateOIDCAuthRequest(CTX, client.GetClientId(), Instance.Users[integration.UserTypeOrgOwner].ID, redirectURI) require.NoError(t, err) return authRequestID }(), @@ -146,9 +145,10 @@ func TestServer_CreateCallback(t *testing.T) { }, { name: "session token invalid", + ctx: CTX, req: &oidc_pb.CreateCallbackRequest{ AuthRequestId: func() string { - authRequestID, err := Instance.CreateOIDCAuthRequest(CTX, client.GetClientId(), Instance.Users.Get(integration.UserTypeOrgOwner).ID, redirectURI) + _, authRequestID, err := Instance.CreateOIDCAuthRequest(CTX, client.GetClientId(), Instance.Users.Get(integration.UserTypeOrgOwner).ID, redirectURI) require.NoError(t, err) return authRequestID }(), @@ -163,9 +163,36 @@ func TestServer_CreateCallback(t *testing.T) { }, { name: "fail callback", + ctx: CTX, req: &oidc_pb.CreateCallbackRequest{ AuthRequestId: func() string { - authRequestID, err := Instance.CreateOIDCAuthRequest(CTX, client.GetClientId(), Instance.Users.Get(integration.UserTypeOrgOwner).ID, redirectURI) + _, authRequestID, err := Instance.CreateOIDCAuthRequest(CTX, client.GetClientId(), Instance.Users.Get(integration.UserTypeOrgOwner).ID, redirectURI) + require.NoError(t, err) + return authRequestID + }(), + CallbackKind: &oidc_pb.CreateCallbackRequest_Error{ + Error: &oidc_pb.AuthorizationError{ + Error: oidc_pb.ErrorReason_ERROR_REASON_ACCESS_DENIED, + ErrorDescription: gu.Ptr("nope"), + ErrorUri: gu.Ptr("https://example.com/docs"), + }, + }, + }, + want: &oidc_pb.CreateCallbackResponse{ + CallbackUrl: regexp.QuoteMeta(`oidcintegrationtest://callback?error=access_denied&error_description=nope&error_uri=https%3A%2F%2Fexample.com%2Fdocs&state=state`), + Details: &object.Details{ + ChangeDate: timestamppb.Now(), + ResourceOwner: Instance.ID(), + }, + }, + wantErr: false, + }, + { + name: "fail callback, no login client header", + ctx: CTXLoginClient, + req: &oidc_pb.CreateCallbackRequest{ + AuthRequestId: func() string { + _, authRequestID, err := Instance.CreateOIDCAuthRequestWithoutLoginClientHeader(CTX, clientV2.GetClientId(), redirectURI, "") require.NoError(t, err) return authRequestID }(), @@ -188,9 +215,53 @@ func TestServer_CreateCallback(t *testing.T) { }, { name: "code callback", + ctx: CTX, req: &oidc_pb.CreateCallbackRequest{ AuthRequestId: func() string { - authRequestID, err := Instance.CreateOIDCAuthRequest(CTX, client.GetClientId(), Instance.Users.Get(integration.UserTypeOrgOwner).ID, redirectURI) + _, authRequestID, err := Instance.CreateOIDCAuthRequest(CTX, client.GetClientId(), Instance.Users.Get(integration.UserTypeOrgOwner).ID, redirectURI) + require.NoError(t, err) + return authRequestID + }(), + CallbackKind: &oidc_pb.CreateCallbackRequest_Session{ + Session: &oidc_pb.Session{ + SessionId: sessionResp.GetSessionId(), + SessionToken: sessionResp.GetSessionToken(), + }, + }, + }, + want: &oidc_pb.CreateCallbackResponse{ + CallbackUrl: `oidcintegrationtest:\/\/callback\?code=(.*)&state=state`, + Details: &object.Details{ + ChangeDate: timestamppb.Now(), + ResourceOwner: Instance.ID(), + }, + }, + wantErr: false, + }, + { + name: "code callback, no login client header, no permission, error", + ctx: CTX, + req: &oidc_pb.CreateCallbackRequest{ + AuthRequestId: func() string { + _, authRequestID, err := Instance.CreateOIDCAuthRequestWithoutLoginClientHeader(CTX, clientV2.GetClientId(), redirectURI, "") + require.NoError(t, err) + return authRequestID + }(), + CallbackKind: &oidc_pb.CreateCallbackRequest_Session{ + Session: &oidc_pb.Session{ + SessionId: sessionResp.GetSessionId(), + SessionToken: sessionResp.GetSessionToken(), + }, + }, + }, + wantErr: true, + }, + { + name: "code callback, no login client header, with permission", + ctx: CTXLoginClient, + req: &oidc_pb.CreateCallbackRequest{ + AuthRequestId: func() string { + _, authRequestID, err := Instance.CreateOIDCAuthRequestWithoutLoginClientHeader(CTX, clientV2.GetClientId(), redirectURI, "") require.NoError(t, err) return authRequestID }(), @@ -212,6 +283,7 @@ func TestServer_CreateCallback(t *testing.T) { }, { name: "implicit", + ctx: CTX, req: &oidc_pb.CreateCallbackRequest{ AuthRequestId: func() string { client, err := Instance.CreateOIDCImplicitFlowClient(CTX, redirectURIImplicit, nil) @@ -236,10 +308,37 @@ func TestServer_CreateCallback(t *testing.T) { }, wantErr: false, }, + { + name: "implicit, no login client header", + ctx: CTXLoginClient, + req: &oidc_pb.CreateCallbackRequest{ + AuthRequestId: func() string { + clientV2, err := Instance.CreateOIDCImplicitFlowClient(CTX, redirectURIImplicit, loginV2) + require.NoError(t, err) + authRequestID, err := Instance.CreateOIDCAuthRequestImplicitWithoutLoginClientHeader(CTX, clientV2.GetClientId(), redirectURIImplicit) + require.NoError(t, err) + return authRequestID + }(), + CallbackKind: &oidc_pb.CreateCallbackRequest_Session{ + Session: &oidc_pb.Session{ + SessionId: sessionResp.GetSessionId(), + SessionToken: sessionResp.GetSessionToken(), + }, + }, + }, + want: &oidc_pb.CreateCallbackResponse{ + CallbackUrl: `http:\/\/localhost:9999\/callback#access_token=(.*)&expires_in=(.*)&id_token=(.*)&state=state&token_type=Bearer`, + Details: &object.Details{ + ChangeDate: timestamppb.Now(), + ResourceOwner: Instance.ID(), + }, + }, + wantErr: false, + }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - got, err := Client.CreateCallback(CTX, tt.req) + got, err := Client.CreateCallback(tt.ctx, tt.req) if tt.wantErr { require.Error(t, err) return @@ -252,3 +351,324 @@ func TestServer_CreateCallback(t *testing.T) { }) } } + +func TestServer_CreateCallback_Permission(t *testing.T) { + tests := []struct { + name string + ctx context.Context + dep func(ctx context.Context, t *testing.T) *oidc_pb.CreateCallbackRequest + want *oidc_pb.CreateCallbackResponse + wantURL *url.URL + wantErr bool + }{ + { + name: "usergrant to project and different resourceowner with different project grant", + ctx: CTX, + dep: func(ctx context.Context, t *testing.T) *oidc_pb.CreateCallbackRequest { + projectID, clientID := createOIDCApplication(ctx, t, true, true) + projectID2, _ := createOIDCApplication(ctx, t, true, true) + + orgResp := Instance.CreateOrganization(ctx, "oidc-permission-"+gofakeit.AppName(), gofakeit.Email()) + Instance.CreateProjectGrant(ctx, projectID2, orgResp.GetOrganizationId()) + user := Instance.CreateHumanUserVerified(ctx, orgResp.GetOrganizationId(), gofakeit.Email(), gofakeit.Phone()) + Instance.CreateProjectUserGrant(t, ctx, projectID, user.GetUserId()) + + return createSessionAndAuthRequestForCallback(ctx, t, clientID, Instance.Users.Get(integration.UserTypeOrgOwner).ID, user.GetUserId()) + }, + wantErr: true, + }, + { + name: "usergrant to project and different resourceowner with project grant", + ctx: CTX, + dep: func(ctx context.Context, t *testing.T) *oidc_pb.CreateCallbackRequest { + projectID, clientID := createOIDCApplication(ctx, t, true, true) + + orgResp := Instance.CreateOrganization(ctx, "oidc-permission-"+gofakeit.AppName(), gofakeit.Email()) + Instance.CreateProjectGrant(ctx, projectID, orgResp.GetOrganizationId()) + user := Instance.CreateHumanUserVerified(ctx, orgResp.GetOrganizationId(), gofakeit.Email(), gofakeit.Phone()) + Instance.CreateProjectUserGrant(t, ctx, projectID, user.GetUserId()) + + return createSessionAndAuthRequestForCallback(ctx, t, clientID, Instance.Users.Get(integration.UserTypeOrgOwner).ID, user.GetUserId()) + }, + want: &oidc_pb.CreateCallbackResponse{ + CallbackUrl: `oidcintegrationtest:\/\/callback\?code=(.*)&state=state`, + Details: &object.Details{ + ChangeDate: timestamppb.Now(), + ResourceOwner: Instance.ID(), + }, + }, + }, + { + name: "usergrant to project grant and different resourceowner with project grant", + ctx: CTX, + dep: func(ctx context.Context, t *testing.T) *oidc_pb.CreateCallbackRequest { + projectID, clientID := createOIDCApplication(ctx, t, true, true) + + orgResp := Instance.CreateOrganization(ctx, "oidc-permission-"+gofakeit.AppName(), gofakeit.Email()) + projectGrantResp := Instance.CreateProjectGrant(ctx, projectID, orgResp.GetOrganizationId()) + user := Instance.CreateHumanUserVerified(ctx, orgResp.GetOrganizationId(), gofakeit.Email(), gofakeit.Phone()) + Instance.CreateProjectGrantUserGrant(ctx, orgResp.GetOrganizationId(), projectID, projectGrantResp.GetGrantId(), user.GetUserId()) + + return createSessionAndAuthRequestForCallback(ctx, t, clientID, Instance.Users.Get(integration.UserTypeOrgOwner).ID, user.GetUserId()) + }, + want: &oidc_pb.CreateCallbackResponse{ + CallbackUrl: `oidcintegrationtest:\/\/callback\?code=(.*)&state=state`, + Details: &object.Details{ + ChangeDate: timestamppb.Now(), + ResourceOwner: Instance.ID(), + }, + }, + }, + { + name: "no usergrant and different resourceowner", + ctx: CTX, + dep: func(ctx context.Context, t *testing.T) *oidc_pb.CreateCallbackRequest { + _, clientID := createOIDCApplication(ctx, t, true, true) + + orgResp := Instance.CreateOrganization(ctx, "oidc-permission-"+gofakeit.AppName(), gofakeit.Email()) + user := Instance.CreateHumanUserVerified(ctx, orgResp.GetOrganizationId(), gofakeit.Email(), gofakeit.Phone()) + + return createSessionAndAuthRequestForCallback(ctx, t, clientID, Instance.Users.Get(integration.UserTypeOrgOwner).ID, user.GetUserId()) + }, + wantErr: true, + }, + { + name: "no usergrant and same resourceowner", + ctx: CTX, + dep: func(ctx context.Context, t *testing.T) *oidc_pb.CreateCallbackRequest { + _, clientID := createOIDCApplication(ctx, t, true, true) + user := Instance.CreateHumanUser(ctx) + + return createSessionAndAuthRequestForCallback(ctx, t, clientID, Instance.Users.Get(integration.UserTypeOrgOwner).ID, user.GetUserId()) + }, + wantErr: true, + }, + { + name: "usergrant and different resourceowner", + ctx: CTX, + dep: func(ctx context.Context, t *testing.T) *oidc_pb.CreateCallbackRequest { + projectID, clientID := createOIDCApplication(ctx, t, true, true) + + orgResp := Instance.CreateOrganization(ctx, "oidc-permission-"+gofakeit.AppName(), gofakeit.Email()) + user := Instance.CreateHumanUserVerified(ctx, orgResp.GetOrganizationId(), gofakeit.Email(), gofakeit.Phone()) + Instance.CreateProjectUserGrant(t, ctx, projectID, user.GetUserId()) + + return createSessionAndAuthRequestForCallback(ctx, t, clientID, Instance.Users.Get(integration.UserTypeOrgOwner).ID, user.GetUserId()) + }, + wantErr: true, + }, + { + name: "usergrant and same resourceowner", + ctx: CTX, + dep: func(ctx context.Context, t *testing.T) *oidc_pb.CreateCallbackRequest { + projectID, clientID := createOIDCApplication(ctx, t, true, true) + user := Instance.CreateHumanUser(ctx) + Instance.CreateProjectUserGrant(t, ctx, projectID, user.GetUserId()) + + return createSessionAndAuthRequestForCallback(ctx, t, clientID, Instance.Users.Get(integration.UserTypeOrgOwner).ID, user.GetUserId()) + }, + want: &oidc_pb.CreateCallbackResponse{ + CallbackUrl: `oidcintegrationtest:\/\/callback\?code=(.*)&state=state`, + Details: &object.Details{ + ChangeDate: timestamppb.Now(), + ResourceOwner: Instance.ID(), + }, + }, + }, + { + name: "projectRoleCheck, usergrant and same resourceowner", + ctx: CTX, + dep: func(ctx context.Context, t *testing.T) *oidc_pb.CreateCallbackRequest { + projectID, clientID := createOIDCApplication(ctx, t, true, false) + user := Instance.CreateHumanUser(ctx) + Instance.CreateProjectUserGrant(t, ctx, projectID, user.GetUserId()) + + return createSessionAndAuthRequestForCallback(ctx, t, clientID, Instance.Users.Get(integration.UserTypeOrgOwner).ID, user.GetUserId()) + }, + want: &oidc_pb.CreateCallbackResponse{ + CallbackUrl: `oidcintegrationtest:\/\/callback\?code=(.*)&state=state`, + Details: &object.Details{ + ChangeDate: timestamppb.Now(), + ResourceOwner: Instance.ID(), + }, + }, + }, + { + name: "projectRoleCheck, no usergrant and same resourceowner", + ctx: CTX, + dep: func(ctx context.Context, t *testing.T) *oidc_pb.CreateCallbackRequest { + _, clientID := createOIDCApplication(ctx, t, true, false) + user := Instance.CreateHumanUser(ctx) + + return createSessionAndAuthRequestForCallback(ctx, t, clientID, Instance.Users.Get(integration.UserTypeOrgOwner).ID, user.GetUserId()) + }, + wantErr: true, + }, + { + name: "projectRoleCheck, usergrant and different resourceowner", + ctx: CTX, + dep: func(ctx context.Context, t *testing.T) *oidc_pb.CreateCallbackRequest { + projectID, clientID := createOIDCApplication(ctx, t, true, false) + orgResp := Instance.CreateOrganization(ctx, "oidc-permission-"+gofakeit.AppName(), gofakeit.Email()) + user := Instance.CreateHumanUserVerified(ctx, orgResp.GetOrganizationId(), gofakeit.Email(), gofakeit.Phone()) + Instance.CreateProjectUserGrant(t, ctx, projectID, user.GetUserId()) + + return createSessionAndAuthRequestForCallback(ctx, t, clientID, Instance.Users.Get(integration.UserTypeOrgOwner).ID, user.GetUserId()) + }, + want: &oidc_pb.CreateCallbackResponse{ + CallbackUrl: `oidcintegrationtest:\/\/callback\?code=(.*)&state=state`, + Details: &object.Details{ + ChangeDate: timestamppb.Now(), + ResourceOwner: Instance.ID(), + }, + }, + }, + { + name: "projectRoleCheck, no usergrant and different resourceowner", + ctx: CTX, + dep: func(ctx context.Context, t *testing.T) *oidc_pb.CreateCallbackRequest { + _, clientID := createOIDCApplication(ctx, t, true, false) + orgResp := Instance.CreateOrganization(ctx, "oidc-permission-"+gofakeit.AppName(), gofakeit.Email()) + user := Instance.CreateHumanUserVerified(ctx, orgResp.GetOrganizationId(), gofakeit.Email(), gofakeit.Phone()) + + return createSessionAndAuthRequestForCallback(ctx, t, clientID, Instance.Users.Get(integration.UserTypeOrgOwner).ID, user.GetUserId()) + }, + wantErr: true, + }, + { + name: "projectRoleCheck, usergrant on project grant and different resourceowner", + ctx: CTX, + dep: func(ctx context.Context, t *testing.T) *oidc_pb.CreateCallbackRequest { + projectID, clientID := createOIDCApplication(ctx, t, true, false) + + orgResp := Instance.CreateOrganization(ctx, "oidc-permission-"+gofakeit.AppName(), gofakeit.Email()) + projectGrantResp := Instance.CreateProjectGrant(ctx, projectID, orgResp.GetOrganizationId()) + user := Instance.CreateHumanUserVerified(ctx, orgResp.GetOrganizationId(), gofakeit.Email(), gofakeit.Phone()) + Instance.CreateProjectGrantUserGrant(ctx, orgResp.GetOrganizationId(), projectID, projectGrantResp.GetGrantId(), user.GetUserId()) + return createSessionAndAuthRequestForCallback(ctx, t, clientID, Instance.Users.Get(integration.UserTypeOrgOwner).ID, user.GetUserId()) + }, + want: &oidc_pb.CreateCallbackResponse{ + CallbackUrl: `oidcintegrationtest:\/\/callback\?code=(.*)&state=state`, + Details: &object.Details{ + ChangeDate: timestamppb.Now(), + ResourceOwner: Instance.ID(), + }, + }, + }, + { + name: "projectRoleCheck, no usergrant on project grant and different resourceowner", + ctx: CTX, + dep: func(ctx context.Context, t *testing.T) *oidc_pb.CreateCallbackRequest { + projectID, clientID := createOIDCApplication(ctx, t, true, false) + + orgResp := Instance.CreateOrganization(ctx, "oidc-permission-"+gofakeit.AppName(), gofakeit.Email()) + Instance.CreateProjectGrant(ctx, projectID, orgResp.GetOrganizationId()) + user := Instance.CreateHumanUserVerified(ctx, orgResp.GetOrganizationId(), gofakeit.Email(), gofakeit.Phone()) + return createSessionAndAuthRequestForCallback(ctx, t, clientID, Instance.Users.Get(integration.UserTypeOrgOwner).ID, user.GetUserId()) + }, + wantErr: true, + }, + { + name: "hasProjectCheck, same resourceowner", + ctx: CTX, + dep: func(ctx context.Context, t *testing.T) *oidc_pb.CreateCallbackRequest { + user := Instance.CreateHumanUser(ctx) + _, clientID := createOIDCApplication(ctx, t, false, true) + + return createSessionAndAuthRequestForCallback(ctx, t, clientID, Instance.Users.Get(integration.UserTypeOrgOwner).ID, user.GetUserId()) + }, + want: &oidc_pb.CreateCallbackResponse{ + CallbackUrl: `oidcintegrationtest:\/\/callback\?code=(.*)&state=state`, + Details: &object.Details{ + ChangeDate: timestamppb.Now(), + ResourceOwner: Instance.ID(), + }, + }, + }, + { + name: "hasProjectCheck, different resourceowner", + ctx: CTX, + dep: func(ctx context.Context, t *testing.T) *oidc_pb.CreateCallbackRequest { + _, clientID := createOIDCApplication(ctx, t, false, true) + orgResp := Instance.CreateOrganization(ctx, "oidc-permission-"+gofakeit.AppName(), gofakeit.Email()) + user := Instance.CreateHumanUserVerified(ctx, orgResp.GetOrganizationId(), gofakeit.Email(), gofakeit.Phone()) + + return createSessionAndAuthRequestForCallback(ctx, t, clientID, Instance.Users.Get(integration.UserTypeOrgOwner).ID, user.GetUserId()) + }, + wantErr: true, + }, + { + name: "hasProjectCheck, different resourceowner with project grant", + ctx: CTX, + dep: func(ctx context.Context, t *testing.T) *oidc_pb.CreateCallbackRequest { + projectID, clientID := createOIDCApplication(ctx, t, false, true) + + orgResp := Instance.CreateOrganization(ctx, "oidc-permission-"+gofakeit.AppName(), gofakeit.Email()) + Instance.CreateProjectGrant(ctx, projectID, orgResp.GetOrganizationId()) + user := Instance.CreateHumanUserVerified(ctx, orgResp.GetOrganizationId(), gofakeit.Email(), gofakeit.Phone()) + + return createSessionAndAuthRequestForCallback(ctx, t, clientID, Instance.Users.Get(integration.UserTypeOrgOwner).ID, user.GetUserId()) + }, + want: &oidc_pb.CreateCallbackResponse{ + CallbackUrl: `oidcintegrationtest:\/\/callback\?code=(.*)&state=state`, + Details: &object.Details{ + ChangeDate: timestamppb.Now(), + ResourceOwner: Instance.ID(), + }, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + req := tt.dep(IAMCTX, t) + + got, err := Client.CreateCallback(tt.ctx, req) + if tt.wantErr { + require.Error(t, err) + return + } + require.NoError(t, err) + integration.AssertDetails(t, tt.want, got) + if tt.want != nil { + assert.Regexp(t, regexp.MustCompile(tt.want.CallbackUrl), got.GetCallbackUrl()) + } + }) + } +} + +func createSession(t *testing.T, ctx context.Context, userID string) *session.CreateSessionResponse { + sessionResp, err := Instance.Client.SessionV2beta.CreateSession(ctx, &session.CreateSessionRequest{ + Checks: &session.Checks{ + User: &session.CheckUser{ + Search: &session.CheckUser_UserId{ + UserId: userID, + }, + }, + }, + }) + require.NoError(t, err) + return sessionResp +} + +func createSessionAndAuthRequestForCallback(ctx context.Context, t *testing.T, clientID, loginClient, userID string) *oidc_pb.CreateCallbackRequest { + _, authRequestID, err := Instance.CreateOIDCAuthRequest(ctx, clientID, loginClient, redirectURI) + require.NoError(t, err) + sessionResp := createSession(t, ctx, userID) + return &oidc_pb.CreateCallbackRequest{ + AuthRequestId: authRequestID, + CallbackKind: &oidc_pb.CreateCallbackRequest_Session{ + Session: &oidc_pb.Session{ + SessionId: sessionResp.GetSessionId(), + SessionToken: sessionResp.GetSessionToken(), + }, + }, + } +} + +func createOIDCApplication(ctx context.Context, t *testing.T, projectRoleCheck, hasProjectCheck bool) (string, string) { + project, err := Instance.CreateProjectWithPermissionCheck(ctx, projectRoleCheck, hasProjectCheck) + require.NoError(t, err) + clientV2, err := Instance.CreateOIDCClientLoginVersion(ctx, redirectURI, logoutRedirectURI, project.GetId(), app.OIDCAppType_OIDC_APP_TYPE_NATIVE, app.OIDCAuthMethodType_OIDC_AUTH_METHOD_TYPE_NONE, false, loginV2) + require.NoError(t, err) + return project.GetId(), clientV2.GetClientId() +} diff --git a/internal/api/grpc/oidc/v2beta/integration_test/server_test.go b/internal/api/grpc/oidc/v2beta/integration_test/server_test.go new file mode 100644 index 0000000000..227f4f5910 --- /dev/null +++ b/internal/api/grpc/oidc/v2beta/integration_test/server_test.go @@ -0,0 +1,44 @@ +//go:build integration + +package oidc_test + +import ( + "context" + "os" + "testing" + "time" + + "github.com/zitadel/zitadel/internal/integration" + "github.com/zitadel/zitadel/pkg/grpc/app" + oidc_pb "github.com/zitadel/zitadel/pkg/grpc/oidc/v2beta" +) + +var ( + CTX context.Context + CTXLoginClient context.Context + IAMCTX context.Context + Instance *integration.Instance + Client oidc_pb.OIDCServiceClient + loginV2 = &app.LoginVersion{Version: &app.LoginVersion_LoginV2{LoginV2: &app.LoginV2{BaseUri: nil}}} +) + +const ( + redirectURI = "oidcintegrationtest://callback" + redirectURIImplicit = "http://localhost:9999/callback" + logoutRedirectURI = "oidcintegrationtest://logged-out" +) + +func TestMain(m *testing.M) { + os.Exit(func() int { + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Minute) + defer cancel() + + Instance = integration.NewInstance(ctx) + Client = Instance.Client.OIDCv2beta + + IAMCTX = Instance.WithAuthorization(ctx, integration.UserTypeIAMOwner) + CTX = Instance.WithAuthorization(ctx, integration.UserTypeOrgOwner) + CTXLoginClient = Instance.WithAuthorization(ctx, integration.UserTypeLogin) + return m.Run() + }()) +} diff --git a/internal/api/grpc/oidc/v2beta/oidc.go b/internal/api/grpc/oidc/v2beta/oidc.go index 04ffdbb348..66c4bee828 100644 --- a/internal/api/grpc/oidc/v2beta/oidc.go +++ b/internal/api/grpc/oidc/v2beta/oidc.go @@ -100,8 +100,22 @@ func (s *Server) failAuthRequest(ctx context.Context, authRequestID string, ae * }, nil } +func (s *Server) checkPermission(ctx context.Context, clientID string, userID string) error { + permission, err := s.query.CheckProjectPermissionByClientID(ctx, clientID, userID) + if err != nil { + return err + } + if !permission.HasProjectChecked { + return zerrors.ThrowPermissionDenied(nil, "OIDC-BakSFPjbfN", "Errors.User.ProjectRequired") + } + if !permission.ProjectRoleChecked { + return zerrors.ThrowPermissionDenied(nil, "OIDC-EP688AF2jA", "Errors.User.GrantRequired") + } + return nil +} + func (s *Server) linkSessionToAuthRequest(ctx context.Context, authRequestID string, session *oidc_pb.Session) (*oidc_pb.CreateCallbackResponse, error) { - details, aar, err := s.command.LinkSessionToAuthRequest(ctx, authRequestID, session.GetSessionId(), session.GetSessionToken(), true) + details, aar, err := s.command.LinkSessionToAuthRequest(ctx, authRequestID, session.GetSessionId(), session.GetSessionToken(), true, s.checkPermission) if err != nil { return nil, err } diff --git a/internal/api/grpc/saml/v2/integration/saml_test.go b/internal/api/grpc/saml/v2/integration/saml_test.go index b70099fb20..1f227ab149 100644 --- a/internal/api/grpc/saml/v2/integration/saml_test.go +++ b/internal/api/grpc/saml/v2/integration/saml_test.go @@ -5,13 +5,13 @@ package saml_test import ( "context" "net/url" - "os" "regexp" "testing" "time" "github.com/brianvoe/gofakeit/v6" "github.com/crewjam/saml" + "github.com/crewjam/saml/samlsp" "github.com/muhlemmer/gu" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -19,80 +19,48 @@ import ( "github.com/zitadel/zitadel/internal/integration" "github.com/zitadel/zitadel/pkg/grpc/object/v2" - oidc_pb "github.com/zitadel/zitadel/pkg/grpc/oidc/v2" saml_pb "github.com/zitadel/zitadel/pkg/grpc/saml/v2" "github.com/zitadel/zitadel/pkg/grpc/session/v2" ) -var ( - CTX context.Context - Instance *integration.Instance - Client saml_pb.SAMLServiceClient -) - -func TestMain(m *testing.M) { - os.Exit(func() int { - ctx, cancel := context.WithTimeout(context.Background(), 15*time.Minute) - defer cancel() - - Instance = integration.NewInstance(ctx) - Client = Instance.Client.SAMLv2 - - CTX = Instance.WithAuthorization(ctx, integration.UserTypeOrgOwner) - return m.Run() - }()) -} - -func TestServer_GetAuthRequest(t *testing.T) { - rootURL := "https://sp.example.com" +func TestServer_GetSAMLRequest(t *testing.T) { idpMetadata, err := Instance.GetSAMLIDPMetadata() require.NoError(t, err) - spMiddlewareRedirect, err := integration.CreateSAMLSP(rootURL, idpMetadata, saml.HTTPRedirectBinding) - require.NoError(t, err) - spMiddlewarePost, err := integration.CreateSAMLSP(rootURL, idpMetadata, saml.HTTPPostBinding) - require.NoError(t, err) acsRedirect := idpMetadata.IDPSSODescriptors[0].SingleSignOnServices[0] acsPost := idpMetadata.IDPSSODescriptors[0].SingleSignOnServices[1] - project, err := Instance.CreateProject(CTX) - require.NoError(t, err) - _, err = Instance.CreateSAMLClient(CTX, project.GetId(), spMiddlewareRedirect) - require.NoError(t, err) - _, err = Instance.CreateSAMLClient(CTX, project.GetId(), spMiddlewarePost) - require.NoError(t, err) - - now := time.Now() + _, _, spMiddlewareRedirect := createSAMLApplication(CTX, t, idpMetadata, saml.HTTPRedirectBinding, false, false) + _, _, spMiddlewarePost := createSAMLApplication(CTX, t, idpMetadata, saml.HTTPPostBinding, false, false) tests := []struct { name string - dep func() (string, error) - want *oidc_pb.GetAuthRequestResponse + dep func() (time.Time, string, error) wantErr bool }{ { name: "Not found", - dep: func() (string, error) { - return "123", nil + dep: func() (time.Time, string, error) { + return time.Time{}, "123", nil }, wantErr: true, }, { name: "success, redirect binding", - dep: func() (string, error) { + dep: func() (time.Time, string, error) { return Instance.CreateSAMLAuthRequest(spMiddlewareRedirect, Instance.Users[integration.UserTypeOrgOwner].ID, acsRedirect, gofakeit.BitcoinAddress(), saml.HTTPRedirectBinding) }, }, { name: "success, post binding", - dep: func() (string, error) { + dep: func() (time.Time, string, error) { return Instance.CreateSAMLAuthRequest(spMiddlewarePost, Instance.Users[integration.UserTypeOrgOwner].ID, acsPost, gofakeit.BitcoinAddress(), saml.HTTPPostBinding) }, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - authRequestID, err := tt.dep() + creationTime, authRequestID, err := tt.dep() require.NoError(t, err) retryDuration, tick := integration.WaitForAndTickWithMaxDuration(CTX, time.Minute) @@ -108,7 +76,7 @@ func TestServer_GetAuthRequest(t *testing.T) { authRequest := got.GetSamlRequest() assert.NotNil(ttt, authRequest) assert.Equal(ttt, authRequestID, authRequest.GetId()) - assert.WithinRange(ttt, authRequest.GetCreationDate().AsTime(), now.Add(-time.Second), now.Add(time.Second)) + assert.WithinRange(ttt, authRequest.GetCreationDate().AsTime(), creationTime.Add(-time.Second), creationTime.Add(time.Second)) }, retryDuration, tick, "timeout waiting for expected saml request result") }) } @@ -117,33 +85,12 @@ func TestServer_GetAuthRequest(t *testing.T) { func TestServer_CreateResponse(t *testing.T) { idpMetadata, err := Instance.GetSAMLIDPMetadata() require.NoError(t, err) - rootURLRedirect := "spredirect.example.com" - spMiddlewareRedirect, err := integration.CreateSAMLSP("https://"+rootURLRedirect, idpMetadata, saml.HTTPRedirectBinding) - require.NoError(t, err) - rootURLPost := "sppost.example.com" - spMiddlewarePost, err := integration.CreateSAMLSP("https://"+rootURLPost, idpMetadata, saml.HTTPPostBinding) - require.NoError(t, err) - acsRedirect := idpMetadata.IDPSSODescriptors[0].SingleSignOnServices[0] acsPost := idpMetadata.IDPSSODescriptors[0].SingleSignOnServices[1] - project, err := Instance.CreateProject(CTX) - require.NoError(t, err) - _, err = Instance.CreateSAMLClient(CTX, project.GetId(), spMiddlewareRedirect) - require.NoError(t, err) - _, err = Instance.CreateSAMLClient(CTX, project.GetId(), spMiddlewarePost) - require.NoError(t, err) - - sessionResp, err := Instance.Client.SessionV2.CreateSession(CTX, &session.CreateSessionRequest{ - Checks: &session.Checks{ - User: &session.CheckUser{ - Search: &session.CheckUser_UserId{ - UserId: Instance.Users[integration.UserTypeOrgOwner].ID, - }, - }, - }, - }) - require.NoError(t, err) + _, rootURLPost, spMiddlewarePost := createSAMLApplication(CTX, t, idpMetadata, saml.HTTPPostBinding, false, false) + _, rootURLRedirect, spMiddlewareRedirect := createSAMLApplication(CTX, t, idpMetadata, saml.HTTPRedirectBinding, false, false) + sessionResp := createSession(CTX, t, Instance.Users[integration.UserTypeOrgOwner].ID) tests := []struct { name string @@ -170,7 +117,7 @@ func TestServer_CreateResponse(t *testing.T) { name: "session not found", req: &saml_pb.CreateResponseRequest{ SamlRequestId: func() string { - authRequestID, err := Instance.CreateSAMLAuthRequest(spMiddlewareRedirect, Instance.Users[integration.UserTypeOrgOwner].ID, acsRedirect, gofakeit.BitcoinAddress(), saml.HTTPRedirectBinding) + _, authRequestID, err := Instance.CreateSAMLAuthRequest(spMiddlewareRedirect, Instance.Users[integration.UserTypeOrgOwner].ID, acsRedirect, gofakeit.BitcoinAddress(), saml.HTTPRedirectBinding) require.NoError(t, err) return authRequestID }(), @@ -187,7 +134,7 @@ func TestServer_CreateResponse(t *testing.T) { name: "session token invalid", req: &saml_pb.CreateResponseRequest{ SamlRequestId: func() string { - authRequestID, err := Instance.CreateSAMLAuthRequest(spMiddlewareRedirect, Instance.Users[integration.UserTypeOrgOwner].ID, acsRedirect, gofakeit.BitcoinAddress(), saml.HTTPRedirectBinding) + _, authRequestID, err := Instance.CreateSAMLAuthRequest(spMiddlewareRedirect, Instance.Users[integration.UserTypeOrgOwner].ID, acsRedirect, gofakeit.BitcoinAddress(), saml.HTTPRedirectBinding) require.NoError(t, err) return authRequestID }(), @@ -204,7 +151,7 @@ func TestServer_CreateResponse(t *testing.T) { name: "fail callback, post", req: &saml_pb.CreateResponseRequest{ SamlRequestId: func() string { - authRequestID, err := Instance.CreateSAMLAuthRequest(spMiddlewarePost, Instance.Users[integration.UserTypeOrgOwner].ID, acsPost, gofakeit.BitcoinAddress(), saml.HTTPPostBinding) + _, authRequestID, err := Instance.CreateSAMLAuthRequest(spMiddlewarePost, Instance.Users[integration.UserTypeOrgOwner].ID, acsPost, gofakeit.BitcoinAddress(), saml.HTTPPostBinding) require.NoError(t, err) return authRequestID }(), @@ -232,7 +179,7 @@ func TestServer_CreateResponse(t *testing.T) { name: "fail callback, post, already failed", req: &saml_pb.CreateResponseRequest{ SamlRequestId: func() string { - authRequestID, err := Instance.CreateSAMLAuthRequest(spMiddlewarePost, Instance.Users[integration.UserTypeOrgOwner].ID, acsPost, gofakeit.BitcoinAddress(), saml.HTTPPostBinding) + _, authRequestID, err := Instance.CreateSAMLAuthRequest(spMiddlewarePost, Instance.Users[integration.UserTypeOrgOwner].ID, acsPost, gofakeit.BitcoinAddress(), saml.HTTPPostBinding) require.NoError(t, err) Instance.FailSAMLAuthRequest(CTX, authRequestID, saml_pb.ErrorReason_ERROR_REASON_AUTH_N_FAILED) return authRequestID @@ -250,7 +197,7 @@ func TestServer_CreateResponse(t *testing.T) { name: "fail callback, redirect", req: &saml_pb.CreateResponseRequest{ SamlRequestId: func() string { - authRequestID, err := Instance.CreateSAMLAuthRequest(spMiddlewareRedirect, Instance.Users[integration.UserTypeOrgOwner].ID, acsPost, gofakeit.BitcoinAddress(), saml.HTTPPostBinding) + _, authRequestID, err := Instance.CreateSAMLAuthRequest(spMiddlewareRedirect, Instance.Users[integration.UserTypeOrgOwner].ID, acsPost, gofakeit.BitcoinAddress(), saml.HTTPPostBinding) require.NoError(t, err) return authRequestID }(), @@ -275,7 +222,7 @@ func TestServer_CreateResponse(t *testing.T) { name: "callback, redirect", req: &saml_pb.CreateResponseRequest{ SamlRequestId: func() string { - authRequestID, err := Instance.CreateSAMLAuthRequest(spMiddlewareRedirect, Instance.Users[integration.UserTypeOrgOwner].ID, acsRedirect, gofakeit.BitcoinAddress(), saml.HTTPRedirectBinding) + _, authRequestID, err := Instance.CreateSAMLAuthRequest(spMiddlewareRedirect, Instance.Users[integration.UserTypeOrgOwner].ID, acsRedirect, gofakeit.BitcoinAddress(), saml.HTTPRedirectBinding) require.NoError(t, err) return authRequestID }(), @@ -300,7 +247,7 @@ func TestServer_CreateResponse(t *testing.T) { name: "callback, post", req: &saml_pb.CreateResponseRequest{ SamlRequestId: func() string { - authRequestID, err := Instance.CreateSAMLAuthRequest(spMiddlewarePost, Instance.Users[integration.UserTypeOrgOwner].ID, acsPost, gofakeit.BitcoinAddress(), saml.HTTPPostBinding) + _, authRequestID, err := Instance.CreateSAMLAuthRequest(spMiddlewarePost, Instance.Users[integration.UserTypeOrgOwner].ID, acsPost, gofakeit.BitcoinAddress(), saml.HTTPPostBinding) require.NoError(t, err) return authRequestID }(), @@ -328,7 +275,7 @@ func TestServer_CreateResponse(t *testing.T) { name: "callback, post", req: &saml_pb.CreateResponseRequest{ SamlRequestId: func() string { - authRequestID, err := Instance.CreateSAMLAuthRequest(spMiddlewarePost, Instance.Users[integration.UserTypeOrgOwner].ID, acsPost, gofakeit.BitcoinAddress(), saml.HTTPPostBinding) + _, authRequestID, err := Instance.CreateSAMLAuthRequest(spMiddlewarePost, Instance.Users[integration.UserTypeOrgOwner].ID, acsPost, gofakeit.BitcoinAddress(), saml.HTTPPostBinding) require.NoError(t, err) Instance.SuccessfulSAMLAuthRequest(CTX, Instance.Users[integration.UserTypeOrgOwner].ID, authRequestID) return authRequestID @@ -365,3 +312,338 @@ func TestServer_CreateResponse(t *testing.T) { }) } } + +func TestServer_CreateResponse_Permission(t *testing.T) { + idpMetadata, err := Instance.GetSAMLIDPMetadata() + require.NoError(t, err) + acsRedirect := idpMetadata.IDPSSODescriptors[0].SingleSignOnServices[0] + + tests := []struct { + name string + dep func(ctx context.Context, t *testing.T) *saml_pb.CreateResponseRequest + want *saml_pb.CreateResponseResponse + wantURL *url.URL + wantErr bool + }{ + { + name: "usergrant to project and different resourceowner with different project grant", + dep: func(ctx context.Context, t *testing.T) *saml_pb.CreateResponseRequest { + projectID, _, sp := createSAMLApplication(ctx, t, idpMetadata, saml.HTTPRedirectBinding, true, true) + projectID2, _, _ := createSAMLApplication(ctx, t, idpMetadata, saml.HTTPRedirectBinding, true, true) + + orgResp := Instance.CreateOrganization(ctx, "saml-permission-"+gofakeit.AppName(), gofakeit.Email()) + Instance.CreateProjectGrant(ctx, projectID2, orgResp.GetOrganizationId()) + user := Instance.CreateHumanUserVerified(ctx, orgResp.GetOrganizationId(), gofakeit.Email(), gofakeit.Phone()) + Instance.CreateProjectUserGrant(t, ctx, projectID, user.GetUserId()) + + return createSessionAndSmlRequestForCallback(ctx, t, sp, Instance.Users[integration.UserTypeOrgOwner].ID, acsRedirect, user.GetUserId(), saml.HTTPRedirectBinding) + }, + wantErr: true, + }, + { + name: "usergrant to project and different resourceowner with project grant", + dep: func(ctx context.Context, t *testing.T) *saml_pb.CreateResponseRequest { + projectID, _, sp := createSAMLApplication(ctx, t, idpMetadata, saml.HTTPRedirectBinding, true, true) + + orgResp := Instance.CreateOrganization(ctx, "saml-permission-"+gofakeit.AppName(), gofakeit.Email()) + Instance.CreateProjectGrant(ctx, projectID, orgResp.GetOrganizationId()) + user := Instance.CreateHumanUserVerified(ctx, orgResp.GetOrganizationId(), gofakeit.Email(), gofakeit.Phone()) + Instance.CreateProjectUserGrant(t, ctx, projectID, user.GetUserId()) + + return createSessionAndSmlRequestForCallback(ctx, t, sp, Instance.Users[integration.UserTypeOrgOwner].ID, acsRedirect, user.GetUserId(), saml.HTTPRedirectBinding) + }, + want: &saml_pb.CreateResponseResponse{ + Url: `https:\/\/(.*)\/saml\/acs\?RelayState=(.*)&SAMLResponse=(.*)&SigAlg=(.*)&Signature=(.*)`, + Binding: &saml_pb.CreateResponseResponse_Redirect{Redirect: &saml_pb.RedirectResponse{}}, + Details: &object.Details{ + ChangeDate: timestamppb.Now(), + ResourceOwner: Instance.ID(), + }, + }, + wantErr: false, + }, + { + name: "usergrant to project grant and different resourceowner with project grant", + dep: func(ctx context.Context, t *testing.T) *saml_pb.CreateResponseRequest { + projectID, _, sp := createSAMLApplication(ctx, t, idpMetadata, saml.HTTPRedirectBinding, true, true) + + orgResp := Instance.CreateOrganization(ctx, "saml-permission-"+gofakeit.AppName(), gofakeit.Email()) + projectGrantResp := Instance.CreateProjectGrant(ctx, projectID, orgResp.GetOrganizationId()) + user := Instance.CreateHumanUserVerified(ctx, orgResp.GetOrganizationId(), gofakeit.Email(), gofakeit.Phone()) + Instance.CreateProjectGrantUserGrant(ctx, orgResp.GetOrganizationId(), projectID, projectGrantResp.GetGrantId(), user.GetUserId()) + + return createSessionAndSmlRequestForCallback(ctx, t, sp, Instance.Users[integration.UserTypeOrgOwner].ID, acsRedirect, user.GetUserId(), saml.HTTPRedirectBinding) + }, + want: &saml_pb.CreateResponseResponse{ + Url: `https:\/\/(.*)\/saml\/acs\?RelayState=(.*)&SAMLResponse=(.*)&SigAlg=(.*)&Signature=(.*)`, + Binding: &saml_pb.CreateResponseResponse_Redirect{Redirect: &saml_pb.RedirectResponse{}}, + Details: &object.Details{ + ChangeDate: timestamppb.Now(), + ResourceOwner: Instance.ID(), + }, + }, + wantErr: false, + }, + { + name: "no usergrant and different resourceowner", + dep: func(ctx context.Context, t *testing.T) *saml_pb.CreateResponseRequest { + _, _, sp := createSAMLApplication(ctx, t, idpMetadata, saml.HTTPRedirectBinding, true, true) + + orgResp := Instance.CreateOrganization(ctx, "saml-permisison-"+gofakeit.AppName(), gofakeit.Email()) + user := Instance.CreateHumanUserVerified(ctx, orgResp.GetOrganizationId(), gofakeit.Email(), gofakeit.Phone()) + return createSessionAndSmlRequestForCallback(ctx, t, sp, Instance.Users[integration.UserTypeOrgOwner].ID, acsRedirect, user.GetUserId(), saml.HTTPRedirectBinding) + }, + wantErr: true, + }, + { + name: "no usergrant and same resourceowner", + dep: func(ctx context.Context, t *testing.T) *saml_pb.CreateResponseRequest { + _, _, sp := createSAMLApplication(ctx, t, idpMetadata, saml.HTTPRedirectBinding, true, true) + user := Instance.CreateHumanUser(ctx) + + return createSessionAndSmlRequestForCallback(ctx, t, sp, Instance.Users[integration.UserTypeOrgOwner].ID, acsRedirect, user.GetUserId(), saml.HTTPRedirectBinding) + }, + wantErr: true, + }, + { + name: "usergrant and different resourceowner", + dep: func(ctx context.Context, t *testing.T) *saml_pb.CreateResponseRequest { + projectID, _, sp := createSAMLApplication(ctx, t, idpMetadata, saml.HTTPRedirectBinding, true, true) + + orgResp := Instance.CreateOrganization(ctx, "saml-permisison-"+gofakeit.AppName(), gofakeit.Email()) + user := Instance.CreateHumanUserVerified(ctx, orgResp.GetOrganizationId(), gofakeit.Email(), gofakeit.Phone()) + Instance.CreateProjectUserGrant(t, ctx, projectID, user.GetUserId()) + + return createSessionAndSmlRequestForCallback(ctx, t, sp, Instance.Users[integration.UserTypeOrgOwner].ID, acsRedirect, user.GetUserId(), saml.HTTPRedirectBinding) + }, + wantErr: true, + }, + { + name: "usergrant and same resourceowner", + dep: func(ctx context.Context, t *testing.T) *saml_pb.CreateResponseRequest { + projectID, _, sp := createSAMLApplication(ctx, t, idpMetadata, saml.HTTPRedirectBinding, true, true) + + user := Instance.CreateHumanUser(ctx) + Instance.CreateProjectUserGrant(t, ctx, projectID, user.GetUserId()) + + return createSessionAndSmlRequestForCallback(ctx, t, sp, Instance.Users[integration.UserTypeOrgOwner].ID, acsRedirect, user.GetUserId(), saml.HTTPRedirectBinding) + }, + want: &saml_pb.CreateResponseResponse{ + Url: `https:\/\/(.*)\/saml\/acs\?RelayState=(.*)&SAMLResponse=(.*)&SigAlg=(.*)&Signature=(.*)`, + Binding: &saml_pb.CreateResponseResponse_Redirect{Redirect: &saml_pb.RedirectResponse{}}, + Details: &object.Details{ + ChangeDate: timestamppb.Now(), + ResourceOwner: Instance.ID(), + }, + }, + }, + { + name: "projectRoleCheck, usergrant and same resourceowner", + dep: func(ctx context.Context, t *testing.T) *saml_pb.CreateResponseRequest { + projectID, _, sp := createSAMLApplication(ctx, t, idpMetadata, saml.HTTPRedirectBinding, true, false) + + user := Instance.CreateHumanUser(ctx) + Instance.CreateProjectUserGrant(t, ctx, projectID, user.GetUserId()) + + return createSessionAndSmlRequestForCallback(ctx, t, sp, Instance.Users[integration.UserTypeOrgOwner].ID, acsRedirect, user.GetUserId(), saml.HTTPRedirectBinding) + }, + want: &saml_pb.CreateResponseResponse{ + Url: `https:\/\/(.*)\/saml\/acs\?RelayState=(.*)&SAMLResponse=(.*)&SigAlg=(.*)&Signature=(.*)`, + Binding: &saml_pb.CreateResponseResponse_Redirect{Redirect: &saml_pb.RedirectResponse{}}, + Details: &object.Details{ + ChangeDate: timestamppb.Now(), + ResourceOwner: Instance.ID(), + }, + }, + }, + { + name: "projectRoleCheck, no usergrant and same resourceowner", + dep: func(ctx context.Context, t *testing.T) *saml_pb.CreateResponseRequest { + _, _, sp := createSAMLApplication(ctx, t, idpMetadata, saml.HTTPRedirectBinding, true, false) + user := Instance.CreateHumanUser(ctx) + + return createSessionAndSmlRequestForCallback(ctx, t, sp, Instance.Users[integration.UserTypeOrgOwner].ID, acsRedirect, user.GetUserId(), saml.HTTPRedirectBinding) + }, + wantErr: true, + }, + { + name: "projectRoleCheck, usergrant and different resourceowner", + dep: func(ctx context.Context, t *testing.T) *saml_pb.CreateResponseRequest { + projectID, _, sp := createSAMLApplication(ctx, t, idpMetadata, saml.HTTPRedirectBinding, true, false) + orgResp := Instance.CreateOrganization(ctx, "saml-permisison-"+gofakeit.AppName(), gofakeit.Email()) + user := Instance.CreateHumanUserVerified(ctx, orgResp.GetOrganizationId(), gofakeit.Email(), gofakeit.Phone()) + Instance.CreateProjectUserGrant(t, ctx, projectID, user.GetUserId()) + + return createSessionAndSmlRequestForCallback(ctx, t, sp, Instance.Users[integration.UserTypeOrgOwner].ID, acsRedirect, user.GetUserId(), saml.HTTPRedirectBinding) + }, + want: &saml_pb.CreateResponseResponse{ + Url: `https:\/\/(.*)\/saml\/acs\?RelayState=(.*)&SAMLResponse=(.*)&SigAlg=(.*)&Signature=(.*)`, + Binding: &saml_pb.CreateResponseResponse_Redirect{Redirect: &saml_pb.RedirectResponse{}}, + Details: &object.Details{ + ChangeDate: timestamppb.Now(), + ResourceOwner: Instance.ID(), + }, + }, + }, + { + name: "projectRoleCheck, no usergrant and different resourceowner", + dep: func(ctx context.Context, t *testing.T) *saml_pb.CreateResponseRequest { + _, _, sp := createSAMLApplication(ctx, t, idpMetadata, saml.HTTPRedirectBinding, true, false) + orgResp := Instance.CreateOrganization(ctx, "saml-permisison-"+gofakeit.AppName(), gofakeit.Email()) + user := Instance.CreateHumanUserVerified(ctx, orgResp.GetOrganizationId(), gofakeit.Email(), gofakeit.Phone()) + + return createSessionAndSmlRequestForCallback(ctx, t, sp, Instance.Users[integration.UserTypeOrgOwner].ID, acsRedirect, user.GetUserId(), saml.HTTPRedirectBinding) + }, + wantErr: true, + }, + { + name: "projectRoleCheck, usergrant on project grant and different resourceowner", + dep: func(ctx context.Context, t *testing.T) *saml_pb.CreateResponseRequest { + projectID, _, sp := createSAMLApplication(ctx, t, idpMetadata, saml.HTTPRedirectBinding, true, false) + + orgResp := Instance.CreateOrganization(ctx, "saml-permissison-"+gofakeit.AppName(), gofakeit.Email()) + projectGrantResp := Instance.CreateProjectGrant(ctx, projectID, orgResp.GetOrganizationId()) + user := Instance.CreateHumanUserVerified(ctx, orgResp.GetOrganizationId(), gofakeit.Email(), gofakeit.Phone()) + Instance.CreateProjectGrantUserGrant(ctx, orgResp.GetOrganizationId(), projectID, projectGrantResp.GetGrantId(), user.GetUserId()) + + return createSessionAndSmlRequestForCallback(ctx, t, sp, Instance.Users[integration.UserTypeOrgOwner].ID, acsRedirect, user.GetUserId(), saml.HTTPRedirectBinding) + }, + want: &saml_pb.CreateResponseResponse{ + Url: `https:\/\/(.*)\/saml\/acs\?RelayState=(.*)&SAMLResponse=(.*)&SigAlg=(.*)&Signature=(.*)`, + Binding: &saml_pb.CreateResponseResponse_Redirect{Redirect: &saml_pb.RedirectResponse{}}, + Details: &object.Details{ + ChangeDate: timestamppb.Now(), + ResourceOwner: Instance.ID(), + }, + }, + }, + { + name: "projectRoleCheck, no usergrant on project grant and different resourceowner", + dep: func(ctx context.Context, t *testing.T) *saml_pb.CreateResponseRequest { + projectID, _, sp := createSAMLApplication(ctx, t, idpMetadata, saml.HTTPRedirectBinding, true, false) + + orgResp := Instance.CreateOrganization(ctx, "saml-permissison-"+gofakeit.AppName(), gofakeit.Email()) + Instance.CreateProjectGrant(ctx, projectID, orgResp.GetOrganizationId()) + user := Instance.CreateHumanUserVerified(ctx, orgResp.GetOrganizationId(), gofakeit.Email(), gofakeit.Phone()) + + return createSessionAndSmlRequestForCallback(ctx, t, sp, Instance.Users[integration.UserTypeOrgOwner].ID, acsRedirect, user.GetUserId(), saml.HTTPRedirectBinding) + }, + wantErr: true, + }, + { + name: "hasProjectCheck, same resourceowner", + dep: func(ctx context.Context, t *testing.T) *saml_pb.CreateResponseRequest { + _, _, sp := createSAMLApplication(ctx, t, idpMetadata, saml.HTTPRedirectBinding, false, true) + user := Instance.CreateHumanUser(ctx) + + return createSessionAndSmlRequestForCallback(ctx, t, sp, Instance.Users[integration.UserTypeOrgOwner].ID, acsRedirect, user.GetUserId(), saml.HTTPRedirectBinding) + }, + want: &saml_pb.CreateResponseResponse{ + Url: `https:\/\/(.*)\/saml\/acs\?RelayState=(.*)&SAMLResponse=(.*)&SigAlg=(.*)&Signature=(.*)`, + Binding: &saml_pb.CreateResponseResponse_Redirect{Redirect: &saml_pb.RedirectResponse{}}, + Details: &object.Details{ + ChangeDate: timestamppb.Now(), + ResourceOwner: Instance.ID(), + }, + }, + }, + { + name: "hasProjectCheck, different resourceowner", + dep: func(ctx context.Context, t *testing.T) *saml_pb.CreateResponseRequest { + _, _, sp := createSAMLApplication(ctx, t, idpMetadata, saml.HTTPRedirectBinding, false, true) + orgResp := Instance.CreateOrganization(ctx, "saml-permisison-"+gofakeit.AppName(), gofakeit.Email()) + user := Instance.CreateHumanUserVerified(ctx, orgResp.GetOrganizationId(), gofakeit.Email(), gofakeit.Phone()) + + return createSessionAndSmlRequestForCallback(ctx, t, sp, Instance.Users[integration.UserTypeOrgOwner].ID, acsRedirect, user.GetUserId(), saml.HTTPRedirectBinding) + }, + wantErr: true, + }, + { + name: "hasProjectCheck, different resourceowner with project grant", + dep: func(ctx context.Context, t *testing.T) *saml_pb.CreateResponseRequest { + projectID, _, sp := createSAMLApplication(ctx, t, idpMetadata, saml.HTTPRedirectBinding, false, true) + orgResp := Instance.CreateOrganization(ctx, "saml-permissison-"+gofakeit.AppName(), gofakeit.Email()) + Instance.CreateProjectGrant(ctx, projectID, orgResp.GetOrganizationId()) + user := Instance.CreateHumanUserVerified(ctx, orgResp.GetOrganizationId(), gofakeit.Email(), gofakeit.Phone()) + + return createSessionAndSmlRequestForCallback(ctx, t, sp, Instance.Users[integration.UserTypeOrgOwner].ID, acsRedirect, user.GetUserId(), saml.HTTPRedirectBinding) + }, + want: &saml_pb.CreateResponseResponse{ + Url: `https:\/\/(.*)\/saml\/acs\?RelayState=(.*)&SAMLResponse=(.*)&SigAlg=(.*)&Signature=(.*)`, + Binding: &saml_pb.CreateResponseResponse_Redirect{Redirect: &saml_pb.RedirectResponse{}}, + Details: &object.Details{ + ChangeDate: timestamppb.Now(), + ResourceOwner: Instance.ID(), + }, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + req := tt.dep(IAMCTX, t) + + got, err := Client.CreateResponse(CTX, req) + if tt.wantErr { + require.Error(t, err) + return + } + require.NoError(t, err) + integration.AssertDetails(t, tt.want, got) + if tt.want != nil { + assert.Regexp(t, regexp.MustCompile(tt.want.Url), got.GetUrl()) + if tt.want.GetPost() != nil { + assert.NotEmpty(t, got.GetPost().GetRelayState()) + assert.NotEmpty(t, got.GetPost().GetSamlResponse()) + } + if tt.want.GetRedirect() != nil { + assert.NotNil(t, got.GetRedirect()) + } + } + }) + } +} + +func createSession(ctx context.Context, t *testing.T, userID string) *session.CreateSessionResponse { + sessionResp, err := Instance.Client.SessionV2.CreateSession(ctx, &session.CreateSessionRequest{ + Checks: &session.Checks{ + User: &session.CheckUser{ + Search: &session.CheckUser_UserId{ + UserId: userID, + }, + }, + }, + }) + require.NoError(t, err) + return sessionResp +} + +func createSessionAndSmlRequestForCallback(ctx context.Context, t *testing.T, sp *samlsp.Middleware, loginClient string, acsRedirect saml.Endpoint, userID, binding string) *saml_pb.CreateResponseRequest { + _, authRequestID, err := Instance.CreateSAMLAuthRequest(sp, loginClient, acsRedirect, gofakeit.BitcoinAddress(), binding) + require.NoError(t, err) + sessionResp := createSession(ctx, t, userID) + return &saml_pb.CreateResponseRequest{ + SamlRequestId: authRequestID, + ResponseKind: &saml_pb.CreateResponseRequest_Session{ + Session: &saml_pb.Session{ + SessionId: sessionResp.GetSessionId(), + SessionToken: sessionResp.GetSessionToken(), + }, + }, + } +} + +func createSAMLSP(t *testing.T, idpMetadata *saml.EntityDescriptor, binding string) (string, *samlsp.Middleware) { + rootURL := "example." + gofakeit.DomainName() + spMiddleware, err := integration.CreateSAMLSP("https://"+rootURL, idpMetadata, binding) + require.NoError(t, err) + return rootURL, spMiddleware +} + +func createSAMLApplication(ctx context.Context, t *testing.T, idpMetadata *saml.EntityDescriptor, binding string, projectRoleCheck, hasProjectCheck bool) (string, string, *samlsp.Middleware) { + project, err := Instance.CreateProjectWithPermissionCheck(ctx, projectRoleCheck, hasProjectCheck) + require.NoError(t, err) + rootURL, sp := createSAMLSP(t, idpMetadata, binding) + _, err = Instance.CreateSAMLClient(ctx, project.GetId(), sp) + require.NoError(t, err) + return project.GetId(), rootURL, sp +} diff --git a/internal/api/grpc/saml/v2/integration/server_test.go b/internal/api/grpc/saml/v2/integration/server_test.go new file mode 100644 index 0000000000..ab9e92a157 --- /dev/null +++ b/internal/api/grpc/saml/v2/integration/server_test.go @@ -0,0 +1,34 @@ +//go:build integration + +package saml_test + +import ( + "context" + "os" + "testing" + "time" + + "github.com/zitadel/zitadel/internal/integration" + saml_pb "github.com/zitadel/zitadel/pkg/grpc/saml/v2" +) + +var ( + CTX context.Context + IAMCTX context.Context + Instance *integration.Instance + Client saml_pb.SAMLServiceClient +) + +func TestMain(m *testing.M) { + os.Exit(func() int { + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Minute) + defer cancel() + + Instance = integration.NewInstance(ctx) + Client = Instance.Client.SAMLv2 + + IAMCTX = Instance.WithAuthorization(ctx, integration.UserTypeIAMOwner) + CTX = Instance.WithAuthorization(ctx, integration.UserTypeOrgOwner) + return m.Run() + }()) +} diff --git a/internal/api/grpc/saml/v2/saml.go b/internal/api/grpc/saml/v2/saml.go index de4f3440ab..866846dfd7 100644 --- a/internal/api/grpc/saml/v2/saml.go +++ b/internal/api/grpc/saml/v2/saml.go @@ -14,7 +14,7 @@ import ( saml_pb "github.com/zitadel/zitadel/pkg/grpc/saml/v2" ) -func (s *Server) GetAuthRequest(ctx context.Context, req *saml_pb.GetSAMLRequestRequest) (*saml_pb.GetSAMLRequestResponse, error) { +func (s *Server) GetSAMLRequest(ctx context.Context, req *saml_pb.GetSAMLRequestRequest) (*saml_pb.GetSAMLRequestResponse, error) { authRequest, err := s.query.SamlRequestByID(ctx, true, req.GetSamlRequestId(), true) if err != nil { logging.WithError(err).Error("query samlRequest by ID") @@ -56,8 +56,22 @@ func (s *Server) failSAMLRequest(ctx context.Context, samlRequestID string, ae * return createCallbackResponseFromBinding(details, url, body, authReq.RelayState), nil } +func (s *Server) checkPermission(ctx context.Context, issuer string, userID string) error { + permission, err := s.query.CheckProjectPermissionByEntityID(ctx, issuer, userID) + if err != nil { + return err + } + if !permission.HasProjectChecked { + return zerrors.ThrowPermissionDenied(nil, "SAML-foSyH49RvL", "Errors.User.ProjectRequired") + } + if !permission.ProjectRoleChecked { + return zerrors.ThrowPermissionDenied(nil, "SAML-foSyH49RvL", "Errors.User.GrantRequired") + } + return nil +} + func (s *Server) linkSessionToSAMLRequest(ctx context.Context, samlRequestID string, session *saml_pb.Session) (*saml_pb.CreateResponseResponse, error) { - details, aar, err := s.command.LinkSessionToSAMLRequest(ctx, samlRequestID, session.GetSessionId(), session.GetSessionToken(), true) + details, aar, err := s.command.LinkSessionToSAMLRequest(ctx, samlRequestID, session.GetSessionId(), session.GetSessionToken(), true, s.checkPermission) if err != nil { return nil, err } diff --git a/internal/api/grpc/session/v2/integration_test/query_test.go b/internal/api/grpc/session/v2/integration_test/query_test.go index 36e412be23..4b2eacf570 100644 --- a/internal/api/grpc/session/v2/integration_test/query_test.go +++ b/internal/api/grpc/session/v2/integration_test/query_test.go @@ -695,6 +695,12 @@ func TestServer_ListSessions(t *testing.T) { return } + // expected count of sessions is not equal to created dependencies + if !assert.Len(ttt, tt.want.Sessions, len(infos)) { + return + } + + // expected count of sessions is not equal to received sessions if !assert.Equal(ttt, got.Details.TotalResult, tt.want.Details.TotalResult) || !assert.Len(ttt, got.Sessions, len(tt.want.Sessions)) { return } @@ -705,8 +711,17 @@ func TestServer_ListSessions(t *testing.T) { tt.want.Sessions[i].CreationDate = infos[i].Details.GetChangeDate() tt.want.Sessions[i].ChangeDate = infos[i].Details.GetChangeDate() - verifySession(ttt, got.Sessions[i], tt.want.Sessions[i], time.Minute, tt.wantExpirationWindow, infos[i].UserID, tt.wantFactors...) + // only check for contents of the session, not sorting for now + found := false + for _, session := range got.Sessions { + if session.Id == infos[i].ID { + verifySession(ttt, session, tt.want.Sessions[i], time.Minute, tt.wantExpirationWindow, infos[i].UserID, tt.wantFactors...) + found = true + } + } + assert.True(t, found) } + integration.AssertListDetails(ttt, tt.want, got) }, retryDuration, tick) }) diff --git a/internal/api/oidc/integration_test/client_test.go b/internal/api/oidc/integration_test/client_test.go index 6d61e84437..43b000108c 100644 --- a/internal/api/oidc/integration_test/client_test.go +++ b/internal/api/oidc/integration_test/client_test.go @@ -307,7 +307,7 @@ func TestServer_VerifyClient(t *testing.T) { } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - authRequestID, err := Instance.CreateOIDCAuthRequest(CTX, tt.client.authReqClientID, Instance.Users.Get(integration.UserTypeLogin).ID, redirectURI, oidc.ScopeOpenID) + _, authRequestID, err := Instance.CreateOIDCAuthRequest(CTX, tt.client.authReqClientID, Instance.Users.Get(integration.UserTypeLogin).ID, redirectURI, oidc.ScopeOpenID) require.NoError(t, err) linkResp, err := Instance.Client.OIDCv2.CreateCallback(CTXLOGIN, &oidc_pb.CreateCallbackRequest{ AuthRequestId: authRequestID, diff --git a/internal/api/oidc/integration_test/oidc_test.go b/internal/api/oidc/integration_test/oidc_test.go index 302c818c36..2ab78b972e 100644 --- a/internal/api/oidc/integration_test/oidc_test.go +++ b/internal/api/oidc/integration_test/oidc_test.go @@ -441,13 +441,13 @@ func createImplicitClientNoLoginClientHeader(t testing.TB) string { } func createAuthRequest(t testing.TB, instance *integration.Instance, clientID, redirectURI string, scope ...string) string { - redURL, err := instance.CreateOIDCAuthRequest(CTX, clientID, instance.Users.Get(integration.UserTypeLogin).ID, redirectURI, scope...) + _, redURL, err := instance.CreateOIDCAuthRequest(CTX, clientID, instance.Users.Get(integration.UserTypeLogin).ID, redirectURI, scope...) require.NoError(t, err) return redURL } func createAuthRequestNoLoginClientHeader(t testing.TB, instance *integration.Instance, clientID, redirectURI string, scope ...string) string { - redURL, err := instance.CreateOIDCAuthRequestWithoutLoginClientHeader(CTX, clientID, redirectURI, "", scope...) + _, redURL, err := instance.CreateOIDCAuthRequestWithoutLoginClientHeader(CTX, clientID, redirectURI, "", scope...) require.NoError(t, err) return redURL } diff --git a/internal/command/auth_request.go b/internal/command/auth_request.go index 91705acedf..340155d11b 100644 --- a/internal/command/auth_request.go +++ b/internal/command/auth_request.go @@ -80,7 +80,7 @@ func (c *Commands) AddAuthRequest(ctx context.Context, authRequest *AuthRequest) return authRequestWriteModelToCurrentAuthRequest(writeModel), nil } -func (c *Commands) LinkSessionToAuthRequest(ctx context.Context, id, sessionID, sessionToken string, checkLoginClient bool) (*domain.ObjectDetails, *CurrentAuthRequest, error) { +func (c *Commands) LinkSessionToAuthRequest(ctx context.Context, id, sessionID, sessionToken string, checkLoginClient bool, projectPermissionCheck domain.ProjectPermissionCheck) (*domain.ObjectDetails, *CurrentAuthRequest, error) { writeModel, err := c.getAuthRequestWriteModel(ctx, id) if err != nil { return nil, nil, err @@ -96,6 +96,7 @@ func (c *Commands) LinkSessionToAuthRequest(ctx context.Context, id, sessionID, return nil, nil, err } } + sessionWriteModel := NewSessionWriteModel(sessionID, authz.GetInstance(ctx).InstanceID()) err = c.eventstore.FilterToQueryReducer(ctx, sessionWriteModel) if err != nil { @@ -108,6 +109,12 @@ func (c *Commands) LinkSessionToAuthRequest(ctx context.Context, id, sessionID, return nil, nil, err } + if projectPermissionCheck != nil { + if err := projectPermissionCheck(ctx, writeModel.ClientID, sessionWriteModel.UserID); err != nil { + return nil, nil, err + } + } + if err := c.pushAppendAndReduce(ctx, writeModel, authrequest.NewSessionLinkedEvent( ctx, &authrequest.NewAggregate(id, authz.GetInstance(ctx).InstanceID()).Aggregate, sessionID, diff --git a/internal/command/auth_request_test.go b/internal/command/auth_request_test.go index 3668b6563b..590e4086f4 100644 --- a/internal/command/auth_request_test.go +++ b/internal/command/auth_request_test.go @@ -181,6 +181,7 @@ func TestCommands_LinkSessionToAuthRequest(t *testing.T) { sessionID string sessionToken string checkLoginClient bool + permissionCheck domain.ProjectPermissionCheck } type res struct { details *domain.ObjectDetails @@ -712,6 +713,163 @@ func TestCommands_LinkSessionToAuthRequest(t *testing.T) { }, }, }, + { + "linked with permission, application permission check", + fields{ + eventstore: expectEventstore( + expectFilter( + eventFromEventPusher( + authrequest.NewAddedEvent(mockCtx, &authrequest.NewAggregate("V2_id", "instanceID").Aggregate, + "otherLoginClient", + "clientID", + "redirectURI", + "state", + "nonce", + []string{"openid"}, + []string{"audience"}, + domain.OIDCResponseTypeCode, + domain.OIDCResponseModeQuery, + nil, + nil, + nil, + nil, + nil, + nil, + true, + ), + ), + ), + expectFilter( + eventFromEventPusher( + session.NewAddedEvent(mockCtx, + &session.NewAggregate("sessionID", "instance1").Aggregate, + &domain.UserAgent{ + FingerprintID: gu.Ptr("fp1"), + IP: net.ParseIP("1.2.3.4"), + Description: gu.Ptr("firefox"), + Header: http.Header{"foo": []string{"bar"}}, + }, + )), + eventFromEventPusher( + session.NewUserCheckedEvent(mockCtx, &session.NewAggregate("sessionID", "instance1").Aggregate, + "userID", "org1", testNow, &language.Afrikaans), + ), + eventFromEventPusher( + session.NewPasswordCheckedEvent(mockCtx, &session.NewAggregate("sessionID", "instance1").Aggregate, + testNow), + ), + eventFromEventPusherWithCreationDateNow( + session.NewLifetimeSetEvent(mockCtx, &session.NewAggregate("sessionID", "instance1").Aggregate, + 2*time.Minute), + ), + ), + expectPush( + authrequest.NewSessionLinkedEvent(mockCtx, &authrequest.NewAggregate("V2_id", "instanceID").Aggregate, + "sessionID", + "userID", + testNow, + []domain.UserAuthMethodType{domain.UserAuthMethodTypePassword}, + ), + ), + ), + tokenVerifier: newMockTokenVerifierValid(), + checkPermission: newMockPermissionCheckAllowed(), + }, + args{ + ctx: authz.NewMockContext("instanceID", "orgID", "loginClient"), + id: "V2_id", + sessionID: "sessionID", + sessionToken: "token", + checkLoginClient: true, + permissionCheck: newMockProjectPermissionCheckAllowed(), + }, + res{ + details: &domain.ObjectDetails{ResourceOwner: "instanceID"}, + authReq: &CurrentAuthRequest{ + AuthRequest: &AuthRequest{ + ID: "V2_id", + LoginClient: "otherLoginClient", + ClientID: "clientID", + RedirectURI: "redirectURI", + State: "state", + Nonce: "nonce", + Scope: []string{"openid"}, + Audience: []string{"audience"}, + ResponseType: domain.OIDCResponseTypeCode, + ResponseMode: domain.OIDCResponseModeQuery, + }, + SessionID: "sessionID", + UserID: "userID", + AuthMethods: []domain.UserAuthMethodType{domain.UserAuthMethodTypePassword}, + }, + }, + }, + { + "linked with permission, no application permission", + fields{ + eventstore: expectEventstore( + expectFilter( + eventFromEventPusher( + authrequest.NewAddedEvent(mockCtx, &authrequest.NewAggregate("V2_id", "instanceID").Aggregate, + "otherLoginClient", + "clientID", + "redirectURI", + "state", + "nonce", + []string{"openid"}, + []string{"audience"}, + domain.OIDCResponseTypeCode, + domain.OIDCResponseModeQuery, + nil, + nil, + nil, + nil, + nil, + nil, + true, + ), + ), + ), + expectFilter( + eventFromEventPusher( + session.NewAddedEvent(mockCtx, + &session.NewAggregate("sessionID", "instance1").Aggregate, + &domain.UserAgent{ + FingerprintID: gu.Ptr("fp1"), + IP: net.ParseIP("1.2.3.4"), + Description: gu.Ptr("firefox"), + Header: http.Header{"foo": []string{"bar"}}, + }, + )), + eventFromEventPusher( + session.NewUserCheckedEvent(mockCtx, &session.NewAggregate("sessionID", "instance1").Aggregate, + "userID", "org1", testNow, &language.Afrikaans), + ), + eventFromEventPusher( + session.NewPasswordCheckedEvent(mockCtx, &session.NewAggregate("sessionID", "instance1").Aggregate, + testNow), + ), + eventFromEventPusherWithCreationDateNow( + session.NewLifetimeSetEvent(mockCtx, &session.NewAggregate("sessionID", "instance1").Aggregate, + 2*time.Minute), + ), + ), + ), + tokenVerifier: newMockTokenVerifierValid(), + checkPermission: newMockPermissionCheckAllowed(), + }, + args{ + ctx: authz.NewMockContext("instanceID", "orgID", "loginClient"), + id: "V2_id", + sessionID: "sessionID", + sessionToken: "token", + checkLoginClient: true, + permissionCheck: newMockProjectPermissionCheckOIDCNotAllowed(), + }, + res{ + wantErr: zerrors.ThrowPermissionDenied(nil, "OIDC-foSyH49RvL", "Errors.PermissionDenied"), + }, + }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { @@ -720,7 +878,7 @@ func TestCommands_LinkSessionToAuthRequest(t *testing.T) { sessionTokenVerifier: tt.fields.tokenVerifier, checkPermission: tt.fields.checkPermission, } - details, got, err := c.LinkSessionToAuthRequest(tt.args.ctx, tt.args.id, tt.args.sessionID, tt.args.sessionToken, tt.args.checkLoginClient) + details, got, err := c.LinkSessionToAuthRequest(tt.args.ctx, tt.args.id, tt.args.sessionID, tt.args.sessionToken, tt.args.checkLoginClient, tt.args.permissionCheck) require.ErrorIs(t, err, tt.res.wantErr) assertObjectDetails(t, tt.res.details, details) if err == nil { diff --git a/internal/command/main_test.go b/internal/command/main_test.go index e75392309f..61d1abf6fd 100644 --- a/internal/command/main_test.go +++ b/internal/command/main_test.go @@ -232,6 +232,24 @@ func newMockPermissionCheckNotAllowed() domain.PermissionCheck { } } +func newMockProjectPermissionCheckAllowed() domain.ProjectPermissionCheck { + return func(ctx context.Context, clientID, userID string) (err error) { + return nil + } +} + +func newMockProjectPermissionCheckOIDCNotAllowed() domain.ProjectPermissionCheck { + return func(ctx context.Context, clientID, userID string) (err error) { + return zerrors.ThrowPermissionDenied(nil, "OIDC-foSyH49RvL", "Errors.PermissionDenied") + } +} + +func newMockProjectPermissionCheckSAMLNotAllowed() domain.ProjectPermissionCheck { + return func(ctx context.Context, clientID, userID string) (err error) { + return zerrors.ThrowPermissionDenied(nil, "SAML-foSyH49RvL", "Errors.PermissionDenied") + } +} + func newMockTokenVerifierValid() func(ctx context.Context, sessionToken, sessionID, tokenID string) (err error) { return func(ctx context.Context, sessionToken, sessionID, tokenID string) (err error) { return nil diff --git a/internal/command/saml_request.go b/internal/command/saml_request.go index 9d12ba6e44..2dfa8756c7 100644 --- a/internal/command/saml_request.go +++ b/internal/command/saml_request.go @@ -63,7 +63,7 @@ func (c *Commands) AddSAMLRequest(ctx context.Context, samlRequest *SAMLRequest) return samlRequestWriteModelToCurrentSAMLRequest(writeModel), nil } -func (c *Commands) LinkSessionToSAMLRequest(ctx context.Context, id, sessionID, sessionToken string, checkLoginClient bool) (*domain.ObjectDetails, *CurrentSAMLRequest, error) { +func (c *Commands) LinkSessionToSAMLRequest(ctx context.Context, id, sessionID, sessionToken string, checkLoginClient bool, projectPermissionCheck domain.ProjectPermissionCheck) (*domain.ObjectDetails, *CurrentSAMLRequest, error) { writeModel, err := c.getSAMLRequestWriteModel(ctx, id) if err != nil { return nil, nil, err @@ -89,6 +89,12 @@ func (c *Commands) LinkSessionToSAMLRequest(ctx context.Context, id, sessionID, return nil, nil, err } + if projectPermissionCheck != nil { + if err := projectPermissionCheck(ctx, writeModel.Issuer, sessionWriteModel.UserID); err != nil { + return nil, nil, err + } + } + if err := c.pushAppendAndReduce(ctx, writeModel, samlrequest.NewSessionLinkedEvent( ctx, &samlrequest.NewAggregate(id, authz.GetInstance(ctx).InstanceID()).Aggregate, sessionID, diff --git a/internal/command/saml_request_test.go b/internal/command/saml_request_test.go index 18b1c2a392..ed7363e151 100644 --- a/internal/command/saml_request_test.go +++ b/internal/command/saml_request_test.go @@ -141,6 +141,7 @@ func TestCommands_LinkSessionToSAMLRequest(t *testing.T) { sessionID string sessionToken string checkLoginClient bool + checkPermission domain.ProjectPermissionCheck } type res struct { details *domain.ObjectDetails @@ -524,6 +525,144 @@ func TestCommands_LinkSessionToSAMLRequest(t *testing.T) { }, }, }, + { + "linked with login client check, application permission check", + fields{ + eventstore: expectEventstore( + expectFilter( + eventFromEventPusher( + samlrequest.NewAddedEvent(mockCtx, &samlrequest.NewAggregate("V2_id", "instanceID").Aggregate, + "loginClient", + "application", + "acs", + "relaystate", + "request", + "binding", + "issuer", + "destination", + ), + ), + ), + expectFilter( + eventFromEventPusher( + session.NewAddedEvent(mockCtx, + &session.NewAggregate("sessionID", "instance1").Aggregate, + &domain.UserAgent{ + FingerprintID: gu.Ptr("fp1"), + IP: net.ParseIP("1.2.3.4"), + Description: gu.Ptr("firefox"), + Header: http.Header{"foo": []string{"bar"}}, + }, + )), + eventFromEventPusher( + session.NewUserCheckedEvent(mockCtx, &session.NewAggregate("sessionID", "instance1").Aggregate, + "userID", "org1", testNow, &language.Afrikaans), + ), + eventFromEventPusher( + session.NewPasswordCheckedEvent(mockCtx, &session.NewAggregate("sessionID", "instance1").Aggregate, + testNow), + ), + eventFromEventPusherWithCreationDateNow( + session.NewLifetimeSetEvent(mockCtx, &session.NewAggregate("sessionID", "instance1").Aggregate, + 2*time.Minute), + ), + ), + expectPush( + samlrequest.NewSessionLinkedEvent(mockCtx, &samlrequest.NewAggregate("V2_id", "instanceID").Aggregate, + "sessionID", + "userID", + testNow, + []domain.UserAuthMethodType{domain.UserAuthMethodTypePassword}, + ), + ), + ), + tokenVerifier: newMockTokenVerifierValid(), + }, + args{ + ctx: authz.NewMockContext("instanceID", "orgID", "loginClient"), + id: "V2_id", + sessionID: "sessionID", + sessionToken: "token", + checkLoginClient: true, + checkPermission: newMockProjectPermissionCheckAllowed(), + }, + res{ + details: &domain.ObjectDetails{ResourceOwner: "instanceID"}, + authReq: &CurrentSAMLRequest{ + SAMLRequest: &SAMLRequest{ + ID: "V2_id", + LoginClient: "loginClient", + ApplicationID: "application", + ACSURL: "acs", + RelayState: "relaystate", + RequestID: "request", + Binding: "binding", + Issuer: "issuer", + Destination: "destination", + }, + SessionID: "sessionID", + UserID: "userID", + AuthMethods: []domain.UserAuthMethodType{domain.UserAuthMethodTypePassword}, + }, + }, + }, + { + "linked with login client check, no application permission", + fields{ + eventstore: expectEventstore( + expectFilter( + eventFromEventPusher( + samlrequest.NewAddedEvent(mockCtx, &samlrequest.NewAggregate("V2_id", "instanceID").Aggregate, + "loginClient", + "application", + "acs", + "relaystate", + "request", + "binding", + "issuer", + "destination", + ), + ), + ), + expectFilter( + eventFromEventPusher( + session.NewAddedEvent(mockCtx, + &session.NewAggregate("sessionID", "instance1").Aggregate, + &domain.UserAgent{ + FingerprintID: gu.Ptr("fp1"), + IP: net.ParseIP("1.2.3.4"), + Description: gu.Ptr("firefox"), + Header: http.Header{"foo": []string{"bar"}}, + }, + )), + eventFromEventPusher( + session.NewUserCheckedEvent(mockCtx, &session.NewAggregate("sessionID", "instance1").Aggregate, + "userID", "org1", testNow, &language.Afrikaans), + ), + eventFromEventPusher( + session.NewPasswordCheckedEvent(mockCtx, &session.NewAggregate("sessionID", "instance1").Aggregate, + testNow), + ), + eventFromEventPusherWithCreationDateNow( + session.NewLifetimeSetEvent(mockCtx, &session.NewAggregate("sessionID", "instance1").Aggregate, + 2*time.Minute), + ), + ), + ), + tokenVerifier: newMockTokenVerifierValid(), + }, + args{ + ctx: authz.NewMockContext("instanceID", "orgID", "loginClient"), + id: "V2_id", + sessionID: "sessionID", + sessionToken: "token", + checkLoginClient: true, + checkPermission: newMockProjectPermissionCheckSAMLNotAllowed(), + }, + res{ + wantErr: zerrors.ThrowPermissionDenied(nil, "SAML-foSyH49RvL", "Errors.PermissionDenied"), + }, + }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { @@ -531,7 +670,7 @@ func TestCommands_LinkSessionToSAMLRequest(t *testing.T) { eventstore: tt.fields.eventstore(t), sessionTokenVerifier: tt.fields.tokenVerifier, } - details, got, err := c.LinkSessionToSAMLRequest(tt.args.ctx, tt.args.id, tt.args.sessionID, tt.args.sessionToken, tt.args.checkLoginClient) + details, got, err := c.LinkSessionToSAMLRequest(tt.args.ctx, tt.args.id, tt.args.sessionID, tt.args.sessionToken, tt.args.checkLoginClient, tt.args.checkPermission) require.ErrorIs(t, err, tt.res.wantErr) assertObjectDetails(t, tt.res.details, details) if err == nil { diff --git a/internal/domain/permission.go b/internal/domain/permission.go index bf24c09e53..0ddf08a664 100644 --- a/internal/domain/permission.go +++ b/internal/domain/permission.go @@ -39,3 +39,7 @@ const ( PermissionIDPRead = "iam.idp.read" PermissionOrgIDPRead = "org.idp.read" ) + +// ProjectPermissionCheck is used as a check for preconditions dependent on application, project, user resourceowner and usergrants. +// Configurable on the project the application belongs to through the flags related to authentication. +type ProjectPermissionCheck func(ctx context.Context, clientID, userID string) (err error) diff --git a/internal/integration/client.go b/internal/integration/client.go index d18c2d9b12..cefaf0ef42 100644 --- a/internal/integration/client.go +++ b/internal/integration/client.go @@ -678,6 +678,15 @@ func (i *Instance) CreatePasswordSession(t *testing.T, ctx context.Context, user createResp.GetDetails().GetChangeDate().AsTime(), createResp.GetDetails().GetChangeDate().AsTime() } +func (i *Instance) CreateProjectGrant(ctx context.Context, projectID, grantedOrgID string) *mgmt.AddProjectGrantResponse { + resp, err := i.Client.Mgmt.AddProjectGrant(ctx, &mgmt.AddProjectGrantRequest{ + GrantedOrgId: grantedOrgID, + ProjectId: projectID, + }) + logging.OnError(err).Panic("create project grant") + return resp +} + func (i *Instance) CreateProjectUserGrant(t *testing.T, ctx context.Context, projectID, userID string) string { resp, err := i.Client.Mgmt.AddUserGrant(ctx, &mgmt.AddUserGrantRequest{ UserId: userID, @@ -687,6 +696,16 @@ func (i *Instance) CreateProjectUserGrant(t *testing.T, ctx context.Context, pro return resp.GetUserGrantId() } +func (i *Instance) CreateProjectGrantUserGrant(ctx context.Context, orgID, projectID, projectGrantID, userID string) string { + resp, err := i.Client.Mgmt.AddUserGrant(SetOrgID(ctx, orgID), &mgmt.AddUserGrantRequest{ + UserId: userID, + ProjectId: projectID, + ProjectGrantId: projectGrantID, + }) + logging.OnError(err).Panic("create project grant user grant") + return resp.GetUserGrantId() +} + func (i *Instance) CreateOrgMembership(t *testing.T, ctx context.Context, userID string) { _, err := i.Client.Mgmt.AddOrgMember(ctx, &mgmt.AddOrgMemberRequest{ UserId: userID, diff --git a/internal/integration/oidc.go b/internal/integration/oidc.go index 04cf951048..4d7f5277c9 100644 --- a/internal/integration/oidc.go +++ b/internal/integration/oidc.go @@ -194,6 +194,14 @@ func (i *Instance) CreateProject(ctx context.Context) (*management.AddProjectRes }) } +func (i *Instance) CreateProjectWithPermissionCheck(ctx context.Context, projectRoleCheck, hasProjectCheck bool) (*management.AddProjectResponse, error) { + return i.Client.Mgmt.AddProject(ctx, &management.AddProjectRequest{ + Name: fmt.Sprintf("project-%d", time.Now().UnixNano()), + HasProjectCheck: hasProjectCheck, + ProjectRoleCheck: projectRoleCheck, + }) +} + func (i *Instance) CreateAPIClientJWT(ctx context.Context, projectID string) (*management.AddAPIAppResponse, error) { return i.Client.Mgmt.AddAPIApp(ctx, &management.AddAPIAppRequest{ ProjectId: projectID, @@ -212,22 +220,22 @@ func (i *Instance) CreateAPIClientBasic(ctx context.Context, projectID string) ( const CodeVerifier = "codeVerifier" -func (i *Instance) CreateOIDCAuthRequest(ctx context.Context, clientID, loginClient, redirectURI string, scope ...string) (authRequestID string, err error) { +func (i *Instance) CreateOIDCAuthRequest(ctx context.Context, clientID, loginClient, redirectURI string, scope ...string) (now time.Time, authRequestID string, err error) { return i.CreateOIDCAuthRequestWithDomain(ctx, i.Domain, clientID, loginClient, redirectURI, scope...) } -func (i *Instance) CreateOIDCAuthRequestWithoutLoginClientHeader(ctx context.Context, clientID, redirectURI, loginBaseURI string, scope ...string) (authRequestID string, err error) { +func (i *Instance) CreateOIDCAuthRequestWithoutLoginClientHeader(ctx context.Context, clientID, redirectURI, loginBaseURI string, scope ...string) (now time.Time, authRequestID string, err error) { return i.createOIDCAuthRequestWithDomain(ctx, i.Domain, clientID, redirectURI, "", loginBaseURI, scope...) } -func (i *Instance) CreateOIDCAuthRequestWithDomain(ctx context.Context, domain, clientID, loginClient, redirectURI string, scope ...string) (authRequestID string, err error) { +func (i *Instance) CreateOIDCAuthRequestWithDomain(ctx context.Context, domain, clientID, loginClient, redirectURI string, scope ...string) (now time.Time, authRequestID string, err error) { return i.createOIDCAuthRequestWithDomain(ctx, domain, clientID, redirectURI, loginClient, "", scope...) } -func (i *Instance) createOIDCAuthRequestWithDomain(ctx context.Context, domain, clientID, redirectURI, loginClient, loginBaseURI string, scope ...string) (authRequestID string, err error) { +func (i *Instance) createOIDCAuthRequestWithDomain(ctx context.Context, domain, clientID, redirectURI, loginClient, loginBaseURI string, scope ...string) (now time.Time, authRequestID string, err error) { provider, err := i.CreateRelyingPartyForDomain(ctx, domain, clientID, redirectURI, loginClient, scope...) if err != nil { - return "", fmt.Errorf("create relying party: %w", err) + return now, "", fmt.Errorf("create relying party: %w", err) } codeChallenge := oidc.NewSHACodeChallenge(CodeVerifier) authURL := rp.AuthURL("state", provider, rp.WithCodeChallenge(codeChallenge)) @@ -238,21 +246,22 @@ func (i *Instance) createOIDCAuthRequestWithDomain(ctx context.Context, domain, } req, err := GetRequest(authURL, headers) if err != nil { - return "", fmt.Errorf("get request: %w", err) + return now, "", fmt.Errorf("get request: %w", err) } + now = time.Now() loc, err := CheckRedirect(req) if err != nil { - return "", fmt.Errorf("check redirect: %w", err) + return now, "", fmt.Errorf("check redirect: %w", err) } if loginBaseURI == "" { loginBaseURI = provider.Issuer() + i.Config.LoginURLV2 } if !strings.HasPrefix(loc.String(), loginBaseURI) { - return "", fmt.Errorf("login location has not prefix %s, but is %s", loginBaseURI, loc.String()) + return now, "", fmt.Errorf("login location has not prefix %s, but is %s", loginBaseURI, loc.String()) } - return strings.TrimPrefix(loc.String(), loginBaseURI), nil + return now, strings.TrimPrefix(loc.String(), loginBaseURI), nil } func (i *Instance) CreateOIDCAuthRequestImplicitWithoutLoginClientHeader(ctx context.Context, clientID, redirectURI string, scope ...string) (authRequestID string, err error) { diff --git a/internal/integration/saml.go b/internal/integration/saml.go index bf04246956..483543b322 100644 --- a/internal/integration/saml.go +++ b/internal/integration/saml.go @@ -11,6 +11,7 @@ import ( "net/http" "net/url" "strings" + "time" "github.com/brianvoe/gofakeit/v6" "github.com/crewjam/saml" @@ -135,32 +136,33 @@ func (i *Instance) CreateSAMLClient(ctx context.Context, projectID string, m *sa }) } -func (i *Instance) CreateSAMLAuthRequest(m *samlsp.Middleware, loginClient string, acs saml.Endpoint, relayState string, responseBinding string) (authRequestID string, err error) { +func (i *Instance) CreateSAMLAuthRequest(m *samlsp.Middleware, loginClient string, acs saml.Endpoint, relayState string, responseBinding string) (now time.Time, authRequestID string, err error) { authReq, err := m.ServiceProvider.MakeAuthenticationRequest(acs.Location, acs.Binding, responseBinding) if err != nil { - return "", err + return now, "", err } redirectURL, err := authReq.Redirect(relayState, &m.ServiceProvider) if err != nil { - return "", err + return now, "", err } req, err := GetRequest(redirectURL.String(), map[string]string{oidc_internal.LoginClientHeader: loginClient}) if err != nil { - return "", fmt.Errorf("get request: %w", err) + return now, "", fmt.Errorf("get request: %w", err) } + now = time.Now() loc, err := CheckRedirect(req) if err != nil { - return "", fmt.Errorf("check redirect: %w", err) + return now, "", fmt.Errorf("check redirect: %w", err) } prefixWithHost := i.Issuer() + i.Config.LoginURLV2 if !strings.HasPrefix(loc.String(), prefixWithHost) { - return "", fmt.Errorf("login location has not prefix %s, but is %s", prefixWithHost, loc.String()) + return now, "", fmt.Errorf("login location has not prefix %s, but is %s", prefixWithHost, loc.String()) } - return strings.TrimPrefix(loc.String(), prefixWithHost), nil + return now, strings.TrimPrefix(loc.String(), prefixWithHost), nil } func (i *Instance) FailSAMLAuthRequest(ctx context.Context, id string, reason saml_pb.ErrorReason) *saml_pb.CreateResponseResponse { diff --git a/internal/notification/handlers/integration_test/telemetry_pusher_test.go b/internal/notification/handlers/integration_test/telemetry_pusher_test.go index 9252790263..1163195377 100644 --- a/internal/notification/handlers/integration_test/telemetry_pusher_test.go +++ b/internal/notification/handlers/integration_test/telemetry_pusher_test.go @@ -79,7 +79,7 @@ func TestServer_TelemetryPushMilestones(t *testing.T) { func loginToClient(t *testing.T, instance *integration.Instance, clientID, redirectURI, sessionID, sessionToken string) { iamOwnerCtx := instance.WithAuthorization(CTX, integration.UserTypeIAMOwner) - authRequestID, err := instance.CreateOIDCAuthRequestWithDomain(iamOwnerCtx, instance.Domain, clientID, instance.Users.Get(integration.UserTypeIAMOwner).ID, redirectURI, "openid") + _, authRequestID, err := instance.CreateOIDCAuthRequestWithDomain(iamOwnerCtx, instance.Domain, clientID, instance.Users.Get(integration.UserTypeIAMOwner).ID, redirectURI, "openid") require.NoError(t, err) callback, err := instance.Client.OIDCv2.CreateCallback(iamOwnerCtx, &oidc_v2.CreateCallbackRequest{ AuthRequestId: authRequestID, diff --git a/internal/query/app.go b/internal/query/app.go index b826bb52b8..1aa0323a5a 100644 --- a/internal/query/app.go +++ b/internal/query/app.go @@ -3,6 +3,7 @@ package query import ( "context" "database/sql" + _ "embed" "errors" "time" @@ -368,6 +369,77 @@ func (q *Queries) ProjectByClientID(ctx context.Context, appID string) (project return project, err } +//go:embed app_oidc_project_permission.sql +var appOIDCProjectPermissionQuery string + +func (q *Queries) CheckProjectPermissionByClientID(ctx context.Context, clientID, userID string) (_ *projectPermission, err error) { + ctx, span := tracing.NewSpan(ctx) + defer func() { span.EndWithError(err) }() + + var p *projectPermission + err = q.client.QueryRowContext(ctx, func(row *sql.Row) error { + p, err = scanProjectPermissionByClientID(row) + return err + }, appOIDCProjectPermissionQuery, + authz.GetInstance(ctx).InstanceID(), + clientID, + domain.AppStateActive, + domain.ProjectStateActive, + userID, + domain.UserStateActive, + domain.ProjectGrantStateActive, + domain.UserGrantStateActive, + ) + return p, err +} + +//go:embed app_saml_project_permission.sql +var appSAMLProjectPermissionQuery string + +func (q *Queries) CheckProjectPermissionByEntityID(ctx context.Context, entityID, userID string) (_ *projectPermission, err error) { + ctx, span := tracing.NewSpan(ctx) + defer func() { span.EndWithError(err) }() + + var p *projectPermission + err = q.client.QueryRowContext(ctx, func(row *sql.Row) error { + p, err = scanProjectPermissionByClientID(row) + return err + }, appSAMLProjectPermissionQuery, + authz.GetInstance(ctx).InstanceID(), + entityID, + domain.AppStateActive, + domain.ProjectStateActive, + userID, + domain.UserStateActive, + domain.ProjectGrantStateActive, + domain.UserGrantStateActive, + ) + return p, err +} + +type projectPermission struct { + HasProjectChecked bool + ProjectRoleChecked bool +} + +func scanProjectPermissionByClientID(row *sql.Row) (*projectPermission, error) { + var hasProjectChecked, projectRoleChecked sql.NullBool + err := row.Scan( + &hasProjectChecked, + &projectRoleChecked, + ) + if err != nil || !hasProjectChecked.Valid || !projectRoleChecked.Valid { + if errors.Is(err, sql.ErrNoRows) { + return nil, zerrors.ThrowNotFound(err, "QUERY-4tq8wCTCgf", "Errors.App.NotFound") + } + return nil, zerrors.ThrowInternal(err, "QUERY-NwH4lAqlZC", "Errors.Internal") + } + return &projectPermission{ + HasProjectChecked: hasProjectChecked.Bool, + ProjectRoleChecked: projectRoleChecked.Bool, + }, nil +} + func (q *Queries) ProjectIDFromClientID(ctx context.Context, appID string) (id string, err error) { ctx, span := tracing.NewSpan(ctx) defer func() { span.EndWithError(err) }() diff --git a/internal/query/app_oidc_project_permission.sql b/internal/query/app_oidc_project_permission.sql new file mode 100644 index 0000000000..cf25e6763e --- /dev/null +++ b/internal/query/app_oidc_project_permission.sql @@ -0,0 +1,74 @@ +with application as ( + SELECT a.instance_id, + a.resource_owner, + a.project_id, + a.id as app_id, + p.project_role_check, + p.has_project_check + FROM projections.apps7 as a + LEFT JOIN projections.apps7_oidc_configs as aoc + ON aoc.app_id = a.id + AND aoc.instance_id = a.instance_id + INNER JOIN projections.projects4 as p + ON p.instance_id = a.instance_id + AND p.resource_owner = a.resource_owner + AND p.id = a.project_id + WHERE a.instance_id = $1 + AND aoc.client_id = $2 + AND a.state = $3 + AND p.state = $4 +), user_resourceowner as ( +/* resourceowner of the active user */ + SELECT u.instance_id, + u.resource_owner, + u.id as user_id + FROM projections.users14 as u + WHERE u.instance_id = $1 + AND u.id = $5 + AND u.state = $6 +), has_project_grant_check as ( +/* all projectgrants active, then filtered with the project and user resourceowner */ + SELECT pg.instance_id, + pg.resource_owner, + pg.project_id, + pg.granted_org_id + FROM projections.project_grants4 as pg + WHERE pg.instance_id = $1 + AND pg.state = $7 +), project_role_check as ( +/* all usergrants active and associated with the user, then filtered with the project */ + SELECT ug.instance_id, + ug.resource_owner, + ug.project_id + FROM projections.user_grants5 as ug + WHERE ug.instance_id = $1 + AND ug.user_id = $5 + AND ug.state = $8 +) +SELECT + /* project existence does not need to be checked, or resourceowner of user and project are equal, or resourceowner of user has project granted*/ + bool_and(COALESCE( + (NOT a.has_project_check OR + a.resource_owner = uro.resource_owner OR + uro.resource_owner = hpgc.granted_org_id) + , FALSE) + ) as project_checked, + /* authentication existence does not need to checked, or authentication for project is existing*/ + bool_and(COALESCE( + (NOT a.project_role_check OR + a.project_id = prc.project_id) + , FALSE) + ) as role_checked +FROM application as a + LEFT JOIN user_resourceowner as uro + ON uro.instance_id = a.instance_id + LEFT JOIN has_project_grant_check as hpgc + ON hpgc.instance_id = a.instance_id + AND hpgc.project_id = a.project_id + AND hpgc.granted_org_id = uro.resource_owner + LEFT JOIN project_role_check as prc + ON prc.instance_id = a.instance_id + AND prc.project_id = a.project_id +GROUP BY a.instance_id, a.resource_owner, a.project_id, a.app_id, uro.resource_owner, hpgc.granted_org_id, + prc.project_id +LIMIT 1; diff --git a/internal/query/app_saml_project_permission.sql b/internal/query/app_saml_project_permission.sql new file mode 100644 index 0000000000..9f15668c23 --- /dev/null +++ b/internal/query/app_saml_project_permission.sql @@ -0,0 +1,74 @@ +with application as ( + SELECT a.instance_id, + a.resource_owner, + a.project_id, + a.id as app_id, + p.project_role_check, + p.has_project_check + FROM projections.apps7 as a + LEFT JOIN projections.apps7_saml_configs as asaml + ON asaml.app_id = a.id + AND asaml.instance_id = a.instance_id + INNER JOIN projections.projects4 as p + ON p.instance_id = a.instance_id + AND p.resource_owner = a.resource_owner + AND p.id = a.project_id + WHERE a.instance_id = $1 + AND asaml.entity_id = $2 + AND a.state = $3 + AND p.state = $4 +), user_resourceowner as ( +/* resourceowner of the active user */ + SELECT u.instance_id, + u.resource_owner, + u.id as user_id + FROM projections.users14 as u + WHERE u.instance_id = $1 + AND u.id = $5 + AND u.state = $6 +), has_project_grant_check as ( +/* all projectgrants active, then filtered with the project and user resourceowner */ + SELECT pg.instance_id, + pg.resource_owner, + pg.project_id, + pg.granted_org_id + FROM projections.project_grants4 as pg + WHERE pg.instance_id = $1 + AND pg.state = $7 +), project_role_check as ( +/* all usergrants active and associated with the user, then filtered with the project */ + SELECT ug.instance_id, + ug.resource_owner, + ug.project_id + FROM projections.user_grants5 as ug + WHERE ug.instance_id = $1 + AND ug.user_id = $5 + AND ug.state = $8 +) +SELECT + /* project existence does not need to be checked, or resourceowner of user and project are equal, or resourceowner of user has project granted*/ + bool_and(COALESCE( + (NOT a.has_project_check OR + a.resource_owner = uro.resource_owner OR + uro.resource_owner = hpgc.granted_org_id) + , FALSE) + ) as project_checked, + /* authentication existence does not need to checked, or authentication for project is existing*/ + bool_and(COALESCE( + (NOT a.project_role_check OR + a.project_id = prc.project_id) + , FALSE) + ) as role_checked +FROM application as a + LEFT JOIN user_resourceowner as uro + ON uro.instance_id = a.instance_id + LEFT JOIN has_project_grant_check as hpgc + ON hpgc.instance_id = a.instance_id + AND hpgc.project_id = a.project_id + AND hpgc.granted_org_id = uro.resource_owner + LEFT JOIN project_role_check as prc + ON prc.instance_id = a.instance_id + AND prc.project_id = a.project_id +GROUP BY a.instance_id, a.resource_owner, a.project_id, a.app_id, uro.resource_owner, hpgc.granted_org_id, + prc.project_id +LIMIT 1; From 39a7977e34f21e6b2448f4189a530c4a22744dfd Mon Sep 17 00:00:00 2001 From: Stefan Benz <46600784+stebenz@users.noreply.github.com> Date: Wed, 12 Feb 2025 11:46:14 +0100 Subject: [PATCH 004/169] test: session v2beta corrected like v2 (#9350) # Which Problems Are Solved Ordering of sessions in v2beta is still relevant in the integration tests. # How the Problems Are Solved Correct the integration tests on session service v2beta like in v2. # Additional Changes None # Additional Context Failing integration tests in pipeline. --- .../v2beta/integration_test/query_test.go | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/internal/api/grpc/session/v2beta/integration_test/query_test.go b/internal/api/grpc/session/v2beta/integration_test/query_test.go index b347ba8224..dc131cdaaf 100644 --- a/internal/api/grpc/session/v2beta/integration_test/query_test.go +++ b/internal/api/grpc/session/v2beta/integration_test/query_test.go @@ -493,6 +493,12 @@ func TestServer_ListSessions(t *testing.T) { return } + // expected count of sessions is not equal to created dependencies + if !assert.Len(ttt, tt.want.Sessions, len(infos)) { + return + } + + // expected count of sessions is not equal to received sessions if !assert.Equal(ttt, got.Details.TotalResult, tt.want.Details.TotalResult) || !assert.Len(ttt, got.Sessions, len(tt.want.Sessions)) { return } @@ -503,8 +509,17 @@ func TestServer_ListSessions(t *testing.T) { tt.want.Sessions[i].CreationDate = infos[i].Details.GetChangeDate() tt.want.Sessions[i].ChangeDate = infos[i].Details.GetChangeDate() - verifySession(ttt, got.Sessions[i], tt.want.Sessions[i], time.Minute, tt.wantExpirationWindow, infos[i].UserID, tt.wantFactors...) + // only check for contents of the session, not sorting for now + found := false + for _, session := range got.Sessions { + if session.Id == infos[i].ID { + verifySession(ttt, session, tt.want.Sessions[i], time.Minute, tt.wantExpirationWindow, infos[i].UserID, tt.wantFactors...) + found = true + } + } + assert.True(t, found) } + integration.AssertListDetails(ttt, tt.want, got) }, retryDuration, tick) }) From bcc6a689fab919f480694be254905d18d48ce565 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tim=20M=C3=B6hlmann?= Date: Wed, 12 Feb 2025 13:06:34 +0200 Subject: [PATCH 005/169] fix(setup): use template for in_tx_order type (#9346) # Which Problems Are Solved Systems running with PostgreSQL before Zitadel v2.39 are likely to have a wrong type for the `in_tx_order` column in the `eventstore.event2` table. The migration at the time used the `event_sequence` as default value without typecast, which results in a `bigint` type for that column. However, when creating the table from scratch, we explicitly specify the type to be `integer`. Starting from Zitadel v2.67 we use a Pl/PgSQL function to push events. The function requires the types from `eventstore.events2` to the same as the `select` destinations used in the function. In the function `in_tx_order` is also expected to by of `integer` type. CochroachDB systems are not affected because `bigint` is an alias to the `int` type. In other words, CockroachDB uses `int8` when specifying type `int`. Therefore the types already match. # How the Problems Are Solved Retrieve the actual column type currently in use. A template is used to assign the type to the `ordinality` column returned as `in_tx_order`. # Additional Changes - Detailed logging on migration failure # Additional Context - Closes #9180 --------- Co-authored-by: Silvan <27845747+adlerhurst@users.noreply.github.com> --- cmd/setup/40.go | 74 +++++++++++++++++-- .../40/cockroach/00_in_tx_order_type.sql | 5 ++ cmd/setup/40/cockroach/01_type.sql | 10 +++ .../{40_init_push_func.sql => 02_func.sql} | 14 +--- cmd/setup/40/postgres/00_in_tx_order_type.sql | 5 ++ cmd/setup/40/postgres/02_func.sql | 2 +- cmd/setup/setup.go | 20 ++++- 7 files changed, 110 insertions(+), 20 deletions(-) create mode 100644 cmd/setup/40/cockroach/00_in_tx_order_type.sql create mode 100644 cmd/setup/40/cockroach/01_type.sql rename cmd/setup/40/cockroach/{40_init_push_func.sql => 02_func.sql} (92%) create mode 100644 cmd/setup/40/postgres/00_in_tx_order_type.sql diff --git a/cmd/setup/40.go b/cmd/setup/40.go index ff1188776f..b16b9226f7 100644 --- a/cmd/setup/40.go +++ b/cmd/setup/40.go @@ -2,8 +2,13 @@ package setup import ( "context" + "database/sql" "embed" "fmt" + "io/fs" + "path" + "strings" + "text/template" "github.com/zitadel/logging" @@ -11,6 +16,13 @@ import ( "github.com/zitadel/zitadel/internal/eventstore" ) +// query filenames +const ( + fileInTxOrderType = "00_in_tx_order_type.sql" + fileType = "01_type.sql" + fileFunc = "02_func.sql" +) + var ( //go:embed 40/cockroach/*.sql //go:embed 40/postgres/*.sql @@ -22,10 +34,6 @@ type InitPushFunc struct { } 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 @@ -36,7 +44,10 @@ func (mig *InitPushFunc) Execute(ctx context.Context, _ eventstore.Event) (err e // Force the pool to reopen connections to apply the new types mig.dbClient.Pool.Reset() }() - + statements, err := mig.prepareStatements(ctx) + if err != nil { + return err + } for _, stmt := range statements { logging.WithFields("file", stmt.file, "migration", mig.String()).Info("execute statement") if _, err := conn.ExecContext(ctx, stmt.query); err != nil { @@ -50,3 +61,56 @@ func (mig *InitPushFunc) Execute(ctx context.Context, _ eventstore.Event) (err e func (mig *InitPushFunc) String() string { return "40_init_push_func_v4" } + +func (mig *InitPushFunc) prepareStatements(ctx context.Context) ([]statement, error) { + funcTmpl, err := template.ParseFS(initPushFunc, mig.filePath(fileFunc)) + if err != nil { + return nil, fmt.Errorf("prepare steps: %w", err) + } + typeName, err := mig.inTxOrderType(ctx) + if err != nil { + return nil, fmt.Errorf("prepare steps: %w", err) + } + var funcStep strings.Builder + err = funcTmpl.Execute(&funcStep, struct { + InTxOrderType string + }{ + InTxOrderType: typeName, + }) + if err != nil { + return nil, fmt.Errorf("prepare steps: %w", err) + } + typeStatement, err := fs.ReadFile(initPushFunc, mig.filePath(fileType)) + if err != nil { + return nil, fmt.Errorf("prepare steps: %w", err) + } + return []statement{ + { + file: fileType, + query: string(typeStatement), + }, + { + file: fileFunc, + query: funcStep.String(), + }, + }, nil +} + +func (mig *InitPushFunc) inTxOrderType(ctx context.Context) (typeName string, err error) { + query, err := fs.ReadFile(initPushFunc, mig.filePath(fileInTxOrderType)) + if err != nil { + return "", fmt.Errorf("get in_tx_order_type: %w", err) + } + + err = mig.dbClient.QueryRowContext(ctx, func(row *sql.Row) error { + return row.Scan(&typeName) + }, string(query)) + if err != nil { + return "", fmt.Errorf("get in_tx_order_type: %w", err) + } + return typeName, nil +} + +func (mig *InitPushFunc) filePath(fileName string) string { + return path.Join("40", mig.dbClient.Type(), fileName) +} diff --git a/cmd/setup/40/cockroach/00_in_tx_order_type.sql b/cmd/setup/40/cockroach/00_in_tx_order_type.sql new file mode 100644 index 0000000000..68b7daf984 --- /dev/null +++ b/cmd/setup/40/cockroach/00_in_tx_order_type.sql @@ -0,0 +1,5 @@ +SELECT data_type +FROM information_schema.columns +WHERE table_schema = 'eventstore' +AND table_name = 'events2' +AND column_name = 'in_tx_order'; diff --git a/cmd/setup/40/cockroach/01_type.sql b/cmd/setup/40/cockroach/01_type.sql new file mode 100644 index 0000000000..e26af2f828 --- /dev/null +++ b/cmd/setup/40/cockroach/01_type.sql @@ -0,0 +1,10 @@ +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 +); diff --git a/cmd/setup/40/cockroach/40_init_push_func.sql b/cmd/setup/40/cockroach/02_func.sql similarity index 92% rename from cmd/setup/40/cockroach/40_init_push_func.sql rename to cmd/setup/40/cockroach/02_func.sql index 9a08b5d355..9cb45529ad 100644 --- a/cmd/setup/40/cockroach/40_init_push_func.sql +++ b/cmd/setup/40/cockroach/02_func.sql @@ -1,15 +1,3 @@ --- 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.latest_aggregate_state( instance_id TEXT , aggregate_type TEXT @@ -98,7 +86,7 @@ BEGIN , ("c").creator , COALESCE(current_owner, ("c").owner) -- AS owner , cluster_logical_timestamp() -- AS position - , ordinality::INT -- AS in_tx_order + , ordinality::{{ .InTxOrderType }} -- AS in_tx_order FROM UNNEST(commands) WITH ORDINALITY AS c WHERE diff --git a/cmd/setup/40/postgres/00_in_tx_order_type.sql b/cmd/setup/40/postgres/00_in_tx_order_type.sql new file mode 100644 index 0000000000..68b7daf984 --- /dev/null +++ b/cmd/setup/40/postgres/00_in_tx_order_type.sql @@ -0,0 +1,5 @@ +SELECT data_type +FROM information_schema.columns +WHERE table_schema = 'eventstore' +AND table_name = 'events2' +AND column_name = 'in_tx_order'; diff --git a/cmd/setup/40/postgres/02_func.sql b/cmd/setup/40/postgres/02_func.sql index 0d566ebb42..851547c240 100644 --- a/cmd/setup/40/postgres/02_func.sql +++ b/cmd/setup/40/postgres/02_func.sql @@ -72,7 +72,7 @@ BEGIN , c.creator , COALESCE(current_owner, c.owner) -- AS owner , EXTRACT(EPOCH FROM NOW()) -- AS position - , c.ordinality::INT -- AS in_tx_order + , c.ordinality::{{ .InTxOrderType }} -- AS in_tx_order FROM UNNEST(commands) WITH ORDINALITY AS c WHERE diff --git a/cmd/setup/setup.go b/cmd/setup/setup.go index 2394af2024..33298033a9 100644 --- a/cmd/setup/setup.go +++ b/cmd/setup/setup.go @@ -4,9 +4,11 @@ import ( "context" "embed" _ "embed" + "errors" "net/http" "path" + "github.com/jackc/pgx/v5/pgconn" "github.com/spf13/cobra" "github.com/spf13/viper" "github.com/zitadel/logging" @@ -271,7 +273,23 @@ func Setup(ctx context.Context, config *Config, steps *Steps, masterKey string) func mustExecuteMigration(ctx context.Context, eventstoreClient *eventstore.Eventstore, step migration.Migration, errorMsg string) { err := migration.Migrate(ctx, eventstoreClient, step) - logging.WithFields("name", step.String()).OnError(err).Fatal(errorMsg) + if err == nil { + return + } + logFields := []any{ + "name", step.String(), + } + pgErr := new(pgconn.PgError) + if errors.As(err, &pgErr) { + logFields = append(logFields, + "severity", pgErr.Severity, + "code", pgErr.Code, + "message", pgErr.Message, + "detail", pgErr.Detail, + "hint", pgErr.Hint, + ) + } + logging.WithFields(logFields...).WithError(err).Fatal(errorMsg) } // readStmt reads a single file from the embedded FS, From 0ea42f1ddf9f26d4564aa89faaccfd89e1be4063 Mon Sep 17 00:00:00 2001 From: Stefan Benz <46600784+stebenz@users.noreply.github.com> Date: Wed, 12 Feb 2025 12:48:28 +0100 Subject: [PATCH 006/169] fix: no project owner at project creation and cleanup (#9317) # Which Problems Are Solved Project creation always requires a user as project owner, in case of a system user creating the project, there is no valid user existing at that moment. # How the Problems Are Solved Remove the initially created project owner membership, as this is something which was necessary in old versions, and all should work perfectly without. The call to add a project automatically designates the calling user as the project owner, which is irrelevant currently, as this user always already has higher permissions to be able to even create the project. # Additional Changes Cleanup of the existing checks for the project, which can be improved through the usage of the fields table. # Additional Context Closes #9182 --- internal/api/grpc/management/project.go | 3 +- internal/command/instance_test.go | 5 - internal/command/project.go | 132 ++++--------------- internal/command/project_application_api.go | 14 +- internal/command/project_application_oidc.go | 12 +- internal/command/project_application_saml.go | 6 +- internal/command/project_model.go | 13 ++ internal/command/project_old.go | 94 +------------ internal/command/project_test.go | 81 +++++------- 9 files changed, 87 insertions(+), 273 deletions(-) diff --git a/internal/api/grpc/management/project.go b/internal/api/grpc/management/project.go index e7fad52c56..00ccbd215c 100644 --- a/internal/api/grpc/management/project.go +++ b/internal/api/grpc/management/project.go @@ -177,8 +177,7 @@ func (s *Server) ListProjectChanges(ctx context.Context, req *mgmt_pb.ListProjec } func (s *Server) AddProject(ctx context.Context, req *mgmt_pb.AddProjectRequest) (*mgmt_pb.AddProjectResponse, error) { - ctxData := authz.GetCtxData(ctx) - project, err := s.command.AddProject(ctx, ProjectCreateToDomain(req), ctxData.OrgID, ctxData.UserID) + project, err := s.command.AddProject(ctx, ProjectCreateToDomain(req), authz.GetCtxData(ctx).OrgID) if err != nil { return nil, err } diff --git a/internal/command/instance_test.go b/internal/command/instance_test.go index fd21e0e704..2ea248dfb1 100644 --- a/internal/command/instance_test.go +++ b/internal/command/instance_test.go @@ -50,11 +50,6 @@ func projectAddedEvents(ctx context.Context, instanceID, orgID, id, owner string false, domain.PrivateLabelingSettingUnspecified, ), - project.NewProjectMemberAddedEvent(ctx, - &project.NewAggregate(id, orgID).Aggregate, - owner, - domain.RoleProjectOwner, - ), instance.NewIAMProjectSetEvent(ctx, &instance.NewAggregate(instanceID).Aggregate, id, diff --git a/internal/command/project.go b/internal/command/project.go index 6923f1169e..df4f8ab545 100644 --- a/internal/command/project.go +++ b/internal/command/project.go @@ -26,13 +26,8 @@ func (c *Commands) AddProjectWithID(ctx context.Context, project *domain.Project if projectID == "" { return nil, zerrors.ThrowInvalidArgument(nil, "COMMAND-nDXf5vXoUj", "Errors.IDMissing") } - - existingProject, err := c.getProjectWriteModelByID(ctx, projectID, resourceOwner) - if err != nil { - return nil, err - } - if existingProject.State != domain.ProjectStateUnspecified { - return nil, zerrors.ThrowInvalidArgument(nil, "COMMAND-opamwu", "Errors.Project.AlreadyExisting") + if !project.IsValid() { + return nil, zerrors.ThrowInvalidArgument(nil, "PROJECT-IOVCC", "Errors.Project.Invalid") } project, err = c.addProjectWithID(ctx, project, resourceOwner, projectID) if err != nil { @@ -41,23 +36,22 @@ func (c *Commands) AddProjectWithID(ctx context.Context, project *domain.Project return project, nil } -func (c *Commands) AddProject(ctx context.Context, project *domain.Project, resourceOwner, ownerUserID string) (_ *domain.Project, err error) { +func (c *Commands) AddProject(ctx context.Context, project *domain.Project, resourceOwner string) (_ *domain.Project, err error) { + ctx, span := tracing.NewSpan(ctx) + defer func() { span.EndWithError(err) }() if !project.IsValid() { return nil, zerrors.ThrowInvalidArgument(nil, "PROJECT-IOVCC", "Errors.Project.Invalid") } if resourceOwner == "" { return nil, zerrors.ThrowInvalidArgument(nil, "COMMAND-fmq7bqQX1s", "Errors.ResourceOwnerMissing") } - if ownerUserID == "" { - return nil, zerrors.ThrowInvalidArgument(nil, "COMMAND-xe95Gl3Dro", "Errors.IDMissing") - } projectID, err := c.idGenerator.Next() if err != nil { return nil, err } - project, err = c.addProjectWithIDWithOwner(ctx, project, resourceOwner, ownerUserID, projectID) + project, err = c.addProjectWithID(ctx, project, resourceOwner, projectID) if err != nil { return nil, err } @@ -66,13 +60,19 @@ func (c *Commands) AddProject(ctx context.Context, project *domain.Project, reso func (c *Commands) addProjectWithID(ctx context.Context, projectAdd *domain.Project, resourceOwner, projectID string) (_ *domain.Project, err error) { projectAdd.AggregateID = projectID - addedProject := NewProjectWriteModel(projectAdd.AggregateID, resourceOwner) - projectAgg := ProjectAggregateFromWriteModel(&addedProject.WriteModel) + projectWriteModel, err := c.getProjectWriteModelByID(ctx, projectAdd.AggregateID, resourceOwner) + if err != nil { + return nil, err + } + if isProjectStateExists(projectWriteModel.State) { + return nil, zerrors.ThrowAlreadyExists(nil, "COMMAND-opamwu", "Errors.Project.AlreadyExisting") + } events := []eventstore.Command{ project.NewProjectAddedEvent( ctx, - projectAgg, + //nolint: contextcheck + ProjectAggregateFromWriteModel(&projectWriteModel.WriteModel), projectAdd.Name, projectAdd.ProjectRoleAssertion, projectAdd.ProjectRoleCheck, @@ -88,47 +88,11 @@ func (c *Commands) addProjectWithID(ctx context.Context, projectAdd *domain.Proj return nil, err } postCommit(ctx) - err = AppendAndReduce(addedProject, pushedEvents...) + err = AppendAndReduce(projectWriteModel, pushedEvents...) if err != nil { return nil, err } - return projectWriteModelToProject(addedProject), nil -} - -func (c *Commands) addProjectWithIDWithOwner(ctx context.Context, projectAdd *domain.Project, resourceOwner, ownerUserID, projectID string) (_ *domain.Project, err error) { - if !projectAdd.IsValid() { - return nil, zerrors.ThrowInvalidArgument(nil, "PROJECT-IOVCC", "Errors.Project.Invalid") - } - projectAdd.AggregateID = projectID - addedProject := NewProjectWriteModel(projectAdd.AggregateID, resourceOwner) - projectAgg := ProjectAggregateFromWriteModel(&addedProject.WriteModel) - - projectRole := domain.RoleProjectOwner - events := []eventstore.Command{ - project.NewProjectAddedEvent( - ctx, - projectAgg, - projectAdd.Name, - projectAdd.ProjectRoleAssertion, - projectAdd.ProjectRoleCheck, - projectAdd.HasProjectCheck, - projectAdd.PrivateLabelingSetting), - project.NewProjectMemberAddedEvent(ctx, projectAgg, ownerUserID, projectRole), - } - postCommit, err := c.projectCreatedMilestone(ctx, &events) - if err != nil { - return nil, err - } - pushedEvents, err := c.eventstore.Push(ctx, events...) - if err != nil { - return nil, err - } - postCommit(ctx) - err = AppendAndReduce(addedProject, pushedEvents...) - if err != nil { - return nil, err - } - return projectWriteModelToProject(addedProject), nil + return projectWriteModelToProject(projectWriteModel), nil } func AddProjectCommand( @@ -159,9 +123,6 @@ func AddProjectCommand( hasProjectCheck, privateLabelingSetting, ), - project.NewProjectMemberAddedEvent(ctx, &a.Aggregate, - owner, - domain.RoleProjectOwner), }, nil }, nil } @@ -182,20 +143,6 @@ func projectWriteModel(ctx context.Context, filter preparation.FilterToQueryRedu return project, nil } -func (c *Commands) getProjectByID(ctx context.Context, projectID, resourceOwner string) (_ *domain.Project, err error) { - ctx, span := tracing.NewSpan(ctx) - defer func() { span.EndWithError(err) }() - - projectWriteModel, err := c.getProjectWriteModelByID(ctx, projectID, resourceOwner) - if err != nil { - return nil, err - } - if projectWriteModel.State == domain.ProjectStateUnspecified || projectWriteModel.State == domain.ProjectStateRemoved { - return nil, zerrors.ThrowNotFound(nil, "PROJECT-Gd2hh", "Errors.Project.NotFound") - } - return projectWriteModelToProject(projectWriteModel), nil -} - func (c *Commands) projectAggregateByID(ctx context.Context, projectID, resourceOwner string) (*eventstore.Aggregate, domain.ProjectState, error) { result, err := c.projectState(ctx, projectID, resourceOwner) if err != nil { @@ -250,15 +197,11 @@ func (c *Commands) ChangeProject(ctx context.Context, projectChange *domain.Proj return nil, zerrors.ThrowInvalidArgument(nil, "COMMAND-4m9vS", "Errors.Project.Invalid") } - if !authz.GetFeatures(ctx).ShouldUseImprovedPerformance(feature.ImprovedPerformanceTypeProject) { - return c.changeProjectOld(ctx, projectChange, resourceOwner) - } - existingProject, err := c.getProjectWriteModelByID(ctx, projectChange.AggregateID, resourceOwner) if err != nil { return nil, err } - if existingProject.State == domain.ProjectStateUnspecified || existingProject.State == domain.ProjectStateRemoved { + if !isProjectStateExists(existingProject.State) { return nil, zerrors.ThrowNotFound(nil, "COMMAND-3M9sd", "Errors.Project.NotFound") } @@ -277,11 +220,7 @@ func (c *Commands) ChangeProject(ctx context.Context, projectChange *domain.Proj if !hasChanged { return nil, zerrors.ThrowPreconditionFailed(nil, "COMMAND-2M0fs", "Errors.NoChangesFound") } - pushedEvents, err := c.eventstore.Push(ctx, changedEvent) - if err != nil { - return nil, err - } - err = AppendAndReduce(existingProject, pushedEvents...) + err = c.pushAppendAndReduce(ctx, existingProject, changedEvent) if err != nil { return nil, err } @@ -302,7 +241,7 @@ func (c *Commands) DeactivateProject(ctx context.Context, projectID string, reso return nil, err } - if state == domain.ProjectStateUnspecified || state == domain.ProjectStateRemoved { + if !isProjectStateExists(state) { return nil, zerrors.ThrowNotFound(nil, "COMMAND-112M9", "Errors.Project.NotFound") } if state != domain.ProjectStateActive { @@ -314,17 +253,6 @@ func (c *Commands) DeactivateProject(ctx context.Context, projectID string, reso return nil, err } - existingProject, err := c.getProjectWriteModelByID(ctx, projectID, resourceOwner) - if err != nil { - return nil, err - } - if existingProject.State == domain.ProjectStateUnspecified || existingProject.State == domain.ProjectStateRemoved { - return nil, zerrors.ThrowNotFound(nil, "COMMAND-112M9", "Errors.Project.NotFound") - } - if existingProject.State != domain.ProjectStateActive { - return nil, zerrors.ThrowPreconditionFailed(nil, "COMMAND-mki55", "Errors.Project.NotActive") - } - return &domain.ObjectDetails{ ResourceOwner: pushedEvents[0].Aggregate().ResourceOwner, Sequence: pushedEvents[0].Sequence(), @@ -346,25 +274,13 @@ func (c *Commands) ReactivateProject(ctx context.Context, projectID string, reso return nil, err } - if state == domain.ProjectStateUnspecified || state == domain.ProjectStateRemoved { + if !isProjectStateExists(state) { return nil, zerrors.ThrowNotFound(nil, "COMMAND-3M9sd", "Errors.Project.NotFound") } - if state != domain.ProjectStateInactive { return nil, zerrors.ThrowPreconditionFailed(nil, "COMMAND-5M9bs", "Errors.Project.NotInactive") } - existingProject, err := c.getProjectWriteModelByID(ctx, projectID, resourceOwner) - if err != nil { - return nil, err - } - if existingProject.State == domain.ProjectStateUnspecified || existingProject.State == domain.ProjectStateRemoved { - return nil, zerrors.ThrowNotFound(nil, "COMMAND-3M9sd", "Errors.Project.NotFound") - } - if existingProject.State != domain.ProjectStateInactive { - return nil, zerrors.ThrowPreconditionFailed(nil, "COMMAND-5M9bs", "Errors.Project.NotInactive") - } - pushedEvents, err := c.eventstore.Push(ctx, project.NewProjectReactivatedEvent(ctx, projectAgg)) if err != nil { return nil, err @@ -382,15 +298,11 @@ func (c *Commands) RemoveProject(ctx context.Context, projectID, resourceOwner s return nil, zerrors.ThrowInvalidArgument(nil, "COMMAND-66hM9", "Errors.Project.ProjectIDMissing") } - if !authz.GetFeatures(ctx).ShouldUseImprovedPerformance(feature.ImprovedPerformanceTypeProject) { - return c.removeProjectOld(ctx, projectID, resourceOwner) - } - existingProject, err := c.getProjectWriteModelByID(ctx, projectID, resourceOwner) if err != nil { return nil, err } - if existingProject.State == domain.ProjectStateUnspecified || existingProject.State == domain.ProjectStateRemoved { + if !isProjectStateExists(existingProject.State) { return nil, zerrors.ThrowNotFound(nil, "COMMAND-3M9sd", "Errors.Project.NotFound") } diff --git a/internal/command/project_application_api.go b/internal/command/project_application_api.go index e3718b5010..21c3bc5ee7 100644 --- a/internal/command/project_application_api.go +++ b/internal/command/project_application_api.go @@ -78,11 +78,10 @@ func (c *Commands) AddAPIApplicationWithID(ctx context.Context, apiApp *domain.A if existingAPI.State != domain.AppStateUnspecified { return nil, zerrors.ThrowPreconditionFailed(nil, "PROJECT-mabu12", "Errors.Project.App.AlreadyExisting") } - _, err = c.getProjectByID(ctx, apiApp.AggregateID, resourceOwner) - if err != nil { - return nil, zerrors.ThrowPreconditionFailed(err, "PROJECT-9fnsa", "Errors.Project.NotFound") - } + if err := c.checkProjectExists(ctx, apiApp.AggregateID, resourceOwner); err != nil { + return nil, err + } return c.addAPIApplicationWithID(ctx, apiApp, resourceOwner, appID) } @@ -90,11 +89,10 @@ func (c *Commands) AddAPIApplication(ctx context.Context, apiApp *domain.APIApp, if apiApp == nil || apiApp.AggregateID == "" { return nil, zerrors.ThrowInvalidArgument(nil, "PROJECT-5m9E", "Errors.Project.App.Invalid") } - _, err = c.getProjectByID(ctx, apiApp.AggregateID, resourceOwner) - if err != nil { - return nil, zerrors.ThrowPreconditionFailed(err, "PROJECT-9fnsf", "Errors.Project.NotFound") - } + if err := c.checkProjectExists(ctx, apiApp.AggregateID, resourceOwner); err != nil { + return nil, err + } if !apiApp.IsValid() { return nil, zerrors.ThrowInvalidArgument(nil, "PROJECT-Bff2g", "Errors.Project.App.Invalid") } diff --git a/internal/command/project_application_oidc.go b/internal/command/project_application_oidc.go index 257cdeaec4..fccb0efe06 100644 --- a/internal/command/project_application_oidc.go +++ b/internal/command/project_application_oidc.go @@ -132,11 +132,9 @@ func (c *Commands) AddOIDCApplicationWithID(ctx context.Context, oidcApp *domain return nil, zerrors.ThrowPreconditionFailed(nil, "PROJECT-lxowmp", "Errors.Project.App.AlreadyExisting") } - _, err = c.getProjectByID(ctx, oidcApp.AggregateID, resourceOwner) - if err != nil { - return nil, zerrors.ThrowPreconditionFailed(err, "PROJECT-3m9s2", "Errors.Project.NotFound") + if err := c.checkProjectExists(ctx, oidcApp.AggregateID, resourceOwner); err != nil { + return nil, err } - return c.addOIDCApplicationWithID(ctx, oidcApp, resourceOwner, appID) } @@ -144,11 +142,9 @@ func (c *Commands) AddOIDCApplication(ctx context.Context, oidcApp *domain.OIDCA if oidcApp == nil || oidcApp.AggregateID == "" { return nil, zerrors.ThrowInvalidArgument(nil, "PROJECT-34Fm0", "Errors.Project.App.Invalid") } - _, err = c.getProjectByID(ctx, oidcApp.AggregateID, resourceOwner) - if err != nil { - return nil, zerrors.ThrowPreconditionFailed(err, "PROJECT-3m9ss", "Errors.Project.NotFound") + if err := c.checkProjectExists(ctx, oidcApp.AggregateID, resourceOwner); err != nil { + return nil, err } - if oidcApp.AppName == "" || !oidcApp.IsValid() { return nil, zerrors.ThrowInvalidArgument(nil, "PROJECT-1n8df", "Errors.Project.App.Invalid") } diff --git a/internal/command/project_application_saml.go b/internal/command/project_application_saml.go index 612c2dbd5c..76297ad93f 100644 --- a/internal/command/project_application_saml.go +++ b/internal/command/project_application_saml.go @@ -16,11 +16,9 @@ func (c *Commands) AddSAMLApplication(ctx context.Context, application *domain.S return nil, zerrors.ThrowInvalidArgument(nil, "PROJECT-35Fn0", "Errors.Project.App.Invalid") } - _, err = c.getProjectByID(ctx, application.AggregateID, resourceOwner) - if err != nil { - return nil, zerrors.ThrowPreconditionFailed(err, "PROJECT-3p9ss", "Errors.Project.NotFound") + if err := c.checkProjectExists(ctx, application.AggregateID, resourceOwner); err != nil { + return nil, err } - addedApplication := NewSAMLApplicationWriteModel(application.AggregateID, resourceOwner) projectAgg := ProjectAggregateFromWriteModel(&addedApplication.WriteModel) events, err := c.addSAMLApplication(ctx, projectAgg, application) diff --git a/internal/command/project_model.go b/internal/command/project_model.go index 99b878885b..a46b07a8fe 100644 --- a/internal/command/project_model.go +++ b/internal/command/project_model.go @@ -124,6 +124,19 @@ func (wm *ProjectWriteModel) NewChangedEvent( return changeEvent, true, nil } +func isProjectStateExists(state domain.ProjectState) bool { + return !hasProjectState(state, domain.ProjectStateRemoved, domain.ProjectStateUnspecified) +} + func ProjectAggregateFromWriteModel(wm *eventstore.WriteModel) *eventstore.Aggregate { return eventstore.AggregateFromWriteModel(wm, project.AggregateType, project.AggregateVersion) } + +func hasProjectState(check domain.ProjectState, states ...domain.ProjectState) bool { + for _, state := range states { + if check == state { + return true + } + } + return false +} diff --git a/internal/command/project_old.go b/internal/command/project_old.go index b31b4e58bf..35ea9b3ebb 100644 --- a/internal/command/project_old.go +++ b/internal/command/project_old.go @@ -3,10 +3,7 @@ package command import ( "context" - "github.com/zitadel/logging" - "github.com/zitadel/zitadel/internal/domain" - "github.com/zitadel/zitadel/internal/eventstore" "github.com/zitadel/zitadel/internal/repository/project" "github.com/zitadel/zitadel/internal/telemetry/tracing" "github.com/zitadel/zitadel/internal/zerrors" @@ -20,58 +17,18 @@ func (c *Commands) checkProjectExistsOld(ctx context.Context, projectID, resourc if err != nil { return err } - if projectWriteModel.State == domain.ProjectStateUnspecified || projectWriteModel.State == domain.ProjectStateRemoved { + if !isProjectStateExists(projectWriteModel.State) { return zerrors.ThrowPreconditionFailed(nil, "COMMAND-EbFMN", "Errors.Project.NotFound") } return nil } -func (c *Commands) changeProjectOld(ctx context.Context, projectChange *domain.Project, resourceOwner string) (*domain.Project, error) { - if !projectChange.IsValid() || projectChange.AggregateID == "" { - return nil, zerrors.ThrowInvalidArgument(nil, "COMMAND-4m9vS", "Errors.Project.Invalid") - } - - existingProject, err := c.getProjectWriteModelByID(ctx, projectChange.AggregateID, resourceOwner) - if err != nil { - return nil, err - } - if existingProject.State == domain.ProjectStateUnspecified || existingProject.State == domain.ProjectStateRemoved { - return nil, zerrors.ThrowNotFound(nil, "COMMAND-3M9sd", "Errors.Project.NotFound") - } - - //nolint: contextcheck - projectAgg := ProjectAggregateFromWriteModel(&existingProject.WriteModel) - changedEvent, hasChanged, err := existingProject.NewChangedEvent( - ctx, - projectAgg, - projectChange.Name, - projectChange.ProjectRoleAssertion, - projectChange.ProjectRoleCheck, - projectChange.HasProjectCheck, - projectChange.PrivateLabelingSetting) - if err != nil { - return nil, err - } - if !hasChanged { - return nil, zerrors.ThrowPreconditionFailed(nil, "COMMAND-2M0fs", "Errors.NoChangesFound") - } - pushedEvents, err := c.eventstore.Push(ctx, changedEvent) - if err != nil { - return nil, err - } - err = AppendAndReduce(existingProject, pushedEvents...) - if err != nil { - return nil, err - } - return projectWriteModelToProject(existingProject), nil -} - func (c *Commands) deactivateProjectOld(ctx context.Context, projectID string, resourceOwner string) (*domain.ObjectDetails, error) { existingProject, err := c.getProjectWriteModelByID(ctx, projectID, resourceOwner) if err != nil { return nil, err } - if existingProject.State == domain.ProjectStateUnspecified || existingProject.State == domain.ProjectStateRemoved { + if !isProjectStateExists(existingProject.State) { return nil, zerrors.ThrowNotFound(nil, "COMMAND-112M9", "Errors.Project.NotFound") } if existingProject.State != domain.ProjectStateActive { @@ -96,7 +53,7 @@ func (c *Commands) reactivateProjectOld(ctx context.Context, projectID string, r if err != nil { return nil, err } - if existingProject.State == domain.ProjectStateUnspecified || existingProject.State == domain.ProjectStateRemoved { + if !isProjectStateExists(existingProject.State) { return nil, zerrors.ThrowNotFound(nil, "COMMAND-3M9sd", "Errors.Project.NotFound") } if existingProject.State != domain.ProjectStateInactive { @@ -116,51 +73,6 @@ func (c *Commands) reactivateProjectOld(ctx context.Context, projectID string, r return writeModelToObjectDetails(&existingProject.WriteModel), nil } -func (c *Commands) removeProjectOld(ctx context.Context, projectID, resourceOwner string, cascadingUserGrantIDs ...string) (*domain.ObjectDetails, error) { - existingProject, err := c.getProjectWriteModelByID(ctx, projectID, resourceOwner) - if err != nil { - return nil, err - } - if existingProject.State == domain.ProjectStateUnspecified || existingProject.State == domain.ProjectStateRemoved { - return nil, zerrors.ThrowNotFound(nil, "COMMAND-3M9sd", "Errors.Project.NotFound") - } - - samlEntityIDsAgg, err := c.getSAMLEntityIdsWriteModelByProjectID(ctx, projectID, resourceOwner) - if err != nil { - return nil, err - } - - uniqueConstraints := make([]*eventstore.UniqueConstraint, len(samlEntityIDsAgg.EntityIDs)) - for i, entityID := range samlEntityIDsAgg.EntityIDs { - uniqueConstraints[i] = project.NewRemoveSAMLConfigEntityIDUniqueConstraint(entityID.EntityID) - } - - //nolint: contextcheck - projectAgg := ProjectAggregateFromWriteModel(&existingProject.WriteModel) - events := []eventstore.Command{ - project.NewProjectRemovedEvent(ctx, projectAgg, existingProject.Name, uniqueConstraints), - } - - for _, grantID := range cascadingUserGrantIDs { - event, _, err := c.removeUserGrant(ctx, grantID, "", true) - if err != nil { - logging.WithFields("usergrantid", grantID).WithError(err).Warn("could not cascade remove user grant") - continue - } - events = append(events, event) - } - - pushedEvents, err := c.eventstore.Push(ctx, events...) - if err != nil { - return nil, err - } - err = AppendAndReduce(existingProject, pushedEvents...) - if err != nil { - return nil, err - } - return writeModelToObjectDetails(&existingProject.WriteModel), nil -} - func (c *Commands) checkProjectGrantPreConditionOld(ctx context.Context, projectGrant *domain.ProjectGrant, resourceOwner string) error { preConditions := NewProjectGrantPreConditionReadModel(projectGrant.AggregateID, projectGrant.GrantedOrgID, resourceOwner) err := c.eventstore.FilterToQueryReducer(ctx, preConditions) diff --git a/internal/command/project_test.go b/internal/command/project_test.go index 4f8ad149e3..645371e2fc 100644 --- a/internal/command/project_test.go +++ b/internal/command/project_test.go @@ -25,7 +25,6 @@ func TestCommandSide_AddProject(t *testing.T) { ctx context.Context project *domain.Project resourceOwner string - ownerID string } type res struct { want *domain.Project @@ -54,7 +53,7 @@ func TestCommandSide_AddProject(t *testing.T) { }, }, { - name: "org with project owner, resourceowner empty", + name: "project, resourceowner empty", fields: fields{ eventstore: eventstoreExpect( t, @@ -70,40 +69,17 @@ func TestCommandSide_AddProject(t *testing.T) { PrivateLabelingSetting: domain.PrivateLabelingSettingAllowLoginUserResourceOwnerPolicy, }, resourceOwner: "", - ownerID: "user1", }, res: res{ err: zerrors.IsErrorInvalidArgument, }, }, { - name: "org with project owner, ownerID empty", - fields: fields{ - eventstore: eventstoreExpect( - t, - ), - }, - args: args{ - ctx: context.Background(), - project: &domain.Project{ - Name: "project", - ProjectRoleAssertion: true, - ProjectRoleCheck: true, - HasProjectCheck: true, - PrivateLabelingSetting: domain.PrivateLabelingSettingAllowLoginUserResourceOwnerPolicy, - }, - resourceOwner: "org1", - ownerID: "", - }, - res: res{ - err: zerrors.IsErrorInvalidArgument, - }, - }, - { - name: "org with project owner, error already exists", + name: "project, error already exists", fields: fields{ eventstore: eventstoreExpect( t, + expectFilter(), expectPushFailed(zerrors.ThrowAlreadyExists(nil, "ERROR", "internl"), project.NewProjectAddedEvent( context.Background(), @@ -111,11 +87,36 @@ func TestCommandSide_AddProject(t *testing.T) { "project", true, true, true, domain.PrivateLabelingSettingAllowLoginUserResourceOwnerPolicy, ), - project.NewProjectMemberAddedEvent( - context.Background(), - &project.NewAggregate("project1", "org1").Aggregate, - "user1", - []string{domain.RoleProjectOwner}..., + ), + ), + idGenerator: id_mock.NewIDGeneratorExpectIDs(t, "project1"), + }, + args: args{ + ctx: authz.WithInstanceID(context.Background(), "instanceID"), + project: &domain.Project{ + Name: "project", + ProjectRoleAssertion: true, + ProjectRoleCheck: true, + HasProjectCheck: true, + PrivateLabelingSetting: domain.PrivateLabelingSettingAllowLoginUserResourceOwnerPolicy, + }, + resourceOwner: "org1", + }, + res: res{ + err: zerrors.IsErrorAlreadyExists, + }, + }, + { + name: "project, already exists", + fields: fields{ + eventstore: eventstoreExpect( + t, + expectFilter( + eventFromEventPusher( + project.NewProjectAddedEvent(context.Background(), + &project.NewAggregate("project1", "org1").Aggregate, + "project", true, true, true, + domain.PrivateLabelingSettingAllowLoginUserResourceOwnerPolicy), ), ), ), @@ -131,17 +132,17 @@ func TestCommandSide_AddProject(t *testing.T) { PrivateLabelingSetting: domain.PrivateLabelingSettingAllowLoginUserResourceOwnerPolicy, }, resourceOwner: "org1", - ownerID: "user1", }, res: res{ err: zerrors.IsErrorAlreadyExists, }, }, { - name: "org with project owner, ok", + name: "project, ok", fields: fields{ eventstore: eventstoreExpect( t, + expectFilter(), expectPush( project.NewProjectAddedEvent( context.Background(), @@ -149,12 +150,6 @@ func TestCommandSide_AddProject(t *testing.T) { "project", true, true, true, domain.PrivateLabelingSettingAllowLoginUserResourceOwnerPolicy, ), - project.NewProjectMemberAddedEvent( - context.Background(), - &project.NewAggregate("project1", "org1").Aggregate, - "user1", - []string{domain.RoleProjectOwner}..., - ), ), ), idGenerator: id_mock.NewIDGeneratorExpectIDs(t, "project1"), @@ -169,7 +164,6 @@ func TestCommandSide_AddProject(t *testing.T) { PrivateLabelingSetting: domain.PrivateLabelingSettingAllowLoginUserResourceOwnerPolicy, }, resourceOwner: "org1", - ownerID: "user1", }, res: res{ want: &domain.Project{ @@ -193,7 +187,7 @@ func TestCommandSide_AddProject(t *testing.T) { idGenerator: tt.fields.idGenerator, } c.setMilestonesCompletedForTest("instanceID") - got, err := c.AddProject(tt.args.ctx, tt.args.project, tt.args.resourceOwner, tt.args.ownerID) + got, err := c.AddProject(tt.args.ctx, tt.args.project, tt.args.resourceOwner) if tt.res.err == nil { assert.NoError(t, err) } @@ -1207,9 +1201,6 @@ func TestAddProject(t *testing.T) { false, domain.PrivateLabelingSettingAllowLoginUserResourceOwnerPolicy, ), - project.NewProjectMemberAddedEvent(ctx, &agg.Aggregate, - "CAOS AG", - domain.RoleProjectOwner), }, }, }, From 415bc32ed6a8fb53fd529d9ba1c0845dbd65b9a6 Mon Sep 17 00:00:00 2001 From: Silvan <27845747+adlerhurst@users.noreply.github.com> Date: Wed, 12 Feb 2025 15:51:55 +0100 Subject: [PATCH 007/169] feat: add task queue (#9321) # Which Problems Are Solved To integrate river as a task queue we need to ensure the migrations of river are executed. # How the Problems Are Solved - A new schema was added to the Zitadel database called "queue" - Added a repeatable setup step to Zitadel which executes the [migrations of river](https://riverqueue.com/docs/migrations#go-migration-api). # Additional Changes - Added more hooks to the databases to properly set the schema for the task queue # Additional Context - Closes https://github.com/zitadel/zitadel/issues/9280 --- cmd/setup/48_river_queue_repeatable.go | 28 +++++++++ cmd/setup/setup.go | 3 + go.mod | 18 ++++-- go.sum | 28 +++++++++ internal/database/cockroach/crdb.go | 21 +++++++ internal/database/dialect/connections.go | 24 ++++++-- internal/database/postgres/pg.go | 34 +++++++++-- internal/queue/queue.go | 75 ++++++++++++++++++++++++ 8 files changed, 218 insertions(+), 13 deletions(-) create mode 100644 cmd/setup/48_river_queue_repeatable.go create mode 100644 internal/queue/queue.go diff --git a/cmd/setup/48_river_queue_repeatable.go b/cmd/setup/48_river_queue_repeatable.go new file mode 100644 index 0000000000..e88293256b --- /dev/null +++ b/cmd/setup/48_river_queue_repeatable.go @@ -0,0 +1,28 @@ +package setup + +import ( + "context" + + "github.com/zitadel/zitadel/internal/database" + "github.com/zitadel/zitadel/internal/eventstore" + "github.com/zitadel/zitadel/internal/queue" +) + +type RiverMigrateRepeatable struct { + client *database.DB +} + +func (mig *RiverMigrateRepeatable) Execute(ctx context.Context, _ eventstore.Event) error { + if mig.client.Type() != "postgres" { + return nil + } + return queue.New(mig.client).ExecuteMigrations(ctx) +} + +func (mig *RiverMigrateRepeatable) String() string { + return "repeatable_migrate_river" +} + +func (f *RiverMigrateRepeatable) Check(lastRun map[string]interface{}) bool { + return true +} diff --git a/cmd/setup/setup.go b/cmd/setup/setup.go index 33298033a9..b78d1fc9cf 100644 --- a/cmd/setup/setup.go +++ b/cmd/setup/setup.go @@ -199,6 +199,9 @@ func Setup(ctx context.Context, config *Config, steps *Steps, masterKey string) eventstore: eventstoreClient, rolePermissionMappings: config.InternalAuthZ.RolePermissionMappings, }, + &RiverMigrateRepeatable{ + client: dbClient, + }, } for _, step := range []migration.Migration{ diff --git a/go.mod b/go.mod index b35bf04216..f316c3e866 100644 --- a/go.mod +++ b/go.mod @@ -42,7 +42,7 @@ require ( github.com/h2non/gock v1.2.0 github.com/hashicorp/golang-lru/v2 v2.0.7 github.com/improbable-eng/grpc-web v0.15.0 - github.com/jackc/pgx/v5 v5.7.0 + github.com/jackc/pgx/v5 v5.7.2 github.com/jarcoal/jpath v0.0.0-20140328210829-f76b8b2dbf52 github.com/jinzhu/gorm v1.9.16 github.com/k3a/html2text v1.2.1 @@ -63,7 +63,7 @@ require ( github.com/sony/sonyflake v1.2.0 github.com/spf13/cobra v1.8.1 github.com/spf13/viper v1.19.0 - github.com/stretchr/testify v1.9.0 + github.com/stretchr/testify v1.10.0 github.com/superseriousbusiness/exifremove v0.0.0-20210330092427-6acd27eac203 github.com/ttacon/libphonenumber v1.2.1 github.com/twilio/twilio-go v1.22.2 @@ -87,7 +87,7 @@ require ( golang.org/x/exp v0.0.0-20240613232115-7f521ea00fb8 golang.org/x/net v0.33.0 golang.org/x/oauth2 v0.23.0 - golang.org/x/sync v0.10.0 + golang.org/x/sync v0.11.0 golang.org/x/text v0.21.0 google.golang.org/api v0.187.0 google.golang.org/genproto/googleapis/api v0.0.0-20240822170219-fc7c04adadcd @@ -115,7 +115,7 @@ require ( github.com/google/go-tpm v0.9.0 // indirect github.com/google/pprof v0.0.0-20240528025155-186aa0362fba // indirect github.com/google/s2a-go v0.1.7 // indirect - github.com/jackc/puddle/v2 v2.2.1 // indirect + github.com/jackc/puddle/v2 v2.2.2 // indirect github.com/klauspost/cpuid/v2 v2.2.8 // indirect github.com/lib/pq v1.10.9 // indirect github.com/mattermost/xml-roundtrip-validator v0.1.0 // indirect @@ -124,11 +124,20 @@ require ( github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect github.com/pelletier/go-toml/v2 v2.2.2 // indirect github.com/pkg/errors v0.9.1 // indirect + github.com/riverqueue/river v0.16.0 // indirect + github.com/riverqueue/river/riverdriver v0.16.0 // indirect + github.com/riverqueue/river/rivershared v0.16.0 // indirect + github.com/riverqueue/river/rivertype v0.16.0 // indirect github.com/sagikazarmark/locafero v0.4.0 // indirect github.com/sagikazarmark/slog-shim v0.1.0 // indirect github.com/sourcegraph/conc v0.3.0 // indirect + github.com/tidwall/gjson v1.18.0 // indirect + github.com/tidwall/match v1.1.1 // indirect + github.com/tidwall/pretty v1.2.1 // indirect + github.com/tidwall/sjson v1.2.5 // indirect github.com/yuin/gopher-lua v1.1.1 // indirect github.com/zenazn/goji v1.0.1 // indirect + go.uber.org/goleak v1.3.0 // indirect go.uber.org/multierr v1.11.0 // indirect golang.org/x/time v0.5.0 // indirect google.golang.org/genproto v0.0.0-20240624140628-dc46fd24d27d // indirect @@ -193,6 +202,7 @@ require ( github.com/prometheus/client_model v0.6.1 // indirect github.com/prometheus/common v0.55.0 // indirect github.com/prometheus/procfs v0.15.1 // indirect + github.com/riverqueue/river/riverdriver/riverpgxv5 v0.16.0 github.com/rs/xid v1.5.0 // indirect github.com/russellhaering/goxmldsig v1.4.0 // indirect github.com/sirupsen/logrus v1.9.3 diff --git a/go.sum b/go.sum index 0745f5aca7..709d6b7a14 100644 --- a/go.sum +++ b/go.sum @@ -422,8 +422,12 @@ github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7Ulw github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= github.com/jackc/pgx/v5 v5.7.0 h1:FG6VLIdzvAPhnYqP14sQ2xhFLkiUQHCs6ySqO91kF4g= github.com/jackc/pgx/v5 v5.7.0/go.mod h1:awP1KNnjylvpxHuHP63gzjhnGkI1iw+PMoIwvoleN/8= +github.com/jackc/pgx/v5 v5.7.2 h1:mLoDLV6sonKlvjIEsV56SkWNCnuNv531l94GaIzO+XI= +github.com/jackc/pgx/v5 v5.7.2/go.mod h1:ncY89UGWxg82EykZUwSpUKEfccBGGYq1xjrOpsbsfGQ= github.com/jackc/puddle/v2 v2.2.1 h1:RhxXJtFG022u4ibrCSMSiu5aOq1i77R3OHKNJj77OAk= github.com/jackc/puddle/v2 v2.2.1/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= +github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo= +github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= github.com/jarcoal/jpath v0.0.0-20140328210829-f76b8b2dbf52 h1:jny9eqYPwkG8IVy7foUoRjQmFLcArCSz+uPsL6KS0HQ= github.com/jarcoal/jpath v0.0.0-20140328210829-f76b8b2dbf52/go.mod h1:RDZ+4PR3mDOtTpVbI0qBE+rdhmtIrtbssiNn38/1OWA= github.com/jcmturner/aescts/v2 v2.0.0 h1:9YKLH6ey7H4eDBXW8khjYslgyqG2xZikXP0EQFKrle8= @@ -640,6 +644,16 @@ github.com/rakyll/statik v0.1.7/go.mod h1:AlZONWzMtEnMs7W4e/1LURLiI49pIMmp6V9Ung github.com/rcrowley/go-metrics v0.0.0-20181016184325-3113b8401b8a/go.mod h1:bCqnVzQkZxMG4s8nGwiZ5l3QUCyqpo9Y+/ZMZ9VjZe4= github.com/redis/go-redis/v9 v9.7.0 h1:HhLSs+B6O021gwzl+locl0zEDnyNkxMtf/Z3NNBMa9E= github.com/redis/go-redis/v9 v9.7.0/go.mod h1:f6zhXITC7JUJIlPEiBOTXxJgPLdZcA93GewI7inzyWw= +github.com/riverqueue/river v0.16.0 h1:YyQrs0kGgjuABwgat02DPUYS0TMyG2ZFlzvf6+fSFaw= +github.com/riverqueue/river v0.16.0/go.mod h1:pEZ8Gc15XyFjVY89nJeL256ub5z18XF7ukYn8ktqQrs= +github.com/riverqueue/river/riverdriver v0.16.0 h1:y4Df4e1Xk3Id0nnu1VxHJn9118OzmRHcmvOxM/i1Q30= +github.com/riverqueue/river/riverdriver v0.16.0/go.mod h1:7Kdf5HQDrLyLUUqPqXobaK+7zbcMctWeAl7yhg4nHes= +github.com/riverqueue/river/riverdriver/riverpgxv5 v0.16.0 h1:6HP296OPN+3ORL9qG1f561pldB5eovkLzfkNIQmaTXI= +github.com/riverqueue/river/riverdriver/riverpgxv5 v0.16.0/go.mod h1:MAeBNoTQ+CD3nRvV9mF6iCBfsGJTxYHZeZSP4MYoeUE= +github.com/riverqueue/river/rivershared v0.16.0 h1:L1lQ3gMwdIsxA6yF0/PwAdsFP0T82yBD1V03q2GuJDU= +github.com/riverqueue/river/rivershared v0.16.0/go.mod h1:y5Xu8Shcp44DUNnEQV4c6oWH4m2OTkSMCe6nRrgzT34= +github.com/riverqueue/river/rivertype v0.16.0 h1:iDjNtCiUbXwLraqNEyQdH/OD80f1wTo8Ai6WHYCwRxs= +github.com/riverqueue/river/rivertype v0.16.0/go.mod h1:DETcejveWlq6bAb8tHkbgJqmXWVLiFhTiEm8j7co1bE= github.com/rogpeppe/fastuuid v0.0.0-20150106093220-6724a57986af/go.mod h1:XWv6SoW27p1b0cqNHllgS5HIMJraePCO15w5zCzIWYg= github.com/rogpeppe/fastuuid v1.2.0/go.mod h1:jVj6XXZzXRy/MSR5jhDC/2q6DgLz+nrA6LYCDYWNEvQ= github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4= @@ -715,10 +729,22 @@ github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= +github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8= github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU= github.com/superseriousbusiness/exifremove v0.0.0-20210330092427-6acd27eac203 h1:1SWXcTphBQjYGWRRxLFIAR1LVtQEj4eR7xPtyeOVM/c= github.com/superseriousbusiness/exifremove v0.0.0-20210330092427-6acd27eac203/go.mod h1:0Xw5cYMOYpgaWs+OOSx41ugycl2qvKTi9tlMMcZhFyY= +github.com/tidwall/gjson v1.14.2/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= +github.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY= +github.com/tidwall/gjson v1.18.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= +github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA= +github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM= +github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= +github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4= +github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= +github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY= +github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28= github.com/tmc/grpc-websocket-proxy v0.0.0-20170815181823-89b8d40f7ca8/go.mod h1:ncp9v5uamzpCO7NfCPTXjqaC+bZgJeR0sMTm6dMHP7U= github.com/ttacon/builder v0.0.0-20170518171403-c099f663e1c2 h1:5u+EJUQiosu3JFX0XS0qTf5FznsMOzTjGqavBGuCbo0= github.com/ttacon/builder v0.0.0-20170518171403-c099f663e1c2/go.mod h1:4kyMkleCiLkgY6z8gK5BkI01ChBtxR0ro3I1ZDcGM3w= @@ -900,6 +926,8 @@ golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJ golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.10.0 h1:3NQrjDixjgGwUOCaF8w2+VYHv0Ve/vGYSbdkTa98gmQ= golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/sync v0.11.0 h1:GGz8+XQP4FvTTrjZPzNKTMFtSXH80RAzG+5ghFPgK9w= +golang.org/x/sync v0.11.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= diff --git a/internal/database/cockroach/crdb.go b/internal/database/cockroach/crdb.go index 48e912b5f5..a5b3208a86 100644 --- a/internal/database/cockroach/crdb.go +++ b/internal/database/cockroach/crdb.go @@ -97,6 +97,27 @@ func (c *Config) Connect(useAdmin bool) (*sql.DB, *pgxpool.Pool, error) { } } + if len(connConfig.BeforeAcquire) > 0 { + config.BeforeAcquire = func(ctx context.Context, conn *pgx.Conn) bool { + for _, f := range connConfig.BeforeAcquire { + if err := f(ctx, conn); err != nil { + return false + } + } + return true + } + } + if len(connConfig.AfterRelease) > 0 { + config.AfterRelease = func(conn *pgx.Conn) bool { + for _, f := range connConfig.AfterRelease { + if err := f(conn); err != nil { + return false + } + } + return true + } + } + if connConfig.MaxOpenConns != 0 { config.MaxConns = int32(connConfig.MaxOpenConns) } diff --git a/internal/database/dialect/connections.go b/internal/database/dialect/connections.go index 13a4d657c3..11b2681fea 100644 --- a/internal/database/dialect/connections.go +++ b/internal/database/dialect/connections.go @@ -18,7 +18,9 @@ var ( type ConnectionConfig struct { MaxOpenConns, MaxIdleConns uint32 - AfterConnect []func(ctx context.Context, c *pgx.Conn) error + AfterConnect []func(ctx context.Context, c *pgx.Conn) error + BeforeAcquire []func(ctx context.Context, c *pgx.Conn) error + AfterRelease []func(c *pgx.Conn) error } var afterConnectFuncs []func(ctx context.Context, c *pgx.Conn) error @@ -27,6 +29,18 @@ func RegisterAfterConnect(f func(ctx context.Context, c *pgx.Conn) error) { afterConnectFuncs = append(afterConnectFuncs, f) } +var beforeAcquireFuncs []func(ctx context.Context, c *pgx.Conn) error + +func RegisterBeforeAcquire(f func(ctx context.Context, c *pgx.Conn) error) { + beforeAcquireFuncs = append(beforeAcquireFuncs, f) +} + +var afterReleaseFuncs []func(c *pgx.Conn) error + +func RegisterAfterRelease(f func(c *pgx.Conn) error) { + afterReleaseFuncs = append(afterReleaseFuncs, f) +} + func RegisterDefaultPgTypeVariants[T any](m *pgtype.Map, name, arrayName string) { // T var value T @@ -58,8 +72,10 @@ func RegisterDefaultPgTypeVariants[T any](m *pgtype.Map, name, arrayName string) // The pusherRatio and spoolerRatio must be between 0 and 1. func NewConnectionConfig(openConns, idleConns uint32) *ConnectionConfig { return &ConnectionConfig{ - MaxOpenConns: openConns, - MaxIdleConns: idleConns, - AfterConnect: afterConnectFuncs, + MaxOpenConns: openConns, + MaxIdleConns: idleConns, + AfterConnect: afterConnectFuncs, + BeforeAcquire: beforeAcquireFuncs, + AfterRelease: afterReleaseFuncs, } } diff --git a/internal/database/postgres/pg.go b/internal/database/postgres/pg.go index 5f4d9a6c9b..c847cc0a58 100644 --- a/internal/database/postgres/pg.go +++ b/internal/database/postgres/pg.go @@ -81,13 +81,37 @@ func (c *Config) Connect(useAdmin bool) (*sql.DB, *pgxpool.Pool, error) { 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 + 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 + } + } + + if len(connConfig.BeforeAcquire) > 0 { + config.BeforeAcquire = func(ctx context.Context, conn *pgx.Conn) bool { + for _, f := range connConfig.BeforeAcquire { + if err := f(ctx, conn); err != nil { + return false + } + } + return true + } + } + + if len(connConfig.AfterRelease) > 0 { + config.AfterRelease = func(conn *pgx.Conn) bool { + for _, f := range connConfig.AfterRelease { + if err := f(conn); err != nil { + return false + } + } + return true } - return nil } if connConfig.MaxOpenConns != 0 { diff --git a/internal/queue/queue.go b/internal/queue/queue.go new file mode 100644 index 0000000000..265988e9ef --- /dev/null +++ b/internal/queue/queue.go @@ -0,0 +1,75 @@ +package queue + +import ( + "context" + "sync" + + "github.com/jackc/pgx/v5" + "github.com/riverqueue/river/riverdriver" + "github.com/riverqueue/river/riverdriver/riverpgxv5" + "github.com/riverqueue/river/rivermigrate" + + "github.com/zitadel/zitadel/internal/database" + "github.com/zitadel/zitadel/internal/database/dialect" +) + +const ( + schema = "queue" + applicationName = "zitadel_queue" +) + +var conns = &sync.Map{} + +type queueKey struct{} + +func WithQueue(parent context.Context) context.Context { + return context.WithValue(parent, queueKey{}, struct{}{}) +} + +func init() { + dialect.RegisterBeforeAcquire(func(ctx context.Context, c *pgx.Conn) error { + if _, ok := ctx.Value(queueKey{}).(struct{}); !ok { + return nil + } + _, err := c.Exec(ctx, "SET search_path TO "+schema+"; SET application_name TO "+applicationName) + if err != nil { + return err + } + conns.Store(c, struct{}{}) + return nil + }) + dialect.RegisterAfterRelease(func(c *pgx.Conn) error { + _, ok := conns.LoadAndDelete(c) + if !ok { + return nil + } + _, err := c.Exec(context.Background(), "SET search_path TO DEFAULT; SET application_name TO "+dialect.DefaultAppName) + return err + }) +} + +// Queue abstracts the underlying queuing library +// For more information see github.com/riverqueue/river +// TODO(adlerhurst): maybe it makes more sense to split the effective queue from the migrator. +type Queue struct { + driver riverdriver.Driver[pgx.Tx] +} + +func New(client *database.DB) *Queue { + return &Queue{driver: riverpgxv5.New(client.Pool)} +} + +func (q *Queue) ExecuteMigrations(ctx context.Context) error { + _, err := q.driver.GetExecutor().Exec(ctx, "CREATE SCHEMA IF NOT EXISTS "+schema) + if err != nil { + return err + } + + migrator, err := rivermigrate.New(q.driver, nil) + if err != nil { + return err + } + ctx = WithQueue(ctx) + _, err = migrator.Migrate(ctx, rivermigrate.DirectionUp, nil) + return err +} From bd4e53314d2daa52d5a6649a4912b8d36e45424b Mon Sep 17 00:00:00 2001 From: Iraq <66622793+kkrime@users.noreply.github.com> Date: Wed, 12 Feb 2025 15:50:56 +0000 Subject: [PATCH 008/169] doc: Updating LDAP config documentation (#9303) # Which Problems Are Solved Adding `docker-compose` setup to [docs/docs/guides/integrate/identity-providers/openldap.mdx](https://github.com/zitadel/zitadel/compare/ldap_doc?expand=1#diff-6105dfa1b0b954ae5a6c914edaa6912715a1bba75bf75b1a722043edb8d429f9) --------- Co-authored-by: Iraq Jaber Co-authored-by: Stefan Benz <46600784+stebenz@users.noreply.github.com> --- .../integrate/identity-providers/openldap.mdx | 30 ++++++++++++++++++- 1 file changed, 29 insertions(+), 1 deletion(-) diff --git a/docs/docs/guides/integrate/identity-providers/openldap.mdx b/docs/docs/guides/integrate/identity-providers/openldap.mdx index 36c7ac06de..efba37fe94 100644 --- a/docs/docs/guides/integrate/identity-providers/openldap.mdx +++ b/docs/docs/guides/integrate/identity-providers/openldap.mdx @@ -34,7 +34,35 @@ Otherwise, your users passwords are sent in clear text through the wire. ### Basic configuration -To run LDAP locally to test it with ZITADEL please refer to [OpenLDAP](https://www.openldap.org/) with [slapd](https://www.openldap.org/software/man.cgi?query=slapd). +You can run OpenLdap via `docker-compose` using the following: +``` +version: '2' + +networks: + my-network: + driver: bridge +services: + openldap: + image: bitnami/openldap:latest + ports: + - '389:1389' + environment: + - LDAP_ADMIN_USERNAME=admin + - LDAP_ADMIN_PASSWORD=Password1! + - LDAP_USERS=test + - LDAP_PASSWORDS=Password1! + - LDAP_ROOT=dc=example,dc=com + - LDAP_ADMIN_DN=cn=admin,dc=example,dc=com + networks: + - my-network + volumes: + - 'openldap_data:/bitnami/openldap' +volumes: + openldap_data: + driver: local +```` + +Alternatively, you can run LDAP locally. To run LDAP locally to test it with ZITADEL please refer to [OpenLDAP](https://www.openldap.org/) with [slapd](https://www.openldap.org/software/man.cgi?query=slapd). For a quickstart guide please refer to their [official documentation](https://www.openldap.org/doc/admin22/quickstart.html). From 66296db971444717f87f455444dc9c3fe09c8844 Mon Sep 17 00:00:00 2001 From: Iraq <66622793+kkrime@users.noreply.github.com> Date: Thu, 13 Feb 2025 09:17:05 +0000 Subject: [PATCH 009/169] fix: custom userID not being added when specified in zitadel.org.v2.AddOrganizationRequest.AddOrganization() request (#9334) # Which Problems Are Solved When specifying a `user_id` as a human admin in `zitadel.org.v2.AddOrganizationRequest.AddOrganization()` the `user_id` specified in the request should have been used, before it was being ignored, this has been fixed with this PR # Additional Context - Closes https://github.com/zitadel/zitadel/issues/9308 --------- Co-authored-by: Iraq Jaber --- .../grpc/org/v2/integration_test/org_test.go | 8 +++++--- internal/api/grpc/org/v2/org.go | 1 + internal/command/org.go | 18 +++++++++++++----- 3 files changed, 19 insertions(+), 8 deletions(-) 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 2bca4b9349..aa8a718e68 100644 --- a/internal/api/grpc/org/v2/integration_test/org_test.go +++ b/internal/api/grpc/org/v2/integration_test/org_test.go @@ -43,6 +43,7 @@ func TestMain(m *testing.M) { func TestServer_AddOrganization(t *testing.T) { idpResp := Instance.AddGenericOAuthProvider(CTX, Instance.DefaultOrg.Id) + userId := "userID" tests := []struct { name string @@ -81,7 +82,7 @@ func TestServer_AddOrganization(t *testing.T) { wantErr: true, }, { - name: "admin with init", + name: "admin with init with userID passed for Human admin", ctx: CTX, req: &org.AddOrganizationRequest{ Name: gofakeit.AppName(), @@ -89,6 +90,7 @@ func TestServer_AddOrganization(t *testing.T) { { UserType: &org.AddOrganizationRequest_Admin_Human{ Human: &user.AddHumanUserRequest{ + UserId: &userId, Profile: &user.SetHumanProfile{ GivenName: "firstname", FamilyName: "lastname", @@ -108,7 +110,7 @@ func TestServer_AddOrganization(t *testing.T) { OrganizationId: integration.NotEmpty, CreatedAdmins: []*org.AddOrganizationResponse_CreatedAdmin{ { - UserId: integration.NotEmpty, + UserId: userId, EmailCode: gu.Ptr(integration.NotEmpty), PhoneCode: nil, }, @@ -140,7 +142,7 @@ func TestServer_AddOrganization(t *testing.T) { IdpLinks: []*user.IDPLink{ { IdpId: idpResp.Id, - UserId: "userID", + UserId: userId, UserName: "username", }, }, diff --git a/internal/api/grpc/org/v2/org.go b/internal/api/grpc/org/v2/org.go index be830bc7b5..5f21f7403e 100644 --- a/internal/api/grpc/org/v2/org.go +++ b/internal/api/grpc/org/v2/org.go @@ -57,6 +57,7 @@ func addOrganizationRequestAdminToCommand(admin *org.AddOrganizationRequest_Admi if err != nil { return nil, err } + return &command.OrgSetupAdmin{ Human: human, Roles: admin.GetRoles(), diff --git a/internal/command/org.go b/internal/command/org.go index 261a571cb2..a018a90c82 100644 --- a/internal/command/org.go +++ b/internal/command/org.go @@ -96,16 +96,24 @@ func (c *orgSetupCommands) setupOrgAdmin(admin *OrgSetupAdmin, allowInitialMail c.validations = append(c.validations, c.commands.AddOrgMemberCommand(c.aggregate, admin.ID, orgAdminRoles(admin.Roles)...)) return nil } - userID, err := c.commands.idGenerator.Next() - if err != nil { - return err + + var userID string + if admin.Human != nil && admin.Human.ID != "" { + userID = admin.Human.ID + } else { + var err error + userID, err = c.commands.idGenerator.Next() + if err != nil { + return err + } } + if admin.Human != nil { admin.Human.ID = userID c.validations = append(c.validations, c.commands.AddHumanCommand(admin.Human, c.aggregate.ID, c.commands.userPasswordHasher, c.commands.userEncryption, allowInitialMail)) } else if admin.Machine != nil { admin.Machine.Machine.AggregateID = userID - if err = c.setupOrgAdminMachine(c.aggregate, admin.Machine); err != nil { + if err := c.setupOrgAdminMachine(c.aggregate, admin.Machine); err != nil { return err } } @@ -179,7 +187,7 @@ func (c *orgSetupCommands) push(ctx context.Context) (_ *CreatedOrg, err error) func (c *orgSetupCommands) createdAdmins() []*CreatedOrgAdmin { users := make([]*CreatedOrgAdmin, 0, len(c.admins)) for _, admin := range c.admins { - if admin.ID != "" { + if admin.ID != "" && admin.Human == nil { continue } if admin.Human != nil { From 49de5c61b2fc6226e29a931cfa9bd1bd84f628eb Mon Sep 17 00:00:00 2001 From: Stefan Benz <46600784+stebenz@users.noreply.github.com> Date: Thu, 13 Feb 2025 17:03:05 +0100 Subject: [PATCH 010/169] feat: saml application configuration for login version (#9351) # Which Problems Are Solved OIDC applications can configure the used login version, which is currently not possible for SAML applications. # How the Problems Are Solved Add the same functionality dependent on the feature-flag for SAML applications. # Additional Changes None # Additional Context Closes #9267 Follow up issue for frontend changes #9354 --------- Co-authored-by: Livio Spring --- cmd/setup/48.go | 27 +++ cmd/setup/48.sql | 2 + cmd/setup/config.go | 1 + ...epeatable.go => river_queue_repeatable.go} | 0 cmd/setup/setup.go | 2 + .../integrate/login-ui/oidc-standard.mdx | 10 +- .../integrate/login-ui/saml-standard.mdx | 12 +- .../integrate/login-ui/typescript-repo.mdx | 1 - docs/static/img/guides/login-ui/oidc-flow.png | Bin 74577 -> 72706 bytes docs/static/img/guides/login-ui/saml-flow.png | Bin 75138 -> 73249 bytes .../grpc/management/project_application.go | 12 +- .../project_application_converter.go | 32 ++- internal/api/grpc/project/application.go | 3 +- internal/api/saml/serviceprovider.go | 53 +++++ internal/api/saml/storage.go | 44 ++-- internal/command/org_test.go | 4 +- .../command/project_application_oidc_model.go | 4 +- .../command/project_application_oidc_test.go | 4 +- internal/command/project_application_saml.go | 7 +- .../command/project_application_saml_model.go | 28 ++- .../command/project_application_saml_test.go | 220 +++++++++++++++--- internal/command/project_application_test.go | 2 + internal/command/project_converter.go | 16 +- internal/command/project_test.go | 8 + internal/command/saml_request.go | 4 +- internal/command/saml_request_test.go | 93 +++++++- internal/domain/application_saml.go | 12 +- internal/integration/saml.go | 40 +++- internal/query/app.go | 170 +++++++------- internal/query/app_test.go | 66 +++++- internal/query/oidc_client_test.go | 41 ++++ internal/query/projection/app.go | 24 +- internal/query/saml_sp.go | 104 +++++++++ internal/query/saml_sp_by_id.sql | 19 ++ internal/query/saml_sp_test.go | 123 ++++++++++ .../oidc_client_jwt_loginversion.json | 32 +++ internal/repository/project/oidc_config.go | 4 +- internal/repository/project/saml_config.go | 46 ++-- proto/zitadel/app.proto | 5 + proto/zitadel/management.proto | 16 +- 40 files changed, 1051 insertions(+), 240 deletions(-) create mode 100644 cmd/setup/48.go create mode 100644 cmd/setup/48.sql rename cmd/setup/{48_river_queue_repeatable.go => river_queue_repeatable.go} (100%) create mode 100644 internal/api/saml/serviceprovider.go create mode 100644 internal/query/saml_sp.go create mode 100644 internal/query/saml_sp_by_id.sql create mode 100644 internal/query/saml_sp_test.go create mode 100644 internal/query/testdata/oidc_client_jwt_loginversion.json diff --git a/cmd/setup/48.go b/cmd/setup/48.go new file mode 100644 index 0000000000..2da0ad51a8 --- /dev/null +++ b/cmd/setup/48.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 48.sql + addSAMLAppLoginVersion string +) + +type Apps7SAMLConfigsLoginVersion struct { + dbClient *database.DB +} + +func (mig *Apps7SAMLConfigsLoginVersion) Execute(ctx context.Context, _ eventstore.Event) error { + _, err := mig.dbClient.ExecContext(ctx, addSAMLAppLoginVersion) + return err +} + +func (mig *Apps7SAMLConfigsLoginVersion) String() string { + return "48_apps7_saml_configs_login_version" +} diff --git a/cmd/setup/48.sql b/cmd/setup/48.sql new file mode 100644 index 0000000000..018231f59e --- /dev/null +++ b/cmd/setup/48.sql @@ -0,0 +1,2 @@ +ALTER TABLE IF EXISTS projections.apps7_saml_configs ADD COLUMN IF NOT EXISTS login_version SMALLINT; +ALTER TABLE IF EXISTS projections.apps7_saml_configs ADD COLUMN IF NOT EXISTS login_base_uri TEXT; diff --git a/cmd/setup/config.go b/cmd/setup/config.go index f3215fd980..d782a32dd6 100644 --- a/cmd/setup/config.go +++ b/cmd/setup/config.go @@ -136,6 +136,7 @@ type Steps struct { s45CorrectProjectOwners *CorrectProjectOwners s46InitPermissionFunctions *InitPermissionFunctions s47FillMembershipFields *FillMembershipFields + s48Apps7SAMLConfigsLoginVersion *Apps7SAMLConfigsLoginVersion } func MustNewSteps(v *viper.Viper) *Steps { diff --git a/cmd/setup/48_river_queue_repeatable.go b/cmd/setup/river_queue_repeatable.go similarity index 100% rename from cmd/setup/48_river_queue_repeatable.go rename to cmd/setup/river_queue_repeatable.go diff --git a/cmd/setup/setup.go b/cmd/setup/setup.go index b78d1fc9cf..bfa289ab36 100644 --- a/cmd/setup/setup.go +++ b/cmd/setup/setup.go @@ -173,6 +173,7 @@ func Setup(ctx context.Context, config *Config, steps *Steps, masterKey string) steps.s45CorrectProjectOwners = &CorrectProjectOwners{eventstore: eventstoreClient} steps.s46InitPermissionFunctions = &InitPermissionFunctions{eventstoreClient: dbClient} steps.s47FillMembershipFields = &FillMembershipFields{eventstore: eventstoreClient} + steps.s48Apps7SAMLConfigsLoginVersion = &Apps7SAMLConfigsLoginVersion{dbClient: dbClient} err = projection.Create(ctx, dbClient, eventstoreClient, config.Projections, nil, nil, nil) logging.OnError(err).Fatal("unable to start projections") @@ -256,6 +257,7 @@ func Setup(ctx context.Context, config *Config, steps *Steps, masterKey string) steps.s37Apps7OIDConfigsBackChannelLogoutURI, steps.s42Apps7OIDCConfigsLoginVersion, steps.s43CreateFieldsDomainIndex, + steps.s48Apps7SAMLConfigsLoginVersion, } { mustExecuteMigration(ctx, eventstoreClient, step, "migration failed") } diff --git a/docs/docs/guides/integrate/login-ui/oidc-standard.mdx b/docs/docs/guides/integrate/login-ui/oidc-standard.mdx index e9bdfb7cbf..f05d0d99b1 100644 --- a/docs/docs/guides/integrate/login-ui/oidc-standard.mdx +++ b/docs/docs/guides/integrate/login-ui/oidc-standard.mdx @@ -10,9 +10,9 @@ The following flow shows you the different components you need to enable OIDC fo ![OIDC Flow](/img/guides/login-ui/oidc-flow.png) 1. Your application makes an authorization request to your login UI -2. The login UI proxies the request to the ZITADEL API. In the request to the ZITADEL API, a header to identify your client is needed. +2. The login UI proxies the request to the ZITADEL API. 3. The ZITADEL API parses the request and does what it needs to interpret certain parameters (e.g., organization scope, etc.) -4. Redirect to a predefined, relative URL of the login UI that includes the authrequest ID ("/login?authRequest=") +4. Redirect to a predefined, relative URL of the login UI that includes the authrequest ID ("/login?authRequest="), configurable per application. 5. Request to ZITADEL API to get all the information from the auth request. This is optional and only needed if you like to get all the parsed information from the authrequest- 6. Authenticate the user in your login UI by creating and updating a session with all the checks you need. 7. Finalize the auth request by sending the session to the request, you will get the callback URL in the response @@ -37,10 +37,10 @@ https://login.example.com/oauth/v2/authorize?client_id=170086824411201793%40your The auth request includes all the relevant information for the OIDC standard and in this example we also have a login hint for the login name "minnie-mouse". You now have to proxy the auth request from your own UI to the authorize Endpoint of ZITADEL. -Make sure to add the user id of your login UI service/machine user as a header to the request: ```x-zitadel-login-client: ``` +For more information, see [OIDC Proxy](./typescript-repo#oidc-proxy) for the necessary headers. :::note -The user id sent in the 'x-zitadel-login-client' has to match to the PAT you are sending in the request. +The version and the optional custom URI for the available login UI is configurable under the application settings. ::: Read more about the [Authorize Endpoint Documentation](/docs/apis/openidoauth/endpoints#authorization_endpoint) @@ -97,7 +97,7 @@ The latest session token has to be sent to the following request: Read more about the [Finalize Auth Request Documentation](/docs/apis/resources/oidc_service_v2/oidc-service-create-callback) -Make sure that the authorization header is from the same account that you originally sent in the client id header ```x-zitadel-login-client: ``` on the authorize endpoint. +Make sure that the authorization header is from an account which is permitted to finalize the Auth Request through the `IAM_LOGIN_CLIENT` role. ```bash curl --request POST \ --url $ZITADEL_DOMAIN/v2/oidc/auth_requests/V2_224908753244265546 \ diff --git a/docs/docs/guides/integrate/login-ui/saml-standard.mdx b/docs/docs/guides/integrate/login-ui/saml-standard.mdx index c1f282371d..a2cb907874 100644 --- a/docs/docs/guides/integrate/login-ui/saml-standard.mdx +++ b/docs/docs/guides/integrate/login-ui/saml-standard.mdx @@ -10,9 +10,9 @@ The following flow shows you the different components you need to enable SAML fo ![SAML Flow](/img/guides/login-ui/saml-flow.png) 1. Your application makes an SAML request to your login UI -2. The login UI proxies the request to the ZITADEL API. In the request to the ZITADEL API, a header to identify your client is needed. +2. The login UI proxies the request to the ZITADEL API. 3. The ZITADEL API parses the request and does what it needs to interpret certain parameters (e.g., binding, nameID policy, etc.) -4. Redirect to a predefined, relative URL of the login UI that includes the samlrequest ID ("/login?authRequest=") +4. Redirect to a predefined, relative URL of the login UI that includes the samlrequest ID ("/login?authRequest="), configurable per application. 5. Request to ZITADEL API to get all the information from the SAML request. This is optional and only needed if you like to get all the parsed information from the samlrequest- 6. Authenticate the user in your login UI by creating and updating a session with all the checks you need. 7. Finalize the SAML request by sending the session to the request, you will get the URL to redirect to or the body in the response @@ -37,10 +37,10 @@ https://login.example.com/saml/v2/SSO?SAMLRequest=nJLRa9swEMb%2FFXHvjmVTY0fUhqxh The SAML request includes all the relevant information for the SAML standard, which includes the RelayState, the used binding and other information. You now have to proxy the SAML request from your own UI to the SSO Endpoint of ZITADEL. -Make sure to add the user id of your login UI service/machine user as a header to the request: ```x-zitadel-login-client: ``` +For more information, see [OIDC Proxy](./typescript-repo#oidc-proxy) for the necessary headers. :::note -The user id sent in the 'x-zitadel-login-client' has to match to the PAT you are sending in the request. +The version and the optional custom URI for the available login UI is configurable under the application settings. ::: Read more about the [SSO Endpoint Documentation](/docs/apis/saml/endpoints#sso_endpoint) @@ -87,14 +87,14 @@ Read the following resources for more information about the different checks: ### Finalize SAML Request -To finalize the SAML request and connect an existing user session with it you have to update the SAML request with the session token. +To finalize the SAML request and connect an existing user session with it you have to update the SAML Request with the session token. On the create and update user session request you will always get a session token in the response. The latest session token has to be sent to the following request: Read more about the [Finalize SAML Request Documentation](/docs/apis/resources/saml_service_v2/saml-service-create-response) -Make sure that the authorization header is from the same account that you originally sent in the client id header ```x-zitadel-login-client: ``` on the SSO endpoint. +Make sure that the authorization header is from an account which is permitted to finalize the SAML Request through the `IAM_LOGIN_CLIENT` role. ```bash curl --request POST \ --url $ZITADEL_DOMAIN/v2/saml/saml_requests/V2_224908753244265546 \ diff --git a/docs/docs/guides/integrate/login-ui/typescript-repo.mdx b/docs/docs/guides/integrate/login-ui/typescript-repo.mdx index d5fd6d9e4d..d4a0726621 100644 --- a/docs/docs/guides/integrate/login-ui/typescript-repo.mdx +++ b/docs/docs/guides/integrate/login-ui/typescript-repo.mdx @@ -130,7 +130,6 @@ To register your login domain on your instance, [add](/docs/apis/resources/admin When setting up the new login app for OIDC, ensure it meets the following requirements: - The OIDC Proxy is deployed and running on HTTPS -- The OIDC Proxy sets `x-zitadel-login-client` which is the user ID of the service account - The OIDC Proxy sets `x-zitadel-public-host` which is the host, your login is deployed to `ex. login.example.com`. - The OIDC Proxy sets `x-zitadel-instance-host` which is the host of your instance `ex. test-hdujwl.zitadel.cloud`. diff --git a/docs/static/img/guides/login-ui/oidc-flow.png b/docs/static/img/guides/login-ui/oidc-flow.png index b6060447702af9bc4c7ba78d6d2fcca84bc8a717..a427bad4efad8f5208663f99b2fbe478304d2c72 100644 GIT binary patch delta 49539 zcmZ_0bzGEh(>_cs-Q6J|Al*nV(o&*?fHVk7t#n^3t)!sRjetlB0@5WR0xL+@BHi5! zycgfNpZocJp7%fh5Ux4roH=IZnB(MEH&#eJRwcnRU=^MD`zwiS4vWOyo9~4e(DBpX z{xloS-oN#B$;qSUly?n+*P2u2s)Q{WY@B+$W}eKz;!U^eyS&~k#+|n|vVT}!;c(gY z;GNxAK9}=5@p^7#y)v6QKR7Vh1{^MZx~#~DvRempUm-)|`}9Ter*XxeSsPV+c``=I zD!*U^1!NC-zeQnW-Jkb4oK3AZ9MO;q#hBgP!`Ln+IDir2df4DVpG6RM%w9BZU?{ZUeIlY4TSAeP-CEa-jsZTy-RY$IHzSy!)e73OP=t0>BnDyp9~9C za5cQ7A6C;jaX8Y89dJjsCcT|SV_ERtua*fNXggmBvlKP%K*17A($-No^m|0T zVL*~t-B0&dnqk&(3q`+nvM$CQId8g};X(|ep@y4_ao{Zeq4rrVdu6_FLUh2^y$GF0C09Er0eRzXoaNq>a87o^7zRNIH>^ zXpU$XcoEyl!uE3wANy+)DvW)Q#;;ChzJ4Ct;$t4^c9fLG>R_KyH=tGD_(C;ZsAFLT zd!D_I06pfApCuQT= zNQ=B|Z@mNMEh*rs5fH7E^kg4z~abr(6r`!j&bkBmUvV zLL>{po9?$ZtAbk`(Z>Pbo8fzFHbbP^T%qrJi|-xmmetLV$aJs7xU8He0LUM&`wS}E z1({5Y<+P<_*OPSRBV`ryRa^I08c{lDz*|o_K?M{?>)}Ogawr&U33$M-K8xe;$v0XL zt;Tn;dUNg`#1ndb35OP86FR}iDlxXd<9Q^u8tK*hYvzqKIsJ9e#xIXV`DY(dEG9B- zku8Q|#Lm3IgP&MK8tzSbK1{RnmB$wgVa4q)JrQp_&A`R$Ki$*eWZ!b!c+TBG;{cHi zU_qTpOJ+;w46BmDf$QyZiBo>93MM5iX(?D4WhsiI#t6m2a6kNI`8#lFW%x=gQn zaJ%3h9ST-fY=m4V(z#9DPE^zG}RJ|~b zhkKEX-a9RkJ-SbEsm)E~>4F4Pi^xxQrve#?BX1xXM8qtg0DS-LWAC7gbrauZ%3e|m zBYZ9nSDD{qLrsI90xV$y8Aa}xGShy7_(yq+W~PbkzTv!DLO300R1T}bddxw6*XG|B zT*_YS9J+fi2mueDoA>iDIC{pvB<0(s{yNIFGOinKLiE5E4Pqz{) z;}&W`JuF31fPIC3ttrUns-GFlq`jHRzxh%a(Sn}3w|{l25V9S3soQx{yRFu1Nl0pV zc7|ByqLRdFu$u{R`~8PA9l3Zh7+6WP;+HAvmRNwC6GlS(e$*^i2Ci&{29#*@cEy1( zR5@Gab?VtpyY~BNYI8)Q}TrmOWEi)+Yi zOjk3duiCP&Q5!TC*A%v|w0i`?Ju!&r{W2MTp-(3>`~8tVj`z@Ez!NhL24{FmZ6Lepch8fvXRAcxj3PDgadp;vtz@hG5a9O4%g zS1#W7NveZCH9dTs04t5p29DwE4?o!-3d3b{k1=_jZvW%=I`3IBmP>S~bWA{IhlMy+ z{ZmUW-|B#Z5?&J9!XX524wStPjw{xPcv}cK=H|n6=Z~OXStqr&J>S3mm8e_NnM196 z2VWh>b+Ub7hL>p+T!QAU204fLrBrK+TodkN;x!|!v(|8Il89|4nRxolN*}H1y7?fS zRvMjQ39Da4+1HDHk{Coc6`PXM9kD)Xn{Nbo($(cp8lXc1WAWOsd-&eE2Z#9kKz$YQ zOlb#ftGC4ko+OsqXq(t1*CK#g@Sd53GS5fD@PPXL}*weOV z)0;Cnzy)D52ELdZgDNbU8&^zrOh$BzlYV7AogQgG-e;Z%#-u{ob?~ z9m?V}>!I?Aij>MZuUWJ*e#QZ|w4dST2OmQ0YxTG z59p#A^VHyDbk^nW)$&Ok-^(zx$B-znp0cHfq_+v*Z6TCk_FZ~0L2{q!S>${qaCv#m zfT0~>SMjVlG=qo(dkdBB+Oo_Qb+o5LMEIQR6UQcn^Zcu@Ew1YCr*8nLO!0#tlbik% ztwQ0sZ>&G%;HK)6ojh%NECq`2T1j|>vX zt+qbn?EXn`H0|07WTpeq#dZ5@Cs^F3VvgdMZ2xP{GoRzHb@5CJx)JnAR4e|Z4#?`E zmij?Ya)s-zL)ID9YMAUEa(7{o!L@~8HB4c5>Z{|zTNT?ALLu_AU8v51BnABLW@W%Z z-|C*4PF90fd$XKx=H^m_8pKByEj6MV9^J$F;pS{n7xthRF90|nZ7n}dgkCnD0WGNU zkzpdFz_)x^>%eBrSyT?E$Xdwn4N@FRhemRTDllxll*Tzrh$ABXNqT}=y<4SR%Ghz! zKD~%AdVWJJV06KarI>*J{mZiHJaEG<)c>T2CF%{2??SvFg(mj9;me zH+R*e&=RozaXeXI`hj1Lq>DV-WQk)@k`u*Uet3LzveO-w{(E;@!4%Hw6YbdRfje((1PaEEK3px5uH%3l8Jhy~cIgDZumwO;N-+IsMyRL@8r ztc;rjIKNXf3d(btuGP;zBW3q8Na=rl{qV?O;K;}#kRV7LC4DxpnB~6{7{y`Sd1l+w z`m@32!5149%EpNA{7?4WFyt^G4e?l0icAuK6&Ds)*931x6P}{_U3aWI`Oz#*M`tt^ z0g$28y)dA*!G;F8@|N(|@e+$U>!vN|NBwrMKpUB7BBV9D&8Kyljd5>TR~6x^Ti9-| zJIwHyci5zm@>B!b5HsfGsSis&^AlETn^Z@7V136O-VA23$UFpa996}*q-fmTC&K+N zE*j1n_davyLr*iI+0CouTdjV_{64s0_FNM!FkoMAVt0+4WJvGkdX9&|u&|xU;U|wsX0IwR5 zo;W|5-c6FXIXfSl-fhn+ohi&qtlbPj-!B@f6vW1gT;XwP>bpm|Mq}X@OZa1G(D@Ea zcq;@#U&ZhgzViIb$g9qr1$nBi9h%L}OsWo@=i{JHD!sbw`-4J>V?jG6h(iU|9Gd@R zLT(zRj3g!6KXDB6%k_$Gbe|E0$^*FiZiM;` z#*Q*d$U)>y=lpktnGqU+-yuTYxS1L}5lWtKfO1qeyXTucn75GA1qbLP0M{BFQMeT0 z+8#1Fo-Y5_6gLPt1yVBhy;i)`)mbeZKt}{MUC!(mGc@?;f+#} z9ar_Co3#aJ7_1NXAj}c?8t10ss9lm_ihjKKANPqw8!;ITyu!513_So5Xy zU)EC4hhRSF8vo8xJ}PCg8)G?^%NC3*ln~7{PWfzVoMUWJI`^z(#;|b4V4ODU4wT1|<31EBbYiQ&S+@jg1@9seZ zo3E7~93%FKv9Zm29l#|*%mZAD*Ov3zLYIh;^tqiM z?~9;?H^9j+u7G=~n!hmMVBl8-++3ZaK7oa{C@Gbi>Nc9Ku)H~0!d`us+XVJ}xHG~bQM+Hcvu7&8_=pAV?95&#vjrpQSK`O?5m8_> zOyiz2*7;{7;Sv0{o3R!sprB_=BV1x7-rnhrTgG)ejov$mJwC>cjc7ET<37!UoeGn= zEbEoO|5ZLJEFjKEYO7j`&*T{o+(Q*4CBQMe+B}tbZ~u9XW|+`LxQD^D4Y|F#V6kN7 zdbs3jgpVzj@7?oirXefy769&YEkEcqUyaBzwxd%&DP4O4rzSAwbN^nlo!>#wafe`) ze+_lMnjN2AufAGC1CID)xJ+qBN}VbM3xAe>da2+vUVVMgF>DwuuL5+l*%IvL| zk`8TIlCVbpDK?U>p#U#{Tt(n3P8B;JL1+~mex%by{nSv=$G`-Lv4aSt^M=&W7`JE9 zwP&rvvp%7lBL`l!%;}lSTV=H(+V7Fl{^^ zHXrxk&#m#AXvNhG6l+~r@WRRwmzgzxW`|HoTrUnpSa43puXFz3&;I0@FD<}!m>3xhRZE8o3T+#O5Xn6!sdYx1++zn}?ZJre_v zszNE2No+VS7vLp{WZzSg&A(@WTleHE&l!ohBaQcspPHsV zpd7b+l$9S>jFWW$Q5u{KrBL$A%>BjIDWTqT*j8f?!S~1x!H9i|@y%+VUfVDKwMJ(d zk-+o%9P4^;vIzcA#g8eP zcTG)6Ge#hBF!(6uJLLLx?ysGSD27!hob-=C3ReC<4LDx29@RoflhY z5Sd*bk^5rUQ+peCBH80d;xO`CH+sP*gnGv+=kJqRrh~sxU`!t&3Ie1L0?vbt{Sb!H)OruUxxnJn)yKYUJEIurHqs&aTw9hh8XH zcUm?IWRF883Po9)qs6I?Fh|Hs-sBy!%2{f|`?ubZ*4_=^9SKb)6nm6&NeZ^`5i#{l zjy3rUaU|e1i>3l4Ru)EFZ4#gnH&OVZZ;A8et?ADn-Xy%B)mT>aqp6}gVw21WUX3bG z8H_jz7KdEpjc^z7hPW7#g7yXCf~70DbYRL@OSq0N7E(8wMb)OE z`L8*t3tHPjuG(s_c%8q}rtnpLY1@B8D|LHD06e6{os!HPJszc-{+Q+T*c+jN%1H2A z?#kiq63*zh*El0s3JQx2c~&HuMC&Si#W{lY_|iqZSMj;$(&KpAtI7?XAs9Wp)|Md{ zvyou91->r!3c0KMzIR#_{RAhSN7IHN8oCZvG`Y&kE;52x{xoQA(t3^wIgL}t5gY$Ca~a;w>{ z7!NyH&8SF%kmWHP%`S&x_bZ9~_hcN?t+gRn z$a;gi7)$wJO?R+~|8;lDybj`wH$Ony#VJ9s z)V`z<6*C_{fw9SpOBQ~7dn`2c|2kG0PufrL%)rFNPrRc$JN?#we-!8r5vn&e#fsGG zZ1{jx%KEw;L+vNlzduJXg_p(NV9ioq?He6IwFXIZnuw4)YiXUeQlw=ZrtV}*5TyZ`G$AyaHI z^KeiKQWTJ}j9VM{sZRrZ}3R%Ie z3u#k+rr36S=q!X$2b*t+^6i;{|Lf3kf5@MC#X+q0D2oUtOR)a`d=s}m!C5~AgP`Af z$4AEk5^0Y)paZ6eIaSc^RjIeT7t$Y5TuuMzJfYud{ ztZ{S|GLBMZkH0@)>3dA98pdm*zCo>h&2seqt}8cqHx?=MQXCqSN#U$w>`V`a+yF;; zh0hfjk=J1_Q1BN#t+T(0o+^IO%Xyerf%{;{(Z%L}Lah09qljCz#0^#8HjWl%4We4y z_@NzlW^B)`f5o`4UR<+^$n^KNGJnRa#^ev(_w`o_Ft@ZgRr@SAH}vy`iBuSiTu4garWTXzJpO0P z5b4E~a$4rV^`Ej&6`R3KJmL%-pwUW-Ok^7WjrkNAmHJP8s;DwG#l;sl5sDusc1E8W z;T`4icpd_#;z09EFFazp%!=Vt%=wtm2^YxSv+7d?4j@DfDt)xe?~%RCHP6KV0FV~G z5$3U7VsQ2=!vW)%sF0ZWHR%z+UiaOI4lle`IZ27@n9%T11sW$X+jJiCUx97D$P{>G z5LgD&eJ8Tw7vG$Cn>MRb@bb+noV24W*^>Y@@21$c{5vP)8Xn|V8D@IsuiUiCqJQHo>bIeN0#tuafpU#FP;b0l9J zwu*^e-eb>2SAy4O;dt8JSasv?68F?k)&n;M6)vL%p~cSQD>t?dSHYjWU*Xa)pS@`r zTjSBR(xU}~r<1K6BUH^rGhdnrf14+e^BKW(4TLG}SB2aBWd*{^Kb*WWq|`oCf(5@o z-wt>?@rjA~O{ZF=gCL%l#N><$G%qjfWj9F>MFu%~A>Z!jQuf&`_+G;~)O{f}Wj3 z;0b4EQsOP_idrOT_fM*rO^$0-|C$6tgha{=xBhD+!+NTOr!*zK?0L*%&%j?K$HyTp z8~#eszw#@{o+=vVdE?%2jT|MmMCgd%{Z9d~=9O@h6OgHac#y~6hQ`%15)T7OImF

5<9*}#U9VdSw_p7Il-i)ld+Oghy~A&rXU1 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 U=(pLb@!&sYMRkQ@IrC@#3#p<4GXMYp delta 51425 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 z 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 $=Ytel4xICr(Z$FJql#)Gw}2ZNLl19A;t6FY$o z!MFpn+DVH2xUn6}^e5FpK>O9FHyJ`s9!bZq>o!dMFTyTTM)vIK2Y=}+Q3IBl;V8R~ zKoGlpZb%oZgnJVv++)qF9!igUdr9%|CalttHrk^G{}X4|bDq<_rD-DP_DKQXD@_U9 zbwu}*XWPEfWoYjvpEziOc2q__kNOQ{I4-J88QGB(>$^1pVvQ#teq2_{J;Yi&^(G{R zRuPJnoC>k#ml9Y4tsA+Xqk4Z2uHRMua|2p7Z=Hhp28TS3QDKQZk$a8Xb>TYCFSNg_ zV>3zsdt1M`Nw*cNVVv%pe@4_sK$Tm1Tjn>Uhn^TP&G~Wt zq8uUX>5v;hD5VwiOV}F&=c(*JkVJONqh;V^r{ov<(;0sGou93~AR1=2DbGtgj(y|> z5=kzhUItD&CN4HU7P$>5(`aISCcg_QwS`56?+W4Kd%}g?(2)(6wLQNX;W?zV=(hZU z^y+izqUkf%PB4ra7?pK<-P(}_r-Jzw@F(G-7=0d!Q@{ZWMf%jW$4kW~7B*9rGFR@q zQzu5O(H1{uEw8A-hlw3RAkH92CL<(fLaqVzQ1GIo#VSO{rn<}Py1l_|KA6P;C6v!R=#JqH)aORQSStMqP(TMVLG0`mH zaYV^)RO+#47RRza zet`z+%nUYjh{fM2kk6d3taGL93DTfw46g>28}l&#mG8m$E`Iq$5JJ+&W449(BI6h6zNGbx7?kq{EotF%)NnOZ45 z)u*wu9vuv9db>{VO=YmGjlmz&fgVqI#Lip`Wc~xOKM13NwD4oX6T^|R#jU;@*D4O- z-3#@#?Hc&F4j(gu(p|0Kki{K9ExQKkCOPm76!S|y*@NX7wQk-u7FqDf2Obot^Y^TG*>2)Y|2C%|fm_8ZJ!M+mgJ4$0 zSdwha$s}Swm5B43=*QcK49A7-JMw0r$6zg{l3`rMIaTF&sP+*8|6Y-0DO(RiGnG^? zoydrQ&0Ulplv^Pno0Rb)fbUzL|Mb3!uip;Es^fpGroExF>7>v+aJ-e%!OA+q`p3Sp zE;#VJ!Li7(DpAoxQBh6dbZIFx2Y3ZaRtsvzrq3NNdq~K+s zBnOqFzc)#fIDgMP^z0SXD=*@2`@tFy`UenJAfN0HK&5^(9_5tk&P#hi|5G`IA#?v6 zvQVnkE!^QtlPs3*?l_9X${$Fi&Kom_4|~;zQaXYLQR3Z#ty_n!3#4pM41@8`{1G9I zpau#h3lX)H;*P;__SoYup=l==Q`I$$oFpCky7uBh8T93jDK&gZDxEZ11Q7Qm-nP6Q z5uc=q61quu7flqyHj=dmb%b1>-`CYup)y zAcB;^{=Ge+a;B04)>(}Mc0>~LMD3PC(P|FpN!?d(*I~vlk{=Dx&S3b>K4_EM< zZvnW0V#b#wJq8I_9L%&>i_ygi-|M%(Zl{67)-`&yz0B&n8N{n|$pt#K`_EczjyVZ_924j1FO+B{-7+d}oT8N#(Hi_)xU(Q-2`N*64zOLr3ffoDx z;T=l-H#oqqPH~On=;`-5FA@y$;-+OI3Ax|lV@4v&NzeWkP&n49 z9!Ko3yZ=oel2VDA6~k|1W|)z=LG(xsD0$E45P}lf+mTW;x2a5i@Vv!;Xqs3zZ$duL zAMcx~sztm*xoGWsR%@8Qaupy9#rxgJDv@Ly)O4|CY(GnEF3lCDA^>laXFUtUXKtX( z(wjgb?VpIoU~KXh?r)Sy>T5j0+6jF9&$X@U$;P3f;F`^CZ>AT0W;uz%zP@~ILd*ai zoF#(#^!YMl&4nXDu*aS7hZ{}OILNO7YapHCfa?46zlRA3-K8ALfh$f|h|Mgi@-SJI~`6ZOa;;gu00o zA`&py?fxV6T^NEORdK%0{~apY2bKM4$6vctf6Oek|Ik1UFuPizhk78FK-0?SinooeI(Zc68p;vME4UaqIvNsAGH)q|LyQk zA9j0ZcAN2r1dY&&*{yME3QQ9Ip85d<3h_(nD=M2#ll-s9aq+Cyu)x18KN}CHqT90; zPh<|M|AGGJrZG_p>;C#&%2?9EqasnW?AF|9(hp%Q=KsOLBdWyX%Y?fyB&#Z&JV}3U z*dtAQg0GLPjP-$>`7aZUGVgZKj>DGz6ZZamM%tDXjnI+?0?;8?)jO+2+<`=f2F`5h zf+o%UihP*}p6x%5#->ld8vh}ba+Ou|9hhU{@Uk){pzo`ryXibb{6oak9hz3m1^@Ys zPbahDkkI7q* z(S@$~x>D^`Y!3Kb_5wKKqQqJ<_3+R}i$V9!n>EY7SY8oVa%+2f7;>J^@B;BLvsP-v zaHnq!`9Lud_h0z2ktF&~Th5WST5OAwD7FM!Bo^elRX!c`?TRx$4&kg_(yChu{*N&d z0N7b@4D^XMpX4u53o_dO+Ypo2w?v@(dA5t^#pTxH;OT1WEourk`d{WG7AuQ#o30uZ z;^V2zHy!@$8G=vH(GtdG(61$nRV3(%wXxCF;c%%^=3u85F7N>-X`)gc<|e&sz(FTP z615pP({B06ufT%q{;^duAmec`9f5i(P)FYFXlJiaRkZ$V6-6<{eHDdYvT<{f+)xm@ z70ODd+0t_>)|^?5pU7gM=FN48`*fT6Gc(Co-8d+TrhN zhejKHcS}YO6>+=D8q0buB^@xBx z-v4YG)(%~vyyLSDlJY}Gl&Iz0!0;Xjy|#x+=c{e+gG;%=A#!|@lvc+_h8zu{3*|qa zGbchw&jDM{-4gN#YHzAo_jt5GY4=~@FZTmaVSCug+^YwHRy@Qr!*^ni3mz8W)Xo|` zD=8r4#v}AahaTCjH-AWIBnmzZecI}O{!hj`e z3An#OzB}5Sl>VW8P%&Li?!UJKj(`s=m81h=04s9M*bm{+JH&64rKgv5nR0oDft?s=aD(;!Jqiz)7yUib?D>G1 zqL=mo*GLNr*Je{%-0DHCquc)BnaFFSwI&HRMY?Yw9Gi&DahGnpDQqvrbq+wvIaB2%8D$ zFm^ULcjbnT#Ht$B=58kyDc+Y~@d2*-*ps)txTB%>E9ijP+>H0P`89BcUZ?T9#^9kV zh|rfGk=Bcx zS?a;pxyceX&gIbDJ~ZM44)Esmft4-g{$2wUS{PHq;ZI3$04AA!2kwcn+1^0VFvhc8 zmX`@1DliG&d*0sw$gIQm9k8=_NldRx7(w^P#cm3VYtyawLS`~D6vf*{b$J+~(>}el zs&)T@%#)Tlw#T;}4^rrNwPjCS>v@%{GOjH$#3<%*Z{s|0qE6RSYz*|i& zPY>>m`3*#YV}(Zk=h_2H+Z3aHkIRxOM~5d>EWMG8);Du&9^2kV8D`XD0{j8mmp;Fr!s9o?#z8oQ9c48n zZh+5wDd>(Zj29=ByRdNK;^m7A(8-YF81Z0d$+DvIB@L6bYmOpi-lCLPo6@$){~*v( zpX@s&5%v&o(5A}pWB<5;KS^mxGQ1=C2^BgV>cr8Bzm+YJ z{RQ!2R@yjj$=-VvQiJZm_yn%P*9^kXAV8S4k{r>nM%N6NbN^b-a(rLgzV%RJvzY)G zeZ30Wv=F%+*6h4=E}>#1c^vs+C@o(>eGacL+gek-sK06k-(U{X4foK&tZ*Xa=ofSav!7{E-*Hs3Y-);<9lAigK-6TUI*sL(pr>+4NY-&T-Zc z11VM%8u+3dkbv2W06crx)G7fy)9rPg2Ec*16>67Nnf}<)FFBE0&+m}JAf5_8jWm_p z3E!n1;1r2T0>{B^IDqwAYBGrlkxHi`645E32mFR+g_(=lz{IgOU6$&R zXrdIJQna8ffD8o=5=>wV4xcBkd5HJYX>uq*ITPdR2S(YNbqYyguBOENhLkUVkwQ5H zXP8MtY|~+hn+A{BLxF+{mGr6u4GYT(?490#`z2gwzm3rPu^*7KL|{p@teGkNU!VDTwUT;36HK#+_34!5>Wef^_(~PvZ}snK*T41<&bdhI@Z7lo^Q-jYib7L zZR5^MTU?24nLk2#!-6-~a)!~numfL=4zQ8;zDg7R=Fk7=wH?k~f=K4uwJ1{Q^+9~F z_?Wj1a>eIWW20n!Md>x5ej#?+pz$novl}1Gj|2yk`&tLwN^*bYwiWzrIMpV>DmU^C z?fs%03$R|OS1BJfn%}$LMIj4eKMf<+mZI=<;rg13)3-WQdNi9`Rd+6HqW$%UL&nid zvQ{rJ8qXu*vn}+5d?SXu<}{zQ;aOlr7s~bvGCS-NJz8E9QsWOUsQDgouI_oj*VAe@ z*1r}$9p6EIzg;Ypa0CXonwJ!$*P@kx{HxMG1_=+{q6P6r^}I?9c&kA_YHnjC^3dh# zw~q34HG)3Zob5x<29u{!dM%2?VAGa+B*&-%WYgH-_d1)fAfCe}y(wx$75_?jchu(Z z>1KTQ6J{pRZ_0HVuvX}T zW0qeaLpH}e3pg576dErq7k41niu2lZWP1^`e{+tXBzw%jN$B;(!q_7MEcv2&k^H7| zu+gBKk1)D(PTeEPtmi3{9e=v9jn+H2zZ1l>@7YJ@cdzT!*X#LoPJFrMtW4Of0Fjx8 zBPen}-uS^I;C&thCVT+Nu*?;=jii7S-gewRSml&|AbG#5^<)XS<}>LLd>fdI^BuGj z>lLVkH1$d~9WRm`Lv6N&F2Z40pJ|$$m7}rKRUuDh;%CJXvT=s&Y4G6Q2!dX2mH-w~ z$YktoB@>kY(&w-)j!O~9Ec>)$nZ!f)!tcOCb*_(w*IIC$0Hx({mamjhMcyYgq8g`T zJd%LxgyJsfY_jg1dFMT4N9KPn`w)og=SK>C$q@j(0u>W>6h;>C9%vl{9YoYY_Hz&| zgQLB^3q7Q#rwG36Sc-M@3U@EYJDVhZAnY-{T08Db`lb_4zKWVTv)81+iv3*Slszig z<@^9V_e{4_wwIILhNpgnuf=mt1;9R&SLAGzLKi{2hvbkP!v?{2taPR>xhuFa^j!l# zmHUM(Zn=(?m>q-LA*-aDuZfWt`^zpt*6kpHIXgG&hXm~#=kBS-d=WV86< zs3N>WmfSbPm(jroo!F>Hh zpYTrqIv-8BV=>Fwr-a5QJH2PzaZlsHB(tVn$gJe;`fV+!FwjZ=CSAHx&*uRO!7gv# zV;LwtZje9KkNFq*SRDQ3M0ay6kjZ{?n(1gNaFgXjHi5OAWcBls*hm(_JQe@-fyspXz*qmyk};MNHp>iY*aYslU0L4`#V-O)ItzWB^z8B$B)C!%v^wK{ z^+yriZOe*oAhmey@R0Of0wZ~-~I`kPxyd7I&gxbl2;9RcVNXj>4z}*+r{L?pLC?V!Zz?@~p*$|)-~EAl z>q?ahYgtTc}N9*P#4Ke4VySdaph4Ay1Ad!P-Y9pmKwIEqCZDL{2{9tBsDGLVZ^e_#_-~c zIg$}~&7vu#SQa5FN@gnbWCzw*WS(<}cj&?yZWgdxOO2*cu)Us&e;pOAYJVr}hp)#_ z3lV+$W3uy#CEjISjpz>C@SAxjkt=CWK$7Zida(ewwR?&SD`&R&Erj}e$(gSVK)(yJ z;*!G53T1l}i9-@7E8aza>XKdD)6z+dV@E+wwui03$ahDp8GwT|V;JS%@PXtZtbUbOUuc$+-gmtkc7v`sc92lL zL$-p2Sh~bRB9K!DZ*ysm0wy=K021x5%K-oZm$lv4u4<3 zgY^l6BI?NqyNCa+k{9t`)%=E8)e1(xXr=Z;1hZKfk%PN3e;!XE*ZEA}W8?nhPssqd zKT;YSV}h7&-lI6wKJWuf@wrmQ`b39lV!|l7|EdEKB8rKX1noTM7bZ?p{$Bq@Fs*Y31{tsVtAqFs z#8tZwo@S4b3A6mLe>tXUwRFJx34wJ!I+%g5_BW0W4P|WW{_Xf||6t}tZ>4Nr;eDIh za00f(doLyr^?Rs`TDE-h6j3GPA8B{(f5abq7MNoX90;DPZoJUerU39D08{bjeKN=I zg9`r;9}Tlu2Fr=0AlO7ucQ>N%ID}H5fxykK$4TyUC4VgD2y*I~^ixnVJ#pIw_IGBej^7az zyr=&N+#>mCG=?oXy^WY<_v%vjEtos@xF_VX4@^6Fnn{}=M-rl82Eb+JZ);7P@sxfU z&6{69KI=I5G(gg}my6@Xqj;Rad;4BDRz2^zKC2BFCm7x)gQ2J%Tw~%m9-pTGQ&6z3 zS@^y&3KnIOoybm#n!}tmpg<8NEGPn0W|eDefr5(umYRi8Qvh40>udUQb$-Mq|c4;69{$I(Is&Ihf;Z$PR-^VFibdpl2?h*EVzJlr9%o5g(r; z@PX1ym(}W8p0j{ky=}r`OWZ7bJ>zbNSME6MkQ@;=6bmG6I6Rtg;3FfT{Uo!U4M831 z)axpJXJW@KUP?Cp?)H+!JSk22%-N(rPOqny*zueH^XEYUj+ti~`kriepHX)>LM7IL z4#v{vHZ`QPdc^9LrfEL6_4#~S{cDtp3ey%L2`|lKFx9FUoBrAU@x;zrZ}exCD!s}! zIHoRQXR-C`grR>d&k?`8 z8>O&#jy=)GG9|zLb6#Ee$uV<$r&RM@GO@hmY$&uJ9bhr-=<-`G`+MkYMPv$CmsGg1 zjy7FpF|)p1=E`33{7spC=hj=?KQ)Gd26kq^kQR|J#dPCZw~M>N;U@J)|7eKz8)fR< zTP>hCt%Q7vM#TckzS1f+h4*;F#(h;=F17sBK4r%A0|cxi?`-r{9ZclNnkbSLp_efp zLNNQ72o5Xi+w#sB?+E?;+wHSur6E1(*Y{A5<@q1Z`62-1GmR5urk66k`A75^Eb%B0 zPo=J}l9_2VyQF#Eoq&X+g6t;?%Qz1Vz#0ZxB9Ip@<`pR#RLqh(hYP3iW$z!Ur3k7H zZj;%#0s5g5)I{(M>+9T@XQPuqSklv=TFuz>)KCwCn+9E|#ou1J7?Xs@kPDrvgG;30 zc-bVm;7U=an(fqKj0*D>{71C1j{c(V;(;Px7bQPJWtYR_D8Bge=19Tcz-rN4neYR% zDDeXUNw=l;via5*Sw*YPN>FFrdW6kqV_(!c3n|_A*FQB+ndP;M32(mNY>Fn(BpydS zvG7J;YH3^n*EkV+wOB(wCc{c%5RsNzaxwF;=l;g*x3i(` zENO)b2SbF~`Bac$i-Ix&FsIEh>SX*dGz`YHF-uJ?m1e7UnU>jo&;`cYmNTRV5~p&l zP=ij?yVUH2B@BLDv8?TGD%l)N1Iyg{6UeET-+d?JfU@jL(~0LoHfxSvH}Ikz8?7}9 z#xs-o;2AKxcpu{?TM+m~wz&XY@lwh={^YSfeINb_tT%H-PAilHBm~`WC@tDn?6S_P zaOW@q(QthjM&mKn;J<~Rs?j{Ot}!VNji8FpYgwT-8n-H{PMV{(5iGFEupHU1M_nZg z@8w#Tji$8I34^LhV=0X+_6#P}Cnw{5QF|hEpsk9rUIV*9HSI*-1-UB=*}7YO9}0?Q z)zvFHa_GB6@0^V5y^@8QR;SvFk4w4%XIrWAMEgErld?uTLf$_Rj;gMDM;E#N(OZc5OV=Ur z$reQBKFschezDu{#5g*Dn$0{4{$GhJ!Yj%Bf;i!!9E~x?HpjFUVEG|nbMH+6vma?I z6LpCUKOKy{I=DUSyIJ>)Thn#3RFc^Bqy^qvx#vkWC$nZ9t$%s+h)*N|N>kwAYJ6)) zNK@@0nD~dL9>K1E=-We<{e)hJ4MT=)9gJa&OIV5!^=pSq`!LB;e zr%s3?&eaiSDR7E!r}}XjqCofzfnBJ?96DzRd!>HPbFm~bn;5AB>%;fngMBQ_Y`;x# zyPuDQ&wBt%Xw1|cT*`JvfKLipBEw94X+C>h#xRjm37zZvfV)w(7_3sjQ&`+T;gj{U z1kXB{io7Lyi+3_LI`4~PR$L6YR|nSRz71kqD4*Y80>dVTKkH3TII?i5>DNBQT5U5t zwaV0d)gowqSA&bj>XA)JVszk2WrvwNsMNZka61yDiFNuUYyYf>#3Ml2#fO;_h+BQX zt5cuuPuKx4eDhTZlb;G#&r2zlI(Eci) zBRNee{;5N6?RQ-a;LvH_M2E=YuE`WO8Rl_S%l;|9U7R-WB;K|d74z0LA)O5`&|gtt zWZxOofnhhS8nY#KHa_Pqv-HC)!2EKc3lzd5_$iyvG%Sajto*?SgS8B}9#5Le&di*z z>a93mx$9*ysCp&Vn_re{X8Nlyf=_C0sKuUVu4?q11ny+nl0LX$y7+oe_kT<`AUq1x zSBtHAdt+asgHmBfruTehDwMFx~G6-6qs)oV-l=LUS=Tlg!sl8 zTP(fxU>hAU)qh|aWz(gn0wPvwGe738G>YxM+Sz;M3WslPYl0T5SLw19ty|*!f0+8} zs4BnbYo$A+q`SMjq(!dJ)Q1WK!N3o)_XjglYWyUA#E%Rm6v0oC# z9o!1Ax$o;vd(MK_G>`AY`b3I+8u?bAZbuW(gE)tO4FZVb+ci)tkn%BG)2zx$MTJuw zf+4UQSx$_8A&X(L70359TMZ!0UP4$jbB&1R(E--n>W-r@;LvI+zN8`5c3~0kzZv|3 z_FNIR$?TP2hVADZ>@A>K1LnzBnw<{#2LcE|#Tg4=!!9nAEA(W2x6X^lHs3!sjsoE@ zT|;TF5lFIEq>VH;p%he@epLwl?uoQ|{R&B0EEt+*+Fh3jH?FGB{Bas)RanmmRmPhq zdO3<^Ew%%gyuEMAw-o(IUaRVnBIgdKVO%vz`gbj#4X=QLimdINt>t(tVDv7hO2dmt z79)Q7wbuFCAh3_HRf#7HO&O7RKme0;zH|hNXD7(JWPLBge?fTy-qbpba$bn)x=QR} zh-SMJ!}sI}&I>v{XqM41NW1$PDfHreJxrvYnI7(HlBZ*v*`mR8JOb-n<@OUS3u|m_ zId?U9Eg=5jkI3}`6Avmv;Z+d=TRu6lU197Sxb4;DuaEwR6^9So^#IbQ_IpC>r)$_3 z4lX?O2PXe;qKQTE%#Nvd--iW?%FQQ)m(`1e2G%?k$$yW&+mNi zwjpK?YK#7%4P+Ka=AbadO8OPFq$(AnZs$Fg)jl>1fE@6#^7;t9Evv=aUEzJ;9(k#S zN_5`0?zSwX5UBp-a6oTleaW%Puae|K)N3F%_GJ%|C%7!p75Gw;XmiMmjBlZTN$?cd zmw+>oUY#4B`zGE`6pPp=ME>Y2-uM-->mKLy`7p|4&CV?n_$!Qrn2179e9Y+CNcb>* z-fbM3-TGhF#8>2fMoa0Q`Pi}S&6%U&>vZyLM8XR3$k*T!>?rEw;CxM(?LcARg#qdmI6 zJtX0~;P4neQ9=a~cR_5ywsPBLUTnK!M@CjljU!gnV{2WmR!RWngVV>n#&4pJb$O}Z zQq(c!pJA?f>jjY+_oRn*Sy@AQzR)wJ<9Cp+Yv3^;ZB2z&g2-O-Wc ziu50{<^V~fMh4y%vmOL};nV&x?7Mc*H&|J8GjWs0vL|Y*`|6bEe~QUFVYj0e|HDjC z$!GX-w!D9b;f&e#X`bye4WJV2yRr}Nh!rpX_WC^59OsUImMG{~h+j|`>|B8~=I_F7 z`g`yVYNkP3i=nv9^A^y!`{G{muFNjgPwT&#qR0LK_aTk`_DP3@#2W+Swc*e2+;RU| z5kFiJ{575?=)N!p893=%{UA#HQKY}w_bo~tx##E~va7*hJd}Ur>UUHTH}7fhXS;2%_ zj#PaB$8V)AB|n~tNy)I_v?tJJl2>a|&v-cS%b0eJW(b$FaRjJsnNX};6hct9ZTdC(p*UU*wyliDr_(~awN5NC_%_q*trJGXZ)iL=tt#+M@6$pEDWV>o_m;n zkh&lBb^wb9t+x=aUU^tFA2azAwnzDhgN9uameKSY@Y;Wthq_(!>yaCotn2mxN@W{6 zX7Fg~;;(4()!Al0S$rmG%s4M_G*Ri>gP~0Q023u;X5k?Q@7^g}#TDZ_P^L}vy!-G`hzTaOrkyEZe7w>2D4}lmg2QKowe$@>QYdYJ6fU#jm$jj}Z(pv~9FA8% z0X?rCe4sAULro*-ya$W}V0TkEPEh&z_`Mwvc2aln}*x1HzrJn$&*Om3b`WxLOs$ zT`+FH_NJ`UzJbd1a`S=uOWh4IiR6J~d;)_fS;k0E83w>P^VkPWk_gyGcy6X^v5~lA z#RNQ7NaovMz+*^0cHEZ?&6&y>nY;`NlCn#6;h#g=*u^t;*C3@ANU`w zDO>ad`tBK^jyM2y#OY3WoB}08@JYJyPyMFoEBPoepf4AfQV8#W&dw_vNO;1l^E2L@ z#$4m$IBFgKH=udsE7lbTS{{I9E8u_<3|&g_d=*|l5y$&%h~t>slI`ER*F#7D?sau3 z7NN4uSQG*&$W}zH@I!C0!Wy8yif1B+?9^Ke4~bTPel6%sv}VUwY&PE`ujP3B?NcCH z#+zUld-tpOjNNr}saYP#gBhhr6%o3<$Dp|o?&?*z&Bxej`d@wl(df))7FRs~yjLFZ zUXg|H^PfRE%WxJ9IJ%#s!LJDXJ-AKbQMO-Ur348fGL#ZG4x!f_?-KJtg>668wT z+O_=rXZ7)|gvq#Mxz76{V_)v*cG2tMei4bdc^$N=RoZmZqGtiN@Q3qG7T^RVJb)6) zw`W;v{b60?tKgJp29{&@W*t2MyDn>L*uG^-zub0WAVkqw3|wAtto4$8kW?cr$8%y4w|5k-`Zwq5Gq9 zC{$mSh(RMhZX@=pI-6j7vq+#afDkp2>UWUMlbcZ=CUdVMZOIn7aJqrjpG2H zuahb*T^LFX}ldTgL=;C}^fCos~&Q5HLmhN0dMqF)}n`Q=T`c?dt^vXq9bFPM4& zNn#Z1zI|F7(TjfpSh^qTjtH@qH3!0g5?0OP@xJ{mjdjK55$DRo?z18HT{iIp$1^j) z;RysoKr#8dVWsi+r5mGK*+;uu0GO@RpoVa+3C|*-{bL{dx?zLyGQen2CS2x3{7w(! z8)=|9<57Z5kXbdee~(#0@#AjxqPybiENTkc&zY+3HfvEoU3Gs5m&<@|;B6kZ4~S_T1#~?k$^}SulkEuQ#wb z3Qn|Pni7(p^==yZ;a_b-woc{Q)`1>Spn9%hj8RG&HblBW+^6|$$rl_i7-WP75GjsiAr`2mBrL z08IW^?~BwGxsuuhrj?vRBwtw_yrqW4b11A8jej6a-Ji>4VcPFD*sag*+za|&7_Wkp z%YPg==+8t;z z{+l&8_L`)wsC?7@gLRO6)j`Ye2VFwY==nH3mDr~I6JrYzYx6kL)}r2TFy?Iwvr@GUW-61N$lc4!5QW^b^ zrNiykL|@I+lT5>A6N1yrXT@&x6$2WK@2-b5p7z$#R*15ik*EoN9E&Lva8>94YN*{P zD9^UMLLVQyKug)qE~aa>#QNBB`v}v=$u7hU^B-}K#&c6b`=~4KcfS7_)CArm(71q* z@};F|rKAZu^_8!gnAI@vu6$G4d4DbqD!jBb_tQJRME^Y=%n?RGyrcGc|9MPHF0>we z5{kd(Q(lS;?g2p6tdFHza5m(lfPE!anbQ>Xn$GIl)Q10P>58?kJ%sYewl0&1WS{j{ z-LSrfv3+8!cvGDBuDXhaLl>X>n(!SVriOXx&-m z|2F{OmjmW5&C-OfdRKBbdxln2ddx?JSEHU|8J^Ehk#Hj!S;e?zeg|X)*leR*a?+%X-r$1|+`Cc!tN{nr_?4QhK2EjNVW{kPGO|GZ&Af9Simsfo~c=YRRX$CqBz4&h9# zp9H<;hT@bA@)xm!*)LoV!e#ZeOWv5UzXzy$Oy%EgQ1R=gW;L-bwR$GL%(?47pV zF?#t|U3LMjAn?crlOJ%ugMObQgz{H-h5ur-(76nQF(^-oGv482j|%J;R50Uj-iSAD z)0@@ru~ZRw21dH}Gd=dz4b+`~yEzPUfC;&VB2^$vVh0C-L2>Jw_FLFxk*8+B6s-!6 zd-_6=n#)3llZXLf#xhLgB>C+Ry!v`m&xFJbah!D?pfHiKVjx>%d{=PirY#DtKAOvZ z_XO>N=G56%91id*ES!k7N}r6x&OD9Yt%x>nCm4-7qv>XsX3CV-L24RI*3=tN!6-WI zAA^Jtk_#7*mPSg0;cn=3!K2e)b7i4m4j-o~@YW^ky>Ec_$%jueH0l{K%_5Rxl(7jx zIFp_;(Gd*&oR3$hD?Dl+y=Edt>_YyuXoNlY%ZO+Fm?6Bv9i7GT+Z#UXTk1+2(F<*v zVE%(atByAvUspEhFoH{=R5>FMvaDUjY-Veercr!UhFfeGuB2XmEAab%|12_{X9tGq+;T&GHdFuin7ZHP}*$4 zoX*EOZtN6DIkoDU7?mbv7V6?f`_8ITsA>QDPMkvN=uVmRyM zj|T|Yp_%^SYfZg)NaMF%+az}LuePS+AFdp}CvhRxwy$N5=r0L1%E#FxE!UWPva;Q{ zi_a}1k3=I3v`SeRTu}MpLNGW_)@F6W%A(hLgvu+)8ws@L^D`eE2-en%lY5KLORODA zX_cS19F^%0`f*O>o-)6M{V<{X#9J+!T=RKqP1V+NCu+y7-5ne=!u==MMw*Mql7nIc~n z$Nd>?YYImsJJox?#15PQ8BJ>hLf=ObE3H{9%}{qnFrgLH_M{{eIm;3JlCTm=lu$tY zhZF>(4FP#u%~L2hX+RQ3RufC*NmsNGd_DB0&(+slod&KM3v{AnnE8q?WoxdLxg$RI zN*^sqpyZLN8&?l#;=E{!P6Y?L+Bgeu1&cT=yV_%S7{)Afypkr4N>crm%)j51gFO(% zrhZAe4u;&MPGOa~4QC6eXte$e2*4^rF7Z8ub0%P)sLpYDKM_=+`MNq1aRl3$gMwa#(p)mOjG7xSe z00IiQN;wsQT$8Y0jg2@ClUfKGhn+cOUZ%&YqHRXM65uf0i3&jO2x8)G&R%r1cxE9g zW%)g!W1JU{{MaHqkwPx*0dl?sT^wS%xZJk+<0Q@fVA)&1(+r!P@z-9E_jAl1Z>a3- zPh@b)Y}rP*H5|Xf_)QeU_`~cVLv!pVFM7hBi-~8pFYnLj*DwCe{6x{_E*v_gPrR*s zrtS!9I`(t8E_uz8({gk^H!CGrLJ+I(d@jvL%V`sBF2(tku#|q2wY6})J+k2)#jn6@ z6D%8q*etd&f3|HTLtC2I=qysp!qBIzShPx5GD@-eBsAea6bNeBwH?cTLHNsvQ8y+= zx2l~2#x0&lXVJ~S9K5+g8-_~5$1H=>IJYjWQJEf5-mz9T$Mfaowc~yMF;sDG;nMWu z#qh5=;S{Y4Yhrh^T^KjSYNvbm*G(wj@{vuS2N*vOkP}sJ%AQ3gf}>fRL)Ckrb6TW8YT%!(; ziw1!O7(f3d_+$yn-KPr%CE)v4t7<3Y{++dqGKom*_xjb%4s3Q7pCLI0B}Ck*A9cBW zFg<^cx|VnbNYsd#3lv_?DZ6%!E336thGzlAv@;xcCuy3Y?lC~=d=OIAxrV)S?-_p( zX}ZbKinyCu`-alR27Z$KWIo?t%|t_fqujL1#nw3PCAHdo-cvN7Ik!RK7GNSmLDR2} z$YPOsy8FEZ1#B{8dsKaiPBYZ%{d(bw>3F82)8_fy509Gbv#X1pjfY0B9Y}#9r{&Jm z*6Dq}zwdNI?L3v*&NDJ!chG+Sz>_P^~QiK zj?)6Ibt9hhi450=s7Xd%8({1bh6UkOwiLR}ThNTv|zP^EUX8=Vr`- zm5&Q{@osbD&BKSSEo&05xfFMN0=#*?jnZ)hirnJ<4yZdy2}9Cj-RE$N9ni)G$oNdA zHTIM5jmLBK&v`_2Ja6uIMzK%1jO0}{nOTJmDsHX!e?F-xFa-r38 zhbVz%I(^p7itvwrHNEP4y&I*@DlyR~b?@-H2a4;@06P4JRuUKFR;_<8!Fnw4Fpm^n)!RBh6@$LMQI5sNvlz_fSZ<*FUGjzmN8ZVx6c z=N10k0AnwhSIqY}v!oBaUMA<$OW@u;TCvW+(kLx@S2cjKJNWs61rcX$sF;vdGVTSbfi9em%{(dX97_24{chJ`*(zK^c4T60!FM7^5> zb-GFm1~IMr?xxwx{7v(z57dM;nYhMQEEqVXgO~4g?M(q^Mo|uMO2E#XL!|pSuDDs?x3N4cuLH+o+j&JXxhZ&D1O@;F_;rL9U6B&gb25l z>Sz2lU>PWZxT%H@z))yVA&d69g1;C(e0Z+nKaW{0_K;Z!+w@9U0)4ce8I>79@NDQA zTO4fi;|CK-iG0PJ(TAq%OXMgoh1U;Fzeu)H_E5&}0UOzO3`{tqTrJ{ZFBB|H%1J-kwM+G1_K9rO+l zZrz68^!rk*4=TTfZm_v4atw#tRBl!%^A%FsOiyQZB1S|+H^(&linV);4@c!(7!Iu` z3x9`T{Ls1kO>-y|gqo(`M8fCN*<#unkp>@x({w?!G~Md#)5*;(b7ZmXTzWhi9F^WG zvvZ-Oy-=I!`050r(_pvjk0Bt8X;5qp4YMjbPSjJUfbQ2^dpzL>f%Uix2sydf6+tmK z(adZzBW|%!9B%$uYbR){wzEJx!AIEeB=qnBjYtTy0gn7^r(wd7;)SO$K23b->$eoXlm%>(H&DvK<7;u|@52@naelr8|Jt-R@FE zsBM5Gc!@KOCV3;pClg(txM_1=#zG(Epw;&ut7QE0}>f)7XMVC`S->7t5BSs>5t9!XZc{a;@>iI zVegW~MkOZFC9INm6VCG*m0E?OdkH#`OlBZ3U#*~)=lzZ`F(MOOyiz8KRJ2N$g#0 z7EMCw&@wobWbZOtrK)Pf3**L`RhH#OOdo{=mIc`w7m}?lN9#q5{RL)O4kWI0YNDPZ z*Uxk0S&2RGrF_`A(jr3J7v0eczE0b?kBgW?Msx!X`yz9?#k202^_wT!Yc?Y(5jxUA zmBQuZSK7&D#ZSB8N~rJ$*n$wPuzfTGb_S{XqK)ev4U`vA8(U-VV5YZue>RlNydNAC z)M$yzN}>FLJmDylPJzl-OYs*c3PvEV|9%c@ghcv|L-_I7(tR8O$o8A|_)ak;9aPA0_q6v}2Z zxmBv5RK&9y<#{jEU2Bc?m2|fz9?R<~$z)q5HBLr3>QN2mw9^!04%cbO{a=iy zk`J$tr(0Z(7$8{$F)Eq90ScJ%7yE)CQ7UiD#+j&|*Qm$1-FCYUm+RK;``GqZAq=D} zC+~@xjv$>SySzrmWydx$*Y-I-xM|~x2$>}xOcZgtCCZ;EU&t?i5nGWt92~oe35npv zu9?N1$YSg~6~t2(_j(f;aV>>%(lFXUCW)5#etmSTbC5qW=`CS{0m<(hRbB7fbfuXN zge)C#9OOQhHi;I2^PC})uK_{0hg{`)?Zk6JDamDYM64C^jH2uN^Gz4M0y}Aw4HuG) z^76~TDfN>gah*r5nG_Y`2lWqjK^UKu*eu&Ux#?q6TH#}te65YO8TO{=;sJ*Z!J*>m zoKRB|pUZmTR4VYnJ|jb9A+C4V;kRQ1cub?qlx{xh0-o$Iq69iQ)>hZpZI%mL%}RjR zprWQW#vE5{h9jv~ErUWTSr&~h73;c92PTY_snaS}p=~*O0UBszU&cUE6BAu_rxzdA z%iWGvx&1P<6I zdGW%efg~VO{gfqi*_b-xU~3Pwe0X9$C-G{Q+^!Iig+=Dd@r4yQQC+AnEA4)n{0x@+ zMds#^KK(qcB?S%cR#<>^CV5?Mfy5^oUhDCvSXEUOX9{pnr`|yh_Bapm)Iz~U3(B<^ zYMi6~HaSz8S}NvzM)*nWl6Tk&rm4`CxR}FlicQd~Q|MJBGFk`M2Jp__=EUP^UH~5Xe1;wFUst713Ws(w?XuW zes6IsNlwG3$Xq87>b<#-m%ZUZ4|M79J_+$JW%_?Iqc2i1y0(11?~vu#T9X6OFy<%? z6?s9wDb=%Q5G{y(b#-+x?t^yuiyT*E&2OX$V$NHW58t~Agyt{m>Mf6Meu-8(boXx~ z6lnb%-`I8B{bH=pGGDEd8GXIC06|Hz@oRR!e6!h~N{R6WGCZJ$Hvk?%SuPgl*WEup z?=MoVkaj_Y zFZ(U)&|<_O8T{^u=AS09`{LuLLtM&#Zbe5&#>B*cX@YV6^^J6CsW;KHuUSXcLz6|g zXps>eUbcUr8|{n8=#rYPw4=S38A|7KCDD4KO2If~d8#KT9ckxq9vyc8P00H@y8`}l zWjvU51_o^OoERi}lPV`Z44q`^llP3t_4OHoF7_9cp%_C_;kjdc+`PG}TW5xF?7vE`u@&bS}#Fdq-?`NH^H!lJhYg)33k z5FCVSui||&sNllpK+&m*){hr$Zf{$~v}&v(XwAQ7B2q|BAouygM{W-3$$9S7yAN>8A1Eq>MD<3Wf%Y2`ccyC-WPb4_swK<<&4ca}ajme%H9 z@<;j?wq)Ilr{!V85GZjhEh`u|M`;^v*Z;o3lvN<`DmyO^$&}8Fe&TqJ&*yw!+fdZ1Nb$28zT7nF+t4OpB< zJ2XSsE8=aYXab+!(-N(K2)8H*S5P%hl;8($dVZgP9EnC&!!PU~r#aR)-{y#^^}OA= zmAO{VWW1Z_qM&%DGT5uo+zS^-MiQllyCp6`vuYYeo9BM_NHeg4IGP4P^C9?>D`81*Qu!&hJTI^>y!jCsaq);E%QXkei{T)gT?#ptB` zW%u^u0N={b?2lj2j6Qkhou^g4RH|GQj8Llg9rZ(=HPiR5vx7x*uA*Sm7N+D7Ql`g&`@?2623WVva$iVdz;Otw)XDQ_7FLWXL-NxSXt%nT%x(en6^c*_cZWeNO!Q8m zaE^YyEs%&y{*D!&7VYX5LgS^PwLa|n3-cjG#lMH?S{0|Ja2ei~2S(0kD; z>2J-tJ}Fac)V9fGpZrFDzdH4zSA8$*wZAmz4tU0FP4<*h<~w9G(y13nO#A7+qCD5B zaU!(8TB98j%vR@~Kya8na4&q%rP3-bUxfwkPH!LV^`;Q6`Ze-5AUHy||LdF>-eM>Y zi}t(PjoXel{tJ|~Zm+_P3^Nzea54AVgHRoWDz~&a7`JXZ35Bp>y>qKh39b8x!{AuF z(Ncp<@nL~7dWcV?+c`k_#C#9|EUFAM*kXt?h&nq%I$d}+Eq2OCw}Ln4q&X4gU7b#9 zdP+aYbc*&l-p-pyI-fG@bw@pV7FV+Okjum1{&cw_+%31W1@e_AvYCCbfn?`Fkq~+A zh(cb0Zo(7X(*0FIm|#AG0f4t1L2xN0i>HnigG?lAbD8hI%oPVA3D{g$JA1e@QF^8A zSo6M!Y-SNNJOy21jXwxFRLfPi4S;l9x!L0iZ_8Iwn7aUzXIoIzh95c2?{Fw#TsP3$-x#6TLl z+*a%-UsL63U|}Gfuwp%CR%w_d-T7Cjw6V)ZG5MEL5#SNQ*0f$OkuLrRH?sLKMKloF7ekxFWY%~;&TT{s>d^H9=;wW zr&Wq^W{3xmJ5?@$P*nsCdb(m_U`R!cwo!U!-eJ9QW2?(+yP81B`N#15PNTM zDrNFbKp__g51(HP{s%ixcO{}Um?rf(xXEiM`Ohp12v@x@h+L}_JCGGL@^xtKmmJ<( zP@Um9HDa90$*0&`47d@pS6hVC@f|@GvOIOKEt|w8lyvCbvT}(lP^)XWI&F?ro#MKS`xJo16YI&QteHgA7#ynYce33r*Rd zjf@nFe9kY-&aSeoYEWr)hO0C6r72T+Ot-o!bk*QK$26}ULaXrYy6NnP(I&`(>ueTw zE{6WFYq0^q>;=ZfGaq7`cZO4(CvoDCvCHu>F~6Ao<|)3gUY=##nLF|G>mJldadq6g z;9_V}`kHv_Z{-axNpKUs>kv~>-xf#<-CSVROIHuyannAuA6N?P-*1t+=qXH+>ro^@0H<@FcM~<4}@D=mu zVF%9>Z=KPC(|FZm_$Q5JklG2$9ejjRN%_PfF8l+JjA~Pu=jqmVi57dzxZLV!CjEK% z{O(L2B(PaRe;n={SvuY0!DlT9{`8$0#>;q!4cnVv)ya;!yLd(PYHj4J!UrtAs@H}9 z%HG-BzV^HX+SC@zug3wRvFaR_pYB>HApIbuumyZ6{02!WCsbE}Qcvq>AcS8Q8<**I z$-POc;Duirb)#+bYeoTZW9wFD>qQ<=DMU!gp|0|c8b!8Fy| z=rtN>F_d;;1aW=>{R>_!qOgOn6e!vcyi2 z_MGu{bvWr&{vFOA^Ys<0&~NyCv__{;g!3$Z$y55nk6PrUv8i|U#VK&Q3$=+tj$`S< zfnZif#BSW<&YRfghZ{LkZ-Dzf-=`pvv=BKCUYUqg5$F9k&J*MFuujK28bd#`Vg+2$ zh1^Hj4u^kSl|UwUxva;<@7dnP)l)H!2KiSQG#452D_kzYUIQ=ArsC*@VosL9I=)2@Ya1Pkyq!qg|Nwdp1-o)cqF1LGEw(jJ33A8 zF`uIDxB_%POSLLNz)&4}fN>@eNB`2YGM<%6$A^a-QsZ_^Wz{n{e0?_Icc7AKx%FBo z^;&9~wM*ueysRvfVO5pam^qsu74~O3Ktpha{=mx)<^QOq8W1JzBDN>#Y2`@{$g6&c z)|0rU|E?_N+1K9Fm7xZx21u9kOGV{m#szaT#NgXo>9NEJ1Dh4(IH$xxECjQmmu>Y(LsOmD0J5 zU)Sd=#)%&eZY|LMg0ou=2p3%G-ccua*fh~FeS$^APOj6i^m0R!Cp5UeOy~xrkIjpiZo5MN(3qw10`=nb)WJ$t**Pu zB5E!G_VaQb62fUn(uDXc{3O`zyhSf^rC;jjs5Y?qLN92qoT+=w%^KV70h5TUGO6nZ_tk(3u#! z%!T}uOSx{N(~ai|>4&9m;ZQI3^DYt>0zrqk@MV^*v1b_$RS1FF)eg|Z6h*!1(eFCD z0&ZSxN`x9caS5{%oOB=IBjUHe?({DK9AKD>uB{p!k(cf9q`&;Y)3N*Z<%>*=ld}AYDOMIiR z3#nm?i+}u4a;z!1uZ_7YD-G0A7WFpEk)dJca9@bmv#Il#2ZN#~DhwmP2DtQMK##KR zX-?_eu6+tn=*X%LwK!>;3D!qj= zR3G;)`Gu@m4SRn+3+^a9V)gU?-5VO_3nZ1y0F;;|T4`)a{K}2#90dRa|dDm2JxChsYwwE2}wFMC%7snQW8X&il8qZja66{659p73w{6o88>O2@it z^J=s;lce@kR0aDJ-_EaLLF^z&Q`u2dIoAu!zzI<49LGjNXXY+)Ye!yNFgD%@ ztC@^IEBxqJLH)`p%8Qa6s2MUs@>imR-bbyEQsjOPDvR3RX?PJA!cD87L-)e%0zW(o z+(Y@#H8M!yRJ4~3{D5?duI_BFg37bn<=qn_qjA-a%wNi^_#o4d-!H z7;qN>i;Y^Pm&*1`w4Yk_bz+h=liszSPcH;M$(u+FDeFLkiRf(C1B*_S)4V*(hqzqm z9{F`F`w;b@9@(1DqHD7&4oG9f`a&gc-kdJMj!-=Ci8 zS`pCEr~B#1lIK@Jp@r*M{O}7tS=MP&$u!P5{SdNfMzlqV?)lZHepRRkp7b^EP^ZP2 z-k3y5n{pieC**pE-w{%cP%b|)5fw_iWM-`xMsR5G=NeJETkq>A-d{qFTk(+d= z{8Vj(I(-G9SPOpzTgTC-q-up|0(DR{%NdNPk=iqOYWS0+4DiVloMtSsaY&riF(rMD z(&{zt7a3rCqg5$cWrj)9lASx4?8UWF8=kpRRo@;UP(77Jz<~bw=?xUG+suCX01~y9x0Tif>T^c=mwwRXj=4{r zxu6z{9nps1`C>gyDchsDc^o0aDfj}c`Bs~D@f80fQ1)CsUbh>2ez*YehB%#RcB zNx-?u$}uG>jFs6rB--9yFocfd4ZrqAo2v>I*xMy6YgYa!484X-qsy_&bT zj$UF*R^tq4Hvjk>h4Z37I`i$DRI%5Mwm9V3 z$HFG)Is>QU6QA-Nlv^P2lW_Xig<@+MQOvVnE@zCEF!)T>>ExAWlu;2=FSX&Px|p&# zhOL&7rG|HZKIAQFZFNYLzp6poh>+a40X4d}Z}!oO0!j%Re2xdQkpguypH}N767yXy zLWlLe;z8o7UbaV~6hb)M8Z0jKwtor!1Y#&*i6%uK6H~t8g9pT4ut0`|hPyR10-$7c zRFGsc1#7XP&vUihYpCwmV`Hq@>B3sP#5vv7&ZJ!z!}d4?D&+eB9%Q|g5@iZ5I(?@` z$GVQlsC-3-$c4x_TQ#A~K`wHj8jl|0&kc14xbVyX^%B$Ry^U(sul1l-r4UIp3JXdj z7#vnHQW+38xlBvgXUS`{ONa`91{vyf$yG;oc)1wvDM7~Gc+aIkJ&K{w`1x|@rPdP? zTaZya8Hv@&^~5f zIs*x^(mo{YJNhBBBQ=qw#q;t-#5#P({E5;Kdj#jKJS(5xs|mSr3QuB-b$=i%X)xQy z+@C1yMU+=Q81~vkRrQ(V@@Ss4$E_hmf5LhkTxM&Wm7SSD4yj3BQ{)pc!_4s>^$Mfp@4A|8v}BNHG!|4w*GS?}WmgrVdYMIBRCDi9?GRpDH41v# zOG=8b5cuH6H+RpiT4q_vZo~hr^Qft1sbWc9cUnJjqzEwe24Xrp%sXes-(`I;IIWrl zeCEWGrC7C43Eb|hE)e4H#E1(eF=!+c;Ar#vFWx}e>^1RG`$)eeC4F#$bV^Ftz{mwJ zW5R}pIIH6j>UB&SwtKy~oF~MT%X_~~K59AjUbu|RIqNz(M%6wT1x{a%$|vsSiq+3) zPzu5?`Sh|hMZk^p`h;paE*>dOov|S)mN(Z2id;keqdI~EKq{trFAMnsIW`C>sC18T z!o}2}J;>vLQfxMde0DTMl~fn|R}rkhBMHI~Jc+<%9Z3}Ql9UsT#Na8P64fn*-!V#i zc!vDyr}mw*XwM!j_p^Bgq4gcx*VL~W*%zu#ygzQTfTEJL;sFA7!aDi^l;!Ye1TIg7 z7xjK-RSrinXB7rLWEdtdm%A|of2Q@+U=!XXyHP5j`vla5Vl+Vx_)yPskbgF-*S*qT z;pT=y2?~({@;Z4s2e2p6F)7vP^f@-*MG5Ns)p&!J$sQjdZu3oIbp~Q=b z%hDq_?6*wWhGKrEuveBSdpWKNa*ISg&as<@nl7voWE6yA+a5PsIDlqh3pR^&e#NjBP8S&2U! z1peo@-JD(X0AG>RPZ_t&F14pNJp$?v8Zhsd?ZhBFG>$Jaqn%cRMnzOiUe>*!_rD1e zQws=h)5kDfii3?2!M4~z3V0u$8|OC2;L}U|S%dqM6Rp|#!S&``*wJ~UfBfr%Ei&M| zy|kEste|C5e!Jqa%4L<=X2LYfq!FH0JOlUdMd!FB%xk6-u;TL@lAy+Xw`UwBrxD7& zLJ+H5WKX|$tI@F&hM7+#kq#Q$9c$oSfQ-(J zCb=l979mXS6L9yfX*}aJ;BR1d&4SR#WNT)73#^8RhN?MNKOM_Dp9zYWBu8Bp+=HR7 zEoER6D@HNSHj%feDxJ&*(t3ps7)ug$oZzZPkC4|bfC?C&FnYxk?=(Etw-<*iSk`AX zRXJWV=A?30>k2sX9Iaw%Ueb$>(35^cMdK9;N0D6%RBP?7zxJqaq<@?- zvW%M4&-p(lWLeR(K$Y`DMN`Iv{pK!;iEKjS(zw@Y@Nx&_U)@eUdCBgE=t~f=zNfXn zy!-uvw}f1d?9p zXxi%Pe)<);9L2$CQ?hc4?RnxV3JTMZ%~%|ZYa$<7QHwnDXB>jjBG1P>y9qW;A#E814Il&olJF+9v?;oF?=(pQI+wV2CkU zX>cx8Jk{14jx2};op4n;v@o`W%n~NG0xpR`6gWfS1MnCVVHhVI{0qGIVOuc2r^}kv z*FM2Cy}Am>76~gAYQ1dF^d#ij?`ERlg6zTj*ox2NOvx0G<{w#4aOtO3ygt98R_?M> zaOJE<%@!cqXW~#%XGU`6DgH{yIL0)TGS2`q?^AB?bEBEBlxLubGVI?|*f7iQXejYp z`EA}c#y-a}=Zj3tJmpgV8+jG@YOYC=G_TJrhBFNl3VM%RDCjQxTv@pob4Vj3l z-)O3e+`Ylw#ve8jP0WiuJw*D-Rf=51>hQXjGZvfJdS@$~k|JDvx70J|T-ZIbJ7Bih zgRKOyOy1p+n8z9qQcL#mv>;-uLyKiR#-Q(U|F5>Mj;eC& z_C}<;I|Kx!5eY#Ml#&wZl5P+YNvTI91wjx|q`Q$0ky1cHVpD=3Wgy)ledpHqyx+KY z+;5Eg$GvBqGsYQ5&fc5#thMHxzuNR?e%M8`NN1hU5l=jlnEf_msl{EYX*FNz*95YJautjH%}(k~+VgA8ukn6EO`< zQ?cshqciwb^C4$7F!gf#@vvBK0sr0Pea_;e3?~iS>ulRddaQ%atkOyO8J~zL6z5N| zC!;FFe3VWfKQ?{#lPl=9+exH&~ zZvvWvCl0lqp-)AoxFy+hHzJ(BR;l{S^C|4o^~)ZT1ZIxopD2xrN#DQSu#6;p^zXRN zne&*~lapa;z)8VGNwwPxr$=jP0*6@o62F?sSyKZ(^%_Na=ZTst0_?2naYRx^-Z%0G z-{ya~KEu|=E|2%yY<`7CQ1I5&mmJ=ct5Uv;Cl_7&#;}wM9QWVTe|!GzTjWKO(<{57 zG21lNRoHvX1&;#kFH1$$2?C;zkZ}&lH$4`^!&+3C@?L!Id^3tSv@?!{x>uIRTS|!c znl$zPeLbzkVh*hj?K&M>oh#uHqhmq!B&4a%U%h%~7C(#emj$Ri(;NTrFBQZ&fy2!? zy_*4TF=S@M&(HdM9|U5FLU)6sKKT54y5!?&?DmM!kzdmJw6DU$JCQR%T=U|}PZmXu z1dQ+HOG+T`8IA{Cuv4|v&j$6MrCsEexf@Quoin#UT%nk#uQr+X@mB`9eW>zA~h5+=wT4nzJ_yWYRVitwLt!~QR2^#VcD zO@Q0}C3}eew3mO>u5?^Bf8Rk(G1IU8W0Oj$Clqu*SP4m+)hFx5Mb~fdN#%ST;w4K0 zHYCX7&7H=#Qtm^eYo?DZ&gU@J-I_YP(FRw#+xRvoh~sk3#BJ#0iLByyY$!74n>0PJ zI*dLGBOS7&ZljG=x(UxQ|#ujFwyRHv+-a>q>)AU&3F%BmR_W%)`v%z1Pz~w zRk%Mu3d!`zP$cBQa;;H3to=^ke7PRUrfY4!r_{e{7?m7 z$H%5yHFAyNS3_s@yY=i1h8z0k%PTD{E&G#^#%!}Fi@~Is>AAM{4mrUa`oWx>oV_IY zA>lK#c^2wY4Vc?WD06XXpQsl1T9($>v!H2-7LFo{tD)kvq`ScjYCAQ+FN0WA57=lt zJQpJFOTGE@k`ZTsD)Pez&&V^vpI*4s!AO6}h&W(uwr(b7xMO`<@b5F=lFmRZ@Y>Uf zSo44yF{Cdqz9%iNhBA29l55Lr&!8~LiZ!yRPUanGMa zW}!+X0d+}8Z(hFv4-!2hZqE13^*jy$BK5v&fkx@4Qho%713;1gd388Lv@S0uQu(XqO=PKKCUrO9`-5 zR^!cU_G%m_I?qbf?=*N5M;;LWR;MJFBB!2ok?PMZ70o>Z(zE6Y9iNsjicaWeMPm>f~ zWB)*o?B5%!+XCfvrw=F!%B-1Ec0(QH-^1{i&>jki?pyyumR$~>evb|2#|k?u(hj>o z$6XXwf5oS5rsudYz^BGgd4-@)N_aE2_$WB#YaO}k$q{v;Z9=JBK?U)8K1avR$_!ql z<1%;Xr97_^pQx7!o(>CAb(;!s>WI#Ga_t*p%AF2Tg{ zH1b(T+^z_Z59oE-KV!r6m2F4iGl|T2Z*Y^&MaiCLKakcwdYU#>a`|5v&hS82Rp1>| zUedz5wL9hpQ(SniJ#mHI_|sU0FX(A$_nbuK7?B=!HYKLS(sXs;Ov8~i5>^TIs8U7= z_qOF}0(V{2ytX#^E8^7+0-ECjTwJ>VsyyFlO?%koQ}yIFE>9qh?iIaak>S$&Xqn$* zYmbp=8Pc?RZ|>w=UOmqi2M7auZ99T#0@_F*$AM*dZapLGj>m>NBxMToqvbW#Uw#eO zokeRNe4W8ZBp2B)e#R^l7Iu4>K$mbE_SB`xCb@I`1cVd(MOZ%IF-QopULt)jlt!GY zTqnHg!QPe=&#D`cHJvIGeS0}d6f~yFK2g(4qb`_-^gb(eqPASNpmWKemPoe^xp{Rv zQkeKJyfv)J?X{Eokdfx_r@yh4%g_Oty~fbf3B(}EkwV7rw>4~T*Ue$<;Ng;Aiqs*n zH8Vwv3`JjQv!TueeiuS_MwY*T3bGgg(Nh`T161YS&1xDaET0c;`xTiV$Vfcw+-UUr zKtymBM?L)u5dVsb*LZYm&h!>yClVBjhtMZFk|#={fQd;}Xnv#^NlnLPvahj<6pofE z9RW(x+n4Z`@7js;QjK&w!Hx9Ui{5=3V)(V6hXeakY8e`{*XfP}>E^cPVR$0d{Z4wQ z)h0LA)5WM^uBOKKG6IuOc_F{A^iU`pvtrW;g^FW8{eGcu$;-6Z+r%hJ#Zj2|?<(|X zn%|3T-Jdk2p4XA8jSqnDU(6`idP77>Y(k!kGgl8F7T@N!k3P{$6~N0L)E666__%~4 zoH^!nnAZm`W?DI-_!qDw&8{cGZKmBA4bRy1JsZ_EoAGbJPw`;JzM$BEN|67Kb3raJ z)Y{5t;G0L)%|U#3gRxb(hIGa|gvSwDfwy}iN6T&^O&?2Pu^@)S~nYvDLhoj-)ULHS&m&YEGMTX+2XQalGsYZ&0 zU^?y}{C3ru3$Qo*(_W|DY?P`Nn{%SpEgL$&WoT`WHf@L$av1`-fZyCDraS!B&dXP5 zIh)YYvpJBJ2NQ%&X;2Jo|4fH!*~0_k_U*N#Q!*z_}i}342Y* z~ zTm_g2TiEZ80H%6m(WsG3!IxY=oTL3b3Ny-NKNbgq<<=3S>z#@_>7rNO% z+b72t(H!pUe;mp_*P$)$J)aFi&+l*jkFGfW4tFS-*kz(++T3-nEwLAb2f=f01A^V)KQnqK<-MLj^xJToQ0-`E zRCLJi^FG1&okmZ}$|v1xAEWSqg)!Go$Z+Dn$R`$fMoHRN;BHHnzuBV1se_LD-DM{= zK9iaaT5ZBH{%`ibC07VG1WPoWv+Z3#i0ip3>_--?aXYKkW2x4P8OzemL*__K)YxKS zcp=h3Z-@}VjlOv(|AWM^85}#s6!zH!3Dk0Sm(=Ph#sum5lhpb3J!a5{V&c62MO3P3 zphyp6s%ZmNSiEEIjdcHdl8?pp@03ILDaa(In@{bxUN$buc3vWW!XCh@@BA?_W(FJ_ zGhI}(Lbgyj+d^Sjh-Q|_8}B144|{{BS`c@F^nipCr27Yib$41W=bB&e_YTd>f0zR5 zTK4FqTJvhJSJ!iG*La-a>fQrNpM2^QLWN&+bJJoB8io&fa_9a(8jY#9nVLgGqjtnCCR2WomF!_VYNA=}BJUk{`Cqb~Gk<{Kr_CEsSGiyy| z^Qv@=PP5GCv<1X)UqDRtl$eBEov57J^RZPFAqltUcpr>dFC*xfuD;T})+@Bo zy}zYT!pKO32mN*~iw4UxUKUNwhgE<8r=EgHgk9AT6y-D)k7=%#+Qe&I?x7w0SfYQ& zuR*S|FY&(oX1GKwa=bKY=MGBLkw~(K?0rfUs3y7eddaYK7{kq)Gr+H4Rn2-K*9NwZ zwNRh{$bLLDLicDMa1Bxi>Uz*N?1Hiy6WiW^lb=Dae#oBGJi%_fjCkv#3a*>r9`K$$TLZ&|gFmKK zbaa60;cla6s=vJRKr^-mCcAsxCphevz1(QHf*yrY#y$xUiGV%d=%4gREz6!ZiQ(Mm z`x{WVoswt{a)`F*sVf2wU$pRNoix}VeHccKWpMaYj#A|d^`O=5hk!9M z9H3TQ=H6$Qh&WcUESmDOvsc9J$!e6!I$C@m z^-sul$M6Fm7EGyEMAvQ_MAMOq{4yq2|x9metf~clp8^$X9N@ zsJ%B4CJOpzEVH&L=oTa17@xi*cw1sdGw)X^d-(AoQM?ns+XPdW-Nk@=h}8i;ry4yC zdX6jJS+oo7!9W{5v-zQ3M!T?~%-?^gCZsJHK~gSUrir)r1=W*bc&V*uLU}P!>UnzX z1JfUJn#Pen#SMNxadrr=>y+OgrmC>4g+`!&wc?F(s$?v-WGA+NxE=OYkFHOR>S$S` z8W&oNA4`I1Qh})X!!~2hb7>hHEuCh%8gOgKCj~3f@y?@AbxHHxqkUcxC({o#Mbj~0 zVFP=f!j&CLwLd6!dED2BMa&?>EvE?Zo9~aiw{papGg+SewG_Cq9eh8gAv3-^YD))+ zV)j->g3XRPH~x&=e>#C^m-8s7f3oQsNV1eh<3521<_hq}hQ*-{`ED?&UC_uB16N+- zyS($L=Yz3AB-c&kXGhz;&|<@cvD#CEb{-PPx@!7Ao>n&AJ8h%Yj=bRwnGT@&ti5&FPeC%kZ4k0(Y0S=-5v}V7fD~oVla@uUqg@@ma0X>}U zMEzc_A!Y{{i9ihy?$csN3|=Qh-zAeRhqyp-IzvVSq@6f{$B$k4?qss&ZHNdgA0Ix0 zUZv2i?dYHV$Qp>{Gks!(kN+gW`}evsbc+s}s4{Mm8(Qw$k>FT@j$2td9XeN~^Srz= z_It4SozKbr)sh=z-DASsKSA9(mO!sEcFo$AbG%tdF#Cm5XmfI3h20x0-K9TD?)V1N z&~jOk8H0O-IJj_@2{P3x(E_xKRk9XxN}1p))KXJD;0>*N=e=Rn&a;-}TBJ!Ra{Qfa zyOa;r0>htYK7FV(<@eRuXQ!hBUzD1|_y2&N^jhh_)X`ol4eHdVjHBVxq6|z7L^QQB z^DA(9I5g#^&kU7ihe2q8c7gK*jq4jw;~D44dYvVS1B_a^Hj*B`aGCN?^NblN9jxU0{=WU-CY-U1 zh{RcD^tA{Z3pzl(roIylfXi2>V|Ff4v@lXQK~^KRetsr{5>Sx=K})*OE)fGGkr$8lw_c5Xh0=Vq39`^dS>vl- z#cIC!&J%iCii!x<7Oq`d=Gk`w;dl zL9w!c++N7BN6oO{Sswl&!ganV2ABbZq!*A%{5Rdd9#3eYsc__+EUxg^lPee9w!teRxLG&gvwh^}cfa!w9DOFA0wWOEq*a)gGxIV_S%U zSZRNvCQ)y_JMeb0;b9no4SMjXT>9*iXEl(+f!+Ugrf;gr-=(83=s-b@=igwd-z73_ z4}FS)W#Y4E9Y0cdvCrjcMn53BB_Q4TUK;5G59eq-a*WU z36@uCzHRmfru=V`An?2To{uTT7NolrJInihCb+6E3rZ!5)NmficV=(Y;hJIo18dJ2 zgOD@csFTcc5x=h?xFkRnQKl!f0!^Tf44oc7X2;W{V-X%BvCqn6m?j{|g_e%4Y<_DB z&X1lXMywVa>iNJ`P>UkyaQaF zNkM)bP8e~2>vHnjP{p|C<%z<@Mh9giBE*+`f!#hY0@XE44Lz+1lZ2BE$f+g_n)aD+TKOg$+R1*;ytl$)KZpl)1l+`JwIE$? zxyDVn1N`%`OFyepK3U4{6Uy6b+Hlocwl6{H!)1duLBe}p{*E8w zcjpdM)N#hb$9ogI#|gwI&e7I)eZo(fUo9=aRZR#z7vZJ~{lus}H={ATt)+lTQ#8{H zN$b@k-QOLq4>_%9upLA;CBU|)-7iGXQ_^$pj|N{U{TcIBUfyz{Z-Ukc2$2mSeZvQv z=i0+9E)s;CzmPUo_ETYL9A;@Wa%`{3gMF|bI|N$E<9brL#m9#7Z9pdu6z{8Fgc3%$DNEDnCrP62na*gStLK`a^pJzubq zBP`cEcE)>m7(H)$dEf<~XwB5`Oen~7#GI$2=mJKdKSHiWrM%w|seIKsz|Ev7q9-S% z3=sCXm1pum9{wUhR&4oREr<#8Q^h@ke+@T8)^Ccl8z;Z57d(Be;CRxsPg-~>(+)Jp zcR_>p6c5s#lN)B*y0*Yw*m`POdCQ5Qw%`kaQ$*ld)p+Me4OmZKk2~GK${4=sbTa0P zb>Zn1J>gVF>MkS*3`7>5FlC4s=laPR=8V3k_7UN@sW`6kF}MOtySxbY~?9-;9(Q+dpkm>6%t zO*RFTJj-QO`RFH4LfM*tAz5^FC1>W4m#4%+`eMEr5{GNcV{R$`5Y*xLlHbu_Exp4> zaVr_fI5^O(iyCLg)q}VKa8T9cS;C*mSi<)UzM_%YgPhl)5O$Ey`{HsMG5Vk|V!*VuXidvf8PGeGWJSZMnz9zu@I4>Q5; zij1OU04S?A+IheT0s8!i$jekw9LbW_9!_@lHWiS{QQ;^x0T~uug-x2E+((-(4fkn0 zK@q4qTMWj5;RKw+uT(WfU^46!yWI)i1rB!hIOoF;LPT-bA>x7^ybs}DKD4?ly*lBy z=@$RmVB?~=1pdGN)YkCe4So0i4xxWW8Gd@DA6!EU0%k+W$voyejO&cK1^T%_X+(m? z0VaNptjsip=8KM|N?IzOY$>3T$J@`1hlMSh5_d5#a~&2VCzte$ZoG~Hrrh=CVlN0^ zAeQ3173IrF+S2!NM7j%Pa9ab zZ`UVFZ&FivftcR*?)`AObFVDuxLUP}y(fXOU2JXEE;JOSjS{4vb3yk8+0SHYVgG;G zjM9nbS1V=>q^DhRfzqT#2UQV-Jn(*!J#_fhJXk}>$V&U{T(R1L*f53yq!1L5$ zIgDoDHS_{n;>ysr>7^xEFW0>&x{v01)js|ibFX2lLzK`^yI1E1WoUN?*$}&mp|FRH zJIt)qMaY#$U^vauth3M8h2WCh{o=K+ji(1YN$~64c%OF7Uk}Ql2E}|BX&RH1J7z^{ zOw8El##)02*)L<=j4>zozSO_^SWpk(nL%7=%W<9T=>@M5SnY#6`>^5o1+c&U#x3yo z_x`;CNL3^zDa{|^dEfM3K1)gj;vs@?TWJ|ZM5s_KdOqhzPUrf}6qX1BKURS!&u}5| zmKY5k@?2J%IvOZ9Yny!ILSlB3OVA|@=!=xOEGtZ&zh2xPpv8eo2$gLe@PCJ0;B|9l z+G(O@vd)3F;*5dB;ukjpfF98aPHWicaYGVs8+oAbJk1tUkB7ul1?CPI0(VkY<*BTq z0wEyrMk2H6cu%7PuKVB1|LeF;Bn<0Innt~Aw}bB@4cq0eA0&KTc|%O^gPxq#)Hk*J zeN;D&r(vqG1gP_FsH=O?Y!4 z|I%gT5bJ5i;|YLgu4JcrIOPfNY|G$*>eDkBsucPYa-xZFL0fq}{c=HW*7z zoY~!d{K82J3`M@+tmqAq)mGlGM%Im;Cw;BLI23zZ5FLnsqT64Nxy?lKFbGnF%$ZB0 zkISx!y^0T5o~YZQXiOGH>jdJ?{MqY4vM@`Y&B|fjXp9NHcVQ@b7061o(44Mne12pJ^6eGSj4kj{r*7Z*TQ`#7Ug(GemT3 z2!h4y8I45>@Ee~B?=3?+{;GaCeHygU;C|fNCU^>G0}~p@%%<`UGpVSE+?JGgB!#Ef z|AH%2|5{(n1HjaLflfhRGT*^)|dCDzsYKbxZZKYP#9|+Sg+_Ow^Un zgV|nr;YJ(G^gU4au0nXhEjk`Pv0#hVPl0IZJ38tsdT_)gJQp_~Vi~+PAscBqZn=dQ zy^5{YWYG8_=+5u;*(kIEBb#lXaOD2p`rZzs%6A@0D8koquDJv(p{t<(Wz6!&AoN!g zaobc3WqRmc{=hCR&_F#!L0r%xQhLS(G%TI@k%5_Xq5D#Q`3P2o1DVYRJ`5Ony> z{z+xdhbQIl*&x_SHA0r-Je4r*Wzt(Bv#!uN~h!v^~tw=3shXQE=XW?&+Qrdw4paH{G1BOiaq z?c#fZ(0SbiJ9HwyVQzqy)*zU0?>kYTZNLvG2}6}B|1J!AHHdUX+1a&j7b17h9!omf z_L|<>5g2X;wjExNg^?-KG!4sWvi{)bC%WL4Bn3R({d|Kj0R4Q=HAxH-Cfa(4ZnNOk zLM@sk-#7Qh#!E70VlmtvYSAI6LfMetP%+7@Vut$t2gf8qUwR3p(eMD6_Mk->7H`OQ zPgfQd(BeiR78dWw2LaRX6?-rr{spkD=j9|K=>L=Y-?Nv?dsbiKtSX2ta{#vUlbm?t zL(1Cw14i7y^TJpp_9V(*+HR^FpgQe9ktmODxN95;|KG?1;8$<)mrq?!H^iX?iwMOb z(*v+A;L6c2^+RSq=wi+Fo?g#tIoM6bB~3|BT`qeZrjHSFRdnrfJdCj=66~!)X@7<1 z|5+LIXBP`WTqZjH1%1Cl<|h3=4-NiMt^41r1b?XYS9AMcR7eQgE}rn>--9)ri|qeS zh4jDiUjN-&A^)rseENSBPyhSxmDM`jQ&^DYRq+By7G8+nYjFpdGNHz07%^>T(1eJP zaSKp~aJPx1RUgP=M>Y^%03azIG!AaUV^0G5XF&D?!@Z?ZWqc}#!OLuc=19B`p#9>c zUg^N*FR8UXE&&BclJ`Ptxqm%Jy+Ti;;YR*R6%Z7v)<%FMFa#$*kD(``g1M=ZJz@!ZjsQO zWoZK)-T4atFj)c+Bc%gih!^bWBv$IO_GDcK*=5-W02AnfW<58G$eZcY4NtU7r-uiU zs6OY!r&&|YL#m%BG!?Y=)Az0$I0J+U-6p^fLcF(4_}BxR0;F#QV}Yx`6Dk-d`k))6 zzqUpnhq){?mJ3DDJO%_Pw1H{oQGjR3UZD~L=)_RBiZACJ%6t*oZpWLh{8jcs5ff4a z=!Yh!Peoe(Fq;v`TzXFPw6C>574P)FbPGpfJ3jw2Uq}YxBh$c5l0(oe4P_}q} z2U8lL=3p>1=vtzWGIpXe4*ET{a_>pk{>7!18LRcf@=Vjw6if?P;0KpkeV>_so@`CC z)uymlb)p^o420wcI>Z9fstCQ|`fT|%;p@4QiE>GYbDkn#<*AoOlU(n$dcj1?czeskH_EopJ z3~O8@ZD%O2pMsK7A@U;x+nC1z5{oKsqKyU)1@jPlNbB#?p*6p7wP5TVdO~EL=Jy*h zDuHh_Cv=gYIco^)H2~P3!;fBkcXzqsJK+*KQOwNa#~!+I*-S80Q4DON-H$o&ttt4j zC{y?&2Av4{&rR1gJD#fq#pctiOK5+nDvTlU-9j(oPsE2O`!#)=K#HjX+Be`hUGD_f zZY^{8m&?L8lo9b3o7Ac!{v?2Gkk8LjpX1cWHG(;=Rp_6K-~Gi!=e_v>ZG-O+;vwIT z|5%Y-ElM_tI{$?4Af%YFt_{doOc=5tB7+Ct9iZOvhY*wlzY;M93Ca=u^89h~Sr4%sZPv;0X0}{BsRwS;2g19d%%4 zyf>w_h&7(KWZq-i;H_S{m9}N@wQ&z462dptJ1rzu#ZkApDM!cOBn4M)-;%oG7%huiiH0^I{2%+iYbZ z1xBBg_Utnwkg@lKy{^{QsaXZ6YlUBWv%d9y_?WWmZ^Lh9u>rEr%} zVs}-z6DH?li80ajmToxAACO@DXS@D8OE*;{>aQdRaJLG{V4|0{QFhcRelo{O&*hZw z7zz5b0ikPpuZdNU)OZGPsgM86Uw}}je5`tdk~=m_KdbJ03!;wy`MH5W5}Or?8HgyW z=+~_vf#lj=-@rGoi;5?G16RLZRh_ww-f>XX0`3!GPG-SNHgTzcbsN6#a{(0s5t7z7 z)3=OIb*gON2=6>FLPWYOCWL*rNWMry)a<{;gq~;7*VEtaeVY3`l3ngg!kCv#z2X-C zNOt1L8WJa4Gil}rcOLwggj*r>&VkA$a|I`Gl_sXqnKmx;oWbeTot=%1aZ0fc!2_UF z9h;RZG^GfCAJNGMpSXV4`FyLfvUWb9wNEt9X6Vk(Wh8({Zz=Eb2rP^_5vH>R+5C+W zxA^?HqD+4hM!UFB=Zf0Z_JyUO$M*Mstq6YD_G|3qcjD=6R$;)6)t!w z1pbl1IjuCGYL8p|Ywaz&9oB?K1*JF~XNVVWa=LCX_7N3k zVoFFe&r1DuXKw{B-VqhQ?55|12$#DA*>M_oq@I$P?5?453jD78>7*=#@!uAmAWM43 zzQ;Ur8*Z2TnLLc?7G1&#P_{22E?1~-=~3bJNFyd15(&pzbd-2wiH2VDBZPT;1NqLC zy}57x=XLg1yG(XYxo+*RQTkSTtiGOXzkQUPRj=Kr(tU19!3p8n5$7YGUhm4GV_zpOS9K87A6tEgcV^n{n~NDuEVcZ9;S{ zbix>H`0s@{i2^YK&D(@J&s|I}oms~;!Xx~2;dU*J-hv+O0cKP1udj^HF^V|&Zf+GF zl=ynkoF49Y^&`wIf_L^sMBFDE*Onh(V2BE0X-tyt&7JJ39=Gib&U|^GQFGkzRxmnE z|882d$i(zb!$jP*k2+i&yjAEE>on@)G*%@CjcQe45~!wVR) zy?75V@4fH$*juA{GsTm7#Z1yW_p5mF$ktR{LwWt>Dbrf(i66fuY>+HX+0ooCHuoC* zMsMSz;=OJpda>T5Ji{25=PFL?8J-nZeilZ-)Wm!lueT>zP9dt|g!=cW-%JYJY1aPz zrq_Xbo(&2v1=TCW}xFTE>rs%QDUmGy}cGZB8Ckp>N36{(cLqTG7Gavr*%H=VfiXM|8ExEPk%J#-caE)PN zZ<5>!-*}>jo2uHc*FnG9XmRZ*P=cWy!Uq=0NLVYC{?*4Jr2O4|hW9REw79v{o3iYx zy%didkQ(XjXHRj%JWvq!u-tr}8nMujcLu}pqs|XCXD6vh_dfi2+QFX7+t#{gF?vij zX!+-*<)bbJePh3<6Ph)9E?X;|NsAp{Lg>%|Kul;|M4gNKY4q5dRVA+ Z)~;bOY5ey^eDmhLX4LmH%!ya7Cw()c+b~@ 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 AhX4Qo diff --git a/internal/api/grpc/management/project_application.go b/internal/api/grpc/management/project_application.go index 15e057c1bd..4b65808776 100644 --- a/internal/api/grpc/management/project_application.go +++ b/internal/api/grpc/management/project_application.go @@ -98,7 +98,11 @@ func (s *Server) AddOIDCApp(ctx context.Context, req *mgmt_pb.AddOIDCAppRequest) }, nil } func (s *Server) AddSAMLApp(ctx context.Context, req *mgmt_pb.AddSAMLAppRequest) (*mgmt_pb.AddSAMLAppResponse, error) { - app, err := s.command.AddSAMLApplication(ctx, AddSAMLAppRequestToDomain(req), authz.GetCtxData(ctx).OrgID) + samlApp, err := AddSAMLAppRequestToDomain(req) + if err != nil { + return nil, err + } + app, err := s.command.AddSAMLApplication(ctx, samlApp, authz.GetCtxData(ctx).OrgID) if err != nil { return nil, err } @@ -150,7 +154,11 @@ func (s *Server) UpdateOIDCAppConfig(ctx context.Context, req *mgmt_pb.UpdateOID } func (s *Server) UpdateSAMLAppConfig(ctx context.Context, req *mgmt_pb.UpdateSAMLAppConfigRequest) (*mgmt_pb.UpdateSAMLAppConfigResponse, error) { - config, err := s.command.ChangeSAMLApplication(ctx, UpdateSAMLAppConfigRequestToDomain(req), authz.GetCtxData(ctx).OrgID) + samlApp, err := UpdateSAMLAppConfigRequestToDomain(req) + if err != nil { + return nil, err + } + config, err := s.command.ChangeSAMLApplication(ctx, samlApp, authz.GetCtxData(ctx).OrgID) if err != nil { return nil, err } diff --git a/internal/api/grpc/management/project_application_converter.go b/internal/api/grpc/management/project_application_converter.go index 787470d9c1..13a0048a5b 100644 --- a/internal/api/grpc/management/project_application_converter.go +++ b/internal/api/grpc/management/project_application_converter.go @@ -67,15 +67,21 @@ func AddOIDCAppRequestToDomain(req *mgmt_pb.AddOIDCAppRequest) (*domain.OIDCApp, }, nil } -func AddSAMLAppRequestToDomain(req *mgmt_pb.AddSAMLAppRequest) *domain.SAMLApp { +func AddSAMLAppRequestToDomain(req *mgmt_pb.AddSAMLAppRequest) (*domain.SAMLApp, error) { + loginVersion, loginBaseURI, err := app_grpc.LoginVersionToDomain(req.GetLoginVersion()) + if err != nil { + return nil, err + } return &domain.SAMLApp{ ObjectRoot: models.ObjectRoot{ AggregateID: req.ProjectId, }, - AppName: req.Name, - Metadata: req.GetMetadataXml(), - MetadataURL: req.GetMetadataUrl(), - } + AppName: req.Name, + Metadata: req.GetMetadataXml(), + MetadataURL: req.GetMetadataUrl(), + LoginVersion: loginVersion, + LoginBaseURI: loginBaseURI, + }, nil } func AddAPIAppRequestToDomain(app *mgmt_pb.AddAPIAppRequest) *domain.APIApp { @@ -125,15 +131,21 @@ func UpdateOIDCAppConfigRequestToDomain(app *mgmt_pb.UpdateOIDCAppConfigRequest) }, nil } -func UpdateSAMLAppConfigRequestToDomain(app *mgmt_pb.UpdateSAMLAppConfigRequest) *domain.SAMLApp { +func UpdateSAMLAppConfigRequestToDomain(app *mgmt_pb.UpdateSAMLAppConfigRequest) (*domain.SAMLApp, error) { + loginVersion, loginBaseURI, err := app_grpc.LoginVersionToDomain(app.GetLoginVersion()) + if err != nil { + return nil, err + } return &domain.SAMLApp{ ObjectRoot: models.ObjectRoot{ AggregateID: app.ProjectId, }, - AppID: app.AppId, - Metadata: app.GetMetadataXml(), - MetadataURL: app.GetMetadataUrl(), - } + AppID: app.AppId, + Metadata: app.GetMetadataXml(), + MetadataURL: app.GetMetadataUrl(), + LoginVersion: loginVersion, + LoginBaseURI: loginBaseURI, + }, nil } func UpdateAPIAppConfigRequestToDomain(app *mgmt_pb.UpdateAPIAppConfigRequest) *domain.APIApp { diff --git a/internal/api/grpc/project/application.go b/internal/api/grpc/project/application.go index 573156e637..fc05013c53 100644 --- a/internal/api/grpc/project/application.go +++ b/internal/api/grpc/project/application.go @@ -85,7 +85,8 @@ func loginVersionToPb(version domain.LoginVersion, baseURI *string) *app_pb.Logi func AppSAMLConfigToPb(app *query.SAMLApp) app_pb.AppConfig { return &app_pb.App_SamlConfig{ SamlConfig: &app_pb.SAMLConfig{ - Metadata: &app_pb.SAMLConfig_MetadataXml{MetadataXml: app.Metadata}, + Metadata: &app_pb.SAMLConfig_MetadataXml{MetadataXml: app.Metadata}, + LoginVersion: loginVersionToPb(app.LoginVersion, app.LoginBaseURI), }, } } diff --git a/internal/api/saml/serviceprovider.go b/internal/api/saml/serviceprovider.go new file mode 100644 index 0000000000..98865e0858 --- /dev/null +++ b/internal/api/saml/serviceprovider.go @@ -0,0 +1,53 @@ +package saml + +import ( + "strings" + + "github.com/zitadel/saml/pkg/provider/serviceprovider" + + "github.com/zitadel/zitadel/internal/command" + "github.com/zitadel/zitadel/internal/query" +) + +const ( + LoginSamlRequestParam = "samlRequest" + LoginPath = "/login" +) + +type ServiceProvider struct { + SP *query.SAMLServiceProvider + defaultLoginURL string + defaultLoginURLV2 string +} + +func ServiceProviderFromBusiness(spQuery *query.SAMLServiceProvider, defaultLoginURL, defaultLoginURLV2 string) (*serviceprovider.ServiceProvider, error) { + sp := &ServiceProvider{ + SP: spQuery, + defaultLoginURL: defaultLoginURL, + defaultLoginURLV2: defaultLoginURLV2, + } + + return serviceprovider.NewServiceProvider( + spQuery.AppID, + &serviceprovider.Config{Metadata: spQuery.Metadata}, + sp.LoginURL, + ) +} + +func (s *ServiceProvider) LoginURL(id string) string { + // if the authRequest does not have the v2 prefix, it was created for login V1 + if !strings.HasPrefix(id, command.IDPrefixV2) { + return s.defaultLoginURL + id + } + // any v2 login without a specific base uri will be sent to the configured login v2 UI + // this way we're also backwards compatible + if s.SP.LoginBaseURI == nil || s.SP.LoginBaseURI.String() == "" { + return s.defaultLoginURLV2 + id + } + // for clients with a specific URI (internal or external) we only need to add the auth request id + uri := s.SP.LoginBaseURI.JoinPath(LoginPath) + q := uri.Query() + q.Set(LoginSamlRequestParam, id) + uri.RawQuery = q.Encode() + return uri.String() +} diff --git a/internal/api/saml/storage.go b/internal/api/saml/storage.go index 76f1bfd903..5a02619d93 100644 --- a/internal/api/saml/storage.go +++ b/internal/api/saml/storage.go @@ -17,6 +17,7 @@ import ( "github.com/zitadel/zitadel/internal/actions" "github.com/zitadel/zitadel/internal/actions/object" "github.com/zitadel/zitadel/internal/activity" + "github.com/zitadel/zitadel/internal/api/authz" http_utils "github.com/zitadel/zitadel/internal/api/http" "github.com/zitadel/zitadel/internal/api/http/middleware" "github.com/zitadel/zitadel/internal/auth/repository" @@ -62,22 +63,12 @@ type Storage struct { } func (p *Storage) GetEntityByID(ctx context.Context, entityID string) (*serviceprovider.ServiceProvider, error) { - app, err := p.query.ActiveAppBySAMLEntityID(ctx, entityID) + sp, err := p.query.ActiveSAMLServiceProviderByID(ctx, entityID) if err != nil { return nil, err } - return serviceprovider.NewServiceProvider( - app.ID, - &serviceprovider.Config{ - Metadata: app.SAMLConfig.Metadata, - }, - func(id string) string { - if strings.HasPrefix(id, command.IDPrefixV2) { - return p.defaultLoginURLv2 + id - } - return p.defaultLoginURL + id - }, - ) + + return ServiceProviderFromBusiness(sp, p.defaultLoginURL, p.defaultLoginURLv2) } func (p *Storage) GetEntityIDByAppID(ctx context.Context, appID string) (string, error) { @@ -108,11 +99,34 @@ func (p *Storage) CreateAuthRequest(ctx context.Context, req *samlp.AuthnRequest ctx, span := tracing.NewSpan(ctx) defer func() { span.EndWithError(err) }() + // for backwards compatibility we pass the login client if set headers, _ := http_utils.HeadersFromCtx(ctx) - if loginClient := headers.Get(LoginClientHeader); loginClient != "" { + loginClient := headers.Get(LoginClientHeader) + + // for backwards compatibility we'll use the new login if the header is set (no matter the other configs) + if loginClient != "" { return p.createAuthRequestLoginClient(ctx, req, acsUrl, protocolBinding, relayState, applicationID, loginClient) } - return p.createAuthRequest(ctx, req, acsUrl, protocolBinding, relayState, applicationID) + + // if the instance requires the v2 login, use it no matter what the application configured + if authz.GetFeatures(ctx).LoginV2.Required { + return p.createAuthRequestLoginClient(ctx, req, acsUrl, protocolBinding, relayState, applicationID, loginClient) + } + version, err := p.query.SAMLAppLoginVersion(ctx, applicationID) + if err != nil { + return nil, err + } + switch version { + case domain.LoginVersion1: + return p.createAuthRequest(ctx, req, acsUrl, protocolBinding, relayState, applicationID) + case domain.LoginVersion2: + return p.createAuthRequestLoginClient(ctx, req, acsUrl, protocolBinding, relayState, applicationID, loginClient) + case domain.LoginVersionUnspecified: + fallthrough + default: + // since we already checked for a login header, we can fall back to the v1 login + return p.createAuthRequest(ctx, req, acsUrl, protocolBinding, relayState, applicationID) + } } func (p *Storage) createAuthRequestLoginClient(ctx context.Context, req *samlp.AuthnRequestType, acsUrl, protocolBinding, relayState, applicationID, loginClient string) (_ models.AuthRequestInt, err error) { diff --git a/internal/command/org_test.go b/internal/command/org_test.go index bf88b55a86..4ec85d61e1 100644 --- a/internal/command/org_test.go +++ b/internal/command/org_test.go @@ -1264,10 +1264,10 @@ func TestCommandSide_RemoveOrg(t *testing.T) { ), expectFilter( eventFromEventPusher( - project.NewSAMLConfigAddedEvent(context.Background(), &project.NewAggregate("project1", "org1").Aggregate, "app1", "entity1", []byte{}, ""), + project.NewSAMLConfigAddedEvent(context.Background(), &project.NewAggregate("project1", "org1").Aggregate, "app1", "entity1", []byte{}, "", domain.LoginVersionUnspecified, ""), ), eventFromEventPusher( - project.NewSAMLConfigAddedEvent(context.Background(), &project.NewAggregate("project2", "org1").Aggregate, "app2", "entity2", []byte{}, ""), + project.NewSAMLConfigAddedEvent(context.Background(), &project.NewAggregate("project2", "org1").Aggregate, "app2", "entity2", []byte{}, "", domain.LoginVersionUnspecified, ""), ), ), expectPush( diff --git a/internal/command/project_application_oidc_model.go b/internal/command/project_application_oidc_model.go index 3fc07c79a9..603ebdcda2 100644 --- a/internal/command/project_application_oidc_model.go +++ b/internal/command/project_application_oidc_model.go @@ -325,10 +325,10 @@ func (wm *OIDCApplicationWriteModel) NewChangedEvent( changes = append(changes, project.ChangeBackChannelLogoutURI(backChannelLogoutURI)) } if wm.LoginVersion != loginVersion { - changes = append(changes, project.ChangeLoginVersion(loginVersion)) + changes = append(changes, project.ChangeOIDCLoginVersion(loginVersion)) } if wm.LoginBaseURI != loginBaseURI { - changes = append(changes, project.ChangeLoginBaseURI(loginBaseURI)) + changes = append(changes, project.ChangeOIDCLoginBaseURI(loginBaseURI)) } if len(changes) == 0 { diff --git a/internal/command/project_application_oidc_test.go b/internal/command/project_application_oidc_test.go index 8b663afa57..4b9f5bf94f 100644 --- a/internal/command/project_application_oidc_test.go +++ b/internal/command/project_application_oidc_test.go @@ -1297,8 +1297,8 @@ func newOIDCAppChangedEvent(ctx context.Context, appID, projectID, resourceOwner project.ChangeIDTokenRoleAssertion(false), project.ChangeIDTokenUserinfoAssertion(false), project.ChangeClockSkew(time.Second * 2), - project.ChangeLoginVersion(domain.LoginVersion2), - project.ChangeLoginBaseURI("https://login.test.ch"), + project.ChangeOIDCLoginVersion(domain.LoginVersion2), + project.ChangeOIDCLoginBaseURI("https://login.test.ch"), } event, _ := project.NewOIDCConfigChangedEvent(ctx, &project.NewAggregate(projectID, resourceOwner).Aggregate, diff --git a/internal/command/project_application_saml.go b/internal/command/project_application_saml.go index 76297ad93f..b14bed0758 100644 --- a/internal/command/project_application_saml.go +++ b/internal/command/project_application_saml.go @@ -79,6 +79,8 @@ func (c *Commands) addSAMLApplication(ctx context.Context, projectAgg *eventstor string(entity.EntityID), samlApp.Metadata, samlApp.MetadataURL, + samlApp.LoginVersion, + samlApp.LoginBaseURI, ), }, nil } @@ -119,7 +121,10 @@ func (c *Commands) ChangeSAMLApplication(ctx context.Context, samlApp *domain.SA samlApp.AppID, string(entity.EntityID), samlApp.Metadata, - samlApp.MetadataURL) + samlApp.MetadataURL, + samlApp.LoginVersion, + samlApp.LoginBaseURI, + ) if err != nil { return nil, err } diff --git a/internal/command/project_application_saml_model.go b/internal/command/project_application_saml_model.go index 2652acc617..f219039b58 100644 --- a/internal/command/project_application_saml_model.go +++ b/internal/command/project_application_saml_model.go @@ -12,11 +12,13 @@ import ( type SAMLApplicationWriteModel struct { eventstore.WriteModel - AppID string - AppName string - EntityID string - Metadata []byte - MetadataURL string + AppID string + AppName string + EntityID string + Metadata []byte + MetadataURL string + LoginVersion domain.LoginVersion + LoginBaseURI string State domain.AppState saml bool @@ -121,6 +123,8 @@ func (wm *SAMLApplicationWriteModel) appendAddSAMLEvent(e *project.SAMLConfigAdd wm.Metadata = e.Metadata wm.MetadataURL = e.MetadataURL wm.EntityID = e.EntityID + wm.LoginVersion = e.LoginVersion + wm.LoginBaseURI = e.LoginBaseURI } func (wm *SAMLApplicationWriteModel) appendChangeSAMLEvent(e *project.SAMLConfigChangedEvent) { @@ -134,6 +138,12 @@ func (wm *SAMLApplicationWriteModel) appendChangeSAMLEvent(e *project.SAMLConfig if e.EntityID != "" { wm.EntityID = e.EntityID } + if e.LoginVersion != nil { + wm.LoginVersion = *e.LoginVersion + } + if e.LoginBaseURI != nil { + wm.LoginBaseURI = *e.LoginBaseURI + } } func (wm *SAMLApplicationWriteModel) Query() *eventstore.SearchQueryBuilder { @@ -161,6 +171,8 @@ func (wm *SAMLApplicationWriteModel) NewChangedEvent( entityID string, metadata []byte, metadataURL string, + loginVersion domain.LoginVersion, + loginBaseURI string, ) (*project.SAMLConfigChangedEvent, bool, error) { changes := make([]project.SAMLConfigChanges, 0) var err error @@ -173,6 +185,12 @@ func (wm *SAMLApplicationWriteModel) NewChangedEvent( if wm.EntityID != entityID { changes = append(changes, project.ChangeEntityID(entityID)) } + if wm.LoginVersion != loginVersion { + changes = append(changes, project.ChangeSAMLLoginVersion(loginVersion)) + } + if wm.LoginBaseURI != loginBaseURI { + changes = append(changes, project.ChangeSAMLLoginBaseURI(loginBaseURI)) + } if len(changes) == 0 { return nil, false, nil diff --git a/internal/command/project_application_saml_test.go b/internal/command/project_application_saml_test.go index ff774e9f49..3082e87c46 100644 --- a/internal/command/project_application_saml_test.go +++ b/internal/command/project_application_saml_test.go @@ -50,7 +50,7 @@ var testMetadataChangedEntityID = []byte(` func TestCommandSide_AddSAMLApplication(t *testing.T) { type fields struct { - eventstore *eventstore.Eventstore + eventstore func(t *testing.T) *eventstore.Eventstore idGenerator id.Generator httpClient *http.Client } @@ -72,9 +72,7 @@ func TestCommandSide_AddSAMLApplication(t *testing.T) { { name: "no aggregate id, invalid argument error", fields: fields{ - eventstore: eventstoreExpect( - t, - ), + eventstore: expectEventstore(), }, args: args{ ctx: authz.WithInstanceID(context.Background(), "instanceID"), @@ -88,8 +86,7 @@ func TestCommandSide_AddSAMLApplication(t *testing.T) { { name: "project not existing, not found error", fields: fields{ - eventstore: eventstoreExpect( - t, + eventstore: expectEventstore( expectFilter(), ), }, @@ -111,8 +108,7 @@ func TestCommandSide_AddSAMLApplication(t *testing.T) { { name: "invalid app, invalid argument error", fields: fields{ - eventstore: eventstoreExpect( - t, + eventstore: expectEventstore( expectFilter( eventFromEventPusher( project.NewProjectAddedEvent(context.Background(), @@ -141,8 +137,7 @@ func TestCommandSide_AddSAMLApplication(t *testing.T) { { name: "create saml app, metadata not parsable", fields: fields{ - eventstore: eventstoreExpect( - t, + eventstore: expectEventstore( expectFilter( eventFromEventPusher( project.NewProjectAddedEvent(context.Background(), @@ -174,8 +169,7 @@ func TestCommandSide_AddSAMLApplication(t *testing.T) { { name: "create saml app, ok", fields: fields{ - eventstore: eventstoreExpect( - t, + eventstore: expectEventstore( expectFilter( eventFromEventPusher( project.NewProjectAddedEvent(context.Background(), @@ -196,6 +190,8 @@ func TestCommandSide_AddSAMLApplication(t *testing.T) { "https://test.com/saml/metadata", testMetadata, "", + domain.LoginVersionUnspecified, + "", ), ), ), @@ -229,11 +225,73 @@ func TestCommandSide_AddSAMLApplication(t *testing.T) { }, }, }, + { + name: "create saml app, loginversion, ok", + fields: fields{ + eventstore: expectEventstore( + expectFilter( + eventFromEventPusher( + project.NewProjectAddedEvent(context.Background(), + &project.NewAggregate("project1", "org1").Aggregate, + "project", true, true, true, + domain.PrivateLabelingSettingUnspecified), + ), + ), + expectPush( + project.NewApplicationAddedEvent(context.Background(), + &project.NewAggregate("project1", "org1").Aggregate, + "app1", + "app", + ), + project.NewSAMLConfigAddedEvent(context.Background(), + &project.NewAggregate("project1", "org1").Aggregate, + "app1", + "https://test.com/saml/metadata", + testMetadata, + "", + domain.LoginVersion2, + "https://test.com/login", + ), + ), + ), + idGenerator: id_mock.NewIDGeneratorExpectIDs(t, "app1"), + }, + args: args{ + ctx: authz.WithInstanceID(context.Background(), "instanceID"), + samlApp: &domain.SAMLApp{ + ObjectRoot: models.ObjectRoot{ + AggregateID: "project1", + }, + AppName: "app", + EntityID: "https://test.com/saml/metadata", + Metadata: testMetadata, + MetadataURL: "", + LoginVersion: domain.LoginVersion2, + LoginBaseURI: "https://test.com/login", + }, + resourceOwner: "org1", + }, + res: res{ + want: &domain.SAMLApp{ + ObjectRoot: models.ObjectRoot{ + AggregateID: "project1", + ResourceOwner: "org1", + }, + AppID: "app1", + AppName: "app", + EntityID: "https://test.com/saml/metadata", + Metadata: testMetadata, + MetadataURL: "", + State: domain.AppStateActive, + LoginVersion: domain.LoginVersion2, + LoginBaseURI: "https://test.com/login", + }, + }, + }, { name: "create saml app metadataURL, ok", fields: fields{ - eventstore: eventstoreExpect( - t, + eventstore: expectEventstore( expectFilter( eventFromEventPusher( project.NewProjectAddedEvent(context.Background(), @@ -254,6 +312,8 @@ func TestCommandSide_AddSAMLApplication(t *testing.T) { "https://test.com/saml/metadata", testMetadata, "http://localhost:8080/saml/metadata", + domain.LoginVersionUnspecified, + "", ), ), ), @@ -291,8 +351,7 @@ func TestCommandSide_AddSAMLApplication(t *testing.T) { { name: "create saml app metadataURL, http error", fields: fields{ - eventstore: eventstoreExpect( - t, + eventstore: expectEventstore( expectFilter( eventFromEventPusher( project.NewProjectAddedEvent(context.Background(), @@ -327,7 +386,7 @@ func TestCommandSide_AddSAMLApplication(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { c := &Commands{ - eventstore: tt.fields.eventstore, + eventstore: tt.fields.eventstore(t), idGenerator: tt.fields.idGenerator, httpClient: tt.fields.httpClient, } @@ -348,7 +407,7 @@ func TestCommandSide_AddSAMLApplication(t *testing.T) { func TestCommandSide_ChangeSAMLApplication(t *testing.T) { type fields struct { - eventstore *eventstore.Eventstore + eventstore func(t *testing.T) *eventstore.Eventstore httpClient *http.Client } type args struct { @@ -369,9 +428,7 @@ func TestCommandSide_ChangeSAMLApplication(t *testing.T) { { name: "invalid app, invalid argument error", fields: fields{ - eventstore: eventstoreExpect( - t, - ), + eventstore: expectEventstore(), }, args: args{ ctx: context.Background(), @@ -390,9 +447,7 @@ func TestCommandSide_ChangeSAMLApplication(t *testing.T) { { name: "missing appid, invalid argument error", fields: fields{ - eventstore: eventstoreExpect( - t, - ), + eventstore: expectEventstore(), }, args: args{ ctx: context.Background(), @@ -412,9 +467,7 @@ func TestCommandSide_ChangeSAMLApplication(t *testing.T) { { name: "missing aggregateid, invalid argument error", fields: fields{ - eventstore: eventstoreExpect( - t, - ), + eventstore: expectEventstore(), }, args: args{ ctx: context.Background(), @@ -434,8 +487,7 @@ func TestCommandSide_ChangeSAMLApplication(t *testing.T) { { name: "app not existing, not found error", fields: fields{ - eventstore: eventstoreExpect( - t, + eventstore: expectEventstore( expectFilter(), ), }, @@ -457,8 +509,7 @@ func TestCommandSide_ChangeSAMLApplication(t *testing.T) { { name: "no changes, precondition error, metadataURL", fields: fields{ - eventstore: eventstoreExpect( - t, + eventstore: expectEventstore( expectFilter( eventFromEventPusher( project.NewApplicationAddedEvent(context.Background(), @@ -474,6 +525,8 @@ func TestCommandSide_ChangeSAMLApplication(t *testing.T) { "https://test.com/saml/metadata", testMetadata, "http://localhost:8080/saml/metadata", + domain.LoginVersionUnspecified, + "", ), ), ), @@ -502,8 +555,7 @@ func TestCommandSide_ChangeSAMLApplication(t *testing.T) { { name: "no changes, precondition error, metadata", fields: fields{ - eventstore: eventstoreExpect( - t, + eventstore: expectEventstore( expectFilter( eventFromEventPusher( project.NewApplicationAddedEvent(context.Background(), @@ -519,6 +571,8 @@ func TestCommandSide_ChangeSAMLApplication(t *testing.T) { "https://test.com/saml/metadata", testMetadata, "", + domain.LoginVersionUnspecified, + "", ), ), ), @@ -547,8 +601,7 @@ func TestCommandSide_ChangeSAMLApplication(t *testing.T) { { name: "change saml app, ok, metadataURL", fields: fields{ - eventstore: eventstoreExpect( - t, + eventstore: expectEventstore( expectFilter( eventFromEventPusher( project.NewApplicationAddedEvent(context.Background(), @@ -564,6 +617,8 @@ func TestCommandSide_ChangeSAMLApplication(t *testing.T) { "https://test.com/saml/metadata", testMetadata, "http://localhost:8080/saml/metadata", + domain.LoginVersionUnspecified, + "", ), ), ), @@ -613,8 +668,7 @@ func TestCommandSide_ChangeSAMLApplication(t *testing.T) { { name: "change saml app, ok, metadata", fields: fields{ - eventstore: eventstoreExpect( - t, + eventstore: expectEventstore( expectFilter( eventFromEventPusher( project.NewApplicationAddedEvent(context.Background(), @@ -630,6 +684,8 @@ func TestCommandSide_ChangeSAMLApplication(t *testing.T) { "https://test.com/saml/metadata", testMetadata, "", + domain.LoginVersionUnspecified, + "", ), ), ), @@ -675,13 +731,85 @@ func TestCommandSide_ChangeSAMLApplication(t *testing.T) { State: domain.AppStateActive, }, }, + }, { + name: "change saml app, ok, loginversion", + fields: fields{ + eventstore: expectEventstore( + expectFilter( + eventFromEventPusher( + project.NewApplicationAddedEvent(context.Background(), + &project.NewAggregate("project1", "org1").Aggregate, + "app1", + "app", + ), + ), + eventFromEventPusher( + project.NewSAMLConfigAddedEvent(context.Background(), + &project.NewAggregate("project1", "org1").Aggregate, + "app1", + "https://test.com/saml/metadata", + testMetadata, + "", + domain.LoginVersionUnspecified, + "", + ), + ), + ), + expectPush( + newSAMLAppChangedEventLoginVersion(context.Background(), + "app1", + "project1", + "org1", + "https://test.com/saml/metadata", + "https://test2.com/saml/metadata", + testMetadataChangedEntityID, + domain.LoginVersion2, + "https://test.com/login", + ), + ), + ), + httpClient: nil, + }, + args: args{ + ctx: context.Background(), + samlApp: &domain.SAMLApp{ + ObjectRoot: models.ObjectRoot{ + AggregateID: "project1", + ResourceOwner: "org1", + }, + AppID: "app1", + AppName: "app", + EntityID: "https://test2.com/saml/metadata", + Metadata: testMetadataChangedEntityID, + MetadataURL: "", + LoginVersion: domain.LoginVersion2, + LoginBaseURI: "https://test.com/login", + }, + resourceOwner: "org1", + }, + res: res{ + want: &domain.SAMLApp{ + ObjectRoot: models.ObjectRoot{ + AggregateID: "project1", + ResourceOwner: "org1", + }, + AppID: "app1", + AppName: "app", + EntityID: "https://test2.com/saml/metadata", + Metadata: testMetadataChangedEntityID, + MetadataURL: "", + State: domain.AppStateActive, + LoginVersion: domain.LoginVersion2, + LoginBaseURI: "https://test.com/login", + }, + }, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { r := &Commands{ - eventstore: tt.fields.eventstore, + eventstore: tt.fields.eventstore(t), httpClient: tt.fields.httpClient, } got, err := r.ChangeSAMLApplication(tt.args.ctx, tt.args.samlApp, tt.args.resourceOwner) @@ -726,6 +854,22 @@ func newSAMLAppChangedEventMetadataURL(ctx context.Context, appID, projectID, re return event } +func newSAMLAppChangedEventLoginVersion(ctx context.Context, appID, projectID, resourceOwner, oldEntityID, entityID string, metadata []byte, loginVersion domain.LoginVersion, loginURI string) *project.SAMLConfigChangedEvent { + changes := []project.SAMLConfigChanges{ + project.ChangeEntityID(entityID), + project.ChangeMetadata(metadata), + project.ChangeSAMLLoginVersion(loginVersion), + project.ChangeSAMLLoginBaseURI(loginURI), + } + event, _ := project.NewSAMLConfigChangedEvent(ctx, + &project.NewAggregate(projectID, resourceOwner).Aggregate, + appID, + oldEntityID, + changes, + ) + return event +} + type roundTripperFunc func(*http.Request) *http.Response // RoundTrip implements the http.RoundTripper interface. diff --git a/internal/command/project_application_test.go b/internal/command/project_application_test.go index ae2c6c39b0..050a41d29f 100644 --- a/internal/command/project_application_test.go +++ b/internal/command/project_application_test.go @@ -596,6 +596,8 @@ func TestCommandSide_RemoveApplication(t *testing.T) { "https://test.com/saml/metadata", []byte("\n\n \n urn:oasis:names:tc:SAML:1.1:nameid-format:unspecified\n \n \n \n"), "", + domain.LoginVersionUnspecified, + "", )), ), expectPush( diff --git a/internal/command/project_converter.go b/internal/command/project_converter.go index 59343aa762..01b5a4e63d 100644 --- a/internal/command/project_converter.go +++ b/internal/command/project_converter.go @@ -55,13 +55,15 @@ func oidcWriteModelToOIDCConfig(writeModel *OIDCApplicationWriteModel) *domain.O func samlWriteModelToSAMLConfig(writeModel *SAMLApplicationWriteModel) *domain.SAMLApp { return &domain.SAMLApp{ - ObjectRoot: writeModelToObjectRoot(writeModel.WriteModel), - AppID: writeModel.AppID, - AppName: writeModel.AppName, - State: writeModel.State, - Metadata: writeModel.Metadata, - MetadataURL: writeModel.MetadataURL, - EntityID: writeModel.EntityID, + ObjectRoot: writeModelToObjectRoot(writeModel.WriteModel), + AppID: writeModel.AppID, + AppName: writeModel.AppName, + State: writeModel.State, + Metadata: writeModel.Metadata, + MetadataURL: writeModel.MetadataURL, + EntityID: writeModel.EntityID, + LoginVersion: writeModel.LoginVersion, + LoginBaseURI: writeModel.LoginBaseURI, } } diff --git a/internal/command/project_test.go b/internal/command/project_test.go index 645371e2fc..842e1aa640 100644 --- a/internal/command/project_test.go +++ b/internal/command/project_test.go @@ -988,6 +988,8 @@ func TestCommandSide_RemoveProject(t *testing.T) { "https://test.com/saml/metadata", []byte("\n\n \n urn:oasis:names:tc:SAML:1.1:nameid-format:unspecified\n \n \n \n"), "http://localhost:8080/saml/metadata", + domain.LoginVersionUnspecified, + "", ), ), ), @@ -1039,6 +1041,8 @@ func TestCommandSide_RemoveProject(t *testing.T) { "https://test1.com/saml/metadata", []byte("\n\n \n urn:oasis:names:tc:SAML:1.1:nameid-format:unspecified\n \n \n \n"), "", + domain.LoginVersionUnspecified, + "", ), ), eventFromEventPusher(project.NewApplicationAddedEvent(context.Background(), @@ -1053,6 +1057,8 @@ func TestCommandSide_RemoveProject(t *testing.T) { "https://test2.com/saml/metadata", []byte("\n\n \n urn:oasis:names:tc:SAML:1.1:nameid-format:unspecified\n \n \n \n"), "", + domain.LoginVersionUnspecified, + "", ), ), eventFromEventPusher(project.NewApplicationAddedEvent(context.Background(), @@ -1067,6 +1073,8 @@ func TestCommandSide_RemoveProject(t *testing.T) { "https://test3.com/saml/metadata", []byte("\n\n \n urn:oasis:names:tc:SAML:1.1:nameid-format:unspecified\n \n \n \n"), "", + domain.LoginVersionUnspecified, + "", ), ), ), diff --git a/internal/command/saml_request.go b/internal/command/saml_request.go index 2dfa8756c7..17f56101ec 100644 --- a/internal/command/saml_request.go +++ b/internal/command/saml_request.go @@ -75,7 +75,9 @@ func (c *Commands) LinkSessionToSAMLRequest(ctx context.Context, id, sessionID, return nil, nil, zerrors.ThrowPreconditionFailed(nil, "COMMAND-ttPKNdAIFT", "Errors.SAMLRequest.AlreadyHandled") } if checkLoginClient && authz.GetCtxData(ctx).UserID != writeModel.LoginClient { - return nil, nil, zerrors.ThrowPermissionDenied(nil, "COMMAND-KCd48Rxt7x", "Errors.SAMLRequest.WrongLoginClient") + if err := c.checkPermission(ctx, domain.PermissionSessionLink, writeModel.ResourceOwner, ""); err != nil { + return nil, nil, err + } } sessionWriteModel := NewSessionWriteModel(sessionID, authz.GetInstance(ctx).InstanceID()) err = c.eventstore.FilterToQueryReducer(ctx, sessionWriteModel) diff --git a/internal/command/saml_request_test.go b/internal/command/saml_request_test.go index ed7363e151..761edde8fb 100644 --- a/internal/command/saml_request_test.go +++ b/internal/command/saml_request_test.go @@ -132,8 +132,9 @@ func TestCommands_AddSAMLRequest(t *testing.T) { func TestCommands_LinkSessionToSAMLRequest(t *testing.T) { mockCtx := authz.NewMockContext("instanceID", "orgID", "loginClient") type fields struct { - eventstore func(t *testing.T) *eventstore.Eventstore - tokenVerifier func(ctx context.Context, sessionToken, sessionID, tokenID string) (err error) + eventstore func(t *testing.T) *eventstore.Eventstore + tokenVerifier func(ctx context.Context, sessionToken, sessionID, tokenID string) (err error) + checkPermission domain.PermissionCheck } type args struct { ctx context.Context @@ -207,7 +208,7 @@ func TestCommands_LinkSessionToSAMLRequest(t *testing.T) { }, }, { - "wrong login client", + "wrong login client / not permitted", fields{ eventstore: expectEventstore( expectFilter( @@ -225,7 +226,8 @@ func TestCommands_LinkSessionToSAMLRequest(t *testing.T) { ), ), ), - tokenVerifier: newMockTokenVerifierValid(), + tokenVerifier: newMockTokenVerifierValid(), + checkPermission: newMockPermissionCheckNotAllowed(), }, args{ ctx: authz.NewMockContext("instanceID", "orgID", "wrongLoginClient"), @@ -235,7 +237,7 @@ func TestCommands_LinkSessionToSAMLRequest(t *testing.T) { checkLoginClient: true, }, res{ - wantErr: zerrors.ThrowPermissionDenied(nil, "COMMAND-KCd48Rxt7x", "Errors.SAMLRequest.WrongLoginClient"), + wantErr: zerrors.ThrowPermissionDenied(nil, "AUTHZ-HKJD33", "Errors.PermissionDenied"), }, }, { @@ -524,6 +526,86 @@ func TestCommands_LinkSessionToSAMLRequest(t *testing.T) { AuthMethods: []domain.UserAuthMethodType{domain.UserAuthMethodTypePassword}, }, }, + }, { + "linked with permission", + fields{ + eventstore: expectEventstore( + expectFilter( + eventFromEventPusher( + samlrequest.NewAddedEvent(mockCtx, &samlrequest.NewAggregate("V2_id", "instanceID").Aggregate, + "loginClient", + "application", + "acs", + "relaystate", + "request", + "binding", + "issuer", + "destination", + ), + ), + ), + expectFilter( + eventFromEventPusher( + session.NewAddedEvent(mockCtx, + &session.NewAggregate("sessionID", "instance1").Aggregate, + &domain.UserAgent{ + FingerprintID: gu.Ptr("fp1"), + IP: net.ParseIP("1.2.3.4"), + Description: gu.Ptr("firefox"), + Header: http.Header{"foo": []string{"bar"}}, + }, + )), + eventFromEventPusher( + session.NewUserCheckedEvent(mockCtx, &session.NewAggregate("sessionID", "instance1").Aggregate, + "userID", "org1", testNow, &language.Afrikaans), + ), + eventFromEventPusher( + session.NewPasswordCheckedEvent(mockCtx, &session.NewAggregate("sessionID", "instance1").Aggregate, + testNow), + ), + eventFromEventPusherWithCreationDateNow( + session.NewLifetimeSetEvent(mockCtx, &session.NewAggregate("sessionID", "instance1").Aggregate, + 2*time.Minute), + ), + ), + expectPush( + samlrequest.NewSessionLinkedEvent(mockCtx, &samlrequest.NewAggregate("V2_id", "instanceID").Aggregate, + "sessionID", + "userID", + testNow, + []domain.UserAuthMethodType{domain.UserAuthMethodTypePassword}, + ), + ), + ), + tokenVerifier: newMockTokenVerifierValid(), + checkPermission: newMockPermissionCheckAllowed(), + }, + args{ + ctx: authz.NewMockContext("instanceID", "orgID", "loginClient"), + id: "V2_id", + sessionID: "sessionID", + sessionToken: "token", + checkLoginClient: true, + }, + res{ + details: &domain.ObjectDetails{ResourceOwner: "instanceID"}, + authReq: &CurrentSAMLRequest{ + SAMLRequest: &SAMLRequest{ + ID: "V2_id", + LoginClient: "loginClient", + ApplicationID: "application", + ACSURL: "acs", + RelayState: "relaystate", + RequestID: "request", + Binding: "binding", + Issuer: "issuer", + Destination: "destination", + }, + SessionID: "sessionID", + UserID: "userID", + AuthMethods: []domain.UserAuthMethodType{domain.UserAuthMethodTypePassword}, + }, + }, }, { "linked with login client check, application permission check", @@ -669,6 +751,7 @@ func TestCommands_LinkSessionToSAMLRequest(t *testing.T) { c := &Commands{ eventstore: tt.fields.eventstore(t), sessionTokenVerifier: tt.fields.tokenVerifier, + checkPermission: tt.fields.checkPermission, } details, got, err := c.LinkSessionToSAMLRequest(tt.args.ctx, tt.args.id, tt.args.sessionID, tt.args.sessionToken, tt.args.checkLoginClient, tt.args.checkPermission) require.ErrorIs(t, err, tt.res.wantErr) diff --git a/internal/domain/application_saml.go b/internal/domain/application_saml.go index b00366df7e..de7ef789ee 100644 --- a/internal/domain/application_saml.go +++ b/internal/domain/application_saml.go @@ -7,11 +7,13 @@ import ( type SAMLApp struct { models.ObjectRoot - AppID string - AppName string - EntityID string - Metadata []byte - MetadataURL string + AppID string + AppName string + EntityID string + Metadata []byte + MetadataURL string + LoginVersion LoginVersion + LoginBaseURI string State AppState } diff --git a/internal/integration/saml.go b/internal/integration/saml.go index 483543b322..533b0ee515 100644 --- a/internal/integration/saml.go +++ b/internal/integration/saml.go @@ -20,6 +20,7 @@ import ( http_util "github.com/zitadel/zitadel/internal/api/http" oidc_internal "github.com/zitadel/zitadel/internal/api/oidc" + app_pb "github.com/zitadel/zitadel/pkg/grpc/app" "github.com/zitadel/zitadel/pkg/grpc/management" saml_pb "github.com/zitadel/zitadel/pkg/grpc/saml/v2" session_pb "github.com/zitadel/zitadel/pkg/grpc/session/v2" @@ -102,7 +103,7 @@ func CreateSAMLSP(root string, idpMetadata *saml.EntityDescriptor, binding strin return sp, nil } -func (i *Instance) CreateSAMLClient(ctx context.Context, projectID string, m *samlsp.Middleware) (*management.AddSAMLAppResponse, error) { +func (i *Instance) CreateSAMLClientLoginVersion(ctx context.Context, projectID string, m *samlsp.Middleware, loginVersion *app_pb.LoginVersion) (*management.AddSAMLAppResponse, error) { spMetadata, err := xml.MarshalIndent(m.ServiceProvider.Metadata(), "", " ") if err != nil { return nil, err @@ -114,9 +115,10 @@ func (i *Instance) CreateSAMLClient(ctx context.Context, projectID string, m *sa } resp, err := i.Client.Mgmt.AddSAMLApp(ctx, &management.AddSAMLAppRequest{ - ProjectId: projectID, - Name: fmt.Sprintf("app-%s", gofakeit.AppName()), - Metadata: &management.AddSAMLAppRequest_MetadataXml{MetadataXml: spMetadata}, + ProjectId: projectID, + Name: fmt.Sprintf("app-%s", gofakeit.AppName()), + Metadata: &management.AddSAMLAppRequest_MetadataXml{MetadataXml: spMetadata}, + LoginVersion: loginVersion, }) if err != nil { return nil, err @@ -136,7 +138,19 @@ func (i *Instance) CreateSAMLClient(ctx context.Context, projectID string, m *sa }) } -func (i *Instance) CreateSAMLAuthRequest(m *samlsp.Middleware, loginClient string, acs saml.Endpoint, relayState string, responseBinding string) (now time.Time, authRequestID string, err error) { +func (i *Instance) CreateSAMLClient(ctx context.Context, projectID string, m *samlsp.Middleware) (*management.AddSAMLAppResponse, error) { + return i.CreateSAMLClientLoginVersion(ctx, projectID, m, nil) +} + +func (i *Instance) CreateSAMLAuthRequestWithoutLoginClientHeader(m *samlsp.Middleware, loginBaseURI string, acs saml.Endpoint, relayState, responseBinding string) (now time.Time, authRequestID string, err error) { + return i.createSAMLAuthRequest(m, "", loginBaseURI, acs, relayState, responseBinding) +} + +func (i *Instance) CreateSAMLAuthRequest(m *samlsp.Middleware, loginClient string, acs saml.Endpoint, relayState, responseBinding string) (now time.Time, authRequestID string, err error) { + return i.createSAMLAuthRequest(m, loginClient, "", acs, relayState, responseBinding) +} + +func (i *Instance) createSAMLAuthRequest(m *samlsp.Middleware, loginClient, loginBaseURI string, acs saml.Endpoint, relayState, responseBinding string) (now time.Time, authRequestID string, err error) { authReq, err := m.ServiceProvider.MakeAuthenticationRequest(acs.Location, acs.Binding, responseBinding) if err != nil { return now, "", err @@ -147,7 +161,11 @@ func (i *Instance) CreateSAMLAuthRequest(m *samlsp.Middleware, loginClient strin return now, "", err } - req, err := GetRequest(redirectURL.String(), map[string]string{oidc_internal.LoginClientHeader: loginClient}) + var headers map[string]string + if loginClient != "" { + headers = map[string]string{oidc_internal.LoginClientHeader: loginClient} + } + req, err := GetRequest(redirectURL.String(), headers) if err != nil { return now, "", fmt.Errorf("get request: %w", err) } @@ -158,11 +176,13 @@ func (i *Instance) CreateSAMLAuthRequest(m *samlsp.Middleware, loginClient strin return now, "", fmt.Errorf("check redirect: %w", err) } - prefixWithHost := i.Issuer() + i.Config.LoginURLV2 - if !strings.HasPrefix(loc.String(), prefixWithHost) { - return now, "", fmt.Errorf("login location has not prefix %s, but is %s", prefixWithHost, loc.String()) + if loginBaseURI == "" { + loginBaseURI = i.Issuer() + i.Config.LoginURLV2 } - return now, strings.TrimPrefix(loc.String(), prefixWithHost), nil + if !strings.HasPrefix(loc.String(), loginBaseURI) { + return now, "", fmt.Errorf("login location has not prefix %s, but is %s", loginBaseURI, loc.String()) + } + return now, strings.TrimPrefix(loc.String(), loginBaseURI), nil } func (i *Instance) FailSAMLAuthRequest(ctx context.Context, id string, reason saml_pb.ErrorReason) *saml_pb.CreateResponseResponse { diff --git a/internal/query/app.go b/internal/query/app.go index 1aa0323a5a..fafbbe72d9 100644 --- a/internal/query/app.go +++ b/internal/query/app.go @@ -66,9 +66,11 @@ type OIDCApp struct { } type SAMLApp struct { - Metadata []byte - MetadataURL string - EntityID string + Metadata []byte + MetadataURL string + EntityID string + LoginVersion domain.LoginVersion + LoginBaseURI *string } type APIApp struct { @@ -137,6 +139,10 @@ var ( name: projection.AppSAMLTable, instanceIDCol: projection.AppSAMLConfigColumnInstanceID, } + AppSAMLConfigColumnInstanceID = Column{ + name: projection.AppSAMLConfigColumnInstanceID, + table: appSAMLConfigsTable, + } AppSAMLConfigColumnAppID = Column{ name: projection.AppSAMLConfigColumnAppID, table: appSAMLConfigsTable, @@ -153,6 +159,14 @@ var ( name: projection.AppSAMLConfigColumnMetadataURL, table: appSAMLConfigsTable, } + AppSAMLConfigColumnLoginVersion = Column{ + name: projection.AppSAMLConfigColumnLoginVersion, + table: appSAMLConfigsTable, + } + AppSAMLConfigColumnLoginBaseURI = Column{ + name: projection.AppSAMLConfigColumnLoginBaseURI, + table: appSAMLConfigsTable, + } ) var ( @@ -320,30 +334,6 @@ func (q *Queries) AppByID(ctx context.Context, appID string, activeOnly bool) (a return app, err } -func (q *Queries) ActiveAppBySAMLEntityID(ctx context.Context, entityID string) (app *App, err error) { - ctx, span := tracing.NewSpan(ctx) - defer func() { span.EndWithError(err) }() - - stmt, scan := prepareSAMLAppQuery(ctx, q.client) - eq := sq.Eq{ - AppSAMLConfigColumnEntityID.identifier(): entityID, - AppColumnInstanceID.identifier(): authz.GetInstance(ctx).InstanceID(), - AppColumnState.identifier(): domain.AppStateActive, - ProjectColumnState.identifier(): domain.ProjectStateActive, - OrgColumnState.identifier(): domain.OrgStateActive, - } - query, args, err := stmt.Where(eq).ToSql() - if err != nil { - return nil, zerrors.ThrowInternal(err, "QUERY-JgUop", "Errors.Query.SQLStatement") - } - - err = q.client.QueryRowContext(ctx, func(row *sql.Row) error { - app, err = scan(row) - return err - }, query, args...) - return app, err -} - func (q *Queries) ProjectByClientID(ctx context.Context, appID string) (project *Project, err error) { ctx, span := tracing.NewSpan(ctx) defer func() { span.EndWithError(err) }() @@ -591,7 +581,7 @@ func (q *Queries) OIDCClientLoginVersion(ctx context.Context, clientID string) ( ctx, span := tracing.NewSpan(ctx) defer func() { span.EndWithError(err) }() - query, scan := prepareLoginVersionByClientID(ctx, q.client) + query, scan := prepareLoginVersionByOIDCClientID(ctx, q.client) eq := sq.Eq{ AppOIDCConfigColumnInstanceID.identifier(): authz.GetInstance(ctx).InstanceID(), AppOIDCConfigColumnClientID.identifier(): clientID, @@ -611,6 +601,30 @@ func (q *Queries) OIDCClientLoginVersion(ctx context.Context, clientID string) ( return loginVersion, nil } +func (q *Queries) SAMLAppLoginVersion(ctx context.Context, appID string) (loginVersion domain.LoginVersion, err error) { + ctx, span := tracing.NewSpan(ctx) + defer func() { span.EndWithError(err) }() + + query, scan := prepareLoginVersionBySAMLAppID(ctx, q.client) + eq := sq.Eq{ + AppSAMLConfigColumnInstanceID.identifier(): authz.GetInstance(ctx).InstanceID(), + AppSAMLConfigColumnAppID.identifier(): appID, + } + stmt, args, err := query.Where(eq).ToSql() + if err != nil { + return domain.LoginVersionUnspecified, zerrors.ThrowInvalidArgument(err, "QUERY-TnaciwZfp3", "Errors.Query.InvalidRequest") + } + + err = q.client.QueryRowContext(ctx, func(row *sql.Row) error { + loginVersion, err = scan(row) + return err + }, stmt, args...) + if err != nil { + return domain.LoginVersionUnspecified, zerrors.ThrowInternal(err, "QUERY-lvDDwRzIoP", "Errors.Internal") + } + return loginVersion, nil +} + func NewAppNameSearchQuery(method TextComparison, value string) (SearchQuery, error) { return NewTextQuery(AppColumnName, value, method) } @@ -659,6 +673,8 @@ func prepareAppQuery(ctx context.Context, db prepareDatabase, activeOnly bool) ( AppSAMLConfigColumnEntityID.identifier(), AppSAMLConfigColumnMetadata.identifier(), AppSAMLConfigColumnMetadataURL.identifier(), + AppSAMLConfigColumnLoginVersion.identifier(), + AppSAMLConfigColumnLoginBaseURI.identifier(), ).From(appsTable.identifier()). PlaceholderFormat(sq.Dollar) @@ -726,6 +742,8 @@ func scanApp(row *sql.Row) (*App, error) { &samlConfig.entityID, &samlConfig.metadata, &samlConfig.metadataURL, + &samlConfig.loginVersion, + &samlConfig.loginBaseURI, ) if err != nil { @@ -827,61 +845,6 @@ func prepareOIDCAppQuery() (sq.SelectBuilder, func(*sql.Row) (*App, error)) { } } -func prepareSAMLAppQuery(ctx context.Context, db prepareDatabase) (sq.SelectBuilder, func(*sql.Row) (*App, error)) { - return sq.Select( - AppColumnID.identifier(), - AppColumnName.identifier(), - AppColumnProjectID.identifier(), - AppColumnCreationDate.identifier(), - AppColumnChangeDate.identifier(), - AppColumnResourceOwner.identifier(), - AppColumnState.identifier(), - AppColumnSequence.identifier(), - - AppSAMLConfigColumnAppID.identifier(), - AppSAMLConfigColumnEntityID.identifier(), - AppSAMLConfigColumnMetadata.identifier(), - AppSAMLConfigColumnMetadataURL.identifier(), - ).From(appsTable.identifier()). - Join(join(AppSAMLConfigColumnAppID, AppColumnID)). - Join(join(ProjectColumnID, AppColumnProjectID)). - Join(join(OrgColumnID, AppColumnResourceOwner)). - PlaceholderFormat(sq.Dollar), func(row *sql.Row) (*App, error) { - - app := new(App) - var ( - samlConfig = sqlSAMLConfig{} - ) - - err := row.Scan( - &app.ID, - &app.Name, - &app.ProjectID, - &app.CreationDate, - &app.ChangeDate, - &app.ResourceOwner, - &app.State, - &app.Sequence, - - &samlConfig.appID, - &samlConfig.entityID, - &samlConfig.metadata, - &samlConfig.metadataURL, - ) - - if err != nil { - if errors.Is(err, sql.ErrNoRows) { - return nil, zerrors.ThrowNotFound(err, "QUERY-d6TO1", "Errors.App.NotExisting") - } - return nil, zerrors.ThrowInternal(err, "QUERY-NAtPg", "Errors.Internal") - } - - samlConfig.set(app) - - return app, nil - } -} - func prepareProjectIDByAppQuery(ctx context.Context, db prepareDatabase) (sq.SelectBuilder, func(*sql.Row) (projectID string, err error)) { return sq.Select( AppColumnProjectID.identifier(), @@ -1031,6 +994,8 @@ func prepareAppsQuery(ctx context.Context, db prepareDatabase) (sq.SelectBuilder AppSAMLConfigColumnEntityID.identifier(), AppSAMLConfigColumnMetadata.identifier(), AppSAMLConfigColumnMetadataURL.identifier(), + AppSAMLConfigColumnLoginVersion.identifier(), + AppSAMLConfigColumnLoginBaseURI.identifier(), countColumn.identifier(), ).From(appsTable.identifier()). LeftJoin(join(AppAPIConfigColumnAppID, AppColumnID)). @@ -1086,6 +1051,8 @@ func prepareAppsQuery(ctx context.Context, db prepareDatabase) (sq.SelectBuilder &samlConfig.entityID, &samlConfig.metadata, &samlConfig.metadataURL, + &samlConfig.loginVersion, + &samlConfig.loginBaseURI, &apps.Count, ) @@ -1135,7 +1102,7 @@ func prepareClientIDsQuery(ctx context.Context, db prepareDatabase) (sq.SelectBu } } -func prepareLoginVersionByClientID(ctx context.Context, db prepareDatabase) (sq.SelectBuilder, func(*sql.Row) (domain.LoginVersion, error)) { +func prepareLoginVersionByOIDCClientID(ctx context.Context, db prepareDatabase) (sq.SelectBuilder, func(*sql.Row) (domain.LoginVersion, error)) { return sq.Select( AppOIDCConfigColumnLoginVersion.identifier(), ).From(appOIDCConfigsTable.identifier()). @@ -1150,6 +1117,21 @@ func prepareLoginVersionByClientID(ctx context.Context, db prepareDatabase) (sq. } } +func prepareLoginVersionBySAMLAppID(ctx context.Context, db prepareDatabase) (sq.SelectBuilder, func(*sql.Row) (domain.LoginVersion, error)) { + return sq.Select( + AppSAMLConfigColumnLoginVersion.identifier(), + ).From(appSAMLConfigsTable.identifier()). + PlaceholderFormat(sq.Dollar), func(row *sql.Row) (domain.LoginVersion, error) { + var loginVersion sql.NullInt16 + if err := row.Scan( + &loginVersion, + ); err != nil { + return domain.LoginVersionUnspecified, zerrors.ThrowInternal(err, "QUERY-KbzaCnaziI", "Errors.Internal") + } + return domain.LoginVersion(loginVersion.Int16), nil + } +} + type sqlOIDCConfig struct { appID sql.NullString version sql.NullInt32 @@ -1209,10 +1191,12 @@ func (c sqlOIDCConfig) set(app *App) { } type sqlSAMLConfig struct { - appID sql.NullString - entityID sql.NullString - metadataURL sql.NullString - metadata []byte + appID sql.NullString + entityID sql.NullString + metadataURL sql.NullString + metadata []byte + loginVersion sql.NullInt16 + loginBaseURI sql.NullString } func (c sqlSAMLConfig) set(app *App) { @@ -1220,9 +1204,13 @@ func (c sqlSAMLConfig) set(app *App) { return } app.SAMLConfig = &SAMLApp{ - MetadataURL: c.metadataURL.String, - Metadata: c.metadata, - EntityID: c.entityID.String, + EntityID: c.entityID.String, + MetadataURL: c.metadataURL.String, + Metadata: c.metadata, + LoginVersion: domain.LoginVersion(c.loginVersion.Int16), + } + if c.loginBaseURI.Valid { + app.SAMLConfig.LoginBaseURI = &c.loginBaseURI.String } } diff --git a/internal/query/app_test.go b/internal/query/app_test.go index ea9444f665..dbbcaef47c 100644 --- a/internal/query/app_test.go +++ b/internal/query/app_test.go @@ -56,7 +56,9 @@ var ( ` projections.apps7_saml_configs.app_id,` + ` projections.apps7_saml_configs.entity_id,` + ` projections.apps7_saml_configs.metadata,` + - ` projections.apps7_saml_configs.metadata_url` + + ` projections.apps7_saml_configs.metadata_url,` + + ` projections.apps7_saml_configs.login_version,` + + ` projections.apps7_saml_configs.login_base_uri` + ` FROM projections.apps7` + ` LEFT JOIN projections.apps7_api_configs ON projections.apps7.id = projections.apps7_api_configs.app_id AND projections.apps7.instance_id = projections.apps7_api_configs.instance_id` + ` LEFT JOIN projections.apps7_oidc_configs ON projections.apps7.id = projections.apps7_oidc_configs.app_id AND projections.apps7.instance_id = projections.apps7_oidc_configs.instance_id` + @@ -103,6 +105,8 @@ var ( ` projections.apps7_saml_configs.entity_id,` + ` projections.apps7_saml_configs.metadata,` + ` projections.apps7_saml_configs.metadata_url,` + + ` projections.apps7_saml_configs.login_version,` + + ` projections.apps7_saml_configs.login_base_uri,` + ` COUNT(*) OVER ()` + ` FROM projections.apps7` + ` LEFT JOIN projections.apps7_api_configs ON projections.apps7.id = projections.apps7_api_configs.app_id AND projections.apps7.instance_id = projections.apps7_api_configs.instance_id` + @@ -178,6 +182,8 @@ var ( "entity_id", "metadata", "metadata_url", + "login_version", + "login_base_uri", } appsCols = append(appCols, "count") ) @@ -252,6 +258,8 @@ func Test_AppsPrepare(t *testing.T) { nil, nil, nil, + nil, + nil, }, }, ), @@ -321,6 +329,8 @@ func Test_AppsPrepare(t *testing.T) { nil, nil, nil, + nil, + nil, }, }, ), @@ -393,6 +403,8 @@ func Test_AppsPrepare(t *testing.T) { "https://test.com/saml/metadata", []byte("\n\n \n urn:oasis:names:tc:SAML:1.1:nameid-format:unspecified\n \n \n \n"), "https://test.com/saml/metadata", + domain.LoginVersionUnspecified, + nil, }, }, ), @@ -467,6 +479,8 @@ func Test_AppsPrepare(t *testing.T) { nil, nil, nil, + nil, + nil, }, }, ), @@ -559,6 +573,8 @@ func Test_AppsPrepare(t *testing.T) { nil, nil, nil, + nil, + nil, }, }, ), @@ -651,6 +667,8 @@ func Test_AppsPrepare(t *testing.T) { nil, nil, nil, + nil, + nil, }, }, ), @@ -743,6 +761,8 @@ func Test_AppsPrepare(t *testing.T) { nil, nil, nil, + nil, + nil, }, }, ), @@ -835,6 +855,8 @@ func Test_AppsPrepare(t *testing.T) { nil, nil, nil, + nil, + nil, }, }, ), @@ -927,6 +949,8 @@ func Test_AppsPrepare(t *testing.T) { nil, nil, nil, + nil, + nil, }, }, ), @@ -1019,6 +1043,8 @@ func Test_AppsPrepare(t *testing.T) { nil, nil, nil, + nil, + nil, }, { "api-app-id", @@ -1059,6 +1085,8 @@ func Test_AppsPrepare(t *testing.T) { nil, nil, nil, + nil, + nil, }, { "saml-app-id", @@ -1099,6 +1127,8 @@ func Test_AppsPrepare(t *testing.T) { "https://test.com/saml/metadata", []byte("\n\n \n urn:oasis:names:tc:SAML:1.1:nameid-format:unspecified\n \n \n \n"), "https://test.com/saml/metadata", + domain.LoginVersion2, + "https://login.ch/", }, }, ), @@ -1165,9 +1195,11 @@ func Test_AppsPrepare(t *testing.T) { Name: "app-name", ProjectID: "project-id", SAMLConfig: &SAMLApp{ - Metadata: []byte("\n\n \n urn:oasis:names:tc:SAML:1.1:nameid-format:unspecified\n \n \n \n"), - MetadataURL: "https://test.com/saml/metadata", - EntityID: "https://test.com/saml/metadata", + Metadata: []byte("\n\n \n urn:oasis:names:tc:SAML:1.1:nameid-format:unspecified\n \n \n \n"), + MetadataURL: "https://test.com/saml/metadata", + EntityID: "https://test.com/saml/metadata", + LoginVersion: domain.LoginVersion2, + LoginBaseURI: gu.Ptr("https://login.ch/"), }, }, }, @@ -1280,6 +1312,8 @@ func Test_AppPrepare(t *testing.T) { nil, nil, nil, + nil, + nil, }, ), }, @@ -1343,6 +1377,8 @@ func Test_AppPrepare(t *testing.T) { nil, nil, nil, + nil, + nil, }, }, ), @@ -1411,6 +1447,8 @@ func Test_AppPrepare(t *testing.T) { nil, nil, nil, + nil, + nil, }, }, ), @@ -1498,6 +1536,8 @@ func Test_AppPrepare(t *testing.T) { nil, nil, nil, + nil, + nil, }, }, ), @@ -1585,6 +1625,8 @@ func Test_AppPrepare(t *testing.T) { "https://test.com/saml/metadata", []byte("\n\n \n urn:oasis:names:tc:SAML:1.1:nameid-format:unspecified\n \n \n \n"), "https://test.com/saml/metadata", + domain.LoginVersionUnspecified, + nil, }, }, ), @@ -1599,9 +1641,11 @@ func Test_AppPrepare(t *testing.T) { Name: "app-name", ProjectID: "project-id", SAMLConfig: &SAMLApp{ - Metadata: []byte("\n\n \n urn:oasis:names:tc:SAML:1.1:nameid-format:unspecified\n \n \n \n"), - MetadataURL: "https://test.com/saml/metadata", - EntityID: "https://test.com/saml/metadata", + Metadata: []byte("\n\n \n urn:oasis:names:tc:SAML:1.1:nameid-format:unspecified\n \n \n \n"), + MetadataURL: "https://test.com/saml/metadata", + EntityID: "https://test.com/saml/metadata", + LoginVersion: domain.LoginVersionUnspecified, + LoginBaseURI: nil, }, }, }, @@ -1654,6 +1698,8 @@ func Test_AppPrepare(t *testing.T) { nil, nil, nil, + nil, + nil, }, }, ), @@ -1741,6 +1787,8 @@ func Test_AppPrepare(t *testing.T) { nil, nil, nil, + nil, + nil, }, }, ), @@ -1828,6 +1876,8 @@ func Test_AppPrepare(t *testing.T) { nil, nil, nil, + nil, + nil, }, }, ), @@ -1915,6 +1965,8 @@ func Test_AppPrepare(t *testing.T) { nil, nil, nil, + nil, + nil, }, }, ), diff --git a/internal/query/oidc_client_test.go b/internal/query/oidc_client_test.go index bb0890bff3..25e069da85 100644 --- a/internal/query/oidc_client_test.go +++ b/internal/query/oidc_client_test.go @@ -4,6 +4,7 @@ import ( "database/sql" "database/sql/driver" _ "embed" + "net/url" "regexp" "testing" @@ -19,6 +20,8 @@ import ( var ( //go:embed testdata/oidc_client_jwt.json testdataOidcClientJWT string + //go:embed testdata/oidc_client_jwt_loginversion.json + testdataOidcClientJWTLoginVersion string //go:embed testdata/oidc_client_public.json testdataOidcClientPublic string //go:embed testdata/oidc_client_public_old_id.json @@ -91,6 +94,44 @@ low2kyJov38V4Uk2I8kuXpLcnrpw5Tio2ooiUE27b0vHZqBKOei9Uo88qCrn3EKx }, }, }, + { + name: "jwt client, login version", + mock: mockQuery(expQuery, cols, []driver.Value{testdataOidcClientJWTLoginVersion}, "instanceID", "clientID", true), + want: &OIDCClient{ + InstanceID: "230690539048009730", + AppID: "236647088211886082", + State: domain.AppStateActive, + ClientID: "236647088211951618", + HashedSecret: "", + RedirectURIs: []string{"http://localhost:9999/auth/callback"}, + ResponseTypes: []domain.OIDCResponseType{domain.OIDCResponseTypeCode}, + GrantTypes: []domain.OIDCGrantType{domain.OIDCGrantTypeAuthorizationCode, domain.OIDCGrantTypeRefreshToken}, + ApplicationType: domain.OIDCApplicationTypeWeb, + AuthMethodType: domain.OIDCAuthMethodTypePrivateKeyJWT, + PostLogoutRedirectURIs: []string{"https://example.com/logout"}, + IsDevMode: true, + AccessTokenType: domain.OIDCTokenTypeJWT, + AccessTokenRoleAssertion: true, + IDTokenRoleAssertion: true, + IDTokenUserinfoAssertion: true, + ClockSkew: 1000000000, + AdditionalOrigins: []string{"https://example.com"}, + ProjectID: "236645808328409090", + ProjectRoleAssertion: true, + PublicKeys: map[string][]byte{"236647201860747266": []byte(pubkey)}, + ProjectRoleKeys: []string{"role1", "role2"}, + Settings: &OIDCSettings{ + AccessTokenLifetime: 43200000000000, + IdTokenLifetime: 43200000000000, + }, + LoginVersion: domain.LoginVersion1, + LoginBaseURI: func() *URL { + ret, _ := url.Parse("https://test.com/login") + retURL := URL(*ret) + return &retURL + }(), + }, + }, { name: "public client", mock: mockQuery(expQuery, cols, []driver.Value{testdataOidcClientPublic}, "instanceID", "clientID", true), diff --git a/internal/query/projection/app.go b/internal/query/projection/app.go index 14053cc8dc..c50bf03f40 100644 --- a/internal/query/projection/app.go +++ b/internal/query/projection/app.go @@ -62,12 +62,14 @@ const ( AppOIDCConfigColumnLoginVersion = "login_version" AppOIDCConfigColumnLoginBaseURI = "login_base_uri" - appSAMLTableSuffix = "saml_configs" - AppSAMLConfigColumnAppID = "app_id" - AppSAMLConfigColumnInstanceID = "instance_id" - AppSAMLConfigColumnEntityID = "entity_id" - AppSAMLConfigColumnMetadata = "metadata" - AppSAMLConfigColumnMetadataURL = "metadata_url" + appSAMLTableSuffix = "saml_configs" + AppSAMLConfigColumnAppID = "app_id" + AppSAMLConfigColumnInstanceID = "instance_id" + AppSAMLConfigColumnEntityID = "entity_id" + AppSAMLConfigColumnMetadata = "metadata" + AppSAMLConfigColumnMetadataURL = "metadata_url" + AppSAMLConfigColumnLoginVersion = "login_version" + AppSAMLConfigColumnLoginBaseURI = "login_base_uri" ) type appProjection struct{} @@ -143,6 +145,8 @@ func (*appProjection) Init() *old_handler.Check { handler.NewColumn(AppSAMLConfigColumnEntityID, handler.ColumnTypeText), handler.NewColumn(AppSAMLConfigColumnMetadata, handler.ColumnTypeBytes), handler.NewColumn(AppSAMLConfigColumnMetadataURL, handler.ColumnTypeText), + handler.NewColumn(AppSAMLConfigColumnLoginVersion, handler.ColumnTypeEnum, handler.Nullable()), + handler.NewColumn(AppSAMLConfigColumnLoginBaseURI, handler.ColumnTypeText, handler.Nullable()), }, handler.NewPrimaryKey(AppSAMLConfigColumnInstanceID, AppSAMLConfigColumnAppID), appSAMLTableSuffix, @@ -703,6 +707,8 @@ func (p *appProjection) reduceSAMLConfigAdded(event eventstore.Event) (*handler. handler.NewCol(AppSAMLConfigColumnEntityID, e.EntityID), handler.NewCol(AppSAMLConfigColumnMetadata, e.Metadata), handler.NewCol(AppSAMLConfigColumnMetadataURL, e.MetadataURL), + handler.NewCol(AppSAMLConfigColumnLoginVersion, e.LoginVersion), + handler.NewCol(AppSAMLConfigColumnLoginBaseURI, e.LoginBaseURI), }, handler.WithTableSuffix(appSAMLTableSuffix), ), @@ -735,6 +741,12 @@ func (p *appProjection) reduceSAMLConfigChanged(event eventstore.Event) (*handle if e.EntityID != "" { cols = append(cols, handler.NewCol(AppSAMLConfigColumnEntityID, e.EntityID)) } + if e.LoginVersion != nil { + cols = append(cols, handler.NewCol(AppSAMLConfigColumnLoginVersion, *e.LoginVersion)) + } + if e.LoginBaseURI != nil { + cols = append(cols, handler.NewCol(AppSAMLConfigColumnLoginBaseURI, *e.LoginBaseURI)) + } if len(cols) == 0 { return handler.NewNoOpStatement(e), nil diff --git a/internal/query/saml_sp.go b/internal/query/saml_sp.go new file mode 100644 index 0000000000..3682375d0b --- /dev/null +++ b/internal/query/saml_sp.go @@ -0,0 +1,104 @@ +package query + +import ( + "context" + "database/sql" + _ "embed" + "errors" + "net/url" + + "github.com/zitadel/zitadel/internal/api/authz" + "github.com/zitadel/zitadel/internal/domain" + "github.com/zitadel/zitadel/internal/telemetry/tracing" + "github.com/zitadel/zitadel/internal/zerrors" +) + +type SAMLServiceProvider struct { + InstanceID string `json:"instance_id,omitempty"` + AppID string `json:"app_id,omitempty"` + State domain.AppState `json:"state,omitempty"` + EntityID string `json:"entity_id,omitempty"` + Metadata []byte `json:"metadata,omitempty"` + MetadataURL string `json:"metadata_url,omitempty"` + ProjectID string `json:"project_id,omitempty"` + ProjectRoleAssertion bool `json:"project_role_assertion,omitempty"` + LoginVersion domain.LoginVersion `json:"login_version,omitempty"` + LoginBaseURI *url.URL `json:"login_base_uri,omitempty"` +} + +//go:embed saml_sp_by_id.sql +var samlSPQuery string + +func (q *Queries) ActiveSAMLServiceProviderByID(ctx context.Context, entityID string) (sp *SAMLServiceProvider, err error) { + ctx, span := tracing.NewSpan(ctx) + defer func() { span.EndWithError(err) }() + + err = q.client.QueryRowContext(ctx, func(row *sql.Row) error { + sp, err = scanSAMLServiceProviderByID(row) + return err + }, samlSPQuery, + authz.GetInstance(ctx).InstanceID(), + entityID, + ) + if errors.Is(err, sql.ErrNoRows) { + return nil, zerrors.ThrowNotFound(err, "QUERY-HeOcis2511", "Errors.App.NotFound") + } + if err != nil { + return nil, zerrors.ThrowInternal(err, "QUERY-OyJx1Rp30z", "Errors.Internal") + } + instance := authz.GetInstance(ctx) + loginV2 := instance.Features().LoginV2 + if loginV2.Required { + sp.LoginVersion = domain.LoginVersion2 + sp.LoginBaseURI = loginV2.BaseURI + } + return sp, err +} + +func scanSAMLServiceProviderByID(row *sql.Row) (*SAMLServiceProvider, error) { + var instanceID, appID, entityID, metadataURL, projectID sql.NullString + var projectRoleAssertion sql.NullBool + var metadata []byte + var state, loginVersion sql.NullInt16 + var loginBaseURI sql.NullString + + err := row.Scan( + &instanceID, + &appID, + &state, + &entityID, + &metadata, + &metadataURL, + &projectID, + &projectRoleAssertion, + &loginVersion, + &loginBaseURI, + ) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return nil, zerrors.ThrowNotFound(err, "QUERY-8cjj8ao6yY", "Errors.App.NotFound") + } + return nil, zerrors.ThrowInternal(err, "QUERY-1xzFD209Bp", "Errors.Internal") + } + sp := &SAMLServiceProvider{ + InstanceID: instanceID.String, + AppID: appID.String, + State: domain.AppState(state.Int16), + EntityID: entityID.String, + Metadata: metadata, + MetadataURL: metadataURL.String, + ProjectID: projectID.String, + ProjectRoleAssertion: projectRoleAssertion.Bool, + } + if loginVersion.Valid { + sp.LoginVersion = domain.LoginVersion(loginVersion.Int16) + } + if loginBaseURI.Valid && loginBaseURI.String != "" { + url, err := url.Parse(loginBaseURI.String) + if err != nil { + return nil, err + } + sp.LoginBaseURI = url + } + return sp, nil +} diff --git a/internal/query/saml_sp_by_id.sql b/internal/query/saml_sp_by_id.sql new file mode 100644 index 0000000000..ff877c7ab9 --- /dev/null +++ b/internal/query/saml_sp_by_id.sql @@ -0,0 +1,19 @@ +select c.instance_id, + c.app_id, + a.state, + c.entity_id, + c.metadata, + c.metadata_url, + a.project_id, + p.project_role_assertion, + c.login_version, + c.login_base_uri +from projections.apps7_saml_configs c + join projections.apps7 a + on a.id = c.app_id and a.instance_id = c.instance_id and a.state = 1 + join projections.projects4 p + on p.id = a.project_id and p.instance_id = a.instance_id and p.state = 1 + join projections.orgs1 o + on o.id = p.resource_owner and o.instance_id = c.instance_id and o.org_state = 1 +where c.instance_id = $1 + and c.entity_id = $2 diff --git a/internal/query/saml_sp_test.go b/internal/query/saml_sp_test.go new file mode 100644 index 0000000000..4aafd95de1 --- /dev/null +++ b/internal/query/saml_sp_test.go @@ -0,0 +1,123 @@ +package query + +import ( + "database/sql" + "database/sql/driver" + _ "embed" + "net/url" + "regexp" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/zitadel/zitadel/internal/api/authz" + "github.com/zitadel/zitadel/internal/database" + "github.com/zitadel/zitadel/internal/domain" + "github.com/zitadel/zitadel/internal/zerrors" +) + +func TestQueries_ActiveSAMLServiceProviderByID(t *testing.T) { + expQuery := regexp.QuoteMeta(samlSPQuery) + cols := []string{ + "instance_id", + "app_id", + "state", + "entity_id", + "metadata", + "metadata_url", + "project_id", + "project_role_assertion", + "login_version", + "login_base_uri", + } + + tests := []struct { + name string + mock sqlExpectation + want *SAMLServiceProvider + wantErr error + }{ + { + name: "no rows", + mock: mockQueryErr(expQuery, sql.ErrNoRows, "instanceID", "entityID"), + wantErr: zerrors.ThrowNotFound(sql.ErrNoRows, "QUERY-HeOcis2511", "Errors.App.NotFound"), + }, + { + name: "internal error", + mock: mockQueryErr(expQuery, sql.ErrConnDone, "instanceID", "entityID"), + wantErr: zerrors.ThrowInternal(sql.ErrConnDone, "QUERY-OyJx1Rp30z", "Errors.Internal"), + }, + { + name: "sp", + mock: mockQuery(expQuery, cols, []driver.Value{ + "230690539048009730", + "236647088211886082", + domain.AppStateActive, + "https://test.com/metadata", + "metadata", + "https://test.com/metadata", + "236645808328409090", + true, + domain.LoginVersionUnspecified, + "", + }, "instanceID", "entityID"), + want: &SAMLServiceProvider{ + InstanceID: "230690539048009730", + AppID: "236647088211886082", + State: domain.AppStateActive, + EntityID: "https://test.com/metadata", + Metadata: []byte("metadata"), + MetadataURL: "https://test.com/metadata", + ProjectID: "236645808328409090", + ProjectRoleAssertion: true, + }, + }, + { + name: "sp with loginversion", + mock: mockQuery(expQuery, cols, []driver.Value{ + "230690539048009730", + "236647088211886082", + domain.AppStateActive, + "https://test.com/metadata", + "metadata", + "https://test.com/metadata", + "236645808328409090", + true, + domain.LoginVersion2, + "https://test.com/login", + }, "instanceID", "entityID"), + want: &SAMLServiceProvider{ + InstanceID: "230690539048009730", + AppID: "236647088211886082", + State: domain.AppStateActive, + EntityID: "https://test.com/metadata", + Metadata: []byte("metadata"), + MetadataURL: "https://test.com/metadata", + ProjectID: "236645808328409090", + ProjectRoleAssertion: true, + LoginVersion: domain.LoginVersion2, + LoginBaseURI: func() *url.URL { + ret, _ := url.Parse("https://test.com/login") + return ret + }(), + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + execMock(t, tt.mock, func(db *sql.DB) { + q := &Queries{ + client: &database.DB{ + DB: db, + Database: &prepareDB{}, + }, + } + ctx := authz.NewMockContext("instanceID", "orgID", "loginClient") + got, err := q.ActiveSAMLServiceProviderByID(ctx, "entityID") + require.ErrorIs(t, err, tt.wantErr) + assert.Equal(t, tt.want, got) + }) + }) + } +} diff --git a/internal/query/testdata/oidc_client_jwt_loginversion.json b/internal/query/testdata/oidc_client_jwt_loginversion.json new file mode 100644 index 0000000000..7664c0abc1 --- /dev/null +++ b/internal/query/testdata/oidc_client_jwt_loginversion.json @@ -0,0 +1,32 @@ +{ + "instance_id": "230690539048009730", + "app_id": "236647088211886082", + "state": 1, + "client_id": "236647088211951618", + "client_secret": null, + "redirect_uris": ["http://localhost:9999/auth/callback"], + "response_types": [0], + "grant_types": [0, 2], + "application_type": 0, + "auth_method_type": 3, + "post_logout_redirect_uris": ["https://example.com/logout"], + "is_dev_mode": true, + "access_token_type": 1, + "access_token_role_assertion": true, + "id_token_role_assertion": true, + "id_token_userinfo_assertion": true, + "clock_skew": 1000000000, + "additional_origins": ["https://example.com"], + "project_id": "236645808328409090", + "project_role_assertion": true, + "project_role_keys": ["role1", "role2"], + "public_keys": { + "236647201860747266": "LS0tLS1CRUdJTiBSU0EgUFVCTElDIEtFWS0tLS0tCk1JSUJJakFOQmdrcWhraUc5dzBCQVFFRkFB\nT0NBUThBTUlJQkNnS0NBUUVBMnVmQUwxYjcyYkl5MWFyK1dzNmIKR29oSkpRRkI3ZGZSYXBEcWVx\nTThVa3A2Q1ZkUHpxL3BPejF2aUFxNTB5eldaSnJ5Risyd3NoRkFLR0Y5QTIvQgoyWWY5YkpYUFov\nS2JrRnJZVDNOVHZZRGt2bGFTVGw5bU1uenJVMjlzNDhGMVBUV0tmQitDM2FNc09FRzFCdWZWCnM2\nM3FGNG5yRVBqU2JobGpJY285RlpxNFhwcEl6aE1RMGZEZEEvK1h5Z0NKcXZ1YUwwTGliTTFLcmxV\nZG51NzEKWWVraFNKakVQbnZPaXNYSWs0SVh5d29HSU93dGp4a0R2Tkl0UXZhTVZsZHI0L2tiNnV2\nYmdkV3dxNUV3QlpYcQpsb3cya3lKb3YzOFY0VWsySThrdVhwTGNucnB3NVRpbzJvb2lVRTI3YjB2\nSFpxQktPZWk5VW84OHFDcm4zRUt4CjZRSURBUUFCCi0tLS0tRU5EIFJTQSBQVUJMSUMgS0VZLS0t\nLS0K" + }, + "settings": { + "access_token_lifetime": 43200000000000, + "id_token_lifetime": 43200000000000 + }, + "login_version": 1, + "login_base_uri": "https://test.com/login" +} diff --git a/internal/repository/project/oidc_config.go b/internal/repository/project/oidc_config.go index 8bc918afbe..dd7d3a85b6 100644 --- a/internal/repository/project/oidc_config.go +++ b/internal/repository/project/oidc_config.go @@ -384,13 +384,13 @@ func ChangeBackChannelLogoutURI(backChannelLogoutURI string) func(event *OIDCCon } } -func ChangeLoginVersion(loginVersion domain.LoginVersion) func(event *OIDCConfigChangedEvent) { +func ChangeOIDCLoginVersion(loginVersion domain.LoginVersion) func(event *OIDCConfigChangedEvent) { return func(e *OIDCConfigChangedEvent) { e.LoginVersion = &loginVersion } } -func ChangeLoginBaseURI(loginBaseURI string) func(event *OIDCConfigChangedEvent) { +func ChangeOIDCLoginBaseURI(loginBaseURI string) func(event *OIDCConfigChangedEvent) { return func(e *OIDCConfigChangedEvent) { e.LoginBaseURI = &loginBaseURI } diff --git a/internal/repository/project/saml_config.go b/internal/repository/project/saml_config.go index 97af24a0d9..ddcb9c0eab 100644 --- a/internal/repository/project/saml_config.go +++ b/internal/repository/project/saml_config.go @@ -3,6 +3,7 @@ package project import ( "context" + "github.com/zitadel/zitadel/internal/domain" "github.com/zitadel/zitadel/internal/eventstore" "github.com/zitadel/zitadel/internal/zerrors" ) @@ -16,10 +17,12 @@ const ( type SAMLConfigAddedEvent struct { eventstore.BaseEvent `json:"-"` - AppID string `json:"appId"` - EntityID string `json:"entityId"` - Metadata []byte `json:"metadata,omitempty"` - MetadataURL string `json:"metadata_url,omitempty"` + AppID string `json:"appId"` + EntityID string `json:"entityId"` + Metadata []byte `json:"metadata,omitempty"` + MetadataURL string `json:"metadata_url,omitempty"` + LoginVersion domain.LoginVersion `json:"loginVersion,omitempty"` + LoginBaseURI string `json:"loginBaseURI,omitempty"` } func (e *SAMLConfigAddedEvent) Payload() interface{} { @@ -50,6 +53,8 @@ func NewSAMLConfigAddedEvent( entityID string, metadata []byte, metadataURL string, + loginVersion domain.LoginVersion, + loginBaseURI string, ) *SAMLConfigAddedEvent { return &SAMLConfigAddedEvent{ BaseEvent: *eventstore.NewBaseEventForPush( @@ -57,10 +62,12 @@ func NewSAMLConfigAddedEvent( aggregate, SAMLConfigAddedType, ), - AppID: appID, - EntityID: entityID, - Metadata: metadata, - MetadataURL: metadataURL, + AppID: appID, + EntityID: entityID, + Metadata: metadata, + MetadataURL: metadataURL, + LoginVersion: loginVersion, + LoginBaseURI: loginBaseURI, } } @@ -80,11 +87,13 @@ func SAMLConfigAddedEventMapper(event eventstore.Event) (eventstore.Event, error type SAMLConfigChangedEvent struct { eventstore.BaseEvent `json:"-"` - AppID string `json:"appId"` - EntityID string `json:"entityId"` - Metadata []byte `json:"metadata,omitempty"` - MetadataURL *string `json:"metadata_url,omitempty"` - oldEntityID string + AppID string `json:"appId"` + EntityID string `json:"entityId"` + Metadata []byte `json:"metadata,omitempty"` + MetadataURL *string `json:"metadata_url,omitempty"` + LoginVersion *domain.LoginVersion `json:"loginVersion,omitempty"` + LoginBaseURI *string `json:"loginBaseURI,omitempty"` + oldEntityID string } func (e *SAMLConfigChangedEvent) Payload() interface{} { @@ -147,6 +156,17 @@ func ChangeEntityID(entityID string) func(event *SAMLConfigChangedEvent) { } } +func ChangeSAMLLoginVersion(loginVersion domain.LoginVersion) func(event *SAMLConfigChangedEvent) { + return func(e *SAMLConfigChangedEvent) { + e.LoginVersion = &loginVersion + } +} +func ChangeSAMLLoginBaseURI(loginBaseURI string) func(event *SAMLConfigChangedEvent) { + return func(e *SAMLConfigChangedEvent) { + e.LoginBaseURI = &loginBaseURI + } +} + func SAMLConfigChangedEventMapper(event eventstore.Event) (eventstore.Event, error) { e := &SAMLConfigChangedEvent{ BaseEvent: *eventstore.BaseEventFromRepo(event), diff --git a/proto/zitadel/app.proto b/proto/zitadel/app.proto index 999e71cabf..08359e3762 100644 --- a/proto/zitadel/app.proto +++ b/proto/zitadel/app.proto @@ -222,6 +222,11 @@ message SAMLConfig { bytes metadata_xml = 1; string metadata_url = 2; } + LoginVersion login_version = 3 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + description: "Specify the preferred login UI, where the user is redirected to for authentication. If unset, the login UI is chosen by the instance default."; + } + ]; } enum APIAuthMethodType { diff --git a/proto/zitadel/management.proto b/proto/zitadel/management.proto index 94de141a65..5444983396 100644 --- a/proto/zitadel/management.proto +++ b/proto/zitadel/management.proto @@ -9850,6 +9850,11 @@ message AddSAMLAppRequest { bytes metadata_xml = 3 [(validate.rules).bytes.max_len = 500000]; string metadata_url = 4 [(validate.rules).string.max_len = 200]; } + zitadel.app.v1.LoginVersion login_version = 5 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + description: "Specify the preferred login UI, where the user is redirected to for authentication. If unset, the login UI is chosen by the instance default."; + } + ]; } message AddSAMLAppResponse { @@ -10014,6 +10019,11 @@ message UpdateSAMLAppConfigRequest { bytes metadata_xml = 3 [(validate.rules).bytes.max_len = 500000]; string metadata_url = 4 [(validate.rules).string.max_len = 200]; } + zitadel.app.v1.LoginVersion login_version = 5 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + description: "Specify the preferred login UI, where the user is redirected to for authentication. If unset, the login UI is chosen by the instance default."; + } + ]; } message UpdateSAMLAppConfigResponse { @@ -13653,7 +13663,7 @@ message SetTriggerActionsRequest { * - Internal Authentication: 3 * - Complement Token: 2 * - Complement SAML Response: 4 - */ + */ string flow_type = 1 [ (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { example: "\"1\""; @@ -13664,11 +13674,11 @@ message SetTriggerActionsRequest { * - External Authentication: * - Post Authentication: TRIGGER_TYPE_POST_AUTHENTICATION or 1 * - Pre Creation: TRIGGER_TYPE_PRE_CREATION or 2 - * - Post Creation: TRIGGER_TYPE_POST_CREATION or 3 + * - Post Creation: TRIGGER_TYPE_POST_CREATION or 3 * - Internal Authentication: * - Post Authentication: TRIGGER_TYPE_POST_AUTHENTICATION or 1 * - Pre Creation: TRIGGER_TYPE_PRE_CREATION or 2 - * - Post Creation: TRIGGER_TYPE_POST_CREATION or 3 + * - Post Creation: TRIGGER_TYPE_POST_CREATION or 3 * - Complement Token: * - Pre Userinfo Creation: 4 * - Pre Access Token Creation: 5 From 7c96dcd9a2a5e174f9bd38d361f3e30447d94747 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fabienne=20B=C3=BChler?= Date: Fri, 14 Feb 2025 11:48:16 +0100 Subject: [PATCH 011/169] docs: update readme with features and new login gif (#9357) # Which Problems Are Solved SCIM 2.0 Server was not listed in the readme of Zitadel New Login was not listed # How the Problems Are Solved Added scim 2.0 as a feature to the list Added new login, including a gif to showcase --- README.md | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 592952cdc2..5d4aecf441 100644 --- a/README.md +++ b/README.md @@ -45,6 +45,7 @@ We provide you with a wide range of out-of-the-box features to accelerate your p :white_check_mark: LDAP :white_check_mark: Passkeys / FIDO2 :white_check_mark: OTP +:white_check_mark: SCIM 2.0 Server and an unlimited audit trail is there for you, ready to use. With ZITADEL, you are assured of a robust and customizable turnkey solution for all your authentication and authorization needs. @@ -124,6 +125,7 @@ Authentication - [Custom sessions](https://zitadel.com/docs/guides/integrate/login-ui/username-password) if you need to go beyond OIDC or SAML - [Machine-to-machine](https://zitadel.com/docs/guides/integrate/service-users/authenticate-service-users) with JWT profile, Personal Access Tokens (PAT), and Client Credentials - [Token exchange and impersonation](https://zitadel.com/docs/guides/integrate/token-exchange) +- [Beta: Hosted Login V2](https://zitadel.com/docs/guides/integrate/login/hosted-login#hosted-login-version-2-beta) our new login version 2.0 Multi-Tenancy @@ -137,10 +139,11 @@ Integration - [GRPC and REST APIs](https://zitadel.com/docs/apis/introduction) for every functionality and resource - [Actions](https://zitadel.com/docs/apis/actions/introduction) to call any API, send webhooks, adjust workflows, or customize tokens - [Role Based Access Control (RBAC)](https://zitadel.com/docs/guides/integrate/retrieve-user-roles) +- [SCIM 2.0 Server](https://zitadel.com/docs/apis/scim2) - [Examples and SDKs](https://zitadel.com/docs/sdk-examples/introduction) - [Audit Log and SOC/SIEM](https://zitadel.com/docs/guides/integrate/external-audit-log) - [User registration and onboarding](https://zitadel.com/docs/guides/integrate/onboarding) -- [Hosted and custom login user interface](https://zitadel.com/docs/guides/integrate/login-ui) +- [Hosted and custom login user interface](https://zitadel.com/docs/guides/integrate/login/login-users) Self-Service - [Self-registration](https://zitadel.com/docs/concepts/features/selfservice#registration) including verification @@ -187,6 +190,11 @@ Use [Console](https://zitadel.com/docs/guides/manage/console/overview) or our [A [![Console Showcase](https://user-images.githubusercontent.com/1366906/223663344-67038d5f-4415-4285-ab20-9a4d397e2138.gif)](http://www.youtube.com/watch?v=RPpHktAcCtk "Console Showcase") +### Login V2 + +Check out our new Login V2 version in our [typescript repository](https://github.com/zitadel/typescript) or in our [documentation](https://zitadel.com/docs/guides/integrate/login/hosted-login#hosted-login-version-2-beta) +[![New Login Showcase](https://github.com/user-attachments/assets/cb5c5212-128b-4dc9-b11d-cabfd3f73e26)] + ## Security You can find our security policy [here](./SECURITY.md). From 0cb03808269339bbc65d9fdb2e3d7dfa54cf5305 Mon Sep 17 00:00:00 2001 From: Iraq <66622793+kkrime@users.noreply.github.com> Date: Mon, 17 Feb 2025 09:55:28 +0000 Subject: [PATCH 012/169] feat: updating eventstore.permitted_orgs sql function (#9309) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit # Which Problems Are Solved Performance issue for GRPC call `zitadel.user.v2.UserService.ListUsers` due to lack of org filtering on `ListUsers` # 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 https://github.com/zitadel/zitadel/issues/9191 --------- Co-authored-by: Iraq Jaber Co-authored-by: Tim Möhlmann --- cmd/setup/49.go | 39 ++++++++++++++ cmd/setup/49/01-permitted_orgs_function.sql | 56 +++++++++++++++++++++ cmd/setup/config.go | 1 + cmd/setup/setup.go | 2 + internal/api/grpc/admin/export.go | 2 +- internal/api/grpc/admin/org.go | 2 +- internal/api/grpc/management/org.go | 2 +- internal/api/grpc/management/user.go | 5 +- internal/api/grpc/user/v2/query.go | 25 +++++---- internal/api/grpc/user/v2beta/query.go | 25 +++++---- internal/api/scim/resources/user.go | 2 +- internal/api/ui/login/login.go | 2 +- internal/query/permission.go | 8 +-- internal/query/user.go | 8 +-- 14 files changed, 143 insertions(+), 36 deletions(-) create mode 100644 cmd/setup/49.go create mode 100644 cmd/setup/49/01-permitted_orgs_function.sql diff --git a/cmd/setup/49.go b/cmd/setup/49.go new file mode 100644 index 0000000000..28bf797110 --- /dev/null +++ b/cmd/setup/49.go @@ -0,0 +1,39 @@ +package setup + +import ( + "context" + "embed" + "fmt" + + "github.com/zitadel/logging" + + "github.com/zitadel/zitadel/internal/database" + "github.com/zitadel/zitadel/internal/eventstore" +) + +type InitPermittedOrgsFunction struct { + eventstoreClient *database.DB +} + +var ( + //go:embed 49/*.sql + permittedOrgsFunction embed.FS +) + +func (mig *InitPermittedOrgsFunction) Execute(ctx context.Context, _ eventstore.Event) error { + statements, err := readStatements(permittedOrgsFunction, "49", "") + if err != nil { + return err + } + for _, stmt := range statements { + logging.WithFields("file", stmt.file, "migration", mig.String()).Info("execute statement") + if _, err := mig.eventstoreClient.ExecContext(ctx, stmt.query); err != nil { + return fmt.Errorf("%s %s: %w", mig.String(), stmt.file, err) + } + } + return nil +} + +func (*InitPermittedOrgsFunction) String() string { + return "49_init_permitted_orgs_function" +} diff --git a/cmd/setup/49/01-permitted_orgs_function.sql b/cmd/setup/49/01-permitted_orgs_function.sql new file mode 100644 index 0000000000..9f291c016b --- /dev/null +++ b/cmd/setup/49/01-permitted_orgs_function.sql @@ -0,0 +1,56 @@ +DROP FUNCTION IF EXISTS eventstore.permitted_orgs; + +CREATE OR REPLACE FUNCTION eventstore.permitted_orgs( + instanceId TEXT + , userId TEXT + , perm TEXT + , filter_orgs TEXT + + , org_ids OUT TEXT[] +) + LANGUAGE 'plpgsql' + STABLE +AS $$ +DECLARE + matched_roles TEXT[]; -- roles containing permission +BEGIN + SELECT array_agg(rp.role) INTO matched_roles + FROM eventstore.role_permissions rp + WHERE rp.instance_id = instanceId + AND rp.permission = perm; + + -- First try if the permission was granted thru an instance-level role + DECLARE + has_instance_permission bool; + BEGIN + SELECT true INTO has_instance_permission + FROM eventstore.instance_members im + WHERE im.role = ANY(matched_roles) + AND im.instance_id = instanceId + AND im.user_id = userId + LIMIT 1; + + IF has_instance_permission THEN + -- Return all organizations or only those in filter_orgs + SELECT array_agg(o.org_id) INTO org_ids + FROM eventstore.instance_orgs o + WHERE o.instance_id = instanceId + AND CASE WHEN filter_orgs != '' + THEN o.org_id IN (filter_orgs) + ELSE TRUE END; + RETURN; + END IF; + END; + + -- Return the organizations where permission were granted thru org-level roles + SELECT array_agg(org_id) INTO org_ids + FROM ( + SELECT DISTINCT om.org_id + FROM eventstore.org_members om + WHERE om.role = ANY(matched_roles) + AND om.instance_id = instanceID + AND om.user_id = userId + ); + RETURN; +END; +$$; diff --git a/cmd/setup/config.go b/cmd/setup/config.go index d782a32dd6..0153f7227f 100644 --- a/cmd/setup/config.go +++ b/cmd/setup/config.go @@ -137,6 +137,7 @@ type Steps struct { s46InitPermissionFunctions *InitPermissionFunctions s47FillMembershipFields *FillMembershipFields s48Apps7SAMLConfigsLoginVersion *Apps7SAMLConfigsLoginVersion + s49InitPermittedOrgsFunction *InitPermittedOrgsFunction } func MustNewSteps(v *viper.Viper) *Steps { diff --git a/cmd/setup/setup.go b/cmd/setup/setup.go index bfa289ab36..74b16355f3 100644 --- a/cmd/setup/setup.go +++ b/cmd/setup/setup.go @@ -174,6 +174,7 @@ func Setup(ctx context.Context, config *Config, steps *Steps, masterKey string) steps.s46InitPermissionFunctions = &InitPermissionFunctions{eventstoreClient: dbClient} steps.s47FillMembershipFields = &FillMembershipFields{eventstore: eventstoreClient} steps.s48Apps7SAMLConfigsLoginVersion = &Apps7SAMLConfigsLoginVersion{dbClient: dbClient} + steps.s49InitPermittedOrgsFunction = &InitPermittedOrgsFunction{eventstoreClient: dbClient} err = projection.Create(ctx, dbClient, eventstoreClient, config.Projections, nil, nil, nil) logging.OnError(err).Fatal("unable to start projections") @@ -238,6 +239,7 @@ func Setup(ctx context.Context, config *Config, steps *Steps, masterKey string) steps.s45CorrectProjectOwners, steps.s46InitPermissionFunctions, steps.s47FillMembershipFields, + steps.s49InitPermittedOrgsFunction, } { mustExecuteMigration(ctx, eventstoreClient, step, "migration failed") } diff --git a/internal/api/grpc/admin/export.go b/internal/api/grpc/admin/export.go index 68b6053c2c..da364909cb 100644 --- a/internal/api/grpc/admin/export.go +++ b/internal/api/grpc/admin/export.go @@ -554,7 +554,7 @@ func (s *Server) getUsers(ctx context.Context, org string, withPasswords bool, w if err != nil { return nil, nil, nil, nil, err } - users, err := s.query.SearchUsers(ctx, &query.UserSearchQueries{Queries: []query.SearchQuery{orgSearch}}, nil) + users, err := s.query.SearchUsers(ctx, &query.UserSearchQueries{Queries: []query.SearchQuery{orgSearch}}, org, nil) if err != nil { return nil, nil, nil, nil, err } diff --git a/internal/api/grpc/admin/org.go b/internal/api/grpc/admin/org.go index 934de1b570..f788bb5f5a 100644 --- a/internal/api/grpc/admin/org.go +++ b/internal/api/grpc/admin/org.go @@ -108,7 +108,7 @@ func (s *Server) getClaimedUserIDsOfOrgDomain(ctx context.Context, orgDomain str if err != nil { return nil, err } - users, err := s.query.SearchUsers(ctx, &query.UserSearchQueries{Queries: []query.SearchQuery{loginName}}, nil) + users, err := s.query.SearchUsers(ctx, &query.UserSearchQueries{Queries: []query.SearchQuery{loginName}}, "", nil) if err != nil { return nil, err } diff --git a/internal/api/grpc/management/org.go b/internal/api/grpc/management/org.go index d25d46d852..abc179a763 100644 --- a/internal/api/grpc/management/org.go +++ b/internal/api/grpc/management/org.go @@ -330,7 +330,7 @@ func (s *Server) getClaimedUserIDsOfOrgDomain(ctx context.Context, orgDomain, or } queries = append(queries, owner) } - users, err := s.query.SearchUsers(ctx, &query.UserSearchQueries{Queries: queries}, nil) + users, err := s.query.SearchUsers(ctx, &query.UserSearchQueries{Queries: queries}, orgID, nil) if err != nil { return nil, err } diff --git a/internal/api/grpc/management/user.go b/internal/api/grpc/management/user.go index dac651af81..17bca58993 100644 --- a/internal/api/grpc/management/user.go +++ b/internal/api/grpc/management/user.go @@ -64,11 +64,12 @@ func (s *Server) ListUsers(ctx context.Context, req *mgmt_pb.ListUsersRequest) ( return nil, err } - err = queries.AppendMyResourceOwnerQuery(authz.GetCtxData(ctx).OrgID) + orgID := authz.GetCtxData(ctx).OrgID + err = queries.AppendMyResourceOwnerQuery(orgID) if err != nil { return nil, err } - res, err := s.query.SearchUsers(ctx, queries, nil) + res, err := s.query.SearchUsers(ctx, queries, orgID, nil) if err != nil { return nil, err } diff --git a/internal/api/grpc/user/v2/query.go b/internal/api/grpc/user/v2/query.go index aeb17d5dcf..aec5367ded 100644 --- a/internal/api/grpc/user/v2/query.go +++ b/internal/api/grpc/user/v2/query.go @@ -29,11 +29,11 @@ func (s *Server) GetUserByID(ctx context.Context, req *user.GetUserByIDRequest) } func (s *Server) ListUsers(ctx context.Context, req *user.ListUsersRequest) (*user.ListUsersResponse, error) { - queries, err := listUsersRequestToModel(req) + queries, filterOrgId, err := listUsersRequestToModel(req) if err != nil { return nil, err } - res, err := s.query.SearchUsers(ctx, queries, s.checkPermission) + res, err := s.query.SearchUsers(ctx, queries, filterOrgId, s.checkPermission) if err != nil { return nil, err } @@ -169,11 +169,11 @@ func accessTokenTypeToPb(accessTokenType domain.OIDCTokenType) user.AccessTokenT } } -func listUsersRequestToModel(req *user.ListUsersRequest) (*query.UserSearchQueries, error) { +func listUsersRequestToModel(req *user.ListUsersRequest) (*query.UserSearchQueries, string, error) { offset, limit, asc := object.ListQueryToQuery(req.Query) - queries, err := userQueriesToQuery(req.Queries, 0 /*start from level 0*/) + queries, filterOrgId, err := userQueriesToQuery(req.Queries, 0 /*start from level 0*/) if err != nil { - return nil, err + return nil, "", err } return &query.UserSearchQueries{ SearchRequest: query.SearchRequest{ @@ -183,7 +183,7 @@ func listUsersRequestToModel(req *user.ListUsersRequest) (*query.UserSearchQueri SortingColumn: userFieldNameToSortingColumn(req.SortingColumn), }, Queries: queries, - }, nil + }, filterOrgId, nil } func userFieldNameToSortingColumn(field user.UserFieldName) query.Column { @@ -213,15 +213,18 @@ func userFieldNameToSortingColumn(field user.UserFieldName) query.Column { } } -func userQueriesToQuery(queries []*user.SearchQuery, level uint8) (_ []query.SearchQuery, err error) { +func userQueriesToQuery(queries []*user.SearchQuery, level uint8) (_ []query.SearchQuery, filterOrgId string, err error) { q := make([]query.SearchQuery, len(queries)) for i, query := range queries { + if orgFilter := query.GetOrganizationIdQuery(); orgFilter != nil { + filterOrgId = orgFilter.OrganizationId + } q[i], err = userQueryToQuery(query, level) if err != nil { - return nil, err + return nil, filterOrgId, err } } - return q, nil + return q, filterOrgId, nil } func userQueryToQuery(query *user.SearchQuery, level uint8) (query.SearchQuery, error) { @@ -315,14 +318,14 @@ func inUserIdsQueryToQuery(q *user.InUserIDQuery) (query.SearchQuery, error) { return query.NewUserInUserIdsSearchQuery(q.UserIds) } func orQueryToQuery(q *user.OrQuery, level uint8) (query.SearchQuery, error) { - mappedQueries, err := userQueriesToQuery(q.Queries, level+1) + mappedQueries, _, err := userQueriesToQuery(q.Queries, level+1) if err != nil { return nil, err } return query.NewUserOrSearchQuery(mappedQueries) } func andQueryToQuery(q *user.AndQuery, level uint8) (query.SearchQuery, error) { - mappedQueries, err := userQueriesToQuery(q.Queries, level+1) + mappedQueries, _, err := userQueriesToQuery(q.Queries, level+1) if err != nil { return nil, err } diff --git a/internal/api/grpc/user/v2beta/query.go b/internal/api/grpc/user/v2beta/query.go index e3602abc33..7baa53e73e 100644 --- a/internal/api/grpc/user/v2beta/query.go +++ b/internal/api/grpc/user/v2beta/query.go @@ -29,11 +29,11 @@ func (s *Server) GetUserByID(ctx context.Context, req *user.GetUserByIDRequest) } func (s *Server) ListUsers(ctx context.Context, req *user.ListUsersRequest) (*user.ListUsersResponse, error) { - queries, err := listUsersRequestToModel(req) + queries, filterOrgIds, err := listUsersRequestToModel(req) if err != nil { return nil, err } - res, err := s.query.SearchUsers(ctx, queries, s.checkPermission) + res, err := s.query.SearchUsers(ctx, queries, filterOrgIds, s.checkPermission) if err != nil { return nil, err } @@ -165,11 +165,11 @@ func accessTokenTypeToPb(accessTokenType domain.OIDCTokenType) user.AccessTokenT } } -func listUsersRequestToModel(req *user.ListUsersRequest) (*query.UserSearchQueries, error) { +func listUsersRequestToModel(req *user.ListUsersRequest) (*query.UserSearchQueries, string, error) { offset, limit, asc := object.ListQueryToQuery(req.Query) - queries, err := userQueriesToQuery(req.Queries, 0 /*start from level 0*/) + queries, filterOrgId, err := userQueriesToQuery(req.Queries, 0 /*start from level 0*/) if err != nil { - return nil, err + return nil, "", err } return &query.UserSearchQueries{ SearchRequest: query.SearchRequest{ @@ -179,7 +179,7 @@ func listUsersRequestToModel(req *user.ListUsersRequest) (*query.UserSearchQueri SortingColumn: userFieldNameToSortingColumn(req.SortingColumn), }, Queries: queries, - }, nil + }, filterOrgId, nil } func userFieldNameToSortingColumn(field user.UserFieldName) query.Column { @@ -209,15 +209,18 @@ func userFieldNameToSortingColumn(field user.UserFieldName) query.Column { } } -func userQueriesToQuery(queries []*user.SearchQuery, level uint8) (_ []query.SearchQuery, err error) { +func userQueriesToQuery(queries []*user.SearchQuery, level uint8) (_ []query.SearchQuery, filterOrgId string, err error) { q := make([]query.SearchQuery, len(queries)) for i, query := range queries { + if orgFilter := query.GetOrganizationIdQuery(); orgFilter != nil { + filterOrgId = orgFilter.OrganizationId + } q[i], err = userQueryToQuery(query, level) if err != nil { - return nil, err + return nil, filterOrgId, err } } - return q, nil + return q, filterOrgId, nil } func userQueryToQuery(query *user.SearchQuery, level uint8) (query.SearchQuery, error) { @@ -311,14 +314,14 @@ func inUserIdsQueryToQuery(q *user.InUserIDQuery) (query.SearchQuery, error) { return query.NewUserInUserIdsSearchQuery(q.UserIds) } func orQueryToQuery(q *user.OrQuery, level uint8) (query.SearchQuery, error) { - mappedQueries, err := userQueriesToQuery(q.Queries, level+1) + mappedQueries, _, err := userQueriesToQuery(q.Queries, level+1) if err != nil { return nil, err } return query.NewUserOrSearchQuery(mappedQueries) } func andQueryToQuery(q *user.AndQuery, level uint8) (query.SearchQuery, error) { - mappedQueries, err := userQueriesToQuery(q.Queries, level+1) + mappedQueries, _, err := userQueriesToQuery(q.Queries, level+1) if err != nil { return nil, err } diff --git a/internal/api/scim/resources/user.go b/internal/api/scim/resources/user.go index bc8d864994..ffd39aa23f 100644 --- a/internal/api/scim/resources/user.go +++ b/internal/api/scim/resources/user.go @@ -240,7 +240,7 @@ func (h *UsersHandler) List(ctx context.Context, request *ListRequest) (*ListRes return NewListResponse(count, q.SearchRequest, make([]*ScimUser, 0)), nil } - users, err := h.query.SearchUsers(ctx, q, nil) + users, err := h.query.SearchUsers(ctx, q, authz.GetCtxData(ctx).OrgID, nil) if err != nil { return nil, err } diff --git a/internal/api/ui/login/login.go b/internal/api/ui/login/login.go index 444c5aaa85..4b028a347f 100644 --- a/internal/api/ui/login/login.go +++ b/internal/api/ui/login/login.go @@ -182,7 +182,7 @@ func (l *Login) getClaimedUserIDsOfOrgDomain(ctx context.Context, orgName string if err != nil { return nil, err } - users, err := l.query.SearchUsers(ctx, &query.UserSearchQueries{Queries: []query.SearchQuery{loginName}}, nil) + users, err := l.query.SearchUsers(ctx, &query.UserSearchQueries{Queries: []query.SearchQuery{loginName}}, "", nil) if err != nil { return nil, err } diff --git a/internal/query/permission.go b/internal/query/permission.go index 96d7db6c6a..591493375e 100644 --- a/internal/query/permission.go +++ b/internal/query/permission.go @@ -11,8 +11,8 @@ import ( ) const ( - // eventstore.permitted_orgs(instanceid text, userid text, perm text) - wherePermittedOrgsClause = "%s = ANY(eventstore.permitted_orgs(?, ?, ?))" + // eventstore.permitted_orgs(instanceid text, userid text, perm text, filter_orgs text) + wherePermittedOrgsClause = "%s = ANY(eventstore.permitted_orgs(?, ?, ?, ?))" ) // wherePermittedOrgs sets a `WHERE` clause to the query that filters the orgs @@ -23,13 +23,15 @@ const ( // and is typically the `resource_owner` column in ZITADEL. // We use full identifiers in the query builder so this function should be // called with something like `UserResourceOwnerCol.identifier()` for example. -func wherePermittedOrgs(ctx context.Context, query sq.SelectBuilder, orgIDColumn, permission string) sq.SelectBuilder { +func wherePermittedOrgs(ctx context.Context, query sq.SelectBuilder, filterOrgIds, orgIDColumn, permission string) sq.SelectBuilder { userID := authz.GetCtxData(ctx).UserID logging.WithFields("permission_check_v2_flag", authz.GetFeatures(ctx).PermissionCheckV2, "org_id_column", orgIDColumn, "permission", permission, "user_id", userID).Debug("permitted orgs check used") + return query.Where( fmt.Sprintf(wherePermittedOrgsClause, orgIDColumn), authz.GetInstance(ctx).InstanceID(), userID, permission, + filterOrgIds, ) } diff --git a/internal/query/user.go b/internal/query/user.go index bb76e51f66..0b00b45e03 100644 --- a/internal/query/user.go +++ b/internal/query/user.go @@ -635,8 +635,8 @@ func (q *Queries) CountUsers(ctx context.Context, queries *UserSearchQueries) (c return count, err } -func (q *Queries) SearchUsers(ctx context.Context, queries *UserSearchQueries, permissionCheck domain.PermissionCheck) (*Users, error) { - users, err := q.searchUsers(ctx, queries, permissionCheck != nil && authz.GetFeatures(ctx).PermissionCheckV2) +func (q *Queries) SearchUsers(ctx context.Context, queries *UserSearchQueries, filterOrgIds string, permissionCheck domain.PermissionCheck) (*Users, error) { + users, err := q.searchUsers(ctx, queries, filterOrgIds, permissionCheck != nil && authz.GetFeatures(ctx).PermissionCheckV2) if err != nil { return nil, err } @@ -646,7 +646,7 @@ func (q *Queries) SearchUsers(ctx context.Context, queries *UserSearchQueries, p return users, nil } -func (q *Queries) searchUsers(ctx context.Context, queries *UserSearchQueries, permissionCheckV2 bool) (users *Users, err error) { +func (q *Queries) searchUsers(ctx context.Context, queries *UserSearchQueries, filterOrgIds string, permissionCheckV2 bool) (users *Users, err error) { ctx, span := tracing.NewSpan(ctx) defer func() { span.EndWithError(err) }() @@ -655,7 +655,7 @@ func (q *Queries) searchUsers(ctx context.Context, queries *UserSearchQueries, p UserInstanceIDCol.identifier(): authz.GetInstance(ctx).InstanceID(), }) if permissionCheckV2 { - query = wherePermittedOrgs(ctx, query, UserResourceOwnerCol.identifier(), domain.PermissionUserRead) + query = wherePermittedOrgs(ctx, query, filterOrgIds, UserResourceOwnerCol.identifier(), domain.PermissionUserRead) } stmt, args, err := query.ToSql() From ad225836d5ace60927ff9429e276a86480af1357 Mon Sep 17 00:00:00 2001 From: Stefan Benz <46600784+stebenz@users.noreply.github.com> Date: Mon, 17 Feb 2025 17:06:55 +0100 Subject: [PATCH 013/169] chore: deprecated skip-dirs move to exclude-dirs (#9370) Moved the deprecated skip-dirs option to the exclude-dirs --- .golangci.yaml | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/.golangci.yaml b/.golangci.yaml index f480eb8c10..1cae359605 100644 --- a/.golangci.yaml +++ b/.golangci.yaml @@ -4,12 +4,7 @@ issues: max-issues-per-linter: 0 # Set to 0 to disable. max-same-issues: 0 - -run: - concurrency: 4 - timeout: 10m - go: '1.22' - skip-dirs: + exclude-dirs: - .artifacts - .backups - .codecov @@ -25,6 +20,11 @@ run: - openapi - proto - tools + +run: + concurrency: 4 + timeout: 10m + go: '1.22' linters: enable: # Simple linter to check that your code does not contain non-ASCII identifiers [fast: true, auto-fix: false] From 3042bbb9932a9c9ce76a1c5b6039fbf3c334260a Mon Sep 17 00:00:00 2001 From: Ramon Date: Mon, 17 Feb 2025 19:25:46 +0100 Subject: [PATCH 014/169] feat: Use V2 API's in Console (#9312) # Which Problems Are Solved Solves #8976 # Additional Changes I have done some intensive refactorings and we are using the new @zitadel/client package for GRPC access. # Additional Context - Closes #8976 --------- Co-authored-by: Max Peintner --- console/angular.json | 2 +- console/package.json | 4 + console/src/app/app.component.html | 2 +- console/src/app/app.component.ts | 26 +- .../features/features.component.html | 28 + .../components/features/features.component.ts | 15 +- .../directives/has-role/has-role.directive.ts | 15 +- .../accounts-card/accounts-card.component.ts | 5 +- .../app/modules/footer/footer.component.html | 4 +- .../app/modules/header/header.component.html | 6 +- .../modules/info-row/info-row.component.html | 12 +- .../modules/info-row/info-row.component.ts | 97 ++- .../metadata-dialog.component.ts | 85 +- .../metadata/metadata/metadata.component.html | 9 +- .../metadata/metadata/metadata.component.ts | 41 +- .../modules/paginator/paginator.component.ts | 3 +- .../refresh-table/refresh-table.component.ts | 3 +- .../app/modules/sidenav/sidenav.component.ts | 20 +- .../pages/signedout/signedout.component.ts | 23 +- .../user-create/user-create.component.html | 49 +- .../user-create/user-create.component.scss | 1 + .../user-create/user-create.component.ts | 458 +++++++---- .../auth-user-detail.component.html | 329 ++++---- .../auth-user-detail.component.ts | 722 ++++++++-------- .../edit-dialog/edit-dialog.component.html | 2 +- .../edit-dialog/edit-dialog.component.ts | 22 +- .../resend-email-dialog.component.html | 2 +- .../resend-email-dialog.component.ts | 14 +- .../contact/contact.component.html | 10 +- .../user-detail/contact/contact.component.ts | 22 +- .../detail-form-machine.component.html | 2 +- .../detail-form-machine.component.ts | 94 ++- .../detail-form/detail-form.component.html | 22 +- .../detail-form/detail-form.component.ts | 172 ++-- .../external-idps/external-idps.component.ts | 98 ++- .../password/password.component.html | 30 +- .../password/password.component.ts | 310 ++++--- .../passwordless/passwordless.component.html | 8 +- .../passwordless/passwordless.component.ts | 58 +- .../user-detail/user-detail.component.html | 531 ++++++------ .../user-detail/user-detail.component.ts | 768 ++++++++++-------- .../user-mfa/user-mfa.component.html | 38 +- .../user-mfa/user-mfa.component.ts | 206 ++--- .../user-table/user-table.component.html | 56 +- .../user-table/user-table.component.ts | 152 ++-- .../timestamp-to-date.pipe.ts | 10 +- console/src/app/services/feature.service.ts | 14 +- console/src/app/services/grpc-auth.service.ts | 245 +++--- console/src/app/services/grpc.service.ts | 162 ++-- .../services/interceptors/auth.interceptor.ts | 120 +-- .../exhausted.grpc.interceptor.ts | 18 +- .../services/interceptors/i18n.interceptor.ts | 10 +- .../services/interceptors/org.interceptor.ts | 26 +- console/src/app/services/new-auth.service.ts | 30 + console/src/app/services/new-mgmt.service.ts | 92 +++ console/src/app/services/user.service.ts | 302 +++++++ console/src/app/utils/formatPhone.ts | 2 +- console/src/app/utils/pairwiseStartWith.ts | 11 + console/src/assets/i18n/bg.json | 15 +- console/src/assets/i18n/cs.json | 15 +- console/src/assets/i18n/de.json | 15 +- console/src/assets/i18n/en.json | 15 +- console/src/assets/i18n/es.json | 15 +- console/src/assets/i18n/fr.json | 15 +- console/src/assets/i18n/hu.json | 15 +- console/src/assets/i18n/id.json | 15 +- console/src/assets/i18n/it.json | 15 +- console/src/assets/i18n/ja.json | 15 +- console/src/assets/i18n/ko.json | 15 +- console/src/assets/i18n/mk.json | 15 +- console/src/assets/i18n/nl.json | 15 +- console/src/assets/i18n/pl.json | 15 +- console/src/assets/i18n/pt.json | 15 +- console/src/assets/i18n/ru.json | 15 +- console/src/assets/i18n/sv.json | 15 +- console/src/assets/i18n/zh.json | 15 +- console/yarn.lock | 44 + e2e/cypress/e2e/machines/machines.cy.ts | 4 +- internal/api/grpc/feature/v2/converter.go | 2 + .../api/grpc/feature/v2/converter_test.go | 10 + internal/command/instance_features.go | 3 +- internal/command/instance_features_model.go | 5 + internal/feature/feature.go | 2 + internal/feature/key_enumer.go | 12 +- internal/query/instance_features.go | 1 + internal/query/instance_features_model.go | 3 + .../query/projection/instance_features.go | 4 + .../feature/feature_v2/eventstore.go | 1 + .../repository/feature/feature_v2/feature.go | 1 + proto/zitadel/feature/v2/instance.proto | 14 + 90 files changed, 3679 insertions(+), 2315 deletions(-) create mode 100644 console/src/app/services/new-auth.service.ts create mode 100644 console/src/app/services/new-mgmt.service.ts create mode 100644 console/src/app/services/user.service.ts create mode 100644 console/src/app/utils/pairwiseStartWith.ts diff --git a/console/angular.json b/console/angular.json index 278498ccd7..5564b2c428 100644 --- a/console/angular.json +++ b/console/angular.json @@ -63,7 +63,7 @@ { "type": "initial", "maximumWarning": "8mb", - "maximumError": "9mb" + "maximumError": "10mb" }, { "type": "anyComponentStyle", diff --git a/console/package.json b/console/package.json index fcf3a4bbf8..2c1d38da1b 100644 --- a/console/package.json +++ b/console/package.json @@ -24,6 +24,8 @@ "@angular/platform-browser-dynamic": "^16.2.5", "@angular/router": "^16.2.5", "@angular/service-worker": "^16.2.5", + "@connectrpc/connect": "^2.0.0", + "@connectrpc/connect-web": "^2.0.0", "@ctrl/ngx-codemirror": "^6.1.0", "@fortawesome/angular-fontawesome": "^0.13.0", "@fortawesome/fontawesome-svg-core": "^6.4.2", @@ -31,6 +33,8 @@ "@grpc/grpc-js": "^1.11.2", "@netlify/framework-info": "^9.8.13", "@ngx-translate/core": "^15.0.0", + "@zitadel/client": "^1.0.6", + "@zitadel/proto": "^1.0.3", "angular-oauth2-oidc": "^15.0.1", "angularx-qrcode": "^16.0.0", "buffer": "^6.0.3", diff --git a/console/src/app/app.component.html b/console/src/app/app.component.html index 5b31b33dc4..18c5c72501 100644 --- a/console/src/app/app.component.html +++ b/console/src/app/app.component.html @@ -1,5 +1,5 @@

- + { // We use navigateByUrl as our urls may have queryParams - this.router.navigateByUrl(currentUrl); + this.router.navigateByUrl(currentUrl).then(); }); } @@ -283,18 +283,16 @@ export class AppComponent implements OnDestroy { this.translate.addLangs(supportedLanguages); this.translate.setDefaultLang(fallbackLanguage); - this.authService.userSubject.pipe(takeUntil(this.destroy$)).subscribe((userprofile) => { - if (userprofile) { - const cropped = navigator.language.split('-')[0] ?? fallbackLanguage; - const fallbackLang = cropped.match(supportedLanguagesRegexp) ? cropped : fallbackLanguage; + this.authService.user.pipe(filter(Boolean), takeUntil(this.destroy$)).subscribe((userprofile) => { + const cropped = navigator.language.split('-')[0] ?? fallbackLanguage; + const fallbackLang = cropped.match(supportedLanguagesRegexp) ? cropped : fallbackLanguage; - const lang = userprofile?.human?.profile?.preferredLanguage.match(supportedLanguagesRegexp) - ? userprofile.human.profile?.preferredLanguage - : fallbackLang; - this.translate.use(lang); - this.language = lang; - this.document.documentElement.lang = lang; - } + const lang = userprofile?.human?.profile?.preferredLanguage.match(supportedLanguagesRegexp) + ? userprofile.human.profile?.preferredLanguage + : fallbackLang; + this.translate.use(lang); + this.language = lang; + this.document.documentElement.lang = lang; }); } @@ -308,7 +306,7 @@ export class AppComponent implements OnDestroy { } private setFavicon(theme: string): void { - this.authService.labelpolicy.pipe(takeUntil(this.destroy$)).subscribe((lP) => { + this.authService.labelpolicy$.pipe(startWith(undefined), takeUntil(this.destroy$)).subscribe((lP) => { if (theme === 'dark-theme' && lP?.iconUrlDark) { // Check if asset url is stable, maybe it was deleted but still wasn't applied fetch(lP.iconUrlDark).then((response) => { diff --git a/console/src/app/components/features/features.component.html b/console/src/app/components/features/features.component.html index e663569210..fdd397084a 100644 --- a/console/src/app/components/features/features.component.html +++ b/console/src/app/components/features/features.component.html @@ -403,6 +403,34 @@ 'SETTING.FEATURES.OIDCSINGLEV1SESSIONTERMINATION_DESCRIPTION' | translate }}
+ +
+ {{ 'SETTING.FEATURES.CONSOLEUSEV2USERAPI' | translate }} +
+ + +
+ {{ 'SETTING.FEATURES.STATES.DISABLED' | translate }} +
+
+ +
+ {{ 'SETTING.FEATURES.STATES.ENABLED' | translate }} +
+
+
+
+ {{ + 'SETTING.FEATURES.CONSOLEUSEV2USERAPI_DESCRIPTION' | translate + }} +
diff --git a/console/src/app/components/features/features.component.ts b/console/src/app/components/features/features.component.ts index 327e9d2792..0f8ff761f6 100644 --- a/console/src/app/components/features/features.component.ts +++ b/console/src/app/components/features/features.component.ts @@ -16,13 +16,14 @@ import { InfoSectionModule } from 'src/app/modules/info-section/info-section.mod import { HasRolePipeModule } from 'src/app/pipes/has-role-pipe/has-role-pipe.module'; import { Event } from 'src/app/proto/generated/zitadel/event_pb'; import { Source } from 'src/app/proto/generated/zitadel/feature/v2beta/feature_pb'; -import { - GetInstanceFeaturesResponse, - SetInstanceFeaturesRequest, -} from 'src/app/proto/generated/zitadel/feature/v2beta/instance_pb'; import { Breadcrumb, BreadcrumbService, BreadcrumbType } from 'src/app/services/breadcrumb.service'; import { FeatureService } from 'src/app/services/feature.service'; import { ToastService } from 'src/app/services/toast.service'; +import { + GetInstanceFeaturesResponse, + SetInstanceFeaturesRequest, +} from '../../proto/generated/zitadel/feature/v2/instance_pb'; +import { withIdentifier } from 'codelyzer/util/astQuery'; enum ToggleState { ENABLED = 'ENABLED', @@ -39,6 +40,7 @@ type ToggleStates = { oidcTokenExchange?: FeatureState; actions?: FeatureState; oidcSingleV1SessionTermination?: FeatureState; + consoleUseV2UserApi?: FeatureState; }; @Component({ @@ -142,6 +144,7 @@ export class FeaturesComponent implements OnDestroy { ); changed = true; } + req.setConsoleUseV2UserApi(this.toggleStates?.consoleUseV2UserApi?.state === ToggleState.ENABLED); if (changed) { this.featureService @@ -232,6 +235,10 @@ export class FeaturesComponent implements OnDestroy { ? ToggleState.ENABLED : ToggleState.DISABLED, }, + consoleUseV2UserApi: { + source: this.featureData.consoleUseV2UserApi?.source || Source.SOURCE_INSTANCE, + state: this.featureData.consoleUseV2UserApi?.enabled ? ToggleState.ENABLED : ToggleState.DISABLED, + }, }; }); } diff --git a/console/src/app/directives/has-role/has-role.directive.ts b/console/src/app/directives/has-role/has-role.directive.ts index b58e1f3a10..9ba21c1dd2 100644 --- a/console/src/app/directives/has-role/has-role.directive.ts +++ b/console/src/app/directives/has-role/has-role.directive.ts @@ -1,18 +1,17 @@ -import { Directive, Input, OnDestroy, TemplateRef, ViewContainerRef } from '@angular/core'; -import { Subject, takeUntil } from 'rxjs'; +import { DestroyRef, Directive, Input, TemplateRef, ViewContainerRef } from '@angular/core'; import { GrpcAuthService } from 'src/app/services/grpc-auth.service'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; @Directive({ selector: '[cnslHasRole]', }) -export class HasRoleDirective implements OnDestroy { - private destroy$: Subject = new Subject(); +export class HasRoleDirective { private hasView: boolean = false; @Input() public set hasRole(roles: string[] | RegExp[] | undefined) { if (roles && roles.length > 0) { this.authService .isAllowed(roles) - .pipe(takeUntil(this.destroy$)) + .pipe(takeUntilDestroyed(this.destroyRef)) .subscribe((isAllowed) => { if (isAllowed && !this.hasView) { if (this.viewContainerRef.length !== 0) { @@ -38,10 +37,6 @@ export class HasRoleDirective implements OnDestroy { private authService: GrpcAuthService, protected templateRef: TemplateRef, protected viewContainerRef: ViewContainerRef, + private readonly destroyRef: DestroyRef, ) {} - - ngOnDestroy(): void { - this.destroy$.next(); - this.destroy$.complete(); - } } diff --git a/console/src/app/modules/accounts-card/accounts-card.component.ts b/console/src/app/modules/accounts-card/accounts-card.component.ts index 617a41bf6d..2676a5bcf5 100644 --- a/console/src/app/modules/accounts-card/accounts-card.component.ts +++ b/console/src/app/modules/accounts-card/accounts-card.component.ts @@ -4,6 +4,7 @@ import { AuthConfig } from 'angular-oauth2-oidc'; import { Session, User, UserState } from 'src/app/proto/generated/zitadel/user_pb'; import { AuthenticationService } from 'src/app/services/authentication.service'; import { GrpcAuthService } from 'src/app/services/grpc-auth.service'; +import { toSignal } from '@angular/core/rxjs-interop'; @Component({ selector: 'cnsl-accounts-card', @@ -18,6 +19,8 @@ export class AccountsCardComponent implements OnInit { public sessions: Session.AsObject[] = []; public loadingUsers: boolean = false; public UserState: any = UserState; + private labelpolicy = toSignal(this.userService.labelpolicy$, { initialValue: undefined }); + constructor( public authService: AuthenticationService, private router: Router, @@ -68,7 +71,7 @@ export class AccountsCardComponent implements OnInit { } public logout(): void { - const lP = JSON.stringify(this.userService.labelpolicy.getValue()); + const lP = JSON.stringify(this.labelpolicy()); localStorage.setItem('labelPolicyOnSignout', lP); this.authService.signout(); diff --git a/console/src/app/modules/footer/footer.component.html b/console/src/app/modules/footer/footer.component.html index 26d863d129..b9eda2d7db 100644 --- a/console/src/app/modules/footer/footer.component.html +++ b/console/src/app/modules/footer/footer.component.html @@ -1,6 +1,6 @@