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] 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 b81abc9fd..d6b5c7b8c 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 000000000..ccc37e37e --- /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 826c198fa..d1ddc35cc 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 1c83b504d..1d2a6d267 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 000000000..227f4f591 --- /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 04ffdbb34..66c4bee82 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 b70099fb2..1f227ab14 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 000000000..ab9e92a15 --- /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 de4f3440a..866846dfd 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 36e412be2..4b2eacf57 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 6d61e8443..43b000108 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 302c818c3..2ab78b972 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 91705aced..340155d11 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 3668b6563..590e4086f 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 e75392309..61d1abf6f 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 9d12ba6e4..2dfa8756c 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 18b1c2a39..ed7363e15 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 bf24c09e5..0ddf08a66 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 d18c2d9b1..cefaf0ef4 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 04cf95104..4d7f5277c 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 bf0424695..483543b32 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 925279026..116319537 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 b826bb52b..1aa0323a5 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 000000000..cf25e6763 --- /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 000000000..9f15668c2 --- /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;