From 8d5f92f80af30a3d20bf0eb3be59dad7d7aab94c Mon Sep 17 00:00:00 2001 From: Livio Spring Date: Fri, 17 Oct 2025 11:21:01 +0200 Subject: [PATCH] feat(api): move organization settings endpoints to v2 (#10910) # Which Problems Are Solved As part of our efforts to simplify the structure and versions of our APIs, were moving all existing v2beta endpoints to v2 and deprecate them. They will be removed in Zitadel V5. # How the Problems Are Solved - This PR adds the organization settings endpoints to the settings service v2. This was split from #10909, since that PR targets v4.x and the corresponding feature is not yet available in v4.x, but only v5. - The comments and have been improved and, where not already done, moved from swagger annotations to proto. # Additional Changes None # Additional Context - relates to #10909 - relates to #10772 --- cmd/start/start.go | 2 +- .../v2/integration_test/query_test.go | 265 +++++++++++++++++ .../v2/integration_test/settings_test.go | 278 ++++++++++++++++++ internal/api/grpc/settings/v2/query.go | 103 +++++++ internal/api/grpc/settings/v2/server.go | 12 +- internal/api/grpc/settings/v2/settings.go | 29 ++ .../grpc/settings/v2/settings_converter.go | 7 + .../settings/v2/organization_settings.proto | 58 ++++ .../settings/v2/settings_service.proto | 152 ++++++++++ 9 files changed, 903 insertions(+), 3 deletions(-) create mode 100644 proto/zitadel/settings/v2/organization_settings.proto diff --git a/cmd/start/start.go b/cmd/start/start.go index db4fafe6b0f..f6e9ee0038f 100644 --- a/cmd/start/start.go +++ b/cmd/start/start.go @@ -514,7 +514,7 @@ func startAPIs( if err := apis.RegisterService(ctx, session_v2.CreateServer(commands, queries, permissionCheck)); err != nil { return nil, err } - if err := apis.RegisterService(ctx, settings_v2.CreateServer(commands, queries)); err != nil { + if err := apis.RegisterService(ctx, settings_v2.CreateServer(config.SystemDefaults, commands, queries, permissionCheck)); err != nil { return nil, err } if err := apis.RegisterService(ctx, org_v2.CreateServer(commands, queries, permissionCheck)); err != nil { diff --git a/internal/api/grpc/settings/v2/integration_test/query_test.go b/internal/api/grpc/settings/v2/integration_test/query_test.go index cc7d2cebe77..f0bca4a4fe3 100644 --- a/internal/api/grpc/settings/v2/integration_test/query_test.go +++ b/internal/api/grpc/settings/v2/integration_test/query_test.go @@ -16,6 +16,7 @@ import ( "google.golang.org/protobuf/types/known/timestamppb" "github.com/zitadel/zitadel/internal/integration" + "github.com/zitadel/zitadel/pkg/grpc/filter/v2" "github.com/zitadel/zitadel/pkg/grpc/idp" idp_pb "github.com/zitadel/zitadel/pkg/grpc/idp/v2" object_pb "github.com/zitadel/zitadel/pkg/grpc/object/v2" @@ -427,3 +428,267 @@ func TestServer_GetHostedLoginTranslation(t *testing.T) { }) } } + +func TestServer_ListOrganizationSettings(t *testing.T) { + instance := integration.NewInstance(CTX) + iamOwnerCtx := instance.WithAuthorizationToken(CTX, integration.UserTypeIAMOwner) + + type args struct { + ctx context.Context + dep func(*settings.ListOrganizationSettingsRequest, *settings.ListOrganizationSettingsResponse) + req *settings.ListOrganizationSettingsRequest + } + tests := []struct { + name string + args args + want *settings.ListOrganizationSettingsResponse + wantErr bool + }{ + { + name: "list by id, unauthenticated", + args: args{ + ctx: CTX, + dep: func(request *settings.ListOrganizationSettingsRequest, response *settings.ListOrganizationSettingsResponse) { + orgResp := instance.CreateOrganization(iamOwnerCtx, integration.OrganizationName(), integration.Email()) + instance.SetOrganizationSettings(iamOwnerCtx, t, orgResp.GetOrganizationId(), true) + + request.Filters[0].Filter = &settings.OrganizationSettingsSearchFilter_InOrganizationIdsFilter{ + InOrganizationIdsFilter: &filter.InIDsFilter{ + Ids: []string{orgResp.GetOrganizationId()}, + }, + } + }, + req: &settings.ListOrganizationSettingsRequest{ + Filters: []*settings.OrganizationSettingsSearchFilter{{}}, + }, + }, + wantErr: true, + }, + { + name: "list by id, no permission", + args: args{ + ctx: instance.WithAuthorizationToken(CTX, integration.UserTypeNoPermission), + dep: func(request *settings.ListOrganizationSettingsRequest, response *settings.ListOrganizationSettingsResponse) { + orgResp := instance.CreateOrganization(iamOwnerCtx, integration.OrganizationName(), integration.Email()) + instance.SetOrganizationSettings(iamOwnerCtx, t, orgResp.GetOrganizationId(), true) + + request.Filters[0].Filter = &settings.OrganizationSettingsSearchFilter_InOrganizationIdsFilter{ + InOrganizationIdsFilter: &filter.InIDsFilter{ + Ids: []string{orgResp.GetOrganizationId()}, + }, + } + }, + req: &settings.ListOrganizationSettingsRequest{ + Filters: []*settings.OrganizationSettingsSearchFilter{{}}, + }, + }, + want: &settings.ListOrganizationSettingsResponse{ + Pagination: &filter.PaginationResponse{ + TotalResult: 1, + AppliedLimit: 100, + }, + OrganizationSettings: []*settings.OrganizationSettings{}, + }, + }, + { + name: "list by id, missing permission", + args: args{ + ctx: instance.WithAuthorizationToken(CTX, integration.UserTypeOrgOwner), + dep: func(request *settings.ListOrganizationSettingsRequest, response *settings.ListOrganizationSettingsResponse) { + orgResp := instance.CreateOrganization(iamOwnerCtx, integration.OrganizationName(), integration.Email()) + instance.SetOrganizationSettings(iamOwnerCtx, t, orgResp.GetOrganizationId(), true) + + request.Filters[0].Filter = &settings.OrganizationSettingsSearchFilter_InOrganizationIdsFilter{ + InOrganizationIdsFilter: &filter.InIDsFilter{ + Ids: []string{orgResp.GetOrganizationId()}, + }, + } + }, + req: &settings.ListOrganizationSettingsRequest{ + Filters: []*settings.OrganizationSettingsSearchFilter{{}}, + }, + }, + want: &settings.ListOrganizationSettingsResponse{ + Pagination: &filter.PaginationResponse{ + TotalResult: 1, + AppliedLimit: 100, + }, + OrganizationSettings: []*settings.OrganizationSettings{}, + }, + }, + { + name: "list, not found", + args: args{ + ctx: iamOwnerCtx, + + req: &settings.ListOrganizationSettingsRequest{ + Filters: []*settings.OrganizationSettingsSearchFilter{{ + Filter: &settings.OrganizationSettingsSearchFilter_InOrganizationIdsFilter{ + InOrganizationIdsFilter: &filter.InIDsFilter{ + Ids: []string{"notexisting"}, + }, + }, + }}, + }, + }, + want: &settings.ListOrganizationSettingsResponse{ + Pagination: &filter.PaginationResponse{ + TotalResult: 0, + AppliedLimit: 100, + }, + OrganizationSettings: []*settings.OrganizationSettings{}, + }, + }, + { + name: "list single id", + args: args{ + ctx: iamOwnerCtx, + dep: func(request *settings.ListOrganizationSettingsRequest, response *settings.ListOrganizationSettingsResponse) { + orgResp := instance.CreateOrganization(iamOwnerCtx, integration.OrganizationName(), integration.Email()) + settingsResp := instance.SetOrganizationSettings(iamOwnerCtx, t, orgResp.GetOrganizationId(), true) + + request.Filters[0].Filter = &settings.OrganizationSettingsSearchFilter_InOrganizationIdsFilter{ + InOrganizationIdsFilter: &filter.InIDsFilter{ + Ids: []string{orgResp.GetOrganizationId()}, + }, + } + response.OrganizationSettings[0] = &settings.OrganizationSettings{ + OrganizationId: orgResp.GetOrganizationId(), + CreationDate: settingsResp.GetSetDate(), + ChangeDate: settingsResp.GetSetDate(), + OrganizationScopedUsernames: true, + } + }, + req: &settings.ListOrganizationSettingsRequest{ + Filters: []*settings.OrganizationSettingsSearchFilter{{}}, + }, + }, + want: &settings.ListOrganizationSettingsResponse{ + Pagination: &filter.PaginationResponse{ + TotalResult: 1, + AppliedLimit: 100, + }, + OrganizationSettings: []*settings.OrganizationSettings{{}}, + }, + }, + { + name: "list multiple id", + args: args{ + ctx: iamOwnerCtx, + dep: func(request *settings.ListOrganizationSettingsRequest, response *settings.ListOrganizationSettingsResponse) { + orgResp1 := instance.CreateOrganization(iamOwnerCtx, integration.OrganizationName(), integration.Email()) + settingsResp1 := instance.SetOrganizationSettings(iamOwnerCtx, t, orgResp1.GetOrganizationId(), true) + orgResp2 := instance.CreateOrganization(iamOwnerCtx, integration.OrganizationName(), integration.Email()) + settingsResp2 := instance.SetOrganizationSettings(iamOwnerCtx, t, orgResp2.GetOrganizationId(), true) + orgResp3 := instance.CreateOrganization(iamOwnerCtx, integration.OrganizationName(), integration.Email()) + settingsResp3 := instance.SetOrganizationSettings(iamOwnerCtx, t, orgResp3.GetOrganizationId(), true) + + request.Filters[0].Filter = &settings.OrganizationSettingsSearchFilter_InOrganizationIdsFilter{ + InOrganizationIdsFilter: &filter.InIDsFilter{ + Ids: []string{orgResp1.GetOrganizationId(), orgResp2.GetOrganizationId(), orgResp3.GetOrganizationId()}, + }, + } + response.OrganizationSettings[2] = &settings.OrganizationSettings{ + OrganizationId: orgResp1.GetOrganizationId(), + CreationDate: settingsResp1.GetSetDate(), + ChangeDate: settingsResp1.GetSetDate(), + OrganizationScopedUsernames: true, + } + response.OrganizationSettings[1] = &settings.OrganizationSettings{ + OrganizationId: orgResp2.GetOrganizationId(), + CreationDate: settingsResp2.GetSetDate(), + ChangeDate: settingsResp2.GetSetDate(), + OrganizationScopedUsernames: true, + } + response.OrganizationSettings[0] = &settings.OrganizationSettings{ + OrganizationId: orgResp3.GetOrganizationId(), + CreationDate: settingsResp3.GetSetDate(), + ChangeDate: settingsResp3.GetSetDate(), + OrganizationScopedUsernames: true, + } + }, + req: &settings.ListOrganizationSettingsRequest{ + Filters: []*settings.OrganizationSettingsSearchFilter{{}}, + }, + }, + want: &settings.ListOrganizationSettingsResponse{ + Pagination: &filter.PaginationResponse{ + TotalResult: 3, + AppliedLimit: 100, + }, + OrganizationSettings: []*settings.OrganizationSettings{{}, {}, {}}, + }, + }, + { + name: "list multiple id, only org scoped usernames", + args: args{ + ctx: iamOwnerCtx, + dep: func(request *settings.ListOrganizationSettingsRequest, response *settings.ListOrganizationSettingsResponse) { + orgResp1 := instance.CreateOrganization(iamOwnerCtx, integration.OrganizationName(), integration.Email()) + instance.SetOrganizationSettings(iamOwnerCtx, t, orgResp1.GetOrganizationId(), false) + orgResp2 := instance.CreateOrganization(iamOwnerCtx, integration.OrganizationName(), integration.Email()) + settingsResp2 := instance.SetOrganizationSettings(iamOwnerCtx, t, orgResp2.GetOrganizationId(), true) + orgResp3 := instance.CreateOrganization(iamOwnerCtx, integration.OrganizationName(), integration.Email()) + instance.SetOrganizationSettings(iamOwnerCtx, t, orgResp3.GetOrganizationId(), false) + + request.Filters[0].Filter = &settings.OrganizationSettingsSearchFilter_InOrganizationIdsFilter{ + InOrganizationIdsFilter: &filter.InIDsFilter{ + Ids: []string{orgResp1.GetOrganizationId(), orgResp2.GetOrganizationId(), orgResp3.GetOrganizationId()}, + }, + } + request.Filters[1].Filter = &settings.OrganizationSettingsSearchFilter_OrganizationScopedUsernamesFilter{ + OrganizationScopedUsernamesFilter: &settings.OrganizationScopedUsernamesFilter{ + OrganizationScopedUsernames: true, + }, + } + response.OrganizationSettings[0] = &settings.OrganizationSettings{ + OrganizationId: orgResp2.GetOrganizationId(), + CreationDate: settingsResp2.GetSetDate(), + ChangeDate: settingsResp2.GetSetDate(), + OrganizationScopedUsernames: true, + } + }, + req: &settings.ListOrganizationSettingsRequest{ + Filters: []*settings.OrganizationSettingsSearchFilter{{}, {}}, + }, + }, + want: &settings.ListOrganizationSettingsResponse{ + Pagination: &filter.PaginationResponse{ + TotalResult: 1, + AppliedLimit: 100, + }, + OrganizationSettings: []*settings.OrganizationSettings{{}}, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if tt.args.dep != nil { + tt.args.dep(tt.args.req, tt.want) + } + + retryDuration, tick := integration.WaitForAndTickWithMaxDuration(iamOwnerCtx, time.Minute) + require.EventuallyWithT(t, func(ttt *assert.CollectT) { + got, listErr := instance.Client.SettingsV2.ListOrganizationSettings(tt.args.ctx, tt.args.req) + if tt.wantErr { + require.Error(ttt, listErr) + return + } + require.NoError(ttt, listErr) + + // always first check length, otherwise its failed anyway + if assert.Len(ttt, got.OrganizationSettings, len(tt.want.OrganizationSettings)) { + for i := range tt.want.OrganizationSettings { + assert.EqualExportedValues(ttt, tt.want.OrganizationSettings[i], got.OrganizationSettings[i]) + } + } + assertPaginationResponse(ttt, tt.want.Pagination, got.Pagination) + }, retryDuration, tick, "timeout waiting for expected execution result") + }) + } +} + +func assertPaginationResponse(t *assert.CollectT, expected *filter.PaginationResponse, actual *filter.PaginationResponse) { + assert.Equal(t, expected.AppliedLimit, actual.AppliedLimit) + assert.Equal(t, expected.TotalResult, actual.TotalResult) +} diff --git a/internal/api/grpc/settings/v2/integration_test/settings_test.go b/internal/api/grpc/settings/v2/integration_test/settings_test.go index 44e5a50852c..4f7336053ec 100644 --- a/internal/api/grpc/settings/v2/integration_test/settings_test.go +++ b/internal/api/grpc/settings/v2/integration_test/settings_test.go @@ -8,7 +8,9 @@ import ( "encoding/hex" "fmt" "testing" + "time" + "github.com/muhlemmer/gu" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "google.golang.org/grpc/codes" @@ -189,3 +191,279 @@ func TestSetHostedLoginTranslation(t *testing.T) { }) } } + +func TestServer_SetOrganizationSettings(t *testing.T) { + instance := integration.NewInstance(CTX) + iamOwnerCTX := instance.WithAuthorizationToken(CTX, integration.UserTypeIAMOwner) + + type args struct { + ctx context.Context + req *settings.SetOrganizationSettingsRequest + } + type want struct { + set bool + setDate bool + } + tests := []struct { + name string + prepare func(req *settings.SetOrganizationSettingsRequest) + args args + want want + wantErr bool + }{ + { + name: "permission error", + args: args{ + ctx: instance.WithAuthorizationToken(CTX, integration.UserTypeOrgOwner), + req: &settings.SetOrganizationSettingsRequest{ + OrganizationId: Instance.DefaultOrg.GetId(), + OrganizationScopedUsernames: gu.Ptr(true), + }, + }, + wantErr: true, + }, + { + name: "org not provided", + args: args{ + ctx: iamOwnerCTX, + req: &settings.SetOrganizationSettingsRequest{ + OrganizationId: "", + OrganizationScopedUsernames: gu.Ptr(true), + }, + }, + wantErr: true, + }, + { + name: "org not existing", + args: args{ + ctx: iamOwnerCTX, + req: &settings.SetOrganizationSettingsRequest{ + OrganizationId: "notexisting", + OrganizationScopedUsernames: gu.Ptr(true), + }, + }, + wantErr: true, + }, + { + name: "success no changes", + prepare: func(req *settings.SetOrganizationSettingsRequest) { + orgResp := instance.CreateOrganization(iamOwnerCTX, integration.OrganizationName(), integration.Email()) + req.OrganizationId = orgResp.GetOrganizationId() + }, + args: args{ + ctx: iamOwnerCTX, + req: &settings.SetOrganizationSettingsRequest{}, + }, + want: want{ + set: false, + setDate: true, + }, + }, + { + name: "success user uniqueness", + prepare: func(req *settings.SetOrganizationSettingsRequest) { + orgResp := instance.CreateOrganization(iamOwnerCTX, integration.OrganizationName(), integration.Email()) + req.OrganizationId = orgResp.GetOrganizationId() + }, + args: args{ + ctx: iamOwnerCTX, + req: &settings.SetOrganizationSettingsRequest{ + OrganizationScopedUsernames: gu.Ptr(true), + }, + }, + want: want{ + set: true, + setDate: true, + }, + }, + { + name: "success no change", + prepare: func(req *settings.SetOrganizationSettingsRequest) { + orgResp := instance.CreateOrganization(iamOwnerCTX, integration.OrganizationName(), integration.Email()) + req.OrganizationId = orgResp.GetOrganizationId() + }, + args: args{ + ctx: iamOwnerCTX, + req: &settings.SetOrganizationSettingsRequest{ + OrganizationScopedUsernames: gu.Ptr(false), + }, + }, + want: want{ + set: false, + setDate: true, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + creationDate := time.Now().UTC() + if tt.prepare != nil { + tt.prepare(tt.args.req) + } + + got, err := instance.Client.SettingsV2.SetOrganizationSettings(tt.args.ctx, tt.args.req) + if tt.wantErr { + assert.Error(t, err) + return + } + setDate := time.Time{} + if tt.want.set { + setDate = time.Now().UTC() + } + assert.NoError(t, err) + assertOrganizationSettingsResponse(t, creationDate, setDate, tt.want.setDate, got) + }) + } +} + +func assertOrganizationSettingsResponse(t *testing.T, creationDate, setDate time.Time, expectedSetDate bool, actualResp *settings.SetOrganizationSettingsResponse) { + if expectedSetDate { + if !setDate.IsZero() { + assert.WithinRange(t, actualResp.GetSetDate().AsTime(), creationDate, setDate) + } else { + assert.WithinRange(t, actualResp.GetSetDate().AsTime(), creationDate, time.Now().UTC()) + } + } else { + assert.Nil(t, actualResp.SetDate) + } +} + +func TestServer_DeleteOrganizationSettings(t *testing.T) { + instance := integration.NewInstance(CTX) + iamOwnerCTX := instance.WithAuthorizationToken(CTX, integration.UserTypeIAMOwner) + + type args struct { + ctx context.Context + req *settings.DeleteOrganizationSettingsRequest + } + type want struct { + deletion bool + deletionDate bool + } + tests := []struct { + name string + prepare func(t *testing.T, req *settings.DeleteOrganizationSettingsRequest) + args args + want want + wantErr bool + }{ + { + name: "permission error", + prepare: func(t *testing.T, req *settings.DeleteOrganizationSettingsRequest) { + orgResp := instance.CreateOrganization(iamOwnerCTX, integration.OrganizationName(), integration.Email()) + req.OrganizationId = orgResp.GetOrganizationId() + instance.SetOrganizationSettings(iamOwnerCTX, t, orgResp.GetOrganizationId(), true) + }, + args: args{ + ctx: instance.WithAuthorizationToken(CTX, integration.UserTypeOrgOwner), + req: &settings.DeleteOrganizationSettingsRequest{ + OrganizationId: Instance.DefaultOrg.GetId(), + }, + }, + wantErr: true, + }, + { + name: "org not provided", + args: args{ + ctx: iamOwnerCTX, + req: &settings.DeleteOrganizationSettingsRequest{ + OrganizationId: "", + }, + }, + wantErr: true, + }, + { + name: "org not existing", + args: args{ + ctx: iamOwnerCTX, + req: &settings.DeleteOrganizationSettingsRequest{ + OrganizationId: "notexisting", + }, + }, + want: want{ + deletion: false, + deletionDate: false, + }, + }, + { + name: "success user uniqueness", + prepare: func(t *testing.T, req *settings.DeleteOrganizationSettingsRequest) { + orgResp := instance.CreateOrganization(iamOwnerCTX, integration.OrganizationName(), integration.Email()) + req.OrganizationId = orgResp.GetOrganizationId() + instance.SetOrganizationSettings(iamOwnerCTX, t, orgResp.GetOrganizationId(), true) + }, + args: args{ + ctx: iamOwnerCTX, + req: &settings.DeleteOrganizationSettingsRequest{}, + }, + want: want{ + deletion: true, + deletionDate: true, + }, + }, + { + name: "success no existing", + prepare: func(t *testing.T, req *settings.DeleteOrganizationSettingsRequest) { + orgResp := instance.CreateOrganization(iamOwnerCTX, integration.OrganizationName(), integration.Email()) + req.OrganizationId = orgResp.GetOrganizationId() + }, + args: args{ + ctx: iamOwnerCTX, + req: &settings.DeleteOrganizationSettingsRequest{}, + }, + want: want{ + deletion: false, + deletionDate: true, + }, + }, + { + name: "success already deleted", + prepare: func(t *testing.T, req *settings.DeleteOrganizationSettingsRequest) { + orgResp := instance.CreateOrganization(iamOwnerCTX, integration.OrganizationName(), integration.Email()) + req.OrganizationId = orgResp.GetOrganizationId() + instance.SetOrganizationSettings(iamOwnerCTX, t, orgResp.GetOrganizationId(), true) + instance.DeleteOrganizationSettings(iamOwnerCTX, t, orgResp.GetOrganizationId()) + }, + args: args{ + ctx: iamOwnerCTX, + req: &settings.DeleteOrganizationSettingsRequest{}, + }, + want: want{ + deletion: false, + deletionDate: true, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + creationDate := time.Now().UTC() + if tt.prepare != nil { + tt.prepare(t, tt.args.req) + } + + got, err := instance.Client.SettingsV2.DeleteOrganizationSettings(tt.args.ctx, tt.args.req) + if tt.wantErr { + assert.Error(t, err) + return + } + deletionDate := time.Time{} + if tt.want.deletion { + deletionDate = time.Now().UTC() + } + assert.NoError(t, err) + assertDeleteOrganizationSettingsResponse(t, creationDate, deletionDate, tt.want.deletionDate, got) + }) + } +} + +func assertDeleteOrganizationSettingsResponse(t *testing.T, creationDate, deletionDate time.Time, expectedDeletionDate bool, actualResp *settings.DeleteOrganizationSettingsResponse) { + if expectedDeletionDate { + if !deletionDate.IsZero() { + assert.WithinRange(t, actualResp.GetDeletionDate().AsTime(), creationDate, deletionDate) + } else { + assert.WithinRange(t, actualResp.GetDeletionDate().AsTime(), creationDate, time.Now().UTC()) + } + } else { + assert.Nil(t, actualResp.DeletionDate) + } +} diff --git a/internal/api/grpc/settings/v2/query.go b/internal/api/grpc/settings/v2/query.go index d522424040d..26f8b1e2629 100644 --- a/internal/api/grpc/settings/v2/query.go +++ b/internal/api/grpc/settings/v2/query.go @@ -7,10 +7,13 @@ import ( "google.golang.org/protobuf/types/known/timestamppb" "github.com/zitadel/zitadel/internal/api/authz" + "github.com/zitadel/zitadel/internal/api/grpc/filter/v2" "github.com/zitadel/zitadel/internal/api/grpc/object/v2" "github.com/zitadel/zitadel/internal/domain" "github.com/zitadel/zitadel/internal/i18n" "github.com/zitadel/zitadel/internal/query" + "github.com/zitadel/zitadel/internal/zerrors" + filter_pb "github.com/zitadel/zitadel/pkg/grpc/filter/v2" object_pb "github.com/zitadel/zitadel/pkg/grpc/object/v2" "github.com/zitadel/zitadel/pkg/grpc/settings/v2" ) @@ -208,3 +211,103 @@ func (s *Server) GetHostedLoginTranslation(ctx context.Context, req *connect.Req return connect.NewResponse(translation), nil } + +func (s *Server) ListOrganizationSettings(ctx context.Context, req *connect.Request[settings.ListOrganizationSettingsRequest]) (*connect.Response[settings.ListOrganizationSettingsResponse], error) { + queries, err := s.listOrganizationSettingsRequestToModel(req.Msg) + if err != nil { + return nil, err + } + resp, err := s.query.SearchOrganizationSettings(ctx, queries, s.checkPermission) + if err != nil { + return nil, err + } + return connect.NewResponse(&settings.ListOrganizationSettingsResponse{ + OrganizationSettings: organizationSettingsListToPb(resp.OrganizationSettingsList), + Pagination: filter.QueryToPaginationPb(queries.SearchRequest, resp.SearchResponse), + }), nil +} + +func (s *Server) listOrganizationSettingsRequestToModel(req *settings.ListOrganizationSettingsRequest) (*query.OrganizationSettingsSearchQueries, error) { + offset, limit, asc, err := filter.PaginationPbToQuery(s.systemDefaults, req.Pagination) + if err != nil { + return nil, err + } + queries, err := organizationSettingsFiltersToQuery(req.Filters) + if err != nil { + return nil, err + } + return &query.OrganizationSettingsSearchQueries{ + SearchRequest: query.SearchRequest{ + Offset: offset, + Limit: limit, + Asc: asc, + SortingColumn: organizationSettingsFieldNameToSortingColumn(req.SortingColumn), + }, + Queries: queries, + }, nil +} + +func organizationSettingsFieldNameToSortingColumn(field *settings.OrganizationSettingsFieldName) query.Column { + if field == nil { + return query.OrganizationSettingsColumnCreationDate + } + switch *field { + case settings.OrganizationSettingsFieldName_ORGANIZATION_SETTINGS_FIELD_NAME_CREATION_DATE: + return query.OrganizationSettingsColumnCreationDate + case settings.OrganizationSettingsFieldName_ORGANIZATION_SETTINGS_FIELD_NAME_ORGANIZATION_ID: + return query.OrganizationSettingsColumnID + case settings.OrganizationSettingsFieldName_ORGANIZATION_SETTINGS_FIELD_NAME_CHANGE_DATE: + return query.OrganizationSettingsColumnChangeDate + case settings.OrganizationSettingsFieldName_ORGANIZATION_SETTINGS_FIELD_NAME_UNSPECIFIED: + return query.OrganizationSettingsColumnCreationDate + default: + return query.OrganizationSettingsColumnCreationDate + } +} + +func organizationSettingsFiltersToQuery(queries []*settings.OrganizationSettingsSearchFilter) (_ []query.SearchQuery, err error) { + q := make([]query.SearchQuery, len(queries)) + for i, qry := range queries { + q[i], err = organizationSettingsToModel(qry) + if err != nil { + return nil, err + } + } + return q, nil +} + +func organizationSettingsToModel(filter *settings.OrganizationSettingsSearchFilter) (query.SearchQuery, error) { + switch q := filter.Filter.(type) { + case *settings.OrganizationSettingsSearchFilter_InOrganizationIdsFilter: + return organizationInIDsFilterToQuery(q.InOrganizationIdsFilter) + case *settings.OrganizationSettingsSearchFilter_OrganizationScopedUsernamesFilter: + return organizationScopedUsernamesFilterToQuery(q.OrganizationScopedUsernamesFilter) + default: + return nil, zerrors.ThrowInvalidArgument(nil, "SETTINGS-uvTDqZHlvS", "List.Query.Invalid") + } +} + +func organizationInIDsFilterToQuery(q *filter_pb.InIDsFilter) (query.SearchQuery, error) { + return query.NewOrganizationSettingsOrganizationIDSearchQuery(q.Ids) +} + +func organizationScopedUsernamesFilterToQuery(q *settings.OrganizationScopedUsernamesFilter) (query.SearchQuery, error) { + return query.NewOrganizationSettingsOrganizationScopedUsernamesSearchQuery(q.OrganizationScopedUsernames) +} + +func organizationSettingsListToPb(settingsList []*query.OrganizationSettings) []*settings.OrganizationSettings { + o := make([]*settings.OrganizationSettings, len(settingsList)) + for i, organizationSettings := range settingsList { + o[i] = organizationSettingsToPb(organizationSettings) + } + return o +} + +func organizationSettingsToPb(organizationSettings *query.OrganizationSettings) *settings.OrganizationSettings { + return &settings.OrganizationSettings{ + OrganizationId: organizationSettings.ID, + CreationDate: timestamppb.New(organizationSettings.CreationDate), + ChangeDate: timestamppb.New(organizationSettings.ChangeDate), + OrganizationScopedUsernames: organizationSettings.OrganizationScopedUsernames, + } +} diff --git a/internal/api/grpc/settings/v2/server.go b/internal/api/grpc/settings/v2/server.go index 8fba2345773..c34e8491717 100644 --- a/internal/api/grpc/settings/v2/server.go +++ b/internal/api/grpc/settings/v2/server.go @@ -11,6 +11,8 @@ import ( "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" "github.com/zitadel/zitadel/pkg/grpc/settings/v2" "github.com/zitadel/zitadel/pkg/grpc/settings/v2/settingsconnect" @@ -19,19 +21,25 @@ import ( var _ settingsconnect.SettingsServiceHandler = (*Server)(nil) type Server struct { - command *command.Commands - query *query.Queries + systemDefaults systemdefaults.SystemDefaults + command *command.Commands + query *query.Queries + checkPermission domain.PermissionCheck assetsAPIDomain func(context.Context) string } func CreateServer( + systemDefaults systemdefaults.SystemDefaults, command *command.Commands, query *query.Queries, + checkPermission domain.PermissionCheck, ) *Server { return &Server{ + systemDefaults: systemDefaults, command: command, query: query, + checkPermission: checkPermission, assetsAPIDomain: assets.AssetAPI(), } } diff --git a/internal/api/grpc/settings/v2/settings.go b/internal/api/grpc/settings/v2/settings.go index c7db2002115..954f93d363a 100644 --- a/internal/api/grpc/settings/v2/settings.go +++ b/internal/api/grpc/settings/v2/settings.go @@ -4,6 +4,7 @@ import ( "context" "connectrpc.com/connect" + "google.golang.org/protobuf/types/known/timestamppb" "github.com/zitadel/zitadel/internal/api/grpc/object/v2" "github.com/zitadel/zitadel/pkg/grpc/settings/v2" @@ -27,3 +28,31 @@ func (s *Server) SetHostedLoginTranslation(ctx context.Context, req *connect.Req return connect.NewResponse(res), nil } + +func (s *Server) SetOrganizationSettings(ctx context.Context, req *connect.Request[settings.SetOrganizationSettingsRequest]) (*connect.Response[settings.SetOrganizationSettingsResponse], error) { + details, err := s.command.SetOrganizationSettings(ctx, organizationSettingsToCommand(req.Msg)) + if err != nil { + return nil, err + } + var setDate *timestamppb.Timestamp + if !details.EventDate.IsZero() { + setDate = timestamppb.New(details.EventDate) + } + return connect.NewResponse(&settings.SetOrganizationSettingsResponse{ + SetDate: setDate, + }), nil +} + +func (s *Server) DeleteOrganizationSettings(ctx context.Context, req *connect.Request[settings.DeleteOrganizationSettingsRequest]) (*connect.Response[settings.DeleteOrganizationSettingsResponse], error) { + details, err := s.command.DeleteOrganizationSettings(ctx, req.Msg.GetOrganizationId()) + if err != nil { + return nil, err + } + var deletionDate *timestamppb.Timestamp + if !details.EventDate.IsZero() { + deletionDate = timestamppb.New(details.EventDate) + } + return connect.NewResponse(&settings.DeleteOrganizationSettingsResponse{ + DeletionDate: deletionDate, + }), nil +} diff --git a/internal/api/grpc/settings/v2/settings_converter.go b/internal/api/grpc/settings/v2/settings_converter.go index 1dec0186578..3d52bd276d2 100644 --- a/internal/api/grpc/settings/v2/settings_converter.go +++ b/internal/api/grpc/settings/v2/settings_converter.go @@ -252,3 +252,10 @@ func securitySettingsToCommand(req *settings.SetSecuritySettingsRequest) *comman EnableImpersonation: req.GetEnableImpersonation(), } } + +func organizationSettingsToCommand(req *settings.SetOrganizationSettingsRequest) *command.SetOrganizationSettings { + return &command.SetOrganizationSettings{ + OrganizationID: req.OrganizationId, + OrganizationScopedUsernames: req.OrganizationScopedUsernames, + } +} diff --git a/proto/zitadel/settings/v2/organization_settings.proto b/proto/zitadel/settings/v2/organization_settings.proto new file mode 100644 index 00000000000..3fb5e53659f --- /dev/null +++ b/proto/zitadel/settings/v2/organization_settings.proto @@ -0,0 +1,58 @@ +syntax = "proto3"; + +package zitadel.settings.v2; + +option go_package = "github.com/zitadel/zitadel/pkg/grpc/settings/v2;settings"; + +import "google/api/field_behavior.proto"; +import "protoc-gen-openapiv2/options/annotations.proto"; +import "validate/validate.proto"; +import "google/protobuf/timestamp.proto"; +import "zitadel/filter/v2/filter.proto"; + +message OrganizationSettings { + // The unique identifier of the organization the settings belong to. + string organization_id = 1 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "\"69629012906488334\""; + } + ]; + + // The timestamp of the organization settings 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 last change to the organization settings. + google.protobuf.Timestamp change_date = 3 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "\"2025-01-23T10:34:18.051Z\""; + } + ]; + + // Defines if the usernames have to be unique in the organization context. + bool organization_scoped_usernames = 4; +} + +enum OrganizationSettingsFieldName { + ORGANIZATION_SETTINGS_FIELD_NAME_UNSPECIFIED = 0; + ORGANIZATION_SETTINGS_FIELD_NAME_ORGANIZATION_ID = 1; + ORGANIZATION_SETTINGS_FIELD_NAME_CREATION_DATE = 2; + ORGANIZATION_SETTINGS_FIELD_NAME_CHANGE_DATE = 3; +} + +message OrganizationSettingsSearchFilter { + oneof filter { + option (validate.required) = true; + + zitadel.filter.v2.InIDsFilter in_organization_ids_filter = 1; + OrganizationScopedUsernamesFilter organization_scoped_usernames_filter = 2; + } +} + +// Query for organization settings with specific scopes usernames. +message OrganizationScopedUsernamesFilter { + bool organization_scoped_usernames = 1; +} \ No newline at end of file diff --git a/proto/zitadel/settings/v2/settings_service.proto b/proto/zitadel/settings/v2/settings_service.proto index 2182a289e6a..857633df761 100644 --- a/proto/zitadel/settings/v2/settings_service.proto +++ b/proto/zitadel/settings/v2/settings_service.proto @@ -19,6 +19,7 @@ import "google/protobuf/struct.proto"; import "zitadel/settings/v2/settings.proto"; import "google/protobuf/timestamp.proto"; import "zitadel/filter/v2/filter.proto"; +import "zitadel/settings/v2/organization_settings.proto"; option go_package = "github.com/zitadel/zitadel/pkg/grpc/settings/v2;settings"; @@ -418,6 +419,99 @@ service SettingsService { }; } + // Set Organization Settings + // + // Sets the settings specific to an organization. + // Organization scopes usernames defines that the usernames have to be unique in the organization scope, + // can only be changed if the usernames of the users are unique in the scope. + // + // Required permissions: + // - `iam.policy.write` + rpc SetOrganizationSettings(SetOrganizationSettingsRequest) returns (SetOrganizationSettingsResponse) { + option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { + responses: { + key: "200"; + value: { + description: "The translations was successfully set."; + } + }; + }; + + option (google.api.http) = { + post: "/v2/settings/organization"; + body: "*" + }; + + option (zitadel.protoc_gen_zitadel.v2.options) = { + auth_option: { + permission: "authenticated" + } + }; + } + + // Delete Organization Settings + // + // Delete the settings specific to an organization. + // + // Required permissions: + // - `iam.policy.delete` + rpc DeleteOrganizationSettings(DeleteOrganizationSettingsRequest) returns (DeleteOrganizationSettingsResponse) { + option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { + responses: { + key: "200"; + value: { + description: "The translations was successfully set."; + } + }; + }; + + option (google.api.http) = { + delete: "/v2/settings/organization"; + body: "*" + }; + + option (zitadel.protoc_gen_zitadel.v2.options) = { + auth_option: { + permission: "authenticated" + } + }; + } + + // List Organization Settings + // + // Returns a list of organization settings. + // + // Required permission: + // - `iam.policy.read` + // - `org.policy.read` + rpc ListOrganizationSettings(ListOrganizationSettingsRequest) returns (ListOrganizationSettingsResponse) { + option (google.api.http) = { + post: "/v2/settings/organization/search" + body: "*" + }; + + option (zitadel.protoc_gen_zitadel.v2.options) = { + auth_option: { + permission: "authenticated" + } + }; + + option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { + responses: { + key: "200"; + value: { + description: "A list of all project grants matching the query"; + }; + }; + responses: { + key: "400"; + value: { + description: "invalid list query"; + }; + }; + }; + } + // Get Hosted Login Translation // // Returns the translations in the requested locale for the hosted login. @@ -640,6 +734,64 @@ message SetSecuritySettingsResponse{ zitadel.object.v2.Details details = 1; } + +message SetOrganizationSettingsRequest { + // Organization ID in which this settings are set. + string organization_id = 1; + + // Force the usernames in the organization to be unique, + // only possible to set if the existing users already have unique usernames in the organization context. + optional bool organization_scoped_usernames = 2; +} + +message SetOrganizationSettingsResponse { + // The timestamp of the set of the organization settings. + google.protobuf.Timestamp set_date = 1 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "\"2025-01-23T10:34:18.051Z\""; + } + ]; +} + +message DeleteOrganizationSettingsRequest { + // Organization ID in which this settings are set. + string organization_id = 1; +} + +message DeleteOrganizationSettingsResponse { + // The timestamp of the deletion of the organization settings. + google.protobuf.Timestamp deletion_date = 1 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "\"2025-01-23T10:34:18.051Z\""; + } + ]; +} + +message ListOrganizationSettingsRequest{ + // List limitations and ordering. + optional zitadel.filter.v2.PaginationRequest pagination = 1; + + // The field the result is sorted by. The default is the creation date. + // Beware that if you change this, your result pagination might be inconsistent. + optional OrganizationSettingsFieldName sorting_column = 2 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + default: "\"ORGANIZATION_SETTINGS_FIELD_NAME_CREATION_DATE\"" + } + ]; + + // Define the criteria to query for. + repeated OrganizationSettingsSearchFilter filters = 4; + + option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_schema) = { + example: "{\"pagination\":{\"offset\":0,\"limit\":0,\"asc\":true},\"sortingColumn\":\"ORGANIZATION_SETTINGS_FIELD_NAME_CREATION_DATE\",\"filters\":[{\"inOrganizationIdsFilter\":{\"ids\":[\"28746028909593987\"]}}]}"; + }; +} + +message ListOrganizationSettingsResponse { + zitadel.filter.v2.PaginationResponse pagination = 1; + repeated OrganizationSettings organization_settings = 2; +} + message GetHostedLoginTranslationRequest { // Specify the level from which the translation should be returned. // If the requested level doesn't contain all translations, and ignore_inheritance is set to false