diff --git a/cmd/start/start.go b/cmd/start/start.go index db4fafe6b..f6e9ee003 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 cc7d2cebe..f0bca4a4f 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 44e5a5085..4f7336053 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 d52242404..26f8b1e26 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 8fba23457..c34e84917 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 c7db20021..954f93d36 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 1dec01865..3d52bd276 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 000000000..3fb5e5365 --- /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 2182a289e..857633df7 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