From 2691dae2b6355d3e962be791db28b88d4b763f98 Mon Sep 17 00:00:00 2001 From: "Marco A." Date: Fri, 27 Jun 2025 17:25:44 +0200 Subject: [PATCH] feat: App API v2 (#10077) # Which Problems Are Solved This PR *partially* addresses #9450 . Specifically, it implements the resource based API for the apps. APIs for app keys ARE not part of this PR. # How the Problems Are Solved - `CreateApplication`, `PatchApplication` (update) and `RegenerateClientSecret` endpoints are now unique for all app types: API, SAML and OIDC apps. - All new endpoints have integration tests - All new endpoints are using permission checks V2 # Additional Changes - The `ListApplications` endpoint allows to do sorting (see protobuf for details) and filtering by app type (see protobuf). - SAML and OIDC update endpoint can now receive requests for partial updates # Additional Context Partially addresses #9450 --- cmd/start/start.go | 5 + internal/api/grpc/admin/export.go | 2 +- internal/api/grpc/app/v2beta/app.go | 208 +++ .../api/grpc/app/v2beta/convert/api_app.go | 60 + .../grpc/app/v2beta/convert/api_app_test.go | 149 ++ .../api/grpc/app/v2beta/convert/convert.go | 165 ++ .../grpc/app/v2beta/convert/convert_test.go | 520 ++++++ .../api/grpc/app/v2beta/convert/oidc_app.go | 291 ++++ .../grpc/app/v2beta/convert/oidc_app_test.go | 755 +++++++++ .../api/grpc/app/v2beta/convert/saml_app.go | 77 + .../grpc/app/v2beta/convert/saml_app_test.go | 256 +++ .../app/v2beta/integration_test/app_test.go | 1446 +++++++++++++++++ .../app/v2beta/integration_test/query_test.go | 575 +++++++ .../v2beta/integration_test/server_test.go | 205 +++ internal/api/grpc/app/v2beta/query.go | 37 + internal/api/grpc/app/v2beta/server.go | 57 + internal/api/grpc/filter/v2/converter.go | 23 + .../grpc/management/project_application.go | 10 +- .../project_application_converter.go | 64 +- .../eventsourcing/view/application.go | 2 +- internal/command/permission_checks.go | 8 + internal/command/project_application.go | 28 +- internal/command/project_application_api.go | 37 +- .../command/project_application_api_test.go | 21 +- internal/command/project_application_oidc.go | 71 +- .../command/project_application_oidc_model.go | 82 +- .../command/project_application_oidc_test.go | 279 ++-- internal/command/project_application_saml.go | 40 +- .../command/project_application_saml_model.go | 22 +- .../command/project_application_saml_test.go | 161 +- internal/command/project_application_test.go | 133 +- internal/command/project_converter.go | 43 +- internal/command/project_model.go | 12 +- internal/domain/application_oidc.go | 93 +- internal/domain/application_oidc_test.go | 57 +- internal/domain/application_saml.go | 13 +- internal/domain/permission.go | 3 + internal/integration/client.go | 3 + internal/project/model/oidc_config.go | 4 +- internal/query/app.go | 92 +- pkg/grpc/app/v2beta/application.go | 5 + proto/zitadel/app/v2beta/api.proto | 26 + proto/zitadel/app/v2beta/app.proto | 94 ++ proto/zitadel/app/v2beta/app_service.proto | 788 +++++++++ proto/zitadel/app/v2beta/login.proto | 18 + proto/zitadel/app/v2beta/oidc.proto | 166 ++ proto/zitadel/app/v2beta/saml.proto | 20 + proto/zitadel/management.proto | 222 +-- 48 files changed, 6845 insertions(+), 603 deletions(-) create mode 100644 internal/api/grpc/app/v2beta/app.go create mode 100644 internal/api/grpc/app/v2beta/convert/api_app.go create mode 100644 internal/api/grpc/app/v2beta/convert/api_app_test.go create mode 100644 internal/api/grpc/app/v2beta/convert/convert.go create mode 100644 internal/api/grpc/app/v2beta/convert/convert_test.go create mode 100644 internal/api/grpc/app/v2beta/convert/oidc_app.go create mode 100644 internal/api/grpc/app/v2beta/convert/oidc_app_test.go create mode 100644 internal/api/grpc/app/v2beta/convert/saml_app.go create mode 100644 internal/api/grpc/app/v2beta/convert/saml_app_test.go create mode 100644 internal/api/grpc/app/v2beta/integration_test/app_test.go create mode 100644 internal/api/grpc/app/v2beta/integration_test/query_test.go create mode 100644 internal/api/grpc/app/v2beta/integration_test/server_test.go create mode 100644 internal/api/grpc/app/v2beta/query.go create mode 100644 internal/api/grpc/app/v2beta/server.go create mode 100644 pkg/grpc/app/v2beta/application.go create mode 100644 proto/zitadel/app/v2beta/api.proto create mode 100644 proto/zitadel/app/v2beta/app.proto create mode 100644 proto/zitadel/app/v2beta/app_service.proto create mode 100644 proto/zitadel/app/v2beta/login.proto create mode 100644 proto/zitadel/app/v2beta/oidc.proto create mode 100644 proto/zitadel/app/v2beta/saml.proto diff --git a/cmd/start/start.go b/cmd/start/start.go index 3c3b5cb3e0..dbd6289041 100644 --- a/cmd/start/start.go +++ b/cmd/start/start.go @@ -36,6 +36,7 @@ import ( internal_authz "github.com/zitadel/zitadel/internal/api/authz" action_v2_beta "github.com/zitadel/zitadel/internal/api/grpc/action/v2beta" "github.com/zitadel/zitadel/internal/api/grpc/admin" + app "github.com/zitadel/zitadel/internal/api/grpc/app/v2beta" "github.com/zitadel/zitadel/internal/api/grpc/auth" feature_v2 "github.com/zitadel/zitadel/internal/api/grpc/feature/v2" feature_v2beta "github.com/zitadel/zitadel/internal/api/grpc/feature/v2beta" @@ -509,6 +510,10 @@ func startAPIs( if err := apis.RegisterService(ctx, debug_events.CreateServer(commands, queries)); err != nil { return nil, err } + if err := apis.RegisterService(ctx, app.CreateServer(commands, queries, permissionCheck)); err != nil { + return nil, err + } + instanceInterceptor := middleware.InstanceInterceptor(queries, config.ExternalDomain, login.IgnoreInstanceEndpoints...) assetsCache := middleware.AssetsCacheInterceptor(config.AssetStorage.Cache.MaxAge, config.AssetStorage.Cache.SharedMaxAge) apis.RegisterHandlerOnPrefix(assets.HandlerPrefix, assets.NewHandler(commands, verifier, config.SystemAuthZ, config.InternalAuthZ, id.SonyFlakeGenerator(), store, queries, middleware.CallDurationHandler, instanceInterceptor.Handler, assetsCache.Handler, limitingAccessInterceptor.Handle)) diff --git a/internal/api/grpc/admin/export.go b/internal/api/grpc/admin/export.go index b5d36272d4..8024cd9d6e 100644 --- a/internal/api/grpc/admin/export.go +++ b/internal/api/grpc/admin/export.go @@ -783,7 +783,7 @@ func (s *Server) getProjectsAndApps(ctx context.Context, org string) ([]*v1_pb.D if err != nil { return nil, nil, nil, nil, nil, err } - apps, err := s.query.SearchApps(ctx, &query.AppSearchQueries{Queries: []query.SearchQuery{appSearch}}, false) + apps, err := s.query.SearchApps(ctx, &query.AppSearchQueries{Queries: []query.SearchQuery{appSearch}}, nil) if err != nil { return nil, nil, nil, nil, nil, err } diff --git a/internal/api/grpc/app/v2beta/app.go b/internal/api/grpc/app/v2beta/app.go new file mode 100644 index 0000000000..48c602f454 --- /dev/null +++ b/internal/api/grpc/app/v2beta/app.go @@ -0,0 +1,208 @@ +package app + +import ( + "context" + "strings" + "time" + + "google.golang.org/protobuf/types/known/timestamppb" + + "github.com/zitadel/zitadel/internal/api/grpc/app/v2beta/convert" + "github.com/zitadel/zitadel/internal/domain" + "github.com/zitadel/zitadel/internal/zerrors" + app "github.com/zitadel/zitadel/pkg/grpc/app/v2beta" +) + +func (s *Server) CreateApplication(ctx context.Context, req *app.CreateApplicationRequest) (*app.CreateApplicationResponse, error) { + switch t := req.GetCreationRequestType().(type) { + case *app.CreateApplicationRequest_ApiRequest: + apiApp, err := s.command.AddAPIApplication(ctx, convert.CreateAPIApplicationRequestToDomain(req.GetName(), req.GetProjectId(), req.GetId(), t.ApiRequest), "") + if err != nil { + return nil, err + } + + return &app.CreateApplicationResponse{ + AppId: apiApp.AppID, + CreationDate: timestamppb.New(apiApp.ChangeDate), + CreationResponseType: &app.CreateApplicationResponse_ApiResponse{ + ApiResponse: &app.CreateAPIApplicationResponse{ + ClientId: apiApp.ClientID, + ClientSecret: apiApp.ClientSecretString, + }, + }, + }, nil + + case *app.CreateApplicationRequest_OidcRequest: + oidcAppRequest, err := convert.CreateOIDCAppRequestToDomain(req.GetName(), req.GetProjectId(), req.GetOidcRequest()) + if err != nil { + return nil, err + } + + oidcApp, err := s.command.AddOIDCApplication(ctx, oidcAppRequest, "") + if err != nil { + return nil, err + } + + return &app.CreateApplicationResponse{ + AppId: oidcApp.AppID, + CreationDate: timestamppb.New(oidcApp.ChangeDate), + CreationResponseType: &app.CreateApplicationResponse_OidcResponse{ + OidcResponse: &app.CreateOIDCApplicationResponse{ + ClientId: oidcApp.ClientID, + ClientSecret: oidcApp.ClientSecretString, + NoneCompliant: oidcApp.Compliance.NoneCompliant, + ComplianceProblems: convert.ComplianceProblemsToLocalizedMessages(oidcApp.Compliance.Problems), + }, + }, + }, nil + + case *app.CreateApplicationRequest_SamlRequest: + samlAppRequest, err := convert.CreateSAMLAppRequestToDomain(req.GetName(), req.GetProjectId(), req.GetSamlRequest()) + if err != nil { + return nil, err + } + + samlApp, err := s.command.AddSAMLApplication(ctx, samlAppRequest, "") + if err != nil { + return nil, err + } + + return &app.CreateApplicationResponse{ + AppId: samlApp.AppID, + CreationDate: timestamppb.New(samlApp.ChangeDate), + CreationResponseType: &app.CreateApplicationResponse_SamlResponse{ + SamlResponse: &app.CreateSAMLApplicationResponse{}, + }, + }, nil + default: + return nil, zerrors.ThrowInvalidArgument(nil, "APP-0iiN46", "unknown app type") + } +} + +func (s *Server) UpdateApplication(ctx context.Context, req *app.UpdateApplicationRequest) (*app.UpdateApplicationResponse, error) { + var changedTime time.Time + + if name := strings.TrimSpace(req.GetName()); name != "" { + updatedDetails, err := s.command.UpdateApplicationName( + ctx, + req.GetProjectId(), + &domain.ChangeApp{ + AppID: req.GetId(), + AppName: name, + }, + "", + ) + if err != nil { + return nil, err + } + + changedTime = updatedDetails.EventDate + } + + switch t := req.GetUpdateRequestType().(type) { + case *app.UpdateApplicationRequest_ApiConfigurationRequest: + updatedAPIApp, err := s.command.UpdateAPIApplication(ctx, convert.UpdateAPIApplicationConfigurationRequestToDomain(req.GetId(), req.GetProjectId(), t.ApiConfigurationRequest), "") + if err != nil { + return nil, err + } + + changedTime = updatedAPIApp.ChangeDate + + case *app.UpdateApplicationRequest_OidcConfigurationRequest: + oidcApp, err := convert.UpdateOIDCAppConfigRequestToDomain(req.GetId(), req.GetProjectId(), t.OidcConfigurationRequest) + if err != nil { + return nil, err + } + + updatedOIDCApp, err := s.command.UpdateOIDCApplication(ctx, oidcApp, "") + if err != nil { + return nil, err + } + + changedTime = updatedOIDCApp.ChangeDate + + case *app.UpdateApplicationRequest_SamlConfigurationRequest: + samlApp, err := convert.UpdateSAMLAppConfigRequestToDomain(req.GetId(), req.GetProjectId(), t.SamlConfigurationRequest) + if err != nil { + return nil, err + } + + updatedSAMLApp, err := s.command.UpdateSAMLApplication(ctx, samlApp, "") + if err != nil { + return nil, err + } + + changedTime = updatedSAMLApp.ChangeDate + } + + return &app.UpdateApplicationResponse{ + ChangeDate: timestamppb.New(changedTime), + }, nil +} + +func (s *Server) DeleteApplication(ctx context.Context, req *app.DeleteApplicationRequest) (*app.DeleteApplicationResponse, error) { + details, err := s.command.RemoveApplication(ctx, req.GetProjectId(), req.GetId(), "") + if err != nil { + return nil, err + } + + return &app.DeleteApplicationResponse{ + DeletionDate: timestamppb.New(details.EventDate), + }, nil +} + +func (s *Server) DeactivateApplication(ctx context.Context, req *app.DeactivateApplicationRequest) (*app.DeactivateApplicationResponse, error) { + details, err := s.command.DeactivateApplication(ctx, req.GetProjectId(), req.GetId(), "") + if err != nil { + return nil, err + } + + return &app.DeactivateApplicationResponse{ + DeactivationDate: timestamppb.New(details.EventDate), + }, nil + +} + +func (s *Server) ReactivateApplication(ctx context.Context, req *app.ReactivateApplicationRequest) (*app.ReactivateApplicationResponse, error) { + details, err := s.command.ReactivateApplication(ctx, req.GetProjectId(), req.GetId(), "") + if err != nil { + return nil, err + } + + return &app.ReactivateApplicationResponse{ + ReactivationDate: timestamppb.New(details.EventDate), + }, nil + +} + +func (s *Server) RegenerateClientSecret(ctx context.Context, req *app.RegenerateClientSecretRequest) (*app.RegenerateClientSecretResponse, error) { + var secret string + var changeDate time.Time + + switch req.GetAppType().(type) { + case *app.RegenerateClientSecretRequest_IsApi: + config, err := s.command.ChangeAPIApplicationSecret(ctx, req.GetProjectId(), req.GetApplicationId(), "") + if err != nil { + return nil, err + } + secret = config.ClientSecretString + changeDate = config.ChangeDate + + case *app.RegenerateClientSecretRequest_IsOidc: + config, err := s.command.ChangeOIDCApplicationSecret(ctx, req.GetProjectId(), req.GetApplicationId(), "") + if err != nil { + return nil, err + } + + secret = config.ClientSecretString + changeDate = config.ChangeDate + + default: + return nil, zerrors.ThrowInvalidArgument(nil, "APP-aLWIzw", "unknown app type") + } + + return &app.RegenerateClientSecretResponse{ + ClientSecret: secret, + CreationDate: timestamppb.New(changeDate), + }, nil +} diff --git a/internal/api/grpc/app/v2beta/convert/api_app.go b/internal/api/grpc/app/v2beta/convert/api_app.go new file mode 100644 index 0000000000..bad76ab0d5 --- /dev/null +++ b/internal/api/grpc/app/v2beta/convert/api_app.go @@ -0,0 +1,60 @@ +package convert + +import ( + "github.com/zitadel/zitadel/internal/domain" + "github.com/zitadel/zitadel/internal/eventstore/v1/models" + "github.com/zitadel/zitadel/internal/query" + app "github.com/zitadel/zitadel/pkg/grpc/app/v2beta" +) + +func CreateAPIApplicationRequestToDomain(name, projectID, appID string, app *app.CreateAPIApplicationRequest) *domain.APIApp { + return &domain.APIApp{ + ObjectRoot: models.ObjectRoot{ + AggregateID: projectID, + }, + AppName: name, + AppID: appID, + AuthMethodType: apiAuthMethodTypeToDomain(app.GetAuthMethodType()), + } +} + +func UpdateAPIApplicationConfigurationRequestToDomain(appID, projectID string, app *app.UpdateAPIApplicationConfigurationRequest) *domain.APIApp { + return &domain.APIApp{ + ObjectRoot: models.ObjectRoot{ + AggregateID: projectID, + }, + AppID: appID, + AuthMethodType: apiAuthMethodTypeToDomain(app.GetAuthMethodType()), + } +} + +func appAPIConfigToPb(apiApp *query.APIApp) app.ApplicationConfig { + return &app.Application_ApiConfig{ + ApiConfig: &app.APIConfig{ + ClientId: apiApp.ClientID, + AuthMethodType: apiAuthMethodTypeToPb(apiApp.AuthMethodType), + }, + } +} + +func apiAuthMethodTypeToDomain(authType app.APIAuthMethodType) domain.APIAuthMethodType { + switch authType { + case app.APIAuthMethodType_API_AUTH_METHOD_TYPE_BASIC: + return domain.APIAuthMethodTypeBasic + case app.APIAuthMethodType_API_AUTH_METHOD_TYPE_PRIVATE_KEY_JWT: + return domain.APIAuthMethodTypePrivateKeyJWT + default: + return domain.APIAuthMethodTypeBasic + } +} + +func apiAuthMethodTypeToPb(methodType domain.APIAuthMethodType) app.APIAuthMethodType { + switch methodType { + case domain.APIAuthMethodTypeBasic: + return app.APIAuthMethodType_API_AUTH_METHOD_TYPE_BASIC + case domain.APIAuthMethodTypePrivateKeyJWT: + return app.APIAuthMethodType_API_AUTH_METHOD_TYPE_PRIVATE_KEY_JWT + default: + return app.APIAuthMethodType_API_AUTH_METHOD_TYPE_BASIC + } +} diff --git a/internal/api/grpc/app/v2beta/convert/api_app_test.go b/internal/api/grpc/app/v2beta/convert/api_app_test.go new file mode 100644 index 0000000000..9f15c3df76 --- /dev/null +++ b/internal/api/grpc/app/v2beta/convert/api_app_test.go @@ -0,0 +1,149 @@ +package convert + +import ( + "testing" + + "github.com/stretchr/testify/assert" + + "github.com/zitadel/zitadel/internal/domain" + "github.com/zitadel/zitadel/internal/eventstore/v1/models" + app "github.com/zitadel/zitadel/pkg/grpc/app/v2beta" +) + +func TestCreateAPIApplicationRequestToDomain(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + appName string + projectID string + appID string + req *app.CreateAPIApplicationRequest + want *domain.APIApp + }{ + { + name: "basic auth method", + appName: "my-app", + projectID: "proj-1", + appID: "someID", + req: &app.CreateAPIApplicationRequest{ + AuthMethodType: app.APIAuthMethodType_API_AUTH_METHOD_TYPE_BASIC, + }, + want: &domain.APIApp{ + ObjectRoot: models.ObjectRoot{AggregateID: "proj-1"}, + AppName: "my-app", + AuthMethodType: domain.APIAuthMethodTypeBasic, + AppID: "someID", + }, + }, + { + name: "private key jwt", + appName: "jwt-app", + projectID: "proj-2", + req: &app.CreateAPIApplicationRequest{ + AuthMethodType: app.APIAuthMethodType_API_AUTH_METHOD_TYPE_PRIVATE_KEY_JWT, + }, + want: &domain.APIApp{ + ObjectRoot: models.ObjectRoot{AggregateID: "proj-2"}, + AppName: "jwt-app", + AuthMethodType: domain.APIAuthMethodTypePrivateKeyJWT, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + // When + got := CreateAPIApplicationRequestToDomain(tt.appName, tt.projectID, tt.appID, tt.req) + + // Then + assert.Equal(t, tt.want, got) + }) + } +} + +func TestUpdateAPIApplicationConfigurationRequestToDomain(t *testing.T) { + t.Parallel() + tests := []struct { + name string + appID string + projectID string + req *app.UpdateAPIApplicationConfigurationRequest + want *domain.APIApp + }{ + { + name: "basic auth method", + appID: "app-1", + projectID: "proj-1", + req: &app.UpdateAPIApplicationConfigurationRequest{ + AuthMethodType: app.APIAuthMethodType_API_AUTH_METHOD_TYPE_BASIC, + }, + want: &domain.APIApp{ + ObjectRoot: models.ObjectRoot{AggregateID: "proj-1"}, + AppID: "app-1", + AuthMethodType: domain.APIAuthMethodTypeBasic, + }, + }, + { + name: "private key jwt", + appID: "app-2", + projectID: "proj-2", + req: &app.UpdateAPIApplicationConfigurationRequest{ + AuthMethodType: app.APIAuthMethodType_API_AUTH_METHOD_TYPE_PRIVATE_KEY_JWT, + }, + want: &domain.APIApp{ + ObjectRoot: models.ObjectRoot{AggregateID: "proj-2"}, + AppID: "app-2", + AuthMethodType: domain.APIAuthMethodTypePrivateKeyJWT, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + // When + got := UpdateAPIApplicationConfigurationRequestToDomain(tt.appID, tt.projectID, tt.req) + + // Then + assert.Equal(t, tt.want, got) + }) + } +} + +func Test_apiAuthMethodTypeToPb(t *testing.T) { + t.Parallel() + + tt := []struct { + name string + methodType domain.APIAuthMethodType + expectedResult app.APIAuthMethodType + }{ + { + name: "basic auth method", + methodType: domain.APIAuthMethodTypeBasic, + expectedResult: app.APIAuthMethodType_API_AUTH_METHOD_TYPE_BASIC, + }, + { + name: "private key jwt", + methodType: domain.APIAuthMethodTypePrivateKeyJWT, + expectedResult: app.APIAuthMethodType_API_AUTH_METHOD_TYPE_PRIVATE_KEY_JWT, + }, + { + name: "unknown auth method defaults to basic", + expectedResult: app.APIAuthMethodType_API_AUTH_METHOD_TYPE_BASIC, + }, + } + for _, tc := range tt { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + // When + res := apiAuthMethodTypeToPb(tc.methodType) + + // Then + assert.Equal(t, tc.expectedResult, res) + }) + } +} diff --git a/internal/api/grpc/app/v2beta/convert/convert.go b/internal/api/grpc/app/v2beta/convert/convert.go new file mode 100644 index 0000000000..c732b3a0c5 --- /dev/null +++ b/internal/api/grpc/app/v2beta/convert/convert.go @@ -0,0 +1,165 @@ +package convert + +import ( + "net/url" + + "github.com/muhlemmer/gu" + "google.golang.org/protobuf/types/known/timestamppb" + + "github.com/zitadel/zitadel/internal/api/grpc/filter/v2" + "github.com/zitadel/zitadel/internal/config/systemdefaults" + "github.com/zitadel/zitadel/internal/domain" + "github.com/zitadel/zitadel/internal/query" + "github.com/zitadel/zitadel/internal/zerrors" + app "github.com/zitadel/zitadel/pkg/grpc/app/v2beta" +) + +func AppToPb(query_app *query.App) *app.Application { + if query_app == nil { + return &app.Application{} + } + + return &app.Application{ + Id: query_app.ID, + CreationDate: timestamppb.New(query_app.CreationDate), + ChangeDate: timestamppb.New(query_app.ChangeDate), + State: appStateToPb(query_app.State), + Name: query_app.Name, + Config: appConfigToPb(query_app), + } +} + +func AppsToPb(queryApps []*query.App) []*app.Application { + pbApps := make([]*app.Application, len(queryApps)) + + for i, queryApp := range queryApps { + pbApps[i] = AppToPb(queryApp) + } + + return pbApps +} + +func ListApplicationsRequestToModel(sysDefaults systemdefaults.SystemDefaults, req *app.ListApplicationsRequest) (*query.AppSearchQueries, error) { + offset, limit, asc, err := filter.PaginationPbToQuery(sysDefaults, req.GetPagination()) + if err != nil { + return nil, err + } + + queries, err := appQueriesToModel(req.GetFilters()) + if err != nil { + return nil, err + } + projectQuery, err := query.NewAppProjectIDSearchQuery(req.GetProjectId()) + if err != nil { + return nil, err + } + + queries = append(queries, projectQuery) + return &query.AppSearchQueries{ + SearchRequest: query.SearchRequest{ + Offset: offset, + Limit: limit, + Asc: asc, + SortingColumn: appSortingToColumn(req.GetSortingColumn()), + }, + + Queries: queries, + }, nil +} + +func appSortingToColumn(sortingCriteria app.AppSorting) query.Column { + switch sortingCriteria { + case app.AppSorting_APP_SORT_BY_CHANGE_DATE: + return query.AppColumnChangeDate + case app.AppSorting_APP_SORT_BY_CREATION_DATE: + return query.AppColumnCreationDate + case app.AppSorting_APP_SORT_BY_NAME: + return query.AppColumnName + case app.AppSorting_APP_SORT_BY_STATE: + return query.AppColumnState + case app.AppSorting_APP_SORT_BY_ID: + fallthrough + default: + return query.AppColumnID + } +} + +func appStateToPb(state domain.AppState) app.AppState { + switch state { + case domain.AppStateActive: + return app.AppState_APP_STATE_ACTIVE + case domain.AppStateInactive: + return app.AppState_APP_STATE_INACTIVE + case domain.AppStateRemoved: + return app.AppState_APP_STATE_REMOVED + case domain.AppStateUnspecified: + fallthrough + default: + return app.AppState_APP_STATE_UNSPECIFIED + } +} + +func appConfigToPb(app *query.App) app.ApplicationConfig { + if app.OIDCConfig != nil { + return appOIDCConfigToPb(app.OIDCConfig) + } + if app.SAMLConfig != nil { + return appSAMLConfigToPb(app.SAMLConfig) + } + return appAPIConfigToPb(app.APIConfig) +} + +func loginVersionToDomain(version *app.LoginVersion) (*domain.LoginVersion, *string, error) { + switch v := version.GetVersion().(type) { + case nil: + return gu.Ptr(domain.LoginVersionUnspecified), gu.Ptr(""), nil + case *app.LoginVersion_LoginV1: + return gu.Ptr(domain.LoginVersion1), gu.Ptr(""), nil + case *app.LoginVersion_LoginV2: + _, err := url.Parse(v.LoginV2.GetBaseUri()) + return gu.Ptr(domain.LoginVersion2), gu.Ptr(v.LoginV2.GetBaseUri()), err + default: + return gu.Ptr(domain.LoginVersionUnspecified), gu.Ptr(""), nil + } +} + +func loginVersionToPb(version domain.LoginVersion, baseURI *string) *app.LoginVersion { + switch version { + case domain.LoginVersionUnspecified: + return nil + case domain.LoginVersion1: + return &app.LoginVersion{Version: &app.LoginVersion_LoginV1{LoginV1: &app.LoginV1{}}} + case domain.LoginVersion2: + return &app.LoginVersion{Version: &app.LoginVersion_LoginV2{LoginV2: &app.LoginV2{BaseUri: baseURI}}} + default: + return nil + } +} + +func appQueriesToModel(queries []*app.ApplicationSearchFilter) (toReturn []query.SearchQuery, err error) { + toReturn = make([]query.SearchQuery, len(queries)) + for i, query := range queries { + toReturn[i], err = appQueryToModel(query) + if err != nil { + return nil, err + } + } + return toReturn, nil +} + +func appQueryToModel(appQuery *app.ApplicationSearchFilter) (query.SearchQuery, error) { + switch q := appQuery.GetFilter().(type) { + case *app.ApplicationSearchFilter_NameFilter: + return query.NewAppNameSearchQuery(filter.TextMethodPbToQuery(q.NameFilter.GetMethod()), q.NameFilter.Name) + case *app.ApplicationSearchFilter_StateFilter: + return query.NewAppStateSearchQuery(domain.AppState(q.StateFilter)) + case *app.ApplicationSearchFilter_ApiAppOnly: + return query.NewNotNullQuery(query.AppAPIConfigColumnAppID) + case *app.ApplicationSearchFilter_OidcAppOnly: + return query.NewNotNullQuery(query.AppOIDCConfigColumnAppID) + case *app.ApplicationSearchFilter_SamlAppOnly: + return query.NewNotNullQuery(query.AppSAMLConfigColumnAppID) + default: + return nil, zerrors.ThrowInvalidArgument(nil, "CONV-z2mAGy", "List.Query.Invalid") + } +} diff --git a/internal/api/grpc/app/v2beta/convert/convert_test.go b/internal/api/grpc/app/v2beta/convert/convert_test.go new file mode 100644 index 0000000000..5835691d43 --- /dev/null +++ b/internal/api/grpc/app/v2beta/convert/convert_test.go @@ -0,0 +1,520 @@ +package convert + +import ( + "errors" + "fmt" + "net/url" + "testing" + "time" + + "github.com/muhlemmer/gu" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "google.golang.org/protobuf/types/known/durationpb" + "google.golang.org/protobuf/types/known/timestamppb" + + filter "github.com/zitadel/zitadel/internal/api/grpc/filter/v2beta" + "github.com/zitadel/zitadel/internal/config/systemdefaults" + "github.com/zitadel/zitadel/internal/domain" + "github.com/zitadel/zitadel/internal/query" + "github.com/zitadel/zitadel/internal/zerrors" + app "github.com/zitadel/zitadel/pkg/grpc/app/v2beta" + filter_pb_v2 "github.com/zitadel/zitadel/pkg/grpc/filter/v2" + filter_pb_v2_beta "github.com/zitadel/zitadel/pkg/grpc/filter/v2beta" +) + +func TestAppToPb(t *testing.T) { + t.Parallel() + + now := time.Now() + + tt := []struct { + testName string + inputQueryApp *query.App + expectedPbApp *app.Application + }{ + { + testName: "full app conversion", + inputQueryApp: &query.App{ + ID: "id", + CreationDate: now, + ChangeDate: now, + State: domain.AppStateActive, + Name: "test-app", + APIConfig: &query.APIApp{}, + }, + expectedPbApp: &app.Application{ + Id: "id", + CreationDate: timestamppb.New(now), + ChangeDate: timestamppb.New(now), + State: app.AppState_APP_STATE_ACTIVE, + Name: "test-app", + Config: &app.Application_ApiConfig{ + ApiConfig: &app.APIConfig{}, + }, + }, + }, + { + testName: "nil app", + inputQueryApp: nil, + expectedPbApp: &app.Application{}, + }, + } + for _, tc := range tt { + t.Run(tc.testName, func(t *testing.T) { + t.Parallel() + + // When + res := AppToPb(tc.inputQueryApp) + + // Then + assert.Equal(t, tc.expectedPbApp, res) + }) + } +} + +func TestListApplicationsRequestToModel(t *testing.T) { + t.Parallel() + + validSearchByNameQuery, err := query.NewAppNameSearchQuery(filter.TextMethodPbToQuery(filter_pb_v2_beta.TextFilterMethod_TEXT_FILTER_METHOD_EQUALS), "test") + require.NoError(t, err) + + validSearchByProjectQuery, err := query.NewAppProjectIDSearchQuery("project1") + require.NoError(t, err) + + sysDefaults := systemdefaults.SystemDefaults{DefaultQueryLimit: 100, MaxQueryLimit: 150} + + tt := []struct { + testName string + req *app.ListApplicationsRequest + + expectedResponse *query.AppSearchQueries + expectedError error + }{ + { + testName: "invalid pagination limit", + req: &app.ListApplicationsRequest{ + Pagination: &filter_pb_v2.PaginationRequest{Asc: true, Limit: uint32(sysDefaults.MaxQueryLimit + 1)}, + }, + expectedResponse: nil, + expectedError: zerrors.ThrowInvalidArgumentf(fmt.Errorf("given: %d, allowed: %d", sysDefaults.MaxQueryLimit+1, sysDefaults.MaxQueryLimit), "QUERY-4M0fs", "Errors.Query.LimitExceeded"), + }, + { + testName: "empty request", + req: &app.ListApplicationsRequest{ + ProjectId: "project1", + Pagination: &filter_pb_v2.PaginationRequest{Asc: true}, + }, + expectedResponse: &query.AppSearchQueries{ + SearchRequest: query.SearchRequest{ + Offset: 0, + Limit: 100, + Asc: true, + SortingColumn: query.AppColumnID, + }, + Queries: []query.SearchQuery{ + validSearchByProjectQuery, + }, + }, + }, + { + testName: "valid request", + req: &app.ListApplicationsRequest{ + ProjectId: "project1", + Filters: []*app.ApplicationSearchFilter{ + { + Filter: &app.ApplicationSearchFilter_NameFilter{NameFilter: &app.ApplicationNameQuery{Name: "test"}}, + }, + }, + SortingColumn: app.AppSorting_APP_SORT_BY_NAME, + Pagination: &filter_pb_v2.PaginationRequest{Asc: true}, + }, + + expectedResponse: &query.AppSearchQueries{ + SearchRequest: query.SearchRequest{ + Offset: 0, + Limit: 100, + Asc: true, + SortingColumn: query.AppColumnName, + }, + Queries: []query.SearchQuery{ + validSearchByNameQuery, + validSearchByProjectQuery, + }, + }, + }, + } + + for _, tc := range tt { + t.Run(tc.testName, func(t *testing.T) { + t.Parallel() + + // When + got, err := ListApplicationsRequestToModel(sysDefaults, tc.req) + + // Then + assert.Equal(t, tc.expectedError, err) + assert.Equal(t, tc.expectedResponse, got) + }) + } +} + +func TestAppSortingToColumn(t *testing.T) { + t.Parallel() + + tt := []struct { + name string + sorting app.AppSorting + expected query.Column + }{ + { + name: "sort by change date", + sorting: app.AppSorting_APP_SORT_BY_CHANGE_DATE, + expected: query.AppColumnChangeDate, + }, + { + name: "sort by creation date", + sorting: app.AppSorting_APP_SORT_BY_CREATION_DATE, + expected: query.AppColumnCreationDate, + }, + { + name: "sort by name", + sorting: app.AppSorting_APP_SORT_BY_NAME, + expected: query.AppColumnName, + }, + { + name: "sort by state", + sorting: app.AppSorting_APP_SORT_BY_STATE, + expected: query.AppColumnState, + }, + { + name: "sort by ID", + sorting: app.AppSorting_APP_SORT_BY_ID, + expected: query.AppColumnID, + }, + { + name: "unknown sorting defaults to ID", + sorting: app.AppSorting(99), + expected: query.AppColumnID, + }, + } + + for _, tc := range tt { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + // When + result := appSortingToColumn(tc.sorting) + + // Then + assert.Equal(t, tc.expected, result) + }) + } +} + +func TestAppStateToPb(t *testing.T) { + t.Parallel() + + tt := []struct { + name string + state domain.AppState + expected app.AppState + }{ + { + name: "active state", + state: domain.AppStateActive, + expected: app.AppState_APP_STATE_ACTIVE, + }, + { + name: "inactive state", + state: domain.AppStateInactive, + expected: app.AppState_APP_STATE_INACTIVE, + }, + { + name: "removed state", + state: domain.AppStateRemoved, + expected: app.AppState_APP_STATE_REMOVED, + }, + { + name: "unspecified state", + state: domain.AppStateUnspecified, + expected: app.AppState_APP_STATE_UNSPECIFIED, + }, + { + name: "unknown state defaults to unspecified", + state: domain.AppState(99), + expected: app.AppState_APP_STATE_UNSPECIFIED, + }, + } + + for _, tc := range tt { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + // When + result := appStateToPb(tc.state) + + // Then + assert.Equal(t, tc.expected, result) + }) + } +} + +func TestAppConfigToPb(t *testing.T) { + t.Parallel() + + tt := []struct { + name string + app *query.App + expected app.ApplicationConfig + }{ + { + name: "OIDC config", + app: &query.App{ + OIDCConfig: &query.OIDCApp{}, + }, + expected: &app.Application_OidcConfig{ + OidcConfig: &app.OIDCConfig{ + ResponseTypes: []app.OIDCResponseType{}, + GrantTypes: []app.OIDCGrantType{}, + ComplianceProblems: []*app.OIDCLocalizedMessage{}, + ClockSkew: &durationpb.Duration{}, + }, + }, + }, + { + name: "SAML config", + app: &query.App{ + SAMLConfig: &query.SAMLApp{}, + }, + expected: &app.Application_SamlConfig{ + SamlConfig: &app.SAMLConfig{ + Metadata: &app.SAMLConfig_MetadataXml{}, + }, + }, + }, + { + name: "API config", + app: &query.App{ + APIConfig: &query.APIApp{}, + }, + expected: &app.Application_ApiConfig{ + ApiConfig: &app.APIConfig{}, + }, + }, + } + + for _, tc := range tt { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + // When + result := appConfigToPb(tc.app) + + // Then + assert.Equal(t, tc.expected, result) + }) + } +} + +func TestLoginVersionToDomain(t *testing.T) { + t.Parallel() + + tt := []struct { + name string + version *app.LoginVersion + expectedVer *domain.LoginVersion + expectedURI *string + expectedError error + }{ + { + name: "nil version", + version: nil, + expectedVer: gu.Ptr(domain.LoginVersionUnspecified), + expectedURI: gu.Ptr(""), + }, + { + name: "login v1", + version: &app.LoginVersion{Version: &app.LoginVersion_LoginV1{LoginV1: &app.LoginV1{}}}, + expectedVer: gu.Ptr(domain.LoginVersion1), + expectedURI: gu.Ptr(""), + }, + { + name: "login v2 valid URI", + version: &app.LoginVersion{Version: &app.LoginVersion_LoginV2{LoginV2: &app.LoginV2{BaseUri: gu.Ptr("https://valid.url")}}}, + expectedVer: gu.Ptr(domain.LoginVersion2), + expectedURI: gu.Ptr("https://valid.url"), + }, + { + name: "login v2 invalid URI", + version: &app.LoginVersion{Version: &app.LoginVersion_LoginV2{LoginV2: &app.LoginV2{BaseUri: gu.Ptr("://invalid")}}}, + expectedVer: gu.Ptr(domain.LoginVersion2), + expectedURI: gu.Ptr("://invalid"), + expectedError: &url.Error{Op: "parse", URL: "://invalid", Err: errors.New("missing protocol scheme")}, + }, + { + name: "unknown version type", + version: &app.LoginVersion{}, + expectedVer: gu.Ptr(domain.LoginVersionUnspecified), + expectedURI: gu.Ptr(""), + }, + } + + for _, tc := range tt { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + // When + version, uri, err := loginVersionToDomain(tc.version) + + // Then + assert.Equal(t, tc.expectedVer, version) + assert.Equal(t, tc.expectedURI, uri) + assert.Equal(t, tc.expectedError, err) + }) + } +} + +func TestLoginVersionToPb(t *testing.T) { + t.Parallel() + + tt := []struct { + name string + version domain.LoginVersion + baseURI *string + expected *app.LoginVersion + }{ + { + name: "unspecified version", + version: domain.LoginVersionUnspecified, + baseURI: nil, + expected: nil, + }, + { + name: "login v1", + version: domain.LoginVersion1, + baseURI: nil, + expected: &app.LoginVersion{ + Version: &app.LoginVersion_LoginV1{ + LoginV1: &app.LoginV1{}, + }, + }, + }, + { + name: "login v2", + version: domain.LoginVersion2, + baseURI: gu.Ptr("https://example.com"), + expected: &app.LoginVersion{ + Version: &app.LoginVersion_LoginV2{ + LoginV2: &app.LoginV2{ + BaseUri: gu.Ptr("https://example.com"), + }, + }, + }, + }, + { + name: "unknown version", + version: domain.LoginVersion(99), + baseURI: nil, + expected: nil, + }, + } + + for _, tc := range tt { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + // When + result := loginVersionToPb(tc.version, tc.baseURI) + + // Then + assert.Equal(t, tc.expected, result) + }) + } +} + +func TestAppQueryToModel(t *testing.T) { + t.Parallel() + + validAppNameSearchQuery, err := query.NewAppNameSearchQuery(query.TextEquals, "test") + require.NoError(t, err) + + validAppStateSearchQuery, err := query.NewAppStateSearchQuery(domain.AppStateActive) + require.NoError(t, err) + + tt := []struct { + name string + query *app.ApplicationSearchFilter + + expectedQuery query.SearchQuery + expectedError error + }{ + { + name: "name query", + query: &app.ApplicationSearchFilter{ + Filter: &app.ApplicationSearchFilter_NameFilter{ + NameFilter: &app.ApplicationNameQuery{ + Name: "test", + Method: filter_pb_v2.TextFilterMethod_TEXT_FILTER_METHOD_EQUALS, + }, + }, + }, + expectedQuery: validAppNameSearchQuery, + }, + { + name: "state query", + query: &app.ApplicationSearchFilter{ + Filter: &app.ApplicationSearchFilter_StateFilter{ + StateFilter: app.AppState_APP_STATE_ACTIVE, + }, + }, + expectedQuery: validAppStateSearchQuery, + }, + { + name: "api app only query", + query: &app.ApplicationSearchFilter{ + Filter: &app.ApplicationSearchFilter_ApiAppOnly{}, + }, + expectedQuery: &query.NotNullQuery{ + Column: query.AppAPIConfigColumnAppID, + }, + }, + { + name: "oidc app only query", + query: &app.ApplicationSearchFilter{ + Filter: &app.ApplicationSearchFilter_OidcAppOnly{}, + }, + expectedQuery: &query.NotNullQuery{ + Column: query.AppOIDCConfigColumnAppID, + }, + }, + { + name: "saml app only query", + query: &app.ApplicationSearchFilter{ + Filter: &app.ApplicationSearchFilter_SamlAppOnly{}, + }, + expectedQuery: &query.NotNullQuery{ + Column: query.AppSAMLConfigColumnAppID, + }, + }, + { + name: "invalid query type", + query: &app.ApplicationSearchFilter{}, + expectedQuery: nil, + expectedError: zerrors.ThrowInvalidArgument(nil, "CONV-z2mAGy", "List.Query.Invalid"), + }, + } + + for _, tc := range tt { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + // When + result, err := appQueryToModel(tc.query) + + // Then + assert.Equal(t, tc.expectedError, err) + assert.Equal(t, tc.expectedQuery, result) + }) + } +} diff --git a/internal/api/grpc/app/v2beta/convert/oidc_app.go b/internal/api/grpc/app/v2beta/convert/oidc_app.go new file mode 100644 index 0000000000..223e43d166 --- /dev/null +++ b/internal/api/grpc/app/v2beta/convert/oidc_app.go @@ -0,0 +1,291 @@ +package convert + +import ( + "github.com/muhlemmer/gu" + "google.golang.org/protobuf/types/known/durationpb" + + "github.com/zitadel/zitadel/internal/domain" + "github.com/zitadel/zitadel/internal/eventstore/v1/models" + "github.com/zitadel/zitadel/internal/query" + app "github.com/zitadel/zitadel/pkg/grpc/app/v2beta" +) + +func CreateOIDCAppRequestToDomain(name, projectID string, req *app.CreateOIDCApplicationRequest) (*domain.OIDCApp, error) { + loginVersion, loginBaseURI, err := loginVersionToDomain(req.GetLoginVersion()) + if err != nil { + return nil, err + } + return &domain.OIDCApp{ + ObjectRoot: models.ObjectRoot{ + AggregateID: projectID, + }, + AppName: name, + OIDCVersion: gu.Ptr(domain.OIDCVersionV1), + RedirectUris: req.GetRedirectUris(), + ResponseTypes: oidcResponseTypesToDomain(req.GetResponseTypes()), + GrantTypes: oidcGrantTypesToDomain(req.GetGrantTypes()), + ApplicationType: gu.Ptr(oidcApplicationTypeToDomain(req.GetAppType())), + AuthMethodType: gu.Ptr(oidcAuthMethodTypeToDomain(req.GetAuthMethodType())), + PostLogoutRedirectUris: req.GetPostLogoutRedirectUris(), + DevMode: &req.DevMode, + AccessTokenType: gu.Ptr(oidcTokenTypeToDomain(req.GetAccessTokenType())), + AccessTokenRoleAssertion: gu.Ptr(req.GetAccessTokenRoleAssertion()), + IDTokenRoleAssertion: gu.Ptr(req.GetIdTokenRoleAssertion()), + IDTokenUserinfoAssertion: gu.Ptr(req.GetIdTokenUserinfoAssertion()), + ClockSkew: gu.Ptr(req.GetClockSkew().AsDuration()), + AdditionalOrigins: req.GetAdditionalOrigins(), + SkipNativeAppSuccessPage: gu.Ptr(req.GetSkipNativeAppSuccessPage()), + BackChannelLogoutURI: gu.Ptr(req.GetBackChannelLogoutUri()), + LoginVersion: loginVersion, + LoginBaseURI: loginBaseURI, + }, nil +} + +func UpdateOIDCAppConfigRequestToDomain(appID, projectID string, app *app.UpdateOIDCApplicationConfigurationRequest) (*domain.OIDCApp, error) { + loginVersion, loginBaseURI, err := loginVersionToDomain(app.GetLoginVersion()) + if err != nil { + return nil, err + } + return &domain.OIDCApp{ + ObjectRoot: models.ObjectRoot{ + AggregateID: projectID, + }, + AppID: appID, + RedirectUris: app.RedirectUris, + ResponseTypes: oidcResponseTypesToDomain(app.ResponseTypes), + GrantTypes: oidcGrantTypesToDomain(app.GrantTypes), + ApplicationType: oidcApplicationTypeToDomainPtr(app.AppType), + AuthMethodType: oidcAuthMethodTypeToDomainPtr(app.AuthMethodType), + PostLogoutRedirectUris: app.PostLogoutRedirectUris, + DevMode: app.DevMode, + AccessTokenType: oidcTokenTypeToDomainPtr(app.AccessTokenType), + AccessTokenRoleAssertion: app.AccessTokenRoleAssertion, + IDTokenRoleAssertion: app.IdTokenRoleAssertion, + IDTokenUserinfoAssertion: app.IdTokenUserinfoAssertion, + ClockSkew: gu.Ptr(app.GetClockSkew().AsDuration()), + AdditionalOrigins: app.AdditionalOrigins, + SkipNativeAppSuccessPage: app.SkipNativeAppSuccessPage, + BackChannelLogoutURI: app.BackChannelLogoutUri, + LoginVersion: loginVersion, + LoginBaseURI: loginBaseURI, + }, nil +} + +func oidcResponseTypesToDomain(responseTypes []app.OIDCResponseType) []domain.OIDCResponseType { + if len(responseTypes) == 0 { + return []domain.OIDCResponseType{domain.OIDCResponseTypeCode} + } + oidcResponseTypes := make([]domain.OIDCResponseType, len(responseTypes)) + for i, responseType := range responseTypes { + switch responseType { + case app.OIDCResponseType_OIDC_RESPONSE_TYPE_UNSPECIFIED: + oidcResponseTypes[i] = domain.OIDCResponseTypeUnspecified + case app.OIDCResponseType_OIDC_RESPONSE_TYPE_CODE: + oidcResponseTypes[i] = domain.OIDCResponseTypeCode + case app.OIDCResponseType_OIDC_RESPONSE_TYPE_ID_TOKEN: + oidcResponseTypes[i] = domain.OIDCResponseTypeIDToken + case app.OIDCResponseType_OIDC_RESPONSE_TYPE_ID_TOKEN_TOKEN: + oidcResponseTypes[i] = domain.OIDCResponseTypeIDTokenToken + } + } + return oidcResponseTypes +} + +func oidcGrantTypesToDomain(grantTypes []app.OIDCGrantType) []domain.OIDCGrantType { + if len(grantTypes) == 0 { + return []domain.OIDCGrantType{domain.OIDCGrantTypeAuthorizationCode} + } + oidcGrantTypes := make([]domain.OIDCGrantType, len(grantTypes)) + for i, grantType := range grantTypes { + switch grantType { + case app.OIDCGrantType_OIDC_GRANT_TYPE_AUTHORIZATION_CODE: + oidcGrantTypes[i] = domain.OIDCGrantTypeAuthorizationCode + case app.OIDCGrantType_OIDC_GRANT_TYPE_IMPLICIT: + oidcGrantTypes[i] = domain.OIDCGrantTypeImplicit + case app.OIDCGrantType_OIDC_GRANT_TYPE_REFRESH_TOKEN: + oidcGrantTypes[i] = domain.OIDCGrantTypeRefreshToken + case app.OIDCGrantType_OIDC_GRANT_TYPE_DEVICE_CODE: + oidcGrantTypes[i] = domain.OIDCGrantTypeDeviceCode + case app.OIDCGrantType_OIDC_GRANT_TYPE_TOKEN_EXCHANGE: + oidcGrantTypes[i] = domain.OIDCGrantTypeTokenExchange + } + } + return oidcGrantTypes +} + +func oidcApplicationTypeToDomainPtr(appType *app.OIDCAppType) *domain.OIDCApplicationType { + if appType == nil { + return nil + } + + res := oidcApplicationTypeToDomain(*appType) + return &res +} + +func oidcApplicationTypeToDomain(appType app.OIDCAppType) domain.OIDCApplicationType { + switch appType { + case app.OIDCAppType_OIDC_APP_TYPE_WEB: + return domain.OIDCApplicationTypeWeb + case app.OIDCAppType_OIDC_APP_TYPE_USER_AGENT: + return domain.OIDCApplicationTypeUserAgent + case app.OIDCAppType_OIDC_APP_TYPE_NATIVE: + return domain.OIDCApplicationTypeNative + } + return domain.OIDCApplicationTypeWeb +} + +func oidcAuthMethodTypeToDomainPtr(authType *app.OIDCAuthMethodType) *domain.OIDCAuthMethodType { + if authType == nil { + return nil + } + + res := oidcAuthMethodTypeToDomain(*authType) + return &res +} + +func oidcAuthMethodTypeToDomain(authType app.OIDCAuthMethodType) domain.OIDCAuthMethodType { + switch authType { + case app.OIDCAuthMethodType_OIDC_AUTH_METHOD_TYPE_BASIC: + return domain.OIDCAuthMethodTypeBasic + case app.OIDCAuthMethodType_OIDC_AUTH_METHOD_TYPE_POST: + return domain.OIDCAuthMethodTypePost + case app.OIDCAuthMethodType_OIDC_AUTH_METHOD_TYPE_NONE: + return domain.OIDCAuthMethodTypeNone + case app.OIDCAuthMethodType_OIDC_AUTH_METHOD_TYPE_PRIVATE_KEY_JWT: + return domain.OIDCAuthMethodTypePrivateKeyJWT + default: + return domain.OIDCAuthMethodTypeBasic + } +} + +func oidcTokenTypeToDomainPtr(tokenType *app.OIDCTokenType) *domain.OIDCTokenType { + if tokenType == nil { + return nil + } + + res := oidcTokenTypeToDomain(*tokenType) + return &res +} + +func oidcTokenTypeToDomain(tokenType app.OIDCTokenType) domain.OIDCTokenType { + switch tokenType { + case app.OIDCTokenType_OIDC_TOKEN_TYPE_BEARER: + return domain.OIDCTokenTypeBearer + case app.OIDCTokenType_OIDC_TOKEN_TYPE_JWT: + return domain.OIDCTokenTypeJWT + default: + return domain.OIDCTokenTypeBearer + } +} + +func ComplianceProblemsToLocalizedMessages(complianceProblems []string) []*app.OIDCLocalizedMessage { + converted := make([]*app.OIDCLocalizedMessage, len(complianceProblems)) + for i, p := range complianceProblems { + converted[i] = &app.OIDCLocalizedMessage{Key: p} + } + + return converted +} + +func appOIDCConfigToPb(oidcApp *query.OIDCApp) *app.Application_OidcConfig { + return &app.Application_OidcConfig{ + OidcConfig: &app.OIDCConfig{ + RedirectUris: oidcApp.RedirectURIs, + ResponseTypes: oidcResponseTypesFromModel(oidcApp.ResponseTypes), + GrantTypes: oidcGrantTypesFromModel(oidcApp.GrantTypes), + AppType: oidcApplicationTypeToPb(oidcApp.AppType), + ClientId: oidcApp.ClientID, + AuthMethodType: oidcAuthMethodTypeToPb(oidcApp.AuthMethodType), + PostLogoutRedirectUris: oidcApp.PostLogoutRedirectURIs, + Version: app.OIDCVersion_OIDC_VERSION_1_0, + NoneCompliant: len(oidcApp.ComplianceProblems) != 0, + ComplianceProblems: ComplianceProblemsToLocalizedMessages(oidcApp.ComplianceProblems), + DevMode: oidcApp.IsDevMode, + AccessTokenType: oidcTokenTypeToPb(oidcApp.AccessTokenType), + AccessTokenRoleAssertion: oidcApp.AssertAccessTokenRole, + IdTokenRoleAssertion: oidcApp.AssertIDTokenRole, + IdTokenUserinfoAssertion: oidcApp.AssertIDTokenUserinfo, + ClockSkew: durationpb.New(oidcApp.ClockSkew), + AdditionalOrigins: oidcApp.AdditionalOrigins, + AllowedOrigins: oidcApp.AllowedOrigins, + SkipNativeAppSuccessPage: oidcApp.SkipNativeAppSuccessPage, + BackChannelLogoutUri: oidcApp.BackChannelLogoutURI, + LoginVersion: loginVersionToPb(oidcApp.LoginVersion, oidcApp.LoginBaseURI), + }, + } +} + +func oidcResponseTypesFromModel(responseTypes []domain.OIDCResponseType) []app.OIDCResponseType { + oidcResponseTypes := make([]app.OIDCResponseType, len(responseTypes)) + for i, responseType := range responseTypes { + switch responseType { + case domain.OIDCResponseTypeUnspecified: + oidcResponseTypes[i] = app.OIDCResponseType_OIDC_RESPONSE_TYPE_UNSPECIFIED + case domain.OIDCResponseTypeCode: + oidcResponseTypes[i] = app.OIDCResponseType_OIDC_RESPONSE_TYPE_CODE + case domain.OIDCResponseTypeIDToken: + oidcResponseTypes[i] = app.OIDCResponseType_OIDC_RESPONSE_TYPE_ID_TOKEN + case domain.OIDCResponseTypeIDTokenToken: + oidcResponseTypes[i] = app.OIDCResponseType_OIDC_RESPONSE_TYPE_ID_TOKEN_TOKEN + } + } + return oidcResponseTypes +} + +func oidcGrantTypesFromModel(grantTypes []domain.OIDCGrantType) []app.OIDCGrantType { + oidcGrantTypes := make([]app.OIDCGrantType, len(grantTypes)) + for i, grantType := range grantTypes { + switch grantType { + case domain.OIDCGrantTypeAuthorizationCode: + oidcGrantTypes[i] = app.OIDCGrantType_OIDC_GRANT_TYPE_AUTHORIZATION_CODE + case domain.OIDCGrantTypeImplicit: + oidcGrantTypes[i] = app.OIDCGrantType_OIDC_GRANT_TYPE_IMPLICIT + case domain.OIDCGrantTypeRefreshToken: + oidcGrantTypes[i] = app.OIDCGrantType_OIDC_GRANT_TYPE_REFRESH_TOKEN + case domain.OIDCGrantTypeDeviceCode: + oidcGrantTypes[i] = app.OIDCGrantType_OIDC_GRANT_TYPE_DEVICE_CODE + case domain.OIDCGrantTypeTokenExchange: + oidcGrantTypes[i] = app.OIDCGrantType_OIDC_GRANT_TYPE_TOKEN_EXCHANGE + } + } + return oidcGrantTypes +} + +func oidcApplicationTypeToPb(appType domain.OIDCApplicationType) app.OIDCAppType { + switch appType { + case domain.OIDCApplicationTypeWeb: + return app.OIDCAppType_OIDC_APP_TYPE_WEB + case domain.OIDCApplicationTypeUserAgent: + return app.OIDCAppType_OIDC_APP_TYPE_USER_AGENT + case domain.OIDCApplicationTypeNative: + return app.OIDCAppType_OIDC_APP_TYPE_NATIVE + default: + return app.OIDCAppType_OIDC_APP_TYPE_WEB + } +} + +func oidcAuthMethodTypeToPb(authType domain.OIDCAuthMethodType) app.OIDCAuthMethodType { + switch authType { + case domain.OIDCAuthMethodTypeBasic: + return app.OIDCAuthMethodType_OIDC_AUTH_METHOD_TYPE_BASIC + case domain.OIDCAuthMethodTypePost: + return app.OIDCAuthMethodType_OIDC_AUTH_METHOD_TYPE_POST + case domain.OIDCAuthMethodTypeNone: + return app.OIDCAuthMethodType_OIDC_AUTH_METHOD_TYPE_NONE + case domain.OIDCAuthMethodTypePrivateKeyJWT: + return app.OIDCAuthMethodType_OIDC_AUTH_METHOD_TYPE_PRIVATE_KEY_JWT + default: + return app.OIDCAuthMethodType_OIDC_AUTH_METHOD_TYPE_BASIC + } +} + +func oidcTokenTypeToPb(tokenType domain.OIDCTokenType) app.OIDCTokenType { + switch tokenType { + case domain.OIDCTokenTypeBearer: + return app.OIDCTokenType_OIDC_TOKEN_TYPE_BEARER + case domain.OIDCTokenTypeJWT: + return app.OIDCTokenType_OIDC_TOKEN_TYPE_JWT + default: + return app.OIDCTokenType_OIDC_TOKEN_TYPE_BEARER + } +} diff --git a/internal/api/grpc/app/v2beta/convert/oidc_app_test.go b/internal/api/grpc/app/v2beta/convert/oidc_app_test.go new file mode 100644 index 0000000000..a6b3f0b709 --- /dev/null +++ b/internal/api/grpc/app/v2beta/convert/oidc_app_test.go @@ -0,0 +1,755 @@ +package convert + +import ( + "net/url" + "testing" + "time" + + "github.com/muhlemmer/gu" + "github.com/stretchr/testify/assert" + "google.golang.org/protobuf/types/known/durationpb" + + "github.com/zitadel/zitadel/internal/domain" + "github.com/zitadel/zitadel/internal/eventstore/v1/models" + "github.com/zitadel/zitadel/internal/query" + app "github.com/zitadel/zitadel/pkg/grpc/app/v2beta" +) + +func TestCreateOIDCAppRequestToDomain(t *testing.T) { + t.Parallel() + + tt := []struct { + testName string + projectID string + req *app.CreateOIDCApplicationRequest + + expectedModel *domain.OIDCApp + expectedError error + }{ + { + testName: "unparsable login version 2 URL", + projectID: "pid", + req: &app.CreateOIDCApplicationRequest{ + LoginVersion: &app.LoginVersion{Version: &app.LoginVersion_LoginV2{ + LoginV2: &app.LoginV2{BaseUri: gu.Ptr("%+o")}}, + }, + }, + expectedModel: nil, + expectedError: &url.Error{ + URL: "%+o", + Op: "parse", + Err: url.EscapeError("%+o"), + }, + }, + { + testName: "all fields set", + projectID: "project1", + req: &app.CreateOIDCApplicationRequest{ + RedirectUris: []string{"https://redirect"}, + ResponseTypes: []app.OIDCResponseType{app.OIDCResponseType_OIDC_RESPONSE_TYPE_CODE}, + GrantTypes: []app.OIDCGrantType{app.OIDCGrantType_OIDC_GRANT_TYPE_AUTHORIZATION_CODE}, + AppType: app.OIDCAppType_OIDC_APP_TYPE_WEB, + AuthMethodType: app.OIDCAuthMethodType_OIDC_AUTH_METHOD_TYPE_BASIC, + PostLogoutRedirectUris: []string{"https://logout"}, + DevMode: true, + AccessTokenType: app.OIDCTokenType_OIDC_TOKEN_TYPE_BEARER, + AccessTokenRoleAssertion: true, + IdTokenRoleAssertion: true, + IdTokenUserinfoAssertion: true, + ClockSkew: durationpb.New(5 * time.Second), + AdditionalOrigins: []string{"https://origin"}, + SkipNativeAppSuccessPage: true, + BackChannelLogoutUri: "https://backchannel", + LoginVersion: &app.LoginVersion{Version: &app.LoginVersion_LoginV2{LoginV2: &app.LoginV2{ + BaseUri: gu.Ptr("https://login"), + }}}, + }, + expectedModel: &domain.OIDCApp{ + ObjectRoot: models.ObjectRoot{AggregateID: "project1"}, + AppName: "all fields set", + OIDCVersion: gu.Ptr(domain.OIDCVersionV1), + RedirectUris: []string{"https://redirect"}, + ResponseTypes: []domain.OIDCResponseType{domain.OIDCResponseTypeCode}, + GrantTypes: []domain.OIDCGrantType{domain.OIDCGrantTypeAuthorizationCode}, + ApplicationType: gu.Ptr(domain.OIDCApplicationTypeWeb), + AuthMethodType: gu.Ptr(domain.OIDCAuthMethodTypeBasic), + PostLogoutRedirectUris: []string{"https://logout"}, + DevMode: gu.Ptr(true), + AccessTokenType: gu.Ptr(domain.OIDCTokenTypeBearer), + AccessTokenRoleAssertion: gu.Ptr(true), + IDTokenRoleAssertion: gu.Ptr(true), + IDTokenUserinfoAssertion: gu.Ptr(true), + ClockSkew: gu.Ptr(5 * time.Second), + AdditionalOrigins: []string{"https://origin"}, + SkipNativeAppSuccessPage: gu.Ptr(true), + BackChannelLogoutURI: gu.Ptr("https://backchannel"), + LoginVersion: gu.Ptr(domain.LoginVersion2), + LoginBaseURI: gu.Ptr("https://login"), + }, + }, + } + + for _, tc := range tt { + t.Run(tc.testName, func(t *testing.T) { + t.Parallel() + + // When + res, err := CreateOIDCAppRequestToDomain(tc.testName, tc.projectID, tc.req) + + // Then + assert.Equal(t, tc.expectedError, err) + assert.Equal(t, tc.expectedModel, res) + }) + } +} + +func TestUpdateOIDCAppConfigRequestToDomain(t *testing.T) { + t.Parallel() + + tt := []struct { + testName string + + appID string + projectID string + req *app.UpdateOIDCApplicationConfigurationRequest + + expectedModel *domain.OIDCApp + expectedError error + }{ + { + testName: "unparsable login version 2 URL", + appID: "app1", + projectID: "pid", + req: &app.UpdateOIDCApplicationConfigurationRequest{ + LoginVersion: &app.LoginVersion{Version: &app.LoginVersion_LoginV2{ + LoginV2: &app.LoginV2{BaseUri: gu.Ptr("%+o")}, + }}, + }, + expectedModel: nil, + expectedError: &url.Error{ + URL: "%+o", + Op: "parse", + Err: url.EscapeError("%+o"), + }, + }, + { + testName: "successful Update", + appID: "app1", + projectID: "proj1", + req: &app.UpdateOIDCApplicationConfigurationRequest{ + RedirectUris: []string{"https://redirect"}, + ResponseTypes: []app.OIDCResponseType{app.OIDCResponseType_OIDC_RESPONSE_TYPE_CODE}, + GrantTypes: []app.OIDCGrantType{app.OIDCGrantType_OIDC_GRANT_TYPE_AUTHORIZATION_CODE}, + AppType: gu.Ptr(app.OIDCAppType_OIDC_APP_TYPE_WEB), + AuthMethodType: gu.Ptr(app.OIDCAuthMethodType_OIDC_AUTH_METHOD_TYPE_BASIC), + PostLogoutRedirectUris: []string{"https://logout"}, + DevMode: gu.Ptr(true), + AccessTokenType: gu.Ptr(app.OIDCTokenType_OIDC_TOKEN_TYPE_BEARER), + AccessTokenRoleAssertion: gu.Ptr(true), + IdTokenRoleAssertion: gu.Ptr(true), + IdTokenUserinfoAssertion: gu.Ptr(true), + ClockSkew: durationpb.New(5 * time.Second), + AdditionalOrigins: []string{"https://origin"}, + SkipNativeAppSuccessPage: gu.Ptr(true), + BackChannelLogoutUri: gu.Ptr("https://backchannel"), + LoginVersion: &app.LoginVersion{Version: &app.LoginVersion_LoginV2{ + LoginV2: &app.LoginV2{BaseUri: gu.Ptr("https://login")}, + }}, + }, + expectedModel: &domain.OIDCApp{ + ObjectRoot: models.ObjectRoot{AggregateID: "proj1"}, + AppID: "app1", + RedirectUris: []string{"https://redirect"}, + ResponseTypes: []domain.OIDCResponseType{domain.OIDCResponseTypeCode}, + GrantTypes: []domain.OIDCGrantType{domain.OIDCGrantTypeAuthorizationCode}, + ApplicationType: gu.Ptr(domain.OIDCApplicationTypeWeb), + AuthMethodType: gu.Ptr(domain.OIDCAuthMethodTypeBasic), + PostLogoutRedirectUris: []string{"https://logout"}, + DevMode: gu.Ptr(true), + AccessTokenType: gu.Ptr(domain.OIDCTokenTypeBearer), + AccessTokenRoleAssertion: gu.Ptr(true), + IDTokenRoleAssertion: gu.Ptr(true), + IDTokenUserinfoAssertion: gu.Ptr(true), + ClockSkew: gu.Ptr(5 * time.Second), + AdditionalOrigins: []string{"https://origin"}, + SkipNativeAppSuccessPage: gu.Ptr(true), + BackChannelLogoutURI: gu.Ptr("https://backchannel"), + LoginVersion: gu.Ptr(domain.LoginVersion2), + LoginBaseURI: gu.Ptr("https://login"), + }, + }, + } + + for _, tc := range tt { + t.Run(tc.testName, func(t *testing.T) { + t.Parallel() + + // When + got, err := UpdateOIDCAppConfigRequestToDomain(tc.appID, tc.projectID, tc.req) + + // Then + assert.Equal(t, tc.expectedError, err) + assert.Equal(t, tc.expectedModel, got) + }) + } +} + +func TestOIDCResponseTypesToDomain(t *testing.T) { + t.Parallel() + + tt := []struct { + testName string + inputResponseType []app.OIDCResponseType + expectedResponse []domain.OIDCResponseType + }{ + { + testName: "empty response types", + inputResponseType: []app.OIDCResponseType{}, + expectedResponse: []domain.OIDCResponseType{domain.OIDCResponseTypeCode}, + }, + { + testName: "all response types", + inputResponseType: []app.OIDCResponseType{ + app.OIDCResponseType_OIDC_RESPONSE_TYPE_UNSPECIFIED, + app.OIDCResponseType_OIDC_RESPONSE_TYPE_CODE, + app.OIDCResponseType_OIDC_RESPONSE_TYPE_ID_TOKEN, + app.OIDCResponseType_OIDC_RESPONSE_TYPE_ID_TOKEN_TOKEN, + }, + expectedResponse: []domain.OIDCResponseType{ + domain.OIDCResponseTypeUnspecified, + domain.OIDCResponseTypeCode, + domain.OIDCResponseTypeIDToken, + domain.OIDCResponseTypeIDTokenToken, + }, + }, + { + testName: "single response type", + inputResponseType: []app.OIDCResponseType{ + app.OIDCResponseType_OIDC_RESPONSE_TYPE_CODE, + }, + expectedResponse: []domain.OIDCResponseType{ + domain.OIDCResponseTypeCode, + }, + }, + } + + for _, tc := range tt { + t.Run(tc.testName, func(t *testing.T) { + t.Parallel() + + // When + res := oidcResponseTypesToDomain(tc.inputResponseType) + + // Then + assert.Equal(t, tc.expectedResponse, res) + }) + } +} + +func TestOIDCGrantTypesToDomain(t *testing.T) { + t.Parallel() + + tt := []struct { + testName string + inputGrantType []app.OIDCGrantType + expectedGrants []domain.OIDCGrantType + }{ + { + testName: "empty grant types", + inputGrantType: []app.OIDCGrantType{}, + expectedGrants: []domain.OIDCGrantType{domain.OIDCGrantTypeAuthorizationCode}, + }, + { + testName: "all grant types", + inputGrantType: []app.OIDCGrantType{ + app.OIDCGrantType_OIDC_GRANT_TYPE_AUTHORIZATION_CODE, + app.OIDCGrantType_OIDC_GRANT_TYPE_IMPLICIT, + app.OIDCGrantType_OIDC_GRANT_TYPE_REFRESH_TOKEN, + app.OIDCGrantType_OIDC_GRANT_TYPE_DEVICE_CODE, + app.OIDCGrantType_OIDC_GRANT_TYPE_TOKEN_EXCHANGE, + }, + expectedGrants: []domain.OIDCGrantType{ + domain.OIDCGrantTypeAuthorizationCode, + domain.OIDCGrantTypeImplicit, + domain.OIDCGrantTypeRefreshToken, + domain.OIDCGrantTypeDeviceCode, + domain.OIDCGrantTypeTokenExchange, + }, + }, + { + testName: "single grant type", + inputGrantType: []app.OIDCGrantType{ + app.OIDCGrantType_OIDC_GRANT_TYPE_AUTHORIZATION_CODE, + }, + expectedGrants: []domain.OIDCGrantType{ + domain.OIDCGrantTypeAuthorizationCode, + }, + }, + } + + for _, tc := range tt { + t.Run(tc.testName, func(t *testing.T) { + t.Parallel() + + // When + res := oidcGrantTypesToDomain(tc.inputGrantType) + + // Then + assert.Equal(t, tc.expectedGrants, res) + }) + } +} + +func TestOIDCApplicationTypeToDomain(t *testing.T) { + t.Parallel() + + tt := []struct { + name string + appType app.OIDCAppType + expected domain.OIDCApplicationType + }{ + { + name: "web type", + appType: app.OIDCAppType_OIDC_APP_TYPE_WEB, + expected: domain.OIDCApplicationTypeWeb, + }, + { + name: "user agent type", + appType: app.OIDCAppType_OIDC_APP_TYPE_USER_AGENT, + expected: domain.OIDCApplicationTypeUserAgent, + }, + { + name: "native type", + appType: app.OIDCAppType_OIDC_APP_TYPE_NATIVE, + expected: domain.OIDCApplicationTypeNative, + }, + { + name: "unspecified type defaults to web", + expected: domain.OIDCApplicationTypeWeb, + }, + } + + for _, tc := range tt { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + // When + result := oidcApplicationTypeToDomain(tc.appType) + + // Then + assert.Equal(t, tc.expected, result) + }) + } +} + +func TestOIDCAuthMethodTypeToDomain(t *testing.T) { + t.Parallel() + + tt := []struct { + name string + authType app.OIDCAuthMethodType + expectedResponse domain.OIDCAuthMethodType + }{ + { + name: "basic auth type", + authType: app.OIDCAuthMethodType_OIDC_AUTH_METHOD_TYPE_BASIC, + expectedResponse: domain.OIDCAuthMethodTypeBasic, + }, + { + name: "post auth type", + authType: app.OIDCAuthMethodType_OIDC_AUTH_METHOD_TYPE_POST, + expectedResponse: domain.OIDCAuthMethodTypePost, + }, + { + name: "none auth type", + authType: app.OIDCAuthMethodType_OIDC_AUTH_METHOD_TYPE_NONE, + expectedResponse: domain.OIDCAuthMethodTypeNone, + }, + { + name: "private key jwt auth type", + authType: app.OIDCAuthMethodType_OIDC_AUTH_METHOD_TYPE_PRIVATE_KEY_JWT, + expectedResponse: domain.OIDCAuthMethodTypePrivateKeyJWT, + }, + { + name: "unspecified auth type defaults to basic", + expectedResponse: domain.OIDCAuthMethodTypeBasic, + }, + } + + for _, tc := range tt { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + // When + res := oidcAuthMethodTypeToDomain(tc.authType) + + // Then + assert.Equal(t, tc.expectedResponse, res) + }) + } +} + +func TestOIDCTokenTypeToDomain(t *testing.T) { + t.Parallel() + + tt := []struct { + name string + tokenType app.OIDCTokenType + expectedType domain.OIDCTokenType + }{ + { + name: "bearer token type", + tokenType: app.OIDCTokenType_OIDC_TOKEN_TYPE_BEARER, + expectedType: domain.OIDCTokenTypeBearer, + }, + { + name: "jwt token type", + tokenType: app.OIDCTokenType_OIDC_TOKEN_TYPE_JWT, + expectedType: domain.OIDCTokenTypeJWT, + }, + { + name: "unspecified defaults to bearer", + expectedType: domain.OIDCTokenTypeBearer, + }, + } + + for _, tc := range tt { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + // When + result := oidcTokenTypeToDomain(tc.tokenType) + + // Then + assert.Equal(t, tc.expectedType, result) + }) + } +} +func TestAppOIDCConfigToPb(t *testing.T) { + t.Parallel() + + tt := []struct { + name string + input *query.OIDCApp + expected *app.Application_OidcConfig + }{ + { + name: "empty config", + input: &query.OIDCApp{}, + expected: &app.Application_OidcConfig{ + OidcConfig: &app.OIDCConfig{ + Version: app.OIDCVersion_OIDC_VERSION_1_0, + ComplianceProblems: []*app.OIDCLocalizedMessage{}, + ClockSkew: durationpb.New(0), + ResponseTypes: []app.OIDCResponseType{}, + GrantTypes: []app.OIDCGrantType{}, + }, + }, + }, + { + name: "full config", + input: &query.OIDCApp{ + RedirectURIs: []string{"https://example.com/callback"}, + ResponseTypes: []domain.OIDCResponseType{domain.OIDCResponseTypeCode}, + GrantTypes: []domain.OIDCGrantType{domain.OIDCGrantTypeAuthorizationCode}, + AppType: domain.OIDCApplicationTypeWeb, + ClientID: "client123", + AuthMethodType: domain.OIDCAuthMethodTypeBasic, + PostLogoutRedirectURIs: []string{"https://example.com/logout"}, + ComplianceProblems: []string{"problem1", "problem2"}, + IsDevMode: true, + AccessTokenType: domain.OIDCTokenTypeBearer, + AssertAccessTokenRole: true, + AssertIDTokenRole: true, + AssertIDTokenUserinfo: true, + ClockSkew: 5 * time.Second, + AdditionalOrigins: []string{"https://app.example.com"}, + AllowedOrigins: []string{"https://allowed.example.com"}, + SkipNativeAppSuccessPage: true, + BackChannelLogoutURI: "https://example.com/backchannel", + LoginVersion: domain.LoginVersion2, + LoginBaseURI: gu.Ptr("https://login.example.com"), + }, + expected: &app.Application_OidcConfig{ + OidcConfig: &app.OIDCConfig{ + RedirectUris: []string{"https://example.com/callback"}, + ResponseTypes: []app.OIDCResponseType{app.OIDCResponseType_OIDC_RESPONSE_TYPE_CODE}, + GrantTypes: []app.OIDCGrantType{app.OIDCGrantType_OIDC_GRANT_TYPE_AUTHORIZATION_CODE}, + AppType: app.OIDCAppType_OIDC_APP_TYPE_WEB, + ClientId: "client123", + AuthMethodType: app.OIDCAuthMethodType_OIDC_AUTH_METHOD_TYPE_BASIC, + PostLogoutRedirectUris: []string{"https://example.com/logout"}, + Version: app.OIDCVersion_OIDC_VERSION_1_0, + NoneCompliant: true, + ComplianceProblems: []*app.OIDCLocalizedMessage{ + {Key: "problem1"}, + {Key: "problem2"}, + }, + DevMode: true, + AccessTokenType: app.OIDCTokenType_OIDC_TOKEN_TYPE_BEARER, + AccessTokenRoleAssertion: true, + IdTokenRoleAssertion: true, + IdTokenUserinfoAssertion: true, + ClockSkew: durationpb.New(5 * time.Second), + AdditionalOrigins: []string{"https://app.example.com"}, + AllowedOrigins: []string{"https://allowed.example.com"}, + SkipNativeAppSuccessPage: true, + BackChannelLogoutUri: "https://example.com/backchannel", + LoginVersion: &app.LoginVersion{ + Version: &app.LoginVersion_LoginV2{ + LoginV2: &app.LoginV2{ + BaseUri: gu.Ptr("https://login.example.com"), + }, + }, + }, + }, + }, + }, + } + + for _, tt := range tt { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + // When + result := appOIDCConfigToPb(tt.input) + + // Then + assert.Equal(t, tt.expected, result) + }) + } +} + +func TestOIDCResponseTypesFromModel(t *testing.T) { + t.Parallel() + + tt := []struct { + name string + responseTypes []domain.OIDCResponseType + expected []app.OIDCResponseType + }{ + { + name: "empty response types", + responseTypes: []domain.OIDCResponseType{}, + expected: []app.OIDCResponseType{}, + }, + { + name: "all response types", + responseTypes: []domain.OIDCResponseType{ + domain.OIDCResponseTypeUnspecified, + domain.OIDCResponseTypeCode, + domain.OIDCResponseTypeIDToken, + domain.OIDCResponseTypeIDTokenToken, + }, + expected: []app.OIDCResponseType{ + app.OIDCResponseType_OIDC_RESPONSE_TYPE_UNSPECIFIED, + app.OIDCResponseType_OIDC_RESPONSE_TYPE_CODE, + app.OIDCResponseType_OIDC_RESPONSE_TYPE_ID_TOKEN, + app.OIDCResponseType_OIDC_RESPONSE_TYPE_ID_TOKEN_TOKEN, + }, + }, + { + name: "single response type", + responseTypes: []domain.OIDCResponseType{ + domain.OIDCResponseTypeCode, + }, + expected: []app.OIDCResponseType{ + app.OIDCResponseType_OIDC_RESPONSE_TYPE_CODE, + }, + }, + } + + for _, tc := range tt { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + // When + result := oidcResponseTypesFromModel(tc.responseTypes) + + // Then + assert.Equal(t, tc.expected, result) + }) + } +} +func TestOIDCGrantTypesFromModel(t *testing.T) { + t.Parallel() + + tt := []struct { + name string + grantTypes []domain.OIDCGrantType + expected []app.OIDCGrantType + }{ + { + name: "empty grant types", + grantTypes: []domain.OIDCGrantType{}, + expected: []app.OIDCGrantType{}, + }, + { + name: "all grant types", + grantTypes: []domain.OIDCGrantType{ + domain.OIDCGrantTypeAuthorizationCode, + domain.OIDCGrantTypeImplicit, + domain.OIDCGrantTypeRefreshToken, + domain.OIDCGrantTypeDeviceCode, + domain.OIDCGrantTypeTokenExchange, + }, + expected: []app.OIDCGrantType{ + app.OIDCGrantType_OIDC_GRANT_TYPE_AUTHORIZATION_CODE, + app.OIDCGrantType_OIDC_GRANT_TYPE_IMPLICIT, + app.OIDCGrantType_OIDC_GRANT_TYPE_REFRESH_TOKEN, + app.OIDCGrantType_OIDC_GRANT_TYPE_DEVICE_CODE, + app.OIDCGrantType_OIDC_GRANT_TYPE_TOKEN_EXCHANGE, + }, + }, + { + name: "single grant type", + grantTypes: []domain.OIDCGrantType{ + domain.OIDCGrantTypeAuthorizationCode, + }, + expected: []app.OIDCGrantType{ + app.OIDCGrantType_OIDC_GRANT_TYPE_AUTHORIZATION_CODE, + }, + }, + } + + for _, tc := range tt { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + // When + result := oidcGrantTypesFromModel(tc.grantTypes) + + // Then + assert.Equal(t, tc.expected, result) + }) + } +} + +func TestOIDCApplicationTypeToPb(t *testing.T) { + t.Parallel() + + tt := []struct { + name string + appType domain.OIDCApplicationType + expected app.OIDCAppType + }{ + { + name: "web type", + appType: domain.OIDCApplicationTypeWeb, + expected: app.OIDCAppType_OIDC_APP_TYPE_WEB, + }, + { + name: "user agent type", + appType: domain.OIDCApplicationTypeUserAgent, + expected: app.OIDCAppType_OIDC_APP_TYPE_USER_AGENT, + }, + { + name: "native type", + appType: domain.OIDCApplicationTypeNative, + expected: app.OIDCAppType_OIDC_APP_TYPE_NATIVE, + }, + { + name: "unspecified type defaults to web", + appType: domain.OIDCApplicationType(999), // Invalid value to trigger default case + expected: app.OIDCAppType_OIDC_APP_TYPE_WEB, + }, + } + + for _, tc := range tt { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + // When + result := oidcApplicationTypeToPb(tc.appType) + + // Then + assert.Equal(t, tc.expected, result) + }) + } +} + +func TestOIDCAuthMethodTypeToPb(t *testing.T) { + t.Parallel() + + tt := []struct { + name string + authType domain.OIDCAuthMethodType + expected app.OIDCAuthMethodType + }{ + { + name: "basic auth type", + authType: domain.OIDCAuthMethodTypeBasic, + expected: app.OIDCAuthMethodType_OIDC_AUTH_METHOD_TYPE_BASIC, + }, + { + name: "post auth type", + authType: domain.OIDCAuthMethodTypePost, + expected: app.OIDCAuthMethodType_OIDC_AUTH_METHOD_TYPE_POST, + }, + { + name: "none auth type", + authType: domain.OIDCAuthMethodTypeNone, + expected: app.OIDCAuthMethodType_OIDC_AUTH_METHOD_TYPE_NONE, + }, + { + name: "private key jwt auth type", + authType: domain.OIDCAuthMethodTypePrivateKeyJWT, + expected: app.OIDCAuthMethodType_OIDC_AUTH_METHOD_TYPE_PRIVATE_KEY_JWT, + }, + { + name: "unknown auth type defaults to basic", + authType: domain.OIDCAuthMethodType(999), + expected: app.OIDCAuthMethodType_OIDC_AUTH_METHOD_TYPE_BASIC, + }, + } + + for _, tc := range tt { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + // When + result := oidcAuthMethodTypeToPb(tc.authType) + + // Then + assert.Equal(t, tc.expected, result) + }) + } +} + +func TestOIDCTokenTypeToPb(t *testing.T) { + t.Parallel() + + tt := []struct { + name string + tokenType domain.OIDCTokenType + expected app.OIDCTokenType + }{ + { + name: "bearer token type", + tokenType: domain.OIDCTokenTypeBearer, + expected: app.OIDCTokenType_OIDC_TOKEN_TYPE_BEARER, + }, + { + name: "jwt token type", + tokenType: domain.OIDCTokenTypeJWT, + expected: app.OIDCTokenType_OIDC_TOKEN_TYPE_JWT, + }, + { + name: "unknown token type defaults to bearer", + tokenType: domain.OIDCTokenType(999), // Invalid value to trigger default case + expected: app.OIDCTokenType_OIDC_TOKEN_TYPE_BEARER, + }, + } + + for _, tc := range tt { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + // When + result := oidcTokenTypeToPb(tc.tokenType) + + // Then + assert.Equal(t, tc.expected, result) + }) + } +} diff --git a/internal/api/grpc/app/v2beta/convert/saml_app.go b/internal/api/grpc/app/v2beta/convert/saml_app.go new file mode 100644 index 0000000000..7f1bef082b --- /dev/null +++ b/internal/api/grpc/app/v2beta/convert/saml_app.go @@ -0,0 +1,77 @@ +package convert + +import ( + "github.com/muhlemmer/gu" + + "github.com/zitadel/zitadel/internal/domain" + "github.com/zitadel/zitadel/internal/eventstore/v1/models" + "github.com/zitadel/zitadel/internal/query" + app "github.com/zitadel/zitadel/pkg/grpc/app/v2beta" +) + +func CreateSAMLAppRequestToDomain(name, projectID string, req *app.CreateSAMLApplicationRequest) (*domain.SAMLApp, error) { + loginVersion, loginBaseURI, err := loginVersionToDomain(req.GetLoginVersion()) + if err != nil { + return nil, err + } + return &domain.SAMLApp{ + ObjectRoot: models.ObjectRoot{ + AggregateID: projectID, + }, + AppName: name, + Metadata: req.GetMetadataXml(), + MetadataURL: gu.Ptr(req.GetMetadataUrl()), + LoginVersion: loginVersion, + LoginBaseURI: loginBaseURI, + }, nil +} + +func UpdateSAMLAppConfigRequestToDomain(appID, projectID string, app *app.UpdateSAMLApplicationConfigurationRequest) (*domain.SAMLApp, error) { + loginVersion, loginBaseURI, err := loginVersionToDomain(app.GetLoginVersion()) + if err != nil { + return nil, err + } + + metasXML, metasURL := metasToDomain(app.GetMetadata()) + return &domain.SAMLApp{ + ObjectRoot: models.ObjectRoot{ + AggregateID: projectID, + }, + AppID: appID, + Metadata: metasXML, + MetadataURL: metasURL, + LoginVersion: loginVersion, + LoginBaseURI: loginBaseURI, + }, nil +} + +func metasToDomain(metas app.MetaType) ([]byte, *string) { + switch t := metas.(type) { + case *app.UpdateSAMLApplicationConfigurationRequest_MetadataXml: + return t.MetadataXml, nil + case *app.UpdateSAMLApplicationConfigurationRequest_MetadataUrl: + return nil, &t.MetadataUrl + case nil: + return nil, nil + default: + return nil, nil + } +} + +func appSAMLConfigToPb(samlApp *query.SAMLApp) app.ApplicationConfig { + if samlApp == nil { + return &app.Application_SamlConfig{ + SamlConfig: &app.SAMLConfig{ + Metadata: &app.SAMLConfig_MetadataXml{}, + LoginVersion: &app.LoginVersion{}, + }, + } + } + + return &app.Application_SamlConfig{ + SamlConfig: &app.SAMLConfig{ + Metadata: &app.SAMLConfig_MetadataXml{MetadataXml: samlApp.Metadata}, + LoginVersion: loginVersionToPb(samlApp.LoginVersion, samlApp.LoginBaseURI), + }, + } +} diff --git a/internal/api/grpc/app/v2beta/convert/saml_app_test.go b/internal/api/grpc/app/v2beta/convert/saml_app_test.go new file mode 100644 index 0000000000..b41ec432b6 --- /dev/null +++ b/internal/api/grpc/app/v2beta/convert/saml_app_test.go @@ -0,0 +1,256 @@ +package convert + +import ( + "fmt" + "net/url" + "testing" + + "github.com/brianvoe/gofakeit/v6" + "github.com/muhlemmer/gu" + "github.com/stretchr/testify/assert" + + "github.com/zitadel/zitadel/internal/domain" + "github.com/zitadel/zitadel/internal/eventstore/v1/models" + "github.com/zitadel/zitadel/internal/query" + app "github.com/zitadel/zitadel/pkg/grpc/app/v2beta" +) + +func samlMetadataGen(entityID string) []byte { + str := fmt.Sprintf(` + + + urn:oasis:names:tc:SAML:1.1:nameid-format:unspecified + + + + +`, + entityID) + + return []byte(str) +} + +func TestCreateSAMLAppRequestToDomain(t *testing.T) { + t.Parallel() + + genMetaForValidRequest := samlMetadataGen(gofakeit.URL()) + + tt := []struct { + testName string + appName string + projectID string + req *app.CreateSAMLApplicationRequest + + expectedResponse *domain.SAMLApp + expectedError error + }{ + { + testName: "login version error", + appName: "test-app", + projectID: "proj-1", + req: &app.CreateSAMLApplicationRequest{ + Metadata: &app.CreateSAMLApplicationRequest_MetadataXml{ + MetadataXml: samlMetadataGen(gofakeit.URL()), + }, + LoginVersion: &app.LoginVersion{ + Version: &app.LoginVersion_LoginV2{ + LoginV2: &app.LoginV2{BaseUri: gu.Ptr("%+o")}, + }, + }, + }, + expectedError: &url.Error{ + URL: "%+o", + Op: "parse", + Err: url.EscapeError("%+o"), + }, + }, + { + testName: "valid request", + appName: "test-app", + projectID: "proj-1", + req: &app.CreateSAMLApplicationRequest{ + Metadata: &app.CreateSAMLApplicationRequest_MetadataXml{ + MetadataXml: genMetaForValidRequest, + }, + LoginVersion: nil, + }, + + expectedResponse: &domain.SAMLApp{ + ObjectRoot: models.ObjectRoot{AggregateID: "proj-1"}, + AppName: "test-app", + Metadata: genMetaForValidRequest, + MetadataURL: gu.Ptr(""), + LoginVersion: gu.Ptr(domain.LoginVersionUnspecified), + LoginBaseURI: gu.Ptr(""), + State: 0, + }, + }, + { + testName: "nil request", + appName: "test-app", + projectID: "proj-1", + req: nil, + + expectedResponse: &domain.SAMLApp{ + AppName: "test-app", + ObjectRoot: models.ObjectRoot{AggregateID: "proj-1"}, + MetadataURL: gu.Ptr(""), + LoginVersion: gu.Ptr(domain.LoginVersionUnspecified), + LoginBaseURI: gu.Ptr(""), + }, + }, + } + + for _, tc := range tt { + t.Run(tc.testName, func(t *testing.T) { + t.Parallel() + + // When + res, err := CreateSAMLAppRequestToDomain(tc.appName, tc.projectID, tc.req) + + // Then + assert.Equal(t, tc.expectedError, err) + assert.Equal(t, tc.expectedResponse, res) + }) + } +} +func TestUpdateSAMLAppConfigRequestToDomain(t *testing.T) { + t.Parallel() + + genMetaForValidRequest := samlMetadataGen(gofakeit.URL()) + + tt := []struct { + testName string + appID string + projectID string + req *app.UpdateSAMLApplicationConfigurationRequest + + expectedResponse *domain.SAMLApp + expectedError error + }{ + { + testName: "login version error", + appID: "app-1", + projectID: "proj-1", + req: &app.UpdateSAMLApplicationConfigurationRequest{ + Metadata: &app.UpdateSAMLApplicationConfigurationRequest_MetadataXml{ + MetadataXml: samlMetadataGen(gofakeit.URL()), + }, + LoginVersion: &app.LoginVersion{ + Version: &app.LoginVersion_LoginV2{ + LoginV2: &app.LoginV2{BaseUri: gu.Ptr("%+o")}, + }, + }, + }, + expectedError: &url.Error{ + URL: "%+o", + Op: "parse", + Err: url.EscapeError("%+o"), + }, + }, + { + testName: "valid request", + appID: "app-1", + projectID: "proj-1", + req: &app.UpdateSAMLApplicationConfigurationRequest{ + Metadata: &app.UpdateSAMLApplicationConfigurationRequest_MetadataXml{ + MetadataXml: genMetaForValidRequest, + }, + LoginVersion: nil, + }, + expectedResponse: &domain.SAMLApp{ + ObjectRoot: models.ObjectRoot{AggregateID: "proj-1"}, + AppID: "app-1", + Metadata: genMetaForValidRequest, + LoginVersion: gu.Ptr(domain.LoginVersionUnspecified), + LoginBaseURI: gu.Ptr(""), + }, + }, + { + testName: "nil request", + appID: "app-1", + projectID: "proj-1", + req: nil, + expectedResponse: &domain.SAMLApp{ + ObjectRoot: models.ObjectRoot{AggregateID: "proj-1"}, + AppID: "app-1", + LoginVersion: gu.Ptr(domain.LoginVersionUnspecified), + LoginBaseURI: gu.Ptr(""), + }, + }, + } + + for _, tc := range tt { + t.Run(tc.testName, func(t *testing.T) { + t.Parallel() + + // When + res, err := UpdateSAMLAppConfigRequestToDomain(tc.appID, tc.projectID, tc.req) + + // Then + assert.Equal(t, tc.expectedError, err) + assert.Equal(t, tc.expectedResponse, res) + }) + } +} + +func TestAppSAMLConfigToPb(t *testing.T) { + t.Parallel() + + metadata := samlMetadataGen(gofakeit.URL()) + + tt := []struct { + name string + inputSAMLApp *query.SAMLApp + + expectedPbApp app.ApplicationConfig + }{ + { + name: "valid conversion", + inputSAMLApp: &query.SAMLApp{ + Metadata: metadata, + LoginVersion: domain.LoginVersion2, + LoginBaseURI: gu.Ptr("https://example.com"), + }, + expectedPbApp: &app.Application_SamlConfig{ + SamlConfig: &app.SAMLConfig{ + Metadata: &app.SAMLConfig_MetadataXml{ + MetadataXml: metadata, + }, + LoginVersion: &app.LoginVersion{ + Version: &app.LoginVersion_LoginV2{ + LoginV2: &app.LoginV2{BaseUri: gu.Ptr("https://example.com")}, + }, + }, + }, + }, + }, + { + name: "nil saml app", + inputSAMLApp: nil, + expectedPbApp: &app.Application_SamlConfig{ + SamlConfig: &app.SAMLConfig{ + Metadata: &app.SAMLConfig_MetadataXml{}, + LoginVersion: &app.LoginVersion{}, + }, + }, + }, + } + + for _, tc := range tt { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + // When + got := appSAMLConfigToPb(tc.inputSAMLApp) + + // Then + assert.Equal(t, tc.expectedPbApp, got) + }) + } +} diff --git a/internal/api/grpc/app/v2beta/integration_test/app_test.go b/internal/api/grpc/app/v2beta/integration_test/app_test.go new file mode 100644 index 0000000000..1ba46987cf --- /dev/null +++ b/internal/api/grpc/app/v2beta/integration_test/app_test.go @@ -0,0 +1,1446 @@ +//go:build integration + +package instance_test + +import ( + "context" + "fmt" + "testing" + + "github.com/brianvoe/gofakeit/v6" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" + + app "github.com/zitadel/zitadel/pkg/grpc/app/v2beta" + org "github.com/zitadel/zitadel/pkg/grpc/org/v2beta" +) + +func TestCreateApplication(t *testing.T) { + p := instance.CreateProject(IAMOwnerCtx, t, instance.DefaultOrg.GetId(), gofakeit.Name(), false, false) + + t.Parallel() + + notExistingProjectID := gofakeit.UUID() + + tt := []struct { + testName string + creationRequest *app.CreateApplicationRequest + inputCtx context.Context + + expectedResponseType string + expectedErrorType codes.Code + }{ + { + testName: "when project for API app creation is not found should return failed precondition error", + inputCtx: IAMOwnerCtx, + creationRequest: &app.CreateApplicationRequest{ + ProjectId: notExistingProjectID, + Name: "App Name", + CreationRequestType: &app.CreateApplicationRequest_ApiRequest{ + ApiRequest: &app.CreateAPIApplicationRequest{ + AuthMethodType: app.APIAuthMethodType_API_AUTH_METHOD_TYPE_PRIVATE_KEY_JWT, + }, + }, + }, + expectedErrorType: codes.FailedPrecondition, + }, + { + testName: "when CreateAPIApp request is valid should create app and return no error", + inputCtx: IAMOwnerCtx, + creationRequest: &app.CreateApplicationRequest{ + ProjectId: p.GetId(), + Name: "App Name", + CreationRequestType: &app.CreateApplicationRequest_ApiRequest{ + ApiRequest: &app.CreateAPIApplicationRequest{ + AuthMethodType: app.APIAuthMethodType_API_AUTH_METHOD_TYPE_PRIVATE_KEY_JWT, + }, + }, + }, + expectedResponseType: fmt.Sprintf("%T", &app.CreateApplicationResponse_ApiResponse{}), + }, + { + testName: "when project for OIDC app creation is not found should return failed precondition error", + inputCtx: IAMOwnerCtx, + creationRequest: &app.CreateApplicationRequest{ + ProjectId: notExistingProjectID, + Name: "App Name", + CreationRequestType: &app.CreateApplicationRequest_OidcRequest{ + OidcRequest: &app.CreateOIDCApplicationRequest{ + RedirectUris: []string{"http://example.com"}, + ResponseTypes: []app.OIDCResponseType{app.OIDCResponseType_OIDC_RESPONSE_TYPE_CODE}, + GrantTypes: []app.OIDCGrantType{app.OIDCGrantType_OIDC_GRANT_TYPE_AUTHORIZATION_CODE}, + AppType: app.OIDCAppType_OIDC_APP_TYPE_WEB, + AuthMethodType: app.OIDCAuthMethodType_OIDC_AUTH_METHOD_TYPE_BASIC, + PostLogoutRedirectUris: []string{"http://example.com/home"}, + Version: app.OIDCVersion_OIDC_VERSION_1_0, + AccessTokenType: app.OIDCTokenType_OIDC_TOKEN_TYPE_JWT, + BackChannelLogoutUri: "http://example.com/logout", + LoginVersion: &app.LoginVersion{ + Version: &app.LoginVersion_LoginV2{ + LoginV2: &app.LoginV2{ + BaseUri: &baseURI, + }, + }, + }, + }, + }, + }, + expectedErrorType: codes.FailedPrecondition, + }, + { + testName: "when CreateOIDCApp request is valid should create app and return no error", + inputCtx: IAMOwnerCtx, + creationRequest: &app.CreateApplicationRequest{ + ProjectId: p.GetId(), + Name: gofakeit.AppName(), + CreationRequestType: &app.CreateApplicationRequest_OidcRequest{ + OidcRequest: &app.CreateOIDCApplicationRequest{ + RedirectUris: []string{"http://example.com"}, + ResponseTypes: []app.OIDCResponseType{app.OIDCResponseType_OIDC_RESPONSE_TYPE_CODE}, + GrantTypes: []app.OIDCGrantType{app.OIDCGrantType_OIDC_GRANT_TYPE_AUTHORIZATION_CODE}, + AppType: app.OIDCAppType_OIDC_APP_TYPE_WEB, + AuthMethodType: app.OIDCAuthMethodType_OIDC_AUTH_METHOD_TYPE_BASIC, + PostLogoutRedirectUris: []string{"http://example.com/home"}, + Version: app.OIDCVersion_OIDC_VERSION_1_0, + AccessTokenType: app.OIDCTokenType_OIDC_TOKEN_TYPE_JWT, + BackChannelLogoutUri: "http://example.com/logout", + LoginVersion: &app.LoginVersion{ + Version: &app.LoginVersion_LoginV2{ + LoginV2: &app.LoginV2{ + BaseUri: &baseURI, + }, + }, + }, + }, + }, + }, + + expectedResponseType: fmt.Sprintf("%T", &app.CreateApplicationResponse_OidcResponse{}), + }, + { + testName: "when project for SAML app creation is not found should return failed precondition error", + inputCtx: IAMOwnerCtx, + creationRequest: &app.CreateApplicationRequest{ + ProjectId: notExistingProjectID, + Name: gofakeit.AppName(), + CreationRequestType: &app.CreateApplicationRequest_SamlRequest{ + SamlRequest: &app.CreateSAMLApplicationRequest{ + Metadata: &app.CreateSAMLApplicationRequest_MetadataUrl{ + MetadataUrl: "http://example.com/metas", + }, + LoginVersion: &app.LoginVersion{ + Version: &app.LoginVersion_LoginV2{ + LoginV2: &app.LoginV2{ + BaseUri: &baseURI, + }, + }, + }, + }, + }, + }, + expectedErrorType: codes.FailedPrecondition, + }, + { + testName: "when CreateSAMLApp request is valid should create app and return no error", + inputCtx: IAMOwnerCtx, + creationRequest: &app.CreateApplicationRequest{ + ProjectId: p.GetId(), + Name: gofakeit.AppName(), + CreationRequestType: &app.CreateApplicationRequest_SamlRequest{ + SamlRequest: &app.CreateSAMLApplicationRequest{ + Metadata: &app.CreateSAMLApplicationRequest_MetadataXml{ + MetadataXml: samlMetadataGen(gofakeit.URL()), + }, + LoginVersion: &app.LoginVersion{ + Version: &app.LoginVersion_LoginV2{ + LoginV2: &app.LoginV2{ + BaseUri: &baseURI, + }, + }, + }, + }, + }, + }, + expectedResponseType: fmt.Sprintf("%T", &app.CreateApplicationResponse_SamlResponse{}), + }, + } + + for _, tc := range tt { + t.Run(tc.testName, func(t *testing.T) { + t.Parallel() + res, err := instance.Client.AppV2Beta.CreateApplication(tc.inputCtx, tc.creationRequest) + + require.Equal(t, tc.expectedErrorType, status.Code(err)) + if tc.expectedErrorType == codes.OK { + resType := fmt.Sprintf("%T", res.GetCreationResponseType()) + assert.Equal(t, tc.expectedResponseType, resType) + assert.NotZero(t, res.GetAppId()) + assert.NotZero(t, res.GetCreationDate()) + } + }) + } +} + +func TestCreateApplication_WithDifferentPermissions(t *testing.T) { + p, projectOwnerCtx := getProjectAndProjectContext(t, instance, IAMOwnerCtx) + + t.Parallel() + + tt := []struct { + testName string + creationRequest *app.CreateApplicationRequest + inputCtx context.Context + + expectedResponseType string + expectedErrorType codes.Code + }{ + // Login User with no project.app.write + { + testName: "when user has no project.app.write permission for API request should return permission error", + inputCtx: LoginUserCtx, + creationRequest: &app.CreateApplicationRequest{ + ProjectId: p.GetId(), + Name: gofakeit.Name(), + CreationRequestType: &app.CreateApplicationRequest_ApiRequest{ + ApiRequest: &app.CreateAPIApplicationRequest{ + AuthMethodType: app.APIAuthMethodType_API_AUTH_METHOD_TYPE_PRIVATE_KEY_JWT, + }, + }, + }, + expectedErrorType: codes.PermissionDenied, + }, + { + testName: "when user has no project.app.write permission for OIDC request should return permission error", + inputCtx: LoginUserCtx, + creationRequest: &app.CreateApplicationRequest{ + ProjectId: p.GetId(), + Name: gofakeit.AppName(), + CreationRequestType: &app.CreateApplicationRequest_OidcRequest{ + OidcRequest: &app.CreateOIDCApplicationRequest{ + RedirectUris: []string{"http://example.com"}, + ResponseTypes: []app.OIDCResponseType{app.OIDCResponseType_OIDC_RESPONSE_TYPE_CODE}, + GrantTypes: []app.OIDCGrantType{app.OIDCGrantType_OIDC_GRANT_TYPE_AUTHORIZATION_CODE}, + AppType: app.OIDCAppType_OIDC_APP_TYPE_WEB, + AuthMethodType: app.OIDCAuthMethodType_OIDC_AUTH_METHOD_TYPE_BASIC, + PostLogoutRedirectUris: []string{"http://example.com/home"}, + Version: app.OIDCVersion_OIDC_VERSION_1_0, + AccessTokenType: app.OIDCTokenType_OIDC_TOKEN_TYPE_JWT, + BackChannelLogoutUri: "http://example.com/logout", + LoginVersion: &app.LoginVersion{ + Version: &app.LoginVersion_LoginV2{ + LoginV2: &app.LoginV2{ + BaseUri: &baseURI, + }, + }, + }, + }, + }, + }, + + expectedErrorType: codes.PermissionDenied, + }, + { + testName: "when user has no project.app.write permission for SAML request should return permission error", + inputCtx: LoginUserCtx, + creationRequest: &app.CreateApplicationRequest{ + ProjectId: p.GetId(), + Name: gofakeit.AppName(), + CreationRequestType: &app.CreateApplicationRequest_SamlRequest{ + SamlRequest: &app.CreateSAMLApplicationRequest{ + Metadata: &app.CreateSAMLApplicationRequest_MetadataXml{ + MetadataXml: samlMetadataGen(gofakeit.URL()), + }, + LoginVersion: &app.LoginVersion{ + Version: &app.LoginVersion_LoginV2{ + LoginV2: &app.LoginV2{ + BaseUri: &baseURI, + }, + }, + }, + }, + }, + }, + expectedErrorType: codes.PermissionDenied, + }, + + // OrgOwner with project.app.write permission + { + testName: "when user is OrgOwner API request should succeed", + inputCtx: OrgOwnerCtx, + creationRequest: &app.CreateApplicationRequest{ + ProjectId: p.GetId(), + Name: gofakeit.Name(), + CreationRequestType: &app.CreateApplicationRequest_ApiRequest{ + ApiRequest: &app.CreateAPIApplicationRequest{ + AuthMethodType: app.APIAuthMethodType_API_AUTH_METHOD_TYPE_PRIVATE_KEY_JWT, + }, + }, + }, + expectedResponseType: fmt.Sprintf("%T", &app.CreateApplicationResponse_ApiResponse{}), + }, + { + testName: "when user is OrgOwner OIDC request should succeed", + inputCtx: OrgOwnerCtx, + creationRequest: &app.CreateApplicationRequest{ + ProjectId: p.GetId(), + Name: gofakeit.AppName(), + CreationRequestType: &app.CreateApplicationRequest_OidcRequest{ + OidcRequest: &app.CreateOIDCApplicationRequest{ + RedirectUris: []string{"http://example.com"}, + ResponseTypes: []app.OIDCResponseType{app.OIDCResponseType_OIDC_RESPONSE_TYPE_CODE}, + GrantTypes: []app.OIDCGrantType{app.OIDCGrantType_OIDC_GRANT_TYPE_AUTHORIZATION_CODE}, + AppType: app.OIDCAppType_OIDC_APP_TYPE_WEB, + AuthMethodType: app.OIDCAuthMethodType_OIDC_AUTH_METHOD_TYPE_BASIC, + PostLogoutRedirectUris: []string{"http://example.com/home"}, + Version: app.OIDCVersion_OIDC_VERSION_1_0, + AccessTokenType: app.OIDCTokenType_OIDC_TOKEN_TYPE_JWT, + BackChannelLogoutUri: "http://example.com/logout", + LoginVersion: &app.LoginVersion{ + Version: &app.LoginVersion_LoginV2{ + LoginV2: &app.LoginV2{ + BaseUri: &baseURI, + }, + }, + }, + }, + }, + }, + + expectedResponseType: fmt.Sprintf("%T", &app.CreateApplicationResponse_OidcResponse{}), + }, + { + testName: "when user is OrgOwner SAML request should succeed", + inputCtx: OrgOwnerCtx, + creationRequest: &app.CreateApplicationRequest{ + ProjectId: p.GetId(), + Name: gofakeit.AppName(), + CreationRequestType: &app.CreateApplicationRequest_SamlRequest{ + SamlRequest: &app.CreateSAMLApplicationRequest{ + Metadata: &app.CreateSAMLApplicationRequest_MetadataXml{ + MetadataXml: samlMetadataGen(gofakeit.URL()), + }, + LoginVersion: &app.LoginVersion{ + Version: &app.LoginVersion_LoginV2{ + LoginV2: &app.LoginV2{ + BaseUri: &baseURI, + }, + }, + }, + }, + }, + }, + expectedResponseType: fmt.Sprintf("%T", &app.CreateApplicationResponse_SamlResponse{}), + }, + + // Project owner with project.app.write permission + { + testName: "when user is ProjectOwner API request should succeed", + inputCtx: projectOwnerCtx, + creationRequest: &app.CreateApplicationRequest{ + ProjectId: p.GetId(), + Name: gofakeit.Name(), + CreationRequestType: &app.CreateApplicationRequest_ApiRequest{ + ApiRequest: &app.CreateAPIApplicationRequest{ + AuthMethodType: app.APIAuthMethodType_API_AUTH_METHOD_TYPE_PRIVATE_KEY_JWT, + }, + }, + }, + expectedResponseType: fmt.Sprintf("%T", &app.CreateApplicationResponse_ApiResponse{}), + }, + { + testName: "when user is ProjectOwner OIDC request should succeed", + inputCtx: projectOwnerCtx, + creationRequest: &app.CreateApplicationRequest{ + ProjectId: p.GetId(), + Name: gofakeit.AppName(), + CreationRequestType: &app.CreateApplicationRequest_OidcRequest{ + OidcRequest: &app.CreateOIDCApplicationRequest{ + RedirectUris: []string{"http://example.com"}, + ResponseTypes: []app.OIDCResponseType{app.OIDCResponseType_OIDC_RESPONSE_TYPE_CODE}, + GrantTypes: []app.OIDCGrantType{app.OIDCGrantType_OIDC_GRANT_TYPE_AUTHORIZATION_CODE}, + AppType: app.OIDCAppType_OIDC_APP_TYPE_WEB, + AuthMethodType: app.OIDCAuthMethodType_OIDC_AUTH_METHOD_TYPE_BASIC, + PostLogoutRedirectUris: []string{"http://example.com/home"}, + Version: app.OIDCVersion_OIDC_VERSION_1_0, + AccessTokenType: app.OIDCTokenType_OIDC_TOKEN_TYPE_JWT, + BackChannelLogoutUri: "http://example.com/logout", + LoginVersion: &app.LoginVersion{ + Version: &app.LoginVersion_LoginV2{ + LoginV2: &app.LoginV2{ + BaseUri: &baseURI, + }, + }, + }, + }, + }, + }, + + expectedResponseType: fmt.Sprintf("%T", &app.CreateApplicationResponse_OidcResponse{}), + }, + { + testName: "when user is ProjectOwner SAML request should succeed", + inputCtx: projectOwnerCtx, + creationRequest: &app.CreateApplicationRequest{ + ProjectId: p.GetId(), + Name: gofakeit.AppName(), + CreationRequestType: &app.CreateApplicationRequest_SamlRequest{ + SamlRequest: &app.CreateSAMLApplicationRequest{ + Metadata: &app.CreateSAMLApplicationRequest_MetadataXml{ + MetadataXml: samlMetadataGen(gofakeit.URL()), + }, + LoginVersion: &app.LoginVersion{ + Version: &app.LoginVersion_LoginV2{ + LoginV2: &app.LoginV2{ + BaseUri: &baseURI, + }, + }, + }, + }, + }, + }, + expectedResponseType: fmt.Sprintf("%T", &app.CreateApplicationResponse_SamlResponse{}), + }, + } + + for _, tc := range tt { + t.Run(tc.testName, func(t *testing.T) { + t.Parallel() + res, err := instance.Client.AppV2Beta.CreateApplication(tc.inputCtx, tc.creationRequest) + + require.Equal(t, tc.expectedErrorType, status.Code(err)) + if tc.expectedErrorType == codes.OK { + resType := fmt.Sprintf("%T", res.GetCreationResponseType()) + assert.Equal(t, tc.expectedResponseType, resType) + assert.NotZero(t, res.GetAppId()) + assert.NotZero(t, res.GetCreationDate()) + } + }) + } +} + +func TestUpdateApplication(t *testing.T) { + orgNotInCtx := instance.CreateOrganization(IAMOwnerCtx, gofakeit.Name(), gofakeit.Email()) + pNotInCtx := instance.CreateProject(IAMOwnerCtx, t, orgNotInCtx.GetOrganizationId(), gofakeit.AppName(), false, false) + + p := instance.CreateProject(IAMOwnerCtx, t, instance.DefaultOrg.GetId(), gofakeit.Name(), false, false) + + baseURI := "http://example.com" + + t.Cleanup(func() { + instance.Client.OrgV2beta.DeleteOrganization(IAMOwnerCtx, &org.DeleteOrganizationRequest{ + Id: orgNotInCtx.GetOrganizationId(), + }) + }) + + reqForAppNameCreation := &app.CreateApplicationRequest_ApiRequest{ + ApiRequest: &app.CreateAPIApplicationRequest{AuthMethodType: app.APIAuthMethodType_API_AUTH_METHOD_TYPE_PRIVATE_KEY_JWT}, + } + reqForAPIAppCreation := reqForAppNameCreation + + reqForOIDCAppCreation := &app.CreateApplicationRequest_OidcRequest{ + OidcRequest: &app.CreateOIDCApplicationRequest{ + RedirectUris: []string{"http://example.com"}, + ResponseTypes: []app.OIDCResponseType{app.OIDCResponseType_OIDC_RESPONSE_TYPE_CODE}, + GrantTypes: []app.OIDCGrantType{app.OIDCGrantType_OIDC_GRANT_TYPE_AUTHORIZATION_CODE}, + AppType: app.OIDCAppType_OIDC_APP_TYPE_WEB, + AuthMethodType: app.OIDCAuthMethodType_OIDC_AUTH_METHOD_TYPE_BASIC, + PostLogoutRedirectUris: []string{"http://example.com/home"}, + Version: app.OIDCVersion_OIDC_VERSION_1_0, + AccessTokenType: app.OIDCTokenType_OIDC_TOKEN_TYPE_JWT, + BackChannelLogoutUri: "http://example.com/logout", + LoginVersion: &app.LoginVersion{ + Version: &app.LoginVersion_LoginV2{ + LoginV2: &app.LoginV2{ + BaseUri: &baseURI, + }, + }, + }, + }, + } + + samlMetas := samlMetadataGen(gofakeit.URL()) + reqForSAMLAppCreation := &app.CreateApplicationRequest_SamlRequest{ + SamlRequest: &app.CreateSAMLApplicationRequest{ + Metadata: &app.CreateSAMLApplicationRequest_MetadataXml{ + MetadataXml: samlMetas, + }, + LoginVersion: &app.LoginVersion{ + Version: &app.LoginVersion_LoginV2{ + LoginV2: &app.LoginV2{ + BaseUri: &baseURI, + }, + }, + }, + }, + } + + appForNameChange, appNameChangeErr := instance.Client.AppV2Beta.CreateApplication(IAMOwnerCtx, &app.CreateApplicationRequest{ + ProjectId: p.GetId(), + Name: gofakeit.AppName(), + CreationRequestType: reqForAppNameCreation, + }) + require.Nil(t, appNameChangeErr) + + appForAPIConfigChange, appAPIConfigChangeErr := instance.Client.AppV2Beta.CreateApplication(IAMOwnerCtx, &app.CreateApplicationRequest{ + ProjectId: p.GetId(), + Name: gofakeit.AppName(), + CreationRequestType: reqForAPIAppCreation, + }) + require.Nil(t, appAPIConfigChangeErr) + + appForOIDCConfigChange, appOIDCConfigChangeErr := instance.Client.AppV2Beta.CreateApplication(IAMOwnerCtx, &app.CreateApplicationRequest{ + ProjectId: p.GetId(), + Name: gofakeit.AppName(), + CreationRequestType: reqForOIDCAppCreation, + }) + require.Nil(t, appOIDCConfigChangeErr) + + appForSAMLConfigChange, appSAMLConfigChangeErr := instance.Client.AppV2Beta.CreateApplication(IAMOwnerCtx, &app.CreateApplicationRequest{ + ProjectId: p.GetId(), + Name: gofakeit.AppName(), + CreationRequestType: reqForSAMLAppCreation, + }) + require.Nil(t, appSAMLConfigChangeErr) + + t.Parallel() + + tt := []struct { + testName string + inputCtx context.Context + updateRequest *app.UpdateApplicationRequest + + expectedErrorType codes.Code + }{ + { + testName: "when app for app name change request is not found should return not found error", + inputCtx: IAMOwnerCtx, + updateRequest: &app.UpdateApplicationRequest{ + ProjectId: pNotInCtx.GetId(), + Id: appForNameChange.GetAppId(), + Name: "New name", + }, + expectedErrorType: codes.NotFound, + }, + { + testName: "when request for app name change is valid should return updated timestamp", + inputCtx: IAMOwnerCtx, + updateRequest: &app.UpdateApplicationRequest{ + ProjectId: p.GetId(), + Id: appForNameChange.GetAppId(), + + Name: "New name", + }, + }, + + { + testName: "when app for API config change request is not found should return not found error", + inputCtx: IAMOwnerCtx, + updateRequest: &app.UpdateApplicationRequest{ + ProjectId: pNotInCtx.GetId(), + Id: appForAPIConfigChange.GetAppId(), + UpdateRequestType: &app.UpdateApplicationRequest_ApiConfigurationRequest{ + ApiConfigurationRequest: &app.UpdateAPIApplicationConfigurationRequest{ + AuthMethodType: app.APIAuthMethodType_API_AUTH_METHOD_TYPE_PRIVATE_KEY_JWT, + }, + }, + }, + expectedErrorType: codes.NotFound, + }, + { + testName: "when request for API config change is valid should return updated timestamp", + inputCtx: IAMOwnerCtx, + updateRequest: &app.UpdateApplicationRequest{ + ProjectId: p.GetId(), + Id: appForAPIConfigChange.GetAppId(), + UpdateRequestType: &app.UpdateApplicationRequest_ApiConfigurationRequest{ + ApiConfigurationRequest: &app.UpdateAPIApplicationConfigurationRequest{ + AuthMethodType: app.APIAuthMethodType_API_AUTH_METHOD_TYPE_BASIC, + }, + }, + }, + }, + { + testName: "when app for OIDC config change request is not found should return not found error", + inputCtx: IAMOwnerCtx, + updateRequest: &app.UpdateApplicationRequest{ + ProjectId: pNotInCtx.GetId(), + Id: appForOIDCConfigChange.GetAppId(), + UpdateRequestType: &app.UpdateApplicationRequest_OidcConfigurationRequest{ + OidcConfigurationRequest: &app.UpdateOIDCApplicationConfigurationRequest{ + PostLogoutRedirectUris: []string{"http://example.com/home2"}, + }, + }, + }, + expectedErrorType: codes.NotFound, + }, + { + testName: "when request for OIDC config change is valid should return updated timestamp", + inputCtx: IAMOwnerCtx, + updateRequest: &app.UpdateApplicationRequest{ + ProjectId: p.GetId(), + Id: appForOIDCConfigChange.GetAppId(), + UpdateRequestType: &app.UpdateApplicationRequest_OidcConfigurationRequest{ + OidcConfigurationRequest: &app.UpdateOIDCApplicationConfigurationRequest{ + PostLogoutRedirectUris: []string{"http://example.com/home2"}, + }, + }, + }, + }, + + { + testName: "when app for SAML config change request is not found should return not found error", + inputCtx: IAMOwnerCtx, + updateRequest: &app.UpdateApplicationRequest{ + ProjectId: pNotInCtx.GetId(), + Id: appForSAMLConfigChange.GetAppId(), + UpdateRequestType: &app.UpdateApplicationRequest_SamlConfigurationRequest{ + SamlConfigurationRequest: &app.UpdateSAMLApplicationConfigurationRequest{ + Metadata: &app.UpdateSAMLApplicationConfigurationRequest_MetadataXml{ + MetadataXml: samlMetas, + }, + LoginVersion: &app.LoginVersion{Version: &app.LoginVersion_LoginV1{LoginV1: &app.LoginV1{}}}, + }, + }, + }, + expectedErrorType: codes.NotFound, + }, + { + testName: "when request for SAML config change is valid should return updated timestamp", + inputCtx: IAMOwnerCtx, + updateRequest: &app.UpdateApplicationRequest{ + ProjectId: p.GetId(), + Id: appForSAMLConfigChange.GetAppId(), + UpdateRequestType: &app.UpdateApplicationRequest_SamlConfigurationRequest{ + SamlConfigurationRequest: &app.UpdateSAMLApplicationConfigurationRequest{ + Metadata: &app.UpdateSAMLApplicationConfigurationRequest_MetadataXml{ + MetadataXml: samlMetas, + }, + LoginVersion: &app.LoginVersion{Version: &app.LoginVersion_LoginV1{LoginV1: &app.LoginV1{}}}, + }, + }, + }, + }, + } + + for _, tc := range tt { + t.Run(tc.testName, func(t *testing.T) { + t.Parallel() + res, err := instance.Client.AppV2Beta.UpdateApplication(tc.inputCtx, tc.updateRequest) + + require.Equal(t, tc.expectedErrorType, status.Code(err)) + if tc.expectedErrorType == codes.OK { + assert.NotZero(t, res.GetChangeDate()) + } + }) + } +} + +func TestUpdateApplication_WithDifferentPermissions(t *testing.T) { + baseURI := "http://example.com" + + p, projectOwnerCtx := getProjectAndProjectContext(t, instance, IAMOwnerCtx) + + reqForAppNameCreation := &app.CreateApplicationRequest_ApiRequest{ + ApiRequest: &app.CreateAPIApplicationRequest{AuthMethodType: app.APIAuthMethodType_API_AUTH_METHOD_TYPE_PRIVATE_KEY_JWT}, + } + + appForNameChange, appNameChangeErr := instance.Client.AppV2Beta.CreateApplication(IAMOwnerCtx, &app.CreateApplicationRequest{ + ProjectId: p.GetId(), + Name: gofakeit.AppName(), + CreationRequestType: reqForAppNameCreation, + }) + require.Nil(t, appNameChangeErr) + + appForAPIConfigChangeForProjectOwner := createAPIApp(t, p.GetId()) + appForAPIConfigChangeForOrgOwner := createAPIApp(t, p.GetId()) + appForAPIConfigChangeForLoginUser := createAPIApp(t, p.GetId()) + + appForOIDCConfigChangeForProjectOwner := createOIDCApp(t, baseURI, p.GetId()) + appForOIDCConfigChangeForOrgOwner := createOIDCApp(t, baseURI, p.GetId()) + appForOIDCConfigChangeForLoginUser := createOIDCApp(t, baseURI, p.GetId()) + + samlMetasForProjectOwner, appForSAMLConfigChangeForProjectOwner := createSAMLApp(t, baseURI, p.GetId()) + samlMetasForOrgOwner, appForSAMLConfigChangeForOrgOwner := createSAMLApp(t, baseURI, p.GetId()) + samlMetasForLoginUser, appForSAMLConfigChangeForLoginUser := createSAMLApp(t, baseURI, p.GetId()) + + t.Parallel() + + tt := []struct { + testName string + inputCtx context.Context + updateRequest *app.UpdateApplicationRequest + + expectedErrorType codes.Code + }{ + // ProjectOwner + { + testName: "when user is ProjectOwner app name request should succeed", + inputCtx: projectOwnerCtx, + updateRequest: &app.UpdateApplicationRequest{ + ProjectId: p.GetId(), + Id: appForNameChange.GetAppId(), + + Name: gofakeit.AppName(), + }, + }, + { + testName: "when user is ProjectOwner API app request should succeed", + inputCtx: projectOwnerCtx, + updateRequest: &app.UpdateApplicationRequest{ + ProjectId: p.GetId(), + Id: appForAPIConfigChangeForProjectOwner.GetAppId(), + UpdateRequestType: &app.UpdateApplicationRequest_ApiConfigurationRequest{ + ApiConfigurationRequest: &app.UpdateAPIApplicationConfigurationRequest{ + AuthMethodType: app.APIAuthMethodType_API_AUTH_METHOD_TYPE_BASIC, + }, + }, + }, + }, + { + testName: "when user is ProjectOwner OIDC app request should succeed", + inputCtx: projectOwnerCtx, + updateRequest: &app.UpdateApplicationRequest{ + ProjectId: p.GetId(), + Id: appForOIDCConfigChangeForProjectOwner.GetAppId(), + UpdateRequestType: &app.UpdateApplicationRequest_OidcConfigurationRequest{ + OidcConfigurationRequest: &app.UpdateOIDCApplicationConfigurationRequest{ + PostLogoutRedirectUris: []string{"http://example.com/home2"}, + }, + }, + }, + }, + { + testName: "when user is ProjectOwner SAML request should succeed", + inputCtx: projectOwnerCtx, + updateRequest: &app.UpdateApplicationRequest{ + ProjectId: p.GetId(), + Id: appForSAMLConfigChangeForProjectOwner.GetAppId(), + UpdateRequestType: &app.UpdateApplicationRequest_SamlConfigurationRequest{ + SamlConfigurationRequest: &app.UpdateSAMLApplicationConfigurationRequest{ + Metadata: &app.UpdateSAMLApplicationConfigurationRequest_MetadataXml{ + MetadataXml: samlMetasForProjectOwner, + }, + LoginVersion: &app.LoginVersion{Version: &app.LoginVersion_LoginV1{LoginV1: &app.LoginV1{}}}, + }, + }, + }, + }, + + // OrgOwner context + { + testName: "when user is OrgOwner app name request should succeed", + inputCtx: OrgOwnerCtx, + updateRequest: &app.UpdateApplicationRequest{ + ProjectId: p.GetId(), + Id: appForNameChange.GetAppId(), + + Name: gofakeit.AppName(), + }, + }, + { + testName: "when user is OrgOwner API app request should succeed", + inputCtx: OrgOwnerCtx, + updateRequest: &app.UpdateApplicationRequest{ + ProjectId: p.GetId(), + Id: appForAPIConfigChangeForOrgOwner.GetAppId(), + UpdateRequestType: &app.UpdateApplicationRequest_ApiConfigurationRequest{ + ApiConfigurationRequest: &app.UpdateAPIApplicationConfigurationRequest{ + AuthMethodType: app.APIAuthMethodType_API_AUTH_METHOD_TYPE_BASIC, + }, + }, + }, + }, + { + testName: "when user is OrgOwner OIDC app request should succeed", + inputCtx: OrgOwnerCtx, + updateRequest: &app.UpdateApplicationRequest{ + ProjectId: p.GetId(), + Id: appForOIDCConfigChangeForOrgOwner.GetAppId(), + UpdateRequestType: &app.UpdateApplicationRequest_OidcConfigurationRequest{ + OidcConfigurationRequest: &app.UpdateOIDCApplicationConfigurationRequest{ + PostLogoutRedirectUris: []string{"http://example.com/home2"}, + }, + }, + }, + }, + { + testName: "when user is OrgOwner SAML request should succeed", + inputCtx: OrgOwnerCtx, + updateRequest: &app.UpdateApplicationRequest{ + ProjectId: p.GetId(), + Id: appForSAMLConfigChangeForOrgOwner.GetAppId(), + UpdateRequestType: &app.UpdateApplicationRequest_SamlConfigurationRequest{ + SamlConfigurationRequest: &app.UpdateSAMLApplicationConfigurationRequest{ + Metadata: &app.UpdateSAMLApplicationConfigurationRequest_MetadataXml{ + MetadataXml: samlMetasForOrgOwner, + }, + LoginVersion: &app.LoginVersion{Version: &app.LoginVersion_LoginV1{LoginV1: &app.LoginV1{}}}, + }, + }, + }, + }, + + // LoginUser + { + testName: "when user has no project.app.write permission for app name change request should return permission error", + inputCtx: LoginUserCtx, + updateRequest: &app.UpdateApplicationRequest{ + ProjectId: p.GetId(), + Id: appForNameChange.GetAppId(), + + Name: gofakeit.AppName(), + }, + expectedErrorType: codes.PermissionDenied, + }, + { + testName: "when user has no project.app.write permission for API request should return permission error", + inputCtx: LoginUserCtx, + updateRequest: &app.UpdateApplicationRequest{ + ProjectId: p.GetId(), + Id: appForAPIConfigChangeForLoginUser.GetAppId(), + UpdateRequestType: &app.UpdateApplicationRequest_ApiConfigurationRequest{ + ApiConfigurationRequest: &app.UpdateAPIApplicationConfigurationRequest{ + AuthMethodType: app.APIAuthMethodType_API_AUTH_METHOD_TYPE_BASIC, + }, + }, + }, + expectedErrorType: codes.PermissionDenied, + }, + { + testName: "when user has no project.app.write permission for OIDC request should return permission error", + inputCtx: LoginUserCtx, + updateRequest: &app.UpdateApplicationRequest{ + ProjectId: p.GetId(), + Id: appForOIDCConfigChangeForLoginUser.GetAppId(), + UpdateRequestType: &app.UpdateApplicationRequest_OidcConfigurationRequest{ + OidcConfigurationRequest: &app.UpdateOIDCApplicationConfigurationRequest{ + PostLogoutRedirectUris: []string{"http://example.com/home2"}, + }, + }, + }, + expectedErrorType: codes.PermissionDenied, + }, + { + testName: "when user has no project.app.write permission for SAML request should return permission error", + inputCtx: LoginUserCtx, + updateRequest: &app.UpdateApplicationRequest{ + ProjectId: p.GetId(), + Id: appForSAMLConfigChangeForLoginUser.GetAppId(), + UpdateRequestType: &app.UpdateApplicationRequest_SamlConfigurationRequest{ + SamlConfigurationRequest: &app.UpdateSAMLApplicationConfigurationRequest{ + Metadata: &app.UpdateSAMLApplicationConfigurationRequest_MetadataXml{ + MetadataXml: samlMetasForLoginUser, + }, + LoginVersion: &app.LoginVersion{Version: &app.LoginVersion_LoginV1{LoginV1: &app.LoginV1{}}}, + }, + }, + }, + expectedErrorType: codes.PermissionDenied, + }, + } + + for _, tc := range tt { + t.Run(tc.testName, func(t *testing.T) { + t.Parallel() + res, err := instance.Client.AppV2Beta.UpdateApplication(tc.inputCtx, tc.updateRequest) + + require.Equal(t, tc.expectedErrorType, status.Code(err)) + if tc.expectedErrorType == codes.OK { + assert.NotZero(t, res.GetChangeDate()) + } + }) + } +} + +func TestDeleteApplication(t *testing.T) { + p := instance.CreateProject(IAMOwnerCtx, t, instance.DefaultOrg.GetId(), gofakeit.Name(), false, false) + + reqForAppNameCreation := &app.CreateApplicationRequest_ApiRequest{ + ApiRequest: &app.CreateAPIApplicationRequest{AuthMethodType: app.APIAuthMethodType_API_AUTH_METHOD_TYPE_PRIVATE_KEY_JWT}, + } + + appToDelete, appNameChangeErr := instance.Client.AppV2Beta.CreateApplication(IAMOwnerCtx, &app.CreateApplicationRequest{ + ProjectId: p.GetId(), + Name: gofakeit.AppName(), + CreationRequestType: reqForAppNameCreation, + }) + require.Nil(t, appNameChangeErr) + + t.Parallel() + tt := []struct { + testName string + deleteRequest *app.DeleteApplicationRequest + inputCtx context.Context + + expectedErrorType codes.Code + }{ + { + testName: "when app to delete is not found should return not found error", + inputCtx: IAMOwnerCtx, + deleteRequest: &app.DeleteApplicationRequest{ + ProjectId: p.GetId(), + Id: gofakeit.Sentence(2), + }, + expectedErrorType: codes.NotFound, + }, + { + testName: "when app to delete is found should return deletion time", + inputCtx: IAMOwnerCtx, + deleteRequest: &app.DeleteApplicationRequest{ + ProjectId: p.GetId(), + Id: appToDelete.GetAppId(), + }, + }, + } + + for _, tc := range tt { + t.Run(tc.testName, func(t *testing.T) { + t.Parallel() + + // When + res, err := instance.Client.AppV2Beta.DeleteApplication(tc.inputCtx, tc.deleteRequest) + + // Then + require.Equal(t, tc.expectedErrorType, status.Code(err)) + if tc.expectedErrorType == codes.OK { + assert.NotZero(t, res.GetDeletionDate()) + } + }) + } +} + +func TestDeleteApplication_WithDifferentPermissions(t *testing.T) { + p, projectOwnerCtx := getProjectAndProjectContext(t, instance, IAMOwnerCtx) + + appToDeleteForLoginUser := createAPIApp(t, p.GetId()) + appToDeleteForProjectOwner := createAPIApp(t, p.GetId()) + appToDeleteForOrgOwner := createAPIApp(t, p.GetId()) + + t.Parallel() + tt := []struct { + testName string + deleteRequest *app.DeleteApplicationRequest + inputCtx context.Context + + expectedErrorType codes.Code + }{ + // Login User + { + testName: "when user has no project.app.delete permission for app delete request should return permission error", + inputCtx: LoginUserCtx, + deleteRequest: &app.DeleteApplicationRequest{ + ProjectId: p.GetId(), + Id: appToDeleteForLoginUser.GetAppId(), + }, + expectedErrorType: codes.PermissionDenied, + }, + + // Project Owner + { + testName: "when user is ProjectOwner delete app request should succeed", + inputCtx: projectOwnerCtx, + deleteRequest: &app.DeleteApplicationRequest{ + ProjectId: p.GetId(), + Id: appToDeleteForProjectOwner.GetAppId(), + }, + }, + + // Org Owner + { + testName: "when user is OrgOwner delete app request should succeed", + inputCtx: projectOwnerCtx, + deleteRequest: &app.DeleteApplicationRequest{ + ProjectId: p.GetId(), + Id: appToDeleteForOrgOwner.GetAppId(), + }, + }, + } + + for _, tc := range tt { + t.Run(tc.testName, func(t *testing.T) { + t.Parallel() + + // When + res, err := instance.Client.AppV2Beta.DeleteApplication(tc.inputCtx, tc.deleteRequest) + + // Then + require.Equal(t, tc.expectedErrorType, status.Code(err)) + if tc.expectedErrorType == codes.OK { + assert.NotZero(t, res.GetDeletionDate()) + } + }) + } +} + +func TestDeactivateApplication(t *testing.T) { + p := instance.CreateProject(IAMOwnerCtx, t, instance.DefaultOrg.GetId(), gofakeit.Name(), false, false) + + reqForAppNameCreation := &app.CreateApplicationRequest_ApiRequest{ + ApiRequest: &app.CreateAPIApplicationRequest{AuthMethodType: app.APIAuthMethodType_API_AUTH_METHOD_TYPE_PRIVATE_KEY_JWT}, + } + + appToDeactivate, appCreateErr := instance.Client.AppV2Beta.CreateApplication(IAMOwnerCtx, &app.CreateApplicationRequest{ + ProjectId: p.GetId(), + Name: gofakeit.AppName(), + CreationRequestType: reqForAppNameCreation, + }) + require.NoError(t, appCreateErr) + + t.Parallel() + + tt := []struct { + testName string + inputCtx context.Context + deleteRequest *app.DeactivateApplicationRequest + + expectedErrorType codes.Code + }{ + { + testName: "when app to deactivate is not found should return not found error", + inputCtx: IAMOwnerCtx, + deleteRequest: &app.DeactivateApplicationRequest{ + ProjectId: p.GetId(), + Id: gofakeit.Sentence(2), + }, + expectedErrorType: codes.NotFound, + }, + { + testName: "when app to deactivate is found should return deactivation time", + inputCtx: IAMOwnerCtx, + deleteRequest: &app.DeactivateApplicationRequest{ + ProjectId: p.GetId(), + Id: appToDeactivate.GetAppId(), + }, + }, + } + + for _, tc := range tt { + t.Run(tc.testName, func(t *testing.T) { + t.Parallel() + + // When + res, err := instance.Client.AppV2Beta.DeactivateApplication(tc.inputCtx, tc.deleteRequest) + + // Then + require.Equal(t, tc.expectedErrorType, status.Code(err)) + if tc.expectedErrorType == codes.OK { + assert.NotZero(t, res.GetDeactivationDate()) + } + }) + } +} + +func TestDeactivateApplication_WithDifferentPermissions(t *testing.T) { + p, projectOwnerCtx := getProjectAndProjectContext(t, instance, IAMOwnerCtx) + + appToDeactivateForLoginUser := createAPIApp(t, p.GetId()) + appToDeactivateForPrjectOwner := createAPIApp(t, p.GetId()) + appToDeactivateForOrgOwner := createAPIApp(t, p.GetId()) + + t.Parallel() + + tt := []struct { + testName string + inputCtx context.Context + deleteRequest *app.DeactivateApplicationRequest + + expectedErrorType codes.Code + }{ + // Login User + { + testName: "when user has no project.app.write permission for app deactivate request should return permission error", + inputCtx: IAMOwnerCtx, + deleteRequest: &app.DeactivateApplicationRequest{ + ProjectId: p.GetId(), + Id: appToDeactivateForLoginUser.GetAppId(), + }, + }, + + // Project Owner + { + testName: "when user is ProjectOwner deactivate app request should succeed", + inputCtx: projectOwnerCtx, + deleteRequest: &app.DeactivateApplicationRequest{ + ProjectId: p.GetId(), + Id: appToDeactivateForPrjectOwner.GetAppId(), + }, + }, + + // Org Owner + { + testName: "when user is OrgOwner deactivate app request should succeed", + inputCtx: OrgOwnerCtx, + deleteRequest: &app.DeactivateApplicationRequest{ + ProjectId: p.GetId(), + Id: appToDeactivateForOrgOwner.GetAppId(), + }, + }, + } + + for _, tc := range tt { + t.Run(tc.testName, func(t *testing.T) { + t.Parallel() + + // When + res, err := instance.Client.AppV2Beta.DeactivateApplication(tc.inputCtx, tc.deleteRequest) + + // Then + require.Equal(t, tc.expectedErrorType, status.Code(err)) + if tc.expectedErrorType == codes.OK { + assert.NotZero(t, res.GetDeactivationDate()) + } + }) + } +} + +func TestReactivateApplication(t *testing.T) { + p := instance.CreateProject(IAMOwnerCtx, t, instance.DefaultOrg.GetId(), gofakeit.Name(), false, false) + + reqForAppNameCreation := &app.CreateApplicationRequest_ApiRequest{ + ApiRequest: &app.CreateAPIApplicationRequest{AuthMethodType: app.APIAuthMethodType_API_AUTH_METHOD_TYPE_PRIVATE_KEY_JWT}, + } + + appToReactivate, appCreateErr := instance.Client.AppV2Beta.CreateApplication(IAMOwnerCtx, &app.CreateApplicationRequest{ + ProjectId: p.GetId(), + Name: gofakeit.AppName(), + CreationRequestType: reqForAppNameCreation, + }) + require.Nil(t, appCreateErr) + + _, appDeactivateErr := instance.Client.AppV2Beta.DeactivateApplication(IAMOwnerCtx, &app.DeactivateApplicationRequest{ + ProjectId: p.GetId(), + Id: appToReactivate.GetAppId(), + }) + require.Nil(t, appDeactivateErr) + + t.Parallel() + + tt := []struct { + testName string + inputCtx context.Context + reactivateRequest *app.ReactivateApplicationRequest + + expectedErrorType codes.Code + }{ + { + testName: "when app to reactivate is not found should return not found error", + inputCtx: IAMOwnerCtx, + reactivateRequest: &app.ReactivateApplicationRequest{ + ProjectId: p.GetId(), + Id: gofakeit.Sentence(2), + }, + expectedErrorType: codes.NotFound, + }, + { + testName: "when app to reactivate is found should return deactivation time", + inputCtx: IAMOwnerCtx, + reactivateRequest: &app.ReactivateApplicationRequest{ + ProjectId: p.GetId(), + Id: appToReactivate.GetAppId(), + }, + }, + } + + for _, tc := range tt { + t.Run(tc.testName, func(t *testing.T) { + t.Parallel() + + // When + res, err := instance.Client.AppV2Beta.ReactivateApplication(tc.inputCtx, tc.reactivateRequest) + + // Then + require.Equal(t, tc.expectedErrorType, status.Code(err)) + if tc.expectedErrorType == codes.OK { + assert.NotZero(t, res.GetReactivationDate()) + } + }) + } +} + +func TestReactivateApplication_WithDifferentPermissions(t *testing.T) { + p, projectOwnerCtx := getProjectAndProjectContext(t, instance, IAMOwnerCtx) + + appToReactivateForLoginUser := createAPIApp(t, p.GetId()) + deactivateApp(t, appToReactivateForLoginUser, p.GetId()) + + appToReactivateForProjectOwner := createAPIApp(t, p.GetId()) + deactivateApp(t, appToReactivateForProjectOwner, p.GetId()) + + appToReactivateForOrgOwner := createAPIApp(t, p.GetId()) + deactivateApp(t, appToReactivateForOrgOwner, p.GetId()) + + t.Parallel() + + tt := []struct { + testName string + inputCtx context.Context + reactivateRequest *app.ReactivateApplicationRequest + + expectedErrorType codes.Code + }{ + // Login User + { + testName: "when user has no project.app.write permission for app reactivate request should return permission error", + inputCtx: LoginUserCtx, + reactivateRequest: &app.ReactivateApplicationRequest{ + ProjectId: p.GetId(), + Id: appToReactivateForLoginUser.GetAppId(), + }, + expectedErrorType: codes.PermissionDenied, + }, + + // Project Owner + { + testName: "when user is ProjectOwner reactivate app request should succeed", + inputCtx: projectOwnerCtx, + reactivateRequest: &app.ReactivateApplicationRequest{ + ProjectId: p.GetId(), + Id: appToReactivateForProjectOwner.GetAppId(), + }, + }, + + // Org Owner + { + testName: "when user is OrgOwner reactivate app request should succeed", + inputCtx: OrgOwnerCtx, + reactivateRequest: &app.ReactivateApplicationRequest{ + ProjectId: p.GetId(), + Id: appToReactivateForOrgOwner.GetAppId(), + }, + }, + } + + for _, tc := range tt { + t.Run(tc.testName, func(t *testing.T) { + t.Parallel() + + // When + res, err := instance.Client.AppV2Beta.ReactivateApplication(tc.inputCtx, tc.reactivateRequest) + + // Then + require.Equal(t, tc.expectedErrorType, status.Code(err)) + if tc.expectedErrorType == codes.OK { + assert.NotZero(t, res.GetReactivationDate()) + } + }) + } +} + +func TestRegenerateClientSecret(t *testing.T) { + p := instance.CreateProject(IAMOwnerCtx, t, instance.DefaultOrg.GetId(), gofakeit.Name(), false, false) + + reqForApiAppCreation := &app.CreateApplicationRequest_ApiRequest{ + ApiRequest: &app.CreateAPIApplicationRequest{AuthMethodType: app.APIAuthMethodType_API_AUTH_METHOD_TYPE_PRIVATE_KEY_JWT}, + } + + apiAppToRegen, apiAppCreateErr := instance.Client.AppV2Beta.CreateApplication(IAMOwnerCtx, &app.CreateApplicationRequest{ + ProjectId: p.GetId(), + Name: gofakeit.AppName(), + CreationRequestType: reqForApiAppCreation, + }) + require.Nil(t, apiAppCreateErr) + + reqForOIDCAppCreation := &app.CreateApplicationRequest_OidcRequest{ + OidcRequest: &app.CreateOIDCApplicationRequest{ + RedirectUris: []string{"http://example.com"}, + ResponseTypes: []app.OIDCResponseType{app.OIDCResponseType_OIDC_RESPONSE_TYPE_CODE}, + GrantTypes: []app.OIDCGrantType{app.OIDCGrantType_OIDC_GRANT_TYPE_AUTHORIZATION_CODE}, + AppType: app.OIDCAppType_OIDC_APP_TYPE_WEB, + AuthMethodType: app.OIDCAuthMethodType_OIDC_AUTH_METHOD_TYPE_BASIC, + PostLogoutRedirectUris: []string{"http://example.com/home"}, + Version: app.OIDCVersion_OIDC_VERSION_1_0, + AccessTokenType: app.OIDCTokenType_OIDC_TOKEN_TYPE_JWT, + BackChannelLogoutUri: "http://example.com/logout", + LoginVersion: &app.LoginVersion{ + Version: &app.LoginVersion_LoginV2{ + LoginV2: &app.LoginV2{ + BaseUri: &baseURI, + }, + }, + }, + }, + } + + oidcAppToRegen, oidcAppCreateErr := instance.Client.AppV2Beta.CreateApplication(IAMOwnerCtx, &app.CreateApplicationRequest{ + ProjectId: p.GetId(), + Name: gofakeit.AppName(), + CreationRequestType: reqForOIDCAppCreation, + }) + require.Nil(t, oidcAppCreateErr) + + t.Parallel() + + tt := []struct { + testName string + inputCtx context.Context + regenRequest *app.RegenerateClientSecretRequest + + expectedErrorType codes.Code + oldSecret string + }{ + { + testName: "when app to regen is not expected type should return invalid argument error", + inputCtx: IAMOwnerCtx, + regenRequest: &app.RegenerateClientSecretRequest{ + ProjectId: p.GetId(), + ApplicationId: gofakeit.Sentence(2), + }, + expectedErrorType: codes.InvalidArgument, + }, + { + testName: "when app to regen is not found should return not found error", + inputCtx: IAMOwnerCtx, + regenRequest: &app.RegenerateClientSecretRequest{ + ProjectId: p.GetId(), + ApplicationId: gofakeit.Sentence(2), + AppType: &app.RegenerateClientSecretRequest_IsApi{}, + }, + expectedErrorType: codes.NotFound, + }, + { + testName: "when API app to regen is found should return different secret", + inputCtx: IAMOwnerCtx, + regenRequest: &app.RegenerateClientSecretRequest{ + ProjectId: p.GetId(), + ApplicationId: apiAppToRegen.GetAppId(), + AppType: &app.RegenerateClientSecretRequest_IsApi{}, + }, + oldSecret: apiAppToRegen.GetApiResponse().GetClientSecret(), + }, + { + testName: "when OIDC app to regen is found should return different secret", + inputCtx: IAMOwnerCtx, + regenRequest: &app.RegenerateClientSecretRequest{ + ProjectId: p.GetId(), + ApplicationId: oidcAppToRegen.GetAppId(), + AppType: &app.RegenerateClientSecretRequest_IsOidc{}, + }, + oldSecret: oidcAppToRegen.GetOidcResponse().GetClientSecret(), + }, + } + + for _, tc := range tt { + t.Run(tc.testName, func(t *testing.T) { + t.Parallel() + + // When + res, err := instance.Client.AppV2Beta.RegenerateClientSecret(tc.inputCtx, tc.regenRequest) + + // Then + require.Equal(t, tc.expectedErrorType, status.Code(err)) + if tc.expectedErrorType == codes.OK { + assert.NotZero(t, res.GetCreationDate()) + assert.NotEqual(t, tc.oldSecret, res.GetClientSecret()) + } + }) + } + +} + +func TestRegenerateClientSecret_WithDifferentPermissions(t *testing.T) { + p, projectOwnerCtx := getProjectAndProjectContext(t, instance, IAMOwnerCtx) + + apiAppToRegenForLoginUser := createAPIApp(t, p.GetId()) + apiAppToRegenForProjectOwner := createAPIApp(t, p.GetId()) + apiAppToRegenForOrgOwner := createAPIApp(t, p.GetId()) + + oidcAppToRegenForLoginUser := createOIDCApp(t, baseURI, p.GetId()) + oidcAppToRegenForProjectOwner := createOIDCApp(t, baseURI, p.GetId()) + oidcAppToRegenForOrgOwner := createOIDCApp(t, baseURI, p.GetId()) + + t.Parallel() + + tt := []struct { + testName string + inputCtx context.Context + regenRequest *app.RegenerateClientSecretRequest + + expectedErrorType codes.Code + oldSecret string + }{ + // Login user + { + testName: "when user has no project.app.write permission for API app secret regen request should return permission error", + inputCtx: LoginUserCtx, + regenRequest: &app.RegenerateClientSecretRequest{ + ProjectId: p.GetId(), + ApplicationId: apiAppToRegenForLoginUser.GetAppId(), + AppType: &app.RegenerateClientSecretRequest_IsApi{}, + }, + expectedErrorType: codes.PermissionDenied, + }, + { + testName: "when user has no project.app.write permission for OIDC app secret regen request should return permission error", + inputCtx: LoginUserCtx, + regenRequest: &app.RegenerateClientSecretRequest{ + ProjectId: p.GetId(), + ApplicationId: oidcAppToRegenForLoginUser.GetAppId(), + AppType: &app.RegenerateClientSecretRequest_IsOidc{}, + }, + expectedErrorType: codes.PermissionDenied, + }, + + // Project Owner + { + testName: "when user is ProjectOwner regen API app secret request should succeed", + inputCtx: projectOwnerCtx, + regenRequest: &app.RegenerateClientSecretRequest{ + ProjectId: p.GetId(), + ApplicationId: apiAppToRegenForProjectOwner.GetAppId(), + AppType: &app.RegenerateClientSecretRequest_IsApi{}, + }, + oldSecret: apiAppToRegenForProjectOwner.GetApiResponse().GetClientSecret(), + }, + { + testName: "when user is ProjectOwner regen OIDC app secret request should succeed", + inputCtx: projectOwnerCtx, + regenRequest: &app.RegenerateClientSecretRequest{ + ProjectId: p.GetId(), + ApplicationId: oidcAppToRegenForProjectOwner.GetAppId(), + AppType: &app.RegenerateClientSecretRequest_IsOidc{}, + }, + oldSecret: oidcAppToRegenForProjectOwner.GetOidcResponse().GetClientSecret(), + }, + + // Org Owner + { + testName: "when user is OrgOwner regen API app secret request should succeed", + inputCtx: OrgOwnerCtx, + regenRequest: &app.RegenerateClientSecretRequest{ + ProjectId: p.GetId(), + ApplicationId: apiAppToRegenForOrgOwner.GetAppId(), + AppType: &app.RegenerateClientSecretRequest_IsApi{}, + }, + oldSecret: apiAppToRegenForOrgOwner.GetApiResponse().GetClientSecret(), + }, + { + testName: "when user is OrgOwner regen OIDC app secret request should succeed", + inputCtx: OrgOwnerCtx, + regenRequest: &app.RegenerateClientSecretRequest{ + ProjectId: p.GetId(), + ApplicationId: oidcAppToRegenForOrgOwner.GetAppId(), + AppType: &app.RegenerateClientSecretRequest_IsOidc{}, + }, + oldSecret: oidcAppToRegenForOrgOwner.GetOidcResponse().GetClientSecret(), + }, + } + + for _, tc := range tt { + t.Run(tc.testName, func(t *testing.T) { + t.Parallel() + + // When + res, err := instance.Client.AppV2Beta.RegenerateClientSecret(tc.inputCtx, tc.regenRequest) + + // Then + require.Equal(t, tc.expectedErrorType, status.Code(err)) + if tc.expectedErrorType == codes.OK { + assert.NotZero(t, res.GetCreationDate()) + assert.NotEqual(t, tc.oldSecret, res.GetClientSecret()) + } + }) + } + +} diff --git a/internal/api/grpc/app/v2beta/integration_test/query_test.go b/internal/api/grpc/app/v2beta/integration_test/query_test.go new file mode 100644 index 0000000000..578fcec138 --- /dev/null +++ b/internal/api/grpc/app/v2beta/integration_test/query_test.go @@ -0,0 +1,575 @@ +//go:build integration + +package instance_test + +import ( + "context" + "fmt" + "slices" + "testing" + "time" + + "github.com/brianvoe/gofakeit/v6" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" + + "github.com/zitadel/zitadel/internal/integration" + app "github.com/zitadel/zitadel/pkg/grpc/app/v2beta" + "github.com/zitadel/zitadel/pkg/grpc/filter/v2" +) + +func TestGetApplication(t *testing.T) { + p, projectOwnerCtx := getProjectAndProjectContext(t, instance, IAMOwnerCtx) + + apiAppName := gofakeit.AppName() + createdApiApp, errAPIAppCreation := instance.Client.AppV2Beta.CreateApplication(IAMOwnerCtx, &app.CreateApplicationRequest{ + ProjectId: p.GetId(), + Name: apiAppName, + CreationRequestType: &app.CreateApplicationRequest_ApiRequest{ + ApiRequest: &app.CreateAPIApplicationRequest{ + AuthMethodType: app.APIAuthMethodType_API_AUTH_METHOD_TYPE_BASIC, + }, + }, + }) + require.Nil(t, errAPIAppCreation) + + samlAppName := gofakeit.AppName() + createdSAMLApp, errSAMLAppCreation := instance.Client.AppV2Beta.CreateApplication(IAMOwnerCtx, &app.CreateApplicationRequest{ + ProjectId: p.GetId(), + Name: samlAppName, + CreationRequestType: &app.CreateApplicationRequest_SamlRequest{ + SamlRequest: &app.CreateSAMLApplicationRequest{ + LoginVersion: &app.LoginVersion{Version: &app.LoginVersion_LoginV1{LoginV1: &app.LoginV1{}}}, + Metadata: &app.CreateSAMLApplicationRequest_MetadataXml{MetadataXml: samlMetadataGen(gofakeit.URL())}, + }, + }, + }) + require.Nil(t, errSAMLAppCreation) + + oidcAppName := gofakeit.AppName() + createdOIDCApp, errOIDCAppCreation := instance.Client.AppV2Beta.CreateApplication(IAMOwnerCtx, &app.CreateApplicationRequest{ + ProjectId: p.GetId(), + Name: oidcAppName, + CreationRequestType: &app.CreateApplicationRequest_OidcRequest{ + OidcRequest: &app.CreateOIDCApplicationRequest{ + RedirectUris: []string{"http://example.com"}, + ResponseTypes: []app.OIDCResponseType{app.OIDCResponseType_OIDC_RESPONSE_TYPE_CODE}, + GrantTypes: []app.OIDCGrantType{app.OIDCGrantType_OIDC_GRANT_TYPE_AUTHORIZATION_CODE}, + AppType: app.OIDCAppType_OIDC_APP_TYPE_WEB, + AuthMethodType: app.OIDCAuthMethodType_OIDC_AUTH_METHOD_TYPE_BASIC, + PostLogoutRedirectUris: []string{"http://example.com/home"}, + Version: app.OIDCVersion_OIDC_VERSION_1_0, + AccessTokenType: app.OIDCTokenType_OIDC_TOKEN_TYPE_JWT, + BackChannelLogoutUri: "http://example.com/logout", + LoginVersion: &app.LoginVersion{Version: &app.LoginVersion_LoginV2{LoginV2: &app.LoginV2{BaseUri: &baseURI}}}, + }, + }, + }) + require.Nil(t, errOIDCAppCreation) + + t.Parallel() + + tt := []struct { + testName string + inputRequest *app.GetApplicationRequest + inputCtx context.Context + + expectedErrorType codes.Code + expectedAppName string + expectedAppID string + expectedApplicationType string + }{ + { + testName: "when unknown app ID should return not found error", + inputCtx: IAMOwnerCtx, + inputRequest: &app.GetApplicationRequest{ + Id: gofakeit.Sentence(2), + }, + + expectedErrorType: codes.NotFound, + }, + { + testName: "when user has no permission should return membership not found error", + inputCtx: NoPermissionCtx, + inputRequest: &app.GetApplicationRequest{ + Id: createdApiApp.GetAppId(), + }, + + expectedErrorType: codes.NotFound, + }, + { + testName: "when providing API app ID should return valid API app result", + inputCtx: projectOwnerCtx, + inputRequest: &app.GetApplicationRequest{ + Id: createdApiApp.GetAppId(), + }, + + expectedAppName: apiAppName, + expectedAppID: createdApiApp.GetAppId(), + expectedApplicationType: fmt.Sprintf("%T", &app.Application_ApiConfig{}), + }, + { + testName: "when providing SAML app ID should return valid SAML app result", + inputCtx: IAMOwnerCtx, + inputRequest: &app.GetApplicationRequest{ + Id: createdSAMLApp.GetAppId(), + }, + + expectedAppName: samlAppName, + expectedAppID: createdSAMLApp.GetAppId(), + expectedApplicationType: fmt.Sprintf("%T", &app.Application_SamlConfig{}), + }, + { + testName: "when providing OIDC app ID should return valid OIDC app result", + inputCtx: IAMOwnerCtx, + inputRequest: &app.GetApplicationRequest{ + Id: createdOIDCApp.GetAppId(), + }, + + expectedAppName: oidcAppName, + expectedAppID: createdOIDCApp.GetAppId(), + expectedApplicationType: fmt.Sprintf("%T", &app.Application_OidcConfig{}), + }, + } + + for _, tc := range tt { + t.Run(tc.testName, func(t *testing.T) { + t.Parallel() + + retryDuration, tick := integration.WaitForAndTickWithMaxDuration(tc.inputCtx, 30*time.Second) + require.EventuallyWithT(t, func(ttt *assert.CollectT) { + // When + res, err := instance.Client.AppV2Beta.GetApplication(tc.inputCtx, tc.inputRequest) + + // Then + require.Equal(t, tc.expectedErrorType, status.Code(err)) + if tc.expectedErrorType == codes.OK { + + assert.Equal(t, tc.expectedAppID, res.GetApp().GetId()) + assert.Equal(t, tc.expectedAppName, res.GetApp().GetName()) + assert.NotZero(t, res.GetApp().GetCreationDate()) + assert.NotZero(t, res.GetApp().GetChangeDate()) + + appType := fmt.Sprintf("%T", res.GetApp().GetConfig()) + assert.Equal(t, tc.expectedApplicationType, appType) + } + }, retryDuration, tick) + }) + } +} + +func TestListApplications(t *testing.T) { + p, projectOwnerCtx := getProjectAndProjectContext(t, instance, IAMOwnerCtx) + + t.Parallel() + + createdApiApp, apiAppName := createAPIAppWithName(t, p.GetId()) + + createdDeactivatedApiApp, deactivatedApiAppName := createAPIAppWithName(t, p.GetId()) + deactivateApp(t, createdDeactivatedApiApp, p.GetId()) + + _, createdSAMLApp, samlAppName := createSAMLAppWithName(t, gofakeit.URL(), p.GetId()) + + createdOIDCApp, oidcAppName := createOIDCAppWithName(t, gofakeit.URL(), p.GetId()) + + type appWithName struct { + app *app.CreateApplicationResponse + name string + } + + // Sorting + appsSortedByName := []appWithName{ + {name: apiAppName, app: createdApiApp}, + {name: deactivatedApiAppName, app: createdDeactivatedApiApp}, + {name: samlAppName, app: createdSAMLApp}, + {name: oidcAppName, app: createdOIDCApp}, + } + slices.SortFunc(appsSortedByName, func(a, b appWithName) int { + if a.name < b.name { + return -1 + } + if a.name > b.name { + return 1 + } + + return 0 + }) + + appsSortedByID := []appWithName{ + {name: apiAppName, app: createdApiApp}, + {name: deactivatedApiAppName, app: createdDeactivatedApiApp}, + {name: samlAppName, app: createdSAMLApp}, + {name: oidcAppName, app: createdOIDCApp}, + } + slices.SortFunc(appsSortedByID, func(a, b appWithName) int { + if a.app.GetAppId() < b.app.GetAppId() { + return -1 + } + if a.app.GetAppId() > b.app.GetAppId() { + return 1 + } + return 0 + }) + + appsSortedByCreationDate := []appWithName{ + {name: apiAppName, app: createdApiApp}, + {name: deactivatedApiAppName, app: createdDeactivatedApiApp}, + {name: samlAppName, app: createdSAMLApp}, + {name: oidcAppName, app: createdOIDCApp}, + } + slices.SortFunc(appsSortedByCreationDate, func(a, b appWithName) int { + aCreationDate := a.app.GetCreationDate().AsTime() + bCreationDate := b.app.GetCreationDate().AsTime() + + if aCreationDate.Before(bCreationDate) { + return -1 + } + if bCreationDate.Before(aCreationDate) { + return 1 + } + + return 0 + }) + + tt := []struct { + testName string + inputRequest *app.ListApplicationsRequest + inputCtx context.Context + + expectedOrderedList []appWithName + expectedOrderedKeys func(keys []appWithName) any + actualOrderedKeys func(keys []*app.Application) any + }{ + { + testName: "when no apps found should return empty list", + inputCtx: IAMOwnerCtx, + inputRequest: &app.ListApplicationsRequest{ + ProjectId: "another-id", + }, + + expectedOrderedList: []appWithName{}, + expectedOrderedKeys: func(keys []appWithName) any { return keys }, + actualOrderedKeys: func(keys []*app.Application) any { return keys }, + }, + { + testName: "when user has no read permission should return empty set", + inputCtx: NoPermissionCtx, + inputRequest: &app.ListApplicationsRequest{ + ProjectId: p.GetId(), + }, + + expectedOrderedList: []appWithName{}, + expectedOrderedKeys: func(keys []appWithName) any { return keys }, + actualOrderedKeys: func(keys []*app.Application) any { return keys }, + }, + { + testName: "when sorting by name should return apps sorted by name in descending order", + inputCtx: IAMOwnerCtx, + inputRequest: &app.ListApplicationsRequest{ + ProjectId: p.GetId(), + SortingColumn: app.AppSorting_APP_SORT_BY_NAME, + Pagination: &filter.PaginationRequest{Asc: true}, + }, + + expectedOrderedList: appsSortedByName, + expectedOrderedKeys: func(apps []appWithName) any { + names := make([]string, len(apps)) + for i, a := range apps { + names[i] = a.name + } + + return names + }, + actualOrderedKeys: func(apps []*app.Application) any { + names := make([]string, len(apps)) + for i, a := range apps { + names[i] = a.GetName() + } + + return names + }, + }, + + { + testName: "when user is project owner should return apps sorted by name in ascending order", + inputCtx: projectOwnerCtx, + inputRequest: &app.ListApplicationsRequest{ + ProjectId: p.GetId(), + SortingColumn: app.AppSorting_APP_SORT_BY_NAME, + Pagination: &filter.PaginationRequest{Asc: true}, + }, + + expectedOrderedList: appsSortedByName, + expectedOrderedKeys: func(apps []appWithName) any { + names := make([]string, len(apps)) + for i, a := range apps { + names[i] = a.name + } + + return names + }, + actualOrderedKeys: func(apps []*app.Application) any { + names := make([]string, len(apps)) + for i, a := range apps { + names[i] = a.GetName() + } + + return names + }, + }, + + { + testName: "when sorting by id should return apps sorted by id in descending order", + inputCtx: IAMOwnerCtx, + inputRequest: &app.ListApplicationsRequest{ + ProjectId: p.GetId(), + SortingColumn: app.AppSorting_APP_SORT_BY_ID, + Pagination: &filter.PaginationRequest{Asc: true}, + }, + expectedOrderedList: appsSortedByID, + expectedOrderedKeys: func(apps []appWithName) any { + ids := make([]string, len(apps)) + for i, a := range apps { + ids[i] = a.app.GetAppId() + } + + return ids + }, + actualOrderedKeys: func(apps []*app.Application) any { + ids := make([]string, len(apps)) + for i, a := range apps { + ids[i] = a.GetId() + } + + return ids + }, + }, + { + testName: "when sorting by creation date should return apps sorted by creation date in descending order", + inputCtx: IAMOwnerCtx, + inputRequest: &app.ListApplicationsRequest{ + ProjectId: p.GetId(), + SortingColumn: app.AppSorting_APP_SORT_BY_CREATION_DATE, + Pagination: &filter.PaginationRequest{Asc: true}, + }, + expectedOrderedList: appsSortedByCreationDate, + expectedOrderedKeys: func(apps []appWithName) any { + creationDates := make([]time.Time, len(apps)) + for i, a := range apps { + creationDates[i] = a.app.GetCreationDate().AsTime() + } + + return creationDates + }, + actualOrderedKeys: func(apps []*app.Application) any { + creationDates := make([]time.Time, len(apps)) + for i, a := range apps { + creationDates[i] = a.GetCreationDate().AsTime() + } + + return creationDates + }, + }, + { + testName: "when filtering by active apps should return active apps only", + inputCtx: IAMOwnerCtx, + inputRequest: &app.ListApplicationsRequest{ + ProjectId: p.GetId(), + Pagination: &filter.PaginationRequest{Asc: true}, + Filters: []*app.ApplicationSearchFilter{ + {Filter: &app.ApplicationSearchFilter_StateFilter{StateFilter: app.AppState_APP_STATE_ACTIVE}}, + }, + }, + expectedOrderedList: slices.DeleteFunc( + slices.Clone(appsSortedByID), + func(a appWithName) bool { return a.name == deactivatedApiAppName }, + ), + expectedOrderedKeys: func(apps []appWithName) any { + creationDates := make([]time.Time, len(apps)) + for i, a := range apps { + creationDates[i] = a.app.GetCreationDate().AsTime() + } + + return creationDates + }, + actualOrderedKeys: func(apps []*app.Application) any { + creationDates := make([]time.Time, len(apps)) + for i, a := range apps { + creationDates[i] = a.GetCreationDate().AsTime() + } + + return creationDates + }, + }, + { + testName: "when filtering by app type should return apps of matching type only", + inputCtx: IAMOwnerCtx, + inputRequest: &app.ListApplicationsRequest{ + ProjectId: p.GetId(), + Pagination: &filter.PaginationRequest{Asc: true}, + Filters: []*app.ApplicationSearchFilter{ + {Filter: &app.ApplicationSearchFilter_OidcAppOnly{}}, + }, + }, + expectedOrderedList: slices.DeleteFunc( + slices.Clone(appsSortedByID), + func(a appWithName) bool { return a.name != oidcAppName }, + ), + expectedOrderedKeys: func(apps []appWithName) any { + creationDates := make([]time.Time, len(apps)) + for i, a := range apps { + creationDates[i] = a.app.GetCreationDate().AsTime() + } + + return creationDates + }, + actualOrderedKeys: func(apps []*app.Application) any { + creationDates := make([]time.Time, len(apps)) + for i, a := range apps { + creationDates[i] = a.GetCreationDate().AsTime() + } + + return creationDates + }, + }, + } + + for _, tc := range tt { + t.Run(tc.testName, func(t *testing.T) { + t.Parallel() + retryDuration, tick := integration.WaitForAndTickWithMaxDuration(tc.inputCtx, 30*time.Second) + require.EventuallyWithT(t, func(ttt *assert.CollectT) { + // When + res, err := instance.Client.AppV2Beta.ListApplications(tc.inputCtx, tc.inputRequest) + + // Then + require.Equal(ttt, codes.OK, status.Code(err)) + + if err == nil { + assert.Len(ttt, res.GetApplications(), len(tc.expectedOrderedList)) + actualOrderedKeys := tc.actualOrderedKeys(res.GetApplications()) + expectedOrderedKeys := tc.expectedOrderedKeys(tc.expectedOrderedList) + assert.ElementsMatch(ttt, expectedOrderedKeys, actualOrderedKeys) + } + }, retryDuration, tick) + }) + } +} + +func TestListApplications_WithPermissionV2(t *testing.T) { + ensureFeaturePermissionV2Enabled(t, instancePermissionV2) + iamOwnerCtx := instancePermissionV2.WithAuthorization(context.Background(), integration.UserTypeIAMOwner) + p, projectOwnerCtx := getProjectAndProjectContext(t, instancePermissionV2, iamOwnerCtx) + _, otherProjectOwnerCtx := getProjectAndProjectContext(t, instancePermissionV2, iamOwnerCtx) + + appName1, appName2, appName3 := gofakeit.AppName(), gofakeit.AppName(), gofakeit.AppName() + reqForAPIAppCreation := &app.CreateApplicationRequest_ApiRequest{ + ApiRequest: &app.CreateAPIApplicationRequest{AuthMethodType: app.APIAuthMethodType_API_AUTH_METHOD_TYPE_PRIVATE_KEY_JWT}, + } + + app1, appAPIConfigChangeErr := instancePermissionV2.Client.AppV2Beta.CreateApplication(iamOwnerCtx, &app.CreateApplicationRequest{ + ProjectId: p.GetId(), + Name: appName1, + CreationRequestType: reqForAPIAppCreation, + }) + require.Nil(t, appAPIConfigChangeErr) + + app2, appAPIConfigChangeErr := instancePermissionV2.Client.AppV2Beta.CreateApplication(iamOwnerCtx, &app.CreateApplicationRequest{ + ProjectId: p.GetId(), + Name: appName2, + CreationRequestType: reqForAPIAppCreation, + }) + require.Nil(t, appAPIConfigChangeErr) + + app3, appAPIConfigChangeErr := instancePermissionV2.Client.AppV2Beta.CreateApplication(iamOwnerCtx, &app.CreateApplicationRequest{ + ProjectId: p.GetId(), + Name: appName3, + CreationRequestType: reqForAPIAppCreation, + }) + require.Nil(t, appAPIConfigChangeErr) + + t.Parallel() + + tt := []struct { + testName string + inputRequest *app.ListApplicationsRequest + inputCtx context.Context + + expectedCode codes.Code + expectedAppIDs []string + }{ + { + testName: "when user has no read permission should return empty set", + inputCtx: instancePermissionV2.WithAuthorization(context.Background(), integration.UserTypeNoPermission), + inputRequest: &app.ListApplicationsRequest{ + ProjectId: p.GetId(), + }, + + expectedAppIDs: []string{}, + }, + { + testName: "when projectOwner should return full app list", + inputCtx: projectOwnerCtx, + inputRequest: &app.ListApplicationsRequest{ + ProjectId: p.GetId(), + }, + + expectedCode: codes.OK, + expectedAppIDs: []string{app1.GetAppId(), app2.GetAppId(), app3.GetAppId()}, + }, + { + testName: "when orgOwner should return full app list", + inputCtx: instancePermissionV2.WithAuthorization(context.Background(), integration.UserTypeOrgOwner), + inputRequest: &app.ListApplicationsRequest{ + ProjectId: p.GetId(), + }, + + expectedAppIDs: []string{app1.GetAppId(), app2.GetAppId(), app3.GetAppId()}, + }, + { + testName: "when iamOwner user should return full app list", + inputCtx: iamOwnerCtx, + inputRequest: &app.ListApplicationsRequest{ + ProjectId: p.GetId(), + }, + + expectedAppIDs: []string{app1.GetAppId(), app2.GetAppId(), app3.GetAppId()}, + }, + { + testName: "when other projectOwner user should return empty list", + inputCtx: otherProjectOwnerCtx, + inputRequest: &app.ListApplicationsRequest{ + ProjectId: p.GetId(), + }, + + expectedAppIDs: []string{}, + }, + } + + for _, tc := range tt { + t.Run(tc.testName, func(t *testing.T) { + t.Parallel() + retryDuration, tick := integration.WaitForAndTickWithMaxDuration(tc.inputCtx, 5*time.Second) + require.EventuallyWithT(t, func(ttt *assert.CollectT) { + // When + res, err := instancePermissionV2.Client.AppV2Beta.ListApplications(tc.inputCtx, tc.inputRequest) + + // Then + require.Equal(ttt, tc.expectedCode, status.Code(err)) + + if err == nil { + require.Len(ttt, res.GetApplications(), len(tc.expectedAppIDs)) + + resAppIDs := []string{} + for _, a := range res.GetApplications() { + resAppIDs = append(resAppIDs, a.GetId()) + } + + assert.ElementsMatch(ttt, tc.expectedAppIDs, resAppIDs) + } + }, retryDuration, tick) + }) + } +} diff --git a/internal/api/grpc/app/v2beta/integration_test/server_test.go b/internal/api/grpc/app/v2beta/integration_test/server_test.go new file mode 100644 index 0000000000..6618ab0616 --- /dev/null +++ b/internal/api/grpc/app/v2beta/integration_test/server_test.go @@ -0,0 +1,205 @@ +//go:build integration + +package instance_test + +import ( + "context" + "fmt" + "os" + "testing" + "time" + + "github.com/brianvoe/gofakeit/v6" + "github.com/muhlemmer/gu" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/zitadel/zitadel/internal/integration" + app "github.com/zitadel/zitadel/pkg/grpc/app/v2beta" + "github.com/zitadel/zitadel/pkg/grpc/feature/v2" + project_v2beta "github.com/zitadel/zitadel/pkg/grpc/project/v2beta" +) + +var ( + NoPermissionCtx context.Context + LoginUserCtx context.Context + OrgOwnerCtx context.Context + IAMOwnerCtx context.Context + + instance *integration.Instance + instancePermissionV2 *integration.Instance + + baseURI = "http://example.com" +) + +func TestMain(m *testing.M) { + os.Exit(func() int { + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Minute) + defer cancel() + + instance = integration.NewInstance(ctx) + instancePermissionV2 = integration.NewInstance(ctx) + + IAMOwnerCtx = instance.WithAuthorization(ctx, integration.UserTypeIAMOwner) + + LoginUserCtx = instance.WithAuthorization(ctx, integration.UserTypeLogin) + OrgOwnerCtx = instance.WithAuthorization(ctx, integration.UserTypeOrgOwner) + NoPermissionCtx = instance.WithAuthorization(ctx, integration.UserTypeNoPermission) + + return m.Run() + }()) +} + +func getProjectAndProjectContext(t *testing.T, inst *integration.Instance, ctx context.Context) (*project_v2beta.CreateProjectResponse, context.Context) { + project := inst.CreateProject(ctx, t, inst.DefaultOrg.GetId(), gofakeit.Name(), false, false) + userResp := inst.CreateMachineUser(ctx) + patResp := inst.CreatePersonalAccessToken(ctx, userResp.GetUserId()) + inst.CreateProjectMembership(t, ctx, project.GetId(), userResp.GetUserId()) + projectOwnerCtx := integration.WithAuthorizationToken(context.Background(), patResp.Token) + + return project, projectOwnerCtx +} + +func samlMetadataGen(entityID string) []byte { + str := fmt.Sprintf(` + + + urn:oasis:names:tc:SAML:1.1:nameid-format:unspecified + + + + +`, + entityID) + + return []byte(str) +} + +func createSAMLAppWithName(t *testing.T, baseURI, projectID string) ([]byte, *app.CreateApplicationResponse, string) { + samlMetas := samlMetadataGen(gofakeit.URL()) + appName := gofakeit.AppName() + + appForSAMLConfigChange, appSAMLConfigChangeErr := instance.Client.AppV2Beta.CreateApplication(IAMOwnerCtx, &app.CreateApplicationRequest{ + ProjectId: projectID, + Name: appName, + CreationRequestType: &app.CreateApplicationRequest_SamlRequest{ + SamlRequest: &app.CreateSAMLApplicationRequest{ + Metadata: &app.CreateSAMLApplicationRequest_MetadataXml{ + MetadataXml: samlMetas, + }, + LoginVersion: &app.LoginVersion{ + Version: &app.LoginVersion_LoginV2{ + LoginV2: &app.LoginV2{ + BaseUri: &baseURI, + }, + }, + }, + }, + }, + }) + require.Nil(t, appSAMLConfigChangeErr) + + return samlMetas, appForSAMLConfigChange, appName +} + +func createSAMLApp(t *testing.T, baseURI, projectID string) ([]byte, *app.CreateApplicationResponse) { + metas, app, _ := createSAMLAppWithName(t, baseURI, projectID) + return metas, app +} + +func createOIDCAppWithName(t *testing.T, baseURI, projectID string) (*app.CreateApplicationResponse, string) { + appName := gofakeit.AppName() + + appForOIDCConfigChange, appOIDCConfigChangeErr := instance.Client.AppV2Beta.CreateApplication(IAMOwnerCtx, &app.CreateApplicationRequest{ + ProjectId: projectID, + Name: appName, + CreationRequestType: &app.CreateApplicationRequest_OidcRequest{ + OidcRequest: &app.CreateOIDCApplicationRequest{ + RedirectUris: []string{"http://example.com"}, + ResponseTypes: []app.OIDCResponseType{app.OIDCResponseType_OIDC_RESPONSE_TYPE_CODE}, + GrantTypes: []app.OIDCGrantType{app.OIDCGrantType_OIDC_GRANT_TYPE_AUTHORIZATION_CODE}, + AppType: app.OIDCAppType_OIDC_APP_TYPE_WEB, + AuthMethodType: app.OIDCAuthMethodType_OIDC_AUTH_METHOD_TYPE_BASIC, + PostLogoutRedirectUris: []string{"http://example.com/home"}, + Version: app.OIDCVersion_OIDC_VERSION_1_0, + AccessTokenType: app.OIDCTokenType_OIDC_TOKEN_TYPE_JWT, + BackChannelLogoutUri: "http://example.com/logout", + LoginVersion: &app.LoginVersion{ + Version: &app.LoginVersion_LoginV2{ + LoginV2: &app.LoginV2{ + BaseUri: &baseURI, + }, + }, + }, + }, + }, + }) + require.Nil(t, appOIDCConfigChangeErr) + + return appForOIDCConfigChange, appName +} + +func createOIDCApp(t *testing.T, baseURI, projctID string) *app.CreateApplicationResponse { + app, _ := createOIDCAppWithName(t, baseURI, projctID) + + return app +} + +func createAPIAppWithName(t *testing.T, projectID string) (*app.CreateApplicationResponse, string) { + appName := gofakeit.AppName() + + reqForAPIAppCreation := &app.CreateApplicationRequest_ApiRequest{ + ApiRequest: &app.CreateAPIApplicationRequest{AuthMethodType: app.APIAuthMethodType_API_AUTH_METHOD_TYPE_PRIVATE_KEY_JWT}, + } + + appForAPIConfigChange, appAPIConfigChangeErr := instance.Client.AppV2Beta.CreateApplication(IAMOwnerCtx, &app.CreateApplicationRequest{ + ProjectId: projectID, + Name: appName, + CreationRequestType: reqForAPIAppCreation, + }) + require.Nil(t, appAPIConfigChangeErr) + + return appForAPIConfigChange, appName +} + +func createAPIApp(t *testing.T, projectID string) *app.CreateApplicationResponse { + res, _ := createAPIAppWithName(t, projectID) + return res +} + +func deactivateApp(t *testing.T, appToDeactivate *app.CreateApplicationResponse, projectID string) { + _, appDeactivateErr := instance.Client.AppV2Beta.DeactivateApplication(IAMOwnerCtx, &app.DeactivateApplicationRequest{ + ProjectId: projectID, + Id: appToDeactivate.GetAppId(), + }) + require.Nil(t, appDeactivateErr) +} + +func ensureFeaturePermissionV2Enabled(t *testing.T, instance *integration.Instance) { + ctx := instance.WithAuthorization(context.Background(), integration.UserTypeIAMOwner) + f, err := instance.Client.FeatureV2.GetInstanceFeatures(ctx, &feature.GetInstanceFeaturesRequest{ + Inheritance: true, + }) + require.NoError(t, err) + + if f.PermissionCheckV2.GetEnabled() { + return + } + + _, err = instance.Client.FeatureV2.SetInstanceFeatures(ctx, &feature.SetInstanceFeaturesRequest{ + PermissionCheckV2: gu.Ptr(true), + }) + require.NoError(t, err) + + retryDuration, tick := integration.WaitForAndTickWithMaxDuration(ctx, 5*time.Minute) + require.EventuallyWithT(t, func(tt *assert.CollectT) { + f, err := instance.Client.FeatureV2.GetInstanceFeatures(ctx, &feature.GetInstanceFeaturesRequest{Inheritance: true}) + require.NoError(tt, err) + assert.True(tt, f.PermissionCheckV2.GetEnabled()) + }, retryDuration, tick, "timed out waiting for ensuring instance feature") +} diff --git a/internal/api/grpc/app/v2beta/query.go b/internal/api/grpc/app/v2beta/query.go new file mode 100644 index 0000000000..add8af83e6 --- /dev/null +++ b/internal/api/grpc/app/v2beta/query.go @@ -0,0 +1,37 @@ +package app + +import ( + "context" + + "github.com/zitadel/zitadel/internal/api/grpc/app/v2beta/convert" + filter "github.com/zitadel/zitadel/internal/api/grpc/filter/v2" + app "github.com/zitadel/zitadel/pkg/grpc/app/v2beta" +) + +func (s *Server) GetApplication(ctx context.Context, req *app.GetApplicationRequest) (*app.GetApplicationResponse, error) { + res, err := s.query.AppByIDWithPermission(ctx, req.GetId(), false, s.checkPermission) + if err != nil { + return nil, err + } + + return &app.GetApplicationResponse{ + App: convert.AppToPb(res), + }, nil +} + +func (s *Server) ListApplications(ctx context.Context, req *app.ListApplicationsRequest) (*app.ListApplicationsResponse, error) { + queries, err := convert.ListApplicationsRequestToModel(s.systemDefaults, req) + if err != nil { + return nil, err + } + + res, err := s.query.SearchApps(ctx, queries, s.checkPermission) + if err != nil { + return nil, err + } + + return &app.ListApplicationsResponse{ + Applications: convert.AppsToPb(res.Apps), + Pagination: filter.QueryToPaginationPb(queries.SearchRequest, res.SearchResponse), + }, nil +} diff --git a/internal/api/grpc/app/v2beta/server.go b/internal/api/grpc/app/v2beta/server.go new file mode 100644 index 0000000000..8343cbe404 --- /dev/null +++ b/internal/api/grpc/app/v2beta/server.go @@ -0,0 +1,57 @@ +package app + +import ( + "google.golang.org/grpc" + + "github.com/zitadel/zitadel/internal/api/authz" + "github.com/zitadel/zitadel/internal/api/grpc/server" + "github.com/zitadel/zitadel/internal/command" + "github.com/zitadel/zitadel/internal/config/systemdefaults" + "github.com/zitadel/zitadel/internal/domain" + "github.com/zitadel/zitadel/internal/query" + app "github.com/zitadel/zitadel/pkg/grpc/app/v2beta" +) + +var _ app.AppServiceServer = (*Server)(nil) + +type Server struct { + app.UnimplementedAppServiceServer + command *command.Commands + query *query.Queries + systemDefaults systemdefaults.SystemDefaults + checkPermission domain.PermissionCheck +} + +type Config struct{} + +func CreateServer( + command *command.Commands, + query *query.Queries, + checkPermission domain.PermissionCheck, +) *Server { + return &Server{ + command: command, + query: query, + checkPermission: checkPermission, + } +} + +func (s *Server) RegisterServer(grpcServer *grpc.Server) { + app.RegisterAppServiceServer(grpcServer, s) +} + +func (s *Server) AppName() string { + return app.AppService_ServiceDesc.ServiceName +} + +func (s *Server) MethodPrefix() string { + return app.AppService_ServiceDesc.ServiceName +} + +func (s *Server) AuthMethods() authz.MethodMapping { + return app.AppService_AuthMethods +} + +func (s *Server) RegisterGateway() server.RegisterGatewayFunc { + return app.RegisterAppServiceHandler +} diff --git a/internal/api/grpc/filter/v2/converter.go b/internal/api/grpc/filter/v2/converter.go index 7a7d7cd8d7..f797ad4bba 100644 --- a/internal/api/grpc/filter/v2/converter.go +++ b/internal/api/grpc/filter/v2/converter.go @@ -48,3 +48,26 @@ func QueryToPaginationPb(request query.SearchRequest, response query.SearchRespo TotalResult: response.Count, } } + +func TextMethodPbToQuery(method filter.TextFilterMethod) query.TextComparison { + switch method { + case filter.TextFilterMethod_TEXT_FILTER_METHOD_EQUALS: + return query.TextEquals + case filter.TextFilterMethod_TEXT_FILTER_METHOD_EQUALS_IGNORE_CASE: + return query.TextEqualsIgnoreCase + case filter.TextFilterMethod_TEXT_FILTER_METHOD_STARTS_WITH: + return query.TextStartsWith + case filter.TextFilterMethod_TEXT_FILTER_METHOD_STARTS_WITH_IGNORE_CASE: + return query.TextStartsWithIgnoreCase + case filter.TextFilterMethod_TEXT_FILTER_METHOD_CONTAINS: + return query.TextContains + case filter.TextFilterMethod_TEXT_FILTER_METHOD_CONTAINS_IGNORE_CASE: + return query.TextContainsIgnoreCase + case filter.TextFilterMethod_TEXT_FILTER_METHOD_ENDS_WITH: + return query.TextEndsWith + case filter.TextFilterMethod_TEXT_FILTER_METHOD_ENDS_WITH_IGNORE_CASE: + return query.TextEndsWithIgnoreCase + default: + return -1 + } +} diff --git a/internal/api/grpc/management/project_application.go b/internal/api/grpc/management/project_application.go index ab49905409..a5526d3cb7 100644 --- a/internal/api/grpc/management/project_application.go +++ b/internal/api/grpc/management/project_application.go @@ -29,7 +29,7 @@ func (s *Server) ListApps(ctx context.Context, req *mgmt_pb.ListAppsRequest) (*m if err != nil { return nil, err } - apps, err := s.query.SearchApps(ctx, queries, false) + apps, err := s.query.SearchApps(ctx, queries, nil) if err != nil { return nil, err } @@ -125,7 +125,7 @@ func (s *Server) AddAPIApp(ctx context.Context, req *mgmt_pb.AddAPIAppRequest) ( } func (s *Server) UpdateApp(ctx context.Context, req *mgmt_pb.UpdateAppRequest) (*mgmt_pb.UpdateAppResponse, error) { - details, err := s.command.ChangeApplication(ctx, req.ProjectId, UpdateAppRequestToDomain(req), authz.GetCtxData(ctx).OrgID) + details, err := s.command.UpdateApplicationName(ctx, req.ProjectId, UpdateAppRequestToDomain(req), authz.GetCtxData(ctx).OrgID) if err != nil { return nil, err } @@ -139,7 +139,7 @@ func (s *Server) UpdateOIDCAppConfig(ctx context.Context, req *mgmt_pb.UpdateOID if err != nil { return nil, err } - config, err := s.command.ChangeOIDCApplication(ctx, oidcApp, authz.GetCtxData(ctx).OrgID) + config, err := s.command.UpdateOIDCApplication(ctx, oidcApp, authz.GetCtxData(ctx).OrgID) if err != nil { return nil, err } @@ -157,7 +157,7 @@ func (s *Server) UpdateSAMLAppConfig(ctx context.Context, req *mgmt_pb.UpdateSAM if err != nil { return nil, err } - config, err := s.command.ChangeSAMLApplication(ctx, samlApp, authz.GetCtxData(ctx).OrgID) + config, err := s.command.UpdateSAMLApplication(ctx, samlApp, authz.GetCtxData(ctx).OrgID) if err != nil { return nil, err } @@ -171,7 +171,7 @@ func (s *Server) UpdateSAMLAppConfig(ctx context.Context, req *mgmt_pb.UpdateSAM } func (s *Server) UpdateAPIAppConfig(ctx context.Context, req *mgmt_pb.UpdateAPIAppConfigRequest) (*mgmt_pb.UpdateAPIAppConfigResponse, error) { - config, err := s.command.ChangeAPIApplication(ctx, UpdateAPIAppConfigRequestToDomain(req), authz.GetCtxData(ctx).OrgID) + config, err := s.command.UpdateAPIApplication(ctx, UpdateAPIAppConfigRequestToDomain(req), authz.GetCtxData(ctx).OrgID) if err != nil { return nil, err } diff --git a/internal/api/grpc/management/project_application_converter.go b/internal/api/grpc/management/project_application_converter.go index 13a0048a5b..186cedc933 100644 --- a/internal/api/grpc/management/project_application_converter.go +++ b/internal/api/grpc/management/project_application_converter.go @@ -4,6 +4,8 @@ import ( "context" "time" + "github.com/muhlemmer/gu" + "github.com/zitadel/zitadel/internal/api/authz" authn_grpc "github.com/zitadel/zitadel/internal/api/grpc/authn" "github.com/zitadel/zitadel/internal/api/grpc/object" @@ -46,24 +48,24 @@ func AddOIDCAppRequestToDomain(req *mgmt_pb.AddOIDCAppRequest) (*domain.OIDCApp, AggregateID: req.ProjectId, }, AppName: req.Name, - OIDCVersion: app_grpc.OIDCVersionToDomain(req.Version), + OIDCVersion: gu.Ptr(app_grpc.OIDCVersionToDomain(req.Version)), RedirectUris: req.RedirectUris, ResponseTypes: app_grpc.OIDCResponseTypesToDomain(req.ResponseTypes), GrantTypes: app_grpc.OIDCGrantTypesToDomain(req.GrantTypes), - ApplicationType: app_grpc.OIDCApplicationTypeToDomain(req.AppType), - AuthMethodType: app_grpc.OIDCAuthMethodTypeToDomain(req.AuthMethodType), + ApplicationType: gu.Ptr(app_grpc.OIDCApplicationTypeToDomain(req.AppType)), + AuthMethodType: gu.Ptr(app_grpc.OIDCAuthMethodTypeToDomain(req.AuthMethodType)), PostLogoutRedirectUris: req.PostLogoutRedirectUris, - DevMode: req.DevMode, - AccessTokenType: app_grpc.OIDCTokenTypeToDomain(req.AccessTokenType), - AccessTokenRoleAssertion: req.AccessTokenRoleAssertion, - IDTokenRoleAssertion: req.IdTokenRoleAssertion, - IDTokenUserinfoAssertion: req.IdTokenUserinfoAssertion, - ClockSkew: req.ClockSkew.AsDuration(), + DevMode: gu.Ptr(req.GetDevMode()), + AccessTokenType: gu.Ptr(app_grpc.OIDCTokenTypeToDomain(req.AccessTokenType)), + AccessTokenRoleAssertion: gu.Ptr(req.GetAccessTokenRoleAssertion()), + IDTokenRoleAssertion: gu.Ptr(req.GetIdTokenRoleAssertion()), + IDTokenUserinfoAssertion: gu.Ptr(req.GetIdTokenUserinfoAssertion()), + ClockSkew: gu.Ptr(req.GetClockSkew().AsDuration()), AdditionalOrigins: req.AdditionalOrigins, - SkipNativeAppSuccessPage: req.SkipNativeAppSuccessPage, - BackChannelLogoutURI: req.GetBackChannelLogoutUri(), - LoginVersion: loginVersion, - LoginBaseURI: loginBaseURI, + SkipNativeAppSuccessPage: gu.Ptr(req.GetSkipNativeAppSuccessPage()), + BackChannelLogoutURI: gu.Ptr(req.GetBackChannelLogoutUri()), + LoginVersion: gu.Ptr(loginVersion), + LoginBaseURI: gu.Ptr(loginBaseURI), }, nil } @@ -78,9 +80,9 @@ func AddSAMLAppRequestToDomain(req *mgmt_pb.AddSAMLAppRequest) (*domain.SAMLApp, }, AppName: req.Name, Metadata: req.GetMetadataXml(), - MetadataURL: req.GetMetadataUrl(), - LoginVersion: loginVersion, - LoginBaseURI: loginBaseURI, + MetadataURL: gu.Ptr(req.GetMetadataUrl()), + LoginVersion: gu.Ptr(loginVersion), + LoginBaseURI: gu.Ptr(loginBaseURI), }, nil } @@ -114,20 +116,20 @@ func UpdateOIDCAppConfigRequestToDomain(app *mgmt_pb.UpdateOIDCAppConfigRequest) RedirectUris: app.RedirectUris, ResponseTypes: app_grpc.OIDCResponseTypesToDomain(app.ResponseTypes), GrantTypes: app_grpc.OIDCGrantTypesToDomain(app.GrantTypes), - ApplicationType: app_grpc.OIDCApplicationTypeToDomain(app.AppType), - AuthMethodType: app_grpc.OIDCAuthMethodTypeToDomain(app.AuthMethodType), + ApplicationType: gu.Ptr(app_grpc.OIDCApplicationTypeToDomain(app.AppType)), + AuthMethodType: gu.Ptr(app_grpc.OIDCAuthMethodTypeToDomain(app.AuthMethodType)), PostLogoutRedirectUris: app.PostLogoutRedirectUris, - DevMode: app.DevMode, - AccessTokenType: app_grpc.OIDCTokenTypeToDomain(app.AccessTokenType), - AccessTokenRoleAssertion: app.AccessTokenRoleAssertion, - IDTokenRoleAssertion: app.IdTokenRoleAssertion, - IDTokenUserinfoAssertion: app.IdTokenUserinfoAssertion, - ClockSkew: app.ClockSkew.AsDuration(), + DevMode: gu.Ptr(app.GetDevMode()), + AccessTokenType: gu.Ptr(app_grpc.OIDCTokenTypeToDomain(app.AccessTokenType)), + AccessTokenRoleAssertion: gu.Ptr(app.GetAccessTokenRoleAssertion()), + IDTokenRoleAssertion: gu.Ptr(app.GetIdTokenRoleAssertion()), + IDTokenUserinfoAssertion: gu.Ptr(app.GetIdTokenUserinfoAssertion()), + ClockSkew: gu.Ptr(app.GetClockSkew().AsDuration()), AdditionalOrigins: app.AdditionalOrigins, - SkipNativeAppSuccessPage: app.SkipNativeAppSuccessPage, - BackChannelLogoutURI: app.BackChannelLogoutUri, - LoginVersion: loginVersion, - LoginBaseURI: loginBaseURI, + SkipNativeAppSuccessPage: gu.Ptr(app.GetSkipNativeAppSuccessPage()), + BackChannelLogoutURI: gu.Ptr(app.GetBackChannelLogoutUri()), + LoginVersion: gu.Ptr(loginVersion), + LoginBaseURI: gu.Ptr(loginBaseURI), }, nil } @@ -142,9 +144,9 @@ func UpdateSAMLAppConfigRequestToDomain(app *mgmt_pb.UpdateSAMLAppConfigRequest) }, AppID: app.AppId, Metadata: app.GetMetadataXml(), - MetadataURL: app.GetMetadataUrl(), - LoginVersion: loginVersion, - LoginBaseURI: loginBaseURI, + MetadataURL: gu.Ptr(app.GetMetadataUrl()), + LoginVersion: gu.Ptr(loginVersion), + LoginBaseURI: gu.Ptr(loginBaseURI), }, nil } diff --git a/internal/authz/repository/eventsourcing/view/application.go b/internal/authz/repository/eventsourcing/view/application.go index 8db8ec8e39..7fa920bcfe 100644 --- a/internal/authz/repository/eventsourcing/view/application.go +++ b/internal/authz/repository/eventsourcing/view/application.go @@ -32,7 +32,7 @@ func (v *View) ApplicationByProjecIDAndAppName(ctx context.Context, projectID, a }, } - apps, err := v.Query.SearchApps(ctx, queries, false) + apps, err := v.Query.SearchApps(ctx, queries, nil) if err != nil { return nil, err } diff --git a/internal/command/permission_checks.go b/internal/command/permission_checks.go index 6bfeaae219..3f978b6618 100644 --- a/internal/command/permission_checks.go +++ b/internal/command/permission_checks.go @@ -85,3 +85,11 @@ func (c *Commands) checkPermissionDeleteProjectGrant(ctx context.Context, resour } return nil } + +func (c *Commands) checkPermissionUpdateApplication(ctx context.Context, resourceOwner, appID string) error { + return c.newPermissionCheck(ctx, domain.PermissionProjectAppWrite, project.AggregateType)(resourceOwner, appID) +} + +func (c *Commands) checkPermissionDeleteApp(ctx context.Context, resourceOwner, appID string) error { + return c.newPermissionCheck(ctx, domain.PermissionProjectAppDelete, project.AggregateType)(resourceOwner, appID) +} diff --git a/internal/command/project_application.go b/internal/command/project_application.go index 0ccf5dc852..465b12e1e1 100644 --- a/internal/command/project_application.go +++ b/internal/command/project_application.go @@ -15,7 +15,7 @@ type AddApp struct { Name string } -func (c *Commands) ChangeApplication(ctx context.Context, projectID string, appChange domain.Application, resourceOwner string) (*domain.ObjectDetails, error) { +func (c *Commands) UpdateApplicationName(ctx context.Context, projectID string, appChange domain.Application, resourceOwner string) (*domain.ObjectDetails, error) { if projectID == "" || appChange.GetAppID() == "" || appChange.GetApplicationName() == "" { return nil, zerrors.ThrowInvalidArgument(nil, "COMMAND-4m9vS", "Errors.Project.App.Invalid") } @@ -30,6 +30,13 @@ func (c *Commands) ChangeApplication(ctx context.Context, projectID string, appC if existingApp.Name == appChange.GetApplicationName() { return nil, zerrors.ThrowPreconditionFailed(nil, "COMMAND-2m8vx", "Errors.NoChangesFound") } + if err := c.eventstore.FilterToQueryReducer(ctx, existingApp); err != nil { + return nil, err + } + if err := c.checkPermissionUpdateApplication(ctx, existingApp.ResourceOwner, existingApp.AggregateID); err != nil { + return nil, err + } + projectAgg := ProjectAggregateFromWriteModel(&existingApp.WriteModel) pushedEvents, err := c.eventstore.Push( ctx, @@ -59,6 +66,13 @@ func (c *Commands) DeactivateApplication(ctx context.Context, projectID, appID, if existingApp.State != domain.AppStateActive { return nil, zerrors.ThrowPreconditionFailed(nil, "COMMAND-dsh35", "Errors.Project.App.NotActive") } + if err := c.eventstore.FilterToQueryReducer(ctx, existingApp); err != nil { + return nil, err + } + if err := c.checkPermissionUpdateApplication(ctx, existingApp.ResourceOwner, existingApp.AggregateID); err != nil { + return nil, err + } + projectAgg := ProjectAggregateFromWriteModel(&existingApp.WriteModel) pushedEvents, err := c.eventstore.Push(ctx, project.NewApplicationDeactivatedEvent(ctx, projectAgg, appID)) if err != nil { @@ -86,6 +100,11 @@ func (c *Commands) ReactivateApplication(ctx context.Context, projectID, appID, if existingApp.State != domain.AppStateInactive { return nil, zerrors.ThrowPreconditionFailed(nil, "COMMAND-1n8cM", "Errors.Project.App.NotInactive") } + + if err := c.checkPermissionUpdateApplication(ctx, existingApp.ResourceOwner, existingApp.AggregateID); err != nil { + return nil, err + } + projectAgg := ProjectAggregateFromWriteModel(&existingApp.WriteModel) pushedEvents, err := c.eventstore.Push(ctx, project.NewApplicationReactivatedEvent(ctx, projectAgg, appID)) @@ -111,6 +130,13 @@ func (c *Commands) RemoveApplication(ctx context.Context, projectID, appID, reso if existingApp.State == domain.AppStateUnspecified || existingApp.State == domain.AppStateRemoved { return nil, zerrors.ThrowNotFound(nil, "COMMAND-0po9s", "Errors.Project.App.NotExisting") } + if err := c.eventstore.FilterToQueryReducer(ctx, existingApp); err != nil { + return nil, err + } + if err := c.checkPermissionDeleteApp(ctx, existingApp.ResourceOwner, existingApp.AggregateID); err != nil { + return nil, err + } + projectAgg := ProjectAggregateFromWriteModel(&existingApp.WriteModel) entityID := "" diff --git a/internal/command/project_application_api.go b/internal/command/project_application_api.go index 2832dcf873..82e7d0bde8 100644 --- a/internal/command/project_application_api.go +++ b/internal/command/project_application_api.go @@ -90,16 +90,24 @@ func (c *Commands) AddAPIApplication(ctx context.Context, apiApp *domain.APIApp, return nil, zerrors.ThrowInvalidArgument(nil, "PROJECT-5m9E", "Errors.Project.App.Invalid") } - if _, err := c.checkProjectExists(ctx, apiApp.AggregateID, resourceOwner); err != nil { + projectResOwner, err := c.checkProjectExists(ctx, apiApp.AggregateID, resourceOwner) + if err != nil { return nil, err } + if resourceOwner == "" { + resourceOwner = projectResOwner + } + if !apiApp.IsValid() { return nil, zerrors.ThrowInvalidArgument(nil, "PROJECT-Bff2g", "Errors.Project.App.Invalid") } - appID, err := c.idGenerator.Next() - if err != nil { - return nil, err + appID := apiApp.AppID + if appID == "" { + appID, err = c.idGenerator.Next() + if err != nil { + return nil, err + } } return c.addAPIApplicationWithID(ctx, apiApp, resourceOwner, appID) @@ -112,6 +120,13 @@ func (c *Commands) addAPIApplicationWithID(ctx context.Context, apiApp *domain.A apiApp.AppID = appID addedApplication := NewAPIApplicationWriteModel(apiApp.AggregateID, resourceOwner) + if err := c.eventstore.FilterToQueryReducer(ctx, addedApplication); err != nil { + return nil, err + } + if err := c.checkPermissionUpdateApplication(ctx, addedApplication.ResourceOwner, addedApplication.AggregateID); err != nil { + return nil, err + } + projectAgg := ProjectAggregateFromWriteModel(&addedApplication.WriteModel) events := []eventstore.Command{ @@ -150,7 +165,7 @@ func (c *Commands) addAPIApplicationWithID(ctx context.Context, apiApp *domain.A return result, nil } -func (c *Commands) ChangeAPIApplication(ctx context.Context, apiApp *domain.APIApp, resourceOwner string) (*domain.APIApp, error) { +func (c *Commands) UpdateAPIApplication(ctx context.Context, apiApp *domain.APIApp, resourceOwner string) (*domain.APIApp, error) { if apiApp.AppID == "" || apiApp.AggregateID == "" { return nil, zerrors.ThrowInvalidArgument(nil, "COMMAND-1m900", "Errors.Project.App.APIConfigInvalid") } @@ -165,6 +180,13 @@ func (c *Commands) ChangeAPIApplication(ctx context.Context, apiApp *domain.APIA if !existingAPI.IsAPI() { return nil, zerrors.ThrowInvalidArgument(nil, "COMMAND-Gnwt3", "Errors.Project.App.IsNotAPI") } + if err := c.eventstore.FilterToQueryReducer(ctx, existingAPI); err != nil { + return nil, err + } + if err := c.checkPermissionUpdateApplication(ctx, existingAPI.ResourceOwner, existingAPI.AggregateID); err != nil { + return nil, err + } + projectAgg := ProjectAggregateFromWriteModel(&existingAPI.WriteModel) changedEvent, hasChanged, err := existingAPI.NewChangedEvent( ctx, @@ -205,6 +227,11 @@ func (c *Commands) ChangeAPIApplicationSecret(ctx context.Context, projectID, ap if !existingAPI.IsAPI() { return nil, zerrors.ThrowInvalidArgument(nil, "COMMAND-aeH4", "Errors.Project.App.IsNotAPI") } + + if err := c.checkPermissionUpdateApplication(ctx, existingAPI.ResourceOwner, existingAPI.AggregateID); err != nil { + return nil, err + } + encodedHash, plain, err := c.newHashedSecret(ctx, c.eventstore.Filter) //nolint:staticcheck if err != nil { return nil, err diff --git a/internal/command/project_application_api_test.go b/internal/command/project_application_api_test.go index a6d4349254..53448e1c5e 100644 --- a/internal/command/project_application_api_test.go +++ b/internal/command/project_application_api_test.go @@ -142,6 +142,7 @@ func TestAddAPIConfig(t *testing.T) { } func TestCommandSide_AddAPIApplication(t *testing.T) { + t.Parallel() type fields struct { eventstore func(t *testing.T) *eventstore.Eventstore idGenerator id.Generator @@ -238,6 +239,7 @@ func TestCommandSide_AddAPIApplication(t *testing.T) { domain.PrivateLabelingSettingUnspecified), ), ), + expectFilter(), expectPush( project.NewApplicationAddedEvent(context.Background(), &project.NewAggregate("project1", "org1").Aggregate, @@ -292,6 +294,7 @@ func TestCommandSide_AddAPIApplication(t *testing.T) { domain.PrivateLabelingSettingUnspecified), ), ), + expectFilter(), expectPush( project.NewApplicationAddedEvent(context.Background(), &project.NewAggregate("project1", "org1").Aggregate, @@ -346,6 +349,7 @@ func TestCommandSide_AddAPIApplication(t *testing.T) { domain.PrivateLabelingSettingUnspecified), ), ), + expectFilter(), expectPush( project.NewApplicationAddedEvent(context.Background(), &project.NewAggregate("project1", "org1").Aggregate, @@ -390,6 +394,8 @@ func TestCommandSide_AddAPIApplication(t *testing.T) { } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { + t.Parallel() + r := &Commands{ eventstore: tt.fields.eventstore(t), idGenerator: tt.fields.idGenerator, @@ -397,6 +403,7 @@ func TestCommandSide_AddAPIApplication(t *testing.T) { defaultSecretGenerators: &SecretGenerators{ ClientSecret: emptyConfig, }, + checkPermission: newMockPermissionCheckAllowed(), } got, err := r.AddAPIApplication(tt.args.ctx, tt.args.apiApp, tt.args.resourceOwner) if tt.res.err == nil { @@ -413,6 +420,8 @@ func TestCommandSide_AddAPIApplication(t *testing.T) { } func TestCommandSide_ChangeAPIApplication(t *testing.T) { + t.Parallel() + type fields struct { eventstore func(t *testing.T) *eventstore.Eventstore } @@ -516,6 +525,7 @@ func TestCommandSide_ChangeAPIApplication(t *testing.T) { domain.APIAuthMethodTypePrivateKeyJWT), ), ), + expectFilter(), ), }, args: args{ @@ -555,6 +565,7 @@ func TestCommandSide_ChangeAPIApplication(t *testing.T) { domain.APIAuthMethodTypeBasic), ), ), + expectFilter(), expectPush( newAPIAppChangedEvent(context.Background(), "app1", @@ -593,14 +604,17 @@ func TestCommandSide_ChangeAPIApplication(t *testing.T) { } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { + t.Parallel() + r := &Commands{ eventstore: tt.fields.eventstore(t), newHashedSecret: mockHashedSecret("secret"), defaultSecretGenerators: &SecretGenerators{ ClientSecret: emptyConfig, }, + checkPermission: newMockPermissionCheckAllowed(), } - got, err := r.ChangeAPIApplication(tt.args.ctx, tt.args.apiApp, tt.args.resourceOwner) + got, err := r.UpdateAPIApplication(tt.args.ctx, tt.args.apiApp, tt.args.resourceOwner) if tt.res.err == nil { assert.NoError(t, err) } @@ -615,6 +629,8 @@ func TestCommandSide_ChangeAPIApplication(t *testing.T) { } func TestCommandSide_ChangeAPIApplicationSecret(t *testing.T) { + t.Parallel() + type fields struct { eventstore func(*testing.T) *eventstore.Eventstore } @@ -734,12 +750,15 @@ func TestCommandSide_ChangeAPIApplicationSecret(t *testing.T) { } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { + t.Parallel() + r := &Commands{ eventstore: tt.fields.eventstore(t), newHashedSecret: mockHashedSecret("secret"), defaultSecretGenerators: &SecretGenerators{ ClientSecret: emptyConfig, }, + checkPermission: newMockPermissionCheckAllowed(), } got, err := r.ChangeAPIApplicationSecret(tt.args.ctx, tt.args.projectID, tt.args.appID, tt.args.resourceOwner) if tt.res.err == nil { diff --git a/internal/command/project_application_oidc.go b/internal/command/project_application_oidc.go index 77ef7ff0c7..7f33b6a3cf 100644 --- a/internal/command/project_application_oidc.go +++ b/internal/command/project_application_oidc.go @@ -5,6 +5,8 @@ import ( "strings" "time" + "github.com/muhlemmer/gu" + http_util "github.com/zitadel/zitadel/internal/api/http" "github.com/zitadel/zitadel/internal/command/preparation" "github.com/zitadel/zitadel/internal/domain" @@ -120,6 +122,7 @@ func (c *Commands) AddOIDCAppCommand(app *addOIDCApp) preparation.Validation { } } +// TODO: Combine with AddOIDCApplication and addOIDCApplicationWithID func (c *Commands) AddOIDCApplicationWithID(ctx context.Context, oidcApp *domain.OIDCApp, resourceOwner, appID string) (_ *domain.OIDCApp, err error) { ctx, span := tracing.NewSpan(ctx) defer func() { span.EndWithError(err) }() @@ -142,9 +145,15 @@ func (c *Commands) AddOIDCApplication(ctx context.Context, oidcApp *domain.OIDCA if oidcApp == nil || oidcApp.AggregateID == "" { return nil, zerrors.ThrowInvalidArgument(nil, "PROJECT-34Fm0", "Errors.Project.App.Invalid") } - if _, err := c.checkProjectExists(ctx, oidcApp.AggregateID, resourceOwner); err != nil { + + projectResOwner, err := c.checkProjectExists(ctx, oidcApp.AggregateID, resourceOwner) + if err != nil { return nil, err } + if resourceOwner == "" { + resourceOwner = projectResOwner + } + if oidcApp.AppName == "" || !oidcApp.IsValid() { return nil, zerrors.ThrowInvalidArgument(nil, "PROJECT-1n8df", "Errors.Project.App.Invalid") } @@ -162,6 +171,13 @@ func (c *Commands) addOIDCApplicationWithID(ctx context.Context, oidcApp *domain defer func() { span.EndWithError(err) }() addedApplication := NewOIDCApplicationWriteModel(oidcApp.AggregateID, resourceOwner) + if err := c.eventstore.FilterToQueryReducer(ctx, addedApplication); err != nil { + return nil, err + } + if err := c.checkPermissionUpdateApplication(ctx, addedApplication.ResourceOwner, addedApplication.AggregateID); err != nil { + return nil, err + } + projectAgg := ProjectAggregateFromWriteModel(&addedApplication.WriteModel) oidcApp.AppID = appID @@ -183,27 +199,27 @@ func (c *Commands) addOIDCApplicationWithID(ctx context.Context, oidcApp *domain } events = append(events, project_repo.NewOIDCConfigAddedEvent(ctx, projectAgg, - oidcApp.OIDCVersion, + gu.Value(oidcApp.OIDCVersion), oidcApp.AppID, oidcApp.ClientID, oidcApp.EncodedHash, trimStringSliceWhiteSpaces(oidcApp.RedirectUris), oidcApp.ResponseTypes, oidcApp.GrantTypes, - oidcApp.ApplicationType, - oidcApp.AuthMethodType, + gu.Value(oidcApp.ApplicationType), + gu.Value(oidcApp.AuthMethodType), trimStringSliceWhiteSpaces(oidcApp.PostLogoutRedirectUris), - oidcApp.DevMode, - oidcApp.AccessTokenType, - oidcApp.AccessTokenRoleAssertion, - oidcApp.IDTokenRoleAssertion, - oidcApp.IDTokenUserinfoAssertion, - oidcApp.ClockSkew, + gu.Value(oidcApp.DevMode), + gu.Value(oidcApp.AccessTokenType), + gu.Value(oidcApp.AccessTokenRoleAssertion), + gu.Value(oidcApp.IDTokenRoleAssertion), + gu.Value(oidcApp.IDTokenUserinfoAssertion), + gu.Value(oidcApp.ClockSkew), trimStringSliceWhiteSpaces(oidcApp.AdditionalOrigins), - oidcApp.SkipNativeAppSuccessPage, - strings.TrimSpace(oidcApp.BackChannelLogoutURI), - oidcApp.LoginVersion, - strings.TrimSpace(oidcApp.LoginBaseURI), + gu.Value(oidcApp.SkipNativeAppSuccessPage), + strings.TrimSpace(gu.Value(oidcApp.BackChannelLogoutURI)), + gu.Value(oidcApp.LoginVersion), + strings.TrimSpace(gu.Value(oidcApp.LoginBaseURI)), )) addedApplication.AppID = oidcApp.AppID @@ -226,7 +242,7 @@ func (c *Commands) addOIDCApplicationWithID(ctx context.Context, oidcApp *domain return result, nil } -func (c *Commands) ChangeOIDCApplication(ctx context.Context, oidc *domain.OIDCApp, resourceOwner string) (*domain.OIDCApp, error) { +func (c *Commands) UpdateOIDCApplication(ctx context.Context, oidc *domain.OIDCApp, resourceOwner string) (*domain.OIDCApp, error) { if !oidc.IsValid() || oidc.AppID == "" || oidc.AggregateID == "" { return nil, zerrors.ThrowInvalidArgument(nil, "COMMAND-5m9fs", "Errors.Project.App.OIDCConfigInvalid") } @@ -241,7 +257,23 @@ func (c *Commands) ChangeOIDCApplication(ctx context.Context, oidc *domain.OIDCA if !existingOIDC.IsOIDC() { return nil, zerrors.ThrowInvalidArgument(nil, "COMMAND-GBr34", "Errors.Project.App.IsNotOIDC") } + if err := c.eventstore.FilterToQueryReducer(ctx, existingOIDC); err != nil { + return nil, err + } + if err := c.checkPermissionUpdateApplication(ctx, existingOIDC.ResourceOwner, existingOIDC.AggregateID); err != nil { + return nil, err + } + projectAgg := ProjectAggregateFromWriteModel(&existingOIDC.WriteModel) + var backChannelLogout, loginBaseURI *string + if oidc.BackChannelLogoutURI != nil { + backChannelLogout = gu.Ptr(strings.TrimSpace(*oidc.BackChannelLogoutURI)) + } + + if oidc.LoginBaseURI != nil { + loginBaseURI = gu.Ptr(strings.TrimSpace(*oidc.LoginBaseURI)) + } + changedEvent, hasChanged, err := existingOIDC.NewChangedEvent( ctx, projectAgg, @@ -261,9 +293,9 @@ func (c *Commands) ChangeOIDCApplication(ctx context.Context, oidc *domain.OIDCA oidc.ClockSkew, trimStringSliceWhiteSpaces(oidc.AdditionalOrigins), oidc.SkipNativeAppSuccessPage, - strings.TrimSpace(oidc.BackChannelLogoutURI), + backChannelLogout, oidc.LoginVersion, - strings.TrimSpace(oidc.LoginBaseURI), + loginBaseURI, ) if err != nil { return nil, err @@ -301,6 +333,11 @@ func (c *Commands) ChangeOIDCApplicationSecret(ctx context.Context, projectID, a if !existingOIDC.IsOIDC() { return nil, zerrors.ThrowInvalidArgument(nil, "COMMAND-Ghrh3", "Errors.Project.App.IsNotOIDC") } + + if err := c.checkPermissionUpdateApplication(ctx, existingOIDC.ResourceOwner, existingOIDC.AggregateID); err != nil { + return nil, err + } + encodedHash, plain, err := c.newHashedSecret(ctx, c.eventstore.Filter) //nolint:staticcheck if err != nil { return nil, err diff --git a/internal/command/project_application_oidc_model.go b/internal/command/project_application_oidc_model.go index 603ebdcda2..375bb26f5e 100644 --- a/internal/command/project_application_oidc_model.go +++ b/internal/command/project_application_oidc_model.go @@ -258,77 +258,77 @@ func (wm *OIDCApplicationWriteModel) NewChangedEvent( postLogoutRedirectURIs []string, responseTypes []domain.OIDCResponseType, grantTypes []domain.OIDCGrantType, - appType domain.OIDCApplicationType, - authMethodType domain.OIDCAuthMethodType, - oidcVersion domain.OIDCVersion, - accessTokenType domain.OIDCTokenType, + appType *domain.OIDCApplicationType, + authMethodType *domain.OIDCAuthMethodType, + oidcVersion *domain.OIDCVersion, + accessTokenType *domain.OIDCTokenType, devMode, accessTokenRoleAssertion, idTokenRoleAssertion, - idTokenUserinfoAssertion bool, - clockSkew time.Duration, + idTokenUserinfoAssertion *bool, + clockSkew *time.Duration, additionalOrigins []string, - skipNativeAppSuccessPage bool, - backChannelLogoutURI string, - loginVersion domain.LoginVersion, - loginBaseURI string, + skipNativeAppSuccessPage *bool, + backChannelLogoutURI *string, + loginVersion *domain.LoginVersion, + loginBaseURI *string, ) (*project.OIDCConfigChangedEvent, bool, error) { changes := make([]project.OIDCConfigChanges, 0) var err error - if !slices.Equal(wm.RedirectUris, redirectURIS) { + if redirectURIS != nil && !slices.Equal(wm.RedirectUris, redirectURIS) { changes = append(changes, project.ChangeRedirectURIs(redirectURIS)) } - if !slices.Equal(wm.ResponseTypes, responseTypes) { + if responseTypes != nil && !slices.Equal(wm.ResponseTypes, responseTypes) { changes = append(changes, project.ChangeResponseTypes(responseTypes)) } - if !slices.Equal(wm.GrantTypes, grantTypes) { + if grantTypes != nil && !slices.Equal(wm.GrantTypes, grantTypes) { changes = append(changes, project.ChangeGrantTypes(grantTypes)) } - if wm.ApplicationType != appType { - changes = append(changes, project.ChangeApplicationType(appType)) + if appType != nil && wm.ApplicationType != *appType { + changes = append(changes, project.ChangeApplicationType(*appType)) } - if wm.AuthMethodType != authMethodType { - changes = append(changes, project.ChangeAuthMethodType(authMethodType)) + if authMethodType != nil && wm.AuthMethodType != *authMethodType { + changes = append(changes, project.ChangeAuthMethodType(*authMethodType)) } - if !slices.Equal(wm.PostLogoutRedirectUris, postLogoutRedirectURIs) { + if postLogoutRedirectURIs != nil && !slices.Equal(wm.PostLogoutRedirectUris, postLogoutRedirectURIs) { changes = append(changes, project.ChangePostLogoutRedirectURIs(postLogoutRedirectURIs)) } - if wm.OIDCVersion != oidcVersion { - changes = append(changes, project.ChangeVersion(oidcVersion)) + if oidcVersion != nil && wm.OIDCVersion != *oidcVersion { + changes = append(changes, project.ChangeVersion(*oidcVersion)) } - if wm.DevMode != devMode { - changes = append(changes, project.ChangeDevMode(devMode)) + if devMode != nil && wm.DevMode != *devMode { + changes = append(changes, project.ChangeDevMode(*devMode)) } - if wm.AccessTokenType != accessTokenType { - changes = append(changes, project.ChangeAccessTokenType(accessTokenType)) + if accessTokenType != nil && wm.AccessTokenType != *accessTokenType { + changes = append(changes, project.ChangeAccessTokenType(*accessTokenType)) } - if wm.AccessTokenRoleAssertion != accessTokenRoleAssertion { - changes = append(changes, project.ChangeAccessTokenRoleAssertion(accessTokenRoleAssertion)) + if accessTokenRoleAssertion != nil && wm.AccessTokenRoleAssertion != *accessTokenRoleAssertion { + changes = append(changes, project.ChangeAccessTokenRoleAssertion(*accessTokenRoleAssertion)) } - if wm.IDTokenRoleAssertion != idTokenRoleAssertion { - changes = append(changes, project.ChangeIDTokenRoleAssertion(idTokenRoleAssertion)) + if idTokenRoleAssertion != nil && wm.IDTokenRoleAssertion != *idTokenRoleAssertion { + changes = append(changes, project.ChangeIDTokenRoleAssertion(*idTokenRoleAssertion)) } - if wm.IDTokenUserinfoAssertion != idTokenUserinfoAssertion { - changes = append(changes, project.ChangeIDTokenUserinfoAssertion(idTokenUserinfoAssertion)) + if idTokenUserinfoAssertion != nil && wm.IDTokenUserinfoAssertion != *idTokenUserinfoAssertion { + changes = append(changes, project.ChangeIDTokenUserinfoAssertion(*idTokenUserinfoAssertion)) } - if wm.ClockSkew != clockSkew { - changes = append(changes, project.ChangeClockSkew(clockSkew)) + if clockSkew != nil && wm.ClockSkew != *clockSkew { + changes = append(changes, project.ChangeClockSkew(*clockSkew)) } - if !slices.Equal(wm.AdditionalOrigins, additionalOrigins) { + if additionalOrigins != nil && !slices.Equal(wm.AdditionalOrigins, additionalOrigins) { changes = append(changes, project.ChangeAdditionalOrigins(additionalOrigins)) } - if wm.SkipNativeAppSuccessPage != skipNativeAppSuccessPage { - changes = append(changes, project.ChangeSkipNativeAppSuccessPage(skipNativeAppSuccessPage)) + if skipNativeAppSuccessPage != nil && wm.SkipNativeAppSuccessPage != *skipNativeAppSuccessPage { + changes = append(changes, project.ChangeSkipNativeAppSuccessPage(*skipNativeAppSuccessPage)) } - if wm.BackChannelLogoutURI != backChannelLogoutURI { - changes = append(changes, project.ChangeBackChannelLogoutURI(backChannelLogoutURI)) + if backChannelLogoutURI != nil && wm.BackChannelLogoutURI != *backChannelLogoutURI { + changes = append(changes, project.ChangeBackChannelLogoutURI(*backChannelLogoutURI)) } - if wm.LoginVersion != loginVersion { - changes = append(changes, project.ChangeOIDCLoginVersion(loginVersion)) + if loginVersion != nil && wm.LoginVersion != *loginVersion { + changes = append(changes, project.ChangeOIDCLoginVersion(*loginVersion)) } - if wm.LoginBaseURI != loginBaseURI { - changes = append(changes, project.ChangeOIDCLoginBaseURI(loginBaseURI)) + if loginBaseURI != nil && wm.LoginBaseURI != *loginBaseURI { + changes = append(changes, project.ChangeOIDCLoginBaseURI(*loginBaseURI)) } if len(changes) == 0 { diff --git a/internal/command/project_application_oidc_test.go b/internal/command/project_application_oidc_test.go index d0383b1b29..d728ffca45 100644 --- a/internal/command/project_application_oidc_test.go +++ b/internal/command/project_application_oidc_test.go @@ -5,6 +5,7 @@ import ( "testing" "time" + "github.com/muhlemmer/gu" "github.com/stretchr/testify/assert" "github.com/zitadel/zitadel/internal/api/authz" @@ -401,6 +402,8 @@ func TestAddOIDCApp(t *testing.T) { } func TestCommandSide_AddOIDCApplication(t *testing.T) { + t.Parallel() + type fields struct { eventstore func(t *testing.T) *eventstore.Eventstore idGenerator id.Generator @@ -497,6 +500,7 @@ func TestCommandSide_AddOIDCApplication(t *testing.T) { domain.PrivateLabelingSettingUnspecified), ), ), + expectFilter(), expectPush( project.NewApplicationAddedEvent(context.Background(), &project.NewAggregate("project1", "org1").Aggregate, @@ -538,24 +542,24 @@ func TestCommandSide_AddOIDCApplication(t *testing.T) { AggregateID: "project1", }, AppName: "app", - AuthMethodType: domain.OIDCAuthMethodTypePost, - OIDCVersion: domain.OIDCVersionV1, + AuthMethodType: gu.Ptr(domain.OIDCAuthMethodTypePost), + OIDCVersion: gu.Ptr(domain.OIDCVersionV1), RedirectUris: []string{" https://test.ch "}, ResponseTypes: []domain.OIDCResponseType{domain.OIDCResponseTypeCode}, GrantTypes: []domain.OIDCGrantType{domain.OIDCGrantTypeAuthorizationCode}, - ApplicationType: domain.OIDCApplicationTypeWeb, + ApplicationType: gu.Ptr(domain.OIDCApplicationTypeWeb), PostLogoutRedirectUris: []string{" https://test.ch/logout "}, - DevMode: true, - AccessTokenType: domain.OIDCTokenTypeBearer, - AccessTokenRoleAssertion: true, - IDTokenRoleAssertion: true, - IDTokenUserinfoAssertion: true, - ClockSkew: time.Second * 1, + DevMode: gu.Ptr(true), + AccessTokenType: gu.Ptr(domain.OIDCTokenTypeBearer), + AccessTokenRoleAssertion: gu.Ptr(true), + IDTokenRoleAssertion: gu.Ptr(true), + IDTokenUserinfoAssertion: gu.Ptr(true), + ClockSkew: gu.Ptr(time.Second * 1), AdditionalOrigins: []string{" https://sub.test.ch "}, - SkipNativeAppSuccessPage: true, - BackChannelLogoutURI: " https://test.ch/backchannel ", - LoginVersion: domain.LoginVersion2, - LoginBaseURI: " https://login.test.ch ", + SkipNativeAppSuccessPage: gu.Ptr(true), + BackChannelLogoutURI: gu.Ptr(" https://test.ch/backchannel "), + LoginVersion: gu.Ptr(domain.LoginVersion2), + LoginBaseURI: gu.Ptr(" https://login.test.ch "), }, resourceOwner: "org1", }, @@ -569,24 +573,24 @@ func TestCommandSide_AddOIDCApplication(t *testing.T) { AppName: "app", ClientID: "client1", ClientSecretString: "secret", - AuthMethodType: domain.OIDCAuthMethodTypePost, - OIDCVersion: domain.OIDCVersionV1, + AuthMethodType: gu.Ptr(domain.OIDCAuthMethodTypePost), + OIDCVersion: gu.Ptr(domain.OIDCVersionV1), RedirectUris: []string{"https://test.ch"}, ResponseTypes: []domain.OIDCResponseType{domain.OIDCResponseTypeCode}, GrantTypes: []domain.OIDCGrantType{domain.OIDCGrantTypeAuthorizationCode}, - ApplicationType: domain.OIDCApplicationTypeWeb, + ApplicationType: gu.Ptr(domain.OIDCApplicationTypeWeb), PostLogoutRedirectUris: []string{"https://test.ch/logout"}, - DevMode: true, - AccessTokenType: domain.OIDCTokenTypeBearer, - AccessTokenRoleAssertion: true, - IDTokenRoleAssertion: true, - IDTokenUserinfoAssertion: true, - ClockSkew: time.Second * 1, + DevMode: gu.Ptr(true), + AccessTokenType: gu.Ptr(domain.OIDCTokenTypeBearer), + AccessTokenRoleAssertion: gu.Ptr(true), + IDTokenRoleAssertion: gu.Ptr(true), + IDTokenUserinfoAssertion: gu.Ptr(true), + ClockSkew: gu.Ptr(time.Second * 1), AdditionalOrigins: []string{"https://sub.test.ch"}, - SkipNativeAppSuccessPage: true, - BackChannelLogoutURI: "https://test.ch/backchannel", - LoginVersion: domain.LoginVersion2, - LoginBaseURI: "https://login.test.ch", + SkipNativeAppSuccessPage: gu.Ptr(true), + BackChannelLogoutURI: gu.Ptr("https://test.ch/backchannel"), + LoginVersion: gu.Ptr(domain.LoginVersion2), + LoginBaseURI: gu.Ptr("https://login.test.ch"), State: domain.AppStateActive, Compliance: &domain.Compliance{}, }, @@ -604,6 +608,7 @@ func TestCommandSide_AddOIDCApplication(t *testing.T) { domain.PrivateLabelingSettingUnspecified), ), ), + expectFilter(), expectPush( project.NewApplicationAddedEvent(context.Background(), &project.NewAggregate("project1", "org1").Aggregate, @@ -645,24 +650,24 @@ func TestCommandSide_AddOIDCApplication(t *testing.T) { AggregateID: "project1", }, AppName: "app", - AuthMethodType: domain.OIDCAuthMethodTypePost, - OIDCVersion: domain.OIDCVersionV1, + AuthMethodType: gu.Ptr(domain.OIDCAuthMethodTypePost), + OIDCVersion: gu.Ptr(domain.OIDCVersionV1), RedirectUris: []string{"https://test.ch"}, ResponseTypes: []domain.OIDCResponseType{domain.OIDCResponseTypeCode}, GrantTypes: []domain.OIDCGrantType{domain.OIDCGrantTypeAuthorizationCode}, - ApplicationType: domain.OIDCApplicationTypeWeb, + ApplicationType: gu.Ptr(domain.OIDCApplicationTypeWeb), PostLogoutRedirectUris: []string{"https://test.ch/logout"}, - DevMode: true, - AccessTokenType: domain.OIDCTokenTypeBearer, - AccessTokenRoleAssertion: true, - IDTokenRoleAssertion: true, - IDTokenUserinfoAssertion: true, - ClockSkew: time.Second * 1, + DevMode: gu.Ptr(true), + AccessTokenType: gu.Ptr(domain.OIDCTokenTypeBearer), + AccessTokenRoleAssertion: gu.Ptr(true), + IDTokenRoleAssertion: gu.Ptr(true), + IDTokenUserinfoAssertion: gu.Ptr(true), + ClockSkew: gu.Ptr(time.Second * 1), AdditionalOrigins: []string{"https://sub.test.ch"}, - SkipNativeAppSuccessPage: true, - BackChannelLogoutURI: "https://test.ch/backchannel", - LoginVersion: domain.LoginVersion2, - LoginBaseURI: "https://login.test.ch", + SkipNativeAppSuccessPage: gu.Ptr(true), + BackChannelLogoutURI: gu.Ptr("https://test.ch/backchannel"), + LoginVersion: gu.Ptr(domain.LoginVersion2), + LoginBaseURI: gu.Ptr("https://login.test.ch"), }, resourceOwner: "org1", }, @@ -676,24 +681,24 @@ func TestCommandSide_AddOIDCApplication(t *testing.T) { AppName: "app", ClientID: "client1", ClientSecretString: "secret", - AuthMethodType: domain.OIDCAuthMethodTypePost, - OIDCVersion: domain.OIDCVersionV1, + AuthMethodType: gu.Ptr(domain.OIDCAuthMethodTypePost), + OIDCVersion: gu.Ptr(domain.OIDCVersionV1), RedirectUris: []string{"https://test.ch"}, ResponseTypes: []domain.OIDCResponseType{domain.OIDCResponseTypeCode}, GrantTypes: []domain.OIDCGrantType{domain.OIDCGrantTypeAuthorizationCode}, - ApplicationType: domain.OIDCApplicationTypeWeb, + ApplicationType: gu.Ptr(domain.OIDCApplicationTypeWeb), PostLogoutRedirectUris: []string{"https://test.ch/logout"}, - DevMode: true, - AccessTokenType: domain.OIDCTokenTypeBearer, - AccessTokenRoleAssertion: true, - IDTokenRoleAssertion: true, - IDTokenUserinfoAssertion: true, - ClockSkew: time.Second * 1, + DevMode: gu.Ptr(true), + AccessTokenType: gu.Ptr(domain.OIDCTokenTypeBearer), + AccessTokenRoleAssertion: gu.Ptr(true), + IDTokenRoleAssertion: gu.Ptr(true), + IDTokenUserinfoAssertion: gu.Ptr(true), + ClockSkew: gu.Ptr(time.Second * 1), AdditionalOrigins: []string{"https://sub.test.ch"}, - SkipNativeAppSuccessPage: true, - BackChannelLogoutURI: "https://test.ch/backchannel", - LoginVersion: domain.LoginVersion2, - LoginBaseURI: "https://login.test.ch", + SkipNativeAppSuccessPage: gu.Ptr(true), + BackChannelLogoutURI: gu.Ptr("https://test.ch/backchannel"), + LoginVersion: gu.Ptr(domain.LoginVersion2), + LoginBaseURI: gu.Ptr("https://login.test.ch"), State: domain.AppStateActive, Compliance: &domain.Compliance{}, }, @@ -702,6 +707,7 @@ func TestCommandSide_AddOIDCApplication(t *testing.T) { } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { + t.Parallel() c := &Commands{ eventstore: tt.fields.eventstore(t), idGenerator: tt.fields.idGenerator, @@ -709,6 +715,7 @@ func TestCommandSide_AddOIDCApplication(t *testing.T) { defaultSecretGenerators: &SecretGenerators{ ClientSecret: emptyConfig, }, + checkPermission: newMockPermissionCheckAllowed(), } c.setMilestonesCompletedForTest("instanceID") got, err := c.AddOIDCApplication(tt.args.ctx, tt.args.oidcApp, tt.args.resourceOwner) @@ -726,6 +733,7 @@ func TestCommandSide_AddOIDCApplication(t *testing.T) { } func TestCommandSide_ChangeOIDCApplication(t *testing.T) { + t.Parallel() type fields struct { eventstore func(*testing.T) *eventstore.Eventstore } @@ -775,7 +783,7 @@ func TestCommandSide_ChangeOIDCApplication(t *testing.T) { AggregateID: "project1", }, AppID: "", - AuthMethodType: domain.OIDCAuthMethodTypePost, + AuthMethodType: gu.Ptr(domain.OIDCAuthMethodTypePost), GrantTypes: []domain.OIDCGrantType{domain.OIDCGrantTypeAuthorizationCode}, ResponseTypes: []domain.OIDCResponseType{domain.OIDCResponseTypeCode}, }, @@ -797,7 +805,7 @@ func TestCommandSide_ChangeOIDCApplication(t *testing.T) { AggregateID: "", }, AppID: "appid", - AuthMethodType: domain.OIDCAuthMethodTypePost, + AuthMethodType: gu.Ptr(domain.OIDCAuthMethodTypePost), GrantTypes: []domain.OIDCGrantType{domain.OIDCGrantTypeAuthorizationCode}, ResponseTypes: []domain.OIDCResponseType{domain.OIDCResponseTypeCode}, }, @@ -821,7 +829,7 @@ func TestCommandSide_ChangeOIDCApplication(t *testing.T) { AggregateID: "project1", }, AppID: "app1", - AuthMethodType: domain.OIDCAuthMethodTypePost, + AuthMethodType: gu.Ptr(domain.OIDCAuthMethodTypePost), GrantTypes: []domain.OIDCGrantType{domain.OIDCGrantTypeAuthorizationCode}, ResponseTypes: []domain.OIDCResponseType{domain.OIDCResponseTypeCode}, }, @@ -870,6 +878,7 @@ func TestCommandSide_ChangeOIDCApplication(t *testing.T) { ), ), ), + expectFilter(), ), }, args: args{ @@ -880,24 +889,24 @@ func TestCommandSide_ChangeOIDCApplication(t *testing.T) { }, AppID: "app1", AppName: "app", - AuthMethodType: domain.OIDCAuthMethodTypePost, - OIDCVersion: domain.OIDCVersionV1, + AuthMethodType: gu.Ptr(domain.OIDCAuthMethodTypePost), + OIDCVersion: gu.Ptr(domain.OIDCVersionV1), RedirectUris: []string{"https://test.ch"}, ResponseTypes: []domain.OIDCResponseType{domain.OIDCResponseTypeCode}, GrantTypes: []domain.OIDCGrantType{domain.OIDCGrantTypeAuthorizationCode}, - ApplicationType: domain.OIDCApplicationTypeWeb, + ApplicationType: gu.Ptr(domain.OIDCApplicationTypeWeb), PostLogoutRedirectUris: []string{"https://test.ch/logout"}, - DevMode: true, - AccessTokenType: domain.OIDCTokenTypeBearer, - AccessTokenRoleAssertion: true, - IDTokenRoleAssertion: true, - IDTokenUserinfoAssertion: true, - ClockSkew: time.Second * 1, + DevMode: gu.Ptr(true), + AccessTokenType: gu.Ptr(domain.OIDCTokenTypeBearer), + AccessTokenRoleAssertion: gu.Ptr(true), + IDTokenRoleAssertion: gu.Ptr(true), + IDTokenUserinfoAssertion: gu.Ptr(true), + ClockSkew: gu.Ptr(time.Second * 1), AdditionalOrigins: []string{"https://sub.test.ch"}, - SkipNativeAppSuccessPage: true, - BackChannelLogoutURI: "https://test.ch/backchannel", - LoginVersion: domain.LoginVersion2, - LoginBaseURI: "https://login.test.ch", + SkipNativeAppSuccessPage: gu.Ptr(true), + BackChannelLogoutURI: gu.Ptr("https://test.ch/backchannel"), + LoginVersion: gu.Ptr(domain.LoginVersion2), + LoginBaseURI: gu.Ptr("https://login.test.ch"), }, resourceOwner: "org1", }, @@ -944,6 +953,7 @@ func TestCommandSide_ChangeOIDCApplication(t *testing.T) { ), ), ), + expectFilter(), ), }, args: args{ @@ -954,24 +964,24 @@ func TestCommandSide_ChangeOIDCApplication(t *testing.T) { }, AppID: "app1", AppName: "app", - AuthMethodType: domain.OIDCAuthMethodTypePost, - OIDCVersion: domain.OIDCVersionV1, + AuthMethodType: gu.Ptr(domain.OIDCAuthMethodTypePost), + OIDCVersion: gu.Ptr(domain.OIDCVersionV1), RedirectUris: []string{"https://test.ch "}, ResponseTypes: []domain.OIDCResponseType{domain.OIDCResponseTypeCode}, GrantTypes: []domain.OIDCGrantType{domain.OIDCGrantTypeAuthorizationCode}, - ApplicationType: domain.OIDCApplicationTypeWeb, + ApplicationType: gu.Ptr(domain.OIDCApplicationTypeWeb), PostLogoutRedirectUris: []string{" https://test.ch/logout"}, - DevMode: true, - AccessTokenType: domain.OIDCTokenTypeBearer, - AccessTokenRoleAssertion: true, - IDTokenRoleAssertion: true, - IDTokenUserinfoAssertion: true, - ClockSkew: time.Second * 1, + DevMode: gu.Ptr(true), + AccessTokenType: gu.Ptr(domain.OIDCTokenTypeBearer), + AccessTokenRoleAssertion: gu.Ptr(true), + IDTokenRoleAssertion: gu.Ptr(true), + IDTokenUserinfoAssertion: gu.Ptr(true), + ClockSkew: gu.Ptr(time.Second * 1), AdditionalOrigins: []string{" https://sub.test.ch "}, - SkipNativeAppSuccessPage: true, - BackChannelLogoutURI: " https://test.ch/backchannel ", - LoginVersion: domain.LoginVersion2, - LoginBaseURI: " https://login.test.ch ", + SkipNativeAppSuccessPage: gu.Ptr(true), + BackChannelLogoutURI: gu.Ptr(" https://test.ch/backchannel "), + LoginVersion: gu.Ptr(domain.LoginVersion2), + LoginBaseURI: gu.Ptr(" https://login.test.ch "), }, resourceOwner: "org1", }, @@ -980,7 +990,7 @@ func TestCommandSide_ChangeOIDCApplication(t *testing.T) { }, }, { - name: "change oidc app, ok", + name: "partial change oidc app, ok", fields: fields{ eventstore: expectEventstore( expectFilter( @@ -1018,6 +1028,7 @@ func TestCommandSide_ChangeOIDCApplication(t *testing.T) { ), ), ), + expectFilter(), expectPush( newOIDCAppChangedEvent(context.Background(), "app1", @@ -1032,26 +1043,11 @@ func TestCommandSide_ChangeOIDCApplication(t *testing.T) { ObjectRoot: models.ObjectRoot{ AggregateID: "project1", }, - AppID: "app1", - AppName: "app", - AuthMethodType: domain.OIDCAuthMethodTypePost, - OIDCVersion: domain.OIDCVersionV1, - RedirectUris: []string{" https://test-change.ch "}, - ResponseTypes: []domain.OIDCResponseType{domain.OIDCResponseTypeCode}, - GrantTypes: []domain.OIDCGrantType{domain.OIDCGrantTypeAuthorizationCode}, - ApplicationType: domain.OIDCApplicationTypeWeb, - PostLogoutRedirectUris: []string{" https://test-change.ch/logout "}, - DevMode: true, - AccessTokenType: domain.OIDCTokenTypeJWT, - AccessTokenRoleAssertion: false, - IDTokenRoleAssertion: false, - IDTokenUserinfoAssertion: false, - ClockSkew: time.Second * 2, - AdditionalOrigins: []string{"https://sub.test.ch"}, - SkipNativeAppSuccessPage: true, - BackChannelLogoutURI: "https://test.ch/backchannel", - LoginVersion: domain.LoginVersion2, - LoginBaseURI: "https://login.test.ch", + AppID: "app1", + AppName: "app", + AuthMethodType: gu.Ptr(domain.OIDCAuthMethodTypeBasic), + GrantTypes: []domain.OIDCGrantType{domain.OIDCGrantTypeAuthorizationCode}, + ResponseTypes: []domain.OIDCResponseType{domain.OIDCResponseTypeCode}, }, resourceOwner: "org1", }, @@ -1064,24 +1060,24 @@ func TestCommandSide_ChangeOIDCApplication(t *testing.T) { AppID: "app1", ClientID: "client1@project", AppName: "app", - AuthMethodType: domain.OIDCAuthMethodTypePost, - OIDCVersion: domain.OIDCVersionV1, - RedirectUris: []string{"https://test-change.ch"}, + AuthMethodType: gu.Ptr(domain.OIDCAuthMethodTypeBasic), + OIDCVersion: gu.Ptr(domain.OIDCVersionV1), + RedirectUris: []string{"https://test.ch"}, ResponseTypes: []domain.OIDCResponseType{domain.OIDCResponseTypeCode}, GrantTypes: []domain.OIDCGrantType{domain.OIDCGrantTypeAuthorizationCode}, - ApplicationType: domain.OIDCApplicationTypeWeb, - PostLogoutRedirectUris: []string{"https://test-change.ch/logout"}, - DevMode: true, - AccessTokenType: domain.OIDCTokenTypeJWT, - AccessTokenRoleAssertion: false, - IDTokenRoleAssertion: false, - IDTokenUserinfoAssertion: false, - ClockSkew: time.Second * 2, + ApplicationType: gu.Ptr(domain.OIDCApplicationTypeWeb), + PostLogoutRedirectUris: []string{"https://test.ch/logout"}, + DevMode: gu.Ptr(false), + AccessTokenType: gu.Ptr(domain.OIDCTokenTypeBearer), + AccessTokenRoleAssertion: gu.Ptr(true), + IDTokenRoleAssertion: gu.Ptr(true), + IDTokenUserinfoAssertion: gu.Ptr(true), + ClockSkew: gu.Ptr(time.Second * 1), AdditionalOrigins: []string{"https://sub.test.ch"}, - SkipNativeAppSuccessPage: true, - BackChannelLogoutURI: "https://test.ch/backchannel", - LoginVersion: domain.LoginVersion2, - LoginBaseURI: "https://login.test.ch", + SkipNativeAppSuccessPage: gu.Ptr(true), + BackChannelLogoutURI: gu.Ptr("https://test.ch/backchannel"), + LoginVersion: gu.Ptr(domain.LoginVersion1), + LoginBaseURI: gu.Ptr(""), Compliance: &domain.Compliance{}, State: domain.AppStateActive, }, @@ -1090,10 +1086,12 @@ func TestCommandSide_ChangeOIDCApplication(t *testing.T) { } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { + // t.Parallel() r := &Commands{ - eventstore: tt.fields.eventstore(t), + eventstore: tt.fields.eventstore(t), + checkPermission: newMockPermissionCheckAllowed(), } - got, err := r.ChangeOIDCApplication(tt.args.ctx, tt.args.oidcApp, tt.args.resourceOwner) + got, err := r.UpdateOIDCApplication(tt.args.ctx, tt.args.oidcApp, tt.args.resourceOwner) if tt.res.err == nil { assert.NoError(t, err) } @@ -1108,6 +1106,8 @@ func TestCommandSide_ChangeOIDCApplication(t *testing.T) { } func TestCommandSide_ChangeOIDCApplicationSecret(t *testing.T) { + t.Parallel() + type fields struct { eventstore func(*testing.T) *eventstore.Eventstore } @@ -1237,36 +1237,40 @@ func TestCommandSide_ChangeOIDCApplicationSecret(t *testing.T) { AppName: "app", ClientID: "client1@project", ClientSecretString: "secret", - AuthMethodType: domain.OIDCAuthMethodTypePost, - OIDCVersion: domain.OIDCVersionV1, + AuthMethodType: gu.Ptr(domain.OIDCAuthMethodTypePost), + OIDCVersion: gu.Ptr(domain.OIDCVersionV1), RedirectUris: []string{"https://test.ch"}, ResponseTypes: []domain.OIDCResponseType{domain.OIDCResponseTypeCode}, GrantTypes: []domain.OIDCGrantType{domain.OIDCGrantTypeAuthorizationCode}, - ApplicationType: domain.OIDCApplicationTypeWeb, + ApplicationType: gu.Ptr(domain.OIDCApplicationTypeWeb), PostLogoutRedirectUris: []string{"https://test.ch/logout"}, - DevMode: true, - AccessTokenType: domain.OIDCTokenTypeBearer, - AccessTokenRoleAssertion: true, - IDTokenRoleAssertion: true, - IDTokenUserinfoAssertion: true, - ClockSkew: time.Second * 1, + DevMode: gu.Ptr(true), + AccessTokenType: gu.Ptr(domain.OIDCTokenTypeBearer), + AccessTokenRoleAssertion: gu.Ptr(true), + IDTokenRoleAssertion: gu.Ptr(true), + IDTokenUserinfoAssertion: gu.Ptr(true), + ClockSkew: gu.Ptr(time.Second * 1), AdditionalOrigins: []string{"https://sub.test.ch"}, - SkipNativeAppSuccessPage: false, - BackChannelLogoutURI: "", - LoginVersion: domain.LoginVersionUnspecified, + SkipNativeAppSuccessPage: gu.Ptr(false), + BackChannelLogoutURI: gu.Ptr(""), + LoginVersion: gu.Ptr(domain.LoginVersionUnspecified), + LoginBaseURI: gu.Ptr(""), State: domain.AppStateActive, }, }, }, } for _, tt := range tests { - t.Run(tt.name, func(*testing.T) { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + r := &Commands{ eventstore: tt.fields.eventstore(t), newHashedSecret: mockHashedSecret("secret"), defaultSecretGenerators: &SecretGenerators{ ClientSecret: emptyConfig, }, + checkPermission: newMockPermissionCheckAllowed(), } got, err := r.ChangeOIDCApplicationSecret(tt.args.ctx, tt.args.projectID, tt.args.appID, tt.args.resourceOwner) if tt.res.err == nil { @@ -1284,16 +1288,7 @@ func TestCommandSide_ChangeOIDCApplicationSecret(t *testing.T) { func newOIDCAppChangedEvent(ctx context.Context, appID, projectID, resourceOwner string) *project.OIDCConfigChangedEvent { changes := []project.OIDCConfigChanges{ - project.ChangeRedirectURIs([]string{"https://test-change.ch"}), - project.ChangePostLogoutRedirectURIs([]string{"https://test-change.ch/logout"}), - project.ChangeDevMode(true), - project.ChangeAccessTokenType(domain.OIDCTokenTypeJWT), - project.ChangeAccessTokenRoleAssertion(false), - project.ChangeIDTokenRoleAssertion(false), - project.ChangeIDTokenUserinfoAssertion(false), - project.ChangeClockSkew(time.Second * 2), - project.ChangeOIDCLoginVersion(domain.LoginVersion2), - project.ChangeOIDCLoginBaseURI("https://login.test.ch"), + project.ChangeAuthMethodType(domain.OIDCAuthMethodTypeBasic), } event, _ := project.NewOIDCConfigChangedEvent(ctx, &project.NewAggregate(projectID, resourceOwner).Aggregate, diff --git a/internal/command/project_application_saml.go b/internal/command/project_application_saml.go index 1a5cefa221..9b1dc9e97a 100644 --- a/internal/command/project_application_saml.go +++ b/internal/command/project_application_saml.go @@ -3,6 +3,7 @@ package command import ( "context" + "github.com/muhlemmer/gu" "github.com/zitadel/saml/pkg/provider/xml" "github.com/zitadel/zitadel/internal/domain" @@ -16,10 +17,22 @@ func (c *Commands) AddSAMLApplication(ctx context.Context, application *domain.S return nil, zerrors.ThrowInvalidArgument(nil, "PROJECT-35Fn0", "Errors.Project.App.Invalid") } - if _, err := c.checkProjectExists(ctx, application.AggregateID, resourceOwner); err != nil { + projectResOwner, err := c.checkProjectExists(ctx, application.AggregateID, resourceOwner) + if err != nil { return nil, err } + if resourceOwner == "" { + resourceOwner = projectResOwner + } + addedApplication := NewSAMLApplicationWriteModel(application.AggregateID, resourceOwner) + if err := c.eventstore.FilterToQueryReducer(ctx, addedApplication); err != nil { + return nil, err + } + if err := c.checkPermissionUpdateApplication(ctx, addedApplication.ResourceOwner, addedApplication.AggregateID); err != nil { + return nil, err + } + projectAgg := ProjectAggregateFromWriteModel(&addedApplication.WriteModel) events, err := c.addSAMLApplication(ctx, projectAgg, application) if err != nil { @@ -49,12 +62,8 @@ func (c *Commands) addSAMLApplication(ctx context.Context, projectAgg *eventstor return nil, zerrors.ThrowInvalidArgument(nil, "PROJECT-1n9df", "Errors.Project.App.Invalid") } - if samlApp.Metadata == nil && samlApp.MetadataURL == "" { - return nil, zerrors.ThrowInvalidArgument(nil, "SAML-podix9", "Errors.Project.App.SAMLMetadataMissing") - } - - if samlApp.MetadataURL != "" { - data, err := xml.ReadMetadataFromURL(c.httpClient, samlApp.MetadataURL) + if samlApp.MetadataURL != nil && *samlApp.MetadataURL != "" { + data, err := xml.ReadMetadataFromURL(c.httpClient, *samlApp.MetadataURL) if err != nil { return nil, zerrors.ThrowInvalidArgument(err, "SAML-wmqlo1", "Errors.Project.App.SAMLMetadataMissing") } @@ -78,14 +87,14 @@ func (c *Commands) addSAMLApplication(ctx context.Context, projectAgg *eventstor samlApp.AppID, string(entity.EntityID), samlApp.Metadata, - samlApp.MetadataURL, - samlApp.LoginVersion, - samlApp.LoginBaseURI, + gu.Value(samlApp.MetadataURL), + gu.Value(samlApp.LoginVersion), + gu.Value(samlApp.LoginBaseURI), ), }, nil } -func (c *Commands) ChangeSAMLApplication(ctx context.Context, samlApp *domain.SAMLApp, resourceOwner string) (*domain.SAMLApp, error) { +func (c *Commands) UpdateSAMLApplication(ctx context.Context, samlApp *domain.SAMLApp, resourceOwner string) (*domain.SAMLApp, error) { if !samlApp.IsValid() || samlApp.AppID == "" || samlApp.AggregateID == "" { return nil, zerrors.ThrowInvalidArgument(nil, "COMMAND-5n9fs", "Errors.Project.App.SAMLConfigInvalid") } @@ -100,10 +109,15 @@ func (c *Commands) ChangeSAMLApplication(ctx context.Context, samlApp *domain.SA if !existingSAML.IsSAML() { return nil, zerrors.ThrowInvalidArgument(nil, "COMMAND-GBr35", "Errors.Project.App.IsNotSAML") } + + if err := c.checkPermissionUpdateApplication(ctx, existingSAML.ResourceOwner, existingSAML.AggregateID); err != nil { + return nil, err + } + projectAgg := ProjectAggregateFromWriteModel(&existingSAML.WriteModel) - if samlApp.MetadataURL != "" { - data, err := xml.ReadMetadataFromURL(c.httpClient, samlApp.MetadataURL) + if samlApp.MetadataURL != nil && *samlApp.MetadataURL != "" { + data, err := xml.ReadMetadataFromURL(c.httpClient, *samlApp.MetadataURL) if err != nil { return nil, zerrors.ThrowInvalidArgument(err, "SAML-J3kg3", "Errors.Project.App.SAMLMetadataMissing") } diff --git a/internal/command/project_application_saml_model.go b/internal/command/project_application_saml_model.go index f219039b58..f3097914f3 100644 --- a/internal/command/project_application_saml_model.go +++ b/internal/command/project_application_saml_model.go @@ -2,7 +2,7 @@ package command import ( "context" - "reflect" + "slices" "github.com/zitadel/zitadel/internal/domain" "github.com/zitadel/zitadel/internal/eventstore" @@ -170,26 +170,26 @@ func (wm *SAMLApplicationWriteModel) NewChangedEvent( appID string, entityID string, metadata []byte, - metadataURL string, - loginVersion domain.LoginVersion, - loginBaseURI string, + metadataURL *string, + loginVersion *domain.LoginVersion, + loginBaseURI *string, ) (*project.SAMLConfigChangedEvent, bool, error) { changes := make([]project.SAMLConfigChanges, 0) var err error - if !reflect.DeepEqual(wm.Metadata, metadata) { + if metadata != nil && !slices.Equal(wm.Metadata, metadata) { changes = append(changes, project.ChangeMetadata(metadata)) } - if wm.MetadataURL != metadataURL { - changes = append(changes, project.ChangeMetadataURL(metadataURL)) + if metadataURL != nil && wm.MetadataURL != *metadataURL { + changes = append(changes, project.ChangeMetadataURL(*metadataURL)) } if wm.EntityID != entityID { changes = append(changes, project.ChangeEntityID(entityID)) } - if wm.LoginVersion != loginVersion { - changes = append(changes, project.ChangeSAMLLoginVersion(loginVersion)) + if loginVersion != nil && wm.LoginVersion != *loginVersion { + changes = append(changes, project.ChangeSAMLLoginVersion(*loginVersion)) } - if wm.LoginBaseURI != loginBaseURI { - changes = append(changes, project.ChangeSAMLLoginBaseURI(loginBaseURI)) + if loginBaseURI != nil && wm.LoginBaseURI != *loginBaseURI { + changes = append(changes, project.ChangeSAMLLoginBaseURI(*loginBaseURI)) } if len(changes) == 0 { diff --git a/internal/command/project_application_saml_test.go b/internal/command/project_application_saml_test.go index c6f6f7cf21..5d18d9587c 100644 --- a/internal/command/project_application_saml_test.go +++ b/internal/command/project_application_saml_test.go @@ -7,6 +7,7 @@ import ( "net/http" "testing" + "github.com/muhlemmer/gu" "github.com/stretchr/testify/assert" "github.com/zitadel/zitadel/internal/api/authz" @@ -49,6 +50,8 @@ var testMetadataChangedEntityID = []byte(` `) func TestCommandSide_AddSAMLApplication(t *testing.T) { + t.Parallel() + type fields struct { eventstore func(t *testing.T) *eventstore.Eventstore idGenerator id.Generator @@ -117,6 +120,7 @@ func TestCommandSide_AddSAMLApplication(t *testing.T) { domain.PrivateLabelingSettingUnspecified), ), ), + expectFilter(), ), }, args: args{ @@ -134,6 +138,37 @@ func TestCommandSide_AddSAMLApplication(t *testing.T) { err: zerrors.IsErrorInvalidArgument, }, }, + { + name: "empty metas, invalid argument error", + fields: fields{ + eventstore: expectEventstore( + expectFilter( + eventFromEventPusher( + project.NewProjectAddedEvent(context.Background(), + &project.NewAggregate("project1", "org1").Aggregate, + "project", true, true, true, + domain.PrivateLabelingSettingUnspecified), + ), + ), + expectFilter(), + ), + idGenerator: id_mock.NewIDGeneratorExpectIDs(t), + }, + args: args{ + ctx: authz.WithInstanceID(context.Background(), "instanceID"), + samlApp: &domain.SAMLApp{ + ObjectRoot: models.ObjectRoot{ + AggregateID: "project1", + }, + AppName: "app", + EntityID: "https://test.com/saml/metadata", + }, + resourceOwner: "org1", + }, + res: res{ + err: zerrors.IsErrorInvalidArgument, + }, + }, { name: "create saml app, metadata not parsable", fields: fields{ @@ -146,6 +181,7 @@ func TestCommandSide_AddSAMLApplication(t *testing.T) { domain.PrivateLabelingSettingUnspecified), ), ), + expectFilter(), ), idGenerator: id_mock.NewIDGeneratorExpectIDs(t), }, @@ -158,7 +194,7 @@ func TestCommandSide_AddSAMLApplication(t *testing.T) { AppName: "app", EntityID: "https://test.com/saml/metadata", Metadata: []byte("test metadata"), - MetadataURL: "", + MetadataURL: gu.Ptr(""), }, resourceOwner: "org1", }, @@ -178,6 +214,7 @@ func TestCommandSide_AddSAMLApplication(t *testing.T) { domain.PrivateLabelingSettingUnspecified), ), ), + expectFilter(), expectPush( project.NewApplicationAddedEvent(context.Background(), &project.NewAggregate("project1", "org1").Aggregate, @@ -206,7 +243,7 @@ func TestCommandSide_AddSAMLApplication(t *testing.T) { AppName: "app", EntityID: "https://test.com/saml/metadata", Metadata: testMetadata, - MetadataURL: "", + MetadataURL: gu.Ptr(""), }, resourceOwner: "org1", }, @@ -216,12 +253,14 @@ func TestCommandSide_AddSAMLApplication(t *testing.T) { AggregateID: "project1", ResourceOwner: "org1", }, - AppID: "app1", - AppName: "app", - EntityID: "https://test.com/saml/metadata", - Metadata: testMetadata, - MetadataURL: "", - State: domain.AppStateActive, + AppID: "app1", + AppName: "app", + EntityID: "https://test.com/saml/metadata", + Metadata: testMetadata, + MetadataURL: gu.Ptr(""), + State: domain.AppStateActive, + LoginVersion: gu.Ptr(domain.LoginVersionUnspecified), + LoginBaseURI: gu.Ptr(""), }, }, }, @@ -237,6 +276,7 @@ func TestCommandSide_AddSAMLApplication(t *testing.T) { domain.PrivateLabelingSettingUnspecified), ), ), + expectFilter(), expectPush( project.NewApplicationAddedEvent(context.Background(), &project.NewAggregate("project1", "org1").Aggregate, @@ -265,9 +305,9 @@ func TestCommandSide_AddSAMLApplication(t *testing.T) { AppName: "app", EntityID: "https://test.com/saml/metadata", Metadata: testMetadata, - MetadataURL: "", - LoginVersion: domain.LoginVersion2, - LoginBaseURI: "https://test.com/login", + MetadataURL: gu.Ptr(""), + LoginVersion: gu.Ptr(domain.LoginVersion2), + LoginBaseURI: gu.Ptr("https://test.com/login"), }, resourceOwner: "org1", }, @@ -281,10 +321,10 @@ func TestCommandSide_AddSAMLApplication(t *testing.T) { AppName: "app", EntityID: "https://test.com/saml/metadata", Metadata: testMetadata, - MetadataURL: "", + MetadataURL: gu.Ptr(""), State: domain.AppStateActive, - LoginVersion: domain.LoginVersion2, - LoginBaseURI: "https://test.com/login", + LoginVersion: gu.Ptr(domain.LoginVersion2), + LoginBaseURI: gu.Ptr("https://test.com/login"), }, }, }, @@ -300,6 +340,7 @@ func TestCommandSide_AddSAMLApplication(t *testing.T) { domain.PrivateLabelingSettingUnspecified), ), ), + expectFilter(), expectPush( project.NewApplicationAddedEvent(context.Background(), &project.NewAggregate("project1", "org1").Aggregate, @@ -329,7 +370,7 @@ func TestCommandSide_AddSAMLApplication(t *testing.T) { AppName: "app", EntityID: "https://test.com/saml/metadata", Metadata: nil, - MetadataURL: "http://localhost:8080/saml/metadata", + MetadataURL: gu.Ptr("http://localhost:8080/saml/metadata"), }, resourceOwner: "org1", }, @@ -339,12 +380,14 @@ func TestCommandSide_AddSAMLApplication(t *testing.T) { AggregateID: "project1", ResourceOwner: "org1", }, - AppID: "app1", - AppName: "app", - EntityID: "https://test.com/saml/metadata", - Metadata: testMetadata, - MetadataURL: "http://localhost:8080/saml/metadata", - State: domain.AppStateActive, + AppID: "app1", + AppName: "app", + EntityID: "https://test.com/saml/metadata", + Metadata: testMetadata, + MetadataURL: gu.Ptr("http://localhost:8080/saml/metadata"), + State: domain.AppStateActive, + LoginVersion: gu.Ptr(domain.LoginVersionUnspecified), + LoginBaseURI: gu.Ptr(""), }, }, }, @@ -360,6 +403,7 @@ func TestCommandSide_AddSAMLApplication(t *testing.T) { domain.PrivateLabelingSettingUnspecified), ), ), + expectFilter(), ), idGenerator: id_mock.NewIDGeneratorExpectIDs(t), httpClient: newTestClient(http.StatusNotFound, nil), @@ -373,7 +417,7 @@ func TestCommandSide_AddSAMLApplication(t *testing.T) { AppName: "app", EntityID: "https://test.com/saml/metadata", Metadata: nil, - MetadataURL: "http://localhost:8080/saml/metadata", + MetadataURL: gu.Ptr("http://localhost:8080/saml/metadata"), }, resourceOwner: "org1", }, @@ -385,10 +429,13 @@ func TestCommandSide_AddSAMLApplication(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { + t.Parallel() + c := &Commands{ - eventstore: tt.fields.eventstore(t), - idGenerator: tt.fields.idGenerator, - httpClient: tt.fields.httpClient, + eventstore: tt.fields.eventstore(t), + idGenerator: tt.fields.idGenerator, + httpClient: tt.fields.httpClient, + checkPermission: newMockPermissionCheckAllowed(), } c.setMilestonesCompletedForTest("instanceID") got, err := c.AddSAMLApplication(tt.args.ctx, tt.args.samlApp, tt.args.resourceOwner) @@ -406,6 +453,8 @@ func TestCommandSide_AddSAMLApplication(t *testing.T) { } func TestCommandSide_ChangeSAMLApplication(t *testing.T) { + t.Parallel() + type fields struct { eventstore func(t *testing.T) *eventstore.Eventstore httpClient *http.Client @@ -544,7 +593,7 @@ func TestCommandSide_ChangeSAMLApplication(t *testing.T) { AppID: "app1", EntityID: "https://test.com/saml/metadata", Metadata: nil, - MetadataURL: "http://localhost:8080/saml/metadata", + MetadataURL: gu.Ptr("http://localhost:8080/saml/metadata"), }, resourceOwner: "org1", }, @@ -590,7 +639,7 @@ func TestCommandSide_ChangeSAMLApplication(t *testing.T) { AppID: "app1", EntityID: "https://test.com/saml/metadata", Metadata: testMetadata, - MetadataURL: "", + MetadataURL: gu.Ptr(""), }, resourceOwner: "org1", }, @@ -646,7 +695,7 @@ func TestCommandSide_ChangeSAMLApplication(t *testing.T) { AppName: "app", EntityID: "https://test2.com/saml/metadata", Metadata: nil, - MetadataURL: "http://localhost:8080/saml/metadata", + MetadataURL: gu.Ptr("http://localhost:8080/saml/metadata"), }, resourceOwner: "org1", }, @@ -656,17 +705,19 @@ func TestCommandSide_ChangeSAMLApplication(t *testing.T) { AggregateID: "project1", ResourceOwner: "org1", }, - AppID: "app1", - AppName: "app", - EntityID: "https://test2.com/saml/metadata", - Metadata: testMetadataChangedEntityID, - MetadataURL: "http://localhost:8080/saml/metadata", - State: domain.AppStateActive, + AppID: "app1", + AppName: "app", + EntityID: "https://test2.com/saml/metadata", + Metadata: testMetadataChangedEntityID, + MetadataURL: gu.Ptr("http://localhost:8080/saml/metadata"), + State: domain.AppStateActive, + LoginVersion: gu.Ptr(domain.LoginVersionUnspecified), + LoginBaseURI: gu.Ptr(""), }, }, }, { - name: "change saml app, ok, metadata", + name: "partial change saml app, ok, metadata", fields: fields{ eventstore: expectEventstore( expectFilter( @@ -713,7 +764,7 @@ func TestCommandSide_ChangeSAMLApplication(t *testing.T) { AppName: "app", EntityID: "https://test2.com/saml/metadata", Metadata: testMetadataChangedEntityID, - MetadataURL: "", + MetadataURL: gu.Ptr(""), }, resourceOwner: "org1", }, @@ -723,15 +774,18 @@ func TestCommandSide_ChangeSAMLApplication(t *testing.T) { AggregateID: "project1", ResourceOwner: "org1", }, - AppID: "app1", - AppName: "app", - EntityID: "https://test2.com/saml/metadata", - Metadata: testMetadataChangedEntityID, - MetadataURL: "", - State: domain.AppStateActive, + AppID: "app1", + AppName: "app", + EntityID: "https://test2.com/saml/metadata", + Metadata: testMetadataChangedEntityID, + MetadataURL: gu.Ptr(""), + State: domain.AppStateActive, + LoginVersion: gu.Ptr(domain.LoginVersionUnspecified), + LoginBaseURI: gu.Ptr(""), }, }, - }, { + }, + { name: "change saml app, ok, loginversion", fields: fields{ eventstore: expectEventstore( @@ -781,9 +835,9 @@ func TestCommandSide_ChangeSAMLApplication(t *testing.T) { AppName: "app", EntityID: "https://test2.com/saml/metadata", Metadata: testMetadataChangedEntityID, - MetadataURL: "", - LoginVersion: domain.LoginVersion2, - LoginBaseURI: "https://test.com/login", + MetadataURL: gu.Ptr(""), + LoginVersion: gu.Ptr(domain.LoginVersion2), + LoginBaseURI: gu.Ptr("https://test.com/login"), }, resourceOwner: "org1", }, @@ -797,10 +851,10 @@ func TestCommandSide_ChangeSAMLApplication(t *testing.T) { AppName: "app", EntityID: "https://test2.com/saml/metadata", Metadata: testMetadataChangedEntityID, - MetadataURL: "", + MetadataURL: gu.Ptr(""), State: domain.AppStateActive, - LoginVersion: domain.LoginVersion2, - LoginBaseURI: "https://test.com/login", + LoginVersion: gu.Ptr(domain.LoginVersion2), + LoginBaseURI: gu.Ptr("https://test.com/login"), }, }, }, @@ -808,11 +862,14 @@ func TestCommandSide_ChangeSAMLApplication(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { + t.Parallel() + r := &Commands{ - eventstore: tt.fields.eventstore(t), - httpClient: tt.fields.httpClient, + eventstore: tt.fields.eventstore(t), + httpClient: tt.fields.httpClient, + checkPermission: newMockPermissionCheckAllowed(), } - got, err := r.ChangeSAMLApplication(tt.args.ctx, tt.args.samlApp, tt.args.resourceOwner) + got, err := r.UpdateSAMLApplication(tt.args.ctx, tt.args.samlApp, tt.args.resourceOwner) if tt.res.err == nil { assert.NoError(t, err) } diff --git a/internal/command/project_application_test.go b/internal/command/project_application_test.go index 050a41d29f..a67e6886ed 100644 --- a/internal/command/project_application_test.go +++ b/internal/command/project_application_test.go @@ -8,13 +8,16 @@ import ( "github.com/zitadel/zitadel/internal/domain" "github.com/zitadel/zitadel/internal/eventstore" + "github.com/zitadel/zitadel/internal/eventstore/repository/mock" "github.com/zitadel/zitadel/internal/repository/project" "github.com/zitadel/zitadel/internal/zerrors" ) func TestCommandSide_ChangeApplication(t *testing.T) { + t.Parallel() + type fields struct { - eventstore *eventstore.Eventstore + eventstore func(*testing.T) *eventstore.Eventstore } type args struct { ctx context.Context @@ -35,9 +38,7 @@ func TestCommandSide_ChangeApplication(t *testing.T) { { name: "invalid app missing projectid, invalid argument error", fields: fields{ - eventstore: eventstoreExpect( - t, - ), + eventstore: expectEventstore(func(mockRepository *mock.MockRepository) {}), }, args: args{ ctx: context.Background(), @@ -55,9 +56,7 @@ func TestCommandSide_ChangeApplication(t *testing.T) { { name: "invalid app missing appid, invalid argument error", fields: fields{ - eventstore: eventstoreExpect( - t, - ), + eventstore: expectEventstore(func(mockRepository *mock.MockRepository) {}), }, args: args{ ctx: context.Background(), @@ -74,9 +73,7 @@ func TestCommandSide_ChangeApplication(t *testing.T) { { name: "invalid app missing name, invalid argument error", fields: fields{ - eventstore: eventstoreExpect( - t, - ), + eventstore: expectEventstore(func(mockRepository *mock.MockRepository) {}), }, args: args{ ctx: context.Background(), @@ -94,10 +91,7 @@ func TestCommandSide_ChangeApplication(t *testing.T) { { name: "app not existing, not found error", fields: fields{ - eventstore: eventstoreExpect( - t, - expectFilter(), - ), + eventstore: expectEventstore(expectFilter()), }, args: args{ ctx: context.Background(), @@ -115,8 +109,7 @@ func TestCommandSide_ChangeApplication(t *testing.T) { { name: "app name not changed, not found error", fields: fields{ - eventstore: eventstoreExpect( - t, + eventstore: expectEventstore( expectFilter( eventFromEventPusher(project.NewApplicationAddedEvent(context.Background(), &project.NewAggregate("project1", "org1").Aggregate, @@ -142,8 +135,14 @@ func TestCommandSide_ChangeApplication(t *testing.T) { { name: "app changed, ok", fields: fields{ - eventstore: eventstoreExpect( - t, + eventstore: expectEventstore( + expectFilter( + eventFromEventPusher(project.NewApplicationAddedEvent(context.Background(), + &project.NewAggregate("project1", "org1").Aggregate, + "app1", + "app", + )), + ), expectFilter( eventFromEventPusher(project.NewApplicationAddedEvent(context.Background(), &project.NewAggregate("project1", "org1").Aggregate, @@ -179,10 +178,13 @@ func TestCommandSide_ChangeApplication(t *testing.T) { } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { + t.Parallel() + r := &Commands{ - eventstore: tt.fields.eventstore, + eventstore: tt.fields.eventstore(t), + checkPermission: newMockPermissionCheckAllowed(), } - got, err := r.ChangeApplication(tt.args.ctx, tt.args.projectID, tt.args.app, tt.args.resourceOwner) + got, err := r.UpdateApplicationName(tt.args.ctx, tt.args.projectID, tt.args.app, tt.args.resourceOwner) if tt.res.err == nil { assert.NoError(t, err) } @@ -197,8 +199,10 @@ func TestCommandSide_ChangeApplication(t *testing.T) { } func TestCommandSide_DeactivateApplication(t *testing.T) { + t.Parallel() + type fields struct { - eventstore *eventstore.Eventstore + eventstore func(*testing.T) *eventstore.Eventstore } type args struct { ctx context.Context @@ -219,9 +223,7 @@ func TestCommandSide_DeactivateApplication(t *testing.T) { { name: "missing projectid, invalid argument error", fields: fields{ - eventstore: eventstoreExpect( - t, - ), + eventstore: expectEventstore(func(mockRepository *mock.MockRepository) {}), }, args: args{ ctx: context.Background(), @@ -236,9 +238,7 @@ func TestCommandSide_DeactivateApplication(t *testing.T) { { name: "missing appid, invalid argument error", fields: fields{ - eventstore: eventstoreExpect( - t, - ), + eventstore: expectEventstore(func(mockRepository *mock.MockRepository) {}), }, args: args{ ctx: context.Background(), @@ -253,8 +253,7 @@ func TestCommandSide_DeactivateApplication(t *testing.T) { { name: "app not existing, not found error", fields: fields{ - eventstore: eventstoreExpect( - t, + eventstore: expectEventstore( expectFilter(), ), }, @@ -271,8 +270,7 @@ func TestCommandSide_DeactivateApplication(t *testing.T) { { name: "app already inactive, precondition error", fields: fields{ - eventstore: eventstoreExpect( - t, + eventstore: expectEventstore( expectFilter( eventFromEventPusher(project.NewApplicationAddedEvent(context.Background(), &project.NewAggregate("project1", "org1").Aggregate, @@ -299,8 +297,14 @@ func TestCommandSide_DeactivateApplication(t *testing.T) { { name: "app deactivate, ok", fields: fields{ - eventstore: eventstoreExpect( - t, + eventstore: expectEventstore( + expectFilter( + eventFromEventPusher(project.NewApplicationAddedEvent(context.Background(), + &project.NewAggregate("project1", "org1").Aggregate, + "app1", + "app", + )), + ), expectFilter( eventFromEventPusher(project.NewApplicationAddedEvent(context.Background(), &project.NewAggregate("project1", "org1").Aggregate, @@ -331,8 +335,11 @@ func TestCommandSide_DeactivateApplication(t *testing.T) { } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { + t.Parallel() + r := &Commands{ - eventstore: tt.fields.eventstore, + eventstore: tt.fields.eventstore(t), + checkPermission: newMockPermissionCheckAllowed(), } got, err := r.DeactivateApplication(tt.args.ctx, tt.args.projectID, tt.args.appID, tt.args.resourceOwner) if tt.res.err == nil { @@ -349,8 +356,10 @@ func TestCommandSide_DeactivateApplication(t *testing.T) { } func TestCommandSide_ReactivateApplication(t *testing.T) { + t.Parallel() + type fields struct { - eventstore *eventstore.Eventstore + eventstore func(*testing.T) *eventstore.Eventstore } type args struct { ctx context.Context @@ -371,9 +380,7 @@ func TestCommandSide_ReactivateApplication(t *testing.T) { { name: "missing projectid, invalid argument error", fields: fields{ - eventstore: eventstoreExpect( - t, - ), + eventstore: expectEventstore(func(mockRepository *mock.MockRepository) {}), }, args: args{ ctx: context.Background(), @@ -388,9 +395,7 @@ func TestCommandSide_ReactivateApplication(t *testing.T) { { name: "missing appid, invalid argument error", fields: fields{ - eventstore: eventstoreExpect( - t, - ), + eventstore: expectEventstore(func(mockRepository *mock.MockRepository) {}), }, args: args{ ctx: context.Background(), @@ -405,10 +410,7 @@ func TestCommandSide_ReactivateApplication(t *testing.T) { { name: "app not existing, not found error", fields: fields{ - eventstore: eventstoreExpect( - t, - expectFilter(), - ), + eventstore: expectEventstore(expectFilter()), }, args: args{ ctx: context.Background(), @@ -423,8 +425,7 @@ func TestCommandSide_ReactivateApplication(t *testing.T) { { name: "app already active, precondition error", fields: fields{ - eventstore: eventstoreExpect( - t, + eventstore: expectEventstore( expectFilter( eventFromEventPusher(project.NewApplicationAddedEvent(context.Background(), &project.NewAggregate("project1", "org1").Aggregate, @@ -447,8 +448,7 @@ func TestCommandSide_ReactivateApplication(t *testing.T) { { name: "app reactivate, ok", fields: fields{ - eventstore: eventstoreExpect( - t, + eventstore: expectEventstore( expectFilter( eventFromEventPusher(project.NewApplicationAddedEvent(context.Background(), &project.NewAggregate("project1", "org1").Aggregate, @@ -483,8 +483,11 @@ func TestCommandSide_ReactivateApplication(t *testing.T) { } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { + t.Parallel() + r := &Commands{ - eventstore: tt.fields.eventstore, + eventstore: tt.fields.eventstore(t), + checkPermission: newMockPermissionCheckAllowed(), } got, err := r.ReactivateApplication(tt.args.ctx, tt.args.projectID, tt.args.appID, tt.args.resourceOwner) if tt.res.err == nil { @@ -501,8 +504,10 @@ func TestCommandSide_ReactivateApplication(t *testing.T) { } func TestCommandSide_RemoveApplication(t *testing.T) { + t.Parallel() + type fields struct { - eventstore *eventstore.Eventstore + eventstore func(*testing.T) *eventstore.Eventstore } type args struct { ctx context.Context @@ -523,9 +528,7 @@ func TestCommandSide_RemoveApplication(t *testing.T) { { name: "missing projectid, invalid argument error", fields: fields{ - eventstore: eventstoreExpect( - t, - ), + eventstore: expectEventstore(func(mockRepository *mock.MockRepository) {}), }, args: args{ ctx: context.Background(), @@ -540,9 +543,7 @@ func TestCommandSide_RemoveApplication(t *testing.T) { { name: "missing appid, invalid argument error", fields: fields{ - eventstore: eventstoreExpect( - t, - ), + eventstore: expectEventstore(func(mockRepository *mock.MockRepository) {}), }, args: args{ ctx: context.Background(), @@ -557,10 +558,7 @@ func TestCommandSide_RemoveApplication(t *testing.T) { { name: "app not existing, not found error", fields: fields{ - eventstore: eventstoreExpect( - t, - expectFilter(), - ), + eventstore: expectEventstore(expectFilter()), }, args: args{ ctx: context.Background(), @@ -575,8 +573,7 @@ func TestCommandSide_RemoveApplication(t *testing.T) { { name: "app remove, entityID, ok", fields: fields{ - eventstore: eventstoreExpect( - t, + eventstore: expectEventstore( expectFilter( eventFromEventPusher(project.NewApplicationAddedEvent(context.Background(), &project.NewAggregate("project1", "org1").Aggregate, @@ -584,6 +581,7 @@ func TestCommandSide_RemoveApplication(t *testing.T) { "app", )), ), + expectFilter(), expectFilter( eventFromEventPusher(project.NewApplicationAddedEvent(context.Background(), &project.NewAggregate("project1", "org1").Aggregate, @@ -625,8 +623,7 @@ func TestCommandSide_RemoveApplication(t *testing.T) { { name: "app remove, ok", fields: fields{ - eventstore: eventstoreExpect( - t, + eventstore: expectEventstore( expectFilter( eventFromEventPusher(project.NewApplicationAddedEvent(context.Background(), &project.NewAggregate("project1", "org1").Aggregate, @@ -636,6 +633,7 @@ func TestCommandSide_RemoveApplication(t *testing.T) { ), // app is not saml, or no saml config available expectFilter(), + expectFilter(), expectPush( project.NewApplicationRemovedEvent(context.Background(), &project.NewAggregate("project1", "org1").Aggregate, @@ -661,8 +659,11 @@ func TestCommandSide_RemoveApplication(t *testing.T) { } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { + t.Parallel() + r := &Commands{ - eventstore: tt.fields.eventstore, + eventstore: tt.fields.eventstore(t), + checkPermission: newMockPermissionCheckAllowed(), } got, err := r.RemoveApplication(tt.args.ctx, tt.args.projectID, tt.args.appID, tt.args.resourceOwner) if tt.res.err == nil { diff --git a/internal/command/project_converter.go b/internal/command/project_converter.go index 01b5a4e63d..e88a1cb75a 100644 --- a/internal/command/project_converter.go +++ b/internal/command/project_converter.go @@ -1,6 +1,8 @@ package command import ( + "github.com/muhlemmer/gu" + "github.com/zitadel/zitadel/internal/domain" ) @@ -35,21 +37,21 @@ func oidcWriteModelToOIDCConfig(writeModel *OIDCApplicationWriteModel) *domain.O RedirectUris: writeModel.RedirectUris, ResponseTypes: writeModel.ResponseTypes, GrantTypes: writeModel.GrantTypes, - ApplicationType: writeModel.ApplicationType, - AuthMethodType: writeModel.AuthMethodType, + ApplicationType: gu.Ptr(writeModel.ApplicationType), + AuthMethodType: gu.Ptr(writeModel.AuthMethodType), PostLogoutRedirectUris: writeModel.PostLogoutRedirectUris, - OIDCVersion: writeModel.OIDCVersion, - DevMode: writeModel.DevMode, - AccessTokenType: writeModel.AccessTokenType, - AccessTokenRoleAssertion: writeModel.AccessTokenRoleAssertion, - IDTokenRoleAssertion: writeModel.IDTokenRoleAssertion, - IDTokenUserinfoAssertion: writeModel.IDTokenUserinfoAssertion, - ClockSkew: writeModel.ClockSkew, + OIDCVersion: gu.Ptr(writeModel.OIDCVersion), + DevMode: gu.Ptr(writeModel.DevMode), + AccessTokenType: gu.Ptr(writeModel.AccessTokenType), + AccessTokenRoleAssertion: gu.Ptr(writeModel.AccessTokenRoleAssertion), + IDTokenRoleAssertion: gu.Ptr(writeModel.IDTokenRoleAssertion), + IDTokenUserinfoAssertion: gu.Ptr(writeModel.IDTokenUserinfoAssertion), + ClockSkew: gu.Ptr(writeModel.ClockSkew), AdditionalOrigins: writeModel.AdditionalOrigins, - SkipNativeAppSuccessPage: writeModel.SkipNativeAppSuccessPage, - BackChannelLogoutURI: writeModel.BackChannelLogoutURI, - LoginVersion: writeModel.LoginVersion, - LoginBaseURI: writeModel.LoginBaseURI, + SkipNativeAppSuccessPage: gu.Ptr(writeModel.SkipNativeAppSuccessPage), + BackChannelLogoutURI: gu.Ptr(writeModel.BackChannelLogoutURI), + LoginVersion: gu.Ptr(writeModel.LoginVersion), + LoginBaseURI: gu.Ptr(writeModel.LoginBaseURI), } } @@ -60,10 +62,10 @@ func samlWriteModelToSAMLConfig(writeModel *SAMLApplicationWriteModel) *domain.S AppName: writeModel.AppName, State: writeModel.State, Metadata: writeModel.Metadata, - MetadataURL: writeModel.MetadataURL, + MetadataURL: gu.Ptr(writeModel.MetadataURL), EntityID: writeModel.EntityID, - LoginVersion: writeModel.LoginVersion, - LoginBaseURI: writeModel.LoginBaseURI, + LoginVersion: gu.Ptr(writeModel.LoginVersion), + LoginBaseURI: gu.Ptr(writeModel.LoginBaseURI), } } @@ -78,15 +80,6 @@ func apiWriteModelToAPIConfig(writeModel *APIApplicationWriteModel) *domain.APIA } } -func roleWriteModelToRole(writeModel *ProjectRoleWriteModel) *domain.ProjectRole { - return &domain.ProjectRole{ - ObjectRoot: writeModelToObjectRoot(writeModel.WriteModel), - Key: writeModel.Key, - DisplayName: writeModel.DisplayName, - Group: writeModel.Group, - } -} - func memberWriteModelToProjectGrantMember(writeModel *ProjectGrantMemberWriteModel) *domain.ProjectGrantMember { return &domain.ProjectGrantMember{ ObjectRoot: writeModelToObjectRoot(writeModel.WriteModel), diff --git a/internal/command/project_model.go b/internal/command/project_model.go index cabceb8500..4c9496b3ad 100644 --- a/internal/command/project_model.go +++ b/internal/command/project_model.go @@ -2,6 +2,7 @@ package command import ( "context" + "slices" "github.com/zitadel/zitadel/internal/domain" "github.com/zitadel/zitadel/internal/eventstore" @@ -120,7 +121,7 @@ func (wm *ProjectWriteModel) NewChangedEvent( } func isProjectStateExists(state domain.ProjectState) bool { - return !hasProjectState(state, domain.ProjectStateRemoved, domain.ProjectStateUnspecified) + return !slices.Contains([]domain.ProjectState{domain.ProjectStateRemoved, domain.ProjectStateUnspecified}, state) } func ProjectAggregateFromWriteModel(wm *eventstore.WriteModel) *eventstore.Aggregate { @@ -130,12 +131,3 @@ func ProjectAggregateFromWriteModel(wm *eventstore.WriteModel) *eventstore.Aggre func ProjectAggregateFromWriteModelWithCTX(ctx context.Context, wm *eventstore.WriteModel) *eventstore.Aggregate { return project.AggregateFromWriteModel(ctx, wm) } - -func hasProjectState(check domain.ProjectState, states ...domain.ProjectState) bool { - for _, state := range states { - if check == state { - return true - } - } - return false -} diff --git a/internal/domain/application_oidc.go b/internal/domain/application_oidc.go index 5d466c689d..10a70a1776 100644 --- a/internal/domain/application_oidc.go +++ b/internal/domain/application_oidc.go @@ -1,6 +1,7 @@ package domain import ( + "slices" "strings" "time" @@ -32,22 +33,22 @@ type OIDCApp struct { RedirectUris []string ResponseTypes []OIDCResponseType GrantTypes []OIDCGrantType - ApplicationType OIDCApplicationType - AuthMethodType OIDCAuthMethodType + ApplicationType *OIDCApplicationType + AuthMethodType *OIDCAuthMethodType PostLogoutRedirectUris []string - OIDCVersion OIDCVersion + OIDCVersion *OIDCVersion Compliance *Compliance - DevMode bool - AccessTokenType OIDCTokenType - AccessTokenRoleAssertion bool - IDTokenRoleAssertion bool - IDTokenUserinfoAssertion bool - ClockSkew time.Duration + DevMode *bool + AccessTokenType *OIDCTokenType + AccessTokenRoleAssertion *bool + IDTokenRoleAssertion *bool + IDTokenUserinfoAssertion *bool + ClockSkew *time.Duration AdditionalOrigins []string - SkipNativeAppSuccessPage bool - BackChannelLogoutURI string - LoginVersion LoginVersion - LoginBaseURI string + SkipNativeAppSuccessPage *bool + BackChannelLogoutURI *string + LoginVersion *LoginVersion + LoginBaseURI *string State AppState } @@ -69,7 +70,7 @@ func (a *OIDCApp) setClientSecret(encodedHash string) { } func (a *OIDCApp) requiresClientSecret() bool { - return a.AuthMethodType == OIDCAuthMethodTypeBasic || a.AuthMethodType == OIDCAuthMethodTypePost + return a.AuthMethodType != nil && (*a.AuthMethodType == OIDCAuthMethodTypeBasic || *a.AuthMethodType == OIDCAuthMethodTypePost) } type OIDCVersion int32 @@ -137,7 +138,7 @@ const ( ) func (a *OIDCApp) IsValid() bool { - if a.ClockSkew > time.Second*5 || a.ClockSkew < time.Second*0 || !a.OriginsValid() { + if (a.ClockSkew != nil && (*a.ClockSkew > time.Second*5 || *a.ClockSkew < time.Second*0)) || !a.OriginsValid() { return false } grantTypes := a.getRequiredGrantTypes() @@ -204,30 +205,25 @@ func ContainsOIDCGrantTypes(shouldContain, list []OIDCGrantType) bool { } func containsOIDCGrantType(grantTypes []OIDCGrantType, grantType OIDCGrantType) bool { - for _, gt := range grantTypes { - if gt == grantType { - return true - } - } - return false + return slices.Contains(grantTypes, grantType) } func (a *OIDCApp) FillCompliance() { a.Compliance = GetOIDCCompliance(a.OIDCVersion, a.ApplicationType, a.GrantTypes, a.ResponseTypes, a.AuthMethodType, a.RedirectUris) } -func GetOIDCCompliance(version OIDCVersion, appType OIDCApplicationType, grantTypes []OIDCGrantType, responseTypes []OIDCResponseType, authMethod OIDCAuthMethodType, redirectUris []string) *Compliance { - switch version { - case OIDCVersionV1: +func GetOIDCCompliance(version *OIDCVersion, appType *OIDCApplicationType, grantTypes []OIDCGrantType, responseTypes []OIDCResponseType, authMethod *OIDCAuthMethodType, redirectUris []string) *Compliance { + if version != nil && *version == OIDCVersionV1 { return GetOIDCV1Compliance(appType, grantTypes, authMethod, redirectUris) } + return &Compliance{ NoneCompliant: true, Problems: []string{"Application.OIDC.UnsupportedVersion"}, } } -func GetOIDCV1Compliance(appType OIDCApplicationType, grantTypes []OIDCGrantType, authMethod OIDCAuthMethodType, redirectUris []string) *Compliance { +func GetOIDCV1Compliance(appType *OIDCApplicationType, grantTypes []OIDCGrantType, authMethod *OIDCAuthMethodType, redirectUris []string) *Compliance { compliance := &Compliance{NoneCompliant: false} checkGrantTypesCombination(compliance, grantTypes) @@ -247,7 +243,7 @@ func checkGrantTypesCombination(compliance *Compliance, grantTypes []OIDCGrantTy } } -func checkRedirectURIs(compliance *Compliance, grantTypes []OIDCGrantType, appType OIDCApplicationType, redirectUris []string) { +func checkRedirectURIs(compliance *Compliance, grantTypes []OIDCGrantType, appType *OIDCApplicationType, redirectUris []string) { // See #5684 for OIDCGrantTypeDeviceCode and redirectUris further explanation if len(redirectUris) == 0 && (!containsOIDCGrantType(grantTypes, OIDCGrantTypeDeviceCode) || (containsOIDCGrantType(grantTypes, OIDCGrantTypeDeviceCode) && containsOIDCGrantType(grantTypes, OIDCGrantTypeAuthorizationCode))) { compliance.NoneCompliant = true @@ -266,53 +262,58 @@ func checkRedirectURIs(compliance *Compliance, grantTypes []OIDCGrantType, appTy } } -func checkApplicationType(compliance *Compliance, appType OIDCApplicationType, authMethod OIDCAuthMethodType) { - switch appType { - case OIDCApplicationTypeNative: - GetOIDCV1NativeApplicationCompliance(compliance, authMethod) - case OIDCApplicationTypeUserAgent: - GetOIDCV1UserAgentApplicationCompliance(compliance, authMethod) +func checkApplicationType(compliance *Compliance, appType *OIDCApplicationType, authMethod *OIDCAuthMethodType) { + if appType != nil { + switch *appType { + case OIDCApplicationTypeNative: + GetOIDCV1NativeApplicationCompliance(compliance, authMethod) + case OIDCApplicationTypeUserAgent: + GetOIDCV1UserAgentApplicationCompliance(compliance, authMethod) + case OIDCApplicationTypeWeb: + return + } } + if compliance.NoneCompliant { compliance.Problems = append([]string{"Application.OIDC.V1.NotCompliant"}, compliance.Problems...) } } -func GetOIDCV1NativeApplicationCompliance(compliance *Compliance, authMethod OIDCAuthMethodType) { - if authMethod != OIDCAuthMethodTypeNone { +func GetOIDCV1NativeApplicationCompliance(compliance *Compliance, authMethod *OIDCAuthMethodType) { + if authMethod != nil && *authMethod != OIDCAuthMethodTypeNone { compliance.NoneCompliant = true compliance.Problems = append(compliance.Problems, "Application.OIDC.V1.Native.AuthMethodType.NotNone") } } -func GetOIDCV1UserAgentApplicationCompliance(compliance *Compliance, authMethod OIDCAuthMethodType) { - if authMethod != OIDCAuthMethodTypeNone { +func GetOIDCV1UserAgentApplicationCompliance(compliance *Compliance, authMethod *OIDCAuthMethodType) { + if authMethod != nil && *authMethod != OIDCAuthMethodTypeNone { compliance.NoneCompliant = true compliance.Problems = append(compliance.Problems, "Application.OIDC.V1.UserAgent.AuthMethodType.NotNone") } } -func CheckRedirectUrisCode(compliance *Compliance, appType OIDCApplicationType, redirectUris []string) { +func CheckRedirectUrisCode(compliance *Compliance, appType *OIDCApplicationType, redirectUris []string) { if urlsAreHttps(redirectUris) { return } if urlContainsPrefix(redirectUris, http) { - if appType == OIDCApplicationTypeUserAgent { + if appType != nil && *appType == OIDCApplicationTypeUserAgent { compliance.NoneCompliant = true compliance.Problems = append(compliance.Problems, "Application.OIDC.V1.Code.RedirectUris.HttpOnlyForWeb") } - if appType == OIDCApplicationTypeNative && !onlyLocalhostIsHttp(redirectUris) { + if appType != nil && *appType == OIDCApplicationTypeNative && !onlyLocalhostIsHttp(redirectUris) { compliance.NoneCompliant = true compliance.Problems = append(compliance.Problems, "Application.OIDC.V1.Native.RedirectUris.MustBeHttpLocalhost") } } - if containsCustom(redirectUris) && appType != OIDCApplicationTypeNative { + if containsCustom(redirectUris) && appType != nil && *appType != OIDCApplicationTypeNative { compliance.NoneCompliant = true compliance.Problems = append(compliance.Problems, "Application.OIDC.V1.Code.RedirectUris.CustomOnlyForNative") } } -func CheckRedirectUrisImplicit(compliance *Compliance, appType OIDCApplicationType, redirectUris []string) { +func CheckRedirectUrisImplicit(compliance *Compliance, appType *OIDCApplicationType, redirectUris []string) { if urlsAreHttps(redirectUris) { return } @@ -321,7 +322,7 @@ func CheckRedirectUrisImplicit(compliance *Compliance, appType OIDCApplicationTy compliance.Problems = append(compliance.Problems, "Application.OIDC.V1.Implicit.RedirectUris.CustomNotAllowed") } if urlContainsPrefix(redirectUris, http) { - if appType == OIDCApplicationTypeNative { + if appType != nil && *appType == OIDCApplicationTypeNative { if !onlyLocalhostIsHttp(redirectUris) { compliance.NoneCompliant = true compliance.Problems = append(compliance.Problems, "Application.OIDC.V1.Native.RedirectUris.MustBeHttpLocalhost") @@ -333,20 +334,20 @@ func CheckRedirectUrisImplicit(compliance *Compliance, appType OIDCApplicationTy } } -func CheckRedirectUrisImplicitAndCode(compliance *Compliance, appType OIDCApplicationType, redirectUris []string) { +func CheckRedirectUrisImplicitAndCode(compliance *Compliance, appType *OIDCApplicationType, redirectUris []string) { if urlsAreHttps(redirectUris) { return } - if containsCustom(redirectUris) && appType != OIDCApplicationTypeNative { + if containsCustom(redirectUris) && appType != nil && *appType != OIDCApplicationTypeNative { compliance.NoneCompliant = true compliance.Problems = append(compliance.Problems, "Application.OIDC.V1.Implicit.RedirectUris.CustomNotAllowed") } if urlContainsPrefix(redirectUris, http) { - if appType == OIDCApplicationTypeUserAgent { + if appType != nil && *appType == OIDCApplicationTypeUserAgent { compliance.NoneCompliant = true compliance.Problems = append(compliance.Problems, "Application.OIDC.V1.Code.RedirectUris.HttpOnlyForWeb") } - if !onlyLocalhostIsHttp(redirectUris) && appType == OIDCApplicationTypeNative { + if !onlyLocalhostIsHttp(redirectUris) && appType != nil && *appType == OIDCApplicationTypeNative { compliance.NoneCompliant = true compliance.Problems = append(compliance.Problems, "Application.OIDC.V1.Native.RedirectUris.MustBeHttpLocalhost") } diff --git a/internal/domain/application_oidc_test.go b/internal/domain/application_oidc_test.go index b3d9488827..4208917cdd 100644 --- a/internal/domain/application_oidc_test.go +++ b/internal/domain/application_oidc_test.go @@ -6,6 +6,8 @@ import ( "testing" "time" + "github.com/muhlemmer/gu" + "github.com/zitadel/zitadel/internal/eventstore/v1/models" ) @@ -25,7 +27,7 @@ func TestApplicationValid(t *testing.T) { ObjectRoot: models.ObjectRoot{AggregateID: "AggregateID"}, AppID: "AppID", AppName: "AppName", - ClockSkew: time.Minute * 1, + ClockSkew: gu.Ptr(time.Minute * 1), ResponseTypes: []OIDCResponseType{OIDCResponseTypeCode}, GrantTypes: []OIDCGrantType{OIDCGrantTypeAuthorizationCode}, }, @@ -39,7 +41,7 @@ func TestApplicationValid(t *testing.T) { ObjectRoot: models.ObjectRoot{AggregateID: "AggregateID"}, AppID: "AppID", AppName: "AppName", - ClockSkew: time.Minute * -1, + ClockSkew: gu.Ptr(time.Minute * -1), ResponseTypes: []OIDCResponseType{OIDCResponseTypeCode}, GrantTypes: []OIDCGrantType{OIDCGrantTypeAuthorizationCode}, }, @@ -190,9 +192,9 @@ func TestApplicationValid(t *testing.T) { func TestGetOIDCV1Compliance(t *testing.T) { type args struct { - appType OIDCApplicationType + appType *OIDCApplicationType grantTypes []OIDCGrantType - authMethod OIDCAuthMethodType + authMethod *OIDCAuthMethodType redirectUris []string } tests := []struct { @@ -266,7 +268,7 @@ func Test_checkGrantTypesCombination(t *testing.T) { func Test_checkRedirectURIs(t *testing.T) { type args struct { grantTypes []OIDCGrantType - appType OIDCApplicationType + appType *OIDCApplicationType redirectUris []string } tests := []struct { @@ -304,7 +306,7 @@ func Test_checkRedirectURIs(t *testing.T) { args: args{ redirectUris: []string{"http://redirect.to/me"}, grantTypes: []OIDCGrantType{OIDCGrantTypeImplicit}, - appType: OIDCApplicationTypeUserAgent, + appType: gu.Ptr(OIDCApplicationTypeUserAgent), }, }, { @@ -316,7 +318,7 @@ func Test_checkRedirectURIs(t *testing.T) { args: args{ redirectUris: []string{"http://redirect.to/me"}, grantTypes: []OIDCGrantType{OIDCGrantTypeAuthorizationCode}, - appType: OIDCApplicationTypeUserAgent, + appType: gu.Ptr(OIDCApplicationTypeUserAgent), }, }, } @@ -338,7 +340,7 @@ func Test_checkRedirectURIs(t *testing.T) { func Test_CheckRedirectUrisImplicitAndCode(t *testing.T) { type args struct { - appType OIDCApplicationType + appType *OIDCApplicationType redirectUris []string } tests := []struct { @@ -356,17 +358,6 @@ func Test_CheckRedirectUrisImplicitAndCode(t *testing.T) { redirectUris: []string{"https://redirect.to/me"}, }, }, - // { - // name: "custom protocol, not native", - // want: &Compliance{ - // NoneCompliant: true, - // Problems: []string{"Application.OIDC.V1.Implicit.RedirectUris.CustomNotAllowed"}, - // }, - // args: args{ - // redirectUris: []string{"protocol://redirect.to/me"}, - // appType: OIDCApplicationTypeWeb, - // }, - // }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { @@ -386,7 +377,7 @@ func Test_CheckRedirectUrisImplicitAndCode(t *testing.T) { func TestCheckRedirectUrisImplicitAndCode(t *testing.T) { type args struct { - appType OIDCApplicationType + appType *OIDCApplicationType redirectUris []string } tests := []struct { @@ -402,7 +393,7 @@ func TestCheckRedirectUrisImplicitAndCode(t *testing.T) { { name: "custom protocol not native app", args: args{ - appType: OIDCApplicationTypeWeb, + appType: gu.Ptr(OIDCApplicationTypeWeb), redirectUris: []string{"custom://nirvana.com"}, }, want: &Compliance{ @@ -413,7 +404,7 @@ func TestCheckRedirectUrisImplicitAndCode(t *testing.T) { { name: "http localhost user agent app", args: args{ - appType: OIDCApplicationTypeUserAgent, + appType: gu.Ptr(OIDCApplicationTypeUserAgent), redirectUris: []string{"http://localhost:9009"}, }, want: &Compliance{ @@ -424,7 +415,7 @@ func TestCheckRedirectUrisImplicitAndCode(t *testing.T) { { name: "http, not only localhost native app", args: args{ - appType: OIDCApplicationTypeNative, + appType: gu.Ptr(OIDCApplicationTypeNative), redirectUris: []string{"http://nirvana.com", "http://localhost:9009"}, }, want: &Compliance{ @@ -435,7 +426,7 @@ func TestCheckRedirectUrisImplicitAndCode(t *testing.T) { { name: "not allowed combination", args: args{ - appType: OIDCApplicationTypeNative, + appType: gu.Ptr(OIDCApplicationTypeNative), redirectUris: []string{"https://nirvana.com", "cutom://nirvana.com"}, }, want: &Compliance{ @@ -461,7 +452,7 @@ func TestCheckRedirectUrisImplicitAndCode(t *testing.T) { func TestCheckRedirectUrisImplicit(t *testing.T) { type args struct { - appType OIDCApplicationType + appType *OIDCApplicationType redirectUris []string } tests := []struct { @@ -488,7 +479,7 @@ func TestCheckRedirectUrisImplicit(t *testing.T) { name: "only http protocol, app type native, not only localhost", args: args{ redirectUris: []string{"http://nirvana.com"}, - appType: OIDCApplicationTypeNative, + appType: gu.Ptr(OIDCApplicationTypeNative), }, want: &Compliance{ NoneCompliant: true, @@ -499,7 +490,7 @@ func TestCheckRedirectUrisImplicit(t *testing.T) { name: "only http protocol, app type native, only localhost", args: args{ redirectUris: []string{"http://localhost:8080"}, - appType: OIDCApplicationTypeNative, + appType: gu.Ptr(OIDCApplicationTypeNative), }, want: &Compliance{ NoneCompliant: false, @@ -510,7 +501,7 @@ func TestCheckRedirectUrisImplicit(t *testing.T) { name: "only http protocol, app type web", args: args{ redirectUris: []string{"http://nirvana.com"}, - appType: OIDCApplicationTypeWeb, + appType: gu.Ptr(OIDCApplicationTypeWeb), }, want: &Compliance{ NoneCompliant: true, @@ -535,7 +526,7 @@ func TestCheckRedirectUrisImplicit(t *testing.T) { func TestCheckRedirectUrisCode(t *testing.T) { type args struct { - appType OIDCApplicationType + appType *OIDCApplicationType redirectUris []string } tests := []struct { @@ -552,7 +543,7 @@ func TestCheckRedirectUrisCode(t *testing.T) { name: "custom prefix, app type web", args: args{ redirectUris: []string{"custom://nirvana.com"}, - appType: OIDCApplicationTypeWeb, + appType: gu.Ptr(OIDCApplicationTypeWeb), }, want: &Compliance{ NoneCompliant: true, @@ -563,7 +554,7 @@ func TestCheckRedirectUrisCode(t *testing.T) { name: "only http protocol, app type user agent", args: args{ redirectUris: []string{"http://nirvana.com"}, - appType: OIDCApplicationTypeUserAgent, + appType: gu.Ptr(OIDCApplicationTypeUserAgent), }, want: &Compliance{ NoneCompliant: true, @@ -574,7 +565,7 @@ func TestCheckRedirectUrisCode(t *testing.T) { name: "only http protocol, app type native, only localhost", args: args{ redirectUris: []string{"http://localhost:8080", "http://nirvana.com:8080"}, - appType: OIDCApplicationTypeNative, + appType: gu.Ptr(OIDCApplicationTypeNative), }, want: &Compliance{ NoneCompliant: true, @@ -585,7 +576,7 @@ func TestCheckRedirectUrisCode(t *testing.T) { name: "custom protocol, not native", args: args{ redirectUris: []string{"custom://nirvana.com"}, - appType: OIDCApplicationTypeWeb, + appType: gu.Ptr(OIDCApplicationTypeWeb), }, want: &Compliance{ NoneCompliant: true, diff --git a/internal/domain/application_saml.go b/internal/domain/application_saml.go index de7ef789ee..aff1875c7e 100644 --- a/internal/domain/application_saml.go +++ b/internal/domain/application_saml.go @@ -11,9 +11,9 @@ type SAMLApp struct { AppName string EntityID string Metadata []byte - MetadataURL string - LoginVersion LoginVersion - LoginBaseURI string + MetadataURL *string + LoginVersion *LoginVersion + LoginBaseURI *string State AppState } @@ -31,11 +31,14 @@ func (a *SAMLApp) GetMetadata() []byte { } func (a *SAMLApp) GetMetadataURL() string { - return a.MetadataURL + if a.MetadataURL != nil { + return *a.MetadataURL + } + return "" } func (a *SAMLApp) IsValid() bool { - if a.MetadataURL == "" && a.Metadata == nil { + if (a.MetadataURL == nil || *a.MetadataURL == "") && a.Metadata == nil { return false } return true diff --git a/internal/domain/permission.go b/internal/domain/permission.go index bb569955f5..119e8c2d3e 100644 --- a/internal/domain/permission.go +++ b/internal/domain/permission.go @@ -47,6 +47,9 @@ const ( PermissionProjectRoleWrite = "project.role.write" PermissionProjectRoleRead = "project.role.read" PermissionProjectRoleDelete = "project.role.delete" + PermissionProjectAppWrite = "project.app.write" + PermissionProjectAppDelete = "project.app.delete" + PermissionProjectAppRead = "project.app.read" ) // ProjectPermissionCheck is used as a check for preconditions dependent on application, project, user resourceowner and usergrants. diff --git a/internal/integration/client.go b/internal/integration/client.go index 20c98b5628..326d6fa8b4 100644 --- a/internal/integration/client.go +++ b/internal/integration/client.go @@ -22,6 +22,7 @@ import ( "github.com/zitadel/zitadel/internal/integration/scim" action "github.com/zitadel/zitadel/pkg/grpc/action/v2beta" "github.com/zitadel/zitadel/pkg/grpc/admin" + app "github.com/zitadel/zitadel/pkg/grpc/app/v2beta" "github.com/zitadel/zitadel/pkg/grpc/auth" "github.com/zitadel/zitadel/pkg/grpc/feature/v2" feature_v2beta "github.com/zitadel/zitadel/pkg/grpc/feature/v2beta" @@ -75,6 +76,7 @@ type Client struct { SCIM *scim.Client Projectv2Beta project_v2beta.ProjectServiceClient InstanceV2Beta instance.InstanceServiceClient + AppV2Beta app.AppServiceClient } func NewDefaultClient(ctx context.Context) (*Client, error) { @@ -114,6 +116,7 @@ func newClient(ctx context.Context, target string) (*Client, error) { SCIM: scim.NewScimClient(target), Projectv2Beta: project_v2beta.NewProjectServiceClient(cc), InstanceV2Beta: instance.NewInstanceServiceClient(cc), + AppV2Beta: app.NewAppServiceClient(cc), } return client, client.pollHealth(ctx) } diff --git a/internal/project/model/oidc_config.go b/internal/project/model/oidc_config.go index 50be6c318a..2c482a67a7 100644 --- a/internal/project/model/oidc_config.go +++ b/internal/project/model/oidc_config.go @@ -3,6 +3,8 @@ package model import ( "time" + "github.com/muhlemmer/gu" + "github.com/zitadel/zitadel/internal/crypto" "github.com/zitadel/zitadel/internal/domain" es_models "github.com/zitadel/zitadel/internal/eventstore/v1/models" @@ -98,7 +100,7 @@ func GetOIDCCompliance(version OIDCVersion, appType OIDCApplicationType, grantTy for i, grantType := range grantTypes { domainGrantTypes[i] = domain.OIDCGrantType(grantType) } - compliance := domain.GetOIDCV1Compliance(domain.OIDCApplicationType(appType), domainGrantTypes, domain.OIDCAuthMethodType(authMethod), redirectUris) + compliance := domain.GetOIDCV1Compliance(gu.Ptr(domain.OIDCApplicationType(appType)), domainGrantTypes, gu.Ptr(domain.OIDCAuthMethodType(authMethod)), redirectUris) return &Compliance{ NoneCompliant: compliance.NoneCompliant, Problems: compliance.Problems, diff --git a/internal/query/app.go b/internal/query/app.go index bc97c1807e..777c295139 100644 --- a/internal/query/app.go +++ b/internal/query/app.go @@ -5,9 +5,11 @@ import ( "database/sql" _ "embed" "errors" + "slices" "time" sq "github.com/Masterminds/squirrel" + "github.com/muhlemmer/gu" "github.com/zitadel/logging" "github.com/zitadel/zitadel/internal/api/authz" @@ -307,6 +309,19 @@ func (q *Queries) AppByProjectAndAppID(ctx context.Context, shouldTriggerBulk bo return app, err } +func (q *Queries) AppByIDWithPermission(ctx context.Context, appID string, activeOnly bool, permissionCheck domain.PermissionCheck) (*App, error) { + app, err := q.AppByID(ctx, appID, activeOnly) + if err != nil { + return nil, err + } + + if err := appCheckPermission(ctx, app.ResourceOwner, app.ProjectID, permissionCheck); err != nil { + return nil, err + } + + return app, nil +} + func (q *Queries) AppByID(ctx context.Context, appID string, activeOnly bool) (app *App, err error) { ctx, span := tracing.NewSpan(ctx) defer func() { span.EndWithError(err) }() @@ -476,11 +491,54 @@ func (q *Queries) AppByOIDCClientID(ctx context.Context, clientID string) (app * return app, err } -func (q *Queries) SearchApps(ctx context.Context, queries *AppSearchQueries, withOwnerRemoved bool) (apps *Apps, err error) { +func (q *Queries) AppByClientID(ctx context.Context, clientID string) (app *App, err error) { + ctx, span := tracing.NewSpan(ctx) + defer func() { span.EndWithError(err) }() + + stmt, scan := prepareAppQuery(true) + eq := sq.Eq{ + AppColumnInstanceID.identifier(): authz.GetInstance(ctx).InstanceID(), + AppColumnState.identifier(): domain.AppStateActive, + ProjectColumnState.identifier(): domain.ProjectStateActive, + OrgColumnState.identifier(): domain.OrgStateActive, + } + query, args, err := stmt.Where(sq.And{ + eq, + sq.Or{ + sq.Eq{AppOIDCConfigColumnClientID.identifier(): clientID}, + sq.Eq{AppAPIConfigColumnClientID.identifier(): clientID}, + }, + }).ToSql() + if err != nil { + return nil, zerrors.ThrowInternal(err, "QUERY-Dfge2", "Errors.Query.SQLStatement") + } + + err = q.client.QueryRowContext(ctx, func(row *sql.Row) error { + app, err = scan(row) + return err + }, query, args...) + return app, err +} + +func (q *Queries) SearchApps(ctx context.Context, queries *AppSearchQueries, permissionCheck domain.PermissionCheck) (*Apps, error) { + apps, err := q.searchApps(ctx, queries, PermissionV2(ctx, permissionCheck)) + if err != nil { + return nil, err + } + + if permissionCheck != nil && !authz.GetFeatures(ctx).PermissionCheckV2 { + apps.Apps = appsCheckPermission(ctx, apps.Apps, permissionCheck) + } + return apps, nil +} + +func (q *Queries) searchApps(ctx context.Context, queries *AppSearchQueries, isPermissionV2Enabled bool) (apps *Apps, err error) { ctx, span := tracing.NewSpan(ctx) defer func() { span.EndWithError(err) }() query, scan := prepareAppsQuery() + query = appPermissionCheckV2(ctx, query, isPermissionV2Enabled, queries) + eq := sq.Eq{AppColumnInstanceID.identifier(): authz.GetInstance(ctx).InstanceID()} stmt, args, err := queries.toQuery(query).Where(eq).ToSql() if err != nil { @@ -498,6 +556,21 @@ func (q *Queries) SearchApps(ctx context.Context, queries *AppSearchQueries, wit return apps, err } +func appPermissionCheckV2(ctx context.Context, query sq.SelectBuilder, enabled bool, queries *AppSearchQueries) sq.SelectBuilder { + if !enabled { + return query + } + + join, args := PermissionClause( + ctx, + AppColumnResourceOwner, + domain.PermissionProjectAppRead, + SingleOrgPermissionOption(queries.Queries), + WithProjectsPermissionOption(AppColumnProjectID), + ) + return query.JoinClause(join, args...) +} + func (q *Queries) SearchClientIDs(ctx context.Context, queries *AppSearchQueries, shouldTriggerBulk bool) (ids []string, err error) { ctx, span := tracing.NewSpan(ctx) defer func() { span.EndWithError(err) }() @@ -574,10 +647,25 @@ func (q *Queries) SAMLAppLoginVersion(ctx context.Context, appID string) (loginV return loginVersion, nil } +func appCheckPermission(ctx context.Context, resourceOwner string, projectID string, permissionCheck domain.PermissionCheck) error { + return permissionCheck(ctx, domain.PermissionProjectAppRead, resourceOwner, projectID) +} + +// appsCheckPermission returns only the apps that the user in context has permission to read +func appsCheckPermission(ctx context.Context, apps []*App, permissionCheck domain.PermissionCheck) []*App { + return slices.DeleteFunc(apps, func(app *App) bool { + return permissionCheck(ctx, domain.PermissionProjectAppRead, app.ResourceOwner, app.ProjectID) != nil + }) +} + func NewAppNameSearchQuery(method TextComparison, value string) (SearchQuery, error) { return NewTextQuery(AppColumnName, value, method) } +func NewAppStateSearchQuery(value domain.AppState) (SearchQuery, error) { + return NewNumberQuery(AppColumnState, int(value), NumberEquals) +} + func NewAppProjectIDSearchQuery(id string) (SearchQuery, error) { return NewTextQuery(AppColumnProjectID, id, TextEquals) } @@ -1089,7 +1177,7 @@ func (c sqlOIDCConfig) set(app *App) { if c.loginBaseURI.Valid { app.OIDCConfig.LoginBaseURI = &c.loginBaseURI.String } - compliance := domain.GetOIDCCompliance(app.OIDCConfig.Version, app.OIDCConfig.AppType, app.OIDCConfig.GrantTypes, app.OIDCConfig.ResponseTypes, app.OIDCConfig.AuthMethodType, app.OIDCConfig.RedirectURIs) + compliance := domain.GetOIDCCompliance(gu.Ptr(app.OIDCConfig.Version), gu.Ptr(app.OIDCConfig.AppType), app.OIDCConfig.GrantTypes, app.OIDCConfig.ResponseTypes, gu.Ptr(app.OIDCConfig.AuthMethodType), app.OIDCConfig.RedirectURIs) app.OIDCConfig.ComplianceProblems = compliance.Problems var err error diff --git a/pkg/grpc/app/v2beta/application.go b/pkg/grpc/app/v2beta/application.go new file mode 100644 index 0000000000..bbce4289f9 --- /dev/null +++ b/pkg/grpc/app/v2beta/application.go @@ -0,0 +1,5 @@ +package app + +type ApplicationConfig = isApplication_Config + +type MetaType = isUpdateSAMLApplicationConfigurationRequest_Metadata \ No newline at end of file diff --git a/proto/zitadel/app/v2beta/api.proto b/proto/zitadel/app/v2beta/api.proto new file mode 100644 index 0000000000..9ef09d5ad8 --- /dev/null +++ b/proto/zitadel/app/v2beta/api.proto @@ -0,0 +1,26 @@ +syntax = "proto3"; + +package zitadel.app.v2beta; + +import "protoc-gen-openapiv2/options/annotations.proto"; + +option go_package = "github.com/zitadel/zitadel/pkg/grpc/app/v2beta;app"; + +enum APIAuthMethodType { + API_AUTH_METHOD_TYPE_BASIC = 0; + API_AUTH_METHOD_TYPE_PRIVATE_KEY_JWT = 1; +} + +message APIConfig { + string client_id = 1 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "\"69629023906488334@ZITADEL\""; + description: "generated oauth2/oidc client_id"; + } + ]; + APIAuthMethodType auth_method_type = 2 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + description: "defines how the API passes the login credentials"; + } + ]; +} \ No newline at end of file diff --git a/proto/zitadel/app/v2beta/app.proto b/proto/zitadel/app/v2beta/app.proto new file mode 100644 index 0000000000..f85e3c021d --- /dev/null +++ b/proto/zitadel/app/v2beta/app.proto @@ -0,0 +1,94 @@ +syntax = "proto3"; + +package zitadel.app.v2beta; + +import "zitadel/app/v2beta/oidc.proto"; +import "zitadel/app/v2beta/saml.proto"; +import "zitadel/app/v2beta/api.proto"; +import "zitadel/filter/v2/filter.proto"; +import "protoc-gen-openapiv2/options/annotations.proto"; +import "google/protobuf/duration.proto"; +import "google/protobuf/timestamp.proto"; +import "validate/validate.proto"; + +option go_package = "github.com/zitadel/zitadel/pkg/grpc/app/v2beta;app"; + +message Application { + string id = 1 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "\"69629023906488334\""; + } + ]; + + // The timestamp of the app creation. + google.protobuf.Timestamp creation_date = 2 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "\"2024-12-18T07:50:47.492Z\""; + } + ]; + + // The timestamp of the app update. + google.protobuf.Timestamp change_date = 3 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "\"2024-12-18T07:50:47.492Z\""; + } + ]; + + AppState state = 4 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + description: "current state of the application"; + } + ]; + string name = 5 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "\"Console\""; + } + ]; + oneof config { + OIDCConfig oidc_config = 6; + APIConfig api_config = 7; + SAMLConfig saml_config = 8; + } +} + +enum AppState { + APP_STATE_UNSPECIFIED = 0; + APP_STATE_ACTIVE = 1; + APP_STATE_INACTIVE = 2; + APP_STATE_REMOVED = 3; +} + +enum AppSorting { + APP_SORT_BY_ID = 0; + APP_SORT_BY_NAME = 1; + APP_SORT_BY_STATE = 2; + APP_SORT_BY_CREATION_DATE = 3; + APP_SORT_BY_CHANGE_DATE = 4; +} + +message ApplicationSearchFilter { + oneof filter { + option (validate.required) = true; + ApplicationNameQuery name_filter = 1; + AppState state_filter = 2; + bool api_app_only = 3; + bool oidc_app_only = 4; + bool saml_app_only = 5; + } +} + +message ApplicationNameQuery { + string name = 1 [ + (validate.rules).string = {max_len: 200}, + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "\"Conso\"" + } + ]; + + zitadel.filter.v2.TextFilterMethod method = 2 [ + (validate.rules).enum.defined_only = true, + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + description: "defines which text equality method is used" + } + ]; +} diff --git a/proto/zitadel/app/v2beta/app_service.proto b/proto/zitadel/app/v2beta/app_service.proto new file mode 100644 index 0000000000..a881022caa --- /dev/null +++ b/proto/zitadel/app/v2beta/app_service.proto @@ -0,0 +1,788 @@ +syntax = "proto3"; + +package zitadel.app.v2beta; + +import "google/api/annotations.proto"; +import "google/api/field_behavior.proto"; +import "google/protobuf/duration.proto"; +import "google/protobuf/struct.proto"; +import "protoc-gen-openapiv2/options/annotations.proto"; +import "validate/validate.proto"; +import "zitadel/app/v2beta/login.proto"; +import "zitadel/app/v2beta/oidc.proto"; +import "zitadel/app/v2beta/api.proto"; +import "zitadel/app/v2beta/app.proto"; +import "google/protobuf/timestamp.proto"; +import "zitadel/protoc_gen_zitadel/v2/options.proto"; +import "zitadel/filter/v2/filter.proto"; + +option go_package = "github.com/zitadel/zitadel/pkg/grpc/app/v2beta;app"; + +option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_swagger) = { + info: { + title: "Application Service"; + version: "2.0-beta"; + description: "This API is intended to manage apps (SAML, OIDC, etc..) in a ZITADEL instance. This service is in beta state. It can AND will continue breaking until a stable version is released."; + contact:{ + name: "ZITADEL" + url: "https://zitadel.com" + email: "hi@zitadel.com" + } + license: { + name: "Apache 2.0", + url: "https://github.com/zitadel/zitadel/blob/main/LICENSING.md"; + }; + }; + schemes: HTTPS; + schemes: HTTP; + + consumes: "application/json"; + consumes: "application/grpc"; + + produces: "application/json"; + produces: "application/grpc"; + + consumes: "application/grpc-web+proto"; + produces: "application/grpc-web+proto"; + + host: "$CUSTOM-DOMAIN"; + base_path: "/"; + + external_docs: { + description: "Detailed information about ZITADEL", + url: "https://zitadel.com/docs" + } + security_definitions: { + security: { + key: "OAuth2"; + value: { + type: TYPE_OAUTH2; + flow: FLOW_ACCESS_CODE; + authorization_url: "$CUSTOM-DOMAIN/oauth/v2/authorize"; + token_url: "$CUSTOM-DOMAIN/oauth/v2/token"; + scopes: { + scope: { + key: "openid"; + value: "openid"; + } + scope: { + key: "urn:zitadel:iam:org:project:id:zitadel:aud"; + value: "urn:zitadel:iam:org:project:id:zitadel:aud"; + } + } + } + } + } + security: { + security_requirement: { + key: "OAuth2"; + value: { + scope: "openid"; + scope: "urn:zitadel:iam:org:project:id:zitadel:aud"; + } + } + } + responses: { + key: "403"; + value: { + description: "Returned when the user does not have permission to access the resource."; + schema: { + json_schema: { + ref: "#/definitions/rpcStatus"; + } + } + } + } + responses: { + key: "404"; + value: { + description: "Returned when the resource does not exist."; + schema: { + json_schema: { + ref: "#/definitions/rpcStatus"; + } + } + } + } +}; + +// Service to manage apps. +// The service provides methods to create, update, delete and list apps and app keys. +service AppService { + + // Create Application + // + // Create an application. The application can be OIDC, API or SAML type, based on the input. + // + // The user needs to have project.app.write permission + // + // Required permissions: + // - project.app.write + rpc CreateApplication(CreateApplicationRequest) returns (CreateApplicationResponse) { + option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { + responses: { + key: "200"; + value: { + description: "The created application"; + } + }; + }; + + option (google.api.http) = { + post: "/v2beta/applications" + body: "*" + }; + + option (zitadel.protoc_gen_zitadel.v2.options) = { + auth_option: { + permission: "authenticated" + } + }; + } + + // Update Application + // + // Changes the configuration of an OIDC, API or SAML type application, as well as + // the application name, based on the input provided. + // + // The user needs to have project.app.write permission + // + // Required permissions: + // - project.app.write + rpc UpdateApplication(UpdateApplicationRequest) returns (UpdateApplicationResponse) { + option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { + responses: { + key: "200"; + value: { + description: "The updated app."; + } + }; + }; + + option (google.api.http) = { + patch: "/v2beta/applications/{id}" + body: "*" + }; + + option (zitadel.protoc_gen_zitadel.v2.options) = { + auth_option: { + permission: "authenticated" + } + }; + } + + // Get Application + // + // Retrieves the application matching the provided ID. + // + // The user needs to have project.app.read permission + // + // Required permissions: + // - project.app.read + rpc GetApplication(GetApplicationRequest) returns (GetApplicationResponse) { + option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { + responses: { + key: "200"; + value: { + description: "The fetched app."; + } + }; + }; + + option (google.api.http) = { + get: "/v2beta/applications/{id}" + }; + + option (zitadel.protoc_gen_zitadel.v2.options) = { + auth_option: { + permission: "authenticated" + } + }; + } + + // Delete Application + // + // Deletes the application belonging to the input project and matching the provided + // application ID + // + // The user needs to have project.app.delete permission + // + // Required permissions: + // - project.app.delete + rpc DeleteApplication(DeleteApplicationRequest) returns (DeleteApplicationResponse) { + option (google.api.http) = { + delete: "/v2beta/applications/{id}" + }; + + option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { + responses: { + key: "200"; + value: { + description: "The time of deletion."; + } + }; + }; + + option (zitadel.protoc_gen_zitadel.v2.options) = { + auth_option: { + permission: "authenticated" + } + }; + } + + // Deactivate Application + // + // Deactivates the application belonging to the input project and matching the provided + // application ID + // + // The user needs to have project.app.write permission + // + // Required permissions: + // - project.app.write + rpc DeactivateApplication(DeactivateApplicationRequest) returns (DeactivateApplicationResponse) { + option (google.api.http) = { + post: "/v2beta/applications/{id}/deactivate" + body: "*" + }; + + option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { + responses: { + key: "200"; + value: { + description: "The time of deactivation."; + } + }; + }; + + option (zitadel.protoc_gen_zitadel.v2.options) = { + auth_option: { + permission: "authenticated" + } + }; + } + + // Reactivate Application + // + // Reactivates the application belonging to the input project and matching the provided + // application ID + // + // The user needs to have project.app.write permission + // + // Required permissions: + // - project.app.write + rpc ReactivateApplication(ReactivateApplicationRequest) returns (ReactivateApplicationResponse) { + option (google.api.http) = { + post: "/v2beta/applications/{id}/reactivate" + body: "*" + }; + + option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { + responses: { + key: "200"; + value: { + description: "The time of reactivation."; + } + }; + }; + + option (zitadel.protoc_gen_zitadel.v2.options) = { + auth_option: { + permission: "authenticated" + } + }; + } + + + // Regenerate Client Secret + // + // Regenerates the client secret of an API or OIDC application that belongs to the input project. + // + // The user needs to have project.app.write permission + // + // Required permissions: + // - project.app.write + rpc RegenerateClientSecret(RegenerateClientSecretRequest) returns (RegenerateClientSecretResponse) { + option (google.api.http) = { + post: "/v2beta/applications/{application_id}/generate_client_secret" + body: "*" + }; + + option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { + responses: { + key: "200"; + value: { + description: "The regenerated client secret."; + } + }; + }; + + option (zitadel.protoc_gen_zitadel.v2.options) = { + auth_option: { + permission: "authenticated" + } + }; + } + + // List Applications + // + // Returns a list of applications matching the input parameters that belong to the provided + // project. + // + // The result can be sorted by app id, name, creation date, change date or state. It can also + // be filtered by app state, app type and app name. + // + // The user needs to have project.app.read permission + // + // Required permissions: + // - project.app.read + rpc ListApplications(ListApplicationsRequest) returns (ListApplicationsResponse) { + option (google.api.http) = { + post: "/v2beta/applications/search" + body: "*" + }; + + option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { + responses: { + key: "200"; + value: { + description: "The matching applications"; + } + }; + }; + + option (zitadel.protoc_gen_zitadel.v2.options) = { + auth_option: { + permission: "authenticated" + } + }; + } +} + +message CreateApplicationRequest { + string project_id = 1 [(validate.rules).string = {min_len: 1, max_len: 200}]; + string id = 2 [(validate.rules).string = {max_len: 200}]; + string name = 3 [ + (validate.rules).string = {min_len: 1, max_len: 200}, + (google.api.field_behavior) = REQUIRED, + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + min_length: 1; + max_length: 200; + example: "\"MyApp\""; + } + ]; + oneof creation_request_type { + option (validate.required) = true; + CreateOIDCApplicationRequest oidc_request = 4; + CreateSAMLApplicationRequest saml_request = 5; + CreateAPIApplicationRequest api_request = 6; + } +} + +message CreateApplicationResponse { + string app_id = 1; + // The timestamp of the app creation. + google.protobuf.Timestamp creation_date = 2 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "\"2024-12-18T07:50:47.492Z\""; + } + ]; + + oneof creation_response_type { + CreateOIDCApplicationResponse oidc_response = 3; + CreateSAMLApplicationResponse saml_response = 4; + CreateAPIApplicationResponse api_response = 5; + } +} + +message CreateOIDCApplicationRequest { + // Callback URI of the authorization request where the code or tokens will be sent to + repeated string redirect_uris = 1 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "[\"http://localhost:4200/auth/callback\"]"; + description: "Callback URI of the authorization request where the code or tokens will be sent to"; + } + ]; + repeated OIDCResponseType response_types = 2 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + description: "Determines whether a code, id_token token or just id_token will be returned" + } + ]; + repeated OIDCGrantType grant_types = 3 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + description: "The flow type the application uses to gain access"; + } + ]; + OIDCAppType app_type = 4 [ + (validate.rules).enum = {defined_only: true}, + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + description: "Determines the paradigm of the application"; + } + ]; + OIDCAuthMethodType auth_method_type = 5 [ + (validate.rules).enum = {defined_only: true}, + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + description: "Defines how the application passes login credentials"; + } + ]; + + // ZITADEL will redirect to this link after a successful logout + repeated string post_logout_redirect_uris = 6 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "[\"http://localhost:4200/signedout\"]"; + description: "ZITADEL will redirect to this link after a successful logout"; + } + ]; + OIDCVersion version = 7 [(validate.rules).enum = {defined_only: true}]; + bool dev_mode = 8 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + description: "Used for development, some checks of the OIDC specification will not be checked."; + } + ]; + OIDCTokenType access_token_type = 9 [ + (validate.rules).enum = {defined_only: true}, + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + description: "Type of the access token returned from ZITADEL"; + } + ]; + bool access_token_role_assertion = 10 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + description: "Adds roles to the claims of the access token (only if type == JWT) even if they are not requested by scopes"; + } + ]; + bool id_token_role_assertion = 11 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + description: "Adds roles to the claims of the id token even if they are not requested by scopes"; + } + ]; + bool id_token_userinfo_assertion = 12 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + description: "Claims of profile, email, address and phone scopes are added to the id token even if an access token is issued. Attention this violates the OIDC specification"; + } + ]; + google.protobuf.Duration clock_skew = 13 [ + (validate.rules).duration = {gte: {}, lte: {seconds: 5}}, + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + description: "Used to compensate time difference of servers. Duration added to the \"exp\" claim and subtracted from \"iat\", \"auth_time\" and \"nbf\" claims"; + example: "\"1s\""; + } + ]; + repeated string additional_origins = 14 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "[\"scheme://localhost:8080\"]"; + description: "Additional origins (other than the redirect_uris) from where the API can be used, provided string has to be an origin (scheme://hostname[:port]) without path, query or fragment"; + } + ]; + bool skip_native_app_success_page = 15 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + description: "Skip the successful login page on native apps and directly redirect the user to the callback."; + } + ]; + string back_channel_logout_uri = 16 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "[\"https://example.com/auth/backchannel\"]"; + description: "ZITADEL will use this URI to notify the application about terminated session according to the OIDC Back-Channel Logout (https://openid.net/specs/openid-connect-backchannel-1_0.html)"; + } + ]; + LoginVersion login_version = 17 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + description: "Specify the preferred login UI, where the user is redirected to for authentication. If unset, the login UI is chosen by the instance default."; + } + ]; +} + +message CreateOIDCApplicationResponse { + string client_id = 1 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "\"1035496534033449\""; + description: "generated client id for this config"; + } + ]; + string client_secret = 2 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "\"gjoq34589uasgh\""; + description: "generated secret for this config"; + } + ]; + bool none_compliant = 3; + repeated OIDCLocalizedMessage compliance_problems = 4; +} + +message CreateSAMLApplicationRequest { + oneof metadata { + option (validate.required) = true; + bytes metadata_xml = 1 [(validate.rules).bytes.max_len = 500000]; + string metadata_url = 2 [(validate.rules).string.max_len = 200]; + } + LoginVersion login_version = 3 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + description: "Specify the preferred login UI, where the user is redirected to for authentication. If unset, the login UI is chosen by the instance default."; + } + ]; +} + +message CreateSAMLApplicationResponse {} + +message CreateAPIApplicationRequest { + APIAuthMethodType auth_method_type = 1 [(validate.rules).enum = {defined_only: true}]; +} + +message CreateAPIApplicationResponse { + string client_id = 1 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "\"3950723409029374\""; + description: "generated secret for this config"; + } + ]; + string client_secret = 2 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "\"gjoq34589uasgh\""; + description: "generated secret for this config"; + } + ]; +} + +message UpdateApplicationRequest { + string project_id = 1 [(validate.rules).string = {min_len: 1, max_len: 200}]; + string id = 2 [ + (validate.rules).string = {min_len: 1, max_len: 200}, + (google.api.field_behavior) = REQUIRED, + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + min_length: 1; + max_length: 200; + example: "\"45984352431\""; + } + ]; + string name = 3 [ + (validate.rules).string = {max_len: 200}, + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "\"MyApplicationName\""; + min_length: 1; + max_length: 200; + } + ]; + + oneof update_request_type { + UpdateSAMLApplicationConfigurationRequest saml_configuration_request = 4; + UpdateOIDCApplicationConfigurationRequest oidc_configuration_request = 5; + UpdateAPIApplicationConfigurationRequest api_configuration_request = 6; + } +} + +message UpdateApplicationResponse { + // The timestamp of the app update. + google.protobuf.Timestamp change_date = 2 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "\"2024-12-18T07:50:47.492Z\""; + } + ]; +} + +message UpdateSAMLApplicationConfigurationRequest { + oneof metadata { + option (validate.required) = true; + bytes metadata_xml = 1 [(validate.rules).bytes.max_len = 500000]; + string metadata_url = 2 [(validate.rules).string.max_len = 200]; + } + optional LoginVersion login_version = 3 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + description: "Specify the preferred login UI, where the user is redirected to for authentication. If unset, the login UI is chosen by the instance default."; + } + ]; +} + +message UpdateOIDCApplicationConfigurationRequest { + repeated string redirect_uris = 1 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "[\"http://localhost:4200/auth/callback\"]"; + description: "Callback URI of the authorization request where the code or tokens will be sent to"; + } + ]; + repeated OIDCResponseType response_types = 2 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + description: "Determines whether a code, id_token token or just id_token will be returned" + } + ]; + repeated OIDCGrantType grant_types = 3 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + description: "The flow type the application uses to gain access"; + } + ]; + optional OIDCAppType app_type = 4 [ + (validate.rules).enum = {defined_only: true}, + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + description: "Determines the paradigm of the application"; + } + ]; + optional OIDCAuthMethodType auth_method_type = 5 [ + (validate.rules).enum = {defined_only: true}, + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + description: "Defines how the application passes login credentials"; + } + ]; + repeated string post_logout_redirect_uris = 6 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "[\"http://localhost:4200/signedout\"]"; + description: "ZITADEL will redirect to this link after a successful logout"; + } + ]; + optional OIDCVersion version = 7 [(validate.rules).enum = {defined_only: true}]; + optional bool dev_mode = 8 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + description: "Used for development, some checks of the OIDC specification will not be checked."; + } + ]; + optional OIDCTokenType access_token_type = 9 [ + (validate.rules).enum = {defined_only: true}, + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + description: "Type of the access token returned from ZITADEL"; + } + ]; + optional bool access_token_role_assertion = 10 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + description: "Adds roles to the claims of the access token (only if type == JWT) even if they are not requested by scopes"; + } + ]; + optional bool id_token_role_assertion = 11 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + description: "Adds roles to the claims of the id token even if they are not requested by scopes"; + } + ]; + optional bool id_token_userinfo_assertion = 12 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + description: "Claims of profile, email, address and phone scopes are added to the id token even if an access token is issued. Attention this violates the OIDC specification"; + } + ]; + optional google.protobuf.Duration clock_skew = 13 [ + (validate.rules).duration = {gte: {}, lte: {seconds: 5}}, + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + description: "Used to compensate time difference of servers. Duration added to the \"exp\" claim and subtracted from \"iat\", \"auth_time\" and \"nbf\" claims"; + example: "\"1s\""; + } + ]; + repeated string additional_origins = 14 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "[\"scheme://localhost:8080\"]"; + description: "Additional origins (other than the redirect_uris) from where the API can be used, provided string has to be an origin (scheme://hostname[:port]) without path, query or fragment"; + } + ]; + optional bool skip_native_app_success_page = 15 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + description: "Skip the successful login page on native apps and directly redirect the user to the callback."; + } + ]; + optional string back_channel_logout_uri = 16 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "[\"https://example.com/auth/backchannel\"]"; + description: "ZITADEL will use this URI to notify the application about terminated session according to the OIDC Back-Channel Logout (https://openid.net/specs/openid-connect-backchannel-1_0.html)"; + } + ]; + optional LoginVersion login_version = 17 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + description: "Specify the preferred login UI, where the user is redirected to for authentication. If unset, the login UI is chosen by the instance default."; + } + ]; +} + +message UpdateAPIApplicationConfigurationRequest { + APIAuthMethodType auth_method_type = 1 [(validate.rules).enum = {defined_only: true}]; +} + +message GetApplicationRequest { + string id = 1 [ + (validate.rules).string = {min_len: 1, max_len: 200}, + (google.api.field_behavior) = REQUIRED, + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + min_length: 1; + max_length: 200; + example: "\"45984352431\""; + } + ]; +} + +message GetApplicationResponse { + Application app = 1; +} + +message DeleteApplicationRequest { + string project_id = 1 [(validate.rules).string = {min_len: 1, max_len: 200}]; + string id = 2 [(validate.rules).string = {min_len: 1, max_len: 200}]; +} + +message DeleteApplicationResponse { + google.protobuf.Timestamp deletion_date = 1 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "\"2025-01-23T10:34:18.051Z\""; + } + ]; +} + +message DeactivateApplicationRequest{ + string project_id = 1 [(validate.rules).string = {min_len: 1, max_len: 200}]; + string id = 2 [(validate.rules).string = {min_len: 1, max_len: 200}]; +} + +message DeactivateApplicationResponse{ + google.protobuf.Timestamp deactivation_date = 1 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "\"2025-01-23T10:34:18.051Z\""; + } + ]; +} + +message ReactivateApplicationRequest{ + string project_id = 1 [(validate.rules).string = {min_len: 1, max_len: 200}]; + string id = 2 [(validate.rules).string = {min_len: 1, max_len: 200}]; +} + +message ReactivateApplicationResponse{ + google.protobuf.Timestamp reactivation_date = 1 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "\"2025-01-23T10:34:18.051Z\""; + } + ]; +} + +message RegenerateClientSecretRequest{ + string project_id = 1 [(validate.rules).string = {min_len: 1, max_len: 200}]; + string application_id = 2 [(validate.rules).string = {min_len: 1, max_len: 200}]; + oneof app_type { + option (validate.required) = true; + bool is_oidc = 3; + bool is_api = 4; + } +} + +message RegenerateClientSecretResponse{ + string client_secret = 1 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "\"gjoq34589uasgh\""; + description: "generated secret for the client"; + } + ]; + + // The timestamp of the creation of the new client secret + google.protobuf.Timestamp creation_date = 2 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "\"2025-01-23T10:34:18.051Z\""; + } + ]; +} + +message ListApplicationsRequest { + string project_id = 1 [(validate.rules).string = {min_len: 1, max_len: 200}]; + + // Pagination and sorting. + zitadel.filter.v2.PaginationRequest pagination = 2; + + //criteria the client is looking for + repeated ApplicationSearchFilter filters = 3; + + AppSorting sorting_column = 4; +} + +message ListApplicationsResponse { + repeated Application applications = 1; + + // Contains the total number of apps matching the query and the applied limit. + zitadel.filter.v2.PaginationResponse pagination = 2; +} \ No newline at end of file diff --git a/proto/zitadel/app/v2beta/login.proto b/proto/zitadel/app/v2beta/login.proto new file mode 100644 index 0000000000..567b4b5167 --- /dev/null +++ b/proto/zitadel/app/v2beta/login.proto @@ -0,0 +1,18 @@ +syntax = "proto3"; + +package zitadel.app.v2beta; +option go_package = "github.com/zitadel/zitadel/pkg/grpc/app/v2beta;app"; + +message LoginVersion { + oneof version { + LoginV1 login_v1 = 1; + LoginV2 login_v2 = 2; + } +} + +message LoginV1 {} + +message LoginV2 { + // Optionally specify a base uri of the login UI. If unspecified the default URI will be used. + optional string base_uri = 1; +} \ No newline at end of file diff --git a/proto/zitadel/app/v2beta/oidc.proto b/proto/zitadel/app/v2beta/oidc.proto new file mode 100644 index 0000000000..7cfd1dcc43 --- /dev/null +++ b/proto/zitadel/app/v2beta/oidc.proto @@ -0,0 +1,166 @@ +syntax = "proto3"; + +package zitadel.app.v2beta; +import "zitadel/app/v2beta/login.proto"; +import "protoc-gen-openapiv2/options/annotations.proto"; +import "google/protobuf/duration.proto"; + +option go_package = "github.com/zitadel/zitadel/pkg/grpc/app/v2beta;app"; + +message OIDCLocalizedMessage { + string key = 1; + string localized_message = 2; +} + +enum OIDCResponseType { + OIDC_RESPONSE_TYPE_UNSPECIFIED = 0; + OIDC_RESPONSE_TYPE_CODE = 1; + OIDC_RESPONSE_TYPE_ID_TOKEN = 2; + OIDC_RESPONSE_TYPE_ID_TOKEN_TOKEN = 3; +} + +enum OIDCGrantType{ + OIDC_GRANT_TYPE_AUTHORIZATION_CODE = 0; + OIDC_GRANT_TYPE_IMPLICIT = 1; + OIDC_GRANT_TYPE_REFRESH_TOKEN = 2; + OIDC_GRANT_TYPE_DEVICE_CODE = 3; + OIDC_GRANT_TYPE_TOKEN_EXCHANGE = 4; +} + +enum OIDCAppType { + OIDC_APP_TYPE_WEB = 0; + OIDC_APP_TYPE_USER_AGENT = 1; + OIDC_APP_TYPE_NATIVE = 2; +} + +enum OIDCAuthMethodType { + OIDC_AUTH_METHOD_TYPE_BASIC = 0; + OIDC_AUTH_METHOD_TYPE_POST = 1; + OIDC_AUTH_METHOD_TYPE_NONE = 2; + OIDC_AUTH_METHOD_TYPE_PRIVATE_KEY_JWT = 3; +} + +enum OIDCVersion { + OIDC_VERSION_1_0 = 0; +} + +enum OIDCTokenType { + OIDC_TOKEN_TYPE_BEARER = 0; + OIDC_TOKEN_TYPE_JWT = 1; +} + +message OIDCConfig { + repeated string redirect_uris = 1 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "[\"https://console.zitadel.ch/auth/callback\"]"; + description: "Callback URI of the authorization request where the code or tokens will be sent to"; + } + ]; + repeated OIDCResponseType response_types = 2 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + description: "Determines whether a code, id_token token or just id_token will be returned" + } + ]; + repeated OIDCGrantType grant_types = 3 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + description: "The flow type the application uses to gain access"; + } + ]; + OIDCAppType app_type = 4 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + description: "determines the paradigm of the application"; + } + ]; + string client_id = 5 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "\"69629023906488334@ZITADEL\""; + description: "generated oauth2/oidc client id"; + } + ]; + OIDCAuthMethodType auth_method_type = 6 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + description: "defines how the application passes login credentials"; + } + ]; + repeated string post_logout_redirect_uris = 7 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "[\"https://console.zitadel.ch/logout\"]"; + description: "ZITADEL will redirect to this link after a successful logout"; + } + ]; + OIDCVersion version = 8 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + description: "the OIDC version used by the application"; + } + ]; + bool none_compliant = 9 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + description: "specifies whether the config is OIDC compliant. A production configuration SHOULD be compliant"; + } + ]; + repeated OIDCLocalizedMessage compliance_problems = 10 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + description: "lists the problems for non-compliancy"; + } + ]; + bool dev_mode = 11 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + description: "used for development"; + } + ]; + OIDCTokenType access_token_type = 12 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + description: "type of the access token returned from ZITADEL"; + } + ]; + bool access_token_role_assertion = 13 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + description: "adds roles to the claims of the access token (only if type == JWT) even if they are not requested by scopes"; + } + ]; + bool id_token_role_assertion = 14 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + description: "adds roles to the claims of the id token even if they are not requested by scopes"; + } + ]; + bool id_token_userinfo_assertion = 15 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + description: "claims of profile, email, address and phone scopes are added to the id token even if an access token is issued. Attention this violates the OIDC specification"; + } + ]; + google.protobuf.Duration clock_skew = 16 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + description: "Used to compensate time difference of servers. Duration added to the \"exp\" claim and subtracted from \"iat\", \"auth_time\" and \"nbf\" claims"; + // min: "0s"; + // max: "5s"; + } + ]; + repeated string additional_origins = 17 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "[\"https://console.zitadel.ch/auth/callback\"]"; + description: "additional origins (other than the redirect_uris) from where the API can be used"; + } + ]; + repeated string allowed_origins = 18 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "[\"https://console.zitadel.ch/auth/callback\"]"; + description: "all allowed origins from where the API can be used"; + } + ]; + bool skip_native_app_success_page = 19 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + description: "Skip the successful login page on native apps and directly redirect the user to the callback."; + } + ]; + string back_channel_logout_uri = 20 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "[\"https://example.com/auth/backchannel\"]"; + description: "ZITADEL will use this URI to notify the application about terminated session according to the OIDC Back-Channel Logout (https://openid.net/specs/openid-connect-backchannel-1_0.html)"; + } + ]; + LoginVersion login_version = 21 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + description: "Specify the preferred login UI, where the user is redirected to for authentication. If unset, the login UI is chosen by the instance default."; + } + ]; +} \ No newline at end of file diff --git a/proto/zitadel/app/v2beta/saml.proto b/proto/zitadel/app/v2beta/saml.proto new file mode 100644 index 0000000000..7c85447880 --- /dev/null +++ b/proto/zitadel/app/v2beta/saml.proto @@ -0,0 +1,20 @@ +syntax = "proto3"; + +package zitadel.app.v2beta; + +import "zitadel/app/v2beta/login.proto"; +import "protoc-gen-openapiv2/options/annotations.proto"; + +option go_package = "github.com/zitadel/zitadel/pkg/grpc/app/v2beta;app"; + +message SAMLConfig { + oneof metadata{ + bytes metadata_xml = 1; + string metadata_url = 2; + } + LoginVersion login_version = 3 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + description: "Specify the preferred login UI, where the user is redirected to for authentication. If unset, the login UI is chosen by the instance default."; + } + ]; +} \ No newline at end of file diff --git a/proto/zitadel/management.proto b/proto/zitadel/management.proto index d633fbe8c5..74d5dcf60b 100644 --- a/proto/zitadel/management.proto +++ b/proto/zitadel/management.proto @@ -3287,6 +3287,7 @@ service ManagementService { }; } + // Deprecated: Use [GetApplication](/apis/resources/application_service_v2/application-service-get-application.api.mdx) instead to fetch an app rpc GetAppByID(GetAppByIDRequest) returns (GetAppByIDResponse) { option (google.api.http) = { get: "/projects/{project_id}/apps/{app_id}" @@ -3309,9 +3310,11 @@ service ManagementService { required: false; }; }; + deprecated: true; }; } + // Deprecated: Use [ListApplications](/apis/resources/application_service_v2/application-service-list-applications.api.mdx) instead to list applications rpc ListApps(ListAppsRequest) returns (ListAppsResponse) { option (google.api.http) = { post: "/projects/{project_id}/apps/_search" @@ -3335,6 +3338,7 @@ service ManagementService { required: false; }; }; + deprecated: true; }; } @@ -3363,6 +3367,7 @@ service ManagementService { }; } + // Deprecated: Use [CreateApplication](/apis/resources/application_service_v2/application-service-create-application.api.mdx) instead to create an OIDC application rpc AddOIDCApp(AddOIDCAppRequest) returns (AddOIDCAppResponse) { option (google.api.http) = { post: "/projects/{project_id}/apps/oidc" @@ -3386,62 +3391,74 @@ service ManagementService { required: false; }; }; + deprecated: true; }; } - rpc AddSAMLApp(AddSAMLAppRequest) returns (AddSAMLAppResponse) { - option (google.api.http) = { - post: "/projects/{project_id}/apps/saml" - body: "*" - }; - - option (zitadel.v1.auth_option) = { - permission: "project.app.write" - check_field_name: "ProjectId" - }; - - option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { - tags: "Applications"; - summary: "Create Application (SAML)"; - description: "Create a new SAML client. Returns an entity ID" - parameters: { - headers: { - name: "x-zitadel-orgid"; - description: "The default is always the organization of the requesting user. If you like to change/get objects of another organization include the header. Make sure the requesting user has permission to access the requested data."; - type: STRING, - required: false; - }; - }; - }; - } - - rpc AddAPIApp(AddAPIAppRequest) returns (AddAPIAppResponse) { - option (google.api.http) = { - post: "/projects/{project_id}/apps/api" - body: "*" - }; - - option (zitadel.v1.auth_option) = { - permission: "project.app.write" - check_field_name: "ProjectId" + // Deprecated: Use [CreateApplication](/apis/resources/application_service_v2/application-service-create-application.api.mdx) instead to create a SAML application + rpc AddSAMLApp(AddSAMLAppRequest) returns (AddSAMLAppResponse) { + option (google.api.http) = { + post: "/projects/{project_id}/apps/saml" + body: "*" }; - option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { - tags: "Applications"; - summary: "Create Application (API)"; - description: "Create a new API client. The client id will be generated and returned in the response. Depending on the chosen configuration also a secret will be generated and returned." - parameters: { - headers: { - name: "x-zitadel-orgid"; - description: "The default is always the organization of the requesting user. If you like to change/get objects of another organization include the header. Make sure the requesting user has permission to access the requested data."; - type: STRING, - required: false; - }; - }; - }; + option (zitadel.v1.auth_option) = { + permission: "project.app.write" + check_field_name: "ProjectId" + }; + + option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { + tags: "Applications"; + summary: "Create Application (SAML)"; + description: "Create a new SAML client. Returns an entity ID" + parameters: { + headers: { + name: "x-zitadel-orgid"; + description: "The default is always the organization of the requesting user. If you like to change/get objects of another organization include the header. Make sure the requesting user has permission to access the requested data."; + type: STRING, + required: false; + }; + }; + deprecated: true; + }; + } + + // Create Application (API) + // + // Create a new API client. The client id will be generated and returned in the response. + // Depending on the chosen configuration also a secret will be generated and returned. + // + // Deprecated: Use [CreateApplication](/apis/resources/application_service_v2/application-service-create-application.api.mdx) instead to create an API application + rpc AddAPIApp(AddAPIAppRequest) returns (AddAPIAppResponse) { + option (google.api.http) = { + post: "/projects/{project_id}/apps/api" + body: "*" + }; + + option (zitadel.v1.auth_option) = { + permission: "project.app.write" + check_field_name: "ProjectId" + }; + + option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { + tags: "Applications"; + summary: "Create Application (API)"; + description: "Create a new API client. The client id will be generated and returned in the response. Depending on the chosen configuration also a secret will be generated and returned." + parameters: { + headers: { + name: "x-zitadel-orgid"; + description: "The default is always the organization of the requesting user. If you like to change/get objects of another organization include the header. Make sure the requesting user has permission to access the requested data."; + type: STRING, + required: false; + }; + }; + deprecated: true; + }; } // Changes application + // + // Deprecated: Use [PatchApplication](/apis/resources/application_service_v2/application-service-patch-application.api.mdx) instead to update the generic params of an app rpc UpdateApp(UpdateAppRequest) returns (UpdateAppResponse) { option (google.api.http) = { put: "/projects/{project_id}/apps/{app_id}" @@ -3465,9 +3482,11 @@ service ManagementService { required: false; }; }; + deprecated: true; }; } + // Deprecated: Use [PatchApplication](/apis/resources/application_service_v2/application-service-patch-application.api.mdx) instead to update the config of an OIDC app rpc UpdateOIDCAppConfig(UpdateOIDCAppConfigRequest) returns (UpdateOIDCAppConfigResponse) { option (google.api.http) = { put: "/projects/{project_id}/apps/{app_id}/oidc_config" @@ -3491,61 +3510,67 @@ service ManagementService { required: false; }; }; + deprecated: true }; } - rpc UpdateSAMLAppConfig(UpdateSAMLAppConfigRequest) returns (UpdateSAMLAppConfigResponse) { - option (google.api.http) = { - put: "/projects/{project_id}/apps/{app_id}/saml_config" - body: "*" - }; - - option (zitadel.v1.auth_option) = { - permission: "project.app.write" - check_field_name: "ProjectId" - }; - - option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { - tags: "Applications"; - summary: "Update SAML Application Config"; - description: "Update the SAML specific configuration of an application." - parameters: { - headers: { - name: "x-zitadel-orgid"; - description: "The default is always the organization of the requesting user. If you like to change/get objects of another organization include the header. Make sure the requesting user has permission to access the requested data."; - type: STRING, - required: false; - }; - }; - }; - } - - rpc UpdateAPIAppConfig(UpdateAPIAppConfigRequest) returns (UpdateAPIAppConfigResponse) { - option (google.api.http) = { - put: "/projects/{project_id}/apps/{app_id}/api_config" - body: "*" - }; + // Deprecated: Use [PatchApplication](/apis/resources/application_service_v2/application-service-patch-application.api.mdx) instead to update the config of a SAML app + rpc UpdateSAMLAppConfig(UpdateSAMLAppConfigRequest) returns (UpdateSAMLAppConfigResponse) { + option (google.api.http) = { + put: "/projects/{project_id}/apps/{app_id}/saml_config" + body: "*" + }; option (zitadel.v1.auth_option) = { - permission: "project.app.write" - check_field_name: "ProjectId" + permission: "project.app.write" + check_field_name: "ProjectId" }; - option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { - tags: "Applications"; - summary: "Update API Application Config"; - description: "Update the OIDC-specific configuration of an application." - parameters: { - headers: { - name: "x-zitadel-orgid"; - description: "The default is always the organization of the requesting user. If you like to change/get objects of another organization include the header. Make sure the requesting user has permission to access the requested data."; - type: STRING, - required: false; - }; - }; - }; + option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { + tags: "Applications"; + summary: "Update SAML Application Config"; + description: "Update the SAML specific configuration of an application." + parameters: { + headers: { + name: "x-zitadel-orgid"; + description: "The default is always the organization of the requesting user. If you like to change/get objects of another organization include the header. Make sure the requesting user has permission to access the requested data."; + type: STRING, + required: false; + }; + }; + deprecated: true; + }; } + // Deprecated: Use [PatchApplication](/apis/resources/application_service_v2/application-service-patch-application.api.mdx) instead to update the config of an API app + rpc UpdateAPIAppConfig(UpdateAPIAppConfigRequest) returns (UpdateAPIAppConfigResponse) { + option (google.api.http) = { + put: "/projects/{project_id}/apps/{app_id}/api_config" + body: "*" + }; + + option (zitadel.v1.auth_option) = { + permission: "project.app.write" + check_field_name: "ProjectId" + }; + + option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { + tags: "Applications"; + summary: "Update API Application Config"; + description: "Update the OIDC-specific configuration of an application." + parameters: { + headers: { + name: "x-zitadel-orgid"; + description: "The default is always the organization of the requesting user. If you like to change/get objects of another organization include the header. Make sure the requesting user has permission to access the requested data."; + type: STRING, + required: false; + }; + }; + deprecated: true; + }; + } + + // Deprecated: Use [DeactivateApplication](/apis/resources/application_service_v2/application-service-deactivate-application.api.mdx) instead to deactivate an app rpc DeactivateApp(DeactivateAppRequest) returns (DeactivateAppResponse) { option (google.api.http) = { post: "/projects/{project_id}/apps/{app_id}/_deactivate" @@ -3569,9 +3594,11 @@ service ManagementService { required: false; }; }; + deprecated: true; }; } + // Deprecated: Use [ReactivateApplication](/apis/resources/application_service_v2/application-service-reactivate-application.api.mdx) instead to reactivate an app rpc ReactivateApp(ReactivateAppRequest) returns (ReactivateAppResponse) { option (google.api.http) = { post: "/projects/{project_id}/apps/{app_id}/_reactivate" @@ -3595,9 +3622,11 @@ service ManagementService { required: false; }; }; + deprecated: true; }; } + // Deprecated: Use [DeleteApplication](/apis/resources/application_service_v2/application-service-delete-application.api.mdx) instead to delete an app rpc RemoveApp(RemoveAppRequest) returns (RemoveAppResponse) { option (google.api.http) = { delete: "/projects/{project_id}/apps/{app_id}" @@ -3620,9 +3649,11 @@ service ManagementService { required: false; }; }; + deprecated: true; }; } + // Deprecated: Use [RegenerateClientSecret](/apis/resources/application_service_v2/application-service-regenerate-client-secret.api.mdx) instead to regenerate an OIDC app client secret rpc RegenerateOIDCClientSecret(RegenerateOIDCClientSecretRequest) returns (RegenerateOIDCClientSecretResponse) { option (google.api.http) = { post: "/projects/{project_id}/apps/{app_id}/oidc_config/_generate_client_secret" @@ -3646,9 +3677,11 @@ service ManagementService { required: false; }; }; + deprecated: true; }; } + // Deprecated: Use [RegenerateClientSecret](/apis/resources/application_service_v2/application-service-regenerate-client-secret.api.mdx) instead to regenerate an API app client secret rpc RegenerateAPIClientSecret(RegenerateAPIClientSecretRequest) returns (RegenerateAPIClientSecretResponse) { option (google.api.http) = { post: "/projects/{project_id}/apps/{app_id}/api_config/_generate_client_secret" @@ -3672,6 +3705,7 @@ service ManagementService { required: false; }; }; + deprecated: true; }; }